로그 강의 수강 후 복습을 위해 작성하는 포스팅이며, 이전 포스팅은 여기에서 확인하실 수 있습니다.

 

5. Elasticsearch, Logstash로 로그 수집 

5-1. 로그 수집

로그 수집이란, 여러 대의 애플리케이션 서버에서 발생한 로그들을 모아 중앙화된 저장소에 모으는 것을 의미한다. 

1) 보통 서비스에서 생성된 로그들을 Logback을 통해 콘솔/파일/그 외 무언가로 로그를 기록한다.

2) Logback에 의해 생성된 산출물들을 Logstash를 이용해 Elasticsearch에 저장한다.

이 일련의 과정을 로그 수집이라고 한다.

 

그 다음 Kibana를 통해 Elastcisearch에 수집된 로그들을 시각화한 로그 상황을 확인할 수 있다.

 

5-2. 로그 수집이 필요한 이유

보통 작은 회사에서 여러 대의 서버를 돌리기 때문에,

1) 직접 각 서버에 접근해 로그를 수집하는 것은 번거롭기도 하고,

2) 데이터를 한데 모아서 관리하기가 어려워질 수 있다.

따라서 각 서버에서 발생한 로그 파일들을 한 곳에 모으는 것이 좋다.

 

본 강의에서는 각 로그 파일들을 ElasticSearch에 모으고, 그 모으는 걸 Logstash가 수행하는 프로세스를 학습했다.

 

5-3. ElasticSearch, Logstash 세팅하기

로그 수집을 위해 logstash, elastic search를 사용할건데, 이를 docker로 실행한다.

 

구체적인 과정은 다음과 같다.

1. docker를 다운로드 한 뒤, 실행시켜 아래 명령어를 IntelliJ 터미널에 입력해 docker를 실행한다.

docker run -d --name elasticsearch -p 9200:9200 -e "discovery.type=single-node" -e "xpack.security.enabled=false" -e "xpack.security.http.ssl.enabled=false" docker.elastic.co/elasticsearch/elasticsearch:8.10.0

 

2. pom.xml에 logstash  관련 의존성 추가한다.

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

 

3. logback.xml 파일의 <appender> 설정도 변경해 logstash로도 log가 발송되도록한다.

    <!-- Logstash로 전송할 Appender -->
    <appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
        <destination>localhost:5044</destination>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder" />
    </appender>

 

4. logstash가 input으로 받은 로그를 elasticsearch로 output 되도록 구성한다.

./logstash.conf 파일을 생성한 뒤 아래 내용을 추가한다.

input {
    tcp {
        port => 5044
        codec => json
    }
}

output {
    elasticsearch {
        hosts => ["http://elasticsearch:9200"]
        index => "application-logs-%{+YYYY.MM.dd}"
    }
}

 

5. logstash, elasticsearch는 서로 다른 network에 있으므로 하나의 network로 묶어둔다.

docker network create elastic-network
docker network connect elastic-network elasticsearch
docker network connect elastic-network logstash

 

6. elasticsearch에서 어떤 index 목록이 있는지 확인한다.

elasticsearch-head에서 확인한 docker의 index

7. kibana 명령어를 터미널에 실행 한 뒤 링크에 접속해 kibana로 로그 데이터를 시각화하는 대시보드를 만들 수도 있다.

docker run -d --name kibana --network elastic-network -p 5601:5601 -e "ELASTICSEARCH_HOSTS=http://elasticsearch:9200" kibana:8.10.1

 

 

 

 

본 내용은 주로 실습을 기반으로 진행되었기에 해당 내용은 참고만 하고, 실제로 Elasticsearch에 로그 파일이 쌓이는 것은 강의를 참고하시길 바랍니다.

 

 

6. 로그 레벨을 기준으로 알람 설정하기

WARN 로그 레벨인 경우에는 warn 로그가 n회 이상일 때 개발자에게 알림이 가게 설정해두는 정책이 있을 수 있다. 그럴 경우 스케줄러를 통해 알림을 발송 할 수 있다. 

 

Elasticsearch 쿼리는 결국엔 단순한 HTTP 요청이고, 주기적으로 실행되는 태스크를 스케쥴링 태스크라고 한다.

  • 스케쥴링 태스크 처리 솔루션
    • 서버에 크론탭 등록 및 실행
    • Jenkins 활용
    • Kubernetes의 크론잡
    • scdf

