
안녕하세요. 에어프레미아에서 3년째 순항중인 App Engineer 김찬울입니다.
웹, 앱에서 표기하는 텍스트가 달라져야 한다면 어떻게 하시나요? 일반적으론 서버와 클라이언트 사이에 BFF (Backend for Frontend) 역할을 하는 것을 둔다던지, 정적인 텍스트는 프론트엔드의 코드 레벨이나 Localization 파일에 기록하여 사용하기도 하죠. 오늘은 그중 정적 텍스트의 업데이트에 대해 다뤄보려 합니다.
앱에 표시하는 상담번호는 보통 크게 바뀔 일이 없죠. 그래서 정적 텍스트로 다루는 경우가 많습니다. 하지만 이 텍스트가 모종의 사유로 급히 바뀌어야 한다면 무슨 일이 일어날까요? 담당자가 개발자에게 새 번호를 알려주는 데는 오래 걸리지 않습니다. 하지만 고객의 앱 화면에 그 번호가 보이기까지는 많은 시간이 걸렸어요. 웹은 배포까지 약 1시간이 걸렸고 앱은 긴급 심사를 요청하더라도 24시간 이상을 기다려야 했습니다. 심사가 끝나도 즉각 업데이트되진 않겠죠? 고객이 새 버전을 설치해야 수정된 번호가 보일테니까요. 결국 강제 업데이트를 적용하지 않으면 기존 버전을 쓰는 고객에게는 예전 번호가 계속 표시됐습니다. 전화번호 몇 자를 고치는 일에도 앱의 빌드와 심사, 사용자의 업데이트라는 너무나 많은 조건이 붙어 있었습니다.
이번에 번역 원격 배포 시스템을 도입하면서, 국가별 상담번호와 같은 앱 내 정적 텍스트를 바꿀 때 Production에 배포하기까지 10분이 채 걸리지 않습니다. 이 과정에 앱을 새로 빌드하거나 스토어에 심사를 요청하는 절차는 사라졌구요. 이번 글에서는 이 배포 시스템을 어떻게 만들었는지, 특히 AI를 활용해 개발한 Slack 봇과 Flutter에서의 런타임 번역 연결을 소개합니다.
Slack 봇과 GitHub Actions로 번역 배포 자동화하기
에어프레미아는 항공업 특성상 앱의 텍스트들을 다국어로 관리하기 위해 Crowdin이라는 콘텐츠 저장 툴을 사용하고 있었어요. 문구를 고치는 공간은 마련돼 있었고 모든 작업자가 이 툴에 익숙해져 있었기 때문에, 툴을 새로 만드는 것보다는 저장된 번역을 앱과 웹에 전달하는 과정을 바꾸고 싶었습니다. 사실 Crowdin에서는 유료 서비스인 OTA Update를 운영하고 있습니다. 이미 만들어진 서비스를 사용하는 것은 쉽지만 추가 비용이 걱정스러웠고, 무엇보다 어느 환경에 무엇을 배포할지(flavor, env), 변경 사항은 어떻게 보여줄지(diff), 검증은 어떻게 할지(validator), 누가 배포를 실행할 수 있을지(권한 관리)를 우리 운영 방식에 맞춰 커스터마이징하고 싶었습니다.
그래서 Crowdin은 번역 원본을 관리하는 역할로 두고, 배포 요청은 Slack 봇에서 받고, 다운로드·검증·배포는 GitHub Actions에서 처리하고, 파일은 Cloudflare R2 (Web은 BFF + Private S3) 에 저장해 CDN으로 전달하기로 했습니다. 담당자가 Slack에서 봇으로 요청하면 GitHub Actions가 Crowdin에서 번역 파일을 가져와 현재 배포본과 비교합니다. 배포가 필요한 경우 환경에 맞는 R2 버킷 (혹은 S3) 에 파일을 올리고 CDN 반영 여부를 확인한 뒤 결과를 봇으로 돌려주는 형태를 구상한 것이지요. 이렇게 정리해보니 필요한 스펙이 눈에 들어왔습니다.
Slack 봇을 개발할 때는 AI를 적극적으로 활용했습니다. 봇이 배포 요청을 받고 GitHub Actions의 결과를 다시 보여주도록 다음과 같은 명세를 정의한 후 개발에 들어갔죠.
- Dev / Production 환경과 Web / App 대상을 사용자가 지정할 수 있어야 한다.
- Production은 디자이너, PO와 같이 권한이 있는 분들만 배포할 수 있도록 한다. Slack의 사용자 그룹 태그를 기반으로 판단한다.
- Dev는 누구든지 빠르게 배포하여 서비스에 적용된 형태를 확인할 수 있도록 한다.
- Dev 환경에 같은 변경 사항을 먼저 배포해야 Production에도 배포할 수 있다. (Dev 확인을 건너뛰지 않기 위한 안전장치)
- 배포 전 어떤 정보가 바뀌었는지 Preview가 보였으면 좋겠다. 이후 배포됐으면 좋겠고, 배포 직전 정보가 달라져 Preview와 값에 차이가 난다면 다시 작업을 수행하도록 했으면 좋겠다.
- 변경되는 정보에 대해 검증이 이루어졌으면 좋겠다. (대소문자, 상표명, 필요 없는 띄어쓰기 등의 Lint)
또한 배포 및 프리뷰를 위한 GitHub Actions에는 다음과 같은 주문을 포함했습니다.
- GitHub에서 직접 실행하지 않고, 오로지 Slack 봇의 요청만 수행하도록 한다. (Slack 채널에 모든 배포 로그가 남도록 하기)
- Slack 봇에서 선택한 배포 환경과 대상에 맞는 R2 위치에 업로드되어야 한다.
위와 같은 스펙을 세우고 개발에 들어간 3시간 후쯤, Slack 봇을 전용 채널에 초대하고 호출했을 때 첫 동작 결과가 눈에 보이기 시작했습니다.

