schemalism
PART 02·of 04

행동 한 줄에 컬럼 40개 — 역정규화는 빠르기가 아니라 '그때 그 모습'의 보존이다

통계 빠르기는 부차적인 이유. 진짜 이유는 사람과 팀이 떠난 뒤에도 '그때 그 조직과 그때 그 위험등급'이 살아남아야 한다는 것. ID + permanent_id 동시 보관, 위험등급 스냅샷, 1:N 별도 테이블을 1:1 인라인으로 뒤집은 이야기.

2026년 8월 9일 · 정윤환 · 7

BBS 모듈을 처음 본 신규 합류자가 가장 놀라는 모델이 BbsObsBehavior 입니다.

python
class BbsObsBehavior(Base):
    """관찰 행동 기록"""

    # 관찰자(observer) 측 조직 계층 — 7개 컬럼
    observer_org_site_id, observer_org_site_permanent_id,
    observer_org_site_team_id, observer_org_site_team_permanent_id,
    observer_department_id, observer_department_permanent_id,
    observer_org_member_id, observer_org_member_permanent_id,
    observer_is_subcontractor

    # 관찰 대상(target) 측 조직 계층 — 같은 구조로 또 7개
    target_org_site_id, target_org_site_permanent_id,
    target_org_site_team_id, target_org_site_team_permanent_id,
    target_department_id, target_department_permanent_id,
    target_org_member_id, target_org_member_permanent_id,
    target_is_subcontractor

    # 행동 본문 (이름, 설명, 이미지 2종)
    name, description, thumbnail_image_filepath, original_image_filepath

    # 분류 (risk/core 마스터 참조 — ID 6개 + sub_*_ids 3개)
    risk_core_event_id, risk_core_sub_event_ids,
    risk_core_hazard_id, risk_core_sub_hazard_ids,
    risk_core_control_id, risk_core_sub_control_ids

    # 위험등급 — 스냅샷 정책
    risk_severity_level, risk_possibility, risk_criteria_type,
    risk_level_name, risk_level_bg_color

    # 분석 자동화 worker 추적
    risk_analysis_status, risk_analysis_attempt_count,
    risk_analysis_last_attempted_at, risk_analysis_failure_reason,
    is_risk_analysis_ai_generated

    # 평가
    behavior_result, is_commendable, need_action, action_content, memo
    ...

세지 않아도 분명히 많습니다. 정규화 교과서대로라면 이 중 절반은 join 으로 빠질 수 있습니다. 그런데 우리는 정반대로 갔습니다. 왜인지 한 컬럼씩 풀어보겠습니다.

40 columns on one row
역정규화는 빠르기가 아니라 보존이다

01 · Query frequency

첫 번째 이유 — 통계 쿼리 빈도

BBS 운영 화면은 행동 을 기준으로 모이고 흩어집니다.

  • 사업장별 SAFE/UNSAFE 비율
  • 팀별 칭찬 비율
  • 부서별 협력사 vs 원청 비교
  • 위험 이벤트별 분포
  • 작업 영역별 패턴

이 질문에 답하려면 “행동 한 줄을 사업장·팀·부서·멤버 ID 로 잘라야” 합니다. 매번 bbs_obs 와 join 해서 조직 계층을 따라가면, 대시보드 한 장에 join 이 10개씩 쌓입니다. 그래서 obs 가 가진 컨텍스트를 자식 행동까지 cascade denormalize 했습니다. 이건 흔한 결정입니다.

02 · People leave

진짜 이유는 — 사람과 팀은 떠난다

흔한 결정이라고 한 위의 이유는 사실 부차적입니다. 진짜 결정적인 이유는 따로 있어요.

조직은 살아 움직입니다. 부서는 통폐합되고, 팀은 사라지고, 멤버는 이직합니다.

작년 가을, 박 과장이 김 사원의 안전모 미착용을 SAFE 로 평가했다 — 이 사실은 박 과장이 회사를 나가고 김 사원이 다른 팀으로 옮긴 뒤에도 살아남아야 합니다. 통계 화면을 띄울 때마다 그때 그 조직 트리 가 그대로 보여야 합니다.

그래서 ID 와 함께 *_permanent_id 도 같이 박았습니다. ID 는 현재 운영용, permanent_id 는 통계 그룹핑용. 팀이 통폐합되면 team_id 는 새 팀을 가리키게 되지만, team_permanent_id 는 그때 그 팀을 영원히 가리킵니다. 두 종류 ID 가 같이 박혀 있어야 “지금 어디 소속인지” “그때 어디 소속이었는지” 가 동시에 답변됩니다.

03 · Both sides of subcontractor

협력사 플래그도 양쪽 모두

python
observer_is_subcontractor = Column(Boolean, nullable=True,
    comment='관찰자 협력업체 여부. 원청/협력사 통계 그룹핑용 (스냅샷)')
target_is_subcontractor   = Column(Boolean, nullable=True,
    comment='관찰 대상 여부. True: 외부 업체 직원, False: 내부 직원')

