LLM 자동번역: 중첩 객체와 목록 구조 확장

LLM 자동번역: 중첩 객체와 목록 구조 확장

1. 들어가며

지난 작업에서는 LLM 자동번역 기능을 비동기 이벤트 흐름으로 분리하는 구조를 다뤘습니다. 저장 요청 안에서 LLM을 직접 호출하지 않고, 번역이 필요한 상황을 이벤트로 발행한 뒤 별도 흐름에서 번역을 수행하도록 구성했습니다. 이번 작업은 그 후속 확장에 가깝습니다.

기존 구조는 엔티티가 직접 가진 다국어 필드를 번역하는 데 초점을 맞추고 있었습니다. 하지만 실제 도메인 모델에서는 다국어 문자열이 항상 엔티티의 직접 필드에만 존재하지 않습니다. 엔티티 내부의 값 객체(Value Object)에 다국어 필드가 들어가거나, 값 객체가 목록 형태로 존재하는 경우도 많습니다.

이 글에서는 LLM 자동번역 대상을 중첩 객체와 목록 구조까지 확장하면서 생긴 기술적 고민을 정리합니다. 핵심은 단순히 번역 가능한 위치를 늘리는 것이 아니라, 식별자가 없는 목록 구조를 어떻게 안전하게 다룰지, 그리고 공통 기능의 사용자 API를 어떻게 단순하게 유지할지였습니다.

2. 기존 구조의 한계

기존 자동번역 요청 방식은 비교적 단순했습니다. 서비스는 번역 대상 엔티티와 필드명을 Helper에 전달하고, Helper는 이를 기반으로 번역 요청 이벤트를 발행합니다. 예를 들어 requestTranslation(entity, "title") 같은 형태입니다.

이 방식은 title, description처럼 엔티티가 직접 가진 필드에는 잘 맞습니다. 완료 이벤트가 도착하면 엔티티 이름, 엔티티 ID, 필드명을 기준으로 대상 엔티티를 조회하고, 해당 필드에 번역 결과를 반영할 수 있기 때문입니다.

문제는 번역 대상이 detail.name이나 items[0].name처럼 더 깊은 위치에 있을 때 발생합니다. 단순히 문자열 경로를 그대로 사용자에게 전달하게 만들 수도 있지만, 이 방식은 공통 기능의 사용성을 떨어뜨릴 수 있습니다. 사용자가 내부 field path 규칙을 직접 알아야 하고, 목록 구조에서는 index까지 지정해야 합니다.

특히 목록의 index는 안정적인 식별자라고 보기 어렵습니다. 요청 시점의 items[0]이 완료 이벤트 반영 시점에도 같은 객체라는 보장이 없다면, 잘못된 대상에 번역 결과가 반영될 수 있습니다. 따라서 기존 fieldName에 점 표기법과 index 표기법을 단순히 허용하는 방식은 지양했습니다.

image1.png

[그림 1. 직접 필드 번역과 중첩 객체/목록 번역 비교]

3. 중첩 객체와 목록 구조에서 생긴 고민

중첩 객체 번역을 확장하면서 가장 먼저 고민한 것은 값 객체의 식별 문제였습니다. 엔티티는 보통 고유한 ID를 가지므로 완료 이벤트가 도착했을 때 다시 조회할 수 있습니다. 반면 엔티티 내부의 값 객체는 독립적인 식별자를 가지지 않는 경우가 많습니다.

단일 값 객체는 비교적 단순합니다. 예를 들어 article.detail.name처럼 하나의 경로로 표현할 수 있습니다. 하지만 값 객체 목록은 더 조심해야 합니다. article.items[0].name, article.items[1].name처럼 index가 개입되기 때문입니다.

목록의 특정 index만 번역하도록 API를 제공할 수도 있습니다. 그러나 이 경우 사용자는 index를 직접 전달해야 하고, 목록 순서가 바뀌면 번역 결과가 다른 요소에 반영될 수 있습니다. 값 객체에 별도 key가 없다면 index는 기술적으로 편리한 위치 정보일 뿐, 안정적인 식별자라고 보기 어렵습니다.

