Pingu
영차영차! Backend

MongoDB CSFLE로 개인정보 필드 암호화 구현: AWS KMS 연동과 접근 감사 로깅

2026년 1월 21일
6개 태그
MongoDB
암호화
CSFLE
보안
Field-Level Encryption
AWS KMS

MongoDB CSFLE로 개인정보 필드 암호화 구현: AWS KMS 연동과 접근 감사 로깅

들어가며

서비스를 운영하다 보면 사용자의 개인정보를 다루게 됩니다. 처음에는 단순히 데이터베이스에 저장하면 될 거라고 생각했습니다. 하지만 이름, 전화번호, 이메일 같은 민감한 정보를 평문으로 저장하는 것은 보안상 큰 리스크였습니다.

저희 폼 빌더 서비스에서도 지원자들의 개인정보를 다루고 있었는데, 데이터베이스 유출 시나리오를 고민하다 보니 암호화가 필수라는 결론에 이르렀습니다. 여러 방법을 검토한 끝에 MongoDB의 Client-Side Field Level Encryption(CSFLE)과 AWS KMS를 활용하기로 결정했습니다.

이번 글에서는 암호화 시스템을 구축하면서 겪었던 고민들과 기술적 선택의 과정을 공유하려고 합니다.

전체 아키텍처

전체 아키텍처

전체 아키텍처

암호화 시스템을 설계하면서 생각보다 고려할 요소가 많다는 것을 알게 되었습니다. 최종적으로 구축한 시스템은 크게 세 개의 계층으로 나뉩니다.

시스템 아키텍처

전체 아키텍처

전체 아키텍처

핵심 구성 요소

시스템은 네 가지 핵심 컴포넌트로 구성됩니다. 가장 중심에는 CsfleService가 있습니다. 모든 암호화와 복호화를 담당하는 핵심 엔진입니다. AWS KMS와 통신하면서 실제 암호화 작업을 수행합니다.

실제 지원서의 개인정보를 처리하는 것은 SubmissionAnswerPersonalInfoQuestionService의 역할입니다. 이 서비스가 CsfleService를 활용하여 이름, 전화번호, 이메일 같은 필드들을 암호화하고, 화면에는 마스킹된 값(예: 홍**동)을 보여주도록 처리합니다.

비슷한 역할을 하는 AnonymousApplicantService는 익명 지원자 정보를 암호화하여 관리합니다. 같은 사람이 여러 공간에 지원하더라도 안전하게 통합 관리할 수 있도록 설계했습니다.

마지막으로 MongoCsfleModule이 이 모든 것을 NestJS 애플리케이션과 통합하는 역할을 합니다. 모듈 초기화 시점에 암호화 연결을 검증하고, 서비스들을 앱 전체에서 사용할 수 있도록 구성합니다.

암호화 대상 데이터

암호화 대상을 정의하는 것이 첫 번째 과제였습니다. 검토 결과 크게 두 가지 영역으로 분류했습니다.

첫 번째는 지원서의 개인정보 질문 타입입니다. 이름, 전화번호, 이메일과 같은 필수 개인정보 문항들이 여기에 해당합니다. 개인정보 보호법에 따라 이러한 정보들은 반드시 안전하게 암호화하여 저장해야 합니다.

두 번째는 익명 지원자 정보입니다. 한 지원자가 여러 공간에 지원하는 경우 동일 인물임을 식별해야 합니다. 이 정보 역시 개인정보이므로 암호화가 필요합니다. submission builder에서 익명 지원자를 생성할 때부터 암호화된 상태로 저장되도록 구현했습니다.

암호화된 데이터의 형태

암호화된 데이터가 실제로 어떤 형태로 저장되는지 살펴보겠습니다. MongoDB에는 Binary 객체 형태로 저장됩니다.


