JPA의 Entity는 기본 생성자가 왜 반드시 필요할까?
JPA에서는 파라미터가 없는 기본생성자를 만드는 것이 필수이다.
만약 기본 생성자를 만들지 않았다고 해도 Java 단에서는 개발자가 만든 생성자가 하나도 존재하지 않으면 자동으로 기본생성자를 만들어 주기 때문에 기본 생성자가 반드시 있게 된다.
엔티티에 기본생성자가 존재하지 않으면 어떤일이 일어날까?
게시글 Entity를 만들고, 기본 생성자를 생성하지 않아보겠다.
@Getter
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Lob
private String contents;
public Post(String contents) { // 기본 생성자 없음
this.contents = contents;
}
}
그 이후 글을 저장하고 저장된 글들을 가져온다고 생각해보자.
저장된 글을 가져올 때 postRepository에서 findById 메서드를 통해 가져오게 된다.
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public Post list(Pageable pageable) {
return postRepository.findById(1L);
}
}
게시글을 저장하는 데에는 성공지만, 게시글을 가지고오려고 하니 해당 엔티티에 기본 생성자가 없다는 에러가 발생한다.
Error occurs org.springframework.orm.jpa.JpaSystemException: No default constructor for entity
여기서 알 수 있는 점은 포스트를 가져올 때 postRepository에서 findById 로 엔티티 정보를 조회하는 과정에서 기본 생성자가 필요하다는 것이다.
왜 기본 생성자가 필요한지에 대해 알기 위해 Java Reflection에 대해 잠깐 설명하겠다.
| Java Reflection는 무엇이며, 사용하는 이유는 무엇일까?
Java Reflection API는 구체적인 클래스 타입을 알지 못해도 그 클래스정보(메서드, 타입, 변수 등)에 접근할 수 있도록 해주는 Java API이다.
객체에 데이터를 넣기 위해서는 new키워드를 사용하거나 setter를 사용해야하며, 접근 제어자에 따라 접근이 가능할수도 불가능할 수 도 있다.
하지만 reflection을 사용하면 이러한 과정(new, setter)가 필요하지 않고, private 메서드의 접근도 가능할 수 있다. 왜냐하면 reflection은 static 영역에 저장되어 있는 클래스 정보에 접근이 가능하기 때문이다.
여기서 중요한 포인트는 reflection은 기본 생성자를 통해서 객체를 생성하기 때문에 해당 클래스에 기본 생성자(빈 생성자)가 있어야 사용할 수 있다.
이 refelction을 사용하는 것 중에 대표적인 것이 Spring Data JPA이다.
기본생성자가 없으면 오류나는 이유를 발견했다 !
위의 코드에서 기본 메서드가 없으면 오류가 나는 이유를 알 수 있다.
xxRepository.findById 메서드를 통해 객체를 조회하고 반환하는 로직에서 reflection이 적용되었기 때문에 기본생성자가 없으면 객체를 생성할 수가 없다.
JPA와 유사하게 기본 생성자를 강제하는 경우를 살펴보자.
@RequestBody
를 DTO로 바인딩하는 과정에서 바인딩할 타입에 기본생성자가 존재하지 않는다면 정상적으로 바인딩되지 않는 예외가 발생한다. 이는 스프링의 @RequestBody
바인딩 방식이 기본생성자를 통해 객체를 생성한 후 Java Reflection
을 이용하여 필드 값을 집어넣어 주는 방식이기 때문이다.
Reflection은 클래스의 이름만 알아도 생성자,필드,메서드 등 클래스의 모든 정보에 접근이 가능하나 생성자의 매개변수 정보를 가져올 수가 없다. 때문에 Reflection으로 생성할 객체에 모든 필드를 받는 생성자가 있더라도 Reflection은 해당 생성자를 호출할 수가 없다. 그래서 Reflection은 기본생성자로 객체를 생성하고 필드 값을 강제로 매핑해주는 방식을 사용한다.
| Java Reflection는 사용하는 이유는 무엇일까?
따라서 Java Reflection을 사용하는 이유는 JPA는 우리가 어떤 타입의 엔티티를 생성할지 알 수 없기 때문이다.
Reflection을 사용하지 않고 객체를 생성하려면 미리 객체 타입을 알고 있어야 한다. 하지만 프레임워크나 라이브러리는 사용자가 정의할 구체 클래스 정보를 알 수가 없다.
따라서 어떤 타입으로 엔티티를 만들더라도 해당 엔티티를 생성하기 위해 Reflection을 사용하여 엔티티 인스턴스를 만들어 주는 것이다.
JPA가 기본 생성자를 강제하는 이유를 정리하자면
JPA는 데이터를 DB에서 조회해온 뒤 객체를 생성할 때 Refalection을 사용한다. 때문에 위의 예시처럼 기본 생성자로 객체를 생성해야한다.
결론적으로는 기본 생성자가 존재하지 않는다면 DB에서 조회해 온 값을 엔티티로 만들 때 객체 생성 자체가 실패하게 되기 때문에 , JPA에서는 기본 스펙으로 기본 생성자를 반드시 생성해 줄 것을 정해놓는 것이다.
기본생성자의 접근 제어자
위에서 기본생성자를 무조건 생성해야 된다는 것을 알았지만 어떤 접근 제어자를 쓰는것이 맞는지 의문이 든다.
예시로 들었던 @RequestBody
용도로 사용한 DTO 클래스들 처럼 접근 제한자를 private제한하는 것은 어떨까?
기존에 만들었던 Post 엔티티와 새롭게 Comment 엔티티를 생성해서 예시를 들어보겠다.
Comment와 Post은 다대일 관계로 @ManyToOne 으로 다대일 단반향 매핑을 걸어주었다.
@Getter
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Lob
private String contents;
private Post() { // 기본 생성자 추가
}
public Post(String contents) {
this.contents = contents;
}
}
@Getter
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
**@ManyToOne(fetch = FetchType.LAZY)**
private Post post;
private String contents;
private Comment() {
}
public Comment(Post post, String contents) {
this.post = post;
this.contents = contents;
}
public void update(String contents) {
this.contents = contents;
}
}
이 때 Comment를 조회할 때 Post까지 굳이 필요하지 않은 경우가 있기 때문에 JPA의 지연로딩을 사용하였다.
지연로딩을 사용하면 Comment 인스턴스를 조회해서 생성할 때 Post자리에 Post를 상속받은 가짜 Proxy 객체가 들어간다.
이후에 comment.getPost().getContent()와 같이 Post를 실제로 사용하게 되면 select 쿼리를 날려 진짜 Post 엔티티를 가져오게 된다.
- commentService.java
@Transactional
public void update(Long commentId, CommentUpdateRequest commentUpdateRequest) {
Comment comment = commentRepository.findById(commentId)
.orElseThrow(IllegalArgumentException::new);
comment.update(commentUpdateRequest.getContents());
}
위의 코드처럼 수정요청을 보낸다고 할 때 아래와 같은 같은 오류가 발생한다.
o.h.p.p.bytebuddy.ByteBuddyProxyFactory : HHH000143: Bytecode enhancement failed because no public, protected or package-private default constructor was found for entity: com.couple.sns.domain.post.persistance.Pos. Private constructors don't work with runtime proxies
commentService 클래스의 로직을 보면 commentRepository에서 Comment 엔티티를 가져오는 findById를 사용하고 있다.
이 때 위에서 설명한 것처럼 Comment 엔티티는 지연로딩이 적용되어 지연로딩 대상 엔티티인 Post를 가짜 Proxy 객체로 가져오게 된다.
이 Proxy 객체는 진짜 Post 엔티티를 상속받아 해당 기본 생성자를 사용하는 객체이다. 그런데 상속받을 부모의 기본 생성자가 private이라서 생성에 실패하게 된다.
기본 생성자를 private로 하면 안되는 이유를 정리
지연 로딩으로 인해 프록시 객체를 사용하게 되는 경우 원본 엔티티를 상속한 프록시 객체를 생성하게 된다.
그 이후 실제 필요한 타이밍에 엔티티를 조회해 온 뒤 프록시 엔티티가 원본 엔티티를 참조하도록해야한다.
엔티티의 생성자가 private로 되어있다면 해당 엔티티를 상속한 프록시 엔티티를 만들수가 없다.
상속한 객체의 생성자는 반드시 부모 객체의 생성자 super
를 호출해야 하는데, private이면 상속받은 클래스에서 호출할 수 없기 때문이다.
한번 더 정리하자면 제한접근자가 private 여서 부모객체인 엔티티에 프록시 엔티티가 접근할 수 없다는 것이다.
즉, JPA가 Entity Class를 상속받는 Proxy 객체를 생성하기 위해 Entity Class의 기본생성자는 Public 또는 Protected의 접근제어자를 가져야 한다.
참고 : 컴파일 타임에 잡아내는 오류가 아니기 때문에 즉시 로딩을 사용하거나 하여 프록시를 사용할 때 예외가 발생한다.
그럼 엔티티 생성자는 어떤 제한 접근자를 사용하는 것이 좋을까?
기본적으로 객체를 생성하고 값을 채워넣는 방식은 크게 3가지로 분류된다.
- 기본생성자를 통해 객체 생성 - setter를 통해 필드값 주입
- 매개변수를 가지는 생성자를 통해 객체 생성과 동시에 필드값 초기화
- 정적 팩토리 메서드 (static factory method) 또는 빌더 (builder) 패턴을 통해 객체 생성과 동시에 필드값 초기화
- setter를 통한 필드값 주입
@Getter
@Setter
@NoArgsConstructor
public class User{
private Long id;
private String name;
private Long age;
private String email;
}
public static void main(String[] args){
User user = new User();
user.setName("이름1");
user.setEmail("이메일1");
}
위 코드와 같이 이름과 이메일만 setter를 통해 값을 설정해준 것을 볼 수 있는데, 이렇게 될 경우 age만 누락되어 불완전한 객체가 되어버린다.
setter를 통해 언제 어디서든 객체의 값이 변경될 수 있으므로 추후 객체의 값이 어디서 변경되었는지 추적하기 어렵고, 객체의 일관성 유지에도 좋지 않다.
따라서 setter 는 권장 방법이 아니다.
1. 도메인과 관련있는 정보만 오픈하여 생성자 생성 (메타 데이터 제외)
public Post(UserAccount userAccount, String title, String content, String hashtag) {
this.userAccount = userAccount;
this.title = title;
this.content = content;
this.hashtag = hashtag;
}
2. 팩토릭메서드 - new 키워드를 사용하지않고 도메인 Post 생성시 필요한 값 전달할 수 있도록 팩토리 메서드 생성
public static Post of(UserAccount userAccount, String title, String content, String hashtag) {
return new Post(userAccount, title, content, hashtag);
}
2, 3의 방법을 사용하게 되면 객체 생성 시 아무런 매개변수를 가지지 않는 기본생성자는 그저 JPA의 Entity Class의 요구사항 이외에는 사용할 일이 없게 된다.
제한 접근자를 protected로 제한 하였을때의 장점
이러한 이유로, 객체의 변경점을 줄이고 객체의 일관성을 유지하기 위해 매개변수를 가지는 생성자 또는 정적 팩토리 메서드를 통해 객체를 생성할 수 있도록 하고 무분별한 객체 생성을 방지하기 위해 기본 생성자의 접근제어자는 Protected로 제한함으로써 최대한 접근 범위를 작게 가져가는 것이다.
참고 : 롬복 어노테이션으로 @NoArgsConstructor(access = AccessLevel.PROTECTED)이 사용된다.
참고
[JPA] JPA에서 Entity에 protected 생성자를 만드는 이유
'spring 🍀 > spring-data-jpa' 카테고리의 다른 글
JPA N+1 문제 해결과정 ( join fetch + CountQuery) (0) | 2023.11.23 |
---|---|
JPA 프록시와 지연로딩/즉시로딩 (1) | 2023.11.23 |
EntityManager와 EntityManagerFactory (0) | 2023.11.20 |
엔티티 생명주기 - 비영속, 영속, 준영속, 삭제 (0) | 2023.11.20 |
영속성 컨텍스트란? (0) | 2023.11.18 |
JPA의 Entity는 기본 생성자가 왜 반드시 필요할까?
JPA에서는 파라미터가 없는 기본생성자를 만드는 것이 필수이다.
만약 기본 생성자를 만들지 않았다고 해도 Java 단에서는 개발자가 만든 생성자가 하나도 존재하지 않으면 자동으로 기본생성자를 만들어 주기 때문에 기본 생성자가 반드시 있게 된다.
엔티티에 기본생성자가 존재하지 않으면 어떤일이 일어날까?
게시글 Entity를 만들고, 기본 생성자를 생성하지 않아보겠다.
@Getter
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Lob
private String contents;
public Post(String contents) { // 기본 생성자 없음
this.contents = contents;
}
}
그 이후 글을 저장하고 저장된 글들을 가져온다고 생각해보자.
저장된 글을 가져올 때 postRepository에서 findById 메서드를 통해 가져오게 된다.
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public Post list(Pageable pageable) {
return postRepository.findById(1L);
}
}
게시글을 저장하는 데에는 성공지만, 게시글을 가지고오려고 하니 해당 엔티티에 기본 생성자가 없다는 에러가 발생한다.
Error occurs org.springframework.orm.jpa.JpaSystemException: No default constructor for entity
여기서 알 수 있는 점은 포스트를 가져올 때 postRepository에서 findById 로 엔티티 정보를 조회하는 과정에서 기본 생성자가 필요하다는 것이다.
왜 기본 생성자가 필요한지에 대해 알기 위해 Java Reflection에 대해 잠깐 설명하겠다.
| Java Reflection는 무엇이며, 사용하는 이유는 무엇일까?
Java Reflection API는 구체적인 클래스 타입을 알지 못해도 그 클래스정보(메서드, 타입, 변수 등)에 접근할 수 있도록 해주는 Java API이다.
객체에 데이터를 넣기 위해서는 new키워드를 사용하거나 setter를 사용해야하며, 접근 제어자에 따라 접근이 가능할수도 불가능할 수 도 있다.
하지만 reflection을 사용하면 이러한 과정(new, setter)가 필요하지 않고, private 메서드의 접근도 가능할 수 있다. 왜냐하면 reflection은 static 영역에 저장되어 있는 클래스 정보에 접근이 가능하기 때문이다.
여기서 중요한 포인트는 reflection은 기본 생성자를 통해서 객체를 생성하기 때문에 해당 클래스에 기본 생성자(빈 생성자)가 있어야 사용할 수 있다.
이 refelction을 사용하는 것 중에 대표적인 것이 Spring Data JPA이다.
기본생성자가 없으면 오류나는 이유를 발견했다 !
위의 코드에서 기본 메서드가 없으면 오류가 나는 이유를 알 수 있다.
xxRepository.findById 메서드를 통해 객체를 조회하고 반환하는 로직에서 reflection이 적용되었기 때문에 기본생성자가 없으면 객체를 생성할 수가 없다.
JPA와 유사하게 기본 생성자를 강제하는 경우를 살펴보자.
@RequestBody
를 DTO로 바인딩하는 과정에서 바인딩할 타입에 기본생성자가 존재하지 않는다면 정상적으로 바인딩되지 않는 예외가 발생한다. 이는 스프링의 @RequestBody
바인딩 방식이 기본생성자를 통해 객체를 생성한 후 Java Reflection
을 이용하여 필드 값을 집어넣어 주는 방식이기 때문이다.
Reflection은 클래스의 이름만 알아도 생성자,필드,메서드 등 클래스의 모든 정보에 접근이 가능하나 생성자의 매개변수 정보를 가져올 수가 없다. 때문에 Reflection으로 생성할 객체에 모든 필드를 받는 생성자가 있더라도 Reflection은 해당 생성자를 호출할 수가 없다. 그래서 Reflection은 기본생성자로 객체를 생성하고 필드 값을 강제로 매핑해주는 방식을 사용한다.
| Java Reflection는 사용하는 이유는 무엇일까?
따라서 Java Reflection을 사용하는 이유는 JPA는 우리가 어떤 타입의 엔티티를 생성할지 알 수 없기 때문이다.
Reflection을 사용하지 않고 객체를 생성하려면 미리 객체 타입을 알고 있어야 한다. 하지만 프레임워크나 라이브러리는 사용자가 정의할 구체 클래스 정보를 알 수가 없다.
따라서 어떤 타입으로 엔티티를 만들더라도 해당 엔티티를 생성하기 위해 Reflection을 사용하여 엔티티 인스턴스를 만들어 주는 것이다.
JPA가 기본 생성자를 강제하는 이유를 정리하자면
JPA는 데이터를 DB에서 조회해온 뒤 객체를 생성할 때 Refalection을 사용한다. 때문에 위의 예시처럼 기본 생성자로 객체를 생성해야한다.
결론적으로는 기본 생성자가 존재하지 않는다면 DB에서 조회해 온 값을 엔티티로 만들 때 객체 생성 자체가 실패하게 되기 때문에 , JPA에서는 기본 스펙으로 기본 생성자를 반드시 생성해 줄 것을 정해놓는 것이다.
기본생성자의 접근 제어자
위에서 기본생성자를 무조건 생성해야 된다는 것을 알았지만 어떤 접근 제어자를 쓰는것이 맞는지 의문이 든다.
예시로 들었던 @RequestBody
용도로 사용한 DTO 클래스들 처럼 접근 제한자를 private제한하는 것은 어떨까?
기존에 만들었던 Post 엔티티와 새롭게 Comment 엔티티를 생성해서 예시를 들어보겠다.
Comment와 Post은 다대일 관계로 @ManyToOne 으로 다대일 단반향 매핑을 걸어주었다.
@Getter
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Lob
private String contents;
private Post() { // 기본 생성자 추가
}
public Post(String contents) {
this.contents = contents;
}
}
@Getter
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
**@ManyToOne(fetch = FetchType.LAZY)**
private Post post;
private String contents;
private Comment() {
}
public Comment(Post post, String contents) {
this.post = post;
this.contents = contents;
}
public void update(String contents) {
this.contents = contents;
}
}
이 때 Comment를 조회할 때 Post까지 굳이 필요하지 않은 경우가 있기 때문에 JPA의 지연로딩을 사용하였다.
지연로딩을 사용하면 Comment 인스턴스를 조회해서 생성할 때 Post자리에 Post를 상속받은 가짜 Proxy 객체가 들어간다.
이후에 comment.getPost().getContent()와 같이 Post를 실제로 사용하게 되면 select 쿼리를 날려 진짜 Post 엔티티를 가져오게 된다.
- commentService.java
@Transactional
public void update(Long commentId, CommentUpdateRequest commentUpdateRequest) {
Comment comment = commentRepository.findById(commentId)
.orElseThrow(IllegalArgumentException::new);
comment.update(commentUpdateRequest.getContents());
}
위의 코드처럼 수정요청을 보낸다고 할 때 아래와 같은 같은 오류가 발생한다.
o.h.p.p.bytebuddy.ByteBuddyProxyFactory : HHH000143: Bytecode enhancement failed because no public, protected or package-private default constructor was found for entity: com.couple.sns.domain.post.persistance.Pos. Private constructors don't work with runtime proxies
commentService 클래스의 로직을 보면 commentRepository에서 Comment 엔티티를 가져오는 findById를 사용하고 있다.
이 때 위에서 설명한 것처럼 Comment 엔티티는 지연로딩이 적용되어 지연로딩 대상 엔티티인 Post를 가짜 Proxy 객체로 가져오게 된다.
이 Proxy 객체는 진짜 Post 엔티티를 상속받아 해당 기본 생성자를 사용하는 객체이다. 그런데 상속받을 부모의 기본 생성자가 private이라서 생성에 실패하게 된다.
기본 생성자를 private로 하면 안되는 이유를 정리
지연 로딩으로 인해 프록시 객체를 사용하게 되는 경우 원본 엔티티를 상속한 프록시 객체를 생성하게 된다.
그 이후 실제 필요한 타이밍에 엔티티를 조회해 온 뒤 프록시 엔티티가 원본 엔티티를 참조하도록해야한다.
엔티티의 생성자가 private로 되어있다면 해당 엔티티를 상속한 프록시 엔티티를 만들수가 없다.
상속한 객체의 생성자는 반드시 부모 객체의 생성자 super
를 호출해야 하는데, private이면 상속받은 클래스에서 호출할 수 없기 때문이다.
한번 더 정리하자면 제한접근자가 private 여서 부모객체인 엔티티에 프록시 엔티티가 접근할 수 없다는 것이다.
즉, JPA가 Entity Class를 상속받는 Proxy 객체를 생성하기 위해 Entity Class의 기본생성자는 Public 또는 Protected의 접근제어자를 가져야 한다.
참고 : 컴파일 타임에 잡아내는 오류가 아니기 때문에 즉시 로딩을 사용하거나 하여 프록시를 사용할 때 예외가 발생한다.
그럼 엔티티 생성자는 어떤 제한 접근자를 사용하는 것이 좋을까?
기본적으로 객체를 생성하고 값을 채워넣는 방식은 크게 3가지로 분류된다.
- 기본생성자를 통해 객체 생성 - setter를 통해 필드값 주입
- 매개변수를 가지는 생성자를 통해 객체 생성과 동시에 필드값 초기화
- 정적 팩토리 메서드 (static factory method) 또는 빌더 (builder) 패턴을 통해 객체 생성과 동시에 필드값 초기화
- setter를 통한 필드값 주입
@Getter
@Setter
@NoArgsConstructor
public class User{
private Long id;
private String name;
private Long age;
private String email;
}
public static void main(String[] args){
User user = new User();
user.setName("이름1");
user.setEmail("이메일1");
}
위 코드와 같이 이름과 이메일만 setter를 통해 값을 설정해준 것을 볼 수 있는데, 이렇게 될 경우 age만 누락되어 불완전한 객체가 되어버린다.
setter를 통해 언제 어디서든 객체의 값이 변경될 수 있으므로 추후 객체의 값이 어디서 변경되었는지 추적하기 어렵고, 객체의 일관성 유지에도 좋지 않다.
따라서 setter 는 권장 방법이 아니다.
1. 도메인과 관련있는 정보만 오픈하여 생성자 생성 (메타 데이터 제외)
public Post(UserAccount userAccount, String title, String content, String hashtag) {
this.userAccount = userAccount;
this.title = title;
this.content = content;
this.hashtag = hashtag;
}
2. 팩토릭메서드 - new 키워드를 사용하지않고 도메인 Post 생성시 필요한 값 전달할 수 있도록 팩토리 메서드 생성
public static Post of(UserAccount userAccount, String title, String content, String hashtag) {
return new Post(userAccount, title, content, hashtag);
}
2, 3의 방법을 사용하게 되면 객체 생성 시 아무런 매개변수를 가지지 않는 기본생성자는 그저 JPA의 Entity Class의 요구사항 이외에는 사용할 일이 없게 된다.
제한 접근자를 protected로 제한 하였을때의 장점
이러한 이유로, 객체의 변경점을 줄이고 객체의 일관성을 유지하기 위해 매개변수를 가지는 생성자 또는 정적 팩토리 메서드를 통해 객체를 생성할 수 있도록 하고 무분별한 객체 생성을 방지하기 위해 기본 생성자의 접근제어자는 Protected로 제한함으로써 최대한 접근 범위를 작게 가져가는 것이다.
참고 : 롬복 어노테이션으로 @NoArgsConstructor(access = AccessLevel.PROTECTED)이 사용된다.
참고
[JPA] JPA에서 Entity에 protected 생성자를 만드는 이유
'spring 🍀 > spring-data-jpa' 카테고리의 다른 글
JPA N+1 문제 해결과정 ( join fetch + CountQuery) (0) | 2023.11.23 |
---|---|
JPA 프록시와 지연로딩/즉시로딩 (1) | 2023.11.23 |
EntityManager와 EntityManagerFactory (0) | 2023.11.20 |
엔티티 생명주기 - 비영속, 영속, 준영속, 삭제 (0) | 2023.11.20 |
영속성 컨텍스트란? (0) | 2023.11.18 |