과정을 즐기자

복잡한 비즈니스 로직을 풀어내는 방법 본문

아키텍쳐

복잡한 비즈니스 로직을 풀어내는 방법

320Hwany 2024. 5. 28. 15:21

이번 글에서는 프로젝트를 진행하면서 복잡한 비즈니스 로직을 풀어냈던 방법에 대해 작성해보려고 합니다.

⛳️ 요구 사항

먼저 요구사항에 대해 정리해보겠습니다.

 

1. 각각의 회원이 각자의 캘린더를 가지고 있음

2. 회원은 여러 모임에 참여할 수 있음

3. 모임장은 참여한 회원들의 캘린더 정보를 바탕으로 팀원들의 가용시간을 확인할 수 있음

4. 이 가용시간을 바탕으로 특정 시간에 미팅을 생성할 수 있음

 

위와 같은 요구 사항이 있습니다. 이번 글에서는 3번에 대한 요구 사항을 중점적으로 이야기를 해볼텐데 대략 아래와 같은 UI를

제공합니다. 각 회원의 캘린더 정보를 가져와서 하나의 캘린더에 색상으로 표현을 합니다.

색상이 진할 수록 가능한 사람이 많은 시간대를 의미하며 해당 캘린더를 클릭하면 가능한 회원의 정보를 볼 수 있습니다.

📘 로직을 풀어보자

3번 요구사항을 조금 더 자세히 풀어서 어떻게 코드를 작성해야할 지를 생각해보겠습니다.

 

1. 해당 모임에 참여한 회원의 정보를 가져온다.

2. 가져온 회원 정보를 바탕으로 해당 기간의 (1주일) 각각의 캘린더 정보를 가져온다.

3. 회원들의 캘린더 정보들을 바탕으로 특정 시간대에 어떤 사람들이 몇명이 가능한지를 계산한다.

 

먼저 Service 영역의 코드를 살펴보겠습니다.

public List<AvailableTimeInMoimResponse> findWeeklyAvailableTimeInMoim(
			final long moimId, final LocalDate startDate) {
    // 1번
    List<Long> memberIds = joinedMoimFinder.findAllJoinedMemberId(moimId);
    
    // 2번
    List<MoimScheduleResponse> moimScheduleResponses = 
    			scheduleFinder.findAllInMembersByWeekly(memberIds, startDate);
                
    // 3번
    List<AvailableTime> availableTimes =
    			AvailableTime.calculateAvailableTimes(moimScheduleResponses, startDate);

    return AvailableTimeInMoimResponse.from(availableTimes);
}

 

Service에서는 자세한 상세한 구현은 알지 못합니다. 하지만 이 메소드 1개만 보았을 때 1, 2, 3번으로 전체 흐름을 느낄 수 있습니다.

1, 2번은 모두 DB에 쿼리를 날려 데이터를 가져오는 로직입니다. 여기서 주목해볼 점은 3번을 도메인을 사용하여 풀어냈다는 점입니다.

해당 코드를 읽었을 때 "가용시간을 계산하여 반환하는 구나" 정도로 전체 흐름만 알 수 있고 상세한 구현은 도메인으로 넘겼습니다.

🌟 상세한 구현은 도메인으로 넘기자

그럼 도메인을 살펴보겠습니다. 도메인에는 상세한 구현이 있습니다. 여기서 말하는 도메인은 DB 엔티티와는 다르며

로직을 풀기 위해 새롭게 정의한 것을 말합니다. 로직을 간단히 요약하면 다음과 같습니다.

 

1. 회원들의 스케줄 정보, 시간 시간대를 입력 받음

2. 스케줄 정보를 바탕으로 각각의 시작 시간, 종료 시간의 경계 시간대를 설정

3. 회원들을 그룹핑한 후 해당 시간대에 속하는 지 확인

4. 속하면 정보를 추가하고 색상 정보가 점점 진해짐

AvailableTime

public record AvailableTime(
        List<Member> members,
        LocalDateTime startDateTime,
        LocalDateTime endDateTime
) {

    public static AvailableTime toDomain(final List<Member> members, final LocalDateTime startDateTime,
                                         final LocalDateTime endDateTime) {
        return new AvailableTime(members, startDateTime, endDateTime);
    }

    public static List<AvailableTime> calculateAvailableTimes(final List<MoimScheduleResponse> moimScheduleResponses,
                                                              final LocalDate startDate) {
        ScheduleLocalDate scheduleLocalDate = ScheduleLocalDate.from(startDate);
        LocalDateTime start = scheduleLocalDate.atWeeklyStartDateTime();
        LocalDateTime end = scheduleLocalDate.atWeeklyEndDateTime();

        Map<Long, List<MoimScheduleResponse>> schedulesByMember = Member.groupSchedulesByMember(moimScheduleResponses);

        List<LocalDateTime> allTimes = getAllTimes(moimScheduleResponses, start, end);
        List<AvailableTime> availableTimes = new ArrayList<>();

        for (int i = 0; i < allTimes.size() - 1; i++) {
            LocalDateTime startDateTime = allTimes.get(i);
            LocalDateTime endDateTime = allTimes.get(i + 1);

            List<Member> availableMembers = Member.filterByDateTime(schedulesByMember, startDateTime, endDateTime);

            if (!availableMembers.isEmpty()) {
                availableTimes.add(AvailableTime.toDomain(availableMembers, startDateTime, endDateTime));
            }
        }

        return availableTimes;
    }

    private static List<LocalDateTime> getAllTimes(final List<MoimScheduleResponse> moimScheduleResponses,
                                                   final LocalDateTime startTime,
                                                   final LocalDateTime endTime) {
        List<LocalDateTime> times = getTimesFromSchedules(moimScheduleResponses);
        List<LocalDateTime> allTimes = new ArrayList<>(times);
        allTimes.add(SCHEDULE_TIME_START_IDX.value(), startTime);
        allTimes.add(endTime);

        return allTimes;
    }

    private static List<LocalDateTime> getTimesFromSchedules(final List<MoimScheduleResponse> moimScheduleResponses) {
        return moimScheduleResponses.stream()
                .flatMap(schedule -> Stream.of(schedule.startDateTime(), schedule.endDateTime()))
                .distinct()
                .sorted()
                .toList();
    }
}

 

