article thumbnail image
Published 2023. 6. 23. 20:58

yata project에서는 Github에서 클론받자마자 바로 사용할 수 있는 환경을 맞추기 위해

 

외부환경에 의존X =>

외부 캐시 JWT 로그아웃 구현을 위해 redis를 사용

외부환경에 구속받지 않기 위해 embedded redis 사용

 

다음 세 가지를 사용

 

📌Spring Data Redis
Redis를 JPA Repository를 이용하듯이 인터페이스를 제공하는 스프링 모듈
CrudRepository를 지원하기 때문에 좀 더 직관적으로 사용 가능

📌Lettuce

Redis Java Client

현재 (Spring Boot 2.0.2) Spring Data Redis에서 공식지원하는 Client

📌Embedded Redis

H2와 같은 내장 Redis 데몬

이를 이용하면 외부 Redis 서버를 설치하고 구성할 필요 없이 애플리케이션 내에서 Redis를 실행 가능

 

 

 

설정

build.gradle 의존성 추가

-Embedded Redis 사용을 위한 설정

-Redis 사용을 위한 설정

 

 

application.yml설정까지 해줌

캐시 타입 레디스 설정

레디스 연결에 필요한 호스트, 포트번호, pw 지정

 

 

그리고 Config 파일을 2개를 생성

 

 

내장 서버로 환경을 구성할 것이기 때문에 EmbeddedRedis의 설정을 따로 해주는 것!

내장 Redis를 사용할 때 설정 파일의 prifile이 local일 때만 동작하도록 @profile어노테이션을 사용한다.

 

 

EmbeddedRedisConfig
내장 서버로 환경을 구성하기 위해 profile 설정을 local로 해주었다.

@Slf4j
@Profile("!prod") //profile이 prod가 아닐때만 활성화
@Configuration
public class LocalRedisConfig {
   @Value("${spring.redis.port}")
   private int redisPort;

   private RedisServer redisServer;

   @PostConstruct
   public void redisServer() throws IOException {
       int port = isRedisRunning() ? findAvailablePort() : redisPort; //Redis 서버가 이미 실행 중인지 확인하고, 실행 중이라면 사용 가능한 다른 포트를 찾아서 port 변수에 할당하고, 실행 중이 아니라면 redisPort 변수의 값을 사용
       /*현재 시스템이 ARM 아키텍처인지 확인 
       만약 ARM 아키텍처라면, 
       RedisServer 클래스를 사용하여 Redis 서버를 생성 
       그렇지 않으면, RedisServer.builder()를 사용하여 Redis 서버를 생성*/
       if (isArmArchitecture()) {
           System.out.println("ARM Architecture");
           redisServer = new RedisServer(Objects.requireNonNull(getRedisServerExecutable()), port);
       } else {
           redisServer = RedisServer.builder()
                   .port(port)
                   .setting("maxmemory 128M")
                   .build();
       }
		//전 단계에서 생성한 Redis 서버 객체를 실행
       redisServer.start();
   }

   @PreDestroy
   public void stopRedis() {
       if (redisServer != null) {
           redisServer.stop();
       }
   }
   }

문제! ) 프로그램 아키텍쳐에 따라 실행파일이 다름

embedded redis는 ARM 프로세서 아키텍처에서 실행되는 것을 지원하지 않는다.

따라서 해당 환경에서는 test빌드가 되지 않음
그러나 RestDocs 사용을 위해서는 test빌드가 필수였고,

Arm 아키텍쳐라면 Redis 실행 파일과 포트번호를 넣은 Redis서버를 생성함으로서 문제를 해결하였다.

 

 

직접 바이너리를 지정해 사용

$ wget <https://download.redis.io/releases/redis-6.0.10.tar.gz>

1. 레디스 다운로드

$ tar -xzf redis-6.0.10.tar.gz

2. 다운받은 레디스 파일의 압축해제

 

$ cd redis-6.0.10

3. 압축을 해제한 레디스 디렉토리로 이동

 

$ make

4. 레디스 컴파일(make - 소스코드에서 실행 파일을 만드는 명령어)

 

$ src/redis-server

5. 레디스 서버 시작

 

6. src/redis-server에 생성된 바이너리의 이름을 변경하여 아래 프로젝트 경로에 추가

