DTO를 설계하던 중 다음과 같은 문제의 부딪혔습니다.
"필드를 생략했을 때 기본값을 넣어야 한다. 그런데 사용자가 이를 모르게 하면 곤란하다."
이 글에서는 실제로 제가 고민했던 부분들에 대해서 예시 코드와 함께 개선 과정을 소개하고자 합니다.
처음에는 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);
}
}
}
사용자가 의도하지 않은 값으로 변경됨
사용자가 명시적으로 값을 입력하지 않아도 내부에서 자동으로 100L이 적용되어, 실제 사용된 값이 무엇인지 알 수 없습니다.
Builder가 너무 많은 책임을 가짐
객체 생성 외에도 기본값 보정, 유효성 검증까지 모두 처리하면서 단일 책임 원칙(SRP)을 위배합니다.
primitive 타입이라 생략 여부 자체를 알 수 없음
long 타입은 null을 허용하지 않기 때문에, 사용자가 이 값을 생략했는지 알 수 없습니다.
이를 보완하기 위해 아래 3가지를 변경하였습니다.
timeout을 Long으로 바꾸기DEFAULT_TIMEOUT을 public으로 명시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;
}
}
그러나 여전히 Builder 내부에서 기본값이나 검증 정책이 해석되면서 책임 분리가 부족했습니다.
이제 DTO는 순수하게 값만 보관합니다:
public class ExecutionDTO {
private final Long timeout;
public ExecutionDTO(Long timeout) {
this.timeout = timeout;
}
public Long getTimeout() {
return timeout;
}
}
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);
}
}
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가 담당하게 됩니다.
DTO 내부에서 정책을 제거했더니 사용자에게 해당 조건을 명확히 안내할 방법이 필요해졌습니다.
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, 문서, 상수, 예외 메시지 등을 통해 정책을 명확하게 전달해야 합니다.
결국 책임 분리는 "어디까지 설계할 것인가"에 대한 트레이드오프이며, 불변성, 유지보수성, 가시성, 성능 등을 종합적으로 고려해 결정해야 합니다.