Opensearch demo 인증서에서 내부 서버용 CA 생성 후 등록

 

1. root CA 생성

root CA로 esnode.pem, admin.pem을 서명

cd /etc/opensearch

sudo openssl genrsa -out root-ca-key.pem 2048

sudo openssl req -new -x509 -sha256 \
  -key root-ca-key.pem \
  -subj "/C=KR/ST=Seoul/L=Seoul/O=MyCompany/OU=OpenSearch/CN=MyRootCA" \
  -out root-ca.pem \
  -days 3650

 

2. 노드 인증서 생성

esnode.pem, esnode-key.pem이 Opensearch 노드가 실제로 쓰는 인증서와 키, 문서 기준으로 노드 키는 PKCS#8이어야 하고, 호스트 인증서는 SAN(subjectAltName)을 넣는게 권장

cd /etc/opensearch

# 1) 임시 개인키
sudo openssl genrsa -out esnode-key-temp.pem 2048

# 2) PKCS#8 변환
sudo openssl pkcs8 -inform PEM -outform PEM \
  -in esnode-key-temp.pem \
  -topk8 -nocrypt -v1 PBE-SHA1-3DES \
  -out esnode-key.pem

# 3) CSR 생성
sudo openssl req -new \
  -key esnode-key.pem \
  -subj "/C=KR/ST=Seoul/L=Seoul/O=MyCompany/OU=OpenSearch/CN=os1.example.com" \
  -out esnode.csr

# 4) SAN 파일
cat <<'EOF' | sudo tee /etc/opensearch/esnode.ext >/dev/null
subjectAltName=DNS:localhost,DNS:os1.example.com,IP:127.0.0.1
extendedKeyUsage=serverAuth,clientAuth
EOF

# 5) root CA로 서명
sudo openssl x509 -req -in esnode.csr \
  -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \
  -sha256 -out esnode.pem -days 730 \
  -extfile /etc/opensearch/esnode.ext
  
# 생성 후 확인
openssl x509 -in /etc/opensearch/esnode.pem -noout -subject -ext subjectAltName

 

3. admin 인증서 생성

admin.pem, admin-key.pem은 관리자 인증서. 이건 노드 인증서와 역할이 다르고, securityadmin.sh이나 Security REST API 작업용. admin cert는 특정 호스트에 묶이지 않으므로 SAN이 필요없다고 문서에 명시

cd /etc/opensearch

# 1) 임시 키
sudo openssl genrsa -out admin-key-temp.pem 2048

# 2) PKCS#8 변환
sudo openssl pkcs8 -inform PEM -outform PEM \
  -in admin-key-temp.pem \
  -topk8 -nocrypt -v1 PBE-SHA1-3DES \
  -out admin-key.pem

# 3) CSR 생성
sudo openssl req -new \
  -key admin-key.pem \
  -subj "/C=KR/ST=Seoul/L=Seoul/O=MyCompany/OU=OpenSearch/CN=admin" \
  -out admin.csr

# 4) root CA로 서명
sudo openssl x509 -req -in admin.csr \
  -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \
  -sha256 -out admin.pem -days 730
  
# 생성 후 확인
openssl x509 -in /etc/opensearch/admin.pem -noout -subject

 

4. 파일 권한 추가

sudo chown opensearch:opensearch /etc/opensearch/esnode.pem /etc/opensearch/esnode-key.pem
sudo chown opensearch:opensearch /etc/opensearch/admin.pem /etc/opensearch/admin-key.pem
sudo chown opensearch:opensearch /etc/opensearch/root-ca.pem

sudo chmod 644 /etc/opensearch/esnode.pem /etc/opensearch/admin.pem /etc/opensearch/root-ca.pem
sudo chmod 600 /etc/opensearch/esnode-key.pem /etc/opensearch/admin-key.pem /etc/opensearch/root-ca-key.pem

 

5. opensearch.yml 설정

 

  • nodes_dn 값은 esnode.pem Subject DN과 정확히 맞아야 함
  • admin_dn 값은 admin.pem Subject DN과 정확히 맞아야 함

 

cluster.name: spring-log-cluster
node.name: node-1
network.host: 0.0.0.0
discovery.type: single-node

path.data: /var/lib/opensearch
path.logs: /var/log/opensearch

plugins.security.ssl.transport.pemcert_filepath: esnode.pem
plugins.security.ssl.transport.pemkey_filepath: esnode-key.pem
plugins.security.ssl.transport.pemtrustedcas_filepath: root-ca.pem

plugins.security.ssl.http.enabled: true
plugins.security.ssl.http.pemcert_filepath: esnode.pem
plugins.security.ssl.http.pemkey_filepath: esnode-key.pem
plugins.security.ssl.http.pemtrustedcas_filepath: root-ca.pem

plugins.security.nodes_dn:
  - "CN=os1.example.com,OU=OpenSearch,O=MyCompany,L=Seoul,ST=Seoul,C=KR"

plugins.security.authcz.admin_dn:
  - "CN=admin,OU=OpenSearch,O=MyCompany,L=Seoul,ST=Seoul,C=KR"

 

6. securityadmin.sh 실행

opensearch-security.yml 변경 시 실행

cd /usr/share/opensearch/plugins/opensearch-security/tools

sudo ./securityadmin.sh \
  -cd /etc/opensearch/opensearch-security \
  -cacert /etc/opensearch/root-ca.pem \
  -cert /etc/opensearch/admin.pem \
  -key /etc/opensearch/admin-key.pem \
  -icl -nhnv

 

7. opensearch-dashboards.yml 수정

sudo cp /etc/opensearch/root-ca.pem /etc/opensearch-dashboards/root-ca.pem
sudo chown root:root /etc/opensearch-dashboards/root-ca.pem
sudo chmod 644 /etc/opensearch-dashboards/root-ca.pem

# /etc/opensearch-dashboards/opensearch_dashboards.yml
opensearch.hosts: ["https://os1.example.com:9200"]
opensearch.username: "kibanaserver"
opensearch.password: "kibanaserver"
opensearch.ssl.verificationMode: certificate
opensearch.ssl.certificateAuthorities: ["/etc/opensearch-dashboards/root-ca.pem"]

 

이후 각각 재기동

'Others' 카테고리의 다른 글

logback + fluent-bit + opensearch 개발 서버용  (1) 2026.04.15
keycloak으로 구글 SSO 처리  (0) 2026.03.23
Redis, Prometheus, Grafana 모니터링  (0) 2025.10.31
mybatis sql 로그 pretty하게 남기기  (4) 2025.07.31
Redis + Sentine  (0) 2025.03.28

로그를 편하게 한곳에서 볼 수 있도록 로그 수집기를 통해 opensearch로 넘김

Spring boot 4.0.2

Opensearch 3.6.1

Fluent-bit 2.2.2

 

1. pom.xml

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>8.1</version>
</dependency>

 

2. logback.xml

<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/${WORKER_NAME}/${APP_NAME}.json</file>

        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/${WORKER_NAME}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.json.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
            <totalSizeCap>5GB</totalSizeCap>
        </rollingPolicy>

        <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
            <providers>
                <timestamp>
                    <fieldName>@timestamp</fieldName>
                    <pattern>yyyy-MM-dd'T'HH:mm:ss.SSSXXX</pattern>
                    <timeZone>Asia/Seoul</timeZone>
                </timestamp>
                <threadName>
                    <fieldName>thread</fieldName>
                </threadName>
                <logLevel>
                    <fieldName>level</fieldName>
                </logLevel>
                <loggerName>
                    <fieldName>logger</fieldName>
                </loggerName>
                <message>
                    <fieldName>message</fieldName>
                </message>
                <mdc>
                    <includeMdcKeyName>requestId</includeMdcKeyName>
                    <includeMdcKeyName>userId</includeMdcKeyName>
                    <includeMdcKeyName>requestUri</includeMdcKeyName>
                    <includeMdcKeyName>requestIp</includeMdcKeyName>
                </mdc>
                <stackTrace>
                    <fieldName>stack_trace</fieldName>
                </stackTrace>
                <globalCustomFields>
                    <customFields>{"service":"${APP_NAME}","worker_name":"${WORKER_NAME}"}</customFields>
                </globalCustomFields>
            </providers>
        </encoder>
    </appender>

 

일반 file appender가 먼저 출력되는 경우 json appender로 로그가 출력되지 않을 수 있음. 어차피 opensearch로 확인하기 떄문에 file appender는 삭제했음

MDC에 저장한 값도 json 로그로 출력할 수 있음. 나중에 opensearch-dashborads에서 대시보드 만들어서 활용가능

 

3. opensearch 설치

 

2.12+에서는 설치 전에 OPENSEARCH_INITIAL_ADMIN_PASSWORD를 넘겨야 demo security가 정상 설치된다

