본문 바로가기
Java & Spring

Spring Security 6: JWT 로그인/리프레시 토큰

by yamoojin83 2025. 10. 8.

Spring Security 6: JWT 로그인/리프레시 토큰 실무 가이드

제가 운영하던 서비스에서 가장 크게 데였던 사건 중 하나는

“리프레시 토큰 만료 정책을 잘못 잡아서 새벽에 전체 로그인 기능이 멈췄던 일”입니다.

코드만 보면 멀쩡해 보였는데, 만료 시간·Redis 설정·클라이언트 재발급 로직이 서로 어긋나면서
수천 건의 로그인 실패 로그가 한꺼번에 쏟아졌습니다.

그래서 이 글은 Spring Boot 3 + Spring Security 6 기준으로
Access Token + Refresh Token 구조를 구현하는 동시에,
실제 운영에서 경험했던 이슈와 설계 선택 이유까지 정리한 기록입니다.

단순 코드 나열 튜토리얼이 아니라,

“왜 이런 구조를 썼는지, 운영에서 어떤 문제가 생겼고 어떻게 막았는지”를 중심으로 설명합니다.

이 글을 보고 JWT 로그인을 구현하시는 분들이
저처럼 새벽에 로그인 장애로 고생하지 않기를 바랍니다.



 

Spring Security 6 JWT 로그인 리프레시 토큰 전체 아키텍처 흐름도



1. Access + Refresh 토큰 전략, 실제로 써보면 어떤 차이가 있을까?

JWT 로그인은 겉으로 보면 “토큰 하나 발급해서 헤더에 넣고 다니면 끝”처럼 보입니다.

하지만 실서비스를 돌려보면 Access만 쓰는 구조
Access + Refresh를 함께 쓰는 구조의 차이가 꽤 크게 느껴집니다.

1-1. Access Token만 쓰던 시절의 문제들

  • 토큰 탈취 시 즉시 무효화가 어렵고, 결국 키 교체 + 전체 재로그인으로 수습
  • 만료 시간을 길게 잡으면 보안이 불안하고, 짧게 잡으면 사용자 불편이 심해짐
  • 모바일 앱에서는 조용히 만료됐다가 갑자기 “로그인이 풀렸다”는 문의가 폭주

1-2. Access + Refresh 전략 도입 후

  • Access는 짧게(예: 15분) → 탈취돼도 피해 최소화
  • Refresh는 길게(예: 14일) + 서버 저장소(DB/Redis)에 보관
  • Refresh를 활용해 자동 재발급강제 로그아웃 제어 가능
  • 모바일/SPA에서 만료 시 조용히 재발급 → 사용자 경험(UX) 개선

즉 이 구조는 단순히 “요즘 많이 쓰니까 쓰는 패턴”이 아니라,

보안과 UX 사이를 조율하기 위한 현실적인 타협안에 가깝습니다.



2. 전체 흐름 한 번에 보기

이 글에서 구현하는 구조를 단순화하면 다음 그림과 같습니다.

[클라이언트]
   │
   ├─ /auth/login        → 아이디/비밀번호로 Access + Refresh 발급
   ├─ /auth/refresh      → Refresh로 Access + 새 Refresh 재발급(회전)
   ├─ /auth/logout       → Refresh 폐기(서버 저장소에서 삭제)
   │
   └─ /me 등 보호 API    → Authorization: Bearer <access> 로 접근

아래부터는 실제 코드와 함께 각 단계에서 주의할 점을 정리해 보겠습니다.



3. 의존성 추가 (Gradle)

// build.gradle.kts 기준
dependencies {
  implementation("org.springframework.boot:spring-boot-starter-web")
  implementation("org.springframework.boot:spring-boot-starter-security")
  implementation("org.springframework.boot:spring-boot-starter-validation")

  // JWT (Nimbus)
  implementation("org.springframework.security:spring-security-oauth2-jose")

  // 예제용 JPA/H2
  implementation("org.springframework.boot:spring-boot-starter-data-jpa")
  runtimeOnly("com.h2database:h2")
}

JWT는 직접 파싱/서명을 구현할 수도 있지만,

