“userId 하나만 봐도 조직이 보인다”

여러 서비스를 운영하다 보면 어느 순간 이상한 걸 깨닫는다. 분명 같은 개념인데, 서비스마다 타입도 다르고, 뜻도 미묘하게 다르다. 한 서비스의 userIdLong이고, 다른 서비스에선 UUID이며, 또 다른 곳에선 아예 “식별 가능한 사용자”가 아니라 “익명 주체”를 가리킨다. 이걸 이벤트로 주고받는 순간, 아무도 틀린 코드를 짜지 않았는데 시스템은 조용히 어긋난다.

이 글은 내 세 개 서비스(정산 도메인, 오디오 측정 도메인, 익명우선 XR 도메인)를 실제로 감사해서 데이터 정의 표준(Data Definition Standard) 을 세우고, 조직 전체가 같은 데이터를 같은 의미로 쓰도록 정렬해 본 경험의 고찰이다. 결론부터 말하면 — 표준 문서는 쉬운 부분이고, 진짜 어려운 건 “이미 어긋난 것을 어떻게 되돌리느냐”와 “다시 어긋나지 않게 어떻게 막느냐” 였다.


1. 데이터 거버넌스의 착시 — “스키마”가 아니라 “의미”가 어긋난다

데이터 정렬이라고 하면 흔히 스키마 통일을 떠올린다. 컬럼명 맞추고, 타입 맞추고. 그런데 세 서비스를 실제로 뜯어보니, 명명(naming)은 이미 정렬돼 있었다. 세 곳 다 JSON은 camelCase, DB는 snake_case, 식별자는 영문, enum은 문자열 저장. 놀랍게도 이건 안 맞춘 적이 없다.

정작 어긋난 건 눈에 안 보이는 의미(semantics) 쪽이었다.

  • 시각(timestamp): 한 서비스는 LocalDateTime(타임존 없음, 서버 로컬), 다른 서비스는 Instant(UTC), 또 다른 서비스는 둘이 한 엔티티 안에서도 뒤섞여 있었다. 세 서비스의 “2026-07-25 03:00”이 서로 다른 순간을 가리킬 수 있다.
  • 식별자: 같은 “id”가 Long(auto-increment)과 UUID(무작위)로 갈렸다. 서비스 간 참조를 하는 순간 타입이 안 맞는다.
  • 주체(subject): 가장 무서운 건 이거였다. 한 서비스의 “user”는 이메일을 가진 식별 사용자이고, 다른 서비스의 “user”는 재식별을 금지하는 익명 주체다. 같은 단어인데 개인정보 정책이 정반대다.

교훈 하나: 조직이 이미 합의한 것(명명 규칙)은 놀랍도록 잘 정렬돼 있고, 각 서비스가 로컬에서 “알아서 결정한 것”(타임존·ID·주체)은 반드시 갈라진다. 정렬의 대상은 스키마가 아니라 각자 조용히 내린 결정이다.


2. 표준은 “이미 맞은 것을 고정”하고 “갈라진 것을 정규화”한다

그래서 표준 문서(DATA-STANDARD.md)는 두 가지 일을 한다.

(a) 이미 정렬된 것을 못 박는다. camelCase/snake_case, enum 문자열 저장, 영문 식별자 — 이건 규칙으로 고정만 하면 된다. 리스크 제로, 즉시 효과. 거버넌스의 첫 승리는 여기서 나온다. 새로운 규율을 강요하는 게 아니라, 이미 합의된 걸 명문화하는 것.

(b) 갈라진 것을 하나의 정의로 수렴시킨다. 여기가 진짜 작업이다.

  • 시각은 전부 UTC + tz-aware 타입 + wire는 ISO-8601 Z. 표시용 KST 변환은 표현 계층에서만.
  • 식별자는 신규는 시간정렬 UUID 권장, 기존 Long은 유지하되 크로스서비스 참조는 {service}:{type}:{id} 정규화 문자열로. (이미 이벤트 봉투가 aggregate_id를 문자열로 담고 있었다 — 관례를 규약으로 승격.)
  • 주체는 IdentifiedUser / AnonymousSubject / Operator 세 개념으로 분리하고, 어떤 payload든 subjectType을 함께 실어 익명과 식별을 절대 섞지 않게.

각 항목마다 MUST / SHOULD / MAY 강도를 붙였다. 강도가 없는 표준은 “권고 모음”일 뿐, 아무도 안 따른다.


3. 그런데 — 문서는 소원(wish)일 뿐이다

여기까지가 흔히 말하는 “데이터 거버넌스”다. 그리고 여기서 대부분 멈춘다. 위키에 표준 문서 하나 올려두고 “이제 다들 이렇게 씁시다” 하고 끝낸다.