encryptedValue type: object
encryptedValue instanceof Binary: true
encryptedValue buffer length: 1001
encryptedValue buffer (first 50 bytes): [
    2,   5, 120, 219, 249,  82,  88, 160,  29, 197,
  133, 174, 130, 123, 114, 244, 202,  62, 170, 196,
   61,  94,  96, 143, 103, 122,  19,  28,  47, 160,
  120,  97,  83, 226,  65,   0, 197,   0,   3,   0,
   21,  97, 119, 115,  45,  99, 114, 121, 112, 116
]

보시다시피 완전히 알아볼 수 없는 바이트 배열입니다. 이 안에는 암호화된 값뿐만 아니라 어떤 키로 암호화했는지, AWS KMS 정보는 무엇인지 같은 메타데이터도 함께 포함되어 있습니다. 복호화 시 이 정보들이 필요하기 때문입니다.

CsfleService - 암호화의 핵심

이제 암호화를 담당하는 CsfleService를 살펴보겠습니다. 이 서비스가 모든 암호화/복호화 작업의 중심입니다.

주요 메서드


// 데이터 키 생성
async createDataKey({ keyAltNames }: { keyAltNames: string[] }): Promise<Binary>

// 값 암호화
async encryptValue({ value, dataKeyId }: { value: string; dataKeyId: Binary })

// 값 복호화
async decryptValue<T>({ encryptedValue }: { encryptedValue: Binary })

// 데이터 키 ID 조회
async getDataKeyId({ keyAltName }: { keyAltName: string })

암호화 프로세스

암호화는 크게 세 단계로 진행됩니다.

초기화 단계에서는 AWS KMS Keyring을 구성하고, 성능 최적화를 위한 Caching Materials Manager를 설정합니다. 그리고 MongoDB ClientEncryption 객체를 초기화하여 실제 암호화 작업을 준비합니다.

암호화 단계에서는 먼저 사용할 데이터 키의 유효성을 검증합니다. 그 다음 암호화 컨텍스트를 생성합니다. 이는 나중에 데이터의 무결성을 검증하는 데 사용됩니다. 마지막으로 AWS 암호화 클라이언트를 통해 실제 암호화를 수행합니다.

복호화 단계는 역순으로 진행됩니다. AWS 복호화 클라이언트가 Binary 데이터를 받아 원본 텍스트로 변환합니다. 이 과정에서도 암호화 컨텍스트를 검증하여 데이터가 변조되지 않았음을 확인합니다.

AWS 암호화 계층

전체 아키텍처

전체 아키텍처

Caching Materials Manager를 통해 KMS 호출을 크게 줄일 수 있었습니다. 캐시는 최대 1시간 동안 유지되며, 크기는 1GB, 메시지는 1000개까지 저장할 수 있습니다. 덕분에 동일한 키를 사용하는 암호화 작업에서는 캐시된 키를 재사용하여 응답 속도를 크게 개선할 수 있었습니다.

SubmissionAnswerPersonalInfoQuestionService

이 서비스는 실제 지원서의 개인정보를 처리합니다. 암호화와 마스킹 처리를 모두 담당합니다.

개인정보 질문 타입 식별


private readonly personalInfoQuestionTypeList = [
    QuestionType.NAME,
    QuestionType.EMAIL,
    QuestionType.PHONE,
];

마스킹 전략

사용자 인터페이스에서 암호화된 원본 데이터를 보여줄 수는 없지만, 아무 정보도 표시하지 않으면 불편합니다. 그래서 마스킹 처리를 도입했습니다. 화면에는 일부만 가린 값을 보여주는 방식입니다.

이름 마스킹

undefined

전화번호 마스킹

undefined

이메일 마스킹

undefined

이렇게 하니까 일반 조회에서는 개인정보가 완전히 노출되지 않으면서도, "아, 이 사람 홍길동인가보네" 정도는 파악할 수 있으니 적당한 수준이었습니다.

AnonymousApplicantService

익명 지원자 정보도 비슷하게 관리합니다.

핵심 메서드


// 익명 지원자 생성 또는 업데이트
async createOrUpdateAnonymousApplicant({
    plainName, plainPhone, plainEmail, spaceId
})

