4/28(ν™”) 둜그인, UserContext

dev_jooΒ·2026λ…„ 4μ›” 28일

둜그인

jjwt 의쑴

// jjwt - gateway와 λ™μΌν•œ λ²„μ „μ˜ jjwt μ‚¬μš©
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"

application.yml

jwt:
  secret: ${JWT_SECRET}
  access-token-expiration-ms: 86400000   # 24μ‹œκ°„
  # access-token-expiration-ms: 900000   # μš΄μ˜μ‹œ 15λΆ„

JwtProperties

package com.pagely.userservice.infrastructure.security;

/**
 * JWT κ΄€λ ¨ μ„€μ • λ§€ν•‘.
 *
 * @param secret                  JWT μ„œλͺ…μš© λΉ„λ°€ν‚€ (HS256, 32λ°”μ΄νŠΈ 이상)
 * @param accessTokenExpirationMs AccessToken 만료 μ‹œκ°„ (λ°€λ¦¬μ΄ˆ)
 */
@ConfigurationProperties(prefix = "jwt") // ServiceApplication - @EnableConfigurationProperties(JwtProperties.class)
public record JwtProperties(
        String secret,
        long accessTokenExpirationMs
) {
}

JwtProvider

@Component
public class JwtTokenProvider {

    private static final String CLAIM_ROLE = "role";

    private final SecretKey secretKey;
    private final long accessTokenExpirationMs;

    public JwtTokenProvider(JwtProperties jwtProperties) {
        this.secretKey = Keys.hmacShaKeyFor(
                jwtProperties.secret().getBytes(StandardCharsets.UTF_8)
        );
        this.accessTokenExpirationMs = jwtProperties.accessTokenExpirationMs();
    }

    /**
     * AccessToken 생성.
     *
     * @param userId μœ μ € UUID (sub ν΄λ ˆμž„)
     * @param role   μœ μ € κΆŒν•œ λ¬Έμžμ—΄ (role ν΄λ ˆμž„)
     * @return JWT 토큰 λ¬Έμžμ—΄
     */
    public String createAccessToken(UUID userId, String role) {
        long now = System.currentTimeMillis();
        Date issuedAt = new Date(now);
        Date expiration = new Date(now + accessTokenExpirationMs);

        return Jwts.builder()
                .subject(userId.toString())
                .claim(CLAIM_ROLE, role)
                .issuedAt(issuedAt)
                .expiration(expiration)
                .signWith(secretKey)
                .compact();
    }

    /**
     * 만료 μ‹œκ°„(초 λ‹¨μœ„) λ°˜ν™˜. 응닡 DTO 의 expiresIn 에 μ‚¬μš©.
     */
    public long getAccessTokenExpirationSeconds() {
        return accessTokenExpirationMs / 1000;
    }
}

DTO

/**
 * 토큰 λ°œκΈ‰ 응닡 DTO.
 *
 * <p>OAuth 2.0 RFC 6749 의 token endpoint 응닡 ν˜•μ‹ μ°Έμ‘°.</p>
 *
 * @param accessToken λ°œκΈ‰λœ JWT
 * @param tokenType   토큰 νƒ€μž… (항상 "Bearer")
 * @param expiresIn   λ§Œλ£ŒκΉŒμ§€ 남은 μ‹œκ°„ (초)
 */
public record TokenResponse(
        String accessToken,
        String tokenType,
        long expiresIn
) {

    public static TokenResponse of(String accessToken, long expiresInSeconds) {
        return new TokenResponse(accessToken, "Bearer", expiresInSeconds);
    }
}

πŸŽ“ OAuth 2.0 ν‘œμ€€ ν˜•μ‹

RFC 6749 Section 5.1:
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600
}

ν‘œμ€€μ„ λ”°λ₯΄λ©΄
ν΄λΌμ΄μ–ΈνŠΈ 라이브러리 ν˜Έν™˜μ΄ 쉽닀. (axios interceptor λ“± μžλ™ 인식)
μΆ”ν›„ RefreshToken μΆ”κ°€ μ‹œ OAuth 2.0둜 μ‰½κ²Œ ν™•μž₯ν•  수 μžˆλ‹€.

ν‘œμ€€μ€ snake_case(access_token) μ΄μ§€λ§Œ,
μ„œλΉ„μŠ€ λ‚΄ 일관성을 μš°μ„ ν•΄μ„œ camelCaseλ₯Ό(accessToken) μ‚¬μš©ν•˜κΈ°λ‘œ ν–ˆλ‹€.
μ™ΈλΆ€ OAuth ν˜Έν™˜ ν•„μš” μ‹œ @JsonProperty 둜 λ³€ν™˜ν•˜λ©΄ λœλ‹€κ³  ν•œλ‹€.

/**
 * 둜그인 μš”μ²­ DTO.
 *
 * <p>POST /api/v1/auth/token 의 Request Body.</p>
 */
public record LoginRequest(
        @NotBlank(message = "둜그인 μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
        String loginId,

        @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
        String password
) {
    public LoginCommand toCommand() {
        return new LoginCommand(
                loginId,
                password
        );
    }
}

AuthController

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

    private final AuthApplicationService authApplicationService;

    @PostMapping("/token")
    public ResponseEntity<ApiResponse> login(@Valid @RequestBody LoginRequest request) {
        TokenResponse response = authApplicationService.login(request.toCommand());
        return ApiResponse.ok(response);
    }
}



UserContextHolder κ΅¬ν˜„

ThreadLocal κ°œλ… 정리

ThreadLocalμ΄λž€?

