S3를 이용하여 React-native에 CodePush 적용하기

wishwish·2025년 10월 15일
1

React-native

목록 보기
2/2

App center 지원 종료로 브레이브모바일(숨고)에서 만든 코드푸시 라이브러리를 사용했습니다.

설치

npm install @bravemobile/react-native-code-push
npm install dotenv // env 파일을 읽기 위함

.env 파일 설정

환경변수로 설정되어 있다면 생략 가능


AWS_ACCESS_KEY_ID=내 AWS 엑세스 키
AWS_SECRET_ACCESS_KEY=내 AWS 시크릿 엑세스 키

CodePush 설정

안드로이드

android/app/build.gradle 가장 하단에 아래 코드 추가

apply from: "../../node_modules/@bravemobile/react-native-code-push/android/codepush.gradle"

MainApplication.kt 파일에

상단에 import com.microsoft.codepush.react.CodePush 추가

class MainApplication : Application(), ReactApplication {

  override val reactNativeHost: ReactNativeHost =
      object : DefaultReactNativeHost(this) {
      ...
      ...
      override fun getJSBundleFile(): String { // 추가
          return CodePush.getJSBundleFile()
        } 
  }
}

IOS

  1. Info의 Configurations에 아래 플러스 버튼 → “Duplicate “Release” Configuration” → 이름 “Staging”으로 변경

  2. Build Setting
    Add User-Defined Setting 클릭하고 이름을 MULTI_DEPLOYMENT_CONFIG로 변경합니다.

Release와 Staging에 각각 입력합니다.

  1. AppDelegate.mm 파일에
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];

이 부분을

return [CodePush bundleURL]; 

로 수정

code-push.config.ts 작성

import {
  CliConfigInterface,
  ReleaseHistoryInterface,
} from '@bravemobile/react-native-code-push';
import {S3Client, PutObjectCommand, GetObjectCommand} from '@aws-sdk/client-s3';
import {Upload} from '@aws-sdk/lib-storage';
import * as fs from 'fs';
import * as path from 'path';
import * as dotenv from 'dotenv';

// .env 파일 로드
dotenv.config();

// S3 설정
const S3_BUCKET = 'wish-project'; // 해당 프로젝트 버킷에 맞게 수정
const S3_REGION = 'ap-northeast-2'; // 서울 리전
const S3_BASE_PATH = 'codepush'; // S3 내 베이스 경로

const s3Client = new S3Client({
  region: S3_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
  },
});

// S3 URL 생성 헬퍼
function getS3Url(key: string): string {
  return `https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com/${key}`;
}

// S3 키 생성 헬퍼
function getBundleKey(
  platform: 'ios' | 'android',
  identifier: string,
  binaryVersion: string,
  appVersion: string,
): string {
  return `${S3_BASE_PATH}/bundles/${platform}/${identifier}/${binaryVersion}/${appVersion}.zip`;
}

function getHistoryKey(
  platform: 'ios' | 'android',
  identifier: string,
  binaryVersion: string,
): string {
  return `${S3_BASE_PATH}/histories/${platform}/${identifier}/${binaryVersion}.json`;
}

// 번들 출력 디렉토리에서 메타데이터 읽기
function getBundleMetadata(source: string): {
  binaryVersion: string;
  appVersion: string;
} {
  try {
    // CLI가 생성하는 번들 디렉토리 구조에서 metadata.json 읽기
    const bundleDir = path.dirname(source);
    const metadataPath = path.join(bundleDir, 'metadata.json');

    if (fs.existsSync(metadataPath)) {
      const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
      return {
        binaryVersion: metadata.binaryVersion || '1.0.0',
        appVersion: metadata.appVersion || '1.0.0',
      };
    }
  } catch (error) {
    console.warn('Could not read bundle metadata:', error);
  }

  // 환경변수에서 읽기 (fallback)
  return {
    binaryVersion: process.env.BINARY_VERSION || '1.0.0',
    appVersion: process.env.APP_VERSION || '1.0.1',
  };
}

