[Spring] 연관관계 매핑

Miin·2023년 11월 25일
0

Spring

목록 보기
13/17

연관관계 매핑 종류와 방향

  • One To One : 일대일(1:1)
  • One To Many : 일대다(1:N)
  • Many To One : 다대일(N:1)
  • Many To Many : 다대다(N:M)

예를 들어, 재고로 등록돼 있는 상품 엔티티에는 가게로 상품을 공급하는 공급업체의 정보 엔티티가 매핑되어 있다. 공급업체 입장에서 보면 한 가게에 납품하는 상품이 여러 개 있을 수 있으므로 상품 엔티티와는 일대다 관계이다. 반면, 상품 입장에서는 하나의 공급업체에 속하게 되므로 다대일 관계이다.

즉, 어떤 엔티티를 중심으로 연관 엔티티를 보느냐에 따라 연관관계의 상태가 달라진다.


데이터베이스에서는 두 테이블의 연관관계를 설정하면 외래키를 통해 서로 조인해서 참조하는 구조로 생성되지만 JPA를 사용하는 객체지향모델링에서는 엔티티 간 참조 방향 설정이 가능하다. 데이터베이스와 관계를 일치시키기 위해 양방향으로 설정해도 무관하지만 비즈니스 로직의 관점에서 단방향 관계만 설정해도 해결되는 경우가 많다.

  • 단방향: 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
  • 양방향: 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식

연관관계가 설정되면 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 일반적으로 외래키를 가진 테이블이 그 관계의 주인(Owner)이 되며, 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행 가능.


📌 일대일 매핑

일대일 단방향 매핑

@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @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;

}
@Entity
@Table(name = "product_detail")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ProductDetail extends BaseEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String description;
    
    @OneToOne
    @JoinColumn(name = "product_number")
    private Product product;
    
}

@OneToOne 어노테이션

다른 엔티티 객체를 필드로 정의했을 때 일대일 연관관계로 매핑하기 위해 사용

Product 객체가 반드시 있어야 한다면 @OneToOne(optional = false) - product가 null인 값을 허용하지 않음

@JoinColumn 어노테이션

매핑할 외래키 설정

@JoinColumn을 선언하지 않으면 엔티티를 매핑하는 중간 테이블이 생기면서 관리 테이블이 늘어나 좋지 않음

  • name: 매핑할 외래키의 이름을 설정하는 속성
  • referencedColumnName: 외래키가 참조할 상대 테이블의 컬럼명 지정
  • foreignKey: 외래키를 생성하면서 지정할 제약조건 설정(unique, nullable, insertable, updatable 등)

hibernate.ddl-autocreate 설정 후 애플리케이션 실행 -> 하이버네이트에서 자동으로 테이블 생성


테스트 코드

@SpringBootTest
public class ProductDetailRepositoryTest {

    @Autowired
    ProductDetailRepository productDetailRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    public void saveAndReadTest1() {
        Product product = new Product();
        product.setName("스프링 부트 JPA");
        product.setPrice(5000);
        product.setStock(500);
        productRepository.save(product);

        ProductDetail productDetail = new ProductDetail();
        productDetail.setProduct(product);
        productDetail.setDescription("스프링 부트와 JPA를 함께 볼 수 있는 책");
        
        productDetailRepository.save(productDetail);
        
        // 생성한 데이터 조회
        System.out.println("savedProduct : " + productDetailRepository.findById(productDetail.getId()).get().getProduct());

        System.out.println("savedProductDetail : " + productDetailRepository.findById(productDetail.getId()).get());
    }
    
}

ProductDetail 객체에서 Product 객체를 일대일 단방향 연관관계를 설정했기 때문에 ProductDetailRepository에서 ProductDetail 객체를 조회한 후 연관 매핑된 Product 객체 조회 가능

이처럼 엔티티 조회 시 연관된 엔티티도 함께 조회하는 것 -> 즉시 로딩


일대일 양방향 매핑

@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @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
    private ProductDetail productDetail;

}

