유저챗 개인정보 마스킹 개발기

고객이 남긴 개인정보, 저장되기 전에 지웁니다.

kia

  • Backend

들어가며

안녕하세요, 채널톡 Security팀 Software Engineer Kia입니다.

Security팀은 채널톡의 제품과 인프라, 고객 데이터를 안전하게 보호하면서, B2B 고객이 요구하는 보안 수준을 제품 전반에 녹여내는 일을 합니다.

이번 글에서는 개인정보 마스킹 기능을 만들며 있었던 이야기를 통해, 제가 경험한 "보안을 제품에 녹이는 일"이 어떤 것이었는지 이야기해보려 합니다.

채널톡 상담 대화에는 이런 메시지가 종종 올라옵니다.

"제 주민번호는 951016-1234567이에요."

"카드번호 4539-1234-5678-9999로 결제했는데요."

고객 입장에서는 자연스러운 대화입니다. 문의를 해결하려면 결제한 카드가 무엇인지, 본인이 누구인지 알려줘야 하니까요.

별다른 조치가 없으면 이런 개인정보가 포함된 메시지는 대화창에 그대로 쌓이게 되고, 그 대화를 열어볼 수 있는 사람이라면 누구나 원문을 볼 수 있습니다.

개인정보가 화면에 그대로 남는 이 상황에 대해, 실제로 여러 고객사가 불편함을 전해주셨습니다.

“프론트 화면에서 개인정보가 오가지 않게 고객 발화를 막고 싶어요”

”일일이 민감정보를 삭제해야하는 실무 액션이 번거로워요”

이렇게 남은 원문은 그대로 DB에 쌓이는데, 이는 고객사가 지켜야 할 개인정보 처리 기준을 어렵게 만들고, 내부 감사에서 지적되는 리스크로 이어집니다. 또한 보안이 중요한 엔터프라이즈 고객사에게는 도입을 가로막는 벽이기도 했습니다.

채널톡의 코어 밸류가 Customer-Driven인 만큼, 고객이 반복해서 말하는 문제라면 우리가 풀어야 할 문제라고 봤습니다.

그래서 고객이 남긴 개인정보를 상담사가 찾아 지우는 대신, 저장하기 전에 자동으로 마스킹해서 원문이 채널톡에 아예 남지 않게 만들기로 했습니다.

"제 카드번호는 4539-1234-5678-9999이에요"

"제 카드번호는 *******************이에요"

저장되는 값이 이미 마스킹되어 있으니, 상담사 화면에도 뜨지 않고 따로 찾아 지울 일도 없습니다.

본격적인 이야기 전에, 기능이 실제로 어떻게 동작하는지 먼저 간단히 소개하겠습니다.

상담 내 개인정보 마스킹 기능

채널톡에서 고객과 상담 담당자가 주고받는 대화를 유저챗이라고 부릅니다.

채널 설정에서 [상담 내 개인정보 마스킹] 기능을 켜면, 그때부터 들어오는 유저챗 메시지에서 자동으로 개인정보를 찾아 별표(*)로 바꿔 저장합니다.

기본 제공되는 규칙 7가지에 커스텀 규칙 최대 10개를 더해, 채널당 최대 17가지 규칙까지 사용할 수 있습니다.

  • 기본 규칙: 신용카드 번호, 개인 식별자, 이메일, 전화번호, IP 주소

  • 커스텀 규칙: 사번이나 주문번호처럼 채널마다 다른 형식은 정규식으로 직접 등록 가능합니다.

자세한 기능 사용법과 규칙 등록 방법은 가이드 문서를 참고해주세요

기능 자체는 정규식으로 값을 찾아 별표(*)로 바꾸는 단순한 작업입니다. 정작 시간이 오래 걸린 건 규칙을 어디까지 적용할지 정하는 쪽이었습니다. 범위를 넓게 잡으면 정상 값까지 지워지고, 좁게 잡으면 원문이 남습니다. 규칙이 늘어나면 메시지 저장도 그만큼 느려집니다.

개인정보 유출 위험을 줄이려고 만든 기능이 그 자체로 새로운 위험이 되지 않는 범위를 찾아야 했습니다.

1. 저장 전에, 그리고 한 곳에서

가장 먼저 정해야 했던 건 마스킹을 언제, 어디서 처리할지였습니다.

채널톡에서는 팀챗을 비롯해 여러 곳에서 같은 메시지 구조를 함께 쓰고 있어서, 마스킹 로직을 어디에 두는지에 따라 영향 범위가 크게 달라집니다.

