에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/데이터 & DB

[JPA] 연관관계 매핑 정리: 연관관계 주인 mappedBy, 양방향, cascade, orphanRemoval

반응형
[JPA] 연관관계 매핑 정리: 연관관계 주인 mappedBy, 양방향, cascade, orphanRemoval

[JPA] 연관관계 매핑 정리: 연관관계 주인 mappedBy, 양방향, cascade, orphanRemoval

JPA 연관관계에서 제일 헷갈리는 게 "주인"이에요. 분명 양쪽에 다 매핑했는데 외래키가 NULL로 저장돼요. mappedBy가 뭘 하는 건지 모르겠고요. cascade는 어디까지 걸어야 할지 막막하죠. 전부 한 가지 사실에서 출발하면 풀려요 — 객체는 참조가 두 개지만, 테이블의 외래키는 하나라는 것.

연관관계 주인이 왜 필요한지, 양방향 매핑의 함정, 연관관계 편의 메서드, cascade와 orphanRemoval, @ManyToMany를 피해야 하는 이유까지 하나씩 풀어요. 영속성 컨텍스트·지연 로딩 위에 서 있는 글이에요.

01. 단방향이 기본이에요

주문(Order)과 회원(User)이 있으면, 외래키는 orders 테이블의 user_id에 있어요. 매핑은 그 모양 그대로, 외래키를 가진 쪽에서 한 방향으로 거는 게 기본이에요.

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;          // Order → User 단방향
}

이것만으로 저장·조회가 다 돼요. 양방향(User에서 orders 목록 접근)은 "반대 방향 탐색이 정말 필요할 때"만 추가하는 옵션이에요. 무조건 양방향으로 시작하면 관리할 게 늘어나기만 해요.

02. 연관관계 주인, 외래키는 하나니까

양방향을 걸면 참조가 두 개가 돼요. Order.userUser.orders. 그런데 DB에는 관계를 표현하는 자리가 orders.user_id 하나뿐이에요. 두 참조 중 누가 이 외래키를 관리할지 정해야 해요. 그게 연관관계 주인이에요.

JPA 연관관계 주인 개념. 객체는 Order.user와 User.orders 두 참조가 있지만 테이블 외래키는 orders.user_id 하나뿐이라, 외래키를 가진 쪽이 주인이 되고 반대편은 mappedBy로 읽기 전용이 된다
@Entity
public class User {
    @OneToMany(mappedBy = "user")   // "주인은 Order.user다" 선언
    private List<Order> orders = new ArrayList<>();
}

규칙은 하나예요. 외래키가 있는 테이블의 엔티티(보통 @ManyToOne 쪽)가 주인이고, 반대편은 mappedBy로 주인을 가리켜요. 주인의 값만 DB에 반영되고, mappedBy 쪽은 읽기 전용 거울이에요.

03. 양방향의 함정, 주인이 아닌 쪽에만 값을 넣으면

JPA 양방향 매핑 함정. mappedBy 쪽 컬렉션에만 추가하면 주인의 외래키가 비어 user_id가 NULL로 저장된다. 주인에 값을 설정하고 연관관계 편의 메서드로 양쪽을 동기화해야 한다
// ❌ 거울에만 넣음 — user_id가 NULL로 저장됨
user.getOrders().add(order);
orderRepository.save(order);

// ✅ 주인에 설정해야 DB에 반영됨
order.setUser(user);

"양방향 걸었는데 외래키가 NULL이에요"의 정체가 이거예요. mappedBy 쪽은 DB에 아무 영향이 없으니, 주인에 값을 설정해야 해요.

그런데 주인에만 설정하면 이번엔 객체 그래프가 어긋나요. order.getUser()는 있는데 user.getOrders()엔 없는 상태죠. 같은 트랜잭션 안에서 그 컬렉션을 읽는 코드가 있으면 비어 보여요(1차 캐시는 DB를 다시 안 보니까요). 그래서 한쪽을 설정하면 반대쪽도 같이 맞추는 연관관계 편의 메서드를 둬요.

public class Order {
    public void setUser(User user) {
        this.user = user;                 // 주인 설정 (DB 반영)
        user.getOrders().add(this);       // 거울도 동기화 (객체 일관성)
    }
}

04. cascade, 생명주기를 부모에 묶기

