Skip to content
Merged
20 changes: 15 additions & 5 deletions src/main/java/konkuk/thip/config/OpenApiConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,24 @@ public class OpenApiConfig {

@Value("${server.http-url}") private String httpUrl;

@Value(("${server.profile}")) private String profile;
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

server.profile 미설정 시 구동 실패 가능성 — 기본값 지정 권장

운영 환경에서 항상 server.profile이 주입된다는 보장이 없다면 애플리케이션 부팅 시점에 실패할 수 있습니다. 안전한 기본값(local)을 지정하는 것을 권장합니다.

-    @Value(("${server.profile}")) private String profile;
+    @Value("${server.profile:local}") private String profile;

아직 server.profile가 어떻게 사용·설정되는지 확인이 필요해 보입니다. 코드 및 설정 파일 전반에서 해당 프로퍼티가 어디에 선언/사용되는지 찾아보겠습니다.


🏁 Script executed:

#!/bin/bash
# repository 루트에서 server.profile 프로퍼티 전체 검색
rg "server.profile" -n

Length of output: 135


서버 프로필 기본값 지정 제안

server.profile 프로퍼티가 없을 경우 애플리케이션 구동 시점에
Could not resolve placeholder 'server.profile' 예외가 발생할 수 있습니다.
안전하게 기본값(local)을 지정해 주세요.

  • 대상 파일: src/main/java/konkuk/thip/config/OpenApiConfig.java (라인 49)
-    @Value("${server.profile}") private String profile;
+    @Value("${server.profile:local}") private String profile;
📝 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.

Suggested change
@Value(("${server.profile}")) private String profile;
@Value("${server.profile:local}") private String profile;
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/config/OpenApiConfig.java around line 49, the
@Value injection for server.profile currently fails if the property is missing;
change the annotation to provide a default by using the Spring property default
syntax so it falls back to "local" (e.g. replace the current @Value(...) with
@Value("${server.profile:local}") to avoid Could not resolve placeholder
errors).


@Bean
public OpenAPI openAPI() {
List<Server> serverList = switch (profile) {
case "prod" -> List.of(new Server().url(httpsUrl).description("HTTPS 배포 서버"));
case "dev" -> List.of(
new Server().url(httpUrl).description("HTTP 개발 서버"),
new Server().url(httpsUrl).description("HTTPS 배포 서버"),
new Server().url("http://localhost:8080").description("로컬 개발 서버")
);
default -> List.of(
new Server().url("http://localhost:8080").description("로컬 개발 서버")
);
};
Comment on lines +53 to +63
Copy link
Collaborator

Choose a reason for hiding this comment

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

굳굳


return new OpenAPI()
.servers(List.of(
new Server().url(httpsUrl).description("HTTPS 배포 서버"),
new Server().url(httpUrl).description("HTTP IP"),
new Server().url("http://localhost:8080").description("로컬 개발 서버")
))
.servers(serverList)
.components(setComponents())
.addSecurityItem(setSecurityItems());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import konkuk.thip.user.application.port.in.dto.UserSignupCommand;
Expand All @@ -12,8 +13,9 @@ public record UserSignupRequest(
@NotBlank(message = "aliasName은 필수입니다.")
String aliasName,

@Schema(description = "사용자 닉네임", example = "홍길동_123")
@Pattern(regexp = "[가-힣a-zA-Z0-9]+", message = "닉네임은 한글, 영어, 숫자로만 구성되어야 합니다.(공백불가)")
@Schema(description = "사용자 닉네임", example = "thip")
@NotNull(message = "닉네임은 필수입니다.")
@Pattern(regexp = "[가-힣a-z0-9]+", message = "닉네임은 한글, 영어 소문자, 숫자로만 구성되어야 합니다.(공백불가)")
Comment on lines +17 to +18
Copy link
Collaborator

Choose a reason for hiding this comment

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

오호 이렇게 notnull + pattern 의 조합이면 [null 체크 + 공백 체크 + 패턴 체크] 가 전부 되겠네요!! 좋습니다!!

@coderabbitai 내가 작성한 내용에 틀린 부분이 없지?

Copy link

Choose a reason for hiding this comment

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

@seongjunnoh 맞습니다! 분석이 정확합니다.

@NotNull + @Pattern(regexp = "[가-힣a-z0-9]+") 조합으로:

  • null 체크: @NotNull이 null 값을 방지
  • 공백 체크: 패턴의 + 수량자가 최소 1개 이상의 문자를 요구하므로 빈 문자열 방지
  • 패턴 체크: 한글, 영어 소문자, 숫자만 허용하고 공백 등 다른 문자는 거부

이전의 @NotBlank는 null, 빈 문자열, 공백 문자열을 모두 체크했는데, 새로운 조합도 동일한 효과를 내면서 더 엄격한 문자 패턴 검증까지 수행합니다. 아주 좋은 개선이네요!


🧠 Learnings used
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#93
File: src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java:49-114
Timestamp: 2025-07-28T16:44:31.224Z
Learning: seongjunnoh는 코드 중복 문제에 대한 리팩토링 제안을 적극적으로 수용하고 함수형 인터페이스를 활용한 해결책을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#113
File: src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java:38-44
Timestamp: 2025-07-30T14:05:04.945Z
Learning: seongjunnoh는 코드 최적화 제안에 대해 구체적인 기술적 근거와 효율성 차이를 이해하고 싶어하며, 성능 개선 방식에 대한 상세한 설명을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#195
File: src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java:0-0
Timestamp: 2025-08-13T05:22:32.287Z
Learning: seongjunnoh는 데이터 무결성과 중복 방지에 대한 고민이 깊으며, LinkedHashSet을 활용한 중복 제거와 순서 보장을 동시에 달성하는 솔루션을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#180
File: src/main/java/konkuk/thip/room/application/service/AttendanceCheckCreateService.java:22-40
Timestamp: 2025-08-14T09:15:31.332Z
Learning: seongjunnoh는 기술적 완벽성보다 실제 비즈니스 시나리오와 사용자 행동 패턴을 우선 고려하며, 발생 가능성이 낮은 엣지 케이스보다는 실용적인 구현을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#112
File: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java:272-272
Timestamp: 2025-07-30T10:44:34.115Z
Learning: seongjunnoh는 피드 커서 페이지네이션에서 LocalDateTime 단일 커서 방식을 선호하며, 복합 키 기반 커서보다 구현 단순성과 성능을 우선시한다.

@Size(max = 10, message = "닉네임은 최대 10자 입니다.")
String nickname
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class UserJpaEntity extends BaseJpaEntity {
@Column(length = 60, nullable = false)
private String nickname;

@Column(name = "nickname_updated_at", nullable = false)
@Column(name = "nickname_updated_at", nullable = true) // 회원가입 시에는 null
private LocalDate nicknameUpdatedAt; // 날짜 형식으로 저장 (예: "2023-10-01")

Comment on lines +27 to 29
Copy link

@coderabbitai coderabbitai bot Aug 14, 2025

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

DB 스키마(DDL) 일치 여부 확인 필요: NOT NULL → NULL 변경에 대한 마이그레이션

JPA 매핑에서 nickname_updated_at을 nullable=true로 변경한 것은 서비스 변경과 일치합니다. 다만 운영/개발 DB에도 동일한 스키마 변경(ALTER TABLE … DROP NOT NULL 등)이 반영되어야 런타임 INSERT 시 에러가 발생하지 않습니다. 마이그레이션 스크립트 존재 여부를 확인해주세요.

다음 스크립트로 마이그레이션/DDL 흔적을 빠르게 점검할 수 있습니다:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "후보 마이그레이션 디렉터리 탐색:"
fd -t d -HI '(db|database).*(migration|migrations)|migration|migrations' || true

echo
echo "nickname_updated_at 관련 스키마/변경 검색:"
rg -n -A3 -B3 -i 'nickname_updated_at|alter table.*users|drop not null|nullable' || true

echo
echo "Liquibase/Hibernate 설정 파일 내 컬럼 정의 검색(XML/YAML 등):"
rg -n -A3 -B3 -i 'nickname_updated_at|nullable' --glob '*.{xml,yml,yaml,sql}' || true

Length of output: 41636


DB 스키마(DDL) 마이그레이션 스크립트 확인 및 추가 필요

UserJpaEntity에서 nickname_updated_atnullable=true로 변경하셨지만,
검색 결과 프로젝트 내 마이그레이션 디렉터리나 관련 SQL 스크립트가 발견되지 않았습니다.
운영·개발 DB에도 아래 ALTER 문을 반영하는 마이그레이션 스크립트를 반드시 추가해 주세요.

파일 위치

  • src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java (27–29라인)

예시 SQL (프로젝트에 맞게 조정)

-- MySQL
ALTER TABLE users
  MODIFY COLUMN nickname_updated_at DATE NULL;

-- PostgreSQL
ALTER TABLE users
  ALTER COLUMN nickname_updated_at DROP NOT NULL;

마이그레이션 도구(Flyway, Liquibase 등) 디렉터리에 버전 관리되는 스크립트로 포함하시고,
배포 전 스테이징 환경에서 스키마 변경을 검증해 주세요.

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java around
lines 27–29, you made nickname_updated_at nullable but did not add a DB
migration; add a versioned migration script (in the project's
Flyway/Liquibase/SQL migrations directory) that updates the users table to make
nickname_updated_at NULL (include the correct DDL for your target DB engine,
e.g., MySQL or PostgreSQL), commit it to version control alongside the code
change, and validate the schema change in staging before deployment.

Copy link
Collaborator

Choose a reason for hiding this comment

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

오케이 DB 레벨에서도 alter sql 문을 통해서 설정을 변경해야겟네

Copy link

Choose a reason for hiding this comment

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

@seongjunnoh 네, 맞습니다!

JPA 엔티티 변경과 함께 데이터베이스 스키마도 동기화해야 합니다. 다음과 같이 진행하시면 됩니다:

MySQL의 경우:

ALTER TABLE users MODIFY COLUMN nickname_updated_at DATE NULL;

PostgreSQL의 경우:

ALTER TABLE users ALTER COLUMN nickname_updated_at DROP NOT NULL;

배포 시 안전을 위해 스테이징 환경에서 먼저 테스트해보시고, 가능하다면 마이그레이션 스크립트로 버전 관리하시는 것을 권장합니다.


🧠 Learnings used
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#113
File: src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java:38-44
Timestamp: 2025-07-30T14:05:04.945Z
Learning: seongjunnoh는 코드 최적화 제안에 대해 구체적인 기술적 근거와 효율성 차이를 이해하고 싶어하며, 성능 개선 방식에 대한 상세한 설명을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#93
File: src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java:49-114
Timestamp: 2025-07-28T16:44:31.224Z
Learning: seongjunnoh는 코드 중복 문제에 대한 리팩토링 제안을 적극적으로 수용하고 함수형 인터페이스를 활용한 해결책을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#195
File: src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java:0-0
Timestamp: 2025-08-13T05:22:32.287Z
Learning: seongjunnoh는 데이터 무결성과 중복 방지에 대한 고민이 깊으며, LinkedHashSet을 활용한 중복 제거와 순서 보장을 동시에 달성하는 솔루션을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#112
File: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java:272-272
Timestamp: 2025-07-30T10:44:34.115Z
Learning: seongjunnoh는 피드 커서 페이지네이션에서 LocalDateTime 단일 커서 방식을 선호하며, 복합 키 기반 커서보다 구현 단순성과 성능을 우선시한다.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

오케이 DB 레벨에서도 alter sql 문을 통해서 설정을 변경해야겟네

수정해두었습니다!!

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!

@Column(name = "oauth2_id", length = 50, nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;

import static konkuk.thip.user.adapter.out.jpa.UserRole.USER;


Expand All @@ -29,7 +27,7 @@ public class UserSignupService implements UserSignupUseCase {
public UserSignupResult signup(UserSignupCommand command) {
Alias alias = Alias.from(command.aliasName());
User user = User.withoutId(
command.nickname(), LocalDate.now(), USER.getType(), command.oauth2Id(), alias
command.nickname(), USER.getType(), command.oauth2Id(), alias
);
Long userId = userCommandPort.save(user);
String accessToken = jwtUtil.createAccessToken(userId);
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/konkuk/thip/user/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ public class User extends BaseDomainEntity {

private Alias alias;

public static User withoutId(String nickname, LocalDate nicknameUpdatedAt, String userRole, String oauth2Id, Alias alias) {
public static User withoutId(String nickname, String userRole, String oauth2Id, Alias alias) {
return User.builder()
.id(null)
.nickname(nickname)
.nicknameUpdatedAt(nicknameUpdatedAt)
.nicknameUpdatedAt(null)
.userRole(userRole)
.oauth2Id(oauth2Id)
.followerCount(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import konkuk.thip.vote.adapter.in.web.response.VoteResponse;
import konkuk.thip.vote.application.port.in.VoteCreateUseCase;
import konkuk.thip.vote.application.port.in.VoteUseCase;
import konkuk.thip.vote.application.port.in.dto.VoteResult;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
Expand Down Expand Up @@ -55,8 +56,7 @@ public BaseResponse<VoteResponse> vote(
@Parameter(description = "투표를 진행할 방 ID", example = "1") @PathVariable Long roomId,
@Parameter(description = "투표할 투표 ID", example = "1") @PathVariable Long voteId,
@Valid @RequestBody VoteRequest request) {
return BaseResponse.ok(VoteResponse.of(
voteUseCase.vote(request.toCommand(userId, roomId, voteId)))
);
VoteResult voteResult = voteUseCase.vote(request.toCommand(userId, roomId, voteId));
return BaseResponse.ok(VoteResponse.of(voteResult.postId(), voteResult.roomId(), voteResult.voteItems()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import konkuk.thip.vote.application.port.in.dto.VoteResult;

import java.util.List;

public record VoteResponse(
Long voteItemId,
Long postId,
Long roomId,
Boolean type
List<VoteResult.VoteItemDto> voteItems
) {
public static VoteResponse of(VoteResult voteResult) {
return new VoteResponse(voteResult.voteItemId(), voteResult.roomId(), voteResult.type());

public static VoteResponse of(Long postId, Long roomId, List<VoteResult.VoteItemDto> voteItems) {
return new VoteResponse(postId, roomId, voteItems);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@ public Map<Long, List<VoteItemQueryDto>> findVoteItemsByVoteIds(Set<Long> voteId
)
);
}

@Override
public List<VoteItemQueryDto> findVoteItemsByVoteId(Long voteId, Long userId) {
return voteJpaRepository.findVoteItemsByVoteId(voteId, userId);
}
Comment on lines +41 to +44
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

투표 항목 정렬 보장 필요(클라이언트 응답 일관성 관점).

현재 Repository가 반환하는 List<VoteItemQueryDto>의 정렬이 보장되지 않으면, API 응답 내 항목 순서가 호출 시점마다 달라질 수 있습니다. 서비스 단의 퍼센트 계산은 동일 리스트 기반이라 즉각적인 불일치는 없지만, 클라이언트 UI/스냅샷 테스트에서 순서가 바뀌는 것은 불안정성을 유발합니다. Repository 쿼리에서 명시적으로 ORDER BY(비즈니스 기준: 노출 순서/생성일/ID 등)를 보장하거나, 어댑터에서 최소한의 정렬을 적용하는 것을 권장합니다.

다음 스크립트로 Repository 구현에서 ORDER BY가 보장되는지 확인해 주세요.

정렬이 보장되지 않는 경우, 임시 방편으로 어댑터에서 ID 기준 정렬을 적용할 수 있습니다(비즈니스 정렬 기준이 따로 있다면 해당 기준을 사용).

-    public List<VoteItemQueryDto> findVoteItemsByVoteId(Long voteId, Long userId) {
-        return voteJpaRepository.findVoteItemsByVoteId(voteId, userId);
-    }
+    public List<VoteItemQueryDto> findVoteItemsByVoteId(Long voteId, Long userId) {
+        return voteJpaRepository.findVoteItemsByVoteId(voteId, userId)
+                .stream()
+                .sorted(java.util.Comparator.comparing(VoteItemQueryDto::voteItemId))
+                .toList();
+    }

참고: 위 변경 시 import가 필요합니다.

import java.util.Comparator;

🏁 Script executed:

#!/bin/bash
# findVoteItemsByVoteId 쿼리 정의 및 ORDER BY 여부 확인
rg -n "findVoteItemsByVoteId\\(" -A 10
rg -n "ORDER\\s+BY|order\\s+by" -A 2

Length of output: 6109


투표 항목 정렬 보장 필요
VoteQueryRepositoryImpl의 findVoteItemsByVoteId 쿼리에 ORDER BY가 없어 반환 순서가 불안정합니다. 클라이언트 UI나 스냅샷 테스트에서 리스트 순서가 매번 달라지지 않도록, 아래 중 하나를 적용해주세요:

  • Repository 레벨에서 QueryDSL .orderBy(...) 추가
    return jpaQueryFactory
            .select(new QVoteItemQueryDto(...))
            .from(voteItem)
            …  
            .where(...)
  •     .orderBy(voteItem.voteItemId.asc())    // 비즈니스 기준 필드 사용
        .fetch();
    
  • 어댑터 레벨에서 반환 리스트에 최소 정렬 적용
        public List<VoteItemQueryDto> findVoteItemsByVoteId(Long voteId, Long userId) {
  •     return voteJpaRepository.findVoteItemsByVoteId(voteId, userId);
    
  •     return voteJpaRepository.findVoteItemsByVoteId(voteId, userId)
    
  •             .stream()
    
  •             .sorted(Comparator.comparing(VoteItemQueryDto::voteItemId))
    
  •             .toList();
    }
    
    + import java.util.Comparator;
    
    
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java
around lines 41 to 44, the adapter currently returns
voteJpaRepository.findVoteItemsByVoteId(voteId, userId) but the underlying query
has no ORDER BY so list order is unstable; fix by either adding an
.orderBy(voteItem.voteItemId.asc()) to the QueryDSL query in
VoteQueryRepositoryImpl, or (if changing repository is not possible) sort the
returned list in this adapter before returning, e.g. call
result.sort(Comparator.comparing(VoteItemQueryDto::getVoteItemId)) and add
import java.util.Comparator; to ensure deterministic ordering.

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface VoteQueryRepository {
List<RoomPlayingDetailViewResponse.CurrentVote> findTopParticipationVotesByRoom(Long roomId, int count);

List<VoteItemQueryDto> mapVoteItemsByVoteIds(Set<Long> voteIds, Long userId);

List<VoteItemQueryDto> findVoteItemsByVoteId(Long voteId, Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,30 @@ public List<VoteItemQueryDto> mapVoteItemsByVoteIds(Set<Long> voteIds, Long user
.where(voteItem.voteJpaEntity.postId.in(voteIds))
.fetch();
}

@Override
public List<VoteItemQueryDto> findVoteItemsByVoteId(Long voteId, Long userId) {
QVoteItemJpaEntity voteItem = QVoteItemJpaEntity.voteItemJpaEntity;
QVoteParticipantJpaEntity voteParticipant = QVoteParticipantJpaEntity.voteParticipantJpaEntity;

return jpaQueryFactory
.select(new QVoteItemQueryDto(
voteItem.voteJpaEntity.postId,
voteItem.voteItemId,
voteItem.itemName,
voteItem.count,
JPAExpressions
.selectOne()
.from(voteParticipant)
.where(
voteParticipant.voteItemJpaEntity.eq(voteItem),
voteParticipant.userJpaEntity.userId.eq(userId)
)
.exists() // isVoted : 로그인한 사용자가 해당 투표 아이템에 투표했는지 여부 서브 쿼리
))
.from(voteItem)
.where(voteItem.voteJpaEntity.postId.eq(voteId))
.orderBy(voteItem.count.desc(), voteItem.voteItemId.asc())
.fetch();
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
package konkuk.thip.vote.application.port.in.dto;

import java.util.List;

public record VoteResult(
Long voteItemId,
Long postId,
Long roomId,
Boolean type
List<VoteItemDto> voteItems
) {
public static VoteResult of(Long voteItemId, Long roomId, Boolean type) {
return new VoteResult(voteItemId, roomId, type);
public record VoteItemDto(
Long voteItemId,
String itemName,
int percentage,
Boolean isVoted
) {
public static VoteItemDto of(Long voteItemId, String itemName, int percentage, Boolean isVoted) {
return new VoteItemDto(voteItemId, itemName, percentage, isVoted);
}
}

public static VoteResult of(Long postId, Long roomId, List<VoteItemDto> voteItems) {
return new VoteResult(postId, roomId, voteItems);
}
Comment on lines +21 to 23
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

List 불변성 보장 필요 (외부 변경으로부터 VoteResult 보호)

record 자체는 불변이지만, 생성자에 전달된 List는 외부에서 변경될 수 있습니다. 응답 DTO를 안전하게 유지하려면 방어적 복사를 권장합니다.

아래처럼 불변 리스트로 감싸주세요.

-    public static VoteResult of(Long postId, Long roomId, List<VoteItemDto> voteItems) {
-        return new VoteResult(postId, roomId, voteItems);
+    public static VoteResult of(Long postId, Long roomId, List<VoteItemDto> voteItems) {
+        return new VoteResult(postId, roomId, List.copyOf(voteItems));
     }
📝 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.

Suggested change
public static VoteResult of(Long postId, Long roomId, List<VoteItemDto> voteItems) {
return new VoteResult(postId, roomId, voteItems);
}
public static VoteResult of(Long postId, Long roomId, List<VoteItemDto> voteItems) {
return new VoteResult(postId, roomId, List.copyOf(voteItems));
}
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/vote/application/port/in/dto/VoteResult.java around
lines 21 to 23, the factory method passes the mutable List voteItems directly
into the record allowing external mutation; change it to pass an immutable
defensive copy (e.g., List.copyOf(voteItems) or Collections.unmodifiableList(new
ArrayList<>(voteItems))) so the VoteResult holds an unmodifiable list and is
protected from external changes.

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ public interface VoteQueryPort {
List<RoomPlayingDetailViewResponse.CurrentVote> findTopParticipationVotesByRoom(Room room, int count);

Map<Long, List<VoteItemQueryDto>> findVoteItemsByVoteIds(Set<Long> voteIds, Long userId);

List<VoteItemQueryDto> findVoteItemsByVoteId(Long voteId, Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,30 @@
import konkuk.thip.common.exception.code.ErrorCode;
import konkuk.thip.room.application.service.validator.RoomParticipantValidator;
import konkuk.thip.vote.application.port.in.VoteUseCase;
import konkuk.thip.vote.application.port.out.VoteCommandPort;
import konkuk.thip.vote.application.port.in.dto.VoteCommand;
import konkuk.thip.vote.application.port.in.dto.VoteResult;
import konkuk.thip.vote.application.port.out.VoteCommandPort;
import konkuk.thip.vote.application.port.out.VoteQueryPort;
import konkuk.thip.vote.application.port.out.dto.VoteItemQueryDto;
import konkuk.thip.vote.domain.VoteItem;
import konkuk.thip.vote.domain.VoteParticipant;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.IntStream;

@Service
@RequiredArgsConstructor
public class VoteService implements VoteUseCase {

private final VoteCommandPort voteCommandPort;
private final VoteQueryPort voteQueryPort;
private final RoomParticipantValidator roomParticipantValidator;

@Override
@Transactional
public VoteResult vote(VoteCommand command) {
// 1. 방 참가자 검증
roomParticipantValidator.validateUserIsRoomMember(command.roomId(), command.userId());
Expand All @@ -32,7 +40,24 @@ public VoteResult vote(VoteCommand command) {
cancelVote(command.userId(), command.voteItemId());
}

return VoteResult.of(command.voteItemId(), command.roomId(), command.type());
// 2. 투표 결과 반환
List<VoteItemQueryDto> voteItems = voteQueryPort.findVoteItemsByVoteId(command.voteId(), command.userId());
List<Integer> counts = voteItems.stream()
.map(VoteItemQueryDto::voteCount)
.toList();

List<Integer> percentages = VoteItem.calculatePercentages(counts);

var voteItemDtos = IntStream.range(0, voteItems.size())
.mapToObj(i -> VoteResult.VoteItemDto.of(
voteItems.get(i).voteItemId(),
voteItems.get(i).itemName(),
percentages.get(i),
voteItems.get(i).isVoted()
))
.toList();

return VoteResult.of(command.voteId(), command.roomId(), voteItemDtos);
}

private void voteOrUpdate(Long userId, Long voteId, Long voteItemId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,27 @@ void signup_nickname_blank() throws Exception {
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode()))
.andExpect(jsonPath("$.message", containsString("닉네임은 한글, 영어, 숫자로만 구성되어야 합니다.(공백불가)")));
.andExpect(jsonPath("$.message", containsString("닉네임은 한글, 영어 소문자, 숫자로만 구성되어야 합니다.(공백불가)")));
}

@Test
@DisplayName("[닉네임]값이 null일 경우, 400 error가 발생한다.")
void signup_nickname_null() throws Exception {
//given: nickname blank
UserSignupRequest request = new UserSignupRequest(
"문학가",
null
);

//when //then
String testToken = jwtUtil.createSignupToken("kakao_12345678");
mockMvc.perform(post("/users/signup")
.header("Authorization", "Bearer " + testToken) //헤더 추가
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode()))
.andExpect(jsonPath("$.message", containsString("닉네임은 필수입니다.")));
}

@Test
Expand All @@ -145,7 +165,7 @@ void signup_nickname_invalid_pattern() throws Exception {
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode()))
.andExpect(jsonPath("$.message", containsString("닉네임은 한글, 영어, 숫자로만 구성되어야 합니다.(공백불가)")));
.andExpect(jsonPath("$.message", containsString("닉네임은 한글, 영어 소문자, 숫자로만 구성되어야 합니다.(공백불가)")));
}

@Test
Expand Down
16 changes: 11 additions & 5 deletions src/test/java/konkuk/thip/vote/adapter/in/web/VoteApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,15 @@ void vote_first_success() throws Exception {
.requestAttr("userId", user.getUserId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.postId").value(vote.getPostId()))
.andExpect(jsonPath("$.data.roomId").value(room.getRoomId()))
.andExpect(jsonPath("$.data.voteItems[0].voteItemId").value(item.getVoteItemId()))
.andExpect(jsonPath("$.data.voteItems[0].isVoted").value(true));

assertThat(voteParticipantJpaRepository.findAll())
.hasSize(1)
.allMatch(vp -> vp.getVoteItemJpaEntity().getVoteItemId().equals(item.getVoteItemId()));

voteItemJpaRepository.findById(item.getVoteItemId())
.ifPresent(voteItem -> assertThat(voteItem.getCount()).isEqualTo(1));
}
Expand All @@ -121,12 +124,13 @@ void vote_alreadyVoted_change_success() throws Exception {
.requestAttr("userId", user.getUserId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted", item2.getVoteItemId()).value(true))
.andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted", item1.getVoteItemId()).value(false));
Comment on lines +127 to +129
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

JsonPath 필터 결과는 배열입니다. 단일 값 비교 시 인덱싱이 필요합니다.

[?(@.voteItemId == %s)].isVoted는 배열을 반환합니다. 현재 .value(true)는 단일 boolean을 기대하므로 실패 가능성이 있습니다. 첫 번째 매칭 항목만 검사하려면 [0] 인덱스를 추가하세요(또는 Matchers.contains/hasItem를 사용).

-                .andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted", item2.getVoteItemId()).value(true))
-                .andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted", item1.getVoteItemId()).value(false));
+                .andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted[0]", item2.getVoteItemId()).value(true))
+                .andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted[0]", item1.getVoteItemId()).value(false));

대안: import static org.hamcrest.Matchers.hasItem;

.andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted", item2.getVoteItemId()).value(hasItem(true)))
🤖 Prompt for AI Agents
In src/test/java/konkuk/thip/vote/adapter/in/web/VoteApiTest.java around lines
127 to 129, the JsonPath filter expression returns an array so asserting a
single boolean causes failures; either index the first match by appending [0] to
the path (e.g. ...[?(@.voteItemId == %s)].isVoted[0]) or change the assertion to
expect a collection using a Hamcrest matcher (import static
org.hamcrest.Matchers.hasItem and assert value(hasItem(true)) / hasItem(false))
so the test checks the result correctly.


assertThat(voteParticipantJpaRepository.findAll())
.hasSize(1)
.allMatch(vp -> vp.getVoteItemJpaEntity().getVoteItemId().equals(item2.getVoteItemId()));

voteItemJpaRepository.findById(item1.getVoteItemId())
.ifPresent(voteItem -> assertThat(voteItem.getCount()).isEqualTo(0));
voteItemJpaRepository.findById(item2.getVoteItemId())
Expand Down Expand Up @@ -167,7 +171,8 @@ void vote_cancel_success() throws Exception {
.requestAttr("userId", user.getUserId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted", item.getVoteItemId()).value(false));

Comment on lines +174 to 176
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

JsonPath 필터 결과 인덱싱 누락 동일 이슈.

여기도 배열 비교 이슈가 동일하게 발생할 수 있습니다. [0] 인덱스를 추가하는 것을 권장합니다.

-                .andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted", item.getVoteItemId()).value(false));
+                .andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted[0]", item.getVoteItemId()).value(false));
📝 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.

Suggested change
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted", item.getVoteItemId()).value(false));
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.voteItems[?(@.voteItemId == %s)].isVoted[0]", item.getVoteItemId()).value(false));
🤖 Prompt for AI Agents
In src/test/java/konkuk/thip/vote/adapter/in/web/VoteApiTest.java around lines
174 to 176, the JsonPath assertion uses a filter that returns an array which can
cause indexing ambiguity; update the JsonPath to select the first matched
element by appending [0] before accessing .isVoted (e.g. use
voteItems[?(@.voteItemId == %s)][0].isVoted) so the assertion targets a single
value and avoid array comparison issues.

assertThat(voteParticipantJpaRepository.findAll()).isEmpty();
voteItemJpaRepository.findById(item.getVoteItemId())
Expand Down Expand Up @@ -208,4 +213,5 @@ void vote_userNotRoomMember_fail() throws Exception {
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ROOM_ACCESS_FORBIDDEN.getCode()));
}

}
Loading