JPA Entity를 작성하다 보면 다음과 같이 Lombok을 이용해 생성자의 접근 범위를 제한하는 코드를 자주 사용한다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
처음에는 관례적으로 사용했지만 다음과 같은 의문이 생겼다.
protected로 선언할까?private으로 제한할까?private으로 설정하면 어떻게 될까?직접 생성자의 접근 범위를 변경하고 Entity 조회와 Proxy 생성을 테스트하면서 그 이유를 확인해 보았다.
JPA Entity를 작성하면서 다음과 같이 기본 생성자는 protected, 모든 필드를 받는 생성자는 private으로 제한하는 방식을 자주 사용해 왔다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
두 생성자 모두 외부에서 Entity를 임의로 생성하는 것을 제한하기 위한 것이라고 생각할 수 있다.
하지만 단순히 생성자의 호출을 제한하는 것이 목적이라면 두 생성자를 동일한 접근 제어자로 제한할 수도 있다.
그럼에도 JPA Entity에서는 기본 생성자에 protected, 모든 필드를 받는 생성자에 private을 사용하는 경우가 많다.
각 생성자의 접근 범위를 다르게 설정하는 이유가 무엇인지 알아보기 위해 먼저 Java에서 생성자의 접근을 제한하는 의미부터 확인하였다.
먼저 JPA Entity에서 기본 생성자 자체가 필요한 이유를 확인하기 위해 @NoArgsConstructor를 제거하였다.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
public Member(String name) {
this.name = name;
}
}
Entity를 저장한 뒤 flush(), clear()를 수행하고 DB에서 다시 조회하였다.
Member member = new Member("홍길동");
Member savedMember = repository.save(member);
em.flush();
em.clear();
Member findMember =
repository.findById(savedMember.getId()).orElseThrow();
조회 과정에서 InstantiationException이 발생하였다.
Hibernate는 DB에서 조회한 데이터를 바탕으로 실제 Entity 인스턴스를 생성해야 한다.
하지만 Entity에 기본 생성자가 존재하지 않아 인스턴스를 생성하지 못한 것이다.
이를 통해 Hibernate가 실제 Entity 객체를 생성하기 위해 기본 생성자가 필요하다는 것을 확인하였다.
기본 생성자가 필요하므로 이번에는 public으로 선언하였다.
@NoArgsConstructor(access = AccessLevel.PUBLIC)
public 기본 생성자는 JPA의 요구사항을 만족하므로 Entity의 저장과 조회가 정상적으로 동작한다.
하지만 애플리케이션의 외부 코드에서도 다음과 같이 빈 Entity를 자유롭게 생성할 수 있다.
Member member = new Member();
JPA가 Entity를 생성하기 위해서는 기본 생성자가 필요하지만 일반 애플리케이션 코드에서 빈 Entity를 직접 생성할 필요는 없다.
따라서 JPA의 요구사항을 만족하면서 기본 생성자의 호출 범위를 더 제한할 수 있는 방법이 필요하다.
이번에는 기본 생성자의 접근 범위를 가장 강하게 제한하기 위해 private으로 설정하였다.
@NoArgsConstructor(access = AccessLevel.PRIVATE)
JPA 명세에서는 Entity 클래스에 public 또는 protected 기본 생성자를 제공하도록 요구하기 때문에 private 기본 생성자는 JPA 명세의 요구사항을 만족하지 않는다.
따라서 Entity 조회 과정에서 문제가 발생할 것으로 예상했지만, Hibernate를 사용하는 현재 환경에서는 일반적인 Entity 저장 및 조회가 정상적으로 동작하였다.
일반적인 Entity 조회에서는 Proxy 객체가 반드시 필요한 것은 아니며, Hibernate는 자체 구현을 통해 private 기본 생성자에도 접근하여 실제 Entity 인스턴스를 생성할 수 있었기 때문이다.
예상과 달리 private 기본 생성자로도 Entity 조회가 정상적으로 동작했기 때문에, Proxy 객체를 생성하는 경우에도 문제가 없는지 추가로 확인해 보았다.
Proxy 생성을 확인하기 위해 EntityManager.getReference()를 사용하였다.
Member proxy =
em.getReference(Member.class, savedMember.getId());
기본 생성자가 private인 상태에서 테스트한 결과, 일반적인 Entity 조회와 달리 Proxy 생성 과정에서는 org.hibernate.HibernateException이 발생하였다.
이를 통해 Hibernate에서는 private 기본 생성자를 이용한 실제 Entity 생성이 가능할 수 있지만, Proxy를 생성하는 상황에서는 생성자의 접근 범위가 문제가 될 수 있음을 확인하였다.
따라서 특정 Hibernate 구현에서 일반적인 Entity 조회가 동작한다는 것에 의존하지 않고, JPA 명세를 준수하여 기본 생성자를 public 또는 protected로 제공해야 한다.
마지막으로 기본 생성자를 protected로 설정하였다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
JPA 명세에서는 Entity의 기본 생성자를 public 또는 protected로 제공하도록 요구하므로 protected는 JPA의 요구사항을 만족한다.
동시에 public과 달리 다른 패키지의 일반 코드에서는 다음과 같이 기본 생성자를 직접 호출할 수 없다.
Member member = new Member(); // 접근 불가
따라서 protected를 사용하면 JPA Provider가 Entity를 생성할 수 있도록 기본 생성자를 제공하면서 애플리케이션 외부에서 빈 Entity를 임의로 생성하는 것은 제한할 수 있다.
이를 통해 기본 생성자에 protected를 사용하는 이유를 확인할 수 있었다.
이번에는 모든 필드를 받는 생성자를 확인하였다.
@AllArgsConstructor(access = AccessLevel.PUBLIC)
AllArgsConstructor는 JPA가 요구하는 생성자가 아니다.
따라서 JPA의 Entity 생성 여부와 관계없이 애플리케이션에서 어떻게 Entity를 생성하도록 할 것인지에 따라 접근 범위를 결정할 수 있다.
모든 필드를 받는 생성자를 public으로 공개하면 외부에서 ID를 포함한 모든 필드를 직접 전달하여 Entity를 생성할 수 있다.
Member member = new Member(1L, "홍길동");
이 경우 개발자가 의도하지 않은 값까지 외부에서 직접 지정할 수 있는 생성 경로가 노출된다.
모든 필드를 받는 생성자를 private으로 제한하였다.
@AllArgsConstructor(access = AccessLevel.PRIVATE)
AllArgsConstructor는 JPA가 사용할 필요가 없기 때문에 외부에 공개할 이유가 없다면 private으로 제한할 수 있다.
이를 통해 외부에서 ID를 포함한 모든 필드를 임의로 지정하여 Entity를 생성하는 것을 방지할 수 있다.
AllArgsConstructor의 접근 범위는 JPA에서 요구하는 사항이 아니므로 반드시 private으로 선언해야 하는 것은 아니다.
도메인의 요구사항이나 객체 생성 방식에 따라 필요한 접근 범위로 변경할 수 있지만,
모든 필드를 외부에서 직접 전달하여 Entity를 생성해야 할 명확한 이유가 없다면 private으로 제한하여 불필요한 생성 경로를 노출하지 않는 것이 적절하다.
대신 외부에는 실제 도메인에서 필요한 생성 방법만 제공한다.
public Member(String name) {
this.name = name;
}
또는 정적 팩토리 메서드나 Builder와 같이 개발자가 의도한 객체 생성 방법을 제공할 수 있다.
테스트를 통해 기본 생성자와 모든 필드 생성자의 접근 범위를 서로 다르게 설정하는 이유를 확인할 수 있었다.
기본 생성자는 JPA Provider가 Entity를 생성하기 위해 필요한 생성자이다.
JPA 명세에서는 기본 생성자를 public 또는 protected로 제공하도록 요구하며, 이 중 protected를 사용하면 JPA의 요구사항을 만족하면서 외부에서 빈 Entity를 직접 생성하는 것을 제한할 수 있다.
따라서 기본 생성자는 다음과 같이 구성한다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
반면 모든 필드를 받는 생성자는 JPA가 요구하는 생성자가 아니다.
따라서 외부에서 ID를 포함한 모든 필드를 임의로 설정하는 생성 방법을 노출하지 않기 위해 private으로 제한할 수 있다.
@AllArgsConstructor(access = AccessLevel.PRIVATE)
결과적으로 다음과 같은 Entity 생성자 구성이 각각의 목적을 만족한다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
public Member(String name) {
this.name = name;
}
}
NoArgsConstructor(PROTECTED)와 AllArgsConstructor(PRIVATE)는 단순히 생성자의 접근 범위를 제한하기 위해 서로 다른 접근 제어자를 사용하는 것이 아니다.
기본 생성자는 JPA가 사용해야 하는 생성자이기 때문에 JPA 명세가 허용하는 범위 안에서 최대한 접근을 제한하여 protected로 사용하고, 모든 필드 생성자는 JPA가 요구하지 않는 생성자이므로 외부에 공개할 필요가 없다면 private으로 제한하는 것이다.
따라서 JPA Entity에서는 JPA가 필요로 하는 생성자와 애플리케이션에 공개할 생성자를 구분하고, 외부에는 개발자가 의도한 Entity 생성 방법만 제공하도록 구성한다.