로컬 알림 다국어 적용기: 언어가 바뀌어도 알림을 다시 등록하지 않아도 되는 이유

2026. 2. 3. 23:37·SWIFT개발일지

글로벌 앱을 개발하면서 알림 기능을 설계부터 구현까지 맡게 되었어요:) 

어떻게 보면 첫 막중한 설계부터 책임감있는 일이 왔네요.

이전의 FCM도 했지만 이번에는 Local 기반의 앱이기에 알림조차도 로컬기반으로 구현을 했어야 했죠

출근하는 흔한 직장인의 로컬 알림...

https://codeisfuture.tistory.com/159

 

푸시 알람을 어떻게 설계할까?

토스 같은 금융 앱은 하루에 수백만 건의 알림을 보냅니다. 송금 완료, 카드 결제, 이벤트 참여, 마케팅까지.....만약 이걸 그냥 "생각날 때마다" 보낸다면? 사용자는 알림 폭격에 스트레스 받아서

codeisfuture.tistory.com

푸시 알림의 경우 이전의 글에서 더 자세히 다뤘기에 간단하게 생략할게요

오늘은 로컬 알림을 구현하면서 겪은 사용자가 언어를 바꾸면 알림 언어는 어떻게 되지? 라는 고민과 그 해결 과정을 공유해보려 합니다.

문제의 시작: 왜 언어를 바꿨는데 등록된 알림은 이전 언어로 나와요?

사용자가 설정한 시간에 물 마시라고 알려주는 전형적인 리마인더 기능을 만든다고 가정해 봅시다. 처음에는 가볍게 코드를 짰어요

let content = UNMutableNotificationContent()
content.title = "물 마실 시간이에요"
content.body = "2L 목표까지 500ml 남았어요"

이렇게 등록하면 지정된 시간에 알림이 잘 울립니다.

하지만 사용자가 해외로 나가 시스템 언어를 영어로 바꾸거나, 앱 내 설정을 변경하면 어떻게 될까요?

"알림이 한국어로 나와요."

당연한 얘기였어요. 저는 한국어 문자열을 직접 박아넣었으니까...당연히 어색하죠.

그러면 문제는 어떻게 유저의 언어 설정을 감지하고 알림을 적절한 언어로 울리게 해줄수 있을까요?

처음 제가 생각한 해결책

앱이 켜질 때마다 언어 설정을 체크해서, 바뀌었다면 기존 알림을  removeAllPendingNotificationRequests()로 싹 지우고 새로 등록해주면 되지 않을까?

실제로 구현 직전까지 갔습니다...

title이랑 body에 문자열을 직접 넣어주니까, 한번 등록하면 바꿀 방법이 없다고 생각했거든요.

하지만 이 방식은 치명적인 단점이 있었습니다.

  • 수십개라면.,? -> 오버헤드
  • 앱이 백그라운드에 있을 때는 언어가 바뀌면 어떻게 감지하지?
  • 타이밍 이슈로 알림이 누락되면 어떡하지?

그래서 좀 더 찾아봤고, NSString.localizedUserNotificationString이라는 메서드를 발견했습니다. 오늘 본격적으로 말할 주제!.

그전에 다른 앱들은 어떻게 했을까를 조금 찾아봤는데..
todoist나 google Calender에서는 둘다 등록 시점의 언어로 유지해서 보여주더라고요...!

NSString.localizedUserNotificationString

Apple 공식 문서를 보니 이렇게 설명하고 있더라고요.

"Returns a localized string intended for display in a notification alert."

혹시 NSLocalizedString는 들어보셨나요?

저도 첨듣긴했는데 이것도 번역된 문자열을 반환하는  건데 한번 비교해보면서 볼게요

NSLocalizedString: 즉시 평가

// 호출하는 순간 현재 언어로 번역된 "문자열"을 반환
let title = NSLocalizedString("reminder.title", comment: "")
// → "물 마실 시간이에요" (String 타입)

content.title = title
// → 문자열 "물 마실 시간이에요"가 그대로 저장됨

이 방식은 즉시 평가입니다. 

NSLocalizedString을 호출하는 순간, 현재 언어 설정을 확인해서 번역된 문자열을 반환해요. 그 문자열이 title에 저장되는 거죠.

문제는 뭘까요? 한번 저장되면 끝이에요. 나중에 언어가 바뀌어도 이미 박힌 문자열은 안 바뀝니다.

