연관관계 매핑

goose_bumps·2025년 5월 9일

SpringBoot

목록 보기
6/9

관계형 데이터베이스로 복잡한 설계를 할 때 하나의 테이블만으로는 애플리케이션의 모든 기능을 구현하는 것이 불가능하다.
도메인에 맞는 테이블을 설계하고 테이블마다 연관관계를 설정하는데, JPA를 사용하여 이러한 테이블 간의 연관관계를 Entity 간의 연관관계로 표현할 수 있다.

  • OneToOne(1:1)
  • OneToMany(1:N)
  • ManyToOne(N:1)
  • ManyToMany(N:M)

연관관계는 위와 같이 4가지 종류가 있는데 이름 그대로 몇개의 Entity가 몇개의 Entity와 연관관계가 있는지에 따라 분류되는 것이다.

Entity 간 참조방향에 따라 단방향 매핑과 양방향 매핑으로도 분류가 된다.
한쪽의 Entity만 참조하는 경우에는 단방향 매핑, 서로의 Entity를 참조하는 경우에는 양방향 매핑이 된다.

연관관계를 이해하려면 외래키의 개념에 대해 알아야 하는데 간단한 예시를 들어 설명해보겠다.

상품번호상품명상품가격공급 업체 번호
1노트북500000A-1
2시계10000B-1
3스피커20000A-1
4소파200000C-1

공급 업체 번호업체명
A-1OO전자
B-1OO악세서리
C-1OO가구

여기 상품 정보가 저장된 상품 테이블과 공급 업체 정보가 저장된 공급 업체 테이블도 있다.

두 테이블을 연관시키는 속성은 공급 업체 번호인데 이 속성은 공급 업체 테이블에서는 기본키에 해당하지만 상품 테이블에서는 외래키에 해당한다.

즉, 어떤 다른 테이블과 연관관계를 맺으려면 외래키를 필요로 하는데 이 외래키는 해당 테이블의 기본키만 될 수 있는 것이다.

연관관계가 설정되면 일반적으로 외래키를 가진 테이블이 관계의 주인이 된다.

1. 1:1 매핑

가장 기본적인 1:1 매핑에 대해 알아보자.
말그대로 1개의 Entity와 다른 1개의 Entity 간 연관관계를 설정하는 것이다.

위에서 다룬 상품 테이블공급 업체 테이블을 바탕으로 Entity를 구성해보겠다.

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Table(name = "product")
public class Product extends BasedEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;
}

Product라는 엔티티이며 상품 테이블을 엔티티로 구현한 것이다. 필드값으로 상품 번호, 상품명, 가격, 재고를 가진다.

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Provider {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
}

공급 업체 테이블을 구현한 Provider 엔티티이며 필드값으로 공급 업체 번호와 공급 업체명을 가진다.

이제 두 엔티티 간 연관관계를 설정해보겠다.

1) 단방향 매핑

외래키를 Product 엔티티가 가질 것이기 때문에 Product 엔티티가 Provider 엔티티를 참조하게 된다.
그래서 Product 엔티티 클래스에 외래키를 설정할 수 있게 바꿔야 한다.

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Table(name = "product")
public class Product extends BasedEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;
    
    @OneToOne
    @JoinColumn(name = "provider_id") //외래키 지정
    private Provider provider;
}

@OneToOne 에너테이션을 통해 1:1 연관관계를 설정할 수 있다.
Provider의 기본키인 idProduct에서는 외래키가 되기 때문에 @JoinColumn 에너테이션을 추가하여 외래키를 설정한다. @JoinColumn에서 사용할 수 있는 여러 속성이 있는데 name = 을 사용하면 칼럼명으로 지정이 가능하다.

칼럼명은 소문자로 참조되는 엔티티_기본키명로 입력하면 된다.

2) 양방향 매핑

양방향 매핑의 개념은 양쪽에서 서로를 단방향 매핑하는 것이다. 기존에 Product -> Provider 방향으로 단방향 매핑을 했으니 Provider -> Product 방향으로 단방향 매핑을 추가하면 된다.

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Provider {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @OneToOne //Provider -> Product 단방향 매핑 추가
    private Product product;
}

Provider 엔티티에 단방향 매핑을 추가해주면 양방향 연관관계가 설정된다.

