Intro
다대다 테이블을 보통 일대다 ↔ 다대일 테이블로 풀어서 구성하곤 합니다. 중간에 이어주는 테이블을 중간 테이블이라고 부르는데 이 테이블의 PK가 양쪽 테이블의 PK를 가지고 복합키를 구성할 수 있습니다.
JPA에서 복합키로 PK를 구성했을 때, 저장 또는 PK를 업데이트를 해야할 때 주의해야 할 점에 대해서 알아보도록 하겠습니다.
개발환경
- SpringBoot 2.5.11
- Java 11
- H2 memory db
테스트용 엔티티 소개
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
public Member(String name) {
this.name = name;
}
public Member(String name, Team team) {
this.name = name;
this.team = team;
}
public void updateTeam(Team team) {
this.team = team;
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
public void addMember(Member member) {
this.members.add(member);
member.updateTeam(this);
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MemberTeam {
@EmbeddedId
private MemberTeamId id;
@MapsId("memberId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "MEMBER_ID")
private Member member;
@MapsId("teamId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
private String description;
public MemberTeam(Member member, Team team) {
this.id = MemberTeamId.of(team.getId(), member.getId());
this.member = member;
this.team = team;
this.description = "none";
}
public void changeMember(Member member) {
this.member = member;
}
public void changeDescription(String description) {
this.description = description;
}
}
엔티티 저장 테스트
복합키를 가진 엔티티를 수정하거나 저장할 때 실수할 수 있는 케이스들을 테스트를 통해서 알아보겠습니다.
테스트 기본 구조
@DataJpaTest
class MemberTeamRepositoryTest {
@Autowired private MemberRepository memberRepository;
@Autowired private TeamRepository teamRepository;
@Autowired private MemberTeamRepository memberTeamRepository;
@Autowired private EntityManager em;
private Long member1Id;
private Long member2Id;
private Long team1Id;
private Long team2Id;
@Commit
@BeforeEach
void setup() {
Member member1 = new Member("member1");
Member member2 = new Member("member2");
Team team1 = new Team("team1");
Team team2 = new Team("team2");
team1.addMember(member1);
team1.addMember(member2);
teamRepository.saveAndFlush(team1);
teamRepository.saveAndFlush(team2);
MemberTeam memberTeam = new MemberTeam(member1, team1);
memberTeamRepository.saveAndFlush(memberTeam);
MemberTeam memberTeam2 = new MemberTeam(member1, team2);
memberTeamRepository.saveAndFlush(memberTeam2);
this.member1Id = member1.getId();
this.member2Id = member2.getId();
this.team1Id = team1.getId();
this.team2Id = team2.getId();
em.flush();
em.clear();
}
}
변경하려는 엔티티만 수정하는 경우
@Test
void MemberTeam을_변경할수있다() throws Exception {
// given
MemberTeam memberTeam = memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id)).orElseThrow();
// when
Member member2 = memberRepository.findById(member2Id).orElseThrow();
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.save(memberTeam);
assertThat(modifiedMemberTeam.getId().getMemberId()).isEqualTo(member2Id);
}
위에 간단하게 하나의 팀에 속한 2명의 Member를 저장하고, member1과 team1의 MemberTeam을 하나 저장했습니다.
그리고 memberTeam에 있는 member1을 member2로 다음과 같이 변경하려고 합니다.
- member1과 team1에 대한 memberTeam을 조회한다.
- memberTeam 내부에 있는 changeMember를 통해 member를 변경한다.
- 변경된 memberTeam을 다시 저장한다.
위 테스트는 통과일까요? 실패일까요?
실행결과
org.opentest4j.AssertionFailedError:
expected: 3L
but was : 2L
Expected :3L
Actual :2L
실행결과는 당연히 실패입니다. 왜그럴까요?
복합키를 사용하는 엔티티를 수정할 때 가장 헷갈리는 부분인데, MemberTeam은 복합키를 가지고 있습니다. 근데 member만 바꾼다고 반영이 될까요?
우리는 MemberTeam의 아이디인 MemberTeamId을 함께 바꿔줘야 합니다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@Test
void MemberTeam을_변경할수있다() throws Exception {
// given
MemberTeam memberTeam = memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id)).orElseThrow();
// when
Member member2 = memberRepository.findById(member2Id).orElseThrow();
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.save(memberTeam);
assertThat(modifiedMemberTeam.getId().getMemberId()).isEqualTo(member2Id);
}
복합키 전체를 변경하는 경우
위에서 살펴본 예제에서 복합키를 변경해보도록 하면 어떻게 될까요?
아래는 DeployLogId를 새로운 객체로 변경하는 코드입니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MemberTeam {
...
public void changeMember(Member member) {
this.id = MemberTeamId.of(this.team.getId(), member.getId());
this.member = member;
}
}
실행결과는?
org.springframework.orm.jpa.JpaSystemException: identifier of an instance of com.in2wise.in8opsbe.entity.deployLog.DeployLog was altered from com.in2wise.in8opsbe.entity.deployLog.DeployLogId@90404d71 to com.in2wise.in8opsbe.entity.deployLog.DeployLogId@90404d90; nested exception is org.hibernate.HibernateException: identifier of an instance of com.in2wise.in8opsbe.entity.deployLog.DeployLog was altered from com.in2wise.in8opsbe.entity.deployLog.DeployLogId@90404d71 to com.in2wise.in8opsbe.entity.deployLog.DeployLogId@90404d90
JPA에서는 객체의 동일성 비교를 지원하는데 위 코드로 인해서 DeployLogId의 객체의 주소가 변경되었기 때문에, 아래와 같은 에러가 생깁니다.
절대 새로운 객체를 생성하지 말고 기존 Id 객체에서 변경하려는 키값만 수정하도록 해야합니다.
복합키 내부 변수를 바꿔보자
위 예제처럼 전체 통으로 바꾸지말고 ID 클래스 내부 변수를 변경해주도록 합시다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MemberTeam {
...
public void changeTrainedModel(TrainedModel trainedModel) {
this.member = member;
this.id.changeMemberId(member.getId()); // **
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(staticName = "of")
@Embeddable
public class MemberTeamId implements Serializable {
private Long teamId;
private Long memberId;
public void changeMemberId(Long memberId) {
this.memberId = memberId;
}
...
}
이제 아래 테스트를 실행해보면 어떻게 될까요?
@Test
@Transactional
void MemberTeam을_변경할수있다() throws Exception {
// given
MemberTeam memberTeam = memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id)).orElseThrow();
// when
Member member2 = memberRepository.findById(member2Id).orElseThrow();
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.saveAndFlush(memberTeam);
// then
(1) assertThat(modifiedMemberTeam.getId().getMemberId()).isEqualTo(member2Id);
(2) assertThat(memberTeamRepository.count()).isEqualTo(2);
(3) assertThat(memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id))).isNotPresent();
}
실행결과
[1] o.h.p.entity.AbstractEntityPersister : HHH000502: The [member] property of the [me.iseunghan.testjpa.domain.MemberTeam] entity was modified, but it won't be updated because the property is immutable.
...
Expecting an empty Optional but was containing value: me.iseunghan.testjpa.domain.MemberTeam@5f01361e, mergedContextConfiguration = [WebMergedContextConfiguration@d3957fe testClass = MemberTeamRepositoryTest, locations = '{}', classes = '{class me.iseunghan.testjpa.TestJpaApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@4d14b6c2, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@25df00a0, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@1be2019a, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@28cda624, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@14bdbc74, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@11dc3715], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true, 'org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]
java.lang.AssertionError:
[2] Expecting an empty Optional but was containing value: me.iseunghan.testjpa.domain.MemberTeam@5f01361e
- [1]: MemberTeam의 member는 Immutable이라 변경할 수 없다는 경고와 함께 Update 쿼리가 발생하지 않았습니다.
- [2]: 객체상으로는 변경됐지만, JPA쪽에서 변경을 막아 실제 DB에는 반영이 되질 않아서 (1)은 통과하고 (3)은 실패했습니다.
그럼 여기서 우리는 다음을 알 수 있습니다.
- 복합키는 Immutable이라 수정이 되지 않는다.
- 객체 상으로 수정이 됐지만 JPA에서는 Update를 막는다.
만약 다른 컬럼을 함께 변경 시도한다면?
@Commit
@Transactional
@Test
void MemberTeam을_변경할수있다() throws Exception {
System.out.println("-----------------------------------");
// given
MemberTeam memberTeam = memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id)).orElseThrow();
Member member2 = memberRepository.findById(member2Id).orElseThrow();
// when
memberTeam.changeText("edit");
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.saveAndFlush(memberTeam);
// then
assertThat(modifiedMemberTeam.getId().getMemberId()).isEqualTo(member2Id);
assertThat(memberTeamRepository.count()).isEqualTo(2);
assertThat(memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id))).isNotPresent();
}
실행결과
2023-07-28 14:44:30.248 WARN 90834 --- [1] main] o.h.p.entity.AbstractEntityPersister : HHH000502: The [member] property of the [me.iseunghan.testjpa.domain.MemberTeam] entity was modified, but it won't be updated because the property is immutable.
2023-07-28 14:44:30.248 DEBUG 90834 --- [2] main] org.hibernate.SQL :
update
member_team
set
text=?
where
member_id=?
and team_id=?
2023-07-28 14:44:30.248 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [edit]
2023-07-28 14:44:30.249 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [BIGINT] - [3]
2023-07-28 14:44:30.249 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [BIGINT] - [1]
2023-07-28 14:44:30.250 DEBUG 90834 --- [3] main] org.hibernate.SQL :
select
memberteam0_.member_id as member_i1_1_0_,
memberteam0_.team_id as team_id2_1_0_,
memberteam0_.text as text3_1_0_
from
member_team memberteam0_
where
memberteam0_.member_id=?
and memberteam0_.team_id=?
2023-07-28 14:44:30.251 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [3]
2023-07-28 14:44:30.252 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [BIGINT] - [1]
2023-07-28 14:44:30.253 DEBUG 90834 --- [ main] .l.e.p.AbstractLoadPlanBasedEntityLoader : Done entity load : me.iseunghan.testjpa.domain.MemberTeam#me.iseunghan.testjpa.domain.MemberTeamId@3e3
2023-07-28 14:44:30.259 DEBUG 90834 --- [ main] cResourceLocalTransactionCoordinatorImpl : JDBC transaction marked for rollback-only (exception provided for stack trace)
...
[4] org.springframework.orm.ObjectOptimisticLockingFailureException: Object of class [me.iseunghan.testjpa.domain.MemberTeam] with identifier [me.iseunghan.testjpa.domain.MemberTeamId@3e3]: optimistic locking failed; nested exception is org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [me.iseunghan.testjpa.domain.MemberTeam#me.iseunghan.testjpa.domain.MemberTeamId@3e3]
- [1]: 위에서 봤던 Id가 변경되었다고 경고하는 로그. 객체의 memberId는 변경되었지만 실제 DB에는 반영 안됨.
- [2]: text를 수정한 update 쿼리 발생.
- [3]: save 전 MemberTeamId로 먼저 조회
- [4]: update문에는 member를 변경한 부분은 JPA에 의해 제외됐지만, 이미 MemberTeamId는 수정되었습니다. 그렇다보니 JPA는 1차캐시에 있는 스냅샷과 비교하여 이 문제를 발견하고 예외를 터뜨리는 것입니다.
그렇다면 해결방법은?
- 가장 현명한 방법은 복합키는 절대 변경하지 않는 것입니다. 변경하려면 기존 엔티티를 삭제하고 새로운 값으로 저장하는 방법이 있습니다.
- AutoIncrement를 기본키로 설정하고 복합키를 대체키로 변경하는 방법이 있습니다.
1번째 방법은 그냥 삭제하고 다시 생성하면 되는 것이므로 실습은 건너뛰고, 2번째 방법을 실습을 통해 알아보도록 하겠습니다.
MemberTeamId를 대체키로 변경하기
AutoIncrement 값을 PK로 변경하고, member와 team을 같이 묶어서 unique 제약을 설정해줍니다. 이렇게 되면 이제 PK가 변경될 일은 없을 것 입니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"MEMBER_ID", "TEAM_ID"})})
public class MemberTeam {
@Id @GeneratedValue
private Long candidateId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "MEMBER_ID", nullable = false)
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID", nullable = false)
private Team team;
private String text;
public MemberTeam(Member member, Team team) {
this.member = member;
this.team = team;
}
public void changeMember(Member member) {
this.member = member;
}
public void changeText(String text) {
this.text = text;
}
}
변경된 테스트 코드
@Test
void MemberTeam을_변경할수있다() throws Exception {
System.out.println("-----------------------------------");
// given
MemberTeam memberTeam = memberTeamRepository.findById(memberTeam1CandidateId).orElseThrow();
Member member2 = memberRepository.findById(member2Id).orElseThrow();
// when
memberTeam.changeText("edit");
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.saveAndFlush(memberTeam);
// then
assertThat(modifiedMemberTeam.getMember().getId()).isEqualTo(member2Id);
assertThat(memberTeamRepository.count()).isEqualTo(2);
assertThat(memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id))).isNotPresent();
}
실행결과
2023-07-28 15:50:22.333 DEBUG 95256 --- [1] main] org.hibernate.SQL :
update
member_team
set
member_id=?,
team_id=?,
text=?
where
candidate_id=?
2023-07-28 15:50:22.335 TRACE 95256 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [3]
2023-07-28 15:50:22.335 TRACE 95256 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [BIGINT] - [1]
2023-07-28 15:50:22.335 TRACE 95256 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [edit]
2023-07-28 15:50:22.335 TRACE 95256 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [BIGINT] - [5]
- [1]: 정상적으로 update Query가 발생한 것을 볼 수 있습니다.
Outro
JPA는 편리하게 객체지향적으로 DB를 핸들링할 수 있게 도와주는 프레임워크이지만 그만큼 추상화되어있고 내부동작원리를 잘 알아야 실무에서 장애로 이어지지 않고 좋은 서비스를 만들 수 있습니다. JPA에 대해서 깊게 학습하는데 집중을 해야할 것 같습니다.
긴 글 읽어주셔서 감사합니다.
(틀린 부분이 있다면 언제든지 말씀해주시면 감사하겠습니다)
Intro
다대다 테이블을 보통 일대다 ↔ 다대일 테이블로 풀어서 구성하곤 합니다. 중간에 이어주는 테이블을 중간 테이블이라고 부르는데 이 테이블의 PK가 양쪽 테이블의 PK를 가지고 복합키를 구성할 수 있습니다.
JPA에서 복합키로 PK를 구성했을 때, 저장 또는 PK를 업데이트를 해야할 때 주의해야 할 점에 대해서 알아보도록 하겠습니다.
개발환경
- SpringBoot 2.5.11
- Java 11
- H2 memory db
테스트용 엔티티 소개
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
public Member(String name) {
this.name = name;
}
public Member(String name, Team team) {
this.name = name;
this.team = team;
}
public void updateTeam(Team team) {
this.team = team;
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
public void addMember(Member member) {
this.members.add(member);
member.updateTeam(this);
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MemberTeam {
@EmbeddedId
private MemberTeamId id;
@MapsId("memberId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "MEMBER_ID")
private Member member;
@MapsId("teamId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
private String description;
public MemberTeam(Member member, Team team) {
this.id = MemberTeamId.of(team.getId(), member.getId());
this.member = member;
this.team = team;
this.description = "none";
}
public void changeMember(Member member) {
this.member = member;
}
public void changeDescription(String description) {
this.description = description;
}
}
엔티티 저장 테스트
복합키를 가진 엔티티를 수정하거나 저장할 때 실수할 수 있는 케이스들을 테스트를 통해서 알아보겠습니다.
테스트 기본 구조
@DataJpaTest
class MemberTeamRepositoryTest {
@Autowired private MemberRepository memberRepository;
@Autowired private TeamRepository teamRepository;
@Autowired private MemberTeamRepository memberTeamRepository;
@Autowired private EntityManager em;
private Long member1Id;
private Long member2Id;
private Long team1Id;
private Long team2Id;
@Commit
@BeforeEach
void setup() {
Member member1 = new Member("member1");
Member member2 = new Member("member2");
Team team1 = new Team("team1");
Team team2 = new Team("team2");
team1.addMember(member1);
team1.addMember(member2);
teamRepository.saveAndFlush(team1);
teamRepository.saveAndFlush(team2);
MemberTeam memberTeam = new MemberTeam(member1, team1);
memberTeamRepository.saveAndFlush(memberTeam);
MemberTeam memberTeam2 = new MemberTeam(member1, team2);
memberTeamRepository.saveAndFlush(memberTeam2);
this.member1Id = member1.getId();
this.member2Id = member2.getId();
this.team1Id = team1.getId();
this.team2Id = team2.getId();
em.flush();
em.clear();
}
}
변경하려는 엔티티만 수정하는 경우
@Test
void MemberTeam을_변경할수있다() throws Exception {
// given
MemberTeam memberTeam = memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id)).orElseThrow();
// when
Member member2 = memberRepository.findById(member2Id).orElseThrow();
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.save(memberTeam);
assertThat(modifiedMemberTeam.getId().getMemberId()).isEqualTo(member2Id);
}
위에 간단하게 하나의 팀에 속한 2명의 Member를 저장하고, member1과 team1의 MemberTeam을 하나 저장했습니다.
그리고 memberTeam에 있는 member1을 member2로 다음과 같이 변경하려고 합니다.
- member1과 team1에 대한 memberTeam을 조회한다.
- memberTeam 내부에 있는 changeMember를 통해 member를 변경한다.
- 변경된 memberTeam을 다시 저장한다.
위 테스트는 통과일까요? 실패일까요?
실행결과
org.opentest4j.AssertionFailedError:
expected: 3L
but was : 2L
Expected :3L
Actual :2L
실행결과는 당연히 실패입니다. 왜그럴까요?
복합키를 사용하는 엔티티를 수정할 때 가장 헷갈리는 부분인데, MemberTeam은 복합키를 가지고 있습니다. 근데 member만 바꾼다고 반영이 될까요?
우리는 MemberTeam의 아이디인 MemberTeamId을 함께 바꿔줘야 합니다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@Test
void MemberTeam을_변경할수있다() throws Exception {
// given
MemberTeam memberTeam = memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id)).orElseThrow();
// when
Member member2 = memberRepository.findById(member2Id).orElseThrow();
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.save(memberTeam);
assertThat(modifiedMemberTeam.getId().getMemberId()).isEqualTo(member2Id);
}
복합키 전체를 변경하는 경우
위에서 살펴본 예제에서 복합키를 변경해보도록 하면 어떻게 될까요?
아래는 DeployLogId를 새로운 객체로 변경하는 코드입니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MemberTeam {
...
public void changeMember(Member member) {
this.id = MemberTeamId.of(this.team.getId(), member.getId());
this.member = member;
}
}
실행결과는?
org.springframework.orm.jpa.JpaSystemException: identifier of an instance of com.in2wise.in8opsbe.entity.deployLog.DeployLog was altered from com.in2wise.in8opsbe.entity.deployLog.DeployLogId@90404d71 to com.in2wise.in8opsbe.entity.deployLog.DeployLogId@90404d90; nested exception is org.hibernate.HibernateException: identifier of an instance of com.in2wise.in8opsbe.entity.deployLog.DeployLog was altered from com.in2wise.in8opsbe.entity.deployLog.DeployLogId@90404d71 to com.in2wise.in8opsbe.entity.deployLog.DeployLogId@90404d90
JPA에서는 객체의 동일성 비교를 지원하는데 위 코드로 인해서 DeployLogId의 객체의 주소가 변경되었기 때문에, 아래와 같은 에러가 생깁니다.
절대 새로운 객체를 생성하지 말고 기존 Id 객체에서 변경하려는 키값만 수정하도록 해야합니다.
복합키 내부 변수를 바꿔보자
위 예제처럼 전체 통으로 바꾸지말고 ID 클래스 내부 변수를 변경해주도록 합시다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MemberTeam {
...
public void changeTrainedModel(TrainedModel trainedModel) {
this.member = member;
this.id.changeMemberId(member.getId()); // **
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(staticName = "of")
@Embeddable
public class MemberTeamId implements Serializable {
private Long teamId;
private Long memberId;
public void changeMemberId(Long memberId) {
this.memberId = memberId;
}
...
}
이제 아래 테스트를 실행해보면 어떻게 될까요?
@Test
@Transactional
void MemberTeam을_변경할수있다() throws Exception {
// given
MemberTeam memberTeam = memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id)).orElseThrow();
// when
Member member2 = memberRepository.findById(member2Id).orElseThrow();
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.saveAndFlush(memberTeam);
// then
(1) assertThat(modifiedMemberTeam.getId().getMemberId()).isEqualTo(member2Id);
(2) assertThat(memberTeamRepository.count()).isEqualTo(2);
(3) assertThat(memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id))).isNotPresent();
}
실행결과
[1] o.h.p.entity.AbstractEntityPersister : HHH000502: The [member] property of the [me.iseunghan.testjpa.domain.MemberTeam] entity was modified, but it won't be updated because the property is immutable.
...
Expecting an empty Optional but was containing value: me.iseunghan.testjpa.domain.MemberTeam@5f01361e, mergedContextConfiguration = [WebMergedContextConfiguration@d3957fe testClass = MemberTeamRepositoryTest, locations = '{}', classes = '{class me.iseunghan.testjpa.TestJpaApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@4d14b6c2, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@25df00a0, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@1be2019a, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@28cda624, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@14bdbc74, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@11dc3715], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.populatedRequestContextHolder' -> true, 'org.springframework.test.context.web.ServletTestExecutionListener.resetRequestContextHolder' -> true, 'org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]
java.lang.AssertionError:
[2] Expecting an empty Optional but was containing value: me.iseunghan.testjpa.domain.MemberTeam@5f01361e
- [1]: MemberTeam의 member는 Immutable이라 변경할 수 없다는 경고와 함께 Update 쿼리가 발생하지 않았습니다.
- [2]: 객체상으로는 변경됐지만, JPA쪽에서 변경을 막아 실제 DB에는 반영이 되질 않아서 (1)은 통과하고 (3)은 실패했습니다.
그럼 여기서 우리는 다음을 알 수 있습니다.
- 복합키는 Immutable이라 수정이 되지 않는다.
- 객체 상으로 수정이 됐지만 JPA에서는 Update를 막는다.
만약 다른 컬럼을 함께 변경 시도한다면?
@Commit
@Transactional
@Test
void MemberTeam을_변경할수있다() throws Exception {
System.out.println("-----------------------------------");
// given
MemberTeam memberTeam = memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id)).orElseThrow();
Member member2 = memberRepository.findById(member2Id).orElseThrow();
// when
memberTeam.changeText("edit");
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.saveAndFlush(memberTeam);
// then
assertThat(modifiedMemberTeam.getId().getMemberId()).isEqualTo(member2Id);
assertThat(memberTeamRepository.count()).isEqualTo(2);
assertThat(memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id))).isNotPresent();
}
실행결과
2023-07-28 14:44:30.248 WARN 90834 --- [1] main] o.h.p.entity.AbstractEntityPersister : HHH000502: The [member] property of the [me.iseunghan.testjpa.domain.MemberTeam] entity was modified, but it won't be updated because the property is immutable.
2023-07-28 14:44:30.248 DEBUG 90834 --- [2] main] org.hibernate.SQL :
update
member_team
set
text=?
where
member_id=?
and team_id=?
2023-07-28 14:44:30.248 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [edit]
2023-07-28 14:44:30.249 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [BIGINT] - [3]
2023-07-28 14:44:30.249 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [BIGINT] - [1]
2023-07-28 14:44:30.250 DEBUG 90834 --- [3] main] org.hibernate.SQL :
select
memberteam0_.member_id as member_i1_1_0_,
memberteam0_.team_id as team_id2_1_0_,
memberteam0_.text as text3_1_0_
from
member_team memberteam0_
where
memberteam0_.member_id=?
and memberteam0_.team_id=?
2023-07-28 14:44:30.251 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [3]
2023-07-28 14:44:30.252 TRACE 90834 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [BIGINT] - [1]
2023-07-28 14:44:30.253 DEBUG 90834 --- [ main] .l.e.p.AbstractLoadPlanBasedEntityLoader : Done entity load : me.iseunghan.testjpa.domain.MemberTeam#me.iseunghan.testjpa.domain.MemberTeamId@3e3
2023-07-28 14:44:30.259 DEBUG 90834 --- [ main] cResourceLocalTransactionCoordinatorImpl : JDBC transaction marked for rollback-only (exception provided for stack trace)
...
[4] org.springframework.orm.ObjectOptimisticLockingFailureException: Object of class [me.iseunghan.testjpa.domain.MemberTeam] with identifier [me.iseunghan.testjpa.domain.MemberTeamId@3e3]: optimistic locking failed; nested exception is org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [me.iseunghan.testjpa.domain.MemberTeam#me.iseunghan.testjpa.domain.MemberTeamId@3e3]
- [1]: 위에서 봤던 Id가 변경되었다고 경고하는 로그. 객체의 memberId는 변경되었지만 실제 DB에는 반영 안됨.
- [2]: text를 수정한 update 쿼리 발생.
- [3]: save 전 MemberTeamId로 먼저 조회
- [4]: update문에는 member를 변경한 부분은 JPA에 의해 제외됐지만, 이미 MemberTeamId는 수정되었습니다. 그렇다보니 JPA는 1차캐시에 있는 스냅샷과 비교하여 이 문제를 발견하고 예외를 터뜨리는 것입니다.
그렇다면 해결방법은?
- 가장 현명한 방법은 복합키는 절대 변경하지 않는 것입니다. 변경하려면 기존 엔티티를 삭제하고 새로운 값으로 저장하는 방법이 있습니다.
- AutoIncrement를 기본키로 설정하고 복합키를 대체키로 변경하는 방법이 있습니다.
1번째 방법은 그냥 삭제하고 다시 생성하면 되는 것이므로 실습은 건너뛰고, 2번째 방법을 실습을 통해 알아보도록 하겠습니다.
MemberTeamId를 대체키로 변경하기
AutoIncrement 값을 PK로 변경하고, member와 team을 같이 묶어서 unique 제약을 설정해줍니다. 이렇게 되면 이제 PK가 변경될 일은 없을 것 입니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"MEMBER_ID", "TEAM_ID"})})
public class MemberTeam {
@Id @GeneratedValue
private Long candidateId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "MEMBER_ID", nullable = false)
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID", nullable = false)
private Team team;
private String text;
public MemberTeam(Member member, Team team) {
this.member = member;
this.team = team;
}
public void changeMember(Member member) {
this.member = member;
}
public void changeText(String text) {
this.text = text;
}
}
변경된 테스트 코드
@Test
void MemberTeam을_변경할수있다() throws Exception {
System.out.println("-----------------------------------");
// given
MemberTeam memberTeam = memberTeamRepository.findById(memberTeam1CandidateId).orElseThrow();
Member member2 = memberRepository.findById(member2Id).orElseThrow();
// when
memberTeam.changeText("edit");
memberTeam.changeMember(member2);
MemberTeam modifiedMemberTeam = memberTeamRepository.saveAndFlush(memberTeam);
// then
assertThat(modifiedMemberTeam.getMember().getId()).isEqualTo(member2Id);
assertThat(memberTeamRepository.count()).isEqualTo(2);
assertThat(memberTeamRepository.findById(MemberTeamId.of(team1Id, member1Id))).isNotPresent();
}
실행결과
2023-07-28 15:50:22.333 DEBUG 95256 --- [1] main] org.hibernate.SQL :
update
member_team
set
member_id=?,
team_id=?,
text=?
where
candidate_id=?
2023-07-28 15:50:22.335 TRACE 95256 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [3]
2023-07-28 15:50:22.335 TRACE 95256 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [BIGINT] - [1]
2023-07-28 15:50:22.335 TRACE 95256 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [edit]
2023-07-28 15:50:22.335 TRACE 95256 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [BIGINT] - [5]
- [1]: 정상적으로 update Query가 발생한 것을 볼 수 있습니다.
Outro
JPA는 편리하게 객체지향적으로 DB를 핸들링할 수 있게 도와주는 프레임워크이지만 그만큼 추상화되어있고 내부동작원리를 잘 알아야 실무에서 장애로 이어지지 않고 좋은 서비스를 만들 수 있습니다. JPA에 대해서 깊게 학습하는데 집중을 해야할 것 같습니다.
긴 글 읽어주셔서 감사합니다.
(틀린 부분이 있다면 언제든지 말씀해주시면 감사하겠습니다)