본문 바로가기
Projects/OpenRoadmaps

[OpenRoadmaps] 5. 로드맵 API 구현 및 테스트

by DevJaewoo 2023. 1. 5.
반응형

Intro

로드맵 기능은 사용자가 로드맵 편집기를 통해 트리 형태로 자신만의 로드맵을 만들고, 다른 사람이 만든 로드맵을 보고, 로드맵의 각 항목을 따라 공부할 수 있게 해주는 기능이다.

로드맵 편집기
로드맵 편집기, 원하는 위치에 항목을 추가하고 내용을 수정할 수 있음

프론트엔드에서 로드맵을 생성하고 조회하기 위해 백엔드에서 로드맵 관련 API를 만들었다.

구현한 API 목록은 다음과 같다.

 

  • 로드맵 생성
  • 로드맵 검색
  • 로드맵 조회
  • 로드맵 추천
  • 로드맵 항목 공부 완료

구현한 코드를 일일이 설명하진 않을거고, 개발하면서 짚고 넘어가야 된다고 생각되는 것들을 정리해보도록 하겠다.

 

소스코드는 Github에 업로드 되어있습니다.

 

GitHub - DevJaewoo/OpenRoadmaps

Contribute to DevJaewoo/OpenRoadmaps development by creating an account on GitHub.

github.com


DB 구조

아래 사진은 로드맵 기능 구현 완료 후 시점의 E-R 다이어그램이다.

E-R 다이어그램은 ERDCloud에서 볼 수 있다.

https://www.erdcloud.com/d/P2TMjGDoCdhjMEG3S

 

OpenRoadmaps

Draw ERD with your team members. All states are shared in real time. And it's FREE. Database modeling tool.

www.erdcloud.com

E-R Diagram

기능을 구현하기 위한 정보들이 들어있고, 특징은 다음과 같다.

  • RoadmapItem 테이블은 트리 구조를 만들기 위해 자기 자신을 FK로 갖도록 구성했다.
  • RoadmapItem 테이블엔 로드맵 항목의 위치를 저장하기 위한 X, Y 필드가 있다. 단위는 REM이다.
  • RoadmapLike와 RoadmapItemClear는 사용자의 좋아요 / 완료 유무에 따라 수시로 Insert / Delete 하는 것이 아닌, 처음 값이 설정될 때만 Insert하고 이후 변경이 생겼을 때는 테이블의 like / clear 필드의 boolean 값을 Update시켜주는 구조로 작성했다.

 

아래 다이어그램은 코드로 구현하기 전 설계한 다이어그램이다.

Old Diagram
초기 설계

초기 설계에는 로드맵 항목의 X, Y 좌표, 로드맵 좋아요 수, 레퍼런스 등 기능을 구현하기 위해 당연히 들어갔어야 할 정보들이 반영되지 않아 기능을 구현하며 많이 갈아엎게 되었다. 기능 구현 시간이 조금 줄어들더라도 설계를 꼼꼼히 하는것이 결과적으로 시간을 단축시켜준다는 것을 깨달았다.


API 구조

API DTO는 Class가 아닌 Record로 작성했다. 그 이유는 아래 글에 별도로 작성했다.

2022.11.30 - [Study/고민] - [비교] DTO Class vs Record

 

[비교] DTO Class vs Record

DTO Class vs Record 기존에 Java 11만 쓰다가 이번 프로젝트에서 Java 17을 처음 써보는데, 클래스 생성 중 Record라는 메뉴가 생겨서 찾아보니 DTO로 쓰기 딱이라는 생각이 들었다. Record는 Java 16에서 정식

devjaewoo.tistory.com

public record CreateRequest(
        @NotNull
        @Size(min = 1, max = 50)
        String title,
        String image,
        Accessibility accessibility,
        @NotNull List<RoadmapItemDto.CreateRequest> roadmapItemList) { }

 

로드맵 생성 API의 경우 트리 구조의 데이터를 넘겨받지만, 고민 끝에 List 형태로 변환해서 넘겨받도록 작성했다.

고민한 내용은 아래 글에 있다.

2022.10.29 - [Study/고민] - [비교] Tree 구조 API 디자인

 

[비교] Tree 구조 API 디자인

Tree 구조 API 디자인 토이프로젝트 진행 중 트리 구조의 데이터를 서버에 저장해야 할 일이 생겼다. 저장 API를 구현하는 방식으로 아래의 2가지 방법이 떠올랐는데, 이 방법들의 장단점을 비교해

devjaewoo.tistory.com

 

트리 구조를 리스트로 만드는 과정에서 ParentId 데이터가 추가됐다.

때문에 리스트 안의 항목들이 트리처럼 구성될 수 있는지 검증해야 한다.

검증은 아래의 코드와 같이 HashMap에 넣어놓고, 부모 ID가 존재하는지 확인하는 방식으로 검증했다.

