-
[📝 JPA] 연관관계의 주인과 매핑관련 스터디스터디 노트 2025. 6. 26. 12:54
JPA에서 연관관계는 객체 간의 참조를 데이터베이스의 외래 키(Foreign Key) 관계로 매핑하는 중요한 개념입니다. 특히 양방향 연관관계에서는 '연관관계 주인'이라는 개념을 명확히 이해해야 데이터 정합성 문제를 피할 수 있습니다.
1. JPA 연관관계 주인의 개념
관계형 데이터베이스에서는 테이블 간의 관계가 외래 키(Foreign Key)를 통해 이루어집니다. 이 외래 키는 항상 하나의 테이블에만 존재합니다. 예를 들어, Member 테이블에 Team 테이블의 team_id라는 외래 키가 있다면, Member 테이블만이 Team과의 관계를 "가지고" 있다고 볼 수 있습니다.
반면, 객체 지향 프로그래밍에서는 양방향으로 객체 참조를 가질 수 있습니다. Member 객체가 Team 객체를 참조하고, Team 객체가 Member 목록을 참조하는 것이 가능하죠.
문제점: 만약 객체의 양방향 관계를 모두 데이터베이스에 반영하려고 할 때, JPA는 어떤 객체의 변경을 통해 실제 데이터베이스의 외래 키를 업데이트해야 할지 혼란스러워집니다. member.setTeam(team)을 했을 때와 team.getMembers().add(member)를 했을 때, JPA가 각각 어떤 행동을 해야 할까요?
해결책: 연관관계 주인 (Owning Side) JPA는 이러한 혼란을 방지하기 위해 "연관관계의 주인"이라는 개념을 도입했습니다.
- 역할: 연관관계 주인만이 데이터베이스의 외래 키를 관리하고, 외래 키의 등록, 수정, 삭제를 할 수 있습니다.
- 주인이 아닌 쪽 (mappedBy 사용): 주인 객체가 매핑한 것을 단순히 "미러링(Mirroring)"하는 역할만 합니다. 즉, 읽기(Read)만 가능하고, 해당 객체의 변경만으로는 데이터베이스의 외래 키에 아무런 영향을 주지 못합니다.
- 규칙: 외래 키를 가지고 있는 테이블에 매핑되는 엔티티가 연관관계의 주인이 됩니다. JPA 어노테이션 상으로는 @JoinColumn이 있는 곳이 연관관계의 주인입니다. 주인이 아닌 쪽은 @OneToMany나 @OneToOne 어노테이션에 mappedBy 속성을 사용하여 자신이 주인에게 매핑되었음을 명시합니다.
중요성: 양방향 연관관계 설정 시, 반드시 연관관계 주인 쪽에 값을 설정해야만 데이터베이스에 외래 키가 올바르게 저장됩니다. 주인이 아닌 쪽에만 값을 설정하면 DB에는 반영되지 않고 객체 상태만 바뀌는 문제가 발생합니다. 따라서 양방향 매핑 시에는 양쪽 모두에 값을 설정해주는 편의 메서드를 작성하는 것이 권장됩니다.
2. JPA 연관관계 매핑 종류
JPA는 객체 지향의 관계(일대일, 일대다, 다대일, 다대다)를 관계형 데이터베이스의 관계로 매핑하기 위해 4가지 종류의 어노테이션을 제공합니다.
- @ManyToOne (다대일): N:1 관계. 여러 개의 엔티티(N)가 하나의 엔티티(1)에 속하는 관계.
- 외래 키: 항상 다(Many) 쪽 테이블이 일(One) 쪽 테이블의 외래 키를 가집니다.
- 연관관계 주인: 다(Many) 쪽 엔티티가 됩니다. (@ManyToOne 어노테이션이 붙은 엔티티)
- @OneToMany (일대다): 1:N 관계. 하나의 엔티티(1)가 여러 개의 엔티티(N)를 가지는 관계.
- 외래 키: @OneToMany가 붙은 테이블이 아닌, 다(Many) 쪽 테이블 (즉, @ManyToOne이 붙은 테이블)이 외래 키를 가집니다.
- 연관관계 주인: 일(One) 쪽 엔티티가 아닌, 다(Many) 쪽 엔티티가 됩니다. (@OneToMany는 mappedBy를 사용하여 주인이 아님을 명시)
- @OneToOne (일대일): 1:1 관계. 두 엔티티가 서로 일대일 관계를 가짐.
- 외래 키: 어느 한쪽 테이블이 외래 키를 가질 수 있습니다. (자주 접근하는 쪽이나 FK를 저장하는 쪽에 둠)
- 연관관계 주인: 외래 키를 가진 테이블에 매핑되는 엔티티가 됩니다.
- @ManyToMany (다대다): N:M 관계. 여러 개의 엔티티(N)가 여러 개의 엔티티(M)와 관계를 가짐.
- 외래 키: 관계형 데이터베이스에서는 다대다 관계를 직접 표현할 수 없어 중간(조인) 테이블을 통해 일대다-다대일 관계로 풀어냅니다.
- JPA @ManyToMany의 한계: JPA에서 @ManyToMany는 사용하기 복잡하고 제약이 많아, 실무에서는 @ManyToMany 대신 중간 테이블을 엔티티로 만들어서 ManyToOne과 OneToMany 조합으로 풀어내는 것을 강력히 권장합니다.
3. 연관관계 매핑 예제 코드
편의를 위해 엔티티의 getter, setter, constructor, toString 등은 생략하거나 최소화합니다.
3.1. @ManyToOne (다대일) & @OneToMany (일대다) 양방향 매핑
이 두 매핑은 실제 데이터베이스 구조상 같은 외래 키를 공유하지만, 객체 지향적인 관점에서 양방향으로 연관관계를 설정할 때 함께 사용됩니다. Member가 Team의 외래 키를 가집니다.
- Team (1) : Member (N)
- 연관관계 주인: Member (다(N) 쪽)
// --- Team.java (1) --- import jakarta.persistence.*; import java.util.ArrayList; import java.util.List; @Entity @Table(name = "TEAM") public class Team { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "TEAM_ID") private Long id; private String name; // @OneToMany: 일대다 관계. Team은 여러 Member를 가질 수 있음. // mappedBy = "team": Member 엔티티의 "team" 필드에 의해 매핑되었음을 명시. // 즉, Team 엔티티는 연관관계 주인이 아님 (읽기 전용). // fetch = FetchType.LAZY: 기본적으로 지연 로딩 (필요할 때 Member 목록 조회). @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) // Cascade, orphanRemoval은 추가 고려사항 private List<Member> members = new ArrayList<>(); // --- 양방향 편의 메서드 (중요!) --- // Team에 Member를 추가할 때 Member의 team도 설정하도록 하여 양쪽 객체 상태 일치 public void addMember(Member member) { this.members.add(member); member.setTeam(this); // 중요: 연관관계 주인 쪽에 값 설정 } // Constructor, Getter, Setter (생략) protected Team() {} public Team(String name) { this.name = name; } public Long getId() { return id; } public String getName() { return name; } public List<Member> getMembers() { return members; } public void setName(String name) { this.name = name; } } // --- Member.java (N) --- import jakarta.persistence.*; @Entity @Table(name = "MEMBER") public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "MEMBER_ID") private Long id; private String username; // @ManyToOne: 다대일 관계. 여러 Member가 하나의 Team에 속함. // @JoinColumn(name = "TEAM_ID"): MEMBER 테이블에 TEAM_ID라는 외래 키 컬럼 생성. // 이 어노테이션이 붙었으므로 Member 엔티티가 연관관계의 주인. // fetch = FetchType.LAZY: 기본적으로 지연 로딩 (필요할 때 Team 정보 조회). @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "TEAM_ID") private Team team; // 연관관계 주인 // --- 양방향 편의 메서드 (중요!) --- // Member가 소속 팀을 변경할 때, 이전 팀에서 자신을 제거하고 새 팀에 추가 public void setTeam(Team team) { // 기존 팀과의 관계 끊기 (필요시) if (this.team != null) { this.team.getMembers().remove(this); } this.team = team; // 새로운 팀과의 관계 설정 (List에 추가) if (team != null && !team.getMembers().contains(this)) { team.getMembers().add(this); } } // Constructor, Getter, Setter (생략) protected Member() {} public Member(String username) { this.username = username; } public Long getId() { return id; } public String getUsername() { return username; } public Team getTeam() { return team; } public void setUsername(String username) { this.username = username; } }
3.2. @OneToOne (일대일) 양방향 매핑
Member가 하나의 Locker를 가지고, Locker는 하나의 Member에만 속합니다. 외래 키는 Member 테이블에 존재하도록 설정합니다.
- Member (1) : Locker (1)
- 연관관계 주인: Member (외래 키를 Member 테이블이 가지도록 설정했으므로)
// --- Member.java (1:1에서 1, 연관관계 주인) --- import jakarta.persistence.*; @Entity @Table(name = "MEMBER") // 위에서 사용된 Member와는 다른 엔티티로 가정 public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "MEMBER_ID") private Long id; private String username; // @OneToOne: 일대일 관계. // @JoinColumn(name = "LOCKER_ID"): MEMBER 테이블에 LOCKER_ID 외래 키 컬럼 생성. // Member가 Locker의 외래 키를 가짐 -> Member가 연관관계 주인. // fetch = FetchType.LAZY: 기본적으로 지연 로딩. @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "LOCKER_ID") private Locker locker; // 연관관계 주인 // --- 양방향 편의 메서드 (중요!) --- public void setLocker(Locker locker) { // 기존 락커와의 관계 끊기 (필요시) if (this.locker != null) { this.locker.setMember(null); } this.locker = locker; // 새로운 락커와의 관계 설정 (반대쪽도 설정) if (locker != null && locker.getMember() != this) { locker.setMember(this); } } // Constructor, Getter, Setter (생략) protected Member() {} public Member(String username) { this.username = username; } public Long getId() { return id; } public String getUsername() { return username; } public Locker getLocker() { return locker; } public void setUsername(String username) { this.username = username; } } // --- Locker.java (1:1에서 1, 연관관계 주인이 아님) --- import jakarta.persistence.*; @Entity @Table(name = "LOCKER") public class Locker { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "LOCKER_ID") private Long id; private String name; // @OneToOne: 일대일 관계. // mappedBy = "locker": Member 엔티티의 "locker" 필드에 의해 매핑되었음을 명시. // 즉, Locker 엔티티는 연관관계 주인이 아님 (읽기 전용). // fetch = FetchType.LAZY: 기본적으로 지연 로딩. @OneToOne(mappedBy = "locker", fetch = FetchType.LAZY) private Member member; // 연관관계 주인이 아님 // --- 양방향 편의 메서드 (중요!) --- public void setMember(Member member) { // 기존 멤버와의 관계 끊기 (필요시) if (this.member != null) { this.member.setLocker(null); } this.member = member; // 새로운 멤버와의 관계 설정 (반대쪽도 설정) if (member != null && member.getLocker() != this) { member.setLocker(this); } } // Constructor, Getter, Setter (생략) protected Locker() {} public Locker(String name) { this.name = name; } public Long getId() { return id; } public String getName() { return name; } public Member getMember() { return member; } public void setName(String name) { this.name = name; } }
3.3. @ManyToMany (다대다) - 중간 테이블 엔티티 사용 (권장 방식)
Product와 Category가 다대다 관계일 때, 중간 테이블 Product_Category를 엔티티 ProductCategory로 만들어 ManyToOne + OneToMany 조합으로 풀어냅니다. 이렇게 하면 중간 테이블에 추가 필드(registeredAt)를 넣을 수 있는 유연성도 확보됩니다.
- Product (N) : ProductCategory (1)
- Category (M) : ProductCategory (1)
- 연관관계 주인: ProductCategory (중간 테이블이 Product와 Category의 외래 키를 모두 가짐)
import jakarta.persistence.*; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; // for equals/hashCode in IdClass // --- Product.java --- @Entity @Table(name = "PRODUCT") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "PRODUCT_ID") private Long id; private String name; // ProductCategory 엔티티의 product 필드에 의해 매핑됨 @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) private List<ProductCategory> productCategories = new ArrayList<>(); // Constructor, Getter, Setter (생략) protected Product() {} public Product(String name) { this.name = name; } public Long getId() { return id; } public String getName() { return name; } public List<ProductCategory> getProductCategories() { return productCategories; } public void setName(String name) { this.name = name; } } // --- Category.java --- @Entity @Table(name = "CATEGORY") public class Category { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "CATEGORY_ID") private Long id; private String name; // ProductCategory 엔티티의 category 필드에 의해 매핑됨 @OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true) private List<ProductCategory> productCategories = new ArrayList<>(); // Constructor, Getter, Setter (생략) protected Category() {} public Category(String name) { this.name = name; } public Long getId() { return id; } public String getName() { return name; } public List<ProductCategory> getProductCategories() { return productCategories; } public void setName(String name) { this.name = name; } } // --- ProductCategory.java (중간 테이블 엔티티) --- // 복합키를 사용하는 방법 1: @IdClass 사용 @Entity @Table(name = "PRODUCT_CATEGORY") @IdClass(ProductCategoryId.class) // 복합키 클래스 지정 public class ProductCategory { // 복합키의 각 부분은 @Id와 @ManyToOne으로 매핑 @Id @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "PRODUCT_ID") // PRODUCT_CATEGORY 테이블에 PRODUCT_ID 외래 키 생성 private Product product; // 연관관계 주인 @Id @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "CATEGORY_ID") // PRODUCT_CATEGORY 테이블에 CATEGORY_ID 외래 키 생성 private Category category; // 연관관계 주인 private LocalDateTime registeredAt; // 중간 테이블의 추가 필드 // Constructor, Getter, Setter (생략) protected ProductCategory() {} public ProductCategory(Product product, Category category) { this.product = product; this.category = category; this.registeredAt = LocalDateTime.now(); } public Product getProduct() { return product; } public Category getCategory() { return category; } public LocalDateTime getRegisteredAt() { return registeredAt; } public void setRegisteredAt(LocalDateTime registeredAt) { this.registeredAt = registeredAt; } // equals, hashCode는 IdClass를 사용할 때 중요 (생략) } // --- ProductCategoryId.java (복합키 클래스) --- import jakarta.persistence.Embeddable; import java.io.Serializable; import java.util.Objects; @Embeddable // Embeddable 또는 Serializable 구현 public class ProductCategoryId implements Serializable { private Long product; // ProductCategory 엔티티의 product 필드명과 일치 private Long category; // ProductCategory 엔티티의 category 필드명과 일치 // Constructor, Getter, Setter (생략) protected ProductCategoryId() {} public ProductCategoryId(Long product, Long category) { this.product = product; this.category = category; } public Long getProduct() { return product; } public Long getCategory() { return category; } public void setProduct(Long product) { this.product = product; } public void setCategory(Long category) { this.category = category; } // equals()와 hashCode() 구현 필수 (복합키 동등성 비교를 위함) @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ProductCategoryId that = (ProductCategoryId) o; return Objects.equals(getProduct(), that.getProduct()) && Objects.equals(getCategory(), that.getCategory()); } @Override public int hashCode() { return Objects.hash(getProduct(), getCategory()); } }
참고: @ManyToMany를 JPA에서 직접 사용하는 것은 다음과 같은 형태입니다. 하지만 이 방식은 중간 테이블에 추가 필드를 넣을 수 없고, 커스터마이징이 어려워 실무에서는 거의 사용되지 않습니다.
// --- Product.java (ManyToMany 직접 사용 예시 - 권장하지 않음) --- @Entity public class Product { @Id @GeneratedValue private Long id; private String name; @ManyToMany @JoinTable(name = "PRODUCT_CATEGORY_MAPPING", // 중간 테이블 이름 joinColumns = @JoinColumn(name = "PRODUCT_ID"), // 현재 엔티티(Product)의 FK inverseJoinColumns = @JoinColumn(name = "CATEGORY_ID")) // 반대쪽 엔티티(Category)의 FK private List<Category> categories = new ArrayList<>(); // ... } // --- Category.java (ManyToMany 직접 사용 예시 - 권장하지 않음) --- @Entity public class Category { @Id @GeneratedValue private Long id; private String name; @ManyToMany(mappedBy = "categories") // Product 엔티티의 "categories" 필드에 의해 매핑됨 private List<Product> products = new ArrayList<>(); // ... }
마치며
JPA 연관관계 주인의 개념과 각 매핑 종류, 그리고 예시 코드를 통해 JPA의 객체-관계 매핑 원리를 이해하는 데 도움이 되기를 바랍니다. 특히 양방향 연관관계에서는 연관관계 주인 쪽에 값을 설정하고, 양방향 편의 메서드를 통해 양쪽 객체의 상태를 일관되게 관리하는 것이 핵심입니다.
'스터디 노트' 카테고리의 다른 글
[📝 DB] 외래 키(FK) 설정의 주요 이유 (0) 2025.06.27 [📝 DB] DB 테이블의 인덱스의 기준 (1) 2025.06.27 📝 [JPA] @EntityGraph에 대하여.. (1) 2025.06.25 [Docker + Spring Boot] Spring boot jar 배포 시 도커 컨테이너 내 파일 위치 확인(app.jar) (0) 2025.03.25 [Spring Security 3.x + JWT] PermitAll() 이 정상적으로 인식이 되지 않을 경우 (0) 2024.09.22