DTO
처음 상품 관리 애플리케이션을 만들 때는 Controller와 Repository 모두에서
Product 클래스를 그대로 사용해도 크게 문제 없어 보일 수 있다.
하지만 애플리케이션이 조금만 커지면 이 구조의 한계가 드러난다.
가장 큰 문제는 외부와 내부가 너무 강하게 연결된다는 점이다.
컨트롤러는 애플리케이션의 외부 접점이다.
즉, 브라우저나 Postman 같은 클라이언트가 직접 데이터를 주고받는 곳이다.
그런데 컨트롤러가 내부 도메인 객체인 Product를 그대로 사용하고 있으면,
내부 구조의 변화가 외부에 바로 노출될 수 있다.
예를 들어 Product 클래스에 필드가 추가되거나 이름이 바뀌면,
그 영향이 곧바로 API 응답 형식이나 요청 형식에도 반영될 수 있다.
즉, 내부 구조를 조금 바꾸는 것만으로도 외부 사용자와 약속한 데이터 형식이 깨질 수 있는 것이다.
반대로 외부 요구가 내부 구조를 억지로 바꾸게 만들 수도 있다.
예를 들어 같은 “상품”이라는 존재를 다룬다고 해도, 어떤 클라이언트는 가격과 이름만 필요하고,
어떤 클라이언트는 재고와 상품 번호까지 모두 필요할 수 있다.
만약 이런 요구를 모두 Product 하나로 해결하려고 하면,
외부 응답을 맞추기 위해 도메인 객체에 불필요한 필드나 구조를 계속 추가하게 될 수 있다.
이 역시 바람직하지 않다.
그래서 등장하는 것이 DTO(Data Transfer Object) 다.
DTO는 말 그대로 데이터를 전달하기 위한 객체다.
도메인 객체와 비슷하게 생겼을 수는 있지만, 역할은 다르다.
도메인 객체가 애플리케이션 내부에서 핵심 개념을 표현하는 클래스라면,
DTO는 외부와 데이터를 주고받기 위해 사용하는 별도의 객체다.
즉, DTO는 내부와 외부 사이에 위치하는 완충지대라고 이해하면 된다.
DTO 도입하기
DTO를 실제 코드에 적용할 때 중요한 원칙은, 외부와 맞닿는 곳에서는 DTO를 사용하고,
내부 저장이나 핵심 로직에서는 도메인 객체를 사용한다는 것이다.
이번 예제에서는 Controller 쪽의 Product를 ProductDto로 바꾸는 방향으로 이 원칙을 적용할 수 있다.
우선 ProductDto 클래스를 만들고, 기존 Product에 있던 필드 구조를 비슷하게 가져온다.
public class ProductDto {
private Long id;
private String name;
private Integer price;
private Integer amount;
public Long getId() {
return id;
}
public Integer getAmount() {
return amount;
}
public String getName() {
return name;
}
public Integer getPrice() {
return price;
}
}
이렇게 하면 클라이언트는 더 이상 Product가 아니라 ProductDto를 통해 데이터를 주고받게 된다.
이 시점부터 컨트롤러는 외부 요청을 ProductDto로 받고, 응답도 ProductDto로 돌려주게 된다.
즉, 흐름은 이렇게 된다.
① 외부 요청이 컨트롤러에 들어온다.
② 컨트롤러는 ProductDto를 서비스에 전달한다.
③ 서비스는 ProductDto를 Product로 변환한다.
④ 레포지토리는 Product를 받아 저장한다.
⑤ 저장 결과로 나온 Product를 다시 ProductDto로 변환한다.
⑥ 컨트롤러는 최종적으로 ProductDto를 응답으로 반환한다.
이 구조를 취하면, 애플리케이션의 안쪽에서는 여전히 Product를 중심으로 로직을 유지할 수 있고,
바깥쪽에서는 외부 요구에 맞는 DTO 형식을 자유롭게 설계할 수 있다.
DTO는 표현 계층에 두어도 되고, 응용 계층 쪽에 두어도 되며, 별도의 dto 패키지를 따로 만들어 관리해도 된다.
중요한 것은 패키지 위치 자체보다, DTO의 역할이 외부와 내부 사이의 데이터 전달을 담당한다는 점이다.
ModelMapper 사용하기
DTO를 도입하면 자연스럽게 생기는 작업이 하나 있다. 바로 객체 변환이다.
요청으로 받은 ProductDto를 Product로 바꿔야 하고, 저장 후에는 다시 Product를 ProductDto로 바꿔야 한다.
필드가 몇 개 없을 때는 직접 하나씩 옮겨도 되지만, 클래스가 많아지고 필드가 늘어나면
이런 변환 코드가 반복되고 지저분해질 수 있다.
이때 사용할 수 있는 대표적인 라이브러리가 ModelMapper다.
ModelMapper는 이름처럼 한 객체의 필드 값을 다른 객체로 매핑해주는 도구다.
필드 이름과 타입이 비슷하다면 비교적 간단하게 객체를 변환할 수 있다.
먼저 Maven 의존성을 추가한다.
(Maven 프로젝트 기준)
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.1.0</version>
</dependency>
그리고 ModelMapper를 매번 새로 생성해서 쓰기보다, 스프링 빈으로 등록해두고 재사용하는 것이 좋다.
객체를 매번 생성하는 비용을 줄일 수 있고, 설정도 한 번만 해두면 되기 때문이다.
@Bean
public ModelMapper modelMapper(){
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setFieldAccessLevel(Configuration.AccessLevel.PRIVATE)
.setFieldMatchingEnabled(true);
return modelMapper;
}
여기서 설정을 추가하는 이유는 Product와 ProductDto의 필드가 모두 private으로 선언되어 있기 때문이다.
기본적으로는 getter/setter를 통해 접근하는 방식이 일반적이지만,
위 설정을 주면 private 필드에도 접근해서 값을 읽고 쓸 수 있게 된다.
이 과정에는 자바의 리플렉션(Reflection) 이라는 기능이 활용된다.
리플렉션은 런타임에 클래스의 필드나 메서드 정보를 읽고 접근할 수 있게 해주는 자바의 기능이다.
이제 서비스(Service)에서는 이렇게 사용할 수 있다.
@Service
public class SimpleProductService {
private ListProductRepository listProductRepository;
private ModelMapper modelMapper;
@Autowired
SimpleProductService(ListProductRepository listProductRepository, ModelMapper modelMapper){
this.listProductRepository = listProductRepository;
this.modelMapper = modelMapper;
}
public ProductDto add(ProductDto productDto){
Product product = modelMapper.map(productDto, Product.class);
Product savedProduct = listProductRepository.add(product);
ProductDto savedProductDto = modelMapper.map(savedProduct, ProductDto.class);
return savedProductDto;
}
}
이 코드를 보면 서비스는 외부에서 받은 ProductDto를 도메인 객체 Product로 바꾼 뒤 저장하고,
저장된 결과를 다시 ProductDto로 변환해서 반환한다.
getter와 setter
자바를 처음 배울 때는 보통 필드를 private으로 숨기고, getter와 setter를 만들어 접근하는 방식이 자주 소개된다.
그래서 많은 사람들이 “getter와 setter는 항상 만드는 것이 좋은 것”이라고 생각하기 쉽다.
물론 이 방식은 객체의 필드를 직접 public으로 노출하는 것보다는 훨씬 낫다.
하지만 그렇다고 해서 getter와 setter를 무조건 많이 만드는 것이 항상 좋은 설계는 아니다.
이유는 getter와 setter도 결국 필드를 외부에 노출하는 또 다른 방법이기 때문이다.
예를 들어 모든 필드에 대해 아무 의미 없는 getter와 setter를 기계적으로 만들어버리면,
외부 코드가 그 객체의 내부 상태를 쉽게 읽고 마음대로 바꿀 수 있게 된다.
이렇게 되면 객체가 자기 상태를 스스로 통제하지 못하고, 단순히 데이터를 담아두는 그릇처럼 변해버릴 수 있다.
이 점에서 캡슐화가 약해질 수 있다.
또한 객체와 상호작용할 때는 단순히 setPrice(1000)처럼 값만 바꾸는 메서드보다,
의도가 드러나는 메서드 이름을 사용하는 것이 더 좋은 경우가 많다.
예를 들어 “재고를 3개 줄인다”는 행위를 표현하고 싶다면 setAmount()로 값을 덮어쓰기보다,
decreaseStock(3)처럼 의미가 드러나는 메서드가 훨씬 읽기 쉽고 안전하다.
이렇게 하면 객체가 단순히 값 저장소가 아니라, 자기 규칙과 행위를 가진 존재로 보이게 된다.
물론 처음 자바에 익숙해지는 단계에서는 getter와 setter를 활용해 구조를 이해하는 것이 도움이 될 수 있다.
이번 예제처럼 JSON 직렬화나 간단한 데이터 이동을 위해 getter가 필요한 상황도 있다.
하지만 장기적으로는 “필드를 그냥 꺼내고 넣는 방식”에 익숙해지기보다는,
객체가 의미 있는 메서드로 자기 상태를 표현하고 변경하도록 설계하는 방향을 지향하는 것이 좋다.
'공부 기록 > 이것이 백엔드 개발이다 With Java' 카테고리의 다른 글
| EP17. List를 이용한 상품 관리 애플리케이션 만들기 5편 (0) | 2026.04.10 |
|---|---|
| EP16. List를 이용한 상품 관리 애플리케이션 만들기 4편 (0) | 2026.04.10 |
| EP14. List를 이용한 상품 관리 애플리케이션 만들기 2편 (0) | 2026.04.08 |
| EP13. List를 이용한 상품 관리 애플리케이션 만들기 1편 (0) | 2026.04.07 |
| EP12. HTTP 요청과 응답 (0) | 2026.04.04 |