IT/작업기록

[DB] 개발 DB 민감정보 마스킹하기 — 어디까지, 어떻게

aram 2026. 8. 19. 14:22

배경

개발 DB는 운영 DB를 그대로 복사해서 쓰고 있었다. 실제 데이터로 테스트하는 것 자체는 합리적인데, 문제는 화면을 캡처해서 "이거 제대로 된 게 맞나?" 하고 확인 요청하기가 어려웠다는 것 — 캡처 안에 실제 고객사명, 담당자 연락처 같은 진짜 민감정보가 그대로 찍히기 때문이다.

완전 랜덤 재생성 vs 마스킹

민감정보 이슈를 해결하는 방법으로 두 가지를 생각해볼 수 있었다.

  • 완전 랜덤 재생성: 데이터를 다 지우고 처음부터 가짜 데이터로 채우기
  • 마스킹(anonymization): 구조·관계·볼륨은 그대로 두고, 민감한 컬럼값만 가짜 값으로 덮어쓰기

완전 랜덤 재생성은 FK 관계, 트리거가 참조하는 연관 데이터, 실제 엣지케이스(긴 값, 특수문자 등)를 처음부터 다시 다 맞춰줘야 해서 손이 훨씬 많이 가고, 실데이터의 특성이 필요한 버그 테스트 작업에는 오히려 방해가 된다. 그래서 마스킹을 택했다 — 프로덕션에서 복사해온 구조는 그대로 두고, 이름/연락처/이메일/주소 같은 컬럼만 스크립트로 가짜 값으로 덮어쓰는 방식.

트리거가 있는 테이블에서 마스킹할 때 주의할 점

레거시 테이블 여러 개에 AFTER UPDATE 트리거가 걸려 있어서, INSERT/UPDATE가 있을 때마다 변경 이력을 JSON으로 별도의 *_history 테이블에 자동으로 기록하고 있었다. 마스킹 스크립트도 결국 UPDATE 문이라, 실행하면 이 트리거가 그대로 발동한다.

  • 앞으로 쌓이는 이력: 마스킹된 값으로 기록되니 문제 없음
  • 이미 쌓여있던 과거 이력: old_data/new_data JSON 안에는 마스킹 이전의 원본 민감정보가 그대로 남아있음

그래서 원본 테이블을 마스킹하는 스크립트와, 이미 쌓인 이력 테이블의 JSON을 스크러빙하는 스크립트를 따로 만들어야 했다. 이력 기록 방식도 두 종류였다:

  • DB 트리거 기반: JSON 컬럼(old_data/new_data)에 스냅샷 저장 — JSON_SET()으로 특정 키만 덮어쓰기
  • 애플리케이션 코드 기반: 트리거가 아니라 뷰 코드가 직접 old_value/new_value 평문 컬럼에 기록하는 케이스도 있었음 — 트리거가 없다고 안심할 게 아니라, "이력에 값이 남는가"를 기준으로 따로 확인해야 했다

마스킹하다가 우연히 발견한 기존 버그

마스킹을 실행하다가 특정 테이블에서 Data too long for column 에러가 났다. 알고 보니, 예전에 어떤 컬럼의 길이를 늘리는 마이그레이션을 했을 때 이력 테이블의 대응 컬럼은 같이 늘리지 않았던 것. 그런데 운영 DB에서는 한 번 에러가 나서 그때 수동으로 컬럼을 넓혀놨었고, 그 수동 변경이 마이그레이션 파일에는 반영되지 않아서, 그 시점 이전에 뜬 개발 DB(운영 복사본)에는 반영되지 않은 상태였다.

즉 "운영에서만 몰래 고쳐놓고 마이그레이션에는 안 남긴" 스키마 드리프트가, 이번에 개발 DB를 다시 마스킹하다가 우연히 드러난 것. 트리거에 예외 처리(EXIT HANDLER FOR SQLEXCEPTION)가 걸려있어서 원본 테이블 UPDATE 자체는 성공하고 이력만 조용히 유실되는 구조였던 게 더 위험했다 — 에러가 눈에 띄지 않았다면 계속 몰랐을 것.

