Hotsse Developer

Spring MessageSource

Spring MessageSource 란?

Spring MessageSource는 국제화(i18n)와 다국어 메시지 처리를 지원하기 위해 제공되는 Spring의 인터페이스다.
UI나 로그, 예외 메시지를 코드에 하드코딩하지 않고 메시지 번들(properties 파일)을 통해 관리할 수 있게 해준다.


주요 기능

  1. 메시지 추출
    • 코드(key) 기반으로 메시지를 가져옴
  2. 다국어 지원
    • 요청의 Locale 에 따라 다른 메시지를 반환
  3. 매개변수 포맷팅
    • 메시지 내 {0}, {1} 같은 부분에 값 삽입 가능
    • 그러나 실제 DB 연결은 현재 컨텍스트에 따라 선택된 데이터소스를 통해 이루어짐
  4. 기본 메시지 설정
    • 키가 없을 경우 기본 메시지를 설정 가능

예제 코드

아래는 글로벌 그룹사에 대한 그룹웨어 서비스를 제공하기 위해 사용자의 법인에 따라 다른 언어를 노출해야 하는 시나리오이다.

각 요청 접근 시 Interceptor 에서 사용자 토큰에서 사용자 언어를 조회하여 LocaleContextHolder 에 해당 정보를 저장한다.
(LocaleContextHolder 는 Spring 에서 제공하는 ThreadLocale 기반의 Context 저장소이다)

@Component
public class LocaleInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String lang = request.getHeader("Accept-Language"); // 또는 "X-Locale", 커스텀 헤더 가능
        Locale locale = (lang != null) ? Locale.forLanguageTag(lang) : Locale.getDefault();
        LocaleContextHolder.setLocale(locale); // ThreadLocal에 저장
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        LocaleContextHolder.clear();
    }
}

MessageSource 를 통해 다국어 메시지 데이터를 조회한다. (다국어 호출 시 별도의 Locale 을 지정하지 않으면 LocaleContextHolder 기준으로 다국어 언어를 조회한다)

@Autowired
private MessageSource messageSource;

public String getWelcomeMessage() {
    return messageSource.getMessage("greeting", new Object[]{"죠르디"});
}

각 message 에는 언어별 메시지 정보를 정의한다.

# messages_ko.properties
greeting=안녕하세요, {0}님!

# messages_en.properties
greeting=Hello, {0}!


Spring AbstractRoutingDataSource

AbstractRoutingDataSource 란?

AbstractRoutingDataSource는 Spring Framework에서 제공하는 동적 데이터소스 라우팅을 위한 추상 클래스다. 쉽게 말해, 여러 데이터소스를 등록해두고 런타임에 상황에 따라 적절한 데이터소스를 선택해주는 역할을 한다.


주요 역할

  1. 다수의 DataSource 관리
    • 하나의 애플리케이션에서 여러 DB 연결을 사용할 수 있도록 함 (예: Master/Slave 구조, 멀티 테넌시 등)
  2. 현재 컨텍스트에 맞는 DataSource 결정
    • determineCurrentLookupKey() 메서드를 오버라이딩하여, 현재 어떤 데이터소스를 사용할지 결정함
  3. 실제 라우팅 처리
    • 내부적으로는 DataSource 인터페이스를 구현하고 있어서, 마치 일반 DataSource처럼 사용할 수 있음
    • 그러나 실제 DB 연결은 현재 컨텍스트에 따라 선택된 데이터소스를 통해 이루어짐

동작 흐름 요약

  1. 여러 개의 DataSource 를 Map 형태로 등록 (targetDataSources)
  2. determineCurrentLookupKey() 메서드가 호출되어 현재 컨텍스트의 키를 반환
  3. 그 키에 해당하는 DataSource 를 찾아서 실제 쿼리 수행 시 사용

주로 사용되는 사례

  • 읽기/쓰기 분리
  • 멀티 테넌시
  • 샤딩/분산 DB 구성
  • 다중 지역 DB 연결 등

예제 코드

아래는 그룹사의 그룹웨어 서비스의 입장에서 하나의 서비스가 각 법인마다 다른 DataSource 에 연결해야 하는 시나리오이다.

각 요청 접근 시 Interceptor 에서 사용자 토큰에서 사용자의 소속 법인을 조회하여 ContextHolder 패턴을 통해 ThreadLocal 에 소속 법인 정보를 저장한다.

public class CorporationContextHolder {
    private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();

    public static void setCorpCode(String corpCode) {
        contextHolder.set(corpCode);
    }

    public static String getCorpCode() {
        return contextHolder.get();
    }

