본문 바로가기

백엔드

Kok 도메인 API 코드 개선 및 쿼리 최적화 - 집콕 서버 리팩토링(2)

저번에 이어서 계속 코드 리팩토링과 쿼리 최적화에 대한 내용이다. 

저번에는 RealEstate 도메인에서 가장 중요한 GET /realEstate 라는 API 에 대해서 리팩토링을 진행했었다. 저번과 마찬가지로 최우선적으로 수정하고자한 부분은 코드의 객체지향화와 발생 쿼리를 최소한으로 유지시키는 것이 목표였다.

 

이번에는 우리 집콕의 핵심 기능이라고 할 수 있는 Kok 도메인에 대한 API이다.

Kok은 사용자가 발품을 팔면서 느낀점, 사진, 별점, 후기, 체크리스트 등을 등록해두어서, 나중에 다시 보더라도 그때 겪은 느낌을 생생히 다시 전달하여 매물에 대한 비교분석이 쉬워지도록 하는 기능이다. 체크리스트는 사용자 별로 커스텀이 가능하지만 디폴트로 제공되는 항목들이 있어서 매물을 볼 때 어떤 점을 확인해야 하는지 모르는 초보 자취생들도 쉽게 매물을 평가할 수 있도록 도와준다. 

 

Kok 도메인 API에 대한 리팩토링은 '이주 재민는 스터디' 6주차 모임에 대한 내용이다. 우리가 개선해야 할 API는 다음과 같이 총 9개이다. 하나하나 어떤 점을 리팩토링 했는지 적어보려고 한다.

 

Kok 도메인 API 이다.

 

 

Kok 생성 (POST Kok)

Kok을 생성하는 API이다.

사용자는 자신이 발품 판 매물에 대해 Kok을 등록할 수 있다. 이 API는 클라이언트로부터 매물사진, 체크리스트, 후기, 별점 정보를 받아서 각 Table에 데이터를 추가한다. 아래는 API 명세서이다. 

API 명세서

 

 

해프닝

개선하기에 앞서서 API 가 잘 동작하는지 테스트를 해보기 위해서 postman으로 요청을 보내보았는데, 2월 달에는 잘 동작하던 API가 갑자기 동작하지 않았다. 서버에서는  InvalidDataAccessApiUsageException 이라는 오류를 반환했다. 오류 상세 내용을 읽어보면 DB에서 checkedHighlight가 kok을 FK로 가지고 있는데 kok이 생성되지 않아서 FK로 참조할 수 없다는 내용이다. 왜 Kok이 생성되지 않았을까? 

 

스터디에서 공유하니 다들 영문을 모르는 눈치였다. 그래서 Kok을 우선적으로 DB에 생성하는 쿼리를 실행하고 나머지 값들을 생성하는 방식으로 진행하고자 했다. 해결법은 알지만 안되는 이유를 모르는 것이다. 그러다 문득 저번 회차에 진행했던 GET /realEstate API 수정에서 Kok과 Star의 연관관계를 바꾸었던 것이 생각났다. 

 

GET /realEstate API를 리팩토링 하면서 zim,kok 여부를 알기 위해 매물마다 쿼리를 발생시켰던 부분을, User를 조회하며 zim, kok을 fetch join 해서 가져오는 방식으로 수정했는데 이때 Kok에 일대일로 mapping 되어 있는 Star도 같이 fetch join되는 문제가 발생했다. (일대일 관계에서는 EAGER loading이 default이기 때문이다.) 그래서 Star에 대해서 Lazy loading을 명시해주었는데 지연로딩이 반영되지 않았다. 이는 Kok-Star 연관관계에서 주인이 Star 이기 때문에 반영이 되지 않은 것이다. (DB에서 FK를 가지고 있는 쪽이 연관관계의 주인이다.) 즉 Kok 입장에서는 Star 테이블을 따로 조회하지 않는 이상 연관관계를 알 수 없기 때문에 일대일 관계에서 연관관계의 주인이 아닌 쪽에서 지연 로딩은 불가하기 때문이다. 

 

그래서 Kok에서 Star 에 Lazy loading을 적용하기 위해서 DB에서 연관관계의 주인을 바꾸어주었다. (kok이 star_id를 FK로 가지고 있도록 수정했다. )

 

이렇게 연관관계의 주인을 바꾸어 주었기 때문에 Kok은 DB에 생성될 때 star_id가 필수적이게 되었다. 하지만 새로운 Kok을 생성할 때는 참조할 Star 가 필요한데 Star 또한 새로 생성되어야 하고 Kok을 생성될 당시에 Star 는 생성되지 않은 상태이므로 Kok이 참조할 Star_id가 없어서 Kok이 생성되지 못한 것이다. 

 

그래서 아래 코드와 같이, Kok을 생성하기 전에 Star를 먼저 생성해두고 Kok이 생성된 Star를 참조하도록 수정했다. 

Star를 먼저 DB에 저장하고 Kok을 저장하도록 수정

 

개선 전

