Skip to main content
Version: Next

네이티브와 React Native 간 통신

기존 앱 통합 가이드네이티브 UI 컴포넌트 가이드에서 네이티브 컴포넌트에 React Native를 임베드하는 방법과 그 반대의 경우를 배울 수 있다. 네이티브와 React Native 컴포넌트를 함께 사용하다 보면, 두 세계 간의 소통이 필요할 때가 있다. 다른 가이드에서 이미 몇 가지 방법을 소개했지만, 이 글에서는 사용 가능한 기술들을 종합적으로 정리한다.

소개

React Native는 React에서 영감을 받았기 때문에 정보 흐름의 기본 개념은 비슷하다. React에서의 흐름은 단방향이다. 컴포넌트의 계층 구조를 유지하며, 각 컴포넌트는 부모 컴포넌트와 자신의 내부 상태에만 의존한다. 이는 속성을 통해 이루어진다. 데이터는 상위에서 하위로 전달된다. 만약 상위 컴포넌트가 하위 컴포넌트의 상태에 의존한다면, 하위 컴포넌트가 상위 컴포넌트를 업데이트할 수 있도록 콜백 함수를 전달해야 한다.

이와 동일한 개념이 React Native에도 적용된다. 프레임워크 내에서만 애플리케이션을 구축한다면, 속성과 콜백을 통해 앱을 구동할 수 있다. 그러나 React Native와 네이티브 컴포넌트를 혼합해서 사용할 때는, 두 언어 간에 정보를 전달할 수 있는 특별한 메커니즘이 필요하다.

속성(Properties)

속성은 컴포넌트 간 통신을 위한 가장 직관적인 방법이다. 따라서 네이티브에서 리액트 네이티브로, 그리고 리액트 네이티브에서 네이티브로 속성을 전달할 수 있는 방법이 필요하다.

네이티브에서 React Native로 프로퍼티 전달하기

네이티브 컴포넌트에 React Native 뷰를 임베드하기 위해 RCTRootView를 사용한다. RCTRootView는 React Native 앱을 담는 UIView이며, 네이티브와 호스팅된 앱 사이의 인터페이스를 제공한다.

RCTRootView는 React Native 앱으로 임의의 프로퍼티를 전달할 수 있는 초기화 메서드를 제공한다. initialProperties 파라미터는 NSDictionary 인스턴스여야 한다. 이 딕셔너리는 내부적으로 JSON 객체로 변환되어 최상위 JS 컴포넌트에서 참조할 수 있다.

objectivec
NSArray *imageList = @[@"https://dummyimage.com/600x400/ffffff/000000.png",
@"https://dummyimage.com/600x400/000000/ffffff.png"];

NSDictionary *props = @{@"images" : imageList};

RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
moduleName:@"ImageBrowserApp"
initialProperties:props];
tsx
import React from 'react';
import {View, Image} from 'react-native';

export default class ImageBrowserApp extends React.Component {
renderImage(imgURI) {
return <Image source={{uri: imgURI}} />;
}
render() {
return <View>{this.props.images.map(this.renderImage)}</View>;
}
}

RCTRootView는 읽기-쓰기 프로퍼티인 appProperties도 제공한다. appProperties가 설정되면, React Native 앱은 새로운 프로퍼티로 리렌더링된다. 업데이트는 이전 프로퍼티와 새로운 프로퍼티가 다를 때만 수행된다.

objectivec
NSArray *imageList = @[@"https://dummyimage.com/600x400/ff0000/000000.png",
@"https://dummyimage.com/600x400/ffffff/ff0000.png"];

rootView.appProperties = @{@"images" : imageList};

프로퍼티는 언제든지 업데이트할 수 있다. 하지만 업데이트는 메인 스레드에서 수행되어야 한다. 반면, getter는 어떤 스레드에서든 사용할 수 있다.

note

현재 브릿지 시작 시 appProperties를 설정하면 변경 사항이 유실될 수 있는 알려진 이슈가 있다. 자세한 내용은 https://github.com/facebook/react-native/issues/20115에서 확인할 수 있다.

일부 프로퍼티만 업데이트하는 방법은 없다. 대신, 이를 직접 래퍼로 구현하는 것을 권장한다.

React Native에서 네이티브로 속성 전달하기

