Development

[Development] MSA 기반 Passport 인증 시스템 도입기

soeun2537 2025. 4. 16. 21:55
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의 구체적인 사용 방식은 시스템 구성에 따라 달라질 수 있다.

🔽 예시 흐름

1. 클라이언트가 Gateway로 요청 시 JWT를 함께 보냄

Authorization: Bearer eyJhbGciOi...

2. Gateway가 Passport Server에 JWT를 전달

POST /passports?jwt=eyJhbGciOi...

3. Passport Server가 아래 정보를 바탕으로 Passport 생성

{
  "passport_id": "9d1a...",
  "member_id": 123,
  "issued_at": 1710000000,
  "expires_at": 1710003600,
  "auth_level": "HIGH"
}

4. 위 정보를 Protobuf로 직렬화 -> HMAC 서명 -> Base64 인코딩 후 응답

X-Passport: CiQ5ZDFh... // Base64 인코딩된 Passport

5. 내부 서버에서는 이 Passport를 검증 후 사용자 정보를 추출

public ResponseEntity<?> getProfile(@PassportUser Passport passport) {
    Long memberId = passport.getMemberId();
    ...
}

 

 

 

✅ 구현

0️⃣ 전체적인 그림

  1. 공통 모듈
    • @PassportUser, WebMvcConfig, PassportArgumentResolver, HmacUtil, PassportValidator는 모두 공통 모듈에 포함되어 있으며, Passport를 사용하는 각 서버에서 공통적으로 사용할 수 있도록 구성했다.
    • Passport의 검증 및 사용자 정보 추출 과정을 어노테이션 기반으로 자동화하여, 개발자의 편의성을 높이기 위해 설계했다.
  2. Gateway (공통 모듈 X)
    • Gateway는 공통 모듈에 대한 의존성 없이 동작하며, 요청에 JWT가 포함되어 있는지 검사한다.
    • JWT가 있을 경우, Passport 생성을 위해 Passport Server에 생성 요청을 보낸다.
    • JWT가 없는 경우, 인증되지 않은 요청으로 간주하여 예외를 발생시킨다.
  3. Passport Server (공통 모듈 O)
    • Passport Server는 공통 모듈에 포함된 HmacUtil을 사용하여 Passport에 대한 HMAC 서명을 계산한다.
    • 직렬화된 Passport 원본과 HMAC을 결합한 후 Base64로 인코딩하여 반환한다.
  4. Passport를 사용하는 다른 서버 (공통 모듈 O)
    • Passport를 사용하는 각 서버는 공통 모듈에 포함된 PassportArgumentResolver와 PassportValidator를 통해, Base64로 인코딩된 Passport를 디코딩하고 HMAC 서명을 검증한 뒤 사용자 정보를 추출한다.
    • 컨트롤러에서는 @PassportUser 어노테이션을 사용해 Passport 객체를 자동으로 주입받을 수 있어, 개발자가 사용자 정보를 더욱 편리하게 사용할 수 있다.

 

 

1️⃣ 공통 모듈화

🔽 폴더 구조

src
└── main
    ├── java
    │   └── com.bbebig.commonmodule
    │       └── passport
    │           ├── annotation
    │           │   └── PassportUser
    │           ├── config
    │           │   └── WebMvcConfig
    │           ├── resolver
    │           │   └── PassportArgumentResolver
    │           ├── HmacUtil
    │           └── PassportValidator
    └── proto
        └── Passport.proto

common-module은 여러 서버에서 공통적으로 사용할 수 있도록 구성된 패키지이다. Passport 관련 검증 로직, 프로토콜 정의 파일 등을 이곳에 모아 두어 중복 제거 및 유지보수 효율성을 높였다.

 

🔽 Passport.proto

syntax = "proto3";

package com.bbebig.commonmodule;

option java_package = "com.bbebig.commonmodule.proto";
option java_outer_classname = "PassportProto";

message Passport {
  string passport_id = 1;
  int64 member_id = 2;
  int64 issued_at = 3;
  int64 expires_at = 4;
  string auth_level = 5;
}

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

HMAC 서명을 생성하고 검증하는 유틸리티 클래스이다.

  • signPassport(byte[] rawData)
    • 직렬화된 Passport 데이터를 HMAC으로 서명하고, 원본 + HMAC을 Base64로 인코딩하여 반환한다.
  • validatePassport(String base64Passport)
    • Base64로 인코딩된 Passport를 디코딩한 뒤, 원본과 HMAC을 분리하고 일치 여부를 검증해 원본 바이트를 반환한다.

 

