
RDBMS를 사용할 때 테이블 하나만 사용해서 모든 기능을 구현하긴 어려워 여러 테이블을 연관 관계를 설계하고 조인한다.
JPA도 테이블의 연관관계를 표현할 수 있는데 이에 대해서 알아보자.
연관관계 매핑 종류와 방향
JPA에서는 두 엔티티 간에 다양한 종류의 연관관계를 정의할 수 있다.
일대일 (One-to-One)
- 한 객체가 다른 객체와 하나의 연관된 객체를 가진다.
- 예를 들어, 한 사람은 하나의 주소만 가질 수 있고, 한 주소는 하나의 사람과 연관될 수 있다.

아래 예시는 하나의 제품은 하나의 설명을 가질 수 있다는 예시다.
@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 //(optional=false) 예제 9.5
@JoinColumn(name = "product_number")
private Product product;
}
public class ProductDetailRepositoryTest {
@Autowired
private ProductDetailRepository productDetailRepository;
@Test
public void testSaveProductDetail() {
// ProductDetail 엔티티 생성
ProductDetail productDetail = new ProductDetail();
productDetail.setDescription("Test description");
// Product 엔티티 생성
Product product = new Product();
product.setProductName("Test Product");
// Product DB저장
productRepository.save(product);
// ProductDetail에 Product 설정
productDetail.setProduct(product);
// ProductDetail 저장
ProductDetail savedProductDetail = productDetailRepository.save(productDetail);
// 저장된 ProductDetail 조회
ProductDetail foundProductDetail = productDetailRepository.findById(savedProductDetail.getId()).orElse(null);
// 조회한 ProductDetail의 description이 일치하는지 확인
assertThat(foundProductDetail.getDescription()).isEqualTo("Test description");
// 조회한 ProductDetail의 Product의 productName이 일치하는지 확인
assertThat(foundProductDetail.getProduct().getProductName()).isEqualTo("Test Product");
}
}

일대일 관계의 fetch 기본값이 EAGER이기 때문에 즉시 조인이 되는 것을 볼 수 있다.
참고: 일대일 양방향은 아래쪽의 mappedBy에 나오는 코드를 참고해주세요!
일대다 (One-to-Many)
- 한 객체가 다른 객체의 집합과 연관된다.
- 예를 들어, 한 부서에는 여러 직원이 속할 수 있지만, 각 직원은 하나의 부서에만 속할 수 있다.
아래 코드는 하나의 카테고리에는 여러 제품이 있다는 예시다.
@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<>();
}
이렇게 하면 product테이블에 외래키 category_id가 추가된다.
즉, 매핑의 주체가 아닌 반대 테이블에 외래키가 추가된다.
쿼리방식도 조금 다르게 진행된다.
category에 products에 product를 추가하고 category테이블에 저정하려고 하면,
// 생략: 카테고리 생성 and product 생성 및 db 저장
category.getProducts().add(product);
categoryRepository.save(category);
먼저 category의 code와 name만 저장하는 쿼리가 실행되고
insert into category (code, name) values (?,?)
그다음에 update문으로 product의 외래키가 업데이트 된다.
update product set category_id=? where number=?
일대다 연관관계보다는 다대일 연관관계를 사용하는 것이 좋다.
일대다는 양방향을 사용하지 않는 것이 좋다고한다.
다대일 (Many-to-One)
- 여러 객체가 한 객체와 연관된다.
- 예를 들어, 여러 주문은 하나의 고객에게 속할 수 있지만, 각 주문은 하나의 고객에만 속한다.
아래 코드는 한 공급업체는 여러 제품을 생산할 수 있다는 예시이다.
// 상품
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long number;
@Column(nullable = false)
private String name;
...
@ManyToOne
@JoinColumn(name = "provider_id") // @JoinColumn을 사용하면 상대 엔티티에 외래키가 설정된다.
@ToString.Exclude
private Provider 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
@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;
// 예제 9.14, 9.25, 9.27
@OneToMany(mappedBy = "provider", fetchType.EAGER)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
}
여러 상품 엔티티가 포함될 수 있는 일대다 관계에서는 컬렉션 형식으로 필드를 생성한다.
// 생략: 삼품 생성 및 공급업체 생성 및 DB 저장
product.setProvider(provider);
productRepository.save(product);
insert into product (provider_id, ... 그외 값들) values (?, ...)
여기서는 insert 후 업테이트가 아닌 한꺼번에 insert하는 것을 볼 수 있다.
다대다 (Many-to-Many)
실무에서 거의 사용되지 않는 구성
- 여러 객체가 서로 다른 여러 객체와 연관된다.
- 예를 들어, 여러 학생은 여러 과목을 수강할 수 있고, 각 과목은 여러 학생에게 수강될 수 있다.
아래 코드는 한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수 있다는 점이다.
@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> products = new ArrayList<>();
public void addProduct(Product product){
products.add(product);
}
}
이렇게 하면 생산업체 테이블이나 제품 테이블에 외래키가 생기는 것이 아닌 중간 테이블이 생성된다.

