Skip to content
메모장
Go back

FK 없는 스키마 설계 — 데이터 무결성을 애플리케이션에 넘긴 이유

개요

최근 진행하고 있는 프로젝트에서는 FK를 거의 사용하지 않는다. users 테이블을 참조하는 FK 제약이 프로젝트 전체에 단 하나도 없다. 여기서는 이에 관련된 이야기를 정리한다.

-- 실제로 FK가 걸린 곳들 (users 테이블과 무관)
help_images.help_id       → helps(id)
help_categories.help_id   → helps(id)
review_images.review_id   → reviews(id)
chat_images.chat_message_id → chat_messages(id)
chat_room_participants.room_id → chat_rooms(id)

Help.writerId, Review.reviewerId, ChatMessage.senderId, Participant.userId는 전부 단순 UUID 컬럼이다. 흥미로운 건 완전히 FK를 안 쓰는 게 아니라는 점이다 — 같은 애그리거트 내부(예: help_images → helps)에는 FK가 걸려 있다. FK가 사라지는 지점은 정확히 바운디드 컨텍스트를 넘어가는 참조다.

이 글은 세 가지를 정리한다


1. FK의 일반적인 장단점

FK를 걸지 말지는 이 프로젝트만의 특수한 고민이 아니라, RDB를 쓰는 모든 시스템이 마주치는 선택이다. 먼저 FK 자체의 장단점을 정리해두면 이 프로젝트의 선택이 어떤 트레이드오프를 감수한 건지 더 명확해진다.

장점

단점


2. FK를 안 쓰는 이유 — 바운디드 컨텍스트 경계

이 프로젝트는 Gradle 멀티모듈로 iam, help, review, communication 등을 나눈 DDD 구조다. 모듈은 나눠져 있어도 같은 PostgreSQL 인스턴스, 같은 스키마를 공유한다

그런데도 안 걸려있다는 건, 위에서 정리한 단점 중 정확히 모듈 간 결합도 문제를 피하겠다는 방향과 맞아떨어진다. FK가 있었다면:

help/review/communication 모듈이 마이그레이션 순서상 iamusers 테이블 존재에 의존하게 된다. 유저를 지우려면 참조하는 모든 테이블을 먼저(또는 CASCADE로) 처리해야 하는데, 그 처리 순서가 DB 제약으로 고정된다. 모듈 간 스키마 변경이 서로의 마이그레이션에 영향을 준다.

반면 같은 애그리거트 내부(help_images → helps)에는 FK를 그대로 남겨뒀다


3. 애플리케이션이 떠안은 책임과 비용

FK를 안 쓰기로 한 순간, 위에서 FK가 공짜로 해주던 것들(참조 무결성 보장, 즉시 실패, 삭제 순서 강제)을 애플리케이션이 대신 구현해야 한다. 이게 추상적인 이야기가 아니라는 걸 회원탈퇴 기능을 만들면서 직접 확인했다. FK가 없다는 건 “삭제 순서를 DB가 안 지켜준다”는 뜻이다. 회원탈퇴 시 User row를 지우면:

FK가 있었다면 “참조하는 row가 남아있는데 User를 못 지운다”는 형태로 삭제 시점에 즉시 실패했을 텐데, FK가 없으니 삭제는 조용히 성공하고 문제는 나중에 터진다.

대체 수단 — 도메인 이벤트 기반 크로스모듈 정리

FK가 대신하던 무결성 보장을 여기서는 도메인 이벤트 기반 크로스모듈 정리로 대체했다.

User.markWithdraw() → UserWithdrawn 이벤트 등록
  → help/review/communication이 각자 구독해서 자기 데이터 정리

이게 FK CASCADE보다 나은 점도 있다

  • 각 모듈이 “삭제”가 아니라 “익명화” 같은 다른 처리를 선택할 여지가 생기고, 삭제 로직이 SQL 레벨이 아니라 각 도메인의 규칙을 따를 수 있다.

반대로 FK CASCADE보다 확실히 나쁜 점도 있다

  • CASCADE는 트랜잭션 하나로 원자적으로 끝나지만, 이벤트 기반은 여러 트랜잭션·여러 모듈에 걸쳐 진행되고 중간에 실패할 수 있다. 그래서 회원탈퇴 기능에서 완료추적(어떤 모듈이 정리를 마쳤는지, Redis Set으로 추적)과 재시도를 별도로 설계해야 했다. FK가 공짜로 주던 원자성을, 애플리케이션이 직접 추적 로직을 만들어서 흉내 내야 했던 셈이다.

핵심 트레이드오프 요약

FK로 강제FK 없이 애플리케이션이 처리
모듈 독립성낮음 — 마이그레이션·스키마가 서로 얽힘높음 — 각 모듈이 독립적으로 배포/변경 가능
삭제 시 정합성DB가 즉시, 시끄럽게 보장애플리케이션이 이벤트로 나중에, 비동기로 보장
실패 시 원자성트랜잭션 하나로 원자적여러 트랜잭션에 걸쳐 진행, 부분 실패 가능 → 완료추적/재시도 필요
삭제 정책의 유연성CASCADE 삭제로 고정모듈별로 삭제/익명화 등 다르게 선택 가능
적용 범위같은 애그리거트 내부는 FK 유지, 바운디드 컨텍스트 경계만 제거

FK를 없애는 선택 자체가 틀렸다고 생각하진 않는다


Share this post:

Previous Post
쿼리 - 효율적인 처리 와 N+1
Next Post
Port/Adapter 인터페이스, 외부와의 연결