Product 엔티티에도 @OneToOne private ProductDetail productDetail; 코드를 추가해주고 애플리케이션을 실행하면 product 테이블에도 컬럼이 생성됨


실제 데이터베이스의 연관관계를 반영해서 한쪽의 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋음. 엔티티는 양방향으로 매핑하되 한쪽에게만 외래키를 줘야 함.

  • mappedBy : 어떤 객체가 주인인지 표시하는 속성
	@OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;

ProductDetail 엔티티가 Product 엔티티의 주인이 됨.
양방향으로 연관관계가 설정되면 ToString을 사용할 때 순환참조가 발생하기 때문에 대체로 단방향으로 설정하거나 양방향 설정이 필요할 경우 순환참조 제거를 위해 @ToString.Exclude를 추가해주어야 함.


📌 다대일 매핑, 일대다 매핑


상품 테이블과 공급업체 테이블은 상품 테이블의 입장에서 볼 경우 다대일, 공급업체 테이블 입장에서 볼 경우 일대다 관계로 볼 수 있음.

다대일 단방향 매핑

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {
	// 공급업체 엔티티 클래스
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
}
public class Product extends BaseEntity {

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

}

공급업체 엔티티에 대한 다대일 연관관계 설정

일반적으로 외래키를 갖는 쪽이 주인의 역할을 수행하기 때문에 이 경우 상품 엔티티가 공급업체 엔티티의 주인임.


테스트 코드

@SpringBootTest
public class ProviderRepositoryTest {
    
    @Autowired
    ProductRepository productRepository;
    
    @Autowired
    ProviderRepository providerRepository;
    
    @Test
    void relationshipTest1() {
        // 테스트 데이터 생성
        Provider provider = new Provider();
        provider.setName("코너물산");
        
        providerRepository.save(provider);

        Product product = new Product();
        product.setName("가위");
        product.setPrice(5000);
        product.setStock(500);
        product.setProvider(provider);
        
        productRepository.save(product);
        
        // 테스트
        System.out.println("product : " + productRepository.findById(1L)
                .orElseThrow(RuntimeException::new));

        System.out.println("provider : " + productRepository.findById(1L)
                .orElseThrow(RuntimeException::new).getProvider());
    }
    
}

Product 엔티티에서 단방향으로 Provider 엔티티 연관관계를 맺고 있기 때문에 ProductRepository만으로도 Provider 객체 조회 가능


다대일 양방향 매핑

아까와 반대로 공급업체를 통해 등록된 상품을 조회하기 위한 일대다 연관관계 설정

JPA에서는 이처럼 양쪽에서 단방향으로 매핑하는 것이 양방향 매핑 방식

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

}

fetchType.EAGER : @OneToMany의 기본 fetch 전략이 Lazy이기 때문에 즉시 로딩으로 조정

mappedBy로 설정된 필드는 컬럼에 적용되지 않음, 양쪽에서 연관관계를 설정하고 있을 때 mappedBy를 통해 한쪽으로 외래키 관리를 위임한 것

지연로딩(lazy loading)과 즉시로딩(eager loading)
연관관계와 상관없이 즉각 해당 엔티티의 값만 조회하고 싶거나 연관관계를 가진 테이블의 값도 조회하고 싶은 경우 등 여러 조건들을 만족하기 위해 등장한 개념


테스트 코드

@SpringBootTest
public class ProviderRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    ProviderRepository providerRepository;
    
    @Test
    void relationshipTest() {
        
        // 테스트 데이터 생성
        Provider provider = new Provider();
        provider.setName("코너상사");

        providerRepository.save(provider);

        Product product1 = new Product();
        product1.setName("펜");
        product1.setPrice(2000);
        product1.setStock(100);
        product1.setProvider(provider);

        Product product2 = new Product();
        product2.setName("가방");
        product2.setPrice(20000);
        product2.setStock(200);
        product2.setProvider(provider);

        Product product3 = new Product();
        product3.setName("노트");
        product3.setPrice(3000);
        product3.setStock(1000);
        product3.setProvider(provider);
        
        productRepository.save(product1);
        productRepository.save(product2);
        productRepository.save(product3);

        List<Product> products = providerRepository.findById(provider.getId()).get().getProductList();
        
        for (Product product : products) {
            System.out.println(product);
        }
    }
    
}