sudo curl -SL https://artifacts.opensearch.org/releases/bundle/opensearch/3.x/opensearch-3.x.repo \
  -o /etc/yum.repos.d/opensearch-3.x.repo

sudo yum clean all
sudo yum repolist
sudo yum list opensearch --showduplicates

# 신규 설치(2.12+)는 관리자 비밀번호 필수
sudo env OPENSEARCH_INITIAL_ADMIN_PASSWORD='강한비밀번호' yum install -y opensearch

sudo systemctl enable opensearch
sudo systemctl start opensearch
sudo systemctl status opensearch
# /etc/opensearch/opensearch.yml

path.data: /home/opensearch/data
path.logs: /home/opensearch/log
network.host: 0.0.0.0
discovery.type: single-node

# demo security 자동 추가됨
plugins.security.ssl.transport.pemcert_filepath: esnode.pem
plugins.security.ssl.transport.pemkey_filepath: esnode-key.pem
plugins.security.ssl.transport.pemtrustedcas_filepath: root-ca.pem
transport.ssl.enforce_hostname_verification: false
plugins.security.ssl.http.enabled: true
plugins.security.ssl.http.pemcert_filepath: esnode.pem
plugins.security.ssl.http.pemkey_filepath: esnode-key.pem
plugins.security.ssl.http.pemtrustedcas_filepath: root-ca.pem
plugins.security.allow_unsafe_democertificates: true
plugins.security.allow_default_init_securityindex: true
plugins.security.authcz.admin_dn: ['CN=kirk,OU=client,O=client,L=test,C=de']
plugins.security.audit.type: internal_opensearch
plugins.security.enable_snapshot_restore_privilege: true
plugins.security.check_snapshot_restore_write_privileges: true
plugins.security.restapi.roles_enabled: [all_access, security_rest_api_access]
plugins.security.system_indices.enabled: true
node.max_local_storage_nodes: 3

 

4. opensearch dashboard 설치

sudo curl -SL https://artifacts.opensearch.org/releases/bundle/opensearch-dashboards/3.x/opensearch-dashboards-3.x.repo \
  -o /etc/yum.repos.d/opensearch-dashboards-3.x.repo

sudo yum clean all
sudo yum repolist
sudo yum install -y opensearch-dashboards

sudo systemctl enable opensearch-dashboards
sudo systemctl start opensearch-dashboards
sudo systemctl status opensearch-dashboards
# /etc/opensearch-dashboards/opensearch_dashboards.yml
server.host: 0.0.0.0

opensearch.hosts: ["https://localhost:9200"]
opensearch.username: "kibanaserver"
opensearch.password: "kibanaserver"

opensearch.ssl.verificationMode: certificate
opensearch.ssl.certificateAuthorities: ["/etc/opensearch-dashboards/root-ca.pem"]

opensearch.requestHeadersAllowlist: ["authorization", "securitytenant"]

opensearch_security.multitenancy.enabled: true
opensearch_security.readonly_mode.roles: ["kibana_read_only"]

# Dashboards 자체는 일단 HTTP로 열기
server.ssl.enabled: false
opensearch_security.cookie.secure: false

 

opensearch 폴더에 있는 root-ca.pem 파일을 권한 오류(FATAL  Error: EACCES: permission denied, open '/etc/opensearch/root-ca.pem' ) 때문에 그냥 opensearch-dashboards 폴더에 넣었음

 

5. fluent-bit 설치

sudo yum install -y fluent-bit
sudo systemctl enable fluent-bit
[SERVICE]
    flush        1
    daemon       Off
    log_level    info
    parsers_file parsers.conf
    plugins_file plugins.conf
    http_server  Off
    http_listen  0.0.0.0
    http_port    2020
    storage.metrics on