데이터베이스에서는 테이블 간 연관관계를 맺으면 한쪽 테이블이 외래키를 가지는 구조로 이뤄지는데 ,JPA에서도 이를 반영해서 한쪽 엔티티에서만 외래키를 가질 수 있도록 정하는 것이 좋다.

이를 연관관계에서 주인(Owner)을 정하는 것이라 하는데, 어떤 객체가 주인인지 정하려면 주인이 되지 않을 객체에 mappedBy를 추가하면 된다.
mappedBy에는 주인쪽의 필드명을 적어주어야 하는데 여기서는 Product가 주인이 되기 때문에 Product가 가지고 있는 provider라는 필드명을 적어주면 된다.

현재 양방향 관계를 맺었지만 외래키는 Product가 가지기 때문에 ProvidermappedBy를 추가해주어야 한다.

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Provider {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @OneToOne(mappedBy = "provider") //Provider -> Product 단방향 매핑 추가
    private Product product;
}

양방향 매핑 관계에서 주의해야 할 점은 바로 순환참조이다.
두 엔티티가 서로를 필드값으로 가지고 있기 때문에 toString()을 실행했을 경우 Product를 출력하면 필드값인 Provider도 출력되고, Provider에도 Product 필드가 있기 때문에 다시 출력되는 루프에 빠지게 되어 StackOverFlowError가 발생한다.
(Product->Provider->Product->Provider->.....)

이를 방지하려면 exclude를 사용해 ToString에서 제외 설정을 해야 하는데 보통은 외래키를 가진 쪽(연관관계의 주인)에 추가를 한다. 필요하다면 반대쪽(연관관계의 주인이 아닌 쪽)에도 추가해도 된다.

//Product 클래스
	@OneToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;

2. N:1 / 1:N 매핑

동일한 공급 업체와 연관관계가 있는 상품 엔티티들이 있다고 가정해보자.
서로 연관관계를 맺을 경우 다음과 같은 구조가 된다.

Product 입장에서 볼 경우 N:1 관계가, Provider 입장에서는 1:N 관계가 된다.
그럼 가장 먼저 N:1 매핑에 대해 알아보자.

1) N:1 단방향 매핑

Product공급 업체 번호라는 외래키를 가지도록 설계를 할 것인데 한가지 의문이 들 것이다.
N과 1중 어느쪽에서 외래키를 가지는게 더 좋을까? 정답은 N이 가지는 것이 좋다.

지금 Product는 N이기 때문에 Provider의 기본키 하나만 외래키로 가지면 된다.
하지만, Provider가 외래키를 가질 경우 모든 Product의 기본키 즉, N개의 외래키를 가져야 하기 때문에 N인 쪽이 외래키를 가지고 연관관계의 주인이 되는 것이 효율적이다.

우선 Provider 엔티티는 앞에서와 동일하게 구성하겠다.

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Provider {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;
}

Product 엔티티는 다음과 같이 구성하겠다.

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Table(name = "product")
public class Product extends BasedEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    @ManyToOne
    @JoinColumn(name = "provider_id")
    private Provider provider;
}

연관관계 설정을 했으니 테스트를 통해 올바르게 설정되었는지 확인해보겠다.

테스트 코드 작성 전에 JpaRepository를 상속받는 ProviderRespository 인터페이스를 생성해야 한다.

@SpringBootTest
public class ProviderRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private ProviderRepository providerRepository;

    @Test
    @Transactional
    void ManyToOneTest(){
        Provider provider = new Provider();
        provider.setName("OO전자");
        providerRepository.save(provider);

        Product product1 = Product.builder().name("Laptop").price(1000000).stock(100).provider(provider).build();
        Product product2 = Product.builder().name("Desktop").price(2000000).stock(50).provider(provider).build();
        Product product3 = Product.builder().name("EarPhone").price(40000).stock(200).provider(provider).build();

        productRepository.save(product1);
        productRepository.save(product2);
        productRepository.save(product3);

        System.out.println("Product1's Provider_name : " + productRepository.getReferenceById(1L).getProvider().getName());
        System.out.println("Product2's Provider_name : " + productRepository.getReferenceById(2L).getProvider().getName());
        System.out.println("Product3's Provider_name : " + productRepository.getReferenceById(3L).getProvider().getName());
    }
}

빌더 패턴으로 필드값을 정의하였고 모든 Product 객체의 공급 업체는 같은 곳으로 지정하였다.

