schemalism
PART 04·of 04

어디서 왔든 한 곳에서 끝나야 한다 — 폴리모피즘으로 풀은 다중 출처

BBS·문화·리스크·사고 — 부적합 출처는 늘 늘어납니다. (source_module, source_entity_id) 두 컬럼으로 다중 출처를 통합한 폴리모피즘 패턴, 그리고 한 번 결정을 뒤집어 routine 모듈을 분리한 이야기.

2026년 7월 26일 · 정윤환 · 7

회사의 부적합은 한 군데서만 나오지 않습니다.

  • BBS 관찰에서 “안전 미준수 행동” 으로 표시된 항목
  • 조직문화 진단 에서 응답자가 던진 개선제안
  • 리스크 평가 에서 발견된 위험 통제 미흡
  • 사고 보고 에서 도출된 시정/예방조치
  • 그리고 직접 등록 (현장에서 안전팀장이 본 것)

이걸 모두 다른 모듈에서 관리하면, 임원이 “이번 달 미해결 부적합 몇 건이지?” 한 질문에 화면 5개를 띄워야 합니다. 그래서 우리는 부적합은 한 모듈에서 끝낸다 는 원칙을 세웠습니다.

01 · Why not per-source FK

출처 컬럼을 모듈별로 안 둔 이유

흔한 방법 1 — 출처별 FK 컬럼.

python
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

우리 선택 — 폴리모피즘 한 쌍

Many sources → one module
(source_module, source_entity_id) 두 컬럼으로 수렴
python
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 행동 평가 하나를 보고 “이건 조치해야겠다” 결정합니다. 누른 순간 우리가 해야 할 일은 두 가지.

  1. 같은 obs_behavior 에서 이미 만들어진 활동이 있는지 확인 (중복 등록 방지)
  2. 없으면 새 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 는 부적합 출처와 정기점검 출처 중 하나” 로 구분했습니다.

한 회차 운영해 보고 결정을 뒤집었습니다. 이유는 두 가지였어요.

  1. 라이프사이클이 너무 다릅니다. 부적합 조치는 효과성 검증 / 승인 / 외부 담당자 / 캔버스 좌표 같은 부적합 전용 필드가 많은데, 정기점검에는 다 불필요합니다. 한 테이블에 두 의미를 담는 부담이 더 컸어요.
  2. 운영 화면이 다릅니다. 정기점검은 캘린더·체크리스트 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

정리 — 부적합은 한 번에 만들어진 시스템이 아닙니다

  1. PART 01

    한 줄로 끝낼 수 없으니 활동·부적합·원인·조치를 4단계 엔티티로 쪼갰다

  2. PART 02

    트리 구조를 살리려고 폼/리스트 대신 캔버스를 골랐고, 좌표를 데이터로 박았다

  3. PART 03

    ‘완료’가 시점이 아니라 사슬임을 인정하고, 효과성·재조치 chain·자동 상태 재계산까지 모델에 들였다

  4. PART 04

    출처 다양성은 폴리모피즘으로 입구를 통합하되, 운영 라이프사이클이 다르면(routine) 모듈을 분리했다

Next

다음 시리즈는 측정 위에 얹는 지표 — 부적합 재발 위험, 출처별 종결률, 사이트별 효과성 — 가 어떻게 같은 데이터 모델 위에서 자연스럽게 떨어지는지.