테이블 이름을 설정하고 싶다면 @JoinTable(name="")을 사용하면 된다.
// 각각 repository로 저장한다.
// product repository로 저장
// 생략: product객체1,2,3 생성
Product product1 = productRepository.save(product객체1);
Product product2 = productRepository.save(product객체2);
Product product3 = productRepository.save(product객체3);
// producer repository로 저장
// 생략: producer 객체1,2 생성
Producer producer1 = producerRepository.save(producer객체1);
Producer producer2 = producerRepository.save(producer객체2);
// 주인객체에 연관관계객체를 세팅한다.
producer1.addProduct(product1);
producer1.addProduct(product2);
producer2.addProduct(product2);
producer2.addProduct(product3);
// 주인객체 다시 repository로 저장
producerRepository.saveAll(Lists.newArrayList(producer1, producer2);
- 다대다는 양쪽다 repository로 저장을 해두고
- 주인쪽(producer)엔티티의 연관관계엔티티(product)를 세팅을 해준 후
- 주인객체를 다시 repository로 저장하면 중간테이블에 데이터가 입력됨을 알 수 있다.

양방향은 상대 엔티티에서도 똑같이 @ManyToMany를 작성하면 된다. - 테이블구조는 변하지않는다.
마찬가지로 mappedBy속성으로 주인 설정이 가능하다.
다만, 다대다 연관관계는 중간 테이블이 형성되면서 예기치 못한 쿼리가 생길 수 있기 때문에 중간테이블을 생성하는 대신, 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋다.
자기 참조 (Self-Reference)
- 객체가 자신과 연관된다.
- 예를 들어, 조직도에서 한 부서는 다른 부서의 상위 부서가 될 수 있다.
데이터베이스에서는 두 테이블의 외래키를 통해 서로 조인해서 참조하는 구조지만 JPA는 엔티티 간 참조 방향을 설정할 수 있다.
관계 속성
targetEntity
연관된 엔티티의 클래스를 지정.
대상 엔티티 클래스를 직접 참조하거나, 지정하지 않고 관계를 매핑하는 대상 엔티티의 필드를 참조한다.
cascade: 영속성 전이
CascadeType[] cascade() default {};
부모 엔티티에 대한 변경이 자식 엔티티에도 적용되는지 여부를 지정.
영속성 전이 개념에 관련되어있다.
CascadeType
- 영속성 전이 타입은 다양한 종류가 있으며, 각각의 동작이 다르다.
- 엔티티 생명주기과 연관이 있다.
- 한 엔티티가 영속 상태의 변경이 일어나면 매핑으로 연관된 엔티티에도 동일한 동작이 일어나도록 전이한다.
- 리턴타입이 배열형식이다 -> 개발자가 사용하고자 하는 cascade 타입을 골라 각 상황에 적용할 수 있다.?
- ALL: 모든 변경 작업을 연관된 엔티티에 전이.
- 즉, 부모 엔티티가 저장되거나 삭제될 때 연관된 모든 엔티티도 같이 저장되거나 삭제
- PERSIST: 부모 엔티티가 영속 상태로 변할 때 연관된 엔티티도 함께 영속화.
- 부모 엔티티가 새로 생성될 때만 해당.?
- MERGE: 부모 엔티티가 영속성 컨텍스트에 병합될 때 연관된 엔티티도 함께 병합.
- REMOVE: 부모 엔티티가 삭제될 때 연관된 엔티티도 함께 삭제.
- REFRESH: 부모 엔티티가 새로 고쳐질 때 연관된 엔티티도 함께 새로 고침.
- DETACH: 부모 엔티티가 분리될 때 연관된 엔티티도 함께 분리.
- ALL_SAVE_UPDATE: Hibernate에서만 사용되는 추가적인 전이 유형으로, 모든 변경 작업(저장, 삭제, 업데이트)을 연관된 엔티티에 전이.
- ALL: 모든 변경 작업을 연관된 엔티티에 전이.
PERSIST 예
@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;
// 예제 9.14, 9.25, 9.27
@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); // 여기서 영속성 전이 수행
}
쿼리 실행문을 보면 거래처는 물론, 거래처에 추가된 상품들도 함께 저장되는 점을 알 수 있다.