localizedUserNotificationString: 지연 평가 (Lazy Evaluation)

// "문자열"이 아니라 "키 정보를 담은 토큰"을 반환
content.title = NSString.localizedUserNotificationString(
    forKey: "reminder.title", 
    arguments: nil
)
// → 내부적으로 키 정보만 저장됨

이 방식은 지연 평가입니다. 실제 번역은 알림이 표시되는 순간에 수행돼요.

Medium의 한 아티클에서 이 개념을 이렇게 설명해요

"If we had used String(localized:) when setting the notification content, the message would have already been translated. If the user changed the locale or language before receiving the scheduled notification, they would see it in a different language."

근데 여기서 또 의문이 생겨요🤦🏻‍♀️

앱 코드가 실행되지 않는데 어떻게 현재 언어로 번역이 되는 걸까요?

로컬 알림의 핵심은 앱이 완전히 종료된 상태에서도 뜬다는 거예요

앱 코드가 실행되지 않는데 어떻게 현재 언어로 번역이 되는 걸까요?

OS가 앱 번들의 리소스를 직접 읽을 수 있다는 점에 있어요.

MyApp.app/
│
├── 📦 앱 번들 (정적 리소스) ← OS가 직접 접근 가능
│   ├── ko.lproj/
│   │   └── Localizable.strings
│   │       "reminder.title.drink" = "물 마실 시간이에요";
│   │       "reminder.body.drink" = "목표까지 %@ 남았어요";
│   │
│   ├── en.lproj/
│   │   └── Localizable.strings
│   │       "reminder.title.drink" = "Time to drink water";
│   │       "reminder.body.drink" = "%@ left to reach your goal";
│   │
│   └── ja.lproj/
│       └── Localizable.strings
│           "reminder.title.drink" = "水を飲む時間です";
│
├── ⚙️ 앱 코드 (실행 필요) ← 앱이 꺼져있으면 실행되지 않음
│   ├── AppDelegate.swift
│   ├── NotificationManager.swift
│   └── ...

OS는 `bundleID`만 알면 앱 번들의 경로를 찾을 수 있고, 그 안의 `Localizable.strings` 파일을 직접 읽을 수 있어요.

앱은 "이 키값으로 나중에 번역해줘"라는 예약표만 던져두고, 실제 번역은 iOS가 앱 번들의 정적 리소스(.strings)를 직접 읽어서 처리하는 방식입니다.

1단계: 알림 등록 시점 (앱 실행 중)

앱에서 localizedUserNotificationString을 호출하면, 반환되는 건 번역된 문자열이 아니라 키 정보를 담은 특수한 객체예요.

localizedUserNotificationString(forKey: "reminder.title.drink", arguments: nil)

이게 OS의 Notification Daemon에 저장될 때는 이런 식으로 저장됩니다.

{
  "titleKey": "reminder.title.drink",
  "titleArgs": null,
  "bundleID": "com.example.myapp",
  "trigger": { "type": "calendar", "date": "2024-01-15T09:00:00" }
}

 

2단계: 알림 표시시점(앱이 꺼져 있을때)

며칠 후, 트리거 조건이 충족되면 OS의 Notification Daemon이 알림을 표시합니다. 이때 OS가 하는 일을 순서대로 보면

1. 저장된 bundleID로 앱 번들 경로 찾기 → /var/containers/Bundle/Application/[UUID]/MyApp.app

2. 현재 시스템 언어 확인 -> "en"

3. 해당 언어의 Localizable.strings 파일 읽기 → MyApp.app/en.lproj/Localizable.strings -> 키 매칭

4. arguments가 있다면 문자열 포맷팅 적용

→ "reminder.body.drink" = "%@ left to reach your goal"; 

→ arguments: ["500ml"]  → "500ml left to reach your goal"

5. 최종 번역된 문자열로 알림 배너 표시

알림 등록 코드

let content = UNMutableNotificationContent()

content.title = NSString.localizedUserNotificationString(
    forKey: "reminder.title.drink", 
    arguments: nil
)

content.body = NSString.localizedUserNotificationString(
    forKey: "reminder.body.drink",
    arguments: ["500ml"]
)

// 트리거 설정 및 등록
let trigger = UNCalendarNotificationTrigger(/* ... */)
let request = UNNotificationRequest(identifier: UUID().uuidString, 
                                   content: content, 
                                   trigger: trigger)

