리디북스 모바일 앱을 개발하는 안다빈입니다.
리액트 네이티브를 선택하고 도입하며 겪은 고민과 경험을 나누려 합니다.
리디북스 모바일 앱은 올해로 13년이 되었는데요. 전반적인 사용 경험을 웹에서 앱으로 옮기는 제품 로드맵을 소화하고 있습니다. 기존에 서점의 전유물이었던 콘텐츠 탐색 및 결제 기능을 갖추는 것이 골자인데요. 서점만큼 변화에 빠르게 대응할 수 있는 환경이 필요했어요. 그래서 작년 하반기에 리액트 네이티브를 선택하게 됐습니다.
TF팀은 프론트엔드 개발자 두 명, 네이티브 개발자 한 명, 디자이너 두 명, PM 한 명으로 구성했습니다. 2주의 검증을 거쳐 고객이 탐색이 가능한 지면을 만들기까지 iOS 석 달, Android 한 달이 걸렸습니다. 특히 iOS에서는 리액트로 구조부터 뷰까지 만들며 시행착오를 겪느라 더 오랜 시간이 걸렸어요. Android는 리액트 코드를 재활용했지만, 플랫폼 특성을 하나로 통합하는 데 시간이 걸렸습니다.
영상으로 보기 ▼
왜 리액트 네이티브인가?
완결성 있는 앱 중심 서비스
2020년 하반기만 해도, 고객이 리디북스 전체 서비스를 이용하려면 웹과 앱을 계속 오갈 수밖에 구조였어요. 콘텐츠 탐색과 결제 경험은 웹으로, 뷰어 중심의 감상 경험은 앱으로 나뉘어 있었거든요.
앱 안에서도 완결성 있는 고객 경험을 주고자 했습니다. 그래서 탐색과 결제의 경험을 앱 내로 들고 오는 제품 로드맵을 세웠어요. 우선 웹툰・웹소설 콘텐츠에 주력해 앱 중심 서비스를 성공적으로 안착시키고자 했습니다. 탐색 경험은 시장의 변화나 고객의 높이에 따라서 끊임없이 변화해야 합니다. 종래의 개발 환경으로는 주어진 기간 내 당면한 목표를 소화하기에 무리가 있었어요.
크로스 플랫폼 선택
대안으로 나온 것은 크로스 플랫폼 기술인 플러터와 리액트 네이티브였습니다. 당시에 플러터는 떠오르는 트랜드였고, 리액트 네이티브는 6년간 안정화를 이뤄 다수의 커뮤니티를 형성해 나가고 있었습니다.
선택은 오래 걸리지 않았어요. 리액트 네이티브만 한 것이 없었습니다. 사용하는 개발자가 많아 커뮤니티가 활성화되어 있고, 후기도 많았거든요. 무엇보다 팀 내 프론트・네이티브 개발자가 빠르게 시도하기에 최적이었습니다.
네이티브 기반
vs 리액트 네이티브 기반
개발에 앞서 고민한 것은 ‘네이티브 위에 리액트 네이티브를 올리느냐, 리액트 네이티브에 네이티브를 올리느냐’였어요. 장단점이 확실했죠.
네이티브 위에 리액트 네이티브를 올릴 경우
기존에 개발된 네이티브 구현은 안정적으로 유지될 수 있었어요. 반면 리액트 네이티브에서는 예상되는 번거로움이 있었죠. 구성에 따라 화면별로 앤드 포인트가 따지게 되고, 네이티브 또는 리액트 네이티브가 상태 관리의 주체가 되어야 하니까요. 반대의 경우도 마찬가지지만, 리액트 네이티브의 시작이 앱 라이프사이클과의 시작이 아니라는 점도 애로사항이 될 수 있었어요. 예를 들면 CodePush의 반영 시점이나 다른 앤드 포인트가 초기화되지도 않은 상태일 수 있으니까요.
리액트 네이티브 위에 네이티브를 올릴 경우
그레이존을 걸쳐 최종적으로 그린존에 안착해야 하기에 가장 자연스러운 선택이었죠. 하지만 네이티브 영역이 플랫폼 고유의 라이프 사이클을 따르도록 설계 및 개발되어 있기에 두 번의 과정을 거쳐야 했어요. 그레이존 단계에선 포팅할 때 네이티브 구현을 다시 한번 손봐야 하고, 그린존으로 가려면 리액트로 전환해야 했으니까요. 부수적으로는 그린존으로 가기 전까지 유지보수가 배로 든다는 점이 있었습니다.
결국 리액트 네이티브 위에 네이티브를 올리는 방향을 선택하게 됐습니다. 어차피 모두 리액트 네이티브로 옮겨갈 것이고, 네이티브 위에 리액트 네이티브를 올리는 것보다 핸들링하기 쉬운 문제들이었으니까요.
네이티브 화면 재활용
방향을 정한 다음, 본격적인 검증 단계로 들어갔어요. 주어진 기간 내에 변화에 빠르게 대응하고자, 새로 만드는 화면은 리액트 네이티브로, 기존 화면은 모두 네이티브를 유지하는 형태로 개발을 진행했습니다.
기존에 리디북스는 iOS는 UITabBarController
, 안드로이드는 ViewPager
기반으로 탭바를 만들고 ‘내 서재’, ‘구매 목록’, ‘리디셀렉트’, ‘서점’, ‘설정’ 탭을 플랫폼에 따라 다르게 구성하고 있었습니다.
먼저, 크로스 플랫폼 이점을 살리고 플랫폼에 상관없이 동일한 경험을 주기 위해 탭 구성을 통합했습니다. 그리고 리액트 네이티브를 베이스로 결정했기에 플랫폼 공통 요소인 탭바를 React Navigation으로 대체해야 했습니다. 마지막으로 네이티브로 유지할 화면들을 리액트 네이티브에서 제공하는 커스텀 UI 컴포넌트를 사용해 래핑해야 했습니다.
래핑할 때 플랫폼별로 고유의 라이프 사이클을 유지하기 위한 처리가 어려웠습니다. 커스텀 UI 컴포넌트는 최종적으로 각 플랫폼의 UIView
・View
로 이뤄져야 했어요. 그래서 iOS는 UIViewController
의 라이프 사이클을 살리기 위해 리액트 네이티브에 브릿지 추가가 필요했습니다. 안드로이드의 경우, Fragment
생성 시점을 커스텀 UI 컴포넌트 생성 시점과 맞추기 위한 브릿지 추가와 리액트 네이티브의 한계로 인해 안드로이드 고유의 뷰 라이프 사이클에 의한 크기를 계산하는 데 문제가 있었습니다. 따라서 이를 보충할 수단으로 비용이 큰 Choreographer
를 통해 프레임 단위로 뷰 크기를 계산하도록 해야 했습니다.
콘텐츠 뷰어는 리액트 네이티브 영역을 덮는 형태로 이뤄져 독립적인 화면이기에 우선은 별 다른 래핑 없이 네이티브 그대로 사용하기로 했습니다. 이렇게 해서 다음과 같은 구조로 리액트 네이티브와 네이티브 뷰의 위계와 구성을 가지게 되었습니다.
네이티브 코드 재활용
다음으로 리디북스 앱이 13년간 유지보수 하며 쌓아 올린 네이티브의 정수를 리액트 네이티브에 녹여낼 수 있을지 보았습니다.
Swift 언어의 iOS
iOS 앱의 경우, 2014년 말부터 Swift를 전환하기 시작해 90% 이상의 전환율을 달성한 상태였습니다. 이 과정에서 Swift 언어 특성을 살린 기능들이 추가되어 왔습니다.
하지만 리액트 네이티브는 동작 원리상 ObjC 런타임이 바탕이라, 도입하려면 ObjC로 역행해야 하는 상황이었습니다. 관련해서 자료를 찾아보았지만, 당시 Swift를 사용한 사례는 소수에 불과해 직접 해보는 수밖에 없었어요. 결과적으로 ObjC 특성을 살려 Swift로 덮는 형태가 가능해 큰 문제가 없었습니다.
아래는 검증하는 단계에서 만든 브릿지 샘플 코드입니다.
공통
리액트 네이티브는 ObjC 기반 프로젝트 템플릿이 바탕이기 때문에, Swift와 혼용하기 위해선 브릿징 헤더 추가가 필요했습니다.
// Ridibooks-Bridging-header.h
#ifndef Ridibooks_Bridging_Header_h
#define Ridibooks_Bridging_Header_h
#import <React/RCTBridge.h>
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
#endif /* Ridibooks_Bridging_Header_h */
타입 무결성을 유지하기 위해 커스텀 브릿지에도 리액트 네이티브에서 갖춘 수준만큼 Typescript 정의를 작성했습니다.
// bridges.ts
import { EventSubscriptionVendor } from 'react-native';
export interface RNExampleModule {
foo: string;
methodWithNoFeedback(): void;
methodWithCallback(callback: (one: number, two: number, three: number) => void): void;
methodWithCallbackParamater(paramater: string, callback: (result: string) => void): void;
methodWithPromise(): Promise<boolean>;
methodWithPromiseParamater(paramater: string): Promise<boolean>;
}
export interface RNEventExampleModule extends EventSubscriptionVendor {
}
export enum RNEventExampleModuleEvent {
CHANGE = 'change',
}
export interface RNEventExampleModuleEventMap {
[RNEventExampleModuleEvent.CHANGE]: { foo?: string };
}
interface NativeEventMap extends RNEventExampleModuleEventMap {}
declare module 'react-native' {
interface NativeEventEmitter extends EventEmitter {
addListener<K extends keyof NativeEventMap>(
eventType: K,
listener: (event: NativeEventMap[K]) => any,
context?: any,
): EmitterSubscription;
emit<K extends keyof NativeEventMap>(eventType: K, ...params: any[]): void;
listeners<K extends keyof NativeEventMap>(eventType: K): EmitterSubscription[];
removeAllListeners<K extends keyof NativeEventMap>(eventType?: K): void;
removeCurrentListener(): void;
removeListener<K extends keyof NativeEventMap>(
eventType: K,
listener: (event: NativeEventMap[K]) => any,
): void;
removeSubscription(subscription: EmitterSubscription): void;
once<K extends keyof NativeEventMap>(
eventType: K,
listener: (event: NativeEventMap[K]) => any,
context: any,
): EmitterSubscription;
}
interface NativeModulesStatic {
RNExampleModule: RNExampleModule;
RNEventExampleModule: RNEventExampleModule;
}
}
단순 브릿지
// RNExampleModule.m
#if __has_include("RCTBridgeModule.h")
#import "RCTBridgeModule.h"
#else
#import <React/RCTBridgeModule.h>
#endif
@interface RCT_EXTERN_MODULE(RNExampleModule, NSObject)
RCT_EXTERN_METHOD(methodWithNoFeedback);
RCT_EXTERN_METHOD(methodWithCallback:(RCTResponseSenderBlock)callback);
RCT_EXTERN_METHOD(methodWithCallbackParamater:(NSString *)paramater callback:(RCTResponseSenderBlock)callback);
RCT_EXTERN_METHOD(methodWithPromise:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(methodWithPromiseParamater:(NSString *)paramater resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject);
@end
// RNExampleModule.swift
import Foundation
/**
import { NativeModules } from 'react-native';
const { RNExampleModule } = NativeModules;
*/
@objc(RNExampleModule)
@objcMembers final class RNExampleModule: NSObject {
static func requiresMainQueueSetup() -> Bool {
return true
}
var methodQueue: DispatchQueue? {
return DispatchQueue.main
}
/**
RNExampleModule.foo; // 'bar'
*/
func constantsToExport() -> [AnyHashable: Any] {
return ["foo": "bar"]
}
/**
RNExampleModule.methodWithNoFeedback();
*/
@objc(methodWithNoFeedback)
func methodWithNoFeedback() {
NSLog("'methodWithNoFeedback()' called!")
}
/**
RNExampleModule.methodWithCallback(one, two, three => {
console.log(one, two, three); // '1 2 3'
});
*/
@objc(methodWithCallback:)
func methodWithCallback(callback: RCTResponseSenderBlock) {
NSLog("'methodWithCallback(callback:)' called!")
callback([1, 2, 3])
}
/**
RNExampleModule.methodWithCallbackParamater('bar', result => {
console.log(result); // 'bar'
});
*/
@objc(methodWithCallbackParamater:callback:)
func methodWithCallbackParamater(paramater: String, callback: RCTResponseSenderBlock) {
NSLog("'methodWithCallbackParamater(paramater:callback:)' called!")
callback([paramater])
}
/**
RNExampleModule.methodWithPromise()
.then(result => {
console.log(result); // true
})
.catch(error => {
console.log(error); // { code: '0', message: 'Error!', error: { ... } }
});
*/
@objc(methodWithPromise:reject:)
func methodWithPromise(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
NSLog("'methodWithPromise(resolve:reject:)' called!")
if arc4random() % 2 != 0 {
resolve(true/* ex: nil, 5, "string", ["for", "bar"], ["foo": "bar"] */)
} else {
reject("0", "Error!", NSError())
}
}
/**
RNExampleModule.methodWithPromiseParamater('foo')
.then(result => {
console.log(result); // 'foo'
})
.catch(error => {
console.log(error); // { code: '0', message: 'Error!', error: { ... } }
});
*/
@objc(methodWithPromiseParamater:resolve:reject:)
func methodWithPromiseParamater(paramater: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
NSLog("'methodWithPromiseParamater(paramater:resolve:reject:)' called!")
if arc4random() % 2 != 0 {
resolve(paramater/* ex: nil, 5, "string", ["for", "bar"], ["foo": "bar"] */)
} else {
reject("0", "Error!", NSError())
}
}
}
이벤트 브릿지
// RNEventExampleModule.m
#if __has_include("RCTBridgeModule.h")
#import "RCTBridgeModule.h"
#else
#import <React/RCTBridgeModule.h>
#endif
#if __has_include("RCTEventEmitter.h")
#import "RCTEventEmitter.h"
#else
#import <React/RCTEventEmitter.h>
#endif
@interface RCT_EXTERN_MODULE(RNEventExampleModule, RCTEventEmitter)
@end
// RNEventExampleModule.swift
import Foundation
/**
import { NativeModules } from 'react-native';
const { RNEventExampleModule } = NativeModules;
*/
@objc(RNEventExampleModule)
@objcMembers final class RNEventExampleModule: RCTEventEmitter {
static var emitter: RCTEventEmitter?
override static func requiresMainQueueSetup() -> Bool {
return true
}
override var methodQueue: DispatchQueue? {
return DispatchQueue.main
}
private static let changeEvent = "change"
/**
export class EventExample {
private static emitter = new NativeEventEmitter(RNEventExampleModule);
static setup() {
this.emitter.removeAllListeners(RNEventExampleModuleEvent.CHANHE);
this.emitter.addListener(RNEventExampleModuleEvent.CHANHE, event => {
console.log(event);
});
}
}
*/
override func supportedEvents() -> [String]! {
return [Self.changeEvent]
}
override func startObserving() {
super.startObserving()
Self.emitter = self
}
static func sendEvent() {
Self.emitter?.sendEvent(withName: changeEvent, body: ["foo": "bar"]/* ex: true, 5, "foo", [1, 2, 3] */)
}
}
Kotlin 언어의 Android
Android도 2015년부터 Kotlin 전환을 시작해 80% 이상의 전환율을 달성한 상태였어요. 코드 일관성을 위해 Gradle Build Script도 Groovy에서 Kotlin Script로 전환한 상태였습니다.
리액트 네이티브의 코드 베이스가 Java긴 하지만, Kotlin은 언어 특성상 Java와 일대일 매핑이 가능해 Swift만큼 고민스럽진 않았습니다. 하지만 CodePush를 적용하면서 AppCenter CLI에서 Kotlin Script로 작성된 Gradle Build Script를 읽지 못하는 문제가 확인되었어요. 이는 Hermes 활성화 여부를 확인하기 위한 용도였다는 걸 알게 되어 아래와 같은 우회 스크립트를 작성해 사용하도록 했습니다.
const { execSync } = require("child_process");
const fs = require('fs');
function ktsWorkaroundEnabled(enabled) {
const targets = [
'/usr/local/lib/node_modules/appcenter-cli/dist/commands/codepush/lib/react-native-utils.js',
`${exec('brew --prefix')}/lib/node_modules/appcenter-cli/dist/commands/codepush/lib/react-native-utils.js`,
`${process.env.HOME}/.config/yarn/global/node_modules/appcenter-cli/dist/commands/codepush/lib/react-native-utils.js`,
];
targets.filter(target => fs.existsSync(target))
.forEach(target => {
if (enabled) {
execSync(`sed -ie "s/= getHermesEnabled;/= function() { return true; };/" ${target}`);
} else {
execSync(`sed -ie "s/= function() { return true; };/= getHermesEnabled;/" ${target}`);
}
});
}
브릿지 개발은 iOS만큼 어렵지 않았지만, 값을 전달하고 받을 때나 메인 스레드로 넘어갈 때 반복되는 코드가 많고 번거로운 부분이 많았어요. 이를 개선하기 위해 아래와 같은 익스텐션을 구현했습니다.
AS-IS
리액트 네이티브에서는 안정적인 값 전달을 위해 자체적인 WritableMap/Array
타입을 만들어 제공합니다. 그런데 이 타입은 Java 기준으로 만들어져 있어, Kotlin에서는 상당히 번거롭게 느껴졌습니다.
val map = Arguments.createMap().apply {
putString("foo", "bar")
putInt("baz", 5)
};
val array = Arguments.createArray().apply {
putString("foo")
putString("bar")
putMap(map)
}
TO-BE
Kotlin에서는 mapOf
나 listOf
로 축약이 가능했기에 이 패턴을 그대로 채용해 WritableMap/Array
타입용 축약 함수를 추가했습니다.
val map = writableMapOf("foo" to "bar", "baz" to 5)
val array = wrtieableArrayOf("foo", "bar", map)
// WriteableHelper.kt
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
@Suppress("unused", "UNCHECKED_CAST")
fun writableMapOf(map: Map<String, *>): WritableMap {
val newMap = WritableNativeMap()
for ((key, value) in map) {
when (value) {
null -> newMap.putNull(key)
is Boolean -> newMap.putBoolean(key, value)
is Double -> newMap.putDouble(key, value)
is Int -> newMap.putInt(key, value)
is String -> newMap.putString(key, value)
is Array<*> -> newMap.putArray(key, Arguments.fromArray(value))
is Map<*, *> -> (value as? Map<String, *>)?.let { newMap.putMap(key, writableMapOf(it)) }
is WritableMap -> newMap.putMap(key, value)
is WritableArray -> newMap.putArray(key, value)
else -> throw IllegalArgumentException("Unsupported value type ${value::class.java.name} for key [$key]")
}
}
return newMap
}
@Suppress("unused")
fun writableMapOf(vararg values: Pair<String, *>): WritableMap {
return writableMapOf(values.toMap())
}
@Suppress("unused", "UNCHECKED_CAST")
fun writableArrayOf(vararg values: Any?): WritableArray {
val newArray = WritableNativeArray()
for (value in values) {
when (value) {
null -> newArray.pushNull()
is Boolean -> newArray.pushBoolean(value)
is Double -> newArray.pushDouble(value)
is Int -> newArray.pushInt(value)
is String -> newArray.pushString(value)
is Array<*> -> newArray.pushArray(Arguments.fromArray(value))
is Map<*, *> -> (value as? Map<String, *>)?.let { newArray.pushMap(writableMapOf(it)) }
is WritableArray -> newArray.pushArray(value)
is WritableMap -> newArray.pushMap(value)
else -> throw IllegalArgumentException("Unsupported type ${value::class.java.name}")
}
}
return newArray
}
AS-IS
리액트 네이티브로부터 값을 받을 때도 안정적으로 사용하기 위해 ReadableMap/Array
타입을 제공하고 있었고, 마찬가지로 Kotlin에서 사용하기엔 번거로운 감이 있었습니다.
@ReactMathod
fun some(map: ReadableMap, array: ReadableArray) {
if (map.hasKey("foo") && map.isNull("foo")) {
val foo = map.getString("foo")
}
if (array.size() > 0 && array.isNull(0)) {
val first = array.getString(0)
}
}
TO-BE
이것도 Kotlin에서 자주 쓰이는 ~OrNull
패턴을 채용해 Readableap/Array
타입용 축약 함수를 추가했습니다.
@ReactMathod
fun some(map: ReadableMap, array: ReadableArray) {
val foo = map.getStringOrNull()
val first = array.getStringOrNull(0)
}
// ReadableHelper.kt
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
private fun <R> ReadableMap.invokeOrNull(name: String, getter: () -> R) =
if (hasKey(name) && isNull(name).not()) getter.invoke() else null
@Suppress("unused")
fun ReadableMap.getBooleanOrNull(name: String) = invokeOrNull(name) { getBoolean(name) }
@Suppress("unused")
fun ReadableMap.getDoubleOrNull(name: String) = invokeOrNull(name) { getDouble(name) }
@Suppress("unused")
fun ReadableMap.getIntOrNull(name: String) = invokeOrNull(name) { getInt(name) }
@Suppress("unused")
fun ReadableMap.getStringOrNull(name: String) = invokeOrNull(name) { getString(name) }
@Suppress("unused")
fun ReadableMap.getArrayOrNull(name: String) = invokeOrNull(name) { getArray(name) }
@Suppress("unused")
fun ReadableMap.getMapOrNull(name: String) = invokeOrNull(name) { getMap(name) }
@Suppress("unused")
fun ReadableMap.getDynamicOrNull(name: String) = invokeOrNull(name) { getDynamic(name) }
@Suppress("unused")
fun ReadableMap.getTypeOrNull(name: String) = invokeOrNull(name) { getType(name) }
@Suppress("unused")
fun ReadableMap.getEntryIteratoreOrNull() = entryIterator
@Suppress("unused")
fun ReadableMap.getKeySetIteratorOrNull() = keySetIterator()
@Suppress("unused")
fun ReadableMap.toHashMapOrNull() = toHashMap()
private fun <R> ReadableArray.invokeOrNull(index: Int, getter: () -> R) =
if ((index in 0 until size()) && isNull(index).not()) getter.invoke() else null
@Suppress("unused")
fun ReadableArray.getBooleanOrNull(index: Int) = invokeOrNull(index) { getBoolean(index) }
@Suppress("unused")
fun ReadableArray.getDoubleOrNull(index: Int) = invokeOrNull(index) { getDouble(index) }
@Suppress("unused")
fun ReadableArray.getIntOrNull(index: Int) = invokeOrNull(index) { getInt(index) }
@Suppress("unused")
fun ReadableArray.getStringOrNull(index: Int) = invokeOrNull(index) { getString(index) }
@Suppress("unused")
fun ReadableArray.getArrayOrNull(index: Int) = invokeOrNull(index) { getArray(index) }
@Suppress("unused")
fun ReadableArray.getMapOrNull(index: Int) = invokeOrNull(index) { getMap(index) }
@Suppress("unused")
fun ReadableArray.getDynamicOrNull(index: Int) = invokeOrNull(index) { getDynamic(index) }
@Suppress("unused")
fun ReadableArray.getTypeOrNull(index: Int) = invokeOrNull(index) { getType(index) }
@Suppress("unused", "UNCHECKED_CAST")
fun <T> ReadableArray.toList() = toArrayList().toList() as? List<T>
아래는 위 익스텐션을 바탕으로 만든 브릿지 샘플 코드입니다.
단순 브릿지
// RNExampleModule.kt
import android.util.Log
import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
/**
* import { NativeModules } from 'react-native';
* const { RNExampleModule } = NativeModules;
*/
class RNExampleModule : ReactContextBaseJavaModule() {
override fun getName() = "RNExampleModule"
/**
* RNExampleModule.foo; // 'bar'
*/
override fun getConstants() = mapOf(
"foo" to "bar"
)
/**
* RNExampleModule.methodWithNoFeedback();
*/
@ReactMethod
fun methodWithNoFeedback() {
Log.d("RN", "'methodWithNoFeedback()' called!")
}
/**
* RNExampleModule.methodWithCallback(one, two, three => {
* console.log(one, two, three); // '1 2 3'
* });
*/
@ReactMethod
fun methodWithCallback(callback: Callback) {
Log.d("RN", "'methodWithCallback()' called!")
callback(1, 2, 3)
}
/**
* RNExampleModule.methodWithCallbackParamater('bar', result => {
* console.log(result); // 'bar'
* });
*/
@ReactMethod
fun methodWithCallbackParamater(paramater: String, callback: Callback) {
Log.d("RN", "'methodWithCallbackParamater()' called!")
callback(paramater)
}
/**
* RNExampleModule.methodWithPromise()
* .then(result => {
* console.log(result); // true
* })
* .catch(error => {
* console.log(error);
* });
*/
@ReactMethod
fun methodWithPromise(promise: Promise) {
Log.d("RN", "'methodWithPromise()' called!")
try {
promise.resolve(true/* ex: nil, 5, "string", writableArrayOf("for", "bar"), writableMapOf("foo" to "bar") */)
} catch (e: Exception) {
promise.reject(e)
}
}
/**
* RNExampleModule.methodWithPromiseParamater('foo')
* .then(result => {
* console.log(result); // 'foo'
* })
* .catch(error => {
* console.log(error);
* });
*/
@ReactMethod
fun methodWithPromiseParamater(paramater: String, promise: Promise) {
Log.d("RN", "'methodWithPromiseParamater()' called!")
try {
promise.resolve(paramater/* ex: nil, 5, "string", writableArrayOf("for", "bar"), writableMapOf("foo" to "bar") */)
} catch (e: Exception) {
promise.reject(e)
}
}
}
// RNExamplePackage.kt
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class RNExamplePackage : ReactPackage {
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(RNExampleModule())
}
}
이벤트 브릿지
// RNEventExampleModule.kt
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
/**
* import { NativeModules } from 'react-native';
* const { RNEventExampleModule } = NativeModules;
*/
class RNEventExampleModule(
val context: ReactApplicationContext
) : ReactContextBaseJavaModule(context) {
override fun getName() = "RNEventExampleModule"
/**
* export class EventExample {
* private static emitter = new NativeEventEmitter(RNEventExampleModule);
*
* static setup()
* this.emitter.removeAllListeners(RNEventExampleModuleEvent.CHANHE);
* this.emitter.addListener(RNEventExampleModuleEvent.CHANHE, event => {
* console.log(event);
* });
* }
* }
*/
fun sendEvent() {
EventHelper.send(context, changeEvent, writableMapOf("foo" to "bar"))/* ex: true, 5, "foo", [1, 2, 3] */
}
companion object {
const val changeEvent = "change"
}
}
// RNEventExamplePackage.kt
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class RNEventExamplePackage : ReactPackage {
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(RNEventExampleModule(reactContext))
}
}
적용 과정의 애로사항
네이티브를 혼용하며 생긴 구조적 문제
한참 개발하던 도중, 네이티브만 사용했을 때와 리액트 네이티브 위에 네이티브로 올라갔을 때 번들 크기나 메모리 사용량에 얼마나 차이가 있는지 확인했습니다. 콘텐츠를 서비스하는 앱이다 보니, 가용할 수 있는 메모리는 최대한 뷰어에 할당해야 하는 상황이었거든요. 따라서 메모리 사용량은 중요한 지표였습니다. 새로 추가된 웹툰・웹소설 탭에 접근해 작품을 선택하고 뷰어를 이용했을 때의 메모리 사용량을 정리해봤습니다.
Native | React Native | |
번들 크기 | 92MB | 102MB |
메모리 사용량 | 72MB | 360MB ~ |
번들 크기는 예상한 수준이었지만, 메모리는 처참했습니다. 최근 2~3년 내 출시된 기기는 하드웨어 스펙이 좋아 그럭저럭 문제가 되지 않았어요. 하지만 저사양 기기는 메모리 부족으로 책을 열람하다 앱이 종료되는 문제로 이어졌습니다. 원인을 확인하려고 리액트 네이티브 디버거, IDE 디버거 등 다양한 도구를 이용해봤는데요. 원인은 간단했습니다.
리액트 네이티브 위에 네이티브를 올리는 방향으로 개발하기로 했지만, 뷰어는 독립적인 화면이거든요. 리액트 네이티브를 덮는 형태로 뷰어가 사용되었고, 리액트 네이티브에서는 자신 위에 뷰어가 덮고 있는지 알 수 없어서 발생한 문제였습니다.
뷰 구조상으로는 이미 화면을 덮었기에 리액트 네이티브는 내려간 상태지만, 리액트에서 랜더링을 위해 들고 있는 리소스나 캐러셀을 위한 애니메이션 타이머 등이 그대로 유지되면서 메모리를 계속 점유하고 있던 겁니다. 이를 해결하고자 뷰어와 리액트 네이티브 간에 이벤트를 추가하고, 리액트에서 사용되는 타이머들은 화면에 보이지 않게 될 때 소멸하도록 대응했습니다.
그렇지만 언제 다시 뷰어가 사용할 메모리가 부족해질지 몰라, 항상 체크하고 관리하는 것이 영원한 과제로 남았습니다.
서드파티 라이브러리를 신뢰할 수 없음
리액트 네이티브 커뮤니티가 활발해지고 서드파티 라이브러리도 많이 나왔습니다. 하지만 개발하면서 느낀 것은 이들 라이브러리의 완성도가 천차만별하다는 것입니다.
예를 들어볼게요. 어떤 라이브러리는 Android 개발자가 메인 개발자였는지, Android의 완성도는 높았지만 iOS는 동작이 상이하거나 퍼포먼스가 떨어지는 쪽으로 구현이 되어있었어요. 또 네이티브 구현은 완벽한데, 리액트의 랜더 트리에 대한 이해도가 부족한 탓인지 과하게 리랜더를 타는 경우도 있었고요. 기능에 따라 네이티브와 프론트 기술 셋을 모두 다뤄야 하다 보니, 라이브러리의 완성도 차이가 곧잘 있었습니다.
그래서 라이브러리를 선택하려면 네이티브와 프론트 코드를 한 번씩 확인해야 했어요. 그런데도 나중에 문제가 발견되어 대체할 라이브러리를 찾거나, 직접 만들어야 하는 지경에 이르기도 했습니다.
네이티브와 프론트 모두 간과할 수 없다
프론트만 생각하면 “이거 그냥 한번 해보면 되는 거 아냐?”라고 리액트 네이티브를 간단하게 볼 수 있습니다. 하지만 프로덕트가 커지거나 기능이 복잡해질수록, 네이티브적인 한계를 만나게 됩니다.
예를 들어 네이티브 개발자의 경우, 네이티브 라이프 사이클과는 패러다임 체계가 전혀 다른 리액트 라이프 사이클에 한 번 놀랄 거예요. 앞서 소개한 대로 자칫 엉뚱한 서드파티 라이브러리를 만들게 될 수 있어요. 또 프론트 개발자의 경우, ‘분명 내가 아는 리액트인데 리액트가 아닌 것 같은’ 리액트 네이티브의 요소들 때문에 고통받을 것이고요. 최종적으로는 네이티브적인 제약 때문에 답답함을 느끼게 됩니다.
결국 리액트 네이티브로 프로덕트를 길게 가져갈 예정이라면 명심할 것이 있습니다. 네이티브 개발자가 프론트 개발을, 프론트 개발자가 네이티브 개발을 알아가야 합니다. 서로 간 교류가 성공의 핵심 열쇠가 될 것입니다.
리액트 네이티브,
이래서 좋다
높은 생산성
네이티브 개발자라면 공감하실 겁니다. UI나 로직 수정 시 반영된 결과 화면을 보기까지 빌드 타임을 거치죠. 개발하면서 확인하는 시간, 피드백을 받아 또 그걸 수정하고 확인하는 시간. 이런 시간이 많이 들어가곤 합니다.
리액트 네이티브에서는 리액트의 핫 리로드를 통해 빌드 타임 없이 바로바로 확인할 수 있어요. 하나의 코드 베이스로 여러 플랫폼을 지원해 생산성 증대에 효과적이었습니다. 네이티브에서도 패턴화, 자동화 등으로 생산성 증대가 가능하지만, 리액트 네이티브에서는 더 적은 비용으로 이뤄낼 수 있었다고 생각합니다.
협업하기에 좋은 플랫폼
네이티브로 모바일 앱을 개발하다 보면, 플랫폼 각각의 특성에 맞춰 기획을 다르게 하는 일이 잦습니다. 극단적인 경우, 아예 기능과 화면까지 크게 차이 나서 두 가지 제품을 관리하는 수준까지 갈 수 있는데요. 크로스 플랫폼으로 만들다 보니, 한 가지 기획으로 관리할 수 있어서 고객에게 실제로 주는 경험으로서의 가치에만 집중하게 되었습니다.
기획이 일원화되고 개발적인 생산성이 높아지자, 제품팀의 호홉적인 변화도 커졌습니다. 리액트의 핫 리로드를 바탕으로 디자이너와 빠른 피드백을 주고받을 수 있고, 이전보다 A/B 테스트를 빠르게 적용하고 검증하는 이터레이션 도는 속도가 나아졌습니다. 무엇보다 CodePush 덕분에 기획자, 마케터, 디자이너의 요구사항을 빠르게 반영할 수 있었습니다.
본격적인 시작
리액트 네이티브로 웹툰・웹소설을 서비스한 지 일 년이 지났습니다. 우여곡절이 있었지만, 리액트 네이티브였기에 주어진 기간 내에 제품 로드맵을 달성했습니다. 또 변화에 빠르게 대응하는 환경과 생산성을 갖출 수 있었다고 생각합니다.
검증은 마무리했으니 본격적으로 네이티브 영역을 리액트 네이티브로 바꿔나갈 예정입니다. 다만, ‘뷰어’만큼은 13년이라는 기간 쌓아 올린 노하우를 바탕으로 최상의 사용자 경험을 제공할 겁니다. 그래서 엔진 부분을 네이티브로 두고, UI/UX 요소를 리액트 네이티브로 포팅하는 형태로 진행할 것입니다.
이제 시작이고 앞으로도 도전할 과제가 많습니다. 리디북스에서 리액트 네이티브의 진수를 함께 경험하실 분들의 합류를 고대합니다.
고객과 발맞춰 새로운 콘텐츠 경험을 선보이는
리디와 함께할 당신을 기다립니다.