Java

[Java] Java 21 Record로 DTO를 다시 설계해 본 이유

sowith 2026. 8. 16. 02:51

시작하며

기존 프로젝트에서는 Response DTO를 작성할 때 주로 Lombok의 @Data, @Builder, @NoArgsConstructor, @AllArgsConstructor를 사용했습니다.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class CreditDTO {
    private Long creditId;
    private String name;
    private String charName;
    private String profileImageUrl;
}

 

처음에는 이 방식이 꽤 편했습니다. DTO마다 Getter, Setter, 생성자를 직접 작성하지 않아도 됐고 Builder를 사용하면 객체를 생성할 때 어떤 필드에 어떤 값을 넣는지도 쉽게 확인할 수 있었습니다. 실제로 기능을 개발하는 동안에도 큰 불편은 없었습니다. 그러다 Java 21을 사용하면서 record를 살펴보게 되었고 기존 DTO도 다시 확인해봤습니다. 처음에는 DTO마다 반복되는 Lombok 애노테이션을 줄이고 선언을 조금 더 간결하게 만들 수 있겠다는 정도의 생각이었습니다. 그런데 코드를 살펴보다 보니 그보다 먼저 한 가지 의문이 생겼습니다.

 

지금 사용하고 있는 DTO가 정말 데이터 전달 객체라는 역할에 맞게 설계되어 있을까?

 

이번 글에서는 이 질문에서 시작해 기존 Response DTO를 record로 변경하고 그 과정에서 생긴 문제를 MapStruct Mapper로 정리하기까지의 과정을 적어보려고 합니다.

 

DTO란?

DTO(Data Transfer Object)는 이름 그대로 데이터를 전달하기 위한 객체입니다. 프로젝트의 Response DTO도 대부분 비슷한 흐름으로 사용하고 있었습니다.

데이터 조회 → Service → Response DTO 생성 → Controller → Client

 

Service에서 필요한 데이터를 조회하고 Response DTO를 만든 뒤 Controller를 거쳐 클라이언트에 전달했습니다. 실제 코드를 확인해봐도 DTO는 대부분 Builder로 한 번 생성한 뒤 값을 읽어 전달했고 생성 이후 Setter를 이용해 상태를 계속 변경하는 경우는 거의 없었습니다. 그런데 기존 DTO에는 @Data가 붙어 있었습니다.

@Data
public static class CreditDTO {
    private Long creditId;
    private String name;
}

 

Lombok의 @Data는 Getter뿐 아니라 Setter도 함께 만들어줍니다. 따라서 실제로 사용하고 있지는 않더라도 다음과 같은 코드를 작성할 수 있었습니다.

CreditDTO credit = new CreditDTO();
credit.setCreditId(1L);
credit.setName("Actor A");
credit.setName("Actor B");

 

Response DTO는 실제로는 한 번 생성한 뒤 읽고 전달하는 용도로 사용하고 있었지만, 타입 자체는 생성 이후 언제든 상태를 변경할 수 있었습니다.  DTO가 데이터를 전달하는 역할이라면 생성 이후 상태 변경까지 열어둘 필요가 있는지 다시 생각해보기로 했습니다.

 

Record 전환을 결정한 이유

먼저 불필요한 변경 가능성을 제거하고 싶었습니다. 기존 DTO는 대부분 다음과 같이 Builder로 생성했습니다.

ContentResponseDTO.CreditDTO.builder()
    .creditId(creditVo.getCreditId())
    .name(creditVo.getName())
    .charName(creditVo.getCharName())
    .profileImageUrl(creditVo.getProfileImageUrl())
    .build();

 

@Data가 붙어 있는 이상 Builder로 객체를 생성한 이후에도 Setter를 호출할 수 있었습니다. 기존 DTO는 읽기 전용처럼 사용하고 있었지만 읽기 전용으로 설계된 객체는 아니었습니다.

 

두 번째로는 DTO마다 반복되는 선언도 줄이고 싶었습니다. Response DTO가 늘어날수록 다음과 같은 애노테이션이 거의 동일하게 반복되고 있었습니다.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class CreditDTO { ... }

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class EpisodeDTO { ... }

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SeasonDTO { ... }

 

당시에는 DTO를 빠르게 작성하기 위해 편리한 조합이라고 생각했지만 기존 코드를 다시 살펴보니 모든 기능이 실제로 필요한 것은 아니었습니다. Response DTO는 대부분 한 번 생성한 뒤 값을 읽어 전달하고 있었기 때문에 Setter나 기본 생성자가 필요한 경우는 거의 없었습니다. 물론 Class를 유지하면서 필요한 기능만 남기는 방법도 있었습니다. 

@Getter
@AllArgsConstructor
public static class CreditDTO {
    private final Long creditId;
    private final String name;
    private final String charName;
    private final String profileImageUrl;
}

 

