[Spring Plus] Level 1-5 요구사항 반영

2024. 11. 13. 16:24내일배움캠프/Spring Plus

 'Level 0-0' 의 요구사항을 반영한 내용을 기록한 포스팅이다. 어떠한 생각과 과정을 통해 요구사항을 반영했는지 알 수 있도록 작성해 보았다.

 

0. 요구사항

 API(GET /todos) 요청시 검색 조건을 사용할 수 있도록 코드를 수정해야 한다. 사용할 수 있는 검색 조건은 '날씨, 수정 기간' 이다. 조건 중 '수정 기간' 은 특정 범위의 기간안에 수정된 일정들을 조회하기 위해 사용된다.

 

모든 검색 조건들은 있던 없던 요청이 수행될 수 있어야 한다. 즉, 요청에 모든 조건이 없을 수도 있고 날씨 조건만 있거나 수정 기간 조건만이 있을 수도 있다. 또한 모든 조건이 있을 수도 있다.

 

 

1. TodoController 수정

 먼저 컨트롤러에서 검색 조건에 해당하는 값을 전달 받을 수 있도록 'TodoController' 클래스의 'getTodos()' 를 아래와 같이 수정했다.

@RestController
@RequiredArgsConstructor
public class TodoController {
    ...

    @GetMapping("/todos")
    public ResponseEntity<Page<TodoResponse>> getTodos(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(defaultValue = "") String weather,
            @RequestParam(defaultValue = "") String periodStart,
            @RequestParam(defaultValue = "") String periodEnd
    ) {
        return ResponseEntity.ok(todoService.getTodos(page, size, weather, periodStart, periodEnd));
    }

    ...
}

 

검색 조건에 해당하는 날씨(=weather)와 기간(periodStart, periodEnd)에 대한 정보를 쿼리 파라미터로 전달받도록 했다. 그 중 기간에 대한 정보를 String 타입을 전달 받고 있는데, String 으로 받는 이유는 null 이 돌아다니게 하고 싶지 않아서 이다.

 

이게 무슨 소리냐면 요구사항에는 검색 조건이 있던 없던 요청은 수행되어야 한다고 명시되어 있다. 즉, 검색 조건이 없다면 '기본' 값으로 검색을 진행해야 한다는 것이다. 이러한 상황에서 LocalDateTime, LocalDate 등으로 기간에 대한 정보를 받는다면 기간에 대한 조건이 없는 경우 null 을 허용해 이를 다루어야 할 것이다. 하지만 String 을 사용한다면 빈 값("") 을 기본값으로 사용해 null 사용을 방지할 수 있을거라 생각해 String 타입으로 기간에 대한 정보를 전달받기로 정하였다.

 

 

2. TodoService 수정

 'TodoService' 클래스의 'getTodos()' 를 아래와 같이 수정했다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TodoService {
    ...

    public Page<TodoResponse> getTodos(int page, int size, String weather, String periodStart, String periodEnd) {
        Pageable pageable = PageRequest.of(page - 1, size);

        Page<Todo> todos = todoRepository.findAllWithSearchCondition(pageable, weather, periodStart, periodEnd);

        return todos.map(todo -> new TodoResponse(
                todo.getId(),
                todo.getTitle(),
                todo.getContents(),
                todo.getWeather(),
                new UserResponse(todo.getUser().getId(), todo.getUser().getEmail(), todo.getUser().getNickname()),
                todo.getCreatedAt(),
                todo.getModifiedAt()
        ));
    }

    ...
}

 

기존에 파라미터로 전달 받던 'size, page' 외에도 검색 조건인 'weather, periodStart, periodEnd' 를 추가로 전달 받을 수 있도록 했으며, 기존에는 'TodoRepository' 인터페이스의 'findAllByOrderByModifiedAtDesc()' 가 아닌 'QueryTodoRepository' 인터페이스의 'findAllWithSearchCondition()' 을 사용하도록 수정했다.

 

