엔지니어링 노트설치·운영과 품질

영어 설치에 한국어 문구가 섞이는 결함은 머지 때 소스 검사로 막는다

OwlAI는 구축형 제품이라 설치 하나가 언어 하나로 돕니다. 지원 언어는 한국어와 영어입니다. 서버 코드에 한국어 문구가 문자열로 들어가 있으면, 한국어 설치에서는 그 문구가 맞는 문구로 보이고 영어 설치에서는 그 자리만 한국어로 나옵니다. 개발팀이 한국어 설치에서 아무리 꼼꼼히 테스트해도 이 결함은 드러나지 않고, 영어 설치에서도 정상 경로만 걸어 보면 지나칩니다. 이런 문구는 턴이 실패하거나 요청이 거절되는 자리에 몰려 있기 때문입니다.

이 글은 이 결함을 사람의 주의에 맡기지 않고 두 가지 검사로 막는 설계를 설명합니다. 소스에 흔적이 남는 결함(한국어 문자열, 언어를 못 박은 프롬프트 문장, 오류 코드 없는 거절)은 머지 리퀘스트마다 도는 CI 검사가 막습니다. 소스에 흔적이 남지 않는 결함, 곧 로케일을 몰라 기본값인 한국어로 떨어지는 코드는 영어 계정으로 실제 턴을 돌리는 야간 시험이 잡습니다.

같은 코드에서 영어 설치만 실패 문구가 한국어로 나온다
그림 1. 같은 코드에서 영어 설치만 실패 문구가 한국어로 나온다

한국어 설치의 테스트로는 볼 수 없는 결함 세 가지

이 결함이 생기는 모양은 셋입니다.

모양 예 한국어 설치에서 보이는 것
코드에 들어간 한국어 문구 턴 실패 문구, 웹 푸시, 회의록 절 제목 맞는 문구
언어를 문장으로 못 박은 프롬프트 「한국어로 답하라」 맞는 언어의 답
로케일을 모를 때 기본값으로 떨어지는 코드 로케일 칸을 빠뜨리고 사용자 정보를 다시 만드는 코드 맞는 결과

둘째 모양에서는 관리자가 언어를 영어로 두어도 그 경로만 한국어로 답하고, 원인은 설정 화면 어디에도 보이지 않습니다.

셋째 모양이 가장 찾기 어렵습니다. 사용자 정보를 필드 몇 개만 골라 새로 만드는 코드가 로케일 칸을 빠뜨리면, 그 정보를 받는 아래쪽 경로가 모두 기본값인 한국어로 문구를 만듭니다. 답변 본문은 다른 경로로 언어를 정하므로 영어로 멀쩡하게 나오고, 그 아래의 실패 문구와 도구 문구만 한국어가 됩니다. 한국어 설치에서는 결과까지 맞아서 흔적이 없습니다.

한글을 grep 하면 주석까지 걸린다

가장 먼저 떠오르는 방법은 소스에서 한글을 찾는 정규식 검사입니다. 이 방법은 이 코드베이스에서 쓸 수 없습니다. 백엔드 주석과 독스트링의 한국어가 수만 줄이고, 프론트엔드도 한글이 든 줄의 대부분이 주석입니다. 주석은 개발자가 읽는 글이라 고객에게 나가지 않는데, 주석까지 막으면 위반을 다 정리하기 전에는 검사를 켤 수 없습니다.

리뷰와 수동 테스트도 약합니다. 한국어 문구는 문장이 멀쩡해서 리뷰어 눈에 이상하지 않고, 수동 테스트는 걸어 본 경로만 확인하는데 이 결함은 일부러 만들어야 하는 실패·거절 경로에 몰려 있습니다.

머지 리퀘스트마다 고객에게 나가는 문자열을 검사한다