네이티브 컴포넌트의 속성을 노출하는 문제는 이 글에서 자세히 다룬다. 간단히 말해, 커스텀 네이티브 컴포넌트에서 RCT_CUSTOM_VIEW_PROPERTY 매크로를 사용해 속성을 내보낸 후, React Native에서 일반 React Native 컴포넌트처럼 사용하면 된다.

프로퍼티의 한계

크로스 언어 프로퍼티의 주요 단점은 콜백을 지원하지 않는다는 점이다. 콜백이 있다면 하향식 데이터 바인딩을 처리할 수 있다. 예를 들어, JS 액션의 결과로 네이티브 부모 뷰에서 작은 RN 뷰를 제거하려는 상황을 생각해보자. 프로퍼티만으로는 이를 구현할 수 없다. 정보가 하향식으로 전달되어야 하기 때문이다.

크로스 언어 콜백을 지원하는 방법이 있지만(여기서 설명), 이 콜백은 항상 우리가 원하는 것을 제공하지는 않는다. 주요 문제는 이 메커니즘이 프로퍼티로 전달되도록 설계되지 않았다는 점이다. 대신, 이 메커니즘은 JS에서 네이티브 액션을 트리거하고, 그 액션의 결과를 JS에서 처리할 수 있게 해준다.

다른 방식의 크로스 언어 상호작용 (이벤트와 네이티브 모듈)

이전 장에서 언급했듯이, 프로퍼티를 사용하는 데에는 몇 가지 제한이 따른다. 때로는 프로퍼티만으로는 앱의 로직을 충분히 구현하기 어려우며, 더 유연한 해결책이 필요하다. 이 장에서는 React Native에서 사용할 수 있는 다른 통신 기법을 다룬다. 이러한 기법은 내부 통신(React Native의 JS와 네이티브 레이어 간)뿐만 아니라 외부 통신(React Native와 앱의 '순수 네이티브' 부분 간)에도 활용할 수 있다.

React Native는 크로스 언어 함수 호출을 가능하게 한다. JS에서 커스텀 네이티브 코드를 실행할 수 있고, 그 반대도 가능하다. 다만, 작업하는 측면에 따라 동일한 목표를 달성하는 방법이 다르다. 네이티브 측에서는 이벤트 메커니즘을 사용해 JS에서 핸들러 함수의 실행을 스케줄링하고, React Native 측에서는 네이티브 모듈에서 내보낸 메서드를 직접 호출한다.

React Native에서 네이티브 함수 호출하기 (이벤트)

이벤트에 대한 자세한 설명은 이 글에서 확인할 수 있다. 이벤트는 별도의 스레드에서 처리되기 때문에 실행 시간에 대한 보장이 없다는 점을 유의해야 한다.

이벤트는 강력한 기능이다. React Native 컴포넌트에 대한 참조 없이도 컴포넌트를 변경할 수 있게 해주기 때문이다. 하지만 이벤트를 사용할 때 주의해야 할 몇 가지 문제점이 있다:

  • 이벤트는 어디서든 전송될 수 있기 때문에, 프로젝트 내에 스파게티 코드 형태의 의존성을 만들 수 있다.
  • 이벤트는 네임스페이스를 공유한다. 따라서 이름 충돌이 발생할 수 있으며, 이러한 충돌은 정적으로 감지되지 않아 디버깅이 어려울 수 있다.
  • 동일한 React Native 컴포넌트의 여러 인스턴스를 사용하고, 이벤트 관점에서 이를 구분하려면 식별자를 도입하고 이벤트와 함께 전달해야 한다. (네이티브 뷰의 reactTag를 식별자로 사용할 수 있다.)

React Native에 네이티브를 통합할 때 일반적으로 사용하는 패턴은 네이티브 컴포넌트의 RCTViewManager를 뷰의 대리자로 만드는 것이다. 이렇게 하면 브릿지를 통해 JavaScript로 이벤트를 보낼 수 있다. 이 방식은 관련 이벤트 호출을 한 곳에 모아 관리할 수 있게 해준다.

React Native에서 네이티브 함수 호출하기 (네이티브 모듈)

네이티브 모듈은 JS에서 사용할 수 있는 Objective-C 클래스다. 일반적으로 각 JS 브리지마다 모듈의 인스턴스가 하나씩 생성된다. 이 모듈은 임의의 함수와 상수를 React Native에 노출할 수 있다. 이에 대한 자세한 내용은 이 글에서 다룬다.