여러 기업이 개인정보를 어떻게 마스킹하는지 살펴보니 크게 두 갈래였습니다.

  • 조회할 때 마스킹해서 보여주기: 원문은 DB에 남고, 보여줄 때만 가립니다.

  • 저장하기 전에 마스킹하기: 마스킹된 값만 DB에 들어가고, 원문은 어디에도 남지 않습니다.

조회할 때 가리는 쪽의 장점은 되돌릴 수 있다는 것입니다. 잘못 가린 값도 원본이 그대로 있으니, 나중에 확인할 방법이 남습니다.

문제는 그 원문이 계속 DB에 남아 있다는 사실 자체입니다. 뒤에 이어지는 모든 것(검색, 웹훅, 푸시 알림 등)이 원문을 볼 수 있게 되고, 그 각각이 유출 경로가 됩니다.

되돌릴 수 있다는 이점보다 원문이 남는 위험이 크다고 봐서, 저장하기 전에 마스킹하는 방식을 택했습니다. 저장 시점에 이미 마스킹되어 있으면, 그 아래로 흐르는 모든 다운스트림은 코드를 건드릴 필요도 없이 마스킹본만 보게 됩니다.

대신 조건이 하나 붙습니다. 저장으로 가는 길이 하나여야 합니다.

보안 기능이라, 마스킹이 한 경로에서 조용히 빠지면 그 경로로는 원문이 그대로 새어 나갑니다. 나중에 누군가 새로운 저장 경로를 추가하면서 마스킹의 존재를 모른다면, 유출 구멍이 하나 더 생기는 셈입니다.

그래서 적용 지점을 한 곳으로 모았습니다. 유저챗 메시지는 생성이든 편집이든 모두 같은 처리 파이프라인을 지나가고, 마스킹은 그 파이프라인 앞단에 놓입니다.

마스킹이 적용되는 이 지점에 fail-secure 원칙을 더했습니다. 마스킹 도중 예외가 나면 메시지 저장 자체가 실패합니다. 마스킹이 실패했는데 개인정보가 포함된 메시지는 그대로 저장되는 상황을 막기 위해서입니다. 저장 직전에 예외를 던지면 웹훅 같은 후속 처리도 함께 멈추고, 결과적으로 메시지 전송 자체가 실패로 끝납니다.

저장 전에, 그리고 한 곳에서. 이 두 결정으로 유출 위험은 크게 줄었습니다.

대신 두 가지 제약이 따라왔습니다. 마스킹한 값은 되돌릴 수 없고, 마스킹 처리를 모든 유저챗 메시지가 한 번씩 거치게 됩니다. 여기서부터는 이 제약들을 유지하며, 기능을 안정적으로 구현하는 일이 남았습니다.

2. 못 잡아도, 잘못 잡아도 되돌릴 수 없다

저장 전에 마스킹한다는 건, 지울 기회가 그 한 번뿐이라는 뜻이기도 합니다. 그 순간을 놓치면 원문은 이미 저장된 뒤라 뒤늦게 규칙을 고쳐도 그 메시지는 그대로 남습니다.

반대로 잘못 지우면 정상 값이 영구히 사라집니다. 원본이 어디에도 없으니, 무엇이 지워졌는지 확인할 방법이 없습니다.

특히 관리자가 등록한 정규식은 그 채널의 모든 인입 메시지에 적용되기 때문에, 패턴 하나만 잘못 짜도 알아차리기 전까지 멀쩡한 데이터가 계속 지워집니다.

그래서 기본 규칙과 커스텀 규칙 양쪽에서, 놓치지도 과하게 지우지도 않을 선이 필요했습니다.

탐지 범위

1) 값의 형식은 정해져 있지만, 그걸 적는 방식은 제각각입니다.

01012345678

010-1234-5678

010.1234.5678

821012345678

같은 전화번호라도 하이픈·공백·점으로 나뉘거나, 아무 구분 없이 붙여 쓰거나, 앞에 국가번호(+82, +81, +1)가 붙습니다. 규칙이 표준 형태만 담고 있으면 조금 다르게 적은 번호는 그대로 통과해 버리기 때문에, 이런 변형을 최대한 규칙에 담았습니다.

2) 반대로 형식만 보고 판단하면 개인정보가 아닌 값까지 걸릴 수 있습니다.

4539-1234-5678-9999 → 카드번호

4539-1234-5678-9998 → 통과 (체크섬 불일치)

9812-3456-7890-1234 → 통과 (존재하지 않는 BIN)

