본문 바로가기
Spring

DTO의 생성 위치(with 장점)

by 고선제 2025. 1. 19.

🌟 글의 취지

먼저, 이 글은 자바, 스프링을 공부하면서 고민한 내용이다.

자바와 스프링을 공부하는 사람이라면, 한번쯤은 DTO를 패키지의 어느 위치에 생성해야 하는지 고민해 본 경험이 있을 것이다. 프로젝트를 하면서 나 또한 정말 많이 고민하여 내 생각을 글로 표현하고자 이 글을 작성한다.

정말 정답이 없는 내용이고 온전히 나의 생각이니 이 글을 읽는다면, 참고만 하는 것을 추천한다!

 

❓ DTO란?

이 글을 읽는 사람은 DTO의 정의를 알고있겠지만, 내가 생각하는 DTO와 다를 수 있으니 이 글의 흐름 이해를 돕기 위해 DTO를 설명한다.

DTO는 Data Transfer Object로 번역하면 데이터를 이동시키는 객체이다. 하지만 데이터를 이동할 때마다 DTO를 사용하지 않고, 주로 계층 간 데이터를 주고받을 경우 사용한다. 여기서 계층이라 함은 각자의 책임에 맞게 역할을 분리한 구조를 말한다. 흔히 알고 있는 클라이언트와 상호작용하는 Presentation Layer, 우리의 도메인, 비즈니스 로직을 구현하는 Business Layer, 데이터베이스에 접근하는 Data Access Layer 가 있다.

 

 

DTO의 주요 목적과 역할이 데이터를 전달해주는 것일 수 있지만, 코드를 보기도 하고 작성도 하는 우리에게 간접적으로 여러 이점을 준다.

 

이점 1.

우리가 전달하고 싶은 데이터가 어떤 의미를 가지는지 DTO의 이름을 통해 명확하게 알 수 있다.

// DTO를 사용하지 않은 경우
update(1, "고선제", 25, 60201659)

// DTO를 사용한 경우 - 1번째
update(new UpdateStudentRequest(1, "고선제", 25, 60201659));

// DTO를 사용한 경우 - 2번째
update(1, new UpdateStudentInfo("고선제", 25, 60201659));

 

이점 2.

전달하고자 하는 데이터의 타입이 변하거나 데이터가 추가 혹은 삭제될 경우 기존 메소드를 변경하지 않아도 된다.

// DTO를 사용하지 않은 경우 데이터를 추가한다면?

//변경 전
public void update(Long id, String name, int age) {

}
//변경 후
public void update(Long id, String name, int age, String studentNumber) {

}

// DTO를 사용할 경우 데이터를 추가한다면?

//변경 전
public void update(UpdateStudentRequest request) { 

}
//변경 후
public void update(UpdateStudentRequest request) { //DTO 내부 필드 추가

}

→ 즉, 코드 변경의 유연함이 증가한다.

메소드의 매개변수를 수정한다면, API 명세 혹은 비즈니스 로직과 관련된 코드와 직접적인 연관이 있는 메소드를 수정해야 할 경우가 생기지만, DTO를 사용한다면 DTO 내부의 변수만 수정하면 된다.

 

이점 3.

세 번째는 두 번째의 연장선인데, DTO는 클래스와 클래스간의 결합도가 낮춘다.

예를 들어, 흔히 우리가 사용하는 Controller 계층이 Service계층의 메소드를 작성한다고 생각해 보자.

// DTO를 사용하지 않는 경우
class Controller {

	private final Service service;
     
	public void update(Long id, String name, int age) {
		service.update(id, name, age);
	}
}

class Service {

	public void update(Long id, String name, int age) {
		// 구현
	}
}

// DTO를 사용하는 경우
class Controller {

	private final Service service;
     
	public void update(UpdateStudentRequest request) {
		service.update(request);
	}
}

class Service {

	public void update(UpdateStudentRequest request) {
		// 구현
	}
}

DTO를 사용하지 않는 상황일 때 Service의 update메소드의 매개변수가 변경된다면 Controller의 호출 인자도 함께 변경되어야 한다.

DTO를 사용하는 상황이라면, request의 내부 변수만 수정하면 되므로 결합도가 낮아진다.

이렇게 여러 가지 이점을 주고, 여기 적힌 것 이외에도 다양한 이점들이 있을 것이다.

 

