[Project] Redis로 로그아웃 기능 구현하기 !

현주·2023년 6월 28일
0

📌 Redis에 대한 자세한 개념들은 아래 포스팅을 참고해주세요.

카풀 서비스 프로젝트 개발 중에 로그아웃 기능이 필요했다.

이 프로젝트에서는

Redis를 사용하여 외부 캐시 JWT 로그아웃을 구현하였고,

외부환경에 구속받지 않고 테스트하기 위해 Embedded Redis도 사용하였다.

이번 포스팅에서 Redis로 로그아웃 기능을 어떻게 구현하는지 살펴볼 것이다 !

그 전에 여기서 사용되는 몇가지 개념들에 대해 설명해보자면,

✔️ Spring Data Redis

  • Spring Data 프로젝트의 하위 프로젝트로서, Redis를 Spring 애플리케이션에 쉽게 통합할 수 있도록 도와주는 라이브러리
  • Redis 클라이언트 라이브러리인 Jedis, Lettuce 등을 활용하여 Redis와 상호 작용하기 위해 더 높은 수준의 추상화와 기능을 제공하는 프레임워크

    ✔️ Lettuce
  • Java Redis 클라이언트 라이브러리
  • Redis와 상호 작용하는 비차단, 스레드 안전, 고성능 방식을 제공
  • 현재 (Spring Boot 2.0.2) Spring Data Redis에서 공식지원하는 Client

    ✔️ Embedded Redis
  • 내장 Redis
  • 로컬 개발 환경이나 테스트 환경에서 Redis를 쉽게 실행할 수 있도록 도와주는 도구
  • 이를 사용하면 외부 Redis 서버를 설치하고 구성할 필요 없이 애플리케이션 내에서 Redis를 실행 가능

💡 로그아웃 기능 구현 시에 Redis를 사용한 이유

  1. 세션 관리
    ➜ 로그아웃 기능은 사용자 세션 관리의 일부로 사용되는데,
    Redis를 사용할 경우 메모리에 세션 데이터를 효율적으로 저장/조회가 가능하고
    로그아웃 요청 시 해당 세션을 빠르게 제거가 가능

  2. 캐싱
    ➜ 로그아웃 요청 시 Redis를 사용하여 사용자 세션 정보를 캐싱 가능
    ➜ 데이터베이스에 대한 부하를 줄이고 응답 시간을 단축 가능

  3. 분산 환경 지원
    ➜ Redis는 분산 환경에서의 데이터 공유 및 동기화를 지원하기 때문에
    여러 서버 또는 인스턴스에서 동시에 실행되는 애플리케이션의 경우, 사용자 세션 데이터의 공유/동기화 가능

  4. TTL(Time-To-Live) 기능
    ➜ Redis로 로그아웃 세션 정보에 대해 일정 시간 후에 자동으로 만료되도록 TTL을 설정이 가능
    ➜ 사용자 세션 데이터의 메모리 점유를 최소화하고, 만료된 세션 데이터를 자동으로 정리할 수 있음

  5. Blacklist 관리
    ➜ Set이나 List와 같은 데이터 구조를 활용하여 로그아웃된 사용자의 Access Tocken을 블랙리스트에 추가하여 로그인 시 검증 과정에서 블랙리스트에 있는지 확인하여 로그인 거부 가능
    ➜ 이를 통해 세션을 유지하지 않고도 로그아웃한 사용자를 빠르게 인식 가능

👉 우리 프로젝트에서는 결정적으로 한 번 로그인 한 Access Tocken을 Blacklist에 넣고,
로그아웃은 되었지만 Access Tocken의 유효기간이 남아있을 때 탈취당하여 로그인되는 것을 방지하기 위해 Redis를 사용하였다.


🌼 Redis로 로그아웃 구현하기

1. build.gradle에 의존성 추가

  • Redis 사용을 위함

  • Embedded Redis 사용을 위함


2. application.yml에 설정 추가

  • Redis 캐시를 사용하기 위해 cache 타입을 redis로 정해줌

  • Redis 연결에 필요한 host, port 번호, password를 지정해줌


3. Redis 사용을 위한 Config 파일 생성

3-1. RedisRepositoryConfig 클래스

  • Redis와의 연결 정보를 설정하고, Redis 데이터를 저장/조회하는 데 사용되는 RedisTemplate 객체를 생성하는 역할

  • Redis를 캐시로 사용하기 위한 설정도 함께 해줌

