개요
최근 진행하고 있는 프로젝트에서는 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가 사라지는 지점은 정확히 바운디드 컨텍스트를 넘어가는 참조다.
이 글은 세 가지를 정리한다
- 이 프로젝트가 왜 FK를 안 쓰기로 한 이유
- FK 자체가 일반적인 장단점
- 그 대가로 애플리케이션이 떠안게 된 책임과 비용
1. FK의 일반적인 장단점
FK를 걸지 말지는 이 프로젝트만의 특수한 고민이 아니라, RDB를 쓰는 모든 시스템이 마주치는 선택이다. 먼저 FK 자체의 장단점을 정리해두면 이 프로젝트의 선택이 어떤 트레이드오프를 감수한 건지 더 명확해진다.
장점
- 참조 무결성을 DB가 보장한다. 존재하지 않는 부모 row를 가리키는 자식 row가 생기는 걸 애초에 막는다.
- 실패가 즉시, 명확하게 일어난다. 참조하는 row가 남아있는데 부모를 지우려 하면 그 순간 DB가 에러를 낸다
- 애플리케이션 버그가 몇 주 뒤 “이상한 null” 형태로 늦게 발견되는 대신, 원인 행위 시점에 바로 드러난다.
- CASCADE로 연쇄 처리를 선언적으로 기술할 수 있다. 부모 삭제 시 자식도 같이 지우거나 null로 바꾸는 로직을 애플리케이션 코드 없이 스키마 자체에 담을 수 있다.
- 스키마가 곧 문서가 된다. ERD 생성 도구, DB 클라이언트가 FK만 보고도 테이블 간 관계를 자동으로 그려준다.
단점
- 스키마 변경/배포 순서가 강제된다. 부모 테이블이 먼저 존재해야 자식 테이블을 만들 수 있고, 마이그레이션 순서가 테이블 생성 순서에 종속된다.
- 모듈/서비스 간 결합도가 올라간다. 다른 모듈이 소유한 테이블에 FK를 걸면, 그 순간 내 스키마가 DB 레벨에서 그 모듈에 묶인다
- 상대 모듈이 테이블 구조를 바꾸면 내 마이그레이션도 영향을 받는다.
- 대량 데이터 재구성(샤딩, 테이블 분리, 파티셔닝)에서 걸림돌이 된다. FK가 걸린 테이블은 물리적으로 재배치하기 어렵다.
- 삭제 정책이 CASCADE 하나로 고정되기 쉽다. “삭제 대신 익명화”, “테이블마다 다른 정리 방식” 같은 걸 표현하려면 결국 트리거나 애플리케이션 코드가 다시 필요해진다.
- 다형적 연관관계를 표현할 수 없다. 한 컬럼이 여러 타입의 테이블 중 하나를 가리켜야 하는 경우(예: 알림 대상이 게시글일 수도, 리뷰일 수도 있는 경우) FK로는 못 담고 결국 애플리케이션이 관리해야 한다.
2. FK를 안 쓰는 이유 — 바운디드 컨텍스트 경계
이 프로젝트는 Gradle 멀티모듈로 iam, help, review, communication 등을 나눈 DDD 구조다.
모듈은 나눠져 있어도 같은 PostgreSQL 인스턴스, 같은 스키마를 공유한다
- 그래서 물리적으로는
help.writer_id에REFERENCES users(id)를 거는 게 기술적으로 불가능하지 않다.
그런데도 안 걸려있다는 건, 위에서 정리한 단점 중 정확히 모듈 간 결합도 문제를 피하겠다는 방향과 맞아떨어진다. FK가 있었다면:
help/review/communication모듈이 마이그레이션 순서상iam의users테이블 존재에 의존하게 된다. 유저를 지우려면 참조하는 모든 테이블을 먼저(또는 CASCADE로) 처리해야 하는데, 그 처리 순서가 DB 제약으로 고정된다. 모듈 간 스키마 변경이 서로의 마이그레이션에 영향을 준다.
반면 같은 애그리거트 내부(help_images → helps)에는 FK를 그대로 남겨뒀다
- 이건 “같은 모듈, 같은 배포 단위” 안에서는 위 결합도는 문제가 되지 않는다.
- 즉 이 프로젝트의 규칙은 “FK를 안 쓴다”가 아니라 **“바운디드 컨텍스트 경계를 넘는 곳에서만 안 쓴다”**에 가깝다.
3. 애플리케이션이 떠안은 책임과 비용
FK를 안 쓰기로 한 순간, 위에서 FK가 공짜로 해주던 것들(참조 무결성 보장, 즉시 실패, 삭제 순서 강제)을 애플리케이션이 대신 구현해야 한다. 이게 추상적인 이야기가 아니라는 걸 회원탈퇴 기능을 만들면서 직접 확인했다. FK가 없다는 건 “삭제 순서를 DB가 안 지켜준다”는 뜻이다. 회원탈퇴 시 User row를 지우면:
- 이 시점에
Help.writerId,Review.reviewerId,ChatMessage.senderId가 가리키던 UUID는 더 이상 존재하지 않는 유저를 가리키는 고아 참조가 된다. - DB는 이걸 막아주지도, 자동으로 처리해주지도 않는다.
WriterProfileAdapter처럼findById로 라이브 조회하던 코드는 유저가 사라진 순간부터 조용히 null을 반환하거나, null 처리가 안 돼 있으면 에러를 낸다.
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를 없애는 선택 자체가 틀렸다고 생각하진 않는다
- 다만 그 대가로 “삭제 정합성을 애플리케이션이 떠안는다”는 게 추상적인 이야기가 아니라, 회원탈퇴처럼 여러 모듈에 걸친 삭제가 실제로 필요해지는 순간 곧바로 구체적인 설계 비용(이벤트 리스너, 완료추적, 재시도)으로 돌아온다는 걸 이번에 직접 확인했다.
- FK가 하던 일을 애플리케이션이 대신하기로 한 이상, 그 비용을 “가끔 겪는 버그”가 아니라 “설계에 처음부터 포함해야 하는 항목”으로 보는 게 맞는 것 같다.
- 이는 마이크로 서비스에서의 “결과론적 일관성”, “코레오그레피 사가패턴”, “분산 트랜젝션” 등에서도 자주 나오게 되는 트레이드 오프이다.