브리핑 표면은 추천 입력 전체가 아니라 직무 식별자만 받는다. camelCase 자바 필드 {@code jobIds} 는 snake_case JSON {@code
+ * job_ids} 로 직렬화된다. FastAPI 계약상 최소 1개이며 각 코드는 공백 제거 후 1자 이상이어야 하므로, 빈 목록을 보내지 않는 책임은 호출 서비스에 있다.
+ */
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public record AiBriefingRequest(List jobIds) {}
diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingResponse.java b/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingResponse.java
new file mode 100644
index 0000000..0e7d97e
--- /dev/null
+++ b/src/main/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingResponse.java
@@ -0,0 +1,34 @@
+package com.hansung.tracktory.domain.briefing.ai;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import java.util.List;
+import tools.jackson.databind.PropertyNamingStrategies;
+import tools.jackson.databind.annotation.JsonNaming;
+
+/**
+ * AI 중계 서버 직무 브리핑 응답의 data 본문 — FastAPI BriefingResponse 와 중첩 모델(JobBriefing/BriefingSource)을
+ * mirror 한다. 모든 레코드는 snake_case JSON 매핑 + 알 수 없는 필드 무시를 적용한다.
+ */
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record AiBriefingResponse(List briefings) {
+
+ /**
+ * 추천 직무 한 건에 연결된 트렌드 브리핑 카드. {@code jobId} 는 직무 카탈로그 표준 코드(추천 직무 식별자와 정합), {@code sources} 는 검증
+ * 가능한 출처(FastAPI 가 최소 1개를 보장).
+ */
+ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record Briefing(
+ String jobId,
+ String jobName,
+ String headline,
+ String summary,
+ List skills,
+ List sources) {}
+
+ /** 브리핑 근거 출처. {@code publishedAt} 은 발행 시점(YYYY 또는 YYYY-MM, 미상이면 null). */
+ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record Source(String title, String url, String publishedAt) {}
+}
diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/ai/BriefingClient.java b/src/main/java/com/hansung/tracktory/domain/briefing/ai/BriefingClient.java
new file mode 100644
index 0000000..c124a12
--- /dev/null
+++ b/src/main/java/com/hansung/tracktory/domain/briefing/ai/BriefingClient.java
@@ -0,0 +1,62 @@
+package com.hansung.tracktory.domain.briefing.ai;
+
+import com.hansung.tracktory.domain.recommendation.ai.AiEnvelope;
+import com.hansung.tracktory.global.exception.BusinessException;
+import com.hansung.tracktory.global.exception.ErrorCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.WebClientException;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
+
+/**
+ * AI 중계 서버(FastAPI)의 직무 브리핑 엔드포인트를 호출하는 클라이언트.
+ *
+ *
내부 인증 헤더(X-Internal-Token)·사용자 식별 헤더(X-User-Id)는 {@code aiRelayWebClient} 빈의 필터가 자동 부착하므로 여기서
+ * 설정하지 않는다. 필터가 호출 스레드의 SecurityContext 를 읽으므로 인증된 요청 스레드에서 블로킹 호출해야 한다.
+ */
+@Component
+public class BriefingClient {
+
+ private static final String BRIEFING_PATH = "/api/v1/ai/briefing";
+ private static final Logger log = LoggerFactory.getLogger(BriefingClient.class);
+
+ private final WebClient aiRelayWebClient;
+
+ public BriefingClient(@Qualifier("aiRelayWebClient") WebClient aiRelayWebClient) {
+ this.aiRelayWebClient = aiRelayWebClient;
+ }
+
+ /**
+ * 추천 직무 코드 목록으로 큐레이션 브리핑 카드를 요청한다.
+ *
+ * @throws BusinessException AI 서버 호출 실패 또는 비정상 응답 시 ({@link ErrorCode#AI_RELAY_ERROR}).
+ */
+ public AiBriefingResponse fetch(AiBriefingRequest request) {
+ AiEnvelope envelope;
+ try {
+ envelope =
+ aiRelayWebClient
+ .post()
+ .uri(BRIEFING_PATH)
+ .bodyValue(request)
+ .retrieve()
+ .bodyToMono(new ParameterizedTypeReference>() {})
+ .block();
+ } catch (WebClientResponseException e) {
+ log.warn("AI relay 응답 오류: status={} body={}", e.getStatusCode(), e.getResponseBodyAsString());
+ throw new BusinessException(ErrorCode.AI_RELAY_ERROR);
+ } catch (WebClientException e) {
+ log.warn("AI relay 호출 실패", e);
+ throw new BusinessException(ErrorCode.AI_RELAY_ERROR);
+ }
+
+ if (envelope == null || !envelope.success() || envelope.data() == null) {
+ throw new BusinessException(ErrorCode.AI_RELAY_ERROR);
+ }
+ return envelope.data();
+ }
+}
diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/controller/BriefingController.java b/src/main/java/com/hansung/tracktory/domain/briefing/controller/BriefingController.java
new file mode 100644
index 0000000..274988d
--- /dev/null
+++ b/src/main/java/com/hansung/tracktory/domain/briefing/controller/BriefingController.java
@@ -0,0 +1,32 @@
+package com.hansung.tracktory.domain.briefing.controller;
+
+import com.hansung.tracktory.domain.briefing.dto.BriefingResponse;
+import com.hansung.tracktory.domain.briefing.service.BriefingService;
+import com.hansung.tracktory.domain.user.service.UserPrincipal;
+import com.hansung.tracktory.global.response.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 직무 브리핑 조회 API — 인증된 사용자의 활성 추천 직무에 연결된 트렌드 브리핑 카드를 반환한다.
+ *
+ *
앱은 AI 서버를 직접 호출하지 않고 본 백엔드를 경유한다. 활성 추천이 없으면 {@code RECOMMENDATION_NOT_FOUND}(404) 로 거절된다.
+ */
+@RestController
+@RequestMapping("/api/v1/briefings")
+@RequiredArgsConstructor
+public class BriefingController {
+
+ private final BriefingService briefingService;
+
+ @GetMapping
+ public ResponseEntity> getBriefings(
+ @AuthenticationPrincipal UserPrincipal principal) {
+ BriefingResponse response = briefingService.getBriefings(principal.getUserId());
+ return ResponseEntity.ok(ApiResponse.ok(response));
+ }
+}
diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/dto/BriefingResponse.java b/src/main/java/com/hansung/tracktory/domain/briefing/dto/BriefingResponse.java
new file mode 100644
index 0000000..bcace36
--- /dev/null
+++ b/src/main/java/com/hansung/tracktory/domain/briefing/dto/BriefingResponse.java
@@ -0,0 +1,27 @@
+package com.hansung.tracktory.domain.briefing.dto;
+
+import java.util.List;
+
+/**
+ * 직무 브리핑 조회 API 의 프론트엔드 응답 — 활성 추천 직무에 연결된 트렌드 카드 묶음을 전달한다.
+ *
+ *
홈 브리핑 시트가 소비하며, 카드는 추천 직무 순서(적합도 내림차순)를 따른다. 큐레이션이 없는 직무는 빠지므로 카드 수가 추천 직무 수보다 적을 수 있고, 매칭이
+ * 하나도 없으면 빈 리스트다.
+ */
+public record BriefingResponse(List briefings) {
+
+ /**
+ * 직무 한 건의 트렌드 브리핑 카드. {@code code} 는 직무 카탈로그 표준 코드(추천 직무 식별자와 정합), {@code skills} 는 카드가 강조하는 필수
+ * 역량·기술 키워드(없으면 빈 리스트), {@code sources} 는 검증 가능한 출처(최소 1개).
+ */
+ public record BriefingCardView(
+ String code,
+ String name,
+ String headline,
+ String summary,
+ List skills,
+ List sources) {}
+
+ /** 브리핑 근거 출처. {@code publishedAt} 은 발행 시점(YYYY 또는 YYYY-MM), 미상이면 null. */
+ public record SourceView(String title, String url, String publishedAt) {}
+}
diff --git a/src/main/java/com/hansung/tracktory/domain/briefing/service/BriefingService.java b/src/main/java/com/hansung/tracktory/domain/briefing/service/BriefingService.java
new file mode 100644
index 0000000..f7e3c36
--- /dev/null
+++ b/src/main/java/com/hansung/tracktory/domain/briefing/service/BriefingService.java
@@ -0,0 +1,93 @@
+package com.hansung.tracktory.domain.briefing.service;
+
+import com.hansung.tracktory.domain.briefing.ai.AiBriefingRequest;
+import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse;
+import com.hansung.tracktory.domain.briefing.ai.BriefingClient;
+import com.hansung.tracktory.domain.briefing.dto.BriefingResponse;
+import com.hansung.tracktory.domain.briefing.dto.BriefingResponse.BriefingCardView;
+import com.hansung.tracktory.domain.briefing.dto.BriefingResponse.SourceView;
+import com.hansung.tracktory.domain.recommendation.entity.Recommendation;
+import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus;
+import com.hansung.tracktory.domain.recommendation.entity.RecommendedJob;
+import com.hansung.tracktory.domain.recommendation.repository.RecommendationRepository;
+import com.hansung.tracktory.global.exception.BusinessException;
+import com.hansung.tracktory.global.exception.ErrorCode;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 직무 브리핑 중계 — 사용자의 활성 추천 직무를 기준으로 AI 중계 서버의 큐레이션 브리핑을 조회한다.
+ *
+ *
추천 재계산 없이 홈 추천과 같은 활성 추천 aggregate 를 읽어 직무 코드만 추출하므로 홈 결과와 정합한다. 활성 추천이 없으면 {@code
+ * RECOMMENDATION_NOT_FOUND}(404) 로 거절해 새 추천 생성을 유도한다. 브리핑 카드는 AI 서버가 큐레이션 카탈로그에서 직접 채우며, 본 백엔드는 추천
+ * 직무 코드 전달과 응답 매핑만 담당한다.
+ */
+@Service
+@RequiredArgsConstructor
+public class BriefingService {
+
+ private final RecommendationRepository recommendationRepository;
+ private final BriefingClient briefingClient;
+
+ /**
+ * 활성 추천 직무 코드로 브리핑 카드를 조회한다. 직무 코드는 적합도(score) 내림차순으로 정렬하고 중복을 제거해 카드 순서가 추천 직무 순서를 따르게 한다.
+ *
+ *
추천 직무가 하나도 없으면 AI 가 빈 {@code job_ids} 를 422 로 거절하므로, 호출하지 않고 빈 결과를 반환한다(부분 충족 허용). 큐레이션이 없는
+ * 직무는 AI 응답에서 빠져 카드 수가 추천 직무 수보다 적을 수 있다.
+ */
+ @Transactional(readOnly = true)
+ public BriefingResponse getBriefings(Long userId) {
+ Recommendation active =
+ recommendationRepository
+ .findFirstByUser_IdAndStatusOrderByCreatedAtDesc(userId, RecommendationStatus.ACTIVE)
+ .orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_NOT_FOUND));
+
+ List jobCodes = jobCodesByScoreDesc(active);
+ if (jobCodes.isEmpty()) {
+ return new BriefingResponse(List.of());
+ }
+
+ AiBriefingResponse aiResponse = briefingClient.fetch(new AiBriefingRequest(jobCodes));
+ return toResponse(aiResponse);
+ }
+
+ // 추천 직무 코드를 적합도 내림차순 + 등장 순 중복 제거로 추출한다. 같은 코드로 fold 된 중복 직무가 같은 브리핑을 두 번 요청하지 않도록 한다.
+ private static List jobCodesByScoreDesc(Recommendation recommendation) {
+ Set ordered = new LinkedHashSet<>();
+ recommendation.getRecommendedJobs().stream()
+ .filter(job -> job.getJob() != null && job.getJob().getCode() != null)
+ .sorted(Comparator.comparingInt(RecommendedJob::getScore).reversed())
+ .map(job -> job.getJob().getCode())
+ .forEach(ordered::add);
+ return List.copyOf(ordered);
+ }
+
+ private static BriefingResponse toResponse(AiBriefingResponse aiResponse) {
+ List cards =
+ nullSafe(aiResponse.briefings()).stream().map(BriefingService::toCard).toList();
+ return new BriefingResponse(cards);
+ }
+
+ private static BriefingCardView toCard(AiBriefingResponse.Briefing briefing) {
+ return new BriefingCardView(
+ briefing.jobId(),
+ briefing.jobName(),
+ briefing.headline(),
+ briefing.summary(),
+ nullSafe(briefing.skills()),
+ nullSafe(briefing.sources()).stream().map(BriefingService::toSource).toList());
+ }
+
+ private static SourceView toSource(AiBriefingResponse.Source source) {
+ return new SourceView(source.title(), source.url(), source.publishedAt());
+ }
+
+ private static List nullSafe(List list) {
+ return list == null ? List.of() : list;
+ }
+}
diff --git a/src/test/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingEnvelopeContractTest.java b/src/test/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingEnvelopeContractTest.java
new file mode 100644
index 0000000..006fe9f
--- /dev/null
+++ b/src/test/java/com/hansung/tracktory/domain/briefing/ai/AiBriefingEnvelopeContractTest.java
@@ -0,0 +1,76 @@
+package com.hansung.tracktory.domain.briefing.ai;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse.Briefing;
+import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse.Source;
+import com.hansung.tracktory.domain.recommendation.ai.AiEnvelope;
+import org.junit.jupiter.api.Test;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.json.JsonMapper;
+
+/**
+ * AI 중계 서버(FastAPI) 브리핑 응답 계약의 역직렬화 회귀 가드.
+ *
+ *