optional한 필터링 조건 대응하기

해로(haero77)·2023년 3월 23일
1

트러블 슈팅

목록 보기
2/6
post-thumbnail

발단


오디고 프로젝트에서 장소를 조회하는 API 를 개발하던 중, 장소의 카테고리 에 따라 필터링을 하도록 API를 설계했다. API의 URI를 설계하는 과정에서 nullable한 쿼리 파라미터의 필요성을 느꼈고, 그에 따라 URI와 컨트롤러 메서드 역시 변경하게 되었다. 본 포스팅에서는 문제 해결 과정 중 왜 nullable한 쿼리 파라미터를 사용하게 되었는지, 그에 따라 구현은 어떻게 변경되었는지를 다룬다.


처음 설계한 URI의 문제점


URI 설계와 구현

특정 지하철역 이름이 주어지면, 해당 역 근처의 음식점 또는 카페를 조회하는 API의 URI를 아래와 같이 설계하였다.

APIURI
지하철역 근처의 음식점 조회GET /api/v1/places?station-name={stationName}&category=RESTAURANT&page={page}&size={size}
지하철역 근처의 카페 조회GET /api/v1/places?station-name={stationName}&category=CAFE&page={page}&size={size}
지하철역 근처의 모든 장소(음식점 + 카페) 조회GET /api/v1/places?station-name={stationName}&category=ALL&page={page}&size={size}

URI 에서 categoryRESTAURANT, CAFE, ALL 일 때 각각 음식점, 카페, 모든 장소(음식점 + 카페) 을 조회하는 것으로 매핑했고, 컨트롤러와 서비스 객체는 다음과 같이 구현했다.

// 컨트롤러 - PlaceApi::getAll
@GetMapping
public ResponseEntity<Page<PlaceQueryResponse>> getAll(
	@RequestParam(name = "station-name") String stationName,
	@RequestParam PlaceCategory category,
	@PageableDefault(sort = "id", direction = Sort.Direction.ASC) Pageable pageable
) {
	Page<PlaceQueryResponse> response = 
		placeQueryService.getAll(stationName, category, pageable);

	return ResponseEntity.ok(response);		
}
// 서비스 - PlaceQueryService::getAll
public Page<PlaceQueryResponse> getAll(
	String stationName, 
	PlaceCategory category, 
	Pageable pageable
) {
	if (placeCategory.isAll()) {
		return getAllByStationName(stationName, pageable);
	}
	return getAllByStationNameAndCategory(stationName, category, pageable);
}
// 장소 카테고리는 Enum으로 관리한다.
public enum PlaceCategory {
	ALL,
	CAFE,
	RESTAURANT;

	// ... 
}

클라이언트가 /api/…category=ALL… 처럼 API를 호출하면 파라미터 categoryPlaceCategory.ALL 로 매핑된다. 컨트롤러 객체는 서비스 객체의 public 메서드인 getAll() 을 호출하고, 서비스 객체 PlaceCategoryvalue 에 따라 어떤 private 메서드를 호출할지 스스로 결정한 후에 컨트롤러 객체에게 DTO를 반환하는 책임을 갖도록 설계했다.

그런데 위 설계에는 한 가지 문제가 있다. URI에 표현된 장소 카테고리 ALL 은 실제로 존재하지 않는 카테고리이기 때문이다. 처음 ALL 을 URI 드러내게 설계했던 것은, RESTAURANTCAFE 를 모두 포함하는 논리적인 개념으로서 사용하기 위함이었다. 그러나 카테고리로 ALL 을 갖는 Place 엔티티는 애플리케이션과 DB 그 어디에도 존재하지 않았으며, 이 잘못된 설계는 끝없이 지저분한 코드를 낳기 시작했다.


잘못된 설계가 낳는 리소스

