JPA 연관관계 매핑

2ㅣ2ㅣ·2024년 6월 26일

Spring

목록 보기
1/7
post-thumbnail

스프링에서는 어노테이션을 이용해 간단하게 연관관계를 매핑할 수 있다. 해당 포스팅에서는 연관관계 매핑 시 제약을 보완하면서 연관관계를 사용하는 법과 더불어 로딩, 영속성 전이, 트랜잭션을 어떻게 고려해야 하는지 살펴보겠다.

0. 연관관계 매핑 개요

JPA(Java Persistence API)는 객체지향 프로그래밍 언어인 자바에서 데이터베이스와 객체 간의 매핑을 도와주는 프레임워크다. 객체와 테이블의 성질이 달라서 정확한 연관관계를 표현하는 데 어려움이 있지만, 이를 보완하여 연관관계를 매핑하고 사용할 수 있다.

연관관계 매핑의 종류와 방향

  • 일대일(One To One): 한 개의 엔티티가 다른 한 개의 엔티티와 매핑되는 관계
  • 일대다(One To Many): 한 개의 엔티티가 여러 개의 엔티티와 매핑되는 관계
  • 다대일(Many To One): 여러 개의 엔티티가 한 개의 엔티티와 매핑되는 관계
  • 다대다(Many To Many): 여러 개의 엔티티가 여러 개의 엔티티와 매핑되는 관계

JPA에서는 엔티티 간 참조 방향을 설정할 수 있다.

  • 단방향: 한쪽만 참조
  • 양방향: 서로 참조

연관관계 매핑을 알아보기 전에 중요한 대전제를 명심하자.
일반적으로 ✨✨외래키를 갖는 테이블이 관계의 주인✨✨이 된다.

1. 일대일 매핑

1.1 일대일 단방향 매핑

단방향 매핑에서는 한쪽 엔티티만 다른 엔티티를 참조한다. 예를 들어, Product 엔티티가 ProductDetail 엔티티를 참조할 때 다음과 같이 설정할 수 있다.

public class Product extends BaseEntity{
	...
	@OneToOne // 1:1
	@JoinColumn(name="product_name") // Product Entity가 Owner임을 명시
	private ProductDetail productDetail;
}

@JoinColmn 는 외래키를 명시하고 해당 어노테이션이 사용된 엔티티가 관계의 주인임을 나타낸다.
@JoinColmn 를 선언하지 않으면 의도치 않은 필드가 외래키로 지정되어 엔티티를 매핑하는 중간 테이블이 생겨나 관리 포인트가 늘어나는 문제가 발생할 수 있으니 웬만하면 사용하는게 좋다.

일대일로 매핑된 Product 엔티티와 ProductDetail 엔티티는 함께 조회 되어야하므로 FetchType.EAGER 로 즉시로딩시키는 것이 좋을 것 같다. 즉시로딩 이란 이처럼 두 객체가 항상 같이 로딩되는 방식을 말한다.

  • 즉시 로딩 (FetchType.EAGER): 엔티티가 로드될 때 연관된 엔티티도 함께 로드됨.
  • 지연 로딩 (FetchType.LAZY): 연관된 엔티티가 실제로 필요할 때까지 로드되지 않음.

로딩과 관련된 이야기는 밑에서 자세히 설명하겠다.

1.2 일대일 양방향 매핑

양방향 매핑에서는 양쪽 엔티티가 서로를 참조한다. 사실 객체에서의 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미한다. 즉, ProductDetail 엔티티에도 @OneToOne 을 사용해주면 ProductProductDetail 이 양방향 매핑이 된다는 뜻이다.

public class ProductDetail extends BaseEntity{
	...
	@OneToOne // 1:1
	private Product product;
}

하지만 이처럼 양쪽에서 @OneToOne 어노테이션을 사용하면 양쪽 엔티티 모두 외래키를 갖게 되므로 내부에서는 left outer join이 두 번 일어나기 때문에 쿼리가 복잡해져서 비효율적일 수 있다.

이를 해결하기 위해 둘 중 한쪽만 외래키를 갖도록 설정해야 한다.
이때 mappedBy 로 주인이 아닌 객체를 명시하면 외래키를 한쪽에 위임할 수 있다.

@OneToOne(mappedBy="productDetail")
private Product product;

이로써 ProductProductDetail의 주인으로써 두 엔티티가 1:1로 매핑되었다.

정리하자면 @OneToOne 으로 1:1 연관관계를 설정하고 @JoinColumn으로 FK를 설정한다. mappedBy 속성값으로 주인이 아닌 객체를 명시한다.

유의해야할 사항은 양방향 매핑 시 두 객체가 서로를 참조하기 때문에 순환참조가 발생할 수 있다. 이때는 단방향으로 설정하거나 exclude 를 사용해 참조를 제외해준다.

@OneToOne(mappedBy="productDetail")
@ToString.Exclude
private Product product;

2. 다대일, 일대다 매핑

