복잡한 기본값, 예외 로직을 요구하는 DTO를 생성하기

메밀·2024년 8월 20일

1. 문제 상황

1) 많은 파라미터

기능 구현을 위해 5개의 조건이, 기본값 처리 로직을 위해 6개의 파라미터가 필요

2) 복잡한 초깃값

  • hashtag 필드가 없다면 요청자의 username으로,
  • 통계 시작일이 없다면 오늘 기준 일주일 전,
  • 통계 종료일이 없다면 오늘로 초기화 필요

3) 날짜 범위에 따른 예외처리 로직

  • 통계 시작일이 종료일보다 미래이거나,
  • 일별 통계에서 30일을 초과한 경우,
  • 시간대별 통계에서 7일을 초과한 경우 예외 처리 필요.

2. 초기 코드

Builder와 삼항연산자를 사용하고, 컨트롤러에서 예외처리했다.

    @GetMapping("/statistics")
    public Map<String, Integer> getStatistics(
            @AuthenticationPrincipal UserDetails UserDetail,
            @RequestParam(value = "hashtag") String hashtag,
            @RequestParam(value = "type") String type,
            @RequestParam(value = "start", required = false) LocalDate start,
            @RequestParam(value = "end", required = false) LocalDate end,
            @RequestParam(value = "value", defaultValue = "count") String value
    ) {
        String username = userDetail.getUsername();

        StatisticsQuery query = StatisticsQuery.builder()
                .hashtag(hashtag == null ? username : hashtag)
                .type(StatisticType.from(type))
                .start(start == null ? LocalDate.now().minusDays(7) : start)
                .end(end == null ? LocalDate.now() : end)
                .value(StatisticValue.from(value))
                .build();

        if(query.getEnd().isBefore(query.getStart())) {
            throw new IllegalArgumentException("검색 시작일이 종료일보다 미래여선 안됨");
        } 
        // 이하 예외처리 생략

        return statisticService.getStatistic(query);
    }
}

이 코드는 마음에 들지 않았다.

컨트롤러에 DTO 생성 로직, 비즈니스 로직이 너무 섞여 있는 느낌이었기 때문이다.

- 초기값, 유효성 검사 로직을 서비스로 보내기 싫었던 이유

1) 우선 여섯 개의 파라미터를 서비스로 넘기는 게 깔끔하지 않고,
2) 컨트롤러-서비스 간 DTO를 사용한다고 해도 불완전한 DTO 객체를 생성하는 게 찜찜했다.
3) 게다가 유효하지 않은 요청이라면, 서비스 레이어까지 갈 이유가 없다.

3. 정적 팩토리 메소드를 사용하여 리팩토링

유효한 DTO 객체만 생성하고 싶다!
유효하지 않은 요청 데이터라면 최대한 빨리 쫓아내고 싶다!

이에 정적 팩토리 메소드를 사용하기로 했다.

	public static StatisticRequest createWithDefaults(
            String hashtag,
            String type,
            LocalDateTime start,
            LocalDateTime end,
            String value,
            String email
    ) {
        // 기본값 설정
        String finalHashTag = (hashtag == null) ? email : hashtag;
        StatisticType finalType = StatisticType.from(type);
        LocalDateTime finalStart = (start == null) ? LocalDateTime.now().minusDays(7) : start;
        LocalDateTime finalEnd = (end == null) ? LocalDateTime.now() : end;
        StatisticValue finalValue = StatisticValue.from(value);

        // 통계 시작일, 종료일 유효성 검사
        // 2024-08-26, 2024-08-27이 제공될 때, 1을 반환
        long daysBetween = ChronoUnit.DAYS.between(finalStart, finalEnd);

        if(daysBetween < 0) {
            // 시작일이 종료일보다 미래
            throw new IllegalArgumentException(START_AFTER_END);
        } else if(finalType == StatisticType.DATE && daysBetween >= 30) {
            // 일별 통계 시 30일 초과 불가
            throw new IllegalArgumentException(TOO_LONG_DATE_QUERY);
        } else if(finalType == StatisticType.HOUR && daysBetween >= 7) {
            // 시간별 통계 시 7일 초과 불가
            throw new IllegalArgumentException(TOO_LONG_HOUR_QUERY);
        }

        // 유효한 `StatisticRequest` 객체를 생성하여 반환
        return new StatisticRequest(finalHashTag, finalType, finalStart, finalEnd, finalValue);
    }

생성자는 private으로 설정하여 정적 팩토리 메서드를 통해서만 객체를 만들 수 있도록 구현했다.

- new를 사용하지 않은 이유

물론 같은 동작을 하는 생성자를 만들어 해결할 수도 있다.

하지만 위의 메소드와 같은 파라미터를 사용하는 생성자를 통해 DTO를 생성한다면, 그 생성자에 기본값 처리 로직이 있는지, 예외 처리 로직이 존재하는지 알리기 어렵다고 판단했다.

반면, 정적 팩토리 메소드는 이름을 가질 수 있다.

나의 경우 createWithDefaults라는 이름으로 기본값(과 예외처리) 로직이 포함되어 있음을 나타냈다.

- 단점과 나의 반박ㅋㅋㅋ

1) 상속 불가
실컷 객체 초기화 로직을 만들어놓고 생성자를 무분별하게 개방해버리면 의미가 없으니까 생성자를 private으로 제한해야한다.

이 경우, 하위 클래스를 만들 수 없다는 단점이 생긴다.

하지만 객체 재사용이 필요하다면 포함 관계로 얼마든지 해결할 수 있을 것이다.

2) 프로그래머가 찾기 어렵다
IntelliJ가 잘 찾으니까 괜찮다.

0개의 댓글