Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public enum ErrorCode implements ResponseCode {
* 140000 : userRoom error
*/
USER_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 140000, "존재하지 않는 USER_ROOM (방과 사용자 관계) 입니다."),
USER_NOT_BELONG_TO_ROOM(HttpStatus.BAD_REQUEST, 140001, "현재 모임방에 속하지 않는 유저입니다."),

/**
* 150000 : Category error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.common.security.annotation.UserId;
import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse;
import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse;
import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse;
import konkuk.thip.room.adapter.in.web.response.RoomGetMemberListResponse;
import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse;
import konkuk.thip.room.application.port.in.*;
import konkuk.thip.room.application.port.in.RoomGetHomeJoinedListUseCase;
import konkuk.thip.room.application.port.in.RoomGetMemberListUseCase;
import konkuk.thip.room.application.port.in.RoomSearchUseCase;
import jakarta.validation.Valid;
import konkuk.thip.room.adapter.in.web.request.RoomVerifyPasswordRequest;
import konkuk.thip.room.application.port.in.RoomShowRecruitingDetailViewUseCase;
import konkuk.thip.room.application.port.in.RoomVerifyPasswordUseCase;
import konkuk.thip.room.application.port.in.dto.RoomGetHomeJoinedListQuery;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -29,6 +29,7 @@ public class RoomQueryController {
private final RoomVerifyPasswordUseCase roomVerifyPasswordUseCase;
private final RoomShowRecruitingDetailViewUseCase roomShowRecruitingDetailViewUseCase;
private final RoomGetMemberListUseCase roomGetMemberListUseCase;
private final RoomShowPlayingDetailViewUseCase roomShowPlayingDetailViewUseCase;

@GetMapping("/rooms/search")
public BaseResponse<RoomSearchResponse> searchRooms(
Expand All @@ -48,6 +49,7 @@ public BaseResponse<Void> verifyRoomPassword(@PathVariable("roomId") final Long
return BaseResponse.ok(roomVerifyPasswordUseCase.verifyRoomPassword(roomVerifyPasswordRequest.toQuery(roomId)));
}

// 모집중인 방 상세보기
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

@GetMapping("/rooms/{roomId}/recruiting")
public BaseResponse<RoomRecruitingDetailViewResponse> getRecruitingRoomDetailView(
@UserId final Long userId,
Expand All @@ -71,4 +73,13 @@ public BaseResponse<RoomGetMemberListResponse> getRoomMemberList(@PathVariable("
return BaseResponse.ok(roomGetMemberListUseCase.getRoomMemberList(roomId));
}

// 진행중인 방 상세보기
@GetMapping("/rooms/{roomId}/playing")
public BaseResponse<RoomPlayingDetailViewResponse> getPlayingRoomDetailView(
@UserId final Long userId,
@PathVariable("roomId") final Long roomId
) {
return BaseResponse.ok(roomShowPlayingDetailViewUseCase.getPlayingRoomDetailView(userId, roomId));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package konkuk.thip.room.adapter.in.web.response;

import lombok.Builder;

import java.util.List;

@Builder
public record RoomPlayingDetailViewResponse(
boolean isHost,
Long roomId,
String roomName,
String roomImageUrl,
boolean isPublic,
String progressStartDate,
String progressEndDate,
String category,
String roomDescription,
int memberCount,
int recruitCount,
String isbn,
String bookTitle,
String authorName,
int currentPage,
double userPercentage,
List<CurrentVote> currentVotes
) {
public record CurrentVote(
String content,
int page,
boolean isOverview,
List<VoteItem> voteItems
) {
public record VoteItem(
String itemName
) {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package konkuk.thip.room.application.port.in;

import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse;

public interface RoomShowPlayingDetailViewUseCase {

RoomPlayingDetailViewResponse getPlayingRoomDetailView(Long userId, Long roomId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package konkuk.thip.room.application.service;

import konkuk.thip.book.application.port.out.BookCommandPort;
import konkuk.thip.book.domain.Book;
import konkuk.thip.common.util.DateUtil;
import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse;
import konkuk.thip.room.application.port.in.RoomShowPlayingDetailViewUseCase;
import konkuk.thip.room.application.port.out.RoomCommandPort;
import konkuk.thip.room.domain.Room;
import konkuk.thip.user.application.port.out.UserRoomCommandPort;
import konkuk.thip.user.domain.RoomParticipants;
import konkuk.thip.user.domain.UserRoom;
import konkuk.thip.vote.application.port.out.VoteQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class RoomShowPlayingDetailViewService implements RoomShowPlayingDetailViewUseCase {

private static final int TOP_PARTICIPATION_VOTES_COUNT = 3;

private final RoomCommandPort roomCommandPort;
private final BookCommandPort bookCommandPort;
private final UserRoomCommandPort userRoomCommandPort;
private final VoteQueryPort voteQueryPort;

@Override
@Transactional(readOnly = true)
public RoomPlayingDetailViewResponse getPlayingRoomDetailView(Long userId, Long roomId) {
// 1. Room 조회, Book 조회
Room room = roomCommandPort.findById(roomId);
Book book = bookCommandPort.findById(room.getBookId());

// 2. Room과 연관된 UserRoom 조회, RoomParticipants 일급 컬렉션 생성
// TODO. Room 도메인에 memberCount 값 추가된 후 리펙토링
List<UserRoom> findByRoomId = userRoomCommandPort.findAllByRoomId(roomId);
RoomParticipants roomParticipants = RoomParticipants.from(findByRoomId);

// 3. 투표 참여율이 가장 높은 투표 조회
List<RoomPlayingDetailViewResponse.CurrentVote> topParticipationVotes = voteQueryPort.findTopParticipationVotesByRoom(room, TOP_PARTICIPATION_VOTES_COUNT);

// 4. response 구성
return buildResponse(userId, room, book, roomParticipants, topParticipationVotes);
}

private RoomPlayingDetailViewResponse buildResponse(Long userId, Room room, Book book, RoomParticipants roomParticipants, List<RoomPlayingDetailViewResponse.CurrentVote> topParticipationVotes) {
return RoomPlayingDetailViewResponse.builder()
.isHost(roomParticipants.isHostOfRoom(userId))
.roomId(room.getId())
.roomName(room.getTitle())
.roomImageUrl(room.getCategory().getImageUrl())
.isPublic(room.isPublic())
.progressStartDate(DateUtil.formatDate(room.getStartDate()))
.progressEndDate(DateUtil.formatDate(room.getEndDate()))
.category(room.getCategory().getValue())
.roomDescription(room.getDescription())
.memberCount(roomParticipants.calculateMemberCount())
.recruitCount(room.getRecruitCount())
.isbn(book.getIsbn())
.bookTitle(book.getTitle())
.authorName(book.getAuthorName())
.currentPage(roomParticipants.getCurrentPageOfUser(userId))
.userPercentage(roomParticipants.getUserPercentageOfUser(userId))
.currentVotes(topParticipationVotes)
.build();
}
}
19 changes: 19 additions & 0 deletions src/main/java/konkuk/thip/user/domain/RoomParticipants.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package konkuk.thip.user.domain;

import konkuk.thip.common.exception.InvalidStateException;
import konkuk.thip.user.adapter.out.jpa.UserRoomRole;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.List;

import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_BELONG_TO_ROOM;

@Getter
@RequiredArgsConstructor
public class RoomParticipants {
Expand Down Expand Up @@ -33,4 +36,20 @@ public boolean isHostOfRoom(Long userId) {
.filter(userRoom -> userRoom.getUserId().equals(userId))
.anyMatch(userRoom -> userRoom.getUserRoomRole().equals(UserRoomRole.HOST.getType()));
}

public int getCurrentPageOfUser(Long userId) {
return participants.stream()
.filter(userRoom -> userRoom.getUserId().equals(userId))
.map(UserRoom::getCurrentPage)
.findFirst()
.orElseThrow(() -> new InvalidStateException(USER_NOT_BELONG_TO_ROOM));
}

public double getUserPercentageOfUser(Long userId) {
return participants.stream()
.filter(userRoom -> userRoom.getUserId().equals(userId))
.map(UserRoom::getUserPercentage)
.findFirst()
.orElseThrow(() -> new InvalidStateException(USER_NOT_BELONG_TO_ROOM));
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package konkuk.thip.vote.adapter.out.persistence;

import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse;
import konkuk.thip.room.domain.Room;
import konkuk.thip.vote.adapter.out.mapper.VoteMapper;
import konkuk.thip.vote.application.port.out.VoteQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class VoteQueryPersistenceAdapter implements VoteQueryPort {
Expand All @@ -17,4 +21,9 @@ public class VoteQueryPersistenceAdapter implements VoteQueryPort {
public boolean isUserVoted(Long userId, Long voteItemId) {
return userVoteJpaRepository.existsByUserJpaEntity_UserIdAndVoteItemJpaEntity_VoteItemId(userId, voteItemId);
}

@Override
public List<RoomPlayingDetailViewResponse.CurrentVote> findTopParticipationVotesByRoom(Room room, int count) {
return voteJpaRepository.findTopParticipationVotesByRoom(room.getId(), count);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package konkuk.thip.vote.adapter.out.persistence;

import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

웹 레이어 DTO에 대한 의존성을 검토해보세요.

Repository 레이어가 web response DTO(RoomPlayingDetailViewResponse.CurrentVote)에 직접 의존하고 있습니다. 이는 의존성 방향이 역전된 것으로, 더 안정적인 도메인 객체나 별도의 DTO를 사용하는 것을 고려해보세요.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepository.java
at line 3, the repository layer improperly depends on a web response DTO
(RoomPlayingDetailViewResponse.CurrentVote), causing an inversion of dependency.
To fix this, remove the import and usage of the web layer DTO in this repository
class and instead define and use a dedicated domain-level DTO or entity that
represents the required data, ensuring the repository depends only on domain or
persistence models.

import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity;

import java.util.List;

public interface VoteQueryRepository {

List<VoteJpaEntity> findVotesByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Long userId);

List<RoomPlayingDetailViewResponse.CurrentVote> findTopParticipationVotesByRoom(Long roomId, int count);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse;
import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity;
import konkuk.thip.vote.adapter.out.jpa.QVoteItemJpaEntity;
import konkuk.thip.vote.adapter.out.jpa.QVoteJpaEntity;
import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity;
import lombok.RequiredArgsConstructor;
Expand All @@ -16,11 +18,12 @@ public class VoteQueryRepositoryImpl implements VoteQueryRepository {

private final JPAQueryFactory jpaQueryFactory;

private final QVoteJpaEntity vote = QVoteJpaEntity.voteJpaEntity;
private final QUserJpaEntity user = QUserJpaEntity.userJpaEntity;
private final QVoteItemJpaEntity voteItem = QVoteItemJpaEntity.voteItemJpaEntity;

@Override
public List<VoteJpaEntity> findVotesByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Long userId) {
QVoteJpaEntity vote = QVoteJpaEntity.voteJpaEntity;
QUserJpaEntity user = QUserJpaEntity.userJpaEntity;

return jpaQueryFactory
.select(vote)
.from(vote)
Expand All @@ -43,4 +46,39 @@ private BooleanExpression filterByType(String type, QVoteJpaEntity post, Long us
}
return null;
}

@Override
public List<RoomPlayingDetailViewResponse.CurrentVote> findTopParticipationVotesByRoom(Long roomId, int count) {
// 1. Fetch top votes by total participation count
List<VoteJpaEntity> topVotes = jpaQueryFactory
.select(vote)
.from(vote)
.leftJoin(voteItem).on(voteItem.voteJpaEntity.eq(vote))
.where(vote.roomJpaEntity.roomId.eq(roomId))
.groupBy(vote)
.orderBy(voteItem.count.sum().desc()) // 해당 투표에 참여한 총 참여자 수 기준 내림차순 정렬
.limit(count)
.fetch();

// 2. Map to DTOs including vote items
return topVotes.stream()
.map(vote -> {
List<RoomPlayingDetailViewResponse.CurrentVote.VoteItem> voteItems = jpaQueryFactory
.select(voteItem)
.from(voteItem)
.where(voteItem.voteJpaEntity.eq(vote))
.orderBy(voteItem.count.desc())
.fetch()
.stream()
.map(item -> new RoomPlayingDetailViewResponse.CurrentVote.VoteItem(item.getItemName()))
.toList();
return new RoomPlayingDetailViewResponse.CurrentVote(
vote.getContent(),
vote.getPage(),
vote.isOverview(),
voteItems
);
})
.toList();
}
Comment on lines +50 to +83
Copy link

@coderabbitai coderabbitai bot Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

복잡한 QueryDSL 쿼리의 N+1 문제 가능성

findTopParticipationVotesByRoom 메서드에서 N+1 쿼리 문제가 발생할 수 있습니다. 상위 투표를 조회한 후 각 투표마다 별도의 쿼리로 투표 아이템들을 조회하고 있습니다.

성능을 개선하기 위해 단일 쿼리로 모든 데이터를 조회하는 방식을 고려해보세요:

 @Override
 public List<RoomPlayingDetailViewResponse.CurrentVote> findTopParticipationVotesByRoom(Long roomId, int count) {
-    // 1. Fetch top votes by total participation count
-    List<VoteJpaEntity> topVotes = jpaQueryFactory
-            .select(vote)
-            .from(vote)
-            .leftJoin(voteItem).on(voteItem.voteJpaEntity.eq(vote))
-            .where(vote.roomJpaEntity.roomId.eq(roomId))
-            .groupBy(vote)
-            .orderBy(voteItem.count.sum().desc())
-            .limit(count)
-            .fetch();
-
-    // 2. Map to DTOs including vote items
-    return topVotes.stream()
-            .map(vote -> {
-                List<RoomPlayingDetailViewResponse.CurrentVote.VoteItem> voteItems = jpaQueryFactory
-                        .select(voteItem)
-                        .from(voteItem)
-                        .where(voteItem.voteJpaEntity.eq(vote))
-                        .orderBy(voteItem.count.desc())
-                        .fetch()
-                        .stream()
-                        .map(item -> new RoomPlayingDetailViewResponse.CurrentVote.VoteItem(item.getItemName()))
-                        .toList();
-                return new RoomPlayingDetailViewResponse.CurrentVote(
-                        vote.getContent(),
-                        vote.getPage(),
-                        vote.isOverview(),
-                        voteItems
-                );
-            })
-            .toList();
+    // Fetch all data in a single query with join fetch
+    List<VoteJpaEntity> topVotesWithItems = jpaQueryFactory
+            .selectFrom(vote)
+            .leftJoin(vote.voteItemJpaEntities, voteItem).fetchJoin()
+            .where(vote.roomJpaEntity.roomId.eq(roomId))
+            .orderBy(voteItem.count.sum().desc())
+            .limit(count)
+            .fetch();
+            
+    // Group and transform data
+    return topVotesWithItems.stream()
+            .collect(Collectors.groupingBy(Function.identity()))
+            .entrySet().stream()
+            .map(entry -> {
+                VoteJpaEntity voteEntity = entry.getKey();
+                List<RoomPlayingDetailViewResponse.CurrentVote.VoteItem> voteItems = 
+                    voteEntity.getVoteItemJpaEntities().stream()
+                        .sorted((a, b) -> Integer.compare(b.getCount(), a.getCount()))
+                        .map(item -> new RoomPlayingDetailViewResponse.CurrentVote.VoteItem(item.getItemName()))
+                        .toList();
+                return new RoomPlayingDetailViewResponse.CurrentVote(
+                        voteEntity.getContent(),
+                        voteEntity.getPage(),
+                        voteEntity.isOverview(),
+                        voteItems
+                );
+            })
+            .toList();
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java
between lines 50 and 83, the method findTopParticipationVotesByRoom currently
fetches top votes first and then executes separate queries for each vote's
items, causing an N+1 query problem. To fix this, refactor the method to
retrieve all necessary vote and vote item data in a single query using a join
and grouping, then map the results to the DTOs accordingly, eliminating the
multiple queries inside the stream mapping.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seongjunnoh @hd0rable 현재 위쪽에서 이미 vote 테이블과 voteItem 테이블을 조인하지 않고 vote만 조회한 후, 각 Vote에 대해 개별적으로 voteItem을 조회하고 있습니다. 만약 양방향 매핑이 되어 있다면 getVoteItemJpaEntities()를 통해 접근할 수 있지만, 현 구조에서는 각 Vote에 대해 별도의 select 쿼리가 발생하므로 결과적으로 N+1 문제가 발생할 수 있는 구조라고 생각됩니다.

현재는 상위 3개의 투표만 조회하므로 총 4개의 쿼리로 끝나지만, 이후 다른 기능들(특히 기록장 조회 API)에서도 Vote와 VoteItem은 자주 함께 사용될 가능성이 높아 보입니다. 따라서 성능 측면에서도 양방향 매핑을 추가한 뒤 fetch join을 고려하는 방식이 더 나을 수 있지 않을까 조심스럽게 제안드립니다..!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@buzz0331 정말 좋은 분석입니다! 맞습니다. 현재 구조에서는 N+1 문제가 발생하고 있으며, 양방향 매핑과 fetch join을 통한 해결 방안이 더 나은 접근법이라고 생각합니다.

특히 기록장 조회 API 등 다른 기능에서도 Vote와 VoteItem이 함께 사용될 가능성이 높다는 점을 고려하면, 장기적으로 보았을 때 양방향 매핑을 추가하는 것이 성능상 더 유리할 것 같습니다.

현재 3개의 투표만 조회하므로 4개의 쿼리로 제한되지만, 확장성을 고려한다면 좋은 개선 방향이라고 생각합니다!


✏️ Learnings added
Learnt from: buzz0331
PR: THIP-TextHip/THIP-Server#75
File: src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java:50-83
Timestamp: 2025-07-14T14:19:38.762Z
Learning: Vote와 VoteItem 엔티티는 자주 함께 사용되므로, N+1 문제를 방지하기 위해 양방향 매핑과 fetch join을 고려하는 것이 좋습니다. 특히 기록장 조회 API 등에서도 함께 사용될 가능성이 높습니다.

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.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package konkuk.thip.vote.application.port.out;

import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse;
import konkuk.thip.room.domain.Room;

import java.util.List;

public interface VoteQueryPort {

boolean isUserVoted(Long userId, Long voteId);

List<RoomPlayingDetailViewResponse.CurrentVote> findTopParticipationVotesByRoom(Room room, int count);
}
Loading