코딩은 마라톤

[JPA] 기본 생성자가 private인데도 에러가 안 나는 이유 본문

Backend/JPA

[JPA] 기본 생성자가 private인데도 에러가 안 나는 이유

anxi 2025. 9. 7. 04:14

들어가며

 

엔티티를 설계하다 보면 기계적으로 붙이는 애노테이션이 있다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)

 

항상 위 2개의 애노테이션을 붙이면서 엔티티 관련 문제가 발생한 적이 없고, 잘 쓰고 있었다.

 

NoArgsConstructor를 사용하는 이유는 JPA에서 엔티티 생성 시 Reflection 방식을 사용하는데 이때 기본 생성자가 필요하기 때문이다. 또한 지연로딩(Lazy Loading)을 사용해 연관된 엔티티를 조회할 때 실제로 사용하기 전까지는 프록시 객체를 사용하는데, 이때 기본 생성자가 private으로 선언되어 있다면 해당 엔티티를 상속한 프록시 객체를 사용할 수 없어 이를 방지하기 위해 public이나 protected를 사용해 기본 생성자를 만든다.

 

만약 private로 기본 생성자를 선언했다면, IDE는 친절하게 경고를 제공한다.

 

친절리제이


가설 : private으로 선언된 기본 생성자를 사용하면 프록시 객체가 생성되지 않는다.

@Data
@Entity
public class Taco {

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

    private Date createdAt;

    @NotNull
    @Size(min = 5, message = "이름은 최소 5글자 이상이어야 합니다.")
    private String name;

    @ManyToMany(targetEntity = Ingredient.class)
    @Size(min = 1, message = "최소 1개 이상의 재료가 선택해야 합니다.")
    private List<Ingredient> ingredients;

    @PrePersist
    void createdAt() {
        this.createdAt = new Date();
    }
}

 

Taco 엔티티

  • Taco 엔티티의 기본 생성자는 public으로 되어 있다.
  • @ManyToMany로 Ingredient 엔티티와 연관 관계가 맺어 있고, ManyToMany는 지연 로딩이 기본값이다.
@Data
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Entity
public class Ingredient {

    @Id
    private String id;
    private String name;
    private Type type;

    public enum Type {
        WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE,
    }
}

 

Ingredient 엔티티

  • Ingredient 엔티티의 기본 생성자는 private으로 되어 있다.

실행 결과 확인

 

프록시 관련 에러가 발생하길 바라며, 테스트 코드를 돌려보면? 🧐

@DataJpaTest
class TacoRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private TacoRepository tacoRepository;

    @Test
    void PRIVATE_생성자에서_프록시객체가_생성된다() {
        // 1. 재료 데이터 저장
        Ingredient ingredient = new Ingredient("ING1", "Lettuce", Ingredient.Type.VEGGIES);
        entityManager.persist(ingredient);

        // 2. 타코 데이터 저장
        Taco taco = new Taco();
        taco.setName("Veggie Taco");
        taco.setIngredients(List.of(ingredient));
        entityManager.persist(taco);
        entityManager.flush();
        entityManager.clear();  // 1차 캐시 초기화 (프록시 확인)

        // 3. Taco 조회 (Lazy 로딩 확인)
        Taco foundTaco = tacoRepository.findById(taco.getId()).orElseThrow();
        System.out.println(">>> Taco 조회 완료");

        // 4. Ingredient 프록시 여부 확인
        List<Ingredient> ingredients = foundTaco.getIngredients();
        System.out.println(">>> ingredients 클래스: " + ingredients.getClass().getName());
        System.out.println(">>> 첫 번째 ingredient 클래스: " + ingredients.get(0).getClass().getName());

        // 5. 실제 필드 접근 (강제 초기화)
        System.out.println(">>> 첫 번째 ingredient ID: " + ingredients.get(0).getId());
    }
}

 

서두에서 밝혔듯이, 연관관계가 맺어 있고, Taco 엔티티를 불러오면 Ingredient 엔티티가 지연 로딩으로 프록시 객체가 생성되어 프록시 관련 에러가 발생해야 한다.

엔티티 생성자를 찾을 수 없다는 에러

 

하지만...

성공 ?!

 

테스트가 성공함을 볼 수 있다..


왜 이런 일이 발생할까?

Hibernate: 
    select
        next value for taco_seq
Hibernate: 
    insert 
    into
        ingredient
        (name, type, id) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        taco
        (created_at, name, id) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        taco_ingredients
        (taco_id, ingredients_id) 
    values
        (?, ?)
Hibernate: 
    select
        t1_0.id,
        t1_0.created_at,
        t1_0.name 
    from
        taco t1_0 
    where
        t1_0.id=?
>>> Taco 조회 완료
>>> ingredients 클래스: org.hibernate.collection.spi.PersistentBag
Hibernate: 
    select
        i1_0.taco_id,
        i1_1.id,
        i1_1.name,
        i1_1.type 
    from
        taco_ingredients i1_0 
    join
        ingredient i1_1 
            on i1_1.id=i1_0.ingredients_id 
    where
        i1_0.taco_id=?
>>> 첫 번째 ingredient 클래스: spring.springinaction.tacos.domain.model.Ingredient
>>> 첫 번째 ingredient ID: ING1

 

테스트 코드의 쿼리를 보자.

 

Taco를 조회하면 ingredients는 지연 로딩으로 프록시 객체여야 한다.

ingredients 클래스: org.hibernate.collection.spi.PersistentBag

 

하지만 Proxy 객체가 아닌 PersistentBag이라는 생소한 클래스를 확인할 수 있다.


👜 PersistentBag

PersistentBag

 

  • Hibernate가 엔티티의 컬렉션 필드를 관리하기 위해 제공하는 Collection Wrapper이다.
  • 하이버네이트는 Entity를 영속화할 때, Entity 안의 Collection을 추적하고 관리하기 위해 원본 컬렉션을 감싼 내장 컬렉션을 사용한다.
  • 원본 Collection, List를 하이버네이트가 제공하는 내장 컬렉션(PersistentBag)으로 변경한다. (Set은 PersistentSet을 사용)

정리

프록시가 필요한 경우와 필요 없는 경우를 아래와 같이 나타낼 수 있다.

  • 프록시가 필요한 경우 (단일 엔티티 연관관계: @ManyToOne, @OneToOne)
  • 프록시가 필요 없는 경우 (컬렉션 연관관계: @OneToMany, @ManyToMany)

프록시가 필요한 경우인 단일 엔티티 연관관계에서는 지연 로딩에서 바로 프록시 객체를 사용하기 때문에 private 기본 생성자를 사용하면 에러가 발생한다.

 

프록시가 필요 없는 경우인 컬렉션 연관관계에서는 내장 컬렉션을 프록시 대신 사용하므로 private 기본 생성자라도 문제가 발생하지 않음을 알 수 있다.

 

Hibernate

 

하지만 JPA, Hibernate 표준 스펙에서 기본 생성자를 protected 이상으로 사용하길 권장한다. 

따라서 웬만하면 protected로 기본 생성자를 선언함으로써, 상속과 캡슐화를 고려해서 사용하는 것이 가장 적절한 선택이라 생각한다!


참고

 

Entity types

The goal of the @Synchronize annotation in the AccountSummary entity mapping is to instruct Hibernate which database tables are needed by the underlying @Subselect SQL query. This is because, unlike JPQL and HQL queries, Hibernate cannot parse the underlyi

docs.jboss.org