코드 before refactoring
@Transactional
public PostKokResponse registerKok(long userId, List<MultipartFile> multipartFiles, PostKokRequest postKokRequest) {

    log.info("[KokService.registerKok]");

//try {

    Kok kok = new Kok();

    User user = userRepository.findByUserId(userId);

    RealEstate realEstate = realEstateRepository.findById(postKokRequest.getRealEstateId()).get();

    List<CheckedHighlight> checkedHighlights = postKokRequest.getCheckedHighlights()
            .stream()
            .map(checkedHighlight ->
                    CheckedHighlight.builder()
                            .kok(kok)
                            .highlight(highlightRepository.findByUserAndTitle(user, checkedHighlight))
                            .build())
            .toList();

    List<CheckedFurniture> checkedFurnitures = postKokRequest.getCheckedFurnitureOptions()
            .stream()
            .map(checkedFurniture ->
                    CheckedFurniture.builder()
                            .furnitureOption(furnitureOptionRepository.findByFurnitureName(checkedFurniture))
                            .kok(kok)
                            .build())
            .toList();

    Star star = Star.builder()
            .facilityStar(postKokRequest.getReviewInfo().getFacilityStarCount())
            .infraStar(postKokRequest.getReviewInfo().getInfraStarCount())
            .structureStar(postKokRequest.getReviewInfo().getStructureStarCount())
            .vibeStar(postKokRequest.getReviewInfo().getVibeStarCount())
            .kok(kok)
            .build();

    List<CheckedImpression> checkedImpressions = postKokRequest.getReviewInfo().getCheckedImpressions()
            .stream()
            .map(checkedImpression ->
                    CheckedImpression.builder()
                            .impression(impressionRepository.findByUserAndImpressionTitle(user, checkedImpression))
                            .kok(kok)
                            .build())
            .toList();

    List<PostKokRequest.Option> kokOptions = Stream.of(postKokRequest.getCheckedOuterOptions(), postKokRequest.getCheckedInnerOptions(), postKokRequest.getCheckedContractOptions())
            .flatMap(Collection::stream)
            .toList();


    List<String> stringList = kokOptions.stream().map(option -> {
        return (option.getCheckedDetailOptionIds().toString());
    }).toList();

    List<CheckedOption> checkedOptions = kokOptions.stream().map(kokOption -> CheckedOption.builder()
                    .option(optionRepository.findByOptionId(kokOption.getOptionId()))
                    .kok(kok)
                    .build())
            .toList();


    List<Long> detailOptionIds = kokOptions.stream()
            .flatMap(option -> option.getCheckedDetailOptionIds().stream())
            .collect(Collectors.toList());


    List<CheckedDetailOption> checkedDetailOptions = detailOptionIds.stream()
            .map(id -> CheckedDetailOption.builder()
                    .detailOption(detailOptionRepository.findByDetailOptionId(id))
                    .kok(kok)
                    .build())
            .toList();


    if(multipartFiles != null && !multipartFiles.isEmpty()) {

        List<KokImage> kokImages = multipartFiles.stream()
                .map(file -> {
                    String url = file.getOriginalFilename();
                    OptionCategory category = OptionCategory.OUTER;
                    if (url.contains("OUTER")) {
                        category = OptionCategory.OUTER;
                    } else if (url.contains("INNER")) {
                        category = OptionCategory.INNER;
                    } else if (url.contains("CONTRACT")) {
                        category = OptionCategory.CONTRACT;
                    }

                    url = fileUploadUtils.uploadFile(user.getUserId().toString() + "/" + System.currentTimeMillis(), file);


                    return KokImage.builder()
                            .category(category.getDescription())
                            .imageUrl(url)
                            .kok(kok)
                            .option(null)
                            .build();
                }).collect(Collectors.toList());

        kok.setKokImages(kokImages);
    }

    kok.setDirection(postKokRequest.getDirection());
    kok.setReview(postKokRequest.getReviewInfo().getReviewText());
    kok.setRealEstate(realEstate);
    kok.setUser(user);
    kok.setCheckedFurniturs(checkedFurnitures);
    kok.setCheckedImpressions(checkedImpressions);
    kok.setCheckedHighlights(checkedHighlights);
    kok.setCheckedDetailOptions(checkedDetailOptions);
    kok.setCheckedOptions(checkedOptions);
    kok.setStar(star);

    Long kokId = kokRepository.save(kok).getKokId();


    return new PostKokResponse(kokId);

//} catch (Exception e) {
//    throw new KokException(KOK_REGISTRATION_FAILURE);
//}

}

 

코드를 우선 분석해보자.

가장 큰 문제는 하나의 메서드에 너무 많은 책임이 들어있다는 것이다. registerKok이라는 Service 메서드에서 Kok 사진 등록도 하고, checkedHighlight, checkedImpression, checkedOption, checkedDetailOption, Star 등의 DB 생성도 하고 있다. 우선적으로 이 책임을 메서들 별로 분리시킬 필요가 있을 것 같다. 

 

그리고 Kok 수정 (UPDATE kok) 이라는 API에서 쓰는 Service의 modifyKok 메소드의 로직과 registerKok 메소드의 로직과 거의 흡사해서 중복 코드가 많다. 이것을 디자인 패턴으로 합쳐서 사용할 수 있을 것 같다. 

 

마지막으로 쿼리 문제가 있다. 

현재 checkedHighlight, checkedImpression, checkedFurniture는 string 타입으로 입력받기 때문에 각각 유저가 설정한 Highlight, Impression, Furniture와 일치하는지를 확인하고 저장해야 한다. 이 과정을 stream 에서 DB에 접근하는 쿼리를 발생시키기 때문에 입력으로 들어온 string 의 개수만큼 쿼리가 발생한다. 이를 stream 밖에서 한 번에 조회해두면 쿼리를 하나로 줄일 수 있을 것이다. 

 

 

개선 후

코드 after refactoring
@Transactional
public PostOrPutKokResponse createOrUpdateKok(JwtUserDetails jwtUserDetail, List<MultipartFile> multipartFiles, PostOrPutKokRequest postOrPutKokRequest) {
    log.info("KokService.postOrPutKok");

    Kok kok = settingKok(jwtUserDetail.getUserId(), multipartFiles, postOrPutKokRequest);

    kokRepository.save(kok);

    return PostOrPutKokResponse.builder()
            .kokId(kok.getKokId())
            .build();
}

private Kok settingKok(long userId, List<MultipartFile> multipartFiles, PostOrPutKokRequest postOrPutKokRequest){
    log.info("KokService.settingKok");

    User user = userRepository.findByUserIdWithZimAndKok(userId)
            .orElseThrow(() -> new NoMatchUserException(MEMBER_NOT_FOUND));
    Kok kok = getInitializeKok(user, postOrPutKokRequest);

    if(kok == null){
        throw new KokException(KOK_ID_NOT_FOUND);
    }

    fillKokFieldValue(user, kok, postOrPutKokRequest);
    handleKokImage(user, multipartFiles, kok);

    return kok;
}

private Kok getInitializeKok(User user, PostOrPutKokRequest postOrPutKokRequest) {
    log.info("KokService.getInitializeKok");

    return (postOrPutKokRequest.getKokId() == null)
            ? Kok.builder()
                .realEstate(realEstateRepository.findById(postOrPutKokRequest.getRealEstateId()).get())
                .user(user)
                .checkedHighlights(new ArrayList<>())
                .checkedFurnitures(new ArrayList<>())
                .checkedImpressions(new ArrayList<>())
                .checkedOptions(new LinkedHashSet<>())
                .checkedDetailOptions(new LinkedHashSet<>())
                .kokImages(new ArrayList<>())
                .build()
            : clearKok(Objects.requireNonNull(kokRepository.findById(postOrPutKokRequest.getKokId()).orElse(null)));
}