네이티브 모듈이 싱글톤이라는 사실은 임베딩 상황에서 메커니즘을 제한한다. 예를 들어, 네이티브 뷰에 임베디드된 React Native 컴포넌트가 있고, 이를 통해 상위 네이티브 뷰를 업데이트하고 싶다고 가정해 보자. 네이티브 모듈 메커니즘을 사용하면, 예상된 인자뿐만 아니라 상위 네이티브 뷰의 식별자도 받는 함수를 노출해야 한다. 이 식별자는 상위 뷰의 참조를 가져와 업데이트하는 데 사용된다. 따라서 모듈 내에서 식별자와 네이티브 뷰 간의 매핑을 유지해야 한다.

이 해결책은 복잡하지만, React Native 내부 클래스인 RCTUIManager에서 사용된다. RCTUIManager는 모든 React Native 뷰를 관리하는 클래스다.

네이티브 모듈은 기존 네이티브 라이브러리를 JS에 노출하는 데에도 사용할 수 있다. Geolocation 라이브러리가 이 아이디어의 실제 예시다.

caution

모든 네이티브 모듈은 동일한 네임스페이스를 공유한다. 새로운 모듈을 생성할 때 이름 충돌에 주의해야 한다.

레이아웃 계산 흐름

네이티브와 React Native를 통합할 때, 서로 다른 두 레이아웃 시스템을 통합하는 방법이 필요하다. 이 섹션에서는 흔히 발생하는 레이아웃 문제와 이를 해결하기 위한 메커니즘에 대해 간략히 설명한다.

React Native에 내장된 네이티브 컴포넌트의 레이아웃

이 경우는 이 글에서 다룬다. 요약하자면, 모든 네이티브 React 뷰는 UIView의 서브클래스이기 때문에 대부분의 스타일과 크기 속성은 기본적으로 예상대로 작동한다.

네이티브에 내장된 React Native 컴포넌트의 레이아웃

고정 크기의 React Native 컨텐츠

일반적인 시나리오는 네이티브 측에서 크기를 알고 있는 고정 크기의 React Native 앱을 다루는 경우다. 특히, 전체 화면을 차지하는 React Native 뷰가 이 경우에 해당한다. 더 작은 루트 뷰를 원한다면, RCTRootView의 프레임을 명시적으로 설정할 수 있다.

예를 들어, React Native 앱의 높이를 200(논리적) 픽셀로 만들고, 호스팅 뷰의 너비만큼 넓게 설정하려면 다음과 같이 할 수 있다:

SomeViewController.m
- (void)viewDidLoad
{
[...]
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
moduleName:appName
initialProperties:props];
rootView.frame = CGRectMake(0, 0, self.view.width, 200);
[self.view addSubview:rootView];
}

고정 크기의 루트 뷰를 사용할 때는 JS 측에서 이 경계를 존중해야 한다. 즉, React Native 컨텐츠가 고정 크기의 루트 뷰 안에 포함될 수 있도록 해야 한다. 이를 보장하는 가장 쉬운 방법은 플렉스 박스 레이아웃을 사용하는 것이다. 절대 위치 지정을 사용하고 React 컴포넌트가 루트 뷰의 경계를 벗어나 보이면 네이티브 뷰와 겹치게 되어 일부 기능이 예상치 못하게 동작할 수 있다. 예를 들어, 'TouchableHighlight'는 루트 뷰의 경계를 벗어난 터치를 강조하지 않는다.

루트 뷰의 크기를 프레임 속성을 다시 설정하여 동적으로 업데이트하는 것은 전혀 문제가 없다. React Native는 컨텐츠의 레이아웃을 자동으로 처리한다.

React Native에서 유동적인 크기의 콘텐츠 처리

어떤 경우에는 초기 크기가 정해지지 않은 콘텐츠를 렌더링해야 할 때가 있다. 예를 들어, 크기가 JS에서 동적으로 정의되는 상황이 그렇다. 이 문제를 해결하기 위한 두 가지 방법이 있다.

  1. React Native 뷰를 ScrollView 컴포넌트로 감싸는 방법이다. 이렇게 하면 콘텐츠가 항상 표시되고, 네이티브 뷰와 겹치지 않도록 보장할 수 있다.
  2. React Native에서는 JS에서 앱의 크기를 결정하고, 이를 호스팅하는 RCTRootView의 소유자에게 전달할 수 있다. 소유자는 서브뷰를 다시 배치하고 UI를 일관되게 유지할 책임이 있다. 이는 RCTRootView의 유연성 모드를 통해 구현된다.