Product1's Provider_name : OO전자
Product2's Provider_name : OO전자
Product3's Provider_name : OO전자

같은 공급 업체명이 출력된 것을 알 수 있다.
데이터베이스에도 정상적으로 반영되었는지 확인해보자.

Provider 테이블이 정상적으로 생성되었고 OO전자만 저장했기 때문에 공급 업체 번호가 1L로 1개의 데이터만 저장되었다.

Product 테이블에는 외래키로 Providerid 값이 지정되어 있으며

저장한 레코드들이 모두 같은 provider_id 값을 가지는 것을 알 수 있다.

2) N:1 양방향 매핑

양방향 매핑이기 때문에 이번에는 추가로 Provider -> Product 방향으로 단방향 매핑을 맺어야 한다.

Provider 엔티티 클래스를 다음과 같이 수정해보자.

@Entity
@Table(name = "provider")
@Builder
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Provider {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;
    
    @OneToMany(mappedBy = "provider", fetch = FetchType.EAGER)
    private List<Product> productList = new ArrayList<>();
}

Provider 입장에서는 1:N이기 때문에 @ManyToOne이 아닌 @OneToMany를 추가해주어야 한다. 기존에는 연관관계의 대상이 되는 객체를 필드로 정의했지만 ProviderProduct와 1:N 관계를 가지기 때문에 N개의 객체를 가져야 한다.
그래서 List 형태로 객체를 가지도록 필드를 정의한 것이다.

외래키는 Product쪽에서 가지기 때문에 연관관계의 주인은 Product가 되므로 mappedBy를 설정해주었다.

fetch 값은 일단 예시와 같이 설정해두고 왜 이렇게 되는지는 가장 마지막에 설명하도록 하겠다.


양방향 매핑을 하는 이유는 서로를 참조하여 양쪽에서 서로에게 접근이 가능하기 때문이다.
또한 API 응답에서 데이터 구조를 명확하게 표현할 때 필요하기 때문이다.
예를 들어, 특정 공급 업체에서 생산된 상품을 조회할 경우 List 로 가지고 있어야 편리하다.

하지만, 양방향 매핑을 설정할 경우 연관관계의 주인을 반드시 설정해야 한다.
만약, Provider에서 Product 객체를 List에 저장하려고 한다면 다음과 같이 할 것이다.

Provider provider = new Provider();
provider.getProductList().add(product1);
provider.getProductList().add(product2);
provider.getProductList().add(product3);

Provider는 연관관계에서 주인이 아니기 때문에 해당 데이터는 데이터베이스에 반영되지 않는다. 이런 이유로 주인을 반드시 설정하고 데이터베이스에 반영할 경우 연관관계의 주인이 데이터를 반영해야 한다.
연관관계에서 주인이 아닌 엔티티는 상대 엔티티를 "읽기(SELECT)"만 가능하고 주인인 엔티티만 "읽기(SELECT)"와 "쓰기(INSERT/UPDATE)"가 가능하기 때문이다.

3. M:N 매핑

상품과 생산업체를 예를 들자면 한 종류의 상품은 여러 개의 생산업체를 통해 생산될 수 있고 생산업체 한 곳이 여러 상품을 생산할 수 있다.

그래서 각 엔티티에서는 서로를 List로 가지는 구조가 만들어진다.

1) 단방향 매핑

//Producer 엔티티 클래스

@Entity
@Table(name = "producer")
@Getter
@Setter
@NoArgsConstructor
public class Producer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String code;

    @Column(nullable = false)
    private String name;

    @ManyToMany
    @ToString.Exclude
    List<Product> productList = new ArrayList<>();
    
}

Producer -> Product 방향의 단방향 매핑을 설정하기 위해 Producer라는 엔티티 클래스를 만들어보았다.

List를 필드로 가지는 객체에서는 외래키를 가지지 않기 때문에 @JoinColumn은 설정하지 않아도 된다.
그렇다면 외래키는 누가 가지게 되는걸까?

다대다 매핑에서는 중간 테이블이 생성이 되는데, 이 테이블이 일대다, 다대일 관계로 다대다 관계를 해소한다.
이 중간 테이블에서 두 엔티티에 대한 외래키를 가지게 된다.

@SpringBootTest
public class ProducerRepositoryTest {
    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private ProducerRepository producerRepository;

