feat: 학습센터 '앱 개발' 카테고리 12강좌 추가 (안드로이드·iOS·크로스플랫폼)

다중 에이전트로 하우스 스타일(ReactIntroPage) 그대로 12개 강좌 페이지 생성:
앱의 지형 → 안드로이드 구조 → Kotlin → iOS 구조 → Swift → React Native →
Flutter → 앱 내비게이션 → 상태·데이터 → PWA → 앱 보안 → 스토어 배포

- 프레이밍: '웹 개발자가 보는 모바일' — 학생 React 지식을 다리로(RN·PWA),
  안드로이드·iOS 둘 다 정확히 다루되 인식·literacy 수준
- 각 페이지: hero·pill-nav·6~7섹션·SVG 다이어그램·tip/warn·다음코스 링크,
  색은 전부 테마 변수(하드코딩 0), 386~489줄
- courseCatalog에 lazy import 12 + '앱 개발' 카테고리(라우팅·허브·완료바 자동)
- 확인 퀴즈 60문항(코스당 5) — quizzes.json 병합 + 운영 DB 적재
- 총 코스 71→83

검증: 빌드 통과, 하우스스타일 일괄 점검(12/12 준수), 운영 E2E —
학습센터 앱개발 카테고리 노출·React Native 렌더·PWA 퀴즈 채점(정답 서버보관)·콘솔에러0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 15:36:28 +09:00
parent 24ff3320a3
commit 4d5c6d2e2b
14 changed files with 9384 additions and 3550 deletions

View File

@ -3548,5 +3548,605 @@
"option4": "피그마 회색 박스 → React 코드 → 손그림 → 플로우",
"answerIndex": 2,
"explanation": "섹션 8의 순서대로 손그림으로 후보를 쏟아내고, 플로우로 구멍을 찾고, 피그마 회색 박스로 배치를 확정한 다음에야 React로 옮깁니다. '그리기는 분 단위, 코딩은 일 단위'니까 싼 것부터예요."
},
{
"courseSlug": "app-landscape",
"question": "네이티브 앱을 만들 때 안드로이드와 iOS의 대표 언어를 옳게 짝지은 것은?",
"option1": "안드로이드 = Kotlin, iOS = Swift",
"option2": "안드로이드 = Swift, iOS = Kotlin",
"option3": "안드로이드 = Dart, iOS = JavaScript",
"option4": "안드로이드 = HTML, iOS = Java",
"answerIndex": 0,
"explanation": "3번 섹션 — 네이티브는 안드로이드가 Kotlin, iOS가 Swift가 대표 언어다."
},
{
"courseSlug": "app-landscape",
"question": "본문이 든, React Native가 웹 React 개발자에게 특히 익숙한 이유는?",
"option1": "언어가 Dart로 완전히 바뀌기 때문",
"option2": "useState·props·JSX 같은 사고방식은 그대로이고 주로 태그 이름(<div>→<View>)만 바뀌기 때문",
"option3": "브라우저 안에서 그대로 실행되기 때문",
"option4": "앱스토어 등록이 전혀 필요 없기 때문",
"answerIndex": 1,
"explanation": "4번 섹션 — 컴포넌트·props·state·JSX는 그대로 쓰이고 <div>→<View>, <p>→<Text>처럼 태그 이름 위주로만 달라진다."
},
{
"courseSlug": "app-landscape",
"question": "PWA(Progressive Web App)에 대한 설명으로 옳은 것은?",
"option1": "Kotlin으로 작성하는 네이티브 안드로이드 앱이다",
"option2": "코드 한 벌로 안드로이드·iOS의 진짜 네이티브 위젯을 그린다",
"option3": "manifest.json과 service worker로 설치·오프라인이 되지만 앱스토어에는 정식 등록되지 않는다",
"option4": "맥(Xcode)에서만 빌드할 수 있다",
"answerIndex": 2,
"explanation": "5번 섹션 — PWA는 manifest.json과 service worker로 설치·오프라인을 지원하되 앱스토어 정식 등록은 되지 않는다."
},
{
"courseSlug": "app-landscape",
"question": "본문의 선택 기준표에서 '안드로이드·iOS 둘 다 필요하고 인력·시간은 빠듯하며 팀이 React/JS에 능숙'할 때 유력한 길은?",
"option1": "네이티브",
"option2": "크로스플랫폼(React Native)",
"option3": "iOS 전용 Swift 개발",
"option4": "순수 정적 HTML 사이트",
"answerIndex": 1,
"explanation": "6번 섹션 처방전 — 두 플랫폼 + 빠듯한 자원 + React 능숙이면 크로스플랫폼(React Native)이 유력하다."
},
{
"courseSlug": "app-landscape",
"question": "우리 회사 관점에서 앱으로 확장할 때 본문이 강조한 핵심 사실은?",
"option1": "앱으로 가려면 Spring 백엔드를 처음부터 다시 만들어야 한다",
"option2": "어느 길을 가든 백엔드(Spring API)는 그대로 재사용되고, 앱은 같은 서버에 요청하는 또 하나의 화면이다",
"option3": "앱은 HTTP가 아니라 완전히 다른 통신 방식을 쓴다",
"option4": "네이티브로만 우리 서버에 접속할 수 있다",
"answerIndex": 1,
"explanation": "7번 섹션 — 앱도 같은 Spring API에 HTTP 요청을 보내는 또 하나의 화면일 뿐이라 백엔드는 그대로 재사용된다."
},
{
"courseSlug": "android-basics",
"question": "안드로이드에서 'Activity'는 무엇을 가리키나요?",
"option1": "앱의 배경에서 조용히 도는 작업(예: 음악 재생)",
"option2": "화면 한 장(로그인 화면, 목록 화면 등)",
"option3": "앱끼리 데이터를 주고받는 공유 통로",
"option4": "배터리 부족 같은 시스템 알림을 받는 부품",
"answerIndex": 1,
"explanation": "본문 2섹션에서 Activity는 '화면 한 장'으로 정의했고, 배경 작업은 Service, 데이터 공유는 ContentProvider, 알림 수신은 BroadcastReceiver다."
},
{
"courseSlug": "android-basics",
"question": "한 화면에서 다른 화면으로 이동하며 데이터까지 넘길 때 쓰는 것은?",
"option1": "Gradle",
"option2": "Logcat",
"option3": "Intent",
"option4": "AndroidManifest",
"answerIndex": 2,
"explanation": "2섹션에서 Intent를 '어디로 갈지 + 무엇을 들고 갈지 담은 쪽지'로 설명했고 startActivity로 화면을 전환한다."
},
{
"courseSlug": "android-basics",
"question": "실행 중인 앱에서 폰을 가로로 회전하면 액티비티에 기본적으로 무슨 일이 일어나나요?",
"option1": "아무 일도 없고 변수 값이 그대로 유지된다",
"option2": "onPause만 한 번 호출되고 끝난다",
"option3": "onDestroy 후 onCreate로 액티비티가 통째로 다시 만들어진다",
"option4": "앱이 자동으로 강제 종료된다",
"answerIndex": 2,
"explanation": "3섹션 경고대로 회전 시 액티비티가 재생성되므로 변수에만 담아 둔 값은 사라진다."
},
{
"courseSlug": "android-basics",
"question": "Jetpack Compose가 우리 React와 가장 닮은 점은?",
"option1": "XML 파일에 화면 구조를 미리 적어 둔다",
"option2": "상태가 바뀌면 화면이 자동으로 다시 그려지는 선언형이다",
"option3": "findViewById로 요소를 찾아 직접 손으로 바꾼다",
"option4": "자바스크립트로만 작성해야 한다",
"answerIndex": 1,
"explanation": "4섹션에서 Compose는 '화면 = f(상태)' 선언형이라 상태 변경 시 재구성되어 React의 재렌더와 발상이 같다고 설명했다."
},
{
"courseSlug": "android-basics",
"question": "카메라 같은 '위험 권한'은 안드로이드 6.0(API 23) 이후 어떻게 얻나요?",
"option1": "AndroidManifest에 선언만 하면 설치 시 자동으로 허용된다",
"option2": "앱 실행 중에 사용자에게 팝업으로 허락을 요청해야 한다",
"option3": "구글 심사팀이 검토해 대신 켜 준다",
"option4": "위험 권한은 코드로는 요청할 수 없다",
"answerIndex": 1,
"explanation": "6섹션대로 INTERNET 같은 일반 권한은 설치 시 자동 허용되지만 카메라·위치 등 위험 권한은 실행 중 런타임 요청이 필요하다."
},
{
"courseSlug": "kotlin-intro",
"question": "Kotlin에서 한 번 값을 담으면 다시 대입할 수 없어, JS의 const와 대응되는 변수 키워드는?",
"option1": "val",
"option2": "var",
"option3": "let",
"option4": "def",
"answerIndex": 0,
"explanation": "val은 value(값 고정)로 재대입이 불가능해 JS의 const와 짝지어진다. var가 재대입 가능한 let 쪽이다."
},
{
"courseSlug": "kotlin-intro",
"question": "null일 수 있는 값 maybe에 대해 maybe?.length를 실행했는데 maybe가 null이면 결과는?",
"option1": "앱이 NPE로 크래시된다",
"option2": "그냥 null이 반환된다(크래시 없음)",
"option3": "무조건 0이 반환된다",
"option4": "컴파일 자체가 안 된다",
"answerIndex": 1,
"explanation": "?.(안전 호출)은 앞이 null이면 크래시 없이 통째로 null을 반환한다. 크래시가 나는 건 !!다."
},
{
"courseSlug": "kotlin-intro",
"question": "maybe ?: \"손님\" 처럼 쓰이는 ?:(엘비스) 연산자의 역할은?",
"option1": "왼쪽이 null이면 오른쪽 기본값으로 대체한다",
"option2": "무조건 즉시 크래시를 낸다",
"option3": "두 숫자를 더한다",
"option4": "타입을 String으로 강제 변환한다",
"answerIndex": 0,
"explanation": "엘비스 연산자 ?:는 왼쪽 값이 null일 때 오른쪽 기본값으로 대체해 준다."
},
{
"courseSlug": "kotlin-intro",
"question": "일반 클래스 앞에 data를 붙여 data class로 만들 때, 본문 기준 '공짜로 따라오는 것'이 아닌 것은?",
"option1": "equals(값 비교)",
"option2": "toString(보기 좋은 출력)",
"option3": "copy(하나만 바꿔 복제)",
"option4": "서버 API 자동 호출",
"answerIndex": 3,
"explanation": "data class는 equals·toString·copy를 자동 생성해 주지만 네트워크 호출 같은 기능은 제공하지 않는다."
},
{
"courseSlug": "kotlin-intro",
"question": "본문에서 Kotlin의 when이 JS의 switch보다 나은 점으로 든 것은?",
"option1": "각 갈래마다 break를 반드시 붙여야 한다",
"option2": "break가 필요 없고, 값을 돌려주는 식(expression)으로도 쓸 수 있다",
"option3": "숫자만 비교할 수 있다",
"option4": "else 갈래를 쓸 수 없다",
"answerIndex": 1,
"explanation": "when은 break 없이 맞는 갈래 하나만 실행하고, 결과를 바로 변수에 담는 식으로도 쓸 수 있다."
},
{
"courseSlug": "ios-basics",
"question": "iOS를 '담장 친 정원(walled garden)'이라 부르는 이유로 본문 설명에 가장 맞는 것은?",
"option1": "애플이 기기·OS·앱 스토어·칩까지 한 회사가 직접 만들어 통제하기 때문",
"option2": "앱을 무료로만 배포할 수 있기 때문",
"option3": "안드로이드보다 기기 종류가 훨씬 많기 때문",
"option4": "개발에 윈도우 PC만 쓸 수 있기 때문",
"answerIndex": 0,
"explanation": "애플은 기기·OS·스토어·칩을 수직 통합해 규칙을 통제하므로 '담장 친 정원'이라 부른다."
},
{
"courseSlug": "ios-basics",
"question": "React의 useState와 가장 비슷한 사고방식을 가진 iOS 기술은?",
"option1": "UIKit에서 라벨을 직접 찾아 글자를 바꾸는 명령형 코드",
"option2": "SwiftUI의 @State — 상태만 바꾸면 화면이 자동으로 다시 그려짐",
"option3": "Info.plist의 권한 이유 문구",
"option4": "Xcode의 시뮬레이터",
"answerIndex": 1,
"explanation": "SwiftUI의 @State는 상태를 바꾸면 body가 재렌더되어 '화면=상태의 함수'인 useState와 판박이다."
},
{
"courseSlug": "ios-basics",
"question": "본문은 iOS의 '뷰 계층(view hierarchy)'을 무엇과 같은 뼈대라고 설명했는가?",
"option1": "데이터베이스 테이블",
"option2": "브라우저의 DOM 트리이자 React의 컴포넌트 트리",
"option3": "네트워크 패킷의 흐름",
"option4": "파일 시스템의 접근 권한",
"answerIndex": 1,
"explanation": "화면 위 모든 뷰가 부모-자식 트리로 쌓이는 구조는 DOM 트리·컴포넌트 트리와 같은 뼈대라고 설명했다."
},
{
"courseSlug": "ios-basics",
"question": "iOS 개발 환경에 대한 본문 설명과 일치하는 것은?",
"option1": "Xcode는 윈도우·맥·리눅스 모두에서 실행된다",
"option2": "Xcode는 macOS에서만 실행되어 개발용 맥이 사실상 필수다",
"option3": "시뮬레이터로 카메라 하드웨어까지 완벽히 테스트된다",
"option4": "App Store 정식 출시는 완전 무료다",
"answerIndex": 1,
"explanation": "Xcode가 macOS 전용이라 개발용 맥이 사실상 필수이며, 윈도우만으로는 공식 빌드가 안 된다고 했다."
},
{
"courseSlug": "ios-basics",
"question": "iOS에서 카메라 권한을 쓰는데 Info.plist에 이유 문구(NSCameraUsageDescription)를 적지 않으면?",
"option1": "시스템이 자동으로 권한을 허용한다",
"option2": "심사에서 경고만 받고 정상 동작한다",
"option3": "앱이 그 자리에서 죽는다(크래시)",
"option4": "안드로이드에서만 문제가 된다",
"answerIndex": 2,
"explanation": "iOS는 이유 문구가 없으면 해당 자원을 쓰는 순간 앱이 그대로 크래시한다고 본문이 강조했다."
},
{
"courseSlug": "swift-intro",
"question": "Swift에서 한 번 정하면 다시 값을 넣을 수 없는(상수) 키워드는 무엇인가요?",
"option1": "let",
"option2": "var",
"option3": "const",
"option4": "val",
"answerIndex": 0,
"explanation": "let은 상수라 재할당이 불가하고 var가 바뀔 수 있는 변수입니다(const·val은 Swift 키워드가 아님)."
},
{
"courseSlug": "swift-intro",
"question": "옵셔널 타입 String? 이 뜻하는 것으로 가장 정확한 것은?",
"option1": "언제나 문자열이 들어 있다",
"option2": "String 이거나 nil 일 수 있다",
"option3": "문자열들의 배열이다",
"option4": "자동으로 숫자로 바뀐다",
"answerIndex": 1,
"explanation": "타입 뒤 물음표(?)는 '값이 있거나 nil일 수 있음'을 타입에 표시한 옵셔널입니다."
},
{
"courseSlug": "swift-intro",
"question": "옵셔널이 nil일 때 대신 쓸 기본값을 지정하는, JS에도 있는 그 연산자는?",
"option1": "?. (옵셔널 체이닝)",
"option2": "! (강제 언래핑)",
"option3": "?? (nil 병합)",
"option4": "-> (반환 타입)",
"answerIndex": 2,
"explanation": "??는 nil 병합 연산자로 값이 없으면 오른쪽 기본값을 씁니다. !는 위험한 강제 언래핑, ?.는 안전한 접근입니다."
},
{
"courseSlug": "swift-intro",
"question": "struct(값 타입)에서 var s2 = s1 로 복사한 뒤 s2.x를 99로 바꾸면 s1.x는 어떻게 되나요?",
"option1": "함께 99로 바뀐다",
"option2": "그대로 1이다(복사되어 서로 남남)",
"option3": "컴파일 에러가 난다",
"option4": "nil이 된다",
"answerIndex": 1,
"explanation": "struct는 대입할 때 복사본이 생겨 s1과 s2가 독립적이므로 s1.x는 원래 값 그대로입니다(공유되는 class와 반대)."
},
{
"courseSlug": "swift-intro",
"question": "Swift의 protocol은 TypeScript나 Java의 무엇과 같은 역할을 하나요?",
"option1": "class",
"option2": "struct",
"option3": "interface",
"option4": "enum",
"answerIndex": 2,
"explanation": "프로토콜은 '갖춰야 할 프로퍼티·기능'을 정하는 계약으로, interface와 같은 자리에 대응합니다."
},
{
"courseSlug": "react-native",
"question": "React Native가 웹뷰(WebView) 방식과 근본적으로 다른 점은 무엇인가요?",
"option1": "자바스크립트 코드가 iOS·안드로이드의 진짜 네이티브 UI 위젯으로 렌더링된다",
"option2": "웹사이트를 앱 아이콘으로 감싸 앱 속 브라우저 안에서 보여 준다",
"option3": "HTML 파일을 그대로 폰 화면에 띄운다",
"option4": "앱 안에 작은 크롬 브라우저를 넣어 웹페이지를 연다",
"answerIndex": 0,
"explanation": "RN은 웹을 포장하는 WebView가 아니라, JS로 쓴 <Text>가 iOS의 UILabel, 안드로이드의 TextView 같은 진짜 네이티브 위젯으로 그려진다(섹션 1)."
},
{
"courseSlug": "react-native",
"question": "웹의 <div>에 대응하는 React Native 컴포넌트는 무엇인가요?",
"option1": "<Text>",
"option2": "<View>",
"option3": "<Image>",
"option4": "<Box>",
"answerIndex": 1,
"explanation": "대응표(섹션 3)에서 레이아웃 상자인 <div>는 RN의 <View>에 대응한다. <Text>는 <span>/<p> 글자에, <Image>는 <img>에 대응한다."
},
{
"courseSlug": "react-native",
"question": "React Native에서 화면에 글자를 표시할 때 반드시 지켜야 하는 규칙은?",
"option1": "모든 글자는 반드시 <Text> 컴포넌트 안에 넣어야 한다",
"option2": "모든 글자는 <View> 안에 맨몸으로 직접 써야 한다",
"option3": "글자는 className 속성으로만 표시한다",
"option4": "글자는 웹처럼 <p> 태그로 감싸야 한다",
"answerIndex": 0,
"explanation": "RN에서는 글자를 그리는 부품과 레이아웃 상자가 다르기 때문에 모든 글자는 <Text> 안에 있어야 하며, <View>에 맨몸으로 넣으면 에러가 난다(섹션 3)."
},
{
"courseSlug": "react-native",
"question": "React Native의 StyleSheet가 웹 CSS와 다른 점으로 옳은 것은?",
"option1": "CSS처럼 부모의 스타일이 자식에게 그대로 상속(캐스케이드)된다",
"option2": "크기 값에 반드시 px 단위를 붙여야 한다",
"option3": "flexDirection의 기본값이 웹(row)과 달리 column이다",
"option4": "속성 이름을 background-color처럼 하이픈으로 쓴다",
"answerIndex": 2,
"explanation": "RN은 flexDirection 기본값이 column이다. 반대로 속성은 camelCase(backgroundColor), 숫자엔 단위 없음, 캐스케이드 없음이라 나머지 보기는 틀리다(섹션 4)."
},
{
"courseSlug": "react-native",
"question": "Expo Go 앱의 역할로 맞는 것은?",
"option1": "앱을 애플·구글 스토어에 자동으로 심사·출시해 준다",
"option2": "자바 코드를 자바스크립트로 번역해 준다",
"option3": "Spring Boot 서버를 대신 실행해 준다",
"option4": "터미널에 뜬 QR코드를 스캔하면 방금 만든 앱을 내 진짜 폰에서 바로 실행해 준다",
"answerIndex": 3,
"explanation": "Expo Go는 QR코드를 스캔해 개발 중인 앱을 실제 폰에서 즉시 실행해 주는 앱이다. 스토어 출시나 코드 번역, 서버 실행과는 무관하다(섹션 5)."
},
{
"courseSlug": "flutter",
"question": "Flutter가 React Native와 구별되는 가장 결정적인 차이는 무엇인가?",
"option1": "OS가 제공하는 순정 네이티브 부품을 빌려다 화면을 배치한다",
"option2": "자체 렌더링 엔진이 버튼 모양까지 픽셀 단위로 직접 그린다",
"option3": "자바스크립트로 UI를 작성한다",
"option4": "서버 없이도 데이터를 저장할 수 있다",
"answerIndex": 1,
"explanation": "본문 섹션 1·2의 핵심 — RN은 OS 부품을 빌리지만 Flutter는 자체 엔진(Impeller/Skia)이 화면을 직접 그린다."
},
{
"courseSlug": "flutter",
"question": "Dart의 널 안전성(null safety)에 대한 설명으로 옳은 것은?",
"option1": "모든 변수가 기본적으로 null이 될 수 있다",
"option2": "타입에 ?를 붙인 값만 null이 될 수 있다",
"option3": "final로 선언한 값만 null이 될 수 있다",
"option4": "Dart에는 null이라는 개념이 아예 없다",
"answerIndex": 1,
"explanation": "섹션 3에서 설명하듯 ?를 붙인 타입만 null 허용, 나머지는 컴파일러가 null이 올 수 없음을 보증한다."
},
{
"courseSlug": "flutter",
"question": "Flutter에서 '모든 게 위젯'이라는 말이 뜻하는 것으로 가장 알맞은 것은?",
"option1": "버튼·글자뿐 아니라 여백·정렬 같은 레이아웃까지 전부 위젯이다",
"option2": "위젯은 오직 버튼만 가리키는 용어다",
"option3": "위젯은 HTML 태그를 부르는 다른 이름일 뿐이다",
"option4": "위젯은 서버에서만 만들 수 있다",
"answerIndex": 0,
"explanation": "섹션 4의 핵심 — Flutter는 버튼·글자는 물론 SizedBox 같은 여백·레이아웃까지 모두 위젯으로 다룬다."
},
{
"courseSlug": "flutter",
"question": "StatefulWidget에서 count 값을 바꿨는데 화면이 다시 그려지게 하려면?",
"option1": "count 값을 직접 바꾸기만 하면 자동으로 갱신된다",
"option2": "변경을 setState()로 감싸야 build()가 다시 불린다",
"option3": "앱을 껐다 켜야 반영된다",
"option4": "StatelessWidget으로 바꿔야 한다",
"answerIndex": 1,
"explanation": "섹션 5 — React의 setCount처럼, 상태 변경을 setState()로 감싸야 Flutter가 build()를 다시 호출한다."
},
{
"courseSlug": "flutter",
"question": "우리 상황에서 앱을 React Native 대신 Flutter로 만든다면 백엔드는 어떻게 되나?",
"option1": "Dart로 서버를 새로 작성해야 한다",
"option2": "기존 Spring API를 그대로 쓰고, 화면을 그리는 도구만 바뀐다",
"option3": "서버도 Flutter로 다시 만들어야 한다",
"option4": "서버가 아예 필요 없어진다",
"answerIndex": 1,
"explanation": "섹션 7 — 앱 도구 선택은 '화면을 무엇으로 그릴까'의 문제일 뿐, 데이터는 여전히 우리 Spring API에 요청한다."
},
{
"courseSlug": "app-navigation",
"question": "앱 내비게이션에서 '지금 화면에 보이는 것'은 무엇으로 결정될까요?",
"option1": "스택(카드 더미)에 쌓인 것 중 맨 위 한 장",
"option2": "브라우저 주소창(URL)에 적힌 경로",
"option3": "드로어에 숨겨진 첫 번째 메뉴 항목",
"option4": "데이터베이스에 저장된 마지막 로그",
"answerIndex": 0,
"explanation": "앱엔 주소창이 없고, 화면을 push로 쌓는 스택의 맨 위 카드가 곧 지금 화면이라고 본문에서 설명했다."
},
{
"courseSlug": "app-navigation",
"question": "'바깥은 탭, 각 탭 안은 스택'인 앱에서 검색 탭으로 상세에 들어갔다가 홈 탭에 갔다 다시 검색 탭으로 오면?",
"option1": "항상 앱이 종료된다",
"option2": "아까 보던 상세 화면이 그대로 유지된다",
"option3": "검색 탭이 홈 화면으로 바뀐다",
"option4": "모든 탭의 스택이 초기화된다",
"answerIndex": 1,
"explanation": "각 탭이 자기 스택을 따로 기억하므로, 탭을 오가도 이전에 보던 상세 화면이 보존된다고 본문에서 설명했다."
},
{
"courseSlug": "app-navigation",
"question": "iOS에서 이전 화면으로 뒤로 가는 대표적인 방법은?",
"option1": "화면 왼쪽 가장자리에서 오른쪽으로 쓸어넘기거나 좌상단 '뒤로'를 누른다",
"option2": "화면 아래 하드웨어 뒤로가기 버튼을 누른다",
"option3": "볼륨 버튼을 두 번 누른다",
"option4": "앱을 강제 종료했다가 다시 켠다",
"answerIndex": 0,
"explanation": "iOS는 하드웨어 뒤로가기 버튼이 없어 swipe-back 제스처나 좌상단 뒤로 버튼을 쓴다고 본문에서 설명했다."
},
{
"courseSlug": "app-navigation",
"question": "화면 A에서 B로 데이터를 넘길 때 본문이 권장한 방식은?",
"option1": "큰 데이터 객체를 통째로 params에 실어 넘긴다",
"option2": "id 같은 작은 열쇠만 넘기고 상세 데이터는 그 화면이 다시 조회한다",
"option3": "전역 변수에 넣고 아무 값도 넘기지 않는다",
"option4": "비밀번호까지 URL에 담아 넘긴다",
"answerIndex": 1,
"explanation": "params엔 id 같은 작은 값만 담고 큰 데이터는 도착 화면이 id로 재조회해야 뒤로가기·딥링크 진입에도 깨지지 않는다고 본문에서 강조했다."
},
{
"courseSlug": "app-navigation",
"question": "React Native에서 사실상 표준으로 쓰이는 내비게이션 라이브러리는?",
"option1": "React Navigation",
"option2": "react-router-dom",
"option3": "go_router",
"option4": "Redux",
"answerIndex": 0,
"explanation": "본문에 따르면 RN은 React Navigation이 표준이며, react-router-dom은 웹, go_router는 Flutter 쪽이다."
},
{
"courseSlug": "app-state-data",
"question": "다크모드 on/off 설정처럼 '앱을 껐다 켜도 이 기기에 남아야 하지만, 다른 기기와 공유할 필요는 없는' 값은 데이터의 어느 층에 두어야 할까요?",
"option1": "화면 상태 (useState)",
"option2": "기기 로컬 저장 (AsyncStorage)",
"option3": "서버 (Spring + PostgreSQL)",
"option4": "어느 층에도 저장하지 않는다",
"answerIndex": 1,
"explanation": "앱 재시작에도 남아야 하지만(→화면 상태 아님) 다른 기기와 공유할 필요는 없으므로(→서버 아님) 가운데 층인 기기 로컬 저장이 맞다."
},
{
"courseSlug": "app-state-data",
"question": "검색창에 입력 중인 글자나 지금 열린 탭처럼 '화면을 나가면 사라져도 되는' 값은 앱에서 무엇으로 관리하나요?",
"option1": "AsyncStorage",
"option2": "SQLite",
"option3": "useState",
"option4": "iOS Keychain",
"answerIndex": 2,
"explanation": "화면 상태는 웹 React와 100% 동일하게 useState로 관리하며, 앱이라고 특별한 도구가 필요하지 않다."
},
{
"courseSlug": "app-state-data",
"question": "로그인 토큰을 AsyncStorage에 그대로 저장하면 안 되는 이유와 올바른 대안은?",
"option1": "용량이 작아서 — 대신 SQLite에 저장한다",
"option2": "암호화되지 않은 평문이라 유출 위험 — iOS Keychain / Android Keystore 같은 보안 저장소에 저장한다",
"option3": "속도가 느려서 — 대신 서버에만 저장한다",
"option4": "문제없다 — 토큰은 어디에 저장해도 안전하다",
"answerIndex": 1,
"explanation": "AsyncStorage와 SQLite는 기본적으로 평문이라, 토큰 같은 민감정보는 OS 보안 저장소(Keychain/Keystore)에 넣어야 한다."
},
{
"courseSlug": "app-state-data",
"question": "우리 웹 프론트는 쿠키(withCredentials)로 로그인을 유지합니다. 앱(React Native)에서 서버 API를 호출할 때 로그인을 유지하는 흔한 방식은?",
"option1": "쿠키가 자동으로 붙으므로 아무것도 하지 않아도 된다",
"option2": "토큰을 Authorization 헤더에 직접 실어 보낸다",
"option3": "매 요청마다 비밀번호를 body에 담아 보낸다",
"option4": "서버가 접속 IP로 사용자를 알아본다",
"answerIndex": 1,
"explanation": "앱은 브라우저가 아니라 쿠키 자동첨부가 없으므로, 보안 저장소에 둔 토큰을 요청마다 Authorization 헤더에 직접 넣는다."
},
{
"courseSlug": "app-state-data",
"question": "오프라인에서 '좋아요'를 눌렀을 때, 서버 응답을 기다리지 않고 화면부터 먼저 바꾼 뒤 요청은 대기 큐에 쌓아 재연결 시 보내는 방식을 무엇이라 하나요?",
"option1": "비관적 UI",
"option2": "낙관적 UI (optimistic update)",
"option3": "강제 새로고침",
"option4": "서버 사이드 렌더링",
"answerIndex": 1,
"explanation": "응답을 기다리지 않고 '어차피 성공하겠지' 하고 화면부터 바꾸는 것이 낙관적 UI이며, 실패하면 되돌리고 요청은 큐에 쌓아 재연결 시 동기화한다."
},
{
"courseSlug": "pwa",
"question": "PWA를 '설치되는 앱'으로 만들어 주는 세 가지 핵심 재료가 아닌 것은?",
"option1": "HTTPS(안전한 연결)",
"option2": "웹 앱 매니페스트",
"option3": "서비스워커",
"option4": "앱스토어 심사 통과",
"answerIndex": 3,
"explanation": "본문에서 재료는 HTTPS·매니페스트·서비스워커 셋이며, PWA는 스토어 심사 없이 배포하는 게 장점이다."
},
{
"courseSlug": "pwa",
"question": "매니페스트의 display를 \"standalone\"으로 설정하면 어떤 일이 일어나는가?",
"option1": "주소창·탭 없이 전체화면으로 앱처럼 열린다",
"option2": "오프라인에서 무조건 동작한다",
"option3": "푸시 알림이 자동으로 켜진다",
"option4": "앱스토어에 자동 등록된다",
"answerIndex": 0,
"explanation": "섹션 3에서 display: standalone은 주소창을 숨기고 전체화면으로 열어 겉보기엔 네이티브 앱처럼 보이게 한다고 설명했다."
},
{
"courseSlug": "pwa",
"question": "서비스워커 덕분에 인터넷이 끊겨도 화면이 뜰 수 있는 이유는?",
"option1": "서버가 항상 켜져 있어서",
"option2": "요청을 가로채 캐시에 있으면 캐시로 응답해서",
"option3": "매니페스트가 데이터를 저장해서",
"option4": "브라우저가 HTML을 통째로 외워서",
"answerIndex": 1,
"explanation": "섹션 4에서 서비스워커는 요청을 가로채 캐시에 있으면 캐시(hit)로 응답하므로, 네트워크가 없어도 화면을 그려 낸다."
},
{
"courseSlug": "pwa",
"question": "웹 푸시 알림에 대한 설명으로 옳은 것은?",
"option1": "사용자 허락 없이도 서버가 보낼 수 있다",
"option2": "사용자가 권한을 거절하면 알림을 보낼 방법이 없다",
"option3": "서비스워커 없이 앱이 꺼진 상태에서 알림을 띄운다",
"option4": "매니페스트만 있으면 곧바로 발송된다",
"answerIndex": 1,
"explanation": "섹션 5에서 requestPermission은 '물어보는' 것이라, 사용자가 거절하면 알림을 보낼 방법이 아예 없다고 강조했다."
},
{
"courseSlug": "pwa",
"question": "iOS(아이폰)에서 PWA 웹 푸시에 대한 설명으로 옳은 것은?",
"option1": "사파리 탭으로 열어 두기만 해도 푸시가 온다",
"option2": "iOS는 웹 푸시를 전혀 지원하지 않는다",
"option3": "iOS 16.4부터, 홈 화면에 설치된 PWA에서만 푸시가 동작한다",
"option4": "웹 기능은 iOS가 안드로이드보다 더 많이 열려 있다",
"answerIndex": 2,
"explanation": "섹션 6에서 iOS는 16.4(2023)부터 웹 푸시를 지원하되 홈 화면에 설치된 PWA에서만 작동한다고 정확히 다뤘다."
},
{
"courseSlug": "app-security",
"question": "이 강좌가 말하는, 앱 보안이 웹 보안과 근본적으로 다른 한 가지 이유는?",
"option1": "앱은 접속할 때마다 서버가 화면 코드를 새로 내려 준다",
"option2": "앱 파일(APK/IPA)이 사용자·공격자의 기기에 설치돼 남아 뜯어볼 수 있다",
"option3": "앱은 HTTPS를 사용할 수 없다",
"option4": "앱은 아무 권한도 필요로 하지 않는다",
"answerIndex": 1,
"explanation": "코드가 기기에 설치돼 남아 디컴파일·변조가 가능하므로 '클라이언트를 믿지 마라'가 제1원칙이 된다."
},
{
"courseSlug": "app-security",
"question": "앱이 카메라·위치 권한을 다룰 때 지켜야 할, 방화벽 코스의 '필요한 포트만 연다'와 같은 원칙은?",
"option1": "최대 권한 — 미리 다 받아 둔다",
"option2": "권한은 설치 때 한 번에 몰아서 받는다",
"option3": "최소 권한 — 기능이 실제로 필요로 하는 것만 요청한다",
"option4": "권한은 서버가 대신 받아 준다",
"answerIndex": 2,
"explanation": "AWS 보안그룹의 '필요한 포트만 열기'와 같은 최소 권한이 사용자 신뢰와 스토어 심사에서 유리하다."
},
{
"courseSlug": "app-security",
"question": "앱에서 로그인 토큰을 저장하는 안전한 방법은?",
"option1": "SharedPreferences/UserDefaults에 평문으로 저장한다",
"option2": "앱 코드에 상수로 하드코딩한다",
"option3": "iOS Keychain / 안드로이드 Keystore 같은 OS 금고에 저장한다",
"option4": "디버그 로그 파일에 남겨 둔다",
"answerIndex": 2,
"explanation": "하드웨어로 보호되는 Keychain/Keystore에 맡겨야 하며, 평문 저장이나 하드코딩은 그대로 읽혀 금지된다."
},
{
"courseSlug": "app-security",
"question": "일반 HTTPS를 넘어, 가짜 CA를 이용한 중간자(MITM) 공격까지 막으려고 '우리 서버의 바로 그 인증서'만 믿도록 앱에 지문을 새기는 기법은?",
"option1": "인증서 고정(Certificate Pinning)",
"option2": "평문 전송(cleartext)",
"option3": "포트포워딩",
"option4": "캐싱",
"answerIndex": 0,
"explanation": "인증서 고정은 정해진 인증서만 신뢰해 MITM까지 막지만, 서버 인증서 갱신 시 앱도 함께 업데이트해야 한다."
},
{
"courseSlug": "app-security",
"question": "\"관리자 메뉴를 앱 화면에서 숨겼으니 안전하다\"는 생각이 위험한 이유로 이 강좌가 강조한 원칙은?",
"option1": "앱은 절대 뚫리지 않는다",
"option2": "화면 숨김은 UX일 뿐이고 진짜 권한 검사는 서버가 다시 해야 한다",
"option3": "스토어 심사가 모든 위협을 막아 준다",
"option4": "권한을 많이 받을수록 안전하다",
"answerIndex": 1,
"explanation": "설치된 앱은 뜯어볼 수 있어 숨긴 화면도 드러나므로, 진짜 검사는 서버가 담당해야 한다(App.jsx 주석과 동일)."
},
{
"courseSlug": "app-store-deploy",
"question": "배포 전 준비물 중, 앱이 사용자 데이터를 조금이라도 수집한다면 스토어에 반드시 등록해야 하는 것은?",
"option1": "공개 URL 형태의 개인정보 처리방침",
"option2": "512×512 앱 아이콘 한 장이면 충분",
"option3": "유료 개발자 등급 승급",
"option4": "별도의 서버 TLS 인증서",
"answerIndex": 0,
"explanation": "로그인·광고ID·로그 등 데이터를 조금이라도 모으면 공개 URL로 올린 개인정보 처리방침을 스토어에 등록해야 한다."
},
{
"courseSlug": "app-store-deploy",
"question": "안드로이드 keystore(서명 도장) 파일과 비밀번호를 잃어버리면 어떤 일이 생기나?",
"option1": "앱 아이콘 색이 초기화된다",
"option2": "같은 앱을 더 이상 업데이트할 수 없다",
"option3": "심사가 자동으로 빨라진다",
"option4": "앱이 무료로 전환된다",
"answerIndex": 1,
"explanation": "업데이트는 '같은 도장'으로 서명해야 기존 앱을 덮어쓸 수 있어, keystore를 잃으면 업데이트가 불가능해진다(그래서 Play App Signing으로 대비)."
},
{
"courseSlug": "app-store-deploy",
"question": "2021년 8월부터 Google Play에 올리는 '새 앱'이 사용해야 하는 업로드 형식은?",
"option1": ".apk 한 덩어리",
"option2": "IPA 패키지",
"option3": "AAB(Android App Bundle)",
"option4": "실행 가능한 exe",
"answerIndex": 2,
"explanation": "새 앱은 AAB로 올리고, 구글이 각 기기에 맞는 최적 APK로 재조립해 배달하므로 다운로드 용량이 줄어든다."
},
{
"courseSlug": "app-store-deploy",
"question": "App Store 심사가 Google Play보다 대체로 더 엄격하고 오래 걸리는 핵심 이유는?",
"option1": "안드로이드보다 개발자 비용이 싸서",
"option2": "리뷰어가 직접 심사하고, 앱 설치가 App Store로만 이뤄지는 닫힌 생태계라서",
"option3": "서명 과정을 생략하기 때문에",
"option4": "스크린샷을 요구하지 않기 때문에",
"answerIndex": 1,
"explanation": "애플은 사람이 직접 앱을 실행해 심사하며, 설치 경로가 App Store 하나뿐인 닫힌 생태계라 문지기 역할이 강하다."
},
{
"courseSlug": "app-store-deploy",
"question": "우리 웹 배포(Caddy·Docker)와 앱 스토어 배포의 '되돌리기(롤백)' 차이로 옳은 것은?",
"option1": "웹은 이전 Docker 이미지로 즉시 롤백되지만, 앱은 대개 고친 새 버전을 다시 올려 심사받아야 한다",
"option2": "둘 다 몇 초 만에 즉시 롤백된다",
"option3": "앱이 웹보다 롤백이 더 빠르다",
"option4": "웹은 원래 롤백이 불가능하다",
"answerIndex": 0,
"explanation": "웹은 이전 이미지로 즉시 되돌릴 수 있지만, 스토어는 즉시 롤백이 어려워 새 버전을 다시 올려 심사를 받아야 하므로 출시 전 테스트가 더 중요하다."
}
]

View File

@ -82,6 +82,20 @@ const ToolCapturePage = lazy(() => import('./pages/courses/ToolCapturePage'));
const ToolNotionPage = lazy(() => import('./pages/courses/ToolNotionPage'));
const ToolShortcutsPage = lazy(() => import('./pages/courses/ToolShortcutsPage'));
// (·iOS·)
const AppLandscapePage = lazy(() => import('./pages/courses/AppLandscapePage'));
const AndroidBasicsPage = lazy(() => import('./pages/courses/AndroidBasicsPage'));
const KotlinIntroPage = lazy(() => import('./pages/courses/KotlinIntroPage'));
const IosBasicsPage = lazy(() => import('./pages/courses/IosBasicsPage'));
const SwiftIntroPage = lazy(() => import('./pages/courses/SwiftIntroPage'));
const ReactNativePage = lazy(() => import('./pages/courses/ReactNativePage'));
const FlutterPage = lazy(() => import('./pages/courses/FlutterPage'));
const AppNavigationPage = lazy(() => import('./pages/courses/AppNavigationPage'));
const AppStateDataPage = lazy(() => import('./pages/courses/AppStateDataPage'));
const PwaPage = lazy(() => import('./pages/courses/PwaPage'));
const AppSecurityPage = lazy(() => import('./pages/courses/AppSecurityPage'));
const AppStoreDeployPage = lazy(() => import('./pages/courses/AppStoreDeployPage'));
// . slug = /learn/<slug> . = ( ).
export const COURSE_CATEGORIES = [
{
@ -192,6 +206,24 @@ export const COURSE_CATEGORIES = [
{ slug: 'tool-notion', icon: '🗒️', title: '노션으로 기록하기', desc: 'TIL·트러블슈팅 — 검색되는 기록이 자산.', level: '실습', Component: ToolNotionPage },
],
},
{
category: '앱 개발',
desc: '웹 개발자가 보는 모바일 — 안드로이드·iOS부터 우리 React로 앱까지.',
items: [
{ slug: 'app-landscape', icon: '📱', title: '앱의 지형', desc: '네이티브·크로스플랫폼·웹 — 뭘 언제 고르나.', level: '교양', Component: AppLandscapePage },
{ slug: 'android-basics', icon: '🤖', title: '안드로이드 앱의 구조', desc: '액티비티·생명주기·스튜디오 — 앱이 도는 원리.', level: '입문', Component: AndroidBasicsPage },
{ slug: 'kotlin-intro', icon: '🟣', title: 'Kotlin 입문', desc: '안드로이드의 언어 — JS/Java 관점으로 빠르게.', level: '입문', Component: KotlinIntroPage },
{ slug: 'ios-basics', icon: '🍎', title: 'iOS 앱의 구조', desc: '뷰·생명주기·Xcode — 애플 생태계의 규칙.', level: '입문', Component: IosBasicsPage },
{ slug: 'swift-intro', icon: '🐦', title: 'Swift 입문', desc: 'iOS의 언어 — 옵셔널·구조체 핵심만.', level: '입문', Component: SwiftIntroPage },
{ slug: 'react-native', icon: '⚛️', title: 'React Native', desc: '우리 React 지식이 그대로 앱이 되는 다리.', level: '심화', Component: ReactNativePage },
{ slug: 'flutter', icon: '🦋', title: 'Flutter', desc: 'Dart·위젯 — 또 하나의 크로스플랫폼.', level: '심화', Component: FlutterPage },
{ slug: 'app-navigation', icon: '🧭', title: '앱 화면과 이동', desc: '내비게이션·화면 전환 — 웹 라우팅과 비교.', level: '입문', Component: AppNavigationPage },
{ slug: 'app-state-data', icon: '🗃️', title: '앱의 상태와 데이터', desc: '상태관리·로컬 저장·우리 Spring API 연동.', level: '심화', Component: AppStateDataPage },
{ slug: 'pwa', icon: '🌐', title: 'PWA — 웹앱을 앱처럼', desc: '설치·오프라인·푸시 — 우리 웹앱에 가장 가까운 길.', level: '심화', Component: PwaPage },
{ slug: 'app-security', icon: '🔐', title: '앱 보안과 권한', desc: '권한 모델·저장소 보안·심사 기준.', level: '입문', Component: AppSecurityPage },
{ slug: 'app-store-deploy', icon: '🚀', title: '스토어 배포', desc: 'Play·App Store 등록과 심사 흐름.', level: '실습', Component: AppStoreDeployPage },
],
},
];
// : slug .

View File

@ -0,0 +1,443 @@
// : " "
// , React/JS 7
// . · Activity·Intent··
// (XML vs Compose)·/·/Manifest, "
// " .
// : API ' ' . global.css
// (step-card, code-block, tip, warn ) .
// JSX / .
// : "2 " " " .
import { Link } from 'react-router-dom';
//
const CODE_STACK = `안드로이드는 '층층이 쌓은 케이크'예요 (오픈소스 프로젝트 AOSP)
앱들 ( , 카카오톡, 크롬 ...) 우리가 만드는
프레임워크 (Activity, 알림, 화면 ...) 미리 준비된 부품 상자
런타임(ART) + 시스템 라이브러리 코드가 실제로 도는
리눅스 커널 (드라이버, 메모리, 전원 ...) 하드웨어와의 통역사
- 아래 바탕이 '리눅스'예요. (리눅스 기초 코스에서 바로 커널!)
- 소스가 공개(AOSP) 삼성·샤오미·구글이 각자 고쳐 씁니다
화면 크기·OS 버전·제조사가 천차만별. "기기 파편화" 불러요.
- 언어는 Kotlin(구글이 미는 1순위) 또는 Java.
우리 백엔드가 Java라, 중괄호·세미콜론·클래스 문법이 이미 눈에 익습니다.`;
const CODE_ACTIVITY = `// Activity = 화면 '한 장'. 앱은 액티비티 여러 장을 넘겨 보는 묶음이에요.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) //
}
}
// Intent = ' ' ( + )
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("courseId", 42) //
startActivity(intent) // !
// <Link to="/course/42"> + .
// Activity '' Intent " " .`;
const CODE_LIFECYCLE = `액티비티 생명주기 ↔ 웹 페이지, 나란히 놓고 보기
안드로이드 (Activity) (브라우저 /React 컴포넌트)
onCreate() 최초 생성 · 1 컴포넌트 mount / DOMContentLoaded
onStart() 화면에 보이기 시작 (탭이 보이기 시작)
onResume() 포커스 · 입력 받음 visibilitychange visible
사용자와 상호작용 사용자와 상호작용
onPause() 가려짐 · 저장 시점 visibilitychange hidden
onStop() 완전히 보임 (다른 탭으로 전환)
onDestroy() 메모리에서 제거 컴포넌트 unmount / beforeunload
핵심 차이: 웹은 대개 '열고 닫기' 단순하지만, 안드로이드는 전화가 오고,
화면을 돌리고, 홈으로 나갔다 돌아오는 일이 잦아 onPauseonResume이 수시로 오갑니다.
그래서 "onPause에서 저장하고 onResume에서 복구" 앱의 기본 예절이에요.`;
const CODE_XML = `<!-- XML 레이아웃 — res/layout/activity_main.xml (명령형에 가까운 옛 방식) -->
<Button
android:id="@+id/likeBtn"
android:text="좋아요 0" />
// '' (React !)
val btn = findViewById<Button>(R.id.likeBtn)
var count = 0
btn.setOnClickListener {
count++
btn.text = "좋아요 " + count //
}`;
const CODE_COMPOSE = `// Jetpack Compose — 선언형 UI. 우리 React와 발상이 판박이예요!
@Composable
fun LikeButton() {
var count by remember { mutableStateOf(0) } // React useState
Button(onClick = { count++ }) { //
Text("좋아요 " + count) //
}
}
// React: = f() setCount(1)
// Compose: = f() count '(recomposition)'
// XML " "(), Compose " "().`;
const CODE_STUDIO = `안드로이드 스튜디오 = 앱 개발용 '통합 작업실' (IntelliJ 기반)
에디터 코드 작성·자동완성 (IntelliJ 코스에서 만진 IDE 계열!)
Gradle 빌드 도구 소스 코드를 설치 파일(APK / AAB) 조립
에뮬레이터(AVD) PC 안에 뜨는 '가짜 안드로이드 폰'
Logcat 앱이 뱉는 로그 (웹의 F12 콘솔에 해당)
Preview 화면을 실행 없이 눈으로 미리 확인
# 개발 바퀴
1) 에뮬레이터(가상 ) 실행 또는 실제 USB 연결 + 개발자 옵션 켜기
2) Run Gradle 빌드 /에뮬에 설치 실행
3) Logcat으로 로그 보며 고치고 다시 Run (웹의 '저장 → 새로고침' 닮은 리듬)
# adb 폰과 대화하는 명령줄 도구 (터미널 코스의 CLI 감각 그대로)
adb devices # 연결된 기기 목록 보기
adb install app.apk # 설치
adb logcat # 로그 실시간으로 흘려보기`;
const CODE_MANIFEST = `<!-- AndroidManifest.xml — 앱의 '주민등록증 + 권한 신청서' -->
<manifest ...>
<!-- 앱이 쓰겠다고 '선언'하는 권한들 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<application android:label="미림앱" android:icon="@mipmap/ic_launcher">
<!-- 앱을 켜면 처음 뜨는 화면(런처 액티비티) 지정 -->
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>`;
const CODE_RUNTIME_PERM = `// 위험 권한(카메라·위치·연락처 등)은 '선언'만으론 부족!
// 6.0(API 23) .
if (checkSelfPermission(Manifest.permission.CAMERA) != PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.CAMERA), REQ_CAMERA)
// " ?"
}
// INTERNET ' ' , .
// CAMERA· ' ' . !`;
const CODE_PATH = `웹 개발자가 안드로이드로 가는 세 갈래 길
A. React Native / Expo 지금 실력에서 가장 가까운 다리
· 언어: JavaScript/TypeScript + React (이미 배운 !)
· 화면: <View> <Text> 같은 React 컴포넌트로 iOS·안드 동시에
· 코드로 OS. '웹 지식 재사용' 최대 강점.
B. Kotlin + Jetpack Compose 정통 네이티브, 성능·기기기능 최대
· 선언형 UI라 "화면 = f(상태)" React 감각이 그대로 통함
· 카메라·센서·알림 안드로이드의 모든 기능에 직접 접근. 구글의 정공법.
C. WebView / PWA(하이브리드) 기존 웹을 '앱 껍데기' 담기
· 만든 웹을 앱처럼 포장. 시작은 빠르지만 네이티브 느낌·성능은 제한적.
우리 상황에 맞는 현실 판단
· 백엔드는 그대로! 앱은 우리 Spring API(/api/...) 부르는 '또 다른 손님' .
화면 기술만 바뀌고, 서버는 웹이든 앱이든 똑같이 JSON으로 응답해요.
· 2개월 만에 '네이티브 전문가' 무리 목표는 '지형 읽기'.
Compose로 화면 하나 만들고, 화면에서 API 호출까지가 현실적인 봉우리.`;
//
// : . props(n·title·sub) .
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '안드로이드란' },
{ n: 2, label: '구성요소·Intent' },
{ n: 3, label: '생명주기' },
{ n: 4, label: '화면 만들기' },
{ n: 5, label: '스튜디오·에뮬' },
{ n: 6, label: '권한·Manifest' },
{ n: 7, label: '첫 앱으로 가는 길' },
];
export default function AndroidBasicsPage() {
return (
<div>
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>안드로이드 앱의 구조<br /> 개발자가 보는 모바일</h1>
<p>
우리 회사 스택은 React 프론트 + Spring Boot 백엔드예요. 그런데 스마트폰 앱은
어떻게 다를까요? 코스는 <strong>이미 배운 React/JS 지식을 다리 삼아</strong>{' '}
안드로이드가 무엇으로 이뤄지고 어떻게 도는지를 읽습니다. 목표는 "2개월에 네이티브
전문가" 아니라 <strong>지형을 읽고, 실력으로 앱까지 가는 길을 아는 </strong>이에요.
</p>
<div className="chip-row">
<span className="chip">예상 소요 70</span>
<span className="chip">둘러보기 실습 2</span>
<span className="chip">선수 지식: React·HTTP 기초</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="안드로이드란" sub="오픈소스 위에 선, 기기가 천차만별인 세계">
<p>
안드로이드는 구글이 주도하는 <strong>오픈소스 운영체제</strong>예요. 정식 이름은{' '}
<span className="icode">AOSP</span>(Android Open Source Project). 바탕에{' '}
<strong>리눅스 커널</strong> 깔려 있고 그래서 리눅스 코스에서 배운 프로세스·파일·권한
개념이 여기서도 통합니다 위에 앱을 위한 부품들이 층층이 쌓여 있습니다.
</p>
<Code>{CODE_STACK}</Code>
<p>
소스가 공개돼 있다 보니 삼성·샤오미·구글 폰이 저마다 조금씩 다르고, 화면 크기와 OS
버전도 제각각이에요. 이걸 <strong>기기 파편화(fragmentation)</strong>라고 부릅니다.
웹에서 "크롬·사파리·모바일 화면을 다 신경 써야 하는" 상황과 똑같은 고민이, 앱에서는
한층 크게 나타나는 셈이죠.
</p>
<div className="tip">
<b>이미 아는 것과 잇기</b> 언어는 <span className="icode">Kotlin</span>(구글 권장)
또는 <span className="icode">Java</span>예요. 우리 백엔드가 Java라 클래스·중괄호·타입
같은 뼈대가 낯설지 않습니다. Kotlin은 Java와 100% 함께 있는 "더 간결해진 사촌"
정도로 생각하면 시작이 편해요.
</div>
</Section>
<Section n={2} title="앱의 구성요소와 Intent" sub="Activity = 화면 한 장, Intent = 화면 사이의 쪽지">
<p>
안드로이드 앱은 하나의 거대한 프로그램이 아니라 <strong>정해진 부품(컴포넌트)들의
조합</strong>이에요. 대표 부품이 넷인데 <strong>Activity</strong>(화면),{' '}
<strong>Service</strong>(백그라운드 작업, : 음악 재생),{' '}
<strong>BroadcastReceiver</strong>(시스템 알림 수신, : 배터리 부족),{' '}
<strong>ContentProvider</strong>(앱끼리 데이터 공유) 그중 초보가 가장 먼저,
가장 많이 만지는 <strong>Activity</strong>입니다.
</p>
<p>
<strong>Activity는 "화면 한 장"</strong>이에요. 로그인 화면, 코스 목록 화면, 상세
화면 각각이 액티비티 하나라고 보면 됩니다. 우리 React 앱에서{' '}
<span className="icode">pages/</span> 폴더의 페이지 컴포넌트 하나하나가 화면 장이었죠?
거의 대응이에요.
</p>
<Code>{CODE_ACTIVITY}</Code>
<p>
화면과 화면을 잇는 <strong>Intent</strong> "이 화면으로 가 주세요"라고 시스템에
내미는 <strong>쪽지</strong>입니다. 어디로 갈지(대상) 무엇을 들고 갈지(데이터)
담아 <span className="icode">startActivity()</span> 보내죠. 웹으로 치면{' '}
<span className="icode">&lt;Link to="/course/42" /&gt;</span> 페이지를 옮기며
파라미터를 넘기는 것과 같은 역할이에요.
</p>
<div className="tip">
<b> 문장 요약</b> "앱 = 액티비티(화면)들의 묶음, 이동은 Intent(쪽지)로."
줄만 쥐고 있어도 안드로이드 코드의 절반은 길을 잃지 않아요.
</div>
</Section>
<Section n={3} title="액티비티 생명주기" sub="화면은 태어나고, 가려지고, 사라진다 — 웹 페이지와 비교">
<p>
페이지는 열리고(load) 닫히죠(unload). 액티비티도 비슷하게{' '}
<strong>태어나서(onCreate) 보이고(onResume) 가려졌다(onPause) 사라지는(onDestroy)</strong>{' '}
일생을 삽니다. 안드로이드는 순간마다 정해진 함수(콜백) 자동으로 불러 줘요.
우리는 "그 순간에 무엇을 할지" 채워 넣으면 됩니다.
</p>
<Code>{CODE_LIFECYCLE}</Code>
<svg className="diagram" viewBox="0 0 640 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="액티비티 생명주기 사다리: onCreate에서 onStart, onResume을 거쳐 사용자와 상호작용하는 활성 상태로 갔다가 onPause, onStop, onDestroy로 내려온다. onStop에서 화면으로 돌아오면 onRestart를 거쳐 다시 onStart로 올라간다.">
{/* 세로 연결선 + 화살표 (아래로 흐름) */}
<line x1="320" y1="54" x2="320" y2="74" stroke="var(--line)" strokeWidth="2" />
<path d="M320 80 L314 68 L326 68 Z" fill="var(--line)" />
<line x1="320" y1="114" x2="320" y2="134" stroke="var(--line)" strokeWidth="2" />
<path d="M320 140 L314 128 L326 128 Z" fill="var(--line)" />
<line x1="320" y1="174" x2="320" y2="194" stroke="var(--line)" strokeWidth="2" />
<path d="M320 200 L314 188 L326 188 Z" fill="var(--line)" />
<line x1="320" y1="234" x2="320" y2="254" stroke="var(--line)" strokeWidth="2" />
<path d="M320 260 L314 248 L326 248 Z" fill="var(--line)" />
<line x1="320" y1="294" x2="320" y2="314" stroke="var(--line)" strokeWidth="2" />
<path d="M320 320 L314 308 L326 308 Z" fill="var(--line)" />
<line x1="320" y1="354" x2="320" y2="374" stroke="var(--line)" strokeWidth="2" />
<path d="M320 380 L314 368 L326 368 Z" fill="var(--line)" />
{/* onRestart 되돌이 경로 (onStop → onStart) */}
<polyline points="220,337 150,337 150,97 218,97" fill="none" stroke="var(--rose)" strokeWidth="2" strokeDasharray="5 4" />
<path d="M226 97 L214 91 L214 103 Z" fill="var(--rose)" />
<text x="144" y="213" textAnchor="middle" fontSize="12" fill="var(--rose)" transform="rotate(-90 144 213)">다시 화면으로 onRestart()</text>
{/* 상태 박스들 */}
<rect x="220" y="20" width="200" height="34" rx="8" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="320" y="42" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">onCreate()</text>
<text x="440" y="42" textAnchor="start" fontSize="12" fill="var(--muted)">레이아웃 붙이기 · 1회만</text>
<rect x="220" y="80" width="200" height="34" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="320" y="102" textAnchor="middle" fontSize="13" fill="var(--ink)">onStart()</text>
<text x="440" y="102" textAnchor="start" fontSize="12" fill="var(--muted)">화면에 보이기 시작</text>
<rect x="220" y="140" width="200" height="34" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="320" y="162" textAnchor="middle" fontSize="13" fill="var(--ink)">onResume()</text>
<text x="440" y="162" textAnchor="start" fontSize="12" fill="var(--muted)">터치 입력 받음</text>
<rect x="220" y="200" width="200" height="34" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="320" y="222" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">화면 활성 · 사용 </text>
<text x="440" y="222" textAnchor="start" fontSize="12" fill="var(--muted)">사용자와 대화 </text>
<rect x="220" y="260" width="200" height="34" rx="8" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="320" y="282" textAnchor="middle" fontSize="13" fill="var(--ink)">onPause()</text>
<text x="440" y="282" textAnchor="start" fontSize="12" fill="var(--muted)"> 가려짐 · 저장 시점</text>
<rect x="220" y="320" width="200" height="34" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="320" y="342" textAnchor="middle" fontSize="13" fill="var(--ink)">onStop()</text>
<text x="440" y="342" textAnchor="start" fontSize="12" fill="var(--muted)">완전히 보임</text>
<rect x="220" y="380" width="200" height="34" rx="8" fill="var(--card)" stroke="var(--rose)" strokeWidth="2" />
<text x="320" y="402" textAnchor="middle" fontSize="13" fill="var(--ink)">onDestroy()</text>
<text x="440" y="402" textAnchor="start" fontSize="12" fill="var(--muted)">메모리에서 정리</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 액티비티 생명주기 사다리 생성(onCreate)에서 활성으로 올랐다 소멸(onDestroy) 내려오고, 화면으로 돌아오면 onRestart로 되돌아 올라간다</p>
<div className="warn">
<b>초보 함정: 화면 회전</b> 폰을 가로로 돌리면 안드로이드는 액티비티를{' '}
<span className="icode">onDestroy()</span> <span className="icode">onCreate()</span>
<strong> 통째로 다시 만들어요</strong>. 그래서 변수에만 담아 값은 회전하는 순간
사라집니다. "화면이 언제든 재생성될 수 있다" 전제 위에서, 중요한 값은 저장해 두는
습관이 필요해요 React에서 새로고침하면 state가 초기화되던 것과 같은 결이에요.
</div>
</Section>
<Section n={4} title="화면 만들기 — XML vs Jetpack Compose" sub="명령형 옛 방식과, React를 닮은 선언형 새 방식">
<p>
화면을 그리는 방식은 크게 둘이에요. 오래된 방식은{' '}
<strong>XML 레이아웃</strong> <span className="icode">res/layout/</span> 폴더에 화면
구조를 XML로 적고, 코드에서 <span className="icode">findViewById</span> 요소를
'찾아서' 손으로 바꾸는 <strong>명령형</strong> 스타일입니다. HTML을 만들어 두고
순수 자바스크립트로 <span className="icode">document.getElementById</span> 해서 글자를
바꾸던, 느낌이죠.
</p>
<Code>{CODE_XML}</Code>
<p>
요즘 구글이 미는 방식은 <strong>Jetpack Compose</strong>예요. Kotlin 함수 하나가
화면 조각이 되고(<span className="icode">@Composable</span>), <strong>상태가 바뀌면
부분이 자동으로 다시 그려집니다.</strong> 어디서 많이 발상이죠? 맞아요 {' '}
<strong>우리 React의 "화면 = 상태의 함수" 똑같습니다.</strong>
</p>
<Code>{CODE_COMPOSE}</Code>
<div className="tip">
<b>React를 배운 그대로 무기</b> Compose의{' '}
<span className="icode">remember {'{ mutableStateOf(0) }'}</span> React의{' '}
<span className="icode">useState(0)</span>, "상태 변경 → 재구성(recomposition)"
"state 변경 → 재렌더" 대응해요. 문법(Kotlin) 새로 익히면, <strong>사고방식은
이미 배운 </strong>입니다. 그래서 프론트를 사람이 Compose에 특히 빨리 붙어요.
</div>
</Section>
<Section n={5} title="안드로이드 스튜디오와 에뮬레이터" sub="앱을 만들고 돌려 보는 작업실">
<p>
개발의 공식 도구는 <strong>안드로이드 스튜디오</strong>예요. 우리가 IntelliJ 코스에서
만진 IDE와 뿌리가 같아서( IntelliJ 기반) 단축키와 화면이 익숙할 겁니다. 여기에
빌드 도구 <span className="icode">Gradle</span>, 가상 폰을 띄우는{' '}
<strong>에뮬레이터(AVD)</strong>, 로그 <span className="icode">Logcat</span>
몸으로 붙어 있어요.
</p>
<Code>{CODE_STUDIO}</Code>
<p>
여기서 <span className="icode">Logcat</span> 웹의 <span className="kbd">F12</span>{' '}
콘솔에, <strong> Run 빌드 설치 실행</strong> 반복은 웹의 "저장 → 새로고침"
해당해요. 도구 이름만 다를 , "고치고 → 돌려 보고 → 로그로 확인"하는 개발 리듬은
웹과 똑같습니다. 명령줄로 폰을 다루는 <span className="icode">adb</span> 터미널 코스에서
익힌 CLI 감각이 그대로 이어지고요.
</p>
<div className="tip">
<b>에뮬레이터가 무겁다면</b> 가상 폰은 PC 자원을 먹어요. 노트북이 버거우면{' '}
<strong>실제 안드로이드 폰을 USB로 연결</strong>하고 설정에서 "개발자 옵션 USB
디버깅" 켜면, 진짜 기기에서 빠르고 정확하게 테스트할 있습니다. 실제 기기의
터치감·성능은 에뮬로는 100% 재현되지 않으니, 마무리 점검은 실기기가 정석이에요.
</div>
</Section>
<Section n={6} title="권한과 AndroidManifest" sub="앱의 신분증이자 권한 신청서 — 그리고 최소 권한의 예의">
<p>
폴더에는 <span className="icode">AndroidManifest.xml</span>이라는 특별한 파일이
하나 있어요. 여기에 <strong> 앱이 어떤 화면을 갖고, 어떤 권한을 쓰며, 처음 켜면 어느
화면이 뜨는지</strong> 선언합니다. 시스템은 앱을 설치할 파일부터 읽어요
말하자면 앱의 <strong>주민등록증 권한 신청서</strong>.
</p>
<Code>{CODE_MANIFEST}</Code>
<p>
권한에는 결이 가지 있어요. <span className="icode">INTERNET</span> 같은{' '}
<strong>일반 권한</strong> 설치할 조용히 허용되지만, 카메라·위치·연락처 같은{' '}
<strong>위험 권한</strong> 안드로이드 6.0(API 23)부터 <strong>실행 중에 사용자에게
팝업으로 직접 물어봐야</strong> 합니다. 매니페스트에 적는 것은 "신청" , 실제 허락은
사용자가 순간에 주는 거예요.
</p>
<Code>{CODE_RUNTIME_PERM}</Code>
<div className="warn">
<b>최소 권한의 원칙</b> "혹시 몰라서" 권한을 잔뜩 신청하면, 사용자는 불안해서 앱을
지우고 스토어 심사에서도 감점돼요. 방화벽 코스에서 배운 <strong>"필요한 것만
열어라"</strong> 원칙이 권한에도 그대로 적용됩니다 정말 쓰는 기능의 권한만,
쓰는 순간에 요청하세요.
</div>
</Section>
<Section n={7} title="웹 개발자의 첫 안드로이드 앱으로 가는 길" sub="지형을 읽었으니, 이제 현실적인 첫 봉우리를 고른다">
<p>
여기까지 왔다면 안드로이드의 <strong>지형도</strong> 머릿속에 그려졌을 거예요
층층 구조, 화면(Activity) 이동(Intent), 생명주기, 화면 그리기(XML/Compose), 작업실,
권한. 이제 남은 "그래서 나는 어디로 첫발을 뗄까"입니다. 웹을 사람에게는 길이
갈래 있어요.
</p>
<Code>{CODE_PATH}</Code>
<p>
가장 중요한 사실 하나. <strong>앱을 만든다고 백엔드를 새로 짜지 않습니다.</strong>{' '}
우리 Spring API(<span className="icode">/api/...</span>) 브라우저든 안드로이드
앱이든 가리지 않고 똑같이 JSON으로 응답해요. 앱은 그저 <strong>같은 서버를 부르는
다른 손님</strong> 뿐이죠. 그래서 여러분이 배운 HTTP·JSON·API 지식이 앱에서도 100%
재사용됩니다.
</p>
<div className="tip">
<b>현실적인 목표</b> "네이티브 앱 전문가" 만에 되겠다는 무리이고, 그럴
필요도 없어요. 대신 <strong>Compose로 화면 하나를 만들고, 화면에서 우리 API를
호출해 목록을 띄우는 </strong> 여기까지가 지금 실력으로 오를 있는 정직한
봉우리입니다. React에서 <span className="icode">useEffect</span> 데이터를 불러오던
경험이, Compose에서도 거의 같은 모양으로 반복돼요.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>📱 여기까지 왔다면</h3>
<p className="muted">
이제 안드로이드를 "낯선 딴 세상" 아니라 <strong>이미 아는 개념의 다른 표현</strong>으로
읽을 있게 됐어요 화면 장은 Activity, 페이지 이동은 Intent, 컴포넌트 생명주기는
액티비티 생명주기, "화면 = 상태의 함수" Jetpack Compose로. 다음은 화면을 실제로
쓰는 언어를 배울 차례예요.{' '}
<Link to="/learn/kotlin-intro"><strong>Kotlin 입문</strong></Link> 코스에서, 우리
백엔드 Java와 사촌지간인 언어의 문법을 손에 익히고 Compose 화면으로 걸음
나아가 봅시다.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,414 @@
// : " "
// ( · · /PWA), React/JS
// . "2 "
// " , " .
// : API ' ' . global.css
// (step-card, code-block, tip )
// var(--primary/--teal/--amber/--ink/--line/--card/--muted/--rose) .
// JSX / .
import { Link } from 'react-router-dom';
//
const CODE_THREE_PATHS = `모바일 앱을 만드는 세 갈래 길 — 한눈에
네이티브 크로스플랫폼 / PWA
언어 Kotlin·Swift JS/TS · Dart HTML·CSS·JS
코드는 ? 플랫폼마다 1 벌로 ( 그대로)
(안드+iOS=2)
성능·기기 접근 최상 대체로 충분 브라우저 한계
앱스토어 배포 O O (설치는 '홈에 추가')
개발 속도·비용 느림·비쌈 빠름·중간 가장 빠름·
비유: 갈래 = 나라에서 나고 자란 원주민(네이티브),
나라 말을 하는 통역사(크로스플랫폼),
어디서나 대충 통하는 여행 영어().`;
const CODE_NATIVE = `// 같은 "좋아요 버튼" — 네이티브는 플랫폼마다 언어가 통째로 다릅니다.
// (Kotlin + Jetpack Compose)
@Composable
fun LikeButton() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("좋아요 $count")
}
}
// iOS (Swift + SwiftUI)
struct LikeButton: View {
@State private var count = 0
var body: some View {
Button("좋아요 \\(count)") { count += 1 }
}
}
// ? ''
// (count) . React useState !
// ·· ' ' .`;
const CODE_CROSS = `// React Native — React를 안다면 이미 절반 이상 아는 코드예요.
import { useState } from 'react';
import { View, Text, Button } from 'react-native'; // div View, p Text
function LikeButton() {
const [count, setCount] = useState(0); // useState, !
return (
<View>
<Text>좋아요 {count}</Text>
<Button title="누르기" onPress={() => setCount(count + 1)} />
</View>
);
}
// React ' ':
// <div> <View> <p> <Text> onClick onPress
// ·props·state·JSX .
// '' iOS '' .`;
const CODE_PWA = `// PWA = 우리가 아는 웹사이트에 '앱 옷' 두 벌을 입힌 것
// manifest.json " " (·· )
{
"name": "미림 학습 플랫폼",
"short_name": "mirim",
"start_url": "/",
"display": "standalone", //
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
}
// service worker ' '
//
self.addEventListener('fetch', (e) => {
e.respondWith(caches.match(e.request)); //
});
// : , ,
// ( ) . , .`;
const CODE_DECISION = `무엇을 언제 고르나 — 상황별 처방전
이런 상황이면 길이 유력
3D 게임·영상 편집·카메라를 극한으로 쓴다 네이티브
프레임 성능·최신 OS 기능이 생명이다 네이티브
안드로이드·iOS , 인력·시간은 빠듯 크로스플랫폼
이미 팀이 React/JS에 능숙하다 크로스플랫폼(React Native)
서비스가 이미 있고 '앱 같은' 접근만 원함 / PWA
사내 도구·초기 MVP, 앱스토어 심사가 부담 / PWA
황금률: "가장 단순한 길부터." 웹으로 되면 , 되면 크로스,
그래도 벽에 부딪히면 네이티브. 처음부터 네이티브는
대개 과잉 투자예요. (, 성능이 제품의 본질이면 예외)`;
const CODE_OUR_STACK = `우리 스택에서 '앱까지'의 현실적인 지도
지금 우리가 가진 :
frontend/ React + JSX (Vite로 도는 프론트, 지금 화면!)
backend/ Spring Boot REST API (JSON을 돌려주는 서버)
여기서 앱으로 가는 갈래 (서버는 그대로 재사용!):
A. PWA 지금 웹에 manifest + service worker를 얹어
'설치되는 웹'으로. 가장 적은 노력부터 시작.
B. React Native 컴포넌트·props·state 지식을 그대로 들고 이사.
태그 이름(View/Text) 새로 익히면 진짜 네이티브 .
핵심: 어느 길이든 백엔드(Spring API) 바뀝니다.
앱은 결국 '같은 서버에 요청하는 또 하나의 화면' 뿐이에요.
네트워크 코스에서 배운 HTTP 요청을, 이번엔 폰이 보낼 뿐입니다.`;
//
// : . props .
// (React .)
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '왜 앱인가' },
{ n: 2, label: '세 갈래 길' },
{ n: 3, label: '네이티브' },
{ n: 4, label: '크로스플랫폼' },
{ n: 5, label: '웹 / PWA' },
{ n: 6, label: '선택 기준' },
{ n: 7, label: '우리의 길' },
];
export default function AppLandscapePage() {
return (
<div>
{/* 히어로: 이 코스가 무엇을 겨냥하는지 — '전문가'가 아니라 '지형 읽기' */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>앱의 지형<br /> 모바일 개발의 갈래 </h1>
<p>
같은 "앱"이라도 만드는 길은 하나가 아니에요. 네이티브, 크로스플랫폼,
/PWA 갈래 길엔 저마다 장단점과 어울리는 상황이 있습니다. 코스의
목표는 <strong>2개월 만에 전문가가 되는 것이 아니라</strong>, 지형을 읽고{' '}
<strong>여러분이 이미 아는 React/JS를 다리 삼아</strong> " 실력으로 앱까지
가는 " 그려 보는 거예요.
</p>
<div className="chip-row">
<span className="chip">예상 소요 70</span>
<span className="chip">실습: 비교·판단 중심</span>
<span className="chip">선수 지식: React 입문</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="웹 개발자가 왜 앱을 알아야 하나" sub="지도를 가진 사람만 길을 고를 수 있다">
<p>
우리 회사의 주력은 <strong></strong>이에요. 그런데 사용자의 손엔 대부분{' '}
<strong>스마트폰</strong> 들려 있죠. 언젠가 "이거 앱으로도 나와요?"라는 질문이
옵니다. 그때 "앱은 제 분야가 아니라서요"라고 물러서는 개발자와,
"이런 경우엔 이 길이 맞고, 저런 경우엔 저 길이 낫습니다"라고 지형을 읽어 주는
개발자는 안에서 무게가 다릅니다.
</p>
<p>
좋은 소식은, <strong>여러분은 이미 출발선을 한참 지나 있다는 </strong>이에요.
화면도 결국 "상태가 바뀌면 화면이 다시 그려진다" React의 사고방식으로
만들어지고, 앱이 데이터를 가져오는 방식도 웹과 똑같이{' '}
<span className="icode">HTTP 요청 JSON 응답</span>입니다. 새로 배우는
'전혀 다른 세계' 아니라 '익숙한 개념의 다른 옷' 경우가 많아요.
</p>
<div className="tip">
<b> 코스의 약속</b> 여기서 Kotlin이나 Swift 문법을 통째로 외우진 않아요.
대신 "어떤 길들이 있고, 각각 언제·왜 고르는가" 확실히 잡습니다. 세부 문법은
길을 정한 다음에 파도 늦지 않아요 <strong>지도 먼저, 삽질은 나중</strong>.
</div>
</Section>
<Section n={2} title="세 갈래 길 개요" sub="네이티브 · 크로스플랫폼 · 웹, 무엇이 다른가">
<p>
모바일 앱을 만드는 방법은 크게 셋으로 나뉩니다. 셋을 가르는 핵심 질문은
하나예요. <strong>" 코드가 폰의 하드웨어(카메라·센서·GPU) 얼마나 가까이,
겹을 거쳐 닿느냐?"</strong> 가까울수록 빠르고 강력하지만 손이 많이 가고,
멀수록 편하고 싸지만 있는 일에 한계가 생깁니다.
</p>
<Code>{CODE_THREE_PATHS}</Code>
<p>
아래 그림이 코스 전체의 뼈대예요. 모두 <strong> 아래의 OS·하드웨어는
똑같이 공유</strong>합니다. 다른 "그 위에 몇 겹의 다리를 얹느냐"뿐이에요.
</p>
<svg className="diagram" viewBox="0 0 660 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="세 갈래 길 비교도: 네이티브는 Kotlin과 Swift 코드가 OS API를 직접 호출하고, 크로스플랫폼은 자바스크립트나 Dart 한 벌이 리액트 네이티브 브리지나 플러터 엔진을 거치며, 웹과 PWA는 HTML CSS JS가 브라우저 엔진을 거친다. 세 길 모두 맨 아래의 안드로이드 iOS 운영체제와 카메라 센서 GPU 하드웨어를 똑같이 공유한다.">
{/* 네이티브 열 */}
<text x="130" y="24" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--primary)">네이티브</text>
<rect x="50" y="38" width="160" height="46" rx="8" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="130" y="60" textAnchor="middle" fontSize="13" fill="var(--ink)">Kotlin · Swift</text>
<text x="130" y="77" textAnchor="middle" fontSize="11" fill="var(--muted)">플랫폼마다 1</text>
<line x1="130" y1="84" x2="130" y2="116" stroke="var(--line)" strokeWidth="2" />
<rect x="50" y="116" width="160" height="46" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="130" y="138" textAnchor="middle" fontSize="13" fill="var(--ink)">OS API 직접 호출</text>
<text x="130" y="155" textAnchor="middle" fontSize="11" fill="var(--muted)">다리 없음 · 가장 가까움</text>
<line x1="130" y1="162" x2="130" y2="286" stroke="var(--primary)" strokeWidth="2" />
<path d="M130 298 L124 286 L136 286 Z" fill="var(--primary)" />
{/* 크로스플랫폼 열 */}
<text x="330" y="24" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--teal)">크로스플랫폼</text>
<rect x="250" y="38" width="160" height="46" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="330" y="60" textAnchor="middle" fontSize="13" fill="var(--ink)">JS/TS · Dart</text>
<text x="330" y="77" textAnchor="middle" fontSize="11" fill="var(--muted)"> 벌로 </text>
<line x1="330" y1="84" x2="330" y2="116" stroke="var(--line)" strokeWidth="2" />
<rect x="250" y="116" width="160" height="46" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="330" y="137" textAnchor="middle" fontSize="12" fill="var(--ink)">RN 브리지·Flutter 엔진</text>
<text x="330" y="155" textAnchor="middle" fontSize="11" fill="var(--muted)">얇은 다리 </text>
<line x1="330" y1="162" x2="330" y2="286" stroke="var(--teal)" strokeWidth="2" />
<path d="M330 298 L324 286 L336 286 Z" fill="var(--teal)" />
{/* 웹 / PWA 열 */}
<text x="530" y="24" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--amber)"> / PWA</text>
<rect x="450" y="38" width="160" height="46" rx="8" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="530" y="60" textAnchor="middle" fontSize="13" fill="var(--ink)">HTML · CSS · JS</text>
<text x="530" y="77" textAnchor="middle" fontSize="11" fill="var(--muted)"> 그대로</text>
<line x1="530" y1="84" x2="530" y2="116" stroke="var(--line)" strokeWidth="2" />
<rect x="450" y="116" width="160" height="46" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="530" y="138" textAnchor="middle" fontSize="13" fill="var(--ink)">브라우저 엔진</text>
<text x="530" y="155" textAnchor="middle" fontSize="11" fill="var(--muted)">두꺼운 다리 · 가장 </text>
<line x1="530" y1="162" x2="530" y2="286" stroke="var(--amber)" strokeWidth="2" />
<path d="M530 298 L524 286 L536 286 Z" fill="var(--amber)" />
{/* 공유되는 바닥: OS + 하드웨어 */}
<rect x="50" y="300" width="560" height="58" rx="10" fill="var(--card)" stroke="var(--line)" strokeWidth="2" />
<text x="330" y="324" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">OS: 안드로이드 · iOS</text>
<text x="330" y="344" textAnchor="middle" fontSize="12" fill="var(--muted)">하드웨어 카메라 · 센서 · GPU · 저장소 ( 길이 함께 )</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 갈래 길은 위쪽 '언어와 다리' 다르고, 아래쪽 OS·하드웨어는 똑같이 공유한다 다리가 두꺼울수록 편하지만 하드웨어에서 멀어진다</p>
<div className="tip">
<b>기억할 문장</b> "다리를 얹을수록 편해지지만, 하드웨어에서는 멀어진다."
저울 하나가 길의 성능·비용·자유도 차이를 거의 설명해요.
</div>
</Section>
<Section n={3} title="네이티브란" sub="그 플랫폼의 원주민 언어로 직접 말하기">
<p>
<strong>네이티브(native)</strong> 플랫폼이 공식으로 정한 언어·도구로 앱을
만드는 방식이에요. 안드로이드는 <strong>Kotlin</strong>(과거엔 Java)으로{' '}
<span className="icode">Android Studio</span>에서, iOS는 <strong>Swift</strong>
(과거엔 Objective-C) <span className="icode">Xcode</span>에서 만듭니다. 요즘의
화면(UI) 각각 <strong>Jetpack Compose</strong>(안드로이드){' '}
<strong>SwiftUI</strong>(iOS) 그리고요.
</p>
<Code>{CODE_NATIVE}</Code>
<p>
장점은 명확해요. <strong>하드웨어에 가장 가깝기 때문에 가장 빠르고</strong>,
카메라·센서·최신 OS 기능을 <strong>제일 먼저, 제일 완전하게</strong> 있어요.
고사양 3D 게임, 영상 편집, 정밀한 카메라 앱이 네이티브를 고르는 이유입니다.
단점도 그만큼 분명하죠 안드로이드용과 iOS용을 <strong>사실상 </strong>{' '}
만들어야 하고, 서로 다른 언어·도구를 아는 인력이 양쪽 필요합니다.
</p>
<div className="warn">
<b>iOS의 현실적 제약</b> iOS 앱을 <strong>빌드하려면 (macOS)</strong>
필요해요. Xcode가 맥에서만 돌기 때문이죠. "아이폰 앱도 만들자" 결정에는
장비·계정 비용이 따라온다는 미리 알아 두면 나중에 당황하지 않습니다.
</div>
</Section>
<Section n={4} title="크로스플랫폼이란" sub="한 벌의 코드로 안드로이드와 iOS 둘 다">
<p>
"두 번 만드는 건 너무 비싸다. 한 벌로 둘 다 안 될까?" 답하는 {' '}
<strong>크로스플랫폼(cross-platform)</strong>이에요. 대표 주자는 둘입니다.
Meta가 만든 <strong>React Native</strong>(JavaScript/TypeScript) Google이 만든{' '}
<strong>Flutter</strong>(<span className="icode">Dart</span> 언어)예요.
</p>
<p>
도구의 '그리는 방식' 살짝 달라요. <strong>React Native</strong> 우리가
컴포넌트를 <strong>진짜 네이티브 위젯</strong>으로 바꿔 화면에 올리고,{' '}
<strong>Flutter</strong> 자체 엔진으로 <strong>화면을 직접 그립니다</strong>
(픽셀을 스스로 칠하는 ). 방식은 달라도 목표는 같아요 {' '}
<strong>코드 벌로 플랫폼</strong>.
</p>
<Code>{CODE_CROSS}</Code>
<p>
여러분에게 특히 반가운 <strong>React Native</strong>예요. 코드를 보면{' '}
<span className="icode">useState</span>, props, JSX, 컴포넌트 전부 React 입문
코스에서 배운 그대로죠. 바뀐 주로 <strong>태그 이름</strong>입니다
(<span className="icode">&lt;div&gt;&lt;View&gt;</span>,{' '}
<span className="icode">&lt;p&gt;&lt;Text&gt;</span>). 성능은 네이티브만큼
극한은 아니지만 <strong>대부분의 앱엔 충분</strong>하고, 개발 속도와 비용은
크게 절약됩니다.
</p>
<div className="tip">
<b> 우리에게 매력적인가</b> 우리 프론트가 이미 React라서, React Native는{' '}
<strong>'새 언어' 아니라 '같은 사고방식의 확장'</strong>이에요. 웹에서 쌓은
컴포넌트 설계 감각이 그대로 자산이 됩니다. (7 섹션에서 이어서 봅니다.)
</div>
</Section>
<Section n={5} title="웹앱 · PWA란" sub="설치되는 웹 — 이미 있는 웹에 '앱 옷'을 입히기">
<p>
번째 길은 아예 앱을 따로 만들지 않는 거예요. <strong>웹앱</strong> 그냥
모바일 브라우저에서 열리게 만든 웹사이트고, 여기에 가지를 얹어 "앱처럼"
쓰게 <strong>PWA(Progressive Web App)</strong>입니다. 핵심 재료는 둘이에요 {' '}
<span className="icode">manifest.json</span>( 명함){' '}
<span className="icode">service worker</span>(뒤에서 도는 작은 비서).
</p>
<Code>{CODE_PWA}</Code>
<p>
가지 덕분에 PWA는 <strong> 화면에 아이콘으로 설치되고, 오프라인에서도
열리고,</strong> 최신 브라우저에선 푸시 알림까지 받을 있어요. 개발은{' '}
<strong>우리가 이미 아는 HTML·CSS·JS 그대로</strong> 가장 빠르고 저렴합니다.
이미 서비스가 있다면 "앱까지" 가는 가장 가까운 길이에요.
</p>
<div className="warn">
<b>공짜 점심은 없다</b> PWA는 <strong>앱스토어에 정식 등록되지 않고</strong>
('설치' 브라우저의 홈에 추가), 블루투스·정밀 센서 같은 <strong>일부 하드웨어
기능은 제한</strong>됩니다. iOS에서는 지원 범위가 안드로이드보다 좁은 편이라,
"웹으로 다 될 것"이라고 단정하기 전에 <strong>필요한 기능이 브라우저에서
되는지</strong>부터 확인해야 해요.
</div>
</Section>
<Section n={6} title="무엇을 언제 고르나" sub="정답은 '상황'에 달려 있다 — 선택의 기준표">
<p>
"그래서 뭐가 제일 좋아요?"라는 질문엔 정답이 없어요. 좋은 답은 항상{' '}
<strong>"무엇을 만드는데요?"라는 되물음</strong>에서 시작합니다. 아래 표는
현업에서 실제로 쓰는 판단 기준을 압축한 거예요.
</p>
<Code>{CODE_DECISION}</Code>
<p>
기준을 관통하는 축은 결국 개로 좁혀집니다. <strong> 성능·하드웨어 요구</strong>
(얼마나 극한인가), <strong> 예산·시간·인력</strong>( 만들 여유가 있나),{' '}
<strong> 팀이 이미 가진 실력</strong>(무엇에 능숙한가),{' '}
<strong> 앱스토어 등록이 필요한가</strong>. 넷을 놓고 저울질하면
대개 길이 보여요.
</p>
<div className="tip">
<b>가장 흔한 실수</b> "멋있어 보이니까 처음부터 네이티브"입니다. 대부분의 초기
제품은 <strong>가장 단순한 길로 시작해</strong> 사용자가 붙고 한계가 보일
가까운 길로 옮겨도 늦지 않아요. <strong>과잉 투자보다 '나중에 갈아타기'
싸다</strong> 기억하세요.
</div>
</Section>
<Section n={7} title="우리 회사 관점" sub="React 자산을 살려 '앱까지' 가는 현실적인 길">
<p>
이제 우리 상황에 대입해 봅시다. 우리는 <strong>프론트가 React,
백엔드가 Spring Boot REST API</strong>예요. 자산을 버리지 않고 앱으로
넓히는 길이 실제로 존재합니다.
</p>
<Code>{CODE_OUR_STACK}</Code>
<p>
가장 중요한 가지를 강조할게요. <strong>어느 길을 가든 백엔드(Spring API)
그대로 재사용된다</strong> 점이에요. 앱은 마법이 아니라, 화면과 똑같이{' '}
<span className="icode">GET /api/...</span> 요청을 보내 JSON을 받아 그리는{' '}
<strong>'또 하나의 화면'</strong> 뿐입니다. 네트워크 코스에서 배운 HTTP
요청을 이번엔 폰이 보낼 뿐이죠. 그래서 백엔드를 튼튼히 배워 시대에도
그대로 힘이 됩니다.
</p>
<p>
그러니 여러분의 로드맵은 분명해요. <strong>지금은 React를 깊게, 백엔드를
단단히.</strong> 실력이 쌓이면 React Native로의 이사도, PWA 얹기도 도약이
아니라 <strong>자연스러운 다음 걸음</strong> 됩니다. "앱 전문가"라는 간판보다
"필요할 때 옳은 길을 고를 줄 아는 개발자" 훨씬 오래가요.
</p>
<div className="tip">
<b>스스로 점검</b> 친구에게 30 안에 이렇게 설명할 있나요? "네이티브는 원주민
언어라 빠르지만 만들고, 크로스플랫폼은 벌로 다인데 React Native면
우리 지식이 거의 그대로 쓰이고, PWA는 있는 웹에 옷을 입히는 가장 길이다."
문장이 나오면 코스의 목표는 달성입니다.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>📱 여기까지 왔다면</h3>
<p className="muted">
이제 "앱을 만든다" 말이 하나가 아니라 <strong> 갈래 </strong>이라는 ,
그리고 각각 언제· 고르는지를 설명할 있게 됐어요. 무엇보다 {' '}
<strong>여러분이 배운 React가 앱으로 가는 진짜 다리라는 </strong> 봤습니다.
다음은 가장 넓은 , 안드로이드를 조금 가까이서 들여다볼 차례예요.{' '}
<Link to="/learn/android-basics"><strong>안드로이드 기초</strong></Link> 코스에서
Kotlin과 앱의 구성 요소를, 우리가 아는 개념에 하나씩 포개어 만나 보세요.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,414 @@
// : " " ·,
// (URL·react-router) 7
// . " " ,
// ·· , , ·, RN/Flutter
// .
// : API ' ' . global.css
// (step-card, code-block, tip ) .
// JSX / .
import { Link } from 'react-router-dom';
//
const CODE_WHY_NAV = `앱은 "화면 여러 장"으로 이루어진 그림책이에요.
[ ] [ 코스 목록 ] 누르면 [ 코스 상세 ] 누르면 [ 강의 영상 ]
화면만 있는 앱은 거의 없어요. 목록에서 항목을 누르면 상세로,
상세에서 버튼을 누르면 결제로 이렇게 "지금 어느 화면을 보여줄지"
결정하고 화면끼리 오가게 하는 일을 통틀어 '내비게이션(navigation)'이라 불러요.
비유: 앱은 여러 장의 카드가 담긴 카드 지갑.
내비게이션은 "어느 카드를 맨 앞으로 꺼내 보여줄까" 정하는 손이에요.`;
const CODE_WEB_VS_APP = `웹 라우팅 — 주소(URL)가 곧 화면 앱 내비게이션 — 스택(카드 더미)이 화면
브라우저 주소창이 있다 주소창이 없다 (URL이 보임)
/courses/42 = URL = " "
URL을 바꾸면 화면으로 이동 navigate()/push() 카드를 쌓음
뒤로가기 = 브라우저 히스토리 뒤로가기 = 카드 걷어내기(pop)
주소를 복사해 남에게 보낼 있다 대신 '딥링크' 특정 화면을 연다
공통점: '스택(쌓임)' 구조예요.
웹도 사실 방문 기록을 스택으로 쌓아 두고, 뒤로가기로 걷어냅니다.
차이는 "그 스택이 눈에 보이느냐(URL)" .`;
const CODE_PATTERNS = `앱 내비게이션 3대 패턴 — 거의 모든 앱이 이 셋의 조합이에요.
1) 스택(Stack) 카드를 위로 쌓기 / 걷어내기
목록 상세 결제 처럼 '깊이 들어갔다 나오는' 흐름.
push(들어가기) pop(뒤로) 기본 동작.
2) (Tab) 나란한 방들 사이를 오가기
화면 아래 [][검색][ 학습][프로필] .
서로 대등한 최상위 구역. 탭을 바꿔도 탭은 자기 스택을 기억.
3) 드로어(Drawer) 옆에서 밀려 나오는 서랍 메뉴
왼쪽 가장자리에서 스윽 나오는 설정·메뉴 목록.
화면이 좁은 모바일에서 '덜 쓰는 메뉴' 숨겨 두는 서랍.
조합 : 전체는 '탭', 안은 '스택', 계정 메뉴는 '드로어'.`;
const CODE_PASS_DATA = `화면 A에서 B로 값 넘기기 — "코스 42번을 열어줘"
// React Navigation (React Native) param()
function CourseList({ navigation }) {
// '' id
return <Button onPress={() => navigation.navigate('Detail', { courseId: 42 })} />;
}
function CourseDetail({ route }) {
const { courseId } = route.params; //
return <Text>코스 {courseId} 상세</Text>;
}
// (react-router) :
// : navigate('/courses/42') URL(/courses/:id)
// : navigate('Detail', { courseId: 42 }) params
// " " . .
params엔 id 같은 '작은 열쇠' 넘기고, 데이터는 화면이
id로 다시 조회하게 하세요. 화면 사이로 무거운 짐을 통째 나르지 않기.`;
const CODE_BACK_DEEPLINK = `뒤로가기와 딥링크 — 앱만의 두 가지 감각
뒤로가기(back)
Android: 화면 아래 '시스템 뒤로가기' 버튼/제스처가 항상 있음
스택에서 카드 pop. 스택이 비면 앱이 종료.
iOS: 하드웨어 버튼이 없음 화면 왼쪽 '< 뒤로', 또는 왼쪽
가장자리에서 오른쪽으로 쓸어넘기는(swipe-back) 제스처.
그래서 앱은 "뒤로가기를 눌렀을 때 뭐가 나와야 자연스러운가"
스택 순서로 항상 설계해 둬야 해요.
딥링크(deep link) 특정 화면을 '주소' 바로 열기
myapp://courses/42 ( )
https://app.example.com/courses/42 / ( )
카톡·메일의 링크를 누르면 앱이 켜지며 '코스 42 상세' 직행.
웹의 "URL 복사해서 공유" 앱에서 되살리는 장치가 바로 딥링크예요.`;
const CODE_RN_FLUTTER = `RN / Flutter — 내비게이션은 '라이브러리'로 한다 (내장 아님에 가까움)
React Native 사실상 표준: React Navigation
createNativeStackNavigator() //
createBottomTabNavigator() //
createDrawerNavigator() //
navigation.navigate / goBack // /
(Expo를 쓰면 파일 기반 라우팅인 expo-router도 선택지)
Flutter 내장 Navigator + 라우팅 패키지 go_router
Navigator.push(context, MaterialPageRoute(builder: (_) => DetailPage()));
Navigator.pop(context); //
// go_router URL :
// GoRoute(path: '/courses/:id', builder: ...)
공통 감각: "화면을 스택에 push, 뒤로는 pop, 탭/드로어는 껍데기."
프레임워크가 달라도 단어들(push·pop·stack·tab) 그대로 통해요.`;
const CODE_ROUTER_BRIDGE = `우리 웹 라우팅(react-router-dom) — 앱 내비게이션과 한 뿌리
// frontend/src/App.jsx URL ( )
<Routes>
<Route path="/learn/:slug" element={<CoursePage />} />
</Routes>
// : <Link> useNavigate navigate
import { Link, useNavigate, useParams } from 'react-router-dom';
<Link to="/learn/react-intro">React 입문 열기</Link> // = navigation.navigate
function CoursePage() {
const { slug } = useParams(); // = route.params (URL )
const navigate = useNavigate();
return <button onClick={() => navigate(-1)}>뒤로</button>; // = goBack / pop
}
// :
// <Link to> / navigate() navigation.navigate('')
// useParams() route.params
// navigate(-1) navigation.goBack() / Navigator.pop()
// ( )`;
//
// : props .
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '화면 이동이란' },
{ n: 2, label: 'URL vs 스택' },
{ n: 3, label: '스택·탭·드로어' },
{ n: 4, label: '데이터 전달' },
{ n: 5, label: '뒤로가기·딥링크' },
{ n: 6, label: 'RN·Flutter' },
{ n: 7, label: '우리 웹과의 다리' },
];
export default function AppNavigationPage() {
return (
<div>
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1> 화면과 이동<br /> 라우팅으로 내비게이션 읽기</h1>
<p>
앱은 화면 여러 장을 오가는 그림책이에요. "지금 어느 화면을 보여줄지" 정하고
화면끼리 이동시키는 일이 <strong>내비게이션</strong>입니다. 여러분이 이미 배운{' '}
<strong> 라우팅(URL·react-router)</strong> 다리 삼아, 앱의 스택··드로어와
딥링크까지 <strong>지형을 읽는 </strong> 만들어 봅니다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 70</span>
<span className="chip">실습: 우리 App.jsx 라우팅 뜯어보기</span>
<span className="chip">선수 지식: React 입문(라우팅)</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="앱의 화면 이동이란" sub="앱 = 화면 여러 장, 내비게이션 = 그걸 오가는 손">
<p>
페이지는 스크롤 번이면 위아래로 이어지지만, 앱은 <strong>화면(screen) 여러
</strong>으로 나뉘어요. 홈에서 코스 목록으로, 목록에서 상세로, 상세에서 영상으로
이렇게 "지금 어느 화면을 앞에 세울지" 정하고 화면끼리 오가게 하는 전부를{' '}
<strong>내비게이션</strong>이라고 부릅니다.
</p>
<Code>{CODE_WHY_NAV}</Code>
<p>
굳이 화면을 쪼갤까요? 모바일은 <strong>화면이 좁고</strong>, 번에 가지 일에
집중하는 사용 패턴이라서예요. 그래서 "한 화면 = 하나의 목적"으로 잘게 나누고, 이동으로
연결합니다. 감각은 사실 우리 웹에서도 익숙해요 페이지 하나가 컴포넌트 하나였죠.
앱은 <strong>페이지 전환을 또렷하게, 애니메이션까지 곁들여</strong> 하는 것뿐이에요.
</p>
<div className="tip">
<b> 정리</b> "화면 = 상태의 함수" 배웠다면, 이번엔 위층
"<strong>어느 화면</strong>을 지금 보여줄까" 정하는 이야기예요.
코스가 카드 <em> </em> 그리는 법이었다면, 이번은 카드 <em>여러 </em>
꺼내고 넣는 법입니다.
</div>
</Section>
<Section n={2} title="웹 라우팅(URL) vs 앱 내비게이션(스택)" sub="주소창이 하던 일을, 앱에선 '카드 더미'가 한다">
<p>
웹에서 화면을 결정하는 <strong>주소(URL)</strong>였어요.{' '}
<span className="icode">/learn/react-intro</span> 주소가 바뀌면 화면이 뜨고,
뒤로가기를 누르면 브라우저의 방문 기록을 되짚어 갑니다. 그런데 앱을 보면{' '}
<strong>주소창 자체가 없죠.</strong> 그럼 앱은 "지금 화면" 무엇으로 기억할까요?
</p>
<p>
답은 <strong>스택(stack)</strong>입니다. 화면에 들어갈 때마다 카드를 <strong>위로
쌓고(push)</strong>, 뒤로가기를 하면 카드를 <strong>걷어냅니다(pop)</strong>.
지금 보이는 화면은 언제나 "쌓인 카드 더미의 맨 위 한 장"이에요. 사실 웹의 방문
기록도 내부적으론 똑같은 스택 구조라, <strong>둘은 뿌리</strong>예요 차이는
스택이 <strong>주소창으로 눈에 보이느냐</strong> 뿐입니다.
</p>
<Code>{CODE_WEB_VS_APP}</Code>
<svg className="diagram" viewBox="0 0 640 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="웹은 주소창의 URL이 바뀌며 화면이 정해지고, 앱은 화면 카드를 위로 쌓는 스택 구조로 push는 카드를 올리고 pop인 뒤로가기는 맨 위 카드를 걷어낸다">
{/* 왼쪽: 웹 — 주소창 */}
<text x="165" y="34" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)"> 주소창(URL) 화면</text>
<rect x="55" y="52" width="220" height="34" rx="8" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="70" y="74" textAnchor="start" fontSize="12" fill="var(--muted)">🔒 /learn/react-intro</text>
<rect x="55" y="98" width="220" height="150" rx="10" fill="var(--card)" stroke="var(--line)" />
<text x="165" y="176" textAnchor="middle" fontSize="13" fill="var(--ink)">react-intro 화면</text>
<text x="165" y="272" textAnchor="middle" fontSize="12" fill="var(--muted)">URL 바꾸면 화면으로</text>
<text x="165" y="290" textAnchor="middle" fontSize="12" fill="var(--muted)">뒤로가기 = 히스토리 되짚기</text>
{/* 오른쪽: 앱 — 스택 */}
<text x="475" y="34" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)"> 스택(카드 더미) 화면</text>
<rect x="380" y="176" width="190" height="34" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="475" y="198" textAnchor="middle" fontSize="12" fill="var(--muted)"> ( 아래 카드)</text>
<rect x="392" y="142" width="190" height="34" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="487" y="164" textAnchor="middle" fontSize="12" fill="var(--muted)">코스 목록</text>
<rect x="404" y="108" width="190" height="34" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="499" y="130" textAnchor="middle" fontSize="12" fontWeight="600" fill="var(--ink)">코스 상세 지금 보임</text>
{/* push 화살표 */}
<line x1="352" y1="150" x2="352" y2="112" stroke="var(--primary)" strokeWidth="2" />
<path d="M352 104 L346 116 L358 116 Z" fill="var(--primary)" />
<text x="346" y="132" textAnchor="end" fontSize="12" fill="var(--primary)">push</text>
{/* pop 화살표 */}
<line x1="612" y1="112" x2="612" y2="150" stroke="var(--rose)" strokeWidth="2" strokeDasharray="4 3" />
<path d="M612 158 L606 146 L618 146 Z" fill="var(--rose)" />
<text x="616" y="132" textAnchor="start" fontSize="12" fill="var(--rose)">pop</text>
<text x="475" y="272" textAnchor="middle" fontSize="12" fill="var(--muted)">push=카드 올리기 · pop=걷어내기(뒤로)</text>
<text x="475" y="290" textAnchor="middle" fontSize="12" fill="var(--muted)"> 카드가 지금 화면</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 웹은 URL이, 앱은 카드 더미(스택) 위가 '지금 화면' push로 쌓고 pop(뒤로가기)으로 걷어낸다</p>
<div className="tip">
<b>연결고리</b> react-router에서 <span className="icode">navigate('/courses/42')</span>
화면을 바꾸고 <span className="icode">navigate(-1)</span> 뒤로 갔던 경험이,
앱에선 <span className="icode">push</span>/<span className="icode">pop</span>이라는
이름으로 똑같이 반복돼요. 개념이 아니라 <strong>이름만 바뀐 친구</strong>입니다.
</div>
</Section>
<Section n={3} title="스택·탭·드로어 내비게이션 패턴" sub="거의 모든 앱은 이 셋의 조합이다">
<p>
화면 구조는 복잡해 보여도 사실 <strong> 가지 패턴</strong> 조합이에요.
셋의 역할을 구분할 알면, 처음 보는 앱도 "아, 여긴 탭 안의 스택이구나" 하고
지도를 그릴 있게 됩니다.
</p>
<Code>{CODE_PATTERNS}</Code>
<p>
핵심은 <strong>역할이 다르다</strong> 거예요. <strong>스택</strong> "깊이 들어갔다
나오는" 상하 이동(목록상세결제), <strong></strong> 서로 대등한 구역 사이의
좌우 이동(검색프로필), <strong>드로어</strong> 가끔 쓰는 메뉴를 옆에 숨겨 두는
서랍이에요. 실무 앱은 보통 <strong>바깥은 , 안은 각자의 스택</strong>이라,
"검색 탭에서 상세로 들어갔다가 홈 탭으로 갔다 돌아오면 <strong>아까 그 상세가 그대로</strong>"
유지되죠. 탭이 자기 카드 더미를 따로 기억하기 때문입니다.
</p>
<div className="tip">
<b>우리 웹으로 번역하면</b> 상단 네비게이션 바에서 "학습·도구·내 정보" 오가는 {' '}
<strong></strong> 같은 역할, 코스 목록에서 코스 상세로 들어가는 <strong>스택</strong>
같은 역할이에요. 좁은 화면이라 옆으로 숨기는 <strong>드로어</strong> 웹에선 흔할 ,
개념은 그대로 대응됩니다.
</div>
<div className="warn">
<b>흔한 착각</b> "탭을 누르면 새 화면이 쌓인다" 아니에요. 전환은 <strong>서랍을 바꿔
여는 </strong> 가깝고, 탭은 자기 스택을 <strong>그대로 보존</strong>합니다.
탭을 오갈 때마다 스택이 초기화된다면 그건 대개 설계 실수예요.
</div>
</Section>
<Section n={4} title="화면 간 데이터 전달" sub="다음 화면에게 '쪽지 한 장' 쥐여 보내기">
<p>
"코스 목록에서 42번을 눌렀다" 정보를 상세 화면이 알아야 코스를 보여줄 있죠.
화면을 이동시킬 <strong>값을 함께 실어 보내는 </strong> 데이터 전달이라고 해요.
앱에선 값을 <strong>파라미터(params)</strong>라는 쪽지에 담아 넘깁니다.
</p>
<Code>{CODE_PASS_DATA}</Code>
<p>
웹과 나란히 놓으면 원리가 똑같아요. 웹은 값을 <strong>URL</strong> 실었고
(<span className="icode">/courses/:id</span> <span className="icode">useParams()</span>),
앱은 값을 <strong>params 객체</strong> 실어(<span className="icode">navigate('Detail', {'{ courseId: 42 }'})</span>{' '}
<span className="icode">route.params</span>) 넘길 뿐입니다. "다음 화면에게
쪽지를 건네는" 같은 일이고, <strong>통로만 URL이냐 객체냐</strong> 다릅니다.
</p>
<div className="warn">
<b> 짐은 나르지 </b> params엔 <span className="icode">id</span> 같은 작은 열쇠만
담고, 실제 데이터는 도착한 화면이 id로 서버에{' '}
<span className="icode">/api/courses/42</span> <strong>다시 조회</strong>하게 하세요.
화면 사이로 무거운 객체를 통째 나르면, 뒤로 갔다 오거나 딥링크로 바로 들어왔을
데이터가 없어서 화면이 깨집니다.
</div>
</Section>
<Section n={5} title="뒤로가기와 딥링크" sub="앱에만 있는 두 가지 감각 — 시스템 back, 그리고 주소 없는 곳의 주소">
<p>
<strong>뒤로가기</strong> 설계에서 생각보다 주제예요. 특히{' '}
<strong>Android</strong> 화면 아래에 <strong>시스템 뒤로가기</strong> 버튼(또는
제스처) 항상 있어서, 사용자가 언제든 스택을 <span className="icode">pop</span>
있어요. 반대로 <strong>iOS</strong> 하드웨어 뒤로가기 버튼이 없고, 화면 왼쪽 {' '}
<span className="icode">&lt; 뒤로</span> <strong>왼쪽 가장자리에서 쓸어넘기는
제스처</strong> 뒤로 갑니다. 그래서 개발자는 "뒤로를 눌렀을 뭐가 나와야
자연스러운가" <strong>스택 순서로 설계</strong> 둬야 해요.
</p>
<Code>{CODE_BACK_DEEPLINK}</Code>
<p>
<strong>딥링크(deep link)</strong> 웹에서 당연했던 "URL 복사해서 공유" 앱에서
되살리는 장치예요. 앱엔 주소창이 없지만, <span className="icode">myapp://courses/42</span>{' '}
같은 <strong>커스텀 스킴</strong>이나 <span className="icode">https://app.example.com/courses/42</span>{' '}
같은 <strong>유니버설/ 링크</strong> 누르면 앱이 켜지며 <strong>특정 화면으로
직행</strong>합니다. 카톡·메일로 받은 링크가 앱을 열어 바로 코스로 데려가는
전부 딥링크죠.
</p>
<div className="tip">
<b> 중요할까</b> 딥링크로 <em>중간 화면</em> 곧장 들어오면, 화면(목록·)
스택에 없을 있어요. 그래서 만든 앱은 딥링크 진입 "돌아갈 자리( ) 스택에
미리 깔아 두는" 처리를 합니다. 섹션 4의 "id만 넘기고 다시 조회" 원칙이 여기서 빛나요
중간에 떨어져도 id만 있으면 화면을 스스로 채울 있으니까요.
</div>
</Section>
<Section n={6} title="RN·Flutter의 내비게이션 라이브러리 개요" sub="프레임워크는 달라도 push·pop·stack은 통한다">
<p>
모바일 앱을 만드는 대표적인 크로스플랫폼 도구가 <strong>React Native(RN)</strong>{' '}
<strong>Flutter</strong>예요. 재미있게도 내비게이션을 <strong>라이브러리/패키지</strong>
다루고, 쓰는 <strong>단어가 거의 같습니다.</strong> 그래서 하나를 익히면 다른 지형도
금방 읽혀요.
</p>
<Code>{CODE_RN_FLUTTER}</Code>
<p>
<strong>React Native</strong> <span className="icode">React Navigation</span> 사실상
표준이에요. 여러분이 배운 React 문법(컴포넌트·props) 위에서{' '}
<span className="icode">navigation.navigate('Detail')</span>처럼 이동하죠 JSX를 아는
사람에게 진입 장벽이 가장 낮은 길입니다. <strong>Flutter</strong> Dart 언어를 쓰고,
화면을 <span className="icode">Navigator.push</span> 쌓고{' '}
<span className="icode">Navigator.pop</span>으로 걷어내며, 규모가 커지면{' '}
<span className="icode">go_router</span> URL 스타일 경로를 선언합니다.
</p>
<div className="tip">
<b>지형만 읽어도 충분</b> 지금 목표는 "2개월 만에 RN/Flutter 전문가" 아니에요.
"앱 내비게이션은 결국 <strong>스택에 push, 뒤로는 pop, 탭·드로어는 껍데기</strong>"라는
지도를 갖는 지도만 있으면, 나중에 어떤 프레임워크를 만나도 여러분의 React
실력을 다리 삼아 걸어 들어갈 있어요.
</div>
</Section>
<Section n={7} title="우리 웹 라우팅과의 다리" sub="react-router에서 배운 게 앱에서 그대로 쓰인다">
<p>
마지막으로, 모든 <strong>우리 코드</strong> 되감아 봅시다. 우리 학습 플랫폼
프론트는 <span className="icode">react-router-dom</span>으로 라우팅해요. 지금 보고 있는
코스 페이지도 <span className="icode">frontend/src/App.jsx</span>{' '}
<span className="icode">&lt;Route&gt;</span> 등록돼 URL로 열린 화면입니다.
</p>
<Code>{CODE_ROUTER_BRIDGE}</Code>
<p>
대응표를 보면 <strong>새로 외울 거의 없다</strong> 있어요.{' '}
<span className="icode">&lt;Link to&gt;</span> 앱의{' '}
<span className="icode">navigate('화면')</span>, <span className="icode">useParams()</span>{' '}
<span className="icode">route.params</span>, <span className="icode">navigate(-1)</span>{' '}
<span className="icode">goBack()</span>/<span className="icode">pop()</span> 웹에서 손에
익힌 동작들이 앱에서 <strong>이름만 갈아입고</strong> 다시 등장합니다. 이게 "
개발자가 보는 모바일" 핵심이에요. 밑바닥 개념(스택·params·history) 공유하니까요.
</p>
<div className="tip">
<b>직접 확인해 보기</b> 에디터로 <span className="icode">frontend/src/App.jsx</span> 열어{' '}
<span className="icode">&lt;Routes&gt;</span> <span className="icode">&lt;Route path=.../&gt;</span>
찾아보세요. 그다음 아무 코스 카드나 눌렀다가 브라우저 <span className="kbd"></span>
(뒤로가기) 눌러 보세요. 방금 화면이 스택에서 <span className="icode">pop</span> 되는
감각 앱의 뒤로가기와 정확히 같은 동작입니다.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>📱 여기까지 왔다면</h3>
<p className="muted">
이제 앱의 화면 전환을 <strong>스택··드로어</strong>라는 단어로 설명하고,{' '}
<strong>params로 데이터를 넘기고</strong>, 뒤로가기·딥링크가 중요한지 말할
있게 됐어요. 무엇보다 전부가 여러분이 배운 <strong>react-router와 뿌리</strong>라는
봤죠. 그런데 화면을 오갈 " 화면이 <em>보여줄 데이터</em> 누가, 어떻게
들고 있을까?" 다음은 이야기예요.{' '}
<Link to="/learn/app-state-data"><strong> 상태와 데이터</strong></Link> 코스에서,
화면을 넘나드는 데이터를 다루는 법으로 이어 갑니다.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,386 @@
// : " "
// , · · · · ,
// (React + Spring Boot)
// 7 . "2 "
// " , " .
// : API ' ' . global.css
// (step-card, code-block, tip )
// var(--primary/--teal/--amber/--ink/--line/--card/--muted/--rose) .
// JSX / .
import { Link } from 'react-router-dom';
//
const CODE_TRUST = `보안에서 가장 중요한 질문 하나: "코드가 누구 손에 있나?"
접속할 때마다 우리 서버가 설치하면 파일(APK/IPA)
화면 코드를 '새로' 내려 준다. 사용자 기기 안에 '남는다'.
민감한 로직·비밀은 서버에. 파일은 다운로드·디컴파일·변조 가능.
비유: 웹은 식당에서 그때그때 비유: 앱은 레시피 카드를 인쇄해
차려 내오는 요리(접시는 회수). 나눠 주는 . 나가면 누구나
줄씩 읽고, 복사하고, 고쳐 본다.
그래서 보안의 제1원칙: "클라이언트(앱)는 이미 공격자 손 안에 있다."
안에 넣은 비밀은 비밀이 아니다. 진짜 검사는 언제나 서버가 다시 한다.`;
const CODE_PERMISSION = `// 안드로이드 — AndroidManifest.xml 에 '필요한 권한'을 선언한다
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
// iOS Info.plist ' ' ( /)
<key>NSCameraUsageDescription</key>
<string>과제 사진을 찍어 제출하기 위해 카메라를 사용합니다</string>
// (···) ' '
// , .
// [] [ ] [] [] !
//
// " " .
if (!granted) {
showMessage("카메라 없이도 과제는 파일 업로드로 낼 수 있어요"); //
}`;
const CODE_STORAGE = `// ❌ 평문 저장 — 절대 금지
// SharedPreferences / iOS UserDefaults ''
// / .
prefs.putString("authToken", token); //
// OS ''
// iOS : Keychain (Secure Enclave )
// : Keystore / EncryptedSharedPreferences (TEE )
// , " / " OS .
// (API 릿·DB ) .
// . .`;
const CODE_TLS = `일반 HTTPS(TLS) 인증서 고정(Certificate Pinning)
"공인기관(CA)이 서명한 "나는 '우리 서버의 바로 그 인증서'
인증서면 믿는다" 믿는다" 지문을 앱에 미리 새겨
공용 와이파이 도청은 막는다 가짜 CA·중간자(MITM) 공격까지 막는다
// : .
// ( ). .
// OS http:// :
// iOS : App Transport Security(ATS) HTTPS
// : cleartext (usesCleartextTraffic=false)`;
const CODE_REVIEW = `스토어 심사 — 보안·개인정보 관련 단골 체크 항목
권한 정당화 신청한 권한을 '실제로' 용도로만 쓰는가?
(배경 위치·SMS·연락처·통화기록은 특히 깐깐하게 본다)
데이터 안전 무슨 데이터를 모으는지 신고
(Google Play '데이터 보안' / Apple '앱 개인정보 보호' 라벨)
개인정보처리방침 공개된 URL 필수
통신 암호화 HTTPS 사용(Apple ATS), 평문 전송 금지
악성·기만 없음 숨은 기능·과도한 추적·다운로드형 코드 실행 금지
// ( ).
// ' ' .`;
const CODE_VULN = `입문자가 가장 자주 만드는 앱 취약점 5가지
1) 평문 저장 토큰·비밀번호를 그냥 저장 섹션 3
2) 하드코딩된 비밀 API 키를 코드에 박아 (디컴파일로 유출) 섹션 3
3) 통신 미암호화 http:// (·) 4
4) 과잉 권한 쓰는 카메라·위치까지 요청(신뢰·심사 감점) 섹션 2
5) 클라이언트 과신 "관리자만" 판정을 화면에서만 섹션 1
다섯 개를 관통하는 : "클라이언트를 믿지 마라"
설치된 앱은 공격자 안에 있다. 화면(메뉴·버튼) 숨기는 UX일 ,
진짜 권한 검사·금액 계산·본인 확인은 반드시 서버가 다시 한다.`;
const CODE_OURS = `앱 세계의 수칙 우리 플랫폼이 이미 하는 것 (React + Spring Boot)
최소 권한 RequireAuth·RequireMentor 가드로 '필요한 사람만' 통과.
방화벽 코스: AWS 보안그룹은 필요한 포트만 연다.
비밀은 평문 금지 비밀번호는 서버에서 BCrypt로 해싱해 저장 원문은 없다.
서버가 최종 심판 App.jsx 주석 그대로:
"프론트 가드는 UX용 안내, 진짜 보안은 서버(API)가 담당."
통신은 HTTPS 로그인은 비밀번호를 실어 보낸다 구간 HTTPS가 전제.
(네트워크·HTTP 코스에서 배운 TLS·인증서가 바탕)
로그인 상태 확인 토큰을 화면이 들고 다니지 않고 서버에 물어 확인
(GET /api/auth/me). 앱이라면 토큰을 Keychain/Keystore에.`;
//
// : . props(n·title·sub) .
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '웹과 다른 점' },
{ n: 2, label: '권한 모델' },
{ n: 3, label: '민감정보 저장' },
{ n: 4, label: '통신 보안' },
{ n: 5, label: '스토어 심사' },
{ n: 6, label: '흔한 취약점' },
{ n: 7, label: '우리 플랫폼과 공통점' },
];
export default function AppSecurityPage() {
return (
<div>
{/* 히어로: 웹 보안 감각을 다리 삼아 앱 보안의 지형으로 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1> 보안과 권한<br /> 웹과 무엇이 다른가</h1>
<p>
보안의 원리는 웹과 뿌리가 같아요. 다른 하나 {' '}
<strong>코드가 사용자(그리고 공격자) 기기 안에 설치돼 남는다</strong> 점입니다.
가지 차이에서 권한 모델·저장소 보안·통신 보안·스토어 심사가 전부 갈라져 나와요.
여러분이 이미 배운 방화벽의 최소 권한, HTTP의 TLS, 우리 서버의 해싱을{' '}
<strong>다리 삼아</strong> 보안의 지형을 읽어 봅니다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 75</span>
<span className="chip">실습: 우리 SecurityConfig·App.jsx 가드 읽기</span>
<span className="chip">선수 지식: 방화벽·HTTP 코스</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="앱 보안이 웹과 다른 점" sub="핵심 질문: 코드가 누구 손에 있나?">
<p>
페이지는 접속할 때마다 우리 서버가 화면 코드를 <strong>새로 내려 줍니다.</strong>{' '}
민감한 로직과 비밀은 서버에 두고, 브라우저에는 그때그때 필요한 조각만 보내죠.
브라우저는 코드를 <strong>샌드박스</strong>라는 격리된 안에서만 실행하고요.
</p>
<p>
앱은 다릅니다. 설치하면 파일(<span className="icode">APK</span>·
<span className="icode">IPA</span>) 사용자 기기 안에 <strong>남아요.</strong> 그리고
파일은 다운로드·디컴파일(역분석)·변조가 가능합니다. <strong>여러분이 앱에 넣은
모든 API , 숨긴 화면, 검사 로직 잠재적으로 공격자 손에 있다</strong>
뜻이에요.
</p>
<Code>{CODE_TRUST}</Code>
<svg className="diagram" viewBox="0 0 640 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="신뢰 경계 그림: 웹은 우리 서버가 매번 화면 코드를 내려 주고 브라우저 샌드박스만 사용자 기기에 있지만, 앱은 앱 바이너리 전체가 사용자와 공격자의 기기에 설치되어 남아 뜯어보고 복제할 수 있다">
<line x1="330" y1="64" x2="330" y2="316" stroke="var(--amber)" strokeWidth="2" strokeDasharray="5 4" />
<text x="330" y="52" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--amber)">신뢰 경계</text>
<text x="175" y="52" textAnchor="middle" fontSize="12" fill="var(--muted)">우리가 통제 · 서버</text>
<text x="490" y="52" textAnchor="middle" fontSize="12" fill="var(--muted)">사용자 · 공격자의 기기</text>
<text x="28" y="126" textAnchor="start" fontSize="14" fontWeight="600" fill="var(--ink)"></text>
<rect x="70" y="96" width="150" height="52" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="145" y="118" textAnchor="middle" fontSize="13" fill="var(--ink)">우리 서버</text>
<text x="145" y="136" textAnchor="middle" fontSize="11" fill="var(--muted)">코드·비밀 전부</text>
<line x1="220" y1="122" x2="392" y2="122" stroke="var(--primary)" strokeWidth="2" />
<path d="M400 122 L390 117 L390 127 Z" fill="var(--primary)" />
<text x="306" y="112" textAnchor="middle" fontSize="11" fill="var(--primary)">HTTPS로 조각만</text>
<rect x="400" y="96" width="170" height="52" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="485" y="118" textAnchor="middle" fontSize="13" fill="var(--ink)">브라우저 </text>
<text x="485" y="136" textAnchor="middle" fontSize="11" fill="var(--muted)">샌드박스 · 매번 새로</text>
<text x="28" y="236" textAnchor="start" fontSize="14" fontWeight="600" fill="var(--ink)"></text>
<rect x="70" y="206" width="150" height="52" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="145" y="228" textAnchor="middle" fontSize="13" fill="var(--ink)">우리 서버</text>
<text x="145" y="246" textAnchor="middle" fontSize="11" fill="var(--muted)">API만 제공</text>
<line x1="220" y1="232" x2="392" y2="232" stroke="var(--primary)" strokeWidth="2" />
<path d="M400 232 L390 227 L390 237 Z" fill="var(--primary)" />
<text x="306" y="222" textAnchor="middle" fontSize="11" fill="var(--primary)">HTTPS</text>
<rect x="400" y="206" width="170" height="52" rx="8" fill="var(--card)" stroke="var(--rose)" strokeWidth="2" />
<text x="485" y="228" textAnchor="middle" fontSize="13" fill="var(--ink)"> 바이너리</text>
<text x="485" y="246" textAnchor="middle" fontSize="11" fill="var(--muted)">기기에 설치 · 상주</text>
<text x="485" y="288" textAnchor="middle" fontSize="12" fill="var(--rose)">APK/IPA = 뜯어보고 복제 가능</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 신뢰 경계 웹은 브라우저 샌드박스만 경계 밖에 있지만, 앱은 바이너리 전체가 공격자 영역에 상주한다</p>
<div className="warn">
<b>가장 흔한 착각</b> "앱에 넣었으니 사용자는 못 보겠지" 틀렸어요. 앱은 뜯어볼
있으니 <strong> 안의 비밀은 비밀이 아닙니다.</strong> 그래서 보안의 제1원칙은
"클라이언트를 믿지 마라" 진짜 검사는 항상 서버가 다시 합니다. 우리 플랫폼도
똑같아서, <span className="icode">App.jsx</span> 라우팅 가드는{' '}
<strong>화면 안내(UX)</strong> 뿐이고 진짜 권한 판정은 서버(API) 해요.
</div>
</Section>
<Section n={2} title="권한 모델" sub="카메라·위치… 최소 권한은 방화벽에서 배운 그 원칙">
<p>
웹에서도 위치·카메라를 쓰려면 브라우저가 팝업으로 물어봤죠(
<span className="icode">getUserMedia</span>·위치 권한). 앱은 이걸{' '}
<strong>훨씬 넓게, OS 차원에서</strong> 합니다. 앱은 필요한 권한을 먼저{' '}
<strong>선언</strong>하고(안드로이드 <span className="icode">AndroidManifest.xml</span>,
iOS <span className="icode">Info.plist</span>), 카메라·위치·연락처 같은{' '}
<strong>위험 권한</strong> 실제로 쓰는 순간 사용자에게 다시 물어봐요(런타임 권한).
</p>
<Code>{CODE_PERMISSION}</Code>
<p>
여기서 방화벽 코스의 <strong>최소 권한(least privilege)</strong> 그대로 돌아옵니다.
AWS 보안그룹에서 <strong>필요한 포트만</strong> 열었던 것처럼, 앱도{' '}
<strong>기능이 실제로 필요로 하는 권한만</strong> 요청해야 해요. "언젠가 쓸지도
몰라서" 권한을 미리 몰아 받는 사용자 불신 + 스토어 감점의 지름길입니다.
그리고 사용자가 <strong>거부할 있다</strong> 전제로, 거부 시의 대비책까지
코드에 넣어야 하고요.
</p>
<div className="tip">
<b>연결해서 보기</b> "필요한 것만 연다"{' '}
<Link to="/learn/firewall"><strong>방화벽과 보안 기초</strong></Link> 코스의 핵심과
똑같아요. 포트든 권한이든 데이터베이스 계정이든 <strong> 있는 최소한만
준다</strong> 보안의 뼈대입니다. 권한을 하나 요청할 때마다 "이게 화면에 정말
필요한가?" 스스로 물어보세요.
</div>
</Section>
<Section n={3} title="민감정보 저장" sub="키체인/키스토어 vs 평문 — 열쇠를 어디에 둘까">
<p>
앱은 로그인 토큰이나 설정을 기기에 저장할 때가 많아요. 문제는{' '}
<strong>어디에, 어떻게</strong> 저장하느냐입니다. 안드로이드의{' '}
<span className="icode">SharedPreferences</span> iOS의{' '}
<span className="icode">UserDefaults</span> 토큰을 <strong>평문으로 그냥</strong>{' '}
넣으면, 루팅·탈옥된 기기나 백업 파일에서 통째로 읽힙니다. 열쇠를 현관 매트 밑에 두는
셈이에요.
</p>
<Code>{CODE_STORAGE}</Code>
<p>
그래서 OS는 <strong>하드웨어로 보호되는 금고</strong> 제공합니다. iOS의{' '}
<strong>Keychain</strong>(Secure Enclave), 안드로이드의 <strong>Keystore</strong>
(TEE). 앱조차 원본을 직접 보지 못하고 "이걸로 암호화/복호화만 해 줘"라고 OS에
부탁만 해요. 그리고 <strong>API 시크릿·DB 비밀번호 같은 서버의 비밀은 앱에 넣지
않습니다</strong> 디컴파일하면 문자열이 보이니까요. 비밀은 오직 서버에.
</p>
<div className="tip">
<b>우리 서버가 하는 것과 같은 </b> 우리 백엔드는 비밀번호를{' '}
<strong>절대 평문으로 저장하지 않고</strong> BCrypt로 해싱해서 넣어요(
<span className="icode">SecurityConfig.java</span>{' '}
<span className="icode">BCryptPasswordEncoder</span>). "민감한 값은 읽을 있는
형태로 두지 않는다" 원칙은 서버의 해싱이든 앱의 키스토어든 완전히 똑같습니다.
</div>
</Section>
<Section n={4} title="통신 보안" sub="HTTPS는 기본, 인증서 고정은 한 겹 더">
<p>
기기와 서버 사이를 오가는 데이터는 <strong>암호화</strong>돼야 해요. 여기까진 웹과
완전히 같습니다 HTTP 코스에서 배운 <strong>HTTPS(TLS)</strong> 공용 와이파이의
도청을 막죠. 다행히 요즘 OS는 <strong>기본적으로 평문 http:// </strong>
(iOS의 ATS, 안드로이드의 cleartext 차단). 그래서 앱은 처음부터 HTTPS가 전제예요.
</p>
<Code>{CODE_TLS}</Code>
<p>
앱만의 겹이 <strong>인증서 고정(Certificate Pinning)</strong>이에요. 일반 HTTPS는
"공인기관(CA)이 서명한 인증서면 믿는다"인데, 고정은 <strong>"우리 서버의 바로
인증서만 믿는다"</strong> 지문을 앱에 미리 새겨 둡니다. 그러면 가짜 CA를 동원한
중간자(MITM) 공격까지 막을 있죠. 대신 <strong>서버 인증서를 갱신하면 앱도 같이
업데이트</strong>해야 하는 부담이 있어, 신중히 도입하는 기법입니다.
</p>
<div className="tip">
<b>웹에서 눈으로 확인</b> 우리 플랫폼을 열고 <span className="kbd">F12</span> {' '}
<span className="kbd">Network</span> 탭에서 요청 URL이 <span className="icode">https://</span>
시작하는지 보세요. 로그인은 비밀번호를 실어 보내니 <strong>반드시 HTTPS여야</strong>{' '}
합니다. 앱도 정확히 같은 이유로 평문 통신을 금지해요.
</div>
</Section>
<Section n={5} title="스토어 심사의 보안 기준" sub="웹 배포와 가장 다른 리듬 — 심사를 통과해야 나간다">
<p>
웹은 우리가 서버에 올리면 끝이지만, 앱은 <strong>애플 앱스토어·구글 플레이의 심사</strong>
통과해야 사용자에게 갑니다. 심사에서 보안·개인정보는 비중을 차지해요. 핵심은
"요청한 권한을 정말 그 용도로만 쓰는가", "어떤 데이터를 왜 모으는지 투명하게 밝혔는가"
입니다.
</p>
<Code>{CODE_REVIEW}</Code>
<p>
특히 구글 플레이의 <strong>'데이터 보안'</strong> 섹션과 애플의{' '}
<strong>'앱 개인정보 보호'</strong> 라벨은, 앱이 수집하는 데이터를 사용자가 설치 전에
있게 강제합니다. 개인정보처리방침 URL도 필수고요. 배경 위치·SMS·연락처처럼{' '}
<strong>민감한 권한일수록 정당화가 까다롭습니다.</strong> 여기서도 결론은 섹션 2
같아요 <strong>최소 권한</strong> 심사 통과의 지름길입니다.
</p>
<div className="warn">
<b>배포 리듬이 다르다</b> 웹은 고쳐서 푸시하면 바로 반영되지만, 앱은 거부되면 사유를
받고 <strong>고쳐서 재심사</strong>(며칠 걸릴 있음) 받아야 해요. 게다가 사용자가
업데이트를 하면 <strong>취약한 버전이 기기에 계속 남습니다.</strong> 그래서
"처음부터 최소 권한·HTTPS·안전한 저장"으로 만드는 훨씬 이득이에요.
</div>
</Section>
<Section n={6} title="흔한 앱 취약점" sub="입문자가 반복하는 실수 5가지 — 전부 앞 섹션의 응용">
<p>
지금까지 배운 "이렇게 하면 뚫린다" 관점으로 뒤집어 보면 취약점 목록이 됩니다.
입문자가 가장 자주 만드는 다섯 가지는 대부분 섹션에서 이미 다룬 것들이에요.
(업계에선 <span className="icode">OWASP Mobile Top 10</span>이라는 표준 목록으로
정리해 두고 있어요.)
</p>
<Code>{CODE_VULN}</Code>
<p>
다섯 개를 관통하는 문장이 <strong>"클라이언트를 믿지 마라"</strong>입니다. 다섯 번째
<strong>클라이언트 과신</strong> 특히 무서워요. "관리자 메뉴를 앱에서 숨겼으니
안전하다" 생각하지만, 앱은 뜯어볼 있으니 <strong>숨긴 화면은 언제든 드러납니다.</strong>{' '}
진짜 권한 검사·금액 계산·본인 확인은 <strong>반드시 서버가 다시</strong> 해야 해요.
화면을 감추는 편의(UX) , 보안이 아닙니다.
</p>
<div className="tip">
<b>우리 코드로 이미 경험한 </b> 우리 <span className="icode">App.jsx</span>{' '}
<span className="icode">RequireMentor</span> 가드는 학생이 <span className="icode">/mentor</span>{' '}
주소를 직접 쳐도 돌려보내지만, 파일 주석은 분명히 말해요 "진짜 보안은
서버(API) 담당한다." 바로 이게 "클라이언트를 믿지 마라" 실천이에요.
</div>
</Section>
<Section n={7} title="우리 플랫폼 보안 원칙과의 공통점" sub="새로 배운 게 아니다 — 이미 하던 걸 앱으로 옮길 뿐">
<p>
여기까지 오면 반전이 하나 있어요. 보안의 수칙 대부분을{' '}
<strong>우리 플랫폼이 이미 지키고 있다</strong> 겁니다. 트러스트 경계가
바깥(공격자 기기)으로 밀려났을 , 지켜야 원칙은 웹에서 배운 것과 똑같아요.
</p>
<Code>{CODE_OURS}</Code>
<p>
정리하면 <strong>최소 권한</strong>(방화벽·라우팅 가드),{' '}
<strong>HTTPS</strong>(네트워크 코스의 TLS), <strong>비밀은 평문 금지</strong>(BCrypt
해싱), <strong>서버가 최종 심판</strong>(App.jsx 주석 그대로). 앱에서는 여기에{' '}
<strong>토큰을 Keychain/Keystore에</strong>, <strong>필요하면 인증서 고정</strong>
더해질 뿐이에요. 여러분은 이미 보안의 감각을 코드에서 익힌 셈입니다.
</p>
<div className="tip">
<b>직접 확인해 보기</b> 파일을 나란히 열어 보세요. {' '}
<span className="icode">backend/.../config/SecurityConfig.java</span> 비밀번호가 어떻게
해싱되는지(BCrypt). <span className="icode">frontend/src/App.jsx</span> 라우팅
가드와 "진짜 보안은 서버가 담당한다" 주석. 개가 보안 원칙의{' '}
<strong> 버전 실물 교재</strong>예요.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>🔐 여기까지 왔다면</h3>
<p className="muted">
이제 보안이 웹과 <strong></strong> 다른지 문장으로 말할 있어요
"코드가 사용자 기기에 설치돼 남기 때문". 가지 차이에서 권한 모델·저장소
보안·통신 보안·스토어 심사가 갈라져 나왔고, 뿌리는 결국{' '}
<strong>우리 플랫폼이 이미 지키는 최소 권한·HTTPS·해싱·"서버가 최종 심판" 같다</strong>
것도 확인했습니다. 안전하게 만든 앱은 이제 세상에 내보낼 차례예요 {' '}
<Link to="/learn/app-store-deploy"><strong> 스토어 배포</strong></Link> 코스에서
심사를 통과해 실제 사용자에게 앱을 올리는 길을 따라가 봅시다.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,438 @@
// : " " ' '
// ( · ·) ,
// React useState · ·/·
// · 7 .
// : API ' ' . global.css
// (step-card, code-block, tip ) .
// JSX / .
// : " " React/JS .
import { Link } from 'react-router-dom';
//
const CODE_LAYERS = `앱 데이터의 세 층 — "어디에, 얼마나 오래 사는가"
저장 위치 수명 범위
화면 상태 메모리(RAM) 화면 닫으면 사라짐 화면 하나
useState ( 종료 = 리셋)
기기 로컬 디스크(저장소) 앱을 껐다 켜도 남음 기기 1
AsyncStorage·SQLite (직접 지우기 전까지)
서버 남의 컴퓨터 영구 보관(백업까지) 모든 기기·모든 사용자
Spring + Postgres
고르는 (아래로 질문):
"앱을 껐다 켜도 남아야 하나?" 아니오면 화면 상태
"다른 기기·다른 사람도 봐야 하나?" 예면 서버
애매하면( 폰에서만, 오래) 사이 기기 로컬`;
const CODE_USESTATE = `// 화면 상태 = 이 화면이 '잠깐' 기억하는 값 (웹 React와 100% 동일)
import { useState } from 'react';
function SearchScreen() {
const [keyword, setKeyword] = useState(''); //
const [loading, setLoading] = useState(false); // /
const [tab, setTab] = useState('전체'); //
// : " " .
// · · · on/off · .
}
// : mirim-app DashboardPage useState
// . " " .
// ( <div> <View> , .)`;
const CODE_LOCAL = `// 기기 로컬 저장 — 앱을 껐다 켜도 남는 값 (React Native 기준)
import AsyncStorage from '@react-native-async-storage/async-storage';
// ( - . localStorage )
await AsyncStorage.setItem('theme', 'dark');
// ( )
const theme = await AsyncStorage.getItem('theme'); // 'dark' null
//
await AsyncStorage.removeItem('theme');
// ?
// ··" " AsyncStorage
// ·· SQLite ( DB)
// × · !
// iOS Keychain / Android Keystore ( expo-secure-store)
// AsyncStorage '' .`;
const CODE_SERVER = `// 앱에서 우리 Spring API 호출 — fetch는 앱(React Native)에도 그대로 있어요
async function loadStats(token) {
const res = await fetch(
'https://edu.awesomedevapp.com/api/dashboard/stats', //
{
headers: { Authorization: 'Bearer ' + token }, // ()
}
);
if (!res.ok) throw new Error('요청 실패: ' + res.status);
return await res.json(); // JSON
}
// ? ( )
// '/api/...' ( ). ''
// https:// .
// ''
// (api/client.js withCredentials).
// , Authorization .`;
const CODE_OFFLINE = `오프라인 대응 3단계 — "지하철에서 신호가 끊겨도 앱이 안 죽게"
읽기: 마지막으로 받은 데이터를 로컬(②층) 캐시해 두고,
오프라인이면 그걸 보여 준다. 화면 대신 '방금 전의 목록'.
쓰기: 오프라인에서 '좋아요' 눌렀다면?
화면은 먼저 바꾸고(= 낙관적 UI, optimistic update),
서버로 보낼 요청은 '대기 큐' 차곡차곡 쌓아 둔다.
재연결: 인터넷이 돌아오면 큐에 쌓인 요청을 순서대로 전송한다.
성공하면 큐에서 지우고, 실패하면 잠시 재시도.
충돌(conflict): 같은 글을 폰과 PC에서 동시에 고치면 누가 이기나?
- 마지막에 저장한 쪽이 이김 (last-write-wins) 구현이 간단, 데이터 손실 가능
- 양쪽 변경을 합침 (merge) 안전하지만 어렵다
'정답' 없다 데이터 성격에 맞게 규칙을 미리 정하는 정답이다.`;
const CODE_STATE_LIBS = `상태가 여기저기서 필요해질 때 — '도구의 사다리'
작을 커질
useState Context Zustand / Redux TanStack Query
( 컴포넌트 (로그인 정보처럼 ( '전역' 상태를 (서버 데이터 전용:
안에서만) 여러 화면이 공유) 곳에 모아 관리) 캐시·로딩·재요청 자동)
클라이언트 상태( 화면이 만든 ) 서버 상태(서버가 주인인 )
우리 mirim-app은 지금?
· 화면 하나짜리 상태 useState (거의 모든 페이지)
· 로그인 사용자 정보 React Context (AuthContext) 모든 화면이 공유
아직 Redux 같은 도구는 씁니다. "필요해지기 전엔 안 꺼낸다" 원칙.
핵심: 라이브러리들은 React 웹과 React Native가 '그대로' 공유해요.
앱이라고 새로 배워야 상태관리 도구는 없습니다.`;
const CODE_WEB_VS_APP = `웹(React) vs 앱(React Native) — 데이터 관점에서 같은 것·다른 것
(우리 mirim-app) (React Native)
화면 상태 useState useState (똑같음)
서버 호출 fetch / axios fetch / axios (똑같음)
상태관리 라이브러리 Context·Zustand·Query 그대로 사용 (똑같음)
로컬 저장 localStorage AsyncStorage (이름만 다름)
로그인 유지 쿠키(브라우저가 자동) 토큰 + 보안저장소 (직접 챙김)
API 주소 '/api/...' (같은 출처) 'https://...' 전체 주소
CORS 브라우저가 검사·차단 앱엔 없음(브라우저가 아니라서)
평문 HTTP 브라우저 경고 iOS·Android 기본 차단
결론: '로직' 거의 그대로 옮겨진다. 달라지는 대부분
"저장 위치" "로그인 유지 방법", 가지다.`;
//
// : (props ).
// ReactIntroPage Section/Code .
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '데이터의 세 층' },
{ n: 2, label: '화면 상태' },
{ n: 3, label: '로컬 저장' },
{ n: 4, label: '서버 연동' },
{ n: 5, label: '오프라인·동기화' },
{ n: 6, label: '상태관리 도구' },
{ n: 7, label: '웹과 같은 것·다른 것' },
];
export default function AppStateDataPage() {
return (
<div>
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>앱의 상태와 데이터<br /> 화면·기기·서버, 데이터의 </h1>
<p>
앱이 다루는 데이터는 저마다 사는 집이 달라요. 방금 검색어는 화면이 잠깐
기억하고, 다크모드 설정은 기기에 저장되고, 학습 기록은 서버에 남죠.
셋을 <strong>화면 상태·기기 로컬·서버</strong>라는 층으로 나눠 보면
"이 값은 어디에 둬야 하지?" 훤해집니다. 우리가 이미 배운{' '}
<span className="icode">useState</span> <span className="icode">fetch</span>
다리 삼아, <strong> 개발자의 눈으로 모바일 데이터</strong> 읽어 봅시다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 80</span>
<span className="chip">실습: 분류</span>
<span className="chip">선수 지식: React 입문(useState·fetch)</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="앱 데이터의 세 층" sub="화면 상태 · 기기 로컬 · 서버 — 데이터의 세 집">
<p>
앱을 만들다 보면 데이터마다 "얼마나 오래, 어디까지 살아 있어야 하나"
제각각이에요. 그걸 정리하는 가장 쓸모 있는 지도가 <strong> </strong>입니다.
<strong>화면 상태</strong> 앱을 끄면 사라지는 잠깐의 기억,
가운데 <strong>기기 로컬</strong> 폰에만 오래 남는 저장,
아래 <strong>서버</strong> 모든 기기가 공유하는 영구 보관소예요.
</p>
<Code>{CODE_LAYERS}</Code>
<p>
굳이 나눌까요? 층을 잘못 고르면 바로 사고가 나거든요. 로그인 정보를 화면
상태에만 두면 앱을 껐다 때마다 로그아웃되고, 반대로 스크롤 위치까지 서버에
저장하면 쓸데없는 요청이 폭발합니다. " 값의 <strong>수명</strong>{' '}
<strong>범위</strong> 어디까지인가?" 질문이 층을 결정해요.
</p>
<svg className="diagram" viewBox="0 0 640 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="앱 데이터의 세 층 다이어그램. 맨 위 화면 상태는 메모리에 있고 앱을 끄면 사라진다. 가운데 기기 로컬 저장은 AsyncStorage와 SQLite로 이 기기에만 남는다. 맨 아래 서버는 Spring과 PostgreSQL로 모든 기기가 공유하며 영구 보관된다. 데이터는 아래로 저장되고 위로 읽어 온다.">
{/* 층 3개 박스 */}
<rect x="70" y="36" width="250" height="64" rx="10" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="195" y="66" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)"> 화면 상태 (useState)</text>
<text x="195" y="88" textAnchor="middle" fontSize="12" fill="var(--muted)">메모리(RAM)</text>
<rect x="70" y="138" width="250" height="64" rx="10" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="195" y="168" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)"> 기기 로컬 저장</text>
<text x="195" y="190" textAnchor="middle" fontSize="12" fill="var(--muted)">AsyncStorage · SQLite · 디스크</text>
<rect x="70" y="240" width="250" height="64" rx="10" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="195" y="270" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)"> 서버</text>
<text x="195" y="292" textAnchor="middle" fontSize="12" fill="var(--muted)">Spring API · PostgreSQL</text>
{/* 층 사이 화살표 (①↔②) */}
<line x1="150" y1="102" x2="150" y2="130" stroke="var(--muted)" strokeWidth="2" />
<path d="M150 136 L144 125 L156 125 Z" fill="var(--muted)" />
<line x1="240" y1="136" x2="240" y2="108" stroke="var(--muted)" strokeWidth="2" />
<path d="M240 102 L234 113 L246 113 Z" fill="var(--muted)" />
<text x="142" y="124" textAnchor="end" fontSize="11" fill="var(--teal)">저장</text>
<text x="248" y="124" textAnchor="start" fontSize="11" fill="var(--primary)">읽기</text>
{/* 층 사이 화살표 (②↔③) */}
<line x1="150" y1="204" x2="150" y2="232" stroke="var(--muted)" strokeWidth="2" />
<path d="M150 238 L144 227 L156 227 Z" fill="var(--muted)" />
<line x1="240" y1="238" x2="240" y2="210" stroke="var(--muted)" strokeWidth="2" />
<path d="M240 204 L234 215 L246 215 Z" fill="var(--muted)" />
{/* 오른쪽 범위/수명 설명 */}
<text x="340" y="72" textAnchor="start" fontSize="12.5" fill="var(--muted)">앱을 끄면 사라짐</text>
<text x="340" y="174" textAnchor="start" fontSize="12.5" fill="var(--muted)"> 기기에만 · 재시작해도 남음</text>
<text x="340" y="276" textAnchor="start" fontSize="12.5" fill="var(--muted)">모든 기기 공유 · 영구 보관</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 위로 갈수록 잠깐 살고(화면 상태), 아래로 갈수록 오래·널리 공유된다 데이터는 아래로 '저장'되고 위로 '읽어' 온다</p>
<div className="tip">
<b> 판별 연습</b> 다음 값들은 층일까요? () 지금 누른 ,
() "알림 켜기" 설정, () 프로필 사진. 정답: ()=①화면 상태,
()=②기기 로컬, ()=③서버(다른 기기에서도 같은 사진이 보여야 하니까).
감각이 코스 전체의 뼈대예요.
</div>
</Section>
<Section n={2} title="화면 상태" sub="우리가 배운 useState가 앱에서도 똑같이 산다">
<p>
가장 위층 <strong>화면 상태</strong> 사실 새로 배울 없어요. React 입문에서
만난 <span className="icode">useState</span> 그대로거든요. 검색창 글자, 스피너
on/off, 지금 열린 "화면을 나가면 없어져도 되는" 값이면 전부 화면 상태이고,
<span className="icode"> useState</span> 관리합니다.
</p>
<Code>{CODE_USESTATE}</Code>
<p>
웹에서든 앱에서든 규칙은 똑같아요. 값을 직접 고치지 말고 반드시{' '}
<span className="icode">set</span> 함수를 부를 그래야 React가 "상태가
바뀌었으니 화면을 다시 그리자"를 알아챕니다. 섹션 1의 "화면 = 상태의 함수"
모바일에서도 그대로 성립하는 거죠. 바뀌는 화면을 그리는 태그뿐이에요:
웹의 <span className="icode">&lt;div&gt;</span>·<span className="icode">&lt;p&gt;</span>
React Native에선 <span className="icode">&lt;View&gt;</span>·<span className="icode">&lt;Text&gt;</span> 바뀝니다.
</p>
<div className="tip">
<b>이미 실력이 있다는 신호</b> "앱의 화면 상태"라는 말이 어렵게 들렸다면, 방금
그게 그냥 <span className="icode">useState</span>였다는 확인하세요. 웹에서
쌓은 React 실력의 <strong>절반 이상이 그대로 앱으로 건너갑니다.</strong> 이게
코스가 "웹 개발자가 보는 모바일" 표방하는 이유예요.
</div>
</Section>
<Section n={3} title="로컬 저장" sub="AsyncStorage · SQLite · 앱 캐시 — 이 기기에 남기기">
<p>
"앱을 껐다 켜도 남아야 하지만, 서버까지 갈 필요는 없는" 값들이 가운데 층에
삽니다. 대표 도구가 <strong>AsyncStorage</strong>예요. 웹의{' '}
<span className="icode">localStorage</span> 봤다면 사고방식이 똑같습니다
<strong> 문자열 - 창고</strong>. "이름표(key) (value) 적어 서랍에
넣어 둔다" 생각하면 돼요.
</p>
<Code>{CODE_LOCAL}</Code>
<p>
저장할 <strong>구조가 복잡하거나 양이 많으면</strong> AsyncStorage로는
벅차요(전부 문자열이라 검색·정렬이 힘드니까). 그럴 {' '}
<strong>SQLite</strong> 기기 안에 들어 있는 작은 관계형 데이터베이스를 씁니다.
우리가 배운 <span className="icode">SELECT ... WHERE</span> 같은 SQL을 안에서
그대로 있어요. 채팅 앱이 비행기 모드에서도 지난 대화를 보여 주는 보통
방식입니다. 여기에 OS가 알아서 관리하는 <strong> 캐시</strong>(이미지·임시
파일 저장 공간, 기기 설정에서 "캐시 지우기" 비워지는 그것)까지 더하면 로컬
저장의 그림이 완성됩니다.
</p>
<div className="warn">
<b>민감정보는 로컬 평문 금지</b> AsyncStorage와 SQLite는 기본적으로{' '}
<strong>암호화되지 않은 평문</strong>이에요. 로그인 토큰·비밀번호를 여기 그냥
두면, 기기를 잃어버렸을 통째로 새어 나갑니다. 민감정보는 반드시 OS의 보안
저장소 <span className="icode">iOS Keychain</span> /{' '}
<span className="icode">Android Keystore</span>(또는{' '}
<span className="icode">expo-secure-store</span>) 넣으세요. 이건 우리{' '}
<Link to="/learn/firewall">방화벽·보안</Link> 코스의 "민감한 건 안전한 곳에"
원칙이 모바일로 그대로 이어지는 지점이에요.
</div>
</Section>
<Section n={4} title="서버 연동" sub="우리 Spring API를 앱에서 부르기 — fetch는 그대로다">
<p>
아래층, <strong>서버</strong> 모든 기기가 공유하는 진짜 데이터 창고예요.
우리 회사 백엔드는 <strong>Spring Boot + PostgreSQL</strong>이고, 프론트는
이미 <span className="icode">fetch('/api/...')</span> API를 부르고 있죠
(React 입문의 DashboardPage 예제 기억나죠?). 좋은 소식은,{' '}
<strong>앱에서도 fetch가 그대로 있다</strong> 겁니다. React Native에 fetch가
내장돼 있어요.
</p>
<Code>{CODE_SERVER}</Code>
<p>
그러니 "서버에서 데이터를 받아 <span className="icode">useState</span> 담고
화면을 다시 그린다"는 흐름 — React 입문의 <span className="icode">useEffect</span> +
fetch 패턴 앱에서도 판박이예요. 달라지는 가지: 주소를 전체 URL로
적는 , 그리고 로그인 유지 방법입니다. 우리 웹은{' '}
<span className="icode">api/client.js</span>{' '}
<span className="icode">withCredentials</span> <strong>쿠키</strong> 자동
첨부하지만, 앱은 브라우저가 아니라 쿠키 자동첨부가 없어요. 그래서 로그인 받은{' '}
<strong>토큰</strong> 보안 저장소(섹션 3) 넣어 두고, 요청마다{' '}
<span className="icode">Authorization</span> 헤더에 직접 실어 보냅니다.
</p>
<div className="tip">
<b>개발할 'localhost' 함정</b> (또는 에뮬레이터)에게 "localhost"{' '}
<strong> PC가 아니라 자신</strong> 뜻해요. 그래서 개발 중엔 안드로이드
에뮬레이터는 <span className="icode">10.0.2.2</span>, 실제 기기는 PC의 사설
IP(: <span className="icode">192.168.x.x</span>) 백엔드에 접속합니다.
"웹에선 됐는데 앱에선 서버 연결이 안 돼요" 8할이 주소 문제예요.
</div>
</Section>
<Section n={5} title="오프라인과 동기화" sub="신호가 끊겨도 죽지 않는 앱 — 개념만 잡기">
<p>
웹과 앱의 가장 체감 차이가 여기예요. 웹은 대개 "인터넷이 있다" 가정하지만,
앱은 <strong>지하철·엘리베이터·비행기 모드</strong>에서도 손에 들려 있죠. 그래서
좋은 앱은 "서버에 못 닿을 때" 미리 설계합니다. 완벽히 구현하는 어렵지만,
<strong>개념의 뼈대</strong> 셋으로 단순해요: 읽기 캐시, 쓰기 , 그리고
재연결 동기화.
</p>
<Code>{CODE_OFFLINE}</Code>
<p>
핵심 아이디어는 <strong>낙관적 UI(optimistic update)</strong>예요. 서버 응답을
기다렸다가 화면을 바꾸면 느리고 답답하니, "어차피 성공하겠지"하고{' '}
<strong>화면부터 먼저 바꾸고</strong> 요청은 뒤에서 처리하는 거죠. 만약 나중에
실패하면 화면을 되돌립니다(rollback). 그리고 오프라인 동안 쌓인 변경들을 다시
연결됐을 순서대로 흘려보내는 <strong>동기화(sync)</strong>예요. 과정에서
같은 데이터를 기기가 동시에 고치는 <strong>충돌</strong> 생기는데, 여기엔
만능 정답이 없다는 아는 자체가 중요합니다.
</p>
<div className="warn">
<b>여기서 눈높이 맞추기</b> 2개월 만에 오프라인 동기화를 완성하는 목표가
아니에요. " 앱은 오프라인을 신경 써야 하고, 낙관적 UI·대기 ·충돌이라는
<strong> 지형</strong> 있다" 아는 목표입니다. 실무에선 이걸 직접
짜기보다 <strong>TanStack Query·WatermelonDB</strong> 같은 도구의 오프라인
기능에 기대는 경우가 많아요 바퀴를 다시 발명하지 않는 것도 실력이니까요.
</div>
</Section>
<Section n={6} title="상태관리 라이브러리 개요" sub="useState → Context → Zustand/Redux → TanStack Query">
<p>
앱이 커지면 "이 상태를 저 멀리 있는 화면도 알아야 하는" 순간이 와요. 로그인
사용자 정보처럼요. 그때마다 부모에서 자식으로 props를 단계씩 넘기는
고통스럽죠(이걸 <span className="icode">props drilling</span>이라 불러요). 그래서
상태를 넓게 공유하는 <strong>도구의 사다리</strong> 있습니다.
</p>
<Code>{CODE_STATE_LIBS}</Code>
<p>
여기서 중요한 구분 하나. <strong>클라이언트 상태</strong>( 화면이 만든 :
열린 , 입력 중인 ) <strong>서버 상태</strong>(서버가 주인인 : 코스 목록,
프로필) 성격이 달라요. 서버 상태는 "캐시해 두고, 낡으면 다시 받아오고,
로딩·에러를 관리하는" 일이 반복돼서, 그걸 전문으로 주는{' '}
<strong>TanStack Query</strong> 같은 도구를 씁니다. 반면 순수 클라이언트 상태는{' '}
<strong>Zustand·Redux</strong> 쪽이죠. 우리 mirim-app은 아직 도구들
없이 <span className="icode">useState</span> Context(<span className="icode">AuthContext</span>)만으로
충분한 규모예요.
</p>
<div className="tip">
<b>우리 코드에서 확인</b> 에디터로{' '}
<span className="icode">frontend/src/AuthContext.jsx</span> 열어 보세요.
로그인한 사용자 정보를 <strong>Context</strong> 전체에 뿌리고, 화면은{' '}
<span className="icode">useAuth()</span> 꺼내 씁니다. "props를 층층이 넘기지
않고 필요한 곳에서 바로 꺼내 쓰는" 상태관리의 가장 작은 형태를, 이미 우리 앱이
쓰고 있는 거예요. 개념도 <strong>웹과 앱이 100% 공유</strong>합니다.
</div>
</Section>
<Section n={7} title="웹과 같은 것 · 다른 것" sub="옮겨지는 것 vs 새로 챙길 것 — 지형 정리">
<p>
마지막으로 오늘 배운 "웹 지식 대비 얼마나 옮겨지나" 정리해 봅시다. 결론부터
말하면 <strong>대부분 그대로 옮겨져요.</strong> 화면 상태, 서버 호출, 상태관리
라이브러리는 웹과 앱이 <strong>같은 도구·같은 문법</strong> 씁니다. 진짜로
달라지는 "데이터를 <strong>어디에</strong> 저장하나" "로그인을{' '}
<strong>어떻게</strong> 유지하나" 정도예요.
</p>
<Code>{CODE_WEB_VS_APP}</Code>
<p>
그래서 "2개월 만에 네이티브 앱 전문가" 과장이지만, "웹에서 쌓은 실력으로 앱의
지형을 읽고, 손으로 앱까지 가는 길을 아는" 것은 충분히 현실적인 목표입니다.
오늘 세운 <strong> 지도</strong> 하나면, 앞으로 어떤 튜토리얼을 봐도
"아, 이건 화면 상태 얘기구나 / 이건 로컬 저장이네 / 이건 서버 연동이구나" 하고
제자리에 꽂아 넣을 있어요.
</p>
<div className="tip">
<b>미니 실습</b> 여러분이 매일 쓰는 하나(인스타그램·카카오톡·유튜브 )
떠올려, 화면에 보이는 데이터 5개를 골라 층으로 분류해 보세요. : 좋아요 (
서버), 다크모드 설정( 로컬), 지금 보고 있는 ( 화면 상태). 정답을 맞히는
것보다 "왜 그 층인지" 줄로 말할 있는 핵심입니다. 멘토에게 표를
공유하면 좋은 대화가 시작돼요.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>📱 여기까지 왔다면</h3>
<p className="muted">
이제 앱의 데이터를 <strong>화면 상태·기기 로컬·서버</strong> 층으로 나눠
보는 눈이 생겼어요. 그리고 대부분이 우리가 웹에서 이미 배운{' '}
<span className="icode">useState</span>·<span className="icode">fetch</span>·Context
그대로라는 것도요. 남은 "저장 위치" "로그인 유지"라는 갈래의 차이뿐.
다음은 지식을 가장 적은 비용으로 실전에 보는 {' '}
<Link to="/learn/pwa"><strong>PWA(설치되는 웹앱)</strong></Link> 코스로
이어집니다. 우리가 만든 React 웹을 <strong>설치·오프라인 지원</strong>까지
갖춘 '앱처럼' 만드는, 개발자에게 가장 가까운 번째 앱을 만나 보세요.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,425 @@
// : " "
// ( Google Play App Store )
// . " "
// " (Caddy·Docker) " .
// : API ' ' . global.css
// (step-card, code-block, tip )
// var(--primary/--teal/--amber/--ink/--line/--card/--muted/--rose) .
// JSX / .
import { Link } from 'react-router-dom';
//
const CODE_PREP = `배포 전 체크리스트 — "코드가 다 됐다"는 끝이 아니라 시작
아이콘 여러 해상도의 정사각 이미지
- Android: 적응형 아이콘(앞면+배경 ) + 512×512 Play 아이콘
- iOS: 1024×1024 App Store 아이콘 (투명·둥근모서리 X 애플이 알아서 깎음)
스크린샷 실제 화면 캡처를 기기 크기별로
- Play: 최소 2 (/태블릿)
- App Store: 아이폰·아이패드 크기별로 각각
스토어 설명 이름 · 짧은 설명 · 자세한 설명 · 카테고리 · 연령 등급
개인정보 처리방침 URL 필수! (데이터를 조금이라도 모으면 반드시)
- Play: '데이터 보안(Data safety)' 설문 작성
- Apple: 'App 개인정보 보호'(영양성분표처럼 생긴 라벨)
비유: = 상품, 스토어 = 백화점 매대.
물건만 좋다고 진열되진 않아요 사진·설명·안전정보까지 갖춰야 매대에 오릅니다.`;
const CODE_SIGNING = `앱 서명 = 앱에 찍는 '인감도장' — 누가 만들었나 + 중간에 변조 안 됐나
필요할까?
1) 출처 증명 "이 앱은 진짜 그 개발자가 냈다"
2) 위변조 탐지 누가 코드를 몰래 바꾸면 도장이 깨져서 설치가 막힘
3) 업데이트 연결 버전도 '같은 도장'이어야 기존 앱을 덮어쓸 있음
# Android keystore(도장) 만들기 (keytool은 JDK에 딸려 )
keytool -genkeypair -v -keystore mirim-release.keystore -alias mirim -keyalg RSA -keysize 2048 -validity 10000
# keystore로 번들(AAB) 서명해서 업로드합니다.
# keystore 파일과 비밀번호를 잃어버리면? 같은 앱을 업데이트할 없어요!
# (Play App Signing을 켜면 구글이 '최종 서명키' 대신 보관 한결 안전)
// iOS
// (Certificate) + (Provisioning Profile)
// , Xcode .`;
const CODE_PLAY_FLOW = `Google Play 등록 흐름 — Play Console에서 차례로
0) 개발자 계정 등록 1 $25 (평생, 갱신 없음)
1) 만들기 이름 · 기본 언어 · 무료/유료
2) 스토어 등록정보 아이콘 · 스크린샷 · 설명 (섹션 1에서 준비한 )
3) 콘텐츠 등급 설문지에 답하면 연령 등급이 자동 산정
4) 데이터 보안 어떤 개인정보를 모으는지 신고
5) 번들 업로드 .aab (Android App Bundle) 파일
2021 8월부터 '새 앱' AAB 필수!
구글이 기기마다 맞는 APK를 만들어 배달해요
6) 테스트 트랙 내부 비공개 공개 테스트로 단계별 검증
7) 프로덕션 출시 검토를 통과하면 스토어에 공개
APK vs AAB: APK = 완성된 설치 파일 '한 덩어리'.
AAB = '재료 묶음' 구글이 기기별 최적 APK로 재조립(용량, 다운로드).`;
const CODE_APP_STORE_FLOW = `App Store 등록 흐름 — App Store Connect + Xcode
0) Apple Developer Program 가입 $99 (매년 갱신)
1) App Store Connect에서 생성 번들 ID · 이름
2) Xcode로 빌드 업로드 (또는 Transporter 앱으로)
3) TestFlight 베타 테스트 내부/외부 테스터에게 먼저 배포
4) 메타데이터 입력 스크린샷 · 설명 · 키워드 · 개인정보 라벨
5) 심사(App Review) 제출 사람이 직접 앱을 실행해 검사!
6) 승인 출시 수동 출시 or 승인 즉시 자동 출시
Play보다 대체로 오래, 깐깐합니다. ?
애플은 '앱은 App Store로만 설치'하는 닫힌 생태계라,
품질·보안·프라이버시를 사람이 문지기처럼 직접 지키기 때문이에요.`;
const CODE_REJECT = `심사에서 자주 미끄러지는 이유 (특히 App Store)
1) 크래시·버그 리뷰어 기기에서 앱이 죽음 바로 리젝
폰이 아니라 여러 실기기에서 충분히 테스트하고 제출!
2) 정보 부실 로그인 필요한 앱인데 '데모 계정' /
스크린샷이 실제 화면과 딴판
3) 개인정보 처리방침 URL이 없거나 깨졌거나 내용이 부실
4) 미완성·빈약 "웹사이트를 그냥 감싸기만"
(Apple 가이드 4.2 '최소한의 기능' 위반)
5) 결제 우회 디지털 상품을 애플 결제(IAP) 거치고 팔기
(Apple 3.1.1)
6) 권한 남용 쓰는 위치·연락처 권한을 이유 없이 요구
7) 사칭·저작권 남의 브랜드/아이콘 도용, 똑같은 도배(스팸)
교훈: 리젝은 '탈락' 아니라 '보완 요청서'예요. 사유를 읽고 고쳐 재제출하면 됩니다.`;
const CODE_VERSION = `버전은 숫자 두 개로 관리해요 — 사람용 '이름' + 기계용 '번호'
사람에게 보이는 이름 스토어가 세는 번호(내부)
Android versionName "1.2.0" versionCode 34 (정수, 올릴 때마다 +1)
iOS CFBundleShortVersion "1.2.0" 빌드 번호 34
// build.gradle (Android)
android {
defaultConfig {
versionCode 34 // ''
versionName "1.2.0" //
}
}
시맨틱 버저닝(SemVer) MAJOR.MINOR.PATCH
1 . 2 . 0
PATCH: 버그 수정 (호환 O)
MINOR: 기능 추가 (호환 O)
MAJOR: 변경 (호환이 깨질 있음)
스토어는 웹처럼 '즉시 롤백' 어려워요. 잘못 버전을 되돌리려면
대개 고친 버전을 다시 올려 심사를 받아야 합니다. 올리기 전에 테스트!`;
const CODE_COMPARE = `우리 웹 배포 vs 앱 스토어 배포 — 결정적 차이
우리 (React+Spring) 모바일
배포 방법 git push 빌드 Docker 빌드+서명 스토어 업로드
서버 Caddy(HTTPS)
문지기(심사) 없음 (내가 결정권) 있음 (구글·애플이 검사)
반영 속도 배포 즉시 = 전원 최신 심사 대기 + 사용자가 설치해야
버전 파편화 URL 하나 모두 같은 버전 기기마다 설치된 버전이 제각각
되돌리기 이전 이미지로 즉시 롤백 버전 올려 다시 심사(느림)
비용 서버 비용 Play $25(1) · Apple $99(매년)
요약: 웹은 '내 서버, 내 마음' 앱은 '남의 백화점 매대'.
그래서 앱엔 준비·서명·심사라는 관문이 하나 붙는 거예요.`;
// ( + )
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '배포 전 준비' },
{ n: 2, label: '앱 서명' },
{ n: 3, label: 'Google Play' },
{ n: 4, label: 'App Store 심사' },
{ n: 5, label: '흔한 리젝' },
{ n: 6, label: '버전 관리' },
{ n: 7, label: '웹 배포와 비교' },
];
export default function AppStoreDeployPage() {
return (
<div>
{/* 히어로: 코드가 끝난 지점에서 '세상에 내놓기'까지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>스토어 배포<br /> 앱을 세상에 내놓기</h1>
<p>
코드가 돌아간다고 끝이 아니에요. 앱은 <strong>구글·애플이라는 문지기</strong> 지키는
백화점 매대(스토어) 올라가야 사람들 손에 닿습니다. 준비물부터 서명·등록·심사까지,
<strong> 우리 배포(Caddy·Docker) 나란히 놓고</strong> 무엇이 같고 무엇이 다른지
지형을 읽어 봅니다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 70</span>
<span className="chip">실습: 스토어 등록정보 초안</span>
<span className="chip">선수 지식: 앱의 지형 · React 기초</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="배포 전 준비" sub="아이콘·스크린샷·설명·개인정보 처리방침">
<p>
개발자 지망생이 가장 자주 놀라는 지점이에요. "코드 다 짰는데 왜 아직 출시가 안 되지?"
스토어는 코드만 받는 곳이 아니라 <strong>상품 매대</strong>라서, 물건() 함께
<strong> 진열용 재료</strong> 요구합니다. 아이콘, 스크린샷, 설명 , 그리고
가장 자주 빠뜨리는 <strong>개인정보 처리방침</strong>까지요.
</p>
<Code>{CODE_PREP}</Code>
<p>
<strong>개인정보 처리방침(Privacy Policy)</strong> 선택이 아니라 사실상
의무예요. 로그인, 광고 ID, 위치, 심지어 에러 로그만 모아도 "개인정보 수집"
해당할 있고, 그때는 <span className="icode">https://...</span>
<strong> 공개 URL</strong> 방침을 올려 두고 스토어에 주소를 등록해야 합니다.
우리 <Link to="/learn/wifi-security"><strong>보안 기초</strong></Link> 코스에서 배운
"데이터는 함부로 모으지도, 흘리지도 않는다" 원칙이 스토어 규칙으로 강제되는 셈이죠.
</p>
<div className="tip">
<b>지금 </b> 우리 학습 플랫폼 화면 장을 골라 스토어 등록정보처럼 보세요.
이름 , 짧은 설명(30 이내) , 자세한 설명 3~4문장.
막상 보면 "이 앱이 사용자에게 무엇을 주는가" 문장으로 말한다는
깨닫게 되는데 깨달음이 실습의 진짜 목적이에요.
</div>
</Section>
<Section n={2} title="앱 서명" sub="키스토어·인증서 — 앱에 찍는 '인감도장'">
<p>
웹에선 그냥 서버에 파일을 올리면 끝이지만, 앱은 설치 파일에 반드시
<strong> 디지털 서명(signing)</strong> 해야 해요. 서명은 앱에 찍는
<strong> 인감도장</strong>이라고 생각하면 맞습니다. 도장은 가지를 보장해요:
누가 만들었는지(출처), 중간에 변조되지 않았는지(무결성), 그리고
<strong> 나중에 올릴 업데이트가 같은 사람 것인지</strong>(연속성).
</p>
<Code>{CODE_SIGNING}</Code>
<p>
안드로이드에선 <span className="icode">keystore</span>라는 파일이 도장이고,
iOS에선 애플이 발급하는 <strong>인증서 + 프로비저닝 프로파일</strong> 역할을
합니다. 특히 안드로이드에서 <strong>keystore와 비밀번호는 목숨처럼 지켜야</strong> 해요
잃어버리면 "같은 도장" 다시 만들 없어서, 사용자들이 쓰고 있는 앱을
영영 업데이트하지 못하게 됩니다.
</p>
<div className="warn">
<b> 하필 도장이 하나여야 할까</b> 만약 아무 도장으로나 업데이트를 허용하면,
공격자가 "같은 이름의 가짜 새 버전" 만들어 사용자 폰의 진짜 앱을 덮어쓸 있어요.
"같은 도장만 덮어쓰기 가능"이라는 규칙이 그걸 막는 자물쇠입니다. 그래서
<span className="icode"> keystore</span> 소스코드가 아니라 비밀(secret) 다뤄
Git에 절대 올리지 않아요 우리 백엔드가 <span className="icode">JWT_SECRET</span>
코드가 아니라 <span className="icode">.env</span> 다루는 것과 똑같은 이치예요.
</div>
</Section>
<Section n={3} title="Google Play 등록 흐름" sub="개발자 계정 → AAB 업로드 → 테스트 → 출시">
<p>
준비물과 도장이 갖춰졌으면 이제 매대에 올릴 차례예요. 안드로이드는
<strong> Google Play Console</strong>이라는 관리 화면에서 진행합니다.
번만 내는 <strong>$25</strong> 개발자 계정을 열고 나면, 아래 순서대로
빈칸을 채워 나가는 방식이에요.
</p>
<Code>{CODE_PLAY_FLOW}</Code>
<p>
여기서 개발자가 짚고 개념이 <strong>AAB(Android App Bundle)</strong>예요.
예전엔 <span className="icode">.apk</span> 덩어리를 올렸지만, 2021년부터 앱은
<span className="icode"> .aab</span> 올립니다. AAB는 '완성품' 아니라
<strong> 재료 묶음</strong>이라, 구글이 사용자의 기기(화면 크기·CPU 종류·언어) 맞춰
필요한 부분만 골라 APK를 재조립해 배달해요. 덕분에 다운로드 용량이 줄죠.
</p>
<div className="tip">
<b>테스트 트랙이라는 안전벨트</b> Play는 곧바로 세계에 뿌리기 전에
<strong> 내부 비공개 공개 테스트</strong> 단계를 줍니다. 우리 백엔드에
<Link to="/learn/cicd"><strong>CI/CD</strong></Link> 스테이징 환경이 있는 것과 같은
발상이에요 "진짜 사용자에게 가기 전에, 좁은 무대에서 먼저 확인한다."
</div>
</Section>
<Section n={4} title="App Store 등록·심사" sub="왜 애플이 더 엄격한가">
<p>
iOS는 <strong>App Store Connect</strong>( 관리 화면) <strong>Xcode</strong>( 전용
개발 도구) 오가며 올립니다. <strong>$99</strong> 개발자 프로그램에 가입하고,
빌드를 업로드한 , <strong>TestFlight</strong> 베타 테스트를 거쳐 심사에 제출해요.
흐름은 Play와 비슷하지만, 결정적으로 다른 가지가 있습니다.
</p>
<Code>{CODE_APP_STORE_FLOW}</Code>
<p>
바로 <strong>사람이 직접 심사</strong>한다는 점이에요. 애플 리뷰어가 실제로 앱을
내려받아 실행해 보고, 가이드라인(App Review Guidelines) 맞는지 하나하나 확인합니다.
그래서 Play보다 <strong> 오래 걸리고 깐깐</strong>해요. 이유는 애플의 생태계 구조에
있습니다 iOS 앱은 원칙적으로 <strong>App Store를 통해서만 설치</strong>되는
닫힌 정원이라, 애플이 품질·보안·프라이버시의 문지기 역할을 스스로 떠안는 거예요.
</p>
<div className="tip">
<b>닫힌 정원 vs 열린 마당</b> 안드로이드는 스토어 밖에서도 설치(사이드로딩)
비교적 자유로운 '열린 마당'이고, iOS는 문이 하나뿐인 '닫힌 정원'이에요.
문이 하나라 통제가 강한 대신, (심사) 통과하는 문턱도 높습니다.
어느 쪽이 옳다기보다 <strong>철학이 다른 </strong> 개발자는 세계의 규칙을
모두 존중해야 하죠.
</div>
</Section>
<Section n={5} title="심사 리젝, 흔한 사유" sub="리젝은 탈락이 아니라 '보완 요청서'">
<p>
제출이 번에 통과되는 일은 흔치 않아요. 특히 App Store는 리젝(반려)
자주 일어나는데, 다행히 사유는 대체로 <strong>정해진 가지 패턴</strong>이에요.
미리 알면 대부분 피할 있습니다.
</p>
<Code>{CODE_REJECT}</Code>
<p>
초보가 가장 많이 걸리는 4, <strong>"웹사이트를 그냥 감싸기만 한 앱"</strong>
이에요. 애플은 이런 앱을 '최소한의 기능(4.2)' 미친다고 봅니다 브라우저로
사이트에 가면 되는데 굳이 앱일 이유가 없다는 거죠. 이건 우리가
<Link to="/learn/pwa"><strong>PWA</strong></Link> 코스에서 다룬 경계와도 맞닿아 있어요:
"웹을 앱처럼 쓰고 싶다" 스토어 심사를 통과하려 억지 껍데기를 씌우기보다,
애초에 PWA로 가는 정직한 선택일 때가 많습니다.
</p>
<div className="warn">
<b>리젝을 대하는 태도</b> 리젝 사유서는 채점표가 아니라 <strong>수정 가이드</strong>예요.
"우리 앱은 이래서 반려됐고, 이렇게 고치면 된다" 담담히 읽고, 고쳐서 재제출하면 됩니다.
이건 우리가 <Link to="/learn/debugging"><strong>디버깅</strong></Link>에서 배운 자세와
똑같아요 <strong>에러 메시지는 적이 아니라 단서</strong>.
</div>
</Section>
<Section n={6} title="업데이트·버전 관리" sub="사람용 이름 하나, 기계용 번호 하나">
<p>
앱은 내고 끝이 아니라 계속 고쳐 올리는 물건이에요. 그래서 <strong>버전</strong>
체계적으로 붙여야 합니다. 핵심은 숫자를 <strong> 종류</strong> 관리한다는
하나는 사용자에게 보여 주는 <strong>이름</strong>(: 1.2.0), 다른 하나는 스토어가
"이게 더 새 거야?" 판단하는 <strong>내부 번호</strong>예요.
</p>
<Code>{CODE_VERSION}</Code>
<p>
사람용 이름은 <strong>시맨틱 버저닝(SemVer)</strong> 규칙을 따르는 관례예요.
<span className="icode"> MAJOR.MINOR.PATCH</span> 버그만 고치면 PATCH를,
기능을 더하면 가운데 MINOR를, 크게 갈아엎어 호환이 깨지면 MAJOR를 올립니다.
우리 백엔드 라이브러리(: <span className="icode">Spring Boot 4.0.7</span>) 버전도
정확히 규칙으로 붙어 있어요 낯설어 보이던 숫자가 사실은 약속이었던 거죠.
</p>
<div className="warn">
<b>웹과 가장 다른 지점, 롤백</b> 우리 웹은 문제가 생기면 이전 Docker 이미지로
<strong> 만에 되돌립니다</strong>. 하지만 스토어에 잘못 올린 버전은 그렇게
즉시 물려요 대개 <strong>고친 버전을 다시 올려 심사를 받아야</strong> 합니다.
그래서 세계에선 "올리기 전 테스트" 무게가 웹보다 훨씬 무거워요.
<strong> 단계적 출시(staged rollout)</strong> 5% 20% 100%처럼 조금씩 푸는 이유도
바로 되돌리기의 어려움 때문입니다.
</div>
</Section>
<Section n={7} title="우리 웹 배포와 비교하며 마무리" sub="Caddy·Docker vs 스토어 — 지형을 한눈에">
<p>
마지막으로 코스 전체를, 우리가 실제로 쓰는 <strong> 배포</strong> 나란히 놓아
봅시다. 우리 플랫폼은 코드를 <span className="icode">git push</span>하면 빌드되어
<strong> Docker</strong> 이미지로 서버에 올라가고, <strong>Caddy</strong> HTTPS로
바깥에 내보내죠. 여기엔 <strong>문지기(심사) 없어요</strong> 배포하는 순간
모든 사용자가 즉시 같은 최신 버전을 봅니다.
</p>
<Code>{CODE_COMPARE}</Code>
<svg className="diagram" viewBox="0 0 640 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="우리 웹 배포와 앱 스토어 배포의 파이프라인 비교. 왼쪽 웹은 git push부터 Caddy HTTPS까지 문지기 없이 흘러 전원이 즉시 최신이 되고, 오른쪽 앱은 빌드와 서명 뒤 스토어 업로드, 심사라는 게이트를 통과해 승인된 다음에도 사용자가 직접 설치해야 하므로 기기마다 버전이 제각각이 된다.">
<text x="165" y="26" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--primary)">우리 배포</text>
<text x="475" y="26" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)"> 스토어 배포</text>
{/* 왼쪽: 웹 파이프라인 */}
<rect x="80" y="48" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="165" y="71" textAnchor="middle" fontSize="12.5" fill="var(--ink)">git push</text>
<line x1="165" y1="86" x2="165" y2="102" stroke="var(--teal)" strokeWidth="2" />
<path d="M159 101 L171 101 L165 108 Z" fill="var(--teal)" />
<rect x="80" y="108" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="165" y="131" textAnchor="middle" fontSize="12.5" fill="var(--ink)">빌드 · Docker</text>
<line x1="165" y1="146" x2="165" y2="162" stroke="var(--teal)" strokeWidth="2" />
<path d="M159 161 L171 161 L165 168 Z" fill="var(--teal)" />
<rect x="80" y="168" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="165" y="191" textAnchor="middle" fontSize="12.5" fill="var(--ink)">서버 배포</text>
<line x1="165" y1="206" x2="165" y2="222" stroke="var(--teal)" strokeWidth="2" />
<path d="M159 221 L171 221 L165 228 Z" fill="var(--teal)" />
<rect x="80" y="228" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="165" y="251" textAnchor="middle" fontSize="12.5" fill="var(--ink)">Caddy · HTTPS</text>
<line x1="165" y1="266" x2="165" y2="282" stroke="var(--teal)" strokeWidth="2" />
<path d="M159 281 L171 281 L165 288 Z" fill="var(--teal)" />
<rect x="80" y="288" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="165" y="311" textAnchor="middle" fontSize="12.5" fill="var(--ink)">전원 즉시 최신</text>
<text x="165" y="352" textAnchor="middle" fontSize="12" fill="var(--muted)">문지기 없음 · 롤백도 즉시</text>
{/* 오른쪽: 앱 파이프라인 */}
<rect x="390" y="48" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="475" y="71" textAnchor="middle" fontSize="12.5" fill="var(--ink)">빌드 · 서명</text>
<line x1="475" y1="86" x2="475" y2="102" stroke="var(--line)" strokeWidth="2" />
<path d="M469 101 L481 101 L475 108 Z" fill="var(--line)" />
<rect x="390" y="108" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="475" y="131" textAnchor="middle" fontSize="12.5" fill="var(--ink)">스토어 업로드</text>
<line x1="475" y1="146" x2="475" y2="162" stroke="var(--line)" strokeWidth="2" />
<path d="M469 161 L481 161 L475 168 Z" fill="var(--line)" />
<rect x="390" y="168" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="475" y="191" textAnchor="middle" fontSize="12.5" fill="var(--ink)">심사 (사람이 검사)</text>
<text x="574" y="182" textAnchor="start" fontSize="11" fill="var(--amber)"> 게이트</text>
<text x="574" y="196" textAnchor="start" fontSize="11" fill="var(--amber)">여기서 대기</text>
<line x1="475" y1="206" x2="475" y2="222" stroke="var(--line)" strokeWidth="2" />
<path d="M469 221 L481 221 L475 228 Z" fill="var(--line)" />
<rect x="390" y="228" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="475" y="251" textAnchor="middle" fontSize="12.5" fill="var(--ink)">승인 · 출시</text>
<line x1="475" y1="266" x2="475" y2="282" stroke="var(--line)" strokeWidth="2" />
<path d="M469 281 L481 281 L475 288 Z" fill="var(--line)" />
<rect x="390" y="288" width="170" height="36" rx="8" fill="var(--card)" stroke="var(--rose)" strokeWidth="2" />
<text x="475" y="311" textAnchor="middle" fontSize="12.5" fill="var(--ink)">사용자가 직접 설치</text>
<text x="475" y="352" textAnchor="middle" fontSize="12" fill="var(--muted)">버전이 기기마다 제각각</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 웹은 문지기 없이 흘러 전원이 즉시 최신, 앱은 심사 게이트를 지나도 사용자가 설치해야 최신이 된다</p>
<p>
정리하면 이래요. 웹은 <strong> 서버, 마음</strong> 배포도 롤백도 즉시고,
모두가 같은 버전을 봅니다. 앱은 <strong>남의 백화점 매대</strong> 준비물·서명·심사라는
관문을 지나야 하고, 통과해도 사용자가 각자 업데이트를 받아야 최신이 되죠.
번거로워 보이지만, 관문이 <strong>품질과 신뢰의 안전장치</strong>이기도 해요.
</p>
<div className="tip">
<b> 코스의 진짜 목표</b> 여러분이 이번 수습에서 배포를 직접 일은
없을 거예요. 그래도 지형을 알아 두면, 나중에 "우리도 앱 낼까?"라는 대화에서
<strong> 비용·시간·제약을 현실적으로 가늠</strong> 있습니다. 그게
"웹 개발자가 읽는 모바일" 실전 감각이에요.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>🚀 개발 코스, 여기까지</h3>
<p className="muted">
축하해요 <strong>앱의 지형</strong>에서 출발해 언어·네비게이션·크로스플랫폼을 거쳐,
드디어 <strong>스토어라는 마지막 관문</strong>까지 왔습니다. 이제 여러분은 네이티브
전문가는 아니지만, <strong>앱까지 가는 길이 어떻게 생겼는지 지도를 읽을 아는</strong>
개발자예요. 지도와, 지금 손에 익힌 React·Spring·Docker·보안 지식이면
필요할 스스로 다음 걸음을 있습니다. 그동안 쌓은 코스들을 다시 둘러보고
싶다면 언제든 <Link to="/learn"><strong>학습 센터</strong></Link>
수습 8주가 여러분의 포트폴리오가 거예요.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,445 @@
// : "Flutter" Flutter
// " " . Dart
// , React Native ·Dart ·' '·
// StatefulWidget · · (React vs Dart )
// 7 . "2 " " " .
// : API ' ' . · global.css
// (step-card, code-block, tip, warn )
// var(--primary/--teal/--amber/--ink/--line/--card/--muted/--rose) .
// JSX / .
// (: Dart ${'{}'} $ JS 릿 .)
import { Link } from 'react-router-dom';
//
const CODE_WHAT_IS = `Flutter를 한 문장으로:
"Dart로 UI를 짜면, Flutter가 화면의 픽셀을 직접 그려 준다."
여기서 핵심은 '직접 그린다'예요.
일반 Flutter
OS가 제공하는 버튼·목록을 OS 버튼을 빌리고,
빌려다 화면에 배치 자체 엔진이 버튼 모양을
( OS의 순정 부품 조립) 픽셀 단위로 직접 그림
비유: 남의 가구를 빌려 오는 아니라,
목공소(렌더링 엔진)에서 가구를 통째로 깎아 만드는 .
그래서 안드로이드/iOS에서 화면이 거의 똑같이 나옵니다.
구성: Dart(언어) + 위젯(부품) + 렌더링 엔진(Impeller/Skia).
만든 : Google. 하나의 코드로 안드로이드·iOS··데스크톱까지 목표.`;
const CODE_RN_COMPARE = `React Native vs Flutter — 같은 목표, 다른 길
React Native Flutter
언어 JavaScript/TypeScript Dart
그리는 방식 OS의 '진짜' 네이티브 자체 엔진이 픽셀을
부품을 빌려 직접 그림 (부품 빌림)
화면 느낌 OS 순정 느낌에 가까움 어디서든 거의 동일
만든 Meta(페이스북) Google
생태계 npm·React 자산 그대로 pub.dev (Dart 전용)
우리 팀과의 거리 React를 이미 가까움 Dart부터 새로 배움
살아있나?
- RN: "웹(React) 지식을 모바일로 그대로 잇고 싶다" 강함.
- Flutter: "디자인을 픽셀까지 통제하고, 애니메이션을 매끄럽게" 강함.
정답이 하나가 아니라, '무엇을 아끼고 싶은가' 다른 도구예요.`;
const CODE_DART = `// Dart 맛보기 — JS를 알면 90%는 눈에 익어요 (세미콜론·타입만 추가된 느낌)
//
var name = '미림'; // (JS let )
String grade = '3학년'; // JS
final school = '미림마이스터고'; // final = (JS const )
// (null safety) Dart
String? nickname; // ? null
print(nickname ?? '별명 없음'); // ?? : null
//
int add(int a, int b) => a + b; // =>
//
var courses = ['네트워크', 'React', 'Flutter'];
for (var c in courses) {
print('오늘의 코스: $c'); // $ ()
}
// ( )
class Student {
final String name;
Student(this.name); //
}`;
const CODE_WIDGET = `// 모든 게 위젯 — 버튼도, 글자도, 여백도, 정렬도 전부 '위젯'
// React " ", Flutter " ". .
class HelloCard extends StatelessWidget { // = React
const HelloCard({super.key});
@override
Widget build(BuildContext context) { // build() = React return (JSX)
return Column( // ' '
children: [
Text('안녕, 미림!'), //
SizedBox(height: 8), // (React margin )
ElevatedButton(
onPressed: () {}, // React onClick
child: Text('좋아요'), // '' child
),
],
);
}
}
// : HTML , '' .
// Column > [Text, SizedBox, ElevatedButton > Text] ... ' '.`;
const CODE_STATEFUL = `// StatefulWidget — 값이 변하고 화면이 다시 그려져야 할 때
// React useState + setState .
class LikeButton extends StatefulWidget {
const LikeButton({super.key});
@override
State<LikeButton> createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton> {
int count = 0; // (state). React useState(0)
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
setState(() { // " "
count++; // (React setCount )
});
},
child: Text('좋아요 $count'),
);
}
}
count++ 하고 setState() 감싸지 않으면? 값은 바뀌어도 화면이 바뀝니다.
React에서 count = count + 1로는 재렌더가 되던 것과 완전히 똑같은 함정이에요.`;
const CODE_WHEN = `언제 Flutter를 고르나 — 상황별 감각 잡기
Flutter가 빛나는 경우
디자인을 픽셀까지 똑같이, 브랜드 UI를 강하게 통제하고 싶다
애니메이션·전환이 많은 화려한 화면 (자체 엔진이라 부드러움)
안드로이드/iOS를 하나의 코드로, 화면 차이 없이 내고 싶다
팀이 처음부터 배운다면 Dart 하나로 통일된 경험
React Native가 어울리는 경우
이미 React/JS로 굴러가는 ·코드가 있다 (지식 재사용)
화면을 OS의 '순정 느낌' 최대한 맞추고 싶다
npm 생태계· 인력을 그대로 모바일에 붙이고 싶다
아닌 경우
카메라·센서·게임 성능을 극한까지 네이티브(Kotlin/Swift)
화면 개짜리 정보성 반응형 웹으로도 충분할 때가 많음`;
const CODE_OUR_VIEW = `우리 관점 — 냉정하게 손익 계산 (지금 우리 상황 기준)
우리가 이미 가진 자산(React) Flutter를 택할 때의 비용
· 프론트가 React로 돌아감 · Dart를 언어로 배워야
· 컴포넌트/상태/단방향 흐름에 익숙 · pub.dev 생태계를 새로 익힘
· Spring API를 이미 쓰고 있음 · 위젯 트리·빌드 컨텍스트
(앱이 바뀌어도 서버는 그대로!) Flutter 고유 개념 학습
옮겨 가는 지식( 공통)
· "화면 = 상태의 함수" 사고방식은 그대로 통함
· 컴포넌트(React) 위젯(Flutter)
· useState/setState StatefulWidget/setState
· props 생성자 매개변수
결론(과장 없이): "React를 배웠으니 개념의 8할은 이사 온다.
남는 2할이 Dart 문법과 위젯 트리." 그래서 코스의 목표는
'Flutter 전문가' 아니라 '지형을 읽고 필요할 때 갈 길을 아는' .`;
//
// : . props .
// (React 2 .)
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: 'Flutter란' },
{ n: 2, label: 'RN과 비교' },
{ n: 3, label: 'Dart 맛보기' },
{ n: 4, label: '모든 게 위젯' },
{ n: 5, label: 'StatefulWidget' },
{ n: 6, label: '언제 고르나' },
{ n: 7, label: '우리 관점' },
];
export default function FlutterPage() {
return (
<div>
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>Flutter<br /> 하나의 크로스플랫폼</h1>
<p>
React Native가 유일한 길은 아니에요. Google의 <strong>Flutter</strong> 전혀 다른
방식으로 같은 목표(하나의 코드로 여러 OS) 노립니다. 코스는 여러분이 배운
<strong> React 지식을 다리 삼아</strong> Dart와 위젯을 읽고, " 도구가 둘이나
있는지"와 "우리에게 무엇을 아끼고 무엇을 치르게 하는지"까지 지형을 읽는
목표로 합니다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 45</span>
<span className="chip">코드 예제 6(읽기 위주)</span>
<span className="chip">선수 지식: React 입문 · JS 기초</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="Flutter란" sub="Dart 언어 + 화면을 직접 그리는 자체 엔진">
<p>
<strong>Flutter</strong> Google이 만든 크로스플랫폼 UI 도구예요. 언어로는{' '}
<span className="icode">Dart</span> 쓰고, 하나의 코드로 안드로이드·iOS는 물론
·데스크톱까지 화면을 있게 하는 목표로 합니다. 여기까지는 React
Native와 비슷해 보이지만, <strong>결정적으로 다른 </strong> 하나 있어요.
</p>
<p>
바로 <strong>화면을 그리는 방식</strong>입니다. 보통의 앱은 OS가 제공하는 '순정
버튼·목록'을 빌려다 배치해요. 그런데 Flutter는 그걸 빌리지 않고,{' '}
<strong>자체 렌더링 엔진</strong>(요즘은 <span className="icode">Impeller</span>,
예전엔 <span className="icode">Skia</span>) 버튼 모양까지 <strong>픽셀 단위로
직접 그립니다</strong>. 남의 가구를 빌리는 대신 목공소에서 통째로 깎는 셈이죠.
</p>
<Code>{CODE_WHAT_IS}</Code>
<div className="tip">
<b>'직접 그린다' 주는 </b> 엔진이 화면을 통제하니 안드로이드와 iOS에서 UI가
거의 똑같이 나오고, 애니메이션도 매끄럽게 뽑기 좋아요. 대신 "각 OS의 순정 느낌"과는
살짝 멀어질 있습니다 트레이드오프가 섹션 2 핵심이에요.
</div>
</Section>
<Section n={2} title="React Native와 비교" sub="같은 목표, 정반대의 전략 — 그래서 둘 다 산다">
<p>
"이미 React Native가 있는데 왜 Flutter도 존재하지?" 질문의 답이 도구를
이해하는 지름길이에요. 둘은 <strong>같은 목표( 코드로 여러 OS) 정반대의
전략</strong>으로 풉니다.
</p>
<Code>{CODE_RN_COMPARE}</Code>
<p>
가장 갈림길은 <strong>"OS 부품을 빌리느냐, 직접 그리느냐"</strong>예요.
React Native는 자바스크립트로 지시를 내리면 <strong>진짜 네이티브 부품</strong>
화면에 놓입니다(그래서 OS 느낌에 가깝죠). Flutter는 부품을 빌리지 않고{' '}
<strong>엔진이 그리니</strong> 어디서든 화면이 거의 동일합니다. 어느 쪽이
'더 좋다' 아니라, <strong>무엇을 아끼고 싶은가</strong> 다른 거예요.
</p>
<div className="tip">
<b> 개발자의 눈으로</b> React Native가 우리에게 가까운 이유는 하나 {' '}
<strong>언어와 사고방식(React) 이미 안다</strong> 점이에요. Flutter는 화면
품질에서 강점이 있지만, 앞엔 <strong>Dart 학습</strong>이라는 통행료가
있습니다. 통행료의 정체가 다음 섹션입니다.
</div>
</Section>
<Section n={3} title="Dart 언어 맛보기" sub="JS를 알면 90%는 눈에 익는다">
<p>
Flutter의 언어 <strong>Dart</strong> Google이 만든 객체지향 언어예요. 다행히
자바스크립트와 뿌리가 닮아서, <strong>JS를 안다면 문법의 대부분이 눈에 익습니다</strong>.
크게 새로운 가지 <strong>타입을 앞에 적는 </strong>{' '}
<strong> 안전성(null safety)</strong>이에요.
</p>
<Code>{CODE_DART}</Code>
<p>
<strong> 안전성</strong> Dart의 자랑거리예요. 타입에{' '}
<span className="icode">?</span> 붙인 값만 <span className="icode">null</span>
있고, 나머지는 컴파일러가 "여긴 null이 올 수 없다" 미리 보증합니다. JS에서
그렇게 당하던 <span className="icode">undefined is not a function</span> 류의 사고를
<strong> 실행 전에</strong> 막아 주는 안전벨트인 셈이죠.
</p>
<div className="warn">
<b>실행 방식도 알아두면 좋아요</b> Dart는 개발 중엔 코드를 바로 실행(JIT){' '}
<strong> 리로드(Hot Reload)</strong> 저장하면 상태를 유지한 화면만
즉시 갱신 지원하고, 배포용으로는 <strong>기계어로 미리 컴파일(AOT)</strong>
빠르게 돕니다. React 개발의 HMR(저장 즉시 반영) 느낌이 비슷해요.
</div>
</Section>
<Section n={4} title="모든 게 위젯" sub="버튼도 여백도 정렬도 — 전부 위젯의 중첩(위젯 트리)">
<p>
React에서 화면이 <strong>컴포넌트</strong> 조립이었다면, Flutter에서 화면은{' '}
<strong>위젯(Widget)</strong> 조립이에요. 그런데 Flutter의 위젯은 범위가 훨씬
넓습니다. 버튼·글자는 물론이고 <strong>여백·정렬·간격 같은 '레이아웃'까지 전부
위젯</strong>이에요. "모든 게 위젯" Flutter의 슬로건입니다.
</p>
<Code>{CODE_WIDGET}</Code>
<p>
위젯을 <strong>중첩</strong>해서 만든 구조를 <strong>위젯 트리</strong>라고 불러요.
최상위 <span className="icode">MaterialApp</span>부터 아래로 화면 뼈대
(<span className="icode">Scaffold</span>), 안의 상단바·본문, 다시 안의
글자·버튼까지 위젯이 작은 위젯을 품는 나무 구조입니다. React의 컴포넌트
트리와 판박이라, 여러분에겐 낯설지 않을 거예요.
</p>
<svg className="diagram" viewBox="0 0 640 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flutter 위젯 트리: 최상위 MaterialApp 아래 Scaffold, 그 아래 AppBar와 Column, 다시 그 아래 Text와 ElevatedButton, 버튼 아래 Text로 이어지는 중첩 구조. 잎에 해당하는 Text와 버튼은 초록 테두리로 강조.">
<line x1="320" y1="54" x2="320" y2="86" stroke="var(--line)" strokeWidth="2" />
<line x1="320" y1="124" x2="170" y2="160" stroke="var(--line)" strokeWidth="2" />
<line x1="320" y1="124" x2="470" y2="160" stroke="var(--line)" strokeWidth="2" />
<line x1="170" y1="198" x2="170" y2="250" stroke="var(--line)" strokeWidth="2" />
<line x1="470" y1="198" x2="395" y2="250" stroke="var(--line)" strokeWidth="2" />
<line x1="470" y1="198" x2="540" y2="250" stroke="var(--line)" strokeWidth="2" />
<line x1="540" y1="286" x2="540" y2="322" stroke="var(--line)" strokeWidth="2" />
<rect x="250" y="16" width="140" height="38" rx="8" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="320" y="40" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">MaterialApp</text>
<rect x="250" y="86" width="140" height="38" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="320" y="110" textAnchor="middle" fontSize="13" fill="var(--ink)">Scaffold</text>
<rect x="95" y="160" width="150" height="38" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="170" y="184" textAnchor="middle" fontSize="13" fill="var(--ink)">AppBar</text>
<rect x="395" y="160" width="150" height="38" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="470" y="184" textAnchor="middle" fontSize="13" fill="var(--ink)">Column</text>
<rect x="95" y="250" width="150" height="36" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="170" y="273" textAnchor="middle" fontSize="12" fill="var(--ink)">Text '미림 앱'</text>
<rect x="335" y="250" width="120" height="36" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="395" y="273" textAnchor="middle" fontSize="12" fill="var(--ink)">Text '안녕'</text>
<rect x="470" y="250" width="150" height="36" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="545" y="273" textAnchor="middle" fontSize="12" fill="var(--ink)">ElevatedButton</text>
<rect x="470" y="322" width="150" height="34" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="545" y="344" textAnchor="middle" fontSize="12" fill="var(--ink)">Text '좋아요'</text>
<text x="626" y="150" textAnchor="end" fontSize="12" fill="var(--muted)">초록 = 위젯(글자·버튼)</text>
<text x="626" y="168" textAnchor="end" fontSize="12" fill="var(--muted)"> 아래로 중첩</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: Flutter 위젯 트리 MaterialApp에서 Text·버튼까지 위젯이 위에서 아래로 중첩된다(React 컴포넌트 트리와 같은 구조)</p>
<div className="tip">
<b>React와 나란히 두기</b> <span className="icode">build()</span> 메서드가 React의{' '}
<span className="icode">return (JSX)</span> 자리이고, 위젯 중첩이 JSX 태그 중첩이에요.
다만 여백을 CSS <span className="icode">margin</span>으로 주는 대신{' '}
<span className="icode">SizedBox</span> 같은 <strong>위젯</strong>으로 준다는 점만
새롭습니다. "스타일도 위젯"이라는 감각에 익숙해지는 관문이에요.
</div>
</Section>
<Section n={5} title="상태관리 개요 — StatefulWidget" sub="useState/setState를 이미 안다면 절반은 끝">
<p>
위젯은 종류예요. 값이 변하는 <strong>StatelessWidget</strong>(React의 순수
컴포넌트), 값이 변하고 그때마다 화면을 다시 그려야 하는{' '}
<strong>StatefulWidget</strong>입니다. 좋아요 버튼처럼 <strong>클릭할 때마다 숫자가
바뀌는</strong> 화면이 바로 후자예요.
</p>
<Code>{CODE_STATEFUL}</Code>
<p>
핵심은 <span className="icode">setState()</span>입니다. 상태 변수( 예제의{' '}
<span className="icode">count</span>) 그냥 바꾸기만 하면 화면은 꿈쩍도 해요.
변경을 <span className="icode">setState(() =&gt; ...)</span> <strong>감싸야</strong>{' '}
Flutter가 "이 위젯 다시 그려야겠구나" 하고 <span className="icode">build()</span>
다시 부릅니다. React에서 <span className="icode">count = count + 1</span>로는 재렌더가
되고 <span className="icode">setCount()</span> 불러야 했던 것과{' '}
<strong>완전히 같은 원리</strong>예요.
</p>
<div className="warn">
<b> 함정, 어디서 봤죠?</b> React 입문 섹션 4 "직접 바꾸면 화면이 안 바뀐다"
여기서 그대로 반복돼요. 이름만 <span className="icode">setCount</span> {' '}
<span className="icode">setState</span> 바뀌었을 , <strong>"상태 변경은 반드시
정해진 함수를 통해"</strong>라는 규칙은 동일합니다. 개념이 이사 온다는 이런 뜻이에요.
</div>
<div className="tip">
<b>참고</b> 앱이 커지면 <span className="icode">setState</span>만으로는 벅차서{' '}
Provider·Riverpod·Bloc 같은 상태관리 도구를 얹습니다. React에서 규모가 커지면
Context나 Redux 계열을 찾는 것과 같은 흐름이에요 지금은 "그런 게 있다" 정도만
알아 두면 충분합니다.
</div>
</Section>
<Section n={6} title="언제 Flutter를 고르나" sub="정답은 상황이 정한다 — 도구는 목적의 함수">
<p>
도구 선택은 유행이 아니라 <strong>상황</strong> 정해요. Flutter가 강한 자리와,
오히려 다른 선택이 나은 자리를 나눠 두면 판단이 쉬워집니다.
</p>
<Code>{CODE_WHEN}</Code>
<p>
요약: <strong>"디자인·애니메이션을 픽셀까지 통제하고 싶다" Flutter가,
"React/JS 자산을 그대로 잇고 싶다" React Native가</strong> 유리해요. 그리고
잊지 화면 개짜리라면 <strong>반응형 </strong>으로 충분한 경우도 많고,
센서·게임처럼 성능이 극한으로 필요하면 <strong>네이티브</strong>(Kotlin/Swift)
답일 있습니다. 크로스플랫폼이 항상 정답은 아니에요.
</p>
<div className="tip">
<b>수습생용 판단 훈련</b> 아이디어가 생기면 스스로 물어보세요. 화면 품질을
얼마나 통제하고 싶나? 우리 팀이 이미 아는 언어는? 웹만으로는 되나?
질문의 답을 적어 보는 것만으로도, "무작정 유명한 걸 고르는" 실수를 피할
있어요.
</div>
</Section>
<Section n={7} title="우리 관점 — React 자산 vs Dart 학습비용" sub="과장 없이, 손익을 계산해 본다">
<p>
마지막으로 <strong>우리 상황</strong> 붙여 봅시다. 사실부터 정확히 우리
플랫폼의 프론트엔드는 <strong>React</strong>이고, 서버는 <strong>Spring Boot</strong>예요.
지금 학습 페이지도 React로 그려지고 있죠. 우리에겐 이미 <strong>React라는
자산</strong> 있고, Flutter는 <strong>Dart라는 학습비용</strong> 요구합니다.
</p>
<Code>{CODE_OUR_VIEW}</Code>
<p>
중요한 <strong>서버는 그대로라는 </strong>이에요. 앱을 React Native로 짜든
Flutter로 짜든, 데이터는 여전히 우리 <strong>Spring API</strong> 요청해서
받아옵니다("네트워크의 이해" 코스에서 배운 요청 그대로!). 그러니 도구를
고르는 일은 <strong>'화면을 무엇으로 그릴까' 문제</strong>이지, 백엔드를 다시
짜는 일이 아니에요.
</p>
<div className="warn">
<b>과장 금지 코스가 약속하는 </b> 2개월 만에 여러분을 "네이티브
전문가" 만들어 주지 않아요. 그건 정직하지 않은 약속입니다. 대신 코스는{' '}
<strong>지형을 읽는 </strong> 줍니다. React·RN·Flutter가 각각 무엇을 아끼고
무엇을 치르는지 알면, 필요할 <strong>여러분의 실력으로 길을 걸어갈</strong>{' '}
있어요. "길을 아는 것" "이미 다 걸은 것" 다르지만, 길을 알면 반드시 도착합니다.
</div>
<div className="tip">
<b>다음으로</b> 도구가 무엇이든, 화면이 여러 개가 되는 순간 공통으로 마주치는 문제가
있어요 <strong>"이 화면에서 저 화면으로 어떻게 넘어가지?"</strong> 그게 다음
코스의 주제입니다.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>🧭 지형이 조금 보이기 시작했다면</h3>
<p className="muted">
이제 여러분은 크로스플랫폼에 <strong>React Native만 있는 아니라는 </strong>,
그리고 Flutter가 <strong>Dart와 위젯, 직접 그리는 엔진</strong>으로 다른 길을
간다는 설명할 있어요. 무엇보다 여러분이 배운 <strong>React의 상태·컴포넌트
개념이 그대로 이사 온다</strong> 것도요. 어떤 도구를 고르든 화면이 여러 개가
되면 반드시 필요한 화면 이동, {' '}
<Link to="/learn/app-navigation"><strong> 내비게이션</strong></Link> 코스에서
"이 화면에서 저 화면으로" 넘어가는 길을 이어서 배워 봅시다.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,416 @@
// : "iOS " (React )
// . (UIKit vs SwiftUI)
// Xcode· · 7
// . "2 " "
// , React " .
// : API ' ' . global.css
// (step-card, code-block, tip ) .
// / JSX · .
import { Link } from 'react-router-dom';
// ·
const CODE_ECOSYSTEM = `애플 생태계 = 하드웨어 + OS + 스토어를 한 회사가 '수직 통합'
Apple 회사가 만든다 여러 회사가 나눠 만든다 (안드로이드)
기기 iPhone · iPad 기기 삼성 · 샤오미 · 구글
OS iOS OS 구글이 만든 안드로이드
스토어 App Store 하나 스토어 Play · 갤럭시 · 기타
A · M 시리즈 자체칩 퀄컴 · 삼성 · 미디어텍
조합이 적다 = 통일 · 예측 가능 조합이 많다 = 자유 · 파편화
장점: 테스트할 기기가 적고, 사용자 대부분이 최신 OS를 빠르게 올린다.
단점: 애플이 정한 규칙 안에서만 논다 필수, 심사 통과 필수, 수수료 존재.`;
const CODE_UIKIT_SWIFTUI = `// 같은 '좋아요 버튼'을 두 방식으로 — React 배운 눈엔 한쪽이 익숙할 거예요
// UIKit () : DOM
class LikeViewController: UIViewController {
var count = 0
let label = UILabel()
@objc func tapped() {
count += 1
label.text = "좋아요 \\(count)" // ''
}
}
// SwiftUI () : React
struct LikeView: View {
@State private var count = 0 // React useState(0)
var body: some View { // return JSX
Button("좋아요 \\(count)") {
count += 1 //
}
}
}
// : SwiftUI " = f()". React .`;
const CODE_VIEW_BODY = `// SwiftUI 화면 하나 = 뷰들을 '중첩'해 쌓은 트리 (JSX 컴포넌트 조립과 똑같음)
struct ProfileView: View {
var body: some View {
VStack { // (= <div>)
Image("avatar") //
Text("김미림") //
HStack { //
Text("3학년")
Text("프론트엔드")
}
}
}
}
// '(View)'. .
// DOM , React .`;
const CODE_LIFECYCLE = `앱 생명주기 — 앱이 켜지고 · 가려지고 · 잠드는 상태들
실행 런치 Active(화면에 떠서 조작 가능)
홈으로 나가거나 다시 앱으로 돌아옴
전화가 걸려옴
Background(잠깐 뒤에서 정리) Suspended(얼어붙어 대기)
React 훅으로 번역하면:
화면이 처음 useEffect(() => {...}, []) / SwiftUI .onAppear
화면에서 사라짐 useEffect의 cleanup 함수 / SwiftUI .onDisappear
앱이 백그라운드로 (웹엔 맞는 짝이 없다 '앱'만의 개념!)
UIKit : 화면별 콜백 viewDidLoad · viewWillAppear · viewDidDisappear
SwiftUI: @main App 구조체 + scenePhase (.active / .inactive / .background)`;
const CODE_XCODE = `iOS 앱을 만들려면 실제로 필요한 것 (미화·과장 없이)
필수 Xcode 애플 공식 IDE 오직 macOS에서만 돈다
필수 (Mac) Xcode가 전용이라, 개발용 맥이 사실상 필수
포함 시뮬레이터 위에 iPhone 화면을 띄워 테스트
(카메라·GPS 일부 하드웨어는 실기기에서만 확인)
무료 기기 테스트 애플 ID만 있으면 아이폰에 직접 설치 가능(7일마다 갱신)
$99 개발자 프로그램 App Store에 '출시'하려면 유료 멤버십 필요
현실 체크
· 윈도우 / 리눅스만으로는 공식 빌드가 된다. 회사 PC가 윈도우라면
클라우드 (: CI의 macOS 러너) 빌려 빌드하는 우회로가 있다.
· 우리 회사 제품은 React + Spring Boot '웹'이라 지금 iOS 네이티브를 직접
만들진 않는다. 코스는 '앱의 지형을 읽는 지도'이지 출시 매뉴얼이 아니다.`;
const CODE_PERMISSIONS = `권한 — iOS는 '필요한 바로 그 순간'에 사용자에게 직접 묻는다
Info.plist에 '왜 필요한지' 이유 문구를 미리 적어 둔다 ( 적으면 앱이 그냥 죽음)
<key>NSCameraUsageDescription</key>
<string>과제 사진을 찍어 올리려고 카메라를 사용합니다</string>
코드가 카메라를 '처음' 쓰는 순간, 시스템이 팝업을 띄운다
'미림앱' 카메라 접근을 요청합니다
[ 허용 ] [ 허용 ] 결정권은 100% 사용자
거부해도 앱은 기능 없이 동작하도록 만들어야 한다 (강요 불가)
특별 케이스: iOS 14.5+ 추적 투명성(ATT) 다른 ·웹까지 추적하려면
반드시 별도 동의를 받아야 한다. '프라이버시' 애플의 핵심 마케팅 포인트다.`;
const CODE_ANDROID_DIFF = `iOS vs 안드로이드 — 한눈 비교표
iOS 안드로이드
언어 Swift (: Objective-C) Kotlin (: Java)
UI 툴킷 SwiftUI / UIKit Jetpack Compose / XML 레이아웃
IDE Xcode ( 전용) Android Studio (윈도우··리눅스)
스토어 App Store 하나 · 심사 엄격 Play + 갤럭시 여러 · APK 설치 가능
개발자 등록 $99 $25 1
기기 애플 소수 모델(통일) 제조사 수백 (파편화)
OS 업데이트 사용자 대부분 빠르게 최신으로 버전이 오래 갈라져 공존
파일 접근 강한 샌드박스(밖에서 보임) 상대적으로 개방적
공통점: '화면 = 뷰의 트리', '생명주기', '런타임 권한 요청'이라는 뼈대는 같다.
한쪽을 제대로 이해하면 다른 쪽은 '방언' 배우는 수준이다.`;
//
// : . props .
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '애플 생태계' },
{ n: 2, label: 'UIKit vs SwiftUI' },
{ n: 3, label: '뷰 계층' },
{ n: 4, label: '생명주기' },
{ n: 5, label: 'Xcode·시뮬레이터' },
{ n: 6, label: '권한과 심사' },
{ n: 7, label: '안드로이드와 차이' },
];
export default function IosBasicsPage() {
return (
<div>
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>iOS 앱의 구조<br /> 개발자의 눈으로 보는 애플 </h1>
<p>
우리 플랫폼은 React 웹앱이지만, 옆엔 '앱'이라는 세계가 있어요.
코스는 <strong>여러분이 배운 React 지식을 다리 삼아</strong> iOS 앱의 뼈대를
읽는 지도입니다. "2개월에 앱 전문가" 목표가 아니라,{' '}
<strong>지형을 읽고 실력으로 앱까지 가는 길을 아는 </strong> 목표예요.
</p>
<div className="chip-row">
<span className="chip">예상 소요 70</span>
<span className="chip">실습: 개념 확인 위주</span>
<span className="chip">선수 지식: React 입문</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="애플 생태계" sub="한 회사가 다 만든다 — 그 통일의 빛과 그림자">
<p>
iOS 앱을 이해하려면 먼저 <strong>애플이라는 회사의 성격</strong>부터 알아야 해요.
애플은 <strong>기기(iPhone)·운영체제(iOS)· 스토어·심지어 칩까지 회사가
직접 만듭니다.</strong> 이걸 '수직 통합' 또는 '담장 친 정원(walled garden)'이라고
불러요. 반대로 안드로이드는 구글이 OS를 만들고, 기기는 삼성·샤오미 수많은
회사가 제각각 만들죠.
</p>
<Code>{CODE_ECOSYSTEM}</Code>
<p>
'폐쇄성' 장점이자 단점이에요. <strong>기기 종류가 적으니</strong> 개발자는
테스트할 화면 크기가 되고, 사용자들도 iOS가 나오면 대부분 금방
업데이트합니다. 반대로 <strong>애플이 정한 규칙 밖으로는 나갈 없어요</strong>
개발에 맥이 필요하고, 앱을 올리려면 애플의 심사를 통과해야 하며, 스토어 결제엔
수수료가 붙습니다. 섹션에서 '규칙들' 하나씩 만나게 돼요.
</p>
<div className="tip">
<b> 이게 중요할까</b> "iPhone은 종류가 몇 개 없다" 사실 하나가 개발 경험 전체를
바꿔요. 안드로이드 개발자가 수백 기기의 파편화와 싸울 , iOS 개발자는 통일된
소수 모델만 챙기면 됩니다. 편함과 자유를 맞바꾼 셈이죠.
</div>
</Section>
<Section n={2} title="앱의 구조 — UIKit vs SwiftUI" sub="명령형 옛 방식 vs 선언형 새 방식(= React를 닮은 쪽)">
<p>
iOS 앱은 <strong>Swift</strong>라는 언어로 만들고(예전엔 Objective-C), 화면을 그리는
도구(UI 프레임워크) 크게 가지가 공존해요. 오래된{' '}
<span className="icode">UIKit</span>(2008~) 새로운{' '}
<span className="icode">SwiftUI</span>(2019~, iOS 13+)입니다. 둘의 차이는 여러분이
React 코스에서 이미 배운 대비 <strong>명령형 vs 선언형</strong> 정확히
같아요.
</p>
<Code>{CODE_UIKIT_SWIFTUI}</Code>
<p>
왼쪽 <span className="icode">UIKit</span> "내가 라벨을 찾아서 글자를 직접 바꾼다"
옛날 자바스크립트로 DOM을 손수 조작하던 방식과 똑같아요. 오른쪽{' '}
<span className="icode">SwiftUI</span> <span className="icode">@State</span> 상태를
두고, <strong>상태만 바꾸면 화면이 알아서 다시 그려집니다</strong>. React의{' '}
<span className="icode">useState</span> "화면 = 상태의 함수" 그대로 재현되죠.
</p>
<div className="tip">
<b>React를 배운 헛되지 않다</b> SwiftUI의 <span className="icode">@State</span>
React의 <span className="icode">useState</span>, <span className="icode">body</span>
컴포넌트의 <span className="icode">return</span>, 중첩 JSX 조립. 문법(Swift)
새로 익히면, <strong>사고방식은 이미 여러분 머릿속에 있어요.</strong> 앱은 대개
SwiftUI로 시작하고, 기존 대형 앱엔 UIKit이 여전히 많다는 것만 기억해 두세요.
</div>
</Section>
<Section n={3} title="화면과 뷰 — 뷰 계층" sub="화면 위 모든 것은 '뷰', 뷰는 트리로 쌓인다">
<p>
iOS에서 <strong>화면에 보이는 모든 것은 '뷰(View)'</strong>예요. 버튼도 , 글자도
, 사진도 , 그리고 그것들을 담는 상자도 뷰입니다. 뷰가 뷰를 품고, 뷰가
다른 뷰를 품으면서 <strong>트리(나무) 구조</strong> 만들어져요. 이걸{' '}
<strong> 계층(view hierarchy)</strong>이라고 부릅니다.
</p>
<Code>{CODE_VIEW_BODY}</Code>
<p>
그림이 낯설지 않을 거예요. 브라우저의 <strong>DOM 트리</strong>, React의{' '}
<strong>컴포넌트 트리</strong> 전부 "부모가 자식을 품는 트리"라는 같은 뼈대입니다.
화면은 보통 하나의 <strong> 컨트롤러(UIViewController)</strong> 관리하고,
컨트롤러가 뿌리 뷰를, 뿌리 뷰가 자식 뷰들을 거느리는 구조예요.
</p>
<svg className="diagram" viewBox="0 0 640 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="iOS 뷰 계층 트리: 기기 화면인 Window 아래에 화면 한 장을 담당하는 ViewController, 그 아래 뿌리 View, 다시 VStack과 Image로 갈라지고 VStack 아래에 Text와 Button 잎 뷰가 달린 나무 구조. 브라우저 DOM 트리와 같은 뼈대임을 보여준다">
<line x1="320" y1="54" x2="320" y2="86" stroke="var(--line)" strokeWidth="2" />
<line x1="320" y1="124" x2="320" y2="156" stroke="var(--line)" strokeWidth="2" />
<line x1="320" y1="194" x2="185" y2="230" stroke="var(--line)" strokeWidth="2" />
<line x1="320" y1="194" x2="455" y2="230" stroke="var(--line)" strokeWidth="2" />
<line x1="185" y1="268" x2="120" y2="308" stroke="var(--line)" strokeWidth="2" />
<line x1="185" y1="268" x2="250" y2="308" stroke="var(--line)" strokeWidth="2" />
<rect x="260" y="16" width="120" height="38" rx="8" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="320" y="40" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">Window</text>
<rect x="232" y="86" width="176" height="38" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="320" y="110" textAnchor="middle" fontSize="13" fill="var(--ink)">UIViewController</text>
<rect x="250" y="156" width="140" height="38" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="320" y="180" textAnchor="middle" fontSize="13" fill="var(--ink)">root View</text>
<rect x="115" y="230" width="140" height="38" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="185" y="254" textAnchor="middle" fontSize="13" fill="var(--ink)">VStack (상자)</text>
<rect x="385" y="230" width="140" height="38" rx="8" fill="var(--card)" stroke="var(--teal)" />
<text x="455" y="254" textAnchor="middle" fontSize="13" fill="var(--ink)">Image</text>
<rect x="70" y="308" width="100" height="36" rx="8" fill="var(--card)" stroke="var(--teal)" />
<text x="120" y="331" textAnchor="middle" fontSize="13" fill="var(--ink)">Text</text>
<rect x="195" y="308" width="110" height="36" rx="8" fill="var(--card)" stroke="var(--teal)" />
<text x="250" y="331" textAnchor="middle" fontSize="13" fill="var(--ink)">Button</text>
<line x1="600" y1="44" x2="600" y2="326" stroke="var(--primary)" strokeWidth="2" strokeDasharray="5 4" />
<path d="M600 338 L593 324 L607 324 Z" fill="var(--primary)" />
<text x="600" y="32" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--primary)">부모</text>
<text x="608" y="200" textAnchor="start" fontSize="12" fill="var(--muted)">품는</text>
<text x="608" y="218" textAnchor="start" fontSize="12" fill="var(--muted)">방향</text>
<text x="590" y="352" textAnchor="end" fontSize="12" fill="var(--muted)">자식</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: iOS 계층 트리 화면 전체(Window)에서 (Text·Button)까지, 브라우저 DOM 트리와 똑같은 '부모가 자식을 품는' 뼈대</p>
<div className="tip">
<b>연결해서 기억하기</b> React 입문에서 컴포넌트 트리 다이어그램을 떠올려 보세요.
<span className="icode">&lt;App&gt; &lt;Layout&gt; &lt;Card&gt; &lt;Badge&gt;</span>{' '}
그림의 <span className="icode">Window View VStack Text</span> 이름만
다른 쌍둥이예요. 트리를 읽는 눈을 기르면, 웹이든 앱이든 화면 구조가 보입니다.
</div>
</Section>
<Section n={4} title="앱·뷰 생명주기" sub="켜지고·가려지고·잠드는 순간마다 불리는 콜백">
<p>
페이지는 대개 "열려 있거나 닫혀 있거나" 하나예요. 그런데 앱은 상태가
많아요. 화면에 <strong> 있고(Active)</strong>, 홈으로 나가면{' '}
<strong>뒤로 가려지고(Background)</strong>, 한참 쓰면 시스템이{' '}
<strong>얼려 버리기도(Suspended)</strong> 하죠. 상태 변화의 순간마다 iOS가
"지금 이런 일이 일어났어!" 하고 우리 코드의 특정 함수를 불러 줘요. 이걸{' '}
<strong>생명주기(lifecycle)</strong>라고 합니다.
</p>
<Code>{CODE_LIFECYCLE}</Code>
<p>
여기서도 React 지식이 다리가 돼요. 화면이 처음 하는 일은{' '}
<span className="icode">useEffect(..., [])</span>, 화면이 사라질 정리하는 일은
<strong>cleanup 함수</strong> 짝이 맞아요. SwiftUI에선 각각{' '}
<span className="icode">.onAppear</span>·<span className="icode">.onDisappear</span>{' '}
라는 이름으로 부릅니다. 다만 <strong>"앱이 백그라운드로 갔다"</strong> 같은 상태는
웹엔 맞는 짝이 없는 '앱만의 개념'이에요.
</p>
<div className="warn">
<b>생명주기를 무시하면</b> 백그라운드로 앱에서 음악·위치추적·타이머를 계속
돌리면 배터리를 잡아먹고, iOS가 앱을 강제로 잠재우거나 종료시킬 있어요. "화면이
사라질 멈추고, 돌아올 다시 시작한다" 챙기는 개발의 기본기입니다
React에서 cleanup으로 타이머를 정리하던 습관과 똑같아요.
</div>
</Section>
<Section n={5} title="Xcode와 시뮬레이터" sub="iOS 개발에 맥이 필요한 현실">
<p>
이제 가장 현실적인 이야기예요. iOS 앱을 만들려면 애플의 공식 개발 도구{' '}
<strong>Xcode</strong> 필요한데, <strong>Xcode는 오직 macOS에서만 돌아갑니다.</strong>{' '}
<strong>개발용 맥이 사실상 필수</strong>예요. Xcode 안에는 코드 편집기·컴파일러와
함께 <strong>시뮬레이터(Simulator)</strong> 들어 있어, 화면 위에 iPhone을 띄워
앱을 바로 테스트할 있습니다.
</p>
<Code>{CODE_XCODE}</Code>
<p>
시뮬레이터는 빠르고 편하지만 <strong>진짜 기기는 아니에요.</strong> 카메라·GPS·가속도
센서·푸시 알림처럼 하드웨어가 걸린 기능은 결국 실제 아이폰에서 확인해야 합니다.
아이폰에 설치해 보는 애플 ID만 있으면 무료지만(7일마다 다시 설치),{' '}
<strong>App Store에 정식 출시</strong>하려면 $99의 개발자 프로그램이 필요해요.
</p>
<div className="warn">
<b>윈도우만으론 공식 빌드가 된다</b> 이건 취향이 아니라 애플의 정책이에요. 회사
PC가 윈도우라면 <strong>클라우드 </strong>(: CI의 macOS 러너) 빌려 빌드하는
우회로가 있습니다. "맥이 없으면 iOS는 시작도 못 하나?" 답은 "정석은 , 우회로는
클라우드"입니다.
</div>
<div className="tip">
<b>솔직하게</b> 우리 회사 제품은 React + Spring Boot <strong></strong>이라 지금
iOS 네이티브 앱을 직접 만들진 않아요. 그러니 섹션은 "당장 맥을 사라" 아니라,
개발의 진입 장벽이 어디에 있는지 <strong>지형을 파악</strong>하는 것으로 충분합니다.
</div>
</Section>
<Section n={6} title="권한과 앱 심사 문화" sub="사용자에게 묻고, 애플에게 검사받는다">
<p>
iOS의 성격을 가장 보여주는 가지가 <strong>권한(permission)</strong>{' '}
<strong> 심사(review)</strong>예요. 먼저 권한. iOS는 카메라·사진·위치·마이크·연락처
같은 민감한 자원을 <strong>앱이 실제로 쓰려는 순간, 사용자에게 직접 물어봅니다.</strong>{' '}
그리고 앱은 <span className="icode">Info.plist</span> <strong>"왜 이게 필요한지"</strong>{' '}
이유 문구를 미리 적어 둬야 해요 적으면 앱이 자리에서 꺼져 버립니다.
</p>
<Code>{CODE_PERMISSIONS}</Code>
<p>
번째는 <strong>심사 문화</strong>예요. 안드로이드가 상대적으로 자동 검사에 기대는
것과 달리, App Store는 <strong>사람이 직접 앱을 검토</strong>합니다. 개인정보 처리,
디지털 상품의 인앱 결제 규정, 오해를 부르는 설명, 비공개 API 사용 등에서 걸리면
<strong>가차 없이 반려(reject)</strong>돼요. 통과에 보통 하루이틀이 걸리고, 규칙이
까다로운 만큼 스토어의 품질·안전 신뢰도가 높다는 애플의 논리입니다.
</p>
<div className="warn">
<b>이유 문구 누락 = 즉시 크래시</b> <span className="icode">NSCameraUsageDescription</span>{' '}
같은 문구 없이 카메라를 켜면 앱이 바로 죽어요. iOS의 규칙은 "권한은 사용자 것이고,
앱은 이유를 밝혀야 한다"입니다. 사용자가 거부해도 앱은 기능 없이 굴러가도록
만들어야 하고요.
</div>
<div className="tip">
<b>우리 보안 코스와 이어진다</b> "필요한 최소한만, 이유를 밝히고 요청한다" iOS의
권한 철학은 우리{' '}
<Link to="/learn/firewall"><strong>방화벽·보안</strong></Link> 코스에서 배운{' '}
<strong>최소 권한 원칙</strong> 정확히 같은 사고방식이에요. 플랫폼만 다를 ,
"함부로 열지 않는다" 원칙은 ·서버· 어디서나 통합니다.
</div>
</Section>
<Section n={7} title="안드로이드와 다른 점 정리" sub="언어·IDE·스토어·기기 — 그리고 똑같은 뼈대">
<p>
마지막으로 지금까지의 조각을 안드로이드와 나란히 놓고 정리해 봅시다. 표를 보면
"다른 점" 잔뜩 보이지만, <strong>정말 중요한 아래 </strong>이에요
<strong>뼈대는 같다.</strong>
</p>
<Code>{CODE_ANDROID_DIFF}</Code>
<p>
언어(Swift vs Kotlin), 도구(Xcode vs Android Studio), 스토어 정책은 서로 다릅니다.
하지만 <strong>"화면은 뷰의 트리다", "화면엔 생명주기가 있다", "권한은 실행 중에
사용자에게 묻는다"</strong> 근본 개념은 양쪽이 똑같아요. 심지어 안드로이드의
최신 UI 도구 <span className="icode">Jetpack Compose</span> SwiftUI·React처럼{' '}
<strong>선언형</strong>입니다. 결국 세계가 같은 방향으로 수렴하고 있는 거죠.
</p>
<div className="tip">
<b>지도를 읽었다면</b> 한쪽 플랫폼을 제대로 이해하면 다른 쪽은 '방언' 배우는
수준이에요. 그리고 여러분은 이미 React로 <strong>선언형 UI라는 표준어</strong>
익혔습니다. iOS든 안드로이드든, 남은 지역의 사투리(문법) 얹는 일이에요.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>🍎 여기까지 왔다면</h3>
<p className="muted">
이제 iOS 앱을 이루는 단어들 애플 생태계·UIKit/SwiftUI· 계층·생명주기·Xcode·권한·심사
<strong>여러분이 배운 React 언어로 설명</strong> 있게 됐어요. 화면은 뷰의
트리이고, 상태가 바뀌면 다시 그려지며, 권한은 최소한만 이유를 밝혀 요청한다는 .
지도를 읽었으니, 다음은 땅에서 쓰는 실제 언어 차례예요 {' '}
<Link to="/learn/swift-intro"><strong>Swift 입문</strong></Link> 코스에서 SwiftUI를
움직이는 문법을, 여러분이 아는 JavaScript와 나란히 놓고 익혀 봅시다.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,452 @@
// : "Kotlin " Kotlin,
// React/JS .
// Kotlin (val/var)· null ·
// ·data class ·when "JS/Java " 7 .
// : API ' ' . global.css
// (step-card, code-block, tip ) .
// JSX / .
// : "2 " " " .
import { Link } from 'react-router-dom';
//
const CODE_WHY = `같은 일, 다른 분량 — Java 대비 Kotlin이 왜 '간결·안전'인가
// Java: ()
public class Person {
private final String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
// equals(), hashCode(), toString() ...
}
// Kotlin:
data class Person(val name: String, var age: Int)
Kotlin이 내세우는 글자:
· 간결 반복 코드(보일러플레이트) 언어가 대신 준다
· 안전 null을 '컴파일 단계'에서 잡아 크래시를 예방한다
· 호환 Java와 같은 JVM에서 돌고, 서로 100% 섞어 있다`;
const CODE_VAL_VAR = `변수는 두 가지 — 여러분이 아는 JS와의 지도
Kotlin JavaScript (이미 아는 )
val name = "미림" const name = "미림" // X
var age = 18 let age = 18 // O
val = value( 고정). 담으면 다른 값으로 바꾼다 (const의 친척)
var = variable(변함). 얼마든지 다시 대입할 있다 (let의 친척)
// :
val name = "미림" // String
val age = 18 // Int
val pi = 3.14 // Double
// (JS , )
val name: String = "미림"
var age: Int = 18
age = 19 // var OK
name = "다른값" // val ' ' !`;
const CODE_NULL = `null 안정성 — 안드로이드 앱 크래시 1위 'NPE'를 언어가 막는다
// (?) " null "
var name: String = "미림" // null null
var maybe: String? = null // ? null
// ?. (safe call): null null ( X)
val len = maybe?.length // maybe null len null
// ?: : null
val shown = maybe ?: "손님" // maybe null ""
// !! (not-null assertion): " null "
val forced = maybe!!.length // maybe null NullPointerException
정리: ?. ?: '안전', !! '위험한 탈출구'.
정말 확신할 때만 !!, 평소엔 ?. / ?: 조용히 흘려보낸다.`;
const CODE_FUN = `함수와 람다 — React에서 쓰던 화살표 함수, 여기서도 통한다
// fun ( )
fun add(a: Int, b: Int): Int {
return a + b
}
// = ( )
fun add(a: Int, b: Int) = a + b //
//
fun greet(name: String = "손님") = "안녕, $name" // $ =
greet() // ", "
greet("미림") // ", "
// = . { -> }
val square = { x: Int -> x * x }
square(4) // 16
// JS :
// JS : const square = (x) => x * x;
// Kt : val square = { x: Int -> x * x }`;
const CODE_CLASS = `클래스와 data class — 반복 코드를 언어에게 떠넘기기
// (val/var )
class Person(val name: String, var age: Int) {
fun birthday() { age += 1 } //
}
val p = Person("미림", 18) // new !
println(p.name) // "" ( )
p.birthday() // age 19
p.name = "X" // val
// data class: ' '
data class User(val id: Long, val name: String)
// data :
val a = User(1, "미림")
val b = User(1, "미림")
a == b // true equals ( )
println(a) // User(id=1, name=) toString
val c = a.copy(name = "미르") // ' ' copy `;
const CODE_WHEN = `컬렉션과 when — React의 .map()/.filter()가 거의 그대로
// (listOf = , mutableListOf = )
val courses = listOf("네트워크", "React", "Kotlin")
// React !
val loud = courses.map { it.uppercase() } // it = ( )
val short = courses.filter { it.length <= 6 }
courses.forEach { println(it) }
// JS : courses.map(c => c.toUpperCase())
// Kt : courses.map { it.uppercase() } //
// when = switch . ' '
fun grade(score: Int): String = when {
score >= 90 -> "A"
score >= 80 -> "B"
score >= 70 -> "C"
else -> "재수강"
}
// (break , )
when (day) {
"토", "일" -> println("주말!")
else -> println("평일")
}`;
const CODE_SUMMARY = `JS/Java 아는 사람을 위한 한 장 — Kotlin 번역표
개념 이미 아는 (JS) Kotlin
상수 / 변수 const / let val / var
타입 동적(런타임에 결정) 정적 + 추론(컴파일 결정)
문자열 삽입 \`안녕 \${name}\` "안녕 $name"
화살표 함수 (x) => x * x { x -> x * x }
map / filter arr.map(f) list.map { it }
null 대비 x?.y / x ?? "기본" x?.y / x ?: "기본"
클래스 class + 생성자 코드 data class
switch switch / case / break when (break 불필요)
Java 하던 사람에게 한마디:
· 문장 세미콜론(;) 붙여도 된다
· new 키워드가 없다 그냥 Person("미림", 18)
· 게터/세터·equals·toString을 손으로 쓴다 (data class가 해결)
· null이 '타입에 드러난다' String String? 아예 다른 타입`;
//
// : React .
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '왜 Kotlin인가' },
{ n: 2, label: 'val / var · 타입추론' },
{ n: 3, label: 'null 안정성' },
{ n: 4, label: '함수와 람다' },
{ n: 5, label: '클래스 · data class' },
{ n: 6, label: '컬렉션과 when' },
{ n: 7, label: '한 장 요약' },
];
export default function KotlinIntroPage() {
return (
<div>
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>Kotlin 입문<br /> 개발자의 눈으로 보는 안드로이드 언어</h1>
<p>
여러분은 이미 React로 <span className="icode">const</span>·<span className="icode">let</span>,
화살표 함수, <span className="icode">.map()</span> 봤어요. 놀랍게도 안드로이드 앱을
만드는 언어 <strong>Kotlin</strong> 감각과 소름 돋게 닮았습니다. 목표는 "2개월 만에
전문가" 아니라 <strong>이미 아는 지식을 다리 삼아 언어의 지형을 읽는 </strong>
val/var부터 null 안정성, 람다, data class까지 뼈대를 세워 봅니다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 70</span>
<span className="chip">실습: 코드 읽고 옮겨 보기</span>
<span className="chip">선수 지식: JS 기초(변수·함수·배열)</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="왜 Kotlin인가" sub="Java의 사투리를 표준어로 다듬은 언어">
<p>
안드로이드 앱은 오랫동안 <strong>Java</strong> 만들었어요. Java는 튼튼하지만
<strong> 장황</strong>합니다 사람 명을 담는 클래스 하나에도 게터·세터·생성자로
수십 줄이 필요하죠. 젯브레인즈(JetBrains) 만든 <strong>Kotlin</strong> "같은 일을
짧고 안전하게" 목표로 나온 언어이고, 2017 구글이 안드로이드 공식 언어로
채택한 지금은 <strong>안드로이드 개발의 표준</strong> 됐습니다.
</p>
<Code>{CODE_WHY}</Code>
<p>
핵심은 Kotlin이 Java와 <strong>같은 JVM</strong> 위에서 돌고, 서로 자유롭게 섞어
있다는 점이에요. 우리 회사 백엔드가 <strong>Spring Boot</strong>(역시 JVM에서 도는
Java) 있는데, Kotlin은 바로 세계의 이웃 언어입니다. 여러분이 배우는 React가
"웹의 현대 표준"이라면, Kotlin은 "안드로이드의 현대 표준"이라고 보면 돼요.
</p>
<div className="tip">
<b>미리 보는 그림</b> 코스는 문법 하나하나를 <strong>여러분이 아는 JS와 짝지어</strong>
갑니다. valconst, 람다화살표 함수, .map().map(). 언어를 통째로 외우는 아니라,
이미 있는 지식에 "번역표" 붙이는 여정이에요.
</div>
</Section>
<Section n={2} title="변수: val과 var, 그리고 타입추론" sub="const/let을 이미 안다면 절반은 끝났다">
<p>
Kotlin의 변수는 종류예요. <span className="icode">val</span> <strong>value</strong>,
담으면 다른 값으로 바꾸는 상자 JS의 <span className="icode">const</span>
사촌입니다. <span className="icode">var</span> <strong>variable</strong>, 얼마든지 다시
대입할 있는 상자 JS의 <span className="icode">let</span> 사촌이고요.
</p>
<Code>{CODE_VAL_VAR}</Code>
<p>
겉보기엔 JS와 똑같지만 결정적 차이가 하나 있어요. JS는 <strong>동적 타입</strong>이라
변수에 뭐가 들었는지 실행해 봐야 알지만, Kotlin은 <strong>정적 타입</strong>이라
타입이 애초에 정해져 있습니다. 다만 <strong>타입 추론</strong> 덕분에{' '}
<span className="icode">val age = 18</span>처럼 짧게 써도 컴파일러가 알아서{' '}
<span className="icode">Int</span> 판단해 줘요. "짧게 쓰는 편함 + 타입의 안전함"
챙기는 거죠.
</p>
<div className="tip">
<b>습관 하나</b> Kotlin에서는 <strong>일단 val로 쓰고, 정말 바꿔야 때만 var로</strong>
바꾸는 정석이에요. 바뀌는 값이 많을수록 코드가 예측 가능해지고 버그가 줄거든요.
React에서 <span className="icode">const</span>부터 손이 가는 습관과 똑같습니다.
</div>
</Section>
<Section n={3} title="null 안정성" sub="?. · ?: · !! — 앱을 지키는 세 글자">
<p>
안드로이드 앱이 갑자기 죽는(크래시) 원인 1위는 오랫동안{' '}
<strong>NullPointerException(NPE)</strong>이었어요. "값이 있는 알고 꺼냈는데 비어
있어서" 터지는 사고죠. null을 만든 토니 호어(Tony Hoare)조차 훗날 그걸{' '}
<strong>"10억 달러짜리 실수"</strong>라고 불렀을 정도예요. Kotlin은 문제를{' '}
<strong>타입 차원에서</strong> 막습니다.
</p>
<p>
방법은 간단해요. 타입 뒤에 물음표가 <strong>없으면</strong>{' '}
(<span className="icode">String</span>) null을 절대 넣고, <strong>있으면</strong>{' '}
(<span className="icode">String?</span>) null이 들어올 있다는 뜻입니다. 그리고
null일 있는 값을 다룰 연산자를 씁니다.
</p>
<Code>{CODE_NULL}</Code>
<svg className="diagram" viewBox="0 0 640 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="null일 수 있는 값을 다루는 세 갈래: 물음표 점 연산자는 null이면 null을 안전하게 반환하고, 엘비스 연산자는 기본값으로 대체하고, 느낌표 두 개 단언은 null이면 앱이 크래시된다">
<rect x="210" y="18" width="220" height="46" rx="10" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="320" y="40" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">val name: String?</text>
<text x="320" y="57" textAnchor="middle" fontSize="11.5" fill="var(--muted)">null일 수도 있는 </text>
<line x1="320" y1="64" x2="110" y2="150" stroke="var(--line)" strokeWidth="2" />
<line x1="320" y1="64" x2="320" y2="150" stroke="var(--line)" strokeWidth="2" />
<line x1="320" y1="64" x2="530" y2="150" stroke="var(--line)" strokeWidth="2" />
<rect x="30" y="150" width="160" height="40" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="110" y="175" textAnchor="middle" fontSize="13" fill="var(--ink)">name?.length</text>
<rect x="240" y="150" width="160" height="40" rx="8" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="320" y="175" textAnchor="middle" fontSize="13" fill="var(--ink)">name ?: "손님"</text>
<rect x="450" y="150" width="160" height="40" rx="8" fill="var(--card)" stroke="var(--rose)" strokeWidth="2" />
<text x="530" y="175" textAnchor="middle" fontSize="13" fill="var(--ink)">name!!.length</text>
<line x1="110" y1="190" x2="110" y2="238" stroke="var(--teal)" strokeWidth="2" />
<path d="M110 246 L104 234 L116 234 Z" fill="var(--teal)" />
<line x1="320" y1="190" x2="320" y2="238" stroke="var(--primary)" strokeWidth="2" />
<path d="M320 246 L314 234 L326 234 Z" fill="var(--primary)" />
<line x1="530" y1="190" x2="530" y2="238" stroke="var(--rose)" strokeWidth="2" />
<path d="M530 246 L524 234 L536 234 Z" fill="var(--rose)" />
<rect x="24" y="246" width="172" height="56" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="110" y="268" textAnchor="middle" fontSize="12.5" fill="var(--ink)">null이면 그냥 null</text>
<text x="110" y="287" textAnchor="middle" fontSize="12" fill="var(--muted)">안전하게 통과</text>
<rect x="234" y="246" width="172" height="56" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="320" y="268" textAnchor="middle" fontSize="12.5" fill="var(--ink)">null이면 "손님"</text>
<text x="320" y="287" textAnchor="middle" fontSize="12" fill="var(--muted)">기본값으로 대체</text>
<rect x="444" y="246" width="172" height="56" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="530" y="268" textAnchor="middle" fontSize="12.5" fill="var(--ink)">null이면 크래시</text>
<text x="530" y="287" textAnchor="middle" fontSize="12" fill="var(--muted)">NPE 발생</text>
<text x="110" y="326" textAnchor="middle" fontSize="12" fontWeight="600" fill="var(--teal)">?. 안전 호출</text>
<text x="320" y="326" textAnchor="middle" fontSize="12" fontWeight="600" fill="var(--primary)">?: 엘비스</text>
<text x="530" y="326" textAnchor="middle" fontSize="12" fontWeight="600" fill="var(--rose)">!! 단언(위험)</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 같은 null 값도 어떤 연산자로 다루느냐에 따라 안전(?., ?:)하거나 위험(!!)하게 갈린다</p>
<div className="warn">
<b>!! 최후의 수단</b> <span className="icode">!!</span> "이건 절대 null 아냐"라는
장담이에요. 틀리면 바로 자리에서 앱이 죽습니다. Kotlin을 쓰는 이유의 절반이 null
안전인데 <span className="icode">!!</span> 남발하면 안전을 스스로 반납하는 셈이죠.
평소엔 <span className="icode">?.</span> <span className="icode">?:</span> 흘려보내고,{' '}
<span className="icode">!!</span> 정말 확신할 때만 아껴 쓰세요.
</div>
</Section>
<Section n={4} title="함수와 람다" sub="화살표 함수를 안다면, 람다도 안다">
<p>
함수는 <span className="icode">fun</span> 키워드로 정의해요. Java와 달리 타입을{' '}
<strong>이름 뒤에 콜론</strong>으로 붙이는 특징입니다
(<span className="icode">fun add(a: Int, b: Int): Int</span>). 그리고 함수 몸통이
<strong> 하나뿐이면 = 기호로 줄로</strong> 줄일 있어요 Kotlin다운 간결함이죠.
</p>
<Code>{CODE_FUN}</Code>
<p>
<strong>람다</strong> <strong>이름 없는 일회용 함수</strong>예요. 포스트잇에 "이거
번만 "라고 적어 건네는 메모 같은 거죠. React에서 매일 쓰던{' '}
<span className="icode">{'(x) => x * x'}</span> 화살표 함수, 그게 Kotlin에선{' '}
<span className="icode">{'{ x -> x * x }'}</span>입니다. 화살표의 방향과 중괄호 위치만
다를 , "값을 받아서 결과를 돌려주는 작은 상자"라는 개념은 똑같아요.
</p>
<div className="tip">
<b>문자열 템플릿</b> JS의 <span className="icode">{'`안녕 ${name}`'}</span>
Kotlin에선 <span className="icode">"안녕 $name"</span>이에요. 백틱 없이 큰따옴표 안에서
바로 <span className="icode">$변수</span> 꽂습니다. 계산식이 들어가면{' '}
<span className="icode">{'"합계 ${a + b}"'}</span>처럼 중괄호를 쓰고요. 거의 같죠?
</div>
</Section>
<Section n={5} title="클래스와 data class" sub="한 줄로 끝나는 데이터 상자">
<p>
Kotlin의 클래스는 <strong>생성자를 클래스 이름 괄호에 바로</strong> 씁니다. 게다가
괄호 안에 <span className="icode">val</span>/<span className="icode">var</span>
붙이면 그게 필드가 되고, 게터/세터까지 자동으로 생겨요. Java라면 수십 줄이 필요한
일이 줄로 끝납니다. 참고로 객체를 만들 <strong>new 키워드도 없어요</strong>
그냥 함수 부르듯 <span className="icode">Person("미림", 18)</span>.
</p>
<Code>{CODE_CLASS}</Code>
<p>
하이라이트는 <span className="icode">data class</span>예요. 클래스 앞에{' '}
<span className="icode">data</span> 단어만 붙이면 <strong> 비교(equals),
보기 좋은 출력(toString), 복제(copy)</strong> 공짜로 따라옵니다. 서버(우리 Spring
Boot API)에서 받은 JSON을 담을 그릇으로 맞아요 "데이터만 담는 도시락통"
셈이죠. <span className="icode">copy(name = "미르")</span> 반찬 하나만 바꿔 도시락을
만드는 감각은, React에서 <span className="icode">{'{ ...user, name: "미르" }'}</span>
객체를 복제하던 것과 똑같습니다.
</p>
<div className="tip">
<b> 비교가 공짜가 중요할까</b> 보통 클래스는 <span className="icode">==</span>
"같은 메모리 주소냐" 따져요. 하지만 <span className="icode">data class</span>
"안에 든 값이 같냐" 비교합니다. 그래서 서버에서 User가 내용이 같은지 줄로
확인할 있어요. 목록을 다룰 (React의 key를 떠올려 보세요) 아주 요긴합니다.
</div>
</Section>
<Section n={6} title="컬렉션과 when 표현식" sub=".map()·.filter()는 그대로, switch는 진화형으로">
<p>
목록을 다루는 감각도 웹과 거의 같아요. <span className="icode">listOf(...)</span>
리스트를 만들고, 여러분이 React에서 쓰던{' '}
<span className="icode">.map()</span>·<span className="icode">.filter()</span>·
<span className="icode">.forEach()</span> <strong>그대로</strong> 씁니다. , 인자가
하나일 이름을 짓고 <span className="icode">it</span>으로 부르고, 소괄호 없이
중괄호 람다만 붙이는 Kotlin 스타일이에요.
</p>
<Code>{CODE_WHEN}</Code>
<p>
<span className="icode">when</span> JS의 <span className="icode">switch</span>
진화한 형태예요. 가지가 크게 좋아졌습니다. 첫째, <strong>break가 필요 없어요</strong>
맞는 갈래 하나만 실행하고 끝납니다(switch에서 break 빼먹어 생기는 사고가 없죠). 둘째,{' '}
<strong>값을 돌려주는 '식(expression)'</strong>이라 <span className="icode">val grade =
when {'{ ... }'}</span>처럼 결과를 바로 변수에 담을 있어요. 여러 갈림길을 그리는
표지판이라고 생각하면 됩니다.
</p>
<div className="tip">
<b>불변이 기본</b> <span className="icode">listOf</span> 만든 리스트는 원소를
추가·삭제할 없어요(읽기 전용). 바꿔야 하면{' '}
<span className="icode">mutableListOf</span> 씁니다. 여기서도 Kotlin은 "일단 바꾸게,
필요할 때만 바꾸게"라는 val/var의 철학을 그대로 밀고 갑니다.
</div>
</Section>
<Section n={7} title="JS/Java 아는 사람을 위한 한 장 요약" sub="새 언어가 아니라, 번역표를 외운 것">
<p>
여기까지 왔다면 눈치챘을 거예요 Kotlin은 <strong>완전히 새로운 세계가 아니라,
여러분이 아는 것들의 재배치</strong>라는 걸요. 아래 장이면 오늘 배운 전부
담깁니다. 막힐 표만 다시 보세요.
</p>
<Code>{CODE_SUMMARY}</Code>
<p>
물론 코스가 안드로이드 전체를 만드는 법까지 알려 주진 않아요. 화면을 그리는
Jetpack Compose, 기기 권한, 생명주기(lifecycle) 같은 안드로이드 고유의 세계가 다음에
기다리고 있습니다. 하지만 <strong>언어의 지형을 읽을 있게 됐다는 </strong>
출발점이에요. 낯선 Kotlin 코드를 만나도 ", 이건 val이니까 상수, 이건 ?.니까 null
안전 호출"이라고 해석되기 시작할 테니까요.
</p>
<div className="tip">
<b>가장 좋은 연습</b> React 코스에서 만들었던 작은 함수(: 좋아요 개수를 세거나 목록을
거르는 코드) 골라, 번역표를 보며 <strong>Kotlin으로 옮겨 보세요</strong>.{' '}
<span className="icode">const</span><span className="icode">val</span>,{' '}
화살표 함수람다로 바꾸는 손맛이 붙으면, 언어가 가족이라는 몸으로 느껴집니다.
온라인 <span className="icode">Kotlin Playground</span>(play.kotlinlang.org) 붙여넣어
바로 돌려볼 수도 있어요.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>🤖 여기까지 왔다면</h3>
<p className="muted">
이제 val/var, null 안정성(?. ?: !!), 람다, data class, when이라는 단어로 Kotlin
코드를 <strong>읽고 옮길 </strong> 있게 됐어요. 그리고 무엇보다 여러분이 배운
React/JS 지식이 <strong> 언어로 건너가는 다리가 된다</strong> 확인했습니다.
안드로이드의 현대 UI 도구 <strong>Jetpack Compose</strong> React처럼 선언형이라,
"화면 = 상태의 함수"라는 감각이 그대로 이어지고요. 다음은 바다 건너 반대편 대륙 {' '}
<Link to="/learn/ios-basics"><strong>iOS 기초</strong></Link>에서 애플의 Swift와
아이폰 앱의 지형을 같은 방식으로 읽어 봅시다.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,455 @@
// : "PWA "
// , ·· ·iOS 7
// . " " React/JS
// , (Vite+React ) ' ' PWA .
// : API ' ' . styles/global.css
// (step-card, code-block, tip ) var(--*) ( ).
// JSX / .
import { Link } from 'react-router-dom';
//
const CODE_SPECTRUM = `웹 페이지 ───────────── PWA ───────────── 네이티브 앱
(브라우저 ) (설치되는 ) (스토어에서 받는 )
주소창으로 접속 화면 아이콘 화면 아이콘
닫으면 사라짐 전체화면으로 실행 전체화면으로 실행
오프라인이면 먹통 오프라인도 일부 동작 카메라·센서 깊이 사용
설치 0 · 즉시 배포 설치 O · 즉시 배포 설치 O · 스토어 심사
핵심: PWA는 "웹의 몸으로 앱의 껍데기를 입는" 중간 지대예요.
우리가 이미 만든 React 웹앱에 파일 두어 (매니페스트·서비스워커)
얹으면, 자리에서 '설치되는 앱'으로 승격됩니다.
언어(Kotlin·Swift), 스토어 심사도 없이.`;
const CODE_MANIFEST = `// manifest.webmanifest — 브라우저에게 "나 이런 앱이야"라고 소개하는 명함
{
"name": "AWESOMEDEV 수습 학습 플랫폼",
"short_name": "미림학습", //
"start_url": "/", //
"display": "standalone", // ''
"background_color": "#0f172a", //
"theme_color": "#4f46e5", //
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png",
"purpose": "any maskable" } // /
]
}`;
const CODE_MANIFEST_LINK = `<!-- index.html의 <head>에 명함을 걸어 두면 브라우저가 읽어요 -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#4f46e5" />
<!-- display 4 ( 앱스러움 앱스러움):
browser < minimal-ui < standalone < fullscreen
대부분의 PWA는 standalone을 씁니다. -->`;
const CODE_SW_REGISTER = `// 서비스워커 등록 — main.jsx 어딘가에서 딱 한 번
if ('serviceWorker' in navigator) { //
window.addEventListener('load', () => { //
navigator.serviceWorker.register('/sw.js')
.then((reg) => console.log('SW 등록됨:', reg.scope))
.catch((err) => console.error('SW 등록 실패:', err));
});
}
// : HTTPS (localhost ).
// , .`;
const CODE_SW_FETCH = `// sw.js — 브라우저와 네트워크 사이에 앉은 '중간 관리인'
const CACHE = 'mirim-v1';
const ASSETS = ['/', '/index.html', '/logo.svg', '/styles/global.css'];
// install:
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE).then((c) => c.addAll(ASSETS))
);
});
// fetch:
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request) // ?
.then((hit) => hit || fetch(e.request)) // ,
);
});
// : (hit) .
// 'cache-first' 'network-first' .`;
const CODE_PUSH = `// 푸시 알림 — 앱이 꺼져 있어도 서버가 사용자에게 말을 거는 통로
// ''. .
const perm = await Notification.requestPermission(); // 'granted' | 'denied'
if (perm !== 'granted') return;
// ' ' (VAPID )
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true, // ( X)
applicationServerKey: VAPID_PUBLIC_KEY,
});
// Spring
await fetch('/api/push/subscribe', { method: 'POST', body: JSON.stringify(sub) });
// sw.js ( !)
self.addEventListener('push', (e) => {
const data = e.data.json();
self.registration.showNotification(data.title, { body: data.body });
});`;
const CODE_VITE_PWA = `# frontend/ 에서 — 손으로 sw.js를 짜는 대신 플러그인에 맡기기
npm install -D vite-plugin-pwa
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate', //
manifest: {
name: 'AWESOMEDEV 수습 학습 플랫폼',
short_name: '미림학습',
display: 'standalone',
theme_color: '#4f46e5',
icons: [ /* 192 · 512 아이콘 경로 */ ],
},
}),
],
});
// npm run build dist/ manifest ' '.
// Vite .`;
//
// : (React 2). props .
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: 'PWA란' },
{ n: 2, label: '우리도 PWA로' },
{ n: 3, label: '매니페스트' },
{ n: 4, label: '서비스워커' },
{ n: 5, label: '푸시 알림' },
{ n: 6, label: '네이티브 대비 한계' },
{ n: 7, label: '실습 경로' },
];
export default function PwaPage() {
return (
<div>
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>PWA<br /> 웹앱을 앱처럼</h1>
<p>
"앱을 만들려면 안드로이드·iOS를 새로 배워야 하나요?" 그렇진 않아요.
<strong> 우리가 이미 만든 React 웹앱</strong> 파일 두어 개를 얹으면, 화면에
설치되고 오프라인에서도 도는 <strong>PWA(Progressive Web App)</strong> 됩니다.
개발자가 가장 적은 힘으로 '앱' 닿는 , 지형을 읽어 봅시다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 70</span>
<span className="chip">실습: 매니페스트·SW 얹기</span>
<span className="chip">선수 지식: React·HTTP 기초</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="PWA란 무엇인가" sub="웹과 네이티브 사이, '설치되는 웹'">
<p>
우리가 아는 웹사이트는 <strong>브라우저 </strong> 안에서만 살아요. 주소창으로
들어가고, 탭을 닫으면 사라지고, 인터넷이 끊기면 먹통이 되죠. 반대편엔{' '}
<strong>네이티브 </strong> 있어요. 스토어에서 받아 화면에 깔리고, 오프라인에서도
돌고, 카메라·센서 같은 기기 기능을 깊이 씁니다. 대신 안드로이드는 Kotlin, iOS는
Swift로 <strong>따로</strong> 만들어야 하죠.
</p>
<p>
<strong>PWA</strong> 사이의 중간 지대예요. 몸통은 여전히 (HTML·CSS·JS)인데,
껍데기만 앱처럼 입습니다. 화면에 아이콘이 깔리고, 주소창 없이 전체화면으로 열리고,
오프라인에서도 일부 동작해요. 스토어 심사도, 언어도 없이요.
</p>
<Code>{CODE_SPECTRUM}</Code>
<p>
PWA를 앱답게 만드는 재료는 가지입니다. <strong> HTTPS</strong>(안전한 연결),{' '}
<strong> 매니페스트</strong>(명함 섹션 3), <strong> 서비스워커</strong>(중간
관리인 섹션 4). 셋이 갖춰지면 브라우저가 "어, 이거 설치할 수 있는 앱이네?"
하고 알아봅니다.
</p>
<div className="tip">
<b>이름 뜻풀이</b> Progressive(점진적) Web App "가능한 기기에선 앱처럼, 되는
곳에선 그냥 웹처럼" <strong>단계적으로</strong> 좋아진다는 뜻이에요. 낡은 브라우저라도
최소한 웹페이지로는 열립니다. 하나의 코드가 상황에 맞춰 눈높이를 바꾸는 셈이죠.
</div>
</Section>
<Section n={2} title="우리 학습 플랫폼도 PWA가 될 수 있다" sub="이미 절반은 갖춰져 있다">
<p>
멀리 있는 얘기가 아니에요. <strong>지금 보고 있는 학습 플랫폼</strong> 바로 유력한
후보입니다. 우리는 이미 React로 만든 웹앱을 <span className="icode">Vite</span>
빌드하고, 운영 서버는 HTTPS로 서비스하죠. PWA의 재료 {' '}
<strong>HTTPS는 이미 있고</strong>, 나머지 (매니페스트·서비스워커) 얹으면 됩니다.
</p>
<p>
, 정직하게 말할게요. <strong>지금 순간의 우리 앱은 아직 PWA가 아니에요.</strong>{' '}
<span className="icode">frontend/index.html</span> 열어 보면{' '}
<span className="icode">&lt;link rel="manifest"&gt;</span> 없고,{' '}
<span className="icode">public/</span> 서비스워커 파일도 없습니다. 그래서 화면에
"설치" 버튼이 뜨는 거예요. 코스의 목표는 빠진 조각을 <strong> 손으로</strong>{' '}
채워 넣는 지형을 익히는 것입니다.
</p>
<div className="tip">
<b>직접 확인해 보기</b> 에디터에서{' '}
<span className="icode">frontend/index.html</span> 열어 <span className="icode">&lt;head&gt;</span>{' '}
안을 보세요. 지금은 <span className="icode">&lt;title&gt;</span> 뷰포트 설정뿐,
manifest 링크가 없습니다. 섹션 3에서 여기에 줄을 더하는 것부터 시작해요.
</div>
<div className="warn">
<b>과장 금지</b> PWA를 얹는다고 우리 웹앱이 "네이티브 앱" 되는 아니에요.
같은 React 코드가 <strong>설치·오프라인·알림이라는 앱의 </strong> 입는 것뿐입니다.
구분을 흐리지 않는 , 2개월 뒤에도 스스로 판단할 있는 개발자의 감각이에요.
</div>
</Section>
<Section n={3} title="매니페스트 — 홈 화면 아이콘과 이름" sub="브라우저에게 건네는 앱 명함">
<p>
<strong> 매니페스트</strong> <span className="icode">manifest.webmanifest</span>{' '}
라는 작은 <strong>JSON 파일</strong>이에요. " 이름은 뭐고, 아이콘은 이거고, 열리면
주소부터 보여 주고, 주소창은 숨겨 " 브라우저에게 건네는 <strong> 명함</strong>
입니다. 사용자가 "홈 화면에 추가" 누를 브라우저가 파일을 읽어 아이콘과
이름을 만듭니다.
</p>
<Code>{CODE_MANIFEST}</Code>
<p>
만든 명함은 <span className="icode">index.html</span> 걸어 둬야 브라우저가 찾아
읽어요. 방식은 우리가 CSS 파일을 <span className="icode">&lt;link&gt;</span> 거는 것과
똑같습니다.
</p>
<Code>{CODE_MANIFEST_LINK}</Code>
<p>
핵심 필드는 <span className="icode">display</span>예요.{' '}
<span className="icode">"standalone"</span>으로 두면 주소창·탭이 사라지고 전체화면으로
열려, 겉보기엔 진짜 앱과 구분이 됩니다. <span className="icode">icons</span>{' '}
<span className="icode">"purpose": "maskable"</span> 안드로이드가 아이콘을 원형이나
둥근 사각형 틀에 맞춰 잘라 여백을 확보해 주는 옵션이고요.
</p>
<div className="tip">
<b>비유</b> 매니페스트는 <strong>가게 간판 신청서</strong>예요. 상호(name), 로고(icons),
열면 보일 화면(start_url), 간판 (theme_color) 적어 내면, OS가 화면이라는
거리에 우리 가게 간판을 대신 달아 줍니다. 간판만으론 장사가 되죠 실제 영업(오프라인
동작) 다음 섹션의 서비스워커 몫입니다.
</div>
</Section>
<Section n={4} title="서비스워커 — 오프라인과 캐시의 원리" sub="브라우저와 네트워크 사이의 중간 관리인">
<p>
"인터넷이 끊겼는데 어떻게 화면이 뜨지?" PWA 오프라인의 비밀이 바로{' '}
<strong>서비스워커(Service Worker)</strong>예요. 우리 앱과 네트워크{' '}
<strong>사이에 끼어 앉은 자바스크립트</strong>인데, 앱이 보내는 모든 요청을 중간에서
가로채(intercept) "이건 캐시에서 줄게, 이건 서버에 물어볼게" 스스로 결정합니다.
</p>
<Code>{CODE_SW_REGISTER}</Code>
<p>
한번 등록되면 서비스워커는 <strong>페이지와 별개의 백그라운드에서</strong> 돌아요.
그래서 탭을 닫아도, 심지어 앱이 꺼져 있어도 살아 있을 있죠(섹션 5 푸시가 이걸
씁니다). 대신 화면(DOM) 만져요 오직 <strong>요청을 가로채고 캐시를 다루는</strong>{' '}
문지기 역할만 합니다.
</p>
<svg className="diagram" viewBox="0 0 640 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="서비스워커가 앱과 네트워크 사이에 앉아 요청을 가로채는 구조 다이어그램. 앱이 데이터를 요청하면 서비스워커가 먼저 받아, 캐시에 있으면 즉시 돌려주고 없으면 서버로 요청을 넘긴다.">
<rect x="30" y="120" width="130" height="60" rx="10" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="95" y="146" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">React </text>
<text x="95" y="166" textAnchor="middle" fontSize="11" fill="var(--muted)">화면 · fetch()</text>
<rect x="255" y="110" width="130" height="80" rx="10" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="320" y="140" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">서비스워커</text>
<text x="320" y="160" textAnchor="middle" fontSize="11" fill="var(--muted)">요청 가로채기</text>
<text x="320" y="176" textAnchor="middle" fontSize="11" fill="var(--muted)">(문지기)</text>
<rect x="480" y="40" width="130" height="56" rx="10" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="545" y="64" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">캐시</text>
<text x="545" y="82" textAnchor="middle" fontSize="11" fill="var(--muted)">Cache API</text>
<rect x="480" y="204" width="130" height="56" rx="10" fill="var(--card)" stroke="var(--line)" strokeWidth="2" />
<text x="545" y="228" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">서버(네트워크)</text>
<text x="545" y="246" textAnchor="middle" fontSize="11" fill="var(--muted)">Spring API</text>
{/* 앱 → 서비스워커 */}
<line x1="160" y1="150" x2="248" y2="150" stroke="var(--primary)" strokeWidth="2" />
<path d="M255 150 L244 145 L244 155 Z" fill="var(--primary)" />
<text x="204" y="140" textAnchor="middle" fontSize="11" fill="var(--muted)">요청</text>
{/* 서비스워커 → 캐시 (있으면) */}
<line x1="385" y1="135" x2="474" y2="80" stroke="var(--teal)" strokeWidth="2" />
<path d="M480 76 L468 77 L473 87 Z" fill="var(--teal)" />
<text x="430" y="98" textAnchor="middle" fontSize="11" fill="var(--teal)"> 있으면 캐시</text>
{/* 서비스워커 → 서버 (없으면) */}
<line x1="385" y1="165" x2="474" y2="222" stroke="var(--line)" strokeWidth="2" strokeDasharray="5 4" />
<path d="M480 226 L468 216 L466 227 Z" fill="var(--muted)" />
<text x="430" y="212" textAnchor="middle" fontSize="11" fill="var(--muted)"> 없으면 서버</text>
<text x="320" y="290" textAnchor="middle" fontSize="12" fill="var(--muted)">오프라인이어도 캐시() 있으면 화면이 뜬다</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 서비스워커는 앱의 요청을 먼저 받아 캐시에 있으면 즉시 돌려주고, 없을 때만 서버로 넘긴다</p>
<Code>{CODE_SW_FETCH}</Code>
<div className="warn">
<b>양날의 , 캐시</b> 서비스워커가 파일을 캐시에 붙들고 있으면, 버전을 배포해도
사용자에겐 <strong>낡은 화면</strong> 계속 보일 있어요. 그래서 캐시 이름에 버전
(<span className="icode">mirim-v1</span> <span className="icode">v2</span>) 붙이고,
버전이 켜질 캐시를 지우는 처리가 필요합니다. 실무에선 이걸 자동화하려고
섹션 7 플러그인을 쓰죠.
</div>
</Section>
<Section n={5} title="푸시 알림 개념" sub="앱이 꺼져 있어도 서버가 말을 거는 통로">
<p>
네이티브 앱의 상징 같은 기능, <strong>푸시 알림</strong> PWA가 있어요.
원리는 이래요. 사용자가 <strong>허락</strong>하면, 브라우저가 기기만의 '푸시 주소'
발급해 줍니다. 주소를 우리 <strong>Spring 서버</strong> 저장해 두면, 나중에 서버가
주소로 메시지를 쏘고, 백그라운드의 서비스워커가 받아서 알림을 띄웁니다.
</p>
<Code>{CODE_PUSH}</Code>
<p>
중간 배달부는 <strong>OS의 푸시 서비스</strong>예요. 안드로이드·크롬은 FCM,
애플은 APNs가 담당합니다. 우리 서버는 배달부에게 "이 주소로 이 내용 전해 줘"라고
맡기는 거고요. 서명에 쓰는 열쇠 쌍을 <strong>VAPID </strong>라고 부르는데,
"이 발신자는 진짜 우리 서버가 맞다" 증명하는 신분증이라고 보면 됩니다.
</p>
<div className="warn">
<b>권한은 사용자의 </b> <span className="icode">Notification.requestPermission()</span>{' '}
그대로 <strong>물어보는</strong> 거예요. 사용자가 거절하면 알림을 보낼 방법이
아예 없습니다. 그러니 켜자마자 권한 창을 들이밀지 말고, "알림을 왜 켜면 좋은지"
보여 다음 필요한 순간에 요청하는 예의이자 성공률을 높이는 길이에요.
</div>
<div className="tip">
<b>우리 스택과의 연결</b> 우리 백엔드는 <strong>Spring Boot</strong>예요. 구독 정보를
받아 DB에 저장하고, 조건이 되면(: 과제 등록) 저장해 주소로 푸시를 쏘는
<strong> 서버 API</strong> 필요하죠. 프론트의 <span className="icode">/api/push/...</span>{' '}
호출과 서버의 발송 로직이 짝을 이룹니다 웹에서 배운 "요청↔응답" 알림에서도
그대로예요.
</div>
</Section>
<Section n={6} title="네이티브 대비 PWA의 한계" sub="특히 iOS — 어디까지 가고 어디서 막히나">
<p>
여기가 코스에서 가장 정직해야 대목이에요. PWA는 강력하지만{' '}
<strong>네이티브를 완전히 대체하진 못합니다.</strong> 특히 <strong>iOS(아이폰)</strong>
제약이 커요. 왜냐하면 iOS에선 크롬·파이어폭스조차 속으로는 애플의 엔진(WebKit)
쓰기 때문에, <strong>애플이 열어 만큼만</strong> 됩니다.
</p>
<p>대표적인 벽들:</p>
<p style={{ marginTop: -6 }}>
<strong>· 설치 유도가 어렵다</strong> 안드로이드/크롬은 "설치하시겠어요?" 배너를
띄울 있지만, iOS엔 자동 배너가 없어요. 사용자가 <strong>공유 화면에
추가</strong> 손수 눌러야 합니다.
</p>
<p style={{ marginTop: -6 }}>
<strong>· 푸시 알림의 조건</strong> iOS는 <strong>16.4(2023)부터</strong> 푸시를
지원하지만, 반드시 <strong> 화면에 설치된 PWA</strong>에서만 작동해요. 사파리 탭으로
열어 상태에선 됩니다.
</p>
<p style={{ marginTop: -6 }}>
<strong>· 저장 공간·기능 제약</strong> iOS는 캐시·저장소를 오래 쓰면 지워 버릴
있고, 블루투스·NFC·정밀한 백그라운드 실행 같은 기능은 웹에 열어 주지 않아요.
안드로이드는 이보다 훨씬 관대합니다(크롬은 아예 WebAPK로 앱처럼 포장해 줘요).
</p>
<Code>{`기능 PWA(크롬/안드로이드) PWA(iOS/사파리) 네이티브
화면 설치 O (설치 배너 O) O (수동 추가만) O
오프라인 캐시 O O (지워질 있음) O
푸시 알림 O 설치 16.4+ O
카메라·마이크 O O O
블루투스·NFC 일부 X O
앱스토어 노출 X (URL로 공유) X O`}</Code>
<div className="tip">
<b>그래서 언제 PWA인가</b> "설치·오프라인·알림 정도면 충분하고, 코드로 ·안드로이드·
iOS를 <strong>빠르게</strong> 덮고 싶다" → PWA가 최고의 가성비예요. 반대로 "블루투스
기기 제어, 앱스토어 노출, 무거운 3D·센서가 핵심" 그땐 네이티브(또는 React Native
같은 크로스플랫폼) 봐야 합니다. <strong>정답은 없고, 요구사항이 정합니다.</strong>
</div>
</Section>
<Section n={7} title="우리 웹앱에 PWA를 얹는 실습 경로" sub="Vite 프로젝트라서 생각보다 짧다">
<p>
이제 지형을 알았으니 실제 길을 걸어 봅시다. 다행히 우리 프론트엔드는 이미{' '}
<span className="icode">Vite</span> 기반이라, 서비스워커를 손으로 줄씩 짜는 대신{' '}
<strong><span className="icode">vite-plugin-pwa</span></strong>에게 매니페스트와
서비스워커 생성을 맡길 있어요. 캐시 버전 관리 같은 골치 아픈 부분까지 자동으로요.
</p>
<Code>{CODE_VITE_PWA}</Code>
<p>
순서를 정리하면 이렇습니다. <strong> 아이콘</strong>(192·512px PNG){' '}
<span className="icode">public/icons/</span> 넣고 <strong> 설정</strong>{' '}
<span className="icode">vite.config.js</span> 추가하고 <strong>{' '}
<span className="icode">npm run build</span></strong> {' '}
<span className="icode">npm run preview</span> 확인 <strong></strong> 개발자 도구로
검증. 오프라인 테스트는 서버를 끄지 말고, 개발자 도구의 네트워크 탭에서 "오프라인"으로
바꿔 보면 됩니다.
</p>
<div className="tip">
<b>직접 확인해 보기</b> 크롬에서 <span className="kbd">F12</span> {' '}
<span className="kbd">Application</span> 탭을 여세요. 왼쪽에{' '}
<span className="icode">Manifest</span> <span className="icode">Service Workers</span>{' '}
항목이 있어요. 지금 우리 앱을 열면 비어 있을 겁니다(섹션 2에서 확인했듯 아직
PWA가 아니니까요). 플러그인을 붙이고 빌드한 다시 보면, 여기에 우리 앱의 명함과
문지기가 등록된 <strong>눈으로</strong> 있어요.
</div>
<div className="warn">
<b>개발 서버에선 보일 있어요</b> 서비스워커는 보통 <span className="icode">npm run
dev</span> 아니라 <strong>빌드된 결과</strong>(<span className="icode">build</span> {' '}
<span className="icode">preview</span>)에서 온전히 동작해요. "설치 버튼이 안 떠요"
절반은 여기서 옵니다 <span className="icode">dev</span> 아니라{' '}
<span className="icode">preview</span> 열었는지, 그리고 HTTPS(또는 localhost)인지
먼저 확인하세요.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>📱 여기까지 왔다면</h3>
<p className="muted">
이제 "앱을 만들려면 새 언어를 배워야 한다" 막연함 대신, <strong>PWA라는 가장 가까운
</strong> 지형을 읽을 있게 됐어요. 매니페스트(명함), 서비스워커(문지기), 푸시
(알림 통로), 그리고 iOS의 벽까지 우리 React 웹앱을 앱처럼 만드는 재료와 한계를
함께 손에 쥐었습니다. 그런데 앱이 되어 <strong>설치되고 알림을 받는 순간</strong>,
챙길 하나 생겨요. 토큰은 어디에 저장하고, 오프라인 캐시엔 남기면 되고,
푸시 권한은 어떻게 다뤄야 안전할까요? 다음{' '}
<Link to="/learn/app-security"><strong> 보안 기초</strong></Link> 코스에서, 앱이라는
껍데기에 맞는 보안 감각을 이어서 익혀 봅시다.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,425 @@
// : "React Native" React
// ' ' , JS UI
// / ·StyleSheet·Expo· ·
// 7 .
// : API ' ' . global.css
// (step-card, code-block, tip ) .
// JSX / .
// : "2 " " , React " .
import { Link } from 'react-router-dom';
//
const CODE_WHAT = `React Native 한 줄 요약:
자바스크립트(React) 화면 = 진짜 '네이티브' UI
흔한 오해 (): 실제 ():
"웹사이트를 껍데기로 JS 코드가 iOS의 진짜 버튼,
감싼 아냐?" 안드로이드의 진짜 목록으로
= WebView( 브라우저) '변신'해서 그려짐
= 진짜 네이티브 위젯
비유: 설계도(JS) 장을 넘기면, 나라 건축팀(iOS·Android)
각자 '자기 나라 자재' 진짜 건물을 짓는다. 사진을 붙인 가짜 벽이
아니라, 나라의 진짜 벽돌로 쌓은 진짜 건물.
만든 : Meta( Facebook), 2015 공개. Instagram·Discord·
Shopify 우리가 매일 쓰는 앱들도 상당 부분 RN으로 만들어졌어요.`;
const CODE_SAME_REACT = `// 웹에서 배운 그대로 — useState도, props도, JSX도 똑같아요
import { useState } from 'react';
import { View, Text, Pressable } from 'react-native'; // import
function LikeButton({ label }) { // props: React 100%
const [count, setCount] = useState(0); // useState: React 100%
return ( // JSX: ' '
<View>
<Text>{label} {count}</Text>
<Pressable onPress={() => setCount(count + 1)}>
<Text>누르기</Text>
</Pressable>
</View>
);
}
// React '' .
// onClick onPress, <div> <View>, <span> <Text> ...
// '( )' , '(React)' .`;
const CODE_TAGS = `웹(HTML/React) React Native 진짜로 바뀌는 것(네이티브)
<div> <View> UIView / ViewGroup
<span> <p> 글자 <Text> UILabel / TextView
<img> <Image> UIImageView / ImageView
<input> <TextInput> UITextField / EditText
<button> <Pressable> 네이티브 터치 처리
<ul> <li> 목록 <FlatList> UITableView / RecyclerView
onClick= onPress=
className="card" style={styles.card}
철칙 하나: RN에서 '모든 글자' 반드시 <Text> 안에 있어야 한다.
웹처럼 <View>안녕</View> 에러!
RN에선 <View><Text>안녕</Text></View> 정답`;
const CODE_STYLE = `import { StyleSheet, View, Text } from 'react-native';
function Card() {
return (
<View style={styles.card}>
<Text style={styles.title}>미림 스터디</Text>
</View>
);
}
// CSS ' '
const styles = StyleSheet.create({
card: {
backgroundColor: '#ffffff', // background-color camelCase
padding: 16, // 16px
borderRadius: 12,
flexDirection: 'row', // RN 'column'! ( CSS row)
},
title: { fontSize: 18, fontWeight: '600' },
});
// : flex·padding·margin·borderRadius .
// : camelCase(backgroundColor)
// flex column ·() `;
const CODE_EXPO = `# 1) 프로젝트 생성 (컴퓨터에 Node.js만 있으면 됨)
npx create-expo-app@latest MirimApp
cd MirimApp
# 2) 개발 서버 켜기
npx expo start
# 3) 폰에 'Expo Go' 앱을 설치하고, 터미널에 QR코드를
# 카메라(또는 Expo Go ) 스캔
# , 방금 만든 앱이 '내 진짜 폰'에서 돌아간다!
# 시뮬레이터/에뮬레이터가 깔려 있다면 터미널에서 키만 눌러도 :
# i = iOS 시뮬레이터 a = 안드로이드 에뮬레이터 w = 웹브라우저
# 코드를 저장하면 화면이 즉시 갱신(Fast Refresh) 웹의 HMR과 똑같은 감각.`;
const CODE_NATIVE = `JS만으로 안 될 때, 그리고 그때의 선택지
대부분의 기능(카메라·위치·알림·저장소...) 이미 누군가
'네이티브 모듈' 만들어 뒀어요. npm으로 설치해 JS처럼 씁니다:
import * as Location from 'expo-location'; //
import { CameraView } from 'expo-camera'; //
정말 아무도 만든 특수 기능이라면? 그땐 얇게 진짜 네이티브 코드를:
· 안드로이드 Kotlin / Java
· iOS Swift / Objective-C
짧게 작성해 JS와 연결(네이티브 모듈)합니다. 흔한 일은 아니에요.
RN의 그림 = 화면·로직은 JS로(대부분), '정말 필요한 얇은 층' 네이티브.
한계도 정직하게:
· 극한의 성능(고사양 게임·초저지연 그래픽) 순수 네이티브가 유리
· 스토어 출시는 결국 Xcode/Android 빌드가 필요(EAS Build로 클라우드 빌드 가능)
· "2개월에 네이티브 전문가" 과장 목표는 '지형을 읽는 눈'`;
const CODE_WHY_US = `우리 회사 스택으로 보는 'RN이 매력적인 이유'
우리 프론트 : React + JavaScript/TypeScript RN과 같은 언어·같은 개념
우리 백엔드 : Spring Boot REST API (JSON) 앱도 웹과 똑같이 fetch로 호출
프론트가 서버를 부르던 코드, 앱에서도 '그대로':
fetch('https://api.tdatalab.../courses') //
.then((res) => res.json())
.then((data) => setCourses(data)); // useState
서버(우리 Spring API) 대고, 화면 코드의 '태그 이름' 바꿔
안드로이드 + iOS 개가 나온다.
= 이미 배운 React가 ·안드로이드·iOS 곳에서 일하는 .`;
//
// : . props .
// (React 2 .)
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: 'React Native란' },
{ n: 2, label: '지식이 그대로' },
{ n: 3, label: '태그 대응표' },
{ n: 4, label: 'StyleSheet' },
{ n: 5, label: 'Expo 시작' },
{ n: 6, label: '네이티브 한계' },
{ n: 7, label: '우리 팀 & RN' },
];
export default function ReactNativePage() {
return (
<div>
{/* 히어로: 웹에서 배운 React가 어떻게 앱으로 이어지는지 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>React Native<br /> React 지식이 그대로 앱이 되는 다리</h1>
<p>
"앱 개발은 완전히 다른 세계 아닌가요?" 절반만 맞아요. React Native는{' '}
<strong>우리가 배운 React 그대로</strong> 컴포넌트·props·state·JSX를 써서
iOS·안드로이드에서 도는 <strong>진짜 네이티브 </strong> 만듭니다. 코스는
전문가가 되는 지름길이 아니라, <strong> 개발자의 눈으로 앱의 지형을 읽고
실력으로 거기까지 가는 </strong> 그려 봅니다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 60</span>
<span className="chip">실습: Expo 1 띄우기</span>
<span className="chip">선수 지식: React 입문</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="React Native란" sub="자바스크립트로 '진짜' 네이티브 UI를 그린다">
<p>
React Native(줄여서 RN) <strong>자바스크립트(React) 스마트폰 앱을 만드는
프레임워크</strong>예요. 여기서 제일 중요한 오해 하나를 먼저 깨고 갑시다.
RN은 웹사이트를 아이콘으로 감싼{' '}
<span className="icode">WebView</span>( 속에 작은 브라우저){' '}
<strong>아닙니다</strong>. 우리가 {' '}
<span className="icode">&lt;Text&gt;</span> 줄은 iOS에선 진짜{' '}
<span className="icode">UILabel</span>, 안드로이드에선 진짜{' '}
<span className="icode">TextView</span>라는 <strong> OS의 순정 부품</strong>으로
바뀌어 그려져요.
</p>
<Code>{CODE_WHAT}</Code>
<p>
그래서 스크롤 ·터치 반응·키보드가 폰의 네이티브 앱과 똑같이 자연스럽습니다.
"웹을 흉내 낸 앱" 아니라, <strong>JS라는 공용 설계도로 OS의 진짜 앱을
동시에 짓는 </strong> 이게 RN의 핵심이에요.
</p>
<svg className="diagram" viewBox="0 0 640 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="자바스크립트로 작성한 React 컴포넌트 하나가 React Native를 거쳐 iOS의 진짜 네이티브 위젯(UIView·UILabel)과 안드로이드의 진짜 네이티브 위젯(ViewGroup·TextView) 두 가지로 각각 변환되는 구조. 웹뷰가 아니라 진짜 네이티브 UI라는 점을 보여 준다.">
<rect x="215" y="20" width="210" height="46" rx="10" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="320" y="41" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">React 컴포넌트</text>
<text x="320" y="58" textAnchor="middle" fontSize="12" fill="var(--muted)">JavaScript · JSX (우리가 쓰는 코드)</text>
<line x1="320" y1="66" x2="320" y2="104" stroke="var(--line)" strokeWidth="2" />
<path d="M320 112 L314 100 L326 100 Z" fill="var(--line)" />
<rect x="245" y="112" width="150" height="42" rx="10" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="320" y="138" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">React Native</text>
<line x1="320" y1="154" x2="170" y2="204" stroke="var(--line)" strokeWidth="2" />
<path d="M170 210 L162 199 L176 197 Z" fill="var(--line)" />
<line x1="320" y1="154" x2="470" y2="204" stroke="var(--line)" strokeWidth="2" />
<path d="M470 210 L464 197 L478 199 Z" fill="var(--line)" />
<rect x="55" y="210" width="230" height="48" rx="10" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="170" y="231" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">iOS</text>
<text x="170" y="249" textAnchor="middle" fontSize="12" fill="var(--muted)">UIView · UILabel (진짜 위젯)</text>
<rect x="355" y="210" width="230" height="48" rx="10" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="470" y="231" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">Android</text>
<text x="470" y="249" textAnchor="middle" fontSize="12" fill="var(--muted)">ViewGroup · TextView (진짜 위젯)</text>
<text x="320" y="292" textAnchor="middle" fontSize="12.5" fill="var(--muted)">웹뷰(WebView) 아님 · HTML 아님 OS의 진짜 네이티브 UI</text>
<text x="320" y="316" textAnchor="middle" fontSize="12.5" fill="var(--rose)">설계도(JS) 하나, 지어지는 진짜 건물은 </text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: JS로 컴포넌트 하나가 RN을 거쳐 iOS·안드로이드의 진짜 네이티브 위젯으로 각각 변환된다</p>
<div className="tip">
<b>기억할 문장</b> RN은 "웹을 앱처럼 포장"하는 아니라
"JS로 두 OS의 진짜 앱을 짓는" 도구예요. 그래서 결과물의 감촉이
네이티브 앱과 같습니다.
</div>
</Section>
<Section n={2} title="우리 React 지식이 그대로 통한다" sub="컴포넌트·state·props — 새 언어가 아니라 새 태그 세트">
<p>
RN 앞에서 겁먹을 필요가 없는 진짜 이유는 이거예요. <strong>React 입문에서 배운
거의 전부가 그대로 통합니다.</strong> 컴포넌트는 여전히 "JSX를 반환하는 함수",{' '}
<span className="icode">props</span> 여전히 부모가 주는 읽기 전용 선물,{' '}
<span className="icode">useState</span> 여전히 <strong>[현재값, 바꾸는 함수]</strong>{' '}
쌍이에요. 배우는 <strong> 프로그래밍 언어가 아니라, 개의 태그 이름</strong>
뿐입니다.
</p>
<Code>{CODE_SAME_REACT}</Code>
<p>
예제를 React 입문의 "좋아요 버튼" 나란히 놓고 보면, 달라진 {' '}
<span className="icode">import</span> 출처와 태그 이름(<span className="icode">divView</span>,{' '}
<span className="icode">spanText</span>,{' '}
<span className="icode">onClickonPress</span>)뿐이에요. "화면 = 상태의 함수",{' '}
"state가 바뀌면 다시 그린다" <strong>React의 심장은 웹이든 앱이든 똑같이
뜁니다.</strong>
</p>
<div className="tip">
<b>이미 8할은 안다</b> React 입문을 마쳤다면 RN의 학습량은 생각보다 작아요.
컴포넌트·JSX·props/state·이벤트·목록·useEffect는 그대로 쓰고, " 태그 RN
컴포넌트" 대응표와 스타일 방식만 새로 얹으면 됩니다. 그게 다음 섹션이에요.
</div>
</Section>
<Section n={3} title="웹 태그 vs RN 컴포넌트" sub="div→View, span→Text — 대응표 한 장이면 절반은 끝">
<p>
웹에는 <span className="icode">&lt;div&gt;</span>·
<span className="icode">&lt;span&gt;</span>·
<span className="icode">&lt;img&gt;</span> 같은 태그가 있었죠. RN엔 그에
<strong>대응하는 컴포넌트</strong> 있어요. 이름만 바뀌었지 역할은 거의
같습니다. 아래 대응표가 사실상 "웹 개발자용 RN 통역 사전"이에요.
</p>
<Code>{CODE_TAGS}</Code>
<p>
하나만 특별히 강조할게요. 웹에선 <span className="icode">&lt;div&gt;</span> 안에
글자를 아무렇게나 써도 됐지만, RN에선 <strong>모든 글자가 반드시{' '}
<span className="icode">&lt;Text&gt;</span> 안에</strong> 있어야 해요. 이건 실수가
아니라 규칙이에요 네이티브에선 "글자를 그리는 부품" "레이아웃 상자" 아예
다른 부품이라서 그렇습니다.
</p>
<div className="warn">
<b>첫날 100% 만나는 에러</b> "Text strings must be rendered within a{' '}
&lt;Text&gt; component"가 뜨면, <span className="icode">&lt;View&gt;</span> 안에
글자를 맨몸으로 넣은 거예요. 글자를{' '}
<span className="icode">&lt;Text&gt;</span> 감싸면 바로 해결됩니다.
웹의 습관이 발목을 잡는 지점이니, 미리 알아 두면 당황하지 않아요.
</div>
</Section>
<Section n={4} title="스타일(StyleSheet)" sub="CSS와 닮았지만 CSS가 아니다 — 같은 점 4·다른 점 4">
<p>
RN에서도 ·여백·정렬을 스타일로 지정해요. 다만 별도의 CSS 파일이 아니라{' '}
<strong>자바스크립트 객체</strong> 씁니다.{' '}
<span className="icode">StyleSheet.create({'{ ... }'})</span> 스타일 묶음을 만들고,{' '}
<span className="icode">className</span> 대신{' '}
<span className="icode">style={'{styles.card}'}</span>처럼 객체를 건네죠.
</p>
<Code>{CODE_STYLE}</Code>
<p>
<strong>반가운 (같은 ):</strong>{' '}
<span className="icode">flex</span>·<span className="icode">padding</span>·
<span className="icode">margin</span>·<span className="icode">borderRadius</span>
이름과 개념이 CSS와 거의 그대로예요. 웹에서 배운 Flexbox 감각이 통째로 재사용됩니다.
</p>
<p>
<strong>조심할 (다른 ) 4가지:</strong> 속성 이름은 camelCase {' '}
<span className="icode">background-color</span> 아니라{' '}
<span className="icode">backgroundColor</span>. 크기 숫자엔 단위가 없어요(
<span className="icode">px</span> 붙임). {' '}
<span className="icode">flexDirection</span> 기본값이 웹의{' '}
<span className="icode">row</span> 아니라{' '}
<span className="icode">column</span>(세로). CSS 셀렉터·상속(캐스케이드) 없어서
스타일이 부모자식으로 자동 전파되지 않아요.
</p>
<div className="warn">
<b>가장 많이 헤매는 지점</b> "왜 가로로 안 놓이고 자꾸 세로로 쌓이지?"
RN의 <span className="icode">flexDirection</span> 기본값이{' '}
<span className="icode">column</span>이기 때문이에요. 가로 배치를 원하면{' '}
<span className="icode">flexDirection: 'row'</span> 직접 적어 주세요.
Flexbox의 기본이 <span className="icode">row</span>였던 것과 정반대라 기억!
</div>
</Section>
<Section n={5} title="Expo로 5분 만에 시작하기" sub="설치 지옥 없이, 내 진짜 폰에서 바로 실행">
<p>
예전엔 개발 첫걸음이 "안드로이드 스튜디오·Xcode 설치와 설정"이라는 산이었어요.{' '}
<strong>Expo</strong> 산을 치워 줍니다. Node.js만 있으면 명령어 줄로 프로젝트를
만들고, <strong> 폰에 <span className="icode">Expo Go</span> 앱으로 QR코드만
스캔하면</strong> 방금 만든 앱이 진짜 폰에서 바로 돌아가요.
</p>
<Code>{CODE_EXPO}</Code>
<p>
좋은 , 코드를 저장하는 순간 화면이 즉시 갱신되는{' '}
<strong>Fast Refresh</strong>예요. React 입문 마지막에 느낀 웹의{' '}
<span className="icode">HMR</span>(저장하면 브라우저가 즉시 바뀌던 경험){' '}
<strong>똑같은 감각</strong> 폰에서 그대로 느낄 있습니다. "코드 저장 폰에서
즉시 확인" 리듬이 개발의 일상이에요.
</p>
<div className="tip">
<b>오늘의 실습</b> 컴퓨터에 Node.js가 있다면 3줄을 그대로 따라 보세요.
<span className="icode">MirimApp</span> 뜨면{' '}
<span className="icode">App.js</span> 글자 하나를 바꿔 저장 화면이 즉시
바뀌는 눈으로 확인! "앱이 이렇게 가볍게 시작되는구나" 몸으로 느끼는
코스의 목표 절반입니다.
</div>
</Section>
<Section n={6} title="네이티브 모듈과 한계" sub="어디까지 JS로 되고, 어디부터 네이티브가 필요한가">
<p>
"그럼 카메라·위치·푸시 알림 같은 폰 기능은 어떻게 써요?" 좋은 질문이에요.
답은 <strong>네이티브 모듈</strong>입니다. 다행히 흔한 기능은{' '}
<strong>이미 누군가 모듈로 만들어 뒀어요.</strong> npm으로 설치해서 평소처럼
JS로 <span className="icode">import</span> 쓰면 됩니다.
</p>
<Code>{CODE_NATIVE}</Code>
<p>
정말 아무도 만든 특수 기능이 필요할 때만, 얇게{' '}
<strong>진짜 네이티브 코드(안드로이드는 Kotlin/Java, iOS는 Swift/Objective-C)</strong>
작성해 JS와 연결해요. 실무에서 이런 일은 자주 있지 않습니다. RN의 전형적인 그림은
<strong>"화면·로직은 JS로 대부분, 정말 필요한 얇은 층만 네이티브"</strong>예요.
</p>
<div className="warn">
<b>과장하지 않기</b> RN이 만능은 아니에요. 초저지연 그래픽·고사양 게임 같은
극한 성능은 순수 네이티브가 유리하고, 스토어 출시는 결국 OS의 빌드 과정(클라우드로
대신 주는 <span className="icode">EAS Build</span> 있음) 필요합니다.
코스의 목표는 "2개월에 앱 전문가" 아니라 <strong>지형을 정확히 읽는 </strong>
갖는 거예요 어디까지 React로 있고, 어디서 도움이 필요한지를 아는 .
</div>
</Section>
<Section n={7} title="우리 팀에게 RN이 특히 매력적인 이유" sub="같은 언어·같은 개념·같은 우리 Spring API">
<p>
하필 회사에서 RN 이야기를 할까요? <strong>우리 스택과 궁합이 좋기 때문</strong>이에요.
우리 프론트는 이미 <strong>React + JavaScript/TypeScript</strong>, 백엔드는{' '}
<strong>Spring Boot REST API</strong>입니다. RN은 프론트와 <strong>같은 언어,
같은 React 개념</strong> 쓰죠. 웹을 만들던 사람이 재학습 없이 앱도 손댈
있다는 뜻이에요.
</p>
<Code>{CODE_WHY_US}</Code>
<p>
결정적으로, 앱은 <strong>우리 Spring API를 웹과 똑같이{' '}
<span className="icode">fetch</span> 호출</strong>해요. 서버는 그대로 두고,
화면 코드의 "태그 이름" 바꿔 안드로이드·iOS 앱을 얹는 셈입니다. React 입문에서
배운 <span className="icode">useEffect</span> + <span className="icode">fetch</span>{' '}
패턴이 앱에서도 판박이로 재등장하죠.
</p>
<div className="tip">
<b>연결해서 보기</b> "네트워크의 이해" 코스에서 배운 HTTP 요청, React 입문에서 배운{' '}
<span className="icode">useEffect</span> 데이터 로딩 둘이 RN 앱에서 그대로
다시 쓰입니다. 배운 사라지지 않고 <strong> 플랫폼에서 복리로 불어나는</strong>{' '}
느낌, 그게 우리가 React 계열을 붙잡는 이유예요.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>📱 여기까지 왔다면</h3>
<p className="muted">
이제 "React Native = JS로 짓는 진짜 네이티브 앱"이라는 문장을,{' '}
태그 대응표·StyleSheet·Expo·네이티브 한계라는 단어들로 설명할 있게 됐어요.
무엇보다 <strong>내가 배운 React가 앱까지 이어진다</strong> 지형을 손에 넣었습니다.
RN이 "우리 React를 그대로 쓰는 길"이었다면, 다음은 <strong>완전히 다른 접근</strong>으로
같은 목표(하나의 코드로 OS) 도달하는{' '}
<Link to="/learn/flutter"><strong>Flutter</strong></Link> 만나 차례예요.
길을 나란히 놓고 보면 "앱 개발의 지도" 훨씬 또렷해집니다.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,489 @@
// : "Swift " iOS() Swift,
// JavaScript/React .
// " " let/var·, , , struct vs class,
// 7 , JS/TS .
// : API ' ' . global.css
// (step-card, code-block, tip ) .
// JSX / .
// ( iOS . ' ' .)
import { Link } from 'react-router-dom';
//
const CODE_LANDSCAPE = `플랫폼마다 '공식 언어'가 다르다 — 문법보다 지형이 먼저
브라우저 JavaScript / TypeScript 네가 지금 쓰는
iOS (아이폰) Swift + SwiftUI / UIKit
안드로이드 Kotlin / Java + Jetpack Compose
양쪽 번에 React Native, Flutter 바로 다음 코스!
Swift은 애플이 2014년에 내놓은 언어예요.
그전까지 iOS는 Objective-C라는, 대괄호가 잔뜩 붙는 옛날 언어를
썼는데, Swift은 '요즘 언어' 편의(타입 추론·옵셔널·클로저)
처음부터 담아 새로 설계됐습니다. 안전하고, 빠르고, 읽기 쉽게.
좋은 소식: 네가 아는 JS/TS 지식의 상당 부분이 그대로 옮겨져요.
낯선 부분이 코스의 주인공 let/var, , ' '.`;
const CODE_LETVAR = `// let = 상수(바뀌지 않음), var = 변수(바뀔 수 있음)
let name = "미림" // JS const
var age = 18 // JS let
age = 19 // OK
// name = "" // ! let
// : Swift .
// (:) TypeScript .
let city: String = "서울"
let score: Int = 95
let ratio: Double = 3.14
let isMentor: Bool = false
// Swift . .
let a = 3
let b = 2.0
// let c = a + b // Int + Double
let c = Double(a) + b // 5.0
// ( ):
// JS '+', Swift +.
print("이름 \\(name), 나이 \\(age)") // , 19`;
const CODE_OPTIONAL = `// 옵셔널 = "값이 없을 수도 있음(nil)"을 타입에 대놓고 표시
var nickname: String? = "미리내" // String? = String nil
nickname = nil // ''
// ? JS :
// Cannot read properties of null (reading 'count')
// '' . Swift .
// nil (?) .
//
// 1) if let :
if let n = nickname {
print("반가워, \\(n)") // n String (nil )
} else {
print("닉네임이 없어요")
}
// 2) guard let : , ( )
func greet(_ name: String?) {
guard let name = name else { return }
print("안녕, \\(name)") // name String
}
// 3) ?? (nil ) : JS ??
let shown = nickname ?? "손님"
// 4) ?. ( ) : , nil JS
let len = nickname?.count // Int? (nil )
// nickname! " !" .
// . .`;
const CODE_FUNC_CLOSURE = `// 함수 — func 이름(인자: 타입) -> 반환타입
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
add(3, 4) // 7
// Swift : ' '. .
func power(base: Int, to exponent: Int) -> Int { /* ... */ }
power(base: 2, to: 10) // "base 2, to 10"
// = (JS )
// JS: const double = (x) => x * 2
// Swift: let double = { (x: Int) -> Int in x * 2 }
let double = { (x: Int) -> Int in x * 2 }
double(21) // 42
// ' ' map / filter
let nums = [1, 2, 3, 4]
let evens = nums.filter { $0 % 2 == 0 } // [2, 4]
let squared = nums.map { $0 * $0 } // [1, 4, 9, 16]
// $0 = . { n in n * n } .
// ' ' .
// React .map() , .`;
const CODE_STRUCT_CLASS = `// struct = 값 타입(복사됨), class = 참조 타입(공유됨) — 이게 최대 차이!
struct PointS { var x: Int; var y: Int } //
class PointC {
var x: Int; var y: Int
init(x: Int, y: Int) { self.x = x; self.y = y } //
}
// : ''
var s1 = PointS(x: 1, y: 1)
var s2 = s1 //
s2.x = 99
print(s1.x) // 1 s1 ! (s1, s2 )
// : ' '
let c1 = PointC(x: 1, y: 1)
let c2 = c1 //
c2.x = 99
print(c1.x) // 99 c1 ! (c1, c2 )
// JS ({}) class .
// Swift struct() '' . .
// SwiftUI (View) struct .`;
const CODE_PROTOCOL = `// 프로토콜 = "이런 기능을 갖춰야 한다"는 약속(계약).
// TypeScript interface, Java interface .
protocol Greetable {
var name: String { get } //
func greet() -> String //
}
// (conform):
struct Student: Greetable {
let name: String
func greet() -> String { "안녕, 나는 \\(name)" }
}
class Robot: Greetable {
let name: String
init(name: String) { self.name = name }
func greet() -> String { "삐빅, \\(name) 가동" }
}
// : struct class, Greetable
// ().
let crew: [Greetable] = [Student(name: "미림"), Robot(name: "T-800")]
for member in crew { print(member.greet()) }
// Swift ' (POP)' (is-a)
// " (can-do)" .`;
const CODE_ROSETTA = `같은 개념, 다른 표기 — '번역표'로 머릿속에 얹기
개념 JavaScript / TS Swift
불변 / 가변 const / let let / var
타입 명시 let x: number let x: Int
없음( ) null / undefined nil
가능 표시 x: string | null x: String?
기본값 대체 x ?? "기본" x ?? "기본" (동일!)
안전한 접근 obj?.prop obj?.prop (동일!)
익명 함수 (x) => x * 2 { x in x * 2 }
배열 변환 arr.map(f) arr.map(f) (거의 동일)
인터페이스 interface protocol
객체(참조 공유) {} · class class
타입(복사) struct Swift의 개성
읽는 : 왼쪽(네가 아는 ) 다리 삼아 오른쪽으로 건너가라.
정말 새로 익힐 아래 '옵셔널' '값 타입 struct'뿐이다.`;
//
// : . props .
function Section({ n, title, sub, children }) {
return (
<section className="step-card" id={`sec-${n}`}>
<div className="step-head">
<span className="step-num">{n}</span>
<div>
<h3>{title}</h3>
{sub && <span className="step-sub">{sub}</span>}
</div>
</div>
<div className="step-body">{children}</div>
</section>
);
}
function Code({ children }) {
return <pre className="code-block"><code>{children}</code></pre>;
}
const SECTIONS = [
{ n: 1, label: '왜 Swift인가' },
{ n: 2, label: 'let / var' },
{ n: 3, label: '옵셔널' },
{ n: 4, label: '함수·클로저' },
{ n: 5, label: 'struct vs class' },
{ n: 6, label: '프로토콜' },
{ n: 7, label: '요약' },
];
export default function SwiftIntroPage() {
return (
<div>
{/* 히어로: 웹 개발자의 눈으로 iOS의 언어를 읽는다 */}
<div className="hero">
<div className="eyebrow">Course · 개발</div>
<h1>Swift 입문<br /> iOS를 움직이는 언어</h1>
<p>
우리 회사의 화면은 (React)이에요. 아이폰 앱은 다른 대륙이고, 대륙의
공식 언어가 <strong>Swift</strong>입니다. 2개월 만에 네이티브 전문가가 되자는
아니라 <strong>이미 아는 JavaScript를 다리 삼아</strong> Swift의 지형을 읽고,
"여기서 앱까지 어떻게 건너가는지" 지도를 손에 쥐는 목표예요.
</p>
<div className="chip-row">
<span className="chip">예상 소요 70</span>
<span className="chip">실습: 문법 손코딩</span>
<span className="chip">선수 지식: JS·React 기초</span>
</div>
</div>
{/* 섹션 바로가기 */}
<nav className="pill-nav">
{SECTIONS.map((s) => (
<a key={s.n} href={`#sec-${s.n}`}>
<b>{s.n}</b>
{s.label}
</a>
))}
</nav>
<Section n={1} title="왜 Swift인가" sub="문법보다 지형이 먼저다">
<p>
프로그래밍 언어는 하나가 아니에요. <strong>플랫폼마다 '공식 언어' 다릅니다.</strong>{' '}
브라우저는 자바스크립트, 안드로이드는 주로 Kotlin, 그리고{' '}
<strong>아이폰(iOS) Swift</strong>. 우리가 매일 만지는 (React)이지만,
"앱은 어떻게 만들지?"라는 질문에 답하려면 지형도부터 머릿속에 그려야 해요.
</p>
<Code>{CODE_LANDSCAPE}</Code>
<p>
Swift은 애플이 만든 <strong>비교적 최신 언어</strong>, 네가 이미 아는 요즘 문법들(타입
추론, <span className="icode">?.</span>, <span className="icode">??</span>, 화살표
함수 같은 클로저) 곳곳에 그대로 있어요. 그래서 코스는 "처음부터" 아니라{' '}
<strong>"JS와 뭐가 같고 뭐가 다른가"</strong> 진행합니다.
</p>
<p>
참고로 실제 iOS 앱은 애플의 개발 도구 <span className="icode">Xcode</span>( 전용)에서
만들고, 거기서 <span className="kbd"></span>+<span className="kbd">R</span> 누르면
아이폰 시뮬레이터로 앱이 실행돼요. 다만 코스는 <strong>도구가 아니라 언어 자체</strong>
집중합니다 문법의 눈을 먼저 갖추면, 도구는 그다음에 붙이면 되니까요.
</p>
<div className="tip">
<b>미리 보는 그림</b> 코스에서 정말 새로 배울 개예요 {' '}
<strong>옵셔널</strong>(섹션 3) <strong> 타입 struct</strong>(섹션 5).
나머지는 아는 개념에 옷을 입히는 정도랍니다.
</div>
</Section>
<Section n={2} title="변수 let / var 와 타입" sub="const/let 이 거의 그대로 옮겨온다">
<p>
Swift에서 값에 이름을 붙이는 방법은 둘이에요. <span className="icode">let</span>{' '}
<strong>상수</strong>( 정하면 바뀜), <span className="icode">var</span>{' '}
<strong>변수</strong>(바뀔 있음). JS를 했다면 <span className="icode">const</span>{' '}
<span className="icode">let</span> 그대로 이사 거라고 보면 돼요. 이름이 살짝
헷갈리는데, <strong>JS의 <span className="icode">let</span> Swift의{' '}
<span className="icode">var</span></strong>라는 점만 주의!
</p>
<Code>{CODE_LETVAR}</Code>
<p>
타입을 콜론으로 붙이는 <span className="icode">let score: Int = 95</span> 문법은
TypeScript와 판박이예요. 값만 주면 Swift가 타입을 알아서 <strong>추론</strong>하지만,
가지 크게 다른 점은 <strong>자동 형변환을 거의 준다</strong> . JS는{' '}
<span className="icode">3 + "2"</span> 슬그머니 <span className="icode">"32"</span>
만들지만, Swift는 "타입 다른 걸 왜 섞어?"라며 컴파일 단계에서 막아요.
</p>
<div className="tip">
<b>습관 하나</b> 특별한 이유가 없으면 일단 <span className="icode">let</span>부터
쓰세요. "이건 나중에 바꿔야 해"라는 확신이 때만 <span className="icode">var</span>.
바뀌지 않는 값을 상수로 박아 두면, 실수로 값이 변하는 버그 자체가 원천 봉쇄됩니다.
</div>
</Section>
<Section n={3} title="옵셔널(Optional)" sub="nil 안전 — Kotlin의 그것과 형제다">
<p>
여기가 번째 진짜 새로운 개념이에요. <strong>옵셔널</strong> " 값은{' '}
<span className="icode">nil</span>(비어 있음) 수도 있다" 사실을{' '}
<strong>타입에 대놓고 표시</strong>하는 장치입니다. 타입 뒤에 물음표를 붙여{' '}
<span className="icode">String?</span>이라고 쓰면 "String 이거나, 아니면 nil"이라는
뜻이에요.
</p>
<Code>{CODE_OPTIONAL}</Code>
<p>
이렇게까지 할까요? JS에서 <span className="icode">null</span> 값의 속성을
읽다가 앱이 <strong>실행 도중에 터지는</strong> 경험, 다들 번쯤 하죠. Swift은
위험을 <strong>실행 (컴파일 단계)</strong> 잡아요. nil이 있는 값은 반드시{' '}
<span className="icode">?</span> 표시하고, 반드시 <strong>안전하게 열도록</strong>{' '}
강제하니까요. <span className="icode">if let</span>, <span className="icode">guard let</span>,{' '}
<span className="icode">??</span>, <span className="icode">?.</span> 가지가 여는 열쇠입니다.
</p>
<svg className="diagram" viewBox="0 0 640 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="옵셔널 String 물음표는 값이거나 nil이다. if let, guard, nil 병합 물음표물음표, 옵셔널 체이닝 같은 방법으로 열면 안전하지만, 느낌표로 강제 언래핑하면 nil일 때 앱이 크래시한다">
<rect x="46" y="90" width="150" height="58" rx="10" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
<text x="121" y="115" textAnchor="middle" fontSize="15" fontWeight="600" fill="var(--ink)">String?</text>
<text x="121" y="136" textAnchor="middle" fontSize="12" fill="var(--muted)"> 또는 nil</text>
<line x1="196" y1="106" x2="356" y2="62" stroke="var(--teal)" strokeWidth="2" />
<path d="M362 59 L349 58 L354 70 Z" fill="var(--teal)" />
<rect x="362" y="38" width="236" height="54" rx="10" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="480" y="61" textAnchor="middle" fontSize="13" fill="var(--ink)">if let · guard · ?? · ?.</text>
<text x="480" y="80" textAnchor="middle" fontSize="12" fill="var(--muted)">안전하게 꺼내기 </text>
<line x1="196" y1="132" x2="356" y2="176" stroke="var(--rose)" strokeWidth="2" />
<path d="M362 179 L349 180 L354 168 Z" fill="var(--rose)" />
<rect x="362" y="150" width="236" height="54" rx="10" fill="var(--card)" stroke="var(--rose)" strokeWidth="2" />
<text x="480" y="173" textAnchor="middle" fontSize="13" fill="var(--ink)">강제 언래핑 nickname!</text>
<text x="480" y="192" textAnchor="middle" fontSize="12" fill="var(--muted)">nil이면 크래시 </text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 같은 옵셔널이라도 안전하게 열면(·teal) 무사하고, 강제로 열면(아래·rose) nil일 앱이 멈춘다</p>
<div className="tip">
<b>Kotlin 아는 사람에게</b> 안드로이드의 Kotlin도 똑같은 안정성을 가져요.
거의 1:1 대응됩니다 가능 타입 <span className="icode">String?</span>(양쪽 동일),
안전한 접근 <span className="icode">?.</span>(동일), 기본값은 Kotlin{' '}
<span className="icode">?:</span>(엘비스) Swift <span className="icode">??</span>,
그리고 위험한 강제는 Kotlin <span className="icode">!!</span> Swift{' '}
<span className="icode">!</span>. 이름만 다른 같은 안전벨트예요.
</div>
<div className="warn">
<b>느낌표(!) 조심</b> 강제 언래핑 <span className="icode">nickname!</span> "여긴
절대 nil 아냐!"라고 개발자가 책임지는 선언이에요. 약속이 틀리는 순간 앱이
자리에서 죽습니다. 입문 단계에선 <span className="icode">if let</span>·
<span className="icode">??</span> 도는 습관을 먼저 들이세요.
</div>
</Section>
<Section n={4} title="함수와 클로저" sub="화살표 함수를 아는 너에게">
<p>
함수는 <span className="icode">func 이름(인자: 타입) -&gt; 반환타입</span> 꼴이에요.
JS와 다른 재미난 점은 <strong>'인자 라벨'</strong> 호출할 인자에 이름을 붙여{' '}
<span className="icode">power(base: 2, to: 10)</span>처럼 <strong>문장처럼 읽히게</strong>{' '}
만듭니다. 순서를 헷갈릴 일이 줄죠.
</p>
<Code>{CODE_FUNC_CLOSURE}</Code>
<p>
<strong>클로저(closure)</strong> 이름 없는 함수 덩어리예요. 네가 React에서 매일 쓰던{' '}
<span className="icode">(x) =&gt; x * 2</span> 화살표 함수의 사촌이라고 보면 됩니다.
진가는 <span className="icode">.map()</span>·<span className="icode">.filter()</span>처럼{' '}
<strong>함수에 함수를 넘길 </strong> 나와요. React에서 배열을{' '}
<span className="icode">.map()</span>으로 목록 UI로 바꿨던 감각이, Swift에서도
거의 똑같이 통합니다.
</p>
<div className="tip">
<b>$0 뭐죠?</b> <span className="icode">nums.map {'{ $0 * $0 }'}</span>에서{' '}
<span className="icode">$0</span> "첫 번째 인자" 축약이에요.{' '}
<span className="icode">{'{ n in n * n }'}</span>이라고 이름 붙여 것을 짧게
줄인 것뿐. 그리고 클로저가 마지막 인자면 소괄호 밖으로 빼서 뒤에 붙이는{' '}
<strong>트레일링 클로저</strong> 문법이라, 괄호가 사라져 짧아 보이는 거예요.
</div>
</Section>
<Section n={5} title="구조체(struct) vs 클래스(class)" sub="복사되는 값 vs 공유되는 참조">
<p>
Swift엔 데이터를 담는 그릇이 있어요 <span className="icode">struct</span>{' '}
<span className="icode">class</span>. 문법은 비슷해 보이지만 <strong>결정적으로 다른
가지</strong> 있습니다. <strong>struct는 타입(대입하면 복사됨)</strong>,{' '}
<strong>class는 참조 타입(대입하면 같은 것을 공유함)</strong>이에요.
</p>
<Code>{CODE_STRUCT_CLASS}</Code>
<p>
JS만 하던 사람에겐 <strong>struct의 '복사' 동작이 낯설어요.</strong> JS에서 객체를
다른 변수에 넣으면 둘은 <strong>같은 객체</strong> 가리켜서(참조), 한쪽을 고치면
다른 쪽도 바뀌었죠 그게 바로 Swift의 <span className="icode">class</span> 같은
동작입니다. 반면 <span className="icode">struct</span> 대입하는 순간 <strong>각자
독립된 복사본</strong> 돼서, 서로 영향을 주지 않아요.
</p>
<svg className="diagram" viewBox="0 0 640 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="struct 값 타입은 s1과 s2가 각자 다른 상자를 가져 s2를 바꿔도 s1은 그대로다. class 참조 타입은 c1과 c2가 같은 상자 하나를 가리켜 c2를 바꾸면 c1도 함께 바뀐다">
<text x="165" y="26" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">struct 타입 (복사)</text>
<rect x="70" y="46" width="80" height="34" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="110" y="68" textAnchor="middle" fontSize="13" fill="var(--ink)">s1</text>
<rect x="180" y="46" width="80" height="34" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
<text x="220" y="68" textAnchor="middle" fontSize="13" fill="var(--ink)">s2</text>
<line x1="110" y1="80" x2="110" y2="112" stroke="var(--line)" strokeWidth="2" />
<path d="M110 120 L104 108 L116 108 Z" fill="var(--line)" />
<line x1="220" y1="80" x2="220" y2="112" stroke="var(--line)" strokeWidth="2" />
<path d="M220 120 L214 108 L226 108 Z" fill="var(--line)" />
<rect x="66" y="120" width="88" height="40" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="110" y="145" textAnchor="middle" fontSize="13" fill="var(--ink)">x = 1</text>
<rect x="176" y="120" width="88" height="40" rx="8" fill="var(--card)" stroke="var(--line)" />
<text x="220" y="145" textAnchor="middle" fontSize="13" fill="var(--ink)">x = 99</text>
<text x="165" y="188" textAnchor="middle" fontSize="12" fill="var(--muted)">각자 다른 상자 s2를 바꿔도 s1.x 1</text>
<line x1="320" y1="36" x2="320" y2="196" stroke="var(--line)" strokeWidth="1" strokeDasharray="4 4" />
<text x="475" y="26" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">class 참조 타입 (공유)</text>
<rect x="392" y="46" width="80" height="34" rx="8" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="432" y="68" textAnchor="middle" fontSize="13" fill="var(--ink)">c1</text>
<rect x="490" y="46" width="80" height="34" rx="8" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="530" y="68" textAnchor="middle" fontSize="13" fill="var(--ink)">c2</text>
<line x1="432" y1="80" x2="462" y2="112" stroke="var(--line)" strokeWidth="2" />
<path d="M468 118 L455 116 L462 106 Z" fill="var(--line)" />
<line x1="530" y1="80" x2="500" y2="112" stroke="var(--line)" strokeWidth="2" />
<path d="M494 118 L500 106 L507 116 Z" fill="var(--line)" />
<rect x="435" y="120" width="94" height="40" rx="8" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
<text x="482" y="145" textAnchor="middle" fontSize="13" fill="var(--ink)">x = 99</text>
<text x="475" y="188" textAnchor="middle" fontSize="12" fill="var(--muted)">같은 상자 하나 c2를 바꾸면 c1.x 99</text>
</svg>
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: struct는 대입 복사돼 서로 남남, class는 같은 상자를 가리켜 몸으로 움직인다</p>
<div className="tip">
<b>고를 기준</b> Swift 문화는 <strong>일단 struct</strong> 권해요. 복사되니까
"저쪽에서 몰래 바꿔 놨네?" 하는 공유 버그가 줄거든요. 상속이 필요하거나, 여러
곳이 하나의 상태를 <strong>일부러 공유</strong>해야 때만 class. 참고로 SwiftUI의
화면 조각(<span className="icode">View</span>) 전부 struct랍니다.
</div>
</Section>
<Section n={6} title="프로토콜 맛보기" sub="interface 자리에 앉는 '계약'">
<p>
<strong>프로토콜(protocol)</strong> "이런 프로퍼티와 기능을 갖춰야 한다" 정해
두는 <strong>약속(계약)</strong>이에요. TypeScript의 <span className="icode">interface</span>,
Java의 <span className="icode">interface</span> 정확히 같은 자리에 앉습니다. 무엇을
할지만 정하고, 어떻게 할지는 <strong>채택하는 </strong> 채워 넣어요.
</p>
<Code>{CODE_PROTOCOL}</Code>
<p>
멋진 점은 <strong>struct든 class든 상관없이</strong> 같은 프로토콜을 지키기만 하면
같은 자리에 바꿔 있다는 거예요(다형성). 예제에서{' '}
<span className="icode">Student</span>(struct) <span className="icode">Robot</span>(class)
배열에 나란히 들어가 똑같이 <span className="icode">greet()</span> 불리죠. Swift은
이렇게 <strong>"무엇을 할 수 있는가" 조립</strong>하는 방식을 특히 좋아해서,{' '}
<strong>프로토콜 지향 프로그래밍(POP)</strong>이라는 이름까지 붙어 있어요.
</p>
<div className="tip">
<b>TS의 interface를 떠올려요</b> 우리 <span className="icode">admin-web</span> 같은
TypeScript 코드에서 <span className="icode">interface Props {'{ ... }'}</span> "
컴포넌트가 받을 값의 모양"을 정했던 그 감각 그대로예요. 프로토콜은 거기에 "가져야
동작(함수)"까지 담을 있는, 조금 힘센 계약서라고 보면 됩니다.
</div>
</Section>
<Section n={7} title="다른 언어 아는 사람을 위한 요약" sub="번역표 한 장으로 정리">
<p>
설명을 장으로 압축해 볼게요. 아래 <strong>번역표</strong> 네가 이미 아는{' '}
JS/TS 표현을 왼쪽에, 대응하는 Swift 표현을 오른쪽에 거예요. 언어를 "처음부터"
외우지 말고, <strong>아는 것에서 다리를 놓아</strong> 건너가세요.
</p>
<Code>{CODE_ROSETTA}</Code>
<p>
보다시피 대부분은 <strong>표기만 다른 같은 개념</strong>이에요.{' '}
<span className="icode">??</span> <span className="icode">?.</span> 아예 똑같고,{' '}
<span className="icode">.map()</span> 거의 그대로죠. 정말 새로 몸에 익힐
<strong>옵셔널(값이 없을 있음을 타입으로 표현)</strong>{' '}
<strong> 타입 struct(대입하면 복사)</strong>. 둘만 손에 익으면 Swift 코드가
'읽히기' 시작합니다.
</p>
<div className="warn">
<b>과욕은 금물</b> 코스는 "2개월에 iOS 앱 전문가" 약속하지 않아요. 그건 거짓말이니까요.
목표는 <strong>지형을 읽는 </strong> Swift 코드를 봤을 겁먹지 않고, ", 이건
옵셔널이고 저건 클로저구나" 알아보는 . 눈이 있으면, 앱까지 가는 길은 실력으로
차근차근 이어 있어요.
</div>
<div className="tip">
<b>손으로 굳히기</b> 온라인 Swift 놀이터(<span className="icode">Swift Playgrounds</span>
기반 <span className="icode">play.swift.org</span>) 예제를 그대로 보세요.{' '}
<span className="icode">let</span> 재할당해서 에러를 직접 보고,{' '}
<span className="icode">struct</span> 복사해 한쪽만 바뀌는 눈으로 확인
'읽어서 아는 것' '쳐 봐서 아는 것' 전혀 다르답니다.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>🍎 여기까지 왔다면</h3>
<p className="muted">
이제 Swift 코드를 만나도 <strong>let/var···struct·</strong>
다섯 단어로 지형을 읽을 있게 됐어요. 그런데 아이폰 앱을 만들려고 Swift를,
안드로이드 앱을 만들려고 Kotlin을 <strong>따로</strong> 배워야 한다면 억울하죠?
바로 지점에서 <strong>네가 이미 아는 React</strong> iOS·안드로이드 앱을 번에
만드는 길이 열립니다 {' '}
<Link to="/learn/react-native"><strong>React Native</strong></Link> 코스에서, 오늘 읽은
네이티브의 지형 위에 실력으로 다리를 놓아 보세요.
</p>
</div>
</div>
</div>
);
}