Spring Boot + MySQL 환경에서 Redis DB를 추가로 적용하여 RefreshToken 처리

Jongwon·2023년 5월 12일
2

DMS

목록 보기
18/18
post-thumbnail

이제 미루어왔던 Refresh Token의 처리를 진행할 차례입니다. Redis DB를 이용하여 만료시간을 DB가 알아서 관리하도록 처리하려고 합니다.

Redis

Redis는 <키, 값> 형태의 비정형 데이터를 저장하고 있는 non-relational DBMS입니다. 또한 모든 데이터를 메모리 내에서 처리하는 In-memory DBMS입니다.

Chat GPT가 알려주는 Redis의 장점은 아래와 같습니다.

  • High-performance: Redis is an in-memory database, which means it stores data in memory for fast access. This makes Redis extremely fast and efficient, and it can handle a large number of requests per second.

  • Data structures: Redis supports a wide range of data structures such as strings, hashes, sets, sorted sets with range queries, bitmaps, and hyperloglogs. This makes Redis versatile and enables it to handle a wide range of use cases.

  • Scalability: Redis is designed to scale horizontally, which means it can handle a large number of concurrent users and requests. It also supports clustering, which allows you to distribute data across multiple nodes.

  • Persistence: Redis allows you to persist data to disk, which means that even if the server crashes, you will not lose your data.

  • Pub/Sub messaging: Redis supports Pub/Sub messaging, which enables real-time messaging between different components of your application.

  • Transactions: Redis supports transactions, which enables you to group multiple commands into a single atomic operation.

  • Lua scripting: Redis supports Lua scripting, which allows you to extend Redis functionality by writing custom scripts.

  • Easy to use: Redis has a simple and easy-to-use API, which makes it easy to integrate with your application.

이중에서 현재 프로젝트에 적용하려는 이유만 설명드리자면, Redis는 메모리, 즉 캐쉬에서 동작하는 DBMS이기 때문에 속도가 굉장히 빠릅니다. mySQL처럼 DB에 접근해서 데이터를 받아오는 일련의 시간에 비해 빠른 속도로 데이터를 받아올 수 있기 때문에, refresh token을 빠르게 체크해야 하는 저희 프로젝트에 필요한 장점입니다.

또한 동시다발적인 접근에 대한 처리도 가능하기 때문에 많은 사용자가 로그인 및 토큰 재발급을 시도해도 과부하에 걸릴 가능성이 적습니다.

마지막으로 Spring Boot에서 Redis를 적용하는 방법이 매우 쉽기 때문에(spring-boot-starter-redis를 통해) 사용하고자 합니다.



Redis 설치

Redis를 설치해볼텐데, Local machine에서 설치하는 방법과 Docker에서 설치하는 방법 모두 진행해볼 예정입니다. 저번 게시글를 통해 앞으로 Docker Container에서 실행할 수 있기 때문에 아래에서 컨테이너를 연결하는 방식 역시 설명하겠습니다.

Local 환경에 설치

현재 저는 Windows 11 환경에서 프로젝트를 진행하고 있습니다. 하지만 Redis에 대한 Microsoft의 공식적인 지원은 16년 이후로 종료되었습니다.

MS Redis 아카이브
This project is no longer being actively maintained. If you are looking for a Windows version of Redis, you may want to check out Memurai. Please note that Microsoft is not officially endorsing this product in any way.
https://github.com/microsoftarchive/redis

따라서 설치를 위해서는 WSL을 설치해 리눅스 환경을 만든 후, ubuntu terminal로 Redis를 설치해야 합니다.

WSL은 이미 도커를 설치하면서 같이 설치되었을 것으로 가정하고 진행하겠습니다.


설치방법

  1. 윈도우에서 리눅스 터미널을 실행합니다.(여기서는 Ubuntu를 실행하였습니다.)
  2. sudo apt install lsb-release 명령어를 실행 후 linux로그인을 하여 설치를 진행합니다.
  3. curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg 명령을 실행합니다.
  4. echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list를 실행합니다.
  5. sudo apt-get update로 업데이트 항목을 받아옵니다.
  6. sudo apt-get install redis로 Redis를 설치합니다.