카드번호가 대표적인데, 16자리 숫자라는 조건만으로는 주문번호나 일련번호 같은 임의의 숫자도 카드번호로 오인될 수 있습니다. 그래서 카드사 BIN(발급사 식별번호)으로 실제 발급 가능한 번호대인지 확인하고, Luhn 체크섬으로 유효한 카드번호인지 한 번 더 검증해 오탐을 줄였습니다.

표시 방식

"어떻게 보이게" 지울지도 정해야 했습니다.

값을 문자열 통째로 들어내면 그 자리에 무언가 있었다는 사실까지 사라집니다. 상담사 입장에서는 고객이 번호를 남겼는데 가려진 것인지, 애초에 아무 말도 없었던 것인지 구분할 수 없습니다.

010-1234-5678 → *************

4539-1488-0343-6467 → *******************

그래서 원본 길이는 유지하되, 숫자든 구분 기호(-)든 매칭된 구간 전체를 같은 개수의 별표(*)로 바꾸는 방식을 택했습니다. 자릿수가 남으니 "무언가 가려졌다"는 건 보이지만, 값 자체는 어떤 유형이든 남김없이 가려집니다.

규칙 충돌

규칙이 최대 17개까지 동시에 적용 가능하기 때문에, 여러 규칙이 같은 자리에 겹치는 일도 생깁니다. 전화번호 규칙과 커스텀 숫자 규칙이 같은 번호를 동시에 잡는 경우가 그렇습니다. 이때 두 규칙이 잡은 구간이 정확히 같지 않으면, 어느 쪽 범위로 가릴지를 정해야 합니다.

규칙마다 우선순위를 매기는 방법도 있지만, 그러려면 어떤 규칙이 더 중요한지를 정해야 합니다. 채널마다 등록하는 커스텀 규칙이 다른데 그 순서를 우리가 미리 정해두는 건 맞지 않았습니다.

원문 번호는 010-1234-5678900 이에요

전화번호 010-1234-5678

커스텀 규칙 5678900

결과 번호는 ****************** 이에요

그래서 우선순위를 두지 않고, 매칭된 구간을 모두 합쳐서 가리는 방식을 택했습니다. 조금 과하게 가려질 수는 있지만, 보안 관점에서 더 넓게 가려 새어 나갈 여지를 줄이는 쪽이 맞다고 봤습니다.

등록 안전장치

정규식을 직접 등록할 수 있으니 어떤 형식이든 감지할 수 있지만, 그만큼 신중해야 합니다.

패턴을 잘못 짜면 정상 값을 개인정보로 오인해 가리거나, 반대로 진짜 개인정보를 놓칩니다. 어느 쪽이든 되돌릴 수 없습니다.

정규식은 눈으로만 봐서는 무엇을 잡을지 알기 어렵습니다. 그래서 규칙 등록 화면에 테스트 버튼을 넣었습니다. 규칙을 저장하기 전에 실제 문장을 넣어보면, 잡아야 할 값을 놓치는지 정상 값을 개인정보로 오인하는지 미리 확인할 수 있습니다.

3. 모든 메시지가 이 길을 지난다

적용 지점을 한 곳으로 모았으니, 모든 유저챗 메시지가 이 규칙들을 빠짐없이 통과합니다.

유출 경로를 막기 위한 선택이었지만, 성능 관점에서는 모든 메시지가 같은 처리를 한 번씩 거친다는 뜻이기도 합니다. 규칙이 하나 늘면 메시지마다 검사할 패턴이 하나 늘고, 그 비용은 저장이 끝나기 전에 다 치러야 해서 그대로 전송이 지연 됩니다.

문제는 이 저장 경로가 채널톡의 핵심 기능이라는 점이었습니다. 개인정보 유출 막자고 넣은 로직이 메시지 전송을 느리게 만들면, 위험을 줄인 게 아니라 옮긴 셈이 됩니다 .

무엇을 예산으로 잡을까

매칭 로직이 실제 환경에서 적절한 레이턴시를 가지는지 판단하려면 기준이 필요했습니다. 마스킹에 쓸 시간이 따로 있는 게 아니라, 기존 쓰기 지연이 곧 예산이고 마스킹은 그 안에서 흡수돼야 하는 비용이기 때문입니다.

그래서 유저챗 메시지가 초당 얼마나 저장되는지(트래픽), 한 번 저장하는 데 원래 얼마나 걸리는지(쓰기 경로 지연), 이 둘을 먼저 실측했습니다.

