App center 지원 종료로 브레이브모바일(숨고)에서 만든 코드푸시 라이브러리를 사용했습니다.
npm install @bravemobile/react-native-code-push
npm install dotenv // env 파일을 읽기 위함
환경변수로 설정되어 있다면 생략 가능
AWS_ACCESS_KEY_ID=내 AWS 엑세스 키
AWS_SECRET_ACCESS_KEY=내 AWS 시크릿 엑세스 키
안드로이드
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()
}
}
}
Info의 Configurations에 아래 플러스 버튼 → “Duplicate “Release” Configuration” → 이름 “Staging”으로 변경
Build Setting
Add User-Defined Setting 클릭하고 이름을 MULTI_DEPLOYMENT_CONFIG로 변경합니다.
Release와 Staging에 각각 입력합니다.
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
이 부분을
return [CodePush bundleURL];
로 수정
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;
반드시 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),
);
{
"extends": "@react-native/typescript-config/tsconfig.json",
"include": ["code-push.config.ts"],
"ts-node": {
"compilerOptions": {
"module": "CommonJS",
"types": ["node"]
}
}
}
버전 히스토리 생성
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
새로운 걸 도입하기 전엔 항상 걱정이 먼저였는데, 막상 직접 적용해보니까 생각보다 훨씬 수월했고 결과도 만족스러웠습니다. 🙌
S3를 통해 별도의 OTA 서버를 구축하신거군요 ㄷ
혹시 서버 구축은 어떤 프레임워크로 하신건지 그 공수가 궁금핣니다!
그리고 별도의 서버를 운영하다보면 유시보수나 비용적인면도 무시를 못할거같은데 EAS Update와 비교할때 어떤 장점이 있는지도 궁금하네요 ㅎ