MariaDB같은 RDBMS를 사용할 떄는 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기란 불가능한다. 연관관계를 설정해서 조인(Join) 등의 기능을 활용해야 한다. 이번에는 JPA에서 연관관계를 매핑하고 사용하는 방법을 알아보도록 한다.
재고관리시스템을 통해 상품을 관리하는 것을 예로 들어 이해해본다. 재고로 등록돼 있는 상품 엔티티에는 가게로 상품을 공급하는 공급업체의 정보 엔티티가 매핑되어있다. 공급업체 입장에서 보면 한 가게게 납품하는 상품이 여러개 있을 수 있으므로 상품 엔티티와는 일대다 관계가 되며, 상품 입장에서 보면 하나의 공급업체에 속하게 되므로 다대일 관계가 된다. 어떤 엔티티를 중심으로 연관 엔티티를 보느냐에 따라 연관관계의 상태가 달라진다.
하나의 상품에 하나의 상품정보만 매핑되는 구조를 일대일 매핑이라고 한다.
ProductDetail 엔티티를 만들어준다. 이때 @OneToOne, @JoinColum 어노테이션을 붙여준다.
@Entity
@Table(name = "product_detail")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@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;
}
테스트코드를 작성하고 실행한다.
@SpringBootTest
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().toString());
System.out.println("savedProductDetail : " +
productDetailRepository.findById(productDetail.getId()).get().toString());
}
}

select 구문을 보면 ProductDetail과 Prodcut 객체가 함께 조회되는 것을 볼 수 있다. 이처럼 엔티티를 조회할 때 연관된 엔티티도 함께 조회하는 것을 즉시 로딩이라고 한다.
그리고 이때 join 부분을 보면 두번의 join을 하는 것을 볼 수 있다. 이는 @OneToOne어노테이션 때문이다.
@OneToOne 어노테이션은 fetch전략으로 EAGER를 사용하낟. 즉, 즉시 로딩 전략이 채택된 것이다. 이때 두번의 join이 아니라 한번의 join(최적의 쿼리)을 위해서는 @OneToOne 어노테이션에 optional=false 속성을 주어야한다.
객체에서 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미한다.
Product 엔티티에 @OneToOne 어노테이션 추가해준다.
@Entity
@Table(name = "product")
@ToString
@Getter
@Setter
@NoArgsConstructor
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;
}

이번에는 left join을 두번 수행한다. 이처럼 양쪽에서 외래키를 가지고 left join을 두번이나 수행되면 효율성이 떨어진다. 실제 테이블 간 연관관계를 맺으면 한쪽 테이블이 외래키를 가지는 구조로 이루어진다. 이를 위해서 mappedBy 속성을 사용하여 어떤 객체가 주인인지 표시해주어야 한다.
@Entity
@Table(name = "product")
@ToString
@Getter
@Setter
@NoArgsConstructor
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(mappedBy = "product") // 이렇게 속성을 사용해 준다.
@ToString.Exclude
private ProductDetail productDetail;
}
하나의 공급업체가 여러개의 상품에 매핑되는 구조를 일대다 매핑, 중점을 상품에 두면 다대일 매핑이 된다.
Provider 엔티티를 만들어준다.
@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;
}
@Entity
@Table(name = "product")
@ToString
@Getter
@Setter
@NoArgsConstructor
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(mappedBy = "product")
@ToString.Exclude
private ProductDetail productDetail;
@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;
}
테스트코드를 작성하고 실행시킨다.
@SpringBootTest
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).get().getProvider().toString());
}
}

쿼리로 저장할 때는 provider_id값만 들어가는 것을 볼 수 있다. 이렇게 테이블에 @JoinColumn에 설정한 이름을 기반으로 자동으로 값을 선정해서 추가하게 된다.
@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<>();
}
@Test
void relationshipTest2(){
// 테스트 데이터 생성
Provider provider = new Provider();
provider.setName("자바물산");
providerRepository.save(provider);
Product product1 = new Product();
product1.setName("가위");
product1.setPrice(5000);
product1.setStock(500);
product1.setProvider(provider);
Product product2 = new Product();
product2.setName("펜");
product2.setPrice(2000);
product2.setStock(100);
product2.setProvider(provider);
Product product3 = new Product();
product3.setName("가방");
product3.setPrice(20000);
product3.setStock(200);
product3.setProvider(provider);
productRepository.save(product1);
productRepository.save(product2);
productRepository.save(product3);
List<Product> productList = providerRepository.findById(provider.getId()).get()
.getProductList();
for (Product product : productList){
System.out.println(product);
}
}

