모든 글

계층마다 DTO를 습관처럼 만들지 마세요

데이터가 서로 다른 계약을 가진 경계를 넘을 때 DTO를 사용합니다. 매핑에도 비용이 들지만 외부 요청 형태가 서비스 내부를 지배하게 두는 데도 비용이 듭니다.

  • backend
  • architecture
  • api-design

출처 및 AI 안내: 이 글은 제미니의 개발실무 유튜브를 기반으로 작성되었습니다. gpt-5.6-sol 모델을 사용해 생성·편집했습니다.

프레젠테이션 계층에서 HTTP 요청을 받고, 서비스 계층용 DTO로 다시 매핑하고, 처리 결과를 응답 DTO로 또 바꾸는 팀이 있습니다. 필드가 많아지면 이 매핑이 전부 낭비가 아닌지 의문이 생깁니다.

낭비일 수 있습니다. 쓸모 있는 경계를 지키는 비용일 수도 있습니다. 클래스 이름만 보고는 어느 쪽인지 알 수 없습니다.

저는 보통 외부 요청과 응답을 애플리케이션 내부의 중요한 객체와 나눕니다. 컨트롤러는 API 계약에 맞는 요청을 받고, 내부 개념이나 커맨드로 변환해 유즈케이스를 호출합니다. 결과는 다시 응답 객체로 바꿉니다. 내부 객체가 비즈니스 계층과 구현 계층이 함께 사용하는 실제 개념이라면 두 계층을 오갈 수 있습니다.

다만 데이터가 프레젠테이션 패키지에서 서비스 패키지로 넘어간다는 이유만으로 DTO를 하나 더 만들지는 않습니다. 경계라면 의미, 소유권, 변경 주기의 차이를 설명해야 합니다. “중간에 계층이 하나 더 있다”는 이유만으로는 부족합니다.

외부 계약은 외부 사정으로 바뀝니다

요청 모델은 API 경계의 소유물입니다. 파싱해야 할 문자열, 선택 필드, 전송 방식에 따른 기본값, 검증 애너테이션, 클라이언트 호환성을 위해 고른 이름이 들어갈 수 있습니다. 다음 API 버전에서 비즈니스 개념은 그대로인데 필드 이름이 바뀌거나 두 값이 하나로 합쳐질 수도 있습니다.

응답 모델도 별도의 이유로 바뀝니다. 내부 필드를 숨기고, 날짜 형식을 바꾸고, 중첩 객체를 평평하게 만들고, 링크를 붙일 수 있습니다. 모바일과 어드민 클라이언트에 같은 도메인 결과를 다르게 보여줄 수도 있습니다. 내부 도메인 객체를 그대로 반환하면 서비스 안쪽이 이런 표현 요구에 끌려갑니다.

그래서 바깥 경계의 매핑은 대개 가치가 있습니다.

HTTP 요청 -> 애플리케이션 개념 -> 유즈케이스
유즈케이스 결과 -> HTTP 응답

AddUserRequestAddUserCommand를 만드는 변환 메서드를 제공할 수 있습니다. QnaResponse가 Q&A 유즈케이스의 결과로부터 자신을 만들 수도 있습니다. 팩터리 메서드를 정확히 어디에 둘지는 팀이 고르면 됩니다. 전환이 눈에 보이고 테스트할 수 있다는 점이 중요합니다.

매핑 코드는 반복처럼 보일 수 있지만 API를 바꿀 때 같은 형태를 내부 모든 계층에 강요하지 않아도 되는 자유를 줍니다. 명시적인 응답 모델과 직렬화 설정으로 노출할 필드를 선택하면 내부 직렬화와의 우연한 결합도 줄일 수 있습니다.

DTO가 많다고 격리가 강해지지는 않습니다

이번에는 중간 DTO가 하나 더 있는 흐름을 생각해 봅시다.

HTTP 요청 -> 프레젠테이션-서비스 DTO -> 애플리케이션 커맨드

