목록으로

API 요청/응답을 통째로 암호화하는 필터를 만들었다

3

API 요청/응답을 통째로 암호화하는 필터를 만들었다

2025-06-09 ~ 2025-11-10

왜 HTTPS만으로는 부족한가

HTTPS는 클라이언트와 서버 사이 통신을 암호화한다. 그런데 우리 프로젝트는 암호화폐 결제 시스템이다. 잔고 조회, 송금, 결제 같은 민감한 데이터가 오간다.

HTTPS 위에 애플리케이션 레벨 암호화를 한 겹 더 씌웠다. 이유는 간단하다.

  1. 중간자 공격 방어 강화: 프록시나 CDN에서 HTTPS가 복호화되는 구간이 있을 수 있다 (중간자 공격이란 클라이언트와 서버 사이에 제3자가 끼어들어 데이터를 엿보거나 조작하는 공격이다)
  2. 로그 노출 방지: 서버 접근 로그에 평문 JSON이 찍히지 않는다
  3. 클라이언트 인증: 토큰을 가진 클라이언트만 통신 가능

설계 목표

개발자가 암호화를 의식하지 않고 평문 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)로 최우선 실행해야 다른 필터/인터셉터에서 복호화된 데이터를 사용 가능
  • 파일 업로드 같은 예외 경로를 반드시 고려해야 한다
  • 디버깅이 어려워지므로 테스트 클라이언트에도 동일한 암호화를 구현해야 한다
API 요청/응답을 통째로 암호화하는 필터를 만들었다 | KYUDORI