🔽 PassportValidator

@Component
@RequiredArgsConstructor
public class PassportValidator {

    private final HmacUtil hmacUtil;

    /**
     * Base64로 인코딩된 Passport + HMAC -> decode -> verify -> parse
     */
    public Passport validatePassport(String base64Passport) {
        try {
            // HMAC 검증 및 원본 데이터 추출
            byte[] rawBytes = hmacUtil.validatePassport(base64Passport);

            // Protobuf 파싱
            Passport passport = Passport.parseFrom(rawBytes);

            // 만료 시간 검증
            long now = Instant.now().getEpochSecond();

            if (passport.getExpiresAt() < now) {
                throw new ErrorHandler(ErrorStatus.PASSPORT_EXPIRED);
            }

            return passport;
        } catch (Exception e) {
            throw new ErrorHandler(ErrorStatus.PASSPORT_INVALID_OR_TAMPERED);
        }
    }
}

Passport의 유효성을 검증하고, 만료 여부까지 검사하는 컴포넌트이다.

  • validatePassport(String base64Passport)
    • HmacUtil로부터 검증된 원본 바이트를 받아 Protobuf로 파싱하고, expiresAt 값이 현재 시간보다 작으면 만료로 간주하여 예외를 던진다.

 

🔽 PassportUser

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PassportUser {
}

Controller 메서드에 Passport 객체를 자동 주입하기 위한 커스텀 어노테이션이다.

public ResponseEntity<?> getMyInfo(@PassportUser Passport passport) { ... }

와 같은 방식으로 사용할 수 있다.

 

🔽 PassportArgumentResolver

@Component
@RequiredArgsConstructor
public class PassportArgumentResolver implements HandlerMethodArgumentResolver {

    private final PassportValidator passportValidator;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(PassportUser.class)
                && parameter.getParameterType().equals(Passport.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        String base64Passport = request.getHeader("X-Passport");
        if (base64Passport == null) {
            throw new ErrorHandler(ErrorStatus.PASSPORT_HEADER_MISSING);
        }

        try {
            return passportValidator.validatePassport(base64Passport);
        } catch (Exception e) {
            throw new ErrorHandler(ErrorStatus.PASSPORT_INVALID_OR_TAMPERED);
        }
    }

@PassportUser가 붙은 파라미터에 대해 Passport를 파싱하고 주입하는 Resolver이다.

  • supportsParameter()
    • HandlerMethodArgumentResolver의 메서드를 오버라이딩하여 처리 대상을 설정하는 메서드이다.
    • @PassportUser 어노테이션이 붙어있고, 타입이 Passport인 파라미터만 처리 대상으로 인식한다.
  • resolveArgument()
    • HandlerMethodArgumentResolver의 메서드를 오버라이딩하여 주입할 실제 값을 반환한다.
    • 요청 헤더(X-Passport)에서 Passport 값을 가져와 검증하고 파싱하여 주입한다.

 

🔽 WebMvcConfig

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    private final PassportArgumentResolver passportArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(passportArgumentResolver);
    }
}

PassportArgumentResolver를 Spring MVC의 argument resolver 목록에 등록하는 설정 클래스이다.

  • addArgumentResolvers()
    • WebMvcConfigurer의 메서드를 오버라이딩하여 커스텀 ArgumentResolver를 등록한다.
    • Spring이 컨트롤러 파라미터에 @PassportUser가 붙은 객체를 자동으로 처리할 수 있도록 설정한다.

 

 

2️⃣ Spring Cloud Gateway 필터 적용

/**
 * Spring Cloud Gateway에서 모든 요청을 가로채 Passport를 확인하거나, 없으면 Auth 서버로 JWT 검증 후 Passport 발급.
 */
@Component
public class PassportFilter extends AbstractGatewayFilterFactory<Object> {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";
    private static final String PASSPORT_HEADER = "X-Passport";

    private final WebClient webClient = WebClient.create();

    @Value("${passport.server.url}")
    private String passportServerUrl;

    public PassportFilter() {
        super(Object.class);
    }

    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            String authHeader = exchange.getRequest().getHeaders().getFirst(AUTHORIZATION_HEADER);

