Spring : JPA 연관관계 매핑 해보기 (@OneToMany, @ManyToOne)

JPA 연관관계 매핑

이 글에서 Spring Data JPA에서 두 개의 테이블을 조인하는 방법을 기술하겠으며, 먼저 연관관계의 주체에 대해 간략히 설명을 진행하고 예제를 통해 JPA를 통한 연관관계 매핑하는 방법을 설명해보겠다.

예제에서는 먼저 조인할 데이터베이스의 테이블의 구조를 확인 후 1:N 관계를 설정하는  @OneToMany 과 N:1 관계를 설정하는 @ManyToOne 어노테이션을 사용하여 두 개의 테이블을 조인하여 데이터를 읽어 보겠다.

참고로 @OneToMany, @ManyToOne 어노테이션 외에 1:1 연관관계를 설정하는 @OneToOne, N:M 연관관계를 설정하는 @ManyToMany도 있지만 이번에는 이 두 개의 연관관계를 제외한 1:N, N:1 연관관계에 있는 테이블만 조인하는 방법을 살펴 볼 것이다.

연관관계의 주체 설정

연관관계에 있는 두 엔티티 중 하나를 연관관계의 주체, 주인으로 정해야 한다.

연관관계의 주체만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)를 할 수 있고, 연관관계의 주체가 아닌 쪽은 읽기만 할 수 있다.

연관관계의 주체는 외래키의 관리자이므로 항상 테이블에 외래 키가 있는 곳으로 정해야한다. 데이터베이스 테이블의 일대다, 다대일 관계에서는 항상 많은 쪽, 다 쪽에서 외래키를 가지게 된다. 즉 다 쪽 테이블의 엔티티 클래스가 항상 연관관계의 주체가 되는 것이다.

외래키가 있는 테이블의 Entity 클래스에서 @ManyToOne 어노테이션을 사용하여 연관관계의 주체, 주인으로 설정하고 @JoinColum 어노테이션으로 1 관계의 엔티티의 PK와 연결되는 FK 컬럼의 이름을 넣어준다.

반대로 주인이 아닌 일 쪽 테이블의 엔티티 클래스에서 @OneToMany 어노테이션을 사용, mappedBy 속성으로 해당 테이블이 주체가 아님을 선언해준다.

JPA 연관관계 매핑 예제

1. 연관관계를 설정할 테이블 구조 확인

allstar 테이블의 구조

kbo 테이블 구조

 

kbo 테이블의 PK인 teamnum 컬럼과 allstar 테이블의 teamno 컬럼이 FK 관계에 있는 것을 확인 할 수 있다. 

 

2. @ManyToOne 설정 (연관관계의 주체)

package pack.model;

import jakarta.persistence.Entity;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import jakarta.persistence.ConstraintMode;

@Entity
@Getter
@Builder
@Table(name = "allstar")
@NoArgsConstructor
@AllArgsConstructor
public class Player {
	@Id
	private int no;
	private int backnum;
	private String name;
	private String position;
	private int pay;
	private String draft;
	//private int teamno; // @JoinColumn FK로 선언될 예정, 중복이라 필요 없음
	
	@ManyToOne // 연관관계의 주체는 @ManyToOne이다.
	@JoinColumn(name = "teamno", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
	// Kbo 엔티티의 PK인 teamnum과 관계를 맺는 FK의 컬럼명을 명시해준다.
	// foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT) 외래키 제약조건 제거할 수 있다.
	private Kbo kbo; // Kbo(엔티티)를 참조한다.
	
	// DTO > Entity
	public static Player toEntity(PlayerDto dto) {
		return Player.builder()
				.no(dto.getNo())
				.backnum(dto.getBacknum())
				.name(dto.getName())
				.position(dto.getPosition())
				.pay(dto.getPay())
				.draft(dto.getDraft())
				.build();
	}

3. @OneToMany

package pack.model;

import java.util.ArrayList;
import java.util.List;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Kbo {
	@Id
	private int teamnum;
	private String teamname;
	private String region;
	private int rank;
	
	@OneToMany(mappedBy = "kbo") // mappedBy로 해당 테이블이 주체가 아님을 선언
	@Builder.Default // Builder 매번 초기화하는 방법
	private List<Player> players = new ArrayList<Player>();
	
	// DTO > Entity
	public static Kbo toEntity(KboDto dto) {
		return Kbo.builder()
				.teamnum(dto.getTeamnum())
				.teamname(dto.getTeamname())
				.region(dto.getRegion())
				.rank(dto.getRank())
				.build();
	}
}

3. 테이블 조인하여 데이터 읽기

package pack.model;

import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;

@Service
public class PlayerServiceImpl implements PlayerService {
	@Autowired
	private PlayerRepository playerRepository;

	@Override
	public void getList(Model model) {
		// 데이터를 읽는 작업은 영속성 컨텍스트 내에서 이루어진다.
		// 엔티티를 DTO로 변환하여 데이터를 출력한다.
		List<PlayerDto> list = playerRepository.findAll()
				.stream()
				.map(PlayerDto :: toDto)
				.collect(Collectors.toList());
		
		model.addAttribute("players", list);
		// 컨트롤러 계층, 서비스 계층에서 모두 사용 가능
		// 컨트롤러에 DTO가 담긴 list를 전달한다.
		// 모델 객체는 스프링이 관리하므로 메소드가 호출된 후 값이 유지된다.
		System.out.println(list.get(0).getName() + "의 팀은 " +
					list.get(0).getKboDto().getTeamname());
	}

4. 결과 및 연관관계가 연결되는 원리

💡 연관관계가 연결되는 원리

콘솔창에 해당 데이터가 출력되게 된다. 테이블이 연결되는 원리를 필자의 이해가 되는 부분까지만 설명해보자면,

1. @ManyToOne, @OneToMany 어노테이션을 사용하여 두 엔티티 간의 연관관계가 설정되었다.

2. JpaRepository의 findAll() 메소드가 데이터를 읽는 작업은 영속성 컨텍스트 내부에서 일어나는 과정으로 영속성 컨텍스트에서 두 엔티티 간의 조인을 자동으로 수행하여 Player 엔티티와 연관관계인 Kbo 엔티티의 데이터를 함께 조회 가능한 구조가 되었다.

3. 그래서 Kbo 엔티티의 데이터를 읽어 오려면 연관관계의 주체가 되는 Player 엔티티를 거쳐야 조회가 가능한 방식인 것 같다.

🙇 필자의 이해가 부족할 수 있으니 적극적인 피드백 부탁드립니다.

Reference

🙏 JPA @OneToMany, @ManyToOne으로 연관관계 관리하기

 

🙏 [JPA] 연관관계 매핑 기초 #2 (양방향 연관관계와 연관관계의 주인)