운영 환경에서 예외 케이스(만료, 시계 오차, 서명 알고리즘 등)를 다 다루려면
spring-security-oauth2-jose를 사용하는 것이 훨씬 안전합니다.



4. 설정(yaml)과 키 관리 팁

app:
  jwt:
    secret: "replace-with-long-random-secret-64bytes-at-least"
    access-minutes: 15
    refresh-days: 14

여기서 실제 운영에서 제일 많이 실수하는 부분이 바로 secret 관리입니다.

제가 겪었던 실수들:

  • 로컬·개발·운영이 모두 같은 키를 쓰면서, 테스트용 계정 토큰이 운영에 그대로 통함
  • 키를 코드에 하드코딩했다가, Git에 올라간 뒤에야 발견
  • 서버 재시작 시 값이 바뀌는 환경 변수를 사용해
    “재시작 후 모든 토큰이 무효화” 되는 사태

그래서 지금은 이런 원칙을 지킵니다.

1) 운영 키는 최소 64바이트 이상 랜덤 값
2) 환경 변수 / Parameter Store / Secret Manager 등에만 저장
3) 로컬·개발·운영 키를 철저하게 분리



5. JwtProvider – HS256 기반 구현과 검증 로그

이제 실제로 토큰을 발급·검증하는 JwtProvider입니다.
여기서는 HMAC(HS256) 예제로 설명하지만, 필요에 따라 RSA/EC 공개키 방식으로 변경 가능합니다.

@Component
@RequiredArgsConstructor
public class JwtProvider {

  private final AppJwtProps props;
  private final JwtEncoder encoder;
  private final JwtDecoder decoder;

  public String createAccess(String subject, Map<String, Object> claims) {
    Instant now = Instant.now();
    JwtClaimsSet claimSet = JwtClaimsSet.builder()
        .subject(subject)
        .issuedAt(now)
        .expiresAt(now.plus(Duration.ofMinutes(props.accessMinutes())))
        .claims(c -> c.putAll(claims))
        .build();
    return this.encoder.encode(JwtEncoderParameters.from(claimSet)).getTokenValue();
  }

  public String createRefresh(String subject, String tokenId) {
    Instant now = Instant.now();
    JwtClaimsSet claims = JwtClaimsSet.builder()
        .subject(subject)
        .id(tokenId) // 재발급/폐기 식별용
        .issuedAt(now)
        .expiresAt(now.plus(Duration.ofDays(props.refreshDays())))
        .claim("typ", "refresh")
        .build();
    return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
  }

  public Jwt verify(String token) {
    return this.decoder.decode(token); // 만료/서명 자동 검증
  }
}

운영하다 보면 verify 한 줄에서 굉장히 많은 로그가 터집니다.

대표적으로:

JwtException: JWT expired at ...
JwtException: Invalid signature
JwtException: Invalid JWT serialization

이 로그가 공격인지, 단순히 오래된 토큰인지,
아니면 모바일 기기의 시계가 틀어진 것인지 파악하는 게 중요합니다.

실제로 특정 기기에서만 유난히 만료 예외가 많이 발생해서 확인해 보니,
단말기 시간이 5분 이상 뒤로 밀려 있던 적도 있었습니다.

 

6. SecurityFilterChain – Stateless 구조로 만들기

