From bb5cd8ef61a9dd49f0078db236464e10c23b14c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0?= Date: Tue, 4 Aug 2026 00:04:44 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]#105=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=EC=9E=90=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80/=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게시글 목록/상세, 댓글 목록 응답에 작성자의 profileImageUrl 과 characterCode 를 추가한다. 프로필 이미지를 등록하지 않은 사용자는 profileImageUrl 이 null 이므로 characterCode 로 대체 표시한다. - 목록 조회는 기존 작성자 projection 이 이미 UserProfile 을 LEFT JOIN 하고 있어, SELECT 절에 컬럼만 추가해 추가 쿼리 없이 처리한다. - getWriters() 의 반환 타입을 projection 으로 변경하면서, Collectors.toMap 의 null value 방어용이던 닉네임 null 필터를 제거한다(값이 projection 객체라 null 이 될 수 없다). 응답 동작은 동일하다. - 댓글 목록은 작성자 프로필을 한 번만 조회해 세 필드를 함께 사용하므로 기존과 쿼리 수가 같다. Co-Authored-By: Claude Opus 5 --- .../converter/CommunityConverter.java | 21 +++++- .../dto/res/CommunityCommentResponseDTO.java | 2 + .../dto/res/CommunityDetailResponseDTO.java | 2 + .../dto/res/CommunityResponseDTO.java | 4 +- .../repository/CommunityRepository.java | 12 ++- .../community/service/CommunityService.java | 74 +++++++++++++------ 6 files changed, 85 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/redo/domain/community/converter/CommunityConverter.java b/src/main/java/com/redo/domain/community/converter/CommunityConverter.java index 8b0df79..0bf5c11 100644 --- a/src/main/java/com/redo/domain/community/converter/CommunityConverter.java +++ b/src/main/java/com/redo/domain/community/converter/CommunityConverter.java @@ -37,7 +37,9 @@ public static CommunityResponseDTO toCommunityResponse( Community community, long numComments, String imageUrl, - String writer + String writer, + String profileImageUrl, + String characterCode ) { return new CommunityResponseDTO( community.getId(), @@ -48,7 +50,9 @@ public static CommunityResponseDTO toCommunityResponse( community.getTitle(), String.valueOf(community.getCategory().getCode()), toPreview(community.getContent()), - writer + writer, + profileImageUrl, + characterCode ); } @@ -66,12 +70,16 @@ private static String toPreview(String content) { public static CommunityDetailResponseDTO toCommunityDetailResponse( Community community, String writer, + String profileImageUrl, + String characterCode, String imageUrl ) { return new CommunityDetailResponseDTO( community.getId(), community.getTitle(), writer, + profileImageUrl, + characterCode, community.getContent(), community.getCreatedAt(), imageUrl @@ -134,10 +142,17 @@ public static CommunityComment toCommunityComment(Community community, User user .build(); } - public static CommunityCommentResponseDTO toCommunityCommentResponse(CommunityComment comment, String writer) { + public static CommunityCommentResponseDTO toCommunityCommentResponse( + CommunityComment comment, + String writer, + String profileImageUrl, + String characterCode + ) { return new CommunityCommentResponseDTO( comment.getId(), writer, + profileImageUrl, + characterCode, comment.getContent(), comment.getCreatedAt() ); diff --git a/src/main/java/com/redo/domain/community/dto/res/CommunityCommentResponseDTO.java b/src/main/java/com/redo/domain/community/dto/res/CommunityCommentResponseDTO.java index 6b66e95..37d6ed7 100644 --- a/src/main/java/com/redo/domain/community/dto/res/CommunityCommentResponseDTO.java +++ b/src/main/java/com/redo/domain/community/dto/res/CommunityCommentResponseDTO.java @@ -5,6 +5,8 @@ public record CommunityCommentResponseDTO( Long commentId, String writer, + String profileImageUrl, + String characterCode, String content, LocalDateTime createdAt ) { diff --git a/src/main/java/com/redo/domain/community/dto/res/CommunityDetailResponseDTO.java b/src/main/java/com/redo/domain/community/dto/res/CommunityDetailResponseDTO.java index 8875c7d..b6d25d0 100644 --- a/src/main/java/com/redo/domain/community/dto/res/CommunityDetailResponseDTO.java +++ b/src/main/java/com/redo/domain/community/dto/res/CommunityDetailResponseDTO.java @@ -6,6 +6,8 @@ public record CommunityDetailResponseDTO( Long id, String title, String writer, + String profileImageUrl, + String characterCode, String content, LocalDateTime createdAt, String imageUrl diff --git a/src/main/java/com/redo/domain/community/dto/res/CommunityResponseDTO.java b/src/main/java/com/redo/domain/community/dto/res/CommunityResponseDTO.java index d59aa1e..2cf1d68 100644 --- a/src/main/java/com/redo/domain/community/dto/res/CommunityResponseDTO.java +++ b/src/main/java/com/redo/domain/community/dto/res/CommunityResponseDTO.java @@ -11,6 +11,8 @@ public record CommunityResponseDTO( String title, String category, String preview, - String writer + String writer, + String profileImageUrl, + String characterCode ) { } diff --git a/src/main/java/com/redo/domain/community/repository/CommunityRepository.java b/src/main/java/com/redo/domain/community/repository/CommunityRepository.java index cdd06cb..3899079 100644 --- a/src/main/java/com/redo/domain/community/repository/CommunityRepository.java +++ b/src/main/java/com/redo/domain/community/repository/CommunityRepository.java @@ -20,20 +20,26 @@ public interface CommunityRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); - // 목록 조회용: 게시글별 작성자 닉네임을 단건 쿼리 반복 없이 한 번에 조회한다. + // 목록 조회용: 게시글별 작성자 프로필(닉네임/프로필 이미지 키/캐릭터 코드)을 단건 쿼리 반복 없이 한 번에 조회한다. // 프로필이 없는 사용자도 게시글은 조회되어야 하므로 UserProfile 은 LEFT JOIN 한다. @Query(""" - SELECT c.id AS communityId, p.nickname AS nickname + SELECT c.id AS communityId, + p.nickname AS nickname, + p.profileImageKey AS profileImageKey, + p.characterCode AS characterCode FROM Community c LEFT JOIN UserProfile p ON p.user = c.user WHERE c IN :communities """) List findWritersByCommunities(@Param("communities") List communities); - // 게시글별 작성자 닉네임 조회 결과 projection + // 게시글별 작성자 프로필 조회 결과 projection + // LEFT JOIN 이므로 프로필이 없는 작성자는 communityId 를 제외한 값이 모두 null 이다. interface CommunityWriter { Long getCommunityId(); String getNickname(); + String getProfileImageKey(); + String getCharacterCode(); } // 동시 요청에서 갱신 유실(Lost Update)이 발생하지 않도록 좋아요 수를 DB에서 원자적으로 증가시킨다. diff --git a/src/main/java/com/redo/domain/community/service/CommunityService.java b/src/main/java/com/redo/domain/community/service/CommunityService.java index 7861f74..b708d08 100644 --- a/src/main/java/com/redo/domain/community/service/CommunityService.java +++ b/src/main/java/com/redo/domain/community/service/CommunityService.java @@ -70,14 +70,20 @@ public Page getCommunityPosts(Integer category, Pageable p Map commentCounts = getCommentCounts(communities.getContent()); Map representativeImageKeys = getRepresentativeImageKeys(communities.getContent()); - Map writers = getWriters(communities.getContent()); - - return communities.map(community -> CommunityConverter.toCommunityResponse( - community, - commentCounts.getOrDefault(community.getId(), 0L), - createImageUrl(representativeImageKeys.get(community.getId())), - writers.get(community.getId()) - )); + Map writers = getWriters(communities.getContent()); + + return communities.map(community -> { + CommunityRepository.CommunityWriter writer = writers.get(community.getId()); + + return CommunityConverter.toCommunityResponse( + community, + commentCounts.getOrDefault(community.getId(), 0L), + createImageUrl(representativeImageKeys.get(community.getId())), + writer == null ? null : writer.getNickname(), + writer == null ? null : createImageUrl(writer.getProfileImageKey()), + writer == null ? null : writer.getCharacterCode() + ); + }); } // 게시글 상세 조회 로직 @@ -85,9 +91,13 @@ public CommunityDetailResponseDTO getCommunityPost(Long communityId) { Community community = communityRepository.findByIdAndDeletedAtIsNull(communityId) .orElseThrow(() -> new CommunityException(CommunityErrorCode.COMMUNITY_NOT_FOUND)); + UserProfile profile = getProfile(community.getUser().getId()); + return CommunityConverter.toCommunityDetailResponse( community, - getNickname(community.getUser().getId()), + getNickname(profile), + getProfileImageUrl(profile), + getCharacterCode(profile), getRepresentativeImageUrl(community) ); } @@ -107,7 +117,7 @@ public CommunityCreateResponseDTO createCommunityPost(Long userId, CommunityCrea // (등록 직후 즉시 조회 용도가 아니며, 조회 시점에 상세/목록 API가 Presigned URL을 새로 발급한다.) List imageKeys = saveImages(community, request.images()); - return CommunityConverter.toCommunityCreateResponse(community, imageKeys, getNickname(userId)); + return CommunityConverter.toCommunityCreateResponse(community, imageKeys, getNickname(getProfile(userId))); } // 댓글 목록 조회 로직(comment ID 기준 커서 페이징, cursor/length 없으면 전체 반환) @@ -123,10 +133,16 @@ public CommunityCommentListResponseDTO getCommunityComments(Long communityId, Lo return CommunityConverter.toCommunityCommentListResponse( comments.stream() - .map(comment -> CommunityConverter.toCommunityCommentResponse( - comment, - getNickname(comment.getUser().getId()) - )) + .map(comment -> { + UserProfile profile = getProfile(comment.getUser().getId()); + + return CommunityConverter.toCommunityCommentResponse( + comment, + getNickname(profile), + getProfileImageUrl(profile), + getCharacterCode(profile) + ); + }) .toList() ); } @@ -268,18 +284,17 @@ private Map getCommentCounts(List communities) { )); } - // 목록의 게시글별 작성자 닉네임을 한 번의 쿼리로 조회하는 로직 - private Map getWriters(List communities) { + // 목록의 게시글별 작성자 프로필을 한 번의 쿼리로 조회하는 로직 + // 프로필이 없는 작성자도 projection 자체는 반환되므로(각 필드가 null) Map 수집에서 제외하지 않는다. + private Map getWriters(List communities) { if (communities.isEmpty()) { return Map.of(); } return communityRepository.findWritersByCommunities(communities).stream() - // 프로필이 없어 닉네임이 null 인 경우 Map 수집에서 제외한다(조회 시 null 로 응답된다). - .filter(writer -> writer.getNickname() != null) .collect(Collectors.toMap( CommunityRepository.CommunityWriter::getCommunityId, - CommunityRepository.CommunityWriter::getNickname + writer -> writer )); } @@ -314,10 +329,23 @@ private String createImageUrl(String imageKey) { return s3Service.createPresignedUrl(imageKey); } - private String getNickname(Long userId) { - return userProfileRepository.findByUserId(userId) - .map(UserProfile::getNickname) - .orElse(null); + // 작성자 프로필을 조회하는 로직(프로필을 아직 만들지 않은 사용자는 null 을 반환한다) + private UserProfile getProfile(Long userId) { + return userProfileRepository.findByUserId(userId).orElse(null); + } + + private String getNickname(UserProfile profile) { + return profile == null ? null : profile.getNickname(); + } + + // 작성자 프로필 이미지의 S3 객체 키를 조회용 Presigned URL로 변환하는 로직 + // 프로필 이미지를 등록하지 않은 사용자는 null 이 되며, 이 경우 characterCode 로 대체 표시한다. + private String getProfileImageUrl(UserProfile profile) { + return profile == null ? null : createImageUrl(profile.getProfileImageKey()); + } + + private String getCharacterCode(UserProfile profile) { + return profile == null ? null : profile.getCharacterCode(); } private Community getActiveCommunity(Long communityId) {