서비스를 기획하고, 개발하고, 배포를 하면서 수 많은 형태의 서비스들이 세상에 나오게 됩니다. 반응형으로 개발을 진행하면 하나의 코드로 웹과 모바일 브라우저 모두 커버가 가능하죠. 하지만 이걸 앱스토어에 등록한다는 또 다른 이야기입니다. 오늘은 웹으로 만들 프로젝트를 앱 형태로 패키징해서 스토어에 등록하는 과정, 즉 앱 패키징(App Packaging)에 대해 이야기해보려 합니다.
앱을 만드는 방식들
사실 앱을 만든다는 말 속에는 여러가지 접근 방식이 숨어있습니다. 대표적으로 아래 4가지 방식이 있죠.
1. 네이티브 개발
네이티브 개발은 Android는 Kotlin/Java, iOS는 Swift/Object-C로 각각 개발하는 방식입니다. 성능은 가장 좋고, 플랫폼 API 접근도 자유롭지만 두 플랫폼은 각각 따로 개발해야 하니 비용과 리소스가 많이 듭니다.
그렇다면 여기서 말하는 성능은 어떤게 있을가요?
네이티브 코드는 OS 레벨에서 바로 실행되기 때문에, CPU, GPU, 메모리, 센서 등 하드웨어 리소스에 직접 접근이 가능합니다. 즉 카메라 제어, 블루투스, 저전력 최적화 등 모바일에 대한 제어권을 완전히 가지고 있죠. 반면에 네이티브로 작성되지 않은 코드들은 이런 API를 간접적으로 호출하거나, 플러그인으로 접근해야 하기 때문에 느리거나 제약이 생기는 경우가 많습니다.
또한 렌더링과 멀티스레팅/백그라운드 제어에도 차이가 납니다. 네이티브는 GPU 가속을 직접 활용해서 UI 애니메이션, 전환, 3D 렌더링 같은 그래픽 연산을 바로 처리할 수 있지만 네이티브가 아닌 경우에는 브릿지, 네이티브 UI로 전달되기 때문에 체감 차이가 발생합니다. 또한 OS의 스레드와 프로세스 모델에 접근이 가능하기 때문에 비동기나 병렬 연산에서의 최적화가 가능하죠.
2. 하이브리드 앱
React Native, Flutter, Ionic, Capacitor 같은 프레임워크를 이용해서 하나의 코드로 Android와 iOS를 모두 빌드하는 방식입니다. 네이티브 코드와 웹 기술(HTML, JS, CSS)이 섞여 있어 브라우저 위에서 네이티브 껍데기를 입힌 방식으로 동작합니다. 기본적으로 웹뷰(Web View)안에 페이지를 띄우고, 이 웹뷰와 네이티브 코드가 브릿지(Bridge)를 통해 통신하는 구조로 되어있습니다.
[ JavaScript 코드 ] ⇄ [ Bridge ] ⇄ [ Android/iOS 네이티브 코드 ]
이 구조 덕분에 UI는 HTML/CSS로 빠르게 구성하고, 센서나 카메라 같은 기능들은 Bridge를 통해 접근할 수 있습니다. 이러한 구조 덕분에 개발 속도에 훨씬 장점이 있습니다. RN이나 Capacitor를 사용하면 기존에 웹 프론트엔드 개발된 코드를 그대로 앱을 만들 수 있기 때문에 웹 개발자도 앱을 만들 수 있게 되는거죠.
하지만 JS와 네이티브 사이의 브릿지 통신은 성능 오버헤드가 존재합니다. 카메라 미리보기와 같이 프레임이 빠른 기능은 렉이 걸릴 수 있고, 무거운 연산은 네이티브보다 느립니다. 그래서 고성능 그래픽, 실시간 연산, 혹은 기기에 대한 제어가 필요한 경우에는 아직도 네이티브 개발을 선호합니다.
3. 웹앱(PWA: Progressive Web App)
PWA는 웹사이트지만, 앱처럼 동작할 수 있게 도와주는 기술입니다. 홈 화면에 아이콘 추가, 오프라인 캐싱, 푸쉬 알림 등의 기능을 지원하기 때문에 겉보기에는 네이티브 앱과 거의 비슷하게 보입니다. PWA는 기본적으로 웹 기반이라 배포나 업데이트가 진짜 간단합니다. 스토어 심사도 따로 필요 없고, 웹 서버에 파일만 배포하면 즉시 반영되죠. 또한 오프라인 상태에서도 일부 페이지 접근 가능, 푸쉬 알림, 앱처럼 아이콘/스플래시 화면 표시가 되기 때문에 스타트업이나 프로토타입 개발에서는 가볍고 빠른 대안으로 많이 사용됩니다. 대표적인 예시로는 넷플릭스, X(구 트위터), 스포티 파이의 모바일 웹 버전이 있죠.
4. 패키징(App Packaging)
패키징은 이름 그대로 웹으로 만든 프로젝트를 앱처럼 감싸는 작업입니다. 즉, 이미 만들어진 웹 서비스를 그대로 묶어서 앱 스토어에 올릴 수 있게 만드는거죠. 이 방식은 기존에 만든 웹앱을 다시 개발하지 않아도 된다는 장점이 있습니다.
패키징은 기본적으로 WebView 기반이기 때문에, 앱을 실행하면 내부적으로 웹뷰가 열리게 되고, 그 안에서 우리의 웹 페이지 www.gnaak.com이 이 동작합니다. 하지만 단순히 웹을 띄우는게 아니라, 푸시 알림, 인앱 결제, 로컬 저장소 접근 등 웹만으로는 불가능했던 기능들을 플러그인 추가를 통해 가능하게 해줍니다.
그중에서도 Capacitor는 지금 가장 많이 쓰이는 방식입니다. 웹 프로젝트(React, Next.js, Vue 등)를 그대로 감싸고 푸시 알림(Firebase), 결제(Google Play Billing), 파일 접근 같은 네이티브 기능까지 바로 붙일 수 있죠. 그렇다면 Capacitor로 패키징은 어떻게 진행할가요?
Firebase 앱 생성
https://console.firebase.google.com/u/0/에 접속해서 새로운 앱을 생성해줍니다.
로그인 - Google 계정
이메일 또는 휴대전화
accounts.google.com
Android로 앱 추가를 누른 후, 패키지 이름, 앱 이름을 설정해줍시다. 여기서 사용된 패키지 이름, 앱 이름을 동일하게 사용하여 패키징도 진행합니다. 이후 google-service.json을 다운로드 받아줍시다. 이 과정은 앱이 어떤 프로젝트에 속하는지를 정의해줍니다. project_id, package_name, project_number 등이 google-service.json 내에 포함되며, 나중에 Android 앱이 Firebase에 접속할 때 사용되는 신분증이라고도 볼 수 있겠네요.

