[사이드 프로젝트] 디지털 굿즈 플랫폼 개발기 #6 - Spring Security + JWT + Redis로 구축하는 강력한 인증 시스템 (1편: 설계와 설정)

회원가입 기능을 완성했으니, 이제 서비스의 대문인 로그인(인증) 시스템을 구축할 차례다. 단순히 "로그인이 된다"를 넘어, 보안성과 확장성을 고려하여 JWT(JSON Web Token) 방식을 채택했다.

특히 이번 구현에서는 보안 강화를 위해 RTR(Refresh Token Rotation) 방식을 도입하고, 성능 이슈를 해결하기 위해 Redis를 저장소로 선택했다. 


1. 왜 Refresh Token, Redis와 RTR인가?

Refresh Token

Access Token은 탈취 위험 때문에 수명을 짧게(30분) 가져간다. 대신 Refresh Token으로 토큰을 갱신한다. Refresh Token은 만료된 Access Token을 새로 발급받는 용도로 수명을 7~30일 혹은 더 길게 잡기도 한다.

Refresh Token을 사용하면 사용자가 자주 로그인을 하지 않아도 되도록 해 UX를 좋게하고 Access Token을 짧게 가져가 유출 피해를 감소할 수 있다.

하지만 Refresh Token이 유출되면 장기간 탈취 세션이 될 수 있다. 웹에서는 저장 위치 문제로 XSS와 결합하면 위험해진다.

RTR (Refresh Token Rotation)

만약 Refresh Token마저 탈취당한다면? 해커는 유효기간 동안 계속 Access Token을 만들어낼 수 있다.

이를 막기 위해 RTR을 도입했다. RTR = Refresh Token을 “1회용”처럼 쓰는 전략이다.

  • Refresh Token을 한 번 쓰면 무조건 폐기하고 새로 발급한다.
  • 직전 Refresh Token은 즉시 무효화 한다.
  • 만약 이미 사용된(폐기된) 토큰으로 재발급을 시도하면? "탈취되었다"라고 판단하고 해당 유저의 모든 토큰을 무효화시킨다

하지만 RTR을 적용하면 동시성 문제가 발생할 수 있으며 저장/검증 로직이 복잡해진다. 그럼에도 RTR을 도입한 이유는 Refresh Token은 길게 살아남는 ‘세션 키’라서, 유출 시 피해가 크다. RTR은 이 키를 ‘일회성’으로 만들고, 재사용 자체를 침해 신호로 바꿔서 대응력을 크게 올리기 때문이다.

Redis (In-Memory DB)

처음엔 Refresh Token을 RDBMS(PostgreSQL)에 저장하려 했다. 하지만 RTR 특성상 토큰 갱신 시마다 DB에 INSERT/UPDATE/DELETE가 빈번하게 발생한다. 디스크 I/O 부하가 우려되었다. 또한, 만료된 토큰을 삭제하기 위해 별도의 스케줄러를 돌려야 하는 번거로움이 있었다.

그래서 Redis로 노선을 변경했다.

  1. 속도: 메모리 기반이라 압도적으로 빠르다.
  2. TTL(Time To Live): 7일 뒤 삭제 설정만 하면 알아서 지워준다. (관리 비용 Zero)

2. 프로젝트 설정 (Dependencies & Infra)

Redis와 JWT 라이브러리를 추가했다.

dependencies {
    // ... 기존 의존성들 ...

    // 1. Spring Security & Validation
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

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

    // 3. JWT (0.11.5 버전)
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

Redis의 경우 기존에 PostgreSQL을 실행하던 docker-compose 에. 추가했다.

version: '3.8'
services:
  redis:
    image: redis:alpine
    container_name: <서비스>
    ports:
      - "6379:6379"
    command: redis-server --appendonly yes

JWT 비밀키와 만료 시간, Redis 연결 정보를 설정한다. 비밀키는 보안상 환경변수로 관리하는 것이 좋지만, 로컬 개발 편의를 위해 설정 파일에 기입했다. 이후 실제 배포를 하게 되면 일괄적으로 수정할 예정이다.

spring:
  data:
    redis:
      host: localhost
      port: 6379

jwt:
  # HS256 알고리즘을 사용하므로 32바이트(영문 32자) 이상이어야 함
  secret: #<난수키 사용>
  access-expiration: 1800000     # 30분 (1000 * 60 * 30)
  refresh-expiration: 604800000  # 7일 (1000 * 60 * 60 * 24 * 7)

secret에 사용할 비밀 키의 경우 터미널에서

openssl rand -base64 64

난수키를 생성해 사용한다.


3. Redis 저장소 구현

RefreshToken.java (Entity)

Redis에 저장될 객체다. @RedisHash를 사용하여 TTL을 설정한 것이 핵심이다.

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;

@Getter
@AllArgsConstructor
@Builder
// value: Redis Key의 prefix (refreshToken:사용자ID)
// timeToLive: 604800초 (7일) 후 자동 삭제
@RedisHash(value = "refreshToken", timeToLive = 604800)
public class RefreshToken {

    @Id // org.springframework.data.annotation.Id 사용
    private String id; // Key: 사용자 ID (email)

    @Indexed // 값으로 검색하기 위해 필요
    private String refreshToken; // Value: 실제 토큰 값

    public void updateRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }
}

