gRPC 보안은 왜 계층으로 쌓는가
TLS 핸드쉐이크부터 mTLS 클라이언트 인증, JWT Interceptor, SPIFFE 기반 서비스 신원까지 — gRPC 보안 계층이 Zero Trust 아키텍처로 수렴하는 이유를 추적한다.
- 01 gRPC는 왜 REST보다 마이크로서비스에 맞는가
- 02 Protobuf은 왜 JSON보다 작고 빠른가
- 03 gRPC를 제대로 쓴다는 것의 의미
- 04 gRPC 스트리밍은 왜 Polling보다 효율적인가
- 05 gRPC 보안은 왜 계층으로 쌓는가
- 06 Spring에서 gRPC를 제대로 쓰려면 무엇이 필요한가
- 07 REST에서 gRPC로 — 점진적 전환의 설계 원칙
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 보안은 계층이다. 전송 암호화, 신원 검증, 토큰 인증, 자동 갱신 — 각 계층은 독립적으로 추가할 수 있고, 누락된 계층은 정확히 그 지점에서 공격 표면이 된다.