Spring Boot SNS 포트폴리오 시리즈 2편. API Gateway 한 곳에서 JWT 서명을 검증하고 Redis 블랙리스트를 확인한 뒤 X-User-Id 헤더를 주입하는 흐름을 코드 한 줄씩 따라갑니다. WebFlux 비동기 게이트웨이의 함정과 BASE64 서명 키, 공개 경로 정책까지 한 글에 정리합니다.
이 글은 Spring Boot SNS 마이크로서비스 포트폴리오 시리즈의 2편입니다. 1편에서 잡은 큰 그림 — 회사 본사 한 동에 부서 4개와 공용 시설 7개 — 중에서 정문 보안실 역할을 하는 API Gateway 한 곳을 줌인해 풀어 갑니다. 모든 외부 요청이 여기 한 번 거치면서 JWT가 검증되고, Redis 블랙리스트가 확인되고, 사용자 ID가 내부 헤더로 박혀 부서로 전달됩니다.
비유는 1편과 그대로 이어 가요 — 회사 정문에 앉은 보안실 직원 한 명이 모든 방문자의 사원증(JWT)을 받아 보고, 본사 분실 사원증 명단(Redis 블랙리스트)에 적혀 있는지 한 번 더 확인한 뒤, "ID 7번 직원, A동 콘텐츠부로" 라고 적힌 쪽지(X-User-Id 헤더)를 들려서 안으로 들여보내는 그림. 이 한 줄 비유만 머리에 박아 두면 게이트웨이 코드는 그냥 그 일과의 기록이 됩니다.
프로젝트 SNS는 이메일·비밀번호와 구글·깃허브 OAuth2 로그인, 게시글·댓글·좋아요·랭킹·구독·알림·한국어 검색까지 흔한 기능을 한 번씩 다 다뤄 보면서, 그 평범한 기능들을 마이크로서비스 패턴(Database-per-Service · API Gateway · Outbox + CDC · Redisson 분산 락)과 인프라(Kafka·Redis·Elasticsearch·LocalStack S3)로 어떻게 묶어 내는가를 직접 손으로 짜 보는 학습용 포트폴리오입니다. 1편에서 큰 그림을 잡았고, 2편부터 부서 한 곳씩 줌인해 갑니다.
왜 API Gateway가 처음엔 어렵게 느껴질까
처음 게이트웨이 코드를 보면 막히는 지점이 두 가지예요.
첫째, WebFlux라는 다른 종족의 Spring Boot예요. 보통 컨트롤러 짤 때 익숙한 @RestController + 동기 메서드 + @Autowired StringRedisTemplate 조합이 통하지 않습니다. Mono<Void>·flatMap·ReactiveStringRedisTemplate 처음 보면 "이게 다 뭐지" 싶거든요. 한 줄만 잘못 짜도 이벤트 루프가 막히면서 게이트웨이 전체가 느려지는데, 그 증상이 한참 뒤에야 나타나서 디버깅이 까다롭습니다.
둘째, JWT라는 또 다른 추상도가 한 번에 얹힙니다. "헤더에서 토큰을 꺼낸다"·"서명을 검증한다"·"클레임을 파싱한다"·"jti를 본다" 가 한꺼번에 등장하는데, 각 용어가 뭐고 왜 필요한지가 안 짚이면 코드가 그냥 외계어처럼 보입니다.
해결법은 두 단계예요. 먼저 정문 보안실 직원의 일과를 한 요청이 들어오면 무슨 순서로 무엇을 하는지 한 번 그려 보고, 그다음에 JWT 한 토큰의 생애 — 발급 → 검증 → 만료 또는 블랙리스트 등재 — 를 한 줄로 따라가 봅니다. 이 두 줄이 통과되면 코드는 그냥 일과의 받아쓰기가 됩니다.
정문 보안실의 일과 — 한 요청이 들어오면
게이트웨이가 한 요청을 받았을 때 무엇을 어떤 순서로 하는지 먼저 그림으로.
요청 도착 (예: GET /v1/posts/7)
│
▼
1. 공개 경로인가? (회원가입·로그인·게시글 목록 등)
├─ 예 → 토큰 검사 SKIP, 부서로 바로 전달
└─ 아니오 → 다음 단계로
▼
2. Authorization 헤더에 Bearer 토큰이 있나?
├─ 없음 → 401 Unauthorized
└─ 있음 → 다음 단계로
▼
3. JWT 서명 검증 + 클레임(jti, sub, email) 파싱
├─ 위조·만료 → 401 Unauthorized
└─ OK → 다음 단계로
▼
4. Redis 블랙리스트 확인 (jti가 등재돼 있는가)
├─ 등재 → 401 (로그아웃된 토큰)
└─ 미등재 → 다음 단계로
▼
5. 요청에 X-User-Id, X-User-Email 헤더 주입
▼
6. Path 조건으로 부서 라우팅
/v1/users/** → User Service (8081)
/v1/posts/** , media → Post Service (8082)
/v1/notifications/** → Notification Service (8083)
이 6단계가 하나의 GlobalFilter 안에서 다 돕니다. 핵심은 3·4번이 토큰 한 개당 두 번의 검사라는 점이에요. 서명 검증(3번)이 통과해도 블랙리스트(4번)에 걸리면 거부합니다. JWT의 약점인 "한 번 발급되면 만료까지 무효화 못 함"을 4번 단계로 메우는 구조입니다.
Spring Cloud Gateway가 WebFlux인 이유 — "동기 클라이언트 섞으면 다 막힘"
게이트웨이는 트래픽이 가장 먼저 닿는 곳이라, 단일 요청을 빠르게 처리하는 것보다 많은 동시 요청을 적은 스레드로 받아내는 게 훨씬 중요합니다. 그래서 Spring MVC의 "요청 1건당 스레드 1개" 모델이 아니라, Netty 위에 올린 WebFlux 이벤트 루프 모델을 씁니다.
Spring MVC : 동시 요청 1000개 → 스레드 1000개 (메모리 폭발)
Spring WebFlux : 동시 요청 1000개 → 이벤트 루프 스레드 8개 (CPU 코어 수 정도)
이벤트 루프가 잘 돌려면 절대 지키는 룰이 하나 있습니다 — 이벤트 루프 스레드를 절대 막으면 안 됩니다. Redis 호출이든 DB 호출이든 외부 호출이 동기로 박히면 그 스레드가 응답 올 때까지 멈추고, 그동안 다른 요청도 다 같이 대기합니다. 게이트웨이 한 곳이 막히면 시스템 전체가 같이 멈추는 셈이라 정말 심각해요.
// ❌ 블로킹 (WebFlux에서 사용 금지)
@Autowired StringRedisTemplate redisTemplate;
Boolean blacklisted = redisTemplate.hasKey("session:blacklist:" + jti);
// ↑ 이 한 줄이 응답 받을 때까지 이벤트 루프 스레드를 통째로 멈춤
// ✅ 리액티브 (WebFlux에서 사용)
@Autowired ReactiveStringRedisTemplate redisTemplate;
return redisTemplate.hasKey("session:blacklist:" + jti) // Mono<Boolean>
.flatMap(blacklisted -> { /* 응답 도착 시 콜백 */ });
리액티브 버전은 Redis 응답이 도착할 때까지 스레드를 놓아 줍니다. 그 사이에 다른 요청을 같은 스레드가 받아 처리하다가, Redis가 응답하면 콜백으로 깨워서 마저 처리하는 식이죠. 코드는 좀 낯설지만 이 한 가지 규칙만 지키면 게이트웨이가 1000명 동시 접속도 무리 없이 받아냅니다.
여기서 시험 함정이 하나 있어요. WebFlux 코드 안에 한 줄이라도 동기 호출이 섞이면 전체가 막힙니다. 코드 리뷰할 때 @Autowired 옆에 Reactive 가 빠진 건 없는지, .block() 호출이 섞이지 않았는지가 단골 체크 포인트예요. 동기 호출 한 줄 때문에 부하 테스트에서 처리량이 1/100로 떨어지는 일이 실제로 일어나거든요.
게이트웨이 = WebFlux 이벤트 루프 / 외부 호출은 모두 리액티브 / 동기 한 줄 섞이면 전체 막힘.
JWT 검증 5단계 — 코드 한 줄씩
이제 GlobalFilter 본체를 봅니다. 위 일과의 1~5단계가 한 메서드 안에 들어 있어요.
@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
HttpMethod method = exchange.getRequest().getMethod();
// 1단계: 공개 경로는 토큰 검사 없이 통과
if (isPublic(method, path)) {
return chain.filter(exchange);
}
// 2단계: Authorization 헤더에서 토큰 추출
String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return setUnauthorized(exchange);
}
String token = authHeader.substring(7);
try {
// 3단계: JWT 서명 검증 + 클레임 파싱
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
String jti = claims.getId(); // JWT 고유 ID (UUID)
String userId = claims.getSubject(); // 사용자 ID
String email = claims.get("email", String.class);
// 4단계: Redis 블랙리스트 확인 (비동기)
return redisTemplate.hasKey("session:blacklist:" + jti)
.flatMap(blacklisted -> {
if (Boolean.TRUE.equals(blacklisted)) {
return setUnauthorized(exchange); // 로그아웃된 토큰
}
// 5단계: 하위 서비스에 userId 헤더 주입
ServerWebExchange mutated = exchange.mutate()
.request(r -> r.header("X-User-Id", userId)
.header("X-User-Email", email))
.build();
return chain.filter(mutated);
});
} catch (JwtException e) {
return setUnauthorized(exchange); // 위조/만료된 토큰
}
}
@Override
public int getOrder() {
return -1; // 다른 모든 필터보다 먼저 실행
}
}
한 줄씩 짚어 보면 이런 흐름이에요.
Jwts.parser().verifyWith(secretKey)— JJWT 라이브러리가 토큰의 서명 부분을 비밀키로 다시 계산해 일치하는지 확인합니다. 비밀키 없이는 위조한 토큰을 만들 수 없어요.claims.getId()(jti) — 토큰마다 발급 시 부여한 UUID. 블랙리스트의 키로 씁니다.claims.getSubject()(sub) — 사용자 ID. 헤더로 박을 값.claims.get("email", String.class)— JWT 표준 클레임 외에 서비스가 추가한 커스텀 클레임. 부서가 사용자 이메일도 알아야 할 때 쓰려고 박아 둔 값.getOrder() = -1— 게이트웨이의 다른 어떤 필터보다 먼저 실행되도록 우선순위를 가장 높게 잡았어요. 인증이 가장 먼저 끝나야 그다음 모든 필터가 안전하게 동작하니까요.
여기서 정말 중요한 시험 함정 — return chain.filter(mutated) 을 잊지 말 것. 게이트웨이의 모든 GlobalFilter는 마지막에 chain.filter(...)를 호출해 다음 필터로 넘기지 않으면 요청이 거기서 끊깁니다. 인증을 통과시키고도 깜빡 빠뜨리면 모든 요청이 200 OK 없이 영원히 응답 대기하는 신비한 증상이 나와요.
JWT 서명 키 함정 — Decoders.BASE64.decode() vs getBytes()
게이트웨이와 User Service가 같은 비밀키를 공유해야 게이트웨이에서 검증한 토큰을 부서가 다시 풀 수 있습니다. 그런데 이 비밀키를 읽는 방식 한 줄이 가장 흔한 실수 자리예요.
// ✅ 올바른 방법: Base64 문자열을 디코딩해 원래 바이트 배열로 복원
secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecret));
// ❌ 잘못된 방법: Base64 문자열 자체를 UTF-8 바이트로 변환 → 완전히 다른 키
secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
application.yml의 jwt.secret 값은 이미 Base64로 인코딩된 원본 비밀키예요. 이걸 그냥 getBytes()로 읽으면 "Base64 문자열을 바이트로" 읽는 게 되어 전혀 다른 키가 만들어집니다. Decoders.BASE64.decode()를 거쳐야 원래 비밀키 바이트가 복원돼요.
여기서 시험 함정이 하나 있어요. User Service와 API Gateway가 비밀키를 다른 방식으로 읽으면 토큰 검증이 영원히 실패합니다. User Service는 이 키로 서명한 토큰을 발급했는데, API Gateway가 다른 키로 검증하니 모든 요청이 401로 떨어지거든요. "로그인은 되는데 그다음 요청은 다 막힘" 이라는 증상이 나오면 거의 99% 이 문제예요. 두 서비스가 정확히 같은 코드 한 줄(Decoders.BASE64.decode(jwtSecret))로 키를 만들어야 합니다.
application.yml의 jwt.secret = 이미 Base64. 반드시 Decoders.BASE64.decode()로 풀기. User Service·API Gateway가 같은 코드로 키를 만들어야 함.
Redis 블랙리스트 — 로그아웃을 stateless에 끼워 넣는 트릭
JWT는 본질적으로 stateless라 서버가 직접 무효화할 수 없습니다. 한 번 발급된 JWT는 만료 시간(예: 15분)이 될 때까지 유효해요. 사용자가 "로그아웃" 버튼을 눌러도 토큰 자체는 살아 있는 거라, 누군가 그 토큰을 가로챘으면 15분 동안 마음대로 쓸 수 있습니다.
이걸 막는 게 Redis 블랙리스트 패턴이에요. 로그아웃 시 토큰의 jti를 Redis에 등재해 두고, 게이트웨이가 매 요청마다 한 번 조회합니다.
// User Service: 로그아웃 시 등록
public void logout(String accessToken) {
String jti = jwtProvider.getJti(accessToken);
Date expiration = jwtProvider.getExpiration(accessToken);
long ttl = expiration.getTime() - System.currentTimeMillis();
if (ttl > 0) {
redisTemplate.opsForValue().set(
"session:blacklist:" + jti,
"logout",
ttl, // 토큰이 자연 만료되면 키도 자동 삭제
TimeUnit.MILLISECONDS
);
}
}
핵심은 TTL을 토큰 만료까지로 잡는다는 점이에요. 토큰이 어차피 15분 후 자연 만료되면 그 이후엔 블랙리스트에 있을 필요가 없습니다 — 서명 검증에서 어차피 떨어지니까요. 그래서 Redis 키도 정확히 만료 시점에 자동 삭제되도록 TTL을 박아 두면 저장소가 무한히 커지지 않아요. 1억 명이 로그아웃해도 15분 후엔 다 자동 정리됩니다.
사용자 로그아웃
→ Redis: SET session:blacklist:abc-uuid "logout" EX 850 (남은 14분 10초)
→ 14분 10초 후 Redis가 자동 DELETE
다음 요청에 그 토큰을 쓰면
→ 게이트웨이: GET session:blacklist:abc-uuid → "logout" 발견 → 401
여기서 시험 함정이 하나 있어요. 블랙리스트 패턴은 "stateful"의 단점을 일부 다시 들여옵니다. Redis가 죽으면 게이트웨이가 매 요청마다 블랙리스트 조회에 실패하거든요. 그래서 운영에서는 Redis를 단일 인스턴스가 아니라 Sentinel 또는 Cluster로 띄워 가용성을 보장합니다. JWT의 stateless 장점을 일부 포기한 셈인데, 로그아웃·강제 로그아웃 같은 보안 요구를 위해 의식적으로 받아들이는 트레이드오프예요.
공개 경로 정책 — 어디는 토큰 없이 통과시킬까
회원가입·로그인은 토큰 없이 와야 합니다 (그게 토큰을 받기 위한 요청이니까요). 게시글 목록·검색·랭킹·게시글 상세 조회도 비로그인 사용자가 볼 수 있어야 하고요. 이런 경로들은 공개 경로로 분류해 토큰 검사 없이 통과시킵니다.
// POST 방식 공개 경로 (회원가입, 로그인, 토큰 갱신)
private static final Set<String> PUBLIC_POST_PATHS = Set.of(
"/v1/users/register", "/v1/users/login", "/v1/users/refresh"
);
// GET 방식 공개 경로
private static final Set<String> PUBLIC_GET_EXACT = Set.of(
"/v1/posts", // 게시글 목록
"/v1/posts/search", // 검색
"/v1/posts/ranking" // 랭킹
);
private boolean isPublic(HttpMethod method, String path) {
if (HttpMethod.POST.equals(method)) return PUBLIC_POST_PATHS.contains(path);
if (HttpMethod.GET.equals(method)) {
if (PUBLIC_GET_EXACT.contains(path)) return true;
if (path.startsWith("/v1/users/oauth2/")) return true;
if (path.matches("/v1/posts/\\d+$")) return true; // 게시글 상세
if (path.matches("/v1/posts/\\d+/comments$")) return true; // 댓글 조회
}
return false;
}
설계 원칙은 단순해요 — 인증이 필요한 동작이면 비공개, 단순 조회면 공개. 게시글 작성·좋아요·댓글 작성은 사용자가 누구인지 알아야 하니 비공개. 게시글 목록·상세·검색은 로그인 안 한 사용자도 볼 수 있어야 하니 공개.
여기서 정말 중요한 시험 함정 — 공개 경로 목록 한 줄 빼먹으면 사용자가 가입조차 못 합니다. 회원가입 경로가 비공개에 들어가 있으면 토큰이 없어서 401, 토큰을 받으려면 가입해야 하는데 가입이 안 되는 무한 루프가 생기죠. 새 공개 API를 추가할 때마다 이 목록을 같이 갱신해야 한다는 룰이 팀에 박혀 있어야 합니다.
또 하나의 함정 — @RequestHeader("X-User-Id") 를 게이트웨이를 거치지 않은 요청으로도 받지 않게 부서를 보호해야 합니다. 1편에서 짚었듯이 부서 포트(8081·8082·8083)를 외부에 절대 노출하지 말아야 해요. 운영 환경에서는 게이트웨이만 외부에 열고, 부서는 같은 사설망 안에서만 접근 가능하게 묶습니다. 클라우드라면 보안 그룹·VPC·k8s NetworkPolicy 같은 도구로요.
공개 경로 = 회원가입·로그인·토큰 갱신·게시글 조회/검색/랭킹·게시글 상세·댓글 조회·OAuth2 콜백. 새 공개 API 추가 시 이 목록 갱신 잊지 말 것.
시리즈 다음 편 — User Service 줌인
여기까지가 2편입니다. 정문 보안실 직원 한 명이 한 요청을 받아 6단계를 거쳐 부서로 보내는 일과를, 코드 한 줄씩 따라가며 봤어요. JWT 검증·Redis 블랙리스트·X-User-Id 주입·공개 경로 — 네 가지가 한 GlobalFilter 안에 잘 묶여 돕니다.
3편에서는 User Service를 줌인합니다. 회원가입에서 BCrypt로 비밀번호 해싱, 로그인 후 Access Token + Refresh Token 두 개를 발급, RefreshToken을 SHA-256 해시로 DB에 저장, 로그아웃 시 블랙리스트 등재, 그리고 OAuth2(Google·GitHub)로 소셜 로그인까지 — User Service의 일과를 처음부터 끝까지 따라가 봅니다.
공식 문서: Spring Cloud Gateway 공식 가이드와 JJWT 공식 README를 함께 보면 이 글의 코드가 어디서 왔는지가 잘 보입니다.
시리즈 다른 편
- 1편 — 마이크로서비스 아키텍처 전체 그림
- 2편 — API Gateway JWT 검증 (현재 글)
- 3편 — User Service · OAuth2
- 4편 — Redisson 분산 락 · 동시성
- 5편 — Kafka 이벤트 흐름 · Outbox
- 6편 — Redis 4가지 활용 패턴
- 7편 — Elasticsearch + S3 업로드
시험 직전 한 번 더 — JWT·게이트웨이 함정 압축 노트
- API Gateway = Spring Cloud Gateway · WebFlux 기반 (Spring MVC 아님)
- WebFlux 이벤트 루프 = 동시 요청 1000개를 스레드 8개로 처리
- 이벤트 루프 스레드 절대 막지 말 것 —
.block()호출·동기 클라이언트 금지 - Redis 호출은 반드시
ReactiveStringRedisTemplate(블로킹 X) - GlobalFilter
getOrder() = -1— 다른 필터보다 먼저 실행 - GlobalFilter 마지막엔 반드시
return chain.filter(...)— 빠뜨리면 응답 영원히 안 옴 - JWT 검증 5단계 = 공개 경로 → Bearer 추출 → 서명 검증 → 블랙리스트 → 헤더 주입
- JWT 표준 클레임 = jti(JWT ID) · sub(사용자 ID) · exp(만료) · iat(발급 시각)
- jti(JWT ID) = UUID, 블랙리스트 키 용도
- sub(subject) = 사용자 ID, X-User-Id 헤더로 박을 값
- 커스텀 클레임 =
claims.get("email", String.class)형태로 추가 가능 - JWT 서명 키 =
Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecret))— 절대getBytes()X - application.yml의
jwt.secret은 이미 Base64 인코딩된 값 — 디코딩 거쳐야 원래 키 - User Service·API Gateway가 같은 코드로 키 만들어야 토큰 검증 통과
- 블랙리스트 키 =
session:blacklist:{jti}, TTL = 토큰 남은 만료 시간 - TTL 자동 삭제로 저장소 무한 증가 방지 — 만료된 토큰은 어차피 서명 검증에서 떨어짐
- Redis 죽으면 블랙리스트 조회 실패 → 운영에서는 Sentinel 또는 Cluster
- 공개 경로 = 회원가입·로그인·토큰 갱신·게시글 목록·검색·랭킹·상세·댓글 조회·OAuth2
- 공개 경로 매칭 = Set 또는 정규식 (예:
/v1/posts/\d+$) - 새 공개 API 추가 시 PUBLIC 목록 갱신 잊지 말 것
- X-User-Id 위장 방지 = 부서 포트(8081·8082·8083) 외부 노출 금지
- WebFlux에서 401 반환 =
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)+setComplete() - Authorization 헤더 형식 =
Bearer <token>— 7글자 substring으로 토큰 추출 - 위조·만료 토큰 =
JwtExceptioncatch → 401 - Refresh Token은 게이트웨이에서 검증 안 함 — User Service의
/v1/users/refresh공개 경로에서만 처리
다음 글(3편)에서는 User Service의 회원가입·로그인·토큰 발급·로그아웃·OAuth2 흐름을 코드 한 줄씩 따라가며 풀어 갑니다.