← all posts
DEV 2026.05.02 · 12 min read Intermediate

gRPC 보안은 왜 계층으로 쌓는가

TLS 핸드쉐이크부터 mTLS 클라이언트 인증, JWT Interceptor, SPIFFE 기반 서비스 신원까지 — gRPC 보안 계층이 Zero Trust 아키텍처로 수렴하는 이유를 추적한다.


gRPC는 HTTP/2 위에서 동작하므로 전송 계층 보안이 기본 전제에 가깝다. 그런데 실무 코드베이스를 보면 보안 설정은 놀랍도록 제각각이다 — usePlaintext()가 프로덕션에 그대로 배포되거나, 인증서가 만료돼 서비스 전체가 멈추거나, 인증 로직이 비즈니스 코드 안에 뒤섞여 있다. 왜 이런 일이 반복되는가?

신뢰의 출발점: TLS와 mTLS

일반 TLS는 클라이언트가 서버를 검증한다. “이 인증서가 정말 example.com인가?” 서버는 클라이언트를 모른다.

mTLS는 방향을 양쪽으로 뒤집는다. 서버도 클라이언트 인증서를 검증한다 — “이 요청이 신뢰된 서비스에서 왔는가?” 이 한 줄의 차이가 마이크로서비스 간 통신에서 결정적이다.

TLS 1.3 핸드쉐이크 (mTLS):

CLIENT                              SERVER
  │── ClientHello (Key Share) ─────►│
  │◄─ ServerHello + Certificate ────│
  │◄─ CertificateVerify + Finished ─│
  │── Certificate (클라이언트) ─────►│  ← mTLS 추가 단계
  │── CertificateVerify + Finished ─►│
  │══ Application Data (암호화) ════│

TLS 1.3은 1-RTT로 연결을 맺는다. TLS 1.2의 2-RTT 대비 핸드쉐이크 비용이 반으로 줄고, ClientHello 직후부터 암호화가 시작되어 Perfect Forward Secrecy(PFS)가 보장된다.

Java에서 mTLS 채널을 열려면 클라이언트 키와 CA 신뢰 체인이 모두 필요하다.

SslContextBuilder sslCtx = GrpcSslContexts.forClient()
    .keyManager(certChainFile, privateKeyFile)   // 클라이언트 인증
    .trustManager(rootCertFile)                  // 서버 검증
    .build();

ManagedChannel channel = NettyChannelBuilder.forAddress(host, port)
    .sslContext(sslCtx)
    .build();
⚠ 흔한 실수

usePlaintext()는 로컬 개발 전용이다. 이 설정이 프로덕션으로 배포되면 데이터는 평문으로 전송된다. 환경별 설정 분기를 반드시 코드 레벨에서 강제해야 한다.

JWT와 Interceptor — 인증 로직의 분리

전송 계층이 암호화됐다고 인증이 끝난 것은 아니다. “누가 이 요청을 보냈는가?”는 여전히 애플리케이션 계층의 문제다.

gRPC는 CallCredentials와 ServerInterceptor 두 가지 훅을 제공한다. CallCredentials는 모든 RPC 호출에 자동으로 메타데이터를 주입하고, ServerInterceptor는 수신 측에서 메타데이터를 가로채 검증한다.

// 클라이언트: JWT 자동 주입
public class JwtCallCredentials extends CallCredentials {
    @Override
    public void applyRequestMetadata(RequestInfo info,
            Executor appExecutor, MetadataApplier applier) {
        appExecutor.execute(() -> {
            Metadata metadata = new Metadata();
            metadata.put(
                Metadata.Key.of("authorization",
                    Metadata.ASCII_STRING_MARSHALLER),
                "Bearer " + tokenProvider.getToken());
            applier.apply(metadata);
        });
    }
}

// 서버: Interceptor에서 검증 후 Context로 전파
Context newContext = Context.current()
    .withValue(AUTH_CONTEXT_KEY, authContext);
return Contexts.interceptCall(newContext, call, headers, next);

핵심은 gRPC Context다. ThreadLocal과 달리 비동기 실행 컨텍스트에서도 전파가 보장된다. Interceptor가 Context에 인증 정보를 저장하면, 비즈니스 로직은 어느 스레드에서든 AUTH_CONTEXT_KEY.get()으로 꺼낼 수 있다.

성능 측면에서 매 요청마다 원격 공개키를 페칭하면 +100ms가 더해진다. 로컬 캐싱 검증으로 전환하면 +2ms 수준으로 줄어든다.

Interceptor 체인의 순서가 보안을 결정한다

