예약 시스템 API - 되돌아보기

Seyeong·2023년 4월 2일
0
post-thumbnail

지금까지 해왔던 것과 앞으로 해야할 것을 정리해봅시다.

지금까지 한 것

예약 메인 페이지 API

  • 카테고리 목록 구하기
  • 상품 목록 구하기
  • 프로모션 정보 구하기

앞으로 해야할 것

상세페이지 API

  • 전시 상세 정보 구하기
  • 댓글 목록 구하기

지금까지 카테고리, 전시 상품, 프로모션 총 3개의 API를 만들었습니다.

그런데 지금 생각해보면 3개의 도메인으로 이루어져 있는데도 하나의 비즈니스 로직 클래스를 만들고 그곳에 다 담았습니다.

그래서인지 Repository의 경우엔 점점 커지고 여러 도메인들에 대한 코드들이 뒤섞이는 바람에 코드들간의 결합도가 높아지고 수정이 필요할 때 찾기도 번거롭게 되었습니다.

따라서 이번 글에서는 이 부분을 포함하여 프로젝트 전반적으로 리팩토링을 진행해보도록 하겠습니다.

먼저 각 도메인별로 비즈니스 코드를 분리해주겠습니다.

도메인 코드 분리

카테고리

  • 구조 (Diagram)

  • 구현 (Code)

CategoryApiController

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class CategoryApiController {
    private final CategoryService service;

    @GetMapping("/categories")
    public CategoriesResponseDto getCategories() {
        return service.getCategories();
    }
}

CategiryService

@Service
@RequiredArgsConstructor
public class CategoryService {
    private final CategoryRepository repository;

    public CategoriesResponseDto getCategories() {
        return repository.getCategories();
    }
}

CategoryRepository

public interface CategoryRepository {
    CategoriesResponseDto getCategories();
}

JdbcCategoryRepository

@Repository
@RequiredArgsConstructor
public class JdbcCategoryRepository implements CategoryRepository {
    private final JdbcTemplate jdbcTemplate;

    public CategoriesResponseDto getCategories() {
        List<CategoryResponseDto> results = jdbcTemplate.query(
                SQLMapper.SELECT_CATEGORIES_QUERY,
                CategoryResponseDto.categoryMapper);
        return new CategoriesResponseDto(results.size(), results);
    }
}

이렇게 기존에 ReservationXxx 클래스에 존재하던 카테고리 관련 로직들을 카테고리 그룹으로 묶어 따로 분리해주었습니다.

마찬가지로 다른 도메인들도 분리해줍시다.

전시 상품

  • 구조 (Diagram)

  • 구현 (Code)

DisplayInfoApiController

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class DisplayInfoApiController {
    private final DisplayInfoService service;

    @GetMapping("/displayinfos")
    public DisplayInfosResponseDto getDisplayInfos(@RequestBody DisplayInfosRequestDto requestDto) {
        return service.getDisplayInfos(requestDto);
    }
}

DisplayInfoService

@Service
@RequiredArgsConstructor
public class DisplayInfoService {
    private final DisplayInfoRepository repository;

    public DisplayInfosResponseDto getDisplayInfos(DisplayInfosRequestDto requestDto) {
        return repository.getDisplayInfos(requestDto);
    }
}

DisplayInfoRepository

public interface DisplayInfoRepository {
    DisplayInfosResponseDto getDisplayInfos(DisplayInfosRequestDto requestDto);
}

JdbcDisplayInfoRepository

@Repository
@RequiredArgsConstructor
public class JdbcDisplayInfoRepository implements DisplayInfoRepository {
    private static final int SHOW_PRODUCT_COUNT_AMOUNT = 4;

    private final JdbcTemplate jdbcTemplate;

    @Override
    public DisplayInfosResponseDto getDisplayInfos(DisplayInfosRequestDto requestDto) {
        List<DisplayInfoResponseDto> results = jdbcTemplate.query(
                SQLMapper.SELECT_DISPLAY_INFOS_QUERY,
                DisplayInfoResponseDto.displayInfoMapper,
                requestDto.getCategoryId()
        );
        return new DisplayInfosResponseDto(results.size(), SHOW_PRODUCT_COUNT_AMOUNT, getProductCountResult(results, requestDto));
    }

    private List<DisplayInfoResponseDto> getProductCountResult(List<DisplayInfoResponseDto> results, DisplayInfosRequestDto requestDto) {
        int start = requestDto.getStart(); // 조회를 시작할 인덱스
        int end = start + SHOW_PRODUCT_COUNT_AMOUNT ; // 조회를 마칠 인덱스
        if (end >= results.size()) { // 조회할 인덱스가 상품 개수보다 많다면
            end = results.size();
        }

        return IntStream.range(start, end)
                .mapToObj(results::get)
                .collect(Collectors.toList());
    }
}

