글로벌 사용자들을 위한 푸시 메세지 로컬라이징

2023년 4월 7일

이 글은 2023년 4월 7일 퍼블 팀 블로그에 먼저 게시되었습니다. 원문 보기

글로벌 사용자들에게 로컬라이징 된 푸시 메세지 보여주기 — Notification Service Extension

안녕하세요! 퍼블의 소프트웨어 엔지니어 정주형입니다. 퍼블은 기술 없이 나만의 플랫폼으로 수익을 창출할 수 있는 노코드 플랫폼 빌더입니다. 또한 퍼블은 나만의 브랜디드 앱을 만드는 멋진 서비스도 제공하고 있답니다.

현재 퍼블은 미국, 일본, 스페인 등 글로벌 국가들을 대상으로 사용자들이 글로벌 비즈니스를 할 수 있도록 관련 기능들을 제공하고 있습니다. 얼마 전에는 Asia Artist Awards의 주관사가 퍼블을 이용해서 해외의 k팝 팬들에게 생중계를 하기도 했죠.

이번 글에서는 퍼블의 브랜디드 iOS 앱에서 글로벌 사용자를 위한 로컬라이징 된 푸시 메세지 기능을 개발하면서 겪었던 문제들과 어떻게 이를 해결 하였는지에 대해서 공유드리려 합니다. 그럼 시작해 볼까요?

Basic Localization

우선 iOS에서 기본적으로 어떻게 푸시 메세지를 로컬라이징 할 수 있는지 부터 알아보도록 하겠습니다. iOS에서 푸시 메세지를 로컬라이징 하는 방법은 보통 두가지가 있습니다. 첫번째 방법은 APNs(Apple Push Notification service) 서버에 로컬라이징 관련 키가 들어 있는 페이로드를 보내는 것입니다.

{
   "aps" : {
      "alert" : {
         "loc-key" : "GAME_PLAY_REQUEST_FORMAT",
         "loc-args" : [ "Shelly", "Rick"]
      }
   }
}

해당 http 요청이 도착하면, APNs가 디바이스에 푸시 메세지를 보여 주라는 요청을 다시 보냅니다.

"GAME_PLAY_REQUEST_FORMAT" = "%@ has invited you to play a game with them. Are you ready to play against %@?";

해당 디바이스는 GAME_PLAY_REQUEST_FORMAT 라는 Localizable String (loc-key) 를 앱 번들에 포함된 스트링 키에서 찾은 후 인자로 받은 loc-args를 할당합니다. 키가 위와 같이 되어 있고, 유저의 기기 언어가 영어라고 가정한다면, 유저가 받은 푸시 메세지의 형태는 다음과 같을 것입니다.

"Shelly has invited you to play a game with them. Are you ready to play against Rick?"

두번째 방법은 Firebase cloud messing의 토픽을 이용하는 방법입니다. Firebase 클라우드 메시징(FCM)은 메시지를 안정적으로 무료 전송할 수 있는 크로스 플랫폼 메시징 솔루션으로써 여러 플랫폼에서 사용할 수 있습니다. FCM을 사용한다면 단 한번의 요청만으로 안드로이드와 iOS 두 플랫폼에서 동일한 푸시 메세지를 사용자에게 보여줄 수 있습니다. 일반적으로 React-Native, Flutter와 같이 크로스 플랫폼 어플리케이션을 개발하고 있는 경우 FCM을 사용할 것입니다.

FCM은 pub-sup 모델을 기반으로 유저들이 특정 주제를 구독하는-특정 푸시 메세지만 받을 수 있도록 Topic 이라는 기능을 제공합니다. 일반적으로 특정 이벤트에 대한 구독을 컨트롤 하기 위해 사용되지만, 사용자의 언어도 특정한 주제가 될 수 있으므로 해당 기능을 통해 로컬라이징을 할 수 있습니다.

const message = {
    notification: {
        title: 'New York Times: Latest News',
        body: 'Check out the top stories of the day!'
    },
    condition: `'NewsApp' in topics && 'LangEnUS' in topics`
};

const messageFr = {
    notification: {
        title: 'Le Monde: Actualités',
        body: 'Découvrez les dernières actualités du jour !'
    },
    condition: `'NewsApp' in topics && 'LangFrFR' in topics`
};

예를 들어 해당 페이로드를 firebase 서버로 보내게 된다면, 유저가 구독하고 있는 토픽 (LangEnUS / LangFrFR) 에 따라서 해당 언어의 메세지를 받게 됩니다.

