갤럭시 S24가 찾아준 배민커넥트 Android 성능 이슈 해결기(feat. React Native)

Mar.19.2024 고우혁, 김정혁

Web Frontend

우아한형제들에서는 배달의민족 앱뿐만 아니라 고객, 사장님, 라이더 사용할 수 있는 다양한 앱들을 개발하고 있는데요.
라이더서비스팀에서는 라이더를 위한 ‘배민커넥트 앱’을 react-native로 개발하고 있습니다.

이 글에서는 최근 출시된 갤럭시 S24와 관련하여 발생했던 Android 성능 이슈와 해결에 관해 이야기를 해보고자 합니다.

배민커넥트앱 아이콘

💡 프론트엔드 개발자 4명이 어떻게 앱을 개발하고 있는지 궁금하시다면, WOOWACON 2023 "배민도 React Native 해요?: 웹개발자들이 만들어 나가는 배민커넥트앱 이야기"를 참고해 주세요.


갤럭시 S24에서 앱이 느려요

지난 2024년 1월, 고객센터에 다음과 같은 문의가 들어오기 시작했습니다.

  • S24에서 앱이 너무 느려요
  • S24에서 터치를 하면 화면을 이동하는 데 10초가 걸려요

그전까지 다른 기기에서 별다른 이슈가 들어온 게 없었고, 로그 상에도 문제는 확인되지 않았습니다.
문의가 들어온 주에는 변경된 사항이 배포된 것도 없었습니다.

이상함을 감지했지만, 인입된 이슈가 많지 않아, 특정 사용자에게만 간헐적으로 발생하는 문제로 추정할 뿐이었습니다. 메모리 점유가 큰 앱을 백그라운드에서 너무 많이 실행했다면 충분히 발생할 수 있는 이슈니까요.

그러나 사전 예약 물량이 점점 풀리며 앱이 느려지는 문제가 점점 고객센터와 커뮤니티에서 뜨겁게 이슈가 되기 시작했습니다.
저희가 초기에 파악한 상황은 아래와 같았습니다.

  • iOS에서는 해당 이슈가 재현되지 않는다.
  • S24를 제외한 다른 기종에서는 재현되지 않는다.

사전 예약 당시에는 테스트 기기를 보유하고 있지 않아서 원인을 파악하는 게 쉽지 않았습니다.
배달을 수행하는 데 화면이동이 10초가 걸린다면 라이더가 안전하고 빠르게 배달을 수행하기 힘들기에 빠르게 해결해야 했습니다.
곧바로 재현을 위해 개인적으로 S24 기기를 구해서 달라진 스펙을 확인하였습니다.
이후 커뮤니티에서 제보된 사항들에 대해 유추되는 설정(RAM 설정 조정, 네트워크 속도 조절 등)을 바꿔가며 재현을 시도하였습니다.
이 과정에서 우아한형제들 앱 최고 전문가들이 모여있는 배민공통앱서비스개발팀에서 적극적으로 도와주었습니다. 덕분에 삼성전자 측과도 연락이 닿게 되었습니다.

삼성전자 측에서 빠르게 원인을 파악하고자 분석 작업에 힘을 써 주셨는데요.
배민커넥트 Android 앱이 StrongBox에 유난히 접근을 많이 하고 있고, 갤럭시 S24 모델에서 처음으로 U OS에 StrongBox가 탑재되었다는 정보를 공유해주셨습니다.

이 정보와 커뮤니티에 제보된 사항을 바탕으로 마침내 원인을 특정할 수 있었습니다.
원인은 삼성전자 측에서 제보해준 바와 같이 앱 내에서 StrongBox에 잦은 접근을 하고 있기 때문이었습니다.


StrongBox란 무엇인가?

StrongBox는 간단하게 말하면 보안을 위한 저장소입니다.
Android는 암호화 키를 안전하게 관리하기 위한 전용 시스템인 Android Keystore(하드웨어 기반의 보안 메커니즘을 통해 애플리케이션의 암호화 키를 보호하여 보안을 강화해주는 시스템)를 제공하는데요.
이번에 문제의 원인이 된 StrongBox는 Android Keystore에서 지원하는 한층 더 강화된 보안을 제공하는 저장소인데요.
다만 Android Developers 공식 문서에 나와있듯 StrongBox는 강화된 보안을 위해 별도의 하드웨어 보안 모듈을 사용하는 구조이기에 접근 과정에서 미세한 지연이 발생할 수 있습니다.


왜 StrongBox를 사용하고 있었을까?

배민커넥트앱에서는 로그인 시 발급되는 토큰 인증 정보들을 react-native-keychain 라이브러리를 통해 각 플랫폼에서 지원하는 적합한 저장소를 통해 관리하고 있습니다.
react-native-keychain 라이브러리에서는 토큰을 저장할 때 특정 저장소 유형에 대한 설정없이 내부적으로 보안 레벨을 처리하도록 설계되어 있으며, 기본적으로 디바이스가 지원하는 가장 높은 보안 수준을 자동으로 선택하게 되어 있습니다.
따라서 Android 디바이스에서 StrongBox를 지원하는 경우 react-native-keychain 라이브러리는 자동으로 가장 높은 보안 구현체인 StrongBox를 사용하게 됩니다.