[INPUT]
    Name                tail
    Path                /home/fluent/app-logs/app_name/*.json
    Parser              json
    Tag                 spring.app_name
    DB                  /home/fluent/log/app_name.db
    Refresh_Interval    1
    Read_From_Head      true

[INPUT]
    Name                tail
    Path                /home/fluent/app-logs/app_name/*.json
    Parser              json
    Tag                 spring.app_name
    DB                  /home/fluent/log/app_name.db
    Refresh_Interval    1
    Read_From_Head      true

[OUTPUT]
    Name          opensearch
    Match         spring.app_name*
    Host          ip
    Port          9200
    HTTP_User     admin
    HTTP_Passwd   password
    Index         spring-app_name
    tls           on
    tls.verify    off
    Suppress_Type_Name  On
    Logstash_Format     On
    Logstash_Prefix     spring-app_name
    Logstash_DateFormat %Y.%m.%d

 

fluent-bit[2196372]: {"error":{"root_cause":[{"type":"illegal_argument_exception","reason":"Action/metadata line [1] contains an unknown parameter [_type]"}],"type":"illegal_argument_exception","reason":"Action/metadata line [1] contains an unknown parameter [_type]"},"status":400} 에러 발생

이 에러는 Fluent Bit가 Bulk 메타데이터에 _type를 넣어서 보내고 있는데, OpenSearch 2.0 이상은 mapping type을 더 이상 지원하지 않기 때문. Fluent Bit의 suppress_type_name 기본값이 Off이고, OpenSearch 2.0+에서는 이 값을 On으로 설정해야 한다

 

개발서버도 이중화되어 있어서 하나의 인덱스로 받게금 설정했는데 각각 받고 싶으면 output 추가해야한다.

[OUTPUT]
    Name   opensearch
    Match  spring.app_name1
    Index  spring-app_name1

[OUTPUT]
    Name   opensearch
    Match  spring.app_name2
    Index  spring-app_name2

 

6. opensearch index policy 추가

 

14일 후 자동삭제

PUT _plugins/_ism/policies/spring-app_name-14d-delete
{
  "policy": {
    "description": "Delete spring-app_name indices after 14 days",
    "default_state": "hot",
    "schema_version": 1,
    "states": [
      {
        "name": "hot",
        "actions": [],
        "transitions": [
          {
            "state_name": "delete",
            "conditions": {
              "min_index_age": "14d"
            }
          }
        ]
      },
      {
        "name": "delete",
        "actions": [
          {
            "delete": {}
          }
        ],
        "transitions": []
      }
    ],
    "ism_template": {
      "index_patterns": ["spring-app_name-*"],
      "priority": 100
    }
  }
}

'Others' 카테고리의 다른 글

opensearch root CA 등록  (0) 2026.04.17
keycloak으로 구글 SSO 처리  (0) 2026.03.23
Redis, Prometheus, Grafana 모니터링  (0) 2025.10.31
mybatis sql 로그 pretty하게 남기기  (4) 2025.07.31
Redis + Sentine  (0) 2025.03.28

이번 글에서는 기존 내부 인증 서버를 사용하고 있는 환경에서, Keycloak Authentication SPI 를 이용해 로그인 화면 노출 없이 쿠키 기반 자동 로그인을 구현한 과정을 정리한다.

우리 환경은 단순한 Keycloak 단독 인증 구조가 아니었다.
이미 운영 중인 인증 서버가 있었고, 이를 통해 인증 토큰을 발급받아 쿠키 기반 세션 유지를 하고 있었다. 여기에 Google Workspace SSO 를 붙이기 위해 Keycloak을 추가하면서, 다음과 같은 요구사항이 생겼다.

  • GWS 로그인 시 기존 인증 체계를 그대로 사용해야 한다.
  • Google Workspace SSO의 IdP 역할은 Keycloak이 담당해야 한다.
  • 사용자가 이미 로그인되어 내부 인증 서버 쿠키가 살아 있다면, Keycloak 로그인 화면 없이 자동 로그인되어야 한다.
  • 반대로 내부 인증 서버 쿠키가 없거나 유효하지 않으면, 그때만 Keycloak 로그인 화면이 보여야 한다.

즉, 기존 인증의 출처(Source of Truth) 유지하고, Keycloak은 Google Workspace와 연동하기 위한 SAML IdP 및 세션 관리자 역할을 하도록 구성했다.


이번 요구사항의 핵심은 'User Storage SPI''Authentication SPI' 설계였다. 우리가 해결해야 했던 것은 다음이었다.

  • 사용자가 Keycloak 로그인 페이지를 보기 전에
  • 브라우저에 있는 내부 인증 서버 쿠키를 확인하고
  • 유효하면 로그인 화면 없이 바로 인증 성공 처리

keycloak에 유저를 등록해서 사용하지 않고 기존 내부 디비에 저장된 유저 정보를 사용하기 위해 User Storage SPI, 로그인 시점의 인증 방식 제얼르 위해 Authentication SPI를 모두 이용했다.

  • Authentication SPI
    현재 요청에서 로그인 성공 여부를 결정
  • User Storage SPI
    User 조회 로직(DB/JDBC) - userId/userName를 기준으로 내부 사용자 조회

Keycloak에서 이걸 구현하려면 Browser Flow 에 custom Authenticator를 추가해야 한다.

우리가 의도한 플로우는 다음과 같다.

  • Cookie — ALTERNATIVE
  • Internal Cookie Authenticator — ALTERNATIVE
  • Forms subflow

이 구조의 의미는 다음과 같다.

1) Cookie execution

Keycloak 자체 세션 쿠키가 있으면 여기서 바로 통과한다.
즉, Keycloak 세션이 살아 있으면 custom authenticator는 아예 실행되지 않는다.

2) Internal Cookie Authenticator

Keycloak 세션은 없지만 브라우저에 내부 인증 서버 쿠키가 있으면, 이 execution에서 내부 인증 서버를 통해 검증한다.
검증 성공 시 context.setUser() 와 context.success() 를 호출해 로그인 화면 없이 인증을 완료한다.

3) Forms subflow

내부 인증 서버 쿠키도 없거나 검증 실패하면, 그제야 일반 로그인 폼을 보여준다.

이 구조 덕분에 다음 요구사항을 만족할 수 있었다.

  • Keycloak 세션이 있으면 빠르게 통과
  • Keycloak 세션이 없더라도 내부 인증 서버 쿠키가 있으면 무인 재인증
  • 둘 다 없을 때만 로그인 폼 표시

내부 DB 조회 방식

 

1. AbstractUserAdapterFederatedStorage

public class UserAdapter extends AbstractUserAdapterFederatedStorage {

    private final ComponentModel model;
    private final User user;

    public UserAdapter(
            KeycloakSession session,
            RealmModel realm,
            ComponentModel model,
            User user
    ) {
        super(session, realm, model);
        this.model = model;
        this.user = user;
    }

    @Override
    public String getId() {
        return StorageId.keycloakId(model, user.userId());
    }

    @Override
    public String getUsername() {
        return user.username();
    }

    @Override
    public String getEmail() {
        return user.email();
    }

}

 

2. User

public record User(
        String userId,
        String username,
        String email,
        boolean enabled
) {

}

 

3. UserStorageProviderFactory

public class SsoUserStorageProviderFactory implements UserStorageProviderFactory<SsoUserStorageProvider> {

    public static final String PROVIDER_ID = "sso-user-storage";

    @Override
    public SsoUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        SsoApi ssoApi = new SsoApiImpl(
                model.getConfig().getFirst("signUrl")
        );

        UserRepository userRepository = new UserRepositoryImpl(
                model.getConfig().getFirst("jdbcUrl"),
                model.getConfig().getFirst("jdbcUser"),
                model.getConfig().getFirst("jdbcPassword")
        );

        return new SsoUserStorageProvider(
                session,
                model,
                ssoApi,
                userRepository
        );
    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }

    @Override
    public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException {
        require(config, "signUrl");
        require(config, "jdbcUrl");
        require(config, "jdbcUser");
        require(config, "jdbcPassword");
    }

    private void require(ComponentModel config, String key) {
        String value = config.getConfig().getFirst(key);
        if (value == null || value.isBlank()) {
            throw new ComponentValidationException(key + " is required");
        }
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        ProviderConfigProperty signUrl = new ProviderConfigProperty();
        signUrl.setName("signUrl");
        signUrl.setLabel("Sign URL");
        signUrl.setType(ProviderConfigProperty.STRING_TYPE);
        signUrl.setHelpText("SSO sign API URL");

        ProviderConfigProperty jdbcUrl = new ProviderConfigProperty();
        jdbcUrl.setName("jdbcUrl");
        jdbcUrl.setLabel("JDBC URL");
        jdbcUrl.setType(ProviderConfigProperty.STRING_TYPE);
        jdbcUrl.setHelpText("Database JDBC connection URL");

        ProviderConfigProperty jdbcUser = new ProviderConfigProperty();
        jdbcUser.setName("jdbcUser");
        jdbcUser.setLabel("JDBC User");
        jdbcUser.setType(ProviderConfigProperty.STRING_TYPE);
        jdbcUser.setHelpText("Database login user");

        ProviderConfigProperty jdbcPassword = new ProviderConfigProperty();
        jdbcPassword.setName("jdbcPassword");
        jdbcPassword.setLabel("JDBC Password");
        jdbcPassword.setType(ProviderConfigProperty.PASSWORD);
        jdbcPassword.setHelpText("Database login password");

        return List.of(signUrl, jdbcUrl, jdbcUser, jdbcPassword);
    }

    @Override
    public void close() {
    }

}

 

4. UserStorageProvider

public class SsoUserStorageProvider implements UserStorageProvider, UserLookupProvider, CredentialInputValidator, CredentialInputUpdater {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final SsoApi ssoApi;
    private final UserRepository userRepository;

    private final Map<String, UserModel> loadedByUsername = new HashMap<>();
    private final Map<String, UserModel> loadedByExternalId = new HashMap<>();

    public SsoUserStorageProvider(
            KeycloakSession session,
            ComponentModel model,
            SsoApi ssoApi,
            UserRepository userRepository
    ) {
        this.session = session;
        this.model = model;
        this.ssoApi = ssoApi;
        this.userRepository = userRepository;
    }

    @Override
    public UserModel getUserById(RealmModel realm, String id) {
        String externalId = StorageId.externalId(id);

        UserModel cached = loadedByExternalId.get(externalId);
        if (cached != null) {
            return cached;
        }

        User user = userRepository.findByUserId(externalId);
        if (user == null) {
            return null;
        }

        UserModel adapter = new UserAdapter(session, realm, model, user);
        cache(user, adapter);
        return adapter;
    }

    @Override
    public UserModel getUserByUsername(RealmModel realm, String username) {
        UserModel cached = loadedByUsername.get(username);
        if (cached != null) {
            return cached;
        }

        User user = userRepository.findByUsername(username);
        if (user == null) {
            return null;
        }

        UserModel adapter = new UserAdapter(session, realm, model, user);
        cache(user, adapter);
        return adapter;
    }

    @Override
    public UserModel getUserByEmail(RealmModel realm, String email) {
        return null;
    }

    @Override
    public boolean supportsCredentialType(String credentialType) {
        return PasswordCredentialModel.TYPE.equals(credentialType);
    }

    @Override
    public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
        return supportsCredentialType(credentialType);
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
        if (user == null || input == null) {
            return false;
        }
        if (!supportsCredentialType(input.getType())) {
            return false;
        }

        String username = user.getUsername();
        String password = input.getChallengeResponse();

        SsoAuthResponse ssoRes = ssoApi.requestSign(
                nvl(username),
                nvl(password),
                ""
        );
        List<String> cookies = ssoRes.setCookies();
        String userId = null;
        try {
            userId = SsoCookieUtils.replaceCookie(cookies);
        } catch (Exception e) {
            return false;
        }
        if (userId == null || userId.isBlank()) {
            return false;
        }

        String expectedExternalId = StorageId.externalId(user.getUsername());
        return userId.equals(expectedExternalId);
    }

    private void cache(User user, UserModel adapter) {
        loadedByUsername.put(user.username(), adapter);
        loadedByExternalId.put(user.userId(), adapter);
    }

    private String nvl(String value) {
        return value == null ? "" : value;
    }

    @Override
    public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
        throw new ReadOnlyException("Password is managed by external SSO");
    }

    @Override
    public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
        throw new ReadOnlyException("Password is managed by external SSO");
    }

    @Override
    public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
        return Stream.of(PasswordCredentialModel.TYPE);
    }

    @Override
    public void close() {
    }
}

쿠키 인증 처리 로직

 

1. AuthenticatorFactory

public class CookieAuthenticatorFactory implements AuthenticatorFactory {

    public static final String PROVIDER_ID = "cookie-authenticator";

    private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
            AuthenticationExecutionModel.Requirement.REQUIRED,
            AuthenticationExecutionModel.Requirement.ALTERNATIVE,
            AuthenticationExecutionModel.Requirement.DISABLED
    };

    private static final List<ProviderConfigProperty> CONFIG_PROPERTIES;

    static {
        ProviderConfigProperty authUrl = new ProviderConfigProperty();
        authUrl.setName("authUrl");
        authUrl.setLabel("Auth URL");
        authUrl.setType(ProviderConfigProperty.STRING_TYPE);
        authUrl.setHelpText("SSO auth API URL");

        ProviderConfigProperty cookieName = new ProviderConfigProperty();
        cookieName.setName(CookieAuthenticator.COOKIE_NAME);
        cookieName.setLabel("Cookie Name");
        cookieName.setType(ProviderConfigProperty.STRING_TYPE);
        cookieName.setDefaultValue(CookieAuthenticator.COOKIE_NAME);

        ProviderConfigProperty jdbcUrl = new ProviderConfigProperty();
        jdbcUrl.setName("jdbcUrl");
        jdbcUrl.setLabel("JDBC URL");
        jdbcUrl.setType(ProviderConfigProperty.STRING_TYPE);
        jdbcUrl.setHelpText("Database JDBC connection URL");

        ProviderConfigProperty jdbcUser = new ProviderConfigProperty();
        jdbcUser.setName("jdbcUser");
        jdbcUser.setLabel("JDBC User");
        jdbcUser.setType(ProviderConfigProperty.STRING_TYPE);
        jdbcUser.setHelpText("Database login user");

        ProviderConfigProperty jdbcPassword = new ProviderConfigProperty();
        jdbcPassword.setName("jdbcPassword");
        jdbcPassword.setLabel("JDBC Password");
        jdbcPassword.setType(ProviderConfigProperty.PASSWORD);
        jdbcPassword.setHelpText("Database login password");

        CONFIG_PROPERTIES = List.of(authUrl, cookieName, jdbcUrl, jdbcUser, jdbcPassword);
    }

}

 

2. Authenticator

public class CookieAuthenticator implements Authenticator {

    private static final Logger log = Logger.getLogger(CookieAuthenticator.class);

    public static final String COOKIE_NAME = "-";

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        Map<String, String> cfg = config != null ? config.getConfig() : Map.of();

        String authUrl = cfg.get("authUrl");
        String jdbcUrl = cfg.get("jdbcUrl");
        String jdbcUser = cfg.get("jdbcUser");
        String jdbcPassword = cfg.get("jdbcPassword");

        UserRepository userRepository = new UserRepositoryImpl(jdbcUrl, jdbcUser, jdbcPassword);
        TokenVerifier tokenVerifier = new TokenVerifierImpl(authUrl, userRepository);

        String cookieHeader = context.getHttpRequest()
                .getHttpHeaders()
                .getHeaderString(HttpHeaders.COOKIE);

        if (cookieHeader == null || cookieHeader.isBlank()) {
            context.attempted();
            return;
        }

        try {
            User user = tokenVerifier.verify(cookieHeader);
            if (user == null || user.username() == null || user.username().isBlank()) {
                log.warn("token verification failed or user info missing");
                context.attempted();
                return;
            }

            UserModel userModel = findUser(context, user);
            if (userModel == null) {
                log.warnf("Keycloak user not found. username=%s, email=%s",
                        user.username(), user.email());

                // 정책에 따라 attempted()로 폼으로 넘길지, failure()로 막을지 선택
                context.failure(AuthenticationFlowError.UNKNOWN_USER);
                return;
            }

            if (!userModel.isEnabled()) {
                log.warnf("User disabled. username=%s", user.username());
                context.failure(AuthenticationFlowError.USER_DISABLED);
                return;
            }

            context.setUser(userModel);
            context.success();
        } catch (Exception e) {
            log.error("token authentication error", e);

            // 위변조/서버오류를 강하게 막고 싶으면 failure
            // 그냥 다음 폼으로 넘기고 싶으면 attempted
            context.failure(AuthenticationFlowError.INTERNAL_ERROR);
        }
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // 이 authenticator는 화면/폼 submit을 안 쓰므로 보통 no-op
        context.attempted();
    }

    @Override
    public boolean requiresUser() {
        // token으로 사용자를 식별하므로 false
        return false;
    }

    @Override
    public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
        // 별도 user setup 필요 없음
        return true;
    }

    @Override
    public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
        // required action 없음
    }

    @Override
    public void close() {
    }
    
    private UserModel findUser(AuthenticationFlowContext context, User user) {
        KeycloakSession session = context.getSession();
        RealmModel realm = context.getRealm();
        UserModel userModel = session.users().getUserByUsername(realm, user.username());
        if (userModel != null) {
            return userModel;
        }
        return null;
    }
}

 

3. TokenVerifierImpl

public class TokenVerifierImpl implements TokenVerifier {
    private final HttpClient httpClient = HttpClient.newHttpClient();
    private final UserRepository userRepository;
    private final String authUrl;

    public TokenVerifierImpl(String authUrl, UserRepository userRepository) {
        this.authUrl = authUrl;
        this.userRepository = userRepository;
    }

    @Override
    public User verify(String cookieHeader) throws Exception {
        if (cookieHeader == null || cookieHeader.isBlank()) {
            return null;
        }

        try {
            String body = "-";

            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(authUrl))
                    .header("Cookie", cookieHeader)
                    .header("Content-Type", "application/x-www-form-urlencoded")
                    .POST(HttpRequest.BodyPublishers.ofString(body))
                    .build();

            HttpResponse<String> response =
                    httpClient.send(request, HttpResponse.BodyHandlers.ofString());

            List<String> cookies = response.headers().allValues("set-cookie");
            String userId = SsoCookieUtils.replaceCookie(cookies);
            if (userId == null || userId.isBlank()) {
                return null;
            }

            return userRepository.findByUsername(userId);
        } catch (Exception e) {
            throw new RuntimeException("verifyByCookieHeader failed", e);
        }
    }

    private String enc(String value) {
        return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
    }

}

코드만 배포했다고 바로 동작하는 건 아니었다. 여기서 많이 헷갈린 부분이 있는데, 우리가 한 건 User Federation provider 등록이 아니라 custom authenticator를 서버에 배포한 것이다. 즉, JAR을 providers/ 에 배포했다고 해서 끝이 아니다. 실제로 로그인에 사용하려면 Browser Flow에 execution으로 추가해야 한다.

 

작업 순서는 다음과 같았다.

  1. Realm 선택
  2. Authentication
  3. Flows
  4. 기본 browser 플로우를 Copy
  5. 복사한 새 플로우 선택
  6. Add execution
  7. 목록에서 Cookie Authenticator 추가
  8. Requirement 를 ALTERNATIVE 로 변경
  9. 위치를 Cookie 다음, Forms 앞쪽으로 조정
  10. Bindings 탭에서 Browser Flow를 새 플로우로 변경

이 과정을 마치고 나서야 실제 로그인 요청에서 custom authenticator가 실행되기 시작했다. User Storage SPI는 User federation에 추가하면된다.


구글 SAML Client - keycloak 연동

  1. Clients
  2. Create client - Client ID: google
  3. Client - Settings - Name ID format: email, Force POST binding: ON
  4. Realm settings - keys - Algorithm: RS256 - Certificate copy
  5. https://admin.google.com/ac/security/sso
  6. google - SAML 프로필 등록
  7. IDP 엔티티 ID: google
  8. 로그인 페이지 URL: https://{keycloak-domain}/realms/{realm}/protocol/saml
  9. 인증서 업로드: (4)에서 복사한 Certificate을 .pom 파일로 만들어 등록
    • -----BEGIN CERTIFICATE----- ``` -----END CERTIFICATE-----
  10. SP 세부정보에 빌드된 엔티티 ID를 keycloak Client ID에 입력
  11. SP 세부정보에 빌드된 ACS URL을 아래 칸에 입력
    • Settings - Valid redirect URIs
    • Settings - Master SAML Processing URL
    • Advanced - Assertion Consumer Service POST Binding URL
  12. keys - Encryption keys config - Encrypt assertions: ON
  13. keys - Encryption keys config - Certificate: 구글에서 생성한 SP 인증서

'Others' 카테고리의 다른 글

opensearch root CA 등록  (0) 2026.04.17
logback + fluent-bit + opensearch 개발 서버용  (1) 2026.04.15
Redis, Prometheus, Grafana 모니터링  (0) 2025.10.31
mybatis sql 로그 pretty하게 남기기  (4) 2025.07.31
Redis + Sentine  (0) 2025.03.28

Redis-3.0.7, Redis_exporter-1.55.0, Alertmanager-0.27.0, Prometheus-2.37.9, Grafana-12.2.0 기준


prometheus.yml

# my global config
global:
  scrape_interval: 10s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
    - static_configs:
        - targets:
           - 127.0.0.1:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
  - "/usr/local/prometheus/alert_rules.yml"
  

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: "redis_exporter"

    static_configs:
      - targets: ["node1:9121"]
      
  - job_name: "redis_exporter2"

    static_configs:
      - targets: ["node2:9121"]

 

alert_rules.yml

Redis 노드 인스턴스 다운 감지 알림 보내기 위한 설정, 

groups:
  - name: redis-alerts
    rules:
      # Redis Exporter 자체 다운 감지
      - alert: RedisExporterDown
        expr: up{job=~"redis_exporter.*"} == 0
        for: 30s
        labels:
          severity: critical
        annotations:
          summary: "Redis Exporter Down"
          description: "Redis exporter {{ $labels.instance }} ({{ $labels.job }}) is DOWN."

      # Redis 프로세스 자체 다운 감지
      - alert: RedisInstanceDown
        expr: redis_up{job=~"redis_exporter.*"} == 0
        for: 30s
        labels:
          severity: critical
        annotations:
          summary: "Redis Instance Down"
          description: "Redis instance behind {{ $labels.instance }} is DOWN."

      # 메모리 사용량 경고
      - alert: RedisHighMemory
        expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.9
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Redis High Memory Usage"
          description: "Redis {{ $labels.instance }} memory usage > 90%."

      # 연결 수 과다 경고
      - alert: RedisTooManyClients
        expr: redis_connected_clients > 1000
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Redis Too Many Connections"
          description: "Redis {{ $labels.instance }} has over 1000 connected clients."

 

alertmanager.yml

group_by: ['alertname', 'job', 'instance']를 구분해야 모든 redis 노드들이 묶여서 알림 발송되지 않음

global:
  resolve_timeout: 5m

route:
  receiver: 'slack-notifications'
  group_by: ['alertname', 'job', 'instance']
  group_wait: 10s
  group_interval: 30s
  repeat_interval: 1h

receivers:
  - name: 'slack-notifications'
    slack_configs:
      - send_resolved: true
        api_url: 'slack_hook_url'
        channel: 'slack_channel'
        username: 'slack_user_name'
        title: '{{ if eq .Status "firing" }} [DOWN] {{ .CommonAnnotations.summary }}{{ else }} [RECOVERED] {{ .CommonAnnotations.summary }}{{ end }}'
        text: |-
          {{ range .Alerts }}
          *Status:* {{ .Status | toUpper }}
          *Instance:* {{ .Labels.instance }}
          *Description:* {{ .Annotations.description }}
          {{ end }}
          *Severity:* {{ .CommonLabels.severity }}

 

grafana.ini

대시보드 판넬 캡처 알림 보내기 위한 설정, grafana-image-renderer 플러그인 설치 필수

[unified_alerting]
# Enable the Alerting sub-system and interface.
enabled = true

[unified_alerting.screenshots]
# Enable screenshots in notifications. You must have either installed the Grafana image rendering
# plugin, or set up Grafana to use a remote rendering service.
# For more information on configuration options, refer to [rendering].
capture = true

[rendering]
# Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer.
# URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service.
server_url = http://grafana-image-renderer-ip:8081/render
# If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/.
# The `callback_url` can also be configured to support usage of the image renderer running as a plugin with support for SSL / HTTPS. For example https://localhost:3000/.
callback_url = http://grafana-ip:3000

values.D, values.E, values.F는 'Define query and alert condition'에서 추가한 값, Notification Templates에서 {{ .Annotations.descriopon }} 으로 참조

grafana paenl alert message config

 

MyBatis을 사용하면 기본적으로 로그 레벨이 DEBUG일 때 자동적으로 sql 로그를 남기도록 설정이 되어있다. 물론 이때 sql는 이쁘게 정렬되지 않고 개행 없이 출력된다. 덤으로 파라미터가 '?'으로 출력되어 어떤 값이 사용되었는지 따로 확인을 해야 한다.

그래서 대부분 net.sf.log4jdbc.sql.jdbcapi.DriverSpy 프록시 드라이버를 사용해 불편을 해소하는 편이다. 하지만 프록시 드라이버를 사용할 수 없는 문제에 직면해 다른 방법을 찾아보았다.

 

Version

  • jdk : 1.6
  • Mybatis: 3.2.7

ConnectionLogger에서 debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);로 sql 로그를 남긴다. 이때 sql 파라미터가 바인딩 되지 않았기 때문에 로그에서 파라미터가 '?'으로 출력된다. 따라서 ConnectionLogger에서 sql 로그를 남기는 부분을 과감히 삭제하자. 그리고 sql 파라미터가 바인딩 되는 PreparedStatementLogger로 책임을 전가하자. PreparedStatementLogger.newInstance(stmt, statementLog, queryStack, (String) params[0]);으로 수정

public Object invoke(Object proxy, Method method, Object[] params)
      throws Throwable {
    try {
      ```
      if ("prepareStatement".equals(method.getName())) {
      	/**
        if (isDebugEnabled()) {
          debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);
        }  
        **/
        PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
        //stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
        stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack, (String) params[0]);
        return stmt;
      } else if ("prepareCall".equals(method.getName())) {
      	/**
        if (isDebugEnabled()) {
          debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);
        }  
        **/
      ```
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
  }

 

PreparedStatementLogger에서 debug("Parameters: " + getParameterValueString(), true);로 바인딩된 sql 파라미터를 로그로 남긴다. 바인딩 되기전 method.getName()는 "setString"으로 먼저 로그가 출력되지 않는다. method.getName()이 "execute"가 되기까지 else if (SET_METHODS.contains(method.getName())) 분기에 걸려 계속 파라미터를 바인딩한다.

바인딩된 sql 파라미터 로그를 남기는 부분을 ConnectionLogger에서 전달받은 sql과 같이 합쳐 로그를 남기도록 수정한다.

public Object invoke(Object proxy, Method method, Object[] params) throws Throwable {
    try {
      ```      
      if (EXECUTE_METHODS.contains(method.getName())) {
        /**
        if (isDebugEnabled()) {
          debug("Parameters: " + getParameterValueString(), true);
        }
        **/
        if (isInfoEnabled()) {
          info("SQL: \n" + bindSqlParameters(preparedSql), true);
        }
        clearColumnInfo();
        if ("executeQuery".equals(method.getName())) {
          ResultSet rs = (ResultSet) method.invoke(statement, params);
          if (rs != null) {
            return ResultSetLogger.newInstance(rs, statementLog, queryStack);
          } else {
            return null;
          }
        } else {
          return method.invoke(statement, params);
        }
      } else if (SET_METHODS.contains(method.getName())) {
        if ("setNull".equals(method.getName())) {
          setColumn(params[0], null);
        } else {
          setColumn(params[0], params[1]);
        }
        return method.invoke(statement, params);
      } else if ("getResultSet".equals(method.getName())) {
      ```
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
  }

 

bindSqlParemeters, formatParameterValue, leftTrimMultiline, getLeadingWhitespaceWidth

protected String bindSqlParameters(String original) {
    if (!(this instanceof PreparedStatementLogger)) {
      return leftTrimMultiline(original);
    }
    String sql = leftTrimMultiline(original);
    StringBuffer output = new StringBuffer();
    Matcher placeholderMatcher = Pattern.compile("\\?").matcher(sql);

    int paramIndex = 0;
    int paramCount = this.columnValues.size();
    while (placeholderMatcher.find()) {
      String replacement = (paramIndex < paramCount) ? formatParameterValue(this.columnValues.get(paramIndex++)) : "[Output]";
      placeholderMatcher.appendReplacement(output, Matcher.quoteReplacement(replacement));
    }

    placeholderMatcher.appendTail(output);
    return output.toString();
  }

  private String formatParameterValue(Object value) {
    if (value == null) {
      return "null";
    }

    if (value instanceof Number) {
      return value.toString();
    }

    String str = value.toString().replace("$", "\\$");
    return "'" + str + "'";
  }
  
  public static String leftTrimMultiline(String str) {
    if (str == null || str.length() == 0) {
      return str;
    }

    String[] lines = str.split("\\r?\\n");
    List<String> nonEmptyLines = new ArrayList<String>();

    // 유효한 줄만 추출
    for (int i = 0; i < lines.length; i++) {
      String line = lines[i];
      if (line.trim().length() > 0) {
        nonEmptyLines.add(line);
      }
    }

    // 모두 공백일 경우
    if (nonEmptyLines.size() == 0) {
      return "";
    }

    // 공통 들여쓰기 최소값 계산
    int minIndent = Integer.MAX_VALUE;
    for (int i = 0; i < nonEmptyLines.size(); i++) {
      String line = nonEmptyLines.get(i);
      int leadingSpaces = getLeadingWhitespaceWidth(line);
      if (leadingSpaces < minIndent) {
        minIndent = leadingSpaces;
      }
    }

    // 들여쓰기 제거
    StringBuilder result = new StringBuilder();
    for (int i = 0; i < lines.length; i++) {
      String line = lines[i];
      if (line.length() >= minIndent) {
        result.append(line.substring(minIndent));
      } else {
        result.append(line.trim());
      }
      result.append('\n');
    }

    return result.toString().trim();
  }

  /**
   * 왼쪽 공백 개수 계산 (탭은 4칸으로 처리)
   */
  private static int getLeadingWhitespaceWidth(String line) {
    int count = 0;
    for (int i = 0; i < line.length(); i++) {
      char ch = line.charAt(i);
      if (ch == ' ') {
        count += 1;
      } else if (ch == '\t') {
        count += 4;
      } else {
        break;
      }
    }
    return count;
  }

 

로그 레벨이 INFO인 환경에서도 sql 로그를 남기도록 수정했는데 중요한 sql 파라미터(ex, 비밀번호..)가 있는 경우 로그를 출력하지 않게 수정해야 한다. 그리고 반복적인 불필요한 sql(ex, 메뉴 리스트 조회)도 굳이 로그로 남길 필요가 없었다.

로그 제어를 위해 @NoLogging 어노테이션을 추가하고 매퍼 인터페이스 메서드에 추가해 특정 sql은 로그를 남기지 않도록 하자.

Mybatis는 sql를 xml에 작성하거나 어노테이션으로 추가할 수 있다. 어노테이션 방식을 사용하면 MapperAnnotationBuilder가 해당 정보를 저장하고 xml에 작성하면 XMLStatementBuilder가 정보를 저장한다. XMLStatementBuilder는 xml파일 자체를 읽기 때문에 매퍼 인터페이스에 정의된 정보를 확인 할 수 없다. 따라서 모든 xml 정보를 읽은 후 @NoLogging 어노테이션 여부를 저장해야한다.

public class SqlSessionFactoryBuilder {
  ```
  public SqlSessionFactory build(Configuration config) {
    for (Class<?> mapperClass : config.getMapperRegistry().getMappers()) {
      for (Method method : mapperClass.getDeclaredMethods()) {
        if (method.isAnnotationPresent(NoLogging.class)) {
          String msId = mapperClass.getName() + "." + method.getName();
          if (config.hasStatement(msId)) {
            MappedStatement ms = config.getMappedStatement(msId);
            ms.setNoLogging(true);
          }
        }
      }
    }
    return new DefaultSqlSessionFactory(config);
  }
}

 

@NoLogging 여부를 저장했으면 해당 어노테이션 존재 여부에 따라 로그를 출력하지 않도록 설정하면된다. handler.prepare(connection)에서 connection이 프록시이면 내부 로깅 프록시 동작으로 ConnectionLogger, PreparedStatementLogger, ResultSetLogger에서 각각 로그를 남기는데 unwrapConnection(connection)으로 실제 connection 객체를 넘겨 내부 로깅 프록시가 동작하지 않도록 수정한다.

public class SimpleExecutor extends BaseExecutor {
  ```
  private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
    Statement stmt;
    Connection connection = getConnection(statementLog);
    if (isNoLogging(handler)) {
      stmt = handler.prepare(unwrapConnection(connection));
    } else {
      stmt = handler.prepare(connection);
    }
    handler.parameterize(stmt);
    return stmt;
  }
}
public abstract class BaseExecutor implements Executor {
  ```
  protected boolean isNoLogging(StatementHandler handler) {
    try {
      Field delegateField = handler.getClass().getDeclaredField("delegate");
      delegateField.setAccessible(true);
      Object delegate = delegateField.get(handler);

      Field msField = delegate.getClass().getSuperclass().getDeclaredField("mappedStatement");
      msField.setAccessible(true);
      MappedStatement ms = (MappedStatement) msField.get(delegate);
      if (ms.getNoLogging()) {
        return true;
      }
      return false;
    } catch (Exception e) {
      return false;
    }
  }

  protected Connection unwrapConnection(Connection conn) {
    if (Proxy.isProxyClass(conn.getClass())) {
      InvocationHandler handler = Proxy.getInvocationHandler(conn);
      if (handler.getClass().getSimpleName().equals("ConnectionLogger")) {
        try {
          Field connField = handler.getClass().getDeclaredField("connection");
          connField.setAccessible(true);
          return (Connection) connField.get(handler);
        } catch (Exception e) {
          // ignore and return original
        }
      }
    }
    return conn;
  }
}

Gpt 정보 - 100% 신뢰 할 수 없는 정보 / 기본 정보만 확인

📌Master-Slave Sync가 발생하는 시점

1️⃣ Slave가 처음 연결될 때

  • Slave가 replicaof 명령을 통해 Master에 연결되면 전체 RDB snapshot을 받아 초기 sync 수행.
  • Master는 BGSAVE로 덤프 파일 생성 → Slave에게 전송 → Slave는 메모리에 로드

2️⃣ Slave가 재시작되었을 때

  • 재부팅 등으로 Redis Slave가 재시작되면 Master와 다시 동기화 시도
  • 기존 연결이 끊겼으면 Full sync 진행됨

3️⃣ Master와 연결이 일시적으로 끊겼다가 복구되었을 때

  • Redis는 연결이 끊겼던 동안의 데이터를 메모리 버퍼에 저장
  • 끊긴 시간이 짧으면 Partial Resync로 빠르게 복구
  • 끊긴 시간이 길면 Full Resync (RDB 재전송)

4️⃣ Slave가 SLAVEOF 명령으로 다른 Master에 붙을 때

  • 새 Master에 붙는 순간 Full Resync 진행됨

✅ 2. 그 이후는 실시간으로 복제 (Command Propagation)

  • Master에서 키 변경(set/del 등) 발생 시 → 그 명령이 그대로 Slave에게 전달됨
  • Redis는 이걸 비동기 명령 전파라고 부릅니다

✔️ 기본 동작 흐름

  1. Slave에서 Master에 replicaof <master-ip> <port> 명령 전송
  2. Master는 Slave와 연결된 후, 현재 데이터 전체 전송 (Full Sync)
  3. 이후 Master에서 실행되는 모든 명령(set/del/incr 등)을 그대로 Slave에 전파

🧠 참고: Full vs Partial Sync

구분 설명
🔁 Full Resync 전체 RDB 파일을 보내고 Slave는 기존 데이터를 다 날리고 새로 동기화
⚡ Partial Resync 연결만 끊겼다가 재접속 시, 누락된 명령만 전송해서 빠르게 복구
⛔ 조건 Partial Sync는 Master가 backlog를 유지하고 있어야 가능

📌 장애 복구 흐름 요약

1. Master 장애 발생

  • Sentinel이 감지하고 기존 Slave를 Master로 자동 승격

2. 기존 Master 복구 (서버 재시작 등)

  • Redis는 여전히 자신이 Master라고 생각하고 기동됨
  • 하지만 Sentinel이 이를 감지하고 내부적으로 replicaof 명령을 날립니다
  • → 복구된 노드를 새로운 Master의 Slave로 강등

✔️ 이 과정은 Sentinel이 자동으로 처리합니다
단, sentinel monitor 설정이 제대로 되어 있고, Sentinel이 해당 노드를 계속 감시 중일 경우에만요

🧠 흐름도 요약

[초기 상태]
Master (A)      <--->      Slave (B)

[Master A 장애 → Failover 발생]
Slave (B) → Master로 승격

[Master A 복구]
→ Sentinel이 Master B를 기준으로 A에게 `replicaof` 명령 실행
→ Master A는 자신의 데이터를 버리고 → B에서 Full Sync 수행
→ 이후 A는 Slave가 됨

📌 예외 상황 주의

  • 만약 Sentinel이 죽어 있거나, 기존 Master가 다른 설정으로 기동된다면:
    • 자동으로 Slave로 전환되지 않을 수 있음
    • 이 경우 수동으로 명령 실행 필요:
# 복구된 서버에서 실행
redis-cli
> replicaof <new-master-ip> 6379

🔍 확인 명령

redis-cli INFO replication

 

복구된 노드에서 role:slave로 보이면 성공적으로 전환된 것입니다.


📌 Sentinel 수가 중요한 이유는 → Quorum & Failover 판단 때문입니다.

Redis Sentinel은 단순한 감시 도구가 아니라,
다수결(Quorum)을 기반으로 Master 장애를 판단하고, 새로운 Master를 선출합니다.

✅ 핵심 개념 1: Quorum (쿼럼)

Master가 진짜로 죽었는지를 판단하기 위한 Sentinel의 최소 동의 수

예시:

sentinel monitor mymaster master_ip 6379 2
  • 여기서 2가 quorum입니다.
  • 즉, Sentinel 최소 2개가 동시에 "Master 죽음"을 감지해야 → Failover 진행됨

✅ 핵심 개념 2: Majority (다수결)

Failover 시, Sentinel들끼리 리더를 뽑고 승격을 진행해야 함
→ 전체 Sentinel 중 과반수의 동의가 필요

  • 총 Sentinel 3개 → 과반수는 2개
  • 총 Sentinel 2개 → 과반수는 2개 (결과적으로 둘 다 있어야 failover 됨)
  • Sentinel 1개 → 과반수도 quorum도 만족 못함 → ❌ 자동 failover 불가

✅ 정리

Sentinel 수 Quorum 만족 가능? 자동 Failover 가능? 설명
1개 감지만 가능. 자동 failover 불가
2개 ⚠️ 어렵다 ⚠️ 이론상 가능, 실제 불안정 하나만 죽어도 quorum 미달
3개 이상 가장 안정적. 실무 표준

 

🧠 용어 정리

용어 설명
quorum Master 장애 판단에 필요한 Sentinel 수
majority Failover 실행을 위해 필요한 Sentinel 과반수
monitor Sentinel이 감시할 Redis Master 등록 명령
failover Master 장애 시, Slave를 Master로 승격시키는 과정

📌 Redis Sentinel이 failover 발생 시 여러 Slave 중 어떤 노드를 Master로 승격시키는지

가장 최신 데이터(=가장 많은 복제 offset을 가진) Slave를 Master로 승격시킵니다.

🔍 Sentinel의 Slave 선출 기준 순서

Redis Sentinel은 다음과 같은 우선순위 기준으로 승격할 Slave를 선택합니다:

1️⃣ 복제 offset이 가장 높은 슬레이브

  • → 즉, 가장 최근까지 마스터로부터 데이터를 잘 복제한 노드
  • 데이터를 거의 유실하지 않고 마스터로 전환 가능

2️⃣ priority 값이 가장 낮은 슬레이브

  • redis.conf 또는 CONFIG SET slave-priority 로 설정 가능
  • 0이면 "절대 승격 대상 아님" 의미

3️⃣ 연결 상태 (availability)

  • 다운돼 있거나, Sentinel이 접근할 수 없는 노드는 제외

4️⃣ ID 기준 사전순 (tie-breaker)

  • 위 조건이 모두 동일할 경우 → runid 기준 정렬로 승격

📌 slave-priority 설정 예시

redis.conf:

replica-priority 100   # 기본값: 100
  • 0으로 설정하면 해당 슬레이브는 절대 마스터로 승격되지 않음

✅ Sentinel 로그 예시

Sentinel failover 시 다음과 같이 판단 로그가 나옵니다:

+failover-state-select-slave slave ***.***.*.12:6380 offset 123456 priority 100
+failover-state-select-slave slave ***.***.*.13:6380 offset 123460 priority 100
+failover-state-select-slave slave ***.***.*.14:6380 offset 123450 priority 0 (skipped)

→ 192.168.0.13이 가장 최신 offset → 승격 대상

🧠 참고: 복제 offset이란?

  • Redis 복제는 마스터로부터 명령 스트림을 받아 offset 기준으로 동기화
  • offset 값이 클수록 → 최신 데이터를 가진 상태
  • Sentinel은 이 값을 기준으로 누가 가장 동기화 잘 되어 있는지를 판단함

✅ 결론

기준 설명
① 복제 offset 가장 최신 데이터를 가진 슬레이브
② replica-priority 낮을수록 우선, 0이면 절대 승격 안 함
③ 접근 가능 여부 Sentinel이 접근 가능한 슬레이브만 대상
④ Tie-breaker runid 기준 (사전순)

 

spring-boot-starter-security 수동으로 인증 시 2.5.2에서는 인증 이력이 남아있는데 3.3.2에서는 사라지는 이유

 

2.5.2에서는 FilterChain에서 SecurityContextPersistenceFilter로 동작하지만 3.3.2에서는 SecurityContextHolderFilter로 동작한다. SecurityContextPersistenceFilter에서는 SecurityContextRepository에 SecurityContext를 저장하기 때문에 인증 이력이 남아있다.

 

1. SecurityContextPersistenceFilter

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (request.getAttribute("__spring_security_scpf_applied") != null) {
            chain.doFilter(request, response);
        } else {
            request.setAttribute("__spring_security_scpf_applied", Boolean.TRUE);
            if (this.forceEagerSessionCreation) {
                HttpSession session = request.getSession();
                if (this.logger.isDebugEnabled() && session.isNew()) {
                    this.logger.debug(LogMessage.format("Created session %s eagerly", session.getId()));
                }
            }

            HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
            SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
            boolean var10 = false;

            try {
                var10 = true;
                SecurityContextHolder.setContext(contextBeforeChainExecution);
                if (contextBeforeChainExecution.getAuthentication() == null) {
                    this.logger.debug("Set SecurityContextHolder to empty SecurityContext");
                } else if (this.logger.isDebugEnabled()) {
                    this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", contextBeforeChainExecution));
                }

                chain.doFilter(holder.getRequest(), holder.getResponse());
                var10 = false;
            } finally {
                if (var10) {
                    SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
                    SecurityContextHolder.clearContext();
                    // breakPoint
                    this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
                    request.removeAttribute("__spring_security_scpf_applied");
                    this.logger.debug("Cleared SecurityContextHolder to complete request");
                }
            }

            SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
            SecurityContextHolder.clearContext();
            this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
            request.removeAttribute("__spring_security_scpf_applied");
            this.logger.debug("Cleared SecurityContextHolder to complete request");
        }
    }

 