여러개의 상품을 여러개의 생산자가 생산하는 구조를 다대다 매핑이라고 한다. 하지만 이는 실무에서 거의 사용되지 안흔 구조이다.
다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다.
@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
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
public void addProduct(Product product){
productList.add(product);
}
}
@SpringBootTest
class ProducerRepositoryTest {
@Autowired
ProducerRepository producerRepository;
@Autowired
ProductRepository productRepository;
@Test
@Transactional
void relationshipTest1(){
Product product1 = saveProduct("동글펜", 500, 1000);
Product product2 = saveProduct("네모 공책", 100, 2000);
Product product3 = saveProduct("지우개", 152, 1234);
Producer producer1 = saveProducer("flature");
Producer producer2 = saveProducer("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().getProductList());
}
private Product saveProduct(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 saveProducer(String name){
Producer producer = new Producer();
producer.setName(name);
return producerRepository.save(producer);
}
}
가독성을 위해 레포지토리를 통해 테스트를 생성하는 부분을 별도 메서드로 빼서 구현했다.
@Entity
@Table(name = "product")
@ToString
@Getter
@Setter
@NoArgsConstructor
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(mappedBy = "product")
@ToString.Exclude
private ProductDetail productDetail;
@ManyToOne
@JoinColumn(name = "provider_id")
@ToString.Exclude
private Provider provider;
@ManyToMany
@ToString.Exclude
private List<Producer> producerList = new ArrayList<>();
public void addProducer(Producer producer){
this.producerList.add(producer);
}
}
@Test
@Transactional
void relationshipTest2(){
Product product1 = saveProduct("동글펜", 500, 1000);
Product product2 = saveProduct("네모 공책", 100, 2000);
Product product3 = saveProduct("지우개", 152, 1234);
Producer producer1 = saveProducer("flature");
Producer producer2 = saveProducer("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(producerRepository.findById(1L).get().getProductList());
System.out.println(productRepository.findById(1L).get().getProducerList());
}
private Product saveProduct(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 saveProducer(String name){
Producer producer = new Producer();
producer.setName(name);
return producerRepository.save(producer);
}
영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미한다.
영속성 전이를 적용할 Provider 엔티티를 다음과 같이 수정해 줍니다.
@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", cascade = CascadeType.PERSIST)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
}
@OneToMany 어노테이션에 cascade속성을 활용한다.
@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에는 이러한 고아 객체를 자동으로 제거하는 기능이 있다.
@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", cascade = CascadeType.PERSIST, orphanRemoval = true)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
}
orphanRemoval = true 속성으로 고아 객체를 제거하는 기능을 활성화한다.
@Test
@Transactional
void orphanRemoval(){
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);
}
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;
}
연관관계 끊기 전

연관관계 끊기 전

출력 결과를 보면 실제로 연관관계가 제거되면서 하이버네이트에서는 상태 감지를 통해 삭제하는 쿼리가 수행되는 것을 볼 수있다.
Product1의 연관관계가 끊어지면서 제거된 것을 전, 후 비교로 알 수 있다.
연관관계를 설정함으로 더 편하게 데이터를 저장할 수 있고 사용할 수 있다는 것을 알게되었다. 또한 영속성 전이를 통해서 연관관계가 끊어진 객체가 자동으로 삭제되기도 하고 자동으로 저장되기도 하는 기능을 배웠다. 항상 save매서드를 반복적으로 사용했었는데 실제로 프로젝트에서 사용한다면 시간적 효율을 증가시킬 수 있겠다는 생각이 들었다.
또한 이번 챕터를 공부하면서 궁금했던 점은 왜 테스트코드에서 @Transactional 어노테이션을 사용했는지와, saveAndFlush() 메서드가 save()와 다른점이 무엇인가 였다. 코드를 이해하기 위해 간략하게는 찾아봤지만 아직 잘 이해가 되지 않아서 추가적인 공부가 필요할것 같다.