- Published on
선반영이 드러낸 도메인 문제 두 케이스 — 신청 취소와 OID 트리 구조 회고
- Authors

- Name
- Hyo814
선반영이 드러낸 도메인 문제 두 케이스
5월에 두 가지 이슈를 같이 정리해야 했습니다. 한쪽은 워크플로우(신청 취소), 한쪽은 데이터 모델(OID 트리). 다른 층의 문제였지만 발견 메커니즘이 똑같았습니다 — 둘 다 기능은 동작하는데 도메인이 어긋나 있었다.
1. 들어가며 — 두 개의 "되는데 잘못된 것"
코드 자체에 에러는 없었습니다. 누르면 동작하고, 데이터도 들어갑니다. 그런데 운영해보니 이상한 상태가 만들어졌어요. 이런 버그가 가장 짚어내기 어렵습니다. 스택 트레이스로 안 잡히고, "왜 이런 데이터가 있지?"라고 누군가 의문을 가져야 비로소 드러나니까요.
두 케이스를 따라가다 보니 공통 패턴이 보였습니다. 결론을 먼저 적어두면 — 선반영(precommit) 자체가 나쁜 게 아니라, 선반영이 드러낸 전제 결함을 빠르게 회수하는 흐름이 더 중요하다.
2. 케이스 1: 신청 취소 프로세스 — 미리 만든 기능이 의도와 다르게 동작
2.1 증상
심사 단계인 신청 건이 사용자에 의해 임의로 취소되는 일이 발생했습니다. 본래 흐름은:
신청 → 심사 → 승인/반려
이지만, 사용자는 심사 진행 중에도 "신청 취소" 버튼을 눌러 자기 신청을 회수할 수 있었어요. 결과적으로 심사자의 노력이 무효화되거나, 심사 도중 사라진 데이터가 통계상 불일치를 만들었습니다.
2.2 원인 — 2월에 프론트엔드가 선반영한 취소 기능
원인을 따라 올라가니, 2월에 프론트엔드 단에서 "취소 기능이 필요할 것 같다"는 가설로 화면을 먼저 만들어 둔 상태였습니다. 그때는 백엔드 정책이 명확히 정해지지 않은 상태였고, 그래서 "취소 가능 조건" 검증이 느슨한 채로 배포됐어요.
쉽게 말해 취소권의 범위가 정의되지 않은 채 취소 버튼이 동작하고 있었던 상태였습니다.
2.3 선택지 세 가지
| 옵션 | 설명 | 장점 | 단점 |
|---|---|---|---|
| ① 현상 유지 | 언제든 취소 가능 | 추가 작업 0 | 심사자의 작업이 무효화될 수 있음 |
| ② 관리자 승인 후에만 취소 | 심사 시작 이후엔 관리자 허가 필요 | 양쪽 보호 | 관리자 부담 증가, 동선 길어짐 |
| ③ 신청 전에만 취소 | 심사 시작 시점부터 취소 불가 | 정책이 가장 단순 | 사용자 입장에선 다소 경직 |
세 가지를 두고 회의에서 갈렸습니다. "취소권은 누구의 권리인가?"가 결정 기준이었고, 결국 ②번(관리자 승인) 방향으로 정리됐어요. 신청자도, 심사자도 보호되는 유일한 선택지였습니다.
2.4 동시에 보강한 에러 방어
취소 정책 자체와는 별도로, 코드 리뷰에서 발견된 500 에러 경로를 함께 메웠습니다.
if request_obj.current_state is None:
logger.warning(
f"cancel_request: request {request_obj.pk} has no current_state, aborting"
)
return redirect(detail_url)
current_state가None인 Request(데이터 이관/장애 복구 중에 생길 수 있음)에 취소를 누르면 NOT NULL 제약 위반으로 500이 나는 경로가 있었습니다. 별도 가드로 메꿨어요.State.objects.get_or_create(...)도(process, type)에 DB-level unique 제약이 없어 중복 행이 만들어지면MultipleObjectsReturned로 500이 나는 위험이 있었습니다.filter().first()후 없으면create()로 패턴을 바꿨습니다.
2.5 교훈
- 선반영의 양면성: 도메인을 빠르게 더듬어볼 수 있는 디스커버리 도구지만, 정책이 굳기 전에 외부에 노출되면 사용자가 그걸 "의도된 기능"으로 받아들입니다.
- 선반영 기능에는 피처 플래그/내부 전용 노출 가드를 같이 두고, "정책 미확정"이 보이도록 표시하자.
3. 케이스 2: OID 트리 구조 역전 — 원본과 참조의 방향이 거꾸로였다
3.1 기존 구조의 가정
이전 OID 모델은 표준데이터가 원본이고, 데이터 엘리먼트는 표준 아래에 종속되어 있다는 전제로 설계되어 있었습니다.
표준 1 ─┬─ 엘리먼트 A
├─ 엘리먼트 B
└─ 엘리먼트 C
즉 표준이 부모, 엘리먼트가 자식. 이런 구조에서는 표준을 등록하면 그 아래에 엘리먼트들이 줄줄이 묶여 들어갑니다.
3.2 드러난 문제
실제로 운영해보니 하나의 엘리먼트가 여러 표준에 동시에 사용되는 경우가 다수였습니다. 같은 "위치 좌표" 엘리먼트가 표준 A에도 있고, 표준 B에도 있고, C에도 있는 거죠.
기존 구조에서는 이게 표현이 안 되니까, 같은 엘리먼트를 표준마다 복제 저장하고 있었습니다. 그러다 보니:
- 같은 의미의 엘리먼트가 여러 ID로 흩어짐
- 표준마다 조금씩 다른 정의가 붙어 마스터가 사라짐
- "이게 어디서 처음 정의된 거죠?" 라는 질문에 답할 수 없음
결국 도메인의 진짜 관계는 반대였습니다. 엘리먼트가 원본이고, 표준이 그 엘리먼트를 N개 참조.
3.3 변경 방향 — 트리 역전
엘리먼트 A ─┬─ 표준 1 (참조)
└─ 표준 3 (참조)
엘리먼트 B ── 표준 2 (참조)
이렇게 바꾸면 같은 엘리먼트는 단 한 번만 등록되고, 어느 표준이 이 엘리먼트를 사용하는지를 N:M으로 추적합니다.
이 결정과 함께 같이 정리한 항목들:
- 관리자 직접 등록 경로 추가 — 신청/심사/승인 절차를 거치지 않고 관리자가 직접 엘리먼트를 등록할 수 있게 함. 마이그레이션 과정에서 필요.
- "참조 표준데이터" 항목 제거 — 역전된 구조에서는 의미가 사라짐. UI에서 들어냄.
- 27년 OID 자동 부여는 장기 과제로 분리 — 단기 마이그레이션과 묶지 않고 별도 트랙으로.
3.4 마이그레이션 분담
- 백엔드: 기존 표준 단위로 흩어져 있던 엘리먼트를 마스터로 통합. 동일성 판단(이름/단위/타입 같은 키)으로 머지.
- 프론트: "엘리먼트 먼저 검색 → 어디에 쓸지 결정"하는 등록 동선으로 화면 재구성. 이전에는 표준을 먼저 만들고 그 아래 엘리먼트를 넣었지만, 이제는 반대.
3.5 교훈
- 트리/그래프 방향은 "어느 쪽이 마스터인가"를 가장 먼저 정해야 합니다. 화면 동선이 마스터 위치를 거꾸로 만들면 데이터가 산발됩니다.
- 장기 과제는 단기 마이그레이션과 분리합니다. 같은 PR/스프린트에 묶으면 가장 위험한 부분(마스터 통합)이 비중요한 항목(자동 부여)에 끌려갑니다.
4. 두 케이스의 공통 패턴
| 항목 | 케이스 1 (취소) | 케이스 2 (OID) |
|---|---|---|
| 무엇이 잘못 되어 있었나 | 정책이 비어 있는 채로 버튼만 동작 | 도메인의 관계 방향이 거꾸로 |
| 어떻게 드러났나 | 운영 중 비정상 상태 발견 | 운영 중 데이터 중복 발견 |
| 코드는 에러 없었나 | 네, 멀쩡히 동작했음 | 네, 멀쩡히 동작했음 |
| 누가 발견했나 | 심사자/관리자 | 데이터 정합성 검토 |
공통점은 단순합니다. "문서/회의로는 안 보이고, 실제로 굴려봐야 드러나는 결함"이라는 점.
선반영이 그래서 의미가 있어요. 정책이 굳지 않은 상태에서 일단 만들어 보면 위와 같은 결함이 빠르게 떠오릅니다. 다만 떠오른 것을 얼마나 빠르게 회수하느냐가 선반영의 가치를 결정합니다. 회수가 늦으면 사용자가 그걸 기본 동작으로 학습해버려서 되돌리기가 어려워집니다.
5. 다음에 비슷한 일이 생기면
- 프론트엔드 선반영 시 → 피처 플래그/내부 전용 노출 가드 필수
- 도메인 모델 검토 시 → "어느 쪽이 마스터(원본)이냐"부터 한 줄로 못 박고 화면을 그린다
- 단기/장기 과제는 명시적으로 분리, 같은 PR에 묶지 않는다
- 의심 가는 상태(
None, 중복 행)는 가드로 닫고 경고 로그 남기기
6. 마치며
두 이슈 모두 "코드가 잘못된 것"이 아니라 "전제가 잘못된 것"이었다는 점이 공통이었습니다. 다음 분기를 시작하기 전에, 우리가 만들고 있는 시스템의 "마스터가 어디인지"를 다시 한 번 그려보는 시간을 가지려고 합니다. 이게 가장 비용 효율이 좋은 사전 정비라는 걸 이번 두 케이스가 보여줬다고 느꼈어요.