    public static void clear() {
        contextHolder.remove();
    }
}
@Component
public class CorporationContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String corpCode = request.getHeader("X-Corp-Code"); // 또는 토큰 파싱 등
        CorporationContextHolder.setCorpCode(corpCode);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        CorporationContextHolder.clear();
    }
}

DataSource 접근 시 사용자의 법인코드에 대해 동적 라우팅을 진행한다.

public class CorporationRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return CorporationContextHolder.getCorpCode(); // 현재 법인코드 반환
    }
}

각 법인에 대한 DB 정보와 CustomRoutingDataSource 설정 정보를 정의한다.

@Configuration
public class DataSourceConfig {

    @Bean
    public DataSource dataSource() {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("CORP_A", dataSourceForCorpA());
        targetDataSources.put("CORP_B", dataSourceForCorpB());

        CorporationRoutingDataSource routingDataSource = new CorporationRoutingDataSource();
        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(dataSourceForCorpA()); // default 설정
        routingDataSource.afterPropertiesSet();

        return routingDataSource;
    }

    public DataSource dataSourceForCorpA() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://hostA:3306/db_corpA")
            .username("userA")
            .password("passA")
            .driverClassName("com.mysql.cj.jdbc.Driver")
            .build();
    }

    public DataSource dataSourceForCorpB() {
        return DataSourceBuilder.create()
            .url("jdbc:mysql://hostB:3306/db_corpB")
            .username("userB")
            .password("passB")
            .driverClassName("com.mysql.cj.jdbc.Driver")
            .build();
    }
}


TIL 20240403

신규로 추가되어야 하는 비즈니스 로직 및 이해관계가 복잡한 서비스를 어떻게 운영할 것인가?

  • 우선 복잡한 내용이 정말 복잡한 것인지? 오해나 기반지식 부족으로 인한 착오가 아닌지부터 재확인한다.
  • 그럼에도 불구하고 명백하게 복잡한 내용이라면, 신규 비즈니스의 사용추이를 고려하여 아예 시스템화 하지 않거나, 일부 스펙을 제외하고 개발하는 방향으로 진행한다.
  • 그럼에도 불구하고 꼭 시스템화 해야하는 비즈니스라면, 공통화를 해치는 해당 비즈니스에 대한 모듈을 격리하여 프로젝트 서비스 전체가 오염되지 않도록 한다.

격리는?

  • 아예 서비스 모듈을 분리해 버릴 수도 있고
  • 비즈니스로직 적으로 분할이 가능한 구조라면 패키지 단위로 분리해서 가져가는 것도 좋다.

제일 중요한 건 하나의 요구사항 때문에 프로젝트 전체의 운영&개발 난이도가 올라가지 않도록(오염되지 않도록) 하는 것이라고 생각한다.


Push Notification 설정 구성하기

PC, Android 환경 설정

https://console.firebase.google.com/u/1/(firebase cloud messaging)

  • 프로젝트 생성
  • 프로젝트 > 프로젝트 설정 > 일반 > 내 앱 > 앱추가
    • 웹 앱(PC, Android 용) 등록
    • 등록 시 제공받은 앱ID(key) 로 인증하며 npm 으로 연동 가능

iOS 환경 설정(FCM based)

https://developer.apple.com/

  • 계정 > 프로그램 리소스
  • Keys
    • iOS 앱으로 Push 를 날릴 수 있는 권한(Apple Push Notification service)을 가진 key 를 생성한다.

https://console.firebase.google.com/u/1/(firebase cloud messaging)

  • 프로젝트 > 프로젝트 설정 > 일반 > 내 앱 > 앱 추가
    • Apple 앱(iOS 용) 등록
    • 등록시 제공받은 .plist 파일을 iOS 프로젝트 root 에 추가
  • 프로젝트 > 프로젝트 설정 > 클라우드 메시징
    • Apple 앱 구성
      • apple developer 에서 apns key 발급 시 획득한 아래의 정보를 등록한다.
        • APN 인증키 파일(.p8)
        • 키 ID
        • 팀 ID

참조

  • https://firebase.google.com/docs/cloud-messaging/ios/first-message?hl=ko
  • https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns
  • https://developer.apple.com/documentation/usernotifications#//apple_ref/doc/uid/TP40008194-CH8-SW1
  • https://burgerkinghero.tistory.com/1
  • https://dokit.tistory.com/49


PWA 를 native app 으로 빌드하기

PWA 를 Android App 으로 변환하기

