본문 바로가기
실습/리눅스 서버 + 스프링 부트

[Spring JPA] JPA에서도 PK가 변경될까?

by 이민우 2023. 12. 18.
728x90
반응형

내년 초에 시작될 신규 프로젝트에 대비해서 프로젝트 세팅 및 공부를 하던 도중 한 가지 사실을 알아냈다.

 

우선 내년 초에 시작될 신규 프로젝트는 Spring Webflux를 사용해사 개발할 예정이다. 그리고 Webflux를 사용하기에, DB 접근도 비동기식으로 접근하고자 R2DBC를 사용할 예정이었다.

 

그런데 프로젝트 세팅겸 도메인 클래스를 미리 생성하던 중 아래와 같은 이슈가 발생했다.

 

테이블 중에는 다중 PK값을 가지는 테이블이 존재했는데, 해당 테이블의 도메인을 설정하다보니, R2DBC는 @IdClass를 지원하지 않는다는 이슈를 발견한 것이다.

 

열심히 구글링도 해보고,

https://stackoverflow.com/questions/66166365/spring-data-r2dbc-composite-primary-key

 

전지전능한 챗지피티에게도 문의해보았다.

 

하지만 돌아온 답변은 모두 "사용할 수 없음" 이었다.

 

뭐, 이 이슈는 그냥 jpql을 사용하면 되는 부분이니 크게 신경이 쓰이지 않았다.

 

그런데 문득 한 가지 의문이 들었다.

해당 테이블의 PK 중에는 "기준일"이라는 시간 컬럼이 존재한다. 그리고 이 기준일은 언제든지 수정이 될 수 있다.

 

그렇다면

JPA에서 PK가 바뀌어도 save() 메서드가 insert가 아닌 update를 해줄까?

 

오늘은 이 의문을 풀어보기 위해 공부한 내용을 포스팅해놓고자 한다.

 

 

일반적인 방법으로 시도

 

개별 IoT 디바이스들로부터 로그를 받아 수집하는 프로그램이 있다고 가정하자. 그리고 디바이스별로 로그를 적재하는 테이블을 생성해볼 것이다.

구성은 "날짜", "디바이스", "로그" 3개의 컬럼으로 구성된다.

 

테이블 생성 쿼리는 아래와 같으며, MariaDB를 사용했다.

DROP TABLE IF EXISTS test_log;

CREATE TABLE test_log (
	dt VARCHAR(10), #ex) 2023-12-18
	device VARCHAR(10),
	log VARCHAR(1000),
	PRIMARY KEY (dt, device)
);

 

 

이제 도메인 클래스와 Repository 클래스를 작성하자.

 

TestDomainPk.java

import java.io.Serializable;

import jakarta.persistence.Column;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class TestDomainPk implements Serializable {

	private static final long serialVersionUID = -7124995726265524642L;

	@Id
	@Column(name="dt")
	private String dt;

	@Id
	@Column(name="device")
	private String device;

}

 

TestDomain.java

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@ToString
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name="test_log")
@IdClass(TestDomainPk.class)
public class TestDomain {
	@Id
	@Column(name="dt")
	private String dt;

	@Id
	@Column(name="device")
	private String device;

	@Column(name="log")
	private String log;

}

 

TestRepository.java

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface TestRepository extends CrudRepository<TestDomain, TestDomainPk> {
	
}

 

다음으로 Test 코드를 작성해놓았다.

 

jpaTestApplicationTests.java

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@SpringBootTest
class JpatestApplicationTests {
	
	@Test
	void contextLoads() {
	}
	
	@Autowired TestRepository repo;
	
	@Test
	void pkChangeTest() {
		
		// 데이터 초기화
		repo.deleteAll();
		
		// PK 설정 (조회용)
		TestDomainPk tdPk = new TestDomainPk();
		tdPk.setDt("2023-12-18");
		tdPk.setDevice("A");
		
		// 신규 데이터 저장
		TestDomain td = new TestDomain();
		
		td.setDt(tdPk.getDt());
		td.setDevice(tdPk.getDevice());
		td.setLog("이런저런 로그 탑재");
		
		repo.save(td);
		
		// 정상 저장 여부 확인 : null이 아니어야 함.
		TestDomain saved = repo.findById(tdPk).orElse(null);
		assertNotNull(saved, "@@ Saved Before Change Is Null");
		log.info("@@ Saved Before Change : {}", saved.toString());
		
		// PK를 변경해보자.
		saved.setDevice("B");
		repo.save(saved);
		
		// 정상 수정 여부 확인 : null이여야 함.
		saved = repo.findById(tdPk).orElse(null);
		assertNull(saved, "@@ Saved After Change Is Not Null");
		
	}
}

 