Redis 공식문서 설치 방법
https://redis.io/docs/getting-started/installation/install-redis-on-windows/

이제 설치가 완료되었습니다. 정상적으로 설치되었는지 확인하는 방법은 Linux 터미널에서 redis-cli를 쳐서 클라이언트를 실행해보면 확인할 수 있습니다.



Docker Image 다운로드

도커 이미지로 다운받는 방법은 더욱 간단합니다.
윈도우 cmd에서 docker pull redis:[원하는 버전]을 실행하시면 됩니다. 저는 최신버전을 다운받을 예정이므로 아래와 같이 작성하였습니다.

docker pull redis



Redis 프로젝트에 적용

Spring Boot 프로젝트에 적용하는 법은 간단합니다. Gradle Dependency가 이미 존재하기 때문입니다. build.gradle에 아래의 코드를 추가합니다.

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-redis', version: '3.0.6'

아래의 사이트에서 최신 버전을 찾아 적용하셔도 상관없습니다. 다만 Redis 버전을 지원하는 Dependency인지만 확인해주시면 됩니다.
https://mvnrepository.com/artifact/org.springframework.data/spring-data-redis/3.0.5


다음으로 Redis를 Refresh Token에만 적용하고, 이외의 다른 엔티티는 원래대로 mySQL에 사용할 것이기 때문에 기존의 코드에서 수정이 필요합니다.

RefreshToken

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 1000L)
public class RefreshToken {

    @Id
    private String token;

    private String memberId;

    @Builder
    public RefreshToken(String memberId, String token) {
        this.memberId = memberId;
        this.token = token;
    }
}

여기서 @Id 어노테이션의 import문을 주의있게 확인해주세요. 기존의 JPA 엔티티의 어노테이션은 Lombok에서 제공하지만, Redis에 적용할 ID는 data.annotation.id를 import해야 합니다.

RefreshTokenRepository

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> { }

Repository는 기존의 JpaRepository와 동일하게 사용할 수 있습니다.


JwtTokenProvider

토큰을 생성하고 유효성을 확인하는 클래스입니다.

@Component
@Log4j2
public class JwtTokenProvider {

    private final Key encodedKey;
    private static final String BEARER_TYPE = "Bearer";

    private final Long accessTokenValidationTime = 30 * 60 * 1000L;  //30분
    private final Long refreshTokenValidationTime = 1 * 60 * 60 * 1000L;  //1시간

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Base64.getDecoder().decode(secretKey);
        this.encodedKey = Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * accessToken과 refreshToken을 생성함
     * @param subject
     * @return TokenDTO
     * subject는 Form Login방식의 경우 userId, Social Login방식의 경우 email
     */
    public TokenDTO createTokenDTO(String subject, List<Role> roles) {

        //권한을 하나의 String으로 합침
        String authority = roles.stream().map(Role::getType).collect(Collectors.joining(","));

        //토큰 생성시간
        Instant now = Instant.from(OffsetDateTime.now());

        //accessToken 생성
        String accessToken = Jwts.builder()
                .setSubject(subject)
                .claim("roles", authority)
                .setExpiration(Date.from(now.plusMillis(accessTokenValidationTime)))
                .signWith(encodedKey)
                .compact();

        //refreshToken 생성
        String refreshToken = Jwts.builder()
                .setExpiration(Date.from(now.plusMillis(refreshTokenValidationTime)))
                .setSubject(subject)
                .signWith(encodedKey)
                .compact();

        //TokenDTO에 두 토큰을 담아서 반환
        return TokenDTO.builder()
                .tokenType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .duration(Duration.ofMillis(refreshTokenValidationTime))
                .build();
    }