중간 DTO가 양옆 객체와 필드도 같고, 검증 전제도 같고, 생명주기도 같다면 무슨 문제를 푸는지 묻겠습니다. 작은 예제에서는 보이지 않는 팀의 좋은 이유가 있을 수 있습니다. 그 이유가 없다면 이름, 매핑, 테스트, 파일 이동만 늘고 실제 경계는 생기지 않습니다.

쓸모 있는 DTO는 보통 서로 다른 변화 원인으로부터 한쪽을 보호합니다.

  • HTTP 요청은 호환성을 위해 예전 필드명을 받지만 애플리케이션 커맨드는 현재 비즈니스 용어를 씁니다.
  • 엔드포인트에서는 문자열을 받지만 내부 커맨드는 파싱이 끝난 값 객체를 요구합니다.
  • HTTP와 메시지 컨슈머가 서로 다른 전송 모델에서 같은 애플리케이션 커맨드를 만듭니다.
  • 응답은 내부 결과 중 외부에 허용한 일부만 보여줍니다.

반대로 아키텍처 그림에 상자가 세 개 있다는 이유로 id, name, imageUrl을 똑같은 클래스 세 개에 복사하는 일은 더 강한 설계를 만들지 않습니다. 목적을 설명할 수 없는 변환 작업만 만듭니다.

매핑이 귀찮다고 경계까지 없애지 않습니다

반대 방향의 실패도 있습니다. 반복되는 매핑을 보고 요청 DTO를 리포지토리까지 넘기거나 도메인 객체를 그대로 응답으로 직렬화합니다. 코드는 짧아지지만 이제 외부 계약이 내부 모델을 지배합니다.

매핑이 유난히 무의미해 보인다면 내부 개념이 약하다는 신호일 수 있습니다. 모든 객체가 필드 묶음에 불과하면 변환도 복사로만 보입니다. 경계를 전부 없애기 전에 서비스가 실제로 무엇을 중요하게 보는지 물어야 합니다. 이미지 URL은 비즈니스 안에서도 임의 문자열일까요? 사용자 등록 요청과 등록된 사용자는 같은 개념일까요? 수정 요청은 완전한 객체일까요, 행위자가 바꿀 수 있는 값만 담은 명령일까요?

의미가 분명해지면 매핑은 의식적인 복사가 아니라 번역이 됩니다.

그렇다고 기능도 만들기 전에 완벽한 개념을 찾느라 며칠을 써서는 안 됩니다. 프로젝트 초기에는 클래스 하나로 충분할 수 있습니다. 정책이 생기고 외부 계약이 내부와 다른 이유로 변하기 시작하면 그때 나눕니다. 관찰한 차이에서 아키텍처를 진화시킬 수 있습니다.

변경 이유로 판단합니다

DTO를 추가하거나 없애기 전에 다음을 묻습니다.

  • 이 형태는 외부 클라이언트, 애플리케이션 유즈케이스, 내부 도메인 개념 중 누가 소유하는가?
  • 어떤 사건이 생기면 바뀌는가?
  • 경계에서 멈춰야 할 전송 기술 세부사항이 들어 있는가?
  • 다른 진입점이 다른 외부 형태로 같은 내부 개념을 사용할 수 있는가?
  • 응답이 호출자에게 보여줘야 할 범위보다 많은 것을 노출하는가?
  • 추가 클래스가 의미를 번역하는가, 규칙을 지키려고 필드만 복사하는가?

팀은 저보다 엄격한 규칙을 고를 수 있습니다. 여러 개발자가 같은 코드베이스에서 일할 때 일관성도 가치가 있습니다. 그래도 규칙의 이점은 설명할 수 있어야 합니다. 중간 DTO가 왜 있는지 아무도 설명하지 못한다면 “팀에서 합의했다”는 말은 질문을 보존할 뿐입니다.

저는 프레젠테이션 경계에 명시적인 요청과 응답을 두고, 의미 있는 내부 객체로 변환하며, 애플리케이션에 필요한 만큼만 추가 형태를 만드는 방식을 선호합니다. 매핑은 공짜가 아닙니다. 서비스 전체를 외부 계약에 결합하는 일도 공짜가 아닙니다. 양쪽 책임이 실제로 다를 때 경계의 비용을 지불하면 됩니다.