이제 본론! ✍️ DTO의 위치는 어디에 생성해야 할까?

내가 고민한 끝에 내린 결론은 총 두 가지 경우가 있다.

 

첫 번째는 사용의 쓰임새가 분명하게 분리해 놓는 경우이다.

ㄴ controller
	ㄴ dto
ㄴ service
	ㄴ dto
  • 컨트롤러 내의 dto는 클라이언트에서 요청을 보낸 JSON 데이터를 객체의 형태로 변환하여 요청에 필요한 데이터를 받아오거나, 클라이언트에서 필요한 데이터를 객체의 형태에서 JSON 형식으로 변환할 때 응답에 필요한 데이터를 반환할 때 사용하는 경우이다.
  • 서비스 내의 dto는 비즈니스 로직을 수행하기 위한 데이터를 컨트롤러에서 받아올 때, 컨트롤러에서 요청한 비즈니스 로직을 수행한 결과 데이터를 컨트롤러에 넘겨줄 때 사용한다.

 

두 번째는 컨트롤러와 서비스에 같은 레벨에 생성함으로써 DTO를 공유하는 것이다.

ㄴ controller
ㄴ service
ㄴ dto
  • 위에서 설명한 컨트롤러와 서비스의 역할을 동시에 수행하는 DTO를 만드는 것이다.

위와 같은 구조들에는 각각 장점과 단점이 분명히 존재한다.

첫 번째 방법의 장점이 곧 두 번째 방법의 단점이고,

두 번째 방법의 장점이 곧 첫 번째 방법의 단점이다.

첫 번째 방법을 책임 분리 DTO, 두 번째 방법을 공유 DTO

 

👍 책임 분리 DTO 장점, 👎 공유 DTO  단점

  1. 역할 분리
    • 각 계층의 DTO가 분리되어 있어 컨트롤러용 DTO와 서비스용 DTO의 목적과 쓰임새가 명확해진다.
    • ex) swagger 사용
    • 공유 DTO일 경우
    // controller 및 Service DTO
    public record CreateFeedRequest(
        @Schema(description = "활동 내용", example = "저희의 활동 내용은 이것입니다.")
        @NotNull(message = "activityContent는 null이 될 수 없습니다.")
        String activityContent,
        @Schema(description = "이미지/비디오 식별자 id", example = "0192c828-ffce-7ee8-94a8-d9d4c8cdec00")
        @NotNull(message = "mediaId는 null이 될 수 없습니다.")
        String mediaId,
        @Schema(description = "컨텐츠 종류", example = "IMAGE")
        @NotNull(message = "contentType은 null이 될 수 없습니다.")
        String contentType
    ) {
    
    }
    
    • 책임 분리 DTO일 경우
    // controller DTO
    public record CreateFeedRequest(
        @Schema(description = "활동 내용", example = "저희의 활동 내용은 이것입니다.")
        @NotNull(message = "activityContent는 null이 될 수 없습니다.")
        String activityContent,
        @Schema(description = "이미지/비디오 식별자 id", example = "0192c828-ffce-7ee8-94a8-d9d4c8cdec00")
        @NotNull(message = "mediaId는 null이 될 수 없습니다.")
        String mediaId,
        @Schema(description = "컨텐츠 종류", example = "IMAGE")
        @NotNull(message = "contentType은 null이 될 수 없습니다.")
        String contentType
    ) {
    
    }
    
    // Service DTO
    public record CreateFeedCommand(
        String activityContent,
        String mediaId,
        String contentType,
    ) {
    
    }
    
    → swagger 사용 시 문서화에 용이하지만, 비즈니스 로직을 구현하는 서비스 계층에서는 불필요한 코드에 의존성이 생기는 것일 수 있다. 하지만 DTO를 분리한다면 각 계층의 쓰임새에 맞게 사용할 수 있다.

 

2.  API 명세 변경에 대한 유연성

  • 수시로 바뀌는 API 명세, 즉 클라이언트의 요청/응답의 형식이 바뀌어도 서비스 계층의 로직에 직접적인 영향을 주지 않는다.
  • DTO를 공유할 경우
// controller 및 Service DTO
public record CreateFeedRequest(
    String activityContent, //필드명이 content로 수정된다면?
    String mediaId,
    String contentType
) {

}

//어노테이션 생략
class Controller {
	
	private final Service servce;
		