2.1 다대일 단방향 매핑

다대일 단방향 매핑은 여러 개의 엔티티가 한 개의 엔티티를 참조하는 구조다.
@ManyToOne 을 사용해서 Product엔티티와 Provider 엔티티를 다대일로 매핑했다.

public class Product extends BaseEntity{
	...
	@ManyToOne
	@JoinColumn(name="provider_id")
	@ToString.Exclude
	private Provider provider;
}

2.2 일대다 양방향 매핑

양방향 매핑은 양쪽에서 단방향으로 서로를 참조하는 구조다.

@OneToMany 를 사용해서 Provider 엔티티와 Prodict 엔티티를 일대다로 매핑했다.

public class Provider extends BaseEntity{
	@OneToMany(mappedBy="provider", fetch=FetchType.LAZY)
	@ToString.Exclude
	private List<Product> productList = new ArrayList<>(); // 컬렉션 형식
}

상대 엔티티가 Many일 경우 여러 엔티티가 포함될 수 있어 컬렉션 형식(ex. List, Map 등)으로 필드를 생성하는게 좋다.
또한 많은 양의 데이터를 로딩시키면 초기 로딩 성능이 좋지 않기 때문에 FetchType.LAZY로 설정해 필요할 때 데이터를 로딩시키는 전략을 택하는게 좋다.

2.3 즉시 로딩과 지연 로딩

스쳐가듯 말했지만 매핑 관계에 따라 로딩 전략이 다르다. 쉽게 정리하면 다음과 같다.

  • 즉시로딩 : 자주 사용되는 데이터는 즉시 로딩 시켜라.
@OneToOne(fetch = FetchType.EAGER)
private Profile profile;
  • 지연로딩 : 드물게 사용되는 데이터는 지연 로딩 시켜라.
@OneToMany(fetch = FetchType.LAZY, mappedBy = "user")
private List<Log> logs;

예를 들어, 사용자 프로필 정보와 같이 주요 화면에서 항상 필요로 하는 데이터는 즉시 로딩시켜야 할 것이고 사용자 상세 로그와 같은 기록들은 지연 로딩 시켜서 성능을 최적화 시켜야 할 것이다.

지연 로딩은 어떻게 일어나는가?
JPA의 구현체인 Hibernate 가 빈 객체를 프록시 객체로 감싸서 주소값을 가지고 있다가 어떠한 요청을 보내면(ex. get) 그제서야 프록시에 실제 데이터를 채워서 반환한다. 이를 통해 초기 로딩 시 성능을 최적화할 수 있다. 우리가 사용할 일은 면접 질문이라길래 넣어봤다.

3. 다대다 매핑

실무에서는 거의 사용되지 않지만, 다대다 매핑이 필요한 경우 중간 엔티티를 생성하여 해결할 수 있다.

3.1 다대다 단방향 매핑

@ManyToMany 를 사용해서 Producer와 Product를 다대다 매핑한다.

public class Producer extends BaseEntity{
	@ManyToMany
	// @JoinTable(name="이름") // 중간 테이블 이름 관리
	@ToString.Exclude
	private List<Product> products = new ArrayList<>();
}

@ManyToMany 를 사용하면 다대다 연관관계가 성립되는데 다대다 연관관계는 한 엔티티가 여러 다른 엔티티와 관계를 맺을 수 있고, 반대로 그 다른 엔티티도 여러 개의 첫 번째 엔티티와 관계를 맺을 수 있는 상황을 의미한다.

하나의 생산업체 Producer는 여러 개의 상품 Product를 만들 수 있고 반대로 하나의 상품 Product도 여러 개의 생산업체 Producer로부터 만들어질 수 있다.

그래서 각 엔티티에서 서로를 리스트로 갖는 구조가 만들어진다.

private List<Product> products = new ArrayList<>();

이런 경우 두 엔티티의 PKFK 이자 PK로 갖는 중간 테이블 을 만들어서 다대다 관계를 일대다 또는 다대일 관계로 해소한다. 때문에 엔티티에 별도의 @JoinColumn 를 사용하지 않아도 된다.

이렇게 생성된 중간 테이블은 기본적으로 엔티티1_엔티티2 라는 이름으로 설정된다. 이름을 따로 정의하고 싶다면@JoinTable(name="이름")의 형식으로 어노테이션을 선언해 중간 테이블의 이름을 관리할 수 있다.

3.2 다대다 양방향 매핑

양방향 매핑에서는 양쪽 엔티티가 서로를 참조한다.

양쪽 엔티티에 @ManyToMany를 사용하여 양방향으로 매핑하고 중간 테이블을 통해 일대다 또는 다대일 관계로 해소할 수 있다.

public class Product extends BaseEntity{
	@ManyToMany
	@ToString.Exclude
	private List<Producer> producer = new ArrayList<>();
}

필요에 따라 두 엔티티간에 주인을 설정할 수도 있다. 다대다 매핑의 경우 중간 테이블이 연관관계를 설정하고 있기 때문에 애플리케이션 실행 후에도 테이블 구조는 변경되지 않는다.