@Configuration
@EnableRedisRepositories // Redis를 사용한다고 명시해주는 애너테이션
public class RedisRepositoryConfig {
    @Value("${spring.redis.host}")
    private String redisHost;

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

    @Value(value = "${spring.redis.password}")
    private String redisPassword;
    @Autowired
    private Environment environment;

    // LettuceConnectionFactory 객체를 생성하여 반환하는 메서드
    // 이 객체는 Redis Java 클라이언트 라이브러리인 Lettuce를 사용하여 Redis 서버와 연결해 줌
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        // RedisStandaloneConfiguration를 통해 redis 접속 정보(host, port 등)를 갖고 있는 객체를 생성
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(redisHost);
        redisStandaloneConfiguration.setPort(redisPort);
        // profile이 prod(배포환경)가 맞다면, redis password 설정
        Arrays.stream(environment.getActiveProfiles()).forEach(profile -> {
            if (profile.equals("prod")) {
                redisStandaloneConfiguration.setPassword(redisPassword);
            }
        });
        // Redis 설정정보를 LettuceConnectionFactory에 담아서 반환
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    // Redis 작업을 수행하기 위해 RedisTemplate 객체를 생성하여 반환하는 메서드
    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
    
    // Redis를 캐시로 사용하기 위한 CacheManager 빈 생성
    @Bean
    public CacheManager cacheManager() {
        // RedisCacheManagerBuilder를 사용하여 RedisConnectionFactory를 설정하고, RedisCacheConfiguration 구성
        RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                // Redis의 Key와 Value의 직렬화 방식 설정
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .prefixCacheNameWith("cache:") // Key의 접두사로 "cache:"를 앞에 붙여 저장
                .entryTtl(Duration.ofMinutes(30)); // 캐시 수명(유효기간)을 30분으로 설정
        builder.cacheDefaults(configuration);

        return builder.build(); // cacheDefaults를 설정하여 만든 RedisCacheManager 반환
    }
}

💡 RedisConnectionFactory 인터페이스 하위 클래스에는 LettuceConnectionFactory, JedisConnectionFactory 두 가지가 존재하는데,

성능 상 Lettuce가 Jedis에 비해 몇배 이상의 성능과 하드웨어 자원 절약이 가능하므로
우리 프로젝트에서도 Lettuce를 사용했다.

[ 참고 - Jedis 보다 Lettuce 를 쓰자 ]

  • RedisStandaloneConfiguration
    ➜ single node에 redis를 연결하기 위한 설정 정보를 가지고 있는 기본 클래스

  • RedisTemplate
    ➜ Redis 데이터를 저장하고 조회하는 기능을 하는 클래스

3-2. LocalRedisConfig 클래스

  • 이 프로젝트의 경우 내장 서버로 환경을 구성할 것이기 때문에 Embedded Redis의 설정을 해주었다.
@Slf4j
@Profile("!prod") // profile이 prod(배포환경)이 아닐 경우에만 활성화
// -> 로컬 환경에서만 Embedded Redis를 사용하고, 실제 배포 환경에서는 외부 Redis 서버를 사용하기 때문
@Configuration
public class LocalRedisConfig {
    @Value("${spring.redis.port}") // yml에 넣어둔 port 번호를 가져옴
    private int redisPort;

    private RedisServer redisServer;

    // Redis 서버를 실행시키는 메서드
    // 해당 클래스가 로딩될 때, startRedis() 메서드가 자동으로 실행돼서 Embedded Redis를 실행함
    @PostConstruct
    public void redisServer() throws IOException {
        int port = isRedisRunning() ? findAvailablePort() : redisPort; // Redis 서버가 실행 중인지 확인
        // 실행 중이라면 사용 가능한 다른 포트를 찾아서 port 변수에 할당하고,
        // 실행 중이 아니라면 redisPort 변수의 값을 사용
        
        // 현재 시스템이 ARM 아키텍처인지 확인 
        if (isArmArchitecture()) {
            // ARM 아키텍처가 맞다면, RedisServer 클래스를 사용하여 Redis 서버를 생성
            System.out.println("ARM Architecture");
            redisServer = new RedisServer(Objects.requireNonNull(getRedisServerExecutable()), port);
            // getRedisServerExecutable() - ARM 아키텍처에서 Redis Server를 실행할 때 사용할 Redis Server 실행 파일을 가져오는 메서드
            // ( 가져올 파일이 없는 경우 예외를 던짐 )
        } else {
            // ARM 아키텍처가 아니라면, RedisServer.builder()를 사용하여 Redis 서버를 생성
            redisServer = RedisServer.builder()
                    .port(port)
                    .setting("maxmemory 128M")
                    .build();
        }
        
        // 위에서 생성한 Redis 서버 객체를 실행
        redisServer.start();
    }