	public void create(CreateServiceRequest request) {
		servce.create(request);
	}
}

class Service {

	public void create(CreateFeedRequest request) {
		String activityContent = request.activityContent(); //해당 코드도 content로 수정되어야 함
		String mediaId = request.mediaId();
		String contentType = request.contentType();

	}
}

→ 위 코드의 문제점을 알아보겠는가?

만약, API의 명세가 수정되어, activityContent를 content로 변수의 이름을 수정하게 된다면 service 내의 코드 또한 변경되어야 한다.

 

  • 하지만, 책임 분리 DTO는??
//controller DTO
public record CreateFeedRequest(
    String activityContent, //content로 수정된다면?
    String mediaId,
    String contentType
) {
		
	public CreateFeedCommand toCommand() {...}
}

//Service DTO
public record CreateFeedCommand(
    String activityContent, 
    String mediaId,
    String contentType
) {

}

//어노테이션 생략
class Controller {
	
	private final Service servce;
		
	public void create(CreateServiceRequest request) {
		servce.create(request.toCommand());
	}
}

class Service {

	public void create(CreateFeedCommand command) {
		String activityContent = command.activityContent(); //해당 코드는 변경되지 않아도 된다.
		String mediaId = command.mediaId();
		String contentType = command.contentType();

	}
}

→ API의 명세가 수정되어 activityContent → content로 수정되어도, Service 내의 코드는 변경하지 않아도 된다.

 

 

3.  각 계층별 결합도

  • 2번과 비슷하지만 조금 다른 장점이다.
  • 위 DTO의 장점에서 했던 얘기 중 3번과 같은 이야기이다.
  • Controller에서 Service로 데이터를 이동시킬 때 DTO만 이용하여 데이터를 전달하지 않고, 쿼리 파라미터로 받은 변수, PathVariable로 받은 변수도 데이터를 전달시킨다. 이때, 데이터의 변경은 Service 내 코드의 변경을 초래할 수 있다.
  • 다음 예를 살펴보자 (공유 DTO일 경우)
//어노테이션 생략
class Controller {

	private final Service servce;
    
	@GetMapping("/clubs/{clubId}/feeds")
	public FeedResponse getFeed(
		@PathVariable("clubId") Long clubId,
		@RequestParam int size,
		@RequestParam Long currentCursorId
		//파라미터가 추가된다면?
	) {
		//전달할 데이터가 추가된다면?
		//service의 getFeed()도 함께 변경되어야 함
		return service.getFeed(clubId, size, currentCursorId); 
	}
}

class Service {

	public FeedResponse getFeed(
		Long clubId,
		int size,
		Long currentCursorId
	){
		//구현
	}
}

→ 위와 같이 DTO와 PathVariable의 데이터를 함께 넘겨준다고 하자. 어떤 문제가 있어 보이는가?

쿼리 파라미터 데이터가 추가되어, 비즈니스 로직에 데이터를 추가해야 할 때 서비스 계층 코드까지 같이 변할 수 있다.

 

  • 하지만, 서비스 계층만을 위한 DTO를 생성한다면?? (책임 분리 DTO)
@RestController
@RequiredArgsConstructor
class Controller {

private final Service servce;
    
	@GetMapping(/clubs/{clubId}/feeds)
	public FeedResponse getFeed(
		@PathVariable("clubId") Long clubId,
		@RequestParam int size,
		@RequestParam Long currentCursorId
		//파라미터가 추가된다면?
	) {
		//전달할 데이터가 추가된다면?
		//서비스 DTO의 내부 필드만 변경되면 된다.
		return service.getFeed(new GetFeedCommand(clubId, size, currentCursorId); 
	}
}

class Service {

	public FeedResponse getFeed(GetFeedCommand command){
		//구현
	}
}

→ 쿼리 파라미터가 추가되어 전달할 데이터가 추가되어도 서비스의 코드는 변경되지 않는다.

즉, 컨트롤러 계층과 서비스 계층의 결합도가 낮아진다.

 

👎 책임 분리 DTO 단점,  👍 공유 DTO  장점