'findAllWithSearchCondition()' 에 대해서는 아래에서 좀 더 자세히 설명하도록 하겠다.

 

 

3. TodoRepository 수정 및 QueryTodoRepository(Impl) 생성

 Todo 의 Controller 와 Service 에 비하면 Repository 는 꽤나 수정하였다. 먼저 앞서 말한 것처럼 'TodoRepository' 인터페이스의 'findAllByOrderByModifiedAtDesc()' 를 사용하지 않고 'QueryTodoRepository' 인터페이스의  'findAllWithSearchCondition()' 을 사용하게 되었다.

 

그래서 TodoRepository 인터페이스가 아래와 같이 QueryTodoRepository 인터페이스를 상속하도록 하였다.

public interface TodoRepository extends JpaRepository<Todo, Long>, QueryTodoRepository {
    ...
}

 

그리고 추가로 생성한 QueryTodoRepository 인터페이스와 구현 클래스 QueryTodoRepositoryImpl 은 아래와 같이 작성하였다.

public interface QueryTodoRepository {
    Page<Todo> findAllWithSearchCondition(Pageable pageable, String weather, String periodStart, String PeriodEnd);
}


@Repository
@RequiredArgsConstructor
public class QueryTodoRepositoryImpl implements QueryTodoRepository {
    private final EntityManager entityManager;

    @Override
    public Page<Todo> findAllWithSearchCondition(
            Pageable pageable, String weather, String periodStart, String periodEnd
    ) {
        String jpql = "SELECT t FROM Todo AS t LEFT JOIN FETCH t.user";
        List<String> conditions = new ArrayList<>();

        if (StringUtils.hasText(weather)) {
            conditions.add("t.weather = :weather");
        }

        if (StringUtils.hasText(periodStart)) {
            conditions.add("t.modifiedAt >= :start");
        }

        if (StringUtils.hasText(periodEnd)) {
            conditions.add("t.modifiedAt <= :end");
        }

        if (!conditions.isEmpty()) {
            jpql += " WHERE " + String.join(" AND ", conditions);
        }

        jpql += " ORDER BY t.modifiedAt DESC LIMIT :size OFFSET :page";
        TypedQuery<Todo> query = entityManager.createQuery(jpql, Todo.class);

        if (StringUtils.hasText(weather)) {
            query.setParameter("weather", weather);
        }

        if (StringUtils.hasText(periodStart)) {
            query.setParameter("start", convertStringToLocalDatetime(periodStart));
        }

        if (StringUtils.hasText(periodEnd)) {
            query.setParameter("end", convertStringToLocalDatetime(periodEnd));
        }

        query.setParameter("page", (int) pageable.getOffset());
        query.setParameter("size", pageable.getPageSize());

        List<Todo> todos = query.getResultList();
        long totalRows = countTotalRow();

        return new PageImpl<>(todos, pageable, totalRows);
    }

    private long countTotalRow() {
        String jpql = "SELECT COUNT(*) FROM Todo AS t";
        TypedQuery<Long> query = entityManager.createQuery(jpql, Long.class);
        return query.getSingleResult();
    }
}

 

이렇게 추가 Repository 를 생성한 이유는 다음과 같다. 우선 검색 조건에 따른 요청은 결국 '동적 쿼리' 를 사용하게 된다. 하지만 그렇다고 해서 무조건 동적 쿼리를 사용해야한다 묻는다면 그건 아니라 답할 것 같다.

 

개인적으로 요구사항에 JPQL 을 사용해야 한다는 내용이 없었다면 JpaRepository 와 native query 를 활용해 추가 Repository 생성 없이 요구사항을 반영할 수 있지 않았을까 라는 생각을 가지고 있기 때문이다. 하지만 요구사항에 명시된 내용을 반영해야 하는 만큼 위와 같이 추가 Repository 를 생성하고 동적 쿼리를 작성하게 되었다.

 

