티스토리 뷰

반응형

✏️ 왜 JWT인가? 인증 방식의 변화

웹 애플리케이션에서 사용자를 인증하는 방식은 꾸준히 진화해왔습니다. 과거에는 서버 측 세션(Session)기반 인증이 주류였지만, 확장성과 유지 관리의 한계로 점차 무상태(stateless) 방식인 JWT 기반 인증으로 전환되고 있습니다.

JWT는 클라이언트에게 토큰을 발급하고, 사용자가 이후 요청마다 토큰을 포함시켜 보내는 방식입니다. 서버는 이 토큰을 검증함으로써 사용자의 인증 상태를 파악할 수 있습니다. JWT는 특히 RESTful API, SPA(Single Page Application), 모바일 앱 환경에서 매우 적합한 구조입니다.

 

또한 JWT 기반 인증은 세션 방식과 달리 서버에 인증 상태를 저장하지 않기 때문에 수평 확장에 유리하며, 로드 밸런싱이 적용된 환경에서도 세션 동기화 문제 없이 인증 처리를 할 수 있습니다. 그리고 JWT는 자체적으로 서명되어 있어 위변조 여부를 서버가 쉽게 판단할 수 있습니다. 이로 인해 인증 시 별도의 저장소(세션, Redis)를 필요로 하지 않습니다.

토큰을 Authorization 헤더로 전달하기 때문에 쿠키를 사용하지 않아 CSRF 공격에도 상대적으로 안전합니다.

단, 클라이언트 측 스토리지 보안(XSS 등)에 대한 대비는 반드시 필요합니다.

 

✏️ JWT 인증 흐름: 전체 구조 한눈에 보기

  1. 사용자가 로그인 요청 (ID/PW) -> /auth/login
  2. 서버가 인증 성공 시 JWT 토큰을 발급 -> 응답 본문에 포함
  3. 클라이언트는 토큰을 로컬 스토리지 or 메모리에 저장
  4. 이후 API 요청마다 Authorization 헤더에 Bearer {토큰} 형태로 포함
  5. 서버는 필터에서 토큰 유효성 검증 -> 유효하면 인증 객체(Authentication) 생성
  6. SecruityContextHolder에 등록 -> 인증된 사용자로 처리

이 구조의 핵심은 서버가 사용자 상태를 저장하지 않고도 인증을 유지할 수 있습니다.

 

✏️ 프로젝트 설정

Spring Boot에서 JWT 기반 인증을 구현하려면 몇 가지 필수 라이브러리를 추가해야 합니다.
대표적으로 spring-boot-starter-security, spring-boot-starter-web, 그리고 JWT 생성을 위한 jjwt 관련 라이브러리가 필요합니다.

 

build.gradle 설정 예시

기존에는 다음과 같은 한 줄 의존성으로 JWT를 사용할 수 있었습니다:

=> implementation 'io.jsonwebtoken:jjwt:0.9.1' 

하지만 Spring Boot 버전이 올라가면서 이 버전은 더 이상 권장되지 않으며, 일부 암호화 알고리즘(HMAC-SHA512, RSA 등)의 지원이 불안정하거나, JDK 17+ 환경에서 문제가 발생할 수 있습니다. 따라서 최신 jjwt 사용을 위해 다음과 같이 3가지 라이브러리를 나누어 명시해야 합니다.

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// Security
implementation 'org.springframework.boot:spring-boot-starter-security'

// 
implementation 'org.springframework.boot:spring-boot-starter-web'

 

✏️ 구현하기

이번 구현에서는 DDD(Domain-Driven Design) 설계를 반영하여 디렉터리 구조를 다음과 같이 나누었습니다:

📦 src/main/java
├── 📂common
│   ├── 📂jwt
│   │   ├── JwtTokenProvider.java
│   │   └── JwtFilter.java
│   └── 📂config
│       └── SecurityConfig.java
├── 📂domain
│   └── 📂auth
│       ├── 📂controller
│       │   └── AuthController.java
│       └── 📂service
│           └── AuthService.java

 

