[Kiwi 형태소 분석기] 메모리 누수(Memory Leak)와 성능 최적화 리팩터링 기록
1. 서론
작업중인 LLM 챗봇 서비스에서 Kiwi 형태소 분석기를 사용하여 사용자의 한국어 입력 문장을 형태소 단위로 분석하고 특정 품사만 추출하여 검색 및 로그 저장에 활용하고 있었다.
처음에는 단순히 요청이 들어올 때마다 Kiwi.init()을 호출하고 tokenize()를 실행한 뒤 바로 결과를 반환하는 방식으로 구현했다.
public List<String> morphAnalyzeToList(String sendText) throws Exception {
Kiwi kiwi = Kiwi.init(getModelPath());
List<String> sbList = new ArrayList<>();
KiwiBuilder builder = new KiwiBuilder(getModelPath());
kiwi = builder.build();
Kiwi.Token[] tokens = kiwi.tokenize(sendText, Kiwi.Match.allWithNormalizing);
for (Kiwi.Token token : tokens) {
sbList.add(token.form);
}
return sbList;
}
이 방식은 처음엔 잘 동작했다. 그러나 트래픽이 많아지고 서버가 장기 실행되는 환경에서 몇 가지 문제가 발생하기 시작했다.
2. 문제 상황
2.1 장기 실행 시 메모리 증가
Kiwi는 내부적으로 모델 파일을 메모리에 매핑(MMAP) 하고, 형태소 사전 데이터를 네이티브 메모리에 로드한다.
요청마다 Kiwi 인스턴스를 생성했지만 close()를 호출하지 않았기 때문에 가비지 콜렉터(GC)가 실제로 수거하기 전까지 모델 핸들이 계속 남아있는 상태가 지속되었다.
결과적으로 다음과 같은 문제가 발생/예상되어 코드 리팩토링을 하기로 했다:
- 네이티브 메모리 점유율이 계속 증가 → 잦은 서버 다운
- OS 레벨에서 파일 디스크립터가 닫히지 않는 문제 발생
- 장기 실행 후에는
Too many open files경고 가능성
2.2 성능 저하
또 다른 문제는 요청마다 Kiwi 모델을 매번 다시 초기화했다는 점이다.
Kiwi kiwi = Kiwi.init(getModelPath());
KiwiBuilder builder = new KiwiBuilder(getModelPath());
kiwi = builder.build(); // 모델 로드 & 사전 빌드 → 비용 큼
모델 로딩은 디스크 I/O + 사전 초기화가 포함되기 때문에 요청이 많아질수록 응답 속도가 떨어졌다.
즉,
- 분석기 초기화 오버헤드 → 요청당 불필요한 지연 발생
- 동일 모델 파일인데 매번 로드 → 캐싱 효과 없음
3. 1차 개선 – kiwi.close() 명시적 호출
시니어 개발자가 처음 한 개선은 리소스 누수(Memory Leak)를 막는 것이었다.
형태소 분석 후에는 close()를 호출하면 네이티브 메모리와 파일 핸들이 해제된다.
그래서 morphAnalyzeToList()와 morphAnalyzeToString() 각 함수의 마지막에 kiwi.close()를 추가했다.
Kiwi.Token[] tokens = kiwi.tokenize(sendText, Kiwi.Match.allWithNormalizing);
// ...
kiwi.close(); // 명시적으로 리소스 해제
return sbList;
이렇게 하면 아래 두 문제가 해결된다:
- 메모리 누수 방지 ✅
- 파일 디스크립터 정상 해제 ✅
하지만 여전히 요청마다 모델 로딩 비용은 해결되지 않았다. 그래서 직접 다른 방법을 찾아보기로 했다.
4. 2차 개선 – 싱글톤(Singleton) 패턴 도입
리소스 누수는 해결됐지만, 여전히 트래픽이 많은 환경에서는 초기화 비용이 반복되었다.
사실 Kiwi 모델은 내부 상태를 변경하지 않는 불변 객체(Immutable)이며 동일한 모델을 여러 요청에서 안전하게 재사용할 수 있었다.
그래서 싱글톤 패턴으로 Kiwi 인스턴스를 재사용하도록 리팩터링했다.
- 싱글톤 패턴: 애플리케이션 전체에서 하나의 인스턴스만 존재하도록 보장하는 디자인 패턴
→ 매번 new Kiwi() + close() 하면 비효율적이고 형태소 분석기의 모델 로딩 비용(디스크 읽기 + 메모리 매핑)이 컸기 때문에 한 번 로드하면 여러 요청에서 안전하게 tokenize() 호출 가능 (thread-safe)하도록 수정하려는 목적에서 도입했다.
4.1 지연 초기화(Lazy Init) + 싱글톤 Kiwi
@Component
@Slf4j
public class MorphUtil {
private final Environment env;
private final ResourceLoader resourceLoader;
private volatile Kiwi kiwi; // 멀티스레드 안전 Lazy Init
public MorphUtil(Environment env, ResourceLoader resourceLoader) {
this.env = env;
this.resourceLoader = resourceLoader;
}
/**
최초 요청 시 한 번만 모델 로드
* Double-Checked Locking + volatile로 Thread-Safe한 지연 초기화
* 1. volatile: 메모리 가시성 보장
* 2. synchronized: 초기화 구간의 동시 접근 방지
* 3. 이중 체크: 불필요한 동기화 오버헤드 최소화
*/
private Kiwi getKiwiInstance() throws IOException {
if (kiwi == null) { // 1차 체크 (동기화 없이 빠른 확인)
synchronized (this) { // 동기화 구간 진입
if (kiwi == null) { // 2차 체크 (동기화 안에서 재확인)
String modelPath = getModelPath();
KiwiBuilder builder = new KiwiBuilder(modelPath);
kiwi = builder.build(); // volatile 변수에 할당
log.info("Kiwi 모델 로드 완료: {}", modelPath);
}
}
}
return kiwi; // volatile 읽기로 최신 값 보장
}
public List<String> morphAnalyzeToList(String sendText) throws Exception {
Kiwi kiwi = getKiwiInstance(); // 매 요청에서 재사용
List<String> result = new ArrayList<>();
Kiwi.Token[] tokens = kiwi.tokenize(sendText, Kiwi.Match.allWithNormalizing);
for (Kiwi.Token token : tokens) {
result.add(token.form);
}
return result;
}
@PreDestroy
public void destroy() {
if (kiwi != null) {
kiwi.close();
log.info("Kiwi 모델 정상 해제");
}
}
}
*Lazy Init: 객체를 바로 만들지 않고, 정말 필요할 때 최초 1번만 생성하는 방식
-
Eager Init (즉시 초기화)
→ 프로그램 시작할 때 무조건 만들어둠
-
Lazy Init (지연 초기화)
→ “필요할 때”까지 안 만들고 처음 호출될 때만 생성, 이후엔 재사용
** Volatile의 역할 private volatile Kiwi kiwi; :
- 가시성(Visibility) 보장: 한 스레드에서 변경한 값이 다른 스레드에서 즉시 보임
- 명령어 재배열 방지: JVM이 최적화를 위해 코드 순서를 바꾸는 것을 막음
- Double-Checked Locking 패턴의 안전성 확보
4.2 개선 효과
✅ 모델 로딩 비용 1회로 축소
- 최초 요청 시 한 번만 모델 로드
- 이후 요청에서는
tokenize()만 수행 → 훨씬 빠름
✅ 메모리 관리 안정화
- close()는 서버 종료 시 한 번만 호출
- 네이티브 메모리 및 파일 핸들 릴리즈 확실
✅ 트래픽 증가에도 안정적
- 요청당 초기화 비용 사라짐 → 응답 지연 감소
5. Before & After 비교
| 단계 | 방식 | 메모리 | 초기화 비용 | 문제 |
|---|---|---|---|---|
| 초기 | 요청마다 Kiwi.init() |
증가함 (GC 의존) | 매번 발생 | 메모리 릭, 응답 지연 |
| 1차 개선 | 요청마다 close() 추가 |
안정화됨 | 매번 발생 | 성능 문제 남아있음 |
| 2차 개선 | 싱글톤 & Lazy Init | 안정화됨 | 최초 1회만 | ✅ 최적화 완료 |
6. 결론
- 1단계:
close()없이 쓰면 메모리 누수가 발생 - 2단계: 요청마다
close()추가로 누수는 막을 수 있지만 여전히 초기화 비용이 큼 - 3단계: 싱글톤 & Lazy Init으로 최적화 → 메모리 안정성과 성능 모두 개선
결과적으로 Kiwi 형태소 분석기는 모델 로딩 비용이 큰 불변 리소스이기 때문에 서버 전역에서 재사용 가능한 싱글톤으로 관리하는 것이 가장 효율적이다.
이 과정을 통해, 형태소 분석처럼 고정된 리소스를 사용하는 네이티브 라이브러리는 요청마다 초기화하기보다는 캐싱 & 재사용 전략을 반드시 고려해야 한다는 교훈을 얻었다.