  1. 중복 코드
    • 이것이 치명적인 단점이자 장점이다.
    • 위에서 봤던 코드와 같이 중복되는 코드가 많다.
    • 예를 들어, 책임 분리 DTO일 경우
    // controller DTO
    public record CreateFeedRequest(
        @Schema(description = "활동 내용", example = "저희의 활동 내용은 이것입니다.")
        @NotNull(message = "activityContent는 null이 될 수 없습니다.")
        String activityContent,
        @Schema(description = "이미지/비디오 식별자 id", example = "0192c828-ffce-7ee8-94a8-d9d4c8cdec00")
        @NotNull(message = "mediaId는 null이 될 수 없습니다.")
        String mediaId,
        @Schema(description = "컨텐츠 종류", example = "IMAGE")
        @NotNull(message = "contentType은 null이 될 수 없습니다.")
        String contentType
    ) {
    
    }
    
    // Service DTO
    public record CreateFeedCommand(
        String activityContent,
        String mediaId,
        String contentType,
    ) {
    
    }
    
    → 똑같은 변수를 전달하는 데 두 개의 DTO를 생성하여 Swagger 문서 외 같은 코드를 작성하고 있다. 만약 Swagger를 사용하지 않는다면 완전히 같은 변수를 가진 DTO를 두 개 생성하는 것이다.

 

하지만, 하나의 DTO를 공유한다면 이런 중복은 없어지게 된다.

// controller 및 Service DTO
public record CreateFeedRequest(
    String activityContent,
    String mediaId,
    String contentType
) {

}

 

 

 

2.  관리 측면 복잡성

  • 1번으로부터 나온 장점이자 단점이다.
  • 중복 코드가 많아지게 되면서, 코드의 양이 증가되어 몸집이 두 배이상으로 불어나게 되고, 그만큼 관리해야 할 클래스의 수가 늘어난다.
  • 특히 수정 작업 시 복잡성이 증가한다
    • 새로운 필드가 추가되거나 기존 필드가 수정될 때, 컨트롤러의 DTO와 서비스의 DTO를 모두 수정해야 하는 경우가 많다.
    • 예를 들어, Controller DTO 필드가 추가된다면 거의 비즈니스 로직에서 해당 필드를 사용하기 때문에 Service DTO 필드도 같이 추가해야 한다는 것이다.

 

  • 책임 분리 DTO 사용 경우
// controller DTO 수정
public record CreateFeedRequest(
    @NotNull(message = "activityContent는 null이 될 수 없습니다.")
    @Size(max = 500, message = "activityContent는 최대 500자를 초과할 수 없습니다.")
    String activityContent,
    
    //필드 추가 및 변경
) {}

// service DTO 수정
public record CreateFeedCommand(
    String activityContent,
    
    //필드 추가 및 변경
) {}

 

  • 공유 DTO 사용 경우
public record CreateFeedRequest(
    @NotNull(message = "activityContent는 null이 될 수 없습니다.")
    @Size(max = 500, message = "activityContent는 최대 500자를 초과할 수 없습니다.")
    String activityContent,
    
    //필드 추가 및 변경
) {}

 

->  한 번 수정할 것, 두 번 수정해야 한다는 것이다!

 

♾️ 결론!

사실 항상 듣는 말이고, 항상 하는 말이지만 코드에는 정답이 없기에 프로젝트 내 팀원 간 합의를 보고 정하는 것이 좋다.

그러나 내 생각이지만 책임 분리 DTO 방법은 보통 규모가 큰 프로젝트일 경우 사용하는 것 같고 공유 DTO 방법은 비교적 간단한 프로젝트일 경우 사용하는 것 같다.

역시… 지긋지긋한 트레이드오프다

 

요약

  • DTO를 계층별 분리할 경우
    • 장점
      • 역할 분리가 확실하다.
      • API 명세 변경에 유연하다.
      • 계층별 결합도가 낮아진다.
    • 단점
      • 코드 중복이 심해진다.
      • 관리 측면에서 비용이 많이 든다.
  • DTO를 계층 간 공유할 경우
    • 장점
      • 코드가 간결하다
      • 관리 비용이 적어진다.
    • 단점
      • 역할 분리가 불분명하다
      • 서비스 계층이 API 명세 변경에 영향을 많이 받는다.
      • 계층별 결합도가 높아진다.

'Spring' 카테고리의 다른 글

Spring Security의 흐름과 개념 설명  (0) 2024.08.11
Layered Architecture  (0) 2023.10.05
HTTP요청부터 응답까지의 과정  (0) 2023.09.25
IoC 컨테이너  (0) 2023.09.20