Display 도메인에 관한 로직을 분리해주었습니다.

이제 프로모션 정보와 관련된 로직을 분리해줍시다.

프로모션 정보

  • 구조 (Diagram)

  • 구현 (Code)

PromotionApiController

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PromotionApiController {
    private final PromotionService service;

    @GetMapping("/promotions")
    public PromotionsResponseDto getPromotions() {
        return service.getPromotions();
    }
}

PromotionService

@Service
@RequiredArgsConstructor
public class PromotionService {
    private final PromotionRepository repository;

    public PromotionsResponseDto getPromotions() {
        return repository.getPromotions();
    }
}

PromotionRepository

public interface PromotionRepository {
    PromotionsResponseDto getPromotions();
}

JdbcPromotionRepository

@Repository
@RequiredArgsConstructor
public class JdbcPromotionRepository implements PromotionRepository {
    private final JdbcTemplate jdbcTemplate;

    @Override
    public PromotionsResponseDto getPromotions() {
        List<PromotionResponseDto> results = jdbcTemplate.query(
                SQLMapper.SELECT_PROMOTIONS_QUERY,
                PromotionResponseDto.promotionMapper
        );
        return PromotionsResponseDto.builder().size(results.size()).items(results).build();
    }
}

이렇게 지금까지 만들었던 3개의 API에 대해서 도메인별로 로직을 분리해주었습니다.

도메인별 SQL 분리

도메인별로 Controller, Service, Repository를 분리해주었는데, 아직 SQL들을 관리하는 유틸리티 클래스인 SQLMapper에는 모든 도메인의 SQL들이 뒤섞여 있습니다.

SQLMapper

public class SQLMapper {
    public static final String SELECT_CATEGORIES_QUERY = "SELECT category.id as id, name, count(category_id) as count from category, product, display_info where category.id = product.category_id and product.id = display_info.product_id group by category_id;";
    public static final String SELECT_DISPLAY_INFOS_QUERY =
            "SELECT p.id, p.category_id categoryId, di.id displayInfoId, c.name, p.description, p.content, p.event, di.opening_hours openingHours, di.place_name placeName, di.place_lot placeLot, di.place_street placeStreet, di.tel, di.homepage, di.email, di.create_date createDate, di.modify_date modifyDate, "
                    + "    ("
                    + "        SELECT fi.id "
                    + "        FROM product_image pi, file_info fi "
                    + "        WHERE pi.file_id = fi.id and pi.product_id = p.id and fi.file_name LIKE CONCAT('%', 'ma' '%') "
                    + "    ) as fileId "
                    + "FROM product p, display_info di, category c "
                    + "WHERE p.id = di.product_id and p.category_id = c.id and p.category_id = ?";

    public static final String SELECT_PROMOTIONS_QUERY =
            "SELECT pm.id id, pd.id productId, pd.category_id categoryId, c.name categoryName, pd.description,\n"
                    + "       (\n"
                    + "           SELECT fi.id\n"
                    + "           FROM product_image pi, file_info fi\n"
                    + "           WHERE pi.file_id = fi.id and pi.product_id = pd.id and fi.file_name LIKE CONCAT('%', 'ma' '%')\n"
                    + "       ) as fileId\n"
                    + "FROM promotion pm, product pd, category c\n"
                    + "WHERE pd.id = pm.product_id AND pd.category_id = c.id";
}

이렇게 각 도메인들이 사용하는 모든 SQL문들을 하나의 클래스에 담게되면 앞으로 계속해서 API를 개발해 나갈수록 더 코드가 복잡해지게 되고 그로인해 유지보수성이 떨어지게 될 겁니다.

따라서 이 부분 또한 각 도메인이 사용하는 SQL은 각자가 담당하도록 책임을 분리해줍시다.

카테고리 SQL

CategorySQLMapper

public class CategorySQLMapper {
    public static final String SELECT_CATEGORIES_QUERY = "SELECT category.id as id, name, count(category_id) as count from category, product, display_info where category.id = product.category_id and product.id = display_info.product_id group by category_id;";
}

전시 상품 SQL

DisplayInfoSQLMapper

public class DisplayInfoSQLMapper {
    public static final String SELECT_DISPLAY_INFOS_QUERY =
            "SELECT p.id, p.category_id categoryId, di.id displayInfoId, c.name, p.description, p.content, p.event, di.opening_hours openingHours, di.place_name placeName, di.place_lot placeLot, di.place_street placeStreet, di.tel, di.homepage, di.email, di.create_date createDate, di.modify_date modifyDate, "
                    + "    ("
                    + "        SELECT fi.id "
                    + "        FROM product_image pi, file_info fi "
                    + "        WHERE pi.file_id = fi.id and pi.product_id = p.id and fi.file_name LIKE CONCAT('%', 'ma' '%') "
                    + "    ) as fileId "
                    + "FROM product p, display_info di, category c "
                    + "WHERE p.id = di.product_id and p.category_id = c.id and p.category_id = ?";
}