UNUserNotificationCenter.current().add(request)

이렇게 하면 끝이에요. 사용자가 언어를 바꿔도, 알림은 바뀐 언어로 뜹니다. 재등록이 필요 없어요.

arguments 사용할 때 주의할 점

알림에 동적인 값을 포함해야 할 때는 arguments 파라미터를 사용하는데, 몇 가지 주의할 점이 있어요.

타입 문제

Apple Developer Forums에서 발견한 이슈인데, arguments는 반드시 CVarArg를 준수하는 타입이어야 해요.

// ❌ 이렇게 하면 에러 발생하는 경우가 있음
arguments: [5]  // Int는 CVarArg이지만 에러가 발생하는 경우가 있음

// ✅ 문자열로 변환해서 사용
arguments: ["\(count)"]
arguments: ["500ml"]

어순 대응

영어는 "이름 + 행동 + 양"이지만, 한국어는 "이름 + 대상 + 양 + 행동"이에요. 어순이 완전히 달라요.

// ❌ 어순이 다르면 문제
"notification.body" = "%@ drank %@ of water";  // 영어
"notification.body" = "%@님이 물 %@를 마셨어요";  // 한국어 (순서는 맞지만 위험)

// ✅ positional specifier 사용
"notification.body" = "%1$@ drank %2$@ of water";  // 영어
"notification.body" = "%1$@님이 물 %2$@를 마셨어요";  // 한국어
 
arguments: ["John", "500ml"]
// 영어: "John drank 500ml of water"
// 한국어: "John님이 물 500ml를 마셨어요"

OS 알림은 해결되었는데... 앱 내 알림함은?

여기까지하면 OS 알림은 완벽하게 해결되었어요. 언어를 바꿔도 알림 배너는 현재 언어로 잘 뜨는 거죠.

근데 문제가 하나 더 있었어요. 앱 안에서 "알림 목록"을 보여주는 경우를 생각해봐야 했어요.

여기서 또 의문이 생겼죠. OS는 앱 번들을 직접 읽어서 번역할 수 있지만, 데이터베이스에 저장된 문자열은 OS가 번역해줄 수 없어요...ㅜ

왜냐하면 알림함은 앱이 실행 중일 때 표시되는 거니까요. OS가 번들을 읽어서 번역하는 방식이 아니라, 앱 자체가 DB에서 데이터를 꺼내서 표시하는 거예요.

처음 시도: 번역된 문자열을 DB에 저장

NotificationRecord(
    id: 1001,
    type: .hydrationReminder,
    title: "물 마실 시간이에요",           // 한국어로 고정
    body: "목표까지 500ml 남았어요",       // 한국어로 고정
    createdAt: Date()
)

자 이제 눈치채셧죠? 이렇게 저장하면 언어를 바꿔도 DB에 박힌 한국어는 안 바뀌죠

DB 구조를 어떻게 짜야할까?

핵심 아이디어는 간단해요. DB에는 "무슨 종류의 알림인지"와 "동적 값"만 저장하고, 실제 문자열은 표시 시점에 생성하는 거예요.

문자열은 사용자에게 보여주기 위한 표현 방식일 뿐이에요. 그리고 표현 방식은 사용자의 현재 언어 설정에 따라 달라져야 하고요.

구조

@Model
final class NotificationRecord {
    var id: Int64
    var type: String              // 알림 종류. 핵심 정보
    var metadata: String?         // JSON. 동적 값 저장
    var createdAt: Date
    var isRead: Bool
    var isSent: Bool              // OS 등록 성공 여부
    
    // title, body는 아예 없음
}

title과 body는 아예 저장하지 않아요. type으로 알림의 종류만, metadata로 동적 값만 저장해요.

 

왜 metadata가 필요할까요?

예를 들어 "목표까지 500ml 남았어요"라는 문자열에서 500ml은 사용자마다 다른 값이에요.

이 정보는 어디에든 저장되어야 하는데, type만 저장하면 이게 사라져요.

// ✅ 저장 시
let metadata: [String: String] = [
    "amount": "500ml",
    "goal": "2L"
]

NotificationRecord(
    type: "hydrationReminder",
    metadata: metadata.jsonString,  // JSON으로 변환하여 저장
    ...
)

표시 시점에 동적 생성

알림함에서 목록을 표시할 때, type과 metadata를 보고 현재 언어로 동적 생성해요.