각 μŠ€λ ˆλ“œλ§ˆλ‹€ 자기만의 λ…λ¦½λœ λ³€μˆ˜ μ €μž₯μ†Œ.

  1. 일반 λ³€μˆ˜λŠ” λͺ¨λ“  μŠ€λ ˆλ“œκ°€ 곡유:
private static String shared = "hello";
// μŠ€λ ˆλ“œ A κ°€ "world" 둜 λ³€κ²½ν•˜λ©΄
// μŠ€λ ˆλ“œ B 도 "world" 둜 λ³΄μž„ β†’ λ™μ‹œμ„± 문제
  1. ThreadLocal 은 μŠ€λ ˆλ“œλ³„λ‘œ 격리
public static final ThreadLocal<UserContext> CONTEXT;

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           Thread A               β”‚       β”‚           Thread B               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚       β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚       ThreadLocalMap         β”‚ β”‚       β”‚ β”‚       ThreadLocalMap         β”‚ β”‚
β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚       β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚
β”‚ β”‚     Key      β”‚    Value      β”‚ β”‚       β”‚ β”‚     Key      β”‚    Value      β”‚ β”‚
β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚       β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚
β”‚ β”‚   CONTEXT    β”‚    userA      β”‚ β”‚       β”‚ β”‚   CONTEXT    β”‚    userB      β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚       β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                (A,B μ„œλ‘œ κ°„μ„­ λΆˆκ°€)

λ©”λͺ¨λ¦¬ λˆ„μˆ˜ μœ„ν—˜ - clearκ°€ ν•„μš”ν•œ 이유

μ‹œλ‚˜λ¦¬μ˜€: Tomcat 같은 μŠ€λ ˆλ“œ ν’€ ν™˜κ²½
Tomcat 의 μ›Œμ»€ μŠ€λ ˆλ“œ ν’€: 200개 μŠ€λ ˆλ“œ

[μš”μ²­ 1]
  μŠ€λ ˆλ“œ A λ°›μŒ
  CONTEXT.set(userA)    ← Thread A 의 Map 에 μ €μž₯
  ... 처리 ...
  응닡 λ°˜ν™˜
  ← 이 μ‹œμ μ— CLEAR ν•˜μ§€ μ•ŠμœΌλ©΄ ?

[μš”μ²­ 2 (λ‹€λ₯Έ μ‚¬μš©μž)]
  μŠ€λ ˆλ“œ A λ‹€μ‹œ λ°›μŒ (μŠ€λ ˆλ“œ ν’€μ—μ„œ μž¬μ‚¬μš©)
  CONTEXT.get()         ← userA κ·ΈλŒ€λ‘œ λ‚¨μ•„μžˆμŒ! ❌
  β†’ λ‹€λ₯Έ μ‚¬μš©μž μš”μ²­μ„ μ²˜λ¦¬ν•˜λ©΄μ„œ 이전 μ‚¬μš©μž 정보 μ‚¬μš©
  β†’ **κΆŒν•œ λˆ„μˆ˜ / λ³΄μ•ˆ 사고**

κ·Έλž˜μ„œ try-finally + clear()

try {
    UserContextHolder.set(...);
    chain.doFilter(...);
} finally {
    UserContextHolder.clear();  // ν•„μˆ˜
}

ThreadLocal 의 ν•œκ³„

β‘  비동기 μž‘μ—… μ‹œ μ»¨ν…μŠ€νŠΈ μ•ˆ λ„˜μ–΄κ°

public void method() {
    UserContextHolder.set(userA);
    
    CompletableFuture.supplyAsync(() -> {
        // 별도 μŠ€λ ˆλ“œμ—μ„œ μ‹€ν–‰
        return UserContextHolder.get();   // null!
    });
}

이유: ThreadLocal 은 ν˜„μž¬ μŠ€λ ˆλ“œλ§Œ 인식. λ‹€λ₯Έ μŠ€λ ˆλ“œλŠ” 자기 Map 만 λ΄„.
해결법

  • InheritableThreadLocal β€” μžμ‹ μŠ€λ ˆλ“œ 생성 μ‹œ 볡사 (κ·Όλ³Έ ν•΄κ²° μ•„λ‹˜)
  • TaskDecorator β€” Spring 의 비동기 μž‘μ—… μ‹œ λͺ…μ‹œμ μœΌλ‘œ μ»¨ν…μŠ€νŠΈ 볡사
  • MDC (SLF4J) β€” λ‘œκΉ…μ— ν•œν•΄ λΉ„μŠ·ν•œ νŒ¨ν„΄
  • μˆ˜λ™ 전달 β€” λžŒλ‹€ 캑처

β†’ MVP 에선 동기 μ²˜λ¦¬μ—μ„œλ§Œ μ‚¬μš© κ³ λ €.

둜그인 ν›„ κ²Œμ΄νŠΈμ›¨μ΄ 헀더 μ£Όμž… 확인


μ–΄λ…Έν…Œμ΄μ…˜ ν…ŒμŠ€νŠΈ

 @GetMapping("/me/_debug")
    @AuthRequired
    public ResponseEntity<ApiResponse> debugMe(
            @CurrentUserId UUID userId,
            @CurrentUserRole Role role
    ) {
        return ApiResponse.ok(Map.of(
                "userId", userId.toString(),
                "role", role.name()
        ));
    }


곡톡 λͺ¨λ“ˆλ‘œ 이관

profile
ν’€μŠ€νƒ μ—°μŠ΅μƒ. λˆκΈ°μžˆλŠ” μ‚½μ§ˆλ‘œ λ¬΄λŒ€μ—μ„œ ν™”λ €ν•˜κ²Œ 데뷔할 μ˜ˆμ • ❀️πŸ”₯

0개의 λŒ“κΈ€