개인정보 마스킹 체크리스트: 화면·API·로그·엑셀 12곳
개인정보 마스킹을 화면에서만 하면 API 응답·로그·Sentry·엑셀에는 원문이 남습니다. 이름·전화번호·이메일·주소 규칙과 데이터 경로 12곳 점검표를 코드 예시와 함께 정리했습니다.
개인정보 마스킹은 서버가 응답을 만들 때 해야 합니다. 화면에서 010-****-5678로 보여도 브라우저 개발자 도구의 네트워크 탭에 010-1234-5678이 보인다면 마스킹을 안 한 것과 같습니다. 이름은 홍*동, 전화번호는 가운데 4자리, 이메일은 @ 앞부분 일부, 주소는 상세주소를 가리는 방식이 흔히 쓰입니다. 그리고 같은 규칙을 API 응답, 로그, 에러 트래킹, 알림 발송 기록, CS 도구, 엑셀 다운로드까지 똑같이 적용해야 합니다.
점검을 해보면 가장 많이 나오는 패턴이 '가짜 마스킹'입니다. 프론트엔드 컴포넌트에서만 문자열을 가리고 API는 원문을 내려주는 경우, 또는 화면과 응답은 가렸는데 요청·응답 본문을 통째로 남기는 로그나 Sentry 이벤트에 원문이 쌓이는 경우입니다. 이 글은 데이터가 흘러가는 경로를 따라 12곳을 점검표로 정리합니다.
마스킹을 어느 수준까지 할지는 서비스 성격과 내부 정책에 따라 달라집니다. 개인정보보호위원회가 2024년 10월 공개한 「개인정보의 안전성 확보조치 기준 안내서」에서 출력·표시 관련 보호조치를 함께 확인하고, 구현 항목 전체는 개인정보 안전성 확보조치 기준, 개발자가 구현할 항목을 참고하세요.
가짜 마스킹: 화면만 가리고 응답에는 원문이 남는 구조
가장 흔한 형태는 이렇습니다.
// 취약: 서버는 원문을 주고, 프론트에서만 가린다
// GET /api/orders/123 → { "receiverPhone": "010-1234-5678", ... }
function OrderDetail({ order }: { order: Order }) {
const masked = order.receiverPhone.replace(/(\d{3})-(\d{4})-(\d{4})/, "$1-****-$3");
return <span>{masked}</span>;
}
화면은 정상처럼 보이지만 응답 JSON, React DevTools, 상태 관리 스토어, 브라우저 캐시에는 원문이 그대로 남습니다. 모바일 앱도 마찬가지로, 앱 코드를 고쳐도 API 응답은 바뀌지 않습니다.
고친 구조는 서버가 용도별 응답 DTO를 따로 두고, 마스킹한 값만 직렬화하는 방식입니다.
// 수정: 엔티티를 그대로 반환하지 않고, 마스킹된 DTO만 내려준다
public record OrderSummaryResponse(
Long orderId,
String receiverName, // "홍*동"
String receiverPhone // "010-****-5678"
) {
public static OrderSummaryResponse from(Order o) {
return new OrderSummaryResponse(
o.getId(),
Masking.name(o.getReceiverName()),
Masking.phone(o.getReceiverPhone())
);
}
}
핵심은 두 가지입니다. 첫째, 엔티티나 ORM 모델을 그대로 반환하지 않습니다. 컬럼이 하나 추가되면 응답에도 자동으로 따라 나가기 때문입니다. OWASP API Security Top 10 2023의 API3(Broken Object Property Level Authorization)이 바로 이 문제, 즉 객체 속성 단위로 읽기·쓰기 권한을 검증하지 않는 문제를 다룹니다. 쓰기 방향의 같은 결함은 Mass Assignment 취약점에서 다뤘습니다. 둘째, 원문이 필요한 화면(본인 정보 수정, 배송 처리)은 별도 엔드포인트와 권한으로 분리합니다.
항목별 마스킹 규칙과 공통 함수
규칙 자체보다 중요한 건 모든 경로에서 같은 규칙을 쓰는 것입니다. 주문 목록은 가운데 4자리, 배송 조회는 뒤 4자리를 가리는 식으로 화면마다 가리는 위치가 다르면 두 화면을 합쳐 원문을 복원할 수 있습니다.
| 항목 | 원문 | 마스킹 예시 | 비고 |
|---|---|---|---|
| 이름 | 홍길동 / 김철 / 남궁민수 | 홍*동 / 김* / 남**수 | 첫 글자·끝 글자만 노출 |
| 휴대전화 | 010-1234-5678 | 010-****-5678 | 하이픈 없는 입력도 정규화 후 처리 |
| 이메일 | [email protected] | gi*****@example.com | 앞 2자만 노출, 짧은 ID는 전부 가림 |
| 주소 | 서울시 중구 세종대로 110, 101동 1203호 | 서울시 중구 세종대로 110, *** | 상세주소 필드를 통째로 가림 |
규칙은 한 곳에 모아 두고 테스트로 고정합니다.
import re
def mask_name(v: str) -> str:
if not v: return v
if len(v) <= 2: return v[0] + "*"
return v[0] + "*" * (len(v) - 2) + v[-1]
def mask_phone(v: str) -> str:
digits = re.sub(r"\D", "", v or "")
if len(digits) < 10: return "*" * len(digits)
return f"{digits[:3]}-****-{digits[-4:]}"
def mask_email(v: str) -> str:
local, _, domain = (v or "").partition("@")
if not domain: return "***"
keep = local[:2] if len(local) > 3 else ""
return f"{keep}{'*' * max(len(local) - len(keep), 3)}@{domain}"
주의할 점: 마스킹 함수는 입력 형식이 예상과 달라도 원문을 반환하지 않아야 합니다. 정규식이 매칭되지 않으면 원문을 그대로 돌려주는 코드가 흔한데, 하이픈 없는 번호나 국제 번호가 들어오면 그대로 노출됩니다. 실패 시 전부 가리는 쪽으로 처리하세요.
응답 경로 점검 1~4: 화면, API, 목록·검색, 클라이언트 저장소
1. 화면(웹·앱 UI)
- 화면에 보이는 값과 네트워크 탭 응답 값이 같은가 (화면만 가린 곳이 없는가)
- 화면마다 마스킹 위치가 같은가
2. 상세 API 응답 DTO
- 엔티티·모델을 직접 직렬화하는 엔드포인트가 없는가
- 원문 응답 엔드포인트는 본인 또는 권한 있는 역할로만 제한되는가
- GraphQL이라면 필드 단위 권한(resolver 레벨)이 있는가
3. 목록·검색·자동완성 API
- 상세는 가렸는데 목록 API, 관리자 검색, 자동완성에서 원문이 나가지 않는가
- 전화번호 '부분 검색'이 원문 추측 도구가 되지 않는가 (검색 결과 건수 제한, 권한 확인)
4. 클라이언트 저장소와 응답 캐시
- localStorage·sessionStorage, 앱 로컬 DB에 원문을 저장하지 않는가
- 개인정보 응답에
Cache-Control: no-store를 붙였는가 (공용 PC, CDN 캐시 대비)
로그·관측 경로 점검 5~8: 애플리케이션 로그, APM, Sentry, 접근 로그
화면과 API를 고쳐도 원문은 로그로 새어 나갑니다. OWASP 계열 가이드(ASVS, MASWE)는 일관되게 민감 정보는 아예 기록하지 말고, 필요하면 기록 전에 마스킹하라고 권합니다. 실무 기준은 간단합니다. 로그에는 개인정보 대신 user_id 같은 내부 식별자를 남긴다.
5. 애플리케이션 로그
# 취약: 요청 본문 전체를 그대로 기록
logger.info("signup request: %s", request.json())
# 수정: 필요한 식별자만 남기고, 혹시 섞여 들어온 값은 필터에서 한 번 더 가림
logger.info("signup request user_id=%s channel=%s", user.id, body.get("channel"))
class PiiFilter(logging.Filter):
PHONE = re.compile(r"01[016789]-?\d{3,4}-?\d{4}")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
def filter(self, record):
msg = record.getMessage()
msg = self.PHONE.sub("[PHONE]", msg)
record.msg, record.args = self.EMAIL.sub("[EMAIL]", msg), None
return True
정규식 필터는 안전망일 뿐이고 1차 방어는 '애초에 넣지 않기'입니다. 요청·응답 본문을 통째로 남기는 미들웨어, ORM의 SQL 파라미터 바인딩 로그(디버그 레벨)가 운영에서 켜져 있지 않은지 확인하세요.
- 요청·응답 바디 로깅 미들웨어가 운영 환경에서 꺼져 있거나 필드 허용 목록 방식인가
- SQL 바인딩 파라미터 로그가 운영에서 비활성인가
6. APM·분산 트레이싱
- span 속성에 SQL 파라미터, HTTP 바디, 헤더가 수집되지 않는가
- URL 경로·쿼리스트링에 이메일·전화번호가 들어가지 않는가 (
/users/by-email/[email protected]같은 설계는 트랜잭션 이름에 그대로 남음)
7. 에러 트래킹(Sentry 등)
Sentry 문서는 before_send로 SDK 안에서 데이터를 지워 애초에 외부로 나가지 않게 할 것을 권합니다. Python·Node·PHP SDK는 스택 트레이스의 지역 변수 값도 수집할 수 있고, breadcrumb·쿼리스트링·트랜잭션 이름도 개인정보가 섞이는 경로로 명시돼 있습니다.
import sentry_sdk
SENSITIVE_KEYS = {"phone", "email", "name", "address", "receiver_phone"}
def scrub(event, hint):
req = event.get("request", {})
if isinstance(req.get("data"), dict):
for k in req["data"]:
if k.lower() in SENSITIVE_KEYS:
req["data"][k] = "[Filtered]"
req.pop("query_string", None)
return event
sentry_sdk.init(
dsn="https://[email protected]/0",
send_default_pii=False,
before_send=scrub,
)
-
send_default_pii설정 값을 의도적으로 정했는가 - 지역 변수 수집, breadcrumb(
before_breadcrumb)까지 점검했는가 - 서버 측 스크러빙 규칙(대시보드 설정)도 이중으로 걸었는가
8. 게이트웨이·로드밸런서·웹서버 접근 로그
- 개인정보가 GET 쿼리스트링으로 전달되는 API가 없는가 (접근 로그에 원문이 남음)
- 이미 쌓인 로그의 보관 기간과 접근 권한이 정해져 있는가
외부 발송·운영 도구 점검 9~12: 알림톡·이메일, CS 도구, 엑셀, 분석 도구
9. 알림톡·SMS·이메일 템플릿과 발송 기록
- 수신자 본인에게 가는 메시지라도 불필요한 정보(전체 주소, 타인 연락처)를 넣지 않는가
- 발송 대행사 API 호출 로그·재시도 큐에 본문 원문이 쌓이지 않는가
- 이메일 오발송에 대비해 본문 개인정보를 최소화했는가
10. CS 도구·관리자 페이지
- 상담 도구에 고객 정보를 동기화할 때 필요한 필드만 보내는가
- 관리자 페이지 기본 화면은 마스킹, 원문은 '원문 보기' 버튼 + 사유 입력 + 접근 기록으로 분리했는가
- 상담원 역할별로 볼 수 있는 필드가 나뉘어 있는가
11. 엑셀·CSV 다운로드
다운로드는 한 번에 대량이 나가는 경로라 가장 신경 써야 합니다.
- 다운로드 파일도 화면과 같은 마스킹 규칙을 쓰는가 (화면은 가리고 엑셀은 원문인 경우가 흔함)
- 원문 다운로드는 별도 권한, 사유 입력, 다운로드 이력 기록이 있는가
- 생성된 파일이 공개 버킷이나 추측 가능한 URL에 남지 않는가 (만료되는 서명 URL 사용)
- 엑셀 수식 인젝션 대비로
=,+,-,@로 시작하는 셀 값을 이스케이프하는가
12. 분석·마케팅 도구와 웹훅
- 이벤트 속성, 페이지 URL, 사용자 속성에 이메일·전화번호를 넣지 않는가
- 외부 웹훅·슬랙 알림(예: "신규 주문: 홍길동 010-...")에 원문이 나가지 않는가
- 데이터 웨어하우스로 복제되는 테이블의 접근 권한이 운영 DB보다 느슨하지 않은가
원문이 필요한 경우의 설계와 회귀 방지
마스킹만으로 업무가 돌아가지 않는 순간이 옵니다. 배송 담당자는 전체 주소가, 상담원은 본인 확인을 위한 번호가 필요합니다. 이때 원칙은 원문 조회를 기본값이 아닌 별도 행위로 만드는 것입니다.
- 기본 응답은 마스킹, 원문은
GET /admin/customers/{id}/contact같은 별도 엔드포인트 - 역할 기반 권한 검사 + 조회 사유 입력
- 누가, 언제, 누구의 원문을 봤는지 접속 기록으로 남기기 (이 기록 자체에는 원문을 넣지 않음)
그리고 마스킹은 새 기능이 붙을 때마다 깨지기 쉬우므로 테스트로 고정합니다.
def test_order_list_does_not_expose_raw_phone(client, seeded_order):
res = client.get("/api/orders", headers=auth_header("customer"))
body = res.text
assert "010-1234-5678" not in body
assert "01012345678" not in body
assert "010-****-5678" in body
시드 데이터에 알아보기 쉬운 가짜 값(예: [email protected])을 넣고, 스테이징에서 응답·로그·Sentry·다운로드 파일을 이 값으로 검색해보는 '카나리아 점검'도 효과가 좋습니다. 검색에 걸리는 곳이 원문이 새는 경로입니다.
자주 묻는 질문
프론트에서 마스킹하고 HTTPS를 쓰면 충분하지 않나요?
아닙니다. HTTPS는 전송 구간을 보호할 뿐이고, 응답을 받은 브라우저·앱에서는 원문이 그대로 보입니다. 로그인한 사용자라면 누구나 개발자 도구로 확인할 수 있으므로 마스킹은 서버 응답 단계에서 해야 합니다.
로그에 개인정보를 마스킹해서 남기면 괜찮나요?
마스킹보다 '남기지 않기'가 우선입니다. 장애 분석에는 대부분 user_id, 주문 번호 같은 내부 식별자로 충분합니다. 마스킹 필터는 실수로 섞여 들어온 값을 잡는 안전망으로 두세요.
이미 로그와 Sentry에 원문이 쌓여 있다면 어떻게 하나요?
먼저 수집 경로를 막고, 이후 보관 기간과 접근 권한을 확인한 뒤 불필요한 데이터를 삭제합니다. 백업과 외부 도구에 복제된 사본까지 범위를 잡아야 하며, 절차는 개인정보 파기 방법에서 정리했습니다.
정리
- 개인정보 마스킹은 화면이 아니라 서버 응답 DTO에서 하고, 원문 조회는 별도 엔드포인트·권한·기록으로 분리합니다.
- 이름·전화번호·이메일·주소 규칙은 공통 함수 하나로 모아 모든 화면과 다운로드에서 같은 위치를 가립니다.
- 로그, APM, Sentry, 접근 로그, 알림 발송, CS 도구, 엑셀, 분석 도구까지 12곳을 카나리아 값으로 검색해 원문이 새는 경로를 찾습니다.
- 데이터 흐름 전체를 직접 점검하기 어렵다면 무료 분석 신청으로 외부 시각의 점검을 받아볼 수 있습니다.
참고 자료
함께 읽으면 좋은 글
모의해킹 준비: 범위·테스트 계정·환경 정하는 법
모의해킹 준비를 처음 하는 팀을 위한 의뢰 전 체크리스트입니다. 점검 범위, 권한별 테스트 계정, 운영·스테이징 선택, 결제·SMS 연동, WAF 예외, 백업까지 의뢰서 템플릿으로 정리했습니다.
JWT 취약점: 서명 검증 누락·alg 혼동·약한 시크릿
JWT 취약점은 대부분 구현 실수에서 생깁니다. decode·verify 혼동, alg none, RS256/HS256 혼동, 약한 시크릿, kid·jku 신뢰, 로그아웃 후 재사용 문제를 jsonwebtoken·jjwt·PyJWT 코드 비교로 점검합니다.
문자 인증 보안 체크리스트: SMS 펌핑·인증 우회 막는 10가지
문자 인증 보안 점검 항목 10가지. SMS 펌핑 비용 폭탄을 막는 발송 제한, 인증번호 시도 횟수·만료, 클라이언트 응답 조작 우회, 번호 불일치까지 취약한 코드와 고친 코드로 정리했다.