Spring
JPA
연관관계 매핑

이 페이지에서는 JPA 연관관계 맵핑에 대해서 다뤄보겠다.

다대일 (N:1)

단방향 맵핑

1쪽이 아니라 N쪽에서 맵핑을 건다. 즉, N 쪽에서는 1쪽을 알 수 없지만 1쪽에서는 N쪽을 알 수 있다.

@Entity
@Table(name = "MEMBER")
public class Member {
    @Id
    @Column(name = "MEMBER_ID")
    private String id;
 
    @Column
    private String username;
 
    @ManyToOne @Setter
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
  • @ManyToOne : 다대일 관계 맵핑
  • @JoinColumn(name = "TEAM_ID") : 외래키 맵핑

실제 DML에서는

ALTER TABLE MEMBER ADD CONSTRAINT FK_MEMBER_TEAM
    FOREIGN KEY (TEAM_ID)
    REFERENCES  TEAM(TEAM_ID)
;

다음과 같이 맵핑을 하는데 위의 코드는 Team이라는 클래스의 team_id를 참조해서 맵핑을 하라는 뜻이다.

JoinColumn

Team이라는 객체의 위에 붙어있다면 Team 테이블의 어떤 컬럼을 참조해서 맵핑을 할지에 대한 설정이다. 만약 아무런 이름도 지정해주지 않는다면 기본적으로 필드명 + _id로 설정이 된다. 즉,

@JoinColumn
private Team t;

라면 t_id로 맵핑을 할려고 한다.

속성기능기본값
name매핑할 외래 키 이름필드명 + _ + 참조하는 테이블의 기본 키 컬럼명
referencedColumnName외래 키가 참조하는 대상 테이블의 컬럼명참조하는 테이블의 기본 키 컬럼명
foreignKey(DDL) 외래 키 제약조건을 직접 지정할 수 있다.
unique, nullable, insertable, updatable, columnDefinition, table@Column 속성과 같다.
JPA 최적화 전략
ℹ️

@JoinColumn(name = "TEAM_ID", nullable = false) nullable을 false로 하게 되면 반드시 조인이 된다는 뜻이 되는데 이러면 외부조인이 아니라 내부조인이 된다. 내부조인은 기본적으로 외부조인보다 성능이 더 좋기 때문에 성능 최적화에 도움이 된다.

name과 referencedColumnName
  • name 옵션 : 현재 엔티티의 외래 키 이름을 지정한다.
  • referencedColumnName : 외래 키가 참조하는 대상 테이블의 컬럼명을 지정한다.
@ManyToOne
@JoinColumn(name = "gameId", referencedColumnName = "gameId")
private MatchGame matchGame;

ManyToOne

속성기능기본값
optionalfalse로 설정하면 연관된 엔티티가 항상 있어야 한다.true
fetch글로벌 페치 전략을 설정한다.@ManyToOne = FetchType.EAGER
@OneToMany = FetchType.LAZY
cascade영속성 기능
@OneToMany
private List<Member> members;
@OneToMany(targetEntity = Member.class)
private List members; // 제네릭이 없으면 타입 정보를 알 수 없다.

삭제시 주의사항

만약에 Member N vs Team 1 관계에서 Team을 삭제할 때 연관된 Member를 전부 삭제해주지 않게 되면 조회시에

🚫

TransientPropertyValueException: object references an unsaved transient instance

라는 에러가 발생하게 된다.

입력시 주의할 점

연관관계의 주인쪽는 값을 입력하지 않고 주인이 아닌 곳에 값을 입력하는 것이다,

Member member1 = new Member("member1", "회원1");
em.persist(member1);
Team team = new Team("team1", "팀1");
team1.getMembers().add(member1);
em.persist(team1);

위와 같이 작성하게 되면 fk키가 있는 member의 persistence에는 team_id가 없기 때문에 연관관계가 제대로 맵핑이 안되게 된다.

양방향 맵핑

Team.java
@Entity
@Getter
@Table(name = "TEAM")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor @Builder
public class Team {
    @Id
    @Column(name = "TEAM_ID")
    private String id;
    private String name;
 
    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> members = new ArrayList<Member>();
 
