현재 필자는 JavaFX라는 GUI 라이브러리를 사용해 PDF 책을 편하게 볼 수 있는 PDF 뷰어 프로그램을 만들고 있다.
프로그램을 만들면서 처음 계층형 구조를 갖는 테이블을 구축하게 되었는데
초기에는 어떤 문제가 있었고 이를 어떻게 점차 개선했는지에 대해 기록하려고 한다.
계층형 구조를 가진 북마크 구현하기
우선 계층형 구조를 가진 북마크의 구현은 다음과 같이 되어 있다.
북마크의 계층형 구조를 구현하기 위해 자기 참조와 양방향 연관관계를 사용했다.
@Entity
@Getter
@Table(name = "pdf_bookmark")
@ToString(exclude = {"document", "parent", "children"})
@NoArgsConstructor
public class PDFBookmark {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "pdf_bookmark_seq")
@SequenceGenerator(
name = "pdf_bookmark_seq",
sequenceName = "pdf_bookmark_sequence",
allocationSize = 50
)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id", nullable = false)
private PDFDocument document;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private int pageNumber;
@Column(nullable = false)
private int level;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private PDFBookmark parent;
@OneToMany(mappedBy = "parent", orphanRemoval = true, fetch = FetchType.LAZY)
private List<PDFBookmark> children = new ArrayList<>();
public void addChild(PDFBookmark child) {
this.children.add(child);
child.parent = this;
}
...
}
계층형 북마크 조회 로직은 다음과 같다.
@Slf4j
@Service
@RequiredArgsConstructor
public class PDFBookmarkService {
private final PDFBookmarkRepository bookmarkRepository;
@LogPerformance
@Transactional(readOnly = true)
public List<PDFBookmarkDTO> getBookmarks(PDFDocument pdfDocument) {
List<PDFBookmark> bookmarks = bookmarkRepository.findAllByDocumentIdOrderByLevelAscPageNumberAsc(pdfDocument.getId());
return bookmarks.stream()
.map(PDFBookmarkDTO::toDTO)
.toList();
}
...
}
public record PDFBookmarkDTO(Long id, String title, int pageNumber, int level, List<PDFBookmarkDTO> children) {
public static PDFBookmarkDTO toDTO(PDFBookmark entity) {
List<PDFBookmarkDTO> children = entity.getChildren().stream()
.map(PDFBookmarkDTO::toDTO)
.toList();
return new PDFBookmarkDTO(
entity.getId(),
entity.getTitle(),
entity.getPageNumber(),
entity.getLevel(),
children
);
}
}
문서 ID에 해당하는 모든 북마크를 조회하고 북마크를 계층형 구조로 만들고 있다.
이유는 조회한 리스트를 그대로 반환하게되면 평면 구조로 북마크가 보이기 때문이다.
즉, 계층형 구조로 다시 만드는 이유는 아래와 같이 PDF 뷰어에 모든 북마크 목록을 트리 형태로 표시하기 위함이다.
> 1장 title
> 1 title
> 1.1 title
- 1.1.1 title
- 1.1.2 title
- 1.2 title
> 2 title
- 2.1 title
- 3 title
> 4 title
- 4.1 title
> 4.2 title
> 4.2.1 title
> 4.2.1.1 title
(“>”는 토글을, “-”는 하위 북마크가 없음을 표현했다.)
그리고 이를 DTO로 변환해 View 컨트롤러에 반환한다.
이제 해당 엔티티를 조회하면 어떤 일이 일어날까?
계층형 엔티티 조회 쿼리 최적화하기
쿼리는 fetch join 없는 아래 쿼리를 사용했다.
(테스트한 PDF 파일의 경우 총 북마크 수는 272 개고 루트 북마크는 15개)
@Query("SELECT b FROM PDFBookmark b WHERE b.document.id = :pdfDocumentId")
List<PDFBookmark> findAllBookmarksByDocumentId(Long pdfDocumentId);
fetch 조인을 사용하지 않은 위 쿼리는 pdf_bookmark 추가 쿼리가 무려 272건이나 나가고 있었다.
실행 시간은 애초에 H2로 외부 네트워크 통신이 없기도 했 많은 데이터가 있진 않아 0.042초로 나왔다.
하지만 실행 프로그램을 만드는 것이 아닌 서비스를 만드는 입장에서는 해당 문제를 개선하고 싶어 알아보기로 했다.
SELECT c.* FROM pdf_bookmark c WHERE c.parent_id = ?
원인은 바로 계층형 구조로 만드는 과정에서 사용하는 아래 메서드 떄문이었다.
List<PDFBookmarkDTO> children = entity.getChildren().stream()
.map(PDFBookmarkDTO::toDTO)
.toList();
entity.getChildren().stream()을 호출할 때 children이 초기화되지 않은 상태라면 N+1 문제가 발생하는 것이다.
1단계. FETCH JOIN 적용 (해결 X)
이 문제를 해결하고자 다음과 같이 쿼리를 작성했다.
@Query("SELECT DISTINCT b FROM PDFBookmark b LEFT JOIN FETCH b.children WHERE b.document.id = :pdfDocumentId")
List<PDFBookmark> findAllBookmarksByDocumentId(Long pdfDocumentId);
위 쿼리는 다음과 같이 변환되어 전송된다.
select
distinct p1_0.id,
c1_0.parent_id,
c1_0.id,
c1_0.document_id,
c1_0.level,
c1_0.page_number,
c1_0.title,
p1_0.document_id,
p1_0.level,
p1_0.page_number,
p1_0.parent_id,
p1_0.title
from
pdf_bookmark p1_0
left join
pdf_bookmark c1_0
on p1_0.id=c1_0.parent_id
where
p1_0.document_id=?
쿼리가 조금 헷갈릴 수 있다.
children 필드는 @OneToMany(mappedBy = "parent")로 정의되어 있어 물리적인 테이블 컬럼을 의미하며 JOIN 시 parent 필드로 관계를 맺는다.
즉, 부모 북마크와 자식 북마크가 LEFT JOIN 되는 것이다
다음과 같은 pdf_bookmark 테이블이 있다고 가정해보자.
1 | 100 | 제목1 | 10 | 1 | NULL |
2 | 100 | 제목2 | 20 | 2 | 1 |
3 | 100 | 제목3 | 30 | 2 | 1 |
4 | 100 | 제목4 | 40 | 1 | NULL |
"LEFT JOIN FETCH b.children"을 사용하면 다음과 같은 결과가 생성된다.
(여기서 ”p”는 부모 북마크를 나타내고, ”c”는 자식 북마크를 나타낸다.)
p.id | p.document_id | p.title | p.page_number | p.level | p.parent_id | c.id | c.document_id | c.title | c.page_number | c.level | c.parent_id |
1 | 100 | 제목1 | 10 | 1 | NULL | 2 | 100 | 제목2 | 20 | 2 | 1 |
1 | 100 | 제목1 | 10 | 1 | NULL | 3 | 100 | 제목3 | 30 | 2 | 1 |
4 | 100 | 제목4 | 40 | 1 | NULL | NULL | NULL | NULL | NULL | NULL | NULL |
중복된 행이 있는데 어떻게 엔티티로 처리될까?
신기한점은 JPQL의 DISTINCT는 SQL 레벨에서 중복된 행을 제거하는 것과 더불어 JPA 레벨에서 엔티티의 중복을 제거하는 역할을 수행한다는 것이다.
이 결과를 JPA가 처리할 때, 다음과 같은 과정이 진행된다.
- JPA는 p.id가 1인 PDFBookmark 객체를 생성한다.
- 이제 p.id가 1인 자식들을 찾는다.
- p.id = 1을 참조하는 c.id = 2인 PDFBookmark 엔티티를 생성하고, 이를 p.id가 1인 PDFBookmark 객체의 children 컬렉션에 추가한다.
- p.id = 1을 참조하는 c.id = 3인 PDFBookmark 엔티티를 생성하고, 이를 p.id가 1인 DFBookmark 객체의 children 컬렉션에 추가한다.
결과적으로, ID가 1인 PDFBookmark 객체는 2개의 자식 북마크(ID가 2, 3)를 가지게 된다.
해당 쿼리를 적용한 후 조회 속도는 얼마나 빨라졌을까?
fetch 조인 적용 후에는 0.019초로 나왔고 272개의 추가쿼리가 사라진만큼 약 2배 빨리진 것을 볼 수 있다.
외부 DB와 통신을 하거나 테이블에 저장된 데이터가 많았다면 이 수치는 더욱 컸으리라 본다.
하지만 fetch 방식은 여전히 문제가 있다.
다단계 계층형 구조를 표현하지 못하고 1 depth 자식만 로딩하는 것이다.
A (루트)
├── B (A의 자식)
│ └── C (B의 자식)
│ └── D (C의 자식)
└── E (A의 자식)
- A (children = [B, E])
- B (children = []) - C가 누락됨!
- C (children = []) - D가 누락됨!
- D (children = [])
- E (children = [])
2단계. Batch fetch Size 설정 (해결 O)
어떻게 해결할까? 현재 문제는 다음과 같다.
- 자기 참조 관계을 가져 부모-자식 관계가 동일 엔티티 내에서 재귀적이다.
- 깊이가 일정하지 않은 가변 깊이 트리 구조이다.
- 프로그램에 모든 북마크 계층 구조를 표현해주어야 한다.
이 문제는 Batch fetch size로 해결할 수 있었다.
Batch fetch size는 지연 로딩된 컬렉션이나 엔티티를 조회할 때 한 번에 몇 개의 엔티티를 로딩할지 결정한다.
코드에서 지연 로딩된 연관 엔티티에 접근하면, 연관 엔티티들을 개별적으로 조회하지 않고, 배치 크기만큼 한 번에 IN 절을 사용하여 조회한다.
@OneToMany(mappedBy = "parent", orphanRemoval = true, fetch = FetchType.LAZY)
@BatchSize(size = 500)
private List<PDFBookmark> children = new ArrayList<>();
기존 쿼리에서 Batch fetch size를 설정하면, 모든 엔티티 별로 자식 엔티티 컬렉션에 접근할 때마다 IN 절 쿼리로 가져오지만
아래와 같이 각 엔티티별로 자식 엔티티 컬렉션을 가져오기 때문에 중복이 발생할 수 있다.
- A (children = [B, E])
- B (children = [C, D])
- C (children = [D])
- D (children = [])
- E (children = [])
따라서 중복을 제거하기 위해 다음과 같이 루트 엔티티만 조회하고 자식 엔티티에 접근할 때 IN 절 쿼리로 들고오도록 했다.
@Query("SELECT b FROM PDFBookmark b WHERE b.document.id = :pdfDocumentId AND b.parent IS NULL")
List<PDFBookmark> findRootBookmarksByDocumentId(Long pdfDocumentId);
그 결과 각 계층마다 모든 북마크의 자식을 하나의 IN절 쿼리로 가져올 수 있었다. (현재 총 depth는 4로 4번의 IN절 쿼리)
// SELECT * FROM pdf_bookmark WHERE parent_id IN (부모_북마크_ID_리스트)
select c1_0.parent_id,c1_0.id,c1_0.document_id,c1_0.level,c1_0.page_number,c1_0.title from pdf_bookmark c1_0 where c1_0.parent_id in (?,?,?,?,?,?,?, ~)
select c1_0.parent_id,c1_0.id,c1_0.document_id,c1_0.level,c1_0.page_number,c1_0.title from pdf_bookmark c1_0 where c1_0.parent_id in (?,?,?,?,?,?,?, ~)
select c1_0.parent_id,c1_0.id,c1_0.document_id,c1_0.level,c1_0.page_number,c1_0.title from pdf_bookmark c1_0 where c1_0.parent_id in (?,?,?,?,?,?,?, ~)
select c1_0.parent_id,c1_0.id,c1_0.document_id,c1_0.level,c1_0.page_number,c1_0.title from pdf_bookmark c1_0 where c1_0.parent_id in (?,?,?,?,?,?,?, ~)
fetch join의 깊이 제한과 다르게 계층의 깊이와 관계없이, 모든 부모-자식 관계를 한 번에 로딩할 수 있어 이 문제를 해결할 수 있었다.
조회 속도는 평균 0.02초로 나왔다.
3단계. 양방향 연관관계 제거 (해결 O)
Batch fetch size로 문제를 해결할 수도 있었지만
양방향 연관관계를 사용하면 사이드 이펙트가 발생할 수 있는 지점들이 많아지기 때문에 개인적으로는 선호하지 않았다.
문제가 되는 것은 조회시에 양방향 연관관계가 있는 children 필드였기 때문에 과연 “양방향 연관관계가 필요한가?”를 고민해봤다.
결론은 PDF 북마크는 PDF 파일에서 추출해 저장하기 때문에
북마크 재정렬, 다른 부모로 이동, 동일 레벨 내 위치 변경 등 북마크 편집 기능을 적용하기엔 무리가 있다.
정리하면 PDF 파일에서 추출한 북마크는 변경, 삭제, 추가 작업은 있을 수 없었다.
북마크 하위 계층들을 화면에 보여주고 해당 북마크를 클릭하면 해당 페이지로 이동하는 기능이 필요한 것이다.
이런점을 바탕으로 양방향 연관관계 필드를 제거하고 프로그램에 보여주기만하는 것이 목적이므로 DTO에 자식 북마크 리스트 필드를 추가하였다.
public record PDFBookmarkDTO(Long id, String title, int pageNumber, int level, List<PDFBookmarkDTO> children) {
public static PDFBookmarkDTO from(PDFBookmark pdfBookmark) {
return new PDFBookmarkDTO(
pdfBookmark.getId(),
pdfBookmark.getTitle(),
pdfBookmark.getPageNumber(),
pdfBookmark.getLevel(),
new ArrayList<>()
);
}
}
그리고 계층적 구조를 가진 북마크 DTO로 만들기 위해 아래와 같이 애플리케이션 로직으로 처리하도록 수정했다.
@LogPerformance
@Transactional(readOnly = true)
public List<PDFBookmarkDTO> findAll(PDFDocument pdfDocument) {
List<PDFBookmark> bookmarks = bookmarkRepository.findAllByDocumentId(pdfDocument.getId());
return toHierarchicalBookmarkDTO(bookmarks);
}
private List<PDFBookmarkDTO> toHierarchicalBookmarkDTO(List<PDFBookmark> bookmarks) {
Map<Long, PDFBookmarkDTO> bookmarkDTOMap = new HashMap<>();
for (PDFBookmark bookmark : bookmarks) {
bookmarkDTOMap.put(bookmark.getId(), PDFBookmarkDTO.from(bookmark));
}
List<PDFBookmarkDTO> rootBookmarks = new ArrayList<>();
for (PDFBookmark bookmark : bookmarks) {
PDFBookmarkDTO bookmarkDTO = bookmarkDTOMap.get(bookmark.getId());
if (bookmark.getParent() == null) {
rootBookmarks.add(bookmarkDTO);
} else {
Long parentId = bookmark.getParent().getId();
PDFBookmarkDTO parentDTO = bookmarkDTOMap.get(parentId);
if (parentDTO != null) {
parentDTO.children().add(bookmarkDTO);
} else {
rootBookmarks.add(bookmarkDTO);
}
}
}
return rootBookmarks;
}
이렇게 구성했을 때의 속도는 0.01초로 이전 방법들에 비해서 제일 빨랐다.
문서당 PDF 북마크는 아무리 큰 문서여봤자 500개 이상은 안넘어을 것이고 이 정도 수치의 엔티티를 처리하는 것은 서버 메모리 부족 문제를 일으키진 않을 것이다.
결국 이 방법으로 계층형 북마크를 구성해 보여주도록 적용했다.
돌아보기
다단계 계층형 북마크 조회 기능을 개선해보면서 추가로 궁금했던점은 쇼핑몰 카테고리같은 계층형 구조는 어떻게 처리할지였다.
이 상황에서는 카테고리 트리를 재구성, 하위 카테고리를 다른 상위 카테고리로 이동, 특정 카테고리의 전체 제품 수를 계산 등을 할 수 있다.
이 경우엔 양방향 연결이 필요할 수 있을 것이다.
이 경우 또한 계층의 깊이는 가변적일 것이다.
직접 구현해보지 못했지만 삽질의 경험으로 N + 1를 해결한다면 left fetch join은 의미 없을 것이고
batch fetch size를 설정해서 여러 부모 ID를 한 번에 조회하는 IN 절 사용으로 효과적으로 추가 쿼리 수를 줄인다던가
Projection을 사용해 완전히 초기화된 객체로 만들어 가져와 애플리케이션 코드로 관계를 구성하는 방법이 있을 수 있을 것 같다.
계층형 구조를 가진 엔티티를 설계해본 것은 처음이라 재미있었고 여기안에서 발생한 문제를 해결해보는 것도 의미 깊었다.
특히 구현하고자 하는 기능의 특징을 돌아보고 재구성해보는 과정에 삽질은 있엇지만 의미있는 삽질이였다.
'◼ JPA' 카테고리의 다른 글
Batch Insert 설정 이해하기 그리고 JPA, JDBC 성능 비교 (0) | 2025.04.25 |
---|---|
hibernate batch 옵션 order_inserts와 order_updates는 항상 좋을까 ? (0) | 2025.04.22 |
@Transactional(readOnly = true)는 항상 성능에 좋을까? (2) | 2025.03.28 |
[Spring Data JPA] 스프링 데이터 JPA에서 페이징(Paging) 사용하기 (2) | 2023.06.26 |
[Spring Data JPA] @EntityGraph 엔티티 그래프란? (0) | 2023.06.20 |