여기서 상황에 적절한 도구를 활용하는 것이 필요하고, 스케쥴링된 태스크들은 가급적 팀 내에서 한 곳에서 관리될 수 있도록 하는 것이 좋다. 왜냐하면 스케쥴링 태스크가 산재되면 전체 로직을 파악하기 어렵고, 스케쥴링된 태스크들이 정상적으로 실행되고 있는지 알기 어렵기 때문이다.

업무를 하면서 Log 코드를 흔하게 볼 수 있고, 다들 로그를 자연스럽게 쓰는 것 같은데 나는 이에 대해 잘 모르고 있다고 느껴 자연스레 로그 관련해서 공부를 해야겠다고 생각했다. 어렴풋이 코드 상황을 기록하는? 것으로 로그를 알고 있고, 로그 레벨로 무엇이 있는 정도만 알고있는 터라 인프런 강의를 통해 공부했고, 그 기반으로 이해한 내용을 기록하려 한다.

 

참고로 인프런에서 들은 강의는 '개발자에게 필요한 로그 관리 강의' 이다.

 

1. 어떤 것을 로그로 남겨야할까?

개발시 범용적으로 로그를 남기는 상황들은 다음과 같다.

 

  1. 요청/응답 로그(특히 요청): 디버깅, 성능 분석
  2. 오류/예외 로그 : 장애 대응
  3. 사용자 활동 : 감사 및 사용 분석(법적인 상황)
  4. 디버깅 로그 : 문제 추적 (로컬에서만 사용 권장)
  5. 시스템 상태 로그 : 운영 모니터링 (x) -> APM에서 다루는 게 더 나음
  6. 데이터베이스 쿼리 로그 : 성능 최적화를 위해 남기는 경우가 있지만, Application에서는 보통 Log로 기록하지 않음
  7. 보안 로그 : 침해 대응 및 방어
  8. 배치 작업 로그 : 백그라운드 프로세스 모니터링

이 중에서 1, 2, 3, 4의 상황은 Application Log로 남기면 좋은 상황들이다.

그 외 로그를 남기면 좋은 상황으로는 Entity 상태가 변경될 때, 변경 전/후의 값을 Log로 기록하면 변화를 파악하기에 용이하다.

 

2. 예외와 로그

2-2. Checked/Unchecked Exception

예외가 발생했을 경우 예외 원인 분석과 처리를 위해 로그를 남겨야하는 경우가 있을 수 있다. 그 전에 예외에 대해 정확히 짚고 넘어가려고 한다. 예외에는 checked exception과 unchecked exception이 있는데, 흔히들 checked exception은 컴파일 시점에 발생하는 에러이고, unchecked exception은 런타임 시점에 발생하는 에러라고 알고 있는데 이는 잘못되었다고 한다.

 

예외처리는 모두 런타임에 발생하고, checked와 unchecked의 차이는 컴파일 시점에 예외 처리를 강제 하는지 안하는지에 대한 부분이다. 이 둘의 차이는 바로 컴파일 시점에서의 예외처리 강제 여부이다. checked exception은 컴파일 시점에 예외처리를 강제하고, unchecked exception은 컴파일 시점에 예외처리를 강제하지 않는다.

 

2-2. 모든 Exception이 에러는 아니다

바로 다음 단계에서 구체적인 로그레벨에 대해 설명하겠지만, 일단 예외 상황이 발생했을 때 어떤 로그 레벨을 선택하는 것이 좋은지 알아보고 가자. Exception이 발생한다고 해서 모든 예외가 에러는 아니다. 그래서 Exception의 성격을 잘 파악해 로그레벨을 설정해주는 것이 필요하다.

 

[Info/Warn]

잘못된 URL 파라미터 등 사용자(클라이언트)가 잘못된 데이터를 넘겼을 때 발생하는 예외는 시스템에서 발생한 에러가 아니므로 Info, 혹은 Warn 레벨이 적당하다. 이 상황에서 Error를 사용하는 것은 부적절한 느낌이다.

 

[Error]

잘못된 데이터가 들어올 가능성이 없는 경우에 발생한 Exception 같은 경우는 발생했을 시 개발자가 직접 개입해야하는 시스템적인 문제일 가능성이 높으므로 Error 로그레벨을 사용하는 것이 적절하다.

 

3. 로그 레벨

로그 레벨로는 Trace, Debug, Info, Warn, Error, Fatal이 있다. 

 