이처럼 영속성 전이 타입을 설정하면 영속 상태 변화에 따라 연관된 엔티티들의 동작도 함께 수행이 가능하다.
다만 설정을 정확히 하지 않으면 의도치 않게 연관된 데이터가 모두 삭제될 수 있기 때문에 영향을 고려해서 사용해야 한다.
fetch
연관된 엔티티를 검색하는 방법을 지정.
- @OneToOne 의 기본값이 Eager다.
- @OneToMany 의 기본값이 Lazy다.
- FetchType
- 열거형을 사용하여 FetchType.LAZY(지연 로딩) 또는 FetchType.EAGER(즉시 로딩) 중 하나를 선택할 수 있다.
JPA(Java Persistence API)에서는 영속성 전이(Cascade Type)를 사용하여 엔티티의 상태 변화가 연관된 엔티티에도 영향을 미치도록 지정할 수 있습니다. 영속성 전이 타입은 다양한 종류가 있으며, 각각의 동작이 다릅니다. 주요한 영속성 전이 타입은 다음과 같습니다
optional
연관된 엔티티가 선택적인지 여부를 지정. (= nullable지정)
기본값은 true이며, false로 설정하면 연관된 엔티티가 반드시 있어야 함을 나타낸다.
mappedBy
양방향 관계에서 사용. 연관된 엔티티에서 역방향으로 참조를 할 때 사용할 필드를 지정.
이 속성을 설정하면 연관된 엔티티에서 관계의 주인이 아닌 엔티티에 대한 참조를 매핑할 수 있다.
- 주인: 연관관계가 설정됐을 때, 한 테이블에서 다른 테이블의 기본값을 외래키로 가지는 구조
양방향으로 매핑하되 한쪽에서만 외래키를 줘야 할 때 사용하는 속성이다.
mappedBy는 어떤 객체가 주인인지 표시하는 속성이다.
해당 속성을 통해 한쪽으로 외래키 관리를 위임한다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(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(mappedBy = "product") // 주인이 ProductDetail
@ToString.Exclude // 양방향 관계에서 toString 순환참조 방지
private ProductDetail productDetail;
}
}
위처럼 작성하면 ProductDetail엔티티가 Product엔티티의 주인이 된다.
- 주의: 연관관계 설정은 주인 엔티티로 가능하다.
@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;
// 예제 9.14, 9.25, 9.27
@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
}
위의 Product 엔티티가 Provider 엔티티의 주인인 상황으로 예를들면,
각 Product에 Provider객체를 설정해야한다. 코드로 말하자면,
product객체.setProvider(provider객체);
는 되지만
provider객체.getProductList().add (product객체);
는 주인이 아닌 엔티티(provider)에서 연관관계를 설정했기 때문에 데이터베이스에 반영이 되지 않는다.
궁금증
1. 일대일에서 양방향에 mappedBy지정(한테이블만 외래키)이랑.. 그냥 단방향이랑 다른게 뭐지..?
- 테이블 구조는 똑같으나 객체에 변수가 추가되는 것이 다른 것 같다
2. 다대다 관계의 양방향에선 mappedBy를 어떻게 써야하나?
- 중간테이블 이름을 넣어줘야하는게 아닐까??
3. DB에만 반영이 안되고 java 에서는 반영되나?
orphanRemoval
부모 엔티티에서 자식 엔티티를 제거할 때 자식 엔티티도 데이터베이스에서 제거할지 여부를 지정.
기본값은 false이며, true로 설정하면 자식 엔티티가 제거된다. -> casecade랑 개념이 헷갈린다? 고아객체랑 관련있는 속성인가??
로딩
즉시로딩
즉시 로딩(Eager Loading)은 관련된 엔티티도 함께 데이터베이스에서 가져와서 메모리에 로드하는 전략이다.
즉, 부모 엔티티가 로드될 때 관련된 자식 엔티티도 함께 로드된다.
부모 엔티티와 자식 엔티티 간에 즉시 로딩이 설정되어 있으면 부모 엔티티의 데이터를 사용할 때 즉시 관련된 자식 엔티티도 로드되어 메모리에 존재하게 된다.
주의점
- 성능: 데이터베이스에 수많은 연관된 엔티티가 있는 경우에는 성능 문제가 발생할 수 있다.
- 부하: 즉시 로딩은 한 번에 많은 양의 데이터를 가져와야 하므로 데이터베이스 부하가 증가할 수 있다.
- 불필요한 작업: 굳이 필요하지 않은 자식 엔티티까지 로드하는 경우가 있을 수 있다.
따라서 성능 문제를 고려하여 실제로 필요한 경우가 아니라면 지연 로딩을 사용하는 것이 일반적으로 더 좋다.
지연로딩
지연 로딩 (Lazy Loading) 은 연관된 엔티티가 실제로 사용될 때까지 로드를 지연시키는 방식으로, 필요한 시점에만 데이터베이스에서 관련된 엔티티를 로드한다.
고아 객체
JPA에는 고아 객체를 자동으로 제거하는 기능이 있다.
고아(orphan): 부모 엔티티와 연관관계가 끊어진 엔티티
...
public class Provider extends BaseEntity {
...
// orphanRemoval = true 는 고아 객체를 제거하는 기능
@OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
@ToString.Exclude
private List<Product> productList = new ArrayList<>();
}
Provider foundProvider = providerRepository.findById(1L).get();
foundProvider.getProductList().remove(0);
위와 같은 코드로 연관관계를 제거하면 제거(delete) 쿼리가 실행될 수 있다.
delete
from product
where number =?
그 외 어노테이션 정리
@JoinColumn
어노테이션을 사용해 매핑할 외래키를 설정한다.
- 해당 컬럼을 선언하지 않으면 엔티티를 매핑하는 중간 테이블이 생겨 관리에 좋지 않다.
- 속성
- name: 매핑할 외래키 이름 설정
기본값이 있어 자동 매핑되지만 의도한 이름이 들어가지 않기 때문에 name속성으로 칼럼명을 지정하는 것이 좋다. - referencedColumnName: 외래키가 참조할 상대 테이블 칼럼명 지정
- foreginKey: 외래키를 생성하면서 지정할 제약조건을 설정(unique, nullable, insertable, updatable)
- name: 매핑할 외래키 이름 설정
@ToString.Exclude
@ToString.Exclude는 Lombok 라이브러리에서 제공하는 어노테이션 중 하나로, @ToString 어노테이션과 함께 사용되어 특정 필드를 toString() 메서드에서 제외할 때 사용된다.
Java에서 toString() 메서드는 객체의 문자열 표현을 반환하는 메서드다. 보통 객체의 필드 값을 문자열로 조합하여 반환하는데, 때로는 toString()을 사용하여 객체를 문자열로 표현할 때 특정 필드를 포함시키지 않고 싶은 경우가 있다. 이런 경우에 @ToString.Exclude를 사용하여 해당 필드를 제외할 수 있다.
해당 페이지에서는 양방향 관계에서 toString 순환참조 방지를 위해 사용했다.
'spring > spring jpa' 카테고리의 다른 글
| [Spring][JPA][Exception] LazyInitializationException (0) | 2024.04.29 |
|---|---|
| [JPA] @MapsId로 FK를 PK로 설정하기 (0) | 2024.04.01 |
| [JPA] JPQL,쿼리메서드, QueryDSL, JPA Auditing - [스프링 부트 핵심 가이드] (0) | 2024.02.15 |
| [JPA][스프링 부트 핵심 가이드] 리포지토리 메서드 생성 규칙 (0) | 2024.02.08 |
| [JPA][스프링 부트 핵심 가이드] JPA & 영속성 컨텍스트 & 엔티티 매니저 (0) | 2024.02.08 |