DTO를 꼭 써야 할까? Java Record로 DTO 보일러플레이트 줄이기
DB Entity를 직접 사용하면 안 되는 이유부터 Java Record를 DTO로 활용하는 방법까지
1. DTO, 안 쓸 수는 없을까? — DTO의 필요성
백엔드 개발을 하다 보면 한 번쯤 이런 생각을 하게 된다.
"Entity를 그대로 Controller에서 사용하면 DTO를 만들 필요가 없지 않을까?"
실제로 기술적으로는 가능하다.
@PostMapping("/users")
public User createUser(@RequestBody User user) {
return userService.create(user);
}
작은 프로젝트에서는 이런 방식이 크게 문제가 되지 않을 수도 있다.
하지만 프로젝트 규모가 커지고 API가 많아지면 Entity를 API에 직접 노출하는 방식은 여러 문제를 만들 수 있다.
1.1 Entity를 직접 반환했을 때 발생하는 문제
예를 들어 User Entity가 다음과 같다고 가정해보자.
@Entity
public class User {
private Long id;
private String email;
private String password;
private String role;
}
이를 Controller에서 그대로 반환하면 JSON 변환 과정에서 Entity의 필드가 API 응답에 포함될 수 있다.
{
"id": 1,
"email": "test@test.com",
"password": "...",
"role": "USER"
}
물론 @JsonIgnore 등의 방법으로 특정 필드를 제외할 수 있다.
하지만 API마다 필요한 데이터가 다르다면 Entity에 계속 API 응답 정책을 추가해야 한다.
결국 다음과 같은 문제가 발생한다.
DB 구조
↓
Entity
↓
API 응답
Entity의 변경이 API에 직접적인 영향을 주게 된다.
1.2 DTO를 사용하면
Response DTO를 별도로 만들면 필요한 데이터만 명확하게 정의할 수 있다.
public record UserResponse(
Long id,
String email
) {}
그러면 API 응답은 다음과 같이 제한된다.
{
"id": 1,
"email": "test@test.com"
}
Entity에 password, role 등의 필드가 추가되어도 Response DTO에 포함하지 않는다면 API 응답에는 노출되지 않는다.
즉 DTO는 Entity와 API 사이에 경계를 만들어주는 역할을 한다.
1.3 Request DTO와 @Valid
DTO의 또 다른 장점은 요청 데이터에 대한 검증을 API 계층에서 명확하게 관리할 수 있다는 것이다.
public record UserCreateRequest(
@NotBlank
String name,
@NotBlank
@Email
String email
) {}
Controller에서는 다음과 같이 사용할 수 있다.
@PostMapping("/users")
public void create(
@Valid @RequestBody UserCreateRequest request
) {
userService.create(request);
}
이렇게 하면 요청 데이터가 Controller에 들어오는 시점에 Validation을 적용할 수 있다.
Entity 자체를 요청 객체로 사용하는 것보다 API가 어떤 데이터를 요구하는지 명확하게 표현할 수 있다.
2. DTO를 만들 때마다 찾아오는 보일러플레이트의 습격
DTO의 필요성을 알았다면 다음 문제가 생긴다.
"그런데 DTO 하나 만들 때마다 코드가 너무 많은데?"
전통적인 Java 클래스 방식으로 DTO를 작성하면 다음과 같은 코드가 필요하다.
public class UserResponse {
private final Long id;
private final String email;
public UserResponse(Long id, String email) {
this.id = id;
this.email = email;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
@Override
public boolean equals(Object o) {
// ...
}
@Override
public int hashCode() {
// ...
}
}
실제로 DTO의 핵심은 단순하다.
id
email
그런데 이를 표현하기 위해 생성자, Getter, equals(), hashCode() 등의 코드가 추가된다.
2.1 Lombok을 사용하면?
그래서 많은 프로젝트에서 Lombok을 사용한다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserResponse {
private Long id;
private String email;
}
확실히 코드가 줄어든다.
하지만 여전히 DTO마다 어노테이션을 붙여야 하고, 필드가 변경될 때 가변성이나 생성 방식 등을 계속 신경 써야 한다.
특히 단순히 데이터를 전달하기 위한 객체인데 코드가 너무 많다는 문제가 남는다.
3. 구원투수로 등장한 Java Record
이런 DTO의 보일러플레이트를 줄이기 위해 활용할 수 있는 것이 바로 Java Record다.
record는 Java 14에서 처음 등장했고, Java 16부터 정식 기능으로 제공되었다.
기존 DTO를 다음과 같이 표현할 수 있다.
public record UserResponse(
Long id,
String email
) {}
끝이다.
기존 클래스에서 직접 작성해야 했던 생성자, 접근자 메서드, equals(), hashCode(), toString() 등을 Record가 자동으로 제공한다.
3.1 Class DTO vs Record DTO
기존 방식:
public class UserResponse {
private final Long id;
private final String email;
public UserResponse(Long id, String email) {
this.id = id;
this.email = email;
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
// equals()
// hashCode()
// toString()
}
Record:
public record UserResponse(
Long id,
String email
) {}
DTO처럼 데이터 전달을 목적으로 하는 객체를 훨씬 간결하게 표현할 수 있다.
3.2 Record의 불변성
Record의 중요한 특징 중 하나는 불변 객체를 만들기 쉽다는 것이다.
public record UserResponse(
Long id,
String email
) {}
Record의 컴포넌트는 재할당할 수 없기 때문에 다음과 같이 값을 변경할 수 없다.
response.email = "change@test.com"; // 불가능
DTO는 일반적으로 생성된 이후 값을 변경할 필요가 없는 경우가 많기 때문에 이러한 특성과 잘 맞는다.
다만 여기서 주의할 점이 있다.
Record 자체가 모든 내부 객체까지 깊게 불변으로 만드는 것은 아니다.
예를 들어 List 같은 가변 객체를 Record 컴포넌트로 사용한다면 해당 객체의 내부 내용은 별도의 방어적 복사가 필요할 수 있다.
4. Record를 DTO로 실무에 써먹기
Record는 단순히 코드를 줄이는 용도로만 사용할 수 있는 것이 아니다.
Spring Boot와 함께 사용하면 일반적인 DTO를 상당히 깔끔하게 작성할 수 있다.
4.1 Spring Validation 적용
Record의 컴포넌트에도 Spring Validation 어노테이션을 적용할 수 있다.
public record UserCreateRequest(
@NotBlank
String name,
@NotBlank
@Email
String email
) {}
Controller에서는 기존 DTO와 동일하게 사용할 수 있다.
@PostMapping("/users")
public void create(
@Valid @RequestBody UserCreateRequest request
) {
userService.create(request);
}
Record라고 해서 Spring Validation을 사용할 수 없는 것이 아니다.
4.2 Compact Constructor
Record에서 특히 유용하게 사용할 수 있는 기능이 Compact Constructor다.
예를 들어 사용자가 입력한 문자열의 앞뒤 공백을 제거하고 싶다고 해보자.
일반적인 생성자를 사용하면 다음과 같이 작성할 수 있다.
public record UserCreateRequest(
String name,
String email
) {
public UserCreateRequest(String name, String email) {
this.name = name.trim();
this.email = email.trim().toLowerCase();
}
}
Record에서는 Compact Constructor를 사용할 수 있다.
public record UserCreateRequest(
String name,
String email
) {
public UserCreateRequest {
name = name.trim();
email = email.trim().toLowerCase();
}
}
Record의 컴포넌트를 다시 선언하지 않아도 된다.
4.3 입력값 정규화
예를 들어 이메일을 항상 소문자로 정규화하고 싶다면 다음과 같이 작성할 수 있다.
public record UserCreateRequest(
@NotBlank
@Email
String email
) {
public UserCreateRequest {
email = email.trim().toLowerCase();
}
}
다음과 같은 요청이 들어와도:
TEST@TEST.COM
Record가 생성되는 시점에:
test@test.com
으로 정규화할 수 있다.
즉 다음과 같은 흐름을 만들 수 있다.
HTTP Request
↓
Request DTO
↓
Validation
↓
입력값 정규화
↓
Service
다만 정규화 로직을 어디에 둘지는 프로젝트의 책임 분리에 따라 판단할 필요가 있다.
단순한 입력 형식 정규화라면 DTO에서 처리할 수 있지만, 비즈니스 규칙에 해당한다면 Service나 별도의 도메인 계층에서 처리하는 것이 더 적절할 수 있다.
5. 정리하며
처음에는 Entity를 Controller에서 직접 사용하면 DTO를 따로 만들 필요가 없다고 생각할 수도 있다.
하지만 프로젝트가 커질수록 Entity와 API의 역할을 분리하는 것이 중요하다.
DTO를 사용하면 다음과 같은 장점이 있다.
- Entity와 API의 결합도 감소
- API에서 필요한 필드만 노출
- 민감한 Entity 필드의 실수로 인한 노출 방지
- Request/Response 구조를 명확하게 정의
@Valid를 활용한 입력값 검증- DB 구조 변경과 API 변경의 영향 분리
문제는 DTO를 많이 만들수록 발생하는 보일러플레이트 코드였다.
Java의 record는 이 문제를 상당 부분 해결해준다.
public record UserResponse(
Long id,
String email
) {}
단순한 데이터 전달 객체라면 이 정도의 코드만으로 충분하다.
결국 DTO를 사용하지 않는 것보다는 DTO의 필요성을 인정하고, Record를 활용해서 DTO 작성에 발생하는 불필요한 코드를 줄이는 것이 현대적인 Java/Spring 개발에서 좋은 선택지가 될 수 있다.
마무리
이번 내용을 정리하면 다음과 같다.
Entity를 API에 직접 사용
↓
결합도 증가 + 불필요한 데이터 노출 가능성
↓
DTO로 API와 Entity 분리
↓
하지만 DTO의 보일러플레이트 발생
↓
Java Record 활용
↓
간결하고 불변적인 DTO 작성
Entity는 DB를 표현하고, DTO는 API의 데이터를 표현한다.
그리고 단순한 데이터 전달 객체라면 Java record를 활용해 반복적인 코드를 크게 줄일 수 있다.