프로그래밍/JAVA

JWT 기반 로그인 구현하기

hwangsehee 2025. 8. 25. 15:09

 

데브코스 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 를 어떻게 쓰고 있는지 궁금했다. 

단점을 보완하기위해 블랙리스트,화이트리스트 처럼 어떠한 장치를 해놓았을 것 같다고 예상해본다. 

(혹시 소개 되어있는 테크 블로그 있으면 알려주세요! .. 전 아직 찾는중 👀)

 

글을 작성하면서 다시 코드를 보니 리팩토링 해야할 요소들도 보여서 조만간 리팩토링도 진행해야 할 것 같다 ! 

 

 

 

더 자세한 코드는 아래 주소에서 확인 가능합니다 ! 🤓

https://github.com/prgrms-be-devcourse/NBE5-7-2-Team06