이건 작동하지 않는다. 이유는 단순하다. 사람은 문서를 안 읽고, 읽어도 잊고, 마감이 급하면 로컬에서 또 알아서 결정한다. 표준 문서가 CI에 연결되지 않으면 그건 강제력 없는 소원이다.

그래서 표준의 진짜 절반은 강제(enforcement) 다. 그런데 강제를 붙이려는 순간 두 개의 현실적인 벽에 부딪힌다.

벽 1 — 기존 코드가 이미 표준을 위반하고 있다

“순간에 LocalDateTime 금지”라는 규칙을 하드하게 넣으면, 그 규칙을 넣는 순간 기존 67개 파일이 전부 빨간불이 되어 빌드가 깨진다. 아무도 이런 규칙을 머지하지 않는다.

답은 래칫(ratchet, 미늘) 이다. 지금 위반하는 67개를 baseline으로 동결(freeze) 하고, 새로 추가되는 위반만 실패시킨다. 레거시는 무해하게 얼려두고, 새 코드는 표준을 따르게 강제하고, 레거시는 시간을 두고 점진적으로 녹인다. 정렬은 이벤트가 아니라 프로세스다 — 한 번의 대규모 마이그레이션이 아니라, “더 나빠지지 않게 막고 천천히 좋아지게” 하는 톱니바퀴.

이걸 붙이고 나서 더미 위반 파일을 하나 넣어 봤다. CI가 정확히 그 파일을 짚어 실패했다. 빼니 통과했다. 이제 이 규칙은 소원이 아니라 문(gate)이다.

벽 2 — 강제한다고 믿었던 가드가 실은 헛돌고 있었다

이번 작업에서 가장 뼈아프고 중요한 발견이 이거였다. 정산 서비스엔 이미 헥사고날 경계를 지키는 아키텍처 테스트(ArchUnit)가 여러 개 있었다. “당연히 강제되고 있겠지” 하고 그 위에 표준 규칙을 얹으려 했는데 — 아무리 규칙을 넣어도 위반이 0건으로 잡혔다.

파고들어 보니, 이 빌드 셋업에서 ArchUnit이 클래스를 하나도 임포트하지 못하고 있었다. 즉 기존 아키텍처 테스트들이 “빈 집합에 대해” 검사를 돌리며 항상 통과(vacuously pass) 하고 있었던 것이다. 초록불이 켜져 있었지만, 실제로는 아무것도 지키지 않는 초록불이었다.

이건 무방비보다 나쁘다. 거짓 확신(false confidence) 이기 때문이다. 아무 가드가 없으면 조심이라도 하지만, “가드가 있다”고 믿으면 그 뒤로 마음 놓고 위반이 쌓인다.

교훈 둘: 가드가 실제로 가드하는지를 가드하라. 테스트가 초록불인 것과 테스트가 무언가를 검증하는 것은 다르다. 나는 결국 ArchUnit을 버리고, 클래스패스에 의존하지 않는 소스 스캔 기반 래칫으로 N1을 강제했다. 도구가 환경과 싸울 땐, 도구의 우아함보다 확실히 작동하는 단순함이 낫다.


4. 조직을 정렬한다는 것의 실체

이 작업을 관통하고 나서 “조직 전체가 같은 데이터를 같은 의미로 쓰게 한다”는 문장의 무게가 다르게 느껴진다. 그건 네 겹이다.

  1. 문서(Definition) — 무엇이 정답인지 한 곳에 성문화. 용어집 + 규범 + 강도(MUST/SHOULD/MAY).
  2. 측정(Audit) — 지금 실제로 어디가 어긋났는지 계측. 감사 없이 세우는 표준은 현실과 무관한 이상론.
  3. 강제(Enforcement) — 위반이 CI에서 시끄럽게 실패하게. 문서는 소원, 문(gate)이 표준.
  4. 점진(Ratchet) — 빅뱅 마이그레이션이 아니라 baseline 동결 + 신규 차단 + 레거시 점진 해소.

그리고 이 넷을 관통하는 한 문장이 있다. 정렬이란 “옳은 것을 기본값이자 쉬운 길로 만들고, 틀린 것은 시끄럽게 실패하게 만드는 것” 이다. 사람에게 규율을 지키라고 설득하는 게 아니라, 규율을 어기는 게 어렵고 지키는 게 자연스럽도록 시스템을 배치하는 일. 거버넌스의 성공은 회의록이 아니라 레드 CI로 측정된다.


한 줄 요약

데이터 정의 표준의 90%는 문서가 아니라 강제다. 이미 정렬된 관례(명명)는 고정하고, 조용히 갈라진 결정(타임존·ID·주체)은 하나의 의미로 수렴시키되 — 기존 위반은 래칫으로 얼리고 신규만 막아 점진적으로 정렬한다. 그리고 가장 중요한 것: 당신의 가드가 실제로 가드하는지부터 확인하라. 헛도는 초록불은 무방비보다 위험하다.