태그 조회의 3-way JOIN을 비정규화와 이벤트로 걷어낸 이야기


어느 날 슬로우 쿼리 로그를 열었다
제가 운영하는 커뮤니티(fullstackfamily.com)에는 블로그, 자유게시판, Q&A, 뉴스 등 여러 종류의 글이 올라옵니다. 글마다 태그를 달 수 있고, 블로그 사이드바에 "태그 목록"을, 홈 피드에는 "인기 태그"를 보여줍니다.
문제는 이 태그 목록을 가져오는 쿼리였습니다.
SELECT pt.tag_name, COUNT(DISTINCT p.id) FROM unified_post p JOIN unified_post_blog pb ON pb.post_id = p.id JOIN unified_post_tag pt ON pt.post_id = p.id WHERE pb.blog_id = ? AND p.status = 'PUBLISHED' GROUP BY pt.tag_name ORDER BY COUNT(DISTINCT p.id) DESC
테이블 3개를 JOIN하고, GROUP BY + COUNT를 돌린다. 글이 100개일 때는 괜찮지만, 태그가 늘어나면 인덱스를 타더라도 임시 테이블이 생긴다.
홈 피드의 인기 태그 쿼리도 구조가 비슷합니다.
SELECT pt.tag_name, COUNT(DISTINCT p.id) FROM unified_post p JOIN unified_post_tag pt ON pt.post_id = p.id WHERE p.show_in_feed = true AND p.status = 'PUBLISHED' GROUP BY pt.tag_name ORDER BY COUNT(DISTINCT p.id) DESC LIMIT 20
두 쿼리 모두 읽기 빈도가 쓰기보다 압도적으로 높은 곳에서 매번 집계를 돌리고 있었습니다. 블로그 사이드바는 페이지를 열 때마다, 홈 피드 태그 필터는 피드에 들어올 때마다. 사이드바 숫자 하나 보여주려고 3-way JOIN을 매번 돌리는 건 좀 아까운 일이다.
"미리 세놓자"
태그별 글 수를 매번 세지 말고, 미리 세서 별도 테이블에 저장해두면 조회 시 JOIN과 GROUP BY 없이 단순 SELECT로 끝난다.
테이블 2개를 추가했습니다.
기존: 읽을 때마다 3-way JOIN + GROUP BY (느림) ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ unified_post │◄──►│ unified_post_blog│ │ unified_post_tag │ │ │◄──►│ │ │ │ └──────────────┘ └──────────────────┘ └──────────────────┘ ▲ ▲ └────────── JOIN ── JOIN ── GROUP BY ────────┘ 개선: 미리 세놓고 SELECT만 (빠름) ┌──────────────────┐ ┌──────────────────┐ │ blog_tag_count │ │ global_tag_count │ │ │ │ │ │ blog_id │ │ tag_name (UNIQUE) │ │ tag_name │ │ post_count │ │ post_count │ │ │ │ UNIQUE(blog,tag) │ │ │ └──────────────────┘ └───────────────────┘
| 테이블 | 용도 | 조회 쿼리 |
|---|---|---|
blog_tag_count | 블로그별 태그 목록 | SELECT * FROM blog_tag_count WHERE blog_id = ? AND post_count > 0 ORDER BY post_count DESC |
global_tag_count | 홈 피드 인기 태그 | SELECT * FROM global_tag_count WHERE post_count > 0 ORDER BY post_count DESC LIMIT 20 |
3-way JOIN + GROUP BY가 단순 인덱스 스캔으로 바뀐다.
기존 데이터 마이그레이션
테이블만 만들면 비어 있으니, 기존 데이터를 한 번 집계해서 넣어야 합니다.
-- blog_tag_count 초기 데이터 INSERT INTO blog_tag_count (blog_id, tag_name, post_count) SELECT pb.blog_id, pt.tag_name, COUNT(DISTINCT p.id) FROM unified_post p JOIN unified_post_blog pb ON pb.post_id = p.id JOIN unified_post_tag pt ON pt.post_id = p.id WHERE p.status = 'PUBLISHED' GROUP BY pb.blog_id, pt.tag_name ON DUPLICATE KEY UPDATE post_count = VALUES(post_count);
이 3-way JOIN + GROUP BY를 마이그레이션 때 딱 한 번 실행하고, 이후에는 증분 업데이트만 한다.
global_tag_count도 같은 원리로 PUBLISHED + show_in_feed=true 조건으로 초기 데이터를 넣었습니다. show_in_feed는 "이 글을 홈 피드에 노출할 것인가" 플래그인데, 블로그 글 중 일부만 피드에 보이도록 설정할 수 있습니다.
조회 코드: 원래 수십 줄이 3줄로
비정규화 전의 BlogTagService가 어땠는지는 기억하고 싶지 않지만, 지금은 이렇습니다.
public List<BlogTagResponse> getTagsForBlog(String blogSlug) { Blog blog = blogRepository.findBySlug(blogSlug) .orElseThrow(() -> new ResourceNotFoundException("...")); return blogTagCountRepository .findByBlogIdAndPostCountGreaterThanOrderByPostCountDesc(blog.getId(), 0) .stream() .map(tc -> new BlogTagResponse(tc.getTagName(), (long) tc.getPostCount())) .toList(); }
홈 피드 인기 태그도 비슷합니다.
public List<TagCountDto> getPopularFeedTags(int limit) { return globalTagCountRepository .findByPostCountGreaterThanOrderByPostCountDesc(0, PageRequest.of(0, limit)) .stream() .map(tc -> new TagCountDto(tc.getTagName(), (long) tc.getPostCount())) .toList(); }
Spring Data JPA 메서드 이름이 길긴 한데, 실행되는 쿼리는 단순하다. JOIN도 GROUP BY도 없다.
카운트를 누가 언제 갱신하나
읽기는 해결됐고, 쓰기가 남았습니다. 글이 생성/수정/삭제될 때 카운트를 맞춰야 합니다.
처음에는 BlogPostService의 createPost(), updatePost(), deletePost() 안에 직접 카운트 갱신 코드를 넣었습니다. 그랬더니 서비스 클래스가 비대해지고, 태그 카운트 로직이 비즈니스 로직과 뒤섞여서 읽기 어려워졌습니다.
생각해보면 태그 카운트 갱신은 "글이 바뀌었을 때 따라오는 부수 효과"입니다. 메인 로직과 분리하기 위해 Spring Application Events를 썼습니다.
BlogPostService TagCountEventListener ┌─────────────────┐ ┌──────────────────────┐ │ createPost() │ │ │ │ updatePost() │ ── 이벤트 발행 ──► │ handlePostTagsChanged│ │ deletePost() │ │ - blog_tag_count │ │ copyPost() │ │ - global_tag_count │ └─────────────────┘ └──────────────────────┘
이벤트 설계
이벤트에 뭘 담을지 고민했습니다. "글 ID가 바뀌었다"만 보내면 리스너가 다시 글을 조회해야 하니까, 카운트 갱신에 필요한 것만 넣기로 했습니다.
public record PostTagsChangedEvent( Long blogId, List<String> addedTags, List<String> removedTags, boolean affectsBlogCount, boolean affectsGlobalCount ) { public static PostTagsChangedEvent blogOnly(...) { ... } public static PostTagsChangedEvent globalOnly(...) { ... } public static PostTagsChangedEvent both(...) { ... } }
addedTags는 카운트를 올릴 태그, removedTags는 내릴 태그. affectsBlogCount와 affectsGlobalCount는 어떤 테이블에 영향을 주는지 나타낸다.
왜 플래그가 2개냐면, global_tag_count는 show_in_feed=true인 글만 반영하기 때문입니다. 블로그 글을 썼는데 피드 노출을 꺼놨다면 blog_tag_count만 올리고 global_tag_count는 건드리면 안 됩니다.
팩토리 메서드 3개(blogOnly, globalOnly, both)로 이 조합을 표현합니다.
동기 vs 비동기: 숫자가 잠깐 안 맞아도 되나
이벤트 처리를 동기로 할지, 비동기로 할지.
| 동기 | 비동기 | |
|---|---|---|
| 일관성 | 트랜잭션과 함께 즉시 반영 | 잠시 불일치 가능 |
| 성능 | 글 저장 응답 시간에 포함 | 메인 트랜잭션과 분리 |
| 실패 처리 | 글 저장도 함께 롤백 | 카운트만 실패, 글은 정상 |
태그 카운트는 사이드바에 보이는 숫자입니다. 글을 쓰고 1~2초 뒤에 숫자가 올라가도 알아차리기 어렵다. 반면 글 저장이 카운트 갱신 때문에 느려지면 바로 느낀다.
비동기를 골랐습니다. 원본 트랜잭션이 커밋된 뒤, 별도 스레드에서 처리합니다.
@Async("tagCountExecutor") @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handlePostTagsChanged(PostTagsChangedEvent event) { if (event.affectsBlogCount()) { incrementBlogTagCounts(event.blogId(), event.addedTags()); decrementBlogTagCounts(event.blogId(), event.removedTags()); } if (event.affectsGlobalCount()) { incrementGlobalTagCounts(event.addedTags()); decrementGlobalTagCounts(event.removedTags()); } }
어노테이션 3개가 각각 하는 일이 다릅니다.
| 어노테이션 | 역할 |
|---|---|
@TransactionalEventListener(AFTER_COMMIT) | 글 저장 트랜잭션이 커밋된 후에만 실행 |
@Async("tagCountExecutor") | 별도 스레드 풀에서 실행 |
@Transactional(REQUIRES_NEW) | 카운트 갱신용 새 트랜잭션 |
REQUIRES_NEW가 필수다. AFTER_COMMIT 단계에서는 원본 트랜잭션이 이미 끝났으니 새 트랜잭션을 열어야 합니다. Spring 6.1부터는 이걸 안 붙이면 기동 시 오류가 나는데, 처음에 @Transactional만 붙였다가 애플리케이션이 안 떠서 알게 됐습니다. 에러 메시지가 친절한 편이라 금방 찾긴 했지만.
@TransactionalEventListener는 글 저장이 롤백되면 이벤트 자체가 실행되지 않습니다. "글은 롤백됐는데 카운트만 올라간" 상황이 아예 안 생긴다.
전용 스레드 풀
태그 카운트 갱신이 다른 비동기 작업(배너 클릭 로그, 경험치 적립 등)과 스레드를 두고 경쟁하면 안 됩니다. 풀을 따로 뺐습니다.
@Bean("tagCountExecutor") public Executor tagCountExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(200); executor.setThreadNamePrefix("tag-count-"); executor.setRejectedExecutionHandler( new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }
CallerRunsPolicy를 쓴 건, 큐가 꽉 차도 이벤트를 버리지 않으려고입니다. 큐가 넘치면 호출 스레드에서 직접 실행하므로 약간 느려질 수는 있지만, 카운트가 유실되진 않습니다.
수정(edit)이 복잡한 이유
글 생성과 삭제는 단순합니다. 생성하면 increment, 삭제하면 decrement.
수정은 경우의 수가 많다.
수정 시 고려해야 할 상태 전이 조합 ┌─────────────────────────────────────────────────────┐ │ 상태 전이 │ blog_tag_count │ global_tag_count│ ├─────────────────────┼────────────────┼─────────────────┤ │ DRAFT → PUBLISHED │ +all tags │ +all (if feed) │ │ PUBLISHED → DRAFT │ -all tags │ -all (if feed) │ │ PUBLISHED → PUBLISHED│ diff only │ diff only │ │ + 태그 변경 │ │ │ │ showInFeed OFF → ON │ (no change) │ +all tags │ │ showInFeed ON → OFF │ (no change) │ -all tags │ └─────────────────────┴────────────────┴─────────────────┘
publishTagCountEventOnEdit 메서드가 이 분기를 처리합니다. blog 쪽과 global 쪽 변경을 독립적으로 계산한 뒤, 둘 다 같은 내용이면 PostTagsChangedEvent.both()로 이벤트 하나만, 다르면 각각 blogOnly()와 globalOnly()를 따로 보냅니다.
showInFeed 플래그가 바뀌는 게 제일 까다로운데, blog_tag_count에는 영향이 없지만 global_tag_count에는 영향이 있다. 그래서 두 테이블의 변경 사항을 따로 계산합니다.
엔티티: increment()와 decrement()
BlogTagCount와 GlobalTagCount는 구조가 거의 같습니다.
@Entity public class BlogTagCount { private Long blogId; private String tagName; private Integer postCount; public void increment() { this.postCount++; } public void decrement() { this.postCount = Math.max(0, this.postCount - 1); } }
decrement()에 Math.max(0, ...)을 넣은 이유는 비동기 특성상 이벤트 순서가 뒤바뀔 수 있어서입니다. "삭제 이벤트 → 생성 이벤트" 순서로 처리되면 카운트가 음수가 되는데, 그건 막아야 합니다.
리스너에서 태그명은 trim().toLowerCase()로 정규화합니다. " Java "와 "java"가 같은 태그로 잡힌다.
테스트: 비동기인데 어떻게 검증하나
비동기 이벤트 처리 테스트에는 두 가지 방법이 있습니다.
- 통합 테스트로 실제 비동기 실행 후 Thread.sleep으로 대기
- 이벤트 발행 여부만 검증 (서비스 테스트) + 리스너 로직 별도 검증 (리스너 테스트)
2번을 골랐습니다. Thread.sleep 테스트는 불안정한 테스트의 원흉이니까요.
서비스 테스트에서는 ArgumentCaptor로 이벤트가 제대로 나갔는지 확인합니다.
ArgumentCaptor<PostTagsChangedEvent> captor = ArgumentCaptor.forClass(PostTagsChangedEvent.class); then(eventPublisher).should().publishEvent(captor.capture()); PostTagsChangedEvent event = captor.getValue(); assertThat(event.addedTags()).containsExactly("java", "spring"); assertThat(event.affectsGlobalCount()).isTrue();
리스너 테스트에서는 TagCountEventListener를 직접 호출해서 repository 상호작용을 검증합니다. @Async와 @TransactionalEventListener는 Spring이 처리하는 부분이니까, 단위 테스트에서는 순수 로직만 본다.
리스너 테스트는 12개 케이스를 커버합니다.
| 카테고리 | 테스트 케이스 |
|---|---|
| blog_tag_count | 태그 추가 시 increment, 새 태그 생성, 제거 시 decrement |
| 태그명 정규화, 빈 태그 무시, 추가+제거 동시 처리 | |
| null 리스트 처리, affectsBlogCount=false 시 스킵 | |
| global_tag_count | 추가 시 global increment, 제거 시 global decrement |
| 추가+제거 동시 처리, 기존 global 태그 increment | |
| globalOnly 이벤트 시 blog 미갱신 |
누락 인덱스도 같이
마이그레이션하면서 슬로우 쿼리에 영향을 줄 수 있는 인덱스 2개를 추가했습니다.
-- 태그명 단독 조회용 ALTER TABLE unified_post_tag ADD INDEX idx_tag_name (tag_name); -- 피드 조회 복합 인덱스 ALTER TABLE unified_post ADD INDEX idx_up_feed (show_in_feed, status, created_at DESC);
idx_tag_name은 태그 이름으로 글을 필터링할 때, idx_up_feed는 피드 목록의 WHERE show_in_feed = true AND status = 'PUBLISHED' ORDER BY created_at DESC 패턴을 타도록 만들었습니다.
마무리
정리하면 이렇습니다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 블로그 태그 목록 | 3-way JOIN + GROUP BY | SELECT * FROM blog_tag_count WHERE blog_id = ? |
| 홈 인기 태그 | 2-way JOIN + GROUP BY | SELECT * FROM global_tag_count LIMIT 20 |
| 카운트 갱신 | 서비스 메서드에서 직접 | 이벤트 발행 → 비동기 리스너 |
| 글 저장 응답 시간 | 카운트 갱신 포함 | 카운트 갱신 분리 |
대가는 쓰기 복잡도입니다. 태그가 바뀔 때 카운트를 직접 관리해야 하고, 비동기라서 숫자가 잠깐 안 맞을 수 있다. 하지만 "블로그 사이드바의 태그 옆 숫자가 1초간 7이 아니라 6으로 보인다"는 건, 솔직히 저도 모를 수준입니다.
읽기가 쓰기보다 훨씬 잦은 데이터라면 쓰기를 좀 복잡하게 만들더라도 읽기를 단순하게 가져가는 게 낫다고 봅니다. 쿼리 하나가 느린 건 참을 수 있지만, 사이드바 로딩 때마다 3-way JOIN이 도는 건 좀 아깝습니다.






댓글
댓글을 작성하려면 이 필요합니다.