본문 바로가기
Spring/JPA

[JPA] 연관관계 매핑 (양방향)

by J4J 2021. 3. 28.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 JPA 연관관계 매핑(양방향)에 대해 적어보는 시간을 가져보려고 합니다.

 

들어가기에 앞서 이전에 작성된 포스팅을 읽고 오시면 이해가 더 잘되실 겁니다.

 

2021.03.21 - [Spring/JPA] - [JPA] JPA 환경설정

2021.03.22 - [Spring/JPA] - [JPA] JPA Repository설정 및 CRUD

2021.03.23 - [Spring/JPA] - [JPA] 조건절(where) 메서드

2021.03.24 - [Spring/JPA] - [JPA] 복합키(Composite Key) 엔티티

2021.03.26 - [Spring/JPA] - [JPA] 연관관계 매핑 (단방향)

 

 

연관관계 주인

 

이전 포스팅에서 양방향으로 매핑이 되는 것은 두 객체 모두 단방향 매핑이 되는것이라고 언급한 적이 있습니다.

 

사실 객체들간의 연관관계 매핑은 단방향 매핑만 해주는 것이 좋습니다.

 

왜냐하면 단방향 매핑만으로 연관관계 매핑은 이미 이루어진 상태이고 만약 양방향으로 매핑하게 되면 양쪽을 모두 신경 써야 하기 때문에 복잡도가 올라가기 때문입니다.

 

그럼에도 불구하고 양방향 매핑이 필요할 경우에는 두 객체들 중 연관관계의 주인을 정해줘야 합니다.

 

 

연관관계의 주인은 외래키가 있는 객체로 정해주면 됩니다.

 

이전 포스팅부터 계속 사용해오던 학교와 학생들 간의 관계로 예를 들어보겠습니다.

 

학교와 학생의 관계, 1:n

 

학교와 학생들의 관계는 1:n이고 외래키는 학생 테이블에 존재합니다.

 

만약 학교와 학생에 대해 양방향 매핑을 해준다면 외래 키를 가지고 있는 학생이 연관관계의 주인이 된다는 의미입니다.

 

 

연관관계의 주인이 된다는 것은 데이터베이스에서 본인 테이블은 물론이고 연관 엔티티의 테이블에도 영향을 준다는 의미입니다.

 

데이터 조회뿐만 아니라 삭제, 생성 등에도 영향을 미친다는 것이죠.

 

반대로 연관관계의 주인이 아닌 엔티티는 연관 엔티티의 테이블에 영향을 주지 못하기 때문에 단순히 데이터 조회만 가능합니다.

 

 

연관관계의 주인과 주인이 아닌 것을 구분하는 방법은 @JoinColumn의 존재 유무입니다.

 

양방향 매핑은 단방향 매핑을 서로 하는 것이라고 해서 @JoinColumn이 양쪽에 모두 존재하는 것이 아닙니다.

 

연관관계의 주인에만 @JoinColumn이 존재하고 연관관계의 주인이 아닌 경우에는 @JoinColumn대신 다중성(Multiplicity)에 mappedBy 속성을 추가해주면 됩니다.

 

 

일대다(1:n) 양방향 매핑

 

계속 사용해오던 학교와 학생 엔티티를 가지고 실제로 양방향 매핑을 해보겠습니다.

 

[ 1. MySQL 테이블 / 데이터 생성 ]

 

create table school (
    school_id int auto_increment,
    name varchar(50),
    region varchar(50),
    ranking int,
    primary key(school_id)
);

create table student (
    student_id varchar(30),
    name varchar(30),
    school_id int,
    score int,
    primary key(student_id, name)
);

insert into school(name, region, ranking) values ('강원 고등학교', '강원', 3);
insert into school(name, region, ranking) values ('서울 고등학교', '서울', 1);
insert into school(name, region, ranking) values ('제주 고등학교', '제주', 4);
insert into school(name, region, ranking) values ('경기 고등학교', '경기', 2);

insert into student values ('1234', '김고기', 3, 89);
insert into student values ('2345', '정덮밥', 1, 78);
insert into student values ('3456', '박찌개', 2, 82);
insert into student values ('4567', '문초밥', 3, 99);
insert into student values ('5678', '이족발', 4, 73);
insert into student values ('6789', '진짬뽕', 2, 85);
insert into student values ('0000', '김가네', 5, 89);

 

 

[ 2. School 엔티티 클래스 ]

 

package com.spring.jpa.dto;

