Spring Data JPA에서 Entity 관계 설정
Entity 관계
JPA는 데이터베이스를 객체화하여 개발할 수 있는 인터페이스.
데이터베이스 관계: https://sungwoon.tistory.com/62
데이터베이스의 관계차수는 1:1(OneToONe), 1:M(OneToMany), N:M(ManyToMany)
JPA 1:1(OneToONe), 1:M(OneToMany), N:1(ManyToOne), N:M(ManyToMany)
N:1(ManyToOne)라는 관계 차수가 존재하는 이유는 JPA의 양방향/단방향 개념 때문입니다.
기본적으로 데이터베이스는 양방향의 관계입니다.
예를들어 Team과 Member의 관계차수가 1:M(OneToMany)라면 데이터베이스에서 Many 쪽인 Member 테이블에서 TEAM을 참조하는 외래키를 생성합니다.
외래키는 기본키를 참조
Team에서는 Member를 참조하는 외래키를 가질 수 없습니다.
관계 1:N의 경우에 Foreign Key는 참조하는 쪽 테이블에 생성
intellij 상단 메뉴 Veiw > Tool Window > Persistence
좌측 하단 Persistence 탭 클릭 후 Persistence 창에서 entityManagerFactory 마우스 오른쪽 클릭 Entity ReplationShip Diagram
CascadeType.REMOVE와 orphanRemoval = true
https://tecoble.techcourse.co.kr/post/2021-08-15-jpa-cascadetype-remove-vs-orphanremoval-true/
INSERT할 때 SELECT 쿼리 나가는 현상
save()함수를 통해 Entity를 INSERT할 때 @GeneratedValue와 같은 기본키 생성 전략이 없거나 @Id로 지정된 변수에 값을 직접 넣게 되면, DB에 존재하는 값인지 검사하기 위해 select 하고 없으면 insert 하게 됩니다.
@Entity
public class Team implements Serializable {
@Id
@GeneratedValue
private Long teamNo;
private String teamName;
}
void test() {
Team devTeam = Team.builder()
.teamNo(1000L) // 직접 코드를 할당
.teamName("개발팀")
.build();
this.teamRepository.save(devTeam);
}
Hibernate:
select
team0_.team_no as team_no1_9_1_,
team0_.team_name as team_nam2_9_1_
from
team team0_
where
team0_.team_no=?
17:41:03.177 TRACE o.h.type.descriptor.sql.BasicBinder - binding parameter [1] as [BIGINT] - [1000]
Hibernate:
insert
into
team
(team_name, team_no)
values
(?, ?)
17:25:04.597 TRACE o.h.type.descriptor.sql.BasicBinder - binding parameter [1] as [VARCHAR] - [개발팀]
17:25:04.597 TRACE o.h.type.descriptor.sql.BasicBinder - binding parameter [2] as [BIGINT] - [1000]
이유는?
Entity의 Repository를 만들 때 사용하는 JpaRepository 인터페이스의 구현체인 SimpleJpaRepository를 보면, save 부분에 isNew()가 true이면 persist 하고 아니면 merge하도록 되어 있습니다. isNew()은 @Id로 지정된 변수 값이 존재하는지 내부적으로 검사합니다. merge()는 영속성 객체이면 update하고 비영속성 객체이면 insert하는데, 비영속성 객체에 @Id 값이 존재하면 update 또는 insert를 결정하기 위해 먼저 DB에 select하여 존재하는지 검사합니다.
Tip. IntelliJ 기준으로 인터페이스명을 Ctrl + Alt + 마우스 왼쪽으로 클릭하면 구현체 리스트가 나옵니다.
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
...
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null.");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
}
다중성
N:1(@ManyToOne)
일대다(@OneToMany)
단방향
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Entity
public class Team implements Serializable {
@Id
private Long teamNo;
private String teamName;
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "team_no", foreignKey = @ForeignKey(name = "FK_TEAM_MEMBER"))
private List<Member> members;
public void addMembers(Member member) {
if(this.members == null)
this.members = new ArrayList<>();
this.members.add(member);
}
}
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Entity
public class Member implements Serializable {
@Id
private Long memberNo;
private String memberName;
}
애플리케이션 로딩 시 자동 생성 쿼리
Hibernate:
create table member (
member_no bigint not null,
member_name varchar(255),
team_no bigint,
primary key (member_no)
)
Hibernate:
create table team (
team_no bigint not null,
team_name varchar(255),
primary key (team_no)
)
Hibernate:
alter table member
add constraint FK_TEAM_MEMBER
foreign key (team_no)
references team
테스트코드
@DataJpaTest
class TeamRepositoryTest {
@Autowired
private TestEntityManager testEntityManager;
@Autowired
TeamRepository teamRepository;
@BeforeEach
void setUp() {
Team devTeam = Team.builder()
.teamName("개발팀")
.build();
devTeam.addMembers(Member.builder()
.memberName("이순신")
.build());
devTeam.addMembers(Member.builder()
.memberName("광개토대왕")
.build());
Team testTeam = Team.builder()
.teamName("테스트팀")
.build();
testTeam.addMembers(Member.builder()
.memberName("세종대왕")
.build());
testTeam.addMembers(Member.builder()
.memberName("세종대왕")
.build());
this.teamRepository.saveAllAndFlush(Arrays.asList(devTeam, testTeam));
this.testEntityManager.clear();
}
}
setUp() 메서드에서 Team과 Member를 Insert할 때 Update 쿼리가 추가적으로 발생 함.
Hibernate:
call next value for hibernate_sequence
Hibernate:
insert
into
team
(team_name, team_no)
values
(?, ?)
Hibernate:
insert
into
member
(member_name, member_no)
values
(?, ?)
Hibernate:
insert
into
member
(member_name, member_no)
values
(?, ?)
Hibernate:
insert
into
team
(team_name, team_no)
values
(?, ?)
Hibernate:
insert
into
member
(member_name, member_no)
values
(?, ?)
Hibernate:
insert
into
member
(member_name, member_no)
values
(?, ?)
Hibernate:
update
member
set
team_no=?
where
member_no=?
Hibernate:
update
member
set
team_no=?
where
member_no=?
...
조회 테스트에서 this.teamRepository.findAll() 후 member를 조회하면 N+1 문제가 발생합니다.
@Test
@DisplayName("조회 테스트")
void team_test() {
List<Team> teams = this.teamRepository.findAll();
for(Team team : teams) {
log.info(String.format("TeamNo: %d, TeamName: %s, Member Count: %d ",
team.getTeamNo(),
team.getTeamName(),
team.getMembers().size()));
for(Member member : team.getMembers()) {
log.info(String.format("MemberNo: %d, MemberName: %s ",
member.getMemberNo(),
member.getMemberName()));
}
};
}
Hibernate:
select
team0_.team_no as team_no1_9_,
team0_.team_name as team_nam2_9_
from
team team0_
Hibernate:
select
members0_.team_no as team_no3_8_0_,
members0_.member_no as member_n1_8_0_,
members0_.member_no as member_n1_8_1_,
members0_.member_name as member_n2_8_1_
from
member members0_
where
members0_.team_no=?
Hibernate:
select
members0_.team_no as team_no3_8_0_,
members0_.member_no as member_n1_8_0_,
members0_.member_no as member_n1_8_1_,
members0_.member_name as member_n2_8_1_
from
member members0_
where
members0_.team_no=?
삭제 테스트
@Test
@DisplayName("삭제 테스트")
void delete_test() {
// given
List<Team> teams = this.teamRepository.findAll();
// when
teams.get(0).getMembers().remove(0);
testEntityManager.flush();
testEntityManager.clear();
Team checkTeam = this.teamRepository.findById(1L).orElseThrow();
// then
assertEquals(1, checkTeam.getMembers().size());
}
삭제된 자식 member의 team_no를 null로 업데이트 한다.
Hibernate:
update
member
set
team_no=null
where
team_no=?
and member_no=?
만약 Team Entity의 관계 설정 부분에 orphanRemoval = true을 주면 delete도 실행됩니다.
@Entity
public class Team implements Serializable {
@Id
@GeneratedValue
private Long teamNo;
private String teamName;
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "team_no", foreignKey = @ForeignKey(name = "FK_TEAM_MEMBER"))
private List<Member> members;
}
Hibernate:
update
member
set
team_no=null
where
team_no=?
and member_no=?
Hibernate:
delete
from
member
where
member_no=?
만약 Member가 Team 이외에 다른 Entity와도 관계를 맺고 있다면 어떻게 될까요?
Member가 Card와 1:M(OneToMany) 단방향 관계일 때 team에 있는 member를 삭제할 경우
@Entity
public class Member implements Serializable {
@Id
@GeneratedValue
private Long memberNo;
private String memberName;
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "member_no", foreignKey = @ForeignKey(name = "FK_CARD_MEMBER"))
private List<Card> cards;
}
member가 삭제 될 때 참조되는 card도 삭제합니다.
Hibernate:
update
card
set
member_no=null
where
member_no=?
Hibernate:
update
member
set
team_no=null
where
team_no=?
and member_no=?
Hibernate:
delete
from
card
where
card_no=?
Hibernate:
delete
from
member
where
member_no=?
Member가 Card와 N:1(ManyToOne) 단방향 관계일 때 team에 있는 member를 삭제할 경우
@Entity
public class Card implements Serializable {
@Id
@GeneratedValue
private Long cardNo;
private String cardName;
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "card_no", foreignKey = @ForeignKey(name = "FK_CARD_MEMBER"))
private List<Member> members;
}
member가 삭제 될 때 참조되는 card는 삭제되지 않습니다.
Hibernate:
update
member
set
team_no=null
where
team_no=?
and member_no=?
Hibernate:
delete
from
member
where
member_no=?
단, Member가 Card와 N:1(ManyToOne) 양방향 관계일 때 team에 있는 member를 삭제할 경우
public class Member implements Serializable {
@Id
@GeneratedValue
private Long memberNo;
private String memberName;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "card_no", foreignKey = @ForeignKey(name = "FK_CARD_MEMBER"))
private Card card;
}
member가 삭제 될 때 참조되는 card는 삭제됩니다.
Hibernate:
update
member
set
team_no=null
where
team_no=?
and member_no=?
Hibernate:
delete
from
member
where
member_no=?
Hibernate:
delete
from
card
where
card_no=?
@Test
@DisplayName("삭제 테스트")
void delete_test() {
List<Team> teams = this.teamRepository.findAll();
// teams.get(0).getMembers().remove(0); (1)
teams.remove(0); // (2)
// this.teamRepository.delete(teams.get(0)); (3)
testEntityManager.flush();
}
(1) 첫번째 team에서 첫번째 member를 삭제합니다. teams.get(0)는 영속성 객체이고 영속성 객체 내에 참조 객체를 삭제하므로 member와 참조관계를 제거합니다. 이때 orphanRemoval 옵션에 따라 member의 delete가 수행될 수 있습니다.
(2) Team을 삭제하기 위해 List<Team> teams에서 element를 삭제할 경우 team이 delete되지 않습니다. 그 이유는 teams에 포함된 element들은 영속성 객체이지만 teams 자체는 영속성 객체가 아닙니다. 따라서 teams.remove()로 teams에서 영속성 객체를 제외한다고 JPA가 delete를 수행하지 않습니다.
(3) team을 삭제하기 위해 repository.delete()를 사용합니다.
정리하자면 Team - Member, Card - Member가 1:M, 1:M 관계이고 단방향으로 구성한다면, orphanRemoval 옵션을 사용하지 않아야 합니다.
Team에 속한 Member가 삭제된다고 Member가 참조하고 있는 Card까지 삭제하면 안되기 때문입니다.
일대일(@OneToOne)
다대다(@ManyToMany)
단방향, 양방향
테이블은 외래 키 하나로 조인을 하면 양방향으로 쿼리가 가능해서 방향의 개념이 없다. 반면, 객체는 참조용 필드가 있어 방향이 존재한다. 객체 관계에서 한 쪽만 참조하는 것을 단방향 관계라하고, 양쪽이 서로 참조하는 것을 양방향 관계라고 한다.
양방향을 안하면 1+N문제가 발생할 수 있음.
예를들어
사원 Entity {
@Id
private Long 사번;
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "사번")
private List<구매 Entity> 구매이력;
}
구매 Entity {
...
}
이런씩의 단방향 관계일 때
List<구매 Entity> 구매이력 = ...;
사번 Entity = 사번Entity.builder().
.구매이력(구매이력)
.build();
repository.save(사번 Entity);
이렇게 저장하고 로그를 보면
Insert 구매 Entity
Insert 구매이력
…
Update 구매이력 set 사번 = ? …
insert 후 update가 한번 더 날라간다. 이유는 자식(구매) 입장에서 사원을 알지 못하기 때문이다.
해결방법은? 양방향으로 하면 된다.
연관관계의 주인
객체는 양방향 참조가 존재하기 때문에 어느 쪽에서 외래키를 관리할지 정해야한다. 외래 키를 가진 테이블을 매핑한 엔티티에서 외래 키를 관리하는게 효율적이다. 따라서 이곳을 연관관계의 주인으로 선택한다. 외래 키를 가진 엔티티가 주인이라고 생각하면 쉽다. 일대다, 다대일 관계에서 항상 ‘다’쪽이 외래키를 가진다. 주인이 아닌 쪽은 외래 키를 변경할 수 없고 읽기만 가능하다.
연관관계 주인(관계에서 N)은 @JoinColumn을 설정한다. 그렇지 않으면 JPA는 중간 테이블을 생성하는 조인 테이블 방식을 사용한다.
주인이 아닌 엔티티(관계에서 1)는 mappdBy로 설정해야하고, 주인 쪽에 조회 권한만 가지고 있음.
반대로 연관관계의 주인이 아닌 엔티티는 연관 엔티티의 테이블에 영향을 주지 못하기 때문에 단순히 데이터 조회만 가능합니다.
연관관계의 주인과 주인이 아닌 것을 구분하는 방법은 @JoinColumn의 존재 유무입니다.
N:1 관계
은행:회원 == N:1 관계입니다. 한명의 회원은 여러 은행 중 하나를 참조한다라는 의미입니다.
회원 Entity
@Getter
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "USER")
@Entity
public class UserEntity extends BaseEntity implements Serializable {
@Id
private Long userSeq;
private String userName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "BANK_SEQ")
private BankEntity bank;
}
@ManyToOne N:1 관계를 선언하는 애노테이션.
fetch (default: FetchType.EAGER)
@ManyToOne의 기본 패치 전략은 FetchType.EAGER(즉시로딩) 인데, 회원 정보를 조회할 때 참조되는 은행 정보도 즉시 조회합니다. 만약 회원 정보를 조회할 때 은행정보를 추후에 필요할 때 조회하고 싶다면 FetchType.LAZY(지연로딩)을 사용합니다. 지연로딩을 사용하여 조회한 회원 Entity를 BankEntity를 userEntity.getBank() 와 같이 가져오는 행위를 하는 순간 자동으로 BankEntity의 정보를 조회합니다.
optional (default: false)
false이면 left outer join , true이면 inner join으로 조회됩니다.
@JoinColumn 관계설정하는 하는 애노테이션.
name 속성은 BankEntity의 PK 컬럼명
referencedColumnName PK컬럼이 아닌 다른 컬럼과 관계를 맺어야할 경우 사용.
insertable은 회원Entity가 insert 될 때 은행Entity가 Insert문에 포함되는지 여부를 의미(default: true)
updatable 은 update문에 포함되는지 여부를 의미합니다. (default: true)
은행 Entity
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "BANK")
@Entity
public class BankEntity implements Serializable {
@Id
private Long bankSeq;
private String bankName;
}
테스트 - 즉시로딩 + findById 조회할 경우
@ManyToOne(fetch = FetchType.EAGER) 즉시 로딩 후 findById로 조회합니다.
@Test
@DisplayName("즉시로딩으로 회원을 조회한다.")
void fetchType_eager_find_by_id() {
UserEntity userEntity = userRepository.findById(10L).orElse(null);
BankEntity bankEntity = userEntity.getBank();
}
실행결과 left outer join 문으로 실행됩니다.
select
...
from
user user0_
left outer join
bank bankentity1_
on user0_.bank_cd=bankentity1_.bank_seq
where
user0_.user_seq=?
테스트 - 지연로딩 + findById 조회할 경우
@ManyToOne(fetch = FetchType.LAZY) 지연로딩 후 findById로 조회합니다.
@Test
@DisplayName("지연로딩으로 회원을 조회한다.")
@Transactional
void fetchType_eager_find_by_id() {
UserEntity userEntity = userRepository.findById(10L).orElse(null);
BankEntity bankEntity = userEntity.getBank(); //지연 로딩
}
실행결과
select
...
from
user user0_
where
user0_.user_seq=?
지연로딩
select
bankentity0_.bank_seq as bank_seq1_0_0_,
bankentity0_.bank_name as bank_name2_0_0_
from
bank bankentity0_
where
bankentity0_.bank_seq=?
지연로딩은 한 트랜잭션 내에서 동작하기 때문에 테스트 코드에서 @Transactional을 제외하고 실행하게 되면LazyInitializationException 예외가 발생합니다.
테스트 - 즉시로딩 + findBy컬럼 조회할 경우
findById와 다르게 findBy컬럼 조회시에는 N+1 문제 발생.
@Test
@DisplayName("즉시로딩으로 회원을 조회한다.")
void fetchType_eager_find_by_other() {
List<CalculateEntity> calculateEntity = calculateRepository.findBySalesSeq(2L);
System.out.println(calculateEntity.get(0).getBank());
System.out.println(calculateEntity.get(1).getBank());
}
실행결과 left outer join 문으로 실행됩니다.
calculatee0_.sum_payment_amount as sum_payment_amoun15_2_
from
kdr_tb_solution_cal calculatee0_
where
calculatee0_.sales_seq=?
select
bankentity0_.bank_cd as bank_cd1_0_0_,
bankentity0_.bank_name as bank_name2_0_0_,
bankentity0_.use_yn as use_yn3_0_0_
from
kdr_tb_bank bankentity0_
where
bankentity0_.bank_cd=?