@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
private String name;
private String email;
// 여기서 멈춘다. 생성자를 어떻게 만들지?
}
개발을 하다 보면 별것 아닌 것 같은 결정이 나중에 큰 문제가 되는 경우가 있다. JPA Entity의 생성자 설계가 그 중 하나다. 특히 @GeneratedValue
로 자동 생성되는 id
나 @CreatedDate
, @LastModifiedDate
같은 audit 필드들을 생성자 파라미터로 받을지 말지는 생각보다 복잡한 문제다.
처음에는 단순하게 생각했다. 모든 필드를 받는 생성자를 만들면 완전한 객체를 생성할 수 있고, 테스트도 편하다. 어떻게 보면 가장 객체지향적인 접근 같기도 했다. 하지만 실제로 써보니 문제가 있었다. 개발자가 id
에 임의의 값을 넣으면 JPA의 @GeneratedValue
전략과 충돌하고, audit 필드에 잘못된 시간을 넣으면 데이터 정합성이 깨진다.
그렇다고 자동 관리 필드들을 생성자에서 빼면 다른 고민이 생긴다. Entity 클래스가 JPA에 너무 의존적이 되는 것 같았다. new User("홍길동", "hong@example.com")
라고 쓸 때마다 “이 객체는 JPA 없이는 완전하지 않구나”라는 생각이 들었다. 순수한 도메인 객체라고 하기엔 뭔가 어색했다.
물론 요즘은 JPA Entity마저 인프라로 보고 ‘순수한’ 도메인 객체와 JPA Entity를 아예 분리하려는 시도도 있다. 하지만 그건 너무 과한 것 같다. JPA/Hibernate는 애초에 POJO 기반으로 설계되었고, 비즈니스 로직을 담은 도메인 객체가 동시에 영속성 객체 역할을 하는 것이 원래 의도였다. 매핑 레이어를 하나 더 만드는 것보다는 적당한 타협점을 찾는 게 낫다는 생각이었다. 결국 이 딜레마를 어떻게 해결할지 고민하게 되었다.
현실적인 선택지들
옵션 1: 비즈니스 필드만 생성자에 포함
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
protected User() {} // JPA 기본 생성자
}
깔끔하다. 의도도 명확하다. “이 필드들은 JPA가 알아서 관리하니까 신경 쓰지 마”라는 메시지가 전달된다.
하지만 테스트 작성할 때 좀 귀찮아진다. 특정 ID를 가진 엔티티를 만들어야 하는 상황에서는 리플렉션을 써야 하거나 별도의 팩토리 메서드를 만들어야 한다.
옵션 2: 모든 필드를 생성자에 포함
public User(Long id, String name, String email,
LocalDateTime createdAt, LocalDateTime updatedAt) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
테스트하기는 편하다. Mock 데이터 만들 때도 간단하다. 하지만 이 방식의 문제는 실무에서 금방 드러난다.
파라미터가 너무 많다. 특히 audit 필드가 더 추가되면(createdBy, updatedBy 등) 생성자가 괴물이 된다. 개발자는 매번 new User(null, "홍길동", "hong@example.com", null, null)
이런 식으로 “첫 번째와 마지막 두 개는 null을 넣어야지”라고 의식해야 한다. 자동 관리 필드임을 알고 있어도 매번 null을 명시적으로 써야 하는 번거로움이 있고, 실수로 값을 넣으면 JPA의 자동 관리 기능과 충돌하거나 예상과 다르게 동작할 수 있다.
옵션 3: 생성자 오버로딩
// 비즈니스용
public User(String name, String email) {
this.name = name;
this.email = email;
}
// 테스트용
User(Long id, String name, String email, LocalDateTime createdAt) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = createdAt;
}
패키지 레벨로 테스트용 생성자를 만드는 방식이다. 나쁘지 않지만 생성자가 여러 개 있으면 어떤 걸 써야 할지 헷갈린다. 특히 새로 온 개발자들은 더 그렇다. 요즘에는 이런 문제를 아예 피하려고 UUID를 Primary Key로 쓰는 경우도 본다. 객체 생성 시점에 ID가 정해지니까 깔끔하긴 하다. 하지만 단순히 생성자 설계 문제를 해결하려고 UUID를 도입하는 건 좀 과한 것 같다.
테스트 문제는 해결할 수 있다
옵션 1을 선택했을 때 가장 큰 걸림돌은 테스트다. 하지만 이 문제는 충분히 해결 가능하다. 물론 “@DataJpaTest를 쓰면 되지 않느냐”고 생각할 수도 있다. 하지만 슬라이스 테스트라고 해도 이건 엄연히 통합 테스트다. 도메인 모델에 대한 단위 테스트가 항상 스프링 컨테이너에 의존할 필요는 없다. 빠르고 가벼운 단위 테스트를 위해서는 다른 방법이 필요하다.
ReflectionTestUtils 활용
@Test
void 사용자_조회_테스트() {
User user = new User("홍길동", "hong@example.com");
ReflectionTestUtils.setField(user, "id", 1L);
// 테스트 진행...
}
Spring Test에서 제공하는 ReflectionTestUtils
를 쓰면 간단하다. 리플렉션의 복잡함은 숨기고 깔끔한 API만 제공한다.
조금 더 체계적으로 접근하려면 테스트 패키지에만 존재하는 빌더 클래스를 만들어볼 수도 있다. 프로덕션 코드는 건드리지 않으면서도 테스트에서는 편하게 쓸 수 있어서 나쁘지 않은 방법이다.
// src/test/java 패키지에만 존재
public class UserTestBuilder {
private Long id = 1L;
private String name = "테스트사용자";
private String email = "test@example.com";
private LocalDateTime createdAt = LocalDateTime.now();
public UserTestBuilder id(Long id) {
this.id = id;
return this;
}
public UserTestBuilder name(String name) {
this.name = name;
return this;
}
public User build() {
User user = new User(name, email);
ReflectionTestUtils.setField(user, "id", id);
ReflectionTestUtils.setField(user, "createdAt", createdAt);
return user;
}
}
결국 어떤 선택을 할 것인가
완벽한 해답은 없고, 각 팀의 성향에 따라 다른 선택을 해야겠지만 개인적으로는 비즈니스 필드만 생성자에 넣는 방식을 선호하게 되었다. 테스트가 복잡해지는 건 분명한 단점이지만, 위에서 소개한 ReflectionTestUtils 나 테스트 빌더 같은 방법들로 충분히 해결할 수 있었다.
물론 다른 선택을 하는 프로젝트도 많이 봤다. 테스트의 편의성을 중시해서 모든 필드를 받는 생성자를 만드는 팀도 있고, 패키지 레벨 생성자로 타협하는 팀도 있다. 어떤 선택을 하든 팀 내에서 일관된 기준을 정하고 지키는 것이 더 중요하다. 결국 코드는 사람이 읽는 것이고, 그 의도가 명확하게 전달되는 코드가 좋은 코드라는 생각은 변하지 않는다.
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
private String name;
private String email;
// I stop here. How should I design the constructor?
}
In development, sometimes seemingly trivial decisions can become major issues later. Designing JPA Entity constructors is one of them. Particularly, whether to include auto-generated fields like @GeneratedValue
id
or audit fields like @CreatedDate
and @LastModifiedDate
as constructor parameters is a more complex problem than it initially appears.
At first, I thought it was simple. Create a constructor that accepts all fields - you can create complete objects and testing is easy. It seemed like the most object-oriented approach. But in practice, I found problems. When developers put arbitrary values in id
, it conflicts with JPA’s @GeneratedValue
strategy, and putting incorrect timestamps in audit fields breaks data integrity.
But removing auto-managed fields from constructors creates other concerns. The Entity class seemed too dependent on JPA. Every time I wrote new User("John Doe", "john@example.com")
, I thought “this object isn’t complete without JPA.” It felt awkward for something that should be a pure domain object.
Of course, nowadays there are attempts to view even JPA Entities as infrastructure and completely separate ‘pure’ domain objects from JPA Entities. But that seems excessive. JPA/Hibernate was originally designed to be POJO-based, with the intent that domain objects containing business logic would simultaneously serve as persistence objects. Rather than creating another mapping layer, I thought it would be better to find a reasonable compromise. This led me to ponder how to resolve this dilemma.
Practical Options
Option 1: Only Business Fields in Constructor
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
protected User() {} // JPA default constructor
}
Clean. The intent is clear. It conveys the message: “These fields are managed by JPA, so don’t worry about them.”
However, it becomes a bit cumbersome when writing tests. When you need to create an entity with a specific ID, you have to use reflection or create separate factory methods.
Option 2: All Fields in Constructor
public User(Long id, String name, String email,
LocalDateTime createdAt, LocalDateTime updatedAt) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
Testing is convenient. Creating mock data is simple. But the problems with this approach quickly become apparent in practice.
Too many parameters. Especially when more audit fields are added (createdBy, updatedBy, etc.), the constructor becomes a monster. Developers must constantly think: new User(null, "John Doe", "john@example.com", null, null)
- “remember to put null for the first and last two.” Even knowing they’re auto-managed fields, having to explicitly write null every time is cumbersome, and accidentally putting values can conflict with JPA’s auto-management features or behave unexpectedly.
Option 3: Constructor Overloading
// For business use
public User(String name, String email) {
this.name = name;
this.email = email;
}
// For testing
User(Long id, String name, String email, LocalDateTime createdAt) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = createdAt;
}
Creating a package-level constructor for testing. Not bad, but having multiple constructors can be confusing about which one to use. Especially for new developers. I’ve seen cases where teams use UUID as the Primary Key to avoid this problem entirely. Since the ID is determined at object creation time, it’s clean. But introducing UUID just to solve constructor design issues seems excessive.
The Test Problem Can Be Solved
The biggest obstacle when choosing Option 1 is testing. But this problem is definitely solvable. You might think “Why not just use @DataJpaTest?” But even though it’s a slice test, it’s still an integration test. Unit tests for domain models don’t always need to depend on the Spring container. For fast and lightweight unit tests, we need other approaches.
Using ReflectionTestUtils
@Test
void testUserQuery() {
User user = new User("John Doe", "john@example.com");
ReflectionTestUtils.setField(user, "id", 1L);
// Continue with test...
}
Using ReflectionTestUtils
provided by Spring Test makes it simple. It hides the complexity of reflection and provides a clean API.
For a more systematic approach, you could create a builder class that exists only in the test package. It’s a decent method as it doesn’t touch production code while being convenient for tests.
// Exists only in src/test/java package
public class UserTestBuilder {
private Long id = 1L;
private String name = "Test User";
private String email = "test@example.com";
private LocalDateTime createdAt = LocalDateTime.now();
public UserTestBuilder id(Long id) {
this.id = id;
return this;
}
public UserTestBuilder name(String name) {
this.name = name;
return this;
}
public User build() {
User user = new User(name, email);
ReflectionTestUtils.setField(user, "id", id);
ReflectionTestUtils.setField(user, "createdAt", createdAt);
return user;
}
}
Which Choice to Make?
There’s no perfect answer, and different teams will make different choices based on their preferences. Personally, I’ve come to prefer the approach of including only business fields in constructors. While it’s a clear disadvantage that testing becomes more complex, it can be adequately resolved with methods like ReflectionTestUtils or test builders introduced above.
Of course, I’ve seen many projects make different choices. Some teams prioritize testing convenience and create constructors that accept all fields, while others compromise with package-level constructors. Whatever choice you make, it’s more important to establish and maintain consistent standards within the team. Ultimately, code is read by people, and I believe good code is code that clearly conveys its intent.