# 차근차근 Modern Spring Boot 3 기초 (8) DTO와 Entity의 Mapping

# DTO와 Entity는 비슷해 보여도 역할이 다릅니다.

## DTO와 Entity의 쓰임 구분

우리는 앞서 용어를 불필요하게 넓은 의미로 사용하기보다는, 자주 사용되는 의미면서 권장하는 의미로 설명하려고 했습니다. 그때 DTO와 entity는 다음처럼 구분했습니다.

* DTO: 사용자(클라이언트)와 주고 받는 데이터입니다.
    
* Entity: 데이터베이스와 주고 받는 데이터 양식의 기준이 되는 형태고, DB 테이블에 매핑되는 필드 구조를 띱니다. (즉, 데이터베이스 테이블(또는 행)의 '스프링에서의 모습'처럼 생각할 수 있습니다.)
    

그림을 간소화하면 다음과 같습니다.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1723269346466/fc4fc1ce-1cc9-4544-912f-c1e277ed5c39.avif align="center")

그림을 조금 더 자세히 보면 이렇습니다. (Entity는 서비스와 persistence 레이어에서 사용)

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1723268451517/5510c2cc-65b1-4a96-916c-be7a7e295bb5.avif align="center")

DTO와 entity는 아무리 데이터 구조가 비슷해 보이더라도 위처럼 주로 사용되는 구간이나 역할이 서로 다르기 때문에 우리는 그것들을 잘 구분해 사용하는 것이 편리합니다.

(변환이 필요하다는 내용)

(여전히 entity를 관리하지 않는 회사들 중, DTO와 entity의 역할이 섞인 VO라는 것을 만들어서 사용하는 곳을 자주 볼 수 있습니다.)

# DTO를 Entity로 변환하기

DTO를 entity로 변환하는 데에는 여러 방식이 있습니다.

* DTO는 순수하게 데이터를 전달하는 역할을 해야 합니다.
    
* 사용하는 데에 있어 충분히 편리해야 합니다.
    

## 후보 1: DTO에 toEntity() 메서드

DTO 클래스에 인스턴스 메서드로 `toEntity()`를 만드는 것은 매우 흔한 선택 중 하나입니다. 필요한 역할을 충분히 수행하면서도, 설계에 큰 영향을 주지 않는 편으로 볼 수 있습니다.

**toEntity() 메서드**

```java
public record SampleDto(
        ... /* 필드 목록 */
) {
    public SampleEntity toEntity() {
        return /* 이곳에서 SampleEntity의 생성자 또는 빌더 등을 사용하여 인스턴스 생성 */;
    }
}
```

**사용**

```java
// DTO -> entity
SampleEntity entity = dto.toEntity();

// use entity
sampleRepository.save(entity);
```

* DTO는 순수하게 데이터를 전달하는 역할을 해야 합니다. (O)
    
    * **비판적 의견**  
        일부 사람들은 DTO가 toEntity() 등 변환 메서드를 포함하는 것을 원하지 않습니다. DTO는 데이터를 넣고 빼는 일 외에 다른 기능을 포함하지 않는 것이 좋기 때문입니다.
        
    * **긍정적 의견**  
        변환 메서드까지는 DTO가 데이터를 전달하는 수단의 하나로 볼 수도 있습니다.
        

단점이라고 할 정도는 아니지만, 작은 사이드 이펙트가 있습니다.

* DTO가 일부 entity에 종속되는 개념이 됩니다.
    

## 후보 2: Mapstruct (채택)

우리는 toEntity() 메서드 대신 매핑 라이브러리를 사용할 수 있습니다.

그중 MapStruct는 매핑 라이브러리 중 비교적 젊은 축에 속하는 기술입니다. 오랫동안 사용되던 매핑 라이브러리인 ModelMapper 대신 주목받고 채택되고 있는 기술입니다.

### 비교군 ModelMapper (런타임에 리플렉션)

오래 전부터 사용되던 ModelMapper는 리플렉션(reflection)이라는 기술을 사용합니다. 리플렉션은 클래스나 객체를 뜯어 자유롭게 관리할 수 있도록 돕는 기술이고, 라이브러리 제작에서 자주 활용합니다. 보통 사내 코드에서는 지양하고, 타인의 코드를 건들지 않고 다루고 싶을 때 하는 선택에 가깝습니다.