enum NotificationType: String {
    case hydrationReminder
    case goalAchieved
    case weeklyReport
}

struct NotificationTextBuilder {

    static func title(for type: NotificationType) -> String {
        switch type {
        case .hydrationReminder:
            return String(localized: "inbox.title.hydration_reminder")
        case .goalAchieved:
            return String(localized: "inbox.title.goal_achieved")
        case .weeklyReport:
            return String(localized: "inbox.title.weekly_report")
        }
    }

    static func body(for type: NotificationType, metadata: [String: String]) -> String {
        switch type {
        case .hydrationReminder:
            let amount = metadata["amount"] ?? ""
            return String(localized: "inbox.body.hydration_reminder \(amount)")
        case .goalAchieved:
            return String(localized: "inbox.body.goal_achieved")
        case .weeklyReport:
            return String(localized: "inbox.body.weekly_report")
        }
    }
}

동작 흐름✈️

[한국어로 사용 중]
알림 발생
  → DB 저장: { type: "hydrationReminder", metadata: {"amount": "500ml"} }
  → OS 등록: localizedUserNotificationString(forKey: "reminder.title.drink")

        ↓ (사용자가 영어로 변경)

[영어로 변경 후 알림함 진입]
알림함 화면 표시
  → DB에서 조회: type = "hydrationReminder", metadata = {"amount": "500ml"}
  → NotificationTextBuilder.title(for: .hydrationReminder)
  → String(localized: "inbox.title.hydration_reminder")
  → "Time to Hydrate" ✅ (현재 영어 환경)

  → NotificationTextBuilder.body(for:metadata:)
  → "500ml left to reach your goal" ✅

주의할 점 몇 가지

  • 키가 없으면 키가 그대로 표시됨 - Localizable.strings에 해당 키가 빠진 언어라면, 알림에 키 문자열 자체가 그대로 표시되는 거예요. OS가 폴백으로 키를 보여주는 거죠. 지원 언어 전부에 빠짐없이 키가 있는지 반드시 체크해야 해요.
  • 번역 변경은 앱 업데이트가 필요함 - 번들에 포함된 파일을 읽는 방식이기 때문에, 번역을 수정하려면 앱을 새로 빌드해서 배포해야 해요. 서버에서 실시간으로 바꿀 수 없어요.

결국 문제의 본질은 하나였어요.

언제 문자열이 확정되는지.

등록 시점에 확정되면 언어 변경이 반영 안 되고, 표시 시점에 확정되면 항상 현재 언어로 보여지는 거죠. OS 알림에서도, DB에서도, 결국 같은 질문에 대한 답이었어요.

비슷한 고민하고 계신 분들께 도움이 되길 바랄게요 😊

'SWIFT개발일지' 카테고리의 다른 글

TCA Dependency는 어떻게 동작하는지 정확하게 설명할수 있는 사람  (0) 2026.05.24
SwiftUI TabView 오버레이가 iOS 26에서 안 맞는 이유  (0) 2026.04.04
왜 한번 빌드 이후부터는 빌드가 더 빠를까? - 증분 빌드  (0) 2026.01.25
ProtoBuf - 이게 뭔데 사람들은 환호성을 지를까?  (1) 2025.11.29
아 지겹다 복붙! Xcode 커스텀 템플릿 만들기  (1) 2025.11.25
'SWIFT개발일지' 카테고리의 다른 글
  • TCA Dependency는 어떻게 동작하는지 정확하게 설명할수 있는 사람
  • SwiftUI TabView 오버레이가 iOS 26에서 안 맞는 이유
  • 왜 한번 빌드 이후부터는 빌드가 더 빠를까? - 증분 빌드
  • ProtoBuf - 이게 뭔데 사람들은 환호성을 지를까?
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (148)
      • SWIFT개발일지 (35)
        • ARkit (1)
        • Vapor-Server with swift (3)
        • UIkit (2)
      • 알고리즘 (25)
      • Design (6)
      • iOS (46)
        • 반응형프로그래밍 (12)
      • 디자인패턴 (6)
      • CS (3)
      • 도서관 (3)
      • 인생회고 (1)
      • AI 라이브러리 (1)
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
2료일
로컬 알림 다국어 적용기: 언어가 바뀌어도 알림을 다시 등록하지 않아도 되는 이유
상단으로

티스토리툴바