Conversation
- 차후 각 서비스로 이전 예정
- PostDetailResponseDTO, PollInfoResponseDTO를 포함하는 PostPageItemResponseDTO 래퍼 도입 - 목록/상세/베스트 등 모든 게시글 응답을 래퍼 DTO(List<PostPageItemResponseDTO> 등)로 일원화 - 중복 DTO 변환 로직 toPageItemDTO로 통합, Object 반환/instanceof 분기 제거
- 이전 postDetailResponse 변경 전파 사항
- PostService의 이미지, 조회수, 베스트, 투표 등 부가 기능/서브 로직을 SeperatedPostService로 분리 - PostService는 핵심 비즈니스 흐름/응답 조립에만 집중하도록 응집도(cohesion) 강화 - 부가 기능은 별도 서비스로 위임하여 결합도(coupling) 낮춤 - 테스트 코드도 DI 구조에 맞게 리팩토링
- post, comment, reply 의 user 정보 mapping
- 댓글/대댓글 추가/삭제 시 댓글 수 및 BestScore 반영 순서 정비 - 조회 로직 내 사용자 정보 매핑, 응답 DTO 생성 등 책임 분리 - 중복 제거 및 성능 고려 유저 정보 조회 방식 적용
- 상위 도메인 Repository 직접 참조 제거 (Comment → Post 등) - 상위 도메인 쿼리 서비스(PostQueryService 등)에서 유효성 검사 처리 - 의존성 순환 문제 발생 → Query/Command 분리로 해결
- Post 도메인 내 Scheduler 및 ErrorCode 의존성 정리 - 도메인 계층별로 올바른 ErrorCode 및 예외 처리 방식 적용
- 각 서비스내 redisBestService 호출 Best Domain으로 책임 이동
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the 📝 WalkthroughWalkthrough이 PR은 Post/Comment/Reply/Poll 전반에 CQRS(명령/조회) 구조를 도입하고, 관련 컨트롤러·서비스·DTO·엔티티·예외 코드를 대대적으로 재구성했습니다. 레거시 통합 서비스와 예외들을 제거/대체하고, 페이지·상세 응답 DTO를 재설계했으며, 스케줄러 실행 로직을 공통화했습니다. 단위 테스트가 광범위하게 추가되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor U as User
participant PC as PostController
participant PCS as PostCommandService
participant PQS as PostQueryService
participant PDA as PostDtoAssembler
participant PI as PostInteractionService
participant PR as PostRepository
rect rgba(200,230,255,0.3)
note over U,PC: 게시글 생성 (Command)
U->>PC: POST /posts (PostCreateRequestDTO, images)
PC->>PCS: createPost(dto, images)
PCS->>PI: handleImageUpload(images)
PCS->>PR: save(PostEntity.create(...))
PCS-->>PC: 완료
PC-->>U: 201 SingleResponse<Void>
end
rect rgba(220,255,220,0.3)
note over U,PC: 게시글 상세 조회 (Query)
U->>PC: GET /posts/{id}
PC->>PQS: getPostWithDetail(id)
PQS->>PR: findById(id, not deleted)
PQS->>PI: increaseHits(post, currentUser)
PQS->>PDA: toPageItem(post, currentUser)
PDA-->>PQS: PostPageItemResponseDTO
PQS-->>PC: PostPageItemResponseDTO
PC-->>U: 200 SingleResponse<PostPageItemResponseDTO>
end
sequenceDiagram
autonumber
actor U as User
participant CC as CommentController
participant CCS as CommentCommandService
participant CQS as CommentQueryService
participant RCS as ReplyCommandService
participant RQS as ReplyQueryService
rect rgba(255,240,200,0.3)
note over U,CC: 댓글 작성 (Command)
U->>CC: POST /posts/{id}/comments
CC->>CCS: addComment(postId, dto)
CCS-->>CC: 완료
CC-->>U: 201 SingleResponse<Void>
end
rect rgba(255,220,240,0.3)
note over U,CC: 댓글 조회 (Query)
U->>CC: GET /posts/{id}/comments
CC->>CQS: getCommentsByPostId(id)
CQS->>RQS: getRepliesByCommentId(...)
CQS-->>CC: ListResponse<CommentResponseDTO>
CC-->>U: 200
end
rect rgba(240,240,255,0.3)
note over U: 대댓글 작성 (Command)
U->>RCS: POST /comments/{id}/replies
RCS-->>U: 201 SingleResponse<Void>
end
sequenceDiagram
autonumber
actor U as User
participant PollC as PollController
participant PollCmd as PollCommandService
participant PollQry as PollQueryService
participant PR as PollRepository
participant PVR as PollVoteRepository
rect rgba(220,250,255,0.3)
U->>PollC: POST /polls
PollC->>PollCmd: createPoll(dto)
PollCmd->>PR: save(PollEntity.from(...))
PollCmd-->>PollC: 완료
PollC-->>U: 201 SingleResponse<Void>
end
rect rgba(220,255,230,0.3)
U->>PollC: POST /posts/{id}/poll/vote
PollC->>PollCmd: votingPoll(postId, dto)
PollCmd->>PVR: exists / save vote
PollCmd->>PR: incOption(...)
PollCmd-->>PollC: 완료
PollC-->>U: 201 SingleResponse<Void>
end
rect rgba(255,235,235,0.3)
U->>PollC: DELETE /posts/{id}/poll/vote
PollC->>PollCmd: deleteVoting(postId)
PollCmd->>PVR: findByPollIdAndUserId / delete
PollCmd->>PR: dcrOptionIfPositive(...)
PollCmd-->>PollC: 완료
PollC-->>U: 200 SingleResponse<Void>
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
✨ 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 |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
doma17
left a comment
There was a problem hiding this comment.
수고하셨습니다. CQRS 패턴에 대해 새로 알게되었네요.
There was a problem hiding this comment.
Actionable comments posted: 38
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (18)
src/main/java/inu/codin/codin/domain/post/domain/comment/reply/service/ReplyEventListener.java (2)
16-20: 트랜잭션 커밋 전 알림 발송 위험 — AFTER_COMMIT로 전환 권장현재
@EventListener는 트랜잭션 경계와 무관하게 즉시 동작할 수 있어, 롤백 시에도 알림이 나갈 수 있습니다.@TransactionalEventListener(phase = AFTER_COMMIT)로 전환하면 커밋 확정 후에만 발송됩니다. 외부 호출(알림)은 보통 사후-커밋이 안전합니다.-import org.springframework.context.event.EventListener; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; ... - @Async - @EventListener + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void handleReplyNotificationEvent(ReplyNotificationEvent event){ notificationService.sendNotificationMessageByReply(event.getPostCategory(), event.getUserId(), event.getPostId(), event.getContent()); }참고:
fallbackExecution = true는 트랜잭션 밖에서 발행된 이벤트도 처리합니다. 전역 정책에 따라 조정하세요.
19-19: NotificationService.sendNotificationMessageByReply 시그니처 확인 및 방어 로직 부재– 시그니처 정합성
확인 결과,ReplyNotificationEvent의 필드 타입(PostCategory,ObjectId,String,String)과NotificationService.sendNotificationMessageByReply(PostCategory, ObjectId, String, String)의 파라미터가 정확히 일치합니다.– 방어 로직 부재
sendNotificationMessageByReply내부에서는 사용자 존재 여부만 확인하며,content에 대해 길이 제한, 금칙어 필터링, HTML 이스케이핑 등의 방어 로직이 전혀 적용되어 있지 않습니다. UI를 통해 전달되는 텍스트를 별도 검증 없이 전파할 경우, 메시지 오버플로우나 XSS·불법 문자 노출 등의 리스크가 발생할 수 있습니다.– 권장 조치
•NotificationService.sendNotificationMessageByReply메서드에 아래 방어 로직 추가
1. 최대 길이 검증 (예: 200자)
2. 금칙어 목록에 따른 필터링/치환
3. HTML/XML 이스케이핑 또는 Markdown 정규화
•ReplyNotificationEvent생성 시점에 VO(Value Object) 레벨에서 선(先)검증 적용 고려
• 방어 로직 커버리지를 위한 단위 테스트 및 경계값 테스트 추가– 점검 위치
• src/main/java/inu/codin/codin/domain/notification/service/NotificationService.java
–public void sendNotificationMessageByReply(...)
• src/main/java/inu/codin/codin/domain/post/domain/comment/reply/dto/event/ReplyNotificationEvent.javasrc/test/java/inu/codin/codin/domain/email/service/PasswordResetEmailServiceTest.java (1)
21-24: Mockitoeq정적 임포트 누락으로 컴파일 에러 발생본 파일에서
eq(...)를 사용하지만import static org.mockito.ArgumentMatchers.eq;정적 임포트가 없습니다. 현재 정적 임포트는any,anyString만 포함되어 있어 컴파일 시 심볼을 찾지 못합니다.다음 수정으로 해결하세요:
import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*;src/test/java/inu/codin/codin/domain/email/service/JoinEmailAuthServiceTest.java (2)
20-22: Mockitoeq정적 임포트 누락으로 컴파일 에러 발생
verify(emailTemplateService).sendTemplateEmail(eq(...), ...)에서eq가 사용되지만 정적 임포트가 없습니다. 현재 파일에도 동일 문제가 존재합니다.다음 라인을 추가하세요:
import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*;
1-180: 모든 테스트 파일에eq정적 임포트 추가 필요다음 테스트 클래스들에서
eq()사용 시
import static org.mockito.ArgumentMatchers.eq;
정적 임포트가 누락되어 있어 컴파일/실행 오류가 발생할 수 있습니다. 각 파일의 상단에 해당 import 구문을 추가해주세요.
- src/test/java/inu/codin/codin/domain/post/PostQueryServiceTest.java
- src/test/java/inu/codin/codin/domain/post/PostDtoAssemblerTest.java
- src/test/java/inu/codin/codin/domain/post/domain/comment/CommentCommandServiceTest.java
- src/test/java/inu/codin/codin/domain/post/domain/comment/CommentQueryServiceTest.java
- src/test/java/inu/codin/codin/domain/post/domain/poll/PollCommandServiceTest.java
- src/test/java/inu/codin/codin/domain/email/service/JoinEmailAuthServiceTest.java
- src/test/java/inu/codin/codin/domain/email/service/PasswordResetEmailServiceTest.java
- src/test/java/inu/codin/codin/domain/post/domain/comment/reply/ReplyQueryServiceTest.java
- src/test/java/inu/codin/codin/domain/post/domain/comment/reply/ReplyCommandServiceTest.java
- src/test/java/inu/codin/codin/domain/post/domain/best/BestServiceTest.java
- src/test/java/inu/codin/codin/domain/block/service/BlockServiceTest.java
파일 상단에 다음 한 줄을 추가하세요:
import static org.mockito.ArgumentMatchers.eq;필요하다면
org.mockito.Mockito.*,org.mockito.ArgumentMatchers.*등의 정적 임포트를 통일하여 관리성을 높여도 좋습니다.src/main/java/inu/codin/codin/domain/post/domain/comment/reply/dto/request/ReplyAnonnymousUpdateRequestDTO.java (2)
8-8: 클래스명 오탈자 수정 및 파일명 변경 필요리포지토리 전체 검색 결과, 잘못된 클래스명
ReplyAnonnymousUpdateRequestDTO를 참조하는 곳은 해당 파일 외에는 없었습니다. 따라서 아래와 같이 파일명과 클래스 선언만 변경하면 되며, 다른 참조를 추가로 수정할 필요는 없습니다.• 변경 대상
- 경로:
src/main/java/inu/codin/codin/domain/post/domain/comment/reply/dto/request/ReplyAnonnymousUpdateRequestDTO.java- →
src/main/java/inu/codin/codin/domain/post/domain/comment/reply/dto/request/ReplyAnonymousUpdateRequestDTO.java• 수정할 클래스 선언
-public class ReplyAnonnymousUpdateRequestDTO { +public class ReplyAnonymousUpdateRequestDTO {위와 같이 파일명과 클래스명만 교정해 주시면 됩니다.
9-11: primitive boolean 필드에 @NotNull 적용 무효 — Boolean 타입 전환 또는 @NotNull 제거 필요아래 필드에 대해 primitive
boolean타입은 애초에null이 될 수 없어@NotNull검증이 동작하지 않습니다.
API 의도에 따라 아래 두 가지 옵션 중 하나를 선택하여 반영해주세요.• 대상 위치
- 파일:
src/main/java/inu/codin/codin/domain/post/domain/comment/reply/dto/request/ReplyAnonnymousUpdateRequestDTO.java- 라인: 9–11
• 옵션 A) 필수 값 강제 (클라이언트에서 반드시 전송해야 함)
- 타입을 래퍼
Boolean으로 변경@NotNull(message = "...")으로 커스텀 메시지 추가- (추가) OpenAPI 스펙 상 필수임을 표시하려면
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)활용- @Schema(description = "익명 여부", example = "true") - @NotNull - private boolean anonymous; + @Schema(description = "익명 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "anonymous는 필수입니다.") + private Boolean anonymous;• 옵션 B) 기본값 허용 (전송하지 않을 경우
false로 처리)
- primitive
boolean유지@NotNull제거- @Schema(description = "익명 여부", example = "true") - @NotNull - private boolean anonymous; + @Schema(description = "익명 여부", example = "true") + private boolean anonymous;위 두 옵션 중 API 스펙에 맞는 방식을 선택하여 반영 부탁드립니다.
src/main/java/inu/codin/codin/domain/notification/service/NotificationService.java (1)
73-81: 전송 성공 시에만 로그 저장하도록 동작 일치화Topic 전송은 성공 시에만 로그 저장, 사용자 전송은 실패해도 로그 저장으로 동작이 불일치합니다. 일관성 있게 성공 시에만 저장하는 쪽으로 정렬하는 것을 제안합니다.
- try { - fcmService.sendFcmMessage(msgDto); - log.info("[sendFcmMessage] 알림 전송 성공"); - } catch (Exception e) { - log.error("[sendFcmMessage] 알림 전송 실패 : {}", e.getMessage()); - } - saveNotificationLog(msgDto, data); + try { + fcmService.sendFcmMessage(msgDto); + log.info("[sendFcmMessage] 알림 전송 성공"); + saveNotificationLog(msgDto, data); + } catch (Exception e) { + log.error("[sendFcmMessage] 알림 전송 실패 : {}", e.getMessage()); + }src/main/java/inu/codin/codin/domain/post/domain/hits/service/HitsService.java (2)
67-67: Redis 응답 파싱에서 ClassCastException 위험 — 강제 캐스팅 제거 필요.
(String) hits로의 다운캐스팅은 Redis 클라이언트 구현에 따라String이외 타입(예:Long)이 반환될 때ClassCastException을 유발합니다.String.valueOf(...)로 안전 파싱해 주세요.권장 diff:
- else return Integer.parseInt((String)hits); + else return Integer.parseInt(String.valueOf(hits));
61-66: @async이 자기 호출(self-invocation)로 인해 적용되지 않습니다 — 비동기 복구 보장 방식으로 전환 필요.동일 빈 내부에서
recoveryHits를 호출하면 프록시가 우회되어@Async가 적용되지 않습니다(게다가protected가시성). 의도한 "Cache miss 시 비동기 복구"가 실행되지 않을 수 있습니다. 호출 지점에서 명시적 비동기 실행으로 전환하고, 불필요한@Async를 제거하는 것을 권장합니다. 또한 Redis 비가용 시 복구 호출을 생략해 예외 가능성을 줄이세요.권장 최소 변경 diff:
@@ import org.springframework.stereotype.Service; +import java.util.concurrent.CompletableFuture; @@ - if (hits == null) { - recoveryHits(postId); - return hitsRepository.countAllByPostId(postId); - } + if (hits == null) { + if (redisHealthChecker.isRedisAvailable()) { + CompletableFuture.runAsync(() -> recoveryHits(postId)); + } + return hitsRepository.countAllByPostId(postId); + } @@ - @Async - protected void recoveryHits(ObjectId postId) { + protected void recoveryHits(ObjectId postId) { int hits = hitsRepository.countAllByPostId(postId); redisHitsService.recoveryHits(postId, hits); }대안(선호): 전용 컴포넌트로 분리하여 프록시 기반 @async 유지
// new file: HitsCacheRecoveryService.java @Service @RequiredArgsConstructor public class HitsCacheRecoveryService { private final RedisHitsService redisHitsService; private final HitsRepository hitsRepository; @Async public void recoveryHits(ObjectId postId) { int hits = hitsRepository.countAllByPostId(postId); redisHitsService.recoveryHits(postId, hits); } }그리고 HitsService에서는
HitsCacheRecoveryService를 주입받아 호출합니다.Also applies to: 74-75, 12-12
src/main/java/inu/codin/codin/domain/like/service/LikeService.java (3)
80-84: Redis 비가용 시 Best 점수 적용에서 예외 가능성 — 가드 추가 필요.
redisBestService.applyBestScore호출이 Redis 헬스 체크 밖에 있어, Redis 다운 시 예외가 날 수 있습니다. 다른 Redis 호출과 동일하게 가드를 적용해 주세요.권장 diff:
- if (likeType == LikeType.POST) { - redisBestService.applyBestScore(1, new ObjectId(likeTypeId)); - log.info("Redis에 Best Score 적용 - postId: {}", likeTypeId); - } + if (likeType == LikeType.POST) { + if (redisHealthChecker.isRedisAvailable()) { + redisBestService.applyBestScore(1, new ObjectId(likeTypeId)); + log.info("Redis에 Best Score 적용 - postId: {}", likeTypeId); + } + }
113-126: @async 자기 호출 문제로 비동기 복구가 동작하지 않습니다 — 호출 지점 비동기화로 전환 권장.
getLikeCount에서 같은 빈의recoveryLike를 호출하고 있어@Async가 적용되지 않습니다. 호출 지점에서 비동기 실행하고, 메서드의@Async는 제거해 주세요. Redis 비가용 시에는 복구를 생략하도록 가드도 추가합니다.권장 diff:
@@ - int likeCount = likeRepository.countByLikeTypeAndLikeTypeIdAndDeletedAtIsNull(likeType, likeTypeId); - recoveryLike(likeType, likeTypeId, likeCount); + int likeCount = likeRepository.countByLikeTypeAndLikeTypeIdAndDeletedAtIsNull(likeType, likeTypeId); + if (redisHealthChecker.isRedisAvailable()) { + java.util.concurrent.CompletableFuture.runAsync(() -> recoveryLike(likeType, likeTypeId, likeCount)); + } return likeCount; @@ - @Async - protected void recoveryLike(LikeType entityType, String entityId, int likeCount) { + protected void recoveryLike(LikeType entityType, String entityId, int likeCount) { redisLikeService.recoveryLike(entityType.name(), entityId, likeCount); }선호 대안: 전용
LikeCacheRecoveryService로 분리하여@Async유지(구현 패턴은 Hits와 동일).Also applies to: 128-131
1-164: 패키지 이동에 따른 import 경로 일괄 점검 필요
패키지 리팩토링 후에도ReplyCommentRepository에 대한 기존 경로(inu.codin.codin.domain.post.domain.comment.reply.repository)가 다수 남아 있어, 컴파일 타임 오류를 방지하기 위해 아래 파일들의 import 경로를 새로운 패키지 구조로 수정해야 합니다.– src/test/java/inu/codin/codin/domain/post/domain/comment/reply/ReplyQueryServiceTest.java (라인 9, 11)
– src/test/java/inu/codin/codin/domain/post/domain/comment/reply/ReplyCommandServiceTest.java (라인 11)
– src/main/java/inu/codin/codin/domain/report/service/ReportService.java (라인 10)
– src/main/java/inu/codin/codin/domain/notification/service/NotificationService.java (라인 12)
– src/main/java/inu/codin/codin/domain/post/domain/comment/reply/service/ReplyCommandService.java (라인 12)
– src/main/java/inu/codin/codin/domain/post/domain/comment/reply/service/ReplyQueryService.java (라인 10)
– src/main/java/inu/codin/codin/domain/like/service/LikeService.java (라인 12)각 파일에서
import inu.codin.codin.domain.post.domain.comment.reply.repository.ReplyCommentRepository;를
import inu.codin.codin.domain.post.domain.comment.domain.reply.repository.ReplyCommentRepository;(또는 새 패키지 경로에 맞춰)로 변경해 주세요.
src/main/java/inu/codin/codin/domain/post/domain/comment/reply/dto/request/ReplyCreateRequestDTO.java (2)
16-18: 익명 기본값 문서-구현 불일치 및 유효성 애노테이션 오용설명에는 기본값이 true로 명시되어 있으나, 현재 구현은 기본값이 false(자바 기본값)이며, 원시 boolean 타입에
@NotNull은 무의미합니다. 클라이언트가 필드를 생략하면 의도와 다르게 비익명으로 처리될 수 있습니다.다음과 같이 기본값을 명시하고
@NotNull을 제거하는 것을 권장합니다.- @Schema(description = "게시물 익명 여부 default = true (익명)", example = "true") - @NotNull - private boolean anonymous; + @Schema(description = "대댓글 익명 여부", example = "true", defaultValue = "true") + private boolean anonymous = true;
9-11: Jackson 기본 바인딩 정책 적용으로 인한 필드 미매핑 위험 – DTO에 바인딩 어노테이션 및 생성자/세터 추가 필요프로젝트 전반에
Jackson2ObjectMapperBuilderCustomizer나spring.jackson.*설정이 없으므로, 기본 ObjectMapper의 필드·세터 가시성(PUBLIC_ONLY)만 적용됩니다. 이로 인해 private 필드에 세터가 없으면 JSON 요청 데이터를 매핑하지 못할 수 있습니다.
아래와 같이 DTO 클래스에 Jackson 바인딩을 보장할 어노테이션과 생성자/세터를 추가해 주세요.• 수정 대상
- src/main/java/inu/codin/codin/domain/post/domain/comment/reply/dto/request/ReplyCreateRequestDTO.java
• 권장 접근 방식 1: 기본 생성자 + 세터
@Getter - public class ReplyCreateRequestDTO { + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public class ReplyCreateRequestDTO { @Schema(description = "댓글 내용", example = "content") @NotBlank private String content; + + // 필요 시, 개별 필드의 직렬화 이름 보장 + @JsonProperty("content") + public void setContent(String content) { + this.content = content; + } }• 권장 접근 방식 2: 생성자 기반 바인딩
@Getter public class ReplyCreateRequestDTO { - @Schema(description = "댓글 내용", example = "content") - @NotBlank - private String content; + private final String content; + + @JsonCreator + public ReplyCreateRequestDTO( + @JsonProperty("content") + @NotBlank + String content + ) { + this.content = content; + } }위 조치 중 하나를 적용하여 Jackson이 private 필드를 올바르게 바인딩하도록 보장해 주세요.
src/main/java/inu/codin/codin/domain/post/entity/PostEntity.java (1)
71-75:updateNotice에서addAll의 NPE 위험 및 중복 가능성
postImageUrls인자가 null일 경우 NPE가 발생합니다. 또한 동일 URL이 중복으로 누적될 여지가 있습니다.public void updateNotice(String title, String content, List<String> postImageUrls) { this.title = title; this.content = content; - this.postImageUrls.addAll(postImageUrls); + if (postImageUrls != null && !postImageUrls.isEmpty()) { + // 중복 방지(선택): 삽입 전 집합화 + java.util.Set<String> dedup = new java.util.LinkedHashSet<>(this.postImageUrls); + dedup.addAll(postImageUrls); + this.postImageUrls = new java.util.ArrayList<>(dedup); + } }src/main/java/inu/codin/codin/domain/post/domain/comment/dto/response/CommentResponseDTO.java (2)
58-69: Builder 생성자의Boolean→boolean전환 (NPE 리스크 제거)생성자 파라미터가
Boolean anonymous로 선언되어 있어 null 입력 시 오토 언박싱에서 NPE가 발생합니다. 필드가boolean인 만큼 생성자 파라미터도boolean으로 통일하세요.- public CommentResponseDTO(String _id, String userId, String content, - String nickname, String userImageUrl, Boolean anonymous , + public CommentResponseDTO(String _id, String userId, String content, + String nickname, String userImageUrl, boolean anonymous , List<CommentResponseDTO> replies, int likeCount, boolean isDeleted, LocalDateTime createdAt, UserInfo userInfo) {
108-126: repliesFrom에서 다운캐스팅 버그 및 상태 손실
ReportedCommentDetailResponseDTO→CommentResponseDTO캐스팅은 런타임ClassCastException을 유발합니다.- 빌더 재생성 시
isDeleted를 세팅하지 않아 항상 기본값(false)로 덮입니다.메서드 시그니처를
List<CommentResponseDTO>로 바꾸고, 캐스팅을 제거하며, 누락된isDeleted를 전달하세요.- public CommentResponseDTO repliesFrom(List<ReportedCommentDetailResponseDTO> updatedReplies) { - List<CommentResponseDTO> commentReplies = updatedReplies.stream() - .map(reply -> (CommentResponseDTO) reply) // 변환 - .toList(); - - return CommentResponseDTO.builder() + public CommentResponseDTO repliesFrom(List<CommentResponseDTO> updatedReplies) { + return CommentResponseDTO.builder() ._id(this._id) .userId(this.userId) .content(this.content) .nickname(this.nickname) .userImageUrl(this.userImageUrl) .anonymous(this.anonymous) - .replies(commentReplies) // 수정된 대댓글 리스트 적용 + .replies(updatedReplies) // 수정된 대댓글 리스트 적용 .likeCount(this.likeCount) + .isDeleted(this.isDeleted) .createdAt(this.createdAt) .userInfo(this.userInfo) .build(); }
src/main/java/inu/codin/codin/domain/post/domain/comment/entity/CommentEntity.java
Show resolved
Hide resolved
src/main/java/inu/codin/codin/domain/post/domain/comment/reply/service/ReplyCommandService.java
Show resolved
Hide resolved
src/main/java/inu/codin/codin/domain/post/domain/comment/reply/service/ReplyCommandService.java
Show resolved
Hide resolved
src/main/java/inu/codin/codin/domain/post/domain/comment/reply/service/ReplyCommandService.java
Outdated
Show resolved
Hide resolved
| private static AutoCloseable securityUtilsMock; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Mockito static mocking 사용법 오류 — 타입 및 스텁 방식 수정 필요
mockStatic 반환을 AutoCloseable로 받으면 스텁 API(when, verify)를 쓸 수 없습니다. MockedStatic<SecurityUtils>로 선언하고 이를 통해 스텁하세요.
- private static AutoCloseable securityUtilsMock;
+ private static org.mockito.MockedStatic<SecurityUtils> securityUtilsMock;
@@
- void setUp() {
- securityUtilsMock = Mockito.mockStatic(SecurityUtils.class);
- }
+ void setUp() {
+ securityUtilsMock = Mockito.mockStatic(SecurityUtils.class);
+ }
@@
- void tearDown() throws Exception {
- securityUtilsMock.close();
- }
+ void tearDown() {
+ securityUtilsMock.close();
+ }📝 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 static AutoCloseable securityUtilsMock; | |
| @BeforeEach | |
| void setUp() { | |
| securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); | |
| } | |
| private static org.mockito.MockedStatic<SecurityUtils> securityUtilsMock; | |
| @BeforeEach | |
| void setUp() { | |
| securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); | |
| } | |
| @AfterEach | |
| void tearDown() { | |
| securityUtilsMock.close(); | |
| } |
🤖 Prompt for AI Agents
In
src/test/java/inu/codin/codin/domain/post/domain/comment/reply/ReplyCommandServiceTest.java
around lines 42 to 47, the static mock is stored as AutoCloseable which prevents
using the MockedStatic stubbing API; change the field type to
MockedStatic<SecurityUtils>, assign Mockito.mockStatic(SecurityUtils.class) to
it, use that mockedStatic instance for when/verify stubs (e.g.
mockedStatic.when(...)), and ensure you close it in an @AfterEach method by
calling mockedStatic.close().
| given(SecurityUtils.getCurrentUserId()).willReturn(userId); | ||
| given(replyCommentRepository.save(any())).willAnswer(inv -> { |
There was a problem hiding this comment.
🛠️ Refactor suggestion
정적 메서드 스텁 방식 수정
정적 메서드는 given(SecurityUtils.getCurrentUserId())가 아니라 securityUtilsMock.when(...)으로 스텁해야 합니다.
- given(SecurityUtils.getCurrentUserId()).willReturn(userId);
+ securityUtilsMock.when(SecurityUtils::getCurrentUserId).thenReturn(userId);위 변경을 다음 위치 모두에 적용해 주세요.
- Line 66-67 (addReply_정상생성_성공)
- Line 98-99 (addReply_본인게시물_알림미발송)
- Line 137-138 (addReply_다른사용자게시물_알림발송)
Also applies to: 98-99, 137-138
🤖 Prompt for AI Agents
In
src/test/java/inu/codin/codin/domain/post/domain/comment/reply/ReplyCommandServiceTest.java
around lines 66-67, 98-99 and 137-138, replace the static-method style stub
using given(SecurityUtils.getCurrentUserId()) with the mock's when(...)
stubbing: use securityUtilsMock.when(...) to stub
SecurityUtils.getCurrentUserId() to return the test userId in each test case
(addReply_정상생성_성공, addReply_본인게시물_알림미발송, addReply_다른사용자게시물_알림발송), keeping the
rest of the save repository answer stubbing unchanged.
| doNothing().when(SecurityUtils.class); | ||
| SecurityUtils.validateUser(userId); | ||
| given(commentQueryService.findCommentById(reply.getCommentId())).willReturn(comment); |
There was a problem hiding this comment.
🛠️ Refactor suggestion
정적 void 메서드 스텁 오류
doNothing().when(SecurityUtils.class); SecurityUtils.validateUser(userId);는 Mockito에서 지원되지 않습니다. MockedStatic으로 다음처럼 처리하세요.
- doNothing().when(SecurityUtils.class);
- SecurityUtils.validateUser(userId);
+ securityUtilsMock.when(() -> SecurityUtils.validateUser(userId));validateUser가 예외를 던지지 않는 happy-path만 검증한다면 위와 같이 “아무 것도 하지 않음” 스텁이면 충분합니다.
📝 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.
| doNothing().when(SecurityUtils.class); | |
| SecurityUtils.validateUser(userId); | |
| given(commentQueryService.findCommentById(reply.getCommentId())).willReturn(comment); | |
| securityUtilsMock.when(() -> SecurityUtils.validateUser(userId)); | |
| given(commentQueryService.findCommentById(reply.getCommentId())).willReturn(comment); |
| given(postQueryService.getUserAnonymousNumber(any(), any())).willReturn(1); | ||
| given(likeService.getLikeCount(eq(LikeType.REPLY), any())).willReturn(3); | ||
| given(SecurityUtils.getCurrentUserId()).willReturn(new ObjectId()); | ||
| given(likeService.isLiked(eq(LikeType.COMMENT), any(),(ObjectId) any())).willReturn(false); |
There was a problem hiding this comment.
LikeType 불일치로 인한 테스트/로직 혼선 가능성 (REPLY vs COMMENT).
getLikeCount에는 LikeType.REPLY를 사용하면서, isLiked 호출에는 LikeType.COMMENT를 사용하고 있습니다. 대댓글(Reply)에 대한 좋아요 타입이 일관되어야 합니다. 현재처럼 혼재하면 실제 구현과의 불일치가 테스트에서 가려질 수 있습니다.
적용 diff(해당 위치들을 REPLY로 통일하는 예):
- given(likeService.isLiked(eq(LikeType.COMMENT), any(),(ObjectId) any())).willReturn(false);
+ given(likeService.isLiked(eq(LikeType.REPLY), any(), (ObjectId) any())).willReturn(false);
@@
- given(likeService.isLiked(eq(LikeType.COMMENT), any(),(ObjectId) any())).willReturn(true);
+ given(likeService.isLiked(eq(LikeType.REPLY), any(), (ObjectId) any())).willReturn(true);
@@
- given(likeService.isLiked(LikeType.COMMENT, replyId.toString(), userId)).willReturn(true);
+ given(likeService.isLiked(LikeType.REPLY, replyId.toString(), userId)).willReturn(true);
@@
- given(likeService.isLiked(LikeType.COMMENT, replyId.toString(), userId)).willReturn(false);
+ given(likeService.isLiked(LikeType.REPLY, replyId.toString(), userId)).willReturn(false);
@@
- given(likeService.isLiked(eq(LikeType.COMMENT), any(),(ObjectId) any())).willReturn(false);
+ given(likeService.isLiked(eq(LikeType.REPLY), any(), (ObjectId) any())).willReturn(false);필요 시 실제 도메인 정책(Reply에 COMMENT 타입 사용)이 있다면 반대로 getLikeCount도 COMMENT로 통일해 주세요.
Also applies to: 125-125, 167-181, 189-198, 223-223
🤖 Prompt for AI Agents
In
src/test/java/inu/codin/codin/domain/post/domain/comment/reply/ReplyQueryServiceTest.java
around lines 77 (and also apply the same change at 125, 167-181, 189-198, 223),
the test mixes LikeType.COMMENT with getLikeCount using LikeType.REPLY; update
the likeService.isLiked(...) calls to use LikeType.REPLY (or, if the domain
policy is that replies use COMMENT, change getLikeCount calls instead) so that
the LikeType is consistent for Reply-related assertions across all referenced
lines.
| private static AutoCloseable securityUtilsMock; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); | ||
| } | ||
|
|
||
| @AfterEach | ||
| void tearDown() throws Exception { | ||
| securityUtilsMock.close(); | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
Mockito 정적 메서드(stub) 사용 오류: SecurityUtils 스텁이 실제로 적용되지 않습니다
mockStatic 사용 시 BDD 스타일의 given(SecurityUtils...)로는 스텁이 걸리지 않습니다. MockedStatic<SecurityUtils>의 when(...).thenReturn(...) 형태로 교체해야 합니다. 그렇지 않으면 실제 SecurityUtils가 호출되어 테스트가 환경 의존/불안정해집니다.
아래 패치를 적용해 주세요.
@@
- private static AutoCloseable securityUtilsMock;
+ private MockedStatic<SecurityUtils> securityUtilsMock;
@@
- void setUp() {
- securityUtilsMock = Mockito.mockStatic(SecurityUtils.class);
- }
+ void setUp() {
+ securityUtilsMock = Mockito.mockStatic(SecurityUtils.class);
+ }
@@
- void tearDown() throws Exception {
- securityUtilsMock.close();
- }
+ void tearDown() throws Exception {
+ securityUtilsMock.close();
+ }
@@
- given(SecurityUtils.getCurrentUserId()).willReturn(userId);
- given(SecurityUtils.getCurrentUserRole()).willReturn(UserRole.ADMIN);
+ securityUtilsMock.when(SecurityUtils::getCurrentUserId).thenReturn(userId);
+ securityUtilsMock.when(SecurityUtils::getCurrentUserRole).thenReturn(UserRole.ADMIN);
@@
- given(SecurityUtils.getCurrentUserId()).willReturn(new ObjectId());
- given(SecurityUtils.getCurrentUserRole()).willReturn(UserRole.ADMIN);
+ securityUtilsMock.when(SecurityUtils::getCurrentUserId).thenReturn(new ObjectId());
+ securityUtilsMock.when(SecurityUtils::getCurrentUserRole).thenReturn(UserRole.ADMIN);
@@
- given(SecurityUtils.getCurrentUserId()).willReturn(new ObjectId());
- given(SecurityUtils.getCurrentUserRole()).willReturn(UserRole.ADMIN);
+ securityUtilsMock.when(SecurityUtils::getCurrentUserId).thenReturn(new ObjectId());
+ securityUtilsMock.when(SecurityUtils::getCurrentUserRole).thenReturn(UserRole.ADMIN);
@@
- given(SecurityUtils.getCurrentUserId()).willReturn(new ObjectId());
- given(SecurityUtils.getCurrentUserRole()).willReturn(UserRole.ADMIN);
+ securityUtilsMock.when(SecurityUtils::getCurrentUserId).thenReturn(new ObjectId());
+ securityUtilsMock.when(SecurityUtils::getCurrentUserRole).thenReturn(UserRole.ADMIN);
@@
- given(SecurityUtils.getCurrentUserId()).willReturn(new ObjectId());
- given(SecurityUtils.getCurrentUserRole()).willReturn(UserRole.ADMIN);
+ securityUtilsMock.when(SecurityUtils::getCurrentUserId).thenReturn(new ObjectId());
+ securityUtilsMock.when(SecurityUtils::getCurrentUserRole).thenReturn(UserRole.ADMIN);
@@
- given(SecurityUtils.getCurrentUserId()).willReturn(new ObjectId());
- given(SecurityUtils.getCurrentUserRole()).willReturn(UserRole.ADMIN);
+ securityUtilsMock.when(SecurityUtils::getCurrentUserId).thenReturn(new ObjectId());
+ securityUtilsMock.when(SecurityUtils::getCurrentUserRole).thenReturn(UserRole.ADMIN);추가로, 정적 모킹이 동작하려면 mockito-inline 의존성이 필요합니다. 아래 스크립트로 빌드 파일에 포함돼 있는지 확인해 주세요.
Also applies to: 58-60, 98-100, 115-117, 133-135, 149-151, 166-167
🏁 Script executed:
#!/bin/bash
# Gradle 또는 Maven에서 mockito-inline 의존성 유무 확인
rg -n -C2 -g '!**/build/**' -g '!**/target/**' -e 'mockito-inline|mockito-inline-jupiter|org\.mockito:mockito-inline' || trueLength of output: 126
🏁 Script executed:
#!/bin/bash
# Locate build files and check for mockito-inline dependency
find . -maxdepth 3 -type f \( -name 'build.gradle' -o -name 'build.gradle.kts' -o -name 'pom.xml' \) | while read file; do
echo "=== Checking $file ==="
rg -n 'mockito-inline|org\.mockito:mockito-inline' "$file" || echo "No match in $file"
doneLength of output: 326
Mockito 정적 메서드 모킹 오류 수정 및 mockito-inline 의존성 추가
PostCommandServiceTest에서 Mockito.mockStatic(SecurityUtils.class)로 생성한 정적 모킹은 BDD 스타일의 given(SecurityUtils…)로는 적용되지 않습니다. 반드시 MockedStatic<SecurityUtils> 타입을 사용하고, securityUtilsMock.when(...).thenReturn(...) 형태로 스텁을 걸어야 테스트가 올바르게 동작합니다. 또한 mockito-inline 모듈을 추가해야 정적 모킹이 활성화됩니다.
수정 사항 요약:
-
필드 타입 변경
- 기존
private static AutoCloseable securityUtilsMock;
- 수정
private MockedStatic<SecurityUtils> securityUtilsMock;
- 기존
-
스텁 설정 방식 변경 (
given(...)→when(...).thenReturn(...))- given(SecurityUtils.getCurrentUserId()).willReturn(userId); - given(SecurityUtils.getCurrentUserRole()).willReturn(UserRole.ADMIN); + securityUtilsMock.when(SecurityUtils::getCurrentUserId).thenReturn(userId); + securityUtilsMock.when(SecurityUtils::getCurrentUserRole).thenReturn(UserRole.ADMIN);
(위 패턴을 39-49, 58-60, 98-100, 115-117, 133-135, 149-151, 166-167 라인 전 구간에 일괄 적용)
-
@BeforeEach/@AfterEach는 그대로 유지@BeforeEach void setUp() { securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); } @AfterEach void tearDown() { securityUtilsMock.close(); }
-
build.gradle에
mockito-inline의존성 추가dependencies { testImplementation 'org.mockito:mockito-inline:4.+' // 필요 시 정확한 최신 버전으로 대체 }
대상 파일:
- src/test/java/inu/codin/codin/domain/post/PostCommandServiceTest.java (39–49, 58–60, 98–100, 115–117, 133–135, 149–151, 166–167)
#️⃣ PR 개요
post도메인 및 연관 서비스(poll,comment,reply, 'best', 'hit') 전반을 구조적 리팩토링 및 모듈화했습니다.목표는 응집도 강화, 결합도 완화, 복잡도 관리이며, 이를 통해 유지보수성과 품질 지표를 개선했습니다.
📝 주요 변경 사항 (이전 PR 이후)
1. CQRS 패턴 적용 - 단일 책임 원칙 준수
2. 예외 처리 구조 개선
3. 서비스 간 의존성 해결
4. 테스트 커버리지 대폭 향상
5. 데이터 정합성 개선
📊 리팩토링 전후 지표 비교 (SonarQube)
💡 요약
💬 참고사항
추후 개선 필요사항
기술적 세부사항
develop브랜치 (post 도메인 한정)호환성
Summary by CodeRabbit