    @Test
    @Transactional
    void ManyToManyTest(){
        Product product1 = Product.builder().name("pen").price(1000).stock(200).build();
        Product product2 = Product.builder().name("pencil").price(500).stock(100).build();
        Product product3 = Product.builder().name("eraser").price(200).stock(500).build();

        productRepository.save(product1);
        productRepository.save(product2);
        productRepository.save(product3);

        Producer producer1 = new Producer();
        producer1.setCode("A-1");
        producer1.setName("AB문구");
        producer1.getProductList().add(product1);
        producer1.getProductList().add(product2);

        Producer producer2 = new Producer();
        producer2.setCode("B-2");
        producer2.setName("알파문구");
        producer2.getProductList().add(product3);

        producerRepository.saveAll(Lists.newArrayList(producer1,producer2));

    }
}

테스트 코드를 실행시키고 데이터베이스를 확인하면 중간 테이블이 생성된 것을 볼 수 있다.

외래키가 2개 생성되었는데 매핑을 설정한 Producer의 기본키와 Product들의 List의 기본키로 저장이 된다.

저장된 데이터를 확인해보면 생산업체의 번호(기본키)와 상품의 번호(기본키)가 각각 중간 테이블의 외래키로 저장되어 있는 것을 알 수 있다.

2) 양방향 매핑

다대다 단방향 매핑을 설정했다면 양방향 매핑은 어렵지 않다.
이번에는 Product에서 ProducerList를 만들고 단방향 매핑을 추가해주면 된다.

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class Product extends BasedEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    @ManyToMany
    @ToString.Exclude
    private List<Producer> producerList = new ArrayList<>();
}

M:N 양방향 매핑의 특이한 점은 연관관계의 주인을 설정하지 않아도 동작은 한다는 것이다.
양쪽 모두 중간 테이블을 참조하기 때문에 주인/비주인 개념이 @OneToOne이나 @ManyToOne처럼 명확하지가 않다.

하지만, JPA에서는 누가 중간 테이블을 관리할 것인지 결정해야 하기 때문에 결국에는 둘 중 하나를 주인으로 설정해야 한다.

즉, 중간 테이블(조인 테이블)에 접근하고 수정이 가능한 주인과 단순 읽기 전용만 가능한 비주인을 설정해두어야 일관성이 유지되기 때문에 명시적으로 설정하는 편이 좋다.


M:N 연관관계를 설정하면 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있고 관리가 힘들다는 단점이 있다.
그래서 중간 테이블 대신 중간 엔티티를 만들어 다대다 관계를 일대다, 다대일로 연관관계를 맺는 방법도 고려해볼 수 있다.

4. 영속성 전이(Cascade)

영속성 전이, cascade라고도 하는데 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티(ex.매핑되어 있는 엔티티)의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미한다.

종속, 폭포라는 뜻에 걸맞게 엔티티의 영속성 상태 변경이 연관된 엔티티에도 같이 영향을 준다고 생각하면 이해하기 쉽다.

영속성 전이를 설정하려면 @OneToOne,@ManyToOne 같은 연관관계를 설정하는 에너테이션의 속성으로 입력하면 된다.

영속성 전이 타입에는 여러 종류가 있는데 다음과 같다.

  • ALL : 모든 영속 상태 변경에 대해 영속성 전이 적용
  • PERSIST : 엔티티가 영속화할 때 연관된 엔티티도 함께 영속화
  • MERGE : 엔티티를 영속성 컨텍스트에 병합할 때 연관된 엔티티도 병합
  • REMOVE : 엔티티를 제거시 연관된 엔티티도 제거
  • REFRESH : 엔티티를 새로고침할 때 연관된 엔티티도 새로고침
  • DETACH : 엔티티를 영속성 컨텍스트에서 제외하면 연관된 엔티티도 제외

간단한 테스트 코드를 통해 영속성 전이를 확인해볼건데 영속화를 하는 경우를 예로 들 것이기 때문에 PERSIST를 사용하겠다.

상품(Product)와 공급업체(Provider)가 N:1 연관관계를 맺고 있으며, 어떤 가게가 새로운 공급업체와 계약하며 몇 가지 새 상품을 입고시키는 상황에 어떻게 영속성 전이가 적용되는지 알아보자.