특이사항으로는 'findAllWithSearchCondition()' 로직의 마지막 부분에 'countTotalRow()' 를 호출한다는 것인데, 이전에 Pageable 을 통해 페이지네이션을 할 때 'Page<>' 객체를 반환 받을 경우 조회 쿼리 이후 전체 페이지에 대한 정보를 얻기 위해 추가 쿼리가 날아가는 것을 확인한 경험이 있었다. 실제로 위의 코드 작성시 해당 부분이 없으면 전체 페이지 정보를 얻기 어려워 'countTotalRow()' 를 작성해 TODOS 테이블의 전체 행(레코드) 개수를 구할 수 있도록 하였다.

 

 

4. DateTimeConvertor 생성

 String 으로 기간 정보를 받다보니 이를 LocalDateTime 으로 변환할 필요가 생겼다. JPQL 을 사용하므로 Todo 필드의 modifiedAt 과 타입을 맞춰야 하기 때문이다.

 

해당 기능을 Service 에서 다루기 보다 추가 클래스를 생성하고 새로운 메서드를 작성해 역할을 위임하는 것이 괜찮을 거라 생각해 'org.example.domain.common.convertor' 패키지에 DateTimeConvertor 를 아래와 같이 생성하게 되었다.

public class DateTimeConvertor {
    public static LocalDateTime convertStringToLocalDatetime(String str) {
        try {
            return LocalDateTime.parse(str, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        } catch (DateTimeParseException e) {
            throw new InvalidRequestException("Not valid datetime format");
        }
    }
}

 

'convertStringToLocalDateTime()' 은 static 메서드인데, 충분히 다른 곳에서 활용 가능하고 무엇보다 TodoService 에 과도한 의존관계를 주입하는 것을 염려(현재 과도하지는 않지만 앞으로 어떻게 될지 모르기에)한 것도 있고 해당 클래스가 너무 많은 곳에 주입되면 변경사항이 광범위하게 전파되는 것을 우려한 것도 있다. 그래서 static 메서드로 작성하게 되었다.

 

만약 팀원과의 소통을 할 수 있고, 소통을 통해 DateTimeConvertor 가 그렇게 변경사항이 없고 주입된다고 해서 주입받는 클래스의 관계가 과도해지는게 아니라 판단된다면 해당 클래스를 빈으로 등록해 주입할 수 있도록 수정할 것 같다.

 

무튼 'convertStringTodoLocalDateTime()' 은 String 을 전달받아 'yyyy-MM-dd HH:mm:ss' 형식으로 LocalDateTime 으로 변환해 준다. 물론 지정한 형식에 맞지 않는 문자열을 전달 받은 경우에는 'InvalidRequestException' 을 던지며 GlobalExceptionHandler' 에서 해당 예외를 처리할 것이다.

 

 

5. 테스트

