Conversation
기존의 builder 방식은 매개변수로 처음에 받는 값들만 엔티티에 넣을 수 있었는데 builder를 직접 반환해서 메서드 체이닝을 통해 더 유연하게 변수를 주입받아 엔티티를 생성할 수 있게 수정
기존의 keys 방식(O(n)) -> 하나의 Hash에 저장하는 방식을 통해 성능개선
redis에 저장할 때 hash를 사용해서 전체 조회수 캐시를 가져오는 방식으로 바꿈. 이때 해당 작업 중 생기는 새로운 조회수 캐시 증가로 인해 레이스 컨디션이 발생할 수 있기 때문에 해시키의 이름을 변경하여 스냅샷을 획득하여 작업 진행
(1) 게시글 카테고리별로 조회하는 페이지 (2) 메인 페이지 수정 (3) 게시글 생성 리팩토링
Walkthrough백엔드에 카테고리(enum) 기반 게시판 기능과 홈 페이지 API가 추가되었고, 조회수 저장을 Redis 해시로 전환했습니다. 게시글 목록 API는 페이지/크기/카테고리 쿼리 파라미터로 변경되었습니다. 관련 DTO/레포지토리/서비스/시큐리티/더미데이터 및 테스트가 갱신되었고, 프런트는 카테고리 경로/홈·작성 UI를 재구성했습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant C as Client
participant PC as PostController
participant PS as PostService
participant PR as PostRepository
participant LR as LikeRepository
participant CR as CommentRepository
participant RR as ReadCountRedisService
C->>PC: GET /api/posts/home?perCategory=N
PC->>PS: homePagePosts(N)
loop for each Category
PS->>PR: findByCategoryAndDeletedAtIsNull(category, PageRequest(N, sort=createdAt desc,id desc))
PR-->>PS: Page<Post>
end
PS->>RR: getReadDeltaFromRedis(allPostIds)
RR-->>PS: Map<postId, readDelta>
PS->>LR: countByPostIds(allPostIds)
LR-->>PS: List<IdCount>
PS->>CR: countByPostIds(allPostIds)
CR-->>PS: List<IdCount>
PS-->>PC: HomePageResponse(sections...)
PC-->>C: 200 OK + JSON
sequenceDiagram
autonumber
participant S as Scheduler
participant VS as ViewCountSyncService
participant R as Redis (Hash)
participant DB as PostRepository
S->>VS: sync()
VS->>R: HLEN VIEW_COUNT_KEY? (rename to snapshot)
alt source exists
VS->>R: RENAME VIEW_COUNT_KEY -> VIEW_COUNT_KEY:snap:ts
loop scan in chunks (BATCH)
VS->>R: HSCAN snapshot
R-->>VS: entries [field=postId, value=delta]
loop for each entry
VS->>DB: findById(postId)
DB-->>VS: Post?
VS->>DB: apply readCount += delta (if present)
end
end
VS->>R: DEL snapshot
else no source
VS-->>S: exit
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Pre-merge checks (2 passed, 3 warnings)❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
Poem
Tip 👮 Agentic pre-merge checks are now available in preview!Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.
Please see the documentation for more information. Example: reviews:
pre_merge_checks:
custom_checks:
- name: "Undocumented Breaking Changes"
mode: "warning"
instructions: |
Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).Please share your feedback with us on this Discord post. ✨ Finishing touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 21
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (9)
backend/src/main/java/org/juniortown/backend/post/dto/request/PostEditRequest.java (1)
11-21: 중복 @builder로 인한 Lombok 충돌/컴파일 오류 가능성클래스와 생성자에 동시 @builder가 선언되어 있습니다. 하나만 유지하세요. 또한 Jackson 역직렬화를 안정적으로 하려면 기본 생성자를 두는 편이 안전합니다. 아래처럼 정리 제안:
package org.juniortown.backend.post.dto.request; import jakarta.validation.constraints.NotBlank; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AccessLevel; import lombok.ToString; @Getter @Builder @ToString -public class PostEditRequest { +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostEditRequest { @NotBlank(message = "타이틀을 입력해주세요.") private String title; @NotBlank(message = "컨텐츠를 입력해주세요.") private String content; - @Builder - public PostEditRequest(String title, String content) { - this.title = title; - this.content = content; - } }frontend/src/App.jsx (2)
1-1: /posts 및 /posts/category 기본 리다이렉트 추가로 링크 깨짐 방지다수 컴포넌트가 "/posts"로 이동합니다. 현재 라우트는 "/posts/category/:category"만 존재해 404가 납니다. 기본 카테고리(QUESTION)로 리다이렉트 라우트를 추가하세요.
-import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' @@ - <Route path="/posts/category/:category" element={<PostListPage />} /> + <Route path="/posts/category/:category" element={<PostListPage />} /> + <Route path="/posts" element={<Navigate to="/posts/category/QUESTION" replace />} /> + <Route path="/posts/category" element={<Navigate to="/posts/category/QUESTION" replace />} />Also applies to: 24-25
4-5:/posts라우트 누락으로 인한 404 방지 필요
PostDetailPage.jsxhandleDelete에서navigate('/posts')호출 시,App.jsx에/posts경로가 없어 404가 발생합니다.
App.jsx에<Route path="/posts" element={<PostListPage />} />추가- 또는 더 안전하게 홈(
/)이나 카테고리 목록(/posts/all등)으로 리다이렉트 경로를 명시적으로 변경backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java (1)
335-345: 조회수 해시 동기화 검증 로직이 잘못된 키를 확인합니다해시는 VIEW_COUNT_KEY(Hash)에 field=postId 형태로 저장되는데, 현재 테스트는 문자열 키 "VIEW_COUNT_KEY+postId"의 존재를 확인하고 있어 실질 검증이 되지 않습니다.
다음과 같이 필드 삭제 여부를 검증하도록 수정하세요.
- String key = ReadCountRedisService.VIEW_COUNT_KEY + postId; - readCountRedisTemplate.opsForHash().put(ReadCountRedisService.VIEW_COUNT_KEY, postId.toString(), 10L); + readCountRedisTemplate.opsForHash() + .put(ReadCountRedisService.VIEW_COUNT_KEY, postId.toString(), 10L); @@ - assertEquals(10L, result.getReadCount()); - assertFalse(readCountRedisTemplate.hasKey(key)); + assertEquals(10L, result.getReadCount()); + assertFalse(readCountRedisTemplate.opsForHash() + .hasKey(ReadCountRedisService.VIEW_COUNT_KEY, postId.toString()));backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java (2)
18-18: 프로덕션에서 더미 데이터가 생성됩니다
@Profile("!test")는 prod에서도 실행됩니다. 운영 데이터 오염 위험이 큽니다. 로컬/개발에서만 활성화하세요.-@Profile("!test") +@Profile({"local","dev"})
33-36: 재기동 시 회원가입 중복 실패 가능성고정 이메일로
signUp을 매번 호출하면 unique 제약에 걸릴 수 있습니다. 존재 시 생략/로그로 전환하세요.예시(파일 범위 밖 수정):
userRepository.findByEmail(signUpDTO.getEmail()) .orElseGet(() -> { authService.signUp(signUpDTO); return userRepository.findByEmail(signUpDTO.getEmail()) .orElseThrow(() -> new IllegalStateException("signUp 후 조회 실패")); });backend/src/test/java/org/juniortown/backend/post/controller/PostControllerPagingTest.java (1)
78-81: Redis Docker 이미지 버전 고정 권장
redis:8.0 사용이 의도된 것인지 확인하고, Testcontainers Best Practices에 따라 프로덕션 환경과 동일한 구체적인 버전(예: redis:7.2.0)으로 태그를 고정하세요. (docker.com, testcontainers.com)backend/src/main/java/org/juniortown/backend/post/service/PostService.java (1)
128-150: Redis 해시 마이그레이션 불일치
getReadCountFromRedisCache메서드가 여전히 이전의 키 기반 접근 방식(opsForValue().multiGet)을 사용하고 있습니다.ReadCountRedisService가 해시 기반으로 변경되었으므로 이 메서드도 업데이트되어야 합니다.이 메서드를
ReadCountRedisService.getReadDeltaFromRedis를 사용하도록 리팩토링하세요:private Map<Long,Long> getReadCountFromRedisCache(Page<PostWithLikeCountProjection> postPage) { List<Long> ids = postPage.getContent().stream() .map(PostWithLikeCountProjection::getId) .toList(); if(ids.isEmpty()) return Map.of(); - List<String> keys = ids.stream() - .map(id -> ReadCountRedisService.VIEW_COUNT_KEY + id) - .toList(); - try { - List<Long> readCounts = redisTemplate.opsForValue().multiGet(keys); - - HashMap<Long, Long> map = new HashMap<>(); - for (int i = 0; i < ids.size(); i++) { - Long readCount = (readCounts != null && i < readCounts.size()) ? readCounts.get(i) : null; - map.put(ids.get(i), (readCount != null) ? readCount : 0L); - } - return map; - } catch (DataAccessException e) { - log.error("Redis 장애: {}", e.getMessage()); - return ids.stream().collect(Collectors.toMap(id -> id, id -> 0L)); - } + try { + Map<String, Long> stringKeyMap = readCountRedisService.getReadDeltaFromRedis(ids); + return stringKeyMap.entrySet().stream() + .collect(Collectors.toMap( + e -> Long.valueOf(e.getKey()), + Map.Entry::getValue + )); + } catch (DataAccessException e) { + log.error("Redis 장애: {}", e.getMessage()); + return ids.stream().collect(Collectors.toMap(id -> id, id -> 0L)); + } }backend/src/main/java/org/juniortown/backend/post/controller/PostController.java (1)
40-41: null 체크 추가 필요주석에서 언급한 대로
customUserDetails가 null일 경우 NPE가 발생할 수 있습니다. 인증이 필수인 엔드포인트라면 null 체크를 추가하세요.- // 이거 null 체크를 해야하나? - Long userId = customUserDetails.getUserId(); + if (customUserDetails == null) { + throw new UnauthorizedException("인증이 필요합니다."); + } + Long userId = customUserDetails.getUserId();
🧹 Nitpick comments (53)
backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java (1)
1-6: 레이어 결합도 완화: projection 패키지 분리 제안comment/like 레포지토리가 post.dto.response 하위 타입에 의존합니다. 공용 projection 전용 패키지(e.g., org.juniortown.backend.common.projection 또는 post.projection)로 이동을 고려해 주세요. 또한 cnt → count로의 명확한 네이밍 변경도(연쇄 변경 포함) 중장기적으로 검토하면 가독성이 좋아집니다.
backend/src/main/java/org/juniortown/backend/post/entity/Category.java (2)
3-6: @Getter 불필요 — 제거 권장현재 enum에 인스턴스 필드가 없어 @Getter가 효과가 없습니다. 불필요한 Lombok 의존을 줄여 주세요.
package org.juniortown.backend.post.entity; -import lombok.Getter; - -@Getter public enum Category {
5-11: API 직렬화 문자열 규약 확인 필요(대/소문자, 구분자 일치)프론트는 'question', 'second_hand_market' 등 소문자/스네이크케이스를 가정합니다. 백엔드가 그대로 문자열을 내보내는 경우 'QUESTION'으로 내려가면 홈 섹션 매핑이 실패할 가능성이 큽니다. Enum 직렬화 규약을 일치시키거나 변환 계층을 둬 주세요. 예시(옵션):
+import com.fasterxml.jackson.annotation.JsonValue; ... public enum Category { QUESTION(), STUDY(), NOTICE(), SECOND_HAND_MARKET(), MENTOR() + + @JsonValue + public String toJson() { + return this.name().toLowerCase(); + } }적용 시 기존 클라이언트(다른 화면) 영향 범위도 함께 검증 부탁드립니다.
backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java (1)
15-17: 필드명 일관성: nickname ↔ username다른 DTO(PostWithLikeCount 등)와 'username' vs 'nickname' 명칭이 상이합니다. API 일관성을 위해 네이밍 통합(혹은 Swagger 문서에 명확화)을 검토해 주세요.
backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java (1)
16-21: 빈 목록 처리 확인 요청
:postIds가 빈 리스트일 때 일부 JPA 설정/DB에선 비효율 쿼리 또는 예외가 발생할 수 있습니다. 서비스 계층에서 빈 입력 시 즉시 빈 결과를 반환하도록 가드 로직 추가를 검토해 주세요.frontend/src/pages/Home.jsx (2)
36-45: 날짜 포맷 타임존 통일다른 페이지(PostListPage.jsx)와 동일하게 KST로 고정하면 표시가 일관됩니다.
- ? new Date(iso).toLocaleString('ko-KR', { + ? new Date(iso).toLocaleString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', - }) + timeZone: 'Asia/Seoul', + })
72-74: 불필요한 옵셔널 호출 제거Number.prototype.toLocaleString는 항상 존재합니다. 단순화하세요.
-const fmtNumber = (n) => - typeof n === 'number' ? n.toLocaleString?.('ko-KR') ?? `${n}` : '0'; +const fmtNumber = (n) => + typeof n === 'number' ? n.toLocaleString('ko-KR') : '0';frontend/src/pages/posts/PostCreatePage.jsx (2)
8-17: 카테고리 메타/정규화 중복 정의 — 공통 유틸로 분리 권장Home.jsx, PostListPage.jsx, 여기 파일에 동일(또는 유사)한 CATEGORY_META/ORDER 및 정규화 로직이 반복됩니다. 소스 간 불일치 위험이 큽니다. 공통 모듈(예: src/shared/categoryMeta.js)로 추출하여 단일 소스 오브 트루스로 관리하세요.
예시(새 파일: src/shared/categoryMeta.js):
export const CATEGORY_META = { QUESTION: { label: '질문', icon: '❓', tint: '#0d6efd' }, STUDY: { label: '스터디', icon: '📚', tint: '#20c997' }, NOTICE: { label: '공지', icon: '📢', tint: '#6f42c1' }, SECOND_HAND_MARKET: { label: '중고장터', icon: '💱', tint: '#fd7e14' }, MENTOR: { label: '멘토', icon: '🧭', tint: '#198754' }, }; export const CATEGORY_ORDER = ['QUESTION','STUDY','NOTICE','SECOND_HAND_MARKET','MENTOR']; const lowerMap = { question:'QUESTION', study:'STUDY', notice:'NOTICE', second_hand_market:'SECOND_HAND_MARKET', mentor:'MENTOR' }; export const normalizeCategory = (raw, { fallback = null } = {}) => { if (!raw) return fallback; const up = String(raw).toUpperCase(); if (CATEGORY_META[up]) return up; return lowerMap[String(raw).toLowerCase()] ?? fallback; };이후 각 페이지에서 import 하여 사용:
-const CATEGORY_META = { ... } -const CATEGORY_ORDER = [ ... ] -function normalizeCategoryFromQuery(raw) { ... } +import { CATEGORY_META, CATEGORY_ORDER, normalizeCategory as normalizeCategoryFromQuery } from '../../shared/categoryMeta';원하시면 PR 내 반영 패치까지 만들어 드립니다.
Also applies to: 18-31
126-135: 입력 UX 소소 개선: 글자수 카운터/최대 행 높이사용자가 즉시 길이를 파악할 수 있도록 제목 실시간 카운터를, 본문은 최소/최대 높이 제어를 권장합니다.
- <input + <div className="d-flex align-items-center justify-content-between"> + <input type="text" placeholder="제목을 입력하세요" className="form-control" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} - /> - <div className="form-text">최대 120자</div> + /> + <span className="ms-2 small text-muted">{title.trim().length}/120</span> + </div> + <div className="form-text">최대 120자</div>- <textarea + <textarea placeholder="내용을 입력하세요" rows={15} className="form-control" value={content} onChange={(e) => setContent(e.target.value)} + style={{ minHeight: 240, resize: 'vertical' }} />Also applies to: 139-146
frontend/src/pages/posts/PostListPage.jsx (3)
235-237: 접근성/현지화: 스크린 리더 텍스트 한글화visually-hidden 텍스트가 "Loading..."으로 고정되어 있습니다. 다른 한글 문구와 일관되게 한글화 권장.
- <span className="visually-hidden">Loading...</span> + <span className="visually-hidden">로딩 중...</span>
21-30: 카테고리 메타/정규화 중복 — 공통 모듈 사용 제안동일 상수/맵핑이 여기와 Home.jsx, PostCreatePage.jsx에 중복 정의되어 있습니다. 공통 유틸로 추출하여 재사용하면 유지보수 비용과 드리프트 위험을 줄일 수 있습니다.
적용 방향:
- src/shared/categoryMeta.js에서 CATEGORY_META/CATEGORY_ORDER/normalizeCategory export
- 본 파일에서는 import 하여 사용
원하시면 변경 패치 생성해 드립니다.
Also applies to: 31-44
141-145: 내부 enum 노출 최소화(선택)배지에 내부 키(예: QUESTION)를 그대로 노출하고 있습니다. 사용자에게는 한글 라벨만으로 충분하므로 내부 키 노출은 제거하는 편이 깔끔합니다.
- <Badge bg="light" text="dark" className="border"> - {category} - </Badge> + {/* 내부 키 배지는 제거하거나 tooltip 등 비가시적 정보로만 유지 */}frontend/src/App.jsx (1)
1-1: 불필요 import 정리(선택)BrowserRouter를 이 파일에서 사용하지 않으면 import에서 제거하세요. 빌드/린트 설정에 따라 경고가 발생할 수 있습니다.
backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java (2)
111-121: 중복 save 호출 제거 제안postRepository.save(post)가 두 번 호출됩니다. 두 번째 호출은 불필요합니다.
다음처럼 정리하세요.
post = postRepository.save( Post.builder() .title("테스트 게시글") .content("테스트 내용입니다.") .user(testUser) - .category(Category.NOTICE) - .build() - ); - postRepository.save(post); + .category(Category.NOTICE) + .build() + );
445-451: 지역 변수 네이밍 컨벤션CommentOwner → commentOwner로 카멜케이스 권장.
- User CommentOwner = userRepository.save(otherUser); + User commentOwner = userRepository.save(otherUser);그리고 아래 사용처도 함께 변경하세요.
- .user(CommentOwner) - .username(CommentOwner.getName()) + .user(commentOwner) + .username(commentOwner.getName())backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java (1)
95-98: Redis 태그 검증 완료
redis:8.0은 공식 태그(2025-08-13 기준 Stable)로 존재합니다. CI 안정성을 위해 최신 안정 마이너 릴리스인redis:8.2.1-alpine(또는redis:8.2.1) 사용을 권장합니다. (releasealert.dev)backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java (1)
3-11: 인터페이스 기반 프로젝션 전환 제안이 레포는 인터페이스 프로젝션 선호(과거 학습) 경향이 있습니다. IdCount를 인터페이스로 정의해 JPA가 직접 바인딩하도록 전환을 권장합니다.
참고: 이전 학습에 따르면 클래스 기반 DTO는 실제로 사용되지 않는 패턴이 있었습니다. 이번에도 동일하게 맞추면 일관성이 좋아집니다.
backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java (1)
37-45: 테스트 설명과 검증 불일치: updatedAt 미검증DisplayName은 updatedAt 변경 확인인데, 실제로는 title/content만 검증합니다. updatedAt도 함께 확인하세요.
- // when - post.update(editRequest); + // when + LocalDateTime before = post.getUpdatedAt(); + post.update(editRequest); @@ assertEquals("Changed Title", post.getTitle()); assertEquals("Changed Content", post.getContent()); + assertNotNull(post.getUpdatedAt()); + if (before != null) { + assertNotEquals(before, post.getUpdatedAt()); + }backend/src/main/java/org/juniortown/backend/post/entity/Post.java (1)
70-72: NPE 방지: 조회수 증가 파라미터 null 가드Long가 null이면 NPE가 납니다. primitive로 바꾸거나 null 가드를 추가하세요.
- public void addReadCount(Long redisReadCount) { - this.readCount += redisReadCount; - } + public void addReadCount(long delta) { + this.readCount += delta; + }또는:
- public void addReadCount(Long redisReadCount) { - this.readCount += redisReadCount; - } + public void addReadCount(Long delta) { + this.readCount += (delta == null ? 0L : delta); + }backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java (1)
13-13: DTO 불변성 유지: @Setter 제거 고려요청 DTO에 Setter는 불필요한 상태변경을 허용합니다. 빌더/생성자만 허용해 불변성을 유지하세요.
backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java (1)
45-51: 파라미터 네이밍 및 메서드 역할 명확화
Dummy_Post_Title같은 네이밍은 컨벤션에 맞지 않습니다. title로 교체하세요.-private void createPost(String Dummy_Post_Title, String content, User user, Category category) { +private void createPost(String title, String content, User user, Category category) { Post testPost1 = Post.builder() - .title(Dummy_Post_Title) + .title(title) .content(content) .user(user) .category(category) .build();backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageSection.java (1)
1-19: 카테고리 타입 일관성: enum 사용 고려백엔드 전반이 Category enum을 사용하므로 DTO도 enum으로 유지하면 계약 일관성과 타입 안정성이 좋아집니다. 프런트 표시명은 FE에서 매핑하세요.
package org.juniortown.backend.post.dto.response; +import org.juniortown.backend.post.entity.Category; import java.util.List; @@ public class HomePageSection { - private String category; + private Category category; private List<PostForHomePage> items; @Builder - public HomePageSection(String category, List<PostForHomePage> items) { + public HomePageSection(Category category, List<PostForHomePage> items) { this.category = category; this.items = items; } }backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java (1)
21-32: 카테고리 필터 추가는 방향 좋습니다삭제여부+카테고리 조건 명확하고 프로젝션도 적절합니다. 인덱스 추가와 함께 쓰면 좋습니다.
Also applies to: 35-43
backend/src/test/java/org/juniortown/backend/post/controller/PostControllerPagingTest.java (2)
362-367: 비회원 시나리오에 Authorization 헤더가 포함되어 있습니다“비회원” 테스트에서 JWT 헤더를 제거해 시나리오를 정확히 검증하세요.
- mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts") .param("page", String.valueOf(page)) .param("size", String.valueOf(pageSize)) .param("category", category) - .header("Authorization", jwt) )
43-43: 불필요/잘못된 import 제거
org.testcontainers.shaded.org.checkerframework...C는 불필요합니다. 삭제하세요.backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageResponse.java (1)
13-19: 필드 가시성 일관화
sections가 패키지 프라이빗입니다. 일관성을 위해 private로 지정하세요.- List<HomePageSection> sections; + private List<HomePageSection> sections;backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java (3)
48-50: 로그 오타메시지의 ‘에세’ → ‘에서’.
- log.info("동기화 락을 획득하지 못했습니다. 다른 인스턴스에세 이미 실행 중입니다."); + log.info("동기화 락을 획득하지 못했습니다. 다른 인스턴스에서 이미 실행 중입니다.");
84-92: DB I/O 최적화: ID별 합산 후 일괄 조회/업데이트현재 청크 내 엔트리마다
findById가 호출되어 최대 1000회 쿼리가 발생합니다. postId별 delta를 합산한 뒤findAllById로 일괄 로딩하고 업데이트하세요.- private void applyChunkToDbOnly(List<Map.Entry<Object, Object>> chunk) { - for (Map.Entry<Object, Object> e : chunk) { - Long postId = parsePostId(e.getKey()); - long delta = parseDelta(e.getValue()); - if(postId == null || delta == 0L) continue; - postRepository.findById(postId).ifPresent((Post p) -> p.addReadCount(delta)); - } - } + private void applyChunkToDbOnly(List<Map.Entry<Object, Object>> chunk) { + Map<Long, Long> sums = new java.util.HashMap<>(); + for (Map.Entry<Object, Object> e : chunk) { + Long postId = parsePostId(e.getKey()); + long delta = parseDelta(e.getValue()); + if (postId == null || delta == 0L) continue; + sums.merge(postId, delta, Long::sum); + } + if (sums.isEmpty()) return; + List<Post> posts = postRepository.findAllById(sums.keySet()); + for (Post p : posts) { + long d = sums.getOrDefault(p.getId(), 0L); + if (d != 0L) p.addReadCount(d); + } + }
125-139: 미사용 유틸 제거키 패턴 기반 처리 삭제 후
getPostIdFromKey는 사용되지 않습니다. 제거해 주세요.backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java (3)
47-48: 주석 처리된 코드 제거 필요주석 처리된 이전 구현 코드를 삭제하여 코드를 깔끔하게 유지하세요.
- //readCountRedisTemplate.opsForValue().increment(readCountKey); readCountRedisTemplate.opsForHash().increment(VIEW_COUNT_KEY, postId,1);
59-60: 주석 처리된 코드 제거 필요이전 구현의 주석을 제거하여 코드를 정리하세요.
- //Long readCount = readCountRedisTemplate.opsForValue().get(readCountKey); Long readCount = (Long)readCountRedisTemplate.opsForHash().get(VIEW_COUNT_KEY, postId);
64-83: 메서드 최적화 제안
getReadDeltaFromRedis메서드에서 Stream API를 더 효과적으로 활용하여 가독성을 개선할 수 있습니다.public Map<String, Long> getReadDeltaFromRedis(List<Long> postIds) { if(postIds == null || postIds.isEmpty()) return Map.of(); Collection<Object> postIdKeys = postIds.stream() .map(String::valueOf) .collect(Collectors.toList()); // multiGet으로 캐시된 조회수 가져오기 List<Object> deltas = readCountRedisTemplate.opsForHash() .multiGet(VIEW_COUNT_KEY, postIdKeys); - HashMap<String, Long> map = new HashMap<>(); - for (int i = 0; i < postIds.size(); i++) { - // 여기서도 바로 (Long)으로 캐스팅 하면 위험한가? - Object readCountCache = deltas.get(i); - if(readCountCache == null) readCountCache = 0L; - map.put(String.valueOf(postIds.get(i)), (Long)readCountCache); - } - return map; + return IntStream.range(0, postIds.size()) + .boxed() + .collect(Collectors.toMap( + i -> String.valueOf(postIds.get(i)), + i -> { + Object cache = deltas.get(i); + if (cache == null) return 0L; + if (cache instanceof Long) return (Long) cache; + if (cache instanceof Number) return ((Number) cache).longValue(); + return 0L; + } + )); }backend/src/main/java/org/juniortown/backend/post/service/PostService.java (2)
197-258: 홈페이지 조회 메서드 성능 최적화 가능
homePagePosts메서드가 잘 구현되었지만, 카테고리별로 순차적으로 쿼리를 실행하는 것은 성능상 비효율적일 수 있습니다.병렬 처리를 고려해보세요:
+import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; // 5개의 카테고리별로 최신 게시글 5개씩 조회 -for (Category category : categories) { - List<Post> posts = postRepository.findByCategoryAndDeletedAtIsNull(category, categoryPageRequest) - .getContent(); - byCategory.put(category, posts); - collected.addAll(posts); -} +List<CompletableFuture<Void>> futures = categories.stream() + .map(category -> CompletableFuture.runAsync(() -> { + List<Post> posts = postRepository.findByCategoryAndDeletedAtIsNull(category, categoryPageRequest) + .getContent(); + synchronized(byCategory) { + byCategory.put(category, posts); + collected.addAll(posts); + } + })) + .toList(); +CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();또한 Redis 캐싱을 추가하여 자주 조회되는 홈페이지 데이터의 응답 속도를 개선할 수 있습니다.
219-225: 빈 결과 처리 로직 개선게시글이 없을 때 빈 섹션 리스트를 반환하는 것은 좋지만, 카테고리 정보는 유지하는 것이 프론트엔드에서 더 유용할 수 있습니다.
// 게시글이 하나도 없는 경우에 대한 방어 코드 if (postIds.isEmpty()) { + // 카테고리는 유지하되 아이템만 비어있는 섹션들 반환 + List<HomePageSection> emptySections = categories.stream() + .map(cat -> HomePageSection.builder() + .category(cat.name().toLowerCase()) + .items(List.of()) + .build()) + .toList(); return HomePageResponse.builder() - .sections(List.of()) + .sections(emptySections) .createdAt(LocalDateTime.now()) .build(); }backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java (3)
31-78: 주석 처리된 JSON 예시 제거테스트 파일 상단의 JSON 예시 주석은 테스트 코드를 이해하는 데 도움이 되지만, 실제 응답 형식이 변경될 경우 혼란을 줄 수 있습니다. 별도의 문서로 관리하는 것이 좋습니다.
Line 31-78의 주석을 제거하거나 README 또는 API 문서로 이동하세요.
183-194: 테스트 메서드에 @DisplayName 누락
when_empty_category_returns_empty_array_and_no_repository_call테스트에@DisplayName어노테이션이 없어 일관성이 떨어집니다.@Test +@DisplayName("카테고리가 비어있을 때 빈 배열 반환 및 리포지토리 호출 없음") void when_empty_category_returns_empty_array_and_no_repository_call() {
189-191: 검증 로직 강화 필요빈 결과에 대한 검증이 부족합니다.
createdAt필드도 검증하면 좋겠습니다.assertNotNull(resp); assertEquals(0, resp.getSections().size()); +assertNotNull(resp.getCreatedAt()); +assertTrue(resp.getCreatedAt().isBefore(LocalDateTime.now().plusSeconds(1))); +assertTrue(resp.getCreatedAt().isAfter(LocalDateTime.now().minusSeconds(1)));backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java (4)
57-57:private접근 제어자 추가 필요일관성을 위해 다른 필드처럼
private접근 제어자를 추가하세요.- @Autowired - CommentRepository commentRepository; + @Autowired + private CommentRepository commentRepository;
59-59:private접근 제어자 추가 필요일관성을 위해 다른 필드처럼
private접근 제어자를 추가하세요.- @Autowired - LikeRepository likeRepository; + @Autowired + private LikeRepository likeRepository;
61-61:private접근 제어자 추가 필요일관성을 위해 다른 필드처럼
private접근 제어자를 추가하세요.- @Autowired - ReadCountRedisService readCountRedisService; + @Autowired + private ReadCountRedisService readCountRedisService;
61-61: 사용되지 않는 필드 제거 검토
readCountRedisService가 테스트에서 실제로 사용되지 않습니다. 필요 없다면 제거를 고려하세요.backend/src/main/java/org/juniortown/backend/post/controller/PostController.java (1)
58-59: 불필요한 주석 제거"202가 뭐임?" 같은 주석은 코드 리뷰나 개인 메모로 보입니다. 프로덕션 코드에서는 제거하세요.
- // 202가 뭐임? return ResponseEntity.status(HttpStatus.OK).body(response);backend/src/test/java/org/juniortown/backend/post/controller/PostControllerTest.java (2)
106-107: 하드코딩된 카테고리 값 개선
Categoryenum을 직접 사용할 수 있는데 문자열로 전달하고 있습니다. enum의 name() 메서드를 사용하는 것이 더 안전합니다.- .category("NOTICE") + .category(Category.NOTICE.name())
155-156: 중복된 카테고리 설정Line 155와 187에서 동일하게
"NOTICE"를 설정하고 있습니다. 일관성을 위해 Category enum을 활용하세요.- .category("NOTICE") + .category(Category.NOTICE.name())Also applies to: 187-188
backend/src/test/java/org/juniortown/backend/post/service/ViewCountServiceTest.java (2)
67-67: 제네릭 타입 파라미터 간격 일관성Line 45와 67에서 제네릭 타입 파라미터 사이의 간격이 다릅니다. 일관성을 위해 통일하세요.
- when(readCountRedisTemplate.<String,Long>opsForHash()).thenReturn(readCountValueOperations); + when(readCountRedisTemplate.<String, Long>opsForHash()).thenReturn(readCountValueOperations);
76-76: increment 메서드 호출 시 파라미터 간격 불일치다른 곳과 일관성을 위해 파라미터 사이에 공백을 추가하세요.
- verify(readCountValueOperations, never()).increment(readCountKey, postId,1); + verify(readCountValueOperations, never()).increment(readCountKey, postId, 1);backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java (2)
77-77: Category enum 직접 사용
Category.NOTICE.name()대신 enum을 직접 사용하는 것이 타입 안전성 측면에서 더 좋습니다.PostCreateRequest가 Category enum을 직접 받을 수 있도록 수정을 고려하세요.
302-302: 테스트에서 사용되지 않는 파라미터
getPosts_emptyPage테스트에서category파라미터를 when 절에 전달하지만, 실제로 빈 페이지를 반환하므로 카테고리별 필터링이 테스트되지 않습니다. 카테고리별 빈 결과를 명시적으로 테스트하려면 테스트명을 더 구체적으로 변경하세요.- @DisplayName("getPosts: 페이지 조회 시, 빈 페이지 반환") + @DisplayName("getPosts: NOTICE 카테고리 조회 시, 빈 페이지 반환")backend/src/test/java/org/juniortown/backend/util/TestBuilders.java (5)
11-11: 유틸리티 클래스 봉인(final) 및 인스턴스화 방지테스트용 정적 팩토리 컨테이너는 인스턴스화될 필요가 없습니다. final + private 생성자로 봉인해 주세요.
-public class TestBuilders { +public final class TestBuilders { + private TestBuilders() {}
41-41: LocalDateTime.now() 기본값은 테스트 플레이키니스를 유발할 수 있습니다정렬/필터/그룹핑 테스트에서 경계 타이밍 이슈가 날 수 있습니다. 고정 시각 상수로 대체하는 것을 권장합니다.
public static class PostBuilder { @@ - private LocalDateTime createdAt = LocalDateTime.now(); + private static final LocalDateTime DEFAULT_NOW = LocalDateTime.of(2024, 1, 1, 0, 0); + private LocalDateTime createdAt = DEFAULT_NOW; @@ public static class CommentBuilder { @@ - private LocalDateTime createdAt = LocalDateTime.now(); + private static final LocalDateTime DEFAULT_NOW = LocalDateTime.of(2024, 1, 1, 0, 0); + private LocalDateTime createdAt = DEFAULT_NOW;선호 시, Clock 주입(Clock.fixed) 기반으로도 제안 드릴 수 있습니다.
Also applies to: 74-74
38-38: 중첩 기본 엔티티가 또다시 ID를 주입합니다PostBuilder/CommentBuilder의 기본 user/post가 내부적으로 ID를 세팅한 인스턴스를 생성합니다. 위 ‘withoutId()’와 조합해 영속화 테스트에서 안전한 기본값을 사용하도록 바꾸는 것을 권장합니다.
- private User user = TestBuilders.user().build(); + private User user = TestBuilders.user().withoutId().build(); @@ - private Post post = TestBuilders.post().build(); - private User user = TestBuilders.user().build(); + private Post post = TestBuilders.post().withoutId().build(); + private User user = TestBuilders.user().withoutId().build();Also applies to: 69-71
16-21: UserBuilder에 password(및 필요 시 role) 세터 추가 제안비밀번호를 커스터마이즈해야 하는 테스트가 있을 수 있습니다. 최소 password 세터는 제공해 두는 편이 재사용성이 좋습니다.
private String password = "password"; @@ - public UserBuilder email(String email) { this.email = email; return this; } + public UserBuilder email(String email) { this.email = email; return this; } + public UserBuilder password(String password) { this.password = password; return this; } + // public UserBuilder role(String role) { this.role = role; return this; } // 필요 시
28-29: Reflection 문자열 상수화/헬퍼 메서드로 DRY 유지필드명이 바뀌면 테스트가 런타임에만 깨집니다.
setId/setCreatedAt같은 헬퍼를 두거나 필드명 상수화로 변경 리스크를 줄이세요.예:
private static void setField(Object target, String field, Object value) { ReflectionTestUtils.setField(target, field, value); }Also applies to: 58-60, 92-93
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (36)
backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java(1 hunks)backend/src/main/java/org/juniortown/backend/config/RedisConfig.java(1 hunks)backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java(1 hunks)backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java(2 hunks)backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/controller/PostController.java(5 hunks)backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java(2 hunks)backend/src/main/java/org/juniortown/backend/post/dto/request/PostEditRequest.java(2 hunks)backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageResponse.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageSection.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/entity/Category.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/entity/Post.java(2 hunks)backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java(2 hunks)backend/src/main/java/org/juniortown/backend/post/service/PostService.java(7 hunks)backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java(4 hunks)backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java(3 hunks)backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java(3 hunks)backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java(2 hunks)backend/src/test/java/org/juniortown/backend/config/RedisTestConfig.java(1 hunks)backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java(2 hunks)backend/src/test/java/org/juniortown/backend/post/controller/PostControllerPagingTest.java(14 hunks)backend/src/test/java/org/juniortown/backend/post/controller/PostControllerTest.java(13 hunks)backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java(1 hunks)backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java(4 hunks)backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java(2 hunks)backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java(1 hunks)backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java(13 hunks)backend/src/test/java/org/juniortown/backend/post/service/ViewCountServiceTest.java(3 hunks)backend/src/test/java/org/juniortown/backend/util/TestBuilders.java(1 hunks)backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java(0 hunks)frontend/src/App.jsx(1 hunks)frontend/src/pages/Home.jsx(1 hunks)frontend/src/pages/posts/PostCreatePage.jsx(1 hunks)frontend/src/pages/posts/PostListPage.jsx(4 hunks)
💤 Files with no reviewable changes (1)
- backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-07-26T02:32:29.865Z
Learnt from: Doncham
PR: Doncham/JuniorTown#12
File: backend/src/main/java/org/juniortown/backend/post/dto/response/PostResponse.java:18-20
Timestamp: 2025-07-26T02:32:29.865Z
Learning: PostResponse와 PostDetailResponse 클래스에서 readCount 필드는 Redis에서 실시간 조회수를 가져와 addReadCount() 메서드로 더해주는 로직 때문에 가변 필드로 유지되어야 한다. 단순한 불변성 일관성보다는 실제 비즈니스 로직의 필요성을 우선 고려해야 한다.
Applied to files:
backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.javabackend/src/main/java/org/juniortown/backend/post/service/PostService.java
📚 Learning: 2025-07-08T14:49:27.380Z
Learnt from: Doncham
PR: Doncham/JuniorTown#10
File: backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCount.java:20-28
Timestamp: 2025-07-08T14:49:27.380Z
Learning: 이 프로젝트에서는 JPA 인터페이스 기반 프로젝션(PostWithLikeCountProjection)을 사용하여 데이터를 조회하므로, 클래스 기반 DTO(PostWithLikeCount)는 실제로 사용되지 않는다.
Applied to files:
backend/src/main/java/org/juniortown/backend/post/controller/PostController.javabackend/src/main/java/org/juniortown/backend/post/service/PostService.javabackend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.javabackend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java
📚 Learning: 2025-07-26T02:35:05.695Z
Learnt from: Doncham
PR: Doncham/JuniorTown#12
File: backend/src/main/java/org/juniortown/backend/post/dto/response/PostResponse.java:40-52
Timestamp: 2025-07-26T02:35:05.695Z
Learning: PostResponse.from() 메서드에 기본값을 설정하면 update 작업에서도 해당 메서드를 사용하기 때문에 likeCount가 0으로 초기화되어 문제가 발생한다. 대신 update 작업을 위한 별도의 응답 클래스를 만드는 것이 권장된다.
Applied to files:
backend/src/main/java/org/juniortown/backend/post/service/PostService.java
🧬 Code graph analysis (25)
frontend/src/App.jsx (2)
frontend/src/pages/posts/PostListPage.jsx (1)
PostListPage(61-320)frontend/src/pages/posts/PostDetailPage.jsx (1)
useParams(57-258)
backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java (2)
backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (2)
Test(277-307)Test(407-437)backend/src/test/java/org/juniortown/backend/comment/service/CommentServiceTest.java (3)
Test(248-268)Test(62-97)Test(142-169)
backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java (2)
backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCountProjection.java (2)
PostWithLikeCountProjection(6-16)getId(7-7)backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCount.java (2)
PostWithLikeCount(9-41)PostWithLikeCount(20-28)
backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageResponse.java (3)
backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageSection.java (1)
Getter(9-19)backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java (1)
Getter(9-32)frontend/src/pages/Home.jsx (1)
sections(206-206)
backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java (2)
backend/src/test/java/org/juniortown/backend/util/TestBuilders.java (1)
TestBuilders(11-98)backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java (1)
TestDataUtil(8-38)
backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java (1)
backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (2)
SpringBootTest(55-438)Test(334-372)
backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java (2)
backend/src/test/java/org/juniortown/backend/util/TestBuilders.java (1)
TestBuilders(11-98)backend/src/test/java/org/juniortown/backend/post/service/ViewCountServiceTest.java (1)
ExtendWith(18-79)
frontend/src/pages/Home.jsx (2)
frontend/src/pages/posts/PostCreatePage.jsx (2)
CATEGORY_META(9-15)CATEGORY_ORDER(16-16)frontend/src/pages/posts/PostListPage.jsx (4)
CATEGORY_META(22-28)CATEGORY_ORDER(29-29)fmtDate(47-57)fmtNumber(58-59)
frontend/src/pages/posts/PostCreatePage.jsx (5)
frontend/src/pages/Home.jsx (2)
CATEGORY_META(15-21)CATEGORY_ORDER(33-33)frontend/src/pages/posts/PostListPage.jsx (6)
CATEGORY_META(22-28)CATEGORY_ORDER(29-29)lowerMap(36-42)navigate(62-62)category(65-68)error(72-72)frontend/src/pages/posts/PostEditPage.jsx (6)
navigate(13-13)useAuth(14-14)title(17-17)content(18-18)handleSubmit(43-69)error(20-20)frontend/src/auth/AuthContext.jsx (3)
useAuth(63-63)useAuth(63-63)token(22-22)frontend/src/pages/posts/CommentPage.jsx (3)
useAuth(14-14)CommentSection(7-250)e(16-35)
backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java (2)
backend/src/main/java/org/juniortown/backend/post/service/ViewCountService.java (2)
readCountUp(27-49)ViewCountService(13-64)backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java (1)
Test(326-342)
backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageSection.java (2)
backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageResponse.java (1)
Getter(10-20)backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java (1)
Getter(9-32)
backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java (1)
backend/src/main/java/org/juniortown/backend/post/service/ViewCountService.java (3)
ViewCountService(13-64)readCountUp(27-49)getReadCount(51-55)
backend/src/main/java/org/juniortown/backend/post/controller/PostController.java (1)
backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (3)
Test(277-307)Test(407-437)Test(374-405)
backend/src/test/java/org/juniortown/backend/util/TestBuilders.java (2)
backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java (2)
TestDataUtil(8-38)createPost(20-28)backend/src/main/java/org/juniortown/backend/user/entity/User.java (1)
User(21-47)
backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java (2)
backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java (5)
SpringBootTest(47-345)Test(231-271)Test(273-317)Test(136-162)Test(319-343)backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java (2)
SpringBootTest(54-456)Test(219-250)
backend/src/main/java/org/juniortown/backend/dummyData/DummyDataInit.java (2)
backend/src/test/java/org/juniortown/backend/util/TestDataUtil.java (2)
createPost(20-28)TestDataUtil(8-38)backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (1)
Test(277-307)
backend/src/main/java/org/juniortown/backend/post/service/PostService.java (5)
backend/src/main/java/org/juniortown/backend/post/exception/PostDeletePermissionDeniedException.java (1)
PostDeletePermissionDeniedException(6-18)backend/src/main/java/org/juniortown/backend/post/exception/PostNotFoundException.java (1)
PostNotFoundException(5-15)backend/src/main/java/org/juniortown/backend/post/exception/PostUpdatePermissionDeniedException.java (1)
PostUpdatePermissionDeniedException(6-17)frontend/src/pages/posts/PostListPage.jsx (3)
page(75-75)category(65-68)posts(70-70)frontend/src/pages/Home.jsx (1)
sections(206-206)
backend/src/main/java/org/juniortown/backend/post/dto/request/PostEditRequest.java (2)
backend/src/main/java/org/juniortown/backend/post/dto/PostEdit.java (2)
PostEdit(8-21)PostEdit(16-20)backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (3)
Test(277-307)Test(407-437)Test(374-405)
backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java (4)
backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCountProjection.java (1)
getLikeCount(11-11)backend/src/main/java/org/juniortown/backend/like/service/LikeService.java (1)
LikeService(18-64)backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCount.java (1)
PostWithLikeCount(9-41)backend/src/main/java/org/juniortown/backend/like/entity/Like.java (1)
Like(20-43)
frontend/src/pages/posts/PostListPage.jsx (2)
frontend/src/pages/Home.jsx (4)
CATEGORY_META(15-21)CATEGORY_ORDER(33-33)fmtDate(36-45)fmtNumber(72-73)frontend/src/pages/posts/PostCreatePage.jsx (4)
CATEGORY_META(9-15)CATEGORY_ORDER(16-16)lowerMap(23-29)category(39-39)
backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java (2)
backend/src/main/java/org/juniortown/backend/post/dto/PostEdit.java (1)
PostEdit(8-21)backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (2)
Test(277-307)Test(407-437)
backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java (4)
frontend/src/pages/posts/PostCreatePage.jsx (1)
category(39-39)frontend/src/pages/posts/PostListPage.jsx (1)
category(65-68)backend/src/main/java/org/juniortown/backend/post/repository/PostRepositoryImpl.java (1)
PostRepositoryImpl(14-26)backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCountProjection.java (1)
PostWithLikeCountProjection(6-16)
backend/src/main/java/org/juniortown/backend/post/entity/Post.java (3)
frontend/src/pages/posts/PostCreatePage.jsx (3)
category(39-39)title(40-40)content(41-41)backend/src/main/java/org/juniortown/backend/post/dto/PostEdit.java (2)
PostEdit(8-21)PostEdit(16-20)backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (1)
Test(277-307)
backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java (2)
backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageResponse.java (1)
Getter(10-20)backend/src/main/java/org/juniortown/backend/post/dto/response/HomePageSection.java (1)
Getter(9-19)
backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java (1)
backend/src/test/java/org/juniortown/backend/util/TestBuilders.java (1)
TestBuilders(11-98)
🔇 Additional comments (15)
backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java (1)
3-6: 프로젝션 인터페이스 정의 적절합니다.JPQL alias(postId, cnt)와 getter 이름이 정확히 매칭되어 Spring Data JPA 인터페이스 프로젝션이 정상 동작합니다.
backend/src/main/java/org/juniortown/backend/post/dto/response/PostForHomePage.java (1)
11-32: 홈 카드용 DTO 구성 양호합니다.필드 구성이 프론트 요구사항과 자연스럽게 매핑되며, null 허용 필드는 프론트에서 기본값 처리로 보완됩니다.
backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java (1)
16-21: JPQL 집계 프로젝션 적절합니다.
SELECT c.post.id AS postId, COUNT(c) AS cnt ...→IdCount인터페이스 프로젝션 매핑이 올바릅니다.frontend/src/pages/Home.jsx (1)
217-229: 쿼리 파라미터 명 확인 필요(limit vs perCategory 등)백엔드가 기대하는 파라미터명이
limit인지 확인해 주세요. 불일치 시 기본 5개가 보장되지 않을 수 있습니다.backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java (2)
65-71: 카테고리 필수 필드 반영 LGTM테스트 픽스처에 Category.NOTICE 지정 적절합니다.
147-150: 스크립트 실행 후 CustomException의 정의와 getStatusCode 동작 확인 필요.backend/src/test/java/org/juniortown/backend/comment/controller/CommentControllerTest.java (2)
117-118: 카테고리 지정 추가 LGTM
223-229: dummyPost에도 카테고리 지정 추가 LGTMbackend/src/test/java/org/juniortown/backend/config/RedisTestConfig.java (1)
37-39: 해시 직렬화 설정 검토 완료
readCountRedisTemplate에서 opsForValue() 호출은 모두 주석 처리되어 있으며, 실제 코드에서는 opsForHash()만 사용되고 있어 JDK 직렬화 우려가 없습니다.backend/src/test/java/org/juniortown/backend/comment/service/CommentTreeBuilderTest.java (1)
31-38: TestBuilders DSL 도입 LGTM테스트 픽스처 구성 가독성과 재사용성 개선에 도움 됩니다.
backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java (1)
132-133: 카테고리 필드 설정 LGTMbackend/src/main/java/org/juniortown/backend/config/RedisConfig.java (1)
39-41: 해시 직렬화 설정 전환 LGTM해시 기반 조회수 저장 전략과 일치합니다.
backend/src/main/java/org/juniortown/backend/post/controller/PostController.java (1)
63-73: 호환성 이슈 없음 –/api/posts/{page}호출 미사용
테스트(PostControllerPagingTest.java)와 프론트엔드 모두/api/posts?page=방식만 사용하고 있어 패스 변수 방식 호출이 존재하지 않습니다.Likely an incorrect or invalid review comment.
backend/src/test/java/org/juniortown/backend/util/TestBuilders.java (2)
11-98: 전반적으로 테스트 DSL 구성 훌륭합니다도메인 빌더 + Reflection 주입으로 테스트 가독성과 일관성이 좋아졌습니다. 카테고리/읽음수/작성시각까지 한 번에 세팅 가능한 점이 특히 유용합니다.
60-61: createdAt 필드명 일치 확인 완료 – 조치 불필요
BaseTimeEntity에protected LocalDateTime createdAt;가 정의되어 있으므로(BaseTimeEntity.java:18),ReflectionTestUtils.setField(p, "createdAt", …)호출 시 필드명이 일치하여 정상 동작합니다.
backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java
Outdated
Show resolved
Hide resolved
backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java
Show resolved
Hide resolved
| @Column(nullable = false) | ||
| @Enumerated(EnumType.STRING) | ||
| private Category category; | ||
|
|
||
| @Builder | ||
| public Post(String title, String content, User user) { | ||
| public Post(String title, String content, User user, Category category) { | ||
| this.title = title; | ||
| this.content = content; | ||
| this.user = user; | ||
| this.category = category; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
카테고리 null 방지 및 인덱스 고려 필요
- DB 레벨에 nullable=false만으로는 런타임 null 유입을 막기 어렵습니다. 생성자/빌더에서 null 방어를 추가해 주세요.
- 카테고리별 조회가 핵심이면 DB 인덱스가 필요합니다(카테고리+삭제여부, 정렬 컬럼 조합 포함).
적용 예(선택 1: Lombok @nonnull 사용):
@Getter
public class Post extends BaseTimeEntity {
@@
- @Column(nullable = false)
- @Enumerated(EnumType.STRING)
- private Category category;
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ private Category category;
@@
- public Post(String title, String content, User user, Category category) {
+ public Post(String title, String content, User user, @lombok.NonNull Category category) {
this.title = title;
this.content = content;
this.user = user;
this.category = category;
}인덱스 예(파일 범위 밖 변경 예시):
// 클래스 선언 위에 추가
// import jakarta.persistence.Table;
// import jakarta.persistence.Index;
@Table(indexes = {
@Index(name = "idx_post_category_deleted_created",
columnList = "category, deleted_at, created_at")
})🤖 Prompt for AI Agents
backend/src/main/java/org/juniortown/backend/post/entity/Post.java lines 49-59:
add null-checks for category at construction/builder level and declare DB index
for category+deleted_at+created_at; specifically, ensure the Post constructor
(and any Lombok builder) validates category is not null (throw
IllegalArgumentException or use Objects.requireNonNull) and set the field only
after validation, and add a @Table(indexes = {...}) annotation on the entity
class (file-scope change) with an index like "category, deleted_at, created_at"
to support category-based queries and soft-delete filtering.
There was a problem hiding this comment.
실무에서는 DTO와 엔티티에서 입력을 이중으로 검증하는구나.. 일단 나는 DTO에서만 막을게
There was a problem hiding this comment.
Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!
| public void update(PostEditRequest postEditRequest) { | ||
| this.title = postEditRequest.getTitle(); | ||
| this.content = postEditRequest.getContent(); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
엔티티가 API DTO(PostEditRequest)에 의존합니다
엔티티 레이어가 웹 DTO에 의존하면 계층 결합이 심해집니다. 순수 도메인 메서드로 바꾸고 매핑은 Service에서 처리해 주세요.
- public void update(PostEditRequest postEditRequest) {
- this.title = postEditRequest.getTitle();
- this.content = postEditRequest.getContent();
- }
+ public void update(String title, String content) {
+ this.title = title;
+ this.content = content;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public void update(PostEditRequest postEditRequest) { | |
| this.title = postEditRequest.getTitle(); | |
| this.content = postEditRequest.getContent(); | |
| } | |
| public void update(String title, String content) { | |
| this.title = title; | |
| this.content = content; | |
| } |
🤖 Prompt for AI Agents
In backend/src/main/java/org/juniortown/backend/post/entity/Post.java around
lines 65-68, the entity currently depends on the web DTO PostEditRequest; change
the entity method to a pure domain method that does not reference web DTOs
(e.g., replace public void update(PostEditRequest postEditRequest) with a
signature like public void update(String title, String content) or a
domain-specific DTO), move mapping from PostEditRequest to the service layer
where you call post.update(postEditRequest.getTitle(),
postEditRequest.getContent()), and update all callers to perform the
DTO-to-domain mapping in the service or controller so the entity layer has no
dependency on web DTO classes.
| const handleSubmit = async () => { | ||
| const postData = { title, content }; | ||
| if (!token) { | ||
| alert('로그인이 필요합니다.'); | ||
| navigate('/login', { replace: true }); | ||
| return; | ||
| } | ||
| if (!canSubmit) return; | ||
|
|
||
| const postData = { title: title.trim(), content: content.trim(), category }; |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
인증 토큰 전송 방식 확인 필요
토큰 체크는 하지만 axios 요청에 Authorization 헤더 세팅이 보이지 않습니다(인터셉터로 처리 중일 수도 있음). 서버가 Bearer 토큰을 요구한다면 누락 시 401이 납니다. 전역 인터셉터 또는 요청단 설정 여부 확인 부탁드립니다.
다음 스크립트로 인터셉터/기본 헤더 설정을 확인하세요:
원하시면 로그인 상태에서 생성 요청 E2E 테스트 코드도 추가해 드립니다.
Also applies to: 66-69
🏁 Script executed:
#!/bin/bash
# axios 인터셉터 및 Authorization 기본 헤더 설정 검색
rg -n -C2 -g '!**/dist/**' -P "axios\.interceptors|axios\.defaults\.headers\.common\.Authorization|Bearer\s+\$\{?token|Authorization:\s*`?Bearer"Length of output: 68
🏁 Script executed:
#!/bin/bash
# axios 인터셉터 및 Authorization 헤더 설정 검색(분할 패턴)
rg -n -C2 -g '!**/dist/**' -P 'axios\.interceptors'
rg -n -C2 -g '!**/dist/**' -P 'axios\.defaults\.headers\.common\.Authorization'
rg -n -C2 -g '!**/dist/**' -P 'Authorization\s*[:=]\s*Bearer'
rg -n -C2 -g '!**/dist/**' -P 'Bearer\s+\$\{?token'Length of output: 595
🏁 Script executed:
#!/bin/bash
# axios 인터셉터 및 Authorization 헤더 설정 재검색 (–g 필터 제거)
rg -n -C2 -P 'axios\.interceptors|axios\.defaults\.headers\.common\.Authorization|Authorization\s*[:=]\s*Bearer|Bearer\s+\$\{?token'Length of output: 0
🏁 Script executed:
#!/bin/bash
# axios 사용 파일 검색
rg -n -C1 'import\s+axios' --glob '*.js' --glob '*.jsx' --glob '*.ts' --glob '*.tsx'
rg -n -C1 'axios\.' --glob '*.js' --glob '*.jsx' --glob '*.ts' --glob '*.tsx'Length of output: 6131
Authorization 헤더에 Bearer 접두사 추가 필요
AuthContext.jsx 34줄:
// 기존
axios.defaults.headers.common['Authorization'] = token;
// 변경
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;🤖 Prompt for AI Agents
In frontend/src/pages/posts/PostCreatePage.jsx around lines 55 to 63: the submit
flow uses the auth token but the global axios Authorization header is set
without the Bearer prefix (see AuthContext.jsx line 34), so update the header
assignment to include the "Bearer " prefix when setting
axios.defaults.headers.common['Authorization'] (and clear/delete that header
when token is null) to ensure requests send a proper Bearer token; make this
change in AuthContext.jsx where the header is assigned.
There was a problem hiding this comment.
안해도 됩니다. 이미 AuthContext에서 axios.defaults.headers.common['Authorization'] = token;를 통해 전연적으로 처리하고 있어요
There was a problem hiding this comment.
@Doncham, 알겠습니다! 전역적으로 처리되고 있다면 별도 수정이 불필요하겠네요. 현재 구현이 잘 작동하고 있다면 그대로 유지하시는 게 맞습니다.
✏️ Learnings added
Learnt from: Doncham
PR: Doncham/JuniorTown#18
File: frontend/src/pages/posts/PostCreatePage.jsx:55-63
Timestamp: 2025-09-11T13:15:10.216Z
Learning: In the JuniorTown frontend codebase, authentication is handled globally via AuthContext.jsx which sets axios.defaults.headers.common['Authorization'] = token without requiring the "Bearer " prefix, and this setup works correctly with their backend implementation.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
| // 생성 후: 해당 카테고리 목록으로 이동 | ||
| // 서버가 생성된 게시글 id를 반환한다면 다음처럼 상세로 보내도 됨: | ||
| // const createdId = response?.data?.id; | ||
| // if (createdId) return navigate(`/posts/${createdId}`, { replace: true }); | ||
|
|
||
| navigate('/posts', { replace: true }); | ||
| navigate(`/`, { replace: true }); | ||
| } catch (error) { |
There was a problem hiding this comment.
🛠️ Refactor suggestion
생성 후 이동 경로 주석과 구현 불일치
주석은 “해당 카테고리 목록으로 이동”이라고 되어 있지만 실제 코드는 홈("/")으로 리다이렉트합니다. UX 일관성을 위해 카테고리 목록으로 이동하는 게 자연스럽습니다.
- navigate(`/`, { replace: true });
+ navigate(`/posts/category/${category}`, { replace: true });📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 생성 후: 해당 카테고리 목록으로 이동 | |
| // 서버가 생성된 게시글 id를 반환한다면 다음처럼 상세로 보내도 됨: | |
| // const createdId = response?.data?.id; | |
| // if (createdId) return navigate(`/posts/${createdId}`, { replace: true }); | |
| navigate('/posts', { replace: true }); | |
| navigate(`/`, { replace: true }); | |
| } catch (error) { | |
| // 생성 후: 해당 카테고리 목록으로 이동 | |
| // 서버가 생성된 게시글 id를 반환한다면 다음처럼 상세로 보내도 됨: | |
| // const createdId = response?.data?.id; | |
| // if (createdId) return navigate(`/posts/${createdId}`, { replace: true }); | |
| navigate(`/posts/category/${category}`, { replace: true }); | |
| } catch (error) { |
🤖 Prompt for AI Agents
frontend/src/pages/posts/PostCreatePage.jsx around lines 70 to 76: the comment
says to navigate to the category list after creating a post but the code
navigates to "/" (home). Change the navigation to point to the category list
route instead (e.g., navigate(`/categories/${selectedCategoryId}` or the
appropriate category-list path), passing replace: true; if the server returns
the created post id and you want to show details, use that id as noted in the
comment. Ensure you obtain the category id from form state or response and use
it in the navigate call so the implemented behavior matches the comment.
| <Button as={Link} to="/posts" variant="outline-secondary" size="sm" className="rounded-pill"> | ||
| 목록으로 | ||
| </Button> | ||
| </div> |
There was a problem hiding this comment.
🛠️ Refactor suggestion
/posts 경로가 라우팅에서 제거되었습니다 — 링크 깨짐
목록 버튼이 "/posts"로 이동합니다. App.jsx에서 "/posts" 라우트가 없어 404가 납니다. 카테고리 탭과 일관되게 "/posts/category/:category"로 이동하도록 수정하세요.
적용 예시:
- <Button as={Link} to="/posts" variant="outline-secondary" size="sm" className="rounded-pill">
+ <Button as={Link} to={`/posts/category/${category}`} variant="outline-secondary" size="sm" className="rounded-pill">
목록으로
</Button>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Button as={Link} to="/posts" variant="outline-secondary" size="sm" className="rounded-pill"> | |
| 목록으로 | |
| </Button> | |
| </div> | |
| <Button | |
| as={Link} | |
| to={`/posts/category/${category}`} | |
| variant="outline-secondary" | |
| size="sm" | |
| className="rounded-pill" | |
| > | |
| 목록으로 | |
| </Button> | |
| </div> |
🤖 Prompt for AI Agents
In frontend/src/pages/posts/PostCreatePage.jsx around lines 90-93, the "목록으로"
Button links to "/posts" which no longer exists and causes 404; change the Link
target to the category-aware route "/posts/category/:category" by using the
current category value from props/state (e.g., to={`/posts/category/${category
|| 'all'}`}), ensuring you reference the component's category variable or
fallback to a sensible default so the button navigates to the same category
listing as the category tab.
| const response = await axios.get('/api/posts', { | ||
| params: { | ||
| page: page, // 서버가 0-based 페이지 받는다고 가정 | ||
| category: category, // QUESTION 등 Enum 문자열 | ||
| sort: sort, // 정렬 키 (백엔드와 합의) | ||
| }, | ||
| signal: controller.signal, | ||
| }); | ||
|
|
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
정렬 파라미터 백엔드 계약 재확인 필요
sort 값을 latest/views/likes/comments로 보냅니다. Spring Data의 기본 sort 문법(sort=property,desc)을 쓰는지, 커스텀 파라미터인지 명확히 합의되어야 합니다. 계약 불일치 시 정렬이 무시되거나 400이 납니다.
검증 스크립트(백엔드 컨트롤러 시그니처/파라미터 확인):
필요 시, 프런트에서 sort를 서버 기대 형식으로 매핑하는 스위치를 추가해 드릴 수 있습니다.
Also applies to: 151-178
🏁 Script executed:
#!/bin/bash
# PostController/Service에서 sort 파라미터 처리 확인
rg -n -C3 -g '!**/build/**' -P "GetMapping\\(\"/api/posts\"\\)|@RequestParam\\(.*sort|PageRequest\\.|Sort\\.|orderBy|views|likes|comments" backend/Length of output: 0
🏁 Script executed:
#!/bin/bash
# Locate the Posts controller and API mapping
rg -nP 'class\s+\w*Post\w*Controller' -g '*.java'
rg -n -C3 '@GetMapping\("/api/posts"\)' -g '*.java'
# Check if Spring Data Pageable is used
rg -nP 'Pageable' -g '*.java'
# Look for explicit sort @RequestParam handling
rg -nP '@RequestParam.*sort' -g '*.java'Length of output: 3874
🏁 Script executed:
#!/bin/bash
# 출력: PostController 및 PostService 핵심 로ジック 확인
sed -n '1,200p' backend/src/main/java/org/juniortown/backend/post/controller/PostController.java
sed -n '1,200p' backend/src/main/java/org/juniortown/backend/post/service/PostService.javaLength of output: 11731
정렬 파라미터 미처리: 백엔드 고정 정렬 로직 vs. 프론트 sort 불일치
PostController.getPosts 엔드포인트는 @RequestParam sort를 수신하거나 처리하지 않고, 서비스에서는 항상 Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))로 고정 정렬합니다. 프론트에서 보내는 sort 값이 무시되므로, 백엔드가 지원할 정렬 키와 형식을 명확히 정의해 API를 확장하거나, 프론트에서 해당 파라미터 전송을 제거/조정해야 합니다.
· 대상: backend/src/main/java/org/juniortown/backend/post/controller/PostController.java – getPosts 메서드
🤖 Prompt for AI Agents
In frontend/src/pages/posts/PostListPage.jsx around lines 93 to 101 the code
sends a "sort" query param to /api/posts even though the backend's
PostController.getPosts currently ignores any sort input and uses a fixed
Sort.by(...); remove the "sort" field from the axios request params (or
conditionally omit it) so the frontend doesn't send a misleading/unused
parameter, and if you intend to support backend-driven sorting later, add a
clear TODO and update API docs or coordinate with backend to implement and agree
on the supported sort keys/format.
There was a problem hiding this comment.
@Doncham 네, 알겠습니다! 현재 PR 범위에서는 정렬 기능을 나중에 구현하신다는 점 이해했습니다.
frontend에서 sort 파라미터를 보내고 있지만 backend에서 아직 처리하지 않는 상황이므로, 향후 정렬 기능 구현 시 참고하시면 될 것 같습니다.
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java (1)
33-49: 테스트 이름과 검증 내용 불일치updatedAt 변경을 검증하지 않습니다. 엔티티 단위 테스트로는 JPA Auditing이 동작하지 않으니, 통합 테스트로 검증하거나 테스트명을 실제 검증에 맞게 바꾸세요.
- @DisplayName("Post 수정 시 updatedAt 값이 변경되는지 확인") + @DisplayName("Post 수정 시 제목/내용 변경 확인")backend/src/main/java/org/juniortown/backend/post/service/PostService.java (1)
188-195: viewerId null 처리 누락으로 인한 NPE 가능성쿠키가 없으면
viewerId가 null일 수 있어isLong(viewerId)에서 NPE가 발생합니다. null/blank 방어 로직을 추가하세요.- private boolean isLong(String str) { - try { - Long.parseLong(str); - return true; - } catch (NumberFormatException e) { - return false; - } - } + private boolean isLong(String str) { + if (str == null || str.isBlank()) return false; + try { + Long.parseLong(str); + return true; + } catch (NumberFormatException e) { + return false; + } + }
♻️ Duplicate comments (7)
frontend/src/pages/posts/PostListPage.jsx (2)
95-104: 정렬 파라미터는 백엔드 미지원 시 전송/노출하지 마세요이전 코멘트와 동일 이슈입니다. 현재 백엔드는 sort를 무시하거나 미구현일 수 있습니다. 미지원 상태라면 요청 파라미터에서 제거하고 UI도 비활성화/숨김 처리하세요.
간단 대응(요청 파라미터에서 제거):
const response = await axios.get('/api/posts', { params: { page: page, // 서버가 0-based 페이지 받는다고 가정 size: pageSize, // 페이지 크기 category: category, // QUESTION 등 Enum 문자열 - sort: sort, // 정렬 키 (백엔드와 합의) }, signal: controller.signal, });백엔드 지원 여부 확인 스크립트:
#!/bin/bash # sort 요청 파라미터 및 처리 로직 존재 여부 점검 rg -n -C3 -g '!**/build/**' -P '@GetMapping\("/api/posts"\)|@RequestParam\(.*sort|Sort\\.|orderBy|views|likes|comments' backend/Also applies to: 80-87, 157-185
288-289: 페이지 크기 하드코딩 제거 및 응답 size 반영 — 굿!row 번호 계산에 pageSize를 사용하고, 서버 응답의 size로 동기화하는 접근이 이전 지적 사항을 깔끔히 해결했습니다.
Also applies to: 95-109
backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java (1)
50-71: 해시 기반 스냅샷 vs 문자열 키 불일치 — 동기화/조회가 실패합니다 (긴급 수정 필요)검증 결과: ReadCountRedisService는 VIEW_COUNT_KEY를 해시로 사용해 opsForHash.increment/get/multiGet 하고 있고, ViewCountSyncService도 SRC_KEY(ReadCountRedisService.VIEW_COUNT_KEY)를 해시 스냅샷(HSCAN)으로 처리합니다. 반면 PostService는 ReadCountRedisService.VIEW_COUNT_KEY + id 형태의 문자열 키를 만들어 redisTemplate.opsForValue().multiGet으로 조회합니다. 이 불일치로 인해 동기화 대상 키가 없거나 WRONGTYPE 예외가 발생합니다.
증거(수정 필요 파일)
- backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java — opsForHash.increment / opsForHash.get / opsForHash().multiGet 사용.
- backend/src/main/java/org/juniortown/backend/post/service/PostService.java — .map(id -> ReadCountRedisService.VIEW_COUNT_KEY + id) 후 redisTemplate.opsForValue().multiGet 사용.
- backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java — SRC_KEY = ReadCountRedisService.VIEW_COUNT_KEY; redisTemplate.opsForHash().scan 사용.
수정안(중택)
- 권장(통일): 해시로 통일 — PostService와 관련 테스트를 해시 필드 조회/다중조회로 바꾸고(opsForHash.multiGet(VIEW_COUNT_KEY, postIdKeys) 등), ReadCountRedisService·ViewCountSyncService는 변경 없음.
- 대안: 문자열 키 유지 — ReadCountRedisService를 포스트별 문자열 키 쓰기(opsForValue.increment)로 되돌리거나 ViewCountSyncService를 SCAN post:viewCount:* → GET/DEL 처리(스냅샷 원자성 포기).
수정이 필요한 파일을 바로 고치세요: ReadCountRedisService.java, PostService.java, ViewCountSyncService.java, 관련 테스트들.
backend/src/main/java/org/juniortown/backend/post/entity/Post.java (1)
49-51: category null 방지 및 인덱스는 추후 검토(과거 논의 반영)DTO 단에서만 검증하겠다는 이전 논의를 존중합니다. 다만 운영 쿼리 패턴상 카테고리+삭제여부+정렬 컬럼 인덱스가 성능에 유의미할 수 있습니다. 다음 스프린트에서 엔티티 레벨 null 방지(또는 @NotNull)와 DB 인덱스를 재검토해 주세요.
Also applies to: 54-59
backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java (1)
58-62: 단일 조회에서의 캐스팅 제거(타입 안전)HashOperations 제네릭을 사용하면 불필요한
(Long)캐스팅을 제거할 수 있습니다.- Long readCount = (Long)readCountRedisTemplate.opsForHash().get(VIEW_COUNT_KEY, postId); + Long readCount = readCountRedisTemplate.<String, Long>opsForHash().get(VIEW_COUNT_KEY, postId);backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java (1)
114-136: 테스트 검증 로직 적절함홈페이지 조회 테스트가 카테고리별 정렬(createdAt DESC, id DESC)을 올바르게 검증하고 있습니다. 주석으로 정렬 기준을 명시한 것도 좋습니다.
backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java (1)
162-162: JPA Auditing으로 수정 시간 자동 관리됨
post.update(editRequest.getTitle(), editRequest.getContent())에서 수정 시간 업데이트가 없어도 JPA Auditing(@LastModifiedDate)이 자동으로 처리합니다.
🧹 Nitpick comments (19)
frontend/src/pages/posts/PostListPage.jsx (3)
47-61: fmtDate에서 Invalid Date 예외 가능성 처리ISO가 아니거나 파싱 실패 시 toLocaleString이 RangeError를 던질 수 있습니다. 안전 가드를 추가하세요.
-const fmtDate = (iso) => - iso - ? new Date(iso).toLocaleString('ko-KR', { - year: '2-digit', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - timeZone: 'Asia/Seoul', - }) - : ''; +const fmtDate = (iso) => { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZone: 'Asia/Seoul', + }); +};
32-45: 카테고리 슬러그 매핑 보강(하이픈/슬러그 대응)사용자가
second-hand-market처럼 하이픈 슬러그를 입력해도 정상 매핑되도록 항목을 보강하세요.const lowerMap = { question: 'QUESTION', study: 'STUDY', notice: 'NOTICE', second_hand_market: 'SECOND_HAND_MARKET', + 'second-hand-market': 'SECOND_HAND_MARKET', mentor: 'MENTOR', };
1-1: 파일 경로 주석 정합성주석의 상대 경로가 실제 경로와 다릅니다(
src/pages/posts/PostListPage.jsx). 혼동 방지를 위해 맞춰 주세요.backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java (4)
47-47: 오탈자 및 불필요한 주석 정리
- “에세” → “에서”
- “Number가 뭡니까?” 주석 제거 또는 “Number 인스턴스면 Long으로 변환” 등으로 정제
- log.info("동기화 락을 획득하지 못했습니다. 다른 인스턴스에세 이미 실행 중입니다."); + log.info("동기화 락을 획득하지 못했습니다. 다른 인스턴스에서 이미 실행 중입니다."); @@ - // Number가 뭡니까? + // Number 타입이면 Long으로 변환Also applies to: 94-99
114-121: RENAME 실패 처리 보강 제안키 없음 외에도 권한/네트워크 오류 등이 섞일 수 있습니다. 키 없음(NO KEY)과 기타 오류를 로그 레벨로 구분하거나 재시도 정책을 고려하세요.
124-138: 미사용 메서드 제거 또는 @deprecated 표시현재 해시 기반 경로에서는 사용되지 않습니다. 정리해 주세요.
- private Long getPostIdFromKey(String key) { - String[] parts = key.split(":"); - if (parts.length < 3) { - log.warn("잘못된 키 형식: {}", key); - return null; - } - Long postId; - try { - postId = Long.valueOf(parts[2]); - } catch (NumberFormatException e) { - log.warn("키에서 postId를 추출할 수 없습니다: {}", key); - return null; - } - return postId; - } + @Deprecated // 문자열 키 경로 제거 예정 + private Long getPostIdFromKey(String key) { return null; }
29-37: 키 상수 단일 출처화
SRC_KEY = ReadCountRedisService.VIEW_COUNT_KEY사용은 좋습니다. ViewCountService도 동일 상수를 참조하도록 모듈화(예: 전용 ReadCountRedisService만 사용)하면 경로 불일치가 재발하지 않습니다.backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java (1)
22-22: 코드 스타일 및 0건 누락 케이스 주의
- 가독성을 위해 공백 정리 제안.
- IN 절은 “좋아요가 0인 게시글”은 반환되지 않습니다. 호출부에서 기본값 0 merge 처리 필요합니다.
- List<IdCount> countByPostIds(@Param("postIds")List<Long>postIds); + List<IdCount> countByPostIds(@Param("postIds") List<Long> postIds);backend/src/main/java/org/juniortown/backend/post/entity/Post.java (1)
70-72: null 방어 추가 제안
addReadCount인자에 null이 유입될 수 있으면 NPE 위험이 있습니다. 방어 로직 추가를 권장합니다.- public void addReadCount(Long redisReadCount) { - this.readCount += redisReadCount; - } + public void addReadCount(Long redisReadCount) { + if (redisReadCount == null) return; + this.readCount += redisReadCount; + }backend/src/main/java/org/juniortown/backend/post/service/PostService.java (1)
223-224: 응답 타임스탬프는 Clock 기반으로 고정테스트 안정성과 일관성을 위해
LocalDateTime.now(clock)사용을 권장합니다. 한 번 계산해서 재사용하세요.- .createdAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now(clock)) @@ - .createdAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now(clock))Also applies to: 256-257
backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java (2)
29-33: 주석/상수 의미 정확화해시는 키 1개(
VIEW_COUNT_KEY)와 필드(postId) 조합입니다. 현재 주석은 이전 키 스킴을 가리키는 듯하여 혼동됩니다. 상수에 콜론을 남기는 것도 취향 차이나만, 해시 키라면post:viewCount로 고정하는 편이 일반적입니다.- // 조회수증분키 형태, post:viewCount:{postId}:{userId} - public static final String VIEW_COUNT_KEY = "post:viewCount:"; + // 조회수 해시 키, 필드는 postId + public static final String VIEW_COUNT_KEY = "post:viewCount";(키 문자열 변경 시 참조 전반 동시 수정 필요)
23-24: Redis 접근에 @transactional 필요성 재검토DB 트랜잭션과 무관한 Redis I/O이므로 클래스 레벨 트랜잭션은 불필요할 수 있습니다. 제거를 고려하세요.
-@Transactional public class ReadCountRedisService { +// @Transactional 제거: Redis I/O는 트랜잭션 불요 +public class ReadCountRedisService {backend/src/test/java/org/juniortown/backend/post/service/ReadCountRedisServiceTest.java (2)
76-78: verify 대상을 명시적으로 HashOperations로 검증체이닝된
verify(readCountRedisTemplate.opsForHash())대신, 모킹된readCountValueOperations를 직접 검증하는 것이 명확합니다.- verify(readCountValueOperations, never()).increment(readCountKey, postId,1); - verify(readCountRedisTemplate.opsForHash()).get(readCountKey, postId); + verify(readCountValueOperations, never()).increment(readCountKey, postId, 1); + verify(readCountValueOperations).get(readCountKey, postId);
35-55: 단일 케이스 외 배치 조회(getReadDeltaFromRedis) 테스트 추가 제안해시
multiGet경로에 대한 성공/널/타입 혼합 케이스를 추가하면 회귀를 막을 수 있습니다.추가 테스트 예시:
@Test @DisplayName("배치 조회수 - null/Number 혼재 시 0L/longValue로 매핑") void getReadDelta_batch_mixed_types() { List<Long> ids = List.of(1L, 2L, 3L); when(readCountRedisTemplate.<String, Long>opsForHash()).thenReturn(readCountValueOperations); when(readCountValueOperations.multiGet(ReadCountRedisService.VIEW_COUNT_KEY, List.of("1","2","3"))) .thenReturn(List.of(null, 5L, Integer.valueOf(7))); Map<Long, Long> map = readCountRedisService.getReadDeltaFromRedis(ids); Assertions.assertEquals(0L, map.get(1L)); Assertions.assertEquals(5L, map.get(2L)); Assertions.assertEquals(7L, map.get(3L)); }backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java (4)
31-78: 주석 블록 제거 필요Line 31-78의 JSON 주석 블록은 테스트 코드에 불필요합니다. 응답 예시를 문서화하려면 테스트 메서드 내
@DisplayName이나 JavaDoc으로 이동하는 것이 좋습니다.-// { -// "sections": [ -// { -// "category": "question", -// "items": [ -// { -// "postId": 21, -// "title": "title20", -// "userId": 1, -// "nickname": "testUser", -// "likeCount": 0, -// "commentCount": 0, -// "readCount": 0, -// "createdAt": "2025-08-31T07:11:39.535671" -// }, -// -// ] -// }, -// { -// "category": "study", -// "items": [ -// { -// "postId": 22, -// "title": "title21", -// "userId": 1, -// "nickname": "testUser", -// "likeCount": 0, -// "commentCount": 0, -// "readCount": 0, -// "createdAt": "2025-08-31T07:11:39.539753" -// } -// ] -// }, -// { -// "category": "notice", -// "items": [ -// { -// "postId": 23, -// "title": "title22", -// "userId": 1, -// "nickname": "testUser", -// "likeCount": 0, -// "commentCount": 0, -// "readCount": 0, -// "createdAt": "2025-08-31T07:11:39.544494" -// } -// "createdAt": "2025-08-31T16:11:54.634256" -// } -// }
94-101: 익명 클래스 대신 record 사용 권장
IdCount인터페이스 구현을 위해 익명 클래스를 사용하는 대신, Java 14+ record를 사용하면 더 간결합니다.-private IdCount idCount(long postId, long cnt) { - return new IdCount() { - @Override - public Long getPostId() { return postId; } - @Override - public Long getCount() { return cnt; } - }; -} +private record TestIdCount(Long postId, Long count) implements IdCount { + @Override + public Long getPostId() { return postId; } + @Override + public Long getCount() { return count; } +} + +private IdCount idCount(long postId, long cnt) { + return new TestIdCount(postId, cnt); +}
154-154: 불필요한 주석 제거Line 154의 주석은 테스트 로직 이해에 도움이 되지 않으므로 제거하는 것이 좋습니다.
-// 왜 cat[0]에 있는 값이랑 반환값의 첫번째 category가 같은건지 이해하기
186-186: 제네릭 타입 명시Line 186에서
PageImpl<Post>의 제네릭 타입을 명시적으로 지정하는 것이 좋습니다.-.thenReturn(new PageImpl<Post>(List.of())); +.thenReturn(new PageImpl<>(List.of()));backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java (1)
57-61: 필드 접근 제어자 일관성Line 57-61의
@Autowired필드들이 다른 필드와 달리private접근 제어자가 없습니다.@Autowired -CommentRepository commentRepository; +private CommentRepository commentRepository; @Autowired -LikeRepository likeRepository; +private LikeRepository likeRepository; @Autowired -ReadCountRedisService readCountRedisService; +private ReadCountRedisService readCountRedisService;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (19)
backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java(1 hunks)backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java(2 hunks)backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java(2 hunks)backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/entity/Category.java(1 hunks)backend/src/main/java/org/juniortown/backend/post/entity/Post.java(2 hunks)backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java(2 hunks)backend/src/main/java/org/juniortown/backend/post/service/PostService.java(7 hunks)backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java(4 hunks)backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java(3 hunks)backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java(1 hunks)backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java(2 hunks)backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java(1 hunks)backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java(13 hunks)backend/src/test/java/org/juniortown/backend/post/service/ReadCountRedisServiceTest.java(3 hunks)frontend/src/pages/Home.jsx(1 hunks)frontend/src/pages/posts/PostCreatePage.jsx(1 hunks)frontend/src/pages/posts/PostListPage.jsx(4 hunks)
🚧 Files skipped from review as they are similar to previous changes (8)
- backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java
- backend/src/main/java/org/juniortown/backend/post/dto/response/IdCount.java
- backend/src/main/java/org/juniortown/backend/post/dto/request/PostCreateRequest.java
- backend/src/main/java/org/juniortown/backend/comment/repository/CommentRepository.java
- frontend/src/pages/posts/PostCreatePage.jsx
- frontend/src/pages/Home.jsx
- backend/src/main/java/org/juniortown/backend/config/SecurityConfig.java
- backend/src/main/java/org/juniortown/backend/post/entity/Category.java
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-07-08T14:49:27.380Z
Learnt from: Doncham
PR: Doncham/JuniorTown#10
File: backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCount.java:20-28
Timestamp: 2025-07-08T14:49:27.380Z
Learning: 이 프로젝트에서는 JPA 인터페이스 기반 프로젝션(PostWithLikeCountProjection)을 사용하여 데이터를 조회하므로, 클래스 기반 DTO(PostWithLikeCount)는 실제로 사용되지 않는다.
Applied to files:
backend/src/main/java/org/juniortown/backend/post/service/PostService.javabackend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java
📚 Learning: 2025-07-26T02:32:29.865Z
Learnt from: Doncham
PR: Doncham/JuniorTown#12
File: backend/src/main/java/org/juniortown/backend/post/dto/response/PostResponse.java:18-20
Timestamp: 2025-07-26T02:32:29.865Z
Learning: PostResponse와 PostDetailResponse 클래스에서 readCount 필드는 Redis에서 실시간 조회수를 가져와 addReadCount() 메서드로 더해주는 로직 때문에 가변 필드로 유지되어야 한다. 단순한 불변성 일관성보다는 실제 비즈니스 로직의 필요성을 우선 고려해야 한다.
Applied to files:
backend/src/main/java/org/juniortown/backend/post/service/PostService.javabackend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java
📚 Learning: 2025-07-26T02:35:05.695Z
Learnt from: Doncham
PR: Doncham/JuniorTown#12
File: backend/src/main/java/org/juniortown/backend/post/dto/response/PostResponse.java:40-52
Timestamp: 2025-07-26T02:35:05.695Z
Learning: PostResponse.from() 메서드에 기본값을 설정하면 update 작업에서도 해당 메서드를 사용하기 때문에 likeCount가 0으로 초기화되어 문제가 발생한다. 대신 update 작업을 위한 별도의 응답 클래스를 만드는 것이 권장된다.
Applied to files:
backend/src/main/java/org/juniortown/backend/post/service/PostService.java
📚 Learning: 2025-09-11T13:01:23.309Z
Learnt from: Doncham
PR: Doncham/JuniorTown#18
File: backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java:162-162
Timestamp: 2025-09-11T13:01:23.309Z
Learning: Post 엔티티는 BaseTimeEntity를 상속받아 JPA Auditing(EntityListeners(AuditingEntityListener.class), LastModifiedDate)을 통해 수정 시간을 자동으로 관리하므로, update 메서드에서 명시적으로 timestamp를 설정할 필요가 없습니다.
Applied to files:
backend/src/main/java/org/juniortown/backend/post/entity/Post.javabackend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java
📚 Learning: 2025-09-11T13:01:23.309Z
Learnt from: Doncham
PR: Doncham/JuniorTown#18
File: backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java:162-162
Timestamp: 2025-09-11T13:01:23.309Z
Learning: Post 엔티티는 LastModifiedDate 어노테이션을 사용하여 JPA Auditing으로 수정 시간을 자동 관리하므로, update 메서드에서 명시적으로 timestamp를 설정할 필요가 없습니다.
Applied to files:
backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java
🧬 Code graph analysis (11)
backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java (2)
backend/src/main/java/org/juniortown/backend/post/service/ViewCountService.java (2)
readCountUp(27-49)ViewCountService(13-64)backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java (2)
Test(252-269)Test(326-342)
backend/src/main/java/org/juniortown/backend/post/service/PostService.java (1)
backend/src/main/java/org/juniortown/backend/post/controller/PostController.java (2)
RestController(31-83)PatchMapping(54-60)
backend/src/main/java/org/juniortown/backend/post/entity/Post.java (4)
frontend/src/pages/posts/PostCreatePage.jsx (3)
category(39-39)title(40-40)content(41-41)frontend/src/pages/posts/PostEditPage.jsx (2)
title(17-17)content(18-18)backend/src/main/java/org/juniortown/backend/post/dto/PostEdit.java (2)
PostEdit(8-21)PostEdit(16-20)backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (1)
Test(277-307)
backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java (3)
backend/src/main/java/org/juniortown/backend/like/service/LikeService.java (1)
LikeService(18-64)backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCount.java (1)
PostWithLikeCount(9-41)backend/src/main/java/org/juniortown/backend/like/entity/Like.java (1)
Like(20-43)
backend/src/test/java/org/juniortown/backend/post/controller/PostMainPageControllerTest.java (2)
backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java (5)
SpringBootTest(47-345)Test(231-271)Test(273-317)Test(136-162)Test(319-343)backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java (2)
SpringBootTest(54-456)Test(219-250)
backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java (2)
backend/src/test/java/org/juniortown/backend/util/TestBuilders.java (1)
TestBuilders(11-98)backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java (1)
ExtendWith(42-377)
backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java (2)
backend/src/test/java/org/juniortown/backend/controller/PostControllerTest.java (3)
Test(277-307)Test(407-437)Test(374-405)backend/src/test/java/org/juniortown/backend/comment/service/CommentServiceTest.java (1)
Test(392-413)
frontend/src/pages/posts/PostListPage.jsx (2)
frontend/src/pages/posts/PostDetailPage.jsx (6)
navigate(59-59)useParams(58-58)loading(63-63)error(64-64)post(62-62)useParams(57-258)frontend/src/pages/posts/CommentPage.jsx (1)
CommentSection(7-250)
backend/src/test/java/org/juniortown/backend/post/service/ReadCountRedisServiceTest.java (3)
backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java (1)
ExtendWith(79-195)backend/src/test/java/org/juniortown/backend/post/service/ViewCountServiceTest.java (3)
ViewCountServiceTest(17-78)incrementViewCount_success(34-54)setUp(29-32)backend/src/test/java/org/juniortown/backend/controller/PostRedisReadControllerTest.java (6)
Test(326-342)Test(252-269)Test(219-250)Test(179-199)Test(204-217)Test(271-291)
backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java (1)
backend/src/test/java/org/juniortown/backend/util/TestBuilders.java (1)
TestBuilders(11-98)
backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java (1)
backend/src/test/java/org/juniortown/backend/post/service/ViewCountServiceTest.java (1)
ViewCountServiceTest(17-78)
🔇 Additional comments (9)
frontend/src/pages/posts/PostListPage.jsx (1)
90-104: 요청 취소(AbortController) 적용 — 굿언마운트/재요청 시 중복 요청을 차단하는 안정적인 패턴입니다.
backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java (1)
18-22: 프로젝션 alias(getCount)와 JPQL alias(count) 일치 — OK
IdCount(postId, count)와 매칭되어 런타임 매핑 이슈 없겠습니다.backend/src/main/java/org/juniortown/backend/post/entity/Post.java (1)
65-68: 엔티티의 DTO 의존 제거 — 잘 반영되었습니다
update(String, String)로 순수 도메인 메서드가 되었습니다. 👍backend/src/main/java/org/juniortown/backend/post/service/PostService.java (1)
88-99: Update 경로의 DTO 분리 적용 좋습니다
PostEditRequest로 업데이트 경로를 분리한 점과 권한 체크 흐름 모두 적절합니다.backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java (2)
64-90: 배치 조회 API 구현 방향 LGTM빈/널 입력 방어, 안전한 Number 변환, 0L 기본값 처리까지 적절합니다.
34-55: 해시 전환 검증 — ValueOps 잔존 없음(주석 처리된 테스트만 존재)검사 결과: 조회수는 opsForHash로 전환되어 있고, 활성 opsForValue 사용처는 중복방지용 keyCheckRedisTemplate.opsForValue().get/set 뿐입니다 (backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java:38,45). 기존 ValueOps 기반 코드는 서비스 내부와 테스트에서 주석 처리되어 있습니다 (ReadCountRedisService 주석된 readCount opsForValue; backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java:353,371). 기능상 문제 없음 — 주석 처리된 테스트 정리 권장.
backend/src/test/java/org/juniortown/backend/post/service/ReadCountRedisServiceTest.java (1)
19-33: 클래스/모킹 전환 방향 LGTM해시 기반으로 테스트 대상을 전환하고
HashOperations를 모킹한 점 적절합니다.backend/src/test/java/org/juniortown/backend/post/service/HomePageServiceTest.java (1)
103-180: 테스트 코드 구조 개선 가능메인 페이지 조회 테스트가 잘 작성되었습니다. 특히 다양한 시나리오(카운트 누락 시 0 폴백, 정렬 순서 검증)를 커버하고 있어 좋습니다.
backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java (1)
326-337: TestBuilders 활용 개선됨테스트 데이터 생성 시
TestBuilders를 활용하여 가독성과 재사용성이 향상되었습니다.
| private final RedisTemplate<String, Long> redisTemplate; | ||
| private final static int PAGE_SIZE = 10; | ||
|
|
There was a problem hiding this comment.
Redis 해시 전환 불일치로 인한 조회수 누락 위험 (목록 API)
해시 기반으로 변경되었는데, 여기서는 여전히 per-key + ValueOps(multiGet)를 사용합니다. VIEW_COUNT_KEY + id 키는 더 이상 쓰이지 않아 목록의 readCount가 항상 0으로 나올 수 있습니다. 해시의 필드로 postId를 넣어 batch 조회하도록 수정하거나, 이미 주입된 readCountRedisService.getReadDeltaFromRedis(...)를 재사용하세요. 또한 redisTemplate 필드는 제거 가능합니다.
적용 diff:
@@
- private final RedisTemplate<String, Long> redisTemplate;
+ // Redis 직접 접근은 ReadCountRedisService로 일원화
@@ public Page<PostResponse> getPosts(Long userId, int page, int size, Category category) {
- Map<Long,Long> redisReadCounts = getReadCountFromRedisCache(postPage);
+ List<Long> ids = postPage.getContent().stream()
+ .map(PostWithLikeCountProjection::getId)
+ .toList();
+ Map<Long, Long> redisReadCounts = readCountRedisService.getReadDeltaFromRedis(ids);
@@
- private Map<Long,Long> getReadCountFromRedisCache(Page<PostWithLikeCountProjection> postPage) {
- List<Long> ids = postPage.getContent().stream()
- .map(PostWithLikeCountProjection::getId)
- .toList();
- if(ids.isEmpty()) return Map.of();
-
- List<String> keys = ids.stream()
- .map(id -> ReadCountRedisService.VIEW_COUNT_KEY + id)
- .toList();
- try {
- List<Long> readCounts = redisTemplate.opsForValue().multiGet(keys);
-
- HashMap<Long, Long> map = new HashMap<>();
- for (int i = 0; i < ids.size(); i++) {
- Long readCount = (readCounts != null && i < readCounts.size()) ? readCounts.get(i) : null;
- map.put(ids.get(i), (readCount != null) ? readCount : 0L);
- }
- return map;
- } catch (DataAccessException e) {
- log.error("Redis 장애: {}", e.getMessage());
- return ids.stream().collect(Collectors.toMap(id -> id, id -> 0L));
- }
- }
+ // 해시 기반으로 이전했으므로 더 이상 필요 없음불필요해진 import도 정리하세요:
- import org.springframework.dao.DataAccessException;
- import org.springframework.data.redis.core.RedisTemplate;
+ // removedAlso applies to: 101-126, 128-150
🤖 Prompt for AI Agents
In backend/src/main/java/org/juniortown/backend/post/service/PostService.java
around lines 59-60 (and also review lines 101-126, 128-150), the class still
declares and uses a per-key RedisTemplate<ValueOps>/multiGet pattern though the
app switched to a hash-based view count; this causes readCount to be missing.
Remove the unused RedisTemplate<String, Long> field and any related per-key
ValueOperations code, replace the multi-key per-key lookup with a hash-based
batch fetch (e.g., opsForHash().multiGet using VIEW_COUNT_KEY with a list of
postId fields) or simply call the existing
readCountRedisService.getReadDeltaFromRedis(...) to obtain deltas for multiple
posts, and update the code to set readCount from those results; finally, remove
any imports that became unused after this change.
| public Page<PostResponse> getPosts(Long userId, int page, int size, Category category) { | ||
| Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); | ||
|
|
||
| Page<PostWithLikeCountProjection> postPage = (userId == null) | ||
| ? postRepository.findAllWithLikeCountForNonUser(pageable) | ||
| : postRepository.findAllWithLikeCountForUser(userId, pageable); | ||
| ? postRepository.findAllWithLikeCountForNonUser(pageable, category) | ||
| : postRepository.findAllWithLikeCountForUser(userId, pageable, category); | ||
|
|
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
컨트롤러/호출부 시그니처 정합성 확인 필요
getPosts(Long userId, int page, int size, Category category)로 변경되었습니다. 모든 호출부(특히 컨트롤러)가 page/size/category를 넘기도록 업데이트되었는지 확인 부탁드립니다.
다음 스크립트로 호출부/구식 명칭을 점검해 주세요.
🏁 Script executed:
#!/bin/bash
# getPosts 호출 아리티 확인
rg -nP --type=java -C2 '\bpostService\.getPosts\s*\('
# 기존 ViewCountService/ValueOps 기반 조회수 접근 잔존 여부
rg -nP --type=java -C2 '\bViewCountService\b|post:viewCount:\s*\+|opsForValue\(\)\.multiGet|opsForValue\(\)\.(get|set|increment)\('Length of output: 6475
getPosts 호출부는 업데이트됨 — Redis read-count 접근 방식 불일치로 수정 필요
- 확인: 컨트롤러/테스트가 새 시그니처로 호출합니다 — backend/src/main/java/org/juniortown/backend/post/controller/PostController.java:70; backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java:231,273,305.
- 문제(수정 필요): ValueOps 기반 접근이 잔존합니다 — backend/src/main/java/org/juniortown/backend/post/service/ReadCountRedisService.java:38,45 (opsForValue.get/set), backend/src/main/java/org/juniortown/backend/post/service/PostService.java:138 (redisTemplate.opsForValue().multiGet(keys)), 관련 주석/테스트: backend/src/test/java/org/juniortown/backend/post/controller/PostRedisReadControllerTest.java:353,371.
- 조치: read-count 저장/조회 방식을 opsForHash(해시)로 통일하거나 PostService의 multiGet 호출을 VIEW_COUNT_KEY 해시 조회로 변경하여 일관성 확보.
🤖 Prompt for AI Agents
In backend/src/main/java/org/juniortown/backend/post/service/PostService.java
around lines 101-107 and ~138, the Redis read-count access is inconsistent with
ReadCountRedisService (which still uses opsForValue); unify to use hash
operations: change PostService's redisTemplate.opsForValue().multiGet(...) to
redisTemplate.opsForHash().multiGet(VIEW_COUNT_KEY, fields) (using the postId
values as hash fields) and update ReadCountRedisService (lines ~38 and ~45) to
use opsForHash get/put operations against the same VIEW_COUNT_KEY hash so both
read and write use the same hash-based storage.
| ScanOptions scanOptions = ScanOptions.scanOptions().count(BATCH).build(); | ||
| try (Cursor<Map.Entry<Object, Object>> cursor = redisTemplate.opsForHash().scan(snapKey, scanOptions)) { | ||
| List<Map.Entry<Object, Object>> chunk = new ArrayList<>(BATCH); |
There was a problem hiding this comment.
🛠️ Refactor suggestion
해시 타입 확인 없이 HSCAN 수행 — WRONGTYPE 방어 필요
snapKey가 해시가 아닐 경우 예외가 납니다. 타입을 확인하고 아니면 안전히 스킵/삭제하세요.
- ScanOptions scanOptions = ScanOptions.scanOptions().count(BATCH).build();
+ ScanOptions scanOptions = ScanOptions.scanOptions().count(BATCH).build();
+ org.springframework.data.redis.connection.DataType t = redisTemplate.type(snapKey);
+ if (t == null || t != org.springframework.data.redis.connection.DataType.HASH) {
+ log.warn("스냅샷 키 타입이 HASH가 아닙니다. type={}", t);
+ redisTemplate.delete(snapKey);
+ return;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ScanOptions scanOptions = ScanOptions.scanOptions().count(BATCH).build(); | |
| try (Cursor<Map.Entry<Object, Object>> cursor = redisTemplate.opsForHash().scan(snapKey, scanOptions)) { | |
| List<Map.Entry<Object, Object>> chunk = new ArrayList<>(BATCH); | |
| ScanOptions scanOptions = ScanOptions.scanOptions().count(BATCH).build(); | |
| org.springframework.data.redis.connection.DataType t = redisTemplate.type(snapKey); | |
| if (t == null || t != org.springframework.data.redis.connection.DataType.HASH) { | |
| log.warn("스냅샷 키 타입이 HASH가 아닙니다. type={}", t); | |
| redisTemplate.delete(snapKey); | |
| return; | |
| } | |
| try (Cursor<Map.Entry<Object, Object>> cursor = redisTemplate.opsForHash().scan(snapKey, scanOptions)) { | |
| List<Map.Entry<Object, Object>> chunk = new ArrayList<>(BATCH); |
🤖 Prompt for AI Agents
In
backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java
around lines 56-58, the code calls opsForHash().scan(snapKey, ...) without
verifying the Redis key type which throws WRONGTYPE if snapKey is not a hash;
change it to first check the key type via redisTemplate.type(snapKey) (or via
RedisConnection.type) and proceed with scan only when the type equals
DataType.HASH; if the type is null or not HASH, log a warning and either skip
processing that key or delete it, and also wrap the scan in a try/catch to
defensively handle WrongTypeException in case of race conditions.
| redisTemplate.delete(snapKey); | ||
| log.info("ViewCountSyncService.syncViewCounts() - 완료 (snapKey: {})", snapKey); | ||
|
|
There was a problem hiding this comment.
Redis 스냅샷 삭제 시점이 DB 트랜잭션과 분리됨 — 롤백 시 데이터 유실 위험
DB 업데이트에 실패해 트랜잭션이 롤백되어도 스냅샷을 먼저 삭제하면 증분이 사라집니다. 삭제를 “커밋 이후(afterCommit)”로 지연하세요.
다음과 같이 수정하세요:
- redisTemplate.delete(snapKey);
- log.info("ViewCountSyncService.syncViewCounts() - 완료 (snapKey: {})", snapKey);
+ org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(
+ new org.springframework.transaction.support.TransactionSynchronizationAdapter() {
+ @Override public void afterCommit() {
+ redisTemplate.delete(snapKey);
+ log.info("ViewCountSyncService.syncViewCounts() - 완료 (snapKey: {})", snapKey);
+ }
+ }
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| redisTemplate.delete(snapKey); | |
| log.info("ViewCountSyncService.syncViewCounts() - 완료 (snapKey: {})", snapKey); | |
| org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization( | |
| new org.springframework.transaction.support.TransactionSynchronizationAdapter() { | |
| @Override public void afterCommit() { | |
| redisTemplate.delete(snapKey); | |
| log.info("ViewCountSyncService.syncViewCounts() - 완료 (snapKey: {})", snapKey); | |
| } | |
| } | |
| ); |
🤖 Prompt for AI Agents
In
backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java
around lines 72 to 74, the code deletes the Redis snapshot immediately after the
DB update which can lose data if the DB transaction rolls back; change this to
run the redisTemplate.delete(snapKey) in an after-commit callback so deletion
only occurs when the transaction succeeds. Implement
TransactionSynchronizationManager.registerSynchronization(new
TransactionSynchronizationAdapter() { public void afterCommit() { try {
redisTemplate.delete(snapKey); log.info(...); } catch (Exception e) {
log.error(..., e); } } }) or use TransactionSynchronization.afterCommit /
@TransactionalEventListener to schedule the deletion after commit, and remove
the immediate delete call. Ensure proper exception handling and logging in the
after-commit callback.
| private void applyChunkToDbOnly(List<Map.Entry<Object, Object>> chunk) { | ||
| for (Map.Entry<Object, Object> e : chunk) { | ||
| Long postId = parsePostId(e.getKey()); | ||
| long delta = parseDelta(e.getValue()); | ||
| if(postId == null || delta == 0L) continue; | ||
|
|
||
| postRepository.findById(postId).ifPresent((Post p) -> p.addReadCount(delta)); | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
N+1 쿼리 발생 — chunk마다 findAllById로 일괄 로드하세요
findById 반복은 대량 처리 시 병목입니다. ID→delta 맵을 만든 뒤 findAllById로 한 번에 로드하고 엔티티에 적용하세요.
- private void applyChunkToDbOnly(List<Map.Entry<Object, Object>> chunk) {
- for (Map.Entry<Object, Object> e : chunk) {
- Long postId = parsePostId(e.getKey());
- long delta = parseDelta(e.getValue());
- if(postId == null || delta == 0L) continue;
-
- postRepository.findById(postId).ifPresent((Post p) -> p.addReadCount(delta));
- }
- }
+ private void applyChunkToDbOnly(List<Map.Entry<Object, Object>> chunk) {
+ java.util.Map<Long, Long> deltaById = new java.util.HashMap<>();
+ for (Map.Entry<Object, Object> e : chunk) {
+ Long postId = parsePostId(e.getKey());
+ long delta = parseDelta(e.getValue());
+ if (postId == null || delta == 0L) continue;
+ deltaById.merge(postId, delta, Long::sum);
+ }
+ if (deltaById.isEmpty()) return;
+ for (Post p : postRepository.findAllById(deltaById.keySet())) {
+ Long d = deltaById.get(p.getId());
+ if (d != null && d != 0L) p.addReadCount(d);
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private void applyChunkToDbOnly(List<Map.Entry<Object, Object>> chunk) { | |
| for (Map.Entry<Object, Object> e : chunk) { | |
| Long postId = parsePostId(e.getKey()); | |
| long delta = parseDelta(e.getValue()); | |
| if(postId == null || delta == 0L) continue; | |
| postRepository.findById(postId).ifPresent((Post p) -> p.addReadCount(delta)); | |
| } | |
| } | |
| private void applyChunkToDbOnly(List<Map.Entry<Object, Object>> chunk) { | |
| java.util.Map<Long, Long> deltaById = new java.util.HashMap<>(); | |
| for (Map.Entry<Object, Object> e : chunk) { | |
| Long postId = parsePostId(e.getKey()); | |
| long delta = parseDelta(e.getValue()); | |
| if (postId == null || delta == 0L) continue; | |
| deltaById.merge(postId, delta, Long::sum); | |
| } | |
| if (deltaById.isEmpty()) return; | |
| for (Post p : postRepository.findAllById(deltaById.keySet())) { | |
| Long d = deltaById.get(p.getId()); | |
| if (d != null && d != 0L) p.addReadCount(d); | |
| } | |
| } |
🤖 Prompt for AI Agents
In
backend/src/main/java/org/juniortown/backend/post/service/ViewCountSyncService.java
around lines 83 to 91, the current loop calls postRepository.findById for each
entry causing N+1 queries; instead collect all postIds and their deltas into a
Map<Long, Long>, call postRepository.findAllById once with that id set to load
entities in bulk, iterate the returned posts and apply the corresponding delta
(post.addReadCount(delta)) for each, then persist changes in bulk (e.g.,
postRepository.saveAll or rely on the surrounding transaction/flush); ensure you
skip null/zero deltas when building the id set and handle missing entities
gracefully.
| {/* 페이지네이션 */} | ||
| <Pagination className="justify-content-center mt-3"> | ||
| <Pagination.First onClick={() => setPage(0)} disabled={page === 0} /> | ||
| <Pagination.Prev | ||
| onClick={() => setPage(Math.max(0, page - 1))} | ||
| disabled={page === 0} | ||
| /> | ||
| {items} | ||
| <Pagination.Prev onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0} /> | ||
| {Array.from({ length: Math.max(0, endPage - startPage + 1) }).map((_, idx) => { | ||
| const number = startPage + idx; | ||
| return ( | ||
| <Pagination.Item key={number} active={number === page} onClick={() => setPage(number)}> | ||
| {number + 1} | ||
| </Pagination.Item> | ||
| ); | ||
| })} | ||
| <Pagination.Next | ||
| onClick={() => setPage(Math.min(totalPages - 1, page + 1))} | ||
| disabled={page === totalPages - 1} | ||
| /> | ||
| <Pagination.Last | ||
| onClick={() => setPage(totalPages - 1)} | ||
| onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} | ||
| disabled={page === totalPages - 1} | ||
| /> | ||
| <Pagination.Last onClick={() => setPage(totalPages - 1)} disabled={page === totalPages - 1} /> | ||
| </Pagination> |
There was a problem hiding this comment.
totalPages=0일 때 음수 페이지로 이동하는 페이징 버그
빈 결과에서 totalPages가 0이면 Next/Last 클릭 시 page가 -1로 내려갈 수 있습니다(비활성화 조건과 onClick 한계 계산에 직접 totalPages를 사용). 안전 상한/하한을 두세요.
다음 패치를 적용해 음수 이동을 방지하세요:
@@
-const currentGroup = Math.floor(page / GROUP_SIZE);
-const startPage = currentGroup * GROUP_SIZE;
-const endPage = Math.min(startPage + GROUP_SIZE - 1, Math.max(0, totalPages - 1));
+const currentGroup = Math.floor(page / GROUP_SIZE);
+const startPage = currentGroup * GROUP_SIZE;
+const totalPagesSafe = Math.max(1, totalPages);
+const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPagesSafe - 1);
@@
-<Pagination.Prev onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0} />
+<Pagination.Prev onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0} />
@@
-<Pagination.Next
- onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
- disabled={page === totalPages - 1}
-/>
+<Pagination.Next
+ onClick={() => setPage((p) => Math.min(totalPagesSafe - 1, p + 1))}
+ disabled={page === totalPagesSafe - 1}
+/>
-<Pagination.Last onClick={() => setPage(totalPages - 1)} disabled={page === totalPages - 1} />
+<Pagination.Last onClick={() => setPage(totalPagesSafe - 1)} disabled={page === totalPagesSafe - 1} />Also applies to: 123-127
🤖 Prompt for AI Agents
In frontend/src/pages/posts/PostListPage.jsx around lines 219 to 236 (also
applies to lines 123 to 127), the pagination onClick handlers use totalPages
directly which allows page to become -1 when totalPages is 0; update the
handlers and disabled conditions to clamp page updates within [0, Math.max(0,
totalPages - 1)] (use Math.max to ensure upper bound is at least 0) and ensure
Next/Last handlers compute the new page with Math.min(Math.max(0, totalPages -
1), current+1) or equivalent clamping so page never becomes negative or exceeds
the safe upper bound.
1.홈 페이지 생성, 카테고리별로 5개씩 게시글을 조회해서 메인페이지에 렌더링, 개인적으로 아주 어려웠음
2.레디스 캐시를 키 기반에서 해시 기반 스토어로 변경하여 스케줄링 작업 시 redis에 걸리는 부하를 줄임.
3.프론트 페이지 리팩토링 with gpt
closed #17
Summary by CodeRabbit
New Features
Improvements
Performance
Security