왜 StrongBox에 잦은 접근이 발생했을까?

배민커넥트앱은 보안규정을 준수하기 위해서 암호화 라이브러리인 react-native-keychain을 통해 사용자의 인증 토큰을 포함한 정보를 암호화하여 안전하게 관리하고 있습니다.
또한 앱의 인증 유효성 검증 프로세스는 StrongBox에 접근하여 인증 토큰을 확인하게 되고, 대표적으로는 API 요청이 이루어질 때가 있는데요.
매번 API 요청이 발생하는 과정에서 인증 토큰을 검색하고 해당 토큰을 요청에 첨부하기 위해 StrongBox에 접근하게 되며, 대략적으로 다음과 같은 형태로 동작하고 있었습니다.

배민커넥트앱 로그인 및 API 시퀀스 다이어그램

이 과정에서 단일 이벤트에 의해 분산된 여러 도메인으로의 API 호출이 발생되는 경우와 StrongBox의 미세한 지연이 결합되어 유의미한 지연이 발생하였습니다.

원인 요약

  • react-native-keychain 라이브러리는 Android에서 StrongBox 지원기기일 때 기본적으로 StrongBox 사용
  • 앱 API 호출 시마다, StrongBox로의 잦은 접근
  • StrongBox의 경우 접근할 때 높은 보안수준으로 인한 미세한 지연이 발생
  • 갤럭시 S24 모델에서 처음으로 U OS에 StrongBox가 탑재되어 잦은 접근 시 지연현상이 두드러지게 나타남


어떻게 해결할까?

성능 이슈는 라이더님들이 앱을 이용하는 것에 지장이 발생하는 수준이었기에 더 많은 라이더님들이 사용하게 되는 정식 출시 이전에 신속한 수정이 필요한 상황이었습니다.
react-native-keychain에 대한 분석과 앱의 기존 프로세스 검토 결과, 다행히도 네이티브 코드의 수정 없이 CodePush로 문제에 빠르게 대응할 수 있는 방법을 두 가지 도출하게 되었습니다.

1. StrongBox 미사용

앞서 설명한 바와 같이 앱에서 사용하는 react-native-keychain은 기본적으로 디바이스가 지원하는 가장 높은 보안 수준을 선택하고 있고, 라이브러리를 교체하거나 다른 방식으로 토큰을 저장하는 것을 고려할 수 있지만 이는 라이브러리의 API 스펙에는 포함되어 있지 않기에 네이티브의 수정이 수반됩니다.
그러나 스토어 배포는 고려하지 않았으므로 네이티브 수정 없이 해당 라이브러리를 유지하여 대응하는 방향을 찾아보던 중 암호화 알고리즘 변경을 통해 우회적으로 변경이 가능함을 확인하였습니다.
react-native-keychain의 암호화 알고리즘은 기본적으로는 AES 대칭 키 알고리즘이 적용되지만 Meta에서 Android을 위해 자체적으로 설계한 라이브러리인 Facebook Conceal도 적용할 수 있는데요.
Facebook Conceal은 암호화 과정에서 Android Keystore 시스템을 사용하지 않으며 자연스럽게 StrongBox도 사용하지 않게 되므로 성능 이슈에 대한 해결책으로 적합하였습니다.
또한 소프트웨어 기반의 암호화 방식과 빠른 암호화 및 복호화를 위해 설계되었기에 시간적인 측면에서 더 나은 성능을 보장할 수 있다는 이점도 존재하였습니다.

다만 Facebook Conceal은 이미 아카이브된 상태이기에 최신 보안 요구사항을 충족시키거나 새로운 보안 위협에 대응하기 어려울 수 있는 가능성이 있다고 판단하여 최종적으로는 채택하지 않게 되었습니다.

2. 사용자 인증 토큰 캐시 적용

다음으로 StrongBox를 사용하되 최소한으로 접근할 수 있도록 캐싱 계층을 추가하는 방향을 검토하였습니다.
기존 사용자 인증 유효성 검증 프로세스는 모든 이벤트에 대해 StrongBox에 접근하는 스테이트리스(Stateless) 형태로 구성되어 있었고, 코드로 보면 대략적으로 다음과 같은 구조였습니다.

export const setTokenToStorage = async ({
  token,
  server,
  name,
}) => {
  return Keychain.setInternetCredentials(server, name, JSON.stringify(token));
};

export const getTokenFromStorage = async (server) => {
  const credential = await Keychain.getInternetCredentials(server);
  if (!credential) return false;

  return JSON.parse(credential)
};
// refresh, remove, auth header 세팅 등 기타 코드

위의 구조에서 처음에는 앱에서 사용하는 상태관리 라이브러리를 적용하는 방안을 고려했지만, 함께 고려했던 싱글톤 클래스 구조보다 더 많이 변경해야 한다는 것을 확인했습니다.
변경사항이 더 많은 경우 자연스럽게 테스트의 범위나 소요 시간이 길어질 수 있으므로 신속한 배포를 할 수 있는 싱글톤 클래스 구조로 적용하게 되었습니다.

