티스토리 뷰

Entity의 기본 생성자

JPA를 처음 접한지 얼마 지나지 않았던 시기에, Entity의 기본 생성자와 관련하여 발생한 프록시 예외로 몇 시간 동안 이유를 알아내지 못해 당황해했던 기억이 있습니다. 실제로 JPA 2.0 표준 스펙에 다음과 같은 내용이 있는데요.

 

엔티티는 반드시 파라미터가 없는 생성자가 있어야 하고, 이는 public 또는 protected 여야 한다.

이와 관련하여 이번 포스팅에서는 JPA Entity의 기본생성자에 대해 간단하게 정리해 보겠습니다.


기본생성자는 필수!

JPA를 처음 접하시는 분들이 쉽게 마주하시는 예외가 바로 기본생성자가 없다고 하는 예외인데요. 다음과 같이 엔티티를 만들어 간단한 애플리케이션을 만들어 보았습니다. (Lombok을 사용하였습니다.)

@Getter
@Entity
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Lob
    private String contents;

    public Article(String contents) { // 기본 생성자 없음
        this.contents = contents;
    }
}

게시글을 의미하는 Article Entity 입니다. 예제이기 때문에 간단하게 id와 @Lob 타입의 contents만 필드로 주었습니다.
위와 같이 contents를 받는 생성자만 있고 기본 생성자가 존재하지 않으면 어떤 일이 일어날까요?

 

다음과 같이 ArticleRepository와 간단한 Article RestController를 구성해 보겠습니다. (ArticleService는 생략하였습니다.)

public interface ArticleRepository extends JpaRepository<Article, Long> {
}
@RestController
public class ArticleApiController {

    private final ArticleService articleService;

    public ArticleApiController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @GetMapping("/api/articles")
    public ResponseEntity<List<ArticleResponse>> fetchArticles() {
        return new ResponseEntity<>(articleService.fetchArticles(), HttpStatus.OK);
    }

    @PostMapping("/api/articles")
    public void saveArticle(@RequestBody ArticleRequest articleRequest) {
        articleService.save(articleRequest);
    }
}

PostMapping을 통해 Article을 저장하고, GetMapping을 통해 Article을 전부 가져오는 두 가지 기능이 있습니다.

 

intellij의 HTTP Request를 사용하여 방금 만든 API에 HTTP 콜을 요청해 보겠습니다.

 

article.http

POST 요청으로 게시글을 하나 저장하는 데에는 성공했는데, GET 요청으로 게시글을 받아오려고 하니 다음과 같은 hibernate 예외가 발생합니다.

org.hibernate.InstantiationException: No default constructor for entity:  : jpa.study.model.Article

기본 생성자가 없다는 내용인데요, GET 요청을 통해 repository에서 엔티티 정보를 조회해 인스턴스를 만들 때 기본 생성자를 사용한다는 것을 알 수 있습니다.  
디버거를 통해 확인해보니 PojoInstantiator의 intantiate() 라는 메소드에서 인스턴스를 생성하려다 실패하고 예외가 발생합니다.

// org.hibernate.tuple.PojoInstantiator.java

public Object instantiate() {
    if ( isAbstract ) {
        throw new InstantiationException( "Cannot instantiate abstract class or interface: ", mappedClass );
    }
    else if ( optimizer != null ) {
        return optimizer.newInstance();
    }
    else if ( constructor == null ) {
        throw new InstantiationException( "No default constructor for entity: ", mappedClass );
    }
    else {
        try {
            return applyInterception( constructor.newInstance( (Object[]) null ) );
        }
        catch ( Exception e ) {
            throw new InstantiationException( "Could not instantiate entity: ", mappedClass, e );
        }
    }
}

기본 생성자의 접근 제어자

이번엔 위에서 발생한 문제를 해결하여 기본 생성자를 만들어주고, 안전하게 private으로 지정해야지! 라는 마음으로 Article Entity를 수정하고 기능구현을 계속 진행합니다.

// Article.java with private default constructor