이렇게 작성해도 생성 이후 필드를 다시 할당할 수 없는 DTO를 만들 수 있습니다. 다만 프로젝트에서는 Java 21을 사용하고 있었고, Response DTO 대부분이 별도의 동작을 가지기보다 값을 담아 전달하는 역할을 하고 있었습니다. 같은 구조를 record로 표현하면 다음과 같이 단순해집니다.

public record Credit(
    Long creditId,
    String name,
    String charName,
    String profileImageUrl
) {}

 

record는 component를 기반으로 생성자와 접근자, equals(), hashCode(), toString() 등을 제공하고 각 component를 생성 이후 다시 할당할 수 없도록 합니다. 따라서 이번에는 Response DTO의 변경 가능성을 제한하면서 반복되는 선언도 줄일 수 있다는 점을 고려해 record를 적용했습니다.

 

Response DTO를 Record로 변경했습니다

기존 Response DTO를 record로 변경했습니다.

public class ContentResponse {
    public record Credit(
        Long creditId,
        String name,
        String charName,
        String profileImageUrl
    ) {}
    public record Episode(
        Long episodeId,
        Integer episodeNo,
        String title,
        String summary,
        String stillImageUrl
    ) {}
    public record Season(
        Long seasonId,
        Integer seasonNo,
        String name,
        String overview,
        String posterImageUrl
    ) {}
    public record CreditList(
        List<Credit> credits
    ) {}
    public record EpisodeList(
        List<Episode> episodes
    ) {}
    public record SeasonList(
        List<Season> seasons
    ) {}
}

 

기존에는 DTO 하나를 만들 때마다 여러 Lombok 애노테이션을 함께 선언해야 했지만 record로 변경한 뒤에는 DTO가 어떤 데이터를 가지고 있는지에 집중할 수 있게 되었습니다. 무엇보다 실제 사용 방식과 타입의 성격이 맞아졌습니다.

 

기존
@Data 기반 Class
- 한 번 생성한 뒤 읽고 전달
- 하지만 Setter를 통한 상태 변경 가능

변경
record
- 한 번 생성한 뒤 읽고 전달
- 생성 이후 component 재할당 불가

 

 

여기까지는 의도했던 방향대로 정리되었습니다. 그런데 실제 Service 코드를 record에 맞게 변경하면서 새로운 문제가 보였습니다.

 

Record로 변경하니 생성 코드가 불편해졌습니다

기존 DTO에서는 Builder를 사용해 객체를 생성했습니다.

ContentResponseDTO.CreditDTO.builder()
    .creditId(creditVo.getCreditId())
    .name(creditVo.getName())
    .charName(creditVo.getCharName())
    .profileImageUrl(creditVo.getProfileImageUrl())
    .build();

 

Builder는 각 값이 어떤 필드에 들어가는지 코드에서 바로 확인할 수 있다는 장점이 있었습니다. 반면 record로 변경한 뒤에는 생성자를 이용해 값을 순서대로 전달해야 했습니다.

new ContentResponse.Credit(
    creditVo.getCreditId(),
    creditVo.getName(),
    creditVo.getCharName(),
    creditVo.getProfileImageUrl()
);

 

필드가 적을 때는 크게 불편하지 않았지만 같은 타입의 필드가 연속되는 경우에는 실수 가능성이 있었습니다

public record Credit(
    Long creditId,
    String name,
    String charName,
    String profileImageUrl
) {}

 

예를 들어 name과 charName의 순서를 바꿔 전달하더라도 두 값이 모두 String이기 때문에 컴파일 단계에서는 오류를 확인할 수 없습니다

new ContentResponse.Credit(
    creditVo.getCreditId(),
    creditVo.getCharName(),
    creditVo.getName(),
    creditVo.getProfileImageUrl()
);

 

또한 Credit, Episode, Season처럼 VO를 Response DTO로 변환하는 코드가 Service마다 직접 작성되면서 Service가 조회 로직뿐 아니라 Response DTO의 생성 방식까지 알아야 했습니다. 그래서 생성자 호출을 각 Service에 두기보다 VO와 Response DTO 사이의 변환 규칙을 한곳에서 관리하는 방향을 선택했습니다. 이를 위해 MapStruct 기반의 Mapper를 적용했습니다.

 

MapStruct Mapper로 변환 책임을 분리했습니다

record로 변경한 뒤에는 Service에서 생성자를 직접 호출해 VO를 Response DTO로 변환해야 했습니다.

List<ContentResponse.Credit> creditDtoList = creditVoList.stream()
    .map(vo -> new ContentResponse.Credit(
        vo.getCreditId(),
        vo.getName(),
        vo.getCharName(),
        vo.getProfileImageUrl()
    ))
    .toList();

return new ContentResponse.CreditList(creditDtoList);

 

Credit뿐 아니라 Episode, Season에서도 비슷한 변환 코드가 반복됐습니다. 실제 Mapper 적용 전 ContentQueryService가 각각의 VO를 순회하면서 record 생성자를 직접 호출하고 있었습니다. 이 구조에서는 Service가 데이터 조회와 조합뿐 아니라 VO를 어떤 Response DTO로 변환해야 하는지까지 알고 있어야 했습니다. 그래서 생성자 호출을 각 Service에 남겨두기보다 VO와 Response DTO 사이의 변환 규칙을 별도의 Mapper로 분리하기로 했습니다.

