diff --git a/ops/db/migrations/20260730_job_posting_embeddings.sql b/ops/db/migrations/20260730_job_posting_embeddings.sql new file mode 100644 index 0000000..cfa3bb9 --- /dev/null +++ b/ops/db/migrations/20260730_job_posting_embeddings.sql @@ -0,0 +1,16 @@ +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS job_posting_embeddings ( + id BIGSERIAL PRIMARY KEY, + job_posting_id BIGINT NOT NULL UNIQUE REFERENCES job_postings(id) ON DELETE CASCADE, + embedding_model VARCHAR(100) NOT NULL, + embedding vector(1024) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_job_posting + ON job_posting_embeddings (job_posting_id); + +CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw + ON job_posting_embeddings USING hnsw (embedding vector_cosine_ops); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingSimilarityResult.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingSimilarityResult.java new file mode 100644 index 0000000..5148579 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingSimilarityResult.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +public record JobPostingSimilarityResult( + Long jobPostingId, + String postingName, + String companyName, + String jobTitle, + double similarityScore +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java index 76187bf..a9c3d2f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java @@ -11,6 +11,8 @@ import java.util.List; public interface JobPostingRepository extends JpaRepository { + List findAllByOrderByIdAsc(); + @EntityGraph(attributePaths = { "company", "user", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingService.java new file mode 100644 index 0000000..fde484e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingService.java @@ -0,0 +1,28 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class JobPostingEmbeddingService { + + private final JobPostingEmbeddingTextBuilder textBuilder; + private final CohereEmbeddingClient cohereEmbeddingClient; + + public float[] embed(JobPosting jobPosting) { + List embeddings = embedAll(List.of(jobPosting)); + return embeddings.getFirst(); + } + + public List embedAll(List jobPostings) { + List texts = jobPostings.stream() + .map(textBuilder::build) + .toList(); + return cohereEmbeddingClient.embedDocuments(texts); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncService.java new file mode 100644 index 0000000..04ae07b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncService.java @@ -0,0 +1,110 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; +import com.pgvector.PGvector; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class JobPostingEmbeddingSyncService { + + private static final String UPSERT_JOB_POSTING_SQL = """ + INSERT INTO job_posting_embeddings (job_posting_id, embedding_model, embedding, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (job_posting_id) + DO UPDATE SET + embedding_model = EXCLUDED.embedding_model, + embedding = EXCLUDED.embedding, + updated_at = EXCLUDED.updated_at + """; + + @Value("${app.corpus.embedding.batch-size:32}") + private int batchSize; + + private final JobPostingRepository jobPostingRepository; + private final JobPostingEmbeddingService jobPostingEmbeddingService; + private final CohereProperties cohereProperties; + private final DataSource dataSource; + + @Transactional + public int syncJobPosting(Long jobPostingId) { + JobPosting jobPosting = jobPostingRepository.findById(jobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId + )); + upsertJobPostingEmbeddings(List.of(jobPosting)); + return 1; + } + + @Transactional + public int syncAllJobPostings() { + return upsertJobPostingEmbeddings(jobPostingRepository.findAllByOrderByIdAsc()); + } + + private int upsertJobPostingEmbeddings(List jobPostings) { + int processed = 0; + for (List batch : partition(jobPostings, batchSize)) { + List embeddings = jobPostingEmbeddingService.embedAll(batch); + upsertVectors(batch.stream().map(JobPosting::getId).toList(), embeddings); + processed += batch.size(); + } + return processed; + } + + private void upsertVectors(List jobPostingIds, List embeddings) { + if (jobPostingIds.size() != embeddings.size()) { + throw new IllegalStateException("임베딩 결과 개수가 채용 공고 개수와 일치하지 않습니다."); + } + + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + PGvector.registerTypes(connection); + try (PreparedStatement statement = connection.prepareStatement(UPSERT_JOB_POSTING_SQL)) { + Timestamp now = Timestamp.valueOf(LocalDateTime.now()); + for (int i = 0; i < jobPostingIds.size(); i++) { + statement.setLong(1, jobPostingIds.get(i)); + statement.setString(2, cohereProperties.embedding().model()); + statement.setObject(3, new PGvector(embeddings.get(i))); + statement.setTimestamp(4, now); + statement.setTimestamp(5, now); + statement.addBatch(); + } + statement.executeBatch(); + } + } catch (SQLException e) { + throw new IllegalStateException("채용 공고 임베딩 벡터 저장 중 오류가 발생했습니다.", e); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private List> partition(List items, int batchSize) { + List> result = new ArrayList<>(); + if (items.isEmpty()) { + return result; + } + int actualBatchSize = Math.max(1, batchSize); + for (int i = 0; i < items.size(); i += actualBatchSize) { + result.add(items.subList(i, Math.min(items.size(), i + actualBatchSize))); + } + return result; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilder.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilder.java new file mode 100644 index 0000000..badb28d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilder.java @@ -0,0 +1,28 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +@Component +public class JobPostingEmbeddingTextBuilder { + + public String build(JobPosting jobPosting) { + List sections = new ArrayList<>(); + addSection(sections, "직무", jobPosting.getJobTitle()); + addSection(sections, "주요업무", jobPosting.getTask()); + addSection(sections, "자격요건", jobPosting.getRequirement()); + addSection(sections, "우대사항", jobPosting.getPreferred()); + return String.join("\n\n", sections); + } + + private void addSection(List sections, String title, String value) { + if (!StringUtils.hasText(value)) { + return; + } + sections.add(title + "\n" + value.trim()); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalService.java new file mode 100644 index 0000000..09c1c55 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalService.java @@ -0,0 +1,97 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import com.pgvector.PGvector; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JobPostingRetrievalService { + + private static final int DEFAULT_LIMIT = 3; + private static final String SIMILAR_JOB_POSTINGS_SQL = """ + SELECT + jp.id, + jp.posting_name, + c.name AS company_name, + jp.job_title, + e.embedding <=> ? AS distance + FROM job_posting_embeddings e + JOIN job_postings jp ON e.job_posting_id = jp.id + JOIN companies c ON jp.company_id = c.id + WHERE jp.id <> ? + AND jp.user_id = ? + ORDER BY e.embedding <=> ? + LIMIT ? + """; + + private final JobPostingRepository jobPostingRepository; + private final JobPostingEmbeddingTextBuilder textBuilder; + private final CohereEmbeddingClient cohereEmbeddingClient; + private final DataSource dataSource; + + public List findSimilarJobPostings(Long jobPostingId) { + return findSimilarJobPostings(jobPostingId, DEFAULT_LIMIT); + } + + public List findSimilarJobPostings(Long jobPostingId, int limit) { + JobPosting current = jobPostingRepository.findById(jobPostingId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_NOT_FOUND, + "해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId + )); + int actualLimit = Math.max(1, limit); + String query = textBuilder.build(current); + float[] vector = cohereEmbeddingClient.embedQuery(query); + return findSimilarJobPostings(current, vector, actualLimit); + } + + private List findSimilarJobPostings( + JobPosting current, + float[] vector, + int limit + ) { + try (Connection connection = dataSource.getConnection()) { + PGvector.registerTypes(connection); + try (PreparedStatement statement = connection.prepareStatement(SIMILAR_JOB_POSTINGS_SQL)) { + statement.setObject(1, new PGvector(vector)); + statement.setLong(2, current.getId()); + statement.setLong(3, current.getUser().getId()); + statement.setObject(4, new PGvector(vector)); + statement.setInt(5, limit); + try (ResultSet resultSet = statement.executeQuery()) { + List results = new ArrayList<>(); + while (resultSet.next()) { + double distance = resultSet.getDouble("distance"); + results.add(new JobPostingSimilarityResult( + resultSet.getLong("id"), + resultSet.getString("posting_name"), + resultSet.getString("company_name"), + resultSet.getString("job_title"), + 1.0 - distance + )); + } + return results; + } + } + } catch (SQLException e) { + throw new IllegalStateException("유사 채용 공고 검색 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index baf65fe..7590699 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -19,6 +19,15 @@ CREATE TABLE IF NOT EXISTS mock_question_embeddings ( updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS job_posting_embeddings ( + id BIGSERIAL PRIMARY KEY, + job_posting_id BIGINT NOT NULL UNIQUE REFERENCES job_postings(id) ON DELETE CASCADE, + embedding_model VARCHAR(100) NOT NULL, + embedding vector(1024) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE INDEX IF NOT EXISTS idx_job_postings_company_detail ON job_postings (company_id, detail_classification_id); @@ -34,12 +43,18 @@ CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_corpus CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_corpus ON mock_question_embeddings (corpus_id); +CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_job_posting + ON job_posting_embeddings (job_posting_id); + CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_hnsw ON mock_job_posting_embeddings USING hnsw (embedding vector_cosine_ops); CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw ON mock_question_embeddings USING hnsw (embedding vector_cosine_ops); +CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw + ON job_posting_embeddings USING hnsw (embedding vector_cosine_ops); + ALTER TABLE IF EXISTS analyses ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]'; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingServiceTest.java new file mode 100644 index 0000000..a9e95f6 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingServiceTest.java @@ -0,0 +1,63 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.entity.UserRole; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JobPostingEmbeddingServiceTest { + + private final JobPostingEmbeddingTextBuilder textBuilder = new JobPostingEmbeddingTextBuilder(); + private final CohereEmbeddingClient cohereEmbeddingClient = mock(CohereEmbeddingClient.class); + private final JobPostingEmbeddingService embeddingService = + new JobPostingEmbeddingService(textBuilder, cohereEmbeddingClient); + + @Test + @DisplayName("채용 공고 embedding text를 Cohere search_document 요청으로 임베딩한다") + void embedAll() { + JobPosting first = jobPosting("백엔드 개발자", "Spring Boot API 개발", "Java", "AWS"); + JobPosting second = jobPosting("데이터 엔지니어", "ETL 파이프라인 개발", "Python", "Airflow"); + List expected = List.of(new float[]{0.1f, 0.2f}, new float[]{0.3f, 0.4f}); + when(cohereEmbeddingClient.embedDocuments(List.of( + textBuilder.build(first), + textBuilder.build(second) + ))).thenReturn(expected); + + List result = embeddingService.embedAll(List.of(first, second)); + + assertThat(result).isSameAs(expected); + verify(cohereEmbeddingClient).embedDocuments(List.of(textBuilder.build(first), textBuilder.build(second))); + } + + private JobPosting jobPosting(String jobTitle, String task, String requirement, String preferred) { + Classification classification = Classification.create("개발"); + DetailClassification detailClassification = classification + .addMiddleClassification("서버") + .addDetailClassification("백엔드"); + return JobPosting.create( + User.authenticatedPrincipal(1L, "user@example.com", UserRole.USER), + Company.create("테스트 기업", CompanySize.MEDIUM), + detailClassification, + JobPostingProfileColor.DEFAULT, + "테스트 공고", + jobTitle, + task, + requirement, + preferred + ); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncServiceTest.java new file mode 100644 index 0000000..c093fe9 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingSyncServiceTest.java @@ -0,0 +1,155 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.entity.UserRole; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.CohereProperties; +import com.pgvector.PGvector; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JobPostingEmbeddingSyncServiceTest { + + private final JobPostingRepository jobPostingRepository = mock(JobPostingRepository.class); + private final JobPostingEmbeddingService embeddingService = mock(JobPostingEmbeddingService.class); + private final DataSource dataSource = mock(DataSource.class); + private final JobPostingEmbeddingSyncService syncService = new JobPostingEmbeddingSyncService( + jobPostingRepository, + embeddingService, + new CohereProperties( + "test-api-key", + "https://api.cohere.com", + new CohereProperties.Embedding( + "embed-v4.0", + 1024, + Duration.ofSeconds(1), + Duration.ofSeconds(2) + ) + ), + dataSource + ); + + @Test + @DisplayName("syncJobPosting은 단일 채용 공고 embedding을 생성해 PGVector upsert로 저장한다") + void syncJobPosting() throws Exception { + JobPosting jobPosting = jobPosting(10L, "백엔드 개발자"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(jobPostingRepository.findById(10L)).thenReturn(Optional.of(jobPosting)); + when(embeddingService.embedAll(List.of(jobPosting))).thenReturn(List.of(new float[]{0.1f, 0.2f})); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + + try (MockedStatic pgvector = mockStatic(PGvector.class)) { + int processed = syncService.syncJobPosting(10L); + + assertThat(processed).isEqualTo(1); + pgvector.verify(() -> PGvector.registerTypes(connection)); + verify(connection).prepareStatement(org.mockito.ArgumentMatchers.contains("job_posting_embeddings")); + verify(statement).setLong(1, 10L); + verify(statement).setString(2, "embed-v4.0"); + verify(statement).setObject(eq(3), isA(PGvector.class)); + verify(statement).setTimestamp(eq(4), any()); + verify(statement).setTimestamp(eq(5), any()); + verify(statement).addBatch(); + verify(statement).executeBatch(); + } + } + + @Test + @DisplayName("syncAllJobPostings는 id 오름차순 전체 공고를 batch size대로 저장한다") + void syncAllJobPostings() throws Exception { + ReflectionTestUtils.setField(syncService, "batchSize", 1); + JobPosting first = jobPosting(1L, "백엔드 개발자"); + JobPosting second = jobPosting(2L, "데이터 엔지니어"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + when(jobPostingRepository.findAllByOrderByIdAsc()).thenReturn(List.of(first, second)); + when(embeddingService.embedAll(List.of(first))).thenReturn(List.of(new float[]{0.1f})); + when(embeddingService.embedAll(List.of(second))).thenReturn(List.of(new float[]{0.2f})); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + + try (MockedStatic ignored = mockStatic(PGvector.class)) { + int processed = syncService.syncAllJobPostings(); + + assertThat(processed).isEqualTo(2); + verify(embeddingService).embedAll(List.of(first)); + verify(embeddingService).embedAll(List.of(second)); + verify(statement).setLong(1, 1L); + verify(statement).setLong(1, 2L); + verify(statement, times(2)).executeBatch(); + } + } + + @Test + @DisplayName("임베딩 결과 개수가 채용 공고 개수와 다르면 저장하지 않는다") + void mismatchedEmbeddingCount() { + JobPosting jobPosting = jobPosting(10L, "백엔드 개발자"); + when(jobPostingRepository.findById(10L)).thenReturn(Optional.of(jobPosting)); + when(embeddingService.embedAll(List.of(jobPosting))).thenReturn(List.of()); + + assertThatThrownBy(() -> syncService.syncJobPosting(10L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("채용 공고 개수"); + } + + @Test + @DisplayName("존재하지 않는 채용 공고는 명확한 예외를 발생시킨다") + void jobPostingNotFound() { + when(jobPostingRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> syncService.syncJobPosting(999L)) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.JOB_POSTING_NOT_FOUND)); + } + + private JobPosting jobPosting(Long id, String jobTitle) { + Classification classification = Classification.create("개발"); + DetailClassification detailClassification = classification + .addMiddleClassification("서버") + .addDetailClassification("백엔드"); + JobPosting jobPosting = JobPosting.create( + User.authenticatedPrincipal(1L, "user@example.com", UserRole.USER), + Company.create("테스트 기업", CompanySize.MEDIUM), + detailClassification, + JobPostingProfileColor.DEFAULT, + "테스트 공고", + jobTitle, + "Spring Boot API 개발", + "Java, Spring, JPA", + "AWS, Docker" + ); + ReflectionTestUtils.setField(jobPosting, "id", id); + return jobPosting; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilderTest.java new file mode 100644 index 0000000..06c5b37 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingEmbeddingTextBuilderTest.java @@ -0,0 +1,78 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class JobPostingEmbeddingTextBuilderTest { + + private final JobPostingEmbeddingTextBuilder textBuilder = new JobPostingEmbeddingTextBuilder(); + + @Test + @DisplayName("채용 공고 필드를 corpus와 같은 단일 embedding text로 구성한다") + void build() { + JobPosting jobPosting = jobPosting( + "백엔드 개발자", + "Spring Boot API 개발\nMSA\nRabbitMQ", + "Java\nSpring\nJPA", + "AWS\nDocker" + ); + + String text = textBuilder.build(jobPosting); + + assertThat(text).isEqualTo(""" + 직무 + 백엔드 개발자 + + 주요업무 + Spring Boot API 개발 + MSA + RabbitMQ + + 자격요건 + Java + Spring + JPA + + 우대사항 + AWS + Docker"""); + } + + @Test + @DisplayName("blank 필드는 embedding text에서 제외한다") + void skipBlankFields() { + JobPosting jobPosting = jobPosting("백엔드 개발자", "Spring Boot API 개발", "Java", " "); + + String text = textBuilder.build(jobPosting); + + assertThat(text).doesNotContain("우대사항"); + assertThat(text).contains("직무\n백엔드 개발자", "주요업무\nSpring Boot API 개발", "자격요건\nJava"); + } + + private JobPosting jobPosting(String jobTitle, String task, String requirement, String preferred) { + Classification classification = Classification.create("개발"); + DetailClassification detailClassification = classification + .addMiddleClassification("서버") + .addDetailClassification("백엔드"); + return JobPosting.create( + User.authenticatedPrincipal(1L, "user@example.com", com.jobdri.jobdri_api.domain.user.entity.UserRole.USER), + Company.create("테스트 기업", CompanySize.MEDIUM), + detailClassification, + JobPostingProfileColor.DEFAULT, + "테스트 공고", + jobTitle, + task, + requirement, + preferred + ); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalServiceTest.java new file mode 100644 index 0000000..2e1d5bf --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingRetrievalServiceTest.java @@ -0,0 +1,173 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.entity.UserRole; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient; +import com.pgvector.PGvector; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JobPostingRetrievalServiceTest { + + private final JobPostingRepository jobPostingRepository = mock(JobPostingRepository.class); + private final JobPostingEmbeddingTextBuilder textBuilder = new JobPostingEmbeddingTextBuilder(); + private final CohereEmbeddingClient cohereEmbeddingClient = mock(CohereEmbeddingClient.class); + private final DataSource dataSource = mock(DataSource.class); + private final JobPostingRetrievalService retrievalService = new JobPostingRetrievalService( + jobPostingRepository, + textBuilder, + cohereEmbeddingClient, + dataSource + ); + + @Test + @DisplayName("현재 공고 embedding query로 유사 JobPosting Top3를 조회한다") + void findSimilarJobPostingsDefaultTop3() throws Exception { + JobPosting current = jobPosting(10L, 7L, "백엔드 개발자"); + float[] queryVector = new float[]{0.1f, 0.2f}; + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(jobPostingRepository.findById(10L)).thenReturn(Optional.of(current)); + when(cohereEmbeddingClient.embedQuery(textBuilder.build(current))).thenReturn(queryVector); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true, true, false); + when(resultSet.getLong("id")).thenReturn(11L, 12L); + when(resultSet.getString("posting_name")).thenReturn("첫 번째 공고", "두 번째 공고"); + when(resultSet.getString("company_name")).thenReturn("첫 번째 회사", "두 번째 회사"); + when(resultSet.getString("job_title")).thenReturn("백엔드 엔지니어", "서버 개발자"); + when(resultSet.getDouble("distance")).thenReturn(0.12, 0.34); + + try (MockedStatic pgvector = mockStatic(PGvector.class)) { + List results = retrievalService.findSimilarJobPostings(10L); + + assertThat(results).containsExactly( + new JobPostingSimilarityResult(11L, "첫 번째 공고", "첫 번째 회사", "백엔드 엔지니어", 0.88), + new JobPostingSimilarityResult(12L, "두 번째 공고", "두 번째 회사", "서버 개발자", 0.6599999999999999) + ); + verify(cohereEmbeddingClient).embedQuery(textBuilder.build(current)); + pgvector.verify(() -> PGvector.registerTypes(connection)); + verify(statement).setObject(eq(1), isA(PGvector.class)); + verify(statement).setLong(2, 10L); + verify(statement).setLong(3, 7L); + verify(statement).setObject(eq(4), isA(PGvector.class)); + verify(statement).setInt(5, 3); + } + } + + @Test + @DisplayName("limit 인자를 SQL LIMIT에 바인딩하고 현재 공고를 제외한다") + void bindLimitAndExcludeCurrentJobPosting() throws Exception { + JobPosting current = jobPosting(20L, 8L, "데이터 엔지니어"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(jobPostingRepository.findById(20L)).thenReturn(Optional.of(current)); + when(cohereEmbeddingClient.embedQuery(textBuilder.build(current))).thenReturn(new float[]{0.3f}); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + try (MockedStatic ignored = mockStatic(PGvector.class)) { + List results = retrievalService.findSimilarJobPostings(20L, 5); + + assertThat(results).isEmpty(); + verify(connection).prepareStatement(sqlCaptor.capture()); + assertThat(sqlCaptor.getValue()) + .contains("FROM job_posting_embeddings e") + .contains("JOIN job_postings jp ON e.job_posting_id = jp.id") + .contains("jp.id <> ?") + .contains("jp.user_id = ?") + .contains("ORDER BY e.embedding <=> ?") + .contains("LIMIT ?"); + verify(statement).setLong(2, 20L); + verify(statement).setLong(3, 8L); + verify(statement).setInt(5, 5); + } + } + + @Test + @DisplayName("limit가 1보다 작으면 최소 1로 보정한다") + void minimumLimit() throws Exception { + JobPosting current = jobPosting(30L, 9L, "프론트엔드 개발자"); + Connection connection = mock(Connection.class); + PreparedStatement statement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(jobPostingRepository.findById(30L)).thenReturn(Optional.of(current)); + when(cohereEmbeddingClient.embedQuery(textBuilder.build(current))).thenReturn(new float[]{0.4f}); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + + try (MockedStatic ignored = mockStatic(PGvector.class)) { + retrievalService.findSimilarJobPostings(30L, 0); + + verify(statement).setInt(5, 1); + } + } + + @Test + @DisplayName("존재하지 않는 현재 공고는 조회하지 않고 예외 처리한다") + void currentJobPostingNotFound() { + when(jobPostingRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> retrievalService.findSimilarJobPostings(404L)) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GeneralErrorCode.JOB_POSTING_NOT_FOUND)); + } + + private JobPosting jobPosting(Long jobPostingId, Long userId, String jobTitle) { + Classification classification = Classification.create("개발"); + DetailClassification detailClassification = classification + .addMiddleClassification("서버") + .addDetailClassification("백엔드"); + JobPosting jobPosting = JobPosting.create( + User.authenticatedPrincipal(userId, "user-" + userId + "@example.com", UserRole.USER), + Company.create("테스트 회사", CompanySize.MEDIUM), + detailClassification, + JobPostingProfileColor.DEFAULT, + "테스트 공고", + jobTitle, + "Spring Boot API 개발", + "Java, Spring, JPA", + "AWS, Docker" + ); + ReflectionTestUtils.setField(jobPosting, "id", jobPostingId); + return jobPosting; + } +}