포스트

Embedding API 성능 최적화

I. 문제 상황

Spring Batch를 활용해 강의 데이터를 처리하는 배치 작업을 구현했다. 약 5000여건의 강의 데이터를 Chunk Size 1000으로, MySQL 및 Opensearch에 저장하는 구조로 구성했다. 특히 Opensearch에 저장되는 데이터 중 제목, 설명, 난이도 등을 OpenAI Embedding API로 벡터화한 후 저장했다.

1
2
3
4
5
강의 데이터 조회 (1000건)
  ↓
Mapper에서 Document 변환 (강의별 임베딩 요청)
  ↓
Opensearch 저장 (1000건)

구현 후 로그를 확인해보니 각 chunk를 처리하는 데 약 7분이 소요되고 있었다.

1
2
3
2025-11-25T15:21:02.269+09:00 ... Executing SQL batch update
  ↓ (6분 45초 경과)
2025-11-25T15:27:47.177+09:00 ... Executing SQL batch update

당장은 5000건 정도의 강의라 처리하는 데 무리가 없지만, 강의 숫자가 늘어날 때마다 병목이 심화될 것이 분명했다.

II. 병목 지점 분석

문제의 원인은 CourseMapper의 구조에 있었다.

1
2
3
4
5
6
7
8
9
public CourseDocument toDocument(Course course) {
    return CourseDocument.builder()
            .id(course.id().id())
            .text(course.toString())
            .textEmbedding(embeddingModel.embed(course.toString()))  // 개별 호출
            .createdAt(course.createdAt())
            .updatedAt(course.updatedAt())
            .build();
}

toDocument() 메서드가 호출될 때마다 embeddingModel.embed()가 실행되고, 이는 내부적으로 OpenAI API를 호출한다. 1000개의 강의 데이터라면 1000번의 API 호출이 발생한다,

1
2
3
4
5
Course 1 → toDocument() → embed() → API 호출 1
Course 2 → toDocument() → embed() → API 호출 2
Course 3 → toDocument() → embed() → API 호출 3
...
(1000번 반복)

각 API 호출은 평균 400~500ms의 응답 시간을 가지며, 여기에 네트워크 오버헤드가 더해지면 전체 처리 시간이 기하급수적으로 증가한다.

III. 해결 방안: Batch Embedding API 활용

문제를 해결하기 위한 접근은 명확했다. 개별 호출을 배치 호출로 전환하는 것이다.

OpenAI Embedding API는 단일 요청에서 여러 텍스트를 배열로 전달받아 한 번에 처리할 수 있다. 다시 말해, 1000번의 API 호출을 몇 번의 배치 요청으로 줄일 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
Before: 개별 호출
강의 1 → API 호출 → 임베딩 1
강의 2 → API 호출 → 임베딩 2
강의 3 → API 호출 → 임베딩 3
...
(1000번 반복, 약 7분 소요)

After: 배치 호출
강의 1~200 → API 호출 → 임베딩 1~200
강의 201~400 → API 호출 → 임베딩 201~400
...
(5번으로 단축, 약 13초 소요)

최적화 전략은 다음과 같다.

  1. 매핑 메서드 변경: toDocument() 대신 toDocuments() 사용
  2. 배치 그룹핑: 여러 Course를 묶어서 한 번에 처리
  3. 배치 API 호출: 그룹 단위로 OpenAI Embedding API 요청
  4. 결과 매핑: 반환된 임베딩 결과를 원본 순서대로 매핑

IV. 구현 상세

1. 기존 구조 (Before)

기존에는 Repository에서 saveAll()을 호출할 때, 내부적으로 각 Course마다 toDocument()가 호출되는 구조였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Repository
@RequiredArgsConstructor
public class CourseRepositoryImpl implements CourseRepository {
    private final CourseJpaRepository courseJpaRepository;
    private final CourseDocumentRepository courseDocumentRepository;
    private final CourseMapper mapper;

    @Override
    public List<Course> saveAll(List<Course> courses) {
        List<Course> savedCourses = courseJpaRepository.saveAll(courses);
        
        // 각 Course마다 toDocument() 호출 → 1000번의 API 호출
        List<CourseDocument> documents = savedCourses.stream()
            .map(mapper::toDocument)
            .collect(Collectors.toList());
        
        courseDocumentRepository.saveAll(documents);
        return savedCourses;
    }
}

이 구조의 문제점은 map(mapper::toDocument)가 스트림의 각 요소마다 실행되면서, 내부의 embeddingModel.embed()가 개별적으로 호출된다는 것이다.

2. 개선된 구조 (After)

배치 처리를 위해 toDocuments() 메서드를 새로 구현하고, Repository에서 이를 호출하도록 변경했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Repository
@RequiredArgsConstructor
public class CourseRepositoryImpl implements CourseRepository {
    private final CourseJpaRepository courseJpaRepository;
    private final CourseDocumentRepository courseDocumentRepository;
    private final CourseMapper mapper;

    @Override
    public List<Course> saveAll(List<Course> courses) {
        List<Course> savedCourses = courseJpaRepository.saveAll(courses);
        
        // toDocuments() 메소드 단독으로 매핑 수행
        courseDocumentRepository.saveAll(mapper.toDocuments(savedCourses));
        
        return savedCourses;
    }
}

