Spring 7기 프로젝트/코드 개선 + 테스트 코드 프로젝트

[트러블슈팅] JWT + RefreshToken 구현 중 만난 5가지 트러블슈팅

JuNo_12 2025. 6. 8. 19:21

안녕하세요! 이번에는 JWT 인증 시스템을 구현하게 되었는데요. "RefreshToken 정도야 금방 만들겠지!"라고 생각했던 제가 너무 순진했습니다... 단순해 보였던 RefreshToken 구현 과정에서 만난 다양한 트러블슈팅 경험을 공유해드리려고 합니다.


구현 목표

실무 수준의 JWT 인증 시스템을 만드는 것이 목표였습니다.

  • JWT AccessToken (15분): 보안 강화
  • RefreshToken (2주): 사용자 편의성
  • 자동 토큰 갱신: 끊김 없는 사용자 경험
  • 보안 강화: 로그아웃, 비밀번호 변경 시 토큰 무효화

트러블슈팅 #1: @Transactional(readOnly=true) 함정

문제 상황

로그인 API를 호출했을 때 이런 에러가 발생했습니다.

Connection is read-only. Queries leading to data modification are not allowed

신기한 점은 회원가입은 잘 되는데 로그인만 안 되는 상황이었어요.

 

삽질의 과정

처음에는 MySQL 설정 문제라고 생각했습니다.

  • MySQL read_only 변수 확인 → OFF 상태
  • 사용자 권한 확인 → 문제없음
  • HikariCP 설정 추가 → 여전히 실패

 

진짜 원인 발견

문제는 AuthService의 signin 메서드에 있었습니다.

@Transactional(readOnly = true)  // ← 이게 문제!
public SigninResponse signin(SigninRequest signinRequest) {
    // User 조회 (SELECT) → OK
    // RefreshToken 저장 (INSERT) → ERROR!
    String refreshToken = refreshTokenService.createRefreshToken(user.getId());
}

 

해결 방법

@Transactional  // readOnly 제거
public SigninResponse signin(SigninRequest signinRequest) {
    // 이제 INSERT도 가능!
}

 

배운 점

@Transactional(readOnly = true)는 SELECT만 허용하고 INSERT/UPDATE/DELETE는 금지합니다. 로그인 과정에서 RefreshToken을 생성(INSERT)해야 하는데 읽기 전용 모드여서 실패한 것이었어요.


트러블슈팅 #2: Bearer 접두사 중복 문제

문제 상황

Postman에서 토큰을 사용할 때 이상한 현상이 발생했습니다.

Authorization: Bearer Bearer eyJhbGciOiJIUzI1NiJ9...

Bearer가 중복으로 들어가는 상황이였습니다..

 

원인 분석

// JwtTokenProvider.java
public String createToken(...) {
    return jwtProperties.getBearerPrefix() +  // "Bearer " 이미 포함
            Jwts.builder()...
}

응답에서 이미 "Bearer " 접두사를 포함해서 반환하는데, Postman에서 Authorization 헤더에 또 "Bearer "를 추가하니까 중복이 된 거였습니다.

 

해결 방법

방법 1: 응답에서 Bearer 제거 (권장)

public String createToken(...) {
    return Jwts.builder()  // Bearer 접두사 제거
            .setSubject(String.valueOf(userId))
            // ...
            .compact();
}

방법 2: 클라이언트에서 주의깊게 사용 응답받은 토큰을 그대로 Authorization 헤더에 넣기

 

배운 점

HTTP 표준에서 Authorization 헤더는 Bearer {token} 형식이고, {token} 부분만 실제 JWT여야 합니다. Bearer는 헤더의 스키마 지시자이지 토큰의 일부가 아닌것이죠.


트러블슈팅 #3: "서버에서 자동 갱신하면 안 될까?" 딜레마

고민의 시작

RefreshToken API를 만들고 나니 이런 생각이 들었습니다.

"어차피 프론트엔드가 401 에러 받으면 자동으로 갱신 요청하는 건데, 서버에서 JWT 필터 단계에서 자동으로 갱신해주면 더 편하지 않을까?"

 

시도해본 구현

// JwtAuthenticationFilter에서
} catch (CredentialsExpiredException e) {
    if (tryAutoRefresh(request, response)) {
        // 자동 갱신 성공
    }
}

 

왜 포기했는가?

1. HTTP Stateless 원칙 위반

GET /todos → 할일 조회인데 갑자기 토큰까지 갱신?
→ 단일 책임 원칙 위반

 

2. RESTful API 원칙 위반
각 API는 명확한 하나의 역할만 해야 하는데, 데이터 조회 + 토큰 갱신을 동시에 하는 것은 부적절합니다.

 

3. 복잡성 증가 모든 API에 토큰 갱신 로직이 들어가야 하고, 클라이언트별로 다른 토큰 저장 방식을 어떻게 처리할지도 문제가 됩니다.

 

 

업계 표준

Google, Facebook, Netflix 등 모든 회사가 백엔드는 갱신 API만 제공하고, 프론트엔드가 자동화 로직을 구현하는 방식을 사용합니다.