import java.io.Serializable;
import java.util.List;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.Table;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity // 영속성 컨텍스트에 의해 관리되는 클래스를 의미
@Table(name = "school") // 엔티티에 매핑되는 DB 테이블 명
public class School implements Serializable { // Serializable을 상속받지 않으면 Entity가 여러개일 때 에러 발생
	@Id // 기본키가 될 변수를 의미
	@GeneratedValue(strategy = GenerationType.IDENTITY) // DB의 auto_increment로 값을 저장하는 것을 의미 
	@Column(name = "school_id") // DB에서는 컬럼명이 school_id
	int id;
	
	private String name;
	private String region;
	private int ranking;
	
	@OneToMany(mappedBy = "school") // 연관관계 주인이 아닌 엔티티, mappedBy에 연관관계 주인이 참조하는 변수명 등록
	private List<Student> students;
}

 

 

반응형

 

 

[ 3. StudentID 식별자 클래스 ]

 

package com.spring.jpa.dto;

import java.io.Serializable;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class StudentID implements Serializable {
	private String studentId;
	private String name;
}

 

 

[ 4. Student 엔티티 클래스 ]

 

package com.spring.jpa.dto;

import java.io.Serializable;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;

import org.hibernate.annotations.NotFound;
import org.hibernate.annotations.NotFoundAction;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student")
@IdClass(StudentID.class)
public class Student implements Serializable {
	
	@Id
	@Column(name = "student_id")
	private String studentId;
	
	@Id
	private String name;
	
	@ManyToOne // 다대일 관계
	@JoinColumn(name = "school_id") // name의 school_id = school테이블을 매핑하는 엔티티의 변수명이 id인것에 매핑, 연관관계 주인인 엔티티
	@NotFound(action = NotFoundAction.IGNORE) // 값이 발견되지 않으면 무시
	private School school;
	
	private int score;
}

 

 

[ 5. StudentRepository 클래스 ]

 

package com.spring.jpa.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import com.spring.jpa.dto.Student;
import com.spring.jpa.dto.StudentID;

public interface StudentRepository extends JpaRepository<Student, StudentID> { // 제네릭 타입: <엔티티 클래스, 엔티티 클래스의 기본키>

}

 

 

[ 6. 단위 테스트 ]

 

package com.spring.jpa;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

import com.spring.jpa.config.RootContext;
import com.spring.jpa.dto.School;
import com.spring.jpa.dto.Student;
import com.spring.jpa.repository.StudentRepository;

import lombok.extern.slf4j.Slf4j;

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = RootContext.class)
@Slf4j
public class JPATest {
	
	@Autowired
	StudentRepository studentRepository;
	
	@Test
	public void studentTest() {
		School school = new School();
		school.setName("충남 고등학교");
		school.setRegion("충남");
		school.setRanking(5);
		
		Student student = new Student();
		student.setStudentId("1111");
		student.setName("밥천국");
		student.setSchool(school);
		student.setScore(95);
		
		studentRepository.save(student);
	}
}

 

 

단위 테스트 코드를 해석해보면 student가 연관관계의 주인이기 때문에 student에 데이터를 생성하면서 school 테이블에도 데이터 생성을 해주고 있습니다.

 

하지만 해당 단위 테스트를 실행할 경우 다음과 같은 에러가 발생하게 됩니다.

 

org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : com.spring.jpa.dto.Student.school -> com.spring.jpa.dto.School; nested exception is java.lang.IllegalStateException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : com.spring.jpa.dto.Student.school -> com.spring.jpa.dto.School

 

 

CascadeType

 

위의 에러를 해결해주기 위해 필요한 것이 CascadeType입니다.

 

CascadeType은 다중성(Multiplicity)의 속성 중 하나로 연관 엔티티에 대한 영속성 처리 설정을 해주기 위해 사용됩니다.

 

사용되는 설정 타입은 다음과 같이 존재합니다.

 

  • CascadeType.DETACH
  • CascadeType.MERGE
  • CascadeType.PERSIST
  • CascadeType.REFRESH
  • CascadeType.REMOVE
  • CascadeType.ALL

 

설정타입 모두 엔티티가 Cascade Type에 속하는 기능을 수행했을 때 연관 엔티티도 동일한 기능을 수행하도록 해줍니다. (ALL은 모든 타입이 적용)

 

참고적으로 Cascade Type은 연관 엔티티에도 영향을 미치기 때문에 정말 필요한 타입만 명시하여 사용하는 것이 좋습니다.

 

 

728x90

 

 

다시 위에 발생된 에러로 돌아가보면 save메서드를 이용하여 merge구문이 실행이 되는데 영속성 처리 설정을 해주지 않아 에러가 발생하고 있는 것입니다.

 