private Kok clearKok(Kok kok) {
    log.info("KokService.clearKok");

    kok.getCheckedHighlights().clear();
    kok.getCheckedFurnitures().clear();
    kok.getCheckedImpressions().clear();
    kok.getCheckedOptions().clear();
    kok.getCheckedDetailOptions().clear();

    if(!kok.getKokImages().isEmpty()) {
        kok.getKokImages().forEach(kokImage -> fileUploadUtils.deleteFile(extractKeyFromUrl(kokImage.getImageUrl())));
        kok.getKokImages().clear();
    }

    return kok;
}

private void fillKokFieldValue(User user, Kok kok, PostOrPutKokRequest postOrPutKokRequest) {
    log.info("KokService.fillKokFieldValue");

    kok.setDirection(postOrPutKokRequest.getDirection());
    kok.setReview(postOrPutKokRequest.getReviewInfo().getReviewText());

    Star star = Star.builder()
            .facilityStar(postOrPutKokRequest.getReviewInfo().getFacilityStarCount())
            .infraStar(postOrPutKokRequest.getReviewInfo().getInfraStarCount())
            .structureStar(postOrPutKokRequest.getReviewInfo().getStructureStarCount())
            .vibeStar(postOrPutKokRequest.getReviewInfo().getVibeStarCount())
            .build();

    starRepository.save(star);
    kok.setStar(star);

    kokRepository.save(kok);

    postOrPutKokRequest.getCheckedHighlights()
            .forEach(checkedHighlight ->
                    kok.getCheckedHighlights().add(CheckedHighlight.builder()
                            .kok(kok)
                            .highlight(
                                    user.getHighlights().stream()
                                            .filter(highlight -> highlight.getTitle().equals(checkedHighlight))
                                            .findFirst()
                                            .orElse(null)
                            )
                            .build()));

    List<FurnitureOption> furnitureOptionList = furnitureOptionRepository.findAll();
    postOrPutKokRequest.getCheckedFurnitureOptions()
            .forEach(checkedFurniture ->
                    kok.getCheckedFurnitures().add(CheckedFurniture.builder()
                            .furnitureOption(
                                    furnitureOptionList.stream()
                                            .filter(furnitureOption -> furnitureOption.getFurnitureName().equals(checkedFurniture))
                                            .findFirst()
                                            .orElse(null)
                            )
                            .kok(kok)
                            .build()));

    postOrPutKokRequest.getReviewInfo().getCheckedImpressions()
            .forEach(checkedImpression ->
                    kok.getCheckedImpressions().add(CheckedImpression.builder()
                            .impression(user.getImpressions().stream()
                                    .filter(highlight -> highlight.getImpressionTitle().equals(checkedImpression))
                                    .findFirst()
                                    .orElse(null))
                            .kok(kok)
                            .build()));

    List<PostOrPutKokRequest.Option> kokOptions = Stream.of(postOrPutKokRequest.getCheckedOuterOptions(), postOrPutKokRequest.getCheckedInnerOptions(), postOrPutKokRequest.getCheckedContractOptions())
            .flatMap(Collection::stream)
            .toList();

    List<Option> optionList = optionRepository.findAll();
    kokOptions.forEach(kokOption -> kok.getCheckedOptions().add(
            CheckedOption.builder()
                .option(
                    optionList.stream()
                            .filter(option -> option.getOptionId() == kokOption.getOptionId())
                            .findFirst()
                            .orElse(null)
                )
                .kok(kok)
                .build()
        )
    );

    List<Long> detailOptionIds = kokOptions.stream()
            .flatMap(option -> option.getCheckedDetailOptionIds().stream())
            .toList();

    List<DetailOption> detailOptionList = detailOptionRepository.findAll();
    detailOptionIds.forEach(id -> kok.getCheckedDetailOptions().add(
            CheckedDetailOption.builder()
                .detailOption(
                        detailOptionList.stream()
                                .filter(detailOption -> detailOption.getDetailOptionId() == id)
                                .findFirst()
                                .orElse(null)
                )
                .kok(kok)
                .build()
        )
    );

}

private void handleKokImage(User user, List<MultipartFile> multipartFiles, Kok kok) {
    log.info("KokService.handleKokImage");

    if(multipartFiles != null && !multipartFiles.isEmpty()) {

        List<KokImage> kokImages = multipartFiles.stream()
                .map(file -> {
                    String url = file.getOriginalFilename();
                    OptionCategory category = determineCategory(url);

                    url = fileUploadUtils.uploadFile(user.getUserId().toString() + "/" + System.currentTimeMillis(), file);

                    return KokImage.builder()
                            .category(category.getDescription())
                            .imageUrl(url)
                            .kok(kok)
                            .option(null)
                            .build();
                }).toList();

        kok.setKokImages(kokImages);
    }
}

private OptionCategory determineCategory(String url) {
    if (url.contains("OUTER")) {
        return OptionCategory.OUTER;
    } else if (url.contains("INNER")) {
        return OptionCategory.INNER;
    } else if (url.contains("CONTRACT")) {
        return OptionCategory.CONTRACT;
    }
    return OptionCategory.OUTER;
}

 

우선적으로 registerKok 메서드의 책임을 단일화하기 위해서 이미지 저장로직과 필드 값 생성 로직을 각각 handleKokImage와 fillKokFieldValue 메서드로 분리 시켰다. 그외에 자잘한 로직들도 분리시킬 수 있으면 분리 시켰다. 한 가지 아쉬운 점은 fillKokFieldValue도 어떤 field냐에 따라 메서드를 분리 시킬 수 있을 것 같다. 나중에 거슬리면 또 분리시키기로 하고 일단 두었다. 

 

그리고 Kok 수정 (UPDATE Kok) API에서 사용하는 modifyKok 메서드와 로직이 비슷하기 때문에 템플릿-메서드 패턴을 사용해서 registerKok 메서드와 modifyKok 메서드를 병합했다. 각각 Kok 의 유무를 판단해서 로직이 초기 Kok 세팅이 달라지도록 settingKok 메서드에서 분기처리를 해주었다. 

 

마지막으로 쿼리 최적화이다. user model이 가지고 있는 필드 값은 user 필드 값을 접근하는 것으로 바꾸었고 (Highlight, Impression), 나머지는 stream을 돌기 전에 DB에 접근해서 list로 들고 오는 것으로 리팩토링 했다. 그렇게 했더니 hightlight 등에 대한 쿼리가 원래는 stream의 요소 개수만큼 발생되었는데 한 번만 발생됨을 확인할 수 있었다. 그리고 Option이 user 필드를 ManyToOne 으로 가지고 있어서 Eager 로딩 되어서 user에 대한 쿼리가 하나 더 발생되는 것을 확인했다. Option에서 user를 굳이 볼 필요가 없을 것 같아서 Lazy 로딩으로 수정했다.

 

 

