안녕하세요 ~ 린내입니다.
오늘은 식별관계 복합키인 DB 구조에서 JPA 사용 시 유의 사항들에 대해 알아보겠습니다.
식별관계 복합키 DB 구조는 주로 레거시 환경에서 볼 수가 있는데요.
https://techblog.woowahan.com/2595/
위의 프로젝트와 흡사한 경우를 맞닥뜨렸을 때에 일어날 수 있는 상황과 유의사항에 대해 정리해보겠습니다.
일단 식별관계 복합키로 부모 자식 엔티티를 만들어 보겠습니다.
위의 우아한 형제들 기술 블로그에서 언급해주셨던 것처럼
저도 @IdClass를 사용하는 것이 가독성이 더 좋고 사용하기 편리하다고 생각하여
식별관계 복합키의 엔티티 표현은 @EmbededId가 아닌 @IdClass를 사용하겠습니다.
- 부모 엔티티 클래스 및 부모 ID 클래스 - “직원 정보”
@Entity
@Table(name = "employee")
@Getter
@NoArgsConstructor
@IdClass(EmployeeId.class)
public class Employee {
@Id
@Column(name = "nick_name")
private String nickName;
@Id
@Column(name = "name")
private String name;
//..
@OneToMany(fetch = FetchType.LAZY, mappedBy = "employee")
List<EmployeeDetail> employeeDetailList = new ArrayList<>();
public Employee(String nickName, String name) {
this.nickName = nickName;
this.name = name;
}
//..
}
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class EmployeeId implements Serializable {
@Column(name = "nick_name")
private String nickName;
@Column(name="name")
private String name;
}
- 자식 엔티티 클래스 및 자식 ID 클래스 - “직원 상세 정보”
@Entity
@Table(name = "employee_detail")
@NoArgsConstructor
@IdClass(EmployeeDetailId.class)
public class EmployeeDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_detail_seq")
private Long employeeDetailSeq;
@Id
@Column(name = "nick_name")
private String nickName;
@Id
@Column(name = "name")
private String name;
@Column(name = "content")
private String content;
//..
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumns({
@JoinColumn(name = "nick_name"),
@JoinColumn(name = "name")
})
private Employee employee;
public EmployeeDetail(String nickName, String name) {
this.nickName = nickName;
this.name = name;
}
//..
}
- @JoinColumn에서 부모의 PK를 선정해주어 식별관계 복합키임을 나타냈습니다.
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class EmployeeDetailId implements Serializable {
@Column(name = "employee_detail_seq")
private Long employeeDetailSeq;
@Column(name = "nick_name")
private String nickName;
@Column(name = "name")
private String name;
}
- 그리고 부모 엔티티 생성 후 insert, 자식 엔티티 생성 후 insert하는 로직을 테스트 해보겠습니다.
@Service
@RequiredArgsConstructor
public class EmployeeService {
private final EmployeeRepository employeeRepository;
private final EmployeeDetailRepository employeeDetailRepository;
public void postEmployee(EmployeeDto employeeDto){
String nickName = employeeDto.getNickName();
String name = employeeDto.getName();
Employee employee = new Employee(nickName, name);
Employee saved = employeeRepository.save(employee);
EmployeeDetail employeeDetail = new EmployeeDetail(saved.getNickName(), saved.getName());
employeeDetailRepository.save(employeeDetail); // error !
}
}
이런 ~ employeeDetailRepository.save(employeeDetail);
메소드에서 에러가 발생했습니다!
자식 엔티티가 insert 되지 못하고 save() 메소드 실행 도중 SQLException이 떨어졌네요..
에러 로그를 한 번 봐보겠습니다.
insert into employee_detail (content,nick_name,name,employee_detail_seq,name,nick_name) values (?,?,?,?,?,?)
java.sql.SQLException: No value specified for parameter 5
empolyee_detail에 insert 요청을 하기 위해 파라미터가 5개 전달되었다는 군요.
분명히 employeeDetail 엔티티 생성 시 생성자에 nickName,과 name을 주입시켜주었고
자동생성되는 employee_detail_seq 칼럼까지 합해도 3개의 칼럼에 대해 insert를 의도했는데
왜 5개의 칼럼이 insert 되는 sql문이 생성된 것일까요?
위의 로그를 보니 문제가 되는 칼럼은 nick_name과 name 입니다. nick_name, name 칼럼이 두 번 명시되었군요!
두 칼럼 다 부모의 외래키라는 공통점이 있습니다. 무엇이 문제일까요?
문제의 코드는 이곳에 있었습니다!
//..
public class EmployeeDetail {
//..
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumns({
@JoinColumn(name = "nick_name"),
@JoinColumn(name = "name")
})
private Employee employee;
//..
@JoinColumn의 name 속성에 nick_name과 name 을 언급했기 때문인데요. 한 번 해당 소스코드에 대한 설명을 읽어보겠습니다.
- @JoinColumn이 지정되면 단일 조인 칼럼이 가정되고 기본 값이 적용된다.
- 저희는 nick_name, name 을 각각 설정했으므로 각각의 조인 칼럼이 가정되었음을 알 수 있습니다.
- @JoinColumn(name) 속성에 대한 설명입니다.
- 특히 ManyToOne은 외래키가 해당 엔티티 클래스 혹은 Embeded 클래스 즉, 클래스 내부에 있다고 보는 군요.
- 특히 이 부분을 주의해서 보겠습니다. 번역이 매끄럽지 않지만, 퍼시스턴스 프로바이더가 생성한 insert, update문에 해당 칼럼을 포함하겠느냐라는 설정입니다.
- 기본값이 true이므로, @JoinColumn을 지정하면 자동으로 insert, update문에 지정된 외래키 필드가 추가되는군요. 원인을 파악한 것 같습니다.
- 이를 증명하기 해보기 위해서 식별관계 복합키가 아닌 케이스에 대한 예시를 한 번 봐볼까요?
식별관계 복합키가 아닌 일반적인 케이스
// 부모
@Entity
@Table(name = "parent")
@Getter
public class Parent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "parent")
private List<Child> children = new ArrayList<>();
}
// 자식
@Entity
@Table(name = "child")
@Getter
public class Child {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
}
- 비교해서 소스코드를 보니 이유를 알 것 같습니다!
- 복합키가 아닌 일반적인 상황에서 외래키에 대한 명시는 따로 필드를 선언하지 않고 @JoinColumn에 대한 명시로 충분히 이루어지는 것 같아 보입니다. 참조 변수로 연결지어진 연관관계와 외래키가 추가적인 필드 필요없이 대응하기 때문입니다.
- 그렇다면 @JoinColumn에 언급한 필드명을 JPA가 insert 시 자동으로 sql문에 추가하는지 눈으로 확인해볼까요?
@Transactional
public void example(){
Parent parent = new Parent();
parentRepository.save(parent);
Child child = new Child();
child.setParent(parent); //설정된 외래키 칼럼 필드에 해당하는 참조 변수 설정
childRepository.save(child); // 해당하는 참조 변수의 키 값을 외래키 칼럼의 값으로 insert하는 sql문 자동으로 추가
}
// insert into child (parent_id) values (1);
- JPA는 insert 시 자동으로 설정된 외래키 칼럼을 insert문에 추가하고 해당 값은 참조변수를 통한 관계 엔티티의 참조값을 주입하고 있습니다.
- @JoinColumn(name=”외래키”)은 연관관계에 대한 명시뿐만아니라, 해당하는 칼럼의 insert, update 방식까지 맡고 있다고 볼 수 있겠습니다.
- 그렇게 되면 복합키인 경우 연관관계에 대한 표현은 어떻게 하면 될까요?
- @JoinColumn(insertable=false, updatable=false)
- 위의 속성들의 값을 false로 설정하면 JPA는 해당 외래키가 insert(혹은 update) 될 때 이 해당 컬럼들을 제외시킵니다.
- 그렇다면 식별관계 복합키를 사용하는 경우 외래키에 대한 insert를 처리할 수 없다는 말일까요?
- 아닙니다! 이 경우엔 외래키에 대한 insert 역할을 @JoinColumn과 해당 참조변수에 맡기는 것이 아닌,
- 별도로 존재하는 @Id 필드로 insert 처리를 하는 것이 가능합니다.
- 조금 난해한 해석이 되었죠? 코드로 확인해봅시다!
// EmployeeDetail
// ..
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_detail_seq")
private Long employeeDetailSeq;
@Id
@Column(name = "nick_name")
private String nickName;
@Id
@Column(name = "name")
private String name;
//..
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumns({
//외래키에 대한 값을 참조변수를 통하여 자동으로 insert, update 하지 않겠다..
@JoinColumn(name = "nick_name", insertable = false, updatable = false),
@JoinColumn(name = "name", insertable = false, updatable = false)
})
private Employee employee;
//..
// 결과
insert into employee_detail (content,employee_detail_seq,name,nick_name) values (?,?,?,?)
- insert 시 @JoinColmn에 대한 정보를 통한 자동 sql문 추가가 사라져서, 개발자가 선언한 필드의 값만 insert하게 되었습니다.
- 이렇게 식별관계 복합키의 상황에서 insert를 성공적으로 해보았습니다.
- 그런데 이것으로 해결 된 것일까요..?
연관관계 확인
- insert를 한 후 연관관계가 설정이 잘 된지 확인을 해보겠습니다.
public void postEmployee(EmployeeDto employeeDto){
// 소스코드를 짧게 변경해보았습니다..
Employee saved = employeeRepository.save(new Employee(employeeDto.getNickName(), employeeDto.getName()));
employeeDetailRepository.save(new EmployeeDetail(saved.getNickName(), saved.getName())); // success !
// 논리적으로는 연관관계가 설정된 것이 맞지 않을까? JPA가 무엇인가를 해주지 않았을까..?
int size = saved.getEmployeeDetailList().size();// 연관관계가 등록이 되었을까?
}
- 위 코드의 결과는 어떻게 될까요?
- saved.getEmployeeDetailList().size(); 의 결과는 1 일까요..?
- 정답은 0 입니다. 현재 트랜잭션 내부에서 연관관계를 맺어지지 않았습니다.
- 혹시 연관관계 fetch 타입을 EAGER가 아닌 LAZY로 설정해두어서 그렇게 된 것은 아닐까요 ..? 한 번 확인해보시죠
//Employee
//..
// @OneToMany(fetch = FetchType.LAZY)
@OneToMany(fetch = FetchType.EAGER, mappedBy = "employee")
List<EmployeeDetail> employeeDetailList = new ArrayList<>();
- fetch타입을 EAGER로 설정해도 결과는 동일했습니다.
- 심지어 두 경우 다 employeeDetail에 대한 select 문도 나가지 않았습니다. JPA는 별 다른 역할을 하지 않고 프로그램은 employeeDetailList에 대한 참조 변수만 확인하였습니다.
- 어찌 생각해보면 당연하기도 합니다.. saved 인스턴스의 필드인 employeeDetailList에는 아무것도 할당해주지 않았기 때문입니다..
- 그렇다면 find()로 조회를 하고 나서야 연관관계를 확인할 수 있는 걸까요?
public void postEmployee(EmployeeDto employeeDto){
Employee saved = employeeRepository.save(new Employee(employeeDto.getNickName(), employeeDto.getName()));
employeeDetailRepository.save(new EmployeeDetail(saved.getNickName(), saved.getName())); // success !
// 하지만 논리적으로는 연관관계가 설정된 것이 맞지 않을까? JPA가 무엇인가를 해주지 않았을까..?
int size = saved.getEmployeeDetailList().size();// 연관관계가 등록이 되었을까?
System.out.println(size);
// 이렇게 하면 조회를 하겠지..?
Employee found = employeeRepository.findById(new EmployeeId(saved.getNickName(), saved.getName())).orElseThrow();
size = found.getEmployeeDetailList().size();
System.out.println(size); // DB를 갔다온 후에 연관관계를 알고 있지 않을까?
}
- 뚜둔?
- size의 결과는 아직도 0이 나오고 있습니다!!
- DB를 조회하고 나서도 연관관계를 파악하지 못하고 있는 걸까요?
- 원인은 아직도 select 문이 나가고 않고 있던 것에 있었습니다.
- JPA야 일 하기 싫어..?
- saved와 found의 인스턴스 주소값이 같네요. select문을 보내지 않고 같은 인스턴스를 반환하고 있던 것이었습니다....
- 이번엔 찾아주겠지. querydsl로 찾아보겠습니다.
- 드디어 select가 나갔다 !!!
- 그런데 size가 아직도 0이다?
- 응? 찾은 엔티티 인스턴스도 기존의 엔티티와 주소값이 같다?
- 뭐지????
- 다음 게시물에 계속..
- (다음 게시물은 1차 캐시에 대해 알아보겠습니다)
'백엔드 > JPA' 카테고리의 다른 글
JPA 영속성 전이 정리 - (영속성 컨텍스트 및 엔티티 생명주기, 영속성 전이 옵션과 insertable=false, updatable=false 설정) (3) | 2024.04.15 |
---|---|
JPA 양방향 연관관계 매핑 시, 순수 객체와 엔티티 작동 차이점 (0) | 2024.03.16 |
복합키 JPA 사용 시 연관관계 및 1차 캐시 주의 사항 - 2 (0) | 2024.01.13 |