리플렉션은 클래스와 인스턴스를 자유롭게 조작할 수 있는 대신 다음과 같은 단점을 포함합니다. 대부분 런타임에 동적으로 동작하기 때문에 최적화와 진단 등과 관련해 발생하는 단점입니다. (컴파일 타임이나 에디터 작성 중 확인되지 않는 동작들을 런타임에 포함하기 때문입니다.)

* **성능 저하**
    
    * 런타임에 동적으로 동작하기 때문에, 일반적인 메서드 호출이나 필드 접근보다 느립니다.
        
* **디버깅이 어려움**
    
    * 런타임에 동적으로 동작하기 때문에 미리 체크되지 않은 오류가 발생할 수 있고, 가독성을 낮춰 디버깅하기 어렵습니다. (런타임에 발견되는 오류)
        
* **안정성 및 보안**
    
    * 런타임에 동적으로 동작하기 때문에 미리 체크되지 않은 동작이 추가되어, 코드의 보안 수준을 낮출 수 있습니다.
        
    * 리플렉션은 설계적인 안정성을 깨뜨리는 기술이기 때문에, 클래스의 설계자가 의도하지 않은 동작이 다른 작업자에 의해 추가될 수 있습니다. 이는 예기치 않은 취약점이 될 수 있습니다.
        

이중 라이브러리로 제공받는 경우, 리플렉션은 내부적으로만 사용되는 경우가 많기 때문에 성능 저하가 주된 고려 대상이 될 수 있습니다. (라이브러리 취약점은 리플렉션을 떠나서 주의할 항목)

### MapStruct의 장단점

MapStruct는 다음과 같은 장점이 있습니다.

* 충분한 자유도를 보장합니다.
    
* 사용이 쉽습니다.
    
* 컴파일타임에 미리 구현됩니다. (런타임의 안정성과 성능에 도움이 됩니다.)
    
* 복잡한 객체의 매핑도 우리가 원하는 대로 지시하기 쉽습니다.
    
* 여러 개발 문화권을 고려한 완성도로 굉장히 스마트한 코드 호환성을 갖고 있습니다.
    
* 다양한 환경과의 호환을 위해 다양한 매핑 전략을 고를 수 있습니다.
    

MapStruct는 ModelMapper에 비해 성능, 사용성, 자유도 등에서 만족도가 높습니다.

일반적으로 단점으로 언급될 만한 것을 추려 보면 다음과 같습니다.

* APT(Annotation Processor Tool) 기반입니다. (애노테이션을 처리하여 프리컴파일)
    
    * 일부 조직에서는 각자의 이유로 자바 스프링 프로젝트에서 APT에 의존하는 상황을 지양하며, 스프링의 철학에 일부 벗어난다고 볼 수도 있으며, 일부 마이그레이션에서 고려 사항이 됩니다.
        
* 여러 환경에 대한 호환성 처리 등 다양한 매핑 전략을 택해야 할 때 아직 러닝커브가 존재합니다.
    

### MapStruct 의존성 라이브러리 추가

`dependencies`에 다음 세 항목을 추가합니다.

```kotlin
dependencies {
    // MapStruct
    implementation("org.mapstruct:mapstruct:1.5.5.Final")
    annotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final")
    annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0")
}
```

마지막 항목은 Lombok에서 MapStruct와 프리컴파일의 동작 순서가 꼬이지 않도록 만들어 게시하여 둔 추가적인 애노테이션 프로세서입니다.

> **💡 org.mapstruct:mapstruct:1.5.5.Final**
> 
> MapStruct 사용을 위한 의존성 라이브러리입니다.

> **💡 org.mapstruct:mapstruct-processor:1.5.5.Final** (Annotation Processor)
> 
> 의존성 목록에 반드시 Annotation Processor를 추가하여야 합니다.  
> MapStruct는 우리가 작성한 매퍼 인터페이스 등을 클래스로 구현하는 프리컴파일을 합니다.

> **💡 org.projectlombok:lombok-mapstruct-binding:0.2.0** (Annotation Processor)
> 
> 롬복은 MapStruct와 충돌을 없애기 위한 org.projectlombok:lombok-mapstruct-binding 애노테이션 프로세서를 제공합니다. 이것을 사용하지 않으면 롬복 Annotation Processor와 동작 순서 등에서 충돌이 있습니다.

### Mapper 인터페이스 선언

매핑을 위해 더 이상 복잡한 작업은 필요하지 않습니다. 메서드를 선언할 수 있는 인터페이스만 있으면, 편하게 매핑용 객체를 얻을 수 있습니다.

* Package: `com.example.demo.auth.mapper`
    