    public void addMember(Member member) {
        if(this.members == null) {
            this.members = new ArrayList<Member>();
        }
        this.members.add(member);
    }
}

연관관계의 주인

연관관계의 주인은 외래키(FK)가 있는 곳이다.
만약에 Member와 Team이 각각 N:1의 관계로 설정이 된다면 Member에 team_id가 FK로 설정되기 때문에 Member가 연관관계의 주인이 된다.
테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다. 따라서 둘 사 이에 차이가 발생한다. 둘 중 어떤 객체를 기준으로 연관관계를 관리할지를 정해주는게 mappedBy Annotaion이다.
연관관계의 주인은 보통 외래 키 관리자 쪽이 주인이 된다.

mappedBy

Team.java
public class Team {
    ...
    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> members = new ArrayList<Member>();
    ...
}

이 코드에서보면 Member에는 team이라는 필드가 있다. 그 말인 즉슨, members에 가져오는 값은 team이라는 필드를 참조해서 가져오라는 뜻이다.

주의할점

Team 👉 Member 접근시

예를 들어서 teamService.findByName("team1")을 통해서 team을 가져오고 그렇게 가져온 team1에서 다시 .getMembers()로 해당 팀에 소속된 멤버들을 조회할려면 다음과 같은 과정이 선행되어야 한다.

  1. N(team)을 먼저 저장한다.
  2. 위에서 저장한 N(Team)에 속하는(참조하는) 1(Member)을 저장한다.
  3. Team에도 방금 저장한 Member를 추가해줘야 한다.
TeamServiceTest.java
@Test
void_조회_By_멤버() {
    // given
    // Team을 저장
    Team teamA = Team.builder().id("팀A").name("팀A").build(); 
    Team savedTeam = teamService.save(teamA);
    // Team에 속하는 멤버를 저장
    Member member = Member.builder().id("memberA").username("memberA").team(teamA).build(); 
 
    // Member 👉 Team으로 맵핑해서 저장
    // ☢️ 여기 안해주면 에러 발생
    savedTeam.addMember(member);
    Member savedMember = memberService.save(member);
    savedTeam = teamService.save(savedTeam);
 
    // when
    Team searchedTeam = teamService.findByName(savedTeam.getName());
 
    // then
    assertEquals(savedMember.getUsername(), searchedTeam.getMembers().get(0).getUsername());
}
Member Team 변경시

Member를 TeamA에서 TeamB로 변경시켜줄 때 TeamA에서 Member를 삭제하고 TeamB에 Member를 추가해줘야 한다.
따라서 Member 코드안에 다음과 같은 메서드를 추가해서 Team에서도 연관관계를 삭제할 수 있도록 해줘야 한다.

Member.java
public void setTeam(Team team) {
    if(this.team != null) {
        this.team.getMembers().remove(this);
    }
    this.team = team;
    team.getMembers().add(this);
}

다대다 (N:N)

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 따라서 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야 한다.

단방향 맵핑

MEMBER 👉 PRODUCT 테이블간의 N:N 단방향 맵핑이라고 예시를 들어보겠다.

MEMBER_PRODUCT 생성 쿼리
CREATE TABLE MEMBER_PRODUCT (
    MEMBER_ID VARCHAR(255) NOT NULL,
    PRODUCT_ID VARCHAR(255) NOT NULL,
    PRIMARY KEY (MEMBER_ID, PRODUCT_ID)
);
Member.java
public class Member { 
    ...
    @ManyToMany
    @Builder.Default
    @JoinTable(
            name = "MEMBER_PRODUCT",
            joinColumns = @JoinColumn(name = "MEMBER_ID"),
            inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID")
    )
    private List<Product> products = new ArrayList<Product>();
    ...
}
  • @JoinTable : 연결할 테이블을 지정한다.
  • name : 테이블의 이름을 지정한다. MEMBER_PRODUCT라는 테이블을 생성했다.
  • joinColumns : 현재 방향인 회원과 조인 컬럼 정보를 지정한다.
  • inverseJoinColumns : 반대 방향인 상품과 조인 컬럼 정보를 지정한다.

테스트코드

MemberService.java
    @Test
    void PRODUCT_연관_단방향_테스트() {
        // given
        Product productA = Product.builder().id("productA").name("productA").build();
        Product savedProduct = productService.save(productA);
        Member member = Member.builder().id("memberA").username("memberA").build();
        member.getProducts().add(productA);
        Member savedMember = memberService.save(member);
 
        // when
        Member searchedMember = memberService.findByUserName(savedMember.getUsername());
 
        // then
        assertEquals(searchedMember.getProducts().get(0).getId(), savedProduct.getId());
    }

양방향 맵핑(N:N)

이제 Product에도 Member를 추가해보겠다.

Product.java
@Entity(name = "PRODUCT")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter @Builder
public class Product {
    @Id
    @Column(name = "PRODUCT_ID")
    private String id;
 
    private String name;
 
