LLM 자동번역 이벤트 기반 아키텍처

LLM 자동번역 이벤트 기반 아키텍처

LLM 자동번역을 비동기 이벤트 흐름으로 다루기

1. 들어가며

최근 다국어 문자열 자동번역 기능을 설계하면서, 단순히 LLM을 호출해 번역 결과를 얻는 것보다 더 중요한 문제가 있다는 것을 느꼈습니다. 그것은 LLM 호출을 기존 서비스 요청 흐름 안에서 어떻게 다룰 것인가였습니다. LLM 기반 처리는 일반적인 내부 로직과 다르게 응답 시간이 일정하지 않고, 외부 모델 상태나 네트워크 상황에 영향을 받습니다. 따라서 저장 API 안에서 LLM 호출까지 함께 처리하면 사용자 요청의 응답 시간이 예측하기 어려워집니다.

처음에는 저장과 번역을 하나의 흐름으로 묶는 방식도 생각했습니다. 구현만 보면 저장 요청을 받은 뒤 원문을 저장하고, 바로 LLM을 호출해 번역 결과까지 만든 다음 응답하는 구조가 단순해 보였습니다. 하지만 이 방식은 저장 기능의 책임과 번역 기능의 책임을 강하게 묶습니다. 저장은 빠르게 끝날 수 있는 작업인데, 번역이 늦어지면 저장 응답까지 함께 늦어집니다. 또한 저장은 성공했지만 번역이 실패한 경우를 하나의 요청 안에서 어떻게 표현해야 하는지도 애매해집니다.

그래서 이번 작업에서는 LLM 호출을 사용자 요청 흐름에서 분리하고, 이벤트 기반 비동기 처리로 다루는 방향을 선택했습니다. 핵심은 번역을 단순한 부가 기능으로 붙이는 것이 아니라, 지연 가능성이 있는 외부 연산을 서비스 내부 상태 변경 흐름과 어떻게 분리할 것인가였습니다.

2. 왜 LLM 호출을 요청 흐름에서 분리했는가

LLM 호출은 일반적인 비즈니스 로직과 성격이 다릅니다. 내부 데이터 검증이나 DB 저장은 비교적 예측 가능한 시간 안에 끝나지만, LLM 호출은 모델의 부하, 네트워크 상태, 프롬프트 길이, 대상 언어 수에 따라 응답 시간이 달라집니다. 이런 처리를 동기 요청 안에 넣으면 사용자는 저장과 직접 관련 없는 작업 때문에 응답을 기다리게 됩니다.

또한 장애 전파 관점에서도 문제가 있습니다. 저장 API가 LLM 호출에 직접 의존하면, LLM 장애가 저장 기능의 장애처럼 보일 수 있습니다. 사용자는 데이터를 저장하려고 했을 뿐인데, 외부 모델 호출 실패 때문에 저장 요청 전체가 실패한 것처럼 경험할 수 있습니다. 실제로는 저장과 번역은 서로 다른 책임을 가진 작업인데, 동기 구조에서는 두 책임의 경계가 흐려집니다.

비동기 구조는 이 문제를 완화합니다. 저장 요청은 저장 책임만 수행하고 빠르게 응답합니다. 이후 번역이 필요한 상황을 이벤트로 표현하고, 번역 처리는 별도 흐름에서 수행합니다. 이 방식은 즉시 일관성보다는 최종 일관성에 가깝습니다. 저장 직후 모든 번역 값이 바로 준비되어 있지는 않을 수 있지만, 사용자 요청의 응답성과 시스템의 장애 격리는 더 좋아집니다.

[그림 1. 서비스 응답 시간 비교: 동기 처리와 비동기 처리]

image1.png

이 구조를 선택하면서 중요하게 본 것은 단순히 “빠르게 응답한다”는 점만은 아니었습니다. 더 중요한 것은 긴 시간이 걸릴 수 있는 작업을 명확히 분리하고, 그 작업의 성공과 실패를 별도의 이벤트 흐름으로 다룰 수 있다는 점이었습니다.

3. 이벤트 계약을 작게 유지하기비동기 구조에서 가장 먼저 정리해야 했던 것은 이벤트 계약이었습니다. 이벤트는 단순히 데이터를 전달하는 객체가 아니라, 서로 다른 처리 흐름 사이의 약속입니다. 요청을 발행하는 쪽과 요청을 처리하는 쪽이 같은 호출 스택 안에 있지 않기 때문에, 이벤트에 어떤 정보를 담을지에 따라 전체 구조의 결합도가 달라집니다.

처음에는 이벤트에 가능한 많은 정보를 담는 것이 안전해 보였습니다. 하지만 이벤트 payload가 커질수록 요청 단계와 처리 단계의 책임이 섞였습니다. 요청 이벤트는 번역을 수행하기 위한 최소 정보에 집중해야 하고, 완료 이벤트는 결과를 반영하기 위한 정보에 집중해야 했습니다. 그래서 요청 이벤트와 완료 이벤트의 역할을 분리했습니다.