이제 Spring Security 6의 SecurityFilterChain 설정입니다.
핵심 키워드는 “세션 없는(stateless) 구조”입니다.

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

  private final AppJwtProps props;

  @Bean
  public SecurityFilterChain http(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(sm ->
            sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(reg -> reg
            .requestMatchers("/auth/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
    return http.build();
  }

  @Bean
  JwtEncoder jwtEncoder() {
    SecretKey key = new SecretKeySpec(
        props.secret().getBytes(StandardCharsets.UTF_8),
        "HmacSHA256");
    return new NimbusJwtEncoder(new ImmutableSecret<>(key));
  }

  @Bean
  JwtDecoder jwtDecoder() {
    SecretKey key = new SecretKeySpec(
        props.secret().getBytes(StandardCharsets.UTF_8),
        "HmacSHA256");
    return NimbusJwtDecoder.withSecretKey(key)
        .macAlgorithm(MacAlgorithm.HS256)
        .build();
  }
}

여기서 실무에서 가장 자주 보는 실수는

세션 + JWT를 동시에 쓰는 혼종 구조입니다.

세션이 살아 있으면 “로그아웃했는데도 요청이 통과되는” 이상한 상황이 쉽게 발생할 수 있기 때문에,
JWT 기반이라면 가능하면 Stateless 원칙을 지키는 편이 운영이 간단합니다.



7. Refresh Token 저장소 – 예제는 JPA, 운영은 Redis 추천

JWT 자체는 클라이언트에만 있어도 되지만,
강제 로그아웃, 기기별 로그아웃, 토큰 회전을 하려면
서버 쪽에 Refresh 토큰 상태를 저장해야 합니다.

@Entity
public class RefreshToken {
  @Id String id;           // UUID
  String username;
  Instant expiresAt;
}

public interface RefreshTokenRepo extends JpaRepository<RefreshToken, String> {
  Optional<RefreshToken> findByIdAndUsername(String id, String username);
}

예제는 이해를 위해 JPA/H2 기반으로 보여주지만,

실제 운영에서는 거의 항상 Redis를 사용했습니다.

  • TTL로 만료 자동 관리
  • 여러 서버 인스턴스 간 공유 용이
  • 조회/삭제가 매우 빠름

특히 트래픽이 많은 서비스일수록,
Refresh를 DB 테이블로 관리하면 인덱스·청소 작업 때문에 점점 부담이 커집니다.



8. AuthController – 로그인/재발급/로그아웃 흐름

이제 실제로 로그인과 재발급, 로그아웃을 처리하는 컨트롤러입니다.
실무에서 이 부분에서 버그가 나면 사용자 경험에 직격탄이기 때문에,
각 단계에서 무엇을 기록하고 검증해야 하는지도 함께 적어두었습니다.

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

  private final AuthenticationManagerBuilder authBuilder;
  private final JwtProvider jwt;
  private final RefreshTokenRepo refreshRepo;

  @PostMapping("/login")
  public Map<String, String> login(@Valid @RequestBody LoginReq req) {
    Authentication auth = authBuilder.getObject()
        .authenticate(new UsernamePasswordAuthenticationToken(
            req.username(), req.password()));

    String access = jwt.createAccess(
        req.username(),
        Map.of("roles", auth.getAuthorities()));

    String rid = UUID.randomUUID().toString();
    String refresh = jwt.createRefresh(req.username(), rid);

    refreshRepo.save(new RefreshToken(
        rid, req.username(),
        Instant.now().plus(14, ChronoUnit.DAYS)));

    // 운영에서는 로그에 최소한 tokenId, user 정도는 남겨두는 것을 추천
    // log.info("login success user={}, rid={}", req.username(), rid);

    return Map.of("accessToken", access, "refreshToken", refresh);
  }

  @PostMapping("/refresh")
  public Map<String, String> refresh(@RequestBody Map<String, String> body) {
    Jwt j = jwt.verify(body.get("refreshToken"));
    if (!"refresh".equals(j.getClaim("typ"))) {
      throw new ResponseStatusException(HttpStatus.FORBIDDEN);
    }

    String rid = j.getId();
    String username = j.getSubject();

    RefreshToken rt = refreshRepo.findByIdAndUsername(rid, username)
        .filter(t -> t.expiresAt.isAfter(Instant.now()))
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN));

    // 회전(rotate): 이전 Refresh 폐기 후 새 Refresh 발급
    refreshRepo.delete(rt);

    String newRid = UUID.randomUUID().toString();
    refreshRepo.save(new RefreshToken(
        newRid, username,
        Instant.now().plus(14, ChronoUnit.DAYS)));

    String access = jwt.createAccess(username, Map.of());
    String newRefresh = jwt.createRefresh(username, newRid);

    return Map.of("accessToken", access, "refreshToken", newRefresh);
  }

  @PostMapping("/logout")
  public void logout(@RequestBody Map<String, String> body) {
    try {
      Jwt j = jwt.verify(body.get("refreshToken"));
      refreshRepo.deleteById(j.getId());
    } catch (Exception ignored) {
      // 이미 만료/삭제된 토큰인 경우 등은 조용히 무시
    }
  }
}