// 개인정보 암호화
async encryptedAnonymousApplicant({ name, phone, email })

// 데이터 키 조회 또는 생성
private async getOrCreateAnonymousApplicantDataKey({ keyAltName })

암호화 프로세스

익명 지원자는 전용 데이터 키로 암호화합니다. 이름, 전화번호, 이메일마다 각자의 키를 사용하며, keyAltName으로 데이터 키를 검색하고 없으면 새로 생성합니다. 세 개 필드를 한꺼번에 처리하여 성능을 최적화했습니다.

여기서 주목할 점은 지원자를 조회할 때 이미 암호화된 값으로 검색한다는 것입니다. "홍길동"을 암호화한 Binary 값과 "010-1234-5678"을 암호화한 Binary 값으로 검색합니다. 그래서 같은 사람이 여러 번 지원해도 동일 인물임을 식별할 수 있습니다.

전체 아키텍처

전체 아키텍처

데이터 흐름

실제로 데이터가 어떻게 흐르는지는 두 가지 시나리오로 파악할 수 있습니다.

지원서 작성 시나리오

전체 아키텍처

전체 아키텍처

지원자가 "홍길동"이라고 이름을 입력하면, 서버는 이를 받아서 암호화합니다. 동시에 "홍**동"이라는 마스킹된 값도 생성하여 함께 저장합니다. 일반 조회 시에는 마스킹된 값을 보여주고, 정말 필요한 경우에만 원본을 복호화합니다.

지원 내역 조회 시나리오

전체 아키텍처

전체 아키텍처

지원 내역을 조회하면 데이터베이스에서 암호화된 데이터를 가져오지만, 클라이언트에는 마스킹된 값만 반환합니다. "홍**동", "010-****-5678" 같은 형태로 표시하여 개인정보를 최소한으로 노출합니다.

RevealLoggerInterceptor - 접근 감사

개인정보를 복호화할 때 누가 언제 접근했는지 기록해야 한다는 판단에 RevealLoggerInterceptor를 구현했습니다.

주요 기능

이 인터셉터는 마스킹 해제 요청이 들어올 때마다 자동으로 로그를 기록합니다. 누가 언제 어떤 데이터를 조회했는지, IP 주소와 User Agent는 무엇인지 등의 정보를 상세하게 남겨 나중에 보안 사고 발생 시 추적할 수 있도록 합니다.

사용 예시


