이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 실제 기능 요구사항을 테이블 관계로 바꿀 때 어떤 질문을 해야 하는가?
- FK, cascade, nullable 관계는 어떤 기준으로 판단하는가?
- 관계 모델링 실수는 운영에서 어떤 문제로 드러나는가?
개요
관계 모델링은 ERD 도형을 맞추는 작업이 아니다. 데이터 생명주기, 조회 방향, 삭제 정책, 정합성 요구사항을 테이블 관계로 표현하는 일이다.
좋은 관계 모델은 서비스 코드가 덜 의심하게 만든다. 잘못된 관계 모델은 조인, N+1, 고아 데이터, 삭제 장애로 드러난다.
원리
관계 모델링의 원리는 “어떤 객체가 어떤 객체를 참조하는가”가 아니라 “데이터의 생명주기, 소유권, 삭제 정책, 조회 방향을 DB가 이해할 수 있는 형태로 남기는 것”이다. 코드에서는 order.getItems()처럼 자연스러워 보여도, DB에서는 FK 검증, 조인 비용, 삭제 순서, 인덱스, 고아 데이터 가능성으로 드러난다.
백엔드 개발자는 관계를 설계할 때 JPA 연관관계 편의와 DB 참조 무결성을 분리해서 봐야 한다. JPA cascade는 영속성 컨텍스트 안의 entity 상태 전이를 돕는 기능이고, DB cascade는 실제 FK 삭제 동작이다. 둘 다 강력하지만, 생명주기 판단 없이 붙이면 운영 데이터 삭제 사고로 이어진다.
먼저 던질 질문
관계를 설계할 때 다음을 묻는다.
- 두 데이터의 생명주기가 같은가?
- 한쪽 없이 다른 쪽이 존재할 수 있는가?
- 삭제 시 함께 삭제해야 하는가?
- 조회는 어느 방향이 많은가?
- 관계 수가 최대 얼마나 커지는가?
- 관계 자체에 속성이 있는가?
- 과거 snapshot이 필요한가?
이 질문이 FK nullable, cascade, 중간 테이블, snapshot 컬럼 선택으로 이어진다.
관계 설계 리뷰에서는 질문을 결정표로 바꿔보면 좋다.
| 질문 | 설계에 미치는 영향 |
|---|---|
| 부모 없이 자식이 존재할 수 있는가? | FK nullable 여부, soft delete, 익명화 |
| 부모 삭제가 자식 삭제를 의미하는가? | ON DELETE 정책, JPA cascade 사용 여부 |
| 관계 수가 계속 늘어나는가? | 중간 테이블, pagination, 인덱스 필요성 |
| 관계 당시 값을 보존해야 하는가? | snapshot 컬럼 또는 이력 테이블 |
| 어느 방향으로 자주 조회하는가? | FK 컬럼과 정렬 컬럼의 복합 인덱스 |
Nullable 관계
주문은 회원이 탈퇴해도 보존해야 할 수 있다. 이때 FK를 nullable로 둘지, 회원을 익명화할지, 별도 snapshot을 둘지 정해야 한다.
CREATE TABLE orders (
id bigint PRIMARY KEY,
member_id bigint REFERENCES members(id),
member_email_snapshot varchar(255) NOT NULL
);회원 삭제와 주문 보관 정책은 법적 요구사항과 도메인 규칙을 함께 봐야 한다.
Cascade
Cascade는 편하지만 위험하다.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;주문과 주문 항목처럼 생명주기가 정말 같으면 적합할 수 있다. 그러나 회원 삭제 시 주문까지 cascade remove되면 치명적일 수 있다.
Cascade는 관계 편의가 아니라 생명주기 동일성의 표현이어야 한다.
실무에서는 “삭제 버튼을 눌렀을 때 무엇이 지워져야 하는가”를 SQL로 써보면 위험이 빨리 보인다.
SELECT count(*)
FROM orders
WHERE member_id = :memberId;회원 삭제 전에 이 count가 큰데 주문이 함께 사라진다면, 그것은 기능 구현이 아니라 데이터 보관 정책 변경이다. 인프라/DBA와 이야기할 때도 “cascade를 켤까요?”보다 “부모 삭제 시 자식 120만 건이 삭제될 수 있고, WAL/binlog와 replica lag가 증가할 수 있습니다”라고 말해야 한다.
관계 자체의 속성
회원과 팀의 관계에 역할과 가입일이 있다면 단순 N:M이 아니라 membership 엔티티다.
CREATE TABLE team_memberships (
id bigint PRIMARY KEY,
team_id bigint NOT NULL REFERENCES teams(id),
member_id bigint NOT NULL REFERENCES members(id),
role varchar(20) NOT NULL,
joined_at timestamp NOT NULL,
CONSTRAINT uq_team_member UNIQUE (team_id, member_id)
);관계에 속성이 생기면 독립 테이블로 승격하는 것이 자연스럽다.
실전 팁
- 관계 설계는 삭제 정책과 함께 한다.
- cascade는 생명주기가 정말 같은 경우에만 사용한다.
- N:M은 중간 테이블로 풀고, 관계 속성을 담을 여지를 본다.
- 조회가 많은 방향에는 인덱스를 둔다.
- 과거 기록은 현재 관계보다 snapshot이 필요할 수 있다.
- JPA cascade와 DB
ON DELETE CASCADE를 같은 것으로 취급하지 않는다. - FK를 nullable로 두면 “아직 모름”, “삭제됨”, “외부 데이터라 없음” 중 무엇인지 도메인 의미를 이름이나 상태값으로 드러낸다.
- 관계 변경 작업은 삭제 대상 건수, FK 검증 비용, lock 범위, 로그 증가량을 함께 검토한다.
위험 신호!
- 회원 삭제 시 주문이 cascade로 삭제된다.
- 관계에 속성이 있는데 단순 연결 테이블로 숨긴다.
- nullable FK 의미를 아무도 설명하지 못한다.
- 삭제 정책이 화면 요구사항에 따라 매번 바뀐다.
- 조회 방향과 인덱스가 맞지 않는다.
- JPA에서
cascade = ALL을 편의로 붙이고 실제 DB 삭제 범위를 계산하지 않는다. - 회원 탈퇴, 주문 보관, 개인정보 삭제 요구가 서로 충돌하는데 관계 모델에 정책이 드러나지 않는다.
확인 질문
- Cascade를 사용할 때 가장 먼저 확인할 것은 무엇인가?
- 부모와 자식의 생명주기가 정말 같은지, 부모 삭제가 자식 삭제를 의미하는지 확인해야 한다.
- 관계에 속성이 있으면 어떻게 모델링하는 것이 좋은가?
- 중간 관계 자체를 독립 엔티티 테이블로 승격해 속성과 제약을 명시한다.
- 주문에 회원 snapshot이 필요할 수 있는 이유는 무엇인가?
- 회원 정보가 바뀌거나 탈퇴해도 주문 당시 계약 정보를 보존해야 하기 때문이다.
- JPA cascade와 DB cascade를 구분해야 하는 이유는 무엇인가?
- JPA cascade는 entity 상태 전이를 전파하는 애플리케이션 기능이고, DB cascade는 FK 기준으로 실제 row 삭제나 갱신을 수행하므로 적용 범위와 장애 영향이 다르기 때문이다.