배운 점

"기술적으로 가능하다"와 "올바른 설계다"는 다른 이야기라는 것을 깨달았습니다. 명확한 책임 분리가 중요해요.


트러블슈팅 #4: RefreshToken 기간 설정의 비즈니스 고려사항

순진한 생각

처음에는 이렇게 생각했습니다.

"AccessToken이 15분이니까 RefreshToken은 30분 정도면 충분하지 않을까? 어차피 15분마다 갱신하면 되잖아!"

 

현실적인 문제

실제 사용자 행동:
- 앱 열고 → 5분 사용 → 다른 일 → 3시간 후 다시 사용
- 웹 탭 열어두고 → 점심 먹고 → 반나절 후 다시 클릭  
- 모바일 앱 → 백그라운드 → 하루 후 다시 실행

30분 RefreshToken이라면?
→ 계속 로그인하라고 나와서 사용자 경험 최악 😫

서비스별 전략

SNS 앱 (인스타그램): 30일
뱅킹 앱: 1일 (보안 중요)  
업무용 앱 (슬랙): 7일
일반 웹 서비스: 2주 (적절한 균형점)

 

최종 결정

2주로 설정한 이유:

  • 적절한 보안 수준 유지
  • 웬만한 휴가/출장 기간도 커버
  • 사용자가 기대하는 "합리적인 기간"

배운 점

기술적 구현뿐만 아니라 사용자 행동 패턴비즈니스 요구사항을 함께 고려해야 한다는 것을 깨달았습니다.


트러블슈팅 #5: "RefreshToken 의미가 있나?" 본질적 질문

의문의 시작

구현을 마치고 나니 이런 생각이 들었습니다.

"어차피 RefreshToken으로 새로 만든 토큰도 15분짜리인데, 그럼 똑같은 거 아닌가? 의미가 있나?"

 

깨달은 핵심

RefreshToken의 진짜 역할은 **"시간 연장기"**였습니다.

RefreshToken 없다면:
로그인 → 15분 토큰 → 15분 후 강제 로그아웃 → 다시 로그인 

RefreshToken 있다면:  
로그인 → 15분 토큰 → 15분 후 자동 갱신 → 새로운 15분
                                    ↓
                                또 15분 후 자동 갱신 → 새로운 15분
                                                ↓
                                            2주간 계속...

 

사용자 경험의 차이

사용자 관점:
"어? 15분 지났는데도 계속 앱이 잘 되네?"
→ 백그라운드에서 자동 갱신됨 (사용자는 모름)

실제로는:
토큰1(15분) → 토큰2(15분) → 토큰3(15분) → ...

 

배운 점

RefreshToken = 짧은 토큰들을 계속 이어주는 "마법의 도구"

개별 토큰은 여전히 15분(보안)이지만, 2주간 자동 연장(편의성)되어 사용자는 2주간 로그인하지 않아도 됩니다.


완성된 시스템 아키텍처

최종적으로 완성된 인증 시스템의 구조입니다.

 

핵심 구성요소

✅ JWT AccessToken (15분) - 보안 강화
✅ RefreshToken (2주) - 사용자 편의성
✅ 토큰 갱신 API - 끊김 없는 사용자 경험
✅ Refresh Token Rotation - 보안 추가 강화
✅ 선택적 무효화 - 로그아웃, 비밀번호 변경 시
✅ 세분화된 예외 처리 - 구체적인 에러 메시지
✅ 완전한 테스트 코드 - 품질 보증

 

사용자 플로우

1. 로그인 → AccessToken + RefreshToken 받음
2. 15분간 AccessToken으로 API 호출
3. 토큰 만료 시 401 에러 + requiresRefresh: true
4. 프론트엔드가 자동으로 RefreshToken으로 갱신 요청  
5. 새로운 AccessToken + RefreshToken 받음
6. 2주간 반복... 사용자는 모름
7. 2주 후 진짜 로그인 필요

 


마무리

단순해 보였던 RefreshToken 구현이 이렇게 많은 고민거리를 안겨줄 줄 몰랐습니다. 하지만 덕분에 다음과 같은 것들을 깊이 이해할 수 있었습니다.

기술적 성장

  • Spring 트랜잭션의 세부 동작 방식
  • JWT 표준과 실제 구현의 차이점
  • RESTful API 설계 원칙
  • 아키텍처 설계 시 고려사항

비즈니스 관점

  • 사용자 행동 패턴 분석의 중요성
  • 보안과 편의성의 균형점 찾기
  • 서비스 특성에 맞는 정책 설정

백엔드 캠프에서 "인증 시스템을 가장 잘 다루는 사람"이 되겠다는 목표로 시작했는데, 아직 많이 부족하다고 느꼈습니다. 혹시 JWT나 RefreshToken 구현 과정에서 비슷한 문제를 겪으셨거나 궁금한 점이 있으시다면 댓글로 남겨주세요. 함께 고민해보면 좋을 것 같습니다.

 

읽어주셔서 감사합니다!