네, 이렇게 해서 푸시 메세지를 현지 언어에 맞게 로컬라이징 해서 보여주는 방법에 대해 알아보았습니다. 감사합니다— 하고 끝나면 좋겠지만, 실제 개발 중에서 마주한 문제들은 그리 녹록치 않았습니다. 늘 그렇듯 우리가 마주하는 비즈니스 상황은 조금 더 복잡하기 마련이죠.

Real World Probleme

퍼블의 경우 사용자의 다양한 비즈니스 요구를 충족시켜주기 위해서 여러가지 기능들을 제공하고 있습니다. 사용자는 자신의 플랫폼 내에 소셜 커뮤니티를 만들 수 도 있고, 라이브 이벤트를 할 수도 있으며 예약 기능을 제공할 수 도 있습니다. 각 기능들은 자신들의 목적에 맞추어 다양한 종류의 푸시 메세지를 사용자의 언어로 보여 줄 수 있어야 했습니다.

  • 누군가 가 어디에 글을 작성했습니다.
  • 무엇 일정이 몇 분 후에 시작됩니다.
  • 무엇 이 새로 출시 되었습니다. 지금 바로 어디에서 확인해 보세요!
  • …

위에서 설명한 다소 베이직한 방법으로 푸시 메세지를 표시하기에는 무리가 있는 상황이었죠. 따라서 이렇게 다양한 니즈를 만족시키기 위해 퍼블의 푸시 메세지는 다음과 같은 기능이 꼭 필요하였습니다.

푸시 메세지가 유저에게 도착하기 전에 서버에서 준 스트링 키 값을 실제 텍스트로 변환할 수 있어야 하고 해당 페이로드에 담겨 있는 데이터 (누구 어디 무엇 과 같은)를 문자열 보간법을 이용해서 사용자에게 보여줄 수 있어야 했죠.

만약 자바스크립트에서 해당 작업을 진행한다면 꽤나 간단한 작업이었겠지만, 첫번째 문제는 푸시 메세지가 App level이 아닌 OS level에서 작동한다는 것이 문제였습니다. 왜냐하면 OS level에서 작동되는 푸시 메세지의 경우 발송된다면 무조건 보여져야 했기 때문입니다. 즉 키 형태의 스트링 값을 변환하기도 전에 키가 노출되는 것이죠.

유저에게 푸시 얼럿을 보여주지 않지만 데이터는 전송해주는 Silent message를 사용하는 방법도 있었지만, 이 방식은 제약사항이 많아 적합치 않았습니다.

결국 문제를 해결하기 위해서는 두가지 물음을 우선적으로 해결해야 하는 상황이었습니다.

  • 어떻게 푸시 메세지가 보여지기 전에 가공할 수 있을까?
  • 어떻게 로컬라이징 된 스트링을 만들 수 있을까?
다행히도 닥터 스트레인지와는 달리 가망은 있었습니다.

다행히도 닥터 스트레인지와는 달리 가망은 있었습니다.

첫번째 문제: 어떻게 푸시 메세지를 가공할 수 있을까?

만약 발송된 푸시 메세지가 보여지는 것을 막을 수 없다면, 방법은 간단합니다. 발송된 푸시 메세지를 가로채서 가공한 후에 보여주면 됩니다.

엥, 아니 아까 안된다면서요? 맞습니다. 푸시 메세지는 OS level에서 핸들링 되므로 이를 가로채는 것은 App level에서는 불가능합니다. 하지만 이를 가능하게 해주는 친구가 있으니.. 짜잔. 바로 애플에서 2016년에 선보인 Notification Service Extension입니다.

Notification Service Extension은 개발자로 하여금 푸시 메세지가 유저에게 보여지기 전에 수정할 수 있도록 하는 기능입니다. 해당 기능을 통해 푸시 메세지를 더 유용한 것으로 바꿀수 있는데, 예를 들면 푸시 메세지에 그림을 첨부하거나 암호화 된 메세지를 복호화 할 수 있습니다. 유저의 현재 위치를 파악해서 푸시 메세지에 이를 이용해서 개인화된 메세지를 보여줄 수 있습니다.

물론 해당 유저의 기기 언어에 따라 로컬라이징된 푸시 메세지를 보여주는 것도 할 수 있습니다. 아까 불가능했던 인자를 텍스트에 넣고, 이를 해당 언어로 바꾸어서 보여주는 작업을 할 수 있는 것입니다.