한 번 실행을 해보자.

 

 

마지막 assertNull이 null이 아니라 에러가 발생했다.

 

실제 DB에도 두 개의 컬럼이 탑재가 되어, save가 update가 아닌 insert로 동작했음을 확인할 수 있다.

 

정확한 이유를 알기 위해 application.properties에 아래 설정을 추가해서 JPA가 어떤 쿼리를 전송하는지 확인해보았다.

spring.jpa.format-sql=true
spring.jpa.show-sql=true
logging.level.org.hibernate.type.descriptor.sql=trace
  • 생각없이 하다가 놓쳤는데, 위 설정 중 logging.level.org.hibernate.type.descriptor.sql 설정은 스프링 부트 3 부터는 logging.level.org.hibernate.orm.jdbc.bind 설정으로 바뀌었다.

 

 

save는 물론 delete 전에도 select 쿼리를 사전에 전송해 필요한 데이터를 가져오고 있다.

 

JPA는 엔티티의 ID값을 확인하고 신규 엔티티 여부를 확인하고 연산을 진행하기에 위와 같이 select 후 다음 연산이 진행되는 것이다.

 

상세한 이유는 아래와 같다.

 

어쨌든 PK 값을 사전에 확인하고 연산을 진행하는 JPA 특성 상 PK의 변경은 평범한 방법으로는 불가능하다.

그렇다면 영속성을 코드에서 직접 제공해주도록 코드를 작성하면 어떨까?

 

Persistable을 사용해 영속성을 직접 지정

 

Persistable을 상속받아 한 번 진행해보았다.

 

TestDomainPk.java

import java.io.Serializable;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Getter;
import lombok.Setter;

@Embeddable // (1)
@Getter
@Setter
public class TestDomainPk implements Serializable {

	private static final long serialVersionUID = -7124995726265524642L;

	//@Id (2)
	@Column(name="dt")
	private String dt;

	//@Id
	@Column(name="device")
	private String device;

}
  1. @Embeddable : 타 클래스 내 컬럼으로 탑재가 될 수 있음을 명시한 어노테이션. 생략 가능
  2. @EmbeddedId 사용 시 @Id가 pk 클래스 안에 있으면 Declaring class is not found in the inheritance state hierarchy 에러가 발생한다.

 

TestDomain.java

import org.springframework.data.domain.Persistable;

import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@ToString
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name="test_log")
//@IdClass(TestDomainPk.class) (1)
public class TestDomain implements Persistable<TestDomainPk> { // (2) 

	@EmbeddedId // (3)
	private TestDomainPk testDomainPk;
	
	@Column(name="log")
	private String log;

	@Transient // (4)
	private boolean isNew=false;
	
	@Override // (5)
	public TestDomainPk getId() {
		return this.testDomainPk;
	}

	@Override // (5)
	public boolean isNew() {
		return this.isNew;
	}
	
	// (6)
	public TestDomain setNew() {
		this.isNew = true;
		return this;
	}

}
  1. @EmbeddedId로 갈음하기 위해 제거
  2. Persistable : 영속성을 코드 내에서 직접 설정
  3. @EmbeddedId : 복합 PK를 별도의 클래스로 선언해서 변수로 삽입
  4. @Transient : DB Table에 저장하지 않으며 코드 내에서만 사용. 누락 시 Unknown column 'td1_0.is_new' in 'field list' 에러 발생
  5. Persistable 오버라이딩
  6. 신규 엔티티로 설정

 

다음으로 Test 코드도 위 양식에 맞게 변경해보았다.

 

JpatestApplicationTests.java

package com.mwlee.test.wfx;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@SpringBootTest
class JpatestApplicationTests {
	
