[Spring] 로그아웃 + AWS ElasticCache Redis

Jina·2023년 11월 14일
1

Art Hunter 프로젝트

목록 보기
5/5

안녕하세요,
저는 2023년도 2학기 졸업프로젝트를 진행하고있는 '에잇'팀의 팀원입니다ヾ(•ω•`)o


저희 팀의 주제는 Object Detection을 사용해 부분해설기반 미술관 도슨트 앱 서비스입니다!

간결한 해설과 인터랙션으로 쉽고 재미있는 감상 경험을 제공하는 것을 목표로 삼아 다음의 3가지 목표를 세웠고, 모두 구현 완료했습니다 (‾◡◝)

  1. 지루한 긴 해설은 NO! ➡️ 조각조각 나누어 짧게 부분별 해설로 보여주자!
  2. 듣기만 하는 감상은 NO! ➡️ YOLO를 사용해 조각을 수집하는 인터랙션을 제공하자! 그리고 수집 현황을 사용자별 도감으로 보여주자!
  3. 조각을 촬영하면➡️ YOLO로 조각을 인식한 뒤 GPT를 활용해 조각에 대한 짧은 해설을 제공하자!

특히 2번에서 사용자별 도감을 보여주기 위해서는 멀티 유저 관리가 필수적이었습니다. 그래서 oauth 로그인을 구현하였고, 마지막으로 이번 포스팅에서는 Redis를 사용한 로그아웃에 대해 다뤄보겠습니다! (ง •_•)ง

포스팅 구성은 다음과 같습니다.

  • 로그아웃 구현 방법 살펴보기
  • Spring 환경에서 로그아웃 로직 구현
  • 로컬 Redis를 설치해서 테스트
  • AWS ElasticCache Redis를 생성하여 테스트

로그아웃 개념

로그인은 jwt 토큰으로 발급한 access token이 유효한지 검증해서 관리했었다. 그럼 로그아웃은 어떻게 관리해야할까?

access token은 프론트에 보내고, 프론트에서 브라우저에 저장하기 때문에 백엔드에서 토큰을 삭제할 수는 없다. (물론 프론트에게 토큰을 로컬 스토리지에 저장한 토큰을 지워달라고 요청할 수도 있지만, 만약 유저가 미리 토큰을 복사해두었다면 그 토큰으로 요청을 보냈을 때 그대로 로그인이 되게 된다.)

1. 로그아웃 API로 들어온 access token을 Redis에블랙 리스트로 기록해두어, 추후 이 토큰으로 요청이 들어온다면 authentication을 허용하지 않으면 된다!

  • key: access token, value: logout)
  • (참고) JWT는 클라이언트에 저장이 되는, Stateless한 점이 가장 큰 특징이다. 그래서 이것을 없애는 것이 어려운 것이다.

그러면 이 블랙리스트를 어디에 저장하면 될까? 기존에 사용하던 mysql DB에 저장하기에는, 만료시간이 지나면 쓸모없는 정보이기에 자원 낭비가 될 수 있다.

2. Redis 메모리에 저장시 만료시간을 설정하자!

  • (참고) refresh token도 2주라는 만료시간을 설정해두었으니 Redis에 저장한다면 더 효율적으로 사용할 수 있다. 하지만 우선 로그아웃용 블랙리스트만 구현하였다.
  • access token이 유효하더라도 Redis에 블랙리스트로 등록되어 있으니 로그인이 불가하다.
  • 유효시간 만료 후에는 블랙리스트에서 삭제되지만, token 자체가 유효하지 않으니 당연히 불가

Redis란?

  • key - value 형식을 갖는 스토리지
  • in-memory로 데이터를 관리하기 때문에, 저장된 데이터가 영속적이지 않다.(휘발성) 대신, 빠른 엑세스 속도의 장점을 갖는다.
  • Spring Data Redis에서 사용할 수 있는 Redis Client 구현체는 크게 Lettuce와 Jedis가 있다. 나는 Lettuce를 사용했다. 그 이유는 별도 설정 없이 사용할 수 있기 때문이고... 더 자료가 많기 때문이다..
  • 스프링에서는 RedisTemplate 혹은 RedisRepository의 2가지 접근방식을 지원한다.

구현 방법

Spring 환경에서의 구현

1. build.grdle 파일

dependency에 Spring Data Redis를 추가해주어야 스프링에서 redis를 사용할 수 있다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

2. application.properties 파일

Redis를 사용하기 위한 configuration을 추가해준다.

  • host 주소는 REDIS_HOST로 환경변수로 설정해두었다! 로컬에서 하려면 localhost 라고 적으면 되고, AWS이면 엔드포인트 주소를 복사해 적으면 된다.
  • 포트는 기본적으로 6379이고 나는 그대로 설정했다.
# Redis Configuration
spring.data.redis.host=${REDIS_HOST}
spring.data.redis.port=6379

3. Redis Repository / config

Redis와의 연결을 설정하는 파일이다.

  • @Value로 앞서 설정한 host와 port 정보를 가져온다.
  • RedisConnectionFactory를 생성한다.
  • Redis에 데이터 저장, 검색을 위한 RedisTemplate 설정한다.
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories  // Redis Repository 활성화
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    // Redis 연결을 위한 RedisConnectionFactory 생성 (Redis 클라이언트로 Lettuce 사용)
    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        return new LettuceConnectionFactory(redisHost, redisPort);  // .properties 파일에서 설정한 host, port 가져와 연동
    }

    // Redis에 데이터 저장, 검색을 위한 RedisTemplate 설정
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

        // StringRedisSerializer로 직렬화 방법 설정
        redisTemplate.setKeySerializer(new StringRedisSerializer());    // 문자열 key 저장
        redisTemplate.setValueSerializer(new StringRedisSerializer());  // 문자열 value 저장

        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

4. JwtAuthenticationFilter - 로그아웃 여부 확인 추가

기존에 로그인을 구현하면서 JwtAuthenticationFilter를 만들었다. 로그인시 jwt 토큰인 access token을 발급한 후, 이후 로그인이 필요한 request마다 해당 access token을 검증하는 필터 역할을 하는 파일이다. 전체 코드 링크

이제는 로그아웃 기능을 추가했으니, 이 access token이 로그아웃 됐는지 여부를 확인하는 코드를 추가하면 된다!

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_TYPE = "Bearer";

    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate redisTemplate;

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

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

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // (추가) Redis 에 해당 accessToken logout 여부 확인
            String isLogout = (String)redisTemplate.opsForValue().get(token);
            if (ObjectUtils.isEmpty(isLogout)) {
                // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        chain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

5. jwtService 파일에 로그아웃 메소드 추가

JwtService도 로그인 구현시 만들어둔 파일이다. 토큰 발급, 재발급, 유효성 검사 등의 메소드가 포함되어 있다. 전체 코드는 전체 코드 링크

로그인을 위한 메소드도 다음과 같이 추가했다!

  • request의 헤더의 access token을 추출해서 Redis 메모리에 블랙리스트로 저장한다. token에서 만료시간을 추출해 그 시간동안 유효하도록 설정한다.
  • 정상적이면 200 응답, 오류가 있다면 401을 응답하며 오류 메시지를 보낸다.

[주의] 추출한 만료시간에서 현재시간을 차감한 뒤 ttl로 설정해야 한다!

            Long expiresTime = decodedJWT.getExpiresAt().getTime();
            Long now = new Date().getTime();    // 현재 시간
            return Optional.ofNullable(expiresTime - now); 
    // 로그아웃 메소드
    public ResponseEntity<ResponseDto> logout(String accessToken){
        try {
            // accessToken의 유효시간 추출해서, 해당 시간동안 Redis에 블랙리스트로 저장
            Long expiration = getExpiresTime(accessToken).get();
            redisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS);

            // 200 응답
            ResponseDto responseDto = ResponseDto.builder()
                    .status("success")
                    .message("Logout successful")
                    .data(null)
                    .build();

            return ResponseEntity.ok(responseDto);
        }
        // accessToken 만료된 경우 401 응답
        catch (TokenExpiredException e) {
            return createResponseEntity(HttpStatus.UNAUTHORIZED, "Expired access Token", null);
        }
        // 그 외 401 응답
        catch (Exception e) {
            log.error(e.getMessage());
            return createResponseEntity(HttpStatus.UNAUTHORIZED, "Access Token is Invalid", null);
        }
    }

    // AccessToken에서 expiresTime 추출하는 메소드
    public Optional<Long> getExpiresTime(String accessToken) {
        try {
            DecodedJWT decodedJWT = (JWT.require(Algorithm.HMAC512(secretKey)).build().verify(accessToken));
            // 만료시간 리턴 (현재시간 차감 후 남은 만료시간)
            Long expiresTime = decodedJWT.getExpiresAt().getTime();
            Long now = new Date().getTime();    // 현재 시간
            return Optional.ofNullable(expiresTime - now);  // 기본 만료시간은 1시간 (3600초)
        } catch (Exception e) {
            log.error("expiresTime 추출 오류: {}", e);
            return Optional.empty();
        }
    }

로컬 Redis와 연동

먼저 로컬에 설치된 Redis에 접속해서 잘 구현되었는지 테스트해보자!

1. PC에 Redis 설치

나는 윈도우를 사용해서 다음의 블로그를 참고해서 설치했다.
설치된 여러 파일 중 redis-cli를 선택하면 바로 접속할 수 있다 !

2. 저장된 Key 확인

포스트맨에서 로그아웃 api를 보낸 뒤 redis에 접속하자. 로컬이라서 127.0.0.1:6379라고 나온다.

KEYS * 명령어를 입력하면, request시 보낸 access token이 Redis에 잘 저장된 것을 확인할 수 있다( •̀ ω •́ )✧

[참고] KEYS 명령어는 key-value 중 key만 보여준다. key로 access token을 저장했으니 access token이 보이는 것이다~ (나는 2번 실행한 뒤라 2개의 토큰이 보인다.)

3. Key의 만료시간 확인

이 중 하나의 key를 선택해서, TTL 명령어로 만료시간을 확인해보자.

다음과 같이 3423이라고 나오는 건 만료될 때까지 3423초가 남았다는 뜻이다! 초반에 access token 유효시간을 1시간(3600초)로 설정했으니 잘 지정된 것이다 (ノ◕ヮ◕)ノ*:・゚✧

트러블 슈팅
위에서 언급했듯이, access token에서 만료시간을 가져온 뒤 현재시간을 차감하지 않았더니 무지막지하게 큰 시간이 만료시간으로 남아있었다. 현재 시간을 차감하는 것을 잊지 말자^^

4. value 확인

key의 value를 확인하고 싶으면 GET 명령어를 사용하면 된다.
logout이라는 value가 잘 설정된 것을 볼 수 있다.

AWS ElasticCache Redis와 연동

이제 본격적으로 AWS에서 Redis를 설정해보자!! EC2 서버에 직접 Redis를 설치할 수도 있으나, AWS에서 Elastic Cache를 사용하는 게 더 효율적이라고 한다!

단, ElasticCashe는 로컬에서 접속할 수는 없다. EC2 서버에 접속한 후 다음과 같이 실행하면 된다.

redis-cli -c -h {endpoint} -p 6379

1. AWS ElasticCache Redis 클러스터 생성

다음과 같이 설정해서 생성하면 된다. 참고로 생성하는데 꽤 오래 걸린다. 5분 이상 걸렸던 것 같다.

과금을 방지하기 위해선 클러스터 모드를 비활성화 해주어야 한다!

AWS 클라우드를 선택하고

복제본 개수를 0으로 설정한다.
특히 t2.micro 노드를 선택해야만 프리티어 내에서 사용할 수 있다 ( •̀ ω •́ )✧

보안그룹은 인바운드 규칙으로 6379 포트를 허용하는 보안그룹을 생성해 적용했다.

2. EC2에서 접속

  1. GNU 컴파일러 모음인 gcc 설치
sudo apt install gcc
  1. redis-cli 설치
wget http://download.redis.io/redis-stable.tar.gz
tar xvzf redis-stable.tar.gz
cd redis-stable
make distclean      # ubuntu에서만 입력!!!!
make
  1. 모든 위치에서 사용가능하도록 /user/bin에 파일 복사
sudo cp src/redis-cli /user/bin
  1. Redis 접근
    REDIS_HOST 자리에 ElasticCacheㅢ 엔드포인트 주소를 넣어주면 된다.
src/redis-cli -c -h {REDIS_HOST} -p 6379

테스트용으로 'ping'이라고 보내보면 'pong이라는 답변이 오는 것을 확인할 수 있다.

3. Spring 연동 / 환경변수 등록

  1. 깃허브 Secret에 추가
    깃허브 액션을 통해 빌드하고 있으니 REDIS_HOST를 시크릿에 추가했다.

  2. EC2 환경변수에 추가
    참고 블로그를 참고해서 진행했다. 먼저 환경변수를 설정하고 확인해보면, 잘 설정된 모습이다.

    참고로 이 과정을 안 하고 한참을 헤맸다^^ 아예 스프링 실행이 안 되더라.
    팀원에게 SOS 쳤더니 알려주심.. 역시 혼자 끙끙 앓지 말자

  3. 스프링 파일 실행
    이제 연동이 완료되었다! 스프링 파일을 실행해주면 된다~~~

테스트

마지막으로 테스트를 해보자 (ノ◕ヮ◕)ノ*:・゚✧

유효한 access token을 헤더에 담아 로그아웃 요청을 보내면, logout이 성공했다는 200 응답이 잘 오는 모습이다!!!

Redis에 접속해서 KEY 명령어로 확인해보면, 해당 access token이 key로 저장된 것도 확인 가능하다

또한 TTL 명령어로 해당 key의 남은 만료시간을 확인해보면, 3573초이라고 나온다. 기본 1시간(3600초)로 설정해두었으니 잘 설정된 것이다! 만료시간이 지나면 자동으로 Redis에서 삭제될 것이다.

이제 이 access token을 가지고 로그인이 필요한 api 요청을 보내보자. 그러면 로그아웃된 토큰이기 때문에, 403응답으로 "Logged out access token"이라는 응답이 잘 온다. 모두 성공 !!!! (~ ̄▽ ̄)~

참고자료

0개의 댓글