    // PreDestroy 애너테이션으로 해당 클래스가 종료될 때 stopRedis() 메서드가 자동으로 실행되어 Embedded Redis를 종료함
    @PreDestroy
    public void stopRedis() {
        if (redisServer != null) {
            redisServer.stop();
        }
    }
    
    // Embedded Redis가 현재 실행중인지 확인
   private boolean isRedisRunning() throws IOException {
       return isRunning(executeGrepProcessCommand(redisPort));
   }

Redis는 M1의 ARM 프로세서 아키텍처에서 실행되는 것을 지원하지 않음 !

Embedded Redis는 애플리이션이 실행될 때 자동으로 시작되고, 애플리케이션이 종료될 때 Redis도 종료되는데,
Redis가 ARM 프로세서 아키텍처에서 실행되지 않기 때문에 M1에서 Embedded Redis를 실행할 수 없었다.

우리 프로젝트에서 RestDocs 사용을 위해서는 Test 빌드가 필수였는데
Embedded Redis를 실행할 수 없으니 Test도 빌드가 되지 않았다.

👉 위의 if문에서 Arm 아키텍처라면,
Redis 실행파일과 port 번호를 넣은 RedisServer를 생성함으로써 문제를 해결하였다 !

📌 관련 에러 내용은 아래 포스팅을 참고해주세요.

But, 여기까지 작성하면 Test 환경에서 Redis를 테스트 할 때 아래와 같은 문제가 생긴다.

❗ 전역 테이스를 할 때 각 테스트 클래스마다 Redis를 띄우게 되는데,
한 테스트 클래스의 Redis 서버가 죽기 전에 다음 테스트 클래스를 실행하려면
이전 Redis 서버가 아직 살아있어 port 충돌로 인해 테스트가 실패하게 된다.

따라서 LocalRedisConfig에 해당 포트가 미사용 중일 때만 사용하고,
사용 중이면 그 외 다른 포트를 사용할 수 있도록 아래 설정을 추가 해주어야 한다 !

   // 현재 PC/서버에서 사용가능한 포트 조회
   public int findAvailablePort() throws IOException {

       for (int port = 10000; port <= 65535; port++) {
           Process process = executeGrepProcessCommand(port);
           if (!isRunning(process)) {
               return port;
           }
       }

       throw new IllegalArgumentException("Not Found Available port: 10000 ~ 65535");
   }

   // 해당 port를 사용중인 프로세스 확인하는 sh 실행
   private Process executeGrepProcessCommand(int port) throws IOException {
       String OS = System.getProperty("os.name").toLowerCase();
       System.out.println("OS: " + OS);
       System.out.println(System.getProperty("os.arch"));
       if (OS.contains("win")) {
           log.info("OS is  " + OS + " " + port);
           String command = String.format("netstat -nao | find \"LISTEN\" | find \"%d\"", port);
           String[] shell = {"cmd.exe", "/y", "/c", command};
           return Runtime.getRuntime().exec(shell);
       }
       String command = String.format("netstat -nat | grep LISTEN|grep %d", port);
       String[] shell = {"/bin/sh", "-c", command};
       return Runtime.getRuntime().exec(shell);
   }


   // 해당 Process가 현재 실행중인지 확인
   private boolean isRunning(Process process) {
       String line;
       StringBuilder pidInfo = new StringBuilder();

       try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
           while ((line = input.readLine()) != null) {
               pidInfo.append(line);
           }
       } catch (Exception e) {
       }

       return !StringUtils.isEmpty(pidInfo.toString());
   }

   private boolean isArmArchitecture() {
       return System.getProperty("os.arch").contains("aarch64");
   }

   private File getRedisServerExecutable() throws IOException {
       try {
           //return  new ClassPathResource("binary/redis/redis-server-linux-arm64-arc").getFile();
           return new File("src/main/resources/binary/redis/redis-server-linux-arm64-arc");
       } catch (Exception e) {
           throw new IOException("Redis Server Executable not found");
       }
   }
}

4. RedisUtils 클래스 생성

@Component
@RequiredArgsConstructor
public class RedisUtils {
    private final RedisTemplate<String, Object> redisTemplate;
    private final RedisTemplate<String, Object> redisBlackListTemplate;

