데브코스 2차 프로젝트에서
JWT 기반으로 회원가입, 로그인을 구현했던 과정을 기록해보고자한다.
1) 배경 & 목표
왜 JWT?
- 세션 방식에 비해 서버를 여러대로 늘려도 동기화 없이 동작해(Stateless) 수평 확장에서 유리하다는 특징이 있다.
- 이 프로젝트는 React 기반 SPA라서 요청마다 Authorization 헤더로 Access 토큰으로만 통신하면 프론트–백 연동이 단순해진다.
- 단점보안을 위해 Access 토큰의 유효기간을 짧게주고 Refresh 블랙리스트를 구현하였다.
목표
- 회원가입/로그인/토큰 재발급/로그아웃 플로우 완성
2) 시스템 개요
- 스택 : Java21, Spring Boot 3.4.5, Spring Security, JPA, MySQL, Redis

3) 요구사항 정의
- 회원가입 : 이메일/비밀번호 유효성, 중복 체크, 비밀번호 해시(BCrypt)
- 로그인 : 이메일 + 비밀번호 인증 ➡️ Access,Refresh Token 발급
- 인증 보호 API : Authorization: Bearere AccessToken
- 재발급 : RefreshToken 유효성 체크 후 새 AccessToken 발급
- 로그아웃 : RefreshToken 블랙리스트 등록
4) 토큰 설계
- 클레임 : subject(memberId), claim(memberName), claim(memberRole)
- 유효기간 : Access 5분(300000ms) , Refresh 14일(1209600000ms)
5) 시퀀스 다이어그램

6) API 스펙
회원가입 [ POST /auth/signup ]
Request
{
"name": "홍길동",
"birth": "1995-11-11",
"email": "hong@example.com",
"password": "password1234!!",
"joinDate": "2024-05-07T09:00:00",
"dept": "01",
"position": "01"
}
Response 201 Created
로그인 [ POST /auth/login ]
Request
{"email": "hong@example.com","password": "password1234!!"}
Response 200 OK
{
"token": {
"accessToken": "eyR.....",
"accessTokenExpiresIn": 300000,
"id": 1,
"name": "홍길동",
"role": "USER"
}
}