record LoginReq(@NotBlank String username,
                @NotBlank String password) {}

운영할 때는 로그인 성공/실패, 재발급 요청, 로그아웃 요청마다
username, rid, IP, User-Agent 정도는 로그에 남겨두는 것을 강력히 추천합니다.

이 정보가 있어야 나중에 “비정상적인 토큰 사용 패턴”을 역추적할 수 있습니다.



Refresh 토큰 회전 및 서버 저장소에서의 삭제와 재발급 흐름 다이어그램



9. 보호된 API 예제와 흔한 오류

@RestController
class HelloApi {

  @GetMapping("/me")
  public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
    return Map.of(
        "sub", jwt.getSubject(),
        "exp", jwt.getExpiresAt()
    );
  }
}

이 엔드포인트에서 실무에서 가장 자주 겪는 문제는 두 가지였습니다.

1) 클라이언트가 Authorization: Bearer <토큰> 를 빼먹는 경우
2) 토큰은 맞는데 만료가 애매하게 지나, 간헐적으로 401이 터지는 경우

두 번째 문제는 대부분 클라이언트 시계 오차 때문이었습니다.
일부 모바일 기기에서 시간이 수 분씩 어긋나 있는 경우가 있어서,
저는 운영에서 JwtDecoder에 허용 시계 오차(clock skew)를 약간 주고 운영한 적도 있습니다.



10. 운영 체크리스트 (실제로 쓰는 목록)

마지막으로, JWT 로그인/리프레시 구조를 운영하면서
저는 아래 항목들을 주기적으로 확인하고 있습니다.

  • Refresh 저장소(Redis/DB)의 key 수와 메모리 사용량
  • JWT 검증 실패(만료, 서명 오류) 로그 비율
  • 로그인 실패 비율과 특정 계정/아이피에 집중되는지 여부
  • 토큰 만료 정책 변경 시 기존 사용자와의 충돌 여부(점진적 롤링)
  • 서버 재배포 시 secret 롤링 전략(두 개 키를 일정 기간 병행 운영 등)
  • 모바일/웹에서 만료 → 재발급 → 재요청 흐름이 실제 단말에서 정상 동작하는지

JWT 로그인은 코드는 금방 만들 수 있지만,

“운영 환경에서 수개월 동안 장애 없이 돌아가는 구조”를 만들려면
위와 같은 체크리스트를 꾸준히 점검하는 것이 훨씬 더 중요했습니다.



마무리 – 코드보다 중요한 건 “운영에서의 선택”

이 글에서 다룬 코드는 어디서나 볼 수 있는 JWT 예제와 크게 다르지 않을 수 있습니다.

하지만 어떤 만료 시간을 선택했는지, Refresh를 어디에 저장했는지,
로그를 어디까지 남기고 어떤 기준으로 모니터링하는지

각 서비스마다 정답이 조금씩 달라집니다.

이 글은 “정답”을 제시한다기보다,

하나의 서비스에서 실제로 적용해 본 구조와 삽질 기록을 공유하는 것에 가깝습니다.

여기서 소개한 구조를 바탕으로, 각자의 서비스 환경에 맞는
나만의 JWT 정책을 설계하는 데 도움이 되었으면 합니다.



👉 1편: Ubuntu 24.04에서 Nginx로 무료 SSL(HTTPS) 적용

👉 2편: UFW 방화벽 실전 규칙

👉 3편: systemd 서비스로 스프링부트 배포

👉 4편: Tomcat 10 설치+튜닝 체크리스트

👉 5편: Gradle 빌드 최적화로 빌드 50% 줄이기

👉 6편: Spring Security 6 JWT 로그인/리프레시 토큰

👉 7편: JPA N+1 완전 정복: 자동 감지부터 fetch join·EntityGraph·batch_size 실전 해결 전략

👉 8편: Docker로 PostgreSQL 운영(백업/복구/업그레이드)

👉 9편: Micrometer+Prometheus+Grafana 대시보드

👉 10편: Testcontainers로 DB 통합테스트

.