@Getter
@Entity
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Lob
    private String contents;

    private Article() { // 기본 생성자 추가
    }

    public Article(String contents) {
        this.contents = contents;
    }
}

그리고 새로운 Entity인 게시글에 달린 댓글을 의미하는 Comment Entity도 추가합니다.

 

하나의 게시글에는 여러 개의 댓글이 달릴 수 있으니 Comment는 Article과 다대일 관계를 가집니다. 따라서 @ManyToOne으로 다대일 단방향 매핑을 걸어줍니다.

@Getter
@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Article article;

    private String contents;

    private Comment() {
    }

    public Comment(Article article, String contents) {
        this.article = article;
        this.contents = contents;
    }

    public void update(String contents) {
        this.contents = contents;
    }
}

이 때 Comment를 repository에서 가져올 때 Article까지 굳이 필요하지 않은 경우를 위해 JPA의 지연로딩을 사용합니다.

 

지연로딩을 사용하면 처음 Comment 인스턴스를 조회해서 생성할 때 Article 자리에는 Article을 상속받은 가짜 Proxy 객체가 들어갑니다. 그리고 이후에 Article을 실제로 사용하는 곳(ex. comment.getArticle().getContents())에 가서야 SELECT 쿼리를 날려서 진짜 Article 엔티티를 가져옵니다.
지연로딩 적용을 위해 fetchType을 LAZY로 걸어줍니다.

 

Article에서와 마찬가지로 repository와 RestController를 만들어보는데, 이번에는 저장 기능과 수정 기능을 만들어 보겠습니다.

public interface CommentRepository extends JpaRepository<Comment, Long> {
}
@RestController
public class CommentApiController {

    private final CommentService commentService;

    public CommentApiController(CommentService commentService) {
        this.commentService = commentService;
    }

    @PostMapping("/api/comments")
    public void save(@RequestBody CommentRequest commentRequest) {
        commentService.save(commentRequest);
    }

    @PutMapping("/api/comments/{commentId}")
    public void update(@PathVariable Long commentId, @RequestBody CommentUpdateRequest commentUpdateRequest) {
        commentService.update(commentId, commentUpdateRequest);
    }
}

그리고 마찬가지로 HTTP Request를 사용하여 HTTP 콜을 요청해 보겠습니다.

 

comment.http

POST 요청으로 아까 만든 1번 Article에 댓글을 저장하고 나서, PUT 요청으로 댓글 수정 요청을 보내니 다음과 같은 예외가 발생합니다.

java.lang.InstantiationException: jpa.study.model.Article$HibernateProxy$FFpeW17u

Repository에서 Comment 엔티티를 가져오는 CommentService 로직을 보시면 다음과 같이 findById()를 사용하고 있는데요.

@Transactional
public void update(Long commentId, CommentUpdateRequest commentUpdateRequest) {
    Comment comment = commentRepository.findById(commentId)
            .orElseThrow(IllegalArgumentException::new);

    comment.update(commentUpdateRequest.getContents());
}

위에서 말한대로 지연로딩을 적용한 엔티티인 Comment에 지연로딩 대상 엔티티인 Article을 가짜 Proxy 객체로 채워넣는데, 이 Proxy 객체는 진짜 Article Entity를 상속 받아 해당 기본 생성자를 사용하는 객체입니다. 그런데 상속 받을 부모의 기본 생성자가 private이라서, 생성에 실패하게 됩니다.

 

HibernateProxy 생성 시도


결론은 다음과 같습니다.

 

  • Entity를 JpaRepository에서 가져올 때 기본 생성자를 사용합니다.
  • 기본 생성자의 접근 제어자를 private으로 걸면, 추후에 Lazy Loading 사용 시 Proxy 관련 예외가 발생할 수 있습니다.
  • Entity의 기본 생성자는 public 혹은 protected 제어자로 걸어야 합니다. 다만 안전성 측면에서 public 보다는 protected가 더 좋을 듯 싶습니다.
댓글
댓글쓰기 폼