๐ŸŽซ JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ ๊ตฌํ˜„ - Stateless ์ธ์ฆ ๊ตฌ์กฐ & JWT

okorionยท2025๋…„ 10์›” 6์ผ

๐Ÿ” Spring Security 6

๋ชฉ๋ก ๋ณด๊ธฐ
6/10

๐Ÿ“– ๋“ค์–ด๊ฐ€๋ฉฐ

์ด๋ฒˆ ์žฅ์—์„œ๋Š” Spring Security์˜ Stateful ์ธ์ฆ(JSESSIONID)์„ ๋ฌด์ƒํƒœ(Stateless) JWT ๊ตฌ์กฐ๋กœ ๋Œ€์ฒดํ•œ๋‹ค.

๊ธฐ์กด JSESSIONID + XSRF-TOKEN ์ฟ ํ‚ค ๊ธฐ๋ฐ˜ ์ธ์ฆ์€

  • ์„ธ์…˜์— ์‚ฌ์šฉ์ž ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜๊ณ ,
  • ์„œ๋ฒ„๊ฐ€ ์‚ฌ์šฉ์ž ์‹๋ณ„์„ ๋‹ด๋‹นํ–ˆ๋‹ค.

๋ฐ˜๋ฉด, JWT(JSON Web Token)์„ ์‚ฌ์šฉํ•˜๋ฉด

โ€œ์‚ฌ์šฉ์ž ์ƒํƒœ๋ฅผ ํ† ํฐ ๋‚ด๋ถ€์— ์ง์ ‘ ์ €์žฅํ•˜์—ฌ, ์„œ๋ฒ„๊ฐ€ ์ƒํƒœ๋ฅผ ๊ธฐ์–ตํ•˜์ง€ ์•Š์•„๋„ ๋œ๋‹ค.โ€

์ฆ‰, ๋ฐฑ์—”๋“œ ์„œ๋ฒ„๋Š” ์™„์ „ํžˆ Statelessํ•˜๊ฒŒ ๋™์ž‘ํ•˜๋ฉฐ,

๋ชจ๋“  ์š”์ฒญ์€ Authorization ํ—ค๋”์— JWT๋ฅผ ํฌํ•จํ•ด ์ธ์ฆ๋œ๋‹ค.


#1. Opaque Token vs JWT

๊ตฌ๋ถ„Opaque TokenJWT Token
ํ˜•ํƒœ๋ฌด์˜๋ฏธํ•œ ๋žœ๋ค ๋ฌธ์ž์—ดJSON ๊ตฌ์กฐ(Base64 ์ธ์ฝ”๋”ฉ)
์ •๋ณด ์ €์žฅ์—†์Œ (์„œ๋ฒ„ ์„ธ์…˜์— ์ €์žฅ)ํ† ํฐ ๋‚ด๋ถ€์— ์ €์žฅ(Self-contained)
๊ฒ€์ฆ ๋ฐฉ์‹์„œ๋ฒ„์— ๋งค๋ฒˆ ๊ฒ€์ฆ ์š”์ฒญ ํ•„์š”๊ณต๊ฐœ ํ‚ค๋กœ ๋กœ์ปฌ ๊ฒ€์ฆ ๊ฐ€๋Šฅ
์˜์กด์„ฑAuth ์„œ๋ฒ„ ํ•„์š”๋…๋ฆฝ์  ๊ฒ€์ฆ ๊ฐ€๋Šฅ
์ด์ƒ์  ํ™˜๊ฒฝํ์‡„๋ง / ๋‚ด๋ถ€ API๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค / ๋ถ„์‚ฐ ํ™˜๊ฒฝ
์†๋„๋А๋ฆผ (์„œ๋ฒ„ ํ†ต์‹  ํ•„์š”)๋น ๋ฆ„ (๋กœ์ปฌ ๊ฒ€์ฆ ๊ฐ€๋Šฅ)

๐Ÿ”‘ JWT์˜ ํ•ต์‹ฌ ์ด์ :

Auth ์„œ๋ฒ„์— ๋งค๋ฒˆ ์˜์กดํ•˜์ง€ ์•Š๊ณ , ๊ณต๊ฐœํ‚ค ๊ฒ€์ฆ๋งŒ์œผ๋กœ ํ† ํฐ์˜ ์œ ํšจ์„ฑ์„ ํŒ๋‹จํ•  ์ˆ˜ ์žˆ๋‹ค.


