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초 소요)
최적화 전략은 다음과 같다.
- 매핑 메서드 변경:
toDocument()대신toDocuments()사용 - 배치 그룹핑: 여러 Course를 묶어서 한 번에 처리
- 배치 API 호출: 그룹 단위로 OpenAI Embedding API 요청
- 결과 매핑: 반환된 임베딩 결과를 원본 순서대로 매핑
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. 개선 효과 분석
| 항목 | Before | After | 개선율 |
|---|---|---|---|
| 1000건 처리 시간 | 420초 | 13초 | 96.9% 감소 |
| API 호출 횟수 | 1000번 | 5번 | 99.5% 감소 |
| 10만 건 예상 시간 | 700분 (11.6시간) | 21.7분 | 96.9% 감소 |