```java
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface AccountDtoMapper {
    Account toEntity(SignUpRequest dto, AccountStatus status, Instant createdAt);
    SignUpResponse toSignUpResponse(Account entity);
}
```

수행한 작업은 다음과 같습니다.

* `@Mapper` 애노테이션을 추가합니다.
    
    * 컴포넌트 모델은 `"spring"`입니다. (이로써 자동으로 스프링의 빈으로 등록됩니다.)
        
    * 유효한 컴포넌트 모델은 다음과 같습니다. 이중 우리는 스프링으로 합니다.
        
        * default
            
        * cdi
            
        * spring
            
        * jsr330
            
        * jakarta
            
        * jakarta-cdi
            
* 인풋은 메서드 매개변수, 아웃풋은 반환 타입으로 선언하기만 하면 준비 완료입니다.
    
    * 예를 들어 `SignUpRequest` 객체를 `Account` 객체로 변환하는 메서드는 다음과 같습니다.
        
        ```java
        Account toEntity(SignUpRequest dto);
        ```
        

### Mapper를 주입받아서 사용하기

이제 API에서 번거롭게 빌더로 변환하고 있던 코드를 간결하게 바꾸어 보겠습니다. 우선 생성자 주입을 통해 빈을 받아 옵니다.

```java
@RestController
@RequiredArgsConstructor
public class AuthenticationApi {
    private final SignUpUseCase signUpUseCase;
    private final AccountDtoMapper mapper; // 추가된 필드

    // ...
}
```

기존 메서드는 다음처럼 데이터 변환을 직접 하고 있었습니다.

```java
@PostMapping("/sign-up")
@ResponseStatus(HttpStatus.CREATED)
public SignUpResponse signUp(@RequestBody @Valid SignUpRequest body) {
    Account account = Account.builder()
            .username(body.username())
            .nickname(body.nickname())
            .password(body.password())
            .status(AccountStatus.ACTIVE)
            .createdAt(Instant.now())
            .build();

    Account savedAccount = signUpUseCase.signUp(account);

    return SignUpResponse.builder()
            .username(savedAccount.getUsername())
            .nickname(savedAccount.getNickname())
            .createdAt(savedAccount.getCreatedAt())
            .status(savedAccount.getStatus())
            .build();
}
```

매퍼를 사용하면 다음처럼 간결하게 변경됩니다.

```java
@PostMapping("/sign-up")
@ResponseStatus(HttpStatus.CREATED)
public SignUpResponse signUp(@RequestBody @Valid SignUpRequest body) {
    // 매퍼: DTO -> Entity
    Account account = mapper.toEntity(body, AccountStatus.ACTIVE, Instant.now());

    Account savedAccount = signUpUseCase.signUp(account);

    // 매퍼: Entity -> DTO
    return mapper.toSignUpResponse(savedAccount);
}
```

### 선택 사항: MapStruct의 기본 컴포넌트 모델을 스프링으로 설정

`@Mapper` 애노테이션을 사용할 때 컴포넌트 모델(`componentModel = ...`)을 작성하는 것은 빠뜨리기 쉽고 귀찮은 작업입니다. 매번 명시하는 것도 괜찮지만, 명시하지 않아도 스프링의 빈으로 등록이되도록 설정할 수 있습니다.

`build.gradle.kts` 파일에서 다음 내용을 추가합니다. 다음 코드는 컴파일타임에 JVM에 매개변수로 MapStruct의 기본 컴포넌트 모델(`defaultComponentModel`)을 설정하는 옵션을 전달한 것입니다.

```kotlin
tasks.withType<JavaCompile> {
    options.forkOptions.jvmArgs = listOf(
            "-Amapstruct.defaultComponentModel=spring",
    )
}
```

이제 MapStruct 매퍼 인터페이스 작성이 다음처럼 더 간편하게 완료됩니다.

```java
@Mapper
public interface AccountDtoMapper {
    Account toEntity(SignUpRequest dto, AccountStatus status, Instant createdAt);
    SignUpResponse toSignUpResponse(Account entity);
}
```

MapStruct 매퍼 인터페이스를 작성할 때마다 모든 팀원이 `componentModel = spring`처럼 기억하기 번거로운 코드를 외우거나 찾지 않아도 사용할 수 있습니다.

---

**&lt; Prev**

[DTO와 API](https://letsdev.hashnode.dev/step-by-step-springboot3-7-kor)

**Next &gt;**

작성 예정