    /**
     * UsernamePasswordAuthenticationToken으로 보내 인증된 유저인지 확인
     * @param accessToken
     * @return Authentication
     * @throws ExpiredJwtException
     */
    public Authentication getAuthentication(String accessToken) throws ExpiredJwtException {
        Claims claims = Jwts.parserBuilder().setSigningKey(encodedKey).build().parseClaimsJws(accessToken).getBody();

        if(claims.get("roles") == null) {
            throw new RuntimeException("권한정보가 없는 토큰입니다.");
        }

        Collection<? extends GrantedAuthority> roles = Arrays.stream(claims.get("roles").toString().split(",")).map(SimpleGrantedAuthority::new).collect(Collectors.toList());
        UserDetails user = new User(claims.getSubject(), "", roles);
        return new UsernamePasswordAuthenticationToken(user, "", roles);
    }

    public Authentication checkRefreshToken(String refreshToken) throws ExpiredJwtException {
        Claims claims = Jwts.parserBuilder().setSigningKey(encodedKey).build().parseClaimsJws(refreshToken).getBody();

        UserDetails user = new User(claims.getSubject(), "", null);
        return new UsernamePasswordAuthenticationToken(user, "", null);
    }

    public boolean tokenMatches(String accessToken, String refreshToken) {
        Claims accessTokenClaim = Jwts.parserBuilder().setSigningKey(encodedKey).build().parseClaimsJws(accessToken).getBody();
        Claims refreshTokenClaim = Jwts.parserBuilder().setSigningKey(encodedKey).build().parseClaimsJws(refreshToken).getBody();

        if(accessTokenClaim.getSubject().equals(refreshTokenClaim.getSubject()))
            return true;

        return false;
    }

    public int validateToken(String token) {
        try {
            //access token
            Claims claims = Jwts.parserBuilder().setSigningKey(encodedKey).build().parseClaimsJws(token).getBody();
            if (claims.containsKey("role")) {
                return 1;
            }

            //refresh token
            return 0;

        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            throw new RuntimeException("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            throw new RuntimeException("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            throw new RuntimeException("JWT 토큰이 잘못되었습니다.");
        }
    }
}
인증 시 1이면(token에 role 내용도 있다는 것은 access token) access token, 0이면 refresh token을 의미합니다.


JwtRequestFilter

@Component
@RequiredArgsConstructor
@Log4j2
public class JwtRequestFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private static final String BEARER_PREFIX = "Bearer";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String jwt = resolveToken(request);

        if(jwt != null) {
            int validation = jwtTokenProvider.validateToken(jwt);
            if(validation == 1) {
                Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            else if(validation == 0) {
                SecurityContextHolder.getContext().setAuthentication(jwtTokenProvider.checkRefreshToken(jwt));
            }
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String token = request.getHeader("Authorization");
        if(StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
            return token.substring(7);    //"Bearer "를 뺀 값, 즉 토큰 값
        }

        return null;
    }
}

token validation에 결과에 따른 Security Filter 통과방식을 다르게 하였습니다.



중요
이제 MySQL과 연결된 JPA Repository로부터 Redis Repository를 분리하는 작업을 진행하겠습니다.

DmsApplication(가장 상단의 main 클래스)

@SpringBootApplication
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "uos.capstone.dms", excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {RefreshTokenRepository.class}))
public class DmsApplication {

	public static void main(String[] args) {
		SpringApplication.run(DmsApplication.class, args);
	}
}

@EnableJpaRepositories를 이용하여 Jpa를 적용할 패키지를 지정합니다. 여기서 exclude 옵션을 주어 RefreshTokenRepository는 적용하지 않도록 합니다.


RedisConfig

Redis 설정파일을 새로 생성합니다.

@Configuration
@EnableRedisRepositories(basePackages = "uos.capstone.dms.repository", includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {RefreshTokenRepository.class}))
public class RedisConfig {

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisHost, 6379));
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory());
        template.setKeySerializer(new StringRedisSerializer());
        return template;
    }
}