#2. ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ์˜ ์žฅ์ 

  1. โœ… ์ž๊ฒฉ ์ฆ๋ช… ๋…ธ์ถœ ์ตœ์†Œํ™”

    ๋กœ๊ทธ์ธ ์‹œ 1ํšŒ๋งŒ ์•„์ด๋””/๋น„๋ฐ€๋ฒˆํ˜ธ ์ „์†ก โ†’ ์ดํ›„ ์š”์ฒญ์€ JWT๋งŒ ์ „์†ก

    โ†’ ๋„คํŠธ์›Œํฌ ๋‚ด ์ž๊ฒฉ ์ฆ๋ช… ๋…ธ์ถœ ์ตœ์†Œํ™”

  2. ๐Ÿ•“ ๋งŒ๋ฃŒ ์‹œ๊ฐ„(Expiration)

    ํ† ํฐ ์ƒ์„ฑ ์‹œ ๋งŒ๋ฃŒ ์‹œ๊ฐ„(exp) ์ง€์ •

    โ†’ ํ† ํฐ ํƒˆ์ทจ ์‹œ์—๋„ ์ผ์ • ์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด ์ž๋™ ๋ฌดํšจํ™”

  3. ๐Ÿ“ฆ Self-contained ๊ตฌ์กฐ

    ์‚ฌ์šฉ์ž๋ช…, ๊ถŒํ•œ, ๋ฐœ๊ธ‰์ผ์‹œ ๋“ฑ์„ ํ† ํฐ ๋‚ด๋ถ€์— ์ €์žฅ

    โ†’ ์„œ๋ฒ„/DB์— ๋ณ„๋„ ์„ธ์…˜ ๊ด€๋ฆฌ ํ•„์š” ์—†์Œ

  4. ๐Ÿ” ์žฌ์‚ฌ์šฉ์„ฑ (SSO)

    ํ•œ ๋ฒˆ ๋กœ๊ทธ์ธ ์‹œ ๋ฐœ๊ธ‰๋œ ํ† ํฐ์„ ์—ฌ๋Ÿฌ ์„œ๋น„์Šค์—์„œ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

    โ†’ Single Sign-On(SSO) ๊ตฌํ˜„์— ์ ํ•ฉ

  5. ๐Ÿ“ฑ Cross-platform ํ˜ธํ™˜์„ฑ

    ์›น, ๋ชจ๋ฐ”์ผ, IoT ๋“ฑ ์–ด๋””์„œ๋‚˜ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

  6. ๐Ÿงฑ Stateless (๋ฌด์ƒํƒœ)

    ์„œ๋ฒ„ ๊ฐ„ ์„ธ์…˜ ๋™๊ธฐํ™” ๋ถˆํ•„์š” โ†’ ์ˆ˜ํ‰ ํ™•์žฅ(MSA) ํ™˜๊ฒฝ์— ์ตœ์ ํ™”


#3. JWT ๊ตฌ์กฐ ์ดํ•ด

JWT๋Š” Header.Payload.Signature์˜ ์„ธ ๋ถ€๋ถ„์œผ๋กœ ๊ตฌ์„ฑ๋œ๋‹ค.

xxxxx.yyyyy.zzzzz
๊ตฌ์„ฑ์š”์†Œ์—ญํ• ์˜ˆ์‹œ
Header๋ฉ”ํƒ€๋ฐ์ดํ„ฐ(์•Œ๊ณ ๋ฆฌ์ฆ˜, ํƒ€์ž…){"alg": "HS256", "typ": "JWT"}
Payload์‚ฌ์šฉ์ž ์ •๋ณด(Claim){"sub":"user", "role":"admin", "exp":1718794117}
Signature๋ฌด๊ฒฐ์„ฑ ๊ฒ€์ฆ์šฉ ๋””์ง€ํ„ธ ์„œ๋ช…HMACSHA256(Base64(header+payload), secret)

โœ๏ธ ๋””์ง€ํ„ธ ์„œ๋ช…(Signature)

์„œ๋ช…์€ ํ† ํฐ ๋ณ€์กฐ๋ฅผ ๊ฐ์ง€ํ•˜๋Š” ํ•ต์‹ฌ ์š”์†Œ.

๋น„๋ฐ€ํ‚ค(secret)๋กœ ์ƒ์„ฑ๋˜๋ฉฐ, ํ† ํฐ ์กฐ์ž‘ ์‹œ ์„œ๋ช…์ด ๋ถˆ์ผ์น˜ํ•˜๊ฒŒ ๋œ๋‹ค.