// 엔티티 - Place::of
public static Place of(
	String name, 
	Address address, 
	String stationName, 
	PlaceCategory category
) {
	if (category.isAll()) {
    	throw new PlaceCategoryNotValidException(
        	"PlaceCategory '%s' is invalid for create Place object.".formatted(category.toString())
        );
    }
	return new Place(name, address, stationName, category);
}

여기 이 끔찍한 코드를 잠깐 살펴보겠다. 존재하지도 않는 카테고리 ALL 을 만들게 되면서, 나는 카테고리 ALL 을 갖는 Place 엔티티가 혹여나 영속화되는 것을 방지하기 위해 파라미터 category의 유효성을 객체가 생성될 때마다 검증해줄 수밖에 없었다. 또한 팩터리 메서드를 만들었으므로, 기존 생성자를 private 으로 객체 생성을 막아 팩터리 메서드 of 만을 이용하여 객체를 생성하도록 할 수밖에 없었다. (보통 생성자를 통해 객체를 생성할 때는 인자로 넘긴 값들이 그대로 필드에 할당되는 것을 기대하기 때문에, 객체 생성에 조금의 의미라도 부여하고자 팩터리 메서드를 사용하였다.)

위와 같이 새로운 코드를 작성할 때마다 카테고리가 ALL 인지 아닌지 매번 확인해야하는 리소스가 들었고, 결국 나는 원인이 잘못된 URI 설계에 있다고 생각했다. 그래서, 모든 장소를 조회하는 경우의 URI를 클라이언트 개발자와 상의 후에 다음과 같이 변경했다.

  • 변경 전: /api/v1/places?station-name={stationName}&category=ALL&page={page}&size={size}
  • 변경 후: /api/v1/places?station-name={stationName}&page={page}&size={size}

이렇게 category 자체를 입력 받지 않도록 변경하니 자연스럽게 존재하지 않는 장소 카테고리가 URI에 표현된다 라는 문제를 해결할 수 있었다. 다만 나는 category를 입력 한 경우/입력하지 않은 경우 모두 하나의 엔드포인트로 관리하도록 이를 필터링 조건으로 보고 싶었고, 이에 category 를 nullable한 쿼리 파라미터로 만들게 되었다.


nullable한 쿼리 파라미터에 대응하자


@RequestParam(required = false) 을 활용

@RequestParam 의 경우 required 옵션의 기본값이 true 로 되어있기 때문에, 변경된 URI (/api/v1/places?station-name={stationName}&page={page}&size={size})를 그대로 사용하면 아래와 같은 오류가 발생한다.

org.springframework.web.bind.MissingServletRequestParameterException: 
Required request parameter 'category' for method parameter type PlaceCategory is not present

필수로 입력해야하는 쿼리 파라미터 category 를 입력하지 않았으므로 발생한 예외이다. 위 문제를 해결하기 위해 required 옵션을 false로 지정했다.

// 컨트롤러 - PlaceApi::getAll
@GetMapping
public ResponseEntity<Page<PlaceQueryResponse>> getAll(
	@RequestParam(name = "station-name") String stationName,
	@RequestParam(required = false) PlaceCategory category, // required 의 false 활성화
	@PageableDefault(sort = "id", direction = Sort.Direction.ASC) Pageable pageable
) {
	Page<PlaceQueryResponse> response = 
			placeQueryService.getAll(stationName, category, pageable);

	return ResponseEntity.ok(response);		
}
// 서비스 - PlaceQueryService::getAll
public Page<PlaceQueryResponse> getAll(
	String stationName, 
	PlaceCategory category, 
	Pageable pageable
) {
	if (placeCategory == null) { // 전: placeCategory.isAll() -> 후: placeCategory == null
    	return getAllByStationName(stationName, pageable);
	}
    return getAllByStationNameAndCategory(stationName, category, pageable);
}

