-
Notifications
You must be signed in to change notification settings - Fork 0
[Refactor] 댓글 조회 API 분리 #346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
9cbf9f0
820169d
f8b25ff
d755e12
1fe8414
22bae8f
5411d5c
2dd495a
d4c063b
d85d8a9
9c0ad46
afe0717
8d8a135
794e70a
718c007
16c51e1
44eb601
fa5fe3b
8040bde
a660c44
02c1df1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,19 @@ env: | |
| jobs: | ||
| build: | ||
| runs-on: ubuntu-latest | ||
|
|
||
| # Redis를 서비스로 실행 (GitHub Runner -> Docker 컨테이너 통신) | ||
| services: | ||
| redis: | ||
| image: redis:7 | ||
| ports: | ||
| - 6379:6379 | ||
| options: >- | ||
| --health-cmd "redis-cli ping" | ||
| --health-interval 10s | ||
| --health-timeout 5s | ||
| --health-retries 5 | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
|
|
@@ -46,11 +59,6 @@ jobs: | |
| - name: 👏🏻 grant execute permission for gradlew | ||
| run: chmod +x gradlew | ||
|
|
||
| - name: 🚀 Start Redis | ||
| uses: supercharge/[email protected] | ||
| with: | ||
| redis-version: 7 | ||
|
|
||
| - name: 🐘 build with Gradle | ||
| run: ./gradlew build --parallel --stacktrace | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -122,4 +122,8 @@ $RECYCLE.BIN/ | |
| *.msp | ||
|
|
||
| # Windows shortcuts | ||
| *.lnk | ||
| *.lnk | ||
|
|
||
| # monitoring files for local development | ||
| /monitoring/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,14 @@ | ||
| FROM amazoncorretto:17 | ||
|
|
||
| ARG PORT=8000 | ||
| ENV JAVA_TOOL_OPTIONS="-Xms512m -Xmx2g -XX:+ExitOnOutOfMemoryError" | ||
| # 작업 디렉토리 생성 | ||
| WORKDIR /app | ||
|
|
||
| EXPOSE ${PORT} | ||
| # 컨테이너 환경에 맞춰 힙 메모리를 자동 조절하는 옵션 적용 | ||
| # MaxRAMPercentage=75.0 : 컨테이너 메모리의 75%를 힙으로 사용 (나머지는 메타스페이스, 스레드, OS 등을 위해 남겨둠) | ||
| ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError" | ||
|
|
||
| COPY ./build/libs/*.jar ./app.jar | ||
| # 빌드된 JAR 파일 복사 | ||
| COPY ./build/libs/*.jar app.jar | ||
|
|
||
| # 실행 | ||
| ENTRYPOINT ["java", "-jar", "app.jar"] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import http from 'k6/http'; | ||
| import { check } from 'k6'; | ||
|
|
||
| // --- 환경 설정 --- | ||
| const BASE_URL = 'http://localhost:8080'; | ||
| const TARGET_POST_ID = 1; | ||
| const POST_TYPE = 'FEED'; | ||
|
|
||
| export const options = { | ||
| // --- 시나리오 설정 (Constant Arrival Rate) --- | ||
| scenarios: { | ||
| // 1단계: 초당 50 요청 (Warm-up) | ||
| warm_up: { | ||
| executor: 'constant-arrival-rate', | ||
| rate: 50, | ||
| timeUnit: '1s', | ||
| duration: '30s', | ||
| preAllocatedVUs: 10, | ||
| maxVUs: 50, | ||
| }, | ||
| // 2단계: 초당 150 요청 (Target Load) | ||
| load_test: { | ||
| executor: 'constant-arrival-rate', | ||
| rate: 150, | ||
| timeUnit: '1s', | ||
| duration: '1m', | ||
| startTime: '30s', | ||
| preAllocatedVUs: 30, | ||
| maxVUs: 100, | ||
| }, | ||
| // 3단계: 초당 300 요청 (Stress Test) | ||
| stress_test: { | ||
| executor: 'constant-arrival-rate', | ||
| rate: 300, | ||
| timeUnit: '1s', | ||
| duration: '30s', | ||
| startTime: '1m30s', | ||
| preAllocatedVUs: 50, | ||
| maxVUs: 200, | ||
| }, | ||
| }, | ||
|
|
||
| // --- [핵심 수정] Thresholds 타겟팅 --- | ||
| // setup() 단계의 요청은 무시하고, 'root_comment_show' 태그가 있는 요청만 평가합니다. | ||
| thresholds: { | ||
| // 1. 응답 시간: 해당 태그 요청의 95%가 50ms 이내여야 함 | ||
| 'http_req_duration{name:root_comment_show}': ['p(95)<50'], | ||
|
|
||
| // 2. 에러율: 해당 태그 요청의 실패율이 1% 미만이어야 함 | ||
| 'http_req_failed{name:root_comment_show}': ['rate<0.01'], | ||
| }, | ||
| }; | ||
|
|
||
| // --- Setup: 토큰 발급 (성능 측정 제외 대상) --- | ||
| export function setup() { | ||
| const MAX_SETUP_VUS = 200; | ||
| console.log(`🚀 토큰 ${MAX_SETUP_VUS}개 발급 시작...`); | ||
| const tokens = []; | ||
|
|
||
| for (let userId = 1; userId <= MAX_SETUP_VUS; userId++) { | ||
| // *주의* 여기에는 tags를 붙이지 않습니다. 따라서 thresholds 평가에서 자동 제외됩니다. | ||
| const res = http.get(`${BASE_URL}/api/test/token/access?userId=${userId}`); | ||
| if (res.status === 200 && res.body.length > 0) { | ||
| tokens.push(res.body); | ||
| } | ||
| } | ||
| console.log(`✅ 토큰 ${tokens.length}개 발급 완료`); | ||
| return { tokens }; | ||
| } | ||
|
|
||
| // --- Main Logic: 루트 댓글 조회 (성능 측정 대상) --- | ||
| export default function (data) { | ||
| // 토큰 랜덤 선택 | ||
| const token = data.tokens[Math.floor(Math.random() * data.tokens.length)]; | ||
|
|
||
| const params = { | ||
| headers: { | ||
| 'Authorization': `Bearer ${token}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| // [중요] 이 태그(name)를 기준으로 thresholds가 작동합니다. | ||
| tags: { name: 'root_comment_show' }, | ||
| }; | ||
|
|
||
| const res = http.get(`${BASE_URL}/comments/${TARGET_POST_ID}?postType=${POST_TYPE}`, params); | ||
|
|
||
| check(res, { | ||
| 'status is 200': (r) => r.status === 200, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,8 +3,11 @@ | |||||||||
| import io.swagger.v3.oas.annotations.Operation; | ||||||||||
| import io.swagger.v3.oas.annotations.Parameter; | ||||||||||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||||||||||
| import konkuk.thip.comment.adapter.in.web.response.CommentForSinglePostResponse; | ||||||||||
| import konkuk.thip.comment.application.port.in.CommentShowAllUseCase; | ||||||||||
| import konkuk.thip.comment.adapter.in.web.response.ChildCommentsResponse; | ||||||||||
| import konkuk.thip.comment.adapter.in.web.response.RootCommentsResponse; | ||||||||||
| import konkuk.thip.comment.application.port.in.ChildCommentShowUseCase; | ||||||||||
| import konkuk.thip.comment.application.port.in.RootCommentShowUseCase; | ||||||||||
| import konkuk.thip.comment.application.port.in.dto.ChildCommentsShowQuery; | ||||||||||
| import konkuk.thip.comment.application.port.in.dto.CommentShowAllQuery; | ||||||||||
| import konkuk.thip.common.dto.BaseResponse; | ||||||||||
| import konkuk.thip.common.security.annotation.UserId; | ||||||||||
|
|
@@ -19,23 +22,40 @@ | |||||||||
| @RequiredArgsConstructor | ||||||||||
| public class CommentQueryController { | ||||||||||
|
|
||||||||||
| public final CommentShowAllUseCase commentShowAllUseCase; | ||||||||||
| public final RootCommentShowUseCase rootCommentShowUseCase; | ||||||||||
| public final ChildCommentShowUseCase childCommentShowUseCase; | ||||||||||
|
Comment on lines
+25
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 필드 접근 제어자를
🔒 수정 제안- public final RootCommentShowUseCase rootCommentShowUseCase;
- public final ChildCommentShowUseCase childCommentShowUseCase;
+ private final RootCommentShowUseCase rootCommentShowUseCase;
+ private final ChildCommentShowUseCase childCommentShowUseCase;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| @Operation( | ||||||||||
| summary = "댓글 전체 조회", | ||||||||||
| description = "특정 게시글(= 피드, 기록, 투표) 의 댓글과 대댓글들을 전체 조회합니다." | ||||||||||
| summary = "루트 댓글 조회", | ||||||||||
| description = "특정 게시글(= 피드, 기록, 투표) 에 직접 달린 루트 댓글을 조회합니다." | ||||||||||
| ) | ||||||||||
| @GetMapping("/comments/{postId}") | ||||||||||
| public BaseResponse<CommentForSinglePostResponse> showAllCommentsOfPost( | ||||||||||
| public BaseResponse<RootCommentsResponse> showRootCommentsOfPost( | ||||||||||
| @Parameter(hidden = true) @UserId final Long userId, | ||||||||||
| @Parameter(description = "댓글을 조회할 게시글(= FEED, RECORD, VOTE)의 id값") | ||||||||||
| @PathVariable("postId") final Long postId, | ||||||||||
| @Parameter(description = "게시물 타입 (RECORD, VOTE, FEED)", example = "RECORD") | ||||||||||
| @RequestParam(value = "postType") final String postType, | ||||||||||
| @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") | ||||||||||
| @RequestParam(value = "cursor", required = false) final String cursor) { | ||||||||||
| return BaseResponse.ok(commentShowAllUseCase.showAllCommentsOfPost( | ||||||||||
| return BaseResponse.ok(rootCommentShowUseCase.showRootCommentsOfPost( | ||||||||||
| CommentShowAllQuery.of(postId, userId, postType, cursor) | ||||||||||
| )); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @Operation( | ||||||||||
| summary = "특정 댓글의 대댓글 조회", | ||||||||||
| description = "특정 루트 댓글의 모든 대댓글(자식 댓글)을 작성 시각순으로 조회합니다." | ||||||||||
| ) | ||||||||||
| @GetMapping("/comments/replies/{rootCommentId}") | ||||||||||
| public BaseResponse<ChildCommentsResponse> showChildComments( | ||||||||||
| @Parameter(hidden = true) @UserId final Long userId, | ||||||||||
| @Parameter(description = "부모 댓글(루트 댓글)의 id값") | ||||||||||
| @PathVariable("rootCommentId") final Long rootCommentId, | ||||||||||
| @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") | ||||||||||
| @RequestParam(value = "cursor", required = false) final String cursor) { | ||||||||||
| return BaseResponse.ok(childCommentShowUseCase.showChildComments( | ||||||||||
| ChildCommentsShowQuery.of(rootCommentId, userId, cursor) | ||||||||||
| )); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package konkuk.thip.comment.adapter.in.web.request; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import konkuk.thip.comment.application.port.in.dto.ChildCommentCreateCommand; | ||
|
|
||
| @Schema(description = "자식 댓글(답글) 작성 요청 DTO") | ||
| public record ChildCommentCreateRequest( | ||
|
|
||
| @Schema(description = "댓글 내용", example = "좋은 의견이네요!") | ||
| @NotBlank(message = "댓글 내용은 필수입니다.") | ||
| String content, | ||
|
|
||
| @Schema(description = "게시물 타입 (RECORD, VOTE, FEED)", example = "RECORD") | ||
| @NotBlank(message = "게시물 타입은 필수입니다.") | ||
| String postType, | ||
|
|
||
| @Schema(description = "게시물 ID", example = "1") | ||
| @NotNull(message = "게시물 ID는 필수입니다.") | ||
| Long postId | ||
| ) { | ||
| public ChildCommentCreateCommand toCommand(Long userId, Long parentCommentId) { | ||
| return new ChildCommentCreateCommand(content, postType, postId, userId, parentCommentId); | ||
| } | ||
| } |
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package konkuk.thip.comment.adapter.in.web.request; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import konkuk.thip.comment.application.port.in.dto.RootCommentCreateCommand; | ||
|
|
||
| @Schema(description = "루트 댓글 작성 요청 DTO") | ||
| public record RootCommentCreateRequest( | ||
|
|
||
| @Schema(description = "댓글 내용", example = "이 게시물 정말 좋아요!") | ||
| @NotBlank(message = "댓글 내용은 필수입니다.") | ||
| String content, | ||
|
|
||
| @Schema(description = "게시물 타입 (RECORD, VOTE, FEED)", example = "RECORD") | ||
| @NotBlank(message = "게시물 타입은 필수입니다.") | ||
| String postType | ||
| ) { | ||
| public RootCommentCreateCommand toCommand(Long userId, Long postId) { | ||
| return new RootCommentCreateCommand(content, postType, postId, userId); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,25 @@ | ||||||||||||||||||
| package konkuk.thip.comment.adapter.in.web.response; | ||||||||||||||||||
|
|
||||||||||||||||||
| import java.util.List; | ||||||||||||||||||
|
|
||||||||||||||||||
| public record ChildCommentsResponse( | ||||||||||||||||||
| List<ChildCommentDto> childComments, | ||||||||||||||||||
| String nextCursor, | ||||||||||||||||||
| boolean isLast | ||||||||||||||||||
| ) { | ||||||||||||||||||
|
Comment on lines
+5
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's examine the complete ChildCommentsResponse.java file
cat -n src/main/java/konkuk/thip/comment/adapter/in/web/response/ChildCommentsResponse.javaRepository: THIP-TextHip/THIP-Server Length of output: 917 🏁 Script executed: # Check if ChildCommentDto exists and examine it
find . -name "ChildCommentDto.java" -type fRepository: THIP-TextHip/THIP-Server Length of output: 50 🏁 Script executed: # Check build configuration for Jackson and Spring Boot versions
fd "build.gradle|pom.xml" -type f | head -5Repository: THIP-TextHip/THIP-Server Length of output: 238 🏁 Script executed: # Search for Jackson configuration or annotations in the codebase
rg "@JsonProperty|@JsonSerialize|ObjectMapper" --type java | grep -i "comment\|response" | head -20Repository: THIP-TextHip/THIP-Server Length of output: 1547 🏁 Script executed: # Find build configuration files
find . -maxdepth 3 -name "build.gradle" -o -name "pom.xml" | head -5Repository: THIP-TextHip/THIP-Server Length of output: 82 🏁 Script executed: # Read build.gradle if it exists
cat src/../build.gradle 2>/dev/null || cat build.gradle 2>/dev/null || echo "Not found"Repository: THIP-TextHip/THIP-Server Length of output: 3630 🏁 Script executed: # Search for Spring Boot and Jackson version declarations
rg "spring-boot|jackson|org.springframework" --type-list | head -3Repository: THIP-TextHip/THIP-Server Length of output: 457 🏁 Script executed: # Look for any application.yml or application.properties with Jackson config
find . -name "application*.yml" -o -name "application*.properties" | xargs cat 2>/dev/nullRepository: THIP-TextHip/THIP-Server Length of output: 50 🏁 Script executed: # Check for any ObjectMapper configuration beans
rg "ObjectMapper|@Configuration" --type-add "gradle:build.gradle" src/main/java -A 3 | grep -A 3 "ObjectMapper\|Jackson"Repository: THIP-TextHip/THIP-Server Length of output: 8424
Spring Boot 3.5.0에 포함된 Jackson 2.17.x는 Java record의 코드베이스의 다른 응답 클래스들(BaseResponse.java, ErrorResponse.java)은 이미 🤖 Prompt for AI Agents |
||||||||||||||||||
| public record ChildCommentDto( | ||||||||||||||||||
| Long commentId, | ||||||||||||||||||
| String parentCommentCreatorNickname, | ||||||||||||||||||
| Long creatorId, | ||||||||||||||||||
| String creatorProfileImageUrl, | ||||||||||||||||||
| String creatorNickname, | ||||||||||||||||||
| String aliasName, | ||||||||||||||||||
| String aliasColor, | ||||||||||||||||||
| String postDate, // 댓글 작성 시각 (~ 전 형식) | ||||||||||||||||||
| String content, | ||||||||||||||||||
| int likeCount, | ||||||||||||||||||
| boolean isLike, | ||||||||||||||||||
| boolean isWriter | ||||||||||||||||||
|
Comment on lines
+20
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Jackson derives JSON property names from JavaBean accessor names, not the raw field name. By default it removes How to keep the
|
||||||||||||||||||
| int likeCount, | |
| boolean isLike, | |
| boolean isWriter | |
| int likeCount, | |
| `@JsonProperty`("isLike") | |
| boolean isLike, | |
| `@JsonProperty`("isWriter") | |
| boolean isWriter |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/konkuk/thip/comment/adapter/in/web/response/ChildCommentsResponse.java`
around lines 20 - 22, The two boolean components in the ChildCommentsResponse
record, isLike and isWriter, will be serialized by Jackson as "like" and
"writer" due to JavaBeans `is` stripping; update the record so the JSON property
names match the API contract by either annotating the boolean components with
`@JsonProperty`("isLike") and `@JsonProperty`("isWriter") on the record components
(or their accessor methods), or rename the components to like and writer; apply
the chosen change to the ChildCommentsResponse record definition and import
com.fasterxml.jackson.annotation.JsonProperty if you use annotations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: THIP-TextHip/THIP-Server
Length of output: 82
🏁 Script executed:
Repository: THIP-TextHip/THIP-Server
Length of output: 80
🏁 Script executed:
# Read build.gradle to check jar and bootJar configuration cat -n ./build.gradleRepository: THIP-TextHip/THIP-Server
Length of output: 4547
🏁 Script executed:
# Read Dockerfile to see the COPY statement cat -n ./DockerfileRepository: THIP-TextHip/THIP-Server
Length of output: 504
와일드카드 COPY로 인한 plain JAR 복사 위험: 컨테이너 실행 오류 가능
build.gradle에서jar { enabled = false }를 명시하지 않았으므로, Spring Boot 3.5.0은 기본적으로build/libs/thip-0.0.1-SNAPSHOT.jar(실행 가능한 fat JAR)와build/libs/thip-0.0.1-SNAPSHOT-plain.jar(plain JAR) 두 파일을 모두 생성합니다.Dockerfile의
COPY ./build/libs/*.jar app.jar은 두 파일 모두에 매칭되지만 대상(app.jar)이 디렉토리가 아니므로 Docker 명세에 위배됩니다. 빌더 버전에 따라 비결정적으로 plain JAR이 복사될 수 있으며, 이 경우ENTRYPOINT에서 "no main manifest attribute" 오류로 컨테이너 기동이 실패합니다.해결 방법:
build.gradle에 다음을 추가하여 plain JAR 생성을 비활성화하세요:jar { enabled = false }또는 Dockerfile의 COPY를 명시적 파일명으로 변경하세요:
COPY ./build/libs/thip-*.jar app.jar🤖 Prompt for AI Agents