국민학교 프로젝트를 리팩토링하면서 DTO를 Entity로 변환하는 최적의 위치에 대한 고민이 들었습니다. 이를 해결하기 위해, 엔티티를 서비스 계층에서만 사용하는 것과 모든 계층에서 사용하는 것의 차이를 고려해야 했습니다.
코드를 작성하다 보면 DTO(Data Transfer Object)를 어디에서 어떻게 사용할지에 대한 고민이 자주 생깁니다. 특히, 이러한 고민은 엔티티를 어느 계층까지 허용할지, 그리고 DTO와 엔티티 간 변환을 어디에서 수행하는 것이 가장 적절한지에 대한 의문으로 이어집니다. 그중에서도 엔티티 내부에서 DTO를 사용하는 것이 괜찮을지에 대한 의문은 많은 개발자들이 한 번쯤 겪는 고민일 것입니다.
엔티티(Entity)는 비즈니스 로직을 포함하는 핵심 도메인 객체로, 데이터베이스의 테이블과 1:1로 매핑되는 경우가 많습니다. 그런데 엔티티를 그대로 외부로 반환하면 여러 가지 문제가 발생할 수 있습니다.
모든 속성이 노출됨
엔티티에는 도메인에서 관리해야 하는 다양한 속성이 포함되어 있습니다. 하지만 이 모든 속성이 외부에서 필요한 것은 아닙니다. 예를 들어, 사용자의 비밀번호 같은 민감한 정보도 포함될 수 있는데, 이를 그대로 노출하면 보안 문제가 발생할 수 있습니다.
불필요한 데이터까지 전송됨
클라이언트(프론트엔드)에서는 특정 필드만 필요할 수도 있지만, 엔티티를 그대로 반환하면 불필요한 데이터까지 함께 전송됩니다. 이는 네트워크 비용 증가로 이어질 수 있고, 성능 최적화에 악영향을 미칠 수 있습니다.
도메인 로직이 변경될 위험
클라이언트의 요구사항에 따라 반환되는 데이터 구조가 달라질 수도 있습니다. 이때, 엔티티를 직접 사용하면 엔티티의 구조를 변경해야 할 수도 있는데, 이는 비즈니스 로직과 밀접하게 연관된 엔티티의 안정성을 해칠 수 있습니다.
이러한 문제를 해결하기 위해 DTO를 활용하는 것이 일반적입니다. DTO는 데이터를 전송하기 위한 객체로, 필요한 정보만 캡슐화하여 제공할 수 있습니다.
불필요한 데이터 제거
DTO는 필요한 데이터만 포함하도록 설계할 수 있습니다. 예를 들어, 사용자의 프로필 정보를 조회할 때 비밀번호는 제외하고, 이름과 이메일만 포함하는 DTO를 만들 수 있습니다.
도메인 로직 보호
엔티티는 서비스 내부에서만 사용되고, 외부에는 DTO를 통해 데이터를 제공하면 도메인 로직이 보호됩니다. 클라이언트의 요구사항이 바뀌더라도 DTO만 수정하면 되므로 엔티티의 구조를 유지할 수 있습니다.
보안 강화
중요한 필드를 숨기거나 변환하여 제공할 수 있습니다. 예를 들어, 이메일 주소를 마스킹 처리하거나, 특정 필드명을 변경하는 등의 처리를 DTO에서 수행할 수 있습니다.
DTO를 엔티티 내부에서 사용하는 것이 괜찮을까? 이를 고민하게 된 이유는 주로 코드 구조와 유지보수성 때문입니다.
보통, DTO가 도메인에 직접 의존하지 않도록 하기 위해 서비스 레이어에서 DTO를 엔티티로 변환하는 방식을 선호합니다. 그래서 DTO 내부에 toEntity() 메서드를 통해 엔티티로 변환하는 방식을 사용합니다.
왜 DTO에서 toEntity()를 제공하는가?
코드 가독성과 일관성 유지
DTO 내부에서 toEntity() 메서드를 제공하면, DTO에서 엔티티로 변환하는 로직을 한 곳에 모을 수 있어 코드의 일관성을 유지할 수 있습니다.
서비스 로직 단순화
서비스 레이어에서 DTO를 일일이 엔티티로 변환하는 대신, DTO 자체에서 변환할 수 있도록 하면 서비스 코드가 더욱 깔끔해집니다.
DTO의 역할 분명히 하기
DTO는 데이터를 전달하는 역할을 하면서도, 이를 엔티티로 변환하는 책임을 가질 수 있습니다. 이를 통해 불필요한 변환 로직이 서비스 레이어에 중복되는 것을 방지할 수 있습니다.
@Getter
@NoArgsConstructor
public class UserRequestDto {
private String name;
private String email;
@Builder
public UserRequestDto(String name, String email) {
this.name = name;
this.email = email;
}
public User toEntity() {
return User.builder()
.name(this.name)
.email(this.email)
.build();
}
}
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Long saveUser(UserRequestDto userRequestDto) {
User user = userRequestDto.toEntity();
return userRepository.save(user).getId();
}
}
유지보수성을 높이기 위해
DTO가 엔티티 내부에서 사용되면, 도메인 객체가 DTO에 의존하게 되는 구조가 될 수도 있습니다. 이는 도메인의 독립성을 해치며, 변경이 필요할 때 여러 부분을 수정해야 하는 문제를 초래할 수 있습니다.
확장성을 고려해서
DTO의 요구사항은 클라이언트의 요청에 따라 달라질 수 있습니다. DTO가 엔티티 내부에서 사용되면, 이를 수정하는 과정에서 엔티티에도 영향을 미칠 가능성이 있습니다. 하지만 서비스 레이어에서 변환하면, 엔티티는 그대로 유지하면서 DTO만 수정하면 되므로 유연성이 높아집니다.
한 곳에서 변경되면 다른 곳에서 터지는 문제를 방지하기 위해
만약 엔티티 내부에서 DTO를 사용하면, 엔티티가 변경될 때 DTO도 함께 수정해야 할 가능성이 높아집니다. 반대로, DTO가 변경되었을 때 엔티티도 영향을 받을 수 있습니다. 이로 인해 코드 수정 시 예상하지 못한 버그가 발생할 위험이 큽니다.
나는 보통 서비스 레이어에서 DTO를 변환하는 방식을 선호하여, 응답 DTO를 서비스에서 변환해서 보냅니다.
하지만 서비스는 비즈니스 로직을 처리하는 역할과 책임을 가지며,
컨트롤러는 클라이언트의 요청을 받고 응답을 반환하는 역할을 수행합니다.
이 점을 고려했을 때, 응답을 컨트롤러에서 가공하는 것이 더 적절하지 않을까?라는 생각이 들었습니다.
서비스 레이어에서 응답 DTO 변환
✅ 장점
❌ 단점
컨트롤러에서 응답 DTO 변환
서비스는 비즈니스 로직을 담당하고, 컨트롤러는 요청을 처리하고 응답을 반환하는 역할을 수행합니다.
✅ 장점
❌ 단점
DTO 변환을 서비스 레이어에서 처리하는 방식이 시스템을 더 깔끔하고 확장 가능하게 만든다고 판단했습니다. 만약 컨트롤러에서 변환을 처리하면, 컨트롤러가 변환과 관련된 책임을 가지게 되고 컨트롤러의 코드가 길어질 수 있습니다. 반면, 서비스 레이어에서 변환을 처리하면 형식 변경 시 서비스 레이어만 수정하면 되므로, 컨트롤러와의 의존성을 최소화할 수 있어 서비스단에서 엔티티와 응답 DTO로 변환하는 방식으로 결정했습니다.