const Config: CliConfigInterface = {
  // 번들 파일을 S3에 업로드
  bundleUploader: async (
    source: string,
    platform: 'ios' | 'android',
    identifier?: string,
  ): Promise<{downloadUrl: string}> => {
    try {
      const id = identifier || 'production';

      // 번들 메타데이터 가져오기
      const {binaryVersion, appVersion} = getBundleMetadata(source);

      console.log(
        `Bundle Info - Binary: ${binaryVersion}, App: ${appVersion}, Identifier: ${id}`,
      );

      const fileStream = fs.createReadStream(source);
      const key = getBundleKey(platform, id, binaryVersion, appVersion);

      console.log(`Uploading bundle to S3: ${key}`);

      const upload = new Upload({
        client: s3Client,
        params: {
          Bucket: S3_BUCKET,
          Key: key,
          Body: fileStream,
          ContentType: 'application/zip',
          // Public read 권한 설정 (필요한 경우)
          // ACL: "public-read",
        },
      });

      await upload.done();

      const downloadUrl = getS3Url(key);
      console.log(`Bundle uploaded successfully: ${downloadUrl}`);

      return {downloadUrl};
    } catch (error) {
      console.error('Error uploading bundle to S3:', error);
      throw error;
    }
  },

  // S3에서 릴리즈 히스토리 가져오기
  getReleaseHistory: async (
    targetBinaryVersion: string,
    platform: 'ios' | 'android',
    identifier?: string,
  ): Promise<ReleaseHistoryInterface> => {
    console.log('getReleaseHistory: ', targetBinaryVersion, ', ', identifier);
    try {
      const id = identifier || 'production';
      const key = getHistoryKey(platform, id, targetBinaryVersion);

      console.log(`Fetching release history from S3: ${key}`);

      const command = new GetObjectCommand({
        Bucket: S3_BUCKET,
        Key: key,
      });

      const response = await s3Client.send(command);
      const bodyString = await response.Body?.transformToString();

      if (!bodyString) {
        throw new Error('Empty response from S3');
      }

      const releaseHistory: ReleaseHistoryInterface = JSON.parse(bodyString);
      console.log('Release history fetched successfully');
      return releaseHistory;
    } catch (error: any) {
      if (error.name === 'NoSuchKey') {
        console.log('Release history not found, returning empty history');
        // 히스토리가 없으면 빈 객체 반환
        return {};
      }
      console.error('Error fetching release history from S3:', error);
      throw error;
    }
  },

  // S3에 릴리즈 히스토리 저장
  setReleaseHistory: async (
    targetBinaryVersion: string,
    jsonFilePath: string,
    releaseInfo: ReleaseHistoryInterface,
    platform: 'ios' | 'android',
    identifier?: string,
  ): Promise<void> => {
    try {
      const id = identifier || 'production';
      const key = getHistoryKey(platform, id, targetBinaryVersion);

      console.log(`Uploading release history to S3: ${key}`);

      // JSON 파일 읽기
      const jsonContent = fs.readFileSync(jsonFilePath, 'utf-8');

      const command = new PutObjectCommand({
        Bucket: S3_BUCKET,
        Key: key,
        Body: jsonContent,
        ContentType: 'application/json',
        // Public read 권한 설정 (필요한 경우)
        // ACL: "public-read",
      });

      await s3Client.send(command);

      console.log(`Release history uploaded successfully: ${getS3Url(key)}`);
    } catch (error) {
      console.error('Error uploading release history to S3:', error);
      throw error;
    }
  },
};

module.exports = Config;

App.tsx 수정

반드시 CodePush HOC로 App 컴포넌트를 감싸야 합니다!

const S3_BUCKET = 'the-liter-codepush'; // 해당 프로젝트 버킷에 맞게 수정
const S3_REGION = 'ap-northeast-2';
const S3_BASE_PATH = 'codepush';
const IDENTIFIER = __DEV__ ? 'staging' : 'production';

// Release History Fetcher: S3에서 업데이트 정보 가져오기
async function releaseHistoryFetcher(updateRequest: any) {
  try {
    const platform = Platform.OS;
    const url = `https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com/${S3_BASE_PATH}/histories/${platform}/${IDENTIFIER}/${updateRequest.app_version}.json`;

    console.log('[CodePush] Fetching release history:', url);
    const {data} = await axios.get(url);
    console.log('[CodePush] Release history fetched:', data);

    return data;
  } catch (error) {
    console.error('[CodePush] Failed to fetch release history:', error);
    // 에러 발생 시 빈 히스토리 반환
    return {
      binary_version: updateRequest.app_version,
      releases: [],
    };
  }
}

// ...

// ============================================
// CodePush HOC로 App 컴포넌트 감싸기
// ============================================
export default Sentry.wrap(
  CodePush({
    // 앱 재시작/재개 시 자동으로 업데이트 체크
    checkFrequency: CodePush.CheckFrequency.ON_APP_RESUME,

    // 다음 재시작 시 업데이트 적용 (사용자 경험 방해 안함)
    installMode: CodePush.InstallMode.IMMEDIATE,

    // 필수 업데이트인 경우 즉시 재시작 (선택사항)
    // installMode: CodePush.InstallMode.IMMEDIATE,

    // Release History Fetcher 함수
    releaseHistoryFetcher: releaseHistoryFetcher,

    // 콜백 함수들 (선택사항)
  })(App),
);

tsconfig.json 수정

{
  "extends": "@react-native/typescript-config/tsconfig.json",
  "include": ["code-push.config.ts"],
  "ts-node": {
    "compilerOptions": {
      "module": "CommonJS",
      "types": ["node"]
    }
  }
}

CodePush 명령어

버전 히스토리 생성

npx code-push create-history --binary-version [현재 배포된 앱 버전] --platform ios --identifier production

릴리즈

npx code-push release --binary-version [현재 배포된 앱 버전] --app-version [릴리즈할 버전] \ --platform [ios 또는 android] --identifier production --entry-file index.js \ --mandatory true

느낀점

새로운 걸 도입하기 전엔 항상 걱정이 먼저였는데, 막상 직접 적용해보니까 생각보다 훨씬 수월했고 결과도 만족스러웠습니다. 🙌

참고

https://velog.io/@minwoo129/React-Native%EC%97%90%EC%84%9C-CodePush-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

https://github.com/Soomgo-Mobile/react-native-code-push

3개의 댓글

comment-user-thumbnail
6일 전

S3를 통해 별도의 OTA 서버를 구축하신거군요 ㄷ
혹시 서버 구축은 어떤 프레임워크로 하신건지 그 공수가 궁금핣니다!
그리고 별도의 서버를 운영하다보면 유시보수나 비용적인면도 무시를 못할거같은데 EAS Update와 비교할때 어떤 장점이 있는지도 궁금하네요 ㅎ

1개의 답글