RefreshTokenRepository.java

JPA가 아닌 CrudRepository를 상속받는다. 왜 JpaRepository가 아니라 CrudRepository인가?

보통 Spring Boot에서 DB를 다룰 때 JpaRepository를 습관적으로 사용하지만, Redis는 JPA 기술을 사용하지 않기 때문

  • 기술의 목적이 다르다.
    • JpaRepository: 관계형 데이터베이스(RDBMS)를 위한 JPA(ORM) 기술에 특화되어 있다. 페이징, 배치 처리, 영속성 컨텍스트(flush) 등 Redis에는 없는 기능들을 포함하고 있어 무겁고 맞지 않다.
    • CrudRepository: Spring Data의 최상위 인터페이스로, 가장 기초적인 CRUD(저장, 조회, 수정, 삭제) 기능만 정의되어 있다.
  • Redis의 특성 (Key-Value): Redis는 복잡한 쿼리나 관계 매핑이 필요 없는 Key-Value 저장소다. 단순히 put(저장), get(조회), delete(삭제)만 하면 되기 때문에, 가장 가볍고 직관적인 CrudRepository를 상속받는 것이다.
package com.creators.photocard.global.jwt.repository;

import com.creators.photocard.global.jwt.entity.RefreshToken;
import org.springframework.data.repository.CrudRepository;

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
    // CrudRepository가 기본 메서드(save, findById 등)를 제공함
}

4. JWT 핵심 로직 (Provider & Filter)

JwtTokenProvider.java

토큰을 생성하고, 검증하고, 정보를 꺼내는 "JWT 공장"이다. 

package com.creators.photocard.global.jwt;

import com.creators.photocard.global.jwt.dto.TokenResponse;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Slf4j
@Component
public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private final Key key;
    private final long accessTokenValidityTime;
    private final long refreshTokenValidityTime;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret,
                            @Value("${jwt.access-expiration}") long accessTokenValidityTime,
                            @Value("${jwt.refresh-expiration}") long refreshTokenValidityTime) {
        byte[] keyBytes = Decoders.BASE64.decode(secret); // 혹은 secret.getBytes()
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.accessTokenValidityTime = accessTokenValidityTime;
        this.refreshTokenValidityTime = refreshTokenValidityTime;
    }

    // 1. 토큰 생성 (Access & Refresh 동시 생성)
    public TokenResponse generateTokenDto(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + accessTokenValidityTime);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + refreshTokenValidityTime))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return new TokenResponse(BEARER_TYPE + " " + accessToken, refreshToken);
    }

    // 2. 인증 정보 조회
    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);

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

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    // 3. 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

JwtAuthenticationFilter.java

모든 API 요청을 가로채서 "헤더에 토큰이 있는지, 유효한지" 검사하는 경비원이다.

package com.creators.photocard.global.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    private final JwtTokenProvider jwtTokenProvider;

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

        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

5. 보안 설정 완료 (SecurityConfig)

SecurityConfig.java

마지막으로 이 모든 부품을 조립한다. 세션을 끄고(Stateless), 우리가 만든 JWT 필터를 끼워 넣는다.

package com.creators.photocard.global.config;

import com.creators.photocard.global.jwt.JwtAuthenticationFilter;
import com.creators.photocard.global.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            
            // 세션 미사용 (JWT 방식)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // 접근 권한 설정
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**", "/api/v1/members/signup").permitAll()
                .anyRequest().authenticated()
            )
            
            // JWT 필터 등록
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

기초 공사는 끝났다. 구현해 보면서 느낀 점은 역시 스프링은 복잡하다 였다. 알아야 할 것도 많고 코드도 많다. 전부 외우거나 해당되는 메서드나 클래스를 전부 안다는 가정에서 개발하려면 얼마나 많은 시간이 걸릴지 모르겠다. 그래서 이번에 잘 정리해 두고 만약 실제 사용하게 되면 꺼내 볼 목적으로 작성했다.

다음에는 실제 로그인과 토큰 재발급(RTR), 그리고 웹/앱을 동시에 지원하는 하이브리드 컨트롤러를 구현해 본다.