다짐을 RN으로 교체하는 알보칠 프로젝트를 시작한지 얼마 안된 시점, 내 개발 멘토인 bang9 형님이 "로그인 유지 작업은 당연히 할 예정이지?"라고 물어왔다. 사실 로그인 유지쪽은 전혀 생각지도 못했고 '앱이 아예 갈아끼워지는데 그게 가능해? 어쩔 수 없이 로그인 풀리는거 아닌가'라는 안일한 생각이 먼저 들었다. 하지만 생각할 수록 로그인이 풀린다는 것은 충분히 이탈로도 이어질 수 있는 사고라는 판단이 섰고, 결국 로그인 유지 작업을 진행하기로 결정했다.
bang9형님이 알려준 방법의 컨셉은 다음과 같다. 지금 상황은 네이티브로 작성된 앱을 RN으로 대체하는 작업이다. 이 프로젝트로 바뀌는 것은 client 뿐이며, 당연히 기존 앱과 새로운 앱에서 사용하는 user access token은 완전히 호환 가능하다. 플랫폼이 다르더라도 로그인을 유지하는 컨셉은 동일하다. ios/android 앱은 각각 네이티브의 로컬 저장소에 access token을 저장하고 있을 것이고, 새로운 앱은 RN의 로컬 저장소를 사용할 것이다. 즉, native local storage에 저장된 access token을 react native local storage로 옮겨오면 되는 간단한(?) 작업이다.
로컬 스토리지는 앱 ID(bundle identifier / package name) 단위로 구분되어 있다. 즉, 앱을 교체할 때와 마찬가지로 실제로는 전혀 다른 앱이라도 앱 ID만 같으면 로컬 스토리지에 접근해 데이터를 꺼내올 수 있다. 다만 앱 ID가 같더라도 ios/android의 local storage와 react native의 local storage가 호환되지는 않는다. 따라서 엑세스토큰을 옮기기 위해서는 RN에서 native layer를 컨트롤해야할 필요성이 생기게 된다.
react native는 native module이란 것을 사용해 네이티브 레이어와 통신할 수 있다. 즉, 로컬 스토리지를 컨트롤하는 native module을 만들고, RN단에서 이를 적절히 활용하면 네이티브 앱에 저장된 엑세스 토큰을 가져올 수 있는 것이다. 이제 본격적으로 RN Native Module을 만들어 엑세스 토큰을 옮기는 작업을 해보도록 하겠다.
xcode를 열고 프로젝트에 NativeStorage swift 파일을 추가해준다. 이전에 네이티브 모듈 작업을 하지 않았다면 자동으로 Bidging-Header 파일이 만들어질 것이다.(없다면 직접 추가해주어야 한다.)
import Foundation
@objc(NativeStorage)
class NativeStorage: NSObject{
let defaults = UserDefaults.standard
@objc
func getToken(_ callback:RCTResponseSenderBlock){
let token = defaults.string(forKey: "userTokenKey") ?? "0";
callback([token])
}
@objc
func removeToken(){
defaults.removeObject(forKey: "userTokenKey")
}
@objc
static func requiresMainQueueSetup()->Bool{
return true;
}
}
다짐 ios 네이티브 앱에서는 UserDefaults라는 로컬 스토리지를 사용하고 있다. 때문에 UserDafaults를 이용해서 setToken, removeToken 함수를 정의해 주었다.
이번에는 아까 만들어준 NativeStorage swift파일과 같은 위치에 NativeStorage obejctive-c 파일을 추가해 준다. react native는 swift와 직접적인 소통을 할 수 없기 때문에 반드시 object-c를 거쳐서 통신해야 한다.
#import <Foundation/Foundation.h>
#import "React/RCTBridgeModule.h"
@interface RCT_EXTERN_MODULE(NativeStorage,NSObject)
RCT_EXTERN_METHOD(getToken:(RCTResponseSenderBlock)callback)
RCT_EXTERN_METHOD(removeToken)
@end
objective-c 파일에는 포맷에 맞춰 이전에 swift로 작성한 메서드들의 타입을 명시해주면 된다. 이렇게 모듈과 메서드를 export 해주면 브릿지에 노출되면서 RN에서 NativeModule에 접근할 수 있게 된다.
프로젝트의 MainActivity.java 파일이 있는 경로에 NativeStorageModule이라 명명한 코틀린 파일을 만들어 주었다. (편의에 따라 java로 작성해도 무방하다.)
import android.content.SharedPreferences
import com.facebook.react.bridge.*
import com.facebook.react.bridge.ReactApplicationContext
import androidx.preference.PreferenceManager
class NativeStorageModule internal constructor(context: ReactApplicationContext?): ReactContextBaseJavaModule(context) {
private var mPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(MainApplication.getAppContext())
override fun getName():String {
return "NativeStorage"
}
@ReactMethod
fun getToken(callback: Callback){
var token = mPrefs.getString("user_token", "0") ?: ""
callback.invoke(token)
}
@ReactMethod
fun removeToken(){
val editor = mPrefs.edit()
editor.putString("user_token", "")
editor.apply()
}
}
다짐 android 네이티브에서는 preference라는 로컬스토리지를 사용하고 있었다. 따라서 우선 preference를 다운로드한 뒤 진행해 주었다. 위 코드에서 getName이 RN에서 접근할 모듈명을 나타내며, getToken과 removeToken은 ios와 동일하게 작성해주었다.
이번에는 같은 경로에 package 파일을 만들어준다. package파일은 우리가 작성한 module을 하나로 취합해주는 역할을 한다. 나는 MainPackage.kotlin 파일을 만들어 주었다.
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
class MainPackage : ReactPackage {
override fun createViewManagers(
reactContext: ReactApplicationContext
): MutableList<ViewManager<View, ReactShadowNode<*>>> = mutableListOf()
override fun createNativeModules(
reactContext: ReactApplicationContext
): MutableList<NativeModule> =
listOf(NativeStorageModule(reactContext)).toMutableList()
}
이제 마지막으로 MainApplication.java에 우리가 만든 package를 추가해주면 된다. MainApplication 안쪽에 오버라이드된 getPackages에 추가해주면 된다.(친절하게 주석도 쓰여 있다.)
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new MainPackage());
return packages;
}
이제 native module 작성은 끝났고, RN에서 native module을 호출해 엑세스 토큰을 옮기기만 하면 된다.
import {NativeModules} from 'react-native';
...
const {
getToken: getNativeToken,
removeToken: removeNativeToken
} = NativeModules.NativeStorage
const useLoginInit = () =>{
const setAccessToken = useUserInfo(state => state.setAccessToken);
const onSuccess = (token:string) =>{
setAccessToken(token);
removeNativeToken();
}
const getNativeTokenCallback = (nativeToken: string) => {
if (nativeToken && nativeToken !== '0') {
onSuccess(nativeToken);
}
}
const loginInit = async () =>{
const token = await AsyncStorage.getItem(LOCAL_STORAGE_KEYS.ACCESS_TOKEN)
if (token) {
onSuccess(token);
} else {
getNativeToken(getNativeTokenCallback);
}
}
useEffect(()=>{
loginInit()
},[])
}
먼저 React Native에 저장 중인 access token을 확인하고, 없다면 native module을 통해 native storage에 저장된 native token을 확인한다. 이 과정에서 저장된 토큰이 확인되면 RN의 전역상태 및 local storage에 셋팅하고, native token은 제거해준다. 여기서 native token을 굳이 제거해 주는 이유는 원치 않는 로그인을 방지하기 위해서다. 만약 native token을 제거해주지 않으면 rn에서 로그아웃하더라도 native token은 남아있기 때문에 나중에 재접속 시 native token으로 다시 자동 로그인이 적용되기 때문이다. 그리고 당연히 로그아웃 할때도 remoteNativeToken을 호출해주어야 한다.
RN을 시작한 이후 처음으로 네이티브 모듈 작업을 해봤다. 아무리 인터넷을 뒤져봐도 react native native module쪽은 만만한 자료가 없어서 꽤나 헤맸던 기억이 난다.(유튜브의 이름모를 인도형님께 감사하다.) 그래도 한번 해봤다고 이후 kakaoAd SDK native module을 만들 때는 훨씬 수월하게 작업할 수 있었다. 역시 뭐든 하면 느는 것 같다. 이번 글에서는 아무래도 대주제가 "다짐의 RN 전환기"이다 보니 native module 자체보다는 로그인 유지 모듈을 만드는 것에 좀 더 초점이 맞춰져 있었는데, 다음에는 native module에 대해서 제대로 다루는 글을 쓸 수 있었으면 좋겠다.