그림 1. Slack 봇의 배포 요청 화면과 배포 결과
Flutter AppLocalizations에 OTA 번역 연결하기
배포 시스템을 구축한 뒤에는 기존 앱과 웹에도 이를 적용해야 했습니다. 앱에는 이미 Crowdin 데이터를 하루 한 번 가져오는 GitHub Actions 워크플로가 있었습니다. 이 작업은 CSV를 Flutter의 ARB 파일로 변환하고, 변경 사항이 있으면 검수용 PR을 생성했습니다.
이 과정은 새로운 배포 시스템과 함께 유지해야 했습니다. ARB는 빌드할 때 앱에서 사용할 번역 키와 기본 문구를 만드는 데 필요합니다. 이 기본 문구는 앱이 실행된 뒤에도 남아 있습니다. OTA 언어팩을 받을 수 없거나 필요한 키가 빠졌을 때 보여줄 문구니까요. 반면 OTA 언어팩은 이미 설치된 앱의 문구를 바꾸는 데 쓰입니다. 결국 새 앱을 빌드할 때의 번역과 배포 후 문구를 바꿀 때의 번역이 둘 다 필요했습니다.
새 앱 버전을 만들 때는 ARB와 생성 코드를 계속 갱신합니다. 네트워크가 없는 곳에서도 문구를 보여줄 수 있고, 앱에 들어 있는 기본 번역이 너무 오래된 상태로 남지 않게 하려는 거죠. 그러면서도 런타임에 내려받은 문자열을 기존 화면의 번역 조회에 연결해야 합니다. 에어프레미아의 Flutter 앱은 ARB에서 생성한 AppLocalizations를 사용합니다. 화면에서는 다음처럼 키에 해당하는 getter를 읽습니다.
final text = AppLocalizations.of(context)!.action_confirm;
이 getter는 앱을 빌드할 때 ARB를 바탕으로 만들어집니다. 화면에서 action_confirm을 읽으면 앱에 들어 있던 문구를 돌려주죠. CDN에서 같은 키의 새 문구를 받아도 이미 만들어진 getter가 그 값을 알아서 읽지는 않습니다. 그렇다고 화면마다 번역 맵을 직접 조회하게 만들면 기존 호출부를 전부 수정해야 하는데 너무 부담이 되더라구요. 키를 문자열로 잘못 적거나 폴백 처리를 빼먹을 수도 있고요. 결국 기존 AppLocalizations 인터페이스를 그대로 활용할 방법이 필요했습니다.
그리하여 언어별 생성 클래스를 상속한 OTA 클래스를 만들고 getter를 재정의하는 방식을 선택했습니다. 한국어 클래스의 일부를 소개해 드릴게요.
class OtaAppLocalizationsKo extends AppLocalizationsKo {
OtaAppLocalizationsKo([super.locale = 'ko']);
@override
String get action_confirm =>
OtaTranslationService.instance
.translate('ko', 'action_confirm') ??
super.action_confirm;
}
화면이 action_confirm을 읽으면 OtaTranslationService 에서 한국어의 같은 키를 조회합니다. 값이 있으면 그 문구를 반환하고 없으면 부모 클래스에 들어 있는 기본 번역을 반환합니다. 화면에서는 AppLocalizations를 계속 씁니다. 네트워크나 캐시를 조회하는 코드를 화면마다 넣을 필요도, 기존 호출부를 고칠 필요도 없지요. 이 getter들을 손으로 관리하지는 않습니다. 키가 늘거나 없어질 때마다 언어별 클래스를 직접 고칠 수는 없으니까요. 한국어 ARB 템플릿에서 OTA 대상으로 쓸 키를 읽고 한국어·영어·일본어 하위 클래스를 만드는 스크립트를 추가했습니다. 기존 ARB 동기화 워크플로에서 이 스크립트도 함께 실행합니다. 새 키가 생기거나 기존 키가 삭제되면 OTA 클래스에도 반영됩니다.
다음은 이 하위 클래스를 Flutter의 localization에 연결할 차례입니다. MaterialApp의 로컬라이징을 담당하는 커스텀 LocalizationsDelegate
언어팩을 언제 가져올지도 앱 시작 과정과 함께 고려했습니다. 번역을 받느라 첫 화면이 늦어지면 앱 전체의 시작 경험에 영향을 주기 때문이죠. 첫 화면은 번들 번역으로 먼저 표시하고, 저장된 언어팩을 읽은 뒤 이를 적용합니다. 최신 번역은 백그라운드에서 내려받아 새로 그려지는 화면에 반영하도록 했습니다. 또한 사용자의 앱 언어에 맞는 언어팩을 먼저 받도록 해 효율성을 높였죠. 매번 같은 파일을 모두 내려받지 않도록 언어별 ETag도 저장하고 활용하도록 했습니다.
실패했을 때의 fallback 흐름도 필요했습니다. 최신 언어팩을 내려받지 못하거나 받은 파일이 손상됐다면, 앱은 이전에 저장한 언어팩을 계속 씁니다. 저장된 언어팩마저 없다면 앱에 포함된 기본 번역을 보여주고요. 파일은 정상인데 앱에서 쓰는 키 하나가 빠졌다면, 그 문구만 기본 번역으로 돌아갑니다. 어느 경우든 번역 파일 때문에 앱 화면이 비어서는 안 되니까요. 다만 정상적으로 보여주되, 이런 문제는 non-fatal 에러로 기록해 개발자들이 실시간으로 확인할 수 있도록 했습니다.
팀원들이 직접 번역을 배포하며 달라진 운영
지금은 Slack 봇을 통한 텍스트 업데이트 요청이 하루에 대여섯건 들어옵니다. 번역 담당자인 PO, 디자이너분들이 직접 할 수 있는 작업으로 자리 잡고 있다는 사실은 명확하죠. 담당자는 익숙한 Crowdin에서 다국어 작업을 진행하고, Slack에서 변경 내용을 확인하고 배포를 진행하고 있습니다. 매번 개발자에게 업데이트를 요청해야 한다면 무척 번거로웠을 일입니다. 저는 배포에 걸리는 시간을 개선한 것도 중요하지만 동료에게 부탁하기 애매했던 지점들을 스스로가 할수 있도록 만들었다는 점에서 이번 개발이 가치가 있었다고 생각합니다.

그림 2. 위 작업 이후 Crowdin 문구 검증 결과 기능을 만들어 주신 QA 파트장 전현수님께 감사드립니다. :>
2~3년간 회사의 App Engineer로서 서비스를 함께 만들어 나가는 동료들이 어디서 병목을 겪는지 조금씩 알 수 있었습니다. 이제는 App만 신경 쓰기보다 경계를 넘어 Slack 봇 같은 앱 주변의 도구를 만들고, 전반적인 운영 흐름도 개선하고 싶었어요. AI가 도입된 이상 잘 만드는 것도 중요하지만 어떤 걸 만들어 어떤 문제를 풀어나갈지도 중요한 시대가 된 것 같습니다. 앞으로도 개인의 역량을 올리는 것보다 팀 차원에서 병목을 줄이고 효과적으로 일할 수 있는 방법이 어떤 게 있을지 찾아보도록 하겠습니다.
긴 글 읽어주셔서 감사합니다. 우리 함께 일해요!