diff --git a/backend/src/main/resources/seed/quizzes.json b/backend/src/main/resources/seed/quizzes.json index e7bff70..3d2a8a6 100644 --- a/backend/src/main/resources/seed/quizzes.json +++ b/backend/src/main/resources/seed/quizzes.json @@ -1,3552 +1,4152 @@ [ - { - "courseSlug": "ai-tools", - "question": "이 코스에서 AI 코딩 도구를 크게 두 갈래로 나눈 것은 무엇인가?", - "option1": "코드 리뷰형과 배포 자동화형", - "option2": "자동완성형과 챗 어시스턴트형", - "option3": "검색 엔진형과 문서 번역형", - "option4": "디버깅 전용형과 테스트 전용형", - "answerIndex": 1, - "explanation": "섹션 1에서 AI 코딩 도구를 에디터에서 다음 코드를 이어 써 주는 자동완성형(예: GitHub Copilot)과 대화창에 질문하면 답하는 챗 어시스턴트형(예: ChatGPT, Claude)의 두 갈래로 소개했다." - }, - { - "courseSlug": "ai-tools", - "question": "코스에서 말하는 '좋은 질문의 3요소'로 올바르게 짝지어진 것은?", - "option1": "맥락 · 속도 · 정확성", - "option2": "목표 · 일정 · 결과", - "option3": "예의 · 간결함 · 끈기", - "option4": "맥락 · 제약 · 예시", - "answerIndex": 3, - "explanation": "섹션 2에서 좋은 질문의 3요소는 맥락(어떤 환경에서 뭘 만들다가), 제약(버전·라이브러리 등 조건), 예시(실제 코드·에러 메시지)라고 정리했다. 30분 룰에서 배운 멘토 질문법과 같은 구조다." - }, - { - "courseSlug": "ai-tools", - "question": "다음 중 코스의 'AI 답변 검증 4단계' 체크리스트에 해당하지 않는 것은?", - "option1": "같은 질문을 여러 AI에게 던져서 답이 많이 겹치는 쪽을 고른다", - "option2": "한 줄 한 줄 읽고 무슨 코드인지 스스로 설명할 수 있는지 확인한다", - "option3": "로컬에서 정상 케이스 1개와 이상한 입력 1개로 실제 실행해 본다", - "option4": "낯선 API나 설정이면 공식 문서를 열어 근거를 교차 확인한다", - "answerIndex": 0, - "explanation": "섹션 3의 4단계는 '설명할 수 있는가, 우리 프로젝트에 맞는가, 실제로 돌아가는가, 근거를 교차 확인했는가'다. 여러 AI의 다수결로 고르는 방법은 체크리스트에 없다." - }, - { - "courseSlug": "ai-tools", - "question": "AWESOMEDEV AI 사용 보안 수칙에 따르면, AI 입력창에 넣어도 되는 것은 무엇인가?", - "option1": "고객사 프로젝트의 소스 코드", - "option2": "서버 IP와 계정이 함께 적힌 접속 정보", - "option3": "교재용 학습 플랫폼(mirim-app)처럼 공개 가능한 학습 코드", - "option4": ".env 파일에 들어 있는 API 키와 토큰 값", - "answerIndex": 2, - "explanation": "섹션 5의 보안 수칙에서 공개 가능한 학습 코드, 내가 연습용으로 짠 코드, 경로·키 값을 지운 일반적인 에러 메시지는 넣어도 된다고 했다. 고객사 코드·비밀번호·접속 정보·개인정보는 절대 금지다." - }, - { - "courseSlug": "ai-tools", - "question": "수습 B가 React 화면이 하얗게 나오면서 콘솔에 \"TypeError: Cannot read properties of undefined (reading 'map')\" 에러를 만났다. 코스의 실습 기준으로 가장 잘 쓴 프롬프트는?", - "option1": "\"에러가 났어요. 급하니까 빨리 고쳐 주세요. 부탁드립니다.\"", - "option2": "\"React로 코스 목록 화면을 만드는 중인데, 화면이 하얗게 나오고 이 에러 전문이 떠요. 12번째 줄 근처 코드와 제가 시도한 것도 붙일게요. 왜 undefined에서 map을 못 읽는지 원리도 설명해 주세요.\"", - "option3": "\"TypeError라는 게 정확히 무엇인지 사전적인 정의부터 하나하나 알려 주세요.\"", - "option4": "\"프로젝트 코드 전체를 붙여넣을 테니 알아서 전부 고친 완성본 파일만 만들어 주세요.\"", - "answerIndex": 1, - "explanation": "섹션 7의 실습처럼 맥락(React로 무엇을 만드는지)·증상(에러 전문)·코드(해당 줄 근처)·시도한 것을 담고 원리 설명까지 요청한 프롬프트가 좋은 질문이다. '고쳐줘'만 보내면 AI도 점쟁이가 된다고 했다." - }, - { - "courseSlug": "api", - "question": "코스의 식당 비유에서 '메뉴판'에 해당하는 것은 무엇인가요?", - "option1": "요청(Request) — 손님이 적어 내는 주문서", - "option2": "API 문서 — 뭘 시킬 수 있는지 적힌 목록", - "option3": "백엔드와 DB — 요리를 만드는 주방", - "option4": "응답(Response) — 주방에서 나온 음식", - "answerIndex": 1, - "explanation": "섹션 1의 비유에서 메뉴판은 API 문서에 해당해요. 손님(프론트엔드)은 메뉴판에 있는 것만 주문할 수 있고, 그래서 정해진 요청에 정해진 응답이 오는 예측 가능한 약속이 됩니다." - }, - { - "courseSlug": "api", - "question": "REST 규칙에서 '무엇을(자원)'과 '어떻게(행위)'는 각각 어디에 담나요?", - "option1": "자원은 HTTP 메서드에, 행위는 URL에 담는다", - "option2": "자원과 행위를 모두 요청 본문(body)에 담는다", - "option3": "자원은 URL에, 행위는 HTTP 메서드에 담는다", - "option4": "자원은 헤더에, 행위는 상태코드에 담는다", - "answerIndex": 2, - "explanation": "섹션 2의 핵심 두 줄 요약이에요. 자원은 명사로 URL에(/api/checklists/3), 행위는 GET·POST·PUT·DELETE 같은 메서드에 담아서, URL과 메서드만 봐도 무슨 일이 일어날지 읽히게 합니다." - }, - { - "courseSlug": "api", - "question": "수습 사원이 로그인한 상태에서 관리자 전용 API를 호출했을 때, 서버가 돌려주는 상태코드는 무엇인가요?", - "option1": "400 — 요청 본문의 문법이 잘못되었다는 뜻", - "option2": "401 — 토큰이 없거나 만료되었다는 뜻", - "option3": "404 — 요청한 자원이 존재하지 않는다는 뜻", - "option4": "403 — 누군지는 알지만 권한이 없다는 뜻", - "answerIndex": 3, - "explanation": "섹션 6에서 401은 인증 실패(너 누구야?), 403은 인가 실패(누군진 아는데 자격이 없어)로 구분했어요. 로그인은 했지만 권한이 없는 경우이므로 403 Forbidden입니다." - }, - { - "courseSlug": "api", - "question": "다음 중 코스에서 배운 JSON 문법 규칙으로 올바른 것은 무엇인가요?", - "option1": "이름표(key)는 항상 큰따옴표로 감싸야 한다", - "option2": "문자열에는 작은따옴표를 쓰는 것도 허용된다", - "option3": "마지막 항목 뒤에 쉼표를 붙여도 문제없다", - "option4": "// 를 사용해 주석을 자유롭게 달 수 있다", - "answerIndex": 0, - "explanation": "섹션 3의 규칙 4가지 중 하나로, key는 항상 큰따옴표로 감싸요. 반대로 작은따옴표, 마지막 항목 뒤 쉼표, 주석(//)은 모두 코스에서 '함정 주의'로 짚은 JSON 에러 원인입니다." - }, - { - "courseSlug": "api", - "question": "동료가 체크리스트 삭제 기능을 'GET /api/deleteChecklist?id=3' 으로 만들었습니다. 코스 내용에 비추어 이 설계의 가장 큰 문제는 무엇인가요?", - "option1": "GET은 응답 본문을 받을 수 없어서 삭제 결과를 확인할 방법이 없다", - "option2": "GET을 안전하다고 믿는 크롤러가 링크를 따라가며 데이터를 지울 수 있고, 동사는 URL이 아니라 메서드에 담아야 한다", - "option3": "URL에 물음표(?)와 숫자를 함께 쓰면 REST에서 요청 자체가 거부된다", - "option4": "삭제는 반드시 POST로만 보내야 하며 DELETE 메서드는 조회 전용이다", - "answerIndex": 1, - "explanation": "섹션 2에서 배운 두 가지가 모두 어긋난 설계예요. 브라우저·검색엔진은 GET을 안전한 읽기로 믿고 미리 불러가기도 해서 크롤러가 데이터를 전부 지운 사고가 실제로 있었고, 자원은 명사(URL)·행위는 메서드로 표현해 DELETE /api/checklists/3 처럼 써야 합니다." - }, - { - "courseSlug": "assembly", - "question": "부품 고르기의 '호환성 3대 체크'에서 CPU와 메인보드의 관계로 옳은 것은?", - "option1": "같은 제조사 제품이면 소켓이 달라도 장착할 수 있다", - "option2": "소켓 이름이 같은 CPU와 메인보드끼리만 장착할 수 있다", - "option3": "CPU가 더 최신 제품이면 어떤 메인보드에든 장착할 수 있다", - "option4": "DDR5를 지원하는 메인보드라면 CPU 종류는 상관없다", - "answerIndex": 1, - "explanation": "코스의 호환성 3대 체크 중 첫 번째로, CPU와 보드는 '같은 소켓 이름'끼리만 결혼할 수 있다고 설명해요. 예를 들어 LGA1700 CPU는 AM5 보드에 아예 꽂히지 않습니다." - }, - { - "courseSlug": "assembly", - "question": "파워(PSU) 뒷면 스위치의 I와 O 표시에 대한 설명으로 옳은 것은?", - "option1": "O가 켜짐이고 I가 꺼짐이다", - "option2": "I와 O는 전압을 고르는 선택 스위치다", - "option3": "I가 켜짐이고 O가 꺼짐이다", - "option4": "I는 절전 모드이고 O는 완전 꺼짐이다", - "answerIndex": 2, - "explanation": "코스의 '초보가 자주 하는 실수'에서 파워 뒷면 스위치는 I가 켜짐, O가 꺼짐이라고 알려줘요(이진수의 1과 0). '전원 버튼을 눌러도 반응이 없어요'의 단골 범인입니다." - }, - { - "courseSlug": "assembly", - "question": "첫 부팅에서 팬은 도는데 화면이 안 나올 때, 코스에서 가장 먼저 해보라고 안내하는 조치는?", - "option1": "부팅 USB를 만들어 윈도우를 다시 설치한다", - "option2": "CPU 쿨러를 분리해 서멀구리스를 다시 바른다", - "option3": "파워를 더 높은 용량의 제품으로 교체한다", - "option4": "RAM을 뽑았다가 \"딸깍\" 소리가 나게 다시 장착한다", - "answerIndex": 3, - "explanation": "화면이 안 나오는 원인 1위는 RAM이 덜 꽂힌 상태예요. 코스의 점검 순서에서도 팬은 도는데 화면이 안 나오면 RAM을 뽑았다가 '딸깍' 소리가 나게 재장착하라고 안내합니다." - }, - { - "courseSlug": "assembly", - "question": "조립 마지막 단계인 '선정리'를 하는 진짜 이유로 코스에서 설명하는 것은?", - "option1": "케이스 안 공기 흐름을 확보해 냉각을 돕기 위해서", - "option2": "케이블끼리 닿으면 합선이 일어날 수 있어서", - "option3": "전면 패널 커넥터를 꽂을 공간을 만들기 위해서", - "option4": "케이블 길이를 줄여 파워 효율을 높이기 위해서", - "answerIndex": 0, - "explanation": "표준 9단계의 마지막인 선정리는 예쁘라고 하는 게 아니라 공기 흐름, 즉 냉각을 위해서라고 코스에서 설명해요. 케이블을 케이스 뒷면 공간으로 넘기고 케이블타이로 묶습니다." - }, - { - "courseSlug": "assembly", - "question": "동아리 후배의 PC는 RAM 8GB에 SSD 256GB인데, IntelliJ와 도커를 같이 켜면 버벅인다고 해요. 코스의 '가상 업그레이드 계획' 기준으로 1순위 업그레이드는?", - "option1": "외장 그래픽카드 추가 — 화면을 그리는 건 GPU의 일이니까", - "option2": "RAM을 32GB로 증설 — 개발 도구는 메모리 대식가이고 가장 싸고 체감이 크니까", - "option3": "CPU 교체 — 컴파일 속도가 곧 개발 체감 속도니까", - "option4": "SSD를 1TB로 교체 — 저장 공간이 커지면 속도도 빨라지니까", - "answerIndex": 1, - "explanation": "코스의 업그레이드 계획 예시에서 개발 공부용 PC의 1순위는 RAM 8GB→32GB예요. 개발 도구는 메모리 대식가라서 가장 싸고 체감이 큰 업그레이드이기 때문이고, CPU는 소켓 문제로 사실상 새 PC가 되어 3순위입니다." - }, - { - "courseSlug": "aws-intro", - "question": "코스에서 말하는 클라우드의 핵심 발상 전환으로 가장 알맞은 것은?", - "option1": "서버 컴퓨터를 더 싸게 대량 구매해서 소유하는 것", - "option2": "컴퓨터를 소유하는 대신 컴퓨팅 파워를 쓴 만큼 구독하는 것", - "option3": "모든 소프트웨어를 무료로 내려받아 쓰는 것", - "option4": "회사마다 데이터센터를 직접 지어 운영하는 것", - "answerIndex": 1, - "explanation": "코스 섹션 1은 공장이 발전기를 버리고 전기를 사 쓰게 된 것처럼, '컴퓨터를 소유'하는 시대에서 '컴퓨팅 파워를 구독'하는 시대로 바뀐 것이 클라우드 혁명의 핵심이라고 설명합니다." - }, - { - "courseSlug": "aws-intro", - "question": "코스에서 S3(Simple Storage Service)를 한 줄로 소개한 내용은 무엇인가요?", - "option1": "DB를 AWS가 대신 설치하고 백업해 주는 관리형 데이터베이스", - "option2": "클릭 몇 번으로 만드는 가상 서버 인스턴스", - "option3": "이미지·백업·로그를 파일 단위로 넣고 꺼내는 용량 무제한 웹 창고", - "option4": "코드만 올리면 실행 환경을 알아서 준비해 주는 서비스", - "answerIndex": 2, - "explanation": "섹션 5에서 S3는 '용량 무제한 파일 창고'로, 서버 디스크와 달리 파일 단위로 넣고 꺼내는 웹 창고라고 소개합니다. DB를 대신 관리해 주는 것은 RDS입니다." - }, - { - "courseSlug": "aws-intro", - "question": "인스턴스 이름 t3.medium에서 숫자 '3'이 뜻하는 것은?", - "option1": "3세대라는 뜻으로, 숫자가 클수록 최신이다", - "option2": "vCPU가 3개 달려 있다는 뜻이다", - "option3": "메모리가 3GB라는 뜻이다", - "option4": "크기 등급 중 3번째로 크다는 뜻이다", - "answerIndex": 0, - "explanation": "섹션 3의 '자동차 모델명 읽기' 비유에서 t는 패밀리(차종), 3은 세대(연식), medium은 크기(배기량)입니다. 숫자가 클수록 최신 세대로 보통 더 빠르고 저렴하다고 설명합니다." - }, - { - "courseSlug": "aws-intro", - "question": "EC2 인스턴스를 중지(Stop)했을 때에도 계속 나가는 요금은 무엇인가요?", - "option1": "인스턴스의 시간당 사용료", - "option2": "프리 티어 가입 시 내는 기본 요금", - "option3": "t 패밀리의 CPU 버스트 크레딧 요금", - "option4": "EBS 디스크와 서버에 연결 안 된 탄력적 IP 요금", - "answerIndex": 3, - "explanation": "섹션 4에서 인스턴스를 끄면 시간당 사용료는 멈추지만, 데이터를 보관 중인 EBS 디스크와 서버에 연결되지 않은 탄력적 IP는 계속 과금된다고 설명합니다. '켜 놓고 잊어버림'과 함께 대표적인 요금 함정입니다." - }, - { - "courseSlug": "aws-intro", - "question": "수습 A가 nslookup으로 edu.awesomedevapp.com의 IP를 확인해 두었다. 며칠 뒤 서버가 재시작됐는데도 같은 IP로 잘 접속된다. 코스 내용상 이를 가능하게 하는 부품은?", - "option1": "보안 그룹이 허락된 포트를 고정해 주기 때문", - "option2": "탄력적 IP가 '영구 도로명 주소'처럼 서버에 붙어 있기 때문", - "option3": "Caddy가 HTTPS 현관에서 요청을 대신 받아 주기 때문", - "option4": "EBS 디스크에 서버의 IP 주소가 저장되어 있기 때문", - "answerIndex": 1, - "explanation": "섹션 6에서 EC2는 재시작하면 IP가 바뀔 수 있는데, 탄력적 IP는 '영구 도로명 주소'를 서버에 붙여 두는 것이라 도메인이 늘 같은 곳을 가리킬 수 있다고 설명합니다. 보안 그룹은 경비실(방화벽), Caddy는 HTTPS 처리 담당입니다." - }, - { - "courseSlug": "bandwidth", - "question": "이 코스의 도로 비유에서 '대역폭(bandwidth)'은 무엇에 해당할까요?", - "option1": "차 한 대가 달리는 최고 속도", - "option2": "도로의 폭, 즉 동시에 지나갈 수 있는 차선 수", - "option3": "출발지에서 목적지까지의 거리", - "option4": "도로 위에 있는 신호등의 개수", - "answerIndex": 1, - "explanation": "섹션 1에서 배웠듯 신호가 이동하는 속도는 어느 회선이나 거의 비슷하고, 진짜 차이는 동시에 얼마나 많은 데이터를 흘려보낼 수 있느냐입니다. 그래서 대역폭은 차의 빠르기가 아니라 도로의 폭(차선 수)에 비유됩니다." - }, - { - "courseSlug": "bandwidth", - "question": "통신사 500Mbps 상품을 쓸 때, 브라우저 다운로드 창에 뜰 수 있는 이론상 최대 속도는 약 얼마일까요?", - "option1": "약 50 MB/s", - "option2": "약 125 MB/s", - "option3": "약 62.5 MB/s", - "option4": "약 500 MB/s", - "answerIndex": 2, - "explanation": "Mbps의 b는 bit, MB/s의 B는 Byte이고 1바이트는 8비트이므로 500Mbps ÷ 8 = 62.5MB/s입니다. 섹션 2의 '8배의 비밀' — 다운로드 창 숫자가 작아 보이는 건 고장이 아니라 단위 착시예요." - }, - { - "courseSlug": "bandwidth", - "question": "기가 인터넷을 쓰는데도 온라인 게임에서 랙이 걸릴 수 있는 이유로 가장 알맞은 것은?", - "option1": "게임은 작은 데이터를 자주 주고받아서 대역폭보다 지연시간(ping)의 영향을 크게 받기 때문", - "option2": "게임은 큰 데이터를 한꺼번에 받아야 해서 기가 회선으로도 대역폭이 부족하기 때문", - "option3": "기가 회선은 다운로드 전용이라 게임의 업로드 데이터를 처리하지 못하기 때문", - "option4": "핑이 낮아질수록 대역폭이 함께 줄어드는 반비례 관계가 있기 때문", - "answerIndex": 0, - "explanation": "섹션 4에서 배운 내용으로, 게임은 '내 캐릭터 움직였어!' 같은 작은 메시지를 아주 자주 주고받는 왕복 시간(ping) 싸움입니다. 도로가 아무리 넓어도 서버가 멀면 지연시간은 높을 수 있어요." - }, - { - "courseSlug": "bandwidth", - "question": "인터넷 속도를 '제대로' 측정하는 방법으로 옳은 것은?", - "option1": "숫자가 가장 크게 나오도록 해외 측정 서버를 골라서 잰다", - "option2": "저녁 8~11시에 한 번만 측정해서 그 값을 기준으로 삼는다", - "option3": "다운로드 숫자 하나만 기록하면 업로드와 핑은 볼 필요가 없다", - "option4": "다른 기기·앱의 다운로드를 끄고, 시간대를 바꿔 2~3번 측정해 경향을 본다", - "answerIndex": 3, - "explanation": "섹션 7의 체크리스트대로 측정도 조건 통제가 생명입니다. 다운로드·업로드·핑을 모두 기록하고, 혼잡 시간(저녁 8~11시)을 피해 시간대를 바꿔 여러 번 재서 한 번의 숫자가 아니라 경향으로 읽어야 해요." - }, - { - "courseSlug": "bandwidth", - "question": "500Mbps 상품을 쓰는 집에서 벽 너머 방에서 와이파이로 측정했더니 다운로드 90Mbps, 핑 5ms가 나왔습니다. 가장 의심되는 병목 구간은?", - "option1": "통신사 회선 구간 — 계약 상품이 실제보다 낮게 설정되어 있다", - "option2": "내 PC와 공유기 사이 구간 — 벽 너머 와이파이가 속도를 깎고 있다", - "option3": "상대 서버 구간 — 해외 서버라 국제 구간에서 느려지고 있다", - "option4": "측정 도구 구간 — 핑이 5ms로 낮게 나온 것 자체가 측정 오류다", - "answerIndex": 1, - "explanation": "섹션 7의 마지막 예시처럼 '다운로드는 낮은데 핑은 좋은' 패턴이면 회선(②)이 아니라 집 안 구간(①)이 병목일 가능성이 큽니다. 섹션 6에서 가정의 1순위 용의자는 거의 항상 벽·거리에 약해지는 와이파이라고 배웠어요." - }, - { - "courseSlug": "binary", - "question": "컴퓨터가 0과 1, 딱 두 가지 상태만 사용하는 가장 큰 이유는 무엇인가요?", - "option1": "2진수는 10진수보다 계산 공식이 단순해서 CPU 설계 비용이 줄어들기 때문", - "option2": "켜짐과 꺼짐 두 상태만 있으면 전압이 조금 흔들려도 확실히 구분할 수 있기 때문", - "option3": "전기의 세기를 10단계로 나누면 스위치(트랜지스터)가 10배 더 필요하기 때문", - "option4": "0과 1만 쓰면 데이터가 자동으로 압축되어 저장 용량이 줄어들기 때문", - "answerIndex": 1, - "explanation": "섹션 1 '왜 0과 1인가'의 핵심입니다. 상태가 딱 2개면 헷갈릴 수가 없어서, 전압이 살짝 흔들려도 켜짐인지 꺼짐인지 확실히 구분됩니다. 반대로 0~9를 전압 10단계로 표현하면 6이 7로 읽히는 사고가 납니다." - }, - { - "courseSlug": "binary", - "question": "2진수 1011을 10진수로 바꾸면 얼마인가요?", - "option1": "9", - "option2": "10", - "option3": "11", - "option4": "13", - "answerIndex": 2, - "explanation": "섹션 2의 자리값 계산입니다. 2진수는 오른쪽 끝부터 자리값이 1, 2, 4, 8로 커지고, 1이 켜진 자리의 값만 골라 더하면 됩니다. 1011은 8 + 0 + 2 + 1 = 11입니다." - }, - { - "courseSlug": "binary", - "question": "1KB가 1,000바이트가 아니라 1,024바이트인 이유로 코스가 설명한 것은 무엇인가요?", - "option1": "컴퓨터는 2의 거듭제곱으로 세는 게 편해서, 1,000에 가장 가까운 2¹⁰ = 1,024를 한 묶음으로 삼았기 때문", - "option2": "하드디스크 제조사들이 용량을 더 크게 보이게 하려고 국제 규격을 1,024로 정했기 때문", - "option3": "1바이트가 8비트이므로, 1,000에 가장 가까운 8의 거듭제곱이 1,024이기 때문", - "option4": "초기 컴퓨터의 메모리가 1,024바이트 단위로만 생산되어 그 관습이 굳어졌기 때문", - "answerIndex": 0, - "explanation": "섹션 3 '1024의 비밀'입니다. 컴퓨터는 2진수 세상에 살기 때문에 2를 10번 곱한 1,024가 1,000에 아주 가까운 '2진수식 천 단위'가 되어 한 묶음으로 씁니다. 참고로 제조사는 1,000 기준으로 계산해서 1TB 디스크가 윈도우에선 약 931GB로 보입니다." - }, - { - "courseSlug": "binary", - "question": "'뷁' 같은 글자 깨짐(외계어) 현상이 생기는 원인으로 코스가 설명한 것은 무엇인가요?", - "option1": "글자 수가 128개를 넘으면 ASCII가 넘치는 글자를 자동으로 잘라내기 때문", - "option2": "한글은 3바이트라서 전송 중에 마지막 바이트가 자주 손실되기 때문", - "option3": "유니코드 번호표가 나라마다 달라서 같은 글자에 다른 번호가 붙기 때문", - "option4": "UTF-8로 포장한 데이터를 EUC-KR 같은 다른 규칙으로 풀어서 번호표가 엉뚱한 글자에 매칭되기 때문", - "answerIndex": 3, - "explanation": "섹션 4의 UTF-8 설명입니다. 인코딩은 유니코드 번호를 바이트로 담는 '포장법'이라서, 포장 규칙과 개봉 규칙이 같아야 합니다. 우리 회사가 인코딩을 처음부터 끝까지 UTF-8로 통일하는 이유이기도 합니다." - }, - { - "courseSlug": "binary", - "question": "수민이는 코스에서 배운 대로 한 손의 손가락 5개를 스위치처럼 써서(접힘=0, 펴짐=1) 수를 표현하려고 합니다. 한 손으로 셀 수 있는 가장 큰 수는 얼마인가요?", - "option1": "15", - "option2": "31", - "option3": "32", - "option4": "63", - "answerIndex": 1, - "explanation": "섹션 1의 손가락 비유입니다. 스위치 5개는 2×2×2×2×2 = 32가지 상태를 만들 수 있어 0부터 31까지 셀 수 있습니다. 생일의 '일'(1~31)이 반드시 2진수 5자리 안에 들어가는 이유이기도 합니다." - }, - { - "courseSlug": "cache-nosql", - "question": "코스에서 설명한 '캐시 히트(hit)'에 해당하는 상황은 무엇인가?", - "option1": "캐시에 데이터가 없어 DB까지 다녀온 뒤 응답하는 경우", - "option2": "원본 DB가 바뀌어 캐시의 낡은 사본을 지워야 하는 경우", - "option3": "캐시에 찾는 데이터가 있어 DB에 가지 않고 바로 응답하는 경우", - "option4": "브라우저가 강력 새로고침으로 캐시를 무시하고 요청하는 경우", - "answerIndex": 2, - "explanation": "섹션 2의 사물함 비유처럼 캐시에 찾는 게 있으면 히트, 없으면 미스입니다. 미스일 때는 DB에 다녀오면서 캐시에 사본을 복사해 두어 다음 요청부터 히트가 납니다." - }, - { - "courseSlug": "cache-nosql", - "question": "코스에서 Redis가 빠른 비결로 든 두 가지 이유를 바르게 묶은 것은?", - "option1": "데이터를 메모리(RAM)에 두고, 키를 해시 계산 한 번으로 바로 찾기 때문", - "option2": "데이터를 디스크에 압축해 저장하고, 인덱스를 미리 정렬해 두기 때문", - "option3": "데이터를 표(테이블) 구조로 저장해 JOIN 연산이 강력하기 때문", - "option4": "모든 데이터를 브라우저에 복사해 두어 네트워크를 타지 않기 때문", - "answerIndex": 0, - "explanation": "섹션 3에서 Redis는 디스크보다 훨씬 빠른 메모리(RAM)에 데이터를 두고, 키를 해시로 계산해 위치를 바로 알아내서 데이터가 백만 개여도 찾는 시간이 거의 같다고 설명합니다." - }, - { - "courseSlug": "cache-nosql", - "question": "캐시의 유통기한(TTL)을 1시간처럼 길게 잡았을 때 생기는 문제로 코스가 설명한 것은?", - "option1": "DB에 너무 자주 다녀오게 되어 캐시 효과가 떨어진다", - "option2": "메모리 대신 디스크에 저장되어 읽기 속도가 크게 느려진다", - "option3": "키를 해시로 찾을 수 없게 되어 검색 시간이 점점 늘어난다", - "option4": "원본이 바뀌어도 낡은 정보를 계속 보여줄 위험이 커진다", - "answerIndex": 3, - "explanation": "섹션 4의 우유 유통기한 비유입니다. TTL이 짧으면 신선하지만 DB에 자주 다녀오고, 길면 빠르지만 낡은 정보를 보여줄 위험이 커지는 트레이드오프가 캐시 무효화가 어려운 이유입니다." - }, - { - "courseSlug": "cache-nosql", - "question": "실습에서 확인한 상태 코드 304 Not Modified가 뜻하는 것은 무엇인가?", - "option1": "서버가 요청받은 파일 전체를 본문에 담아 새로 보내 주었다", - "option2": "파일이 안 바뀌었으니 브라우저가 가진 사본을 그대로 쓰라는 답장이다", - "option3": "서버에 묻지 않고 메모리 캐시에서 바로 꺼내 썼다는 표시이다", - "option4": "요청한 파일이 서버에서 삭제되어 더 이상 찾을 수 없다는 뜻이다", - "answerIndex": 1, - "explanation": "섹션 8에서 304는 서버가 ETag 등으로 버전을 비교해 보고 '안 바뀌었으니 네 사본을 써'라고 본문 없이 답장만 보내는 신호, 즉 브라우저 캐시 히트의 영수증이라고 설명합니다." - }, - { - "courseSlug": "cache-nosql", - "question": "학습 플랫폼을 설계한다. 학생마다 형식이 제각각인 자유 양식 학습일지, 날아가도 다시 만들 수 있는 로그인 세션, 절대 사라지면 안 되는 수강 기록 — 섹션 7의 결정 기준에 맞는 배치는?", - "option1": "학습일지는 Redis, 세션은 PostgreSQL, 수강 기록은 MongoDB", - "option2": "학습일지는 PostgreSQL, 세션은 MongoDB, 수강 기록은 Redis", - "option3": "학습일지는 MongoDB, 세션은 Redis, 수강 기록은 PostgreSQL", - "option4": "학습일지는 Redis, 세션은 MongoDB, 수강 기록은 PostgreSQL", - "answerIndex": 2, - "explanation": "섹션 7의 세 질문 그대로입니다. 사라지면 안 되는 원본(수강 기록)은 PostgreSQL, 날아가도 재생성 가능한 것(세션)은 Redis, 문서마다 모양이 다른 것(자유 양식 학습일지)은 MongoDB가 맡습니다." - }, - { - "courseSlug": "cicd", - "question": "코스에서 설명하는 CI(Continuous Integration, 지속적 통합)의 핵심 의미로 가장 알맞은 것은?", - "option1": "서버에 새 버전을 올린 뒤 사람이 브라우저를 새로고침하며 직접 확인하는 것", - "option2": "코드를 저장소에 합칠 때마다(push할 때마다) 로봇이 자동으로 빌드하고 테스트하는 것", - "option3": "여러 사람의 코드를 한 달 치씩 모아 두었다가 한꺼번에 몰아서 검사하는 것", - "option4": "새 버전에서 장애가 났을 때 이전 버전으로 1분 안에 되돌리는 것", - "answerIndex": 1, - "explanation": "섹션 2의 내용으로, CI는 push될 때마다 로봇이 빌드와 테스트를 자동으로 하는 것입니다. '가끔 크게'가 아니라 '자주 조금씩' 검사하기 때문에 버그를 일찍, 싸게 잡을 수 있어요." - }, - { - "courseSlug": "cicd", - "question": "CD(지속적 배포)가 도입된 뒤 사람에게 남는 일은 무엇이라고 코스는 설명하나?", - "option1": "빌드 결과물을 scp 명령으로 서버에 직접 올리는 일", - "option2": "서버에 SSH로 접속해서 돌고 있던 옛 버전을 중지하는 일", - "option3": "git push 명령 하나를 실행하는 일", - "option4": "배포가 끝난 뒤 헬스체크 로그를 사람이 눈으로 검사하는 일", - "answerIndex": 2, - "explanation": "섹션 3에서 CD 이후에는 빌드·검사·업로드·교체·확인이 전부 로봇 몫이 되고, 사람이 하는 일은 git push 하나만 남는다고 설명합니다. 손빨래가 세탁기 버튼이 되는 비유가 여기에 해당해요." - }, - { - "courseSlug": "cicd", - "question": "코스가 파이프라인의 가장 중요한 성질로 꼽는 것은?", - "option1": "한 정거장이라도 실패하면 그 자리에서 멈춰 배포 단계까지 가지 않는다", - "option2": "모든 단계를 사람보다 훨씬 빠른 속도로 처리해 배포 시간을 줄인다", - "option3": "빌드와 테스트를 병렬로 동시에 실행해서 기다리는 시간을 아낀다", - "option4": "장애가 나면 원인을 자동으로 분석해서 코드까지 고쳐 준다", - "answerIndex": 0, - "explanation": "섹션 4의 핵심으로, 파이프라인은 컨베이어 벨트처럼 '실패하면 멈추는' 구조입니다. 그래서 테스트가 깨진 코드는 배포 단계에 구조적으로 도착할 수 없고, 불량품이 사용자에게 가기 전에 벨트가 멈춥니다." - }, - { - "courseSlug": "cicd", - "question": "코스에서 우리 스택의 롤백(되돌리기)을 빠르게 만들어 주는 열쇠로 꼽은 것은?", - "option1": "장애가 나면 서버에 접속해 코드를 그 자리에서 고치고 다시 빌드하는 것", - "option2": "배포 전에 테스트를 여러 번 반복 실행해서 장애 자체를 아예 막는 것", - "option3": "Caddy가 이전 버전의 화면을 캐시로 저장해 두었다가 대신 보여 주는 것", - "option4": "Docker 이미지에 버전 태그를 붙여 보관해 두고 이전 이미지로 다시 실행하는 것", - "answerIndex": 3, - "explanation": "섹션 6에서 롤백의 열쇠는 Docker라고 설명합니다. v1.2, v1.3처럼 버전마다 이미지를 태그로 보관하면, 되돌리기가 '옛 이미지로 컨테이너 다시 실행' 한 줄이 되기 때문이에요." - }, - { - "courseSlug": "cicd", - "question": "수습 A가 마지막 실습대로 우리 플랫폼의 배포 지도를 그리고 각 단계에 [사람]·[자동]·[위험] 표시를 붙였다. 코스의 기준에 따르면 ★(자동화 1순위)을 쳐야 하는 단계는?", - "option1": "[자동] 표시가 붙은, 이미 로봇이 처리하고 있는 단계", - "option2": "[사람]과 [위험] 표시가 함께 붙은, 실수가 잦고 실수하면 아픈 단계", - "option3": "[사람] 표시만 붙은 단계 중에서 시간이 가장 오래 걸리는 단계", - "option4": "지도의 맨 앞에 있는, 코드 수정 직후의 첫 번째 단계", - "answerIndex": 1, - "explanation": "섹션 7 실습의 3단계 규칙으로, [사람]+[위험]이 겹치는 단계에 ★을 칩니다. 사람 손이 필요해 실수가 잦고, 실수하면 사이트가 죽는 아픈 곳부터 자동화하는 것이 1순위이기 때문이에요." - }, - { - "courseSlug": "clean-code", - "question": "이 코스는 모든 규칙이 한 문장으로 요약된다고 소개합니다. 그 문장은 무엇인가요?", - "option1": "코드는 무조건 짧게 써라", - "option2": "동작만 같으면 스타일은 자유다", - "option3": "읽는 사람의 물음표를 줄여라", - "option4": "주석은 많이 달수록 안전하다", - "answerIndex": 2, - "explanation": "섹션 1의 팁에서 이름 짓기부터 코드리뷰 매너까지 이 코스의 규칙 전부가 '읽는 사람의 물음표를 줄여라' 한 문장의 각론이라고 설명합니다." - }, - { - "courseSlug": "clean-code", - "question": "코스에서 제시한 이름 짓기 규칙으로 올바른 것은 무엇인가요?", - "option1": "변수는 동사로, 함수는 명사로 짓는다", - "option2": "변수·상수는 명사로, 함수는 동사로 시작한다", - "option3": "모든 이름은 타자 속도를 위해 짧게 줄인다", - "option4": "임시 값에는 tmp, data 같은 이름을 쓴다", - "answerIndex": 1, - "explanation": "섹션 2의 규칙 3줄 요약 — 변수·상수는 명사(activeMembers 등), 함수는 동사로 시작(fetchCourses 등), a·tmp·data 같은 '아무 이름'은 금지입니다." - }, - { - "courseSlug": "clean-code", - "question": "함수를 쪼개야 할지 판단할 때 코스가 제시한 진짜 기준은 무엇인가요?", - "option1": "함수가 5줄을 넘으면 무조건 쪼갠다", - "option2": "주석이 세 개 이상 달리면 쪼갠다", - "option3": "매개변수가 두 개를 넘으면 쪼갠다", - "option4": "하는 일을 한 문장으로 말할 때 '그리고'가 나오면 쪼갠다", - "answerIndex": 3, - "explanation": "섹션 3에서 기준은 줄 수가 아니라 \"이 함수가 하는 일을 '그리고' 없이 한 문장으로 말할 수 있는가\"라고 했습니다. 한 가지 일을 하는 20줄 함수는 괜찮습니다." - }, - { - "courseSlug": "clean-code", - "question": "코스가 말하는 '좋은 주석'의 예로 가장 알맞은 것은 무엇인가요?", - "option1": "서버가 페이지 번호를 0이 아니라 1부터 세기 때문에 보정한다", - "option2": "i 값을 1만큼 증가시키는 코드다", - "option3": "activeMembers는 회원 목록을 담는 변수다", - "option4": "아래 반복문은 배열을 처음부터 끝까지 돈다", - "answerIndex": 0, - "explanation": "섹션 4에 따르면 좋은 주석은 코드만 봐서는 알 수 없는 '왜'(의도·사정·제약)를 남기는 주석입니다. 나머지 셋은 코드가 이미 말하는 '무엇'을 반복한 소음 주석입니다." - }, - { - "courseSlug": "clean-code", - "question": "수습 동료가 handleSubmit 함수 하나에서 입력 검사, 데이터 가공, 서버 전송, 화면 뒷정리를 전부 처리하고 있습니다. 이 상황을 코스의 비유로 표현하면 무엇인가요?", - "option1": "받는 사람 없이 나만 알아보면 되는 일기 같은 코드다", - "option2": "이름표 대신 '그거, 저거'라고 써 붙인 창고다", - "option3": "한 사람이 주문받고 요리하고 서빙하고 설거지까지 하는 식당이다", - "option4": "게시판 세 곳에 같은 공지를 붙여 둔 학교다", - "answerIndex": 2, - "explanation": "섹션 3 '함수는 한 가지 일만'의 비유입니다. 한 함수가 네 가지 일을 하면 사고 나기 쉽고 일을 나눠 줄 수도 없으니, 일 하나당 함수 하나로 쪼개야 합니다." - }, - { - "courseSlug": "cloud-network", - "question": "우리 학습 플랫폼의 EC2 서버가 실제로 위치한 AWS 리전은 어디인가?", - "option1": "ap-northeast-1 (도쿄 리전)", - "option2": "us-east-1 (미국 버지니아 리전)", - "option3": "ap-northeast-2 (서울 리전)", - "option4": "ap-northeast-2a (서울의 가용영역)", - "answerIndex": 2, - "explanation": "코스 1섹션에서 우리 서버는 사용자와 물리적으로 가까운 서울 리전(ap-northeast-2)에 있다고 배웠다. ap-northeast-2a는 리전이 아니라 그 안의 독립된 건물 그룹인 가용영역(AZ)이다." - }, - { - "courseSlug": "cloud-network", - "question": "코스에서 설명한 VPC(Virtual Private Cloud)의 개념으로 가장 알맞은 것은?", - "option1": "클라우드 안에 친 가상의 울타리로, 다른 회사와 서로 전혀 보이지 않게 분리된 나만의 네트워크", - "option2": "서버 여러 대 앞에서 요청을 골고루 나눠 주며 교통정리를 하는 장치", - "option3": "전 세계 곳곳의 엣지 서버에 복사본을 미리 배치해 두는 전송 네트워크", - "option4": "도시 단위로 데이터센터 건물들을 묶어 놓은 지리적 구역", - "answerIndex": 0, - "explanation": "2섹션에서 VPC는 거대한 데이터센터 단지 안에 울타리를 치고 '여기부터는 우리 땅'이라 선언한 가상 네트워크로, 다른 회사 VPC와는 서로 절대 보이지 않는다고 설명했다. 지난 코스의 공유기 사설 네트워크(192.168.x.x)의 회사·클라우드 버전이다." - }, - { - "courseSlug": "cloud-network", - "question": "데이터베이스처럼 절대 외부에 노출되면 안 되는 서버를 배치할 서브넷과 그 이유로 옳은 것은?", - "option1": "퍼블릭 서브넷 — 인터넷 게이트웨이와 연결되어 있어 관리자가 접속하기 편하기 때문", - "option2": "퍼블릭 서브넷 — 손님(사용자)의 요청을 가장 빠르게 받아 처리할 수 있기 때문", - "option3": "프라이빗 서브넷 — 퍼블릭 서브넷보다 IP 주소를 더 많이 확보할 수 있기 때문", - "option4": "프라이빗 서브넷 — 인터넷에서 들어오는 길 자체가 없어 해커가 두드려도 접근할 수 없기 때문", - "answerIndex": 3, - "explanation": "3섹션의 핵심 원칙 '손님 맞을 것만 대로변에, 금고(DB)는 안쪽 방에'에 해당한다. 프라이빗 서브넷은 인터넷 게이트웨이(정문)와 이어지지 않아 외부에서 직접 들어올 길이 없으며, DB를 퍼블릭에 두는 것은 실무에서 가장 흔한 보안 사고 중 하나라고 경고했다." - }, - { - "courseSlug": "cloud-network", - "question": "코스에서 탄력적 IP(Elastic IP)를 '서버의 평생 전화번호'에 비유했다. 탄력적 IP가 해결해 주는 문제는 무엇인가?", - "option1": "서버가 살아 있는지 주기적으로 확인해 쓰러진 서버를 배분 명단에서 빼는 문제", - "option2": "EC2 서버를 껐다 켜면 공인 IP가 바뀌어 도메인이 허공을 가리키게 되는 문제", - "option3": "지구 반대편 사용자에게는 물리적 거리 때문에 응답이 느려지는 문제", - "option4": "한 데이터센터 건물에 정전이나 화재가 나면 서비스 전체가 멈추는 문제", - "answerIndex": 1, - "explanation": "4섹션에서 EC2는 껐다 켜면 공인 IP가 바뀔 수 있어 도메인이 가리키던 주소가 어긋나는 사고가 나는데, 탄력적 IP(우리 플랫폼은 3.36.160.246)를 고정으로 붙이면 재부팅하거나 새 서버로 갈아타도 같은 주소를 유지한다고 배웠다. 휴대폰 번호이동과 같은 원리다." - }, - { - "courseSlug": "cloud-network", - "question": "우리 플랫폼에 브라질 지사가 생겨 CDN을 도입하기로 했다. 코스의 '편의점 체인' 비유에 따른 올바른 배치는 무엇인가?", - "option1": "개인별 학습 기록을 각 엣지 서버에 미리 복사해 두고, 이미지는 서울 서버가 매번 새로 보낸다", - "option2": "모든 데이터를 브라질 엣지 서버로 옮기고 서울의 원본 서버는 더 이상 쓰지 않는다", - "option3": "이미지·JS/CSS처럼 모두에게 똑같은 파일은 엣지에 두고, 사람마다 다른 학습 기록은 서울 원본 서버가 만든다", - "option4": "HTTPS 암호 해제만 엣지 서버에 맡기고, 이미지를 포함한 모든 요청은 전부 서울 서버로 보낸다", - "answerIndex": 2, - "explanation": "7섹션에서 CDN에는 이미지·동영상·JS/CSS처럼 자주 바뀌지 않는 것을 두고, 개인 학습 기록처럼 매번 달라지는 데이터는 원본 서버가 만들어야 한다고 했다. 본사 공장(원본 서버)은 그대로 두고 동네 편의점(엣지)에 똑같은 물건을 미리 깔아 두는 비유와 같다." - }, - { - "courseSlug": "coding", - "question": "이 코스에서는 '코딩'을 무엇이라고 정의하나요?", - "option1": "컴퓨터가 스스로 배우도록 데이터를 주는 일", - "option2": "컴퓨터에게 일을 시키는 글쓰기", - "option3": "컴퓨터 부품을 조립해 성능을 높이는 작업", - "option4": "컴퓨터 화면을 예쁘게 꾸미는 디자인 작업", - "answerIndex": 1, - "explanation": "섹션 ①에서 코딩은 '컴퓨터에게 일을 시키는 글쓰기'라고 정의합니다. 컴퓨터는 적힌 대로만 움직이므로 무엇을, 어떤 순서로, 어떤 조건에서 할지 빠짐없이 적어야 합니다." - }, - { - "courseSlug": "coding", - "question": "로그인 페이지 코드에서 setUsername 같은 set 함수로 상태(state) 값을 바꾸면 React는 어떤 일을 하나요?", - "option1": "바뀐 값을 서버에 자동으로 저장한다", - "option2": "브라우저 전체를 새로고침해서 초기화한다", - "option3": "값이 바뀐 것을 알아채고 화면을 다시 그린다", - "option4": "에러가 없는지 검사해 콘솔에 결과를 출력한다", - "answerIndex": 2, - "explanation": "섹션 ②의 useState 설명대로, set 함수로 값을 바꾸면 React가 '값이 바뀌었네? 화면도 다시 그리자'라고 알아챕니다. 입력창에 한 글자 칠 때마다 이 일이 일어납니다." - }, - { - "courseSlug": "coding", - "question": "대시보드 체크리스트 코드에 나오는 done ? ' done' : '' 한 줄의 의미로 알맞은 것은?", - "option1": "체크된 항목이면 done이라는 CSS 클래스를 붙이고, 아니면 붙이지 않는다", - "option2": "체크된 항목이면 그 항목을 목록에서 바로 삭제한다", - "option3": "체크 여부와 상관없이 모든 항목에 done 클래스를 붙인다", - "option4": "체크된 항목이면 서버에 완료 알림 요청을 보낸다", - "answerIndex": 0, - "explanation": "섹션 ③에서 이 조건식은 '체크됐으면 done 클래스를 붙이고, 아니면 붙이지 마라'는 if문과 같다고 설명합니다. 체크할 때 글자에 취소선이 생기는 이유가 바로 이 한 줄입니다." - }, - { - "courseSlug": "coding", - "question": "이 앱에서 '비밀번호가 맞는지' 같은 믿을 수 있는 판단을 프론트엔드가 아니라 백엔드(서버)가 맡는 이유는 무엇인가요?", - "option1": "브라우저에서는 자바스크립트로 비교 연산을 할 수 없기 때문", - "option2": "서버가 브라우저보다 계산 속도가 훨씬 빠르기 때문", - "option3": "프론트엔드는 화면을 그리는 기능이 아예 없기 때문", - "option4": "브라우저에서 도는 코드는 사용자가 열어보고 고칠 수 있기 때문", - "answerIndex": 3, - "explanation": "섹션 ④에서 브라우저 코드는 사용자가 얼마든지 열어보고 고칠 수 있어, 검사 코드를 지워버리면 통과할 수 있다고 설명합니다. 그래서 로그인·권한·저장 같은 판단은 전부 서버가 합니다." - }, - { - "courseSlug": "coding", - "question": "수습생 A가 회원가입 화면에서 이미 다른 사람이 쓰고 있는 아이디를 입력하자 '이미 사용 중인 아이디예요'라는 문구가 떴습니다. 이 동작을 만들어 낸 규칙은 코스의 어느 부분일까요?", - "option1": "passwordHash 필드가 비밀번호를 해시로만 저장하는 보안 수칙", - "option2": "User 엔티티의 username 컬럼에 붙은 unique = true 규칙", - "option3": ".map()이 배열의 각 항목마다 화면을 하나씩 만들어 내는 반복 규칙", - "option4": "try/catch 구조로 실패 상황을 미리 준비해 두는 함수 작성법", - "answerIndex": 1, - "explanation": "섹션 ⑤의 '직접 확인해 보기'에서 다룬 상황입니다. users 테이블의 username에 unique = true가 붙어 있어 같은 아이디로 두 명이 가입할 수 없다는 규칙이 데이터베이스 차원에서 강제됩니다." - }, - { - "courseSlug": "color", - "question": "색의 3속성 중 채도(Saturation)가 답하는 질문으로 가장 알맞은 것은?", - "option1": "\"무슨 색이야?\"", - "option2": "\"얼마나 밝아?\"", - "option3": "\"얼마나 쨍해?\"", - "option4": "\"얼마나 투명해?\"", - "answerIndex": 2, - "explanation": "섹션 1의 HSL 좌표에서 채도(S)는 0%(회색)부터 100%(쨍함)까지 색이 얼마나 쨍한지를 나타냅니다. \"무슨 색이야?\"는 색상(H), \"얼마나 밝아?\"는 명도(L)의 질문이에요." - }, - { - "courseSlug": "color", - "question": "60-30-10 법칙에서 포인트색인 브랜드 블루 #2159C5를 쓰기에 알맞은 곳은?", - "option1": "배경과 카드 같은 넓은 바탕", - "option2": "버튼·링크 같은 강조 요소", - "option3": "본문 텍스트와 경계선 전체", - "option4": "아이콘을 포함한 화면의 절반", - "answerIndex": 1, - "explanation": "섹션 3에서 포인트색은 화면의 10%만 차지하는 '주인공'으로, 버튼·링크·강조에 씁니다. 파랑이 귀하니까(희소성) 눈에 띄는 것이고, 60%를 차지하면 아무것도 강조되지 않아요." - }, - { - "courseSlug": "color", - "question": "색상환에서 보색(Complementary)에 대한 설명으로 옳은 것은?", - "option1": "정반대(180° 차이)의 색으로, 강조가 필요한 한 곳에만 쓴다", - "option2": "이웃(±30° 안팎)의 색으로, 배경 같은 넓은 면적에 어울린다", - "option3": "같은 색상에서 명도만 바꾼 색으로, 다크모드 전환에 쓴다", - "option4": "채도가 같은 색끼리의 묶음으로, 그래프 계열색에 쓴다", - "answerIndex": 0, - "explanation": "섹션 2에서 보색은 색상환에서 정반대(180° 차이)에 있는 색으로 대비가 강렬해 강조·경고에 딱이라고 배웁니다. 이웃한 색은 유사색으로, 넓은 면적에 어울려요. 암기법은 \"싸울 땐 맞은편, 친할 땐 옆자리\"." - }, - { - "courseSlug": "color", - "question": "WCAG 기준으로 본문 텍스트가 통과해야 하는 최소 대비비(AA 등급)는?", - "option1": "3 : 1 이상", - "option2": "1.8 : 1 이상", - "option3": "21 : 1 이상", - "option4": "4.5 : 1 이상", - "answerIndex": 3, - "explanation": "섹션 6에서 WCAG AA 등급의 본문 텍스트 최소 기준은 4.5:1입니다. 3:1은 큰 텍스트(18pt+)의 기준이고, 흰 배경 위 #2159C5는 약 6.3:1로 본문 합격이에요." - }, - { - "courseSlug": "color", - "question": "다크모드 화면에서 브랜드 블루 #2159C5 버튼이 어두운 배경에 묻혀 잘 안 보인다. 코스의 원칙대로 고치는 방법은?", - "option1": "채도를 100%로 올려 형광처럼 쨍하게 만든다", - "option2": "눈에 잘 띄는 주황 같은 다른 색상으로 교체한다", - "option3": "색상(H)은 그대로 두고 명도(L)만 올린 밝은 파랑을 쓴다", - "option4": "버튼 글자를 순수 흰색 #FFFFFF로 바꿔 번쩍이게 한다", - "answerIndex": 2, - "explanation": "섹션 7에서 다크모드용 파랑은 색상(H)을 유지한 채 명도(L)만 올린 색입니다. 색상까지 바꾸면 '다른 브랜드'가 되니 정체성은 지키고 조명 대응만 하는 것 — \"같은 사람인데 무대 조명에 맞춰 옷만 갈아입는\" 비유 그대로예요." - }, - { - "courseSlug": "cpu-deep", - "question": "CPU가 명령어 하나를 처리할 때 반복하는 명령어 사이클의 올바른 순서는 무엇인가요?", - "option1": "해석(Decode) → 가져오기(Fetch) → 실행(Execute)", - "option2": "실행(Execute) → 해석(Decode) → 가져오기(Fetch)", - "option3": "가져오기(Fetch) → 해석(Decode) → 실행(Execute)", - "option4": "가져오기(Fetch) → 실행(Execute) → 해석(Decode)", - "answerIndex": 2, - "explanation": "섹션 1의 명령어 사이클입니다. CPU는 메모리에서 명령어를 가져오고(Fetch), 무슨 뜻인지 해석한 뒤(Decode), 실행하는(Execute) 세 박자를 1초에 수십억 번 반복해요." - }, - { - "courseSlug": "cpu-deep", - "question": "코스의 주방 비유에서 '코어(Core)'와 '클럭(Clock)'은 각각 무엇에 해당하나요?", - "option1": "코어는 요리사 수, 클럭은 손 놀리는 속도", - "option2": "코어는 도마 옆 재료 선반, 클럭은 요리사 수", - "option3": "코어는 손 놀리는 속도, 클럭은 요리사의 손 놀림", - "option4": "코어는 주문 창구 수, 클럭은 냉장고까지의 거리", - "answerIndex": 0, - "explanation": "섹션 2의 주방 비유예요. 코어는 주방의 요리사 수(8코어 = 요리사 8명), 클럭은 손 놀리는 속도(4.5GHz = 1초에 45억 박자)에 해당합니다. 재료 선반은 캐시, 냉장고는 RAM이었죠." - }, - { - "courseSlug": "cpu-deep", - "question": "캐시 3층 구조(L1~L3)에 대한 설명으로 옳은 것은 무엇인가요?", - "option1": "L3가 코어에서 가장 가깝고, 셋 중 가장 빠르다", - "option2": "L1은 코어들이 공유하는 큰 선반이라 용량이 가장 크다", - "option3": "CPU는 데이터를 RAM에서 먼저 찾고 없으면 L1으로 내려간다", - "option4": "코어에서 가까울수록 작고 빠르며, 멀수록 크고 느리다", - "answerIndex": 3, - "explanation": "섹션 3의 층수 규칙입니다. L1은 손 안의 재료처럼 가장 작고 빠르고, L3는 코어들이 공유하는 큰 선반이에요. CPU는 L1 → L2 → L3 → RAM 순서로 찾아봅니다." - }, - { - "courseSlug": "cpu-deep", - "question": "CPU 온도가 90℃를 넘었을 때 일어나는 '스로틀링'에 대한 설명으로 옳은 것은 무엇인가요?", - "option1": "CPU가 고장 나기 직전이라는 신호이므로 즉시 교체해야 한다", - "option2": "CPU가 타 버리는 걸 막으려고 스스로 클럭을 낮추는 자기 보호 동작이다", - "option3": "쿨링 팬이 멈추면서 CPU가 강제로 전원을 차단하는 현상이다", - "option4": "온도를 낮추기 위해 CPU가 클럭을 오히려 더 높여서 일을 빨리 끝내는 기능이다", - "answerIndex": 1, - "explanation": "섹션 4의 스로틀링입니다. 마라톤 선수가 무리가 오면 스스로 페이스를 낮추듯, CPU가 클럭을 낮춰(예: 4.5 → 3.0GHz) 타 버리는 걸 막는 정상적인 자기 보호 동작이에요. 고장이 아닙니다." - }, - { - "courseSlug": "cpu-deep", - "question": "친구가 \"내 컴퓨터는 i7인데 최신 i5를 쓰는 네 컴퓨터보다 왜 느리지?\"라고 물었습니다. 코스에서 배운 내용으로 가장 알맞은 대답은 무엇인가요?", - "option1": "i7이 i5보다 항상 빠르니까 다른 부품이 고장 난 게 분명하다", - "option2": "클럭(GHz) 숫자만 비교하면 되고, 등급 숫자는 성능과 관계없다", - "option3": "등급만큼 세대가 중요해서, 최신 세대 i5가 구형 i7을 이기는 건 흔한 일이다", - "option4": "인텔과 라이젠은 이름 문법이 달라서 두 CPU는 애초에 비교할 수 없다", - "answerIndex": 2, - "explanation": "섹션 5의 중고차 비유를 적용한 문제예요. 체급(그랜저냐 아반떼냐)만큼 연식(세대)이 중요해서, 최신 연식 중형차가 10년 된 대형차보다 잘 달리듯 최신 세대 i5가 구형 i7을 이기는 일은 아주 흔합니다." - }, - { - "courseSlug": "data-modeling", - "question": "이 코스가 '미리 보는 핵심 문장'으로 제시한, 코스 전체를 한 줄로 줄인 문장은 무엇인가요?", - "option1": "테이블은 무조건 잘게 쪼갤수록 좋은 설계다", - "option2": "모든 데이터는 하나의 테이블에 모아서 관리한다", - "option3": "같은 사실은 딱 한 곳에만 적고, 필요한 곳에서는 번호(FK)로 가리킨다", - "option4": "관계는 종류와 상관없이 항상 중간 테이블로 표현한다", - "answerIndex": 2, - "explanation": "섹션 1의 팁에서 코스 전체를 한 줄로 줄인 문장으로 \"같은 사실은 딱 한 곳에만 적고, 필요한 곳에서는 번호(FK)로 가리킨다\"를 제시합니다. 무조건 쪼개는 것은 오히려 섹션 5에서 경계한 내용이에요." - }, - { - "courseSlug": "data-modeling", - "question": "코스에서 기본키(PK)를 설명할 때 사용한 비유로 알맞은 것은 무엇인가요?", - "option1": "이름 — 누구나 알아보기 쉬운 값", - "option2": "학번 — 학교 안에서 절대 겹치지 않는 번호", - "option3": "지도 — 테이블 전체의 구조를 보여 주는 것", - "option4": "다리 — 두 테이블을 이어 주는 연결값", - "answerIndex": 1, - "explanation": "섹션 3에서 기본키는 '학번'에 비유됩니다. 이름은 동명이인이 있을 수 있지만 학번은 절대 겹치지 않아 모든 줄을 유일하게 찍어낼 수 있기 때문이에요. 지도는 ERD, 다리는 중간 테이블의 비유입니다." - }, - { - "courseSlug": "data-modeling", - "question": "1:N 관계(예: 멘토 1명이 수습 여러 명을 담당)를 테이블로 표현할 때, 코스가 알려 준 규칙으로 옳은 것은 무엇인가요?", - "option1": "항상 N쪽(여럿 쪽) 테이블에 FK 한 칸을 둔다", - "option2": "항상 1쪽 테이블에 담당 대상 칸을 개수만큼 늘려 간다", - "option3": "두 테이블 사이에 반드시 중간 테이블을 새로 만든다", - "option4": "양쪽 테이블 모두에 서로를 가리키는 FK를 하나씩 둔다", - "answerIndex": 0, - "explanation": "섹션 4의 규칙은 \"1:N에서는 항상 N쪽(여럿 쪽)에 FK를 둔다\"입니다. 1쪽에 '담당수습1, 담당수습2...' 칸을 늘리는 것은 섹션 6의 나쁜 설계 냄새 2에 해당하고, 중간 테이블은 N:M 관계에서 필요해요." - }, - { - "courseSlug": "data-modeling", - "question": "코스에서 이메일을 기본키(PK)로 쓰지 말라고 한 핵심 이유는 무엇인가요?", - "option1": "이메일은 문자열이라 숫자보다 검색 속도가 크게 느리기 때문", - "option2": "이메일은 동명이인처럼 여러 사람이 겹칠 수 있는 값이기 때문", - "option3": "이메일은 민감한 개인정보라 테이블에 저장하면 안 되기 때문", - "option4": "이메일은 바뀔 수 있는 값이라, 바뀌면 그걸 가리키던 FK가 모두 흔들리기 때문", - "answerIndex": 3, - "explanation": "섹션 3의 경고 박스에서 이메일은 유일하긴 하지만 '바뀔 수 있는 값'이라 PK가 바뀌면 그걸 가리키던 모든 FK가 함께 흔들린다고 설명합니다. 그래서 PK는 의미 없는 번호(id)로 두는 것이 정석이에요." - }, - { - "courseSlug": "data-modeling", - "question": "실습에서 '쪽지 기능'을 설계하던 수습이 한 쪽지를 여러 명에게 보내는 단체 쪽지까지 지원하기로 했습니다. 코스에서 배운 대로라면 가장 알맞은 설계는 무엇인가요?", - "option1": "쪽지 테이블에 받는사람1, 받는사람2, 받는사람3 칸을 추가한다", - "option2": "받는 사람들의 이메일을 쉼표로 이어 붙여 한 칸에 저장한다", - "option3": "쪽지와 users 사이에 수신자 중간 테이블을 만들어 N:M로 잇는다", - "option4": "받는 사람 수만큼 쪽지 제목과 내용을 통째로 복사해 저장한다", - "answerIndex": 2, - "explanation": "섹션 7의 '흔한 함정 미리보기'에서 단체 쪽지(한 쪽지 → 여러 수신자)는 N:M 관계이므로 섹션 4의 중간 테이블이 답이라고 알려 줍니다. 번호 붙은 칸의 행진과 한 칸에 여러 값은 섹션 6의 나쁜 설계 냄새예요." - }, - { - "courseSlug": "data-structures", - "question": "코스에서 브라우저의 '뒤로가기' 버튼과 똑같은 방식으로 동작한다고 소개한 자료구조는 무엇인가요?", - "option1": "큐 — 먼저 방문한 페이지부터 꺼낸다", - "option2": "배열 — 방문한 순서대로 번호를 붙인다", - "option3": "스택 — 나중에 쌓은 페이지부터 꺼낸다", - "option4": "트리 — 페이지를 부모-자식으로 잇는다", - "answerIndex": 2, - "explanation": "스택은 접시 탑처럼 맨 위에 쌓고 맨 위부터 꺼내는 LIFO(Last In, First Out) 구조입니다. 코스 섹션 3에서 브라우저 뒤로가기, Ctrl+Z, 콜 스택을 모두 스택의 예로 들었습니다." - }, - { - "courseSlug": "data-structures", - "question": "해시맵(Map)이 배열과 달리 값을 '한 번에' 찾을 수 있는 이유로 코스가 설명한 것은 무엇인가요?", - "option1": "데이터를 항상 이름 순서로 정렬해 두기 때문에", - "option2": "해시 함수가 열쇠(key)로 보관 위치를 바로 계산해 주기 때문에", - "option3": "자주 찾는 값을 맨 앞 칸으로 미리 옮겨 두기 때문에", - "option4": "같은 데이터를 여러 칸에 복사해서 나눠 담아 두기 때문에", - "answerIndex": 1, - "explanation": "섹션 4의 사물함 비유처럼, 해시 함수는 열쇠를 넣으면 보관 위치가 계산되어 나오는 기계입니다. 저장할 때와 찾을 때 같은 계산을 하므로 뒤지지 않고 바로 그 칸으로 직행합니다." - }, - { - "courseSlug": "data-structures", - "question": "자바스크립트 배열로 스택과 큐를 흉내낼 때, 코스에서 설명한 메서드 조합을 바르게 짝지은 것은 무엇인가요?", - "option1": "스택은 push와 shift, 큐는 push와 pop을 쓴다", - "option2": "스택은 pop과 shift, 큐는 push와 at을 쓴다", - "option3": "스택은 shift와 pop, 큐는 pop과 push를 쓴다", - "option4": "스택은 push와 pop, 큐는 push와 shift를 쓴다", - "answerIndex": 3, - "explanation": "두 그릇 모두 넣을 때는 push로 같고, 꺼내는 쪽만 다릅니다. 스택은 뒤에서 꺼내는 pop(LIFO), 큐는 앞에서 꺼내는 shift(FIFO)를 쓴다고 섹션 3에서 정리했습니다." - }, - { - "courseSlug": "data-structures", - "question": "코스의 시간복잡도 설명에 따르면, O(n²)인 코드에서 데이터가 10배로 늘어나면 해야 하는 일의 양은 대략 몇 배가 되나요?", - "option1": "약 100배로 늘어난다", - "option2": "약 10배로 늘어난다", - "option3": "약 2배로 늘어난다", - "option4": "거의 그대로 유지된다", - "answerIndex": 0, - "explanation": "섹션 6의 '반 전체 악수' 비유처럼 O(n²)는 데이터가 10배면 일은 100배가 됩니다(30명 약 450번, 300명 약 45,000번). 그래서 반복문 안의 반복문이 데이터가 커질 때 화면을 버벅이게 만듭니다." - }, - { - "courseSlug": "data-structures", - "question": "학교 매점 프로그램을 만든다고 해 봅시다. 주문이 들어온 순서 그대로, 먼저 주문한 학생부터 빵을 받아야 합니다. 코스의 비유에 따르면 이 주문 목록에 가장 어울리는 그릇은 무엇인가요?", - "option1": "스택 — 접시 탑처럼 최근 주문부터 처리한다", - "option2": "해시맵 — 사물함처럼 학생 이름으로 바로 찾는다", - "option3": "큐 — 급식 줄처럼 먼저 선 사람이 먼저 받는다", - "option4": "트리 — 폴더처럼 주문을 종류별 가지로 나눈다", - "answerIndex": 2, - "explanation": "먼저 온 주문이 먼저 처리되어야 하므로 FIFO(First In, First Out)인 큐가 맞습니다. 코스 섹션 3에서 큐를 '새치기 없는 급식 줄'에 비유하며 프린터 대기열, 서버의 요청 처리 줄을 예로 들었습니다." - }, - { - "courseSlug": "debugging", - "question": "코스에서는 에러 메시지를 '사건 보고서'에 비유하며 세 가지 질문에 답해 준다고 했다. 다음 중 그 세 가지에 해당하지 않는 것은?", - "option1": "무슨 일이야? — 에러의 종류 (NullPointerException, TypeError 등)", - "option2": "왜 그랬는데? — 에러 옆의 설명 문장 (\"... is null\" 등)", - "option3": "어디서? — 파일 이름과 줄 번호 (UserService.java:42 등)", - "option4": "어떻게 고쳐? — 에러를 해결할 수정 코드 제안", - "answerIndex": 3, - "explanation": "섹션 1 '에러 = 단서'에서 에러 메시지는 종류(무슨 일)·설명 문장(왜)·파일과 줄 번호(어디서) 세 가지에 답해 준다고 했다. 고치는 방법까지 알려 주지는 않으며, 이 셋만 읽어도 문제의 절반은 푼 것이라고 설명한다." - }, - { - "courseSlug": "debugging", - "question": "Spring Boot 콘솔에 수십 줄짜리 스택트레이스가 여러 덩어리로 이어져 나올 때, 코스가 알려 준 '진짜 원인'을 찾는 치트키는?", - "option1": "맨 위의 NestedServletException을 자세히 분석한다", - "option2": "Ctrl+F로 검색해 가장 아래쪽 \"Caused by\"를 찾는다", - "option3": "org.springframework로 시작하는 줄부터 차례로 읽는다", - "option4": "\"... 47 more\"로 접힌 부분을 모두 펼쳐서 읽는다", - "answerIndex": 1, - "explanation": "섹션 2에서 에러는 양파처럼 겹겹이 포장돼 올라오며, 맨 위 예외는 겉포장(증상)이고 진범은 거의 항상 가장 아래쪽 Caused by에 있다고 했다. 그래서 긴 로그에서는 Ctrl+F로 Caused by부터 검색하라고 안내한다." - }, - { - "courseSlug": "debugging", - "question": "의심 가는 함수 안에 라벨을 붙여 console.log를 찍었는데, 콘솔에 그 로그가 아예 나타나지 않았다. 코스에 따르면 이 상황의 올바른 해석은?", - "option1": "그 함수 자체가 호출되지 않았다는 뜻으로, 용의자 절반을 지워 준 중요한 단서다", - "option2": "로그 출력이 실패한 것이므로 console.log를 다른 문법으로 다시 찍어야 한다", - "option3": "변수 값이 undefined라서 로그가 출력되지 못한 것이다", - "option4": "브라우저 캐시 문제이므로 시크릿 창에서 다시 확인해야 한다", - "answerIndex": 0, - "explanation": "섹션 4의 주의 박스에서 로그가 아예 안 찍히는 것도 중요한 단서라고 했다. '값이 이상하다'가 아니라 '이 함수 자체가 호출되지 않는다'는 뜻이며, 실패가 아니라 용의자 절반을 지워 준 수확이라고 설명한다." - }, - { - "courseSlug": "debugging", - "question": "브라우저 F12 콘솔에 \"500 Internal Server Error\"가 떠 있다. 코스에서 배운 '프론트 문제인지 서버 문제인지 가르는 감각'에 따르면 다음으로 열어 봐야 할 곳은?", - "option1": "React 컴포넌트의 JSX에서 오타가 없는지 코드를 다시 읽는다", - "option2": "브라우저 콘솔에서 TypeError가 함께 떴는지 계속 지켜본다", - "option3": "백엔드(Spring Boot) 콘솔을 열어 스택트레이스를 확인한다", - "option4": "새로고침한 뒤 다른 브라우저에서도 같은 화면을 띄워 본다", - "answerIndex": 2, - "explanation": "섹션 3에서 TypeError·ReferenceError는 내 JS 코드 문제지만 404·500 같은 숫자는 서버와의 통신 문제라고 했다. 500은 서버 쪽에서 터진 것이므로 브라우저가 아니라 Spring Boot 콘솔의 스택트레이스를 봐야 한다." - }, - { - "courseSlug": "debugging", - "question": "버튼을 누르면 화면이 하얗게 죽는다. 처리 흐름은 A(이벤트 핸들러) → B(fetch 호출) → C(응답 가공) → D(화면 그리기)이고, B와 C 사이에 찍어 둔 로그가 콘솔에 정상적으로 나타났다. 코스의 이분 탐색 요령대로라면 다음 한 수는?", - "option1": "A의 첫 줄에 로그를 찍어 이벤트가 들어오는지부터 다시 확인한다", - "option2": "범인을 C·D로 좁혔으니 그 남은 절반의 가운데에 로그를 찍는다", - "option3": "A부터 D까지 모든 줄에 로그를 찍어 전체 흐름을 한 번에 본다", - "option4": "fetch 호출(B)의 코드를 처음부터 한 줄씩 자세히 읽어 본다", - "answerIndex": 1, - "explanation": "섹션 6의 이분 탐색에서 가운데 검문소(로그)를 통과했으면 앞 구간(A·B)은 무죄이고 범인은 C·D 중에 있다고 했다. 이후에도 남은 절반의 가운데에 또 로그를 찍어 용의자를 반씩 줄여 가는 것이 요령이다." - }, - { - "courseSlug": "design-refs", - "question": "코스에서는 3대 레퍼런스 사이트를 각각 비유로 소개해요. '미술관 전시장'에 비유되며, 완성도 높은 '한 장면'을 보기 좋지만 실제 서비스가 아닌 컨셉 작업이 많은 곳은 어디인가요?", - "option1": "비핸스", - "option2": "드리블", - "option3": "핀터레스트", - "option4": "피그마", - "answerIndex": 1, - "explanation": "섹션 2에서 드리블은 '미술관 전시장'으로, 색·타이포·분위기 같은 한 장면의 완성도를 보기 좋지만 보여주기용 컨셉이 많다고 설명해요. 비핸스는 발표회장, 핀터레스트는 스크랩북에 비유됩니다." - }, - { - "courseSlug": "design-refs", - "question": "피그마 무드보드 만들기 4단계 중, 코스가 '이게 무드보드의 절반'이라고 할 만큼 중요하다고 강조한 단계는 무엇인가요?", - "option1": "새 파일을 만들고 이름 짓기", - "option2": "스크린샷을 캔버스에 붙여넣기", - "option3": "이미지 옆에 태그 메모 붙이기", - "option4": "비슷한 것끼리 프레임으로 묶기", - "answerIndex": 2, - "explanation": "섹션 4에서 가장 중요한 건 ③ 태그라고 말해요. 태그 없는 무드보드는 제목 없는 도서관 같아서 100장을 모아도 필요할 때 못 찾기 때문에, 저장하는 순간 한 단어라도 붙이는 습관이 무드보드의 절반이라고 강조합니다." - }, - { - "courseSlug": "design-refs", - "question": "코스에서 '베끼기'와 '참고'를 스스로 구분해 보라며 제시한 판별 테스트 질문은 무엇인가요?", - "option1": "원본을 안 보고도 내 디자인을 다시 그릴 수 있는가?", - "option2": "결과물에 원본의 출처 URL을 표기했는가?", - "option3": "레퍼런스를 세 개 이상 섞어서 참고했는가?", - "option4": "색과 폰트를 원본과 다르게 바꿨는가?", - "answerIndex": 0, - "explanation": "섹션 3의 판별 테스트는 '원본을 안 보고도 다시 그릴 수 있는가'예요. YES면 원리를 이해한 참고, NO면 그림을 외운 것이라 베끼기에 가깝다고 설명하며, 출처 표기는 사용 허락이 아니라고 못 박습니다." - }, - { - "courseSlug": "design-refs", - "question": "'나만의 수집 루틴' 섹션에서 디자인 실력이 자라는 공식으로 제시한 것은 무엇인가요?", - "option1": "하루 스크롤 시간 × 저장한 이미지 수", - "option2": "팔로우한 디자이너 수 × 활동 기간", - "option3": "무드보드 페이지 수 × 붙인 태그 수", - "option4": "많이 본 총량 × 분석한 횟수", - "answerIndex": 3, - "explanation": "섹션 6의 루틴 다이어그램은 실력이 '많이 본 총량 × 분석한 횟수'로 자란다고 말해요. 모으기만 하고 분석하지 않으면 그냥 스크랩 중독이라고 경고합니다." - }, - { - "courseSlug": "design-refs", - "question": "수습생 지민이는 드리블에서 본 학습 앱 화면을 밑에 깔고 그대로 따라 그린 뒤, 이미지 아래에 원본 출처 URL을 적어 포트폴리오에 넣었어요. 코스의 기준으로 가장 옳은 판단은 무엇인가요?", - "option1": "출처를 밝혔으므로 문제없는 '참고'에 해당한다", - "option2": "연습 삼아 그린 것이므로 포트폴리오에 넣어도 괜찮다", - "option3": "출처 표기는 사용 허락이 아니므로, 트레이싱을 포트폴리오에 쓰면 안 된다", - "option4": "색과 문구만 바꾸면 저작권 문제가 사라지므로 수정 후 올리면 된다", - "answerIndex": 2, - "explanation": "섹션 3에서 트레이싱은 연습장 안에서만 허용되고 제출물·포트폴리오·실제 서비스에 쓰는 순간 문제가 된다고 해요. 또 '출처 표기 ≠ 사용 허락'이므로 URL을 적었다고 베끼기가 참고가 되지는 않습니다." - }, - { - "courseSlug": "design-system", - "question": "코스에서는 디자인 시스템을 색·글꼴·간격·부품·사용 규칙을 모아 둔 것이라고 소개합니다. 이를 한마디로 무엇에 비유했나요?", - "option1": "제품의 설계 도면", - "option2": "제품의 부품 창고", - "option3": "제품의 문법책", - "option4": "제품의 사용 설명 영상", - "answerIndex": 2, - "explanation": "섹션 1에서 디자인 시스템을 '제품의 문법책'에 비유합니다. 문법이 있어야 여러 사람이 써도 한 사람이 쓴 글처럼 읽히듯, 약속이 있어야 열 명이 만들어도 한 사람이 만든 화면처럼 보이기 때문이에요." - }, - { - "courseSlug": "design-system", - "question": "코스에서 말하는 '디자인 토큰(token)'의 뜻으로 가장 알맞은 것은 무엇인가요?", - "option1": "색·간격·글꼴 같은 디자인 결정에 이름을 붙인 변수", - "option2": "피그마 파일에 접근할 때 쓰는 인증 키", - "option3": "컴포넌트의 버전을 관리하는 고유 번호", - "option4": "버튼의 상태를 저장해 두는 자바스크립트 객체", - "answerIndex": 0, - "explanation": "섹션 2에서 토큰은 '디자인 결정에 이름을 붙인 변수'라고 정의합니다. #3B82F6 같은 값 대신 --color-primary라는 이름으로 부르기로 약속하는 것으로, global.css의 :root에 실물이 살고 있어요." - }, - { - "courseSlug": "design-system", - "question": "피그마 컴포넌트와 React 컴포넌트의 짝이 어긋나기 시작하는 것을 막기 위해 코스가 제시한 규칙은 무엇인가요?", - "option1": "부품 변경은 디자이너가 먼저 끝낸 뒤 개발자에게 문서로 전달한다", - "option2": "React props 이름은 피그마 variant와 다르게 지어 역할을 구분한다", - "option3": "variant 추가는 분기별로 모아 두었다가 한꺼번에 코드에 반영한다", - "option4": "부품을 바꿀 땐 피그마와 코드를 같은 PR·같은 티켓에서 함께 바꾼다", - "answerIndex": 3, - "explanation": "섹션 4의 경고 박스에서, 피그마에만 variant가 추가되고 코드에는 없으면 두 세계가 갈라지기 시작하므로 '피그마와 코드를 같은 PR·같은 티켓에서 함께 바꾸는 것'이 규칙이라고 설명합니다." - }, - { - "courseSlug": "design-system", - "question": "코스에서 소개한 '좋은 컴포넌트 문서에 꼭 들어가는 4가지'에 해당하지 않는 것은 무엇인가요?", - "option1": "언제 쓰는 부품인지 알려 주는 When", - "option2": "누가 언제 만들었는지 적는 제작자·날짜", - "option3": "하지 말아야 할 사용법을 적는 Don't", - "option4": "복사해서 바로 쓰는 예제 코드인 How", - "answerIndex": 1, - "explanation": "섹션 5에서 좋은 문서의 4요소는 When(언제 쓰나), How(예제 코드), Don't(하지 말 것), 그리고 상태·변형 전체 목록입니다. 제작자와 날짜는 이 목록에 없어요." - }, - { - "courseSlug": "design-system", - "question": "수습 A가 과제 제출 버튼을 눌렀는데, 서버가 처리하는 동안에도 버튼이 그대로 눌려서 과제가 두 번 제출되는 사고가 났습니다. 버튼의 다섯 상태 중 어떤 설계가 빠진 걸까요?", - "option1": "hover — 마우스를 올리면 살짝 어두워지는 상태", - "option2": "loading — 스피너를 보여 주고 다시 못 누르게 잠그는 상태", - "option3": "active — 누르는 순간 꺼진 느낌을 주는 상태", - "option4": "default — 평소의 또렷한 기본 모습", - "answerIndex": 1, - "explanation": "섹션 3의 상태표에서 loading은 '처리 중(제출 직후 등)에 스피너를 띄우고 다시 못 누르게 잠그는 상태'입니다. 이 상태가 빠지면 제출 직후 버튼이 두 번 눌려 과제가 중복 제출되는 사고로 이어져요." - }, - { - "courseSlug": "dns-deep", - "question": "재귀 조회의 여행에서 도메인의 진짜 IP를 최종적으로 답해 주는 서버는 무엇인가요?", - "option1": "루트 서버 — 여행의 출발점으로 전 세계 도메인을 다 외우고 있다", - "option2": "TLD 서버 — .com 같은 최상위 도메인을 관리하는 안내소이므로", - "option3": "리졸버 — 내 PC 대신 뛰어다니는 심부름꾼이 답을 만들어 낸다", - "option4": "권한(Authoritative) 서버 — 그 도메인의 진짜 명부를 가진 최종 담당자", - "answerIndex": 3, - "explanation": "섹션 1의 여행에서 루트와 TLD 서버는 \"담당자는 저쪽\"이라고 안내만 하고, 실제 IP는 도메인의 명부를 가진 권한 서버가 답합니다. 우리 도메인은 Route53이 그 역할이에요." - }, - { - "courseSlug": "dns-deep", - "question": "DNS 레코드 타입 중 CNAME에 대한 설명으로 옳은 것은 무엇인가요?", - "option1": "이름을 IPv4 주소로 바꿔 주는, 여행이 끝나는 종착역 레코드다", - "option2": "이 도메인의 메일이 어디로 가야 하는지 알려 주는 레코드다", - "option3": "이름을 다른 이름으로 연결하는 별명 레코드로, 사슬 끝에는 A 레코드가 필요하다", - "option4": "도메인 소유 확인이나 스팸 방지 정책 같은 증명 문자열을 담는 레코드다", - "answerIndex": 2, - "explanation": "섹션 2에서 CNAME은 \"이름 → 다른 이름\"인 별명이라 한 정거장 더 가야 하고, 사슬의 맨 끝에는 반드시 A 레코드가 있어야 실제 IP에 도착한다고 배웠어요." - }, - { - "courseSlug": "dns-deep", - "question": "TTL을 길게(예: 86400 = 하루) 설정하면 어떤 일이 생기나요?", - "option1": "DNS 질문이 줄어 빠르고 서버 부담이 적지만, IP를 바꿔도 최대 하루 동안 옛 주소로 가는 사람이 남는다", - "option2": "변경이 금방 퍼지는 대신 질문이 잦아져서 Route53 과금이 늘어난다", - "option3": "리졸버가 캐시를 아예 쓰지 않게 되어 매번 풀코스 여행을 다녀와야 한다", - "option4": "권한 서버가 하루에 한 번만 레코드를 갱신할 수 있도록 제한된다", - "answerIndex": 0, - "explanation": "섹션 3에서 TTL은 \"이 답을 몇 초 동안 믿고 재사용해도 되는지\"를 뜻하며, 길면 질문이 줄어 빠르지만 변경 사항이 캐시 만료 전까지 퍼지지 않는다고 배웠어요." - }, - { - "courseSlug": "dns-deep", - "question": "hosts 파일에 적은 이름은 왜 DNS 서버 설정과 상관없이 항상 이길까요?", - "option1": "hosts 파일의 내용이 리졸버의 캐시를 강제로 덮어써서 바꾸기 때문이다", - "option2": "OS가 리졸버(DNS)에 물어보기 전에 hosts 파일을 먼저 확인하고, 답을 찾으면 뒤에는 묻지 않기 때문이다", - "option3": "hosts 파일에 적은 내용이 루트 서버의 명부에 자동으로 등록되기 때문이다", - "option4": "관리자 권한으로 저장한 파일은 네트워크 설정보다 항상 우선 적용되기 때문이다", - "answerIndex": 1, - "explanation": "섹션 4에서 이름 → IP 변환은 브라우저 캐시 → OS 캐시 → hosts 파일 → 리졸버 순서로 진행되고, 먼저 답을 찾으면 뒤에는 안 물어본다고 배웠어요." - }, - { - "courseSlug": "dns-deep", - "question": "서버 IP를 바꾼 직후 사무실 PC에서는 새 사이트가 열리는데, 친구 폰에서는 아직 옛 주소로 접속됩니다. 코스 내용에 따른 가장 알맞은 진단은 무엇인가요?", - "option1": "서버 고장이므로 Route53의 레코드를 삭제하고 처음부터 다시 만들어야 한다", - "option2": "친구 폰의 hosts 파일이 조작된 것이므로 즉시 파일을 초기화해야 한다", - "option3": "친구 쪽 통신사 리졸버가 옛 답을 TTL 동안 캐시 중인 것으로, 캐시가 만료되면 자연히 해결된다", - "option4": "두 기기가 서로 다른 권한 서버를 쓰고 있으므로 통신사를 바꿔야 한다", - "answerIndex": 2, - "explanation": "섹션 3의 우리 회사 실화처럼 \"DNS 전파가 느리다\"의 정체는 세계 곳곳의 리졸버 캐시가 각자 만료되기를 기다리는 것이라, 고장이 아니며 기다리면 풀립니다." - }, - { - "courseSlug": "docker-intro", - "question": "이 코스는 이미지와 컨테이너를 붕어빵에 비유해 설명합니다. 두 개념의 관계로 옳은 것은 무엇인가요?", - "option1": "이미지는 실행 중인 프로그램이고, 컨테이너는 그 프로그램의 설계도다", - "option2": "이미지와 컨테이너는 같은 것을 가리키는 두 가지 다른 이름이다", - "option3": "이미지는 붕어빵틀처럼 읽기 전용 설계도이고, 컨테이너는 그 틀로 찍어낸 실행 실체다", - "option4": "이미지는 컨테이너가 종료된 뒤에 남는 실행 기록(로그)을 모아 둔 것이다", - "answerIndex": 2, - "explanation": "섹션 2의 붕어빵 비유 그대로, 이미지는 실행에 필요한 모든 것을 구워 놓은 읽기 전용 틀이고 컨테이너는 그 이미지를 실행한 실체입니다. 틀 하나(postgres:17)로 여러 개의 컨테이너를 찍어낼 수 있어요." - }, - { - "courseSlug": "docker-intro", - "question": "도커 5대 기본 명령 중, 실행 중인 컨테이너 '안으로 직접 들어갈' 때 쓰는 명령은 무엇인가요?", - "option1": "docker ps -a — 멈춘 컨테이너까지 전부 확인한다", - "option2": "docker exec -it <이름> bash — 컨테이너 안으로 들어간다", - "option3": "docker logs -f <이름> — 로그를 실시간으로 따라가며 본다", - "option4": "docker stop <이름> — 컨테이너를 정중하게 멈춘다", - "answerIndex": 1, - "explanation": "섹션 4에서 exec은 '문 열고 들어가기'로 기억하라고 했어요. 문제가 생기면 먼저 logs로 일기장을 읽고, 더 파야 할 때 exec으로 컨테이너 안에 직접 들어갑니다." - }, - { - "courseSlug": "docker-intro", - "question": "실습에서 docker compose down으로 DB 컨테이너를 지웠다가 다시 띄웠는데, 어제 만든 hello 테이블과 데이터가 그대로 남아 있었습니다. 코스 내용에 따르면 그 이유는 무엇인가요?", - "option1": "컨테이너를 삭제하면 도커가 내부 데이터를 자동으로 백업해 두기 때문", - "option2": "postgres:17 이미지 자체에 사용자가 만든 데이터가 함께 저장되기 때문", - "option3": "docker compose가 삭제 직전에 데이터를 Git 저장소로 옮겨 두기 때문", - "option4": "데이터가 컨테이너 밖의 볼륨(practice-data)에 저장되어 컨테이너와 운명을 같이하지 않기 때문", - "answerIndex": 3, - "explanation": "섹션 6의 핵심입니다. 컨테이너는 지우면 안이 통째로 사라지는 일회용 도시락통이라, 데이터는 컨테이너 밖의 외장하드인 볼륨에 두어 컨테이너가 죽어도 살아남게 합니다." - }, - { - "courseSlug": "docker-intro", - "question": "우리 docker-compose.yml에서 backend의 DB 주소가 IP가 아니라 jdbc:postgresql://db:5432/mirim처럼 그냥 'db'로 적혀 있습니다. 이게 동작하는 이유는 무엇인가요?", - "option1": "compose로 띄운 컨테이너들은 같은 가상 네트워크에 들어가고, 서비스 이름이 그대로 도메인 이름이 되기 때문", - "option2": "도커가 모든 컨테이너의 IP 주소를 항상 'db'라는 고정 별칭 하나로 통일해 주기 때문", - "option3": "'db'는 호스트 PC의 localhost를 가리키도록 도커가 예약해 둔 특별한 키워드이기 때문", - "option4": ".env 파일에 'db'라는 이름으로 실제 IP 주소가 미리 등록되어 주입되기 때문", - "answerIndex": 0, - "explanation": "섹션 5의 ⑪번 해설 내용입니다. compose가 만든 같은 가상 네트워크 안에서는 서비스 이름(db, backend)이 곧 도메인 이름이 되어, 네트워크 코스에서 배운 DNS가 컨테이너 세계 안에서도 작동하는 셈입니다." - }, - { - "courseSlug": "docker-intro", - "question": "수습 개발자 A가 실습대로 docker compose up -d를 실행했더니 'port is already allocated' 에러가 났습니다. 알고 보니 A의 PC에는 로컬 PostgreSQL이 이미 5432번 포트를 쓰고 있었어요. 코스에서 배운 해결 방법은 무엇인가요?", - "option1": "ports를 \"5432:15432\"로 바꿔 컨테이너 안의 포스트그레스가 다른 포트를 듣게 한다", - "option2": "docker compose down -v로 볼륨까지 전부 지운 뒤 처음부터 다시 올린다", - "option3": "ports를 \"15432:5432\"처럼 바깥쪽(호스트) 번호만 바꿔서 포트 충돌을 피한다", - "option4": "depends_on 목록에 로컬 PostgreSQL을 추가해서 켜지는 순서를 맞춰 준다", - "answerIndex": 2, - "explanation": "포트 매핑은 '바깥:안쪽' 순서라서, 내 PC 쪽 포트가 이미 쓰이고 있으면 바깥 번호만 15432처럼 바꾸면 됩니다. 섹션 7의 회피 기술이자 섹션 8의 '흔한 막힘 포인트'로 소개된 내용이에요." - }, - { - "courseSlug": "figma", - "question": "피그마 화면은 크게 세 부분으로 나뉩니다. 지금 선택한 요소의 크기·색·글꼴 같은 세부 정보를 보고 고치는 곳은 어디인가요?", - "option1": "왼쪽 레이어 패널", - "option2": "가운데 캔버스", - "option3": "오른쪽 속성 패널", - "option4": "상단 메뉴 바", - "answerIndex": 2, - "explanation": "섹션 2에서 배운 3분할 구조입니다. \"왼쪽에서 찾고, 가운데서 그리고, 오른쪽에서 다듬는다\" — 선택한 요소의 세부 속성은 오른쪽 속성 패널에서 고칩니다." - }, - { - "courseSlug": "figma", - "question": "코스에서 \"섹션 4의 주인공\"이라고 소개한 단축키 Shift + A는 무엇을 하는 키인가요?", - "option1": "선택한 것들을 그룹으로 묶는다", - "option2": "선택한 것에 오토레이아웃을 씌운다", - "option3": "방금 만든 요소를 하나 더 복제한다", - "option4": "새 프레임(화면)을 하나 만든다", - "answerIndex": 1, - "explanation": "단축키 표에서 Shift+A는 오토레이아웃 씌우기입니다. 그룹 묶기는 Ctrl+G, 복제는 Ctrl+D, 프레임은 F가 맡습니다." - }, - { - "courseSlug": "figma", - "question": "코스는 오토레이아웃을 \"피그마의 flexbox\"라고 설명하며 CSS와 짝지어 줍니다. 다음 중 옳게 짝지어진 것은 무엇인가요?", - "option1": "간격(Spacing) ↔ gap", - "option2": "패딩(Padding) ↔ flex-direction", - "option3": "방향(가로/세로) ↔ justify-content", - "option4": "정렬(Alignment) ↔ padding", - "answerIndex": 0, - "explanation": "섹션 4의 대응표에 따르면 간격(Spacing)은 CSS의 gap과 같은 개념입니다. 방향은 flex-direction, 패딩은 padding, 정렬은 justify-content/align-items와 짝을 이룹니다." - }, - { - "courseSlug": "figma", - "question": "로그인 버튼을 Ctrl+Alt+K로 컴포넌트로 만들고 인스턴스를 여러 화면에 배치했습니다. 코스가 말하는 컴포넌트의 가장 큰 이점은 무엇인가요?", - "option1": "인스턴스마다 서로 다른 색을 자동으로 입혀 준다", - "option2": "버튼이 자동으로 CSS 코드 파일로 저장된다", - "option3": "인스턴스를 복제할 때마다 이름이 자동으로 정리된다", - "option4": "원본 하나만 고치면 모든 인스턴스가 한꺼번에 바뀐다", - "answerIndex": 3, - "explanation": "섹션 5의 \"마법 도장\" 비유처럼, 컴포넌트는 복사본(인스턴스)이 원본과 연결되어 원본만 수정하면 이미 찍힌 모든 자국이 함께 바뀝니다." - }, - { - "courseSlug": "figma", - "question": "개발 수습생이 디자이너의 시안을 구현하다가 버튼의 정확한 파란색 색상 코드가 필요해졌습니다. 코스가 \"협업 매너이자 여러분의 속도\"라고 권한 행동은 무엇인가요?", - "option1": "디자이너에게 바로 메시지를 보내 색상 값을 물어본다", - "option2": "화면을 캡처해서 눈으로 가장 비슷한 색을 골라 쓴다", - "option3": "우측 상단 토글로 Dev Mode를 켜고 요소를 클릭해 값을 직접 읽는다", - "option4": "버튼 이미지를 PNG로 내보내서 그대로 프로젝트에 붙여 넣는다", - "answerIndex": 2, - "explanation": "섹션 7 핸드오프에서 배운 내용입니다. Dev Mode를 켜고 요소를 클릭하면 색상 코드·글꼴·간격이 숫자와 CSS 형태로 표시되므로, 물어보기 전에 직접 읽는 것이 협업 매너입니다." - }, - { - "courseSlug": "files", - "question": "코스에서는 파일시스템을 '디스크의 사서'에 비유합니다. 이 비유에서 파일시스템이 하는 핵심 역할은 무엇인가요?", - "option1": "디스크의 0과 1을 사람이 읽을 수 있는 글자로 번역해 준다", - "option2": "어떤 데이터가 디스크의 어느 구역에 있는지 장부(메타데이터)로 관리한다", - "option3": "파일을 저장할 때마다 자동으로 압축해서 공간을 아껴 준다", - "option4": "바이러스가 들어오지 못하게 디스크 입구를 지키는 보안 검사를 한다", - "answerIndex": 1, - "explanation": "섹션 1에서 디스크는 그냥 0과 1의 바다이고, 파일시스템(사서)이 '보고서.docx는 #4021부터'처럼 장부(메타데이터)를 얹어 관리한다고 배웠어요. 사서가 없으면 책(데이터)이 있어도 찾을 수 없습니다." - }, - { - "courseSlug": "files", - "question": "photo.jpg 파일의 이름을 photo.txt로 바꾸면 어떤 일이 벌어지나요?", - "option1": "내용물은 그대로이고, Windows가 여는 프로그램만 메모장으로 바뀐다", - "option2": "파일 맨 앞의 매직 넘버가 텍스트용 바이트로 다시 쓰인다", - "option3": "JPG 데이터가 손상되어 다시 .jpg로 바꿔도 열리지 않는다", - "option4": "확장자와 내용을 맞추기 위해 사진이 자동으로 텍스트로 변환된다", - "answerIndex": 0, - "explanation": "섹션 2의 핵심: 확장자는 내용물이 아니라 이름표라서, 이름을 바꿔도 바이트는 1도 안 변해요. 단지 Windows가 어떤 프로그램으로 열지 판단하는 기준만 달라질 뿐이라 다시 .jpg로 되돌리면 멀쩡히 열립니다." - }, - { - "courseSlug": "files", - "question": "현재 위치가 frontend/src/pages/ 폴더일 때, 코스 내용에 따르면 ../App.jsx 라는 상대 경로가 가리키는 것은?", - "option1": "pages 폴더 안에 있는 App.jsx", - "option2": "두 층 위로 올라간 frontend 폴더의 App.jsx", - "option3": "한 층 올라간 부모 폴더(src)에 있는 App.jsx", - "option4": "뿌리(루트)부터 시작하는 절대 경로라서 위치와 무관하다", - "answerIndex": 2, - "explanation": "섹션 3에서 ..은 부모 폴더, .은 현재 폴더를 뜻한다고 배웠어요. pages/에서 ../App.jsx는 한 층 올라간 src 폴더의 App.jsx를 가리키며, 상대 경로는 '지금 서 있는 곳' 기준이라는 게 포인트입니다." - }, - { - "courseSlug": "files", - "question": "휴지통을 비우거나 Shift+Delete로 지운 파일을 복구 프로그램이 되살릴 수 있는 이유로, 코스의 설명과 맞는 것은?", - "option1": "Windows가 모든 파일을 자동으로 클라우드에 백업해 두기 때문에", - "option2": "휴지통 비우기는 파일을 C:\\$Recycle.Bin 폴더로 옮기기만 하기 때문에", - "option3": "삭제된 파일이 압축된 형태로 디스크 한쪽에 따로 보관되기 때문에", - "option4": "장부에서 '자리 비었음'만 표시할 뿐, 데이터는 덮어쓰기 전까지 그대로 남기 때문에", - "answerIndex": 3, - "explanation": "섹션 5에서 휴지통 비우기는 사서의 장부에서 지우개질만 하는 것이고, 책(데이터)은 새 파일이 덮어쓰기 전까지 서가에 그대로 남는다고 했어요. 복구 프로그램은 이 '장부엔 없지만 서가엔 남은 책'을 찾아냅니다. (휴지통으로 이사하는 건 Delete 단계의 일이에요.)" - }, - { - "courseSlug": "files", - "question": "수습사원 A가 회의록.txt를 zip으로 압축하니 크기가 크게 줄었는데, 사진.jpg는 압축해도 거의 안 줄었습니다. 코스 내용으로 이 차이를 가장 잘 설명한 것은?", - "option1": "텍스트는 반복 패턴이 많아 잘 줄지만, JPG는 이미 자체 압축이 되어 더 짜낼 반복이 없어서다", - "option2": "zip은 글자 데이터 전용 형식이라 사진 파일은 원래 압축 대상이 아니어서다", - "option3": "사진 파일은 시스템 파일로 분류되어 Windows가 압축을 막아 두기 때문이다", - "option4": "텍스트는 손실 압축을 허용하지만 사진은 무손실만 가능해서 압축률이 낮은 것이다", - "answerIndex": 0, - "explanation": "섹션 6에서 압축은 '반복되는 패턴을 짧은 표현으로 바꿔치기'하는 것이라 반복 많은 글은 팍팍 줄고, 사진·영상은 이미 자기 방식으로 압축돼 있어 거의 안 줄어든다고 배웠어요. '직접 확인해 보기' 실습이 바로 이 실험입니다." - }, - { - "courseSlug": "firewall", - "question": "코스에서 방화벽을 '건물 정문의 문지기'에 비유한 이유로 가장 알맞은 것은?", - "option1": "서버에 몰리는 트래픽 속도를 조절해 여러 서버로 분산해 주기 때문에", - "option2": "드나드는 데이터에게 출처와 목적지 포트를 물어 규칙표와 대조한 뒤 통과시키거나 차단하기 때문에", - "option3": "서버에 저장된 데이터를 암호화해서 도둑이 훔쳐도 읽지 못하게 만들기 때문에", - "option4": "바이러스에 감염된 파일을 찾아내 자동으로 치료까지 해 주기 때문에", - "answerIndex": 1, - "explanation": "섹션 1에서 방화벽은 드나드는 모든 데이터에게 \"어디서 왔죠? 몇 호(포트)에 가시죠?\"를 묻고 규칙표와 대조해 통과·차단하는 문지기로 설명됩니다. 규칙표에 없으면 기본은 차단이에요." - }, - { - "courseSlug": "firewall", - "question": "코스 설명에 따르면 '인바운드(Inbound)' 검사에 대한 설명으로 옳은 것은?", - "option1": "서버에서 밖으로 나가는 연결을 검사하며, 악성코드의 정보 유출을 잡는 마지막 그물이다", - "option2": "같은 서버 안의 프로그램끼리 주고받는 내부 연결을 검사하는 것이다", - "option3": "관리자가 SSH로 접속할 때만 예외적으로 동작하는 특별 검사다", - "option4": "밖에서 서버로 들어오는 연결을 검사하며, 공격 대부분이 이 방향이라 규칙이 빡빡하다", - "answerIndex": 3, - "explanation": "섹션 1에서 인바운드는 밖에서 안으로 '들어오는 손님' 검사이고, 공격이 대부분 이 방향으로 오기 때문에 규칙을 빡빡하게 둔다고 설명합니다. 밖으로 나가는 연결을 검사하는 쪽은 아웃바운드예요." - }, - { - "courseSlug": "firewall", - "question": "포트가 진짜로 '열려 있다'고 말하려면 동시에 성립해야 하는 두 가지 조건은?", - "option1": "그 포트에서 귀 기울이는 프로그램이 실행 중이고, 방화벽이 그 포트로의 접속을 허용해야 한다", - "option2": "그 포트 번호가 1024보다 작아야 하고, HTTPS 인증서가 미리 발급되어 있어야 한다", - "option3": "방화벽에 허용 규칙이 있어야 하고, 최근에 그 포트로 접속한 기록이 남아 있어야 한다", - "option4": "관리자가 서버에 로그인해 있어야 하고, 아웃바운드 규칙도 함께 열려 있어야 한다", - "answerIndex": 0, - "explanation": "섹션 2의 핵심입니다. 방 안에 손님을 맞을 프로그램이 실제로 실행 중이고(방에 사람이 있고), 방화벽이 그 포트로 가는 길을 허용해야(문지기가 통과시켜야) 해요. 둘 중 하나라도 아니면 밖에서는 연결할 수 없습니다." - }, - { - "courseSlug": "firewall", - "question": "AWESOMEDEV 서버(AWS EC2)의 보안그룹에서 전 세계(0.0.0.0/0)가 아니라 지정한 IP에서만 접속을 허용하는 포트는?", - "option1": "443 — 웹 서비스를 제공하는 HTTPS 창구", - "option2": "3000 — Gitea 웹과 clone에 쓰이는 문", - "option3": "22 — 서버를 원격 조종하는 SSH 관리자 전용 뒷문", - "option4": "80 — Caddy가 443으로 안내해 주는 HTTP 입구", - "answerIndex": 2, - "explanation": "섹션 4의 실제 규칙표에서 80·443·3000은 전 세계에 공개하지만, SSH(22)는 뚫리면 서버 전체를 내주는 관리자 전용 뒷문이라 지정한 IP에서 온 접속만 통과시키도록 잠가 두었습니다." - }, - { - "courseSlug": "firewall", - "question": "수습 개발자가 기능 테스트를 위해 서버의 PostgreSQL 포트(5432)를 전 세계에 잠깐 열어 두고 그대로 퇴근했다. 코스 내용에 비추어 가장 올바른 판단은?", - "option1": "DB는 계정과 비밀번호로 보호되므로 다음 출근 때 닫아도 위험은 거의 없다", - "option2": "포트 스캔 봇이 열린 문을 금방 찾아내므로, 최소 권한 원칙에 따라 즉시 다시 닫아야 한다", - "option3": "아웃바운드 규칙만 잠겨 있으면 데이터가 밖으로 나갈 수 없으니 그대로 둬도 된다", - "option4": "5432는 원래 전 세계 공개가 기본값이라 보안그룹 방침과 어긋나는 일이 아니다", - "answerIndex": 1, - "explanation": "섹션 5의 경고 그대로예요 — 잠깐 연 문은 다시 닫는 것까지가 한 세트입니다. 공격 봇은 모든 서버의 포트를 자동으로 두드리고 있고(섹션 6의 포트 스캔), 우리 방침에서 5432는 외부에 존재 자체를 숨기는 닫힌 문이어야 합니다." - }, - { - "courseSlug": "git-deep", - "question": "이 코스에서 설명하는 '커밋'의 본질로 가장 알맞은 것은?", - "option1": "마지막 커밋 이후 바뀐 부분(변경분)만 골라 저장한 기록", - "option2": "브랜치 하나를 통째로 압축해 보관해 두는 백업 파일", - "option3": "그 순간 프로젝트 전체를 통째로 찍은 스냅샷(사진) 한 장", - "option4": "원격 저장소에 올리기 위해 임시로 묶어 둔 파일 묶음", - "answerIndex": 2, - "explanation": "섹션 1 '커밋 = 스냅샷'에서 커밋은 변경분 기록이 아니라 프로젝트 전체를 찍은 사진 한 장이라고 설명해요. 안 바뀐 파일은 이전 사진 것을 재활용해서 빠르고 용량도 작습니다." - }, - { - "courseSlug": "git-deep", - "question": "단체 사진 비유에서 \"사진 찍을 사람 이리 모여!\" 하고 줄을 세운 상태에 해당하는 Git 단계는?", - "option1": "스테이지 — git add로 커밋에 담을 파일을 골라 둔 상태", - "option2": "작업 폴더 — 교실에서 각자 코드를 고치고 있는 상태", - "option3": "저장소 — 커밋되어 사진첩에 정식 보관된 상태", - "option4": "원격 저장소 — push가 끝나 서버에 올라간 상태", - "answerIndex": 0, - "explanation": "섹션 1의 3단계 비유에서 작업 폴더는 교실에서 노는 상태, 스테이지는 줄 세운 상태, 커밋은 사진첩 보관이에요. git add를 따로 두는 이유는 고친 것 중 일부만 골라 한 커밋에 담기 위해서입니다." - }, - { - "courseSlug": "git-deep", - "question": "머지 중 충돌이 난 파일에서 <<<<<<< HEAD 부터 ======= 사이에 적혀 있는 내용은 무엇인가?", - "option1": "합치려고 하는 상대 브랜치에서 고친 내용", - "option2": "내 우주, 즉 현재 브랜치에서 고친 내용", - "option3": "Git이 두 내용을 자동으로 합쳐 제안한 절충안 코드", - "option4": "두 브랜치가 갈라지기 전의 원래 공통 코드", - "answerIndex": 1, - "explanation": "섹션 4에서 <<<<<<< HEAD부터 =======까지는 내 우주(현재 브랜치)의 내용, =======부터 >>>>>>>까지는 상대 우주(합치려는 브랜치)의 내용이라고 읽는 법을 알려 줘요." - }, - { - "courseSlug": "git-deep", - "question": "이미 push해서 팀과 공유한 브랜치에서 \"그 기능 롤백해 주세요\" 요청을 받았다. 코스가 권하는 가장 안전한 방법은?", - "option1": "git reset --hard로 그 커밋 이전 시점으로 역사를 되돌린다", - "option2": "git checkout으로 과거 커밋 시점에 다녀오며 상태만 구경한다", - "option3": "git stash로 해당 기능의 코드를 서랍에 임시 보관해 둔다", - "option4": "git revert로 그 커밋의 정반대 내용을 새 커밋으로 추가한다", - "answerIndex": 3, - "explanation": "섹션 7 '되돌리기 3형제'에서 revert는 역사를 지우지 않고 반대 내용을 새 커밋으로 추가하므로 공유한 브랜치에서도 안전하다고 해요. 실무의 롤백 요청은 대부분 revert이고, 공유 후 reset은 대참사입니다." - }, - { - "courseSlug": "git-deep", - "question": "기능 브랜치에서 코드를 고치던 중(커밋하기엔 애매한 미완성 상태) 멘토가 \"급한 버그부터 고쳐줘!\"라고 요청했다. 코스에서 배운 도구로 가장 알맞은 대처는?", - "option1": "git reset --hard로 미완성 수정을 전부 지우고 버그 수정부터 시작한다", - "option2": "git stash로 수정 중인 내용을 서랍에 넣어 두고, 버그를 고친 뒤 git stash pop으로 꺼낸다", - "option3": "미완성 코드라도 일단 main 브랜치에 바로 커밋해 두고 버그를 고친다", - "option4": "git revert로 마지막 커밋을 되돌려 놓고 버그 수정 작업을 이어 간다", - "answerIndex": 1, - "explanation": "섹션 6에서 stash는 커밋하기 애매한 미완성 코드를 '서랍에 임시 보관'했다가 pop으로 도로 꺼내는 도구이며, 작업 중에 급한 버그 요청이 왔을 때가 바로 쓰는 순간이라고 설명해요." - }, - { - "courseSlug": "gpu", - "question": "코스에서 GPU를 '만 명의 일꾼'에 비유한 이유로 가장 알맞은 것은?", - "option1": "코어 하나하나가 CPU 코어보다 복잡한 판단을 더 잘하기 때문", - "option2": "프로그램의 전체 흐름과 순서를 GPU가 직접 결정하기 때문", - "option3": "단순한 계산을 수천~수만 개의 코어가 동시에 처리하기 때문", - "option4": "코어 수는 적지만 코어 한 개의 성능이 매우 강력하기 때문", - "answerIndex": 2, - "explanation": "섹션 1의 핵심 비유입니다. GPU는 단순한 덧셈·곱셈만 하는 코어 수천~수만 개가 '동시에' 달려들어, 같은 계산이 대량으로 필요한 일에서 천재 한 명(CPU)을 압도합니다." - }, - { - "courseSlug": "gpu", - "question": "코스에서 VRAM을 무엇에 비유했고, VRAM이 부족하면 어떤 일이 생긴다고 설명했나요?", - "option1": "창고 — 부족하면 GPU가 데이터를 압축해서 쌓아 두게 된다", - "option2": "작업 책상 — 부족하면 창고(일반 RAM)를 왔다 갔다 하느라 느려진다", - "option3": "냉각 팬 — 부족하면 GPU가 뜨거워져 스스로 속도를 낮춘다", - "option4": "두뇌 — 부족하면 복잡한 판단을 전부 CPU에 넘기게 된다", - "answerIndex": 1, - "explanation": "섹션 4에서 VRAM은 GPU 전용 '작업 책상'으로 비유됩니다. 책상이 좁으면 재료를 창고(일반 RAM)에서 자꾸 가져와야 해서 일꾼 만 명이 놀게 되고, 연산 속도가 충분해도 느려집니다." - }, - { - "courseSlug": "gpu", - "question": "\"모니터가 144Hz면 화면이 무조건 부드럽다\"는 말에 대해 코스가 설명한 올바른 내용은?", - "option1": "GPU가 1초에 60장밖에 못 그리면 모니터가 갈아 끼울 그림이 없으므로, 주사율과 GPU 성능은 세트로 맞아야 한다", - "option2": "주사율은 화면의 색 표현 개수를 정하는 값이라서 화면의 부드러움과는 아무 관계가 없다", - "option3": "144Hz 모니터는 해상도를 자동으로 낮춰 주기 때문에 어떤 GPU를 써도 항상 부드럽다", - "option4": "부드러움은 오직 VRAM 용량이 결정하므로 모니터 주사율은 신경 쓰지 않아도 된다", - "answerIndex": 0, - "explanation": "섹션 2의 '흔한 오해'입니다. 주사율은 모니터가 1초에 그림을 갈아 끼우는 횟수일 뿐, GPU가 프레임을 제때 만들어 주지 못하면 갈아 끼울 그림이 없어 부드러워지지 않습니다." - }, - { - "courseSlug": "gpu", - "question": "게임용으로 태어난 GPU가 AI 학습의 표준 장비가 된 이유로 코스가 설명한 것은?", - "option1": "AI 전용 명령어가 게임 그래픽 명령어보다 훨씬 단순하게 만들어졌기 때문", - "option2": "GPU가 CPU보다 전력을 적게 써서 데이터센터 운영 비용이 줄어들기 때문", - "option3": "GPU에 달린 VRAM이 일반 RAM보다 용량이 언제나 더 크기 때문", - "option4": "AI는 사실상 거대한 행렬 곱셈 덩어리라, 픽셀 수백만 개를 동시에 계산하는 게임 작업과 하는 일이 사실상 같기 때문", - "answerIndex": 3, - "explanation": "섹션 5의 핵심입니다. AI 학습·실행은 '단순한 곱셈·덧셈을 수십억 번' 반복하는 일이고, 이는 픽셀을 동시에 계산하던 GPU(만 명의 일꾼)가 세상에서 제일 잘하는 일과 같아서 GPU가 AI의 표준 장비가 됐습니다." - }, - { - "courseSlug": "gpu", - "question": "실습 중 작업 관리자 [성능] 탭을 옆에 띄워 두고 유튜브에서 4K 영상을 재생했더니 Video Decode 그래프가 쑥 올라갔다. 코스에 따르면 이 현상이 의미하는 것은?", - "option1": "브라우저가 CPU 대신 3D 게임 엔진을 실행하기 시작했다는 뜻이다", - "option2": "VRAM이 부족해서 일반 RAM에서 재료를 빌려 오고 있다는 뜻이다", - "option3": "영상 압축을 푸는 일을 GPU의 전용 회로가 담당하고 있다는 뜻이다", - "option4": "외장 GPU가 고장 나서 내장 GPU가 대신 일을 떠맡았다는 뜻이다", - "answerIndex": 2, - "explanation": "섹션 7의 실습 ②입니다. 4K 영상을 재생하면 영상 압축을 푸는 일(디코딩)을 GPU 전용 회로가 담당해 Video Decode 그래프가 올라가며, GPU가 게임할 때만 쓰이는 게 아님을 숫자로 확인하는 실습입니다." - }, - { - "courseSlug": "hardware", - "question": "컴퓨터가 하는 5가지 일(입력·기억·계산·저장·출력) 중에서 '계산'을 담당하는 부품은 무엇인가?", - "option1": "RAM(메모리)", - "option2": "SSD/HDD(저장장치)", - "option3": "CPU", - "option4": "메인보드", - "answerIndex": 2, - "explanation": "코스 1섹션의 다이어그램에서 CPU는 '두뇌'로서 계산(생각하기)을 담당한다. RAM은 기억(펼쳐 놓기), SSD/HDD는 저장, 메인보드는 부품들이 꽂히는 판이다." - }, - { - "courseSlug": "hardware", - "question": "코스에서 설명한 RAM(메모리)의 중요한 특징으로 올바른 것은?", - "option1": "전원을 꺼도 저장한 자료가 그대로 남는다", - "option2": "전원을 끄면 내용이 싹 비워진다", - "option3": "윈도우와 프로그램 파일이 설치되는 보관 창고다", - "option4": "책장처럼 넓지만 자료를 꺼내는 속도가 가장 느리다", - "answerIndex": 1, - "explanation": "코스 3섹션에서 RAM은 '지금 하는 일을 펼쳐 놓는 작업 책상'이라 전원을 끄면 싹 비워진다고 설명한다. 전원을 꺼도 남는 보관은 저장장치(SSD/HDD)의 역할이다." - }, - { - "courseSlug": "hardware", - "question": "CPU 모델명 '인텔 코어 i5-14400F'에서 맨 뒤의 'F'가 뜻하는 것은 무엇인가?", - "option1": "오버클럭이 가능한 고급형 모델이라는 표시", - "option2": "노트북용 저전력 모델이라는 표시", - "option3": "같은 세대 안에서 가장 최신 모델이라는 표시", - "option4": "내장그래픽이 없어 외장 그래픽카드가 꼭 필요하다는 표시", - "answerIndex": 3, - "explanation": "코스 2섹션의 CPU 스펙표 읽기에서 'F'는 내장그래픽 없음을 뜻하며, 이 경우 외장 그래픽카드가 필수라고 설명한다. i5는 등급, 14는 세대, 400은 세부 모델이다." - }, - { - "courseSlug": "hardware", - "question": "코스에서 PC 견적을 짤 때 '세 개가 한 세트로 맞아야 한다'고 강조한 조합은 무엇인가?", - "option1": "CPU ↔ 메인보드 소켓 ↔ RAM 세대(DDR4/DDR5)", - "option2": "CPU ↔ 파워 정격 출력 ↔ 케이스 색상", - "option3": "그래픽카드 ↔ 모니터 크기 ↔ SSD 용량", - "option4": "RAM 용량 ↔ HDD 회전 속도 ↔ 케이스 팬 개수", - "answerIndex": 0, - "explanation": "5섹션에서 소켓이 맞아야 CPU가 꽂히고 RAM 규격(DDR4/DDR5)도 메인보드가 정하므로, 'CPU ↔ 보드 소켓 ↔ RAM 세대' 세 가지가 한 세트로 맞아야 한다고 설명한다." - }, - { - "courseSlug": "hardware", - "question": "크롬 탭 30개에 게임과 디스코드까지 켰더니 컴퓨터가 뚝뚝 끊긴다. 코스의 주방·책상 비유로 이 현상의 원인을 가장 잘 설명한 것은?", - "option1": "요리사(CPU)의 손 빠르기(GHz)가 절반으로 줄어들었기 때문", - "option2": "책상(RAM)이 꽉 차서 자료를 책장(저장장치)으로 치웠다 꺼내는 스왑이 일어나기 때문", - "option3": "냉장고(저장장치)가 가득 차서 새 파일을 저장할 공간이 없기 때문", - "option4": "배전반(파워)의 전력이 모자라 부품 일부가 꺼졌다 켜지기 때문", - "answerIndex": 1, - "explanation": "3섹션의 '스왑' 설명에 나오는 상황이다. RAM(책상)이 꽉 차면 덜 쓰는 자료를 SSD/HDD(책장)로 잠시 치워 두는데, 책상에서 바로 집는 것보다 수백~수천 배 느려서 끊김이 생긴다." - }, - { - "courseSlug": "history", - "question": "1946년에 등장한 에니악(ENIAC)에서 실행할 프로그램을 바꾸려면 어떻게 해야 했나요?", - "option1": "천공카드를 새로 뚫어서 기계에 읽혔다", - "option2": "메모리에 저장된 파일을 교체해 실행했다", - "option3": "케이블 배선을 손으로 다시 꽂았다", - "option4": "진공관을 다른 종류로 갈아 끼웠다", - "answerIndex": 2, - "explanation": "코스 1섹션에 나오듯 에니악은 \"프로그램을 바꾼다 = 배선을 다시 꽂는다\"였던 시대의 기계입니다. 파일을 바꿔 실행하는 방식은 이후 폰 노이만 구조가 등장하면서 가능해졌어요." - }, - { - "courseSlug": "history", - "question": "코스에서 소개한 무어의 법칙(1965, 고든 무어의 관찰)의 내용으로 옳은 것은?", - "option1": "칩의 가격은 2년마다 절반으로 떨어진다", - "option2": "칩 하나에 들어가는 트랜지스터 수는 약 2년마다 2배가 된다", - "option3": "컴퓨터의 전력 소모는 매년 2배씩 늘어난다", - "option4": "소프트웨어의 크기는 하드웨어 성능만큼 빠르게 커진다", - "answerIndex": 1, - "explanation": "무어의 법칙은 트랜지스터 수가 약 2년마다 2배가 된다는 관찰로, 코스에서는 \"2배 × 25번\"이 50년간 수백만 배라는 지수적 성장을 만들었다고 설명합니다." - }, - { - "courseSlug": "history", - "question": "코스에서 '헷갈리기 쉽다'며 따로 구분한 인터넷과 웹의 관계로 옳은 것은?", - "option1": "인터넷은 전 세계 컴퓨터를 잇는 도로망이고, 웹은 그 도로 위에서 HTTP로 문서와 앱을 주고받는 서비스다", - "option2": "웹이 물리적 연결망이고, 인터넷은 그 연결망 위에서 돌아가는 여러 서비스 중 하나다", - "option3": "인터넷과 웹은 같은 것을 시대에 따라 다르게 부르는 두 가지 이름일 뿐이다", - "option4": "인터넷은 한 나라 안의 연결망을, 웹은 나라와 나라를 잇는 국제 연결망을 가리킨다", - "answerIndex": 0, - "explanation": "섹션 4의 핵심 구분입니다. 인터넷은 도로망(물리적 연결), 웹은 그 도로 위를 달리는 서비스 중 하나(HTTP로 문서·앱 주고받기)라고 코스가 설명해요." - }, - { - "courseSlug": "history", - "question": "클라우드 섹션에서 소개한 우리 회사(AWESOMEDEV)의 실제 서버 구성 중, Caddy가 맡고 있는 역할은?", - "option1": "앱을 컨테이너(규격 화물)로 포장해 실행한다", - "option2": "백엔드 API의 비즈니스 로직을 처리한다", - "option3": "서비스의 데이터를 저장하고 관리한다", - "option4": "HTTPS 현관 안내데스크 역할을 한다", - "answerIndex": 3, - "explanation": "코스의 실제 구성도에서 Docker는 컨테이너 포장, Spring Boot는 백엔드, PostgreSQL은 데이터베이스, Caddy는 'HTTPS 현관 안내데스크'로 소개됩니다." - }, - { - "courseSlug": "history", - "question": "수습사원 A가 학습 플랫폼 페이지에서 F12를 눌러 기기 툴바를 켜고 화면 폭을 천천히 줄였더니, 어느 지점에서 레이아웃이 '뚝' 하고 다시 배치되었습니다. 코스에 따르면 이 지점의 이름과, 이런 설계가 기본기가 된 시대는?", - "option1": "렌더링 포인트 — 클라우드 시대", - "option2": "분기점(breakpoint) — 스마트폰 시대", - "option3": "체크포인트 — PC 혁명 시대", - "option4": "접속점(access point) — 인터넷·웹 시대", - "answerIndex": 1, - "explanation": "섹션 5의 '직접 확인해 보기' 실습 그대로입니다. 배치가 바뀌는 지점이 반응형의 분기점(breakpoint)이며, \"사용자가 어떤 화면으로 볼지 모른다\"는 전제를 만든 스마트폰 시대가 반응형 디자인을 기본기로 남겼어요." - }, - { - "courseSlug": "home-network", - "question": "공유기 관리자 페이지에 들어가려면 먼저 공유기의 주소를 알아내야 한다. 코스에서 안내한 방법은 무엇인가?", - "option1": "브라우저 주소창에 8.8.8.8을 입력해서 관리 화면을 연다", - "option2": "속도 측정 사이트에서 우리 집 공인 IP를 확인해 입력한다", - "option3": "터미널에서 ipconfig를 실행해 기본 게이트웨이 주소를 확인한다", - "option4": "공유기 밑면 스티커에 적힌 SSID를 주소창에 입력한다", - "answerIndex": 2, - "explanation": "섹션 1에서 배운 3단계의 첫걸음이다. ipconfig로 나오는 기본 게이트웨이(예: 192.168.0.1)가 곧 공유기의 주소이고, 그 주소를 브라우저에 입력하면 관리자 페이지에 들어갈 수 있다." - }, - { - "courseSlug": "home-network", - "question": "코스에서 2.4GHz 대역을 저음(우퍼) 스피커에 비유했다. 2.4GHz의 특징으로 맞는 것은?", - "option1": "속도는 느리지만 멀리 도달하고 벽을 잘 통과한다", - "option2": "속도가 빠르고 벽도 잘 통과하지만 도달 거리가 짧다", - "option3": "도달 거리는 짧지만 전자레인지 간섭이 거의 없다", - "option4": "채널이 수십 개라 이웃 간 간섭 걱정이 적은 대역이다", - "answerIndex": 0, - "explanation": "저음이 벽 너머까지 웅웅 울려 퍼지듯, 낮은 주파수인 2.4GHz는 멀리 가고 벽을 잘 뚫는 대신 속도가 느리고 전자레인지·블루투스 등 간섭이 많다. 빠르지만 여린 쪽은 5GHz다." - }, - { - "courseSlug": "home-network", - "question": "2.4GHz 대역에는 채널 1~13번이 있지만 서로 겹치는 구조다. 실제로 겹치지 않게 쓸 수 있는 채널 조합은?", - "option1": "2번 · 7번 · 12번", - "option2": "1번 · 6번 · 11번", - "option3": "1번 · 5번 · 9번", - "option4": "3번 · 6번 · 9번", - "answerIndex": 1, - "explanation": "섹션 3의 핵심이다. 2.4GHz 채널은 이웃 번호끼리 겹치는 구조라 안 겹치는 조합은 1·6·11 세 개뿐 — 왕복 6차선 도로에서 실제로 안 부딪히는 차선이 3개뿐인 상황에 비유했다." - }, - { - "courseSlug": "home-network", - "question": "외부에서 내 기기로 접속을 허용해야 할 때, 코스가 권장한 순서를 안전한 것부터 바르게 나열한 것은?", - "option1": "DMZ → UPnP → 필요한 포트만 포트포워딩", - "option2": "UPnP → 필요한 포트만 포트포워딩 → DMZ", - "option3": "DMZ → 필요한 포트만 포트포워딩 → UPnP", - "option4": "필요한 포트만 포트포워딩 → UPnP → DMZ", - "answerIndex": 3, - "explanation": "좁게 열수록 안전하다는 원칙이다. 필요한 포트만 여는 포트포워딩이 1순위, 자동문인 UPnP가 2순위, 모든 문을 열어 버리는 DMZ는 최후 수단(사실상 쓰지 말 것)이다. 서버 보안의 최소 권한 원칙과 같다." - }, - { - "courseSlug": "home-network", - "question": "내 PC(192.168.0.5)에서 Spring Boot 서버를 8080 포트로 켰다. 친구가 우리 집 공인 IP의 8080 포트로 접속했지만 공유기에서 막혀 실패했다. 코스의 원칙에 맞는 가장 안전한 해결 방법은?", - "option1": "공유기 DMZ에 내 PC를 지정해 모든 외부 요청을 받게 한다", - "option2": "외부 8080 포트를 192.168.0.5의 8080으로 보내는 포트포워딩 규칙을 등록한다", - "option3": "PC를 5GHz SSID로 옮겨 접속해 무선 속도를 끌어올린다", - "option4": "무선 채널을 1·6·11 중 이웃이 덜 쓰는 번호로 수동 변경한다", - "answerIndex": 1, - "explanation": "경비실(NAT) 비유처럼 공유기는 밖에서 먼저 온 손님을 몇 호로 보낼지 몰라 돌려보내므로, 필요한 포트 하나만 정확히 여는 포트포워딩 규칙을 등록해야 한다. DMZ도 되긴 하지만 문을 전부 여는 위험한 방법이다." - }, - { - "courseSlug": "htmlcss", - "question": "이 코스에서는 웹페이지의 세 가지 재료를 사람에 비유합니다. CSS는 무엇에 해당하나요?", - "option1": "골격 — 몸의 구조를 잡아 준다", - "option2": "옷과 화장 — 겉모습을 꾸며 준다", - "option3": "근육 — 몸을 움직이게 한다", - "option4": "신경 — 자극에 반응하게 한다", - "answerIndex": 1, - "explanation": "섹션 1 '웹페이지의 정체'에서 HTML은 골격, CSS는 옷과 화장, JS는 근육에 비유합니다. 같은 뼈대(HTML)에 옷(CSS)만 갈아입혀도 완전히 다른 사이트처럼 보인다고 설명해요." - }, - { - "courseSlug": "htmlcss", - "question": "박스 모델의 택배 상자 비유에서 '뽁뽁이'(물건과 상자 벽 사이의 완충재)에 해당하는 CSS 속성은 무엇인가요?", - "option1": "margin — 옆 상자와의 간격", - "option2": "border — 상자의 벽", - "option3": "padding — 벽과 내용 사이의 안쪽 여백", - "option4": "box-shadow — 상자가 떠 있는 느낌", - "answerIndex": 2, - "explanation": "섹션 4 박스 모델에서 content는 물건, padding은 뽁뽁이, border는 상자 벽, margin은 옆 상자와의 간격에 비유합니다. 뽁뽁이는 상자 '안'에 있으므로 안쪽 여백인 padding이에요." - }, - { - "courseSlug": "htmlcss", - "question": "우리 플랫폼은 컴포넌트 코드를 한 줄도 바꾸지 않고 다크 모드를 지원합니다. 코스에서 설명한 그 원리는 무엇인가요?", - "option1": "모든 색이 :root 변수에 모여 있어, @media 블록이 변수 값만 갈아끼우기 때문", - "option2": "다크 모드 전용 CSS 파일을 페이지마다 하나씩 따로 만들어 두었기 때문", - "option3": "JS가 화면의 모든 색 속성을 찾아서 어두운 색으로 바꿔 주기 때문", - "option4": "브라우저가 페이지의 모든 색을 자동으로 반전시켜 주기 때문", - "answerIndex": 0, - "explanation": "섹션 5에서 global.css의 :root에 색 '이름표'(--bg, --ink, --primary)를 붙여 두고, @media (prefers-color-scheme: dark)에서 이름표의 값만 바꿔치기하면 사이트 전체가 옷을 갈아입는다고 설명합니다." - }, - { - "courseSlug": "htmlcss", - "question": "겉보기엔
도 클릭이 됩니다. 그런데 코스에서 \"누르는 것은 반드시 ; // = goBack / pop +} + +// 대응표 — 웹에서 배운 게 앱에서 그대로 쓰인다: +// / navigate() ↔ navigation.navigate('화면이름') +// useParams() ↔ route.params +// navigate(-1) ↔ navigation.goBack() / Navigator.pop() +// 브라우저 히스토리 스택 ↔ 네비게이션 스택(카드 더미)`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑아 props만 바꿔 재사용한다. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +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 ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 앱 개발
+

앱 화면과 이동
— 웹 라우팅으로 앱 내비게이션 읽기

+

+ 앱은 화면 여러 장을 오가는 그림책이에요. "지금 어느 화면을 보여줄지"를 정하고 + 화면끼리 이동시키는 일이 내비게이션입니다. 여러분이 이미 배운{' '} + 웹 라우팅(URL·react-router)을 다리 삼아, 앱의 스택·탭·드로어와 + 딥링크까지 — 지형을 읽는 눈을 만들어 봅니다. +

+
+ 예상 소요 70분 + 실습: 우리 App.jsx 라우팅 뜯어보기 + 선수 지식: React 입문(라우팅) +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 웹 페이지는 스크롤 한 번이면 위아래로 쭉 이어지지만, 앱은 화면(screen) 여러 + 장으로 나뉘어요. 홈에서 코스 목록으로, 목록에서 상세로, 상세에서 영상으로 — + 이렇게 "지금 어느 화면을 앞에 세울지" 정하고 화면끼리 오가게 하는 일 전부를{' '} + 내비게이션이라고 부릅니다. +

+ {CODE_WHY_NAV} +

+ 왜 굳이 화면을 쪼갤까요? 모바일은 화면이 좁고, 한 번에 한 가지 일에 + 집중하는 사용 패턴이라서예요. 그래서 "한 화면 = 하나의 목적"으로 잘게 나누고, 이동으로 + 연결합니다. 이 감각은 사실 우리 웹에서도 익숙해요 — 페이지 하나가 컴포넌트 하나였죠. + 앱은 그 페이지 전환을 더 또렷하게, 애니메이션까지 곁들여 하는 것뿐이에요. +

+
+ 한 줄 정리 "화면 = 상태의 함수"를 배웠다면, 이번엔 그 위층 — + "어느 화면을 지금 보여줄까"를 정하는 이야기예요. + 앞 코스가 카드 한 장을 그리는 법이었다면, 이번은 카드 여러 장을 + 꺼내고 넣는 법입니다. +
+
+ +
+

+ 웹에서 화면을 결정하는 건 주소(URL)였어요.{' '} + /learn/react-intro로 주소가 바뀌면 그 화면이 뜨고, + 뒤로가기를 누르면 브라우저의 방문 기록을 되짚어 갑니다. 그런데 앱을 켜 보면{' '} + 주소창 자체가 없죠. 그럼 앱은 "지금 화면"을 무엇으로 기억할까요? +

+

+ 답은 스택(stack)입니다. 화면에 들어갈 때마다 카드를 위로 + 쌓고(push), 뒤로가기를 하면 맨 위 카드를 걷어냅니다(pop). + 지금 보이는 화면은 언제나 "쌓인 카드 더미의 맨 위 한 장"이에요. 사실 웹의 방문 + 기록도 내부적으론 똑같은 스택 구조라, 둘은 한 뿌리예요 — 차이는 + 그 스택이 주소창으로 눈에 보이느냐일 뿐입니다. +

+ {CODE_WEB_VS_APP} + + {/* 왼쪽: 웹 — 주소창 */} + 웹 — 주소창(URL)이 화면 + + 🔒 /learn/react-intro + + react-intro 화면 + URL 바꾸면 그 화면으로 + 뒤로가기 = 히스토리 되짚기 + + {/* 오른쪽: 앱 — 스택 */} + 앱 — 스택(카드 더미)이 화면 + + 홈 (맨 아래 카드) + + 코스 목록 + + 코스 상세 ← 지금 보임 + {/* push 화살표 */} + + + push + {/* pop 화살표 */} + + + pop + push=카드 올리기 · pop=걷어내기(뒤로) + 맨 위 카드가 곧 지금 화면 + +

그림: 웹은 URL이, 앱은 카드 더미(스택)의 맨 위가 '지금 화면' — push로 쌓고 pop(뒤로가기)으로 걷어낸다

+
+ 연결고리 react-router에서 navigate('/courses/42')로 + 화면을 바꾸고 navigate(-1)로 뒤로 갔던 그 경험이, + 앱에선 push/pop이라는 + 이름으로 똑같이 반복돼요. 새 개념이 아니라 이름만 바뀐 옛 친구입니다. +
+
+ +
+

+ 앱 화면 구조는 복잡해 보여도 사실 세 가지 패턴의 조합이에요. + 이 셋의 역할을 구분할 줄 알면, 처음 보는 앱도 "아, 여긴 탭 안의 스택이구나" 하고 + 지도를 그릴 수 있게 됩니다. +

+ {CODE_PATTERNS} +

+ 핵심은 역할이 다르다는 거예요. 스택은 "깊이 들어갔다 + 나오는" 상하 이동(목록→상세→결제), 은 서로 대등한 큰 구역 사이의 + 좌우 이동(홈↔검색↔프로필), 드로어는 가끔 쓰는 메뉴를 옆에 숨겨 두는 + 서랍이에요. 실무 앱은 보통 바깥은 탭, 각 탭 안은 각자의 스택이라, + "검색 탭에서 상세로 들어갔다가 홈 탭으로 갔다 돌아오면 아까 그 상세가 그대로" + 유지되죠. 각 탭이 자기 카드 더미를 따로 기억하기 때문입니다. +

+
+ 우리 웹으로 번역하면 상단 네비게이션 바에서 "학습·도구·내 정보"를 오가는 건{' '} + 과 같은 역할, 코스 목록에서 코스 상세로 들어가는 건 스택과 + 같은 역할이에요. 좁은 화면이라 옆으로 숨기는 드로어만 웹에선 덜 흔할 뿐, + 개념은 그대로 대응됩니다. +
+
+ 흔한 착각 "탭을 누르면 새 화면이 쌓인다"가 아니에요. 탭 전환은 서랍을 바꿔 + 여는 것에 가깝고, 각 탭은 자기 스택을 그대로 보존합니다. + 탭을 오갈 때마다 스택이 초기화된다면 그건 대개 설계 실수예요. +
+
+ +
+

+ "코스 목록에서 42번을 눌렀다"는 정보를 상세 화면이 알아야 그 코스를 보여줄 수 있죠. + 화면을 이동시킬 때 값을 함께 실어 보내는 것을 데이터 전달이라고 해요. + 앱에선 이 값을 파라미터(params)라는 쪽지에 담아 넘깁니다. +

+ {CODE_PASS_DATA} +

+ 웹과 나란히 놓으면 원리가 똑같아요. 웹은 값을 URL에 실었고 + (/courses/:iduseParams()), + 앱은 값을 params 객체에 실어(navigate('Detail', {'{ courseId: 42 }'}){' '} + → route.params) 넘길 뿐입니다. 둘 다 "다음 화면에게 + 쪽지를 건네는" 같은 일이고, 통로만 URL이냐 객체냐가 다릅니다. +

+
+ 큰 짐은 나르지 말 것 params엔 id 같은 작은 열쇠만 + 담고, 실제 큰 데이터는 도착한 화면이 그 id로 서버에{' '} + /api/courses/42다시 조회하게 하세요. + 화면 사이로 무거운 객체를 통째 나르면, 뒤로 갔다 오거나 딥링크로 바로 들어왔을 때 + 그 데이터가 없어서 화면이 깨집니다. +
+
+ +
+

+ 뒤로가기는 앱 설계에서 생각보다 큰 주제예요. 특히{' '} + Android는 화면 아래에 시스템 뒤로가기 버튼(또는 + 제스처)이 항상 있어서, 사용자가 언제든 스택을 pop 할 수 + 있어요. 반대로 iOS는 하드웨어 뒤로가기 버튼이 없고, 화면 왼쪽 위{' '} + < 뒤로왼쪽 가장자리에서 쓸어넘기는 + 제스처로 뒤로 갑니다. 그래서 개발자는 "뒤로를 눌렀을 때 뭐가 나와야 + 자연스러운가"를 스택 순서로 늘 설계해 둬야 해요. +

+ {CODE_BACK_DEEPLINK} +

+ 딥링크(deep link)는 웹에서 당연했던 "URL 복사해서 공유"를 앱에서 + 되살리는 장치예요. 앱엔 주소창이 없지만, myapp://courses/42{' '} + 같은 커스텀 스킴이나 https://app.example.com/courses/42{' '} + 같은 유니버설/앱 링크를 누르면 앱이 켜지며 특정 화면으로 + 직행합니다. 카톡·메일로 받은 링크가 앱을 열어 바로 그 코스로 데려가는 게 + 전부 딥링크죠. +

+
+ 왜 중요할까 딥링크로 앱 중간 화면에 곧장 들어오면, 그 앞 화면(목록·홈)이 + 스택에 없을 수 있어요. 그래서 잘 만든 앱은 딥링크 진입 시 "돌아갈 자리(홈 등)를 스택에 + 미리 깔아 두는" 처리를 합니다. 섹션 4의 "id만 넘기고 다시 조회" 원칙이 여기서 빛나요 — + 중간에 뚝 떨어져도 id만 있으면 화면을 스스로 채울 수 있으니까요. +
+
+ +
+

+ 모바일 앱을 만드는 대표적인 크로스플랫폼 도구가 React Native(RN)와{' '} + Flutter예요. 재미있게도 둘 다 내비게이션을 라이브러리/패키지로 + 다루고, 쓰는 단어가 거의 같습니다. 그래서 하나를 익히면 다른 쪽 지형도 + 금방 읽혀요. +

+ {CODE_RN_FLUTTER} +

+ React NativeReact Navigation이 사실상 + 표준이에요. 여러분이 배운 React 문법(컴포넌트·props) 위에서{' '} + navigation.navigate('Detail')처럼 이동하죠 — JSX를 아는 + 사람에게 진입 장벽이 가장 낮은 길입니다. Flutter는 Dart 언어를 쓰고, + 화면을 Navigator.push로 쌓고{' '} + Navigator.pop으로 걷어내며, 규모가 커지면{' '} + go_router로 URL 스타일 경로를 선언합니다. +

+
+ 지형만 읽어도 충분 지금 목표는 "2개월 만에 RN/Flutter 전문가"가 아니에요. + "앱 내비게이션은 결국 스택에 push, 뒤로는 pop, 탭·드로어는 껍데기"라는 + 지도를 갖는 것 — 그 지도만 있으면, 나중에 어떤 프레임워크를 만나도 여러분의 React + 실력을 다리 삼아 걸어 들어갈 수 있어요. +
+
+ +
+

+ 마지막으로, 이 모든 걸 우리 코드로 되감아 봅시다. 우리 학습 플랫폼 + 프론트는 react-router-dom으로 라우팅해요. 지금 보고 있는 + 이 코스 페이지도 frontend/src/App.jsx의{' '} + <Route>에 등록돼 URL로 열린 화면입니다. +

+ {CODE_ROUTER_BRIDGE} +

+ 대응표를 보면 새로 외울 게 거의 없다는 걸 알 수 있어요.{' '} + <Link to>는 앱의{' '} + navigate('화면'), useParams()는{' '} + route.params, navigate(-1)은{' '} + goBack()/pop() — 웹에서 손에 + 익힌 동작들이 앱에서 이름만 갈아입고 다시 등장합니다. 이게 "웹 + 개발자가 보는 모바일"의 핵심이에요. 밑바닥 개념(스택·params·history)은 공유하니까요. +

+
+ 직접 확인해 보기 에디터로 frontend/src/App.jsx를 열어{' '} + <Routes><Route path=.../>를 + 찾아보세요. 그다음 아무 코스 카드나 눌렀다가 브라우저 + (뒤로가기)를 눌러 보세요. 방금 화면이 스택에서 pop 되는 + 그 감각 — 앱의 뒤로가기와 정확히 같은 동작입니다. +
+
+ +
+
+

📱 여기까지 왔다면

+

+ 이제 앱의 화면 전환을 스택·탭·드로어라는 단어로 설명하고,{' '} + params로 데이터를 넘기고, 뒤로가기·딥링크가 왜 중요한지 말할 수 + 있게 됐어요. 무엇보다 이 전부가 여러분이 배운 react-router와 한 뿌리라는 + 걸 봤죠. 그런데 화면을 오갈 때 "그 화면이 보여줄 데이터는 누가, 어떻게 + 들고 있을까?" — 다음은 그 이야기예요.{' '} + 앱 상태와 데이터 코스에서, + 화면을 넘나드는 데이터를 다루는 법으로 이어 갑니다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/courses/AppSecurityPage.jsx b/frontend/src/pages/courses/AppSecurityPage.jsx new file mode 100644 index 0000000..c1a8e0d --- /dev/null +++ b/frontend/src/pages/courses/AppSecurityPage.jsx @@ -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 에 '필요한 권한'을 선언한다 + + + +// iOS — Info.plist 에 '왜 필요한지'를 사람 말로 적어야 한다(안 적으면 크래시/거부) +NSCameraUsageDescription +과제 사진을 찍어 제출하기 위해 카메라를 사용합니다 + +// 위험 권한(카메라·위치·연락처·마이크)은 '설치 때 몰아서'가 아니라 +// 실제로 쓰는 그 순간, 사용자에게 팝업으로 물어본다 — 런타임 권한. +// [허용] [앱 사용 중에만] [이번만] [거부] ← 거부할 수 있다! +// +// 그래서 코드는 언제나 "거부당했을 때"를 대비해야 한다. +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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +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 ( +
+ {/* 히어로: 웹 보안 감각을 다리 삼아 앱 보안의 지형으로 */} +
+
Course · 앱 개발
+

앱 보안과 권한
— 웹과 무엇이 다른가

+

+ 앱 보안의 원리는 웹과 뿌리가 같아요. 다른 건 딱 하나 —{' '} + 코드가 사용자(그리고 공격자)의 기기 안에 설치돼 남는다는 점입니다. + 이 한 가지 차이에서 권한 모델·저장소 보안·통신 보안·스토어 심사가 전부 갈라져 나와요. + 여러분이 이미 배운 방화벽의 최소 권한, HTTP의 TLS, 우리 서버의 해싱을{' '} + 다리 삼아 앱 보안의 지형을 읽어 봅니다. +

+
+ 예상 소요 75분 + 실습: 우리 SecurityConfig·App.jsx 가드 읽기 + 선수 지식: 방화벽·HTTP 코스 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 웹 페이지는 접속할 때마다 우리 서버가 화면 코드를 새로 내려 줍니다.{' '} + 민감한 로직과 비밀은 서버에 두고, 브라우저에는 그때그때 필요한 조각만 보내죠. + 브라우저는 그 코드를 샌드박스라는 격리된 방 안에서만 실행하고요. +

+

+ 앱은 다릅니다. 한 번 설치하면 앱 파일(APK· + IPA)이 사용자 기기 안에 남아요. 그리고 + 그 파일은 다운로드·디컴파일(역분석)·변조가 가능합니다. 즉 여러분이 앱에 넣은 + 모든 것 — API 키, 숨긴 화면, 검사 로직 — 이 잠재적으로 공격자 손에 있다는 + 뜻이에요. +

+ {CODE_TRUST} + + + 신뢰 경계 + 우리가 통제 · 서버 + 사용자 · 공격자의 기기 + + + + 우리 서버 + 코드·비밀 전부 + + + HTTPS로 조각만 + + 브라우저 탭 + 샌드박스 · 매번 새로 + + + + 우리 서버 + API만 제공 + + + HTTPS + + 앱 바이너리 + 기기에 설치 · 상주 + APK/IPA = 뜯어보고 복제 가능 + +

그림: 신뢰 경계 — 웹은 브라우저 샌드박스만 경계 밖에 있지만, 앱은 바이너리 전체가 공격자 영역에 상주한다

+
+ 가장 흔한 착각 "앱에 넣었으니 사용자는 못 보겠지"는 틀렸어요. 앱은 뜯어볼 수 + 있으니 앱 안의 비밀은 비밀이 아닙니다. 그래서 앱 보안의 제1원칙은 + "클라이언트를 믿지 마라" — 진짜 검사는 항상 서버가 다시 합니다. 우리 플랫폼도 + 똑같아서, App.jsx의 라우팅 가드는{' '} + 화면 안내(UX)일 뿐이고 진짜 권한 판정은 서버(API)가 해요. +
+
+ +
+

+ 웹에서도 위치·카메라를 쓰려면 브라우저가 팝업으로 물어봤죠( + getUserMedia·위치 권한). 앱은 이걸{' '} + 훨씬 넓게, OS 차원에서 합니다. 앱은 필요한 권한을 먼저{' '} + 선언하고(안드로이드 AndroidManifest.xml, + iOS Info.plist), 카메라·위치·연락처 같은{' '} + 위험 권한은 실제로 쓰는 순간 사용자에게 다시 물어봐요(런타임 권한). +

+ {CODE_PERMISSION} +

+ 여기서 방화벽 코스의 최소 권한(least privilege)이 그대로 돌아옵니다. + AWS 보안그룹에서 필요한 포트만 열었던 것처럼, 앱도{' '} + 기능이 실제로 필요로 하는 권한만 요청해야 해요. "언젠가 쓸지도 + 몰라서" 권한을 미리 몰아 받는 건 사용자 불신 + 스토어 감점의 지름길입니다. + 그리고 사용자가 거부할 수 있다는 전제로, 거부 시의 대비책까지 + 코드에 넣어야 하고요. +

+
+ 연결해서 보기 이 "필요한 것만 연다"는{' '} + 방화벽과 보안 기초 코스의 핵심과 + 똑같아요. 포트든 앱 권한이든 데이터베이스 계정이든 — 줄 수 있는 최소한만 + 준다가 보안의 뼈대입니다. 권한을 하나 요청할 때마다 "이게 이 화면에 정말 + 필요한가?"를 스스로 물어보세요. +
+
+ +
+

+ 앱은 로그인 토큰이나 설정을 기기에 저장할 때가 많아요. 문제는{' '} + 어디에, 어떻게 저장하느냐입니다. 안드로이드의{' '} + SharedPreferences나 iOS의{' '} + UserDefaults에 토큰을 평문으로 그냥{' '} + 넣으면, 루팅·탈옥된 기기나 백업 파일에서 통째로 읽힙니다. 열쇠를 현관 매트 밑에 두는 + 셈이에요. +

+ {CODE_STORAGE} +

+ 그래서 OS는 하드웨어로 보호되는 금고를 제공합니다. iOS의{' '} + Keychain(Secure Enclave), 안드로이드의 Keystore + (TEE)죠. 앱조차 키 원본을 직접 보지 못하고 "이걸로 암호화/복호화만 해 줘"라고 OS에 + 부탁만 해요. 그리고 API 시크릿·DB 비밀번호 같은 서버의 비밀은 앱에 넣지 + 않습니다 — 디컴파일하면 문자열이 다 보이니까요. 비밀은 오직 서버에. +

+
+ 우리 서버가 하는 것과 같은 결 우리 백엔드는 비밀번호를{' '} + 절대 평문으로 저장하지 않고 BCrypt로 해싱해서 넣어요( + SecurityConfig.java의{' '} + BCryptPasswordEncoder). "민감한 값은 읽을 수 있는 + 형태로 두지 않는다"는 원칙은 서버의 해싱이든 앱의 키스토어든 완전히 똑같습니다. +
+
+ +
+

+ 기기와 서버 사이를 오가는 데이터는 암호화돼야 해요. 여기까진 웹과 + 완전히 같습니다 — HTTP 코스에서 배운 HTTPS(TLS)가 공용 와이파이의 + 도청을 막죠. 다행히 요즘 OS는 기본적으로 평문 http:// 를 막습니다 + (iOS의 ATS, 안드로이드의 cleartext 차단). 그래서 앱은 처음부터 HTTPS가 전제예요. +

+ {CODE_TLS} +

+ 앱만의 한 겹이 인증서 고정(Certificate Pinning)이에요. 일반 HTTPS는 + "공인기관(CA)이 서명한 인증서면 믿는다"인데, 고정은 "우리 서버의 바로 그 + 인증서만 믿는다"고 지문을 앱에 미리 새겨 둡니다. 그러면 가짜 CA를 동원한 + 중간자(MITM) 공격까지 막을 수 있죠. 대신 서버 인증서를 갱신하면 앱도 같이 + 업데이트해야 하는 부담이 있어, 신중히 도입하는 기법입니다. +

+
+ 웹에서 눈으로 확인 우리 플랫폼을 열고 F12 →{' '} + Network 탭에서 요청 URL이 https://로 + 시작하는지 보세요. 로그인은 비밀번호를 실어 보내니 반드시 HTTPS여야{' '} + 합니다. 앱도 정확히 같은 이유로 평문 통신을 금지해요. +
+
+ +
+

+ 웹은 우리가 서버에 올리면 끝이지만, 앱은 애플 앱스토어·구글 플레이의 심사를 + 통과해야 사용자에게 갑니다. 심사에서 보안·개인정보는 큰 비중을 차지해요. 핵심은 + "요청한 권한을 정말 그 용도로만 쓰는가", "어떤 데이터를 왜 모으는지 투명하게 밝혔는가" + 입니다. +

+ {CODE_REVIEW} +

+ 특히 구글 플레이의 '데이터 보안' 섹션과 애플의{' '} + '앱 개인정보 보호' 라벨은, 앱이 수집하는 데이터를 사용자가 설치 전에 + 볼 수 있게 강제합니다. 개인정보처리방침 URL도 필수고요. 배경 위치·SMS·연락처처럼{' '} + 민감한 권한일수록 정당화가 까다롭습니다. 여기서도 결론은 섹션 2와 + 같아요 — 최소 권한이 심사 통과의 지름길입니다. +

+
+ 배포 리듬이 다르다 웹은 고쳐서 푸시하면 바로 반영되지만, 앱은 거부되면 사유를 + 받고 고쳐서 재심사(며칠 걸릴 수 있음)를 받아야 해요. 게다가 사용자가 + 업데이트를 안 하면 취약한 옛 버전이 기기에 계속 남습니다. 그래서 + "처음부터 최소 권한·HTTPS·안전한 저장"으로 만드는 게 훨씬 이득이에요. +
+
+ +
+

+ 지금까지 배운 걸 "이렇게 하면 뚫린다" 관점으로 뒤집어 보면 취약점 목록이 됩니다. + 입문자가 가장 자주 만드는 다섯 가지는 대부분 앞 섹션에서 이미 다룬 것들이에요. + (업계에선 OWASP Mobile Top 10이라는 표준 목록으로 + 정리해 두고 있어요.) +

+ {CODE_VULN} +

+ 다섯 개를 관통하는 한 문장이 "클라이언트를 믿지 마라"입니다. 다섯 번째 + — 클라이언트 과신이 특히 무서워요. "관리자 메뉴를 앱에서 숨겼으니 + 안전하다"고 생각하지만, 앱은 뜯어볼 수 있으니 숨긴 화면은 언제든 드러납니다.{' '} + 진짜 권한 검사·금액 계산·본인 확인은 반드시 서버가 다시 해야 해요. + 화면을 감추는 건 편의(UX)일 뿐, 보안이 아닙니다. +

+
+ 우리 코드로 이미 경험한 것 우리 App.jsx의{' '} + RequireMentor 가드는 학생이 /mentor{' '} + 주소를 직접 쳐도 돌려보내지만, 그 파일 주석은 분명히 말해요 — "진짜 보안은 + 서버(API)가 담당한다." 바로 이게 "클라이언트를 믿지 마라"의 실천이에요. +
+
+ +
+

+ 여기까지 오면 반전이 하나 있어요. 앱 보안의 수칙 대부분을{' '} + 우리 플랫폼이 이미 지키고 있다는 겁니다. 트러스트 경계가 더 + 바깥(공격자 기기)으로 밀려났을 뿐, 지켜야 할 원칙은 웹에서 배운 것과 똑같아요. +

+ {CODE_OURS} +

+ 정리하면 — 최소 권한(방화벽·라우팅 가드),{' '} + HTTPS(네트워크 코스의 TLS), 비밀은 평문 금지(BCrypt + 해싱), 서버가 최종 심판(App.jsx 주석 그대로). 앱에서는 여기에{' '} + 토큰을 Keychain/Keystore에, 필요하면 인증서 고정이 + 더해질 뿐이에요. 즉 여러분은 이미 앱 보안의 감각을 웹 코드에서 익힌 셈입니다. +

+
+ 직접 확인해 보기 두 파일을 나란히 열어 보세요. ①{' '} + backend/.../config/SecurityConfig.java — 비밀번호가 어떻게 + 해싱되는지(BCrypt). ② frontend/src/App.jsx — 라우팅 + 가드와 "진짜 보안은 서버가 담당한다" 주석. 이 두 개가 앱 보안 원칙의{' '} + 웹 버전 실물 교재예요. +
+
+ +
+
+

🔐 여기까지 왔다면

+

+ 이제 앱 보안이 웹과 다른지 한 문장으로 말할 수 있어요 — + "코드가 사용자 기기에 설치돼 남기 때문". 그 한 가지 차이에서 권한 모델·저장소 + 보안·통신 보안·스토어 심사가 갈라져 나왔고, 그 뿌리는 결국{' '} + 우리 플랫폼이 이미 지키는 최소 권한·HTTPS·해싱·"서버가 최종 심판"과 같다는 + 것도 확인했습니다. 안전하게 만든 앱은 이제 세상에 내보낼 차례예요 —{' '} + 앱 스토어 배포 코스에서 + 심사를 통과해 실제 사용자에게 앱을 올리는 길을 따라가 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/courses/AppStateDataPage.jsx b/frontend/src/pages/courses/AppStateDataPage.jsx new file mode 100644 index 0000000..a68c840 --- /dev/null +++ b/frontend/src/pages/courses/AppStateDataPage.jsx @@ -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와 +// 문법이 한 글자도 다르지 않아요. "앱이라 특별한 상태"는 없습니다. +// (화면을 그리는 태그만
로 바뀔 뿐, 상태 로직은 그대로.)`; + +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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +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 ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 앱 개발
+

앱의 상태와 데이터
— 화면·기기·서버, 데이터의 세 집

+

+ 앱이 다루는 데이터는 저마다 사는 집이 달라요. 방금 친 검색어는 화면이 잠깐 + 기억하고, 다크모드 설정은 기기에 저장되고, 내 학습 기록은 서버에 남죠. + 이 셋을 화면 상태·기기 로컬·서버라는 세 층으로 나눠 보면 + "이 값은 어디에 둬야 하지?"가 훤해집니다. 우리가 이미 배운{' '} + useStatefetch를 + 다리 삼아, 웹 개발자의 눈으로 모바일 데이터를 읽어 봅시다. +

+
+ 예상 소요 80분 + 실습: 세 층 분류 + 선수 지식: React 입문(useState·fetch) +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 앱을 만들다 보면 데이터마다 "얼마나 오래, 어디까지 살아 있어야 하나"가 + 제각각이에요. 그걸 정리하는 가장 쓸모 있는 지도가 세 층입니다. + 맨 위 화면 상태는 앱을 끄면 사라지는 잠깐의 기억, + 가운데 기기 로컬은 이 폰에만 오래 남는 저장, + 맨 아래 서버는 모든 기기가 공유하는 영구 보관소예요. +

+ {CODE_LAYERS} +

+ 왜 굳이 나눌까요? 층을 잘못 고르면 바로 사고가 나거든요. 로그인 정보를 화면 + 상태에만 두면 앱을 껐다 켤 때마다 로그아웃되고, 반대로 스크롤 위치까지 서버에 + 저장하면 쓸데없는 요청이 폭발합니다. "이 값의 수명과{' '} + 범위는 어디까지인가?" — 이 한 질문이 층을 결정해요. +

+ + {/* 층 3개 박스 */} + + ① 화면 상태 (useState) + 메모리(RAM) + + + ② 기기 로컬 저장 + AsyncStorage · SQLite · 디스크 + + + ③ 서버 + Spring API · PostgreSQL + + {/* 층 사이 화살표 (①↔②) */} + + + + + 저장 + 읽기 + + {/* 층 사이 화살표 (②↔③) */} + + + + + + {/* 오른쪽 범위/수명 설명 */} + 앱을 끄면 사라짐 + 이 기기에만 · 재시작해도 남음 + 모든 기기 공유 · 영구 보관 + +

그림: 위로 갈수록 잠깐 살고(화면 상태), 아래로 갈수록 오래·널리 공유된다 — 데이터는 아래로 '저장'되고 위로 '읽어' 온다

+
+ 세 층 판별 연습 다음 값들은 몇 층일까요? — (가) 지금 누른 탭, + (나) "알림 켜기" 설정, (다) 내 프로필 사진. 정답: (가)=①화면 상태, + (나)=②기기 로컬, (다)=③서버(다른 기기에서도 같은 사진이 보여야 하니까). + 이 감각이 이 코스 전체의 뼈대예요. +
+
+ +
+

+ 가장 위층 화면 상태는 사실 새로 배울 게 없어요. React 입문에서 + 만난 useState 그대로거든요. 검색창 글자, 스피너 + on/off, 지금 열린 탭 — "화면을 나가면 없어져도 되는" 값이면 전부 화면 상태이고, + useState로 관리합니다. +

+ {CODE_USESTATE} +

+ 웹에서든 앱에서든 규칙은 똑같아요. 값을 직접 고치지 말고 반드시{' '} + set 함수를 부를 것 — 그래야 React가 "상태가 + 바뀌었으니 화면을 다시 그리자"를 알아챕니다. 섹션 1의 "화면 = 상태의 함수"가 + 모바일에서도 그대로 성립하는 거죠. 바뀌는 건 화면을 그리는 태그뿐이에요: + 웹의 <div>·<p>가 + React Native에선 <View>·<Text>로 바뀝니다. +

+
+ 이미 실력이 있다는 신호 "앱의 화면 상태"라는 말이 어렵게 들렸다면, 방금 + 그게 그냥 useState였다는 걸 확인하세요. 웹에서 + 쌓은 React 실력의 절반 이상이 그대로 앱으로 건너갑니다. 이게 + 이 코스가 "웹 개발자가 보는 모바일"을 표방하는 이유예요. +
+
+ +
+

+ "앱을 껐다 켜도 남아야 하지만, 서버까지 갈 필요는 없는" 값들이 가운데 층에 + 삽니다. 대표 도구가 AsyncStorage예요. 웹의{' '} + localStorage를 써 봤다면 사고방식이 똑같습니다 — + 문자열 키-값 창고죠. "이름표(key)에 값(value)을 적어 서랍에 + 넣어 둔다"고 생각하면 돼요. +

+ {CODE_LOCAL} +

+ 저장할 게 구조가 복잡하거나 양이 많으면 AsyncStorage로는 + 벅차요(전부 문자열이라 검색·정렬이 힘드니까). 그럴 땐{' '} + SQLite — 기기 안에 들어 있는 작은 관계형 데이터베이스를 씁니다. + 우리가 배운 SELECT ... WHERE 같은 SQL을 폰 안에서 + 그대로 쓸 수 있어요. 채팅 앱이 비행기 모드에서도 지난 대화를 보여 주는 게 보통 + 이 방식입니다. 여기에 OS가 알아서 관리하는 앱 캐시(이미지·임시 + 파일 저장 공간, 기기 설정에서 "캐시 지우기"로 비워지는 그것)까지 더하면 로컬 + 저장의 큰 그림이 완성됩니다. +

+
+ 민감정보는 로컬 평문 금지 AsyncStorage와 SQLite는 기본적으로{' '} + 암호화되지 않은 평문이에요. 로그인 토큰·비밀번호를 여기 그냥 + 두면, 기기를 잃어버렸을 때 통째로 새어 나갑니다. 민감정보는 반드시 OS의 보안 + 저장소 — iOS Keychain /{' '} + Android Keystore(또는{' '} + expo-secure-store) — 에 넣으세요. 이건 우리{' '} + 방화벽·보안 코스의 "민감한 건 안전한 곳에" + 원칙이 모바일로 그대로 이어지는 지점이에요. +
+
+ +
+

+ 맨 아래층, 서버는 모든 기기가 공유하는 진짜 데이터 창고예요. + 우리 회사 백엔드는 Spring Boot + PostgreSQL이고, 웹 프론트는 + 이미 fetch('/api/...')로 그 API를 부르고 있죠 + (React 입문의 DashboardPage 예제 기억나죠?). 좋은 소식은,{' '} + 앱에서도 fetch가 그대로 있다는 겁니다. React Native에 fetch가 + 내장돼 있어요. +

+ {CODE_SERVER} +

+ 그러니 "서버에서 데이터를 받아 useState에 담고 + 화면을 다시 그린다"는 흐름 — React 입문의 useEffect + + fetch 패턴 — 이 앱에서도 판박이예요. 달라지는 건 딱 두 가지: 주소를 전체 URL로 + 적는 것, 그리고 로그인 유지 방법입니다. 우리 웹은{' '} + api/client.js의{' '} + withCredentials쿠키를 자동 + 첨부하지만, 앱은 브라우저가 아니라 쿠키 자동첨부가 없어요. 그래서 로그인 때 받은{' '} + 토큰을 보안 저장소(섹션 3)에 넣어 두고, 요청마다{' '} + Authorization 헤더에 직접 실어 보냅니다. +

+
+ 개발할 땐 'localhost'가 함정 폰(또는 에뮬레이터)에게 "localhost"는{' '} + 내 PC가 아니라 폰 자신을 뜻해요. 그래서 개발 중엔 안드로이드 + 에뮬레이터는 10.0.2.2, 실제 기기는 PC의 사설 + IP(예: 192.168.x.x)로 백엔드에 접속합니다. + "웹에선 됐는데 앱에선 서버 연결이 안 돼요"의 8할이 이 주소 문제예요. +
+
+ +
+

+ 웹과 앱의 가장 큰 체감 차이가 여기예요. 웹은 대개 "인터넷이 있다"고 가정하지만, + 앱은 지하철·엘리베이터·비행기 모드에서도 손에 들려 있죠. 그래서 + 좋은 앱은 "서버에 못 닿을 때"를 미리 설계합니다. 완벽히 구현하는 건 어렵지만, + 개념의 뼈대는 셋으로 단순해요: 읽기 캐시, 쓰기 큐, 그리고 + 재연결 동기화. +

+ {CODE_OFFLINE} +

+ 핵심 아이디어는 낙관적 UI(optimistic update)예요. 서버 응답을 + 기다렸다가 화면을 바꾸면 느리고 답답하니, "어차피 성공하겠지"하고{' '} + 화면부터 먼저 바꾸고 요청은 뒤에서 처리하는 거죠. 만약 나중에 + 실패하면 화면을 되돌립니다(rollback). 그리고 오프라인 동안 쌓인 변경들을 다시 + 연결됐을 때 순서대로 흘려보내는 게 동기화(sync)예요. 이 과정에서 + 같은 데이터를 두 기기가 동시에 고치는 충돌이 생기는데, 여기엔 + 만능 정답이 없다는 걸 아는 것 자체가 중요합니다. +

+
+ 여기서 눈높이 맞추기 2개월 만에 오프라인 동기화를 완성하는 게 목표가 + 아니에요. "왜 앱은 오프라인을 신경 써야 하고, 낙관적 UI·대기 큐·충돌이라는 + 지형이 있다"는 걸 아는 게 목표입니다. 실무에선 이걸 직접 + 짜기보다 TanStack Query·WatermelonDB 같은 도구의 오프라인 + 기능에 기대는 경우가 많아요 — 바퀴를 다시 발명하지 않는 것도 실력이니까요. +
+
+ +
+

+ 앱이 커지면 "이 상태를 저 멀리 있는 화면도 알아야 하는" 순간이 와요. 로그인 + 사용자 정보처럼요. 그때마다 부모에서 자식으로 props를 몇 단계씩 넘기는 건 + 고통스럽죠(이걸 props drilling이라 불러요). 그래서 + 상태를 좀 더 넓게 공유하는 도구의 사다리가 있습니다. +

+ {CODE_STATE_LIBS} +

+ 여기서 중요한 구분 하나. 클라이언트 상태(내 화면이 만든 값: + 열린 탭, 입력 중인 글)와 서버 상태(서버가 주인인 값: 코스 목록, + 내 프로필)는 성격이 달라요. 서버 상태는 "캐시해 두고, 낡으면 다시 받아오고, + 로딩·에러를 관리하는" 일이 반복돼서, 그걸 전문으로 해 주는{' '} + TanStack Query 같은 도구를 씁니다. 반면 순수 클라이언트 상태는{' '} + Zustand·Redux 쪽이죠. 우리 mirim-app은 아직 이 큰 도구들 + 없이 useState와 Context(AuthContext)만으로 + 충분한 규모예요. +

+
+ 우리 코드에서 확인 에디터로{' '} + frontend/src/AuthContext.jsx를 열어 보세요. + 로그인한 사용자 정보를 Context로 앱 전체에 뿌리고, 각 화면은{' '} + useAuth()로 꺼내 씁니다. "props를 층층이 넘기지 + 않고 필요한 곳에서 바로 꺼내 쓰는" 상태관리의 가장 작은 형태를, 이미 우리 앱이 + 쓰고 있는 거예요. 이 개념도 웹과 앱이 100% 공유합니다. +
+
+ +
+

+ 마지막으로 오늘 배운 걸 "웹 지식 대비 얼마나 옮겨지나"로 정리해 봅시다. 결론부터 + 말하면 대부분 그대로 옮겨져요. 화면 상태, 서버 호출, 상태관리 + 라이브러리는 웹과 앱이 같은 도구·같은 문법을 씁니다. 진짜로 + 달라지는 건 "데이터를 어디에 저장하나"와 "로그인을{' '} + 어떻게 유지하나" 정도예요. +

+ {CODE_WEB_VS_APP} +

+ 그래서 "2개월 만에 네이티브 앱 전문가"는 과장이지만, "웹에서 쌓은 실력으로 앱의 + 지형을 읽고, 내 손으로 앱까지 가는 길을 아는" 것은 충분히 현실적인 목표입니다. + 오늘 세운 세 층 지도 하나면, 앞으로 어떤 앱 튜토리얼을 봐도 + "아, 이건 화면 상태 얘기구나 / 이건 로컬 저장이네 / 이건 서버 연동이구나" 하고 + 제자리에 꽂아 넣을 수 있어요. +

+
+ 미니 실습 여러분이 매일 쓰는 앱 하나(인스타그램·카카오톡·유튜브 등)를 + 떠올려, 화면에 보이는 데이터 5개를 골라 세 층으로 분류해 보세요. 예: 좋아요 수(③ + 서버), 다크모드 설정(② 로컬), 지금 보고 있는 탭(① 화면 상태). 정답을 맞히는 + 것보다 "왜 그 층인지"를 한 줄로 말할 수 있는 게 핵심입니다. 멘토에게 그 표를 + 공유하면 좋은 대화가 시작돼요. +
+
+ +
+
+

📱 여기까지 왔다면

+

+ 이제 앱의 데이터를 화면 상태·기기 로컬·서버 세 층으로 나눠 + 보는 눈이 생겼어요. 그리고 그 대부분이 우리가 웹에서 이미 배운{' '} + useState·fetch·Context + 그대로라는 것도요. 남은 건 "저장 위치"와 "로그인 유지"라는 두 갈래의 차이뿐. + 다음은 이 지식을 가장 적은 비용으로 실전에 써 보는 길 —{' '} + PWA(설치되는 웹앱) 코스로 + 이어집니다. 우리가 만든 React 웹을 설치·오프라인 지원까지 + 갖춘 '앱처럼' 만드는, 웹 개발자에게 가장 가까운 첫 번째 앱을 만나 보세요. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/courses/AppStoreDeployPage.jsx b/frontend/src/pages/courses/AppStoreDeployPage.jsx new file mode 100644 index 0000000..09e555e --- /dev/null +++ b/frontend/src/pages/courses/AppStoreDeployPage.jsx @@ -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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +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 ( +
+ {/* 히어로: 코드가 끝난 지점에서 '세상에 내놓기'까지 */} +
+
Course · 앱 개발
+

스토어 배포
— 앱을 세상에 내놓기

+

+ 코드가 다 돌아간다고 끝이 아니에요. 앱은 구글·애플이라는 문지기가 지키는 + 백화점 매대(스토어)에 올라가야 사람들 손에 닿습니다. 준비물부터 서명·등록·심사까지, + 우리 웹 배포(Caddy·Docker)와 나란히 놓고 무엇이 같고 무엇이 다른지 + 지형을 읽어 봅니다. +

+
+ 예상 소요 70분 + 실습: 스토어 등록정보 초안 + 선수 지식: 앱의 지형 · React 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 앱 개발자 지망생이 가장 자주 놀라는 지점이에요. "코드 다 짰는데 왜 아직 출시가 안 되지?" + 스토어는 코드만 받는 곳이 아니라 상품 매대라서, 물건(앱)과 함께 + 진열용 재료를 요구합니다. 아이콘, 스크린샷, 설명 글, 그리고 + 가장 자주 빠뜨리는 개인정보 처리방침까지요. +

+ {CODE_PREP} +

+ 이 중 개인정보 처리방침(Privacy Policy)은 선택이 아니라 사실상 + 의무예요. 로그인, 광고 ID, 위치, 심지어 에러 로그만 모아도 "개인정보 수집"에 + 해당할 수 있고, 그때는 https://... 형태의 + 공개 URL로 방침을 올려 두고 스토어에 그 주소를 등록해야 합니다. + 우리 보안 기초 코스에서 배운 + "데이터는 함부로 모으지도, 흘리지도 않는다"는 원칙이 스토어 규칙으로 강제되는 셈이죠. +

+
+ 지금 해 볼 것 우리 학습 플랫폼 화면 한 장을 골라 스토어 등록정보처럼 써 보세요. + 앱 이름 한 줄, 짧은 설명(30자 이내) 한 줄, 자세한 설명 3~4문장. + 막상 써 보면 "이 앱이 사용자에게 무엇을 주는가"를 한 문장으로 못 말한다는 걸 + 깨닫게 되는데 — 그 깨달음이 이 실습의 진짜 목적이에요. +
+
+ +
+

+ 웹에선 그냥 서버에 파일을 올리면 끝이지만, 앱은 설치 파일에 반드시 + 디지털 서명(signing)을 해야 해요. 서명은 앱에 찍는 + 인감도장이라고 생각하면 딱 맞습니다. 이 도장은 세 가지를 보장해요: + 누가 만들었는지(출처), 중간에 변조되지 않았는지(무결성), 그리고 + 나중에 올릴 업데이트가 같은 사람 것인지(연속성). +

+ {CODE_SIGNING} +

+ 안드로이드에선 keystore라는 파일이 곧 도장이고, + iOS에선 애플이 발급하는 인증서 + 프로비저닝 프로파일이 그 역할을 + 합니다. 특히 안드로이드에서 keystore와 비밀번호는 목숨처럼 지켜야 해요 — + 잃어버리면 "같은 도장"을 다시 만들 수 없어서, 사용자들이 쓰고 있는 앱을 + 영영 업데이트하지 못하게 됩니다. +

+
+ 왜 하필 도장이 하나여야 할까 만약 아무 도장으로나 업데이트를 허용하면, + 공격자가 "같은 이름의 가짜 새 버전"을 만들어 사용자 폰의 진짜 앱을 덮어쓸 수 있어요. + "같은 도장만 덮어쓰기 가능"이라는 규칙이 그걸 막는 자물쇠입니다. 그래서 + keystore는 소스코드가 아니라 비밀(secret)로 다뤄 + Git에 절대 올리지 않아요 — 우리 백엔드가 JWT_SECRET을 + 코드가 아니라 .env로 다루는 것과 똑같은 이치예요. +
+
+ +
+

+ 준비물과 도장이 갖춰졌으면 이제 매대에 올릴 차례예요. 안드로이드는 + Google Play Console이라는 웹 관리 화면에서 진행합니다. + 한 번만 내는 $25로 개발자 계정을 열고 나면, 아래 순서대로 + 빈칸을 채워 나가는 방식이에요. +

+ {CODE_PLAY_FLOW} +

+ 여기서 웹 개발자가 꼭 짚고 갈 개념이 AAB(Android App Bundle)예요. + 예전엔 .apk 한 덩어리를 올렸지만, 2021년부터 새 앱은 + .aab로 올립니다. AAB는 '완성품'이 아니라 + 재료 묶음이라, 구글이 사용자의 기기(화면 크기·CPU 종류·언어)에 맞춰 + 꼭 필요한 부분만 골라 APK를 재조립해 배달해요. 덕분에 다운로드 용량이 줄죠. +

+
+ 테스트 트랙이라는 안전벨트 Play는 곧바로 전 세계에 뿌리기 전에 + 내부 → 비공개 → 공개 테스트 단계를 줍니다. 우리 백엔드에 + CI/CD의 스테이징 환경이 있는 것과 같은 + 발상이에요 — "진짜 사용자에게 가기 전에, 좁은 무대에서 먼저 확인한다." +
+
+ +
+

+ iOS는 App Store Connect(웹 관리 화면)와 Xcode(맥 전용 + 개발 도구)를 오가며 올립니다. 연 $99의 개발자 프로그램에 가입하고, + 빌드를 업로드한 뒤, TestFlight로 베타 테스트를 거쳐 심사에 제출해요. + 큰 흐름은 Play와 비슷하지만, 결정적으로 다른 한 가지가 있습니다. +

+ {CODE_APP_STORE_FLOW} +

+ 바로 사람이 직접 심사한다는 점이에요. 애플 리뷰어가 실제로 앱을 + 내려받아 실행해 보고, 가이드라인(App Review Guidelines)에 맞는지 하나하나 확인합니다. + 그래서 Play보다 더 오래 걸리고 더 깐깐해요. 이유는 애플의 생태계 구조에 + 있습니다 — iOS 앱은 원칙적으로 App Store를 통해서만 설치되는 + 닫힌 정원이라, 애플이 품질·보안·프라이버시의 문지기 역할을 스스로 떠안는 거예요. +

+
+ 닫힌 정원 vs 열린 마당 안드로이드는 스토어 밖에서도 앱 설치(사이드로딩)가 + 비교적 자유로운 '열린 마당'이고, iOS는 문이 하나뿐인 '닫힌 정원'이에요. + 문이 하나라 통제가 강한 대신, 그 문(심사)을 통과하는 문턱도 높습니다. + 어느 쪽이 옳다기보다 철학이 다른 것 — 개발자는 두 세계의 규칙을 + 모두 존중해야 하죠. +
+
+ +
+

+ 첫 제출이 한 번에 통과되는 일은 흔치 않아요. 특히 App Store는 리젝(반려)이 + 자주 일어나는데, 다행히 사유는 대체로 정해진 몇 가지 패턴이에요. + 미리 알면 대부분 피할 수 있습니다. +

+ {CODE_REJECT} +

+ 이 중 초보가 가장 많이 걸리는 건 4번, "웹사이트를 그냥 감싸기만 한 앱" + 이에요. 애플은 이런 앱을 '최소한의 기능(4.2)'에 못 미친다고 봅니다 — 브라우저로 그 + 사이트에 가면 되는데 굳이 앱일 이유가 없다는 거죠. 이건 우리가 + PWA 코스에서 다룬 경계와도 맞닿아 있어요: + "웹을 앱처럼 쓰고 싶다"면 스토어 심사를 통과하려 억지 껍데기를 씌우기보다, + 애초에 PWA로 가는 게 정직한 선택일 때가 많습니다. +

+
+ 리젝을 대하는 태도 리젝 사유서는 채점표가 아니라 수정 가이드예요. + "우리 앱은 이래서 반려됐고, 이렇게 고치면 된다"를 담담히 읽고, 고쳐서 재제출하면 됩니다. + 이건 우리가 디버깅에서 배운 자세와 + 똑같아요 — 에러 메시지는 적이 아니라 단서다. +
+
+ +
+

+ 앱은 한 번 내고 끝이 아니라 계속 고쳐 올리는 물건이에요. 그래서 버전을 + 체계적으로 붙여야 합니다. 핵심은 숫자를 두 종류로 관리한다는 점 — + 하나는 사용자에게 보여 주는 이름(예: 1.2.0), 다른 하나는 스토어가 + "이게 더 새 거야?"를 판단하는 내부 번호예요. +

+ {CODE_VERSION} +

+ 사람용 이름은 시맨틱 버저닝(SemVer) 규칙을 따르는 게 관례예요. + MAJOR.MINOR.PATCH — 버그만 고치면 맨 뒤 PATCH를, + 기능을 더하면 가운데 MINOR를, 크게 갈아엎어 호환이 깨지면 맨 앞 MAJOR를 올립니다. + 우리 백엔드 라이브러리(예: Spring Boot 4.0.7)의 버전도 + 정확히 이 규칙으로 붙어 있어요 — 낯설어 보이던 숫자가 사실은 약속이었던 거죠. +

+
+ 웹과 가장 다른 지점, 롤백 우리 웹은 문제가 생기면 이전 Docker 이미지로 + 몇 초 만에 되돌립니다. 하지만 스토어에 잘못 올린 버전은 그렇게 + 즉시 못 물려요 — 대개 고친 새 버전을 다시 올려 심사를 또 받아야 합니다. + 그래서 앱 세계에선 "올리기 전 테스트"의 무게가 웹보다 훨씬 무거워요. + 단계적 출시(staged rollout)로 5% → 20% → 100%처럼 조금씩 푸는 이유도 + 바로 이 되돌리기의 어려움 때문입니다. +
+
+ +
+

+ 마지막으로 이 코스 전체를, 우리가 실제로 쓰는 웹 배포와 나란히 놓아 + 봅시다. 우리 플랫폼은 코드를 git push하면 빌드되어 + Docker 이미지로 서버에 올라가고, Caddy가 HTTPS로 + 바깥에 내보내죠. 여기엔 문지기(심사)가 없어요 — 배포하는 순간 + 모든 사용자가 즉시 같은 최신 버전을 봅니다. +

+ {CODE_COMPARE} + + 우리 웹 배포 + 앱 스토어 배포 + + {/* 왼쪽: 웹 파이프라인 */} + + git push + + + + 빌드 · Docker + + + + 서버 배포 + + + + Caddy · HTTPS + + + + 전원 즉시 최신 + 문지기 없음 · 롤백도 즉시 + + {/* 오른쪽: 앱 파이프라인 */} + + 빌드 · 서명 + + + + 스토어 업로드 + + + + 심사 (사람이 검사) + ← 게이트 + 여기서 대기 + + + + 승인 · 출시 + + + + 사용자가 직접 설치 + 버전이 기기마다 제각각 + +

그림: 웹은 문지기 없이 흘러 전원이 즉시 최신, 앱은 심사 게이트를 지나도 사용자가 설치해야 최신이 된다

+

+ 정리하면 이래요. 웹은 내 서버, 내 마음 — 배포도 롤백도 즉시고, + 모두가 같은 버전을 봅니다. 앱은 남의 백화점 매대 — 준비물·서명·심사라는 + 관문을 지나야 하고, 통과해도 사용자가 각자 업데이트를 받아야 최신이 되죠. + 더 번거로워 보이지만, 그 관문이 곧 품질과 신뢰의 안전장치이기도 해요. +

+
+ 이 코스의 진짜 목표 여러분이 이번 수습에서 앱 배포를 직접 다 해 볼 일은 + 없을 거예요. 그래도 이 지형을 알아 두면, 나중에 "우리도 앱 낼까?"라는 대화에서 + 비용·시간·제약을 현실적으로 가늠할 수 있습니다. 그게 + "웹 개발자가 읽는 모바일"의 실전 감각이에요. +
+
+ +
+
+

🚀 앱 개발 코스, 여기까지

+

+ 축하해요 — 앱의 지형에서 출발해 언어·네비게이션·크로스플랫폼을 거쳐, + 드디어 스토어라는 마지막 관문까지 왔습니다. 이제 여러분은 네이티브 + 앱 전문가는 아니지만, 앱까지 가는 길이 어떻게 생겼는지 지도를 읽을 줄 아는 + 개발자예요. 그 지도와, 지금 손에 익힌 React·Spring·Docker·보안 지식이면 + 필요할 때 스스로 다음 한 걸음을 뗄 수 있습니다. 그동안 쌓은 코스들을 다시 둘러보고 + 싶다면 언제든 학습 센터로 — + 수습 8주가 곧 여러분의 포트폴리오가 될 거예요. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/courses/FlutterPage.jsx b/frontend/src/pages/courses/FlutterPage.jsx new file mode 100644 index 0000000..e409d64 --- /dev/null +++ b/frontend/src/pages/courses/FlutterPage.jsx @@ -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 createState() => _LikeButtonState(); +} + +class _LikeButtonState extends State { + 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +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 ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 앱 개발
+

Flutter
— 또 하나의 크로스플랫폼

+

+ React Native가 유일한 길은 아니에요. Google의 Flutter는 전혀 다른 + 방식으로 같은 목표(하나의 코드로 여러 OS)를 노립니다. 이 코스는 여러분이 배운 + React 지식을 다리 삼아 Dart와 위젯을 읽고, "왜 도구가 둘이나 + 있는지"와 "우리에게 무엇을 아끼고 무엇을 치르게 하는지"까지 — 지형을 읽는 걸 + 목표로 합니다. +

+
+ 예상 소요 45분 + 코드 예제 6개(읽기 위주) + 선수 지식: React 입문 · JS 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ Flutter는 Google이 만든 크로스플랫폼 UI 도구예요. 언어로는{' '} + Dart를 쓰고, 하나의 코드로 안드로이드·iOS는 물론 + 웹·데스크톱까지 화면을 낼 수 있게 하는 걸 목표로 합니다. 여기까지는 React + Native와 비슷해 보이지만, 결정적으로 다른 점이 하나 있어요. +

+

+ 바로 화면을 그리는 방식입니다. 보통의 앱은 OS가 제공하는 '순정 + 버튼·목록'을 빌려다 배치해요. 그런데 Flutter는 그걸 빌리지 않고,{' '} + 자체 렌더링 엔진(요즘은 Impeller, + 예전엔 Skia)이 버튼 모양까지 픽셀 단위로 + 직접 그립니다. 남의 가구를 빌리는 대신 내 목공소에서 통째로 깎는 셈이죠. +

+ {CODE_WHAT_IS} +
+ '직접 그린다'가 주는 것 엔진이 화면을 통제하니 안드로이드와 iOS에서 UI가 + 거의 똑같이 나오고, 애니메이션도 매끄럽게 뽑기 좋아요. 대신 "각 OS의 순정 느낌"과는 + 살짝 멀어질 수 있습니다 — 이 트레이드오프가 섹션 2의 핵심이에요. +
+
+ +
+

+ "이미 React Native가 있는데 왜 Flutter도 존재하지?" 이 질문의 답이 두 도구를 + 이해하는 지름길이에요. 둘은 같은 목표(한 코드로 여러 OS)를 정반대의 + 전략으로 풉니다. +

+ {CODE_RN_COMPARE} +

+ 가장 큰 갈림길은 "OS 부품을 빌리느냐, 직접 그리느냐"예요. + React Native는 자바스크립트로 지시를 내리면 진짜 네이티브 부품이 + 화면에 놓입니다(그래서 각 OS 느낌에 가깝죠). Flutter는 부품을 빌리지 않고{' '} + 엔진이 다 그리니 어디서든 화면이 거의 동일합니다. 어느 쪽이 + '더 좋다'가 아니라, 무엇을 아끼고 싶은가가 다른 거예요. +

+
+ 웹 개발자의 눈으로 React Native가 우리에게 가까운 이유는 단 하나 —{' '} + 언어와 사고방식(React)을 이미 안다는 점이에요. Flutter는 화면 + 품질에서 강점이 있지만, 그 문 앞엔 Dart 학습이라는 통행료가 + 있습니다. 이 통행료의 정체가 다음 섹션입니다. +
+
+ +
+

+ Flutter의 언어 Dart는 Google이 만든 객체지향 언어예요. 다행히 + 자바스크립트와 뿌리가 닮아서, JS를 안다면 문법의 대부분이 눈에 익습니다. + 크게 새로운 건 두 가지 — 타입을 앞에 적는 것과{' '} + 널 안전성(null safety)이에요. +

+ {CODE_DART} +

+ 널 안전성은 Dart의 자랑거리예요. 타입에{' '} + ?를 붙인 값만 null이 될 + 수 있고, 나머지는 컴파일러가 "여긴 null이 올 수 없다"고 미리 보증합니다. JS에서 + 그렇게 당하던 undefined is not a function 류의 사고를 + 실행 전에 막아 주는 안전벨트인 셈이죠. +

+
+ 실행 방식도 알아두면 좋아요 Dart는 개발 중엔 코드를 바로 실행(JIT)해{' '} + 핫 리로드(Hot Reload) — 저장하면 앱 상태를 유지한 채 화면만 + 즉시 갱신 — 를 지원하고, 배포용으로는 기계어로 미리 컴파일(AOT)해 + 빠르게 돕니다. React 개발의 HMR(저장 즉시 반영)과 느낌이 비슷해요. +
+
+ +
+

+ React에서 화면이 컴포넌트의 조립이었다면, Flutter에서 화면은{' '} + 위젯(Widget)의 조립이에요. 그런데 Flutter의 위젯은 범위가 훨씬 + 넓습니다. 버튼·글자는 물론이고 여백·정렬·간격 같은 '레이아웃'까지 전부 + 위젯이에요. "모든 게 위젯"이 Flutter의 슬로건입니다. +

+ {CODE_WIDGET} +

+ 위젯을 중첩해서 만든 구조를 위젯 트리라고 불러요. + 최상위 MaterialApp부터 아래로 화면 뼈대 + (Scaffold), 그 안의 상단바·본문, 다시 그 안의 + 글자·버튼까지 — 큰 위젯이 작은 위젯을 품는 나무 구조입니다. React의 컴포넌트 + 트리와 판박이라, 여러분에겐 낯설지 않을 거예요. +

+ + + + + + + + + + MaterialApp + + Scaffold + + AppBar + + Column + + Text '미림 앱' + + Text '안녕' + + ElevatedButton + + Text '좋아요' + 초록 = 잎 위젯(글자·버튼) + 위 → 아래로 중첩 + +

그림: Flutter 위젯 트리 — MaterialApp에서 Text·버튼까지 위젯이 위에서 아래로 중첩된다(React 컴포넌트 트리와 같은 구조)

+
+ React와 나란히 두기 build() 메서드가 React의{' '} + return (JSX) 자리이고, 위젯 중첩이 JSX 태그 중첩이에요. + 다만 여백을 CSS margin으로 주는 대신{' '} + SizedBox 같은 위젯으로 준다는 점만 + 새롭습니다. "스타일도 위젯"이라는 감각에 익숙해지는 게 첫 관문이에요. +
+
+ +
+

+ 위젯은 두 종류예요. 값이 안 변하는 StatelessWidget(React의 순수 + 컴포넌트)과, 값이 변하고 그때마다 화면을 다시 그려야 하는{' '} + StatefulWidget입니다. 좋아요 버튼처럼 클릭할 때마다 숫자가 + 바뀌는 화면이 바로 후자예요. +

+ {CODE_STATEFUL} +

+ 핵심은 setState()입니다. 상태 변수(위 예제의{' '} + count)를 그냥 바꾸기만 하면 화면은 꿈쩍도 안 해요. + 변경을 setState(() => ...)감싸야{' '} + Flutter가 "이 위젯 다시 그려야겠구나" 하고 build()를 + 다시 부릅니다. React에서 count = count + 1로는 재렌더가 + 안 되고 꼭 setCount()를 불러야 했던 것과{' '} + 완전히 같은 원리예요. +

+
+ 이 함정, 어디서 봤죠? React 입문 섹션 4의 "직접 바꾸면 화면이 안 바뀐다"가 + 여기서 그대로 반복돼요. 이름만 setCount →{' '} + setState로 바뀌었을 뿐, "상태 변경은 반드시 + 정해진 함수를 통해"라는 규칙은 동일합니다. 개념이 이사 온다는 게 이런 뜻이에요. +
+
+ 참고 앱이 커지면 setState만으로는 벅차서{' '} + Provider·Riverpod·Bloc 같은 상태관리 도구를 얹습니다. React에서 규모가 커지면 + Context나 Redux 계열을 찾는 것과 같은 흐름이에요 — 지금은 "그런 게 있다" 정도만 + 알아 두면 충분합니다. +
+
+ +
+

+ 도구 선택은 유행이 아니라 상황이 정해요. Flutter가 강한 자리와, + 오히려 다른 선택이 나은 자리를 나눠 두면 판단이 쉬워집니다. +

+ {CODE_WHEN} +

+ 한 줄 요약: "디자인·애니메이션을 픽셀까지 통제하고 싶다"면 Flutter가, + "React/JS 자산을 그대로 잇고 싶다"면 React Native가 유리해요. 그리고 + 잊지 말 것 — 화면 몇 개짜리라면 반응형 웹으로 충분한 경우도 많고, + 센서·게임처럼 성능이 극한으로 필요하면 네이티브(Kotlin/Swift)가 + 답일 수 있습니다. 크로스플랫폼이 항상 정답은 아니에요. +

+
+ 수습생용 판단 훈련 새 앱 아이디어가 생기면 스스로 물어보세요. ① 화면 품질을 + 얼마나 통제하고 싶나? ② 우리 팀이 이미 아는 언어는? ③ 웹만으로는 왜 안 되나? + 이 세 질문의 답을 적어 보는 것만으로도, "무작정 유명한 걸 고르는" 실수를 피할 수 + 있어요. +
+
+ +
+

+ 마지막으로 우리 상황에 붙여 봅시다. 사실부터 정확히 — 우리 + 플랫폼의 프론트엔드는 React이고, 서버는 Spring Boot예요. + 지금 이 학습 페이지도 React로 그려지고 있죠. 즉 우리에겐 이미 React라는 + 자산이 있고, Flutter는 Dart라는 새 학습비용을 요구합니다. +

+ {CODE_OUR_VIEW} +

+ 중요한 건 서버는 그대로라는 점이에요. 앱을 React Native로 짜든 + Flutter로 짜든, 데이터는 여전히 우리 Spring API에 요청해서 + 받아옵니다("네트워크의 이해" 코스에서 배운 그 요청 그대로!). 그러니 앱 도구를 + 고르는 일은 '화면을 무엇으로 그릴까'의 문제이지, 백엔드를 다시 + 짜는 일이 아니에요. +

+
+ 과장 금지 — 이 코스가 약속하는 것 2개월 만에 여러분을 "네이티브 앱 + 전문가"로 만들어 주지 않아요. 그건 정직하지 않은 약속입니다. 대신 이 코스는{' '} + 지형을 읽는 눈을 줍니다. React·RN·Flutter가 각각 무엇을 아끼고 + 무엇을 치르는지 알면, 필요할 때 여러분의 실력으로 그 길을 걸어갈{' '} + 수 있어요. "길을 아는 것"과 "이미 다 걸은 것"은 다르지만, 길을 알면 반드시 도착합니다. +
+
+ 다음으로 도구가 무엇이든, 화면이 여러 개가 되는 순간 공통으로 마주치는 문제가 + 있어요 — "이 화면에서 저 화면으로 어떻게 넘어가지?" 그게 다음 + 코스의 주제입니다. +
+
+ +
+
+

🧭 지형이 조금 보이기 시작했다면

+

+ 이제 여러분은 크로스플랫폼에 React Native만 있는 게 아니라는 것, + 그리고 Flutter가 Dart와 위젯, 직접 그리는 엔진으로 다른 길을 + 간다는 걸 설명할 수 있어요. 무엇보다 여러분이 배운 React의 상태·컴포넌트 + 개념이 그대로 이사 온다는 것도요. 어떤 도구를 고르든 화면이 여러 개가 + 되면 반드시 필요한 것 — 화면 간 이동, 즉{' '} + 앱 내비게이션 코스에서 + "이 화면에서 저 화면으로" 넘어가는 길을 이어서 배워 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/courses/IosBasicsPage.jsx b/frontend/src/pages/courses/IosBasicsPage.jsx new file mode 100644 index 0000000..a7943b4 --- /dev/null +++ b/frontend/src/pages/courses/IosBasicsPage.jsx @@ -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 { // 세로로 쌓는 컨테이너 (=
) + 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에 '왜 필요한지' 이유 문구를 미리 적어 둔다 (안 적으면 앱이 그냥 죽음) + NSCameraUsageDescription + 과제 사진을 찍어 올리려고 카메라를 사용합니다 + +② 코드가 카메라를 '처음' 쓰는 순간, 시스템이 팝업을 띄운다 + ┌──────────────────────────────┐ + │ '미림앱'이 카메라 접근을 요청합니다 │ + │ [ 허용 안 함 ] [ 허용 ] │ ← 결정권은 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +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 ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 앱 개발
+

iOS 앱의 구조
— 웹 개발자의 눈으로 보는 애플 앱

+

+ 우리 플랫폼은 React 웹앱이지만, 그 옆엔 늘 '앱'이라는 큰 세계가 있어요. + 이 코스는 여러분이 배운 React 지식을 다리 삼아 iOS 앱의 뼈대를 + 읽는 지도입니다. "2개월에 앱 전문가"가 목표가 아니라,{' '} + 지형을 읽고 내 실력으로 앱까지 가는 길을 아는 것이 목표예요. +

+
+ 예상 소요 70분 + 실습: 개념 확인 위주 + 선수 지식: React 입문 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ iOS 앱을 이해하려면 먼저 애플이라는 회사의 성격부터 알아야 해요. + 애플은 기기(iPhone)·운영체제(iOS)·앱 스토어·심지어 칩까지 한 회사가 + 직접 만듭니다. 이걸 '수직 통합' 또는 '담장 친 정원(walled garden)'이라고 + 불러요. 반대로 안드로이드는 구글이 OS를 만들고, 기기는 삼성·샤오미 등 수많은 + 회사가 제각각 만들죠. +

+ {CODE_ECOSYSTEM} +

+ 이 '폐쇄성'은 장점이자 단점이에요. 기기 종류가 적으니 개발자는 + 테스트할 화면 크기가 몇 개 안 되고, 사용자들도 새 iOS가 나오면 대부분 금방 + 업데이트합니다. 반대로 애플이 정한 규칙 밖으로는 나갈 수 없어요 — + 개발에 맥이 필요하고, 앱을 올리려면 애플의 심사를 통과해야 하며, 스토어 결제엔 + 수수료가 붙습니다. 뒤 섹션에서 이 '규칙들'을 하나씩 만나게 돼요. +

+
+ 왜 이게 중요할까 "iPhone은 종류가 몇 개 없다"는 사실 하나가 개발 경험 전체를 + 바꿔요. 안드로이드 개발자가 수백 종 기기의 파편화와 싸울 때, iOS 개발자는 통일된 + 소수 모델만 챙기면 됩니다. 편함과 자유를 맞바꾼 셈이죠. +
+
+ +
+

+ iOS 앱은 Swift라는 언어로 만들고(예전엔 Objective-C), 화면을 그리는 + 도구(UI 프레임워크)는 크게 두 가지가 공존해요. 오래된{' '} + UIKit(2008~)과 새로운{' '} + SwiftUI(2019~, iOS 13+)입니다. 둘의 차이는 여러분이 + React 코스에서 이미 배운 그 대비 — 명령형 vs 선언형 — 과 정확히 + 같아요. +

+ {CODE_UIKIT_SWIFTUI} +

+ 왼쪽 UIKit은 "내가 라벨을 찾아서 글자를 직접 바꾼다" — + 옛날 자바스크립트로 DOM을 손수 조작하던 방식과 똑같아요. 오른쪽{' '} + SwiftUI@State로 상태를 + 두고, 상태만 바꾸면 화면이 알아서 다시 그려집니다. React의{' '} + useState와 "화면 = 상태의 함수"가 그대로 재현되죠. +

+
+ React를 배운 게 헛되지 않다 SwiftUI의 @State ≈ + React의 useState, body ≈ + 컴포넌트의 return, 뷰 중첩 ≈ JSX 조립. 문법(Swift)만 + 새로 익히면, 사고방식은 이미 여러분 머릿속에 있어요. 새 앱은 대개 + SwiftUI로 시작하고, 기존 대형 앱엔 UIKit이 여전히 많다는 것만 기억해 두세요. +
+
+ +
+

+ iOS에서 화면에 보이는 모든 것은 '뷰(View)'예요. 버튼도 뷰, 글자도 + 뷰, 사진도 뷰, 그리고 그것들을 담는 상자도 뷰입니다. 뷰가 뷰를 품고, 그 뷰가 또 + 다른 뷰를 품으면서 트리(나무) 구조가 만들어져요. 이걸{' '} + 뷰 계층(view hierarchy)이라고 부릅니다. +

+ {CODE_VIEW_BODY} +

+ 이 그림이 낯설지 않을 거예요. 브라우저의 DOM 트리, React의{' '} + 컴포넌트 트리 — 전부 "부모가 자식을 품는 트리"라는 같은 뼈대입니다. + 한 화면은 보통 하나의 뷰 컨트롤러(UIViewController)가 관리하고, 그 + 컨트롤러가 뿌리 뷰를, 뿌리 뷰가 자식 뷰들을 거느리는 구조예요. +

+ + + + + + + + + Window + + UIViewController + + root View + + VStack (상자) + + Image + + Text + + Button + + + 부모 + 품는 + 방향 + 자식 + +

그림: iOS 뷰 계층 트리 — 화면 전체(Window)에서 잎 뷰(Text·Button)까지, 브라우저 DOM 트리와 똑같은 '부모가 자식을 품는' 뼈대

+
+ 연결해서 기억하기 React 입문에서 본 컴포넌트 트리 다이어그램을 떠올려 보세요. + <App> → <Layout> → <Card> → <Badge>{' '} + 와 위 그림의 Window → View → VStack → Text는 이름만 + 다른 쌍둥이예요. 트리를 읽는 눈을 한 번 기르면, 웹이든 앱이든 화면 구조가 보입니다. +
+
+ +
+

+ 웹 페이지는 대개 "열려 있거나 닫혀 있거나" 둘 중 하나예요. 그런데 앱은 상태가 더 + 많아요. 화면에 떠 있고(Active), 홈으로 나가면{' '} + 뒤로 가려지고(Background), 한참 안 쓰면 시스템이{' '} + 얼려 버리기도(Suspended) 하죠. 이 상태 변화의 순간마다 iOS가 + "지금 이런 일이 일어났어!" 하고 우리 코드의 특정 함수를 불러 줘요. 이걸{' '} + 생명주기(lifecycle)라고 합니다. +

+ {CODE_LIFECYCLE} +

+ 여기서도 React 지식이 다리가 돼요. 화면이 처음 뜰 때 하는 일은{' '} + useEffect(..., [])와, 화면이 사라질 때 정리하는 일은 + 그 cleanup 함수와 짝이 맞아요. SwiftUI에선 각각{' '} + .onAppear·.onDisappear{' '} + 라는 이름으로 부릅니다. 다만 "앱이 백그라운드로 갔다" 같은 상태는 + 웹엔 딱 맞는 짝이 없는 '앱만의 개념'이에요. +

+
+ 생명주기를 무시하면 백그라운드로 간 앱에서 음악·위치추적·타이머를 계속 + 돌리면 배터리를 잡아먹고, iOS가 앱을 강제로 잠재우거나 종료시킬 수 있어요. "화면이 + 사라질 때 멈추고, 돌아올 때 다시 시작한다"를 챙기는 게 앱 개발의 기본기입니다 — + React에서 cleanup으로 타이머를 정리하던 습관과 똑같아요. +
+
+ +
+

+ 이제 가장 현실적인 벽 이야기예요. iOS 앱을 만들려면 애플의 공식 개발 도구{' '} + Xcode가 필요한데, Xcode는 오직 macOS에서만 돌아갑니다.{' '} + 즉 개발용 맥이 사실상 필수예요. Xcode 안에는 코드 편집기·컴파일러와 + 함께 시뮬레이터(Simulator)가 들어 있어, 맥 화면 위에 iPhone을 띄워 + 앱을 바로 테스트할 수 있습니다. +

+ {CODE_XCODE} +

+ 시뮬레이터는 빠르고 편하지만 진짜 기기는 아니에요. 카메라·GPS·가속도 + 센서·푸시 알림처럼 하드웨어가 걸린 기능은 결국 실제 아이폰에서 확인해야 합니다. + 내 아이폰에 설치해 보는 건 애플 ID만 있으면 무료지만(7일마다 다시 설치),{' '} + App Store에 정식 출시하려면 연 $99의 개발자 프로그램이 필요해요. +

+
+ 윈도우만으론 공식 빌드가 안 된다 이건 취향이 아니라 애플의 정책이에요. 회사 + PC가 윈도우라면 클라우드 맥(예: CI의 macOS 러너)을 빌려 빌드하는 + 우회로가 있습니다. "맥이 없으면 iOS는 시작도 못 하나?"의 답은 "정석은 맥, 우회로는 + 클라우드"입니다. +
+
+ 솔직하게 우리 회사 제품은 React + Spring Boot 이라 지금 + iOS 네이티브 앱을 직접 만들진 않아요. 그러니 이 섹션은 "당장 맥을 사라"가 아니라, + 앱 개발의 진입 장벽이 어디에 있는지 지형을 파악하는 것으로 충분합니다. +
+
+ +
+

+ iOS의 성격을 가장 잘 보여주는 두 가지가 권한(permission)과{' '} + 앱 심사(review)예요. 먼저 권한. iOS는 카메라·사진·위치·마이크·연락처 + 같은 민감한 자원을 앱이 실제로 쓰려는 그 순간, 사용자에게 직접 물어봅니다.{' '} + 그리고 앱은 Info.plist"왜 이게 필요한지"{' '} + 이유 문구를 미리 적어 둬야 해요 — 안 적으면 앱이 그 자리에서 꺼져 버립니다. +

+ {CODE_PERMISSIONS} +

+ 두 번째는 심사 문화예요. 안드로이드가 상대적으로 자동 검사에 기대는 + 것과 달리, App Store는 사람이 직접 앱을 검토합니다. 개인정보 처리, + 디지털 상품의 인앱 결제 규정, 오해를 부르는 설명, 비공개 API 사용 등에서 걸리면 + 가차 없이 반려(reject)돼요. 통과에 보통 하루이틀이 걸리고, 규칙이 + 까다로운 만큼 스토어의 품질·안전 신뢰도가 높다는 게 애플의 논리입니다. +

+
+ 이유 문구 누락 = 즉시 크래시 NSCameraUsageDescription{' '} + 같은 문구 없이 카메라를 켜면 앱이 바로 죽어요. iOS의 규칙은 "권한은 사용자 것이고, + 앱은 이유를 밝혀야 한다"입니다. 사용자가 거부해도 앱은 그 기능 없이 굴러가도록 + 만들어야 하고요. +
+
+ 우리 보안 코스와 이어진다 "필요한 최소한만, 이유를 밝히고 요청한다"는 iOS의 + 권한 철학은 우리{' '} + 방화벽·보안 코스에서 배운{' '} + 최소 권한 원칙과 정확히 같은 사고방식이에요. 플랫폼만 다를 뿐, + "함부로 열지 않는다"는 원칙은 웹·서버·앱 어디서나 통합니다. +
+
+ +
+

+ 마지막으로 지금까지의 조각을 안드로이드와 나란히 놓고 정리해 봅시다. 표를 보면 + "다른 점"이 잔뜩 보이지만, 정말 중요한 건 맨 아래 한 줄이에요 — + 뼈대는 같다. +

+ {CODE_ANDROID_DIFF} +

+ 언어(Swift vs Kotlin), 도구(Xcode vs Android Studio), 스토어 정책은 서로 다릅니다. + 하지만 "화면은 뷰의 트리다", "화면엔 생명주기가 있다", "권한은 실행 중에 + 사용자에게 묻는다"는 근본 개념은 양쪽이 똑같아요. 심지어 안드로이드의 + 최신 UI 도구 Jetpack Compose는 SwiftUI·React처럼{' '} + 선언형입니다. 결국 세 세계가 같은 방향으로 수렴하고 있는 거죠. +

+
+ 지도를 다 읽었다면 한쪽 플랫폼을 제대로 이해하면 다른 쪽은 '방언'을 배우는 + 수준이에요. 그리고 여러분은 이미 React로 선언형 UI라는 표준어를 + 익혔습니다. iOS든 안드로이드든, 남은 건 각 지역의 사투리(문법)를 얹는 일이에요. +
+
+ +
+
+

🍎 여기까지 왔다면

+

+ 이제 iOS 앱을 이루는 단어들 — 애플 생태계·UIKit/SwiftUI·뷰 계층·생명주기·Xcode·권한·심사 + — 을 여러분이 배운 React 언어로 설명할 수 있게 됐어요. 화면은 뷰의 + 트리이고, 상태가 바뀌면 다시 그려지며, 권한은 최소한만 이유를 밝혀 요청한다는 것. + 지도를 다 읽었으니, 다음은 이 땅에서 쓰는 실제 언어 차례예요 —{' '} + Swift 입문 코스에서 SwiftUI를 + 움직이는 문법을, 여러분이 아는 JavaScript와 나란히 놓고 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/courses/KotlinIntroPage.jsx b/frontend/src/pages/courses/KotlinIntroPage.jsx new file mode 100644 index 0000000..0e8b71b --- /dev/null +++ b/frontend/src/pages/courses/KotlinIntroPage.jsx @@ -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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +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 ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 앱 개발
+

Kotlin 입문
— 웹 개발자의 눈으로 보는 안드로이드 언어

+

+ 여러분은 이미 React로 const·let, + 화살표 함수, .map()을 써 봤어요. 놀랍게도 안드로이드 앱을 + 만드는 언어 Kotlin은 그 감각과 소름 돋게 닮았습니다. 목표는 "2개월 만에 + 앱 전문가"가 아니라 이미 아는 웹 지식을 다리 삼아 앱 언어의 지형을 읽는 것 — + val/var부터 null 안정성, 람다, data class까지 뼈대를 세워 봅니다. +

+
+ 예상 소요 70분 + 실습: 코드 읽고 옮겨 보기 + 선수 지식: JS 기초(변수·함수·배열) +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 안드로이드 앱은 오랫동안 Java로 만들었어요. Java는 튼튼하지만 + 장황합니다 — 사람 한 명을 담는 클래스 하나에도 게터·세터·생성자로 + 수십 줄이 필요하죠. 젯브레인즈(JetBrains)가 만든 Kotlin은 "같은 일을 + 더 짧고 안전하게"를 목표로 나온 언어이고, 2017년 구글이 안드로이드 공식 언어로 + 채택한 뒤 지금은 안드로이드 개발의 표준이 됐습니다. +

+ {CODE_WHY} +

+ 핵심은 Kotlin이 Java와 같은 JVM 위에서 돌고, 서로 자유롭게 섞어 쓸 수 + 있다는 점이에요. 우리 회사 백엔드가 Spring Boot(역시 JVM에서 도는 + Java)로 돼 있는데, Kotlin은 바로 그 세계의 이웃 언어입니다. 여러분이 배우는 React가 + "웹의 현대 표준"이라면, Kotlin은 "안드로이드의 현대 표준"이라고 보면 돼요. +

+
+ 미리 보는 큰 그림 이 코스는 문법 하나하나를 여러분이 아는 JS와 짝지어 + 갑니다. val↔const, 람다↔화살표 함수, .map()↔.map(). 새 언어를 통째로 외우는 게 아니라, + 이미 있는 지식에 "번역표"를 붙이는 여정이에요. +
+
+ +
+

+ Kotlin의 변수는 딱 두 종류예요. valvalue, + 한 번 담으면 다른 값으로 못 바꾸는 상자 — JS의 const와 + 사촌입니다. varvariable, 얼마든지 다시 + 대입할 수 있는 상자 — JS의 let과 사촌이고요. +

+ {CODE_VAL_VAR} +

+ 겉보기엔 JS와 똑같지만 결정적 차이가 하나 있어요. JS는 동적 타입이라 + 변수에 뭐가 들었는지 실행해 봐야 알지만, Kotlin은 정적 타입이라 + 타입이 애초에 정해져 있습니다. 다만 타입 추론 덕분에{' '} + val age = 18처럼 짧게 써도 컴파일러가 알아서{' '} + Int로 판단해 줘요. "짧게 쓰는 편함 + 타입의 안전함"을 + 둘 다 챙기는 거죠. +

+
+ 습관 하나 Kotlin에서는 일단 val로 쓰고, 정말 바꿔야 할 때만 var로 + 바꾸는 게 정석이에요. 안 바뀌는 값이 많을수록 코드가 예측 가능해지고 버그가 줄거든요. + React에서 const부터 손이 가는 습관과 똑같습니다. +
+
+ +
+

+ 안드로이드 앱이 갑자기 죽는(크래시) 원인 1위는 오랫동안{' '} + NullPointerException(NPE)이었어요. "값이 있는 줄 알고 꺼냈는데 비어 + 있어서" 터지는 사고죠. null을 만든 토니 호어(Tony Hoare)조차 훗날 그걸{' '} + "10억 달러짜리 실수"라고 불렀을 정도예요. Kotlin은 이 문제를{' '} + 타입 차원에서 막습니다. +

+

+ 방법은 간단해요. 타입 뒤에 물음표가 없으면{' '} + (String) null을 절대 못 넣고, 있으면{' '} + (String?) null이 들어올 수 있다는 뜻입니다. 그리고 + null일 수 있는 값을 다룰 땐 세 연산자를 씁니다. +

+ {CODE_NULL} + + + val name: String? + null일 수도 있는 값 + + + + + + + name?.length + + name ?: "손님" + + name!!.length + + + + + + + + + + null이면 그냥 null + 안전하게 통과 + + null이면 "손님" + 기본값으로 대체 + + null이면 앱 크래시 + NPE 발생 + + ?. 안전 호출 + ?: 엘비스 + !! 단언(위험) + +

그림: 같은 null 값도 어떤 연산자로 다루느냐에 따라 안전(?., ?:)하거나 위험(!!)하게 갈린다

+
+ !! 는 최후의 수단 !!는 "이건 절대 null 아냐"라는 + 장담이에요. 틀리면 바로 그 자리에서 앱이 죽습니다. Kotlin을 쓰는 이유의 절반이 null + 안전인데 !!를 남발하면 그 안전을 스스로 반납하는 셈이죠. + 평소엔 ?.?:로 흘려보내고,{' '} + !!는 정말 확신할 때만 아껴 쓰세요. +
+
+ +
+

+ 함수는 fun 키워드로 정의해요. Java와 달리 타입을{' '} + 이름 뒤에 콜론으로 붙이는 게 특징입니다 + (fun add(a: Int, b: Int): Int). 그리고 함수 몸통이 + 식 하나뿐이면 = 기호로 한 줄로 줄일 수 있어요 — Kotlin다운 간결함이죠. +

+ {CODE_FUN} +

+ 람다이름 없는 일회용 함수예요. 포스트잇에 "이거 + 한 번만 해 줘"라고 적어 건네는 메모 같은 거죠. React에서 매일 쓰던{' '} + {'(x) => x * x'} 화살표 함수, 그게 Kotlin에선{' '} + {'{ x -> x * x }'}입니다. 화살표의 방향과 중괄호 위치만 + 다를 뿐, "값을 받아서 결과를 돌려주는 작은 상자"라는 개념은 똑같아요. +

+
+ 문자열 템플릿 JS의 {'`안녕 ${name}`'}이 + Kotlin에선 "안녕 $name"이에요. 백틱 없이 큰따옴표 안에서 + 바로 $변수를 꽂습니다. 계산식이 들어가면{' '} + {'"합계 ${a + b}"'}처럼 중괄호를 쓰고요. 거의 같죠? +
+
+ +
+

+ Kotlin의 클래스는 생성자를 클래스 이름 옆 괄호에 바로 씁니다. 게다가 + 그 괄호 안에 val/var를 + 붙이면 그게 곧 필드가 되고, 게터/세터까지 자동으로 생겨요. Java라면 수십 줄이 필요한 + 일이 한 줄로 끝납니다. 참고로 객체를 만들 때 new 키워드도 없어요 — + 그냥 함수 부르듯 Person("미림", 18). +

+ {CODE_CLASS} +

+ 하이라이트는 data class예요. 클래스 앞에{' '} + data 한 단어만 붙이면 값 비교(equals), + 보기 좋은 출력(toString), 복제(copy)가 공짜로 따라옵니다. 서버(우리 Spring + Boot API)에서 받은 JSON을 담을 그릇으로 딱 맞아요 — "데이터만 담는 도시락통"인 + 셈이죠. copy(name = "미르")로 반찬 하나만 바꿔 새 도시락을 + 만드는 감각은, React에서 {'{ ...user, name: "미르" }'}로 + 객체를 복제하던 것과 똑같습니다. +

+
+ 왜 값 비교가 공짜가 중요할까 보통 클래스는 ==가 + "같은 메모리 주소냐"를 따져요. 하지만 data class는 + "안에 든 값이 같냐"로 비교합니다. 그래서 서버에서 온 두 User가 내용이 같은지 한 줄로 + 확인할 수 있어요. 목록을 다룰 때(React의 key를 떠올려 보세요) 아주 요긴합니다. +
+
+ +
+

+ 목록을 다루는 감각도 웹과 거의 같아요. listOf(...)로 + 리스트를 만들고, 여러분이 React에서 쓰던{' '} + .map()·.filter()· + .forEach()그대로 씁니다. 단, 인자가 + 하나일 땐 이름을 안 짓고 it으로 부르고, 소괄호 없이 + 중괄호 람다만 붙이는 게 Kotlin 스타일이에요. +

+ {CODE_WHEN} +

+ when은 JS의 switch가 + 진화한 형태예요. 두 가지가 크게 좋아졌습니다. 첫째, break가 필요 없어요 — + 맞는 갈래 하나만 실행하고 끝납니다(switch에서 break 빼먹어 생기는 사고가 없죠). 둘째,{' '} + 값을 돌려주는 '식(expression)'이라 val grade = + when {'{ ... }'}처럼 결과를 바로 변수에 담을 수 있어요. 여러 갈림길을 그리는 + 표지판이라고 생각하면 됩니다. +

+
+ 불변이 기본 listOf가 만든 리스트는 원소를 + 추가·삭제할 수 없어요(읽기 전용). 바꿔야 하면{' '} + mutableListOf를 씁니다. 여기서도 Kotlin은 "일단 못 바꾸게, + 꼭 필요할 때만 바꾸게"라는 val/var의 철학을 그대로 밀고 갑니다. +
+
+ +
+

+ 여기까지 왔다면 눈치챘을 거예요 — Kotlin은 완전히 새로운 세계가 아니라, + 여러분이 아는 것들의 재배치라는 걸요. 아래 표 한 장이면 오늘 배운 게 전부 + 담깁니다. 막힐 때 이 표만 다시 펴 보세요. +

+ {CODE_SUMMARY} +

+ 물론 이 코스가 안드로이드 앱 전체를 만드는 법까지 알려 주진 않아요. 화면을 그리는 + Jetpack Compose, 기기 권한, 생명주기(lifecycle) 같은 안드로이드 고유의 세계가 그 다음에 + 기다리고 있습니다. 하지만 언어의 지형을 읽을 수 있게 됐다는 것이 큰 + 출발점이에요. 낯선 Kotlin 코드를 만나도 "아, 이건 val이니까 상수, 이건 ?.니까 null + 안전 호출"이라고 해석되기 시작할 테니까요. +

+
+ 가장 좋은 연습 React 코스에서 만들었던 작은 함수(예: 좋아요 개수를 세거나 목록을 + 거르는 코드)를 골라, 위 번역표를 보며 Kotlin으로 옮겨 써 보세요.{' '} + constval,{' '} + 화살표 함수→람다로 바꾸는 손맛이 붙으면, 두 언어가 한 가족이라는 게 몸으로 느껴집니다. + 온라인 Kotlin Playground(play.kotlinlang.org)에 붙여넣어 + 바로 돌려볼 수도 있어요. +
+
+ +
+
+

🤖 여기까지 왔다면

+

+ 이제 val/var, null 안정성(?. ?: !!), 람다, data class, when이라는 단어로 Kotlin + 코드를 읽고 옮길 수 있게 됐어요. 그리고 무엇보다 — 여러분이 배운 + React/JS 지식이 앱 언어로 건너가는 다리가 된다는 걸 확인했습니다. + 안드로이드의 현대 UI 도구 Jetpack Compose는 React처럼 선언형이라, + "화면 = 상태의 함수"라는 감각이 그대로 이어지고요. 다음은 바다 건너 반대편 대륙 —{' '} + iOS 기초에서 애플의 Swift와 + 아이폰 앱의 지형을 같은 방식으로 읽어 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/courses/PwaPage.jsx b/frontend/src/pages/courses/PwaPage.jsx new file mode 100644 index 0000000..03887e0 --- /dev/null +++ b/frontend/src/pages/courses/PwaPage.jsx @@ -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 = ` + + + +`; + +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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +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 ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 앱 개발
+

PWA
— 웹앱을 앱처럼

+

+ "앱을 만들려면 안드로이드·iOS를 새로 배워야 하나요?" — 꼭 그렇진 않아요. + 우리가 이미 만든 React 웹앱에 파일 두어 개를 얹으면, 홈 화면에 + 설치되고 오프라인에서도 도는 PWA(Progressive Web App)가 됩니다. + 웹 개발자가 가장 적은 힘으로 '앱'에 닿는 길, 그 지형을 읽어 봅시다. +

+
+ 예상 소요 70분 + 실습: 매니페스트·SW 얹기 + 선수 지식: React·HTTP 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 우리가 아는 웹사이트는 브라우저 탭 안에서만 살아요. 주소창으로 + 들어가고, 탭을 닫으면 사라지고, 인터넷이 끊기면 먹통이 되죠. 반대편엔{' '} + 네이티브 앱이 있어요. 스토어에서 받아 홈 화면에 깔리고, 오프라인에서도 + 돌고, 카메라·센서 같은 기기 기능을 깊이 씁니다. 대신 안드로이드는 Kotlin, iOS는 + Swift로 따로 만들어야 하죠. +

+

+ PWA는 그 둘 사이의 중간 지대예요. 몸통은 여전히 웹(HTML·CSS·JS)인데, + 껍데기만 앱처럼 입습니다. 홈 화면에 아이콘이 깔리고, 주소창 없이 전체화면으로 열리고, + 오프라인에서도 일부 동작해요. 스토어 심사도, 새 언어도 없이요. +

+ {CODE_SPECTRUM} +

+ PWA를 앱답게 만드는 재료는 딱 세 가지입니다. ① HTTPS(안전한 연결),{' '} + ② 매니페스트(명함 — 섹션 3), ③ 서비스워커(중간 + 관리인 — 섹션 4). 이 셋이 갖춰지면 브라우저가 "어, 이거 설치할 수 있는 앱이네?" + 하고 알아봅니다. +

+
+ 이름 뜻풀이 Progressive(점진적) Web App — "가능한 기기에선 앱처럼, 안 되는 + 곳에선 그냥 웹처럼" 단계적으로 좋아진다는 뜻이에요. 낡은 브라우저라도 + 최소한 웹페이지로는 열립니다. 하나의 코드가 상황에 맞춰 눈높이를 바꾸는 셈이죠. +
+
+ +
+

+ 멀리 있는 얘기가 아니에요. 지금 보고 있는 이 학습 플랫폼이 바로 유력한 + 후보입니다. 우리는 이미 React로 만든 웹앱을 Vite로 + 빌드하고, 운영 서버는 HTTPS로 서비스하죠. PWA의 재료 세 개 중{' '} + HTTPS는 이미 있고, 나머지 둘(매니페스트·서비스워커)만 얹으면 됩니다. +

+

+ 단, 정직하게 말할게요. 지금 이 순간의 우리 앱은 아직 PWA가 아니에요.{' '} + frontend/index.html을 열어 보면{' '} + <link rel="manifest">이 없고,{' '} + public/엔 서비스워커 파일도 없습니다. 그래서 홈 화면에 + "설치" 버튼이 안 뜨는 거예요. 이 코스의 목표는 그 빠진 조각을 내 손으로{' '} + 채워 넣는 지형을 익히는 것입니다. +

+
+ 직접 확인해 보기 에디터에서{' '} + frontend/index.html을 열어 <head>{' '} + 안을 보세요. 지금은 <title>과 뷰포트 설정뿐, + manifest 링크가 없습니다. 섹션 3에서 여기에 딱 한 줄을 더하는 것부터 시작해요. +
+
+ 과장 금지 PWA를 얹는다고 우리 웹앱이 "네이티브 앱"이 되는 건 아니에요. + 같은 React 코드가 설치·오프라인·알림이라는 앱의 옷을 입는 것뿐입니다. + 이 구분을 흐리지 않는 게, 2개월 뒤에도 스스로 판단할 수 있는 개발자의 감각이에요. +
+
+ +
+

+ 웹 앱 매니페스트manifest.webmanifest{' '} + 라는 작은 JSON 파일이에요. "내 이름은 뭐고, 아이콘은 이거고, 열리면 + 이 주소부터 보여 주고, 주소창은 숨겨 줘" — 브라우저에게 건네는 앱 명함 + 입니다. 사용자가 "홈 화면에 추가"를 누를 때 브라우저가 이 파일을 읽어 아이콘과 + 이름을 만듭니다. +

+ {CODE_MANIFEST} +

+ 만든 명함은 index.html에 걸어 둬야 브라우저가 찾아 + 읽어요. 방식은 우리가 CSS 파일을 <link>로 거는 것과 + 똑같습니다. +

+ {CODE_MANIFEST_LINK} +

+ 핵심 필드는 display예요.{' '} + "standalone"으로 두면 주소창·탭이 사라지고 전체화면으로 + 열려, 겉보기엔 진짜 앱과 구분이 안 됩니다. icons의{' '} + "purpose": "maskable"은 안드로이드가 아이콘을 원형이나 + 둥근 사각형 틀에 맞춰 잘라 낼 때 여백을 확보해 주는 옵션이고요. +

+
+ 비유 매니페스트는 가게 간판 신청서예요. 상호(name), 로고(icons), + 문 열면 보일 첫 화면(start_url), 간판 색(theme_color)을 적어 내면, OS가 홈 화면이라는 + 거리에 우리 가게 간판을 대신 달아 줍니다. 간판만으론 장사가 안 되죠 — 실제 영업(오프라인 + 동작)은 다음 섹션의 서비스워커 몫입니다. +
+
+ +
+

+ "인터넷이 끊겼는데 어떻게 화면이 뜨지?" PWA 오프라인의 비밀이 바로{' '} + 서비스워커(Service Worker)예요. 우리 앱과 네트워크{' '} + 사이에 끼어 앉은 자바스크립트인데, 앱이 보내는 모든 요청을 중간에서 + 가로채(intercept) "이건 캐시에서 줄게, 이건 서버에 물어볼게"를 스스로 결정합니다. +

+ {CODE_SW_REGISTER} +

+ 한번 등록되면 서비스워커는 페이지와 별개의 백그라운드에서 돌아요. + 그래서 탭을 닫아도, 심지어 앱이 꺼져 있어도 살아 있을 수 있죠(섹션 5의 푸시가 이걸 + 씁니다). 대신 화면(DOM)은 못 만져요 — 오직 요청을 가로채고 캐시를 다루는{' '} + 문지기 역할만 합니다. +

+ + + React 앱 + 화면 · fetch() + + + 서비스워커 + 요청 가로채기 + (문지기) + + + 캐시 + Cache API + + + 서버(네트워크) + Spring API + + {/* 앱 → 서비스워커 */} + + + 요청 + + {/* 서비스워커 → 캐시 (있으면) */} + + + ① 있으면 캐시 + + {/* 서비스워커 → 서버 (없으면) */} + + + ② 없으면 서버 + + 오프라인이어도 캐시(①)만 있으면 화면이 뜬다 + +

그림: 서비스워커는 앱의 요청을 먼저 받아 — 캐시에 있으면 즉시 돌려주고, 없을 때만 서버로 넘긴다

+ {CODE_SW_FETCH} +
+ 양날의 검, 캐시 서비스워커가 옛 파일을 캐시에 붙들고 있으면, 새 버전을 배포해도 + 사용자에겐 낡은 화면이 계속 보일 수 있어요. 그래서 캐시 이름에 버전 + (mirim-v1v2)을 붙이고, + 새 버전이 켜질 때 옛 캐시를 지우는 처리가 꼭 필요합니다. 실무에선 이걸 자동화하려고 + 섹션 7의 플러그인을 쓰죠. +
+
+ +
+

+ 네이티브 앱의 상징 같은 기능, 푸시 알림도 PWA가 할 수 있어요. + 원리는 이래요. 사용자가 허락하면, 브라우저가 그 기기만의 '푸시 주소'를 + 발급해 줍니다. 그 주소를 우리 Spring 서버에 저장해 두면, 나중에 서버가 + 그 주소로 메시지를 쏘고, 백그라운드의 서비스워커가 받아서 알림을 띄웁니다. +

+ {CODE_PUSH} +

+ 중간 배달부는 OS의 푸시 서비스예요. 안드로이드·크롬은 FCM, + 애플은 APNs가 담당합니다. 우리 서버는 그 배달부에게 "이 주소로 이 내용 전해 줘"라고 + 맡기는 거고요. 서명에 쓰는 열쇠 한 쌍을 VAPID 키라고 부르는데, + "이 발신자는 진짜 우리 서버가 맞다"를 증명하는 신분증이라고 보면 됩니다. +

+
+ 권한은 사용자의 것 Notification.requestPermission()은{' '} + 말 그대로 물어보는 거예요. 사용자가 거절하면 알림을 보낼 방법이 + 아예 없습니다. 그러니 앱 켜자마자 권한 창을 들이밀지 말고, "알림을 왜 켜면 좋은지"를 + 보여 준 다음 필요한 순간에 요청하는 게 예의이자 성공률을 높이는 길이에요. +
+
+ 우리 스택과의 연결 우리 백엔드는 Spring Boot예요. 구독 정보를 + 받아 DB에 저장하고, 조건이 되면(예: 새 과제 등록) 저장해 둔 주소로 푸시를 쏘는 + 서버 API가 필요하죠. 프론트의 /api/push/...{' '} + 호출과 서버의 발송 로직이 짝을 이룹니다 — 웹에서 배운 "요청↔응답"이 알림에서도 + 그대로예요. +
+
+ +
+

+ 여기가 이 코스에서 가장 정직해야 할 대목이에요. PWA는 강력하지만{' '} + 네이티브를 완전히 대체하진 못합니다. 특히 iOS(아이폰)의 + 제약이 커요. 왜냐하면 iOS에선 크롬·파이어폭스조차 속으로는 애플의 웹 엔진(WebKit)을 + 쓰기 때문에, 애플이 열어 준 만큼만 됩니다. +

+

대표적인 벽들:

+

+ · 설치 유도가 어렵다 — 안드로이드/크롬은 "설치하시겠어요?" 배너를 + 띄울 수 있지만, iOS엔 그 자동 배너가 없어요. 사용자가 공유 → 홈 화면에 + 추가를 손수 눌러야 합니다. +

+

+ · 푸시 알림의 조건 — iOS는 16.4(2023년)부터 웹 푸시를 + 지원하지만, 반드시 홈 화면에 설치된 PWA에서만 작동해요. 사파리 탭으로 + 열어 둔 상태에선 안 됩니다. +

+

+ · 저장 공간·기능 제약 — iOS는 캐시·저장소를 오래 안 쓰면 지워 버릴 + 수 있고, 블루투스·NFC·정밀한 백그라운드 실행 같은 기능은 웹에 잘 열어 주지 않아요. + 안드로이드는 이보다 훨씬 관대합니다(크롬은 아예 WebAPK로 앱처럼 포장해 줘요). +

+ {`기능 PWA(크롬/안드로이드) PWA(iOS/사파리) 네이티브 +──────────────────────────────────────────────────────────── +홈 화면 설치 O (설치 배너 O) O (수동 추가만) O +오프라인 캐시 O O (지워질 수 있음) O +푸시 알림 O △ 설치 시 16.4+ O +카메라·마이크 O O O +블루투스·NFC 일부 X O +앱스토어 노출 X (URL로 공유) X O`} +
+ 그래서 언제 PWA인가 "설치·오프라인·알림 정도면 충분하고, 한 코드로 웹·안드로이드· + iOS를 빠르게 덮고 싶다" → PWA가 최고의 가성비예요. 반대로 "블루투스 + 기기 제어, 앱스토어 노출, 무거운 3D·센서가 핵심" → 그땐 네이티브(또는 React Native + 같은 크로스플랫폼)를 봐야 합니다. 정답은 없고, 요구사항이 정합니다. +
+
+ +
+

+ 이제 지형을 알았으니 실제 길을 걸어 봅시다. 다행히 우리 프론트엔드는 이미{' '} + Vite 기반이라, 서비스워커를 손으로 한 줄씩 짜는 대신{' '} + vite-plugin-pwa에게 매니페스트와 + 서비스워커 생성을 맡길 수 있어요. 캐시 버전 관리 같은 골치 아픈 부분까지 자동으로요. +

+ {CODE_VITE_PWA} +

+ 순서를 정리하면 이렇습니다. ① 아이콘(192·512px PNG)을{' '} + public/icons/에 넣고 → ② 위 설정을{' '} + vite.config.js에 추가하고 → ③{' '} + npm run build 후{' '} + npm run preview로 확인 → 개발자 도구로 + 검증. 오프라인 테스트는 서버를 끄지 말고, 개발자 도구의 네트워크 탭에서 "오프라인"으로 + 바꿔 보면 됩니다. +

+
+ 직접 확인해 보기 크롬에서 F12 →{' '} + Application 탭을 여세요. 왼쪽에{' '} + ManifestService Workers{' '} + 항목이 있어요. 지금 우리 앱을 열면 둘 다 비어 있을 겁니다(섹션 2에서 확인했듯 아직 + PWA가 아니니까요). 플러그인을 붙이고 빌드한 뒤 다시 보면, 여기에 우리 앱의 명함과 + 문지기가 등록된 걸 눈으로 볼 수 있어요. +
+
+ 개발 서버에선 안 보일 수 있어요 서비스워커는 보통 npm run + dev가 아니라 빌드된 결과(build →{' '} + preview)에서 온전히 동작해요. "설치 버튼이 안 떠요"의 + 절반은 여기서 옵니다 — dev가 아니라{' '} + preview로 열었는지, 그리고 HTTPS(또는 localhost)인지 + 먼저 확인하세요. +
+
+ +
+
+

📱 여기까지 왔다면

+

+ 이제 "앱을 만들려면 새 언어를 배워야 한다"는 막연함 대신, PWA라는 가장 가까운 + 길의 지형을 읽을 수 있게 됐어요. 매니페스트(명함), 서비스워커(문지기), 푸시 + (알림 통로), 그리고 iOS의 벽까지 — 우리 React 웹앱을 앱처럼 만드는 재료와 한계를 + 함께 손에 쥐었습니다. 그런데 앱이 되어 설치되고 알림을 받는 순간, + 챙길 게 하나 더 생겨요. 토큰은 어디에 저장하고, 오프라인 캐시엔 뭘 남기면 안 되고, + 푸시 권한은 어떻게 다뤄야 안전할까요? 다음{' '} + 앱 보안 기초 코스에서, 앱이라는 + 새 껍데기에 맞는 보안 감각을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/courses/ReactNativePage.jsx b/frontend/src/pages/courses/ReactNativePage.jsx new file mode 100644 index 0000000..844b628 --- /dev/null +++ b/frontend/src/pages/courses/ReactNativePage.jsx @@ -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: '태그 이름'만 바뀜 + + {label} {count} + setCount(count + 1)}> + 누르기 + + + ); +} + +// 웹 React와 '개념'이 다른 곳을 찾아보세요 — 하나도 없습니다. +// onClick → onPress,
, ... +// 바뀐 건 '단어(태그 이름)'일 뿐, '문법(React)'은 그대로예요.`; + +const CODE_TAGS = `웹(HTML/React) React Native 진짜로 바뀌는 것(네이티브) +───────────────── ─────────────── ───────────────────────── +
UIView / ViewGroup +

글자 UILabel / TextView + UIImageView / ImageView + UITextField / EditText +