Perplz Logo
Productivity

다국어 알림 — 박제 선택, 그리고 묵혀둔 부채의 청산

ggman

서비스에 31종 알림이 있다. 프로젝트, 피드, 포스트, 동료, 채팅 다섯 카테고리에 걸쳐 있고 트리거는 대부분 Postgres 안에서 돌아간다. 푸시는 FCM, 인앱은 `notifications` 테이블에 박힌 title/body 를 그대로 렌더하는 구조. 영문 유저 도입을 앞두고 이 알림 본문을 어떻게 다국어로 처리할지 정해야 했다.

선택지는 두 가지였다. 하나는 DB 박제 — 트리거 안에서 `users.lang` 을 읽고 그 자리에서 ko/en 본문을 만들어 INSERT 하는 방식. 또 하나는 동적 i18n — DB 에는 type 과 params 만 저장하고 클라이언트가 i18n key 로 매핑해 그때그때 렌더하는 방식.

박제를 택한 가장 큰 이유는 단순함이다. 푸시 payload 와 인앱 body 가 완전히 같은 문자열이 된다. 클라이언트는 body 를 그대로 렌더하면 끝이고, 두 채널이 문자열을 따로 만들 필요가 없다. 트리거는 어차피 닉네임이나 프로젝트 제목 같은 동적 값을 JOIN 으로 끌어내고 있으니, 그 자리에서 완성된 문장을 만들어 INSERT 하는 게 자연스럽다. 본문 표현을 나중에 바꿔도 이미 발송된 알림은 그대로 유지된다는 점도 일관성과 감사 측면에서 깔끔하다.

동적 i18n 은 그 자체로는 더 우아한데 비용이 컸다. ICU MessageFormat 류를 클라와 푸시 양쪽에 도입해야 하고, 푸시 본문은 클라이언트 코드가 실행되기 전에 결정돼야 하니 결국 서버 측에서도 lang 별 본문을 따로 만들어야 한다. 그러면 절반은 박제와 같은 코드를 또 짜는 셈이다. 동적 값의 escaping 과 포맷 책임을 양쪽이 나눠 갖는 것도 안전성 측면에서 부담이었다.

박제의 단점도 알면서 받아들였다. 유저가 ko 에서 en 으로 바꿔도 이전 알림은 한국어로 남는다. UX 손실이긴 하지만 "그 알림을 받은 시점의 언어가 박혀 있다" 라는 해석이 가능하고, 문구 일괄 수정 비용도 트리거가 클라이언트 코드보다 변경 빈도가 낮으니 감당할 만했다.

구현은 31종 트리거의 INSERT 부분을 모두 `CASE u.lang` 으로 감싸는 작업이었다. 가령 댓글 알림 비슷한 케이스라면 다음과 같은 형태가 된다.

INSERT INTO notifications (user_id, type, title, body, data)
VALUES (
  NEW.author_id,
  'comment_received',
  CASE u.lang
    WHEN 'en' THEN 'New comment'
    ELSE '새 댓글'
  END,
  CASE u.lang
    WHEN 'en' THEN format('%s commented on your post', commenter.name)
    ELSE format('%s 님이 글에 댓글을 남겼어요', commenter.name)
  END,
  jsonb_build_object('post_id', post.id, 'comment_id', comment.id)
);



영문 카피는 ARB 파일과 동일한 톤으로 작성했고, 변수 위치가 언어마다 다른 케이스는 `format()` 인자 순서를 lang 별로 다르게 줬다. 작업 중 새로 추가된 알림 한 종도 양 언어로 미리 박아 도입했다.

박제 자체는 트리거 작업으로 끝나지만, 켜는 순간 깨질 거라는 걸 알고 있던 부분이 클라이언트의 인앱 알림 클릭 라우팅이었다. 인앱 알림 위젯 안에서 본문 한글 키워드로 13군데 분기하고 있었다. 대략 이런 모양이다.

switch (notification.category) {
  case Category.post:
    if (notification.body.contains('댓글')) {
      // → 게시글 페이지
    } else if (notification.body.contains('좋아요')) {
      // → 게시글 페이지
    } else if (notification.body.contains('답글')) {
      // → 댓글 페이지
    }
    // ... N군데
}