@Entity
@Table(name = "provider")
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Provider {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

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

우선, Provider 클래스에서 @OneToMany에 cascade 옵션을 추가해야 한다. 영속화가 전이되는 것을 확인할 것이기 때문에 cascade 옵션은 PERSIST로 설정하겠다.

@SpringBootTest
public class ProviderRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private ProviderRepository providerRepository;

    @Test
    void cascadeTest(){
        Provider provider = savedProvider("OO물산");

        Product product1 = savedProduct("product1",1000,200);
        Product product2 = savedProduct("product2",2000,300);
        Product product3 = savedProduct("product3",3000,400);

        //연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);

        //공급업체에 새 상품들 입고
        //Provider가 연관관계의 주인이 아니지만 cascade 옵션으로 인해 Product 객체 저장이 가능
        provider.getProductList().addAll(Lists.newArrayList(product1,product2,product3));

        providerRepository.save(provider);
    }

	//객체 생성의 편의를 위해 만든 메서드
    private Provider savedProvider(String name){
        Provider provider = new Provider();
        provider.setName(name);
        return provider;
    }
	//객체 생성의 편의를 위해 만든 메서드
    private Product savedProduct(String name, Integer price, Integer stock){
        Product product = new Product();
        product.setName(name);
        product.setPrice(price);
        product.setStock(stock);
        return product;
    }
}

이전에 했던 테스트와 다르게 ProductProductRespositorysave하는 코드를 작성하지 않았다.

Provider에서 Product의 리스트를 호출하여 상품 객체를 추가하기만 하였다. 하지만, 영속성 전이로 인해 데이터베이스에 Product 객체들도 같이 저장된다.

새로운 공급업체인 OO물산이 저장되었고
영속화가 연관된 엔티티에 전이되면서 product1, product2, product3도 데이터베이스에 반영된 것을 알 수 있다.

하지만 여기서 한 가지 의문이 들 것이다. Provider는 연관관계의 주인이 아닌데 어떻게 데이터를 저장하고 데이터베이스에 반영할 수 있을까??

이게 바로 cascade옵션의 기능이다. cascade = CascadeType.PERSIST로 인해 provider를 저장(persist)할 때 필드인 productList에 포함된 Product들도 함께 저장(persist)되도록한다.

즉, 비록 연관관계의 주인은 Product이지만, 영속성 전이 덕분에 Provider에서 Product 엔티티들을 저장할 수 있는 것이다.

5. 고아 객체

JPA에서 고아 객체란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미한다. 이러한 고아 객체를 자동으로 제거하는 기능이 있는데 orphanRemoval를 사용하면 된다.
마찬가지로 @OneToOne,@ManyToOne 같은 연관관계를 설정하는 에너테이션의 속성으로 입력하면 된다.

위의 테스트에서 생성한 Product 객체 중 product1productList에서 제거하여 연관관계를 끊고 해당 객체가 데이터베이스에서 제거되는지 확인해보자.

@OneToMany(
mappedBy = "provider", 
fetch = FetchType.EAGER, 
cascade = CascadeType.PERSIST, 
orphanRemoval = true
)

우선, Provider 클래스의 옵션에 orphanRemoval = true로 설정을 해주어야 한다.

6. Fetch 전략

@OneToOne,@ManyToOne,@OneToMany,@ManyToMany에서 지정할 수 있는 속성이 있는데 optionalfetch가 있다.
참조하려고 하는 엔티티가 null인 값을 허용하지 않으려면 @OneToOne(optional = false)로 지정할 수 있다.

fetch전략은 연관된 엔티티를 언제, 어떻게 가져올지를 결정하는 중요한 설정이다.
크게 즉시로딩(FetchType.EAGER)지연로딩(FetchType.LAZY) 두 가지 전략이 있다.

1) EAGER(즉시로딩)

@OneToOne,@ManyToOnefetch 기본값으로, 연관된 데이터를 항상 가져와야 할 때 사용한다.

public class Product extends BasedEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;
}

Product 엔티티를 가져와봤는데 즉시로딩 전략을 설정하였다. 이 경우에는 Product를 조회할 경우 그 즉시 ProviderJOIN 또는 SELECT를 호출하여 데이터베이스에서 영속성 컨텍스트로 데이터를 로딩하게 된다.

