diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineAsyncService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineAsyncService.java new file mode 100644 index 00000000..f006c4c2 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineAsyncService.java @@ -0,0 +1,5 @@ +package com.whereyouad.WhereYouAd.domains.timeline.domain.service; + +public interface TimelineAsyncService { + void summarizeAsync(Long timelineId, Long orgId); +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineAsyncServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineAsyncServiceImpl.java new file mode 100644 index 00000000..e692c809 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineAsyncServiceImpl.java @@ -0,0 +1,59 @@ +package com.whereyouad.WhereYouAd.domains.timeline.domain.service; + +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Grain; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.MetricFact; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository; +import com.whereyouad.WhereYouAd.domains.timeline.exception.TimelineException; +import com.whereyouad.WhereYouAd.domains.timeline.exception.code.TimelineErrorCode; +import com.whereyouad.WhereYouAd.domains.timeline.persistence.entity.Timeline; +import com.whereyouad.WhereYouAd.domains.timeline.persistence.repository.TimelineRepository; +import com.whereyouad.WhereYouAd.infrastructure.client.openai.service.OpenApiService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TimelineAsyncServiceImpl implements TimelineAsyncService { + + private final TimelineRepository timelineRepository; + private final MetricFactRepository metricFactRepository; + private final OpenApiService openApiService; + + @Override + @Async + @Transactional + public void summarizeAsync(Long timelineId, Long orgId) { + Timeline timeline = timelineRepository.findById(timelineId) + .orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_NOT_FOUND)); + + // 분석 기간 Metric_fact 데이터 불러오기 + List facts = metricFactRepository.findByOrgAndPeriodAndGrain( + orgId, + timeline.getStartDate().atStartOfDay(), + timeline.getEndDate().plusDays(1).atStartOfDay(), + Grain.DAILY + ); + + // 비교 기간 Metric_fact 데이터 불러오기 + List comparisonFacts = metricFactRepository.findByOrgAndPeriodAndGrain( + orgId, + timeline.getComparisonStartDate().atStartOfDay(), + timeline.getComparisonEndDate().plusDays(1).atStartOfDay(), + Grain.DAILY + ); + + try { + // 타임라인 AI요약 생성 요청 + String summary = openApiService.generateTimelineSummary(timeline, facts, comparisonFacts); + timeline.updateSummary(summary); + } catch (Exception e) { + log.error("[Timeline AI 요약 실패] timelineId={}, error={}", timelineId, e.getMessage()); + } + } +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineService.java index 7e08f831..910155c8 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineService.java @@ -11,4 +11,5 @@ public interface TimelineService { void deleteTimeline(Long userId, Long orgId, Long timelineId); List getTimelines(Long userId, Long orgId); TimelineResponse.TimelineDetailDTO getTimelineDetail(Long userId, Long orgId, Long timelineId); + void requestTimelineSummary(Long userId, Long orgId, Long timelineId); } \ No newline at end of file diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java index 4228b1b7..e5d6bf27 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java @@ -1,11 +1,9 @@ package com.whereyouad.WhereYouAd.domains.timeline.domain.service; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.projection.MetricSumProjection; -import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgRole; import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgStatus; import com.whereyouad.WhereYouAd.domains.organization.exception.code.OrgErrorCode; import com.whereyouad.WhereYouAd.domains.organization.exception.handler.OrgHandler; -import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.OrgMember; import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.Organization; import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository; import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgRepository; @@ -28,12 +26,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.DayOfWeek; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; -import java.time.LocalTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -50,6 +49,7 @@ public class TimelineServiceImpl implements TimelineService { private final OrgRepository orgRepository; private final OrgMemberRepository orgMemberRepository; private final TimelineUtil timelineUtil; + private final TimelineAsyncService timelineAsyncService; @Override public TimelineResponse.CreateResponseDTO createTimeline(Long userId, Long orgId, TimelineRequest.TimelineCreateDto dto) { @@ -91,6 +91,11 @@ public TimelineResponse.CreateResponseDTO createTimeline(Long userId, Long orgId Status.ON_GOING ); + // 현재 기간에 성과 데이터가 없거나 모두 0이면 타임라인 생성 불가 + if (isProjectionEmpty(currentFacts)) { + throw new TimelineException(TimelineErrorCode.TIMELINE_NO_CURRENT_DATA); + } + // 입력받은 DTO를 타임라인 엔티티로 변환 Timeline timeline = TimelineConverter.toTimeline(dto, organization, userId, comparisonDates.start(), comparisonDates.end()); @@ -117,12 +122,9 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId throw new TimelineException(TimelineErrorCode.TIMELINE_NOT_FOUND); } - // 4. ADMIN 권한 검증 - OrgMember member = orgMemberRepository.findByUserIdAndOrgId(userId, orgId) + // 4. 조직 멤버 검증 + orgMemberRepository.findByUserIdAndOrgId(userId, orgId) .orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_UPDATE_FORBIDDEN)); - if (member.getRole() != OrgRole.ADMIN) { - throw new TimelineException(TimelineErrorCode.TIMELINE_UPDATE_FORBIDDEN); - } // 5. 날짜 검증 if (dto.endDate().isBefore(dto.startDate())) { @@ -154,6 +156,11 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId Status.ON_GOING ); + // 현재 기간에 성과 데이터가 없거나 모두 0이면 수정 불가 + if (isProjectionEmpty(currentFacts)) { + throw new TimelineException(TimelineErrorCode.TIMELINE_NO_CURRENT_DATA); + } + // 8. 성과 리스트 -> boolean 플래그 변환 boolean useClick = dto.metrics().contains(MetricType.CLICK); boolean useConversion = dto.metrics().contains(MetricType.CONVERSION); @@ -188,14 +195,9 @@ public void deleteTimeline(Long userId, Long orgId, Long timelineId) { } // 조직 맴버가 아닌 경우 - OrgMember member = orgMemberRepository.findByUserIdAndOrgId(userId, orgId) + orgMemberRepository.findByUserIdAndOrgId(userId, orgId) .orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_DELETE_FORBIDDEN)); - // ADMIN 권한이 없는 경우 - if (member.getRole() != OrgRole.ADMIN) { - throw new TimelineException(TimelineErrorCode.TIMELINE_DELETE_FORBIDDEN); - } - // 삭제 timelineRepository.delete(timeline); } @@ -255,6 +257,42 @@ public TimelineResponse.TimelineDetailDTO getTimelineDetail(Long userId, Long or return TimelineConverter.toTimelineDetailDTO(timeline, metrics, dailyTrend, platformContributions); } + @Override + public void requestTimelineSummary(Long userId, Long orgId, Long timelineId) { + orgRepository.findById(orgId) + .orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND)); + + orgMemberRepository.findByUserIdAndOrgId(userId, orgId) + .orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_READ_FORBIDDEN)); + + Timeline timeline = timelineRepository.findById(timelineId) + .orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_NOT_FOUND)); + + // 해당 조직의 타임라인이 아닌 경우 + if (!timeline.getOrganization().getId().equals(orgId)) { + throw new TimelineException(TimelineErrorCode.TIMELINE_NOT_FOUND); + } + + // 생성 이후 MetricFact 변동 or 광고 상태 변경을 대비한 재검증 + boolean hasData = metricFactRepository.existsByTimeBucketBetweenAndOrg( + timeline.getStartDate().atStartOfDay(), + timeline.getEndDate().plusDays(1).atStartOfDay(), + orgId + ); + if (!hasData) { + throw new TimelineException(TimelineErrorCode.TIMELINE_NO_METRIC_DATA); + } + + // 트랜잭션 훅 등록 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + // 트랜잭션이 성공적으로 커밋된 시점 이후에 호출 + public void afterCommit() { + timelineAsyncService.summarizeAsync(timelineId, orgId); + } + }); + } + // 선택된 지표를 리스트로 변환해주는 메서드 private List buildMetricList(Timeline timeline) { List metrics = new ArrayList<>(); diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/exception/code/TimelineErrorCode.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/exception/code/TimelineErrorCode.java index 700dcc28..12d8199b 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/exception/code/TimelineErrorCode.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/exception/code/TimelineErrorCode.java @@ -12,6 +12,8 @@ public enum TimelineErrorCode implements BaseErrorCode { // 400 TIMELINE_INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "TIMELINE_400_1", "종료일은 시작일보다 이후여야 합니다."), TIMELINE_NO_COMPARISON_DATA(HttpStatus.BAD_REQUEST, "TIMELINE_400_2", "비교 기간에 해당하는 성과 데이터가 존재하지 않습니다."), + TIMELINE_NO_METRIC_DATA(HttpStatus.BAD_REQUEST, "TIMELINE_400_3", "해당 타임라인 기간의 광고 데이터가 없습니다."), + TIMELINE_NO_CURRENT_DATA(HttpStatus.BAD_REQUEST, "TIMELINE_400_4", "선택한 기간에 해당하는 성과 데이터가 존재하지 않습니다."), // 403 TIMELINE_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "TIMELINE_403_1", "타임라인을 삭제할 권한이 없습니다."), diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/presentation/TimelineController.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/presentation/TimelineController.java index 92fb6b89..2aaf2ce2 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/presentation/TimelineController.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/presentation/TimelineController.java @@ -68,4 +68,14 @@ public ResponseEntity> deleteTimeline( timelineService.deleteTimeline(userId, orgId, timelineId); return ResponseEntity.ok(DataResponse.ok()); } + + @PostMapping("/{timelineId}/summary") + public ResponseEntity> requestTimelineSummary( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable Long orgId, + @PathVariable Long timelineId + ) { + timelineService.requestTimelineSummary(userId, orgId, timelineId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(DataResponse.ok()); + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/presentation/docs/TimelineControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/presentation/docs/TimelineControllerDocs.java index a7568feb..bd10560d 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/presentation/docs/TimelineControllerDocs.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/presentation/docs/TimelineControllerDocs.java @@ -94,4 +94,20 @@ ResponseEntity> deleteTimeline( @PathVariable Long orgId, @PathVariable Long timelineId ); + + @Operation( + summary = "타임라인 AI 요약 요청 API", + description = "타임라인 기간의 성과 데이터를 바탕으로 AI 요약문을 생성합니다. 비동기로 처리되며, 완료 후 타임라인 상세 조회(summary 필드)에서 확인할 수 있습니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "요약 요청 수락됨"), + @ApiResponse(responseCode = "400_3", description = "해당 기간의 광고 데이터가 없는 경우"), + @ApiResponse(responseCode = "403_2", description = "조직 멤버가 아닌 경우"), + @ApiResponse(responseCode = "404_1", description = "타임라인 또는 조직을 찾을 수 없는 경우") + }) + ResponseEntity> requestTimelineSummary( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable Long orgId, + @PathVariable Long timelineId + ); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/openai/prompt/PromptBuilder.java b/src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/openai/prompt/PromptBuilder.java index 4454f94d..966a309c 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/openai/prompt/PromptBuilder.java +++ b/src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/openai/prompt/PromptBuilder.java @@ -2,13 +2,17 @@ import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.MetricFact; +import com.whereyouad.WhereYouAd.domains.timeline.persistence.entity.Timeline; import org.springframework.stereotype.Component; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; @Component public class PromptBuilder { @@ -111,6 +115,95 @@ public String buildUserPrompt(String provider, LocalDate startDate, LocalDate en return sb.toString(); } + public String buildTimelineSystemPrompt() { + return """ + 당신은 디지털 광고 성과 타임라인 분석 전문가입니다. + 사용자가 제공하는 타임라인 기간의 일별 성과 데이터를 바탕으로 2~3문장의 자연스러운 한국어 요약문을 작성하세요. + + 반드시 지켜야 할 규칙: + - '활성화된 지표'에 명시된 지표만 분석하세요. 목록에 없는 지표는 절대 언급하지 마세요. + - 데이터 부족, 추가 수집 필요, 알 수 없음 등의 표현은 절대 사용하지 마세요. 제공된 데이터만으로 분석하세요. + - 전체적인 흐름, 특이점(최고/최저 날짜), 지표 간 변화를 포함하세요. + - JSON, 마크다운, 불릿 포인트 없이 순수 텍스트로만 응답하세요. + """; + } + + public String buildTimelineUserPrompt(Timeline timeline, List facts, List comparisonFacts) { + StringBuilder sb = new StringBuilder(); + + List activeMetricNames = new ArrayList<>(); + if (timeline.isUseClick()) activeMetricNames.add("클릭수"); + if (timeline.isUseConversion()) activeMetricNames.add("전환수"); + if (timeline.isUseImpression()) activeMetricNames.add("노출수"); + if (timeline.isUseRoas()) activeMetricNames.add("ROAS"); + + sb.append(String.format("타임라인 이름: %s\n", timeline.getName())); + sb.append(String.format("분석 기간: %s ~ %s\n", timeline.getStartDate(), timeline.getEndDate())); + sb.append(String.format("비교 기간: %s ~ %s\n", timeline.getComparisonStartDate(), timeline.getComparisonEndDate())); + if (timeline.getPerformanceStatus() != null) { + String statusLabel = switch (timeline.getPerformanceStatus()) { + case ABOVE_AVG -> "평균 이상"; + case ON_TRACK -> "보통 (목표 수준 유지)"; + case UNDERPERFORM -> "평균 이하"; + }; + sb.append(String.format("성과 상태: %s\n", statusLabel)); + } + sb.append(String.format("활성화된 지표: %s\n\n", String.join(", ", activeMetricNames))); + + // 분석 기간 일별 데이터 + sb.append("분석 기간 일별 성과 데이터:\n"); + appendDailyMetrics(sb, timeline, facts, timeline.getStartDate(), timeline.getEndDate()); + + // 비교 기간 일별 데이터 + sb.append("\n비교 기간 일별 성과 데이터:\n"); + appendDailyMetrics(sb, timeline, comparisonFacts, timeline.getComparisonStartDate(), timeline.getComparisonEndDate()); + + return sb.toString(); + } + + private void appendDailyMetrics(StringBuilder sb, Timeline timeline, List facts, LocalDate from, LocalDate to) { + sb.append("Date"); + if (timeline.isUseClick()) sb.append(",Clk"); + if (timeline.isUseConversion()) sb.append(",Conv"); + if (timeline.isUseImpression()) sb.append(",Imp"); + if (timeline.isUseRoas()) sb.append(",ROAS"); + sb.append("\n"); + + Map> byDate = facts.stream() + .collect(Collectors.groupingBy(f -> f.getTimeBucket().toLocalDate())); + + from.datesUntil(to.plusDays(1)).forEach(date -> { + List dayFacts = byDate.getOrDefault(date, List.of()); + sb.append(date); + + if (timeline.isUseClick()) { + long clk = dayFacts.stream().mapToLong(f -> f.getClicks() != null ? f.getClicks() : 0L).sum(); + sb.append(",").append(clk); + } + if (timeline.isUseConversion()) { + long conv = dayFacts.stream().mapToLong(f -> f.getConversions() != null ? f.getConversions() : 0L).sum(); + sb.append(",").append(conv); + } + if (timeline.isUseImpression()) { + long imp = dayFacts.stream().mapToLong(f -> f.getImpressions() != null ? f.getImpressions() : 0L).sum(); + sb.append(",").append(imp); + } + if (timeline.isUseRoas()) { + BigDecimal spend = dayFacts.stream() + .map(f -> f.getSpend() != null ? f.getSpend() : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal revenue = dayFacts.stream() + .map(f -> f.getRevenue() != null ? f.getRevenue() : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + String roasStr = spend.compareTo(BigDecimal.ZERO) > 0 + ? revenue.divide(spend, 2, RoundingMode.HALF_UP).toPlainString() + : "0"; + sb.append(",").append(roasStr); + } + sb.append("\n"); + }); + } + // 캠페인별 예산 집계용 클래스 private static class CampaignBudgetAccumulator { final Long campaignId; diff --git a/src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/openai/service/OpenApiService.java b/src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/openai/service/OpenApiService.java index 827b8b64..d9d63a0c 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/openai/service/OpenApiService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/infrastructure/client/openai/service/OpenApiService.java @@ -2,6 +2,7 @@ import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.MetricFact; import com.whereyouad.WhereYouAd.domains.ai.application.dto.response.AIResponse; +import com.whereyouad.WhereYouAd.domains.timeline.persistence.entity.Timeline; import com.whereyouad.WhereYouAd.domains.ai.application.mapper.AIConverter; import com.whereyouad.WhereYouAd.domains.ai.exception.AIHandler; import com.whereyouad.WhereYouAd.domains.ai.exception.code.AIErrorCode; @@ -83,6 +84,46 @@ public AIResponse.AnalysisResponse generateAnalysis( return AIConverter.toAnalysisResponse(aiContent); } + public String generateTimelineSummary(Timeline timeline, List facts, List comparisonFacts) { + String systemPrompt = promptBuilder.buildTimelineSystemPrompt(); + String userPrompt = promptBuilder.buildTimelineUserPrompt(timeline, facts, comparisonFacts); + OpenAIRequest.Request request = AIConverter.toOpenAiRequest(model, systemPrompt, userPrompt); + + // openai 응답 + OpenAIResponse.Response response; + try { + log.info("[generateTimelineSummary] OpenAI 호출 시작. timelineId={}, 레코드 수={}", timeline.getId(), facts.size()); + response = openAiClient.chatCompletions(request); + log.info("[generateTimelineSummary] OpenAI 응답 수신 완료."); + + } catch (FeignException.BadRequest e) { + log.error("[generateTimelineSummary] OpenAI 400 Bad Request: {}", extractFeignMessage(e)); + throw new AIHandler(AIErrorCode.INVALID_OPENAI_REQUEST); + + } catch (FeignException.Unauthorized e) { + log.error("[generateTimelineSummary] OpenAI 401 Unauthorized: {}", extractFeignMessage(e)); + throw new AIHandler(AIErrorCode.INVALID_OPENAI_API_KEY); + + } catch (FeignException.TooManyRequests e) { + log.error("[generateTimelineSummary] OpenAI 429 Rate Limit: {}", extractFeignMessage(e)); + throw new AIHandler(AIErrorCode.OPENAI_RATE_LIMIT); + + } catch (FeignException e) { + log.error("[generateTimelineSummary] OpenAI Feign 오류 (status={}): {}", e.status(), extractFeignMessage(e)); + throw new AIHandler(AIErrorCode.AI_CALL_FAILED); + } + + // 본문 추출 + String content = response.getFirstContent(); + if (content == null || content.isBlank()) { + log.error("[generateTimelineSummary] OpenAI 응답 content가 비어있습니다."); + throw new AIHandler(AIErrorCode.AI_CALL_FAILED); + } + + // 공백 제거 및 반환 + return content.trim(); + } + // 에러 메시지 추출 private String extractFeignMessage(FeignException e) { try {