일대다 단방향 매핑

※ @OneToMany를 사용하는 입장에서는 어느 엔티티 클래스도 연관관계의 주인이 될 수 없음

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
@EqualsAndHashCode
@Table(name = "category")
public class Category {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true)
    private String code;
    
    private String name;
    
    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "category_id")
    private List<Product> products = new ArrayList<>();
    
}

Category 엔티티 클래스를 생성하고 애플리케이션을 실행하면 category 테이블이 생성되고 상품 테이블에 외래키가 추가됨

@OneToMany@JoinColumn을 사용하면 상품 엔티티에서 별도의 설정을 하지 않아도 일대다 단방향 연관관계가 매핑됨

🔻 일대다 단방향 관계의 단점 - 매핑의 주체가 아닌 반대 테이블에 외래키 추가됨. 다대일 구조와 다르게 외래키를 설정하기 위해 다른 테이블에 대한 update 쿼리를 발생시킴.


테스트 코드

@SpringBootTest
public class CategoryRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    CategoryRepository categoryRepository;

    @Test
    void relationshipTest() {
        // 테스트 데이터 생성
        Product product = new Product();
        product.setName("펜");
        product.setPrice(2000);
        product.setStock(100);

        productRepository.save(product);

        Category category = new Category();
        category.setCode("S1");
        category.setName("도서");
        // Product 객체를 Category에서 생선한 리스트 객체에 추가해 연관관계 설정
        category.getProducts().add(product);

        categoryRepository.save(category);

        // 테스트 코드
        List<Product> products = categoryRepository.findById(1L).get().getProducts();

        for (Product foundProduct : products) {
            System.out.println(foundProduct);
        }
    }

}

일대다 연관관계에서는 연관관계 설정을 위한 update 쿼리 발생 -> 다대일 연관관계를 사용하는 것이 좋음


📌 다대다 매핑

❕실무에서 거의 사용되지 않음


한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수도 있음

다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어짐. 이런 경우에는 교차 엔티티로 부르는 중간 테이블을 생성해 다대다 관계를 일대다 또는 다대일 관계로 해소.

다대다 단방향 매핑

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "producer")
public class Producer extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String code;

    private String name;

    @ManyToMany
    @JoinTable(name = "producer_products")
    @ToString.Exclude
    private List<Product> products = new ArrayList<>();

    public void addProduct(Product product) {
        products.add(product);
    }
    
}

리스트로 필드를 가지는 객체에서는 외래키를 가지지 않기 때문에 별도의 @JoinColumn은 설정하지 않아도 됨.

생산업체 테이블에는 별도의 외래키가 추가되지 않으며, 데이터베이스에 추가로 중간 테이블이 생성됨. producer_products 테이블의 경우 상품 테이블과 생산업체 테이블에서 id 값을 가져와 두 개의 외래키가 설정되는 것을 볼 수 있음.

테스트 코드

@SpringBootTest
public class ProducerRepositoryTest {

    @Autowired
    ProducerRepository producerRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    @Transactional
    void relationshipTest() {

        Product product1 = savedProduct("동글펜", 500, 1000);
        Product product2 = savedProduct("네모 공책", 100, 2000);
        Product product3 = savedProduct("지우개", 152, 1234);

        Producer producer1 = savedProducer("flature");
        Producer producer2 = savedProducer("wikibooks");

        producer1.addProduct(product1);
        producer1.addProduct(product2);

        producer2.addProduct(product2);
        producer2.addProduct(product3);

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

        System.out.println(producerRepository.findById(1L).get().getProducts());

    }

    private Product savedProduct(String name, Integer price, Integer stock) {
        Product product = new Product();
        product.setName(name);
        product.setPrice(price);
        product.setStock(stock);

        return productRepository.save(product);
    }