Get started

Notification Service Extension을 설치하는 것은 간단합니다. 타겟을 생성하는 것 처럼 Notification Service Extension을 생성한 뒤, 기존 앱에 embedding 시키면 끝입니다. 설치 과정의 경우 해당 문서가 더 잘 설명하고 있으므로, 링크로 대체하도록 하겠습니다.



//
//  NotificationService.m
//  TEST
//
//  Created by cclss on 2023/04/06.
//

#import "NotificationService.h"

@interface NotificationService ()

@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@end

@implementation NotificationService

- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
    self.contentHandler = contentHandler;
    self.bestAttemptContent = [request.content mutableCopy];
    
    // Modify the notification content here...
    self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title];
    
    self.contentHandler(self.bestAttemptContent);
}

- (void)serviceExtensionTimeWillExpire {
    // Called just before the extension will be terminated by the system.
    // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
    self.contentHandler(self.bestAttemptContent);
}

@end

이제 푸시 메세지가 앱으로 도착하면, 해당 메세지를 바로 표시하는 대신 didReceiveNotificationRequest 메소드가 호출되게 됩니다. serviceExtensionTimeWillExpire 에서는 해당 익스텐션에서 타임 아웃이 되거나 에러가 발생한다면 원본 메세지를 다시 전달합니다.

24번째 줄에서 푸시 메세지의 title에 'modified'를 붙이고 있으므로 유저에게 보여지는 푸시 메세지는 다음과 같게 됩니다.

이렇게 해서 첫번째 문제를 해결할 수 있었습니다.

두번째 문제: 어떻게 로컬라이징 된 스트링을 만들 수 있을까?

푸시 메세지가 유저에게 도달하기 전에 Notification Service Extension이 푸시 메세지를 가로챌 수 있게 했으니, 이제 실제로 문자열 변환을 할 차례입니다. 서버에서 받아온 스트링 키를 유저의 언어에 맞게 실제 텍스트로 변환하고, 해당 페이로드에 들어 있는 데이터를 할당한다면 유저들은 자신의 언어에 따라 푸시 메세지를 받을 수 있게 되겠죠.

Objective C 에서 스트링 키를 유저의 언어에 맞게 보여주려면 NSLocalizedString이란 메소드를 사용할 수 있습니다. NSLocalizedString는 앱 번들에 포함된 Localizable.strings에서 유저의 언어에 맞는 스트링 값을 불러옵니다.

// Localizable string

// en
FEED_CREATION_BODY_KEY = "Wrote a new post.";

// kr
FEED_CREATION_BODY_KEY = "새 게시글을 게시하였습니다."
NSString *localizedString = NSLocalizedString(@"FEED_CREATION_BODY_KEY", @"알림 관련 키");

이제 누구 언제 어떻게 와 같은 알림의 변수를 넣어 문자열 보간을 할 차례입니다. Objective C에서 문자열에 변수를 할당하려면 stringWithFormat 메서드를 사용할 수 있습니다. 해당 메서드는 인자로 받은 변수를 순서 ‘그대로’ 할당합니다.

// Localizable string

// en
FEED_CREATION_BODY_KEY = "‘%@’ wrote a new post.";

// kr
FEED_CREATION_BODY_KEY = "'%@'님이 새 게시글을 게시하였습니다."
NSString *localizedString = [NSString stringWithFormat:FEED_CREATION_BODY_KEY, name];

그러나 영어와 한글의 어순은 다릅니다. 그리고 변수가 하나가 아닌 누구 언제 어떻게 와 같이 여러개의 변수를 사용할 수 있어야 했죠. 자바스크립트의 경우 런타임 언어이므로 런타임에 알아서 변수가 자신의 자리를 찾아갈 수 있지만, Objective C는 컴파일 언어이므로 해당 방식의 불가능합니다. 그러나 다행히도 Objective C는 position specifier를 사용해서 변수의 순서를 지정해 줄 수 있습니다.

NSString *formatString = @"The capital of %2$@ is %1$@";

NSString *formatString = @"%2$@ 의 수도는 %1$@";

이렇게 키를 작성한다면, stringWithFormat 메서드의 할당한 인자가 순서대로 들어가게 됩니다. 이렇게 끝인줄 알았지만 정말 마지막으로 귀찮은 문제가 남아 있습니다. 바로 이것입니다.

