From 01ffa5389caa605209e3b4bbfed91d7800e11ef5 Mon Sep 17 00:00:00 2001
From: Jaewon Lee <50106190+jwon0523@users.noreply.github.com>
Date: Fri, 5 Jun 2026 07:45:29 +0900
Subject: [PATCH] =?UTF-8?q?fix:=20=EC=B6=94=EC=B2=9C=20=EC=83=9D=EC=84=B1?=
=?UTF-8?q?=20=EC=8B=9C=20=EC=8B=A4=EC=A0=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90?=
=?UTF-8?q?=20=ED=8A=B8=EB=9E=99=EC=9D=84=20AI=20=EC=9A=94=EC=B2=AD?=
=?UTF-8?q?=EC=97=90=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../PersistedOnboardingProfileReader.java | 112 +++++++++++++
.../SeedOnboardingProfileReader.java | 147 ------------------
.../PersistedOnboardingProfileReaderTest.java | 140 +++++++++++++++++
.../service/RecommendationServiceTest.java | 69 +++++++-
4 files changed, 318 insertions(+), 150 deletions(-)
create mode 100644 src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReader.java
delete mode 100644 src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/SeedOnboardingProfileReader.java
create mode 100644 src/test/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReaderTest.java
diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReader.java b/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReader.java
new file mode 100644
index 0000000..3ffac8f
--- /dev/null
+++ b/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReader.java
@@ -0,0 +1,112 @@
+package com.hansung.tracktory.domain.recommendation.onboarding;
+
+import com.hansung.tracktory.domain.catalog.organization.entity.Department;
+import com.hansung.tracktory.domain.profile.entity.UserCompletedSubject;
+import com.hansung.tracktory.domain.profile.entity.UserProfile;
+import com.hansung.tracktory.domain.profile.repository.UserCompanyTypeRepository;
+import com.hansung.tracktory.domain.profile.repository.UserCompletedSubjectRepository;
+import com.hansung.tracktory.domain.profile.repository.UserDevFieldRepository;
+import com.hansung.tracktory.domain.profile.repository.UserInterestRepository;
+import com.hansung.tracktory.domain.profile.repository.UserProfileRepository;
+import com.hansung.tracktory.domain.profile.repository.UserTrackRepository;
+import com.hansung.tracktory.domain.profile.repository.UserWorkValueRepository;
+import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot.CompletedCourse;
+import java.util.List;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 저장된 온보딩 프로필을 추천 입력 스냅샷으로 변환한다.
+ *
+ *
추천 도메인은 본 reader 를 통해서만 프로필 도메인의 영속 모델을 읽는다. 트랙을 아직 선택하지 않은 사용자도 온보딩 프로필이 있으면 빈 트랙 목록을 그대로 AI
+ * 입력으로 전달해, "선택 없음" 과 "선택 있음" 을 구분한다.
+ */
+@Component
+@RequiredArgsConstructor
+public class PersistedOnboardingProfileReader implements OnboardingProfileReader {
+
+ private static final int DEFAULT_ADMISSION_YEAR_UNTIL_PROFILE_SCHEMA_SUPPORTS_IT = 2023;
+
+ private final UserProfileRepository userProfileRepository;
+ private final UserTrackRepository userTrackRepository;
+ private final UserInterestRepository userInterestRepository;
+ private final UserDevFieldRepository userDevFieldRepository;
+ private final UserWorkValueRepository userWorkValueRepository;
+ private final UserCompanyTypeRepository userCompanyTypeRepository;
+ private final UserCompletedSubjectRepository userCompletedSubjectRepository;
+
+ @Override
+ @Transactional(readOnly = true)
+ public Optional read(Long userId) {
+ return userProfileRepository.findByUserId(userId).map(profile -> toSnapshot(userId, profile));
+ }
+
+ private OnboardingProfileSnapshot toSnapshot(Long userId, UserProfile profile) {
+ Department department = profile.getDepartment();
+ return new OnboardingProfileSnapshot(
+ userId,
+ DEFAULT_ADMISSION_YEAR_UNTIL_PROFILE_SCHEMA_SUPPORTS_IT,
+ department.getCollege().getName(),
+ department.getName(),
+ currentSemester(profile.getCurrentYear()),
+ trackCodes(userId),
+ interestCodes(userId),
+ devFieldCodes(userId),
+ workValueCodes(userId),
+ companyTypeCodes(userId),
+ List.of(),
+ completedCourses(userId));
+ }
+
+ private Integer currentSemester(Integer currentYear) {
+ if (currentYear == null) {
+ return null;
+ }
+ return Math.max(1, currentYear * 2 - 1);
+ }
+
+ private List trackCodes(Long userId) {
+ return userTrackRepository.findByUserIdOrderByTrackOrderAsc(userId).stream()
+ .map(userTrack -> userTrack.getTrack().getCode())
+ .toList();
+ }
+
+ private List interestCodes(Long userId) {
+ return userInterestRepository.findByUserIdOrderByIdAsc(userId).stream()
+ .map(userInterest -> userInterest.getInterest().getCode())
+ .toList();
+ }
+
+ private List devFieldCodes(Long userId) {
+ return userDevFieldRepository.findByUserIdOrderByIdAsc(userId).stream()
+ .map(userDevField -> userDevField.getDevField().getCode())
+ .toList();
+ }
+
+ private List workValueCodes(Long userId) {
+ return userWorkValueRepository.findByUserIdOrderByIdAsc(userId).stream()
+ .map(userWorkValue -> userWorkValue.getWorkValue().getCode())
+ .toList();
+ }
+
+ private List companyTypeCodes(Long userId) {
+ return userCompanyTypeRepository.findByUserIdOrderByIdAsc(userId).stream()
+ .map(userCompanyType -> userCompanyType.getCompanyType().getCode())
+ .toList();
+ }
+
+ private List completedCourses(Long userId) {
+ return userCompletedSubjectRepository.findByUserIdOrderByYearAscSemesterAsc(userId).stream()
+ .map(this::completedCourse)
+ .toList();
+ }
+
+ private CompletedCourse completedCourse(UserCompletedSubject completedSubject) {
+ return new CompletedCourse(
+ completedSubject.getSubject().getCode(),
+ completedSubject.getYear(),
+ completedSubject.getSemester());
+ }
+}
diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/SeedOnboardingProfileReader.java b/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/SeedOnboardingProfileReader.java
deleted file mode 100644
index cbcfc55..0000000
--- a/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/SeedOnboardingProfileReader.java
+++ /dev/null
@@ -1,147 +0,0 @@
-package com.hansung.tracktory.domain.recommendation.onboarding;
-
-import com.hansung.tracktory.domain.catalog.classification.entity.CompanyType;
-import com.hansung.tracktory.domain.catalog.classification.entity.DevField;
-import com.hansung.tracktory.domain.catalog.classification.entity.Interest;
-import com.hansung.tracktory.domain.catalog.classification.entity.WorkValue;
-import com.hansung.tracktory.domain.catalog.classification.repository.CompanyTypeRepository;
-import com.hansung.tracktory.domain.catalog.classification.repository.DevFieldRepository;
-import com.hansung.tracktory.domain.catalog.classification.repository.InterestRepository;
-import com.hansung.tracktory.domain.catalog.classification.repository.WorkValueRepository;
-import com.hansung.tracktory.domain.catalog.curriculum.entity.Subject;
-import com.hansung.tracktory.domain.catalog.curriculum.repository.SubjectRepository;
-import com.hansung.tracktory.domain.catalog.organization.entity.Department;
-import com.hansung.tracktory.domain.catalog.organization.entity.Track;
-import com.hansung.tracktory.domain.catalog.organization.repository.TrackRepository;
-import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot.CompletedCourse;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Function;
-import java.util.function.Supplier;
-import lombok.RequiredArgsConstructor;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-/**
- * 온보딩 영속 도메인이 준비되기 전까지 사용하는 임시 시드 구현.
- *
- * 적재된 카탈로그 기준 데이터에서 그럴듯한 프로필 한 건을 합성한다. 반환하는 모든 분류·트랙·과목 코드는 실제 카탈로그에 존재하므로 추천 파이프라인의 코드 → 카탈로그
- * 매핑이 성립한다. 트랙 두 개를 합성하지 못할 만큼 카탈로그가 비어 있으면 {@link Optional#empty()} 를 반환해 온보딩 미완료로 처리되게 한다. 온보딩
- * 도메인이 완성되면 본 구현을 영속 기반 reader 로 교체한다.
- */
-@Component
-@RequiredArgsConstructor
-public class SeedOnboardingProfileReader implements OnboardingProfileReader {
-
- private static final int SEED_ADMISSION_YEAR = 2023;
- private static final int SEED_CURRENT_SEMESTER = 3;
-
- // 이수 과목은 현재 학기(3학기) 이전 학기에만 놓여야 한다. completed=true 인 과목이 현재 학기 이후로
- // 배치되면 (a) 같은 (year,semester) 학기 카드가 로드맵에서 중복되고 (b) completed=true 인데 timing 이
- // FUTURE 가 되는 모순이 발생한다. 1학년 1·2학기 두 칸만 사용한다.
- private static final int[][] COMPLETED_SCHEDULE = {{1, 1}, {1, 2}};
-
- // MVP 타겟(컴퓨터공학부 빅데이터)을 mirror 한 데모 프로필. 모두 카탈로그에 존재하는 코드이며,
- // 1트랙(빅데이터트랙)이 주전공 소속이라 home 단과대/학부가 자연히 IT공과대학/컴퓨터공학부로 잡힌다.
- // 코드가 누락된 환경에서는 카탈로그 선두 항목으로 graceful fallback 한다.
- private static final List SEED_TRACK_CODES = List.of("빅데이터트랙", "AIㆍ소프트웨어학과");
- private static final List SEED_INTEREST_CODES = List.of("IT/인터넷", "연구개발/설계");
- private static final List SEED_DEV_FIELD_CODES = List.of("데이터");
- private static final List SEED_COMPANY_TYPE_CODES = List.of("대기업");
- private static final List SEED_WORK_VALUE_CODES = List.of("성장성");
-
- // 이수 과목은 컴퓨터공학부 기초 과목으로 합성한다(COMPLETED_SCHEDULE 와 순서·개수 정합).
- // 카탈로그 선두 4개 과목은 패션(계약학과) 소속이라 데모 프로필의 학과와 무관하므로 코드로 직접 지정한다.
- private static final List SEED_COMPLETED_COURSE_CODES = List.of("CTE0001", "W080002");
-
- private final TrackRepository trackRepository;
- private final SubjectRepository subjectRepository;
- private final InterestRepository interestRepository;
- private final DevFieldRepository devFieldRepository;
- private final CompanyTypeRepository companyTypeRepository;
- private final WorkValueRepository workValueRepository;
-
- @Override
- @Transactional(readOnly = true)
- public Optional read(Long userId) {
- List