백엔드, 서버 쪽 사이드카, 업데이트 실행기의 Python 코드는 한 검사기가 봅니다. 검사기는 소스를 AST(추상 구문 트리)로 읽고 문자열 리터럴만 검사합니다. 프론트엔드와 데스크톱 앱의 TypeScript는 TypeScript 컴파일러 API로 AST를 읽는 별도 검사기가 같은 일을 합니다. 두 검사기는 언어 도구가 달라서 나뉘었고, 검사하는 파일은 겹치지 않습니다.

규칙 1. 문자열 리터럴에 한글을 쓰지 않는다. 주석과 독스트링은 판정에서 빠집니다. 기준은 한 줄입니다.

문자열이 문(statement)으로 홀로 서 있으면 그것은 문서다.

이 기준으로 독스트링이 모두 빠지고, 변수에 담기거나 함수에 넘겨지는 문자열만 남습니다. 화면이 다시 만들 수 없고 저장되거나 밖으로 나가는 문구(턴 실패 문구, 웹 푸시, 회의록 절 제목, 감사 기록 요약)는 서버의 언어별 문구 카탈로그로 옮깁니다.

규칙 2. 언어를 코드에 못 박지 않는다. 검사기는 문자열 안의 in Korean, 한국어로, ko-KR, lang="ko" 같은 표현을 찾습니다. 출력 언어는 응답 언어 값으로 넘기고, 로케일은 사용자와 설치 설정에서 해석한 값을 씁니다.

규칙 3. API의 거절에는 오류 코드를 싣는다. 거절 응답에는 사람이 읽을 문장과 함께 errorCode가 있어야 합니다. 화면은 코드로 문구를 만들고, 서버가 실은 문장은 영어로 둬도 됩니다. 코드가 없으면 화면은 HTTP 상태 코드로만 사유를 가릅니다. 예를 들어 계정 등록의 「이미 등록된 이메일」과 계정 삭제의 「활동 기록이 있어 지울 수 없음」은 둘 다 409를 씁니다. 화면이 상태 코드로만 가르면, 활동 기록이 있는 계정을 지우려던 관리자가 「이미 등록된 이메일입니다」를 보게 됩니다. 이 규칙은 API 계층 전체를 검사합니다.

검사 범위에는 정리가 끝난 디렉터리만 넣습니다. 위반이 남은 범위를 켜면 모든 머지 리퀘스트가 막히고, 결국 검사를 다시 끄게 됩니다.

예외는 사유를 적고, 쓸모가 없어지면 CI가 실패한다

예외는 생깁니다. 한국어 문체를 재는 모듈은 를 통해 같은 한국어 패턴이 탐지 대상 그 자체라서, 영어로 옮기면 모듈이 할 일이 사라집니다. 사용자가 「매주 월요일」이라고 쓴 주기 표현을 알아듣는 코드에도 한국어 낱말이 있어야 합니다. 검사기는 예외를 범위가 좁은 것부터 세 단계로 받습니다.

예외 통과하는 것 쓰는 자리
낱말 예외 지정한 낱말만. 지운 뒤에도 한글이 남으면 위반 입력을 알아보는 낱말
문맥 예외(규칙 2) 지정한 문맥 조각이 든 줄만 이미 정해진 언어에 반응하는 조건절
파일 예외 파일 전체 한국어 문구 카탈로그, 한국어 문체 탐지기

파일 예외는 그 파일에 새 한국어가 들어와도 통과시킵니다. 그래서 한두 줄이면 낱말 예외를 먼저 씁니다. 규칙 2의 예외도 같은 이유로 파일이 아니라 줄 단위입니다. 표현 단위로 면제하면 같은 파일에 Always answer in Korean.을 새로 넣어도 통과합니다. 그래서 면제 항목에는 그 줄에 있어야 하는 문맥 조각을 적습니다. 문체 규칙 프롬프트처럼 영어 한 벌로 쓰고 「한국어로 쓸 때는 …」 같은 조건절로 언어를 가르는 줄이 그 예입니다. 이 조건절은 출력 언어를 정하지 않고, 이미 정해진 언어에 반응합니다.