“When numbered argument specifications are used, specifying the Nth argument requires that all the leading arguments, from the first to the (N-1)th, are specified in the format string”

즉, 다음과 같이 사용할 수 없습니다.

NSString *formatString = @"%2$@";

왜냐하면 두번째 인자를 사용하려면 첫번째 인자가 있어야 하기 때문입니다. 즉 10개의 인자를 가지고 position specifier를 사용할 경우 10개의 인자를 모두 명시해야 합니다.

퍼블의 경우 누가 어떻게 무엇을 등의 알림 관련 변수들이 미리 정해져 있었습니다. 각 키마다 어떠한 값만 사용할지 모두 분기처리하는 것은 불가능했기 때문에 position specifier를 사용하지 않고 다른 메소드를 사용해야 했습니다.

/**
 Formats a string with positional specifiers.
 
 @param format The format string with positional specifiers.
 @param args The arguments to replace the positional specifiers.
 @return The formatted string.
 */
- (NSString*)stringWithPositionalSpecifierFormat:(NSString*)format arguments:(NSArray*)arg
{
    static NSString *pattern = @"%\\d+\\$@";
  
    NSError *error;
    NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive error:&error];

    NSMutableString *mString = [[NSMutableString alloc] initWithString:format];
    NSArray *allMatches = [regex matchesInString:format options:0 range:NSMakeRange(0, [format length])];
    if (!error && allMatches>0)
    {
        for (NSTextCheckingResult *aMatch in allMatches)
        {
            NSRange matchRange = [aMatch range];
            NSString *argPlaceholder = [format substringWithRange:matchRange];
            NSMutableString *position = [argPlaceholder mutableCopy];
            [position replaceOccurrencesOfString:@"%" withString:@"" options:NSCaseInsensitiveSearch range:NSMakeRange(0, [position length])];
            [position replaceOccurrencesOfString:@"$@" withString:@"" options:NSCaseInsensitiveSearch range:NSMakeRange(0, [position length])];
            int index = position.intValue;
            [mString replaceOccurrencesOfString:argPlaceholder withString:arg[index-1] options:NSCaseInsensitiveSearch range:NSMakeRange(0, [mString length])];
        }
    }
    return mString;
}

Multiple arguments in stringWithFormat: “n$” positional specifiers

해당 메서드는 두가지 인자를 받습니다. 첫번째 인자는 위치를 포함한 스트링이고, 두번째 인자는 해당 위치에 할당할 배열 형태의 값들입니다. 이제 문자열 regex를 이용해서 순서에 맞는 변수값을 넣게 됩니다.

...  
    self.bestAttemptContent.title = [self stringWithPositionalSpecifierFormat: NSLocalizedString([self.bestAttemptContent valueForKeyPath:@"userInfo.aps.data.subsMsgTitleKey"], nil) arguments:notificationPayload];
    self.bestAttemptContent.body = [self stringWithPositionalSpecifierFormat: NSLocalizedString([self.bestAttemptContent valueForKeyPath:@"userInfo.aps.data.subsMsgBodyKey"], nil) arguments:notificationPayload];
    
    [[FIRMessaging extensionHelper] populateNotificationContent:self.bestAttemptContent withContentHandler:contentHandler];
}

그래서 이제 Notification Service Extension에서 받은 페이로드를 사용해서 문자열을 생성한 후, 해당 메세지를 보내면 로컬라이징 된 푸시 메세지를 유저에게 보여줄 수 있습니다. 스트링을 변환하는 것이 가장 간단한 작업일 거라 예상했지만 Javascript에 더 친숙한 개발자다 보니 가장 오래 걸린 작업이었습니다.

마치며

푸시 메세지는 일반적으로 앱의 재사용율을 높이는 가장 중요한 수단으로 사용됩니다. 새로운 구독자에게 첫주 내로 푸시 메세지를 보내면 앱의 리텐션률이 71% 가량 상승한다는 설문 조사도 있을 정도죠. 현재로 퍼블로 만들어진 수많은 플랫폼들이 글로벌 서비스를 진행하고 있는 지금, 로컬라이징된 푸시 메세지로 해당 브랜디드 앱의 리텐션률이 상승했기를 바라며 글을 마치도록 하겠습니다.


글로벌 사용자들에게 로컬라이징 된 푸시 메세지 보여주기 — Notification Service Extension was originally published in 퍼블 팀 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.