Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9cbf9f0
[feat] comment entity 복합 인덱스 추가 (#343)
seongjunnoh Jan 17, 2026
820169d
[refactor] 댓글 조회 API 코드 수정 (#343)
seongjunnoh Jan 17, 2026
f8b25ff
[feat] 루트 댓글의 모든 자식댓글들 조회하는 API controller 구현 (#343)
seongjunnoh Jan 17, 2026
d755e12
[feat] 루트 댓글의 모든 자식댓글들 조회하는 API use case 구현 (#343)
seongjunnoh Jan 17, 2026
1fe8414
[feat] 루트 댓글의 모든 자식댓글들 조회하는 API queryDsl 코드 구현 (#343)
seongjunnoh Jan 17, 2026
22bae8f
[feat] 루트 댓글의 모든 자식댓글들 조회하는 API query mapper 구현 (#343)
seongjunnoh Jan 17, 2026
5411d5c
[test] 루트 댓글의 모든 자식댓글들 조회하는 API 통합 테스트 코드 추가 (#343)
seongjunnoh Jan 17, 2026
2dd495a
[feat] comments 테이블에 root_comment_id, descendant_count 컬럼 추가 (#343)
seongjunnoh Jan 23, 2026
d4c063b
[refactor] 댓글 조회 API 분리에 따른 controller 계층 파일 네이밍 수정 (#343)
seongjunnoh Feb 15, 2026
d85d8a9
[feat] comment_like table 인덱스 추가 (#343)
seongjunnoh Feb 15, 2026
9c0ad46
[refactor] 댓글 조회 API 분리에 따른 comment query persistence adapter 계층 코드 수…
seongjunnoh Feb 15, 2026
afe0717
[refactor] 댓글 조회 API 분리에 따른 테스트 코드 수정 (#343)
seongjunnoh Feb 15, 2026
8d8a135
[feat] 루트 댓글 조회 API 캐싱 도입 (#343)
seongjunnoh Feb 16, 2026
794e70a
[refactor] 댓글 생성 -> 루트/자손 댓글 생성 API로 분리 (#343)
seongjunnoh Feb 16, 2026
718c007
[feat] 루트/자손 댓글 생성 API service 추가 (#343)
seongjunnoh Feb 16, 2026
16c51e1
[refactor] CommentCommandPersistenceAdapter 수정 (#343)
seongjunnoh Feb 16, 2026
44eb601
[refactor] 댓글 생성 관련 테스트 코드 수정 (#343)
seongjunnoh Feb 16, 2026
fa5fe3b
[test] 루트 댓글 조회 API 부하 테스트 스크립트 추가 (#343)
seongjunnoh Feb 16, 2026
8040bde
[chore] gitignore 에 /monitoring 추가 (#343)
seongjunnoh Feb 16, 2026
a660c44
[chore] Dockerfile 수정 (#343)
seongjunnoh Feb 16, 2026
02c1df1
[chore] ci 스크립트 수정 (#343)
seongjunnoh Feb 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .github/workflows/ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,8 @@ $RECYCLE.BIN/
*.msp

# Windows shortcuts
*.lnk
*.lnk

# monitoring files for local development
/monitoring/

12 changes: 8 additions & 4 deletions Dockerfile
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine build.gradle configuration
find . -name "build.gradle" -o -name "build.gradle.kts" | head -20

Repository: THIP-TextHip/THIP-Server

Length of output: 82


🏁 Script executed:

# Also check if there's a Dockerfile and examine it
find . -name "Dockerfile" | head -5

Repository: THIP-TextHip/THIP-Server

Length of output: 80


🏁 Script executed:

# Read build.gradle to check jar and bootJar configuration
cat -n ./build.gradle

Repository: THIP-TextHip/THIP-Server

Length of output: 4547


🏁 Script executed:

# Read Dockerfile to see the COPY statement
cat -n ./Dockerfile

Repository: 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
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` at line 11, The Dockerfile's COPY line "COPY ./build/libs/*.jar
app.jar" can match both the fat executable JAR and the plain JAR, causing
nondeterministic copy and "no main manifest attribute" runtime failures; fix by
either disabling plain JAR generation in the Gradle build via adding a jar {
enabled = false } configuration in build.gradle, or make the Docker COPY
explicit to target the executable JAR (e.g., replace the wildcard COPY
./build/libs/*.jar app.jar with a pattern or explicit name that only matches the
boot jar, e.g., COPY ./build/libs/thip-*.jar app.jar).


# 실행
ENTRYPOINT ["java", "-jar", "app.jar"]
90 changes: 90 additions & 0 deletions loadtest/comment/root_comment_show.js
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
Expand Up @@ -4,12 +4,14 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import konkuk.thip.comment.adapter.in.web.request.CommentCreateRequest;
import konkuk.thip.comment.adapter.in.web.request.RootCommentCreateRequest;
import konkuk.thip.comment.adapter.in.web.request.ChildCommentCreateRequest;
import konkuk.thip.comment.adapter.in.web.request.CommentIsLikeRequest;
import konkuk.thip.comment.adapter.in.web.response.CommentDeleteResponse;
import konkuk.thip.comment.adapter.in.web.response.CommentCreateResponse;
import konkuk.thip.comment.adapter.in.web.response.CommentIsLikeResponse;
import konkuk.thip.comment.application.port.in.CommentCreateUseCase;
import konkuk.thip.comment.application.port.in.RootCommentCreateUseCase;
import konkuk.thip.comment.application.port.in.ChildCommentCreateUseCase;
import konkuk.thip.comment.application.port.in.CommentDeleteUseCase;
import konkuk.thip.comment.application.port.in.CommentLikeUseCase;
import konkuk.thip.common.dto.BaseResponse;
Expand All @@ -25,28 +27,35 @@
@RequiredArgsConstructor
public class CommentCommandController {

private final CommentCreateUseCase commentCreateUseCase;
private final RootCommentCreateUseCase rootCommentCreateUseCase;
private final ChildCommentCreateUseCase childCommentCreateUseCase;
private final CommentLikeUseCase commentLikeUseCase;
private final CommentDeleteUseCase commentDeleteUseCase;

/**
* 댓글/답글 작성
* parentId:{Long},isReplyRequest:true 답글
* parentId:null,isReplyRequest:false 댓글
*/
@Operation(
summary = "댓글 작성",
description = "사용자가 댓글을 작성합니다.\n" +
"답글 작성 시 parentId를 지정하고 isReplyRequest를 true로 설정합니다. " +
"댓글 작성 시 parentId는 null로 설정하고 isReplyRequest를 false로 설정합니다."
summary = "루트 댓글 작성",
description = "특정 게시글에 루트 댓글을 작성합니다."
)
@ExceptionDescription(COMMENT_CREATE)
@PostMapping("/comments/{postId}")
public BaseResponse<CommentCreateResponse> createComment(
@RequestBody @Valid final CommentCreateRequest request,
public BaseResponse<CommentCreateResponse> createRootComment(
@RequestBody @Valid final RootCommentCreateRequest request,
@Parameter(description = "댓글을 작성하려는 게시물 ID", example = "1") @PathVariable("postId") final Long postId,
@Parameter(hidden = true) @UserId final Long userId) {
return BaseResponse.ok(commentCreateUseCase.createComment(request.toCommand(userId,postId)));
return BaseResponse.ok(rootCommentCreateUseCase.createRootComment(request.toCommand(userId, postId)));
}

@Operation(
summary = "답글 작성",
description = "특정 댓글에 답글(자식 댓글)을 작성합니다."
)
@ExceptionDescription(COMMENT_CREATE)
@PostMapping("/comments/replies/{parentCommentId}")
public BaseResponse<CommentCreateResponse> createChildComment(
@RequestBody @Valid final ChildCommentCreateRequest request,
@Parameter(description = "부모 댓글 ID", example = "1") @PathVariable("parentCommentId") final Long parentCommentId,
@Parameter(hidden = true) @UserId final Long userId) {
return BaseResponse.ok(childCommentCreateUseCase.createChildComment(request.toCommand(userId, parentCommentId)));
}

@Operation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

필드 접근 제어자를 private으로 변경하세요

public final로 선언되어 있어 외부에서 직접 접근이 가능합니다. @RequiredArgsConstructorprivate final 필드에 대해서도 생성자를 생성하므로, 캡슐화를 위해 private final로 변경해야 합니다.

🔒 수정 제안
-    public final RootCommentShowUseCase rootCommentShowUseCase;
-    public final ChildCommentShowUseCase childCommentShowUseCase;
+    private final RootCommentShowUseCase rootCommentShowUseCase;
+    private final ChildCommentShowUseCase childCommentShowUseCase;
📝 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 final RootCommentShowUseCase rootCommentShowUseCase;
public final ChildCommentShowUseCase childCommentShowUseCase;
private final RootCommentShowUseCase rootCommentShowUseCase;
private final ChildCommentShowUseCase childCommentShowUseCase;
🤖 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/CommentQueryController.java`
around lines 25 - 26, Change the field access from public final to private final
for rootCommentShowUseCase and childCommentShowUseCase in CommentQueryController
to enforce encapsulation; keep them final so `@RequiredArgsConstructor` still
generates the constructor and no other changes are needed to usages (update any
direct external accesses to use methods if present).


@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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 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.java

Repository: THIP-TextHip/THIP-Server

Length of output: 917


🏁 Script executed:

# Check if ChildCommentDto exists and examine it
find . -name "ChildCommentDto.java" -type f

Repository: 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 -5

Repository: 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 -20

Repository: 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 -5

Repository: 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 -3

Repository: 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/null

Repository: 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


boolean isLast, isLike, isWriter 필드: Jackson에서 is 접두사 제거 문제 확인됨

Spring Boot 3.5.0에 포함된 Jackson 2.17.x는 Java record의 boolean isLast 등의 필드에서 생성된 접근자 메서드(isLast(), isLike(), isWriter())를 JavaBeans boolean getter로 인식합니다. 이로 인해 JSON 직렬화 시 is 접두사가 자동으로 제거되어, API 응답에서 last, like, writer로 반환됩니다.

코드베이스의 다른 응답 클래스들(BaseResponse.java, ErrorResponse.java)은 이미 @JsonProperty("isSuccess")를 명시적으로 사용하여 이 문제를 해결하고 있습니다. ChildCommentsResponse.java도 동일한 패턴을 따라 각 boolean 필드에 @JsonProperty 어노테이션을 추가해야 합니다.

🤖 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 5 - 9, The record ChildCommentsResponse currently exposes a boolean
field isLast which Jackson 2.17.x will serialize as "last"; to fix, annotate the
boolean field with `@JsonProperty`("isLast") in the ChildCommentsResponse
declaration (record ChildCommentsResponse(... boolean isLast ...)) so the JSON
key remains "isLast"; also apply the same fix inside ChildCommentDto for its
boolean fields isLike and isWriter by adding `@JsonProperty`("isLike") and
`@JsonProperty`("isWriter") to those record fields so their JSON names keep the
"is" prefix.

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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Jackson boolean field "is" prefix serialization JSON property name

💡 Result:

Jackson derives JSON property names from JavaBean accessor names, not the raw field name. By default it removes get/set/is prefixes from methods, so a boolean getter isActive() becomes JSON property "active" (not "isActive"). [1]

How to keep the is in JSON (e.g., "isActive")

  1. Explicitly name it

    • Java: @JsonProperty("isActive") on the field or getter (make sure you import com.fasterxml.jackson.annotation.JsonProperty). [2]
    • Kotlin: prefer @get:JsonProperty("isActive") on the property when needed. [3]
  2. Use getIsXxx() instead of isXxx()

    • If your getter is getIsActive(), Jackson will strip get and keep "isActive" as the property name. This also avoids the “boolean field named with is” confusion that can lead to double-properties / mismatches. [2]
  3. Best practice (avoid the issue): don’t name the field with is

    • Use private boolean active; with isActive() → JSON "active". This aligns with JavaBeans conventions and avoids edge cases. [2]

Kotlin-specific “isXxx” edge cases

is-prefixed properties have had known quirks in jackson-module-kotlin (including stripping is and breaking round-trip serialization) tracked in the project issues. [3]

Sources:
[1] Jackson databind Javadoc: default naming removes get/set/is prefixes (PropertyNamingStrategy)
[2] Stack Overflow: @JsonProperty + isEnabled naming confusion / correct import
[3] FasterXML jackson-module-kotlin issue #80 (“is” prefix stripped / serialization-deserialization problems)


🌐 Web query:

Java record Jackson serialization isLike isWriter boolean fields

💡 Result:

Jackson follows JavaBeans rules for boolean getters: an accessor named isXxx() is treated as the JSON property "xxx" (it strips the is prefix). (stackoverflow.com)

With Java records, the accessor method name is the component name, so:

record Reaction(boolean isLike, boolean isWriter) {}

generates isLike() / isWriter(), and Jackson will typically serialize as:

{"like":true,"writer":false}

Fix options

A) Recommended: don’t name boolean components with is

record Reaction(boolean like, boolean writer) {}

(Produces "like" / "writer".)

B) If your JSON must be "isLike" / "isWriter": rename via @JsonProperty

record Reaction(
  `@com.fasterxml.jackson.annotation.JsonProperty`("isLike") boolean like,
  `@com.fasterxml.jackson.annotation.JsonProperty`("isWriter") boolean writer
) {}

C) Config-based (global): stop auto-detecting isXxx() as getters
Disable AUTO_DETECT_IS_GETTERS so Jackson won’t apply the is-getter naming rule unless you explicitly annotate. (cnblogs.com)

Citations:


🌐 Web query:

Jackson MapperFeature USE_STD_BEAN_NAMING boolean field serialization

💡 Result:

MapperFeature.USE_STD_BEAN_NAMING does not change the fact that Jackson strips the is/get prefix when it derives a JSON property name from a getter. For a boolean getter like isActive(), Jackson still treats the logical property name as active (so JSON becomes "active": true), because that’s standard JavaBean property naming.

What USE_STD_BEAN_NAMING does change is the decapitalization rule for the remainder of the name (mainly affecting acronym/leading-caps cases). Jackson’s default historically lowercased leading caps aggressively; standard JavaBeans keeps leading acronyms intact (e.g., getURL()URL, not url). The feature exists to force that standard behavior. [1]

Why this matters for boolean serialization

Example (Kotlin/Java-style naming):

  • Getter isUSDListing():
    • default Jackson naming may produce "usdlisting" (lowercased)
    • with USE_STD_BEAN_NAMING=true, it becomes "USDListing" (standard bean rule) [1][2]

But in both cases, the leading is is still treated as a getter prefix (so you won’t get "isUSDListing" just by enabling this feature).

How to get "isXxx" in JSON

Use an explicit name override, e.g.:

  • @JsonProperty("isActive") on field/getter, or
  • change the accessor to getIsActive() (so the logical bean name becomes isActive)

(Manual override is also the common recommendation when you need a specific casing like aBoolean vs ABoolean.) [3]

Enabling the feature

ObjectMapper mapper = JsonMapper.builder()
    .enable(MapperFeature.USE_STD_BEAN_NAMING)
    .build();

Sources:
[1] Jackson MapperFeature.USE_STD_BEAN_NAMING Javadoc (behavior + URL example) (javadoc.io)
[2] Example showing isUSDListing serialization and workaround via USE_STD_BEAN_NAMING (thecodinganalyst.com)
[3] Explanation of USE_STD_BEAN_NAMING casing impact and using @JsonProperty to force a desired name (stackoverflow.com)

Citations:


boolean isLike, boolean isWriter: Jackson이 is 접두사를 제거해 like, writer로 직렬화될 수 있음

Jackson은 JavaBeans 규칙에 따라 isLike(), isWriter() 접근자 메서드에서 is 접두사를 제거하며, 이로 인해 JSON 프로퍼티명이 like, writer로 직렬화됩니다. Java record에서도 동일하게 동작하여 API 계약이 클라이언트와 불일치할 수 있습니다.

아래 중 하나를 적용해 주세요:

  • @JsonProperty("isLike") / @JsonProperty("isWriter") 어노테이션 명시
  • 필드명을 like, writer (접두사 없이) 로 변경
🛠️ `@JsonProperty` 적용 방법
 public record ChildCommentDto(
         Long commentId,
         String parentCommentCreatorNickname,
         Long creatorId,
         String creatorProfileImageUrl,
         String creatorNickname,
         String aliasName,
         String aliasColor,
         String postDate,
         String content,
         int likeCount,
+        `@JsonProperty`("isLike")
         boolean isLike,
+        `@JsonProperty`("isWriter")
         boolean isWriter
 ) {}
📝 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
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.

) {}
}

Loading