처음엔 target 측만 두었습니다. “누가 관찰됐는가” 만 봐도 협력사 안전 수준은 잡힙니다. 그런데 운영 두 달째 “원청 멤버끼리 관찰한 비율 vs 협력사 멤버끼리 관찰한 비율” 이라는 새 질문이 나왔습니다. 원청이 협력사를 안 보고, 협력사가 원청을 안 보는 사업장은 BBS 가 사실상 두 동선으로 갈라져 있어요. 이걸 잡으려면 관찰자 쪽도 협력사 플래그가 필요했습니다.

2026-04-27, observer_is_subcontractor 를 6개 테이블에 한꺼번에 보강했습니다. 한 번 보강하면 끝이라는 게 역정규화의 장점이자 단점입니다 — 보강 시점 이전 데이터는 NULL 입니다. 그래서 통계 쿼리는 “NULL 은 미확정으로 별도 집계” 가 디폴트입니다. 데이터 모델은 운영의 흔적을 숨길 수 없고, 우리는 숨기지 않기로 했습니다.

04 · Risk grade snapshot

위험등급 — ID 가 아니라 스냅샷

가장 의도적인 결정은 위험등급입니다. 위험등급 마스터 (OrgRiskLevelCriteria) 가 따로 있는데도, 우리는 risk_level_id 를 박지 않았습니다. 대신 이름과 색상을 박았어요.

python
risk_severity_level   = Column(Integer,     comment='위험 강도 (1~5). 원시 입력 보존')
risk_possibility      = Column(Integer,     comment='발생 가능성. 원시 입력 보존')
risk_criteria_type    = Column(String(40),  comment='등록 시점 기준 유형 스냅샷')
risk_level_name       = Column(String(50),  comment='등록 시점 위험등급명 스냅샷')
risk_level_bg_color   = Column(String(20),  comment='등록 시점 위험등급 배경색 스냅샷')

왜냐하면 위험등급 기준은 바뀝니다.

  • 관리자가 “중간” 의 색깔을 노랑에서 주황으로 바꿉니다 → 작년 관찰의 색깔도 바뀌면 안 됩니다.
  • 관리자가 “보통”“주의” 로 이름만 바꿉니다 → 작년 관찰의 카드 라벨도 바뀌면 안 됩니다.
  • 관리자가 기준 자체를 SEVERITY5_POSSIBILITY5 에서 SEVERITY5_POSSIBILITY4 로 바꿉니다 → 작년 관찰을 다시 보면 어떤 기준으로 평가됐는지가 안 맞아요.

세 가지 모두 “과거의 모습 그대로 보여야 한다” 가 답입니다. 그래서 표시값은 스냅샷.

그런데 그러면 “새 기준으로 일괄 재평가” 는 불가능해질까요? 그래서 원시 입력값인 risk_severity_levelrisk_possibility 도 같이 박았습니다. 표시값은 스냅샷이지만, 원시 입력은 살아 있어서 “필요할 때 새 기준으로 재산출 가능” 합니다.

표시값과 원시값을 둘 다 보관하는 건 일견 중복으로 보이지만, 표시는 그때의 약속이고 재산출은 미래의 가능성 이라 두 종류가 합쳐서 의미가 됩니다.

05 · One reversal

결정을 한 번 뒤집은 이야기 — 1:N 별도 테이블의 deprecate

처음에는 행동 : 위험 이벤트를 1:N 로 두었습니다. BbsObsBehaviorRiskEvent 라는 별도 테이블에 위험 이벤트·하위 이벤트·위험요소·통제조치를 같이 두었어요.

rel
BbsObsBehavior (1) ──→ (N) BbsObsBehaviorRiskEvent

운영 두 분기쯤 돌고 두 가지를 발견했습니다.

  • 기획이 “행동마다 위험 이벤트 1개” 로 정리됐습니다. 1:N 은 우리가 만든 가능성이지, 실제 사용자가 쓰는 패턴이 아니었어요.
  • 별도 테이블에 observer/target 스냅샷 40 컬럼을 또 복사하고 있었습니다. BbsObsBehavior 와 정확히 같은 컬럼 묶음이 한 번 더. 1:N 이 1:1 로 굳어지자 별도 테이블의 명분이 사라지고, 비효율만 남았습니다.

2026-05-04, 분류·평가 컬럼 11개를 BbsObsBehavior 에 인라인하고, BbsObsBehaviorRiskEvent 는 deprecate 했습니다. 즉시 drop 하지 않고 deprecate 만 한 이유는 기존 데이터/코드 호환을 위해서입니다. 신규 코드는 BbsObsBehavior 컬럼만 사용하고, 추후 cleanup 단계에서 테이블 폐기합니다.

가능성보다 현재의 카디널리티를 믿기로 했습니다.

Next

다음 편 — BBS 의 가장 인간적인 부분 — 피드백 — 이 어떻게 AI 초안과 사람이 실제 전달한 한마디로 갈라지는지.