MongoDB에서 개인정보를 가린다고 해보자.

정책 경로는 $.profile이다. 조회 결과에는 이메일과 나이가 들어 있다.

가장 먼저 떠오르는 결과는 아마 이것일 거다.

{
  profile: "<strong>*"
}

깔끔하다. 아주 강력하게 가렸다.

그런데 애플리케이션은 곧바로 당황한다.

어제까지 profile.email을 읽던 코드가 오늘은 문자열 속에서 email을 찾고 있다. 상자를 가리려다가 상자 모양까지 없애 버린 셈이다.

MongoDB 마스킹에서 중요한 질문은 “별표를 몇 개 붙일까?”가 아니다.

어떤 구조는 남기고, 어느 값까지 내려가서 가릴 것인가?

이 질문에 답하려면 먼저 MongoDB 문서를 파일 한 장이 아니라 나무 한 그루처럼 볼 줄 알아야 한다.

MongoDB는 표보다 서류함에 가깝다

관계형 데이터베이스에 익숙하면 보통 이렇게 생각한다.

Database
└─ Table
   └─ Row
      └─ Column

MongoDB의 논리 구조는 비슷해 보이지만 조금 다르다.

Database
└─ Collection
   └─ Document
      └─ Field

예를 들어 shop 데이터베이스 안에 customers 컬렉션이 있고, 그 안에 고객 문서가 들어 있다고 해보자.

{
  _id: ObjectId("64f000000000000000000001"),
  name: "Kim",
  profile: {
    email: "kim@example.com",
    age: 34
  },
  orders: [
    {
      orderNo: "O-001",
      amount: NumberDecimal("39000"),
      paid: true
    },
    {
      orderNo: "O-002",
      amount: NumberDecimal("12500"),
      paid: false
    }
  ],
  createdAt: ISODate("2026-08-04T08:00:00Z")
}

겉보기에는 JSON이다. 하지만 MongoDB가 실제로 다루는 형식은 BSON이다.

BSON에는 문자열과 숫자뿐 아니라 ObjectId, 날짜, Decimal128, Binary 같은 타입도 들어갈 수 있다. 그리고 무엇보다 한 필드 안에 또 다른 객체나 배열을 넣을 수 있다.

그래서 MongoDB 문서는 납작한 행이라기보다 작은 서류함이다.

Document root: $
├─ _id                 → ObjectId 값
├─ name                → String 값
├─ profile             → Object
│  ├─ email            → String 값
│  └─ age              → Int32 값
├─ orders              → Array
│  ├─ [0]              → Object
│  │  ├─ orderNo       → String 값
│  │  ├─ amount        → Decimal 값
│  │  └─ paid          → Boolean 값
│  └─ [1]              → Object
└─ createdAt           → Date 값

여기서 profileorders는 실제 값이라기보다 다음 칸으로 들어가는 문이다.

진짜 마스킹 대상은 그 아래의 문자열, 숫자, Boolean, 날짜 같은 leaf 값이다.

Object와 Array는 포장 상자다

MongoDB 문서를 트리로 보면 node는 크게 둘로 나뉜다.

1. Container node

하위 값을 담는 중간 구조다.

  • Object
  • Array

2. Leaf node

더 내려갈 곳이 없는 실제 값이다.

  • String
  • Number
  • Boolean
  • Date
  • ObjectId
  • Null
  • Binary 등

택배에 비유하면 Object와 Array는 상자이고 leaf는 상자 안의 물건이다.

마스킹이 상자 자체를 검은 비닐로 바꿔 버리면, 받는 쪽에서는 안에 물건이 몇 개였는지조차 알 수 없다. 반대로 상자의 칸과 라벨은 유지하고 내용물만 흐리면 애플리케이션이 기대하던 구조를 계속 사용할 수 있다.

그래서 구조를 보존하는 마스킹은 보통 이런 순서로 움직인다.