    public void set(String key, Object o, int minutes) {
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
        redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
    }

    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public boolean delete(String key) {
        return Boolean.TRUE.equals(redisTemplate.delete(key));
    }

    public boolean hasKey(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    public void setBlackList(String key, Object o, Long milliSeconds) {
        redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
        redisBlackListTemplate.opsForValue().set(key, o, milliSeconds, TimeUnit.MILLISECONDS);
    }

    public Object getBlackList(String key) {
        return redisBlackListTemplate.opsForValue().get(key);
    }

    public boolean deleteBlackList(String key) {
        return Boolean.TRUE.equals(redisBlackListTemplate.delete(key));
    }

    public boolean hasKeyBlackList(String key) {
        return Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key));
    }

    public void deleteAll() {
        redisTemplate.delete(Objects.requireNonNull(redisTemplate.keys("*")));
    }
}
  • Redis와 상호작용하기 편리한 메서드들은 만든 클래스
    Ex. 값 설정, 값 가져오기, 값 삭제, 키의 존재 여부 확인, 모든 키 삭제 등

  • 기본적으로 get, set, delete, hasKey만 있어도 되지만, 로그아웃을 위해서는 blacklist 관련 메서드도 생성함

    ❗ 단순히 Access Tocken, Refresh Tocken 제거 방식으로 구현한다면,
    Access Tocken의 짧은 유효기간 사이에 이를 탈취당할 경우 로그아웃을 하였더라도 사용을 할 수 있는 문제가 발생한다 !
    ⠀⠀
    따라서 Access Tocken을 Blacklist로 저장하여
    로그아웃된 Access Tocken은 바로 만료시키는 기능을 구현하는 것이 좋다.
    ⠀⠀
    Blacklist 등록은 RedisTemplate에다 등록하려는 Access Token, object 값, 유효시간을 넣어준 후,
    Access Token을 받을때마다 Blacklist에 존재하는지 확인하면 된다.

  • redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));

    • redisTemplate.setValueSerializer()
      ➜ RedisTemplate를 사용할 때 Spring - Redis 간 데이터 직렬화, 역직렬화 시 사용하는 방식이 Jdk 직렬화 방식이므로 동작에는 문제가 없지만 redis-cli을 통해 직접 데이터를 보려고 할 때 알아볼 수 없는 형태로 출력되기 때문에 적용한 설정

    • new Jackson2JsonRedisSerializer<>(o.getClass())
      ➜ Redis에 객체를 저장할 때 직렬화해주기 위한 Serializer
      ( 여러 Serializer이 존재하지만 우리 프로젝트에서는 Jackson2JsonRedisSerializer를 사용하였다. )
      참고 - Class Jackson2JsonRedisSerializer< T >
      참고 - Spring RedisTemplate Serializer 구현체 종류

✔️ 직렬화 ( Serialize )

  • Java 시스템 내부에서 사용되는 Object 또는 Data를 외부의 Java 시스템에서도 사용할 수 있도록
    데이터를 Byte 형태로 변환하는 것
  • JVM의 메모리(힙/스택)에 상주되어있는 객체 데이터를 Byte 형태로 변환하는 것

✔️ 역직렬화 ( Deserialize )

  • Byte로 변환된 Data를 원래대로 Object나 Data로 변환하는 것
  • 직렬화된 Byte 형태의 데이터를 객체로 변환하여 JVM의 메모리에 상주시키는 형태

✔️ 직렬화 방법

  • opsForValue() ( 위 코드에서 사용 )
    ➜ String을 쉽게 Serialize/Deserialize 해주는 인터페이스
  • opsForList()
    ➜ List를 쉽게 Serialize/Deserialize 해주는 인터페이스
  • opsForSet()
    ➜ Set을 쉽게 Serialize/Deserialize 해주는 인터페이스
  • opsForZSet()
    ➜ ZSet을 쉽게 Serialize/Deserialize 해주는 인터페이스
  • opsForHash()
    ➜ Hash를 쉽게 Serialize/Deserialize 해주는 인터페이스
    [참고] https://devlog-wjdrbs96.tistory.com/375

✔️ Access Token
➜ 접근에 관여하는 토큰
➜ 유효기간이 짧음

✔️ Refresh Token
➜ Access Tocken의 재발급에 관여하는 토큰
➜ 유효기간이 Access Tocken에 비해 긺