Firebase 웹 앱 추가
이제 웹도 추가해줍시다. 이건 React 코드에서 Firebase SDK를 사용할 수 있게 해주는 별도 엔트리입니다. React는 Android가 아니라 브라우저 기반으로 동작하기 때문에 Firebase는 웹용 설정을 따로 만들어줘야 합니다.

npm install firebase
frontend/
├─ src/
│ ├─ App.tsx
│ ├─ firebase.ts ✅ 새로 생성
│ └─ ...
├─ android/
├─ package.json
└─ ...
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: "AI******************ljmA",
authDomain: "gnaak-*****.firebaseapp.com",
projectId: "gnaak-*****",
storageBucket: "gnaak-*****.firebasestorage.app",
messagingSenderId: "5490******65",
appId: "1:5490******65:web:4ca275340151a8f1cdc354",
measurementId: "G-HE4XJKFE6L"
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
Capacitor
이제 기존에 개발한 React 기반 코드에 capacitor을 세팅해줍시다.
npm install @capacitor/core @capacitor/cli
npx cap init
위 명령어를 입력하면, 앱 이름과 패키지 ID를 입력하는 칸이 나옵니다. Firebase에 등록한 앱 이름과 패키지 이름 동일하게 설정해줍시다.

capacitor.config.ts가 성공적으로 생성되었다고 뜹니다. 파일을 가서 살펴봅시다.
// capacitor.config.ts
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.gnaak.app',
appName: '그낙이네',
webDir: 'dist'
};
export default config;
입력한대로 정상적으로 생성된 것을 확인할 수 있습니다. Capacitor는 React(웹)과 Android/iOS(네이티브)를 이어주는 다리 역할을 맡게 됩니다. 즉, 웹 프로젝트를 네이티브로 감싸주는 껍데기 생성 및 Firebase 연결 ID를 일치시켜주는 과정이죠.
Android 플랫폼 추가
이제 Android 플랫폼을 추가해줍시다.
npm install @capacitor/android
npx cap add android
위 명령어를 입력하면, android 폴더가 생성됩니다. 위에서 미리 다운받았던 google-service.json을 넣어줍시다.
frontend/
├─ android/
│ ├─ app/
│ │ ├─ google-services.json
│ ├─ build.gradle
│ └─ settings.gradle
├─ capacitor.config.ts
├─ src/
└─ package.json
React 프로젝트 밑에 생긴 android 폴더를 통해서 Android Studio가 읽을 수 있는 네이티브 프로젝트가 생성되었습니다. 그 안에 google-service.json을 넣어주는 이유는, Firebase SDK가 앱 실행 시 이 JSON을 읽고 Firebase 서버에 연결하기 위해서입니다.
실제 동작 시 흐름입니다.
1. React 코드가 Capacitor 안에서 돌아갑니다.
2. Capacitor가 Android 네이티브 껍데기에서 WebView를 띄웁니다.
3. Android 네이티브 코드가 google-services.json을 읽습니다.
4. Firebase SDK가 해당 프로젝트(gnaak-851d3)로 연결됩니다.
5. React 쪽 Firebase SDK(firebaseConfig)도 같은 프로젝트에 접속합니다.
6. 둘 다 같은 Firebase 백엔드를 공유하게 됩니다.
즉, React와 Android가 같은 Firebase 프로젝트를 바라보는 구조가 완성되었습니다.
FCM(Firebase Cloud Messaging)
그렇다면, 이제 패키징한 앱에 기능을 달아줍시다. 가장 단순한 기능은 알림이니, 알림을 추가해주면 학습에 도움이 될 것 같습니다. 이를 개발하기 위해서는 우선 FCM에 대한 이해가 필요합니다.
FCM은 Google이 제공하는 무료 푸시 알림 서비스입니다. 쉽게 말해서 서버가 사용자에게 실시간으로 알림을 보낼 수 있게 도와주는 시스템이죠. 앱을 닫아놔도, 심지어는 꺼져 있어도 Firebase가 대신 알림을 전달해줍니다. apscheduler나 crontab을 이용하면 원하는 시간에 알림이 가도록 설정해둘 수도 있죠.
기본적으로 브라우저나 앱은 보안상의 이유로 서버가 직접 기기에 메시지를 보내는 걸 허용하지 않습니다. 중간에서 중계자 역할을 하는게 FCM입니다. 클라이언트(웹/앱)은 Firebase에서 FCM Token을 발급받고, 서버는 그 토큰을 저장해뒀다가 알림이 필요한 경우에 Firebase로 "이 토큰에 푸시 알림 보내줘"라고 요청을 보내는거죠. 그렇다면 이 FCM은 어떻게 받을 수 있을가요?
// frontend/src/firebase.ts
// ...
export const messaging = getMessaging(app);
export async function requestFCMToken() {
try {
const token = await getToken(messaging, {
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
});
console.log("FCM Token:", token);
return token;
} catch (error) {
console.error("FCM 토큰 발급 실패:", error);
}
}
// 포그라운드 메시지 수신
onMessage(messaging, (payload) => {
console.log("새 알림 도착:", payload);
const title = payload.notification?.title || "알림";
const body = payload.notification?.body || "";
const icon = "/image.png";
if (Notification.permission === "granted") {
new Notification(title, { body, icon });
} else {
console.warn("알림 권한이 없습니다.");
}
});
위에서 작성한 firebase.ts에 위 코드를 추가해줍시다. VITE_FIREBASE_VAPID_KEY는 콘솔에서 확인할 수 있습니다.(저는 VITE 기반의 프로젝트이기 때문에 위와 같이 불러왔습니다.)