            // 1) JWT가 있으면 Passport Server Call
            if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
                String jwt = authHeader.substring(BEARER_PREFIX.length());
                return getPassportFromPassportServer(exchange, chain, jwt);
            }

            // 2) JWT가 없으면 401
            return respondWithUnauthorized(exchange);
        };
    }

    /**
     * JWT를 통해 Passport 발급
     */
    private Mono<Void> getPassportFromPassportServer(ServerWebExchange exchange, GatewayFilterChain chain, String jwt) {
        return webClient.post()
                .uri(passportServerUrl + "/passports?jwt=" + jwt)
                .retrieve()
                .bodyToMono(PassportResponseDto.class)
                .flatMap(response -> {
                    // Passport 발급 실패
                    if (response.getResult() == null || response.getResult().getPassport() == null) {
                        return respondWithUnauthorized(exchange);
                    }

                    // 발급된 Passport를 X-Passport 헤더로 설정
                    ServerWebExchange mutated = exchange.mutate()
                            .request(r -> r.headers(h -> h.set(PASSPORT_HEADER, response.getResult().getPassport())))
                            .build();

                    // 다음 필터로 진행
                    return chain.filter(mutated);
                })
                .onErrorResume(e -> respondWithUnauthorized(exchange));
    }

    /**
     * Unauthorized 응답
     */
    private Mono<Void> respondWithUnauthorized(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().setComplete();
    }
}

Spring Cloud Gateway에서는 application.yml 설정을 통해 특정 API 요청에 대해 커스텀 필터를 적용할 수 있다. 위 필터(PassportFilter)는 사용자 정보가 필요한 API에서 공통적으로 사용된다.

 

해당 필터는 "사용자 정보가 반드시 필요한 요청"을 전제로 동작하도록 설계되었기 때문에, 요청에 JWT가 포함되어 있으면 필터는 Passport를 발급하고, JWT가 없는 경우에는 예외를 발생시킨다.

 

 

3️⃣ Passport 서버 발급 로직 구현

🔽 PassportService

@Service
@RequiredArgsConstructor
public class PassportService {

    private final HmacUtil hmacUtil;

    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${eas.passport.expiration}")
    private long EXPIRE_SECONDS;

    /**
     * Passport 발급
     */
    public PassportResponseDto issuePassport(String jwt) {
        // 1) JWT 파싱
        JwtResponseDto jwtResponseDto = parseJwt(jwt);
        if (jwtResponseDto.getMemberId() == null) {
            throw new ErrorHandler(ErrorStatus.INVALID_JWT);
        }

        // 2) memberId
        Long memberId = jwtResponseDto.getMemberId();

        // 3) Passport 발급 (HMAC+Protobuf)
        String passport = generatePassport(memberId, "HIGH");

        // 4) JSON 응답
        return PassportResponseDto.convertToIssuePassportResponse(passport);
    }

    /**
     * 사용자 정보를 받아 Passport 생성 + HMAC 서명 + Base64
     */
    private String generatePassport(long userId, String authLevel) {
        long now = Instant.now().getEpochSecond();
        long expire = now + EXPIRE_SECONDS;

        // Protobuf 객체 생성
        Passport passport = Passport.newBuilder()
                .setPassportId(UUID.randomUUID().toString())
                .setMemberId(userId)
                .setIssuedAt(now)
                .setExpiresAt(expire)
                .setAuthLevel(authLevel)
                .build();

        // 직렬화 -> byte[]
        byte[] rawBytes = passport.toByteArray();

        // HMAC 서명 + Base64
        return hmacUtil.signPassport(rawBytes);
    }

    /**
     * JWT 파싱
     */
    private JwtResponseDto parseJwt(String jwt) {
        try {
            // 1) Parser 생성
            Jws<Claims> jws = Jwts.parserBuilder()
                    .setSigningKey(jwtSecret.getBytes(StandardCharsets.UTF_8))
                    .build()
                    .parseClaimsJws(jwt);

            // 2) 클레임 추출
            Claims claims = jws.getBody();

            // 3) memberId 추출
            Long memberId = claims.get("id", Integer.class).longValue();

            return JwtResponseDto.convertToJwtResponseDto(memberId);

        } catch (JwtException e) {
            return JwtResponseDto.convertToJwtResponseDto(null);
        }
    }
}

JWT 기반으로 Passport를 생성하고, HMAC 서명을 통해 위·변조 방지를 보장하는 서비스 클래스이다.

  • issuePassport(String jwt)
    • JWT를 파싱하여 사용자 정보를 추출한 뒤, 해당 정보를 바탕으로 Passport를 생성하고 서명한다.
    • 최종적으로 Passport 문자열을 응답 객체로 감싸 반환한다.
  • generatePassport(long userId, String authLevel)
    • Protobuf 기반의 Passport 객체를 생성하고, 직렬화된 바이트 배열에 대해 HMAC 서명을 수행한 뒤 Base64로 인코딩된 문자열을 반환한다.
    • 이 과정에서 issuedAt, expiresAt, passportId, authLevel 등의 정보를 포함한다.
  • parseJwt(String jwt)
    • JWT를 파싱하여 클레임에서 memberId를 추출한다.
    • JWT가 유효하지 않거나 파싱에 실패한 경우 null을 반환하여 이후 로직에서 예외 처리할 수 있도록 한다.