영속성 처리 설정을 해주기 위해 Student 엔티티 클래스에 다음과 같이 CascadeType.MERGE를 설정해주겠습니다.

 

package com.spring.jpa.dto;

import java.io.Serializable;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;

import org.hibernate.annotations.NotFound;
import org.hibernate.annotations.NotFoundAction;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student")
@IdClass(StudentID.class)
public class Student implements Serializable {
	
	@Id
	@Column(name = "student_id")
	private String studentId;
	
	@Id
	private String name;
	
	@ManyToOne(cascade = CascadeType.MERGE) // 다대일 관계, 연관 엔티티도 MERGE구문이 수행되게 하기
	@JoinColumn(name = "school_id") // name의 school_id = school테이블을 매핑하는 엔티티의 변수명이 id인것에 매핑, 연관관계 주인인 엔티티
	@NotFound(action = NotFoundAction.IGNORE) // 값이 발견되지 않으면 무시
	private School school;
	
	private int score;
}

 

 

그리고 위에서 작성된 단위 테스트를 다시 실행하게 되면 다음과 같은 로그가 출력되며 정상적으로 실행되는 것을 확인할 수 있습니다.

 

Hibernate: 
    /* load com.spring.jpa.dto.Student */ select
        student0_.name as name1_1_1_,
        student0_.student_id as student_2_1_1_,
        student0_.school_id as school_i4_1_1_,
        student0_.score as score3_1_1_,
        school1_.school_id as school_i1_0_0_,
        school1_.name as name2_0_0_,
        school1_.ranking as ranking3_0_0_,
        school1_.region as region4_0_0_ 
    from
        student student0_ 
    left outer join
        school school1_ 
            on student0_.school_id=school1_.school_id 
    where
        student0_.name=? 
        and student0_.student_id=?
Hibernate: 
    /* insert com.spring.jpa.dto.School
        */ insert 
        into
            school
            (name, ranking, region) 
        values
            (?, ?, ?)
Hibernate: 
    /* insert com.spring.jpa.dto.Student
        */ insert 
        into
            student
            (school_id, score, name, student_id) 
        values
            (?, ?, ?, ?)

 

 

데이터베이스에도 정상적으로 등록되는 것을 확인할 수 있습니다.

 

학교 테이블

 

학생 테이블

 

 

추가로 만약 위의 테스트처럼 StudentRepository를 사용하지 않고 SchoolRepository를 만들어 save()를 하게 될 경우에는 연관관계의 주인이 student이기 때문에 school 데이터만 생성되는 것을 확인할 수 있습니다.

 

 

Find 에러 발생 케이스

 

단위 테스트를 위한 코드를 수정하여 데이터를 조회해보겠습니다.

 

package com.spring.jpa;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

import com.spring.jpa.config.RootContext;
import com.spring.jpa.repository.StudentRepository;

import lombok.extern.slf4j.Slf4j;

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = RootContext.class)
@Slf4j
public class JPATest {
	
	@Autowired
	StudentRepository studentRepository;
	
	@Test
	public void studentTest() {
		log.info(studentRepository.findAll().toString());
	}
}

 

 

위의 코드를 실행할 경우 다음과 같은 에러가 발생됩니다.

 

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.spring.jpa.dto.School.students, could not initialize proxy - no Session

 

 

 

 

위의 에러가 양방향 매핑을 할 때 발생되는데 발생되는 이유는 Lombok사용에 의한 무한루프가 동작하고 있기 때문입니다.

 

테스트 코드에서 toString()을 하게 될 경우 Lombok의 @Data어노테이션에 의해 만들어진 다음과 같은 toString() 메서드가 실행됩니다.

 

@Override
public String toString() {
	return "Student [studentId=" + studentId + ", name=" + name + ", school=" + school + ", score=" + score + "]";
}

 

 

보시면 school에 대해서도 출력을 하고 있는데 school도 Lombok의 @Data어노테이션을 사용하기 때문에 toString()메서드가 다음과 같이 만들어져 있습니다.

 

@Override
public String toString() {
	return "School [id=" + id + ", name=" + name + ", region=" + region + ", ranking=" + ranking + ", students="
	+ students + "]";
}

 

 

여기서는 또 students에 대해서도 출력되고 있습니다.

 

결과적으로 school.toString() → student.toString() → school.toString() → student.toString() ... 의 무한루프가 출력되기 때문에 에러가 발생되는 것입니다.

 

 