 수정을 했으니 Postman 을 통해 일정 목록 조회 요청에 대한 API 테스트를 진행했다. 아래는 테스트에 사용된 TODOS 테이블이다. 이후 테스트 결과와 비교하기 쉽도록 수정일(=modified_at) 기준으로 내림차순 정렬한 상태이다.

TODOS 테이블(modified_at 기준 내리차순 정렬됨)

 

5-1. 모든 조건(페이지, 날씨, 수정 기간)이 없는 요청 시

일정 목록 조회 요청 (검색 조건 - X)

더보기
{
    "content": [
        {
            "id": 14,
            "title": "todo12",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-13T18:00:00"
        },
        {
            "id": 13,
            "title": "todo11",
            "contents": "test",
            "weather": "Sleet",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-13T11:00:00"
        },
        {
            "id": 12,
            "title": "todo10",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-13T05:00:00"
        },
        {
            "id": 11,
            "title": "todo9",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-12T23:00:00"
        },
        {
            "id": 2,
            "title": "API 테스트",
            "contents": "level 1-2 요구사항 반영 후 일정 작성 테스트",
            "weather": "Calm",
            "user": {
                "id": 2,
                "email": "tester@gmail.com",
                "nickname": "테스터"
            },
            "createdAt": "2024-11-12T12:10:32.217563",
            "modifiedAt": "2024-11-12T12:10:32.217563"
        },
        {
            "id": 10,
            "title": "todo8",
            "contents": "test",
            "weather": "Frosty",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-12T12:00:00"
        },
        {
            "id": 9,
            "title": "todo7",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-12T10:00:00"
        },
        {
            "id": 8,
            "title": "todo6",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-11T21:00:00"
        },
        {
            "id": 7,
            "title": "todo5",
            "contents": "test",
            "weather": "Snowy",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-11T18:00:00"
        },
        {
            "id": 1,
            "title": "테스트 일정",
            "contents": "level 1-1 요구사항 반영 후 일정 작성 테스트",
            "weather": "Humid",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-11T11:35:04.004618",
            "modifiedAt": "2024-11-11T11:35:04.004618"
        }
    ],
    "page": {
        "size": 10,
        "number": 0,
        "totalElements": 14,
        "totalPages": 2
    }
}

조건이 없기에 기본 조건(페이지)에 맞추어 전체 TODOS 테이블에서 가장 최근 수정된 일정 목록 10개를 요청하였고, 해당 되는 일정 목록과 페이지 정보를 응답으로 반환받는 것을 확인할 수 있다.

 

5-2. 한 개의 조건만 포함한 요청 시

일정 목록 조회 요청 (검색 조건 - 페이지)

 

페이지 크기가 2일 때 두 번째 페이지에 해당하는 일정 목록을 조회한 경우이다. DB 와 비교해 봐도 일정이 잘 반환되었고 페이지 정보 또한 잘 반환 된것을 확인했다.

 

일정 목록 조회 요청 (검색 조건 - 날씨)

더보기
{
    "content": [
        {
            "id": 14,
            "title": "todo12",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-13T18:00:00"
        },
        {
            "id": 12,
            "title": "todo10",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-13T05:00:00"
        },
        {
            "id": 11,
            "title": "todo9",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-12T23:00:00"
        },
        {
            "id": 9,
            "title": "todo7",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-12T10:00:00"
        },
        {
            "id": 8,
            "title": "todo6",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-11T21:00:00"
        },
        {
            "id": 6,
            "title": "todo4",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T09:00:00",
            "modifiedAt": "2024-11-11T10:00:00"
        },
        {
            "id": 5,
            "title": "todo3",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T20:00:00",
            "modifiedAt": "2024-11-10T20:00:00"
        },
        {
            "id": 3,
            "title": "todo1",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-10T18:00:00"
        }
    ],
    "page": {
        "size": 10,
        "number": 0,
        "totalElements": 14,
        "totalPages": 2
    }
}

 

일정의 날씨가 'Sunny' 인 일정들만을 조회한 경우이다. 반환 받은 일정들 모두 'weather' 가 'Sunny' 이고 DB 와 비교해봐도 수정일 기준 내림차순으로 반환되었으며, 페이지 정보 또한 잘 반환된 것을 확인할 수 있다.

 

일정 목록 조회 요청 (검색 조건 - 수정 기간)

더보기
{
    "content": [
        {
            "id": 5,
            "title": "todo3",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T20:00:00",
            "modifiedAt": "2024-11-10T20:00:00"
        },
        {
            "id": 3,
            "title": "todo1",
            "contents": "test",
            "weather": "Sunny",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T15:00:00",
            "modifiedAt": "2024-11-10T18:00:00"
        },
        {
            "id": 4,
            "title": "todo2",
            "contents": "test",
            "weather": "Snowy",
            "user": {
                "id": 1,
                "email": "test@gmail.com",
                "nickname": "default"
            },
            "createdAt": "2024-11-10T16:00:00",
            "modifiedAt": "2024-11-10T16:00:00"
        }
    ],
    "page": {
        "size": 10,
        "number": 0,
        "totalElements": 14,
        "totalPages": 2
    }
}

특정 기간에 수정된 일정들을 조회한 경우이다. 테스트에서는 '2024-11-10' 하루 동안 수정된 일정들을 조회해 보았다. 반환된 모든 일정들의 modifiedAt 은 '2024-11-10' 으로 시작하며 정렬 또한 DB 와 비교해 보니 정확하게 반환되었고, 페이지 정보 또한 잘 반환된 것을 확인할 수 있었다.

 

따로 이미지는 올리지 않았지만 periodStart, periodEnd 를 따로 조건으로 사용해 요청하니 각각 특정 날짜/시간 이후, 이전의 일정들만을 조회하는 것을 확인했다.

 

5-3. 두 개의 조건을 포함만 요청 시