@Get(&#x27;/:id/reveal&#x27;)
@UseRoleGuards(GemUserRole.MANAGER)
@UseRevealLoggerInterceptor({ resource: &#x27;params&#x27;, key: &#x27;id&#x27; })
@ApiDocs({
    summary: &#x27;지원서 답변 마스킹 해제&#x27;,
    response: {
        statusCode: 200,
        schema: ResponseGetDecryptedAnswerDto,
    },
})
@SerializeOptions({ type: ResponseGetDecryptedAnswerDto })
async revealSubmissionAnswer(@Param(&#x27;id&#x27;) id: string) {
    return await this.submissionAnswerService.getDecryptedSubmissionAnswer({
        id,
    });
}

로깅 정보


this.logger.info({
    action: &#x27;reveal_submission_answer&#x27;,
    url: request.url,
    method: request.method,
    ip: this.getClientIp({ request }),
    userAgent: request.headers.get(&#x27;user-agent&#x27;),
    referer: request.headers.get(&#x27;referer&#x27;),
    referrerSource: request.headers.get(&#x27;referrer-source&#x27;),
    referrerMedium: request.headers.get(&#x27;referrer-medium&#x27;),
    referrerCampaign: request.headers.get(&#x27;referrer-campaign&#x27;),
    referrerContent: request.headers.get(&#x27;referrer-content&#x27;),
    visitedUrl: request.headers.get(&#x27;page-url&#x27;),
    [key]: value,
    userId: user?.id,
    userName: user?.name,
    userEmail: user?.email,
    timestamp: new Date().toISOString(),
});

동작 흐름

전체 아키텍처

전체 아키텍처

MANAGER 권한을 가진 사용자만 복호화할 수 있으며, 모든 접근이 로그로 기록됩니다. 보안 감사 시 매우 유용합니다.

MongoCsfleModule 구성

마지막으로 NestJS와의 통합 방법을 살펴보겠습니다.

설정 구성

MongoCsfleModule은 암호화 전용 연결을 별도로 관리합니다. 일반 데이터베이스 연결과 분리한 구조입니다. 환경변수로 AWS KMS CMK ARN과 MongoDB 연결 정보를 주입받으며, 모듈 초기화 시점에 설정의 유효성을 검증합니다.

초기에는 이 검증 과정 없이 운영 환경에 배포했다가 암호화가 작동하지 않는 문제를 겪었습니다. 이후 초기화 시점에 반드시 검증하도록 개선하여, 설정 오류를 개발 단계에서 발견할 수 있게 되었습니다.

고려 사항

구현 과정에서 고민했던 여러 사항들이 있었습니다. 한번 정리해보려고 합니다.

예외 처리

암호화 시스템은 한 번 잘못되면 복구가 매우 어렵습니다. 그래서 초기화 단계에서 문제가 발생하면 즉시 애플리케이션을 중단하도록 했습니다. 잘못된 상태로 조용히 실행되는 것보다 명확하게 실패하는 것이 낫다는 판단이었습니다.

각 서비스 계층에서도 입력 유효성 검증을 철저히 수행합니다. 암호화 과정 중간에 오류가 발생하면 디버깅이 매우 어렵기 때문에, 사전에 잘못된 값이나 타입을 걸러내도록 했습니다.

보안

보안은 여러 계층으로 구성했습니다. 단일 지점에 의존하는 구조는 위험하기 때문입니다.

마스터 키는 AWS KMS가 관리합니다. 애플리케이션 코드나 데이터베이스 어디에도 존재하지 않습니다. 코드가 유출되는 상황에서도 마스터 키는 안전합니다. Encryption Context를 통해 데이터의 무결성도 검증합니다.

DEK(Data Encryption Key)는 MongoDB에 저장합니다. 매번 AWS KMS를 호출하면 성능과 비용 문제가 발생하기 때문입니다. DEK 자체는 마스터 키로 암호화되어 있어 안전합니다.

접근 제어도 엄격하게 적용했습니다. MANAGER 권한을 가진 사용자만 복호화할 수 있으며, 모든 복호화 요청은 로그로 기록되어 나중에 추적이 가능합니다.

성능

암호화로 인한 성능 저하가 가장 큰 우려사항이었습니다. 실제로 초기 구현에서는 지원서 저장에 2초가 소요되어 최적화가 필요했습니다.

가장 큰 효과를 본 것은 캐싱입니다. NodeCachingMaterialsManager를 사용하면 동일한 키를 재사용할 수 있습니다. 한 번 가져온 키는 최대 1시간 동안 캐시에 유지되므로, AWS KMS를 매번 호출할 필요가 없습니다. 이것만으로도 응답 시간이 절반 이하로 감소했습니다.

단일 키링을 사용하여 네트워크 오버헤드를 줄였고, 여러 필드를 암호화할 때는 Promise.all로 병렬 처리했습니다. 덕분에 3개 필드 암호화 시간이 3초에서 1초로 단축되었습니다.

확장성

향후 확장을 고려하여 유연한 구조로 설계했습니다.

새로운 개인정보 타입을 추가하는 것은 간단합니다. SubmissionAnswerPersonalInfoQuestionService에 해당 타입의 마스킹 로직만 추가하면 됩니다. 예를 들어 주소를 추가한다면 "서울특별시 **구 **동" 형식의 마스킹 함수를 구현하면 됩니다.

CsfleService는 독립적인 모듈로 구성하여 다른 제품에서도 재사용할 수 있도록 했습니다. 필요하다면 공통 라이브러리로 추출하여 여러 프로젝트에서 공유할 수도 있습니다.

설정 관리

운영 환경 배포 시 설정 오류를 방지하기 위해 엄격한 검증을 적용했습니다.

모든 설정은 환경변수로 주입받으며, NestJS ConfigService로 유효성을 검증합니다. 필수 값이 누락되거나 형식이 올바르지 않으면 애플리케이션이 시작되지 않습니다. Fail Fast 전략을 통해 배포 전에 설정 오류를 발견할 수 있습니다.

모니터링

비용 관리와 성능 최적화를 위해 상세한 모니터링 시스템을 구축했습니다.

APM 도구로 암호화/복호화 오류를 실시간으로 추적합니다. 특히 KMS 호출 빈도와 소요 시간을 지속적으로 모니터링하여, 비정상적인 패턴이 발견되면 캐싱 전략을 조정하거나 로직을 개선합니다.

KMS API는 호출당 과금되므로 CloudWatch로 호출 횟수를 모니터링합니다. 캐싱 효율성을 측정하고 불필요한 암호화 작업을 제거하여 비용을 최적화합니다. 최적화 결과 KMS 비용을 70% 가량 절감할 수 있었습니다.

적용 결과

적용 후 화면

적용 후 화면

대시보드에 실제 적용된 마스킹

데이터베이스 단계에서 암호화를 적용하여 보안성을 확보했을 뿐만 아니라, 실제 대시보드에서도 마스킹된 데이터를 제공하면서 개인정보를 보호할 수 있게 되었습니다.

이는 사용자 경험과 비즈니스 측면에서도 새로운 가치를 제공하는 결과로 이어졌습니다.

위 화면에서 "개인정보 보기" 버튼을 클릭하면 마스킹 해제 API를 통해 원본 데이터가 표시됩니다. 이때 모든 API 호출은 로그로 기록되어 누가, 언제, 어떤 데이터를 조회했는지 추적할 수 있습니다. 이러한 로그는 보안 사고 발생 시 증빙 자료로 활용됩니다.

마치며

MongoDB CSFLE과 AWS KMS를 활용한 개인정보 암호화 시스템 구축 과정을 공유해보았습니다.

이 과정에서 몇 가지 중요한 설계 결정이 있었습니다. 첫째는 클라이언트 사이드 암호화 방식을 선택한 것입니다. 애플리케이션 서버에서 암호화를 수행하므로 MongoDB에는 이미 암호화된 데이터만 저장됩니다. 데이터베이스가 유출되더라도 복호화할 수 없다는 점에서 안전합니다.

둘째는 이중 보호 전략입니다. 일반 조회에서는 마스킹된 값만 노출하고, 정말 필요한 경우에만 MANAGER 권한으로 복호화할 수 있도록 했습니다. 내부 직원의 실수나 악의적인 접근으로부터 데이터를 보호할 수 있습니다.

셋째는 모든 복호화 요청에 대한 로깅입니다. 누가, 언제, 어떤 데이터에 접근했는지 기록함으로써 보안 사고 발생 시 추적이 가능하도록 했습니다.

넷째는 성능 최적화입니다. Caching Materials Manager를 통해 KMS 호출을 최소화하고, 병렬 처리로 응답 시간을 단축했습니다. 덕분에 AWS 비용도 크게 절감할 수 있었습니다.

앞으로는 더 많은 개인정보 타입을 지원하고, 주기적인 암호화 키 로테이션, 더 세밀한 접근 제어 등을 추가할 계획입니다.

간과하고 개발하고 있었지만, 개인정보 보호는 선택이 아닌 필수였습니다. 최근 보안 유출 이슈들로 조명을 받았을 뿐, 늘 중요했던 것이었습니다. 이 과정을 통해 조금이라도 보안적으로 안정적인 서비스를 만드는 경험을 할 수 있었습니다.

댓글

?