src/main/resource/binary/redis/{redis mac arm 바이너리 파일}

 

7. 아키텍처에 따라 다르게 실행될 수 있도록 LocalRedisConfig클래스에 설정 추가

 if (isArmArchitecture()) {
           System.out.println("ARM Architecture");
           redisServer = new RedisServer(Objects.requireNonNull(getRedisServerExecutable()), port);
       } else {
           redisServer = RedisServer.builder()
                   .port(port)
                   .setting("maxmemory 128M")
                   .build();
       }
		//전 단계에서 생성한 Redis 서버 객체를 실행
       redisServer.start();
   }

 

 

 

 

 

 

 

 

그러나 이렇게만 작성하면

test 환경에서 redis 테스트 할 때 문제가 발생

spiringBootTest 같이 전역 테스트를 할 때 각각의 테스트 클래스 마다 redis를 띄우게 된다.

이때 redis 서버가 죽기전에 다음 테스트 클래스가 띄워지면

이전에 띄워진 redis 서버가 아직 살아있기 때문에 port 충돌로 인해 테스트가 실패하게 된다.

 

 

 

✏️따라서 해당 포트가 미사용 중일때만 사용하고, 사용중이면 그 외 다른 포트를 사용하도록 추가 설정! 

LocalRedisConfig에 다음 코드 추가!

   /**
    * Embedded Redis가 현재 실행중인지 확인
    */
   private boolean isRedisRunning() throws IOException {
       return isRunning(executeGrepProcessCommand(redisPort));
   }

   /**
    * 현재 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");
       }
   }

}

 

 

 

 

Bean 등록

redis의 연결을 정의 RedisConnectionFactory를 통해 내장 혹을 외부의 Redis를 연결

우리 프로젝트의 경우 내장 Redis

 

RedisTemplate를 통해 RedisConnection에서 넘겨준 byte값을 객체 직렬화

@Configuration
@EnableRedisRepositories
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;

   @Bean
   public RedisConnectionFactory redisConnectionFactory() {
       RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
       redisStandaloneConfiguration.setHostName(redisHost);
       redisStandaloneConfiguration.setPort(redisPort);
       // redis password 설정
       Arrays.stream(environment.getActiveProfiles()).forEach(profile -> {
           if (profile.equals("prod")) {
               redisStandaloneConfiguration.setPassword(redisPassword);
           }
       });
       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() {
       RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
       RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
               .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
               .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
               .prefixCacheNameWith("cache:") // Key Prefix로 "Test:"를 앞에 붙여 저장
               .entryTtl(Duration.ofMinutes(30)); // 캐시 수명 30분
       builder.cacheDefaults(configuration);

       return builder.build();
   }
}

✏️RedisConnectionFactory를 통해 내장 혹은 외부의 Redis를 연결

✏️RedisTemplate을 통해 RedisConnection에서 넘겨준 byte 값을 객체 직렬화

✏️RedisCacheManagerBuilder를 사용하여 RedisConnectionFactory를 설정하고, RedisCacheConfiguration을 구성.
여기서는 Redis의 키와 값의 직렬화 방식을 설정하고, 캐시의 이름에 "cache:" 접두사를 붙이고,
캐시의 유효기간을 30분으로 설정한 후
RedisCacheManager를 반환

 

RedisConnedcinoFactory 인터페이스 하위 클래스에는

LettuceConnectinoFactory, JedisConnectinoFactory 두 가지가 있는데 ,

Lettuce가 Jedis에 비해 몇 배 이상의 성능과 하드웨어 자원 절약이 가능하므로 우리 프로젝트에서는 

Lettuce를 사용하였따. 

 

 

 

좀 더 자세한 설명은 요기
 
스프링 데이터 레디스

 

 

 

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와 상호작용하기 위한 편리한 메서드를 제공

값 설정, 값 가져오기, 값 삭제, 키의 존재 여부 확인, 모든 키 삭제 등의 기능을 제공한다.

 

 

redis에 객체(dto)를 저장할 때 serializer를 통해 직렬화해주어야 한다.
이 때, 선택할 수 있는 여러가지 직렬화 방법이 존재한다.

메서드 설명
opsForValue String을 쉽게 Serialize/Deserialize 해주는 인터페이스
opsForList List를 쉽게 Serialize/Deserialize 해주는 인터페이스
opsForSet Set을 쉽게 Serialize/Deserialize 해주는 인터페이스
opsForZSet ZSet을 쉽게 Serialize/Deserialize 해주는 인터페이스
opsForHash Hash를 쉽게 Serialize/Deserialize 해주는 인터페이스

 

 

 

직렬화란?

객체의 직렬화는 객체의 내용을 바이트 단위로 변환하여 파일 또는 네트워크를 통해서 스트림(송수신)이 가능하도록 하는 것을 의미한다. 

자바의 I/O 처리는, 정수, 문자열 바이트 단위의 처리만 지원했었다. 따라서 복잡한 내용을 저장/복원 하거나, 네트워크로 전송하기 위해서는 객체의 멤버변수의 각 내용을 일정한 형식으로 만들어(이것을 패킷이라고 한다) 전송해야 했다.

객체직렬화는 객체의 내용(구체적으로는 멤버변수의 내용)을 자바 I/O가 자동적으로 바이트 단위로 변환하여, 저장/복원하거나 네트워크로 전송할 수 있도록 기능을 제공해준다. 즉 개발자 입장에서는 객체가 아무리 복잡하더라도, 객체직렬화를 이용하면 객체의 내용을 자바 I/O가 자동으로 바이트 단위로 변환하여 저장이나 전송을 해주게 된다. 

직렬화의 장점
객체 내용을 입출력형식에 구애받지 않고 객체를 파일에 저장함으로써 영속성을 제공한다.
객체를 네트워크를 통해 손쉽게 교환할 수 있다.

 

jwt기반 사용자 인증 중 logout을 구현해야하는데


단순히 DB의 Refresh Token을 제거 하는 방식으로 구현하면

Access Token의 유효기간이 살아있을 때 누군가가 탈취하여 로그아웃을 하였더라도 사용할 수 있는 문제가 발생한다.

 

따라서 ⭐Access Token을 Redis의 Blacklist로 저장하여 만료⭐시키는 기능을 구현하는 것이 좋다!

 

Blacklist 등록은 RedisTemplate에다 등록하려는 Access Token, object 값, 유효시간을 넣어주면 된다.
그 후 Access Token을 받을때마다 Blacklist에 존재하는지 확인하면 된다.

 

🔎Access Token
접근에 관여하는 토큰, 짧은 유효기간
🔎Refresh Token
재발급에 관여하는 토큰, 비교적 긴 유효기간

로그인을 했을 때, 서버는 로그인을 성공시키면서 클라이언트에게 
Access Token과 Refresh Token을 동시에 발급한다.  

서버는 데이터베이스에 Refresh Token을 저장하고,

클라이언트는 Access Token과 Refresh Token을 쿠키, 세션 혹은 웹스토리지에 저장하고 요청이 있을때마다 이 둘을 헤더에 담아서 보낸다.

Refresh Token은 긴 유효기간을(약 2주) 가지면서, Access Token이 만료됐을 때 새로 재발급해주는 열쇠가 된다.
따라서 Access 토큰이 만료되면.
서버는 클라이언트가 보낸 Refresh Token을  DB에 있는 것과 비교해서 일치하면 다시 Access Token을 재발급

사용자가 로그아웃을 하면 저장소에서 Refresh Token을 삭제하여 사용이 불가능하도록 하고 새로 로그인하면 서버에서 다시 재발급해서 DB에 저장한다.

 

 

로그아웃 기능

in RefreshService

    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);
    }
}

DB에 저장된 RefreshToken을 삭제하고
Blacklist에 Access Token을 등록하게 된다.

 

 

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);
//기존 Jwt 검증을 하는 부분에서 Blacklist에 추가된 Token인지 확인하고 검증
      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를 거쳐서
토큰의 유효성(블랙리스트에 없는지)을 검증하고, 유효한 토큰이라면 SecurityContext에 인증 정보를 저장

 

 

 

 

 

https://ozofweird.tistory.com/entry/Spring-Boot-Redis-Lettuce%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B0%84%EB%8B%A8%ED%95%9C-API-%EC%A0%9C%EC%9E%91

https://medium.com/@lunay0ung/basics-%EC%A7%81%EB%A0%AC%ED%99%94-serialization-%EB%9E%80-feat-java-2f3eb11e9a8

복사했습니다!