1. 로그인 요청/응답 DTO

@Schema(description = "로그인 응답 DTO")
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LoginResponse {

    @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1...")
    private final String accessToken;

    @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1...")
    private final String refreshToken;

    @Schema(description = "이메일", example = "user@example.com")
    private final String email;

    @Schema(description = "GitHub 링크", example = "https://github.com/username")
    private final String githubLink;

    @Schema(description = "이름", example = "홍길동")
    private final String name;

    @Schema(description = "자기소개", example = "열정적인 백엔드 개발자입니다.")
    private final String introduction;

    @Schema(description = "거주지 정보")
    private final Residence residence;

    @Schema(description = "기술 스택 목록")
    private final List<TechStack> techStack;

    @Schema(description = "사용자 ID", example = "1")
    private final Long userId;

    private LoginResponse(
            String accessToken,
            String refreshToken,
            String email,
            String githubLink,
            String name,
            String introduction,
            Residence residence,
            List<TechStack> techStack,
            Long userId
    ) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
        this.email = email;
        this.githubLink = githubLink;
        this.name = name;
        this.introduction = introduction;
        this.residence = residence;
        this.techStack = techStack;
        this.userId = userId;
    }

    public static LoginResponse from(
            String accessToken,
            String refreshToken,
            User user,
            List<TechStack> techStack
    ) {
        return new LoginResponse(
                accessToken,
                refreshToken,
                user.getEmail(),
                user.getGithubLink(),
                user.getName(),
                user.getIntroduction(),
                user.getResidence(),
                techStack,
                user.getUserId()
        );
    }

    // Getters
    public String getAccessToken() { return accessToken; }
    public String getRefreshToken() { return refreshToken; }
    public String getEmail() { return email; }
    public String getGithubLink() { return githubLink; }
    public String getName() { return name; }
    public String getIntroduction() { return introduction; }
    public Residence getResidence() { return residence; }
    public List<TechStack> getTechStack() { return techStack; }
    public Long getUserId() { return userId; }
}


@Schema(description = "로그인 요청 DTO")
public class LoginRequest {

    @NotBlank(message = "아이디는 필수입니다.")
    @Size(max = 20, message = "아이디는 20자를 초과할 수 없습니다.")
    @Schema(description = "아이디", example = "testuser")
    private String id;

    @NotBlank(message = "비밀번호는 필수입니다.")
    @Size(min = 8, message = "비밀번호는 최소 8자 이상입니다.")
    @Schema(description = "비밀번호", example = "securePass123!")
    private String password;

    public LoginRequest() {
        // 기본 생성자 (역직렬화용)
    }

    public LoginRequest(String id, String password) {
        this.id = id;
        this.password = password;
    }

    // Getters & Setters
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }

    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
}

2. JwtTokenProvider (common.jwt)

@Component
public class JwtTokenProvider {

    private final Key key;
    private final long accessTokenExpiration;
    private final long refreshTokenExpiration;

    public JwtTokenProvider(
            @Value("${jwt.secret}") String secretKey,
            @Value("${jwt.access-token-expiration}") long accessTokenExpiration,
            @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration
    ) {
        this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
        this.accessTokenExpiration = accessTokenExpiration;
        this.refreshTokenExpiration = refreshTokenExpiration;
    }

    public String createJwtToken(Long userId, String name) {
        return createToken(userId, name, accessTokenExpiration, JwtTokenType.ACCESS);
    }

    public String createRefreshToken(Long userId, String name) {
        return createToken(userId, name, refreshTokenExpiration, JwtTokenType.REFRESH);
    }

    public void validateJwtToken(String token) {
        if (token == null || token.isBlank()) {
            throw new GeneralException(ErrorStatus.JWT_TOKEN_NOT_FOUND);
        }
        Claims claims = getClaims(token);
        validateTokenType(claims, JwtTokenType.ACCESS);
    }