여기서 Local과 Docker 환경에 따라 port번호가 달라지기 때문에 port를 환경변수로 지정하였습니다. application.yml에 아래의 환경변수를 추가합니다.

spring:
  redis:
    host: localhost
    port: 6379



만약 앞에서부터 따라오시던 분들이라면

기존의 코드가 수정되었는데 아래의 내용으로 수정하시면 됩니다.

TokenService

@Service
@RequiredArgsConstructor
public class TokenService {

    private final JwtTokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final MemberRepository memberRepository;

    public TokenDTO createToken(MemberDTO memberDTO) {
        TokenDTO tokenDTO = tokenProvider.createTokenDTO(memberDTO.getUserId(), memberDTO.getRoles());
        Member member = memberRepository.findByUserId(memberDTO.getUserId()).orElseThrow(() -> new RuntimeException("Wrong Access (member does not exist)"));
        RefreshToken refreshToken = RefreshToken.builder()
                .memberId(member.getId())
                .token(tokenDTO.getRefreshToken())
                .build();

        refreshTokenRepository.save(refreshToken);

        return tokenDTO;
    }

    public TokenDTO createToken(Member member) {
        TokenDTO tokenDTO = tokenProvider.createTokenDTO(member.getUserId(), member.getRoles());
        RefreshToken refreshToken = RefreshToken.builder()
                .memberId(member.getId())
                .token(tokenDTO.getRefreshToken())
                .build();

        refreshTokenRepository.save(refreshToken);

        return tokenDTO;
    }

    public TokenDTO regenerateToken(String refreshToken, String userId) {
        refreshTokenRepository.deleteById(refreshToken);
        return createToken(memberRepository.findById(userId).get());

    }
}

ApiController