2. SecurityContextHolderFilter

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        if (request.getAttribute(FILTER_APPLIED) != null) {
            chain.doFilter(request, response);
        } else {
            request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
            DeferredSecurityContext deferredContext = this.securityContextRepository.loadDeferredContext(request);

            try {
                this.securityContextHolderStrategy.setDeferredContext(deferredContext);
                chain.doFilter(request, response);
            } finally {
                this.securityContextHolderStrategy.clearContext();
                request.removeAttribute(FILTER_APPLIED);
            }

        }
    }

spring-boot-starter-security FormLogin으로 로그인 시

 

1. spring-boot-starter-security 2.5.2

AbstractAuthenticationProcessingFilter에서 context 저장 후 SecurityContextPersistenceFilter에서 SecurityContextRepository에 저장

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        //breakPoint
        SecurityContextHolder.getContext().setAuthentication(authResult);
        if (this.logger.isDebugEnabled()) {
            this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
        }

        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }

        this.successHandler.onAuthenticationSuccess(request, response, authResult);
    }

 

2. spring-boot-starter-security 3.3.2

AbstractAuthenticationProcessingFilter에서 모두 저장

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
        context.setAuthentication(authResult);
        this.securityContextHolderStrategy.setContext(context);
        this.securityContextRepository.saveContext(context, request, response);
        if (this.logger.isDebugEnabled()) {
            this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
        }

        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }

        this.successHandler.onAuthenticationSuccess(request, response, authResult);
    }