 두 개의 조건을 포함하는 요청을 모두 테스트했지만 '페이지, 수정기간' 조합에 대한 테스트 결과만을 게시글에 남긴다.

일정 목록 조회 요청 (검색 조건 - 페이지, 수정기간)

 

검색 조건에 페이지 및 수정기간을 포함한 요청에 대한 테스트 결과이다. '2024-11-10' 에 수정된 일정 목록을 크기가 2인 페이지로 페이징 했을 때 두 번째 페이지에 노출될 일정 목록이 잘 반환되었고, 페이지 정보 또한 잘 반환된 것을 확인할 수 있었다.

 

5-4. 모든 조건을 포함한 요청 시

일정 목록 조회 요청 (검색 조건 - 페이지, 날씨, 수정기간)

 

모든 조건을 포함한 요청에 대한 테스트 결과이다. 날씨가 'Sunny' 이면서 '2024-11-10' 에 수정된 일정이 없어 빈 목록이 반환되었고 DB 와 비교해봐도 조건에 만족하는 일정이 하나도 없음을 확인했다. 또한 페이지 정보 또한 잘 반환되었다.

 

5-5. 요청에 사용되는 쿼리

 마지막으로 모든 테스트를 진행하며 DB 날리게 되는 쿼리를 확인했는데 아래와 같이 2개의 쿼리를 사용하는 것을 확인했다. 처음 쿼리는 일정 정보와 연관 매핑된 작성자(사용자) 정보를 한번에 조회하는 쿼리이다. 두 번째 쿼리는 TODOS 테이블의 전체 행(레코드)의 개수를 구하는 쿼리이다.

일정 목록 조회 요청시 발생한 쿼리

 

 

6. 마무리

 사실 테스트를 기록하며 페이지 정보가 잘 반환되었다고 했지만 조금더 디테일하게 본다면 완벽하지는 않다. 예를 들어 전체 페이지의 기준을 어떻게 잡느냐에 따라 해당 값은 달라지기 때문이다. 나처럼 TODOS 테이블의 전체 레코드 개수를 사용할 경우 항상 같은 전체 페이지수와 전체 일정 수를 반환 받을 것(TODOS 테이블의 행의 개수에 변환가 없다는 가정하에)이며, 조건에 맞는 TODOS 테이블 레코드 개수를 사용할 경우 검색 조건에 따라 전체 페이지수와 전체 일정 수가 바뀔 것이다.

 

위의 내용을 인지했지만 반영하지 않은 이유는 "혼자 결정하기에는 애매하다" 라는 생각이 들었기 때문이다. 해당 내용은 잘은 모르지만 기획에 포함될 수도 있고 설령 아니더라도 다른 팀원과 소통하여 팀의 규칙에 맞추어 반영하는 것이 좋을 것이라 생각한다. 더욱이 해당 부분에 대한 내용은 요구사항에 명시되어 있지 않으므로 현 상태를 유지하고 이후 진행할 프로젝트 또는 취업후 참여하게될 업무에서 활용해보는 것으로 결론 지었다.