1. Path로 대상 node를 찾는다
2. Scalar면 바로 마스킹한다
3. Object면 각 field를 재귀적으로 방문한다
4. Array면 각 element를 재귀적으로 방문한다
5. leaf 값마다 탐지 패턴과 치환 패턴을 적용한다

핵심은 3번과 4번이다.

Object와 Array를 만났다고 작업이 끝나는 게 아니다. 그때부터 안쪽 여행이 시작된다.

$.profile을 선택하면 무슨 일이 벌어질까?

원본 문서가 이렇다고 해보자.

{
  name: "Kim",
  profile: {
    email: "kim@example.com",
    age: 34
  }
}

마스킹 경로로 $.profile을 선택했다.

구조를 없애는 방식이라면 결과는 이렇다.

{
  name: "Kim",
  profile: "</strong>*"
}

하지만 재귀적으로 leaf를 처리하면 이렇게 된다.

{
  name: "Kim",
  profile: {
    email: "<strong>*",
    age: "</strong>*"
  }
}

profile이라는 key와 Object 구조는 그대로다. 그 아래의 leaf 값만 바뀐다.

이 차이는 화면 한 번 보는 데는 작아 보여도, API와 애플리케이션에는 크다.

result.profile.email

첫 번째 결과에서는 이 접근이 깨진다. 두 번째 결과에서는 구조가 유지되므로 계속 동작한다.

마스킹은 데이터를 숨기면서도 데이터 계약을 가능한 한 덜 흔들어야 한다.

Array는 복도가 하나 더 긴 Object다

Array가 들어오면 이야기가 조금 더 재미있어진다.

{
  orders: [
    { orderNo: "O-001", amount: 39000, paid: true },
    { orderNo: "O-002", amount: 12500, paid: false }
  ]
}

$.orders 전체를 마스킹 대상으로 잡으면 Array 자체를 문자열로 바꾸는 대신, 각 element 안으로 들어가 leaf를 처리할 수 있다.

{
  orders: [
    { orderNo: "<strong>*", amount: "</strong>*", paid: "<strong>*" },
    { orderNo: "</strong>*", amount: "<strong>*", paid: "</strong>*" }
  ]
}

배열 길이와 객체 구조는 남는다.

경로를 더 좁게 선택할 수도 있다.

Path 선택되는 영역
$.orders orders 배열 전체
$.orders[0] 첫 번째 주문 객체
$.orders[0].amount 첫 번째 주문의 amount 값
$.orders[*] 배열의 모든 element
$..orderNo 깊이에 상관없이 이름이 orderNo인 field

여기서 중요한 운영 원칙이 하나 나온다.

Path는 단순한 주소가 아니라 마스킹 범위의 경계선이다.

너무 넓게 잡으면 업무에 필요한 값까지 사라진다. 너무 좁게 잡으면 새로 추가된 배열 element나 중첩 field가 보호 밖에 남을 수 있다.

Path가 맞아도 값이 그대로일 수 있다

마스킹이 적용되지 않았다고 해서 곧바로 “경로가 틀렸다”고 결론 내리면 안 된다.

많은 마스킹 엔진은 두 단계를 거친다.

Path 탐색
  ↓
탐지 패턴과 값 비교
  ↓
일치한 부분을 마스킹 패턴으로 치환

예를 들어 이메일 형태만 찾는 탐지 패턴을 설정했다고 해보자.

detecting pattern: 이메일 형식
masking pattern:   <strong>*

kim@example.com은 바뀐다.

하지만 34, true, ObjectId(...)는 이메일 패턴과 일치하지 않는다. Path로 leaf까지 도착했어도 원문이 유지될 수 있다.

즉 마스킹 결과를 볼 때는 세 질문을 따로 해야 한다.

  1. Path가 실제 node를 찾았는가?
  2. 선택된 node 아래의 leaf까지 내려갔는가?
  3. 탐지 패턴이 leaf의 문자열 표현과 일치했는가?

이 셋을 한 문장으로 뭉개면 디버깅이 길어진다.

타입이 문자열로 바뀌는 순간도 살펴야 한다

