Development
DTO - 이렇게 사용하지 마세요
2025-03-06

DTOData Transfer Object는 계층 간 교환에 사용되는 데이터를 담은 객체입니다. 가끔 함께 공부하는 개발자들의 코드를 볼 때, '이런 코드는 다음과 같이 바뀌었음 좋겠다'라는 생각이 들 때가 있어 간단하게 정리해 보았습니다.
계층을 무력화하는 DTO
DTO의 잘못된 사용 사례를 살펴보고, 그로 인해 발생하는 문제점을 알아보겠습니다.
예제로 사용할 Article 도메인은 게시판의 게시글을 나타내며, title, content, authorName 을 갖는다고 가정합니다.
게시글을 생성하는 기능을 개발하기 위해, 사용자로부터 받아야하는 DTO 객체를 정의하고 컨트롤러를 작성해보겠습니다. 또한, content는 50자 이상, 200자 이하라는 제약을 넣어주었습니다.
@Getter
public class ArticleCreateRequest {
private String title;
@Size(min = 50, max = 200)
private String content;
private String authorName;
}@RestController
@RequiredArgsConstructor
@RequestMapping("/api/articles")
public class ArticleController {
@PostMapping
public void createArticle(
@Valid @RequestBody ArticleCreateRequest articleCreateRequest
)
{
//todo call article service
}
}계층을 구분하는 것은 중요하니 Article을 생성하는 비즈니스 로직은 서비스 레이어에서 처리하도록 합니다. 서비스 메서드에서 필요한 정보는 title, content와 authorName으로, 공교롭게도 DTO에 모두 포함되어있으므로 그대로 서비스 레이어에 전달하는 방식으로 구현하겠습니다.
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
@Transactional
public void createArticle(ArticleCreateRequest req) {
Article article = new Article(
req.getTitle(), req.getContent(), req.getAuthorName()
);
articleRepository.save(article);
}
}완료되었습니다! 테스트를 해보니 잘 동작하는 것으로 보입니다. 이제 PR 날리고 발뻗고 누워서 리뷰를 기다릴 차례입니다.
문제점
허나 이러한 구조는 몇가지 문제점을 갖습니다.
1. 웹 계층의 변경이 도메인 서비스 로직에 영향을 준다.
이제 인증 시스템이 추가되어서 사용자로부터 authorName을 받지 않습니다. aurhorName은 사용자 토큰에서 추출한다고 가정합시다.
//ArticleCreateRequest.java
public ArticleCreateRequest {
private String title;
@Size(min = 50, max = 200)
private String content;
}
//ArticleController.java
public class ArticleController {
public void createArticle(
@Login String username,
@RequestBody ArticleCreateRequest articleCreateRequest
) {
articleService.createArticle(articleCreateRequest); //???
}
}ArticleCreateRequest에서는 더 이상 authorName을 포함하지 않으며, 컨트롤러에서 @Login 애노테이션을 통해 추출할 수 있습니다. 웹 계층에서 사용자의 이름을 얻는 형태만 바뀐 것입니다. 하지만 DTO가 도메인 서비스 로직까지 침투한 결과, 컨트롤러 뿐만 아니라 서비스 메서드까지 변경해야하는 문제가 발생합니다.
//method parameter 변경
public void createArticle(String authorName, ArticleCreateRequest req) {
//article 생성 파라미터 변경
Article article = new Article(
req.getTitle(), req.getContent(), authorName
);
articleRepository.save(article);
}서비스 메서드에서 전파가 끝나서 다행이지, 만약 Repository에도 동일한 DTO를 사용했다면 변경의 전파는 영속성 계층까지 퍼졌을 것입니다.
도메인 서비스가 필요로 하는 값 자체에는 변함이 없지만, DTO를 그대로 넘겨받았기에 변경이 필요했습니다. 결국 DTO를 넘기는 행위는 서비스 메서드를 변경해야하는 이유를 도메인 로직의 변경 뿐만 아닌 웹 계층의 변경까지로 확장하는 문제를 낳습니다. '하나의 클래스는 변경해야하는 이유가 단 하나여야 한다'라는 SRP의 원칙의 위반입니다.
2. 서비스 메서드의 재사용이 어렵다.
ArticleService의 createArticle을 다른 서비스에서 사용해야하는 일이 생겼다고 가정해봅시다.
@RequiredArgsConstructor
public class AnotherService {
private final ArticleService articleService;
public void someMehtod(...) {
...
ArticleCreateRequest req = new ArticleCreateRequest(...);
articleService.createArticle(req);
}
}위 코드에서 내용이 50자 이상 200자 이하여야 한다는 입력 유효성은 지켜질 수 없습니다. 컨트롤러에서만 검증이 수행되기 때문입니다. 따라서 이런 경우에는 누군가는 검증을 수행해야 합니다.
첫번째 방법은 createArticle을 호출하는 측에서 검증하는 것입니다. 하지만 이 방법은 또다른 곳에서 createArticle이 사용되는 경우 검증 로직의 중복을 초래하며, 실수로 검증 없이 서비스 메서드를 호출할 가능성을 만들게 됩니다.
아무래도 위 방법은 서비스 메서드의 입장에서 입력 유효성을 신뢰할 수 없는 방법인 것 같습니다. 그럼 서비스 메서드가 직접 검증을 수행하면 해결될까요? 이 경우에는 순수한 비즈니스 규칙만을 지켜야 할 도메인 서비스 로직이 입력 유효성 검증 코드로 오염되어 책임이 과다해진다는 문제가 발생합니다. (내용의 길이를 검증하는 것이 입력 유효성인지 비즈니스 규칙인지는 논의 대상이지만, 이는 나중에 다른 토픽으로 다루도록 하겠습니다.)
결국 어떤 방법으로도 서비스 메서드를 재사용하기에는 조금씩 껄끄러운 점이 생깁니다. 이 역시 컨트롤러 레벨의 DTO를 서비스 메서드에서 사용하기에 생기는 문제로 볼 수 있습니다.
해결책
DTO 를 넘기지 않기
첫번째 문제였던 웹 계층의 변경이 도메인 로직에 영향을 주지 않게 하는 방법은, 그저 DTO를 받지 않는 방법입니다.
public class ArticleService {
public void createArticle(String title, String content, String authorName) {..}
}만약 createArticle 메서드가 DTO가 아닌 각각의 값들을 명확히 전달받았다면, 컨트롤러가 값을 어디서 어떻게 가져오든 그것은 더 이상 서비스 메서드의 관심사가 아닙니다. 그저 필요한 값을 받고 사용할 뿐입니다. 만약 메서드가 위와 같이 작성되었다면 첫번째 문제는 발생하지 않았을 것이고, 변경은 오직 컨트롤러에서만 발생했을 것입니다.
하지만 이 해결책은 두번째 문제를 해결하지 않습니다. 여전히 검증에 대한 책임을 부여하기가 애매한 상황입니다.
별도의 DTO 사용하기
두번째 문제를 해결하는 방법으로는 서비스 계층에 별도의 DTO를 정의하는 것입니다. 서비스 계층의 DTO는 서비스 메서드가 필요로 하는 입력 모델을 제공하며, 웹 계층의 DTO와 분리된 별도의 클래스로 작성되어야 합니다
@Getter
public class ArticleCreateCommand {
public ArticleCreateCommand(String title, String content, String authorName) {
if (content.size() < 50 && content.size() > 200) {
throw ValidationError("Content size not valid");
}
this.title = title;
this.content = content;
this.authorName = authorName;
}
private final String title;
private final String content;
private final String authorName;
}ArticleCreateCommand는 Article을 생성하는데 필요한 데이터를 모두 담고 있습니다. 또한 생성자에서 입력 유효성 검증을 수행하여 생성된 이후에는 값의 변경이 제한되기 때문에, 시스템 내에 유효하지 않은 ArticleCreateCommand 객체는 존재할 수 없습니다. 이 객체를 createArticle이 받도록 만들면 됩니다.
public class ArticleService {
public void createArticle(ArticleCreateCommand command) {
Article article = new Article(
command.getTitle(),
command.getContent(),
command.getAuthorName()
);
// save
}
}이러한 방법을 사용했을 때에는 입력 모델에만 맞추어 서비스 메서드를 호출해준다면 웹 계층이 변경되더라도 서비스 메서드는 변경될 이유가 없습니다. 첫번째 문제는 해결되었습니다.
이 방법은 두 번째 문제 역시 해결합니다. 다른 서비스에서 createArticle을 호출할 때도, 해당 서비스 메서드에 별도의 검증 로직을 작성할 필요가 없습니다. createArticle은 커맨드 객체를 신뢰할 수 있으며 비즈니스 규칙에만 집중할 수 있습니다. 검증 로직은 중복되지 않을 것이며, 검증 대상의 데이터를 갖고 있는 클래스에 함께 존재하기에 바람직하게 책임이 분배되었습니다.
물론 단점도 있어요
이 방법은 각각의 레이어마다 DTO를 두기 때문에 시스템 내에서 관리해야하는 클래스의 수가 부쩍 늘어나게 됩니다. 지금 예시에는 createArticle이라는 메서드 하나만 다루었지만, 클래스의 수는 서비스 메서드의 개수만큼 발생할 것입니다. 이는 복잡도를 높일 수도 있습니다만, 계층의 분리와 아키텍처를 위해서는 감수할 수 있는 일이라는 생각이 듦니다.
또한 이제는 컨트롤러에서도 웹 DTO와 서비스 DTO간 변환을 해주는 로직이 필요하기 때문에, 어쩌면 한 줄로 끝날 수 있었던 컨트롤러의 코드가 늘어나게 되는 문제가 생깁니다.
마무리
3년 전에 객체지향 프로그래밍 강의를 들었을 때의 교수님의 말씀이 떠오릅니다.
설계라는 것은 복잡함을 통해서 유연함을 추구하는 행위이다.
복잡한 코드는 유연하고, 단순하고 보기좋은 코드는 변경이 어렵기에 코드에 정답은 없다는 생각이 듭니다. 서비스 레이어에 DTO를 두는 것 역시 정답은 아닙니다. 그럼에도 이러한 논의 혹은 문제점을 모르는 상태로 코드를 작성하는 것과, 알고 있음에도 동일한 코드를 작성하는 것은 분명한 차이가 있다고 생각합니다. 아무쪼록 도움이 되었으면 좋겠습니다.