Trace

Trace 레벨은 가장 세부적인 수준의 로그 레벨로, 특징은 다음과 같다.

 

1) 보통 코드의 세부적인 실행 경로, 요청 바디, 메서드 실행시간 등의 데이터를 기록하는 로그 레벨이다.

2) 이 데이터들은 발생 빈도가 잦아 데이터 양이 많기에 보통 3일 동안만 보관한다.

 

Debug

Debug 레벨은 디버깅 목적의 로그 레벨로, 특징은 다음과 같다.

 

1) 서비스 개발 과정에서 주요하게 찍는 값들을 기록하거나

2) 개발 중 코드의 상태나 흐름을 이해하는 용도로 사용한다

3) 특정 조건에서 발생하는 버그를 확인하거나

4) 지켜야봐야하는 예상 에러 부분을 기록하기 위해 주료 사용한다.

5) Debug 로그 레벨도 Trace와 마찬가지로 데이터 양이 많기에 3일치의 기록만 보통 보관한다.

 

 

- Query를 확인하는 용도로는 Trace나 Debug가 적절하다.

Query 같은 경우는 로그의 양이 많고, 비즈니스적인 변화가 있지 않기 때문에 데이터를 오래 보관할 필요가 없기 때문이다.

 

Info

시스템의 정상적인 운영 상태를 나타내는 정보성 로그로, 중요한 이벤트나 상태 변화를 기록하기 위해 사용하는 로그 레벨이다.

주로 코드 레벨이 아닌 비즈니스 레벨에서 기록해둬야 하는 정보가 있을 때 사용하기도 한다.

 

Warn

잠재적으로 문제가 될 수 있는 상황을 나타내지만, 시스템 운영에는 즉각적인 영향을 주지 않는 경우에 사용하는 로그 레벨이다.

 

Error

치명적이지 않지만, 중요한 문제가 발생했음을 나타내는 로그 레벨이고, 복구가 필요하거나 실패한 작업을 추적해야 할 때 사용하는 로그 레벨이다.

 

Fatal

시스템 운영을 계속할 수 없을 정도로 심각한 오류가 발생했을 때 사용하는 로그 레벨이다.

 

4. Logback

4-1. Slf4j

Logback을 설명하기 전에 Slf4j부터 먼저 설명하자면, 이 프레임워크는 개발하면서 종종 사용했던 터라 비교적 익숙하다.

Slf4j는 로깅 프레임워크(Logback, Log4j, 기타 로깅 프레임워크)의 인터페이스이다. 로깅 인터페이스를 사용함으로써 로깅 프레임워크에 의존하지 않는 코드를 작성할 수 있다. 이를 PSA(Portable Service Abstraction) 이라고 한다.

 

4-2. Portable Service Abstraction(PSA)

특정한 구현 기술에 의존하지 않고 코드를 작성할 수 있도록 추상화 개념을 지원하는 것을 의미한다.

 

4-3. Logback

앞서 설명한것처럼 Logback은 로깅 프레임워크 중에 하나이고, 자바의 ./src/main/resources 경로에 logback.xml 파일을 생성해 설정 정보를 입력하면된다. 예시 코드는 다음과 같다.

<configuration>
    <property name="LOG_FILE" value="application.log"/>

    <!-- 콘솔 출력 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss}%-5level [%thread] %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 파일 출력 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_FILE}</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>application.%d{yyyy-MM-dd_HH-mm}.log.gz</fileNamePattern>
            <maxHistory>2</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Logger 설정 -->
    <root level="info">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="FILE" />
    </root>
</configuration>

 

 

Logback 파일을 구성하는 태그는 다음과 같다.

<property> : 변수로 활용될 것을 선언

<appender> : 로그가 어떻게 출력될지(콘솔, 파일, 그 외)를 제어하는 역할, Logback 설정에서 핵심

<root> : 각 로그 파일들이 어떤 로그 레벨로 지정될지 설정하는 역할. 특정 레벨의 로그에는 어떤 appender를 사용할지

<encoder> : 로그 출력 형식 지정

여기에서 가장 중요한 부분은 <appender>이다. Logback을 잘 활용한다는 건 설정 파일을 잘 작성하는 것을 의미하고, appender를 현 애플리케이션 상황에 맞게 잘 설정하면 로그를 효율적이고 유용하게 분석할 수 있게 구조를 설정할 수 있다.

 