3.3 다대다 연관관계 사용을 지양해야 하는 이유

다대다 연관관계에서는 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있다. 즉, 관리하기 힘든 포인트가 발생한다는 문제가 있다. 이런 경우 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋다.

4. 영속성 전이

영속성이란 데이터를 생성한 프로그램이 종료되어도 사라지지 않는 데이터의 특성을 말한다. 영속성을 갖지 않는다면 데이터는 메모리에만 존재하게 되고 프로그램 종료 시 해당 데이터는 모두 사라진다. 영속성을 갖는 데이터는 파일이나 db에 영구저장시킬 수 있다.

4.1 영속성 전이 개요

영속성 전이는 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성 상태도 함께 변경하는 것이다. 때문에 영속성 전이에 사용되는 타입은 엔티티의 생명 주기와 연관이 있다.

4.2 영속성 전이 적용

Provider 엔티티에 영속성 전이를 설정해보자. ascade=CascadeType. 으로 영속성 타입을 설정할 수 있다. PERSIST로 설정하여 Provider가 영속화될 때 Product도 함께 영속화되도록 설정하였다.

public class Provider extends BaseEntity{
	@OneToMany(mappedBy="provider", cascade=CascadeType.PERSIST)
	@ToString.Exclude
	private List<Product> productList = new ArrayList<>();
}

이처럼 특정 상황에 맞춰 영속성 전이 타입을 설정하면 영속 상태의 변화에 따라 연관된 엔티티들의 동작도 함께 수행할 수 있어 개발 생산성 높아진다. 또한 엔티티 간의 관계를 더 쉽게 관리하고 데이터 일관성을 유지할 수 있다.

영속성 전이 타입은 어떤 작업을 했을 때 영향을 줄 것인지를 설정하는 것이다.
자주 사용되는 영속성 전이 타입은 다음과 같다.

  1. CascadeType.PERSIST: 부모 엔티티가 영속화될 때 연관된 자식 엔티티들도 함께 영속화된다. 이는 엔티티 save 시 유용하다.

    • 예시: 고객과 고객의 주문을 함께 저장할 때 유용하다.
    @OneToMany(cascade = CascadeType.PERSIST)
    private List<Order> orders = new ArrayList<>();
  2. CascadeType.MERGE: 부모 엔티티가 병합될 때 연관된 자식 엔티티들도 함께 병합된다. 이는 엔티티 update 시 유용하다.

    • 예시: 고객 정보와 그 고객의 주소 정보를 함께 업데이트할 때 유용하다.
    @OneToMany(cascade = CascadeType.MERGE)
    private List<Address> addresses = new ArrayList<>();
  3. CascadeType.ALL: 특정 상황에서는 모든 영속성 상태 변경에 대해 영속성 전이를 적용하기 위해 사용되지만, 주의해서 사용해야 한다. 특히 REMOVE를 포함한 ALL 타입은 데이터 삭제 시 매우 조심해야 한다. 자칫 모두 삭제될 수 있기 때문이다.

    • 예시: 고객과 그 고객의 주문, 주소 등 모든 관련 엔티티를 한 번에 관리할 때 유용하다.
    @OneToMany(cascade = CascadeType.ALL)
    private List<Order> orders = new ArrayList<>();
    
    @OneToMany(cascade = CascadeType.ALL)
    private List<Address> addresses = new ArrayList<>();
  4. CascadeType.REMOVE: 부모 엔티티가 제거될 때 연관된 자식 엔티티들도 함께 제거된다. 이는 엔티티 delete 시 유용하다.

    • 예시: 고객과 그 고객의 주문을 함께 삭제할 때 유용하다.
    @OneToMany(cascade = CascadeType.REMOVE)
    private List<Order> orders = new ArrayList<>();

4.3 고아 객체

고아 객체는 부모 엔티티와 연관관계가 끊어진 엔티티를 의미하며, orphanRemoval=true 설정으로 자동 제거할 수 있다.

@OneToMany(mappedBy="provider", cascade=CascadeType.PERSIST, orphanRemoval=true)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();

JPA에서는 이를 자동으로 제거해주는데, 다른 엔티티와 연관관계가 남아있을 시 사용하지 않는것이 좋다.

5. 결론

이번 포스팅에서는 연관관계를 설정하는 방법과 영속성 전이에 대해 알아보았다.

  • 코드 간결성이나 메모리 사용 최적화 관점에서 단방향을 지향해야 한다.
  • 다대다 매핑이 꼭 필요한 경우 중간 엔티티를 생성하여 일대다 혹은 다대일로 관계를 풀어서 JPA에서 관리할 수 있도록 해야한다.
  • JPA를 사용할 때 영속성 개념은 매우 중요하다. 이를 잘 활용하면 데이터의 일관성과 안정성을 높일 수 있다.
profile
https://sususoo.tistory.com/

0개의 댓글