단, 이렇게 하면 트리가 아닌 원형 구조로도 작성될 수 있다. 하지만 트리 구조로 제약을 거는것 보단 로드맵을 원형 구조로 만들 수 있는 것이 낫다고 생각해 그대로 뒀다.

@Transactional
public Long create(RoadmapDto.CreateRequest request, Long clientId) {

    Client client = clientRepository.findById(clientId).orElseThrow(() -> new RestApiException(ClientErrorCode.CLIENT_NOT_FOUND));
    Roadmap roadmap = Roadmap.create(request.title(), request.image(), request.accessibility(), client);
    Map<Long, RoadmapItem> map = new HashMap<>();

    // Map에 RoadmapItem으로 변환하여 저장
    request.roadmapItemList().forEach(roadmapItemDto -> {
        RoadmapItem roadmapItem = roadmapItemDto.toEntity();
        roadmapItem.updateRoadmap(roadmap);
        map.put(roadmapItemDto.id(), roadmapItem);
    });

    // Map에서 Parent Entity를 찾아 parent 필드에 등록
    request.roadmapItemList().forEach(roadmapItemDto -> {
        Long parentId = roadmapItemDto.parentId();
        if(parentId != null) {
            RoadmapItem parent = map.get(parentId);
            if(parentId.equals(roadmapItemDto.id()) || parent == null) throw new RestApiException(RoadmapErrorCode.INVALID_PARENT);
            if(roadmapItemDto.connectionType() == null) throw new RestApiException(RoadmapErrorCode.INVALID_CONNECTION);
            map.get(roadmapItemDto.id()).updateParent(parent);
        }
    });

    // Cascade에 의해 Roadmap만 save해도 RoadmapItem까지 함께 save됨
    Roadmap result = roadmapRepository.save(roadmap);

    return result.getId();
}

검색

작성자, 제목, 공식, 정렬, 페이징이 가능한 검색 API를 개발했다.

검색 DTO는 아래와 같다.

public record RoadmapSearch(
        @Min(1) Long client,
        @Size(min = 1, max = 50) String title,   // null: 전체, exist: contains(name)
        Boolean official,   // null: 전체, true: official, false: custom
        RoadmapOrder order,        // null, likes: 좋아요 순, latest: 최신순
        @NotNull Integer page
) { }

 

검색 로직은 QueryDSL을 사용해 작성했다.

검색 조건이 여러개고, null일 경우 검색조건에서 제외시켜줘야 해서 JPQL로 짰으면 상당히 복잡했을 것이다.

@Override
public Page<Roadmap> search(RoadmapSearch roadmapSearch, Pageable pageable) {

    List<Roadmap> content = queryFactory
            .selectFrom(roadmap)
            .where(
                    clientEq(roadmapSearch.client()),
                    nameLike(roadmapSearch.title()),
                    officialEq(roadmapSearch.official()),
                    roadmap.accessibility.in(Accessibility.PUBLIC, Accessibility.PROTECTED),
                    roadmap.isDeleted.isFalse()
            )
            .orderBy(order(roadmapSearch.order()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    //Count 자체에 페이징을 할 수 없음
    JPAQuery<Long> countQuery = queryFactory
            .select(roadmap.count())
            .from(roadmap)
            .where(
                    clientEq(roadmapSearch.client()),
                    nameLike(roadmapSearch.title()),
                    officialEq(roadmapSearch.official()),
                    roadmap.isDeleted.isFalse()
            );

    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}

private BooleanExpression clientEq(Long clientId) {
    return (clientId != null) ? roadmap.client.id.eq(clientId) : null;
}

private BooleanExpression nameLike(String name) {
    return (name != null) ? roadmap.title.like("%" + name + "%") : null;
}

private BooleanExpression officialEq(Boolean isOfficial) {
    return (isOfficial != null) ? roadmap.isOfficial.eq(isOfficial) : null;
}

private OrderSpecifier<?> order(RoadmapOrder order) {
    if(order == null) return roadmap.createdDate.desc();
    return switch (order) {
        case LIKES -> roadmap.likes.desc();
        case LATEST -> roadmap.createdDate.desc();
    };
}

 

Java17을 사용해 위의 order 함수처럼 return과 switch를 한번에 쓰는 깔끔한 코드를 작성할 수 있었다.


테스트코드

이전에 작성한 글에 따라 테스트코드를 작성했다.

 

[OpenRoadmaps] 4. 테스트 환경 구성 및 테스트 항목

테스트 환경 구성 및 테스트 항목 클라이언트 기능을 구현했으니 테스트코드를 작성해야 한다. Entity, DTO, Repository, Service, Controller 항목을 테스트했으며, 각 항목별 사용한 기술은 아래와 같다.

devjaewoo.tistory.com

 

Entity, DTO, Repository, Service, Controller 합쳐서 57개의 테스트케이스를 작성했고, 모두 통과시켰다.

테스트 결과
Roadmap 도메인 테스트 결과

반응형