4-4. Logback의 <appender>

Logback의 <appender>에도 다양한 종류가 있는데, 수업에서 소개한 거라 기록해두지만 보통 ConsoleAppender와 RollingAppender를 주로 사용한다고 한다. 

 

종류

1. ConsoleAppender 
콘솔/터미널에 로그를 출력하는 설정이다. 로그가 콘솔에만 찍히기 때문에 로그 데이터를 모으고, 분석하기에는 어려움이 있다.

2. FileAppender 
단일한 파일에 로그를 기록함

3. RollingAppender
시간에 따라서(예시, 일 단위로) 새로운 파일에 로그를 기록하는 형식. 일단위로 기록하는게 로그 관리에 용이함

4. SyslogAppender
OS와 상호작용시 OS로 보내는 로그파일

5. SMTPAppender
SMTP는 이메일을 보낼 때 사용하는 프로토콜. 로그를 메일로 보낼때 사용.


등등 다양한 Logback의 Appender들이 있다.

 

4-5. <appender>의 <maxHistory>

설정한 기간 동안 보관하는 최대 로그파일 갯수를 지정할 수 있다.

기간을 'YYYY-MM-DD'로 설정하고, <maxHistory>30</maxHistory>로 설정하면, 일 저장 로그 파일의 갯수가 최대 30개이고, 30개 일 때 새로 로그 파일이 생성된다면 FIFO 방식으로 가장 먼저 생성된 로그 파일부터 삭제된다.

 

4-6. Logback.xml 설정으로 Profile에 따라 다른 logback 설정하기

profile별로 발생하는 로그를 구분해서 저장할 수 있는데, 그러기 위해선 logback.xml에 아래 설정 정보를 추가한 뒤 logback-***(profile명).xml 파일을 추가한다.

<configurationo>
  <springProfile name="dev">
    <include resource="logback-dev.xml"/>
  </springProfile>
  
   <springProfile name="prod">
    <include resource="logback-prod.xml"/>
  </springProfile>
  
  <springProfile name="default">
    <include resource="logback-dev.xml"/>
  </springProfile>
</configuration>

1. 개요

스프링 시큐리티로 로그인을 구현했으니, 스프링 시큐리티로 로그아웃도 구현해보자. 로그아웃은 SecurityConfig 같은 시큐리티 config 클래스에서 설정해주면 된다.

 

스펙

  • Spring Security
  • JWT

 

2. SecurityConfig 코드

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    
    ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        ....
        http.
            ....
            .logout(logout -> logout
                                .logoutUrl("/logout")
                                .logoutSuccessUrl("/index")
                                // 로그아웃 핸들러 추가 (세션 무효화 처리)
                                .addLogoutHandler((request, response, authentication) -> {
                                    HttpSession session = request.getSession();
                                    session.invalidate();
                                })
                                // 로그아웃 성공 핸들러 추가 (리다이렉션 처리)
                                .logoutSuccessHandler((request, response, authentication) ->
                                        response.sendRedirect("/index"))
                                .deleteCookies("JSESSIONID", "access_token"));

        return http.build();
    }
}

 

3. 코드 톺아보기 - 로그아웃

SecurityConfig 클래스의 filterChain() 내부에서 logout을 설정해주면 되는데, 구체적인 과정은 다음과 같다.

 

  • .logoutUrl() : 로그아웃을 요청할 url 설정
  • .logoutSuccessUrl() : 로그아웃 성공시 리다이렉트 할 url
  • .addLogoutHandler() : 세션 무효화 처리를 할 로그아웃 핸들러
    • 로그아웃 성공 실패 여부 상관없이 시도했을 때 수행된다
  • .logoutSuccessHandler() : 로그아웃 성공 핸들러
    • addLogoutHandler가 수행되고, 로그아웃이 성공했을 때 수행된다
  • .deleteCookies() : 쿠키 값 삭제 
    • "JSESSIONID" 를 설정해야 쿠키 에러가 발생하지 않는다.
    • jwt 토큰을 쿠키에 "access_token=~" 형태로 저장했으므로 "access_token"도 삭제한다.
.logout(logout -> logout
        .logoutUrl("/logout")
        .logoutSuccessUrl("/index")
        // 로그아웃 핸들러 추가 (세션 무효화 처리)
        .addLogoutHandler((request, response, authentication) -> {
            HttpSession session = request.getSession();
            session.invalidate();
        })
        // 로그아웃 성공 핸들러 추가 (리다이렉션 처리)
        .logoutSuccessHandler((request, response, authentication) ->
                response.sendRedirect("/index"))
        .deleteCookies("JSESSIONID", "access_token"));

 