아래의 테스트 코드를 통해 이해해보자.

    @Test
    void eagerFetchTest(){
        Product product = setProduct("ExampleProduct",1000,100);
        Provider provider = setProvider("ExampleProvider");

        //연관관계 설정
        product.setProvider(provider);
        providerRepository.save(provider);
        productRepository.save(product);

        //데이터 조회
        System.out.println(productRepository.findById(product.getNumber()).get());
        System.out.println(productRepository.findById(product.getNumber()).get().getProvider());
    }

setProductsetProvider는 편의상 만든 메서드이다.
Product를 즉시로딩으로 설정했기 때문에 추가적인 쿼리 요청 없이 ProductRepository -> Product -> Provider 순으로 조회가 가능하다.

이게 무슨 뜻이냐면 Product와 연관된(매핑된) ProviderProduct를 조회할 때 같이 로딩이 된다는 것인데, 만약 toString()으로 Product 객체를 출력한다면 같이 로딩된 Product 객체도 출력이 된다.

추가 쿼리 없이 즉시 접근 가능하다는 장점이 있지만 필요하지 않은 데이터도 연관되어 있으면 무조건 불러오기 때문에 성능 저하 문제가 발생할 수 있다.

2) LAZY(지연로딩)

@OneToMany,@ManyToManyfetch 기본값으로, 연관된 데이터를 실제 사용할 때 로딩을 하는 기능이다.

즉시로딩으로 설정을 하면 Product 객체 조회 시 Product, Provider 이렇게 연관된 엔티티까지 로딩을 했었다.
지연로딩은 조회 시 ProductProvider(프록시 객체)를 로딩한다.

이 프록시(Proxy) 객체란 가짜 객체로,Product 조회 시 JPA가 내부적으로 Provider 클래스를 상속한 프록시 클래스를 만들어서 반환한다.

예를 들어

    @Test
    void eagerFetchTest(){
        Product product = setProduct("ExampleProduct",1000,100);
        Provider provider = setProvider("ExampleProvider");

        //연관관계 설정
        product.setProvider(provider);
        providerRepository.save(provider);
        productRepository.save(product);
        
        Product foundProduct = productRepository.findById(product.getNumber()).get();
        
        //DB에서 SELECT 한 Provider 객체가 아닌 프록시 객체
        Provider foundProvider = foundProduct.getProvider();
    }

위에서와 같이 ProductRepository -> Product -> Provider 순으로 데이터를 조회하여도 데이터베이스에서 SELECT 쿼리 등으로 조회한 실제 Provider 객체가 아니라는 것이다.

그럼 언제 로딩이 되는가? getName() 같은 실제 데이터를 조회하는 메서드가 호출될 때 데이터베이스에서 SELECT하여 로딩이 된다.

Product foundProduct = productRepository.findById(product.getNumber()).get();

//DB에서 SELECT 한 Provider 객체가 아닌 프록시 객체
Provider foundProvider = foundProduct.getProvider();
        
//DB에서 쿼리로 조회한 실제 Provider 객체의 필드값
String foundProviderName = foundProvider.getName(); 

그럼 굳이 왜 프록시 객체를 만들어서 반환을 할까? 이는 LAZY 전략이 성능 최적화가 목적이기 때문에 필요할 때만 데이터를 실제로 로딩함으로써 불필요한 쿼리를 방지하기 때문이다.
그래서 필요하지 않은 경우에는 프록시 객체를 반환하여 넘기는 것이다.


LAZY 전략을 사용할 경우 주의해야 할 점이 있는데 트랜잭션 안에서만 동작한다는 것이다.
Hibernate의 영속성 컨텍스트는 트랜잭션이 열려 있어야 세션을 유지하고 데이터베이스에 접근이 가능하다.

하지만, 트랜잭션이 닫혀버리면 foundProduct.getProvider().getName()을 호출해도 세션이 없고 Hibernate는 데이터베이스에 접근할 수 없기 때문에 LazyInitializationException이 발생한다.

아래의 테스트 코드를 다시 봐보자.

    @Test
    void eagerFetchTest(){
        Product product = setProduct("ExampleProduct",1000,100);
        Provider provider = setProvider("ExampleProvider");

        //연관관계 설정
        product.setProvider(provider);
        providerRepository.save(provider);
        productRepository.save(product);

        System.out.println(
        productRepository.findById(product.getNumber()).get().getProvider().getName()
        ); 
        //트랜잭션이 없으면 여기서 LazyInitializationException이 발생
    }