	@Test
	void contextLoads() {
	}
	
	@Autowired TestRepository repo;
	
	@Test
	void pkChangeTest() {
		
		// 데이터 초기화
		repo.deleteAll();
		
		// PK 설정 (조회용)
		TestDomainPk tdPk = new TestDomainPk();
		tdPk.setDt("2023-12-18");
		tdPk.setDevice("A");
		
		// 신규 데이터 저장
		TestDomain td = new TestDomain();
		
		td.setTestDomainPk(tdPk);
		td.setLog("이런저런 로그 탑재");
		
		repo.save(td.setNew());
		
		// 정상 저장 여부 확인 : null이 아니어야 함.
		TestDomain saved = repo.findById(tdPk).orElse(null);
		assertNotNull(saved, "@@ Saved Before Change Is Null");
		log.info("@@ Saved Before Change : {}", saved.toString());
		
		// PK를 변경해보자.
		// 우선 다음 조회용으로 기존 Pk클래스 저장
		TestDomainPk tdPkClone = new TestDomainPk();
		tdPkClone.setDevice(tdPk.getDevice());
		tdPkClone.setDt(tdPk.getDt());

		saved.getTestDomainPk().setDevice("B");
		repo.save(saved);
		
		// 정상 수정 여부 확인 : null이여야 함.
		saved = repo.findById(tdPkClone).orElse(null);
		assertNull(saved, "@@ Saved After Change Is Not Null");
		
	}
}

 

이제 실행을 해보자.

 

첫 생성 시에는 isNew를 따라 select 없이 insert가 수행되었다.

단 이후 update 시에는 select를 수행했으며, 동일 PK가 없어 결국 update가 아닌 insert를 수행했다.

 

결국 JPA만으로 PK의 값을 변경하는 것은 무리인 것으로 보였다.

 

해답은 결국 JPQL

설마 JPQL도 안되는 건 아니겠지?

 

그럴리는 없겠지만, 위와 같은 생각이 들어 한 번 시도해보았다.

 

Domian, Pk 클래스는 Persistable 상속 이전으로 되돌린 후 Repository에 아래 코드를 입력해본다.

 

@Modifying // (1)
@Transactional // (2)
@Query("UPDATE TestDomain SET device=:#{#td.device} WHERE dt=:#{#td.dt}")
public int updatePk(@Param("td") TestDomain td); // (3)
  1. 없으면 Expecting a Select query 에러가 발생
  2. 없으면 Executing an update/delete query 에러가 발생
  3. @Modifying 어노테이션이 붙은 메서드는 void, int, Integer 중 하나를 반환해야 한다. 만약 그렇지 않으면 Modifying queries can only use void or int/Integer as return type; 

 

이제 test 코드를 변경해서 실행해보자.

JpatestApplicationTests.java

package com.mwlee.test.wfx;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@SpringBootTest
class JpatestApplicationTests {
	
	@Test
	void contextLoads() {
	}
	
	@Autowired TestRepository repo;
	
	@Test
	void pkChangeTest() {
		
		// 데이터 초기화
		repo.deleteAll();
		
		// PK 설정 (조회용)
		TestDomainPk tdPk = new TestDomainPk();
		tdPk.setDt("2023-12-18");
		tdPk.setDevice("A");
		
		// 신규 데이터 저장
		TestDomain td = new TestDomain();
		
		td.setDevice(tdPk.getDevice());
		td.setDt(tdPk.getDt());
		td.setLog("이런저런 로그 탑재");
		
		repo.save(td);
		
		// 정상 저장 여부 확인 : null이 아니어야 함.
		TestDomain saved = repo.findById(tdPk).orElse(null);
		assertNotNull(saved, "@@ Saved Before Change Is Null");
		log.info("@@ Saved Before Change : {}", saved.toString());
		
		// PK를 변경해보자.
		saved.setDevice("B");
		repo.updatePk(saved);
		
		// 정상 수정 여부 확인 : null이여야 함.
		saved = repo.findById(tdPk).orElse(null);
		assertNull(saved, "@@ Saved After Change Is Not Null");
		
	}
}

 

 

 

정상적으로 수정이 완료되었음을 확인할 수 있다.

728x90
반응형