요청 이벤트에는 어떤 서비스의 어떤 엔티티, 어떤 필드에 대해 번역이 필요한지, 기준 언어와 다국어 문자열 정보, 강제 재번역 여부를 담았습니다. 반대로 완료 이벤트에는 번역 결과를 반영하기 위해 필요한 식별 정보와 번역된 LangStrings만 담도록 정리했습니다. 이렇게 나누면 요청 이벤트는 “무엇을 번역할 것인가”에 집중하고, 완료 이벤트는 “어떤 결과를 반영할 것인가”에 집중하게 됩니다.

이벤트 계약을 작게 유지하면 변경에도 유리합니다. 번역 내부 구현이 Mock에서 실제 LLM으로 바뀌어도 요청 이벤트의 의미는 유지될 수 있습니다. 번역 결과를 만드는 방식이 바뀌어도 완료 이벤트가 표현하는 결과 계약이 유지되면, 결과를 반영하는 쪽의 변경 범위를 줄일 수 있습니다.

4. 전체 데이터가 아니라 patch로 다루기

번역 결과를 어떻게 표현할지도 중요한 결정이었습니다. 한 가지 방법은 번역 완료 후 전체 다국어 문자열을 다시 반환하는 것입니다. 하지만 이 방식은 원본 데이터와 번역 결과의 경계를 흐릴 수 있습니다. 특히 기존에 사람이 입력한 값이 있는 경우, 전체 데이터를 다시 반영하면 의도하지 않은 덮어쓰기가 발생할 수 있습니다.

그래서 번역 결과는 전체 데이터가 아니라 보완해야 하는 값의 집합, 즉 패치(patch)에 가깝게 다루는 방향이 더 적절하다고 판단했습니다. 비어 있는 언어 값만 번역 대상으로 삼고, 이미 값이 있는 언어는 기본적으로 제외했습니다. 기존 번역을 다시 생성해야 하는 경우에는 별도의 forceUpdate 옵션을 통해 명시적으로 재번역하도록 했습니다.

이 정책은 단순한 비즈니스 조건이라기보다 데이터 변경의 안전장치에 가까웠습니다. 자동번역은 편리하지만, 사람이 직접 다듬어 둔 문구를 기계 번역 결과로 덮어쓰면 오히려 품질이 떨어질 수 있습니다. 따라서 자동번역 결과는 “항상 전체를 대체하는 데이터”가 아니라 “현재 부족한 값을 보완하는 데이터”로 해석하는 것이 안전했습니다.

이 관점에서 patch 방식은 이벤트 기반 처리와도 잘 맞았습니다. 요청 이벤트는 원본 상태를 기준으로 번역 대상을 계산하고, 완료 이벤트는 반영 가능한 결과만 전달합니다. 결과를 반영하는 쪽은 전체 상태를 다시 해석하기보다, 전달받은 번역 결과를 필요한 위치에 적용하면 됩니다.

5. LLM 응답은 검증 가능한 데이터가 아니다

실제 LLM을 연결하면서 가장 크게 느낀 점은 LLM 응답을 그대로 신뢰하면 안 된다는 것이었습니다. LLM은 자연어를 생성하는 데 강하지만, 서비스 데이터가 요구하는 형식을 항상 정확히 지켜주지는 않습니다. 번역문 자체는 자연스러워 보여도, 데이터 구조 관점에서는 깨진 응답일 수 있습니다.

예를 들어 “안녕하세요, {{name}}님.”이라는 문장이 있을 때 {{name}}은 번역 대상이 아닙니다. 사용자 이름이 들어갈 자리이므로 번역 결과에서도 그대로 유지되어야 합니다. URL, 이메일, HTML 태그, 포맷 토큰도 마찬가지입니다. 이런 값이 사라지거나 바뀌면 번역 품질 문제가 아니라 서비스 데이터 오류가 됩니다.

줄바꿈도 중요한 검증 대상이었습니다. 화면에 표시되는 라벨 문자열은 단순한 한 줄 문장만 있는 것이 아니라, 여러 줄 안내 문구를 포함할 수 있습니다. 원문에서 줄바꿈으로 의미 단위가 나뉘어 있는데 번역문에서 줄바꿈이 사라지면 화면 구성이나 문맥이 달라질 수 있습니다. 그래서 원문과 번역문의 줄바꿈 개수를 비교하고, 다르면 구조가 깨진 응답으로 보았습니다.

언어별 문자 체계도 별도로 다뤄야 했습니다. 예를 들어 우즈벡어는 라틴 문자와 키릴 문자를 모두 고려해야 합니다. 단순히 Uzbek으로 번역하라고 하면 원하는 문자 체계와 다른 결과가 나올 수 있습니다. 그래서 uz-Latn, uz-Cyrl처럼 문자 체계를 포함한 언어 코드를 promptLabel로 변환하고, 응답에서도 해당 문자 체계가 맞는지 확인했습니다.

