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
기능을 구현하기 위한 정보들이 들어있고, 특징은 다음과 같다.
- RoadmapItem 테이블은 트리 구조를 만들기 위해 자기 자신을 FK로 갖도록 구성했다.
- RoadmapItem 테이블엔 로드맵 항목의 위치를 저장하기 위한 X, Y 필드가 있다. 단위는 REM이다.
- RoadmapLike와 RoadmapItemClear는 사용자의 좋아요 / 완료 유무에 따라 수시로 Insert / Delete 하는 것이 아닌, 처음 값이 설정될 때만 Insert하고 이후 변경이 생겼을 때는 테이블의 like / clear 필드의 boolean 값을 Update시켜주는 구조로 작성했다.
아래 다이어그램은 코드로 구현하기 전 설계한 다이어그램이다.
초기 설계에는 로드맵 항목의 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개의 테스트케이스를 작성했고, 모두 통과시켰다.
'Projects > OpenRoadmaps' 카테고리의 다른 글
[OpenRoadmaps] 7. 로드맵 뷰어 기능 개발 (0) | 2023.02.05 |
---|---|
[OpenRoadmaps] 6. 로드맵 에디터 기능 구현 (0) | 2023.01.27 |
[OpenRoadmaps] 4. 테스트 환경 구성 및 테스트 항목 (0) | 2023.01.05 |
[OpenRoadmaps] 3. 이메일 및 OAuth2 로그인 구현 (0) | 2022.11.28 |
[OpenRoadmaps] 2. 프로젝트 구조 설계 및 환경 설정 (0) | 2022.11.28 |