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

Merge SimpsonMerge Simpson
5 min read

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

DTO와 Entity의 쓰임 구분

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

  • DTO: 사용자(클라이언트)와 주고 받는 데이터입니다.

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

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

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

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

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

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

DTO를 Entity로 변환하기

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

  • DTO는 순수하게 데이터를 전달하는 역할을 해야 합니다.

  • 사용하는 데에 있어 충분히 편리해야 합니다.

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

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

toEntity() 메서드

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

사용

// 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에 다음 세 항목을 추가합니다.

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
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 객체로 변환하는 메서드는 다음과 같습니다.

        Account toEntity(SignUpRequest dto);
      

Mapper를 주입받아서 사용하기

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

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

    // ...
}

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

@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();
}

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

@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)을 설정하는 옵션을 전달한 것입니다.

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

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

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

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


< Prev

DTO와 API

Next >

작성 예정

0
Subscribe to my newsletter

Read articles from Merge Simpson directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Merge Simpson
Merge Simpson

Hello, I am Korean. Welcome, visitor. You are very cool. 안녕하세요, 저는 한국어입니다. 방문자여 환영한다. 당신은 매우 시원해.