From 0148d540a05dd588a9410fa11d015ce7666a776e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Tue, 24 Mar 2026 22:04:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20getBusNoticeArticle=20N+1=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0=20-=20Projection=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 버스 공지 게시글 조회 시 Article 엔티티를 전체 로드하면서 @PostLoad updateAuthor()가 트리거되어 @OneToOne LAZY 관계 (koinArticle, koreatechArticle 등)에 대한 N+1 쿼리가 발생하던 문제 수정. 필요한 필드(id, title, createdAt)만 조회하는 BusArticleProjection을 도입해 엔티티 로드 없이 단일 쿼리로 처리되도록 개선. Co-Authored-By: Claude Sonnet 4.6 --- .../community/article/dto/BusArticleProjection.java | 12 ++++++++++++ .../article/model/redis/BusNoticeArticle.java | 8 ++++++++ .../article/repository/ArticleRepository.java | 6 ++++-- .../article/service/ArticleSyncService.java | 5 +++-- 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java new file mode 100644 index 0000000000..0e1781dd4c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.domain.community.article.dto; + +import java.time.LocalDateTime; + +public interface BusArticleProjection { + + Integer getId(); + + String getTitle(); + + LocalDateTime getCreatedAt(); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java b/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java index 4c3e062d5c..2a5ac7ac09 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java @@ -1,5 +1,6 @@ package in.koreatech.koin.domain.community.article.model.redis; +import in.koreatech.koin.domain.community.article.dto.BusArticleProjection; import in.koreatech.koin.domain.community.article.model.Article; import org.springframework.data.annotation.Id; import lombok.Builder; @@ -27,4 +28,11 @@ public static BusNoticeArticle from(Article article) { .title(article.getTitle()) .build(); } + + public static BusNoticeArticle from(BusArticleProjection projection) { + return BusNoticeArticle.builder() + .id(projection.getId()) + .title(projection.getTitle()) + .build(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java index daabf61dc3..425c787085 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java @@ -14,6 +14,7 @@ import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; +import in.koreatech.koin.domain.community.article.dto.BusArticleProjection; import in.koreatech.koin.domain.community.article.exception.ArticleNotFoundException; import in.koreatech.koin.domain.community.article.exception.BoardNotFoundException; import in.koreatech.koin.domain.community.article.model.Article; @@ -184,8 +185,9 @@ List
findAllByRegisteredAtIsAfterExcludingBoardId(@Param("registeredAt" @Query("SELECT a.title FROM Article a WHERE a.id = :id") String getTitleById(@Param("id") Integer id); - @Query(value = "SELECT * FROM new_articles a " + @Query(value = "SELECT a.id AS id, a.title AS title, a.created_at AS createdAt " + + "FROM new_articles a " + "WHERE a.title REGEXP '통학버스|등교버스|셔틀버스|하교버스' AND a.is_notice = true " + "ORDER BY a.created_at DESC LIMIT 5", nativeQuery = true) - List
findBusArticlesTop5OrderByCreatedAtDesc(); + List findBusArticlesTop5OrderByCreatedAtDesc(); } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleSyncService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleSyncService.java index 28ebcf226d..e81a26d5fc 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleSyncService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleSyncService.java @@ -1,5 +1,6 @@ package in.koreatech.koin.domain.community.article.service; +import in.koreatech.koin.domain.community.article.dto.BusArticleProjection; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.ArticleSearchKeyword; import in.koreatech.koin.domain.community.article.model.ArticleSearchKeywordIpMap; @@ -75,9 +76,9 @@ public void updateHotArticles() { @Transactional public void updateBusNoticeArticle() { - List
articles = articleRepository.findBusArticlesTop5OrderByCreatedAtDesc(); + List articles = articleRepository.findBusArticlesTop5OrderByCreatedAtDesc(); LocalDate latestDate = articles.get(0).getCreatedAt().toLocalDate(); - List
latestArticles = articles.stream() + List latestArticles = articles.stream() .filter(article -> article.getCreatedAt().toLocalDate().isEqual(latestDate)) .toList(); From 87653fb803a6222bef29e2615d16a2cab0f51916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Tue, 24 Mar 2026 22:09:56 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20BusArticleProjection=EC=9D=84?= =?UTF-8?q?=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=BC=EB=B0=98=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../article/dto/BusArticleProjection.java | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java index 0e1781dd4c..bddaa9d950 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java @@ -2,11 +2,27 @@ import java.time.LocalDateTime; -public interface BusArticleProjection { +public class BusArticleProjection { - Integer getId(); + private final Integer id; + private final String title; + private final LocalDateTime createdAt; - String getTitle(); + public BusArticleProjection(Integer id, String title, LocalDateTime createdAt) { + this.id = id; + this.title = title; + this.createdAt = createdAt; + } - LocalDateTime getCreatedAt(); + public Integer getId() { + return id; + } + + public String getTitle() { + return title; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } } From 1e82b63ee2a2b62682d324b01ff82e2f7aab6024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=9D=B8=ED=99=94?= Date: Tue, 24 Mar 2026 23:39:01 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20BusArticleProjection=EC=9D=84?= =?UTF-8?q?=20record=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20JPQL=20NE?= =?UTF-8?q?W=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BusArticleProjection class → record로 교체 - Native Query + REGEXP → JPQL + NEW 키워드 + LIKE 조건으로 변경 - LIMIT 5 → Pageable(PageRequest.of(0, 5))로 처리 - BusNoticeArticle.from() → of()로 정리 Co-Authored-By: Claude Sonnet 4.6 --- .../article/dto/BusArticleProjection.java | 27 ++-------- .../article/model/redis/BusNoticeArticle.java | 20 +++---- .../article/repository/ArticleRepository.java | 20 +++++-- .../article/service/ArticleSyncService.java | 53 ++++++++++--------- 4 files changed, 55 insertions(+), 65 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java index bddaa9d950..cb14cb86af 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/BusArticleProjection.java @@ -2,27 +2,10 @@ import java.time.LocalDateTime; -public class BusArticleProjection { +public record BusArticleProjection( + Integer id, + String title, + LocalDateTime createdAt +) { - private final Integer id; - private final String title; - private final LocalDateTime createdAt; - - public BusArticleProjection(Integer id, String title, LocalDateTime createdAt) { - this.id = id; - this.title = title; - this.createdAt = createdAt; - } - - public Integer getId() { - return id; - } - - public String getTitle() { - return title; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java b/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java index 2a5ac7ac09..7453fe0e2a 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/model/redis/BusNoticeArticle.java @@ -1,11 +1,10 @@ package in.koreatech.koin.domain.community.article.model.redis; -import in.koreatech.koin.domain.community.article.dto.BusArticleProjection; -import in.koreatech.koin.domain.community.article.model.Article; import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + import lombok.Builder; import lombok.Getter; -import org.springframework.data.redis.core.RedisHash; @Getter @RedisHash(value = "busNoticeArticle") @@ -22,17 +21,10 @@ private BusNoticeArticle(Integer id, String title) { this.title = title; } - public static BusNoticeArticle from(Article article) { - return BusNoticeArticle.builder() - .id(article.getId()) - .title(article.getTitle()) - .build(); - } - - public static BusNoticeArticle from(BusArticleProjection projection) { + public static BusNoticeArticle of(int id, String title) { return BusNoticeArticle.builder() - .id(projection.getId()) - .title(projection.getTitle()) - .build(); + .id(id) + .title(title) + .build(); } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java index 425c787085..1733dc9973 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java @@ -185,9 +185,19 @@ List
findAllByRegisteredAtIsAfterExcludingBoardId(@Param("registeredAt" @Query("SELECT a.title FROM Article a WHERE a.id = :id") String getTitleById(@Param("id") Integer id); - @Query(value = "SELECT a.id AS id, a.title AS title, a.created_at AS createdAt " - + "FROM new_articles a " - + "WHERE a.title REGEXP '통학버스|등교버스|셔틀버스|하교버스' AND a.is_notice = true " - + "ORDER BY a.created_at DESC LIMIT 5", nativeQuery = true) - List findBusArticlesTop5OrderByCreatedAtDesc(); + @Query(""" + SELECT new in.koreatech.koin.domain.community.article.dto.BusArticleProjection( + a.id, a.title, a.createdAt + ) + FROM Article a + WHERE ( + a.title LIKE '%통학버스%' + OR a.title LIKE '%등교버스%' + OR a.title LIKE '%셔틀버스%' + OR a.title LIKE '%하교버스%' + ) + AND a.isNotice = true + ORDER BY a.createdAt DESC + """) + List findBusArticlesTop5OrderByCreatedAtDesc(Pageable pageable); } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleSyncService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleSyncService.java index e81a26d5fc..f4422c2e03 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleSyncService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleSyncService.java @@ -1,5 +1,20 @@ package in.koreatech.koin.domain.community.article.service; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations.TypedTuple; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import in.koreatech.koin.domain.community.article.dto.BusArticleProjection; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.ArticleSearchKeyword; @@ -15,20 +30,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ZSetOperations.TypedTuple; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Clock; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - @Slf4j @Service @RequiredArgsConstructor @@ -76,10 +77,11 @@ public void updateHotArticles() { @Transactional public void updateBusNoticeArticle() { - List articles = articleRepository.findBusArticlesTop5OrderByCreatedAtDesc(); - LocalDate latestDate = articles.get(0).getCreatedAt().toLocalDate(); + List articles = articleRepository.findBusArticlesTop5OrderByCreatedAtDesc( + PageRequest.of(0, 5)); + LocalDate latestDate = articles.get(0).createdAt().toLocalDate(); List latestArticles = articles.stream() - .filter(article -> article.getCreatedAt().toLocalDate().isEqual(latestDate)) + .filter(article -> article.createdAt().toLocalDate().isEqual(latestDate)) .toList(); if (latestArticles.size() >= 2) { @@ -89,21 +91,23 @@ public void updateBusNoticeArticle() { int secondWeight = 0; // 제목(title)에 "사과"가 들어가면 후순위, "긴급"이 포함되면 우선순위 - if (first.getTitle().contains("사과")) + if (first.title().contains("사과")) firstWeight++; - if (first.getTitle().contains("긴급")) + if (first.title().contains("긴급")) firstWeight--; - if (second.getTitle().contains("사과")) + if (second.title().contains("사과")) secondWeight++; - if (second.getTitle().contains("긴급")) + if (second.title().contains("긴급")) secondWeight--; return Integer.compare(firstWeight, secondWeight); }) .toList(); } - busArticleRepository.save(BusNoticeArticle.from(latestArticles.get(0))); + + BusArticleProjection latestArticle = latestArticles.get(0); + busArticleRepository.save(BusNoticeArticle.of(latestArticle.id(), latestArticle.title())); } @Transactional @@ -131,9 +135,10 @@ private void syncAllIpSearchCounts() { String ipAddress = ipKey.replace(IP_SEARCH_COUNT_PREFIX, ""); for (Map.Entry entry : keywordSearchCounts.entrySet()) { - String searchedKeyword = (String) entry.getKey(); + String searchedKeyword = (String)entry.getKey(); int searchCount = Integer.parseInt(entry.getValue().toString()); - if (searchCount <= 0) continue; + if (searchCount <= 0) + continue; articleSearchKeywordRepository.findByKeyword(searchedKeyword).ifPresent(keywordEntity -> { ipMapRepository.findByArticleSearchKeywordAndIpAddress(keywordEntity, ipAddress)