TPS 

nGrinder로 API 성능을 측정했다. 

개선 전 TPS 17
개선 후 TPS 23

 

 

크게 별 차이가 없다. 아마 insert 쿼리가 데이터마다 각각 발생되어서 크게 효과를 못본 것 같다. 이건 다음 주차에서 User 도메인 리팩토링에서 Bulk insert를 사용하는데 그때 쓰는 방식으로 해결할 수 있을 것으로 예상된다. 

 

 

 

Kok 수정 (PUT Kok)

이 API는 기존에 존재하는 Kok에 대해서 세부 정보를 수정하는 API 이다. 

위에서 언급했듯이 이 API 를 위한 modifyKok이라는 메서드는 Kok 생성에서 사용하는 registerKok이라는 메서드와 로직이 거의 비슷해서 템플릿-메서드 패턴을 사용해서 리팩토링 했다. 아래는 API 명세서이다. 

API 명세서

 

 

 

Kok 리스트 반환 (GET Kok 무한페이징)

이 API는 사용자가 자신이 등록한 Kok 리스트를 조회할 때 사용하는 API이다. 무한페이징을 사용했다.

 

 

개선 전

코드 before refactoring
@Transactional
public GetKokResponse getKoks(long userId, int page, int size) {

    log.info("[KokService.getKoks]");

    User user = userRepository.findByUserId(userId);

    List<Kok> koks = user.getKoks();

    int startIdx = (page - 1) * size;
    List<Kok> responseKoks = koks.stream()
            .skip(startIdx)
            .limit(size)
            .collect(Collectors.toList());

    boolean isEnd = false;
    if(startIdx + size > koks.size() - 1) {
        isEnd = true;
    }

    int totalPage = (int) Math.ceil((double) koks.size()/size);
    if(page > totalPage) {
        throw new KokException(NO_MORE_KOK_DATA);
    }


    GetKokResponse response = GetKokResponse.builder()
            .koks(responseKoks.stream().map(kok -> GetKokResponse.Koks.builder()
                    .kokId(kok.getKokId())
                    .realEstateId(kok.getRealEstate().getRealEstateId())
                    .imageUrl(Optional.ofNullable(kok.getRealEstate().getRealEstateImages())
                            .filter(images -> !images.isEmpty())
                            .map(images -> images.get(0).getImageUrl())
                            .orElse(null))
                    .address(kok.getRealEstate().getAddress())
                    .detailAddress(kok.getRealEstate().getDetailAddress())
                    .estateAgent(kok.getRealEstate().getAgent())
                    .transactionType(kok.getRealEstate().getTransactionType().toString())
                    .realEstateType(kok.getRealEstate().getRealEstateType().toString())
                    .deposit(kok.getRealEstate().getDeposit())
                    .price(kok.getRealEstate().getPrice())
                    .isZimmed(kok.getRealEstate().getZims().stream().anyMatch(a -> a.equals(zimRepository.findFirstByUserAndRealEstate(user, kok.getRealEstate()))))
                    .build())
                    .collect(Collectors.toList()))
            .meta(GetKokResponse.Meta.builder()
                    .isEnd(isEnd)
                    .currentPage(page)
                    .totalPage(totalPage)
                    .build())
            .build();

    return response;

}

 

현재는 User가 등록한 Kok data 전부를 들고와서 여기서 paging 처리를 해준다. 이는 OOM이 날 가능성도 있으며, 필요한 data만 들고오면 되는 방식과 비교했을 때 너무 손해이다. 하나의 페이지에서 보여질 data만 DB에서 조회하면 더 좋아질 것이다. 

 

그리고 DTO mapping 작업을 service의 getKoks 메서드에서 진행하고 있는데 이는 getKoks에 책임이 여러 개 부여된 것이다. 책임 단일화가 필요하다. 

 

개선 후

코드 after refactoring
@GetMapping("")
@Operation(summary = "유저의 콕리스트 반환", description = "page: 표시할 페이지, size: 페이지당 표시할 데이터 수")
public BaseResponse<GetKokResponse> getKoks(@Parameter(hidden = true) @AuthenticationPrincipal JwtUserDetails jwtUserDetail,
                                            @ParameterObject Pageable pageable) {

    log.info("[KokController.getKoks]");

    return new BaseResponse<>(KOK_LIST_QUERY_SUCCESS, kokService.getKoks(jwtUserDetail, pageable));
}
@Transactional
public GetKokResponse getKoks(JwtUserDetails jwtUserDetail, Pageable pageable) {

    log.info("[KokService.getKoks]");

    List<GetKokWithZimStatus> getKokWithZimStatus = kokRepository.getKokWithZimStatus(jwtUserDetail.getUserId(), pageable);

    return GetKokResponse.from(getKokWithZimStatus);
}
@Query(value = "select new com.project.zipkok.dto.GetKokWithZimStatus(k, " +
        "CASE " +
        "WHEN z.user IS NOT NULL THEN TRUE ELSE FALSE " +
        "END ) " +
        "FROM Kok k " +
        "LEFT JOIN Zim z ON k.user.userId = z.user.userId AND k.realEstate.realEstateId = z.realEstate.realEstateId " +
        "WHERE k.user.userId = :userId ")
Slice<GetKokWithZimStatus> getKokWithZimStatus(@Param("userId") long userId, Pageable pageable);

 

기존에는 User에게 등록된 Kok을 모두 들고와서 Service 메서드 내에서 페이징 처리를 해주었는데, 이것을 Spring Data JPA 에서 제공하는 Pagable 객체를 사용해서 해결했다. 

Pagable은 Paging 처리를 쉽게 하기 위한 것으로 Controller에서 (@ParameterObject Pageable pageable) 라고 인자 값을 지정해주면 자동으로 query parameter에 있는 page=? 와 size=? 정보를 Pagable 객체에 담아서 반환해준다. 이를 Repository에 인자로 전달해주면 알아서 limit 이랑 offset을 사용해서 페이지에 보여질 data만 DB에서 조회해서 반환해준다. 

 

