DTO의 기본값 처리와 책임 분리: 클린한 설계를 위한 고민

haazz·2025년 5월 18일
post-thumbnail

DTO를 설계하던 중 다음과 같은 문제의 부딪혔습니다.

"필드를 생략했을 때 기본값을 넣어야 한다. 그런데 사용자가 이를 모르게 하면 곤란하다."

이 글에서는 실제로 제가 고민했던 부분들에 대해서 예시 코드와 함께 개선 과정을 소개하고자 합니다.


1단계: Builder 내부에서 값 강제 변경 (primitive 타입 사용)

처음에는 Builder 안에서 다음과 같이 기본값을 적용했습니다:

public class ExecutionDTO {

    private final long timeout;

    private ExecutionDTO(Builder builder) {
        this.timeout = builder.timeout >= 100
            ? builder.timeout
            : 100;
    }

    public static class Builder {
        private long timeout;

        public Builder timeout(long timeout) {
            this.timeout = timeout;
            return this;
        }

        public ExecutionDTO build() {
            return new ExecutionDTO(this);
        }
    }
}

문제점

  1. 사용자가 의도하지 않은 값으로 변경됨
    사용자가 명시적으로 값을 입력하지 않아도 내부에서 자동으로 100L이 적용되어, 실제 사용된 값이 무엇인지 알 수 없습니다.

  2. Builder가 너무 많은 책임을 가짐
    객체 생성 외에도 기본값 보정, 유효성 검증까지 모두 처리하면서 단일 책임 원칙(SRP)을 위배합니다.

  3. primitive 타입이라 생략 여부 자체를 알 수 없음
    long 타입은 null을 허용하지 않기 때문에, 사용자가 이 값을 생략했는지 알 수 없습니다.


2단계: 사용자에게 정책 명시 + Long 타입으로 변경

이를 보완하기 위해 아래 3가지를 변경하였습니다.

  1. timeoutLong으로 바꾸기
  2. DEFAULT_TIMEOUT을 public으로 명시
  3. Builder 메서드에 JavaDoc을 추가하여 명확한 정책을 전달
public class ExecutionDTO {

    public static final long DEFAULT_TIMEOUT = 100L;

    private final Long timeout;

    private ExecutionDTO(Builder builder) {
        this.timeout =
            builder.timeout == null || builder.timeout < DEFAULT_TIMEOUT
            ? DEFAULT_TIMEOUT
            : builder.timeout;
    }

    public static class Builder {
        private Long timeout;

        /**
         * Optional. Timeout in milliseconds. Must be >= 100.
         * If omitted, default of 100ms will be used.
         */
        public Builder timeout(Long timeout) {
            this.timeout = timeout;
            return this;
        }

        public ExecutionDTO build() {
            return new ExecutionDTO(this);
        }
    }

    public Long getTimeout() {
        return timeout;
    }
}

장점

  • 사용자에게 정책을 IDE(JavaDoc)로 명확히 안내할 수 있음
  • 사용자가 기본값을 public으로 조회 가능
  • 기본값 생략 여부를 null로 판단 가능

문제점

그러나 여전히 Builder 내부에서 기본값이나 검증 정책이 해석되면서 책임 분리가 부족했습니다.


3단계: 책임 분리 (DTO + Resolver + Validator)

이제 DTO는 순수하게 값만 보관합니다:

public class ExecutionDTO {
    private final Long timeout;

    public ExecutionDTO(Long timeout) {
        this.timeout = timeout;
    }

    public Long getTimeout() {
        return timeout;
    }
}

✅ 기본값 보정: Resolver

public class ExecutionDTOResolver {

    public static final long DEFAULT_TIMEOUT = 100L;

    public static ExecutionDTO applyDefaults(ExecutionDTO raw) {
        Long timeout = raw.getTimeout();
        if (timeout == null) {
            timeout = DEFAULT_TIMEOUT;
        }
        return new ExecutionDTO(timeout);
    }
}

✅ 정책 검증: Validator

public class ExecutionDTOValidator {

    public static void validate(ExecutionDTO executionDTO) {
        if (executionDTO.getTimeout() < ExecutionDTOResolver.DEFAULT_TIMEOUT) {
            throw new IllegalArgumentException("timeout must be >= 100");
        }
    }
}

이제 Builder는 단순 값 설정만 담당하며, 정책 해석은 전적으로 Resolver와 Validator가 담당하게 됩니다.


4단계: 책임 분리 후 사용자에게 정책을 다시 명시하려면?

DTO 내부에서 정책을 제거했더니 사용자에게 해당 조건을 명확히 안내할 방법이 필요해졌습니다.

✅ Resolver 사용하는 코드에서 JavaDoc으로 정책 명시

public class Executor {

    /**
     * @param executionDTO timeout must be >= {@link ExecutionDTOResolver#DEFAULT_TIMEOUT}.
     *               If null, default will be applied by Resolver.
     */
    public void execute(ExecutionDTO executionDTO) {
        ExecutionDTO resolved = ExecutionDTOResolver.applyDefaults(executionDTO);
        ExecutionDTOValidator.validate(resolved);
        // ...
    }
}

결론

단계방식문제/개선
1단계primitive + Builder 내부 처리생략 불가, 사용자 몰래 값 변경, 책임 뭉침
2단계Long + JavaDoc 명시정책 안내 가능, 책임 분리는 미흡
3단계DTO + Resolver + Validator책임 분리 성공, 사용자 명시 약화
4단계실행부에서 사용자 정책 명시책임 분리와 사용자 명시 모두 달성 ✅

마무리

DTO는 순수한 값 객체로 유지하되, 기본값 처리와 검증은 외부로 분리하고, 사용자에게는 JavaDoc, 문서, 상수, 예외 메시지 등을 통해 정책을 명확하게 전달해야 합니다.

책임을 분리하지 않는 것이 더 나은 경우는?

  • DTO의 생명주기가 한정적이고 내부 시스템 전용일 경우: 외부와 공유하는 것이 아닌 내부 구현 편의를 위한 DTO라면 책임을 굳이 나눌 필요는 없습니다.
  • 성능이 중요한 경량 객체일 경우: Resolver처럼 객체를 다시 생성하는 방식이 오히려 성능을 저하시킬 수 있는 상황이라면, DTO 내부에서 처리하는 편이 낫습니다.

결국 책임 분리는 "어디까지 설계할 것인가"에 대한 트레이드오프이며, 불변성, 유지보수성, 가시성, 성능 등을 종합적으로 고려해 결정해야 합니다.

profile
Developers who create benefit social values

0개의 댓글