재발급 [ POST /auth/reissue ]
Request 쿠키 바디에 refresh Token 전달
Response 200 OK : 새 accessToken 전달
로그아웃 [ POST /auth/logout ]
Request 쿠키 바디에 refresh Token 전달
7) 핵심 코드
SecurityConfig
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.httpBasic(httpB -> httpB.disable()) //기본 HTTP Basic off
.formLogin(form -> form.disable()) // form 로그인 off
.csrf(csrf -> csrf.disable()) //무상태 API 기준 CSRF off
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
) //세션 off
.authorizeHttpRequests(
auth -> auth
.requestMatchers(
"/auth/**",
"/codes/**",
"/depts/**"
) //공개
.permitAll()
.requestMatchers("/admin/**").hasAuthority("ADMIN") //ADMIN 권한한테만 공개
.anyRequest().authenticated() //나머지는 인증
).exceptionHandling(exception -> exception
.authenticationEntryPoint((request, response, authException) -> {
ErrorResponseUtil.setErrorResponse(response, UnauthorizedErrorCode.UNAUTHORIZED_ENTRY_POINT);
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
ErrorResponseUtil.setErrorResponse(response, ForbiddenErrorCode.FORBIDDEN_NO_AUTHORITY);
})
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000")); //프론트
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
TokenProvider (발급)
private String issue(JwtMemberInfo jwtMemberInfo, Long expTime) {
return Jwts.builder()
.subject(jwtMemberInfo.id().toString())
.claim("name", jwtMemberInfo.name())
.claim("role", jwtMemberInfo.role())
.issuedAt(new Date())
.expiration(new Date(new Date().getTime() + expTime))
.signWith(getSecretKey(), Jwts.SIG.HS256)
.compact();
}
TokenProvider (검증)
public void validate(String token) {
try {
Jws<Claims> claimsJws = Jwts.parser()
.verifyWith(getSecretKey())
.build()
.parseSignedClaims(token);
} catch (SecurityException | SignatureException e) {
throw new UnauthorizedException(UnauthorizedErrorCode.UNAUTHORIZED_INVALID_SIGNATURE);
} catch (MalformedJwtException e) {
throw new UnauthorizedException(UnauthorizedErrorCode.UNAUTHORIZED_MALFORMED_TOKEN);
} catch (ExpiredJwtException e) {
throw new UnauthorizedException(UnauthorizedErrorCode.UNAUTHORIZED_EXPIRED_TOKEN);
} catch (UnsupportedJwtException e) {
throw new UnauthorizedException(UnauthorizedErrorCode.UNAUTHORIZED_UNSUPPORTED_TOKEN);
} catch (IllegalArgumentException e) {
throw new UnauthorizedException(UnauthorizedErrorCode.UNAUTHORIZED_ILLEGAL_ARGUMENT_TOKEN);
} catch (Exception e) {
throw new UnauthorizedException(UnauthorizedErrorCode.UNAUTHORIZED_INVALID_TOKEN);
}
}
JwtAuthenticationFilter (요청 인증)
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String uri = request.getRequestURI();
//전체 공개 경로
boolean tokenFree = TOKEN_FREE_URIS.stream().anyMatch(uri::startsWith);
String token = jwtTokenProvider.extractToken(request);
//인증 불필요
if (tokenFree) {
filterChain.doFilter(request, response);
return;
}
//401 에러 응답
if (token == null) {
setErrorResponse(response, UNAUTHORIZED_INVALID_HEADER);
return;
}
try {
//유효성 검증
jwtTokenProvider.validate(token);
} catch (UnauthorizedException e) {
//유효성 검증 실패
setErrorResponse(response, e.getErrorCode());
return;
}
//검증 통과 토큰 파싱
TokenBody tokenbody = jwtTokenProvider.parseClaims(token);
//Spring Security 컨텍스트 인증 정보 주입
Authentication auth = new UsernamePasswordAuthenticationToken(
tokenbody, null, List.of(new SimpleGrantedAuthority(tokenbody.role().toString()))
);
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
}
AuthController (로그인)
@PostMapping("/login")
public ResponseEntity<Map<String, AuthTokenResponse>> login(@RequestBody MemberLoginRequest memberLoginRequest,
HttpServletResponse response) {
LoginResponse loginResponse = authService.login(memberLoginRequest);
String refreshToken = loginResponse.refreshToken();
//응답으로 쿠키에 refresh 토큰
JwtUtils.addRefreshTokenCookie(response, refreshToken, loginResponse.refreshTokenExpiresIn());
return ResponseEntity.ok(Map.of("token", loginResponse.authTokenResponse()));
}
AuthController(재발급)
@PostMapping("/reissue")
public ResponseEntity<AccessTokenResponse> refresh(
@CookieValue("refreshToken") String refreshToken, HttpServletResponse response) {
AccessTokenResponse accessToken = authService.reissue(refreshToken);
return ResponseEntity.ok(accessToken);
}
AuthController (로그아웃)
@PostMapping("/logout")
public ResponseEntity<Void> logout(@CookieValue("refreshToken") String refreshToken) {
//Refresh 토큰 블랙리스트 등록
authService.addBlackList(refreshToken);
ResponseCookie deleteCookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(true)
.path("/")
.sameSite("Strict")
.maxAge(0)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, deleteCookie.toString())
.build();
}
JwtService (블랙리스트 등록)
private static final String PREFIX = "BL_";
public void addBlackList(String refreshToken, long expirationTime) {
String key = PREFIX + refreshToken;
stringRedisTemplate.opsForValue().set(key, "logout", expirationTime, TimeUnit.MILLISECONDS);
}
블랙리스트는 TTL 을 활용하기 위해 Redis 에 저장
8) 테스트 전략
방식: @SpringBootTest + @AutoConfigureMockMvc 기반 통합 테스트(전체 컨텍스트+시큐리티 필터 체인 포함, 서블릿 레벨).
9) 트러블 슈팅
로그아웃을 구현하기 위해 Refresh Token 블랙리스트를 Redis를 사용하여 저장했다. 재발급,로그아웃 시 Refresh Token의 잔여기간을 TTL로 등록하고, /auth/refresh에서 해당 Refresh Token이 블랙리스트에 있으면 401을 반환한다.
이 선택으로 Access 경로는 여전히 무상태를 유지하지만, Refresh Token은 서버 상태가 생겼다.
Redis의 TTL을 활용해 DB부하는 피했지만 완전한 Stateless 상태는 지키지 못했다.
10) 회고
JWT 를 이용한 로그인, 로그아웃을 구현해보니 딜레마에 빠졌다.
JWT의 제일 큰 특징이자 장점은 stateless 인데, 보안을 위해 블랙리스트 구현을 하게 되면서 stateless를 완벽하게 살리진 못했다.
그렇다고 블랙리스트를 구현하지 않으면 Refresh Token이 무한정으로 재발급 될 수 있어서 보안에 헛점이 생긴다.
Access Token 의 유효기간을 짧게주는 방식으로 조금 더 보완을 했지만
앞으로 JWT는 장점을 크게 발휘할 수 있을때만 사용해야겠다고 생각했다.
추가로 실제 기업들에선 JWT 를 어떻게 쓰고 있는지 궁금했다.
단점을 보완하기위해 블랙리스트,화이트리스트 처럼 어떠한 장치를 해놓았을 것 같다고 예상해본다.
(혹시 소개 되어있는 테크 블로그 있으면 알려주세요! .. 전 아직 찾는중 👀)
글을 작성하면서 다시 코드를 보니 리팩토링 해야할 요소들도 보여서 조만간 리팩토링도 진행해야 할 것 같다 !
더 자세한 코드는 아래 주소에서 확인 가능합니다 ! 🤓
'프로그래밍 > JAVA' 카테고리의 다른 글
| 끊김 없는 사용자 경험을 위한 WebSocket 세션 복구 (재연결) 구현 (0) | 2026.03.17 |
|---|---|
| toArray(new String[0]); 이 권장되는 이유 (3) | 2025.08.06 |
| Collection 자료구조 (0) | 2025.02.15 |
| String.valueOf(),toString() 차이점 (0) | 2025.01.31 |
| 필드 선언 시 기본 데이터 타입,불변객체 / 참조 타입 차이점 (0) | 2025.01.24 |