#4. JWT ๋ณ€์กฐ ์‹œ ํƒ์ง€ ์›๋ฆฌ

  • ํ•ด์ปค๋Š” Payload๋ฅผ Base64๋กœ ๋””์ฝ”๋”ฉํ•ด ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, Secret Key ์—†์ด Signature๋ฅผ ์žฌ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค.
  • ๋ฐฑ์—”๋“œ๋Š” HMAC ๊ณ„์‚ฐ์„ ๋‹ค์‹œ ์ˆ˜ํ–‰ํ•˜์—ฌ ๋ถˆ์ผ์น˜ ์‹œ ๋ณ€์กฐ๋กœ ํŒ๋‹จํ•œ๋‹ค.

#5. Spring Security ์„ค์ • ๋ณ€๊ฒฝ

http
    .sessionManagement(session ->
        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    )
    .securityContext(AbstractHttpConfigurer::disable) // JSESSIONID ๋น„ํ™œ์„ฑํ™”
    .cors(cors -> cors.configurationSource(...))
    .csrf(csrf -> csrf.disable());
  • STATELESS: ์„ธ์…˜ ์ƒํƒœ ์œ ์ง€ X
  • SecurityContext ๋น„ํ™œ์„ฑํ™”: JSESSIONID ์ œ๊ฑฐ
  • CORS ๋…ธ์ถœ ํ—ค๋”: setExposedHeaders(List.of("Authorization"))

#6. JWT ์ƒ์„ฑ ํ•„ํ„ฐ (Generator)

๐Ÿ“ JWTTokenGeneratorFilter.java

@Component
public class JWTTokenGeneratorFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null) {
            String secret = env.getProperty("JWT_SECRET", "default_secret");
            SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));

            String jwt = Jwts.builder()
                    .issuer("EazyBank")
                    .subject("JWT Token")
                    .claim("username", auth.getName())
                    .claim("authorities", auth.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority)
                        .collect(Collectors.joining(",")))
                    .issuedAt(new Date())
                    .expiration(new Date(System.currentTimeMillis() + 8 * 60 * 60 * 1000))
                    .signWith(secretKey)
                    .compact();

            res.setHeader("Authorization", jwt);
        }

        chain.doFilter(req, res);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getServletPath().equals("/user");
    }
}

๋กœ๊ทธ์ธ ์š”์ฒญ ์‹œ(POST /user)๋งŒ JWT๋ฅผ ์ƒ์„ฑํ•˜๊ณ 

์ดํ›„ ์š”์ฒญ์—์„œ๋Š” JWTTokenValidatorFilter๊ฐ€ ๊ฒ€์ฆ์„ ๋‹ด๋‹นํ•œ๋‹ค.


#7. JWT ๊ฒ€์ฆ ํ•„ํ„ฐ (Validator)

๐Ÿ“ JWTTokenValidatorFilter.java

@Component
public class JWTTokenValidatorFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        String jwt = req.getHeader("Authorization");
        if (jwt != null) {
            try {
                String secret = env.getProperty("JWT_SECRET", "default_secret");
                SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));

                Claims claims = Jwts.parser()
                        .verifyWith(secretKey)
                        .build()
                        .parseSignedClaims(jwt)
                        .getPayload();

                String username = claims.get("username", String.class);
                String authorities = claims.get("authorities", String.class);

                Authentication auth = new UsernamePasswordAuthenticationToken(
                        username, null,
                        AuthorityUtils.commaSeparatedStringToAuthorityList(authorities));

                SecurityContextHolder.getContext().setAuthentication(auth);

            } catch (Exception e) {
                throw new BadCredentialsException("Invalid Token received");
            }
        }

        chain.doFilter(req, res);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return request.getServletPath().equals("/user");
    }
}

#8. ํ•„ํ„ฐ ๋“ฑ๋ก

๐Ÿ“ ProjectSecurityConfig.java

http
  .addFilterAfter(new JWTTokenGeneratorFilter(), BasicAuthenticationFilter.class)
  .addFilterBefore(new JWTTokenValidatorFilter(), BasicAuthenticationFilter.class);

#9. JWT ๋งŒ๋ฃŒ ํ…Œ์ŠคํŠธ

.expiration(new Date(System.currentTimeMillis() + 3000)); // 3์ดˆ