테스트 메서드인데 트랜잭션이 유지되지 않기 때문에 실제 Provider를 조회하는 쿼리를 요청해도 데이터베이스에 접근이 불가능하여 예외가 발생하는 것이다.

그럼 어떻게 해결해야 하나?

첫 번째 방법은 @Transactional을 사용하는 것이다. 이 에너테이션을 메서드에 추가하면 트랜잭션이 메서드 종료까지 유지되므로 예외가 발생하지 않는다.

    @Test
    @Transactional
    void eagerFetchTest(){
        Product product = setProduct("ExampleProduct",1000,100);
        Provider provider = setProvider("ExampleProvider");

        //연관관계 설정
        product.setProvider(provider);
        providerRepository.save(provider);
        productRepository.save(product);

        System.out.println(productRepository.findById(product.getNumber()).get().getProvider().getName());
        //트랜잭션이 유지되어 예외 발생X
    }

두 번째 방법은 fetch 전략을 EAGER로 변경하여 사용하는 것이다. 항상 즉시 로딩되기 때문에 예외가 발생하지 않는다. 하지만, 성능 저하를 염두해두어야 한다.

3) N+1 문제

fetch 전략을 LAZY로 설정함에 따른 문제점 중 하나로 N+1 문제가 있다.
ProductProvider@ManyToOne의 연관관계로 설정되어 있을 경우를 생각해보자.
Provider의 데이터를 로딩하기 위해서는 Product를 조회하는 쿼리를 실행하고 다시 한 번 Provider를 조회하는 쿼리를 실행해야 한다.

이때 실행되는 쿼리의 수는 Product를 불러올 때 1개, Provider를 불러올 때 N개로 총 N+1개의 쿼리가 실행된다.
(1개의 Provider가 N개의 Product와 연관관계를 맺고 있기 때문에 N개의 Product마다 Provider를 각각 조회)

이해를 위해 다음 예시 코드를 보자.

        List<Product> productList = productRepository.findAll();
        //실행되는 쿼리 : SELECT * FROM product;
        for(Product product : productList){
            System.out.println(product.getProvider().getName());
            //실행되는 쿼리 : SELECT * FROM provider WHERE id = ?;
        } 
        //반복문이므로 N개만큼 쿼리가 실행

이러한 N+1개의 쿼리 실행은 성능 저하나 데이터베이스에 부하를 일으킬 수 있다.

그렇다면 어떻게 해결해야 하는가?

1번째 방법 : Fetch Join 사용

@Query를 사용하여 쿼리를 작성하되 JOIN FETCH를 추가하는 것이다.

public interface ProductRepository extends JpaRepository<Product,Long> {
    @Query("SELECT p FROM product p JOIN FETCH p.provider")
    List<Product> findAllWithProvider();
}

Repository에 쿼리 메서드를 작성하고 @Query를 사용하여 JOIN FETCH를 추가하면 ProductProvider를 한 번의 쿼리로 모두 조회가 가능하다.

2번째 방법 : FetchType.EAGER 사용

간단하게 Fetch전략을 EAGER로 바꾸는 방법이다. 즉시 로딩으로 설정하여 N+1 문제를 피할 수 있지만, 과도한 조인으로 오히려 성능 저하가 발생할 수 있어 남용하지 않는 것이 좋다.

3번째 방법 : EntityGraph 사용

2번째 방법과 마찬가지로 과도한 조인으로 오히려 성능 저하가 발생할 수 있다.
특정 연관 엔티티(Provider)를 즉시 로딩처럼 불러오되, 쿼리 명시 없이 선언적으로 제어가 가능하다.

FetchType.EAGER가 엔티티 클래스에 선언하는 방법이면 EntityGraph는 레포지토리 메서드에 선언하는 방법이다.

public interface ProductRepository extends JpaRepository<Product,Long> {
    //fetch join를 사용
    @Query("SELECT p FROM product p JOIN FETCH p.provider")
    List<Product> findAllWithProvider();

	//EntityGraph를 사용
    @EntityGraph(attributePaths = "provider")
    List<Product> findAll();
}

하지만, 메서드에 따라 선택적으로 즉시 로딩을 적용할 수 있고 남용 위험이 적기 때문에 2번째 방법보다는 3번째 방법이 더 유연하고 성능 관리에 유리하다.

0개의 댓글