dependencies {
// jjwt
implementation 'io.jsonwebtoken:jjwt-api'
runtimeOnly 'io.jsonwebtoken:jjwt-impl'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson'
}
유지보수가 쉽도록 변수로 버전을 관리
ext {
set('springCloudVersion', "2025.0.2")
jjwtVersion = '0.12.6'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
mavenBom "io.jsonwebtoken:jjwt-root:${jjwtVersion}"
}
}
jwt:
secret: ${JWT_SECRET:32Bytes 보다 크게(RFC 2104 권장)}
# 주의!!: 운영 환경에서는 환경변수로만 주입
# 생성 명령: openssl rand -base64 64
현재는 설명을 위해 디폴트 값을 주었지만 이럴 경우 application-dev.yml에만 적용해야한다.
만약 배포에 사용되는 설정파일에서 디폴트 값을 주면 실수로 JWT_SECRET 변수를 빼먹었을 때 코드에 적나라하게 표시된 값이 secret으로 사용된다.
public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
JWT 관련 코드에서는 항상 io.jsonwebtoken.security 쪽 선택.
JDK 표준 java.security.SignatureException과 jjwt 라이브러리의 io.jsonwebtoken.security.SignatureException 같은 이름 충돌
Ctrl+N (Mac: Cmd+O)
→ "SignatureException" 입력
→ 두 개 보이면 충돌
jjwt 안에서도 SignatureException이 두 군데에 있다.
io.jsonwebtoken.SignatureException ← Deprecated (구버전 위치 0.10.x)
io.jsonwebtoken.security.SignatureException ← 현재 위치 ⭐io.jsonwebtoken (0.11+)
NoClassDefFoundError: com/fasterxml/jackson/databind/ser/std/ToEmptyObjectSerializer
ClassNotFoundException: com.fasterxml.jackson.databind.ser.std.ToEmptyObjectSerializer
Spring Boot 3.5.13 사용 중인 jackson:
jackson-core, jackson-databind, jackson-datatype-jsr310 (Spring 관리 버전)
jjwt-jackson 0.12.6 가 끌고 온 jackson:
jackson-databind 다른 버전
→ 클래스패스에 두 버전 섞임 → 일부 클래스가 옛 버전에서 로드 → ToEmptyObjectSerializer 못 찾음
// jjwt - 직접 버전 명시
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
요청 도착
↓
[1] Public Path 매칭?
Y → 통과 (인증 우회)
N
↓
[2] Authorization 헤더 추출
None → 401
↓
[3] "Bearer " prefix 검증
None → 401
↓
[4] 토큰 추출
↓
[5] JwtTokenProvider.validateToken()
false → 401
↓
[6] JwtTokenProvider.parseClaims()
예외 → 401
↓
[7] 헤더 주입 (mutate)
X-User-Id: {claims.userId}
X-User-Role: {claims.role}
↓
[8] chain.filter() → 다운스트림 서비스로
Spring Cloud Gateway에는 두 종류 필터가 있다.
GlobalFilter
모든 라우트에 적용 인증, 로깅 등에 적합
GatewayFilter
특정 라우트에만 적용 헤더 추가/제거 등 라우트별 동작 yml에 라우트별 설정 가능
인증은 모든 요청에 적용되는 부분이라 모든 라우트에 적용하는 GlobalFilter를 사용한다.
jwt:
secret: ${JWT_SECRET}
public-paths:
- /api/v1/users
- /api/v1/auth/**
- /actuator/**
- /api/v1/payment/webhook
Spring Cloud Gateway는 WebFlux 기반이라 Mono / Flux 를 사용한다.
Mono<T>: 상자 안에 내용물이 딱 1개(또는 0개) 들어있는 택배(예: 한 명의 유저 정보, 하나의 응답 메시지)
Flux<T>: 상자 안에 내용물이 여러 개 들어있는 택배(예: 전체 도서 목록, 채팅 메시지 리스트)
// 동기 (Servlet)
HttpServletResponse response = ...;
response.setStatus(401);
response.getWriter().write(jsonError);
// 비동기 (WebFlux)
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
DataBuffer buffer = response.bufferFactory().wrap(json.getBytes());
return response.writeWith(Mono.just(buffer));
application.yml의 jwt 섹션
↓
JwtProperties.java (record)로 자동 매핑
↓
@Autowired 또는 생성자 주입으로 사용
@ConfigurationProperties(prefix = "jwt") // GatewayApplication - @EnableConfigurationProperties 추가
public record JwtProperties(
String secret,
List<String> publicPaths
) {
}
@Value와 역할은 비슷하지만, @Value- 단순 값 전달
@ConfigurationProperties - 설정 객체 바인딩이라는 점에서 차이가 있다.
또, @ConfigurationProperties는
의 장점을 가졌다.
@ConfigurationProperties(prefix = "jwt") // GatewayApplication - @EnableConfigurationProperties 추가
public record JwtProperties(
String secret,
List<String> publicPaths
) {
}
// Before
public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
// After
public JwtTokenProvider(JwtProperties jwtProperties) {
this.secretKey = Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8));
}
public interface GlobalFilter {
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}
모든 라우트에 자동 적용
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 100;
}
숫자 작을수록 먼저 실행
우리는 가장 일찍(인증) 실행되어야 한다: HIGHEST_PRECEDENCE + 100 (약 -21억 ... 4700 + 100)
(100을 추가하는 이유: 더 일찍 실행해야 할 필터가 생길 경우를 대비)
pathMatcher.match("/api/v1/auth/**", "/api/v1/auth/token"); // true
pathMatcher.match("/api/v1/auth/**", "/api/v1/users"); // false
ServerHttpRequest 는 불변객체이기 때문에, 헤더를 추가하려면 새 객체 만들어야한다.
mutate() = "변경된 복사본 만들기" 패턴 :
(원본을 보존하고 새 객체를 반환한다.)
ServerHttpRequest mutated = request.mutate()
.header("X-User-Id", "...")
.header("X-User-Role", "...")
.build();
return chain.filter(exchange.mutate().request(mutated).build());
Mono<Void> 반환Spring Cloud Gateway는 WebFlux 기반이라 Servlet의 response.getWriter() 패턴 대신 DataBuffer + Mono.just 로 응답한다.
401 응답:
Servlet과 다르게 수동으로 JSON 작성:
1. ObjectMapper로 Map → JSON 변환
2. DataBuffer로 감쌈
3. response.writeWith(Mono.just(buffer)) 로 클라이언트에 전송
비동기 환경에서 응답 페이로드 직렬화 → 버퍼링 → 비동기 쓰기
return chain.filter(exchange); // 다음 필터로
return response.writeWith(Mono.just(buffer)); // 응답 직접 작성하고 끝
참고:
Mono<T> = 비동기 단일 값 (0 또는 1개)Mono<Void> = 결과는 없고 완료 시점만 알림Public Path — 토큰 없이 통과
http://localhost:8080/api/v1/users

http://localhost:8080/api/v1/meetings



Project 패널에서 com.~~~ 패키지 클릭
우클릭 → Refactor → Rename (또는 Shift + F6)
옵션 체크:
✅ Search in comments and strings
✅ Search for references
새 이름 입력
Refactor > 영향 받는 파일 미리보기 확인 → Do Refactor
~/.gradle/gradle.properties 작성
# ~/.gradle/gradle.properties (사용자 홈 — 프로젝트 밖!)
gpr.user=YOUR_GITHUB_USERNAME
gpr.key=ghp_YOUR_PERSONAL_ACCESS_TOKEN
build.gradle 추가
repositories {
mavenCentral()
// GitHub Packages - pagely-common
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/Pagely-wisely/pagely-common")
credentials {
username = project.findProperty("gpr.user") ?: System.getenv("GITHUB_USERNAME")
password = project.findProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN")
}
}
}
dependencies {
implementation 'com.pagely:pagely-common:0.1.2'
}
검증 정책:
- loginId: 4-50자, 영숫자/언더스코어
- email: @Email + 100자 이하
- password: NotBlank (정책 검증은 Password VO 담당)
- name: 100자 이하
- nickname: 2-30자
- phone: 010-XXXX-XXXX 형식
- gender: NotNull (MALE/FEMALE)
- birthDate: ISO 8601 (yyyy-MM-dd)
@DateTimeFormat: JSON "1990-01-01" → LocalDate 자동 변환.@Pattern 정규식:loginId: ^[a-zA-Z0-9_]+$ — 영숫자/언더스코어만phone: ^010-\d{4}-\d{4}$ — 한국 휴대폰 형식 private void validateDuplicate(SignupRequest request) {
if (userRepository.existsByLoginId(request.loginId())) {
throw new BusinessException(UserErrorCode.DUPLICATE_LOGIN_ID);
}
if (userRepository.existsByEmail(request.email())) {
throw new BusinessException(UserErrorCode.DUPLICATE_EMAIL);
}
if (userRepository.existsByNickname(request.nickname())) {
throw new BusinessException(UserErrorCode.DUPLICATE_NICKNAME);
}
}
애플리케이션 단에서 중복을 확인해도 동시 요청의 경우 두 요청 모두 자신이 사용할 아이디"x" 가 없다고 판단해 충돌이 일어날 수 있다.
이를 막기 위해서 DB자체에 Unique 제약을 걸어둔다.
시점 사용자 A 사용자 B
T1 existsByloginId("x") false
T2 existsByloginId("x") false
T3 save(user_A)
T4 save(user_B) ← UNIQUE 위반
@PostMapping
public ResponseEntity<ApiResponse> signup(@Valid @RequestBody SignupRequest request) {
SignupResponse response = userApplicationService.signup(request.toCommand());
return ApiResponse.created(response);
}






[1] DTO Validation (@Valid)
역할: 형식 검증 (NotBlank, Size, Pattern, Email)
예외: MethodArgumentNotValidException
HTTP: 400 INVALID_INPUT
응답: fieldErrors 포함
[2] Domain VO Validation (Password.validatePolicy)
역할: 도메인 정책 (비밀번호 강도, 공백 등)
예외: BusinessException(INVALID_PASSWORD_POLICY)
HTTP: 400 INVALID_PASSWORD_POLICY
응답: 단일 에러 + 명확한 사유
[3] Application Service Validation (existsBy*)
역할: 비즈니스 규칙 (중복 확인)
예외: BusinessException(DUPLICATE_*)
HTTP: 409 Conflict
[4] Database Constraint
역할: 최후 방어선 (race condition)
예외: DataIntegrityViolationException → BusinessException 변환
HTTP: 409 Conflict
와... 하다하다 이미 PR까지 올리며 작업하고있던 프로젝트에 패키지 이름에 오타가 났다.
이 사실을 뒤늦게 깨달았다.
처음엔 레포와 프로젝트를 아예 처음부터 만들어야 하나.. 생각했는데 InteliJ에서 패키지 이름을 변경해주는 기능을 사용할 수 있다는 것을 알게 되어 패키지 이름을 수정하는 새 이슈를 발행해 진행하기로 했다.
분산 환경에서 모니터링 방법
학습 키워드
분산 트레이싱/모니터링ObservabilityMetricsLoggingTracingTraceIdSpanIdCorrelation ID특강 목표
- MSA 운영에 필요한 모니터링 기법을 학습합니다.
- 관측 가능성의 3대 요소(Logs, Metrics, Traces)를 체계적으로 이해합니다.
- 분산 트레이싱의 핵심 개념을 학습하고, 분산 시스템 문제를 분석 및 해결하는 역량을 기릅니다.