SecurityContextLogoutHandler

코드 상에서 직접 세션을 무효화하거나 쿠키를 삭제하지 않더라도 세션을 무효화하고, 쿠키를 삭제하는 기능을 한다. 

순서는 개발자가 직접 정의한 핸들러 -> 스프링 시큐리티 제공 핸들러 로 진행된다.

 

참고

https://goto-pangyo.tistory.com/170

'Spring > SpringSecurity' 카테고리의 다른 글

[Spring Security] Spring Security, JWT 토큰 로그인  (0) 2024.05.18

1. 개요

Spring Security를 이용해 로그인 기능 구현방법에 대한 내용이다. 로그인 기능을 위해서만을 위해 스프링 시큐리티를 적용하는 것이 애플리케이션이 무거워질 수 있다고는 하지만 일단 한번 경험해보면 좋을 것 같아서 스프링 시큐리티를 적용해보았다.

 

- Spring Security란?

스프링 시큐리티는 스프링 생태계에서 사용자 인증/권한 관리/보안 등의 기능을 효율적이고 신속하게 구현할 수 있게 도움을 주는 스프링 프레임워크 중 하나이다. 다양한 인증방식 중에 principal(username)-credential(password) 패턴을 사용하고 있다.

 

Spring Security 특징

  • Filter를 기반으로 동작
    • Spring MVC와 분리되어 관리하고 동작할 수 있다.
  • Bean으로 설정가능
    • Spring Security 3.2부터는 XML 설정하지 않아도 됨

 

- Spring Security Architecture

 

구체적인 과정은 다음과 같다.

 

1. 사용자 로그인 정보가 담긴 HTTP 요청이 서버로 들어온다.

2. AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성

3. Filter를 통해 AuthenticationToken을 AuthenticationManager로 위임한다.

4. AuthenticationManager는 등록된 AuthenticationProvider들을 조회하며 인증을 요구한다.

5. AuthenticationProvider는 UserDetailsService를 통해 입력받은 사용자 정보를 DB에서 조회한다.

  • supports() 메서드를 통해 실행 가능한지 체크
  • authentication() 메서드를 통해 DB에 저장된 이용자 정보와 입력한 로그인 정보 비교
    • DB 이용자 정보 : UserDetailsService의 loadUserByUsername()을 통해 불러옴
    • 입력 로그인 정보 : (4)애서 받았던 Authentication 객체(UsernameAuthentication Token)
  • 일치하는 경우 Authentication 반환

6. AuthenticationManager는 Authentication 객체를 AuthenticationFilter로 전달한다.

7. AuthenticationFilter는 전달받은 Authentication 객체를 LoginSuccessHandler로 전송하고, SecurityContextHolder에 정보를 저장한다. 

 

2. 과정

 

2-1. 의존성 추가

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

 

2-2. SecurityConfig