@Mapper(componentModel = "spring")
public interface ContentDtoMapper {

    ContentResponse.Credit toCredit(CreditVo creditVo);

    List<ContentResponse.Credit> toCredits(List<CreditVo> creditVoList);

    default ContentResponse.CreditList toCreditList(List<CreditVo> creditVoList) {
        return new ContentResponse.CreditList(toCredits(creditVoList));
    }
}

 

필드명이 동일한 단순한 변환은 MapStruct에 맡기고, 여러 데이터를 조합해야 하는 Response만 필요한 매핑 규칙을 별도로 정의했습니다. 이렇게 하면서 Service에서는 record의 생성자 순서나 VO와 Response 사이의 변환 방법을 직접 알 필요가 없어졌습니다.

public ContentResponse.CreditList getCredits(Long contentId) {
    return contentDtoMapper.toCreditList(contentDao.selectContentCredits(contentId));
}

 

기존에는 Service가 데이터 조회와 DTO 변환을 함께 담당했다면 변경 후에는 역할을 다음과 같이 나눴습니다.

 

ContentQueryService → 데이터 조회 → 필요한 데이터 조합 → 조회 흐름 제어

ContentDtoMapper → VO → Response 변환 → Response 생성 규칙 관리

 

처음 Mapper를 적용하게 된 계기는 Builder가 사라지면서 생긴 생성자 순서 문제였습니다. 하지만 실제로 적용한 뒤에는 단순히 순서 실수를 줄이는 것보다 DTO를 만드는 규칙을 Service에서 분리했다는 점이 더 큰 변화였습니다. 실제 변경에서는 ContentDtoMapper를 Service에 주입하고 직접 작성했던 record 생성 및 List 변환 코드를 Mapper 호출로 변경하면서 ContentQueryService에서 67줄이 제거됐습니다. 역할도 조금 더 명확하게 나눌 수 있었습니다.

 

결과

이번 변경으로 Response DTO에서 사용하지 않던 Setter를 제거하고, 반복해서 사용하던 Lombok 애노테이션도 줄일 수 있었습니다.

 

record로 변경하면서 Service에서 직접 생성자를 호출하는 코드가 늘어났지만 해당 부분은 MapStruct Mapper로 옮겨 VO에서 Response를 만드는 코드를 한곳에서 관리하도록 정리했습니다. 그 결과 ContentQueryService에서도 직접 DTO를 생성하거나 List를 변환하던 코드가 줄어들었습니다.

 

처음에는 반복되는 DTO 선언을 줄이기 위해 시작한 변경이었지만 record를 적용하면서 기존에 편의를 위해 사용하던 기능들이 실제로 필요한지 다시 확인하고 Service에 섞여 있던 변환 코드까지 함께 정리할 수 있었습니다.

 

마치며

처음 기존 DTO를 작성할 때 @Data와 @Builder를 사용한 이유는 명확했습니다. 작성해야 하는 코드를 줄일 수 있었고 DTO를 빠르게 만들기에도 편리했습니다. 그래서 이번에는 DTO의 본래 역할에 대해 생각해보며 Setter를 실제로 사용하고 있는가?보다 먼저 이 객체가 변경 가능해야 할 이유가 있는가?를 생각했습니다.

 

1. 객체가 사용하는 기능과 제공하는 기능을 맞추자

Response DTO에서는 Getter가 필요하다고 해서 Setter까지 필요한 것은 아니었습니다. 실제 사용 방식에 필요한 기능만 타입에 남기는 것이 객체의 역할을 이해하기에도 더 명확했습니다.

 

2. 반복되는 선언도 설계를 다시 볼 신호가 될 수 있었습니다

DTO마다 동일하게 반복되는 Lombok 애노테이션은 처음에는 단순한 보일러플레이트 정도로 생각했습니다. 하지만 대부분의 DTO가 동일한 애노테이션을 필요로 한다는 것은 이 객체들이 비슷한 역할을 가지고 있다는 의미이기도 했습니다. 이번에는 그 역할이 단순한 데이터 전달이라는 점과 Java 21을 사용하고 있다는 점을 고려해 record를 선택했습니다.

 

3. 하나를 개선하면 다른 Trade-off가 생길 수 있습니다

record로 변경하면서 선언은 단순해졌지만 Builder가 제공하던 필드명 기반의 가독성은 사라졌습니다. 특히 같은 타입의 필드가 연속될 경우 생성자 순서를 잘못 전달해도 컴파일 단계에서 확인하기 어려웠습니다. 그래서 단순히 record를 적용하는 데서 끝내지 않고, MapStruct Mapper를 사용해 변환 책임을 별도로 분리했습니다.

 

 

참고자료

https://projectlombok.org/features/Data

 

@Data

 

projectlombok.org