동의를 참/거짓 한 칸에 저장하면 분쟁에서 진다

하승진·3일 전

Level Up 개발자

목록 보기
29/30
post-thumbnail

광고성 정보 수신 동의가 이렇게 저장돼 있었다.

marketing_agreed  TINYINT(1)

참이면 보내고 거짓이면 안 보낸다. 동작은 맞다. 문제는 이 칸이 "동의하셨습니까"에만 답할 수 있다는 것이다.

실제로 들어온 질문들은 이랬다.

  • 이 사람은 언제 동의했나?
  • 어느 화면에서 받은 동의인가?
  • 동의했다가 철회한 적이 있나?
  • 거짓인 사람은 거절한 것인가, 물어본 적이 없는 것인가?

한 칸으로는 하나도 답할 수 없다. 그리고 이 질문들은 보통 답할 수 없을 때 문제가 되는 질문들이다.

한 칸에서 네 겹으로

이걸 고치는 데 네 번의 PR 이 걸렸다. 한 번에 설계한 게 아니라, 질문이 올 때마다 "아 이것도 답을 못 하네"를 발견하면서 한 겹씩 쌓았다.


① 거짓은 두 가지다

제일 먼저 깨진 건 false 의 의미였다.

가입할 때 체크박스를 안 누른 사람과, 동의했다가 수신 거부한 사람이 DB 에서 똑같이 0 이었다. 둘은 완전히 다른 사람이다. 전자에게는 다시 물어볼 수 있고, 후자에게 다시 물으면 안 된다.

그리고 애초에 동의 항목을 보여준 적도 없는 사람이 있었다. 그 기능이 생기기 전에 가입한 사람들. 이들도 0 이었다.

type MarketingConsent = 'UNKNOWN' | 'CONSENTED' | 'NOT_CONSENTED';

셋으로 나눴다. UNKNOWN 은 "확인 불가" — 묻거나 기록한 적이 없다는 뜻이다. 이게 중요한 이유는, UNKNOWN 과 NOT_CONSENTED 의 처리가 다르기 때문이다. 둘 다 발송 대상은 아니지만, 전자는 동의를 받을 여지가 있고 후자는 없다.

여기서 하나 배웠다. 열거형에 "모름"을 넣는 걸 주저하지 말 것. 모름을 거짓으로 접으면, 그 순간부터 거짓이 두 가지를 뜻하게 된다.


② 상태가 아니라 변화를 기록한다

상태를 셋으로 나눠도 "언제"에는 답할 수 없다. 현재 값만 있으니까.

이력 테이블을 만들었다. 단, 상태가 실제로 바뀐 순간만 기록한다. 같은 값으로 저장하는 요청(사용자가 마이페이지에서 아무것도 안 바꾸고 저장 누르는 경우)은 이력을 남기지 않는다. 안 그러면 이력이 노이즈로 가득 차서 진짜 전환을 못 찾는다.

그리고 파생 값 두 개를 뒀다.

  • 철회일 — 동의 → 미동의로 바뀐 시점에만 기록. 처음부터 미동의인 사람에게는 없다
  • 최초 동의일 — 한 번만 기록. 동의 → 철회 → 재동의 해도 처음 값을 유지

백필의 한계를 유의사항에 적었다

기존 데이터를 채우면서 알게 된 건, 과거는 완전히 복원되지 않는다는 것이다.

- 과거의 '최초 미동의' 와 '철회 후 미동의' 는 원본 시각만으로 구별할 수 없다.
  그래서 과거 철회일은 백필하지 않는다.
- 최초 동의일은 '현재 보존된 이력에서 확인 가능한 가장 이른 동의 시각' 이다.
  더 오래된 이력이 없으면 절대 최초 시각을 보장하지 않는다.

이걸 PR 유의사항에 쓴 이유는, 나중에 누군가 이 값을 법적 근거로 쓸 수 있기 때문이다. "최초 동의일"이라는 컬럼 이름만 보면 당연히 진짜 최초라고 생각한다. 아니라는 걸 아는 사람은 지금 나뿐이고, 나는 6개월 뒤에 잊는다.

추정으로 채운 값은 추정이라고 적어야 한다. 컬럼 이름은 그걸 못 적는다.


③ 신청과 동의를 같은 트랜잭션에 묶었다

"어디서 받은 동의인가"를 위해 접점(source)을 기록하기로 했다. 이벤트 신청, 입학설명회, 입학 신청, 무료 체험, 결제 페이지… 12종.