여기서 반환값을 Page로 받을 수도 있다. 이때는 전체 페이지 수를 같이 반환해준다. 전체 페이지 수를 계산하기 위해서는 전체 데이터 개수가 필요하다는 것이고 이를 위한 쿼리가 한 번 더 날아가게 된다. 만약 클라이언트에서 전체 페이지 수를 표시할 필요가 없는 서비스이면 쿼리를 한 번 더 날리는 Page 반환형으로 받는 것보다 필요한 데이터만 조회하는 Slice가 쿼리 발생 측면에서 훨씬 이득일 것이다. 우리는 전체 페이지 인덱스를 보여주지 않으므로 Slice 반환형으로 받았다. 

 

 

TPS

개선 전 TPS 214
개선 후 TPS 512

 

개선 전에는 214였던 TPS가 512 까지 향상 되었다. 유의미하다!

 

 

Kok 세부정보 반환 (GET Kok 기본정보)

이 API는 Kok 리스트에서 Kok을 클릭했을 때 이동하는 콕 상세 정보 페이지에서 보여질 Kok 기본 정보를 반환하는 API이다. 

API 명세서이다.

 

 

개선 전

코드 before refactoring
public GetKokDetailResponse getKokDetail(long userId, long kokId) {

    log.info("[KokService.getKokDetail]");

    User user = userRepository.findByUserId(userId);
    Kok kok = kokRepository.findById(kokId).get();

    boolean isZimmed = false;

    Zim zim = zimRepository.findFirstByUserAndRealEstate(user, kok.getRealEstate());
    if (zim != null) {
        isZimmed = true;
    }

    validateUserAndKok(user, kok);

    GetKokDetailResponse response = GetKokDetailResponse.builder()
            .kokId(kok.getKokId())
            .imageInfo(GetKokDetailResponse.ImageInfo.builder().
                    imageNumber(kok.getKokImages().size())
                    .imageUrls(kok.getKokImages().stream().map(KokImage::getImageUrl).collect(Collectors.toList()))
                    .build())
            .address(kok.getRealEstate().getAddress())
            .detailAddress(kok.getRealEstate().getDetailAddress())
            .transactionType(kok.getRealEstate().getTransactionType().toString())
            .deposit(kok.getRealEstate().getDeposit())
            .price(kok.getRealEstate().getPrice())
            .detail(kok.getRealEstate().getDetail())
            .areaSize(kok.getRealEstate().getAreaSize())
            .pyeongsu((int) kok.getRealEstate().getPyeongsu())
            .realEstateType(kok.getRealEstate().getRealEstateType().toString())
            .floorNum(kok.getRealEstate().getFloorNum())
            .administrativeFee(kok.getRealEstate().getAdministrativeFee())
            .latitude(kok.getRealEstate().getLatitude())
            .longitude(kok.getRealEstate().getLongitude())
            .isZimmed(isZimmed)
            .realEstateId(kok.getRealEstate().getRealEstateId())
            .build();

    return response;
}

 

이것도 DTO mapping 로직이 복잡하다. DTO 에 from 함수를 static으로 만들어서 책임 단일화를 시켜주는 것이 좋을 것 같다.

 

그리고 zim 도 User를 조회하면서 fetch join으로 들고오는 메서드가 있기 때문에 그걸 사용하면 zimRepository를 조회할 필요가 없어진다. 

 

 

개선 후

코드 after refactoring
public GetKokDetailResponse getKokDetail(JwtUserDetails jwtUserDetail, long kokId) {

    log.info("[KokService.getKokDetail]");

    User user = userRepository.findByUserId(jwtUserDetail.getUserId());
    Kok kok = kokRepository.findById(kokId).orElseThrow(() -> new KokException(KOK_ID_NOT_FOUND));

    boolean isZimmed = judgeIsZimmedRealEstate(user, kok.getRealEstate());

    validateUserAndKok(user, kok);

    return GetKokDetailResponse.of(kok, isZimmed);
}

private boolean judgeIsZimmedRealEstate(User user, RealEstate realEstate) {
    log.info("[KokService.judgeIsZimmedRealEstate]");

    return user.getZims().stream().anyMatch(zim -> zim.getRealEstate().getRealEstateId() == realEstate.getRealEstateId());
}

 

이렇게 DTO 매핑을 static of 메서드로 분리 시켰고 해당 매물이 zim 된 매물인지 판단하는 로직을 judgeIsZimmedRealEstate 메서드로 분리시켰다. 그리고 zimRepository를 조회하는 대신 user를 조회할 때 zim을 fetch join해서 쿼리를 하나 줄였다. 

 

TPS

개선 전 TPS 323
개선 후 TPS 317

 

개선 후가 개선 전보다 오히려 줄어들었다. 근데 미미한 차이이다. 비슷하다고 봐도 될 것 같다. 쿼리를 하나 줄이는 것만으로 유의미한 TPS 차이를 보이기는 어려운 것 같다. 

 

 

Kok 세부정보 반환 (GET Kok 집 주변 정보)

이 API는 Kok 세부정보 페이지에서 '집 주변' 이라는 탭을 클릭했을 때 보여지는 정보를 반환하는 API이다. 해당 탭에서는 집 주변 카테고리로 등록한 사진들과 집 주변 하이라이트 태그, 집 주변 체크리스트들을 보여준다. 

API 명세서이다.

 

 

개선 전

코드 before refactoring
public GetKokOuterInfoResponse getKokOuterInfo(long userId, long kokId) {

    log.info("[KokService.getKokOuterInfo]");

    User user = userRepository.findByUserId(userId);
    Kok kok = kokRepository.findById(kokId).get();

    validateUserAndKok(user, kok);

    GetKokOuterInfoResponse response = GetKokOuterInfoResponse.builder()
            .hilights(kok.getCheckedHighlights()
                    .stream()
                    .map(CheckedHighlight::getHighlight)
                    .map(Highlight::getTitle)
                    .collect(Collectors.toList()))
            .options(kok.getCheckedOptions()
                    .stream()
                    .filter(checkedOption -> checkedOption.getOption().getCategory().equals(OptionCategory.OUTER))
                    .filter(checkedOption -> checkedOption.getOption().isVisible())
                    .map(checkedOption -> GetKokOuterInfoResponse.OuterOption.builder()
                            .option(checkedOption.getOption().getName())
                            .orderNumber((int) checkedOption.getOption().getOrderNum())
                            .detailOptions(kok.getCheckedDetailOptions()
                                    .stream()
                                    .filter(checkedDetailOption -> checkedDetailOption.getDetailOption().getOption().equals(checkedOption.getOption()))
                                    .filter(checkedDetailOption -> checkedDetailOption.getDetailOption().isVisible())
                                    .map(CheckedDetailOption::getDetailOption)
                                    .map(DetailOption::getName)
                                    .collect(Collectors.toList()))
                            .build())
                    .collect(Collectors.toList()))
            .build();

    return response;
}

 

