API 키 관리, OAuth Client Credentials, mTLS를 활용한 서비스 간 인증, 마이크로서비스에서의 JWT 전파, API 게이트웨이 인증 패턴을 실전 코드와 함께 설명합니다.
사용자 인증이 "사람이 시스템에 접근하는 것"을 다룬다면, API 인증과 서비스 간 통신 보안은 "시스템이 시스템에 접근하는 것"을 다룹니다. 마이크로서비스 아키텍처에서는 수십, 수백 개의 서비스가 서로를 호출하며, 각 호출마다 "이 서비스가 이 요청을 할 자격이 있는가?"를 검증해야 합니다.
API 키(API Key)는 가장 단순한 API 인증 수단입니다. 클라이언트를 식별하는 긴 문자열을 헤더나 쿼리 파라미터로 전달합니다.
GET /api/v1/data HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_a1b2c3d4e5f6g7h8i9j0import crypto from "crypto";
interface ApiKey {
id: string;
hashedKey: string; // 키의 해시값만 저장
prefix: string; // 식별용 접두어 (sk_live_a1b2)
name: string; // 키 이름 (사용처 설명)
clientId: string; // 소유자
scopes: string[]; // 허용 범위
rateLimit: number; // 분당 요청 제한
expiresAt: Date | null; // 만료일 (null이면 무기한)
lastUsedAt: Date | null;
createdAt: Date;
}
function generateApiKey(): { key: string; prefix: string; hash: string } {
// 32바이트 랜덤 키 생성
const rawKey = crypto.randomBytes(32).toString("base64url");
const key = `sk_live_${rawKey}`;
const prefix = key.substring(0, 12);
const hash = crypto
.createHash("sha256")
.update(key)
.digest("hex");
return { key, prefix, hash };
}
async function validateApiKey(
providedKey: string,
): Promise<ApiKey | null> {
const hash = crypto
.createHash("sha256")
.update(providedKey)
.digest("hex");
const apiKey = await findApiKeyByHash(hash);
if (!apiKey) return null;
// 만료 확인
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
return null;
}
// 사용 시간 업데이트
await updateLastUsed(apiKey.id);
return apiKey;
}API 키는 인증(Authentication)보다는 식별(Identification)에 가깝습니다. API 키만으로는 "누가" 요청하는지 알 수 있지만, 그 키가 정당한 소유자에 의해 사용되는지는 알 수 없습니다. 민감한 API에는 API 키 단독 사용을 피하고, OAuth 2.0이나 mTLS와 조합하는 것이 좋습니다.
| 원칙 | 설명 |
|---|---|
| 해시 저장 | 키의 해시값만 DB에 저장. 원본은 생성 시 한 번만 표시 |
| 범위 제한 | 키마다 허용하는 API 범위(scope)를 최소화 |
| 만료 설정 | 무기한 키를 피하고, 주기적 로테이션을 권장 |
| 속도 제한 | 키별 요청 속도(rate limit)를 설정하여 남용 방지 |
| 환경 분리 | 테스트 키와 프로덕션 키를 명확히 분리 |
| 사용 모니터링 | 비정상 사용 패턴 감지 (갑작스런 트래픽 증가 등) |
서비스 간 통신에서는 사용자 개입이 없으므로, Client Credentials Grant를 사용합니다. 서비스 자체가 클라이언트이자 리소스 요청자입니다.
interface ServiceTokenCache {
token: string;
expiresAt: number;
}
// 서비스 토큰 캐시 (토큰 재사용)
const tokenCache = new Map<string, ServiceTokenCache>();
async function getServiceToken(
targetService: string,
scopes: string[],
): Promise<string> {
const cacheKey = `${targetService}:${scopes.join(",")}`;
const cached = tokenCache.get(cacheKey);
// 만료 1분 전까지 캐시된 토큰 재사용
if (cached && cached.expiresAt > Date.now() + 60000) {
return cached.token;
}
const response = await fetch(AUTH_SERVER_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: scopes.join(" "),
audience: `https://${targetService}.internal`,
}),
});
const data = await response.json();
tokenCache.set(cacheKey, {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
});
return data.access_token;
}
// 서비스 간 호출 시 사용
async function callOrderService(orderId: string) {
const token = await getServiceToken("order-service", ["orders:read"]);
return fetch(`https://order-service.internal/api/orders/${orderId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}mTLS(mutual TLS)는 서버뿐 아니라 클라이언트도 인증서로 자신을 증명하는 양방향 TLS 인증입니다. 서비스 간 통신에서 가장 강력한 인증 수단 중 하나입니다.
import https from "https";
import fs from "fs";
// 서버 측 설정
const serverOptions: https.ServerOptions = {
// 서버 인증서
key: fs.readFileSync("/certs/server-key.pem"),
cert: fs.readFileSync("/certs/server-cert.pem"),
// CA 인증서 (클라이언트 인증서 검증용)
ca: fs.readFileSync("/certs/ca-cert.pem"),
// 클라이언트 인증서 요구
requestCert: true,
rejectUnauthorized: true,
};
const server = https.createServer(serverOptions, (req, res) => {
// 클라이언트 인증서 정보 추출
const clientCert = req.socket.getPeerCertificate();
if (!clientCert || !clientCert.subject) {
res.writeHead(403);
res.end("클라이언트 인증서가 필요합니다.");
return;
}
// 인증서의 CN(Common Name)으로 서비스 식별
const serviceName = clientCert.subject.CN;
console.log(`인증된 서비스: ${serviceName}`);
// 서비스별 권한 확인
if (!isAuthorized(serviceName, req.url ?? "", req.method ?? "")) {
res.writeHead(403);
res.end("해당 작업에 대한 권한이 없습니다.");
return;
}
// 요청 처리
handleRequest(req, res);
});import https from "https";
import fs from "fs";
// 클라이언트 측 설정
const clientOptions: https.RequestOptions = {
hostname: "order-service.internal",
port: 443,
path: "/api/orders",
method: "GET",
// 클라이언트 인증서
key: fs.readFileSync("/certs/client-key.pem"),
cert: fs.readFileSync("/certs/client-cert.pem"),
// 서버 CA 인증서
ca: fs.readFileSync("/certs/ca-cert.pem"),
};
const req = https.request(clientOptions, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => {
console.log("응답:", JSON.parse(data));
});
});
req.end();mTLS의 인증서 관리는 복잡할 수 있습니다. 쿠버네티스 환경에서는 서비스 메시(Istio, Linkerd)가 mTLS를 자동으로 처리해줍니다. 인증서 발급, 로테이션, 폐기를 사이드카 프록시가 투명하게 관리하므로, 애플리케이션 코드를 수정할 필요가 없습니다.
사용자가 API 게이트웨이를 통해 요청하면, 그 요청이 여러 내부 서비스를 거칠 수 있습니다. 이때 사용자의 인증 정보(JWT)를 어떻게 전파할지가 중요한 설계 결정입니다.
토큰 교환(Token Exchange, RFC 8693)을 사용하면 서비스가 인가 서버에 현재 토큰을 제출하고, 대상 서비스에 맞는 범위가 축소된 새 토큰을 받습니다.
async function exchangeToken(config: {
subjectToken: string; // 현재 가진 토큰
targetAudience: string; // 대상 서비스
requestedScopes: string[]; // 필요한 범위
}): Promise<string> {
const response = await fetch(AUTH_SERVER_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`,
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: config.subjectToken,
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
audience: config.targetAudience,
scope: config.requestedScopes.join(" "),
}),
});
const data = await response.json();
return data.access_token;
}
// 서비스 A에서 서비스 B 호출 시
async function callServiceB(userToken: string, orderId: string) {
// 사용자 토큰을 서비스 B 전용 토큰으로 교환
const serviceBToken = await exchangeToken({
subjectToken: userToken,
targetAudience: "https://service-b.internal",
requestedScopes: ["orders:read"],
});
return fetch(`https://service-b.internal/api/orders/${orderId}`, {
headers: { Authorization: `Bearer ${serviceBToken}` },
});
}이 패턴에서는 서비스 간 인증은 mTLS로 처리하고, 사용자 정보는 서명된 컨텍스트 헤더로 전파합니다.
import { SignJWT, jwtVerify } from "jose";
// API 게이트웨이: 사용자 컨텍스트 생성
async function createUserContext(
userToken: JwtPayload,
): Promise<string> {
// 필요한 최소 정보만 포함
const context = await new SignJWT({
sub: userToken.sub,
roles: userToken.roles,
tenant_id: userToken.tenant_id,
// 원본 토큰의 만료 시간을 유지
original_exp: userToken.exp,
})
.setProtectedHeader({ alg: "ES256" })
.setIssuedAt()
.setExpirationTime("5m") // 짧은 수명
.setIssuer("api-gateway")
.sign(GATEWAY_PRIVATE_KEY);
return context;
}
// 내부 서비스: 사용자 컨텍스트 검증
async function verifyUserContext(
contextHeader: string,
): Promise<UserContext> {
const { payload } = await jwtVerify(
contextHeader,
GATEWAY_PUBLIC_KEY,
{
issuer: "api-gateway",
algorithms: ["ES256"],
}
);
return {
userId: payload.sub as string,
roles: payload.roles as string[],
tenantId: payload.tenant_id as string,
};
}API 게이트웨이(API Gateway)는 모든 외부 요청의 단일 진입점으로, 인증을 중앙에서 처리하는 핵심 컴포넌트입니다.
import type { Request, Response, NextFunction } from "express";
interface AuthConfig {
publicPaths: string[]; // 인증 불필요 경로
apiKeyPaths: string[]; // API 키 인증 경로
oauthPaths: string[]; // OAuth 인증 경로
}
function gatewayAuthMiddleware(config: AuthConfig) {
return async (req: Request, res: Response, next: NextFunction) => {
const path = req.path;
// 1. 공개 경로 확인
if (config.publicPaths.some((p) => path.startsWith(p))) {
return next();
}
// 2. API 키 인증 경로
if (config.apiKeyPaths.some((p) => path.startsWith(p))) {
const apiKey = req.headers["x-api-key"] as string;
if (!apiKey) {
return res.status(401).json({ error: "API 키가 필요합니다." });
}
const keyData = await validateApiKey(apiKey);
if (!keyData) {
return res.status(401).json({ error: "유효하지 않은 API 키입니다." });
}
// 스코프 확인
const requiredScope = getRequiredScope(req.method, path);
if (!keyData.scopes.includes(requiredScope)) {
return res.status(403).json({ error: "해당 API에 대한 권한이 없습니다." });
}
req.headers["x-client-id"] = keyData.clientId;
req.headers["x-auth-method"] = "api-key";
return next();
}
// 3. OAuth 토큰 인증
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Bearer 토큰이 필요합니다." });
}
const token = authHeader.substring(7);
try {
const payload = await verifyAccessToken(token);
// 사용자 정보를 헤더로 전달
req.headers["x-user-id"] = payload.sub;
req.headers["x-user-roles"] = JSON.stringify(payload.roles);
req.headers["x-tenant-id"] = payload.tenant_id;
req.headers["x-auth-method"] = "oauth";
// Authorization 헤더 제거 (내부 서비스에 원본 토큰 전달 안 함)
delete req.headers.authorization;
next();
} catch (error) {
if (error instanceof TokenExpiredError) {
return res.status(401).json({ error: "토큰이 만료되었습니다." });
}
return res.status(401).json({ error: "유효하지 않은 토큰입니다." });
}
};
}API 게이트웨이에서 인증을 처리하면 내부 서비스는 인증 로직에서 해방됩니다. 내부 서비스는 게이트웨이가 전달한 헤더(x-user-id, x-user-roles 등)만 신뢰하면 됩니다. 단, 내부 서비스 간 직접 통신이 있다면 mTLS 등의 별도 인증이 필요합니다.
| 시나리오 | 권장 방식 | 이유 |
|---|---|---|
| 사용자 -> 서비스 | OAuth 2.0 + PKCE | 표준화된 위임 인증 |
| 서비스 -> 서비스 (내부) | mTLS 또는 Client Credentials | 강력한 양방향 인증 |
| 서드파티 -> API | API 키 + OAuth | 식별 + 인증 조합 |
| 서비스 메시 내부 | mTLS (자동, Istio/Linkerd) | 투명한 인증서 관리 |
| 웹훅(Webhook) 수신 | HMAC 서명 검증 | 발신자 위조 방지 |
API 인증과 서비스 간 통신 보안은 마이크로서비스 아키텍처의 근간입니다. API 키는 단순한 식별에, OAuth Client Credentials는 표준화된 서비스 인증에, mTLS는 가장 강력한 양방향 인증에 사용됩니다. JWT 전파 전략은 보안, 성능, 운영 복잡도의 균형을 고려하여 선택해야 하며, API 게이트웨이는 인증을 중앙화하여 내부 서비스의 부담을 줄여줍니다.
다음 장에서는 인증 시스템에 적용되는 규제와 컴플라이언스(Compliance) 요구사항을 다룹니다. GDPR, PCI-DSS, SOC 2가 인증에 부과하는 구체적 요구사항을 살펴보겠습니다.
이 글이 도움이 되셨나요?
제로 트러스트의 핵심 원칙과 인증의 관계를 분석하고, 지속적 검증, 디바이스 신뢰, 컨텍스트 기반 접근 제어, BeyondCorp 모델, 아이덴티티 인식 프록시를 설명합니다.
GDPR, PCI-DSS, SOC 2가 인증 시스템에 부과하는 구체적 요구사항, NIST 800-63B 비밀번호 가이드라인, 감사 로깅 설계, MFA 요구사항, 데이터 거주 전략을 정리합니다.
JWT의 클레임과 서명 알고리즘을 깊이 분석하고, 리프레시 토큰 로테이션, 토큰 저장 전략, 세션 관리 패턴, BFF 패턴을 실전 코드와 함께 설명합니다.