교훈: 운영에서 급하게 수동으로 스키마를 고쳐야 하는 상황이 생기더라도, 그걸 마이그레이션 파일로도 반드시 남겨야 한다. 그렇지 않으면 "운영 스키마 = 마이그레이션이 만드는 스키마"라는 전제가 깨지고, 나중에 그 전제를 믿고 만든 개발 환경에서 예상 못한 곳이 터진다.

어디까지 마스킹해야 하나 — 판단 기준

포트폴리오용으로 화면을 녹화해도 되는지까지 고민하면서, "이게 진짜 민감한 건가"를 하나씩 따져봤다.

  • 회사명/주소/대표자명/직인 이미지: 마스킹 필요 — 특히 도장(직인) 이미지는 실제 문서에 쓰이는 것이라 노출 위험이 더 큼
  • 취급하는 SW 브랜드명: 마스킹 불필요 — 이미 공식 홈페이지에 벤더로 공개되어 있는 정보라 노출돼도 문제 없음
  • 라이선스 시작일/만료일: 마스킹 불필요 — 고객 식별 정보와 연결되지 않는 순수 날짜값이라 그 자체로는 민감하지 않음 (연결된 고객 정보 쪽이 이미 마스킹되어 있다는 전제)
  • 폴더 경로: 마스킹 필요 — 아래에서 별도로 다룸

이렇게 컬럼 하나하나가 "정말 민감한가"와 "이미 공개된 정보인가"를 기준으로 판단하니, 처음 걱정했던 것보다 실제로 손대야 할 범위는 좁아졌다.

특이 케이스 — 폴더 경로는 SQL로 마스킹하면 안 된다

한 테이블은 그냥 UPDATE로 마스킹할 수 없는 특이 케이스였다. 이 테이블의 경로 컬럼은 화면에 보여주기만 하는 텍스트가 아니라, 실제로 파일시스템에서 폴더를 찾고 생성/이동/삭제하는 데 쓰이는 진짜 경로였다. SQL로 이 값만 가짜 문자열로 바꿔버리면:

  • DB에 적힌 경로와 실제로 존재하는 폴더 이름이 어긋나버려서
  • 폴더 관련 기능(생성, 이동, 열기)을 테스트할 때 존재하지 않는 경로를 찾다가 그대로 깨진다

그래서 이 부분만 SQL이 아니라 Django management command로 처리했다. 커맨드가 하는 일:

  1. DB의 각 폴더 매핑 레코드를 순회
  2. 그 레코드가 가리키는 실제 디렉터리를 찾아서, 가짜 이름으로 실제로 rename
  3. DB의 경로 컬럼도 그 새 이름에 맞춰 같이 갱신

이렇게 하면 "DB에 적힌 경로 = 실제로 존재하는 폴더"라는 일관성이 항상 유지된다. 추가로, 실수로 운영 컨테이너에서 이 커맨드를 돌리는 사고를 막기 위해, 설정값이 운영 경로와 일치하면 즉시 실행을 중단하는 안전장치도 넣었다.

PRODUCTION_NAS_ROOT = "/nas_shared/customer_files"

def handle(self, *args, **options):
    nas_root = getattr(settings, "NAS_SERVER_BASE", "")
    if nas_root == PRODUCTION_NAS_ROOT:
        raise CommandError(
            f"NAS_SERVER_BASE가 운영 경로({PRODUCTION_NAS_ROOT})입니다. "
            "개발 환경에서만 실행하세요."
        )
    ...

정리 — 마스킹 방식은 데이터의 성격에 따라 갈린다

  • 순수 표시용 데이터(이름, 연락처, 코멘트 등): SQL UPDATE로 값만 덮어쓰면 충분
  • 트리거가 이력을 남기는 테이블: 원본 마스킹 + 과거 이력 JSON/평문 컬럼 스크러빙, 두 단계가 필요
  • 다른 시스템(파일시스템 등)과 실제로 연결된 데이터: 단순히 DB 값만 바꾸면 정합성이 깨진다 — 그 데이터가 가리키는 실제 대상까지 함께 바꿔야 한다

같은 "민감정보 마스킹"이라도, 그 컬럼이 순수하게 보여주기 위한 값인지 아니면 실제로 뭔가를 가리키는(참조하는) 값인지에 따라 접근 방식이 완전히 달라진다는 걸 이번에 제대로 배웠다.