API 요청/응답을 통째로 암호화하는 필터를 만들었다
API 요청/응답을 통째로 암호화하는 필터를 만들었다
2025-06-09 ~ 2025-11-10
왜 HTTPS만으로는 부족한가
HTTPS는 클라이언트와 서버 사이 통신을 암호화한다. 그런데 우리 프로젝트는 암호화폐 결제 시스템이다. 잔고 조회, 송금, 결제 같은 민감한 데이터가 오간다.
HTTPS 위에 애플리케이션 레벨 암호화를 한 겹 더 씌웠다. 이유는 간단하다.
- 중간자 공격 방어 강화: 프록시나 CDN에서 HTTPS가 복호화되는 구간이 있을 수 있다 (중간자 공격이란 클라이언트와 서버 사이에 제3자가 끼어들어 데이터를 엿보거나 조작하는 공격이다)
- 로그 노출 방지: 서버 접근 로그에 평문 JSON이 찍히지 않는다
- 클라이언트 인증: 토큰을 가진 클라이언트만 통신 가능
설계 목표
개발자가 암호화를 의식하지 않고 평문 JSON으로 개발할 수 있어야 한다. 컨트롤러 코드에 암호화 관련 코드가 한 줄도 없어야 한다.
// 컨트롤러는 평문 JSON만 다룬다
@PostMapping("/pci/wallet/v3/login")
fun login(@RequestBody req: LoginRequest): LoginResponse {
// req는 이미 복호화된 객체
return LoginResponse(token = "...") // 응답은 자동으로 암호화됨
}
이걸 가능하게 하는 게 Spring Filter다. → Filter는 서블릿 스펙에서 제공하는 기능으로, HTTP 요청이 컨트롤러에 도달하기 전과 응답이 나가기 전에 가로채서 공통 처리를 할 수 있는 관문이다.
Filter란?
Spring에서 HTTP 요청이 컨트롤러에 도달하기 전에 거치는 관문이다.
클라이언트 → [Filter 1] → [Filter 2] → [Controller] → [Filter 2] → [Filter 1] → 클라이언트
요청이 들어올 때와 응답이 나갈 때 모두 필터를 통과한다. 여기서 요청 본문을 복호화하고, 응답 본문을 암호화하면 된다.
암호화 흐름 전체
[앱(클라이언트)]
1. 랜덤 AES 키(32바이트) + IV(16바이트) 생성
2. 서버에 토큰 발급 요청 (RSA 공개키로 AES 키/IV 암호화하여 전송)
→ RSA는 비대칭키 암호화 방식으로, 공개키로 암호화하면 서버만 가진 개인키로만 복호화할 수 있어서 키 교환에 안전하다
3. 서버가 토큰 발급 → 앱이 토큰 저장
[이후 모든 API 요청]
4. JSON 요청 → AES-256-CBC 암호화 → Base64 인코딩 (Base64는 바이너리 데이터를 텍스트 문자열로 변환하는 인코딩 방식이다. HTTP로 전송하려면 바이너리를 텍스트로 바꿔야 하기 때문에 사용한다)
5. HTTP 헤더에 X-PCI-Token: {토큰} 추가
6. 서버의 TokenFilter가 토큰으로 AES 키/IV 조회
7. 요청 본문 복호화 → 컨트롤러에 평문 전달
8. 컨트롤러 처리 → 응답 JSON
9. 응답 본문 AES-256-CBC 암호화 → Base64 인코딩
10. 앱이 같은 AES 키/IV로 복호화
핵심은 AES 키가 토큰에 매핑되어 있다는 것이다. 토큰을 모르면 키를 모르고, 키를 모르면 복호화할 수 없다.
TokenFilter 구현
필터의 핵심 로직은 이렇다.
@Component
@Order(1) // 가장 먼저 실행되는 필터
class TokenFilter(private val tokenService: TokenService) : OncePerRequestFilter() {
// OncePerRequestFilter는 하나의 HTTP 요청에 대해 필터가 딱 한 번만 실행되도록 보장하는 Spring 제공 클래스다
override fun doFilterInternal(request, response, filterChain) {
if (isEncryptionTarget(request)) {
// 1. 헤더에서 토큰 추출
val token = request.getHeader("X-PCI-Token")
?: throw RequestHeaderException("X-PCI-TOKEN")
// 2. 토큰으로 AES 키/IV 조회 + 만료 확인
val tokenInfo = tokenService.getToken(token)
// 3. 요청 래퍼(복호화) + 응답 래퍼(암호화) 적용
val wrappedResponse = EncryptedServletResponse(response, tokenInfo)
filterChain.doFilter(
EncryptedServletRequest(request, tokenInfo), // 복호화된 요청
wrappedResponse // 암호화될 응답
)
// 4. 암호화된 응답을 클라이언트에 전송
val encrypted = wrappedResponse.encrypt()
response.outputStream.write(encrypted)
} else {
filterChain.doFilter(request, response) // 암호화 대상 아님
}
}
}
@Order(1)로 설정해서 모든 필터 중 가장 먼저 실행된다.
다른 필터나 인터셉터가 볼 때는 이미 평문 JSON이다.
요청 복호화 — EncryptedServletRequest
HttpServletRequestWrapper를 상속해서 getInputStream()을 오버라이드한다.
→ Wrapper 패턴은 원래 객체를 감싸서 일부 동작만 바꾸는 디자인 패턴이다. 여기서는 원본 요청 객체를 감싸서, 본문을 읽을 때 복호화된 데이터를 반환하도록 바꾼 것이다.
컨트롤러가 요청 본문을 읽으려고 하면, 복호화된 데이터를 반환한다.
class EncryptedServletRequest(request, tokenInfo) : HttpServletRequestWrapper(request) {
override fun getInputStream(): ServletInputStream {
// 1. 원본 스트림에서 암호화된 텍스트 읽기
val encrypted = readBody(super.getInputStream())
// 2. Base64 디코딩 → AES-256-CBC 복호화
val decrypted = TokenService.decrypt(encrypted, tokenInfo.key, tokenInfo.iv)
// 3. 복호화된 바이트를 스트림으로 반환
return ByteArrayServletInputStream(decrypted)
}
override fun getContentType() = "application/json;charset=UTF-8"
}
컨트롤러 입장에서는 @RequestBody LoginRequest가 그냥 동작한다.
Jackson이 JSON을 파싱할 때 이 복호화된 스트림을 읽는다.
응답 암호화 — EncryptedServletResponse
반대로, 컨트롤러가 응답을 쓰면 메모리에 버퍼링했다가 암호화한다.
class EncryptedServletResponse(response, tokenInfo) : HttpServletResponseWrapper(response) {
private val buffer = ByteArrayOutputStream()
override fun getOutputStream(): ServletOutputStream {
// 컨트롤러가 쓰는 응답을 버퍼에 저장
return BufferedServletOutputStream(buffer)
}
fun encrypt(): ByteArray {
val jsonResponse = buffer.toByteArray()
// AES-256-CBC 암호화 → Base64 인코딩
val encrypted = TokenService.encrypt(jsonResponse, tokenInfo.key, tokenInfo.iv)
return encrypted.toByteArray(Charsets.UTF_8)
}
}
AES-256-CBC란?
- AES: Advanced Encryption Standard — 대칭키 암호화 표준
- 256: 키 길이 256비트(32바이트) — 현재 가장 강력한 수준
- CBC: Cipher Block Chaining — 이전 블록의 암호문을 다음 블록 암호화에 사용하여 패턴을 숨김
평문: [블록1] [블록2] [블록3]
↓ XOR(IV) ↓ XOR(암호문1) ↓ XOR(암호문2)
암호문: [■■■■] [■■■■] [■■■■]
IV(Initialization Vector, 초기화 벡터)는 첫 번째 블록에 XOR할 랜덤 값이다. (XOR은 두 값이 같으면 0, 다르면 1을 반환하는 비트 연산으로, 암호화에서 데이터를 뒤섞는 데 사용된다) 같은 평문이라도 IV가 다르면 완전히 다른 암호문이 나온다.
파일 업로드는 제외
모든 경로를 암호화하면 파일 업로드가 깨진다. 멀티파트 파일은 바이너리 데이터라 JSON 암호화 로직을 타면 안 된다. (멀티파트란 하나의 HTTP 요청에 텍스트와 파일 등 여러 종류의 데이터를 함께 담아 보내는 전송 방식이다)
// 암호화 제외 경로
if (request.servletPath.startsWith("/pci/wallet/v3/file/upload")) {
filterChain.doFilter(request, response) // 그냥 통과
return
}
디버깅이 어려웠던 점
이 구조의 단점은 네트워크 레벨에서 요청/응답을 볼 수 없다는 것이다. Postman이나 Charles Proxy로 찍어봐야 Base64 암호문만 보인다.
디버깅할 때는 필터 내부에 로그를 찍거나, 테스트 환경에서만 암호화를 끄는 방법을 썼다.
통합 테스트 프레임워크(payprotocol-tx-test)에서는 동일한 AES 암호화를 구현한 TokenEncryptedClient를 만들어서 실제 앱과 같은 방식으로 테스트했다.
배운 것
- Filter + Wrapper 패턴으로 애플리케이션 코드를 수정하지 않고 전체 암호화를 적용할 수 있다
@Order(1)로 최우선 실행해야 다른 필터/인터셉터에서 복호화된 데이터를 사용 가능- 파일 업로드 같은 예외 경로를 반드시 고려해야 한다
- 디버깅이 어려워지므로 테스트 클라이언트에도 동일한 암호화를 구현해야 한다