import { encryptData, decryptData } from '...'

class TokenStorage {
  private static instance: TokenStorage = new TokenStorage();
  private static token: EncryptedToken | null = null;

  private constructor() {}

  public static getInstance(): TokenStorage {
    return TokenStorage.instance;
  }

  public async setToken({
    token,
    server,
    name,
  }) {
    const credentials = await Keychain.setInternetCredentials(server, name, token);
    TokenStorage.token = encryptData(credentials);
    return credentials;
  }

  public async getToken(server) {
    if (TokenStorage.token) {
      return await decryptData(TokenStorage.token);
    }
    const credentials = await Keychain.getInternetCredentials(server);
    if (credentials) {
      TokenStorage.token = encryptData(credentials)
      return credentials;
    } else {
      // 갱신 또는 만료된 경우
    }
  }
}

export default TokenStorage.instance;

클래스는 사용자 인증토큰이 유효한 경우 인스턴스는 별도의 갱신 없이 캐싱된 토큰을 반환하며, StrongBox에는 토큰을 발급받을 때와 갱신할 때만 접근하게 됩니다.
이와 함께 setTokenToStorage, getTokenFromStorage는 다음과 같이 인스턴스를 사용하도록 변경하였습니다.

import TokenStorage from '...'

export const setTokenToStorage = async ({
  token,
  server,
  name,
}) => {
  return TokenStorage.setToken(server, name, JSON.stringify(token));
};

export const getTokenFromStorage = async (server) => {
  return TokenStorage.getToken(server);
};
// ...

이 과정에서 react-native-keychain에서 제공하는 Race Condition 방지 기능이 부재하다는 것이 우려되는 사항이었습니다.

react-native-keychain는 토큰 접근 과정에서 SharedPreferences와 락 매커니즘을 통해 Race Condition이 방지됩니다.

토큰 발급과 검증이 동시에 이루어지는 상황은 지극히 예외적인 경우라고 판단하여 캐싱 계층을 추가하는 방법을 채택하기로 결정하였고,
변경 이후 다음과 같은 프로세스를 갖게 되었습니다.

배민커넥트앱 변경 후 로그인 및 API 시퀀스 다이어그램


사후 회고

다행히 앞선 단기 대응으로 이슈를 해결하였으나 근본적인 의문점 두 가지가 여전히 남아 있습니다.

Q1. 토큰정보가 StrongBox를 사용할 만큼 암호화가 필요한가?

StrongBox는 하드웨어 기반의 보안 모듈을 통해 보안을 크게 향상시키지만 이로 인해 발생하는 처리 지연, 리소스 소모로 인한 성능 영향을 고려했을 때 필수적이지는 않다고 판단하였습니다.
따라서 현재 시점에서는 StrongBox를 사용하고 있지만, 최종적으로는 Keystore는 사용하고 StrongBox는 사용하지 않는 다른 방식의 도입을 검토하고 있습니다.

Q2. 변경된 구조로 인한 Race Condition 발생 가능성은 안고 갈 것인가?

앞서 설명한 바와 같이 캐싱 계층을 사용하지 않는 기존 인증 유효성 검증 프로세스에서는 react-native-keychain에서 자체적으로 Race Condition을 방지하는 기능을 제공하지만 단기대응으로 직접 구현한 TokenStorage를 사용하게 되면서 Race Condition 방지의 이점을 잃어버리게 되었습니다.
현재 시점에서는 지극히 예외적인 경우라고 판단하고 있지만 차후 발생할 가능성이 없다고 단언할 수는 없으므로 구조에 대한 추가적인 개선을 고려하고 있습니다.


마무리

모든 앱 프로덕트들이 그렇듯, 매년 새로운 기기와 기술의 등장에 대응하는 작업이 필요합니다.
일례로 지난해에는 iPhone 15 Pro 모델 출시 당시 카메라 라이브러리와의 충돌 문제가 발생하여 긴급하게 대응한 적이 있었습니다. 또, Play Store의 최소 Android SDK 버전을 맞추기 위해 대응해야 했던 경험들도 있는데요.
Android/iOS의 생태계 변화와 사용하고 있는 오픈소스 라이브러리들의 동향을 하나하나 대응하는 것이 결코 쉽지는 않기에 때로는 사내 Android/iOS 개발자분들에게 조언을 얻기도 합니다.
이번에는 삼성전자 유관부서와 함께 이슈대응을 하게 되어 특히 인상 깊은 경험이었습니다.

이번 이슈에 빠르게 대응할 수 있게 도움주신 배민공통앱서비스개발팀과 삼성전자 관계자분들께도 감사드립니다.

고우혁

우아한형제들에서 배민커넥트 앱과 딜리버리플랫폼 어드민 개발을 담당하고 있습니다.

김정혁

우아한형제들에서 배민커넥트앱과 어드민화면들을 개발하고 있는 프론트엔드 개발자입니다.