Entity를 save한 후에 해당 엔디티를 find하면 transaction이 끝나기전에 flush()되는 이유

 

우선 save() 호출 후 findByName() 호출하는 부분부터 디버깅을 해보았다. 주요 클래스들만 추려보면..

 

1. ConcreteSqmSelectQueryPlan

public ConcreteSqmSelectQueryPlan(SqmSelectStatement<?> sqm, String hql, DomainParameterXref domainParameterXref, Class<R> resultType, TupleMetadata tupleMetadata, QueryOptions queryOptions) {
        this.sqm = sqm;
        this.domainParameterXref = domainParameterXref;
        this.rowTransformer = determineRowTransformer(sqm, resultType, tupleMetadata, queryOptions);
        UniqueSemantic uniqueSemantic;
        if (sqm.producesUniqueResults() && !AppliedGraphs.containsCollectionFetches(queryOptions)) {
            uniqueSemantic = UniqueSemantic.NONE;
        } else {
            uniqueSemantic = UniqueSemantic.ALLOW;
        }

        this.executeQueryInterpreter = (resultsConsumer, executionContext, sqmInterpretation, jdbcParameterBindings) -> {
            SharedSessionContractImplementor session = executionContext.getSession();
            JdbcOperationQuerySelect jdbcSelect = sqmInterpretation.getJdbcSelect();

            Object var10;
            try {
                RegistrationHandler subSelectFetchKeyHandler = SubselectFetch.createRegistrationHandler(session.getPersistenceContext().getBatchFetchQueue(), sqmInterpretation.selectStatement, JdbcParametersList.empty(), jdbcParameterBindings);
                session.autoFlushIfRequired(jdbcSelect.getAffectedTableNames(), true);
                var10 = session.getFactory().getJdbcServices().getJdbcSelectExecutor().executeQuery(jdbcSelect, jdbcParameterBindings, listInterpreterExecutionContext(hql, executionContext, jdbcSelect, subSelectFetchKeyHandler), this.rowTransformer, (Class)null, (sql) -> {
                    return executionContext.getSession().getJdbcCoordinator().getStatementPreparer().prepareQueryStatement(sql, false, (ScrollMode)null);
                }, resultsConsumer);
            } finally {
                domainParameterXref.clearExpansions();
            }

            return var10;
        };
        this.listInterpreter = (unused, executionContext, sqmInterpretation, jdbcParameterBindings) -> {
            SharedSessionContractImplementor session = executionContext.getSession();
            JdbcOperationQuerySelect jdbcSelect = sqmInterpretation.getJdbcSelect();

            List var11;
            try {
                RegistrationHandler subSelectFetchKeyHandler = SubselectFetch.createRegistrationHandler(session.getPersistenceContext().getBatchFetchQueue(), sqmInterpretation.selectStatement, JdbcParametersList.empty(), jdbcParameterBindings);
               	//breakPoint
                session.autoFlushIfRequired(jdbcSelect.getAffectedTableNames(), true);
                var11 = session.getFactory().getJdbcServices().getJdbcSelectExecutor().list(jdbcSelect, jdbcParameterBindings, listInterpreterExecutionContext(hql, executionContext, jdbcSelect, subSelectFetchKeyHandler), this.rowTransformer, uniqueSemantic);
            } finally {
                domainParameterXref.clearExpansions();
            }

            return var11;
        };
        this.scrollInterpreter = (scrollMode, executionContext, sqmInterpretation, jdbcParameterBindings) -> {
            SharedSessionContractImplementor session = executionContext.getSession();
            JdbcOperationQuerySelect jdbcSelect = sqmInterpretation.getJdbcSelect();

            ScrollableResultsImplementor var9;
            try {
                JdbcSelectExecutor jdbcSelectExecutor = session.getFactory().getJdbcServices().getJdbcSelectExecutor();
                session.autoFlushIfRequired(jdbcSelect.getAffectedTableNames(), true);
                var9 = jdbcSelectExecutor.scroll(jdbcSelect, scrollMode, jdbcParameterBindings, new SqmJdbcExecutionContextAdapter(executionContext, jdbcSelect), this.rowTransformer);
            } finally {
                domainParameterXref.clearExpansions();
            }

            return var9;
        };
    }

 