이 과정을 통해 LLM 기능에서 중요한 것은 좋은 프롬프트만이 아니라는 점을 느꼈습니다. 프롬프트는 원하는 결과를 유도하지만, 서비스 코드는 그 결과가 실제 데이터 규칙을 만족하는지 검증해야 합니다. LLM 응답은 곧바로 신뢰 가능한 데이터가 아니라, 검증을 통과해야만 반영할 수 있는 후보 데이터에 가깝습니다.

6. 실패를 분류하고 재시도 기준을 나누기

비동기 구조에서는 실패를 어떻게 다룰지도 중요했습니다. 동기 API라면 실패를 HTTP 응답으로 내려줄 수 있지만, 이벤트 기반 처리에서는 요청한 시점과 처리한 시점이 분리됩니다. 따라서 실패도 이벤트 흐름 안에서 표현할 수 있어야 했습니다.

모든 실패를 같은 방식으로 다루면 운영이 어려워집니다. 네트워크 문제나 LLM 타임아웃처럼 일시적인 외부 장애는 재시도하면 성공할 수 있습니다. 반면 기준 언어가 없거나 원문이 비어 있거나, LLM 응답이 구조 검증을 통과하지 못하는 경우는 단순 재시도로 해결되기 어렵습니다. 그래서 실패를 재시도 가능한(retryable) 오류와 재시도 불가능한 오류(non-retryable) 오류로 나누었습니다.

LLM 요청 실패나 timeout은 retryable 오류로 보고 다시 던져 메시지 처리 시스템의 재시도 흐름에 맡겼습니다. 반대로 필수 값 누락, 원문 없음, 응답 형식 오류처럼 데이터나 계약 자체가 맞지 않는 경우는 실패 이벤트로 발행했습니다. 이렇게 나누면 일시적인 외부 장애와 데이터 계약 문제를 다른 방식으로 다룰 수 있습니다.

실패 이벤트를 남기면 후속 처리도 명확해집니다. 실패한 엔티티와 필드, 오류 코드, 재시도 가능 여부를 이벤트로 남길 수 있고, 이후 운영 도구나 별도 보정 흐름에서 이를 활용할 수 있습니다. 비동기 구조에서는 실패를 단순 로그로만 남기기보다, 시스템이 이해할 수 있는 이벤트로 표현하는 것이 더 안정적이라고 느꼈습니다.

7. Mock 검증에서 Live LLM 검증까지

초기 단계에서는 실제 LLM을 바로 연결하기보다 Mock 기반으로 이벤트 흐름을 먼저 검증했습니다. 실제 모델을 바로 붙이면 이벤트 계약 문제인지, 응답 파싱 문제인지, 모델 호출 문제인지 원인을 한 번에 구분하기 어렵습니다. 그래서 먼저 Mock을 통해 요청 이벤트가 처리 흐름으로 전달되는지, 완료 이벤트가 발행되는지, 번역 대상 계산이 의도대로 동작하는지를 확인했습니다.

그다음 실제 LLM을 연결해 번역 품질과 응답 검증 로직을 확인했습니다. 이때 단순히 번역 결과가 비어 있지 않은지만 보지 않았습니다. 플레이스홀더가 유지되는지, 줄바꿈이 보존되는지, 문자 체계가 맞는지, baseLangCode를 생략했을 때 defaultLangCode가 기준 언어로 사용되는지, forceUpdate가 기존 값 재번역에 영향을 주는지를 함께 확인했습니다.

이 테스트 과정을 통해 “LLM이 응답했다”와 “서비스 데이터로 안전하게 반영할 수 있다”는 서로 다른 문제라는 점을 다시 확인했습니다. LLM 연동 테스트는 모델 호출 성공 여부만 보는 것이 아니라, 시스템이 요구하는 데이터 계약을 결과가 만족하는지까지 확인해야 했습니다.

8. 마무리

이번 작업을 통해 LLM 기능을 서비스에 붙일 때 중요한 것은 모델을 호출하는 코드 자체만이 아니라는 점을 느꼈습니다. 지연 시간이 긴 작업을 요청 흐름에서 분리하고, 이벤트 계약을 작게 유지하며, LLM 응답을 검증 가능한 데이터로 바꾸고, 실패를 재시도 가능한 것과 그렇지 않은 것으로 나누는 과정이 함께 필요했습니다.

결국 LLM 자동번역 기능의 핵심은 “LLM이 번역하게 하는 것”이 아니라, LLM의 결과를 서비스 데이터 흐름 안에서 안전하게 다루는 구조를 만드는 것이었습니다. 이번 설계는 LLM 호출을 이벤트 기반 비동기 흐름으로 분리하고, 비결정적인 응답을 검증과 실패 처리, 관측 가능성 안에서 다뤄본 시도였습니다.

jyyou

[참고문헌]

Microsoft Learn, 비동기 요청-응답 패턴

https://learn.microsoft.com/ko-kr/azure/architecture/patterns/asynchronous-request-reply

IBM, 이벤트 기반 아키텍처란?

https://www.ibm.com/kr-ko/think/topics/event-driven-architecture

카카오페이 기술 블로그, 이벤트 드리븐 적재적소에 사용하기

https://tech.kakaopay.com/post/event-driven-architecture/

Site footer