측정하기 전에는 상담이 몰리는 시간대가 제일 위험할 거라고 예상했습니다. 쓰기가 몰리는 만큼 기존 지연이 올라가 있을 테고, 그 위에 마스킹 비용까지 얹히면 그 시간대만 예산을 따로 잡아야 할 거라고 봤습니다.

시간대마다 트래픽 차이가 있어도, 지연은 대체로 비슷했습니다. 시간대별 쓰기량은 10배 이상 차이가 났는데, p99 지연 차이는 1.2배 안쪽이었고으며 평균은 어느 시간대든 사실상 같았습니다. 몰리는 시간대는 큰 변수가 아니었습니다.

덕분에 예산은 시간대와 무관하게 지표 하나로 잴 수 있었습니다. 평소 쓰기 경로를 예산 1로 두고, 마스킹이 그중 몇 %를 쓰는지 보기로 했습니다.

다음은 마스킹 비용이 무엇에 따라 달라지는지였습니다.

정규식 비용은 대체로 입력 길이에 비례하기 때문에, 메시지 크기가 실제로 어떻게 분포하는지 확인했습니다.

실데이터를 바이트로 재보니 메시지의 95% 이상이 500바이트도 되지 않았고, 99%가 1.2KB 이하였습니다. 하지만 수백 KB에 이르는 큰 메시지도 드물게나마 존재했고, 최악의 비용을 결정하는 건 바로 이 메시지들이었습니다.

시나리오별 벤치마크

이 분포를 바탕으로 규칙 구성과 메시지 크기를 나눠 측정했습니다. 커스텀 규칙 10개는 실제로 등록될 법한 패턴으로 채웠고, worst-case에는 의도적으로 복잡한 패턴을 넣었습니다.

구성

개수

패턴 성격

기본 규칙

7개

카드번호·개인 식별자·이메일·전화번호·IP

커스텀 (simple)

10개

단순 키워드 매칭 (예: `password`, `token`)

커스텀 (medium)

10개

자릿수 + 문자 조합 (예: 여권번호, 계좌번호)

커스텀 (complex, worst-case)

10개

여러 단어 나열, URL/이메일/IP 등 연산량이 큰 패턴

메시지 크기와 규칙 구성별로, 마스킹에 걸리는 시간이 앞서 정한 예산 중 몇 %를 차지하는지 측정했습니다. (p95 기준)

메시지 크기

기본 규칙만

기본 + 복잡한 커스텀 10개

100B

0.01% 미만

0.1%

1KB

0.01% 미만

1.6%

5KB

0.01% 미만

8%

10KB

0.02%

15%

50KB

0.1%

82%

100KB

0.2%

예산을 넘어섬 (약 1.7배)

기본 규칙만 켜면 100KB 메시지에서도 예산의 0.2%입니다. 실제 메시지의 99%가 1.2KB 이하라는 점까지 보면, 대부분의 메시지에서는 마스킹 비용이 쓰기 경로 전체 지연에서 차지하는 비중이 사실상 없었습니다.

문제는 사이즈가 큰 메시지 쪽이었습니다. 같은 100KB 메시지인데, 기본 규칙만 켤 때보다 복잡한 커스텀 규칙 10개까지 더하면 800배 넘게 비쌉니다. 5KB에서 100KB로 20배 커지면 비용도 그만큼 늘어, 100KB에 이르면 마스킹 한 번이 평소 쓰기 지연보다 오히려 오래 걸립니다.

평균이 아니라 순간적인 피크 지연과 비교해도 마찬가지입니다. 마스킹 한 번이 피크 지연의 3분의 2를 차지해서, 큰 메시지 하나가 전체 쓰기 지연을 1.7배 가까이 늘릴 수 있다는 의미였습니다.

여기서 위험이 두 갈래로 갈립니다.

  • 지수적 폭발: 특정 패턴이 백트래킹에 걸리면, 입력이 조금만 길어져도 처리 시간이 폭발적으로 늘어납니다. 이 지점을 노리면 정규식만으로 서버를 마비시키는 ReDoS(Regular Expression Denial of Service) 공격이 가능해집니다. 개인정보를 지우려고 연 정규식 등록 기능이, 그대로 공격면이 되는 셈입니다.

  • 선형 누적: 패턴 자체는 안전해도, 입력이 크면 비용이 길이에 비례해 그대로 쌓입니다. 공격이 없어도 큰 메시지 몇 개면 쓰기 경로가 느려집니다.

3-1. 지수적 폭발 — 엔진 교체

