노션 페이지 조회 API 설계하기

김태훈·2023년 9월 4일
0
post-thumbnail

목표


노션과 유사한 간단한 페이지 관리 API를 구현해주세요.
각 페이지는 제목, 컨텐츠, 그리고 서브 페이지를 가질 수 있습니다.
또한, 특정 페이지에 대한 브로드 크럼스(Breadcrumbs) 정보도 반환해야 합니다.

브로드크럼스란 현재 페이지의 경로입니다.
아래 사진은 브로드크럼스의 예시입니다.

요구사항


페이지 정보 조회 API: 특정 페이지의 정보를 조회할 수 있는 API를 구현하세요.

입력: 페이지 ID
출력: 페이지 제목, 컨텐츠, 서브 페이지 리스트, 브로드 크럼스

단, 컨텐츠 내에서 서브페이지 위치는 고려하지 않습니다.

입력, 출력

GET http://127.0.0.1:8080/pages/3
위 URL로 입력을 받아 아래과 같이 응답을 만들 생각입니다.

{
    "id": 3,
    "title": "1팀",
    "contents": "1팀 화이팅~~",
    "children": [
        {
            "id": 6,
            "title": "김태훈"
        },
        {
            "id": 7,
            "title": "홍길동"
        },
        {
            "id": 8,
            "title": "임꺽정"
        }
    ],
    "routes": [
        {
            "id": 1,
            "title": "최상단"
        },
        {
            "id": 2,
            "title": "원티드 인턴십"
        }
    ]
}

가정


  1. 각 페이지는 제목과 내용을 변경할 수 있습니다.
  2. 현재 페이지에서 자식 페이지 혹은 브로드크럼스 경로로 이동이 가능하도록 설계해야 합니다.

ERD

엔티티 이미지를 보여드리고, 각각을 설명드리겠습니다.

Page Id : 각 페이지를 구별할 수 있는 식별자


Title : 페이지의 제목


Contents : 페이지의 내용


Pagent Id : 부모페이지의 ID
			Parent Page와 Child Page는 일대다 관계를 갖습니다.
            최상단 페이지는 NULL 값이 저장됩니다. (부모가 없습니다)
            
            

테이블에는 아래와 같이 저장됩니다.
parent_route 컬럼은 사라졌습니다!!

프로젝트 구조

Page 엔티티

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "pages")
public class Page {
    @Id
    @Column(name = "page_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String contents;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Page parentPage;
}

PageController

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/pages")
public class PageController {
    private final PageService service;

    @GetMapping("{id}")
    public PageResponse search(@PathVariable Long id) {
        return service.search(id);
    }
}

PageResponse

  • 사용자에게 결과를 반환하기 위해 사용하는 DTO입니다.
import java.util.Collections;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PageResponse {
    private Long id;

    private String title;

    private String contents;

    private List<PageNameResponse> children;

    private List<PageNameResponse> routes;

    @Builder
    public PageResponse(Long id, String title, String contents, List<PageNameResponse> children,
                        List<PageNameResponse> routes) {
        this.id = id;
        this.title = title;
        this.contents = contents;
        this.children = Collections.unmodifiableList(children);
        this.routes = Collections.unmodifiableList(routes);
    }
}

PageRepository

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

@Repository
public interface PageRepository extends JpaRepository<Page, Long> {
    @Query(value = "select p from Page p where p.id = :id")
    Optional<Page> search(@Param("id") Long id);

    @Query(value = "select p.id as id, p.title as title from Page p where p.parentPage.id = :id")
    List<PageNameResponse> searchChildren(@Param("id") Long parentId);

    @Query(value = "with recursive Breadcrumbs as " +
        "(select page_id, title, parent_id " +
        "   from Pages " +
        "   where page_id = :id " +
        "   union all" +
        "       select p.page_id, p.title, p.parent_id " +
        "           from Pages p " +
        "           join Breadcrumbs b " +
        "               on p.page_id = b.parent_id" +
        ") " +
        "select b.page_id as id, b.title as title from Breadcrumbs b", nativeQuery = true)
    List<PageNameResponse> findAllParentInfo(@Param("id") Long id);

}

요구사항 중 직접 쿼리를 만들어보라는 요구사항이 존재했는데요.
Native Query 방식이 가능하다고 하셔 가장 빠르게 적용할 수 있는 Native Query 방식을 사용했습니다.

findAllParentInfo 메서드에 적용한 쿼리는 이후에 설명드리겠습니다.

PageNameResponse

public interface PageNameResponse {
    Long getId();

