
이 글은 우테코 오픈미션을 진행하던 도중 예상치 못한 JPA 오류를 마주하고 그 원인과 학습 내용을 정리한 글이다.
처음엔 단순히 “코드가 잘못된 건가?” 정도로 생각했지만 결국 JPA가 내부적으로 객체를 어떻게 생성하는지를 이해하게 된 계기가 되었다.

개발중에 갑자기 이런 오류 문구가 떴다. 분명 나는 잘 작성한거 같은데..라는 생각으로 눌러서 확인을 해봤다.

원인은 StoredBook에 public 또는 protected 기본 생성자가 있어야 한다는 뜻이였다.
그래서 내 코드를 확인해 보았다.
package smiinii.object_oriented_library.domain;
import jakarta.persistence.*;
@Entity
public class StoredBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Enumerated(EnumType.STRING)
private StoredBookStatus status;
private long bookId;
private StoredBook(long bookId, StoredBookStatus status) {
this.bookId = bookId;
this.status = status;
}
public static StoredBook createAvailable(long bookId) {
return new StoredBook(bookId, StoredBookStatus.AVAILABLE);
}
public static StoredBook createOnHold(long bookId) {
return new StoredBook(bookId, StoredBookStatus.ON_HOLD);
}
}
역시나 기본 생성자가 없었다. 근데 저는 기본 생성자가 없어도 다른 생성자들이 있으니깐 상관없는거 아니야?라는 생각이였다. 순수 자바로 코딩을 할 때는 기본 생성자가 없어도 작동에 문제가 없었는데 JPA를 사용하니 오류가 생긴 것이였다.
순수 자바에서는 개발자가 new로 직접 만든다.
StoredBook storedBook = new StoredBook(1L, StoredBookStatus.AVAILABLE);
프로그램이 이 코드를 직접 실행해서 객체를 생성하기 때문에 기본 생성자가 없어도 아무 문제가 없다.
하지만 JPA는 DB에서 데이터를 꺼낼 때 리플렉션(Reflection)이라는 기술로 객체를 생성한다.
즉, new StoredBook()을 우리가 호출하지 않고 JPA 내부 코드가 자동으로 객체를 만드는 것이다.
// JPA 내부 동작 개념
Class<?> clazz = StoredBook.class;
Object entity = clazz.getDeclaredConstructor().newInstance();
이렇게 호출할 때 기본 생성자가 없으면 JPA가 객체를 만들 수 없다. 그래서 내 코드에서 오류가 난 것이다.
리플렉션은 프로그램이 자기 자신을 분석하고 조작할 수 있는 자바의 기능이다.
즉 클래스 이름만 알고 있어도 런타임에 그 클래스의 필드나 메서드, 생성자 등에 접근할 수 있다.
예를 들어 JPA는 아래처럼 동작한다.
Class<?> clazz = Class.forName("smiinii.object_oriented_library.domain.StoredBook");
Object entity = clazz.getDeclaredConstructor().newInstance();
위 코드는 “클래스 이름 문자열”로부터 실제 객체를 만드는 과정이다.
이렇게 리플렉션을 사용하면 new 없이도 객체를 생성할 수 있지만 그 전제 조건이 바로 ‘기본 생성자가 존재해야 한다’는 점이다.
기본 생성자를 public으로 두면 외부에서 마음대로 사용할 수 있다.
이건 도메인 규칙을 깨뜨릴 위험이 있다.
그래서 외부 코드가 함부로 new 하지 못하게 막으면서 JPA 내부에서는 리플렉션으로 접근할 수 있도록 protected StoredBook()이라는 형태를 권장한다.
package smiinii.object_oriented_library.domain;
import jakarta.persistence.*;
@Entity
public class StoredBook {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Enumerated(EnumType.STRING)
private StoredBookStatus status;
private long bookId;
protected StoredBook() {}
private StoredBook(long bookId, StoredBookStatus status) {
this.bookId = bookId;
this.status = status;
}
public static StoredBook createAvailable(long bookId) {
return new StoredBook(bookId, StoredBookStatus.AVAILABLE);
}
public static StoredBook createOnHold(long bookId) {
return new StoredBook(bookId, StoredBookStatus.ON_HOLD);
}
public void loan() {
if (status == StoredBookStatus.LOANED) {
throw new IllegalStateException("이미 대출 중입니다.");
}
if (status == StoredBookStatus.ON_HOLD) {
throw new IllegalStateException("다른 회원이 예약 중입니다.");
}
this.status = StoredBookStatus.LOANED;
}
public void returnBook(boolean isReservation) {
if (status != StoredBookStatus.LOANED) {
throw new IllegalStateException("대출 중인 도서만 반납할 수 있습니다.");
}
if (isReservation) {
this.status = StoredBookStatus.ON_HOLD;
return;
}
this.status = StoredBookStatus.AVAILABLE;
}
public StoredBookStatus getStatus() {
return status;
}
}
이번 오류를 단순히 “기본 생성자가 없어서 생긴 문제”로 끝내지 않고 왜 필요한지를 직접 찾아보고 이해하는 과정에서 프레임워크(JPA)가 내부에서 어떻게 동작하는지를 조금 더 깊게 볼 수 있었다.
이번 경험을 통해 JPA가 객체를 생성하고 관리하는 방식(리플렉션 기반)을 이해하게 되었고 “코드를 내가 작성하지 않아도 내부에서는 어떤 일이 일어나는가”를 한 단계 더 생각하게 되었다.
앞으로는 단순히 오류를 해결하는 것에 그치지 않고 그 원리를 직접 확인하고 정리하는 습관을 계속 유지하고 싶다.
이런 과정을 반복하다 보면 단순히 코드를 “사용하는 개발자”가 아니라 “이해하고 설명할 수 있는 개발자”로 성장할 수 있을 거라 생각한다.