자바☕

@blocknote 호환 컨텐츠 자동 번역 기능 구현

wannaDevelopIt 2026. 1. 14. 18:29

BlockNote(React를 위해 구축된 오픈소스 기반의 블록 스타일 텍스트 에디터)를 사용하고 있는 상황이었다
링크 : https://www.blocknotejs.org/

 

번역 기능을 구현해야 했는데, 기존 브라우저에서 제공하는 번역 기능이

 

blockNote의 텍스트 블록을 텍스트로 취급하지 않아 번역이 되지 않는 문제가 있었다

 

그래서 블로그 작성 시 다국어 번역본을 자동으로 제공하기 위해 해당 에디터의 구조를 분석하고 번역 수행 로직을 구현했다

0. translation 진행 전 호출 위치 선정

요구사항에 따라 블로그 게시물을 생성이 완료된 후 번역 대상 컨텐츠에 대한 업데이트(번역 컨텐츠는 별도의 컬럼으로 관리한다)를 수행하고, 본 컨텐츠를 업데이트하는 경우사용자의 필요에 따른 번역 API를 호출하는 것으로 했다. 이는 간단한 flag 분기 처리를 통해 구분했다

 

1. 전처리

이번 작업을 수행하는 동안 JsonObject와 JsonArray를 정말 많이 활용했다(정리한 내용 : https://cdaosldk.tistory.com/373)

해당 타입으로 입력된 데이터를 번역에 적합하게 전처리했다

 

또한, 블록 단위로 구성된 텍스트 데이터의 구조를 이해하는게 매우 중요했다. 해당 에디터의 문서를 참조했다

https://www.blocknotejs.org/docs/foundations/document-structure

 

BlockNote - Document Structure

Learn how documents (the content of the rich text editor) are structured to make the most out of BlockNote.

www.blocknotejs.org

블록의 기본 구조는 다음과 같다 :

1) id : 해당 블록의 아이디를 의미. 해당 값이 없으면 스타일을 유지한 텍스트 번역이 불가능하다

2) type : 블록의 유형, 테이블(표)인지, 링크인지, 일반 텍스트인지 등을 알려준다

3) props : 블록의 특징을 정리해둔 키/값 집합

4) content : 해당 블록의 텍스트

5) children : 블록 안에 다른 블록(인라인 블록)의 경우 이 안에서 지금 설명된 것과 같은 블록 객체가 들어있다

 

이러한 블록이 배열 형태로 저장되기 때문에, 이 배열을 먼저 JsonArray로 파싱했다

....

JsonElement element = JsonParser.parseString(jsonString);
    if (element.isJsonNull()) {
        return new JsonArray();
    }
    if (element.isJsonPrimitive()) {
        String realJsonContent = element.getAsString();
        JsonElement arrayElement = JsonParser.parseString(realJsonContent);

...

해당 로직을 통해 파싱한 후, 핵심 전처리 로직을 수행한다

private void addTextPropertiesFromContent(... {
...

String currentIndex = parentIndex.isEmpty() ? String.valueOf(i) : parentIndex + "_" + i;

...
if (contentItem.has(TEXT_FIELD) && !contentItem.get(TEXT_FIELD).getAsString().isEmpty()) {
	translationItem.addProperty(TEXT_FIELD + "_" + currentIndex, contentItem.get(TEXT_FIELD).getAsString());
} else if ("link".equals(contentItem.get(TYPE_FIELD).getAsString())) {
    JsonElement linkContentElement = contentItem.get(CONTENT_FIELD);
    if (linkContentElement != null && linkContentElement.isJsonArray()) {
        // 링크 내부의 텍스트 처리 로직 재사용
        addTextPropertiesFromContent(linkContentElement.getAsJsonArray(), translationItem, currentIndex);
    }
...

핵심적인 로직은 인덱스와 함께 배열에 담는 것이다. 가장 상위엔 상위 블럭의 id가 있고, 그 밑에 "text_0_1" 형태로 값을 담는다.
부모 인덱스와 자식 인덱스 개념이 로직에 반영되어 있어야한다. link 등의 경우, 내부의 다른 블록으로 들어가야 하므로 메서드의 재귀호출로 이를 구현헀다

2. 번역

GPT를 활용한 번역을 수행했으며, 내부 로직에 따라 오류나 잘못된 결과를 반환한 경우, 재시도하는 로직을 구현헀다. 추후 시멘틱 캐싱의 개념을 더 이해하고 도입할 수 있다면, 토큰 비용을 절감할 수 있을것으로 기대한다

 

3. 후처리

"text_0_1" 등의 키에 매핑되어 번역된 결과물을, 원본 blcoknote 배열에 매핑을 해야한다. 애초에 불필요한 원본 배열을 번역 로직에 추가하지 않기 위해 전처리 로직이 필요했다. 큰 흐름은 전처리 로직의 역순으로 볼 수 볼 수 있다. 그렇게 번역까지 진행된 blocknote 배열은 원본 데이터와 함께 저장된다.

 

4. 조회

이후 클라이언트의 헤더에서 어떤 언어로 접속한지 보내는 값에 따라, SELECT 쿼리에서 이를 반영한 CHOOSE 문법을 통해 해당 데이터를 조회하는 것으로 번역 작업은 완료된다

 

 

마치며.

구조화된 데이터, 특히 그 구조가 반복되는 데이터의 처리를 다루며 배우게 된 점이 많다. 반복 작업에 대해 캡슐화를 통한 재귀 호출을 구현하게 된 점이 좋았고, 처리 성능을 최적화하는게 매우 중요하다는 것을 다시 깨달았다