프로모션 SQL

PromotionSQLMapper

public class PromotionSQLMapper {
    public static final String SELECT_PROMOTIONS_QUERY =
            "SELECT pm.id id, pd.id productId, pd.category_id categoryId, c.name categoryName, pd.description,\n"
                    + "       (\n"
                    + "           SELECT fi.id\n"
                    + "           FROM product_image pi, file_info fi\n"
                    + "           WHERE pi.file_id = fi.id and pi.product_id = pd.id and fi.file_name LIKE CONCAT('%', 'ma' '%')\n"
                    + "       ) as fileId\n"
                    + "FROM promotion pm, product pd, category c\n"
                    + "WHERE pd.id = pm.product_id AND pd.category_id = c.id";
}

REST Docs 리팩토링

이제 REST Docs를 생성하는 부분을 리팩토링 해주겠습니다.
기존에는 필드값을 기준으로 문서를 생성해 주었으나, 앞으로 만들어야 할 전시 정보 상세에 대한 요청으로는 URI 파라미터를 넘겨주어야 하므로 기존에 사용하던 방식으로는 문서에 요청 파라미터를 표현할 수 없습니다.

기존의 방식은 아래와 같습니다.

requestFields(
	fieldWithPath("path").description("description"))
)

그러나 이번에 해야할 방식은 아래와 같습니다.

pathParameters(
	parameterWithName("name").description("description")
)

이 부분을 유연하게 선택할 수 있도록 만들어줍시다.

먼저 이걸 하기 위해선 파라미터로 들어오는게 HTTP 본문으로 넘어온 것인지, URI 파라미터로 넘어온 것인지 구분해주어야 할 필요가 있습니다.

이를 위해서 RestDocsTemplate 인터페이스를 약간 수정해줄 필요가 있습니다.

RestDocsTemplate

public interface RestDocsTemplate {
    ...

    @JsonIgnore
    boolean isPathParameters();
}

DTO 객체에 존재하는 필드가 경로 파라미터(Path Parameter)인지 아닌지를 구분하는 추상 메서드를 선언해주었습니다.

여기에 @JsonIgnore를 붙여준 이유는 이 인터페이스를 구현한 DTO 객체들은 컨트롤러에서 @RestController 어노테이션에 의해 JSON 형식으로 변환되어 응답될텐데, 이 과정에서 Jackson 라이브러리가 객체 -> JSON 으로 변환할 때 이러한 메서드를 참고하여 필드가 존재한다고 가정하고 응답에 담기 때문입니다.

무슨말이냐면

@Getter
public class CategoriesResponseDto implements RestDocsTemplate {
    private final int size;
    private final List<CategoryResponseDto> items;
}

이러한 코드가 있을 때 size, items 필드들은 Getter에 의해 아래와 같은 메서드들이 생성될 겁니다.

public int getSize() {
	return size;
}

public List<CategoryResponseDto> getItems() {
	return items;
}

이 메서드들을 토대로 Jackson 라이브러리는 반환되는 값인 size와 items를 객체 내부 필드로 가지고 있다고 생각하고 응답으로 반환하게 됩니다.

여기까지는 별 문제가 없죠.

그런데 저희가 추가한 인터페이스의 메서드를 보면

@Getter
public class CategoriesResponseDto implements RestDocsTemplate {
    private final int size;
    private final List<CategoryResponseDto> items;

    @Override
    public boolean isPathParameters() {
        return false;
    }
}

isPathParameters( ) 메서드가 boolean 값을 반환하고 있습니다. 그럼 Jackson 라이브러리는 pathParameters 필드를 객체 내부가 가지고 있다고 판단해서 실제 JSON 응답에 제출해버립니다.

저희는 isPathParameters( ) 라는 메서드 반환 값을 단순히 경로 변수인지 확인하는 용도로만 사용할 것이기 때문에 응답으로 제출할 필요가 없습니다.

따라서 Jackson 라이브러리가 해당 메서드로부터 JSON으로 변환하기 않도록 설정해주기 위해 @JsonIgnore 어노테이션을 붙여주었습니다.

그리고 각 DTO에서 이를 다시 재정의해주어야 합니다.

여기서는 모든 DTO가 재정의하는 것을 따로 보여주진 않을 생각입니다. 지금까지 만든 3개의 API에 해당하는 모든 DTO들은 false를 반환하도록 재정의해주면 됩니다.

그리고 경로 변수와 그렇지 않은 경우에 REST Docs를 생성하는 부분도 변경해주어야 합니다. 변경해줄 코드는 RestDocsGenerator 클래스이며, 기존의 코드는 아래와 같았습니다.