여러 Interceptor를 등록할 때 순서는 선언의 역순으로 실행된다. 인증 검사가 로깅보다 늦게 실행되면, 미인증 요청의 페이로드가 로그에 남는다.

ServerBuilder.forPort(50051)
    .addService(new MyServiceImpl())
    .intercept(new TracingInterceptor())      // 3번째 실행
    .intercept(new LoggingInterceptor())      // 2번째 실행
    .intercept(new AuthenticationInterceptor()) // 1번째 실행
    .build();

올바른 순서는 인증 → 로깅 → 추적이다. 인증에 실패한 요청은 로깅과 추적 레이어에 도달하지 않는다.

Spring Boot 환경에서는 @GrpcGlobalServerInterceptor 빈으로 선언하면 모든 서비스에 자동 적용된다. Interceptor 오버헤드는 체인 3개 기준 약 +3ms, CPU 5% 수준으로 허용 가능한 범위다.

서비스 신원과 Zero Trust

서비스가 수십 개를 넘어서면 인증서 관리 자체가 운영 부담이 된다. 90일 인증서를 100개 서비스에 수동으로 갱신하는 건 사고가 예정된 구조다.

SPIFFE/SPIRE는 이 문제를 자동화로 해결한다. 각 서비스는 spiffe://trust-domain/service/name 형태의 표준 URI로 신원을 부여받고, SPIRE Agent가 인증서를 자동 발급·갱신한다.

┌──────────────────────────────────────────┐
│ K8s 환경: 서비스 신원 자동화 흐름        │
├──────────────────────────────────────────┤
│ Pod 생성 → SPIRE Agent 신청              │
│         → SPIRE Server Attestation       │
│         → SVID 발급 (CA 서명 인증서)    │
│         → /tmp/spire-agent/public/에 배포│
│         → 24시간마다 자동 갱신          │
└──────────────────────────────────────────┘

Kubernetes 환경이라면 Service Account Token이 더 간단한 출발점이다. /var/run/secrets/kubernetes.io/serviceaccount/token을 읽어 Bearer 토큰으로 사용하면 K8s API Server가 검증을 대신한다. 초기 설정 비용이 낮고 갱신이 자동이다.

✎ 트레이드오프

API Key는 가장 단순하지만 폐지가 어렵고 관리 부담이 선형으로 증가한다. JWT는 만료 기반이라 확장성이 우수하지만 폐지 전 최대 1시간의 창이 열린다. mTLS + SPIRE는 자동화 이후 가장 강력하지만 초기 구축 비용이 높다. K8s 환경이라면 Service Account → SPIRE 순서로 점진적으로 이동하는 것이 현실적이다.

환경별 설정 — 개발과 프로덕션의 분리

보안 설정에서 가장 흔한 사고는 개발 편의 설정이 프로덕션에 배포되는 것이다. 이를 코드 레벨에서 강제하는 패턴이 필요하다.

public ManagedChannel createChannel(String host, int port, Environment env) {
    return switch (env) {
        case DEVELOPMENT -> ManagedChannelBuilder
            .forAddress(host, port).usePlaintext().build();
        case STAGING     -> createTlsChannel(host, port, stagingCA);
        case PRODUCTION  -> createMtlsChannel(host, port, prodCerts);
    };
}

프로덕션에서 인증서 핀닝을 추가하면 MITM에 대한 방어선이 한 겹 더 생긴다. 서버 인증서의 SHA-256 해시를 클라이언트에 하드코딩해두고, 핸드쉐이크 시점에 비교한다. 인증서가 교체되더라도 공개키가 동일하다면 핀닝은 유지된다.

인증서 만료는 ScheduledExecutorService로 24시간 주기 모니터링을 설정하고, 30일 이전에 알림을 보내는 것이 최소한의 안전망이다.

정리

  • TLS는 전송 암호화, mTLS는 양방향 신원 확인 — 둘은 역할이 다르다.
  • JWT Interceptor 패턴은 인증 로직을 비즈니스 코드에서 분리하고, gRPC Context로 비동기 안전하게 전파한다.
  • Interceptor 순서는 보안을 결정한다. 인증이 항상 첫 번째다.
  • 서비스가 많아질수록 수동 인증서 관리는 한계에 달한다. K8s Service Account → SPIRE 순서로 자동화를 확장한다.

gRPC 보안은 계층이다. 전송 암호화, 신원 검증, 토큰 인증, 자동 갱신 — 각 계층은 독립적으로 추가할 수 있고, 누락된 계층은 정확히 그 지점에서 공격 표면이 된다.