그래서 이번 범위에서는 특정 index만 번역하는 API를 제공하지 않는 방향을 선택했습니다. 목록 필드가 들어오면 해당 목록 전체를 번역 대상으로 보고, 내부에서 각 요소를 순회하며 번역 대상을 계산하도록 했습니다.

또 하나의 고민은 여러 다국어 필드 처리였습니다. 실제 값 객체에는 name 하나만 있는 것이 아니라 name, description, summary처럼 여러 다국어 필드가 존재할 수 있습니다. 따라서 단일 중첩 객체와 목록 구조 모두에서 하나의 필드뿐 아니라 여러 필드를 요청할 수 있도록 범위를 정리했습니다.

  • 단일 중첩 객체 내부의 단일 다국어 필드

  • 단일 중첩 객체 내부의 여러 다국어 필드

  • 목록 구조 내부의 단일 다국어 필드

  • 목록 구조 내부의 여러 다국어 필드

4. 사용자 API를 단순하게 유지하기

이번 기능에서 중요하게 본 것은 사용자 API의 단순성이었습니다. 공통 기능은 여러 서비스에서 사용되기 때문에, 사용하는 개발자가 내부 이벤트 구조나 Reflection 경로 규칙을 자세히 알아야 한다면 좋은 API라고 보기 어렵습니다.

그래서 중첩 객체 번역을 위한 별도 요청 메서드를 두었습니다. 사용자는 엔티티 객체와 중첩 필드명, 그리고 그 내부의 다국어 필드명만 전달합니다. 예를 들어 requestNestedTranslation(entity, "items", "name")처럼 호출하면 됩니다.

이 API의 핵심은 사용자가 items[0].name 같은 내부 path를 직접 만들지 않는다는 점입니다. 사용자는 엔티티가 가진 필드명인 items와, 그 내부 객체가 가진 다국어 필드명인 name만 전달합니다.

단일 객체인지 목록인지는 Helper가 판단합니다. Helper는 전달받은 객체를 기준으로 대상 필드를 읽고, 해당 필드가 단일 객체이면 내부 다국어 필드를 하나의 번역 대상으로 계산합니다. 목록이면 목록 전체를 순회하면서 각 요소의 내부 다국어 필드를 번역 대상으로 계산합니다.

이렇게 하면 내부 처리는 복잡해지지만, 사용하는 쪽의 코드는 단순하게 유지됩니다. 공통 기능의 복잡도는 내부에서 감당하고, 사용자에게는 필요한 최소 입력만 요구하는 구조입니다.

5. 이벤트 계약과 결과 반영 방식 확장

사용자 API를 단순하게 유지하려면 내부 이벤트 계약도 함께 확장해야 했습니다. 기존 번역 요청 이벤트는 엔티티의 직접 필드를 기준으로 설계되어 있었습니다. 요청 이벤트는 어떤 서비스의 어떤 엔티티, 어떤 필드를 번역할 것인지 표현합니다.

하지만 중첩 객체와 목록 구조에서는 하나의 요청이 여러 번역 대상으로 확장될 수 있습니다. 예를 들어 items 목록의 name과 description을 번역하면 내부적으로 items[0].name, items[0].description, items[1].name, items[1].description처럼 여러 대상이 만들어집니다.

따라서 중첩 번역 요청은 하나의 필드명만으로 표현하기 어렵습니다. 요청 안에 여러 번역 대상을 담을 수 있어야 하고, 각 대상이 어느 위치의 어떤 다국어 필드인지 알 수 있어야 합니다. 이를 위해 중첩 번역 요청용 이벤트와 대상 객체를 별도로 두었습니다.

완료 이벤트 처리 방식도 함께 고민했습니다. 중첩 번역 결과를 서비스가 직접 수신하고 반영하도록 만들 수도 있지만, 기존 자동번역 구조에서는 성공 이벤트를 서비스가 직접 처리하지 않고 공통 라이브러리가 자동으로 반영하는 방식이었습니다. 중첩 객체 번역만 별도 이벤트 핸들러를 요구하면 사용 방식이 일관되지 않습니다.