이제 클라이언트가 URI에 category 를 입력하지 않고 API호출을 하게 되면, 위 컨트롤러 메서드 getAll()의 파라미터 category 는 null 이 된다. 그러나 코드의 변경과 함께 null 상태의 변수가 애플리케이션을 떠돌게 되었고, 서비스 객체의 기존 구조(categoryvalue에 따라 어떤 private 메서드를 호출할지 결정)를 유지하기 위해 반강제적인 null 체크 역시 수반될 수 밖에 없었다. 서비스 객체들은 적어도 컨트롤러로부터 넘어온 파라미터들이 null이 아님을 기대해야 자신들의 비즈니스 로직을 편히 수행할 수 있으므로, 나는 현재 구조 컨트롤러가 제 책임을 다 못하고 있는 것으로 느꼈다.


Optional을 활용하자

위 문제를 해결하기위해, 서비스 객체에서 매번 null 체크를 해줘야하는 책임을 컨트롤러가 가져가게 하면서, 아예 애플리케이션 내에서 null 체크를 하지 않는 방향으로 재설계하고 싶었다. 그래서 이번엔 Optional을 사용했다.

// 컨트롤러 - PlaceApi::getAll
@GetMapping
public ResponseEntity<Page<PlaceQueryResponse>> getAll(
	@RequestParam(name = "station-name") String stationName,
	@RequestParam Optional<PlaceCategory> category,
	@PageableDefault(sort = "id", direction = Sort.Direction.ASC) Pageable pageable
) {
	Page<PlaceQueryResponse> response = category
    	.map(placeCategory -> placeQueryService.getAll(stationName, placeCategory, pageable))
        .orElseGet(() -> placeQueryService.getAll(stationName, pageable));

	return ResponseEntity.ok(response);
}

이제 클라이언트가 필터링 조건 category 를 입력하지 않으면, 옵셔널 객체 categoryvaluenull 이 된다. 그리고 Optional이 제공하는 map()orElseGet() 을 이용해 옵셔널 객체의 값의 여부에 따라 서비스 객체에게 다른 메시지를 전하도록 변경했다.

그 결과, 애플리케이션 내에 null 이 돌아다님으로써 생기는 문제(null 체크를 매번 해주거나, NPE 발생 위험성 등)를 해결할 수 있었다. 추가적으로 논리적으로 존재하지 않는 장소 카테고리 ALL 을 애플리케이션 내에서 제거함과 동시에 엔티티 객체 생성 시 이를 검증하는 로직 또한 같이 삭제하여 이전보다 더 안전하고 오해의 소지(ALL 이라는 장소 카테고리가 있으면 DB에도 당연히 ALL이 있겠거니 하고 오해할 수도 있으니까!)가 없도록하는 구조를 만들 수 있었다.


결론


API의 URI를 변경해야한다는 슬랙 메시지를 받은 클라이언트 개발자는 조금 의아해 했을지도 모르겠다. 이미 잘 실행되는 API의 URI를 변경하는 것은 클라이언트 코드 역시 변경사항이 생기기 마련이니까. URI 변경을 설득하기 위해서 내 잘못된 설계로 인한 문제점들을 잘 정리해서 설명드렸고, 이내 흔쾌히 받아들여주셨다. 그리고 이러한 변경 과정을 열심히 공유한 덕분에 변경사항이 배포될 때도 API가 정상적으로 호출됨을 확인할 수 있었다. 이번 경험으로 인해 URI를 설계할 때는 필터링 조건을 어떻게 대응할 것인지 를 미리 생각해두어야한다는 점, 그리고 API 스펙이 변경되는 과정을 클라이언트와 지속적으로 소통해야함을 느낄 수 있었다.


추가적으로 공부해봐야할 것

  • 복수의 필터링 조건에 대응(리스트로 받을 지, 여러 개의 쿼리 파라미터를 쓸 지, RequestBody 를 쓸 지 등)

관련 링크

관련 PR
코드

참고

Optional query parameters in Spring Boot

profile
Every Run, Learn Counts.

0개의 댓글