똑같이 DTO mapping 하는 로직을 분리하고 checkedHighlight랑 checkedOption을 fetch join으로 들고와서 쿼리를 줄일 수 있을 것 같다. 

 

 

개선 후

코드 after refactoring
public GetKokOuterInfoResponse getKokOuterInfo(JwtUserDetails jwtUserDetail, long kokId) {

    log.info("[KokService.getKokOuterInfo]");

    User user = userRepository.findByUserId(jwtUserDetail.getUserId());
    Kok kok = kokRepository.findKokWithCheckedOptionAndCheckedDetailOption(kokId);

    validateUserAndKok(user, kok);

    return GetKokOuterInfoResponse.of(kok);
}

 

코드의 길이가 훨씬 줄었음을 확인할 수 있다. DTO mapping 로직을 static of 메서드로 분리시켰고, kok을 조회할 때 checkedOption이랑 checkedDetailOprion을 fetch  join해서 들고 오는 걸로 수정했다. 

 

TPS

개선 전 TPS 128
개선 후 TPS 501

 

TPS 가 눈에 띄게 좋아졌다... 크게 수정한 것도 없는데 왜? nGrinder를 내가 잘못쓰고 있는 건지, 아니면 네트워크 차이인 건지 나중에 좀 더 알아봐야 겠다. 일단 쿼리를 줄였다는 점에서 의의를 가지고 nGrinder TPS는 너무 신경쓰지 말자.

 

 

Kok 세부정보 반환 (GET Kok 집 내부 정보)

이 API는 위의 API 와 비슷하게 Kok 세부정보 페이지에서 '집 내부' 이라는 탭을 클릭했을 때 보여지는 정보를 반환하는 API이다. 해당 탭에서는 집 내부 카테고리로 등록한 사진들과 집 내부 가구 태그, 집 내부 체크리스트들을 보여준다. 

API 명세서이다.

 

개선 전

코드 before refactoring
public GetKokInnerInfoResponse getKokInnerInfo(long userId, long kokId) {

    log.info("[KokService.getKokInnerInfo]");

    User user = userRepository.findByUserId(userId);
    Kok kok = kokRepository.findById(kokId).get();

    validateUserAndKok(user, kok);

    GetKokInnerInfoResponse response = GetKokInnerInfoResponse.builder()
            .furnitureOptions(kok.getCheckedFurniturs()
                    .stream()
                    .map(CheckedFurniture::getFurnitureOption)
                    .map(FurnitureOption::getFurnitureName)
                    .collect(Collectors.toList()))
            .direction(kok.getDirection())
            .options(kok.getCheckedOptions()
                    .stream()
                    .filter(checkedOption -> checkedOption.getOption().getCategory().equals(OptionCategory.INNER))
                    .filter(checkedOption -> checkedOption.getOption().isVisible())
                    .map(checkedOption -> GetKokInnerInfoResponse.InnerOption.builder()
                            .option(checkedOption.getOption().getName())
                            .orderNumber((int) checkedOption.getOption().getOrderNum())
                            .detailOptions(kok.getCheckedDetailOptions()
                                    .stream()
                                    .filter(checkedDetailOption -> checkedDetailOption.getDetailOption().getOption().equals(checkedOption.getOption()))
                                    .filter(checkedDetailOption -> checkedDetailOption.getDetailOption().isVisible())
                                    .map(CheckedDetailOption::getDetailOption)
                                    .map(DetailOption::getName)
                                    .collect(Collectors.toList()))
                            .build())
                    .collect(Collectors.toList()))
            .build();

    return response;
}

 

얘도 위의 API 와 똑같이 DTO mapping 하는 로직을 분리하고 checkedFurniture이랑 checkedOption을 fetch join으로 들고와서 쿼리를 줄일 수 있을 것 같다.

 

개선 후

코드 after refactoring
public GetKokInnerInfoResponse getKokInnerInfo(JwtUserDetails jwtUserDetail, long kokId) {

    log.info("[KokService.getKokInnerInfo]");

    User user = userRepository.findByUserId(jwtUserDetail.getUserId());
    Kok kok = kokRepository.findKokWithCheckedOptionAndCheckedDetailOption(kokId);

    validateUserAndKok(user, kok);

    return GetKokInnerInfoResponse.of(kok);
}

 

위의 API 와 마찬가지로 코드의 길이가 훨씬 줄었음을 확인할 수 있다. DTO mapping 로직을 static of 메서드로 분리시켰고, kok을 조회할 때 checkedOption이랑 checkedDetailOprion을 fetch  join해서 들고 오는 걸로 수정했다. 

 

TPS

개선 전 TPS 122
개선 후 TPS 514

 

이것도 크게 변경한 것 없는데 눈에 띄게 좋아진 TPS... 잘하고 있는거겠지?

 

 

Kok 세부정보 반환 (GET Kok 집 거래 정보)

이 API는 위의 API 들과 비슷하게 Kok 세부정보 페이지에서 '집 거래 정보' 이라는 탭을 클릭했을 때 보여지는 정보를 반환하는 API이다. 해당 탭에서는 집 거래 정보 카테고리로 등록한 사진들과 집 거래 체크리스트들을 보여준다. 

API 명세서이다.

 

개선 전