주문을 저장할 때 주문상품(OrderItem)들을 일일이 persist하는 건 번거로워요. cascade를 걸면 부모의 영속 작업이 자식에게 전파돼요.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> items = new ArrayList<>();
JPA cascade와 orphanRemoval. cascade는 부모 persist 시 자식도 같이 저장하고, orphanRemoval은 컬렉션에서 빠진 자식을 DELETE한다. 둘 다 자식의 소유자가 부모 하나뿐일 때만 써야 한다
cascade의 적용 기준은 명확해요 — "이 자식이 이 부모 없이 존재할 수 있는가." OrderItem은 Order 없이는 의미가 없으니 cascade ⭕. 반면 User는 Order와 무관하게 존재하는 독립 엔티티니 Order→User에 cascade를 걸면 안 돼요. 잘못 걸면 주문 하나 지웠는데 회원까지 지워지는 사고가 나요. "부모가 유일한 소유자인가"를 늘 따져요.

05. orphanRemoval, 컬렉션에서 빠지면 삭제

orphanRemoval = true를 걸면 부모 컬렉션에서 제거된 자식(고아)이 DELETE돼요.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();

order.getItems().remove(0);   // 이 줄이 DELETE 쿼리가 된다

"주문상품을 목록에서 빼면 DB에서도 지워진다"는 직관적인 모델링이 돼요. cascade ALL + orphanRemoval 조합이면 자식의 생명주기를 부모가 완전히 관리해요. 단 컬렉션 조작이 곧 DB 삭제라는 걸 팀이 다 알아야 해요 — 모르고 리스트를 조작하면 의도치 않은 삭제가 나가요. 이것도 "부모가 유일한 소유자"일 때만 거는 옵션이에요.

06. @ManyToMany는 실무에서 안 써요

다대다는 @ManyToMany로 한 줄에 매핑할 수 있지만, 실무에서는 피해요. 중간 테이블이 자동 생성되는데 그 테이블에 컬럼을 추가할 수 없거든요. 그런데 실무의 다대다엔 거의 항상 추가 데이터가 붙어요. 주문↔상품 사이엔 수량·가격이, 회원↔모임 사이엔 가입일이 필요하죠.

그래서 중간 테이블을 엔티티로 직접 만들어요. OrderItem처럼요. 다대다 하나를 일대다 + 다대일 둘로 풀어내는 거예요. 처음부터 이렇게 모델링하면 나중에 컬럼 추가가 자유로워요.

07. 자주 만나는 문제

외래키가 NULL로 저장돼요

주인이 아닌 쪽(mappedBy)에만 값을 넣은 거예요. 주인(@ManyToOne 쪽)에 값을 설정하고, 편의 메서드로 양쪽을 동기화해요.

지웠는데 엉뚱한 것까지 지워져요

독립 엔티티에 cascade REMOVE나 orphanRemoval이 걸린 거예요. "부모가 유일한 소유자"인 관계인지 다시 봐요.

양방향인데 JSON 직렬화가 무한 루프 돌아요

Order→User→orders→Order... 순환 참조예요. 엔티티를 그대로 반환하지 말고 DTO로 변환하는 게 정석이에요(지연 로딩 편에서 본 그 이유와 같아요).

컬렉션을 새 리스트로 교체했더니 예외가 나요

orphanRemoval 컬렉션은 통째로 교체(setItems(new ArrayList))하면 안 돼요. Hibernate가 추적하는 컬렉션이 끊겨요. clear()addAll()로 내용을 갈아끼워요.

정리

연관관계의 핵심은 외래키도 하나, 그걸 관리하는 주인도 하나라는 사실이에요. 값이 DB에 반영되려면 주인(@ManyToOne 쪽)에 설정해야 하고, mappedBy는 읽기 전용 거울이에요. 양방향은 정말 필요할 때만 두되 편의 메서드로 양쪽을 맞춰요. cascade·orphanRemoval은 부모가 유일한 소유자일 때만 걸고요. 다대다는 중간 엔티티로 풀어요. 이 규칙들을 지키면 연관관계는 예측 가능해져요.

여기까지가 매핑이고 이제 동시성으로 넘어가요. 같은 데이터를 동시에 고칠 때 쓰는 낙관적 락과 비관적 락이 이어집니다.

출처: Hibernate User Guide — Associations · Jakarta Persistence Specification

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."