    public Claims validateRefreshToken(String token) {
        Claims claims = getClaims(token);
        validateTokenType(claims, JwtTokenType.REFRESH);
        return claims;
    }

    public Long getUserIdFromJwtToken(String token) {
        try {
            return Long.parseLong(
                Jwts.parserBuilder().setSigningKey(key).build()
                    .parseClaimsJws(token).getBody().getSubject()
            );
        } catch (Exception e) {
            throw new GeneralException(ErrorStatus.JWT_EXTRACT_ID_FAILED);
        }
    }

    private String createToken(Long userId, String name, long expiration, JwtTokenType type) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setSubject(userId.toString())
                .claim("name", name)
                .claim("type", type.getValue())
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    private void validateTokenType(Claims claims, JwtTokenType expectedType) {
        String type = Optional.ofNullable(claims.get("type"))
                .map(Object::toString)
                .map(String::toLowerCase)
                .orElseThrow(() -> new GeneralException(ErrorStatus.JWT_INVALID_TYPE));

        if (!type.equals(expectedType.getValue())) {
            throw new GeneralException(ErrorStatus.JWT_INVALID_TYPE);
        }
    }

    private Claims getClaims(String token) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
        } catch (SecurityException e) {
            throw new GeneralException(ErrorStatus.JWT_INVALID_SIGNATURE);
        } catch (MalformedJwtException e) {
            throw new GeneralException(ErrorStatus.JWT_MALFORMED);
        } catch (ExpiredJwtException e) {
            throw new GeneralException(ErrorStatus.JWT_EXPIRED);
        } catch (UnsupportedJwtException e) {
            throw new GeneralException(ErrorStatus.JWT_UNSUPPORTED);
        } catch (IllegalArgumentException e) {
            throw new GeneralException(ErrorStatus.JWT_INVALID);
        } catch (Exception e) {
            throw new GeneralException(ErrorStatus.JWT_GENERAL_ERROR);
        }
    }
}

3. JwtFilter (common.jwt)

public class JwtFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String jwt = resolveToken(request);
        if (jwt != null) {
            jwtTokenProvider.validateJwtToken(jwt);
            Long userId = jwtTokenProvider.getUserIdFromJwtToken(jwt);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        return (bearerToken != null && bearerToken.startsWith("Bearer "))
                ? bearerToken.substring(7) : null;
    }
}

4. SecurityConfig (common.config)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    private static final String[] SWAGGER_URIS = {
            "/swagger-ui.html",
            "/swagger-ui/**",
            "/v3/api-docs/**",
            "/swagger-resources/**",
            "/webjars/**",
            "/swagger-ui/favicon.ico",
            "/api-docs/**"
    };

    private static final String[] AUTH_URIS = {
            "/auth/login",
            "/auth/signup",
            "/auth/password",
            "/auth/find-id",
            "/auth/check-id",
            "/auth/email-verification",
            "/auth/email-verification/confirm",
            "/auth/reissue",
            "/error",
            "/error/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors().configurationSource(corsConfigurationSource())
            .and().csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and().formLogin().disable()
            .httpBasic().disable()
            .authorizeHttpRequests()
            .requestMatchers(Stream.concat(Arrays.stream(SWAGGER_URIS), Arrays.stream(AUTH_URIS)).toArray(String[]::new)).permitAll()
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(new JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList("http://localhost:8000", "http://localhost:3000"));
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
        config.setExposedHeaders(Arrays.asList("Authorization"));
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

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

5. AuthController (domain.auth.controller)

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

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    @Operation(summary = "로그인")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "로그인 성공",
            content = @Content(mediaType = "application/json", schema = @Schema(implementation = LoginResponse.class))),
        @ApiResponse(responseCode = "401", description = "비밀번호가 일치하지 않는 경우", content = @Content()),
        @ApiResponse(responseCode = "404", description = "유저가 존재하지 않는 경우", content = @Content())
    })
    public ResponseEntity<ApiResponse<LoginResponse>> login(@Valid @RequestBody LoginRequest request) {
        LoginResponse response = authService.login(request);
        return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response);
    }
}