이제 위 키를 프론트엔드 .env에 등록한 후, main.tsx 혹은 다른 파일에서 호출해봅시다. 아래와 같이 FCM을 잘 불러온 것을 확인해 볼 수 있습니다.

이제 포그라운드에서 메시지 수신을 진행해봅시다. 특정 시간에 맞춰 알림이 가게 하는게 가장 좋지만, 지금은 그렇게 까지 기능을 개발할 수 없으니 간단하게 버튼을 클릭했을 때, 알림이 오는 기능으로 진행해봅시다.
const Main = () => {
const sendNotification = async () => {
const res = await fetch("http://localhost:8000/api/sub/send_fcm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: "fT4RSVcsFOTwx8BeNIYqkf:APA91bF6swOS6ZHX9nz4DBK_86JGteH3q1qKIEss9mmHL3NpIS2cgCn2LxrsSO4b8WWpt8-50UcbmVTDJ75-5x7LDd4gtod7BpJ-zWKASZJJOO87GGD3UKI",
title: "테스트 알림",
message: "이건 React에서 보낸 FCM입니다",
}),
});
const data = await res.json();
console.log("서버 응답:", data);
};
return (
<>
<div className="flex flex-col w-full h-full gap-4">
<button onClick={sendNotification}>🔔 테스트 알림 보내기</button>
</div>
</>
);
};
export default Main;
간단한 버튼 한 개와, 콘솔에 찍혔던 FCM을 그대로 넣어서 서버에 전송해줍시다.
다음은 백엔드입니다.
우선 Firebase V1 API를 사용하기 위해서는 서비스 계정 키가 필요합니다. 새 비공개 키 생성을 눌러, 서비스 계정 키를 받아줍시다.

