본문 바로가기

Programing/JVM(Java, Kotlin)

[JPA] 엔티티의 연관관계 시행착오

회사에서 JPA를 사용하고 있었지만 테이블이 비정규화되어 있어서 테이블간에 join이 불필요했다.

엔티티 역시 연관관계를 가지지 않았기에 단순하게 CRUD 정도만 하면 되서 복잡도는 낮았다. N+1 문제가 발생할 일이 없다.

대신 불필요한 데이터를 중복적으로 저장을 하기 위한 오버헤드가 존재했다.

 

이번에 현금영수증 관련 개발을 하면서 약간의 정규화를 해서 테이블의 연관관계를 갖도록 설계를 진행했다.

그런데 데이터를 저장하면서 부터 난관에 봉착했다.

예를 들면 아래와 같은 특정 필드가 값을 기본값을 가지지 않는다는 등의 알 수 없는 메시지를 만났다.

Caused by: java.sql.SQLException: (conn=2575762) Field 'CASH_RECEIPT_REQUEST_ID' doesn't have a default value

개발 학습

문제도메인을 축소시키는 것이 학습에 용이할 것 같았다. 웹에서 Entity간의 관계에 대한 샘플을 찾아서 학습 테스트를 진행하였다.

해당 예제에서는 회원(member)과 전화번호(phone)라는 회원번호에 대한 연관을 가지는 관계를 가지고 있었다.

엔티티

연관관계를 제외한 엔티티 클래스는 아래와 같다.

package com.example.springboot.sandbox.repository.entity;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.persistence.*;

@Entity
@Getter
@Setter
public class Member {
    @Id
    @Column(name = "SEQ")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer seq;

    @Column(name = "NAME")
    private String name;
}
package com.example.springboot.sandbox.repository.entity;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.persistence.*;

@Entity
@Getter
@Setter
public class Phone {
    @Id
    @Column(name = "SEQ")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer seq;

    @Column(name = "MEMBER_ID")
    private Integer memberId;

    @Column(name = "PHONE_NO")
    private String phoneNo;
}

연관관계 1

예제에서는 관계를 member 쪽에 두었다. 아래와 같이 Member에 phones 이라는 컬렉션을 두어 관리하게 하는 것이다.

@Entity
@Getter
@Setter
@ToString
public class Member {
    @Id
    @Column(name = "SEQ")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer seq;

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

    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "MEMBER_ID")
    private Collection<Phone> phones = new ArrayList<>();

    public void addPhone(Phone phone) {
        this.phones.add(phone);
    }
}

데이터를 넣는 부분이다.

final Phone namoPhone = new Phone();
namoPhone.setPhoneNo("010-1234-5678");

final Member namo = new Member();
namo.setName("namo");
namo.addPhone(namoPhone);

memberRepository.save(namo);

select 쿼리를 해보면 아래와 같이 예상대로 값이 저장되어 있다.

그러면 JPA가 실제 수행한 쿼리는 어떨까?

insert into member (seq, name) values (null, 'namo');
insert into phone (seq, member_id, phone_no) values (null, null, '010-1234-5678');
update phone set member_id=1 where seq=1;

전화번호의 member_id를 업데이트 쿼리가 추가로 수행되었음을 알 수 있다.

장점

데이터를 넣는 코드를 보면 memberRepository만 나온다. phoneRepository에 save하는 동작이 없어도 동작이 되는 것이다.

단점

만약 phone 테이블의 FK 컬럼이 not null 이라면 두 번째 쿼리는 에러가 날 것이다.

또한 쿼리가 아래처럼 두 번만 호출되면 되는데 update를 위해서 한 번 더 호출이 이루어진다.

insert into member (seq, name) values (null, 'namo');
insert into phone (seq, member_id, phone_no) values (null, 1, '010-1234-5678');

연관관계 2

연관관계를 phone 쪽에 둘 수도 있다.

이번에는 Phone 엔티티가 아래와 같이 바뀐다.

package com.example.springboot.sandbox.repository.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
public class Phone {
    @Id
    @Column(name = "SEQ")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer seq;

//    @Column(name = "MEMBER_ID")
//    private Integer memberId;

    @ManyToOne(fetch = FetchType.EAGER, targetEntity = Member.class)
    @JoinColumn(name = "MEMBER_ID", referencedColumnName = "SEQ")
    private Member member;

    @Column(name = "PHONE_NO")
    private String phoneNo;
}

데이터를 넣는 코드는 아래와 같다.

final Member namo = new Member();
namo.setName("namo");
memberRepository.save(namo);

final Phone namoPhone = new Phone();
namoPhone.setPhoneNo("010-1234-5678");
namoPhone.setMember(namo);
phoneRepository.save(namoPhone);

처음 생각은 namo 객체에 자동생성되는 seq가 처음에는 null이기 때문에 해당 null이 Phone쪽에도 저장되지 않을까 생각했는데,

memberRepository.save가 호출되면서 seq 값을 먼저 조회해서 갱신을 해주기에 save의 결과로 반환된 객체의 값과 동일했다.

(setter등을 사용하지 않고 refelection 을 이용해서 값을 바꾸는 것 같았다.)

다른 삽질들

Repeated column in mapping for entity 에러

연관관계2에서 FK에 해당하는 memberId를 주석처리했다.

package com.example.springboot.sandbox.repository.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
public class Phone {
    @Id
    @Column(name = "SEQ")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer seq;

    @Column(name = "MEMBER_ID")
    private Integer memberId;	// FK 1

