[Spring Boot] 도메인형 패키지 구조를 통한 문제 해결

boms·2023년 9월 13일

Problem 🤔

음식점 storeId를 파라미터로 받아 해당 음식점의 메뉴 리스트를 response로 GET하는 search api를 개발 중이다. 아래는 storeId 1의 메뉴 리스트를 반환한 데이터다. 그런데 name 필드 값에 null이 들어가 문제 원인을 찾아야 했다.

{
  "result": {
    "result_code": 200,
    "result_message": "성공",
    "result_description": "성공"
  },
  "body": [
    {
      "id": 1,
      "store_id": 1,
      "name": null,
      "amount": 3000,
      "status": "REGISTERED",
      "thumbnail_url": "https://chingchanmall.com/web/product/big/1652667152924l0.jpg",
      "like_count": 0,
      "sequence": 0
    },
    {
      "id": 2,
      "store_id": 1,
      "name": null,
      "amount": 4000,
      "status": "REGISTERED",
      "thumbnail_url": "https://chingchanmall.com/web/product/big/1652667152924l0.jpg",
      "like_count": 0,
      "sequence": 0
    }
  ]
}
}

Solution 🙌

이 문제는 아래와 같은 도메인형 패키지 구조 덕분에 직감적으로 빠르게 해결 할 수 있었다. 아래 어느 영역에서 문제가 생겨 name에 null값이 들어간 것 일까?

  1. business는 여러 서비스의 로직을 결합하여 나온 결과를 response dto로 내려주는 영역이다.
  2. controller는 외부 요청에 따른 동작을 수행하며 business로 부터 response dto를 받아 처리하는 영역이다.
  3. converter는 request->entity와 entity->response로의 변환을 담당하는 영역이다.
  4. service는 해당 도메인 로직만 처리 할 수 있도록 entity와 service 로직을 담당하는 영역이다.

필드에 null이 들어간 실수라면 변환 과정에서 값을 옮겨와 빌드하는 3번 converter가 유력하다. 검증을 위해 search 로직의 흐름을 알아보자.

  1. controller에서 파라미터로 받은 storeId로 search.
@RestController
@RequestMapping("/open-api/store-menu")
@RequiredArgsConstructor
public class StoreMenuApiController {
    private final StoreMenuBusiness storeMenuBusiness;

    @GetMapping("/search")
    public Api<List<StoreMenuResponse>> search(
            @RequestParam Long storeId
    ){
        var response = storeMenuBusiness.search(storeId);
        return Api.OK(response);
    }
}
  1. business에서 service의 search 기능 호출.
@RequiredArgsConstructor
@Business
public class StoreMenuBusiness {
    private final StoreMenuService storeMenuService;
    private final StoreMenuConverter storeMenuConverter;

    public List<StoreMenuResponse> search(
            Long storeId
    ){
        var list = storeMenuService.getStoreMenuByStoreId(storeId);
        return list.stream()
                .map(it->{
                    return storeMenuConverter.toResponse(it);
                })
                .collect(Collectors.toList());
    }


}
  1. service의 search에서 repository에 접근하여 해당 storeId를 가진 storeMenuEntity list 반환.
@Service
@RequiredArgsConstructor
public class StoreMenuService {
    private final StoreMenuRepository storeMenuRepository;

    public List<StoreMenuEntity> getStoreMenuByStoreId(Long storeId){
        return storeMenuRepository.findAllByStoreIdAndStatusOrderBySequenceDesc(storeId,StoreMenuStatus.REGISTERED);
    }
    public StoreMenuEntity register(
            StoreMenuEntity storeMenuEntity
    ){
        return Optional.ofNullable(storeMenuEntity)
                .map(it->{
                    it.setStatus(StoreMenuStatus.REGISTERED);
                    return storeMenuRepository.save(it);
                })
                .orElseThrow(()->new ApiException(ErrorCode.NULL_POINT));
    }
}
  1. business에서 converter로 list의 elements를 response 모델로 변환 후 반환.

  2. controller에서 storeMenuResponse list 반환.

5번에 list가 내려오기까지 내부 객체에 접근하여 수정한 것은 4번 business에서 사용한 converter 밖에 없다. 아래는 converter 패키지의 StoreMenuConverter 클래스다.

@Converter
public class StoreMenuConverter {
    public StoreMenuEntity toEntity(StoreMenuRegisterRequest request){
        return Optional.ofNullable(request)
                .map(it->{
                    return StoreMenuEntity.builder()
                            .storeId(it.getStoreId())
                            .name(it.getName())
                            .thumbnailUrl(it.getThumbnailUrl())
                            .price(it.getAmount())
                            .build();

                })
                .orElseThrow(()->new ApiException(ErrorCode.NULL_POINT));
    }

    public StoreMenuResponse toResponse(StoreMenuEntity entity){
        return Optional.ofNullable(entity)
                .map(it->{
                    return StoreMenuResponse.builder()
                            .id(it.getId())
                            .storeId(it.getStoreId())
                            .amount(it.getPrice())
                            .status(it.getStatus())
                            .thumbnailUrl(it.getThumbnailUrl())
                            .likeCount(it.getLike_count())
                            .sequence(it.getSequence())
                            .build();

                })
                .orElseThrow(()->new ApiException(ErrorCode.NULL_POINT));
    }
}

역시나 toResponse에서 name을 매핑하지 않은 것을 확인 할 수 있었다. 즉 4번에서 list속 entity를 response 모델로 변환하는데 name 필드에 null이 들어간 채 build 된 것이다. 아래는 수정 후 결과다.

{
  "result": {
    "result_code": 200,
    "result_message": "성공",
    "result_description": "성공"
  },
  "body": [
    {
      "id": 1,
      "store_id": 1,
      "name": "ice americano",
      "amount": 3000,
      "status": "REGISTERED",
      "thumbnail_url": "https://chingchanmall.com/web/product/big/1652667152924l0.jpg",
      "like_count": 0,
      "sequence": 0
    },
    {
      "id": 2,
      "store_id": 1,
      "name": "ice caffe latte",
      "amount": 4000,
      "status": "REGISTERED",
      "thumbnail_url": "https://chingchanmall.com/web/product/big/1652667152924l0.jpg",
      "like_count": 0,
      "sequence": 0
    }
  ]
}

storeId 1에 대한 store menu list가 잘 반환된 것을 확인 할 수 있다.

Takeaway 📝

  • 패키지별로 관심사를 명확하게 나눴더니 도메인 흐름을 파악하기 쉬워 로직에 문제가 생겼을 때 어느 패키지를 수정할지 빠르게 판단 할 수 있었다.
  • 앞으로 이런 오류를 해결하기 위해 패키지 구조를 잘 지키려고 노력하는 계기가 되었다.
profile
2023.08.21~

0개의 댓글