@Log4j2
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiController {

    private final TokenService tokenService;
    private final OAuth2UserService oAuth2UserService;
    private final JwtTokenProvider jwtTokenProvider;

    @Operation(summary = "토큰 갱신")
    @PostMapping("/refreshToken")
    public ResponseEntity<String> refreshToken(HttpServletRequest request, @RequestBody String accessToken) {
        String refreshToken = request.getHeader("Authorization").substring(7);

        if(!jwtTokenProvider.tokenMatches(accessToken, refreshToken)) {
            return ResponseEntity.badRequest().body("두 토큰의 소유주가 일치하지 않습니다.");
        }

        TokenDTO tokenDTO = tokenService.regenerateToken(refreshToken, SecurityUtil.getCurrentUsername());
        ResponseCookie responseCookie = ResponseCookie
                .from("refresh_token", tokenDTO.getRefreshToken())
                .httpOnly(true)
                .secure(true)
                .sameSite("None")
                .maxAge(tokenDTO.getDuration())
                .path("/")
                .build();

        return ResponseEntity.ok()
                .header("Set-Cookie", responseCookie.toString())
                .header("Authorization", "Bearer " + tokenDTO.getAccessToken()).build();
    }

...

토큰 갱신 시 클라이언트로부터 Authorization 헤더에 refreshToken을, Body에 accessToken을 받아 2번의 인증을 진행할 것입니다. 첫번째로 존재하는 유효한 refreshToken을 보냈는지, 두번째로 탈취한 것이 아님을 확인하기 위해 만료된 accessToken을 받습니다.


MemberController

    @Operation(summary = "로그인")
    @PostMapping("/login")
    public ResponseEntity<String> memberLogin(@ModelAttribute IdPasswordDTO idPasswordDTO) {
        log.info(idPasswordDTO);
        TokenDTO tokenDTO = memberService.login(idPasswordDTO);
        ResponseCookie responseCookie = ResponseCookie
                .from("refresh_token", tokenDTO.getRefreshToken())
                .httpOnly(true)
                .secure(true)
                .sameSite("None")
                .maxAge(tokenDTO.getDuration())
                .path("/")
                .build();

        return ResponseEntity.ok()
                .header("Set-Cookie", responseCookie.toString())
                .header("Authorization", "Bearer " + tokenDTO.getAccessToken()).build();
    }

로그인시에도 Authorization헤더에 Access Token을 보내고, Cookie에 Refresh Token을 보내는 방식으로 변경하였습니다.



여기까지 진행하시고 실행하시면 Local에서 적용가능한 Redis DB가 가동됩니다. 테스트로 로그인을 진행해보겠습니다.

성공이 뜬 후, redis-cli를 통해 정말로 잘 저장이 되었는지 확인해보겠습니다.

잘 저장된 것을 확인할 수 있습니다.



Docker-Compose를 이용한 컨테이너 환경에서 실행 시

여태 진행한 방식에서 설정만 조금 변경하면 컨테이너에서 실행할 수 있습니다.

기존에 docker-compose로 컨테이너를 만드셨다면 먼저 컨테이너를 삭제해야 합니다.
docker-compose down


docker-compose 파일을 수정하겠습니다.

docker-compose.yml

version: "3"
services:
  8amDB:
    container_name: 8amDB
    image: mysql:8.0.32
    restart: always
    volumes:
      - mysql_volume:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=****
      - MYSQL_DATABASE=dms
      - MYSQL_USER=dms_admin
      - MYSQL_PASSWORD=****
    ports:
      - "3306:3306"
    networks:
      - 8am-net
    healthcheck:
      test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
      timeout: 10s
      retries: 10

  8amServer:
    container_name: 8amServer
    ports:
      - "8080:8080"
    image: 8am_server
    volumes:
      - 8am_images:/app/8am/images
    networks:
      - 8am-net
    depends_on:
      8amDB:
        condition: service_healthy
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://8amDB:3306/dms
      SPRING_REDIS_HOST: 8amRedisDB                    //여기서 Redis host를 redisDB 컨테이너로 변경
      servlet_multipart_location: /app/8am/images

  8amRedisDB:                                        //Redis 컨테이너 생성
    container_name: 8amRedisDB
    command: redis-server --port 6379
    ports:
      - "6379:6379"
    image: redis
    volumes:
      - redis_volume:/data
    networks:
      - 8am-net

networks:
  8am-net:
    driver: bridge
volumes:
  mysql_volume:
    driver: local
  8am_images:
    driver: local
  redis_volume:                                        //Redis 볼륨 생성
    driver: local

이전 글에서 설명했던 부분은 제외하고, Redis 관련 변경사항만 주석으로 처리하였습니다.

서버 이미지를 다시 만든 후, docker-compose up을 진행하시면 도커 환경에서도 정상적으로 실행되는 것을 확인하실 수 있습니다.

도커에서 실행 시엔 컨테이너에서 8amRedisDB > Terminal에서 위에서 했던 우분투 터미널 명령어를 그대로 실행하실 수 있습니다.






참고자료

https://luvstudy.tistory.com/143
https://velog.io/@zzarbttoo/CacheRedis-Springboot
https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/
https://redis.com/blog/jedis-vs-lettuce-an-exploration/
https://velog.io/@rnqhstlr2297/Redis%EB%A5%BC-Docker-Compose%EB%A1%9C-%EC%8B%A4%ED%96%89%ED%95%98%EA%B8%B0
https://batory.tistory.com/463
https://pearlluck.tistory.com/727
https://bcp0109.tistory.com/329
https://earthlyz9-dev.oopy.io/docker/docker-compose-redis
https://velog.io/@yoojkim/Spring-Boot-Redis-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
https://do-study.tistory.com/m/126
https://velog.io/@yoojkim/Spring-Boot-Redis-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
https://jronin.tistory.com/126
https://velog.io/@ililil9482/spring-boot%EC%97%90-Redis-%EB%B6%99%EC%97%AC%EB%B3%B4%EA%B8%B0

profile
Backend Engineer

0개의 댓글