2. SessionImpl

public boolean autoFlushIfRequired(Set<String> querySpaces, boolean skipPreFlush) throws HibernateException {
        this.checkOpen();
        if (!this.isTransactionInProgress()) {
            return false;
        } else {
            AutoFlushEvent event = new AutoFlushEvent(querySpaces, skipPreFlush, this);
            this.fastSessionServices.eventListenerGroup_AUTO_FLUSH.fireEventOnEachListener(event, AutoFlushEventListener::onAutoFlush);
            return event.isFlushRequired();
        }
}

 

3. DefaultAutoFlushEventListener

public void onAutoFlush(AutoFlushEvent event) throws HibernateException {
        EventSource source = event.getSession();
        SessionEventListenerManager eventListenerManager = source.getEventListenerManager();
        EventManager eventManager = source.getEventManager();
        HibernateMonitoringEvent partialFlushEvent = eventManager.beginPartialFlushEvent();

        try {
            eventListenerManager.partialFlushStart();
            if (this.flushMightBeNeeded(source)) {
                ActionQueue actionQueue = source.getActionQueue();
                EventSource session = event.getSession();
                PersistenceContext persistenceContext = session.getPersistenceContextInternal();
                if (!event.isSkipPreFlush()) {
                    this.preFlush(session, persistenceContext);
                }

                int oldSize = actionQueue.numberOfCollectionRemovals();
                this.flushEverythingToExecutions(event, persistenceContext, session);
                if (this.flushIsReallyNeeded(event, source)) {
                    LOG.trace("Need to execute flush");
                    event.setFlushRequired(true);
                    HibernateMonitoringEvent flushEvent = eventManager.beginFlushEvent();

                    try {
                        this.performExecutions(source);
                        //flush
                        this.postFlush(source);
                        this.postPostFlush(source);
                    } finally {
                        eventManager.completeFlushEvent(flushEvent, event, true);
                    }

                    StatisticsImplementor statistics = source.getFactory().getStatistics();
                    if (statistics.isStatisticsEnabled()) {
                        statistics.flush();
                    }
                } else {
                    LOG.trace("No need to execute flush");
                    event.setFlushRequired(false);
                    actionQueue.clearFromFlushNeededCheck(oldSize);
                }
            }
        } finally {
            eventManager.completePartialFlushEvent(partialFlushEvent, event);
            eventListenerManager.partialFlushEnd(event.getNumberOfEntitiesProcessed(), event.getNumberOfEntitiesProcessed());
        }

    }

 

  1. JPA Default FlushMode는 `AUTO`이다.
  2. 조회 쿼리 실행전에 autoFlushIfRequired이 필요한지 확인한다. 이때 autoFlushIfRequired 인자로 getAffectedTableNames 테이블 이름을 넘긴다.
  3. 트랜잭션이 isTransactionInProgress이면 AutoFlushEvent 이벤트를 발생한다
  4. flushIsReallyNeeded 메서드에서 FlushMode가 `ALWAYS`이거나 ActionQueue(flush 되기 전까지 action을 저장하고 있는)의 테이블과 getAffectedTableNames로 넘어온 테이블을 비교한다. 이떄 flush되지 않은 테이블에 데이터를 조회하는 경우 먼저 해당 action을 flush 한다.
  5. 따라서 transaction이 끝나기전에 flush가 된다.
