[JPA] 6. Association Mapping
환경설정
프로젝트 생성 시 Spring Data JPA와 MariaDB Driver 의존성을 추가한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
spring:
datasource:
driver-class-name: org.mariadb.jdbc.Driver
url: jdbc:mariadb://localhost:3307/menudb
username: swcamp
password: swcamp
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
# 일반적으로 테이블 컬럼명은 언더스코어, 자바 필드명은 카멜케이스로쓰는데,
# 이 설정을 해주면 카멜케이스로 작성된 엔터티와 컬럼 이름을 언더스코어로 변환한다.
# 그러면 @column()의 name 속성을 생략할 수 있다.
Association Mapping
Entity 클래스 간의 관계를 매핑하는 것을 의미한다. 이를 통해 객체를 이용해서 DB 테이블 간의 관계를 매핑할 수 있다.
1. 다중성에 의한 분류
연관 관계가 있는 객체 관계에서 실제로 연관을 가지는 객체의 수에 따라 분류된다.
- N:1(ManyToOne) 연관 관계
- 1:N(OneToMany) 연관 관계
- 1:1(OneToOne) 연관 관계
- N:N(ManyToMany) 연관 관계: 다대다 연관 관계는 모델링에서 물리적으로 그릴 수 없으므로 물리화 시엔 중간 테이블 필요
2. 방향에 따른 분류
테이블의 연관 관계는 외래키를 이용하여 양방향 연관 관계의 특징을 갖는다.
참조에 의한 객체의 연관 관계는 단방향인데,
객체 간의 연관 관계를 양방향으로 만들고 싶은 경우 반대 쪽에서도 필드를 추가해서 참조를 보관하면 된다.
엄밀하게 말하면 양방향 관계는 단방향 관계 2개로 볼 수 있다.
- 단방향 연관 관계
- 양방향 연관 관계
ManyToOne
다대일 관계이다.
연관관계 매핑 시 어떻게 처리해야 하는지가 명시되어 있어야 한다. (@ManyToOne, @JoinColumn)
package org.example.associationmapping.section01.manytoone;
@Entity(name = "section01Menu")
@Table(name = "tbl_menu")
public class Menu {
// yml에서 physical-strategy를 설정해줬으므로 @column 생략
@Id
private int menuCode;
private String menuName;
private int menuPrice;
/* 영속성 전이 : 특정 엔터티를 영속화 할 때 연관 된 엔터티도 함께 영속화 한다는 의미이다.
쉽게 말하면, Menu를 save 할 때 Category도 save하고 싶으면 별도의 처리를 해줘야 하는데,
그게 바로 cascade속성이다.
CascadeType.PERSIST를 해주면, persist, 즉 저장을 할 때 영속성 전이를 한다.
부모 엔티티를 저장할 때 연관된 자식 엔티티가 자동으로 저장되도록 한다.
*/
/* FetchType.EAGER : 즉시 로딩, FetchType.LAZY : 지연 로딩
@ManyToOne 어노테이션의 기본 속성은 즉시 로딩 (한 번에 JOIN 해서 처리)
지연로딩이란, JOIN을 처음부터 해주는 게 아니라 추후에 필요할 때 JOIN을 하는 것이다.
이 예시의 경우 카테고리가 필요하지 않을 땐 우선 메뉴만 조회를 해온다.*/
@ManyToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY)
// 어떤 컬럼이랑 JOIN 할 지. name 속성에는 FK에 해당하는 값을 넣어줘야 한다.
@JoinColumn(name = "categoryCode")
// 연관관계 매핑시 이런 식으로 참조해줘야 한다.
private Category category;
private String orderableStatus;
public Menu() {
}
public Menu(int menuCode, String menuName, int menuPrice, Category category, String orderableStatus) {
this.menuCode = menuCode;
this.menuName = menuName;
this.menuPrice = menuPrice;
this.category = category;
this.orderableStatus = orderableStatus;
}
public int getMenuCode() {
return menuCode;
}
public String getMenuName() {
return menuName;
}
public int getMenuPrice() {
return menuPrice;
}
public Category getCategory() {
return category;
}
public String getOrderableStatus() {
return orderableStatus;
}
// 엔티티에서는 보통 setter 메소드는 안쓴다. 데이터를 변경하면 바로 DB에 적용되기 때문.
}
package org.example.associationmapping.section01.manytoone;
@Entity(name = "section01Category")
@Table(name = "tbl_category")
public class Category {
@Id
private int categoryCode;
private String categoryName;
private Integer refCategoryCode;
public Category() {}
public Category(int categoryCode, String categoryName, Integer refCategoryCode) {
this.categoryCode = categoryCode;
this.categoryName = categoryName;
this.refCategoryCode = refCategoryCode;
}
}
package org.example.associationmapping.section01.manytoone;
public class MenuDTO {
private int menuCode;
private String menuName;
private int menuPrice;
// MenuDTO에선 Category를 참조하면 안되고 CategoryDTO를 따로 만들어서 참조해야 한다.
private CategoryDTO category;
private String orderableStatus;
public MenuDTO(int menuCode, String menuName, int menuPrice, CategoryDTO category, String orderableStatus) {
this.menuCode = menuCode;
this.menuName = menuName;
this.menuPrice = menuPrice;
this.category = category;
this.orderableStatus = orderableStatus;
}
public int getMenuCode() {
return menuCode;
}
public void setMenuCode(int menuCode) {
this.menuCode = menuCode;
}
public String getMenuName() {
return menuName;
}
public void setMenuName(String menuName) {
this.menuName = menuName;
}
public int getMenuPrice() {
return menuPrice;
}
public void setMenuPrice(int menuPrice) {
this.menuPrice = menuPrice;
}
public CategoryDTO getCategory() {
return category;
}
public void setCategory(CategoryDTO category) {
this.category = category;
}
public String getOrderableStatus() {
return orderableStatus;
}
public void setOrderableStatus(String orderableStatus) {
this.orderableStatus = orderableStatus;
}
@Override
public String toString() {
return "MenuDTO{" +
"menuCode=" + menuCode +
", menuName='" + menuName + '\'' +
", menuPrice=" + menuPrice +
", category=" + category +
", orderableStatus='" + orderableStatus + '\'' +
'}';
}
}
package org.example.associationmapping.section01.manytoone;
public class CategoryDTO {
private int categoryCode;
private String categoryName;
private Integer refCategoryCode;
public CategoryDTO(int categoryCode, String categoryName, Integer refCategoryCode) {
this.categoryCode = categoryCode;
this.categoryName = categoryName;
this.refCategoryCode = refCategoryCode;
}
public int getCategoryCode() {
return categoryCode;
}
public void setCategoryCode(int categoryCode) {
this.categoryCode = categoryCode;
}
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
public Integer getRefCategoryCode() {
return refCategoryCode;
}
public void setRefCategoryCode(Integer refCategoryCode) {
this.refCategoryCode = refCategoryCode;
}
@Override
public String toString() {
return "CategoryDTO{" +
"categoryCode=" + categoryCode +
", categoryName='" + categoryName + '\'' +
", refCategoryCode=" + refCategoryCode +
'}';
}
}
package org.example.associationmapping.section01.manytoone;
@Repository
public class ManyToOneRepository {
@PersistenceContext
private EntityManager entityManager;
public Menu find(int menuCode) { return entityManager.find(Menu.class, menuCode); }
// find가 아닌 jpql을 이용한 조회도 가능하다.
public String findCategoryName(int menuCode) {
String jpql
= "SELECT c.categoryName FROM section01Menu m JOIN m.category c WHERE m.menuCode = :menuCode";
return entityManager.createQuery(jpql, String.class).setParameter("menuCode", menuCode).getSingleResult();
}
public void regist(Menu menu) { entityManager.persist(menu); }
}
package org.example.associationmapping.section01.manytoone;
@Service
public class MenuToOneService {
private ManyToOneRepository manyToOneRepository;
public MenuToOneService(ManyToOneRepository manyToOneRepository) {
this.manyToOneRepository = manyToOneRepository;
}
// 순수하게 조회하는 동작(find)만 수행할 땐 @transactional이 붙지 않아도 오류 발생 X
public Menu findMenu(int menuCode) {
return manyToOneRepository.find(menuCode);
}
public String findCategoryNameByJpql(int menuCode) {
return manyToOneRepository.findCategoryName(menuCode);
}
@Transactional
public void registMenu(MenuDTO newMenu) {
Menu menu = new Menu(
newMenu.getMenuCode(),
newMenu.getMenuName(),
newMenu.getMenuPrice(),
new Category(
newMenu.getCategory().getCategoryCode(),
newMenu.getCategory().getCategoryName(),
newMenu.getCategory().getRefCategoryCode()
),
newMenu.getOrderableStatus()
);
manyToOneRepository.regist(menu);
}
}
* 테스트 코드
package org.example.associationmapping.section01.manytoone;
@SpringBootTest
class MenuToOneServiceTest {
@Autowired
private MenuToOneService menuToOneService;
@DisplayName("N:1 연관관계 객체 그래프 탐색을 이용한 조회 테스트")
@Test
void manyToOneFindTest() {
//given
int menuCode = 9; //DB에 존재하는 코드로
//when
Menu foundMenu = menuToOneService.findMenu(menuCode);
//then
Category category = foundMenu.getCategory();
assertNotNull(category);
}
@DisplayName("N:1 연관관계 객체 지향 쿼리(jpql)을 이용한 조회 테스트")
@Test
void manyToOneJpqlTest() {
//given
int menuCode = 9; //DB에 존재하는 코드로
//when
String categoryName = menuToOneService.findCategoryNameByJpql(menuCode);
//then
assertNotNull(categoryName);
System.out.println("[Category Name] : " + categoryName);
}
private static Stream<Arguments> getMenuInfo() {
return Stream.of(
Arguments.of(123, "돈가스 스파게티", 30000, 123, "퓨전분식", "Y")
);
}
@DisplayName("N:1 연관관계 객체 삽입 테스트")
@ParameterizedTest
@MethodSource("getMenuInfo")
void manyToOneInsertTest(
int menuCode, String menuName, int menuPrice,
int categoryCode, String categoryName, String orderableStatus
) {
MenuDTO menu = new MenuDTO(
menuCode,
menuName,
menuPrice,
new CategoryDTO(
categoryCode,
categoryName,
null
),
orderableStatus
);
assertDoesNotThrow(
() -> menuToOneService.registMenu(menu)
);
}
}
OneToMany
일대다 관계이다.
package org.example.associationmapping.section02.onetomany;
@Entity(name = "section02Category")
@Table(name = "tbl_category")
public class Category {
@Id
private int categoryCode;
private String categoryName;
private Integer refCategoryCode;
/* @OneToMany 의 경우엔 default FetchType.LAZY로 설정 되어 있다.
* 따라서 즉시 로딩이 필요한 경우 별도로 설정해주어야 한다. */
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "categoryCode")
// 카테고리 하나는 여러 메뉴를 가지니까 List를 이용한다.
private List<Menu> menuList;
public Category() {}
}
package org.example.associationmapping.section02.onetomany;
@Entity(name = "section02Menu")
@Table(name = "tbl_menu")
public class Menu {
@Id
private int menuCode;
private String menuName;
private int menuPrice;
private int categoryCode;
private String orderableStatus;
public Menu() {}
}
package org.example.associationmapping.section02.onetomany;
@Repository
public class OneToManyRepository {
@PersistenceContext
private EntityManager entityManager;
public Category find(int categoryCode) {
return entityManager.find(Category.class, categoryCode);
}
}
package org.example.associationmapping.section02.onetomany;
@Service
public class OneToManyService {
private OneToManyRepository oneToManyRepository;
public OneToManyService(OneToManyRepository oneToManyRepository) {
this.oneToManyRepository = oneToManyRepository;
}
public Category findCategory(int categoryCode) {
return oneToManyRepository.find(categoryCode);
}
}
* 테스트 코드
package org.example.associationmapping.section02.onetomany;
@SpringBootTest
class OneToManyServiceTest {
@Autowired
private OneToManyService oneToManyService;
@DisplayName("1:N 연관 관계 객체 그래프 탐색을 위한 조회")
@Test
void oneToManyFindTest() {
int categoryCode = 4;
Category category = oneToManyService.findCategory(categoryCode);
assertNotNull(category);
}
}
BiDirection
DB 테이블에서 갖는 연관관계와 객체끼리 갖는 연관관계는 조금 다른데,
DB의 테이블은 외래키 하나로 양방향 조회가 가능하지만 객체는 서로 다른 두 단방향 참조를 합쳐서 양방향이라고 한다.
양방향 연관 관계는 양방향으로 그래프 탐색을 할 일이 많은 경우에만 사용한다.
여기서 진짜 연관관계와 가짜 연관관계를 구분해줘야 하는데,
FK를 갖고 있는 애를 진짜 연관관계, 갖고 있지 않은 것을 가짜 연관관계라고 한다.
가짜 연관관계에는 mappedBy 속성을 이용해서 진짜 연관관계를 맺고 있는 필드값을 넣어줘야 한다.
아래 실습에선 진짜 연관관계는 Menu, 가짜 연관관계는 Category이다.
package org.example.associationmapping.section03.bidirection;
@Entity(name = "section03Menu")
@Table(name = "tbl_menu")
public class Menu {
@Id
private int menuCode;
private String menuName;
private int menuPrice;
@ManyToOne
@JoinColumn(name = "categoryCode")
private Category category;
private String orderableStatus;
public Menu() {
}
public int getMenuCode() {
return menuCode;
}
public String getMenuName() {
return menuName;
}
public int getMenuPrice() {
return menuPrice;
}
public Category getCategory() {
return category;
}
public String getOrderableStatus() {
return orderableStatus;
}
}
package org.example.associationmapping.section03.bidirection;
@Entity(name = "section03Category")
@Table(name = "tbl_category")
public class Category {
@Id
private int categoryCode;
private String categoryName;
private Integer refCategoryCode;
/* 가짜 연관 관계는 mappedBy 속성에 진짜 연관 관계 필드명을 넣어서 설정한다. */
@OneToMany(mappedBy = "category")
private List<Menu> menuList;
public Category() {}
public int getCategoryCode() {
return categoryCode;
}
public String getCategoryName() {
return categoryName;
}
public Integer getRefCategoryCode() {
return refCategoryCode;
}
public List<Menu> getMenuList() {
return menuList;
}
}
package org.example.associationmapping.section03.bidirection;
@Repository
public class BiDirectionRepository {
@PersistenceContext
private EntityManager entityManager;
public Menu findMenu(int menuCode) {
return entityManager.find(Menu.class, menuCode);
}
public Category findCategory(int categoryCode) {
return entityManager.find(Category.class, categoryCode);
}
}
package org.example.associationmapping.section03.bidirection;
@Service
public class BiDirectionService {
private BiDirectionRepository biDirectionRepository;
public BiDirectionService(BiDirectionRepository biDirectionRepository) {
this.biDirectionRepository = biDirectionRepository;
}
public Menu findMenu(int menuCode) {
return biDirectionRepository.findMenu(menuCode);
}
@Transactional
public Category findCategory(int categoryCode) {
Category category = biDirectionRepository.findCategory(categoryCode);
/* 양방향 참조를 구현하면 양방향 그래프 탐색이 가능하다. */
System.out.println(category.getMenuList());
System.out.println(category.getMenuList().get(0).getCategory());
return category;
}
}
* 테스트 코드
package org.example.associationmapping.section03.bidirection;
@SpringBootTest
class BiDirectionServiceTest {
@Autowired
private BiDirectionService biDirectionService;
@DisplayName("양방향 연관 관계 매핑 조회 테스트1(연관 관계의 주인)")
@Test
void biDirectionFindTest1() {
int menuCode = 9;
Menu foundMenu = biDirectionService.findMenu(menuCode);
assertNotNull(foundMenu);
}
@DisplayName("양방향 연관 관계 매핑 조회 테스트2(연관 관계의 주인이 아님)")
@Test
void biDirectionFindTest2() {
int categoryCode = 4;
Category foundCategory = biDirectionService.findCategory(categoryCode);
assertNotNull(foundCategory);
}
}