서비스를 분리하면서 내부 인증은 한동안 X-Service-Token 방식으로 두고 있었다.
Feign 요청마다 헤더 하나 붙이면 됐고, 단순했다.
그런데 auction-service → product-service, auction-service → user-service, chat-service → auction-service
같은 호출이 늘어나면서 이 방식을 계속 가져가는 게 맞나 싶었다.
전체 코드는 여기서 볼 수 있다
GitHub - mangtaeeee/bowchat-auction
Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.
github.com
기존 구조
외부 사용자 요청은 비교적 명확했다.
user-service가 JWT 발급
각 서비스는 JWT 클레임(userId, email, role)만 파싱
user-service DB 재조회 없음
문제는 내부 호출이었다.
auction-service -> product-service /internal/products/{id}/seller
auction-service -> user-service /internal/users/{id}
chat-service -> auction-service /internal/auctions/{id}
내부 API는 외부 사용자 요청과 성격이 다르다.
호출 주체가 "사람"이 아니라 "서비스"다.
로그인한 사용자 권한이 아니라 서비스 간 신뢰를 확인해야 한다.
처음엔 이렇게 막았다.
bowchat-auction/auction-service/src/main/java/com/example/auctionservice/auth/config/FeignConfig.java at main · mangtaeeee/bowc
Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.
github.com
// Feign 쪽
requestTemplate.header("X-Service-Token", internalSecret);
// 받는 쪽
if (!internalSecret.equals(serviceToken)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return false;
}
Docker Network로 /internal/** 를 외부에서 직접 때리기 어렵게 막고,
공용 시크릿 하나로 서비스 간 신뢰를 확인하는 구조였다.
초기에는 충분히 실용적이었다.
한계가 생긴 지점
호출 주체를 구분할 수 없다.
X-Service-Token 은 결국 "이 비밀값을 알고 있는가"만 확인한다.
auction-service 가 호출했는지 chat-service 가 호출했는지 구분이 안 된다.
둘 다 같은 비밀값을 알고 있으면 통과다.
권한을 나눌 수 없다.
예를 들어 auction-service 는 product-service 의 판매자 조회 API를 호출할 수 있고,
chat-service 는 불가라는 정책을 만들고 싶어도 구현 방법이 없다.
"있으면 통과, 없으면 차단" 두 가지밖에 없다.
유출 범위가 너무 크다.
공용 시크릿 하나가 새면 내부 API 전체가 위험해진다.
서비스별로 credential이 나뉘어 있으면 영향 범위를 분리할 수 있다.
방향 전환
결국 이렇게 나눠야 했다.
외부 사용자 요청 → user-service JWT
내부 서비스 요청 → Keycloak OAuth 2.0 client credentials
사용자 인증과 내부 서비스 인증을 섞지 않는 게 목표였다.
Keycloak을 auth server로 두고, 각 서비스를 클라이언트로 등록했다.
auction-service, product-service, user-service, chat-service 각각이 자기 client_id, client_secret 으로 액세스 토큰을 발급받는다.
이러면 "누가 호출했는지"를 토큰 기준으로 구분할 수 있고,
scope로 권한도 나눌 수 있다.
지금은 auction.internal.read scope 하나로 맞췄지만
나중에 product.read, user.read 처럼 쪼개는 것도 가능한 구조다.
적용
Keycloak 자동 초기화
로컬에서 매번 realm을 수동으로 만드는 건 비현실적이라
realm import 파일을 같이 두고 docker compose up 시 자동 import 되게 했다.
bowchat-auction/infra at main · mangtaeeee/bowchat-auction
Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.
github.com
infra/
docker-compose-local.yml
keycloak/import/bowchat-realm.json
이 안에 realm, client 등록, scope 설정이 다 들어있어서
올리면 바로 쓸 수 있는 상태가 된다.
보안 체인 분리
각 서비스 보안 설정을 일반 API용과 /internal/** 전용으로 나눴다.
bowchat-auction/auction-service/src/main/java/com/example/auctionservice/auth/config/SecurityConfig.java at main · mangtaeeee/b
Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.
github.com
@Bean
@Order(1)
public SecurityFilterChain internalFilterChain(...) {
http
.securityMatcher("/internal/**")
.authorizeHttpRequests(auth -> auth
.anyRequest().hasAnyAuthority(
"ROLE_INTERNAL_SERVICE",
"SCOPE_auction.internal.read"
)
);
}
Feign에 Bearer 토큰 추가
호출하는 서비스는 Keycloak에서 토큰을 발급받아 요청에 붙인다.
bowchat-auction/auction-service/src/main/java/com/example/auctionservice/auth/config/OAuth2ClientConfig.java at main · mangtaee
Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.
github.com
authorizedClientManager
.map(manager -> OAuth2ClientConfig.resolveAccessToken(manager, clientRegistrationId))
.ifPresent(token -> requestTemplate.header(HttpHeaders.AUTHORIZATION, "Bearer " + token));
흐름은 이렇다.
auction-service
→ Keycloak에 토큰 요청
→ access token 발급
→ product-service /internal/** 호출
→ product-service가 issuer/scope 검증
기존 X-Service-Token은 바로 끊지 않았다
한 번에 전부 교체하면 디버깅이 어렵고 연동이 깨질 수 있다.
지금은 OAuth 2.0 Bearer가 정식 경로고, 기존 토큰은 안전한 전환용으로 남겨 뒀다.
정리
X-Service-Token 은 빠르고 단순해서 초기에 유용했다.
하지만 서비스 간 호출이 늘어나니 "누가 호출했는지, 어떤 권한으로 호출했는지"를 구분할 수 없다는 게 실질적인 문제가 됐다.
결국 이번 작업도 "보안을 더 세게 한다"보다
"인증 책임을 어디에 둘지, 어떤 경로를 표준으로 삼을지 정리하는 작업"에 더 가까웠다.
'Java > 기술회고' 카테고리의 다른 글
| 입찰은 저장됐는데 kafka 이벤트는 사라졌다: 경매 서비스에 Outbox를 적용한 이유 (0) | 2026.04.22 |
|---|---|
| Kafka를 제대로 쓰고 싶어서 MSA로 전환한 이야기 (0) | 2026.04.06 |
| Kafka Consumer Lag 600을 0으로 만든 방법 (k6 + Prometheus +Grafana) (0) | 2026.01.28 |
| Thread.sleep()은 왜 위험할까? Kafka DLQ로 안전하게 재시도 처리하기 (0) | 2025.12.15 |
| 로컬에서 Spring Boot 서버 2개 띄워 HAProxy로 트래픽 분산해보기 (0) | 2025.10.26 |