RestDocsGenerator

public class RestDocsGenerator {
    public static RestDocumentationResultHandler generate(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        if (request == null && response == null) {
            return generateNoneFieldsDocument(identifier);
        }
        if (request == null) {
            return generateResponseFieldsDocument(identifier, response);
        }
        if (response == null) {
            return generateRequestFieldsDocument(identifier, request);
        }
        return generateAllFieldsDocument(identifier, request, response);
    }

    private static RestDocumentationResultHandler generateNoneFieldsDocument(String identifier) {
        return document(identifier);
    }

    private static RestDocumentationResultHandler generateResponseFieldsDocument(String identifier, RestDocsTemplate response) {
        return document(identifier,
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields(null)));
    }

    private static RestDocumentationResultHandler generateRequestFieldsDocument(String identifier, RestDocsTemplate request) {
        return document(identifier,
                preprocessRequest(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields(null)));
    }

    private static RestDocumentationResultHandler generateAllFieldsDocument(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        return  document(identifier,
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields(null)),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields(null)));
    }
}

여기서 Request와 Response를 생성하는 부분에 대한 리팩토링을 해주면서 경로 변수일 때와 그렇지 않을 때 문서를 생성하는 부분을 구분해주겠습니다.

먼저 아래와 같은 부분들을 리팩토링 해주어야 합니다.

RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields(null));
RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields(null));

위의 코드들을

getGenerateRequest(request);
getGenerateResponse(response);

private static Snippet getGenerateRequest(RestDocsTemplate request) {
    return RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields(null));
}

private static Snippet getGenerateResponse(RestDocsTemplate response) {
    return RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields(null));
}

이렇게 메서드들로 분리를 해준 뒤, 경로 변수일 떄의 로직을 만들어줍시다.

...

private static Snippet getGenerateRequest(RestDocsTemplate request) {
    if (request.isPathParameters()) {
        return RestDocsParametersGenerator.generate(request.generateRestDocsFields(null));
    }
    return RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields(null));
}

private static Snippet getGenerateResponse(RestDocsTemplate response) {
    if (response.isPathParameters()) {
        return RestDocsParametersGenerator.generate(response.generateRestDocsFields(null));
    }
    return RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields(null));
}

이렇게 리팩토링 된 RestDocsGenerator의 전체 코드입니다.

RestDocsGenerator

public class RestDocsGenerator {
    public static RestDocumentationResultHandler generate(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        if (request == null && response == null) {
            return generateNoneFieldsDocument(identifier);
        }
        if (request == null) {
            return generateResponseFieldsDocument(identifier, response);
        }
        if (response == null) {
            return generateRequestFieldsDocument(identifier, request);
        }
        return generateAllFieldsDocument(identifier, request, response);
    }

    private static RestDocumentationResultHandler generateNoneFieldsDocument(String identifier) {
        return document(identifier);
    }

    private static RestDocumentationResultHandler generateResponseFieldsDocument(String identifier, RestDocsTemplate response) {
        return document(identifier,
                preprocessResponse(prettyPrint()),
                getGenerateResponse(response));
    }

    private static RestDocumentationResultHandler generateRequestFieldsDocument(String identifier, RestDocsTemplate request) {
        return document(identifier,
                preprocessRequest(prettyPrint()),
                getGenerateRequest(request));
    }

    private static RestDocumentationResultHandler generateAllFieldsDocument(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        return  document(identifier,
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                getGenerateRequest(request),
                getGenerateResponse(response));
    }

    private static Snippet getGenerateRequest(RestDocsTemplate request) {
        if (request.isPathParameters()) {
            return RestDocsParametersGenerator.generate(request.generateRestDocsFields(null));
        }
        return RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields(null));
    }

    private static Snippet getGenerateResponse(RestDocsTemplate response) {
        if (response.isPathParameters()) {
            return RestDocsParametersGenerator.generate(response.generateRestDocsFields(null));
        }
        return RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields(null));
    }
}

경로 변수일 때 문서를 생성하는 RestDocsParametersGenerator 는 아래와 같습니다.

public class RestDocsParametersGenerator {
    public static PathParametersSnippet generate(List<RestDocsDto> restDocsDtos) {
        return pathParameters(
                generateParameters(restDocsDtos)
        );
    }

    private static List<ParameterDescriptor> generateParameters(List<RestDocsDto> restDocsDtos) {
        return restDocsDtos.stream()
                .map(dto -> parameterWithName(dto.getPath()).description(dto.getDescription()))
                .collect(Collectors.toList());
    }
}

이제 Path Parameter이든, HTTP 본문이든 관계없이 문서화할 수 있게 되었습니다.

0개의 댓글