어디서 왔든 한 곳에서 끝나야 한다 — 폴리모피즘으로 풀은 다중 출처
BBS·문화·리스크·사고 — 부적합 출처는 늘 늘어납니다. (source_module, source_entity_id) 두 컬럼으로 다중 출처를 통합한 폴리모피즘 패턴, 그리고 한 번 결정을 뒤집어 routine 모듈을 분리한 이야기.
2026년 7월 26일 · 정윤환 · 7분
회사의 부적합은 한 군데서만 나오지 않습니다.
- BBS 관찰에서 “안전 미준수 행동” 으로 표시된 항목
- 조직문화 진단 에서 응답자가 던진 개선제안
- 리스크 평가 에서 발견된 위험 통제 미흡
- 사고 보고 에서 도출된 시정/예방조치
- 그리고 직접 등록 (현장에서 안전팀장이 본 것)
이걸 모두 다른 모듈에서 관리하면, 임원이 “이번 달 미해결 부적합 몇 건이지?” 한 질문에 화면 5개를 띄워야 합니다. 그래서 우리는 부적합은 한 모듈에서 끝낸다 는 원칙을 세웠습니다.
01 · Why not per-source FK
출처 컬럼을 모듈별로 안 둔 이유
흔한 방법 1 — 출처별 FK 컬럼.
class ActionActivity(Base):
source_bbs_observation_id = Column(String(36), nullable=True)
source_culture_improvement_id = Column(String(36), nullable=True)
source_risk_event_id = Column(String(36), nullable=True)
source_incident_id = Column(String(36), nullable=True)
...출처가 늘 때마다 스키마 마이그레이션. 출처별로 코드 분기 if 가 늘어납니다. 7개쯤 되면 코드가 더는 못 견딥니다.
02 · Polymorphic pair
우리 선택 — 폴리모피즘 한 쌍
class ActionActivity(Base):
source_module = Column(
Enum(ActionActivitySourceModule),
nullable=True,
index=True,
comment="활동의 외부 출처 모듈 (예: BBS_OBS_BEHAVIOR). NULL 이면 사용자가 직접 등록",
)
source_entity_id = Column(
String(36),
nullable=True,
index=True,
comment="source_module 의 대상 엔티티 ID. 모듈 간 참조라 FK 없음",
)두 컬럼 한 쌍이 “어떤 외부 모듈의 어떤 엔티티에서 파생됐는지” 를 표현합니다. 새 출처(예: RISK_CORE_EVENT)를 추가할 때 스키마는 그대로, enum 값 + 신규 코드 경로만 추가합니다.
단점은 분명합니다. referential integrity 가 없습니다. DB 레벨에서 BBS 행이 사라져도 action 쪽은 알 수 없어요. 그런데 우리 코드 컨벤션이 “모듈 간 FK 금지” 이라, 어차피 FK 를 둘 수도 없었습니다 — 모듈 분리의 비용과 폴리모피즘의 비용이 정합합니다. 그 자리에서 받아들였습니다.
03 · A real scene
Polymorphic 이 실제 동작에 풀리는 한 장면
BBS observation-detail 화면에는 “조치 등록”버튼이 있습니다. 사용자가 BBS 행동 평가 하나를 보고 “이건 조치해야겠다” 결정합니다. 누른 순간 우리가 해야 할 일은 두 가지.
- 같은 obs_behavior 에서 이미 만들어진 활동이 있는지 확인 (중복 등록 방지)
- 없으면 새 ActionActivity 를 만들되,
source_module = BBS_OBS_BEHAVIOR,source_entity_id = <bbs_obs_behavior.id>로 박아 둠
(source_module, source_entity_id) 두 컬럼에 인덱스가 있으니 cross-module lookup 한 번이면 됩니다. BBS 모듈은 자기가 어떤 action 으로 파생됐는지 모릅니다 — 알아야 할 쪽이 action 쪽이라, 단방향 polymorphic 으로 충분합니다.
04 · One decision reversed
그리고 한 번 뒤집은 결정 — Routine 분리
처음엔 정기점검(routine) 도 같은 모듈에 두었습니다. 매일·주간·월간 반복 점검이 부적합과 비슷해 보였거든요. ActionExecution 하나에 non_conformity_id 와 routine_id 를 둘 다 nullable 로 두고 “이 EXE 는 부적합 출처와 정기점검 출처 중 하나” 로 구분했습니다.
한 회차 운영해 보고 결정을 뒤집었습니다. 이유는 두 가지였어요.
- 라이프사이클이 너무 다릅니다. 부적합 조치는 효과성 검증 / 승인 / 외부 담당자 / 캔버스 좌표 같은 부적합 전용 필드가 많은데, 정기점검에는 다 불필요합니다. 한 테이블에 두 의미를 담는 부담이 더 컸어요.
- 운영 화면이 다릅니다. 정기점검은 캘린더·체크리스트 UI, 부적합은 캔버스 UI. 같은 EXE 가 두 가지로 그려지는 건 사용자에게 혼란만 줍니다.
2026-05-31, 정기점검을 common/routine 모듈로 완전 분리했습니다. ActionExecution.routine_id 제거, non_conformity_id NOT NULL 강제. ActionRoutine 관련 테이블 전부 drop, 새 모듈에서 RoutineExecution / RoutineExecutionStatus 신규 생성. 운영 전 데이터라 drop 후 재생성으로 끝났습니다.
05 · Two axes, orthogonal
두 결정이 직교한다는 걸 그제야 알았습니다
- “부적합의 출처가 여러 모듈” → 폴리모피즘으로 통합 (1개 모듈로 수렴)
- “라이프사이클이 본질적으로 다른 장르” → 모듈 분리 (2개 모듈로 분기)
처음에는 둘 다 “통합 vs 분리” 라는 같은 축의 결정으로 봤습니다. 그래서 routine 까지 한 모듈에 욱여넣었어요. 한 회차 운영해 보고 나서야 둘이 다른 차원이라는 게 보였습니다. 출처 다양성은 입구의 문제, 라이프사이클 차이는 운영의 문제. 입구는 한 곳에 모아도 운영은 갈라야 합니다.
06 · Recap
정리 — 부적합은 한 번에 만들어진 시스템이 아닙니다
- PART 01
한 줄로 끝낼 수 없으니 활동·부적합·원인·조치를 4단계 엔티티로 쪼갰다
- PART 02
트리 구조를 살리려고 폼/리스트 대신 캔버스를 골랐고, 좌표를 데이터로 박았다
- PART 03
‘완료’가 시점이 아니라 사슬임을 인정하고, 효과성·재조치 chain·자동 상태 재계산까지 모델에 들였다
- PART 04
출처 다양성은 폴리모피즘으로 입구를 통합하되, 운영 라이프사이클이 다르면(routine) 모듈을 분리했다
Next
다음 시리즈는 측정 위에 얹는 지표 — 부적합 재발 위험, 출처별 종결률, 사이트별 효과성 — 가 어떻게 같은 데이터 모델 위에서 자연스럽게 떨어지는지.