From f7420b58996add2ba273b2cc96fd3533f516355b Mon Sep 17 00:00:00 2001 From: songmingyu Date: Wed, 17 Jun 2026 20:22:13 +0900 Subject: [PATCH 1/9] =?UTF-8?q?fix(answer):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20?= =?UTF-8?q?=ED=9A=8C=EA=B3=A0=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=EC=A0=90=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EC=88=98=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 코드는 답변 제출 시 현재 팀 멤버 수와 DONE 답변 수를 == 비교했는데, 회고 진행 중 멤버가 탈퇴/합류하면 수치가 맞지 않아 AI 분석이 영원히 트리거되지 않는 버그가 있었다. - Retrospect 엔티티에 targetMemberCount 필드 추가 (회고 생성 시점 멤버 수 스냅샷) - 회고 생성 시 현재 스페이스 멤버 수를 targetMemberCount로 저장 - 분석 트리거 조건을 == 에서 >= 로 변경하고 targetMemberCount 기준으로 비교 - null인 기존 데이터는 현재 팀 멤버 수로 폴백 (기존 동작 유지) 배포 전 DB 마이그레이션 필요: ALTER TABLE retrospect ADD COLUMN target_member_count INT NULL; Co-Authored-By: Claude Sonnet 4.6 --- .../org/layer/domain/answer/service/AnswerService.java | 8 ++++++-- .../domain/retrospect/service/RetrospectService.java | 5 +++-- .../main/java/org/layer/domain/answer/entity/Answers.java | 1 + .../org/layer/domain/retrospect/entity/Retrospect.java | 6 +++++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/layer-api/src/main/java/org/layer/domain/answer/service/AnswerService.java b/layer-api/src/main/java/org/layer/domain/answer/service/AnswerService.java index a6dbd71a..3d71c53b 100644 --- a/layer-api/src/main/java/org/layer/domain/answer/service/AnswerService.java +++ b/layer-api/src/main/java/org/layer/domain/answer/service/AnswerService.java @@ -103,8 +103,12 @@ public void create(AnswerListCreateRequest request, Long spaceId, Long retrospec Answers answers = new Answers(answerRepository.findAllByRetrospectId(retrospectId)); - // 마지막 답변인 경우 -> ai 분석 실행 - if (answers.getWriteCount(retrospectId) == team.getTeamMemberCount()) { + // 회고 생성 시점에 스냅샷된 targetMemberCount 기준으로 트리거 (멤버 변동과 무관) + // null인 경우(기존 데이터)는 현재 팀 멤버 수로 폴백 + long target = retrospect.getTargetMemberCount() != null + ? retrospect.getTargetMemberCount() + : team.getTeamMemberCount(); + if (answers.getWriteCount(retrospectId) >= target) { retrospect.completeRetrospectAndStartAnalysis(time.now()); retrospectRepository.save(retrospect); eventPublisher.publishEvent(AIAnalyzeStartEvent.of(retrospectId)); diff --git a/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java b/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java index 81e6e6a8..6adcb96e 100644 --- a/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java +++ b/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java @@ -65,7 +65,7 @@ public Long createRetrospect(RetrospectCreateRequest request, Long spaceId, Long Team team = new Team(memberSpaceRelationRepository.findAllBySpaceId(spaceId)); team.validateTeamMembership(memberId); - Retrospect retrospect = getRetrospect(request, spaceId); + Retrospect retrospect = getRetrospect(request, spaceId, (int) team.getTeamMemberCount()); Retrospect savedRetrospect = retrospectRepository.save(retrospect); List questions = getQuestions(request.questions(), savedRetrospect.getId(), null); @@ -100,7 +100,7 @@ public Long createRetrospect(RetrospectCreateRequest request, Long spaceId, Long return savedRetrospect.getId(); } - private Retrospect getRetrospect(RetrospectCreateRequest request, Long spaceId) { + private Retrospect getRetrospect(RetrospectCreateRequest request, Long spaceId, int targetMemberCount) { return Retrospect.builder() .spaceId(spaceId) .title(request.title()) @@ -108,6 +108,7 @@ private Retrospect getRetrospect(RetrospectCreateRequest request, Long spaceId) .retrospectStatus(RetrospectStatus.PROCEEDING) .analysisStatus(AnalysisStatus.NOT_STARTED) .deadline(request.deadline()) + .targetMemberCount(targetMemberCount) .build(); } diff --git a/layer-domain/src/main/java/org/layer/domain/answer/entity/Answers.java b/layer-domain/src/main/java/org/layer/domain/answer/entity/Answers.java index 15fd0d93..927eb771 100644 --- a/layer-domain/src/main/java/org/layer/domain/answer/entity/Answers.java +++ b/layer-domain/src/main/java/org/layer/domain/answer/entity/Answers.java @@ -87,6 +87,7 @@ public long getWriteCount(Long retrospectId) { return answerMembers.size(); } + public void validateNoAnswer() { if (answers.size() != ZERO) { throw new AnswerException(ALREADY_ANSWERED); diff --git a/layer-domain/src/main/java/org/layer/domain/retrospect/entity/Retrospect.java b/layer-domain/src/main/java/org/layer/domain/retrospect/entity/Retrospect.java index ba7f7972..ed9038e9 100644 --- a/layer-domain/src/main/java/org/layer/domain/retrospect/entity/Retrospect.java +++ b/layer-domain/src/main/java/org/layer/domain/retrospect/entity/Retrospect.java @@ -47,15 +47,19 @@ public class Retrospect extends BaseTimeEntity { private LocalDateTime deadline; + // 회고 생성 시점의 스페이스 멤버 수 스냅샷 (이후 멤버 변동과 무관하게 분석 트리거 기준으로 사용) + private Integer targetMemberCount; + @Builder public Retrospect(Long spaceId, String title, String introduction, RetrospectStatus retrospectStatus, - AnalysisStatus analysisStatus, LocalDateTime deadline) { + AnalysisStatus analysisStatus, LocalDateTime deadline, Integer targetMemberCount) { this.spaceId = spaceId; this.title = title; this.introduction = introduction; this.retrospectStatus = retrospectStatus; this.analysisStatus = analysisStatus; this.deadline = deadline; + this.targetMemberCount = targetMemberCount; } public boolean isRetrospectProceeding() { From c978d32072676af7bb27288b722d4c86c3fc6de9 Mon Sep 17 00:00:00 2001 From: songmingyu Date: Wed, 17 Jun 2026 20:34:23 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix(retrospect):=20totalCount=EC=97=90=20ta?= =?UTF-8?q?rgetMemberCount=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROCEEDING 상태 회고의 totalCount를 현재 팀원 수 대신 생성 시점에 스냅샷된 targetMemberCount 기준으로 표시. null인 경우(기존 데이터)는 현재 팀원 수로 폴백. Co-Authored-By: Claude Sonnet 4.6 --- .../domain/retrospect/service/RetrospectService.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java b/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java index 6adcb96e..0f56666f 100644 --- a/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java +++ b/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java @@ -136,7 +136,9 @@ public RetrospectListGetResponse getRetrospects(Long spaceId, Long memberId) { List retrospectDtos = retrospects.stream() .map(r -> { - long totalCount = team.getTeamMemberCount(); + long totalCount = r.getTargetMemberCount() != null + ? r.getTargetMemberCount() + : team.getTeamMemberCount(); if (r.getRetrospectStatus().equals(RetrospectStatus.DONE)) { // 회고가 종료된 경우, 해당 회고의 deadline 시점의 팀원 수를 totalCount로 설정한다. // RetrospectStatus 가 DONE 으로 변경되면, deadline이 null 값이 될 수 없기 때문이다. @@ -166,14 +168,16 @@ public RetrospectListGetResponse getAllRetrospects(Long memberId) { List retrospectDtos = retrospects.stream() .map(r -> { - long writeCount = spaceMemberCountMap.get(r.getSpaceId()); + long totalCount = r.getTargetMemberCount() != null + ? r.getTargetMemberCount() + : spaceMemberCountMap.get(r.getSpaceId()); if (r.getRetrospectStatus().equals(RetrospectStatus.DONE)) { - writeCount = answers.getWriteCount(r.getId()); + totalCount = answers.getWriteCount(r.getId()); } return RetrospectGetResponse.of(r.getSpaceId(), r.getId(), r.getTitle(), r.getIntroduction(), answers.getWriteStatus(memberId, r.getId()), r.getRetrospectStatus(), r.getAnalysisStatus(), - answers.getWriteCount(r.getId()), writeCount, r.getCreatedAt(), r.getDeadline()); + answers.getWriteCount(r.getId()), totalCount, r.getCreatedAt(), r.getDeadline()); }) .toList(); From 8989f9afa7740f6749167989cfca8cdd7a72df87 Mon Sep 17 00:00:00 2001 From: songmingyu Date: Wed, 17 Jun 2026 21:00:34 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(analyze):=20=EA=B0=9C=EC=9D=B8=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=EB=B3=84=20PERSONAL=20=EC=8B=A4=ED=96=89=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 팀 분석 후 팀 실행목표(TEAM)를 생성하는 것과 동일하게, 개인 분석 후 각 멤버의 IMPROVEMENT 항목을 PERSONAL 타입 실행목표로 자동 저장하도록 추가. Co-Authored-By: Claude Sonnet 4.6 --- .../layer/ai/service/AIAnalyzeService.java | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java b/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java index 61feb43c..bb289a5a 100644 --- a/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java +++ b/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java @@ -8,6 +8,7 @@ import java.util.UUID; import org.layer.domain.actionItem.entity.ActionItem; +import org.layer.domain.actionItem.entity.ActionItemType; import org.layer.domain.actionItem.repository.ActionItemRepository; import org.layer.domain.analyze.entity.Analyze; import org.layer.domain.analyze.entity.AnalyzeDetail; @@ -104,22 +105,30 @@ public void createAnalyze(Long retrospectId) { List actionItems = createActionItemsFromAnalyzeDetails( teamAnalyze.getAnalyzeDetailsBy(AnalyzeDetailType.IMPROVEMENT), retrospect.getSpaceId(), - retrospect.getId(), space.getLeaderId()); + retrospect.getId(), space.getLeaderId(), ActionItemType.TEAM); actionItemRepository.saveAll(actionItems); - // 팀원 개인마다의 분석 요청 + // 팀원 개인마다의 분석 요청 및 개인 실행 목표 생성 Team team = new Team(memberSpaceRelationRepository.findAllBySpaceId(retrospect.getSpaceId())); - List individualAnalyzes = team.getMemberIds().stream() - .map(memberId -> { - String individualAnswer = answers.getIndividualAnswer(rangeQuestionId, numberQuestionId, memberId); - OpenAIResponse aiIndividualResponse = openAIService.createAnalyze(individualAnswer); - OpenAIResponse.Content individualContent = aiIndividualResponse.parseContent(); - return getAnalyzeEntity(retrospectId, answers, rangeQuestionId, numberQuestionId, individualContent, - memberId, AnalyzeType.INDIVIDUAL); - }) - .toList(); + List individualAnalyzes = new ArrayList<>(); + List personalActionItems = new ArrayList<>(); + + for (Long memberId : team.getMemberIds()) { + String individualAnswer = answers.getIndividualAnswer(rangeQuestionId, numberQuestionId, memberId); + OpenAIResponse aiIndividualResponse = openAIService.createAnalyze(individualAnswer); + OpenAIResponse.Content individualContent = aiIndividualResponse.parseContent(); + Analyze individualAnalyze = getAnalyzeEntity(retrospectId, answers, rangeQuestionId, numberQuestionId, + individualContent, memberId, AnalyzeType.INDIVIDUAL); + individualAnalyzes.add(individualAnalyze); + + personalActionItems.addAll(createActionItemsFromAnalyzeDetails( + individualAnalyze.getAnalyzeDetailsBy(AnalyzeDetailType.IMPROVEMENT), + retrospect.getSpaceId(), retrospect.getId(), memberId, ActionItemType.PERSONAL)); + } + analyzeRepository.saveAll(individualAnalyzes); + actionItemRepository.saveAll(personalActionItems); long endTime = System.currentTimeMillis(); log.info("createAnalyze completed in {} ms", (endTime - startTime)); @@ -154,7 +163,7 @@ private Analyze getAnalyzeEntity(Long retrospectId, Answers answers, Long rangeQ } private List createActionItemsFromAnalyzeDetails(List analyzeDetails, Long spaceId, - Long retrospectId, Long memberId) { + Long retrospectId, Long memberId, ActionItemType type) { List actionItems = new ArrayList<>(); int order = 1; for (AnalyzeDetail detail : analyzeDetails) { @@ -164,6 +173,7 @@ private List createActionItemsFromAnalyzeDetails(List .memberId(memberId) .content(detail.getContent()) .actionItemOrder(order) + .type(type) .build(); actionItems.add(actionItem); order++; From 5d648d0a6d57d2d792174c3e6958ccc6b587f8dd Mon Sep 17 00:00:00 2001 From: songmingyu Date: Wed, 17 Jun 2026 21:07:04 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix(retrospect):=20targetMemberCount?= =?UTF-8?q?=EB=A5=BC=20DONE=20=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8F=84=20=EC=9A=B0=EC=84=A0=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DONE 상태 회고에서 getTeamMemberCountBefore(deadline)가 멤버 가입일 기준으로 필터링해 0이 되는 문제 수정. targetMemberCount가 있으면 상태와 무관하게 항상 우선 사용하고, null인 기존 데이터만 이전 로직으로 폴백. Co-Authored-By: Claude Sonnet 4.6 --- .../retrospect/service/RetrospectService.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java b/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java index 0f56666f..4ff72d70 100644 --- a/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java +++ b/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java @@ -136,13 +136,14 @@ public RetrospectListGetResponse getRetrospects(Long spaceId, Long memberId) { List retrospectDtos = retrospects.stream() .map(r -> { - long totalCount = r.getTargetMemberCount() != null - ? r.getTargetMemberCount() - : team.getTeamMemberCount(); - if (r.getRetrospectStatus().equals(RetrospectStatus.DONE)) { - // 회고가 종료된 경우, 해당 회고의 deadline 시점의 팀원 수를 totalCount로 설정한다. - // RetrospectStatus 가 DONE 으로 변경되면, deadline이 null 값이 될 수 없기 때문이다. + long totalCount; + if (r.getTargetMemberCount() != null) { + totalCount = r.getTargetMemberCount(); + } else if (r.getRetrospectStatus().equals(RetrospectStatus.DONE)) { + // targetMemberCount가 없는 기존 데이터: deadline 시점 팀원 수로 폴백 totalCount = team.getTeamMemberCountBefore(r.getDeadline()); + } else { + totalCount = team.getTeamMemberCount(); } return RetrospectGetResponse.of(r.getSpaceId(), r.getId(), r.getTitle(), r.getIntroduction(), @@ -171,9 +172,6 @@ public RetrospectListGetResponse getAllRetrospects(Long memberId) { long totalCount = r.getTargetMemberCount() != null ? r.getTargetMemberCount() : spaceMemberCountMap.get(r.getSpaceId()); - if (r.getRetrospectStatus().equals(RetrospectStatus.DONE)) { - totalCount = answers.getWriteCount(r.getId()); - } return RetrospectGetResponse.of(r.getSpaceId(), r.getId(), r.getTitle(), r.getIntroduction(), answers.getWriteStatus(memberId, r.getId()), r.getRetrospectStatus(), r.getAnalysisStatus(), From 463bc63dec9c683da1f312881e230f8d2d9cc0d5 Mon Sep 17 00:00:00 2001 From: songmingyu Date: Wed, 17 Jun 2026 23:05:25 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix(analyze):=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=ED=9A=8C=EA=B3=A0=20=EB=A7=88=EA=B0=90=20?= =?UTF-8?q?=EC=8B=9C=20AI=20=EB=B6=84=EC=84=9D=20=EA=B1=B4=EB=84=88?= =?UTF-8?q?=EB=9C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변이 0개인 상태에서 분석을 요청하면 AI 할루시네이션이 발생하는 문제 수정. DONE 답변이 없으면 OpenAI 호출 없이 analysisStatus만 DONE으로 변경. Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/org/layer/ai/service/AIAnalyzeService.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java b/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java index bb289a5a..dd5b96fc 100644 --- a/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java +++ b/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java @@ -86,6 +86,14 @@ public void createAnalyze(Long retrospectId) { Answers answers = new Answers( answerRepository.findAllByRetrospectIdAndAnswerStatus(retrospectId, AnswerStatus.DONE)); + // 답변이 없으면 할루시네이션 방지를 위해 분석 없이 종료 + if (answers.getAnswers().isEmpty()) { + log.info("No answers found for retrospectId: {}. Skipping AI analysis.", retrospectId); + retrospect.updateAnalysisStatus(AnalysisStatus.DONE); + retrospectRepository.save(retrospect); + return; + } + Long rangeQuestionId = questions.extractEssentialQuestionIdBy(QuestionType.RANGER); Long numberQuestionId = questions.extractEssentialQuestionIdBy(QuestionType.NUMBER); String totalAnswer = answers.getTotalAnswer(rangeQuestionId, numberQuestionId); From 11109049c332c4ca7a76596ebc71f16e28affa32 Mon Sep 17 00:00:00 2001 From: songmingyu Date: Wed, 17 Jun 2026 23:07:29 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix(retrospect):=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=ED=9A=8C=EA=B3=A0=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EB=A7=88=EA=B0=90=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeRetrospect 호출 시 DONE 답변이 하나도 없으면 400 에러를 반환하여 할루시네이션 방지. RetrospectExceptionType에 NO_ANSWERS_TO_CLOSE 추가. Co-Authored-By: Claude Sonnet 4.6 --- .../domain/retrospect/service/RetrospectService.java | 10 ++++++++++ .../global/exception/RetrospectExceptionType.java | 3 ++- .../java/org/layer/ai/service/AIAnalyzeService.java | 8 -------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java b/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java index 4ff72d70..85ef63fb 100644 --- a/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java +++ b/layer-api/src/main/java/org/layer/domain/retrospect/service/RetrospectService.java @@ -1,5 +1,7 @@ package org.layer.domain.retrospect.service; +import static org.layer.global.exception.RetrospectExceptionType.NO_ANSWERS_TO_CLOSE; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -7,7 +9,9 @@ import org.layer.domain.space.entity.MemberSpaceRelation; import org.layer.event.ai.AIAnalyzeStartEvent; import org.layer.domain.answer.entity.Answers; +import org.layer.domain.answer.enums.AnswerStatus; import org.layer.domain.answer.repository.AnswerRepository; +import org.layer.domain.retrospect.exception.RetrospectException; import org.layer.domain.common.random.CustomRandom; import org.layer.domain.common.time.Time; import org.layer.domain.form.entity.Form; @@ -233,6 +237,12 @@ public void closeRetrospect(Long spaceId, Long retrospectId, Long memberId) { Retrospect retrospect = retrospectRepository.findByIdOrThrow(retrospectId); + // 작성된 답변이 없으면 마감 불가 + Answers answers = new Answers(answerRepository.findAllByRetrospectIdAndAnswerStatus(retrospectId, AnswerStatus.DONE)); + if (answers.getAnswers().isEmpty()) { + throw new RetrospectException(NO_ANSWERS_TO_CLOSE); + } + retrospect.completeRetrospectAndStartAnalysis(time.now()); if (retrospect.getAnalysisStatus().equals(AnalysisStatus.DONE)) { log.error("비정상적인 오류입니다."); diff --git a/layer-domain/src/main/java/org/layer/global/exception/RetrospectExceptionType.java b/layer-domain/src/main/java/org/layer/global/exception/RetrospectExceptionType.java index 5efb24d5..f92c6254 100644 --- a/layer-domain/src/main/java/org/layer/global/exception/RetrospectExceptionType.java +++ b/layer-domain/src/main/java/org/layer/global/exception/RetrospectExceptionType.java @@ -11,7 +11,8 @@ public enum RetrospectExceptionType implements ExceptionType { DEADLINE_PASSED(HttpStatus.BAD_REQUEST, "회고 마감기한이 지났습니다."), ALREADY_ANALYSIS_DONE(HttpStatus.BAD_REQUEST, "회고 분석을 이미 마쳤습니다."), NOT_PROCEEDING_RETROSPECT(HttpStatus.BAD_REQUEST, "진행중인 회고가 아닙니다."), - NOT_FOUND_RETROSPECT(HttpStatus.NOT_FOUND, "유효한 회고가 존재하지 않습니다."); + NOT_FOUND_RETROSPECT(HttpStatus.NOT_FOUND, "유효한 회고가 존재하지 않습니다."), + NO_ANSWERS_TO_CLOSE(HttpStatus.BAD_REQUEST, "작성된 답변이 없어 회고를 마감할 수 없습니다."); private final HttpStatus status; private final String message; diff --git a/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java b/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java index dd5b96fc..bb289a5a 100644 --- a/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java +++ b/layer-external/src/main/java/org/layer/ai/service/AIAnalyzeService.java @@ -86,14 +86,6 @@ public void createAnalyze(Long retrospectId) { Answers answers = new Answers( answerRepository.findAllByRetrospectIdAndAnswerStatus(retrospectId, AnswerStatus.DONE)); - // 답변이 없으면 할루시네이션 방지를 위해 분석 없이 종료 - if (answers.getAnswers().isEmpty()) { - log.info("No answers found for retrospectId: {}. Skipping AI analysis.", retrospectId); - retrospect.updateAnalysisStatus(AnalysisStatus.DONE); - retrospectRepository.save(retrospect); - return; - } - Long rangeQuestionId = questions.extractEssentialQuestionIdBy(QuestionType.RANGER); Long numberQuestionId = questions.extractEssentialQuestionIdBy(QuestionType.NUMBER); String totalAnswer = answers.getTotalAnswer(rangeQuestionId, numberQuestionId); From d63b7481212af0f853e56a920153a88b32a7530b Mon Sep 17 00:00:00 2001 From: songmingyu Date: Wed, 17 Jun 2026 23:09:25 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(scheduler):=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=ED=9A=8C=EA=B3=A0=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=A7=88=EA=B0=90=20=EC=8B=9C=20AI=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deadline 초과로 자동 마감되는 회고에 DONE 답변이 없으면 AIAnalyzeStartEvent를 발행하지 않고 analysisStatus를 DONE으로 처리. AnswerRepository에 existsByRetrospectIdAndAnswerStatus 추가. Co-Authored-By: Claude Sonnet 4.6 --- .../batch/retrospect/RetrospectScheduler.java | 22 +++++++++++++++---- .../answer/repository/AnswerRepository.java | 2 ++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/layer-batch/src/main/java/org/layer/batch/retrospect/RetrospectScheduler.java b/layer-batch/src/main/java/org/layer/batch/retrospect/RetrospectScheduler.java index 6c466e9d..274aebb7 100644 --- a/layer-batch/src/main/java/org/layer/batch/retrospect/RetrospectScheduler.java +++ b/layer-batch/src/main/java/org/layer/batch/retrospect/RetrospectScheduler.java @@ -3,7 +3,10 @@ import java.time.LocalDateTime; import java.util.List; +import org.layer.domain.answer.enums.AnswerStatus; +import org.layer.domain.answer.repository.AnswerRepository; import org.layer.domain.common.time.Time; +import org.layer.domain.retrospect.entity.AnalysisStatus; import org.layer.domain.retrospect.entity.Retrospect; import org.layer.domain.retrospect.entity.RetrospectStatus; import org.layer.domain.retrospect.repository.RetrospectRepository; @@ -22,6 +25,7 @@ public class RetrospectScheduler { private final RetrospectRepository retrospectRepository; + private final AnswerRepository answerRepository; private final ApplicationEventPublisher eventPublisher; private final Time time; @@ -49,8 +53,18 @@ public void updateRetrospectAndPublishEvent(List retrospects) { retrospects.forEach(retrospect -> retrospect.completeRetrospectAndStartAnalysis(time.now())); retrospectRepository.saveAll(retrospects); - retrospects.forEach(retrospect -> - eventPublisher.publishEvent(AIAnalyzeStartEvent.of(retrospect.getId())) - ); + retrospects.forEach(retrospect -> { + boolean hasAnswers = answerRepository.existsByRetrospectIdAndAnswerStatus( + retrospect.getId(), AnswerStatus.DONE); + + if (!hasAnswers) { + log.info("No answers for retrospectId: {}. Skipping AI analysis.", retrospect.getId()); + retrospect.updateAnalysisStatus(AnalysisStatus.DONE); + retrospectRepository.save(retrospect); + return; + } + + eventPublisher.publishEvent(AIAnalyzeStartEvent.of(retrospect.getId())); + }); } -} \ No newline at end of file +} diff --git a/layer-domain/src/main/java/org/layer/domain/answer/repository/AnswerRepository.java b/layer-domain/src/main/java/org/layer/domain/answer/repository/AnswerRepository.java index 4c2bcf7d..573906e8 100644 --- a/layer-domain/src/main/java/org/layer/domain/answer/repository/AnswerRepository.java +++ b/layer-domain/src/main/java/org/layer/domain/answer/repository/AnswerRepository.java @@ -19,6 +19,8 @@ List findByRetrospectIdAndMemberIdAndAnswerStatusAndQuestionIdIn(Long re List findAllByRetrospectIdAndAnswerStatus(Long retrospectId, AnswerStatus answerStatus); + boolean existsByRetrospectIdAndAnswerStatus(Long retrospectId, AnswerStatus answerStatus); + List findAllByRetrospectIdAndMemberIdAndAnswerStatus(Long retrospectId, Long memberId, AnswerStatus answerStatus); From 66daae6a8f2afb41d049a2327eb08d8200978f17 Mon Sep 17 00:00:00 2001 From: songmingyu Date: Tue, 23 Jun 2026 19:27:26 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat(reaction):=20=EC=9D=B4=EB=AA=A8?= =?UTF-8?q?=EC=A7=80=20=EC=BD=94=EB=93=9C(LEC01~LEC10)=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=B0=98=EC=9D=91=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imgUrl DB 저장 방식 대신 EmojiCode enum(LEC01~LEC10)으로 서버-클라이언트 간 소통하고, 이미지 리소스는 클라이언트가 관리. 요청 시 emojiCode(String) 전달, 응답에 emojiCode + description 포함. LEC01 대단해 / LEC02 완벽해 / LEC03 최고야 / LEC04 역시 LEC05 고생했어 / LEC06 기대중 / LEC07 괜찮아 / LEC08 성장했다 LEC09 화이팅 / LEC10 할 수 있다 Co-Authored-By: Claude Sonnet 4.6 --- .../RetrospectReactionCreateRequest.java | 7 ++++--- .../dto/response/ReactionGetResponse.java | 13 +++++++++--- .../RetrospectReactionElementResponse.java | 18 ++++++++++++---- .../service/RetrospectReactionService.java | 11 ++++++++-- .../domain/reaction/entity/EmojiCode.java | 21 +++++++++++++++++++ .../domain/reaction/entity/Reaction.java | 9 +++++--- .../repository/ReactionRepository.java | 9 ++++++++ 7 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 layer-domain/src/main/java/org/layer/domain/reaction/entity/EmojiCode.java diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/request/RetrospectReactionCreateRequest.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/request/RetrospectReactionCreateRequest.java index 8a4ddb1e..272b8294 100644 --- a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/request/RetrospectReactionCreateRequest.java +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/request/RetrospectReactionCreateRequest.java @@ -1,13 +1,14 @@ package org.layer.domain.reaction.controller.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @Schema(name = "RetrospectReactionCreateRequest", description = "회고 반응 생성 요청 DTO") public record RetrospectReactionCreateRequest( - @NotNull - @Schema(description = "반응 ID", example = "1") - Long reactionId, + @NotBlank + @Schema(description = "이모지 코드", example = "LEC01") + String emojiCode, @NotNull @Schema(description = "답변 ID", example = "1") diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionGetResponse.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionGetResponse.java index 8ac02f3a..aed9d44e 100644 --- a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionGetResponse.java +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionGetResponse.java @@ -8,10 +8,17 @@ public record ReactionGetResponse( @Schema(description = "반응 ID", example = "1") Long id, - @Schema(description = "반응 이미지 URL", example = "https://example.com/emoji.png") - String imgUrl + @Schema(description = "이모지 코드", example = "LEC01") + String emojiCode, + + @Schema(description = "이모지 설명", example = "대단해") + String description ) { public static ReactionGetResponse from(Reaction reaction) { - return new ReactionGetResponse(reaction.getId(), reaction.getImgUrl()); + return new ReactionGetResponse( + reaction.getId(), + reaction.getEmojiCode().name(), + reaction.getEmojiCode().getDescription() + ); } } diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionElementResponse.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionElementResponse.java index b7398846..8ce6ca3e 100644 --- a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionElementResponse.java +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionElementResponse.java @@ -1,6 +1,7 @@ package org.layer.domain.reaction.controller.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import org.layer.domain.reaction.entity.EmojiCode; import org.layer.domain.reaction.entity.RetrospectReaction; @Schema(name = "RetrospectReactionElementResponse", description = "회고 반응 요소 응답 DTO") @@ -8,8 +9,11 @@ public record RetrospectReactionElementResponse( @Schema(description = "회고 반응 ID", example = "1") Long retrospectReactionId, - @Schema(description = "반응 ID", example = "2") - Long reactionId, + @Schema(description = "이모지 코드", example = "LEC01") + String emojiCode, + + @Schema(description = "이모지 설명", example = "대단해") + String description, @Schema(description = "반응한 멤버 ID", example = "3") Long memberId, @@ -20,10 +24,16 @@ public record RetrospectReactionElementResponse( @Schema(description = "반응한 멤버 프로필 이미지 URL", example = "https://example.com/profile.png") String memberProfileImgUrl ) { - public static RetrospectReactionElementResponse of(RetrospectReaction retrospectReaction, String memberName, String memberProfileImgUrl) { + public static RetrospectReactionElementResponse of( + RetrospectReaction retrospectReaction, + EmojiCode emojiCode, + String memberName, + String memberProfileImgUrl + ) { return new RetrospectReactionElementResponse( retrospectReaction.getId(), - retrospectReaction.getReactionId(), + emojiCode.name(), + emojiCode.getDescription(), retrospectReaction.getMemberId(), memberName, memberProfileImgUrl diff --git a/layer-api/src/main/java/org/layer/domain/reaction/service/RetrospectReactionService.java b/layer-api/src/main/java/org/layer/domain/reaction/service/RetrospectReactionService.java index 7cd381c9..a2c514e3 100644 --- a/layer-api/src/main/java/org/layer/domain/reaction/service/RetrospectReactionService.java +++ b/layer-api/src/main/java/org/layer/domain/reaction/service/RetrospectReactionService.java @@ -9,6 +9,7 @@ import org.layer.domain.reaction.controller.dto.response.ReactionListGetResponse; import org.layer.domain.reaction.controller.dto.response.RetrospectReactionElementResponse; import org.layer.domain.reaction.controller.dto.response.RetrospectReactionListGetResponse; +import org.layer.domain.reaction.entity.EmojiCode; import org.layer.domain.reaction.entity.Reaction; import org.layer.domain.reaction.entity.RetrospectReaction; import org.layer.domain.reaction.exception.ReactionException; @@ -48,14 +49,15 @@ public Long createReaction(Long spaceId, Long retrospectId, RetrospectReactionCr retrospectRepository.findByIdOrThrow(retrospectId); - reactionRepository.findByIdOrThrow(request.reactionId()); + EmojiCode emojiCode = EmojiCode.valueOf(request.emojiCode()); + Reaction reaction = reactionRepository.findByEmojiCodeOrThrow(emojiCode); if (retrospectReactionRepository.existsByAnswerIdAndMemberId(request.answerId(), memberId)) { throw new ReactionException(ALREADY_REACTED); } RetrospectReaction retrospectReaction = RetrospectReaction.builder() - .reactionId(request.reactionId()) + .reactionId(reaction.getId()) .answerId(request.answerId()) .memberId(memberId) .build(); @@ -92,6 +94,10 @@ public RetrospectReactionListGetResponse getRetrospectReactions(Long spaceId, Lo List retrospectReactions = retrospectReactionRepository.findAllByAnswerIdIn(answerIds); + List reactionIds = retrospectReactions.stream().map(RetrospectReaction::getReactionId).distinct().toList(); + Map reactionEmojiMap = reactionRepository.findAllById(reactionIds).stream() + .collect(Collectors.toMap(Reaction::getId, Reaction::getEmojiCode)); + List memberIds = retrospectReactions.stream().map(RetrospectReaction::getMemberId).distinct().toList(); Members members = new Members(memberRepository.findAllById(memberIds)); @@ -105,6 +111,7 @@ public RetrospectReactionListGetResponse getRetrospectReactions(Long spaceId, Lo .stream() .map(r -> RetrospectReactionElementResponse.of( r, + reactionEmojiMap.get(r.getReactionId()), members.getName(r.getMemberId()), members.getProfileImageUrl(r.getMemberId()) )) diff --git a/layer-domain/src/main/java/org/layer/domain/reaction/entity/EmojiCode.java b/layer-domain/src/main/java/org/layer/domain/reaction/entity/EmojiCode.java new file mode 100644 index 00000000..3d8cbbb3 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/reaction/entity/EmojiCode.java @@ -0,0 +1,21 @@ +package org.layer.domain.reaction.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum EmojiCode { + LEC01("대단해"), + LEC02("완벽해"), + LEC03("최고야"), + LEC04("역시"), + LEC05("고생했어"), + LEC06("기대중"), + LEC07("괜찮아"), + LEC08("성장했다"), + LEC09("화이팅"), + LEC10("할 수 있다"); + + private final String description; +} diff --git a/layer-domain/src/main/java/org/layer/domain/reaction/entity/Reaction.java b/layer-domain/src/main/java/org/layer/domain/reaction/entity/Reaction.java index 0b7f4e6c..d87ae6df 100644 --- a/layer-domain/src/main/java/org/layer/domain/reaction/entity/Reaction.java +++ b/layer-domain/src/main/java/org/layer/domain/reaction/entity/Reaction.java @@ -1,6 +1,8 @@ package org.layer.domain.reaction.entity; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -21,10 +23,11 @@ public class Reaction extends BaseTimeEntity { private Long id; @NotNull - private String imgUrl; + @Enumerated(EnumType.STRING) + private EmojiCode emojiCode; @Builder - public Reaction(String imgUrl) { - this.imgUrl = imgUrl; + public Reaction(EmojiCode emojiCode) { + this.emojiCode = emojiCode; } } diff --git a/layer-domain/src/main/java/org/layer/domain/reaction/repository/ReactionRepository.java b/layer-domain/src/main/java/org/layer/domain/reaction/repository/ReactionRepository.java index 8272c109..be2c45d1 100644 --- a/layer-domain/src/main/java/org/layer/domain/reaction/repository/ReactionRepository.java +++ b/layer-domain/src/main/java/org/layer/domain/reaction/repository/ReactionRepository.java @@ -1,9 +1,12 @@ package org.layer.domain.reaction.repository; +import org.layer.domain.reaction.entity.EmojiCode; import org.layer.domain.reaction.entity.Reaction; import org.layer.domain.reaction.exception.ReactionException; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + import static org.layer.global.exception.ReactionExceptionType.NOT_FOUND_REACTION; public interface ReactionRepository extends JpaRepository { @@ -11,4 +14,10 @@ public interface ReactionRepository extends JpaRepository { default Reaction findByIdOrThrow(Long id) { return findById(id).orElseThrow(() -> new ReactionException(NOT_FOUND_REACTION)); } + + Optional findByEmojiCode(EmojiCode emojiCode); + + default Reaction findByEmojiCodeOrThrow(EmojiCode emojiCode) { + return findByEmojiCode(emojiCode).orElseThrow(() -> new ReactionException(NOT_FOUND_REACTION)); + } } From cc4a3c27f233900b7d4af6c44d7110add5c1b64a Mon Sep 17 00:00:00 2001 From: songmingyu Date: Tue, 23 Jun 2026 19:30:18 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs(reaction):=20=EC=9D=B4=EB=AA=A8?= =?UTF-8?q?=EC=A7=80=20=EC=BD=94=EB=93=9C=20=EA=B8=B0=EB=B0=98=20API=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=EC=9C=BC=EB=A1=9C=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imgUrl → emojiCode/description 변경 반영. 이모지 코드 목록(LEC01~LEC10) 및 변경된 요청/응답 스펙 업데이트. Co-Authored-By: Claude Sonnet 4.6 --- docs/reaction-api.md | 59 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/docs/reaction-api.md b/docs/reaction-api.md index 5522ac44..222dd59a 100644 --- a/docs/reaction-api.md +++ b/docs/reaction-api.md @@ -7,6 +7,25 @@ --- +## 이모지 코드 목록 + +서버-클라이언트는 아래 코드 값으로 소통하며, **이미지 리소스는 클라이언트에서 관리**합니다. + +| 코드 | 설명 | +|---|---| +| `LEC01` | 대단해 | +| `LEC02` | 완벽해 | +| `LEC03` | 최고야 | +| `LEC04` | 역시 | +| `LEC05` | 고생했어 | +| `LEC06` | 기대중 | +| `LEC07` | 괜찮아 | +| `LEC08` | 성장했다 | +| `LEC09` | 화이팅 | +| `LEC10` | 할 수 있다 | + +--- + ## 목차 1. [사용 가능한 모든 반응 조회](#1-사용-가능한-모든-반응-조회) @@ -32,11 +51,13 @@ GET /api/reaction "reactions": [ { "id": 1, - "imgUrl": "https://example.com/reaction/thumbs-up.png" + "emojiCode": "LEC01", + "description": "대단해" }, { "id": 2, - "imgUrl": "https://example.com/reaction/heart.png" + "emojiCode": "LEC02", + "description": "완벽해" } ] } @@ -46,7 +67,8 @@ GET /api/reaction |---|---|---| | `reactions` | `array` | 반응 목록 | | `reactions[].id` | `number` | 반응 ID | -| `reactions[].imgUrl` | `string` | 반응 이미지 URL | +| `reactions[].emojiCode` | `string` | 이모지 코드 (예: `LEC01`) | +| `reactions[].description` | `string` | 이모지 설명 (예: `대단해`) | --- @@ -78,11 +100,13 @@ GET /api/reaction/recent?limit=8 "reactions": [ { "id": 3, - "imgUrl": "https://example.com/reaction/fire.png" + "emojiCode": "LEC03", + "description": "최고야" }, { "id": 1, - "imgUrl": "https://example.com/reaction/thumbs-up.png" + "emojiCode": "LEC01", + "description": "대단해" } ] } @@ -112,14 +136,14 @@ POST /space/{spaceId}/retrospect/{retrospectId}/reaction ```json { - "reactionId": 1, + "emojiCode": "LEC01", "answerId": 5 } ``` | 필드 | 타입 | 필수 | 설명 | |---|---|---|---| -| `reactionId` | `number` | Y | 사용할 반응 ID (반응 목록 조회 API에서 가져온 값) | +| `emojiCode` | `string` | Y | 사용할 이모지 코드 (예: `LEC01`) | | `answerId` | `number` | Y | 반응을 달 답변 ID | ### Response @@ -129,7 +153,7 @@ POST /space/{spaceId}/retrospect/{retrospectId}/reaction | `201` | 반응 생성 성공 | | `400` | 이미 해당 답변에 반응을 달았음 | | `403` | 해당 스페이스 멤버가 아님 | -| `404` | 존재하지 않는 반응 ID | +| `404` | 존재하지 않는 이모지 코드 | --- @@ -185,13 +209,19 @@ GET /space/{spaceId}/retrospect/{retrospectId}/reaction "reactions": [ { "retrospectReactionId": 1, - "reactionId": 2, - "memberId": 29 + "emojiCode": "LEC01", + "description": "대단해", + "memberId": 29, + "memberName": "홍길동", + "memberProfileImgUrl": "https://example.com/profile.png" }, { "retrospectReactionId": 2, - "reactionId": 5, - "memberId": 31 + "emojiCode": "LEC05", + "description": "고생했어", + "memberId": 31, + "memberName": "김철수", + "memberProfileImgUrl": "https://example.com/profile2.png" } ] }, @@ -209,8 +239,11 @@ GET /space/{spaceId}/retrospect/{retrospectId}/reaction | `answerReactions[].answerId` | `number` | 답변 ID | | `answerReactions[].reactions` | `array` | 해당 답변에 달린 반응 목록 (없으면 빈 배열) | | `reactions[].retrospectReactionId` | `number` | 회고 반응 ID — **삭제 시 이 값을 사용** | -| `reactions[].reactionId` | `number` | 어떤 반응인지 (imgUrl 매핑에 사용) | +| `reactions[].emojiCode` | `string` | 이모지 코드 (예: `LEC01`) | +| `reactions[].description` | `string` | 이모지 설명 (예: `대단해`) | | `reactions[].memberId` | `number` | 반응을 단 멤버 ID — **내 반응 여부 판단에 사용** | +| `reactions[].memberName` | `string` | 반응을 단 멤버 이름 | +| `reactions[].memberProfileImgUrl` | `string` | 반응을 단 멤버 프로필 이미지 URL | ---