마스킹은 시각적으로는 별표 몇 개지만, 데이터 타입 관점에서는 꽤 큰 변화일 수 있다.

34        → "</strong>*"
true      → "<strong>*"
39000     → "</strong>*"
ISODate() → "<strong>*"

숫자나 Boolean, 날짜가 마스킹 후 문자열이 되면 다음과 같은 코드가 영향을 받을 수 있다.

  • 숫자 합계 계산
  • 날짜 정렬
  • Boolean 조건 검사
  • 타입 기반 화면 렌더링

그래서 “구조를 보존했다”와 “타입까지 보존했다”는 같은 말이 아니다.

마스킹 정책을 설계할 때는 최소한 다음을 확인해야 한다.

확인 항목 질문
구조 Object와 Array 모양이 유지되는가?
타입 숫자·날짜·Boolean이 문자열로 바뀌는가?
범위 선택한 Path 아래 어디까지 처리되는가?
패턴 어떤 leaf 값이 탐지 패턴과 일치하는가?
소비자 API와 화면이 마스킹된 타입을 처리할 수 있는가?

같은 Collection의 문서도 모양이 다를 수 있다

MongoDB의 장점 중 하나는 유연한 스키마다.

같은 컬렉션에 이런 문서들이 함께 있을 수 있다.

{ name: "Kim", profile: { email: "kim@example.com" } }
{ name: "Lee", profile: null }
{ name: "Park", contacts: [{ type: "email", value: "park@example.com" }] }

첫 번째 문서에는 $.profile.email이 있다.

두 번째 문서에는 profile이 있지만 Object가 아니다.

세 번째 문서에는 profile 자체가 없다.

따라서 샘플 문서 한 건에서 잘 동작했다고 전체 컬렉션이 안전하다고 단정할 수 없다.

좋은 마스킹 테스트는 최소한 아래 경우를 포함한다.

[ ] Scalar field
[ ] 중첩 Object
[ ] Array of scalar
[ ] Array of object
[ ] Path가 없는 문서
[ ] 값이 null인 문서
[ ] 탐지 패턴과 일치하지 않는 값
[ ] ObjectId, Date, Decimal 같은 BSON 타입

이 체크리스트는 화려하지 않다. 대신 운영에서 자주 마주치는 “왜 어떤 문서만 안 가려졌지?”라는 질문을 크게 줄여준다.

구조까지 숨겨야 한다면 마스킹 문제가 아닐 수 있다

여기서 경계를 하나 분명히 해야 한다.

마스킹은 일반적으로 값의 노출을 줄이는 기능**이다.

Object의 key, 배열 길이, 문서 구조까지 완전히 숨겨야 한다면 요구사항은 달라진다.

값은 보이되 일부만 가린다     → Data Masking
필드 자체를 조회하지 못하게 한다 → Field/Document Access Control
문서 전체를 반환하지 않는다      → Data Access Control

열쇠가 필요한 창고를 커튼으로 가리는 것만으로는 충분하지 않다.

구조 자체가 민감하다면 “어떻게 보여줄까?”보다 “조회 자체를 허용할까?”를 먼저 물어야 한다.

별표보다 중요한 것

MongoDB 마스킹은 문자열 치환 문제가 아니다.

문서의 모양, 경로, 재귀 탐색, BSON 타입, 탐지 패턴, 애플리케이션 계약이 한꺼번에 만나는 문제다.

그래서 좋은 구현과 좋은 테스트는 같은 질문에서 출발한다.

이 값은 문서 트리의 어디에 있고, 그곳까지 어떤 구조를 보존하며 내려갈 것인가?

Object와 Array는 없애야 할 장애물이 아니다. 안쪽 leaf로 가는 지도다.

별표를 많이 찍는다고 보안이 좋아지는 건 아니다.

정확한 곳까지 내려가 필요한 값만 흐리고, 구조가 필요한 소비자에게는 약속한 모양을 돌려주는 것.

그게 MongoDB 문서를 망가뜨리지 않으면서 민감정보를 가리는 방법이다.