서버간 통신 인증 방식 : API Key

LTT·2025년 7월 7일

Introduce


우리가 시스템을 설계할 때 가장 많이 사용하는 인증 방식 중 하나는 바로 JWT (JSON Web Token) 기반 인증이다.

JWT는 주로 사용자의 인증 정보와 권한을 서버가 아닌 클라이언트 측에 토큰으로 담아 전달하는 방식이다. 사용자는 해당 토큰을 가지고 서버에 접근하며, 서버는 토큰을 검증함으로써 사용자의 신원을 확인한다.

그렇다면 서버와 서버 간의 통신에서도 무조건 JWT를 사용해야 할까?

솔직히 말하면 나는 지금까지 별다른 고민 없이 무조건 JWT를 사용해왔다. 하지만 최근 프로젝트에서는 내가 JWT를 굳이 사용할 필요가 없다는 사실을 깨달았다.

내가 고민한 이유는 다음과 같다.

  1. FastAPI로 구현한 모델 서버에서는 사용자의 세션 정보나 개인정보를 전혀 사용하지 않는다.
  2. 모든 요청은 오직 메인 서버로부터만 들어온다. (즉, 외부 요청 차단)

결국 내 경우에는 복잡한 JWT 기반 인증 대신, 단순하면서도 서버 간 신뢰를 보장할 수 있는 방법으로 API Key 기반 인증을 도입하기로 결정했다.

로직 설계 및 구현


단순히 API Key를 평문으로 전송하는 것은 보안상 위험하다고 판단했다.

물론 내부 통신이기 때문에 큰 위험은 없겠지만, 그래도 암호화는 최소한의 보안 조치라고 생각했다.

그래서 암호화 방식을 간단히 살펴보고 적절한 방법을 선택했다.

암호화 방식


암호화 방식은 크게 두 가지로 나눌 수 있다.

  1. 대칭키 방식 (Symmetric Key)
  2. 비대칭키 방식 (Asymmetric Key)

비대칭키 방식은 보안성이 뛰어나지만, 구현이 복잡하고 속도도 상대적으로 느리다.

내 프로젝트는 다음과 같은 특징이 있다.

  • 민감한 정보 없음
  • 단순한 인증만 필요
  • 빠른 개발이 중요

따라서 이번에는 대칭키 방식 (AES) 을 사용하기로 결정했다.

자세한 차이는 아래 블로그 글이 잘 정리되어 있어 추천한다.

👉 대칭키와 공개키(비대칭키)

API Key 암호화 및 전송


1. Spring 서버 (API Key 암호화 및 전송)

Spring 서버에서는 API 요청 시, 미리 설정해둔 API Key를 암호화한 후 HTTP 헤더에 담아 전송하도록 구성했다.

📌 EncryptedApiKeyProvider (API Key 암호화)

@Slf4j
@Getter
@Component
@RequiredArgsConstructor
public class EncryptedApiKeyProvider {
    @Value("${server.api-key}")
    private String formatClientApiKey;

    @Value("${server.salt}")
    private String salt;

    private final String ALGORITHM = "AES";
    private String encryptedApiKey;

    @PostConstruct
    public void init() throws Exception {
        try {
            this.encryptedApiKey = encrypt();
            log.info("API Key encrypted");
        } catch (Exception e) {
            throw new Exception("Failed to encrypt API key during initialization", e);
        }
    }

    public String encrypt() throws Exception {
        SecretKeySpec secretKey = new SecretKeySpec(this.salt.getBytes(), ALGORITHM);
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);
        byte[] encrypted = cipher.doFinal(this.formatClientApiKey.getBytes());
        return Base64.getEncoder().encodeToString(encrypted);
    }
}

📌 WebClientConfig (API Key 전송)

@Configuration
@RequiredArgsConstructor
public class WebClientConfig {

    @Value("${stt.server.url}")
    private String serverUrl;

    private final EncryptedApiKeyProvider apiKeyProvider;

    @Bean(name = "webClient")
    public WebClient formatWebClient() throws SSLException {
        SslContext sslContext = SslContextBuilder
                .forClient()
                .trustManager(InsecureTrustManagerFactory.INSTANCE)
                .build();

        HttpClient httpClient = HttpClient.create()
                .secure(ssl -> ssl.sslContext(sslContext));

        return WebClient.builder()
                .baseUrl(serverUrl)
                .defaultHeader("X-API-KEY", apiKeyProvider.getEncryptedApiKey())
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
}

✅ 모든 API 요청은 X-API-KEY 헤더에 암호화된 키가 포함된다.

API Key 복호화 및 인증


2. FastAPI 서버 (API Key 복호화 및 검증)

FastAPI 서버에서는 Spring 서버로부터 전달받은 암호화된 API Key를 복호화하고, 미리 설정한 Key와 일치하는지 확인한다. 이를 위해 미들웨어를 작성했다.

📌 ApiKeyValidationMiddleware (복호화 및 인증)

from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from Crypto.Cipher import AES
from dotenv import load_dotenv
import base64
import os
from app.utils.log_util import logger

# .env 파일 로드
load_dotenv()

class ApiKeyValidationMiddleware(BaseHTTPMiddleware):
    def __init__(self, app):
        super().__init__(app)

        self.control_character = "\x06"
        self.SALT = os.getenv("SALT").encode("utf-8")
        self.API_KEY = os.getenv("API_KEY")

    def decrypt_api_key(self, encrypted_key_base64: str) -> str:
        try:
            cipher = AES.new(self.SALT, AES.MODE_ECB)
            decoded = base64.b64decode(encrypted_key_base64)
            decrypted = cipher.decrypt(decoded)
            return decrypted.decode("utf-8").strip(self.control_character)
        except Exception:
            raise HTTPException(status_code=401, detail="API Key Decryption Failed")

    async def dispatch(self, request: Request, call_next):
        encrypted_key = request.headers.get("X-API-KEY")
        if not encrypted_key:
            return JSONResponse(status_code=401, content={"detail": "Missing API Key"})

        try:
            decrypted_key = self.decrypt_api_key(encrypted_key)
        except HTTPException as e:
            return JSONResponse(status_code=401, content={"detail": e.detail})

        if decrypted_key != self.API_KEY:
            return JSONResponse(status_code=401, content={"detail": "Invalid API Key"})

        return await call_next(request)

✅ 요청 헤더의 API Key 복호화 → Key 일치 여부 확인 → 미들웨어 통과

정리


요약하자면, 나의 서버 인증 로직은 다음과 같은 단계로 이루어진다.

  1. Spring 서버: API Key 암호화 → X-API-KEY 헤더에 추가하여 요청 전송
  2. FastAPI 서버: API Key 복호화 → Key 검증 → 인증 성공 시 요청 처리

나는 복잡한 JWT 토큰 인증 대신, 단순하고 빠르게 구현 가능한 API Key 암호화 방식을 선택했다.

물론 이 방식이 모든 상황에 정답은 아니다. 만약 사용자의 권한 분기, 세션 관리, 세밀한 접근 제어가 필요하다면 JWT 또는 OAuth2가 더 적합할 것이다.

하지만 내 상황에서는 간결함, 속도, 효율이 우선이라 판단했고, 이에 맞춰 설계하고 구현했다.


💬 추가 의견이나 개선 아이디어

  • ECB 모드는 간단하지만 보안에 약점이 있다 → CBC, GCM 같은 더 안전한 모드 고려 가능
  • 키 관리: .env 파일 대신 Secret Manager 사용 검토
  • 인증 실패시 로깅 강화 및 모니터링
profile
개발자에서 엔지니어로, 엔지니어에서 리더로

0개의 댓글