    @ManyToOne(fetch = FetchType.EAGER, targetEntity = Member.class)
    @JoinColumn(name = "MEMBER_ID", referencedColumnName = "SEQ")
    private Member member; 	// FK 2

    @Column(name = "PHONE_NO")
    private String phoneNo;
}

만약 위와 같이 구성했다고 하면 Repeated column in mapping for entity 에러가 발생한다.

 

Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaMappingContext': Invocation of init method failed
  nested exception is javax.persistence.PersistenceException: [PersistenceUnit: default] Unable to build Hibernate SessionFactory
  nested exception is org.hibernate.MappingException: Repeated column in mapping for entity: com.example.springboot.sandbox.repository.entity.Phone column: member_id (should be mapped with insert="false" update="false")
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1796) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]

StackOverflowError 주의

만약 연관관계1과 2를 동시에 사용할 경우 toString Override를 주의해서 해야 한다.

무한 루프에 빠져서 stack overflow를 일으킬 수 있기 때문이다.

영속화 시킨 이후에 findAll로 조회를 하면 객체간은 서로 참조를 가지게 된다.

 

for (Member m : memberRepository.findAll()) {
    System.out.println(m.toString());
}

toString은 계속 객체를 따라 가며 이쪽 저쪽의 toString이 호출되다 결국 StackOverflowError를 찍고 죽어버린다.

내 시스템에서는 409 번의 toString 호출이 이루어졌다.

더보기
java.lang.StackOverflowError: null
	at java.lang.StringBuilder.append(StringBuilder.java:136) ~[na:1.8.0_202]
	at com.example.springboot.sandbox.repository.entity.Phone.toString(Phone.java:12) ~[classes/:na]
	at java.lang.String.valueOf(String.java:2994) ~[na:1.8.0_202]
	at java.lang.StringBuilder.append(StringBuilder.java:131) ~[na:1.8.0_202]
	at java.util.AbstractCollection.toString(AbstractCollection.java:462) ~[na:1.8.0_202]
	at org.hibernate.collection.internal.PersistentBag.toString(PersistentBag.java:622) ~[hibernate-core-5.4.15.Final.jar:5.4.15.Final]
	at java.lang.String.valueOf(String.java:2994) ~[na:1.8.0_202]
	at java.lang.StringBuilder.append(StringBuilder.java:131) ~[na:1.8.0_202]
	at com.example.springboot.sandbox.repository.entity.Member.toString(Member.java:14) ~[classes/:na]
	at java.lang.String.valueOf(String.java:2994) ~[na:1.8.0_202]
	at java.lang.StringBuilder.append(StringBuilder.java:131) ~[na:1.8.0_202]
	at com.example.springboot.sandbox.repository.entity.Phone.toString(Phone.java:12) ~[classes/:na]
	at java.lang.String.valueOf(String.java:2994) ~[na:1.8.0_202]
	at java.lang.StringBuilder.append(StringBuilder.java:131) ~[na:1.8.0_202]
	at java.util.AbstractCollection.toString(AbstractCollection.java:462) ~[na:1.8.0_202]
    ...

만약 lombok의 @ToString을 사용한다면 아래와 같이 exclude로 제외할 필드를 지정하면, 무한루프의 사슬은 끊어질 수 있다.

@ToString(exclude = "member")
public class Phone {

primitive 타입보다는 Wrapper 타입을 쓴다.

JDM님의 예제의 Phone Entity에는 회원과 관계인 memberId가 Integer가 아닌 int로 되어 있다.

본문에도 '외래키(foreign key) 제약 조건'을 걸지 않았다고 하므로 문제없이 동작을 하지만, 만약 FK 제약이 있었으면 아래와 같이 에러가 발생할 수 있다.

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referential integrity constraint violation: "FKdkd9v5c9pv5wnuebtr5hjtvkf: PUBLIC.phone FOREIGN KEY(member_id) REFERENCES PUBLIC.member(seq) (0)"; SQL statement:
insert into "phone" ("member_id", "phone_no", "seq") values (?, ?, ?) [23506-200]

왜냐하면 primitive int의 경우 기본값이 0이므로 0이란 값을 DB에 넣으려고 하는데 Member.seq가 0인 데이터가 없으므로 제약조건 위배가 발생하는 것이다.

 

DB관점에서 0과 null은 다른 것으로 구별하기에 자바코드에서도 int나 long보다는 Integer나 Long 같은 참조타입을 사용하는 것이 좋은 이유이다.

FK가 not null 인 경우

만약 F가 not null로 되어 있는 경우 연관관계1 의 방법으로 저장을 하면 insert 에러가 발생한다.

일단 null로 넣고 member의 PK로 update를 하는 방법이므로 null 입력이 불가능하기 때문이다.

 

또한 조회를 할 때 JPA는 기본적으로 null 이라고 가정하고 JOIN을 하기에 외부조인(OUTER JOIN)으로 수행된다.

따라서 아래와 같이 optional을 false로 지정을 하면 내부조인(INNER JOIN)으로 수행한다.

(@JoinColumn의 nullable을 falsef로 지정을 해도 내부조인으로 수행되나, 이 경우 where 절에 외래키가 있는 경우 left out 조인이 될 수도 있다.)

package com.example.springboot.sandbox.repository.entity;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
public class Phone {
    @Id
    @Column(name = "SEQ")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer seq;

    @ManyToOne(fetch = FetchType.EAGER, targetEntity = Member.class, optional = false)
    @JoinColumn(name = "MEMBER_ID", referencedColumnName = "SEQ")
    private Member member;

    @Column(name = "PHONE_NO")
    private String phoneNo;
}