    // 역방향 추가
    // 연관관계의 주인은 Member이고 Member의 products 필드에 의해 매핑된다.
    @ManyToMany(mappedBy = "products", fetch = FetchType.EAGER)
    private List<Member> members;
}

fetchType을 EAGER로 설정해준 이유는 여기 (opens in a new tab)를 참고해주면 된다.

양방향 맵핑(1:N:1)

다대다 연결에서는 다대다 연결로만 단순히 끝나는게 아니고 그 연결로 인해서 추가적인 어떤 정보가 생길 경우가 있다. 예를 들자면

회원이 상품을 주문하면 연결 테이블에 단순히 주문한 회원 아이디와 상품 아이디만 담고 끝나는게 아닌, 연결 테이블에 주문 수량 컬럼이나 주문한 날짜 같은 컬럼이 더 필요하다. - 자바 ORM 표준 JPA 프로그래밍

OrderEntity 추가

@Getter
@Entity(name = "ORDERS")
public class Order {
    @Id
    @Column(name = "ORDER_ID")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long orderId;
 
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
 
    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;
 
    public void setMember(Member member) {
        this.member = member;
        if (!member.getOrders().contains(this)) {
            member.getOrders().add(this);
        }
    }
 
    public void setProduct(Product product) {
        this.product = product;
        if (!product.getOrders().contains(this)) {
            product.getOrders().add(this);
        }
    }
}

MemberEntity 연관설정

Member Entity는 Eager Fetch로 설정해서 맵핑을 한다.

public class Member {
    ...
 
    @Builder.Default
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
}

ProductEntity 연관설정

public class Product {
    ...
    @Builder.Default 
    @OneToMany(mappedBy = "product")
    private List<Order> orders = new ArrayList<Order>();
    ...
}

Test 케이스

주목해야할 점은 Member는 Eager Fetch 전략을 사용했기 때문에 getProduct로 즉시 Product를 가져올 수 있지만, Product는 Lazy Fetch 전략을 사용했기 때문에 Member를 가져올 때는 에러가 발생한다는 점이다.

@Test
@DisplayName("Order를 통한 다대다 맵핑 테스트")
void ORDER_양방향_연관테스트() {
    // given
    Member member = Member.builder().id("홍길동").username("홍길동").build();
    memberService.save(member);
    Product product = Product.builder().id("초코비").name("초코비").build();
    productService.save(product);
    Order order = new Order();
    order.setMember(member);
    order.setProduct(product);
    orderService.save(order);
 
 
    // when
    Order searchedOrder = orderService.findById(order.getOrderId());
    Product searchedProduct = productService.findById(product.getId());
    Member searchedMember = memberService.findByUserName(member.getUsername());
 
    // then
    assertEquals(searchedOrder.getMember().getId(), member.getId());
    assertEquals(searchedOrder.getProduct().getId(), product.getId());
    // Lazy 로딩이라서 에러 발생
    assertThrows(LazyInitializationException.class, () -> {
        searchedProduct.getOrders().get(0).getMember().getUsername();
    });
    // EAGER 로딩이라서 Member를 통해서 정상적으로 Product까지 조회가 됨
    assertEquals(searchedMember.getOrders().get(0).getProduct().getName(), searchedProduct.getName());
}

Cascade

영속성 전이란 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장하는등 부모 엔티티의 영속성에 맞춰서 자식 엔티티의 영속성도 설정해주는 것을 말한다. Cascade를 설정한쪽이 부모 엔티티가 된다.

Merge 예시

만약 아래와 같이 Merge를 설정해주게 되면 Team을 저장할 때 Member도 함께 저장이 된다.

Team.java
@Entity
public class Team {
    ...
    @OneToMany(mappedBy = "team", cascade = CascadeType.MERGE)
    @Builder.Default
    @JsonIgnore
    private List<Member> members = new ArrayList<Member>();
}

테스트 코드 👇

@Test
void MEMBER_영속성전이_테스트() {
    // given
    Team teamA = Team.builder().id("팀A").name("팀A").build();
    String time = String.valueOf(System.currentTimeMillis());
    String yesterday = String.valueOf(System.currentTimeMillis() - 24 * 60 * 60 * 1000);
    Member m1 = Member.builder().username(time).id(time).build();
    Member m2 = Member.builder().username(yesterday).id(yesterday).build();
    m1.setTeam(teamA);
    m2.setTeam(teamA);
 
    // when
    teamService.save(teamA);
 
    // then
    assertEquals(m1.getUsername(), memberService.findByUserName(m1.getUsername()).getUsername());
    assertEquals(m2.getUsername(), memberService.findByUserName(m2.getUsername()).getUsername());
}

Cascade 옵션

옵션이 잘 정리되있는 블로그 👉 https://tecoble.techcourse.co.kr/post/2023-08-14-JPA-Cascade/ (opens in a new tab)

고아객체

@OneToMany에서 orphanRemoval = true 옵션을 주게되면 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하게 될 때 자식 엔티티가 자동으로 삭제된다.

김영한 JPA 311 페이지 참조