FlushMode란..

Hibernate에 FlushMode라는 설정값이 있었고 JPA에서는 `COMMIT`, `AUTO`만 사용가능하다고 명시되어 있었다. 

Hibernate FlushMode JPA FlushModeType Interpretation
MANUAL   Never flush automatically
COMMIT COMMIT Flush before transaction commit
AUTO AUTO Flush before transaction commit, and before execution of a query whose results might be affected by modifications held in memory
ALWAYS   Flush before transaction commit, and before execution of every query

 

스프링에서 flushMode 설정하는 방법은 application.yml  파일 또는 application.properties 파일에 추가
spring.jpa.properties.org.hibernate.flushMode=COMMIT

 

demo

demo.service

@Transactional
public Member test1() {
	PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
	Member member = new Member(1L, "test", "test@gmail.com", passwordEncoder.encode("123"));
	return mainRepository.save(member);
}

@Transactional()
public String test6() {
	Member member = mainRepository.findByEmail("123");
	if (member == null) {
		member = test1();
	}

	log.info("before findByEmail");

	Member member1 = mainRepository.findByEmail("test@gmail.com");

	log.info("after findByEmail");

	return member.toString();
}

 

spring.jpa.properties.org.hibernate.flushMode=COMMIT 적용 전(Default AUTO)