전체 코드

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    // authenticationManager 를 Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authenticationConfiguration
    ) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.httpBasic(HttpBasicConfigurer::disable);
        http.csrf(AbstractHttpConfigurer::disable);
        http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize-> authorize
                                                .requestMatchers(new AntPathRequestMatcher("/index")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/sign-up")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/sign-in")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/denied-page")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/v1/**")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/configuration/ui")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/configuration/security")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/webjars/**")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/static/**")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/ws-stomp/**")).permitAll()
                                                .anyRequest().authenticated())
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                                UsernamePasswordAuthenticationFilter.class)
            .logout(logout -> logout
                                .logoutUrl("/logout")
                                .logoutSuccessUrl("/index")
                                // 로그아웃 핸들러 추가 (세션 무효화 처리)
                                .addLogoutHandler((request, response, authentication) -> {
                                    HttpSession session = request.getSession();
                                    session.invalidate();
                                })
                                // 로그아웃 성공 핸들러 추가 (리다이렉션 처리)
                                .logoutSuccessHandler((request, response, authentication) ->
                                        response.sendRedirect("/index"))
                                .deleteCookies("JSESSIONID", "access_token"));

        return http.build();
    }
}

 

코드 톺아보기 - filterChain()

Spring Security 6.0.0 이후부터 WebSecurityConfigurerAdapter는 deprecated 되었기에 FilterChain의 역할을 하는 메서드를 직접 구현해 Bean으로 등록해 주어야 한다.

 

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {    
    http.httpBasic(HttpBasicConfigurer::disable);
    http.csrf(AbstractHttpConfigurer::disable);
    http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
         .authorizeHttpRequests(authorize-> authorize
                                .requestMatchers(new AntPathRequestMatcher("/index")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/sign-up")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/sign-in")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/denied-page")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/v1/**")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/configuration/ui")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/configuration/security")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/webjars/**")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/static/**")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/ws-stomp/**")).permitAll()
                                .anyRequest().authenticated())
        .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                         UsernamePasswordAuthenticationFilter.class)
                         
	....
}

 

  • http.httpBasic(HttpBasicConfigurer::disable) : JWT토큰을 사용하는 REST API 방식이기에 disable 처리
  • http.csrf(AbstractHttpConfigurer::disable) : REST API 형식으로 JSON으로만 데이터를 주고 받는 Stateless한 통신방식을 사용하기에 csrf 설정이 불필요해 disable 처리
  • http.sessionManagement() : 기존의 .sessionManagement().sessionCreationPolicy()가 deprecated된 후 개정된 코드이다.
  • .authorizeHttpRequests() : 토큰 없이도 접근이 허용되는 url을 설정할 수 있고, 허용 설정을 하지 않은 url은 모든 인증단계를 거쳐야 접근할 수 있다.
  • .addFilterBefore(filter A, filter B) : B 체인 전에 A 체인을 추가하는 역할의 메서드이다. 순서가 (JwtAuthenticationFilter(), UsernamePassword~.class) 이므로 UsernamePasswordAuthenticationFilter 전에 JwtTokenProvider를 실행한다. 즉, JwtFilter를 통과하면 UsernamePasswordAuthenticationFilter 이후의 필터는 통과한 것을 의미한다. 쉽게 말해 JWT를 통해 Username + Password 인증 수행을 의미한다. 

 

2-3. JwtAuthenticationFilter

  • 이 클래스는 클라이언트 요청시 JWT 인증을 하기 위해 설치하는 커스텀 필터
  • UsernamePasswordAuthenticationFilter 이전에 실행된다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.isValidateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

JwtTokenProvider의 resolveToken()으로 토큰을 추출한 뒤, 그 유효성을 isValidateToken()으로 확인한다. 모든 검사가 통과되면 다음단계로 넘어간다.

 

2-4. JwtTokenProvider

JWT 토큰을 발급하고, 추출하고, 유효한지 확인하는 클래스이다.

 

@Slf4j
@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;
    private final static long TOKEN_VALID_TIME = 30 * 60 * 1000L; // 토큰 유효시간 30분
    private UserDetailsService userDetailsService;

    public JwtTokenProvider(@Lazy UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    // 객체 초기화, secretKey 를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public TokenResponseDto createToken(String email, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(email);
        claims.put("roles", roles); // 정보 저장 (key-value)
        Date now = new Date();
        return new TokenResponseDto(Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + TOKEN_VALID_TIME)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 signature에 들어갈 secret 값 세팅
                .compact());
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails =
                userDetailsService.loadUserByUsername(this.getUserEmail(token));
        return new UsernamePasswordAuthenticationToken(
                userDetails,
                "",
                userDetails.getAuthorities()
        );
    }

    // 토큰에서 회원 정보 추출
    public String getUserEmail(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public String resolveToken(HttpServletRequest request) {
        String cookie = request.getHeader("Cookie");
        if (cookie != null) {
            String bearerToken = cookie.split("access_token=")[1];
            if (StringUtils.hasText(bearerToken)) {
                return bearerToken;
            }
        }

        return null;
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean isValidateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().
                            setSigningKey(secretKey).
                            parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

 

 

코드 톺아보기

@Value("${jwt.secret}")

jwt:
  secret: 비밀키

secretKey는 JWt의 signature 부분을 암호화할 개인 시크릿키를 설정해주는 값이다. 값이 비밀로 유지되어야 하므로 yml파일에 jwt.secret이라는 이름으로 .gitignore 처리해서 저장해서 사용하면 된다. 그 후 @Value("${jwt.secret}")으로 런타임에 키를 가져와 사용할 수 있게 하면된다.

 

createToken()

JWT 토큰을 생성하기 위한 메서드로, AuthService에서 로그인하는 함수인 signIn()에서 사용된다.

// JWT 토큰 생성
public TokenResponseDto createToken(String email, List<String> roles) {
     Claims claims = Jwts.claims().setSubject(email);
     claims.put("roles", roles); // 정보 저장 (key-value)
     Date now = new Date();
     return new TokenResponseDto(Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + TOKEN_VALID_TIME)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 signature에 들어갈 secret 값 세팅
                .compact());
}
  • setSubject() : JWT의 목적 같은 내용 추가
  • setClaim : JWT 토큰의 payload에 들어갈 내용으로, key-value 형태로 저장
  • signWith : JWT토큰의 signature를 의미하며, HS256으로 인코딩하고 secretkey를 이용한다는 것을 의미한다

 

getAuthentication()

// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
    UserDetails userDetails =
        userDetailsService.loadUserByUsername(this.getUserEmail(token));
        
        return new UsernamePasswordAuthenticationToken(
                userDetails,
                "",
                userDetails.getAuthorities()
        );
    }
  • UserDetailsService.loadUserbyUsername(this.getUserEmail(token)) : 토큰으로 사용자 이메일을 추출한 뒤 사용자 정보를 가져온다
  • 인증용 객체인 UsernamePasswordAuthenticationToken을 반환한다(시큐리티 동작 과정중 (2)에 해당하는 내용)

 

getUserEmail()

매개변수로 입력된 token을 파싱해서 회원정보를 반환하는 함수이다.

// 토큰에서 회원 정보 추출
public String getUserEmail(String token) {
    return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
}

 

resolveToken()

HTTP request 헤더 Cookie에 있는 access token을 추출하는 메서드이다. 내가 구현한 애플리케이션에는 헤더 쿠키에 'access_token= ~'형태로 access token이 있기에 'access_token='으로 split한 문자열에서 쿠키를 가져온 뒤 bearerToken이 있다면 토큰을 반환하는 방식으로 구현했다.

public String resolveToken(HttpServletRequest request) {
    String cookie = request.getHeader("Cookie");
    if (cookie != null) {
        String bearerToken = cookie.split("access_token=")[1];
        if (StringUtils.hasText(bearerToken)) {
           return bearerToken;
        }
    }
        return null;
    }

 

 

isValidateToken()

유효한 토큰인지, 토큰이 만료되지 않았는지 확인하는 함수이다. boolean 값을 반환하므로 메서드명을 is~()형태로 명명했다.

(보니까 유효성에 대한 코드는 없는 것 같은데, 유효한지는 어떻게 확인하지?)

// 토큰의 유효성 + 만료일자 확인
public boolean isValidateToken(String jwtToken) {
     try {
        Jws<Claims> claims = Jwts.parser().
                            setSigningKey(secretKey).
                            parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date()); // 만료일자 확인
    } catch (Exception e) {
            return false;
}

 

2-5. CustomUserDetailsService

UserDetailsService 인터페이스를 구현한 커스텀 서비스이고, JwtTokenProvider 클래스의 getAuthentication()메서드 내부에서 사용하는 클래스이다. 여기에서는 http 요청으로 넘어온 사용자 정보가 서버에 존재하는 사용자인지 확인하고 다음과 같은 결과를 반환한다. 

  • 성공 시 UserDetails 타입의 객체를 반환
  • 실패 시 UsernameNotFoundException을 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository
                .findByEmail(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
    }

    // 해당하는 Member 의 데이터가 존재한다면 User 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {
        return new SecurityUser(member);
    }
}

loadUserByUsername 내부를 보면 memberRepository에서 이메일로 사용자를 찾고, 그 결과를 SecurityUser로 매핑해서 반환하는 것을 볼 수 있다(람다와 스트림을 사용했다). 만약 사용자가 존재하지 않는다면 예외를 throw 한다.

 

2-6. SecurityUser

위 커스텀 서비스의 createUserDetails() 메서드에서 반환하는 타입으로, 시큐리티의 User를 상속받고 있다. 매개변수로 넘어온 Member 객체의 정보로 User 생성자를 호출해 User 객체를 생성한다.

@Getter
public class SecurityUser extends User {

    private Member member;

    public SecurityUser(Member member) {
        super(member.getEmail(), member.getPassword(),
                AuthorityUtils.createAuthorityList(member.getRole().toString()));
        this.member = member;
    }
}



참고

 

+ Recent posts