기존에 쓰던 JDK 기본 엔진(java.util.regex)은 백트래킹 방식입니다. 매칭이 실패하면 앞으로 되돌아가 다른 조합을 다시 시도하는데, 패턴에 따라 시도해야 할 조합의 수가 입력 길이에 따라 지수적으로 늘어날 수 있습니다.

이 중에서도 지수적 폭발 위험이 특히 컸던 이유는, 관리자가 정규식을 직접 등록할 수 있다는 점 때문입니다. 등록한 정규식은 그 채널의 모든 인입 메시지에 적용되니, 잘못 만든 패턴 하나가 폭발하면 그 채널의 상담 전체가 멈춥니다.

그래서 입력 길이에 선형인 RE2 계열 엔진(re2j)으로 변경했습니다.

RE2는 백트래킹을 하지 않습니다. 되돌아가 다른 조합을 다시 시도하는 과정 자체가 없어서, 입력이 아무리 길어져도 처리 시간은 길이에 비례해서만 늘어납니다.

이 선택이 실제로 안전한지 확인하기 위해, 백트래킹을 유발하는 것으로 알려진 정규식 `(.*?,){N}P` 로 테스트를 진행했습니다. 입력에는 쉼표를 N개 채우고 끝의 `P`는 빼서, 엔진이 모든 조합을 시도한 끝에 매칭 실패로 끝나게 했습니다.

`(.*?,){N}P` 는 "쉼표로 끝나는 아무 문자열"이 N번 반복된 뒤 P가 와야 매칭되는 패턴입니다.

이 상태에서 쉼표 개수(N)를 하나씩 늘려가며, "매칭 실패"라는 결론에 도달하기까지 걸리는 시간을 두 엔진에서 측정했습니다.

N이 늘어날수록 엔진이 시도해 볼 조합의 수도 같이 늘어나는 구조라, 백트래킹 엔진이라면 이 지점에서 처리 시간이 폭발적으로 늘어나야 합니다.

쉼표 30개부터 JDK 기본 엔진은 5초 넘게 멈춰버립니다. 반면 re2j는 처음부터 끝까지 0.1ms 근처를 유지했습니다.

엔진을 바꾼 덕분에, 이런 폭발성 지연은 더 걱정하지 않아도 됐습니다.

3-2. 선형 누적 — 길이와 개수 상한

엔진을 바꿔도 앞 표에서 커스텀 규칙을 얹은 쪽 숫자는 그대로였습니다.

정규식 비용이 입력 길이에 비례하는 이상, 메시지가 충분히 크면 비용이 쌓이는 건 엔진으로 막을 수 없습니다. 그래서 입력 자체에 길이 상한을 뒀습니다.

상한을 어디에 둘지는 통과율과 비용, 두 값을 나란히 놓고 정했습니다. 상한을 올리면 통과하는 메시지는 늘지만, 최악의 경우 마스킹 비용도 같이 늘어납니다.

통과율은 1KB에서 이미 98.6%이고, 100KB까지 올려도 1.4%p밖에 오르지 않습니다. 반면 비용은 같은 구간에서 1.6% → 8% → 15% → 82%로 몇 배씩 뛰다가, 100KB에서는 예산을 넘어섭니다. 상한을 높게 잡아서 얻는 건 1%대의 통과율이고, 내주는 건 최악의 경우 예산 전체였습니다.

그래서 통과율이 이미 충분히 높고 비용은 아직 완만한 구간에 상한을 두었습니다. 덕분에 정상적인 사용에는 거의 영향이 없습니다. 상한에 걸리는 건 극히 일부이고, 그 대부분은 애초에 마스킹 성능에 부담이 되는 대용량 메시지였습니다.

마무리

이 기능을 구현하며 제일 어려웠던 건 마스킹 자체가 아니라 기준을 잡는 일이었습니다. 보안 기능이라고 해서 항상 더 많이 지우고 더 엄격하게 막는 쪽이 정답은 아니었습니다. 문제는 얼마나 촘촘하게 지우느냐가 아니라, 어디까지를 감당할 범위로 볼 것이냐였습니다.

기능을 배포한 뒤로, 처음 이 문제를 꺼냈던 고객사뿐 아니라, 보안 검토 때문에 도입을 미뤘던 고객사에서도 마스킹 기능을 사용하고 있습니다. 상담사가 대화를 훑어보며 개인정보를 지우던 작업이 단순해졌고, 보안 검토에서 막혀 있던 이야기도 넘어갈 수 있게 됐습니다.

We Make a Future Classic Product