ScheduleLocalDate

public record ScheduleLocalDate(
        LocalDate startDate
) {

    public static ScheduleLocalDate from(final LocalDate startDate) {
        return new ScheduleLocalDate(startDate);
    }

    // 시작 날짜의 자정
    public LocalDateTime atWeeklyStartDateTime() {
        return startDate.atStartOfDay();
    }

    // 시작 날짜로부터 7일 후의 자정 전
    public LocalDateTime atWeeklyEndDateTime() {
        return startDate.plusDays(SIX_DAY.time())
                .atTime(23, 59, 59, 999999999);
    }
}

 

Member

public record Member(
        long memberId,
        String username,
        String memberProfileImageUrl
) {

    public static Map<Long, List<MoimScheduleResponse>> groupSchedulesByMember(final List<MoimScheduleResponse> moimScheduleResponses) {

        return moimScheduleResponses.stream()
                .collect(Collectors.groupingBy(MoimScheduleResponse::memberId));
    }

    public static List<Member> filterByDateTime(final Map<Long, List<MoimScheduleResponse>> schedulesByMember,
                                                final LocalDateTime startDateTime, final LocalDateTime endDateTime) {
        return schedulesByMember.values().stream()
                .filter(
                        moimScheduleResponses -> moimScheduleResponses.stream()
                                .noneMatch(
                                        moimScheduleResponse -> isScheduleConflicting(moimScheduleResponse, startDateTime, endDateTime))
                )
                .map(moimScheduleResponses -> {
                    MoimScheduleResponse moimScheduleResponse = moimScheduleResponses.get(MOIM_SCHEDULE_FIRST_IDX.value());
                    return moimScheduleResponse.toDomain();
                })
                .collect(toList());
    }

    private static boolean isScheduleConflicting(final MoimScheduleResponse moimScheduleResponse,
                                                 final LocalDateTime startDateTime, final LocalDateTime endDateTime) {
        return moimScheduleResponse.startDateTime().isBefore(endDateTime)
                && moimScheduleResponse.endDateTime().isAfter(startDateTime);
    }
}

📙 상세한 구현을 도메인으로 넘겼을 때의 장점

상세한 구현을 도메인으로 넘겼을 때의 장점은 다음과 같습니다.

 

1. 서비스의 메소드만 보고도 전체 흐름을 파악하기 쉽다.

2. 테스트를 하기가 쉬워진다.

 

크게 2가지 장점이 있다고 생각하는데 이 2가지가 모두 엄청난 장점이라고 생각합니다.

도메인의 로직을 테스트하기 위해서는 스프링 컨테이너를 사용하지 않고 POJO로 테스트할 수 있게 됩니다.

이러한 영역이 많아질 수록 단위 테스트가 많아집니다.

 

특히 시간과 관련된 로직을 풀기 위해서는 지나간 시간, 다가오는 시간, 겹치는 시간 등등 굉장히 많은 엣지 케이스를 

고려해야 합니다. 이러한 엣지 케이스도 테스트하기 굉장히 용이해집니다. 이때 메소드의 파라미터로 시간 정보를

입력 받는다면 시간이 제어할 수 있는 영역이 되기 때문에 손쉽게 엣지 케이스를 테스트 해볼 수 있습니다.

 

프로젝트에 적용해본 몇 가지 주요 도메인 테스트 케이스 예시는 다음과 같습니다.

1. 모임의 스케줄 정보를 입력받아 각 시간대별 가용시간 목록을 받는다 - 중복 시간대 없음
2. 모임의 스케줄 정보를 입력받아 각 시간대별 가용시간 목록을 받는다 - 중복 시간대 포함
3. 회원 id로 모임 스케줄 정보를 그룹 짓는다.
4. 해당 시간대에 가용시간이 있으면 목록에 포함된다 - 앞 쪽 시간대에만 존재
5. 해당 시간대에 가용시간이 있으면 목록에 포함된다 - 뒤 쪽 시간대에만 존재
6. 해당 시간대에 가용시간이 없으면 목록에 포함된다 - 앞 쪽 시간대와 겹침
7. 해당 시간대에 가용시간이 없으면 목록에 포함된다 - 뒤 쪽 시간대와 겹침
8. 날짜 정보를 입력받아 해당 날짜의 자정 시간으로 반환한다.
9. 날짜 정보를 입력받아 해당 날짜의 7일 후의 자정전 시간으로 반환한다.