핵심은 toDocuments() 메서드가 전체 리스트를 한 번에 받아서 배치 처리한다는 점이다. 이제 Mapper의 구현을 살펴보자.

3. 배치 임베딩 Mapper 구현

CourseMapper에서 배치 처리 로직을 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Component
@RequiredArgsConstructor
public class CourseMapper {
    private final EmbeddingModel embeddingModel;
    private final SkillMapper skillMapper;

    public List<CourseDocument> toDocuments(List<Course> courses) {
        int batchSize = 200;  // OpenAI API 권장: 100~500, 최대 2048
        
        // 1. 모든 임베딩을 배치로 수집
        List<float[]> embeddings = new ArrayList<>();
        for (int i = 0; i < courses.size(); i += batchSize) {
            int end = Math.min(i + batchSize, courses.size());
            List<Course> batch = courses.subList(i, end);
            
            // 텍스트 추출
            List<String> texts = batch.stream()
                .map(Course::toString)
                .toList();
            
            // 배치 API 호출
            embeddings.addAll(embeddingModel.embed(texts));
        }
        
        // 2. Course와 임베딩 결과 매핑
        List<CourseDocument> courseDocuments = new ArrayList<>();
        for (int i = 0; i < courses.size(); i++) {
            Course course = courses.get(i);
            courseDocuments.add(CourseDocument.builder()
                    .id(course.id().id())
                    .text(course.toString())
                    .textEmbedding(embeddings.get(i))
                    .createdAt(course.createdAt())
                    .updatedAt(course.updatedAt())
                    .build());
        }
        
        return courseDocuments;
    }
}

3-1. 배치 분할

1000개의 텍스트를 한 번에 보내지 않고, 200개씩 나눠서 보낸다. OpenAI API의 단일 요청당 입력 크기 제한은 2048개이지만, 너무 크면 당연히 TimeOut 등이 발생할 여지가 있어 200개 단위로 분할했다. (OpenAI 공식 문서에서는 100~500개 단위의 배치 크기를 권장하며, 테스트 결과 실제로 200개까진 무리가 없었다.)

1
2
3
4
5
6
7
1000개 Course
  ↓
[0-199]   → API 호출 1 (200개 텍스트)
[200-399] → API 호출 2 (200개 텍스트)
[400-599] → API 호출 3 (200개 텍스트)
[600-799] → API 호출 4 (200개 텍스트)
[800-999] → API 호출 5 (200개 텍스트)

3-2. 순서 보장

배치 API 호출에서 가장 중요한 것은 입력 순서와 출력 순서가 일치하도록 보장하는 것이다. embeddingModel.embed(texts)는 입력된 텍스트 리스트의 순서대로 임베딩 결과를 반환한다.

1
2
3
4
5
6
7
List<String> texts = batch.stream()
    .map(Course::toString)
    .toList();

// 입력: ["Course A", "Course B", "Course C"]
List<float[]> batchEmbeddings = embeddingModel.embed(texts);
// 출력: [embedding_A, embedding_B, embedding_C]

4. 단건 메서드는 유지

기존의 toDocument() 메서드는 단일 Course를 처리해야 하는 경우 활용하기 위해 삭제 하지 않았다. (예: 실시간 강의 등록)

1
2
3
4
5
6
7
8
9
public CourseDocument toDocument(Course course) {
    return CourseDocument.builder()
            .id(course.id().id())
            .text(course.toString())
            .textEmbedding(embeddingModel.embed(course.toString()))
            .createdAt(course.createdAt())
            .updatedAt(course.updatedAt())
            .build();
}

다만, 대량 데이터 처리 시에는 반드시 toDocuments()를 사용하도록 Repository 레벨에서 제어한다.

IV. 성능 개선 결과

1. Before: 개별 호출

1
2
3
4
5
6
7
1000건 처리 시간: 약 7분 (420초)
  - API 호출 횟수: 1000번
  - 평균 API 응답 시간: 약 420ms

2025-11-25T15:21:02.269+09:00 ... Executing SQL batch update
  ↓ (6분 45초 경과)
2025-11-25T15:27:47.177+09:00 ... Executing SQL batch update

2. After: 배치 호출

1
2
3
4
5
6
7
1000건 처리 시간: 약 13초
  - API 호출 횟수: 5번 (200개씩)
  - 평균 API 응답 시간: 약 2.6초 (200개 일괄 처리)

2025-11-25T21:11:00.191+09:00 ... Executing SQL batch update
  ↓ (13초 경과)
2025-11-25T21:11:13.222+09:00 ... Executing SQL batch update

3. 개선 효과 분석

항목BeforeAfter개선율
1000건 처리 시간420초13초96.9% 감소
API 호출 횟수1000번5번99.5% 감소
10만 건 예상 시간700분 (11.6시간)21.7분96.9% 감소

V. 참고

  1. OpenAI Embeddings API Documentation
  2. OpenAI API Rate Limits
  3. Spring AI Embedding Models
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.