코드 before refactoring
public GetKokContractResponse getKokContractInfo(long userId, long kokId) {

    log.info("[KokService.getKokContractInfo]");

    User user = userRepository.findByUserId(userId);
    Kok kok = kokRepository.findById(kokId).get();

    validateUserAndKok(user, kok);

    List<String> contractImages = kok.getKokImages()
            .stream()
            .filter(kokImage -> kokImage.getCategory().equals(OptionCategory.CONTRACT.getDescription()))
            .map(KokImage::getImageUrl)
            .toList();

    GetKokContractResponse response = GetKokContractResponse.builder()
            .options(kok.getCheckedOptions()
                    .stream()
                    .filter(checkedOption -> checkedOption.getOption().getCategory().equals(OptionCategory.CONTRACT))
                    .filter(checkedOption -> checkedOption.getOption().isVisible())
                    .map(checkedOption -> GetKokContractResponse.ContractOptions.builder()
                            .option(checkedOption.getOption().getName())
                            .orderNumber((int) checkedOption.getOption().getOrderNum())
                            .detailOptions(kok.getCheckedDetailOptions()
                                    .stream()
                                    .filter(checkedDetailOption -> checkedDetailOption.getDetailOption().getOption().equals(checkedOption.getOption()))
                                    .filter(checkedDetailOption -> checkedDetailOption.getDetailOption().isVisible())
                                    .map(CheckedDetailOption::getDetailOption)
                                    .map(DetailOption::getName)
                                    .collect(Collectors.toList()))
                            .build())
                    .collect(Collectors.toList()))
            .imageInfo(GetKokContractResponse.ImageInfo.builder()
                    .imageNumber(contractImages.size())
                    .imageUrls(contractImages)
                    .build())
            .build();

    return response;
}

 

얘도 위의 API 들과 같다.

 

개선 후

코드 after refactoring
public GetKokContractResponse getKokContractInfo(JwtUserDetails jwtUserDetail, long kokId) {

    log.info("[KokService.getKokContractInfo]");

    User user = userRepository.findByUserId(jwtUserDetail.getUserId());
    Kok kok = kokRepository.findKokWithCheckedOptionAndCheckedDetailOption(kokId);

    validateUserAndKok(user, kok);

    return GetKokContractResponse.of(kok);
}

 

위의 API 들과 내용이 동일하다. 

현재 보면 '집 주변', '집 내부' '집 거래 정보' API들은 모두 카테고리만 다를 뿐 같은 로직이다. 각각 디자인 패턴을 적용해서 코드 중복을 줄일 수 있을 것 같다. 

 

TPS

개선 전 TPS 126
개선 후 TPS 499

 

엄청 좋아졌다.. 집 주변/내부/거래정보 카테고리 API 전부 큰 수정이 없었지만 TPS가 확연히 좋아졌음을 확인할 수 있다. 

 

 

Kok 세부정보 반환 (GET Kok 집 발품 후기 정보)

이 API는 위의 API 들과 비슷하게 Kok 세부정보 페이지에서 '후기' 라는 탭을 클릭했을 때 보여지는 정보를 반환하는 API이다. 해당 탭에서는 후기 카테고리로 등록한 사진들과 각 항목에 체크한 별점들을 보여준다.

API 명세서이다.

 

개선 전

코드 before refactoring
public GetKokReviewInfoResponse getKokReviewInfo(long userId, long kokId) {

    log.info("[KokService.getKokContractInfo]");

    User user = userRepository.findByUserId(userId);
    Kok kok = kokRepository.findById(kokId).get();

    validateUserAndKok(user, kok);

    GetKokReviewInfoResponse response = GetKokReviewInfoResponse.builder()
            .impressions(kok.getCheckedImpressions().stream().map(checkedImpression -> checkedImpression.getImpression().getImpressionTitle()).collect(Collectors.toList()))
            .facilityStarCount(kok.getStar().getFacilityStar())
            .infraStarCount(kok.getStar().getInfraStar())
            .structureStarCount(kok.getStar().getStructureStar())
            .vibeStarCount(kok.getStar().getVibeStar())
            .reviewText(kok.getReview())
            .build();

    return response;
}

 

똑같이 DTO 매핑 로직을 분리 시킬 수 있을 것 같다. 쿼리도 kok을 조회할 때 checkedImpression이랑 star를 fetch join하면 될 것 같다. 

 

개선 후

코드 after refactoring
public GetKokReviewInfoResponse getKokReviewInfo(JwtUserDetails jwtUserDetail, long kokId) {

    log.info("[KokService.getKokReviewInfo]");

    User user = userRepository.findByUserId(jwtUserDetail.getUserId());
    Kok kok = kokRepository.findKokWithImpressionAndStar(kokId);

    validateUserAndKok(user, kok);

    return GetKokReviewInfoResponse.of(kok);
}

 

 

TPS

개선 전 TPS 196
개선 후 TPS 1353

 

TPS가 1353이나 나왔다... 이런적은 처음이다. 애초에 Review API가 가볍다고 생각했는데 1300을 넘을 줄은 몰랐다. 

 

 

유저가 설정한 Kok 설정 정보 반환 (GET Kok 설정 정보 반환) → 새 콕 작성 페이지 또는 기존 콕 수정 페이지에서 설정 정보를 불러오기 위한 API

유저마다 콕 설정 정보를 커스텀 할 수 있다. Highlight나 Furniture, Impression의 태그를 추가하거나 Option, DetailOption을 추가/순서변경/숨김처리가 가능하다. 콕 작성을 할 때 이런 설정 정보를 화면에 띄워주어야 하는데 이때 사용되는 API 이다. 

API 명세서이다.

 

개선 전

코드 before refactoring
  public GetKokConfigInfoResponse getKokConfigInfo(long userId, Long kokId) {

    log.info("[KokService.getKokConfigInfo]");

    User user = userRepository.findByUserId(userId);

    if(kokId != null) {
        Kok kok = kokRepository.findByKokId(kokId);
        validateUserAndKok(user, kok);
        return makeKokConfigResponse(user, kok);
    }

    return makeKokConfigResponse(user, null);

}