처음 짤 때부터 임시방편인 걸 알고 있었지만 한국어 단일 환경에서는 굴러갔기 때문에 다음 우선순위에 밀려 미뤄두고 있던 항목이다. 박제로 영문이 들어오는 순간 모든 분기가 else (default) 로 떨어지면서 라우팅이 통째로 깨진다. 일부는 두 분기가 사실상 같은 알림 종류를 본문 워딩 차이로 나누고 있어서, 한국어 환경에서도 카피가 살짝 바뀌면 깨질 수 있는 약한 분기였다. 어차피 정리해야 했고, 다국어 박제가 더 이상 미룰 수 없게 만든 트리거였다.

해결 방향은 분명했다. 라우팅이 의존해야 할 것은 표시용 텍스트가 아니라 알림의 의미. 즉 data 필드 안의 type 값을 클라이언트까지 끌고 와서 그걸로 분기하는 식이다. 알림 엔티티에 type 필드를 추가하고, JSON 파서와 푸시 페이로드 파서가 양쪽에서 그 값을 보존하도록 바꿨다. 로컬 DB 의 알림 테이블에도 동일한 컬럼 (nullable) 을 추가하고 마이그레이션을 한 번 돌렸다. 그리고 클라이언트 라우팅 13군데를 type switch 로 교체했다.

매핑 자체는 단순한 1:1 이었다. 본문에 들어 있는 한국어 키워드 하나마다 거기에 대응하는 의미의 type 식별자가 있었고, 그걸 그대로 옮기는 작업. 13개 분기를 동일한 패턴으로 정리하면 끝났다.

검증은 세 갈래로 했다. 한국어 환경에서 모든 카테고리 알림이 이전과 동일하게 라우팅되는지. 영어 환경에서 같은 알림이 영문 본문으로 도착하고 클릭 시 의도한 페이지로 이동하는지. 로컬 DB 마이그레이션이 기존 데이터에 영향이 없는지 — nullable 컬럼 추가라 안전했다.

이 작업에서 가장 크게 남은 건 부채 관리의 시점 문제다. `body.contains('초대')` 같은 분기는 처음부터 임시방편인 걸 알고 있었지만 한국어 단일 환경에서는 굴러갔기 때문에 계속 밀려나 있었다. 그러다 다국어라는 별개 작업이 들어오는 순간 이 부채가 선결 조건으로 바뀌면서 박제 작업 자체가 묶였다. 부채는 그 부채의 영향권에 들어오는 다른 작업이 잡힐 때 비용을 청구한다. 어차피 갚을 거라면 유관 작업이 들어오기 전에 갚는 게 일정 친화적이다.

또 하나, 표시용 데이터로 의미 분기를 하면 다른 작업이 들어왔을 때 깨진다는 점. `body.contains` 류는 카피 수정, 다국어, A/B 테스트 같은 유관 작업이 들어오는 순간 무너지지만, 그 트리거가 당장 없으면 잠복한다. "지금 동작" 과 "안전" 의 거리가 멀어지는 셈이다. 알림의 의미는 표시용 텍스트가 아니라 type 같은 식별자로 표현돼야 백엔드와 클라이언트가 같은 의미로 분기할 수 있고, 그 위에 표시 포맷, 라우팅, 분석 이벤트를 모두 안정적으로 얹을 수 있다.

마지막으로 — 이번 박제 결정은 "31종 / ko·en 두 언어 / 영문 유저 도입 마감 임박" 이라는 현재 조건 안에서 합리적이었다. 안정성과 일정을 같이 잡았다는 점에서 후회는 없지만 이걸 영구 답이라고 부를 생각도 없다. 언어가 한두 개 더 붙거나 (ja, zh 같은), 알림 카피의 실시간 A/B 테스트나 개인화가 필요해지거나, "유저가 인앱 언어를 바꾸면 과거 알림도 새 언어로 보이게 해 달라" 같은 요구가 들어오는 순간 박제 비용은 급격히 커진다. 트리거 N종 × 언어 가지 수의 곱이 운영 부담이 되고, 카피가 동적이어야 하는 케이스를 못 받고, 이미 박힌 본문을 다시 그릴 수 없는 한계가 모두 같이 드러난다. 셋 중 하나가 들어오면 그때는 동적 i18n 으로 옮기는 게 맞고, 그때를 위해 지금 박제 코드가 한 번에 마이그레이션 받을 수 있을 만큼 격리되어 있는지를 다음 작업에서 같이 점검할 예정이다.