Smilegate Dev Camp에서 개발한 내용을 정리한 글입니다. [깃허브][회고] 틀린 내용이나 질문, 더 나은 구조 제안 등이 있을 경우 댓글 달아주시면 감사하겠습니다.
💭 들어가며
Passport를 도입하면서 가장 큰 어려움은 레퍼런스가 거의 없었다는 점이었다. 정보가 부족한 만큼, 앞으로 이 기술을 도입하려는 분들께 조금이나마 도움이 되었으면 하는 바람으로 MSA 기반 Passport 인증 시스템 도입기를 정리하고자 한다.
본격적인 내용에 앞서, 꼭 인지하고 읽어주셨으면 하는 부분이 있다. Toss 기술 블로그와 Netflix 기술 블로그에서도 언급했듯이, JWT 발급과 Passport 생성은 원래 하나의 인증 서버에서 모두 처리하는 것이 이상적인 구조다. 그러나 우리 프로젝트에서는 인증 서버를 프론트엔드에서 Fastify로 구현했고, 해당 언어와 프레임워크를 백엔드 측에서 학습할 시간이 부족했기 때문에, Passport 발급을 전담하는 별도의 Passport 서버를 따로 두게 되었다. 즉, 현재 구조는 인증 서버가 두 개로 분리되어 있는 특수한 상황임을 꼭 참고해주시길 바란다. 특히 코드를 참고하실 때 이 점을 고려해 주시면 좋겠다!
✅ 도입 배경
해당 프로젝트를 하기 이전에는, 하나의 서버에서 Spring Security를 통해 인증을 처리한 뒤 그 상태로 API를 호출하는 방식을 주로 사용해 왔다. 하지만 이번에는 여러 개의 서버로 나뉜 MSA 환경을 경험하면서, 인증 방식에 대한 고민이 깊어졌다. 기존 구조에서는 각 서버가 요청마다 JWT를 인증 서버에 검증 요청하고, 사용자 정보를 받아온 후에야 실제 비즈니스 로직을 수행할 수 있었다. 예를 들어, 사용자가 채널을 생성하려는 경우에도, 서비스 서버는 먼저 인증 서버에 사용자 정보를 요청한 뒤에야 채널 생성이 가능했다. 즉, 각 서버마다 최소 1회 이상의 추가적인 API 호출이 필요했던 셈이다.
그러다 토스와 넷플릭스에서 MSA 구조에서 Passport를 사용하는 사례를 접했고, 기술적으로도 흥미로웠다. 또한, JWT와 비교했을 때 Protobuf는 이진 포맷 형식으로 매우 가볍고 빠르다는 사실을 알게 되어 도입을 결정하게 되었다. 이제는 인증 서버에서 사용자 정보를 포함한 Passport를 발급하고, 각 서버에서는 공통 모듈의 Resolver를 통해 사용자 정보를 추출해 활용하는 구조로 변경했다.
다만, 우리 프로젝트의 실제 적용 아키텍처는 위와 같다. 외부에서는 오직 JWT를 통해서만 API를 호출할 수 있으며, Gateway는 JWT가 포함된 요청을 받을 경우 Passport 서버에 Passport 생성을 요청한다. 이렇게 생성된 Passport는 내부 서버에서 사용자 정보를 키 기반으로 추출해 활용할 수 있다. 즉, 외부에서는 JWT, 내부에서는 Passport를 사용하는 구조다. 반면, JWT를 처음 발급받는 경우에는 Gateway가 직접 인증 서버로 요청을 보낸다.
이러한 구조는 앞서 도입 부분에서도 언급했듯이, Fastify를 학습할 시간이 부족했기 때문에 선택한 방식이며, 원래 이상적인 구조는 하나의 인증 서버에서 JWT 발급과 Passport 생성이 모두 이뤄지는 것임을 다시 한번 강조한다.
✅ Passport란
Passport는 사용자의 인증 정보를 담은 내부 전용 토큰으로, 서비스 간 통신 시 사용자 정보를 안전하게 전달하기 위해 사용된다. Protobuf로 직렬화된 후 HMAC 서명을 통해 위·변조를 방지하고, 최종적으로 Base64로 인코딩되어 전달된다. 외부에서는 JWT를 사용하고, 내부에서는 Passport를 사용하는 구조다.
Passport.proto는 현재 이 글에서 다루는 핵심으로, Passport 객체의 구조를 정의한 Protobuf 메시지 정의 파일이다. Passport에 포함될 필드들을 명시하며, 컴파일 시 PassportProto.Passport 클래스가 생성되어 Java 코드에서 직접 사용할 수 있다. 정상적으로 인식되려면 빌드 과정을 거쳐야 하며, 그 방법은 4️⃣에서 자세히 설명한다.
🔽 HmacUtil
@Component
public class HmacUtil {
@Value("${eas.passport.secretKey}")
private String secretKey;
@Value("${eas.passport.algorithm}")
private String hmacAlgorithm;
private static final int HMAC_LENGTH = 32;
/**
* 원본(직렬화된 Passport) -> HMAC 계산 -> [원본 + HMAC] -> Base64 인코딩
*/
public String signPassport(byte[] rawData) {
try {
byte[] hmac = computeHmacAlgorithm(rawData);
byte[] combined = new byte[rawData.length + hmac.length];
// 데이터 결합
System.arraycopy(rawData, 0, combined, 0, rawData.length);
System.arraycopy(hmac, 0, combined, rawData.length, hmac.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new ErrorHandler(ErrorStatus.PASSPORT_SIGN_FAIL);
}
}
/**
* Base64 -> [원본 + HMAC] 분리 -> 재계산 -> 검증 -> 원본 바이트 반환
*/
public byte[] validatePassport(String base64Passport) {
try {
byte[] combined = Base64.getDecoder().decode(base64Passport);
if (combined.length < HMAC_LENGTH) {
throw new ErrorHandler(ErrorStatus.PASSPORT_DATA_TOO_SHORT);
}
int dataLength = combined.length - HMAC_LENGTH;
byte[] rawData = new byte[dataLength];
byte[] hmac = new byte[HMAC_LENGTH];
// 데이터와 HMAC 분리
System.arraycopy(combined, 0, rawData, 0, dataLength);
System.arraycopy(combined, dataLength, hmac, 0, HMAC_LENGTH);
// HMAC 검증
byte[] expected = computeHmacAlgorithm(rawData);
if (!MessageDigest.isEqual(expected, hmac)) {
throw new ErrorHandler(ErrorStatus.PASSPORT_HMAC_MISMATCH);
}
return rawData;
} catch (Exception e) {
throw new ErrorHandler(ErrorStatus.PASSPORT_INVALID_DATA);
}
}
/**
* HMAC-SHA256 계산 (32byte)
*/
private byte[] computeHmacAlgorithm(byte[] data) throws Exception {
Mac mac = Mac.getInstance(hmacAlgorithm);
mac.init(new SecretKeySpec(secretKey.getBytes(), hmacAlgorithm));
return mac.doFinal(data);
}
}
@PassportUser 어노테이션을 통해 X-Passport 헤더에 담긴 값을 자동으로 파싱하고 Passport 객체로 주입받을 수 있다.
현재는 memberId만 사용하지만, 필요시 더 많은 사용자 정보를 Passport에 담아 확장할 수 있다.
✅ 결과
K6 성능 지표성능 지표 분석
Passport 도입 전후의 성능을 K6로 비교해 보았다.
Passport 도입 전후로 평균 요청 시간이 50.7% 감소하고, 최대 요청 시간은 80% 단축되는 등 전반적인 성능이 크게 개선된 것을 볼 수 있었다. 또한, 초당 처리할 수 있는 요청 수도 65.6% 증가해 인증 과정에서의 부담을 크게 줄일 수 있었다. 처음으로 직접 성능 비교 지표를 수집하고 분석해 본 경험이었는데, 실제로 이렇게 큰 차이가 나타나 놀랐던 기억이 있다.
✅ 회고
사실 Passport를 도입하면서 장점만 있었던 것은 아니었다. 넷플릭스와 토스의 기술 블로그를 보고 API Call 비용을 줄일 수 있다는 점에 이끌려 다소 무작정 도입한 경향이 있었다. 하지만 실제로 적용해 보니, Passport의 만료 및 재발급 처리에 대한 고민이 더 복잡해졌고, 규모가 작은 현 시점에서 JWT와 Passport가 크게 다른 점이 있는가라는 의문도 들었다. (Passport에 담는 정보를 JWT의 Payload에 포함시키고 Gateway에서 이를 파싱해 각 마이크로서비스에 넘긴다면, 사실상 동일한 구조가 될 수도 있기 때문이다.)
넷플릭스와 토스는 워낙 대규모 서비스를 운영하고 있는 만큼, 작은 성능 최적화도 큰 이익으로 이어지기 때문에 속도 측면에서 우수한 Passport 도입이 매우 합리적인 선택이다. 반면, 우리의 프로젝트는 그런 맥락을 충분히 고려하지 않았다고 본다.
결론적으로, Passport 도입 덕분에 API 요청 처리 시간을 눈에 띄게 단축할 수 있었고, 학습 목적 또한 분명했기 때문에 매우 유익한 경험이었다. 다만, 실제 운영을 시작하는 소규모 서비스라면 도입하지 않았을 것 같다는 생각이 든다.
참고 - 토스의 Passport 만료 방식 토스는 Passport를 짧은 TTL로 캐시한 뒤, Redis Pub/Sub을 활용하여 사용자 정보가 변경되면 해당 Passport를 즉시 만료시키고 새로운 Passport를 발급받도록 최적화했다고 한다.
🎬 마무리하며
처음으로 도입기를 작성해 보았다. 흥미로운 기술을 시도하고 그 과정을 정리하는 일은 늘 뿌듯하다. 다만, 기술을 도입할 때에는 적절한 시점과 리소스, 상황을 충분히 고려해야 한다는 점을 크게 느꼈다.
레퍼런스가 부족해서 0부터 직접 구현한 부분이 많다 보니, 부족하거나 잘못된 부분도 있을 것 같다. 그럼에도 불구하고 이 글이 누군가에게 조금이라도 도움이 되길 바라며 글을 마무리한다.