Trusted Web Activity 란?

  • Android App 내에서 풀스크린 웹 콘텐츠를 표현할 수 있는 방법을 제공하는 기술.
  • 신뢰할 수 있는 PWA 사이트를 WebView 로 감싼 Android App 으로 감싸서 PlayStore 에 배포 가능한 형태로 변환할 수 있다.
  • PWA <-> TWA 사이에서 fingerprint 값을 공유하며, 일치하지 않는(인증되지 않은 앱) 경우는 URL UI 가 노출되는 등 풀스크린을 제공하지 않는다.

PWABuilder 로 TWA 변환 해보기

  • https://www.pwabuilder.com/

bubblewrap 로 TWA 변환 해보기

  • GoogleChromeLabs/bubblewrap 라이브러리를 사용하여 프로젝트를 빌드한다.
  • bubblewrap lib. 설치
    npm install -g bubblewrap
    
  • bubblewrap 으로 TWA 프로젝트 생성
    bubblewrap init --manifest https://your-pwa.com/manifest.json
    
    • 초기 셋팅인 경우 빌드 환경의 Java Path 와 SDK Path 연결해야 한다.
    • Android App 변환을 위한 기본정보를 요구하나 대부분 manifest.json 기반으로 기본값을 제공한다.
    • Signing Key 가 없는 경우 새로 .keystore 파일을 생성해 준다.
  • SHA256 fingerprint 획득
    keytool -list -v -keystore [keystore 파일명]
    
  • SHA256 fingerprint 를 PWA 프로젝트 assetlinks.json 에 등록
    • fingerprint 를 https://your-pwa.com/.well-known/assetlinks.json 에 등록한다.
      [{
        "relation": ["delegate_permission/common.handle_all_urls"],
        "target": {
          "namespace": "android_app",
          "package_name": "com.your-pwa.twa",
          "sha256_cert_fingerprints": ["INSERT_YOUR_SHA256_FINGERPRINT"]
        }
      }]
      
  • TWA 프로젝트 빌드
    bubblewrap build
    

PWA 를 iOS App 으로 변환

  • iOS 환경에서는 순수 PWA 만으로 우리가 원하는 목표를 달성할 수 없음을 깨달았다
  • Android 진영의 TWA 처럼 PWA 를 iOS App 으로 변환하는 것은 따로 지원하지 않는 것으로 확인했다.
    • PWA 개념 자체가 구글에서 미는거다보니 iOS 에서 소극적으로 지원하고 있음.
  • PWA 를 iOS native-app WebView 로 Warpping 후, app 형태로 직접 구현하여 제공하는 것이 유일한 방법이다.
    • 그러나, PWA 를 iOS App > WebView 로 감싼 형태에서는 PWA Web Push API 가 동작하지 않으므로, 순수 Web Push Notification 사용이 불가능하다.

Wrapping 전략1. Swift 로 iOS 앱 직접 개발

  • 개발 환경 구성
    • Xcode(IDE)
    • Swift(Language)
    • CocoaPods(lib. dep.)
  • 샘플 프로젝트(https://github.com/khmyznikov/ios-pwa-wrap)
  • PWA사이트에서 알람권한신청 → native app 영역으로 권한신청에 대한 메시지 발송 → 메시지 수신한 native 영역에서 iOS notification 권한 허용 요청 및 획득 가능

Wrapping 전략2. React Native 로 개발

  • 추후 업데이트

Wrapping 전략 etc..

  • ionic framework
    • capacitor
    • apache cordova
  • flutter

App 빌드 구조에 대한 결론 image


결론 : RN 기반으로 최소한의 native 기능을 제공하고, 메인 서비스는 WebView 안에서 PWA 규격에 맞게 기능을 제공한다.

  • RN 을 통해 모바일 환경별 native app 구성에 대한 프로세스를 통일
  • 메인 서비스는 WebView 내 웹서비스로 구성하여 조직 주요 기술스택을 따름
  • 웹서비스는 PWA 로 구현하여 오프라인 작동 등의 장점도 수용

참조

  • https://developer.chrome.com/docs/android/trusted-web-activity?hl=ko
  • https://developer.chrome.com/docs/android/trusted-web-activity/quick-start?hl=ko
  • https://tunapanini.tistory.com/entry/Bubblewrap-Trusted-Web-ActivityTWA-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%ED%86%B5%ED%95%B4-Progressive-Web-AppPWA%EC%9D%84-APK-%ED%8C%8C%EC%9D%BC%EB%A1%9C-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
  • https://docs.pwabuilder.com/#/builder/app-store?id=building-your-app
  • https://velog.io/@gnwjd309/iOS-WKWebView
  • https://velog.io/@kerri/Xcode-CocoaPods%EC%BD%94%EC%BD%94%EC%95%84%ED%8C%9F-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95
  • https://ionicframework.com/
  • https://capacitorjs.com/
  • https://cordova.apache.org/