"내부니까 괜찮겠지"는 이제 없다 — 마이크로서비스 API에 제로트러스트 적용하기: mTLS·JWT·OPA 실전
솔직히 고백하자면, 저도 한때 "어차피 내부 네트워크인데 뭘 그렇게 철통 방어를 해?"라고 생각했습니다. API Gateway 바깥쪽만 잘 막으면 안쪽은 알아서 안전하다고 믿었죠. 그런데 실제로 사고는 항상 내부에서 터집니다. 신뢰받은 서비스 계정이 탈취되거나, 내부 마이크로서비스 하나가 뚫려서 옆으로 옆으로 횡이동하며 전체 시스템을 장악하는 시나리오. 이게 그냥 가상의 이야기가 아닙니다. Salt Security 2023 API Security Report 기준으로 웹 트래픽의 71%가 이미 API를 통하고 있고, 보안 취약점의 17%가 API에서 발생합니다. API는 그 자체가 새로운 보안 경계선입니다.
이미 JWT를 쓰고 있다면 바로 "API Gateway JWT 강화" 섹션으로 넘어가셔도 됩니다. 서비스 간 통신까지 보안을 넓히고 싶다면 "mTLS + SPIRE" 섹션부터 보시면 좋습니다. 처음이라면 그냥 순서대로 읽으시면 됩니다.
이 글을 읽고 나면 기존 NestJS 서비스에 JWT revocation 체크를 추가하고, 두 서비스 사이에 mTLS 파일럿을 띄우는 데 필요한 개념과 코드를 갖게 됩니다. mTLS, JWT, OPA, SPIRE 같은 키워드는 등장할 때마다 하나씩 풀어드릴 테니, 처음 접하셔도 괜찮습니다. 다만 쿠버네티스 기본 개념(파드, 네임스페이스 정도)을 알고 계시면 후반부 예시를 더 수월하게 따라오실 수 있습니다.
핵심 개념
제로트러스트의 세 가지 기둥
제로트러스트(Zero Trust)는 "절대 신뢰하지 말고, 항상 검증하라(Never Trust, Always Verify)"는 원칙을 기반으로 한 보안 모델입니다. 기존 경계 보안이 내부 네트워크를 암묵적으로 신뢰하던 것과 달리, 네트워크 위치와 관계없이 모든 요청을 인증·인가합니다.
이걸 이해하면 나머지 기술 선택들이 자연스럽게 따라옵니다.
| 원칙 | 의미 | API 설계에서의 적용 |
|---|---|---|
| 명시적 검증 (Verify Explicitly) | 모든 요청을 신원·디바이스·컨텍스트 기준으로 검증 | 매 요청마다 토큰/인증서 검증, IP 신뢰 금지 |
| 최소 권한 (Least Privilege) | 딱 필요한 것만 허용 | scope 기반 세분화 인가, 리소스별 접근 제어 |
| 침해 가정 (Assume Breach) | 내부도 이미 뚫렸다고 가정하고 설계 | 서비스 간 통신도 암호화·인증, 횡이동 차단 |
NIST SP 800-207: 제로트러스트 아키텍처의 공식 표준 프레임워크로, 단계적 마이그레이션을 권고합니다. 한 번에 전환하는 "빅뱅" 방식은 현실적으로 위험 부담이 크기 때문입니다.
기존 경계 보안 vs 제로트러스트
실무에서 자주 맞닥뜨리는 상황인데, 기존 방식은 이런 그림입니다.
[외부] — 방화벽 — [내부 네트워크]
↳ Service A ↔ Service B ↔ Service C
(서로 암묵적으로 신뢰)방화벽 안으로만 들어오면 서비스끼리는 "우리 편"이라고 간주합니다. 반면 제로트러스트는 이렇습니다.
[외부] — API Gateway (인증·인가) — [Service A]
↓ mTLS + 신원 검증
[Service B]
↓ mTLS + 신원 검증
[Service C]모든 홉(hop)마다 검증이 발생합니다. 서비스 A가 서비스 B를 호출할 때도 "너 정말 A 맞아?"를 확인합니다.
워크로드 신원(Workload Identity)이란?
저도 처음엔 헷갈렸는데, 사람에게 ID가 있듯이 서비스(워크로드)에도 고유한 신원이 필요합니다. 이게 워크로드 신원의 핵심입니다.
SPIFFE (Secure Production Identity Framework For Everyone): CNCF 프로젝트로, 동적 컨테이너·서버리스 환경에서도 일관된 워크로드 신원을 부여하는 표준입니다. SPIRE는 이 표준을 실제로 구현한 런타임입니다. 표준과 구현체의 관계로 이해하시면 됩니다.
SPIFFE URI 형식은 이렇게 생겼습니다.
spiffe://trust-domain/path/to/service
예: spiffe://mycompany.com/backend/payment-service이 신원 기반으로 단기 수명 X.509 인증서인 **SVID(SPIFFE Verifiable Identity Document)**가 자동 발급되어 mTLS 통신에 사용됩니다. 인증서가 만료되면 자동 갱신되기 때문에, 정적 API 키처럼 수동으로 관리할 필요가 없습니다. mTLS를 처음 적용했을 때 인증서 만료로 새벽 장애가 났던 경험이 있는데, SPIRE가 있었다면 그 고생을 안 해도 됐을 텐데 싶었습니다.
서비스 메시 유무에 따른 아키텍처 선택
아래 분기를 먼저 파악해두면 예시를 따라올 때 맥락이 더 명확합니다.
| 구분 | 서비스 메시 없는 경우 | 서비스 메시 있는 경우 (Istio 등) |
|---|---|---|
| mTLS 설정 | 앱 코드 또는 cert-manager로 직접 관리 | 사이드카 프록시가 자동 처리 |
| 정책 집행 위치 | 앱 내부 Guard 또는 API Gateway | Envoy 사이드카에서 OPA 연동 |
| 운영 복잡도 | 낮음 (단순하지만 일관성 유지 어려움) | 높음 (초기 구축 후 운영은 수월) |
| 권장 시작점 | ✅ 작은 팀, 빠른 파일럿 | 대규모 마이크로서비스 환경 |
사이드카(Sidecar): 쿠버네티스 파드(Pod, 컨테이너 실행 단위) 안에 앱 컨테이너와 함께 실행되는 보조 컨테이너입니다. 네트워크 트래픽을 앱 대신 가로채서 mTLS, 로깅, 정책 검사 등을 처리합니다.
실전 적용
API Gateway JWT 강화: 토큰 검증의 빈틈 메우기
가장 현실적인 출발점입니다. 서비스 메시 없이도 바로 적용할 수 있어서 첫 번째 단계로 권장합니다.
외부 클라이언트 (OAuth2 Bearer Token)
↓
[API Gateway] → 토큰 검증 (iss, aud, exp, scope, revocation)
↓
내부 서비스 (표준화된 내부 JWT로 변환)JWT 검증 시 놓치기 쉬운 부분이 있습니다. exp(만료시간)만 확인하고 끝내는 경우가 많은데, revocation 상태 확인도 필수입니다. 언어는 달라도 원칙은 동일합니다. 아래는 NestJS TypeScript 예시입니다.
// 의존성: @nestjs/jwt, ioredis (Redis 클라이언트)
import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly tokenBlacklistService: TokenBlacklistService,
) {}
async validateToken(token: string): Promise<JwtPayload> {
let payload: JwtPayload;
try {
payload = this.jwtService.verify(token, {
issuer: 'https://auth.mycompany.com',
audience: 'api.mycompany.com',
// exp, iss, aud는 verify()가 자동으로 체크합니다
});
} catch (e) {
throw new UnauthorizedException('Invalid or expired token');
}
if (!payload.scope?.includes('api:read')) {
throw new ForbiddenException('Insufficient scope');
}
// revocation 체크 — 짧은 수명 토큰이라도 즉시 무효화 가능해야 합니다
const isRevoked = await this.tokenBlacklistService.isRevoked(payload.jti);
if (isRevoked) {
throw new UnauthorizedException('Token has been revoked');
}
return payload;
}
}| 검증 항목 | 의미 | 누락 시 위험 |
|---|---|---|
iss (Issuer) |
토큰 발급자 확인 | 위조된 토큰 수용 가능 |
aud (Audience) |
이 API용 토큰인지 확인 | 다른 서비스용 토큰 재사용 가능 |
exp (Expiry) |
만료 시간 | 무한 유효 토큰 허용 |
scope |
권한 범위 | 과도한 접근 허용 |
jti + revocation |
개별 토큰 무효화 | 유출된 토큰 즉시 차단 불가 |
한 가지 트레이드오프를 짚어두고 싶습니다. Redis 같은 중앙 저장소 기반 블랙리스트는 편리하지만, Redis 장애가 곧 인증 장애로 이어질 수 있는 단일 실패 지점이 됩니다. 이를 대비해 Redis sentinel 또는 클러스터 구성을 검토하시거나, 블랙리스트 조회 실패 시 폴백 정책(기본 허용 vs 기본 거부)을 명시적으로 정해두시면 좋습니다.
mTLS + SPIRE 서비스 간 인증: 내부 통신도 암호화하기
서비스 메시를 도입한다면 이 패턴이 제로트러스트의 핵심을 보여줍니다. 언어는 달라도 원칙은 동일합니다. 아래는 Python 예시이며, pyspiffe 라이브러리의 실제 API가 아닌 개념을 보여주는 pseudocode 수준의 예시입니다.
# pseudocode: SPIRE를 통한 SVID 획득 후 mTLS 연결 개념 예시
# 실제 pyspiffe SDK API와 다를 수 있습니다
import grpc
async def call_payment_service(request_data: dict):
# SPIRE Agent에서 자동으로 갱신되는 SVID(SPIFFE Verifiable Identity Document) 가져오기
svid = get_svid_from_spire_agent() # 실제 구현은 pyspiffe WorkloadApiClient 참조
# SVID 기반 mTLS 채널 구성
credentials = grpc.ssl_channel_credentials(
root_certificates=svid.bundle.x509_authorities(),
private_key=svid.private_key,
certificate_chain=svid.cert_chain,
)
async with grpc.aio.secure_channel(
'payment-service:443', credentials
) as channel:
stub = PaymentServiceStub(channel)
return await stub.ProcessPayment(request_data)여기서 핵심은 코드 어디에도 하드코딩된 인증서나 키가 없다는 점입니다. SPIRE Agent가 주기적으로 인증서를 갱신해주기 때문에 만료 걱정도 없습니다. 실제로 처음 이 구조를 봤을 때 "그래서 인증서는 어디 있지?" 싶었는데, 그게 바로 SPIRE의 묘미더라고요.
mTLS의 레이턴시 오버헤드는 초기 핸드셰이크 기준으로 수 ms 수준이지만, 네트워크 환경·인증서 크기·하드웨어에 따라 크게 달라집니다. 커넥션 풀링과 세션 재사용을 적용하면 반복 요청에서는 거의 무시 가능한 수준으로 줄어듭니다.
East-West 트래픽: 클라이언트↔서버 방향(North-South)과 달리, 내부 서비스끼리 주고받는 트래픽을 의미합니다. 쿠버네티스 환경에서는 이 부분이 제로트러스트의 가장 큰 사각지대입니다. Istio 같은 서비스 메시를 쓰면 파드 간 통신에 자동으로 mTLS가 적용됩니다.
OPA 인가 정책 분리: 권한 로직을 코드 바깥으로 꺼내기
인증(Authentication, "너 누구야?")과 인가(Authorization, "너 이거 할 수 있어?")는 다릅니다. 그런데 실무에서는 이 둘이 서비스 코드 안에 뒤섞여 있는 경우가 많습니다. OPA는 복잡한 인가 정책을 코드처럼 관리할 수 있게 해줍니다.
핵심은 정책이 서비스 코드 바깥에 있다는 거예요. 배포 없이 정책만 바꿀 수 있다는 게 생각보다 강력합니다.
# OPA Rego 정책 — 주문 조회 권한
# 의존성: OPA 서버 (docker run openpolicyagent/opa)
package api.orders
default allow = false
allow {
# GET 요청이고
input.method == "GET"
# /api/v1/orders/{id} 경로이고
[_, _, _, order_id] = input.path
# orders:read 스코프를 가진 토큰이고
input.token.payload.scope[_] == "orders:read"
# 요청자가 해당 주문의 소유자인 경우에만
input.token.payload.sub == data.order_owners[order_id]
}// NestJS에서 OPA 연동 — 모든 인가 결정을 OPA에 위임
// 의존성: @nestjs/axios, rxjs
import { Injectable } from '@nestjs/common';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class OpaGuard implements CanActivate {
constructor(private readonly httpService: HttpService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { data } = await firstValueFrom(
this.httpService.post(
'http://opa:8181/v1/data/api/orders/allow',
{
input: {
method: request.method,
path: request.path.split('/').filter(Boolean),
token: { payload: request.user },
},
}
)
);
return data.result === true;
}
}새로운 권한 규칙이 생겨도 서비스 재배포 없이 OPA 정책만 업데이트하면 됩니다. 여러 서비스가 동일한 OPA 인스턴스를 바라볼 수 있어서 정책이 흩어지지도 않습니다. 처음엔 "그냥 서비스 안에서 if문으로 처리하면 되지 않나?" 싶었는데, 서비스 수가 10개를 넘어가면서 각자 다른 기준으로 권한을 판단하는 걸 보고 나서야 OPA의 가치를 실감했습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 내부 횡이동 차단 | 서비스 하나가 뚫려도 공격자의 이동 반경이 확연히 줄어듭니다 |
| 세분화된 감사 로그 | 모든 API 호출이 기록되어 포렌식과 컴플라이언스 대응이 훨씬 수월합니다 |
| 클라우드·멀티클라우드 적합 | 경계가 흐릿한 분산 환경에서도 일관된 보안 정책을 유지할 수 있습니다 |
| 섀도우 API 탐지 | 지속적 검증 과정에서 등록되지 않은 API 엔드포인트가 자연스럽게 드러납니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 성능 오버헤드 | mTLS 초기 핸드셰이크 시 수 ms 레이턴시가 추가됩니다 | 커넥션 풀링, 세션 재사용으로 완화할 수 있습니다 |
| 운영 복잡도 | 인증서 수명 관리, 토큰 갱신, 정책 업데이트가 늘어납니다 — 솔직히 귀찮습니다 | SPIRE, HashiCorp Vault로 자동화하는 것을 권장합니다 |
| 팀 간 일관성 | 팀마다 OAuth, JWT, mTLS를 혼재해 쓰면 표준이 무너집니다 | API Gateway에서 단일 방식으로 표준화하는 것이 좋습니다 |
| East-West 트래픽 | 쿠버네티스 파드 간 통신은 기본적으로 암호화가 없습니다 | Istio 서비스 메시 도입으로 자동 mTLS 적용이 가능합니다 |
| 토큰 블랙리스트 확장성 | Redis 단일 노드는 장애 시 인증 불가 상태가 됩니다 | 클러스터 구성 또는 단기 수명 토큰으로 의존도를 줄일 수 있습니다 |
| 초기 도입 비용 | PKI 인프라, Identity Provider, API Gateway 구축이 필요합니다 | 단일 API Gateway부터 시작해 점진적으로 확장하는 것이 현실적입니다 |
PKI (Public Key Infrastructure): 인증서를 발급·관리·폐기하는 인프라 전체를 말합니다. mTLS를 운영하려면 이 인프라가 기반에 있어야 합니다.
실무에서 가장 흔한 실수
-
exp만 검증하고 revocation을 건너뜁니다 — 토큰이 탈취되어도 만료 전까지는 막을 방법이 없어집니다.jti기반 블랙리스트 또는 단기 수명 토큰(15분 이내)을 함께 쓰는 것을 권장합니다. -
서비스 계정에 과도한 권한을 부여합니다 — "일단 넓게 줘놓고 나중에 조이자"는 전략은 현실에서 거의 조여지지 않습니다. 처음부터 최소 권한으로 시작하는 것이 훨씬 현명합니다.
-
내부 서비스 API를 인증 없이 열어둡니다 — "어차피 외부에서 못 접근하는데"라는 생각이 가장 위험합니다. 내부 네트워크도 이미 침해되었다고 가정하는 것이 제로트러스트의 출발점입니다.
마치며
제로트러스트는 한 번에 완성하는 프로젝트가 아니라, "모든 신뢰는 증명되어야 한다"는 원칙을 시스템 전반에 점진적으로 심어가는 여정입니다.
실제로 NIST도 빅뱅 전환보다 단계적 마이그레이션을 권고합니다. 지금 당장 전체 인프라를 뜯어고칠 필요는 없습니다. 아래 순서대로 차근차근 시작해보시면 좋습니다.
- JWT 검증 강화부터 시작해볼 수 있습니다 —
iss,aud,exp,scope네 가지를 모두 검증하고 있는지 지금 코드에서 확인해보시면 좋습니다.jtirevocation이 없다면 단기 수명 토큰(15분) 전환이 우선입니다. - 두 서비스 사이에만 mTLS 파일럿을 적용해볼 수 있습니다 — Istio가 부담스럽다면
cert-manager로 시작하는 것도 충분합니다. - OPA를 로컬에서 먼저 실험해볼 수 있습니다 —
docker run openpolicyagent/opa로 띄우고curl -X POST http://localhost:8181/v1/data/...로 정책 쿼리가 동작하는지 확인해보시면 감을 잡을 수 있습니다.
여러분 팀은 내부 서비스 간 인증을 어떻게 처리하고 있나요? 댓글로 공유해주시면 함께 이야기 나눠보겠습니다.
다음 글: 서비스 메시 없이 Envoy + SPIRE만으로 쿠버네티스 환경에서 마이크로서비스 mTLS를 단계별로 구축하는 방법
참고 자료
- NIST SP 800-207: Zero Trust Architecture | NIST
- NIST SP 800-207A: 멀티클라우드 네이티브 환경 제로트러스트 | NIST
- Implementing Zero Trust APIs | Curity
- Zero Trust API Security Explained | A10 Networks
- Zero Trust with Envoy, SPIRE and OPA | Styra
- SPIFFE 공식 사이트
- Zero Trust is Not Enough: Evolving Cloud Security in 2025 | CSA
- API Gateway Authentication Patterns: JWT, OAuth2, mTLS | Elysiate
- Zero Trust Microservices Security | Springfuse