처음 구현은 이랬다.

await submitApplication(form);        // 신청 저장
await patchMyInfo({ marketing: true }); // 동의 갱신  ← 별도 요청

신청이 성공하고 두 번째 요청이 실패하면 신청은 됐는데 동의 기록만 없다. 사용자는 체크했다고 기억하는데 DB 에는 없다. 분쟁이 나면 우리가 진다.

신청 API 안으로 넣고 같은 트랜잭션으로 묶었다. 둘은 함께 확정되거나 함께 실패한다.

그리고 규칙을 하나 더 못 박았다. UI 에 보였다고 기록하지 않는다. 체크박스가 화면에 떴다는 사실이 아니라, 사용자가 제출한 선택만 기록한다. 이미 전역 동의를 해서 체크박스가 아예 안 보인 사용자는 서버에서도 아무것도 기록하지 않는다. 보이지 않은 항목에 대한 "선택"은 존재하지 않는다.

미체크 신청도 중요했다. 체크 안 하고 신청한 경우, 전역 동의 상태를 바꾸지 않는다. 예전에 동의한 사람이 이벤트 신청에서 체크를 안 했다고 해서 기존 동의가 철회되면 안 된다. 다만 "이 신청에서는 미동의를 선택했다"는 응답은 남긴다.


④ 빠뜨리면 컴파일이 실패하게

접점이 12종이 되고, 두 개의 서비스(두 브랜드)가 섞이기 시작했다. "이 접점은 어느 서비스 것인가"를 매핑해야 했다.

가장 쉬운 방법은 이거다.

function getService(source?: Source) {
  if (source === 'PURPLE_ENGLISH_PAYMENT') return 'PURPLE_ENGLISH';
  return 'PURPLE_ACADEMY';  // 나머지 전부
}

이러면 새 접점을 추가할 때 아무 일도 안 일어난다. 조용히 기본값으로 분류되고, 몇 달 뒤 집계가 이상하다는 말이 나온다.

그래서 전체 Record 로 바꿨다.

const SOURCE_SERVICE: Record<MarketingConsentSource, MarketingConsentService> = {
  EVENT:            'PURPLE_ACADEMY',
  ADMISSION:        'PURPLE_ACADEMY',
  // ... 12개 전부
  PE_PAYMENT:       'PURPLE_ENGLISH',
};

Record<K, V> 는 K 의 모든 멤버를 요구한다. 접점 enum 에 하나를 추가하는 순간, 이 객체가 불완전해져서 타입 에러가 난다. 서비스를 정하지 않으면 배포가 안 된다.

이게 이번 작업에서 제일 마음에 드는 부분이다. 문서에 "새 접점을 추가하면 서비스도 지정하세요"라고 적는 대신, 지정하지 않으면 컴파일이 안 되게 만들었다. 문서는 안 읽히지만 컴파일 에러는 반드시 읽힌다.

규칙을 지키게 하는 가장 싼 방법은 안 지키면 빌드가 깨지게 하는 것이다.


네 번에 나눠서 한 게 맞았나

한 번에 설계했으면 더 깔끔했을까. 아마 아니다.

①을 할 때는 접점이 필요한 줄 몰랐다. ③을 할 때는 서비스가 둘로 갈릴 줄 몰랐다. 미리 다 상상해서 만들었으면 안 쓰는 칸이 잔뜩 있는 테이블이 나왔을 것이고, 정작 필요한 칸은 또 없었을 것이다.

대신 각 단계에서 지킨 게 있다. 다음 겹을 올릴 수 있게 남겨두기. 상태를 enum 으로 뺐기 때문에 값이 늘어나도 괜찮았고, 이력을 별도 테이블로 뒀기 때문에 접점 컬럼을 거기 붙일 수 있었다. 스키마 변경은 전부 컬럼 추가(NULL 허용)로만 했고, 기존 컬럼은 하나도 안 지웠다.

개인정보 관련 데이터는 특히 그렇다. "나중에 물어볼 질문"을 지금 다 알 수 없다. 알 수 없다는 걸 전제로, 질문이 왔을 때 한 겹 올릴 수 있는 모양으로 두는 게 최선이다.


다음 글은 전혀 다른 쪽이다. 우리 사이트가 검색엔진에게는 로딩 화면으로만 보이고 있었던 이야기, 그리고 2026년에 AI 크롤러를 어디까지 허용할지 정한 기준.

profile
기어갈지언정 한 발자국씩이라도 가보자

0개의 댓글