에러를 해결하기 위해서는 양방향 매핑되는 두 엔티티 중에서 한 엔티티의 toString()메서드를 수정해줘야 됩니다.

 

예를 들어 School 엔티티 클래스의 @Data어노테이션을 @Setter, @Getter, @ToString으로 나누고 @ToString에는 students에 대해 예외처리를 해주는 겁니다.

 

package com.spring.jpa.dto;

import java.io.Serializable;
import java.util.List;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.Table;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

// @Data를 분리
@Setter
@Getter
@ToString(exclude = {"students"})

@NoArgsConstructor
@AllArgsConstructor
@Entity // 영속성 컨텍스트에 의해 관리되는 클래스를 의미
@Table(name = "school") // 엔티티에 매핑되는 DB 테이블 명
public class School implements Serializable { // Serializable을 상속받지 않으면 Entity가 여러개일 때 에러 발생
	@Id // 기본키가 될 변수를 의미
	@GeneratedValue(strategy = GenerationType.IDENTITY) // DB의 auto_increment로 값을 저장하는 것을 의미 
	@Column(name = "school_id") // DB에서는 컬럼명이 school_id
	int id;
	
	private String name;
	private String region;
	private int ranking;
	
	@OneToMany(mappedBy = "school") // 연관관계 주인이 아닌 엔티티, mappedBy에 연관관계 주인이 참조하는 변수명 등록
	private List<Student> students;
}

 

 

위와 같이 변경 후 다시 findAll() 메서드를 수행하는 테스트를 실행하면 다음과 같은 로그가 정상적으로 출력됩니다.

 

Hibernate: 
    /* select
        generatedAlias0 
    from
        Student as generatedAlias0 */ select
            student0_.name as name1_1_,
            student0_.student_id as student_2_1_,
            student0_.school_id as school_i4_1_,
            student0_.score as score3_1_ 
        from
            student student0_
Hibernate: 
    select
        school0_.school_id as school_i1_0_0_,
        school0_.name as name2_0_0_,
        school0_.ranking as ranking3_0_0_,
        school0_.region as region4_0_0_ 
    from
        school school0_ 
    where
        school0_.school_id=?
Hibernate: 
    select
        school0_.school_id as school_i1_0_0_,
        school0_.name as name2_0_0_,
        school0_.ranking as ranking3_0_0_,
        school0_.region as region4_0_0_ 
    from
        school school0_ 
    where
        school0_.school_id=?
Hibernate: 
    select
        school0_.school_id as school_i1_0_0_,
        school0_.name as name2_0_0_,
        school0_.ranking as ranking3_0_0_,
        school0_.region as region4_0_0_ 
    from
        school school0_ 
    where
        school0_.school_id=?
Hibernate: 
    select
        school0_.school_id as school_i1_0_0_,
        school0_.name as name2_0_0_,
        school0_.ranking as ranking3_0_0_,
        school0_.region as region4_0_0_ 
    from
        school school0_ 
    where
        school0_.school_id=?
Hibernate: 
    select
        school0_.school_id as school_i1_0_0_,
        school0_.name as name2_0_0_,
        school0_.ranking as ranking3_0_0_,
        school0_.region as region4_0_0_ 
    from
        school school0_ 
    where
        school0_.school_id=?
INFO : com.spring.jpa.JPATest - [Student(studentId=0000, name=김가네, school=School(id=5, name=충남 고등학교, region=충남, ranking=5), score=89), Student(studentId=1111, name=밥천국, school=School(id=5, name=충남 고등학교, region=충남, ranking=5), score=95), Student(studentId=1234, name=김고기, school=School(id=3, name=제주 고등학교, region=제주, ranking=4), score=89), Student(studentId=2345, name=정덮밥, school=School(id=1, name=강원 고등학교, region=강원, ranking=3), score=78), Student(studentId=3456, name=박찌개, school=School(id=2, name=서울 고등학교, region=서울, ranking=1), score=82), Student(studentId=4567, name=문초밥, school=School(id=3, name=제주 고등학교, region=제주, ranking=4), score=99), Student(studentId=5678, name=이족발, school=School(id=4, name=경기 고등학교, region=경기, ranking=2), score=73), Student(studentId=6789, name=진짬뽕, school=School(id=2, name=서울 고등학교, region=서울, ranking=1), score=85)]

 

 

참조

[JPA] 양방향 연관관계

[Java/JPA/Hibernate] JPA CascadeType 종류

 

 

 

 

이상으로 JPA 연관관계 매핑(양방향)에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

728x90
반응형

댓글