모든 예외에는 사유가 있어야 하고, 사유 없는 항목은 검사기의 자가 검사가 막습니다. 쓸모가 없어진 예외도 막습니다. 면제한 파일에 더는 한글이 없거나 면제한 경로가 없어졌으면 CI가 실패하고 그 항목을 지우라고 알립니다. 남겨 두면 같은 경로에 새로 생긴 파일이 처음부터 면제된 채 출발합니다. 프론트엔드 검사기는 아직 옮기지 않은 파일마다 허용 건수를 적어 둡니다. 건수가 늘면 실패하고, 줄었는데 허용 건수를 그대로 두어도 실패합니다. 허용 목록은 코드가 정리되는 만큼만 줄어들고 다시 늘지 않습니다.

검사하지 않기로 한 대상(테스트, 개발자용 CLI, 언어별 매뉴얼 원고, 사람이 읽는 문서)도 사유와 함께 목록에 남겨, 새로 생긴 누락을 「원래 안 보던 것」과 구별합니다.

모델이 읽는 지침 파일은 주석까지 검사한다

규칙 1은 Python 파일의 문자열 리터럴만 봅니다. 모델이 필요할 때 꺼내 읽는 스킬 지침은 확장자가 .md와 .js라 이 검사에 걸리지 않습니다. 이 지침은 데이터베이스에 시드되어 프롬프트에 원문 그대로 실립니다. Word·PowerPoint·HWPX 문서를 만드는 지침이 한국어로 적혀 있으면, 영어 설치의 모델도 그 한국어를 읽습니다.

그래서 스킬 지침과 스크립트에는 규칙을 따로 둡니다. Python 코드에서 주석을 빼는 근거는 주석을 읽는 쪽이 사람이라는 것입니다. 스킬 지침은 주석까지 파일 전체가 모델 입력이므로 그 근거가 서지 않습니다. 판정선은 하나입니다.

스킬 지침에서 한국어가 설 수 있는 자리는 인라인 백틱 인용뿐이다.

백틱 안의 한국어는 탐지할 패턴이거나 고유명사입니다. 글꼴 이름 맑은 고딕은 문서 파일에 이 이름 그대로 들어가야 하고, 영어로 옮기면 그 글꼴로 찾아지지 않습니다.

코드 블록은 인용으로 치지 않습니다. 모델은 지침의 예제 코드를 그대로 베껴 사용자 문서를 만듭니다. Word 문서를 만드는 지침의 예제에 new TableOfContents("목차")가 있으면, 영어 설치에서 만든 문서에도 목차 제목이 「목차」로 들어갑니다. 그래서 코드 블록은 검사 대상입니다. 스킬 스크립트도 모델이 샌드박스에서 열어 읽으므로 같은 규칙을 받습니다.

스킬 지침의 세 구역: 영어 산문, 인라인 백틱, 코드 블록
그림 2. 스킬 지침의 세 구역: 영어 산문, 인라인 백틱, 코드 블록

이 규칙에도 덩어리 단위 예외가 있습니다. 관공서 양식은 라벨 폭을 맞추려고 글자 사이에 공백을 넣는데(기 관 명), 양식 편집 지침의 예제는 라벨로 셀을 찾는 코드가 이 공백을 견딘다는 것을 보여 줘야 해서 그 라벨을 그대로 둡니다.

스킬 지침을 생성하는 스크립트도 파일 하나씩 검사 범위에 넣습니다. 생성기가 두 언어 판에 한국어 머리글 하나를 함께 쓰면 영어 지침에 한국어 문장이 실리는데, 그 원인이 생성기에 있기 때문입니다.

소스에 흔적이 없는 폴백은 영어 계정의 실제 턴으로 잡는다

셋째 모양은 소스 검사로 보이지 않습니다. 로케일 칸을 빠뜨린 코드에는 걸릴 문자열이 없습니다. 그래서 두 가지를 함께 둡니다.