Hibernate: 
    select
        m1_0.id,
        m1_0.email,
        m1_0.name,
        m1_0.password 
    from
        member m1_0 
    where
        m1_0.email=?
Hibernate: 
    select
        m1_0.id,
        m1_0.email,
        m1_0.name,
        m1_0.password 
    from
        member m1_0 
    where
        m1_0.id=?
Hibernate: 
    select
        next value for member_seq
INFO 26636 --- [demo] [nio-8080-exec-1] : before findByEmail
Hibernate: 
    insert 
    into
        member
        (email, name, password, id) 
    values
        (?, ?, ?, ?)
Hibernate: 
    select
        m1_0.id,
        m1_0.email,
        m1_0.name,
        m1_0.password 
    from
        member m1_0 
    where
        m1_0.email=?
INFO 26636 --- [demo] [nio-8080-exec-1] : after findByEmail

 

spring.jpa.properties.org.hibernate.flushMode=COMMIT 적용 후.. 트랜잭션 종료 시점(=commit 시점)에 flush

Hibernate: 
    select
        m1_0.id,
        m1_0.email,
        m1_0.name,
        m1_0.password 
    from
        member m1_0 
    where
        m1_0.email=?
Hibernate: 
    select
        m1_0.id,
        m1_0.email,
        m1_0.name,
        m1_0.password 
    from
        member m1_0 
    where
        m1_0.id=?
Hibernate: 
    select
        next value for member_seq
INFO 26772 --- [demo] [nio-8080-exec-1] : before findByEmail
Hibernate: 
    select
        m1_0.id,
        m1_0.email,
        m1_0.name,
        m1_0.password 
    from
        member m1_0 
    where
        m1_0.email=?
INFO 26772 --- [demo] [nio-8080-exec-1] : after findByEmail
Hibernate: 
    insert 
    into
        member
        (email, name, password, id) 
    values
        (?, ?, ?, ?)

 

출처

An Introduction to Hibernate 6 (jboss.org)

ActionQueue (Hibernate JavaDocs) (jboss.org)

 

+ Recent posts