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:
parent
24ff3320a3
commit
4d5c6d2e2b
@ -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": "웹은 이전 이미지로 즉시 되돌릴 수 있지만, 스토어는 즉시 롤백이 어려워 새 버전을 다시 올려 심사를 받아야 하므로 출시 전 테스트가 더 중요하다."
|
||||
}
|
||||
]
|
||||
@ -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가 있는 모든 코스를 평평한 배열로.
|
||||
|
||||
443
frontend/src/pages/courses/AndroidBasicsPage.jsx
Normal file
443
frontend/src/pages/courses/AndroidBasicsPage.jsx
Normal 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
|
||||
|
||||
핵심 차이: 웹은 대개 '열고 닫기'가 단순하지만, 안드로이드는 전화가 오고,
|
||||
화면을 돌리고, 홈으로 나갔다 돌아오는 일이 잦아 onPause↔onResume이 수시로 오갑니다.
|
||||
그래서 "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"><Link to="/course/42" /></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>
|
||||
);
|
||||
}
|
||||
414
frontend/src/pages/courses/AppLandscapePage.jsx
Normal file
414
frontend/src/pages/courses/AppLandscapePage.jsx
Normal 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"><div>→<View></span>,{' '}
|
||||
<span className="icode"><p>→<Text></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>
|
||||
);
|
||||
}
|
||||
414
frontend/src/pages/courses/AppNavigationPage.jsx
Normal file
414
frontend/src/pages/courses/AppNavigationPage.jsx
Normal 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">< 뒤로</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"><Route></span>에 등록돼 URL로 열린 화면입니다.
|
||||
</p>
|
||||
<Code>{CODE_ROUTER_BRIDGE}</Code>
|
||||
<p>
|
||||
대응표를 보면 <strong>새로 외울 게 거의 없다</strong>는 걸 알 수 있어요.{' '}
|
||||
<span className="icode"><Link to></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"><Routes></span>와 <span className="icode"><Route path=.../></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>
|
||||
);
|
||||
}
|
||||
386
frontend/src/pages/courses/AppSecurityPage.jsx
Normal file
386
frontend/src/pages/courses/AppSecurityPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
438
frontend/src/pages/courses/AppStateDataPage.jsx
Normal file
438
frontend/src/pages/courses/AppStateDataPage.jsx
Normal 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"><div></span>·<span className="icode"><p></span>가
|
||||
React Native에선 <span className="icode"><View></span>·<span className="icode"><Text></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>
|
||||
);
|
||||
}
|
||||
425
frontend/src/pages/courses/AppStoreDeployPage.jsx
Normal file
425
frontend/src/pages/courses/AppStoreDeployPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
445
frontend/src/pages/courses/FlutterPage.jsx
Normal file
445
frontend/src/pages/courses/FlutterPage.jsx
Normal 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(() => ...)</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>
|
||||
);
|
||||
}
|
||||
416
frontend/src/pages/courses/IosBasicsPage.jsx
Normal file
416
frontend/src/pages/courses/IosBasicsPage.jsx
Normal 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"><App> → <Layout> → <Card> → <Badge></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>
|
||||
);
|
||||
}
|
||||
452
frontend/src/pages/courses/KotlinIntroPage.jsx
Normal file
452
frontend/src/pages/courses/KotlinIntroPage.jsx
Normal 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>
|
||||
갑니다. val↔const, 람다↔화살표 함수, .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>
|
||||
);
|
||||
}
|
||||
455
frontend/src/pages/courses/PwaPage.jsx
Normal file
455
frontend/src/pages/courses/PwaPage.jsx
Normal 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"><link rel="manifest"></span>이 없고,{' '}
|
||||
<span className="icode">public/</span>엔 서비스워커 파일도 없습니다. 그래서 홈 화면에
|
||||
"설치" 버튼이 안 뜨는 거예요. 이 코스의 목표는 그 빠진 조각을 <strong>내 손으로</strong>{' '}
|
||||
채워 넣는 지형을 익히는 것입니다.
|
||||
</p>
|
||||
<div className="tip">
|
||||
<b>직접 확인해 보기</b> 에디터에서{' '}
|
||||
<span className="icode">frontend/index.html</span>을 열어 <span className="icode"><head></span>{' '}
|
||||
안을 보세요. 지금은 <span className="icode"><title></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"><link></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>
|
||||
);
|
||||
}
|
||||
425
frontend/src/pages/courses/ReactNativePage.jsx
Normal file
425
frontend/src/pages/courses/ReactNativePage.jsx
Normal 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"><Text></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">div→View</span>,{' '}
|
||||
<span className="icode">span→Text</span>,{' '}
|
||||
<span className="icode">onClick→onPress</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"><div></span>·
|
||||
<span className="icode"><span></span>·
|
||||
<span className="icode"><img></span> 같은 태그가 있었죠. RN엔 그에
|
||||
<strong>대응하는 컴포넌트</strong>가 있어요. 이름만 바뀌었지 역할은 거의
|
||||
같습니다. 아래 대응표가 사실상 "웹 개발자용 RN 통역 사전"이에요.
|
||||
</p>
|
||||
<Code>{CODE_TAGS}</Code>
|
||||
<p>
|
||||
하나만 특별히 강조할게요. 웹에선 <span className="icode"><div></span> 안에
|
||||
글자를 아무렇게나 써도 됐지만, RN에선 <strong>모든 글자가 반드시{' '}
|
||||
<span className="icode"><Text></span> 안에</strong> 있어야 해요. 이건 실수가
|
||||
아니라 규칙이에요 — 네이티브에선 "글자를 그리는 부품"과 "레이아웃 상자"가 아예
|
||||
다른 부품이라서 그렇습니다.
|
||||
</p>
|
||||
<div className="warn">
|
||||
<b>첫날 100% 만나는 에러</b> "Text strings must be rendered within a{' '}
|
||||
<Text> component"가 뜨면, <span className="icode"><View></span> 안에
|
||||
글자를 맨몸으로 넣은 거예요. 글자를{' '}
|
||||
<span className="icode"><Text></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>
|
||||
);
|
||||
}
|
||||
489
frontend/src/pages/courses/SwiftIntroPage.jsx
Normal file
489
frontend/src/pages/courses/SwiftIntroPage.jsx
Normal 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 이름(인자: 타입) -> 반환타입</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) => 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user