6 AuthService (domain.auth.service)

@Service
public class AuthService {

    private final UserService userService;
    private final UserTechStackService userTechStackService;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthService(UserService userService, UserTechStackService userTechStackService,
                       JwtTokenProvider jwtTokenProvider) {
        this.userService = userService;
        this.userTechStackService = userTechStackService;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Transactional
    public LoginResponse login(LoginRequest request) {
        User user = userService.findUserById(request.getId());
        PasswordValidator.checkPasswordMatch(request.getPassword(), user.getPassword(), PasswordValidationType.LOGIN);

        String accessToken = jwtTokenProvider.createJwtToken(user.getUserId(), user.getName());
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId(), user.getName());
        List<String> techStack = userTechStackService.getUserTechStack(user.getUserId());

        userService.updateRefreshToken(user, refreshToken);
        return LoginResponse.from(accessToken, refreshToken, user, techStack);
    }
}

 

✏️ 마무리

사용자가 로그인 폼에 아이디와 비밀번호를 입력하면, 클라이언트는 이를 LoginRequest 형태로 /auth/login API에 POST 요청을 보냅니다. 이 경로는 SecurityConfig에서 인증 없이 접근할 수 있도록 permitAll()로 설정되어 있기 때문에, Spring Security의 필터 체인을 거치긴 하지만 인증 과정 없이 곧바로 AuthController에 도달하게 됩니다.

 

AuthController에서는 AuthService를 호출하여 사용자 정보를 데이터베이스에서 조회하고 비밀번호를 검증한 뒤, JwtTokenProvider를 통해 Access TokenRefresh Token을 각각 발급합니다. 이 중 Refresh Token은 DB(User 테이블)에 저장되고, 두 토큰은 모두 LoginResponse에 담겨 클라이언트에 반환됩니다. 이후 클라이언트는 서버에 요청을 보낼 때마다 Authorization: Bearer {AccessToken} 형태로 헤더에 액세스 토큰을 포함시켜 전송합니다.

이때부터는 클라이언트 요청이 Spring Security의 필터 체인을 거치며 인증 처리를 받습니다. 우리가 등록한 JwtFilterUsernamePasswordAuthenticationFilter보다 앞서 동작하도록 addFilterBefore()를 통해 설정되어 있기 때문에, 가장 먼저 토큰을 검사하게 됩니다.

 

JwtFilter는 요청 헤더에서 JWT를 추출하고, JwtTokenProvider를 통해 유효성을 검증한 뒤, 토큰에 포함된 사용자 식별자(userId, 보통 PK 값)를 기반으로 UsernamePasswordAuthenticationToken 객체를 생성합니다. 이후 이 인증 객체를 SecurityContextHolder에 등록함으로써, 현재 요청에 대한 인증 상태를 유지하게 됩니다. 이 객체는 이후 체인을 따라 내려가며 권한 검증 필터 등에서 활용되고, 최종적으로 컨트롤러에서 @AuthenticationPrincipal 또는 SecurityContextHolder를 통해 인증된 사용자 정보를 참조할 수 있습니다.

 

이 전체 인증 과정은 OncePerRequestFilter, SecurityContextPersistenceFilter, UsernamePasswordAuthenticationFilter, ExceptionTranslationFilter, FilterSecurityInterceptorSpring Security의 필터 체인 안에서 체계적이고 유기적으로 동작합니다. 결국 JWT 기반 인증은 기본 로그인 인증 메커니즘을 대체하는 구조로, 커스텀 필터를 통해 인증 정보를 직접 생성하고 주입함으로써, 무상태(stateless)인증 시스템을 구현할 수 있습니다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함