그래서 중첩 객체 번역도 기존 성공 처리와 동일하게 자동 반영되도록 정리했습니다. 번역 서비스는 완료 이벤트를 발행하고, 공통 라이브러리는 해당 이벤트를 수신한 뒤 reflection으로 원래 엔티티의 대상 위치에 번역 결과를 반영합니다.

반면 실패 처리는 서비스가 직접 처리하도록 유지했습니다. 실패 이후의 정책은 서비스마다 다를 수 있기 때문입니다. 어떤 서비스는 단순히 로그만 남길 수 있고, 어떤 서비스는 관리자 화면에서 실패 이력을 보여주거나 재요청 기능을 제공할 수 있습니다. 따라서 실패 이벤트는 공통으로 제공하되, 그 이후의 처리는 각 서비스의 도메인 정책에 맡기는 것이 적절하다고 판단했습니다.

6. 마무리

이번 작업은 LLM 자동번역 대상을 엔티티의 직접 필드에서 중첩 객체와 목록 구조까지 확장한 경험이었습니다. 처음에는 단순히 내부 객체의 다국어 필드도 번역할 수 있게 만드는 작업처럼 보였지만, 실제로는 사용자 API, 목록 index의 안정성, 이벤트 계약, 결과 반영 방식, 성공과 실패의 책임 경계까지 함께 고민해야 했습니다.

특히 목록 구조에서는 특정 index를 API로 노출할 것인지가 중요한 판단 지점이었습니다. index는 구현에는 편리하지만, 값 객체 목록에서 안정적인 식별자라고 보기 어렵습니다. 그래서 특정 index 번역 기능은 제외하고, 목록 전체를 대상으로 번역하는 방향을 선택했습니다.

또한 사용자 API를 단순하게 유지하는 것도 중요한 목표였습니다. 사용자가 내부 path를 직접 만들지 않고, 중첩 필드명과 내부 다국어 필드명만 전달하도록 했습니다. 단일 객체인지 목록인지, 내부적으로 어떤 번역 대상이 만들어지는지는 Helper가 판단합니다.

이 작업을 통해 다시 느낀 점은 LLM 기능의 핵심이 모델 호출 자체에만 있지 않다는 것입니다. 실제 서비스에 적용하려면 어떤 데이터를 번역 대상으로 볼 것인지, 결과를 어디에 어떻게 반영할 것인지, 실패를 누가 처리할 것인지, 사용자가 기능을 얼마나 단순하게 사용할 수 있는지까지 함께 설계해야 합니다.

공통 기능은 내부적으로 많은 경우를 처리해야 하지만, 사용하는 사람에게는 단순하게 보여야 합니다. 이번 작업은 LLM 자동번역 기능을 실제 서비스 도메인 모델에 더 가깝게 확장하면서, 그 원칙을 다시 확인한 과정이었습니다.

7. 향후 검토할 지점

이번 확장으로 번역 요청과 결과 반영 범위는 넓어졌지만, 운영 관점에서 아직 더 고민할 지점도 남아 있습니다. 대표적으로 번역 상태를 어디에서 관리할 것인지가 있습니다. 단순히 값이 비어 있다가 채워지는 최초 번역은 완료 여부를 비교적 쉽게 판단할 수 있지만, 이미 번역값이 있는 상태에서 재번역이 발생하면 필드 값만으로 진행 중과 완료를 구분하기 어려울 수 있습니다.

이런 상태 정보는 다국어 문자열 값 자체라기보다 번역 요청의 처리 이력이나 워크플로우에 가깝습니다. 따라서 문자열 객체 안에 상태를 직접 넣기보다는 별도의 번역 이력 또는 상태 모델로 관리하는 편이 더 명확할 수 있습니다. 실패 재요청 기능도 같은 맥락에서 단순한 LLM 재호출이 아니라, 현재 데이터 상태와 서비스 정책을 함께 고려하는 운영 기능으로 바라볼 필요가 있습니다.

[참고문헌]

Martin Fowler, Value Object
https://martinfowler.com/bliki/ValueObject.html

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

jyyou

Site footer