지난번 외주 프로젝트에서, DeepL 라이브러리로 번역을 하는 기능을 프론트에서 담당했었다.
기존에는 DeepL API에서 자동으로 HTML을 감지해서, 본문을 번역해주는 기능을 사용하려고 했었다.
그러나 아래와 같은 문제가 발생했다.
1. 꽤 높은 확률로 AI 특유의 Hallucination 현상이 있어서, 무의미한 태그가 반복 출력되는 오류가 있었다.
2. 예상은 했었지만, 모든 태그의 속성까지 번역 비용에 포함되어서, 번역 비용이 2~3배 이상 높게 나왔다.
그래서 아래와 같이 수정 적용하였다.
여기서 유의할점이, 절대로 FE에서는 직접 번역 API를 사용하면 안된다!
그럴경우 번역 KEY가 노출되어, 엄청난 비용이 청구될 수도 있다.
Next.js프로젝트의 장점을 살려서, 간단한 API를 만들어서 Next.js 서버에서 번역을 하도록 하였다.
아래는 실제 사용하는 코드를 단순화 시킨 것.
import * as deepl from 'deepl-node'
import type { NextApiRequest, NextApiResponse } from 'next'
class APIError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
try {
const { text, target_lang, source_lang } = req.body
const authKey = ENV에_저장된_API_KEY as string
const translator = new deepl.Translator(authKey)
//여기서 text를 배열로 보낼경우, response는 text 배열로 온다.
const response = (await translator.translateText(text, source_lang, target_lang)) as deepl.TextResult[]
const data = response.map((item: any) => {
return item.text
})
res.status(200).json(data)
} catch (error) {
if (error instanceof APIError) {
res.status(error.status).json({ message: error.message })
} else {
res.status(error.status || 500).json({ message: error.message })
}
}
} else {
res.setHeader('Allow', ['POST'])
res.status(404).end(`${req.method} is not allowed.`)
}
}
번역 한도 초과등 오류의 경우, deepl-node 라이브러리에서 정의되어있다.
아래는 node_modules/.pnpm/deepl-node@1.11.0/node_modules/deepl-node/dist/index.js 에 있는 deepl-node 라이브러리의 오류 처리 코드 중 일부
switch (statusCode) {
case 403:
throw new errors_1.AuthorizationError(`Authorization failure, check auth_key${message}`);
case 456:
throw new errors_1.QuotaExceededError(`Quota for this billing period has been exceeded${message}`);
case 404:
if (usingGlossary)
throw new errors_1.GlossaryNotFoundError(`Glossary not found${message}`);
throw new errors_1.DeepLError(`Not found, check server_url${message}`);
case 400:
throw new errors_1.DeepLError(`Bad request${message}`);
case 429:
throw new errors_1.TooManyRequestsError(`Too many requests, DeepL servers are currently experiencing high load${message}`);
case 503:
if (inDocumentDownload) {
throw new errors_1.DocumentNotReadyError(`Document not ready${message}`);
}
else {
throw new errors_1.DeepLError(`Service unavailable${message}`);
}
default: {
const statusName = http_1.STATUS_CODES[statusCode] || 'Unknown';
throw new errors_1.DeepLError(`Unexpected status code: ${statusCode} ${statusName}${message}, content: ${content}`);
}
}
위와 같은 오류 메시지가 오면, 그걸 아래 함수에서 서버로 알려준다.
여기까지가 API 호출 부분이고,
이제 HTML본문에서 text node를 추출해서 이 API로 보내는 부분을 살펴보자.
------
//코드를 공개할수 없어, 개념 이해를 위한 간략화된 함수만 새로 작성하였습니다. 실제 서비스 로직과는 상이합니다.
function extractTextNodes(html: string): string[] {
const parser = new DOMParser()
//html을 파싱하여 DOM객체로 변환해준다.
const doc = parser.parseFromString(html, 'text/html')
const textNodes = [] as string[]
//재귀함수 정의
function recursiveExtractText(node: any) {
//forEach는 빈 childNodes에는 아무 작업도 하지 않음.
node.childNodes.forEach((child: any) => {
if (child.nodeType === Node.TEXT_NODE) {
textNodes.push(child.textContent)
} else if (child.nodeType === Node.ELEMENT_NODE) {
//텍스트 노드가 아니면 한번 더 재귀 실행
recursiveExtractText(child)
}
})
}
//재귀 실행
recursiveExtractText(doc.body)
//결과 반환
return textNodes
}
이걸로 추출한 string[] 을 다음 함수에서 번역한다.,
//코드를 공개할수 없어, 개념 이해를 위한 간략화된 함수만 새로 작성하였습니다. 실제 서비스 로직과는 상이합니다.
const translateHtmlViaDeepL = async ({
htmlContent,
optionResponseData,
}: {
htmlContent: string | null
optionResponseData: PublicationParseResponse
}) => {
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent, 'text/html')
const docContent = doc.querySelector('.content') // 본문중 css 제외한 부분 선택
const textToTranslate = extractTextNodes(docContent.innerHTML)
//
const chunks = splitIntoChunks(textToTranslate, CHUNK_SIZE)
setTotalChunk(chunks.length)
actions.setMessage(`Starting translation. 번역을 시작합니다.`)
const allTranslations = [] as string[]
let index = 1
const targetLangauge = language.toUpperCase() === 'EN' ? 'en-US' : language
for (const chunk of chunks) {
const response = await fetch('/api/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: chunk,
target_lang: targetLangauge,
source_lang: optionResponseData?.language,
}),
})
const awaitedResponse = await response.json()
if (typeof awaitedResponse.message !== 'undefined') {
//서버로 오류 메시지 전달 (Error Slack Message 전송)
await axiosInstance.post('/error/translation', {
publicationId,
userId,
message: awaitedResponse.message,
})
alert(`일시적으로 번역을 할수 없습니다. 잠시후 다시 시도해주세요. 오류가 지속될 경우 관리자에게 문의해주세요.`)
router.replace('원본 언어 페이지로 이동')
return // 번역 실패 시 루프를 종료
}
allTranslations.push(...awaitedResponse)
}
//함수는 아래에서 별도 정의
const resultHTML = replaceTextsInHtml(docContent.innerHTML, allTranslations)
docContent.innerHTML = resultHTML // 번역된 HTML을 끼워넣기
const res = await axiosInstance.post<UploadViewUrlResponse>('/writer/publication/uploadviewurl', {
publicationId,
page: optionResponseData?.page,
language,
})
const { viewerPreSignUrl } = res.data
const updatedHtml = doc.documentElement.outerHTML
await axios.put(viewerPreSignUrl, updatedHtml, {
headers: {
'Content-Type': 'text/html',
},
})
return updatedHtml
}
아래 함수는 번역 완료된 Text배열을 html node에 순서대로 끼워넣어준다.
deepl-node 번역시, 번역된 배열과 번역전 배열의 순서와 길이는 같음이 보장되기 때문에, 다음과 같이 작동이 가능하다.
//코드를 공개할수 없어, 개념 이해를 위한 간략화된 함수만 새로 작성하였습니다. 실제 서비스 로직과는 상이합니다.
function replaceTextsInHtml(html: string, translations: string[], startIndex = 0) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
let currentIndex = startIndex
function recursiveReplaceText(node: any) {
node.childNodes.forEach((child: any) => {
if (child.nodeType === Node.TEXT_NODE) {
if (currentIndex < startIndex + translations.length) {
child.textContent = translations[currentIndex - startIndex]
currentIndex++
}
} else if (child.nodeType === Node.ELEMENT_NODE) {
recursiveReplaceText(child)
}
})
}
recursiveReplaceText(doc.body)
return doc.body.innerHTML
}