첫째, 문구 카탈로그는 해석하지 못한 로케일을 받으면 기본값으로 문구를 내면서 경고 로그를 남깁니다. 세션도 사용자도 없는 자리처럼 로케일을 정말 알 수 없는 곳은 경고를 남기지 않는 별도 폴백을 쓰고, 그 이유를 코드에 적습니다. 알고 쓴 폴백까지 경고를 찍으면, 로케일을 흘린 진짜 경로가 그 경고들 사이에 묻히기 때문입니다.

둘째, 구축형 테스트 환경에서 매일 밤 도는 live 시험이 영어 계정으로 실패 턴을 일부러 만듭니다. 선호 언어를 영어로 둔 구성원이 오래 걸리는 시험용 도구를 부르는 턴을 시작하고, 도구가 도는 동안 중지 단추를 누른 뒤, 저장된 실패 문구와 화면의 마지막 답에 한글이 없는지 확인합니다. 실행기는 시험 시간대의 서버 로그에서 로케일을 해석하지 못했다는 경고를 세어 결과 요약에 싣습니다. 이 수는 0이어야 하고, 0이 아니면 알림에 언어 경고가 붙습니다. 모델의 답 문장이 매번 달라도 흔들리지 않게 사실만 확인하는 방식은 「모델 답이 매번 달라도 E2E 테스트는 서버에 남은 사실로 판정한다」에서 다룹니다.

설계에서 고른 것

한글 정규식 검사 대신 AST로 문자열 리터럴만 보는 검사를 골랐습니다. 정규식 검사는 언어와 파일 종류를 가리지 않아 하나로 끝납니다. 그러나 주석의 한국어까지 걸려 검사를 켤 수 없습니다. 대가는 검사기가 언어마다 하나씩 필요하다는 것과, 주석과 산문을 봐줄 수 없는 스킬 지침에는 규칙을 따로 둬야 한다는 것입니다.

예외는 파일 단위 대신 낱말·문맥·덩어리 단위를 먼저 씁니다. 파일 단위는 관리할 항목이 적지만 그 파일에 새 한국어가 들어와도 지나갑니다. 좁은 예외는 항목이 늘어나는 비용을 내고 같은 파일의 새 위반을 계속 잡습니다.

오류 코드 규칙은 변경분 대신 API 계층 전체를 검사합니다. 변경분 판정은 이미 있는 위반을 남긴 채 검사를 켜게 해 주지만, 비교 기준 커밋이 없는 로컬 실행과 릴리스 머지 리퀘스트에서는 규칙이 꺼집니다. 그래서 기존 거절을 모두 정리한 뒤 전체 검사로 두었습니다. 거절 내용을 변수에 담아 넘기는 코드는 정적으로 판정할 수 없어 통과시키고, 그 몫은 리뷰가 봅니다.

기본 로케일을 영어로 바꾸는 대신 경고를 남기고 호출하는 쪽이 로케일을 넘기게 했습니다. 기본값을 영어로 바꾸면 영어 설치는 고쳐진 것처럼 보이지만, 같은 결함이 이번에는 한국어 설치에서 영어 문구로 나옵니다. 옳은 기본값은 설치 언어인데, 문구 카탈로그는 DB를 읽지 않는 하위 모듈이라 그 값을 모릅니다.

폴백 판정은 매일 밤 돕니다. 실제 LLM 턴을 쓰므로 머지 리퀘스트마다 돌리기에는 비용과 시간이 큽니다. 대가로 소스 검사가 막지 못한 폴백은 머지 뒤 하루 안에 결과 요약에서 드러납니다.

개발팀의 언어와 고객의 언어가 다른 제품이라면 같은 조합을 옮길 수 있습니다. 문자열 검사는 고객에게 나가는 코드에만 걸고, 기본값으로 떨어지는 자리에는 경고 로그를 달고, 그 경고 수를 고객 언어 계정의 실패 턴과 함께 매일 봅니다. 기본값이 개발팀의 언어인 한, 개발팀의 테스트로는 이 결함이 보이지 않습니다.