RCTRootView는 4가지 크기 유연성 모드를 지원한다:

RCTRootView.h
typedef NS_ENUM(NSInteger, RCTRootViewSizeFlexibility) {
RCTRootViewSizeFlexibilityNone = 0,
RCTRootViewSizeFlexibilityWidth,
RCTRootViewSizeFlexibilityHeight,
RCTRootViewSizeFlexibilityWidthAndHeight,
};

RCTRootViewSizeFlexibilityNone은 기본값으로, 루트 뷰의 크기를 고정한다(하지만 setFrame:을 통해 업데이트할 수 있다). 나머지 세 가지 모드는 React Native 콘텐츠의 크기 업데이트를 추적할 수 있게 해준다. 예를 들어, 모드를 RCTRootViewSizeFlexibilityHeight로 설정하면 React Native가 콘텐츠의 높이를 측정하고 이 정보를 RCTRootView의 델리게이트에게 전달한다. 델리게이트 내에서는 루트 뷰의 프레임을 설정하는 등 다양한 작업을 수행할 수 있으며, 콘텐츠 크기가 변경될 때만 델리게이트가 호출된다.

caution

JS와 네이티브 양쪽에서 동일한 차원을 유연하게 설정하면 정의되지 않은 동작이 발생할 수 있다. 예를 들어, RCTRootViewSizeFlexibilityWidth를 사용하는 동안 최상위 React 컴포넌트의 너비를 flexbox로 유연하게 설정하지 말아야 한다.

예제를 살펴보자.

FlexibleSizeExampleView.m
- (instancetype)initWithFrame:(CGRect)frame
{
[...]

_rootView = [[RCTRootView alloc] initWithBridge:bridge
moduleName:@"FlexibilityExampleApp"
initialProperties:@{}];

_rootView.delegate = self;
_rootView.sizeFlexibility = RCTRootViewSizeFlexibilityHeight;
_rootView.frame = CGRectMake(0, 0, self.frame.size.width, 0);
}

#pragma mark - RCTRootViewDelegate
- (void)rootViewDidChangeIntrinsicSize:(RCTRootView *)rootView
{
CGRect newFrame = rootView.frame;
newFrame.size = rootView.intrinsicContentSize;

rootView.frame = newFrame;
}

이 예제에서는 FlexibleSizeExampleView 뷰가 루트 뷰를 포함하고 있다. 루트 뷰를 생성하고 초기화한 후 델리게이트를 설정한다. 델리게이트는 크기 업데이트를 처리한다. 그런 다음 루트 뷰의 크기 유연성을 RCTRootViewSizeFlexibilityHeight로 설정하여, React Native 콘텐츠의 높이가 변경될 때마다 rootViewDidChangeIntrinsicSize: 메서드가 호출되도록 한다. 마지막으로 루트 뷰의 너비와 위치를 설정한다. 높이도 설정했지만, 높이는 React Native에 의존하므로 효과가 없다.

예제의 전체 소스 코드는 여기에서 확인할 수 있다.

루트 뷰의 크기 유연성 모드를 동적으로 변경하는 것도 가능하다. 유연성 모드를 변경하면 레이아웃 재계산이 예약되고, 콘텐츠 크기가 결정되면 델리게이트의 rootViewDidChangeIntrinsicSize: 메서드가 호출된다.

note

React Native의 레이아웃 계산은 별도의 스레드에서 수행되며, 네이티브 UI 뷰 업데이트는 메인 스레드에서 이루어진다. 이로 인해 네이티브와 React Native 간에 일시적인 UI 불일치가 발생할 수 있다. 이는 알려진 문제이며, 팀은 다양한 소스에서 오는 UI 업데이트를 동기화하기 위해 노력하고 있다.

note

React Native는 루트 뷰가 다른 뷰의 서브뷰가 될 때까지 레이아웃 계산을 수행하지 않는다. React Native 뷰의 크기를 알 때까지 뷰를 숨기려면, 루트 뷰를 서브뷰로 추가하고 초기에 숨긴 상태로 설정한다(UIViewhidden 속성 사용). 그런 다음 델리게이트 메서드에서 뷰의 가시성을 변경한다.