Entity와 DTO의 분리: 설계의 본질에 대하여
LABS
Vigfoot
Keyword
#convert-type#Architecture#backend
115.95.*.*
# Entity와 DTO의 분리: 설계의 본질에 대하여
**Entity** (엔티티)를 **Presentation Layer** (프레젠테이션 계층)까지 그대로 노출하는 것은 위험하다. 이는 단순한 코딩 스타일의 문제가 아니라 소프트웨어의 **Sustainability** (지속 가능성)를 결정짓는 구조적인 설계의 문제다.
---
## 사전 정리
### Entity
정의: 데이터베이스의 테이블과 1:1로 매핑되는 클래스
핵심: **식별자(ID)** 를 가진다. 데이터베이스에 저장되는 실제 데이터의 모델이며, 비즈니스 로직의 핵심을 담고 있다.
특징: 테이블 스키마가 변경되지 않는 한 최대한 수정하지 않는 것이 원칙
<br>
### DTO (Data Transfer Object)
정의: 계층(Layer) 간에 데이터를 전달하기 위해 사용하는 객체
핵심: 데이터의 **운반**이 목적 보통 로직을 가지지 않고 Getter/Setter만 가지는 순수한 데이터 컨테이너
특징: 클라이언트가 요구하는 데이터 형식에 맞춰 자유롭게 필드를 구성할 수 있음
<br>
### 번외) VO (Value Object)
정의: 값 그 자체를 나타내는 객체
핵심: **불변성**이 가장 중요함. (보통 Setter가 없고 생성자로만 값을 넣음)
특징: 내용물이 같으면 같은 객체로 취급
---
## 왜 Entity를 그대로 노출하는 것은 위험한가?
### 1. API 스펙의 강한 결합
**Entity**는 데이터베이스 스키마와 밀접하게 맞닿아 있다. 만약 **Entity**를 그대로 클라이언트에게 던지면, DB 컬럼 하나를 수정하는 순간 그 여파가 곧바로 사용자 화면까지 전파된다.
>> **Entity**를 직접 사용 시
>>
>> DB
>> ↓
>> Repository Layer
>> ↓
>> Service Layer
>> [**Entity**로 전달]
>> ↓
>> Controller Layer
>> ↓
>> View(Client)
> **예시:** 사용자 테이블의 `user_name` 컬럼을 `full_name`으로 변경한다고 가정하자. **Entity**를 직접 사용하고 있었다면, 이를 참조하는 모바일 앱이나 외부 API 서버는 즉시 에러를 뿜는다.
>> **Entity**를 **DTO**로 변환하여 사용 시
>>
>> DB
>> ↓
>> Repository Layer
>> ↓
>> Service Layer
>> [**Entity** -> **DTO**]
>> ↓
>> Controller Layer
>> ↓
>> View(Client)
> DTO(페이지 성격 혹은 요청 API규격에 맞는 DTO로 전달)
하지만 중간에 **DTO**를 두었다면, DB가 어떻게 변하든 **DTO**의 필드명만 유지해 주면 클라이언트는 아무런 영향 없이 서비스를 이용할 수 있다. 즉, **DTO**는 외부의 변화로부터 내부의 설계를 보호하는 **완충 지대** 역할을 한다.
<br>
### 2. 보안과 데이터 노출의 화이트리스트
##### 예시2-1) DB에 저장된 실제 Entity 데이터 (원본)
| id | email | password (hashed) | full_name | role | internal_memo | created_at |
| :--- | :--- | :--- | :--- | :---: | :--- | :--- |
| 104 | admin@vigfoot.com | `$2a$10$76...9kP` | 김철수 | ADMIN | "블랙리스트 관찰 대상" | 2026-02-10 14:00:00 |
**Entity**에는 비즈니스 로직상 필요하지만 외부에는 절대 노출해서는 안 되는 민감한 정보가 포함되기 마련이다. 비밀번호나 주민등록번호 같은 **Sensitive Data** (민감 데이터)는 물론이고, 생성일이나 수정일 같은 관리용 필드도 마찬가지다.
##### 예시2-2) 예시2-1을 그대로 직렬화 한 JSON
```json
{
"id": 104,
"email": "admin@vigfoot.com",
"password": "$2a$10$76bI7B0Z5pG9kPrL1v...",
"fullName": "김철수",
"role": "ADMIN",
"internalMemo": "이 사용자는 블랙리스트 관찰 대상임",
"createdAt": "2026-02-10T14:00:00"
}
```
만약 **Entity**를 그대로 **JSON**으로 직렬화하여 반환하면, 해커나 악의적인 사용자는 개발 도구만으로도 시스템의 내부 구조를 손쉽게 파악할 수 있다. **DTO**는 오직 화면에 필요한 정보만 선별하여 담는 **White-list** (화이트리스트)가 되어야 한다. **"보여줄 것만 보여준다"** 는 원칙은 보안의 시작이다.
<br>
### 3. 네트워크 비용과 오버페칭의 문제
실제 화면에서 필요한 데이터는 전체 데이터의 극히 일부인 경우가 많다. 테이블에는 50개의 컬럼이 정의되어 있어도, 목록 화면에서는 제목과 날짜만 있으면 충분하다.
##### 에시 3-1) DB에 저장된 실제 Entity 데이터중 일부
| 필드명 | 설명 | 데이터 크기 |
| :--- | :--- | :---: |
| `id` | 상품 고유 번호 | Small |
| `name` | 상품명 | Medium |
| `description_html` | **상세 설명 (수만 자의 HTML)** | **Extra Large** |
| `raw_log_data` | **내부 분석용 로그 (JSON)** | **Large** |
| `internal_admin_note` | 관리자용 메모 | Medium |
| `stock_history_blob` | 재고 변경 이력 (Binary) | Large |
| `created_at` / `updated_at` | 생성/수정일 | Small |
##### 예시3-2) Entity를 그대로 보낼 때
```json
// 전송되는 데이터 (Payload: 약 150KB)
{
"id": 2048,
"name": "프리미엄 기계식 키보드",
"price": 159000,
"descriptionHtml": "<html>... (수만 줄의 상세 코드) ...</html>",
"rawLogData": "{ 'click_stream': [...], 'user_behavior': [...] }",
"internalAdminNote": "재입고 예정 없음",
"stockHistoryBlob": "0x45AF...",
"updatedAt": "2026-02-12T10:00:00"
}
```
##### DTO로 필요 필드만 전송했을 때
```json
{
"id": 2048,
"name": "프리미엄 기계식 키보드",
"price": 159000
}
```
무거운 **Entity** 객체를 통째로 네트워크에 실어 보내는 것은 명백한 자원 낭비다. 특히 대규모 트래픽이 발생하는 환경에서 불필요한 필드 수십 개를 포함하는 행위는 **Network Payload** (네트워크 페이로드)를 가중시킨다. **DTO**를 통해 딱 필요한 만큼의 데이터만 정제해서 보내는 것이 개발자가 챙겨야 할 디테일이다.
<br>
### 4. 도메인 모델의 순수성 유지
UI 요구사항은 변화무쌍하다.
두 필드를 합쳐서 보여달라거나, 날짜 형식을 특정 포맷으로 바꿔달라는 요청이 쏟아진다.
이런 가공 로직이 **Entity**에 들어가기 시작하면,
비즈니스 규칙을 담아야 할 **Domain Model** (도메인 모델)은 금세 지저분해진다.
포맷팅이나 가공 로직은 오직 **DTO**의 영역이다.
**Entity**는 데이터베이스의 상태를 반영하는 순수한 객체로 남겨두고,
사용자가 보고 싶어 하는 모습으로의 변신은 **DTO**에게 맡겨야 한다.
<br>
### 5. 보편적인 Entity -> DTO 변환 방법
##### 5-1) 수동 매핑
```java
public UserResponse toDto(UserEntity entity) {
return new UserResponse(
entity.getEmail(),
entity.getFullName(),
entity.getRole()
);
}
```
> 위 처럼 생성자나 getter,setter를 이용해 일일이 값을 복사한다
> **장점**
1. 라이브러리 의존성이 없고 성능이 가장 빠름.
> **단점**
1. 필드가 많아지면 코드가 지저분해지고, 필드 하나 추가될 때마다 수정 누락 위험이 큼.
##### 5-2) BeanUtils (Runtime Copy)
```java
public UserResponse toDto(UserEntity entity) {
UserResponse dto = new UserResponse();
// 두 객체의 필드명이 일치하면 값을 자동으로 복사함
BeanUtils.copyProperties(entity, dto);
return dto;
}
```
> 위처럼 BeanUtils를 이용하여 값을 copy한다
> **장점**
1. 코드 한줄로 구현되기에 매우 간결함
2. Spring 프레임워크 사용자라면 추가 설정 없이 바로 사용 가능
> **단점**
1. 내부적으로 Reflection을 사용하기 때문에 직접 매핑하는 것보다 성능이 느림.
2. 불필요한 복제: DTO에 없는 필드라도 이름만 같으면 무조건 복사하려고 시도하므로, 의도치 않은 데이터 전달이 발생할 수 있음
##### 5-3) MapStruct
```gradle
implementation 'org.mapstruct:mapstruct:xxx'
annotationProcessor 'org.mapstruct:mapstruct-processor:xxx'
```
> 의존성 추가
```java
@Mapper(componentModel = "spring") // 스프링 빈으로 등록
public interface UserMapper {
// 싱글톤처럼 사용할 수 있게 인스턴스 제공
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
// 필드명이 같으면 자동으로 매핑, 다르면 @Mapping으로 지정 가능
@Mapping(target = "fullName", source = "userName")
UserResponse toDto(UserEntity entity);
}
```
> Mapper interface 정의
```java
public UserResponse getUser(Long id) {
UserEntity userEntity = userRepository.findById(id).orElseThrow();
// 주입받은 Mapper를 통해 변환 (컴파일 시점에 생성된 코드가 실행됨)
return userMapper.toDto(userEntity);
}
```
> service Layer logic
> **장점**
1. 컴파일 당시에 에러를 잡을 수 있다
> **단점**
1. 의존성 설정과 인터페이스 설정을 하기 때문에 결국 어디선가 추가로 설정을 하여야한다.
##### 5-4) JPQL 생성자 주입 (JPA)
```java
// DTO 클래스
public class UserResponse {
private String email;
private String fullName;
public UserResponse(String email, String fullName) {
this.email = email;
this.fullName = fullName;
}
}
// Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
@Query("SELECT new com.forestfull.dto.UserResponse(u.email, u.fullName) " +
"FROM UserEntity u WHERE u.id = :id")
UserResponse findUserResponseById(@Param("id") Long id);
}
```
> select절에 new와 패키지경로 + 클래스명을 적어 생성자 주입을 바로 받는다.
> **장점**
1. DTO로 직접 받으면 영속화 과정이 생략되어 가볍고 성능이 좋음
> **단점**
1. 문자열 기반이라 리팩토링시 패키지경로 등을 수정해주어야하여 유지보수 난이도 증가
2. 엔티티가 아니기 때문에 Lazy Loading사용이 불가능
##### 5-5) DTO 변환방법 정리
결국 성능을 챙기면 유지보수가 어렵고, 유지보수를 챙기면 설정이 복잡해진다.
설정은 없으면서 성능은 수동 매핑급이고, 코드 한 줄로 변환되는 방법은 없을까?
그래서 나는 토이프로젝트로 하나 만들어 보았다
[https://mvnrepository.com/artifact/com.forestfull/convert-type](https://mvnrepository.com/artifact/com.forestfull/convert-type)
```java
@Data
class EntityA {
private Long id;
private String username;
private String password;
private String displayName;
private String imageUrl;
private LocalDateTime createdAt;
}
class ExampleDTO {
private Long id;
private String displayName;
private String imageUrl;
}
```
> Entity와 도메인에 사용할 DTO필드가 다음과 같을 때
```java
public ExampleDTO test(Long id) {
final EntityA entity = testRepository.findByid(id);
return ConvertType.from(entity).to(ExampleDTO.class);
}
```
> 이렇게 정적메서드로 호출하여 내부에서 해당 DTO 기본생성자를 생성하고 필드를 리플렉션으로 주입하는 식으로 구현해보았다.
(그 외 null값 덮어쓰기 기능 등이 있지만 생략.. 관심있으시면 보세요..)
> **장점**
1. ConvertType.from(**대상 entity**).to(**타겟클래스**) 만 적으면 되는 간편한 설정
2. DTO에 필드별 매핑 무시 별도 필드명 설정가능
> **단점**
1. BeanUtils와 같이 리플렉션방식도 Jackson라이브러리와 같이 사용하기 때문에 퍼포먼스가 낮을 수가 있음.
> **추천 프로젝트**
1. 트래픽이 적은 소규모 프로젝트에는 적합할 것으로 보여짐
---
## 결론: 지속 가능한 개발을 위한 선택
당장 클래스 하나를 더 만들고 변환 로직을 짜는 것은 귀찮은 일일 수 있다. 하지만 시스템이 커지고 변화의 속도가 빨라질수록, 이 작은 분리가 가져다주는 안정성은 그 어떤 최적화 기법보다 강력하다.
결국 **Entity**는 저장소의 언어이고, **DTO**는 사용자의 언어라는 사실을 명심해야 한다.
하지만, 이 말을 전부 뒤집을 수 있는 요인이 하나 있다.
현실부합성이다.
현재 환경에서 분리해서 구현해야할 필요성이 없는 소규모 프로젝트라면 사실 계층 분리는 보류해두고 서비스제공을 더 우선시 하는게 나는 중요하다가 생각한다.
긴글 읽어봐주셔서 감사합니다