    String getTitle();
}

해당 객체는 DTO Projection을 위해 사용하는 객체입니다.
Database에서 조회할 때 엔티티를 불러오는 게 아니라 id 필드와 title 필드만을 가져오기 위해 사용합니다.

PageService

import com.example.demo.page.response.PageNameResponse;
import com.example.demo.page.response.PageResponse;
import java.util.Collections;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PageService {
    private final PageRepository repository;

    public PageResponse search(Long id) {
        Page page = repository.search(id)
            .orElseThrow(() -> new RuntimeException("존재하지 않는 페이지"));

        List<PageNameResponse> children = repository.searchChildren(page.getId());
        List<PageNameResponse> routes = repository.findAllParentInfo(page.getId());
        Collections.reverse(routes);
        return PageResponse.builder()
            .id(page.getId())
            .title(page.getTitle())
            .contents(page.getContents())
            .children(children)
            .routes(routes)
            .build();
    }
}

설계시 고려한 부분

PageService에는 search 라는 메서드가 있습니다.
search는 Page의 ID를 입력받아서 제목, 내용 등을 반환해주는 메서드입니다.
해당 메서드는 아래와 같은 로직으로 동작합니다.

1. Page 엔티티를 찾는다.
2. 해당 Page의 자식 Page들을 찾는다.
3. Page의 모든 부모 Pages들의 id와 title을 가져온다.

Notion Page를 하나 조회하기 위해 총 3번의 쿼리가 날아갑니다.
왜 굳이 세 번이나 쿼리를 보내야 할까.

각각에 대해 설명드리도록 하겠습니다.

자식 Page들을 가져오기

하나의 페이지는 여러 자식 페이지를 가질 수 있습니다.
각각의 페이지에서는 하위 페이지로 이동이 가능해야 하고, 이름을 보여줘야 합니다.
이렇게 연관있는 여러 정보들을 담기 위해서는 Page와 Page간 관계를 맺어주는 게 가장 합리적이라고 생각했습니다.

해당 부분에서 쿼리를 조금 날리고 싶다면 Join 메서드를 사용해 Page 엔티티를 찾는 로직과 자식 Page를 찾는 로직을 합쳐줄 수 있습니다.
다만 Inner Join을 쓰면 안된다는 사실만 기억하고 넘어갑시다.

브로드크럼스 가져오기

브로드크럼스는 자식에서 부모 Page들을 타고가면서 정보를 가져와야 합니다.

1️⃣ N번 쿼리 요청을 보내기

가장 쉽게 생각할 수 있는 건 최상단 부모에 도착할 때까지 Query를 쏘는 것입니다.

List<Page> routes = new ArrayList<>();
while(true):
  Page page = 페이지를_불러온다()
  routes.add(page);
  if (page.parentId == null):
    break;

하지만 이 방식을 사용하면 Page 하나 조회하는데 꽤나 많은 쿼리를 쏴야 합니다. 너무 비효율적인 구조입니다.
JPA에서도 N+1 문제를 잡는다고 그렇게 말이 많았는데 이런 방식을 사용해서는 안되겠죠~~

다음으로 생각한 방식은 프로시저를 사용하는 방식입니다.

2️⃣ 프로시저 만들어 사용하기

프로시저를 사용하면 MySQL 등에서 메서드를 만들 수 있습니다. (궁금하신 분들은 찾아보세요!)
그런데 프로시저를 사용하면 유지보수하기도 쉽지가 않을 거 같고, 다른 방식이 있지 않을까 싶어 계속 고민했습니다.
최후의 수단으로는 프로시저를 사용할 생각이었습니다.

3️⃣ 역정규화를 통해 부모 페이지들의 title 저장하기

이 방식을 사용하면 제목이 변경될 때 큰 문제가 생깁니다.
만약 부모 페이지에서 title이 변경되면 모든 자식 페이지를 방문해 이름을 변경해야 합니다.

그런데 노션을 사용할 때 title에 대한 변경은 자주 일어납니다. 그래서 이 방법은 사용하지 않습니다.

4️⃣ 역정규화를 통해 부모 페이지들의 id 저장하기

위 방식에서 문제점은 Title이 변경될 때 모든 자식 필드를 변경해야 한다는 점입니다.
그렇다면... id는 변경되지 않으니까 id 값들을 저장하는 게 어떨까 하는 아이디어가 떠올랐습니다.

Page 엔티티의 parent_route 필드에 부모 Page들의 id를 배열로 저장합니다.
이후, Service 계층에서 search 메서드가 실행되면서 parent_route라는 값을 가져옵니다.
String parent_route = "[1,2,3,4]";
다음 값을 파싱해 List<Long> routes = new ArrayList<>(); 를 만들어냅니다.

이제 routes 인스턴스를 통해 다음과 같은 쿼리를 보냅니다.
select p.id as id, p.title as title from Page p where p.id in :ids

그러나! 이 방식은 부모가 변경될 때 자식들에게 변경을 전파할 수 없습니다. 🥲🥲
old/a/b/c 에서 new/a/b/c 로 a의 부모를 변경하면, 자식인 b,c에서 parent_route는 변경되지 않습니다.

✅ with recursive 쿼리

이젠 정말 프로시저뿐이야 생각하고 있던 순간, 팀원 분을 통해 WITH, RECURSIVE라는 문법이 있다는 걸 알게 되었습니다.
기존에 프로시저를 만드려고 한 이유는 MySQL 엔진에게 반복적인 요청을 시키기 위해서였는데, 이런 게 있었네요~
WITH는 임시테이블을 만드는 데 사용하는 쿼리입니다. 다만, 해당 임시테이블은 쿼리가 끝나면 사라집니다.
그리고 WITH와 함께 사용 가능한 recursive라는 문법은 루프를 돌려주는 특징을 가졌습니다.
이 두 가지를 조합하면 다음과 같은 로직을 짤 수 있습니다.

1. 브로드크럼스를 찾아야 하는 Page의 id를 사용합니다.
2. Pages 테이블에서 1의 id값을 사용해 결과를 만듭니다.
3. 만들어진 결과를 사용해 Breadcrumbs라는 내부 테이블을 생성합니다.
4. 여기까지 하면 Breadcrumbs에는 한 개의 튜플이 존재합니다. 
5. Breadcrumbs 테이블과 Pages 테이블이 계속해서 Join을 합니다.
이렇게 하면 Breadcrumbs 테이블에 존재하는 값들에 대해 모든 부모를 가져올 수 있습니다.

추후, 더 자세하게 설명을 적겠습니다 ㅠㅠ 그림을 붙여둬야 이해가 쉬울 거 같은데 지금 할게 너무 많네요

고려한 부분

