4/27(월) GW인증 필터, 회원가입

dev_joo·2026년 4월 27일

GW - 인증 필터 구현

jjwt 의존성 추가

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}"
    }
}

JwtClaims

JwtTokenProvider

application.yml

jwt:
  secret: ${JWT_SECRET:32Bytes 보다 크게(RFC 2104 권장)}
  # 주의!!: 운영 환경에서는 환경변수로만 주입
  # 생성 명령: openssl rand -base64 64

현재는 설명을 위해 디폴트 값을 주었지만 이럴 경우 application-dev.yml에만 적용해야한다.

만약 배포에 사용되는 설정파일에서 디폴트 값을 주면 실수로 JWT_SECRET 변수를 빼먹었을 때 코드에 적나라하게 표시된 값이 secret으로 사용된다.

HMAC-SHA 키 생성 (서명용 키)

public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
    this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}

예외 트러블 슈팅 (같은 이름 import 주의)

JWT 관련 코드에서는 항상 io.jsonwebtoken.security 쪽 선택.
JDK 표준 java.security.SignatureException과 jjwt 라이브러리의 io.jsonwebtoken.security.SignatureException 같은 이름 충돌

같은 이름 클래스 검색

Ctrl+N (Mac: Cmd+O)
→ "SignatureException" 입력
→ 두 개 보이면 충돌

Deprecated된 import 경로 수정하기

jjwt 안에서도 SignatureException이 두 군데에 있다.

io.jsonwebtoken.SignatureException              ← Deprecated (구버전 위치 0.10.x)
io.jsonwebtoken.security.SignatureException     ← 현재 위치 ⭐io.jsonwebtoken (0.11+)

Jackson 버전 충돌 문제 해결

원인 분석

NoClassDefFoundError: com/fasterxml/jackson/databind/ser/std/ToEmptyObjectSerializer
ClassNotFoundException: com.fasterxml.jackson.databind.ser.std.ToEmptyObjectSerializer
  • jackson-datatype-jsr310 라이브러리가 ToEmptyObjectSerializer 클래스를 찾으려 함
  • 이 클래스는 jackson-databind 의 특정 버전 이상에 존재
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}"

AuthenticationFilter

요청 도착
    ↓
[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를 사용한다.

public path 추가

jwt:
  secret: ${JWT_SECRET}
  public-paths:
    - /api/v1/users
    - /api/v1/auth/**
    - /actuator/**
    - /api/v1/payment/webhook

Reactive 패턴

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));

ConfigurationProperties (@Value) 여러개의 값을 불러올 때

application.yml의 jwt 섹션
        ↓
JwtProperties.java (record)로 자동 매핑
        ↓
@Autowired 또는 생성자 주입으로 사용
@ConfigurationProperties(prefix = "jwt") // GatewayApplication - @EnableConfigurationProperties 추가
public record JwtProperties(
        String secret,
        List<String> publicPaths
) {
}

@Value와 역할은 비슷하지만, @Value- 단순 값 전달
@ConfigurationProperties - 설정 객체 바인딩이라는 점에서 차이가 있다.
또, @ConfigurationProperties

  • 여러 값 묶어서 관리
  • 타입 안전 (List, 중첩 객체 가능)
  • 검증 가능

의 장점을 가졌다.

JwtProperties

@ConfigurationProperties(prefix = "jwt") // GatewayApplication - @EnableConfigurationProperties 추가
public record JwtProperties(
        String secret,
        List<String> publicPaths
) {
}

JwtTokenProvider 수정

// 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));
}

AuthenticationFilter

GlobalFilter 인터페이스

public interface GlobalFilter {
    Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}

모든 라우트에 자동 적용

  • chain.filter(exchange) → 다음 필터로 진행
  • 반환 안 하면 → 요청 차단 (응답 직접 작성)

Ordered — 필터 실행 순서

@Override
public int getOrder() {
    return Ordered.HIGHEST_PRECEDENCE + 100;
}

숫자 작을수록 먼저 실행
우리는 가장 일찍(인증) 실행되어야 한다: HIGHEST_PRECEDENCE + 100 (약 -21억 ... 4700 + 100)
(100을 추가하는 이유: 더 일찍 실행해야 할 필터가 생길 경우를 대비)

AntPathMatcher — Ant 패턴 매칭

pathMatcher.match("/api/v1/auth/**", "/api/v1/auth/token");  // true
pathMatcher.match("/api/v1/auth/**", "/api/v1/users");        // false

request.mutate() — 불변 객체 갱신

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


InteliJ 패키지 이름 변경

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'   
}

회원가입 요청 검증 (presentation DTO)

검증 정책:
- 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}$ — 한국 휴대폰 형식

UserApplicationService

동시접근 문제 차단

    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 위반

Controller

@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

프로젝트 생성시, Group 패키지 이름에 오타가 났을때

와... 하다하다 이미 PR까지 올리며 작업하고있던 프로젝트에 패키지 이름에 오타가 났다.
이 사실을 뒤늦게 깨달았다.

처음엔 레포와 프로젝트를 아예 처음부터 만들어야 하나.. 생각했는데 InteliJ에서 패키지 이름을 변경해주는 기능을 사용할 수 있다는 것을 알게 되어 패키지 이름을 수정하는 새 이슈를 발행해 진행하기로 했다.

모니터링 특강

분산 환경에서 모니터링 방법

학습 키워드

분산 트레이싱/모니터링 Observability Metrics Logging Tracing TraceId SpanId Correlation ID

특강 목표

  • MSA 운영에 필요한 모니터링 기법을 학습합니다.
  • 관측 가능성의 3대 요소(Logs, Metrics, Traces)를 체계적으로 이해합니다.
  • 분산 트레이싱의 핵심 개념을 학습하고, 분산 시스템 문제를 분석 및 해결하는 역량을 기릅니다.

모놀리식 vs MSA 에서의 모니터링

  • 모놀리식은 로그를 단일 애플리케이션에서 한번에 볼 수 있어 오류 파악을 할 수 있지만, MSA 환경에서는 어떤 서비스에서 어떤 오류가 발생했는지 정보가 분산되어있다.
profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글