firebase-adminsdk-xxxxx.json와 같이 다운받아졌지만, 코드에서 쉽게 사용할 수 있도록 firebase_service.json으로 이름을 바꾸고 원하는 경로에 저장해줍시다.
from fastapi import APIRouter, Request
from app.module.infra.fcm_service import FCMService
router = APIRouter()
fcm = FCMService("app/core/firebase_service.json")
@router.post("/send_fcm")
async def send_fcm(request: Request):
body = await request.json()
token = body["token"]
title = body.get("title", "테스트 알림")
message = body.get("message", "지금은 FCM 테스트 중입니다")
result = await fcm.send_message(token, title, message)
return {"status": "sent", "result": result}
# app/module/infra/fcm_service.py
import json
import aiohttp
from google.oauth2 import service_account
from google.auth.transport.requests import Request
class FCMService:
def __init__(self, service_key_path: str):
self.service_key_path = service_key_path
self.scoped_credentials = service_account.Credentials.from_service_account_file(
service_key_path,
scopes=["https://www.googleapis.com/auth/firebase.messaging"],
)
async def get_access_token(self):
self.scoped_credentials.refresh(Request())
return self.scoped_credentials.token
async def send_message(self, token: str, title: str, body: str):
access_token = await self.get_access_token()
project_id = self.scoped_credentials.project_id
url = f"https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json; UTF-8",
}
payload = {
"message": {
"token": token,
"notification": {
"title": title,
"body": body,
},
}
}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload) as resp:
data = await resp.json()
print("FCM Response:", data)
return data
이제 버튼 클릭 시, 알림이 오는 것을 확인할 수 있습니다.

이번 글에서는 간단하게 앱을 개발할 수 있는 4가지 접근 방식과, Capacitor을 활용해 FCM을 연동하는 과정까지 살펴봤습니다. 결국 핵심은 하나의 코드베이스로 웹과 앱을 모두 서비스할 수 있는 구조를 만드는 것이겠죠.
언제나처럼 ㅡ 시작은 삽질이지만, 끝은 지식입니다.
'기능 > 앱' 카테고리의 다른 글
| [capacitor] 인증 로직 수정하기 (0) | 2025.12.31 |
|---|---|
| [capacitor] Android 소셜 로그인 적용하기 (0) | 2025.12.31 |
| [capacitor] Safe Area 적용하기 (0) | 2025.12.31 |
| [capacitor] 앱 패키징 시작하기 (1) | 2025.12.30 |