  1. Page에 대해 제목, 내용을 변경하는 로직
    실제로 노션에서 작업을 하면 제목이나 내용은 정말 자주 바뀌게 됩니다.
    해당 부분 처리를 위해 재귀적으로 요청을 처리하게 됩니다.
  2. 경로 변경
    특정 Pages의 경로가 바뀌어도 문제 없습니다.
    경로가 바뀐 Pages의 parent_id만 이어주면 됩니다.

재귀적으로 요청을 보낸다는 건 결국 데이터의 변경에 강력해진다는 의미입니다.
즉, RDB 상에서 표현하기 쉬운 Node와 Node 간의 관계를 효과적으로 처리할 수 있습니다.
기존에 쿼리를 여러 번 보내 네트워크의 낭비가 생긴다는 문제 역시 WITH RECURSIVE를 통해 효과적으로 해결할 수 있었습니다.

정리

WITH RECURSIVE가 재귀로 요청을 효과적으로 처리해준다고는 하는데, 성능상 어떨지가 궁금합니다.
아무래도 내부에 임시로 테이블을 만들어서 사용하는데 성능상 막 좋지는 않을 거 같습니다.
아마 노션같은 페이지는 실제로는 Graph DB 등이 사용되지 않았을까 추측해봅니다.

한줄평

RDB로도 노드와 노드 사이 관계를 만들 수는 있다!!!

profile
작은 지식 모아모아

0개의 댓글