3์ดˆ ํ›„ ์š”์ฒญ ์‹œ ExpiredJwtException ๋ฐœ์ƒ โ†’ BadCredentialsException("Invalid Token") ๋ฐ˜ํ™˜

๐Ÿ’ก ๊ฐœ์„  ํฌ์ธํŠธ:

๋งŒ๋ฃŒ ์˜ˆ์™ธ์™€ ๋ณ€์กฐ ์˜ˆ์™ธ๋ฅผ ๋ณ„๋„ ์ฒ˜๋ฆฌํ•˜์—ฌ

  • 401 Unauthorized: ๋งŒ๋ฃŒ
  • 403 Forbidden: ๋ณ€์กฐ ๋กœ ๊ตฌ๋ถ„ํ•˜๋ฉด ์‹ค๋ฌด์—์„œ ๋””๋ฒ„๊น…์ด ์‰ฌ์›Œ์ง„๋‹ค.

#10. JWT ๊ฒ€์ฆ ํ๋ฆ„ ์š”์•ฝ


#11. REST API ๋กœ๊ทธ์ธ (RequestBody ๊ธฐ๋ฐ˜)

๐Ÿ“ UserController.java

@PostMapping("/apiLogin")
public ResponseEntity<LoginResponseDTO> apiLogin(@RequestBody LoginRequestDTO request) {
    Authentication authReq = UsernamePasswordAuthenticationToken.unauthenticated(
        request.username(), request.password());

    Authentication authRes = authenticationManager.authenticate(authReq);

    if (authRes != null && authRes.isAuthenticated()) {
        String jwt = generateJwt(authRes); // ๋™์ผํ•œ Generator ๋กœ์ง ์žฌ์‚ฌ์šฉ
        return ResponseEntity.ok()
                .header("Authorization", jwt)
                .body(new LoginResponseDTO("OK", jwt));
    }
    throw new BadCredentialsException("Login Failed");
}

#12. Angular ํด๋ผ์ด์–ธํŠธ ๋ณ€๊ฒฝ ์š”์•ฝ

์œ„์น˜์—ญํ• ์ฃผ์š” ์ฝ”๋“œ
login.component.ts๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ํ† ํฐ ์ €์žฅsessionStorage.setItem('Authorization', token)
app.request.interceptor.ts๋ชจ๋“  ์š”์ฒญ์— JWT ์ž๋™ ์ฒจ๋ถ€req = req.clone({ headers: req.headers.set('Authorization', token) })
logout.component.ts์„ธ์…˜ ์ข…๋ฃŒ ์‹œ JWT ์‚ญ์ œsessionStorage.removeItem('Authorization')

#13. JWT ๋งŒ๋ฃŒ ๋ฐ ๋ณ€์กฐ ๋””๋ฒ„๊น… ์˜ˆ์‹œ

์ƒํ™ฉ์˜ˆ์™ธHTTP ์‘๋‹ต์„ค๋ช…
๋งŒ๋ฃŒ(Exp)ExpiredJwtException401ํ† ํฐ ์œ ํšจ์‹œ๊ฐ„ ์ดˆ๊ณผ
๋ณ€์กฐ(Signature)SignatureException403๋””์ง€ํ„ธ ์„œ๋ช… ๋ถˆ์ผ์น˜
์ž˜๋ชป๋œ ํ˜•์‹MalformedJwtException400ํ† ํฐ ํŒŒ์‹ฑ ๋ถˆ๊ฐ€
ํ‚ค ๋ถˆ์ผ์น˜InvalidKeyException500secret key ์†์ƒ

#14. ๊ฒฐ๋ก 

JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ์€

  • ์„ธ์…˜ ์—†๋Š” ํ™•์žฅ์„ฑ (Statelessness)
  • ๋ณด์•ˆ ๊ฐ•ํ™” (์„œ๋ช… ๊ฒ€์ฆ, ๋งŒ๋ฃŒ ์‹œ๊ฐ„)
  • ๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค ์ ํ•ฉ์„ฑ (Self-contained) ์„ ์ œ๊ณตํ•œ๋‹ค.

๋‹ค๋งŒ,

Secret ๊ด€๋ฆฌ, ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ, Refresh Token ์ „๋žต์ด ๋ฐ˜๋“œ์‹œ ๋ณ‘ํ–‰๋˜์–ด์•ผ ํ•œ๋‹ค.

profile
Tech Blog

0๊ฐœ์˜ ๋Œ“๊ธ€