JWT가 유효하지 않거나 파싱에 실패한 경우, 예외를 던지기보다는 null로 처리해 이후 로직에서 예외를 던질 수 있도록 한다.

 

 

🔽 PassportController

@RestController
@RequestMapping("passports")
@RequiredArgsConstructor
public class PassportController {

    private final PassportService passportService;

    @PostMapping("")
    public CommonResponse<PassportResponseDto> createServer(
            @RequestParam String jwt
    ) {
        return CommonResponse.onSuccess(passportService.issuePassport(jwt));
    }
}

Passport 생성 요청을 처리하는 REST API 컨트롤러이다.

 

 

4️⃣ Passport를 사용하는 각 서버 연동 설정

Passport 검증 및 사용자 정보 추출을 사용하기 위해, 각 서버에서는 공통 모듈을 포함한 설정이 필요하다. 아래의 순서에 따라 연동을 진행한다.

 

1️⃣ settings.gradle에 공통 모듈 경로 추가

include(":common-module")
findProject(":common-module")?.projectDir = file("../common-module")

루트 프로젝트에서 공통 모듈을 인식할 수 있도록 포함시킨다.

 

2️⃣ build.gradle에 protobuf 플러그인, 의존성 추가

plugins {
    id 'com.google.protobuf' version '0.9.4'
}
dependencies {
    implementation project(":common-module")
    implementation 'com.google.protobuf:protobuf-java:3.21.12'
}

protobuf 메시지를 사용하기 위해 protobuf 플러그인과 관련 의존성을 추가한다. 또한, 공통 모듈을 의존성으로 등록한다.

 

3️⃣ Protobuf 코드 생성

./gradlew clean generateProto

.proto 파일을 Java 코드로 변환하는 빌드 명령어를 실행한다. 이 과정을 통해 PassportProto.Passport 클래스가 생성된다.

 

4️⃣ .env 파일에 환경 변수 추가

PASSPORT_SECRET_KEY="PASSPORT_SECRET_KEY"
PASSPORT_EXPIRATION=3600
PASSPORT_HEADER="X-Passport"
PASSPORT_ALGORITHM="HmacSHA256"

AUTH_SERVER_URL=["<http://auth-server:9000/>"](<http://3.38.218.175:9000/>)

Passport 생성 및 검증에 필요한 설정 값을 환경 변수로 정의한다.

 

5️⃣ application.yml에 설정값 바인딩

eas:
  passport:
    secretKey: ${PASSPORT_SECRET_KEY}
    expiration: ${PASSPORT_EXPIRATION}
    header: ${PASSPORT_HEADER}
    algorithm: ${PASSPORT_ALGORITHM}

auth:
  server:
    url: ${AUTH_SERVER_URL}

.env에 정의한 환경 변수들을 Spring 환경 설정 파일에서 사용할 수 있도록 설정한다.

 

6️⃣ Application 클래스에 공통 모듈 스캔 및 설정 등록

@ComponentScan(basePackages = {
        "com.bbebig.serviceserver",
        "com.bbebig.commonmodule"
})
@Import(WebMvcConfig.class)
public class ServiceServerApplication {

공통 모듈의 클래스들이 컴포넌트 스캔 대상에 포함되도록 설정하고, WebMvcConfig를 명시적으로 import하여 Passport 리졸버를 등록한다.

서버별로 com.bbebig.serviceserver 부분은 해당 서버 패키지로 변경한다.

 

7️⃣ 각 서버에서 사용 방법 예시

import com.bbebig.commonmodule.proto.PassportProto.Passport;

public CommonResponse<ServerCreateResponseDto> createServer(
        @PassportUser Passport passport,
        @RequestBody ServerCreateRequestDto serverCreateRequestDto
) {
    return CommonResponse.onSuccess(serverService.createServer(passport.getMemberId(), serverCreateRequestDto));
}

@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부터 직접 구현한 부분이 많다 보니, 부족하거나 잘못된 부분도 있을 것 같다. 그럼에도 불구하고 이 글이 누군가에게 조금이라도 도움이 되길 바라며 글을 마무리한다.

 

 

 

📍 참고 자료