private GetKokConfigInfoResponse makeKokConfigResponse(User user, Kok kok) {

    List<String> hilightsResponse = makeHilightTitleList(user.getHighlights());
    List<String> checkedHilightsResponse = null;
    List<String> furnitureOptionsResponse = makeFurnitureNameList(furnitureOptionRepository.findAll());
    List<String> checkedFurinirureOptionsResponse = null;
    GetKokConfigInfoResponse.ReviewInfo reviewInfoResponse = null;
    String directionResponse = null;

    List<String> outerKokImagesResponse = null;
    List<String> innerKokImagesResponse = null;
    List<String> contractKokImagesResponse = null;

    List<GetKokConfigInfoResponse.Option> outerOptionsResponse = makeOptionResponseList(filterOption(user.getOptions(), OptionCategory.OUTER), kok);
    List<GetKokConfigInfoResponse.Option> innerOptionsResponse = makeOptionResponseList(filterOption(user.getOptions(), OptionCategory.INNER), kok);
    List<GetKokConfigInfoResponse.Option> contractOptionsResponse = makeOptionResponseList(filterOption(user.getOptions(), OptionCategory.CONTRACT), kok);

    if (kok != null) {
        checkedHilightsResponse = makeHilightTitleList(kok.getCheckedHighlights().stream().map(CheckedHighlight::getHighlight).toList());
        checkedFurinirureOptionsResponse = makeFurnitureNameList(kok.getCheckedFurniturs().stream().map(CheckedFurniture::getFurnitureOption).toList());
        reviewInfoResponse = makeReviewInfoResponseList(user, kok);
        directionResponse = kok.getDirection();
        outerKokImagesResponse = makeKokImagesUrlList(kok.getKokImages(), OptionCategory.OUTER);
        innerKokImagesResponse = makeKokImagesUrlList(kok.getKokImages(), OptionCategory.INNER);
        contractKokImagesResponse = makeKokImagesUrlList(kok.getKokImages(), OptionCategory.CONTRACT);
    }


    GetKokConfigInfoResponse response = GetKokConfigInfoResponse.builder()
            .hilights(hilightsResponse)
            .checkedHilights(checkedHilightsResponse)
            .furnitureOptions(furnitureOptionsResponse)
            .checkedFurnitureOptions(checkedFurinirureOptionsResponse)
            .reviewInfo(reviewInfoResponse)
            .direction(directionResponse)
            .outerImageUrls(outerKokImagesResponse)
            .innerImageUrls(innerKokImagesResponse)
            .contractImageUrls(contractKokImagesResponse)
            .outerOptions(outerOptionsResponse)
            .innerOptions(innerOptionsResponse)
            .contractOptions(contractOptionsResponse)
            .build();

    return response;
}

private List<String> makeKokImagesUrlList(List<KokImage> kokImages, OptionCategory optionCategory) {
    if (kokImages != null) {
        List<String> urlList = kokImages.stream()
                .filter(kokImage -> kokImage.getCategory().equals(optionCategory.getDescription()))
                .map(KokImage::getImageUrl)
                .toList();
        return urlList;
    }
    return null;
}


private static GetKokConfigInfoResponse.ReviewInfo makeReviewInfoResponseList(User user, Kok kok) {
    GetKokConfigInfoResponse.ReviewInfo reviewInfoResponse = GetKokConfigInfoResponse.ReviewInfo.builder()
            .impressions(user.getImpressions().stream().map(Impression::getImpressionTitle).collect(Collectors.toList()))
            .checkedImpressions(kok.getCheckedImpressions().stream().map(CheckedImpression::getImpression).map(Impression::getImpressionTitle).collect(Collectors.toList()))
            .facilityStarCount(kok.getStar().getFacilityStar())
            .infraStarCount(kok.getStar().getInfraStar())
            .structureStarCount(kok.getStar().getStructureStar())
            .vibeStarCount(kok.getStar().getVibeStar())
            .reviewText(kok.getReview())
            .build();
    return reviewInfoResponse;
}

private static void validateUserAndKok(User user, Kok kok) {

    if (kok == null) {
        throw new KokException(KOK_ID_NOT_FOUND);
    }

    if (!kok.getUser().equals(user)) {
        throw new KokException(INVALID_KOK_ACCESS);
    }
}

private List<Option> filterOption(List<Option> optionList, OptionCategory category) {
    List<Option> filteredOptions = optionList
            .stream()
            .filter(option -> option.getCategory().equals(category) && option.isVisible())
            .toList();

    return filteredOptions;
}

private List<GetKokConfigInfoResponse.Option> makeOptionResponseList(List<Option> options, Kok kok) {

    List<GetKokConfigInfoResponse.Option> response = options.stream().map(option -> GetKokConfigInfoResponse.Option.builder()
                    .optionId(option.getOptionId())
                    .optionTitle(option.getName())
                    .orderNumber((int) option.getOrderNum())
                    .isChecked(Optional.ofNullable(kok)
                            .map(k -> k.getCheckedOptions().stream()
                                    .anyMatch(checkedOption -> checkedOption.getOption().equals(option)))
                            .orElse(false))
                    .detailOptions(option.getDetailOptions()
                            .stream()
                            .filter(DetailOption::isVisible)
                            .map(detailOption -> GetKokConfigInfoResponse.DetailOption.builder()
                                    .detailOptionId(detailOption.getDetailOptionId())
                                    .detailOptionTitle(detailOption.getName())
                                    .isChecked(Optional.ofNullable(kok)
                                            .map(k -> k.getCheckedDetailOptions().stream()
                                                    .anyMatch(checkedDetailOption -> checkedDetailOption.getDetailOption().equals(detailOption)))
                                            .orElse(false))
                                    .build())
                            .collect(Collectors.toList()))
                    .build())
            .toList();
    return response;
}

private static List<String> makeHilightTitleList(List<Highlight> highlights) {
    List<String> hilightsResponse = highlights
            .stream()
            .map(Highlight::getTitle)
            .toList();
    return hilightsResponse;
}

private static List<String> makeFurnitureNameList(List<FurnitureOption> furnitures) {
    List<String> furnitureStringList = furnitures
            .stream()
            .map(FurnitureOption::getFurnitureName)
            .toList();
    return furnitureStringList;
}

 

이 API 는 초기에 짤 때 조회해야 하는 테이블도 많고, 복잡해서 메소드 분리를 잘해두었다. 그래서 코드를 딱히 건드리지 않았다. 

 

 

 

결론

우리 서비스의 핵심 기능인 Kok 도메인에 해당하는 API 들에 대해서 코드 리팩토링 및 쿼리 최적화를 마쳤다. 

각자 브랜치를 파서 각자의 입맛대로 수정해보고 스터디 때 공유하면서 각자가 짠 방식을 비교 분석 하며 모두가 동의하는 방식을 main 브랜치에 merge했다. 

Spring Data JPA 에서 제공하는 Pagable 객체를 사용하면 페이징 처리를 좀 더 쉽게 할 수 있다는 점을 처음 알게 되었고, 가상컬럼을 사용하는 JPQL 도 새롭게 알게 되었다. 

메서드 별로 책임 단일화를 하면서 남의 코드를 읽기 쉽다는 것을 몸소 느꼈다. 그리고 쿼리 최적화에 따른 TPS 도 눈에 띄게 늘어서 나름 뿌듯하다. 

 

다음주는 User 도메인에 대한 API 리팩토링이다. User 도메인도 Kok 과 마찬가지로 쉽지만은 않을 것으로 예상된다. 계속 꾸준히 하자. 파이팅!