보안
앱을 개발할 때 보안은 종종 간과된다. 완전히 뚫리지 않는 소프트웨어를 만드는 것은 불가능하다는 사실은 분명하다. 완전히 뚫리지 않는 자물쇠를 아직 발명하지 못했기 때문이다(결국 은행 금고도 뚫리는 경우가 있다). 하지만 악의적인 공격의 피해자가 되거나 보안 취약점에 노출될 확률은, 그런 상황을 방지하기 위해 들이는 노력에 반비례한다. 일반 자물쇠도 열쇠 없이 열 수 있지만, 캐비닛 후크보다는 훨씬 더 뚫기 어렵다!
이 가이드에서는 민감한 정보 저장, 인증, 네트워크 보안, 그리고 앱을 보호하는 데 도움이 되는 도구에 대한 모범 사례를 배울 수 있다. 이는 사전 점검 목록이 아니라, 앱과 사용자를 더욱 안전하게 보호할 수 있는 다양한 옵션을 모아놓은 카탈로그이다.
민감한 정보 저장 방식
앱 코드에 민감한 API 키를 절대 저장하지 말아야 한다. 코드에 포함된 모든 정보는 앱 번들을 검사하는 사람이 쉽게 평문으로 확인할 수 있다. react-native-dotenv나 react-native-config 같은 도구는 API 엔드포인트와 같은 환경별 변수를 추가하는 데 유용하지만, 서버 측 환경 변수와 혼동해서는 안 된다. 서버 측 환경 변수는 종종 비밀 정보나 API 키를 포함할 수 있다.
앱에서 특정 리소스에 접근하기 위해 API 키나 비밀 정보가 필요하다면, 가장 안전한 방법은 앱과 리소스 사이에 오케스트레이션 계층을 구축하는 것이다. AWS Lambda나 Google Cloud Functions 같은 서버리스 함수를 사용해 필요한 API 키나 비밀 정보와 함께 요청을 전달할 수 있다. 서버 측 코드의 비밀 정보는 앱 코드의 비밀 정보와 달리 API 소비자가 동일한 방식으로 접근할 수 없다.
사용자 데이터를 저장할 때는 민감도에 따라 적절한 저장 방식을 선택해야 한다. 앱을 사용하면서 기기에 데이터를 저장해야 할 필요가 자주 생긴다. 오프라인 사용을 지원하거나 네트워크 요청을 줄이기 위해, 또는 사용자가 매번 앱을 사용할 때마다 재인증하지 않도록 액세스 토큰을 세션 간에 저장하기 위해 데이터를 저장할 수 있다.
저장된 데이터 vs 저장되지 않은 데이터 — 저장된 데이터는 기기의 디스크에 기록되므로 앱이 다시 실행될 때 네트워크 요청을 하거나 사용자가 다시 입력하지 않아도 데이터를 읽을 수 있다. 하지만 이는 공격자가 데이터에 접근할 가능성을 높일 수도 있다. 저장되지 않은 데이터는 디스크에 기록되지 않으므로 접근할 데이터가 없다!
Async Storage
Async Storage는 React Native를 위한 커뮤니티 주도 모듈로, 비동기적이고 암호화되지 않은 키-값 저장소를 제공한다. Async Storage는 앱 간에 공유되지 않는다. 각 앱은 독립적인 샌드박스 환경을 가지며, 다른 앱의 데이터에 접근할 수 없다.
사용해야 할 경우 | 사용하지 말아야 할 경우 |
---|---|
앱 실행 간 비민감 데이터 유지 | 토큰 저장 |
Redux 상태 유지 | 비밀 정보 저장 |
GraphQL 상태 유지 | |
전역 앱 변수 저장 |
- Web
Async Storage는 웹의 Local Storage에 대응하는 React Native의 저장소 기능이다
보안 저장소
React Native는 기본적으로 민감한 데이터를 저장할 수 있는 기능을 제공하지 않는다. 하지만 Android와 iOS 플랫폼에는 이미 존재하는 솔루션이 있다.
iOS - 키체인 서비스
키체인 서비스(Keychain Services)는 사용자의 민감한 정보를 안전하게 저장할 수 있는 기능을 제공한다. 이는 인증서, 토큰, 비밀번호 등 Async Storage에 저장하기에 적합하지 않은 민감한 정보를 보관하기에 이상적인 장소이다.
Android - 보안 Shared Preferences
Shared Preferences는 안드로이드에서 사용하는 지속적인 키-값 데이터 저장소이다. Shared Preferences의 데이터는 기본적으로 암호화되지 않지만, Encrypted Shared Preferences는 Shared Preferences 클래스를 래핑하여 키와 값을 자동으로 암호화한다.
Android - 키스토어
Android 키스토어 시스템은 암호화 키를 컨테이너에 저장하여 기기에서 추출하기 어렵게 만든다.
iOS 키체인 서비스나 Android 보안 공유 환경설정을 사용하려면 직접 브릿지를 작성하거나, 라이브러리를 사용해 통합 API를 제공할 수 있다. 단, 이는 사용자의 책임 하에 진행해야 한다. 고려해볼 만한 라이브러리는 다음과 같다:
민감한 정보를 의도치 않게 저장하거나 노출시키지 않도록 주의한다. 예를 들어, 민감한 폼 데이터를 리덕스 상태에 저장하고 전체 상태 트리를 Async Storage에 지속시키는 경우, 또는 사용자 토큰과 개인 정보를 Sentry나 Crashlytics 같은 애플리케이션 모니터링 서비스로 전송하는 경우가 있다.
인증과 딥 링킹
모바일 앱은 웹에서는 존재하지 않는 독특한 취약점을 가지고 있다. 바로 딥 링킹이다. 딥 링킹은 외부 소스에서 네이티브 앱으로 직접 데이터를 전송하는 방법이다. 딥 링크는 app://
과 같은 형태를 띠는데, 여기서 app
은 앱의 스키마를 의미하고, //
뒤에 오는 내용은 요청을 처리하기 위해 내부적으로 사용할 수 있다.
예를 들어, 이커머스 앱을 개발 중이라면 app://products/1
과 같은 딥 링크를 사용해 앱을 열고 id가 1인 상품의 상세 페이지를 표시할 수 있다. 이는 웹의 URL과 유사하지만 한 가지 중요한 차이점이 있다.
딥 링크는 안전하지 않으며, 절대로 민감한 정보를 포함해서는 안 된다.
딥 링크가 안전하지 않은 이유는 URL 스키마를 등록하는 중앙화된 방법이 없기 때문이다. 앱 개발자는 iOS에서는 Xcode에서 설정하거나, Android에서는 인텐트를 추가함으로써 거의 모든 URL 스키마를 사용할 수 있다.
악의적인 앱이 동일한 스키마를 등록해 딥 링크를 가로채고 링크에 포함된 데이터에 접근하는 것을 막을 방법은 없다. app://products/1
과 같은 정보를 전송하는 것은 해롭지 않지만, 토큰을 전송하는 것은 보안상 문제가 될 수 있다.
운영체제가 링크를 열 때 두 개 이상의 앱 중에서 선택해야 하는 경우, Android는 사용자에게 선택 대화상자를 표시하고 링크를 열기 위해 사용할 앱을 선택하도록 요청한다. 그러나 iOS에서는 운영체제가 직접 선택을 하기 때문에 사용자는 이를 전혀 알지 못한다. Apple은 이후 iOS 버전(iOS 11)에서 선착순 원칙을 도입해 이 문제를 해결하려고 노력했지만, 이 취약점은 여전히 다양한 방식으로 악용될 수 있다. 자세한 내용은 여기에서 확인할 수 있다. iOS에서는 유니버설 링크를 사용해 앱 내 콘텐츠에 안전하게 연결할 수 있다.
OAuth2와 리다이렉트
OAuth2 인증 프로토콜은 현재 가장 완벽하고 안전한 프로토콜로 자리 잡았다. OpenID Connect 프로토콜도 이를 기반으로 한다. OAuth2에서는 사용자가 제3자를 통해 인증을 받는다. 인증이 성공적으로 완료되면, 제3자는 검증 코드를 포함해 요청한 애플리케이션으로 리다이렉트한다. 이 검증 코드는 JWT(JSON Web Token)로 교환할 수 있다. JWT는 웹에서 당사자 간에 정보를 안전하게 전송하기 위한 개방형 표준이다.
웹에서는 URL이 고유하다는 보장이 있기 때문에 리다이렉트 단계가 안전하다. 하지만 앱에서는 URL 스킴을 중앙에서 등록하는 방법이 없기 때문에 동일한 보장을 할 수 없다. 이 보안 문제를 해결하기 위해 PKCE라는 추가 검증 단계가 필요하다.
PKCE(“Pixy”로 발음)는 Proof of Key Code Exchange의 약자로, OAuth 2 사양의 확장이다. PKCE는 인증과 토큰 교환 요청이 동일한 클라이언트에서 온 것인지 확인하는 추가 보안 계층을 제공한다. PKCE는 SHA 256 암호화 해시 알고리즘을 사용한다. SHA 256은 어떤 크기의 텍스트나 파일에 대해 고유한 "서명"을 생성하며, 다음과 같은 특징이 있다:
- 입력 파일의 크기와 상관없이 항상 동일한 길이를 가진다.
- 동일한 입력에 대해 항상 동일한 결과를 생성한다.
- 단방향이다. 즉, 원본 입력을 역추적할 수 없다.
이제 두 가지 값이 생성된다:
- code_verifier - 클라이언트가 생성한 큰 무작위 문자열
- code_challenge - code_verifier의 SHA 256 해시 값
초기 /authorize
요청 시, 클라이언트는 메모리에 저장된 code_verifier
에 대한 code_challenge
도 함께 전송한다. 인증 요청이 정상적으로 반환된 후, 클라이언트는 code_challenge
를 생성하는 데 사용된 code_verifier
도 전송한다. IDP는 code_challenge
를 계산하고, 초기 /authorize
요청에서 설정된 값과 일치하는지 확인한다. 값이 일치할 경우에만 액세스 토큰을 발급한다.
이를 통해 초기 인증 흐름을 시작한 애플리케이션만 검증 코드를 JWT로 교환할 수 있다. 따라서 악의적인 애플리케이션이 검증 코드를 얻더라도, 단독으로는 쓸모가 없다. 이를 실제로 확인하려면 이 예제를 참고한다.
네이티브 OAuth를 구현할 때 고려할 수 있는 라이브러리는 react-native-app-auth이다. react-native-app-auth는 OAuth2 프로바이더와 통신하기 위한 SDK이다. 네이티브 AppAuth-iOS와 AppAuth-Android 라이브러리를 감싸고 있으며, PKCE를 지원할 수 있다.
react-native-app-auth는 IDP가 PKCE를 지원할 경우에만 PKCE를 지원한다.
네트워크 보안
API는 항상 SSL 암호화를 사용해야 한다. SSL 암호화는 데이터가 서버를 떠나 클라이언트에 도달하기 전까지 평문으로 읽히는 것을 방지한다. 엔드포인트가 안전한지 확인하려면 http://
대신 https://
로 시작하는지 확인하면 된다.
SSL Pinning
HTTPS 엔드포인트를 사용해도 데이터가 가로챌 위험에 노출될 수 있다. HTTPS를 사용할 때, 클라이언트는 서버가 신뢰할 수 있는 인증 기관(CA)이 서명한 유효한 인증서를 제공할 경우에만 서버를 신뢰한다. 공격자는 사용자의 장치에 악성 루트 CA 인증서를 설치해 클라이언트가 공격자가 서명한 모든 인증서를 신뢰하도록 만들 수 있다. 따라서 인증서만 의존하면 중간자 공격에 취약해질 수 있다.
SSL Pinning은 클라이언트 측에서 이러한 공격을 방지하기 위한 기술이다. 개발 단계에서 클라이언트에 신뢰할 수 있는 인증서 목록을 내장(또는 고정)해, 신뢰된 인증서 중 하나로 서명된 요청만 수락하고 자체 서명된 인증서는 거부한다.
SSL Pinning을 사용할 때는 인증서 만료 기간을 주의해야 한다. 인증서는 1~2년마다 만료되며, 만료되면 서버뿐만 아니라 앱에서도 업데이트해야 한다. 서버의 인증서가 업데이트되면, 이전 인증서가 내장된 앱은 더 이상 작동하지 않는다.
요약
완벽한 보안을 보장할 수 있는 방법은 없지만, 꾸준한 노력과 주의를 기울이면 애플리케이션에서 보안 위반이 발생할 가능성을 크게 줄일 수 있다. 애플리케이션에 저장된 데이터의 민감도, 사용자 수, 그리고 해커가 계정에 접근했을 때 발생할 수 있는 피해 규모에 비례해 보안에 투자해야 한다. 또한, 처음부터 요청되지 않은 정보에 접근하는 것은 훨씬 더 어렵다는 점을 기억하자.