✔️ 간단한 토큰 기반 로그인 / 로그아웃 과정

  • 사용자가 로그인하면,
    서버는 사용자를 확인하고 로그인을 성공시키면서 클라이언트에게 위 두 Tocken을 동시에 발급하는데,
    서버는 DB에 Refresh Tocken을 저장하고 캐시나 메모리에 Access Tocken을 저장,
    클라이언트는 Access Tocken과 Refresh Tocken을 쿠키, 세션 혹은 WebStorage에 저장한 후,
    보안과 관련된 요청이 있을 때 Access Tocken을 헤더에 담아 서버에 보냄

    만약 로그인이 되어있는 중 보안과 관련된 요청 시에 Access Tocken이 만료되었다면,
    클라이언트는 재발급을 위해 서버에 Refresh Tocken을 보내고
    서버는 보내진 Refresh Tocken을 DB에 있는 것과 비교하여 일치한다면 다시 Access Tocken을 재발급함
  • 사용자가 로그아웃하면,
    Access Tocken은 Blacklist에 추가하고
    Refresh Tocken은 저장소에서 삭제하여 사용이 불가능하도록 함
    새로 로그인 요청이 들어오면 서버에서 두 토큰을 다시 생성하여 클라이언트에게 보내주고
    위의 과정을 다시 처음부터 거치게 됨

    📌 Jwt 기반 로그인에 대한 자세한 개념들은 아래 포스팅을 참고해주세요.


5. 로그아웃 기능 정의

5-1 logout Controller 클래스

@PostMapping("/logout")
    public ResponseEntity logout(HttpServletRequest request , HttpServletResponse response){
        refreshService.logout(request, response);
        return ResponseEntity.ok().build();
    }

➜ 로그아웃 요청을 받는 Controller

5-2 logout Service 클래스

public void logout(HttpServletRequest request, HttpServletResponse response) {
        AuthToken accessToken = authTokenProvider.convertAuthToken(getAccessToken(request));
        //Access Token 검증
        if (!accessToken.validate()) throw new CustomLogicException(ExceptionCode.TOKEN_INVALID);
        String userEmail = accessToken.getTokenClaims().getSubject();
        long time = accessToken.getTokenClaims().getExpiration().getTime() - System.currentTimeMillis();
        //Access Token blacklist에 등록하여 만료시키기
        //해당 엑세스 토큰의 남은 유효시간을 얻음
        redisUtils.setBlackList(accessToken.getToken(), userEmail, time);
        //DB에 저장된 Refresh Token 제거
        refreshTokenRepository.deleteById(userEmail);
    }
}

➜ Controller에서 넘어와 로직 수행

➜ Access Tocke 검증 후 일치한다면, DB에 저장된 Refresh Tocken을 삭제하고 Blacklist에 Access Tocken을 등록


6. JwtVerificationFilter에 Blacklist에 존재하는 토큰인지 확인하는 과정 추가

public class JwtVerificationFilter extends OncePerRequestFilter {
   private final AuthTokenProvider tokenProvider;
   private final RedisUtils redisUtils;

   public JwtVerificationFilter(AuthTokenProvider tokenProvider, RedisUtils redisUtils) {
      this.tokenProvider = tokenProvider;
      this.redisUtils = redisUtils;
   }

   @Override
   protected void doFilterInternal(
           HttpServletRequest request,
           HttpServletResponse response,
           FilterChain filterChain)  throws ServletException, IOException {

      String tokenStr = HeaderUtil.getAccessToken(request);
      AuthToken token = tokenProvider.convertAuthToken(tokenStr);

      // 로그인 요청 시 들어온 Access Tocken이 Blacklist에 들어있는 Tocken인지 확인하는 검증
      // ( 로그아웃 된 토큰인지 아닌지 )
      if (token.validate() && !redisUtils.hasKeyBlackList(tokenStr)) {
         Authentication authentication = null;
         try {
             authentication = tokenProvider.getAuthentication(token);
         } catch (CustomLogicException e) {
            ErrorResponder.sendErrorResponse(response, HttpStatus.BAD_REQUEST);
         }
         SecurityContextHolder.getContext().setAuthentication(authentication);

      }

      filterChain.doFilter(request, response);
   }
   @Override
   protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
      String tokenStr = HeaderUtil.getAccessToken(request);
      return tokenStr == null; // (6-2)
   }
}

➜ 로그인 요청이 들어왔을 때, JwtVerificationFilter를 거쳐서 Tocken의 유효성을 검증하고,
유효한 Tocken이라면 SecurityContext에 인증 정보를 저장


여기까지 한다면 Redis를 활용한 JWT 기반 로그아웃 기능이 완성이다 !!

[ 참고한 사이트 ]

0개의 댓글