    private Producer savedProducer(String name) {
        Producer producer = new Producer();
        producer.setName(name);

        return producerRepository.save(producer);
    }
}

중간 테이블에 생성된 레코드를 확인하면 다음과 같음


다대다 양방향 매핑

public class Product extends BaseEntity {

    ...
    
    @ManyToMany
    @ToString.Exclude
    private List<Producer> producers = new ArrayList<>();
    
    public void addProducer(Producer producer) {
        this.producers.add(producer);
    }

}

테스트 코드

	@Test
    @Transactional
    void relationshipTest2() {

        Product product1 = savedProduct("동글펜", 500, 1000);
        Product product2 = savedProduct("네모 공책", 100, 2000);
        Product product3 = savedProduct("지우개", 152, 1234);

        Producer producer1 = savedProducer("flature");
        Producer producer2 = savedProducer("wikibooks");

        producer1.addProduct(product1);
        producer1.addProduct(product2);
        producer2.addProduct(product2);
        producer2.addProduct(product3);

        product1.addProducer(producer1);
        product2.addProducer(producer1);
        product2.addProducer(producer2);
        product3.addProducer(producer2);

        producerRepository.saveAll(Lists.newArrayList(producer1, producer2));
        productRepository.saveAll(Lists.newArrayList(product1, product2, product3));

        System.out.println("products : " + producerRepository.findById(1L).get().getProducts());

        System.out.println("producers : " + productRepository.findById(2L).get().getProducers());
        
    }

🔻 다대다 연관관계에서는 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있음. 즉, 관리하기 힘든 포인트가 발생하는 문제.

📌 중간 테이블을 생성하는 대신 일대다 다대일 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋음.


📌 영속성 전이(cascade)

특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것

❗영속성 전이 타입 종류❗

cascade() 요소와 함께 사용

영속성 전이에 사용되는 타입은 엔티티 생명주기와 연관

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

영속성 전이 적용

public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

}

테스트 코드

	@Test
    void cascadeTest() {
        Provider provider = savedProvider("새로운 공급업체");

        Product product1 = savedProduct("상품1", 1000, 1000);
        Product product2 = savedProduct("상품2", 500, 1500);
        Product product3 = savedProduct("상품3", 750, 500);

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

        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;
    }

지금까지는 엔티티를 데이터베이스에 저장하기 위해 각 엔티티를 저장하는 코드를 작성해야 했음. 하지만 영속성 전이를 사용하면, 부모 엔티티가 되는 Provider 엔티티만 저장하면 코드에 작성돼 있는 Cascade.PERSIST에 맞춰 상품 엔티티도 함께 저장할 수 있음.


고아 객체

JPA에서 고아(orphan)란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미

JPA에는 이러한 고아 객체를 자동으로 제거하는 기능이 있음.

public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

}

orphanRemoval = true 속성 - 고아 객체를 제거하는 기능

테스트 코드

	@Test
    @Transactional
    void orphanRemovalTest() {
        Provider provider = savedProvider("새로운 공급업체");

        Product product1 = savedProduct("상품1", 1000, 1000);
        Product product2 = savedProduct("상품2", 500, 1500);
        Product product3 = savedProduct("상품3", 750, 500);

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

        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));

        providerRepository.saveAndFlush(provider);

        providerRepository.findAll().forEach(System.out::println);
        productRepository.findAll().forEach(System.out::println);

        Provider foundProvider = providerRepository.findById(1L).get();
        foundProvider.getProductList().remove(0); // 연관관계 제거

        providerRepository.findAll().forEach(System.out::println);
        productRepository.findAll().forEach(System.out::println);
    }




✔️ JPA를 사용할 때 영속성 개념은 매우 중요하다. 코드를 통해 편리하게 데이터베이스에 접근하기 위해서는 중간에서 엔티티의 상태를 조율하는 영속성 컨텍스트가 어떻게 동작하는지 이해해야 한다!




출처 - (책) 스프링 부트 핵심 가이드 / 장정우, 위키북스

profile
컴퓨터공학전공 학부생 Back-end Developer

0개의 댓글