diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5e3108d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.java] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..320efed --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## 문제와 변경 후 동작 + +## 관련 이슈 + +실제 이슈가 있을 때 연결합니다. + +## 영향 범위와 공식 근거 + +Java 최소 버전·출처 및 공고 근거 변경 여부를 적습니다. + +## 실제 검증 결과 + +- 실행한 검사와 결과: +- 독립 검토 범위와 결과: +- 실행하지 못한 검사와 이유: + +## 남은 위험과 배포 + +병합·배포 여부 및 되돌리는 방법을 적습니다. diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..98a32e4 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,105 @@ +name: Verify Java Edu + +on: + push: + branches: [main, 'feat/**', 'fix/**'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + content: + name: Content integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npm run build + - run: npm run check + - run: npm run test:worker + java: + name: Java examples (${{ matrix.java }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: ['8', '11', '17', '21'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - run: npm run test:java + browser: + name: Browser QA + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run dev > /tmp/java-edu-preview.log 2>&1 & + - run: npm run test:browser + env: + QA_ARTIFACT_DIR: work/java-edu-qa + - run: npm run test:theory + env: + QA_ARTIFACT_DIR: work/theory-independent-qa + - run: npm run test:readability + env: + QA_ARTIFACT_DIR: work/readability-qa + professional: + name: Professional projects (${{ matrix.java }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: ['17', '21'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - name: Verify reference and unfinished starter projects + run: | + export MAVEN_HOME="$(dirname "$(dirname "$(readlink -f "$(command -v mvn)")")")" + node scripts/check-professional.mjs + local-windows: + name: Windows local runner (${{ matrix.java }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + java: ['8', '11', '17', '21'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '22' + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - run: npm run test:local + - name: Verify actual local JVM checks + shell: pwsh + run: | + Set-Item -Path 'Env:JAVA_EDU_JDK_${{ matrix.java }}' -Value $env:JAVA_HOME + node tests/local/java-smoke.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64b9a83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +work/ +*.class +.DS_Store +.env +.env.* +.openai/ +test-results/ +worker/assets.generated.mjs +dist/server/ +dist/.openai/ +dist/assets/editor.bundle.js +desktop-release/ +dist/downloads/ +dist/data/professional.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4a808eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Java Edu 작업 계약 + +대상은 다른 언어 경험자의 Java 전환 학습과 현직자 심화다. 완전 입문자 설명을 기본 과정으로 확대하지 않는다. 공식 Java 8/11/17/21 기준과 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 5단 수업 흐름을 유지한다. + +협업 규칙의 기준 문서는 CONTRIBUTING.md이다. 파일당 쓰기 소유자를 한 명으로 정한다. 콘텐츠, 프런트엔드, 조사 근거, 테스트를 독립 작업으로 나누고 의미 있는 구현은 독립 검토를 받는다. 기존 변경을 보존하며 추가 위임은 부모가 허용한 경우에만 한다. + +역할: 총괄은 범위·통합·검증 책임; 연구자는 공식 명세와 공고 원문 대조; 교육 설계자는 교육 데이터와 독립 Java 예제; 프런트엔드 담당자는 dist의 화면·상호작용; 독립 리뷰어는 정확성·보안·회귀; QA는 실제 기능·합성 화면 검사. 역할은 전문 자격을 의미하지 않는다. + +미지원 Java 버전이나 preview 문법을 정식 예제로 표시하지 않는다. 공고 관찰을 채용 통계로 만들지 않는다. 브라우저 퀴즈와 Java 실행 검증을 구분한다. 실제 실행하지 않은 테스트는 미실행으로 기록한다. 실행기 계약은 docs/architecture.md와 docs/local-execution.md를 따른다. 교재 기준·컴파일 대상·실제 실행 JVM을 분리해서 표시한다. 웹 코어 실행은 외부 Wandbox의 실제 JVM21을 사용하며 Java8/11/17 실제 JVM이라고 주장하지 않는다. Windows 설치형은 등록한 로컬 JDK를 사용하며 원격 제공자를 호출하지 않는다. 실무 교재의 정적 공개·기준 테스트 실행·설치형 통합 검증을 분리한다. Redis/Kafka 실제 연동은 선택 환경이며 미검증을 숨기지 않는다. 이 개인 IDE를 OS 보안 샌드박스로 표현하지 않는다. + +사용자는 마일스톤별 커밋·푸시와 완성된 프로토타입/서비스의 배포를 승인했다. origin은 jbsim999/java-edu, main과 단기 작업 브랜치를 사용한다. 강제 푸시·미검증 병합·공개 범위 변경·유료 자원 구매 권한은 만들지 않는다. GitHub 저장소는 공개이며 새 Sites 프로토타입은 기본 owner-private를 유지한다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..53dfbe1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# 협업 규칙 + +이 저장소의 기본 규칙이다. Junbo GitHub Conventions의 추천안을 본 프로젝트에 맞춰 적용했다. 기존 사용자 저장소의 정책이나 GitHub 보호 설정을 확인한 것으로 해석하지 않는다. + +## 변경 단위와 검토 + +- 기준 브랜치 `main`, 단기 작업 브랜치 `feat/java-learning-mvp`처럼 `type/description`. 실제 이슈가 있으면 `type/number-description`; 번호를 만들지 않는다. +- 커밋과 PR 제목은 `type(scope): 변경 내용`. 타입은 feat, fix, docs, test, refactor, perf, style, build, ci, chore. scope는 web, curriculum, evidence, examples, ci 중 실제 변경 대상에 맞게 사용한다. 이 목록은 팀의 추가 규칙이다. +- 하나의 PR은 하나의 목적을 갖는다. 구현 중에는 Draft PR을 사용한다. 중요 변경은 구현자 외 담당자의 독립 검토와 관련 검사 결과를 남긴다. 에이전트 검토를 사람의 GitHub 승인으로 꾸미지 않는다. +- `main` 강제 푸시와 기존 작업 되돌리기를 금지한다. 병합과 운영 배포는 사용자 승인 범위 안에서만 수행한다. PR 준비는 자동 병합 권한을 뜻하지 않는다. +- `[필수]`, `[제안]`, `[질문]`, `[사소]`로 리뷰 의도를 표시한다. 필수 문제 해결 후 해당 실패 조건을 다시 검사한다. + +## 교육 콘텐츠 기준 + +대상은 다른 언어로 프로그래밍 경험이 있는 Java 전환 학습자와 현직 개발자다. 완전 입문자 과정은 포함하지 않는다. 각 수업은 이론·예시·문제풀이·실무 심화·웹 IDE 직접 코딩을 모두 갖춘다. 클래스 골격형과 메서드 본문형을 학습 목표에 맞춰 사용한다. + +언어는 해당 버전 JLS, API는 API Specification과 Since, 런타임은 JVM 명세·공식 가이드를 기준으로 한다. 수업 `minVersion`은 해당 수업의 실행 예제를 지원하는 최소 대상 버전이다. 최초 도입 버전은 본문·호환표에서 별도로 밝힌다. preview/incubator는 기본 과정에서 제외한다. 버전별 예제 폴더와 기대 출력 manifest를 함께 변경한다. + +공고 근거는 URL·제목·기업·확인일·모집 상태·접근 범위·관찰 요건을 기록한다. 확인일은 게시일이 아니다. 공고를 특정한 표본에서 관찰한 기술이라고 표현하고 사용률이나 Java 버전 수요로 확대하지 않는다. 실무 과제는 교육 설계 제안이며 기업의 채용 과제가 아니다. + +## 실제 검사 + +다음은 구현 단계의 검사 계약이다. M1 문서 마일스톤에서는 이 검사를 실행한 것으로 표시하지 않는다. 실제 CI 정의·실행 결과는 후속 마일스톤에서 검증한다. + +`npm run check`: JS 구문과 교재/출처/공고/예제 데이터 정합성. CI 작업 이름 `Content integrity`. + +`npm run test:java`: 설치된 JDK에서 호환 예제를 컴파일하고 기대 출력과 비교한다. CI 작업 `Java examples (8/11/17/21)`에서 각 JDK를 독립 사용한다. `--release` 검사와 실제 해당 JDK 실행을 구분한다. + +`npm run test:browser`: 웹 기능과 화면의 브라우저 에뮬레이션 검사. CI 작업 `Browser QA`. 실제 휴대전화 테스트라고 기록하지 않는다. 테스트 명령이나 CI 정의가 존재하는 것과 실행 성공은 별개다. 상세 결과는 PR과 docs/verification.md에 남긴다. + +필수 브랜치 보호 설정은 아직 적용하지 않는다. CI 작업 이름을 위와 같이 정의했지만 GitHub에서 실제 검사 결과를 확인한 후 필수 검사 지정 여부를 결정한다. + +## 명명과 근거 + +식별자·브랜치는 영어, 설명·리뷰는 한국어. API, HTTP, SQL, URL, ID, JVM, JDK처럼 통용 약어를 사용하고 `usr`, `cnt`, `mgr` 같은 모호한 축약을 새 이름에 넣지 않는다. 기존 공개 식별자는 명명 규칙 때문에 바꾸지 않는다. + +표준 근거: [Conventional Commits 1.0.0](https://www.conventionalcommits.org/ko/v1.0.0/). 브랜치·scope·리뷰 표시는 프로젝트의 선택이다. 프로토타입 배포는 Sites의 owner-private 호스팅과 Worker API를 사용한다. GitHub 저장소 공개와 서비스 공개는 별개다. 지원하지 않는 프레임워크·DB 실습을 실행 가능으로 표시하지 않는다. diff --git a/README.md b/README.md index 22fd85b..2b179e6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,174 @@ -# java-edu -공식 Java 문서 기반 전환 학습과 Java 8·11·17·21 실무 심화 교육 +
+ +Java Edu — 공식 문서로 이해하고, 직접 코딩하며 배우는 Java + +# Java Edu + +**공식 문서로 이해하고, 직접 코딩하며 배우는 Java.** + +다른 언어 경험자의 Java 전환 학습부터 현직 개발자의 OOP·백엔드 심화까지. + +[![Java](https://img.shields.io/badge/Java-8%20%7C%2011%20%7C%2017%20%7C%2021-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) [![Windows](https://img.shields.io/badge/Desktop-Windows%20x64-315b45?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) [![Lessons](https://img.shields.io/badge/Lessons-34-806223?style=flat-square)](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) [![Status](https://img.shields.io/badge/Status-v0.3.1%20preview-806223?style=flat-square)](https://github.com/jbsim999/java-edu/issues) [![CI](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml/badge.svg?branch=feat%2Fjava-learning-mvp)](https://github.com/jbsim999/java-edu/actions/workflows/verify.yml?query=branch%3Afeat%2Fjava-learning-mvp) + +[교육 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [개발 소스](https://github.com/jbsim999/java-edu/tree/feat/java-learning-mvp) · [기여 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/CONTRIBUTING.md) · [버그·제안](https://github.com/jbsim999/java-edu/issues) + +
+ +## 소개 + +Java Edu는 **Windows에서 코드를 직접 실행하는 학습 앱과 웹 교재**를 함께 만드는 한국어 교육 프로젝트입니다. 다른 언어 경험자와 현직 Java 개발자가 “메서드에 객체를 넘겼는데 왜 결과가 달라질까?”, “트랜잭션을 붙였는데 왜 데이터가 남을까?” 같은 질문을 실제 코드로 확인하도록 돕습니다. + +수업은 **이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩** 순서로 진행합니다. Java 언어·표준 API는 Oracle/OpenJDK 공식 문서를, 프레임워크·라이브러리는 각 프로젝트의 공식 문서를 기준으로 합니다. 실무 주제에는 출처를 기록한 채용 공고의 기술 요건도 연결합니다. + +> **개발 상태** — 전체 34수업과 Windows 앱 소스는 `feat/java-learning-mvp` 브랜치에 있습니다. `main`은 프로젝트 소개를 제공합니다. [Windows v0.3.1 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)를 설치해 학습할 수 있습니다. [호스팅 웹](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)은 소유자 전용이며 34수업·이론 도식 12개·편집기 테마가 배포돼 있습니다. + +## 설치하고 시작하기 + +**Windows x64용입니다. 아래에서 하나만 다운로드하세요.** + +**[설치 EXE 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.1/Java-Edu-Setup-0.3.1-x64.exe)** · **[설치 없이 사용하는 ZIP 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.1/Java-Edu-0.3.1-x64.zip)** + +1. **EXE:** 다운로드한 파일을 실행해 설치한 뒤 **Java Edu**를 엽니다. **ZIP:** 폴더 전체를 압축 해제한 뒤 `Java Edu.exe`를 엽니다. +2. 앱에서 수업을 선택하고 **이론 → 예시 → 문제 → 실무 → 코드 실습** 순서로 진행합니다. +3. Spring 실습은 **환경 준비**를 한 번 진행한 뒤 코드를 작성하고 실행·제출합니다. + +Java·Maven·Node.js가 포함돼 있어 따로 설치하거나 폴더를 등록할 필요가 없습니다. Spring 실습의 첫 환경 준비에는 인터넷이 필요합니다. ZIP 안의 EXE만 따로 옮기지 마세요. + +[자세한 설치 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) · [릴리스와 체크섬](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) + +## 학습실 미리보기 + +![Java Edu 학습실: 수업 목록, 과제 설명, Java 문법 색상과 직접 코딩 편집기](docs/assets/learning-lab.png) + +*개발 중인 공용 학습실의 실제 로컬 웹 화면입니다. 화면 캡처를 실행·제출 성공 근거로 사용하지 않습니다.* + +## 주요 기능 + +| 기능 | 학습 경험 | +| --- | --- | +| 공식 문서 기반 과정 | Java 8·11·17·21의 언어·API 차이와 공식 출처 확인 | +| 깊이 있는 OOP | 객체의 책임·불변식, 다형성·합성, 동등성·변경 계약 탐구 | +| 실제 코드로 이해하기 | 34수업의 상황·용어·원문 코드·값 변화·결과 예측 해설 | +| 넓은 수업 본문 | 반복 안내는 펼쳐 보고, 화면 크기에 맞춰 본문과 코드 폭 확보 | +| 검수한 이론 도식 | 참조·상태·호출·보안 조건을 설명하는 12개 도식과 공식 참고자료 | +| 직접 코딩 | 클래스 골격, 메서드 본문, 다중 파일 프로젝트에 코드 작성 | +| 읽기 쉬운 편집기 | 클래스·타입·변수·메서드 문법 색상, 다크·라이트·고대비 테마 | +| 로컬 Java 실행 | 별도 설치 없이 포함된 JDK 21로 컴파일·실행·제출·취소 | +| 실무 프로젝트 | Spring/H2·REST·MyBatis·JPA·보안 과제를 고정 테스트로 확인 | +| 이어서 학습 | 진도·메모·코드 초안·테마 저장 및 앱 재시작 후 복원 | + +## 교육 과정 + +**Java 코어 12수업 + 실무 22수업 · 개념 문제 68문항 · 코딩 과제 34개** + +| 과정 | 주요 주제 | +| --- | --- | +| Java 사고 모델 | 값 전달·객체 동등성, OOP·불변식·다형성·합성 | +| 언어와 표준 API | 컬렉션·제네릭·예외·Stream·날짜/시간, 동시성, 최신 Java | +| 빌드와 테스트 | Maven, JUnit, AssertJ, Mockito, Lombok | +| Spring과 HTTP | DI·AOP, MVC·REST, JSON·입력 검증 | +| 데이터와 일관성 | JDBC·MyBatis·JPA, Flyway, 트랜잭션·동시 수정 | +| 보안과 운영 | CSRF, OAuth2/OIDC의 인증 주체, JWT 검증, 관측·빌드/배포 비교 | +| 선택 과정과 캡스톤 | Redis 캐시, Kafka 멱등성, 주문 API 통합 프로젝트 | + +기본 실무 20프로젝트는 H2·로컬 테스트를 사용합니다. Redis/Kafka 두 단원은 별도 선택 환경이며 실제 서비스 연동은 아직 검증하지 않았습니다. OIDC 수업은 인증 주체 처리에 집중하고 전체 로그인 프로토콜 구현은 다루지 않습니다. [전체 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [실무 과정 상세](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/professional-curriculum.md) + +## 개발 소스로 실행하기 + +**개발 소스 실행:** Node.js 22 이상이 필요합니다. + +```sh +git clone --branch feat/java-learning-mvp https://github.com/jbsim999/java-edu.git +cd java-edu +npm ci +``` + +**웹 교재 실행** + +```sh +npm run dev +``` + +브라우저에서 `http://127.0.0.1:4173`을 엽니다. 실무 코드는 웹에서 편집하고 프로젝트 ZIP을 내려받을 수 있습니다. Spring 프로젝트 실행은 Windows 앱 또는 로컬 IDE에서 진행합니다. + +**Windows 학습 앱 실행** + +```sh +npm run desktop +``` + +개발 실행에 번들을 준비하지 않았다면 앱에서 자신의 JDK/Maven을 등록합니다. 배포 패키지는 도구를 자동 등록하며, 학습 코드 실행에는 명시적인 동의가 필요합니다. 실무 과제는 **환경 준비 → 파일 편집 → 실행·제출** 순서로 진행합니다. [Windows 사용 안내](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installation.md) + +
+Windows 설치 파일 직접 빌드 + +```sh +node scripts/bundle-runtime.mjs --jdk21 --maven --metadata desktop/toolchain-provenance.json +npm run check:bundle +npm run build:desktop +``` + +공식 원본 ZIP의 체크섬을 대조하고 전체 배포 폴더를 준비한 뒤 빌드합니다. `desktop-release/`에 Windows x64 NSIS 설치 EXE와 전체 ZIP을 생성합니다. 직접 재빌드한 파일은 배포 파일과 별도로 검증해야 합니다. [번들·출처·대응 소스 계약](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-runtime.md) · [배포 파일과 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) + +
+ +### 실행 방식과 버전 + +| 구분 | Windows 앱 | 웹 교재 | +| --- | --- | --- | +| Java 코어 | 기본 포함 JVM 21, 컴파일 대상 8·11·17·21; 다른 실제 JDK는 선택 등록 | Wandbox의 JVM 21, 과제별 최소 지원 버전으로 컴파일 | +| Spring 프로젝트 | 컴파일 대상 17, 포함 JDK 21·Maven 3.10.0 사용 | 교재·파일 편집·ZIP 다운로드 | +| 코드 전송 | 작성 코드를 Wandbox로 전송하지 않음 | 코어 실행·제출 시 Wandbox로 전송 | +| 학습 기록 | 앱 데이터에 저장 | 해당 브라우저에 저장 | + +웹과 앱 간 자동 동기화는 제공하지 않습니다. 실무 첫 환경 준비에는 앱 내부의 의존성 다운로드가 필요하며, 처음부터 모든 실무가 완전 오프라인인 패키지는 아닙니다. 실제 JDK 8·11·17 추가 등록은 고급 설정의 선택 기능입니다. [로컬 실행 계약](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/local-execution.md) + +## 개발과 검증 + +| 명령 | 목적 | +| --- | --- | +| `npm run check` | 콘텐츠·API·구문 정합성 검사 | +| `npm run test:java` | 설치된 JDK의 Java 예제·답안 검사 | +| `npm run test:local` | 로컬 실행·Windows 프로세스 종료 계약 검사 | +| `npm run test:professional` | JDK/Maven에서 실무 기준 구현과 미완성 코드 비교 | +| `npm run test:browser` | 실행 중인 로컬 서버의 모의 API 브라우저 검사 | +| `npm run test:theory` | 이론 도식·원문 보존·키보드·반응형 검사 | +| `npm run build` / `npm run test:worker` | 웹 Worker 빌드·산출물 검사 | + +실무 검사는 `JAVA_HOME`·`MAVEN_HOME` 설정이 필요합니다. 기본 검사는 Redis/Kafka를 제외합니다. 브라우저 검사는 실행 제공자의 실제 동작 검증과 구분합니다. + +2026-10-13 기준, v0.3.1 앱 소스 `6ce0428`의 [CI 12작업](https://github.com/jbsim999/java-edu/actions/runs/38338837092)이 모두 성공했습니다. 독립 검수에서 읽기 안내 **24/24**, 기존 기능 **49/49**, 도식 **19/19**, 정적 검사 **5/5**가 통과했고 새 EXE·ZIP의 전체 파일과 포함 도구 604개를 대조했습니다. 사용 중인 앱과 학습 기록을 보존하기 위해 **신버전의 실제 설치·기동 검사는 미실행**입니다. 이전 v0.3.0 설치·제거 검증과 구분합니다. [이번 검증 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/readability-release-qa-report.md) · [검증 기록](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/verification.md) + +## 문서와 로드맵 + +| 찾는 내용 | 문서 | +| --- | --- | +| 서비스의 목표와 학습 대상 | [기획안](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/product-plan.md) | +| 학습 순서와 Java 버전 | [교육 로드맵](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/education-roadmap.md) · [호환 기준](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/java-version-matrix.md) | +| 코어·실무 교재 | [Java 코어](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/curriculum.md) · [실무 22단원](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/professional-curriculum.md) | +| 이론 도식의 선정과 검수 | [34수업 평가·12개 채택 근거](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/theory-visuals.md) · [독립 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/theory-visual-qa-report.md) | +| 채용 공고 조사 근거 | [출처·표본·조사 범위](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/job-evidence.md) | +| 구조와 실행 경계 | [아키텍처](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/architecture.md) | +| 통합 ZIP·설치 앱 검증 | [번들 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-desktop-qa-report.md) · [이전 0.2 설치 QA](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/installed-desktop-qa-report.md) | +| 현재 작업·출시 상태 | [마일스톤](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/milestones.md) · [개발 인계](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/development-handoff.md) | + +- [x] Java 코어 12수업과 직접 코딩 과제 +- [x] Java 문법 강조·3테마·다중 파일 편집 +- [x] Windows 로컬 JDK/Maven 실행과 진도 저장 구현 +- [x] 실무 22수업과 기본 20프로젝트의 기준 검증 +- [x] 이론 도식 12개와 공식 출처·텍스트 설명 +- [x] Windows CI·설치/제거 검증·최종 시각 검수 +- [x] 34수업·이론 도식 웹 교재 배포 +- [x] Java·Maven 포함 Windows EXE와 전체 ZIP 패키징 +- [x] [Windows v0.3.1 통합 프리뷰 릴리스](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) +- [ ] 선택 과정의 Redis/Kafka 실제 연동 검증 + +## 기여와 문의 + +교육 내용의 오류, 공식 문서와 다른 설명, 실행 문제와 개선 아이디어는 [Issues](https://github.com/jbsim999/java-edu/issues)에 남겨 주세요. 실행 문제는 OS·JDK·Maven 버전, 단원, 재현 순서와 오류 메시지를 포함하면 확인에 도움이 됩니다. + +기여 전 [CONTRIBUTING](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/CONTRIBUTING.md)을 읽어 주세요. 공식 출처와 버전별 근거를 함께 기록하고 실제 실행한 검사와 미검증 범위를 구분합니다. 유지관리자: [jbsim999](https://github.com/jbsim999). + +## 라이선스 + +프로젝트 코드·교재의 라이선스는 아직 지정하지 않았습니다. 편집기·JDK·Maven과 포함된 제3자 구성 요소의 고지를 원본대로 보존하며 각 조건을 따릅니다. 대응 JDK·빌드·제3자 소스도 같은 릴리스에서 제공합니다. 학습자는 EXE 또는 ZIP 하나만 받으면 됩니다. [출처와 소스](https://github.com/jbsim999/java-edu/blob/feat/java-learning-mvp/docs/bundled-runtime.md) diff --git a/curriculum/professional/README.md b/curriculum/professional/README.md new file mode 100644 index 0000000..204fa50 --- /dev/null +++ b/curriculum/professional/README.md @@ -0,0 +1,14 @@ +# 실무 프로젝트 원본 + +공통 부모 POM과 22개 독립 학습 프로젝트입니다. 설계·선수 관계·프로필·실행 범위는 [실무 교육 과정](../../docs/professional-curriculum.md)을 읽으세요. + +- catalog.json: 22개 수업의 경로, profileId, exerciseId, editableFiles, files, testFiles +- profiles.json: Java 17 대상, Java 17+ 실행 요구, 의존성과 외부 자원 요구 +- lessons//lesson.json: 이론·예시·문제·실무·직접 코딩 계약 +- lessons//reference: 공개 기준 구현 +- lessons//starter: 출발 코드 +- lessons//grader: 공개 행동 검사 + +source-prepared와 executionVerified는 다릅니다. 실제 실행 결과는 별도 검증 기록과 연결해야 합니다. Redis/Kafka 자원이 없거나 설치형에서 미지원이면 통합 실습은 실행되지 않습니다. + +이 디렉터리 전체 구조를 보존하세요. 각 reference/starter POM은 공통 pom.xml 및 형제 grader를 참조합니다. 실제 학습 실행은 Windows 설치형의 환경 준비와 고정 Maven 경로를 사용합니다. 원격 실행 서버로 코드나 실제 자격증명을 보내지 않습니다. diff --git a/curriculum/professional/catalog.json b/curriculum/professional/catalog.json new file mode 100644 index 0000000..0bb569a --- /dev/null +++ b/curriculum/professional/catalog.json @@ -0,0 +1,1204 @@ +{ + "schemaVersion": 1, + "checkedAt": "2026-10-12", + "status": "source-prepared", + "lessons": [ + { + "id": "build-maven", + "order": 1, + "title": "Maven으로 재현 가능한 주문 계산 빌드", + "profileId": "professional-junit", + "prerequisites": [ + "core:oop", + "core:testing-design" + ], + "lessonPath": "lessons/build-maven/lesson.json", + "referenceDir": "lessons/build-maven/reference", + "starterDir": "lessons/build-maven/starter", + "graderDir": "lessons/build-maven/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrderAmounts.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/OrderAmounts.java", + "templatePath": "lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java", + "referencePath": "lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/build-maven/starter/pom.xml", + "referencePath": "lessons/build-maven/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "build-maven-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "a9d4d663d97d617409faa0998490cce985e0199084dc3fa55e42351f452d2405", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "test-contracts", + "order": 2, + "title": "JUnit·AssertJ로 경계와 상태 전이 검증", + "profileId": "professional-junit", + "prerequisites": [ + "build-maven" + ], + "lessonPath": "lessons/test-contracts/lesson.json", + "referenceDir": "lessons/test-contracts/reference", + "starterDir": "lessons/test-contracts/starter", + "graderDir": "lessons/test-contracts/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/Reservation.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/Reservation.java", + "templatePath": "lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java", + "referencePath": "lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/test-contracts/starter/pom.xml", + "referencePath": "lessons/test-contracts/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "test-contracts-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "cbdeec57521987b40feed67ef6c3af7d4964f6b8804b51c5abae9447dc147028", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "test-collaborators", + "order": 3, + "title": "Mockito로 외부 협력의 실패를 통제", + "profileId": "professional-mockito", + "prerequisites": [ + "test-contracts", + "core:polymorphism" + ], + "lessonPath": "lessons/test-collaborators/lesson.json", + "referenceDir": "lessons/test-collaborators/reference", + "starterDir": "lessons/test-collaborators/starter", + "graderDir": "lessons/test-collaborators/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/Checkout.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/Checkout.java", + "templatePath": "lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java", + "referencePath": "lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/test-collaborators/starter/pom.xml", + "referencePath": "lessons/test-collaborators/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java" + }, + { + "path": "src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker", + "sourcePath": "lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker" + } + ], + "exerciseId": "test-collaborators-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "9b36daa6c1fdc7557702c6825819f8b92f1384bb562f8b95e8b9f79368bab733", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "lombok-values", + "order": 4, + "title": "Lombok 생성 코드와 동등성의 범위", + "profileId": "professional-lombok", + "prerequisites": [ + "test-contracts", + "core:collections" + ], + "lessonPath": "lessons/lombok-values/lesson.json", + "referenceDir": "lessons/lombok-values/reference", + "starterDir": "lessons/lombok-values/starter", + "graderDir": "lessons/lombok-values/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrderKey.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/OrderKey.java", + "templatePath": "lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java", + "referencePath": "lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/lombok-values/starter/pom.xml", + "referencePath": "lessons/lombok-values/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "lombok-values-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "65bcae5900047877a822e89821af9b5ac4a511c1b6a634d002757f4cee5efa9a", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "spring-di", + "order": 5, + "title": "Spring IoC·DI와 객체 수명", + "profileId": "professional-spring-core", + "prerequisites": [ + "test-collaborators" + ], + "lessonPath": "lessons/spring-di/lesson.json", + "referenceDir": "lessons/spring-di/reference", + "starterDir": "lessons/spring-di/starter", + "graderDir": "lessons/spring-di/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/DiLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/DiLab.java", + "templatePath": "lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java", + "referencePath": "lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/spring-di/starter/pom.xml", + "referencePath": "lessons/spring-di/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "spring-di-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "092bd10703609e7a73d032f092ed2ace7c53fdf37819c64a4c41504b156ed844", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "spring-aop", + "order": 6, + "title": "프록시·AOP 호출 경로의 반례", + "profileId": "professional-spring-aop", + "prerequisites": [ + "spring-di" + ], + "lessonPath": "lessons/spring-aop/lesson.json", + "referenceDir": "lessons/spring-aop/reference", + "starterDir": "lessons/spring-aop/starter", + "graderDir": "lessons/spring-aop/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/AopLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/AopLab.java", + "templatePath": "lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java", + "referencePath": "lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/spring-aop/starter/pom.xml", + "referencePath": "lessons/spring-aop/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "spring-aop-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "203bd280bfe19ad5cbaef8117c8ab0d00b95d07fe7f1a1f2f5988a187b82bad9", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "mvc-rest", + "order": 7, + "title": "MVC·REST의 상태 코드와 자원 계약", + "profileId": "professional-spring-web", + "prerequisites": [ + "spring-di" + ], + "lessonPath": "lessons/mvc-rest/lesson.json", + "referenceDir": "lessons/mvc-rest/reference", + "starterDir": "lessons/mvc-rest/starter", + "graderDir": "lessons/mvc-rest/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrdersController.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/OrdersController.java", + "templatePath": "lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java", + "referencePath": "lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/mvc-rest/starter/pom.xml", + "referencePath": "lessons/mvc-rest/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "mvc-rest-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "cf818104cfb16da2cfc56a8a887429f7354bfbaf23b2edb5db949abe45065bdf", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "json-validation", + "order": 8, + "title": "Jackson·Validation의 입력 경계", + "profileId": "professional-spring-web", + "prerequisites": [ + "mvc-rest" + ], + "lessonPath": "lessons/json-validation/lesson.json", + "referenceDir": "lessons/json-validation/reference", + "starterDir": "lessons/json-validation/starter", + "graderDir": "lessons/json-validation/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/CreateOrderController.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/CreateOrderController.java", + "templatePath": "lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java", + "referencePath": "lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/json-validation/starter/pom.xml", + "referencePath": "lessons/json-validation/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "json-validation-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "cb2dd529c781109c07cf6272989e94ab712c38e29484390922d7e38fb4f046b9", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "sql-jdbc", + "order": 9, + "title": "SQL·JDBC로 재고 변경과 롤백", + "profileId": "professional-jdbc", + "prerequisites": [ + "test-contracts", + "core:exceptions" + ], + "lessonPath": "lessons/sql-jdbc/lesson.json", + "referenceDir": "lessons/sql-jdbc/reference", + "starterDir": "lessons/sql-jdbc/starter", + "graderDir": "lessons/sql-jdbc/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/JdbcReservations.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/JdbcReservations.java", + "templatePath": "lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java", + "referencePath": "lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/sql-jdbc/starter/pom.xml", + "referencePath": "lessons/sql-jdbc/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "sql-jdbc-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "408fed487770c066b90e1e7c078dc734c57e66818a2bfeb14f65d66372dc02ba", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "mybatis-mapping", + "order": 10, + "title": "MyBatis 파라미터 바인딩과 XML 매핑", + "profileId": "professional-mybatis", + "prerequisites": [ + "sql-jdbc" + ], + "lessonPath": "lessons/mybatis-mapping/lesson.json", + "referenceDir": "lessons/mybatis-mapping/reference", + "starterDir": "lessons/mybatis-mapping/starter", + "graderDir": "lessons/mybatis-mapping/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/resources/edu/lab/OrderMapper.xml" + ], + "files": [ + { + "path": "src/main/java/edu/lab/MyBatisLab.java", + "templatePath": "lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java", + "referencePath": "lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/mybatis-mapping/starter/pom.xml", + "referencePath": "lessons/mybatis-mapping/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/edu/lab/OrderMapper.xml", + "templatePath": "lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml", + "referencePath": "lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "mybatis-mapping-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "127b6b7a7bdcf465e0b3b55b32791f14bef3accc1f246aa163497b27102bb36c", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "jpa-state", + "order": 11, + "title": "JPA·Hibernate의 상태와 변경 감지", + "profileId": "professional-jpa", + "prerequisites": [ + "sql-jdbc", + "lombok-values" + ], + "lessonPath": "lessons/jpa-state/lesson.json", + "referenceDir": "lessons/jpa-state/reference", + "starterDir": "lessons/jpa-state/starter", + "graderDir": "lessons/jpa-state/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrderEntity.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/JpaLab.java", + "templatePath": "lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java", + "referencePath": "lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/jpa-state/starter/pom.xml", + "referencePath": "lessons/jpa-state/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "templatePath": "lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml", + "referencePath": "lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml", + "editable": false + }, + { + "path": "src/main/java/edu/lab/OrderEntity.java", + "templatePath": "lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java", + "referencePath": "lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "jpa-state-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "c721dc8a92215ad6b837e104656f0ba8defb5781844f845447f5194a1737332c", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "flyway-migrations", + "order": 12, + "title": "Flyway로 스키마 변경을 기록", + "profileId": "professional-flyway", + "prerequisites": [ + "sql-jdbc" + ], + "lessonPath": "lessons/flyway-migrations/lesson.json", + "referenceDir": "lessons/flyway-migrations/reference", + "starterDir": "lessons/flyway-migrations/starter", + "graderDir": "lessons/flyway-migrations/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/resources/db/migration/V2__request_key.sql" + ], + "files": [ + { + "path": "src/main/java/edu/lab/MigrationLab.java", + "templatePath": "lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java", + "referencePath": "lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/flyway-migrations/starter/pom.xml", + "referencePath": "lessons/flyway-migrations/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V1__create_orders.sql", + "templatePath": "lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql", + "referencePath": "lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V2__request_key.sql", + "templatePath": "lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql", + "referencePath": "lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "flyway-migrations-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 1, + "starterSatisfied": false, + "sourceFingerprint": "f26b6d4349c223d9c0ee58c03a190193431592dd68baa2cdec3775fcc42ecd6f", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "tx-proxy", + "order": 13, + "title": "Spring 트랜잭션 프록시와 롤백 경계", + "profileId": "professional-spring-tx", + "prerequisites": [ + "spring-aop", + "sql-jdbc" + ], + "recommendedOneOf": [ + "mybatis-mapping", + "jpa-state" + ], + "lessonPath": "lessons/tx-proxy/lesson.json", + "referenceDir": "lessons/tx-proxy/reference", + "starterDir": "lessons/tx-proxy/starter", + "graderDir": "lessons/tx-proxy/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/TransactionLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/TransactionLab.java", + "templatePath": "lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java", + "referencePath": "lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/tx-proxy/starter/pom.xml", + "referencePath": "lessons/tx-proxy/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "tx-proxy-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "bec5b6f575b6d3e3a8875aa5cd5db2b14f6e5e84f99e76bc0a8c435e0339a0ab", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "optimistic-cancel", + "order": 14, + "title": "낙관적 잠금과 중복 취소의 의미", + "profileId": "professional-jpa", + "prerequisites": [ + "jpa-state", + "tx-proxy" + ], + "lessonPath": "lessons/optimistic-cancel/lesson.json", + "referenceDir": "lessons/optimistic-cancel/reference", + "starterDir": "lessons/optimistic-cancel/starter", + "graderDir": "lessons/optimistic-cancel/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/CancellableOrder.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/CancellationLab.java", + "templatePath": "lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java", + "referencePath": "lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/optimistic-cancel/starter/pom.xml", + "referencePath": "lessons/optimistic-cancel/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "templatePath": "lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml", + "referencePath": "lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml", + "editable": false + }, + { + "path": "src/main/java/edu/lab/CancellableOrder.java", + "templatePath": "lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java", + "referencePath": "lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "optimistic-cancel-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "e5e66d061683ca073cec3b47255908664a8be1efbe2f2223808aa499c2b23ec0", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "security-csrf", + "order": 15, + "title": "Spring Security의 인증·권한·CSRF", + "profileId": "professional-security", + "prerequisites": [ + "json-validation" + ], + "lessonPath": "lessons/security-csrf/lesson.json", + "referenceDir": "lessons/security-csrf/reference", + "starterDir": "lessons/security-csrf/starter", + "graderDir": "lessons/security-csrf/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/SecurityLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/SecurityLab.java", + "templatePath": "lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java", + "referencePath": "lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/security-csrf/starter/pom.xml", + "referencePath": "lessons/security-csrf/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "security-csrf-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "cad4dad0bbd2cd397625790a2880f107777c9222fcaf45fa9ef39664f99e47a4", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "oidc-identity", + "order": 16, + "title": "OAuth2·OIDC의 역할과 계정 식별 경계", + "profileId": "professional-oidc-fixture", + "prerequisites": [ + "security-csrf" + ], + "lessonPath": "lessons/oidc-identity/lesson.json", + "referenceDir": "lessons/oidc-identity/reference", + "starterDir": "lessons/oidc-identity/starter", + "graderDir": "lessons/oidc-identity/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/IdentityLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/IdentityLab.java", + "templatePath": "lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java", + "referencePath": "lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/oidc-identity/starter/pom.xml", + "referencePath": "lessons/oidc-identity/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "oidc-identity-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "efb7ff61b59c5dc1f7d5aa5c28fb1d966c164b72dc6c9af2dda2b586d2335cd0", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "jwt-validation", + "order": 17, + "title": "JWT 서명·발급자·대상·만료 검증", + "profileId": "professional-jwt-local", + "prerequisites": [ + "security-csrf" + ], + "lessonPath": "lessons/jwt-validation/lesson.json", + "referenceDir": "lessons/jwt-validation/reference", + "starterDir": "lessons/jwt-validation/starter", + "graderDir": "lessons/jwt-validation/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/JwtLab.java" + ], + "files": [ + { + "path": "src/main/java/edu/lab/JwtLab.java", + "templatePath": "lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java", + "referencePath": "lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/jwt-validation/starter/pom.xml", + "referencePath": "lessons/jwt-validation/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "exerciseId": "jwt-validation-project", + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 5, + "starterSatisfied": false, + "sourceFingerprint": "d38b26b60eefc5c0ff1a5de48f9e990ae37322631559da4c24fc6f1e7f4574c4", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "redis-cache", + "order": 18, + "title": "Redis TTL·무효화·키 소유권", + "profileId": "professional-redis", + "prerequisites": [ + "tx-proxy" + ], + "lessonPath": "lessons/redis-cache/lesson.json", + "referenceDir": "lessons/redis-cache/reference", + "starterDir": "lessons/redis-cache/starter", + "graderDir": "lessons/redis-cache/grader", + "sourceStatus": "source-prepared", + "executionVerified": false, + "webPublication": "static-only-after-content-review", + "editableFiles": [ + "src/main/java/edu/lab/StockCache.java" + ], + "exerciseId": "redis-cache-project", + "files": [ + { + "path": "src/main/java/edu/lab/StockCache.java", + "templatePath": "lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java", + "referencePath": "lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/redis-cache/starter/pom.xml", + "referencePath": "lessons/redis-cache/reference/pom.xml", + "editable": false + }, + { + "path": "FIXTURE.md", + "templatePath": "lessons/redis-cache/starter/FIXTURE.md", + "referencePath": "lessons/redis-cache/reference/FIXTURE.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java" + } + ] + }, + { + "id": "kafka-idempotency", + "order": 19, + "title": "Kafka 재전달과 DB 멱등 처리", + "profileId": "professional-kafka", + "prerequisites": [ + "tx-proxy", + "flyway-migrations" + ], + "lessonPath": "lessons/kafka-idempotency/lesson.json", + "referenceDir": "lessons/kafka-idempotency/reference", + "starterDir": "lessons/kafka-idempotency/starter", + "graderDir": "lessons/kafka-idempotency/grader", + "sourceStatus": "source-prepared", + "executionVerified": false, + "webPublication": "static-only-after-content-review", + "editableFiles": [ + "src/main/java/edu/lab/EventHandler.java" + ], + "exerciseId": "kafka-idempotency-project", + "files": [ + { + "path": "src/main/java/edu/lab/EventHandler.java", + "templatePath": "lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java", + "referencePath": "lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/kafka-idempotency/starter/pom.xml", + "referencePath": "lessons/kafka-idempotency/reference/pom.xml", + "editable": false + }, + { + "path": "FIXTURE.md", + "templatePath": "lessons/kafka-idempotency/starter/FIXTURE.md", + "referencePath": "lessons/kafka-idempotency/reference/FIXTURE.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java" + } + ] + }, + { + "id": "observability", + "order": 20, + "title": "Micrometer 지표와 관측 실패 신호", + "profileId": "professional-observability", + "prerequisites": [ + "spring-di", + "test-collaborators" + ], + "lessonPath": "lessons/observability/lesson.json", + "referenceDir": "lessons/observability/reference", + "starterDir": "lessons/observability/starter", + "graderDir": "lessons/observability/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/OrderTelemetry.java" + ], + "exerciseId": "observability-project", + "files": [ + { + "path": "src/main/java/edu/lab/OrderTelemetry.java", + "templatePath": "lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java", + "referencePath": "lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/observability/starter/pom.xml", + "referencePath": "lessons/observability/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/observability/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 1, + "starterSatisfied": false, + "sourceFingerprint": "92e8fff2dd813d24d2a77243bf640196e2bbabcde8ec61f95343ce9e953a3c2e", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "build-gradle-ci", + "order": 21, + "title": "Gradle·Docker·CI의 재현 조건", + "profileId": "professional-gradle-ci", + "prerequisites": [ + "build-maven", + "test-contracts" + ], + "lessonPath": "lessons/build-gradle-ci/lesson.json", + "referenceDir": "lessons/build-gradle-ci/reference", + "starterDir": "lessons/build-gradle-ci/starter", + "graderDir": "lessons/build-gradle-ci/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/BuildMain.java" + ], + "exerciseId": "build-gradle-ci-project", + "files": [ + { + "path": "src/main/java/edu/lab/BuildMain.java", + "templatePath": "lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java", + "referencePath": "lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/build-gradle-ci/starter/pom.xml", + "referencePath": "lessons/build-gradle-ci/reference/pom.xml", + "editable": false + }, + { + "path": "settings.gradle", + "templatePath": "lessons/build-gradle-ci/starter/settings.gradle", + "referencePath": "lessons/build-gradle-ci/reference/settings.gradle", + "editable": false + }, + { + "path": "build.gradle", + "templatePath": "lessons/build-gradle-ci/starter/build.gradle", + "referencePath": "lessons/build-gradle-ci/reference/build.gradle", + "editable": false + }, + { + "path": "Dockerfile", + "templatePath": "lessons/build-gradle-ci/starter/Dockerfile", + "referencePath": "lessons/build-gradle-ci/reference/Dockerfile", + "editable": false + }, + { + "path": "deployment-examples/ci.yml", + "templatePath": "lessons/build-gradle-ci/starter/deployment-examples/ci.yml", + "referencePath": "lessons/build-gradle-ci/reference/deployment-examples/ci.yml", + "editable": false + }, + { + "path": "BUILD-COMPARISON.md", + "templatePath": "lessons/build-gradle-ci/starter/BUILD-COMPARISON.md", + "referencePath": "lessons/build-gradle-ci/reference/BUILD-COMPARISON.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "62e994695134b10e2f7284eebd74a5673eb2f1f2359028b85b22661f2a25b4ff", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + }, + { + "id": "orders-capstone", + "order": 22, + "title": "주문·예약·취소 통합 계약", + "profileId": "professional-capstone", + "prerequisites": [ + "json-validation", + "tx-proxy", + "security-csrf", + "flyway-migrations" + ], + "lessonPath": "lessons/orders-capstone/lesson.json", + "referenceDir": "lessons/orders-capstone/reference", + "starterDir": "lessons/orders-capstone/starter", + "graderDir": "lessons/orders-capstone/grader", + "sourceStatus": "reference-verified", + "executionVerified": true, + "webPublication": "reviewed-static", + "editableFiles": [ + "src/main/java/edu/lab/CapstoneLab.java" + ], + "exerciseId": "orders-capstone-project", + "files": [ + { + "path": "src/main/java/edu/lab/CapstoneLab.java", + "templatePath": "lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java", + "referencePath": "lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/orders-capstone/starter/pom.xml", + "referencePath": "lessons/orders-capstone/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/application.properties", + "templatePath": "lessons/orders-capstone/starter/src/main/resources/application.properties", + "referencePath": "lessons/orders-capstone/reference/src/main/resources/application.properties", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V1__orders.sql", + "templatePath": "lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql", + "referencePath": "lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 6, + "starterSatisfied": false, + "sourceFingerprint": "da15b7d861d2b0be337ec2cb9e5a67f8f72e089b444043a84e821298bf159431", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } + } + ] +} diff --git a/curriculum/professional/lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..1943ca3 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,10 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void businessContract() { assertThat(BuildMain.total(1)).isEqualTo(19900); assertThat(BuildMain.total(2)).isEqualTo(39800); } + @Test void rejectsInvalidAndOverflow() { + assertThatThrownBy(() -> BuildMain.total(0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BuildMain.total(Integer.MAX_VALUE)).isInstanceOf(ArithmeticException.class); + } +} diff --git a/curriculum/professional/lessons/build-gradle-ci/lesson.json b/curriculum/professional/lessons/build-gradle-ci/lesson.json new file mode 100644 index 0000000..63ab556 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/lesson.json @@ -0,0 +1,280 @@ +{ + "id": "build-gradle-ci", + "title": "Gradle·Docker·CI의 재현 조건", + "description": "테스트는 통과했는데 컨테이너가 실행할 JAR를 찾지 못할 수 있습니다. 같은 업무 코드를 검사하는 빌드 설정, 만들어진 파일, 실제 실행 JVM을 이어서 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "BuildMain.total의 수량 검사와 19,900원 곱셈이 빌드 도구가 바뀌어도 같은 업무 기준이어야 함을 설명한다.", + "0 이하 수량과 int 범위를 넘는 곱셈이 각각 어디서 실패하는지 찾는다.", + "Maven과 Gradle이 같은 grader 테스트를 읽는 경로를 설명한다.", + "Java 17 컴파일 대상과 Dockerfile의 Java 21 실행 환경을 구분한다.", + "Maven 기준 검사와 아직 실행하지 않은 Gradle·Docker·CI 비교 자료를 구분한다." + ], + "learningGuide": { + "scenario": "BuildMain의 계산 테스트를 통과한 뒤 컨테이너로 실행하려 합니다. Dockerfile에는 Java 21이 보이고 수업의 컴파일 대상은 Java 17입니다.", + "question": "이 파일은 무엇을 빌드하고 무엇을 실행하며, 기본 수량은 어디서 정할까요?", + "terms": [ + { + "term": "JAR", + "meaning": "컴파일한 Java 클래스 등을 묶은 파일입니다. 이 Dockerfile은 Maven이 미리 만든 JAR를 복사합니다." + }, + { + "term": "컴파일 대상 / 실행 JVM", + "meaning": "컴파일 대상은 코드를 만들 기준 버전이고 실행 JVM은 실제 실행 프로그램입니다. 여기서는 각각 17과 21입니다." + }, + { + "term": "ENTRYPOINT / CMD", + "meaning": "이 배열 형식의 ENTRYPOINT는 실행할 명령을, CMD는 그 명령에 붙일 기본 인수를 정합니다." + }, + { + "term": "CI", + "meaning": "소스 변경에 맞춰 빌드·검사를 자동 실행하는 절차입니다. 제공 ci.yml은 아직 실행 검증하지 않은 비교 자료입니다." + } + ], + "code": { + "file": "Dockerfile", + "source": "# 검토용 학습 예시. 먼저 Maven package로 JAR를 만들고 이미지·플랫폼·digest를 별도 검증하세요.\nFROM eclipse-temurin:21-jre\nWORKDIR /app\nCOPY target/build-gradle-ci-0.1.0.jar /app/app.jar\nUSER 10001:10001\nENTRYPOINT [\"java\",\"-cp\",\"/app/app.jar\",\"edu.lab.BuildMain\"]\nCMD [\"2\"]\n" + }, + "walkthrough": [ + "첫 주석은 Maven package로 JAR를 먼저 만들라고 안내합니다. 아래 Dockerfile에는 Java 소스를 컴파일하는 명령이 없습니다.", + "FROM eclipse-temurin:21-jre는 실행용 JVM 21 환경을 고릅니다. 이는 Java 17 컴파일 대상과 별개입니다. WORKDIR은 작업 디렉터리를 /app으로 정합니다.", + "COPY는 target/build-gradle-ci-0.1.0.jar를 /app/app.jar로 복사합니다. 그 입력 파일이 준비되어야 이미지 빌드가 이 단계를 진행할 수 있습니다.", + "USER 10001:10001은 실행할 사용자·그룹 ID를 지정합니다. ENTRYPOINT는 java에 클래스 경로 /app/app.jar와 실행 클래스 edu.lab.BuildMain을 전달합니다.", + "CMD [\"2\"]는 기본 인수 2입니다. 기존 BuildMain.main은 이를 정수로 바꿔 total에 넘기므로 정상 실행 조건에서는 19,900 × 2를 출력합니다." + ], + "prediction": { + "question": "필요한 JAR와 이미지가 준비되어 정상 실행된다고 가정하고 기본 CMD를 쓰면, 기대 출력과 실행 JVM 버전은 무엇일까요?", + "answer": "기대 출력은 39800이고 Dockerfile이 지정한 실행 JVM은 21입니다.", + "explanation": "CMD의 2가 BuildMain.total의 quantity가 됩니다. 컴파일 대상 17은 실행 JVM을 17로 바꾸지 않습니다. 이는 제공 코드를 읽은 예측이며 실제 Docker 실행 통과를 주장하는 결과가 아닙니다." + }, + "takeaway": "어디서 만든 어떤 JAR를 어떤 JVM으로 실행하는지 파일 경로와 버전 값을 따라 확인합니다." + }, + "theory": [ + { + "heading": "빌드 도구보다 고정된 계약", + "text": "빌드 도구가 바뀌어도 수량 2의 합계가 39,800이어야 한다는 업무 조건은 같습니다. BuildMain.total은 양수 quantity만 받아 Math.multiplyExact(19900,quantity)로 int 범위를 넘는 계산도 거절합니다. Maven과 Gradle은 필요한 라이브러리를 가져오고 컴파일·테스트하는 절차를 표현합니다. 두 설정은 같은 grader의 ContractTest와 Java 17 대상을 사용하도록 작성되어 있습니다. BOM은 함께 사용할 의존성 버전 목록입니다. 설정을 나란히 읽는 것과 둘 다 실행해 같은 결과를 확인하는 것은 다르며, 설치형 자동 검사는 Maven 경로를 사용합니다." + }, + { + "heading": "CI와 산출물 연결", + "text": "CI는 저장소 변경 때 빌드와 검사를 자동 실행하는 절차입니다. 제공한 ci.yml은 JDK 21을 준비하고 Maven verify를 실행하는 학습용 파일입니다. 실제 .github/workflows에 설치된 자동 작업은 아닙니다. 테스트한 소스와 나중에 실행할 JAR가 다르면 테스트 성공을 그 JAR의 근거로 삼을 수 없습니다. 따라서 커밋, JDK·의존성, 실제 테스트 실행 여부, 실패 전달, 생성한 파일의 식별값과 보존 위치를 이어서 기록해야 합니다." + }, + { + "heading": "컨테이너의 범위", + "text": "컴파일 대상은 사용할 문법·API와 만들어질 클래스 파일의 기준이고, 실행 JVM은 그 파일을 실제로 읽는 프로세스입니다. build.gradle의 options.release=17과 Dockerfile의 eclipse-temurin:21-jre는 다른 정보를 말합니다. Dockerfile의 COPY는 Maven package로 미리 만든 target의 JAR를 /app/app.jar에 넣습니다. USER 10001:10001 아래에서 BuildMain을 실행하고 CMD의 2를 기본 수량으로 전달합니다. 이것은 값을 출력하고 끝나는 CLI 예시이며, 이미지 digest·취약점·플랫폼·자원·비밀 관리까지 검증한 운영 배포 설정은 아닙니다." + } + ], + "example": { + "title": "BuildMain 기준 구현", + "code": "package edu.lab;\npublic final class BuildMain {\n public static int total(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n return Math.multiplyExact(19900,quantity);\n // END EXERCISE\n }\n public static void main(String[] args) {\n if (args.length != 1) throw new IllegalArgumentException(\"one quantity argument required\");\n System.out.println(total(Integer.parseInt(args[0])));\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 동일 업무 합계 계약 · 잘못된 수량·오버플로 거절 · 두 빌드의 테스트 경로 설명.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "BuildMain.main은 인수 하나를 정수로 읽어 total을 호출합니다. total은 0 이하를 거절하고 multiplyExact로 합계를 계산하므로 수량 2의 계산 결과는 39,800입니다. build.gradle의 sourceSets는 ../grader/src/test/java를 테스트 경로로 지정하고 useJUnitPlatform을 사용합니다. Dockerfile은 Gradle 파일을 실행하는 대신 이미 만든 Maven target JAR를 복사해 BuildMain에 기본 인수 2를 줍니다. 이 Docker 실행 흐름은 코드를 읽은 예상이며 실제 Gradle·Docker·CI 실행 검증은 아직 하지 않았습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/BuildMain.java", + "language": "java", + "code": "package edu.lab;\npublic final class BuildMain {\n public static int total(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n return Math.multiplyExact(19900,quantity);\n // END EXERCISE\n }\n public static void main(String[] args) {\n if (args.length != 1) throw new IllegalArgumentException(\"one quantity argument required\");\n System.out.println(total(Integer.parseInt(args[0])));\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-gradle-ciorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\n" + }, + { + "path": "settings.gradle", + "language": "text", + "code": "rootProject.name = 'build-gradle-ci'\n" + }, + { + "path": "build.gradle", + "language": "text", + "code": "plugins { id 'java' }\ngroup = 'edu.javaedu'\nversion = '0.1.0'\nrepositories { mavenCentral() }\njava { toolchain { languageVersion = JavaLanguageVersion.of(17) } }\ntasks.withType(JavaCompile).configureEach { options.release = 17; options.encoding = 'UTF-8' }\ndependencies {\n testImplementation platform('org.springframework.boot:spring-boot-dependencies:3.5.16')\n testImplementation 'org.junit.jupiter:junit-jupiter'\n testImplementation 'org.assertj:assertj-core'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\nsourceSets {\n test {\n java.srcDirs = ['../grader/src/test/java']\n resources.srcDirs = ['../grader/src/test/resources']\n }\n}\ntest { useJUnitPlatform() }\n" + }, + { + "path": "Dockerfile", + "language": "text", + "code": "# 검토용 학습 예시. 먼저 Maven package로 JAR를 만들고 이미지·플랫폼·digest를 별도 검증하세요.\nFROM eclipse-temurin:21-jre\nWORKDIR /app\nCOPY target/build-gradle-ci-0.1.0.jar /app/app.jar\nUSER 10001:10001\nENTRYPOINT [\"java\",\"-cp\",\"/app/app.jar\",\"edu.lab.BuildMain\"]\nCMD [\"2\"]\n" + }, + { + "path": "deployment-examples/ci.yml", + "language": "text", + "code": "# 학습용 비교 예시이며 실제 .github/workflows 경로에 설치되지 않습니다.\nname: project-contract\non: [push, pull_request]\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-java@v4\n with:\n distribution: temurin\n java-version: '21'\n cache: maven\n - run: mvn -B -ntp -f curriculum/professional/lessons/build-gradle-ci/reference/pom.xml verify\n# 실제 도입 시 저장소 경로·action commit pin·산출물 보존 정책을 검증합니다.\n" + }, + { + "path": "BUILD-COMPARISON.md", + "language": "markdown", + "code": "자동 검사 경로는 Maven입니다. Gradle 설정은 같은 테스트 소스를 읽는 비교 자료이며 설치형에서 Gradle 임의 빌드를 실행하지 않습니다.\nGradle 실행에는 해당 버전과 JDK 17 toolchain 준비가 별도로 필요합니다. Wrapper 바이너리는 포함하지 않습니다.\nDockerfile은 Maven package가 만든 target/build-gradle-ci-0.1.0.jar를 사용합니다. 컨테이너의 JVM은 21이며 컴파일 대상 17과 다릅니다.\nCI 예시 경로는 professional 디렉터리가 저장소 루트 아래 curriculum에 있을 때를 가정합니다. 실제 워크플로, action 고정 commit, 이미지 digest, 취약점 검증과 자동 배포는 수행하지 않았습니다.\n" + } + ] + }, + "quiz": [ + { + "id": "build-gradle-ci-q1", + "question": "Java 17 release로 만든 코드의 컨테이너가 Java 21 JVM을 쓰면 기록해야 할 구분은?", + "options": [ + "모두 Java 17 실행으로 기록한다.", + "컴파일 대상 17과 실제 실행 JVM 21을 각각 기록한다.", + "모두 Java 21 문법으로 작성됐다고 기록한다.", + "JAR 파일만 있으면 JVM 정보는 필요 없다." + ], + "correctIndex": 1, + "explanation": "options.release=17은 Java 17 기준으로 컴파일하도록 정하고 FROM eclipse-temurin:21-jre는 컨테이너가 사용할 JVM을 지정합니다. 따라서 대상 17과 실행 JVM 21을 따로 기록해야 합니다. 이 설정을 읽었다는 것만으로 실제 컨테이너 실행까지 확인한 것은 아닙니다." + }, + { + "id": "build-gradle-ci-q2", + "question": "테스트한 소스와 배포 산출물의 근거를 연결하려면?", + "options": [ + "배포 직전에 개발자 PC에서 임의로 다시 빌드한다.", + "테스트한 커밋·빌드 환경·생성된 산출물 식별을 보존한다.", + "최신이라는 파일 이름만 확인한다.", + "컨테이너를 쓰면 소스 식별은 생략한다." + ], + "correctIndex": 1, + "explanation": "검사한 커밋과 환경, 그 빌드가 만든 JAR의 식별이 이어져야 나중에 어떤 코드를 실행했는지 알 수 있습니다. 배포 직전 다른 환경에서 임의로 다시 만들거나 최신이라는 파일 이름만 보면 검사 대상과 실행 파일이 같다는 근거가 사라집니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "같은 Java 코드를 Maven과 Gradle로 비교하려 합니다. Dockerfile이 어떤 경로의 JAR를 요구하는지 모르고 파일만 섞으면, 업무 테스트와 실제 실행 파일의 연결이 끊길 수 있습니다.", + "tasks": [ + "BuildMain.total에서 수량 2, 0, int 합계 범위를 넘는 수량의 경로를 나누어 설명하세요.", + "Maven 설정과 build.gradle의 sourceSets가 같은 grader를 가리키는지 읽고, Gradle의 JDK 17 toolchain 준비가 별도임을 적으세요.", + "COPY의 target/build-gradle-ci-0.1.0.jar가 먼저 필요한 이유와 ENTRYPOINT·CMD가 BuildMain에 수량 2를 전달하는 방식을 설명하세요.", + "설치형 채점은 Maven입니다. Gradle·Docker·CI 설정은 비교 자료이며 실행 미검증입니다. Docker 예시는 웹 서버나 배포 자동화가 아닌 CLI임을 기록하세요.", + "실제 도입 시 테스트한 커밋·빌드 환경·산출물 식별을 어떻게 연결할지 적고, 아직 검사하지 않은 이미지·플랫폼·action 고정 조건을 구분하세요." + ], + "takeaway": "같은 소스, 같은 검사, 만든 파일, 그 파일을 실행할 JVM까지 연결해야 빌드 성공의 의미를 설명할 수 있습니다." + }, + "sources": [ + { + "title": "Gradle Java plugin", + "url": "https://docs.gradle.org/current/userguide/java_plugin.html", + "edition": "Gradle current guide", + "checkedAt": "2026-10-12" + }, + { + "title": "Maven lifecycle", + "url": "https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html", + "edition": "Maven", + "checkedAt": "2026-10-12" + }, + { + "title": "Dockerfile reference", + "url": "https://docs.docker.com/reference/dockerfile/", + "edition": "Docker", + "checkedAt": "2026-10-12" + }, + { + "title": "GitHub Actions Java Maven", + "url": "https://docs.github.com/en/actions/tutorials/build-and-test-code/java-with-maven", + "edition": "GitHub Actions", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Maven", + "Gradle", + "Docker", + "CI" + ], + "exerciseId": "build-gradle-ci-project", + "professionalExercise": { + "id": "build-gradle-ci-project", + "profileId": "professional-gradle-ci", + "mode": "project", + "instructions": [ + "BuildMain.total은 19,900원 상품의 양수 수량 합계를 계산하며 오버플로를 거절합니다.", + "Maven과 Gradle 설정이 같은 grader 테스트를 읽는 경로를 설명하세요.", + "설치형 자동 채점은 Maven으로 수행합니다. Gradle·Docker·CI는 제공한 설정의 비교·검토 과제이며 아직 실행 검증 전입니다.", + "Docker 예시는 JAR 생성 후 실행하는 CLI이며 웹 서버·배포 자동화가 아닙니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/BuildMain.java" + ], + "template": "package edu.lab;\npublic final class BuildMain {\n public static int total(int quantity) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public static void main(String[] args) {\n if (args.length != 1) throw new IllegalArgumentException(\"one quantity argument required\");\n System.out.println(total(Integer.parseInt(args[0])));\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/BuildMain.java", + "templatePath": "lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java", + "referencePath": "lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/build-gradle-ci/starter/pom.xml", + "referencePath": "lessons/build-gradle-ci/reference/pom.xml", + "editable": false + }, + { + "path": "settings.gradle", + "templatePath": "lessons/build-gradle-ci/starter/settings.gradle", + "referencePath": "lessons/build-gradle-ci/reference/settings.gradle", + "editable": false + }, + { + "path": "build.gradle", + "templatePath": "lessons/build-gradle-ci/starter/build.gradle", + "referencePath": "lessons/build-gradle-ci/reference/build.gradle", + "editable": false + }, + { + "path": "Dockerfile", + "templatePath": "lessons/build-gradle-ci/starter/Dockerfile", + "referencePath": "lessons/build-gradle-ci/reference/Dockerfile", + "editable": false + }, + { + "path": "deployment-examples/ci.yml", + "templatePath": "lessons/build-gradle-ci/starter/deployment-examples/ci.yml", + "referencePath": "lessons/build-gradle-ci/reference/deployment-examples/ci.yml", + "editable": false + }, + { + "path": "BUILD-COMPARISON.md", + "templatePath": "lessons/build-gradle-ci/starter/BUILD-COMPARISON.md", + "referencePath": "lessons/build-gradle-ci/reference/BUILD-COMPARISON.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/build-gradle-ci/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "동일 업무 합계 계약", + "잘못된 수량·오버플로 거절", + "두 빌드의 테스트 경로 설명" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "build-maven", + "test-contracts" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "62e994695134b10e2f7284eebd74a5673eb2f1f2359028b85b22661f2a25b4ff", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/BUILD-COMPARISON.md b/curriculum/professional/lessons/build-gradle-ci/reference/BUILD-COMPARISON.md new file mode 100644 index 0000000..c39921f --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/BUILD-COMPARISON.md @@ -0,0 +1,4 @@ +자동 검사 경로는 Maven입니다. Gradle 설정은 같은 테스트 소스를 읽는 비교 자료이며 설치형에서 Gradle 임의 빌드를 실행하지 않습니다. +Gradle 실행에는 해당 버전과 JDK 17 toolchain 준비가 별도로 필요합니다. Wrapper 바이너리는 포함하지 않습니다. +Dockerfile은 Maven package가 만든 target/build-gradle-ci-0.1.0.jar를 사용합니다. 컨테이너의 JVM은 21이며 컴파일 대상 17과 다릅니다. +CI 예시 경로는 professional 디렉터리가 저장소 루트 아래 curriculum에 있을 때를 가정합니다. 실제 워크플로, action 고정 commit, 이미지 digest, 취약점 검증과 자동 배포는 수행하지 않았습니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/Dockerfile b/curriculum/professional/lessons/build-gradle-ci/reference/Dockerfile new file mode 100644 index 0000000..186a54c --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/Dockerfile @@ -0,0 +1,7 @@ +# 검토용 학습 예시. 먼저 Maven package로 JAR를 만들고 이미지·플랫폼·digest를 별도 검증하세요. +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY target/build-gradle-ci-0.1.0.jar /app/app.jar +USER 10001:10001 +ENTRYPOINT ["java","-cp","/app/app.jar","edu.lab.BuildMain"] +CMD ["2"] diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/build.gradle b/curriculum/professional/lessons/build-gradle-ci/reference/build.gradle new file mode 100644 index 0000000..0abadea --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/build.gradle @@ -0,0 +1,19 @@ +plugins { id 'java' } +group = 'edu.javaedu' +version = '0.1.0' +repositories { mavenCentral() } +java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } +tasks.withType(JavaCompile).configureEach { options.release = 17; options.encoding = 'UTF-8' } +dependencies { + testImplementation platform('org.springframework.boot:spring-boot-dependencies:3.5.16') + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.assertj:assertj-core' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} +sourceSets { + test { + java.srcDirs = ['../grader/src/test/java'] + resources.srcDirs = ['../grader/src/test/resources'] + } +} +test { useJUnitPlatform() } diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/deployment-examples/ci.yml b/curriculum/professional/lessons/build-gradle-ci/reference/deployment-examples/ci.yml new file mode 100644 index 0000000..b4e1895 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/deployment-examples/ci.yml @@ -0,0 +1,17 @@ +# 학습용 비교 예시이며 실제 .github/workflows 경로에 설치되지 않습니다. +name: project-contract +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + - run: mvn -B -ntp -f curriculum/professional/lessons/build-gradle-ci/reference/pom.xml verify +# 실제 도입 시 저장소 경로·action commit pin·산출물 보존 정책을 검증합니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/pom.xml b/curriculum/professional/lessons/build-gradle-ci/reference/pom.xml new file mode 100644 index 0000000..046c816 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-gradle-ciorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/settings.gradle b/curriculum/professional/lessons/build-gradle-ci/reference/settings.gradle new file mode 100644 index 0000000..1236141 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'build-gradle-ci' diff --git a/curriculum/professional/lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java b/curriculum/professional/lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java new file mode 100644 index 0000000..a3dd87a --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/reference/src/main/java/edu/lab/BuildMain.java @@ -0,0 +1,13 @@ +package edu.lab; +public final class BuildMain { + public static int total(int quantity) { + // BEGIN EXERCISE + if (quantity <= 0) throw new IllegalArgumentException("quantity"); + return Math.multiplyExact(19900,quantity); + // END EXERCISE + } + public static void main(String[] args) { + if (args.length != 1) throw new IllegalArgumentException("one quantity argument required"); + System.out.println(total(Integer.parseInt(args[0]))); + } +} diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/BUILD-COMPARISON.md b/curriculum/professional/lessons/build-gradle-ci/starter/BUILD-COMPARISON.md new file mode 100644 index 0000000..c39921f --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/BUILD-COMPARISON.md @@ -0,0 +1,4 @@ +자동 검사 경로는 Maven입니다. Gradle 설정은 같은 테스트 소스를 읽는 비교 자료이며 설치형에서 Gradle 임의 빌드를 실행하지 않습니다. +Gradle 실행에는 해당 버전과 JDK 17 toolchain 준비가 별도로 필요합니다. Wrapper 바이너리는 포함하지 않습니다. +Dockerfile은 Maven package가 만든 target/build-gradle-ci-0.1.0.jar를 사용합니다. 컨테이너의 JVM은 21이며 컴파일 대상 17과 다릅니다. +CI 예시 경로는 professional 디렉터리가 저장소 루트 아래 curriculum에 있을 때를 가정합니다. 실제 워크플로, action 고정 commit, 이미지 digest, 취약점 검증과 자동 배포는 수행하지 않았습니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/Dockerfile b/curriculum/professional/lessons/build-gradle-ci/starter/Dockerfile new file mode 100644 index 0000000..186a54c --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/Dockerfile @@ -0,0 +1,7 @@ +# 검토용 학습 예시. 먼저 Maven package로 JAR를 만들고 이미지·플랫폼·digest를 별도 검증하세요. +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY target/build-gradle-ci-0.1.0.jar /app/app.jar +USER 10001:10001 +ENTRYPOINT ["java","-cp","/app/app.jar","edu.lab.BuildMain"] +CMD ["2"] diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/build.gradle b/curriculum/professional/lessons/build-gradle-ci/starter/build.gradle new file mode 100644 index 0000000..0abadea --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/build.gradle @@ -0,0 +1,19 @@ +plugins { id 'java' } +group = 'edu.javaedu' +version = '0.1.0' +repositories { mavenCentral() } +java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } +tasks.withType(JavaCompile).configureEach { options.release = 17; options.encoding = 'UTF-8' } +dependencies { + testImplementation platform('org.springframework.boot:spring-boot-dependencies:3.5.16') + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.assertj:assertj-core' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} +sourceSets { + test { + java.srcDirs = ['../grader/src/test/java'] + resources.srcDirs = ['../grader/src/test/resources'] + } +} +test { useJUnitPlatform() } diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/deployment-examples/ci.yml b/curriculum/professional/lessons/build-gradle-ci/starter/deployment-examples/ci.yml new file mode 100644 index 0000000..b4e1895 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/deployment-examples/ci.yml @@ -0,0 +1,17 @@ +# 학습용 비교 예시이며 실제 .github/workflows 경로에 설치되지 않습니다. +name: project-contract +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + - run: mvn -B -ntp -f curriculum/professional/lessons/build-gradle-ci/reference/pom.xml verify +# 실제 도입 시 저장소 경로·action commit pin·산출물 보존 정책을 검증합니다. diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/pom.xml b/curriculum/professional/lessons/build-gradle-ci/starter/pom.xml new file mode 100644 index 0000000..046c816 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-gradle-ciorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/settings.gradle b/curriculum/professional/lessons/build-gradle-ci/starter/settings.gradle new file mode 100644 index 0000000..1236141 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'build-gradle-ci' diff --git a/curriculum/professional/lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java b/curriculum/professional/lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java new file mode 100644 index 0000000..fc79926 --- /dev/null +++ b/curriculum/professional/lessons/build-gradle-ci/starter/src/main/java/edu/lab/BuildMain.java @@ -0,0 +1,10 @@ +package edu.lab; +public final class BuildMain { + public static int total(int quantity) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public static void main(String[] args) { + if (args.length != 1) throw new IllegalArgumentException("one quantity argument required"); + System.out.println(total(Integer.parseInt(args[0]))); + } +} diff --git a/curriculum/professional/lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..e0fb6bf --- /dev/null +++ b/curriculum/professional/lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,11 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void normalAndZeroPrice() { assertThat(OrderAmounts.lineTotal(1500, 2)).isEqualTo(3000); assertThat(OrderAmounts.lineTotal(0, 1)).isZero(); } + @Test void invalidInputsAndOverflow() { + assertThatIllegalArgumentException().isThrownBy(() -> OrderAmounts.lineTotal(-1,1)); + assertThatIllegalArgumentException().isThrownBy(() -> OrderAmounts.lineTotal(100,0)); + assertThatExceptionOfType(ArithmeticException.class).isThrownBy(() -> OrderAmounts.lineTotal(Integer.MAX_VALUE,2)); + } +} diff --git a/curriculum/professional/lessons/build-maven/lesson.json b/curriculum/professional/lessons/build-maven/lesson.json new file mode 100644 index 0000000..3251a43 --- /dev/null +++ b/curriculum/professional/lessons/build-maven/lesson.json @@ -0,0 +1,205 @@ +{ + "id": "build-maven", + "title": "Maven으로 재현 가능한 주문 계산 빌드", + "description": "1,500원 상품 두 개는 3,000원이어야 하고, 계산 범위를 넘는 금액은 실패해야 합니다. 이 규칙을 검사하는 코드와 Maven의 의존성·컴파일 대상·실행 JDK를 함께 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "하위 pom.xml이 공통 부모에서 가져오는 버전 설정과 직접 선언하는 테스트 라이브러리를 구분한다.", + "lineTotal(1500, 2)가 3000을 반환하는 순서를 설명한다.", + "무료 상품의 가격 0과 잘못된 수량 0을 구분한다.", + "잘못된 입력은 곱셈 전에 IllegalArgumentException으로 거절한다.", + "int 범위를 넘는 곱셈과 실제 테스트 JVM의 버전을 별도로 확인한다." + ], + "theory": [ + { + "heading": "빌드는 실행 조건의 문서", + "text": "같은 OrderAmounts.java를 가져와도 팀원의 JDK와 라이브러리 버전이 다르면 빌드 결과가 달라질 수 있습니다. pom.xml의 parent는 여러 실습이 공유하는 빌드 설정을 가져옵니다. 하위 pom.xml에는 junit-jupiter와 assertj-core를 test 범위로 선언하고, 공통 부모는 BOM에서 의존성 버전을 관리합니다. BOM은 여러 라이브러리의 버전표이며, import만으로 그 라이브러리를 전부 추가하지 않습니다.\n\n컴파일 대상 17은 Java 17의 언어·공개 API·클래스 파일 범위로 컴파일한다는 뜻입니다. 검사를 실행하는 JVM이 21이면 테스트는 여전히 JVM 21에서 돕니다. 이 수업의 기존 기록도 JDK 21 실행과 Java 17 대상을 따로 표시합니다. 실제 Java 17에서의 동작은 Java 17 JVM으로 실행해야 확인할 수 있습니다." + }, + { + "heading": "금액 규칙과 빌드 재현성", + "text": "먼저 계산이 지켜야 할 규칙을 정합니다. 이것이 여기서 말하는 업무 계약입니다. lineTotal은 단가 unitPrice가 0 이상이고 수량 quantity가 1 이상일 때만 계산합니다. 따라서 (0, 1)은 무료 상품이라 0을 반환하지만 (100, 0)은 잘못된 주문이라 예외를 던집니다. Math.multiplyExact는 결과가 int 범위를 벗어나면 ArithmeticException을 던져 잘못된 금액이 정상값처럼 이어지는 것을 막습니다.\n\n이 규칙을 다른 PC에서도 검사하려고 공통 부모에 컴파일·테스트 플러그인 버전, UTF-8, 테스트 소스 경로를 둡니다. 의존성 다운로드가 끝났다는 사실만으로 ContractTest가 실행됐다고 볼 수는 없습니다. 실제 실행 결과와 테스트 수를 확인해야 하며, 버전 고정만으로 모든 OS·외부 의존성까지 동일해지는 것도 아닙니다." + } + ], + "example": { + "title": "OrderAmounts 기준 구현", + "code": "package edu.lab;\npublic final class OrderAmounts {\n private OrderAmounts() {}\n public static int lineTotal(int unitPrice, int quantity) {\n // BEGIN EXERCISE\n if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException(\"invalid amount\");\n return Math.multiplyExact(unitPrice, quantity);\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 정상 곱셈 · 0원 상품 · 잘못된 수량 거절 · 정수 오버플로 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "lineTotal은 if에서 단가와 수량을 먼저 확인한 다음 Math.multiplyExact를 호출합니다. 기존 테스트의 (1500, 2)는 3000, (0, 1)은 0이고, (-1, 1)과 (100, 0)은 입력 검사에서 멈춥니다. (Integer.MAX_VALUE, 2)는 입력 조건에는 맞지만 곱셈 결과가 너무 커서 ArithmeticException이 납니다. 예제의 pom.xml은 JUnit·AssertJ를 선언하고 실제 버전과 플러그인 설정은 공통 부모에서 받습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/OrderAmounts.java", + "language": "java", + "code": "package edu.lab;\npublic final class OrderAmounts {\n private OrderAmounts() {}\n public static int lineTotal(int unitPrice, int quantity) {\n // BEGIN EXERCISE\n if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException(\"invalid amount\");\n return Math.multiplyExact(unitPrice, quantity);\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-mavenorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\n" + } + ] + }, + "quiz": [ + { + "id": "build-maven-q1", + "question": "BOM의 dependencyManagement에서 버전을 관리하는 라이브러리를 실제로 쓰려면?", + "options": [ + "BOM을 import하면 관리되는 모든 라이브러리가 자동 추가된다.", + "프로젝트 dependencies에도 해당 의존성을 선언한다.", + "라이브러리 JAR를 소스 폴더에 복사하면 Maven이 자동 인식한다.", + "컴파일 플러그인의 release 값에 라이브러리 이름을 넣는다." + ], + "correctIndex": 1, + "explanation": "공통 부모의 BOM은 junit-jupiter 등에 사용할 버전을 알려 줍니다. 하위 pom.xml의 dependencies에 junit-jupiter를 적어야 이 프로젝트의 테스트 의존성으로 선택됩니다. BOM을 가져오는 일과 실제로 사용할 라이브러리를 추가하는 일은 두 단계입니다." + }, + { + "id": "build-maven-q2", + "question": "JDK 21에서 javac --release 17로 컴파일하는 의미는?", + "options": [ + "테스트 JVM이 자동으로 17이 된다.", + "Java 17 대상 언어·클래스 파일·공개 API 범위로 컴파일한다.", + "Java 21 API를 모두 쓸 수 있고 클래스 파일만 17이 된다.", + "네이티브 라이브러리의 모든 호환성이 검증된다." + ], + "correctIndex": 1, + "explanation": "JDK 21의 javac에 --release 17을 주면 Java 17에서 지원하는 언어·공개 API와 클래스 파일을 대상으로 컴파일합니다. 이어서 JDK 21의 java로 ContractTest를 실행하면 실행 JVM은 21입니다. 이 설정만으로 Java 17 JVM 테스트나 네이티브 라이브러리 호환성까지 확인한 것은 아닙니다." + } + ], + "practice": { + "title": "주문 금액 규칙을 빌드에서 반복 확인하기", + "scenario": "주문 계산을 CI에서도 검사하려고 합니다. 무료 상품은 허용하되 수량 0과 너무 큰 금액은 성공 응답으로 흘려보내면 안 됩니다. 결과값뿐 아니라 어떤 설정과 JDK로 테스트했는지 남겨야 팀원이 같은 조건을 확인할 수 있습니다.", + "tasks": [ + "OrderAmounts.lineTotal(int unitPrice, int quantity)를 구현하고 (1500, 2)와 (0, 1)의 반환값을 확인하세요.", + "unitPrice는 0 이상, quantity는 1 이상이어야 합니다. (-1, 1)과 (100, 0)을 IllegalArgumentException으로 거절하는 위치를 설명하세요.", + "Math.multiplyExact로 int 범위 초과를 ArithmeticException으로 알리세요. (Integer.MAX_VALUE, 2)는 왜 입력 검사 뒤에 실패하는지 설명하세요.", + "예제 pom.xml의 dependencies와 공통 부모의 dependencyManagement가 맡는 일을 구분하고, 기존 기록의 컴파일 대상 17·실행 JVM 21·테스트 수를 각각 읽어 보세요." + ], + "takeaway": "금액 규칙은 구체적인 입력과 결과로, 빌드 조건은 의존성·플러그인·컴파일 대상·실행 JVM으로 남깁니다. 두 기록이 있어야 같은 계산을 같은 조건에서 다시 확인할 수 있습니다." + }, + "sources": [ + { + "title": "Maven dependency mechanism", + "url": "https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html", + "edition": "Maven 3", + "checkedAt": "2026-10-12" + }, + { + "title": "Java 17 Math.multiplyExact", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Math.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "build-maven-project", + "professionalExercise": { + "id": "build-maven-project", + "profileId": "professional-junit", + "mode": "project", + "instructions": [ + "OrderAmounts.lineTotal(int unitPrice, int quantity)를 구현하세요.", + "unitPrice는 0 이상, quantity는 1 이상입니다. 위반하면 IllegalArgumentException을 던집니다.", + "int 범위 초과는 Math.multiplyExact를 통해 ArithmeticException으로 알립니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrderAmounts.java" + ], + "template": "package edu.lab;\npublic final class OrderAmounts {\n private OrderAmounts() {}\n public static int lineTotal(int unitPrice, int quantity) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/OrderAmounts.java", + "templatePath": "lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java", + "referencePath": "lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/build-maven/starter/pom.xml", + "referencePath": "lessons/build-maven/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/build-maven/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "정상 곱셈", + "0원 상품", + "잘못된 수량 거절", + "정수 오버플로 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "core:oop", + "core:testing-design" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "a9d4d663d97d617409faa0998490cce985e0199084dc3fa55e42351f452d2405", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "무료 상품 주문을 허용했더니 수량 0도 통과하고, 큰 금액은 이상한 값으로 계산되는 결함을 막으려 합니다. 기존 lineTotal의 두 줄이 서로 다른 실패를 맡습니다.", + "question": "왜 입력을 확인한 뒤에도 단순 곱셈 대신 Math.multiplyExact를 호출할까요?", + "terms": [ + { + "term": "업무 계약", + "meaning": "이 메서드가 받아들일 입력과 돌려줄 결과에 대한 약속입니다. lineTotal에서는 단가 0 이상·수량 1 이상·범위를 넘는 계산 거절이 해당합니다." + }, + { + "term": "오버플로", + "meaning": "계산 결과가 자료형이 담을 수 있는 범위를 넘는 일입니다. Integer.MAX_VALUE에 2를 곱한 금액은 int에 담을 수 없습니다." + }, + { + "term": "BOM", + "meaning": "여러 라이브러리에 사용할 버전을 모아 둔 목록입니다. 이 수업의 pom.xml은 공통 부모를 통해 그 버전 정보를 받고 JUnit·AssertJ 사용은 직접 선언합니다." + }, + { + "term": "컴파일 대상과 실행 JVM", + "meaning": "컴파일 대상은 만들 코드의 호환 범위이고 실행 JVM은 그 코드를 실제로 돌리는 프로그램입니다. 이 수업의 기존 검사는 대상 17, 실행 21입니다." + } + ], + "walkthrough": [ + "lineTotal(1500, 2)를 호출하면 unitPrice는 1500, quantity는 2입니다. 둘 다 if의 거절 조건에 걸리지 않습니다.", + "Math.multiplyExact(1500, 2)가 3000을 계산하고 바로 반환합니다. 계산 결과를 별도 필드에 저장하지 않습니다.", + "lineTotal(0, 1)의 단가 0은 허용하지만 lineTotal(100, 0)의 수량 0은 if에서 IllegalArgumentException을 던집니다.", + "lineTotal(Integer.MAX_VALUE, 2)는 양수 검사에는 통과합니다. 이어지는 multiplyExact가 int 범위 초과를 발견해 ArithmeticException을 던집니다.", + "기존 ContractTest는 정상값·0원·잘못된 입력·범위 초과를 확인합니다. pom.xml의 테스트 의존성 선언은 이 검사를 빌드에서 실행할 준비이고, 실제 실행 여부는 테스트 결과로 확인합니다." + ], + "prediction": { + "question": "lineTotal(0, 1)과 lineTotal(Integer.MAX_VALUE, 2)를 각각 호출하면 어떤 결과가 나오나요?", + "answer": "첫 호출은 0을 반환하고, 두 번째 호출은 ArithmeticException을 던집니다.", + "explanation": "단가가 0인 것은 허용됩니다. 두 번째 호출도 입력 조건에는 맞지만 곱셈 결과가 int 범위를 넘어 multiplyExact에서 실패합니다. 두 실패 지점을 구분해 보는 코드 읽기 질문입니다." + }, + "takeaway": "입력이 허용되는지와 계산 결과를 담을 수 있는지는 다른 검사입니다. 이 차이를 테스트로 남기고, 그 테스트를 돌린 빌드 조건도 함께 기록합니다.", + "code": { + "file": "src/main/java/edu/lab/OrderAmounts.java", + "source": " public static int lineTotal(int unitPrice, int quantity) {\n // BEGIN EXERCISE\n if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException(\"invalid amount\");\n return Math.multiplyExact(unitPrice, quantity);\n // END EXERCISE\n }\n" + } + } +} diff --git a/curriculum/professional/lessons/build-maven/reference/pom.xml b/curriculum/professional/lessons/build-maven/reference/pom.xml new file mode 100644 index 0000000..b7a83d6 --- /dev/null +++ b/curriculum/professional/lessons/build-maven/reference/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-mavenorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java b/curriculum/professional/lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java new file mode 100644 index 0000000..a88ac9e --- /dev/null +++ b/curriculum/professional/lessons/build-maven/reference/src/main/java/edu/lab/OrderAmounts.java @@ -0,0 +1,10 @@ +package edu.lab; +public final class OrderAmounts { + private OrderAmounts() {} + public static int lineTotal(int unitPrice, int quantity) { + // BEGIN EXERCISE + if (unitPrice < 0 || quantity <= 0) throw new IllegalArgumentException("invalid amount"); + return Math.multiplyExact(unitPrice, quantity); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/build-maven/starter/pom.xml b/curriculum/professional/lessons/build-maven/starter/pom.xml new file mode 100644 index 0000000..b7a83d6 --- /dev/null +++ b/curriculum/professional/lessons/build-maven/starter/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlbuild-mavenorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java b/curriculum/professional/lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java new file mode 100644 index 0000000..02a5573 --- /dev/null +++ b/curriculum/professional/lessons/build-maven/starter/src/main/java/edu/lab/OrderAmounts.java @@ -0,0 +1,7 @@ +package edu.lab; +public final class OrderAmounts { + private OrderAmounts() {} + public static int lineTotal(int unitPrice, int quantity) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..1b1e4a0 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,20 @@ +package edu.lab; +import java.sql.*; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void appliesOnceAndEnforcesRequestKey() throws Exception { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + var flyway = MigrationLab.configured(ds); + assertThat(flyway.migrate().migrationsExecuted).isEqualTo(2); + assertThat(flyway.migrate().migrationsExecuted).isZero(); + try (Connection c = ds.getConnection(); Statement s = c.createStatement()) { + s.executeUpdate("insert into orders(id,quantity,request_key) values('A',1,'req-1')"); + assertThatThrownBy(() -> s.executeUpdate("insert into orders(id,quantity,request_key) values('B',2,'req-1')")) + .isInstanceOf(SQLException.class); + try (ResultSet result = s.executeQuery("select count(*) from orders")) { result.next(); assertThat(result.getInt(1)).isEqualTo(1); } + } + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/lesson.json b/curriculum/professional/lessons/flyway-migrations/lesson.json new file mode 100644 index 0000000..26c530c --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/lesson.json @@ -0,0 +1,225 @@ +{ + "id": "flyway-migrations", + "title": "Flyway로 스키마 변경을 기록", + "description": "주문 A와 B에 같은 요청 키가 저장되는 일을 막아 봅니다. 테이블을 만든 V1 다음에 V2를 적용하고, DB가 중복 키를 거절하는 이유를 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "이미 만든 orders 테이블에 request_key를 추가하는 순서를 설명한다.", + "처음 migrate할 때 V1과 V2가 적용되고 두 번째에는 0개가 적용되는 이유를 설명한다.", + "주문 ID가 달라도 같은 요청 키 req-1을 저장하면 왜 실패하는지 찾는다.", + "운영 테이블에 제약을 추가하기 전 중복 데이터와 배포 순서를 확인한다." + ], + "learningGuide": { + "scenario": "주문 A를 req-1로 저장했습니다. 응답을 못 받은 클라이언트가 다시 요청하면서 주문 ID만 B로 바뀌었습니다. 요청 키가 같다는 사실을 DB가 알아볼 수 있게 만듭니다.", + "question": "서로 다른 주문 ID인데도 두 번째 저장을 막으려면 어느 열에 규칙을 걸어야 할까요?", + "terms": [ + { + "term": "마이그레이션", + "meaning": "DB 구조 변경을 순서가 있는 파일로 관리하는 작업입니다. 여기서는 V1로 orders를 만들고 V2로 request_key를 추가합니다." + }, + { + "term": "UNIQUE 제약", + "meaning": "같은 값을 중복 저장하지 못하게 하는 DB 규칙입니다. uq_order_request는 request_key 값의 중복을 검사합니다." + }, + { + "term": "요청 키", + "meaning": "재시도된 요청이 원래 어떤 요청이었는지 식별할 값입니다. 주문 ID와 별도로 req-1 같은 값을 저장합니다." + } + ], + "code": { + "file": "src/main/resources/db/migration/V2__request_key.sql", + "source": "ALTER TABLE orders ADD COLUMN request_key VARCHAR(80);\nALTER TABLE orders ADD CONSTRAINT uq_order_request UNIQUE (request_key);\n" + }, + "walkthrough": [ + "첫 줄 ALTER TABLE orders ADD COLUMN은 V1에서 이미 만든 orders에 request_key 열을 추가합니다. VARCHAR(80)은 최대 길이를 정하며, 이 줄에는 NOT NULL이 없습니다.", + "둘째 줄 ADD CONSTRAINT는 uq_order_request라는 이름의 규칙을 만듭니다. UNIQUE (request_key)이므로 비교 대상은 주문 id가 아니라 요청 키입니다.", + "이 두 줄이 적용된 뒤 A와 B에 같은 req-1을 저장하면 두 번째 INSERT가 제약에 걸립니다. Flyway의 적용 이력과 주문 데이터의 중복 검사는 서로 다른 역할입니다." + ], + "prediction": { + "question": "V1·V2를 적용한 빈 DB에서 기존 테스트처럼 SQL마다 자동 커밋(변경 확정)되는 상태로 A/req-1을 저장한 뒤 B/req-1을 저장하면 주문은 몇 행 남을까요?", + "answer": "두 번째 저장은 거절되고, A 한 행만 남습니다.", + "explanation": "기존 테스트의 JDBC 연결은 기본 자동 커밋 상태이므로 A의 INSERT가 먼저 확정됩니다. B는 같은 request_key=req-1 때문에 V2의 UNIQUE 규칙에 거절되지만, 이미 확정한 A는 남습니다. 두 INSERT를 하나의 트랜잭션으로 묶어 전체 변경을 되돌리면 A도 남지 않으므로, 최종 행 수는 중복 제약과 커밋 조건을 함께 보고 판단해야 합니다." + }, + "takeaway": "무엇이 같은 요청인지 정했다면, 그 값을 저장하는 열에 DB 제약까지 연결해야 합니다." + }, + "theory": [ + { + "heading": "버전 있는 변경 이력", + "text": "개발 PC에는 request_key 열이 있는데 다른 환경에는 없다면 같은 앱도 다르게 실패합니다. 마이그레이션은 DB 구조를 바꾸는 작업을 파일로 남겨 순서대로 적용하는 방법입니다. 이 예제의 V1__create_orders.sql은 orders를 만들고 V2__request_key.sql은 열과 제약을 추가합니다. MigrationLab.configured는 이 파일들이 있는 classpath:db/migration을 지정합니다. Flyway는 적용한 버전과 파일 내용의 확인값인 체크섬을 기록하므로, 같은 DB에서 migrate를 다시 호출해도 완료한 V1·V2를 다시 실행하지 않습니다. 이미 적용한 파일을 고치지 않고 다음 버전을 추가하는 이유입니다." + }, + { + "heading": "제약 추가의 데이터 전제", + "text": "V2의 첫 ALTER TABLE은 request_key라는 열을 추가합니다. 두 번째 줄의 UNIQUE는 같은 키 값을 가진 주문을 두 번 저장하지 못하게 하는 DB 규칙입니다. A와 B의 id가 달라도 request_key가 모두 req-1이면 두 번째 저장이 거절됩니다. 이 열에는 NOT NULL이 없으므로 값이 없는 경우의 의미까지 정한 것은 아닙니다. 이번 검사는 새 학습용 H2 DB의 비어 있지 않은 요청 키 중복을 다룹니다. 기존 운영 데이터의 중복·NULL 정책과 제약 추가 중 잠금은 별도로 확인해야 합니다." + }, + { + "heading": "확장 후 전환", + "text": "앱 여러 대를 차례로 교체하는 동안에는 구버전과 신버전이 같은 DB를 쓸 수 있습니다. 새 열을 먼저 추가하고, 그 열을 쓰는 코드로 전환한 뒤, 더 이상 쓰지 않는 열을 제거하는 순서를 검토하는 이유입니다. V2처럼 열을 추가하는 변경도 실제 DB 종류와 데이터 양에 따라 배포 중 영향이 달라집니다. DDL은 CREATE·ALTER 같은 구조 변경 SQL을 말하며, 실패했을 때 전부 되돌릴 수 있는지는 DB별로 확인합니다." + } + ], + "example": { + "title": "MigrationLab 기준 구현", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport org.flywaydb.core.Flyway;\npublic final class MigrationLab {\n private MigrationLab() {}\n public static Flyway configured(DataSource source) {\n return Flyway.configure().dataSource(source).locations(\"classpath:db/migration\").load();\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 1개 JUnit 테스트 통과.\n검증 항목: 두 버전 적용 · 재실행 시 0개 적용 · 중복 요청 키 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "MigrationLab.configured는 SQL을 직접 실행하는 메서드가 아니라 Flyway가 읽을 위치와 DataSource를 연결합니다. 테스트가 migrate를 호출하면 V1의 orders 생성 뒤 V2의 request_key 추가와 UNIQUE 설정이 진행됩니다. 재호출은 새로 적용할 파일이 없어 0개입니다. 이어 A의 req-1 저장은 성공하고 B의 같은 키 저장은 실패하므로 orders에는 A 한 행만 남습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/MigrationLab.java", + "language": "java", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport org.flywaydb.core.Flyway;\npublic final class MigrationLab {\n private MigrationLab() {}\n public static Flyway configured(DataSource source) {\n return Flyway.configure().dataSource(source).locations(\"classpath:db/migration\").load();\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlflyway-migrationsorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.flywaydbflyway-core\ncom.h2databaseh2\n" + }, + { + "path": "src/main/resources/db/migration/V1__create_orders.sql", + "language": "sql", + "code": "CREATE TABLE orders (id VARCHAR(60) PRIMARY KEY, quantity INT NOT NULL CHECK (quantity > 0));\n" + }, + { + "path": "src/main/resources/db/migration/V2__request_key.sql", + "language": "sql", + "code": "ALTER TABLE orders ADD COLUMN request_key VARCHAR(80);\nALTER TABLE orders ADD CONSTRAINT uq_order_request UNIQUE (request_key);\n" + } + ] + }, + "quiz": [ + { + "id": "flyway-migrations-q1", + "question": "이미 배포되어 적용된 V1 파일의 의미를 바꾸어야 할 때 일반적인 선택은?", + "options": [ + "새 버전 마이그레이션으로 다음 상태를 표현한다.", + "V1 내용을 바꾸고 이력은 무시한다.", + "모든 DB에서 clean을 실행한다.", + "파일 이름만 V1으로 유지하면 체크섬은 무관하다." + ], + "correctIndex": 0, + "explanation": "이미 적용된 V1을 바꾸면 새 환경이 읽는 내용과 기존 DB가 적용했던 내용이 달라집니다. 다음 V2·V3 파일에 변경을 추가해야 이력을 따라 같은 상태에 도달할 수 있습니다. 이 예제도 테이블 생성 V1을 고치지 않고 요청 키를 V2에서 추가합니다." + }, + { + "id": "flyway-migrations-q2", + "question": "UNIQUE 제약을 기존 운영 테이블에 추가하기 전에 필요한 확인은?", + "options": [ + "기존 중복·NULL 정책·DDL 잠금과 배포 호환성", + "Java 클래스의 줄 수", + "JSON 필드 순서", + "서비스 포트 번호만" + ], + "correctIndex": 0, + "explanation": "기존 orders에 req-1이 두 번 있다면 UNIQUE를 붙이는 단계부터 실패할 수 있습니다. NULL을 허용할지, ALTER TABLE이 얼마나 잠그는지, 구버전 앱이 새 스키마에서도 동작할지를 함께 확인해야 합니다. 새 H2 DB의 성공만으로 운영 테이블을 판단할 수 없습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "주문 버튼을 두 번 누르거나 응답을 못 받아 재시도하면 서로 다른 주문 ID에 같은 요청 키가 들어올 수 있습니다. 애플리케이션의 사전 조회만 믿지 않고 DB에도 중복 금지 규칙을 둡니다.", + "tasks": [ + "V1에서 만든 orders에 V2의 두 ALTER TABLE이 각각 무엇을 추가하는지 말로 설명하세요.", + "A에 req-1을 저장한 뒤 B에도 req-1을 저장할 때, id와 request_key 중 어떤 규칙 때문에 실패하는지 짚으세요.", + "같은 학습 DB에서 migrate를 두 번 호출할 때 적용 수가 2, 0인 이유를 변경 이력과 연결하세요.", + "운영 테이블로 옮긴다고 가정하고 기존 중복·NULL·DDL 잠금·구버전 앱 호환성을 확인 목록에 적으세요. 이 실습에서는 학습용 DB만 사용하며 clean을 호출하지 않습니다.", + "학습 H2에서 확인한 제약 동작과 실제 운영 DB에서 아직 확인하지 않은 사항을 구분하세요." + ], + "takeaway": "마이그레이션 파일은 DB가 다음 상태로 가는 순서이고, UNIQUE는 그 상태에서 중복 요청 키를 막는 규칙입니다. 두 역할을 함께 확인합니다." + }, + "sources": [ + { + "title": "Flyway Java API", + "url": "https://documentation.red-gate.com/flyway/reference/usage/api-java", + "edition": "Flyway", + "checkedAt": "2026-10-12" + }, + { + "title": "Flyway versioned migrations", + "url": "https://documentation.red-gate.com/flyway/flyway-concepts/migrations/versioned-migrations", + "edition": "Flyway", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Flyway", + "SQL" + ], + "exerciseId": "flyway-migrations-project", + "professionalExercise": { + "id": "flyway-migrations-project", + "profileId": "professional-flyway", + "mode": "project", + "instructions": [ + "V1은 orders 기본 테이블, V2는 request_key 열과 UNIQUE 제약을 추가합니다.", + "서로 다른 주문에 같은 non-null request_key를 저장하면 DB가 거절해야 합니다.", + "같은 DB에 migrate를 다시 실행하면 이미 적용된 마이그레이션 수는 0입니다.", + "학습용 DB만 대상으로 하며 clean 호출을 구현하지 않습니다." + ], + "editableFiles": [ + "src/main/resources/db/migration/V2__request_key.sql" + ], + "template": "package edu.lab;\nimport javax.sql.DataSource;\nimport org.flywaydb.core.Flyway;\npublic final class MigrationLab {\n private MigrationLab() {}\n public static Flyway configured(DataSource source) {\n return Flyway.configure().dataSource(source).locations(\"classpath:db/migration\").load();\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/MigrationLab.java", + "templatePath": "lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java", + "referencePath": "lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/flyway-migrations/starter/pom.xml", + "referencePath": "lessons/flyway-migrations/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V1__create_orders.sql", + "templatePath": "lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql", + "referencePath": "lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V2__request_key.sql", + "templatePath": "lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql", + "referencePath": "lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/flyway-migrations/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "두 버전 적용", + "재실행 시 0개 적용", + "중복 요청 키 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "sql-jdbc" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 1, + "starterSatisfied": false, + "sourceFingerprint": "f26b6d4349c223d9c0ee58c03a190193431592dd68baa2cdec3775fcc42ecd6f", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/reference/pom.xml b/curriculum/professional/lessons/flyway-migrations/reference/pom.xml new file mode 100644 index 0000000..a802b94 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlflyway-migrationsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.flywaydbflyway-core +com.h2databaseh2 diff --git a/curriculum/professional/lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java b/curriculum/professional/lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java new file mode 100644 index 0000000..67c305a --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/reference/src/main/java/edu/lab/MigrationLab.java @@ -0,0 +1,9 @@ +package edu.lab; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +public final class MigrationLab { + private MigrationLab() {} + public static Flyway configured(DataSource source) { + return Flyway.configure().dataSource(source).locations("classpath:db/migration").load(); + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql b/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql new file mode 100644 index 0000000..fe082ab --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V1__create_orders.sql @@ -0,0 +1 @@ +CREATE TABLE orders (id VARCHAR(60) PRIMARY KEY, quantity INT NOT NULL CHECK (quantity > 0)); diff --git a/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql b/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql new file mode 100644 index 0000000..0b32e30 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/reference/src/main/resources/db/migration/V2__request_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE orders ADD COLUMN request_key VARCHAR(80); +ALTER TABLE orders ADD CONSTRAINT uq_order_request UNIQUE (request_key); diff --git a/curriculum/professional/lessons/flyway-migrations/starter/pom.xml b/curriculum/professional/lessons/flyway-migrations/starter/pom.xml new file mode 100644 index 0000000..a802b94 --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlflyway-migrationsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.flywaydbflyway-core +com.h2databaseh2 diff --git a/curriculum/professional/lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java b/curriculum/professional/lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java new file mode 100644 index 0000000..67c305a --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/starter/src/main/java/edu/lab/MigrationLab.java @@ -0,0 +1,9 @@ +package edu.lab; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +public final class MigrationLab { + private MigrationLab() {} + public static Flyway configured(DataSource source) { + return Flyway.configure().dataSource(source).locations("classpath:db/migration").load(); + } +} diff --git a/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql b/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql new file mode 100644 index 0000000..fe082ab --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V1__create_orders.sql @@ -0,0 +1 @@ +CREATE TABLE orders (id VARCHAR(60) PRIMARY KEY, quantity INT NOT NULL CHECK (quantity > 0)); diff --git a/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql b/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql new file mode 100644 index 0000000..3b2a22d --- /dev/null +++ b/curriculum/professional/lessons/flyway-migrations/starter/src/main/resources/db/migration/V2__request_key.sql @@ -0,0 +1,2 @@ +-- request_key 열과 UNIQUE 제약을 추가하세요. +SELECT 1; diff --git a/curriculum/professional/lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..b5b8318 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,28 @@ +package edu.lab; +import jakarta.persistence.*; +import org.junit.jupiter.api.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + EntityManagerFactory factory; + @BeforeEach void setup() { + factory = JpaLab.open(); + var em = factory.createEntityManager(); em.getTransaction().begin(); + em.persist(new OrderEntity("A", 2)); em.getTransaction().commit(); em.close(); + } + @AfterEach void close() { factory.close(); } + int storedQuantity() { var em = factory.createEntityManager(); try { return em.find(OrderEntity.class,"A").quantity(); } finally { em.close(); } } + @Test void managedChangesAreFlushed() { + var em = factory.createEntityManager(); em.getTransaction().begin(); + em.find(OrderEntity.class,"A").changeQuantity(4); em.getTransaction().commit(); em.close(); + assertThat(storedQuantity()).isEqualTo(4); + } + @Test void detachedChangesAreNotAutomaticallySaved() { + var em = factory.createEntityManager(); OrderEntity detached = em.find(OrderEntity.class,"A"); em.close(); + detached.changeQuantity(7); assertThat(storedQuantity()).isEqualTo(2); + } + @Test void rejectsInvalidTransitionWithoutMutation() { + OrderEntity order = new OrderEntity("B",3); + assertThatThrownBy(() -> order.changeQuantity(0)).isInstanceOf(IllegalArgumentException.class); + assertThat(order.quantity()).isEqualTo(3); + } +} diff --git a/curriculum/professional/lessons/jpa-state/lesson.json b/curriculum/professional/lessons/jpa-state/lesson.json new file mode 100644 index 0000000..c4d1065 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/lesson.json @@ -0,0 +1,238 @@ +{ + "id": "jpa-state", + "title": "JPA·Hibernate의 상태와 변경 감지", + "description": "같은 changeQuantity를 호출해도 어떤 객체는 DB가 바뀌고 어떤 객체는 메모리 값만 바뀝니다. EntityManager가 관리하는 동안과 닫힌 뒤를 기존 테스트 값으로 비교합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "OrderEntity의 메서드 호출과 DB에 SQL이 반영되는 시점을 구분한다.", + "관리 중인 주문 수량 2를 4로 바꾸고 commit하면 새 조회에서 4가 보이는 흐름을 설명한다.", + "EntityManager를 닫은 뒤 객체 수량을 7로 바꿔도 DB에는 2가 남는 이유를 설명한다.", + "changeQuantity(0)이 실패할 때 객체의 이전 수량이 보존되도록 검사 순서를 지킨다." + ], + "theory": [ + { + "heading": "영속성 컨텍스트와 변경 감지", + "text": "영속성 컨텍스트는 EntityManager가 현재 추적하는 엔티티들을 관리하는 범위입니다. 그 안에 있는 객체를 관리 상태라고 합니다. 기존 managedChangesAreFlushed 테스트는 트랜잭션을 시작하고 em.find로 A 주문을 가져온 뒤 changeQuantity(4)를 호출합니다. 이 메서드 자체에는 UPDATE가 없지만 Hibernate가 관리 객체의 변경을 DB와 맞추면서 commit 뒤 새 EntityManager 조회에서 4를 읽게 됩니다.\n\n메모리 변경을 SQL로 동기화하는 flush와 트랜잭션을 확정하는 commit은 다른 일입니다. flush가 됐어도 커밋 전 변경은 롤백될 수 있고, SQL이 메서드 호출 바로 그 순간에 실행된다고 단정할 수 없습니다. 반대로 EntityManager를 닫아 관리에서 벗어난 분리(detached) 객체는 changeQuantity(7)를 불러도 그 변경이 자동으로 DB에 전달되지 않습니다." + }, + { + "heading": "엔티티와 값 객체의 차이", + "text": "OrderEntity는 @Id의 id로 DB 주문 한 건을 식별합니다. 수량이 2에서 4로 바뀌어도 A 주문이라는 식별은 유지됩니다. 같은 필드값 전체를 비교하는 값과 달리 엔티티는 어느 DB 행을 나타내며 누가 관리 중인지도 중요합니다.\n\n수량처럼 바뀌는 필드까지 equals·hashCode에 넣으면 컬렉션에서 찾는 기준이 바뀔 수 있습니다. 연관관계가 있는 엔티티의 자동 생성 메서드는 연결된 객체를 따라 읽거나 지연 로딩을 일으킬 수도 있습니다. 이 예제는 그런 일반 전략을 자동 결정하려고 @Data를 붙이지 않고 changeQuantity의 변경 규칙에 집중합니다. 실제 엔티티의 동등성은 ID가 정해지는 시점·프록시·관계를 함께 검토해야 합니다." + }, + { + "heading": "캡슐화와 ORM 제약", + "text": "캡슐화는 이 코드에서 “quantity를 아무 값으로나 대입하게 하지 않고 changeQuantity를 통해 바꾸게 한다”는 뜻입니다. changeQuantity는 quantity <= 0이면 this.quantity에 대입하기 전에 예외를 던집니다. 그래서 수량 3인 주문에 0을 요청해도 실패 뒤 수량은 3입니다. 공개 생성자도 changeQuantity를 거쳐 초기 수량에 같은 규칙을 적용합니다.\n\nprotected 무인자 생성자는 ORM이 객체를 복원할 때 쓸 생성 경로입니다. 이 코드의 @Id가 필드에 있으므로 JPA는 필드를 통해 값을 읽고 채울 수 있으며 모든 필드에 공개 setter가 필요한 것은 아닙니다. 자바 메서드의 검사만으로 DB를 직접 갱신하는 다른 경로까지 막지는 못하므로 실제 시스템에서는 DB 제약도 함께 설계해야 합니다. create-drop은 이 학습용 H2 DB를 만들고 폐기하기 위한 설정입니다." + } + ], + "example": { + "title": "JpaLab 기준 구현", + "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class JpaLab {\n private JpaLab() {}\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"orders\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 관리 엔티티 변경 감지 · 분리 객체 변경 비반영 · 실패 시 객체 수량 보존.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "JpaLab.open은 테스트마다 별도 이름의 H2 DB를 사용하는 EntityManagerFactory를 만듭니다. 실제 수량 변경은 함께 제공된 OrderEntity.java의 changeQuantity가 맡습니다. 이 메서드는 양수인지 확인한 뒤 필드에 대입할 뿐 SQL을 직접 실행하지 않습니다. 관리 중인 A의 수량 2를 4로 바꾸고 commit하면 새 조회는 4를 읽지만, EntityManager를 닫은 뒤 A 객체를 7로 바꾸면 DB는 2 그대로입니다. 수량 3인 B를 0으로 바꾸려는 시도는 대입 전에 예외가 나서 객체 값도 3으로 남습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/JpaLab.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class JpaLab {\n private JpaLab() {}\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"orders\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljpa-stateorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.hibernate.ormhibernate-core\njakarta.persistencejakarta.persistence-api\ncom.h2databaseh2\n" + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "language": "xml", + "code": "\n\n \n org.hibernate.jpa.HibernatePersistenceProvideredu.lab.OrderEntity\n true\n \n \n \n \n \n \n \n \n\n" + }, + { + "path": "src/main/java/edu/lab/OrderEntity.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.persistence.*;\n@Entity\n@Table(name=\"purchase_orders\")\npublic class OrderEntity {\n @Id private String id;\n private int quantity;\n protected OrderEntity() {}\n public OrderEntity(String id, int quantity) {\n if (id == null || id.isBlank()) throw new IllegalArgumentException(\"id\");\n this.id = id; changeQuantity(quantity);\n }\n public String id() { return id; }\n public int quantity() { return quantity; }\n public void changeQuantity(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n this.quantity = quantity;\n // END EXERCISE\n }\n}\n" + } + ] + }, + "quiz": [ + { + "id": "jpa-state-q1", + "question": "EntityManager를 닫은 뒤 detached 주문의 수량을 바꾸면?", + "options": [ + "setter 호출만으로 항상 UPDATE된다.", + "모든 엔티티는 JVM 종료까지 관리 상태다.", + "@Entity가 있으면 트랜잭션이 필요 없다.", + "다시 영속화하는 별도 과정 없이는 DB에 자동 반영되지 않는다." + ], + "correctIndex": 3, + "explanation": "EntityManager를 닫으면 그 객체는 더 이상 해당 관리 범위에서 변경 추적되지 않습니다. 기존 테스트의 detached.changeQuantity(7)는 자바 필드를 7로 바꾸지만 storedQuantity()가 새 EntityManager로 읽는 DB 값은 2입니다. @Entity 표시가 있다는 이유만으로 모든 객체 변경이 자동 저장되지는 않습니다." + }, + { + "id": "jpa-state-q2", + "question": "엔티티에 모든 필드 기반 equals/hashCode를 적용하기 전에 확인할 사항은?", + "options": [ + "들여쓰기만 확인한다.", + "변경 가능한 필드·연관관계·프록시와 식별자 전략을 검토한다.", + "DB 컬럼 순서만 확인한다.", + "record로 바꾸면 모든 JPA 제약이 없어진다." + ], + "correctIndex": 1, + "explanation": "OrderEntity의 quantity는 정상적으로 바뀔 수 있는 값입니다. 이를 해시 계산에 넣었을 때 컬렉션 조회가 안정적인지, 저장 전후 ID가 같게 유지되는지, 연관관계나 프록시를 읽는 영향이 있는지 따져야 합니다. 코드 줄 수나 DB 열 순서만으로 equals·hashCode 전략을 결정할 수는 없습니다." + } + ], + "practice": { + "title": "객체 수량과 DB 수량을 따로 확인하기", + "scenario": "주문 수량 변경 메서드를 실행했는데 새로 조회하면 옛 수량이 보입니다. 메서드 본문뿐 아니라 그 객체를 읽은 EntityManager가 아직 관리 중인지, 트랜잭션을 확정했는지 확인하려 합니다.", + "tasks": [ + "OrderEntity.changeQuantity에서 양수 검사를 대입보다 먼저 하세요. 수량 3에서 0 변경이 실패하면 객체 값도 3이어야 합니다.", + "기존 managedChangesAreFlushed의 begin → find → changeQuantity(4) → commit → 새 EntityManager 조회를 따라 DB 수량이 4인 이유를 설명하세요.", + "detachedChangesAreNotAutomaticallySaved의 find → close → changeQuantity(7) 순서를 비교하고, 메모리 객체와 DB 수량 2를 구분하세요.", + "persistence.xml의 create-drop은 폐기 가능한 학습 DB용이며 운영 마이그레이션 정책이 아닙니다.", + "flush가 SQL 동기화이고 commit은 트랜잭션 확정이라는 차이를 설명하세요. 이 테스트에는 여러 요청의 동시 수정 충돌이나 운영 DB의 잠금 검증이 없습니다." + ], + "takeaway": "필드를 바꾸는 메서드와 DB에 반영하는 관리 상태를 함께 읽습니다. 값이 바뀌었다는 사실만으로 저장을 판단하지 말고 새 EntityManager의 조회 결과와 트랜잭션 종료를 확인합니다." + }, + "sources": [ + { + "title": "Hibernate persistence context", + "url": "https://docs.hibernate.org/orm/6.6/userguide/html_single/#pc", + "edition": "Hibernate ORM 6.6", + "checkedAt": "2026-10-12" + }, + { + "title": "Jakarta Persistence", + "url": "https://jakarta.ee/specifications/persistence/3.1/", + "edition": "Jakarta Persistence 3.1", + "checkedAt": "2026-10-12" + }, + { + "title": "Lombok EqualsAndHashCode", + "url": "https://projectlombok.org/features/EqualsAndHashCode", + "edition": "Lombok", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "JPA", + "Hibernate" + ], + "exerciseId": "jpa-state-project", + "professionalExercise": { + "id": "jpa-state-project", + "profileId": "professional-jpa", + "mode": "project", + "instructions": [ + "OrderEntity.changeQuantity는 양수만 허용하고 실패하면 기존 수량을 유지합니다.", + "관리 상태에서 수량을 변경하고 commit한 뒤 새 EntityManager로 읽으면 변경이 보입니다.", + "EntityManager를 닫은 뒤 detached 엔티티를 변경해도 DB 값은 바뀌지 않습니다.", + "create-drop 설정은 폐기 가능한 학습 DB용이며 운영 마이그레이션 정책이 아닙니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrderEntity.java" + ], + "template": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class JpaLab {\n private JpaLab() {}\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"orders\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/JpaLab.java", + "templatePath": "lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java", + "referencePath": "lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/jpa-state/starter/pom.xml", + "referencePath": "lessons/jpa-state/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "templatePath": "lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml", + "referencePath": "lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml", + "editable": false + }, + { + "path": "src/main/java/edu/lab/OrderEntity.java", + "templatePath": "lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java", + "referencePath": "lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/jpa-state/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "관리 엔티티 변경 감지", + "분리 객체 변경 비반영", + "실패 시 객체 수량 보존" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "sql-jdbc", + "lombok-values" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "c721dc8a92215ad6b837e104656f0ba8defb5781844f845447f5194a1737332c", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "수량 2인 A 주문을 읽고 changeQuantity를 호출합니다. 관리 중에 4로 바꾼 경우와 EntityManager를 닫은 뒤 7로 바꾼 경우는 같은 메서드를 써도 DB 결과가 다릅니다.", + "question": "changeQuantity에 SQL이 없는데 언제 DB 수량이 바뀌고, 언제 객체 값만 바뀔까요?", + "terms": [ + { + "term": "영속성 컨텍스트", + "meaning": "EntityManager가 현재 추적하는 엔티티들을 관리하는 범위입니다. em.find로 읽은 OrderEntity가 이 범위 안에 있는지에 따라 자동 변경 반영 여부가 달라집니다." + }, + { + "term": "관리 상태와 분리 상태", + "meaning": "관리 상태는 EntityManager가 추적 중인 상태이고, 분리 상태는 그 관리에서 벗어난 상태입니다. 기존 테스트는 em.close()로 두 상황을 나눕니다." + }, + { + "term": "변경 감지", + "meaning": "관리 객체의 값이 바뀐 것을 확인해 DB에 맞추는 동작입니다. changeQuantity(4)의 필드 대입이 관리 상태에서는 SQL 반영으로 이어집니다." + }, + { + "term": "flush와 commit", + "meaning": "flush는 변경을 SQL로 동기화하는 일이고 commit은 트랜잭션을 확정하는 일입니다. SQL이 나갔다는 것만으로 확정된 것은 아닙니다." + } + ], + "walkthrough": [ + "공개 OrderEntity 생성자는 id를 확인하고 changeQuantity(quantity)를 호출합니다. 처음 만들 때도 수량 규칙을 같은 메서드로 검사합니다.", + "changeQuantity는 0 이하인지 먼저 확인하고, 통과한 경우에만 this.quantity에 새 값을 넣습니다. 이 본문 자체에는 SQL 호출이 없습니다.", + "기존 관리 상태 테스트는 begin 후 em.find로 A를 읽고 changeQuantity(4)를 부릅니다. commit 때 필요한 변경이 DB에 동기화·확정되어 새 조회가 4를 읽습니다.", + "분리 상태 테스트는 A를 읽은 em을 먼저 닫습니다. 이후 detached.changeQuantity(7)은 객체의 필드만 바꾸고, 새 EntityManager가 읽는 DB 수량은 2입니다.", + "기존 실패 테스트의 B는 수량 3입니다. changeQuantity(0)은 if에서 예외를 던져 대입을 건너뛰므로 order.quantity()는 계속 3입니다." + ], + "prediction": { + "question": "DB 수량 2인 A를 em.find로 읽고 em.close()한 뒤 그 객체에서 changeQuantity(7)를 호출합니다. 새 EntityManager로 A를 읽으면 수량은 얼마인가요?", + "answer": "2입니다. 바꾼 분리 객체의 수량만 7입니다.", + "explanation": "changeQuantity의 자바 대입은 실행됐지만 원래 EntityManager는 닫혔습니다. 그 뒤 변경을 DB에 반영하는 별도 과정이 없으므로 자동 UPDATE가 일어나지 않습니다." + }, + "takeaway": "“메서드가 실행됐다”와 “DB에 저장됐다”는 확인 지점이 다릅니다. 엔티티를 누가 관리하는지, 어느 트랜잭션에서 확정하는지를 함께 추적합니다.", + "code": { + "file": "src/main/java/edu/lab/OrderEntity.java", + "source": " public OrderEntity(String id, int quantity) {\n if (id == null || id.isBlank()) throw new IllegalArgumentException(\"id\");\n this.id = id; changeQuantity(quantity);\n }\n public String id() { return id; }\n public int quantity() { return quantity; }\n public void changeQuantity(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0) throw new IllegalArgumentException(\"quantity\");\n this.quantity = quantity;\n // END EXERCISE\n }\n}\n" + } + } +} diff --git a/curriculum/professional/lessons/jpa-state/reference/pom.xml b/curriculum/professional/lessons/jpa-state/reference/pom.xml new file mode 100644 index 0000000..46823d4 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljpa-stateorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.hibernate.ormhibernate-core +jakarta.persistencejakarta.persistence-api +com.h2databaseh2 diff --git a/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java b/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java new file mode 100644 index 0000000..6bb00cb --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/JpaLab.java @@ -0,0 +1,11 @@ +package edu.lab; +import jakarta.persistence.*; +import java.util.Map; +import java.util.UUID; +public final class JpaLab { + private JpaLab() {} + public static EntityManagerFactory open() { + return Persistence.createEntityManagerFactory("orders", Map.of( + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1")); + } +} diff --git a/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java b/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java new file mode 100644 index 0000000..7bf6ad9 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/reference/src/main/java/edu/lab/OrderEntity.java @@ -0,0 +1,21 @@ +package edu.lab; +import jakarta.persistence.*; +@Entity +@Table(name="purchase_orders") +public class OrderEntity { + @Id private String id; + private int quantity; + protected OrderEntity() {} + public OrderEntity(String id, int quantity) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("id"); + this.id = id; changeQuantity(quantity); + } + public String id() { return id; } + public int quantity() { return quantity; } + public void changeQuantity(int quantity) { + // BEGIN EXERCISE + if (quantity <= 0) throw new IllegalArgumentException("quantity"); + this.quantity = quantity; + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml b/curriculum/professional/lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..c13eb01 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/reference/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,14 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvideredu.lab.OrderEntity + true + + + + + + + + + diff --git a/curriculum/professional/lessons/jpa-state/starter/pom.xml b/curriculum/professional/lessons/jpa-state/starter/pom.xml new file mode 100644 index 0000000..46823d4 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljpa-stateorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.hibernate.ormhibernate-core +jakarta.persistencejakarta.persistence-api +com.h2databaseh2 diff --git a/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java b/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java new file mode 100644 index 0000000..6bb00cb --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/JpaLab.java @@ -0,0 +1,11 @@ +package edu.lab; +import jakarta.persistence.*; +import java.util.Map; +import java.util.UUID; +public final class JpaLab { + private JpaLab() {} + public static EntityManagerFactory open() { + return Persistence.createEntityManagerFactory("orders", Map.of( + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1")); + } +} diff --git a/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java b/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java new file mode 100644 index 0000000..6ec2240 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/starter/src/main/java/edu/lab/OrderEntity.java @@ -0,0 +1,18 @@ +package edu.lab; +import jakarta.persistence.*; +@Entity +@Table(name="purchase_orders") +public class OrderEntity { + @Id private String id; + private int quantity; + protected OrderEntity() {} + public OrderEntity(String id, int quantity) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("id"); + this.id = id; changeQuantity(quantity); + } + public String id() { return id; } + public int quantity() { return quantity; } + public void changeQuantity(int quantity) { + throw new UnsupportedOperationException("양수 불변식을 구현하세요"); + } +} diff --git a/curriculum/professional/lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml b/curriculum/professional/lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..c13eb01 --- /dev/null +++ b/curriculum/professional/lessons/jpa-state/starter/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,14 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvideredu.lab.OrderEntity + true + + + + + + + + + diff --git a/curriculum/professional/lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..54cbc44 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,34 @@ +package edu.lab; +import com.fasterxml.jackson.databind.*; +import org.junit.jupiter.api.*; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +class ContractTest { + private LocalValidatorFactoryBean validator; + private MockMvc mvc; + @BeforeEach void setup() { + validator = new LocalValidatorFactoryBean(); validator.afterPropertiesSet(); + ObjectMapper mapper = new ObjectMapper().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mvc = standaloneSetup(new CreateOrderController()).setValidator(validator) + .setMessageConverters(new MappingJackson2HttpMessageConverter(mapper)).build(); + } + @AfterEach void cleanup() { validator.close(); } + private void rejects(String json) throws Exception { + mvc.perform(post("/orders").contentType("application/json").content(json)).andExpect(status().isBadRequest()); + } + @Test void acceptsValidInput() throws Exception { + mvc.perform(post("/orders").contentType("application/json").content("{\"sku\":\"BOOK\",\"quantity\":2}")) + .andExpect(status().isCreated()).andExpect(jsonPath("$.sku").value("BOOK")).andExpect(jsonPath("$.quantity").value(2)); + } + @Test void validatesFields() throws Exception { + rejects("{\"sku\":\"BOOK\",\"quantity\":0}"); rejects("{\"sku\":\" \",\"quantity\":1}"); + rejects("{\"sku\":\"BOOK\"}"); + } + @Test void rejectsMalformedAndUnknownFields() throws Exception { + rejects("{broken"); rejects("{\"sku\":\"BOOK\",\"quantity\":1,\"quanity\":8}"); + } +} diff --git a/curriculum/professional/lessons/json-validation/lesson.json b/curriculum/professional/lessons/json-validation/lesson.json new file mode 100644 index 0000000..2e061e4 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/lesson.json @@ -0,0 +1,216 @@ +{ + "id": "json-validation", + "title": "Jackson·Validation의 입력 경계", + "description": "깨진 JSON, 수량 0, 알 수 없는 필드는 서로 다른 단계에서 거절됩니다. Request를 만드는 단계와 @Valid 검사, create 본문에 도달하는 순서를 따라갑니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "JSON을 Request로 바꾸는 과정과 Request 값의 제약 검사를 구분한다.", + "정상 sku·quantity가 create를 거쳐 201 응답으로 반환되는 순서를 설명한다.", + "0 수량·공백 sku가 create 본문 전에 400으로 거절되는 이유를 설명한다.", + "깨진 JSON·미지 필드 거절이 Jackson 설정과 연결된다는 점을 확인한다." + ], + "theory": [ + { + "heading": "세 가지 입력 경계", + "text": "여기서 입력 경계란 외부에서 받은 값을 프로그램이 사용하기 전에 확인하는 지점입니다. 먼저 Jackson이 JSON 문장을 읽어 Request 객체로 바꿉니다. 깨진 JSON이면 이 단계에서 실패합니다. 객체가 만들어지면 MVC가 @Valid에 따라 sku의 @NotBlank와 quantity의 @Min(1)을 검사합니다. 수량 0은 int로 만들 수 있어도 주문 수량 조건에 맞지 않아 이 단계에서 거절됩니다.\n\n필드 검사를 통과해야 create 본문이 실행되어 Created를 반환합니다. 다만 @Min(1)은 재고가 있는지까지 확인하지 않습니다. 실제 주문에서 재고 부족·판매 중단 같은 업무 규칙은 서비스가 현재 데이터를 보고 검사해야 합니다. 이 예제의 create는 받은 값을 응답으로 옮길 뿐 실제 주문을 DB에 저장하지 않습니다." + }, + { + "heading": "누락과 기본값", + "text": "기존 테스트 설정에서 {\"sku\":\"BOOK\"}처럼 quantity를 생략하면 원시 int 값은 0이 되고 @Min(1)에 걸립니다. “값이 없었다”와 “명시적으로 0을 보냈다”가 같은 0으로 보이므로, 두 경우를 API에서 구분해야 한다면 타입과 입력 정책을 함께 정해야 합니다.\n\nInteger로 바꾸면 누락을 null로 표현할 수 있지만 @Min만으로 null을 거절하지는 않습니다. 필수값이어야 한다면 @NotNull 같은 별도 제약도 필요합니다. 이 설명은 타입을 무조건 바꾸라는 처방이 아니라, 현재 Request의 int와 @Min(1)이 누락을 어떻게 처리하는지 읽기 위한 비교입니다." + }, + { + "heading": "호환성과 엄격성", + "text": "알 수 없는 필드 이름을 거절하는 일은 sku·quantity 값의 제약 검사와 다른 역할입니다. 이 수업의 ContractTest.setup은 ObjectMapper에 FAIL_ON_UNKNOWN_PROPERTIES를 켜서 \"quanity\" 같은 필드를 보내면 Jackson 변환 단계에서 실패하게 합니다. 컨트롤러의 @Valid만으로 이 엄격한 필드 정책이 생기는 것은 아닙니다.\n\n이 선택은 오타를 빨리 드러내지만, 새 클라이언트가 추가 필드를 보내는 동안 구 서버가 이를 거절할 수 있다는 비용도 있습니다. 실제 API에서는 서버·클라이언트가 서로 다른 버전으로 배포되는 기간을 고려해 정책을 정합니다. 이 수업의 400 결과는 지정된 MockMvc 변환기·검증기 설정에 대한 결과로 읽습니다." + } + ], + "example": { + "title": "CreateOrderController 기준 구현", + "code": "package edu.lab;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class CreateOrderController {\n // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n // BEGIN EXERCISE\n return new Created(request.sku(), request.quantity());\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 정상 생성 201 · 0 수량·공백 sku 400 · 깨진 JSON·미지 필드 400.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "Request는 sku에 @NotBlank, quantity에 @Min(1)을 선언합니다. 테스트의 MVC 경로로 {\"sku\":\"BOOK\",\"quantity\":2}를 보내면 Jackson이 Request를 만들고 @Valid 검사를 통과한 뒤 create가 Created(\"BOOK\", 2)를 반환합니다. @ResponseStatus가 응답을 201로 정합니다. quantity 0과 공백 sku는 본문 전에 거절됩니다. 알 수 없는 필드 거절은 별도 ContractTest.setup의 ObjectMapper 설정에서 오며, 이 컨트롤러에는 저장소나 재고 검사 구현이 없습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/CreateOrderController.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class CreateOrderController {\n // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n // BEGIN EXERCISE\n return new Created(request.sku(), request.quantity());\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljson-validationorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-validation\norg.springframework.bootspring-boot-starter-testtest\n" + } + ] + }, + "quiz": [ + { + "id": "json-validation-q1", + "question": "이 과제의 quantity가 누락되어 0이 되면 어떻게 처리되는가?", + "options": [ + "@Min(1) 검증으로 400이 된다.", + "정상 수량 1로 자동 보정된다.", + "@Valid가 있으면 모든 값이 허용된다.", + "int는 항상 요청에 존재하므로 누락할 수 없다." + ], + "correctIndex": 0, + "explanation": "현재 테스트의 Jackson 설정은 빠진 원시 int quantity를 0으로 만들고, MVC가 @Valid로 @Min(1)을 검사합니다. 0은 조건에 맞지 않아 create 본문에 들어가기 전에 400으로 끝납니다. 수량 1로 자동 보정하는 코드는 없습니다." + }, + { + "id": "json-validation-q2", + "question": "알 수 없는 JSON 필드를 거절하는 정책의 설명으로 맞는 것은?", + "options": [ + "항상 API 호환성이 좋아진다.", + "ObjectMapper 설정과 API 호환성 요구를 함께 결정해야 한다.", + "Bean Validation이 자동으로 모든 미지 필드를 거절한다.", + "HTTP 상태 코드와 무관하므로 문서화할 필요가 없다." + ], + "correctIndex": 1, + "explanation": "FAIL_ON_UNKNOWN_PROPERTIES는 JSON 이름을 Request의 필드에 맞추는 Jackson 설정입니다. @Valid가 검사하는 것은 이미 만들어진 Request의 값입니다. 알 수 없는 필드를 거절하면 오타를 찾기 쉽지만 구 서버가 새 클라이언트의 추가 필드도 거절할 수 있으므로 배포 호환성과 함께 결정해야 합니다." + } + ], + "practice": { + "title": "요청이 어느 단계에서 거절되는지 찾기", + "scenario": "클라이언트가 수량을 빠뜨리거나 quantity를 quanity로 잘못 적어 보냅니다. 서버는 실패 원인을 구분해 입력을 거절하고 정상 요청만 응답 객체로 옮겨야 합니다.", + "tasks": [ + "sku는 공백이 아니고 quantity는 1 이상이어야 합니다. 각각 Request의 어떤 제약이 담당하는지 설명하세요.", + "POST /orders의 {\"sku\":\"BOOK\",\"quantity\":2}가 검증 후 201과 sku·quantity를 반환하도록 create를 완성하세요.", + "깨진 JSON, quantity 0, 알 수 없는 quanity를 각각 Jackson 변환 단계와 @Valid 검사 단계 중 어디서 거절하는지 구분하세요.", + "ContractTest.setup의 FAIL_ON_UNKNOWN_PROPERTIES 설정을 찾아 @Valid만으로 미지 필드를 거절한다고 오해하지 않도록 설명하세요.", + "현재 Created 반환은 실제 주문 저장이 아닙니다. 실무의 재고 검사·저장 규칙은 어느 서비스에서 확인할지, 컨트롤러를 자바로 직접 호출하면 MVC 검증을 거치지 않는다는 점과 함께 적으세요." + ], + "takeaway": "JSON 문장을 읽을 수 있는지, 객체의 값이 조건에 맞는지, 실제로 주문할 수 있는지는 서로 다른 질문입니다. 실패한 단계를 알면 입력 오류를 내부 장애나 저장 성공과 혼동하지 않습니다." + }, + "sources": [ + { + "title": "Spring MVC validation", + "url": "https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-controller/ann-validation.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Jackson databind deserialization features", + "url": "https://github.com/FasterXML/jackson-databind/wiki/Deserialization-Features", + "edition": "Jackson 2", + "checkedAt": "2026-10-12" + }, + { + "title": "Jakarta Validation specification", + "url": "https://jakarta.ee/specifications/bean-validation/3.0/", + "edition": "Jakarta Bean Validation 3.0", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring MVC", + "Jackson", + "Validation" + ], + "exerciseId": "json-validation-project", + "professionalExercise": { + "id": "json-validation-project", + "profileId": "professional-spring-web", + "mode": "project", + "instructions": [ + "sku는 공백이 아니고 quantity는 1 이상이어야 합니다.", + "POST /orders의 유효한 JSON은 201과 sku·quantity를 반환합니다.", + "잘못된 JSON, 잘못된 필드 값, 알 수 없는 필드는 400입니다.", + "메서드 안의 업무 규칙과 @Valid 요청 검증이 같은 검사라고 가정하지 마세요." + ], + "editableFiles": [ + "src/main/java/edu/lab/CreateOrderController.java" + ], + "template": "package edu.lab;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class CreateOrderController {\n // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/CreateOrderController.java", + "templatePath": "lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java", + "referencePath": "lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/json-validation/starter/pom.xml", + "referencePath": "lessons/json-validation/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/json-validation/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "정상 생성 201", + "0 수량·공백 sku 400", + "깨진 JSON·미지 필드 400" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "mvc-rest" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "cb2dd529c781109c07cf6272989e94ab712c38e29484390922d7e38fb4f046b9", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "같은 POST /orders라도 깨진 JSON과 수량 0은 다른 이유로 실패합니다. {\"sku\":\"BOOK\",\"quantity\":2}가 실제로 create 본문에 도착하기까지 거치는 단계를 봅니다.", + "question": "int로 변환할 수 있는 quantity 0은 왜 정상 요청이 아니며, 누가 거절할까요?", + "terms": [ + { + "term": "역직렬화", + "meaning": "전송된 JSON을 자바 객체로 바꾸는 일입니다. Jackson은 요청 본문을 이 코드의 Request로 만듭니다." + }, + { + "term": "Bean Validation", + "meaning": "객체 값이 애너테이션의 조건에 맞는지 검사하는 기능입니다. Request에서는 공백이 아닌 sku와 1 이상 quantity를 확인합니다." + }, + { + "term": "@Valid", + "meaning": "이 MVC 요청 처리에서 만들어진 Request에 검증을 적용하도록 연결하는 표시입니다. 필드별 조건 자체는 @NotBlank와 @Min에 있습니다." + }, + { + "term": "업무 규칙", + "meaning": "형식이 맞는 요청을 실제로 처리해도 되는지 정한 조건입니다. 주문 수량이 1 이상이어도 재고가 충분한지는 별도 데이터로 판단해야 합니다." + } + ], + "walkthrough": [ + "@RequestBody는 요청 JSON을 Request로 변환해 받도록 합니다. 기존 테스트는 Jackson 변환기와 검증기를 MockMvc에 등록합니다.", + "Request의 quantity가 2이고 sku가 BOOK이면 @Min(1)과 @NotBlank를 모두 만족합니다.", + "MVC가 @Valid 검사를 마친 뒤 create 본문을 실행합니다. request.sku()와 request.quantity()를 읽어 Created에 그대로 옮깁니다.", + "@ResponseStatus(HttpStatus.CREATED)가 이 정상 반환의 상태 코드를 201로 정하고 Created가 JSON 응답이 됩니다.", + "quantity가 0이면 객체로는 만들 수 있지만 @Min(1)을 통과하지 못해 본문 전에 400으로 끝납니다. 깨진 JSON이나 엄격한 설정의 미지 필드는 그보다 앞선 Jackson 변환 단계에서 실패합니다." + ], + "prediction": { + "question": "기존 MockMvc 설정에 {\"sku\":\"BOOK\"}를 POST하면 create 본문이 실행되고 201이 나올까요?", + "answer": "아닙니다. quantity가 0이 되어 @Min(1)에 걸리고 400이 됩니다.", + "explanation": "JSON 문법은 맞아 Request를 만들 수 있지만, 이 설정에서 누락된 원시 int의 값 0이 수량 조건에 맞지 않습니다. Created를 만드는 본문에 들어가기 전에 거절됩니다." + }, + "takeaway": "“값이 자바 타입에 들어왔다”는 것은 검사 완료가 아닙니다. Request 생성 → 값 검증 → 업무 처리의 순서를 구분해 읽습니다.", + "code": { + "file": "src/main/java/edu/lab/CreateOrderController.java", + "source": " // BEGIN MODEL\n public record Request(@NotBlank String sku, @Min(1) int quantity) {}\n // END MODEL\n public record Created(String sku, int quantity) {}\n @PostMapping\n @ResponseStatus(HttpStatus.CREATED)\n public Created create(@Valid @RequestBody Request request) {\n // BEGIN EXERCISE\n return new Created(request.sku(), request.quantity());\n // END EXERCISE\n }\n" + } + } +} diff --git a/curriculum/professional/lessons/json-validation/reference/pom.xml b/curriculum/professional/lessons/json-validation/reference/pom.xml new file mode 100644 index 0000000..bf79200 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljson-validationorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-testtest diff --git a/curriculum/professional/lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java b/curriculum/professional/lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java new file mode 100644 index 0000000..747282b --- /dev/null +++ b/curriculum/professional/lessons/json-validation/reference/src/main/java/edu/lab/CreateOrderController.java @@ -0,0 +1,21 @@ +package edu.lab; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/orders") +public class CreateOrderController { + // BEGIN MODEL + public record Request(@NotBlank String sku, @Min(1) int quantity) {} + // END MODEL + public record Created(String sku, int quantity) {} + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Created create(@Valid @RequestBody Request request) { + // BEGIN EXERCISE + return new Created(request.sku(), request.quantity()); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/json-validation/starter/pom.xml b/curriculum/professional/lessons/json-validation/starter/pom.xml new file mode 100644 index 0000000..bf79200 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljson-validationorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-testtest diff --git a/curriculum/professional/lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java b/curriculum/professional/lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java new file mode 100644 index 0000000..c2480e1 --- /dev/null +++ b/curriculum/professional/lessons/json-validation/starter/src/main/java/edu/lab/CreateOrderController.java @@ -0,0 +1,19 @@ +package edu.lab; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/orders") +public class CreateOrderController { + // BEGIN MODEL + public record Request(@NotBlank String sku, @Min(1) int quantity) {} + // END MODEL + public record Created(String sku, int quantity) {} + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Created create(@Valid @RequestBody Request request) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..be178b4 --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,47 @@ +package edu.lab; +import java.security.*; +import java.security.interfaces.RSAPublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.time.*; +import java.util.List; +import com.nimbusds.jose.jwk.*; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.proc.SecurityContext; +import org.junit.jupiter.api.*; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + static KeyPair pair; + static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z"); + @BeforeAll static void keys() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); pair = generator.generateKeyPair(); } + JwtDecoder decoder() { return JwtLab.decoder((RSAPublicKey)pair.getPublic(),Clock.fixed(NOW,ZoneOffset.UTC)); } + String token(KeyPair signing, String issuer, String audience, Instant expiry, Instant nbf, String subject) { + RSAKey jwk = new RSAKey.Builder((RSAPublicKey)signing.getPublic()).privateKey((RSAPrivateKey)signing.getPrivate()).keyID("training").build(); + NimbusJwtEncoder encoder = new NimbusJwtEncoder(new ImmutableJWKSet(new JWKSet(jwk))); + var claims = JwtClaimsSet.builder().issuer(issuer).issuedAt(NOW.minusSeconds(120)); + if (audience != null) claims.audience(List.of(audience)); + if (expiry != null) claims.expiresAt(expiry); if (nbf != null) claims.notBefore(nbf); if (subject != null) claims.subject(subject); + return encoder.encode(JwtEncoderParameters.from(JwsHeader.with(SignatureAlgorithm.RS256).keyId("training").build(),claims.build())).getTokenValue(); + } + String valid() { return token(pair,JwtLab.ISSUER,"orders-api",NOW.plusSeconds(60),NOW.minusSeconds(1),"buyer-A"); } + @Test void acceptsValid() { assertThat(decoder().decode(valid()).getSubject()).isEqualTo("buyer-A"); } + @Test void rejectsIssuerAndAudience() { + assertThatThrownBy(() -> decoder().decode(token(pair,"https://other.invalid","orders-api",NOW.plusSeconds(60),null,"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"another-api",NOW.plusSeconds(60),null,"A"))).isInstanceOf(JwtException.class); + } + @Test void rejectsTimeAndMissingClaims() { + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",NOW.minusSeconds(1),null,"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",NOW.plusSeconds(60),NOW.plusSeconds(10),"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",null,null,"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",NOW.plusSeconds(60),null,null))).isInstanceOf(JwtException.class); + } + @Test void rejectsMissingAudienceAndExactExpiry() { + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,null,NOW.plusSeconds(60),null,"A"))).isInstanceOf(JwtException.class); + assertThatThrownBy(() -> decoder().decode(token(pair,JwtLab.ISSUER,"orders-api",NOW,null,"A"))).isInstanceOf(JwtException.class); + } + @Test void rejectsWrongKey() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); KeyPair other = generator.generateKeyPair(); + assertThatThrownBy(() -> decoder().decode(token(other,JwtLab.ISSUER,"orders-api",NOW.plusSeconds(60),null,"A"))).isInstanceOf(JwtException.class); + } +} diff --git a/curriculum/professional/lessons/jwt-validation/lesson.json b/curriculum/professional/lessons/jwt-validation/lesson.json new file mode 100644 index 0000000..f1a14e4 --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/lesson.json @@ -0,0 +1,213 @@ +{ + "id": "jwt-validation", + "title": "JWT 서명·발급자·대상·만료 검증", + "description": "서명은 맞지만 다른 API에 쓰라고 발급된 토큰을 주문 API가 받으면 안 됩니다. JwtLab의 공개키 검사와 발급자·대상·만료·사용자 ID 조건을 한 줄씩 연결합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "서명 검증에 성공해도 JWT를 거절해야 하는 조건을 코드에서 찾는다.", + "정상 토큰이 통과하려면 필요한 issuer·audience·subject·exp를 설명한다.", + "다른 발급자·다른 API 대상·다른 키 서명에 대한 거절을 구분한다.", + "만료와 아직 사용 가능한 시각이 오지 않은 nbf를 고정 Clock으로 비교한다.", + "필수 값 누락과 현재 시각에 정확히 만료된 토큰을 거절하는 줄을 찾는다.", + "로컬 키 검사가 운영 키 배포·교체 검증과 다른 범위임을 설명한다." + ], + "learningGuide": { + "scenario": "주문 API가 신뢰하는 키로 서명된 토큰이 왔지만, audience에는 another-api만 들어 있습니다. subject와 만료 시각은 정상입니다.", + "question": "키가 맞는다는 이유만으로 이 토큰을 받아도 될까요?", + "terms": [ + { + "term": "claim", + "meaning": "토큰이 담고 있는 이름·값 정보입니다. issuer, audience, subject, exp 등이 각각 다른 조건을 나타냅니다." + }, + { + "term": "issuer / audience", + "meaning": "issuer는 발급자, audience는 토큰을 받을 대상입니다. 이 예제는 지정 발급자와 orders-api라는 대상을 요구합니다." + }, + { + "term": "exp / nbf", + "meaning": "exp는 만료 시각, nbf는 사용 가능 시작 시각입니다. 서명이 맞아도 현재 시각이 허용 범위 밖이면 거절합니다." + }, + { + "term": "Clock", + "meaning": "현재 시각을 제공하는 Java 객체입니다. 테스트에서는 시간을 고정해 만료 경계를 반복해서 확인할 수 있습니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/JwtLab.java", + "source": " NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock);\n OAuth2TokenValidator required = jwt -> {\n boolean valid = jwt.getAudience() != null && jwt.getAudience().contains(\"orders-api\")\n && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant())\n && jwt.getSubject() != null && !jwt.getSubject().isBlank();\n return valid ? OAuth2TokenValidatorResult.success()\n : OAuth2TokenValidatorResult.failure(new OAuth2Error(\"invalid_token\",\"required claims missing or wrong\",null));\n };\n decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required));\n return decoder;" + }, + "walkthrough": [ + "NimbusJwtDecoder.withPublicKey(publicKey).build()는 주어진 RSA 공개키로 서명 검사할 decoder를 만듭니다. 아래 claim 조건은 이 검사에 추가됩니다.", + "JwtTimestampValidator(Duration.ZERO)와 time.setClock(clock)은 시간 오차 허용을 0으로 두고 인수로 받은 시각을 사용합니다.", + "required의 boolean valid는 audience에 orders-api 포함, exp 존재 및 현재보다 뒤, 비어 있지 않은 subject를 &&로 묶습니다. 모두 참이어야 합니다.", + "return valid ? ...는 조건을 만족하면 성공을, 아니면 invalid_token 오류를 돌려줍니다. 토큰 내용 읽기에 성공해도 이 결과는 실패일 수 있습니다.", + "setJwtValidator의 DelegatingOAuth2TokenValidator는 time, JwtIssuerValidator(ISSUER), required를 함께 적용합니다. return decoder로 돌려준 객체가 토큰을 읽을 때 이 정책을 검사합니다." + ], + "prediction": { + "question": "서명·issuer·subject·시간이 모두 맞고 audience가 another-api뿐인 토큰은 통과할까요?", + "answer": "거절됩니다. required의 orders-api 포함 조건이 false입니다.", + "explanation": "&&로 묶인 조건 중 하나라도 거짓이면 required가 invalid_token을 반환합니다. 서명 성공은 이 토큰이 현재 주문 API를 대상으로 발급됐다는 뜻까지 보장하지 않습니다." + }, + "takeaway": "서명 검사와 토큰 수용 조건을 모두 읽어야, 어떤 토큰을 왜 거절하는지 설명할 수 있습니다." + }, + "theory": [ + { + "heading": "서명과 수용 정책", + "text": "JWT의 claim은 토큰 안에 담긴 이름·값 정보입니다. 내용을 읽을 수 있다는 것만으로 발급자를 믿을 수는 없습니다. decoder의 NimbusJwtDecoder.withPublicKey(publicKey)는 주어진 RSA 공개키로 서명을 확인하며 이 설정의 기본 허용 알고리즘은 RS256입니다. 서명이 맞아도 required의 audience에 orders-api가 없거나 JwtIssuerValidator가 보는 issuer가 다르면 거절합니다. 서명은 내용의 무결성을, 추가 조건은 이 주문 API가 받아들일 토큰인지를 확인합니다." + }, + { + "heading": "시간과 테스트 재현성", + "text": "exp는 만료 시각, nbf는 사용을 시작할 수 있는 시각입니다. JwtTimestampValidator는 시간 조건을 확인하고 setClock(clock)은 비교할 현재 시각을 외부에서 받게 합니다. required의 getExpiresAt().isAfter(clock.instant())는 exp가 반드시 존재하고 현재보다 뒤여야 한다는 이 실습의 정책입니다. 그래서 만료 시각과 현재가 정확히 같아도 거절합니다. Duration.ZERO로 시간 오차 허용을 0으로 두어 경계를 재현하며, 실제 서비스의 서버 시간 차이 허용과 시간 동기화 정책은 따로 정합니다." + }, + { + "heading": "키 수명과 로그", + "text": "테스트는 실행 중 만든 RSA 키 쌍으로 서명하고 그 공개키를 decoder에 줍니다. 따라서 다른 키로 서명한 토큰을 거절하는 검사는 가능하지만 외부 키 서버에서 키를 내려받거나 운영 키를 교체·폐기하는 과정은 포함하지 않습니다. 토큰 재사용 대응도 별도 문제입니다. 운영 관측이 필요할 때는 토큰 원문이나 개인정보 claim을 그대로 기록하기보다 서명 오류·만료·대상 불일치 같은 필요한 거절 유형을 남기는 정책을 검토합니다." + } + ], + "example": { + "title": "JwtLab 기준 구현", + "code": "package edu.lab;\nimport java.security.interfaces.RSAPublicKey;\nimport java.time.Clock;\nimport java.time.Duration;\nimport org.springframework.security.oauth2.core.*;\nimport org.springframework.security.oauth2.jwt.*;\npublic final class JwtLab {\n public static final String ISSUER = \"https://training-issuer.invalid\";\n public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) {\n // BEGIN EXERCISE\n NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock);\n OAuth2TokenValidator required = jwt -> {\n boolean valid = jwt.getAudience() != null && jwt.getAudience().contains(\"orders-api\")\n && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant())\n && jwt.getSubject() != null && !jwt.getSubject().isBlank();\n return valid ? OAuth2TokenValidatorResult.success()\n : OAuth2TokenValidatorResult.failure(new OAuth2Error(\"invalid_token\",\"required claims missing or wrong\",null));\n };\n decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required));\n return decoder;\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 5개 JUnit 테스트 통과.\n검증 항목: 정상 토큰 수용 · issuer·audience 불일치 거절 · 만료·미래 nbf 거절 · 다른 키 서명 거절 · 필수 claim 누락 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "decoder는 공개키로 서명을 확인할 NimbusJwtDecoder를 만들고, time에 고정 가능한 Clock을 넣습니다. required는 audience에 orders-api가 있는지, exp가 현재보다 뒤인지, subject가 비어 있지 않은지 검사합니다. 마지막 setJwtValidator는 time·issuer·required를 모두 연결하므로 하나라도 실패하면 수용하지 않습니다. 테스트는 정상 토큰 외에 다른 키·발급자·대상, 만료·미래 nbf, 누락 claim과 정확한 만료 경계를 검사합니다. 외부 키 서버나 운영 키 교체는 실행하지 않습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/JwtLab.java", + "language": "java", + "code": "package edu.lab;\nimport java.security.interfaces.RSAPublicKey;\nimport java.time.Clock;\nimport java.time.Duration;\nimport org.springframework.security.oauth2.core.*;\nimport org.springframework.security.oauth2.jwt.*;\npublic final class JwtLab {\n public static final String ISSUER = \"https://training-issuer.invalid\";\n public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) {\n // BEGIN EXERCISE\n NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();\n JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock);\n OAuth2TokenValidator required = jwt -> {\n boolean valid = jwt.getAudience() != null && jwt.getAudience().contains(\"orders-api\")\n && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant())\n && jwt.getSubject() != null && !jwt.getSubject().isBlank();\n return valid ? OAuth2TokenValidatorResult.success()\n : OAuth2TokenValidatorResult.failure(new OAuth2Error(\"invalid_token\",\"required claims missing or wrong\",null));\n };\n decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required));\n return decoder;\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljwt-validationorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.securityspring-security-oauth2-jose\norg.springframework.securityspring-security-oauth2-resource-server\n" + } + ] + }, + "quiz": [ + { + "id": "jwt-validation-q1", + "question": "서명은 유효하지만 audience가 다른 API인 JWT의 처리로 맞는 것은?", + "options": [ + "서명만 맞으면 항상 수용한다.", + "현재 API의 수용 정책에 따라 거절한다.", + "subject가 있으면 audience는 무시한다.", + "Base64 디코딩에 성공하면 수용한다." + ], + "correctIndex": 1, + "explanation": "required의 getAudience().contains(\"orders-api\")는 이 토큰을 주문 API가 받아도 되는지 묻는 조건입니다. 서명이 유효해도 audience가 another-api이면 valid가 false가 됩니다. subject 존재나 Base64 내용 읽기는 이 조건을 대신하지 않습니다." + }, + { + "id": "jwt-validation-q2", + "question": "만료 검증 테스트를 재현 가능하게 만드는 설계는?", + "options": [ + "현재 시스템 시간에 맞춰 테스트 상수를 매일 바꾼다.", + "Clock을 주입하고 고정 시각에서 경계를 검사한다.", + "테스트에서 시간 검증을 제거한다.", + "만료를 충분히 미래로 두고 실패 검사를 생략한다." + ], + "correctIndex": 1, + "explanation": "JwtLab.decoder는 Clock을 인수로 받고 time.setClock(clock)과 exp 비교에서 그 시각을 씁니다. 테스트가 Clock.fixed를 주면 오늘 날짜가 바뀌어도 같은 경계를 검사할 수 있습니다. 미래 만료값만 테스트하면 만료·미래 nbf·정확한 만료 경계의 실패를 놓칩니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "같은 발급자가 만든 토큰인데 다른 API용 토큰이 주문 API에 들어왔습니다. 서명 오류와 대상 불일치를 구분해서 거절하고, 만료 시각의 경계도 재현 가능한 검사로 남깁니다.", + "tasks": [ + "withPublicKey가 담당하는 서명 검사와 setJwtValidator의 세 검증기가 담당하는 내용 검사를 나누어 설명하세요. 이 설정은 RS256을 사용합니다.", + "issuer=https://training-issuer.invalid, audience에 orders-api 포함, 비어 있지 않은 subject, 현재보다 뒤인 exp가 필요한 줄을 찾으세요.", + "다른 키·issuer·audience, 만료·미래 nbf, 필수 값 누락을 한 조건씩 바꿔 보는 이유를 적으세요.", + "Clock을 고정했을 때 exp가 현재와 같거나 1초 뒤인 경우의 차이를 설명하세요. 실제 개인키를 파일에 저장하지 않습니다.", + "이 과제의 실행 중 생성한 로컬 키 검사와 실제 키 서버·키 교체·폐기·토큰 재사용 대응에서 남은 검증을 구분하세요." + ], + "takeaway": "토큰의 서명을 확인한 뒤에도 누구에게, 언제까지, 어떤 주체에 대해 발급된 것인지 우리 API의 조건으로 검사합니다." + }, + "sources": [ + { + "title": "Spring Security JWT resource server", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/oauth2/resource-server/jwt.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "JSON Web Token RFC 7519", + "url": "https://www.rfc-editor.org/rfc/rfc7519", + "edition": "RFC 7519", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "JWT", + "Spring Security" + ], + "exerciseId": "jwt-validation-project", + "professionalExercise": { + "id": "jwt-validation-project", + "profileId": "professional-jwt-local", + "mode": "project", + "instructions": [ + "RS256과 주어진 RSA 공개키로 서명을 검증합니다.", + "issuer=https://training-issuer.invalid, audience=orders-api, nonblank subject, exp가 모두 필요합니다.", + "만료·미래 nbf·잘못된 issuer·audience·서명은 거절합니다.", + "Clock을 외부에서 받아 고정 시간으로 테스트하며 실제 개인키를 파일에 저장하지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/JwtLab.java" + ], + "template": "package edu.lab;\nimport java.security.interfaces.RSAPublicKey;\nimport java.time.Clock;\nimport java.time.Duration;\nimport org.springframework.security.oauth2.core.*;\nimport org.springframework.security.oauth2.jwt.*;\npublic final class JwtLab {\n public static final String ISSUER = \"https://training-issuer.invalid\";\n public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/JwtLab.java", + "templatePath": "lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java", + "referencePath": "lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/jwt-validation/starter/pom.xml", + "referencePath": "lessons/jwt-validation/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/jwt-validation/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "정상 토큰 수용", + "issuer·audience 불일치 거절", + "만료·미래 nbf 거절", + "다른 키 서명 거절", + "필수 claim 누락 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "security-csrf" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 5, + "starterSatisfied": false, + "sourceFingerprint": "d38b26b60eefc5c0ff1a5de48f9e990ae37322631559da4c24fc6f1e7f4574c4", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/jwt-validation/reference/pom.xml b/curriculum/professional/lessons/jwt-validation/reference/pom.xml new file mode 100644 index 0000000..cb9c78a --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljwt-validationorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.securityspring-security-oauth2-jose +org.springframework.securityspring-security-oauth2-resource-server diff --git a/curriculum/professional/lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java b/curriculum/professional/lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java new file mode 100644 index 0000000..48f3e1e --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/reference/src/main/java/edu/lab/JwtLab.java @@ -0,0 +1,24 @@ +package edu.lab; +import java.security.interfaces.RSAPublicKey; +import java.time.Clock; +import java.time.Duration; +import org.springframework.security.oauth2.core.*; +import org.springframework.security.oauth2.jwt.*; +public final class JwtLab { + public static final String ISSUER = "https://training-issuer.invalid"; + public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) { + // BEGIN EXERCISE + NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build(); + JwtTimestampValidator time = new JwtTimestampValidator(Duration.ZERO); time.setClock(clock); + OAuth2TokenValidator required = jwt -> { + boolean valid = jwt.getAudience() != null && jwt.getAudience().contains("orders-api") + && jwt.getExpiresAt() != null && jwt.getExpiresAt().isAfter(clock.instant()) + && jwt.getSubject() != null && !jwt.getSubject().isBlank(); + return valid ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token","required claims missing or wrong",null)); + }; + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(time, new JwtIssuerValidator(ISSUER), required)); + return decoder; + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/jwt-validation/starter/pom.xml b/curriculum/professional/lessons/jwt-validation/starter/pom.xml new file mode 100644 index 0000000..cb9c78a --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmljwt-validationorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.securityspring-security-oauth2-jose +org.springframework.securityspring-security-oauth2-resource-server diff --git a/curriculum/professional/lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java b/curriculum/professional/lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java new file mode 100644 index 0000000..9226883 --- /dev/null +++ b/curriculum/professional/lessons/jwt-validation/starter/src/main/java/edu/lab/JwtLab.java @@ -0,0 +1,12 @@ +package edu.lab; +import java.security.interfaces.RSAPublicKey; +import java.time.Clock; +import java.time.Duration; +import org.springframework.security.oauth2.core.*; +import org.springframework.security.oauth2.jwt.*; +public final class JwtLab { + public static final String ISSUER = "https://training-issuer.invalid"; + public static JwtDecoder decoder(RSAPublicKey publicKey, Clock clock) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..534aecc --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,70 @@ +package edu.lab; +import java.sql.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.TimeUnit; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.*; +import org.apache.kafka.clients.admin.*; +import org.apache.kafka.clients.consumer.*; +import org.apache.kafka.clients.producer.*; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.serialization.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + JdbcDataSource ds; EventHandler handler; + @BeforeEach void database() throws Exception { + ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + try (Connection c = ds.getConnection(); Statement s = c.createStatement()) { + s.execute("create table orders(id varchar(60) primary key, handled_count int not null)"); + s.execute("create table processed_events(event_id varchar(80) primary key, order_id varchar(60) not null)"); + s.execute("insert into orders values('A',0),('B',0)"); + } + handler = new EventHandler(ds); + } + int count(String sql) throws Exception { try (Connection c=ds.getConnection(); Statement s=c.createStatement(); ResultSet r=s.executeQuery(sql)) { r.next(); return r.getInt(1); } } + @Test void duplicatesAndFailuresAreAtomic() throws Exception { + assertThat(handler.handle("evt-1","A")).isTrue(); assertThat(handler.handle("evt-1","A")).isFalse(); + assertThatThrownBy(() -> handler.handle("evt-1","B")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> handler.handle("evt-missing","MISSING")).isInstanceOf(SQLException.class); + assertThat(count("select count(*) from processed_events")).isEqualTo(1); + assertThat(count("select handled_count from orders where id='A'")).isEqualTo(1); + assertThat(count("select handled_count from orders where id='B'")).isZero(); + } + @Test @Timeout(90) void duplicateKafkaPayloadHasOneDbEffect() throws Exception { + String brokers = System.getenv("JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS"); + if (brokers == null || !brokers.matches("(127\\.0\\.0\\.1|localhost):[0-9]{1,5}")) throw new IllegalStateException("학습 전용 loopback Kafka 환경이 필요합니다."); + int port = Integer.parseInt(brokers.substring(brokers.lastIndexOf(':')+1)); + if (port < 1 || port > 65535) throw new IllegalStateException("invalid port"); + String topic = "java-edu-" + UUID.randomUUID(); boolean created = false; + Properties adminProps = new Properties(); adminProps.put("bootstrap.servers",brokers); adminProps.put("default.api.timeout.ms","10000"); + try (Admin admin = Admin.create(adminProps)) { + try { + admin.createTopics(List.of(new NewTopic(topic,1,(short)1))).all().get(10,TimeUnit.SECONDS); created = true; + Properties producerProps = new Properties(); producerProps.put("bootstrap.servers",brokers); + producerProps.put("key.serializer",StringSerializer.class.getName()); producerProps.put("value.serializer",StringSerializer.class.getName()); + producerProps.put("max.block.ms","10000"); producerProps.put("request.timeout.ms","5000"); producerProps.put("delivery.timeout.ms","10000"); + try (KafkaProducer producer = new KafkaProducer<>(producerProps)) { + producer.send(new ProducerRecord<>(topic,"evt-kafka","evt-kafka|A")).get(10,TimeUnit.SECONDS); + producer.send(new ProducerRecord<>(topic,"evt-kafka","evt-kafka|A")).get(10,TimeUnit.SECONDS); + } + Properties consumerProps = new Properties(); consumerProps.put("bootstrap.servers",brokers); consumerProps.put("group.id",topic); + consumerProps.put("key.deserializer",StringDeserializer.class.getName()); consumerProps.put("value.deserializer",StringDeserializer.class.getName()); + consumerProps.put("enable.auto.commit","false"); consumerProps.put("default.api.timeout.ms","10000"); + try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) { + var partition = new TopicPartition(topic,0); consumer.assign(List.of(partition)); consumer.seekToBeginning(List.of(partition)); + int received=0; long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(15); + while (received < 2 && System.nanoTime() < deadline) { + for (var record : consumer.poll(Duration.ofMillis(500))) { + String[] fields = record.value().split("\\|",-1); + if (fields.length != 2) throw new IllegalArgumentException("fixture payload"); + handler.handle(fields[0],fields[1]); received++; + } + } + assertThat(received).isEqualTo(2); consumer.commitSync(); + } + assertThat(count("select handled_count from orders where id='A'")).isEqualTo(1); + } finally { if (created) admin.deleteTopics(List.of(topic)).all().get(10,TimeUnit.SECONDS); } + } + } +} diff --git a/curriculum/professional/lessons/kafka-idempotency/lesson.json b/curriculum/professional/lessons/kafka-idempotency/lesson.json new file mode 100644 index 0000000..a5b1531 --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/lesson.json @@ -0,0 +1,220 @@ +{ + "id": "kafka-idempotency", + "title": "Kafka 재전달과 DB 멱등 처리", + "description": "같은 이벤트가 두 번 오면 주문 처리 횟수도 두 번 늘어날까요? 처리한 eventId 기록과 주문 변경을 하나의 DB 작업으로 묶어 중복 적용을 막는 코드를 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "같은 이벤트를 다시 받아도 DB 효과를 반복하지 않는 멱등 처리를 설명한다.", + "첫 handle은 true, 같은 eventId·orderId의 재호출은 false가 되는 경로를 따라간다.", + "없는 주문 처리 실패가 processed_events 기록까지 되돌려야 하는 이유를 설명한다.", + "같은 eventId에 다른 orderId가 오면 왜 거절하는지 설명한다.", + "DB 확정과 Kafka 읽은 위치 확정 사이의 장애 가능성 및 실제 연동 미검증 범위를 구분한다." + ], + "learningGuide": { + "scenario": "주문 A의 handled_count가 0일 때 evt-1이 도착합니다. 처리 후 같은 evt-1과 A가 다시 전달됩니다. 두 번째 전달을 새 작업으로 보면 처리 횟수가 2가 됩니다.", + "question": "이 메서드는 두 번째 전달을 어디서 알아보고, 어떤 값을 반환할까요?", + "terms": [ + { + "term": "멱등 처리", + "meaning": "같은 의미의 요청을 다시 받아도 업무 효과를 더 만들지 않는 처리입니다. 여기서는 handled_count를 한 번만 올립니다." + }, + { + "term": "eventId / payload", + "meaning": "eventId는 이벤트 식별자이고 payload는 그 이벤트의 내용입니다. 이 예제에서 비교할 내용은 orderId입니다." + }, + { + "term": "커밋 / 롤백", + "meaning": "commit은 연결의 DB 변경을 확정하고 rollback은 아직 확정하지 않은 변경을 되돌립니다. 처리 기록과 주문 변경에 함께 적용합니다." + }, + { + "term": "오프셋", + "meaning": "Kafka에서 소비자가 읽은 위치입니다. 이 메서드의 DB 커밋과는 별도로 소비자가 확정합니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/EventHandler.java", + "source": " c.setAutoCommit(false);\n try {\n try (PreparedStatement s = c.prepareStatement(\"insert into processed_events(event_id,order_id) values(?,?)\")) {\n s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate();\n }\n try (PreparedStatement s = c.prepareStatement(\"update orders set handled_count=handled_count+1 where id=?\")) {\n s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException(\"unknown order\",\"02000\");\n }\n c.commit(); return true;\n } catch (SQLException failure) {\n try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; }\n if (!\"23505\".equals(failure.getSQLState())) throw failure;\n try (PreparedStatement s = c.prepareStatement(\"select order_id from processed_events where event_id=?\")) {\n s.setString(1,eventId);\n try (ResultSet result = s.executeQuery()) {\n if (result.next() && orderId.equals(result.getString(1))) return false;\n throw new IllegalArgumentException(\"same eventId with different payload\",failure);" + }, + "walkthrough": [ + "c.setAutoCommit(false)는 아래 SQL을 매번 따로 확정하지 않게 합니다. 첫 INSERT가 processed_events에 eventId와 orderId를 기록합니다.", + "다음 UPDATE가 해당 주문의 handled_count를 1 올립니다. 바뀐 행 수가 1이 아니면 unknown order 예외를 던져 확정 단계로 가지 못하게 합니다.", + "두 SQL이 성공하면 c.commit() 뒤 true를 반환합니다. 예외가 나면 catch에서 c.rollback()으로 이번 변경을 되돌립니다.", + "학습 DB의 중복 키 SQLState인 23505만 중복 처리 분기로 보냅니다. 다른 DB 오류는 그대로 호출자에게 전달합니다.", + "SELECT로 기존 eventId의 order_id를 읽고 전달받은 orderId와 같으면 false를 반환합니다. 다르면 같은 ID의 다른 내용이므로 예외를 던집니다. 중복 분기에는 handled_count를 다시 올리는 SQL이 없습니다." + ], + "prediction": { + "question": "초기 처리 기록이 없고 주문 A의 handled_count가 0일 때, 정상 DB에서 handle(\"evt-1\",\"A\")를 순서대로 두 번 호출하면 어떻게 될까요?", + "answer": "첫 호출은 true, 두 번째는 false이며 handled_count는 1입니다.", + "explanation": "첫 호출이 기록과 증가를 함께 확정합니다. 두 번째 INSERT는 eventId 중복으로 실패하지만 기존 order_id가 A와 같으므로 이미 처리한 내용으로 판단합니다. 이 DB 동작 예측이 실제 Kafka 장애 복구 검증을 뜻하지는 않습니다." + }, + "takeaway": "같은 ID라는 사실과 같은 내용이라는 사실을 함께 확인해야 중복을 안전하게 건너뛸 수 있습니다." + }, + "theory": [ + { + "heading": "전달 횟수와 업무 효과", + "text": "소비자가 이벤트를 처리하고 응답을 남기기 전에 멈추면 같은 내용을 다시 받을 수 있습니다. 전달받은 횟수와 주문 상태를 바꾼 횟수를 분리해야 하는 이유입니다. 멱등 처리는 같은 의미의 이벤트를 반복해도 업무 효과를 추가로 만들지 않는 처리입니다. 이 실습은 eventId가 같은 메시지 두 건을 보내 재전달 가능성을 모델링하도록 작성되어 있습니다. 브로커 장애나 프로세스 강제 종료 후 복구를 직접 재현한 검사는 아니며, 실제 Kafka 연동도 별도 환경에서 확인해야 합니다." + }, + { + "heading": "DB 멱등 처리", + "text": "EventHandler.handle은 c.setAutoCommit(false)로 SQL마다 자동 확정하는 동작을 끄고, processed_events에 eventId·orderId를 넣은 뒤 orders.handled_count를 1 늘립니다. 둘 다 성공해야 c.commit()으로 함께 확정합니다. 실패하면 c.rollback()으로 이번 변경을 되돌립니다. 학습 H2에서 eventId 중복을 나타내는 SQLState 23505라면 기존 order_id를 조회합니다. 같은 주문이면 이미 반영된 이벤트라 false를 반환하고, 다른 주문이면 같은 ID의 의미가 바뀐 것이므로 거절합니다." + }, + { + "heading": "오프셋과 DB 사이", + "text": "오프셋은 Kafka 파티션에서 어디까지 읽었는지 나타내는 위치입니다. 테스트 소스는 자동 오프셋 커밋을 끄고 DB 처리 뒤 consumer.commitSync()로 읽은 위치를 확정합니다. DB의 c.commit() 후 이 호출 전에 멈추면 DB 반영은 끝났어도 같은 메시지를 다시 받을 수 있습니다. 그래서 DB 쪽 eventId 검사가 필요합니다. Kafka 트랜잭션만 켠다고 임의의 외부 DB까지 자동으로 한 번만 반영되는 것은 아닙니다. 이벤트 발행 기록을 DB와 함께 남기는 outbox, 제한된 재시도, 반복 실패 메시지의 격리 정책은 후속 설계 대상입니다." + } + ], + "example": { + "title": "EventHandler 기준 구현", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\npublic final class EventHandler {\n private final DataSource source;\n public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); }\n public boolean handle(String eventId, String orderId) throws SQLException {\n // BEGIN EXERCISE\n if (eventId == null || eventId.isBlank() || orderId == null || orderId.isBlank()) throw new IllegalArgumentException(\"event identity\");\n try (Connection c = source.getConnection()) {\n c.setAutoCommit(false);\n try {\n try (PreparedStatement s = c.prepareStatement(\"insert into processed_events(event_id,order_id) values(?,?)\")) {\n s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate();\n }\n try (PreparedStatement s = c.prepareStatement(\"update orders set handled_count=handled_count+1 where id=?\")) {\n s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException(\"unknown order\",\"02000\");\n }\n c.commit(); return true;\n } catch (SQLException failure) {\n try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; }\n if (!\"23505\".equals(failure.getSQLState())) throw failure;\n try (PreparedStatement s = c.prepareStatement(\"select order_id from processed_events where event_id=?\")) {\n s.setString(1,eventId);\n try (ResultSet result = s.executeQuery()) {\n if (result.next() && orderId.equals(result.getString(1))) return false;\n throw new IllegalArgumentException(\"same eventId with different payload\",failure);\n }\n }\n }\n }\n // END EXERCISE\n }\n}\n", + "output": "예상 검증 항목: 중복 이벤트 효과 한 번 · DB 실패 시 이벤트 기록 롤백 · 다른 payload 중복 ID 거절 · Kafka 전송 후 수동 오프셋 커밋. 아직 실행하지 않았습니다.", + "explanation": "handle은 eventId와 orderId를 확인한 뒤 같은 JDBC 연결에서 처리 기록 INSERT와 주문 횟수 UPDATE를 수행합니다. 주문이 없으면 UPDATE 수가 1이 아니므로 예외를 던지고 INSERT까지 롤백합니다. 이미 처리한 eventId라면 중복 INSERT 실패 뒤 기존 order_id를 읽어 같은 주문일 때만 false를 반환합니다. 따라서 true는 이번에 새로 반영했다는 뜻이고 false는 같은 내용을 앞서 반영했다는 뜻입니다. Kafka 오프셋 커밋은 이 메서드 밖의 테스트 소비자가 담당합니다. 실제 Kafka 연동은 현재 미검증이며 기준 구현과 테스트 소스는 공개 학습 자료입니다. 실제 서비스 연결·설치형 통합 검증은 별도 증거가 필요합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/EventHandler.java", + "language": "java", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\npublic final class EventHandler {\n private final DataSource source;\n public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); }\n public boolean handle(String eventId, String orderId) throws SQLException {\n // BEGIN EXERCISE\n if (eventId == null || eventId.isBlank() || orderId == null || orderId.isBlank()) throw new IllegalArgumentException(\"event identity\");\n try (Connection c = source.getConnection()) {\n c.setAutoCommit(false);\n try {\n try (PreparedStatement s = c.prepareStatement(\"insert into processed_events(event_id,order_id) values(?,?)\")) {\n s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate();\n }\n try (PreparedStatement s = c.prepareStatement(\"update orders set handled_count=handled_count+1 where id=?\")) {\n s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException(\"unknown order\",\"02000\");\n }\n c.commit(); return true;\n } catch (SQLException failure) {\n try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; }\n if (!\"23505\".equals(failure.getSQLState())) throw failure;\n try (PreparedStatement s = c.prepareStatement(\"select order_id from processed_events where event_id=?\")) {\n s.setString(1,eventId);\n try (ResultSet result = s.executeQuery()) {\n if (result.next() && orderId.equals(result.getString(1))) return false;\n throw new IllegalArgumentException(\"same eventId with different payload\",failure);\n }\n }\n }\n }\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlkafka-idempotencyorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.apache.kafkakafka-clients\ncom.h2databaseh2\n" + }, + { + "path": "FIXTURE.md", + "language": "markdown", + "code": "전용 로컬 Kafka 단일 브로커가 필요합니다. 예시 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:19092. localhost도 허용하며 외부 호스트는 거절합니다.\n브로커 advertised.listeners도 같은 loopback 주소를 광고해야 합니다. 실제 Docker 기동과 이미지 버전 검증은 별도 환경 준비 단계입니다.\n테스트는 java-edu- 토픽 한 개와 동일 이름 consumer group을 생성하고 자신이 만든 토픽만 삭제합니다. 업무 클러스터를 연결하지 마세요.\n중복 payload 두 건 전송은 재전달 가능성의 모델입니다. 브로커 장애 복구나 정확히 한 번의 분산 처리를 검증한 결과가 아닙니다.\n" + } + ] + }, + "quiz": [ + { + "id": "kafka-idempotency-q1", + "question": "DB 커밋 후 Kafka 오프셋 커밋 전에 프로세스가 멈추면?", + "options": [ + "DB 커밋도 자동으로 되돌아간다.", + "Kafka가 임의의 외부 DB를 자동 조정한다.", + "메시지는 어떤 경우에도 다시 전달되지 않는다.", + "같은 메시지가 다시 전달될 수 있어 DB 멱등 처리가 필요하다." + ], + "correctIndex": 3, + "explanation": "c.commit()과 consumer.commitSync()는 서로 다른 시스템에 남기는 확정 기록입니다. DB만 확정한 채 멈추면 소비자는 마지막으로 확정된 오프셋부터 다시 읽을 수 있습니다. 같은 이벤트의 processed_events 기록을 확인해 handled_count를 또 올리지 않아야 합니다." + }, + { + "id": "kafka-idempotency-q2", + "question": "같은 eventId로 서로 다른 orderId가 오면 이 과제는 어떻게 처리하는가?", + "options": [ + "먼저 받은 eventId가 있으므로 무조건 성공한다.", + "두 주문에 모두 적용한다.", + "payload 충돌을 계약 위반으로 거절한다.", + "eventId를 새로 만들어 조용히 재처리한다." + ], + "correctIndex": 2, + "explanation": "eventId가 같아도 orderId가 A에서 B로 바뀌면 같은 업무 요청이 아닙니다. handle의 중복 분기는 기존 order_id와 전달받은 orderId를 비교하고 다르면 IllegalArgumentException을 던집니다. 중복 ID라는 이유로 다른 주문까지 처리하거나 성공으로 숨기지 않습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "evt-1을 처리해 주문 A의 처리 횟수를 늘렸지만, 소비자가 Kafka의 읽은 위치를 남기기 전에 종료됐다고 가정합니다. 재시작 뒤 evt-1을 다시 받아도 DB 효과를 반복하지 않아야 합니다.", + "tasks": [ + "handle(\"evt-1\",\"A\")의 첫 true와 두 번째 false가 각각 어떤 분기에서 반환되는지 설명하세요.", + "processed_events INSERT와 handled_count UPDATE 중 하나만 확정되면 어떤 문제가 생기는지, setAutoCommit(false)·commit·rollback과 연결하세요.", + "없는 주문이면 방금 추가한 처리 기록도 사라져야 하는 이유와, 같은 eventId에 다른 orderId가 오면 거절해야 하는 이유를 구분하세요.", + "실제 전송 검사에는 전용 loopback Kafka와 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS가 필요합니다. 현재 실제 연동은 미검증이며 자원 미준비를 검사 통과로 보지 않습니다.", + "DB 커밋 뒤 오프셋 커밋 전 장애와 반복 실패 메시지의 처리 정책을 적으세요. 같은 메시지 두 건 전송은 브로커 장애 복구 검증과 다릅니다." + ], + "takeaway": "중복을 알아보는 기록과 실제 업무 변경을 같은 DB 트랜잭션에 둡니다. Kafka에 읽은 위치를 남기는 단계까지 포함해 장애 구간을 설명해야 합니다." + }, + "sources": [ + { + "title": "KafkaConsumer Java API", + "url": "https://kafka.apache.org/39/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html", + "edition": "Kafka 3.9", + "checkedAt": "2026-10-12" + }, + { + "title": "Apache Kafka quick start", + "url": "https://kafka.apache.org/39/getting-started/quickstart/", + "edition": "Kafka 3.9", + "checkedAt": "2026-10-12" + }, + { + "title": "JDBC transactions", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.sql/java/sql/Connection.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Kafka", + "Idempotency", + "Transaction" + ], + "exerciseId": "kafka-idempotency-project", + "professionalExercise": { + "id": "kafka-idempotency-project", + "profileId": "professional-kafka", + "mode": "project", + "instructions": [ + "handle(eventId, orderId)는 첫 정상 적용에 true, 동일 payload의 중복 적용에는 false를 반환합니다.", + "processed_events 기록과 orders.handled_count 증가는 같은 DB 트랜잭션입니다.", + "없는 주문이나 같은 eventId의 다른 orderId는 거절하고 부분 변경을 남기지 않습니다.", + "실제 전송 검사는 전용 loopback Kafka와 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS가 필요하며 자원 미준비 시 실행되지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/EventHandler.java" + ], + "template": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\npublic final class EventHandler {\n private final DataSource source;\n public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); }\n public boolean handle(String eventId, String orderId) throws SQLException {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/EventHandler.java", + "templatePath": "lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java", + "referencePath": "lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/kafka-idempotency/starter/pom.xml", + "referencePath": "lessons/kafka-idempotency/reference/pom.xml", + "editable": false + }, + { + "path": "FIXTURE.md", + "templatePath": "lessons/kafka-idempotency/starter/FIXTURE.md", + "referencePath": "lessons/kafka-idempotency/reference/FIXTURE.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/kafka-idempotency/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "중복 이벤트 효과 한 번", + "DB 실패 시 이벤트 기록 롤백", + "다른 payload 중복 ID 거절", + "Kafka 전송 후 수동 오프셋 커밋" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": false, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "source-prepared", + "executionVerified": false, + "prerequisites": [ + "tx-proxy", + "flyway-migrations" + ] +} diff --git a/curriculum/professional/lessons/kafka-idempotency/reference/FIXTURE.md b/curriculum/professional/lessons/kafka-idempotency/reference/FIXTURE.md new file mode 100644 index 0000000..7d27a4d --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/reference/FIXTURE.md @@ -0,0 +1,4 @@ +전용 로컬 Kafka 단일 브로커가 필요합니다. 예시 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:19092. localhost도 허용하며 외부 호스트는 거절합니다. +브로커 advertised.listeners도 같은 loopback 주소를 광고해야 합니다. 실제 Docker 기동과 이미지 버전 검증은 별도 환경 준비 단계입니다. +테스트는 java-edu- 토픽 한 개와 동일 이름 consumer group을 생성하고 자신이 만든 토픽만 삭제합니다. 업무 클러스터를 연결하지 마세요. +중복 payload 두 건 전송은 재전달 가능성의 모델입니다. 브로커 장애 복구나 정확히 한 번의 분산 처리를 검증한 결과가 아닙니다. diff --git a/curriculum/professional/lessons/kafka-idempotency/reference/pom.xml b/curriculum/professional/lessons/kafka-idempotency/reference/pom.xml new file mode 100644 index 0000000..f94956f --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlkafka-idempotencyorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.apache.kafkakafka-clients +com.h2databaseh2 diff --git a/curriculum/professional/lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java b/curriculum/professional/lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java new file mode 100644 index 0000000..31799f3 --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/reference/src/main/java/edu/lab/EventHandler.java @@ -0,0 +1,34 @@ +package edu.lab; +import javax.sql.DataSource; +import java.sql.*; +public final class EventHandler { + private final DataSource source; + public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); } + public boolean handle(String eventId, String orderId) throws SQLException { + // BEGIN EXERCISE + if (eventId == null || eventId.isBlank() || orderId == null || orderId.isBlank()) throw new IllegalArgumentException("event identity"); + try (Connection c = source.getConnection()) { + c.setAutoCommit(false); + try { + try (PreparedStatement s = c.prepareStatement("insert into processed_events(event_id,order_id) values(?,?)")) { + s.setString(1,eventId); s.setString(2,orderId); s.executeUpdate(); + } + try (PreparedStatement s = c.prepareStatement("update orders set handled_count=handled_count+1 where id=?")) { + s.setString(1,orderId); if (s.executeUpdate() != 1) throw new SQLException("unknown order","02000"); + } + c.commit(); return true; + } catch (SQLException failure) { + try { c.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); throw failure; } + if (!"23505".equals(failure.getSQLState())) throw failure; + try (PreparedStatement s = c.prepareStatement("select order_id from processed_events where event_id=?")) { + s.setString(1,eventId); + try (ResultSet result = s.executeQuery()) { + if (result.next() && orderId.equals(result.getString(1))) return false; + throw new IllegalArgumentException("same eventId with different payload",failure); + } + } + } + } + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/kafka-idempotency/starter/FIXTURE.md b/curriculum/professional/lessons/kafka-idempotency/starter/FIXTURE.md new file mode 100644 index 0000000..7d27a4d --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/starter/FIXTURE.md @@ -0,0 +1,4 @@ +전용 로컬 Kafka 단일 브로커가 필요합니다. 예시 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:19092. localhost도 허용하며 외부 호스트는 거절합니다. +브로커 advertised.listeners도 같은 loopback 주소를 광고해야 합니다. 실제 Docker 기동과 이미지 버전 검증은 별도 환경 준비 단계입니다. +테스트는 java-edu- 토픽 한 개와 동일 이름 consumer group을 생성하고 자신이 만든 토픽만 삭제합니다. 업무 클러스터를 연결하지 마세요. +중복 payload 두 건 전송은 재전달 가능성의 모델입니다. 브로커 장애 복구나 정확히 한 번의 분산 처리를 검증한 결과가 아닙니다. diff --git a/curriculum/professional/lessons/kafka-idempotency/starter/pom.xml b/curriculum/professional/lessons/kafka-idempotency/starter/pom.xml new file mode 100644 index 0000000..f94956f --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlkafka-idempotencyorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.apache.kafkakafka-clients +com.h2databaseh2 diff --git a/curriculum/professional/lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java b/curriculum/professional/lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java new file mode 100644 index 0000000..5668d45 --- /dev/null +++ b/curriculum/professional/lessons/kafka-idempotency/starter/src/main/java/edu/lab/EventHandler.java @@ -0,0 +1,10 @@ +package edu.lab; +import javax.sql.DataSource; +import java.sql.*; +public final class EventHandler { + private final DataSource source; + public EventHandler(DataSource source) { this.source = java.util.Objects.requireNonNull(source); } + public boolean handle(String eventId, String orderId) throws SQLException { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..de96d6f --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,7 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void stableIdentity() { OrderKey key=new OrderKey("o-1"); java.util.Set set=new java.util.HashSet<>(); set.add(key); key.visit(); assertThat(set).contains(key,new OrderKey("o-1")); assertThat(key.getDisplayVisits()).isEqualTo(1); assertThat(key).isNotEqualTo(new OrderKey("o-2")); } + @Test void rejectsNullId() { assertThatNullPointerException().isThrownBy(()->new OrderKey(null)); } +} diff --git a/curriculum/professional/lessons/lombok-values/lesson.json b/curriculum/professional/lessons/lombok-values/lesson.json new file mode 100644 index 0000000..0272fbe --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/lesson.json @@ -0,0 +1,203 @@ +{ + "id": "lombok-values", + "title": "Lombok 생성 코드와 동등성의 범위", + "description": "HashSet에 넣은 주문 키의 방문 횟수를 바꿔도 다시 찾을 수 있어야 합니다. Lombok이 생성하는 equals·hashCode가 어떤 필드를 사용하는지 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "Lombok이 생성할 생성자·getter·equals·hashCode를 애너테이션과 연결해 설명한다.", + "서로 다른 OrderKey라도 orderId가 같으면 같은 키로 판단하는 이유를 설명한다.", + "visit()로 바뀌는 displayVisits를 동등성에서 제외한다.", + "방문 횟수가 변한 뒤에도 HashSet 조회가 유지되는지 확인한다." + ], + "theory": [ + { + "heading": "애너테이션 처리 결과 읽기", + "text": "OrderKey에는 생성자와 getOrderId가 직접 적혀 있지 않습니다. @RequiredArgsConstructor가 final orderId를 받는 생성자를 만들고 @Getter가 필드를 읽는 메서드를 만듭니다. orderId의 @NonNull은 생성자에 null이 들어오는 것을 거절하게 합니다. 다만 null 검사이므로 빈 문자열까지 주문 ID로 적합한지는 이 애너테이션이 결정하지 않습니다.\n\nequals는 두 객체를 같은 값으로 볼지, hashCode는 해시 기반 컬렉션이 찾을 위치를 정할 때 사용할 값을 제공합니다. @EqualsAndHashCode(onlyExplicitlyIncluded = true)는 표시한 필드만 이 두 메서드에 참여시키라는 설정이고, 여기서는 orderId에만 Include가 붙어 있습니다. 코드를 줄이기 전에 “방문 횟수가 달라도 ID가 같으면 같은 키”라는 규칙을 먼저 정한 것입니다." + }, + { + "heading": "가변 필드와 엔티티의 함정", + "text": "HashSet은 객체를 넣을 때의 해시값을 이용해 저장하고, 찾을 때도 해시값과 equals를 사용합니다. set.add(key) 뒤 key.visit()를 했을 때 displayVisits가 hashCode 계산에 들어 있다면 찾을 기준이 달라져 같은 객체도 못 찾는 문제가 생길 수 있습니다. 이 코드의 hashCode는 final String orderId에만 의존하므로 visit() 뒤에도 기준이 같습니다.\n\n이 예제는 주문을 식별하는 작은 키 객체이며 객체 전체가 불변인 것은 아닙니다. displayVisits는 계속 바뀝니다. 이를 JPA 엔티티에 모든 필드를 포함하는 @Data를 붙여도 된다는 규칙으로 옮기지 않습니다. DB 저장 때 ID가 생기거나 연관 객체를 따라가야 하는 엔티티는 동등성·출력·접근자 생성의 영향을 별도로 검토해야 합니다." + } + ], + "example": { + "title": "OrderKey 기준 구현", + "code": "package edu.lab;\nimport lombok.Getter;\nimport lombok.NonNull;\nimport lombok.RequiredArgsConstructor;\nimport lombok.EqualsAndHashCode;\n@Getter\n@RequiredArgsConstructor\n@EqualsAndHashCode(onlyExplicitlyIncluded = true)\npublic final class OrderKey {\n @NonNull @EqualsAndHashCode.Include private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 같은 ID 동등성 · 가변 표시 값 제외 · HashSet 조회 유지.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "OrderKey(\"o-1\")을 만들면 orderId가 고정되고 displayVisits는 0에서 시작합니다. visit()는 displayVisits만 1 늘립니다. Include는 orderId에만 붙어 있으므로 방문 횟수가 다른 두 OrderKey(\"o-1\")도 equals가 같다고 판단하고 hashCode도 같습니다. 그래서 기존 테스트는 visit() 뒤 원래 key와 새 OrderKey(\"o-1\")을 모두 HashSet에서 찾는지 확인합니다. @NonNull이 생성자의 null 입력을 거절하는지도 별도 테스트합니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/OrderKey.java", + "language": "java", + "code": "package edu.lab;\nimport lombok.Getter;\nimport lombok.NonNull;\nimport lombok.RequiredArgsConstructor;\nimport lombok.EqualsAndHashCode;\n@Getter\n@RequiredArgsConstructor\n@EqualsAndHashCode(onlyExplicitlyIncluded = true)\npublic final class OrderKey {\n @NonNull @EqualsAndHashCode.Include private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmllombok-valuesorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.projectlomboklombokprovidedorg.apache.maven.pluginsmaven-compiler-pluginlombok.launch.AnnotationProcessorHider$AnnotationProcessor\n" + } + ] + }, + "quiz": [ + { + "id": "lombok-values-q1", + "question": "OrderKey의 displayVisits를 equals/hashCode에서 제외하는 이유는?", + "options": [ + "방문 횟수가 바뀌어도 주문 식별과 해시 탐색 기준을 유지하기 위해서다.", + "모든 int 필드는 동등성에 사용할 수 없기 때문이다.", + "Lombok은 가변 필드를 컴파일하지 못하기 때문이다.", + "HashSet은 항상 객체 참조만 비교하기 때문이다." + ], + "correctIndex": 0, + "explanation": "visit()는 사용자가 주문 화면을 몇 번 봤는지를 바꿉니다. 같은 주문인지 판단하는 orderId는 그대로입니다. displayVisits까지 hashCode에 넣으면 Set에 저장할 때와 찾을 때의 해시 기준이 달라질 수 있으므로 이 필드는 제외합니다." + }, + { + "id": "lombok-values-q2", + "question": "JPA 엔티티에 @Data를 그대로 적용하기 전에 무엇을 검토해야 하는가?", + "options": [ + "식별자 수명·가변 필드·연관관계 순환·지연 로딩 접근", + "소스 줄 수만", + "어노테이션의 알파벳 순서만", + "필드가 private인지 여부만" + ], + "correctIndex": 0, + "explanation": "엔티티는 저장 전후 ID가 달라질 수 있고 다른 엔티티와 연결될 수 있습니다. 자동 생성한 equals·hashCode·toString이 그 필드를 읽거나 바꾸는 동작도 실제 코드입니다. 이 수업의 고정 orderId 키에서 안전한 선택이 모든 엔티티에서도 맞는지 식별자 수명과 연관관계를 살펴야 합니다." + } + ], + "practice": { + "title": "방문 횟수가 바뀌어도 찾을 수 있는 주문 키", + "scenario": "최근 본 주문을 HashSet으로 관리합니다. 사용자가 다시 보았다고 visit()를 호출한 뒤에도 같은 주문 키를 찾고 중복으로 넣지 않으려 합니다.", + "tasks": [ + "OrderKey의 equals와 hashCode에 불변 orderId만 참여하게 하세요.", + "HashSet에 넣은 key에 visit()를 호출한 뒤 key와 새 OrderKey(\"o-1\")을 찾을 수 있는지 기존 테스트에서 확인하세요.", + "onlyExplicitlyIncluded=true와 orderId의 Include를 보고 displayVisits가 왜 제외되는지 설명하세요.", + "OrderKey(null)은 거절되지만 빈 ID의 유효성은 별도 규칙이 필요하다는 점, JPA 엔티티의 ID 수명까지 이 테스트로 검증한 것은 아니라는 점을 적으세요." + ], + "takeaway": "자동 생성된 메서드도 어떤 값을 읽는지 직접 작성한 코드처럼 검토합니다. 컬렉션에 들어간 키는 조회 기준이 흔들리지 않아야 하므로 표시용 가변 값과 식별 값을 구분합니다." + }, + "sources": [ + { + "title": "Lombok EqualsAndHashCode", + "url": "https://projectlombok.org/features/EqualsAndHashCode", + "edition": "Lombok", + "checkedAt": "2026-10-12" + }, + { + "title": "Lombok constructor generation", + "url": "https://projectlombok.org/features/constructor", + "edition": "Lombok", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "lombok-values-project", + "professionalExercise": { + "id": "lombok-values-project", + "profileId": "professional-lombok", + "mode": "project", + "instructions": [ + "OrderKey의 동등성과 hashCode는 불변 orderId만 사용하게 하세요.", + "visit()로 displayVisits가 바뀌어도 HashSet에서 키를 찾을 수 있어야 합니다.", + "Lombok @EqualsAndHashCode(onlyExplicitlyIncluded=true)와 Include의 생성 의미를 설명하세요." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrderKey.java" + ], + "template": "package edu.lab;\nimport lombok.Getter;\nimport lombok.NonNull;\nimport lombok.RequiredArgsConstructor;\n@Getter\n@RequiredArgsConstructor\npublic final class OrderKey {\n @NonNull private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n // orderId만 사용하는 동등성 계약을 Lombok으로 선언하세요.\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/OrderKey.java", + "templatePath": "lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java", + "referencePath": "lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/lombok-values/starter/pom.xml", + "referencePath": "lessons/lombok-values/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/lombok-values/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "같은 ID 동등성", + "가변 표시 값 제외", + "HashSet 조회 유지" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "test-contracts", + "core:collections" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "65bcae5900047877a822e89821af9b5ac4a511c1b6a634d002757f4cee5efa9a", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "주문 키를 HashSet에 넣고 화면을 한 번 열어 visit()를 호출했습니다. 방문 횟수가 변했다는 이유로 이미 넣은 주문을 다시 못 찾으면 중복 확인이 깨집니다.", + "question": "OrderKey에서 어떤 필드가 바뀌어도 같은 주문 키라는 판단은 유지되어야 할까요?", + "terms": [ + { + "term": "동등성", + "meaning": "서로 다른 객체를 같은 값으로 판단하는 규칙입니다. 여기서는 orderId가 같으면 같은 OrderKey입니다." + }, + { + "term": "hashCode", + "meaning": "HashSet이 찾을 후보를 좁힐 때 사용하는 정수값입니다. 같은 키로 판단되는 두 객체는 같은 hashCode를 내야 합니다." + }, + { + "term": "생성 코드", + "meaning": "컴파일할 때 Lombok이 애너테이션을 보고 추가하는 메서드입니다. OrderKey의 생성자·getter·equals·hashCode가 해당합니다." + }, + { + "term": "불변 식별 값", + "meaning": "객체가 만들어진 뒤 바뀌지 않는 구별 기준입니다. final String orderId는 고정되어 있고 displayVisits는 visit()마다 바뀝니다." + } + ], + "walkthrough": [ + "@RequiredArgsConstructor는 final orderId를 받는 생성자를 만듭니다. @NonNull 때문에 null ID는 생성할 때 거절됩니다.", + "@EqualsAndHashCode의 onlyExplicitlyIncluded=true가 비교 대상을 명시한 필드로 제한합니다. Include가 있는 필드는 orderId 하나입니다.", + "HashSet에 OrderKey(\"o-1\")을 넣으면 그 키의 hashCode가 저장 위치를 찾는 데 쓰입니다.", + "visit()는 displayVisits만 증가시킵니다. equals와 hashCode의 입력인 orderId에는 손대지 않습니다.", + "그래서 방문 뒤에도 원래 key와 새 OrderKey(\"o-1\")을 같은 키로 찾습니다. 기존 stableIdentity 테스트는 OrderKey(\"o-2\")와는 다른 값인지도 확인합니다." + ], + "prediction": { + "question": "OrderKey(\"o-1\")을 HashSet에 넣고 visit()를 한 번 호출한 뒤 새 OrderKey(\"o-1\")을 조회하면 찾을 수 있나요?", + "answer": "찾을 수 있습니다. 기존 key의 displayVisits는 1이지만 새 키와 orderId가 같습니다.", + "explanation": "displayVisits에는 Include가 없어서 equals와 hashCode 계산에 참여하지 않습니다. 방문 횟수 차이가 있어도 조회 기준인 orderId는 같습니다." + }, + "takeaway": "“모든 필드를 비교한다”보다 “무엇이 같아야 같은 키인가”를 먼저 정합니다. 자동 생성 도구의 설정은 그 결정을 코드로 드러내는 방법입니다.", + "code": { + "file": "src/main/java/edu/lab/OrderKey.java", + "source": "@Getter\n@RequiredArgsConstructor\n@EqualsAndHashCode(onlyExplicitlyIncluded = true)\npublic final class OrderKey {\n @NonNull @EqualsAndHashCode.Include private final String orderId;\n private int displayVisits;\n public void visit() { displayVisits++; }\n}\n" + } + } +} diff --git a/curriculum/professional/lessons/lombok-values/reference/pom.xml b/curriculum/professional/lessons/lombok-values/reference/pom.xml new file mode 100644 index 0000000..2d6ad4c --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/reference/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmllombok-valuesorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.projectlomboklombokprovidedorg.apache.maven.pluginsmaven-compiler-pluginlombok.launch.AnnotationProcessorHider$AnnotationProcessor diff --git a/curriculum/professional/lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java b/curriculum/professional/lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java new file mode 100644 index 0000000..5c92484 --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/reference/src/main/java/edu/lab/OrderKey.java @@ -0,0 +1,13 @@ +package edu.lab; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.EqualsAndHashCode; +@Getter +@RequiredArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public final class OrderKey { + @NonNull @EqualsAndHashCode.Include private final String orderId; + private int displayVisits; + public void visit() { displayVisits++; } +} diff --git a/curriculum/professional/lessons/lombok-values/starter/pom.xml b/curriculum/professional/lessons/lombok-values/starter/pom.xml new file mode 100644 index 0000000..2d6ad4c --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/starter/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmllombok-valuesorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.projectlomboklombokprovidedorg.apache.maven.pluginsmaven-compiler-pluginlombok.launch.AnnotationProcessorHider$AnnotationProcessor diff --git a/curriculum/professional/lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java b/curriculum/professional/lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java new file mode 100644 index 0000000..28075c7 --- /dev/null +++ b/curriculum/professional/lessons/lombok-values/starter/src/main/java/edu/lab/OrderKey.java @@ -0,0 +1,12 @@ +package edu.lab; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +@Getter +@RequiredArgsConstructor +public final class OrderKey { + @NonNull private final String orderId; + private int displayVisits; + public void visit() { displayVisits++; } + // orderId만 사용하는 동등성 계약을 Lombok으로 선언하세요. +} diff --git a/curriculum/professional/lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..b0cc489 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,18 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +class ContractTest { + private final MockMvc mvc = standaloneSetup(new OrdersController()).build(); + @Test void existingResource() throws Exception { + mvc.perform(get("/orders/A-1")).andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(jsonPath("$.id").value("A-1")).andExpect(jsonPath("$.quantity").value(2)); + } + @Test void absentResource() throws Exception { mvc.perform(get("/orders/missing")).andExpect(status().isNotFound()); } + @Test void repeatedReadsDoNotConsumeStock() throws Exception { + for (int i = 0; i < 3; i++) mvc.perform(get("/orders/A-1")).andExpect(jsonPath("$.quantity").value(2)); + } +} diff --git a/curriculum/professional/lessons/mvc-rest/lesson.json b/curriculum/professional/lessons/mvc-rest/lesson.json new file mode 100644 index 0000000..b53e223 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/lesson.json @@ -0,0 +1,207 @@ +{ + "id": "mvc-rest", + "title": "MVC·REST의 상태 코드와 자원 계약", + "description": "GET /orders/A-1이 어떤 메서드로 들어가 200과 JSON이 되는지 따라갑니다. 없는 주문의 404 응답과 반복 조회에서 주문 수량이 그대로인 이유도 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "URL의 A-1이 find의 id로 전달되고 Summary가 JSON 응답이 되는 순서를 설명한다.", + "존재하는 A-1 주문은 200과 id·quantity를 반환하게 한다.", + "없는 주문은 404로 표현하고 서버 오류와 구분한다.", + "조회 세 번 뒤에도 수량 2를 유지하는지 확인한다." + ], + "theory": [ + { + "heading": "전송 계약과 업무 계약", + "text": "클라이언트는 자바 반환 객체를 직접 보지 못하고 HTTP 상태 코드와 응답 본문을 봅니다. 이것이 전송 계약, 즉 요청을 보낸 쪽과 주고받기로 한 형식입니다. GET /orders/A-1은 클래스의 /orders와 메서드의 /{id}에 연결되고 @PathVariable이 A-1을 id 인수로 전달합니다. find는 orders.get(id)의 결과를 보고 있으면 ResponseEntity.ok(order), 없으면 notFound().build()를 반환합니다.\n\n업무 계약은 주문을 읽을 때 기존 수량을 바꾸지 않는다는 규칙입니다. 이 코드에는 Map을 읽는 get만 있으며 수량을 줄이는 동작이 없습니다. @GetMapping을 붙였다고 이런 규칙이 자동으로 지켜지는 것은 아닙니다. 메서드 본문과 반복 호출 결과를 함께 확인해야 합니다." + }, + { + "heading": "컨트롤러의 책임", + "text": "컨트롤러는 HTTP 요청을 자바 호출로 연결하고 결과를 응답으로 표현하는 역할을 합니다. 이 예제에서 @RestController와 응답 변환기가 Summary의 id·quantity를 JSON으로 전달합니다. 가격 계산이나 재고 예약까지 이곳에 섞으면 HTTP 처리와 주문 규칙을 각각 테스트하기 어려워집니다.\n\norders는 A-1 한 건을 담은 고정 Map입니다. 데이터베이스 조회나 저장을 구현한 것이 아니라 “존재하면 200, 없으면 404”라는 응답 선택을 보여 줍니다. DB 실패도 null로 바꿔 404를 주는 구현을 이 코드에서 도출하면 안 됩니다. 주문 부재와 저장소 장애는 클라이언트가 다르게 처리해야 할 상황입니다." + }, + { + "heading": "MockMvc의 경계", + "text": "기존 ContractTest는 standaloneSetup(new OrdersController())로 MockMvc를 준비합니다. MockMvc는 실제 서버 포트를 열지 않고 요청·응답 객체를 통해 Spring MVC 처리를 실행하는 검사 도구입니다. 단순히 find(\"A-1\")을 직접 호출하는 것과 달리 URL 연결, 상태 코드, JSON의 id·quantity까지 확인할 수 있습니다.\n\n반복 GET 검사는 세 번 모두 quantity가 2인지 봅니다. 그러나 실제 TLS 인증서·역방향 프록시·서블릿 서버 설정은 이 경로에 없습니다. 이 검사가 통과했다는 말과 배포된 URL에 실제로 접속해 확인했다는 말을 구분합니다." + } + ], + "example": { + "title": "OrdersController 기준 구현", + "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n // BEGIN EXERCISE\n Summary order = orders.get(id);\n return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order);\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 3개 JUnit 테스트 통과.\n검증 항목: 기존 주문 조회 200 · 부재 주문 404 · 반복 조회 상태 보존.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "orders에는 \"A-1\"을 키로 Summary(\"A-1\", 2)가 들어 있습니다. GET /orders/A-1의 id가 find로 전달되면 orders.get이 그 객체를 찾고 ResponseEntity.ok가 200 응답을 선택합니다. MVC의 JSON 변환을 거쳐 id는 A-1, quantity는 2로 보입니다. /orders/missing은 조회 결과가 null이므로 404를 선택합니다. find는 Map을 바꾸지 않아 같은 조회를 반복해도 수량 2가 유지됩니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/OrdersController.java", + "language": "java", + "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n // BEGIN EXERCISE\n Summary order = orders.get(id);\n return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order);\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmvc-restorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-validation\norg.springframework.bootspring-boot-starter-testtest\n" + } + ] + }, + "quiz": [ + { + "id": "mvc-rest-q1", + "question": "없는 주문의 조회 응답을 결정하는 올바른 기준은?", + "options": [ + "항상 200과 빈 객체를 반환한다.", + "서버 오류이므로 항상 500이다.", + "조회가 실패하면 새 주문을 생성한다.", + "자원 부재를 404로 표현한다." + ], + "correctIndex": 3, + "explanation": "orders.get(\"missing\")이 null이라는 것은 이 저장소에 해당 주문이 없다는 뜻입니다. 이 수업은 그 경우를 404로 약속했습니다. 200의 빈 객체는 정상 주문과 부재를 구분하기 어렵게 하고, 500은 정상적인 부재를 서버 장애처럼 보이게 합니다." + }, + { + "id": "mvc-rest-q2", + "question": "이 수업의 MockMvc 검사만으로 확인할 수 없는 것은?", + "options": [ + "요청 경로의 매핑", + "JSON 응답 필드", + "실제 TLS와 네트워크 프록시 설정", + "부재 주문의 상태 코드" + ], + "correctIndex": 2, + "explanation": "MockMvc는 실제 OrdersController를 MVC 처리 과정으로 호출해 경로·상태 코드·JSON 필드를 검사합니다. 실제 서버 소켓을 열지 않으므로 TLS 연결과 외부 네트워크 프록시 설정은 그 검사에 참여하지 않습니다." + } + ], + "practice": { + "title": "주문 조회의 성공·부재·반복을 구분하기", + "scenario": "주문 상세 화면은 A-1이면 수량 2를 보여 주고, 없는 주문이면 부재 안내를 보여 줘야 합니다. 새로고침을 여러 번 해도 재고나 주문 수량을 소비하면 안 됩니다.", + "tasks": [ + "GET /orders/A-1에서 id A-1, quantity 2인 JSON과 200을 반환하는 분기를 완성하세요.", + "orders에 없는 ID는 404를 반환하세요. 저장소 장애를 404로 취급하는 것과 이 코드의 단순 부재를 구분하세요.", + "반복 GET 세 번의 quantity가 모두 2인 기존 테스트를 읽고, find 안에서 상태를 바꾸는 코드가 없는지 확인하세요. 새 주문 생성은 이 수업 범위에 없습니다.", + "find를 직접 호출하는 검사, MockMvc 검사, 배포된 URL 호출이 각각 무엇을 거치는지 설명하세요. 여기서 실행된 기준 검사는 MockMvc 범위입니다." + ], + "takeaway": "조회 API를 읽을 때 URL → 인수 → 조회값 → 상태 코드 → JSON 순서로 따라갑니다. 성공 응답뿐 아니라 부재의 표현과 반복 호출 뒤 상태도 함께 확인합니다." + }, + "sources": [ + { + "title": "Spring MVC annotated controllers", + "url": "https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-controller.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "MockMvc", + "url": "https://docs.spring.io/spring-framework/reference/6.2/testing/mockmvc.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring MVC", + "REST" + ], + "exerciseId": "mvc-rest-project", + "professionalExercise": { + "id": "mvc-rest-project", + "profileId": "professional-spring-web", + "mode": "project", + "instructions": [ + "GET /orders/A-1은 id A-1, quantity 2인 JSON과 200을 반환합니다.", + "존재하지 않는 ID는 빈 주문이나 null의 200 대신 404를 반환합니다.", + "반복 GET은 데이터를 변경하지 않습니다. 새 주문 생성은 이 수업 범위에 없습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrdersController.java" + ], + "template": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.*;\n@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/OrdersController.java", + "templatePath": "lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java", + "referencePath": "lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/mvc-rest/starter/pom.xml", + "referencePath": "lessons/mvc-rest/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/mvc-rest/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "기존 주문 조회 200", + "부재 주문 404", + "반복 조회 상태 보존" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "spring-di" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 3, + "starterSatisfied": false, + "sourceFingerprint": "cf818104cfb16da2cfc56a8a887429f7354bfbaf23b2edb5db949abe45065bdf", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "주문 상세 화면을 새로고침할 때 같은 주문이 계속 보이고, 존재하지 않는 ID에는 분명한 부재 응답이 필요합니다. 고정된 A-1 주문으로 이 흐름을 좁혀 살펴봅니다.", + "question": "orders.get(id)의 값 하나가 어떻게 200 또는 404 응답으로 이어질까요?", + "terms": [ + { + "term": "라우팅", + "meaning": "요청 경로와 처리 메서드를 연결하는 일입니다. /orders와 /{id}가 합쳐져 GET /orders/A-1을 find로 보냅니다." + }, + { + "term": "경로 변수", + "meaning": "URL의 일부를 메서드 인수로 받는 값입니다. @PathVariable(\"id\")는 A-1을 String id에 전달합니다." + }, + { + "term": "ResponseEntity", + "meaning": "응답 본문과 상태 코드 등을 정하는 객체입니다. 여기서는 ok(order)가 200과 주문을, notFound().build()가 본문 없는 404를 만듭니다." + }, + { + "term": "직렬화", + "meaning": "자바 객체를 전송할 표현으로 바꾸는 일입니다. 이 MVC 설정은 Summary의 id·quantity를 JSON 필드로 보냅니다." + } + ], + "walkthrough": [ + "@RequestMapping(\"/orders\")와 @GetMapping(\"/{id}\")가 GET /orders/A-1을 find 메서드에 연결합니다.", + "@PathVariable(\"id\")가 URL의 A-1을 id에 전달합니다. orders.get(id)는 저장된 Summary(\"A-1\", 2)를 찾습니다.", + "order가 null이 아니므로 ResponseEntity.ok(order)를 반환합니다. MVC가 이 본문을 JSON으로 바꿔 200과 함께 전달합니다.", + "id가 missing이면 orders.get의 결과가 null입니다. 삼항식의 다른 분기가 본문 없는 404를 선택합니다.", + "어느 분기에도 orders나 quantity를 바꾸는 코드가 없습니다. 기존 반복 조회 테스트는 A-1을 세 번 읽어도 quantity가 2인지 확인합니다." + ], + "prediction": { + "question": "같은 컨트롤러에 GET /orders/A-1을 세 번 보내고 GET /orders/missing을 한 번 보내면 어떤 결과인가요?", + "answer": "A-1 세 번은 모두 200과 quantity 2이며, missing은 404입니다.", + "explanation": "find는 저장된 값을 조회해 응답을 고를 뿐 값을 변경하지 않습니다. 고정 Map에 missing 키가 없으므로 마지막 요청은 부재 분기로 갑니다." + }, + "takeaway": "HTTP 응답을 이해하려면 애너테이션부터 반환 객체까지 연결해 읽습니다. 메서드 호출 결과와 클라이언트가 보는 상태 코드·JSON은 MVC가 이어 줍니다.", + "code": { + "file": "src/main/java/edu/lab/OrdersController.java", + "source": "@RestController\n@RequestMapping(\"/orders\")\npublic class OrdersController {\n public record Summary(String id, int quantity) {}\n private final Map orders = Map.of(\"A-1\", new Summary(\"A-1\", 2));\n @GetMapping(\"/{id}\")\n public ResponseEntity find(@PathVariable(\"id\") String id) {\n // BEGIN EXERCISE\n Summary order = orders.get(id);\n return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order);\n // END EXERCISE\n }\n}\n" + } + } +} diff --git a/curriculum/professional/lessons/mvc-rest/reference/pom.xml b/curriculum/professional/lessons/mvc-rest/reference/pom.xml new file mode 100644 index 0000000..7279fa4 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmvc-restorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-testtest diff --git a/curriculum/professional/lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java b/curriculum/professional/lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java new file mode 100644 index 0000000..9516c79 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/reference/src/main/java/edu/lab/OrdersController.java @@ -0,0 +1,17 @@ +package edu.lab; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/orders") +public class OrdersController { + public record Summary(String id, int quantity) {} + private final Map orders = Map.of("A-1", new Summary("A-1", 2)); + @GetMapping("/{id}") + public ResponseEntity find(@PathVariable("id") String id) { + // BEGIN EXERCISE + Summary order = orders.get(id); + return order == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(order); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/mvc-rest/starter/pom.xml b/curriculum/professional/lessons/mvc-rest/starter/pom.xml new file mode 100644 index 0000000..7279fa4 --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmvc-restorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-testtest diff --git a/curriculum/professional/lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java b/curriculum/professional/lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java new file mode 100644 index 0000000..4abab1d --- /dev/null +++ b/curriculum/professional/lessons/mvc-rest/starter/src/main/java/edu/lab/OrdersController.java @@ -0,0 +1,14 @@ +package edu.lab; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/orders") +public class OrdersController { + public record Summary(String id, int quantity) {} + private final Map orders = Map.of("A-1", new Summary("A-1", 2)); + @GetMapping("/{id}") + public ResponseEntity find(@PathVariable("id") String id) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..0365f56 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,32 @@ +package edu.lab; +import java.sql.Connection; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.*; +import org.apache.ibatis.session.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + SqlSessionFactory factory; + @BeforeEach void setup() throws Exception { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + try (Connection c = ds.getConnection(); var s = c.createStatement()) { + s.execute("create table orders(id varchar(60) primary key, sku varchar(40), quantity int)"); + s.execute("insert into orders values('A','BOOK',2),('B','PEN',3)"); + } + factory = MyBatisLab.factory(ds); + } + @Test void mapsOnlyMatchingRows() { + try (SqlSession session = factory.openSession()) { + var result = session.getMapper(MyBatisLab.Mapper.class).findBySku("BOOK"); + assertThat(result).hasSize(1); assertThat(result.get(0).getId()).isEqualTo("A"); + assertThat(result.get(0).getQuantity()).isEqualTo(2); + } + } + @Test void unknownAndSqlTextAreLiteralValues() { + try (SqlSession session = factory.openSession()) { + var mapper = session.getMapper(MyBatisLab.Mapper.class); + assertThat(mapper.findBySku("MISSING")).isEmpty(); + assertThat(mapper.findBySku("' OR '1'='1")).isEmpty(); + } + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/lesson.json b/curriculum/professional/lessons/mybatis-mapping/lesson.json new file mode 100644 index 0000000..df2d247 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/lesson.json @@ -0,0 +1,220 @@ +{ + "id": "mybatis-mapping", + "title": "MyBatis 파라미터 바인딩과 XML 매핑", + "description": "findBySku(\"BOOK\")이 XML의 #{sku}로 전달되고 조회 열이 OrderRow에 채워지는 과정을 봅니다. SQL처럼 생긴 입력도 쿼리 조건을 바꾸지 않고 하나의 값으로 처리해야 합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "Mapper 메서드·XML namespace·select id가 연결되는 위치를 찾는다.", + "order_id라는 조회 열이 OrderRow.id에 들어가는 명시적 매핑을 설명한다.", + "BOOK 입력이 SKU가 BOOK인 행만 조회하는 순서를 설명한다.", + "SQL 조각처럼 보이는 입력을 문자열 값으로 바인딩하고 테스트 데이터에서 빈 결과가 되는 이유를 설명한다." + ], + "theory": [ + { + "heading": "SQL 소유권과 매핑", + "text": "매핑은 이름이 다른 두 대상을 연결하는 규칙입니다. Mapper.findBySku의 호출은 XML의 namespace=\"edu.lab.MyBatisLab$Mapper\"와 select id=\"findBySku\"에 연결됩니다. SELECT는 id를 order_id라는 이름으로 읽고, resultMap은 그 order_id를 자바 OrderRow의 id 속성에 넣습니다. SQL에 별칭을 쓴 뒤 자바 필드 이름까지 자동으로 같아질 것이라 기대하지 않고 이 연결을 명시합니다.\n\nMyBatis는 값 전달과 결과 객체 채우기를 도와주지만 어떤 SQL을 실행할지는 개발자가 작성합니다. 반환 행이 지나치게 많거나 WHERE 조건에 맞는 인덱스가 없다면 매핑을 사용했다는 사실로 해결되지 않습니다. 쿼리와 실제 DB 데이터 규모·통계·실행계획을 확인하는 책임은 남습니다." + }, + { + "heading": "#{}와 ${}의 차이", + "text": "Mapper의 @Param(\"sku\")가 인수 이름을 정하고 XML의 #{sku}가 그 값을 PreparedStatement의 파라미터로 전달합니다. findBySku(\"BOOK\")의 SQL 구조는 그대로이고 비교할 값만 BOOK이 됩니다. 기존 테스트의 \"' OR '1'='1\"도 하나의 sku 값입니다. 준비된 BOOK·PEN 행 중 그 문자열과 같은 SKU가 없어서 빈 목록이 나옵니다. SQL 같은 문자열을 무조건 금지하는 기능이 아니라, 값이 SQL 문법으로 해석되지 않게 하는 방식입니다.\n\n문자열 치환 방식으로 요청값을 SQL 안에 직접 넣으면 따옴표나 조건식이 쿼리의 일부가 될 수 있습니다. 정렬 열처럼 SQL 구조를 고르는 요구는 값 바인딩과 다르므로 서버가 허용한 열·방향 중에서 선택해야 합니다." + }, + { + "heading": "대안 학습 경로", + "text": "이 예제의 factory는 XML을 읽어 SqlSessionFactory를 만들고, 테스트의 호출자가 openSession으로 작업할 세션을 엽니다. SqlSession은 SQL 실행과 매퍼 호출을 담당하는 작업 단위이며 호출자는 try-with-resources로 닫습니다. 연결을 계속 점유한 채 남기지 않으려는 수명 관리입니다.\n\nJDBC는 SQL 실행·값 바인딩·행 읽기를 직접 작성하는 공통 출발점입니다. MyBatis는 직접 작성한 SQL과 객체의 연결을 도와주고, JPA는 관리 중인 객체의 상태 변화와 DB 반영을 연결합니다. 복잡한 조회 SQL을 세밀하게 제어할지, 객체의 변경 흐름을 중심에 둘지에 따라 살필 비용이 다릅니다. 어느 쪽을 쓰든 트랜잭션을 어디서 시작하고 끝낼지는 정해야 합니다." + } + ], + "example": { + "title": "MyBatisLab 기준 구현", + "code": "package edu.lab;\nimport java.io.InputStream;\nimport java.util.List;\nimport javax.sql.DataSource;\nimport org.apache.ibatis.annotations.Param;\nimport org.apache.ibatis.builder.xml.XMLMapperBuilder;\nimport org.apache.ibatis.mapping.Environment;\nimport org.apache.ibatis.session.*;\nimport org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;\npublic final class MyBatisLab {\n public static class OrderRow {\n private String id; private String sku; private int quantity;\n public String getId() { return id; } public void setId(String id) { this.id = id; }\n public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; }\n public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; }\n }\n public interface Mapper { List findBySku(@Param(\"sku\") String sku); }\n public static SqlSessionFactory factory(DataSource source) {\n Configuration config = new Configuration(new Environment(\"training\", new JdbcTransactionFactory(), source));\n try (InputStream xml = MyBatisLab.class.getResourceAsStream(\"/edu/lab/OrderMapper.xml\")) {\n if (xml == null) throw new IllegalStateException(\"mapper resource missing\");\n new XMLMapperBuilder(xml, config, \"edu/lab/OrderMapper.xml\", config.getSqlFragments()).parse();\n return new SqlSessionFactoryBuilder().build(config);\n } catch (java.io.IOException failure) { throw new IllegalStateException(failure); }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 명시적 열·속성 매핑 · 정확한 SKU 조회 · SQL 조각 입력을 값으로 취급.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "MyBatisLab.factory는 /edu/lab/OrderMapper.xml을 읽고 세션 팩터리를 만듭니다. 테스트에서 얻은 Mapper의 findBySku(\"BOOK\") 호출은 XML의 같은 select id로 연결됩니다. #{sku}는 BOOK 값을 바인딩하고, SELECT의 order_id 별칭은 resultMap을 통해 OrderRow.id에 들어갑니다. 현재 테스트 데이터는 A·BOOK·2와 B·PEN·3이라 BOOK 조회는 A 한 행을 돌려줍니다. SQL 조각처럼 보이는 입력도 값으로 전달되며, 현재 데이터에 같은 SKU가 없어 빈 목록입니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/MyBatisLab.java", + "language": "java", + "code": "package edu.lab;\nimport java.io.InputStream;\nimport java.util.List;\nimport javax.sql.DataSource;\nimport org.apache.ibatis.annotations.Param;\nimport org.apache.ibatis.builder.xml.XMLMapperBuilder;\nimport org.apache.ibatis.mapping.Environment;\nimport org.apache.ibatis.session.*;\nimport org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;\npublic final class MyBatisLab {\n public static class OrderRow {\n private String id; private String sku; private int quantity;\n public String getId() { return id; } public void setId(String id) { this.id = id; }\n public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; }\n public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; }\n }\n public interface Mapper { List findBySku(@Param(\"sku\") String sku); }\n public static SqlSessionFactory factory(DataSource source) {\n Configuration config = new Configuration(new Environment(\"training\", new JdbcTransactionFactory(), source));\n try (InputStream xml = MyBatisLab.class.getResourceAsStream(\"/edu/lab/OrderMapper.xml\")) {\n if (xml == null) throw new IllegalStateException(\"mapper resource missing\");\n new XMLMapperBuilder(xml, config, \"edu/lab/OrderMapper.xml\", config.getSqlFragments()).parse();\n return new SqlSessionFactoryBuilder().build(config);\n } catch (java.io.IOException failure) { throw new IllegalStateException(failure); }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmybatis-mappingorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.mybatismybatis3.5.19\ncom.h2databaseh2\n" + }, + { + "path": "src/main/resources/edu/lab/OrderMapper.xml", + "language": "xml", + "code": "\n\n\n \n \n \n \n\n" + } + ] + }, + "quiz": [ + { + "id": "mybatis-mapping-q1", + "question": "요청으로 받은 sku 값을 검색할 때 올바른 XML 표현은?", + "options": [ + "${sku}로 SQL 문자열에 직접 삽입한다.", + "sku가 문자열이면 작은따옴표만 붙이면 된다.", + "#{sku}로 값 바인딩을 사용한다.", + "입력 길이를 줄이면 어떤 치환도 안전하다." + ], + "correctIndex": 2, + "explanation": "#{sku}는 @Param(\"sku\")의 값을 PreparedStatement에 전달합니다. 예를 들어 \"' OR '1'='1\"을 넣어도 WHERE의 비교값 하나일 뿐 조건식을 추가하지 않습니다. 문자열을 직접 SQL에 치환하면 값과 문법이 섞이므로 요청값을 그대로 넣는 방법은 피해야 합니다." + }, + { + "id": "mybatis-mapping-q2", + "question": "MyBatis를 사용하면 자동으로 사라지는 책임은?", + "options": [ + "인덱스 선택 검토", + "트랜잭션 경계 설정", + "SQL의 행 수 검토", + "위 세 책임 중 어느 것도 자동으로 사라지지 않는다." + ], + "correctIndex": 3, + "explanation": "resultMap이 order_id를 id에 채워 줘도 WHERE가 읽는 행 수나 ORDER BY의 정렬 비용은 그대로 존재합니다. 또 여러 변경을 함께 확정할 트랜잭션 구간도 정해야 합니다. MyBatis 사용만으로 인덱스·트랜잭션·행 수 검토가 자동 완료되는 것은 아닙니다." + } + ], + "practice": { + "title": "SKU 입력에서 결과 객체까지 연결하기", + "scenario": "주문 목록을 SKU로 검색합니다. BOOK에는 A 주문 한 건만 보여야 하고, SQL 조건처럼 보이는 문자열이 입력돼도 전체 주문을 돌려줘서는 안 됩니다.", + "tasks": [ + "XML namespace와 Mapper 인터페이스를 연결하고 select id를 findBySku와 맞추세요.", + "findBySku의 @Param(\"sku\")가 #{sku}에 전달되게 하고, order_id 열을 OrderRow.id에 연결하세요.", + "테스트 데이터 A·BOOK·2와 B·PEN·3에서 BOOK은 A 한 행, MISSING과 SQL 조각 모양의 입력은 빈 목록인 이유를 설명하세요.", + "호출자가 SqlSession을 try-with-resources로 닫는 위치를 확인하세요. 값을 조회한 뒤 세션을 계속 열어 둘 필요가 있는지 설명하세요.", + "검색 데이터가 커지면 어떤 WHERE·ORDER BY와 인덱스·행 수를 확인할지 적으세요. 이 H2 소량 데이터 검사는 운영 DB 성능이나 다른 쿼리의 바인딩 안전성까지 증명하지 않습니다." + ], + "takeaway": "매퍼를 읽을 때 자바 인수 → 바인딩 이름 → SQL 조건 → 결과 열 → 객체 속성을 한 번 연결해 봅니다. SQL과 입력값을 구분하고 쿼리 설계·세션 수명은 계속 직접 검토합니다." + }, + "sources": [ + { + "title": "MyBatis Mapper XML", + "url": "https://mybatis.org/mybatis-3/sqlmap-xml.html", + "edition": "MyBatis 3", + "checkedAt": "2026-10-12" + }, + { + "title": "MyBatis Java API", + "url": "https://mybatis.org/mybatis-3/java-api.html", + "edition": "MyBatis 3", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "MyBatis", + "SQL" + ], + "exerciseId": "mybatis-mapping-project", + "professionalExercise": { + "id": "mybatis-mapping-project", + "profileId": "professional-mybatis", + "mode": "project", + "instructions": [ + "XML namespace와 Mapper 인터페이스의 메서드를 연결하세요.", + "findBySku는 sku가 정확히 일치하는 행만 반환하며 #{sku}로 바인딩합니다.", + "알 수 없는 sku와 SQL 조각처럼 생긴 입력은 빈 목록을 반환합니다.", + "SqlSession은 호출자가 try-with-resources로 닫습니다." + ], + "editableFiles": [ + "src/main/resources/edu/lab/OrderMapper.xml" + ], + "template": "package edu.lab;\nimport java.io.InputStream;\nimport java.util.List;\nimport javax.sql.DataSource;\nimport org.apache.ibatis.annotations.Param;\nimport org.apache.ibatis.builder.xml.XMLMapperBuilder;\nimport org.apache.ibatis.mapping.Environment;\nimport org.apache.ibatis.session.*;\nimport org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;\npublic final class MyBatisLab {\n public static class OrderRow {\n private String id; private String sku; private int quantity;\n public String getId() { return id; } public void setId(String id) { this.id = id; }\n public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; }\n public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; }\n }\n public interface Mapper { List findBySku(@Param(\"sku\") String sku); }\n public static SqlSessionFactory factory(DataSource source) {\n Configuration config = new Configuration(new Environment(\"training\", new JdbcTransactionFactory(), source));\n try (InputStream xml = MyBatisLab.class.getResourceAsStream(\"/edu/lab/OrderMapper.xml\")) {\n if (xml == null) throw new IllegalStateException(\"mapper resource missing\");\n new XMLMapperBuilder(xml, config, \"edu/lab/OrderMapper.xml\", config.getSqlFragments()).parse();\n return new SqlSessionFactoryBuilder().build(config);\n } catch (java.io.IOException failure) { throw new IllegalStateException(failure); }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/MyBatisLab.java", + "templatePath": "lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java", + "referencePath": "lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/mybatis-mapping/starter/pom.xml", + "referencePath": "lessons/mybatis-mapping/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/edu/lab/OrderMapper.xml", + "templatePath": "lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml", + "referencePath": "lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/mybatis-mapping/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "명시적 열·속성 매핑", + "정확한 SKU 조회", + "SQL 조각 입력을 값으로 취급" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "sql-jdbc" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "127b6b7a7bdcf465e0b3b55b32791f14bef3accc1f246aa163497b27102bb36c", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "A 주문은 BOOK 2개, B 주문은 PEN 3개입니다. BOOK을 검색하면 A만 나와야 하고, 입력이 SQL처럼 생겼다고 두 주문이 모두 나오면 안 됩니다.", + "question": "findBySku의 String 인수는 어디서 SQL 값이 되고 결과의 order_id는 어떻게 자바 id가 될까요?", + "terms": [ + { + "term": "매퍼(mapper)", + "meaning": "자바 메서드와 SQL을 연결하는 구성입니다. Mapper.findBySku와 XML의 findBySku가 같은 작업을 가리킵니다." + }, + { + "term": "값 바인딩", + "meaning": "SQL의 형태를 유지한 채 값만 따로 전달하는 일입니다. #{sku}는 @Param(\"sku\")로 받은 문자열을 비교값으로 넣습니다." + }, + { + "term": "resultMap", + "meaning": "조회 결과의 열을 객체의 어느 속성에 넣을지 적은 규칙입니다. order_id 열이 OrderRow.id로 연결됩니다." + }, + { + "term": "SqlSession", + "meaning": "매퍼를 얻고 SQL 작업을 수행하는 세션입니다. 이 예제에서는 호출자가 열고 작업 후 닫습니다." + } + ], + "walkthrough": [ + "MyBatisLab.factory가 이 XML을 읽어 등록합니다. namespace의 MyBatisLab$Mapper는 자바 중첩 Mapper 인터페이스를 가리킵니다.", + "호출자가 세션에서 Mapper를 얻고 findBySku(\"BOOK\")을 호출하면 select id=\"findBySku\"의 SQL이 선택됩니다.", + "자바 @Param(\"sku\")로 이름 붙인 BOOK이 #{sku}에 바인딩됩니다. WHERE sku = ?라는 비교 구조를 입력 문자열로 다시 만들지 않습니다.", + "SELECT의 id AS order_id가 만든 열을 resultMap의 id property=\"id\"가 OrderRow.id에 연결합니다. sku와 quantity도 각각 같은 이름의 속성으로 옮깁니다.", + "테스트 데이터에서는 BOOK인 A 한 행만 반환되고 quantity는 2입니다. 호출자는 결과를 확인한 뒤 try-with-resources로 SqlSession을 닫습니다." + ], + "prediction": { + "question": "현재 테스트 데이터에서 findBySku(\"' OR '1'='1\")를 부르면 모든 주문이 나올까요?", + "answer": "아닙니다. 빈 목록이 나옵니다.", + "explanation": "#{sku}는 문자열 전체를 하나의 비교값으로 바인딩합니다. BOOK·PEN 중 그 문자열과 같은 SKU가 없어 결과가 없습니다. SQL 조각을 지워서가 아니라 SQL 구조를 바꾸지 않기 때문입니다." + }, + "takeaway": "MyBatis가 줄여 주는 코드는 값 전달과 결과 매핑입니다. 입력 문자열이 SQL 문법이 되지 않는 이유와 각 열이 어느 필드로 가는지는 XML과 인터페이스를 함께 읽어 확인합니다.", + "code": { + "file": "src/main/resources/edu/lab/OrderMapper.xml", + "source": "\n \n \n \n \n\n" + } + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/reference/pom.xml b/curriculum/professional/lessons/mybatis-mapping/reference/pom.xml new file mode 100644 index 0000000..e62cc4a --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmybatis-mappingorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.mybatismybatis3.5.19 +com.h2databaseh2 diff --git a/curriculum/professional/lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java b/curriculum/professional/lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java new file mode 100644 index 0000000..79c4b25 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/reference/src/main/java/edu/lab/MyBatisLab.java @@ -0,0 +1,26 @@ +package edu.lab; +import java.io.InputStream; +import java.util.List; +import javax.sql.DataSource; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +public final class MyBatisLab { + public static class OrderRow { + private String id; private String sku; private int quantity; + public String getId() { return id; } public void setId(String id) { this.id = id; } + public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; } + public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; } + } + public interface Mapper { List findBySku(@Param("sku") String sku); } + public static SqlSessionFactory factory(DataSource source) { + Configuration config = new Configuration(new Environment("training", new JdbcTransactionFactory(), source)); + try (InputStream xml = MyBatisLab.class.getResourceAsStream("/edu/lab/OrderMapper.xml")) { + if (xml == null) throw new IllegalStateException("mapper resource missing"); + new XMLMapperBuilder(xml, config, "edu/lab/OrderMapper.xml", config.getSqlFragments()).parse(); + return new SqlSessionFactoryBuilder().build(config); + } catch (java.io.IOException failure) { throw new IllegalStateException(failure); } + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml b/curriculum/professional/lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml new file mode 100644 index 0000000..65bed15 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/reference/src/main/resources/edu/lab/OrderMapper.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/curriculum/professional/lessons/mybatis-mapping/starter/pom.xml b/curriculum/professional/lessons/mybatis-mapping/starter/pom.xml new file mode 100644 index 0000000..e62cc4a --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlmybatis-mappingorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.mybatismybatis3.5.19 +com.h2databaseh2 diff --git a/curriculum/professional/lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java b/curriculum/professional/lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java new file mode 100644 index 0000000..79c4b25 --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/starter/src/main/java/edu/lab/MyBatisLab.java @@ -0,0 +1,26 @@ +package edu.lab; +import java.io.InputStream; +import java.util.List; +import javax.sql.DataSource; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +public final class MyBatisLab { + public static class OrderRow { + private String id; private String sku; private int quantity; + public String getId() { return id; } public void setId(String id) { this.id = id; } + public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; } + public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity = quantity; } + } + public interface Mapper { List findBySku(@Param("sku") String sku); } + public static SqlSessionFactory factory(DataSource source) { + Configuration config = new Configuration(new Environment("training", new JdbcTransactionFactory(), source)); + try (InputStream xml = MyBatisLab.class.getResourceAsStream("/edu/lab/OrderMapper.xml")) { + if (xml == null) throw new IllegalStateException("mapper resource missing"); + new XMLMapperBuilder(xml, config, "edu/lab/OrderMapper.xml", config.getSqlFragments()).parse(); + return new SqlSessionFactoryBuilder().build(config); + } catch (java.io.IOException failure) { throw new IllegalStateException(failure); } + } +} diff --git a/curriculum/professional/lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml b/curriculum/professional/lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml new file mode 100644 index 0000000..4f188de --- /dev/null +++ b/curriculum/professional/lessons/mybatis-mapping/starter/src/main/resources/edu/lab/OrderMapper.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/curriculum/professional/lessons/observability/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/observability/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..c620db5 --- /dev/null +++ b/curriculum/professional/lessons/observability/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,28 @@ +package edu.lab; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.*; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + static class Events implements ObservationHandler { + int stops, errors; + public boolean supportsContext(Observation.Context context) { return true; } + public void onStop(Observation.Context context) { stops++; } + public void onError(Observation.Context context) { errors++; } + } + @Test void recordsBothOutcomesAndPreservesFailure() { + var meters = new SimpleMeterRegistry(); + try { + var registry = ObservationRegistry.create(); var events = new Events(); registry.observationConfig().observationHandler(events); + var service = new OrderTelemetry(meters,registry); RuntimeException failure = new IllegalStateException("simulated"); + service.run("A",() -> {}); + assertThatThrownBy(() -> service.run("B",() -> { throw failure; })).isSameAs(failure); + assertThat(meters.get("orders.reserve.calls").tag("outcome","success").counter().count()).isEqualTo(1); + assertThat(meters.get("orders.reserve.calls").tag("outcome","failure").counter().count()).isEqualTo(1); + assertThat(meters.get("orders.reserve.duration").tag("outcome","success").timer().count()).isEqualTo(1); + assertThat(meters.get("orders.reserve.duration").tag("outcome","failure").timer().count()).isEqualTo(1); + assertThat(events.stops).isEqualTo(2); assertThat(events.errors).isEqualTo(1); + assertThat(meters.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags()).allSatisfy(tag -> assertThat(tag.getKey()).isEqualTo("outcome"))); + } finally { meters.close(); } + } +} diff --git a/curriculum/professional/lessons/observability/lesson.json b/curriculum/professional/lessons/observability/lesson.json new file mode 100644 index 0000000..b51a46a --- /dev/null +++ b/curriculum/professional/lessons/observability/lesson.json @@ -0,0 +1,218 @@ +{ + "id": "observability", + "title": "Micrometer 지표와 관측 실패 신호", + "description": "실패한 주문이 로그에만 남고 처리 시간 집계에서 빠지면 장애를 놓칠 수 있습니다. work.run의 성공·실패를 세고 시간을 남기면서 원래 예외도 전달하는 코드를 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "work.run이 성공하거나 실패할 때 어떤 관측 기록을 남기는지 설명한다.", + "outcome이 success에서 failure로 바뀌는 지점을 찾는다.", + "finally에서 호출 수·처리 시간을 기록하고 Observation을 끝내는 이유를 설명한다.", + "관측을 추가해도 원래 업무 예외를 호출자에게 전달해야 하는 이유를 설명한다.", + "주문별 ID 대신 값이 제한된 outcome 태그를 쓰는 이유를 설명한다." + ], + "learningGuide": { + "scenario": "주문 A 처리는 성공하고 B 처리는 IllegalStateException을 던졌습니다. 둘 다 얼마나 걸렸는지 남기면서 B의 호출자에게는 실패를 그대로 알려야 합니다.", + "question": "catch에서 예외를 다시 던지는데도 실패한 요청의 시간을 기록할 수 있을까요?", + "terms": [ + { + "term": "Counter / Timer", + "meaning": "Counter는 횟수를 누적하고 Timer는 실행 시간과 기록 수를 모읍니다. 이 예제는 calls와 duration에 각각 사용합니다." + }, + { + "term": "태그", + "meaning": "지표를 나누는 이름·값입니다. outcome=success 또는 outcome=failure로 결과를 구분합니다." + }, + { + "term": "Observation", + "meaning": "관측할 구간의 시작·오류·종료를 관련 처리기에 알리는 객체입니다. 실제 원격 수집기까지 자동 설치하는 것은 아닙니다." + }, + { + "term": "finally", + "meaning": "try가 정상 종료되거나 catch가 예외를 다시 던져도 실행되는 정리 구간입니다. 여기서 시간 기록과 관측 종료를 합니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/OrderTelemetry.java", + "source": " Timer.Sample sample = Timer.start(meters); String outcome=\"success\";\n Observation observation = Observation.start(\"orders.reserve\", observations);\n try (Observation.Scope ignored = observation.openScope()) { work.run(); }\n catch (RuntimeException | Error failure) { outcome=\"failure\"; observation.error(failure); throw failure; }\n finally {\n meters.counter(\"orders.reserve.calls\",\"outcome\",outcome).increment();\n sample.stop(Timer.builder(\"orders.reserve.duration\").tag(\"outcome\",outcome).register(meters));\n observation.stop();" + }, + "walkthrough": [ + "Timer.start(meters)로 시간을 재기 시작하고 outcome을 success로 둡니다. Observation.start는 orders.reserve라는 관측 구간을 시작합니다.", + "try의 observation.openScope() 안에서 work.run()을 실행합니다. 업무가 정상이면 outcome은 success로 남습니다.", + "업무가 RuntimeException이나 Error를 던지면 catch가 outcome을 failure로 바꾸고 observation.error로 알립니다. throw failure는 원래 예외를 호출자에게 보내기 위한 줄입니다.", + "그 뒤 finally의 counter(...).increment()가 최종 outcome의 호출 수를 늘리고 sample.stop(...)이 같은 outcome으로 경과 시간을 기록합니다.", + "observation.stop()으로 관측을 끝냅니다. 태그를 만드는 두 줄 모두 orderId를 넣지 않고 outcome만 사용합니다." + ], + "prediction": { + "question": "새 메모리 레지스트리에서 성공 한 번과 업무 예외 한 번을 실행하고 관측 도구는 정상 동작했다면, 실패 Counter와 Timer의 기록 수는 얼마일까요?", + "answer": "failure Counter는 1이고 failure Timer의 기록 수도 1입니다. 업무 예외도 호출자에게 전달됩니다.", + "explanation": "catch가 failure로 표시한 뒤 예외를 던져도 finally가 실행됩니다. 그래서 실패 횟수와 시간 기록이 남습니다. 기록 수 1은 특정 밀리초가 측정됐다는 고정 결과나 성능 개선 수치를 뜻하지 않습니다." + }, + "takeaway": "업무 실패를 보이게 만드는 관측 코드가 그 실패를 숨기거나 바꾸지 않는지 확인합니다." + }, + "theory": [ + { + "heading": "로그·지표·추적의 질문", + "text": "주문 처리가 느려졌다는 문의에는 시간이 필요하고, 얼마나 자주 실패했는지에는 횟수가 필요합니다. 지표는 이렇게 수와 변화를 모아 보는 데이터이며, 로그는 개별 사건의 문맥, 추적은 요청이 거친 경로를 보는 데 쓰입니다. OrderTelemetry는 Counter로 호출 수, Timer로 처리 시간을 기록합니다. Observation은 관측 구간의 시작·오류·종료를 알리는 객체입니다. 이번 검사는 메모리 안의 저장소와 콜백을 사용하므로 원격 수집기·대시보드·분산 추적 배포를 구성한 것은 아닙니다." + }, + { + "heading": "태그의 개수 비용", + "text": "태그는 같은 지표를 조건별로 나누는 이름·값입니다. 이 코드의 outcome 태그는 success 또는 failure여서 결과별 호출 수를 볼 수 있습니다. 매 주문의 orderId를 붙이면 주문마다 별도 시계열, 즉 시간에 따른 별도 값의 묶음이 생겨 수집·저장·조회할 대상이 계속 늘어납니다. run은 orderId를 인수로 받지만 지표 태그에 넣지 않습니다. 이메일 같은 개인 식별값도 이런 태그로 쓰지 않습니다. 상세 식별자가 필요한 로그·추적에는 접근 권한과 보존 정책을 따로 정합니다." + }, + { + "heading": "실패도 끝까지 기록", + "text": "try 안의 work.run이 예외를 던지면 catch가 outcome을 failure로 바꾸고 observation.error에 원인을 알린 뒤 같은 failure를 다시 던집니다. finally는 성공·실패 두 경로에서 호출 수와 시간을 기록하고 observation.stop을 호출합니다. 예외를 삼키고 정상 반환하면 상위 호출자가 성공으로 오해해 재시도나 DB 변경 취소인 롤백을 하지 않을 수 있습니다. 다만 관측 도구 자체가 예외를 던질 때 원래 업무에 미칠 영향은 별도 설계 대상이며, 이번 메모리 검사만으로 그 장애 대응까지 보장하지 않습니다." + } + ], + "example": { + "title": "OrderTelemetry 기준 구현", + "code": "package edu.lab;\nimport java.util.Objects;\nimport io.micrometer.core.instrument.*;\nimport io.micrometer.observation.*;\npublic final class OrderTelemetry {\n private final MeterRegistry meters; private final ObservationRegistry observations;\n public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); }\n public void run(String orderId, Runnable work) {\n // BEGIN EXERCISE\n Objects.requireNonNull(orderId); Objects.requireNonNull(work);\n Timer.Sample sample = Timer.start(meters); String outcome=\"success\";\n Observation observation = Observation.start(\"orders.reserve\", observations);\n try (Observation.Scope ignored = observation.openScope()) { work.run(); }\n catch (RuntimeException | Error failure) { outcome=\"failure\"; observation.error(failure); throw failure; }\n finally {\n meters.counter(\"orders.reserve.calls\",\"outcome\",outcome).increment();\n sample.stop(Timer.builder(\"orders.reserve.duration\").tag(\"outcome\",outcome).register(meters));\n observation.stop();\n }\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 1개 JUnit 테스트 통과.\n검증 항목: 성공·실패 계수 · 성공·실패 타이머 기록 · 예외 보존과 Observation 종료 · 주문 ID 태그 제외.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "유효한 인수로 run에 들어오면 Timer.Sample이 시작되고 outcome은 success입니다. work.run에서 RuntimeException 또는 Error가 나면 catch가 failure로 바꾼 뒤 같은 예외를 다시 던집니다. finally는 그 outcome으로 calls를 1 늘리고 duration에 시간을 남긴 다음 Observation을 종료합니다. 기준 테스트는 성공 한 번·실패 한 번 후 각 Counter와 Timer의 기록 수, 종료 두 번과 오류 한 번, 원래 예외 객체 보존을 검사합니다. 실제 처리 속도의 개선이나 원격 수집 성공을 측정하는 테스트는 아닙니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/OrderTelemetry.java", + "language": "java", + "code": "package edu.lab;\nimport java.util.Objects;\nimport io.micrometer.core.instrument.*;\nimport io.micrometer.observation.*;\npublic final class OrderTelemetry {\n private final MeterRegistry meters; private final ObservationRegistry observations;\n public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); }\n public void run(String orderId, Runnable work) {\n // BEGIN EXERCISE\n Objects.requireNonNull(orderId); Objects.requireNonNull(work);\n Timer.Sample sample = Timer.start(meters); String outcome=\"success\";\n Observation observation = Observation.start(\"orders.reserve\", observations);\n try (Observation.Scope ignored = observation.openScope()) { work.run(); }\n catch (RuntimeException | Error failure) { outcome=\"failure\"; observation.error(failure); throw failure; }\n finally {\n meters.counter(\"orders.reserve.calls\",\"outcome\",outcome).increment();\n sample.stop(Timer.builder(\"orders.reserve.duration\").tag(\"outcome\",outcome).register(meters));\n observation.stop();\n }\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlobservabilityorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\nio.micrometermicrometer-core\nio.micrometermicrometer-observation\n" + } + ] + }, + "quiz": [ + { + "id": "observability-q1", + "question": "orderId를 모든 지표의 태그로 넣었을 때 주된 비용은?", + "options": [ + "서로 다른 값만큼 시계열 수가 늘어 저장·조회 비용이 커질 수 있다.", + "지표 값이 항상 0이 된다.", + "Java 타입 검사가 실패한다.", + "트랜잭션 격리 수준이 자동 변경된다." + ], + "correctIndex": 0, + "explanation": "outcome=success와 outcome=failure는 제한된 두 값입니다. 여기에 주문마다 다른 orderId까지 넣으면 태그 조합이 계속 늘어 별도 지표를 수집·저장해야 합니다. 타입 오류나 트랜잭션 변경이 아니라 관측 데이터의 수와 운영 비용 문제입니다." + }, + { + "id": "observability-q2", + "question": "관측을 추가한 뒤 업무 예외를 잡아 정상 반환하면 생기는 문제는?", + "options": [ + "호출자의 실패·롤백·재시도 계약을 바꿀 수 있다.", + "지표 이름만 바뀐다.", + "실패를 기록했으므로 항상 안전하다.", + "Observation이 자동으로 원래 예외를 다시 던진다." + ], + "correctIndex": 0, + "explanation": "catch에서 failure를 기록하고 return하면 호출자는 성공으로 볼 수 있습니다. 이 예제는 observation.error(failure) 뒤 throw failure를 실행하므로 관측 도구가 정상 동작하는 테스트 조건에서 원래 실패를 보존합니다. Observation이 대신 예외를 던져 줄 것이라고 가정하지 않습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "주문 장애가 났는데 성공한 요청의 처리 시간만 남아 원인을 찾기 어렵습니다. 실패도 집계하되, 실패를 기록하는 코드 때문에 호출자가 성공으로 오해하지 않도록 흐름을 점검합니다.", + "tasks": [ + "성공한 work와 예외를 던진 work에서 outcome이 각각 어떤 값으로 finally에 도착하는지 따라가세요.", + "calls Counter와 duration Timer가 성공·실패 각각 한 번 기록되는 테스트 조건을 설명하세요. Timer의 기록 수와 실제 시간 수치를 구분하세요.", + "observation.error(failure), throw failure, observation.stop()의 역할을 나누어 설명하세요.", + "orderId를 인수로 받아도 지표 태그에 넣지 않은 이유를 적으세요. 원격 exporter와 Actuator의 운영 공개는 이번 검사 범위가 아닙니다.", + "지표 저장 자체가 실패하는 경우 원래 업무 예외와 결과에 미칠 영향을 별도 미검증 사항으로 적으세요." + ], + "takeaway": "실패도 수와 시간으로 남기되 원래 실패는 호출자에게 전달합니다. 남길 데이터의 종류와 값의 가짓수도 함께 설계합니다." + }, + "sources": [ + { + "title": "Micrometer Counters", + "url": "https://docs.micrometer.io/micrometer/reference/concepts/counters.html", + "edition": "Micrometer", + "checkedAt": "2026-10-12" + }, + { + "title": "Micrometer Timers", + "url": "https://docs.micrometer.io/micrometer/reference/concepts/timers.html", + "edition": "Micrometer", + "checkedAt": "2026-10-12" + }, + { + "title": "Micrometer Observation", + "url": "https://docs.micrometer.io/micrometer/reference/observation/introduction.html", + "edition": "Micrometer", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Observability", + "Micrometer" + ], + "exerciseId": "observability-project", + "professionalExercise": { + "id": "observability-project", + "profileId": "professional-observability", + "mode": "project", + "instructions": [ + "성공과 실패 각각 calls Counter와 duration Timer를 outcome 태그로 기록합니다.", + "업무 예외는 바꾸거나 삼키지 않고 그대로 전달합니다.", + "Observation은 성공·실패 모두 종료하며 실패 원인을 error로 알립니다.", + "orderId를 지표 태그에 넣지 않습니다. 원격 exporter나 Actuator 운영 공개는 이 과제 범위가 아닙니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/OrderTelemetry.java" + ], + "template": "package edu.lab;\nimport java.util.Objects;\nimport io.micrometer.core.instrument.*;\nimport io.micrometer.observation.*;\npublic final class OrderTelemetry {\n private final MeterRegistry meters; private final ObservationRegistry observations;\n public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); }\n public void run(String orderId, Runnable work) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/OrderTelemetry.java", + "templatePath": "lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java", + "referencePath": "lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/observability/starter/pom.xml", + "referencePath": "lessons/observability/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/observability/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "성공·실패 계수", + "성공·실패 타이머 기록", + "예외 보존과 Observation 종료", + "주문 ID 태그 제외" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "spring-di", + "test-collaborators" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 1, + "starterSatisfied": false, + "sourceFingerprint": "92e8fff2dd813d24d2a77243bf640196e2bbabcde8ec61f95343ce9e953a3c2e", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/observability/reference/pom.xml b/curriculum/professional/lessons/observability/reference/pom.xml new file mode 100644 index 0000000..dbd3cc4 --- /dev/null +++ b/curriculum/professional/lessons/observability/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlobservabilityorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +io.micrometermicrometer-core +io.micrometermicrometer-observation diff --git a/curriculum/professional/lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java b/curriculum/professional/lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java new file mode 100644 index 0000000..620a5ef --- /dev/null +++ b/curriculum/professional/lessons/observability/reference/src/main/java/edu/lab/OrderTelemetry.java @@ -0,0 +1,22 @@ +package edu.lab; +import java.util.Objects; +import io.micrometer.core.instrument.*; +import io.micrometer.observation.*; +public final class OrderTelemetry { + private final MeterRegistry meters; private final ObservationRegistry observations; + public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); } + public void run(String orderId, Runnable work) { + // BEGIN EXERCISE + Objects.requireNonNull(orderId); Objects.requireNonNull(work); + Timer.Sample sample = Timer.start(meters); String outcome="success"; + Observation observation = Observation.start("orders.reserve", observations); + try (Observation.Scope ignored = observation.openScope()) { work.run(); } + catch (RuntimeException | Error failure) { outcome="failure"; observation.error(failure); throw failure; } + finally { + meters.counter("orders.reserve.calls","outcome",outcome).increment(); + sample.stop(Timer.builder("orders.reserve.duration").tag("outcome",outcome).register(meters)); + observation.stop(); + } + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/observability/starter/pom.xml b/curriculum/professional/lessons/observability/starter/pom.xml new file mode 100644 index 0000000..dbd3cc4 --- /dev/null +++ b/curriculum/professional/lessons/observability/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlobservabilityorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +io.micrometermicrometer-core +io.micrometermicrometer-observation diff --git a/curriculum/professional/lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java b/curriculum/professional/lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java new file mode 100644 index 0000000..a1b1369 --- /dev/null +++ b/curriculum/professional/lessons/observability/starter/src/main/java/edu/lab/OrderTelemetry.java @@ -0,0 +1,11 @@ +package edu.lab; +import java.util.Objects; +import io.micrometer.core.instrument.*; +import io.micrometer.observation.*; +public final class OrderTelemetry { + private final MeterRegistry meters; private final ObservationRegistry observations; + public OrderTelemetry(MeterRegistry meters, ObservationRegistry observations) { this.meters=Objects.requireNonNull(meters); this.observations=Objects.requireNonNull(observations); } + public void run(String orderId, Runnable work) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..515703e --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,29 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +@SpringBootTest(classes=IdentityLab.class) +@AutoConfigureMockMvc +class ContractTest { + @Autowired MockMvc mvc; + @Test void authenticationRequired() throws Exception { mvc.perform(get("/me")).andExpect(status().isUnauthorized()); } + @Test void trustedIssuerAndSubject() throws Exception { + mvc.perform(get("/me").with(oidcLogin().idToken(t -> t.claim("iss",IdentityLab.TRUSTED_ISSUER).claim("sub","subject-A")))) + .andExpect(status().isOk()).andExpect(jsonPath("$.issuer").value(IdentityLab.TRUSTED_ISSUER)).andExpect(jsonPath("$.subject").value("subject-A")); + } + @Test void rejectOtherIssuer() throws Exception { + mvc.perform(get("/me").with(oidcLogin().idToken(t -> t.claim("iss","https://other.invalid").claim("sub","subject-A")))) + .andExpect(status().isForbidden()); + } + @Test void sameEmailIsNotSameSubject() throws Exception { + for (String subject : new String[]{"subject-A","subject-B"}) { + mvc.perform(get("/me").with(oidcLogin().idToken(t -> t.claim("iss",IdentityLab.TRUSTED_ISSUER).claim("sub",subject).claim("email","shared@example.invalid")))) + .andExpect(jsonPath("$.subject").value(subject)); + } + } +} diff --git a/curriculum/professional/lessons/oidc-identity/lesson.json b/curriculum/professional/lessons/oidc-identity/lesson.json new file mode 100644 index 0000000..d1976be --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/lesson.json @@ -0,0 +1,211 @@ +{ + "id": "oidc-identity", + "title": "OAuth2·OIDC의 역할과 계정 식별 경계", + "description": "이메일이 같은 두 외부 로그인 사용자를 한 계정으로 합치면 안 되는 이유를 살펴봅니다. 이미 인증된 사용자 정보에서 신뢰한 발급자와 사용자 ID를 골라 반환합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "외부 로그인 사용자의 이메일 대신 issuer와 subject를 함께 식별에 쓰는 이유를 설명한다.", + "GET /me의 미인증 401과 비신뢰 발급자 403을 구분한다.", + "principal의 발급자 검사 뒤 subject를 읽어 Identity를 만드는 순서를 따라간다.", + "같은 이메일이어도 subject가 다른 두 요청이 서로 다른 식별값을 갖는지 설명한다.", + "oidcLogin 테스트가 로그인 전체 과정을 실행하지 않는다는 한계를 설명한다." + ], + "learningGuide": { + "scenario": "테스트에 같은 이메일을 넣은 subject-A와 subject-B가 있습니다. 서비스는 두 요청을 같은 사용자로 합치지 않고 각각의 식별값을 돌려주어야 합니다.", + "question": "이 코드에서 사용자를 구분하는 값은 이메일일까요, 다른 두 값일까요?", + "terms": [ + { + "term": "principal", + "meaning": "인증 후 애플리케이션에 전달되는 요청자의 정보입니다. 여기서는 OidcUser 타입의 principal입니다." + }, + { + "term": "issuer", + "meaning": "사용자 정보를 발급한 주체입니다. getIssuer()의 값을 이 실습이 허용한 TRUSTED_ISSUER와 비교합니다." + }, + { + "term": "subject", + "meaning": "해당 발급자 안에서 사용자를 구분하는 값입니다. getSubject()로 읽으며 발급자와 함께 사용합니다." + }, + { + "term": "fixture", + "meaning": "테스트에서 상황을 만들기 위해 넣는 가상 설정이나 데이터입니다. oidcLogin()은 실제 로그인 절차 대신 인증된 주체를 넣습니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/IdentityLab.java", + "source": " @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n // BEGIN EXERCISE\n if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString()))\n throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"issuer not trusted\");\n String subject = principal.getSubject();\n if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"subject missing\");\n return new Identity(TRUSTED_ISSUER, subject);\n // END EXERCISE" + }, + "walkthrough": [ + "@AuthenticationPrincipal OidcUser principal은 인증 뒤의 사용자 정보를 인수로 받습니다. 이 발췌는 외부 로그인 화면이나 토큰 교환을 구현하는 부분이 아닙니다.", + "첫 if는 getIssuer()가 없거나 허용한 TRUSTED_ISSUER와 다르면 FORBIDDEN, 즉 403을 던집니다. 발급자를 신뢰할 수 있어야 다음으로 갑니다.", + "String subject = principal.getSubject()로 그 발급자 안의 사용자 ID를 읽습니다. 다음 if는 없거나 빈 문자열인 ID를 거절합니다.", + "return new Identity(TRUSTED_ISSUER, subject)는 검사한 발급자와 사용자 ID를 함께 반환합니다. 이메일을 비교하거나 병합하는 단계는 없습니다." + ], + "prediction": { + "question": "허용 issuer와 같은 이메일을 가진 두 주체의 subject가 각각 subject-A, subject-B이면 /me의 결과도 같을까요?", + "answer": "다릅니다. issuer는 같고 각 결과의 subject는 subject-A, subject-B로 유지됩니다.", + "explanation": "반환값은 new Identity(TRUSTED_ISSUER, subject)로 만듭니다. 이메일을 참조하지 않으므로 같은 이메일이 두 주체를 하나로 바꾸지 않습니다. 이 결과는 이미 인증된 주체를 넣은 테스트의 범위입니다." + }, + "takeaway": "외부 신원을 내부 계정에 연결하기 전에 누가 발급한 어떤 사용자 ID인지 먼저 구분합니다." + }, + "theory": [ + { + "heading": "OAuth2와 OIDC의 역할", + "text": "외부 서비스에 내 자원의 일부를 읽을 권한을 주는 일과, 지금 로그인한 사용자가 누구인지 받는 일은 다릅니다. OAuth2는 앞의 권한 위임을 다루고 OIDC는 그 위에 사용자 인증과 ID Token의 의미를 더합니다. ID Token은 로그인 신원 정보를 담는 토큰이고 access token은 자원 접근에 쓰이는 토큰입니다. 이 예제의 me는 인증 후 전달된 OidcUser를 읽습니다. access token 문자열을 임의로 해석해 계정 ID로 삼는 코드가 아닙니다." + }, + { + "heading": "계정 식별의 안정성", + "text": "issuer는 누가 신원을 발급했는지, subject는 그 발급자가 구분한 사용자가 누구인지를 나타냅니다. Me.me는 먼저 principal.getIssuer()가 TRUSTED_ISSUER와 같은지 검사하고, 비어 있지 않은 getSubject()와 묶어 Identity를 반환합니다. 같은 이메일을 가진 subject-A와 subject-B라도 결과는 다릅니다. 이메일은 바뀌거나 다시 쓰일 수 있으므로 이 코드의 계정 식별 기준이 아닙니다. 신뢰할 발급자 목록과 실제 내부 계정 연결 정책은 서비스가 별도로 정해야 합니다." + }, + { + "heading": "테스트 프로필의 한계", + "text": "fixture는 테스트 조건을 만들기 위한 가상 데이터나 설정입니다. 여기서는 oidcLogin()이 이미 인증된 OidcUser를 요청에 넣어 주므로 Me.me의 신원 처리만 집중해서 검사합니다. .invalid 주소의 실제 로그인 제공자를 방문하지 않습니다. 로그인 화면으로 이동하는 redirect, 요청·응답을 연결하는 state와 nonce, 코드 교환을 보호하는 PKCE, authorization code 교환, ID Token 서명 검증을 끝까지 실행한 테스트는 아닙니다. 이 단계들은 별도 로컬 로그인 제공자(IdP) 통합 검증으로 확인해야 합니다." + } + ], + "example": { + "title": "IdentityLab 기준 구현", + "code": "package edu.lab;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.client.registration.*;\nimport org.springframework.security.oauth2.core.AuthorizationGrantType;\nimport org.springframework.security.oauth2.core.oidc.user.OidcUser;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.HttpStatusEntryPoint;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.server.ResponseStatusException;\n@SpringBootApplication\npublic class IdentityLab {\n public static final String TRUSTED_ISSUER = \"https://training-issuer.invalid\";\n public record Identity(String issuer, String subject) {}\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults())\n .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));\n return http.build();\n }\n @Bean ClientRegistrationRepository clients() {\n ClientRegistration fixture = ClientRegistration.withRegistrationId(\"training\")\n .clientId(\"training-client\").clientSecret(\"training-only-not-real\")\n .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)\n .redirectUri(\"{baseUrl}/login/oauth2/code/{registrationId}\").scope(\"openid\")\n .authorizationUri(TRUSTED_ISSUER + \"/authorize\").tokenUri(TRUSTED_ISSUER + \"/token\")\n .jwkSetUri(TRUSTED_ISSUER + \"/jwks\").userInfoUri(TRUSTED_ISSUER + \"/userinfo\")\n .userNameAttributeName(\"sub\").issuerUri(TRUSTED_ISSUER).clientName(\"local fixture\").build();\n return new InMemoryClientRegistrationRepository(fixture);\n }\n @RestController public static class Me {\n @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n // BEGIN EXERCISE\n if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString()))\n throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"issuer not trusted\");\n String subject = principal.getSubject();\n if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"subject missing\");\n return new Identity(TRUSTED_ISSUER, subject);\n // END EXERCISE\n }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 미인증 401 · 신뢰한 issuer·subject 전달 · 비신뢰 issuer 403 · 이메일로 신원 병합하지 않음.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "Me.me는 @AuthenticationPrincipal로 받은 OidcUser에서 발급자를 먼저 확인합니다. 발급자가 없거나 TRUSTED_ISSUER와 다르면 403을 던지고, subject가 없거나 비어 있어도 거절합니다. 통과하면 발급자와 subject 두 값만 Identity에 담아 반환합니다. 이메일을 읽는 줄은 없습니다. 테스트의 oidcLogin은 이 메서드에 전달할 인증 주체를 만들어 주며 실제 외부 로그인이나 키 서버 호출을 실행하지 않습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/IdentityLab.java", + "language": "java", + "code": "package edu.lab;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.client.registration.*;\nimport org.springframework.security.oauth2.core.AuthorizationGrantType;\nimport org.springframework.security.oauth2.core.oidc.user.OidcUser;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.HttpStatusEntryPoint;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.server.ResponseStatusException;\n@SpringBootApplication\npublic class IdentityLab {\n public static final String TRUSTED_ISSUER = \"https://training-issuer.invalid\";\n public record Identity(String issuer, String subject) {}\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults())\n .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));\n return http.build();\n }\n @Bean ClientRegistrationRepository clients() {\n ClientRegistration fixture = ClientRegistration.withRegistrationId(\"training\")\n .clientId(\"training-client\").clientSecret(\"training-only-not-real\")\n .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)\n .redirectUri(\"{baseUrl}/login/oauth2/code/{registrationId}\").scope(\"openid\")\n .authorizationUri(TRUSTED_ISSUER + \"/authorize\").tokenUri(TRUSTED_ISSUER + \"/token\")\n .jwkSetUri(TRUSTED_ISSUER + \"/jwks\").userInfoUri(TRUSTED_ISSUER + \"/userinfo\")\n .userNameAttributeName(\"sub\").issuerUri(TRUSTED_ISSUER).clientName(\"local fixture\").build();\n return new InMemoryClientRegistrationRepository(fixture);\n }\n @RestController public static class Me {\n @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n // BEGIN EXERCISE\n if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString()))\n throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"issuer not trusted\");\n String subject = principal.getSubject();\n if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, \"subject missing\");\n return new Identity(TRUSTED_ISSUER, subject);\n // END EXERCISE\n }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloidc-identityorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-oauth2-client\norg.springframework.bootspring-boot-starter-testtest\norg.springframework.securityspring-security-testtest\n" + } + ] + }, + "quiz": [ + { + "id": "oidc-identity-q1", + "question": "이 과제의 계정 식별자로 적절한 것은?", + "options": [ + "신뢰한 issuer와 subject의 조합", + "표시 이름만", + "이메일만으로 공급자 구분 없이 병합", + "access token 문자열의 앞 5글자" + ], + "correctIndex": 0, + "explanation": "Me.me가 반환하는 Identity의 두 필드는 issuer와 subject입니다. OIDC의 사용자 식별은 이 조합을 기준으로 해야 공급자와 그 안의 사용자를 함께 구분할 수 있습니다. 같은 이메일의 subject-A·subject-B를 반환하는 테스트가 이메일 자동 병합을 하지 않는 이유를 보여 줍니다." + }, + { + "id": "oidc-identity-q2", + "question": "oidcLogin()으로 작성한 이 테스트가 직접 검증하는 범위는?", + "options": [ + "외부 IdP의 authorization code 교환 전체", + "실제 JWKS 키 교체", + "인증된 주체를 받은 애플리케이션의 신원 처리", + "브라우저 redirect와 state·nonce 전체" + ], + "correctIndex": 2, + "explanation": "oidcLogin()은 요청에 OidcUser를 넣어 인증이 끝난 뒤 상황을 만듭니다. 따라서 이 테스트로 알 수 있는 것은 Me.me가 발급자·subject를 어떻게 처리하는지입니다. 브라우저 이동, 코드 교환, state·nonce, PKCE, 실제 서명·키 교체 검증은 별도입니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "같은 이메일 문자열을 가진 외부 사용자 두 명이 들어왔습니다. 이메일만으로 계정을 합치면 다른 사용자의 주문까지 연결될 수 있으므로, 반환할 식별값부터 발급자와 subject로 구분합니다.", + "tasks": [ + "GET /me가 미인증 요청에 401을 주는 설정과, 인증 주체의 발급자를 보고 403을 주는 Me.me의 조건문을 구분하세요.", + "허용 발급자 https://training-issuer.invalid의 subject-A와 subject-B가 같은 이메일이어도 서로 다른 Identity를 만드는 이유를 설명하세요.", + "principal.getIssuer()가 허용 값이 아닌 경우 subject가 그럴듯해도 거절해야 하는 이유를 적으세요.", + ".invalid 주소와 training-only 값은 외부 호출용 자격증명이 아니라 테스트 설정입니다. 실제 계정이나 client secret을 넣지 않습니다.", + "이번 테스트가 인증 주체를 주입한다는 점과 실제 IdP 로그인 전체에서 따로 확인할 redirect·state·nonce·PKCE·코드 교환·서명을 구분해 기록하세요." + ], + "takeaway": "받은 사용자 정보의 신뢰 범위부터 확인하고 issuer와 subject를 함께 다룹니다. 주체 처리 테스트 통과를 외부 로그인 전체 검증으로 확대하지 않습니다." + }, + "sources": [ + { + "title": "Spring Security OIDC testing", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/test/mockmvc/oauth2.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "OpenID Connect Core", + "url": "https://openid.net/specs/openid-connect-core-1_0.html", + "edition": "OpenID Connect 1.0", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "OAuth2", + "OIDC", + "Spring Security" + ], + "exerciseId": "oidc-identity-project", + "professionalExercise": { + "id": "oidc-identity-project", + "profileId": "professional-oidc-fixture", + "mode": "project", + "instructions": [ + "GET /me는 인증된 OIDC 주체의 issuer와 subject를 반환합니다.", + "허용 issuer는 https://training-issuer.invalid 하나이며 다른 issuer는 403입니다.", + "같은 이메일이어도 다른 subject를 같은 계정으로 합치지 않습니다.", + ".invalid 공급자 URL과 training-only 값은 호출하지 않는 테스트 설정입니다. 실제 외부 계정이나 client secret을 입력하지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/IdentityLab.java" + ], + "template": "package edu.lab;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.annotation.AuthenticationPrincipal;\nimport org.springframework.security.oauth2.client.registration.*;\nimport org.springframework.security.oauth2.core.AuthorizationGrantType;\nimport org.springframework.security.oauth2.core.oidc.user.OidcUser;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.HttpStatusEntryPoint;\nimport org.springframework.web.bind.annotation.*;\nimport org.springframework.web.server.ResponseStatusException;\n@SpringBootApplication\npublic class IdentityLab {\n public static final String TRUSTED_ISSUER = \"https://training-issuer.invalid\";\n public record Identity(String issuer, String subject) {}\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults())\n .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));\n return http.build();\n }\n @Bean ClientRegistrationRepository clients() {\n ClientRegistration fixture = ClientRegistration.withRegistrationId(\"training\")\n .clientId(\"training-client\").clientSecret(\"training-only-not-real\")\n .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)\n .redirectUri(\"{baseUrl}/login/oauth2/code/{registrationId}\").scope(\"openid\")\n .authorizationUri(TRUSTED_ISSUER + \"/authorize\").tokenUri(TRUSTED_ISSUER + \"/token\")\n .jwkSetUri(TRUSTED_ISSUER + \"/jwks\").userInfoUri(TRUSTED_ISSUER + \"/userinfo\")\n .userNameAttributeName(\"sub\").issuerUri(TRUSTED_ISSUER).clientName(\"local fixture\").build();\n return new InMemoryClientRegistrationRepository(fixture);\n }\n @RestController public static class Me {\n @GetMapping(\"/me\") public Identity me(@AuthenticationPrincipal OidcUser principal) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/IdentityLab.java", + "templatePath": "lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java", + "referencePath": "lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/oidc-identity/starter/pom.xml", + "referencePath": "lessons/oidc-identity/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/oidc-identity/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "미인증 401", + "신뢰한 issuer·subject 전달", + "비신뢰 issuer 403", + "이메일로 신원 병합하지 않음" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "security-csrf" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "efb7ff61b59c5dc1f7d5aa5c28fb1d966c164b72dc6c9af2dda2b586d2335cd0", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/oidc-identity/reference/pom.xml b/curriculum/professional/lessons/oidc-identity/reference/pom.xml new file mode 100644 index 0000000..56f89e4 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/reference/pom.xml @@ -0,0 +1,6 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloidc-identityorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-oauth2-client +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest diff --git a/curriculum/professional/lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java b/curriculum/professional/lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java new file mode 100644 index 0000000..3eb4892 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/reference/src/main/java/edu/lab/IdentityLab.java @@ -0,0 +1,45 @@ +package edu.lab; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.client.registration.*; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +@SpringBootApplication +public class IdentityLab { + public static final String TRUSTED_ISSUER = "https://training-issuer.invalid"; + public record Identity(String issuer, String subject) {} + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults()) + .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); + return http.build(); + } + @Bean ClientRegistrationRepository clients() { + ClientRegistration fixture = ClientRegistration.withRegistrationId("training") + .clientId("training-client").clientSecret("training-only-not-real") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}").scope("openid") + .authorizationUri(TRUSTED_ISSUER + "/authorize").tokenUri(TRUSTED_ISSUER + "/token") + .jwkSetUri(TRUSTED_ISSUER + "/jwks").userInfoUri(TRUSTED_ISSUER + "/userinfo") + .userNameAttributeName("sub").issuerUri(TRUSTED_ISSUER).clientName("local fixture").build(); + return new InMemoryClientRegistrationRepository(fixture); + } + @RestController public static class Me { + @GetMapping("/me") public Identity me(@AuthenticationPrincipal OidcUser principal) { + // BEGIN EXERCISE + if (principal.getIssuer() == null || !TRUSTED_ISSUER.equals(principal.getIssuer().toString())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "issuer not trusted"); + String subject = principal.getSubject(); + if (subject == null || subject.isBlank()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "subject missing"); + return new Identity(TRUSTED_ISSUER, subject); + // END EXERCISE + } + } +} diff --git a/curriculum/professional/lessons/oidc-identity/starter/pom.xml b/curriculum/professional/lessons/oidc-identity/starter/pom.xml new file mode 100644 index 0000000..56f89e4 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/starter/pom.xml @@ -0,0 +1,6 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloidc-identityorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-oauth2-client +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest diff --git a/curriculum/professional/lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java b/curriculum/professional/lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java new file mode 100644 index 0000000..6624870 --- /dev/null +++ b/curriculum/professional/lessons/oidc-identity/starter/src/main/java/edu/lab/IdentityLab.java @@ -0,0 +1,39 @@ +package edu.lab; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.client.registration.*; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +@SpringBootApplication +public class IdentityLab { + public static final String TRUSTED_ISSUER = "https://training-issuer.invalid"; + public record Identity(String issuer, String subject) {} + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a.anyRequest().authenticated()).oauth2Login(Customizer.withDefaults()) + .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); + return http.build(); + } + @Bean ClientRegistrationRepository clients() { + ClientRegistration fixture = ClientRegistration.withRegistrationId("training") + .clientId("training-client").clientSecret("training-only-not-real") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}").scope("openid") + .authorizationUri(TRUSTED_ISSUER + "/authorize").tokenUri(TRUSTED_ISSUER + "/token") + .jwkSetUri(TRUSTED_ISSUER + "/jwks").userInfoUri(TRUSTED_ISSUER + "/userinfo") + .userNameAttributeName("sub").issuerUri(TRUSTED_ISSUER).clientName("local fixture").build(); + return new InMemoryClientRegistrationRepository(fixture); + } + @RestController public static class Me { + @GetMapping("/me") public Identity me(@AuthenticationPrincipal OidcUser principal) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..b49380f --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,31 @@ +package edu.lab; +import jakarta.persistence.*; +import org.junit.jupiter.api.*; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + EntityManagerFactory factory; + @BeforeEach void setup() { factory = CancellationLab.open(); var em = factory.createEntityManager(); em.getTransaction().begin(); em.persist(new CancellableOrder("A",2)); em.getTransaction().commit(); em.close(); } + @AfterEach void close() { factory.close(); } + @Test void cancellationRestoresOnlyOnceInOneObject() { + CancellableOrder order = new CancellableOrder("B",3); + assertThat(order.cancelAndReturnQuantity()).isEqualTo(3); assertThat(order.cancelled()).isTrue(); + assertThat(order.cancelAndReturnQuantity()).isZero(); + } + @Test void staleContextCannotOverwrite() { + var first = factory.createEntityManager(); var stale = factory.createEntityManager(); + try { + first.getTransaction().begin(); stale.getTransaction().begin(); + var one = first.find(CancellableOrder.class,"A"); var two = stale.find(CancellableOrder.class,"A"); + one.cancelAndReturnQuantity(); two.cancelAndReturnQuantity(); + first.getTransaction().commit(); + assertThatThrownBy(() -> stale.getTransaction().commit()).isInstanceOf(RollbackException.class); + var verify = factory.createEntityManager(); + try { assertThat(verify.find(CancellableOrder.class,"A").cancelled()).isTrue(); } + finally { verify.close(); } + } finally { + if (first.getTransaction().isActive()) first.getTransaction().rollback(); + if (stale.getTransaction().isActive()) stale.getTransaction().rollback(); + first.close(); stale.close(); + } + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/lesson.json b/curriculum/professional/lessons/optimistic-cancel/lesson.json new file mode 100644 index 0000000..fd17173 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/lesson.json @@ -0,0 +1,229 @@ +{ + "id": "optimistic-cancel", + "title": "낙관적 잠금과 중복 취소의 의미", + "description": "취소 버튼을 두 번 누르거나 두 작업이 같은 주문을 읽으면 재고를 두 번 돌려줄 수 있습니다. 객체의 취소 표시와 DB의 버전 검사가 각각 막는 문제를 나누어 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "cancelAndReturnQuantity의 첫 호출과 두 번째 호출이 다른 수량을 반환하는 이유를 설명한다.", + "cancelled가 false에서 true로 바뀌는 지점을 찾는다.", + "같은 객체의 중복 취소를 막는 if 문과 DB 저장 충돌을 잡는 @Version의 역할을 구분한다.", + "반환 수량만으로 외부 재고 복원이 완료됐다고 판단할 수 없는 이유를 설명한다." + ], + "learningGuide": { + "scenario": "수량 3인 주문을 취소한 뒤 같은 객체에 취소를 다시 요청합니다. 매번 3을 돌려주면 이를 믿는 호출자가 재고를 두 번 늘릴 수 있습니다.", + "question": "이미 처리한 취소를 메서드가 어떻게 기억하고 두 번째 반환값을 바꿀까요?", + "terms": [ + { + "term": "상태 전이", + "meaning": "객체가 한 상태에서 다음 상태로 바뀌는 것입니다. 여기서는 cancelled=false가 true로 바뀝니다." + }, + { + "term": "멱등성", + "meaning": "같은 요청을 반복해도 업무 효과가 추가로 생기지 않는 성질입니다. 이미 취소된 객체는 복원 후보 수량을 0으로 돌려줍니다." + }, + { + "term": "낙관적 잠금", + "meaning": "DB에 저장할 때 version을 비교해, 읽은 뒤 다른 작업이 먼저 바꾼 상태를 감지하는 방법입니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/CancellableOrder.java", + "source": " @Version private long version;\n private int quantity;\n private boolean cancelled;\n protected CancellableOrder() {}\n public CancellableOrder(String id, int quantity) {\n if (id == null || id.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid order\");\n this.id = id; this.quantity = quantity;\n }\n public long version() { return version; }\n public boolean cancelled() { return cancelled; }\n public int cancelAndReturnQuantity() {\n // BEGIN EXERCISE\n if (cancelled) return 0;\n cancelled = true;\n return quantity;\n // END EXERCISE" + }, + "walkthrough": [ + "@Version private long version은 JPA가 DB 저장 충돌을 확인할 때 쓰는 필드입니다. quantity와 cancelled는 주문 수량과 취소 여부를 기억합니다.", + "생성자의 if는 비어 있는 id와 0 이하 수량을 거절합니다. this.quantity = quantity로 받은 수량을 객체에 저장합니다.", + "cancelAndReturnQuantity의 if (cancelled) return 0은 이미 취소한 객체의 실행을 여기서 끝냅니다.", + "처음에는 if를 지나 cancelled=true로 바꾸고 quantity를 반환합니다. 이 순서 때문에 같은 객체의 다음 호출은 0을 반환합니다.", + "이 메서드에는 재고를 수정하는 SQL이나 API 호출이 없습니다. 별도 객체끼리의 DB 저장 충돌은 위의 @Version이 담당하고, 실제 재고 반영은 이 발췌 밖의 설계 과제입니다." + ], + "prediction": { + "question": "수량 3인 같은 객체에 cancelAndReturnQuantity를 연속 두 번 호출하면 반환값과 마지막 cancelled는 무엇일까요?", + "answer": "반환값은 3, 0이며 마지막 cancelled는 true입니다.", + "explanation": "첫 호출이 true를 저장한 뒤 3을 반환합니다. 두 번째 호출은 그 true를 읽고 즉시 0을 반환합니다. 이는 같은 객체의 결과이며 외부 재고가 실제로 3개 복원됐다는 뜻은 아닙니다." + }, + "takeaway": "객체에서 취소를 결정한 순간과 DB·외부 시스템에서 그 취소를 확정한 순간을 구분합니다." + }, + "theory": [ + { + "heading": "중복 호출의 업무 의미", + "text": "수량 3인 주문에서 cancelAndReturnQuantity를 처음 호출하면 cancelled는 false입니다. if를 지나 true로 바꾸고 3을 반환합니다. 같은 객체를 다시 호출하면 if (cancelled)에서 0을 반환해 복원 수량을 중복 계산하지 않습니다. 같은 요청을 반복해도 업무 효과를 추가로 만들지 않는 성질을 멱등성이라고 합니다. 다만 두 작업이 각각 읽은 별도 객체에는 cancelled=false가 남아 있을 수 있으므로 이 if만으로 DB의 동시 변경까지 해결하지는 못합니다." + }, + { + "heading": "버전 비교로 충돌 발견", + "text": "낙관적 잠금은 저장할 때 읽었던 버전과 DB의 현재 버전을 비교해 다른 작업의 선행 변경을 발견하는 방식입니다. 이 엔티티의 @Version long version이 그 비교에 쓰입니다. 영속성 컨텍스트는 JPA가 읽어 온 객체와 변경을 관리하는 작업 공간입니다. 테스트의 first와 stale이 같은 주문을 각각 읽고 취소한 뒤 first가 먼저 커밋하면, 오래된 버전을 가진 stale의 커밋은 실패합니다. 커밋은 변경을 확정하는 단계입니다. 충돌을 잡은 호출자는 최신 상태를 다시 읽고 취소가 여전히 필요한지 판단해야 합니다." + }, + { + "heading": "외부 효과와 트랜잭션", + "text": "cancelAndReturnQuantity가 2를 반환한 순간에는 객체의 상태와 복원할 후보 수량만 정해졌습니다. 그 뒤 DB 커밋이 실패할 수도 있습니다. 반환 직후 외부 재고 API에 2개를 더했다면 주문은 취소되지 않았는데 재고만 늘어날 수 있습니다. 이번 검사는 객체 동작과 H2의 버전 충돌까지이며 외부 재고·결제는 호출하지 않습니다. 실제 복원은 같은 DB 트랜잭션에 묶거나, 보낼 이벤트를 DB에 함께 기록하는 outbox 등의 방법을 따로 설계합니다." + } + ], + "example": { + "title": "CancellationLab 기준 구현", + "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class CancellationLab {\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"cancellations\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 취소 상태 전이 · 반복 취소 복원량 0 · 오래된 버전 커밋 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "CancellableOrder의 cancelled 필드는 이미 취소했는지 기억하고, cancelAndReturnQuantity는 처음에만 quantity를 반환합니다. @Version은 이 if와 별도로 DB에 저장할 때 오래된 객체의 덮어쓰기를 막습니다. 테스트 하나는 수량 3인 객체를 두 번 호출해 3, 0을 확인하고, 다른 하나는 두 EntityManager에서 같은 주문을 읽어 두 번째 커밋이 실패하는지 확인합니다. 반환값을 실제 재고 DB에 더하는 코드는 없습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/CancellationLab.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class CancellationLab {\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"cancellations\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloptimistic-cancelorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.hibernate.ormhibernate-core\njakarta.persistencejakarta.persistence-api\ncom.h2databaseh2\n" + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "language": "xml", + "code": "\n\n \n org.hibernate.jpa.HibernatePersistenceProvideredu.lab.CancellableOrder\n true\n \n \n \n \n \n \n \n \n\n" + }, + { + "path": "src/main/java/edu/lab/CancellableOrder.java", + "language": "java", + "code": "package edu.lab;\nimport jakarta.persistence.*;\n@Entity\n@Table(name=\"cancellable_orders\")\npublic class CancellableOrder {\n @Id private String id;\n @Version private long version;\n private int quantity;\n private boolean cancelled;\n protected CancellableOrder() {}\n public CancellableOrder(String id, int quantity) {\n if (id == null || id.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid order\");\n this.id = id; this.quantity = quantity;\n }\n public long version() { return version; }\n public boolean cancelled() { return cancelled; }\n public int cancelAndReturnQuantity() {\n // BEGIN EXERCISE\n if (cancelled) return 0;\n cancelled = true;\n return quantity;\n // END EXERCISE\n }\n}\n" + } + ] + }, + "quiz": [ + { + "id": "optimistic-cancel-q1", + "question": "@Version 충돌이 발생한 뒤 올바른 설명은?", + "options": [ + "항상 무제한으로 재시도한다.", + "충돌한 객체의 값을 강제로 저장하면 안전하다.", + "충돌을 호출자가 처리하고 업무 의미에 맞는 재조회·재시도를 검토한다.", + "버전 필드를 직접 0으로 바꾸면 해결된다." + ], + "correctIndex": 2, + "explanation": "stale이 들고 있던 주문은 first가 저장한 최신 상태를 반영하지 않습니다. 충돌한 객체를 그대로 다시 저장하거나 version을 바꾸면 문제를 숨깁니다. 새 작업에서 주문을 다시 읽고 이미 취소되었는지 확인한 뒤 제한된 재시도나 거절을 결정해야 합니다." + }, + { + "id": "optimistic-cancel-q2", + "question": "cancelAndReturnQuantity가 2를 반환했다는 사실만으로 보장되지 않는 것은?", + "options": [ + "해당 객체가 취소 상태로 바뀜", + "같은 객체의 다음 취소가 0을 반환함", + "외부 재고 시스템에 2개가 원자적으로 복원됨", + "해당 메서드가 계산한 복원 후보 수량" + ], + "correctIndex": 2, + "explanation": "메서드 본문에는 cancelled=true와 return quantity만 있습니다. 외부 재고 API나 재고 UPDATE가 없고 DB 커밋 전일 수도 있습니다. 따라서 2는 복원할 후보 수량이며 외부 재고 2개 복원이 완료되었다는 증거가 아닙니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "취소 요청의 응답이 늦어 사용자가 다시 누릅니다. 한 객체에서의 반복 호출과 서로 다른 요청이 각자 읽은 주문의 경쟁을 나누어, 재고 복원이 중복될 위치를 찾습니다.", + "tasks": [ + "수량 3인 CancellableOrder에서 첫 호출의 cancelled 변화와 반환값, 두 번째 호출이 멈추는 if를 설명하세요.", + "first와 stale이 취소 전 주문 A를 각각 읽은 경우, 양쪽 메서드가 수량을 반환할 수 있어도 두 커밋이 모두 성공하지 않는 이유를 @Version과 연결하세요.", + "두 번째 커밋 충돌 뒤에는 최신 주문을 다시 읽어 이미 취소됐는지 판단해야 하는 이유를 적으세요.", + "반환 수량을 바로 외부 재고 API에 적용했을 때 DB 커밋 실패가 만드는 불일치를 설명하세요. 이 예제는 재고 DB나 결제 API를 실제로 호출하지 않습니다.", + "객체의 반복 호출 검사와 두 영속성 컨텍스트의 버전 충돌 검사에서 각각 확인한 상태를 구분하세요." + ], + "takeaway": "if (cancelled)는 같은 객체의 반복을 막고 @Version은 오래된 DB 상태의 덮어쓰기를 잡습니다. 실제 재고 복원은 별도의 확정 단계까지 설계해야 합니다." + }, + "sources": [ + { + "title": "Hibernate optimistic locking", + "url": "https://docs.hibernate.org/orm/6.6/userguide/html_single/#locking-optimistic", + "edition": "Hibernate ORM 6.6", + "checkedAt": "2026-10-12" + }, + { + "title": "Jakarta Persistence", + "url": "https://jakarta.ee/specifications/persistence/3.1/", + "edition": "Jakarta Persistence 3.1", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "JPA", + "Transaction", + "Concurrency" + ], + "exerciseId": "optimistic-cancel-project", + "professionalExercise": { + "id": "optimistic-cancel-project", + "profileId": "professional-jpa", + "mode": "project", + "instructions": [ + "첫 cancelAndReturnQuantity 호출은 주문 수량을 반환하고 cancelled=true로 바꿉니다.", + "이미 취소된 객체에서는 0을 반환합니다.", + "@Version으로 stale 영속성 컨텍스트의 두 번째 커밋이 실패해야 합니다.", + "이 예제의 반환 수량은 재고 DB나 결제 API에 실제로 적용되지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/CancellableOrder.java" + ], + "template": "package edu.lab;\nimport jakarta.persistence.*;\nimport java.util.Map;\nimport java.util.UUID;\npublic final class CancellationLab {\n public static EntityManagerFactory open() {\n return Persistence.createEntityManagerFactory(\"cancellations\", Map.of(\n \"jakarta.persistence.jdbc.url\", \"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"));\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/CancellationLab.java", + "templatePath": "lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java", + "referencePath": "lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java", + "editable": false + }, + { + "path": "pom.xml", + "templatePath": "lessons/optimistic-cancel/starter/pom.xml", + "referencePath": "lessons/optimistic-cancel/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/META-INF/persistence.xml", + "templatePath": "lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml", + "referencePath": "lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml", + "editable": false + }, + { + "path": "src/main/java/edu/lab/CancellableOrder.java", + "templatePath": "lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java", + "referencePath": "lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java", + "editable": true + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/optimistic-cancel/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "취소 상태 전이", + "반복 취소 복원량 0", + "오래된 버전 커밋 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "jpa-state", + "tx-proxy" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "e5e66d061683ca073cec3b47255908664a8be1efbe2f2223808aa499c2b23ec0", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/reference/pom.xml b/curriculum/professional/lessons/optimistic-cancel/reference/pom.xml new file mode 100644 index 0000000..92432b8 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloptimistic-cancelorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.hibernate.ormhibernate-core +jakarta.persistencejakarta.persistence-api +com.h2databaseh2 diff --git a/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java new file mode 100644 index 0000000..b1b18f3 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellableOrder.java @@ -0,0 +1,24 @@ +package edu.lab; +import jakarta.persistence.*; +@Entity +@Table(name="cancellable_orders") +public class CancellableOrder { + @Id private String id; + @Version private long version; + private int quantity; + private boolean cancelled; + protected CancellableOrder() {} + public CancellableOrder(String id, int quantity) { + if (id == null || id.isBlank() || quantity <= 0) throw new IllegalArgumentException("invalid order"); + this.id = id; this.quantity = quantity; + } + public long version() { return version; } + public boolean cancelled() { return cancelled; } + public int cancelAndReturnQuantity() { + // BEGIN EXERCISE + if (cancelled) return 0; + cancelled = true; + return quantity; + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java new file mode 100644 index 0000000..bf08aa9 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/java/edu/lab/CancellationLab.java @@ -0,0 +1,10 @@ +package edu.lab; +import jakarta.persistence.*; +import java.util.Map; +import java.util.UUID; +public final class CancellationLab { + public static EntityManagerFactory open() { + return Persistence.createEntityManagerFactory("cancellations", Map.of( + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1")); + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..f01e792 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/reference/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,14 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvideredu.lab.CancellableOrder + true + + + + + + + + + diff --git a/curriculum/professional/lessons/optimistic-cancel/starter/pom.xml b/curriculum/professional/lessons/optimistic-cancel/starter/pom.xml new file mode 100644 index 0000000..92432b8 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmloptimistic-cancelorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.hibernate.ormhibernate-core +jakarta.persistencejakarta.persistence-api +com.h2databaseh2 diff --git a/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java new file mode 100644 index 0000000..b632c84 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellableOrder.java @@ -0,0 +1,20 @@ +package edu.lab; +import jakarta.persistence.*; +@Entity +@Table(name="cancellable_orders") +public class CancellableOrder { + @Id private String id; + @Version private long version; + private int quantity; + private boolean cancelled; + protected CancellableOrder() {} + public CancellableOrder(String id, int quantity) { + if (id == null || id.isBlank() || quantity <= 0) throw new IllegalArgumentException("invalid order"); + this.id = id; this.quantity = quantity; + } + public long version() { return version; } + public boolean cancelled() { return cancelled; } + public int cancelAndReturnQuantity() { + throw new UnsupportedOperationException("한 번만 전이하도록 구현하세요"); + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java new file mode 100644 index 0000000..bf08aa9 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/java/edu/lab/CancellationLab.java @@ -0,0 +1,10 @@ +package edu.lab; +import jakarta.persistence.*; +import java.util.Map; +import java.util.UUID; +public final class CancellationLab { + public static EntityManagerFactory open() { + return Persistence.createEntityManagerFactory("cancellations", Map.of( + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1")); + } +} diff --git a/curriculum/professional/lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml new file mode 100644 index 0000000..f01e792 --- /dev/null +++ b/curriculum/professional/lessons/optimistic-cancel/starter/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,14 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvideredu.lab.CancellableOrder + true + + + + + + + + + diff --git a/curriculum/professional/lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..d9b11fa --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,66 @@ +package edu.lab; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.assertj.core.api.Assertions.*; +@SpringBootTest(classes=CapstoneLab.class) +@AutoConfigureMockMvc +class ContractTest { + @Autowired MockMvc mvc; @Autowired JdbcTemplate jdbc; + @BeforeEach void reset() { jdbc.update("delete from orders"); jdbc.update("update inventory set stock=5 where sku='BOOK'"); } + int stock() { return jdbc.queryForObject("select stock from inventory where sku='BOOK'",Integer.class); } + ResultActions reserve(String buyer,String id,int quantity) throws Exception { + return mvc.perform(post("/orders").with(httpBasic(buyer,"training")).with(csrf()).contentType("application/json") + .content("{\"requestId\":\""+id+"\",\"sku\":\"BOOK\",\"quantity\":"+quantity+"}")); + } + ResultActions cancel(String buyer,String id) throws Exception { + return mvc.perform(post("/orders/"+id+"/cancel").with(httpBasic(buyer,"training")).with(csrf())); + } + @Test void sameRequestDoesNotConsumeTwice() throws Exception { + reserve("alice","A",2).andExpect(status().isOk()).andExpect(jsonPath("$.status").value("RESERVED")); + reserve("alice","A",2).andExpect(status().isOk()); assertThat(stock()).isEqualTo(3); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isEqualTo(1); + reserve("alice","A",3).andExpect(status().isConflict()); assertThat(stock()).isEqualTo(3); + } + @Test void insufficientStockLeavesNoOrder() throws Exception { + reserve("alice","A",6).andExpect(status().isConflict()); assertThat(stock()).isEqualTo(5); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isZero(); + } + @Test void ownershipAndRepeatedCancellation() throws Exception { + reserve("alice","A",2).andExpect(status().isOk()); + cancel("bob","A").andExpect(status().isForbidden()); assertThat(stock()).isEqualTo(3); + cancel("alice","A").andExpect(status().isOk()).andExpect(jsonPath("$.status").value("CANCELLED")); + cancel("alice","A").andExpect(status().isOk()); assertThat(stock()).isEqualTo(5); + reserve("alice","A",2).andExpect(jsonPath("$.status").value("CANCELLED")); assertThat(stock()).isEqualTo(5); + } + @Test void rejectsRoleAndCsrfWithoutMutation() throws Exception { + String body="{\"requestId\":\"guarded\",\"sku\":\"BOOK\",\"quantity\":1}"; + mvc.perform(post("/orders").with(user("viewer").roles("VIEWER")).with(csrf()).contentType("application/json").content(body)) + .andExpect(status().isForbidden()); + assertThat(stock()).isEqualTo(5); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isZero(); + mvc.perform(post("/orders").with(httpBasic("alice","training")).contentType("application/json").content(body)) + .andExpect(status().isForbidden()); + assertThat(stock()).isEqualTo(5); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isZero(); + } + @Test void requestIdIsASafeSinglePathSegment() throws Exception { + for (String id : new String[]{"A/B","A?B","A%2FB","한글"}) reserve("alice",id,1).andExpect(status().isBadRequest()); + assertThat(stock()).isEqualTo(5); + assertThat(jdbc.queryForObject("select count(*) from orders",Integer.class)).isZero(); + reserve("alice","A_1-2",1).andExpect(status().isOk()); + cancel("alice","A_1-2").andExpect(status().isOk()); assertThat(stock()).isEqualTo(5); + } + @Test void inputAuthenticationAndAbsence() throws Exception { + reserve("alice","A",0).andExpect(status().isBadRequest()); assertThat(stock()).isEqualTo(5); + mvc.perform(post("/orders").with(csrf()).contentType("application/json").content("{\"requestId\":\"A\",\"sku\":\"BOOK\",\"quantity\":1}")) + .andExpect(status().isUnauthorized()); + cancel("alice","missing").andExpect(status().isNotFound()); + } +} diff --git a/curriculum/professional/lessons/orders-capstone/lesson.json b/curriculum/professional/lessons/orders-capstone/lesson.json new file mode 100644 index 0000000..5050ef7 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/lesson.json @@ -0,0 +1,260 @@ +{ + "id": "orders-capstone", + "title": "주문·예약·취소 통합 계약", + "description": "응답이 늦어 주문이나 취소를 다시 보내도 재고가 두 번 바뀌면 안 됩니다. 입력 검사부터 요청 ID 비교, 주문 소유자 확인, 재고 변경까지 한 흐름으로 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "주문 생성·재시도·취소를 이어서 실행해도 재고가 맞아야 한다는 조건을 설명한다.", + "잘못된 입력과 미인증 요청이 DB를 바꾸기 전에 거절되는 경로를 찾는다.", + "같은 의미의 요청을 반복해도 효과를 늘리지 않는 멱등 처리를 requestId와 연결한다.", + "같은 ID의 다른 요청 내용과 재고 부족을 거절하고 실패한 DB 변경을 되돌리는 롤백을 설명한다.", + "구매자 역할이 있어도 다른 사람 주문을 취소할 수 없는 이유를 찾는다.", + "취소 상태를 실제로 바꾼 요청만 재고를 한 번 복원하는 조건을 읽는다." + ], + "learningGuide": { + "scenario": "초기 재고 5에서 alice가 BOOK 2개를 예약해 현재 재고는 3입니다. alice가 같은 주문의 취소를 두 번 보내도 재고가 7이 되면 안 됩니다.", + "question": "두 번째 취소에서 재고를 더하지 않게 만드는 판단은 어디에 있을까요?", + "terms": [ + { + "term": "인가 / 소유권 검사", + "meaning": "그 사용자가 이 행동을 해도 되는지 확인하는 일입니다. own은 인증된 사용자와 주문 buyer가 같은지 확인합니다." + }, + { + "term": "조건부 UPDATE", + "meaning": "WHERE 조건에 맞는 행만 바꾸는 SQL입니다. 여기서는 RESERVED 주문만 CANCELLED로 바꿉니다." + }, + { + "term": "영향받은 행 수", + "meaning": "UPDATE가 실제 바꾼 행의 개수입니다. changed==1일 때만 이번 요청이 취소 상태를 바꾼 것입니다." + }, + { + "term": "트랜잭션", + "meaning": "DB 변경을 함께 확정하거나 실패 시 되돌리는 단위입니다. cancel의 상태 변경과 재고 복원을 함께 묶습니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/CapstoneLab.java", + "source": " @Transactional public OrderView cancel(String buyer,String id) {\n // BEGIN EXERCISE\n Stored order=require(id); own(order,buyer);\n int changed=jdbc.update(\"update orders set status='CANCELLED' where id=? and status='RESERVED'\",id);\n if(changed==1) jdbc.update(\"update inventory set stock=stock+? where sku=?\",order.quantity(),order.sku());\n return require(id).view();" + }, + "walkthrough": [ + "@Transactional cancel은 Controller가 Spring 관리 Orders를 호출하는 경로에서 DB 트랜잭션을 적용받습니다. 첫 require(id)는 주문이 없으면 404를 던집니다.", + "own(order,buyer)는 주문에 저장된 buyer와 호출자를 비교합니다. 다른 사람이라면 403을 던져 아래 변경 SQL까지 가지 못하게 합니다.", + "첫 jdbc.update는 id가 같고 status가 RESERVED일 때만 CANCELLED로 바꿉니다. 바꾼 행의 수를 changed에 받습니다.", + "if(changed==1)일 때만 주문 수량만큼 재고를 더합니다. 두 번째 취소에서는 이미 CANCELLED여서 changed가 0이고 이 UPDATE를 건너뜁니다.", + "return require(id).view()는 변경 뒤 주문 상태를 다시 읽어 반환합니다. 메서드의 DB 변경들은 같은 트랜잭션으로 확정되며 외부 결제 취소는 이 코드에 없습니다." + ], + "prediction": { + "question": "현재 재고 3, alice 소유의 수량 2인 RESERVED 주문에 alice가 취소를 순서대로 두 번 요청하면 각 요청 뒤 재고는 얼마일까요?", + "answer": "첫 취소 뒤 5, 두 번째 취소 뒤에도 5입니다. 주문 상태는 CANCELLED입니다.", + "explanation": "첫 요청만 RESERVED 조건에 맞아 changed가 1이므로 2개를 복원합니다. 두 번째는 changed가 0이어서 복원하지 않습니다. 같은 사용자·정상 DB의 순차 호출을 읽은 결과이며 동시 부하 검증을 대신하지 않습니다." + }, + "takeaway": "조회 당시의 상태만 믿지 않고 실제 상태 변경에 성공한 요청만 후속 재고 변경을 하도록 연결합니다." + }, + "theory": [ + { + "heading": "계약을 먼저 연결한다", + "text": "재고 5개인 BOOK을 alice가 2개 주문하면 재고는 3개가 되고 주문은 RESERVED가 되어야 합니다. Controller.create는 @Valid로 입력을 검사하고 인증된 사용자 이름을 Orders.reserve에 넘깁니다. 인증은 누구인지 확인하는 일이며, 인가는 그 사람이 해당 행동을 해도 되는지 판단하는 일입니다. 보안 설정은 BUYER 역할을, own은 특정 주문의 소유자를 검사합니다. 서비스는 재고 차감과 주문 저장을 같은 DB 트랜잭션, 즉 함께 확정하거나 취소할 작업으로 묶습니다. DB의 수량·상태 제약과 주문 ID 기본키도 잘못된 저장을 막습니다." + }, + { + "heading": "반복 요청과 경쟁", + "text": "멱등성은 같은 의미의 요청을 반복해도 업무 효과를 더 만들지 않는 성질입니다. reserve는 requestId로 주문을 먼저 찾습니다. 이미 있으면 소유자와 sku·quantity, 즉 요청 내용(payload)이 같은지 확인해 기존 주문을 반환하므로 재고를 또 빼지 않습니다. 같은 사용자가 같은 ID로 수량을 바꾸면 409입니다. 새 요청은 stock>=quantity인 경우에만 차감하고 주문을 저장합니다. 동시에 같은 ID로 신규 저장이 경쟁하면 유일키 충돌이 생길 수 있어 트랜잭션을 롤백하고 409 뒤 같은 요청으로 재시도하게 합니다. 롤백은 확정 전 DB 변경을 되돌리는 일입니다. 제공 테스트는 순차 재시도와 실패 상태를 검사하며 모든 동시 실행·고부하를 검증한 것은 아닙니다." + }, + { + "heading": "취소와 경계의 증거", + "text": "cancel은 주문을 읽은 뒤 own으로 소유자를 확인합니다. 이어 status가 RESERVED일 때만 CANCELLED로 바꾸는 UPDATE를 실행합니다. changed==1인 요청만 재고를 더하고, 반복 취소로 이미 CANCELLED이면 changed==0이라 더하지 않습니다. 이 두 변경은 같은 트랜잭션에 있습니다. 소유권 부족·입력 오류·재고 부족도 실패 뒤 부분 변경이 남지 않아야 합니다. 여기에는 외부 결제 취소·메일·이벤트 발행이 없으며, 추가할 때는 DB 롤백으로 외부 작업도 취소된다고 가정하지 말고 실패 처리를 다시 설계해야 합니다." + }, + { + "heading": "SOLID와 추상화의 비용", + "text": "책임을 나누는 목적은 문제가 생겼을 때 고칠 곳을 찾기 쉽게 하는 데 있습니다. 이 예제에서는 Controller가 HTTP 입력·응답을, Orders가 주문 규칙과 트랜잭션을, SQL이 조건에 맞는 행의 변경을 담당합니다. SOLID는 이런 책임·의존 관계를 검토하는 설계 원칙 묶음이지 클래스마다 인터페이스를 붙이는 규칙이 아닙니다. 외부 결제 구현을 바꾸거나 대역으로 테스트해야 할 때처럼 필요한 경계에 추상화를 둡니다. status 조건과 changed 행 수의 의미를 여러 객체 뒤에 숨기지 않고, 다른 설계도 동일한 주문·재고 결과로 비교합니다." + } + ], + "example": { + "title": "CapstoneLab 기준 구현", + "code": "package edu.lab;\nimport java.util.*;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.*;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.dao.DuplicateKeyException;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.jdbc.core.*;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class CapstoneLab {\n public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); }\n public record Create(@NotBlank @Size(max=60) @Pattern(regexp=\"[A-Za-z0-9_-]{1,60}\") String requestId, @NotBlank @Pattern(regexp=\"BOOK\") String sku, @Min(1) int quantity) {}\n public record OrderView(String id, String sku, int quantity, String status) {}\n private record Stored(String id, String buyer, String sku, int quantity, String status) {\n OrderView view() { return new OrderView(id,sku,quantity,status); }\n }\n public static class Failure extends RuntimeException {\n final int status;\n Failure(int status,String message) { super(message); this.status=status; }\n }\n @Service public static class Orders {\n private final JdbcTemplate jdbc;\n private static final RowMapper ROW = (r,n) -> new Stored(r.getString(\"id\"),r.getString(\"buyer\"),r.getString(\"sku\"),r.getInt(\"quantity\"),r.getString(\"status\"));\n public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; }\n private List find(String id) { return jdbc.query(\"select id,buyer,sku,quantity,status from orders where id=?\",ROW,id); }\n private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,\"order not found\"); return rows.get(0); }\n private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,\"not order owner\"); }\n @Transactional public OrderView reserve(String buyer,Create request) {\n // BEGIN EXERCISE\n if (buyer==null || buyer.isBlank() || request.requestId()==null || !request.requestId().matches(\"[A-Za-z0-9_-]{1,60}\")\n || !\"BOOK\".equals(request.sku()) || request.quantity()<=0) throw new Failure(400,\"invalid order\");\n var existing=find(request.requestId());\n if(!existing.isEmpty()) {\n Stored order=existing.get(0); own(order,buyer);\n if(!order.sku().equals(request.sku()) || order.quantity()!=request.quantity()) throw new Failure(409,\"request payload conflicts\");\n return order.view();\n }\n int changed=jdbc.update(\"update inventory set stock=stock-? where sku=? and stock>=?\",request.quantity(),request.sku(),request.quantity());\n if(changed!=1) throw new Failure(409,\"insufficient stock\");\n jdbc.update(\"insert into orders(id,buyer,sku,quantity,status) values(?,?,?,?,?)\",request.requestId(),buyer,request.sku(),request.quantity(),\"RESERVED\");\n return new OrderView(request.requestId(),request.sku(),request.quantity(),\"RESERVED\");\n // END EXERCISE\n }\n @Transactional public OrderView cancel(String buyer,String id) {\n // BEGIN EXERCISE\n Stored order=require(id); own(order,buyer);\n int changed=jdbc.update(\"update orders set status='CANCELLED' where id=? and status='RESERVED'\",id);\n if(changed==1) jdbc.update(\"update inventory set stock=stock+? where sku=?\",order.quantity(),order.sku());\n return require(id).view();\n // END EXERCISE\n }\n }\n @RestController @RequestMapping(\"/orders\")\n public static class Controller {\n private final Orders orders;\n public Controller(Orders orders) { this.orders=orders; }\n @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); }\n @PostMapping(\"/{id}/cancel\") public OrderView cancel(@PathVariable(\"id\") String id,Authentication auth) { return orders.cancel(auth.getName(),id); }\n @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) {\n return ResponseEntity.status(failure.status).body(Map.of(\"error\",failure.getMessage()));\n }\n @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() {\n return ResponseEntity.status(409).body(Map.of(\"error\",\"concurrent request conflict; retry the same request\"));\n }\n }\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.requestMatchers(\"/orders\",\"/orders/**\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"alice\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"bob\").password(\"{noop}training\").roles(\"BUYER\").build());\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 6개 JUnit 테스트 통과.\n검증 항목: 입력·미인증 거절 · 동일 요청 멱등 처리 · payload 충돌과 재고 부족 롤백 · 다른 사용자 취소 거절 · 반복 취소 재고 한 번 복원.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "reserve는 입력을 확인한 뒤 requestId의 기존 주문을 찾습니다. 같은 사용자·같은 내용이면 바로 반환하고, 새 주문일 때만 조건부 재고 UPDATE와 주문 INSERT를 실행합니다. cancel은 require와 own으로 존재·소유자를 확인하고 RESERVED에서 CANCELLED로 바뀐 행 수가 1일 때만 수량을 복원합니다. 예를 들어 초기 재고 5에서 alice가 2개 주문하면 3, 같은 생성 요청을 반복해도 3, 취소를 두 번 해도 최종 5입니다. 테스트는 이 순차 결과와 입력·인증·역할·CSRF·소유권 실패 뒤 DB 상태를 검사합니다. 동시 부하, 결제, 외부 이벤트까지 실행한 검사는 아닙니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/CapstoneLab.java", + "language": "java", + "code": "package edu.lab;\nimport java.util.*;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.*;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.dao.DuplicateKeyException;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.jdbc.core.*;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class CapstoneLab {\n public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); }\n public record Create(@NotBlank @Size(max=60) @Pattern(regexp=\"[A-Za-z0-9_-]{1,60}\") String requestId, @NotBlank @Pattern(regexp=\"BOOK\") String sku, @Min(1) int quantity) {}\n public record OrderView(String id, String sku, int quantity, String status) {}\n private record Stored(String id, String buyer, String sku, int quantity, String status) {\n OrderView view() { return new OrderView(id,sku,quantity,status); }\n }\n public static class Failure extends RuntimeException {\n final int status;\n Failure(int status,String message) { super(message); this.status=status; }\n }\n @Service public static class Orders {\n private final JdbcTemplate jdbc;\n private static final RowMapper ROW = (r,n) -> new Stored(r.getString(\"id\"),r.getString(\"buyer\"),r.getString(\"sku\"),r.getInt(\"quantity\"),r.getString(\"status\"));\n public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; }\n private List find(String id) { return jdbc.query(\"select id,buyer,sku,quantity,status from orders where id=?\",ROW,id); }\n private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,\"order not found\"); return rows.get(0); }\n private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,\"not order owner\"); }\n @Transactional public OrderView reserve(String buyer,Create request) {\n // BEGIN EXERCISE\n if (buyer==null || buyer.isBlank() || request.requestId()==null || !request.requestId().matches(\"[A-Za-z0-9_-]{1,60}\")\n || !\"BOOK\".equals(request.sku()) || request.quantity()<=0) throw new Failure(400,\"invalid order\");\n var existing=find(request.requestId());\n if(!existing.isEmpty()) {\n Stored order=existing.get(0); own(order,buyer);\n if(!order.sku().equals(request.sku()) || order.quantity()!=request.quantity()) throw new Failure(409,\"request payload conflicts\");\n return order.view();\n }\n int changed=jdbc.update(\"update inventory set stock=stock-? where sku=? and stock>=?\",request.quantity(),request.sku(),request.quantity());\n if(changed!=1) throw new Failure(409,\"insufficient stock\");\n jdbc.update(\"insert into orders(id,buyer,sku,quantity,status) values(?,?,?,?,?)\",request.requestId(),buyer,request.sku(),request.quantity(),\"RESERVED\");\n return new OrderView(request.requestId(),request.sku(),request.quantity(),\"RESERVED\");\n // END EXERCISE\n }\n @Transactional public OrderView cancel(String buyer,String id) {\n // BEGIN EXERCISE\n Stored order=require(id); own(order,buyer);\n int changed=jdbc.update(\"update orders set status='CANCELLED' where id=? and status='RESERVED'\",id);\n if(changed==1) jdbc.update(\"update inventory set stock=stock+? where sku=?\",order.quantity(),order.sku());\n return require(id).view();\n // END EXERCISE\n }\n }\n @RestController @RequestMapping(\"/orders\")\n public static class Controller {\n private final Orders orders;\n public Controller(Orders orders) { this.orders=orders; }\n @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); }\n @PostMapping(\"/{id}/cancel\") public OrderView cancel(@PathVariable(\"id\") String id,Authentication auth) { return orders.cancel(auth.getName(),id); }\n @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) {\n return ResponseEntity.status(failure.status).body(Map.of(\"error\",failure.getMessage()));\n }\n @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() {\n return ResponseEntity.status(409).body(Map.of(\"error\",\"concurrent request conflict; retry the same request\"));\n }\n }\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.requestMatchers(\"/orders\",\"/orders/**\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"alice\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"bob\").password(\"{noop}training\").roles(\"BUYER\").build());\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlorders-capstoneorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-validation\norg.springframework.bootspring-boot-starter-security\norg.springframework.bootspring-boot-starter-jdbc\ncom.h2databaseh2\norg.springframework.bootspring-boot-starter-testtest\norg.springframework.securityspring-security-testtest\norg.flywaydbflyway-core\n" + }, + { + "path": "src/main/resources/application.properties", + "language": "text", + "code": "spring.datasource.url=jdbc:h2:mem:orders-capstone;DB_CLOSE_DELAY=-1\nspring.datasource.username=sa\nspring.datasource.password=\nspring.sql.init.mode=never\nspring.main.banner-mode=off\nserver.address=127.0.0.1\n" + }, + { + "path": "src/main/resources/db/migration/V1__orders.sql", + "language": "sql", + "code": "CREATE TABLE inventory(sku VARCHAR(30) PRIMARY KEY, stock INT NOT NULL CHECK(stock >= 0));\nCREATE TABLE orders(id VARCHAR(60) PRIMARY KEY, buyer VARCHAR(60) NOT NULL, sku VARCHAR(30) NOT NULL, quantity INT NOT NULL CHECK(quantity > 0), status VARCHAR(20) NOT NULL CHECK(status IN ('RESERVED','CANCELLED')));\nINSERT INTO inventory(sku,stock) VALUES('BOOK',5);\n" + } + ] + }, + "quiz": [ + { + "id": "orders-capstone-q1", + "question": "같은 requestId로 수량이 다른 요청이 도착했을 때 이 과제의 정책은?", + "options": [ + "기존 요청으로 간주해 성공이라고 답한다.", + "기존 주문 수량을 조용히 덮어쓴다.", + "원래 요청과 의미가 다르므로 409로 거절한다.", + "재고가 있으면 같은 ID의 주문을 하나 더 만든다." + ], + "correctIndex": 2, + "explanation": "reserve의 기존 주문 분기는 sku와 quantity를 원래 값과 비교합니다. 같은 requestId에 수량만 바꾸면 같은 의미의 재시도가 아니므로 request payload conflicts로 409를 던집니다. 기존 재고 차감을 되풀이하거나 원래 주문을 조용히 덮어쓰지 않습니다." + }, + { + "id": "orders-capstone-q2", + "question": "두 취소 요청이 경쟁할 때 재고를 복원할 작업을 정하는 기준은?", + "options": [ + "처음 주문을 조회한 요청", + "HTTP 연결이 먼저 열린 요청", + "RESERVED에서 CANCELLED로 조건부 UPDATE한 행 수가 1인 트랜잭션", + "더 높은 역할 이름을 가진 사용자" + ], + "correctIndex": 2, + "explanation": "먼저 조회했다고 취소 권한을 독점한 것은 아닙니다. DB에서 status=RESERVED 조건으로 UPDATE에 성공해 changed==1을 받은 작업만 재고를 더합니다. 재고 UPDATE도 같은 트랜잭션에 두어 상태 변경과 함께 확정하거나 취소합니다. 이 조건의 설계와 실제 동시 부하 검증은 구분해야 합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "alice의 BOOK 2개 주문 응답이 늦어 같은 요청을 다시 보냅니다. 이후 bob의 취소 요청, alice의 취소 두 번을 차례로 받았을 때 재고와 주문 상태가 어떻게 남아야 하는지 추적합니다.", + "tasks": [ + "POST /orders의 requestId·sku=BOOK·양수 quantity가 어디서 검사되는지 설명하세요. requestId는 영문 대소문자·숫자·_·-의 1~60자로 취소 URL의 한 경로 요소가 됩니다.", + "초기 재고 5에서 alice가 requestId=A, 수량 2를 두 번 요청하면 200·200과 재고 3이 되는 이유, 같은 ID의 수량 3은 409인 이유를 설명하세요.", + "재고가 5인데 수량 6을 요청하면 409 뒤 주문 0개·재고 5가 남아야 하는 조건을 changed 검사와 연결하세요.", + "bob의 취소는 403으로 거절하고 alice의 취소 두 번은 재고를 최종 5로만 돌려야 합니다. own과 status 조건부 UPDATE의 역할을 나누어 설명하세요.", + "인증·BUYER 역할·CSRF 조건이 DB 변경보다 앞에서 필요한 이유와, Flyway가 학습용 H2 구조를 준비하는 역할을 적으세요.", + "동시 신규 요청의 같은 requestId 충돌은 409 뒤 동일 요청 재시도 정책입니다. 제공된 순차 테스트가 모든 경쟁·고부하를 검증하지 않으며 결제·메일·외부 이벤트도 연결하지 않았음을 기록하세요.", + "취소 후 같은 생성 requestId를 다시 보내면 기존 CANCELLED 주문을 돌려준다는 점을 확인하고 새 주문 ID를 임의로 재사용할 때의 의미를 설명하세요." + ], + "takeaway": "응답 코드와 최종 DB 상태를 함께 확인합니다. 동일 요청 비교, 소유자 검사, 상태 조건부 UPDATE가 각기 다른 중복·권한 문제를 막습니다." + }, + "sources": [ + { + "title": "Spring transaction semantics", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/annotations.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring MVC validation", + "url": "https://docs.spring.io/spring-framework/reference/6.2/web/webmvc/mvc-controller/ann-validation.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring Security authorization", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/authorization/authorize-http-requests.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "Flyway Java API", + "url": "https://documentation.red-gate.com/flyway/reference/usage/api-java", + "edition": "Flyway", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring Boot", + "Transaction", + "Security", + "SQL" + ], + "exerciseId": "orders-capstone-project", + "professionalExercise": { + "id": "orders-capstone-project", + "profileId": "professional-capstone", + "mode": "project", + "instructions": [ + "POST /orders는 requestId·sku=BOOK·양수 quantity를 받고 200과 주문 상태를 반환합니다. requestId는 영문 대소문자·숫자·_·-만 사용한 1~60자이며 취소 URL의 단일 경로 요소로 사용합니다.", + "동일 사용자·동일 payload의 같은 requestId 재시도는 재고를 다시 차감하지 않습니다. 다른 payload는 409입니다.", + "재고 부족은 409이며 주문 기록과 재고 모두 바뀌지 않습니다.", + "POST /orders/{id}/cancel은 주문 소유자만 수행할 수 있고 반복 취소는 재고를 한 번만 복원합니다.", + "인증·BUYER 역할·CSRF가 필요합니다. Flyway는 실행별 학습 H2 스키마를 준비합니다.", + "동시 생성에서 같은 requestId가 충돌하면 409 후 동일 요청 재시도 정책을 사용합니다. 결제·메일·외부 이벤트는 이 프로젝트에 연결하지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/CapstoneLab.java" + ], + "template": "package edu.lab;\nimport java.util.*;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.*;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.dao.DuplicateKeyException;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.jdbc.core.*;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.Authentication;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class CapstoneLab {\n public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); }\n public record Create(@NotBlank @Size(max=60) @Pattern(regexp=\"[A-Za-z0-9_-]{1,60}\") String requestId, @NotBlank @Pattern(regexp=\"BOOK\") String sku, @Min(1) int quantity) {}\n public record OrderView(String id, String sku, int quantity, String status) {}\n private record Stored(String id, String buyer, String sku, int quantity, String status) {\n OrderView view() { return new OrderView(id,sku,quantity,status); }\n }\n public static class Failure extends RuntimeException {\n final int status;\n Failure(int status,String message) { super(message); this.status=status; }\n }\n @Service public static class Orders {\n private final JdbcTemplate jdbc;\n private static final RowMapper ROW = (r,n) -> new Stored(r.getString(\"id\"),r.getString(\"buyer\"),r.getString(\"sku\"),r.getInt(\"quantity\"),r.getString(\"status\"));\n public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; }\n private List find(String id) { return jdbc.query(\"select id,buyer,sku,quantity,status from orders where id=?\",ROW,id); }\n private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,\"order not found\"); return rows.get(0); }\n private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,\"not order owner\"); }\n @Transactional public OrderView reserve(String buyer,Create request) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n @Transactional public OrderView cancel(String buyer,String id) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n }\n @RestController @RequestMapping(\"/orders\")\n public static class Controller {\n private final Orders orders;\n public Controller(Orders orders) { this.orders=orders; }\n @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); }\n @PostMapping(\"/{id}/cancel\") public OrderView cancel(@PathVariable(\"id\") String id,Authentication auth) { return orders.cancel(auth.getName(),id); }\n @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) {\n return ResponseEntity.status(failure.status).body(Map.of(\"error\",failure.getMessage()));\n }\n @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() {\n return ResponseEntity.status(409).body(Map.of(\"error\",\"concurrent request conflict; retry the same request\"));\n }\n }\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n http.authorizeHttpRequests(a -> a.requestMatchers(\"/orders\",\"/orders/**\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"alice\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"bob\").password(\"{noop}training\").roles(\"BUYER\").build());\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/CapstoneLab.java", + "templatePath": "lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java", + "referencePath": "lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/orders-capstone/starter/pom.xml", + "referencePath": "lessons/orders-capstone/reference/pom.xml", + "editable": false + }, + { + "path": "src/main/resources/application.properties", + "templatePath": "lessons/orders-capstone/starter/src/main/resources/application.properties", + "referencePath": "lessons/orders-capstone/reference/src/main/resources/application.properties", + "editable": false + }, + { + "path": "src/main/resources/db/migration/V1__orders.sql", + "templatePath": "lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql", + "referencePath": "lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/orders-capstone/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "입력·미인증 거절", + "동일 요청 멱등 처리", + "payload 충돌과 재고 부족 롤백", + "다른 사용자 취소 거절", + "반복 취소 재고 한 번 복원" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "json-validation", + "tx-proxy", + "security-csrf", + "flyway-migrations" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 6, + "starterSatisfied": false, + "sourceFingerprint": "da15b7d861d2b0be337ec2cb9e5a67f8f72e089b444043a84e821298bf159431", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/orders-capstone/reference/pom.xml b/curriculum/professional/lessons/orders-capstone/reference/pom.xml new file mode 100644 index 0000000..f3a9de5 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/reference/pom.xml @@ -0,0 +1,10 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlorders-capstoneorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-security +org.springframework.bootspring-boot-starter-jdbc +com.h2databaseh2 +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest +org.flywaydbflyway-core diff --git a/curriculum/professional/lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java b/curriculum/professional/lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java new file mode 100644 index 0000000..6abdc78 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/reference/src/main/java/edu/lab/CapstoneLab.java @@ -0,0 +1,87 @@ +package edu.lab; +import java.util.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.*; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +@SpringBootApplication +public class CapstoneLab { + public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); } + public record Create(@NotBlank @Size(max=60) @Pattern(regexp="[A-Za-z0-9_-]{1,60}") String requestId, @NotBlank @Pattern(regexp="BOOK") String sku, @Min(1) int quantity) {} + public record OrderView(String id, String sku, int quantity, String status) {} + private record Stored(String id, String buyer, String sku, int quantity, String status) { + OrderView view() { return new OrderView(id,sku,quantity,status); } + } + public static class Failure extends RuntimeException { + final int status; + Failure(int status,String message) { super(message); this.status=status; } + } + @Service public static class Orders { + private final JdbcTemplate jdbc; + private static final RowMapper ROW = (r,n) -> new Stored(r.getString("id"),r.getString("buyer"),r.getString("sku"),r.getInt("quantity"),r.getString("status")); + public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; } + private List find(String id) { return jdbc.query("select id,buyer,sku,quantity,status from orders where id=?",ROW,id); } + private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,"order not found"); return rows.get(0); } + private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,"not order owner"); } + @Transactional public OrderView reserve(String buyer,Create request) { + // BEGIN EXERCISE + if (buyer==null || buyer.isBlank() || request.requestId()==null || !request.requestId().matches("[A-Za-z0-9_-]{1,60}") + || !"BOOK".equals(request.sku()) || request.quantity()<=0) throw new Failure(400,"invalid order"); + var existing=find(request.requestId()); + if(!existing.isEmpty()) { + Stored order=existing.get(0); own(order,buyer); + if(!order.sku().equals(request.sku()) || order.quantity()!=request.quantity()) throw new Failure(409,"request payload conflicts"); + return order.view(); + } + int changed=jdbc.update("update inventory set stock=stock-? where sku=? and stock>=?",request.quantity(),request.sku(),request.quantity()); + if(changed!=1) throw new Failure(409,"insufficient stock"); + jdbc.update("insert into orders(id,buyer,sku,quantity,status) values(?,?,?,?,?)",request.requestId(),buyer,request.sku(),request.quantity(),"RESERVED"); + return new OrderView(request.requestId(),request.sku(),request.quantity(),"RESERVED"); + // END EXERCISE + } + @Transactional public OrderView cancel(String buyer,String id) { + // BEGIN EXERCISE + Stored order=require(id); own(order,buyer); + int changed=jdbc.update("update orders set status='CANCELLED' where id=? and status='RESERVED'",id); + if(changed==1) jdbc.update("update inventory set stock=stock+? where sku=?",order.quantity(),order.sku()); + return require(id).view(); + // END EXERCISE + } + } + @RestController @RequestMapping("/orders") + public static class Controller { + private final Orders orders; + public Controller(Orders orders) { this.orders=orders; } + @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); } + @PostMapping("/{id}/cancel") public OrderView cancel(@PathVariable("id") String id,Authentication auth) { return orders.cancel(auth.getName(),id); } + @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) { + return ResponseEntity.status(failure.status).body(Map.of("error",failure.getMessage())); + } + @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() { + return ResponseEntity.status(409).body(Map.of("error","concurrent request conflict; retry the same request")); + } + } + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a.requestMatchers("/orders","/orders/**").hasRole("BUYER").anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults()); + return http.build(); + } + @Bean UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("alice").password("{noop}training").roles("BUYER").build(), + User.withUsername("bob").password("{noop}training").roles("BUYER").build()); + } +} diff --git a/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/application.properties b/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/application.properties new file mode 100644 index 0000000..41b0e92 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/application.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:orders-capstone;DB_CLOSE_DELAY=-1 +spring.datasource.username=sa +spring.datasource.password= +spring.sql.init.mode=never +spring.main.banner-mode=off +server.address=127.0.0.1 diff --git a/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql b/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql new file mode 100644 index 0000000..2edd720 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/reference/src/main/resources/db/migration/V1__orders.sql @@ -0,0 +1,3 @@ +CREATE TABLE inventory(sku VARCHAR(30) PRIMARY KEY, stock INT NOT NULL CHECK(stock >= 0)); +CREATE TABLE orders(id VARCHAR(60) PRIMARY KEY, buyer VARCHAR(60) NOT NULL, sku VARCHAR(30) NOT NULL, quantity INT NOT NULL CHECK(quantity > 0), status VARCHAR(20) NOT NULL CHECK(status IN ('RESERVED','CANCELLED'))); +INSERT INTO inventory(sku,stock) VALUES('BOOK',5); diff --git a/curriculum/professional/lessons/orders-capstone/starter/pom.xml b/curriculum/professional/lessons/orders-capstone/starter/pom.xml new file mode 100644 index 0000000..f3a9de5 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/starter/pom.xml @@ -0,0 +1,10 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlorders-capstoneorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-validation +org.springframework.bootspring-boot-starter-security +org.springframework.bootspring-boot-starter-jdbc +com.h2databaseh2 +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest +org.flywaydbflyway-core diff --git a/curriculum/professional/lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java b/curriculum/professional/lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java new file mode 100644 index 0000000..6a60266 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/starter/src/main/java/edu/lab/CapstoneLab.java @@ -0,0 +1,69 @@ +package edu.lab; +import java.util.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.*; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +@SpringBootApplication +public class CapstoneLab { + public static void main(String[] args) { SpringApplication.run(CapstoneLab.class,args); } + public record Create(@NotBlank @Size(max=60) @Pattern(regexp="[A-Za-z0-9_-]{1,60}") String requestId, @NotBlank @Pattern(regexp="BOOK") String sku, @Min(1) int quantity) {} + public record OrderView(String id, String sku, int quantity, String status) {} + private record Stored(String id, String buyer, String sku, int quantity, String status) { + OrderView view() { return new OrderView(id,sku,quantity,status); } + } + public static class Failure extends RuntimeException { + final int status; + Failure(int status,String message) { super(message); this.status=status; } + } + @Service public static class Orders { + private final JdbcTemplate jdbc; + private static final RowMapper ROW = (r,n) -> new Stored(r.getString("id"),r.getString("buyer"),r.getString("sku"),r.getInt("quantity"),r.getString("status")); + public Orders(JdbcTemplate jdbc) { this.jdbc=jdbc; } + private List find(String id) { return jdbc.query("select id,buyer,sku,quantity,status from orders where id=?",ROW,id); } + private Stored require(String id) { var rows=find(id); if(rows.isEmpty()) throw new Failure(404,"order not found"); return rows.get(0); } + private void own(Stored order,String buyer) { if(!order.buyer().equals(buyer)) throw new Failure(403,"not order owner"); } + @Transactional public OrderView reserve(String buyer,Create request) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + @Transactional public OrderView cancel(String buyer,String id) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + } + @RestController @RequestMapping("/orders") + public static class Controller { + private final Orders orders; + public Controller(Orders orders) { this.orders=orders; } + @PostMapping public OrderView create(@Valid @RequestBody Create request,Authentication auth) { return orders.reserve(auth.getName(),request); } + @PostMapping("/{id}/cancel") public OrderView cancel(@PathVariable("id") String id,Authentication auth) { return orders.cancel(auth.getName(),id); } + @ExceptionHandler(Failure.class) public ResponseEntity> failure(Failure failure) { + return ResponseEntity.status(failure.status).body(Map.of("error",failure.getMessage())); + } + @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> duplicate() { + return ResponseEntity.status(409).body(Map.of("error","concurrent request conflict; retry the same request")); + } + } + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(a -> a.requestMatchers("/orders","/orders/**").hasRole("BUYER").anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults()); + return http.build(); + } + @Bean UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("alice").password("{noop}training").roles("BUYER").build(), + User.withUsername("bob").password("{noop}training").roles("BUYER").build()); + } +} diff --git a/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/application.properties b/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/application.properties new file mode 100644 index 0000000..41b0e92 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/application.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:orders-capstone;DB_CLOSE_DELAY=-1 +spring.datasource.username=sa +spring.datasource.password= +spring.sql.init.mode=never +spring.main.banner-mode=off +server.address=127.0.0.1 diff --git a/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql b/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql new file mode 100644 index 0000000..2edd720 --- /dev/null +++ b/curriculum/professional/lessons/orders-capstone/starter/src/main/resources/db/migration/V1__orders.sql @@ -0,0 +1,3 @@ +CREATE TABLE inventory(sku VARCHAR(30) PRIMARY KEY, stock INT NOT NULL CHECK(stock >= 0)); +CREATE TABLE orders(id VARCHAR(60) PRIMARY KEY, buyer VARCHAR(60) NOT NULL, sku VARCHAR(30) NOT NULL, quantity INT NOT NULL CHECK(quantity > 0), status VARCHAR(20) NOT NULL CHECK(status IN ('RESERVED','CANCELLED'))); +INSERT INTO inventory(sku,stock) VALUES('BOOK',5); diff --git a/curriculum/professional/lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..dd88b81 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,37 @@ +package edu.lab; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.*; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.*; +import org.springframework.data.redis.core.StringRedisTemplate; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + LettuceConnectionFactory factory; StringRedisTemplate redis; StockCache cache; + @BeforeEach void connect() { + String host = System.getenv("JAVA_EDU_REDIS_HOST"), portText = System.getenv("JAVA_EDU_REDIS_PORT"); + if (host == null || portText == null) throw new IllegalStateException("학습 전용 Redis 환경이 준비되지 않았습니다."); + if (!Set.of("127.0.0.1","localhost","::1").contains(host)) throw new IllegalStateException("loopback Redis only"); + int port = Integer.parseInt(portText); if (port < 1 || port > 65535) throw new IllegalStateException("invalid port"); + var client = LettuceClientConfiguration.builder().commandTimeout(Duration.ofSeconds(5)).shutdownTimeout(Duration.ofMillis(100)).build(); + factory = new LettuceConnectionFactory(new RedisStandaloneConfiguration(host,port), client); + factory.afterPropertiesSet(); factory.start(); redis = new StringRedisTemplate(factory); + cache = new StockCache(redis,"java-edu:" + UUID.randomUUID() + ":"); + } + @AfterEach void cleanup() { + try { if (redis != null && cache != null) redis.delete(List.of(cache.key("BOOK"),cache.key("PEN"))); } + finally { if (factory != null) factory.destroy(); } + } + @Test void storesWithExpiryAndInvalidatesOnlyOwnSku() { + cache.put("BOOK",5,Duration.ofSeconds(30)); cache.put("PEN",2,Duration.ofSeconds(30)); + assertThat(cache.get("BOOK").getAsInt()).isEqualTo(5); + assertThat(redis.getExpire(cache.key("BOOK"),TimeUnit.MILLISECONDS)).isBetween(1L,30000L); + cache.invalidate("BOOK"); assertThat(cache.get("BOOK").isEmpty()).isTrue(); assertThat(cache.get("PEN").getAsInt()).isEqualTo(2); + } + @Test void rejectsInvalidValueAndTtl() { + assertThatThrownBy(() -> cache.put("BOOK",-1,Duration.ofSeconds(30))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> cache.put("BOOK",1,Duration.ZERO)).isInstanceOf(IllegalArgumentException.class); + assertThat(cache.get("BOOK").isEmpty()).isTrue(); + } +} diff --git a/curriculum/professional/lessons/redis-cache/lesson.json b/curriculum/professional/lessons/redis-cache/lesson.json new file mode 100644 index 0000000..df492b7 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/lesson.json @@ -0,0 +1,217 @@ +{ + "id": "redis-cache", + "title": "Redis TTL·무효화·키 소유권", + "description": "화면에는 재고가 5개인데 DB에는 이미 3개일 수 있습니다. StockCache가 값을 얼마 동안 보관하고 어떤 키만 지우는지 읽으며, 캐시 조회와 판매 가능 판단을 구분합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "캐시에 남은 수량과 DB의 현재 수량이 다를 수 있는 이유를 설명한다.", + "put이 수량을 문자열로 바꾸고 양수 TTL과 함께 저장하는 순서를 따라간다.", + "get의 값 없음과 수량 0을 구분한다.", + "invalidate가 지정 SKU의 키만 지워야 하는 이유를 설명한다.", + "잘못된 수량·TTL의 거절과 전용 Redis가 필요한 실제 연동 검사 범위를 구분한다." + ], + "learningGuide": { + "scenario": "전용 Redis의 이번 실행 접두사 아래 BOOK=5와 PEN=2를 저장했다고 가정합니다. BOOK의 값만 오래되어 지우려 합니다.", + "question": "BOOK을 지운 뒤 get 결과는 수량 0일까요, 값 없음일까요? PEN은 어떻게 될까요?", + "terms": [ + { + "term": "캐시", + "meaning": "조회용으로 잠시 보관한 원본 데이터의 복사본입니다. 이 클래스의 수량은 주문을 확정하는 DB 재고가 아닙니다." + }, + { + "term": "TTL", + "meaning": "저장한 값이 만료되기까지의 기간입니다. put의 ttl은 양수여야 하며 DB 변경을 자동으로 전파하지는 않습니다." + }, + { + "term": "무효화", + "meaning": "더는 사용할 수 없는 캐시 값을 지우는 일입니다. invalidate는 지정 sku의 키 하나를 지웁니다." + }, + { + "term": "OptionalInt", + "meaning": "정수가 있거나 없음을 구분하는 반환 타입입니다. empty()와 값 0은 다릅니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/StockCache.java", + "source": " public void put(String sku, int quantity, Duration ttl) {\n // BEGIN EXERCISE\n if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException(\"cache value or ttl\");\n redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl);\n // END EXERCISE\n }\n public OptionalInt get(String sku) {\n // BEGIN EXERCISE\n String value = redis.opsForValue().get(key(sku));\n return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value));\n // END EXERCISE\n }\n public void invalidate(String sku) {\n // BEGIN EXERCISE\n redis.delete(key(sku));" + }, + "walkthrough": [ + "put의 if는 음수 quantity, null·0·음수 ttl을 거절합니다. 다음 set은 key(sku)에 수량 문자열과 만료 기간을 함께 저장합니다.", + "get의 String value는 같은 key(sku)를 조회합니다. 값이 없으면 null이므로 OptionalInt.empty()를 반환합니다.", + "값이 있으면 Integer.parseInt(value)로 정수로 읽습니다. 저장된 \"0\"은 값 없음이 아니라 수량 0입니다.", + "invalidate의 redis.delete(key(sku))는 그 키만 지웁니다. key는 원래 클래스에서 prefix와 sku를 합치므로 BOOK 삭제가 PEN 키까지 가리키지 않습니다." + ], + "prediction": { + "question": "Redis 명령이 정상 처리되고 TTL이 아직 남아 있을 때 BOOK만 invalidate하면 BOOK과 PEN의 조회 결과는 무엇일까요?", + "answer": "BOOK은 OptionalInt.empty(), PEN은 수량 2입니다.", + "explanation": "delete에 넘긴 것은 BOOK 키 하나입니다. BOOK의 다음 조회는 null이라 empty가 되고, 다른 키인 PEN은 유지됩니다. 이는 코드로 예측한 계약이며 실제 Redis 연동 검증은 별도로 필요합니다." + }, + "takeaway": "값 없음·재고 0·오래된 조회값을 서로 구분하고, 자신이 관리하는 키만 변경합니다." + }, + "theory": [ + { + "heading": "원본과 복제된 조회 값", + "text": "캐시는 원본을 매번 읽지 않도록 잠시 보관한 조회용 복사본입니다. DB 재고가 5에서 3으로 줄어도 Redis에 넣어 둔 문자열 \"5\"가 곧바로 바뀌지는 않습니다. StockCache.put은 수량과 TTL을 받아 저장하지만 DB를 조회하거나 주문을 예약하지 않습니다. TTL은 값이 만료될 때까지의 기간입니다. 오래 남은 값을 없애는 데 쓰지만, 만료 전의 값을 항상 최신으로 만들지는 않습니다. 판매 가능 수량의 최종 판단은 DB 트랜잭션에서 해야 합니다." + }, + { + "heading": "쓰기와 무효화의 순서", + "text": "무효화는 더는 믿지 않을 캐시 값을 지워 다음 조회가 원본을 다시 보게 하는 방법입니다. invalidate(\"BOOK\")은 key(\"BOOK\")을 만들어 그 키를 삭제합니다. 이 클래스에는 DB 조회 후 캐시를 채우는 전체 흐름은 없습니다. 실제 서비스에서 DB 변경 뒤 삭제가 실패하거나 다른 요청이 오래된 값을 다시 넣을 수 있으므로, 허용할 지연과 삭제 재시도·이벤트 전달 정책을 따로 정해야 합니다. 이 실습의 검사 대상은 put/get/invalidate의 키별 계약입니다." + }, + { + "heading": "키 소유권과 환경", + "text": "key는 생성자에 받은 prefix와 sku를 붙입니다. 테스트는 java-edu::처럼 실행마다 다른 접두사를 만들어 BOOK·PEN 키가 다른 실행과 섞이지 않게 합니다. 생성자는 java-edu:로 시작하고 :로 끝나는 형태를 확인하며 UUID 자체는 테스트가 만듭니다. StringRedisTemplate은 키와 값을 문자열로 다루므로 get은 Integer.parseInt로 수량을 복구합니다. FLUSHALL·FLUSHDB 같은 전체 삭제 대신 소유한 키만 지웁니다. 전용 로컬 Redis가 준비되지 않은 상태의 소스 제공을 실제 연동 검사 통과로 기록하지 않습니다." + } + ], + "example": { + "title": "StockCache 기준 구현", + "code": "package edu.lab;\nimport java.time.Duration;\nimport java.util.OptionalInt;\nimport org.springframework.data.redis.core.StringRedisTemplate;\npublic final class StockCache {\n private final StringRedisTemplate redis; private final String prefix;\n public StockCache(StringRedisTemplate redis, String prefix) {\n if (prefix == null || !prefix.startsWith(\"java-edu:\") || !prefix.endsWith(\":\")) throw new IllegalArgumentException(\"training prefix\");\n this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix;\n }\n public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException(\"sku\"); return prefix + sku; }\n public void put(String sku, int quantity, Duration ttl) {\n // BEGIN EXERCISE\n if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException(\"cache value or ttl\");\n redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl);\n // END EXERCISE\n }\n public OptionalInt get(String sku) {\n // BEGIN EXERCISE\n String value = redis.opsForValue().get(key(sku));\n return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value));\n // END EXERCISE\n }\n public void invalidate(String sku) {\n // BEGIN EXERCISE\n redis.delete(key(sku));\n // END EXERCISE\n }\n}\n", + "output": "예상 검증 항목: 문자열 수량 저장·조회 · 양수 TTL 설정 · 지정 키만 무효화 · 잘못된 캐시 입력 거절. 아직 실행하지 않았습니다.", + "explanation": "put은 음수 quantity와 0 이하·누락 TTL을 거절한 뒤 Integer.toString(quantity)와 ttl을 Redis에 함께 넘깁니다. get은 값이 없으면 OptionalInt.empty(), 있으면 문자열을 정수로 바꿉니다. invalidate는 key(sku) 하나만 삭제하므로 BOOK을 지워도 같은 실행의 PEN은 남아야 합니다. 이 코드에는 원본 DB 변경이나 자동 재조회가 없습니다. 전용 Redis에서 이 동작을 실제로 확인하는 통합 검사는 현재 미검증이며, 기준 구현과 테스트 소스는 공개 학습 자료입니다. 실제 서비스 연결·설치형 통합 검증은 별도 증거가 필요합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/StockCache.java", + "language": "java", + "code": "package edu.lab;\nimport java.time.Duration;\nimport java.util.OptionalInt;\nimport org.springframework.data.redis.core.StringRedisTemplate;\npublic final class StockCache {\n private final StringRedisTemplate redis; private final String prefix;\n public StockCache(StringRedisTemplate redis, String prefix) {\n if (prefix == null || !prefix.startsWith(\"java-edu:\") || !prefix.endsWith(\":\")) throw new IllegalArgumentException(\"training prefix\");\n this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix;\n }\n public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException(\"sku\"); return prefix + sku; }\n public void put(String sku, int quantity, Duration ttl) {\n // BEGIN EXERCISE\n if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException(\"cache value or ttl\");\n redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl);\n // END EXERCISE\n }\n public OptionalInt get(String sku) {\n // BEGIN EXERCISE\n String value = redis.opsForValue().get(key(sku));\n return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value));\n // END EXERCISE\n }\n public void invalidate(String sku) {\n // BEGIN EXERCISE\n redis.delete(key(sku));\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlredis-cacheorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-data-redis\n" + }, + { + "path": "FIXTURE.md", + "language": "markdown", + "code": "이 단원은 전용 로컬 Redis가 필요합니다. 예시 연결은 127.0.0.1:16379이며 설치형에서 별도 자원 지원을 확인해야 합니다.\nJAVA_EDU_REDIS_HOST와 JAVA_EDU_REDIS_PORT를 둘 다 명시합니다. 허용 호스트는 127.0.0.1, localhost, ::1뿐입니다.\n공유 업무 Redis를 연결하지 마세요. 테스트는 java-edu:: 접두사의 BOOK/PEN 두 키만 만들고 지웁니다. FLUSHALL/FLUSHDB는 사용하지 않습니다.\nDocker 이미지 준비·실행·네트워크 격리는 이 소스만으로 검증되지 않았습니다. 자원 미준비 오류를 단원 통과나 건너뛴 성공으로 처리하지 않습니다.\n" + } + ] + }, + "quiz": [ + { + "id": "redis-cache-q1", + "question": "TTL을 설정한 재고 캐시에 대한 올바른 설명은?", + "options": [ + "TTL이 있으면 DB 트랜잭션은 필요 없다.", + "TTL이 있으면 중복 주문은 불가능하다.", + "만료를 제어하지만 DB와 캐시의 모든 동시 변경을 원자화하지는 않는다.", + "캐시 조회 값은 언제나 최신 DB 값과 같다." + ], + "correctIndex": 2, + "explanation": "ttl은 put에 넘긴 값이 언제 만료될지 정합니다. 그 사이 DB에서 재고가 줄어도 이 클래스가 그 변경을 자동 전달받지는 않습니다. 따라서 캐시 만료와 DB·Redis 두 저장소의 변경을 함께 확정하는 문제는 별도입니다." + }, + { + "id": "redis-cache-q2", + "question": "학습용 키를 정리할 때 적절한 방식은?", + "options": [ + "모든 Redis 키를 FLUSHALL로 지운다.", + "현재 실행이 생성한 UUID 접두사의 키만 지운다.", + "다른 애플리케이션의 키도 같은 DB면 지운다.", + "정리를 하지 않으면 항상 안전하다." + ], + "correctIndex": 1, + "explanation": "key(\"BOOK\")은 현재 실행의 prefix와 BOOK을 합칩니다. 그 키만 지우면 PEN이나 다른 실행의 데이터는 보존할 수 있습니다. FLUSHALL은 이 소유 범위를 넘어 데이터를 지우므로 이 실습의 정리 방법이 아닙니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "BOOK의 DB 재고를 바꾼 뒤 조회 캐시를 지우려 합니다. 같은 Redis에 PEN이나 다른 실행의 키도 있으므로, 갱신 대상 BOOK만 정확히 지워야 합니다.", + "tasks": [ + "put(\"BOOK\",5,양수 TTL)에서 5가 어떤 문자열로 저장되는지, 음수 수량과 0 TTL이 어디서 거절되는지 설명하세요.", + "get이 OptionalInt.empty()를 반환하는 경우와 저장된 수량 0을 반환하는 경우를 구분하세요.", + "만료 전 BOOK을 invalidate했을 때 BOOK은 없어지고 PEN은 남아야 하는 이유를 key와 prefix에 연결하세요.", + "실제 통합 검사에는 전용 로컬 Redis와 JAVA_EDU_REDIS_HOST·JAVA_EDU_REDIS_PORT가 필요합니다. 자원이 없으면 미실행으로 기록하며 전체 저장소 삭제 명령을 쓰지 않습니다.", + "DB 갱신 뒤 캐시 삭제에 실패한 상황과, 이번 키별 검사만으로 확인할 수 없는 최신성·장애 복구 범위를 적으세요." + ], + "takeaway": "TTL과 키별 삭제는 캐시 수명을 관리합니다. 원본 DB와 언제 같은 값을 보장할지는 별도 흐름으로 설계하고 검사해야 합니다." + }, + "sources": [ + { + "title": "Spring Data Redis templates", + "url": "https://docs.spring.io/spring-data/redis/reference/3.5/redis/template.html", + "edition": "Spring Data Redis 3.5", + "checkedAt": "2026-10-12" + }, + { + "title": "Redis EXPIRE", + "url": "https://redis.io/docs/latest/commands/expire/", + "edition": "Redis command reference", + "checkedAt": "2026-10-12" + }, + { + "title": "Redis Docker installation", + "url": "https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/docker/", + "edition": "Redis official guide", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Redis", + "Cache" + ], + "exerciseId": "redis-cache-project", + "professionalExercise": { + "id": "redis-cache-project", + "profileId": "professional-redis", + "mode": "project", + "instructions": [ + "put은 0 이상 수량과 양수 TTL만 허용하며 문자열로 저장합니다.", + "get은 부재이면 OptionalInt.empty, 존재하면 수량을 반환합니다.", + "invalidate는 지정 SKU만 지우고 다른 키를 보존합니다.", + "전용 로컬 Redis와 JAVA_EDU_REDIS_HOST/JAVA_EDU_REDIS_PORT가 필요합니다. 자원이 없으면 단원 통합 검사는 미실행 상태입니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/StockCache.java" + ], + "template": "package edu.lab;\nimport java.time.Duration;\nimport java.util.OptionalInt;\nimport org.springframework.data.redis.core.StringRedisTemplate;\npublic final class StockCache {\n private final StringRedisTemplate redis; private final String prefix;\n public StockCache(StringRedisTemplate redis, String prefix) {\n if (prefix == null || !prefix.startsWith(\"java-edu:\") || !prefix.endsWith(\":\")) throw new IllegalArgumentException(\"training prefix\");\n this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix;\n }\n public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException(\"sku\"); return prefix + sku; }\n public void put(String sku, int quantity, Duration ttl) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public OptionalInt get(String sku) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public void invalidate(String sku) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/StockCache.java", + "templatePath": "lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java", + "referencePath": "lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/redis-cache/starter/pom.xml", + "referencePath": "lessons/redis-cache/reference/pom.xml", + "editable": false + }, + { + "path": "FIXTURE.md", + "templatePath": "lessons/redis-cache/starter/FIXTURE.md", + "referencePath": "lessons/redis-cache/reference/FIXTURE.md", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/redis-cache/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "문자열 수량 저장·조회", + "양수 TTL 설정", + "지정 키만 무효화", + "잘못된 캐시 입력 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": false, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "source-prepared", + "executionVerified": false, + "prerequisites": [ + "tx-proxy" + ] +} diff --git a/curriculum/professional/lessons/redis-cache/reference/FIXTURE.md b/curriculum/professional/lessons/redis-cache/reference/FIXTURE.md new file mode 100644 index 0000000..c497039 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/reference/FIXTURE.md @@ -0,0 +1,4 @@ +이 단원은 전용 로컬 Redis가 필요합니다. 예시 연결은 127.0.0.1:16379이며 설치형에서 별도 자원 지원을 확인해야 합니다. +JAVA_EDU_REDIS_HOST와 JAVA_EDU_REDIS_PORT를 둘 다 명시합니다. 허용 호스트는 127.0.0.1, localhost, ::1뿐입니다. +공유 업무 Redis를 연결하지 마세요. 테스트는 java-edu:: 접두사의 BOOK/PEN 두 키만 만들고 지웁니다. FLUSHALL/FLUSHDB는 사용하지 않습니다. +Docker 이미지 준비·실행·네트워크 격리는 이 소스만으로 검증되지 않았습니다. 자원 미준비 오류를 단원 통과나 건너뛴 성공으로 처리하지 않습니다. diff --git a/curriculum/professional/lessons/redis-cache/reference/pom.xml b/curriculum/professional/lessons/redis-cache/reference/pom.xml new file mode 100644 index 0000000..0fc3bdc --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/reference/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlredis-cacheorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-data-redis diff --git a/curriculum/professional/lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java b/curriculum/professional/lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java new file mode 100644 index 0000000..af67899 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/reference/src/main/java/edu/lab/StockCache.java @@ -0,0 +1,29 @@ +package edu.lab; +import java.time.Duration; +import java.util.OptionalInt; +import org.springframework.data.redis.core.StringRedisTemplate; +public final class StockCache { + private final StringRedisTemplate redis; private final String prefix; + public StockCache(StringRedisTemplate redis, String prefix) { + if (prefix == null || !prefix.startsWith("java-edu:") || !prefix.endsWith(":")) throw new IllegalArgumentException("training prefix"); + this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix; + } + public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException("sku"); return prefix + sku; } + public void put(String sku, int quantity, Duration ttl) { + // BEGIN EXERCISE + if (quantity < 0 || ttl == null || ttl.isZero() || ttl.isNegative()) throw new IllegalArgumentException("cache value or ttl"); + redis.opsForValue().set(key(sku), Integer.toString(quantity), ttl); + // END EXERCISE + } + public OptionalInt get(String sku) { + // BEGIN EXERCISE + String value = redis.opsForValue().get(key(sku)); + return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value)); + // END EXERCISE + } + public void invalidate(String sku) { + // BEGIN EXERCISE + redis.delete(key(sku)); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/redis-cache/starter/FIXTURE.md b/curriculum/professional/lessons/redis-cache/starter/FIXTURE.md new file mode 100644 index 0000000..c497039 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/starter/FIXTURE.md @@ -0,0 +1,4 @@ +이 단원은 전용 로컬 Redis가 필요합니다. 예시 연결은 127.0.0.1:16379이며 설치형에서 별도 자원 지원을 확인해야 합니다. +JAVA_EDU_REDIS_HOST와 JAVA_EDU_REDIS_PORT를 둘 다 명시합니다. 허용 호스트는 127.0.0.1, localhost, ::1뿐입니다. +공유 업무 Redis를 연결하지 마세요. 테스트는 java-edu:: 접두사의 BOOK/PEN 두 키만 만들고 지웁니다. FLUSHALL/FLUSHDB는 사용하지 않습니다. +Docker 이미지 준비·실행·네트워크 격리는 이 소스만으로 검증되지 않았습니다. 자원 미준비 오류를 단원 통과나 건너뛴 성공으로 처리하지 않습니다. diff --git a/curriculum/professional/lessons/redis-cache/starter/pom.xml b/curriculum/professional/lessons/redis-cache/starter/pom.xml new file mode 100644 index 0000000..0fc3bdc --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/starter/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlredis-cacheorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-data-redis diff --git a/curriculum/professional/lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java b/curriculum/professional/lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java new file mode 100644 index 0000000..0317009 --- /dev/null +++ b/curriculum/professional/lessons/redis-cache/starter/src/main/java/edu/lab/StockCache.java @@ -0,0 +1,21 @@ +package edu.lab; +import java.time.Duration; +import java.util.OptionalInt; +import org.springframework.data.redis.core.StringRedisTemplate; +public final class StockCache { + private final StringRedisTemplate redis; private final String prefix; + public StockCache(StringRedisTemplate redis, String prefix) { + if (prefix == null || !prefix.startsWith("java-edu:") || !prefix.endsWith(":")) throw new IllegalArgumentException("training prefix"); + this.redis = java.util.Objects.requireNonNull(redis); this.prefix = prefix; + } + public String key(String sku) { if (sku == null || sku.isBlank()) throw new IllegalArgumentException("sku"); return prefix + sku; } + public void put(String sku, int quantity, Duration ttl) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public OptionalInt get(String sku) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public void invalidate(String sku) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..576948c --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,18 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@SpringBootTest(classes=SecurityLab.class) +@AutoConfigureMockMvc +class ContractTest { + @Autowired MockMvc mvc; + @Test void unauthenticated() throws Exception { mvc.perform(post("/orders").with(csrf())).andExpect(status().isUnauthorized()); } + @Test void wrongRole() throws Exception { mvc.perform(post("/orders").with(httpBasic("viewer","training")).with(csrf())).andExpect(status().isForbidden()); } + @Test void csrfRequired() throws Exception { mvc.perform(post("/orders").with(httpBasic("buyer","training"))).andExpect(status().isForbidden()); } + @Test void authorized() throws Exception { mvc.perform(post("/orders").with(httpBasic("buyer","training")).with(csrf())).andExpect(status().isOk()); } +} diff --git a/curriculum/professional/lessons/security-csrf/lesson.json b/curriculum/professional/lessons/security-csrf/lesson.json new file mode 100644 index 0000000..bc6e36f --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/lesson.json @@ -0,0 +1,205 @@ +{ + "id": "security-csrf", + "title": "Spring Security의 인증·권한·CSRF", + "description": "로그인한 viewer가 주문을 만들면 왜 403일까요? 사용자 확인, 주문을 만들 권한, CSRF 토큰을 각각 만족시켜야 하는 POST /orders를 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "사용자가 누구인지 확인하는 인증과 그 행동을 허용하는 인가를 구분한다.", + "유효한 CSRF 토큰만 있는 미인증 요청이 401인 이유를 설명한다.", + "VIEWER 역할이나 누락된 CSRF 토큰이 각각 403을 만드는 조건을 찾는다.", + "BUYER와 유효한 CSRF를 갖춘 요청만 200이 되는 경로를 따라간다.", + "역할 검사와 특정 주문의 소유자 검사를 구분한다." + ], + "learningGuide": { + "scenario": "viewer는 올바른 아이디와 비밀번호로 요청했고 CSRF 토큰도 보냈습니다. 그런데 주문 생성 요청이 403으로 거절됩니다.", + "question": "로그인은 확인됐는데 이 요청을 막는 코드가 또 있을까요?", + "terms": [ + { + "term": "인증", + "meaning": "요청자의 신원을 확인하는 과정입니다. 여기서는 HTTP Basic으로 교육용 계정 정보를 받습니다." + }, + { + "term": "인가", + "meaning": "그 사용자가 해당 행동을 해도 되는지 판단하는 과정입니다. hasRole(\"BUYER\")가 주문 요청의 역할을 검사합니다." + }, + { + "term": "CSRF 토큰", + "meaning": "브라우저를 이용한 위조 요청을 막기 위해 서버가 확인하는 값입니다. 이 예제의 POST에는 인증과 별도로 필요합니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/SecurityLab.java", + "source": " @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n // BEGIN EXERCISE\n http.authorizeHttpRequests(auth -> auth.requestMatchers(\"/orders\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n // END EXERCISE\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());" + }, + "walkthrough": [ + "authorizeHttpRequests의 requestMatchers(\"/orders\")가 검사할 경로를 고릅니다. hasRole(\"BUYER\")는 그 경로를 요청할 사용자에게 BUYER 역할을 요구합니다.", + "anyRequest().authenticated()는 앞 규칙에 잡히지 않은 다른 요청에도 인증을 요구합니다. /orders의 BUYER 조건을 없애는 줄이 아닙니다.", + "httpBasic은 인증 방식을, csrf는 기본 CSRF 방어를 설정합니다. http.build()가 이 설정을 적용할 보안 필터 구성을 만듭니다.", + "users의 buyer에는 BUYER, viewer에는 VIEWER 역할이 붙습니다. 따라서 viewer는 신원이 확인되고 토큰이 있어도 /orders의 BUYER 조건을 통과하지 못합니다." + ], + "prediction": { + "question": "POST /orders에 viewer의 올바른 인증 정보와 유효한 CSRF 토큰을 보내면 200일까요, 403일까요?", + "answer": "403입니다. viewer에게는 BUYER 역할이 없습니다.", + "explanation": "인증과 CSRF를 만족해도 hasRole(\"BUYER\") 검사는 남아 있습니다. users에서 viewer에게 준 역할은 VIEWER이므로 요청은 create에 들어가기 전에 거절됩니다." + }, + "takeaway": "로그인 성공은 모든 행동을 허용한다는 뜻이 아닙니다. 코드에서 각 허용 조건을 따로 읽습니다." + }, + "theory": [ + { + "heading": "인증·권한·객체 소유권", + "text": "인증은 요청한 사람이 누구인지 확인하는 일이고, 인가는 확인된 사람이 이 행동을 해도 되는지 판단하는 일입니다. SecurityLab의 httpBasic은 테스트 계정 buyer와 viewer의 인증 정보를 받습니다. requestMatchers(\"/orders\").hasRole(\"BUYER\")는 /orders 요청에 BUYER 역할을 요구하는 인가 규칙입니다. viewer는 인증에 성공해도 이 역할이 없어 주문을 만들 수 없습니다. 또한 BUYER 역할만으로 다른 사람 주문까지 취소할 수는 없습니다. 특정 주문의 소유자 검사는 통합 과제에서 따로 다룹니다." + }, + { + "heading": "CSRF와 인증 전송 방식", + "text": "CSRF는 공격자가 사용자의 브라우저를 통해 사용자가 의도하지 않은 상태 변경 요청을 보내게 하는 공격입니다. 브라우저가 인증 정보를 자동으로 보내는 환경에서는 로그인 여부만으로 이를 가릴 수 없습니다. 이 예제의 csrf(Customizer.withDefaults())는 기본 방어를 유지하므로 POST /orders에 유효한 CSRF 토큰도 필요합니다. REST API라는 이름만으로 이 검사를 끄지 않습니다. HTTP Basic과 {noop}training 비밀번호는 로컬 교육용 설정이며 실제 서비스의 HTTPS와 비밀번호 저장 정책을 대신하지 않습니다." + }, + { + "heading": "실패 순서의 관찰", + "text": "보안 필터는 컨트롤러의 create에 도착하기 전에 요청을 거절할 수 있습니다. 로그인 정보와 CSRF가 모두 없으면 먼저 실패한 검사만 보게 됩니다. 그래서 인증 검사는 csrf()를 붙인 미인증 요청으로, CSRF 검사는 buyer로 인증하되 토큰을 뺀 요청으로 만듭니다. 전자는 이 설정에서 401, 후자는 403입니다. viewer에 유효한 토큰을 주면 역할 부족 403을 따로 볼 수 있습니다. 설정 코드를 쓴 줄 순서를 실제 필터 실행 순서라고 단정하지 말고 각각의 실패 조건을 분리해 확인합니다." + } + ], + "example": { + "title": "SecurityLab 기준 구현", + "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class SecurityLab {\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n // BEGIN EXERCISE\n http.authorizeHttpRequests(auth -> auth.requestMatchers(\"/orders\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n // END EXERCISE\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());\n }\n @RestController public static class Orders {\n @PostMapping(\"/orders\") public Map create() { return Map.of(\"status\",\"accepted\"); }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 미인증 401 · 역할 부족 403 · CSRF 누락 403 · 허용된 요청 200.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "security 메서드는 /orders에 BUYER 역할을 요구하고 HTTP Basic 인증과 기본 CSRF 보호를 켭니다. users는 BUYER인 buyer와 VIEWER인 viewer를 만듭니다. create는 보안 검사를 통과한 뒤에만 status=accepted를 반환합니다. 그래서 테스트는 미인증·역할 부족·토큰 누락을 따로 만들고, buyer와 유효한 토큰이 함께 있을 때 200을 확인합니다. 이 컨트롤러에는 실제 주문 저장이나 소유권 검사 코드가 없습니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/SecurityLab.java", + "language": "java", + "code": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class SecurityLab {\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n // BEGIN EXERCISE\n http.authorizeHttpRequests(auth -> auth.requestMatchers(\"/orders\").hasRole(\"BUYER\").anyRequest().authenticated())\n .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults());\n return http.build();\n // END EXERCISE\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());\n }\n @RestController public static class Orders {\n @PostMapping(\"/orders\") public Map create() { return Map.of(\"status\",\"accepted\"); }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsecurity-csrforg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframework.bootspring-boot-starter-web\norg.springframework.bootspring-boot-starter-security\norg.springframework.bootspring-boot-starter-testtest\norg.springframework.securityspring-security-testtest\n" + } + ] + }, + "quiz": [ + { + "id": "security-csrf-q1", + "question": "인증과 CSRF를 독립적으로 검사하려면?", + "options": [ + "둘 다 항상 끄고 컨트롤러만 검사한다.", + "로그인 성공만 확인하면 CSRF도 검증된다.", + "모든 오류 응답을 200으로 통일한다.", + "인증 검사는 유효한 CSRF와 함께, CSRF 검사는 인증된 요청으로 수행한다." + ], + "correctIndex": 3, + "explanation": "미인증 요청에 CSRF까지 없으면 CSRF 검사가 먼저 막아 인증 실패를 제대로 관찰하지 못할 수 있습니다. csrf()로 한 조건을 만족시킨 뒤 인증만 빼거나, buyer 인증을 준 뒤 CSRF만 빼야 무엇이 401·403을 만들었는지 알 수 있습니다." + }, + { + "id": "security-csrf-q2", + "question": "BUYER 역할이 있는 사용자가 다른 사람의 주문을 취소할 때 필요한 판단은?", + "options": [ + "역할만 맞으면 모든 주문을 허용한다.", + "주문의 소유권이나 위임 권한을 추가로 검사한다.", + "CSRF 토큰이 있으면 소유권 검사는 생략한다.", + "URL에 주문 ID가 있으면 권한이 있다고 본다." + ], + "correctIndex": 1, + "explanation": "hasRole(\"BUYER\")는 이 사용자가 구매자 역할인지 검사할 뿐 어느 주문의 주인인지는 모릅니다. 취소할 주문의 소유자와 인증된 사용자를 별도로 비교해야 합니다. CSRF 토큰은 이 소유권 판단을 대신하지 않습니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "POST /orders가 403을 반환했다는 문의를 받았습니다. 계정의 역할이 부족한 경우와 CSRF 토큰이 빠진 경우가 같은 코드로 보일 수 있으므로 재현 요청을 나누어 원인을 찾습니다.", + "tasks": [ + "csrf()만 붙이고 인증하지 않은 요청의 401을 확인할 때, CSRF 조건을 먼저 만족시키는 이유를 설명하세요.", + "viewer와 유효한 CSRF로 요청하면 users의 어느 역할 설정과 hasRole의 어떤 조건이 충돌하는지 찾으세요.", + "buyer로 인증해도 CSRF를 빼면 403인 이유, 둘 다 갖추면 200인 이유를 각각 말하세요.", + "training은 가상 계정의 로컬 테스트 비밀번호입니다. 실제 자격증명으로 바꾸지 말고, 운영 계정 저장·HTTPS·토큰 전달은 별도 설계 대상으로 적으세요.", + "이번 검사가 URL의 역할 권한까지라는 점을 적고, 다른 사람의 주문 취소를 막으려면 필요한 소유권 검사를 통합 과제와 연결하세요." + ], + "takeaway": "401·403만 보고 원인을 단정하지 않습니다. 인증, 역할, CSRF를 한 조건씩 바꾼 요청으로 확인합니다." + }, + "sources": [ + { + "title": "Spring Security CSRF", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/exploits/csrf.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "In-memory authentication", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/authentication/passwords/in-memory.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring Security" + ], + "exerciseId": "security-csrf-project", + "professionalExercise": { + "id": "security-csrf-project", + "profileId": "professional-security", + "mode": "project", + "instructions": [ + "POST /orders는 BUYER 역할만 허용하고 CSRF 토큰도 요구합니다.", + "유효한 CSRF와 미인증 요청은 401, 인증했지만 CSRF가 없으면 403입니다.", + "VIEWER는 CSRF가 유효해도 403, BUYER와 유효 CSRF는 200입니다.", + "training 비밀번호는 이 로컬 테스트의 가상 계정 값이며 실제 자격증명을 사용하지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/SecurityLab.java" + ], + "template": "package edu.lab;\nimport java.util.Map;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.security.config.Customizer;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.core.userdetails.*;\nimport org.springframework.security.provisioning.InMemoryUserDetailsManager;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.bind.annotation.*;\n@SpringBootApplication\npublic class SecurityLab {\n @Bean SecurityFilterChain security(HttpSecurity http) throws Exception {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n @Bean UserDetailsService users() {\n return new InMemoryUserDetailsManager(\n User.withUsername(\"buyer\").password(\"{noop}training\").roles(\"BUYER\").build(),\n User.withUsername(\"viewer\").password(\"{noop}training\").roles(\"VIEWER\").build());\n }\n @RestController public static class Orders {\n @PostMapping(\"/orders\") public Map create() { return Map.of(\"status\",\"accepted\"); }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/SecurityLab.java", + "templatePath": "lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java", + "referencePath": "lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/security-csrf/starter/pom.xml", + "referencePath": "lessons/security-csrf/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/security-csrf/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "미인증 401", + "역할 부족 403", + "CSRF 누락 403", + "허용된 요청 200" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "json-validation" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "cad4dad0bbd2cd397625790a2880f107777c9222fcaf45fa9ef39664f99e47a4", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/security-csrf/reference/pom.xml b/curriculum/professional/lessons/security-csrf/reference/pom.xml new file mode 100644 index 0000000..76715b6 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/reference/pom.xml @@ -0,0 +1,6 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsecurity-csrforg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-security +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest diff --git a/curriculum/professional/lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java b/curriculum/professional/lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java new file mode 100644 index 0000000..19f16f4 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/reference/src/main/java/edu/lab/SecurityLab.java @@ -0,0 +1,28 @@ +package edu.lab; +import java.util.Map; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.bind.annotation.*; +@SpringBootApplication +public class SecurityLab { + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + // BEGIN EXERCISE + http.authorizeHttpRequests(auth -> auth.requestMatchers("/orders").hasRole("BUYER").anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()).csrf(Customizer.withDefaults()); + return http.build(); + // END EXERCISE + } + @Bean UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("buyer").password("{noop}training").roles("BUYER").build(), + User.withUsername("viewer").password("{noop}training").roles("VIEWER").build()); + } + @RestController public static class Orders { + @PostMapping("/orders") public Map create() { return Map.of("status","accepted"); } + } +} diff --git a/curriculum/professional/lessons/security-csrf/starter/pom.xml b/curriculum/professional/lessons/security-csrf/starter/pom.xml new file mode 100644 index 0000000..76715b6 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/starter/pom.xml @@ -0,0 +1,6 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsecurity-csrforg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframework.bootspring-boot-starter-web +org.springframework.bootspring-boot-starter-security +org.springframework.bootspring-boot-starter-testtest +org.springframework.securityspring-security-testtest diff --git a/curriculum/professional/lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java b/curriculum/professional/lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java new file mode 100644 index 0000000..577a233 --- /dev/null +++ b/curriculum/professional/lessons/security-csrf/starter/src/main/java/edu/lab/SecurityLab.java @@ -0,0 +1,24 @@ +package edu.lab; +import java.util.Map; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.*; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.bind.annotation.*; +@SpringBootApplication +public class SecurityLab { + @Bean SecurityFilterChain security(HttpSecurity http) throws Exception { + throw new UnsupportedOperationException("실습 구현 필요"); + } + @Bean UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("buyer").password("{noop}training").roles("BUYER").build(), + User.withUsername("viewer").password("{noop}training").roles("VIEWER").build()); + } + @RestController public static class Orders { + @PostMapping("/orders") public Map create() { return Map.of("status","accepted"); } + } +} diff --git a/curriculum/professional/lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..192e845 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,7 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void externalCallCrossesProxy() { var trace=new AopLab.TraceAspect(); AopLab.proxied(trace).reserve(); assertThat(trace.calls()).isEqualTo(1); } + @Test void selfInvocationDoesNotCrossProxy() { var trace=new AopLab.TraceAspect(); AopLab.proxied(trace).reserveTwice(); assertThat(trace.calls()).isZero(); } +} diff --git a/curriculum/professional/lessons/spring-aop/lesson.json b/curriculum/professional/lessons/spring-aop/lesson.json new file mode 100644 index 0000000..9e2028e --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/lesson.json @@ -0,0 +1,199 @@ +{ + "id": "spring-aop", + "title": "프록시·AOP 호출 경로의 반례", + "description": "reserve를 직접 부르면 기록되는데 reserveTwice 안의 두 호출은 기록되지 않습니다. 같은 메서드 이름이어도 프록시를 통과하는 경로가 다른 이유를 따라갑니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "호출 앞에서 추가 동작을 수행하는 프록시와 실제 Orders 객체를 구분한다.", + "프록시를 통한 reserve 호출에서 beforeReserve가 한 번 실행되는 이유를 설명한다.", + "reserveTwice 안의 reserve 호출 두 번이 calls를 늘리지 않는 이유를 설명한다." + ], + "theory": [ + { + "heading": "프록시가 가로채는 경계", + "text": "프록시는 호출자가 실제 대상 객체에 도달하기 전에 거치는 대신 호출해 주는 객체입니다. 여기서 경계는 “호출이 프록시로 들어오는 지점”을 뜻합니다. proxied는 new Orders()를 대상으로 AspectJProxyFactory를 만들고 TraceAspect를 붙인 뒤 프록시를 반환합니다. @Before가 가리키는 advice는 대상 메서드 앞에 추가할 동작이며 이 예제에서는 beforeReserve의 calls++입니다.\n\n호출자가 반환된 프록시의 reserve()를 부르면 이름 reserve에 맞는 규칙이 적용되어 beforeReserve가 먼저 실행됩니다. 반면 reserveTwice()는 규칙이 정한 이름에 해당하지 않습니다. 그 안에서 실행되는 reserve(); reserve();는 실제 Orders 객체 자신을 향하는 호출이라 프록시로 다시 들어가지 않습니다. 따라서 새 TraceAspect에서 시작한 reserveTwice 호출은 calls가 0입니다." + }, + { + "heading": "어노테이션보다 호출 경로", + "text": "“메서드에 표시를 붙였으니 어디서 불러도 적용된다”라고 읽으면 이 차이를 놓칩니다. 호출자가 가진 것이 new Orders()인지 proxied(trace)의 반환값인지, 대상 메서드 내부에서 다시 부르는지 확인해야 합니다. 이 예제는 프록시 방식의 Spring AOP를 사용하며 AspectJProxyFactory라는 이름 때문에 바이트코드 전체를 바꾸는 AspectJ weaving과 같은 방식이라고 생각하면 안 됩니다.\n\n실무에서 트랜잭션이나 실행 시간 측정을 붙일 때도 실제 호출 경로를 따라가야 합니다. 내부 호출에도 추가 처리가 필요하다면 그 역할을 별도 객체로 분리해 프록시를 거치도록 하는 설계를 검토할 수 있습니다. 자기 자신을 강제로 다시 주입하는 방식을 기본 해법으로 삼기 전에 객체의 역할이 지나치게 묶여 있지 않은지 살펴봅니다." + } + ], + "example": { + "title": "AopLab 기준 구현", + "code": "package edu.lab;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.springframework.aop.aspectj.annotation.AspectJProxyFactory;\npublic final class AopLab {\n public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n // BEGIN EXERCISE\n AspectJProxyFactory factory=new AspectJProxyFactory(new Orders());\n factory.addAspect(trace);\n return factory.getProxy();\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 외부 호출 advice · 자기 호출 우회 반례.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "proxied(trace)는 새 Orders 인스턴스를 그대로 돌려주지 않고 TraceAspect를 붙인 프록시를 반환합니다. 그 프록시의 reserve()를 호출하면 @Before의 이름 조건과 맞아 calls가 1 증가합니다. reserveTwice()는 이름 조건에 맞지 않고, 본문 안의 reserve 두 번도 대상 객체 내부 호출이라 프록시를 다시 통과하지 않습니다. 두 테스트는 각각 새 TraceAspect를 사용하므로 기대값이 1과 0입니다. reserve 본문이 비어 있는 이유는 예약 기능이 아니라 호출 경로를 관찰하기 위해서입니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/AopLab.java", + "language": "java", + "code": "package edu.lab;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.springframework.aop.aspectj.annotation.AspectJProxyFactory;\npublic final class AopLab {\n public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n // BEGIN EXERCISE\n AspectJProxyFactory factory=new AspectJProxyFactory(new Orders());\n factory.addAspect(trace);\n return factory.getProxy();\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-aoporg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframeworkspring-context\norg.aspectjaspectjweaver\n" + } + ] + }, + "quiz": [ + { + "id": "spring-aop-q1", + "question": "reserveTwice가 내부에서 this.reserve를 두 번 호출할 때 advice 횟수가 0인 이유는?", + "options": [ + "두 번 호출하면 advice가 자동 취소되기 때문이다.", + "메서드 이름에 Twice가 있으면 AOP가 제외하기 때문이다.", + "같은 대상의 자기 호출이 프록시를 다시 거치지 않기 때문이다.", + "모든 메서드가 final이기 때문이다." + ], + "correctIndex": 2, + "explanation": "reserveTwice 자체는 이름 reserve에 해당하지 않아 이 @Before의 대상이 아닙니다. 그 본문이 부르는 reserve 두 번은 같은 Orders 객체 안에서 바로 실행되어 프록시의 가로채는 지점으로 돌아오지 않습니다. 그래서 새 trace의 calls는 0으로 남습니다." + }, + { + "id": "spring-aop-q2", + "question": "이 예제의 프록시 동작을 구분하는 적절한 검사는?", + "options": [ + "프록시 생성에 예외가 없는지만 검사한다.", + "외부 reserve 호출과 내부 자기 호출을 각각 실행하여 advice 횟수를 비교한다.", + "대상 객체의 클래스 이름만 검사한다.", + "로그 출력 순서를 수동으로 기억한다." + ], + "correctIndex": 1, + "explanation": "프록시가 만들어졌어도 기대한 호출이 그 프록시를 거쳤는지는 별도 문제입니다. 기존 두 테스트처럼 새 trace로 외부 reserve를 실행해 1을 확인하고, 또 다른 새 trace로 reserveTwice를 실행해 0을 확인하면 경로 차이가 실제 관찰값으로 드러납니다." + } + ], + "practice": { + "title": "기록이 빠진 예약 호출의 경로 찾기", + "scenario": "개별 reserve 호출은 기록되는데 묶음 메서드 reserveTwice를 통해 실행하면 기록이 없습니다. 애너테이션 이름만 확인하는 대신 호출자가 가진 객체부터 대상 내부 호출까지 추적합니다.", + "tasks": [ + "proxied(TraceAspect)에서 AspectJProxyFactory에 Orders 대상을 넣고 addAspect 뒤 getProxy를 반환하세요.", + "새 trace로 외부 reserve 호출을 실행했을 때 beforeReserve가 한 번 호출되는 경로를 설명하세요.", + "다른 새 trace로 reserveTwice를 실행했을 때, 메서드 이름 조건과 대상 내부 호출을 각각 짚어 calls가 0인 이유를 설명하세요.", + "실제 기능에서 내부 호출도 기록해야 한다면 어느 역할을 별도 객체로 옮길지 설계해 보세요. 현재 빈 reserve 메서드의 테스트가 실제 예약이나 트랜잭션을 검증하는 것은 아닙니다." + ], + "takeaway": "추가 동작이 실행되는지 판단하려면 “어떤 애너테이션인가”와 함께 “호출이 어떤 객체를 통과했는가”를 봅니다. 외부 호출과 내부 호출을 따로 재현하면 빠진 처리를 찾기 쉽습니다." + }, + "sources": [ + { + "title": "Spring AOP proxying", + "url": "https://docs.spring.io/spring-framework/reference/6.2/core/aop/proxying.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "AspectJProxyFactory", + "url": "https://docs.spring.io/spring-framework/docs/6.2.x/javadoc-api/org/springframework/aop/aspectj/annotation/AspectJProxyFactory.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "spring-aop-project", + "professionalExercise": { + "id": "spring-aop-project", + "profileId": "professional-spring-aop", + "mode": "project", + "instructions": [ + "proxied(TraceAspect)를 AspectJProxyFactory로 조립하세요.", + "외부 reserve 호출은 advice를 한 번 거칩니다. reserveTwice의 내부 reserve 호출들은 이 프록시 advice를 거치지 않는 반례를 확인하세요." + ], + "editableFiles": [ + "src/main/java/edu/lab/AopLab.java" + ], + "template": "package edu.lab;\nimport org.aspectj.lang.annotation.Aspect;\nimport org.aspectj.lang.annotation.Before;\nimport org.springframework.aop.aspectj.annotation.AspectJProxyFactory;\npublic final class AopLab {\n public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/AopLab.java", + "templatePath": "lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java", + "referencePath": "lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/spring-aop/starter/pom.xml", + "referencePath": "lessons/spring-aop/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/spring-aop/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "외부 호출 advice", + "자기 호출 우회 반례" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "spring-di" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "203bd280bfe19ad5cbaef8117c8ab0d00b95d07fe7f1a1f2f5988a187b82bad9", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "reserve를 한 번 직접 호출하면 기록 횟수는 1입니다. 그런데 reserveTwice가 reserve를 두 번 실행해도 기록 횟수는 0입니다. 실행 횟수와 기록 횟수가 왜 다른지 읽어 봅니다.", + "question": "reserveTwice 안의 두 reserve 호출은 어느 객체를 향하며 프록시를 다시 통과할까요?", + "terms": [ + { + "term": "프록시", + "meaning": "대상 객체 앞에서 호출을 받아 추가 처리를 한 뒤 대상으로 전달하는 객체입니다. proxied의 getProxy() 반환값이 해당합니다." + }, + { + "term": "advice", + "meaning": "가로챈 호출에 덧붙이는 동작입니다. 이 예제의 beforeReserve는 calls를 1 늘립니다." + }, + { + "term": "pointcut", + "meaning": "어떤 호출에 추가 동작을 붙일지 고르는 조건입니다. execution(* reserve(..))는 이 예제에서 reserve를 고릅니다." + }, + { + "term": "자기 호출", + "meaning": "대상 객체의 메서드가 같은 객체의 다른 메서드를 바로 부르는 일입니다. reserveTwice 안의 reserve();가 그 예입니다." + } + ], + "walkthrough": [ + "proxied(trace)는 new Orders()를 대상으로 팩터리를 만들고 addAspect(trace)로 기록 동작을 등록합니다.", + "factory.getProxy()가 반환한 객체에 reserve()를 호출하면 프록시가 그 호출을 먼저 받습니다.", + "이름 reserve가 @Before의 조건에 맞으므로 beforeReserve가 calls를 늘린 뒤 실제 Orders.reserve가 실행됩니다.", + "reserveTwice()로 들어오면 메서드 이름이 조건과 다르므로 이 advice는 실행되지 않습니다. 본문은 실제 Orders 객체에서 실행됩니다.", + "본문 안의 reserve(); reserve();는 같은 대상 내부 호출입니다. 프록시를 다시 통과하지 않으므로 두 번 실행돼도 calls는 늘지 않습니다." + ], + "prediction": { + "question": "새 TraceAspect로 만든 같은 프록시에서 reserve()를 한 번, 이어서 reserveTwice()를 한 번 부르면 최종 calls()는 얼마인가요?", + "answer": "1입니다.", + "explanation": "첫 외부 reserve 호출에서만 beforeReserve가 실행됩니다. 이어지는 reserveTwice의 진입과 내부 reserve 두 번은 이 advice를 추가로 실행하지 않습니다. calls가 자동으로 초기화되는 것도 아닙니다." + }, + "takeaway": "대상 메서드의 실행 횟수와 advice의 실행 횟수는 다를 수 있습니다. 이 프록시 모델에서는 프록시를 거치는 진입을 기준으로 추가 처리를 판단합니다.", + "code": { + "file": "src/main/java/edu/lab/AopLab.java", + "source": " public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } }\n @Aspect public static class TraceAspect {\n private int calls;\n @Before(\"execution(* reserve(..))\") public void beforeReserve() { calls++; }\n public int calls() { return calls; }\n }\n public static Orders proxied(TraceAspect trace) {\n // BEGIN EXERCISE\n AspectJProxyFactory factory=new AspectJProxyFactory(new Orders());\n factory.addAspect(trace);\n return factory.getProxy();\n // END EXERCISE\n }\n}\n" + } + } +} diff --git a/curriculum/professional/lessons/spring-aop/reference/pom.xml b/curriculum/professional/lessons/spring-aop/reference/pom.xml new file mode 100644 index 0000000..d013602 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-aoporg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context +org.aspectjaspectjweaver diff --git a/curriculum/professional/lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java b/curriculum/professional/lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java new file mode 100644 index 0000000..a762811 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/reference/src/main/java/edu/lab/AopLab.java @@ -0,0 +1,19 @@ +package edu.lab; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; +public final class AopLab { + public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } } + @Aspect public static class TraceAspect { + private int calls; + @Before("execution(* reserve(..))") public void beforeReserve() { calls++; } + public int calls() { return calls; } + } + public static Orders proxied(TraceAspect trace) { + // BEGIN EXERCISE + AspectJProxyFactory factory=new AspectJProxyFactory(new Orders()); + factory.addAspect(trace); + return factory.getProxy(); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/spring-aop/starter/pom.xml b/curriculum/professional/lessons/spring-aop/starter/pom.xml new file mode 100644 index 0000000..d013602 --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-aoporg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context +org.aspectjaspectjweaver diff --git a/curriculum/professional/lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java b/curriculum/professional/lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java new file mode 100644 index 0000000..9a88b1d --- /dev/null +++ b/curriculum/professional/lessons/spring-aop/starter/src/main/java/edu/lab/AopLab.java @@ -0,0 +1,15 @@ +package edu.lab; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; +public final class AopLab { + public static class Orders { public void reserve() {} public void reserveTwice() { reserve(); reserve(); } } + @Aspect public static class TraceAspect { + private int calls; + @Before("execution(* reserve(..))") public void beforeReserve() { calls++; } + public int calls() { return calls; } + } + public static Orders proxied(TraceAspect trace) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..c6b8178 --- /dev/null +++ b/curriculum/professional/lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,8 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void assemblesAndSharesDefaultBean() { try(var context=new AnnotationConfigApplicationContext(DiLab.Config.class)){ var service=context.getBean(DiLab.OrderService.class); assertThat(service.total(29999)).isEqualTo(32999); assertThat(service.total(30000)).isEqualTo(30000); assertThat(service).isSameAs(context.getBean(DiLab.OrderService.class)); } } + @Test void domainChecksRemainInService() { assertThatIllegalArgumentException().isThrownBy(()->new DiLab.OrderService(total->0).total(-1)); } +} diff --git a/curriculum/professional/lessons/spring-di/lesson.json b/curriculum/professional/lessons/spring-di/lesson.json new file mode 100644 index 0000000..a39903b --- /dev/null +++ b/curriculum/professional/lessons/spring-di/lesson.json @@ -0,0 +1,201 @@ +{ + "id": "spring-di", + "title": "Spring IoC·DI와 객체 수명", + "description": "주문 서비스에 배송비 계산기를 누가 만들고 전달하는지 따라갑니다. 29,999원과 30,000원 주문을 계산하며 Spring의 객체 조립과 서비스의 금액 규칙을 구분합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "Config.policy의 반환 객체가 OrderService 생성자까지 전달되는 경로를 설명한다.", + "Spring이 PricePolicy와 OrderService를 조립하는 부분을 찾는다.", + "total(29999)와 total(30000)의 배송비·최종 금액을 계산한다.", + "같은 컨테이너에서 공유하는 singleton 빈과 요청별 값을 구분한다." + ], + "theory": [ + { + "heading": "IoC와 생성자 주입", + "text": "OrderService는 배송비를 계산할 PricePolicy가 필요합니다. 생성자의 policy 인수로 필요한 객체를 밖에서 받는 것이 생성자 주입입니다. 이 예제에서 Config.policy()는 “소계 30000 이상이면 0, 미만이면 3000”을 반환하는 정책 객체를 만들고, Spring은 이 객체를 Config.orders(PricePolicy policy)의 인수로 전달합니다. orders는 new OrderService(policy)로 둘을 연결합니다. Spring이 이런 생성·연결을 관리하는 방식을 IoC라고 부르고, 관리 대상 객체를 빈(bean)이라고 부릅니다.\n\n객체가 연결됐다고 모든 주문이 유효해지는 것은 아닙니다. total(-1)을 거절하는 규칙은 OrderService.total에 남아 있습니다. 생성자가 policy를 받으므로 기존 테스트처럼 배송비 0인 대체 정책을 전달해 Spring 없이도 이 음수 검사를 확인할 수 있습니다." + }, + { + "heading": "스코프와 가변 상태", + "text": "기본 singleton 스코프는 같은 컨테이너의 같은 빈 정의를 조회할 때 한 객체를 공유한다는 뜻입니다. 기존 테스트가 OrderService를 getBean으로 두 번 조회하고 isSameAs로 비교하는 이유입니다. JVM 전체에 OrderService 하나만 존재한다는 의미는 아닙니다.\n\n공유 객체의 필드에 “현재 주문의 소계”를 저장하면 동시에 들어온 두 주문이 같은 필드를 덮어쓸 수 있습니다. 여기서는 subtotal을 total의 매개변수로 받고 요청값을 필드에 저장하지 않습니다. final policy는 정책 참조를 고정할 뿐, 전달된 정책 객체의 내부 변경까지 금지하지는 않습니다. singleton 설정 자체가 동시 접근을 안전하게 만들어 주는 것은 아니므로 정책의 상태도 함께 살펴야 합니다." + } + ], + "example": { + "title": "DiLab 기준 구현", + "code": "package edu.lab;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\npublic final class DiLab {\n public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n // BEGIN EXERCISE\n return new OrderService(policy);\n // END EXERCISE\n }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 컨테이너 조립 · 정책 계약 · 기본 singleton 확인.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "먼저 Config.policy()가 배송비 규칙을 담은 PricePolicy를 만듭니다. Spring이 orders의 policy 인수로 그 객체를 전달하면 new OrderService(policy)가 필드에 보관합니다. total(29999)은 policy.fee에서 3000을 받아 32999를 반환하고, total(30000)은 배송비 0을 더해 30000을 반환합니다. subtotal < 0 검사는 서비스가 직접 맡습니다. 기존 테스트는 이 계산과 동일 컨테이너에서의 OrderService 공유를 각각 확인합니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/DiLab.java", + "language": "java", + "code": "package edu.lab;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\npublic final class DiLab {\n public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n // BEGIN EXERCISE\n return new OrderService(policy);\n // END EXERCISE\n }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-diorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframeworkspring-context\n" + } + ] + }, + "quiz": [ + { + "id": "spring-di-q1", + "question": "생성자 주입의 실질적 장점은?", + "options": [ + "의존 객체가 자동으로 스레드 안전해진다.", + "객체가 필요한 협력을 생성 시점에 드러내고 테스트에서 대체하기 쉽다.", + "모든 의존성에 인터페이스가 자동 생성된다.", + "순환 의존성이 모두 올바른 구조로 바뀐다." + ], + "correctIndex": 1, + "explanation": "OrderService(PricePolicy policy)를 보면 이 서비스가 가격 정책 없이는 만들어질 수 없다는 점이 드러납니다. 테스트에서는 배송비 0인 정책을 직접 전달할 수 있습니다. 이렇게 연결을 바꾸기 쉬워져도 그 정책의 가변 상태나 동시 접근까지 생성자가 해결하지는 않습니다." + }, + { + "id": "spring-di-q2", + "question": "Spring의 기본 singleton scope에 대한 설명으로 맞는 것은?", + "options": [ + "전체 JVM의 모든 컨테이너가 항상 같은 인스턴스를 공유한다.", + "해당 컨테이너의 빈 정의별 공유 인스턴스이며 스레드 안전성은 별도다.", + "요청마다 항상 새 객체를 만든다.", + "필드가 자동으로 불변이 된다." + ], + "correctIndex": 1, + "explanation": "같은 AnnotationConfigApplicationContext에서 같은 OrderService 빈을 두 번 조회하면 기본 스코프에서는 같은 객체를 받습니다. 다른 컨테이너의 객체까지 같은 것은 아닙니다. 또한 같은 객체를 공유한다는 것은 필드도 공유한다는 뜻이므로 요청별 값을 필드에 넣을지는 별도로 설계해야 합니다." + } + ], + "practice": { + "title": "배송비 정책을 받아 주문 금액 계산하기", + "scenario": "주문 서비스는 소계에 배송비를 더합니다. 실제 설정에서는 30,000원부터 무료 배송을 적용하고, 서비스 자체의 입력 검사는 Spring을 띄우지 않고도 확인하려 합니다.", + "tasks": [ + "Config의 @Bean 메서드에서 PricePolicy를 받아 new OrderService(policy)로 연결하세요. Spring이 전달하는 곳과 생성자가 보관하는 곳을 구분하세요.", + "소계 29999와 30000에서 각각의 배송비와 total 결과를 계산해 기존 테스트와 대조하세요.", + "기존 domainChecksRemainInService가 대체 정책으로 OrderService를 직접 만든 뒤 total(-1)을 검사하는 이유를 설명하세요.", + "현재 subtotal이 매개변수인 점을 보고, 이 값을 공유 빈의 필드에 저장할 때 두 주문이 서로 덮어쓸 수 있는 상황을 설명하세요. 기존 singleton 검사는 동시성 검사가 아닙니다." + ], + "takeaway": "Spring은 필요한 객체를 만들어 연결하고, OrderService는 받은 정책을 사용해 주문 규칙을 지킵니다. 누가 객체를 조립하는지와 누가 값을 검사하는지 나눠 읽으면 테스트와 수명 문제가 분명해집니다." + }, + "sources": [ + { + "title": "Spring IoC container", + "url": "https://docs.spring.io/spring-framework/reference/6.2/core/beans.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring bean scopes", + "url": "https://docs.spring.io/spring-framework/reference/6.2/core/beans/factory-scopes.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "spring-di-project", + "professionalExercise": { + "id": "spring-di-project", + "profileId": "professional-spring-core", + "mode": "project", + "instructions": [ + "Config의 @Bean 메서드로 PricePolicy와 OrderService를 조립하세요.", + "PricePolicy는 소계 30000 이상 배송비 0, 미만 3000입니다. OrderService는 생성자로 정책을 받습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/DiLab.java" + ], + "template": "package edu.lab;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\npublic final class DiLab {\n public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/DiLab.java", + "templatePath": "lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java", + "referencePath": "lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/spring-di/starter/pom.xml", + "referencePath": "lessons/spring-di/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/spring-di/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "컨테이너 조립", + "정책 계약", + "기본 singleton 확인" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "test-collaborators" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "092bd10703609e7a73d032f092ed2ace7c53fdf37819c64a4c41504b156ed844", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "배송비 계산이 여러 서비스에 흩어져 정책을 바꾸기 어렵습니다. OrderService에는 계산기를 전달하고 객체 연결은 Config에 모으려 합니다.", + "question": "Config에서 만든 PricePolicy가 어떤 순서로 OrderService.total의 계산에 쓰일까요?", + "terms": [ + { + "term": "주입(DI)", + "meaning": "필요한 객체를 외부에서 전달하는 방식입니다. OrderService가 내부에서 정책을 만들지 않고 생성자의 policy로 받습니다." + }, + { + "term": "빈과 컨테이너", + "meaning": "빈은 Spring이 생성·관리하는 객체이고 컨테이너는 그 객체들을 만들고 연결하는 관리 기능입니다. 여기서는 PricePolicy와 OrderService가 빈입니다." + }, + { + "term": "IoC", + "meaning": "이 예제에서는 객체 생성과 연결을 각 서비스 대신 Spring에 맡기는 뜻입니다. Config의 @Bean 메서드가 연결 방법을 제공합니다." + }, + { + "term": "singleton 스코프", + "meaning": "같은 컨테이너에서 한 빈 정의의 인스턴스를 공유하는 규칙입니다. getBean으로 OrderService를 다시 찾아도 같은 객체를 받습니다." + } + ], + "walkthrough": [ + "Config.policy()가 PricePolicy를 만듭니다. 이 정책의 fee는 subtotal이 30000 이상이면 0, 그보다 작으면 3000을 돌려줍니다.", + "Spring은 orders(PricePolicy policy)를 호출할 때 관리 중인 PricePolicy를 인수로 전달합니다.", + "orders의 new OrderService(policy)가 서비스 생성자를 호출하고, 생성자는 그 객체를 this.policy에 저장합니다.", + "OrderService.total(29999)은 음수 검사를 통과한 다음 policy.fee(29999)의 3000을 Math.addExact로 더해 32999를 반환합니다.", + "같은 서비스의 total(30000)은 배송비 0을 더합니다. 다른 요청의 subtotal은 매개변수로 전달되며 서비스 필드에 누적되지 않습니다." + ], + "prediction": { + "question": "기본 Config로 조립한 서비스에서 total(29999)와 total(30000)은 각각 얼마인가요?", + "answer": "32999와 30000입니다.", + "explanation": "무료 배송 조건은 subtotal >= 30000입니다. 기준에 1원 모자란 첫 주문에는 3000이 붙고 두 번째 주문에는 붙지 않습니다. Spring은 정책 객체를 전달하며 이 판단은 fee에서 합니다." + }, + "takeaway": "주입이라는 말은 이 코드에서 “PricePolicy를 생성자에 전달한다”는 구체적인 동작입니다. 전달받은 뒤의 금액 검사와 계산은 서비스 코드에서 확인합니다.", + "code": { + "file": "src/main/java/edu/lab/DiLab.java", + "source": " public interface PricePolicy { int fee(int subtotal); }\n public static final class OrderService {\n private final PricePolicy policy;\n public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); }\n public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); }\n }\n @Configuration\n public static class Config {\n @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; }\n @Bean public OrderService orders(PricePolicy policy) {\n // BEGIN EXERCISE\n return new OrderService(policy);\n // END EXERCISE\n }\n }\n}\n" + } + } +} diff --git a/curriculum/professional/lessons/spring-di/reference/pom.xml b/curriculum/professional/lessons/spring-di/reference/pom.xml new file mode 100644 index 0000000..8e33048 --- /dev/null +++ b/curriculum/professional/lessons/spring-di/reference/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-diorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context diff --git a/curriculum/professional/lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java b/curriculum/professional/lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java new file mode 100644 index 0000000..1ff1dae --- /dev/null +++ b/curriculum/professional/lessons/spring-di/reference/src/main/java/edu/lab/DiLab.java @@ -0,0 +1,20 @@ +package edu.lab; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +public final class DiLab { + public interface PricePolicy { int fee(int subtotal); } + public static final class OrderService { + private final PricePolicy policy; + public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); } + public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); } + } + @Configuration + public static class Config { + @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; } + @Bean public OrderService orders(PricePolicy policy) { + // BEGIN EXERCISE + return new OrderService(policy); + // END EXERCISE + } + } +} diff --git a/curriculum/professional/lessons/spring-di/starter/pom.xml b/curriculum/professional/lessons/spring-di/starter/pom.xml new file mode 100644 index 0000000..8e33048 --- /dev/null +++ b/curriculum/professional/lessons/spring-di/starter/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlspring-diorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context diff --git a/curriculum/professional/lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java b/curriculum/professional/lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java new file mode 100644 index 0000000..0e1f9df --- /dev/null +++ b/curriculum/professional/lessons/spring-di/starter/src/main/java/edu/lab/DiLab.java @@ -0,0 +1,18 @@ +package edu.lab; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +public final class DiLab { + public interface PricePolicy { int fee(int subtotal); } + public static final class OrderService { + private final PricePolicy policy; + public OrderService(PricePolicy policy) { this.policy=java.util.Objects.requireNonNull(policy); } + public int total(int subtotal) { if(subtotal<0) throw new IllegalArgumentException(); return Math.addExact(subtotal,policy.fee(subtotal)); } + } + @Configuration + public static class Config { + @Bean public PricePolicy policy() { return subtotal -> subtotal>=30000 ? 0 : 3000; } + @Bean public OrderService orders(PricePolicy policy) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + } +} diff --git a/curriculum/professional/lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..e129355 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,21 @@ +package edu.lab; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + JdbcReservations service; JdbcTemplate jdbc; + @BeforeEach void setup() { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + jdbc = new JdbcTemplate(ds); + jdbc.execute("create table inventory(sku varchar(30) primary key, stock int not null check(stock >= 0))"); + jdbc.execute("create table orders(id varchar(60) primary key, quantity int not null)"); + jdbc.update("insert into inventory values('BOOK', 5)"); service = new JdbcReservations(ds); + } + int stock() { return jdbc.queryForObject("select stock from inventory where sku='BOOK'", Integer.class); } + @Test void commitsTogether() throws Exception { assertThat(service.reserve("A", 2)).isTrue(); assertThat(stock()).isEqualTo(3); assertThat(jdbc.queryForObject("select count(*) from orders", Integer.class)).isEqualTo(1); } + @Test void insufficientPreservesState() throws Exception { assertThat(service.reserve("A", 6)).isFalse(); assertThat(stock()).isEqualTo(5); } + @Test void duplicateRollsBackStock() throws Exception { service.reserve("A", 2); assertThatThrownBy(() -> service.reserve("A", 1)).isInstanceOf(java.sql.SQLException.class); assertThat(stock()).isEqualTo(3); } + @Test void invalidInput() { assertThatThrownBy(() -> service.reserve("A", 0)).isInstanceOf(IllegalArgumentException.class); assertThat(stock()).isEqualTo(5); } +} diff --git a/curriculum/professional/lessons/sql-jdbc/lesson.json b/curriculum/professional/lessons/sql-jdbc/lesson.json new file mode 100644 index 0000000..48b444f --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/lesson.json @@ -0,0 +1,220 @@ +{ + "id": "sql-jdbc", + "title": "SQL·JDBC로 재고 변경과 롤백", + "description": "재고를 먼저 줄인 뒤 주문 저장이 실패하면 줄인 재고도 되돌려야 합니다. 같은 Connection의 UPDATE·INSERT·commit·rollback을 따라 두 변경이 함께 남는 이유를 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "재고 감소 뒤 주문 INSERT가 실패하는 상황에서 남아야 할 DB 값을 설명한다.", + "재고 5에서 2개 예약이 성공하면 재고 3과 주문 한 행이 함께 저장되게 한다.", + "조건부 UPDATE의 영향 행 수로 재고 부족을 판단하고 기존 상태를 보존한다.", + "중복 orderId 실패가 앞선 재고 감소까지 롤백하는 경로를 설명한다.", + "잘못된 입력을 DB 연결 전에 거절하고 값 바인딩과 SQL 문자열 조합을 구분한다." + ], + "theory": [ + { + "heading": "불변식은 DB 변경 조건에 남긴다", + "text": "지켜야 할 규칙, 즉 불변식은 “재고가 음수가 되지 않고, 예약이 성공했다면 주문 기록과 감소된 재고가 함께 남는다”입니다. 먼저 stock을 SELECT하고 자바에서 충분하다고 판단한 뒤 UPDATE하면 그 사이 다른 요청이 재고를 바꿀 수 있습니다. 이 예제는 stock >= ?를 UPDATE 조건에 넣어, DB가 조건을 만족하는 행의 재고만 줄이게 합니다.\n\nexecuteUpdate가 돌려준 changed는 영향을 받은 행 수입니다. 테스트의 inventory는 sku가 기본 키이므로 BOOK 행은 최대 한 건입니다. changed가 0이면 조건에 맞는 행이 없다는 뜻이며, 준비된 BOOK 행에서 재고가 부족한 경우가 해당합니다. 코드는 rollback 후 false를 반환합니다. 이 방식의 동작을 운영 DB에 적용할 때는 해당 DB의 잠금·격리 수준도 확인해야 하며 여기서 모든 DB의 동시 예약을 검증한 것은 아닙니다." + }, + { + "heading": "자원 수명과 실패 소유권", + "text": "트랜잭션은 여러 변경을 함께 확정하거나 함께 되돌리는 단위입니다. setAutoCommit(false) 뒤 같은 connection으로 재고 UPDATE와 orders INSERT를 실행하고, 둘 다 성공해야 commit합니다. 테스트처럼 재고 5에서 A 주문 2개를 예약하면 3이 됩니다. 이어 A로 1개를 또 예약하면 UPDATE는 잠시 2로 줄이지만 중복 기본 키로 INSERT가 실패합니다. catch에서 같은 연결을 rollback하면 두 번째 시도의 재고 감소가 취소되어 3으로 돌아갑니다.\n\nConnection과 두 PreparedStatement는 try-with-resources로 사용 후 닫습니다. 실패 처리 중 rollback도 실패하면 원래 failure에 suppressed 예외로 덧붙여 처음 원인을 보존합니다. commit 도중 통신이 끊긴 경우에는 호출자에게 예외가 왔다고 DB가 반드시 미커밋이라고 단정할 수 없습니다. 이 코드는 롤백을 시도하고 오류를 전달하며, 확정 여부 조회·재시도 정책까지 제공하지는 않습니다." + }, + { + "heading": "바인딩과 값의 의미", + "text": "PreparedStatement의 ?는 SQL 문법을 넣는 자리가 아니라 값을 전달하는 자리입니다. UPDATE의 첫 번째 ?에는 줄일 quantity를, 두 번째 ?에는 최소 필요 재고인 같은 quantity를 setInt로 넣습니다. INSERT에는 setString으로 orderId, setInt로 quantity를 넣습니다. 이렇게 쿼리 형태와 입력값을 구분합니다.\n\n테이블 이름이나 ORDER BY 방향을 사용자 입력으로 선택하려는 상황은 다릅니다. ?에 테이블 이름을 값으로 넣어 SQL 구조를 바꿀 수 없으므로 허용된 쿼리 중에서 선택하는 별도 설계가 필요합니다. 이 예제는 BOOK 한 품목과 고정된 두 테이블만 사용하며, 실행계획이나 운영 데이터 규모의 성능을 측정한 예제가 아닙니다." + } + ], + "example": { + "title": "JdbcReservations 기준 구현", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\nimport java.util.Objects;\npublic final class JdbcReservations {\n private final DataSource dataSource;\n public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); }\n public boolean reserve(String orderId, int quantity) throws SQLException {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid reservation\");\n try (Connection connection = dataSource.getConnection()) {\n connection.setAutoCommit(false);\n try {\n int changed;\n try (PreparedStatement update = connection.prepareStatement(\"update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?\")) {\n update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate();\n }\n if (changed == 0) { connection.rollback(); return false; }\n try (PreparedStatement insert = connection.prepareStatement(\"insert into orders(id, quantity) values (?, ?)\")) {\n insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate();\n }\n connection.commit(); return true;\n } catch (SQLException | RuntimeException failure) {\n try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); }\n throw failure;\n }\n }\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 4개 JUnit 테스트 통과.\n검증 항목: 성공 시 두 변경 커밋 · 재고 부족 상태 보존 · 중복 키 실패 재고 롤백 · 잘못된 입력 거절.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "reserve는 orderId·quantity를 먼저 검사하고 하나의 connection에서 자동 커밋을 끕니다. 조건부 UPDATE가 재고를 줄이고 changed가 0이면 주문 INSERT 없이 false로 끝납니다. 1이면 같은 연결로 주문을 넣고 마지막에 commit해 true를 반환합니다. 중복 주문 ID 등으로 INSERT가 실패하면 catch가 앞선 UPDATE까지 rollback하고 예외를 다시 던집니다. 테스트의 재고 5에서 A·2개 예약 성공 후 A·1개 예약이 실패하면 최종 재고는 3입니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/JdbcReservations.java", + "language": "java", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\nimport java.util.Objects;\npublic final class JdbcReservations {\n private final DataSource dataSource;\n public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); }\n public boolean reserve(String orderId, int quantity) throws SQLException {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || quantity <= 0) throw new IllegalArgumentException(\"invalid reservation\");\n try (Connection connection = dataSource.getConnection()) {\n connection.setAutoCommit(false);\n try {\n int changed;\n try (PreparedStatement update = connection.prepareStatement(\"update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?\")) {\n update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate();\n }\n if (changed == 0) { connection.rollback(); return false; }\n try (PreparedStatement insert = connection.prepareStatement(\"insert into orders(id, quantity) values (?, ?)\")) {\n insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate();\n }\n connection.commit(); return true;\n } catch (SQLException | RuntimeException failure) {\n try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); }\n throw failure;\n }\n }\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsql-jdbcorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframeworkspring-jdbc\ncom.h2databaseh2\n" + } + ] + }, + "quiz": [ + { + "id": "sql-jdbc-q1", + "question": "재고 UPDATE 뒤 예약 INSERT가 실패했다. 과제의 올바른 동작은?", + "options": [ + "재고는 줄인 채 false만 반환한다.", + "같은 연결의 트랜잭션을 롤백해 재고도 복원한다.", + "INSERT만 재시도하면 모든 중복이 해결된다.", + "예외를 무시하고 커밋한다." + ], + "correctIndex": 1, + "explanation": "재고 5에서 A·2개 예약을 성공시켜 3이 된 뒤, 같은 A로 1개를 다시 예약하면 UPDATE 직후 재고는 아직 확정되지 않은 2입니다. INSERT의 중복 키 실패 때 같은 connection을 rollback해야 그 시도만 취소되어 3으로 복원됩니다. false만 반환하거나 예외를 삼키면 이 두 변경을 함께 취급한다는 규칙이 깨집니다." + }, + { + "id": "sql-jdbc-q2", + "question": "PreparedStatement의 ?로 직접 바인딩할 수 있는 것은?", + "options": [ + "테이블 이름", + "ORDER BY의 ASC 키워드", + "검색할 orderId 값", + "SQL 전체 문장" + ], + "correctIndex": 2, + "explanation": "INSERT의 첫 번째 ?에 setString(1, orderId)을 쓰는 것처럼 검색·저장할 값은 바인딩할 수 있습니다. 테이블 이름과 ASC는 쿼리의 구조를 정하는 문법이므로 값 파라미터로 바꾸지 않습니다. 구조를 바꿔야 한다면 허용된 쿼리 구성을 별도로 고릅니다." + } + ], + "practice": { + "title": "주문 저장 실패 뒤 재고가 남는 값 확인하기", + "scenario": "BOOK 재고는 5개입니다. A 주문으로 2개를 예약한 뒤 같은 ID로 1개를 다시 예약해 INSERT가 실패합니다. 두 번째 실패가 첫 예약까지 취소하거나 재고만 추가로 줄이지 않아야 합니다.", + "tasks": [ + "reserve의 quantity 양수·orderId 비어 있지 않음 검사를 getConnection 전에 수행하세요.", + "조건부 UPDATE의 changed가 0일 때 false로 끝내고, 성공일 때 주문 한 행과 줄어든 재고를 같은 connection에서 커밋하세요.", + "기존 duplicateRollsBackStock에서 A·2개 성공 뒤 A·1개 실패가 최종 재고 3을 남기는 순서를 UPDATE·INSERT·rollback으로 설명하세요.", + "orders와 inventory는 테스트가 생성한 전용 H2 DB만 사용합니다. 이 테스트의 DB와 실제 운영 DB의 잠금·격리·연결 풀 설정을 같은 것으로 보지 마세요.", + "rollback 실패를 suppressed로 보존하는 이유와 commit 통신 오류 뒤 무조건 재시도할 수 없는 이유를 적으세요. 현재 테스트에는 연결 단절·장기 부하·운영 DB 실행계획 검증이 없습니다." + ], + "takeaway": "한 예약의 성공은 true만이 아니라 주문 행과 재고가 함께 남는 상태입니다. 실패한 두 번째 SQL을 따라가며 첫 번째 SQL의 변경도 같은 연결에서 되돌리는지 확인합니다." + }, + "sources": [ + { + "title": "JDBC Connection", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.sql/java/sql/Connection.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + }, + { + "title": "JDBC PreparedStatement", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.sql/java/sql/PreparedStatement.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring JDBC core", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/jdbc/core.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "SQL", + "JDBC", + "Transaction" + ], + "exerciseId": "sql-jdbc-project", + "professionalExercise": { + "id": "sql-jdbc-project", + "profileId": "professional-jdbc", + "mode": "project", + "instructions": [ + "reserve(orderId, quantity)의 quantity는 양수이고 orderId는 비어 있지 않습니다.", + "재고 부족이면 false, 성공이면 true이며 한 예약 행과 감소된 재고가 함께 커밋됩니다.", + "중복 orderId로 INSERT가 실패하면 재고 감소도 롤백되어야 합니다.", + "orders와 inventory는 테스트가 생성한 전용 H2 DB만 사용합니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/JdbcReservations.java" + ], + "template": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.sql.*;\nimport java.util.Objects;\npublic final class JdbcReservations {\n private final DataSource dataSource;\n public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); }\n public boolean reserve(String orderId, int quantity) throws SQLException {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/JdbcReservations.java", + "templatePath": "lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java", + "referencePath": "lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/sql-jdbc/starter/pom.xml", + "referencePath": "lessons/sql-jdbc/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/sql-jdbc/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "성공 시 두 변경 커밋", + "재고 부족 상태 보존", + "중복 키 실패 재고 롤백", + "잘못된 입력 거절" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "test-contracts", + "core:exceptions" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 4, + "starterSatisfied": false, + "sourceFingerprint": "408fed487770c066b90e1e7c078dc734c57e66818a2bfeb14f65d66372dc02ba", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "BOOK 재고 5에서 A 주문 2개를 예약해 재고가 3이 됐습니다. 같은 주문 ID로 1개를 더 예약하면 주문 저장은 중복 키로 실패합니다. 이때 재고 2가 남으면 주문 기록 없는 감소가 생깁니다.", + "question": "INSERT에서 실패했는데 앞서 실행한 UPDATE의 감소까지 어떻게 취소할까요?", + "terms": [ + { + "term": "트랜잭션", + "meaning": "변경을 함께 확정하거나 함께 취소하는 단위입니다. 이 reserve는 같은 connection에서 재고 감소와 주문 INSERT를 묶습니다." + }, + { + "term": "자동 커밋", + "meaning": "SQL 문장마다 변경을 자동 확정하는 연결 모드입니다. setAutoCommit(false)로 끄고 두 SQL 뒤 직접 commit합니다." + }, + { + "term": "영향 행 수", + "meaning": "UPDATE로 영향을 받은 행의 수입니다. changed가 0이면 BOOK과 재고 조건을 만족한 행이 없어서 주문을 넣지 않습니다." + }, + { + "term": "롤백", + "meaning": "아직 확정하지 않은 현재 트랜잭션의 변경을 되돌리는 일입니다. 이 코드의 rollback은 실패한 두 번째 예약의 재고 감소도 취소합니다." + } + ], + "walkthrough": [ + "reserve의 앞부분은 잘못된 orderId·quantity를 거절합니다. getConnection 뒤 setAutoCommit(false)를 호출해 문장마다 확정되지 않게 합니다.", + "UPDATE의 두 ?에 quantity를 넣습니다. 재고가 충분한 BOOK 한 행의 stock을 줄이고 executeUpdate의 행 수를 changed에 저장합니다.", + "changed가 0이면 rollback 후 false로 끝냅니다. 주문을 INSERT하는 구간까지 내려가지 않습니다.", + "재고 감소가 성공하면 같은 connection으로 orders에 orderId와 quantity를 넣습니다. 성공해야 commit하고 true를 반환합니다.", + "이미 A가 있어 INSERT가 실패하면 catch의 failure가 그 예외를 받습니다. rollback이 두 번째 예약의 UPDATE도 되돌리고, throw failure가 호출자에게 실패를 알립니다.", + "rollback 자체의 SQLException은 failure.addSuppressed에 보관합니다. 처음 INSERT가 왜 실패했는지와 뒤처리 실패를 함께 살필 수 있습니다." + ], + "prediction": { + "question": "재고 5에서 reserve(\"A\", 2)가 성공한 다음 reserve(\"A\", 1)이 중복 키로 실패하고 롤백에 성공했습니다. 최종 재고는 2, 3, 5 중 얼마인가요?", + "answer": "3입니다.", + "explanation": "첫 호출의 재고 3은 이미 커밋됐습니다. 두 번째 호출의 3 → 2 변경만 그 호출의 트랜잭션에 속하므로 롤백하면 3으로 돌아갑니다." + }, + "takeaway": "두 SQL의 순서만 맞춰서는 충분하지 않습니다. 같은 연결에서 어디서 확정하고 어디서 되돌리는지를 따라가야 실패 뒤 DB 상태를 설명할 수 있습니다.", + "code": { + "file": "src/main/java/edu/lab/JdbcReservations.java", + "source": " connection.setAutoCommit(false);\n try {\n int changed;\n try (PreparedStatement update = connection.prepareStatement(\"update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?\")) {\n update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate();\n }\n if (changed == 0) { connection.rollback(); return false; }\n try (PreparedStatement insert = connection.prepareStatement(\"insert into orders(id, quantity) values (?, ?)\")) {\n insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate();\n }\n connection.commit(); return true;\n } catch (SQLException | RuntimeException failure) {\n try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); }\n throw failure;\n }\n" + } + } +} diff --git a/curriculum/professional/lessons/sql-jdbc/reference/pom.xml b/curriculum/professional/lessons/sql-jdbc/reference/pom.xml new file mode 100644 index 0000000..b0d1329 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/reference/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsql-jdbcorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-jdbc +com.h2databaseh2 diff --git a/curriculum/professional/lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java b/curriculum/professional/lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java new file mode 100644 index 0000000..37877d6 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/reference/src/main/java/edu/lab/JdbcReservations.java @@ -0,0 +1,30 @@ +package edu.lab; +import javax.sql.DataSource; +import java.sql.*; +import java.util.Objects; +public final class JdbcReservations { + private final DataSource dataSource; + public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); } + public boolean reserve(String orderId, int quantity) throws SQLException { + // BEGIN EXERCISE + if (orderId == null || orderId.isBlank() || quantity <= 0) throw new IllegalArgumentException("invalid reservation"); + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + try { + int changed; + try (PreparedStatement update = connection.prepareStatement("update inventory set stock = stock - ? where sku = 'BOOK' and stock >= ?")) { + update.setInt(1, quantity); update.setInt(2, quantity); changed = update.executeUpdate(); + } + if (changed == 0) { connection.rollback(); return false; } + try (PreparedStatement insert = connection.prepareStatement("insert into orders(id, quantity) values (?, ?)")) { + insert.setString(1, orderId); insert.setInt(2, quantity); insert.executeUpdate(); + } + connection.commit(); return true; + } catch (SQLException | RuntimeException failure) { + try { connection.rollback(); } catch (SQLException rollback) { failure.addSuppressed(rollback); } + throw failure; + } + } + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/sql-jdbc/starter/pom.xml b/curriculum/professional/lessons/sql-jdbc/starter/pom.xml new file mode 100644 index 0000000..b0d1329 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/starter/pom.xml @@ -0,0 +1,4 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmlsql-jdbcorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-jdbc +com.h2databaseh2 diff --git a/curriculum/professional/lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java b/curriculum/professional/lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java new file mode 100644 index 0000000..a7f6c66 --- /dev/null +++ b/curriculum/professional/lessons/sql-jdbc/starter/src/main/java/edu/lab/JdbcReservations.java @@ -0,0 +1,11 @@ +package edu.lab; +import javax.sql.DataSource; +import java.sql.*; +import java.util.Objects; +public final class JdbcReservations { + private final DataSource dataSource; + public JdbcReservations(DataSource dataSource) { this.dataSource = Objects.requireNonNull(dataSource); } + public boolean reserve(String orderId, int quantity) throws SQLException { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..4463e05 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,8 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class ContractTest { + @Test void oneCallAndInvalidInput() { Checkout.PaymentPort port=mock(Checkout.PaymentPort.class); Checkout c=new Checkout(port); c.submit("o-1",500); verify(port).charge("o-1",500); verifyNoMoreInteractions(port); assertThatIllegalArgumentException().isThrownBy(()->c.submit("",500)); verifyNoMoreInteractions(port); } + @Test void propagatesFailure() { Checkout.PaymentPort port=mock(Checkout.PaymentPort.class); IllegalStateException failure=new IllegalStateException("declined"); doThrow(failure).when(port).charge("o-1",500); assertThatThrownBy(()->new Checkout(port).submit("o-1",500)).isSameAs(failure); } +} diff --git a/curriculum/professional/lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/curriculum/professional/lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/curriculum/professional/lessons/test-collaborators/lesson.json b/curriculum/professional/lessons/test-collaborators/lesson.json new file mode 100644 index 0000000..56c27c2 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/lesson.json @@ -0,0 +1,207 @@ +{ + "id": "test-collaborators", + "title": "Mockito로 외부 협력의 실패를 통제", + "description": "잘못된 주문이 결제 요청까지 도달하거나 결제 실패가 성공처럼 처리되는 일을 막습니다. Mockito 대역으로 charge의 입력·횟수·예외 전달을 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "실제 Checkout을 검사하면서 PaymentPort만 대역으로 바꾸는 이유를 설명한다.", + "submit(\"o-1\", 500)이 charge에 같은 주문 ID와 금액을 한 번 전달하는지 확인한다.", + "빈 주문 ID를 거절할 때 결제 호출이 추가되지 않아야 함을 확인한다.", + "charge가 던진 예외를 호출자에게 전달하고 대역 테스트의 한계를 구분한다." + ], + "theory": [ + { + "heading": "대역의 범위", + "text": "Checkout은 스스로 결제 통신을 만들지 않고 생성자로 받은 PaymentPort에 charge를 요청합니다. 이렇게 필요한 객체를 밖에서 전달하는 방식을 주입이라고 부릅니다. 테스트에서는 실제 결제 대신 Mockito가 만든 PaymentPort 대역을 넣습니다. 대역은 호출을 기록하고 원하는 예외를 내도록 설정할 수 있으므로 돈을 결제하지 않고 실패 상황을 반복해서 확인할 수 있습니다.\n\n기존 propagatesFailure는 charge(\"o-1\", 500)가 IllegalStateException을 던지도록 설정합니다. 검사 대상은 대역이 아닌 실제 Checkout.submit입니다. 이 검사는 Checkout이 예외를 전달하는지는 보여 주지만 실제 결제 서버의 응답 형식·네트워크 지연·결제 확정 여부까지 확인하지는 않습니다." + }, + { + "heading": "행동 중심 검증", + "text": "이 수업에서 중요한 약속은 “유효한 주문만 지정한 금액으로 한 번 요청하고 실패를 숨기지 않는다”입니다. submit은 orderId가 null·공백인지, cents가 양수인지 확인한 뒤 payments.charge를 호출합니다. 기존 oneCallAndInvalidInput은 정상 호출의 ID와 금액을 verify로 확인하고, 빈 ID 제출 후 호출이 더 생기지 않았는지 verifyNoMoreInteractions로 확인합니다.\n\n이 약속은 submit 한 번에 대한 호출 횟수입니다. submit을 두 번 부를 때 한 번만 결제된다는 뜻은 아닙니다. 네트워크 응답이 끊겨 실제 결제 여부를 모르는 상황에서는 무작정 재시도할 수 없습니다. 같은 요청을 다시 보내도 효과를 중복 적용하지 않는 멱등성 정책은 외부 결제 API와 함께 정해야 하며 이 코드에는 구현돼 있지 않습니다." + } + ], + "example": { + "title": "Checkout 기준 구현", + "code": "package edu.lab;\npublic final class Checkout {\n public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException();\n payments.charge(orderId, cents);\n // END EXERCISE\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 결제 한 번 호출 · 잘못된 입력에서 미호출 · 협력 오류 전파.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "생성자는 null이 아닌 PaymentPort를 payments에 보관합니다. submit(\"o-1\", 500)은 if를 통과해 payments.charge(\"o-1\", 500)를 한 번 호출합니다. 빈 주문 ID는 charge에 도달하기 전에 IllegalArgumentException으로 끝납니다. charge 주변에 예외를 삼키는 catch가 없으므로 결제 포트가 던진 예외는 submit 호출자에게 전달됩니다. 이 코드는 한 번의 제출을 검사하며 중복 제출을 막는 저장소나 재시도 로직은 포함하지 않습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/Checkout.java", + "language": "java", + "code": "package edu.lab;\npublic final class Checkout {\n public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException();\n payments.charge(orderId, cents);\n // END EXERCISE\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-collaboratorsorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.mockitomockito-coretest\n" + } + ] + }, + "quiz": [ + { + "id": "test-collaborators-q1", + "question": "외부 결제 협력자를 Mockito로 대체할 때 이 과제의 핵심 검증은?", + "options": [ + "서비스의 모든 private 메서드를 mock한다.", + "호출 횟수만 맞으면 잘못된 금액 전달은 허용한다.", + "외부 포트의 예외는 항상 성공으로 바꾼다.", + "유효 입력에서 한 번 호출하고 잘못된 입력에서는 호출하지 않는다." + ], + "correctIndex": 3, + "explanation": "submit이 정상 주문을 한 번 요청했는지 확인할 때 횟수만 아니라 \"o-1\"과 500이 그대로 전달됐는지도 봅니다. 빈 ID의 제출은 charge 앞에서 끝나야 합니다. Checkout까지 mock하면 이 if와 charge의 연결을 실행하지 않아 그 사이의 결함을 놓칠 수 있습니다." + }, + { + "id": "test-collaborators-q2", + "question": "결제 포트가 실패했다고 자동으로 무제한 재시도하면 왜 위험한가?", + "options": [ + "첫 요청이 실제 처리됐는지 모르면 중복 결제가 생길 수 있다.", + "Mockito가 숫자를 비교하지 못한다.", + "인터페이스가 있으면 예외를 던질 수 없다.", + "생성자 주입을 사용하면 재시도가 금지된다." + ], + "correctIndex": 0, + "explanation": "결제 서버가 처리한 직후 응답만 사라졌다면 호출자는 실패로 보아도 돈은 이미 결제됐을 수 있습니다. 다시 charge하면 중복 결제가 생길 수 있으므로 요청 식별 키와 결과 조회·재시도 규칙이 필요합니다. 현재 Checkout의 한 번 호출 테스트만으로 그런 멱등성이 확보되지는 않습니다." + } + ], + "practice": { + "title": "실패한 결제를 성공으로 숨기지 않기", + "scenario": "주문 제출이 결제 포트를 호출합니다. 주문 ID가 비어 있으면 결제를 시작하지 말아야 하고, 결제 포트의 실패는 화면에서 실패로 처리할 수 있도록 호출자까지 전달되어야 합니다.", + "tasks": [ + "Checkout.submit(String orderId, int cents)를 완성하고 \"o-1\", 500이 PaymentPort.charge에 그대로 한 번 전달되는지 확인하세요.", + "orderId는 null·공백이 아니고 cents는 양수여야 합니다. 잘못된 입력을 charge 전에 거절하는 줄을 짚으세요.", + "기존 테스트가 charge에서 던지게 한 failure와 submit에서 받은 예외가 같은 객체인지 확인하는 이유를 설명하세요.", + "현재 테스트가 증명하는 한 번 호출과, submit을 반복해도 결제를 한 번만 하는 멱등성의 차이를 적으세요. 실제 결제 서버·타임아웃·중복 제출 검증은 이 대역 검사에 포함되지 않습니다." + ], + "takeaway": "협력 대역은 실제 서비스의 결정을 검사하기 위한 도구입니다. 호출하지 않아야 할 때·전달할 값·실패를 알릴 방법을 확인하고 실제 외부 결제의 보장은 따로 검증합니다." + }, + "sources": [ + { + "title": "Mockito documentation", + "url": "https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html", + "edition": "Mockito 5; BOM resolved version", + "checkedAt": "2026-10-12" + }, + { + "title": "JUnit user guide", + "url": "https://docs.junit.org/5.11.4/user-guide/", + "edition": "JUnit 5", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "test-collaborators-project", + "professionalExercise": { + "id": "test-collaborators-project", + "profileId": "professional-mockito", + "mode": "project", + "instructions": [ + "Checkout.submit(String orderId, int cents)를 완성하세요.", + "orderId는 비어 있지 않고 cents는 양수여야 합니다. 잘못된 입력은 외부 호출 전에 거절합니다.", + "PaymentPort.charge를 한 번 호출하고 실패를 삼키지 않습니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/Checkout.java" + ], + "template": "package edu.lab;\npublic final class Checkout {\n public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/Checkout.java", + "templatePath": "lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java", + "referencePath": "lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/test-collaborators/starter/pom.xml", + "referencePath": "lessons/test-collaborators/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/test-collaborators/grader/src/test/java/edu/lab/ContractTest.java" + }, + { + "path": "src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker", + "sourcePath": "lessons/test-collaborators/grader/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker" + } + ], + "behaviorCriteria": [ + "결제 한 번 호출", + "잘못된 입력에서 미호출", + "협력 오류 전파" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "test-contracts", + "core:polymorphism" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "9b36daa6c1fdc7557702c6825819f8b92f1384bb562f8b95e8b9f79368bab733", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "빈 주문 ID로 결제까지 요청되거나, 결제 서버의 오류를 삼켜 주문이 성공한 것처럼 보이면 곤란합니다. 실제 결제를 하지 않고 Checkout이 어느 지점에서 멈추는지 확인하려 합니다.", + "question": "PaymentPort를 대역으로 바꾸어도 Checkout의 어떤 동작은 실제로 검사할 수 있을까요?", + "terms": [ + { + "term": "협력 객체", + "meaning": "일부 작업을 부탁하는 다른 객체입니다. Checkout은 금액 결제를 PaymentPort의 charge에 부탁합니다." + }, + { + "term": "주입", + "meaning": "객체가 필요한 도구를 내부에서 만들지 않고 밖에서 받는 방식입니다. Checkout(PaymentPort payments)가 그 전달 지점입니다." + }, + { + "term": "테스트 대역", + "meaning": "실제 외부 시스템 대신 테스트에 넣는 객체입니다. Mockito로 만든 PaymentPort는 charge의 호출을 기록하거나 미리 정한 예외를 던집니다." + }, + { + "term": "실패 전파", + "meaning": "발생한 오류를 호출자가 알 수 있도록 밖으로 전달하는 일입니다. 이 submit은 charge의 예외를 성공으로 바꾸지 않습니다." + } + ], + "walkthrough": [ + "Checkout의 생성자가 전달받은 PaymentPort를 payments에 저장합니다. null이면 requireNonNull에서 바로 실패합니다.", + "submit의 if가 orderId와 cents를 검사합니다. 빈 ID는 payments.charge까지 내려가기 전에 IllegalArgumentException으로 거절됩니다.", + "submit(\"o-1\", 500)은 payments.charge(orderId, cents) 한 줄을 실행합니다. 테스트는 verify(port).charge(\"o-1\", 500)으로 이 호출의 값과 기본 한 번 호출을 확인합니다.", + "propagatesFailure 테스트의 대역은 charge에서 failure 예외를 던집니다. submit에 이를 잡는 코드가 없어서 같은 예외가 호출자에게 전달됩니다.", + "대역은 네트워크나 실제 결제를 실행하지 않습니다. 이 결과로 확인하는 것은 Checkout의 검사 순서와 호출·실패 전달입니다." + ], + "prediction": { + "question": "결제 대역의 charge가 failure 예외를 던지도록 설정하고 submit(\"o-1\", 500)을 호출하면 정상 종료할까요?", + "answer": "정상 종료하지 않고 charge가 던진 같은 failure 예외가 호출자에게 전달됩니다.", + "explanation": "입력 검사는 통과하지만 charge에서 실행이 중단됩니다. submit에는 예외를 잡거나 재시도하는 코드가 없으므로 실패가 그대로 드러납니다." + }, + "takeaway": "결제를 실제로 하지 않아도 “검사 후 요청하는가, 정확히 무엇을 전달하는가, 실패를 숨기는가”를 확인할 수 있습니다. 그 범위를 외부 결제 성공 보장과 구분합니다.", + "code": { + "file": "src/main/java/edu/lab/Checkout.java", + "source": " public interface PaymentPort { void charge(String orderId, int cents); }\n private final PaymentPort payments;\n public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); }\n public void submit(String orderId, int cents) {\n // BEGIN EXERCISE\n if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException();\n payments.charge(orderId, cents);\n // END EXERCISE\n }\n}\n" + } + } +} diff --git a/curriculum/professional/lessons/test-collaborators/reference/pom.xml b/curriculum/professional/lessons/test-collaborators/reference/pom.xml new file mode 100644 index 0000000..43ed171 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/reference/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-collaboratorsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.mockitomockito-coretest diff --git a/curriculum/professional/lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java b/curriculum/professional/lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java new file mode 100644 index 0000000..23dd761 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/reference/src/main/java/edu/lab/Checkout.java @@ -0,0 +1,12 @@ +package edu.lab; +public final class Checkout { + public interface PaymentPort { void charge(String orderId, int cents); } + private final PaymentPort payments; + public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); } + public void submit(String orderId, int cents) { + // BEGIN EXERCISE + if (orderId == null || orderId.isBlank() || cents <= 0) throw new IllegalArgumentException(); + payments.charge(orderId, cents); + // END EXERCISE + } +} diff --git a/curriculum/professional/lessons/test-collaborators/starter/pom.xml b/curriculum/professional/lessons/test-collaborators/starter/pom.xml new file mode 100644 index 0000000..43ed171 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/starter/pom.xml @@ -0,0 +1,3 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-collaboratorsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.mockitomockito-coretest diff --git a/curriculum/professional/lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java b/curriculum/professional/lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java new file mode 100644 index 0000000..cd34029 --- /dev/null +++ b/curriculum/professional/lessons/test-collaborators/starter/src/main/java/edu/lab/Checkout.java @@ -0,0 +1,9 @@ +package edu.lab; +public final class Checkout { + public interface PaymentPort { void charge(String orderId, int cents); } + private final PaymentPort payments; + public Checkout(PaymentPort payments) { this.payments=java.util.Objects.requireNonNull(payments); } + public void submit(String orderId, int cents) { + throw new UnsupportedOperationException("실습 구현 필요"); + } +} diff --git a/curriculum/professional/lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..6ac12ce --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,7 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void preservesStateOnFailure() { Reservation r=new Reservation(3); assertThat(r.reserve(4)).isFalse(); assertThat(r.reserve(0)).isFalse(); assertThat(r.reserve(-1)).isFalse(); assertThat(r.remaining()).isEqualTo(3); } + @Test void reservesAllAndRejectsNegativeInitial() { Reservation r=new Reservation(3); assertThat(r.reserve(3)).isTrue(); assertThat(r.remaining()).isZero(); assertThat(r.reserve(1)).isFalse(); assertThatIllegalArgumentException().isThrownBy(()->new Reservation(-1)); } +} diff --git a/curriculum/professional/lessons/test-contracts/lesson.json b/curriculum/professional/lessons/test-contracts/lesson.json new file mode 100644 index 0000000..b8d0e34 --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/lesson.json @@ -0,0 +1,201 @@ +{ + "id": "test-contracts", + "title": "JUnit·AssertJ로 경계와 상태 전이 검증", + "description": "재고 3개에서 4개 예약을 거절했는데 재고가 줄었다면 false만 확인한 테스트는 놓칩니다. 반환값과 호출 뒤 남은 재고를 JUnit·AssertJ로 함께 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "reserve 호출 전 재고·반환값·호출 뒤 재고를 한 묶음으로 설명한다.", + "재고 3개를 전량 예약하면 true와 남은 재고 0이 함께 나와야 함을 확인한다.", + "초과·0·음수 예약을 거절한 뒤 재고 3이 보존되는지 확인한다.", + "생성자에서도 음수 재고를 받아들이지 않아야 하는 이유를 설명한다." + ], + "theory": [ + { + "heading": "기대값은 업무 계약에서", + "text": "테스트를 쓰기 전에 사용자가 기대하는 동작을 문장으로 정합니다. 여기서 업무 계약은 “재고가 3개일 때 4개 요청은 거절하고 재고는 그대로 3개다”라는 약속입니다. JUnit의 @Test는 검사할 메서드를 표시하고, AssertJ의 assertThat은 실제 결과가 기대값에 맞는지 확인합니다. 기존 preservesStateOnFailure는 reserve(4), reserve(0), reserve(-1)의 false와 마지막 remaining()의 3을 검사합니다.\n\n기대값 3은 사용자가 요구한 상태에서 나와야 합니다. 구현의 뺄셈식을 그대로 복사해 기대값을 만들면, 둘이 같은 잘못된 계산을 해도 검사가 통과할 수 있습니다. “실패했으니 재고를 건드리지 않는다”를 구체적인 값으로 확인하는 이유입니다." + }, + { + "heading": "경계 검사는 상태 전이를 포함", + "text": "경계값은 허용과 거절이 갈리는 근처의 값입니다. stock이 3일 때 quantity 3은 허용하고 4는 거절합니다. 0과 -1도 거절합니다. reserve의 if가 먼저 반환하기 때문에 이 경우 stock -= quantity까지 내려가지 않습니다. 반대로 reserve(3)은 stock을 0으로 만든 뒤 true를 반환합니다.\n\n상태 전이는 이처럼 호출 전후 stock이 어떻게 달라졌는지 뜻합니다. true만 검사하면 재고를 안 줄이는 결함을 놓치고, false만 검사하면 거절했는데 재고를 줄이는 결함을 놓칩니다. 테스트는 선택한 입력과 순서에 대한 증거입니다. 이 단일 객체 검사를 여러 스레드의 동시 예약이나 DB 재고의 안전성으로 확대하지 않습니다." + } + ], + "example": { + "title": "Reservation 기준 구현", + "code": "package edu.lab;\npublic final class Reservation {\n private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0 || quantity > stock) return false;\n stock -= quantity; return true;\n // END EXERCISE\n }\n public int remaining() { return stock; }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 전량 예약 · 실패 후 상태 보존 · 생성 불변식.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "Reservation(3)으로 시작해 reserve(4)를 부르면 quantity > stock이 참이라 false를 반환하고 stock은 3에 머뭅니다. reserve(3)은 if를 지나 stock -= quantity를 실행하므로 true와 남은 재고 0이 함께 나옵니다. remaining()을 읽는 이유는 반환값이 맞아도 내부 재고 변경은 틀릴 수 있기 때문입니다. 생성자의 stock < 0 검사도 음수 재고를 가진 객체가 처음부터 만들어지는 것을 막습니다.\n\n공개 기준 구현과 테스트를 JDK 21 및 Maven 3.10.0으로 실행해 확인했습니다. 미완성 실습은 테스트를 통과하지 않습니다. 설치형에서는 등록한 JDK로 환경 준비와 학습 코드 검증을 진행합니다. 테스트 범위와 운영 환경의 차이는 실무 설명을 함께 읽으세요.", + "files": [ + { + "path": "src/main/java/edu/lab/Reservation.java", + "language": "java", + "code": "package edu.lab;\npublic final class Reservation {\n private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0 || quantity > stock) return false;\n stock -= quantity; return true;\n // END EXERCISE\n }\n public int remaining() { return stock; }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-contractsorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\n" + } + ] + }, + "quiz": [ + { + "id": "test-contracts-q1", + "question": "재고 부족 예약을 거절하는 테스트에 함께 필요한 검사는?", + "options": [ + "반환값만 false면 내부 재고는 확인하지 않는다.", + "예외가 없으면 항상 성공으로 본다.", + "실패 반환뿐 아니라 남은 재고가 이전 값과 같은지 확인한다.", + "private 필드를 모두 public으로 바꾼다." + ], + "correctIndex": 2, + "explanation": "재고 3개에서 reserve(4)가 false를 반환해도, 앞서 stock을 줄여 놓는 잘못된 구현일 수 있습니다. remaining()이 계속 3인지까지 확인해야 “거절했다”와 “기존 재고를 보존했다”라는 두 약속을 모두 검사합니다." + }, + { + "id": "test-contracts-q2", + "question": "테스트가 내부 메서드 호출 순서에만 강하게 묶일 때 생길 수 있는 비용은?", + "options": [ + "동일한 외부 동작을 유지한 리팩터링도 불필요하게 테스트를 깨뜨릴 수 있다.", + "입력 경계 검사가 자동으로 늘어난다.", + "테스트 실행 JVM이 자동 고정된다.", + "DB 트랜잭션 격리 수준이 올라간다." + ], + "correctIndex": 0, + "explanation": "reserve 내부를 보조 메서드로 나누더라도 같은 입력에서 같은 반환값과 남은 재고를 준다면 사용자에게 보이는 동작은 같습니다. private 메서드의 순서만 고정하면 이런 구조 정리도 테스트를 깨뜨립니다. 이 예제는 reserve와 remaining으로 관찰할 수 있는 동작을 검사합니다." + } + ], + "practice": { + "title": "거절한 예약이 재고를 바꾸지 않는지 확인하기", + "scenario": "주문 화면에는 재고 부족이라고 표시됐는데 이후 정상 주문도 실패합니다. reserve가 false를 반환하면서 재고를 먼저 줄였을 가능성을 테스트로 구분해 봅니다.", + "tasks": [ + "Reservation(int stock), reserve(int quantity), remaining()을 읽고 재고 3에서 reserve(4)를 부른 뒤 기대하는 반환값과 재고를 먼저 적으세요.", + "초기 음수는 예외로 거절하고 0 이하·초과 예약은 false로 거절하세요. 이때 기존 재고를 보존해야 합니다.", + "reserve(3)의 true와 remaining()의 0을 함께 확인하세요. 이어지는 reserve(1)은 어떤 결과여야 하는지 설명하세요.", + "반환값만 검사할 때 놓치는 결함 하나와 남은 재고를 추가로 읽으면 잡을 수 있는 이유를 적으세요. 현재 테스트에는 다중 스레드 예약 검사가 없다는 점도 구분하세요." + ], + "takeaway": "예약 결과는 true·false 하나로 끝나지 않습니다. 호출이 끝난 뒤 재고가 어떤 값이어야 하는지까지 적어야 실패 후 상태를 지키는 테스트가 됩니다." + }, + "sources": [ + { + "title": "JUnit user guide", + "url": "https://docs.junit.org/5.11.4/user-guide/", + "edition": "JUnit 5", + "checkedAt": "2026-10-12" + }, + { + "title": "AssertJ Core documentation", + "url": "https://assertj.github.io/doc/", + "edition": "AssertJ Core", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "test-contracts-project", + "professionalExercise": { + "id": "test-contracts-project", + "profileId": "professional-junit", + "mode": "project", + "instructions": [ + "Reservation(int stock), boolean reserve(int quantity), int remaining() 계약을 완성하세요.", + "초기 음수는 거절합니다. 0 이하 또는 초과 예약은 false이고 상태를 보존합니다." + ], + "editableFiles": [ + "src/main/java/edu/lab/Reservation.java" + ], + "template": "package edu.lab;\npublic final class Reservation {\n private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public int remaining() { return stock; }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/Reservation.java", + "templatePath": "lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java", + "referencePath": "lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/test-contracts/starter/pom.xml", + "referencePath": "lessons/test-contracts/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/test-contracts/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "전량 예약", + "실패 후 상태 보존", + "생성 불변식" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "build-maven" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "cbdeec57521987b40feed67ef6c3af7d4964f6b8804b51c5abae9447dc147028", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + }, + "learningGuide": { + "scenario": "재고 3개에 대한 4개 예약을 거절했습니다. 호출자는 false를 받았지만 이후 remaining()이 3이 아니라면 예약 처리에 결함이 있습니다.", + "question": "reserve가 false를 반환했다는 사실만으로 재고가 안전하다고 할 수 있을까요?", + "terms": [ + { + "term": "업무 계약", + "meaning": "사용자가 기대하는 입력·결과·상태의 약속입니다. reserve(4)를 거절했으면 stock 3도 그대로여야 합니다." + }, + { + "term": "상태 전이", + "meaning": "메서드를 부르기 전과 후에 객체 값이 바뀌는 일입니다. reserve(3)의 성공은 stock 3 → 0이고, 거절은 3 → 3입니다." + }, + { + "term": "경계값", + "meaning": "허용과 거절이 갈리는 근처의 입력입니다. 재고 3에서 수량 3과 4, 그리고 최소 허용 수량 1 근처의 0을 살펴봅니다." + }, + { + "term": "단언(assertion)", + "meaning": "실제 결과와 기대 결과가 맞는지 검사하는 문장입니다. 기존 테스트의 isFalse()와 isEqualTo(3)는 각각 반환값과 남은 재고를 확인합니다." + } + ], + "walkthrough": [ + "Reservation(3)은 생성자의 음수 검사를 통과하고 this.stock에 3을 저장합니다.", + "reserve(4)는 quantity > stock이 참이므로 if에서 false를 반환합니다. stock -= quantity는 실행되지 않습니다.", + "reserve(0)과 reserve(-1)도 quantity <= 0 때문에 같은 지점에서 끝납니다. remaining()은 여전히 3을 돌려줍니다.", + "reserve(3)은 두 거절 조건이 모두 거짓입니다. stock에서 3을 뺀 뒤 true를 반환하므로 remaining()은 0입니다.", + "기존 ContractTest는 거절의 false뿐 아니라 재고 보존도 검사합니다. 음수 초기 재고는 생성자에서 예외가 나는지도 별도로 확인합니다." + ], + "prediction": { + "question": "Reservation(3) 하나에서 reserve(4), reserve(3)을 순서대로 호출하면 두 반환값과 최종 remaining()은 무엇인가요?", + "answer": "순서대로 false, true이고 최종 remaining()은 0입니다.", + "explanation": "첫 호출은 뺄셈 전에 반환해 재고 3을 보존합니다. 두 번째 호출은 그 3개를 전부 예약합니다. 거절한 호출이 다음 호출에 남기는 상태까지 따라가 보세요." + }, + "takeaway": "실패를 알리는 값과 실패 뒤 남겨야 할 상태를 따로 확인합니다. 이 원칙은 예약·취소처럼 상태를 바꾸는 메서드의 테스트를 읽을 때 유용합니다.", + "code": { + "file": "src/main/java/edu/lab/Reservation.java", + "source": " private int stock;\n public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; }\n public boolean reserve(int quantity) {\n // BEGIN EXERCISE\n if (quantity <= 0 || quantity > stock) return false;\n stock -= quantity; return true;\n // END EXERCISE\n }\n public int remaining() { return stock; }\n" + } + } +} diff --git a/curriculum/professional/lessons/test-contracts/reference/pom.xml b/curriculum/professional/lessons/test-contracts/reference/pom.xml new file mode 100644 index 0000000..89ab8ef --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/reference/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-contractsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java b/curriculum/professional/lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java new file mode 100644 index 0000000..35fb05b --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/reference/src/main/java/edu/lab/Reservation.java @@ -0,0 +1,12 @@ +package edu.lab; +public final class Reservation { + private int stock; + public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; } + public boolean reserve(int quantity) { + // BEGIN EXERCISE + if (quantity <= 0 || quantity > stock) return false; + stock -= quantity; return true; + // END EXERCISE + } + public int remaining() { return stock; } +} diff --git a/curriculum/professional/lessons/test-contracts/starter/pom.xml b/curriculum/professional/lessons/test-contracts/starter/pom.xml new file mode 100644 index 0000000..89ab8ef --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/starter/pom.xml @@ -0,0 +1,2 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltest-contractsorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest diff --git a/curriculum/professional/lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java b/curriculum/professional/lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java new file mode 100644 index 0000000..38d1418 --- /dev/null +++ b/curriculum/professional/lessons/test-contracts/starter/src/main/java/edu/lab/Reservation.java @@ -0,0 +1,9 @@ +package edu.lab; +public final class Reservation { + private int stock; + public Reservation(int stock) { if (stock < 0) throw new IllegalArgumentException(); this.stock=stock; } + public boolean reserve(int quantity) { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public int remaining() { return stock; } +} diff --git a/curriculum/professional/lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java b/curriculum/professional/lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java new file mode 100644 index 0000000..4493d12 --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java @@ -0,0 +1,21 @@ +package edu.lab; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import static org.assertj.core.api.Assertions.*; +class ContractTest { + @Test void externalCallRollsBack() { + try (var context = new AnnotationConfigApplicationContext(TransactionLab.Config.class)) { + var orders = context.getBean(TransactionLab.Orders.class); + assertThatThrownBy(orders::reserveThenFail).isInstanceOf(IllegalStateException.class); + assertThat(context.getBean(JdbcTemplate.class).queryForObject("select count(*) from audit_events",Integer.class)).isZero(); + } + } + @Test void selfInvocationDemonstratesMissingBoundary() { + try (var context = new AnnotationConfigApplicationContext(TransactionLab.Config.class)) { + var orders = context.getBean(TransactionLab.Orders.class); + assertThatThrownBy(orders::bypassProxy).isInstanceOf(IllegalStateException.class); + assertThat(context.getBean(JdbcTemplate.class).queryForObject("select count(*) from audit_events",Integer.class)).isEqualTo(1); + } + } +} diff --git a/curriculum/professional/lessons/tx-proxy/lesson.json b/curriculum/professional/lessons/tx-proxy/lesson.json new file mode 100644 index 0000000..b9c1271 --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/lesson.json @@ -0,0 +1,206 @@ +{ + "id": "tx-proxy", + "title": "Spring 트랜잭션 프록시와 롤백 경계", + "description": "같은 INSERT 뒤에 같은 예외를 던져도 호출 방법에 따라 행이 남을 수 있습니다. Spring이 호출을 가로채는 프록시와 실패한 변경을 되돌리는 롤백을 실제 두 메서드로 비교합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": null, + "preview": false, + "duration": 75, + "category": "실무 프로젝트", + "goals": [ + "reserveThenFail을 호출하는 두 경로에서 실패 후 행 수가 다른 이유를 설명한다.", + "프록시를 통한 호출이 INSERT를 롤백해 0행을 남기는 과정을 따라간다.", + "같은 객체 안의 this 호출인 자기 호출이 이 예제에서 1행을 남기는 이유를 찾는다.", + "예외 종류와 실제 호출 경로를 보고 트랜잭션을 어디서 시작할지 정한다." + ], + "learningGuide": { + "scenario": "예약 기록을 한 줄 넣고 곧바로 오류를 던지는 메서드가 있습니다. 오류는 두 호출 모두에서 보이지만, 한쪽 DB에만 reserved 행이 남습니다.", + "question": "실행한 메서드 본문이 같은데 왜 실패 뒤 DB 상태는 달라질까요?", + "terms": [ + { + "term": "프록시", + "meaning": "Spring이 원래 Orders 앞에 두는 대리 객체입니다. 외부 호출을 받아 트랜잭션 같은 동작을 적용합니다." + }, + { + "term": "자기 호출", + "meaning": "객체가 this로 자기 메서드를 호출하는 것입니다. 여기서는 bypassProxy 안의 this.reserveThenFail()입니다." + }, + { + "term": "롤백", + "meaning": "트랜잭션에서 아직 확정하지 않은 DB 변경을 취소하는 것입니다. 이 예제에서는 방금 넣은 reserved 행을 남기지 않습니다." + } + ], + "code": { + "file": "src/main/java/edu/lab/TransactionLab.java", + "source": " @Transactional\n public void reserveThenFail() {\n // BEGIN EXERCISE\n jdbc.update(\"insert into audit_events(id) values('reserved')\");\n throw new IllegalStateException(\"simulated downstream failure\");\n // END EXERCISE\n }\n public void bypassProxy() { this.reserveThenFail(); }" + }, + "walkthrough": [ + "@Transactional이 붙은 reserveThenFail을 Spring의 Orders 프록시를 통해 직접 부르는 경우를 먼저 봅니다. 프록시가 트랜잭션을 적용한 뒤 메서드 본문으로 들어갑니다.", + "jdbc.update 줄은 audit_events에 reserved 한 행을 넣습니다. 다음 throw 줄은 IllegalStateException을 밖으로 전달합니다.", + "직접 호출 경로에서는 프록시가 이 예외를 보고 트랜잭션을 롤백합니다. 메서드를 호출한 쪽은 예외를 받고 DB에는 행이 남지 않습니다.", + "마지막 bypassProxy 줄을 통해 들어오면 this.reserveThenFail()이 프록시를 다시 거치지 않습니다. 이 예제의 bypassProxy에는 트랜잭션도 없어 INSERT가 자동 확정되고, 이후 예외만으로는 그 행을 되돌리지 못합니다." + ], + "prediction": { + "question": "매번 빈 새 DB에서 시작해 reserveThenFail 직접 호출과 bypassProxy 호출을 각각 실행하면 예외 뒤 행 수는 얼마일까요?", + "answer": "Spring 프록시를 통한 직접 호출은 0행, bypassProxy 호출은 1행입니다.", + "explanation": "첫 호출은 INSERT와 예외가 트랜잭션 안에 있어 변경이 취소됩니다. 두 번째는 자기 호출 앞에 새 트랜잭션을 적용할 프록시 진입이 없고 기존 트랜잭션도 없습니다. 두 경우 모두 예외가 발생했다는 사실만 보고 롤백을 판단하면 안 됩니다." + }, + "takeaway": "실패를 던지는 코드와 DB 변경을 되돌리는 트랜잭션은 함께 연결되어 있어야 합니다." + }, + "theory": [ + { + "heading": "프록시가 여는 경계", + "text": "프록시는 원래 객체 앞에서 호출을 받아 부가 작업을 하는 대리 객체입니다. 이 예제에서는 Spring에서 받은 Orders를 통해 reserveThenFail을 부르면 프록시가 DB 트랜잭션을 적용합니다. 트랜잭션은 여러 DB 변경을 함께 확정하거나 취소하는 단위입니다. 반면 bypassProxy 안의 this.reserveThenFail()은 같은 객체가 자기 메서드를 부르는 자기 호출입니다. 이 호출은 프록시로 다시 들어가지 않아 내부 메서드의 @Transactional 설정을 새로 적용하지 않습니다." + }, + { + "heading": "예외와 롤백 정책", + "text": "롤백은 아직 확정하지 않은 트랜잭션의 DB 변경을 되돌리는 일입니다. reserveThenFail은 audit_events에 reserved를 넣은 직후 IllegalStateException을 던집니다. 이는 RuntimeException의 하위 타입이어서 이 예제의 기본 정책에서는 프록시가 예외를 받아 INSERT를 롤백합니다. checked 예외는 처리 또는 throws 선언을 요구하는 예외이며 기본 롤백 규칙이 다릅니다. 또 예외를 내부에서 잡아 정상 반환하면 프록시는 그 예외를 보지 못하므로 실패 정책을 따로 검토해야 합니다." + }, + { + "heading": "실패 예제의 목적", + "text": "테스트는 각자 빈 H2 DB에서 시작합니다. 외부에서 reserveThenFail을 호출한 테스트는 실패 뒤 0행을, bypassProxy를 호출한 테스트는 1행을 기대합니다. 후자는 바람직한 저장 결과가 아니라 잘못된 호출 경로를 드러내는 반례입니다. bypassProxy에 기존 트랜잭션이 없는 이 조건에서 INSERT가 자동 확정되기 때문입니다. 자기 호출이 이미 진행 중인 트랜잭션까지 없앤다는 뜻은 아닙니다. 업무 코드에서는 트랜잭션이 필요한 작업을 다른 Spring 관리 객체로 분리해 호출하거나 명시적으로 트랜잭션을 여는 방법을 비교합니다." + } + ], + "example": { + "title": "TransactionLab 기준 구현", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.util.UUID;\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.springframework.context.annotation.*;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.jdbc.datasource.DataSourceTransactionManager;\nimport org.springframework.transaction.PlatformTransactionManager;\nimport org.springframework.transaction.annotation.*;\npublic final class TransactionLab {\n public static class Orders {\n private final JdbcTemplate jdbc;\n public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; }\n @Transactional\n public void reserveThenFail() {\n // BEGIN EXERCISE\n jdbc.update(\"insert into audit_events(id) values('reserved')\");\n throw new IllegalStateException(\"simulated downstream failure\");\n // END EXERCISE\n }\n public void bypassProxy() { this.reserveThenFail(); }\n }\n @Configuration\n @EnableTransactionManagement(proxyTargetClass=true)\n public static class Config {\n @Bean public DataSource dataSource() {\n JdbcDataSource ds = new JdbcDataSource(); ds.setURL(\"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"); return ds;\n }\n @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute(\"create table audit_events(id varchar(60) primary key)\"); return jdbc; }\n @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); }\n @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); }\n }\n}\n", + "output": "기준 구현 검증: JDK 21 · Java 17 대상 · 2개 JUnit 테스트 통과.\n검증 항목: 외부 프록시 호출 롤백 · 자기 호출 반례 재현 · 호출 경로 설명.\n작성한 코드는 앱에서 실행·제출할 때 별도로 검사합니다.", + "explanation": "reserveThenFail의 두 핵심 줄은 INSERT와 IllegalStateException입니다. Spring에서 받은 Orders로 이 메서드를 직접 호출하면 트랜잭션이 적용되어 실패 뒤 행이 사라집니다. bypassProxy는 같은 객체의 this.reserveThenFail()을 실행하므로 내부 @Transactional을 새로 적용하지 않습니다. 기존 트랜잭션이 없는 이 예제에서는 INSERT가 이미 확정되어 예외 뒤에도 1행이 남습니다. 두 테스트가 서로 다른 행 수를 기대하는 이유입니다. 기존 기준 구현의 검증 기록은 JDK 21·Java 17 대상·Maven 3.10.0에서의 JUnit 검사입니다. 미완성 실습은 기준 테스트를 통과하지 않습니다. 설치형에서는 등록한 로컬 JDK로 자신의 코드를 별도로 실행·제출하며, 기준 구현의 기록과 자신의 실행 결과를 구분합니다.", + "files": [ + { + "path": "src/main/java/edu/lab/TransactionLab.java", + "language": "java", + "code": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.util.UUID;\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.springframework.context.annotation.*;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.jdbc.datasource.DataSourceTransactionManager;\nimport org.springframework.transaction.PlatformTransactionManager;\nimport org.springframework.transaction.annotation.*;\npublic final class TransactionLab {\n public static class Orders {\n private final JdbcTemplate jdbc;\n public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; }\n @Transactional\n public void reserveThenFail() {\n // BEGIN EXERCISE\n jdbc.update(\"insert into audit_events(id) values('reserved')\");\n throw new IllegalStateException(\"simulated downstream failure\");\n // END EXERCISE\n }\n public void bypassProxy() { this.reserveThenFail(); }\n }\n @Configuration\n @EnableTransactionManagement(proxyTargetClass=true)\n public static class Config {\n @Bean public DataSource dataSource() {\n JdbcDataSource ds = new JdbcDataSource(); ds.setURL(\"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"); return ds;\n }\n @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute(\"create table audit_events(id varchar(60) primary key)\"); return jdbc; }\n @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); }\n @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); }\n }\n}\n" + }, + { + "path": "pom.xml", + "language": "xml", + "code": "4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltx-proxyorg.junit.jupiterjunit-jupitertest\norg.assertjassertj-coretest\norg.springframeworkspring-context\norg.springframeworkspring-jdbc\ncom.h2databaseh2\n" + } + ] + }, + "quiz": [ + { + "id": "tx-proxy-q1", + "question": "기본 프록시 모드에서 같은 객체의 this 호출에 대한 설명은?", + "options": [ + "모든 this 호출은 자동으로 프록시 호출로 바뀐다.", + "프록시를 다시 지나지 않아 내부 메서드의 조언이 새로 적용되지 않는다.", + "어노테이션이 있으면 객체 생성 방식은 무관하다.", + "private 메서드에도 항상 같은 트랜잭션이 새로 생성된다." + ], + "correctIndex": 1, + "explanation": "bypassProxy의 this는 실제 Orders 객체 자신을 가리킵니다. reserveThenFail 호출이 프록시를 다시 지나지 않으므로 그 메서드의 @Transactional을 새로 적용하지 않습니다. 이 예제는 외부 트랜잭션도 없어 INSERT가 남습니다. 어노테이션이나 private 여부만으로 새 트랜잭션이 생기는 것은 아닙니다." + }, + { + "id": "tx-proxy-q2", + "question": "기본 롤백 규칙에서 checked 예외가 경계 밖으로 전달된다면?", + "options": [ + "항상 rollback된다.", + "항상 JVM이 종료된다.", + "rollbackFor 등 정책을 확인해야 하며 기본 규칙은 RuntimeException·Error와 다르다.", + "예외 타입은 롤백과 무관하다." + ], + "correctIndex": 2, + "explanation": "현재 예제의 IllegalStateException은 RuntimeException이므로 기본 롤백 대상입니다. checked 예외로 바꾸면 같은 결과를 자동으로 기대할 수 없습니다. 업무상 변경을 취소해야 한다면 rollbackFor 같은 규칙을 정하고 실패 후 실제 DB 상태까지 검사해야 합니다." + } + ], + "practice": { + "title": "주문·재고·취소 프로젝트에 연결", + "scenario": "주문 저장 뒤 후속 작업이 실패했는데 주문 기록만 남았다고 가정합니다. 메서드 위에 @Transactional이 있는지와 함께, 요청이 어떤 객체를 거쳐 그 메서드에 도착했는지를 추적합니다.", + "tasks": [ + "빈 DB에서 Spring의 Orders로 reserveThenFail을 호출하고 INSERT → 예외 → 롤백 → 0행의 순서를 설명하세요.", + "다른 빈 DB에서 bypassProxy를 호출했을 때 this.reserveThenFail() 앞에 트랜잭션을 여는 단계가 왜 없는지 설명하세요. 남은 1행은 의도한 반례입니다.", + "테스트가 컨테이너에서 Orders를 가져오는 이유와 직접 new Orders(...)로 만들 때 잃는 동작을 구분하세요.", + "업무 코드에서 자기 호출 문제를 없앨 대안으로 다른 Spring 관리 서비스에 작업을 분리하거나 명시적 트랜잭션을 사용하는 방법을 비교하세요.", + "예외를 잡아 정상 반환하거나 checked 예외를 던지는 경우에는 어떤 추가 검사가 필요한지 적으세요. 외부 API의 작업까지 이 DB 롤백이 취소해 주는 것은 아닙니다." + ], + "takeaway": "@Transactional은 호출이 실제로 통과한 경로와 함께 읽어야 합니다. 실패 뒤 DB 행이 남는지를 확인하면 경계 누락을 발견할 수 있습니다." + }, + "sources": [ + { + "title": "Spring @Transactional semantics", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/annotations.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring declarative rollback", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/rolling-back.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Spring", + "Transaction", + "AOP" + ], + "exerciseId": "tx-proxy-project", + "professionalExercise": { + "id": "tx-proxy-project", + "profileId": "professional-spring-tx", + "mode": "project", + "instructions": [ + "외부 프록시를 통해 reserveThenFail을 호출하면 예외 뒤 audit_events 행이 0개입니다.", + "bypassProxy는 의도적 반례로 this.reserveThenFail을 호출합니다. 별도 트랜잭션이 없어서 행 1개가 남는 차이를 설명하세요.", + "트랜잭션 빈은 Spring 컨테이너에서 가져오고 직접 new로 대체하지 않습니다.", + "학습 완료 설명에서는 업무 서비스에서 자기 호출 문제를 제거할 대안을 제시하세요." + ], + "editableFiles": [ + "src/main/java/edu/lab/TransactionLab.java" + ], + "template": "package edu.lab;\nimport javax.sql.DataSource;\nimport java.util.UUID;\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.springframework.context.annotation.*;\nimport org.springframework.jdbc.core.JdbcTemplate;\nimport org.springframework.jdbc.datasource.DataSourceTransactionManager;\nimport org.springframework.transaction.PlatformTransactionManager;\nimport org.springframework.transaction.annotation.*;\npublic final class TransactionLab {\n public static class Orders {\n private final JdbcTemplate jdbc;\n public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; }\n @Transactional\n public void reserveThenFail() {\n throw new UnsupportedOperationException(\"실습 구현 필요\");\n }\n public void bypassProxy() { this.reserveThenFail(); }\n }\n @Configuration\n @EnableTransactionManagement(proxyTargetClass=true)\n public static class Config {\n @Bean public DataSource dataSource() {\n JdbcDataSource ds = new JdbcDataSource(); ds.setURL(\"jdbc:h2:mem:\" + UUID.randomUUID() + \";DB_CLOSE_DELAY=-1\"); return ds;\n }\n @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute(\"create table audit_events(id varchar(60) primary key)\"); return jdbc; }\n @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); }\n @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); }\n }\n}\n", + "files": [ + { + "path": "src/main/java/edu/lab/TransactionLab.java", + "templatePath": "lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java", + "referencePath": "lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java", + "editable": true + }, + { + "path": "pom.xml", + "templatePath": "lessons/tx-proxy/starter/pom.xml", + "referencePath": "lessons/tx-proxy/reference/pom.xml", + "editable": false + } + ], + "testFiles": [ + { + "path": "src/test/java/edu/lab/ContractTest.java", + "sourcePath": "lessons/tx-proxy/grader/src/test/java/edu/lab/ContractTest.java" + } + ], + "behaviorCriteria": [ + "외부 프록시 호출 롤백", + "자기 호출 반례 재현", + "호출 경로 설명" + ], + "rubric": [ + "업무 계약과 실패 후 상태를 설명한다.", + "대안·의존 관계·실제 검증 범위를 근거로 제시한다." + ], + "referencePublic": true, + "executionVerified": true, + "runLocation": "windows-desktop", + "remoteExecution": false + }, + "status": "reference-verified", + "executionVerified": true, + "prerequisites": [ + "spring-aop", + "sql-jdbc" + ], + "verification": { + "checkedAt": "2026-10-12", + "runtimeJava": 21, + "compilerTarget": 17, + "maven": "3.10.0", + "referenceTests": 2, + "starterSatisfied": false, + "sourceFingerprint": "bec5b6f575b6d3e3a8875aa5cd5db2b14f6e5e84f99e76bc0a8c435e0339a0ab", + "scope": "reference-and-starter-tests; installed-app-verification-separate" + } +} diff --git a/curriculum/professional/lessons/tx-proxy/reference/pom.xml b/curriculum/professional/lessons/tx-proxy/reference/pom.xml new file mode 100644 index 0000000..5b32865 --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/reference/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltx-proxyorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context +org.springframeworkspring-jdbc +com.h2databaseh2 diff --git a/curriculum/professional/lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java b/curriculum/professional/lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java new file mode 100644 index 0000000..10f15be --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/reference/src/main/java/edu/lab/TransactionLab.java @@ -0,0 +1,33 @@ +package edu.lab; +import javax.sql.DataSource; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.springframework.context.annotation.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.*; +public final class TransactionLab { + public static class Orders { + private final JdbcTemplate jdbc; + public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; } + @Transactional + public void reserveThenFail() { + // BEGIN EXERCISE + jdbc.update("insert into audit_events(id) values('reserved')"); + throw new IllegalStateException("simulated downstream failure"); + // END EXERCISE + } + public void bypassProxy() { this.reserveThenFail(); } + } + @Configuration + @EnableTransactionManagement(proxyTargetClass=true) + public static class Config { + @Bean public DataSource dataSource() { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); return ds; + } + @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute("create table audit_events(id varchar(60) primary key)"); return jdbc; } + @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); } + @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); } + } +} diff --git a/curriculum/professional/lessons/tx-proxy/starter/pom.xml b/curriculum/professional/lessons/tx-proxy/starter/pom.xml new file mode 100644 index 0000000..5b32865 --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/starter/pom.xml @@ -0,0 +1,5 @@ +4.0.0edu.javaeduprofessional-parent0.1.0../../../pom.xmltx-proxyorg.junit.jupiterjunit-jupitertest +org.assertjassertj-coretest +org.springframeworkspring-context +org.springframeworkspring-jdbc +com.h2databaseh2 diff --git a/curriculum/professional/lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java b/curriculum/professional/lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java new file mode 100644 index 0000000..5cae4cf --- /dev/null +++ b/curriculum/professional/lessons/tx-proxy/starter/src/main/java/edu/lab/TransactionLab.java @@ -0,0 +1,30 @@ +package edu.lab; +import javax.sql.DataSource; +import java.util.UUID; +import org.h2.jdbcx.JdbcDataSource; +import org.springframework.context.annotation.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.*; +public final class TransactionLab { + public static class Orders { + private final JdbcTemplate jdbc; + public Orders(JdbcTemplate jdbc) { this.jdbc = jdbc; } + @Transactional + public void reserveThenFail() { + throw new UnsupportedOperationException("실습 구현 필요"); + } + public void bypassProxy() { this.reserveThenFail(); } + } + @Configuration + @EnableTransactionManagement(proxyTargetClass=true) + public static class Config { + @Bean public DataSource dataSource() { + JdbcDataSource ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); return ds; + } + @Bean public JdbcTemplate jdbc(DataSource ds) { JdbcTemplate jdbc = new JdbcTemplate(ds); jdbc.execute("create table audit_events(id varchar(60) primary key)"); return jdbc; } + @Bean public PlatformTransactionManager transactionManager(DataSource ds) { return new DataSourceTransactionManager(ds); } + @Bean public Orders orders(JdbcTemplate jdbc) { return new Orders(jdbc); } + } +} diff --git a/curriculum/professional/pom.xml b/curriculum/professional/pom.xml new file mode 100644 index 0000000..444070f --- /dev/null +++ b/curriculum/professional/pom.xml @@ -0,0 +1,14 @@ + + 4.0.0 + edu.javaeduprofessional-parent0.1.0pom + 17UTF-83.5.16 + org.springframework.bootspring-boot-dependencies${spring-boot.version}pomimport + + ${project.basedir}/../grader/src/test/java + ${project.basedir}/../grader/src/test/resources + + org.apache.maven.pluginsmaven-compiler-plugin3.14.117true + org.apache.maven.pluginsmaven-surefire-plugin3.5.4falsetrue + + + diff --git a/curriculum/professional/profiles.json b/curriculum/professional/profiles.json new file mode 100644 index 0000000..61362f8 --- /dev/null +++ b/curriculum/professional/profiles.json @@ -0,0 +1,1547 @@ +{ + "schemaVersion": 1, + "checkedAt": "2026-10-12", + "status": "source-prepared", + "profiles": [ + { + "id": "professional-junit", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "unit-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-mockito", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.mockito", + "artifactId": "mockito-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "unit-test", + "mock-subclass-no-agent" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-lombok", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.projectlombok", + "artifactId": "lombok", + "scope": "provided", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "unit-test", + "approved-annotation-processor" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-spring-core", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-context", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "context-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-spring-aop", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-context", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.aspectj", + "artifactId": "aspectjweaver", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "context-test", + "proxy" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-spring-web", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-validation", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "mock-http-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-jdbc", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-jdbc", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-mybatis", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.mybatis", + "artifactId": "mybatis", + "scope": "compile", + "version": "3.5.19" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test", + "xml-mapper" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-jpa", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.hibernate.orm", + "artifactId": "hibernate-core", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "jakarta.persistence", + "artifactId": "jakarta.persistence-api", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test", + "resource-local-jpa" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-flyway", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.flywaydb", + "artifactId": "flyway-core", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test", + "sql-migration" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-spring-tx", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-context", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework", + "artifactId": "spring-jdbc", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "database-test", + "spring-transaction-proxy" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-security", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-security", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "mock-http-test", + "security-filter-chain" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-oidc-fixture", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-oauth2-client", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "oidc-test-principal", + "lifecycle": "in-process", + "network": false, + "fullProtocolFlow": false + } + ], + "capabilities": [ + "mock-http-test", + "oidc-principal-boundary" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-jwt-local", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-oauth2-jose", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-oauth2-resource-server", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "test-rsa-keys", + "lifecycle": "generated-per-test", + "productionCredentials": false + } + ], + "capabilities": [ + "jwt-signature-and-claims-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-redis", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": null + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": false, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-data-redis", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "redis", + "provisioning": "optional-local-docker", + "environment": [ + "JAVA_EDU_REDIS_HOST", + "JAVA_EDU_REDIS_PORT" + ], + "lifecycle": "isolated-training-instance" + } + ], + "capabilities": [ + "redis-integration-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "not-executed", + "network": { + "required": "loopback-training-services", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-kafka", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": null + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": false, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.apache.kafka", + "artifactId": "kafka-clients", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "kafka", + "provisioning": "optional-local-docker", + "environment": [ + "JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS" + ], + "lifecycle": "isolated-training-broker" + }, + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "kafka-integration-test", + "database-deduplication" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "not-executed", + "network": { + "required": "loopback-training-services", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-observability", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "io.micrometer", + "artifactId": "micrometer-core", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "io.micrometer", + "artifactId": "micrometer-observation", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "metric-and-observation-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-gradle-ci", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven-with-gradle-comparison", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [], + "capabilities": [ + "unit-test", + "gradle-comparison", + "configuration-review" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + }, + { + "id": "professional-capstone", + "compilerTarget": 17, + "runtimeRequirement": { + "minimumJava": 17, + "recommendedJava": 21, + "verifiedRuntime": 21 + }, + "build": { + "tool": "maven", + "bom": "3.5.16", + "dependencyResolutionVerified": true, + "plugins": { + "compiler": "3.14.1", + "surefire": "3.5.4" + }, + "trustedCommand": [ + "mvn", + "-B", + "-ntp", + "test" + ], + "studentBuildFileEditing": false + }, + "dependencies": [ + { + "groupId": "org.junit.jupiter", + "artifactId": "junit-jupiter", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.assertj", + "artifactId": "assertj-core", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-web", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-validation", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-security", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-jdbc", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "com.h2database", + "artifactId": "h2", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.boot", + "artifactId": "spring-boot-starter-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.springframework.security", + "artifactId": "spring-security-test", + "scope": "test", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + }, + { + "groupId": "org.flywaydb", + "artifactId": "flyway-core", + "scope": "compile", + "managedBy": "org.springframework.boot:spring-boot-dependencies:3.5.16" + } + ], + "resources": [ + { + "type": "h2", + "lifecycle": "in-memory-per-run", + "network": false + } + ], + "capabilities": [ + "http-database-security-test" + ], + "execution": { + "location": "user-windows-desktop", + "remoteCodeExecution": false, + "status": "reference-and-starter-verified", + "network": { + "required": "none", + "enforcement": "not-os-sandboxed" + }, + "dependencyDownload": "explicit-toolchain-setup-only" + }, + "publication": { + "webStaticLessonAllowed": true, + "interactiveRunRequires": [ + "toolchain-validation", + "dependency-resolution", + "reference-tests", + "independent-review" + ], + "productionReadiness": false + } + } + ] +} diff --git a/desktop/build-config.cjs b/desktop/build-config.cjs new file mode 100644 index 0000000..c6b90d0 --- /dev/null +++ b/desktop/build-config.cjs @@ -0,0 +1,18 @@ +const path = require('node:path'); +const { loadBundledRuntime } = require('./bundled-runtime.cjs'); + +module.exports = { + appId: 'kr.javaedu.desktop', + productName: 'Java Edu', + asar: true, + asarUnpack: ['desktop/job-runner.ps1'], + extraResources: [{ from: 'work/desktop-bundle', to: 'toolchains', filter: ['**/*'] }], + beforePack: async () => { await loadBundledRuntime(path.join(__dirname, '..', 'work', 'desktop-bundle')); }, + afterPack: async ({ appOutDir }) => { await loadBundledRuntime(path.join(appOutDir, 'resources', 'toolchains')); }, + directories: { output: 'desktop-release' }, + files: ['desktop/**', 'server/**', 'dist/**', '!dist/server/**', '!dist/.openai/**', 'curriculum/professional/**', 'package.json', '!**/target/**', '!**/node_modules/**'], + win: { target: [{ target: 'nsis', arch: ['x64'] }, { target: 'zip', arch: ['x64'] }], artifactName: 'Java-Edu-${version}-${arch}.${ext}', signAndEditExecutable: false }, + // Preserve vendor ARM64 files: the bundled NSIS 7z decoder predates the ARM64 codec. + nsis: { artifactName: 'Java-Edu-Setup-${version}-${arch}.${ext}', differentialPackage: false, useZip: true, oneClick: false, perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, createStartMenuShortcut: true, runAfterFinish: false, deleteAppDataOnUninstall: false }, + publish: null, +}; diff --git a/desktop/bundled-runtime.cjs b/desktop/bundled-runtime.cjs new file mode 100644 index 0000000..bde7e94 --- /dev/null +++ b/desktop/bundled-runtime.cjs @@ -0,0 +1,132 @@ +const { createHash } = require('node:crypto'); +const { createReadStream } = require('node:fs'); +const { lstat, readFile, readdir, realpath } = require('node:fs/promises'); +const path = require('node:path'); + +const JAVA_VERSIONS = [8, 11, 17, 21]; +const MAX_FILES = 12_000; +const MAX_MANIFEST_BYTES = 8 * 1024 * 1024; +const SHA256 = /^[a-f0-9]{64}$/; +const BUNDLE_ERROR = '앱에 포함된 Java/Maven 파일을 확인할 수 없습니다. 원본 설치 파일을 다시 설치하거나 ZIP을 새 폴더에 풀어 주세요.'; + +function failure(detail) { + const error = new Error(`${BUNDLE_ERROR} (${detail})`); + error.code = 'invalid_bundled_runtime'; + return error; +} + +function safeRelative(value) { + if (typeof value !== 'string' || !value || value.length > 700 || /[\\\x00-\x1f<>:"|?*]/.test(value) + || value.split('/').some((part) => !part || part === '.' || part === '..' || /[. ]$/.test(part) || /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(part))) throw failure('invalid relative path'); + return value; +} + +function httpsUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port && !url.hash; + } catch { return false; } +} + +function validateProvenance(value) { + if (!value || typeof value !== 'object' || typeof value.distribution !== 'string' || !value.distribution.trim() + || typeof value.version !== 'string' || !value.version.trim() || !httpsUrl(value.sourceUrl) + || !SHA256.test(value.archiveSha256) || typeof value.license !== 'string' || !value.license.trim() + || !Array.isArray(value.licenseFiles) || !value.licenseFiles.length || value.licenseFiles.length > 40 + || (value.sourceCodeUrl !== undefined && !httpsUrl(value.sourceCodeUrl)) + || (value.redistributionSourceUrl !== undefined && !httpsUrl(value.redistributionSourceUrl)) + || (value.redistributionSourceFiles !== undefined && (!Array.isArray(value.redistributionSourceFiles) || !value.redistributionSourceFiles.length || value.redistributionSourceFiles.length > 40 || !value.redistributionSourceUrl))) throw failure('missing distribution provenance'); + for (const name of value.licenseFiles) safeRelative(name); + for (const name of value.redistributionSourceFiles || []) safeRelative(name); +} + +function manifestIdentity(manifest) { + const { id: _id, ...contents } = manifest; + return createHash('sha256').update(JSON.stringify(contents)).digest('hex'); +} + +async function sha256File(file) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} + +/** No links or other special files may redirect packaged toolchains outside their root. */ +async function inventoryTree(root, { excludeManifest = false } = {}) { + const files = []; + async function visit(directory, prefix = '') { + for (const item of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { + const relative = safeRelative(prefix ? `${prefix}/${item.name}` : item.name); + if (excludeManifest && relative === 'manifest.json') continue; + const file = path.join(directory, item.name); + const info = await lstat(file); + if (info.isSymbolicLink()) throw failure('linked distribution file'); + if (info.isDirectory()) await visit(file, relative); + else if (info.isFile()) { + if (files.length >= MAX_FILES) throw failure('too many distribution files'); + files.push({ path: relative, size: info.size, sha256: await sha256File(file) }); + } else throw failure('unsupported distribution file'); + } + } + await visit(root); + return files; +} + +/** Check all packaged bytes before using metadata. The manifest is integrity data, not a signature. */ +async function inspectBundledRuntime(directory, { required = true } = {}) { + if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw failure('absolute bundle root required'); + const rootInfo = await lstat(directory).catch((error) => { if (!required && error.code === 'ENOENT') return null; throw error; }); + if (!rootInfo) return null; + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) throw failure('invalid bundle directory'); + const root = await realpath(directory); + const manifestPath = path.join(root, 'manifest.json'); + const info = await lstat(manifestPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_MANIFEST_BYTES) throw failure('invalid manifest file'); + let manifest; + try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } catch { throw failure('invalid manifest JSON'); } + if (manifest.schemaVersion !== 1 || manifest.platform !== 'win32' || manifest.arch !== 'x64' + || !Array.isArray(manifest.jdks) || !manifest.jdks.length || manifest.jdks.length > JAVA_VERSIONS.length + || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > MAX_FILES + || !SHA256.test(manifest.id) || manifest.id !== manifestIdentity(manifest)) throw failure('unsupported manifest'); + const versions = new Set(); + for (const jdk of manifest.jdks) { + validateProvenance(jdk); + if (!JAVA_VERSIONS.includes(jdk.major) || versions.has(jdk.major) || jdk.home !== `jdks/${jdk.major}`) throw failure('invalid JDK layout'); + versions.add(jdk.major); + } + if (!versions.has(21)) throw failure('default Java 21 missing'); + validateProvenance(manifest.maven); + if (manifest.maven.home !== 'maven' || !/^3\.([9]|[1-9]\d+)\.\d+$/.test(manifest.maven.version)) throw failure('invalid Maven layout or version'); + const expected = new Map(); + const insensitive = new Set(); + for (const file of manifest.files) { + const name = safeRelative(file.path); + if (!SHA256.test(file.sha256) || !Number.isSafeInteger(file.size) || file.size < 0 || expected.has(name) || insensitive.has(name.toLowerCase()) + || !['THIRD-PARTY-NOTICES.txt', ...manifest.jdks.map((jdk) => `${jdk.home}/`), 'maven/'].some((prefix) => prefix.endsWith('/') ? name.startsWith(prefix) : name === prefix)) throw failure('invalid file inventory'); + expected.set(name, file); + insensitive.add(name.toLowerCase()); + } + for (const jdk of manifest.jdks) { + for (const name of ['bin/java.exe', 'bin/javac.exe', 'release', ...jdk.licenseFiles]) if (!expected.has(`${jdk.home}/${name}`)) throw failure('missing JDK or license file'); + } + for (const name of ['bin/m2.conf', ...manifest.maven.licenseFiles]) if (!expected.has(`maven/${name}`)) throw failure('missing Maven or license file'); + if (!expected.has('THIRD-PARTY-NOTICES.txt') || !manifest.files.some((file) => /^maven\/boot\/plexus-classworlds-[A-Za-z0-9.-]+\.jar$/.test(file.path)) + || !expected.has(`maven/lib/maven-core-${manifest.maven.version}.jar`)) throw failure('incomplete Maven distribution'); + const actual = await inventoryTree(root, { excludeManifest: true }); + if (actual.length !== expected.size || actual.some((file) => expected.get(file.path)?.sha256 !== file.sha256 || expected.get(file.path)?.size !== file.size)) throw failure('distribution integrity mismatch'); + return { + id: manifest.id, + root, + jdks: manifest.jdks.map((jdk) => ({ version: jdk.major, home: path.join(root, ...jdk.home.split('/')), description: `${jdk.distribution} ${jdk.version} · 앱 포함`, distribution: jdk.distribution, distributionVersion: jdk.version })), + maven: { home: path.join(root, 'maven'), version: manifest.maven.version }, + fileCount: actual.length, + bytes: actual.reduce((total, file) => total + file.size, 0), + }; +} + +async function loadBundledRuntime(directory, options) { + try { return await inspectBundledRuntime(directory, options); } + catch (error) { if (error.code === 'invalid_bundled_runtime') throw error; throw failure('missing or unreadable distribution file'); } +} + +module.exports = { loadBundledRuntime, manifestIdentity, inventoryTree, validateProvenance }; diff --git a/desktop/job-runner.ps1 b/desktop/job-runner.ps1 new file mode 100644 index 0000000..fe0936f --- /dev/null +++ b/desktop/job-runner.ps1 @@ -0,0 +1,114 @@ +param([Parameter(Mandatory = $true)][string]$SpecPath) +$ErrorActionPreference = 'Stop' +$PSModuleAutoLoadingPreference = 'None' +$source = @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +public static class JavaEduJob { + [StructLayout(LayoutKind.Sequential)] struct BasicLimit { + public long ProcessTime, JobTime; public uint Flags; public UIntPtr MinWorking, MaxWorking; + public uint ActiveProcesses; public UIntPtr Affinity; public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct IoCounters { public ulong A,B,C,D,E,F; } + [StructLayout(LayoutKind.Sequential)] struct ExtendedLimit { + public BasicLimit Basic; public IoCounters Io; public UIntPtr ProcessMemory, JobMemory, PeakProcess, PeakJob; + } + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct StartupInfo { + public uint cb; public string reserved, desktop, title; public uint x,y,xsize,ysize,xchars,ychars,fill,flags; + public ushort show, reservedSize; public IntPtr reservedBytes, input, output, error; + } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr process, thread; public uint pid, tid; } + [StructLayout(LayoutKind.Sequential)] struct StartupInfoEx { public StartupInfo basic; public IntPtr attributes; } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attr, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int cls, ref ExtendedLimit limits, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool CreateProcess(string app, StringBuilder cmd, IntPtr pa, IntPtr ta, bool inherit, uint flags, IntPtr env, string cwd, ref StartupInfoEx si, out ProcessInfo pi); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size, IntPtr old, IntPtr result); + [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForMultipleObjects(uint count, IntPtr[] handles, bool all, uint milliseconds); + [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int which); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + static void Check(bool success) { if (!success) throw new Win32Exception(Marshal.GetLastWin32Error()); } + static string Quote(string value) { + var b = new StringBuilder("\""); int slash = 0; + foreach(char c in value) { + if (c == '\\') { slash++; continue; } + if (c == '"') { b.Append('\\', slash * 2 + 1); b.Append(c); slash = 0; continue; } + b.Append('\\', slash); slash = 0; b.Append(c); + } + b.Append('\\', slash * 2); b.Append('"'); return b.ToString(); + } + public static string[] Run(string executable, string[] args, string cwd, int timeout, int memory, int processLimit, uint parentPid) { + IntPtr job = IntPtr.Zero, parent = IntPtr.Zero, attributes = IntPtr.Zero, handles = IntPtr.Zero, jobList = IntPtr.Zero; + var pi = new ProcessInfo(); bool resumed = false, attributesInitialized = false; + try { + // A non-inheritable, opened process handle prevents PID-reuse races while monitoring app death. + parent = OpenProcess(0x00100000, false, parentPid); Check(parent != IntPtr.Zero); + job = CreateJobObject(IntPtr.Zero, null); Check(job != IntPtr.Zero); + var limits = new ExtendedLimit(); + limits.Basic.Flags = 0x00002000 | 0x00000008 | 0x00000200; // KILL_ON_JOB_CLOSE | ACTIVE_PROCESS | JOB_MEMORY + limits.Basic.ActiveProcesses = (uint)processLimit; limits.JobMemory = new UIntPtr((ulong)memory * 1024 * 1024); + Check(SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(limits))); + var cmd = new StringBuilder(Quote(executable)); foreach(var arg in args) cmd.Append(" ").Append(Quote(arg)); + var si = new StartupInfoEx(); si.basic.cb = (uint)Marshal.SizeOf(si); si.basic.flags = 0x00000100; + si.basic.input = GetStdHandle(-10); si.basic.output = GetStdHandle(-11); si.basic.error = GetStdHandle(-12); + // Explicit inheritance allowlist: no helper/app/Job handles can enter learner processes. + var inherited = new List(); + foreach (var handle in new[] { si.basic.input, si.basic.output, si.basic.error }) if (handle != IntPtr.Zero && handle != new IntPtr(-1) && !inherited.Contains(handle)) inherited.Add(handle); + IntPtr attributeSize = IntPtr.Zero; InitializeProcThreadAttributeList(IntPtr.Zero, 2, 0, ref attributeSize); + attributes = Marshal.AllocHGlobal(attributeSize); Check(InitializeProcThreadAttributeList(attributes, 2, 0, ref attributeSize)); attributesInitialized = true; + handles = Marshal.AllocHGlobal(IntPtr.Size * inherited.Count); + for (int i = 0; i < inherited.Count; i++) Marshal.WriteIntPtr(handles, i * IntPtr.Size, inherited[i]); + Check(UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x00020002), handles, new IntPtr(IntPtr.Size * inherited.Count), IntPtr.Zero, IntPtr.Zero)); + // Windows 10+: membership is atomic with process creation, including cancellation before ResumeThread. + jobList = Marshal.AllocHGlobal(IntPtr.Size); Marshal.WriteIntPtr(jobList, job); + Check(UpdateProcThreadAttribute(attributes, 0, new IntPtr(0x0002000D), jobList, new IntPtr(IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); + si.attributes = attributes; + Check(CreateProcess(executable, cmd, IntPtr.Zero, IntPtr.Zero, true, 0x00000004 | 0x08000000 | 0x00080000, IntPtr.Zero, cwd, ref si, out pi)); + if (ResumeThread(pi.thread) == UInt32.MaxValue) throw new Win32Exception(Marshal.GetLastWin32Error()); + resumed = true; + var reason = "exited"; + uint wait = WaitForMultipleObjects(2, new[] { pi.process, parent }, false, (uint)timeout); + if (wait == 1) { reason = "parent_exited"; TerminateJobObject(job, 125); } + else if (wait == 0x00000102) { reason = "timeout"; TerminateJobObject(job, 124); } + else if (wait != 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + uint code; Check(GetExitCodeProcess(pi.process, out code)); + return new[] { code.ToString(), reason }; + } finally { + if (!resumed && pi.process != IntPtr.Zero) TerminateProcess(pi.process, 126); + if (job != IntPtr.Zero) { TerminateJobObject(job, 125); CloseHandle(job); } + if (pi.thread != IntPtr.Zero) CloseHandle(pi.thread); + if (pi.process != IntPtr.Zero) CloseHandle(pi.process); + if (parent != IntPtr.Zero) CloseHandle(parent); + if (attributesInitialized) DeleteProcThreadAttributeList(attributes); + if (attributes != IntPtr.Zero) Marshal.FreeHGlobal(attributes); + if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles); + if (jobList != IntPtr.Zero) Marshal.FreeHGlobal(jobList); + } + } +} +'@ +try { + # Avoid scanning user/third-party modules on clean Windows hosts. Only the OS utility module is needed. + Import-Module -Name ([System.IO.Path]::Combine($PSHOME, 'Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop + $spec = [System.IO.File]::ReadAllText($SpecPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + Add-Type -TypeDefinition $source -Language CSharp + # Compilation uses a private OS-temp child; learner processes keep their original job-local temp. + $env:TEMP = if ($spec.childTemp) { [string]$spec.childTemp } else { [string]$spec.cwd } + $env:TMP = if ($spec.childTmp) { [string]$spec.childTmp } else { [string]$spec.cwd } + $result = [JavaEduJob]::Run([string]$spec.executable, [string[]]$spec.args, [string]$spec.cwd, [int]$spec.timeoutMs, [int]$spec.memoryMiB, [int]$spec.maxProcesses, [uint32]$spec.parentPid) + $report = @{ exitCode = [long]$result[0]; reason = $result[1] } | ConvertTo-Json -Compress + [System.IO.File]::WriteAllText([string]$spec.resultPath, $report, (New-Object System.Text.UTF8Encoding($false))) +} catch { + [Console]::Error.WriteLine('Java Edu process supervisor could not start or supervise the command: ' + $_.Exception.Message) + exit 126 +} diff --git a/desktop/main.cjs b/desktop/main.cjs new file mode 100644 index 0000000..ca116f9 --- /dev/null +++ b/desktop/main.cjs @@ -0,0 +1,104 @@ +const { app, BrowserWindow, dialog, ipcMain, session, shell } = require('electron'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { createDesktopStorage } = require('./storage.cjs'); +const { loadBundledRuntime } = require('./bundled-runtime.cjs'); +const { externalReferenceUrl } = require('./reference-policy.cjs'); +const { mkdirSync } = require('node:fs'); + +if (!app.isPackaged && process.env.JAVA_EDU_QA_DATA) { + if (!path.isAbsolute(process.env.JAVA_EDU_QA_DATA)) throw new Error('개발 검증 데이터 경로는 절대 경로여야 합니다.'); + mkdirSync(process.env.JAVA_EDU_QA_DATA, { recursive: true }); + app.setPath('userData', process.env.JAVA_EDU_QA_DATA); +} + +let mainWindow; +let localServer; +let settings; +let engine; +let allowQuit = false; +let quitting = false; + +function authorizeIpc(event) { + if (!mainWindow || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame || new URL(event.senderFrame.url).origin !== localServer.origin) throw new Error('설치형 앱의 기본 화면에서만 사용할 수 있습니다.'); +} + +function safelyOpenReference(value) { + const url = externalReferenceUrl(value); + if (url) void shell.openExternal(url); +} + +async function initialize() { + const root = path.join(__dirname, '..'); + const dataRoot = path.join(app.getPath('userData'), 'java-edu'); + const [{ createSettingsStore }, { createLocalEngine }, { startLocalServer, SESSION_COOKIE, CSRF_HEADER }] = await Promise.all([ + import(pathToFileURL(path.join(root, 'server/local/settings.mjs'))), + import(pathToFileURL(path.join(root, 'server/local/engine.mjs'))), + import(pathToFileURL(path.join(root, 'server/local/http.mjs'))), + ]); + const bundledRuntime = await loadBundledRuntime(app.isPackaged ? path.join(process.resourcesPath, 'toolchains') : path.join(root, 'work', 'desktop-bundle'), { required: app.isPackaged }); + settings = createSettingsStore(dataRoot, { bundledRuntime }); + await settings.initialize(); + const storage = createDesktopStorage(dataRoot); + engine = await createLocalEngine({ dataRoot, projectRoot: path.join(root, 'curriculum/professional'), settings }); + localServer = await startLocalServer({ distRoot: path.join(root, 'dist'), engine }); + const localSession = session.fromPartition('java-edu-desktop'); // In-memory cookies: no reusable session credential on disk. + localSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + localSession.setPermissionCheckHandler(() => false); + await localSession.cookies.set({ url: localServer.origin, name: SESSION_COOKIE, value: localServer.sessionToken, httpOnly: true, sameSite: 'strict', path: '/' }); + mainWindow = new BrowserWindow({ width: 1440, height: 940, minWidth: 720, minHeight: 560, show: false, title: 'Java Edu', backgroundColor: '#f5f2e9', + webPreferences: { preload: path.join(__dirname, 'preload.cjs'), partition: 'java-edu-desktop', contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, spellcheck: false } }); + mainWindow.removeMenu(); + localSession.webRequest.onBeforeRequest((details, callback) => { + const allowed = details.webContentsId === mainWindow?.webContents.id && new URL(details.url).origin === localServer.origin; + callback({ cancel: !allowed }); + }); + localSession.webRequest.onBeforeSendHeaders({ urls: [`${localServer.origin}/*`] }, (details, callback) => { + const headers = { ...details.requestHeaders }; + for (const key of Object.keys(headers)) if (key.toLowerCase() === CSRF_HEADER) delete headers[key]; + if (details.webContentsId === mainWindow?.webContents.id && new URL(details.url).origin === localServer.origin) headers[CSRF_HEADER] = localServer.csrfToken; + callback({ requestHeaders: headers }); + }); + mainWindow.webContents.on('will-navigate', (event, url) => { event.preventDefault(); safelyOpenReference(url); }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { safelyOpenReference(url); return { action: 'deny' }; }); + mainWindow.webContents.on('will-attach-webview', (event) => event.preventDefault()); + localSession.on('will-download', (event) => event.preventDefault()); + const ipc = (channel, handler) => ipcMain.handle(channel, async (event, ...args) => { authorizeIpc(event); return handler(...args); }); + ipc('java-edu:settings', () => engine.runtime()); + ipc('java-edu:choose-jdk', async (version) => { + if (![8, 11, 17, 21].includes(version)) throw new Error('지원하는 JDK 버전을 선택하세요.'); + const selected = await dialog.showOpenDialog(mainWindow, { title: `Java ${version} JDK 폴더 선택 (bin 폴더의 상위)`, properties: ['openDirectory'] }); + if (!selected.canceled) await settings.chooseJdk(selected.filePaths[0], version); + return engine.runtime(); + }); + ipc('java-edu:choose-maven', async () => { + const selected = await dialog.showOpenDialog(mainWindow, { title: 'Apache Maven 3.9 이상 배포본 폴더 선택', properties: ['openDirectory'] }); + if (!selected.canceled) await settings.chooseMaven(selected.filePaths[0]); + return engine.runtime(); + }); + ipc('java-edu:consent', async (accepted) => { await settings.setConsent(accepted); if (!accepted) await engine.cancelActive(); return engine.runtime(); }); + ipcMain.on('java-edu:storage', (event, input) => { + try { + authorizeIpc(event); + if (!input || !['getItem', 'setItem', 'removeItem'].includes(input.method)) throw new Error('지원하지 않는 저장 요청입니다.'); + event.returnValue = { value: storage[input.method](input.key, input.value) }; + } catch (error) { event.returnValue = { error: error.message }; } + }); + mainWindow.once('ready-to-show', () => mainWindow.show()); + await mainWindow.loadURL(localServer.origin); +} + +app.setAppUserModelId('kr.javaedu.desktop'); +if (!app.requestSingleInstanceLock()) app.quit(); +else { + app.on('second-instance', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); + app.whenReady().then(initialize).catch(async (error) => { dialog.showErrorBox('Java Edu 시작 실패', error.message); await localServer?.close(); allowQuit = true; app.quit(); }); + app.on('window-all-closed', () => app.quit()); + app.on('before-quit', (event) => { + if (allowQuit) return; + event.preventDefault(); + if (quitting) return; + quitting = true; + Promise.resolve(localServer?.close()).finally(() => { allowQuit = true; app.quit(); }); + }); +} diff --git a/desktop/preload.cjs b/desktop/preload.cjs new file mode 100644 index 0000000..eb4e8f2 --- /dev/null +++ b/desktop/preload.cjs @@ -0,0 +1,20 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +function storageCall(method, key, value) { + const result = ipcRenderer.sendSync('java-edu:storage', { method, key, value }); + if (result.error) throw new Error(result.error); + return result.value ?? null; +} + +contextBridge.exposeInMainWorld('javaEduDesktop', Object.freeze({ + platform: process.platform, + getSettings: () => ipcRenderer.invoke('java-edu:settings'), + chooseJdk: (version) => ipcRenderer.invoke('java-edu:choose-jdk', version), + chooseMaven: () => ipcRenderer.invoke('java-edu:choose-maven'), + setExecutionConsent: (accepted) => ipcRenderer.invoke('java-edu:consent', accepted), + storage: Object.freeze({ + getItem: (key) => storageCall('getItem', key), + setItem: (key, value) => storageCall('setItem', key, value), + removeItem: (key) => storageCall('removeItem', key), + }), +})); diff --git a/desktop/reference-policy.cjs b/desktop/reference-policy.cjs new file mode 100644 index 0000000..b49aae4 --- /dev/null +++ b/desktop/reference-policy.cjs @@ -0,0 +1,55 @@ +// Explicitly reviewed textbook, tool download and hiring-evidence hosts. +// New lesson sources must pass the curriculum coverage test before release. +const EXTERNAL_HOSTS = new Set([ + 'adoptium.net', + 'assertj.github.io', + 'career.rememberapp.co.kr', + 'dev.java', + 'docs.docker.com', + 'docs.github.com', + 'docs.gradle.org', + 'docs.hibernate.org', + 'docs.junit.org', + 'docs.micrometer.io', + 'docs.oracle.com', + 'docs.spring.io', + 'documentation.red-gate.com', + 'github.com', + 'jakarta.ee', + 'java.testcontainers.org', + 'javadoc.io', + 'junit.org', + 'jumpit.saramin.co.kr', + 'kafka.apache.org', + 'maven.apache.org', + 'mybatis.org', + 'openid.net', + 'openjdk.org', + 'projectlombok.org', + 'redis.io', + 'site.mockito.org', + 'spring.io', + 'testcontainers.com', + 'toss.im', + 'www.jobplanet.co.kr', + 'www.jumpit.co.kr', + 'www.rallit.com', + 'www.rfc-editor.org', + 'www.wanted.co.kr', + 'www.work24.go.kr', +]); + +function externalReferenceUrl(value) { + if (typeof value !== 'string' || value.length > 8192 || /[\u0000-\u0020\u007f\\]/.test(value)) return null; + // Check the original authority as URL normalization removes explicit :443. + // Credentials, ports and encoded hostnames are not textbook link forms. + const authority = /^https:\/\/([^/?#]+)(?:[/?#]|$)/i.exec(value)?.[1]; + if (!authority || /[:@%]/.test(authority)) return null; + try { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password || url.port || !EXTERNAL_HOSTS.has(url.hostname)) return null; + return url.href; + } catch { return null; } +} + +module.exports = { externalReferenceUrl }; diff --git a/desktop/storage.cjs b/desktop/storage.cjs new file mode 100644 index 0000000..b7cc3bf --- /dev/null +++ b/desktop/storage.cjs @@ -0,0 +1,29 @@ +const { readFileSync, writeFileSync, renameSync } = require('node:fs'); +const path = require('node:path'); + +const MAX_BYTES = 8 * 1024 * 1024; +const validKey = (key) => typeof key === 'string' && /^java-edu:[a-z0-9:-]{1,100}$/.test(key); + +module.exports.createDesktopStorage = (dataRoot) => { + const filename = path.join(dataRoot, 'learning-state.json'); + let data = Object.create(null); + try { + const text = readFileSync(filename, 'utf8'); + if (Buffer.byteLength(text) <= MAX_BYTES) { + const stored = JSON.parse(text); + for (const [key, value] of Object.entries(stored)) if (validKey(key) && typeof value === 'string') data[key] = value; + } + } catch { /* New or corrupt storage starts with an empty, recoverable learning state. */ } + const save = (next) => { + const text = JSON.stringify(next); + if (Buffer.byteLength(text) > MAX_BYTES) throw new Error('학습 저장 공간 8 MiB를 초과했습니다.'); + writeFileSync(`${filename}.tmp`, text, { mode: 0o600 }); + renameSync(`${filename}.tmp`, filename); + data = next; + }; + return { + getItem(key) { if (!validKey(key)) throw new Error('지원하지 않는 학습 데이터 키입니다.'); return data[key] ?? null; }, + setItem(key, value) { if (!validKey(key) || typeof value !== 'string') throw new Error('학습 데이터 형식을 확인하세요.'); save({ ...data, [key]: value }); }, + removeItem(key) { if (!validKey(key)) throw new Error('지원하지 않는 학습 데이터 키입니다.'); const next = { ...data }; delete next[key]; save(next); }, + }; +}; diff --git a/desktop/toolchain-provenance.json b/desktop/toolchain-provenance.json new file mode 100644 index 0000000..4d963e3 --- /dev/null +++ b/desktop/toolchain-provenance.json @@ -0,0 +1,46 @@ +{ + "checkedAt": "2026-10-12", + "jdks": [ + { + "major": 21, + "distribution": "Eclipse Temurin", + "version": "21.0.12.1+1", + "sourceUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12.1_1.zip", + "archiveSha256": "f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e", + "license": "GPL-2.0-with-classpath-exception", + "licenseFiles": [ + "legal/java.base/LICENSE", + "legal/java.base/ASSEMBLY_EXCEPTION", + "NOTICE" + ], + "sourceCodeUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", + "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", + "redistributionSourceFiles": [ + "OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz", + "Third-Party-Sources-0.3.0.zip/temurin-build-e6ba7dec3d07654074559310376a3ae89da5f4ac.tar.gz" + ] + } + ], + "maven": { + "distribution": "Apache Maven", + "version": "3.10.0", + "sourceUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/binaries/apache-maven-3.10.0-bin.zip", + "archiveSha256": "1f6d9909266510f039f59aa0e13dcd2c66da85f043e56276e41f2918f8bddaff", + "license": "Apache-2.0 and bundled third-party licenses", + "licenseFiles": [ + "LICENSE", + "NOTICE", + "lib/org.eclipse.sisu.inject.license", + "lib/org.eclipse.sisu.plexus.license", + "lib/javax.annotation-api.license" + ], + "sourceCodeUrl": "https://downloads.apache.org/maven/maven-3/3.10.0/source/apache-maven-3.10.0-src.zip", + "redistributionSourceUrl": "https://github.com/jbsim999/java-edu/releases/tag/v0.3.0", + "redistributionSourceFiles": [ + "Third-Party-Sources-0.3.0.zip/apache-maven-3.10.0-src.zip", + "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.inject-1.1.0-sources.jar", + "Third-Party-Sources-0.3.0.zip/org.eclipse.sisu.plexus-1.1.0-sources.jar", + "Third-Party-Sources-0.3.0.zip/javax.annotation-api-1.3.2-sources.jar" + ] + } +} diff --git a/dist/app.js b/dist/app.js new file mode 100644 index 0000000..bbc2a76 --- /dev/null +++ b/dist/app.js @@ -0,0 +1,1991 @@ +const STORAGE_KEY = 'java-edu:progress:v1'; +const EDITOR_PREFERENCES_KEY = 'java-edu:editor-preferences:v1'; +const EDITOR_THEMES = ['dark', 'light', 'contrast']; +const VERSIONS = [8, 11, 17, 21]; +const STEPS = ['theory', 'example', 'quiz', 'practice', 'lab']; +const STEP_LABELS = { theory: '이론', example: '예제', quiz: '문제', practice: '실무', lab: '코드 실습' }; +const TRANSITION_IDS = new Set(['java-basics', 'oop', 'polymorphism', 'collections', 'generics', 'exceptions', 'streams', 'modern-apis', 'records-sealed']); +const ADVANCED_IDS = new Set(['oop', 'polymorphism', 'generics', 'streams', 'concurrency', 'records-sealed', 'java21', 'testing-design']); +const EXECUTION_STATUSES = new Set(['passed', 'failed', 'compile_error', 'runtime_error', 'timeout', 'provider_error']); +const SOURCE_LIMIT = 50000; +const PROJECT_SOURCE_LIMIT = 65536; +const PROJECT_SNAPSHOT_LIMIT = 384 * 1024; +const NOTE_LIMIT = 20000; +const desktop = window.javaEduDesktop || null; +const isDesktop = Boolean(desktop); +const mobileMedia = window.matchMedia('(max-width: 899px)'); +let lessons = []; +let exercises = []; +let jobEvidence = []; +let lessonVisuals = {}; +let saveTimer; +let toastTimer; +let runtimeSequence = 0; +let editorLibrary = null; +let activeEditor = null; + +const state = { + version: 21, track: 'all', search: '', lessonId: '', step: 'theory', + quizPassed: new Set(), labPassed: new Map(), drafts: new Map(), notes: new Map(), + fileDrafts: new Map(), activeFiles: new Map(), projectInfo: new Map(), + quizAnswers: new Map(), quizResults: new Map(), labResults: new Map(), undo: new Map(), + storage: 'ready', runtime: null, runtimeStatus: 'checking', runtimeMessage: '', + pending: null, menuOpen: false, + editorTheme: 'dark', editorWrap: false, + runtimeVersion: null, settingsBusy: false, settingsMessage: '', cancelling: false, +}; + +const dom = Object.fromEntries([ + 'workspace-grid', 'loading-state', 'lesson-list', 'lesson-card', 'lesson-panel', 'lesson-title', + 'lesson-description', 'lesson-category', 'lesson-position', 'lesson-meta', 'lesson-achievements', + 'lesson-support', 'lesson-info', 'lesson-detail-meta', 'lesson-goals', + 'lesson-footer', 'source-list', 'evidence-list', 'reference-column', 'version-select', + 'lesson-search', 'curriculum-sidebar', 'mobile-menu-toggle', 'drawer-backdrop', +].map((id) => [id, document.getElementById(id)])); + +function el(tag, className = '', text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = String(text); + return node; +} + +function icon(name) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + svg.setAttribute('class', 'icon'); + svg.setAttribute('aria-hidden', 'true'); + use.setAttribute('href', `#icon-${name}`); + svg.append(use); + return svg; +} + +function actionButton(text, className, onClick, id) { + const button = el('button', className, text); + button.type = 'button'; + if (id) { button.id = id; button.dataset.testid = id; } + button.addEventListener('click', onClick); + return button; +} + +function safeLink(title, url, className = '') { + const link = el('a', className); + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Unsupported URL'); + link.href = parsed.href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + } catch { + link.removeAttribute('href'); + } + if (title) link.textContent = title; + return link; +} + +function announce(message) { + const target = document.getElementById('announcer'); + target.textContent = ''; + window.setTimeout(() => { target.textContent = message; }, 30); +} + +function toast(message) { + const target = document.getElementById('toast'); + clearTimeout(toastTimer); + target.textContent = message; + target.classList.add('is-visible'); + toastTimer = window.setTimeout(() => target.classList.remove('is-visible'), 4500); +} + +function currentLesson() { return visibleLessons().find((lesson) => lesson.id === state.lessonId); } +function exerciseFor(lesson) { return lesson && exercises.find((exercise) => exercise.id === lesson.exerciseId || exercise.lessonId === lesson.id); } +function storageAdapter() { return desktop?.storage ?? window.localStorage; } +function isProject(exercise) { return exercise?.mode === 'project'; } +function projectFiles(exercise) { return state.projectInfo.get(exercise.id)?.files || exercise.files || []; } +function activeFile(exercise) { + if (!isProject(exercise)) return null; + const files = projectFiles(exercise); + return files.find((file) => file.path === state.activeFiles.get(exercise.id)) || files.find((file) => file.editable) || files[0]; +} +function fileKey(exercise, path = activeFile(exercise)?.path) { return isProject(exercise) ? `${exercise.id}::${path}` : exercise.id; } +function sourceFor(exercise, path = activeFile(exercise)?.path) { + if (!isProject(exercise)) return state.drafts.has(exercise.id) ? state.drafts.get(exercise.id) : exercise.template; + const file = projectFiles(exercise).find((item) => item.path === path); + return file?.editable && state.fileDrafts.has(fileKey(exercise, path)) ? state.fileDrafts.get(fileKey(exercise, path)) : file?.source || ''; +} +function setSource(exercise, value, path = activeFile(exercise)?.path) { + if (isProject(exercise)) state.fileDrafts.set(fileKey(exercise, path), value); + else state.drafts.set(exercise.id, value); +} +function sourcesFor(exercise) { + return Object.fromEntries(projectFiles(exercise).filter((file) => file.editable).map((file) => [file.path, sourceFor(exercise, file.path)]).sort(([a], [b]) => a.localeCompare(b))); +} +function sourceSnapshot(exercise) { return isProject(exercise) ? JSON.stringify(sourcesFor(exercise)) : sourceFor(exercise); } +function sourceLimit(exercise) { return isProject(exercise) ? PROJECT_SOURCE_LIMIT : SOURCE_LIMIT; } +function utf8Size(value) { return new TextEncoder().encode(value).byteLength; } +function fileLanguage(path) { return path.endsWith('.java') ? 'Java' : `${path.split('.').at(-1).toUpperCase()} · 일반 텍스트`; } +function labComplete(lesson) { + const exercise = exerciseFor(lesson); + return Boolean(exercise && state.labPassed.has(exercise.id) && state.labPassed.get(exercise.id) === sourceSnapshot(exercise)); +} +function lessonComplete(lesson) { return state.quizPassed.has(lesson.id) && labComplete(lesson); } + +function visibleLessons() { + const query = state.search.trim().toLocaleLowerCase('ko'); + return lessons.filter((lesson) => { + if (Number(lesson.minVersion) > state.version) return false; + if (state.track === 'project') { + if (!isProject(exerciseFor(lesson))) return false; + } else if (state.track !== 'all') { + const hasDeclaredTrack = ['transition', 'advanced', 'both'].includes(lesson.track); + const fallback = state.track === 'transition' ? TRANSITION_IDS : ADVANCED_IDS; + if (hasDeclaredTrack ? lesson.track !== state.track && lesson.track !== 'both' : !fallback.has(lesson.id)) return false; + } + if (!query) return true; + const text = [lesson.title, lesson.description, lesson.category, ...(lesson.goals || []), ...(lesson.jobTags || [])].join(' ').toLocaleLowerCase('ko'); + return text.includes(query); + }); +} + +function ensureSelection() { + const filtered = visibleLessons(); + if (!filtered.some((lesson) => lesson.id === state.lessonId)) { + const nextId = filtered[0]?.id || ''; + if (nextId !== state.lessonId) { + cancelPending('수업 변경', false); + state.lessonId = nextId; + state.step = 'theory'; + } + } +} + +function restoreStorage() { + let raw; + try { raw = storageAdapter().getItem(STORAGE_KEY); } + catch { state.storage = 'unavailable'; return; } + if (!raw) return; + try { + const record = JSON.parse(raw); + if (!record || typeof record !== 'object' || record.schema !== 1) throw new Error('Invalid record'); + if (VERSIONS.includes(record.version)) state.version = record.version; + if (lessons.some((lesson) => lesson.id === record.lessonId)) state.lessonId = record.lessonId; + if (Array.isArray(record.quizPassed)) { + state.quizPassed = new Set(record.quizPassed.filter((id) => lessons.some((lesson) => lesson.id === id))); + } + for (const [key, destination, idKey, collection, limit] of [ + ['drafts', state.drafts, 'exerciseId', exercises, SOURCE_LIMIT], + ['labPassed', state.labPassed, 'exerciseId', exercises, PROJECT_SNAPSHOT_LIMIT], + ['notes', state.notes, 'lessonId', lessons, NOTE_LIMIT], + ]) { + if (!Array.isArray(record[key])) continue; + for (const entry of record[key]) { + if (!entry || typeof entry !== 'object') continue; + const value = key === 'notes' ? entry.text : entry.source; + if (typeof value !== 'string' || value.length > limit || !collection.some((item) => item.id === entry[idKey])) continue; + destination.set(entry[idKey], value); + } + } + if (Array.isArray(record.fileDrafts)) { + for (const entry of record.fileDrafts) { + const exercise = exercises.find((item) => item.id === entry?.exerciseId && isProject(item)); + const file = exercise?.files?.find((item) => item.path === entry.path && item.editable); + if (file && typeof entry.source === 'string' && entry.source.length <= PROJECT_SOURCE_LIMIT) state.fileDrafts.set(fileKey(exercise, entry.path), entry.source); + } + } + } catch { + state.storage = 'invalid'; + } +} + +function restoreEditorPreferences() { + try { + const preferences = JSON.parse(storageAdapter().getItem(EDITOR_PREFERENCES_KEY)); + if (!preferences || typeof preferences !== 'object') return; + if (EDITOR_THEMES.includes(preferences.theme)) state.editorTheme = preferences.theme; + if (typeof preferences.wrap === 'boolean') state.editorWrap = preferences.wrap; + } catch { /* Preferences are optional; the default theme also works without storage. */ } +} + +function saveEditorPreferences() { + try { + storageAdapter().setItem(EDITOR_PREFERENCES_KEY, JSON.stringify({ theme: state.editorTheme, wrap: state.editorWrap })); + } catch { + state.storage = 'unavailable'; + updateStorageIndicator(); + toast('코드 테마는 현재 화면에서만 적용됩니다. 학습 기록 저장소를 사용할 수 없습니다.'); + } +} + +function setEditorTheme(theme) { + if (!EDITOR_THEMES.includes(theme)) return; + state.editorTheme = theme; + document.querySelectorAll('[data-editor-theme]').forEach((node) => { node.dataset.editorTheme = theme; }); + document.querySelectorAll('.editor-theme-select').forEach((select) => { select.value = theme; }); + activeEditor?.setTheme(theme); + saveEditorPreferences(); + announce(`코드 테마를 ${theme === 'dark' ? '다크' : theme === 'light' ? '라이트' : '고대비'}로 변경했습니다.`); +} + +function codePreferences(id = 'editor-theme-select') { + const settings = el('div', 'code-settings'); + const label = el('label', 'code-theme-label', '코드 테마'); + label.htmlFor = id; + const select = el('select', 'editor-theme-select'); + select.id = id; + select.dataset.testid = id; + for (const [value, name] of [['dark', '다크'], ['light', '라이트'], ['contrast', '고대비']]) { + const option = el('option', '', name); option.value = value; select.append(option); + } + select.value = state.editorTheme; + select.addEventListener('change', () => setEditorTheme(select.value)); + settings.append(label, select); + return settings; +} + +function syntaxLegend() { + const legend = el('div', 'syntax-legend'); + legend.setAttribute('aria-label', '코드 색상 안내'); + for (const [category, label] of [['keyword', '키워드'], ['class', '클래스'], ['type', '타입'], ['variable', '변수'], ['method', '메서드'], ['string', '문자열'], ['comment', '주석'], ['number', '숫자']]) { + legend.append(el('span', `syntax-legend-item tok-${category}`, label)); + } + return legend; +} + +function destroyCodeEditor() { + activeEditor?.destroy(); + activeEditor = null; +} + +function updateStorageIndicator() { + const indicator = document.getElementById('storage-indicator'); + indicator.classList.toggle('is-unavailable', state.storage !== 'ready'); + const location = isDesktop ? '이 PC의 앱에 저장' : '이 브라우저에 저장'; + const label = state.storage === 'unavailable' ? '현재 화면 동안만 기록' : state.storage === 'invalid' ? '기존 기록을 읽지 못했습니다' : location; + document.getElementById('storage-label').textContent = label; + indicator.title = state.storage === 'unavailable' ? '학습 기록 저장소를 사용할 수 없습니다. 작성 코드를 별도로 복사해 주세요.' : isDesktop ? '진도와 코드는 이 PC의 앱 데이터에 저장되며 앱 재시작 후에도 유지됩니다.' : '진도와 코드는 현재 브라우저에만 저장됩니다.'; + const editorSave = document.getElementById('editor-save-state'); + if (editorSave) editorSave.textContent = state.storage === 'unavailable' ? '저장 불가 · 코드 복사 권장' : location; + const notesSave = document.getElementById('notes-save-state'); + if (notesSave) notesSave.textContent = state.storage === 'unavailable' ? '저장소를 사용할 수 없어 현재 화면 동안만 유지됩니다.' : `${location}됩니다. 설계 메모는 자동 평가하지 않습니다.`; +} + +function saveStorage() { + clearTimeout(saveTimer); + const record = { + schema: 1, version: state.version, lessonId: state.lessonId, + quizPassed: [...state.quizPassed], + labPassed: [...state.labPassed].map(([exerciseId, source]) => ({ exerciseId, source })), + drafts: [...state.drafts].map(([exerciseId, source]) => ({ exerciseId, source })), + fileDrafts: [...state.fileDrafts].map(([key, source]) => { const [exerciseId, path] = key.split('::'); return { exerciseId, path, source }; }), + notes: [...state.notes].map(([lessonId, text]) => ({ lessonId, text })), + }; + try { + storageAdapter().setItem(STORAGE_KEY, JSON.stringify(record)); + state.storage = 'ready'; + } catch { + if (state.storage !== 'unavailable') toast('학습 기록을 저장할 수 없습니다. 작성 코드를 별도로 복사해 주세요.'); + state.storage = 'unavailable'; + } + updateStorageIndicator(); +} + +function scheduleSave() { clearTimeout(saveTimer); saveTimer = window.setTimeout(saveStorage, 450); } + +function readHash() { + const hash = window.location.hash.slice(1); + if (!hash || hash === 'learning-workspace' || hash === 'lesson-title') return; + let requestedId; + let requestedStep; + let requestedVersion; + try { + if (hash.includes('=')) { + const params = new URLSearchParams(hash); + requestedId = params.get('lesson'); + requestedStep = params.get('step'); + const candidate = Number(params.get('version')); + requestedVersion = VERSIONS.includes(candidate) ? candidate : null; + } else { + requestedId = decodeURIComponent(hash); + } + } catch { requestedId = ''; } + const requestedLesson = lessons.find((lesson) => lesson.id === requestedId); + state.track = 'all'; + state.search = ''; + if (requestedVersion) state.version = requestedVersion; + if (requestedLesson) { + if (!requestedVersion && requestedLesson.minVersion > state.version) { + state.version = VERSIONS.find((version) => version >= requestedLesson.minVersion) || 21; + } + if (requestedLesson.minVersion <= state.version) { + state.lessonId = requestedLesson.id; + state.step = STEPS.includes(requestedStep) ? requestedStep : 'theory'; + } else { + state.lessonId = ''; + state.step = 'theory'; + announce(`요청한 수업에는 Java ${requestedLesson.minVersion} 이상이 필요합니다. Java ${state.version}에서 사용할 수 있는 수업을 표시합니다.`); + } + } else { + state.lessonId = ''; + state.step = 'theory'; + announce('수업 링크를 찾지 못해 첫 번째 수업을 표시합니다.'); + } +} + +function syncHash(mode = 'replace') { + if (!state.lessonId) return; + const params = new URLSearchParams({ lesson: state.lessonId, step: state.step, version: String(state.version) }); + const next = `#${params.toString()}`; + if (window.location.hash === next) return; + try { window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', next); } + catch { /* Browsers with restricted history still retain all in-page interactions. */ } +} + +function renderControls() { + dom['version-select'].value = String(state.version); + dom['lesson-search'].value = state.search; + document.querySelectorAll('[data-track]').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.track === state.track))); +} + +function renderSidebar() { + const filtered = visibleLessons(); + dom['lesson-list'].replaceChildren(); + document.getElementById('filtered-count').textContent = `${filtered.length}개 수업`; + document.getElementById('filtered-completed').textContent = `${filtered.filter(lessonComplete).length} / ${filtered.length} 완료`; + if (!filtered.length) { + dom['lesson-list'].append(el('p', 'sidebar-empty', '조건에 맞는 수업이 없습니다. 검색어 또는 학습 기준 버전을 바꿔 보세요.')); + return; + } + for (const lesson of filtered) { + const index = lessons.findIndex((item) => item.id === lesson.id); + const complete = lessonComplete(lesson); + const link = el('a', `lesson-link${lesson.id === state.lessonId ? ' is-active' : ''}${complete ? ' is-complete' : ''}`); + link.href = `#${new URLSearchParams({ lesson: lesson.id, step: 'theory', version: String(state.version) })}`; + link.dataset.lessonId = lesson.id; + link.dataset.complete = String(complete); + link.dataset.testid = `lesson-${lesson.id}`; + if (lesson.id === state.lessonId) link.setAttribute('aria-current', 'page'); + const number = el('span', 'lesson-index'); + number.append(complete ? icon('check') : document.createTextNode(String(index + 1).padStart(2, '0'))); + if (complete) number.append(el('span', 'sr-only', '수업 완료')); + const copy = el('span', 'lesson-link-copy'); + copy.append(el('span', 'lesson-link-title', lesson.title)); + const meta = el('span', 'lesson-link-meta'); + meta.append(el('span', '', `Java ${lesson.minVersion}+`), el('span', '', complete ? '완료' : isProject(exerciseFor(lesson)) ? '실무 프로젝트' : lesson.track === 'advanced' ? '실무 심화' : lesson.track === 'both' ? '공통' : 'Java 전환')); + copy.append(meta); + link.append(number, copy); + link.addEventListener('click', (event) => { + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return; + event.preventDefault(); + selectLesson(lesson.id, 'theory', true); + }); + dom['lesson-list'].append(link); + } +} + +function continuationLesson() { + const compatible = lessons.filter((lesson) => lesson.minVersion <= state.version); + return compatible.find((lesson) => lesson.id === state.lessonId && !lessonComplete(lesson)) || compatible.find((lesson) => !lessonComplete(lesson)) || compatible[0]; +} + +function renderProgress() { + const count = lessons.filter(lessonComplete).length; + document.getElementById('overall-completed').textContent = String(count); + document.getElementById('overall-denominator').textContent = ` / ${lessons.length}`; + const progress = document.getElementById('overall-progress'); + progress.setAttribute('aria-valuemax', String(lessons.length)); + progress.setAttribute('aria-valuenow', String(count)); + progress.setAttribute('aria-valuetext', `전체 ${lessons.length}개 수업 중 ${count}개 완료`); + document.getElementById('overall-progress-fill').style.width = `${lessons.length ? (count / lessons.length) * 100 : 0}%`; + const next = continuationLesson(); + document.getElementById('continue-label').textContent = next && lessonComplete(next) ? '완료한 수업 다시 보기' : state.quizPassed.size || state.drafts.size || state.fileDrafts.size ? '이어서 학습하기' : '학습 시작하기'; + document.getElementById('continue-title').textContent = next?.title || '수업을 준비하고 있습니다'; + document.getElementById('continue-button').disabled = !next; +} + +function renderAchievements(lesson = currentLesson()) { + dom['lesson-achievements'].replaceChildren(); + if (!lesson) return; + const exercise = exerciseFor(lesson); + const quizDone = state.quizPassed.has(lesson.id); + const labDone = labComplete(lesson); + const priorLab = Boolean(exercise && state.labPassed.has(exercise.id) && !labDone); + for (const [kind, complete, text, past] of [ + ['quiz', quizDone, quizDone ? '객관식 성취 완료' : '객관식 미완료', false], + ['lab', labDone, labDone ? '현재 코드 제출 통과' : priorLab ? '이전 코드 통과 · 현재 코드 재제출 필요' : '코드 제출 미완료', priorLab], + ]) { + const badge = el('span', `achievement-badge${complete ? ' is-complete' : ''}${past ? ' is-past' : ''}`, text); + badge.dataset.achievement = kind; + badge.dataset.complete = String(complete); + dom['lesson-achievements'].append(badge); + } + dom['lesson-card'].dataset.complete = String(lessonComplete(lesson)); +} + +function renderLesson() { + const lesson = currentLesson(); + const changedLesson = dom['lesson-card'].dataset.lessonId !== (lesson?.id || ''); + const tabs = document.querySelector('.lesson-tabs'); + destroyCodeEditor(); + dom['lesson-panel'].replaceChildren(); + dom['lesson-meta'].replaceChildren(); + dom['lesson-detail-meta'].replaceChildren(); + dom['lesson-goals'].replaceChildren(); + dom['lesson-footer'].replaceChildren(); + dom['lesson-achievements'].replaceChildren(); + tabs.hidden = !lesson; + dom['lesson-footer'].hidden = !lesson; + dom['lesson-support'].hidden = !lesson; + if (changedLesson) { + dom['lesson-info'].open = false; + dom['reference-column'].open = false; + } + dom['lesson-card'].dataset.lessonId = lesson?.id || ''; + if (!lesson) { + dom['lesson-category'].textContent = 'FIND YOUR NEXT LESSON'; + dom['lesson-title'].textContent = '수업을 찾지 못했습니다'; + dom['lesson-description'].textContent = '현재 검색어와 필터에 맞는 수업이 없습니다.'; + dom['lesson-position'].textContent = '—'; + dom['lesson-panel'].setAttribute('aria-labelledby', 'lesson-title'); + const empty = el('div', 'empty-state'); + empty.append(icon('search'), el('h3', '', '다른 키워드로 찾아볼까요?'), el('p', '', `Java ${state.version}에서 사용할 수 있는 수업 중 제목·설명·학습 목표·기술 키워드를 검색합니다.`), actionButton('검색과 트랙 초기화', 'button button-secondary', () => { state.track = 'all'; state.search = ''; applyFilters(); })); + dom['lesson-panel'].append(empty); + return; + } + dom['lesson-category'].textContent = lesson.category || 'JAVA CORE'; + dom['lesson-title'].textContent = lesson.title; + dom['lesson-description'].textContent = lesson.description; + dom['lesson-position'].textContent = `${String(lessons.indexOf(lesson) + 1).padStart(2, '0')} / ${String(lessons.length).padStart(2, '0')}`; + dom['lesson-meta'].append(el('span', 'meta-chip', `Java ${lesson.minVersion}+`)); + if (lesson.featureSince) dom['lesson-detail-meta'].append(el('span', '', `기능 도입 ${String(lesson.featureSince)}`)); + const trackLabel = lesson.track === 'both' ? '전환 · 심화 공통' : lesson.track === 'advanced' ? '실무 심화' : 'Java 전환'; + dom['lesson-detail-meta'].append(el('span', '', trackLabel)); + if (isProject(exerciseFor(lesson))) dom['lesson-meta'].append(el('span', 'meta-chip is-neutral', '실무 프로젝트 · Windows 앱 실행')); + if (lesson.duration) { + const time = el('span', 'meta-chip is-neutral'); + time.append(icon('clock'), document.createTextNode(typeof lesson.duration === 'number' ? `${lesson.duration}분` : lesson.duration)); + dom['lesson-detail-meta'].append(time); + } + if (lesson.preview) dom['lesson-meta'].append(el('span', 'meta-chip is-preview', 'Preview 기능 포함')); + dom['lesson-goals'].append(el('h3', '', '학습 목표')); + const goals = el('ul'); + (lesson.goals || []).forEach((goal) => { const item = el('li'); item.append(icon('check'), el('span', '', goal)); goals.append(item); }); + dom['lesson-goals'].append(goals); + renderAchievements(lesson); + document.querySelectorAll('[data-step]').forEach((tab) => { + const selected = tab.dataset.step === state.step; + tab.setAttribute('aria-selected', String(selected)); + tab.tabIndex = selected ? 0 : -1; + }); + dom['lesson-panel'].setAttribute('aria-labelledby', `tab-${state.step}`); + const renderers = { theory: renderTheory, example: renderExample, quiz: renderQuiz, practice: renderPractice, lab: renderLab }; + renderers[state.step](lesson); + renderLessonFooter(lesson); + requestAnimationFrame(() => revealStepTab(state.step)); +} + +function renderLessonFooter(lesson) { + const filtered = visibleLessons(); + const index = filtered.findIndex((item) => item.id === lesson.id); + const previous = filtered[index - 1]; + const next = filtered[index + 1]; + if (previous) { + const button = actionButton('← 이전 수업', '', () => selectLesson(previous.id, 'theory', true)); + button.append(el('strong', '', previous.title)); + dom['lesson-footer'].append(button); + } + if (next) { + const button = actionButton('다음 수업 →', '', () => selectLesson(next.id, 'theory', true)); + button.append(el('strong', '', next.title)); + dom['lesson-footer'].append(button); + } + if (!previous && !next) dom['lesson-footer'].append(el('p', 'inline-note', '현재 조건에 맞는 유일한 수업입니다.')); +} + +function renderReferences() { + const lesson = currentLesson(); + dom['source-list'].replaceChildren(); + dom['evidence-list'].replaceChildren(); + if (!lesson) return; + for (const source of lesson.sources || []) { + const link = safeLink('', source.url, 'source-link'); + link.append(el('strong', '', source.title), icon('external')); + const facts = [source.edition, source.checkedAt ? `확인 ${source.checkedAt}` : '공식 원문'].filter(Boolean); + link.append(el('span', '', facts.join(' · '))); + dom['source-list'].append(link); + } + const evidence = jobEvidence.filter((item) => item.lessonIds?.includes(lesson.id)); + if (!evidence.length) { + dom['evidence-list'].append(el('p', 'reference-description', '이 수업에 직접 연결한 공고 사례는 아직 없습니다. 공식 문서와 실무 시나리오를 먼저 살펴보세요.')); + return; + } + evidence.forEach((item, index) => { + const card = el('div', 'evidence-item'); + const company = el('p', 'evidence-company', item.company); + company.append(el('span', 'evidence-status', item.status || '상태 미확인')); + const link = safeLink(item.title, item.url, 'evidence-title'); + link.append(icon('external')); + card.append(company, link, el('p', '', item.summary)); + const tags = el('div', 'technology-tags'); + (item.technologies || []).slice(0, 5).forEach((tag) => tags.append(el('span', '', tag))); + card.append(tags, el('p', 'evidence-date', `확인 ${item.checkedAt || '미기록'} · ${item.access === 'partial' ? '일부 본문 확인' : '본문 확인'}`)); + if (index < 2) dom['evidence-list'].append(card); + else { + let more = dom['evidence-list'].querySelector('details'); + if (!more) { + more = el('details', 'evidence-more'); + more.append(el('summary', 'text-button', `사례 ${evidence.length - 2}개 더 보기`)); + dom['evidence-list'].append(more); + } + more.append(card); + } + }); +} + +function renderAll() { + renderControls(); + renderSidebar(); + renderProgress(); + renderLesson(); + renderReferences(); + updateStorageIndicator(); +} + +function nextStepButton(step, label) { + const row = el('div', 'next-step-row'); + const button = actionButton(label || `${STEP_LABELS[step]}로 이어가기`, 'button button-secondary', () => selectStep(step, true)); + button.append(icon('arrow')); + row.append(button); + return row; +} + +function sectionTitle(text, number) { + const heading = el('div', 'section-heading'); + if (number) heading.append(el('span', 'section-number', number)); + heading.append(el('h3', '', text)); + return heading; +} + +function validLessonVisual(value) { + if (!value || !['flow', 'comparison', 'state', 'relationship'].includes(value.kind)) return null; + if (!['id', 'title', 'caption', 'altText', 'readingOrder'].every((key) => typeof value[key] === 'string' && value[key].trim())) return null; + if (!Array.isArray(value.panels) || !value.panels.length || value.panels.length > 6) return null; + for (const panel of value.panels) { + if (!panel || typeof panel.title !== 'string' || !Array.isArray(panel.nodes) || !panel.nodes.length || panel.nodes.length > 12 || !Array.isArray(panel.edges) || panel.edges.length > 30) return null; + if (!panel.nodes.every((node) => node && typeof node.id === 'string' && node.id && typeof node.label === 'string' && node.label.trim() && typeof node.detail === 'string')) return null; + const ids = new Set(panel.nodes.map((node) => node.id)); + if (ids.size !== panel.nodes.length || !panel.edges.every((edge) => edge && ids.has(edge.from) && ids.has(edge.to) && typeof edge.label === 'string' && edge.label.trim())) return null; + } + if (!Array.isArray(value.sources) || !value.sources.length || !value.sources.every((source) => { + if (!source || typeof source.title !== 'string' || !source.title.trim()) return false; + try { return ['http:', 'https:'].includes(new URL(source.url).protocol); } catch { return false; } + })) return null; + return value; +} + +function visualNode(node) { + const tone = ['accent', 'warning'].includes(node.tone) ? node.tone : 'neutral'; + const card = el('div', `visual-node is-${tone}`); + card.dataset.nodeId = node.id; + card.append(el('strong', 'visual-node-label', node.label)); + if (node.detail) card.append(el('p', 'visual-node-detail', node.detail)); + return card; +} + +function visualConnection(edge, nodes, standalone = false) { + const from = nodes.find((node) => node.id === edge.from); + const to = nodes.find((node) => node.id === edge.to); + const connection = el('div', standalone ? 'visual-relationship' : 'visual-transition'); + connection.dataset.from = edge.from; + connection.dataset.to = edge.to; + if (standalone) connection.append(el('span', 'visual-endpoint', from.label)); + const arrow = el('span', 'visual-arrow'); + arrow.append(icon('arrow')); + const label = el('span', 'visual-edge-label'); + label.append(el('span', 'sr-only', `${from.label}에서 ${to.label}로: `), document.createTextNode(edge.label)); + arrow.append(label); + connection.append(arrow); + if (standalone) connection.append(el('span', 'visual-endpoint', to.label)); + return connection; +} + +function renderTheoryVisual(lesson, visual) { + const figure = el('figure', 'theory-visual'); + const prefix = `theory-visual-${lessons.indexOf(lesson)}`; + figure.dataset.visualId = visual.id; + figure.dataset.visualKind = visual.kind; + figure.setAttribute('aria-labelledby', `${prefix}-title`); + figure.setAttribute('aria-describedby', `${prefix}-caption`); + const caption = el('figcaption', 'visual-caption'); + const kinds = { flow: '실행 순서', comparison: '나란히 비교하기', state: '상태와 전이', relationship: '요소 사이의 관계' }; + const label = el('p', 'visual-kicker', kinds[visual.kind]); + const title = el('h3', 'visual-title', visual.title); title.id = `${prefix}-title`; + const description = el('p', 'visual-description', visual.caption); description.id = `${prefix}-caption`; + caption.append(label, title, description); + figure.append(caption); + const order = el('p', 'visual-reading-order'); + order.append(el('strong', '', '읽는 순서'), document.createTextNode(` ${visual.readingOrder}`)); + figure.append(order); + const panels = el('div', 'visual-panels'); + visual.panels.forEach((panel, panelIndex) => { + const section = el('section', 'visual-panel'); + const heading = el('h4', 'visual-panel-title', panel.title); heading.id = `${prefix}-panel-${panelIndex}`; + section.setAttribute('aria-labelledby', heading.id); + section.append(heading); + const nodes = el(visual.kind === 'flow' || visual.kind === 'state' ? 'ol' : 'ul', visual.kind === 'relationship' ? 'visual-nodes is-catalog' : 'visual-nodes'); + const used = new Set(); + panel.nodes.forEach((node, index) => { + const row = el('li', 'visual-node-row'); + row.append(visualNode(node)); + if (visual.kind !== 'relationship' && index < panel.nodes.length - 1) { + const edge = panel.edges.find((item) => item.from === node.id && item.to === panel.nodes[index + 1].id); + if (edge) { row.append(visualConnection(edge, panel.nodes)); used.add(edge); } + } + nodes.append(row); + }); + section.append(nodes); + const additional = panel.edges.filter((edge) => !used.has(edge)); + if (additional.length) { + const connections = el('div', 'visual-connections'); + connections.append(el('p', 'visual-connections-label', visual.kind === 'relationship' ? '연결 관계 · 화살표의 양 끝과 라벨을 함께 읽으세요' : '함께 볼 연결 관계')); + for (const edge of additional) connections.append(visualConnection(edge, panel.nodes, true)); + section.append(connections); + } + panels.append(section); + }); + figure.append(panels); + if (typeof visual.takeaway === 'string' && visual.takeaway) { + const takeaway = el('p', 'visual-takeaway'); + takeaway.append(el('strong', '', '핵심 판단'), document.createTextNode(` ${visual.takeaway}`)); + figure.append(takeaway); + } + const details = el('details', 'visual-text-description'); + details.append(el('summary', '', '도식의 텍스트 설명과 범위')); + const alternative = el('p', 'visual-alt-text', visual.altText); alternative.id = `${prefix}-description`; + details.append(alternative); + if (Array.isArray(visual.limits) && visual.limits.length) { + const limits = el('ul', 'visual-limits'); + visual.limits.filter((item) => typeof item === 'string').forEach((item) => limits.append(el('li', '', item))); + details.append(limits); + } + figure.append(details); + const sources = el('div', 'visual-sources'); + sources.append(el('p', 'visual-sources-label', '공식 문서에서 확인')); + const sourceList = el('ul'); + for (const source of visual.sources) { + const item = el('li'); + const link = safeLink(source.title, source.url, 'visual-source-link'); link.append(icon('external')); + const facts = [source.edition, source.checkedAt ? `확인 ${source.checkedAt}` : ''].filter(Boolean); + item.append(link); + if (facts.length) item.append(el('span', 'visual-source-meta', facts.join(' · '))); + sourceList.append(item); + } + sources.append(sourceList); figure.append(sources); + return figure; +} + +function learningGuideFor(lesson) { + const value = lesson.learningGuide; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const text = (item) => typeof item === 'string' && item.trim() ? item : ''; + const terms = Array.isArray(value.terms) ? value.terms + .filter((item) => item && text(item.term) && text(item.meaning)) + .map((item) => ({ term: item.term, meaning: item.meaning })) : []; + const walkthrough = Array.isArray(value.walkthrough) ? value.walkthrough.filter((item) => text(item)) : []; + const code = text(value.code?.source) ? { file: text(value.code.file) || '코드 발췌', source: value.code.source } : null; + const prediction = text(value.prediction?.question) && text(value.prediction?.answer) + ? { question: value.prediction.question, answer: value.prediction.answer, explanation: text(value.prediction.explanation) } : null; + const guide = { scenario: text(value.scenario), question: text(value.question), terms, code, walkthrough, prediction, takeaway: text(value.takeaway) }; + return guide.scenario || guide.question || terms.length || code || walkthrough.length || prediction || guide.takeaway ? guide : null; +} + +function renderGuideCode(excerpt) { + const section = el('section', 'guide-section guide-code-section'); + section.append(el('h3', '', '작은 코드로 확인하기')); + const scope = el('p', 'guide-code-scope', '이론 코드 발췌 · 전체 코드는 예제 단계에서 확인하세요.'); + scope.id = 'theory-code-scope'; + section.append(scope); + const shell = el('div', 'code-shell guide-code'); + shell.dataset.editorTheme = state.editorTheme; + const heading = el('div', 'code-heading'); + const filename = el('span', 'example-filename'); + filename.append(icon('code'), el('span', 'guide-code-filename', excerpt.file)); + const pre = el('pre', 'code-block'); + pre.id = 'theory-code-panel'; + pre.tabIndex = 0; + pre.setAttribute('aria-label', `${excerpt.file} · 이론 코드 발췌`); + pre.setAttribute('aria-describedby', 'theory-code-scope theory-code-hint'); + const code = el('code', '', excerpt.source); + let highlighted = false; + if (editorLibrary && /\.java$/i.test(excerpt.file)) { + try { + editorLibrary.highlightJavaCode(code, excerpt.source); + highlighted = true; + } catch { code.textContent = excerpt.source; } + } + pre.append(code); + const copy = actionButton('코드 복사', 'copy-button', () => copyText(excerpt.source, pre), 'copy-theory-code'); + copy.prepend(icon('copy')); + heading.append(filename, copy); + const tools = el('div', 'guide-code-tools'); + tools.append(codePreferences('theory-theme-select')); + if (highlighted) { + const legend = el('details', 'code-legend-disclosure'); + legend.append(el('summary', '', '구문 색상 안내'), syntaxLegend()); + tools.append(legend); + } + shell.append(heading, tools); + shell.append(pre); + const hint = el('p', 'guide-code-hint', '긴 줄은 코드 영역 안에서 가로로 스크롤할 수 있습니다.'); + hint.id = 'theory-code-hint'; + if (/\.java$/i.test(excerpt.file) && !highlighted) hint.append(document.createTextNode(' 문법 강조를 불러오지 못해 원문으로 표시합니다.')); + section.append(shell, hint); + return section; +} + +function renderLearningGuide(guide) { + const container = el('div', 'learning-guide'); + if (guide.scenario || guide.question) { + const scenario = el('section', 'guide-scenario'); + scenario.append(el('h3', '', '이 상황부터 생각해보세요')); + if (guide.scenario) scenario.append(el('p', 'guide-scenario-text', guide.scenario)); + if (guide.question) scenario.append(el('p', 'guide-question', guide.question)); + container.append(scenario); + } + if (guide.terms.length) { + const section = el('section', 'guide-section guide-terms'); + section.append(el('h3', '', '먼저 알아둘 용어')); + const list = el('dl', 'guide-terms-list'); + guide.terms.forEach(({ term, meaning }) => { + const item = el('div', 'guide-term'); + item.append(el('dt', '', term), el('dd', '', meaning)); + list.append(item); + }); + section.append(list); + container.append(section); + } + if (guide.code) container.append(renderGuideCode(guide.code)); + if (guide.walkthrough.length) { + const section = el('section', 'guide-section guide-walkthrough'); + section.append(el('h3', '', '순서대로 따라가 보기')); + const list = el('ol'); + guide.walkthrough.forEach((step) => list.append(el('li', '', step))); + section.append(list); + container.append(section); + } + if (guide.prediction) { + const section = el('section', 'guide-section guide-prediction-section'); + section.append(el('h3', '', '결과를 먼저 예상해보세요'), el('p', 'guide-prediction-question', guide.prediction.question)); + const details = el('details', 'guide-prediction'); + details.append(el('summary', '', guide.prediction.explanation ? '예상 답과 이유 확인' : '예상 답 확인')); + const answer = el('div', 'guide-prediction-answer'); + answer.append(el('p', 'guide-answer-label', '예상 답'), el('p', 'guide-answer-text', guide.prediction.answer)); + if (guide.prediction.explanation) answer.append(el('p', 'guide-answer-label', '이유'), el('p', 'guide-answer-explanation', guide.prediction.explanation)); + details.append(answer); + section.append(details); + container.append(section); + } + if (guide.takeaway) { + const takeaway = el('div', 'guide-takeaway'); + takeaway.append(el('p', 'small-label', '한 문장으로 정리하면'), el('p', '', guide.takeaway)); + container.append(takeaway); + } + return container; +} + +function renderTheory(lesson) { + const guide = learningGuideFor(lesson); + if (guide) dom['lesson-panel'].append(renderLearningGuide(guide)); + if (guide && lesson.theory?.length) dom['lesson-panel'].append(el('h3', 'theory-reading-title', '핵심 개념 자세히 읽기')); + const parts = lesson.theory || []; + const visual = validLessonVisual(lessonVisuals[lesson.id]); + const afterSection = visual && Number.isInteger(visual.afterSection) ? Math.min(Math.max(visual.afterSection, 0), Math.max(parts.length - 1, 0)) : 0; + parts.forEach((part, index) => { + const section = el('section', 'theory-section'); + section.append(sectionTitle(part.heading, String(index + 1).padStart(2, '0')), el('p', '', part.text)); + dom['lesson-panel'].append(section); + if (visual && index === afterSection) dom['lesson-panel'].append(renderTheoryVisual(lesson, visual)); + }); + if (visual && !parts.length) dom['lesson-panel'].append(renderTheoryVisual(lesson, visual)); + dom['lesson-panel'].append(nextStepButton('example', '예제로 개념 확인하기')); +} + +async function copyText(text, selectableNode) { + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable'); + await navigator.clipboard.writeText(text); + toast('코드를 복사했습니다.'); + } catch { + if (typeof selectableNode?.selectAll === 'function') { + selectableNode.selectAll(); + } else if (selectableNode instanceof HTMLTextAreaElement) { + selectableNode.focus(); + selectableNode.select(); + } else if (selectableNode) { + const range = document.createRange(); + range.selectNodeContents(selectableNode); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + toast('자동 복사를 사용할 수 없습니다. 선택된 코드를 직접 복사해 주세요.'); + } +} + +function fileTabs(files, selectedPath, onSelect, prefix, panelId, label) { + const tabs = el('div', 'file-tabs'); + tabs.setAttribute('role', 'tablist'); + tabs.setAttribute('aria-label', label); + files.forEach((file, index) => { + const button = actionButton(file.path.split('/').at(-1), 'file-tab', () => { + onSelect(file.path); + document.getElementById(`${prefix}-${index}`)?.focus({ preventScroll: true }); + }, `${prefix}-${index}`); + button.title = file.path; + button.dataset.filePath = file.path; + button.setAttribute('role', 'tab'); + button.setAttribute('aria-controls', panelId); + button.setAttribute('aria-selected', String(file.path === selectedPath)); + button.setAttribute('aria-label', `${file.path}${file.editable === false ? ' · 읽기 전용' : ''}`); + button.tabIndex = file.path === selectedPath ? 0 : -1; + if (file.editable === false) button.append(el('span', 'file-readonly-badge', '읽기 전용')); + button.addEventListener('keydown', (event) => { + const next = event.key === 'ArrowRight' ? (index + 1) % files.length : event.key === 'ArrowLeft' ? (index + files.length - 1) % files.length : event.key === 'Home' ? 0 : event.key === 'End' ? files.length - 1 : -1; + if (next < 0) return; + event.preventDefault(); + onSelect(files[next].path); + document.getElementById(`${prefix}-${next}`)?.focus({ preventScroll: true }); + }); + tabs.append(button); + }); + return tabs; +} + +function renderExample(lesson) { + const example = lesson.example; + if (!example) return; + dom['lesson-panel'].append(sectionTitle(example.title || '코드로 개념 살펴보기'), el('p', 'section-caption', '아래는 학습용 예제와 예상 출력입니다. 실제 실행은 코드 실습에서 확인하세요.')); + const shell = el('div', 'code-shell'); + shell.dataset.editorTheme = state.editorTheme; + const heading = el('div', 'code-heading'); + const files = Array.isArray(example.files) && example.files.length ? example.files : [{ path: 'Main.java', language: 'java', code: example.code }]; + let selected = files[0]; + const filename = el('span', 'example-filename'); + const pre = el('pre', 'code-block'); + pre.tabIndex = 0; + pre.setAttribute('aria-label', '학습 예제 소스 코드'); + pre.id = 'example-file-panel'; + const code = el('code'); + pre.append(code); + const copy = actionButton('코드 복사', 'copy-button', () => copyText(selected.code, pre), 'copy-example'); + copy.prepend(icon('copy')); + heading.append(filename, copy); + const legend = syntaxLegend(); + const tabsHost = el('div'); + function selectFile(path) { + selected = files.find((file) => file.path === path) || files[0]; + filename.replaceChildren(icon('code'), document.createTextNode(selected.path)); + filename.append(el('span', 'file-readonly-badge', `${fileLanguage(selected.path)} · 읽기 전용 예제`)); + pre.setAttribute('aria-label', `${selected.path} · ${fileLanguage(selected.path)} · 읽기 전용 예제`); + const java = selected.language === 'java' || selected.path.endsWith('.java'); + if (editorLibrary && java) editorLibrary.highlightJavaCode(code, selected.code); + else code.textContent = selected.code; + legend.hidden = !java; + if (files.length > 1) { + tabsHost.replaceChildren(fileTabs(files, selected.path, selectFile, 'example-file', pre.id, '예제 파일')); + pre.setAttribute('role', 'tabpanel'); + pre.setAttribute('aria-labelledby', `example-file-${files.indexOf(selected)}`); + } + pre.scrollLeft = 0; + } + shell.append(heading, codePreferences(), legend, tabsHost, pre); + selectFile(selected.path); + const output = el('div', 'expected-output'); + output.append(el('p', 'small-label', '예상 출력 · 실행 결과가 아닙니다'), el('pre', '', example.output || '(표준 출력 없음)')); + dom['lesson-panel'].append(shell, el('p', 'inline-note syntax-note', editorLibrary ? '색상은 코드의 구문을 구분하며, 컴파일 결과를 대신하지 않습니다.' : '문법 강조를 불러오지 못해 원문으로 표시합니다.'), output, el('p', 'explanation', example.explanation), nextStepButton('quiz', '문제로 이해 확인하기')); +} + +function renderQuiz(lesson) { + const questions = lesson.quiz || []; + dom['lesson-panel'].append(sectionTitle('이해한 개념을 확인해 보세요'), el('p', 'section-caption', `총 ${questions.length}문항입니다. 모든 문항에 정답을 제출하면 객관식 성취가 기록됩니다.`)); + if (!questions.length) { + dom['lesson-panel'].append(el('p', 'inline-note', '이 수업의 문제를 준비하고 있습니다.')); + return; + } + if (!state.quizAnswers.has(lesson.id)) state.quizAnswers.set(lesson.id, new Map()); + const answers = state.quizAnswers.get(lesson.id); + const result = state.quizResults.get(lesson.id); + if (result) { + const allCorrect = result.correct === questions.length; + const summary = el('div', `result-summary${allCorrect ? '' : ' is-incomplete'}`); + summary.id = 'quiz-result'; + summary.tabIndex = -1; + summary.dataset.correct = String(result.correct); + summary.append(el('strong', '', allCorrect ? '모든 개념을 정확히 이해했습니다.' : `${questions.length}문항 중 ${result.correct}문항 정답`), el('p', '', allCorrect ? '객관식 성취를 기록했습니다. 실무 시나리오를 읽고 코드 실습으로 이어가세요.' : '각 해설을 확인하고 다시 풀어 보세요. 모든 정답을 제출해야 성취가 기록됩니다.')); + dom['lesson-panel'].append(summary); + } else if (state.quizPassed.has(lesson.id)) { + dom['lesson-panel'].append(el('p', 'inline-note', '이미 객관식 성취를 완료한 수업입니다. 다시 풀어도 획득한 성취는 유지됩니다.')); + } + const form = el('form', 'quiz-form'); + form.id = 'quiz-form'; + form.noValidate = true; + questions.forEach((question, index) => { + const fieldset = el('fieldset', 'quiz-question'); + const legend = el('legend'); + legend.append(el('span', 'question-number', `Q${String(index + 1).padStart(2, '0')}.`), document.createTextNode(question.question)); + fieldset.append(legend); + const options = el('div', 'quiz-options'); + question.options.forEach((option, optionIndex) => { + const label = el('label', 'quiz-option'); + const input = el('input'); + input.type = 'radio'; + input.name = `quiz-${question.id}`; + input.value = String(optionIndex); + input.checked = answers.get(question.id) === optionIndex; + input.disabled = Boolean(result); + input.required = true; + input.addEventListener('change', () => { + answers.set(question.id, optionIndex); + const error = document.getElementById('quiz-error'); + if (error) error.textContent = ''; + }); + label.append(input, el('span', 'option-letter', String.fromCharCode(65 + optionIndex)), el('span', '', option)); + options.append(label); + }); + fieldset.append(options); + if (result) { + const correct = answers.get(question.id) === question.correctIndex; + const feedback = el('div', `quiz-feedback${correct ? '' : ' is-wrong'}`); + feedback.append(el('strong', '', correct ? '정답입니다' : `정답: ${String.fromCharCode(65 + question.correctIndex)}. ${question.options[question.correctIndex]}`), el('p', '', question.explanation)); + fieldset.append(feedback); + } + form.append(fieldset); + }); + const error = el('p', 'form-error'); + error.id = 'quiz-error'; + error.setAttribute('role', 'alert'); + form.append(error); + const actions = el('div', 'quiz-actions'); + if (!result) { + const submit = el('button', 'button button-primary', '정답 확인하기'); + submit.type = 'submit'; + submit.id = 'quiz-submit'; + submit.dataset.testid = 'quiz-submit'; + actions.append(submit); + } else { + actions.append(actionButton('다시 풀기', 'button button-secondary', () => { + state.quizResults.delete(lesson.id); + state.quizAnswers.set(lesson.id, new Map()); + renderLesson(); + document.querySelector('#quiz-form input')?.focus(); + announce('답안을 초기화했습니다. 문제를 다시 풀어 보세요.'); + }, 'quiz-retry')); + if (result.correct === questions.length) actions.append(actionButton('실무 시나리오 살펴보기 →', 'button button-primary', () => selectStep('practice', true))); + } + form.append(actions); + form.addEventListener('submit', (event) => { + event.preventDefault(); + const unanswered = questions.find((question) => !answers.has(question.id)); + if (unanswered) { + error.textContent = `${questions.indexOf(unanswered) + 1}번 문제의 답을 선택해 주세요.`; + Array.from(form.querySelectorAll('input')).find((input) => input.name === `quiz-${unanswered.id}`)?.focus(); + return; + } + const correct = questions.filter((question) => answers.get(question.id) === question.correctIndex).length; + state.quizResults.set(lesson.id, { correct }); + if (correct === questions.length) state.quizPassed.add(lesson.id); + saveStorage(); + renderAll(); + document.getElementById('quiz-result')?.focus(); + announce(`${questions.length}문항 중 ${correct}문항 정답입니다.${correct === questions.length ? ' 객관식 성취를 완료했습니다.' : ' 해설을 확인해 주세요.'}`); + }); + dom['lesson-panel'].append(form); +} + +function renderPractice(lesson) { + const practice = lesson.practice; + if (!practice) return; + dom['lesson-panel'].append(sectionTitle(practice.title || '실무의 제약을 더해 보기'), el('p', 'section-caption', '동작하는 코드에서 한 걸음 더 나아가, 선택의 이유와 실패 조건을 설명해 보세요.')); + const scenario = el('div', 'scenario-card'); + scenario.append(el('p', 'small-label', 'THE SCENARIO'), el('p', '', practice.scenario)); + dom['lesson-panel'].append(scenario); + const tasks = el('ol', 'practice-tasks'); + (practice.tasks || []).forEach((task, index) => { + const item = el('li'); + item.append(el('span', 'task-index', String(index + 1).padStart(2, '0')), el('span', '', task)); + tasks.append(item); + }); + dom['lesson-panel'].append(tasks); + if (practice.takeaway) { + const takeaway = el('div', 'takeaway'); + takeaway.append(el('strong', '', '기억할 설계 기준'), el('p', '', practice.takeaway)); + dom['lesson-panel'].append(takeaway); + } + const label = el('label', 'design-notes-label', '나의 설계 판단'); + label.htmlFor = 'design-notes'; + const notes = el('textarea', 'design-notes'); + notes.id = 'design-notes'; + notes.value = state.notes.get(lesson.id) || ''; + notes.maxLength = NOTE_LIMIT; + notes.placeholder = '어떤 선택을 했나요? 책임의 위치, 실패 조건, 다른 대안과의 차이를 적어 보세요.'; + notes.setAttribute('aria-describedby', 'notes-save-state'); + notes.addEventListener('input', () => { state.notes.set(lesson.id, notes.value); scheduleSave(); }); + const footnote = el('p', 'notes-footnote'); + footnote.id = 'notes-save-state'; + dom['lesson-panel'].append(label, notes, footnote, nextStepButton('lab', '직접 구현해 보기')); +} + +function renderLab(lesson) { + const exercise = exerciseFor(lesson); + if (!exercise) { + dom['lesson-panel'].append(sectionTitle('코드 실습 준비 중'), el('p', 'section-caption', '이 수업에 연결할 실습을 준비하고 있습니다.')); + return; + } + const heading = el('div', 'lab-header'); + heading.append(el('h3', '', exercise.title), el('span', 'lab-mode', isProject(exercise) ? 'PROJECT LAB' : exercise.mode === 'class' ? 'CLASS DESIGN' : 'METHOD IMPLEMENTATION')); + dom['lesson-panel'].append(heading, el('p', 'section-caption', isProject(exercise) ? '파일을 오가며 업무 계약을 구현하세요. 편집 가능한 Java 파일 전체를 함께 검증합니다. POM과 지원 리소스는 읽기 전용입니다.' : exercise.mode === 'class' ? '주어진 계약을 만족하는 클래스를 직접 설계하세요. 클래스 전체를 편집할 수 있습니다.' : '제공된 클래스와 메서드 골격을 완성하세요. 클래스 전체를 편집할 수 있습니다.')); + const runtime = el('div', 'runtime-card'); + runtime.id = 'runtime-info'; + runtime.setAttribute('aria-live', 'polite'); + dom['lesson-panel'].append(runtime); + const instructions = el('ol', 'lab-instructions'); + (exercise.instructions || []).forEach((instruction) => instructions.append(el('li', '', instruction))); + dom['lesson-panel'].append(instructions); + if (exercise.sampleDescription) { + const sample = el('div', 'expected-output'); + sample.append(el('p', 'small-label', '공개 예시 · 예상 검증 결과'), el('p', 'section-caption', exercise.sampleDescription), el('pre', '', exercise.sampleOutput || '')); + dom['lesson-panel'].append(sample); + } + if (exercise.rubric?.length) { + const rubric = el('details', 'lab-rubric'); + rubric.append(el('summary', '', '설계를 설명할 때 확인할 기준')); + const list = el('ul'); + exercise.rubric.forEach((item) => list.append(el('li', '', item))); + rubric.append(list, el('p', 'inline-note', '이 기준은 자기 점검용입니다. 테스트 통과가 설계 설명의 자동 승인을 의미하지 않습니다.')); + dom['lesson-panel'].append(rubric); + } + const editorShell = el('div', 'editor-shell'); + editorShell.dataset.editorTheme = state.editorTheme; + const toolbar = el('div', 'editor-toolbar'); + const filename = el('span', 'editor-filename'); + filename.id = 'editor-filename'; + const editorTools = el('div', 'editor-tools'); + const editor = el('div', 'code-editor'); + editor.id = 'code-editor'; + editor.dataset.testid = 'code-editor'; + const wrapToggle = actionButton('줄바꿈', '', () => { + state.editorWrap = !state.editorWrap; + activeEditor?.setWrap(state.editorWrap); + wrapToggle.setAttribute('aria-pressed', String(state.editorWrap)); + saveEditorPreferences(); + }, 'code-wrap-toggle'); + wrapToggle.setAttribute('aria-pressed', String(state.editorWrap)); + wrapToggle.setAttribute('aria-label', '긴 코드 줄 자동 줄바꿈'); + editorTools.append( + actionButton('들여쓰기', '', () => { + activeEditor?.indent(); + }, 'indent-code'), + wrapToggle, + actionButton('복사', '', () => copyText(activeEditor?.getValue() ?? sourceFor(exercise), activeEditor), 'copy-code'), + actionButton('초기 코드', '', () => { + if (isProject(exercise) && !activeFile(exercise)?.editable) return; + const previous = activeEditor?.getValue() ?? sourceFor(exercise); + const template = isProject(exercise) ? activeFile(exercise).source : exercise.template; + if (previous === template) { toast('이미 초기 코드입니다.'); return; } + state.undo.set(fileKey(exercise), previous); + setSource(exercise, template); + activeEditor?.setValue(template); + refreshDraftDependentUi(exercise); + renderUndo(exercise); + saveStorage(); + announce('초기 코드로 되돌렸습니다. 바로 아래 되돌리기 버튼으로 이전 코드를 복구할 수 있습니다.'); + }, 'lab-reset'), + ); + toolbar.append(filename, editorTools); + const editorStatus = el('div', 'editor-status'); + const lines = el('span'); lines.id = 'editor-line-count'; + const save = el('span'); save.id = 'editor-save-state'; + editorStatus.append(lines, save); + const tabsHost = el('div'); tabsHost.id = 'project-file-tabs'; + const legend = syntaxLegend(); legend.id = 'editor-syntax-legend'; + editorShell.append(toolbar, codePreferences(), legend, tabsHost, editor, editorStatus); + const hint = el('p', 'editor-hint', `Tab은 다음 항목으로 이동하고 들여쓰기 버튼은 선택 줄에 공백 4칸을 넣습니다. 색상은 구문을 구분하며 컴파일 결과를 대신하지 않습니다. ${isProject(exercise) ? '현재 파일만 초기화합니다. 편집은 파일당 65,536자, 실행은 UTF-8 파일당 64 KiB·전체 256 KiB까지입니다. 한글은 여러 바이트를 사용합니다.' : `class Solution 골격을 유지하고 최상위 public class·package·Main 선언은 추가하지 마세요. 편집은 50,000자까지이며 ${isDesktop ? '실행 소스' : '실행 요청 전체'}는 UTF-8 32 KiB까지입니다. 한글은 여러 바이트를 사용합니다.`}`); + hint.id = 'editor-hint'; + const undo = el('div', 'reset-undo'); undo.id = 'lab-reset-undo'; undo.hidden = true; + const disclosure = el('p', 'execution-disclosure', isDesktop ? '실행은 이 PC에서 이루어집니다. 코드는 PC의 사용자 권한으로 파일과 네트워크에 접근할 수 있으며, 격리된 보안 샌드박스가 아닙니다. 설정에서 동의한 뒤 실행하세요.' : isProject(exercise) ? '실무 프로젝트 실행은 Windows 앱에서 지원합니다. 이 웹 화면의 코드는 외부 실행 서비스로 전송하지 않습니다.' : '실행·제출을 누르면 작성 코드가 외부 서비스 Wandbox로 전송됩니다. 운영 로그에 코드가 남을 수 있으니 비밀정보를 입력하지 마세요. '); + disclosure.id = 'execution-disclosure'; + if (!isDesktop && !isProject(exercise)) disclosure.append(safeLink('Wandbox 서비스 안내 ↗', 'https://github.com/melpon/wandbox#faq')); + const actions = el('div', 'lab-actions'); + if (isDesktop && isProject(exercise)) actions.append(actionButton('환경 준비', 'button button-secondary', () => executeCode(exercise, 'prepare'), 'prepare-project')); + const run = actionButton(isProject(exercise) ? '테스트 실행' : '예시 실행', 'button button-secondary', () => executeCode(exercise, 'run'), 'run-code'); + run.prepend(icon('play')); + run.setAttribute('aria-describedby', 'execution-disclosure'); + const submit = actionButton('제출하고 검증하기', 'button button-primary', () => executeCode(exercise, 'submit'), 'submit-code'); + submit.prepend(icon('check')); + submit.setAttribute('aria-describedby', 'execution-disclosure'); + const cancel = actionButton(isDesktop ? '실행 취소' : '응답 대기 취소', 'button button-quiet', () => cancelPending('user'), 'cancel-execution'); + cancel.hidden = true; + const executionState = el('span', 'execution-state'); executionState.id = 'execution-state'; executionState.setAttribute('aria-live', 'polite'); + actions.append(run, submit, cancel, executionState); + const output = el('section', 'execution-panel'); + output.id = 'execution-output'; + output.dataset.testid = 'execution-output'; + output.setAttribute('aria-label', '실제 Java 실행 결과'); + output.setAttribute('aria-busy', 'false'); + output.tabIndex = -1; + dom['lesson-panel'].append(editorShell, hint, undo, disclosure, actions, output); + renderEditorFile(exercise); + if (!editorLibrary) hint.prepend(document.createTextNode('편집기 확장을 불러오지 못해 기본 텍스트 편집기를 사용합니다. ')); + renderRuntime(); + renderUndo(exercise); + refreshEditorCounter(exercise); + renderExecution(exercise); + updateLabControls(); + if (isDesktop && isProject(exercise)) void loadProject(exercise); +} + +function renderEditorFile(exercise) { + destroyCodeEditor(); + const parent = document.getElementById('code-editor'); + if (!parent) return; + parent.replaceChildren(); + const file = activeFile(exercise); + const name = document.getElementById('editor-filename'); + name.replaceChildren(icon('code'), document.createTextNode(file ? file.path : 'Solution · Java')); + const readonly = Boolean(file && !file.editable); + if (file && !file.path.endsWith('.java')) name.append(el('span', 'file-readonly-badge', fileLanguage(file.path))); + if (readonly) name.append(el('span', 'file-readonly-badge', '읽기 전용')); + document.getElementById('indent-code').disabled = readonly; + document.getElementById('lab-reset').disabled = readonly; + document.getElementById('editor-syntax-legend').hidden = Boolean(file && !file.path.endsWith('.java')); + const host = document.getElementById('project-file-tabs'); + if (file) { + const files = projectFiles(exercise); + host.replaceChildren(fileTabs(files, file.path, (path) => { + if (activeFile(exercise)?.path === path) return; + saveStorage(); + state.activeFiles.set(exercise.id, path); + renderEditorFile(exercise); + announce(`${path}${files.find((item) => item.path === path)?.editable ? ' 편집' : ' 읽기 전용'}`); + }, 'project-file', parent.id, '실습 프로젝트 파일')); + parent.setAttribute('role', 'tabpanel'); + parent.setAttribute('aria-labelledby', `project-file-${files.indexOf(file)}`); + } + mountCodeEditor(parent, exercise); + renderUndo(exercise); + refreshEditorCounter(exercise); + updateStorageIndicator(); +} + +function mountCodeEditor(parent, exercise) { + const file = activeFile(exercise); + const path = file?.path; + const readOnly = Boolean(file && !file.editable); + const onChange = (value) => { + if (readOnly) return; + setSource(exercise, value, path); + state.undo.delete(fileKey(exercise, path)); + renderUndo(exercise); + refreshDraftDependentUi(exercise); + scheduleSave(); + }; + const options = { + parent, source: sourceFor(exercise), theme: state.editorTheme, wrap: state.editorWrap, + label: `${file?.path || exercise.title} ${file ? fileLanguage(file.path) : 'Java'} ${readOnly ? '읽기 전용 파일' : '코드 편집기'}`, descriptionId: 'editor-hint editor-save-state', + readOnly, language: file && !file.path.endsWith('.java') ? 'text' : 'java', + maxLength: sourceLimit(exercise), onChange, onBlur: saveStorage, + onLimit: () => toast(`코드는 ${sourceLimit(exercise).toLocaleString('ko-KR')}자까지 작성할 수 있습니다. 실행 바이트 제한은 별도로 확인합니다.`), + }; + if (editorLibrary) { + activeEditor = editorLibrary.createJavaEditor(options); + return; + } + const textarea = el('textarea', 'code-editor-fallback'); + textarea.value = options.source; + textarea.maxLength = options.maxLength; + textarea.readOnly = readOnly; + textarea.setAttribute('aria-readonly', String(readOnly)); + textarea.spellcheck = false; + textarea.setAttribute('aria-label', options.label); + textarea.setAttribute('aria-describedby', options.descriptionId); + textarea.dataset.testid = 'code-editor-input'; + textarea.setAttribute('autocapitalize', 'off'); + textarea.setAttribute('autocorrect', 'off'); + textarea.setAttribute('autocomplete', 'off'); + textarea.setAttribute('wrap', state.editorWrap ? 'soft' : 'off'); + textarea.addEventListener('input', () => onChange(textarea.value)); + textarea.addEventListener('blur', saveStorage); + parent.dataset.editorEngine = 'textarea-fallback'; + parent.append(textarea); + activeEditor = { + getValue: () => textarea.value, + setValue: (value) => { textarea.value = value; }, + focus: () => textarea.focus(), + selectAll: () => { textarea.focus(); textarea.select(); }, + indent: () => { + if (readOnly) return; + textarea.setRangeText(' ', textarea.selectionStart, textarea.selectionEnd, 'end'); + onChange(textarea.value); + textarea.focus(); + }, + setTheme: () => {}, + setWrap: (wrap) => textarea.setAttribute('wrap', wrap ? 'soft' : 'off'), + destroy: () => {}, + }; +} + +function refreshEditorCounter(exercise) { + const count = document.getElementById('editor-line-count'); + const source = sourceFor(exercise); + if (count) count.textContent = `${source.split('\n').length} lines · ${source.length.toLocaleString('ko-KR')} / ${sourceLimit(exercise).toLocaleString('ko-KR')} chars · ${(utf8Size(source) / 1024).toFixed(1)} KiB`; +} + +function renderUndo(exercise) { + const row = document.getElementById('lab-reset-undo'); + if (!row) return; + row.replaceChildren(); + const key = fileKey(exercise); + row.hidden = !state.undo.has(key); + if (row.hidden) return; + row.append(document.createTextNode('초기 코드로 되돌렸습니다.'), actionButton('되돌리기', '', () => { + if (!state.undo.has(key)) return; + const source = state.undo.get(key); + state.undo.delete(key); + setSource(exercise, source); + activeEditor?.setValue(source); + refreshDraftDependentUi(exercise); + renderUndo(exercise); + saveStorage(); + activeEditor?.focus(); + announce('이전 작성 코드를 복구했습니다.'); + }, 'lab-undo')); +} + +function refreshDraftDependentUi(exercise) { + refreshEditorCounter(exercise); + renderAchievements(); + renderProgress(); + renderSidebar(); + renderExecution(exercise); +} + +function localRuntimes(exercise) { + const minimum = Number(state.projectInfo.get(exercise?.id)?.runtimeRequirements?.minimumJava || exercise?.minVersion || 8); + return (state.runtime?.runtimes || []).filter((runtime) => runtime.available && VERSIONS.includes(Number(runtime.javaVersion)) && Number(runtime.javaVersion) >= minimum).sort((a, b) => b.javaVersion - a.javaVersion); +} + +function hasBundledRuntime() { + return Boolean(state.runtime?.bundledRuntime || state.runtime?.runtimes?.some((runtime) => runtime.source === 'bundled') || state.runtime?.maven?.source === 'bundled'); +} + +function runtimeSourceLabel(tool) { + return tool?.source === 'bundled' ? '앱에 포함' : tool?.available ? '직접 등록' : '등록하지 않음'; +} + +function runtimeFor(exercise) { + if (!isDesktop) return 21; + const available = localRuntimes(exercise); + return available.some((runtime) => Number(runtime.javaVersion) === state.runtimeVersion) ? state.runtimeVersion : Number(available[0]?.javaVersion) || null; +} + +function environmentKey(version) { + const runtime = state.runtime?.runtimes?.find((item) => Number(item.javaVersion) === version && item.available); + return JSON.stringify([version, runtime?.path || '', state.runtime?.maven?.path || '', state.runtime?.maven?.version || '']); +} + +function projectPrepared(exercise) { + const version = runtimeFor(exercise); + const key = environmentKey(version); + return (state.runtime?.preparedProjects || []).some((item) => item.exerciseId === exercise.id && JSON.stringify([Number(item.runtimeVersion), item.jdkPath || '', item.mavenPath || '', item.mavenVersion || '']) === key); +} + +function executionBlockReason(exercise, action = 'run') { + if (isProject(exercise) && !isDesktop) return '프로젝트 실행은 Windows 앱에서 지원합니다'; + if (state.cancelling) return '이전 실행의 취소를 확인하고 있습니다'; + if (state.settingsBusy) return '실행 설정을 확인하고 있습니다'; + if (state.runtimeStatus === 'checking') return '실행 환경 확인 중'; + if (isDesktop) { + if (!state.runtime?.consent?.accepted) return '앱 설정에서 로컬 코드 실행에 동의해 주세요'; + if (!runtimeFor(exercise)) return `앱 설정에서 Java ${exercise.minVersion} 이상의 JDK를 등록해 주세요`; + if (state.runtimeStatus !== 'ready') return state.runtimeMessage || '로컬 실행 환경을 다시 확인해 주세요'; + if (isProject(exercise)) { + if (!state.runtime.maven?.available) return '앱 설정에서 Maven 3.9 이상을 등록해 주세요'; + const project = state.projectInfo.get(exercise.id); + if (project?.status !== 'ready') return project?.message || '프로젝트 파일을 확인하고 있습니다'; + if (action !== 'prepare' && !projectPrepared(exercise)) return '선택한 JDK로 환경 준비를 먼저 완료해 주세요'; + } + return ''; + } + return state.runtimeStatus === 'ready' && state.runtime?.supportedTargets?.includes(Number(exercise.minVersion)) ? '' : '실행 연결을 확인해 주세요'; +} + +function renderRuntime() { + const card = document.getElementById('runtime-info'); + if (!card) return; + const exercise = exerciseFor(currentLesson()); + card.replaceChildren(); + card.classList.toggle('is-unavailable', state.runtimeStatus === 'unavailable' || (isProject(exercise) && !isDesktop)); + const line = el('div', 'runtime-line'); + const runtimeVersion = runtimeFor(exercise); + line.append(el('strong', '', isProject(exercise) && !isDesktop ? '실행 위치 · Windows 앱' : runtimeVersion ? `실제 실행 JVM · Java ${runtimeVersion}` : '실제 실행 JVM · JDK 등록 필요'), el('span', '', `컴파일 대상 · Java ${exercise?.minVersion || 8}`)); + card.append(line); + if (isProject(exercise) && !isDesktop) { + card.append(el('p', '', '웹에서 이론·문제·다중 파일 코드를 학습하고, Windows 앱에 포함된 Java 21과 Maven으로 테스트를 실행하세요. 웹과 앱의 학습 기록은 각각 저장됩니다.')); + const actions = el('div', 'project-access-actions'); + actions.append(actionButton('Windows 앱 안내', 'button button-secondary', () => openDialog('install-dialog'), 'project-desktop-guide')); + if (/^\/downloads\/[a-z0-9-]+\.zip$/.test(exercise.downloadUrl || '')) { + const download = el('a', 'button button-secondary', '프로젝트 ZIP 다운로드'); + download.id = 'project-download'; download.href = exercise.downloadUrl; download.download = ''; + actions.append(download); + } + card.append(actions); + return; + } + if (isDesktop) { + const row = el('div', 'runtime-controls'); + const label = el('label', '', '실제 실행 JDK'); label.htmlFor = 'runtime-version-select'; + const select = el('select'); select.id = 'runtime-version-select'; select.dataset.testid = select.id; + const available = localRuntimes(exercise); + if (!available.length) select.append(el('option', '', '등록된 호환 JDK 없음')); + available.forEach((runtime) => { const option = el('option', '', `Java ${runtime.javaVersion}${runtime.source ? ` · ${runtimeSourceLabel(runtime)}` : ''}`); option.value = String(runtime.javaVersion); select.append(option); }); + select.value = String(runtimeVersion || ''); + select.disabled = !available.length || Boolean(state.pending) || state.cancelling || state.settingsBusy; + select.addEventListener('change', () => { + cancelPending('실행 JDK 변경', false); + state.runtimeVersion = Number(select.value); + renderRuntime(); updateLabControls(); + document.getElementById('runtime-version-select')?.focus({ preventScroll: true }); + announce(`실제 실행 JDK를 Java ${state.runtimeVersion}로 변경했습니다. 프로젝트 준비 상태를 확인하세요.`); + }); + const needsConsent = !state.runtime?.consent?.accepted; + const selectedRuntime = available.find((runtime) => Number(runtime.javaVersion) === runtimeVersion); + row.append(label, select, actionButton(needsConsent ? '실행 권한 확인' : '실행 설정', `button ${needsConsent ? 'button-primary' : 'button-secondary'}`, openDesktopSettings, 'runtime-settings-open')); + card.append(row, el('p', '', state.runtimeMessage || (selectedRuntime?.source === 'bundled' ? '앱에 포함된 JDK를 사용합니다. 컴파일 대상과 실제 실행 JVM은 위에 따로 표시됩니다.' : '이 PC의 등록 JDK로 실행합니다. 학습 기준 버전·컴파일 대상·실제 실행 JVM을 따로 확인하세요.'))); + if (isProject(exercise)) { + const project = state.projectInfo.get(exercise.id); + const preparation = el('p', 'project-preparation', projectPrepared(exercise) ? '환경 준비 완료 · 현재 JDK와 Maven으로 테스트할 수 있습니다.' : '처음 환경 준비에는 인터넷 연결이 필요합니다. 고정 기준 답안으로 Maven 라이브러리를 자동으로 내려받고 환경을 검증합니다. 이후 학습자 테스트는 저장된 라이브러리로 실행합니다.'); + preparation.id = 'project-preparation-state'; preparation.dataset.prepared = String(projectPrepared(exercise)); + card.append(preparation); + if (project?.status === 'error') card.append(el('p', '', project.message), actionButton('프로젝트 다시 확인', 'runtime-retry', () => loadProject(exercise, true), 'project-retry')); + for (const service of project?.runtimeRequirements?.services || []) card.append(el('p', 'project-service', `${service.label || service.type}: 학습용 ${service.host}:${service.port}가 필요합니다. 포트 확인 후 실제 통합 테스트로 검증합니다.`)); + } + if (state.runtimeStatus === 'unavailable') card.append(actionButton('설정 다시 확인', 'runtime-retry', loadRuntime, 'runtime-retry')); + return; + } + if (state.runtimeStatus === 'checking') { + card.append(el('p', '', 'Wandbox 실행 환경의 연결 상태를 확인하고 있습니다.')); + } else if (state.runtimeStatus === 'unavailable') { + card.append(el('p', '', state.runtimeMessage || '실행 서버에 연결할 수 없습니다. 수업·문제·코드 작성은 계속 이용할 수 있습니다.'), actionButton('연결 다시 확인', 'runtime-retry', loadRuntime, 'runtime-retry')); + } else { + card.append(el('p', '', `Wandbox · 이 실습은 --release ${exercise?.minVersion || 8}로 컴파일하고 Java ${runtimeVersion} JVM에서 실행합니다. 학습 기준 버전과 실제 실행 JVM은 구분됩니다.`)); + if (state.runtime?.notice) card.append(el('p', '', state.runtime.notice)); + } +} + +function updateLabControls() { + const exercise = exerciseFor(currentLesson()); + const run = document.getElementById('run-code'); + const submit = document.getElementById('submit-code'); + const cancel = document.getElementById('cancel-execution'); + const status = document.getElementById('execution-state'); + if (!run || !submit || !cancel || !status || !exercise) return; + const busy = Boolean(state.pending); + const reason = executionBlockReason(exercise); + run.disabled = busy || Boolean(reason); + submit.disabled = busy || Boolean(reason); + run.dataset.blocked = reason ? 'true' : 'false'; submit.dataset.blocked = run.dataset.blocked; + const prepare = document.getElementById('prepare-project'); + if (prepare) { prepare.disabled = busy || Boolean(executionBlockReason(exercise, 'prepare')); prepare.dataset.prepared = String(projectPrepared(exercise)); } + const runtimeSelect = document.getElementById('runtime-version-select'); + if (runtimeSelect) runtimeSelect.disabled = busy || state.cancelling || state.settingsBusy || !localRuntimes(exercise).length; + cancel.hidden = !busy || state.pending.exerciseId !== exercise.id; + status.textContent = busy ? state.pending.action === 'prepare' ? 'Maven 환경 준비 중 · 처음에는 수 분 걸릴 수 있습니다' : '실제 Java 컴파일·실행 중…' : reason || (isDesktop ? '이 PC에서 명시적으로 실행합니다' : '코드는 버튼을 누를 때 전송됩니다'); + document.getElementById('execution-output')?.setAttribute('aria-busy', String(busy)); +} + +function acceptRuntime(runtime) { + if (!runtime || typeof runtime !== 'object' || !Array.isArray(runtime.supportedTargets)) throw new Error('실행 환경 응답을 확인할 수 없습니다.'); + if (isDesktop && (runtime.mode !== 'local' || !Array.isArray(runtime.runtimes))) throw new Error('로컬 실행 환경 응답을 확인할 수 없습니다.'); + state.runtime = { ...runtime, supportedTargets: runtime.supportedTargets.map(Number).filter((version) => VERSIONS.includes(version)) }; + const available = runtime.available === true && (isDesktop || Number(runtime.runtime?.javaVersion) === 21); + state.runtimeStatus = available ? 'ready' : 'unavailable'; + const reasons = { execution_consent_required: '실행 도구를 사용하려면 실행 권한을 확인하고 동의해 주세요.', jdk_required: '실행 설정에서 사용할 JDK를 등록해 주세요.' }; + state.runtimeMessage = available ? '' : Object.hasOwn(reasons, runtime.reason) ? reasons[runtime.reason] : typeof runtime.reason === 'string' ? runtime.reason : typeof runtime.notice === 'string' ? runtime.notice : '실행 환경 설정을 확인해 주세요.'; +} + +async function loadRuntime() { + const sequence = ++runtimeSequence; + state.runtimeStatus = 'checking'; + renderRuntime(); + updateLabControls(); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch('/api/runtime', { signal: controller.signal, headers: { Accept: 'application/json' }, cache: 'no-store' }); + if (!response.ok) throw new Error('Runtime unavailable'); + const runtime = await response.json(); + if (sequence !== runtimeSequence) return; + acceptRuntime(runtime); + } catch { + if (sequence !== runtimeSequence) return; + state.runtimeStatus = 'unavailable'; + state.runtimeMessage = isDesktop ? '로컬 실행 환경을 읽지 못했습니다. 설정을 다시 확인해 주세요.' : '실행 서버에 연결하지 못했습니다. 수업·문제·코드 작성은 계속 이용할 수 있습니다.'; + } finally { + clearTimeout(timer); + if (sequence === runtimeSequence) { renderRuntime(); updateLabControls(); renderDesktopSettings(); } + } +} + +async function loadProject(exercise, retry = false) { + if (!isDesktop || !isProject(exercise)) return; + if (!retry && state.projectInfo.has(exercise.id)) return; + const marker = { status: 'loading' }; + state.projectInfo.set(exercise.id, marker); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(`/api/local/projects/${encodeURIComponent(exercise.id)}`, { signal: controller.signal, headers: { Accept: 'application/json' }, cache: 'no-store' }); + const payload = await response.json(); + if (state.projectInfo.get(exercise.id) !== marker) return; + if (!response.ok) throw new Error(payload.error?.message || '프로젝트 파일을 읽지 못했습니다.'); + const files = payload.files; + if (payload.exerciseId !== exercise.id || !Array.isArray(files) || files.length !== exercise.files.length || !files.every((file) => typeof file.path === 'string' && typeof file.source === 'string' && typeof file.editable === 'boolean' && exercise.files.some((expected) => expected.path === file.path && expected.editable === file.editable)) || new Set(files.map((file) => file.path)).size !== files.length) throw new Error('교재와 실행기의 파일 목록이 다릅니다. 앱의 교재 버전을 확인해 주세요.'); + const changed = files.some((file) => exercise.files.find((item) => item.path === file.path)?.source !== file.source); + state.projectInfo.set(exercise.id, { status: 'ready', files, runtimeRequirements: payload.runtimeRequirements }); + if (changed && currentLesson()?.id === exercise.lessonId && state.step === 'lab') renderEditorFile(exercise); + } catch (error) { + if (state.projectInfo.get(exercise.id) === marker) state.projectInfo.set(exercise.id, { status: 'error', message: error instanceof Error ? error.message : '프로젝트 파일을 읽지 못했습니다.' }); + } finally { + clearTimeout(timer); + if (currentLesson()?.id === exercise.lessonId && state.step === 'lab') { renderRuntime(); updateLabControls(); } + } +} + +function renderDesktopSettings() { + const body = document.getElementById('desktop-settings-body'); + if (!isDesktop || !body) return; + const bundled = hasBundledRuntime(); + const externalTools = state.runtime?.runtimes?.some((runtime) => runtime.available && runtime.source !== 'bundled') || (state.runtime?.maven?.available && state.runtime.maven.source !== 'bundled'); + const advancedOpen = document.getElementById('desktop-advanced-settings')?.open ?? Boolean(externalTools); + const description = document.getElementById('desktop-settings-description'); + if (description) description.textContent = !state.runtime ? '실행 도구를 확인하고 있습니다. 코드는 실행·제출 버튼을 눌렀을 때 실행됩니다.' : bundled ? '앱에 포함된 도구로 시작하세요. 동의 후 실행·제출 버튼을 누르면 코드를 실행합니다.' : '설치된 JDK와 Maven의 폴더를 선택하세요. 시스템 PATH를 바꾸지 않으며, 코드는 실행·제출 버튼을 눌렀을 때 실행됩니다.'; + body.replaceChildren(); + if (bundled) { + const jdk = state.runtime.runtimes.find((runtime) => Number(runtime.javaVersion) === 21); + const maven = state.runtime.maven; + const ready = jdk?.available && maven?.available; + const included = ready && jdk.source === 'bundled' && maven.source === 'bundled'; + const card = el('section', `settings-bundle${ready ? '' : ' is-unavailable'}`); + card.id = 'desktop-bundle-status'; card.setAttribute('aria-labelledby', 'desktop-bundle-title'); + const heading = el('h3', '', included ? '별도 설치 없이 시작하세요' : ready ? '사용 중인 실행 도구' : '실행 도구를 확인해 주세요'); + heading.id = 'desktop-bundle-title'; + const tools = el('ul', 'settings-bundle-tools'); + for (const [name, tool] of [['Java 21', jdk], [`Maven${maven?.version ? ` ${maven.version}` : ''}`, maven]]) { + const item = el('li'); item.append(el('strong', '', name), el('span', '', runtimeSourceLabel(tool))); tools.append(item); + } + card.append(heading, tools, el('p', '', included ? 'Java 8·11·17·21 대상의 코드를 컴파일할 수 있습니다. 기본 실제 실행 JVM은 Java 21입니다.' : '직접 등록한 도구가 있으면 해당 설정을 유지합니다. 사용할 JDK와 Maven은 아래 고급 설정에서 확인할 수 있습니다.'), el('p', '', 'Spring 등 실무 수업은 처음 환경 준비 시 인터넷으로 필요한 라이브러리를 자동 다운로드합니다.')); + body.append(card); + } + const tools = el('div', 'settings-tools'); + for (const version of VERSIONS) { + const runtime = state.runtime?.runtimes?.find((item) => Number(item.javaVersion) === version); + const row = el('div', 'settings-tool-row'); + const copy = el('div'); + copy.append(el('strong', '', `JDK ${version}${bundled && !runtime?.available ? ' · 선택 사항' : ''}`)); + if (runtime?.source) copy.append(el('p', 'settings-tool-source', runtimeSourceLabel(runtime))); + copy.append(el('p', 'settings-tool-path', runtime?.available ? runtime.path || '등록됨' : '등록하지 않음')); + const button = actionButton(runtime?.source === 'bundled' ? '다른 폴더 선택' : runtime?.available ? '변경' : '폴더 선택', 'button button-secondary', () => changeDesktopSettings(() => desktop.chooseJdk(version)), `jdk-register-${version}`); + button.setAttribute('aria-label', `JDK ${version} 설치 폴더 선택`); button.disabled = state.settingsBusy; + row.append(copy, button); tools.append(row); + } + const row = el('div', 'settings-tool-row'); + const copy = el('div'); + copy.append(el('strong', '', `Maven${state.runtime?.maven?.version ? ` ${state.runtime.maven.version}` : ' 3.9+'}`)); + if (state.runtime?.maven?.source) copy.append(el('p', 'settings-tool-source', runtimeSourceLabel(state.runtime.maven))); + copy.append(el('p', 'settings-tool-path', state.runtime?.maven?.available ? state.runtime.maven.path || '등록됨' : '등록하지 않음')); + const maven = actionButton(state.runtime?.maven?.source === 'bundled' ? '다른 폴더 선택' : state.runtime?.maven?.available ? '변경' : '폴더 선택', 'button button-secondary', () => changeDesktopSettings(() => desktop.chooseMaven()), 'maven-register'); + maven.setAttribute('aria-label', 'Maven 설치 폴더 선택'); maven.disabled = state.settingsBusy; + row.append(copy, maven); tools.append(row); + if (!bundled) body.append(tools); + const consent = el('div', 'settings-consent'); + consent.append(el('h3', '', '이 PC에서 코드 실행'), el('p', '', 'Java 코드는 현재 PC의 사용자 권한으로 파일과 네트워크에 접근할 수 있습니다. 이 앱은 격리된 보안 샌드박스가 아닙니다. 신뢰하는 코드를 직접 확인한 뒤 실행하세요.')); + const label = el('label'); + const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.id = 'execution-consent'; + checkbox.checked = state.runtime?.consent?.accepted === true; + checkbox.disabled = state.settingsBusy; + checkbox.addEventListener('change', () => changeDesktopSettings(() => desktop.setExecutionConsent(checkbox.checked))); + label.append(checkbox, el('span', '', '위 실행 권한을 이해했으며 로컬 코드 실행에 동의합니다.')); + consent.append(label); body.append(consent); + const status = el('p', 'settings-status', state.settingsBusy ? '설정을 확인하고 있습니다…' : state.settingsMessage); + status.id = 'desktop-settings-status'; status.setAttribute('role', 'status'); body.append(status); + if (bundled) { + const advanced = el('details', 'settings-advanced'); advanced.id = 'desktop-advanced-settings'; advanced.open = advancedOpen; + const summary = el('summary', '', '고급 설정 · 다른 JDK 또는 Maven 사용'); summary.id = 'desktop-advanced-summary'; + advanced.append(summary, el('p', '', '실제 Java 8·11·17 JVM을 비교하거나 다른 도구를 사용할 때만 직접 등록하세요. 기본 학습에는 추가 설치와 폴더 선택이 필요하지 않습니다.'), tools); + body.append(advanced); + } +} + +async function changeDesktopSettings(operation, mutation = true) { + if (!isDesktop || state.settingsBusy) return; + const focusId = document.activeElement?.id; + if (mutation) cancelPending('실행 설정 변경', false); + state.settingsBusy = true; state.settingsMessage = ''; + renderDesktopSettings(); updateLabControls(); + const sequence = ++runtimeSequence; + try { + const runtime = await operation(); + if (sequence !== runtimeSequence) return; + acceptRuntime(runtime); + state.settingsMessage = mutation ? '실행 설정을 확인했습니다.' : ''; + } catch (error) { + state.settingsMessage = error instanceof Error ? error.message : '실행 설정을 저장하지 못했습니다.'; + } finally { + state.settingsBusy = false; + renderDesktopSettings(); renderRuntime(); updateLabControls(); + if (document.getElementById('desktop-settings-dialog').open && focusId) document.getElementById(focusId)?.focus({ preventScroll: true }); + } +} + +function openDesktopSettings() { + if (!isDesktop) return; + renderDesktopSettings(); + openDialog('desktop-settings-dialog'); + void changeDesktopSettings(() => desktop.getSettings(), false); +} + +function renderExecution(exercise) { + const panel = document.getElementById('execution-output'); + if (!panel) return; + panel.replaceChildren(); + const pending = state.pending?.exerciseId === exercise.id ? state.pending : null; + const result = state.labResults.get(exercise.id); + const outdated = Boolean(result && result.action !== 'prepare' && result.sourceSnapshot !== sourceSnapshot(exercise)); + panel.dataset.status = pending ? 'running' : outdated ? 'outdated' : result?.status || 'idle'; + panel.setAttribute('aria-busy', String(Boolean(pending))); + const heading = el('div', 'execution-panel-heading'); + const title = el('strong'); title.append(icon('terminal'), document.createTextNode('실제 실행 결과')); + const metadata = result && result.runtimeVersion ? `Java ${result.runtimeVersion} · 컴파일 대상 ${result.targetVersion}` : isDesktop ? '이 PC의 JDK' : isProject(exercise) ? 'Windows 앱에서 실행' : 'Wandbox / Java 21'; + heading.append(title, el('span', '', metadata)); + const body = el('div', 'execution-output-body'); + if (pending) { + body.append(el('p', 'execution-placeholder', pending.action === 'prepare' ? '고정 기준 답안으로 Maven 의존성을 내려받고 실행 환경을 검증합니다. 처음에는 수 분 걸릴 수 있습니다. 이 단계는 작성 코드의 제출 통과로 기록하지 않습니다.' : '작성 코드를 컴파일하고 실제 JVM에서 실행하고 있습니다. 결과가 도착하면 여기에 표시됩니다.')); + if (pending.action !== 'prepare' && pending.source !== sourceSnapshot(exercise)) body.append(el('p', 'execution-warning', '요청 후 코드가 변경되었습니다. 도착하는 결과는 요청 당시 코드의 결과이며 현재 코드의 완료 기록에 반영하지 않습니다.')); + } else if (!result) { + body.append(el('p', 'execution-placeholder', isProject(exercise) ? '아직 실행하지 않았습니다. Windows 앱에서 환경 준비 후 프로젝트 전체 테스트를 실행하세요. 제출 통과와 객관식 성취를 모두 마치면 수업 완료입니다.' : '아직 실행하지 않았습니다. 예시 실행으로 공개 사례를 확인하고, 제출로 전체 테스트를 검증하세요. 예상 출력은 실제 실행 결과로 사용하지 않습니다.')); + } else { + if (outdated) body.append(el('p', 'execution-warning', '요청 후 코드가 변경되었습니다. 현재 코드의 실행 결과가 아닙니다. 현재 코드를 다시 실행·제출해 주세요.')); + const labels = { + passed: result.action === 'prepare' ? '환경 준비 완료' : result.action === 'submit' ? '제출 테스트 통과' : isProject(exercise) ? '프로젝트 테스트 통과' : '공개 예시 실행 통과', + failed: '검증 조건을 충족하지 못했습니다', + compile_error: '컴파일 오류', runtime_error: '실행 중 오류', timeout: '실행 시간 초과', + provider_error: '실행 요청을 완료하지 못했습니다', cancelled: isDesktop ? '실행 취소 요청' : '응답 대기를 취소했습니다', validation_error: '실행할 코드의 크기를 확인해 주세요', + }; + const isError = !['passed', 'cancelled'].includes(result.status); + body.append(el('p', `execution-result-label${isError ? ' is-error' : ''}`, labels[result.status] || '실행 결과 확인 필요')); + if (result.message) body.append(el('p', 'inline-note', result.message)); + if (Array.isArray(result.checks) && result.checks.length) { + const checks = el('ul', 'execution-checks'); + result.checks.forEach((check) => { + const item = el('li', check.passed ? '' : 'is-failed'); + item.append(el('span', 'check-symbol', check.passed ? '✓' : '×'), el('span', '', check.name)); + checks.append(item); + }); + body.append(checks); + body.append(el('p', 'inline-note', `${result.passed} / ${result.total} 검증 통과`)); + } + for (const [name, text, error] of [ + ['표준 출력 (stdout)', result.stdout, false], + ['표준 오류 (stderr)', result.stderr, true], + ['컴파일러 메시지', result.compilerOutput, result.status === 'compile_error'], + ]) { + if (!text) continue; + const section = el('div', `output-section${error ? ' is-error' : ''}`); + const pre = el('pre', '', text); + pre.tabIndex = 0; + pre.setAttribute('aria-label', name); + section.append(el('h4', '', name), pre); + body.append(section); + } + if (result.action === 'run' && result.status === 'passed') body.append(el('p', 'inline-note', '실행만으로 수업 완료를 기록하지 않습니다. 현재 코드의 성취를 기록하려면 제출하세요.')); + if (result.action === 'prepare') body.append(el('p', 'inline-note', '환경 준비는 기준 답안의 결과입니다. 작성 코드의 테스트 통과·수업 완료와 별도로 표시합니다.')); + if (result.action === 'submit' && result.status === 'passed' && !outdated) body.append(el('p', 'inline-note', '현재 코드의 테스트 통과를 기록했습니다. 설계 판단과 설명은 실무 탭에서 직접 점검하세요.')); + if (Number.isFinite(result.durationMs)) body.append(el('p', 'execution-timing', `${result.provider || 'Wandbox'} · 응답 시간 ${(result.durationMs / 1000).toFixed(2)}초`)); + } + panel.append(heading, body); +} + +function cancelPending(reason = 'user', update = true) { + const pending = state.pending; + if (!pending) return; + state.pending = null; + clearTimeout(pending.timer); + pending.controller.abort(); + if (reason === 'user') { + state.labResults.set(pending.exerciseId, { + status: 'cancelled', action: pending.action, sourceSnapshot: pending.source, + message: isDesktop ? '로컬 실행의 취소를 요청하고 있습니다.' : '브라우저의 응답 대기를 중단했습니다. 외부 서비스에서 이미 시작한 실행이 종료되었다는 의미는 아닙니다.', + }); + announce(isDesktop ? '로컬 실행의 취소를 요청했습니다.' : '응답 대기를 취소했습니다. 외부 실행의 종료를 보장하지 않습니다.'); + } + if (isDesktop) void cancelLocalRequest(pending, reason === 'user'); + if (update) { + const exercise = exerciseFor(currentLesson()); + if (exercise) renderExecution(exercise); + updateLabControls(); + } +} + +async function cancelLocalRequest(request, showResult = false) { + if (!isDesktop) return; + state.cancelling = true; + updateLabControls(); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10000); + let message; + try { + const response = await fetch('/api/local/cancel', { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ requestId: request.id }) }); + const payload = await response.json(); + if (!response.ok || typeof payload.cancelled !== 'boolean') throw new Error('취소 응답을 확인하지 못했습니다.'); + message = payload.cancelled ? '로컬 실행기에 취소가 반영되었습니다.' : '취소할 실행이 없습니다. 요청이 이미 종료되었을 수 있습니다.'; + } catch { + message = '로컬 실행의 취소 여부를 확인하지 못했습니다. 앱의 실행 상태를 다시 확인해 주세요.'; + toast(message); + } finally { + clearTimeout(timer); + state.cancelling = false; + if (showResult && state.labResults.get(request.exerciseId)?.status === 'cancelled') state.labResults.get(request.exerciseId).message = message; + const current = exerciseFor(currentLesson()); + if (current) renderExecution(current); + updateLabControls(); + } +} + +function executionResult(payload, request, exercise) { + if (!payload || typeof payload !== 'object' || payload.requestId !== request.id || !EXECUTION_STATUSES.has(payload.status)) throw new Error('실행 서버의 응답 형식을 확인할 수 없습니다. 다시 시도해 주세요.'); + if (Number(payload.runtimeVersion) !== request.runtimeVersion || Number(payload.targetVersion) !== Number(exercise.minVersion)) throw new Error('응답의 실행 환경이 이 실습의 계약과 일치하지 않습니다. 완료 기록에 반영하지 않았습니다.'); + if (request.action === 'prepare' && payload.status === 'passed' && payload.prepared !== true) throw new Error('환경 준비 성공을 확인하지 못했습니다. 다시 준비해 주세요.'); + const checks = Array.isArray(payload.checks) ? payload.checks.filter((check) => check && typeof check.name === 'string' && typeof check.passed === 'boolean').map((check) => ({ name: check.name, passed: check.passed })) : []; + const total = Number(payload.total); + const passed = Number(payload.passed); + if (payload.status === 'passed' && (!Number.isInteger(total) || total <= 0 || passed !== total || checks.length !== total || checks.some((check) => !check.passed))) throw new Error('통과 결과의 검증 항목이 일치하지 않습니다. 완료 기록에 반영하지 않았습니다.'); + return { + status: payload.status, action: request.action, sourceSnapshot: request.source, + runtimeVersion: Number(payload.runtimeVersion), targetVersion: Number(payload.targetVersion), + stdout: typeof payload.stdout === 'string' ? payload.stdout : '', + stderr: typeof payload.stderr === 'string' ? payload.stderr : '', + compilerOutput: typeof payload.compilerOutput === 'string' ? payload.compilerOutput : '', + message: typeof payload.error?.message === 'string' ? payload.error.message : '', + checks, passed: Number.isFinite(passed) ? passed : 0, total: Number.isFinite(total) ? total : checks.length, + provider: typeof payload.provider === 'string' ? payload.provider : isDesktop ? 'Local JDK' : 'Wandbox', + durationMs: Number.isFinite(payload.durationMs) ? payload.durationMs : undefined, + }; +} + +async function executeCode(exercise, action) { + if (state.pending || executionBlockReason(exercise, action)) return; + if (action === 'prepare' && (!isDesktop || !isProject(exercise))) return; + const source = sourceSnapshot(exercise); + const files = isProject(exercise) ? sourcesFor(exercise) : null; + if (action !== 'prepare' && (files ? Object.values(files).some((value) => !value.trim()) : !source.trim())) { + toast('실행할 Java 코드를 입력해 주세요.'); + activeEditor?.focus(); + return; + } + saveStorage(); + const id = window.crypto?.randomUUID ? window.crypto.randomUUID() : `request_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; + const runtimeVersion = runtimeFor(exercise); + const body = { exerciseId: exercise.id, requestId: id }; + if (isDesktop) body.runtimeVersion = runtimeVersion; + if (action !== 'prepare') { body.action = action; if (files) body.files = files; else body.source = source; } + const requestBody = JSON.stringify(body); + let sizeMessage = ''; + if (action !== 'prepare') { + if (files) { + const oversized = Object.entries(files).find(([, value]) => utf8Size(value) > 64 * 1024); + if (oversized) sizeMessage = `${oversized[0]} 파일이 UTF-8 64 KiB를 초과합니다.`; + else if (Object.values(files).reduce((sum, value) => sum + utf8Size(value), 0) > 256 * 1024) sizeMessage = '편집 파일 전체가 UTF-8 256 KiB를 초과합니다.'; + } else if (isDesktop && utf8Size(source) > 32 * 1024) sizeMessage = 'Java 소스가 UTF-8 32 KiB를 초과합니다.'; + if (!sizeMessage && utf8Size(requestBody) > (isDesktop ? 384 : 32) * 1024) sizeMessage = `요청 전체가 UTF-8 ${isDesktop ? 384 : 32} KiB를 초과합니다.`; + } + if (sizeMessage) { + state.labResults.set(exercise.id, { + status: 'validation_error', action, sourceSnapshot: source, + message: `${sizeMessage} 코드를 줄여 다시 시도해 주세요. 한글은 여러 바이트를 사용하며 작성 코드는 유지됩니다.`, + }); + renderExecution(exercise); + announce(sizeMessage); + return; + } + const controller = new AbortController(); + const request = { id, source, action, runtimeVersion, environment: environmentKey(runtimeVersion), exerciseId: exercise.id, lessonId: state.lessonId, version: state.version, controller, timedOut: false, timer: null }; + const timeoutMs = isDesktop ? action === 'prepare' ? 360000 : isProject(exercise) ? 180000 : 60000 : 60000; + request.timer = window.setTimeout(() => { request.timedOut = true; controller.abort(); if (isDesktop) void cancelLocalRequest(request); }, timeoutMs); + state.pending = request; + state.labResults.delete(exercise.id); + updateLabControls(); + renderExecution(exercise); + announce(action === 'prepare' ? '기준 답안으로 실행 환경을 준비합니다.' : action === 'submit' ? '코드를 제출해 전체 테스트를 검증합니다.' : '실제 Java 환경에서 코드를 실행합니다.'); + try { + const response = await fetch(action === 'prepare' ? '/api/local/prepare' : '/api/execute', { + method: 'POST', signal: controller.signal, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: requestBody, + }); + let payload; + try { payload = await response.json(); } + catch { throw new Error('실행 서버가 읽을 수 없는 응답을 반환했습니다. 잠시 후 다시 시도해 주세요.'); } + if (state.pending !== request) return; + if (!response.ok && (!EXECUTION_STATUSES.has(payload.status) || payload.status === 'passed')) { + throw new Error(typeof payload.error?.message === 'string' ? payload.error.message : `실행 요청을 처리하지 못했습니다. (HTTP ${response.status})`); + } + const result = executionResult(payload, request, exercise); + state.labResults.set(exercise.id, result); + const fresh = request.source === sourceSnapshot(exercise) && request.lessonId === state.lessonId && request.version === state.version && (!isDesktop || request.environment === environmentKey(runtimeFor(exercise))); + if (result.status === 'passed' && action === 'submit' && fresh) { + state.labPassed.set(exercise.id, request.source); + saveStorage(); + renderAchievements(); + renderSidebar(); + renderProgress(); + } else if (action === 'submit' && fresh && ['failed', 'compile_error', 'runtime_error', 'timeout'].includes(result.status)) { + state.labPassed.delete(exercise.id); + saveStorage(); + renderAchievements(); + renderSidebar(); + renderProgress(); + } + announce(action === 'prepare' ? result.status === 'passed' ? '실행 환경 준비를 완료했습니다. 작성 코드를 제출해 주세요.' : '실행 환경 준비 결과를 확인해 주세요.' : !fresh ? '요청 당시 코드의 결과가 도착했습니다. 현재 코드를 다시 제출해 주세요.' : result.status === 'passed' ? action === 'submit' ? '제출한 코드가 전체 테스트를 통과했습니다.' : '코드 실행을 통과했습니다.' : '실행 결과를 확인해 주세요.'); + } catch (error) { + if (state.pending !== request) return; + state.labResults.set(exercise.id, { + status: request.timedOut ? 'timeout' : 'provider_error', action, sourceSnapshot: source, + message: request.timedOut ? isDesktop ? '응답 대기 시간을 초과해 로컬 실행 취소를 요청했습니다. 실행 상태를 확인한 뒤 다시 시도해 주세요.' : '60초 안에 응답을 받지 못했습니다. 외부 실행이 계속 중일 수 있습니다. 잠시 후 다시 시도해 주세요.' : error instanceof Error ? error.message : '실행 서비스에 연결할 수 없습니다.', + }); + announce(request.timedOut ? '실행 응답 대기 시간이 초과되었습니다.' : '실행 요청을 완료하지 못했습니다. 오류 안내를 확인해 주세요.'); + } finally { + clearTimeout(request.timer); + if (state.pending === request) { + state.pending = null; + renderExecution(exercise); + updateLabControls(); + if (isDesktop) void loadRuntime(); + } + } +} + +function revealStepTab(step) { + const tab = document.getElementById(`tab-${step}`); + const tabs = tab?.parentElement; + if (!tabs || tabs.scrollWidth <= tabs.clientWidth) return; + const bounds = tabs.getBoundingClientRect(); + const selected = tab.getBoundingClientRect(); + // Keep the tab and its focus ring inside the strip without moving the page. + const left = bounds.left + tabs.clientLeft + 8; + const right = bounds.left + tabs.clientLeft + tabs.clientWidth - 8; + if (selected.left < left) tabs.scrollLeft += selected.left - left; + else if (selected.right > right) tabs.scrollLeft += selected.right - right; +} + +function selectStep(step, focus = false) { + if (!STEPS.includes(step) || !currentLesson()) return; + if (state.step === 'lab' && step !== 'lab') cancelPending('단계 변경', false); + saveStorage(); + state.step = step; + syncHash('push'); + renderLesson(); + updateStorageIndicator(); + if (focus) { + document.getElementById(`tab-${step}`)?.focus({ preventScroll: true }); + dom['lesson-panel'].scrollIntoView({ block: 'nearest', behavior: 'auto' }); + } +} + +function selectLesson(id, step = 'theory', focus = false) { + const lesson = lessons.find((item) => item.id === id); + if (!lesson || lesson.minVersion > state.version) return; + cancelPending('수업 변경', false); + state.lessonId = id; + state.step = STEPS.includes(step) ? step : 'theory'; + closeMenu(false); + saveStorage(); + syncHash('push'); + renderAll(); + if (focus) { + dom['lesson-title'].focus({ preventScroll: true }); + dom['lesson-card'].scrollIntoView({ block: 'start', behavior: 'auto' }); + } +} + +function applyFilters() { + ensureSelection(); + saveStorage(); + syncHash(); + renderAll(); + announce(`${visibleLessons().length}개 수업을 표시합니다. 현재 조건에서 ${visibleLessons().filter(lessonComplete).length}개 완료했습니다.`); +} + +function setBackgroundInert(value) { + for (const node of [document.getElementById('site-header'), document.getElementById('intro'), dom['lesson-card'], dom['reference-column'], document.querySelector('.workspace-heading'), document.querySelector('.site-footer')]) { + if (node) node.inert = value; + } +} + +function openMenu() { + if (!mobileMedia.matches || dom['workspace-grid'].hidden) return; + state.menuOpen = true; + dom['curriculum-sidebar'].classList.add('is-open'); + dom['curriculum-sidebar'].setAttribute('role', 'dialog'); + dom['curriculum-sidebar'].setAttribute('aria-modal', 'true'); + dom['curriculum-sidebar'].removeAttribute('aria-hidden'); + dom['drawer-backdrop'].hidden = false; + dom['mobile-menu-toggle'].setAttribute('aria-expanded', 'true'); + document.body.classList.add('menu-open'); + setBackgroundInert(true); + document.getElementById('mobile-menu-close').focus(); +} + +function closeMenu(restoreFocus = true) { + const wasOpen = state.menuOpen; + state.menuOpen = false; + dom['curriculum-sidebar'].classList.remove('is-open'); + dom['curriculum-sidebar'].removeAttribute('role'); + dom['curriculum-sidebar'].removeAttribute('aria-modal'); + if (mobileMedia.matches) dom['curriculum-sidebar'].setAttribute('aria-hidden', 'true'); + else dom['curriculum-sidebar'].removeAttribute('aria-hidden'); + dom['drawer-backdrop'].hidden = true; + dom['mobile-menu-toggle'].setAttribute('aria-expanded', 'false'); + document.body.classList.remove('menu-open'); + setBackgroundInert(false); + if (wasOpen && restoreFocus && mobileMedia.matches) dom['mobile-menu-toggle'].focus(); +} + +function openDialog(id) { + closeMenu(false); + const dialog = document.getElementById(id); + if (dialog && !dialog.open) dialog.showModal(); +} + +function resetProgress() { + cancelPending('학습 기록 초기화', false); + for (const key of ['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'activeFiles', 'notes', 'quizAnswers', 'quizResults', 'labResults', 'undo']) state[key].clear(); + saveStorage(); + document.getElementById('reset-dialog').close(); + renderAll(); + dom['lesson-title'].focus({ preventScroll: true }); + toast(`${isDesktop ? '이 PC 앱' : '이 브라우저'}의 진도, 작성 코드와 설계 메모를 초기화했습니다.`); +} + +function bindEvents() { + document.getElementById('desktop-settings-open').hidden = !isDesktop; + document.getElementById('desktop-install-open').hidden = isDesktop; + document.getElementById('desktop-settings-open').addEventListener('click', openDesktopSettings); + document.getElementById('desktop-install-open').addEventListener('click', () => openDialog('install-dialog')); + document.getElementById('reset-records-description').textContent = `${isDesktop ? '이 PC 앱' : '이 브라우저'}의 진도, 파일별 작성 코드와 설계 메모를 모두 지웁니다. 현재 학습 기준 버전은 유지됩니다.`; + dom['version-select'].addEventListener('change', (event) => { + const version = Number(event.target.value); + if (!VERSIONS.includes(version)) return; + cancelPending('학습 기준 버전 변경', false); + state.version = version; + applyFilters(); + }); + dom['lesson-search'].addEventListener('input', (event) => { state.search = event.target.value; applyFilters(); }); + document.querySelectorAll('[data-track]').forEach((button) => button.addEventListener('click', () => { + state.track = button.dataset.track; + applyFilters(); + })); + document.querySelectorAll('[data-step]').forEach((tab) => { + tab.addEventListener('click', () => selectStep(tab.dataset.step)); + tab.addEventListener('keydown', (event) => { + const index = STEPS.indexOf(tab.dataset.step); + let nextIndex; + if (event.key === 'ArrowRight') nextIndex = (index + 1) % STEPS.length; + else if (event.key === 'ArrowLeft') nextIndex = (index + STEPS.length - 1) % STEPS.length; + else if (event.key === 'Home') nextIndex = 0; + else if (event.key === 'End') nextIndex = STEPS.length - 1; + else return; + event.preventDefault(); + selectStep(STEPS[nextIndex]); + document.getElementById(`tab-${STEPS[nextIndex]}`).focus({ preventScroll: true }); + }); + }); + if (typeof ResizeObserver === 'function') { + const tabObserver = new ResizeObserver(() => revealStepTab(state.step)); + tabObserver.observe(document.querySelector('.lesson-tabs')); + } else { + window.addEventListener('resize', () => revealStepTab(state.step)); + } + document.getElementById('continue-button').addEventListener('click', () => { + const lesson = continuationLesson(); + if (!lesson) return; + state.track = 'all'; + state.search = ''; + selectLesson(lesson.id, state.quizPassed.has(lesson.id) && !labComplete(lesson) ? 'lab' : 'theory', true); + }); + dom['mobile-menu-toggle'].addEventListener('click', () => state.menuOpen ? closeMenu() : openMenu()); + document.getElementById('mobile-menu-close').addEventListener('click', () => closeMenu()); + dom['drawer-backdrop'].addEventListener('click', () => closeMenu()); + mobileMedia.addEventListener('change', () => closeMenu(false)); + document.addEventListener('keydown', (event) => { + if (!state.menuOpen) return; + if (event.key === 'Escape') { event.preventDefault(); closeMenu(); return; } + if (event.key !== 'Tab') return; + const focusable = [...dom['curriculum-sidebar'].querySelectorAll('button, a[href], input, select, textarea, [tabindex="0"]')].filter((node) => !node.disabled && node.getClientRects().length); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }); + for (const id of ['guide-open', 'footer-guide-open']) document.getElementById(id).addEventListener('click', () => openDialog('guide-dialog')); + document.getElementById('reset-progress-open').addEventListener('click', () => openDialog('reset-dialog')); + document.getElementById('confirm-reset-progress').addEventListener('click', resetProgress); + document.querySelectorAll('[data-close-dialog]').forEach((button) => button.addEventListener('click', () => document.getElementById(button.dataset.closeDialog).close())); + window.addEventListener('hashchange', () => { + cancelPending('링크 변경', false); + readHash(); + ensureSelection(); + saveStorage(); + syncHash(); + renderAll(); + }); + window.addEventListener('beforeunload', () => { if (saveTimer) saveStorage(); }); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && saveTimer) saveStorage(); }); +} + +async function boot() { + try { + const [curriculumModule, exerciseModule, evidenceModule, editorModule, professionalModule, visualModule] = await Promise.all([ + import('./data/curriculum.js'), import('./data/exercises.js'), import('./data/job-evidence.js'), + import('./assets/editor.bundle.js').catch(() => null), + import('./data/professional.js'), + import('./data/lesson-visuals.js').catch(() => null), + ]); + if (!Array.isArray(professionalModule.professionalLessons) || !Array.isArray(professionalModule.professionalExercises)) throw new Error('Professional content data unavailable'); + lessons = [...curriculumModule.lessons, ...professionalModule.professionalLessons]; + exercises = [...exerciseModule.exercises, ...professionalModule.professionalExercises]; + jobEvidence = evidenceModule.jobEvidence; + editorLibrary = editorModule; + lessonVisuals = visualModule?.lessonVisuals && typeof visualModule.lessonVisuals === 'object' && !Array.isArray(visualModule.lessonVisuals) ? visualModule.lessonVisuals : {}; + if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(exercises) || !Array.isArray(jobEvidence)) throw new Error('Content data unavailable'); + restoreStorage(); + restoreEditorPreferences(); + readHash(); + ensureSelection(); + bindEvents(); + renderAll(); + dom['loading-state'].hidden = true; + dom['workspace-grid'].hidden = false; + closeMenu(false); + syncHash(); + if (state.storage === 'invalid') toast('기존 저장 기록을 읽을 수 없어 새 학습 상태로 시작합니다.'); + else if (state.storage === 'unavailable') toast('학습 기록 저장소가 제한되어 현재 화면 동안만 유지됩니다.'); + void loadRuntime(); + } catch (error) { + dom['loading-state'].classList.add('is-error'); + dom['loading-state'].textContent = '학습 콘텐츠를 불러오지 못했습니다. 페이지를 새로고침하거나 연결 상태를 확인해 주세요.'; + const retry = actionButton('다시 불러오기', 'button button-secondary', () => window.location.reload()); + dom['loading-state'].append(el('br'), retry); + console.error('Java Edu initialization failed:', error); + } +} + +void boot(); diff --git a/dist/data/curriculum.js b/dist/data/curriculum.js new file mode 100644 index 0000000..ff8b92e --- /dev/null +++ b/dist/data/curriculum.js @@ -0,0 +1,1603 @@ +// Original Korean learning content. Each example is a standalone Main.java. +export const lessons = [ + { + "id": "java-basics", + "title": "Java의 값 전달과 객체 동등성", + "description": "메서드에서 값을 바꿨는데 호출한 쪽의 값은 왜 다를까요? 변수 재할당·객체 변경·내용 비교를 예제의 출력으로 구분합니다.", + "track": "transition", + "level": "전환", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 35, + "category": "Java 사고 모델", + "goals": [ + "update(box) 뒤 값이 99가 아닌 15인 이유를 변수와 객체로 나눠 설명한다.", + "두 String의 ==와 equals 결과가 다른 이유를 설명한다.", + "null인 Integer를 계산할 때 발생하는 실패를 예상한다." + ], + "theory": [ + { + "heading": "객체도 값으로 전달된다", + "text": "예제의 main과 update에는 이름이 같은 box 변수가 각각 있습니다. 호출할 때 복사되는 것은 객체 자체가 아니라 그 객체를 가리키는 참조값입니다. 그래서 update의 box.value += 10은 두 변수가 함께 보는 기존 Box를 바꿉니다. 반면 box = new Box(99)는 update 안의 변수만 새 객체를 가리키게 합니다. 이를 값 전달이라고 합니다. int도 같은 규칙을 따라 replaceNumber 안에서 99를 대입해도 main의 number는 5입니다." + }, + { + "heading": "같은 객체와 같은 내용", + "text": "main은 내용이 java인 String을 두 번 new로 만듭니다. left == right는 ‘같은 객체인가’를 물으므로 false이고, left.equals(right)는 String의 문자 내용을 비교하므로 true입니다. Objects.equals는 한쪽이나 양쪽이 null인 경우까지 다룹니다. 직접 만든 상품 타입에서는 무엇이 같아야 같은 상품인지 먼저 정해야 합니다. equals를 바꾼다면 equals가 true인 객체는 같은 hashCode를 갖도록 맞추세요. DB 식별자로 비교할지 모든 필드로 비교할지는 업무 요구에 따라 다릅니다." + }, + { + "heading": "boxing은 null과 타입 변환을 숨길 수 있다", + "text": "boxing은 int 값을 Integer 객체 형태로 다루는 변환이고, unboxing은 Integer에서 int 값을 꺼내는 변환입니다. count가 null인데 count + 1을 계산하면 꺼낼 int 값이 없어 NullPointerException이 납니다. Integer의 ==를 숫자 내용 비교로 사용하지 말고, 먼저 null을 오류·기본값·값 없음 중 무엇으로 볼지 정하세요. 정수 나눗셈이나 작은 타입으로의 변환도 호출자가 예상할 결과에 영향을 줍니다." + } + ], + "example": { + "title": "참조 복사와 객체 변경 · Main.java", + "code": "class Main {\n static class Box {\n int value;\n Box(int value) { this.value = value; }\n }\n\n static void replaceNumber(int value) {\n value = 99;\n }\n\n static void update(Box box) {\n box.value += 10;\n box = new Box(99);\n }\n\n public static void main(String[] args) {\n int number = 5;\n Box box = new Box(5);\n replaceNumber(number);\n update(box);\n String left = new String(\"java\");\n String right = new String(\"java\");\n System.out.println(number);\n System.out.println(box.value);\n System.out.println(left == right);\n System.out.println(left.equals(right));\n }\n}", + "output": "5\n15\nfalse\ntrue", + "explanation": "출력은 5, 15, false, true입니다. replaceNumber는 자신이 받은 숫자 복사본만 99로 바꾸므로 number는 5입니다. update는 기존 Box의 필드를 15로 바꾼 뒤 자신의 box만 새 객체로 돌립니다. main의 box는 여전히 기존 객체를 봅니다. left와 right는 서로 다른 String이지만 내용은 같습니다." + }, + "quiz": [ + { + "id": "java-basics-q1", + "question": "참조형 매개변수 x에 새 객체를 대입했을 때 호출자의 변수에는 어떤 일이 일어날까요?", + "options": [ + "호출자의 변수도 새 객체를 가리킨다.", + "호출자의 변수는 기존 참조값을 유지한다.", + "기존 객체의 모든 필드가 초기화된다.", + "항상 컴파일 오류가 발생한다." + ], + "correctIndex": 1, + "explanation": "호출자의 변수와 매개변수 x는 별개입니다. x에 새 객체를 넣어도 호출자의 참조는 그대로입니다. 예제의 update에서 box = new Box(99)와 box.value += 10이 서로 다른 결과를 만드는 이유입니다." + }, + { + "id": "java-basics-q2", + "question": "Integer count = null; int next = count + 1; 의 결과로 맞는 것은?", + "options": [ + "next는 1이다.", + "next는 null이다.", + "unboxing 과정에서 NullPointerException이 발생한다.", + "모든 Java 버전에서 컴파일이 금지된다." + ], + "correctIndex": 2, + "explanation": "count + 1을 계산하려면 count에서 int 값을 꺼내야 합니다. null에는 꺼낼 값이 없어 실행 중 NullPointerException이 납니다. 컴파일 가능 여부와 실행 시 값의 유효성은 별도로 확인해야 합니다." + } + ], + "practice": { + "title": "외부 입력의 동등성 계약 정하기", + "scenario": "외부 시스템의 상품 코드 두 개가 모두 같은 문자열인데 == 비교는 false입니다. 어떤 요청은 코드가 null이고, 어떤 요청은 앞뒤 공백을 포함합니다.", + "tasks": [ + "같은 코드의 기준을 대소문자·공백·null 정책으로 명시하세요.", + "참조 비교로 생기는 결함을 재현하고 내용 비교로 수정하세요.", + "정규화가 원본 식별자의 의미를 바꿀 수 있는 사례를 하나 설명하세요." + ], + "takeaway": "같은 상품 코드의 기준을 정한 뒤 비교하세요. 공백 제거·대소문자 변환·null 대체는 편의 기능이지만, 원래 다른 식별자를 같은 것으로 만들 수도 있습니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Passing Information to a Method", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/arguments.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Objects", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/Objects.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Integer", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/Integer.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "java-basics-lab", + "learningGuide": { + "scenario": "main에서 Box(5)를 update에 넘깁니다. 메서드 마지막에는 Box(99)를 대입했는데, 호출 뒤 출력은 15입니다.", + "question": "같은 box에 쓰는 두 줄인데, 왜 한 줄의 변경만 호출한 쪽에 남을까요?", + "terms": [ + { + "term": "참조값", + "meaning": "어떤 객체를 가리키는 값. box 변수와 Box 객체는 구별합니다." + }, + { + "term": "값 전달", + "meaning": "호출한 쪽의 변수값을 매개변수에 복사하는 규칙. 참조값도 복사됩니다." + }, + { + "term": "재할당과 객체 변경", + "meaning": "box = ...는 변수의 참조값을 바꾸고, box.value = ...는 참조가 가리키는 객체의 필드를 바꿉니다." + } + ], + "walkthrough": [ + "main의 box가 가리키는 Box의 value는 5입니다. update의 매개변수 box도 같은 객체를 가리키며 시작합니다.", + "box.value += 10은 그 기존 객체의 value를 15로 바꿉니다.", + "box = new Box(99)는 update 안의 box만 새 객체로 돌립니다. main의 box에 대입한 것이 아닙니다.", + "update가 끝나면 main은 기존 Box를 계속 가리키므로 box.value에서 15를 읽습니다." + ], + "prediction": { + "question": "update(box) 호출 뒤 main의 box.value는 5, 15, 99 중 무엇일까요?", + "answer": "15입니다.", + "explanation": "기존 객체의 필드 변경은 남지만, 매개변수 재할당은 호출자의 변수를 바꾸지 않습니다. 두 대입의 대상이 다릅니다." + }, + "takeaway": "메서드가 객체를 바꾼 것인지, 자신의 변수가 가리키는 대상을 바꾼 것인지부터 확인하면 변경이 사라진 것처럼 보이는 버그를 설명할 수 있습니다.", + "code": { + "file": "examples/java8/java-basics/Main.java", + "source": " static void update(Box box) {\n box.value += 10;\n box = new Box(99);\n }" + } + } + }, + { + "id": "oop", + "title": "OOP: 책임과 불변식을 객체에 두기", + "description": "잔액 100에서 40을 쓰고 70을 다시 요청합니다. 거절 뒤에도 잔액이 60으로 남도록 상태 변경 규칙을 객체 안에 모읍니다.", + "track": "both", + "level": "심화", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 50, + "category": "객체 설계", + "goals": [ + "‘잔액은 항상 0 이상’처럼 객체가 지켜야 할 상태 규칙을 정한다.", + "생성 시 검사와 사용 중 검사를 구분하고, 실패 후 상태가 그대로인지 확인한다.", + "필드·컬렉션을 외부에 노출했을 때 규칙을 우회할 수 있는 경로를 찾는다." + ], + "theory": [ + { + "heading": "캡슐화는 private 필드보다 넓다", + "text": "Credit의 balance는 private이지만, 이것만으로 잔액 규칙이 완성되지는 않습니다. 아무 숫자나 넣는 setBalance가 공개되어 있다면 음수도 들어갈 수 있습니다. 예제는 생성자가 음수 초기값을 거절하고, 잔액 변경은 spend에서만 처리하도록 합니다. 이렇게 상태를 바꿀 수 있는 경로와 그 규칙을 함께 관리하는 것이 캡슐화입니다. balance()는 값을 알려 주고, spend는 요청을 검사한 뒤 상태를 바꿉니다." + }, + { + "heading": "불변식과 실패의 원자성", + "text": "불변식은 객체가 유효한 상태에서 항상 지켜야 하는 규칙입니다. Credit에서는 balance가 0 이상이어야 합니다. spend(40)는 100을 60으로 바꾸지만, 이어진 spend(70)는 차감 전에 false를 반환해 60을 보존합니다. 실패했을 때 일부 변경만 남기지 않는 성질을 여기서는 실패의 원자성이라고 부릅니다. 먼저 차감한 뒤 부족함을 발견하는 순서라면 이 규칙을 놓치기 쉽습니다. 이 예제는 단일 객체의 단일 스레드 동작이며, 동시 호출이나 DB 트랜잭션까지 보장하지 않습니다." + }, + { + "heading": "불변 객체와 협력 경계", + "text": "final List 필드는 다른 리스트로 재할당하지 못하게 할 뿐, 그 리스트의 add까지 금지하지는 않습니다. 객체가 내부 목록을 그대로 반환하면 호출자가 내용을 바꿔 객체의 규칙을 우회할 수 있습니다. 변경 불가 뷰를 줄지, 목록을 복사할지, 목록 안의 요소도 변경 가능한지 나눠 판단하세요. ‘모든 것을 불변으로 만들기’보다 어떤 객체가 변경을 담당하는지 분명히 하는 것이 목표입니다. 서비스·저장소·도메인 객체의 역할도 같은 기준으로 검토합니다." + } + ], + "example": { + "title": "잔액 규칙을 지키는 객체 · Main.java", + "code": "class Main {\n static final class Credit {\n private int balance;\n\n Credit(int initialBalance) {\n if (initialBalance < 0) {\n throw new IllegalArgumentException(\"negative balance\");\n }\n balance = initialBalance;\n }\n\n boolean spend(int amount) {\n if (amount <= 0 || amount > balance) {\n return false;\n }\n balance -= amount;\n return true;\n }\n\n int balance() { return balance; }\n }\n\n public static void main(String[] args) {\n Credit credit = new Credit(100);\n System.out.println(credit.spend(40));\n System.out.println(credit.spend(70));\n System.out.println(credit.balance());\n }\n}", + "output": "true\nfalse\n60", + "explanation": "생성자가 잔액 100을 받아 시작합니다. spend(40)는 검사를 통과해 60을 저장하고 true를 반환합니다. spend(70)는 amount > balance에서 false를 반환하므로 차감 줄에 도달하지 않습니다. 마지막 balance()는 그대로 60입니다. 잔액을 직접 넣는 setter 대신 ‘사용 요청’ 메서드가 검증과 변경을 함께 담당합니다." + }, + "quiz": [ + { + "id": "oop-q1", + "question": "재고 객체의 필드는 private이지만 setStock(int)가 모든 값을 허용합니다. 가장 정확한 평가는?", + "options": [ + "private이므로 불변식이 자동 보장된다.", + "생성자만 검증하면 이후 상태는 안전하다.", + "음수를 넣을 수 있어 공개 변경 경로에서 규칙이 깨질 수 있다.", + "setter 이름을 바꾸면 문제가 해결된다." + ], + "correctIndex": 2, + "explanation": "private은 직접 접근을 제한하지만 setStock(-1)이 허용되면 공개 메서드로 음수 상태를 만들 수 있습니다. 생성자와 이후 변경 메서드 모두에서 ‘재고는 0 이상’이라는 규칙을 지켜야 합니다." + }, + { + "id": "oop-q2", + "question": "final List names에 대한 설명으로 맞는 것은?", + "options": [ + "names의 참조 재할당은 금지되지만 리스트 구현이 허용하면 요소 추가는 가능하다.", + "리스트와 모든 요소가 깊게 불변이 된다.", + "어떤 경우에도 names.add를 호출할 수 없다.", + "다른 스레드와 공유해도 모든 복합 연산이 원자적이다." + ], + "correctIndex": 0, + "explanation": "final이 막는 것은 names에 다른 리스트를 대입하는 일입니다. 현재 리스트가 ArrayList처럼 변경을 허용하면 add는 가능합니다. 요소까지 불변인지, 여러 작업을 묶어 안전하게 처리하는지는 별도 문제입니다." + } + ], + "practice": { + "title": "예약 도메인의 상태 변경 경로 줄이기", + "scenario": "예약 실패가 반환됐는데 재고는 이미 줄어 있습니다. 여러 서비스가 재고 값을 직접 설정해 어떤 경로에서 잘못된 상태가 생겼는지 찾기 어렵습니다.", + "tasks": [ + "생성·예약·취소 각각의 사전조건과 사후조건을 적으세요.", + "호출자가 규칙을 우회하는 setter와 가변 컬렉션 노출을 찾으세요.", + "실패한 예약 뒤 상태가 유지되는 테스트를 먼저 제시하세요." + ], + "takeaway": "상태를 바꾸는 코드를 한곳으로 모으고, 성공 뒤 값뿐 아니라 거절 뒤 보존할 값도 테스트하세요. 상태 규칙의 책임이 분명해져야 호출자도 실패를 다룰 수 있습니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Classes", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/classes.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JLS 8 — Types, Values, and Variables", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-4.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JLS 8 — Classes", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "Spring Boot" + ], + "exerciseId": "oop-lab", + "learningGuide": { + "scenario": "잔액 100인 Credit에 40 사용을 요청한 뒤 70 사용을 요청합니다. 두 번째 요청이 실패해도 첫 번째 결과인 잔액 60은 유지해야 합니다.", + "question": "spend는 어느 줄까지 실행해야 실패 뒤에도 잔액을 지킬 수 있을까요?", + "terms": [ + { + "term": "캡슐화", + "meaning": "필드를 숨기는 것과 함께, 상태를 바꿀 수 있는 경로와 규칙을 객체 안에서 관리하는 것." + }, + { + "term": "불변식", + "meaning": "객체가 항상 지켜야 하는 상태 규칙. 여기서는 balance가 0 이상이어야 합니다." + }, + { + "term": "실패의 원자성", + "meaning": "작업이 실패했을 때 일부 변경만 남지 않도록 하는 것. 여기서는 거절 시 기존 잔액을 유지합니다." + } + ], + "walkthrough": [ + "첫 spend(40) 호출은 amount가 양수이고 balance 100 이하이므로 if를 통과합니다.", + "balance -= amount에서 잔액을 60으로 저장하고 true를 반환합니다.", + "다음 spend(70) 호출은 70 > 60이므로 if 안에서 false를 반환합니다.", + "거절 경로는 balance -= amount에 도달하지 않습니다. 그래서 실패 후에도 잔액 60을 읽을 수 있습니다." + ], + "prediction": { + "question": "두 번의 spend가 끝난 뒤 성공 여부와 잔액은 무엇일까요?", + "answer": "true, false, 잔액 60입니다.", + "explanation": "검사가 변경보다 먼저입니다. 두 번째 요청은 검증 단계에서 끝나므로 70을 차감한 흔적이 남지 않습니다." + }, + "takeaway": "예약·차감·취소 메서드는 실패 메시지만 확인하지 말고, 거절 뒤 어떤 필드가 그대로 남아야 하는지도 확인하세요.", + "code": { + "file": "examples/java8/oop/Main.java", + "source": " boolean spend(int amount) {\n if (amount <= 0 || amount > balance) {\n return false;\n }\n balance -= amount;\n return true;\n }" + } + } + }, + { + "id": "polymorphism", + "title": "OOP: 다형성, 계약과 합성", + "description": "Report의 render는 그대로 두고 출력 형식만 바꿉니다. 다른 구현을 끼워 넣어도 호출자가 기대하는 입력·결과를 지키는지 살펴봅니다.", + "track": "both", + "level": "심화", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 55, + "category": "객체 설계", + "goals": [ + "Report가 구체적인 Formatter 종류를 검사하지 않아도 동작하는 이유를 설명한다.", + "새 구현이 기존에 허용하던 입력과 결과 약속을 깨뜨리는지 확인한다.", + "상속·합성·인터페이스 추가의 이득과 읽기 비용을 실제 변경 요청으로 비교한다." + ], + "theory": [ + { + "heading": "교체 가능성에는 행동 계약이 필요하다", + "text": "Report는 Formatter의 format을 호출합니다. 구현을 바꿔도 같은 메서드 이름으로 요청할 수 있는 것이 다형성입니다. 하지만 타입이 같다고 업무 행동까지 같아지는 것은 아닙니다. 예를 들어 0 이상 금액을 받던 정책을 10000 이상만 받는 정책으로 바꾸면 기존 호출이 실패합니다. 입력 조건, 반환 결과, 예외, 외부 상태 변경까지 호출자가 기대할 수 있는 약속을 행동 계약이라고 부릅니다. LSP는 이런 교체가 기존 호출을 깨뜨리지 않는지 검토하는 원칙입니다." + }, + { + "heading": "상속과 합성은 변경 이유로 선택한다", + "text": "Report는 Formatter를 상속하지 않고 필드에 보관해 일을 맡깁니다. 필요한 객체를 조합하는 이 방식이 합성입니다. 출력 형식이 늘어도 Report의 render는 그대로 두고 Formatter를 바꿀 수 있습니다. 상속은 상위 클래스의 구현과 확장 규칙을 함께 받아들이므로 변경 영향이 다릅니다. 반대로 교체할 필요가 없는 작은 기능마다 인터페이스를 만들면 찾아갈 파일만 늘 수 있습니다. SOLID는 인터페이스 개수를 늘리는 규칙이 아니라 변경 영향과 책임을 검토하는 관점으로 사용하세요." + }, + { + "heading": "수동 의존성 주입에서 실행 원리 이해하기", + "text": "main이 ‘어떤 Formatter를 쓸지’를 선택하고 생성자로 Report에 넘깁니다. Report는 ‘받은 Formatter를 사용하기’만 합니다. 사용할 객체를 외부에서 전달하는 것을 의존성 주입이라고 합니다. 테스트에서는 같은 계약을 지키는 단순한 구현을 넣어 외부 환경을 통제할 수 있습니다. 예제는 사람이 main에서 조립하는 순수 Java 코드입니다. Spring의 컨테이너·프록시·AOP를 실행한 것이 아니며, 후속 과정에서는 누가 조립하고 어떤 경로로 호출하는지 비교합니다." + } + ], + "example": { + "title": "출력 정책을 조합하는 객체 · Main.java", + "code": "class Main {\n interface Formatter {\n String format(String value);\n }\n\n static final class Report {\n private final Formatter formatter;\n\n Report(Formatter formatter) {\n this.formatter = java.util.Objects.requireNonNull(formatter);\n }\n\n String render(String value) {\n return formatter.format(value);\n }\n }\n\n public static void main(String[] args) {\n Report bracketed = new Report(value -> \"[\" + value + \"]\");\n Report prefixed = new Report(value -> \"report:\" + value);\n System.out.println(bracketed.render(\"stock\"));\n System.out.println(prefixed.render(\"stock\"));\n }\n}", + "output": "[stock]\nreport:stock", + "explanation": "bracketed와 prefixed는 둘 다 Report입니다. 생성자에 전달한 람다만 달라 첫 객체는 [stock], 두 번째 객체는 report:stock을 만듭니다. render는 formatter.format(value) 한 줄로 위임하며 포맷터 종류를 검사하지 않습니다. requireNonNull은 협력 객체 자체가 빠진 구성을 생성 시 거절합니다. 람다는 Java 8에서 지원합니다." + }, + "quiz": [ + { + "id": "polymorphism-q1", + "question": "정상 소계 0 이상을 받던 정책의 새 구현이 10000 이상만 허용합니다. 우선 검토할 문제는?", + "options": [ + "클래스 이름의 길이", + "기존 호출자가 허용받던 입력을 거절하는 계약 위반", + "람다를 사용하지 않았다는 점", + "인터페이스 파일의 개수" + ], + "correctIndex": 1, + "explanation": "기존 호출자는 0부터 9999까지도 유효한 입력이라고 믿고 사용했습니다. 새 구현이 이 범위를 거절하면 인터페이스가 같아도 교체 때문에 기존 기능이 깨집니다. 이를 입력 조건을 더 강하게 요구한 문제로 설명할 수 있습니다." + }, + { + "id": "polymorphism-q2", + "question": "정책 합성에 대한 가장 적절한 설명은?", + "options": [ + "모든 상속은 잘못이므로 반드시 제거한다.", + "인터페이스가 많을수록 언제나 좋은 설계다.", + "어떤 작은 메서드도 별도 전략 클래스로 분리해야 한다.", + "실제 교체·테스트 요구와 추가 추상화 비용을 비교해 선택한다." + ], + "correctIndex": 3, + "explanation": "출력 형식을 실제로 교체해야 하는 Report에는 합성이 도움이 됩니다. 그러나 교체 요구가 없는 한 줄짜리 계산까지 분리하면 이해해야 할 파일이 늘어납니다. 변경 이유와 테스트 요구가 그 비용을 정당화하는지 판단합니다." + } + ], + "practice": { + "title": "할인 정책 변경 요청 대응", + "scenario": "결제 모듈의 정액 할인에 비율 할인과 기간 한정 할인이 추가됩니다. 할인 종류마다 결제 코드를 바꾸다 보니 음수 할인이나 결제액보다 큰 할인을 놓칩니다.", + "tasks": [ + "정책이 반환할 할인액의 허용 범위와 실패 조건을 정하세요.", + "정책 종류를 검사하는 조건문 없이 교체 가능한 협력을 만드세요.", + "새 정책의 계약 위반을 잡는 테스트와 추상화 비용을 설명하세요." + ], + "takeaway": "정책을 교체하기 전에 허용 입력·할인액 범위·실패 조건을 공통으로 정하세요. 같은 타입으로 호출할 수 있다는 사실과 기존 기능을 깨뜨리지 않는다는 사실은 다릅니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Interfaces", + "url": "https://docs.oracle.com/javase/tutorial/java/IandI/createinterface.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JDK 8 Tutorial — Polymorphism", + "url": "https://docs.oracle.com/javase/tutorial/java/IandI/polymorphism.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JDK 8 Tutorial — Lambda Expressions", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "Spring Boot" + ], + "exerciseId": "polymorphism-lab", + "learningGuide": { + "scenario": "같은 stock 내용을 화면에서는 [stock], 로그에서는 report:stock으로 표시하려고 합니다. Report의 본문 처리까지 두 벌로 복사하고 싶지는 않습니다.", + "question": "Report는 Formatter의 구체적인 종류를 모르는데 어떻게 서로 다른 결과를 만들까요?", + "terms": [ + { + "term": "다형성", + "meaning": "같은 타입과 메서드로 요청하되, 연결된 구현에 따라 다른 동작을 수행하는 것." + }, + { + "term": "합성", + "meaning": "상속으로 기능을 가져오는 대신 필요한 객체를 필드에 두고 일을 맡기는 방식." + }, + { + "term": "의존성 주입", + "meaning": "사용할 협력 객체를 내부에서 고르지 않고 생성자 같은 외부 경로로 받는 것." + }, + { + "term": "행동 계약", + "meaning": "호출자가 기대해도 되는 입력 조건·결과·실패·상태 변경의 약속." + } + ], + "walkthrough": [ + "Report 생성자는 전달받은 formatter를 필드에 저장합니다. requireNonNull은 null인 협력 객체를 거절합니다.", + "전체 예제의 main은 대괄호를 붙이는 람다와 report:를 붙이는 람다를 서로 다른 Report에 넣습니다.", + "render(\"stock\")는 현재 Report가 가진 formatter의 format에 stock을 전달합니다.", + "같은 render 코드라도 연결된 람다가 다르므로 [stock]과 report:stock이 각각 반환됩니다." + ], + "prediction": { + "question": "두 Report에서 render 구현이 같다면 출력도 반드시 같을까요?", + "answer": "아닙니다. 각각 [stock]과 report:stock을 출력합니다.", + "explanation": "render가 요청하는 메서드 계약은 같지만 실제 처리를 맡은 Formatter가 다릅니다. 선택은 main에서, 사용은 Report에서 이루어집니다." + }, + "takeaway": "할인·알림·포맷 정책을 바꿀 때 호출 코드의 조건문을 늘리기 전에, 교체할 부분을 협력 객체로 넘길 수 있는지 확인하세요.", + "code": { + "file": "examples/java8/polymorphism/Main.java", + "source": " static final class Report {\n private final Formatter formatter;\n\n Report(Formatter formatter) {\n this.formatter = java.util.Objects.requireNonNull(formatter);\n }\n\n String render(String value) {\n return formatter.format(value);\n }\n }" + } + } + }, + { + "id": "collections", + "title": "컬렉션: 순서, 동등성과 변경 계약", + "description": "원본 목록의 첫 값을 바꿨는데 복사본에는 이전 값이 남습니다. 순서·중복·해시와 함께 뷰와 복사본의 차이를 코드로 확인합니다.", + "track": "both", + "level": "기본", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 40, + "category": "타입과 데이터", + "goals": [ + "counts가 java를 두 번 세는 과정을 키 조회와 값 갱신으로 설명한다.", + "같은 키의 기준인 equals와 hashCode가 어긋나면 조회가 깨질 수 있음을 설명한다.", + "unmodifiableList로 감싸는 것과 그 전에 ArrayList로 복사하는 것을 구분한다." + ], + "theory": [ + { + "heading": "인터페이스와 구현이 약속하는 것", + "text": "예제의 tags에는 java가 두 번 들어 있습니다. List는 위치가 있고 중복도 허용하므로 이 입력을 그대로 담습니다. counts는 태그를 키, 개수를 값으로 연결하는 Map이며, Map은 Collection의 하위 인터페이스가 아닙니다. 중복 없는 값 자체가 필요하면 Set을 검토합니다. 순회 순서도 별도로 정해야 합니다. HashMap의 순서를 믿지 말고, 처음 등장한 순서를 유지해야 하는 중복 제거라면 LinkedHashSet처럼 그 순서를 약속하는 구현을 선택하세요." + }, + { + "heading": "동등성과 해시의 계약", + "text": "counts.get(\"java\")가 기존 항목을 찾으려면 키의 ‘같음’이 일관되어야 합니다. 해시 기반 컬렉션에서는 equals로 같은 객체가 같은 hashCode를 반환해야 합니다. 반대로 해시값이 같아도 다른 객체일 수 있습니다. 키를 저장한 뒤 equals나 hashCode에 쓰는 필드를 바꾸면 같은 객체를 갖고도 기대한 조회가 깨질 수 있습니다. 그래서 상품 코드처럼 비교 기준이 안정적인 값을 키로 쓰는지 확인합니다." + }, + { + "heading": "변경 불가 뷰는 스냅샷이 아니다", + "text": "unmodifiableList는 원본을 복사하는 기능이 아닙니다. 같은 목록을 읽되 그 뷰를 통한 변경을 막습니다. 예제는 먼저 new ArrayList(tags)로 별도 목록을 만든 다음 감싸므로 tags.set(0, \"other\")가 snapshot에는 반영되지 않습니다. 이를 얕은 복사라고 부릅니다. 목록은 별개지만 각 요소 객체까지 새로 만드는 것은 아닙니다. 또 tags를 만든 Arrays.asList는 크기가 고정되어 add·remove와 set의 허용 범위가 일반 ArrayList와 다릅니다." + } + ], + "example": { + "title": "집계와 컨테이너 복사 · Main.java", + "code": "class Main {\n public static void main(String[] args) {\n java.util.List tags =\n java.util.Arrays.asList(\"java\", \"sql\", \"java\");\n java.util.Map counts =\n new java.util.LinkedHashMap();\n for (String tag : tags) {\n counts.put(tag, counts.getOrDefault(tag, 0) + 1);\n }\n java.util.List snapshot =\n java.util.Collections.unmodifiableList(\n new java.util.ArrayList(tags));\n tags.set(0, \"other\");\n System.out.println(counts.get(\"java\"));\n System.out.println(snapshot.get(0));\n System.out.println(tags.get(0));\n }\n}", + "output": "2\njava\nother", + "explanation": "반복문은 java를 처음 만나면 0+1, 다시 만나면 1+1을 counts에 저장하므로 첫 출력은 2입니다. snapshot을 만들 때 목록을 먼저 복사했기 때문에 원본 tags의 첫 항목을 other로 교체해도 snapshot은 java를 유지합니다. String 자체는 불변입니다. 요소가 가변 객체라면 별도 목록 안에서도 같은 요소 객체를 공유한다는 점을 추가로 확인해야 합니다." + }, + "quiz": [ + { + "id": "collections-q1", + "question": "equals가 true인 두 객체의 hashCode에 대한 필수 조건은?", + "options": [ + "반드시 달라야 한다.", + "반드시 같아야 한다.", + "매 호출마다 바뀌어야 한다.", + "HashMap에서는 hashCode가 사용되지 않는다." + ], + "correctIndex": 1, + "explanation": "해시로 조회 위치를 좁힌 뒤 같음을 판정하는 컬렉션이 일관되게 동작하려면 equals가 true인 키의 해시도 같아야 합니다. 다만 서로 다른 키가 같은 해시를 갖는 충돌은 허용됩니다." + }, + { + "id": "collections-q2", + "question": "원본 ArrayList를 그대로 unmodifiableList로 감싼 뒤 원본에 항목을 추가하면?", + "options": [ + "뷰는 독립 복사본이므로 바뀌지 않는다.", + "뷰를 만든 순간 원본도 수정할 수 없다.", + "원본의 변경이 뷰에서 보일 수 있다.", + "모든 요소가 깊게 복사된다." + ], + "correctIndex": 2, + "explanation": "뷰는 별도 목록을 만들지 않고 원본을 읽습니다. 원본에 추가하면 뷰에서도 보입니다. 예제의 snapshot이 다른 이유는 unmodifiableList 전에 new ArrayList로 원본과 별도의 목록을 만들었기 때문입니다." + } + ], + "practice": { + "title": "중복 이벤트 식별자의 보관 방식 선택", + "scenario": "배치의 이벤트 코드 목록에 중복이 있습니다. 최초 등장 순서는 유지해야 하고, 결과를 받은 호출자가 목록을 수정해도 배치가 보관한 원본은 바뀌지 않아야 합니다.", + "tasks": [ + "중복 판정 기준과 null 허용 여부를 정하세요.", + "요구 순서를 보장하는 컬렉션을 선택하세요.", + "반환 결과 변경이 원본 데이터에 영향을 주지 않는 테스트를 작성하세요." + ], + "takeaway": "‘Set을 쓸까 List를 쓸까’보다 중복·순서·변경 허용 요구부터 적으세요. 복사한 것이 목록인지 요소까지인지 확인해야 반환값을 안전하게 공유할 수 있습니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Collections", + "url": "https://docs.oracle.com/javase/tutorial/collections/index.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Object.equals/hashCode", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Collections", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/Collections.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "collections-lab", + "learningGuide": { + "scenario": "tags의 java를 센 뒤 현재 목록을 보관하려고 합니다. 이후 원본 첫 값을 other로 바꿔도 snapshot에는 java가 남아야 합니다.", + "question": "unmodifiableList만으로 충분할까요? 그 안의 new ArrayList(tags)는 무엇을 지킬까요?", + "terms": [ + { + "term": "변경 불가 뷰", + "meaning": "원본을 읽되, 이 뷰를 통한 추가·삭제·교체를 막는 객체. 원본을 자동 복사하지 않습니다." + }, + { + "term": "얕은 복사", + "meaning": "목록 자체는 별도로 만들지만 요소 객체의 참조는 공유하는 복사." + }, + { + "term": "키의 동등성", + "meaning": "Map이 어떤 두 키를 같은 항목으로 취급할지 정하는 기준. 해시 기반 구현에서는 equals와 hashCode가 함께 맞아야 합니다." + } + ], + "walkthrough": [ + "counts는 tags의 java, sql, java를 차례로 읽습니다. getOrDefault는 아직 없는 키의 개수를 0으로 보고, put은 증가한 개수를 저장합니다.", + "new ArrayList(tags)가 현재 요소들을 담는 별도 목록을 만듭니다.", + "unmodifiableList는 그 복사된 목록을 감쌉니다. snapshot을 통해 항목을 교체하지 못하게 합니다.", + "tags.set(0, \"other\")는 원본 목록의 첫 자리만 바꿉니다. counts의 집계 결과와 snapshot의 첫 자리는 다시 계산되거나 함께 교체되지 않습니다." + ], + "prediction": { + "question": "마지막 세 출력은 무엇일까요?", + "answer": "2, java, other입니다.", + "explanation": "집계는 변경 전에 끝나 java의 개수가 2입니다. snapshot은 별도 목록을 읽으므로 java를 유지하고, 원본 tags만 other로 바뀝니다." + }, + "takeaway": "조회 결과를 다른 코드에 넘길 때 ‘수정하지 못하게 함’과 ‘나중 원본 변경을 보지 않게 함’을 따로 결정하세요.", + "code": { + "file": "examples/java8/collections/Main.java", + "source": " java.util.List tags =\n java.util.Arrays.asList(\"java\", \"sql\", \"java\");\n java.util.Map counts =\n new java.util.LinkedHashMap();\n for (String tag : tags) {\n counts.put(tag, counts.getOrDefault(tag, 0) + 1);\n }\n java.util.List snapshot =\n java.util.Collections.unmodifiableList(\n new java.util.ArrayList(tags));\n tags.set(0, \"other\");\n System.out.println(counts.get(\"java\"));\n System.out.println(snapshot.get(0));\n System.out.println(tags.get(0));" + } + } + }, + { + "id": "generics", + "title": "제네릭: 불공변성, 경계와 타입 소거", + "description": "정수 목록과 소수 목록을 같은 sum으로 읽습니다. 받을 수 있는 목록을 넓히면서 잘못된 값 추가는 막는 타입 규칙을 이해합니다.", + "track": "both", + "level": "심화", + "minVersion": 8, + "featureSince": 5, + "preview": false, + "duration": 45, + "category": "타입과 데이터", + "goals": [ + "sum이 Integer 목록과 Double 목록을 모두 읽을 수 있는 이유를 설명한다.", + "List를 List로 다루면 어떤 잘못된 추가가 가능해지는지 설명한다.", + "extends·super·타입 소거를 읽기·쓰기·실행 시 검사와 연결한다." + ], + "theory": [ + { + "heading": "하위 타입 관계가 그대로 전달되지 않는다", + "text": "Integer는 Number의 하위 타입이지만 List를 List 변수에 넣을 수는 없습니다. 허용한다면 그 변수를 통해 Double도 추가할 수 있어 ‘정수만 들어 있다’는 원래 목록의 약속이 깨집니다. 요소 타입의 상하 관계가 목록 타입으로 그대로 이어지지 않는 성질을 불공변성이라고 합니다. 배열은 다른 규칙으로 대입을 허용하고 잘못된 저장을 실행 중 검사하므로, 배열에서 되던 대입을 제네릭 목록에도 그대로 기대하면 안 됩니다." + }, + { + "heading": "생산자는 extends, 소비자는 super", + "text": "sum은 목록에 값을 넣지 않고 각 값을 Number로 읽어 doubleValue를 호출합니다. 그래서 List가 맞습니다. 물음표는 정확한 요소 타입은 모르지만 Number 또는 그 하위 타입이라는 뜻입니다. 실제로 Double 목록일 수 있으므로 non-null Integer를 임의로 추가할 수는 없습니다. 반대로 List에는 Integer를 추가할 수 있지만 읽은 값의 일반적 정적 타입은 Object입니다. 이는 타입 안전성에 관한 규칙이며, 변경 불가 목록의 add 성공까지 보장하지 않습니다. 읽고 쓰는 역할이 모두 필요하면 두 타입 사이의 관계부터 정하세요. extends 자체가 null 추가나 clear·remove를 타입 수준에서 금지하는 것은 아니므로 ‘불변 목록’으로 이해하지 마세요." + }, + { + "heading": "타입 소거가 만드는 제한", + "text": "타입 소거는 컴파일할 때 제네릭의 많은 타입 정보를 상한 타입 등으로 바꾸는 처리입니다. 따라서 실행 중에 T가 정확히 무엇인지 안다고 가정해 new T()를 쓰거나 instanceof List으로 요소 타입을 검사할 수는 없습니다. 타입 인자를 생략한 raw type과 확인되지 않은 캐스팅을 섞으면 잘못된 요소가 들어와도 경고만 남고, 나중에 값을 꺼낼 때 실패할 수 있습니다. 경고를 숨기기 전에 API의 타입 관계를 고치세요. 제네릭은 Java 5부터이며 이 예제는 Java 8 대상으로 작성했습니다." + } + ], + "example": { + "title": "읽기 역할에 맞춘 상한 경계 · Main.java", + "code": "class Main {\n static double sum(java.util.List values) {\n double total = 0;\n for (Number value : values) {\n total += value.doubleValue();\n }\n return total;\n }\n\n public static void main(String[] args) {\n System.out.println(sum(java.util.Arrays.asList(1, 2, 3)));\n System.out.println(sum(java.util.Arrays.asList(0.5, 1.5)));\n }\n}", + "output": "6.0\n2.0", + "explanation": "sum은 첫 호출에서 1, 2, 3을 Number로 읽고 각각 double 값으로 더해 6.0을 반환합니다. 다음 호출도 같은 코드를 사용해 0.5와 1.5를 더한 2.0을 반환합니다. 값을 추가하지 않고 읽기만 하므로 실제 목록이 Integer용인지 Double용인지 구분할 필요가 없습니다." + }, + "quiz": [ + { + "id": "generics-q1", + "question": "List를 List에 바로 대입할 수 없는 이유는?", + "options": [ + "Number에 하위 타입이 없기 때문이다.", + "Integer가 참조형이 아니기 때문이다.", + "허용하면 Number 경로로 Double을 넣어 Integer 리스트의 계약을 깨뜨릴 수 있기 때문이다.", + "Java 8에는 제네릭이 없기 때문이다." + ], + "correctIndex": 2, + "explanation": "List에는 Double도 넣을 수 있습니다. List를 그 타입으로 대입할 수 있다면 정수 전용 목록에 소수가 들어가는 문제가 생깁니다. 컴파일러가 이 경로를 막는 것이 제네릭 불공변성의 이유입니다." + }, + { + "id": "generics-q2", + "question": "List target에 안전하게 할 수 있는 작업은?", + "options": [ + "Integer 값 추가", + "읽은 모든 값을 캐스팅 없이 Integer에 대입", + "어떤 Double 값이든 추가", + "target을 List으로 간주" + ], + "correctIndex": 0, + "explanation": "실제 목록의 요소 타입은 Integer 또는 그 상위 타입이므로 Integer 값은 타입상 안전하게 추가할 수 있습니다. 하지만 Number나 Object 목록에는 Integer가 아닌 값도 있을 수 있어, 읽은 값을 곧바로 Integer라고 할 수는 없습니다. add의 실제 지원 여부는 목록 구현의 계약을 따릅니다." + } + ], + "practice": { + "title": "공통 복사 API의 타입 계약", + "scenario": "Integer 목록을 Number 목록 뒤에 붙이는 코드와 String 목록을 Object 목록 뒤에 붙이는 코드가 반복됩니다. 타입 검사를 없애지 않고 공통 메서드로 만들려 합니다.", + "tasks": [ + "source와 target의 읽기·쓰기 역할을 표시하세요.", + "raw type 없이 공통 copy 메서드의 타입 매개변수를 정하세요.", + "target의 기존 항목과 source가 보존되는지 검사하세요." + ], + "takeaway": "복사 메서드에서는 source가 값을 제공하고 target이 값을 받습니다. 이 방향을 정한 뒤 타입 매개변수와 경계를 쓰면, 유연하게 받으면서 잘못된 요소 추가를 컴파일 단계에서 막을 수 있습니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Upper Bounded Wildcards", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/upperBounded.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JDK 8 Tutorial — Lower Bounded Wildcards", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/lowerBounded.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JLS 8 — Type Erasure", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-4.html#jls-4.6", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "generics-lab", + "learningGuide": { + "scenario": "정수 1·2·3과 소수 0.5·1.5를 각각 더하고 싶습니다. 숫자 종류마다 sum을 다시 만들 필요는 없어 보입니다.", + "question": "sum이 List 대신 List를 받는 이유는 무엇일까요?", + "terms": [ + { + "term": "불공변성", + "meaning": "Integer가 Number의 하위 타입이어도 List를 List로 바로 대입할 수 없는 규칙." + }, + { + "term": "상한 경계 extends", + "meaning": "정확한 요소 타입은 몰라도 Number 또는 그 하위 타입임을 보장해 Number로 읽게 하는 조건." + }, + { + "term": "타입 소거", + "meaning": "컴파일 과정에서 많은 제네릭 타입 정보가 상한 타입 등으로 바뀌어 실행 시 구별에 한계가 생기는 것." + } + ], + "walkthrough": [ + "sum의 values는 정확히 어떤 숫자 타입의 목록인지는 몰라도 각 요소를 Number로 읽을 수 있습니다.", + "total은 0에서 시작합니다. for문의 value가 받은 각 숫자에 doubleValue를 호출합니다.", + "정수 목록에서는 1.0, 2.0, 3.0을 누적해 6.0을 반환합니다.", + "소수 목록도 같은 코드를 지나 0.5와 1.5의 합인 2.0을 반환합니다. values에 새 요소를 추가하는 줄은 없습니다." + ], + "prediction": { + "question": "같은 sum에 두 예제 목록을 전달하면 어느 호출이 컴파일되고 어떤 결과를 낼까요?", + "answer": "둘 다 컴파일되며 6.0과 2.0을 반환합니다.", + "explanation": "Integer와 Double 모두 Number로 읽을 수 있고 doubleValue를 제공합니다. sum은 읽기에 필요한 공통 기능만 요구합니다." + }, + "takeaway": "공통 API를 만들 때 모든 목록을 같은 구체 타입으로 바꾸려 하지 말고, 메서드가 값을 읽는지 쓰는지부터 확인하세요.", + "code": { + "file": "examples/java8/generics/Main.java", + "source": " static double sum(java.util.List values) {\n double total = 0;\n for (Number value : values) {\n total += value.doubleValue();\n }\n return total;\n }" + } + } + }, + { + "id": "exceptions", + "title": "예외: 실패 책임과 자원 소유권", + "description": "숫자 대신 many가 들어온 경우를 따라가며, 호출자에게 실패를 알리고 원래 예외와 자원 종료 정보를 남기는 방법을 익힙니다.", + "track": "both", + "level": "기본", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 40, + "category": "실패와 검증", + "goals": [ + "readQuantity의 입력 오류를 성공값 0으로 숨기면 생기는 문제를 설명한다.", + "새 예외의 cause에서 원래 파싱 오류를 찾는다.", + "return이나 예외로 빠져나가도 reader를 닫는 경로를 설명한다." + ], + "theory": [ + { + "heading": "checked 여부와 복구 가능성은 같은 말이 아니다", + "text": "readQuantity(\"many\")는 숫자를 읽지 못한 실패입니다. 이를 catch해서 0을 반환하면 호출자는 ‘입력값이 0’과 ‘읽기에 실패함’을 구별할 수 없습니다. checked 예외는 catch로 처리하거나 throws로 알리도록 컴파일러가 요구하는 예외입니다. 그렇다고 그 자리에서 복구할 수 있다는 뜻은 아닙니다. 예제의 IOException은 밖으로 전달하고 입력 형식 오류는 IllegalArgumentException으로 알립니다. 입력을 다시 받을지 작업을 중단할지는 판단할 정보가 있는 호출자가 결정해야 합니다." + }, + { + "heading": "예외를 바꾸더라도 원인을 잃지 않는다", + "text": "parseInt가 던진 NumberFormatException을 호출자에게 의미 있는 invalid quantity 오류로 바꾸되 원인까지 지우지는 않습니다. 예제는 새 IllegalArgumentException의 두 번째 인자로 cause를 넘깁니다. 그러면 호출자는 ‘수량 입력에 실패했다’는 맥락과 ‘숫자로 해석하지 못했다’는 원인을 함께 확인할 수 있습니다. 로그에는 진단에 필요한 안전한 정보를 남기고 사용자 응답에는 필요한 안내를 줍니다. 민감한 입력 원문 전체를 예외 메시지에 붙일 필요는 없습니다." + }, + { + "heading": "자원 해제와 suppressed exception", + "text": "readQuantity가 만든 reader는 try-with-resources의 괄호 안에 선언되어 있습니다. 본문이 값을 반환하거나 예외로 끝나도 닫기 처리가 이루어집니다. 여러 자원은 선언한 순서의 반대로 닫힙니다. 본문과 close가 모두 실패하면 본문 예외를 주로 전달하고 닫기 오류는 suppressed 예외로 붙여 진단할 수 있게 합니다. 이는 새로운 예외의 cause와 다른 정보입니다. 밖에서 전달받은 자원이라면 이 메서드가 닫아도 되는지 소유 책임을 먼저 정하세요." + } + ], + "example": { + "title": "읽기 자원의 수명 명시 · Main.java", + "code": "class Main {\n static int readQuantity(String text) throws java.io.IOException {\n try (java.io.BufferedReader reader =\n new java.io.BufferedReader(new java.io.StringReader(text))) {\n String line = reader.readLine();\n if (line == null) {\n throw new IllegalArgumentException(\"quantity is required\");\n }\n try {\n return Integer.parseInt(line);\n } catch (NumberFormatException cause) {\n throw new IllegalArgumentException(\"invalid quantity\", cause);\n }\n }\n }\n\n public static void main(String[] args) throws java.io.IOException {\n System.out.println(readQuantity(\"12\"));\n try {\n readQuantity(\"many\");\n } catch (IllegalArgumentException failure) {\n System.out.println(failure.getCause().getClass().getSimpleName());\n }\n }\n}", + "output": "12\nNumberFormatException", + "explanation": "readQuantity(\"12\")는 12를 반환하고 reader를 닫습니다. \"many\"는 parseInt에서 NumberFormatException을 만들며, 이를 cause로 담은 IllegalArgumentException이 밖으로 나옵니다. main은 getCause에서 원래 예외 이름을 읽어 NumberFormatException을 출력합니다. 실패 경로에서도 reader 닫기는 try-with-resources가 담당합니다." + }, + "quiz": [ + { + "id": "exceptions-q1", + "question": "예외를 새 IllegalArgumentException으로 변환할 때 원인 보존에 적합한 방법은?", + "options": [ + "항상 return 0으로 끝낸다.", + "catch 블록을 비워 둔다.", + "원본 예외의 메시지를 지운다.", + "새 예외 생성자의 cause 인자로 원본 예외를 전달한다." + ], + "correctIndex": 3, + "explanation": "새 IllegalArgumentException에 cause를 넘기면 바깥 예외는 업무 맥락을, 원인 예외는 실제 실패 이유를 전달합니다. 예제의 main이 getCause로 NumberFormatException을 확인할 수 있는 이유입니다." + }, + { + "id": "exceptions-q2", + "question": "try-with-resources 본문과 close가 모두 예외를 던졌다면 일반적인 처리 규칙은?", + "options": [ + "close의 예외만 남고 본문 예외는 사라진다.", + "본문 예외가 주 예외가 되고 close 실패가 suppressed로 보존될 수 있다.", + "두 예외 모두 자동으로 무시된다.", + "항상 프로그램이 컴파일되지 않는다." + ], + "correctIndex": 1, + "explanation": "본문에서 발생한 실패를 주 예외로 전달하고 close에서 난 실패는 suppressed 정보로 보존합니다. 하나의 실패가 다른 실패를 무조건 지워 버리는 것이 아닙니다. 호출자는 getSuppressed로 종료 중 문제도 조사할 수 있습니다." + } + ], + "practice": { + "title": "환경 설정 입력의 실패 계약", + "scenario": "설정 파일의 포트 값이 many여도 읽기 메서드가 0을 반환합니다. 서버를 시작하는 코드에서 뒤늦게 실패해 입력 오류였다는 사실을 찾기 어렵습니다.", + "tasks": [ + "입력 부재·형식 오류·범위 오류를 분류하세요.", + "호출자가 실패를 확실하게 알도록 계약을 바꾸세요.", + "파싱 실패의 cause가 남는지 검사하세요." + ], + "takeaway": "오류를 처리했다는 것은 catch를 썼다는 뜻이 아닙니다. 복구하지 못했다면 실패를 명확히 전달하고, 원인과 자원 종료 책임도 함께 남겨야 합니다." + }, + "sources": [ + { + "title": "JDK 8 Tutorial — Exceptions", + "url": "https://docs.oracle.com/javase/tutorial/essential/exceptions/index.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JDK 8 Tutorial — try-with-resources", + "url": "https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Throwable", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/Throwable.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "exceptions-lab", + "learningGuide": { + "scenario": "수량을 읽는 readQuantity는 \"12\"를 받으면 성공하지만 \"many\"를 받으면 실패합니다. 호출자에게 읽기 실패를 알리면서 원래 이유도 남기려 합니다.", + "question": "새 IllegalArgumentException을 던지는데 main은 어떻게 NumberFormatException을 확인할까요?", + "terms": [ + { + "term": "예외 전파", + "meaning": "현재 메서드가 처리하지 못한 실패를 호출한 쪽으로 전달하는 것." + }, + { + "term": "cause", + "meaning": "현재 예외가 생긴 원인 예외. 새 예외를 만들 때 연결해야 원래 이유를 따라갈 수 있습니다." + }, + { + "term": "자원 소유권", + "meaning": "누가 자원을 만들고 누가 닫을 책임을 지는지에 대한 약속." + }, + { + "term": "suppressed 예외", + "meaning": "주된 실패를 전달하면서 함께 보관하는 추가 실패. 자원을 닫는 중 난 오류 등이 여기에 남습니다." + } + ], + "walkthrough": [ + "readQuantity가 StringReader를 감싼 BufferedReader를 만들고 reader에서 첫 줄을 읽습니다.", + "line이 없으면 quantity is required를 던집니다. 값이 있으면 Integer.parseInt로 숫자 변환을 시도합니다.", + "\"many\"의 변환은 NumberFormatException을 던집니다. 안쪽 catch가 이를 cause라는 변수로 받습니다.", + "new IllegalArgumentException(\"invalid quantity\", cause)는 읽기 실패라는 맥락과 원래 예외를 연결해 밖으로 던집니다.", + "try-with-resources가 reader를 닫습니다. 전체 예제의 main은 failure.getCause()를 통해 원래 예외 종류를 출력합니다." + ], + "prediction": { + "question": "두 번째 입력 many를 처리한 뒤 main이 출력하는 원인 예외 이름은 무엇일까요?", + "answer": "NumberFormatException입니다.", + "explanation": "바깥으로 전달한 예외는 IllegalArgumentException이지만 두 번째 생성자 인자에 원인 예외를 보존했기 때문입니다." + }, + "takeaway": "실패를 업무용 예외로 바꿀 때는 호출자에게 필요한 맥락을 더하고, 진단에 필요한 원인은 유지하세요.", + "code": { + "file": "examples/java8/exceptions/Main.java", + "source": " static int readQuantity(String text) throws java.io.IOException {\n try (java.io.BufferedReader reader =\n new java.io.BufferedReader(new java.io.StringReader(text))) {\n String line = reader.readLine();\n if (line == null) {\n throw new IllegalArgumentException(\"quantity is required\");\n }\n try {\n return Integer.parseInt(line);\n } catch (NumberFormatException cause) {\n throw new IllegalArgumentException(\"invalid quantity\", cause);\n }\n }\n }" + } + } + }, + { + "id": "streams", + "title": "Stream: 지연 실행과 부수 효과 없는 집계", + "description": "java·sql·jvm·java에서 [JAVA, JVM]을 만드는 과정을 읽으며, 처리를 연결하는 시점과 실제 결과를 구하는 시점을 구별합니다.", + "track": "both", + "level": "기본", + "minVersion": 8, + "featureSince": 8, + "preview": false, + "duration": 40, + "category": "데이터 처리", + "goals": [ + "filter·distinct·map이 각각 어떤 입력을 남기거나 바꾸는지 설명한다.", + "collect가 결과 계산을 시작하고 selected에 결과를 담는 역할을 설명한다.", + "원본 names를 변경하는 일과 새 결과를 수집하는 일을 구분한다." + ], + "theory": [ + { + "heading": "Stream은 데이터를 저장하는 컬렉션이 아니다", + "text": "예제의 names는 데이터를 보관한 List이고, names.stream() 뒤의 연산은 그 데이터로 무엇을 계산할지 나타냅니다. filter는 j로 시작하는 값을 고르고, distinct는 중복을 제거하며, map은 대문자로 바꿉니다. 이런 중간 연산은 연결만으로 결과를 계산하지 않습니다. collect 같은 최종 연산이 실행될 때 계산이 시작되는 성질을 지연 실행이라고 합니다. 한번 소비한 Stream을 다시 사용하려 하지 말고 names에서 새 Stream을 만드세요." + }, + { + "heading": "외부 상태 변경은 집계와 다른 책임이다", + "text": "예제의 selected는 collect가 수집한 결과입니다. 외부의 빈 ArrayList를 만들고 forEach 안에서 add하는 방식은 콜백이 바깥 상태까지 바꾸게 합니다. 이런 외부 상태 변경을 부수 효과라고 부르며, 특히 병렬 실행에서는 누가 언제 수정하는지 맞추기 어려워집니다. 결과를 모으려면 collect, 숫자 합계라면 해당 합계 연산을 사용해 목적을 드러내세요. 예제의 콜백은 names의 내용을 수정하지 않고 각 입력으로부터 결과를 계산합니다." + }, + { + "heading": "병렬화와 API 버전은 별도 판단이다", + "text": "같은 코드를 parallelStream으로 바꾼다고 항상 빨라지지는 않습니다. 작업을 나눌 비용, 순서 유지 비용, 공유 상태와 실행 환경을 함께 봐야 합니다. Java 8의 이 예제는 collect(Collectors.toList())를 사용합니다. Java 16에서 추가된 Stream.toList()는 Java 8에 없고 반환 목록도 변경 불가입니다. Collectors.toList() 역시 구체 목록 타입이나 변경 가능성을 보장하지 않으므로, 단순히 이름이 비슷하다고 같은 계약으로 취급하지 마세요." + } + ], + "example": { + "title": "필터·변환·수집 파이프라인 · Main.java", + "code": "class Main {\n public static void main(String[] args) {\n java.util.List names =\n java.util.Arrays.asList(\"java\", \"sql\", \"jvm\", \"java\");\n java.util.List selected = names.stream()\n .filter(name -> name.startsWith(\"j\"))\n .distinct()\n .map(name -> name.toUpperCase(java.util.Locale.ROOT))\n .collect(java.util.stream.Collectors.toList());\n System.out.println(selected);\n System.out.println(names.size());\n }\n}", + "output": "[JAVA, JVM]\n4", + "explanation": "filter 뒤의 값은 java, jvm, java이고 distinct가 뒤의 java 중복을 없앱니다. map이 남은 두 값을 JAVA, JVM으로 바꾸고 collect가 [JAVA, JVM]을 selected에 담습니다. 이 설명은 값의 흐름이며 각 단계마다 임시 List를 만든다는 뜻은 아닙니다. names를 수정하지 않았으므로 두 번째 출력은 원래 크기인 4입니다." + }, + "quiz": [ + { + "id": "streams-q1", + "question": "filter만 연결한 Stream에서 실제 결과를 얻기 위해 필요한 것은?", + "options": [ + "원본 리스트를 반드시 정렬한다.", + "최종 연산으로 파이프라인을 평가한다.", + "항상 parallel로 전환한다.", + "System.gc를 호출한다." + ], + "correctIndex": 1, + "explanation": "filter는 ‘어떤 항목을 통과시킬지’를 연결한 중간 연산입니다. collect나 sum 같은 최종 연산이 있어야 결과 계산을 시작합니다. findFirst처럼 필요한 결과를 일찍 얻으면 모든 요소를 끝까지 처리하지 않는 최종 연산도 있습니다." + }, + { + "id": "streams-q2", + "question": "Java 8 대상으로 작성한 예제에 Stream.toList()를 사용하면?", + "options": [ + "Java 8 표준 API이므로 문제없다.", + "모든 반환 리스트가 ArrayList로 보장된다.", + "Java 16에서 추가된 API이므로 Java 8 대상 코드와 맞지 않는다.", + "동일 Stream을 무제한 재사용할 수 있다." + ], + "correctIndex": 2, + "explanation": "Stream.toList()는 Java 16부터 있어 Java 8 대상 예제에서는 사용할 수 없습니다. 지원 버전뿐 아니라 반환 목록의 변경 허용 여부도 확인해야 합니다. 이름이 유사해도 모든 API를 그대로 바꿔 쓸 수는 없습니다." + } + ], + "practice": { + "title": "집계 배치의 공유 누산기 없애기", + "scenario": "배치 코드가 조건을 통과한 숫자를 바깥 변수에 계속 더합니다. 병렬화하거나 조건을 바꿀 때 누락된 합계가 생겨, 필터 조건과 집계 책임을 분명히 하려 합니다.", + "tasks": [ + "필터 조건과 합계의 입력 범위를 분리해 적으세요.", + "외부 상태 없이 조건에 맞는 값을 집계하세요.", + "빈 입력·null 요소·중복 값을 처리하는 계약을 확인하세요." + ], + "takeaway": "먼저 어떤 값을 남기고 어떤 결과를 계산하는지 드러내세요. 외부 변수를 바꾸지 않는 집계로 만든 뒤, 필요한 경우 성능을 측정해 병렬화를 판단합니다." + }, + "sources": [ + { + "title": "Java SE 8 API — Stream", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — Collectors", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/stream/Collectors.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 17 API — Stream.toList (Since 16)", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/stream/Stream.html#toList()", + "edition": "Java SE 17; API Since 16", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "streams-lab", + "learningGuide": { + "scenario": "태그 java, sql, jvm, java에서 j로 시작하는 값만 남기고, 중복 없이 대문자로 표시해야 합니다. 원본 목록은 다른 곳에서도 사용합니다.", + "question": "처리 규칙은 어느 줄에서 연결되고, 최종 결과는 어느 줄에서 만들어질까요?", + "terms": [ + { + "term": "파이프라인", + "meaning": "원본에서 어떤 값을 고르고 바꾸고 모을지 연결한 연산 흐름." + }, + { + "term": "중간 연산·지연 실행", + "meaning": "filter나 map처럼 다음 연산을 연결하며, 최종 연산 전에는 소스의 결과 계산을 시작하지 않는 방식." + }, + { + "term": "최종 연산", + "meaning": "collect처럼 연결된 흐름을 실행해 결과나 효과를 얻는 연산." + }, + { + "term": "부수 효과", + "meaning": "계산 결과를 돌려주는 것 외에 원본이나 외부 변수의 상태도 바꾸는 일." + } + ], + "walkthrough": [ + "names.stream()에서 시작해 filter가 j로 시작하는 java, jvm, java를 통과시키도록 연결합니다.", + "distinct는 같은 문자열 java가 반복돼도 하나만 남기도록 하고, map은 남은 문자열을 대문자로 바꾸도록 연결합니다.", + "collect(Collectors.toList())가 최종 연산입니다. 앞서 연결한 규칙으로 값을 처리해 selected를 만듭니다.", + "selected는 [JAVA, JVM]입니다. 콜백에 names를 수정하는 코드가 없으므로 원본 크기는 4를 유지합니다." + ], + "prediction": { + "question": "selected를 만든 뒤 names.size()도 2로 줄어들까요?", + "answer": "아닙니다. selected는 [JAVA, JVM]이고 names.size()는 4입니다.", + "explanation": "이 예제는 원본의 요소를 삭제하지 않고 조건에 맞는 값으로 별도 결과를 수집합니다. 모든 Stream 사용이 자동으로 원본 변경을 막는다는 뜻은 아닙니다." + }, + "takeaway": "원본·처리 규칙·결과 목록을 따로 생각하면, 읽기 위한 변환인지 외부 상태를 바꾸는 작업인지 구별하기 쉬워집니다.", + "code": { + "file": "examples/java8/streams/Main.java", + "source": " java.util.List names =\n java.util.Arrays.asList(\"java\", \"sql\", \"jvm\", \"java\");\n java.util.List selected = names.stream()\n .filter(name -> name.startsWith(\"j\"))\n .distinct()\n .map(name -> name.toUpperCase(java.util.Locale.ROOT))\n .collect(java.util.stream.Collectors.toList());\n System.out.println(selected);\n System.out.println(names.size());" + } + } + }, + { + "id": "concurrency", + "title": "동시성: JMM, 원자성과 작업 수명", + "description": "두 작업이 각각 1000번 증가시킨 카운터가 왜 2000이어야 하는지, 증가 자체의 안전성과 완료 대기를 나눠 설명합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 8, + "featureSince": 5, + "preview": false, + "duration": 65, + "category": "동시성과 실행 모델", + "goals": [ + "count++에서 증가가 사라질 수 있는 이유와 AtomicInteger의 역할을 설명한다.", + "first.get·second.get 없이 결과를 읽을 때 생기는 문제를 구별한다.", + "작업 실패·인터럽트·실행기 종료 요청을 누가 처리하는지 정한다." + ], + "theory": [ + { + "heading": "보이는 것과 한 번에 처리되는 것은 다르다", + "text": "두 작업이 동시에 0을 읽고 각자 1을 기록하면 증가 요청은 두 번인데 결과는 1일 수 있습니다. count++가 읽기·계산·쓰기를 묶은 동작이기 때문입니다. 다른 스레드의 변경을 어떤 규칙으로 볼 수 있는지는 가시성, 한 동작이 중간에 쪼개져 섞이지 않게 보장되는지는 원자성의 문제입니다. volatile로 가시성과 순서를 다뤄도 ++ 전체가 원자적이 되지는 않습니다. 예제는 total.incrementAndGet으로 각 증가를 원자적으로 수행합니다. JMM은 이런 스레드 사이 읽기 결과와 순서 보장을 규정합니다." + }, + { + "heading": "happens-before와 안전한 공개", + "text": "증가를 안전하게 해도 아직 작업 중일 때 total을 읽으면 값은 2000보다 작을 수 있습니다. 예제는 first.get과 second.get이 모두 정상 반환한 뒤 읽습니다. 이 호출은 해당 작업의 완료를 기다리며 작업의 결과를 읽는 데 필요한 메모리 일관성 관계도 제공합니다. 이런 ‘앞선 작업의 영향이 뒤의 관찰에 반영되도록 하는 관계’를 happens-before라고 부릅니다. 시계상 먼저 실행됐다는 뜻으로만 이해하지 마세요. 락·volatile·스레드 시작과 종료·동시성 API의 명시된 보장을 확인해야 하며, sleep으로 잠시 기다리는 것은 완료 확인을 대신하지 않습니다." + }, + { + "heading": "작업과 자원의 생명주기를 설계한다", + "text": "ExecutorService는 작업을 제출하는 코드와 실행할 스레드를 관리하는 코드를 나눕니다. 두 submit은 작업을 맡기고 Future를 돌려주며, get은 대기 중 인터럽트나 작업 실패를 알릴 수 있습니다. 예제는 예외를 밖으로 전달하면서 finally에서 shutdownNow로 종료를 요청합니다. 이 요청이 모든 실행 중 작업을 강제로 끝냈다는 보장은 아닙니다. 호출자가 InterruptedException을 받아 상위로 의사를 전달할 때는 전파나 인터럽트 상태 복원 정책을 정하세요. 또 AtomicInteger 하나가 안전해도 여러 필드나 DB 행을 함께 바꾸는 업무 전체가 자동으로 원자적이 되지는 않습니다." + } + ], + "example": { + "title": "완료 후 읽는 원자 카운터 · Main.java", + "code": "class Main {\n public static void main(String[] args) throws Exception {\n java.util.concurrent.ExecutorService executor =\n java.util.concurrent.Executors.newFixedThreadPool(2);\n java.util.concurrent.atomic.AtomicInteger total =\n new java.util.concurrent.atomic.AtomicInteger();\n try {\n java.util.concurrent.Future first = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n java.util.concurrent.Future second = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n first.get();\n second.get();\n System.out.println(total.get());\n } finally {\n executor.shutdownNow();\n }\n }\n}", + "output": "2000", + "explanation": "두 작업은 각자 1000번 total.incrementAndGet을 호출합니다. 각 증가가 원자적이므로 정상 완료하면 합계는 2000입니다. first.get과 second.get으로 둘 다 끝났음을 확인한 뒤 total.get을 출력합니다. finally의 shutdownNow는 실행기 종료를 요청합니다. 이 예제는 메모리 안의 카운터이며 여러 DB 변경이나 분산 처리의 보장을 보여 주는 것은 아닙니다." + }, + "quiz": [ + { + "id": "concurrency-q1", + "question": "volatile int count에 여러 스레드가 count++를 수행합니다. 맞는 설명은?", + "options": [ + "volatile이므로 모든 증가가 원자적이다.", + "volatile은 메서드 전체를 자동으로 잠근다.", + "가시성과 별개로 증가 연산의 원자성은 보장되지 않는다.", + "항상 컴파일 오류가 발생한다." + ], + "correctIndex": 2, + "explanation": "두 스레드가 모두 0을 읽고 각자 1을 쓰는 경우를 생각해 보세요. volatile이어도 읽기·계산·쓰기 전체가 한 번에 처리되지는 않아 증가가 사라질 수 있습니다. 예제의 incrementAndGet처럼 요구에 맞는 원자 연산이나 락이 필요합니다." + }, + { + "id": "concurrency-q2", + "question": "작업 완료를 확인하지 않고 Thread.sleep을 사용한 테스트의 한계는?", + "options": [ + "sleep 자체가 모든 작업의 완료를 보장한다.", + "시간이 지났다는 관찰만으로 완료·동기화 계약을 증명할 수 없다.", + "sleep은 모든 변수를 final로 만든다.", + "sleep을 두 번 호출하면 어떤 실행 환경에서도 안전하다." + ], + "correctIndex": 1, + "explanation": "100ms가 지났다는 사실은 특정 작업이 끝났다는 신호가 아닙니다. 환경과 작업량에 따라 아직 실행 중일 수 있고, sleep 자체가 작업 결과의 전달 규칙을 만들지도 않습니다. Future.get이나 join처럼 완료·동기화 계약이 있는 방법을 사용해야 합니다." + } + ], + "practice": { + "title": "병렬 작업의 완료·실패·종료 계약", + "scenario": "병렬 배치가 2000개를 처리했어야 하는데 카운트가 간헐적으로 작습니다. 값 증가가 충돌했는지, 완료 전에 읽었는지부터 나누어 확인하고 작업 후 남는 스레드도 정리하려 합니다.", + "tasks": [ + "공유 카운터의 원자성 문제와 결과를 너무 일찍 읽는 문제를 분리하세요.", + "작업을 제출하고 모두 완료된 후 반환하며 실행기를 닫으세요.", + "인터럽트·작업 실패가 발생했을 때 호출자가 무엇을 관찰할지 설명하세요." + ], + "takeaway": "‘여러 번 실행해 맞았다’보다 어느 연산이 증가를 보호하고 어느 호출이 완료를 확인하는지 설명하세요. 실패와 종료까지 포함해야 호출자가 결과를 믿을 수 있습니다." + }, + "sources": [ + { + "title": "JLS 8 — Threads and Locks / Memory Model", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — ExecutorService", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html", + "edition": "Java SE 8; API Since 5", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — AtomicInteger", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/atomic/AtomicInteger.html", + "edition": "Java SE 8; API Since 5", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java" + ], + "exerciseId": "concurrency-lab", + "learningGuide": { + "scenario": "두 작업이 total을 각각 1000번 증가시킵니다. 합계가 2000이 되려면 증가를 잃지 않아야 하고, 두 작업이 끝나기 전에 읽어서도 안 됩니다.", + "question": "incrementAndGet과 Future.get은 서로 다른 어떤 문제를 해결할까요?", + "terms": [ + { + "term": "원자성", + "meaning": "하나로 취급해야 할 동작의 중간에 다른 작업이 끼어 결과를 깨뜨리지 않도록 하는 보장." + }, + { + "term": "가시성", + "meaning": "한 스레드의 변경을 다른 스레드가 읽을 때 어떤 결과를 볼 수 있는지에 관한 보장." + }, + { + "term": "Future", + "meaning": "제출한 작업의 완료와 결과·실패를 확인하는 객체. get은 필요하면 완료를 기다립니다." + }, + { + "term": "happens-before", + "meaning": "앞선 동작의 영향과 뒤의 관찰 사이에 Java가 보장하는 순서·메모리 일관성 관계. 실제 시각만 비교하는 말은 아닙니다." + } + ], + "walkthrough": [ + "first와 second는 각각 1000번 반복하는 작업을 executor에 제출해 얻은 Future입니다.", + "각 작업의 total.incrementAndGet은 한 번의 증가를 원자적으로 처리합니다. 두 작업이 섞여도 증가 자체를 잃지 않습니다.", + "first.get은 첫 작업을 기다립니다. 이어지는 second.get까지 정상 반환해야 두 작업의 완료를 확인한 것입니다.", + "그 뒤 total.get으로 읽으므로 정상 완료한 두 루프의 합인 2000을 출력합니다. 전체 예제의 finally는 실행기 종료를 요청합니다." + ], + "prediction": { + "question": "incrementAndGet을 사용했으니 두 get 호출을 빼도 출력이 항상 2000일까요?", + "answer": "보장할 수 없습니다.", + "explanation": "증가가 안전하다는 것과 증가 작업이 이미 끝났다는 것은 다릅니다. get을 빼면 출력 시점에도 작업이 진행 중일 수 있습니다." + }, + "takeaway": "동시성 버그를 볼 때 ‘공유 값을 어떻게 바꾸는가’와 ‘언제 결과를 읽어도 되는가’를 별도로 추적하세요.", + "code": { + "file": "examples/java8/concurrency/Main.java", + "source": " java.util.concurrent.Future first = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n java.util.concurrent.Future second = executor.submit(() -> {\n for (int index = 0; index < 1000; index++) {\n total.incrementAndGet();\n }\n });\n first.get();\n second.get();\n System.out.println(total.get());" + } + } + }, + { + "id": "modern-apis", + "title": "Java 11: 입력 처리와 표준 HTTP API", + "description": "여러 줄 입력에서 빈 줄을 빼고, HTTP 연결 제한을 설정합니다. 출력 2와 3이 각각 어떤 사실을 보여 주는지 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 11, + "featureSince": 11, + "preview": false, + "duration": 40, + "category": "버전 전환", + "goals": [ + "lines·strip·isBlank가 입력의 어느 부분을 처리하는지 설명한다.", + "지역 변수 var의 도입 버전과 이 예제의 Java 11 API 요구를 구별한다.", + "HTTP 클라이언트 설정과 실제 요청 성공을 구분한다." + ], + "theory": [ + { + "heading": "문자열 API가 바꾸는 입력 정책", + "text": "예제의 text에는 build, 빈 줄, test가 들어 있고 두 단어의 앞뒤에 공백이 있습니다. lines는 줄 단위로 읽게 하고, strip은 각 줄 앞뒤의 공백을 제거하며, isBlank는 비었거나 공백뿐인 줄을 구별합니다. 이 조합은 ‘의미 있는 줄 두 개’를 세려는 의도를 드러냅니다. strip과 isBlank의 공백 기준은 Character.isWhitespace이며 trim의 기준과 다릅니다. 눈에 공백처럼 보이는 모든 문자를 제거한다고 가정하지 말고 실제 입력 문자·인코딩·줄 구분자를 확인하세요." + }, + { + "heading": "var와 API 도입 버전을 분리한다", + "text": "API를 읽을 때는 ‘코드를 실행할 JDK 버전’과 ‘기능이 처음 생긴 버전’을 나누어 확인합니다. 지역 변수 var는 Java 10에서 추가된 컴파일 시점 타입 추론입니다. 값을 대입한 뒤 아무 타입으로나 바뀌는 동적 타입이 아닙니다. Java 11에서 추가된 것은 람다 매개변수의 var 문법입니다. 이 예제의 strip·isBlank·lines와 표준 java.net.http.HttpClient는 Java 11을 요구하므로 예제 전체 대상도 11입니다." + }, + { + "heading": "HTTP는 성공 응답 한 번으로 끝나지 않는다", + "text": "예제의 client는 연결 시간 제한을 3초로 설정한 객체입니다. send나 sendAsync를 호출하지 않았으므로 서버 응답을 받은 것은 아닙니다. 실제 호출에서는 연결을 기다리는 시간과 요청 응답을 기다리는 시간의 제한을 각각 설계해야 합니다. 응답이 도착해도 상태 코드가 업무 성공을 뜻하는지 따로 봅니다. 같은 요청을 다시 보내도 중복 차감 같은 추가 효과가 없는 성질을 멱등성이라고 합니다. 재시도 전에는 이 성질과 중복 처리 위험을 확인하세요." + } + ], + "example": { + "title": "Java 11 API와 명시적 설정 · Main.java", + "code": "class Main {\n public static void main(String[] args) {\n String text = \" build \\n\\n test \";\n long meaningfulLines = text.lines()\n .map(String::strip)\n .filter(line -> !line.isBlank())\n .count();\n java.net.http.HttpClient client =\n java.net.http.HttpClient.newBuilder()\n .connectTimeout(java.time.Duration.ofSeconds(3))\n .build();\n System.out.println(meaningfulLines);\n System.out.println(client.connectTimeout().get().getSeconds());\n }\n}", + "output": "2\n3", + "explanation": "lines는 세 줄을 읽고 strip과 filter를 거치면 build와 test만 남아 count가 2를 반환합니다. 뒤의 3은 client에 저장된 연결 시간 제한을 초 단위로 읽은 값입니다. 3초 동안 기다렸다거나 실제 요청이 성공했다는 출력이 아닙니다. 이 예제는 외부 네트워크를 호출하지 않습니다." + }, + "quiz": [ + { + "id": "modern-apis-q1", + "question": "지역 변수 타입 추론 var의 최초 도입 버전과 성격은?", + "options": [ + "Java 8의 동적 타입 기능", + "Java 11에서 처음 도입된 런타임 타입 변경 기능", + "Java 10의 컴파일 시점 지역 변수 타입 추론", + "Java 17의 제네릭 제거 기능" + ], + "correctIndex": 2, + "explanation": "지역 변수 var는 초기값 등을 보고 컴파일러가 타입을 정하는 Java 10 기능입니다. Java 11의 람다 매개변수 var와 최초 버전을 혼동하지 마세요. 선언이 짧아져도 컴파일 시 정해지는 타입이라는 성격은 같습니다." + }, + { + "id": "modern-apis-q2", + "question": "strip과 trim을 치환하기 전에 확인할 점은?", + "options": [ + "공백 판정 기준과 대상 Java 버전", + "두 API가 언제나 모든 문자에서 동일하다는 가정", + "strip이 원본 String을 제자리 변경한다는 점", + "strip을 사용하면 네트워크 오류가 사라진다는 점" + ], + "correctIndex": 0, + "explanation": "두 API의 공백 판정 기준이 달라 실제 입력에서 결과가 달라질 수 있습니다. strip은 Java 11부터이므로 배포 대상도 확인해야 합니다. 원본 String을 제자리에서 바꾸는 기능은 아닙니다." + } + ], + "practice": { + "title": "텍스트 설정을 줄 단위 값으로 바꾸기", + "scenario": "운영자가 build, 빈 줄, test처럼 항목을 여러 줄로 붙여 넣습니다. 앞뒤 공백과 빈 줄을 제거하되 중복 항목을 유지할지는 업무 규칙으로 정해야 합니다.", + "tasks": [ + "공백·줄 구분자·중복 보존 정책을 명시하세요.", + "Java 11 API로 의도가 드러나는 변환을 작성하세요.", + "Java 8 배포 대상에 이 API가 들어갔을 때 어떤 검증이 막아야 하는지 설명하세요." + ], + "takeaway": "API 이름이 간단해졌다고 입력 정책까지 같아지는 것은 아닙니다. 허용 문자와 결과를 먼저 정하고, 대상 버전에서 지원되는 API인지 확인하세요." + }, + "sources": [ + { + "title": "Java SE 11 API — String", + "url": "https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html", + "edition": "Java SE 11", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 11 API — HttpClient", + "url": "https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpClient.html", + "edition": "Java SE 11", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 11 — Local Variable Type Inference", + "url": "https://docs.oracle.com/en/java/javase/11/language/local-variable-type-inference.html", + "edition": "Java SE 11; local var Since 10", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "REST API" + ], + "exerciseId": "modern-apis-lab", + "learningGuide": { + "scenario": "붙여 넣은 텍스트에서 실제 항목 수를 세고, 이후 사용할 HTTP 클라이언트의 연결 제한도 지정합니다. 콘솔에는 2와 3이 출력됩니다.", + "question": "각 숫자는 계산한 결과일까요, 실행한 네트워크 요청의 결과일까요?", + "terms": [ + { + "term": "입력 정규화", + "meaning": "비교·처리하기 전에 공백 등 입력 표현을 정해진 규칙으로 맞추는 것. 원래 의미가 바뀌지 않는지 확인해야 합니다." + }, + { + "term": "연결 시간 제한", + "meaning": "새 연결을 만드는 데 허용할 대기 시간에 관한 설정. 응답 처리 전체의 시간 제한과 구별합니다." + }, + { + "term": "최초 도입 버전", + "meaning": "문법이나 API를 처음 사용할 수 있게 된 Java 버전. 현재 실행하는 JVM의 버전과는 다른 정보입니다." + } + ], + "walkthrough": [ + "text.lines()가 build가 있는 줄, 빈 줄, test가 있는 줄을 제공합니다.", + "map(String::strip)이 각 줄의 앞뒤 공백을 처리하고 filter가 빈 줄을 제외합니다.", + "count()가 남은 두 줄을 세어 meaningfulLines에 2를 저장합니다.", + "HttpClient의 builder는 3초 연결 제한을 저장해 client를 만듭니다. 여기까지 요청 전송은 없습니다.", + "마지막 출력은 meaningfulLines의 2와 설정값을 초로 읽은 3입니다." + ], + "prediction": { + "question": "출력 3을 보고 서버가 3초 안에 응답했다고 판단할 수 있을까요?", + "answer": "아닙니다. 연결 시간 제한 설정값만 읽었습니다.", + "explanation": "코드에는 요청을 보내는 send나 sendAsync가 없습니다. 설정 객체를 생성한 사실과 실제 통신 결과는 다릅니다." + }, + "takeaway": "새 API를 읽을 때 입력 변환·설정 생성·실제 실행을 구별하면, 코드가 보장하는 것보다 큰 효과를 기대하지 않게 됩니다.", + "code": { + "file": "examples/java11/modern-apis/Main.java", + "source": " String text = \" build \\n\\n test \";\n long meaningfulLines = text.lines()\n .map(String::strip)\n .filter(line -> !line.isBlank())\n .count();\n java.net.http.HttpClient client =\n java.net.http.HttpClient.newBuilder()\n .connectTimeout(java.time.Duration.ofSeconds(3))\n .build();\n System.out.println(meaningfulLines);\n System.out.println(client.connectTimeout().get().getSeconds());" + } + } + }, + { + "id": "records-sealed", + "title": "Java 17: record와 sealed 결과 모델", + "description": "계산 결과를 숫자 하나나 true/false 대신 성공 값과 실패 이유로 돌려줍니다. record의 값 비교와 sealed의 종류 제한을 실제 반환값에서 확인합니다.", + "track": "advanced", + "level": "심화", + "minVersion": 17, + "featureSince": 17, + "preview": false, + "duration": 45, + "category": "버전 전환", + "goals": [ + "doublePositive가 성공 값과 실패 이유를 서로 다른 타입으로 반환하는 과정을 설명한다.", + "record가 생성하는 접근자·동등성·문자열 표현을 예제와 연결한다.", + "record·sealed의 정식 버전과 Java 17에서 사용할 수 없는 정식 패턴 문법을 구분한다." + ], + "theory": [ + { + "heading": "record는 투명한 데이터 운반자", + "text": "Success(6)은 value가 6인 결과 값입니다. record는 이 구성 요소에 대한 private final 필드, value() 같은 접근자, 생성자와 equals·hashCode·toString을 제공합니다. 예제의 두 Success(6)은 별개 객체여도 같은 record 타입과 구성 요소 값을 가져 equals가 true입니다. record를 쓰면 데이터의 모양이 선언에 드러납니다. 다만 구성 요소가 가변 List라면 참조를 final로 보관해도 목록 내용은 바뀔 수 있습니다. 필요한 입력 검사는 compact constructor에서, 공유를 막을 필요는 방어적 복사로 다룹니다. record의 정식 도입은 Java 16입니다." + }, + { + "heading": "sealed는 확장 가능한 종류를 제한한다", + "text": "Result는 Success 또는 Failure만 직접 구현할 수 있게 선언했습니다. 이렇게 허용할 직접 하위 타입을 정하는 기능이 sealed입니다. 이 예제는 성공이면 숫자, 실패면 이유를 주므로 호출자가 처리할 결과 종류를 읽기 쉽습니다. 하위 타입은 final·sealed·non-sealed 등의 확장 규칙을 따라야 하며, 외부에서 자유롭게 결과 종류를 추가해야 하는 API라면 제한 비용도 있습니다. sealed는 Java 17부터 정식이므로 record와 함께 쓰는 이 예제의 최소 대상은 17입니다." + }, + { + "heading": "모델의 종류와 처리 문법은 별개다", + "text": "Java 17에서 결과 종류를 record와 sealed로 선언할 수 있다는 것과, 그 결과를 어떤 switch 문법으로 처리할 수 있다는 것은 별개입니다. 이 예제는 preview 문법을 쓰지 않습니다. switch 표현식은 14, text block은 15, instanceof 패턴은 16에서 정식 도입되었습니다. record 구성 요소를 패턴으로 꺼내거나 타입 패턴을 switch에 쓰는 정식 기능은 Java 21 수업에서 다룹니다. 새 문법을 사용할 때는 클래스 선언과 처리 문법의 버전을 각각 확인하세요." + } + ], + "example": { + "title": "닫힌 계산 결과와 record 동등성 · Main.java", + "code": "class Main {\n sealed interface Result permits Success, Failure {}\n record Success(int value) implements Result {}\n record Failure(String reason) implements Result {}\n\n static Result doublePositive(int value) {\n if (value <= 0) {\n return new Failure(\"positive-required\");\n }\n return new Success(Math.multiplyExact(value, 2));\n }\n\n public static void main(String[] args) {\n System.out.println(doublePositive(3).equals(new Success(6)));\n System.out.println(doublePositive(0));\n }\n}", + "output": "true\nFailure[reason=positive-required]", + "explanation": "doublePositive(3)는 Success(6)을 반환합니다. 새로 만든 다른 Success(6)과도 equals가 true여서 첫 출력은 true입니다. doublePositive(0)는 계산 전에 Failure(\"positive-required\")를 반환하고 record의 문자열 표현을 출력합니다. 이 예제는 모든 실패를 Failure로 바꾸지는 않습니다. 양수라도 두 배가 int 범위를 넘으면 Math.multiplyExact가 ArithmeticException을 던집니다." + }, + "quiz": [ + { + "id": "records-sealed-q1", + "question": "record에 가변 ArrayList를 구성 요소로 넣었을 때 맞는 설명은?", + "options": [ + "record가 리스트와 요소를 자동으로 깊게 복사한다.", + "record이므로 외부에서 리스트를 절대 바꿀 수 없다.", + "구성 요소 참조의 final과 참조 대상의 깊은 불변성은 별개다.", + "record의 equals는 항상 참조 동일성만 비교한다." + ], + "correctIndex": 2, + "explanation": "record가 고정하는 것은 구성 요소 필드의 참조입니다. 그 참조가 가리키는 ArrayList가 다른 경로에서 바뀌는 것까지 막지는 않습니다. 목록 복사 여부와 요소의 변경 가능성은 별도로 정해야 합니다." + }, + { + "id": "records-sealed-q2", + "question": "정식 도입 버전의 조합으로 맞는 것은?", + "options": [ + "record 17, sealed 21", + "record 16, sealed 17", + "record 8, sealed 11", + "record 11, sealed 16" + ], + "correctIndex": 1, + "explanation": "record는 Java 16에서, sealed는 Java 17에서 정식 도입되었습니다. 하나의 예제에서 함께 쓰므로 더 높은 요구인 Java 17을 대상으로 잡습니다. 예제 대상 버전이 각 기능의 최초 버전과 항상 같지는 않습니다." + } + ], + "practice": { + "title": "성공과 거절 이유를 타입으로 표현", + "scenario": "예약 결과가 false로만 돌아와 수량이 잘못됐는지 재고가 부족한지 알 수 없습니다. 호출자가 실패 이유에 따라 다른 안내를 할 수 있는 반환 모델이 필요합니다.", + "tasks": [ + "성공 결과와 실패 결과의 정보를 정하세요.", + "record와 sealed로 결과 종류를 모델링하세요.", + "새 실패 종류를 추가했을 때 호출 코드가 검토해야 할 지점을 설명하세요." + ], + "takeaway": "record를 골라 선언을 줄이기 전에 성공 때 줄 정보와 실패 때 줄 정보를 정하세요. sealed로 종류를 제한하면 새 종류 추가 시 어떤 호출부를 함께 검토해야 할지도 드러납니다." + }, + "sources": [ + { + "title": "Java SE 17 — Record Classes", + "url": "https://docs.oracle.com/en/java/javase/17/language/records.html", + "edition": "Java SE 17; records final Since 16", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 17 — Sealed Classes", + "url": "https://docs.oracle.com/en/java/javase/17/language/sealed-classes-and-interfaces.html", + "edition": "Java SE 17; sealed final Since 17", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 21 — Language Changes", + "url": "https://docs.oracle.com/en/java/javase/21/language/java-language-changes.html", + "edition": "Java SE 21", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "Java 17" + ], + "exerciseId": "records-sealed-lab", + "learningGuide": { + "scenario": "양수의 두 배를 계산하되, 0 이하 입력은 값 대신 거절 이유를 반환하려 합니다. 호출자는 성공과 실패를 구별할 수 있어야 합니다.", + "question": "Result 한 타입으로 받으면서도 성공 값과 실패 이유를 어떻게 나눠 표현할까요?", + "terms": [ + { + "term": "record", + "meaning": "구성 요소를 선언하면 그 값을 담는 필드·접근자·동등성 등의 기본 동작을 제공하는 클래스." + }, + { + "term": "sealed", + "meaning": "직접 하위 타입으로 허용할 종류를 선언에서 제한하는 기능." + }, + { + "term": "값 동등성", + "meaning": "같은 인스턴스인지가 아니라, 타입이 정한 값 기준으로 같은지 비교하는 것. record는 구성 요소를 사용합니다." + } + ], + "walkthrough": [ + "Result는 직접 구현할 타입을 Success와 Failure로 제한합니다. Success는 value를, Failure는 reason을 보관합니다.", + "doublePositive(3)은 value <= 0을 통과하지 않고 Math.multiplyExact로 6을 계산해 Success로 반환합니다.", + "전체 예제의 main은 그 결과를 새 Success(6)과 비교합니다. 같은 record 타입과 값이므로 true입니다.", + "doublePositive(0)은 첫 조건에서 Failure를 반환합니다. reason은 positive-required이며 아래의 곱셈 줄에는 도달하지 않습니다." + ], + "prediction": { + "question": "doublePositive(0)은 숫자 0이나 Success(0)을 돌려줄까요?", + "answer": "Failure[reason=positive-required]에 해당하는 Failure 객체를 반환합니다.", + "explanation": "메서드의 입력 정책이 0 이하를 거절하도록 정해져 있습니다. record와 sealed는 그 정책의 결과를 표현하며, 정책 자체를 자동으로 정해 주지는 않습니다." + }, + "takeaway": "반환값이 false 하나라서 호출자가 후속 행동을 결정하지 못한다면, 성공 데이터와 실패 이유를 드러내는 결과 타입을 검토하세요.", + "code": { + "file": "examples/java17/records-sealed/Main.java", + "source": " sealed interface Result permits Success, Failure {}\n record Success(int value) implements Result {}\n record Failure(String reason) implements Result {}\n\n static Result doublePositive(int value) {\n if (value <= 0) {\n return new Failure(\"positive-required\");\n }\n return new Success(Math.multiplyExact(value, 2));\n }" + } + } + }, + { + "id": "java21", + "title": "Java 21: 패턴 분기와 가상 스레드", + "description": "Job의 종류와 attempts 값을 함께 보고 이름을 붙입니다. 이 분기 코드와 가상 스레드 실행기의 역할을 나누어 읽습니다.", + "track": "advanced", + "level": "심화", + "minVersion": 21, + "featureSince": 21, + "preview": false, + "duration": 50, + "category": "버전 전환", + "goals": [ + "Job 패턴에서 name·attempts를 꺼내고 when 조건으로 분기를 고르는 과정을 설명한다.", + "0회·null·다른 타입의 입력이 어느 분기에 도달하는지 예측한다.", + "가상 스레드가 바꾸는 실행 방식과 바꾸지 않는 CPU·외부 자원 한계를 구분한다." + ], + "theory": [ + { + "heading": "타입과 상태를 함께 드러내는 패턴", + "text": "label은 입력이 Job이고 attempts가 양수이면 name:attempts를 만듭니다. case Job(String name, int attempts)가 구성 요소를 꺼내는 record pattern이고, when attempts > 0은 추가로 만족해야 할 가드 조건입니다. 나머지 Job은 다음 case로, null은 case null로 갑니다. 앞의 넓은 패턴이 뒤의 구체 패턴을 모두 포함해 버리면 그 분기는 가려질 수 있습니다. 모든 입력을 처리할 분기가 있는지도 확인해야 합니다. 이 record pattern과 pattern switch는 Java 21의 정식 기능입니다." + }, + { + "heading": "가상 스레드는 더 빠른 CPU 스레드가 아니다", + "text": "main은 label 계산을 newVirtualThreadPerTaskExecutor에 제출합니다. 가상 스레드는 JVM이 관리하며, 많은 작업이 I/O를 기다리는 상황을 감당하는 데 도움이 될 수 있습니다. 하지만 이 예제의 문자열 계산 한 건이 더 빨라졌음을 뜻하지 않습니다. CPU 계산량은 그대로이고 DB 연결 10개만 허용되는 서비스가 가상 스레드를 많이 만든다고 연결도 늘어나지는 않습니다. 동시 요청 수·시간 제한·작업 종료를 외부 자원 한계에 맞춰 따로 관리하세요." + }, + { + "heading": "대상 버전의 실행 특성으로 판단한다", + "text": "Java 21에서는 가상 스레드가 synchronized 블록·메서드 안에서 블로킹할 때 이를 실행하는 플랫폼 스레드인 carrier를 놓지 못하는 pinning을 고려해야 합니다. 빈번하고 오래 걸리는 경우 확장성을 떨어뜨릴 수 있으므로 실제 대기와 관측 결과를 봐야 합니다. 이후 JDK의 개선을 Java 21에도 있다고 가정하지 마세요. 이 수업은 preview 기능 없이 패턴 분기와 가상 스레드 사용을 보여 줍니다. 성능 개선 여부는 기존 테스트·지표·부하 비교로 별도 확인해야 합니다." + } + ], + "example": { + "title": "패턴 분기 작업을 실행하고 기다리기 · Main.java", + "code": "class Main {\n record Job(String name, int attempts) {}\n\n static String label(Object value) {\n return switch (value) {\n case Job(String name, int attempts) when attempts > 0 ->\n name + \":\" + attempts;\n case Job job -> \"not-started\";\n case null -> \"missing\";\n default -> \"unknown\";\n };\n }\n\n public static void main(String[] args) throws Exception {\n try (java.util.concurrent.ExecutorService executor =\n java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {\n java.util.concurrent.Future result =\n executor.submit(() -> label(new Job(\"sync\", 2)));\n System.out.println(result.get());\n System.out.println(label(null));\n }\n }\n}", + "output": "sync:2\nmissing", + "explanation": "제출한 작업은 label에 Job(\"sync\", 2)를 넘깁니다. Job 패턴에 맞고 attempts > 0도 참이므로 sync:2를 반환합니다. main은 result.get으로 작업 결과를 기다린 다음 출력합니다. label(null)은 명시한 case null에서 missing을 돌려줍니다. try-with-resources는 실행기를 닫는 시점을 정하며, 이 짧은 실행은 성능 비교 실험이 아닙니다." + }, + "quiz": [ + { + "id": "java21-q1", + "question": "가상 스레드에 대한 설명으로 가장 적절한 것은?", + "options": [ + "CPU 연산은 플랫폼 스레드보다 항상 빠르다.", + "I/O 대기가 많은 동시 작업에 도움이 될 수 있지만 외부 자원의 한계는 별도로 관리해야 한다.", + "가상 스레드를 쓰면 DB 연결 풀의 제한이 사라진다.", + "동기화와 취소 처리를 모두 생략할 수 있다." + ], + "correctIndex": 1, + "explanation": "가상 스레드는 대기하는 작업을 많이 두는 비용을 줄이는 데 도움이 될 수 있습니다. CPU 계산을 자동으로 빠르게 하거나 DB 연결 풀·외부 API 허용량을 늘리는 기능은 아닙니다. 그 자원들의 동시 사용 제한은 따로 필요합니다." + }, + { + "id": "java21-q2", + "question": "case Order(String id, int quantity) when quantity > 0 의 의미는?", + "options": [ + "모든 Object가 조건 없이 일치한다.", + "Order의 모든 필드를 변경한다.", + "Order 패턴에 맞고 quantity > 0도 만족해야 일치한다.", + "Java 8에서 같은 문법을 사용할 수 있다." + ], + "correctIndex": 2, + "explanation": "입력이 Order 패턴에 맞아 구성 요소를 꺼낼 수 있고, 꺼낸 quantity가 0보다 커야 이 case를 선택합니다. 같은 Order라도 quantity가 조건에 맞지 않으면 이 가드가 붙은 분기에 일치하지 않습니다." + } + ], + "practice": { + "title": "버전 상향의 효과와 위험 설명", + "scenario": "Java 17 서비스에서 타입별 if 분기가 길어졌습니다. 한편 외부 호출을 기다리는 작업도 많아, 분기 문법을 바꾸는 효과와 실행 방식을 바꾸는 효과를 각각 확인하려 합니다.", + "tasks": [ + "패턴 분기에서 null·알 수 없는 타입·경계 상태를 테스트하세요.", + "가상 스레드 적용 전후 비교에 필요한 대기·CPU·외부 자원 지표를 정하세요.", + "외부 연결 수 제한과 취소·종료 정책을 설명하세요." + ], + "takeaway": "분기 코드가 읽기 쉬워진 것과 서비스 처리량이 좋아진 것은 다른 결과입니다. 입력 반례로 문법 변경을 검증하고 실제 부하·외부 자원 지표로 실행 모델 변경을 검증하세요." + }, + "sources": [ + { + "title": "Java SE 21 — Pattern Matching for switch", + "url": "https://docs.oracle.com/en/java/javase/21/language/pattern-matching-switch.html", + "edition": "Java SE 21", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 21 — Record Patterns", + "url": "https://docs.oracle.com/en/java/javase/21/language/record-patterns.html", + "edition": "Java SE 21", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 21 — Virtual Threads", + "url": "https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html", + "edition": "Java SE 21", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "Java 21" + ], + "exerciseId": "java21-lab", + "learningGuide": { + "scenario": "작업 Job에는 name과 attempts가 있습니다. 시도 횟수가 양수이면 sync:2처럼 표시하고, 아직 시도하지 않은 Job이나 값이 없는 경우는 다른 이름을 붙입니다.", + "question": "Job이라는 타입 확인만으로 첫 번째 case를 선택할 수 있을까요?", + "terms": [ + { + "term": "record pattern", + "meaning": "record 타입이 맞는지 보면서 구성 요소를 변수로 꺼내는 패턴." + }, + { + "term": "가드 when", + "meaning": "타입 패턴에 맞은 뒤에도 추가로 참이어야 하는 조건." + }, + { + "term": "분기의 포괄성", + "meaning": "가능한 입력을 처리할 분기가 빠지지 않았는지 보는 성질." + }, + { + "term": "가상 스레드", + "meaning": "JVM이 관리하는 스레드. 분기 결과의 의미를 바꾸지 않으며 CPU 계산을 자동 가속하지 않습니다." + } + ], + "walkthrough": [ + "label에 Job(\"sync\", 2)가 들어오면 첫 패턴에서 name은 sync, attempts는 2가 됩니다.", + "when attempts > 0이 참이므로 첫 분기가 sync:2를 반환합니다.", + "Job이지만 가드가 거짓이면 다음 case Job job에서 not-started를 반환합니다.", + "입력이 null이면 case null에서 missing을, 나머지 타입이면 default에서 unknown을 반환합니다. 각 case의 범위와 순서가 결과를 정합니다." + ], + "prediction": { + "question": "Job의 attempts가 0인 경우와 입력이 null인 경우에 각각 무엇을 반환할까요?", + "answer": "각각 not-started와 missing입니다.", + "explanation": "0은 양수 가드를 통과하지 못하지만 Job 타입에는 맞습니다. null은 일반 Job 분기가 아니라 명시한 case null에서 처리합니다." + }, + "takeaway": "타입을 확인한 뒤 필드를 다시 꺼내는 코드를 읽을 때, 패턴·추가 조건·null 처리를 한곳에 모으면 어떤 입력이 어느 분기로 가는지 확인하기 쉬워집니다.", + "code": { + "file": "examples/java21/java21/Main.java", + "source": " static String label(Object value) {\n return switch (value) {\n case Job(String name, int attempts) when attempts > 0 ->\n name + \":\" + attempts;\n case Job job -> \"not-started\";\n case null -> \"missing\";\n default -> \"unknown\";\n };\n }" + } + } + }, + { + "id": "testing-design", + "title": "테스트 설계: 계약과 경계 결함 구별", + "description": "무료 배송 조건의 >=를 >로 잘못 바꿨을 때 어떤 검사가 실패해야 할까요? 실제 경계값과 기대값으로 테스트의 역할을 확인합니다.", + "track": "both", + "level": "심화", + "minVersion": 8, + "featureSince": null, + "preview": false, + "duration": 45, + "category": "실패와 검증", + "goals": [ + "29999·30000·30001의 기대 배송비를 요구사항에서 먼저 정한다.", + "음수 입력이 정상 반환하면 테스트 자체가 실패하도록 만드는 이유를 설명한다.", + "테스트가 찾을 수 있는 결함과 다루지 않은 범위를 구분한다." + ], + "theory": [ + { + "heading": "테스트는 구현을 복사하지 않는다", + "text": "요구사항이 ‘30000 이상은 무료 배송’이라면 29999는 3000, 30000과 30001은 0이어야 합니다. 테스트의 기대값은 이 문장에서 정합니다. shippingFee의 조건문을 테스트 안에 그대로 복사해 기대값을 계산하면, 두 곳에 같은 실수가 들어가도 통과할 수 있습니다. 동작이 바뀌는 값 바로 아래·그 값·바로 위를 경계값이라고 부릅니다. 이 값들을 고르면 >=를 >로 잘못 바꾸는 결함을 드러낼 수 있지만 모든 입력에 대한 증명이 되는 것은 아닙니다." + }, + { + "heading": "테스트 가능성은 의존 관계와 연결된다", + "text": "shippingFee는 subtotal만 받아 결과를 계산하므로 같은 입력으로 반복 확인하기 쉽습니다. 계산 안에서 현재 시간·무작위 값·네트워크·DB를 직접 읽으면 같은 테스트도 조건에 따라 달라질 수 있습니다. 필요한 외부 값을 계산과 분리하거나 협력 객체로 전달하면 조건을 통제할 수 있습니다. 테스트에서 진짜 외부 시스템 대신 쓰는 객체를 대역이라고 합니다. 대역으로 계산을 확인한 단위 테스트와 실제 DB·네트워크 연결을 확인하는 통합 테스트는 서로 다른 범위를 봅니다." + }, + { + "heading": "좋은 테스트는 잘못된 구현을 거절한다", + "text": "check(0, shippingFee(30000))는 무료 배송 경계가 잘못되면 AssertionError를 던집니다. 음수 검사에서는 shippingFee(-1)이 예외 없이 돌아오면 오히려 테스트 쪽에서 AssertionError를 던집니다. 기대한 실패가 없다는 것도 결함이기 때문입니다. 이런 검사가 잘못된 구현을 걸러내는 힘을 테스트의 판별력이라고 부릅니다. 실무에서는 JUnit이 실패 위치와 실행 관리를 돕지만, 이 예제는 의존성 없는 Java 메서드로 원리를 보여 줍니다." + } + ], + "example": { + "title": "기대값을 명시하는 작은 계약 검사 · Main.java", + "code": "class Main {\n static int shippingFee(int subtotal) {\n if (subtotal < 0) {\n throw new IllegalArgumentException(\"negative subtotal\");\n }\n return subtotal >= 30000 ? 0 : 3000;\n }\n\n static void check(int expected, int actual) {\n if (expected != actual) {\n throw new AssertionError(\"expected=\" + expected + \", actual=\" + actual);\n }\n }\n\n public static void main(String[] args) {\n check(3000, shippingFee(29999));\n check(0, shippingFee(30000));\n check(0, shippingFee(30001));\n try {\n shippingFee(-1);\n throw new AssertionError(\"negative input must fail\");\n } catch (IllegalArgumentException expected) {\n // Expected contract failure.\n }\n System.out.println(\"4 contract checks passed\");\n }\n}", + "output": "4 contract checks passed", + "explanation": "앞의 세 check는 경계 아래·경계·경계 위의 배송비를 직접 비교합니다. 다음 try는 음수 입력이 IllegalArgumentException을 던지는지 확인합니다. 정상 반환한다면 테스트가 AssertionError를 던져 실패합니다. 네 검사를 모두 지나야 마지막 문장이 출력됩니다. check의 if와 throw는 -ea 옵션이 필요한 assert 문을 사용하지 않으며, 이 예제가 JUnit의 전체 기능을 대체하는 것은 아닙니다." + }, + "quiz": [ + { + "id": "testing-design-q1", + "question": "무료 배송 조건이 subtotal >= 30000일 때 경계 결함을 잘 구별하는 입력 묶음은?", + "options": [ + "1000, 2000, 3000", + "0 하나만", + "29999, 30000, 30001", + "항상 100000만" + ], + "correctIndex": 2, + "explanation": "29999는 아직 유료, 30000부터는 무료라는 차이를 직접 관찰하는 입력입니다. >=를 >로 잘못 바꾸면 30000 검사가 실패합니다. 같은 쪽에만 있는 1000·2000·3000은 이 결함을 드러내지 못합니다." + }, + { + "id": "testing-design-q2", + "question": "정상 구현과 의도적으로 틀린 구현이 모두 테스트를 통과했다면?", + "options": [ + "테스트가 더 유연하므로 항상 좋은 결과다.", + "테스트가 해당 결함을 구별하지 못하므로 기대값·입력·검사 여부를 검토해야 한다.", + "틀린 구현도 자동으로 올바른 계약이 된다.", + "테스트 실행 횟수만 늘리면 모든 설계 문제가 해결된다." + ], + "correctIndex": 1, + "explanation": "틀린 구현도 통과한다면 그 차이가 드러나는 입력이나 기대값을 검사하지 않았을 수 있습니다. 실제 check가 실행되는지도 확인해야 합니다. 같은 검사를 많이 반복하는 것보다 해당 오답이 실패하는 사례를 추가하는 것이 먼저입니다." + } + ], + "practice": { + "title": "배송비 정책을 검증하는 테스트 작성", + "scenario": "무료 배송 조건이 >=에서 >로 바뀌었는데 기존 테스트는 계속 통과했습니다. 테스트가 1000원과 100000원 주문만 확인해 정확히 30000원인 경우를 놓쳤기 때문입니다.", + "tasks": [ + "정상·경계·실패 대표값을 기대 결과와 함께 정하세요.", + "정상 구현은 수용하고 경계가 틀린 구현은 거절하는 검사 함수를 만드세요.", + "실무 도구로 옮길 때 필요한 실패 메시지와 단위·통합 검사 범위를 설명하세요." + ], + "takeaway": "테스트 이름이나 실행 횟수만 보지 말고, 어떤 잘못된 구현이 어느 입력에서 실패하는지 설명하세요. 실행 성공·계약 확인·설계 설명은 각각 확인해야 합니다." + }, + "sources": [ + { + "title": "Java SE 8 API — IntUnaryOperator", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/function/IntUnaryOperator.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JUnit 5 User Guide — Writing Tests", + "url": "https://docs.junit.org/5.11.4/user-guide/", + "edition": "JUnit 5.11.4; 후속 실무 도구 참고", + "checkedAt": "2026-10-12" + }, + { + "title": "Java SE 8 API — AssertionError", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/AssertionError.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ], + "jobTags": [ + "Java", + "JUnit" + ], + "exerciseId": "testing-design-lab", + "learningGuide": { + "scenario": "배송비 정책은 30000 이상 무료, 그 미만 3000이며 음수 금액은 거절합니다. 정상 코드를 통과시키는 것뿐 아니라 잘못 바뀐 경계도 잡아야 합니다.", + "question": "어떤 줄이 >=를 >로 바꾼 실수를 잡고, 어떤 줄이 음수 검증 누락을 잡을까요?", + "terms": [ + { + "term": "계약 검사", + "meaning": "구현 방식과 별개로, 약속한 입력·결과·실패를 실제 호출로 확인하는 검사." + }, + { + "term": "경계값", + "meaning": "동작이 달라지는 값과 그 바로 주변의 값. 여기서는 30000과 양옆 금액입니다." + }, + { + "term": "테스트의 판별력", + "meaning": "정상 구현은 통과시키고 중요한 잘못된 구현은 실패시키는 능력." + } + ], + "walkthrough": [ + "첫 check는 29999의 배송비가 3000인지, 다음 두 check는 30000과 30001의 배송비가 0인지 비교합니다.", + "check는 expected와 actual이 다르면 AssertionError를 던집니다. 조건을 >로 잘못 바꾸면 30000 검사에서 멈춥니다.", + "음수 입력은 IllegalArgumentException이 나야 합니다. shippingFee(-1)이 정상 반환하면 다음 줄이 AssertionError를 던집니다.", + "기대한 IllegalArgumentException만 catch한 뒤 네 검사를 모두 통과해야 마지막 출력에 도달합니다." + ], + "prediction": { + "question": "shippingFee가 음수에서도 3000을 반환하도록 잘못 바뀌면 마지막 성공 문구가 출력될까요?", + "answer": "출력되지 않습니다.", + "explanation": "shippingFee(-1)이 정상 반환하면 바로 다음 줄의 AssertionError가 발생합니다. catch는 IllegalArgumentException만 받으므로 테스트 실패가 숨겨지지 않습니다." + }, + "takeaway": "실패해야 하는 입력의 테스트에는 ‘예외가 안 나면 테스트가 실패하는가’까지 확인하세요. 기대값은 구현의 조건문을 복사하지 말고 업무 규칙에서 정합니다.", + "code": { + "file": "examples/java8/testing-design/Main.java", + "source": " check(3000, shippingFee(29999));\n check(0, shippingFee(30000));\n check(0, shippingFee(30001));\n try {\n shippingFee(-1);\n throw new AssertionError(\"negative input must fail\");\n } catch (IllegalArgumentException expected) {\n // Expected contract failure.\n }\n System.out.println(\"4 contract checks passed\");" + } + } + } +]; diff --git a/dist/data/exercises.js b/dist/data/exercises.js new file mode 100644 index 0000000..6bfe6f9 --- /dev/null +++ b/dist/data/exercises.js @@ -0,0 +1,300 @@ +// Public learning contracts. Trusted checks and reference answers are kept outside dist. +export const exercises = [ + { + "id": "java-basics-lab", + "lessonId": "java-basics", + "title": "참조가 달라도 같은 코드인가?", + "mode": "method", + "minVersion": 8, + "instructions": [ + "class Solution의 public boolean sameCode(String left, String right)를 완성하세요.", + "두 문자열의 내용이 같으면 true입니다. 두 값이 모두 null이면 true, 하나만 null이면 false입니다.", + "참조 동일성을 내용 동등성과 구분하세요. 입력값을 변경하지 않습니다." + ], + "template": "class Solution {\n public boolean sameCode(String left, String right) {\n // null과 내용 동등성을 함께 처리하세요.\n return false;\n }\n}", + "sampleDescription": "서로 다른 new String(\"JAVA\") 객체 두 개는 같은 코드로 판단합니다.", + "sampleOutput": "내용이 같은 별도 객체: 통과", + "goals": [ + "참조 동일성과 내용 동등성을 구분한다.", + "null을 포함한 호출 계약을 구현한다." + ], + "rubric": [ + "== 비교에 의존하지 않고 내용 비교를 사용하는지 설명한다.", + "null 계약을 호출자가 예측할 수 있도록 설명한다." + ] + }, + { + "id": "oop-lab", + "lessonId": "oop", + "title": "재고의 불변식을 객체 안에 두기", + "mode": "class", + "minVersion": 8, + "instructions": [ + "class Solution을 재고 객체로 설계하세요. public Solution(int initialStock), public boolean reserve(int quantity), public int remaining()을 제공해야 합니다.", + "초기 재고는 0 이상입니다. 음수이면 생성자에서 IllegalArgumentException을 던지세요.", + "reserve는 양수이며 현재 재고 이하인 수량만 예약하고 true를 반환합니다. 나머지는 false이며 재고를 변경하지 않습니다.", + "재고를 외부에서 직접 변경할 수 없게 하세요. 실패한 예약 뒤에도 남은 재고는 그대로여야 합니다." + ], + "template": "class Solution {\n // 필드, 생성자, reserve, remaining을 설계하세요.\n}", + "sampleDescription": "재고 5개에서 3개 예약 후 잔여 2개, 추가 3개 예약은 실패하고 2개를 유지합니다.", + "sampleOutput": "예약 성공과 초과 예약 실패: 통과", + "goals": [ + "생성자와 메서드에서 객체의 불변식을 지킨다.", + "실패한 명령의 상태 변경 여부를 명확히 한다." + ], + "rubric": [ + "재고 필드를 캡슐화하고 잘못된 상태를 만드는 경로를 차단한다.", + "예약 검증과 변경의 책임을 객체에 두며 실패 계약을 설명한다.", + "이 과제는 단일 스레드 객체이며 분산 락이나 동시 예약 안전성을 구현한 것이 아님을 구분한다." + ] + }, + { + "id": "polymorphism-lab", + "lessonId": "polymorphism", + "title": "상속 없이 할인 정책 교체하기", + "mode": "class", + "minVersion": 8, + "instructions": [ + "class Solution 안에 public interface DiscountPolicy { int discount(int subtotal); }를 선언하세요.", + "public int payable(int subtotal, DiscountPolicy policy)를 구현하세요. 소계에서 정책이 반환한 할인액을 뺀 값을 반환합니다.", + "subtotal은 0 이상, 할인액은 0 이상 subtotal 이하입니다. 위반하면 IllegalArgumentException을 던지세요.", + "policy가 null이면 NullPointerException을 던지세요. policy.discount는 유효한 subtotal에 대해 정확히 한 번 호출합니다.", + "정책의 구체 클래스나 종류를 검사하지 않고 인터페이스로 협력하세요." + ], + "template": "class Solution {\n // DiscountPolicy와 payable 메서드를 설계하세요.\n}", + "sampleDescription": "소계 10000에 할인액 1000을 반환하는 정책을 주입하면 결제액은 9000입니다.", + "sampleOutput": "정액 정책 주입: 통과", + "goals": [ + "협력 인터페이스로 구현 교체 지점을 만든다.", + "다형성과 호출 계약을 함께 검증한다." + ], + "rubric": [ + "구체 타입 분기 없이 정책에 계산을 위임한다.", + "정책을 상속할 이유와 조합할 이유를 비교한다.", + "새 정책이 기존 호출 계약을 깨지 않도록 검증하는 위치를 설명한다." + ] + }, + { + "id": "collections-lab", + "lessonId": "collections", + "title": "순서를 보존하는 중복 제거", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public java.util.List uniqueCodes(java.util.List codes)를 완성하세요.", + "내용이 같은 문자열은 첫 항목만 남기고 최초 등장 순서를 유지합니다. null 항목도 한 번만 남깁니다.", + "입력 리스트 자체는 null이 아니라고 가정합니다. 입력 리스트를 변경하지 마세요.", + "결과는 입력과 다른 새로운 리스트여야 합니다. 결과를 수정해도 입력 리스트는 바뀌지 않아야 합니다." + ], + "template": "class Solution {\n public java.util.List uniqueCodes(java.util.List codes) {\n return new java.util.ArrayList();\n }\n}", + "sampleDescription": "[\"B\", \"A\", \"B\"]를 입력하면 [\"B\", \"A\"]를 반환합니다.", + "sampleOutput": "최초 등장 순서: 통과", + "goals": [ + "컬렉션의 순서·중복·변경 계약을 선택한다.", + "참조 공유와 복사본을 구분한다." + ], + "rubric": [ + "선택한 Set/List 구현이 요구 순서와 동등성 계약에 맞는지 설명한다.", + "복사본이 깊은 복사를 뜻하지 않음을 설명한다." + ] + }, + { + "id": "generics-lab", + "lessonId": "generics", + "title": "생산자와 소비자를 받는 복사 API", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public void copy(java.util.List source, java.util.List target)를 완성하세요.", + "source의 모든 항목을 순서대로 target의 끝에 추가하세요. target의 기존 항목은 유지합니다.", + "두 리스트는 null이 아니며 서로 다른 객체이고, 저장소를 공유하는 뷰·래퍼 관계가 없습니다. target은 source의 모든 요소를 수용하고 추가할 수 있는 리스트입니다.", + "raw type이나 강제 캐스팅을 사용하지 마세요. source를 변경하지 않습니다." + ], + "template": "class Solution {\n public void copy(java.util.List source,\n java.util.List target) {\n // 생산자에서 읽고 소비자에 추가하세요.\n }\n}", + "sampleDescription": "List의 1, 2를 List의 0.5 뒤에 추가합니다.", + "sampleOutput": "Integer에서 Number로 복사: 통과", + "goals": [ + "불공변 제네릭과 wildcard 경계를 구분한다.", + "읽기·쓰기 역할로 API 타입을 정한다." + ], + "rubric": [ + "extends에서 T로 읽을 수 있고 super에 T를 쓸 수 있는 이유를 설명한다.", + "컴파일 오류를 raw type이나 unchecked cast로 우회하지 않는다." + ] + }, + { + "id": "exceptions-lab", + "lessonId": "exceptions", + "title": "원인을 보존하는 설정값 파서", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public int parsePort(String text)를 완성하세요.", + "앞뒤 공백은 String.trim()으로 제거하고 1~65535 범위의 정수를 반환하세요.", + "null, 빈 문자열, 숫자 형식 오류, 범위 밖 값은 IllegalArgumentException으로 거절합니다.", + "Integer.parseInt에서 NumberFormatException이 발생했다면 새 IllegalArgumentException의 cause로 원본 예외를 보존하세요. 오류를 0 같은 정상값으로 바꾸지 않습니다." + ], + "template": "class Solution {\n public int parsePort(String text) {\n return 0;\n }\n}", + "sampleDescription": "\" 8080 \"은 8080입니다. \"eighty\"는 원인 예외를 보존하여 거절합니다.", + "sampleOutput": "공백을 포함한 유효 설정: 통과", + "goals": [ + "실패를 정상값과 구분한다.", + "예외 변환 시 진단 가능한 원인을 보존한다." + ], + "rubric": [ + "입력 검증과 파싱 실패의 책임을 설명한다.", + "광범위한 예외 삼키기나 임의의 기본값 반환을 사용하지 않는다." + ] + }, + { + "id": "streams-lab", + "lessonId": "streams", + "title": "공유 상태 없이 조건 합계 구하기", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public int totalPositiveEven(java.util.List values)를 완성하세요.", + "null 항목, 0, 음수와 홀수는 제외하고 양수인 짝수만 합산하세요.", + "리스트 자체는 null이 아니며 유효 항목의 총합은 int 범위를 넘지 않습니다. 입력을 변경하지 마세요.", + "Stream과 원시형 집계 연산으로 외부 가변 누산기 없이 작성하세요. 이 설계 조건은 별도 루브릭으로 확인합니다." + ], + "template": "class Solution {\n public int totalPositiveEven(java.util.List values) {\n return 0;\n }\n}", + "sampleDescription": "[2, -4, 3, null, 6, 0]의 결과는 8입니다.", + "sampleOutput": "조건 필터와 합계: 통과", + "goals": [ + "중간 연산과 최종 연산을 구분한다.", + "공유 상태 대신 데이터 흐름으로 집계한다." + ], + "rubric": [ + "Stream 파이프라인과 최종 연산을 식별한다.", + "외부 컬렉션·누산기 변경 없이 결과를 구한다.", + "parallelStream으로 바꾸면 무조건 빨라진다는 주장을 하지 않는다." + ] + }, + { + "id": "concurrency-lab", + "lessonId": "concurrency", + "title": "작업 완료를 기다리는 안전한 카운터", + "mode": "class", + "minVersion": 8, + "instructions": [ + "class Solution에 public int count(int workers, int iterations)를 구현하세요.", + "workers개의 실제 작업을 ExecutorService에 제출하고, 각 작업이 공유 AtomicInteger를 iterations번 증가시키게 하세요.", + "모든 작업이 끝난 뒤 합계를 반환하고 ExecutorService를 종료하세요. workers는 1~8, iterations는 0~10000이며 범위 밖 입력은 IllegalArgumentException입니다.", + "대기 중 인터럽트가 발생하면 인터럽트 상태를 복원하고 IllegalStateException으로 전파하세요. 작업 실패도 원인을 보존해 IllegalStateException으로 전파합니다.", + "자동 테스트는 반환값과 입력 계약을 확인합니다. 실제 작업 제출·원자 연산·종료 책임은 별도 설계 검토 대상입니다." + ], + "template": "class Solution {\n // count 메서드와 필요한 협력 객체를 작성하세요.\n}", + "sampleDescription": "작업 2개가 각각 500번 증가시키면 반환값은 1000입니다.", + "sampleOutput": "두 작업의 완료 합계: 통과", + "goals": [ + "공유 상태의 원자성과 완료 대기를 분리한다.", + "Executor의 생명주기와 실패 전파를 책임진다." + ], + "rubric": [ + "workers × iterations 계산만 반환하지 않고 실제 작업을 제출한다.", + "AtomicInteger 등 원자 연산의 적용 범위와 복합 업무 트랜잭션의 차이를 설명한다.", + "작업 완료 확인, Executor 종료, 인터럽트 복원을 검토한다." + ] + }, + { + "id": "modern-apis-lab", + "lessonId": "modern-apis", + "title": "Java 11 API로 여러 줄 입력 정규화하기", + "mode": "method", + "minVersion": 11, + "instructions": [ + "public java.util.List normalizedLines(String text)를 완성하세요.", + "String.lines() 기준으로 줄을 나눈 뒤 각 줄에 strip()을 적용하고 isBlank()인 줄을 제외하세요.", + "최초 줄 순서와 중복 줄은 보존합니다. null 입력은 IllegalArgumentException으로 거절하세요.", + "이 과제는 Java 11 이상의 API를 사용합니다. trim()과 strip()의 공백 판정 차이를 확인하세요." + ], + "template": "class Solution {\n public java.util.List normalizedLines(String text) {\n return new java.util.ArrayList();\n }\n}", + "sampleDescription": "\" Java\\n\\n SQL \"은 [\"Java\", \"SQL\"]이 됩니다.", + "sampleOutput": "빈 줄 제거와 순서: 통과", + "goals": [ + "API Since와 대상 런타임을 연결한다.", + "공백 정의가 입력 계약에 미치는 영향을 이해한다." + ], + "rubric": [ + "String.lines·strip·isBlank의 역할을 설명한다.", + "Java 8에서 동일 코드를 실행할 수 있다고 표시하지 않는다." + ] + }, + { + "id": "records-sealed-lab", + "lessonId": "records-sealed", + "title": "성공과 실패를 닫힌 결과 타입으로 표현하기", + "mode": "class", + "minVersion": 17, + "instructions": [ + "class Solution 안에 public sealed interface Result permits Accepted, Rejected를 선언하세요.", + "public record Accepted(int remaining) implements Result와 public record Rejected(String reason) implements Result를 선언하세요.", + "public Result reserve(int stock, int requested)를 구현하세요. stock < 0이면 Rejected(\"invalid-stock\"), requested <= 0이면 Rejected(\"invalid-quantity\"), requested > stock이면 Rejected(\"insufficient-stock\")를 반환합니다. 이 순서로 검증하세요.", + "그 외에는 Accepted(stock - requested)를 반환합니다. 입력값 외 공유 상태는 필요 없습니다.", + "Java 17에서 정식인 record·sealed 타입을 사용하세요. Java 17의 preview pattern switch는 사용하지 않습니다." + ], + "template": "class Solution {\n // Result, Accepted, Rejected와 reserve를 선언하세요.\n}", + "sampleDescription": "재고 5개에서 2개 예약은 Accepted(3)과 값이 같은 결과입니다.", + "sampleOutput": "성공 결과의 값 동등성: 통과", + "goals": [ + "닫힌 타입 계층으로 결과의 종류를 드러낸다.", + "record의 생성 코드와 불변성의 범위를 구분한다." + ], + "rubric": [ + "sealed 계층과 record로 상태의 의미를 명확히 한다.", + "record가 가변 구성 요소까지 깊게 불변으로 만들지는 않음을 설명한다.", + "Java 17에서 preview 문법을 섞지 않는다." + ] + }, + { + "id": "java21-lab", + "lessonId": "java21", + "title": "패턴과 가드로 입력의 의미 분류하기", + "mode": "method", + "minVersion": 21, + "instructions": [ + "제공된 public record Order(String id, int quantity)는 유지하고 public String describe(Object value)를 완성하세요.", + "Order이며 quantity > 0이면 id + \":\" + quantity를 반환합니다. 나머지 Order는 \"empty\", null은 \"missing\", 다른 타입은 \"unsupported\"입니다.", + "과제의 Order.id는 null이 아니라고 가정합니다.", + "Java 21의 record pattern과 when 가드를 사용하세요. 분기 순서와 null 분기를 설명할 수 있어야 합니다." + ], + "template": "class Solution {\n public record Order(String id, int quantity) {}\n\n public String describe(Object value) {\n return \"unsupported\";\n }\n}", + "sampleDescription": "new Order(\"A-21\", 3)은 \"A-21:3\"입니다.", + "sampleOutput": "양수 주문 패턴: 통과", + "goals": [ + "record pattern과 가드 조건으로 타입·상태를 함께 분기한다.", + "null과 포괄 분기 범위를 명시한다." + ], + "rubric": [ + "record pattern·when 가드의 범위를 설명한다.", + "구체적인 패턴을 넓은 패턴보다 먼저 배치하는 이유를 설명한다.", + "Java 21 정식 문법만 사용하고 preview 옵션에 의존하지 않는다." + ] + }, + { + "id": "testing-design-lab", + "lessonId": "testing-design", + "title": "정상 구현과 경계 결함을 구별하는 테스트", + "mode": "method", + "minVersion": 8, + "instructions": [ + "public boolean acceptsPolicy(java.util.function.IntUnaryOperator policy)를 완성하세요. 배송비 정책을 검사하는 작은 테스트 함수를 작성하는 과제입니다.", + "정책의 계약은 주문액 0~29999이면 배송비 3000, 30000 이상이면 0, 음수 주문액은 IllegalArgumentException입니다.", + "이 과제에서는 0, 29999, 30000, 30001, -1의 다섯 대표값을 검사하세요. 모두 기대 행동이면 true, 하나라도 다르거나 예상 밖 RuntimeException이면 false입니다.", + "policy는 null이 아니며 호출 결과는 결정적이라고 가정합니다. JUnit 의존성 없이 테스트의 판별력을 연습합니다." + ], + "template": "class Solution {\n public boolean acceptsPolicy(java.util.function.IntUnaryOperator policy) {\n return false;\n }\n}", + "sampleDescription": "올바른 정책은 통과하고, 무료 배송 경계를 > 30000으로 잘못 쓴 정책은 거절합니다.", + "sampleOutput": "계약을 만족하는 정책 수용: 통과", + "goals": [ + "정상값과 경계값으로 규칙을 검증한다.", + "테스트가 잘못된 구현을 실제로 구분하는지 확인한다." + ], + "rubric": [ + "정상 구현을 통과시키고 의도적으로 틀린 구현을 실패시킨다.", + "검사한 대표값과 전체 입력 범위에 대한 증명을 구분한다.", + "실무에서는 같은 검사를 JUnit 등 테스트 도구에 옮겨 실패 진단을 개선할 수 있음을 설명한다." + ] + } +]; diff --git a/dist/data/job-evidence.js b/dist/data/job-evidence.js new file mode 100644 index 0000000..741051f --- /dev/null +++ b/dist/data/job-evidence.js @@ -0,0 +1,40 @@ +// 확인일은 게시일이 아닙니다. 수업 연결은 교육 설계자의 제안입니다. +export const jobEvidence = [ + { + id: 'tossplace-platform-4071141003', company: '토스플레이스', title: 'Server Developer', + url: 'https://toss.im/career/job-detail?company=%ED%86%A0%EC%8A%A4%ED%94%8C%EB%A0%88%EC%9D%B4%EC%8A%A4&detailedPosition=Platform&job_id=4071141003', + checkedAt: '2026-10-12', status: '상태 미확인', access: 'full', + technologies: ['Java', 'Kotlin', 'Spring Boot', 'JPA/Hibernate', 'Redis', 'Kafka', 'Kubernetes', '동시성', '테스트'], + summary: 'Platform 본문은 공통 기능과 테스트·호환성 관리, 동시성·타임아웃·재시도·중복·부분 실패 대응을 설명합니다. Java/Kotlin과 Spring 경험은 개발 환경 및 우대 경험으로 제시됩니다.', + lessonIds: ['oop', 'exceptions', 'testing-design', 'concurrency'] + }, + { + id: 'aladin-backend-52759934', company: '알라딘커뮤니케이션', title: '백엔드 엔지니어-개발2팀', + url: 'https://jumpit.saramin.co.kr/position/52759934', checkedAt: '2026-10-12', status: '마감', access: 'full', + technologies: ['Kotlin', 'Java', 'Kubernetes', 'Docker', '단위 테스트', '부하 테스트', '쿼리 튜닝', '동시성'], + summary: 'Kotlin 역량과 별도로 Java 등을 포함한 언어 중 하나의 문제 해결 능력, 테스트 작성, 부하·쿼리 튜닝 경험을 요구합니다. 동시성과 분산 처리는 우대사항입니다.', + lessonIds: ['collections', 'testing-design', 'concurrency'] + }, + { + id: 'covision-backend-50154427', company: '코비젼', title: '경력직 개발자 채용 - JAVA/프론트엔드/백엔드/전문연구요원', + url: 'https://jumpit.saramin.co.kr/position/50154427', checkedAt: '2026-10-12', status: '마감', access: 'full', + technologies: ['Java', 'Spring Framework', 'JPA/Hibernate', 'SQL', 'RDBMS', 'Docker', 'Kubernetes', 'API 설계'], + summary: '여러 직군을 함께 모집한 공고입니다. 백엔드 요건에 SQL·관계형 DB, Spring·JPA, 컨테이너 배포·운영과 API 설계 경험이 명시됩니다.', + lessonIds: ['oop', 'modern-apis', 'testing-design'] + }, + { + id: 'whatap-kubernetes-291531', company: '와탭랩스', title: 'Kubernetes 에이전트 개발자', + url: 'https://career.rememberapp.co.kr/job/posting/291531', checkedAt: '2026-10-12', status: '상태 미확인', access: 'full', + technologies: ['Java', 'Go', 'Kubernetes', '자료구조', '멀티스레드', '소켓 통신', '메모리 최적화', '비동기 처리'], + summary: 'Java 또는 Go 역량, 자료구조·멀티스레드·소켓 이해를 요구합니다. 메트릭 수집과 메모리 효율 개선, 비동기 데이터 처리 설계가 업무에 포함됩니다.', + lessonIds: ['collections', 'concurrency'] + }, + { + id: 'muzlive-backend-1679758', company: '뮤즈라이브', title: '백엔드 엔지니어(Java/Spring)', + url: 'https://www.jobplanet.co.kr/job/search?entry=job_posting_keyword&posting_ids%5B%5D=1679758', + checkedAt: '2026-10-12', status: '상태 미확인', access: 'full', + technologies: ['Java', 'Spring Boot', 'MySQL', 'Redis', 'AWS', 'Git'], + summary: 'Java 서버의 개발·유지보수와 코드 품질·설계 개선을 소개합니다. Spring Boot·MySQL·Redis·AWS는 사용 환경이며 대용량 처리 경험은 우대사항입니다.', + lessonIds: ['oop', 'collections', 'exceptions'] + } +]; diff --git a/dist/data/lesson-visuals.js b/dist/data/lesson-visuals.js new file mode 100644 index 0000000..cf144b3 --- /dev/null +++ b/dist/data/lesson-visuals.js @@ -0,0 +1,1328 @@ +// Teaching diagrams are semantic data. Labels and descriptions are plain text. +// Their edges describe the relation stated by each caption, not an implicit universal execution order. +export const lessonVisuals = { + "java-basics": { + "id": "viz-java-basics-reference-copy", + "title": "복사되는 참조값과 바뀌는 객체", + "kind": "comparison", + "afterSection": 0, + "caption": "update(box) 안의 두 시점을 비교합니다. 변수 카드와 객체 카드는 서로 다른 대상을 나타냅니다.", + "altText": "호출 직후에는 호출자의 box와 매개변수 box가 같은 Box A를 가리킵니다. 필드 변경 뒤 A의 value는 15가 됩니다. 매개변수를 새 Box B로 재할당해도 호출자의 box는 A를 가리킵니다.", + "readingOrder": "첫 패널에서 두 변수의 참조 대상을 확인한 다음, 두 번째 패널에서 필드 변경과 매개변수 재할당의 결과를 따로 읽으세요.", + "panels": [ + { + "id": "copy", + "title": "1. 메서드 진입 직후", + "nodes": [ + { + "id": "caller", + "label": "호출자 변수 box", + "detail": "기존 참조값을 유지합니다.", + "tone": "neutral" + }, + { + "id": "parameter", + "label": "매개변수 box", + "detail": "호출자의 참조값을 복사받습니다.", + "tone": "neutral" + }, + { + "id": "original", + "label": "기존 객체 A · value = 5", + "detail": "두 변수로 같은 객체에 접근합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "caller", + "to": "original", + "label": "참조값이 A를 가리킴" + }, + { + "from": "parameter", + "to": "original", + "label": "복사된 참조값도 A를 가리킴" + } + ] + }, + { + "id": "reassigned", + "title": "2. update의 마지막 대입 직후", + "nodes": [ + { + "id": "caller", + "label": "호출자 변수 box", + "detail": "재할당되지 않았습니다.", + "tone": "neutral" + }, + { + "id": "original", + "label": "기존 객체 A · value = 15", + "detail": "box.value += 10이 이 객체를 바꿨습니다.", + "tone": "accent" + }, + { + "id": "parameter", + "label": "매개변수 box", + "detail": "box = new Box(99)로 이 변수만 바뀝니다.", + "tone": "neutral" + }, + { + "id": "replacement", + "label": "새 객체 B · value = 99", + "detail": "호출자의 box가 가리키는 대상은 아닙니다.", + "tone": "neutral" + } + ], + "edges": [ + { + "from": "caller", + "to": "original", + "label": "여전히 A를 가리킴" + }, + { + "from": "parameter", + "to": "replacement", + "label": "새 참조값은 B를 가리킴" + } + ] + } + ], + "takeaway": "객체를 변경하는 일과 참조 변수에 다른 값을 대입하는 일을 분리해서 추적하세요.", + "limits": [ + "객체 A·B는 설명용 이름이며 실제 메모리 주소나 JVM의 물리적 배치를 나타내지 않습니다.", + "두 번째 패널은 메서드가 반환되기 직전입니다. 자원 수명·GC·동시성은 이 그림의 범위가 아닙니다." + ], + "sources": [ + { + "title": "Oracle Tutorial — Passing Reference Data Type Arguments", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/arguments.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "oop": { + "id": "viz-oop-invariant", + "title": "실패 뒤에도 지켜야 하는 잔액", + "kind": "state", + "afterSection": 1, + "caption": "Credit 예제의 spend(40), spend(70)을 순서대로 적용합니다. 잔액 0 이상이라는 업무 규칙을 상태 변화로 확인합니다.", + "altText": "잔액 100에서 40 차감은 검증을 통과해 60이 됩니다. 이어 70 차감은 잔액을 초과하므로 false를 반환하고 60을 유지합니다.", + "readingOrder": "첫 패널의 성공 후 잔액 60을 두 번째 패널의 시작 상태로 이어 읽으세요.", + "panels": [ + { + "id": "accepted", + "title": "1. spend(40) 성공", + "nodes": [ + { + "id": "before", + "label": "잔액 100", + "detail": "생성자가 유효한 초기 상태를 만들었습니다.", + "tone": "neutral" + }, + { + "id": "check", + "label": "0 < 40 ≤ 100", + "detail": "변경하기 전에 요청을 검사합니다.", + "tone": "neutral" + }, + { + "id": "after", + "label": "잔액 60 · true", + "detail": "검증을 통과한 뒤에만 차감합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "before", + "to": "check", + "label": "40 차감 요청" + }, + { + "from": "check", + "to": "after", + "label": "조건 충족 → 차감" + } + ] + }, + { + "id": "rejected", + "title": "2. spend(70) 거절", + "nodes": [ + { + "id": "before", + "label": "잔액 60", + "detail": "앞선 성공 결과가 다음 호출의 시작입니다.", + "tone": "neutral" + }, + { + "id": "check", + "label": "70 > 60", + "detail": "요청이 잔액보다 큽니다.", + "tone": "warning" + }, + { + "id": "after", + "label": "잔액 60 · false", + "detail": "실패를 알리고 기존 상태를 보존합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "before", + "to": "check", + "label": "70 차감 요청" + }, + { + "from": "check", + "to": "after", + "label": "조건 불충족 → 변경 없음" + } + ] + } + ], + "takeaway": "반환값뿐 아니라 실패 후 객체가 어떤 상태인지도 메서드 계약에 포함하세요.", + "limits": [ + "검증 순서와 잔액 불변식은 이 예제의 설계입니다. Java나 private 키워드가 업무 규칙을 자동으로 보장하지 않습니다.", + "단일 스레드의 객체 상태를 설명하며 동시 호출·여러 객체·DB 트랜잭션의 원자성을 뜻하지 않습니다." + ], + "sources": [ + { + "title": "Oracle Tutorial — Classes", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/classes.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "JLS — Access Control", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-6.html#jls-6.6", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "polymorphism": { + "id": "viz-polymorphism-composition", + "title": "Report는 그대로, 협력 구현은 교체", + "kind": "relationship", + "afterSection": 2, + "caption": "main에서 서로 다른 Formatter를 주입합니다. Report는 선택된 구현에 같은 format 요청을 보냅니다.", + "altText": "대괄호 Formatter를 가진 Report는 stock을 [stock]으로, 접두사 Formatter를 가진 Report는 report:stock으로 만듭니다. 두 Report의 render 코드는 같고 협력 구현이 다릅니다.", + "readingOrder": "각 패널에서 Report의 요청, 주입한 Formatter, 반환 결과를 읽은 뒤 두 패널의 바뀐 부분만 비교하세요.", + "panels": [ + { + "id": "bracketed", + "title": "같은 Formatter 계약 · 대괄호 구현", + "nodes": [ + { + "id": "report", + "label": "Report.render(\"stock\")", + "detail": "Report는 Formatter 타입의 필드에 요청합니다.", + "tone": "neutral" + }, + { + "id": "formatter", + "label": "주입한 Formatter", + "detail": "value를 대괄호로 감싸는 람다입니다.", + "tone": "accent" + }, + { + "id": "result", + "label": "반환값: [stock]", + "detail": "선택한 협력 객체의 결과를 반환합니다.", + "tone": "neutral" + } + ], + "edges": [ + { + "from": "report", + "to": "formatter", + "label": "formatter.format(value)로 위임" + }, + { + "from": "formatter", + "to": "result", + "label": "대괄호 구현의 결과" + } + ] + }, + { + "id": "prefixed", + "title": "같은 Formatter 계약 · 접두사 구현", + "nodes": [ + { + "id": "report", + "label": "Report.render(\"stock\")", + "detail": "Report의 render 구현은 같습니다.", + "tone": "neutral" + }, + { + "id": "formatter", + "label": "주입한 Formatter", + "detail": "value 앞에 report:를 붙이는 람다입니다.", + "tone": "accent" + }, + { + "id": "result", + "label": "반환값: report:stock", + "detail": "다른 협력 구현이 다른 결과를 만듭니다.", + "tone": "neutral" + } + ], + "edges": [ + { + "from": "report", + "to": "formatter", + "label": "같은 메서드 계약으로 위임" + }, + { + "from": "formatter", + "to": "result", + "label": "접두사 구현의 결과" + } + ] + } + ], + "takeaway": "구현을 선택하는 곳과 사용하는 곳을 분리하되, 교체할 구현의 입력·결과·실패 계약도 확인하세요.", + "limits": [ + "객체 간 위임 관계이며 클래스 상속도나 Spring 컨테이너 구조가 아닙니다.", + "인터페이스 문법만으로 LSP나 업무상 교체 가능성이 검증되는 것은 아닙니다." + ], + "sources": [ + { + "title": "Oracle Tutorial — Interfaces", + "url": "https://docs.oracle.com/javase/tutorial/java/IandI/createinterface.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Oracle Tutorial — Lambda Expressions", + "url": "https://docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "collections": { + "id": "viz-collections-equality-hash", + "title": "같으면 같은 해시, 같은 해시여도 다를 수 있음", + "kind": "comparison", + "afterSection": 1, + "caption": "equals와 hashCode의 관계는 한 방향의 요구입니다. 반대 방향을 가정하면 해시 충돌을 동등성으로 오해합니다.", + "altText": "equals가 true인 두 객체는 같은 hashCode를 반환해야 합니다. 반대로 문자열 Aa와 BB는 hashCode가 모두 2112여도 equals는 false입니다.", + "readingOrder": "첫 패널의 반드시 따라야 하는 계약을 읽고, 두 번째 패널의 반례로 역방향 추론을 확인하세요.", + "panels": [ + { + "id": "required", + "title": "equals로 같은 객체 → 같은 해시값 필요", + "nodes": [ + { + "id": "equal", + "label": "a.equals(b) == true", + "detail": "예: 별도로 만든 두 String(\"java\") 객체", + "tone": "neutral" + }, + { + "id": "hash", + "label": "a.hashCode() == b.hashCode()", + "detail": "equals로 같다면 만족해야 하는 계약입니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "equal", + "to": "hash", + "label": "equals가 같으면 반드시 같은 해시" + } + ] + }, + { + "id": "collision", + "title": "같은 해시값 → equals 결과는 미정", + "nodes": [ + { + "id": "hash", + "label": "\"Aa\"와 \"BB\"의 해시 = 2112", + "detail": "String의 명세상 해시 계산에서 같은 값이 나옵니다.", + "tone": "neutral" + }, + { + "id": "different", + "label": "\"Aa\".equals(\"BB\") == false", + "detail": "내용이 다른데 해시는 같은 충돌 사례입니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "hash", + "to": "different", + "label": "같은 해시가 같은 내용을 보장하지 않음" + } + ] + } + ], + "takeaway": "해시값만으로 동등성을 결정하지 말고, equals와 hashCode를 함께 같은 업무 의미로 설계하세요.", + "limits": [ + "화살표는 논리 관계를 나타내며 HashMap 내부의 조회 단계나 버킷 구조를 보여 주지 않습니다.", + "키 필드의 변경·순회 순서·성능은 별도 계약입니다. 예시의 숫자는 String API의 해시 계산에만 해당합니다." + ], + "sources": [ + { + "title": "Java API — Object.hashCode and equals", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#hashCode--", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — String.hashCode", + "url": "https://docs.oracle.com/javase/8/docs/api/java/lang/String.html#hashCode--", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "generics": { + "id": "viz-generics-pecs", + "title": "타입을 모를 때 무엇을 보장할 수 있는가", + "kind": "comparison", + "afterSection": 1, + "caption": "extends와 super는 실제 요소 타입을 모두 안다는 뜻이 아닙니다. 읽기와 쓰기에서 안전하게 약속할 수 있는 범위를 비교합니다.", + "altText": "List는 요소를 Number로 읽을 수 있지만 특정 non-null Integer를 추가할 수는 없습니다. List는 Integer를 추가할 수 있으나 읽은 값의 일반적인 정적 타입은 Object입니다.", + "readingOrder": "첫 패널에서 읽기의 보장을, 두 번째 패널에서 쓰기의 보장을 확인하세요. 각 패널의 마지막 카드에는 반대 방향의 제한이 있습니다.", + "panels": [ + { + "id": "producer", + "title": "extends Number · 읽기의 보장", + "nodes": [ + { + "id": "view", + "label": "List", + "detail": "실제 요소 타입은 Number 또는 그 하위 타입입니다.", + "tone": "neutral" + }, + { + "id": "read", + "label": "get 결과를 Number로 읽음", + "detail": "Integer 목록과 Double 목록을 같은 읽기 API에 전달할 수 있습니다.", + "tone": "accent" + }, + { + "id": "write", + "label": "non-null Integer 추가는 불가", + "detail": "실제 목록이 Double 전용일 수 있으므로 컴파일러가 허용하지 않습니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "view", + "to": "read", + "label": "읽기 타입의 상한 보장" + }, + { + "from": "view", + "to": "write", + "label": "구체 요소 타입을 모르므로 쓰기 제한" + } + ] + }, + { + "id": "consumer", + "title": "super Integer · 쓰기의 보장", + "nodes": [ + { + "id": "view", + "label": "List", + "detail": "Integer·Number·Object 등 Integer의 상위 타입을 요소 타입으로 가질 수 있습니다.", + "tone": "neutral" + }, + { + "id": "write", + "label": "Integer 값을 add할 수 있음", + "detail": "해당 정적 타입 관계에서 안전한 값입니다.", + "tone": "accent" + }, + { + "id": "read", + "label": "get의 일반적 정적 타입은 Object", + "detail": "Integer가 아닌 다른 상위 타입의 값도 들어 있을 수 있습니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "view", + "to": "write", + "label": "Integer를 받는 타입 관계" + }, + { + "from": "view", + "to": "read", + "label": "구체 읽기 타입은 더 좁힐 수 없음" + } + ] + } + ], + "takeaway": "읽을 값의 타입과 넣을 값의 타입을 따로 판단한 뒤 wildcard를 선택하세요.", + "limits": [ + "컴파일 시점의 타입 안전성을 비교합니다. 실제 컬렉션이 변경 불가이면 add는 런타임에 실패할 수 있습니다.", + "extends 자체는 null 추가나 제거 연산을 타입 수준에서 금지하지 않습니다. 실제 컬렉션이 해당 연산을 허용하는지는 별도 계약입니다.", + "컬렉션의 불변성·capture helper·타입 소거의 세부는 생략합니다." + ], + "sources": [ + { + "title": "Oracle Tutorial — Upper Bounded Wildcards", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/upperBounded.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Oracle Tutorial — Lower Bounded Wildcards", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/lowerBounded.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Oracle Tutorial — Guidelines for Wildcard Use", + "url": "https://docs.oracle.com/javase/tutorial/java/generics/wildcardGuidelines.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "streams": { + "id": "viz-streams-lazy", + "title": "파이프라인 구성과 실제 소비를 나누기", + "kind": "flow", + "afterSection": 0, + "caption": "기준 예제의 filter·distinct·map을 연결하는 단계와 collect가 결과를 구하는 단계를 나눠 봅니다.", + "altText": "중간 연산을 연결할 때는 처리 계획을 만듭니다. collect 최종 연산이 호출되면 소스를 소비해 JAVA와 JVM을 수집합니다. 이 예제의 원본 names는 네 요소를 유지합니다.", + "readingOrder": "먼저 구성만 하는 패널을 읽고, 다음 패널에서 최종 연산 이후의 평가와 결과를 확인하세요.", + "panels": [ + { + "id": "build", + "title": "1. 중간 연산을 연결", + "nodes": [ + { + "id": "source", + "label": "names: java, sql, jvm, java", + "detail": "Stream의 입력 소스입니다.", + "tone": "neutral" + }, + { + "id": "pipeline", + "label": "filter → distinct → map", + "detail": "j로 시작하는 값 선택·중복 제거·대문자 변환을 연결합니다.", + "tone": "neutral" + } + ], + "edges": [ + { + "from": "source", + "to": "pipeline", + "label": "처리 흐름 구성 · 아직 최종 연산 없음" + } + ] + }, + { + "id": "evaluate", + "title": "2. collect로 최종 결과 요청", + "nodes": [ + { + "id": "terminal", + "label": "collect(Collectors.toList())", + "detail": "최종 연산이 파이프라인 평가를 시작합니다.", + "tone": "accent" + }, + { + "id": "evaluate", + "label": "소스를 소비하며 연산 평가", + "detail": "이 예제는 소스나 외부 상태를 변경하지 않는 콜백을 사용합니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "결과: [JAVA, JVM]", + "detail": "원본 names.size()는 4입니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "terminal", + "to": "evaluate", + "label": "최종 연산이 소비 시작" + }, + { + "from": "evaluate", + "to": "result", + "label": "결과를 리스트로 수집" + } + ] + } + ], + "takeaway": "중간 연산을 적은 시점과 결과가 필요한 시점을 구분하고, 외부 상태 변경을 집계에 섞지 마세요.", + "limits": [ + "각 중간 연산이 별도의 임시 List를 만든다는 뜻이 아닙니다.", + "단락 평가·최적화·병렬 실행의 콜백 횟수와 순서는 그리지 않았습니다. 모든 Stream이 소스를 불변으로 만드는 것도 아닙니다." + ], + "sources": [ + { + "title": "Java API — Stream package, pipelines and laziness", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — Stream", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "concurrency": { + "id": "viz-concurrency-atomicity", + "title": "보이는 값과 원자적인 증가는 다른 보장", + "kind": "comparison", + "afterSection": 0, + "caption": "두 스레드가 한 번씩 증가시키는 비교용 사례입니다. 왼쪽은 volatile int에서도 가능한 끼어들기 한 가지이며 기준 예제의 실행 기록은 아닙니다.", + "altText": "count++를 나누어 실행하면 두 스레드가 모두 0을 읽고 각각 1을 써 최종값이 1일 수 있습니다. AtomicInteger의 두 증가가 완료되면 최종값은 2입니다.", + "readingOrder": "첫 패널의 가능한 읽기·쓰기 순서를 따라가고, 두 번째 패널의 원자 연산 결과와 비교하세요.", + "panels": [ + { + "id": "compound", + "title": "volatile count++ · 가능한 실행 사례", + "nodes": [ + { + "id": "initial", + "label": "count = 0", + "detail": "volatile이어도 ++ 전체가 하나의 연산은 아닙니다.", + "tone": "neutral" + }, + { + "id": "read-a", + "label": "스레드 A: 0 읽기", + "detail": "아직 계산 결과를 쓰지 않았습니다.", + "tone": "neutral" + }, + { + "id": "read-b", + "label": "스레드 B: 0 읽기", + "detail": "A의 쓰기 전이므로 같은 값을 읽을 수 있습니다.", + "tone": "neutral" + }, + { + "id": "write-a", + "label": "스레드 A: 1 쓰기", + "detail": "자신이 읽은 0에 1을 더한 값입니다.", + "tone": "neutral" + }, + { + "id": "write-b", + "label": "스레드 B: 1 쓰기", + "detail": "두 증가가 끝났어도 최종값은 1입니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "initial", + "to": "read-a", + "label": "설명용 실행 순서" + }, + { + "from": "read-a", + "to": "read-b", + "label": "A 쓰기 전에 B 읽기가 끼어듦" + }, + { + "from": "read-b", + "to": "write-a", + "label": "A가 계산한 결과 기록" + }, + { + "from": "write-a", + "to": "write-b", + "label": "B도 이전에 읽은 값으로 기록" + } + ] + }, + { + "id": "atomic", + "title": "AtomicInteger.incrementAndGet()", + "nodes": [ + { + "id": "initial", + "label": "초기값 0", + "detail": "각 증가를 원자 연산으로 요청합니다.", + "tone": "neutral" + }, + { + "id": "first", + "label": "한 증가 완료 → 1", + "detail": "어느 스레드가 먼저인지는 고정하지 않습니다.", + "tone": "accent" + }, + { + "id": "second", + "label": "다른 증가 완료 → 2", + "detail": "두 작업의 완료를 확인한 뒤 결과를 관찰합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "initial", + "to": "first", + "label": "원자적으로 1 증가" + }, + { + "from": "first", + "to": "second", + "label": "다음 원자적 증가" + } + ] + } + ], + "takeaway": "volatile의 가시성·순서 보장과 복합 연산의 원자성을 혼동하지 마세요.", + "limits": [ + "화살표는 가능한 실행 사례를 설명하며 JMM의 happens-before 관계도나 실제 시간 측정 결과가 아닙니다.", + "AtomicInteger 하나의 연산이 여러 필드나 DB의 업무 불변식까지 원자적으로 만들지는 않습니다. 기준 예제는 Future.get으로 작업 완료를 기다립니다." + ], + "sources": [ + { + "title": "JLS — Memory Model and happens-before", + "url": "https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.5", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — AtomicInteger.incrementAndGet", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/atomic/AtomicInteger.html#incrementAndGet--", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — ExecutorService memory consistency", + "url": "https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html", + "edition": "Java SE 8", + "checkedAt": "2026-10-12" + } + ] + }, + "jpa-state": { + "id": "viz-jpa-managed-detached", + "title": "같은 필드 변경, 다른 영속 상태", + "kind": "comparison", + "afterSection": 0, + "caption": "각 패널은 DB 수량 2에서 별도로 시작하는 테스트 사례입니다. 관리 여부와 DB 동기화를 구분합니다.", + "altText": "관리 상태의 주문 수량을 4로 바꾼 뒤 트랜잭션을 커밋하면 새 EntityManager 조회에서 4가 보입니다. EntityManager를 닫고 분리된 주문을 7로 바꾸면 DB에는 자동 반영되지 않아 2가 남습니다.", + "readingOrder": "두 패널을 독립된 실행으로 비교하세요. 첫 패널의 최종 DB 값이 두 번째 패널의 시작 값은 아닙니다.", + "panels": [ + { + "id": "managed", + "title": "A. 관리 상태에서 변경 후 커밋", + "nodes": [ + { + "id": "managed", + "label": "관리 객체 · quantity = 2", + "detail": "활성 트랜잭션의 EntityManager가 관리합니다.", + "tone": "neutral" + }, + { + "id": "changed", + "label": "changeQuantity(4)", + "detail": "객체의 상태가 바뀝니다.", + "tone": "neutral" + }, + { + "id": "commit", + "label": "트랜잭션 commit 호출", + "detail": "필요한 flush와 커밋이 성공합니다.", + "tone": "neutral" + }, + { + "id": "stored", + "label": "새 조회에서 DB 수량 4", + "detail": "다른 EntityManager에서도 확정된 변경을 읽습니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "managed", + "to": "changed", + "label": "업무 메서드 호출" + }, + { + "from": "changed", + "to": "commit", + "label": "이 예제에서 커밋 시 동기화" + }, + { + "from": "commit", + "to": "stored", + "label": "커밋 성공 후 새로 조회" + } + ] + }, + { + "id": "detached", + "title": "B. 컨텍스트 종료 후 객체만 변경", + "nodes": [ + { + "id": "managed", + "label": "조회한 객체 · quantity = 2", + "detail": "별도 실행의 초기 DB 값은 2입니다.", + "tone": "neutral" + }, + { + "id": "close", + "label": "EntityManager.close()", + "detail": "객체가 이 컨텍스트의 관리 범위를 벗어납니다.", + "tone": "neutral" + }, + { + "id": "changed", + "label": "분리 객체를 quantity = 7로", + "detail": "일반 Java 객체의 값은 바뀝니다.", + "tone": "neutral" + }, + { + "id": "stored", + "label": "새 조회에서 DB 수량 2", + "detail": "분리 객체 변경은 자동 저장되지 않습니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "managed", + "to": "close", + "label": "컨텍스트 종료" + }, + { + "from": "close", + "to": "changed", + "label": "detached 객체 메서드 호출" + }, + { + "from": "changed", + "to": "stored", + "label": "자동 UPDATE 없음 · DB 값 유지" + } + ] + } + ], + "takeaway": "setter나 업무 메서드의 호출만 보고 DB 저장 여부를 판단하지 말고 영속 상태와 트랜잭션 경계를 확인하세요.", + "limits": [ + "flush는 SQL 동기화이고 commit은 트랜잭션 확정입니다. SQL은 commit보다 먼저 실행될 수도 있습니다.", + "persist·remove·merge 전체 상태도를 생략했습니다. 특히 merge를 원래 객체가 자동 재부착되는 과정으로 표현하지 않습니다." + ], + "sources": [ + { + "title": "Hibernate User Guide — Persistence Context", + "url": "https://docs.hibernate.org/orm/6.6/userguide/html_single/#pc", + "edition": "Hibernate ORM 6.6", + "checkedAt": "2026-10-12" + }, + { + "title": "Jakarta Persistence specification", + "url": "https://jakarta.ee/specifications/persistence/3.1/", + "edition": "Jakarta Persistence 3.1", + "checkedAt": "2026-10-12" + } + ] + }, + "tx-proxy": { + "id": "viz-tx-proxy-boundary", + "title": "같은 메서드 이름, 다른 트랜잭션 진입", + "kind": "comparison", + "afterSection": 1, + "caption": "호출 전에 열린 트랜잭션이 없는 이 수업의 두 테스트를 비교합니다. INSERT 뒤에는 모두 IllegalStateException이 발생합니다.", + "altText": "외부에서 reserveThenFail을 프록시로 호출하면 트랜잭션이 열리고 예외 전파 후 롤백되어 행이 0개입니다. 트랜잭션이 없는 bypassProxy가 this.reserveThenFail을 부르면 새 조언이 적용되지 않아 자동 커밋된 행 1개가 남습니다.", + "readingOrder": "두 패널에서 프록시의 트랜잭션 진입 여부를 먼저 확인하고, 같은 예외가 난 뒤 DB 상태를 비교하세요.", + "panels": [ + { + "id": "external", + "title": "A. 외부에서 @Transactional 메서드 호출", + "nodes": [ + { + "id": "caller", + "label": "외부 호출자", + "detail": "Spring이 제공한 프록시 빈을 호출합니다.", + "tone": "neutral" + }, + { + "id": "proxy", + "label": "프록시가 트랜잭션을 엶", + "detail": "reserveThenFail의 트랜잭션 조언에 진입합니다.", + "tone": "accent" + }, + { + "id": "target", + "label": "INSERT 후 RuntimeException", + "detail": "예외를 잡아 숨기지 않고 호출 밖으로 보냅니다.", + "tone": "neutral" + }, + { + "id": "database", + "label": "DB 행 수: 0", + "detail": "프록시가 실패를 받아 트랜잭션을 롤백합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "caller", + "to": "proxy", + "label": "외부에서 reserveThenFail 호출" + }, + { + "from": "proxy", + "to": "target", + "label": "트랜잭션 안에서 대상 실행" + }, + { + "from": "target", + "to": "database", + "label": "예외가 프록시까지 전파 → 롤백" + } + ] + }, + { + "id": "self", + "title": "B. 트랜잭션 없는 메서드의 자기 호출", + "nodes": [ + { + "id": "caller", + "label": "bypassProxy로 외부 진입", + "detail": "이 메서드에는 @Transactional이 없습니다.", + "tone": "neutral" + }, + { + "id": "self", + "label": "this.reserveThenFail()", + "detail": "같은 대상 객체 안에서 호출합니다.", + "tone": "neutral" + }, + { + "id": "target", + "label": "INSERT 후 RuntimeException", + "detail": "내부 메서드의 트랜잭션 조언을 새로 통과하지 않습니다.", + "tone": "warning" + }, + { + "id": "database", + "label": "DB 행 수: 1", + "detail": "이 예제에서는 INSERT가 자동 커밋되어 남습니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "caller", + "to": "self", + "label": "현재 트랜잭션 없이 내부 호출" + }, + { + "from": "self", + "to": "target", + "label": "프록시 재진입 없음" + }, + { + "from": "target", + "to": "database", + "label": "이미 커밋된 INSERT는 예외만으로 되돌아가지 않음" + } + ] + } + ], + "takeaway": "어노테이션의 존재와 호출이 실제로 조언을 통과하는지를 따로 확인하세요.", + "limits": [ + "자기 호출이 기존 트랜잭션을 없애는 것은 아닙니다. 바깥에서 이미 열린 트랜잭션이 있으면 그 안에서 실행될 수 있습니다.", + "기본 프록시 모드·이 수업의 DataSource와 기본 RuntimeException 롤백 정책에 한정합니다. 전파 옵션·AspectJ weaving·checked 예외 정책은 생략합니다." + ], + "sources": [ + { + "title": "Spring — Using @Transactional, proxy mode", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/annotations.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring — Rolling Back a Declarative Transaction", + "url": "https://docs.spring.io/spring-framework/reference/6.2/data-access/transaction/declarative/rolling-back.html", + "edition": "Spring Framework 6.2", + "checkedAt": "2026-10-12" + } + ] + }, + "security-csrf": { + "id": "viz-security-independent-conditions", + "title": "한 조건씩 바꾸어 실패 이유 구별", + "kind": "comparison", + "afterSection": 2, + "caption": "POST /orders에 보내는 네 개의 독립 요청입니다. 필터의 고정 실행 순서를 나타내는 그림이 아닙니다.", + "altText": "유효한 CSRF 토큰을 가진 미인증 요청은 401입니다. 인증된 VIEWER는 토큰이 있어도 403입니다. BUYER도 CSRF 토큰이 없으면 403이고, BUYER와 유효한 토큰을 함께 가진 요청은 200입니다.", + "readingOrder": "각 패널의 요청 전제와 응답을 한 쌍으로 읽으세요. 패널 사이에 실행 순서나 상태 전달은 없습니다.", + "panels": [ + { + "id": "anonymous", + "title": "미인증 · CSRF 조건은 충족", + "nodes": [ + { + "id": "request", + "label": "인증 없음 + 유효 CSRF", + "detail": "인증 실패만 관찰할 수 있도록 토큰은 제공합니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "응답 401", + "detail": "이 수업의 HTTP Basic 설정에서 인증이 필요합니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "request", + "to": "result", + "label": "인증 조건 미충족" + } + ] + }, + { + "id": "role", + "title": "VIEWER · CSRF 조건은 충족", + "nodes": [ + { + "id": "request", + "label": "VIEWER 인증 + 유효 CSRF", + "detail": "인증은 되었지만 BUYER 역할이 없습니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "응답 403", + "detail": "역할 권한이 부족합니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "request", + "to": "result", + "label": "BUYER 권한 미충족" + } + ] + }, + { + "id": "csrf", + "title": "BUYER · CSRF 토큰 누락", + "nodes": [ + { + "id": "request", + "label": "BUYER 인증 + CSRF 없음", + "detail": "인증과 역할을 갖춰 CSRF 실패를 분리합니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "응답 403", + "detail": "상태 변경 요청의 CSRF 보호에 의해 거절됩니다.", + "tone": "warning" + } + ], + "edges": [ + { + "from": "request", + "to": "result", + "label": "CSRF 조건 미충족" + } + ] + }, + { + "id": "accepted", + "title": "BUYER · CSRF 조건도 충족", + "nodes": [ + { + "id": "request", + "label": "BUYER 인증 + 유효 CSRF", + "detail": "이 엔드포인트의 세 조건을 충족합니다.", + "tone": "neutral" + }, + { + "id": "result", + "label": "응답 200", + "detail": "컨트롤러가 이 예제의 정상 요청을 처리합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "request", + "to": "result", + "label": "이 예제의 요청 허용" + } + ] + } + ], + "takeaway": "401이나 403 하나만 보고 원인을 단정하지 말고 다른 전제를 고정해 실패 조건을 검사하세요.", + "limits": [ + "응답 코드는 이 수업의 Spring Security 설정과 테스트 계약입니다. 모든 인증 방식이나 설정에서 같은 응답을 보장하지 않습니다.", + "자원 소유권·브라우저 쿠키 정책·TLS·전체 필터 순서는 생략했습니다. BUYER 역할이 다른 사용자의 주문 소유권을 대신하지 않습니다." + ], + "sources": [ + { + "title": "Spring Security — CSRF", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/exploits/csrf.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring Security — Authorize HttpServletRequests", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/authorization/authorize-http-requests.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + } + ] + }, + "jwt-validation": { + "id": "viz-jwt-acceptance", + "title": "서명 성공만으로 토큰을 수용하지 않기", + "kind": "relationship", + "afterSection": 1, + "caption": "디코더의 형식·헤더 검사를 통과한 토큰을 전제로, 이 실습의 주요 수용 조건을 비교합니다. 모두 충족해야 하며 나열은 실제 검사 실행 순서가 아닙니다.", + "altText": "디코더의 형식·헤더 검사를 통과한 토큰이 전제입니다. 허용된 RS256 서명과 주어진 RSA 공개키, 신뢰한 issuer, orders-api audience, 비어 있지 않은 subject, 유효한 시간 조건이 모두 필요합니다. 한 조건만 통과한 것은 충분하지 않습니다.", + "readingOrder": "각 조건 카드에서 수용 카드로 이어지는 AND 관계를 읽으세요. 어느 한 조건이라도 실패하면 수용할 수 없습니다.", + "panels": [ + { + "id": "conditions", + "title": "이 과제의 수용 정책 · 모든 조건 AND", + "nodes": [ + { + "id": "signature", + "label": "허용 알고리즘·키로 서명 검증", + "detail": "RS256과 실행 시 전달한 RSA 공개키를 사용합니다.", + "tone": "neutral" + }, + { + "id": "issuer", + "label": "신뢰한 issuer", + "detail": "https://training-issuer.invalid와 일치해야 합니다.", + "tone": "neutral" + }, + { + "id": "audience", + "label": "우리 API가 audience에 포함", + "detail": "orders-api가 aud에 있어야 합니다.", + "tone": "neutral" + }, + { + "id": "subject", + "label": "필수 subject", + "detail": "sub가 존재하고 공백이 아니어야 합니다.", + "tone": "neutral" + }, + { + "id": "time", + "label": "필수 exp + 시간 조건", + "detail": "exp는 현재보다 뒤여야 합니다. nbf가 있으면 현재 이하여야 합니다.", + "tone": "neutral" + }, + { + "id": "accepted", + "label": "전제 아래 모든 조건 충족 → 수용", + "detail": "그림의 조건 중 하나라도 실패하면 거절합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "signature", + "to": "accepted", + "label": "AND: 서명 조건 필수" + }, + { + "from": "issuer", + "to": "accepted", + "label": "AND: 발급자 조건 필수" + }, + { + "from": "audience", + "to": "accepted", + "label": "AND: 대상 조건 필수" + }, + { + "from": "subject", + "to": "accepted", + "label": "AND: 주체 조건 필수" + }, + { + "from": "time", + "to": "accepted", + "label": "AND: 시간 조건 필수" + } + ] + } + ], + "takeaway": "토큰을 읽을 수 있다는 사실, 서명이 유효하다는 사실, 우리 API가 받아들인다는 판단을 구분하세요.", + "limits": [ + "typ 헤더 등 NimbusJwtDecoder의 기본 형식·헤더 검사는 도식에서 생략했습니다. Spring Security 6.5의 공개키 디코더는 기본 typ 검증을 적용합니다.", + "필수 claim과 clock skew 0은 이 실습의 정책입니다. 모든 JWT에 동일한 필수 claim을 요구하는 일반 규칙으로 확대하지 않습니다.", + "암호화·권한 scope 매핑·키 회전·폐기·원격 JWKS·실제 OIDC 로그인 전체 흐름은 포함하지 않습니다." + ], + "sources": [ + { + "title": "Spring Security — JWT Resource Server validation", + "url": "https://docs.spring.io/spring-security/reference/6.5/servlet/oauth2/resource-server/jwt.html", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + }, + { + "title": "RFC 7519 — JSON Web Token claims", + "url": "https://www.rfc-editor.org/rfc/rfc7519", + "edition": "RFC 7519", + "checkedAt": "2026-10-12" + }, + { + "title": "Spring Security API — NimbusJwtDecoder default typ validation", + "url": "https://docs.spring.io/spring-security/reference/6.5/api/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.PublicKeyJwtDecoderBuilder.html#validateType(boolean)", + "edition": "Spring Security 6.5", + "checkedAt": "2026-10-12" + } + ] + }, + "kafka-idempotency": { + "id": "viz-kafka-db-offset", + "title": "DB 커밋과 오프셋 커밋 사이의 빈틈", + "kind": "flow", + "afterSection": 2, + "caption": "이 과제의 DB 우선 처리 순서와 중간 장애 가능성을 설명합니다. 재전달 장애 시나리오를 실제 실행한 결과 그림은 아닙니다.", + "altText": "메시지를 받은 뒤 eventId 기록과 주문 변경을 같은 DB 트랜잭션에 커밋하고 그다음 오프셋을 커밋합니다. 두 커밋 사이에 중단되면 메시지를 다시 받을 수 있어 같은 eventId와 payload의 DB 효과를 반복하지 않아야 합니다.", + "readingOrder": "첫 패널에서 두 커밋의 순서를 확인한 뒤, 두 번째 패널에서 그 사이에 중단되었을 때의 가능성을 읽으세요.", + "panels": [ + { + "id": "normal", + "title": "1. 정상 처리의 두 커밋", + "nodes": [ + { + "id": "received", + "label": "Kafka 메시지 수신", + "detail": "자동 오프셋 커밋을 끈 소비자가 읽습니다.", + "tone": "neutral" + }, + { + "id": "db-work", + "label": "같은 DB 트랜잭션", + "detail": "processed_events의 eventId 기록과 orders 변경을 함께 수행합니다.", + "tone": "neutral" + }, + { + "id": "db-commit", + "label": "DB commit 성공", + "detail": "업무 효과는 DB에 반영됐습니다.", + "tone": "accent" + }, + { + "id": "offset", + "label": "그다음 offset commit", + "detail": "소비자의 처리 위치를 Kafka에 기록합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "received", + "to": "db-work", + "label": "이벤트를 처리" + }, + { + "from": "db-work", + "to": "db-commit", + "label": "두 DB 변경을 함께 확정" + }, + { + "from": "db-commit", + "to": "offset", + "label": "DB 성공 후 소비 위치 커밋" + } + ] + }, + { + "id": "gap", + "title": "2. DB 성공 뒤 offset 커밋 전에 중단", + "nodes": [ + { + "id": "gap", + "label": "DB에는 효과가 남음", + "detail": "오프셋은 아직 커밋되지 않았을 수 있습니다.", + "tone": "warning" + }, + { + "id": "redelivery", + "label": "같은 이벤트를 다시 받을 수 있음", + "detail": "복구 후 소비 위치에 따라 재처리 가능성이 생깁니다.", + "tone": "neutral" + }, + { + "id": "dedupe", + "label": "eventId와 payload 확인", + "detail": "같은 eventId·같은 orderId이면 이미 반영된 요청으로 판단합니다.", + "tone": "neutral" + }, + { + "id": "no-repeat", + "label": "DB 효과를 반복하지 않음", + "detail": "동일 payload 중복은 false를 반환합니다. 다른 payload이면 거절합니다.", + "tone": "accent" + } + ], + "edges": [ + { + "from": "gap", + "to": "redelivery", + "label": "두 커밋은 별도의 성공 지점" + }, + { + "from": "redelivery", + "to": "dedupe", + "label": "처리 이력과 입력을 대조" + }, + { + "from": "dedupe", + "to": "no-repeat", + "label": "동일한 업무 요청의 멱등 처리" + } + ] + } + ], + "takeaway": "전달 횟수와 DB 반영 횟수를 분리하고, 서로 다른 커밋 사이의 실패를 설계에 포함하세요.", + "limits": [ + "제공 테스트는 같은 payload를 두 메시지로 보내는 모델입니다. 실제 브로커 장애·프로세스 재시작 복구를 재현한 검사는 아닙니다.", + "이 과제의 단일 DB·eventId 유일성 계약에 한정합니다. Kafka와 임의의 외부 DB가 자동으로 exactly-once가 된다는 뜻이 아닙니다." + ], + "sources": [ + { + "title": "KafkaConsumer — Manual Offset Control and delivery semantics", + "url": "https://kafka.apache.org/39/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html", + "edition": "Kafka 3.9", + "checkedAt": "2026-10-12" + }, + { + "title": "Java API — JDBC Connection transactions", + "url": "https://docs.oracle.com/en/java/javase/17/docs/api/java.sql/java/sql/Connection.html", + "edition": "Java SE 17", + "checkedAt": "2026-10-12" + } + ] + } +}; diff --git a/dist/editor.js b/dist/editor.js new file mode 100644 index 0000000..1bd60f6 --- /dev/null +++ b/dist/editor.js @@ -0,0 +1,124 @@ +import { Compartment, EditorSelection, EditorState, Transaction } from '@codemirror/state'; +import { EditorView, drawSelection, dropCursor, highlightActiveLine, highlightActiveLineGutter, highlightSpecialChars, keymap, lineNumbers } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap, indentMore } from '@codemirror/commands'; +import { LanguageSupport, bracketMatching, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language'; +import { javaLanguage } from '@codemirror/lang-java'; +import { highlightTree, styleTags, tagHighlighter, tags } from '@lezer/highlight'; + +// These are syntactic categories. No symbol resolver or Java compiler runs in the editor. +const learningJava = javaLanguage.configure({ + props: [styleTags({ + Definition: tags.definition(tags.variableName), + Identifier: tags.variableName, + 'ClassDeclaration/Definition InterfaceDeclaration/Definition EnumDeclaration/Definition RecordDeclaration/Definition': tags.className, + 'AnnotationTypeDeclaration/Identifier': tags.className, + 'TypeParameter/Definition': tags.typeName, + 'MethodDeclaration/Definition ConstructorDeclaration/Definition': tags.function(tags.variableName), + 'MethodName/Identifier': tags.function(tags.variableName), + 'record sealed permits': tags.definitionKeyword, + })], +}); + +const javaHighlighter = tagHighlighter([ + { tag: tags.keyword, class: 'tok-keyword' }, + { tag: tags.modifier, class: 'tok-keyword' }, + { tag: tags.className, class: 'tok-class' }, + { tag: tags.typeName, class: 'tok-type' }, + { tag: tags.variableName, class: 'tok-variable' }, + { tag: tags.function(tags.variableName), class: 'tok-method' }, + { tag: [tags.string, tags.character], class: 'tok-string' }, + { tag: tags.comment, class: 'tok-comment' }, + { tag: [tags.number, tags.bool, tags.null], class: 'tok-number' }, + { tag: tags.operator, class: 'tok-operator' }, + { tag: tags.punctuation, class: 'tok-punctuation' }, + { tag: tags.meta, class: 'tok-annotation' }, +]); + +const THEMES = new Set(['dark', 'light', 'contrast']); +function normalizeTheme(theme) { return THEMES.has(theme) ? theme : 'dark'; } +function colorMode(theme) { return EditorView.theme({}, { dark: normalizeTheme(theme) !== 'light' }); } + +export function javaHighlightSpans(source) { + const spans = []; + highlightTree(learningJava.parser.parse(source), javaHighlighter, (from, to, classes) => spans.push({ from, to, classes })); + return spans; +} + +export function highlightJavaCode(target, source) { + const fragment = target.ownerDocument.createDocumentFragment(); + let position = 0; + for (const { from, to, classes } of javaHighlightSpans(source)) { + if (from > position) fragment.append(target.ownerDocument.createTextNode(source.slice(position, from))); + const span = target.ownerDocument.createElement('span'); + span.className = classes; + span.textContent = source.slice(from, to); + fragment.append(span); + position = to; + } + if (position < source.length) fragment.append(target.ownerDocument.createTextNode(source.slice(position))); + target.replaceChildren(fragment); +} + +export function createJavaEditor({ parent, source, theme = 'dark', wrap = false, language = 'java', readOnly = false, label, descriptionId, maxLength = 50000, onChange, onBlur, onLimit }) { + const mode = new Compartment(); + const wrapping = new Compartment(); + let suppressChange = false; + let destroyed = false; + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: source, + extensions: [ + language === 'java' ? new LanguageSupport(learningJava) : [], + syntaxHighlighting(javaHighlighter), + EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly), + lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), highlightSpecialChars(), + drawSelection(), dropCursor(), history(), bracketMatching(), indentOnInput(), + EditorState.tabSize.of(4), indentUnit.of(' '), + // Deliberately omit indentWithTab: Tab remains standard keyboard navigation. + keymap.of([...defaultKeymap, ...historyKeymap]), + mode.of(colorMode(theme)), + wrapping.of(wrap ? EditorView.lineWrapping : []), + EditorView.contentAttributes.of({ + role: 'textbox', 'aria-label': label, 'aria-multiline': 'true', + 'aria-describedby': descriptionId, 'data-testid': 'code-editor-input', + 'aria-readonly': String(readOnly), tabindex: '0', + spellcheck: 'false', autocorrect: 'off', autocapitalize: 'off', autocomplete: 'off', + }), + EditorState.transactionFilter.of((transaction) => { + if (!transaction.docChanged || transaction.newDoc.length <= maxLength) return transaction; + queueMicrotask(() => { if (!destroyed) onLimit?.(); }); + return []; + }), + EditorView.updateListener.of((update) => { + if (update.docChanged && !suppressChange) onChange?.(update.state.doc.toString()); + }), + EditorView.domEventHandlers({ blur: () => { onBlur?.(); return false; } }), + ], + }), + }); + parent.dataset.editorEngine = 'codemirror'; + return { + getValue: () => view.state.doc.toString(), + focus: () => view.focus(), + selectAll: () => { + view.dispatch({ selection: EditorSelection.single(0, view.state.doc.length) }); + view.focus(); + }, + indent: () => { if (!readOnly) indentMore(view); view.focus(); }, + setValue: (value) => { + if (value === view.state.doc.toString()) return; + suppressChange = true; + try { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: value }, + selection: EditorSelection.cursor(0), + annotations: Transaction.addToHistory.of(false), + }); + } finally { suppressChange = false; } + }, + setTheme: (nextTheme) => view.dispatch({ effects: mode.reconfigure(colorMode(nextTheme)) }), + setWrap: (nextWrap) => view.dispatch({ effects: wrapping.reconfigure(nextWrap ? EditorView.lineWrapping : []) }), + destroy: () => { destroyed = true; view.destroy(); }, + }; +} diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..c20fed1 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,83 @@ + + + + + + + + Java Edu — 이해에서 설계까지 + + + + + + + + + +
+
+

Java 학습실

0 / 34 완료
+
+ + 이 브라우저에 저장 +
+ +
+
학습 콘텐츠를 불러오고 있습니다…
+ +
+
javaedu.

공식 문서로 이해하고, 코드로 확인하는 시간.

+
+ + +
+
+ +

LEARNING GUIDE

이 학습실을 사용하는 방법

다른 언어 경험자의 Java 전환과 현직 개발자의 심화 학습을 함께 지원합니다. 이론 → 예제 → 문제 → 실무 → 코드 실습 순서로 진행해 보세요.

  • 학습 기준: Java 8·11·17·21. 선택 버전에 맞는 수업을 표시합니다.
  • 객관식 성취: 한 수업의 모든 문제에 정답을 제출하면 기록됩니다.
  • 실습 통과: 현재 작성 코드로 제출한 테스트가 모두 통과하면 기록됩니다. 두 조건을 모두 충족하면 수업 완료입니다.
  • 설계 기록: 나의 판단을 정리하는 메모입니다. 자동 평가나 정답 판정이 아닙니다.
  • 실행 환경: 웹의 코어 실습은 실제 JVM 21에서 실행합니다. Windows 통합 패키지에는 Java 21과 Maven이 포함되어 코어와 기본 실무 프로젝트를 실행할 수 있습니다. 실제 Java 8·11·17 JVM이 필요한 경우 고급 설정에서 직접 등록할 수 있습니다. 컴파일 대상과 실제 JVM은 실행 화면에 따로 표시됩니다.
  • 저장: 웹은 현재 브라우저에, Windows 앱은 이 PC의 앱 데이터에 코드·메모·진도를 저장합니다. 앱 기록은 재시작 후에도 유지됩니다. 웹과 앱의 기록은 서로 동기화되지 않습니다.

코드 예제와 실습에서 Java 구문 강조와 3가지 테마를 사용할 수 있습니다. 실습은 줄 번호·자동 들여쓰기·괄호 짝 표시를 지원합니다. 색상은 코드의 구문을 구분하며 컴파일 결과를 대신하지 않습니다.

+

LEARNING RECORDS

학습 기록을 초기화할까요?

이 브라우저의 진도, 파일별 작성 코드와 설계 메모를 모두 지웁니다. 현재 학습 기준 버전은 유지됩니다.

+

LOCAL EXECUTION

내 PC의 실행 환경

실행 도구를 확인하고 있습니다. 코드는 실행·제출 버튼을 눌렀을 때 실행됩니다.

+

WINDOWS APP

설치 한 번으로 코드 실습까지

Windows x64 통합 패키지에 Java 21과 Maven이 함께 들어 있습니다. 도구를 별도로 설치하거나 폴더를 선택하지 않고 코어와 기본 실무 프로젝트 20개를 시작할 수 있습니다.

  • EXE로 설치하거나 ZIP의 모든 파일을 풀고 Java Edu.exe를 실행하세요. 실행 파일만 따로 옮기면 동작하지 않습니다.
  • 앱의 실행 설정에서 권한에 동의하면 코어 실습을 바로 실행할 수 있습니다. 기본 실제 JVM은 Java 21이며 Java 8·11·17·21 컴파일 대상을 지원합니다.
  • Spring 등 기본 실무 수업은 처음 환경 준비 시 인터넷 연결이 필요합니다. 필요한 라이브러리를 Maven Central에서 자동 다운로드합니다.
  • Redis·Kafka 실제 서비스 연동은 선택 실습입니다. 해당 서버는 패키지에 포함되지 않습니다.
  • 실제 Java 8·11·17 JVM의 차이를 비교하려면 고급 설정에서 해당 JDK를 선택해 등록할 수 있습니다.
  • Java 코드는 PC의 파일·네트워크에 접근할 수 있습니다. 격리된 보안 샌드박스가 아니므로 신뢰하는 코드를 실행하세요.

통합 패키지의 버전과 다운로드 파일은 GitHub 릴리스에서 확인하세요. 웹과 앱의 학습 기록은 각각 저장됩니다.

+ + + diff --git a/dist/styles.css b/dist/styles.css new file mode 100644 index 0000000..0e06bac --- /dev/null +++ b/dist/styles.css @@ -0,0 +1,767 @@ +@charset "UTF-8"; + +:root { + color-scheme: light; + --paper: #f4f3ed; + --card: #fffefa; + --ink: #233a34; + --muted: #66716a; + --green: #173d35; + --green-soft: #e9efe7; + --line: #dddfd4; + --rust: #a54b2b; + --rust-soft: #fcf0e8; + --yellow: #d9e5a5; + --radius: 16px; + --font: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", sans-serif; + --mono: "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 1.5rem; } +body { margin: 0; background: var(--paper); color: var(--ink); font: 0.9375rem/1.7 var(--font); -webkit-text-size-adjust: 100%; } +button, input, select, textarea { font: inherit; } +button, a, input, select, textarea { -webkit-tap-highlight-color: transparent; } +button { color: inherit; cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .58; } +button, input, select, textarea, a { touch-action: manipulation; } +button { -webkit-user-select: none; user-select: none; } +input, textarea { min-width: 0; } +a { color: var(--green); text-underline-offset: 3px; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible, summary:focus-visible { outline: 3px solid #b75d35; outline-offset: 4px; } +button:focus:not(:focus-visible), [tabindex="-1"]:focus { outline: none; } +button, h1, h2, h3, p, ul, ol, pre { margin: 0; } +button { border: 0; background: none; } +ul, ol { padding-left: 1.35rem; } +[hidden] { display: none !important; } +.icon-library { position: absolute; width: 0; height: 0; overflow: hidden; } +.icon { width: 19px; height: 19px; flex: 0 0 19px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.skip-link { position: fixed; z-index: 200; top: -6rem; left: 1rem; padding: .7rem 1rem; background: var(--green); color: white; border-radius: 8px; } +.skip-link:focus { top: 1rem; } +.site-header { min-height: 68px; max-width: 1920px; padding: .65rem clamp(16px, 2vw, 32px); margin: auto; display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.5rem; border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: .65rem; color: var(--green); font-size: 1.65rem; font-weight: 800; letter-spacing: -.09rem; text-decoration: none; line-height: 1.2; } +.brand-light { font-weight: 400; } +.brand-mark { display: inline-flex; flex-shrink: 0; width: 2.25rem; height: 2.375rem; align-items: center; justify-content: center; padding-bottom: .25rem; border-radius: .5625rem; background: var(--green); color: white; font-family: Georgia, serif; font-weight: 600; font-style: italic; font-size: 1.9rem; line-height: 1; } +.brand-mark span { color: var(--yellow); font-style: normal; } +.header-navigation { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem 2rem; max-width: 100%; } +.header-link { padding: .5rem 0; font-size: .875rem; color: var(--muted); text-decoration: none; white-space: nowrap; } +.header-link.is-current { color: var(--green); font-weight: 700; position: relative; } +.header-link.is-current::after { content: ""; width: 4px; height: 4px; border-radius: 50%; position: absolute; bottom: 0; left: calc(50% - 2px); background: var(--green); } +.header-tools { display: flex; align-items: center; gap: 1rem; margin-left: auto; } +.storage-indicator { display: inline-flex; align-items: center; gap: .5rem; color: var(--muted); font-size: .75rem; } +.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex: 0 0 6px; background: #67866e; } +.storage-indicator.is-unavailable .status-dot { background: var(--rust); } +.icon-button { display: inline-flex; align-items: center; justify-content: center; gap: .4rem; min-width: 42px; min-height: 42px; padding: .55rem; border-radius: 8px; } +.icon-button:hover { background: var(--green-soft); } +.mobile-menu-toggle, .mobile-menu-close { display: none; } +.page-shell { width: min(100%, 1920px); margin: auto; padding: 0 clamp(16px, 2vw, 32px); } +.intro { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1.1rem; padding: .75rem 0; border-bottom: 1px solid var(--line); } +.learning-status-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem .8rem; } +.eyebrow, .small-label { font-size: .6875rem; font-weight: 700; line-height: 1.5; letter-spacing: .13em; } +.eyebrow { color: var(--rust); display: flex; align-items: center; gap: .6rem; } +.eyebrow-line { width: 21px; height: 1px; background: var(--rust); } +.intro h1 { font-size: 1rem; font-weight: 750; line-height: 1.5; letter-spacing: -.025em; margin: 0; } +.intro h1 span { color: var(--green); } +.intro-description { color: var(--muted); margin-top: 1rem; font-size: .875rem; line-height: 1.85; letter-spacing: -.01em; } +.intro-facts { display: flex; flex-wrap: wrap; gap: 1.4rem; color: var(--muted); margin-top: 1.45rem; font-size: .75rem; } +.intro-facts > span { display: inline-flex; align-items: baseline; gap: .4rem; } +.intro-facts > span + span { border-left: 1px solid #cbd0c4; padding-left: 1.4rem; } +.intro-facts strong { font-family: var(--mono); font-size: .9375rem; color: var(--green); font-weight: 500; } +.journey-card { background: #edf0e7; border: 1px solid #d9dfd0; border-radius: 13px; padding: 1.4rem 1.5rem; position: relative; overflow: hidden; } +.journey-card::before { content: ""; width: 82px; height: 82px; position: absolute; right: -25px; top: -35px; border: 1px solid #d1d9c7; border-radius: 50%; box-shadow: 0 0 0 12px #e3e9db, 0 0 0 13px #d1d9c7, 0 0 0 26px #e3e9db, 0 0 0 27px #d1d9c7; opacity: .45; pointer-events: none; } +.journey-top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem 1rem; position: relative; } +.journey-top .small-label { color: #60725a; } +.local-badge { font-size: .6875rem; color: #536c4e; background: #e4eadc; padding: .2rem .5rem; border-radius: 4px; } +.journey-progress-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .4rem 1rem; margin: 1.2rem 0 .7rem; font-size: .8125rem; font-weight: 600; } +.journey-count { color: #7c8976; font-family: var(--mono); font-size: .75rem; white-space: nowrap; } +.journey-count strong { font-size: 1.55rem; font-weight: 500; color: var(--green); } +.progress-track { height: 5px; border-radius: 4px; background: #d8dfcf; overflow: hidden; } +.progress-track > span { display: block; height: 100%; width: 0; background: var(--green); border-radius: inherit; transition: width .2s; } +.journey-caption { color: #62715a; font-size: .6875rem; margin-top: .55rem; } +.continue-button { margin-top: 1.1rem; padding: .85rem 0 0; border-top: 1px solid #d4dbca; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 1rem; text-align: left; } +.continue-button > span { min-width: 0; } +.continue-button .icon { background: var(--green); color: white; box-sizing: content-box; padding: .6rem; border-radius: 50%; transition: transform .15s; } +.continue-button:hover .icon { transform: translateX(3px); } +.continue-label { display: block; font-size: .6875rem; color: #68795f; margin-bottom: .2rem; } +.continue-button strong { display: block; font-size: .8125rem; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; } +.intro .journey-count strong { font-size: inherit; font-weight: 750; } +.intro .progress-track { flex: 0 1 112px; width: 112px; min-width: 64px; } +.intro .continue-button { flex: 1 1 260px; width: auto; min-width: 0; min-height: 40px; padding: 0; margin: 0; border: 0; gap: .65rem; justify-content: flex-start; } +.intro .continue-button > span { display: flex; flex-wrap: wrap; align-items: baseline; gap: .15rem .55rem; } +.intro .continue-label { margin: 0; } +.intro .continue-button .icon { width: 16px; height: 16px; padding: 0; background: transparent; color: var(--green); } +.intro .storage-indicator { display: inline-flex; font-size: .6875rem; } +.workspace-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--line); font-size: .6875rem; } +.workspace-heading > div { display: flex; align-items: center; gap: .75rem; } +.workspace-kicker { font: .625rem/1.5 var(--mono); letter-spacing: .03em; color: var(--muted); } +.workspace-divider { display: block; width: 1px; height: 12px; background: #bec6b9; } +.official-note { display: flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .625rem; } +.workspace-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); align-items: start; gap: 20px; padding-top: 16px; } +.curriculum-sidebar { min-width: 0; position: sticky; top: 1.5rem; max-height: calc(100dvh - 3rem); overflow-y: auto; scrollbar-width: thin; scrollbar-color: #c7d0bd transparent; } +.sidebar-heading { display: flex; align-items: center; justify-content: space-between; gap: .7rem; margin-bottom: 1.3rem; } +.sidebar-heading .small-label { font-size: .6875rem; letter-spacing: .12em; color: var(--muted); margin-bottom: .35rem; } +.sidebar-heading h2 { font-size: 1.075rem; line-height: 1.5; letter-spacing: -.025em; } +.version-control { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; gap: .4rem; } +.version-control label { font-size: .75rem; font-weight: 600; word-break: keep-all; } +.version-control select { width: 100%; min-height: 2.5rem; padding: .35rem 1.3rem .35rem .65rem; border: 1px solid #cfd6c7; border-radius: 7px; background: var(--card); color: var(--green); font-size: .75rem; font-weight: 600; max-width: 100%; } +.version-control p { grid-column: 1 / -1; color: var(--muted); font-size: .6875rem; } +.track-filter { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; padding: 4px; border-radius: 8px; background: #e9ebe2; margin-top: 1rem; } +.track-filter button { min-width: 0; flex: 1; padding: .4rem .15rem; border-radius: 5px; font-size: .6875rem; min-height: 36px; line-height: 1.5; } +.track-filter button[aria-pressed="true"] { background: var(--card); box-shadow: 0 1px 3px #173d3510; color: var(--green); font-weight: 700; } +.search-field { display: flex; align-items: center; gap: .4rem; margin: .75rem 0 1rem; padding: .55rem .65rem; background: #fffefa; border: 1px solid var(--line); border-radius: 8px; color: #7c867b; } +.search-field .icon { width: 15px; height: 15px; flex-basis: 15px; } +.search-field input { width: 100%; font-size: .75rem; color: var(--ink); border: 0; outline: none; background: transparent; padding: .1rem; } +.search-field:focus-within { outline: 2px solid #a56541; outline-offset: 2px; } +.search-field input:focus-visible { outline: none; } +.filtered-progress { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: .4rem; margin: 0 .2rem .7rem; color: var(--muted); font-size: .6875rem; } +.filtered-progress span:last-child { font-family: var(--mono); color: #617957; } +.lesson-list { display: flex; flex-direction: column; gap: .3rem; } +.lesson-link { text-decoration: none; color: var(--ink); display: flex; align-items: flex-start; gap: .7rem; padding: .65rem .7rem; border-radius: 9px; border: 1px solid transparent; min-height: 58px; } +.lesson-link:hover { background: #e9ede4; } +.lesson-link.is-active { background: var(--green); color: white; box-shadow: 0 4px 10px #173d350a; } +.lesson-index { display: inline-flex; justify-content: center; align-items: center; flex: 0 0 23px; min-height: 23px; margin-top: .05rem; color: #8b9588; font: .625rem/1.5 var(--mono); } +.lesson-link.is-active .lesson-index { color: var(--yellow); } +.lesson-index .icon { width: 15px; height: 15px; } +.lesson-link.is-complete:not(.is-active) .lesson-index { color: #466941; background: #e0e8d7; border-radius: 50%; } +.lesson-link-copy { min-width: 0; flex: 1; } +.lesson-link-title { display: block; font-size: .8125rem; font-weight: 550; line-height: 1.6; letter-spacing: -.02em; overflow-wrap: anywhere; } +.lesson-link-meta { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .2rem; font-size: .625rem; color: #6c7866; } +.lesson-link.is-active .lesson-link-meta { color: #b2c4b4; } +.lesson-link.is-active .lesson-link-title { font-weight: 650; } +.sidebar-footnote { display: flex; align-items: flex-start; gap: .6rem; border-top: 1px solid var(--line); margin-top: 1rem; padding-top: 1rem; color: var(--muted); } +.sidebar-footnote .icon { flex-basis: 16px; width: 16px; height: 16px; margin-top: 3px; } +.sidebar-footnote p { font-size: .6875rem; line-height: 1.8; } +.text-button { color: var(--muted); padding: .4rem 0; font-size: .6875rem; text-align: left; text-decoration: underline; text-underline-offset: 4px; } +.text-button:hover { color: var(--green); } +.reset-records { margin-top: .7rem; font-size: .6875rem; } +.lesson-card { min-width: 0; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: clip; box-shadow: 0 6px 18px #37493103; } +.lesson-header { padding: 20px 24px 8px; } +.lesson-heading-row { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem 1rem; } +.lesson-heading-row h2 { min-width: 0; flex: 1; } +.lesson-status-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; margin-top: .75rem; } +.lesson-status-row > .lesson-position { margin-left: auto; } +.lesson-topline { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.lesson-topline .eyebrow { font-size: .6875rem; letter-spacing: .08em; } +.lesson-position { font: .5625rem/1.5 var(--mono); color: #8a9385; flex-shrink: 0; } +.lesson-header h2 { font-size: clamp(1.5rem, 2.1vw, 1.8rem); font-weight: 750; line-height: 1.45; letter-spacing: -.04em; margin: 0; word-break: keep-all; overflow-wrap: anywhere; } +.lesson-description { font-size: .875rem; line-height: 1.8; color: var(--muted); margin-top: .8rem; overflow-wrap: anywhere; } +.lesson-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .55rem; } +.meta-chip { display: inline-flex; align-items: center; gap: .3rem; padding: .18rem .5rem; border: 1px solid #dde3d5; border-radius: 5px; color: #5d6f51; background: #f1f4ed; font-size: .6875rem; line-height: 1.65; } +.meta-chip .icon { width: 12px; height: 12px; flex-basis: 12px; } +.meta-chip.is-neutral { background: transparent; border-color: var(--line); color: var(--muted); } +.meta-chip.is-preview { background: var(--rust-soft); border-color: #e8cbb8; color: var(--rust); } +.achievement-row { display: flex; flex-wrap: wrap; gap: .4rem .75rem; } +.achievement-badge { display: inline-flex; align-items: center; gap: .25rem; font-size: .6875rem; color: #677361; } +.achievement-badge::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: #cbd1c5; } +.achievement-badge.is-complete { color: #3b694b; font-weight: 650; } +.achievement-badge.is-complete::before { background: #52775a; } +.achievement-badge.is-past { color: var(--rust); } +.achievement-badge.is-past::before { background: #ba7654; } +.lesson-support { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0 20px; padding: 0 24px 10px; } +.lesson-details { flex: 0 1 auto; min-width: 0; max-width: 100%; } +.lesson-details[open] { flex: 1 1 100%; } +.lesson-details > summary { min-height: 44px; padding: .55rem 0; color: var(--green); font-size: .8125rem; cursor: pointer; touch-action: manipulation; overflow-wrap: anywhere; } +.lesson-details > summary:hover { text-decoration: underline; text-underline-offset: 4px; } +.lesson-information { padding: 8px 0 16px; max-width: 60rem; overflow-wrap: anywhere; } +.lesson-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .9rem; margin-top: .75rem; color: var(--muted); font-size: .75rem; } +.lesson-detail-meta .meta-chip { border: 0; background: transparent; padding: 0; font-size: inherit; } +.lesson-information .learning-goals { padding: 0; margin: 1rem 0 0; background: transparent; border: 0; border-radius: 0; } +.lesson-information h3 { font-size: .875rem; margin-bottom: .5rem; } +.lesson-tabs { display: flex; align-items: stretch; gap: .2rem; padding: 0 1.6rem; border-top: 1px solid #e8eadf; border-bottom: 1px solid #e0e3d8; background: #fafbf5; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #bcc8b5 transparent; } +.lesson-tabs button { position: relative; flex: 1 0 auto; font-size: .8125rem; color: #67765d; padding: .95rem .5rem; border-bottom: 2px solid transparent; white-space: nowrap; } +.lesson-tabs button[aria-selected="true"] { color: var(--green); border-bottom-color: var(--green); font-weight: 750; } +.tab-number { font: .5625rem/1.5 var(--mono); margin-right: .25rem; opacity: .65; } +.lesson-panel { min-height: 330px; padding: 24px; overflow-wrap: anywhere; } +.lesson-panel h3 { font-size: 1.0625rem; letter-spacing: -.025em; line-height: 1.6; } +.section-heading { display: flex; align-items: center; gap: .65rem; margin-bottom: .9rem; } +.section-heading .section-number { flex-shrink: 0; white-space: nowrap; font: .6875rem/1.5 var(--mono); color: #8da078; } +.section-caption { font-size: .875rem; color: var(--muted); margin: .45rem 0 1.25rem; line-height: 1.85; } +.learning-goals { background: #f1f4ed; border: 1px solid #e0e6d6; border-radius: 10px; padding: 1.1rem 1.15rem; margin-bottom: 1.8rem; } +.learning-goals .small-label { color: #617455; margin-bottom: .55rem; font-size: .6875rem; } +.learning-goals ul { list-style: none; padding: 0; display: grid; gap: .45rem; } +.learning-goals li { display: flex; gap: .55rem; font-size: .875rem; line-height: 1.75; } +.learning-goals .icon { width: 14px; height: 14px; flex-basis: 14px; color: #628055; margin-top: .2rem; } +.theory-section + .theory-section { margin-top: 1.7rem; padding-top: 1.45rem; border-top: 1px solid #e8eadf; } +.theory-section p { color: #536152; font-size: .9375rem; line-height: 1.95; margin-top: .7rem; white-space: pre-line; } +.learning-guide { container-type: inline-size; container-name: learning-guide; min-width: 0; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--line); color: #425447; font-size: .9375rem; line-height: 1.9; } +.guide-scenario { padding-left: 1rem; border-left: 3px solid #789263; } +.guide-scenario .small-label { color: #546b48; margin-bottom: .4rem; } +.guide-scenario h3 { color: var(--green); } +.guide-scenario-text, .guide-question { margin-top: .75rem; white-space: pre-line; } +.guide-question { color: var(--green); font-weight: 700; } +.guide-section { min-width: 0; margin-top: 24px; } +.guide-section h3 { color: var(--ink); } +.guide-terms-list { margin: .6rem 0 0; max-width: 60rem; } +.guide-term { display: grid; grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); gap: .3rem 1.15rem; padding: .75rem 0; border-bottom: 1px solid #e8eadf; } +.guide-term dt { color: var(--green); font-weight: 700; } +.guide-term dd { margin: 0; white-space: pre-line; } +.guide-code-scope, .guide-code-hint { color: #66716a; font-size: .8125rem; line-height: 1.8; margin-top: .55rem; } +.guide-code-hint { margin-top: -.3rem; } +.guide-code .code-heading { flex-wrap: wrap; } +.guide-code .example-filename { flex: 1 1 13rem; flex-wrap: wrap; } +.guide-code-filename { min-width: 0; } +.guide-code .copy-button { min-height: 44px; } +.guide-code .editor-theme-select { min-height: 44px; } +.guide-code-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem 1rem; padding: .55rem min(.95rem, 16px); background: var(--code-toolbar); border-bottom: 1px solid var(--code-border); color: var(--code-muted); } +.guide-code-tools .code-settings { padding: 0; } +.code-legend-disclosure { min-width: 0; max-width: 100%; } +.code-legend-disclosure[open] { flex: 1 1 100%; } +.code-legend-disclosure > summary { min-height: 44px; padding: .65rem 0; font-size: .75rem; cursor: pointer; touch-action: manipulation; } +.code-legend-disclosure .syntax-legend { padding: .35rem 0; border: 0; } +.code-legend-disclosure > summary:focus-visible { outline-color: var(--code-focus); outline-offset: 2px; } +.guide-code .code-block:focus-visible { outline: 3px solid var(--code-focus); outline-offset: -4px; } +.guide-code .copy-button:focus-visible, .guide-code .editor-theme-select:focus-visible { outline-color: var(--code-focus); outline-offset: 2px; } +.guide-walkthrough ol { margin-top: .7rem; padding-left: 1.65rem; } +.guide-walkthrough li { padding-left: .3rem; white-space: pre-line; } +.guide-walkthrough li + li { margin-top: .75rem; } +.guide-walkthrough li::marker { color: var(--green); font-weight: 700; font-variant-numeric: tabular-nums; } +.guide-prediction-question { margin-top: .6rem; white-space: pre-line; } +.guide-prediction { margin-top: .85rem; border-top: 1px solid #cdd8c4; border-bottom: 1px solid #cdd8c4; } +.guide-prediction summary { padding: .8rem .35rem; color: var(--green); font-weight: 700; cursor: pointer; touch-action: manipulation; } +.guide-prediction summary::marker { color: #546b48; } +.guide-prediction summary:hover { background: #f1f4ed; } +.guide-prediction-answer { padding: .3rem .35rem 1rem; } +.guide-answer-label { color: var(--green); font-size: .8125rem; font-weight: 700; } +.guide-answer-label:not(:first-child) { margin-top: .85rem; } +.guide-answer-text, .guide-answer-explanation { margin-top: .25rem; white-space: pre-line; } +.guide-takeaway { padding: 1rem 0 0; margin-top: 1.6rem; } +.guide-takeaway .small-label { color: #546b48; margin-bottom: .35rem; } +.guide-takeaway > p:last-child { color: var(--green); font-weight: 650; white-space: pre-line; } +.guide-scenario-text, .guide-question, .guide-walkthrough ol, .guide-prediction-question, .guide-answer-text, .guide-answer-explanation, .guide-takeaway, .theory-section p { max-width: 60rem; } +.theory-reading-title { margin: 0 0 1.2rem; color: #53664c; } +@container learning-guide (max-width: 28rem) { + .guide-term { grid-template-columns: minmax(0, 1fr); gap: .25rem; } +} +.theory-visual { container-type: inline-size; container-name: theory-visual; min-width: 0; margin: 1.65rem 0 1.8rem; padding: 1.05rem; border: 1px solid #cfdcc7; border-radius: 12px; background: #f3f6ee; } +.theory-visual + .theory-section { margin-top: 1.7rem; } +.visual-caption { margin-bottom: .85rem; } +.visual-kicker { color: #546b48; font-size: .6875rem; font-weight: 700; letter-spacing: .08em; word-break: keep-all; overflow-wrap: anywhere; } +.visual-title { margin: .35rem 0 .55rem; color: var(--green); word-break: keep-all; overflow-wrap: anywhere; } +.visual-description { color: #485f45; font-size: .8125rem; line-height: 1.85; } +.visual-reading-order { color: #526849; font-size: .75rem; line-height: 1.85; padding: .6rem 0 .95rem; border-top: 1px solid #dce4d4; } +.visual-reading-order strong { color: #304f37; margin-right: .35rem; } +.visual-panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); gap: 1rem; align-items: start; } +.visual-panel { container-type: inline-size; container-name: visual-panel; min-width: 0; padding: .8rem; border: 1px solid #d8e1cf; border-radius: 9px; background: #fafbf6; } +.visual-panel-title { margin: 0 0 .75rem; font-size: .8125rem; font-weight: 750; line-height: 1.75; color: #34533c; word-break: keep-all; overflow-wrap: anywhere; } +.visual-nodes { display: grid; gap: .55rem; list-style: none; margin: 0; padding: 0; } +.visual-nodes.is-catalog { grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); } +.visual-node-row { min-width: 0; } +.visual-node { min-width: 0; padding: .7rem .75rem; border: 1px solid #cbd8c0; border-radius: 7px; background: #fffefa; } +.visual-node.is-accent { border-color: #648576; border-left: 3px solid #32624d; background: #edf4ed; } +.visual-node.is-warning { border-color: #b07856; border-style: dashed; background: #fcf2e9; } +.visual-node-label { display: block; color: #284535; font-size: .8125rem; font-weight: 700; line-height: 1.75; word-break: keep-all; overflow-wrap: anywhere; } +.visual-node.is-warning .visual-node-label { color: #804225; } +.visual-node-detail { margin-top: .35rem; color: #4e6149; font-size: .75rem; line-height: 1.85; white-space: pre-line; } +.visual-node.is-warning .visual-node-detail { color: #78523b; } +.visual-transition { padding: .4rem .35rem 0; } +.visual-arrow { display: flex; align-items: center; gap: .45rem; color: #3d614c; min-width: 0; } +.visual-arrow > .icon { width: 20px; height: 20px; flex: 0 0 20px; transform: rotate(90deg); stroke-width: 1.8; } +.visual-edge-label { min-width: 0; font-size: .75rem; line-height: 1.8; word-break: keep-all; overflow-wrap: anywhere; } +.visual-connections { border-top: 1px solid #d8e1cf; padding-top: .75rem; margin-top: .85rem; } +.visual-connections-label { color: #52644c; font-size: .6875rem; line-height: 1.85; margin-bottom: .45rem; } +.visual-relationship { display: grid; grid-template-columns: minmax(0, 1fr); gap: .4rem; align-items: center; padding: .5rem 0; } +.visual-relationship + .visual-relationship { border-top: 1px dotted #cfdac5; } +.visual-endpoint { padding: .4rem .6rem; background: #e7eee0; border: 1px solid #ccd9bf; border-radius: 6px; color: #345337; font-size: .75rem; font-weight: 650; line-height: 1.75; text-align: center; word-break: keep-all; overflow-wrap: anywhere; } +.visual-relationship .visual-arrow { justify-content: center; flex-direction: column-reverse; gap: .15rem; text-align: center; } +.visual-takeaway { padding: .85rem 0 .7rem; margin-top: .6rem; color: #34533c; font-size: .8125rem; line-height: 1.85; } +.visual-takeaway strong { margin-right: .4rem; color: var(--green); } +.visual-text-description { border-top: 1px solid #d2dfc6; border-bottom: 1px solid #d2dfc6; padding: .75rem 0; margin-top: .5rem; } +.visual-text-description summary { cursor: pointer; color: #34533c; font-size: .8125rem; font-weight: 650; line-height: 1.8; } +.visual-alt-text, .visual-limits { font-size: .8125rem; line-height: 1.9; color: #4b6046; white-space: pre-line; } +.visual-alt-text { margin-top: .7rem; } +.visual-limits { padding-left: 1.25rem; margin-top: .65rem; } +.visual-limits li + li { margin-top: .4rem; } +.visual-sources { padding-top: .8rem; } +.visual-sources-label { color: #4b6342; font-size: .6875rem; font-weight: 700; margin-bottom: .45rem; } +.visual-sources ul { list-style: none; padding: 0; display: grid; gap: .55rem; } +.visual-source-link { display: inline-flex; align-items: baseline; gap: .35rem; color: #355f45; font-size: .75rem; line-height: 1.85; max-width: 100%; overflow-wrap: anywhere; } +.visual-source-link > .icon { width: 12px; height: 12px; flex: 0 0 12px; } +.visual-source-meta { display: block; color: #53694a; font-size: .6875rem; line-height: 1.8; } +.visual-description, .visual-reading-order, .visual-node-detail, .visual-alt-text, .visual-limits, .visual-takeaway, .visual-text-description summary { word-break: keep-all; overflow-wrap: anywhere; } +@container theory-visual (max-width: 26rem) { + .visual-panel { padding: 0; border: 0; background: transparent; } + .visual-panels { gap: 1.4rem; } + .visual-panel + .visual-panel { border-top: 1px solid #cbd8c0; padding-top: 1.1rem; border-radius: 0; } + .visual-node { padding: min(.65rem, 12px); } + .visual-endpoint { padding: min(.4rem, 10px) min(.6rem, 12px); } +} +@container visual-panel (min-width: 28rem) { + .visual-relationship { grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr) minmax(0, 1fr); gap: .6rem; } + .visual-relationship .visual-arrow > .icon { transform: none; } +} +.next-step-row { display: flex; justify-content: flex-end; padding-top: 1.8rem; margin-top: 1.3rem; border-top: 1px solid #e6e9df; } +.button { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; min-height: 44px; padding: .65rem 1rem; border: 1px solid transparent; border-radius: 8px; font-size: .8125rem; font-weight: 650; line-height: 1.5; text-decoration: none; text-align: center; } +.button .icon { width: 16px; height: 16px; flex-basis: 16px; } +.button-primary { background: var(--green); color: #fff; } +.button-primary:hover:not(:disabled) { background: #265448; } +.button-secondary { border-color: #cfd9c8; background: #f7f9f2; color: var(--green); } +.button-secondary:hover:not(:disabled) { background: #eaf0e3; } +.button-quiet { color: var(--green); border-color: #d7ded0; background: #fffefa; } +.button-quiet:hover:not(:disabled) { background: #f0f4e9; } +.button-danger { background: #a34227; color: #fff; } +.button-small { font-size: .625rem; padding: .4rem .7rem; min-height: 34px; } +.code-shell, .editor-shell { + color-scheme: dark; + --code-bg: #172c2b; + --code-toolbar: #112321; + --code-fg: #e2e9df; + --code-muted: #b2c4b7; + --code-border: #405c52; + --code-gutter: #132724; + --code-gutter-text: #9bb4a7; + --code-active-line: #233b34; + --code-selection: #304751; + --code-focus: #b9d69b; + --code-keyword: #cfa7ef; + --code-class: #edcb78; + --code-type: #76d7c3; + --code-variable: #d1e4fb; + --code-method: #90beff; + --code-string: #bddf91; + --code-comment: #b1c0b2; + --code-number: #f4b98c; + --code-operator: #c8d5cf; +} +[data-editor-theme="light"] { + color-scheme: light; + --code-bg: #fcfbf6; + --code-toolbar: #f0f2e8; + --code-fg: #283e33; + --code-muted: #53674e; + --code-border: #b9c6af; + --code-gutter: #f1f3e9; + --code-gutter-text: #697b5c; + --code-active-line: #edf1e5; + --code-selection: #d2e0db; + --code-focus: #49723d; + --code-keyword: #79439c; + --code-class: #855206; + --code-type: #076054; + --code-variable: #254d80; + --code-method: #3e4caa; + --code-string: #3b6022; + --code-comment: #4e5e46; + --code-number: #943d16; + --code-operator: #475d53; +} +[data-editor-theme="contrast"] { + color-scheme: dark; + --code-bg: #081110; + --code-toolbar: #030a08; + --code-fg: #f4fff8; + --code-muted: #d6e7d8; + --code-border: #a0b9a6; + --code-gutter: #081110; + --code-gutter-text: #c3d7c7; + --code-active-line: #152920; + --code-selection: #304650; + --code-focus: #e2ff92; + --code-keyword: #e0b5ff; + --code-class: #ffe57f; + --code-type: #59efdc; + --code-variable: #e2f0ff; + --code-method: #9bcaff; + --code-string: #cefa9b; + --code-comment: #b6cbb9; + --code-number: #ffd0a8; + --code-operator: #e0ebe3; +} +.code-shell { min-width: 0; overflow: hidden; border: 1px solid var(--code-border); border-radius: 10px; background: var(--code-bg); margin: 1rem 0; } +.code-heading { display: flex; align-items: center; justify-content: space-between; gap: .7rem; padding: .6rem .95rem; background: var(--code-toolbar); color: var(--code-muted); border-bottom: 1px solid var(--code-border); font: .75rem/1.6 var(--mono); } +.code-heading > span { display: flex; align-items: center; gap: .4rem; min-width: 0; } +.code-heading .icon { width: 13px; height: 13px; flex-basis: 13px; } +.copy-button { display: inline-flex; align-items: center; gap: .35rem; color: var(--code-muted); padding: .3rem .4rem; border-radius: 4px; font: .75rem/1.5 var(--font); flex-shrink: 0; min-height: 36px; } +.copy-button:hover { background: var(--code-active-line); } +.code-block { padding: 1.2rem; font: .875rem/1.85 var(--mono); color: var(--code-fg); white-space: pre; overflow-x: auto; tab-size: 4; scrollbar-width: thin; scrollbar-color: var(--code-border) var(--code-bg); } +.code-block code { font: inherit; } +.code-settings { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem .8rem; padding: .65rem .95rem; background: var(--code-toolbar); color: var(--code-muted); } +.code-theme-label { font-size: .75rem; } +.editor-theme-select { min-height: 36px; min-width: 0; width: 6.5rem; max-width: 100%; background: var(--code-bg); color: var(--code-fg); border: 1px solid var(--code-border); border-radius: 6px; padding: .3rem .55rem; font-size: .75rem; } +.syntax-legend { display: flex; flex-wrap: wrap; gap: .35rem .8rem; padding: .55rem .95rem .7rem; border-bottom: 1px solid var(--code-border); background: var(--code-toolbar); } +.syntax-legend-item { display: inline-flex; align-items: center; gap: .3rem; font: .6875rem/1.6 var(--font); } +.syntax-legend-item::before { content: ""; display: inline-block; width: .35rem; height: .35rem; border-radius: 50%; background: currentColor; flex-shrink: 0; } +.tok-keyword { color: var(--code-keyword); font-weight: 600; } +.tok-class { color: var(--code-class); font-weight: 650; } +.tok-type { color: var(--code-type); } +.tok-variable { color: var(--code-variable); } +.tok-method { color: var(--code-method); } +.tok-string { color: var(--code-string); } +.tok-comment { color: var(--code-comment); font-style: italic; } +.tok-number { color: var(--code-number); } +.tok-operator, .tok-punctuation { color: var(--code-operator); } +.tok-annotation { color: var(--code-class); } +.syntax-note { margin-top: -.3rem; } +.example-filename { overflow-wrap: anywhere; } +.file-tabs { display: flex; flex-wrap: wrap; gap: .2rem; padding: .5rem .65rem 0; background: var(--code-toolbar); border-bottom: 1px solid var(--code-border); } +.file-tab { display: inline-flex; align-items: center; flex-wrap: wrap; gap: .35rem; max-width: 100%; min-width: 0; min-height: 42px; padding: .5rem .65rem; border-bottom: 2px solid transparent; color: var(--code-muted); font: .75rem/1.65 var(--mono); text-align: left; overflow-wrap: anywhere; } +.file-tab[aria-selected="true"] { color: var(--code-fg); border-bottom-color: var(--code-focus); background: var(--code-active-line); } +.file-tab:focus-visible { outline-color: var(--code-focus); outline-offset: -3px; } +.file-readonly-badge { display: inline-block; padding: .05rem .3rem; border: 1px solid var(--code-border); border-radius: 4px; color: var(--code-muted); font: .6875rem/1.6 var(--font); white-space: normal; } +.expected-output { margin: 1.2rem 0; padding: 1rem 1.1rem; border: 1px dashed #cbd6c1; border-radius: 8px; background: #f6f8f1; } +.expected-output .small-label { color: #5d7850; margin-bottom: .6rem; font-size: .5625rem; } +.expected-output pre { font: .75rem/1.75 var(--mono); color: #385731; white-space: pre; overflow-x: auto; } +.explanation { font-size: .9375rem; line-height: 1.9; color: #5b675c; white-space: pre-line; } +.inline-note { color: var(--muted); font-size: .6875rem; line-height: 1.85; margin-top: .75rem; } +.quiz-form { display: grid; gap: 1.4rem; } +.quiz-question { padding: 0; border: 0; min-width: 0; } +.quiz-question legend { font-size: .9375rem; font-weight: 650; line-height: 1.85; padding: 0; margin-bottom: .75rem; width: 100%; } +.question-number { font: .625rem/1.5 var(--mono); color: var(--rust); margin-right: .4rem; } +.quiz-options { display: grid; gap: .45rem; } +.quiz-option { display: flex; align-items: flex-start; gap: .65rem; border: 1px solid #dfe4d7; border-radius: 8px; padding: .75rem .85rem; background: #fffefa; cursor: pointer; font-size: .875rem; line-height: 1.8; } +.quiz-option:hover { border-color: #9dad8f; background: #f8faf3; } +.quiz-option:has(input:checked) { border-color: #5a7856; background: #edf2e7; } +.quiz-option input { width: 15px; height: 15px; flex: 0 0 15px; margin: .22rem 0 0; accent-color: var(--green); } +.option-letter { color: #829077; font-family: var(--mono); font-size: .625rem; flex: 0 0 14px; margin-top: .1rem; } +.quiz-feedback { padding: .85rem 1rem; margin-top: .65rem; border-radius: 8px; background: #eef5e8; color: #426339; font-size: .8125rem; line-height: 1.85; } +.quiz-feedback.is-wrong { background: var(--rust-soft); color: #8f452b; } +.quiz-feedback strong { display: block; margin-bottom: .3rem; font-size: .6875rem; } +.result-summary { background: #edf4e7; border: 1px solid #cfdcc2; border-radius: 10px; padding: 1rem 1.1rem; margin: 1rem 0 1.5rem; font-size: .75rem; } +.result-summary strong { display: block; font-size: .875rem; color: #335834; margin-bottom: .3rem; } +.result-summary.is-incomplete { background: #fcf3ec; border-color: #eed3c0; color: #925336; } +.result-summary.is-incomplete strong { color: #97502e; } +.quiz-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .3rem; } +.form-error { color: #a13922; font-size: .75rem; } +.scenario-card { border-left: 3px solid #a7603e; background: #faf3ea; padding: 1.15rem; margin: 1.2rem 0 1.4rem; } +.scenario-card .small-label { color: #a55e3e; margin-bottom: .5rem; font-size: .5625rem; } +.scenario-card p { font-size: .9375rem; line-height: 1.9; color: #795b46; white-space: pre-line; } +.practice-tasks { list-style: none; padding: 0; display: grid; gap: .6rem; margin: 1rem 0; } +.practice-tasks li { display: flex; gap: .75rem; padding-bottom: .65rem; border-bottom: 1px solid #e6e9df; font-size: .875rem; line-height: 1.85; } +.practice-tasks .task-index { display: block; flex: 0 0 22px; font: .625rem/1.8 var(--mono); color: #789165; padding-top: .15rem; } +.takeaway { padding: 1rem 1.1rem; background: #eef3e8; border-radius: 8px; margin-top: 1.4rem; font-size: .75rem; line-height: 1.9; color: #506b45; } +.takeaway strong { display: block; color: #3a5f36; font-size: .625rem; margin-bottom: .4rem; } +.design-notes-label { display: block; font-size: .8125rem; font-weight: 650; margin: 1.5rem 0 .4rem; } +.design-notes { display: block; width: 100%; min-height: 150px; resize: vertical; padding: .85rem 1rem; background: #fcfdf8; color: var(--ink); border: 1px solid #d4ddca; border-radius: 8px; font-size: .8125rem; line-height: 1.85; } +.notes-footnote { color: var(--muted); font-size: .625rem; margin-top: .5rem; } +.lab-header { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-start; justify-content: space-between; } +.lab-header h3 { flex: 1 1 200px; } +.lab-mode { padding: .25rem .55rem; background: #edf2e6; color: #627951; border-radius: 5px; font: .5625rem/1.7 var(--mono); } +.runtime-card { background: #f1f5ec; border: 1px solid #dce5d3; border-radius: 9px; padding: .85rem 1rem; margin: 1rem 0; } +.runtime-line { display: flex; align-items: center; flex-wrap: wrap; gap: .45rem .9rem; font-size: .6875rem; } +.runtime-line strong { color: #2e593d; } +.runtime-card p { font-size: .75rem; line-height: 1.8; color: #526747; margin-top: .3rem; } +.runtime-card.is-unavailable { background: #faf1e8; border-color: #ebd6c4; } +.runtime-card.is-unavailable p, .runtime-card.is-unavailable strong { color: #8e5535; } +.runtime-retry { font-size: .625rem; padding: .2rem 0; color: #7b4d31; text-decoration: underline; } +.runtime-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .7rem; margin-top: .8rem; } +.runtime-controls label { font-size: .75rem; font-weight: 600; } +.runtime-controls select { max-width: 100%; min-height: 42px; background: var(--card); border: 1px solid #c5d3bb; border-radius: 6px; color: var(--green); padding: .35rem .6rem; font-size: .8125rem; } +.runtime-controls .button, .project-access-actions .button { font-size: .75rem; } +.project-access-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .85rem; } +.project-preparation { border-top: 1px solid #dce5d3; padding-top: .55rem; margin-top: .65rem !important; } +.project-preparation[data-prepared="true"] { font-weight: 650; } +.project-service { overflow-wrap: anywhere; } +.lab-instructions { padding-left: 1.3rem; font-size: .875rem; line-height: 1.9; margin: 1rem 0; color: #596852; } +.lab-instructions li + li { margin-top: .35rem; } +.lab-rubric { margin: 1rem 0; border: 1px solid #dfe5d8; border-radius: 8px; padding: .7rem .9rem; } +.lab-rubric summary { cursor: pointer; font-size: .6875rem; font-weight: 600; color: #5c714e; } +.lab-rubric ul { margin-top: .6rem; font-size: .6875rem; line-height: 1.85; color: #687762; } +.editor-shell { border: 1px solid var(--code-border); border-radius: 10px; overflow: hidden; margin-top: 1.2rem; background: var(--code-bg); } +.editor-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .3rem .7rem; background: var(--code-toolbar); border-bottom: 1px solid var(--code-border); padding: .55rem .85rem; color: var(--code-muted); } +.editor-filename { display: inline-flex; align-items: center; flex-wrap: wrap; gap: .4rem; min-width: 0; max-width: 100%; font: .75rem/1.6 var(--mono); overflow-wrap: anywhere; } +.editor-filename .icon { width: 13px; height: 13px; flex-basis: 13px; } +.editor-tools { display: flex; flex-wrap: wrap; gap: .35rem; } +.editor-tools button { padding: .3rem .45rem; font-size: .75rem; color: var(--code-muted); border-radius: 4px; min-height: 36px; } +.editor-tools button:hover, .editor-tools button[aria-pressed="true"] { background: var(--code-active-line); } +.editor-tools button[aria-pressed="true"] { box-shadow: inset 0 -2px var(--code-focus); } +.code-editor { display: block; width: 100%; min-width: 0; color: var(--code-fg); background: var(--code-bg); } +.code-editor .cm-editor, .code-editor-fallback { min-height: 12rem; height: 23rem; max-height: 70vh; font: .875rem/1.8 var(--mono); color: var(--code-fg); background: var(--code-bg); } +.code-editor .cm-editor.cm-focused { outline: 2px solid var(--code-focus); outline-offset: -3px; } +.code-editor .cm-scroller { overflow: auto; font: inherit; scrollbar-width: thin; scrollbar-color: var(--code-border) var(--code-bg); } +.code-editor .cm-content { padding: 1rem 0; caret-color: var(--code-focus); } +.code-editor .cm-content:focus-visible { outline: none; } +.code-editor .cm-line { padding-right: 1.2rem; padding-left: 1rem; } +.code-editor .cm-gutters { background: var(--code-gutter); color: var(--code-gutter-text); border-right: 1px solid var(--code-border); } +.code-editor .cm-lineNumbers .cm-gutterElement { min-width: 2.5rem; padding-right: .65rem; padding-left: .65rem; font-size: .75rem; } +.code-editor .cm-activeLine, .code-editor .cm-activeLineGutter { background-color: var(--code-active-line); } +.code-editor .cm-cursor { border-left-color: var(--code-focus); } +.code-editor .cm-selectionBackground, .code-editor .cm-focused .cm-selectionBackground, .code-editor .cm-content ::selection, .code-block ::selection, .code-editor-fallback::selection { background-color: var(--code-selection) !important; } +.code-editor .cm-matchingBracket { background: var(--code-active-line); outline: 1px solid var(--code-focus); color: var(--code-fg); } +.code-editor .cm-nonmatchingBracket { outline: 1px dashed #ed8a7b; } +.code-editor-fallback { display: block; width: 100%; resize: vertical; padding: 1rem; border: 0; border-radius: 0; letter-spacing: 0; tab-size: 4; overflow: auto; caret-color: var(--code-focus); } +.code-editor-fallback:focus-visible { outline-offset: -4px; outline-color: var(--code-focus); } +.editor-status { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .4rem; padding: .5rem .85rem; color: var(--code-muted); border-top: 1px solid var(--code-border); font: .6875rem/1.6 var(--mono); } +.editor-hint { font-size: .6875rem; color: var(--muted); margin-top: .45rem; line-height: 1.8; } +.reset-undo { padding: .55rem .75rem; margin-top: .6rem; background: #f8efdf; color: #875c38; border-radius: 6px; font-size: .625rem; } +.reset-undo button { font-size: inherit; font-weight: 700; text-decoration: underline; margin-left: .5rem; color: #815334; } +.execution-disclosure { padding: .8rem .95rem; margin: 1.1rem 0 .85rem; border: 1px solid #deddd0; border-radius: 8px; color: #706853; background: #f9f7ee; font-size: .625rem; line-height: 1.85; } +.execution-disclosure a { color: #72623d; } +.lab-actions { display: flex; align-items: center; flex-wrap: wrap; gap: .55rem; } +.lab-actions .execution-state { margin-left: auto; font-size: .625rem; color: var(--muted); } +.execution-panel { margin-top: 1.25rem; background: #f5f7ef; border: 1px solid #dbe2d2; border-radius: 9px; overflow: hidden; } +.execution-panel-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .4rem .8rem; padding: .7rem .9rem; border-bottom: 1px solid #e0e5d8; font-size: .6875rem; } +.execution-panel-heading strong { display: inline-flex; align-items: center; gap: .4rem; color: #426037; } +.execution-panel-heading .icon { width: 14px; height: 14px; flex-basis: 14px; } +.execution-panel-heading > span { font: .5625rem/1.6 var(--mono); color: #79896b; } +.execution-output-body { padding: 1rem; } +.execution-placeholder { color: #526747; font-size: .8125rem; line-height: 1.9; } +.execution-result-label { font-size: .8125rem; font-weight: 700; color: #42683b; margin-bottom: .45rem; } +.execution-result-label.is-error { color: #a04c2e; } +.execution-warning { color: #96552f; background: #fcf0de; border-left: 2px solid #b17135; padding: .6rem .75rem; font-size: .6875rem; line-height: 1.8; margin-bottom: .7rem; } +.output-section { margin: .7rem 0; } +.output-section h4 { margin: 0 0 .3rem; font-size: .5625rem; font-weight: 650; color: #6b7f5b; letter-spacing: .02em; } +.output-section pre { font: .6875rem/1.85 var(--mono); background: #eef2e5; border: 1px solid #dfe5d4; color: #385033; border-radius: 5px; padding: .75rem .8rem; overflow-x: auto; white-space: pre; } +.output-section.is-error pre { background: #fbf0e7; border-color: #e8d8c9; color: #915235; } +.execution-checks { list-style: none; padding: 0; display: grid; gap: .3rem; margin: .8rem 0; } +.execution-checks li { display: flex; gap: .5rem; font-size: .6875rem; color: #527048; line-height: 1.75; } +.execution-checks li.is-failed { color: #965031; } +.execution-checks .check-symbol { font-weight: 700; min-width: 1rem; } +.execution-timing { color: #7e8b71; font-size: .5625rem; margin-top: .75rem; } +.lesson-footer { padding: 1rem 2rem; border-top: 1px solid #e2e6da; display: flex; justify-content: space-between; flex-wrap: wrap; gap: .6rem; background: #fdfef9; } +.lesson-footer button { font-size: .625rem; min-height: 36px; padding: .35rem 0; color: #6c7e5b; text-align: left; } +.lesson-footer button:last-child { margin-left: auto; text-align: right; } +.lesson-footer button strong { display: block; font-size: .6875rem; color: #425e36; font-weight: 650; } +.reference-column { display: block; min-width: 0; } +.reference-content { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding: 8px 0 16px; } +.reference-card { border: 1px solid #dce1d4; background: #f7f8f1; border-radius: 11px; padding: 1.2rem 1.05rem; min-width: 0; } +.reference-card-heading { display: flex; align-items: center; gap: .5rem; } +.reference-card-heading .icon { width: 16px; height: 16px; flex-basis: 16px; color: #527146; } +.reference-card h3 { margin: 0; font-size: .875rem; letter-spacing: -.025em; line-height: 1.55; } +.reference-description { color: var(--muted); font-size: .75rem; margin: .45rem 0 .7rem; line-height: 1.8; } +.source-list { display: flex; flex-direction: column; } +.source-link { display: block; position: relative; border-top: 1px solid #e1e6d6; padding: .85rem 1.2rem .7rem 0; text-decoration: none; overflow-wrap: anywhere; } +.source-link > .icon { position: absolute; top: .9rem; right: 0; width: 12px; height: 12px; color: #879878; } +.source-link strong { display: block; color: #405d35; font-size: .75rem; font-weight: 550; line-height: 1.75; } +.source-link:hover strong { text-decoration: underline; text-underline-offset: 3px; } +.source-link span { display: block; color: #667859; font-size: .625rem; margin-top: .25rem; } +.evidence-card { background: #f5f0e6; border-color: #e4dccb; } +.evidence-card > .small-label { color: #a1774e; font-size: .5rem; margin-bottom: .4rem; } +.evidence-card h3 { color: #6f553a; } +.evidence-card .reference-description { color: #8b7961; } +.evidence-item { padding: .9rem 0; border-top: 1px solid #e5dccc; } +.evidence-company { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin-bottom: .35rem; font-size: .6875rem; font-weight: 650; color: #785b3e; } +.evidence-status { font-size: .5rem; font-weight: 400; border: 1px solid #ded0ba; border-radius: 3px; padding: .05rem .25rem; color: #8c7557; } +.evidence-title { display: inline-flex; align-items: flex-start; gap: .4rem; font-size: .6875rem; line-height: 1.75; color: #795f45; text-decoration: none; } +.evidence-title:hover { text-decoration: underline; } +.evidence-title .icon { width: 11px; height: 11px; flex-basis: 11px; margin-top: .25rem; } +.evidence-item p { margin-top: .45rem; color: #7a674f; font-size: .6875rem; line-height: 1.9; } +.technology-tags { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .6rem; } +.technology-tags span { padding: .12rem .35rem; border-radius: 3px; background: #eee5d5; color: #775832; font-size: .625rem; } +.evidence-item .evidence-date { font-size: .625rem; color: #7c6345; margin-top: .6rem; } +.evidence-disclaimer { font-size: .625rem; color: #7c6345; border-top: 1px solid #e2d8c5; padding-top: .8rem; line-height: 1.9; } +.next-chapter { padding: .7rem .3rem; } +.next-chapter .small-label { font-size: .5rem; color: #8b957f; } +.next-chapter h3 { margin-top: .4rem; font-size: .75rem; color: #637558; font-weight: 600; } +.next-chapter p { font-size: .625rem; color: #88957b; margin-top: .2rem; } +.planned-badge { display: inline-block; margin-top: .65rem; padding: .12rem .4rem; font-size: .5rem; color: #7e8c70; border: 1px dashed #c6d0b9; border-radius: 3px; } +.empty-state { padding: 2.5rem 1rem; text-align: center; color: var(--muted); } +.empty-state .icon { width: 28px; height: 28px; color: #8da078; margin-bottom: 1rem; } +.empty-state h3 { font-size: 1rem; color: #53674a; } +.empty-state p { font-size: .75rem; margin-top: .6rem; line-height: 1.9; } +.empty-state button { margin-top: 1rem; } +.sidebar-empty { padding: 1.2rem .4rem; color: #7d8c6e; font-size: .6875rem; line-height: 1.9; } +.site-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem 1.5rem; padding: 1.8rem 0 2rem; margin-top: 2.5rem; border-top: 1px solid var(--line); color: #859078; } +.footer-brand { font-size: 1rem; font-weight: 700; letter-spacing: -.06em; color: #718066; } +.site-footer p { font-size: .625rem; } +.site-footer button { margin-left: auto; font-size: .5625rem; color: #7c8b6d; } +.loading-state { border: 1px solid var(--line); border-radius: 12px; padding: 4rem 1rem; color: var(--muted); text-align: center; margin-top: 1.5rem; } +.loading-state.is-error { color: #935034; background: #fbf2e8; } +.drawer-backdrop { position: fixed; inset: 0; background: #10261ab3; z-index: 90; backdrop-filter: blur(2px); } +.toast { position: fixed; z-index: 150; left: 50%; bottom: 1.5rem; transform: translateX(-50%) translateY(20px); background: #1d3d2f; color: white; border: 1px solid #466341; padding: .75rem 1.15rem; border-radius: 10px; max-width: calc(100% - 2rem); width: max-content; font-size: .75rem; box-shadow: 0 8px 28px #112b2430; pointer-events: none; opacity: 0; transition: opacity .18s, transform .18s; } +.toast.is-visible { opacity: 1; transform: translateX(-50%) translateY(0); } +.app-dialog { width: min(580px, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow-y: auto; overscroll-behavior: contain; border: 1px solid #cbd7be; border-radius: 14px; padding: 1.6rem 1.8rem; color: var(--ink); background: var(--card); box-shadow: 0 20px 70px #162f2040; } +.app-dialog::backdrop { background: #132c208c; backdrop-filter: blur(2px); } +.app-dialog h2 { font-size: 1.35rem; line-height: 1.6; letter-spacing: -.035em; margin-bottom: .75rem; } +.app-dialog p, .app-dialog li { font-size: .8125rem; line-height: 1.85; color: #5c6d52; } +.app-dialog ul { margin: 1.1rem 0; } +.app-dialog li + li { margin-top: .5rem; } +.app-dialog .dialog-note { padding: .85rem 1rem; background: #eff3e8; border-radius: 8px; font-size: .6875rem; margin-bottom: 1.2rem; } +.dialog-heading { display: flex; justify-content: space-between; align-items: center; gap: .7rem; margin-bottom: .6rem; } +.dialog-heading .small-label, .app-dialog > .small-label { font-size: .5625rem; color: #8b7955; } +.compact-dialog { max-width: 460px; } +.compact-dialog > .small-label { margin-bottom: .65rem; } +.dialog-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: .6rem; margin-top: 1.5rem; } +.settings-dialog { width: min(660px, calc(100% - 2rem)); word-break: keep-all; overflow-wrap: anywhere; } +.settings-bundle { margin: 1.1rem 0; padding: 1rem; border: 1px solid #cbdac4; border-radius: 9px; background: #f0f5ec; } +.settings-bundle h3 { font-size: 1rem; line-height: 1.6; color: var(--green); } +.settings-bundle p + p { margin-top: .45rem; } +.settings-bundle .settings-bundle-tools { display: flex; flex-wrap: wrap; gap: .6rem; list-style: none; padding: 0; margin: .75rem 0; } +.settings-bundle-tools li { display: flex; flex: 1 1 11rem; flex-wrap: wrap; align-items: baseline; gap: .15rem .6rem; min-width: 0; padding: .6rem .75rem; border: 1px solid #d5e0cd; border-radius: 7px; background: var(--card); } +.settings-bundle-tools li + li { margin: 0; } +.settings-bundle-tools strong { color: var(--green); font-size: .875rem; } +.settings-bundle-tools span { color: #586d4c; font-size: .75rem; } +.settings-bundle.is-unavailable { border-color: #dac9b8; background: #faf4eb; } +.settings-advanced { margin-top: 1rem; border-top: 1px solid var(--line); padding-top: .85rem; } +.settings-advanced summary { cursor: pointer; min-height: 44px; padding: .4rem 0; color: var(--green); font-size: .8125rem; font-weight: 650; line-height: 1.8; touch-action: manipulation; } +.settings-advanced > p { margin-top: .4rem; } +.settings-advanced .settings-tools { margin-bottom: 0; } +.settings-bundle, .settings-advanced { overflow-wrap: anywhere; } +.settings-tools { display: grid; gap: .3rem; margin: 1.2rem 0; } +.settings-tool-row { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem; padding: .8rem 0; border-bottom: 1px solid var(--line); } +.settings-tool-row > div { flex: 1 1 15rem; min-width: 0; } +.settings-tool-row strong { font-size: .875rem; } +.settings-tool-row .button { font-size: .75rem; } +.settings-tool-source { font-size: .6875rem !important; margin-top: .2rem; } +.settings-tool-path { font-family: var(--mono); font-size: .6875rem !important; overflow-wrap: anywhere; margin-top: .2rem; } +.settings-consent { padding: 1rem; border: 1px solid #dac9b8; background: #faf4eb; border-radius: 9px; } +.settings-consent h3 { font-size: .9375rem; margin-bottom: .4rem; } +.settings-consent p { color: #684e3a; } +.settings-consent label { display: flex; gap: .65rem; align-items: flex-start; margin-top: .85rem; font-size: .8125rem; font-weight: 600; line-height: 1.8; } +.settings-consent input { flex: 0 0 1.1rem; width: 1.1rem; height: 1.1rem; margin: .25rem 0 0; accent-color: var(--green); } +.settings-status { margin-top: .65rem; min-height: 1.8em; overflow-wrap: anywhere; } +.noscript-note { margin: 2rem; padding: 1rem; border: 1px solid #cba58c; background: #faf0e5; } + +@media (min-width: 1600px) { + .workspace-grid { grid-template-columns: 228px minmax(0, 1fr); gap: 24px; } +} +@media (max-width: 1250px) { + .site-header { padding-right: clamp(16px, 2vw, 32px); padding-left: clamp(16px, 2vw, 32px); } + .page-shell { padding: 0 clamp(16px, 2vw, 32px); } + .next-chapter { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: .7rem 1.2rem; } + .next-chapter h3, .next-chapter p, .next-chapter .planned-badge { margin: 0; } +} +@media (max-width: 899px) { + .site-header { min-height: 64px; padding: min(.65rem, 11px) clamp(12px, 2vw, 24px); gap: min(.6rem, 10px) min(1rem, 16px); } + .header-navigation { gap: min(.4rem, 8px) min(1.3rem, 20px); } + .header-link { display: inline-flex; align-items: center; min-height: 44px; padding: min(.5rem, 8px) 0; } + .storage-indicator { font-size: .625rem; } + .page-shell { padding: 0 clamp(12px, 2vw, 24px); } + .intro { padding: .65rem 0; gap: .5rem 1rem; } + .intro h1 { font-size: 1rem; } + .intro-description { font-size: .8125rem; } + .intro-facts { gap: .8rem; font-size: .625rem; } + .intro-facts > span + span { padding-left: .8rem; } + .journey-card { padding: 1.2rem; } + .workspace-grid { grid-template-columns: minmax(0, 1fr); } + .curriculum-sidebar { display: none; position: fixed; top: 0; bottom: 0; left: 0; width: min(340px, calc(100% - 2rem)); max-height: none; padding: 1.25rem 1.4rem 2rem; background: var(--paper); overflow-y: auto; overscroll-behavior: contain; z-index: 100; } + .curriculum-sidebar.is-open { display: block; } + .mobile-menu-toggle, .mobile-menu-close { display: inline-flex; } + .mobile-menu-toggle { font-size: .75rem; min-height: 44px; padding: min(.55rem, 8px); padding-right: 0; } + .lesson-link { min-height: 58px; } + .lesson-link-title { font-size: .8125rem; } + .sidebar-heading h2 { font-size: 1.25rem; } + .version-control label { font-size: .8125rem; } + .version-control select { font-size: .8125rem; min-height: 40px; } + .version-control p, .filtered-progress { font-size: .6875rem; } + .track-filter button { min-height: 40px; font-size: .75rem; } + .search-field input { font-size: .8125rem; } + .search-field { min-height: 43px; } + .lesson-link-meta, .sidebar-footnote p, .reset-records { font-size: .6875rem; } + body.menu-open { overflow: hidden; } +} +@media (max-width: 650px) { + .site-header { padding: min(.7rem, 12px) min(1.1rem, 18px); flex-wrap: wrap; gap: min(.5rem, 8px) min(1rem, 16px); min-height: 68px; } + .brand { font-size: 1.4rem; gap: .5rem; } + .brand-mark { width: 1.9375rem; height: 2.0625rem; font-size: 1.65rem; border-radius: .4375rem; } + .header-navigation { margin-left: auto; } + .header-navigation > a { display: none; } + .header-link { font-size: .75rem; } + .header-tools { margin-left: 0; } + .storage-indicator { display: none; } + .page-shell { padding: 0 12px; } + .intro { padding: 10px 0; gap: 8px 12px; } + .intro .eyebrow { font-size: .5625rem; } + .intro h1 { font-size: 1rem; margin: 0; } + .intro-description { font-size: .8125rem; margin-top: .8rem; } + .intro-facts { margin-top: 1rem; font-size: .6875rem; } + .journey-card { padding: 1rem 1.15rem; } + .journey-progress-heading { margin-top: .7rem; } + .continue-button { margin-top: .75rem; padding-top: .75rem; } + .continue-button .icon { padding: .5rem; } + .journey-top .small-label { font-size: .5625rem; } + .journey-caption { font-size: .625rem; } + .continue-label { font-size: .625rem; } + .continue-button strong { font-size: .8125rem; } + .workspace-heading { padding-bottom: .75rem; font-size: .625rem; } + .workspace-kicker { font-size: .5rem; } + .official-note { font-size: .5rem; gap: .3rem; } + .workspace-heading > div { gap: .5rem; } + .workspace-grid { padding-top: 12px; gap: 12px; } + .lesson-header { padding: 16px 12px 6px; } + .lesson-support { gap: 0 12px; padding: 0 12px 8px; } + .lesson-details > summary { padding: 10px 0; } + .lesson-header h2 { font-size: 1.5rem; } + .lesson-description { font-size: .875rem; } + .lesson-tabs { flex-wrap: wrap; padding: 0 .8rem; gap: .15rem; overflow-x: visible; } + .lesson-tabs button { padding: .9rem .5rem; font-size: .75rem; } + .tab-number { display: none; } + .lesson-panel { padding: 20px 12px; } + .lesson-panel h3 { font-size: 1rem; } + .learning-goals { padding: .9rem min(.9rem, 14px); margin-bottom: 1.4rem; } + .learning-goals li { font-size: .875rem; } + .theory-section p { font-size: .9375rem; } + .guide-scenario { padding-left: 8px; } + .guide-walkthrough ol { padding-left: 24px; } + .guide-walkthrough li { padding-left: 0; } + .guide-code .code-heading { padding-right: min(.95rem, 12px); padding-left: min(.95rem, 12px); } + .guide-code-tools { padding-right: 12px; padding-left: 12px; } + .guide-walkthrough li + li { margin-top: 16px; } + .theory-section + .theory-section { margin-top: 24px; padding-top: 20px; } + .theory-visual { margin-right: -.5rem; margin-left: -.5rem; padding: min(.5rem, 10px); border-radius: 9px; } + .code-block { padding: 1rem min(1rem, 12px); font-size: .875rem; } + .lesson-footer { padding: .75rem 12px; } + .reference-content { display: flex; flex-direction: column; align-items: stretch; } + .reference-card { padding: 16px 12px; } + .reference-card h3 { font-size: .9375rem; } + .reference-description { font-size: .75rem; } + .source-link strong, .evidence-title { font-size: .8125rem; } + .source-link span, .evidence-disclaimer, .evidence-item .evidence-date { font-size: .625rem; } + .evidence-company { font-size: .8125rem; } + .evidence-item p, .technology-tags span, .evidence-status { font-size: .6875rem; } + .evidence-card > .small-label { font-size: .5625rem; } + .next-chapter { display: block; padding: .75rem .25rem; } + .next-chapter h3, .next-chapter p { margin-top: .3rem; } + .next-chapter .planned-badge { margin-top: .65rem; } + .site-footer { margin-top: 1.5rem; padding-top: 1.3rem; gap: .5rem 1rem; } + .site-footer button { margin-left: 0; flex-basis: 100%; font-size: .625rem; } + .lab-header h3 { flex-basis: 100%; } + .lab-mode { font-size: .625rem; } + .runtime-line { font-size: .6875rem; } + .runtime-card p { font-size: .75rem; } + .code-editor .cm-editor, .code-editor-fallback { height: 21rem; font-size: .875rem; } + .editor-toolbar { padding: .45rem .65rem; } + .editor-tools button { font-size: .75rem; } + .editor-filename { font-size: .75rem; } + .editor-status { font-size: .6875rem; padding: .45rem .65rem; } + .code-settings, .syntax-legend { padding-right: min(.75rem, 12px); padding-left: min(.75rem, 12px); } + .code-editor .cm-line { padding-left: .65rem; } + .execution-disclosure { font-size: .625rem; } + .lab-actions .button { flex: 1 1 auto; padding: .65rem .7rem; font-size: .6875rem; } + .lab-actions .execution-state { margin-left: 0; flex-basis: 100%; font-size: .625rem; } + .execution-output-body { padding: .85rem; } + .app-dialog { padding: 1.3rem; } + .settings-dialog { width: calc(100% - 24px); max-width: calc(100% - 24px); padding: clamp(12px, 4vw, 1.3rem); } + .settings-bundle, .settings-consent { padding: clamp(12px, 4vw, 1rem); } + .app-dialog h2 { font-size: 1.2rem; } + .dialog-actions .button { flex: 1; } +} +@media (max-width: 359px) { + .page-shell { padding: 0 min(.75rem, 12px); } + .site-header { padding-right: min(.75rem, 12px); padding-left: min(.75rem, 12px); gap: min(.7rem, 12px); } + .intro-description { font-size: .75rem; } + .intro-facts { gap: .65rem; } + .intro-facts > span + span { padding-left: .65rem; } + .official-note { display: none; } + .lesson-header { padding: 16px 12px 6px; } + .lesson-panel { padding: 20px 12px; } + .lesson-tabs { padding: 0 .55rem; } + .lesson-tabs button { padding-right: .5rem; padding-left: .5rem; } + .lesson-header h2 { font-size: 1.35rem; } + .button { padding-right: .8rem; padding-left: .8rem; } + .quiz-option { padding: .65rem .7rem; gap: .5rem; } + .option-letter { display: none; } + .lesson-footer { padding-right: 12px; padding-left: 12px; } +} +@media (max-height: 600px) { + .intro { padding-top: 6px; padding-bottom: 6px; gap: 4px 12px; } + .curriculum-sidebar { top: .75rem; } + .code-editor .cm-editor, .code-editor-fallback { min-height: 9rem; max-height: 70vh; } + .app-dialog { max-height: calc(100dvh - 1rem); } +} +@media (max-width: 899px) and (max-height: 600px) { + .curriculum-sidebar { top: 0; } +} +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } +} +@media print { + body { background: white; font-size: 12pt; } + .site-header, .intro, .curriculum-sidebar, .lesson-tabs, .site-footer, .next-step-row, .lesson-footer, .toast { display: none !important; } + .page-shell { padding: 0; } + .workspace-grid { display: block; } + .lesson-card { border: 0; box-shadow: none; } + .reference-column { display: block; margin-top: 1rem; } + .code-block, .code-editor { white-space: pre-wrap; color: black; background: white; } + .code-editor .cm-editor { height: auto; max-height: none; } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..cb8b575 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,41 @@ +# 실행 아키텍처와 공개 범위 + +기준일 2026-10-12. Windows 설치형 앱과 owner-private Sites의 웹 교재가 같은 UI·교육 데이터를 공유한다. 설치형은 로컬 JDK/Maven으로 학습자 코드를 실행한다. 웹 코어 실행은 Cloudflare Worker API에서 Wandbox로 연결한다. 공개 GitHub와 개인 전용 호스팅의 접근 범위는 별개다. + +## Java 실행 계약 + +브라우저→같은 origin의 실행 API→외부 Wandbox 컴파일/실행 API로 연결한다. 제공자 endpoint·compiler·옵션은 서버가 고정한다. 교육 콘텐츠가 가진 과제 ID·버전·trusted checks를 서버가 결정하며 클라이언트의 기대값·컴파일 옵션을 신뢰하지 않는다. + +웹 실행의 실제 JVM은21(`openjdk-jdk-21+35`). `--release`로8/11/17/21의 언어/API 대상 컴파일을 검사해도 실행 JVM은21이다. 설치형 v0.3은 포함된 실제 JDK21을 자동 등록하며, 다른 실제 JDK8/11/17/21 등록은 선택 기능이다. CI에서는 네 실제 런타임을 분리한다. [번들 계약](bundled-runtime.md)을 따른다. 2026-10-12 웹 확인용 코드는 `--release\n8`과 package-private `class Main`으로 컴파일하고 `JAVA_EDU_OK`·`21-internal`을 출력했다. Java8 obsolete 경고와 종료0을 함께 확인했다. 이 결과는 전체 과제 검증을 대신하지 않는다. + +주 파일은prog.java이므로 template의 Solution과 Main은 package-private다. 사용자 Solution과 trusted Main harness를 합쳐 실행한다. 호출·반환 타입·컴파일 대상·샘플/제출 검사는 배포 소스에서 고정한다. 결과는 실행ID·작성 코드 snapshot에 연결한다. 단순 stdout 문구만으로 정답을 판정하지 않는다. 교육용 행동 검증은 악의적 제출에 대한 완전한 부정행위 방지 시스템을 뜻하지 않는다. + +초판의 완료 기록에는 과제 ID와 코드 snapshot을 사용하고 개정판을 별도 연결하지 않는다. 같은 ID의 검사·계약을 바꾸면 이전 성취가 복원될 수 있다. 후속 콘텐츠 개정 전에 revision 또는 계약 fingerprint를 추가하고 과거 성취를 재검증해야 한다. + +## 데이터와 접근 + +진도·작성 코드·설계 메모는 브라우저에 저장한다. 전 기기 동기화로 표시하지 않는다. 실행 버튼을 누르면 코드가 외부 Wandbox에 전송된다. save:false는 공개 permalink 생성을 막을 뿐 제공자 운영 로그에 저장되지 않음을 보장하지 않는다. UI에서 제3자 전송·로그 가능성·민감정보 입력 금지를 설명한다. + +Sites private gateway가 접근을 제한하고 인증된 방문자의 `oai-authenticated-user-id`를 제공한다. 배포 API는 이 trusted ingress 이후 identity를 요구한다. 로컬 preview만 identity 없이 실행한다. 공개 서비스로 전환하려면 계정·쿼터·비용·부정 사용을 별도로 설계한다. + +## 제한과 실패 + +서버에서 요청·소스·응답 크기와 대기시간을 제한하고 동일 origin·과제 ID·JSON 구조를 검사한다. 제공자별429·장애·컴파일 오류·실행 오류·검사 실패를 구분한다. POST 실행을 자동 재시도하지 않는다. 제공자의 IP 쿼터는 shared proxy 요청이 합산될 수 있으며 운영 상한은 미확인이다. 브라우저 취소는 결과 대기를 중단하는 것이며 원격 프로세스 종료를 보장하지 않는다. + +## Windows 설치형 실행 기반 + +사용자가 Windows 설치형 + 웹 교재 구성을 선택했다. Electron 앱의 격리된 렌더러가 세션·Origin·Host·CSRF로 보호된 loopback API와 통신한다. 등록한 실제 JDK8/11/17/21를 검사하고 선택한 JVM으로 실행한다. 전용 실행 디렉터리, 고정된 POM·테스트·편집 파일 목록, 출력·시간·Job Object 프로세스 한도로 실행과 정리를 관리한다. 학습 코드 자체는 학습자 OS 권한으로 동작하는 개인 IDE이므로 보안 샌드박스로 표현하지 않는다. + +Maven 의존성은 명시적인 환경 준비에서 내려받고 과제 실행은 오프라인이다. 기본 DB·웹 요청 실습은 H2·MockMvc를 사용하며 별도 호스팅 컨테이너 서버가 필요하지 않다. Redis/Kafka 단원의 실제 연동은 선택적인 고정 loopback 자원이 필요하다. 상세 계약과 제한은 [로컬 실행 계약](local-execution.md)을 따른다. + +설치형의 진도·초안·테마는 앱 userData에 보존하며 실행 서버의 임의 포트 변경과 무관하다. 웹과 설치형 사이 자동 동기화는 제공하지 않는다. 웹은 실무22단원의 정적 교재·다중 파일 예제·퀴즈·프로젝트 ZIP을 제공하고 해당 실행은 설치형에서 진행한다. 다운로드에는 상대 경로가 유지된 부모 POM·starter·reference·공개 grader를 함께 넣는다. + +## 공식 근거 + +- [Wandbox 공식 API 목록](https://wandbox.org/api/list.json) +- [Wandbox FAQ](https://github.com/melpon/wandbox#faq) +- [Wandbox 실행 처리·로그](https://github.com/melpon/wandbox/blob/develop/feline/src/api_compile_json.rs) +- [Wandbox 요청·응답](https://github.com/melpon/wandbox/blob/develop/feline/src/types.rs) +- [javac 공식 설명](https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html) + +Piston은 확인한 공개 Java 목록15.0.2 및 실행키 정책 때문에 이번 무키 프로토타입에서 선택하지 않았다. [공식 정책](https://github.com/engineer-man/piston#public-api) diff --git a/docs/assets/java-edu-banner.svg b/docs/assets/java-edu-banner.svg new file mode 100644 index 0000000..17fbc01 --- /dev/null +++ b/docs/assets/java-edu-banner.svg @@ -0,0 +1,31 @@ + + Java Edu + 공식 Java 문서 기반 학습. Java 8, 11, 17, 21. Windows 로컬 실습과 웹 교재. + + + + OFFICIAL DOCS. HANDS-ON JAVA. + Java Edu. + Understand the contract. Write the code. + + + JAVA 8 · 11 · 17 · 21 + + WINDOWS + WEB + + THEORY → EXAMPLES → QUIZ → PRACTICE → CODE + + + + + + + JavaEdu.java + + final class JavaEdu { + String topic = "OOP"; + int[] versions = {8,11,17,21}; + // Read. Reason. Run. + } + + diff --git a/docs/assets/learning-lab.png b/docs/assets/learning-lab.png new file mode 100644 index 0000000..602d965 Binary files /dev/null and b/docs/assets/learning-lab.png differ diff --git a/docs/bundled-desktop-qa-report.md b/docs/bundled-desktop-qa-report.md new file mode 100644 index 0000000..4a28a9b --- /dev/null +++ b/docs/bundled-desktop-qa-report.md @@ -0,0 +1,82 @@ +# 0.3 번들 Windows 앱 독립 QA + +상태: **PASS — 최신 ZIP 10/10·NSIS 10/10 기능, 설치·제거, 독립 시각 검수**. 2026-10-12에 같은 최종 제품으로 캡처 안정화 조건을 추가해 ZIP은 08:53:27.461–08:54:38.767 UTC, NSIS는 08:57:30.366–08:58:39.164 UTC에 실제 검증하고 각각 정상 종료했다. 총괄은 08:59:47 UTC에 제거·테스트 데이터 보관을 마쳤다. 아래 판정은 각 제품의 최신 10개 검사에 근거하며 이전 실행이나 0.2 결과를 합산하지 않는다. 초기 환경 검사·이전 ZIP·첫 NSIS 무결성 실패 이력은 따로 보존한다. + +실행한 테스트 SHA-256은 두 제품 모두 `1b3cede082f77048135e87537c97cedef5b87bcce73644f040aa66dbd9f62969`다. 최신 결과는 `work/bundled-desktop-qa/zip/bundled-results-1791795278768.json`과 `work/bundled-desktop-qa/nsis/bundled-results-1791795519165.json`이다. 실제 Electron 패키지·번들 JVM/Maven을 사용했으며 API·실행 결과를 모의 처리하지 않았다. + +독립 시각 검수에서 이전 재시작 화면의 상단 빈 띠를 발견해 추가 검증했다. 기존 helper는 toast 숨김만 기다렸지만, 새 helper는 실제 scrollX/Y·viewport·문서 크기·주요 영역 bounds·내부 scrollTop의 600ms 연속 안정과 캡처 전후 일치를 확인한다. ZIP은 scrollY가 923→1185→1201로 이동한 뒤 332–945ms 동안, NSIS는 923→1178→1201로 이동한 뒤 293–906ms 동안 동일하게 유지됐다. 안정 시 두 제품 모두 사이드바 y=24px·편집기 y=429.63px였다. 재시작 기본·확인 PNG 네 장의 SHA-256은 모두 `04fc6d5a3c960b5c3febbc26c2a90c659fbfa770b5248fdf16bf7c40918c20aa`이며 직접 확인한 네 이미지에 이전 211/248px의 상단 빈 띠가 없다. 같은 제품에서 지속적인 레이아웃 결함은 재현되지 않았고, 기존 캡처의 이동 완료 대기 누락으로 판단한다. 화면을 맞추는 스크롤·스타일·초점 조작이나 제품 변경은 추가하지 않았다. 각 결과 JSON의 `captures`에 샘플을 보존했다. + +## 실제 결과 + +| 검사 | ZIP | NSIS | +| --- | --- | --- | +| 외부 도구 경로 없이 번들 자동 등록 | PASS; JVM 21·Maven 3.10.0, source bundled | 동일 PASS | +| 동의 전 실행·폴더 선택 없음 | PASS; 학습자 요청 0회, 폴더 선택 0회 | 동일 PASS | +| Java 8 대상 제출 | 실제 JVM 21에서 6/6 | 실제 JVM 21에서 6/6 | +| Java 11 대상 제출 | 실제 JVM 21에서 5/5 | 실제 JVM 21에서 5/5 | +| Java 17 대상 제출 | 실제 JVM 21에서 6/6 | 실제 JVM 21에서 6/6 | +| Java 21 대상 제출 | 실제 JVM 21에서 6/6 | 실제 JVM 21에서 6/6 | +| Spring/H2 환경 준비 | 실제 Maven 2/2; 초안·미완료 성취 유지 | 동일 PASS | +| Spring/H2 학생 제출 | 실제 Maven 2/2; --offline 확인 | 동일 PASS | +| 실제 앱 재시작 | 포트 52415→61427; 등록 경로·동의·진도·초안·테마 보존 | 포트 63593→60542; 동일 보존 PASS | +| 최소 창·문서 글꼴 200% | 704×521 viewport; 상단·도구 카드·닫기 버튼 전체 가시성, 가로 넘침 없음·닫기 PASS | 동일 PASS | + +각 제품에서 실제 관측한 코어 java/javac 명령 8개와 Maven 명령 2개 모두 해당 제품의 `resources/toolchains` 내부 파일을 사용했다. 각 javac의 `--release` 값은 8/11/17/21에 일치했다. 준비의 실제 Maven 명령에는 `--offline`이 없고 학생 제출에는 있었다. 두 앱 자식의 PATH/JAVA_HOME/MAVEN_HOME는 실제로 모두 부재였고 기본 사용자 데이터는 `C:/Users/USER/AppData/Roaming/java-edu`였다. 기록된 브라우저 오류는 각각 0건이며 앱 API 요청은 해당 앱의 loopback origin뿐이었다. + +각 제품의 최신 폴더에 `bundled-first-launch-settings.png`, `bundled-settings-minimum200-{top,tools,close}.png`, 코어 대상별 제출, Spring/H2 준비·제출, `bundled-restarted-achievement.png`와 `bundled-restarted-achievement-confirmation.png`를 저장했다. 재시작 캡처는 자연스럽게 스크롤된 편집기 위치이며, 저장된 객관식·실습 성취 자체는 UI의 실제 data-complete 및 저장값으로 별도 검증했다. 모든 최신 원본을 독립 시각 담당에게 전달했다. + +총괄의 최신 `work/bundled-desktop-qa/package-zip.json`은 검토 커밋 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`의 원본 197개·생성 2개·번들 604개(355,610,126 bytes)가 빌드 및 최종 ZIP과 일치함을 기록한다. 번들 ID는 `548c060050b821dbcb99fd90e3f9e992e47e6ab3899d12551c9d7fb965581760`, 최종 ASAR SHA-256은 `8ee1c5df3de2472ff18a31e45cb6054cedd17b89389db8e579aea87c8c7c51e2`, 앱 exe SHA-256은 `d7e0bfab510476dc433b175624a10dd4753c725c4d72949f6870a2af7b09c043`다. 패키지 전체 바이트 대조는 총괄 수행이며 이 보고서의 실제 앱 실행과 구분한다. + +총괄의 최신 `installation.json`은 08:56:18 UTC에 최종 NSIS가 지정 특수문자 경로에 exit 0으로 설치되었음을 기록한다. 설치 EXE SHA-256은 `b3e0a56b339fc3946cd80c194b9b5a544fdb3d5044277fc00acd6a9a11e8eeb8`이다. `package-nsis.json`도 같은 소스 197개·생성 2개·번들 604개 및 동일 ASAR/exe/helper 바이트 일치를 확인한다. QA는 실제 앱을 종료했으며 설치·제거·레지스트리 변경·AppData 보관을 직접 수행하지 않았다. + +총괄의 최신 `uninstallation.json`은 08:59:47 UTC에 NSIS exit 0, 전용 설치 경로 없음, 제거 등록 0개, 설치 바로가기 0개, 학습 데이터 보존을 확인한다. 이후 총괄은 사전 부재·실제 실행 결과의 userData·생성 시점·종료 상태를 대조해 이번 ZIP 데이터를 `app-data-zip-stability`, NSIS 데이터를 `app-data-nsis-stability`로 보관했다. 각 `-archive.json`은 해당 최신 실행 JSON과 정확한 원래 userData를 연결한다. 원래 사용자 AppData 두 경로가 없던 상태를 복원했다는 총괄 확인을 받았다. 이 보관·제거는 총괄 수행이다. + +## 패키징 수정 이력 + +같은 최종 소스의 안정화 전 검사는 ZIP 08:35:21–08:36:23 UTC, NSIS 08:39:30–08:40:31 UTC에 각각 10/10 PASS였다. ZIP 결과 `bundled-results-1791794183707.json`의 테스트 SHA는 `8d82e6512ef70728b726d0ff6c4560db24bc887fa48b0510e5782ed3c0ba70f8`, NSIS 결과 `bundled-results-1791794431439.json`의 테스트 SHA는 `f6eab97f0ab4e86ce5910cd6e4550b91347463ec0e2af3ac4f70114aa258ebe6`이다. 당시 PNG·JSON은 `{zip,nsis}-source-0e1ff5-before-stability`에 별도 보존했다. 당시 NSIS에는 최소 창·200% 도구 카드 중간 검사와 정확한 설치 경로 guard를 추가했다. 총괄의 당시 설치 시각은 08:37:45, 제거 시각은 08:41:27 UTC였다. 데이터는 `app-data-zip-source-0e1ff5`·`app-data-nsis-final`에 보존했고 과거 제거 근거도 총괄이 별도 보존했다. 이 초기 재시작 PNG는 최종 안정 화면의 근거로 사용하지 않는다. + +이전 소스 `ffb77751b45d360804090a46fe7efa2e105ab81d`의 ZIP은 08:15:00–08:16:02 UTC에 10/10 PASS였다. 결과 `bundled-results-1791792962539.json`과 모든 당시 화면은 `work/bundled-desktop-qa/zip-source-ffb77751`에도 별도 보존했다. 당시 ASAR는 `a8c775223188330b4f2e9afee77513b6629fbdab50b611841ce5b37fdacbd8cb`, exe는 `f8dfdcd01c5dcef6a8b0769a394c86f0ba0b1e07d594fbabc01a230799e32b70`이었다. + +그 뒤 총괄이 첫 NSIS 설치 산출물의 번들 파일 604개 중 602개만 존재함을 발견해 무결성 FAIL로 중단했다. 해당 NSIS의 앱 기능 QA는 실행하지 않았다. 총괄이 NSIS 압축 옵션 `differentialPackage:false`, `useZip:true`를 적용해 새 소스로 재빌드했고 전체 바이트 대조를 통과한 최종 ZIP을 위와 같이 재검증했다. 이전 기능 PASS를 새 패키지 검증으로 대체하지 않았다. 첫 NSIS 실패와 새로운 설치·무결성 검증은 총괄 근거에 따라 구분한다. + +## 실행 조건과 소유권 + +총괄이 실제 사용자 AppData 부재를 `work/bundled-desktop-qa/preflight.json`에 확인하고 ZIP 해제·NSIS 설치를 수행한다. QA는 총괄이 지정한 실행 파일만 시작하며 사용자 데이터 경로를 우회하지 않는다. 앱 정상 종료 뒤 생성된 QA 데이터 보관과 설치 제거도 총괄 담당이다. 기존 사용자 AppData가 발견되면 앱 실행 전 중단한다. + +순서는 ZIP → 총괄 데이터 보관 → NSIS다. ZIP은 `work/bundled-zip/한글 학습 & %JAVA_HOME%`를 포함한 실제 경로에서 실행한다. 최종 NSIS는 총괄이 지정한 `work/한글 설치 & %JAVA_HOME%/Java Edu.exe`를 실행한다. 검사 파일은 `tests/bundled-desktop.mjs`, 근거는 `work/bundled-desktop-qa/{zip,nsis}`에 둔다. + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED='1' +$env:NODE_OPTIONS='--use-system-ca' +node tests/bundled-desktop.mjs --kind zip --executable '' +node tests/bundled-desktop.mjs --kind nsis --executable '' +``` + +두 명령 사이에는 총괄의 사용자 데이터 보관·다음 제품 준비 확인이 필요하다. 환경변수 `BUNDLED_PACKAGE_KIND`, `BUNDLED_APP_EXE`로 같은 입력을 전달할 수도 있다. + +## 수용 조건 + +| 영역 | 각 제품에서 확인할 실제 행동 | +| --- | --- | +| 자동 등록 | JDK 21 및 Maven `source: bundled`; 리소스 경로가 해당 제품 내부이며 `bundledRuntime.id/javaVersions/mavenVersion` 일치 | +| 외부 도구 불필요 | 앱 자식 환경에서 PATH 명시적 빈 값, JAVA_HOME/MAVEN_HOME 제거, JDK/Maven 폴더 선택 호출 0회 | +| 동의 경계 | 최초 동의 false, 실행/제출 비활성; 앱·설정 진입과 동의 체크만으로 코드·환경 준비 요청 없음 | +| 코어 | Java 8/11/17/21 대상 기준 답안을 모두 실제 번들 JVM 21로 제출; 결과뿐 아니라 실행 명령의 번들 java/javac 경로 및 --release 대상 확인 | +| 실무 | tx-proxy의 실제 Spring/H2 환경 준비와 제출; 준비 시 학생 초안과 미완료 성취 보존; POM 제외한 학생 파일만 제출 | +| Maven 모드 | UI가 만든 일시적 command JSON을 읽기 전용 관찰; 번들 Maven launcher/home 사용, 준비에는 --offline 없음, 학생 제출에는 --offline 있음 | +| 재시작 | 실제 종료·다른 포트 재시작; 번들 경로/source, 동의, 진도, 테마, 코어·파일별 초안 보존; 실제 편집기 끝의 초안 주석 확인 | +| 화면 | 기본 설정의 번들 표시, 최소 창 720×560·문서 글꼴 200%의 설정 상단·도구 카드·닫기 버튼; 가로 넘침과 실제 닫기; scroll/bounds 안정 캡처 | + +폴더 선택 API는 호출 사실을 기록하고 실패시키는 감시만 사용하며 경로 값을 주입하지 않는다. 앱 API·Java·Maven 응답은 모의 처리하지 않는다. 클립보드도 대체하지 않고 실행 요청 원문·실제 저장값·편집기의 표시를 대조한다. 실제 OS 폴더 대화상자 조작과 클립보드 작동은 검사 범위가 아니다. + +실행 중인 이 앱의 command JSON은 읽기만 하며 AppData 파일을 직접 작성·삭제하지 않는다. 최초 Maven 환경 준비의 의존성 다운로드에는 정상 네트워크가 필요하다. 준비 후 학생 제출의 Maven 오프라인 옵션을 확인하는 것은 모든 Java 코드의 네트워크 차단을 의미하지 않는다. + +## 현재 실제 결과와 미확인 + +최신 ZIP·NSIS의 실제 번들 JVM/Maven 제출과 데이터 보존은 모두 위와 같이 통과했고 총괄의 제거·데이터 보관도 완료했다. 재시작 화면의 빈 띠는 안정화 후 두 제품에서 재현되지 않았다. 독립 시각 담당도 최신 재시작 원본·확인 화면과 최소 창·200% 도구 카드·닫기 버튼을 직접 검토하여 양쪽 제품 PASS를 전달했다. 기존 0.2 검사와 제품 소스는 수정하지 않았다. 각 제품의 실무 패키지 검증은 대표 Spring/H2 tx-proxy를 대상으로 하며 기본 20개 실무 프로젝트 전부를 이 검사에서 다시 실행하는 것은 아니다. + +첫 ZIP 앱 시작은 환경 확인에서 BLOCKED되었다. 자식 환경 객체에서 PATH를 삭제해도 Node/Windows가 부모 PATH를 복원하는 동작을 관찰했으며, 별도 무해한 Node 자식 프로세스에서도 shell:false 조건으로 재현했다. PATH 생략 시 기존 경로가 남고 명시적 빈 문자열일 때 길이 0이었다. 제품 실행 실패로 판정하지 않으며 학습자 코드는 실행하지 않았다. 테스트는 PATH를 명시적 빈 값으로 전달하고 실제 Electron 자식의 빈 값을 확인하도록 보완했다. JAVA_HOME/MAVEN_HOME와 QA_DATA 등은 계속 제거한다. 최초 근거 `zip/bundled-results-1791792262438.json`은 보존한다. + +두 번째 환경 검사는 PATH 키가 반드시 존재하면서 빈 값이어야 한다는 과도한 기대값으로 중단됐다. Playwright가 사용하는 Windows shell:true 조건을 별도 Node 자식으로 재현하니 빈 PATH 키 자체가 제거됐으며, shell:false에서는 빈 키가 유지됐다. 검사 조건을 ‘PATH 부재 또는 빈 값’으로 고쳤고 실제 환경값을 실패 여부와 무관하게 먼저 기록한다. 두 번째 실행도 학습자 요청 전 중단·정상 종료되었으며 `zip/bundled-results-1791792702100.json`과 기본 사용자 데이터 출처를 보존한다. + +화면 근거는 실제 Windows 패키지 앱의 Electron viewport 캡처이며 물리적 모바일 기기 검사가 아니다. SmartScreen의 인터넷 다운로드 경고 흐름, OS 클립보드, 번들 밖 JDK 및 Redis/Kafka 외부 서비스 실행은 이 검사에 포함하지 않는다. diff --git a/docs/bundled-runtime.md b/docs/bundled-runtime.md new file mode 100644 index 0000000..ece96f1 --- /dev/null +++ b/docs/bundled-runtime.md @@ -0,0 +1,38 @@ +# v0.3 통합 Windows 패키지 + +학습자는 Java·Maven·Node.js를 따로 설치하거나 시스템 PATH를 변경하지 않는다. NSIS 설치 EXE 또는 전체 ZIP에 Electron 앱, Eclipse Temurin JDK21, Apache Maven3를 포함한다. ZIP은 전체 내용을 해제한 뒤 Java Edu.exe를 실행한다. exe 하나만 다른 폴더로 이동하면 동작하지 않는다. + +## 기본과 선택 도구 + +기본 도구는 Temurin21.0.12.1+1 Windows x64와 Maven3.10.0이다. 앱 내부 resources/toolchains에서 자동 등록하며 실제 실행 때 java·javac를 검증한다. Java8/11/17/21 교재와 컴파일 대상을 지원하지만 기본 실제 JVM은21이다. 네 실제 JVM을 모두 포함한 패키지라고 표현하지 않는다. 기존 사용자의 외부 JDK/Maven 선택을 보존하며 다른 실제 JDK8/11/17 등록은 고급 설정의 선택 기능이다. + +처음 학습자 코드 실행에는 명시적인 동의가 필요하다. 번들 발견·자동 등록이 학습 코드 자동 실행 권한은 아니다. 앱의 프로세스·파일 편집·명령 계약은 [로컬 실행 계약](local-execution.md)을 유지한다. + +코어 실습은 별도 의존성 다운로드 없이 실행한다. 기본20실무프로젝트는 첫 환경 준비에서 앱이 Maven Central의 고정 의존성을 내려받고 기준 테스트를 실행한다. 별도 프로그램 설치는 필요 없지만 이 준비에는 인터넷이 필요하다. 이후 학습자 Maven 제출은 기존 오프라인 모드다. Redis/Kafka 실제 연동은 선택 환경이며 이 패키지가 외부 서버까지 포함하지는 않는다. + +## 패키징과 무결성 + +개발자는 공식 Windows x64 JDK21·Maven ZIP의 체크섬을 확인하고 전체 내용을 해제한다. 원본 바이너리·legal·LICENSE·NOTICE·JAR 내부 고지를 수정하거나 제거하지 않는다. scripts/bundle-runtime.mjs에 두 배포 폴더와 출처 JSON을 전달해 저장소 내부 work/desktop-bundle을 생성한다. 기존 출력은 자동 덮어쓰지 않는다. binaries와 생성 bundle은 Git에 포함하지 않는다. + +출처 JSON은 jdks 배열과 maven 객체에 배포명·정확한 버전·공식 URL·archiveSha256·license·licenseFiles·sourceCodeUrl·redistributionSourceUrl·redistributionSourceFiles를 기록한다. 최종 manifest와 THIRD-PARTY-NOTICES는 앱의 resources/toolchains에 포함한다. 파일별 SHA256 inventory로 스테이징·빌드 전에 전체 파일을 대조한다. 이 목록은 코드 서명이나 OS 보안 샌드박스를 대신하지 않는다. + +```sh +node scripts/bundle-runtime.mjs --jdk21 --maven --metadata desktop/toolchain-provenance.json +node scripts/bundle-runtime.mjs --check +npm run build:desktop +``` + +빌드는 설치 EXE와 Windows x64 ZIP을 함께 만든다. NSIS는 differentialPackage:false·useZip:true로 내부 ZIP을 사용한다. 첫 빌드의7z ARM64 필터 두 파일을 NSIS의19.00 해제기가 누락한 실제 무결성 실패를 확인해, 원본 파일을 모두 유지하는 ZIP 경로로 수정했다. [electron-builder 공식 옵션](https://www.electron.build/v26/docs/nsis/)을 따른다. 자동 업데이트는 제공하지 않으며 차등 업데이트용 패키지를 생성하지 않는다. 앱의 설치·ZIP 위치가 바뀌면 번들 항목은 현재 resources 기준으로 다시 해석한다. 외부 선택 경로를 임의로 번들 경로로 변경하지 않는다. 버전별 검사와 실제 설치·ZIP 실행 결과는 별도 QA 보고서에 기록한다. + +## 배포 출처·라이선스·대응 소스 + +JDK ZIP205073461바이트의 SHA256는 `f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e`다. Maven ZIP10102082바이트의 SHA256는 `1f6d9909266510f039f59aa0e13dcd2c66da85f043e56276e41f2918f8bddaff`이며 Apache의 SHA512도 대조했다. [Temurin 릴리스](https://github.com/adoptium/temurin21-binaries/releases/tag/jdk-21.0.12.1%2B1)와 [Apache Maven 공식 다운로드](https://maven.apache.org/download.cgi)를 기준으로 원본을 확인했다. + +Temurin은 GPLv2와 Classpath Exception, Maven은 Apache License2.0 및 포함된 제3자 조건을 따른다. JDK NOTICE·legal243개 파일, Maven 루트LICENSE/NOTICE·lib/boot의 원본 license 파일과 JAR 내부 고지를 보존한다. [Adoptium FAQ](https://adoptium.net/docs/faq), [공급망 자료](https://adoptium.net/docs/slsa), [Apache License](https://www.apache.org/licenses/LICENSE-2.0)를 참고한다. 이 조건은 Java Edu 프로젝트 자체의 라이선스를 임의로 지정하지 않는다. + +같은 GitHub 릴리스에 다음 대응 소스를 함께 제공한다. 학습자는 이를 실행을 위해 따로 설치할 필요가 없다. + +- OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz: 공식 대응 소스, SHA256 `573057d03584ae793fb7ec9a14c76d826d9187a53efeefd99da47403a5308234`. 바이너리와 source commit `1c417fbfc2f70ab03a565b0af0a5a3c6f5e15ad6`이 일치한다. +- Third-Party-Sources-0.3.0.zip: 정확한 Temurin build commit `e6ba7dec3d07654074559310376a3ae89da5f4ac`, Maven3.10.0 소스, Sisu Inject/Plexus1.1.0 및 javax.annotation-api1.3.2 sources.jar, 출처·체크섬 manifest와 해당 라이선스 고지. + +JDK lib/src.zip만으로 전체 네이티브 코드와 빌드 소스를 대체하지 않는다. Maven 소스 ZIP도 EPL2/CDDL 조건의 위 제3자 JAR 소스를 대체하지 않는다. 교재 Maven 의존성 캐시는 번들에 넣지 않으며, 추후 포함할 경우 전체 전이 의존성과 플러그인의 별도 고지·소스 제공 조건을 검토한다. diff --git a/docs/content-readability.md b/docs/content-readability.md new file mode 100644 index 0000000..e635d13 --- /dev/null +++ b/docs/content-readability.md @@ -0,0 +1,28 @@ +# 코드로 이해하는 교재 작성 기준 + +다른 언어 경험자와 현직 Java 개발자가 개념을 실제 코드에 연결하도록 작성한다. 공식 문서는 정확성을 확인하는 기준이다. 명세의 문장 순서를 교재의 설명 순서로 사용하지 않는다. + +## 이론 안의 설명 순서 + +1. 구체적인 상황과 질문: 어떤 입력·호출에서 예상과 다른 결과가 생기는가? +2. 용어와 뜻: 이 상황을 설명하는 용어를 실제 변수·객체·메서드와 연결한다. +3. 핵심 코드: 기존 예제에서 연속된 부분을 그대로 발췌한다. 전체 코드와 구분하며 예제 탭에서 원문을 제공한다. +4. 순서대로 읽기: 호출 전 값, 실행되는 분기, 바뀐 값, 실패 후 남는 상태를 설명한다. +5. 결과 예측과 해설: 독자가 먼저 생각한 뒤 답을 펼친다. 이 활동은 퀴즈·실제 제출 성취로 기록하지 않는다. +6. 적용할 판단: 실무에서 무엇을 확인하거나 선택해야 하는지 한 문장으로 정리한다. + +이 흐름 뒤에 기존 상세 이론과 검수한 도식을 제공한다. 코드 해설이 다루지 않은 추가 개념은 기본 본문에 남긴다. 학습 목표·수업 메타데이터는 수업 정보에서 펼쳐 확인하며, 공식 출처와 실무 공고도 별도 펼침 영역으로 제공한다. 수업의 이론 → 예제 → 문제 → 실무 → 코드 실습 구조는 유지한다. + +## 본문을 위한 화면 공간 + +수업 화면은 목차와 본문을 중심으로 구성한다. 반복되는 큰 소개·통계·워크스페이스 표제는 줄이고 전체 진도와 이어서 학습은 보존한다. 참고자료가 본문 옆의 상주 열을 점유하지 않도록 한다. 좁은 화면과 글자 확대에서는 바깥·안쪽 여백의 상한을 낮추며 글자 크기를 줄이거나 페이지 넘침을 숨기지 않는다. + +## 쉬운 설명에서도 지킬 정확성 + +“잔액이 바뀌어도 음수가 되면 안 된다”처럼 구체적인 상황을 설명한 뒤 불변식이라는 이름을 붙인다. 용어만 다른 추상 용어로 바꾸거나 비유만으로 설명을 마치지 않는다. 정상 결과와 실패 결과를 함께 설명한다. + +언어 명세의 보장, 프레임워크의 기본 설정, 이 예제가 선택한 정책을 구분한다. 참조값 복사와 객체 복사, 가시성과 연산 원자성, flush와 commit, OAuth2와 OIDC, 검증용 fixture와 전체 프로토콜 구현을 같은 뜻으로 설명하지 않는다. 조건과 반례를 생략해 잘못된 단정으로 만들지 않는다. + +## 검수 범위 + +34개 수업의 가이드 구조·원문 발췌·실행 계약 보존을 검사한다. 독립 내용·코드 검토와 실제 브라우저 화면·접근성·진도 회귀 검사를 수행한다. 실제 설치 패키지는 별도로 확인한다. 자동 검사와 검토 통과는 실제 학습자의 이해도 향상을 측정한 결과가 아니다. 검수 결과는 [교재 가독성 QA](readability-qa-report.md)에 기록한다. diff --git a/docs/curriculum.md b/docs/curriculum.md new file mode 100644 index 0000000..5062389 --- /dev/null +++ b/docs/curriculum.md @@ -0,0 +1,92 @@ +# Java 공통 코어: 교재와 실습 계약 + +기준일: 2026-10-12. 대상은 다른 언어로 프로그래밍 경험이 있는 Java 전환 학습자와 현직 Java 개발자다. 이 문서는 현재 작성한 12개 시작 수업의 범위와 편집·평가 기준을 설명한다. 완전한 Java·Spring 실무 교육과정의 완성 선언이 아니다. 전체 16단계 교육 로드맵과 제품 기획은 별도 문서를 따른다. + +## 학습 흐름 + +각 수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩의 다섯 단계로 구성한다. 실행 가능한 IDE는 첫 공개 MVP의 필수 기능이다. 수업 데이터와 채점 데이터가 준비되었다는 사실은 실행 서버·브라우저·격리 환경의 통합 검증 완료를 뜻하지 않는다. + +전환 학습자는 값 전달과 객체 모델에서 시작하여 OOP·타입 안전성·실패 처리를 공통 코어로 학습한다. 현직자는 코드 해석·수정·설계 설명의 진단으로 통과한 단원을 건너뛰고 부족한 코어와 버전 심화를 연결한다. 경력·객관식 점수 하나만으로 단원을 면제하지 않는다. + +권장 선수 관계는 다음과 같다. + +- Java 객체·타입 모델 → 책임·불변식 → 다형성·계약·의존 관계. +- 객체 모델 → 컬렉션의 동등성·변경 계약 → 제네릭 API 경계. +- 객체·컬렉션 → 예외·자원·Stream → 테스트 설계. +- 가변 상태·실패 처리·테스트 → JMM·동시성. +- 공통 코어 → Java 11 API → Java 17 결과 모델 → Java 21 패턴·가상 스레드. + +테스트 설계는 데이터 목록의 표시 위치와 관계없이 동시성 심화 전에 학습하거나 진단으로 통과하는 것을 권장한다. 학습 시간은 난이도와 경험에 따른 참고치이며 고정 수료 기간이 아니다. + +## 현재 수업과 직접 코딩 과제 + +| 수업 ID | 학습 초점 | 예제 대상 | 과제 형태 | 주요 완료 증거 | +| --- | --- | --- | --- | --- | +| java-basics | 값 전달·참조·boxing·내용 동등성 | 8 | 메서드 본문 | null을 포함한 문자열 동등성 계약 | +| oop | 책임·불변식·캡슐화·실패 후 상태 | 8 | 클래스 골격 | 유효한 예약과 실패 후 재고 보존 | +| polymorphism | LSP·상속/합성·의존성·수동 DI | 8 | 클래스 골격 | 정책 교체와 할인 범위·호출 계약 | +| collections | 순서·중복·equals/hashCode·복사 | 8 | 메서드 본문 | 최초 순서·내용 동등성·입력 보존 | +| generics | 불공변성·PECS·타입 소거 | 8 | 메서드 본문 | 안전한 source/target 타입과 복사 | +| exceptions | 실패 책임·원인·자원 소유권 | 8 | 메서드 본문 | 유효 범위·거절·cause 보존 | +| streams | 지연 연산·최종 연산·부수 효과 | 8 | 메서드 본문 | 조건 집계·입력 보존·설계 설명 | +| concurrency | JMM·원자성·완료 대기·종료 | 8 | 클래스 골격 | 반환 계약과 별도 동시성 설계 검토 | +| modern-apis | String·HttpClient·API Since | 11 | 메서드 본문 | 줄·유니코드 공백·null 입력 처리 | +| records-sealed | 값 모델·닫힌 결과 타입 | 17 | 클래스 골격 | 성공·거절 이유와 record 값 동등성 | +| java21 | record pattern·when·가상 스레드 | 21 | 메서드 본문 | 타입·가드·null 분기 | +| testing-design | 계약·경계·테스트 판별력 | 8 | 메서드 본문 | 정상 정책 수용·잘못된 정책 거절 | + +예제는 독립적인 `Main.java`이며 외부 라이브러리 의존성이 없다. 웹 실습은 전체 `class Solution`을 편집한다. 메서드 본문형에도 클래스·메서드 시그니처를 제공하지만 이 단계의 에디터는 수정 범위를 강제 잠그지 않는다. 클래스 골격형은 외부 호출 계약을 문제 설명에 명시하며 내부 필드와 협력 구조는 학습자가 설계한다. + +제네릭 복사 과제는 source와 target이 저장소를 공유하는 뷰·래퍼 관계가 없고, target이 source의 모든 요소를 수용할 수 있다는 전제에서 수행한다. 서로 다른 객체라는 조건만으로 구조 변경의 독립성이 보장되지는 않는다. [Java SE 8 List.subList 계약](https://docs.oracle.com/javase/8/docs/api/java/util/List.html#subList-int-int-)에서 원본과 뷰의 관계를 확인한다. + +## 평가를 구분하는 원칙 + +1. **컴파일·실행 성공**: 코드가 대상 버전으로 컴파일되고 표시된 JVM에서 실행되었다. +2. **행동 검증 통과**: 명시한 자동 테스트에서 기대 행동이 확인되었다. +3. **설계 검토**: 책임·캡슐화·의존 관계·타입 사용·동시성 근거를 설명 루브릭으로 검토한다. + +자동 테스트 통과만으로 객체 설계나 동시성 숙련을 인증하지 않는다. 예를 들어 concurrency 과제에서 단순히 workers × iterations를 반환하면 일부 행동 검사를 통과할 수 있다. 실제 작업 제출·원자 연산·완료 대기·실행기 종료는 설계 검토 대상임을 공개한다. 제네릭의 raw type 우회, Stream을 사용하라는 학습 요구도 동작 테스트만으로 완전히 판단할 수 없다. + +실행 결과와 설계 검토 대기를 화면에서 구분해야 한다. 설명란에 글자를 입력했다는 사실만으로 설계 평가를 통과 처리하지 않는다. 실패 피드백은 컴파일 오류, 실행 오류, 기대 행동 불일치, 환경·지원 버전 문제를 구분한다. 학습자는 코드를 보존하고 재시도할 수 있어야 한다. + +객관식은 수업당 두 문항이며 정답과 해설을 함께 제공한다. 코드 실습은 공개 샘플 검사와 추가 행동 검사로 구성한다. 검사 대상은 명시된 입력 계약이며, 문서에 없는 조건으로 학습자를 실패시키지 않는다. 공개 저장소의 기준 답안과 검사 파일을 비밀 평가 장치라고 표현하지 않는다. + +## 공식 근거와 버전 편집 + +- 언어 의미는 해당 Java SE의 JLS, API는 API Specification의 계약·Since, 실행 모델은 공식 가이드를 우선한다. +- sources에는 title, url, edition, checkedAt을 기록한다. checkedAt은 자료 확인일이며 기능 출시일이 아니다. +- minVersion은 8·11·17·21 중 이 수업 예제와 실습을 지원하는 최소 대상이다. +- featureSince는 대표 기능의 최초 도입 버전이다. 서로 다른 도입 시점의 공통 개념을 묶은 경우 null로 두고 본문에 개별 기능의 버전을 적는다. +- 제네릭은 5, 지역 변수 var는 10, 람다 매개변수 var는 11, switch 표현식은 14, text block은 15, record와 instanceof 패턴은 16, sealed는 17, record pattern·pattern switch는 21에서 정식 도입되었다. +- Java 8 예제에 List.of·Stream.toList·Optional.isEmpty를 섞지 않는다. Java 17에 preview pattern switch를 섞지 않는다. +- 모든 현재 수업은 preview: false다. record의 깊은 불변성, volatile의 복합 연산 원자성, 가상 스레드의 자동 CPU 가속을 주장하지 않는다. +- 설계 원칙·LSP·SOLID의 적용 판단은 교육적 해석이다. Java 명세가 특정 설계 패턴을 의무화한다고 설명하지 않는다. + +원문을 그대로 옮기는 방식보다 한국어 설명·자체 예제·반례를 작성하고 근거 문서를 연결한다. 예제의 출력이 바뀌면 교재 코드, 독립 Main.java, expected-output.json을 함께 수정한다. 과제 계약이 바뀌면 공개 metadata, trusted 검사, 기준 답안을 함께 갱신한다. + +## 실무 연결 범위 + +현재 12개 실행 과제는 순수 Java이다. 수동 의존성 주입을 Spring 실행으로, 직접 AssertionError를 던지는 검사를 JUnit 실행으로 표시하지 않는다. Spring·Lombok·MyBatis·JPA·OAuth 2.0·OIDC·Redis·Kafka 등은 전체 로드맵에서 의존성·DB·HTTP 통합 실행 능력이 준비된 단원으로 확장한다. + +jobTags는 교과와 공고 근거를 연결하기 위한 분류다. 태그가 있다는 사실만으로 해당 수업 내용을 특정 기업이 요구했거나 출제했다는 뜻은 아니다. 실제 공고 관찰은 별도 job-evidence 자료의 URL·확인일·요구/우대·접근 한계를 따른다. 교육상 필요한 보완 기술과 공고 본문에서 실제 확인한 기술을 구분한다. + +## 작성 산출물과 직접 검증 + +- dist/data/curriculum.js: 12수업, 이론 36항목, 객관식 24문항, 독립 예제·실무 과제·공식 출처. +- dist/data/exercises.js: 공개 실습 12개, 과제 계약·초기 코드·샘플 기대 결과·설명 루브릭. +- server/exercises.mjs: 클라이언트 입력과 분리된 trusted 검사 73개와 샘플 검사. +- examples/java8·java11·java17·java21: 독립 Main.java 12개. +- examples/expected-output.json: 독립 예제의 대상 버전·mainClass·기대 stdout. +- examples/solutions.json: 과제 ID별 기준 Solution 코드 12개. + +작성 시 데이터 연결·예제 동일성 검사와 설치된 JDK의 컴파일·출력 비교를 수행한다. 실제 실행한 환경·대상·검사 수는 통합 검증 기록을 따른다. JDK 21에서 --release 8로 컴파일한 결과와 실제 JDK 8에서 실행한 결과는 다른 검증으로 기록한다. 브라우저 IDE 통합·실행 격리·접근성 검수는 별도 담당 결과를 확인한 뒤 공개 준비 여부를 판정한다. + +2026-10-12 교육 데이터 작성자의 직접 검사 결과: + +- JDK 8u265: Java 8 독립 예제 9개 컴파일·실행·기대 stdout 비교 통과. +- JDK 8u265: 독립 검토 수정 후 Java 8 기준 답안 9개를 다시 컴파일·실행하고 행동 검사 56개 통과. +- 알려진 결함 7종(참조 비교, 0 예약 허용, 정책 중복 호출, 순서 미보장 Set, cause 누락, 음수 합산, 기대값 검사 생략)을 넣은 변형 답안은 모두 관련 검사에서 실패함을 확인. +- 독립 검토에서 발견한 testing-design의 0·30001 검사 누락을 보완했다. 0만 생략한 답안, 30001만 생략한 답안, 둘 다 생략한 답안은 이전 6개 검사에서 통과했지만 수정된 8개 검사에서 모두 거절됨을 실제 실행으로 확인했다. +- 12수업·24퀴즈·12과제의 ID 연결, 필수 필드, 대상 버전, 교재 예제와 독립 파일 동일성 검사 통과. +- Java 11·17·21 예제와 답안은 이 로컬 JDK 8 검사에서 실행하지 않았다. 이후 통합 담당자의 실행 환경 검증과 구분한다. +- 위 결과는 웹 IDE 실행 서버·브라우저·격리 경계·설계 설명 평가의 통합 통과를 의미하지 않는다. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..783e777 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,93 @@ +# 코어 프로토타입 배포 기록 + +2026-10-12 03:36:36 UTC(한국시간 12:36:36)에 Sites의 개인 전용 배포가 성공했다. 운영 URL은 [Java Learning Lab](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)이다. 공개 GitHub 저장소와 별개로 사이트는 소유자 계정만 접근한다. + +| 항목 | 확인 값 | +| --- | --- | +| GitHub 출시 소스 | `165e113cc77bb56f0bfab78cf28f94de47f022cd` | +| 브랜치 | `feat/java-learning-mvp` | +| 출시 CI | [38200134013: 모든 6개 작업 성공](https://github.com/jbsim999/java-edu/actions/runs/38200134013) | +| Sites 프로젝트 | `appgprj_6acc4fec8bb48191a296197d2d55c1e3` | +| Sites source SHA | `e5c276479bf60c606884c61fb5b90a472e246d2c` | +| 저장된 버전 | `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_ff8ebe8af5d88191a2d038ddb0302484` | +| 배포 ID | `appgdep_6acc55b5a94c81918d004500c476c6c4` | +| 실제 응답 상태 | `succeeded`, `failure_message: null` | + +별도 체크아웃을 통해 Sites source helper로 commit/push 및 패키징했다. GitHub origin은 변경하지 않았다. 모든 출시 파일의 Git blob을 비교해 `.gitignore`의 호스팅 metadata 제외 규칙 외 동일함을 확인했다. Sites에만 `.openai/hosting.json`이 추가되며 인증 토큰은 세션 메모리와 숨겨진 stdin에만 사용했다. + +정적 UI·데이터와 서버 API를 ESM Worker로 묶었다. Cloudflare Worker 안에서 JVM을 실행하는 방식이 아니라 고정된 외부 Wandbox API를 호출한다. 네 대상 버전은 `--release`로 컴파일하며 실제 실행 JVM은 21이다. Spring/의존성/DB/HTTP 앱 실행은 후속 마일스톤이다. + +기능·반응형·키보드·저장소·경쟁 응답은 모의 API 브라우저 QA로, 제공자 실행은 별도 실제 호출로 확인했다. 배포된 인증 세션의 Java 제출·실제 기기·장기 부하 검증은 수행하지 않았다. 상세 범위는 [검증 기록](verification.md)을 따른다. 배포 성공 응답은 런타임 종단 간 검증을 대신하지 않는다. + +다음 배포는 현재 프로젝트 ID·접근 범위를 재사용하고 새 source SHA의 CI·독립 검수를 확인한 뒤 저장된 버전으로 배포한다. 이전 버전 재배포 시에도 저장된 정확한 버전 ID와 당시 계약을 확인한다. 공개 접근 전환이나 별도 실행 자원은 추가 결정 대상이다. + +## M4.1 당시 편집기 배포 상태 + +M4.1 GitHub 소스 `a4d277e10f6c9e03b6a14f4fb6a11a2672987b54`, CI38204310833 전체6작업 성공. Sites source SHA `de02f178b7e236805e4e1594b7f7f4634ff0dd1e`, 버전 `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_91e1ae45c3f88191b32bc7c994e34ad2`, 배포 `appgdep_6acc654d2f788191b0fcec1f82f8738a`가 2026-10-12 04:43:08 UTC에 succeeded를 반환했다. 같은 개인 전용 URL에 코어12수업과 문법 강조·3테마를 제공한다. + +M4.1 당시에는 Windows v0.2.0 설치 파일의 로컬 빌드만 완료됐고 GitHub Release와 34수업 웹 배포는 남아 있었다. 이후 웹 배포 결과는 아래 M7.1 기록을 따른다. Windows 설치형의 최신 검증 상태는 [개발 인계](development-handoff.md)를 따른다. + +## M7.1 34수업·이론 도식 배포 + +2026-10-12 06:29:47 UTC에 같은 소유자 전용 URL로 웹 교재 배포가 성공했다. Java 코어12+실무22수업과 검수한 이론 도식12개를 제공한다. Windows 설치 파일 출시는 별도이며 이 웹 배포를 설치 앱 검증으로 표현하지 않는다. + +| 항목 | 확인 값 | +| --- | --- | +| GitHub 검증 소스 | `a0d12571dcca5574d19b6ffdc35cd1a1093790d4` | +| CI | [38212145793](https://github.com/jbsim999/java-edu/actions/runs/38212145793): 콘텐츠·브라우저(도식 포함)·Java4·실무2 성공, Windows4 실패 | +| Sites source SHA | `88e92c4a6889c621f710027f924450d540040460` | +| 저장된 버전 | `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_776dd5662690819188b347b7a7be2b14` | +| 배포 ID | `appgdep_6acc7e4ad0c88191a5c9c2e721d984a1` | +| 결과 | `succeeded`, `failure_message: null` | +| 배포 파일 SHA256 | `e11b37024706fc81ffe2fbe5f2ee2e3f0ffacd129f524e57b1a6e3cabae54bc4` | + +공식 Sites helper로 연 기존 체크아웃에 GitHub 검증 커밋의 파일260개를 복사하고 Git blob SHA를 전부 대조했다. 정확히 같은 소스를 `npm ci`(audit0)→Worker 빌드→Worker 정적·인증 검사→source commit/push→archive 순서로 준비했다. 이전 opening result의 SHA는 체크아웃 기준 정보이며, 배포에는 helper가 새로 반환한 pushed source SHA만 전달했다. 자동 승인 검토가 초기 패키징 입력을 오래된 SHA·만료 가능 인증정보로 판단해 거절했으나, 260파일 일치 확인·공식 helper 계약 확인·새 인증정보 후 재검토에서 진행됐다. 토큰은 저장·출력하지 않았다. + +웹 관련 8개 CI 작업과 내용·기능·시각 검수는 PASS다. 전체 CI의 Windows4 실패는 감독 PowerShell의 .NET CodeDom 임시 경로 초기화에서 발생하며 Windows 설치형 수정·검증에서 추적한다. 실무 웹은 교재·파일 편집·ZIP 다운로드를 제공하고 Spring을 호스팅에서 실행하지 않는다. 실제 인증 세션 Java 제출·실기기·학습 효과는 이번 배포 검증에 포함하지 않는다. + +## Windows v0.2.0 프리뷰 출시 + +2026-10-12 07:21:26 UTC에 [GitHub v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)를 게시했다. 태그와 릴리스 target은 검증 소스 `6ba4b8aa124f5ce78e79240253946c9fdac3e770`이며 후속 검증 문서 커밋과 구분한다. [CI38213755330](https://github.com/jbsim999/java-edu/actions/runs/38213755330) 12작업이 모두 성공했다. 앞선 웹 배포 당시 Windows4 실패는 이 소스에서 해결·재검증됐다. + +설치 EXE와 SHA256SUMS가 모두 uploaded 상태다. EXE 크기는111753633바이트, GitHub asset digest는 `sha256:6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`로 로컬 검증 파일과 일치한다. SHA256SUMS 파일의 digest도 `sha256:54694a213fadad9a0414c805d7346aa1f930f6ebb7380afbc9808ccf0edd0ffe`로 일치한다. + +실제 NSIS 설치·설치 앱8/8·정상 제거·데이터 보존 및 독립 시각 검수 PASS. 설치 ASAR/helper/exe와 빌드본 동일성, 검토 소스·생성 파일 대조를 확인했다. [설치 QA](installed-desktop-qa-report.md)에 검사 경계·초기 스크립트 오류·실제 사용 도구·미검증을 기록했다. 설치 파일은 코드 서명되지 않은 프리뷰다. Redis/Kafka 실제 연동·SmartScreen·실기기·스크린리더는 이번 출시 검증에 포함하지 않는다. 기존 웹의 소유자 전용 접근과 구현 PR의 미병합 상태를 유지한다. + +## M8 Windows v0.3 통합 패키지와 웹 안내 배포 + +2026-10-12T09:10:19Z에 [v0.3.0 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)를 게시했다. 태그의 실제 commit과 릴리스 target은 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`로 일치한다. 후속 문서·QA 기록 커밋3a745cb와 구분한다. [CI38223955175](https://github.com/jbsim999/java-edu/actions/runs/38223955175)12작업, 최종 실제 ZIP10/10·NSIS10/10, 원본604도구·197소스/2생성물 대조, 코드·시각 독립 검수와 설치/제거·데이터 보존 PASS. package.json은 builder가 제거한 개발 필드를 제외한 런타임 필드로 비교했다. [통합 QA](bundled-desktop-qa-report.md)를 따른다. + +초안에서 다섯 첨부의 uploaded·크기·GitHub SHA256 digest를 로컬 파일과 대조한 뒤 공개하고, 공개 상태·태그·첨부를 다시 확인했다. EXE·ZIP과 대응 JDK·제3자 소스가 같은 릴리스에 함께 있다. 기존v0.2 파일은 교체하지 않았다. + +| 파일 | 바이트 | SHA256 | +| --- | ---: | --- | +| Java-Edu-0.3.0-x64.zip | 362369704 | `478889a5ab413349cc98f80801fb1f9a190ba6b1791f2c47de0d5fefcc0aa8bf` | +| Java-Edu-Setup-0.3.0-x64.exe | 362799820 | `b3e0a56b339fc3946cd80c194b9b5a544fdb3d5044277fc00acd6a9a11e8eeb8` | +| OpenJDK21U-jdk-sources_21.0.12.1_1.tar.gz | 115126841 | `573057d03584ae793fb7ec9a14c76d826d9187a53efeefd99da47403a5308234` | +| SHA256SUMS-0.3.0 | 388 | `b2490cb98bdd9d731a267354c8fd47e03aa29afe4cd16ec94f76fa4def603934` | +| Third-Party-Sources-0.3.0.zip | 4860928 | `4acbde9bafea55ab2334d20b69257c893cbb9b40aa084159d6e35cba2681756c` | + +웹 통합 설치 안내는 기존 소유자 전용 URL에 2026-10-12 08:25:24 UTC succeeded로 배포했다. Sites source `199443b5e185acdcd83bb3d4ca0a4ca43eefc3d8`, 버전 `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_60be210ba2a48191b036dc7530cad71f`, 배포 `appgdep_6acc996c9de48191a896c90d291afd9f`, archiveSHA256 `dd25ad4a14f440d3d4ad956b3332669ef0531724b74bf2cb7ea9cb38fa73e0c9`다. 공식 helper로 연 체크아웃에 GitHub ffb77751의268개 파일을 복사하고 Git blob을 전부 대조했다. npm ci(audit0)·Worker 빌드·Worker 검사 후 공식 source commit/push·package 결과의 SHA만 배포에 사용했다. Windows helper의 npm 실행 경로·Git Bash PATH·GNU tar Windows colon 처리를 확인한 뒤 같은 소스로 패키징했으며 도구를 수정하거나 TLS 검사를 해제하지 않았다. + +후속0e1ff5는 desktop 설치 압축 설정만 수정했고 웹 UI·Worker는 동일하다. 호스팅에서 Spring/JVM을 실행하는 기능은 추가하지 않았다. 배포 완료를 인증된 실제 웹 제출·실기기 검사로 표현하지 않는다. Windows 기본 실제 JVM21, 실무 첫 의존성 준비의 인터넷 필요, 선택 Redis/Kafka 미검증과 코드 서명되지 않은 프리뷰 범위를 유지한다. 구현 PR은 미병합이며 GitHub 공개와 웹 소유자 전용 접근을 구분한다. + +## M9 — v0.3.1 프리뷰 릴리스 + +2026-10-13 03:14:44UTC에 [v0.3.1](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)을 공개했다. Release ID411215208, 태그·대상 커밋 `6ce0428a802be7a8a757a27a40853996a5b7fa04`, CI38338837092의12작업 PASS. EXE362890668바이트·ZIP362460548바이트·대응 소스2개·SHA256SUMS 총5첨부의 업로드 상태·크기·SHA256을 로컬 원본과 대조했다. 릴리스 태그도 대상 커밋과 일치한다. 전체 payload 정합성은 [이번 QA](readability-release-qa-report.md)를 따른다. 새 실제 설치·앱 기동은 미실행이며 과거 패키지의 실행 결과를 대신 사용하지 않는다. + +### M9 웹·GitHub 첫 화면 반영 + +2026-10-13 03:20:24UTC에 기존 소유자 전용 [웹 교재](https://java-edu-lab-jbsim999.junbo1218.chatgpt.site)의 배포가 `succeeded`를 반환했다. 후속 조회에서도 owner/custom, 허용 사용자1·그룹0·외부0으로 접근 범위를 유지했다. + +| 구분 | 확인값 | +| --- | --- | +| GitHub 웹·문서 원본 | `236ce0d3e461fdfea11929669981999110e1b0cf` | +| Git blob 대조 | 원본275파일 전체 일치, 복사 완료 후 빌드·Worker·Node40 PASS/1 SKIP | +| 공식 helper pushed SHA | `24cd85352707112c5875a120169a9e61e3108331` | +| Site ID | `appgprj_6acc4fec8bb48191a296197d2d55c1e3` | +| 저장 버전 | 5, `appgprj_6acc4fec8bb48191a296197d2d55c1e3~appgver_353fa8d7d06c819186e53ef29eb955e1` | +| 배포 ID | `appgdep_6acda3692ce48191840e4117a0011131` | +| 저장 아카이브 | 34파일, 3266560바이트, `sha256:8e789ec0b37028713897522ce8cdc1ebee754e997657f563d7c111d48564ae0a` | +| main 소개 문서 커밋 | `39ad4a8dfbe1548d672e4bc01bb2f3518efbb5e9` | + +공식 helper에 일치 확인한 소스·완료한 빌드의 배포 묶음을 전달했고, 반환된 새 pushed SHA를 저장·배포에 사용했다. 저장 버전 조회의 SHA도 일치한다. main에는 README와 미리보기 이미지 두 변경 파일만 반영했으며 구현 PR은 병합하지 않았다. GitHub GFM1280/390 독립 시각 PASS 및 실제 공개 첫 화면의 소개·이미지·라이선스 상태 PASS를 확인했다. 이 배포 완료 응답을 인증 세션의 실제 Java 제출 검증으로 표현하지 않는다. diff --git a/docs/desktop-qa-report.md b/docs/desktop-qa-report.md new file mode 100644 index 0000000..e472420 --- /dev/null +++ b/docs/desktop-qa-report.md @@ -0,0 +1,96 @@ +# M5·M6 독립 기능 QA + +검증일: 2026-10-12. 기능 판정: **PASS**. 당시 시각 검수는 별도 게이트였다. 이후 최종 시각 PASS 및 실제 설치 앱8/8·제거 결과는 [설치 QA](installed-desktop-qa-report.md)를 따른다. 이 보고서의 앱 검증 대상은 개발 모드 Electron이며, 설치 프로그램 검증과 배포 환경 검증은 포함하지 않는다. + +## 환경과 실행 경계 + +- Windows, Node.js 22.17.0, Playwright 1.62.1, Edge 155.0.4283.45, Electron 44.7.0. +- 웹은 `http://127.0.0.1:4173`의 동결된 공용 UI를 격리된 Edge 컨텍스트에서 검사했다. `/api/**`는 모의 응답으로 처리했으며 외부 실행 제공자에 코드를 전송하지 않았다. +- 앱은 프로젝트의 실제 Electron 실행 파일, preload, 인증된 임시 포트의 로컬 서버와 로컬 실행기를 사용했다. `JAVA_EDU_QA_DATA`로 별도의 `user-data-한글 & 25%-…` 폴더를 만들었다. 기존 사용자 설정·진도는 변경하지 않았다. +- JDK는 OpenJDK 8u265와 Eclipse Temurin 21.0.12.1+1, Maven은 3.10.0을 등록했다. 파일 선택 대화상자의 반환 경로만 모의 처리했고, JDK·Maven 검사, 동의 저장, IPC, HTTP, 컴파일, JVM 실행 및 Maven 테스트는 실제 수행했다. +- 웹의 320/360/390/768/1440 CSS px, 낮은 높이와 방향 전환은 브라우저 에뮬레이션이다. 실제 휴대전화 검증이 아니다. 앱은 기본 창과 최소 외곽 크기 720×560에서 확인했다. 이 환경의 최소 창 내부는 704×521 CSS px였다. +- 글자 확대는 문서 기본 글꼴을 200%로 설정해 검사했다. Windows 시스템 배율·브라우저 페이지 확대·실제 OS IME 검증으로 간주하지 않는다. + +## 실행 결과 + +| 검사 | 실제 결과 | 범위 | +| --- | --- | --- | +| `node --test tests/professional.test.mjs` | 최종 4/4 PASS | 34단원 계약, 22실무 선행학습 관계, 원본·예제·편집 가능 파일, 전체 ZIP의 POM·starter·reference·grader 일치 | +| `JAVA_EDU_QA_MODE=web node tests/desktop.mjs` | 4/4 PASS | 실무 전체 단계·퀴즈, 웹 실행 차단, 파일별 초안과 읽기 전용 파일, ZIP 다운로드, 반응형 | +| `JAVA_EDU_QA_MODE=desktop node tests/desktop.mjs` | 9/9 PASS | 최초 설정, 실제 도구 등록·동의, 실제 JDK 실행·실패·취소, Spring/H2 준비·제출, 재시작 영속성 | +| `node tests/browser.mjs` | 48 PASS, 기대값 정정 후 영향받은 1개 PASS | 기존 49개 시나리오를 유지하고 34단원 기준으로 검사. 한 번의 최종 49/49 실행으로 기록하지 않음 | + +실행 예시(PowerShell, 의존성·번들·실무 자료·웹 서버는 총괄 담당자가 미리 준비): + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED = '1' +$env:NODE_OPTIONS = '--use-system-ca' +$env:PLAYWRIGHT_CHANNEL = 'msedge' +$env:QA_ARTIFACT_DIR = '/work/java-edu-desktop-qa' +node --test tests/professional.test.mjs +$env:JAVA_EDU_QA_MODE = 'web' +node tests/desktop.mjs +node tests/browser.mjs + +$env:JAVA_EDU_QA_MODE = 'desktop' +$env:JAVA_EDU_JDK_8 = '' +$env:JAVA_EDU_JDK_21 = '' +$env:JAVA_EDU_MAVEN_HOME = '' +node tests/desktop.mjs +``` + +수정된 단일 브라우저 검사에는 `QA_FILTER=all-lessons-render-five-learning-steps`를 사용했다. Electron 실행 파일이 없으면 테스트는 실패하며 자동으로 다운로드하지 않는다. `JAVA_EDU_ELECTRON_PATH`로 준비된 실행 파일을 지정할 수 있다. + +## 수용 조건별 근거 + +| 수용 조건 | 확인한 행동과 결과 | +| --- | --- | +| 34단원·버전 선택 | 코어 12+실무 22의 고유 ID 확인. 8/11/17/21 필터 결과를 각 단원의 최소 버전과 대조. 실무 필터에서 Java 8을 고르면 빈 결과와 안내가 나오며 본문은 유지됨 | +| 실무 학습 내용 | 22개 실무 수업의 공식 자료 링크, 예제의 모든 파일 원문, 퀴즈 전체 정답, 실무 단계, 코드 실습을 방문. 퀴즈만 통과해도 실습 배지는 완료되지 않음 | +| 웹 실행 차단 | 실무의 실행·제출 버튼이 비활성이고 설치 안내·ZIP이 표시됨. 준비 버튼 없음. 웹 검사에서 runtime 조회 외의 실행·로컬 API 요청은 0건 | +| 파일별 상태 | Java 초안→읽기 전용 POM→Java 복귀, 다른 수업 왕복, 새로고침 후 초안 보존. 읽기 전용 POM에 키보드 입력을 시도해도 원문 불변. ZIP은 초안이 섞이지 않은 공식 starter임 | +| 최초 실행·도구 등록 | JDK 미등록·동의 없음 상태에서 실행 불가. 폴더 선택 취소로 등록되지 않음. Java 8 등록에 JDK 21 경로를 선택하면 거부. 실제 8·21 및 Maven을 등록해도 자동 코드 실행 없음 | +| 실제 코어 실행 | `java-basics-lab` JDK 8 샘플 1/1, 제출 6/6. JDK 21 제출 6/6. `oop-lab` JDK 21 클래스 제출 5/5. 실제 런타임과 언어 대상 버전을 응답에서 구분 | +| 실제 오류 | 한글 미정의 식별자 컴파일 오류가 실제 출력에 유지됨. 틀린 답은 3/6 실패. 종료 코드 7은 실행 오류. 실패·초안 편집으로 실습 완료를 얻지 않음 | +| 실행 취소 | 테스트 코드가 작업 폴더에 marker를 쓴 뒤 무한 반복하는 것을 확인하고 UI에서 취소. 취소 표시, 작업 폴더 정리, 초안 보존, 미완료 배지를 확인 | +| 동의 철회 | 철회 후 실행·제출 비활성, 초안 유지. 다시 동의해도 자동 실행하지 않음. 실행 중 동의 철회로 자식 프로세스가 종료되는 경우는 본 앱 UI 시나리오에서 별도 수행하지 않음 | +| Maven 준비 구분 | `tx-proxy` Spring/H2 실습은 준비 전 실행 불가. 실제 준비 2/2 통과 후 실행 가능. 준비 요청에 학생 source/files 없음. 준비만으로 학습 완료되지 않으며 초안 보존 | +| 실제 실무 제출 | 준비 이후 편집 가능한 reference 파일을 UI에 입력하고 실제 Maven 제출 2/2 통과. 제출 요청은 편집 가능한 파일만 포함하고 POM을 포함하지 않음. 퀴즈+실습 완료가 함께 표시됨 | +| 앱 재시작 | 실제 창·프로세스 종료 후 같은 QA 데이터로 재실행, 다른 로컬 포트 확인. 코어 초안, 프로젝트 파일 초안, 퀴즈 진도, 테마가 보존됨. 새 앱 세션은 실무 준비를 다시 요구 | +| UI·입력 회귀 | 기존 49개 시나리오의 검색·잘못된 링크·키보드·포커스·오류·취소·stale 응답·불일치 성공 차단·저장소 손상·편집기 fallback·3테마·선택 대비·긴 한글 코드·확대·레이아웃 확인 | + +기존 49개 검사의 실행 응답은 모의 응답이다. 해당 통과를 실제 JVM 또는 외부 실행 제공자 검증으로 확장하지 않는다. 한글 composition은 DOM 이벤트 기반 시뮬레이션이며 실제 Windows IME의 입력 후보 창 검증은 아니다. + +## 발견 사항과 처리 + +1. **동결 전 ZIP 불일치:** 캡스톤 원본 grader가 변경됐는데 생성된 ZIP에는 이전 내용이 남아 정적 검사가 실패했다. 총괄·교재 담당자에게 알렸고 자료 재생성 후 전체 22개 ZIP 내용 검사 PASS. 빌드가 필요한 생성물이라는 점을 유지한다. +2. **테스트 기대값 정정:** MyBatis 수업은 편집 가능한 XML을 처음 보여주고 Java는 읽기 전용이다. 기존 단일 파일 검사에서 Java `template`을 첫 본문으로 기대해 1건 실패했다. 새 프로젝트 계약에 따라 최초 편집 가능 파일 원문과 대조하도록 정정한 뒤, 전체 34단원을 도는 해당 시나리오 PASS. 앱 결함으로 분류하지 않는다. +3. **최초 Electron 시작 환경:** 처음에는 패키지의 실행 파일 조회 과정에서 자동 다운로드가 시작됐고 상속된 TLS 검사 해제 변수의 경고와 45초 launch 시간 초과가 발생했다. 부모에게 즉시 보고했다. 준비된 바이너리의 절대 경로를 명시하고 실행 환경을 정리한 재시도는 9/9 PASS. 첫 시도의 정확한 지연 원인을 확정하지 않으며, 최종 테스트는 실행 파일이 없으면 다운로드 없이 중단한다. 종료된 최초 PID와 최종 앱의 정상 종료를 확인했다. +4. **독립 코드 검토의 링크 정책 P2:** 앱 검사 종료 후 별도 리뷰에서 일부 공식 문서 호스트가 앱의 외부 링크 허용목록에 빠진 문제가 확인됐다. 백엔드 담당자가 `reference-policy.cjs`로 정책을 분리·보완하고 로컬 계약 16/16 및 독립 재리뷰 PASS를 보고했다. 이 결과는 본 QA 담당자가 외부 브라우저를 실제 열어 재검증한 결과와 구분한다. 실행 엔진·IPC·초기화의 변경은 없다고 담당자가 확인했으며, 수정 후 설치 패키지 검증은 총괄 담당자의 범위다. + +현재 수행한 기능 범위에 미해결 주요 결함은 없다. 시각 검수 및 설치 패키지의 판정은 독립 기록으로 확인한다. + +## 산출물과 화면 + +실제 산출물 디렉터리: `/work/java-edu-desktop-qa/`. + +- 웹 4개 결과: `results-web-1791781255008.json`. +- 실제 앱 9개 결과: `results-desktop-1791781614522.json`. +- 최초 앱 환경 실패: `results-desktop-1791781284948.json` — 최종 PASS 근거가 아님. +- Edge 공용 실무 UI: `web-professional-theory.png`, `web-project-editor.png`, `web-project-readonly-pom.png`, `web-project-{dark,light,contrast}-{java,xml,sql}.png`. +- Edge 반응형: `web-project-320x720.png`, `web-project-390x844.png`, `web-project-768x1024.png`, `web-project-1440x900.png`, `web-project-844x390.png`, `web-project-320-200.png`, `web-project-mobile-200-{files,runtime}.png`, `web-install-dialog-320-200.png`. +- 실제 앱 설정: `desktop-first-settings.png`, `desktop-configured-settings.png`, `desktop-settings-200.png`, `desktop-settings-minimum-200.png`, `desktop-settings-minimum-200-consent.png`. +- 실제 JVM: `desktop-core-running.png`, `desktop-core-passed-jvm21.png`, `desktop-korean-compiler-error.png`, `desktop-cancelled.png`. +- 실제 Spring/H2: `desktop-spring-h2-preparing.png`, `desktop-spring-h2-prepared.png`, `desktop-spring-h2-submitted.png`, `desktop-restarted-project.png`, `desktop-project-200.png`. + +`failure-…` 이미지는 당시의 실패 증거로 남겨 두며 최종 정상 화면 근거로 사용하지 않는다. 이름이 `desktop-…`인 기존 웹 회귀 이미지는 Edge 화면일 수 있다. 위에서 **실제 앱**으로 구체적으로 열거한 파일만 Electron 캡처다. + +## 남은 범위 + +- 실제 JDK 11·17 실행, ARM·macOS·Linux, Safari·Firefox, 실제 모바일 기기, OS 화면 배율 및 실제 IME는 이 검증에서 수행하지 않았다. +- 실제 NSIS 설치·제거, 설치된 바이너리에서의 동작, 코드 서명·SmartScreen, 릴리스 다운로드 및 배포 웹의 결과는 총괄 담당자의 별도 검증이다. +- 실제 실행을 수행한 실무 UI 대표는 `tx-proxy` Spring/H2이다. 모든 22개 실무 프로젝트, 실제 Redis/Kafka 컨테이너, 외부 네트워크 장애·프록시·기업 인증서는 본 독립 앱 시나리오 범위에 포함되지 않는다. +- 실행 중 앱 강제 종료, 악의적 코드의 보안 경계, 디스크 가득 참 등 OS 장애는 이 UI 검사로 검증했다고 주장하지 않는다. 별도 백엔드·보안 검토가 필요하다. +- ZIP과 링크 원문은 검사했으나 이번 검사에서 모든 외부 링크를 실제 방문하거나 채용 공고의 현재 유효성을 다시 조사하지 않았다. + +수정한 파일: `tests/browser.mjs`, `tests/desktop.mjs`, `tests/professional.test.mjs`, 이 보고서. 앱 구현 파일은 QA 담당자가 수정하지 않았다. diff --git a/docs/development-handoff.md b/docs/development-handoff.md new file mode 100644 index 0000000..eb8c384 --- /dev/null +++ b/docs/development-handoff.md @@ -0,0 +1,53 @@ +# Java Edu 개발 인계 — v0.3.1 교재·화면 개선 + +최신 앱·릴리스 소스는 `6ce0428a802be7a8a757a27a40853996a5b7fa04`다. [v0.3.1 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)는 2026-10-13 03:14:44UTC에 공개했다. 34수업에 상황·용어·실제 코드·값 추적·예측 해설을 넣고, 반복 안내를 펼침 항목으로 정리했다. 1440px 화면의 본문 폭은 약762px에서1092px로 넓어졌다. CI38338837092의12작업과 독립 정적5/5·읽기24/24·기존49/49·도식19/19 PASS, 새 EXE·ZIP 전체 payload 및 도구604파일 대조 PASS다. 사용 중인 기존 앱과 데이터를 보존하여 새 설치·기동 검사는 미실행이다. [이번 QA](readability-release-qa-report.md)·[설치 안내](installation.md)를 따른다. 문서 후속 커밋은 앱 패키징 소스와 구분한다. + +웹은 같은 소유자 전용 URL에 34수업의 새 읽기 안내와 넓은 본문을 배포했다. 원본275파일 Git blob 대조 후 같은 소스로 빌드·Worker·Node40 PASS/1 SKIP를 확인했다. 저장 버전5의 source SHA는 `24cd85352707112c5875a120169a9e61e3108331`, 배포 `appgdep_6acda3692ce48191840e4117a0011131`은 2026-10-13 03:20:24UTC succeeded다. main에는 README와 미리보기 이미지의 문서 커밋 `39ad4a8d`만 반영했으며 PR1은 미병합이다. 이후 기록 문서 커밋은 배포된 앱·웹 소스와 구분한다. + +## 이전 v0.3.0 통합 패키지 기록 + +당시 앱 소스는 `0e1ff5eb38484d1594d4fd1c231d4dbe229b44e8`, 원격은 jbsim999/java-edu, 구현 브랜치는 feat/java-learning-mvp이며 PR1은 미병합이다. [v0.3 통합 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.3.0)는 Windows x64 EXE·전체 ZIP에 Temurin21.0.12.1+1·Maven3.10.0·Electron/Node를 포함한다. 학습자의 별도 도구 설치·폴더 등록은 필요 없다. 기본 실제 JVM은21이며 컴파일 대상8·11·17·21과 구분한다. 다른 실제 JDK 등록은 고급 설정의 선택 기능이고 기존 외부 설정을 보존한다. 기본20실무 첫 준비에는 인터넷이 필요하며 Redis/Kafka 서버는 포함하지 않는다. + +CI38223955175의12작업, 최종 실제 ZIP10/10·NSIS10/10, 도구604개 전체 무결성·원본197+생성2파일 대조, 코드·기능·시각 독립 검수 PASS. package.json은 builder가 제거한 개발 필드를 제외한 런타임 필드를 비교한다. 첫7z NSIS의2파일 누락은 내부 ZIP으로 수정해 한글 설치 경로에서 재검증했다. 설치 제거·학습 데이터 보존·원래 AppData 부재 복원 PASS. 검사 자료는 work/bundled-desktop-qa에 보존하며 Git에 넣지 않는다. [통합 QA](bundled-desktop-qa-report.md)·[번들 계약](bundled-runtime.md)·[설치 안내](installation.md)·[배포 기록](deployment.md)을 먼저 읽는다. + +웹은 기존 소유자 전용 URL에서 통합 설치 안내를 배포했다. 웹 관련 GitHub 소스ffb77751, Sites199443b5e185acdcd83bb3d4ca0a4ca43eefc3d8, 배포appgdep_6acc996c9de48191a896c90d291afd9f, 2026-10-12 08:25:24UTC succeeded. 후속0e1ff5는 desktop 압축 설정만 수정했으며 웹 UI·Worker는 동일하다. 공개 접근 전환·PR 병합·강제 푸시·유료 자원 구매·자체 라이선스 지정은 승인되지 않았다. + +개발자는 공식 ZIP 체크섬과 대응 소스를 검증한 뒤 scripts/bundle-runtime.mjs로 work/desktop-bundle을 생성한다. 빌드 전후604개 전체 SHA를 확인한다. NSIS differentialPackage:false/useZip:true를 유지한다. 새 빌드는 기존 릴리스와 별도로 검증한다. 학습자 코드의 명시적 동의·개인 IDE 권한·로컬 프로세스 계약을 유지한다. 기존v0.2 검증도 최신 통합 패키지 검증과 구분한다. + +아래는 v0.2 당시 개발 인계의 역사 기록이다. 도구 외부 등록과 이전 배포 표시는 현재v0.3 기본 설정을 대신하지 않는다. + +# Java Edu 개발 인계 — 2026-10-12 + +프로젝트 루트는 이 문서의 상위 저장소 java-learning-lab이다. 원격은 https://github.com/jbsim999/java-edu, 구현 브랜치는 `feat/java-learning-mvp`, PR은 https://github.com/jbsim999/java-edu/pull/1 이다. main에는 README와 소개 이미지가 반영돼 있고, 구현 PR은 병합하지 않았다. 새 대화는 AGENTS.md·CONTRIBUTING.md·이 문서를 먼저 읽는다. + +## 승인된 목표와 현재 결과 + +다른 언어 경험자·현직자를 대상으로 공식 Java8/11/17/21, 깊은 OOP·타입·동시성 및 실무를 제공한다. 이론 → 예시 → 문제풀이 → 실무 심화 → 직접 코딩의34수업·68문항·34과제다. Spring/REST/Lombok/MyBatis/JPA/트랜잭션/Security/OIDC/JWT/관측/빌드·운영과 선택 Redis/Kafka를 포함한다. + +Windows 설치형+웹 교재를 제공한다. 기본 실무20프로젝트는 학습자 JDK17/21·Maven·H2로 실행하며 별도 호스팅 컨테이너가 필요하지 않다. Java 코어는 등록한 실제 JDK8/11/17/21을 사용한다. 문법 색상·3테마·다중 파일 편집·진도와 초안 저장을 구현했다. 웹 코어는 Wandbox JVM21이며 교재 기준·컴파일 대상·실행 JVM을 구분한다. 앱은 Wandbox를 호출하지 않는다. + +## 출시와 검증 + +Windows [v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)는 검증 소스 `6ba4b8aa124f5ce78e79240253946c9fdac3e770`을 대상으로 게시했다. [CI38213755330](https://github.com/jbsim999/java-edu/actions/runs/38213755330) 12작업 모두 성공: 콘텐츠·브라우저, Java4JDK, Windows 로컬4JDK, 실무17/21. Windows 관리자·코드페이지1252에서 로컬16/16과 각 실제 JVM56/61/67/73검사·한글 진단 PASS. 기본20프로젝트54JUnit PASS와 미완성 starter20개 실패 확인. + +설치 파일 `desktop-release/Java-Edu-Setup-0.2.0-x64.exe`, 111753633바이트, SHA256 `6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`. 서명되지 않은 Windows x64 NSIS다. 실제 현재 사용자 설치 → 설치된 앱8/8(JDK21 코어·Spring/H2 제출·재시작 저장) → 제거 및 독립 시각 검수 PASS. 설치 ASAR/helper/exe는 빌드본과 동일하다. 검토 파일195개+생성2개를 대조했으며 package.json은 builder가 개발 필드를 제거한 뒤의 런타임 필드로 비교했다. [설치 QA](installed-desktop-qa-report.md)와 [검증 기록](verification.md)을 따른다. + +테스트 설치는 제거했다. 테스트 이전에 없던 AppData 경로도 원래 부재 상태로 복원했다. 검사 데이터는 저장소 내부 work/installed-desktop-qa/app-data-first-run 및 app-data-final-run에 보관한다. 해당 작업 자료는 Git에 포함하지 않는다. 설치 파일을 재빌드하면 해시와 검증이 달라지므로 기존 릴리스 파일을 교체하지 않는다. + +웹 https://java-edu-lab-jbsim999.junbo1218.chatgpt.site 는 소유자 전용이며34수업·12도식·테마가 배포됐다. GitHub 웹 소스 `a0d12571dcca5574d19b6ffdc35cd1a1093790d4`, Sites source `88e92c4a6889c621f710027f924450d540040460`, 배포 `appgdep_6acc7e4ad0c88191a5c9c2e721d984a1`, 2026-10-12 06:29:47 UTC succeeded. 후속 Windows 감독 helper 수정은 웹 UI·교재를 변경하지 않았다. [배포 기록](deployment.md)을 따른다. + +34수업의 시각 자료를 평가해12개 채택·22개 보류했다. 공식 출처26개를 실제 대조했고 내용·코드·시각 검수 PASS, 도식19/19·기존 브라우저49/49 PASS다. [선정 근거](theory-visuals.md)를 따른다. 테스트의 기본 산출물은 저장소 내부 work/로 정리했고 경로 영향 브라우저1/1·실무 웹4/4 PASS다. + +## 남은 범위와 제약 + +Redis/Kafka 실제 서비스 연동, 실제 모바일·스크린리더·OS IME/클립보드·SmartScreen 다운로드 흐름, 장기 부하, 호스팅 인증 세션의 실제 Java 제출은 미검증이다. 앱과 웹 진도는 자동 동기화하지 않는다. 학습 코드는 사용자 PC 권한으로 실행하며 OS 보안 샌드박스가 아니다. Windows helper의 OS 기본 TEMP는 현재 ANSI 코드페이지로 표현 가능해야 한다. 강제 부모 종료 시 helper 임시 폴더가 남을 수 있다. [로컬 실행 계약](local-execution.md)에 정확한 경계를 기록했다. + +마일스톤 커밋·푸시 및 검증된 배포는 승인됐지만 강제 푸시·PR 병합·유료 구매·사이트 공개 전환·라이선스 지정은 승인되지 않았다. 문서 후속 커밋과 실제 검증·패키징 소스를 구분한다. + +## 개발 환경 + +Node22+, Windows, npm ci 후 npm run dev 또는 npm run desktop. 생성 번들·실무 데이터·ZIP은 빌드로 재생성한다. npm run build:desktop은 별도 신규 설치 파일을 만든다. + +이 작업 환경의 상속 NODE_TLS_REJECT_UNAUTHORIZED=0은 사용하지 않는다. 네트워크/빌드에서는1로 설정하며 Node22.17은 NODE_OPTIONS=--use-system-ca를 사용한다. 인증서 검사를 해제하지 않는다. + +현재 검증 도구는 프로젝트 이동 전의 읽기 전용 폴더 C:/Users/USER/Documents/Codex/2026-10-12/new-chat/work/toolchains/jdk21/jdk-21.0.12.1+1 및 같은 toolchains/maven/apache-maven-3.10.0이다. JDK8은 C:/Program Files/Java/java-1.8.0-openjdk-1.8.0.265-1이다. CI의 실제 JDK11/17 검사와 이 로컬 도구 경로를 구분한다. 일반 학습자는 앱의 폴더 선택으로 자신의 도구를 등록한다. diff --git a/docs/editor-qa-report.md b/docs/editor-qa-report.md new file mode 100644 index 0000000..6cc4c88 --- /dev/null +++ b/docs/editor-qa-report.md @@ -0,0 +1,118 @@ +# M4.1 편집기·문법 강조 독립 QA + +- 기준일: 2026-10-12 +- 담당: 독립 QA. 앱·교재·서버 코드는 수정하지 않았다. +- 최종 판정: **PASS**. 기능 QA와 독립 시각 재검수가 통과했다. 선택 상태 글자 대비 P2는 수정 후 실제 계산색 검사와 원본 화면 검수로 해결 확인했다. +- 범위: CodeMirror 도입, Java 구문별 색, 다크·라이트·고대비 테마, 편집·저장·실행 연동, 기본 편집기 fallback과 기존 학습 흐름 회귀. + +## 환경과 실행 방법 + +Windows, Node v22.17.0, Playwright 1.62.1, Microsoft Edge 155.0.4283.45의 headless 독립 context를 사용했다. 부모가 실행한 `http://127.0.0.1:4173` 서버에 연결했으며 서버 시작·재시작·의존성 설치는 수행하지 않았다. 로컬 sandbox 경계 때문에 브라우저 명령은 승인된 `require_escalated` 실행을 사용했다. + +모든 `/api/*`는 Playwright route로 모의 처리했다. 이번 QA에서 실제 Wandbox 실행 요청은 보내지 않았다. Clipboard API도 context 내부에 모의 구현하여 사용자 운영체제 클립보드를 덮어쓰지 않았다. 브라우저 viewport 검증은 **에뮬레이션이며 실제 기기 테스트가 아니다**. + +```powershell +node --test tests/editor.test.mjs +node --check tests/browser.mjs + +$env:PLAYWRIGHT_CHANNEL='msedge' +$env:QA_ARTIFACT_DIR='C:/Users/USER/Documents/Codex/2026-10-12/new-chat/work/java-edu-editor-qa' +Remove-Item -LiteralPath Env:QA_FILTER -ErrorAction SilentlyContinue +node tests/browser.mjs +``` + +`QA_FILTER`는 선택 실행용 정규식이다. 지정하지 않으면 전체 브라우저 시나리오를 실행한다. CI는 `PLAYWRIGHT_CHANNEL`을 생략하여 설치된 Chromium을 사용할 수 있다. CI 실행 결과는 부모의 별도 검증 기록을 따른다. + +## 실제 실행 기록 + +| 실행 | 실제 결과 | 해석 | +| --- | --- | --- | +| 문법 분류 Node 단위검사 | 4 PASS / 0 FAIL | 선언·호출 구분, 문자열·주석, 최신 구문 표기, 빈값·미완성·Unicode 경계 | +| 신규 편집기 브라우저 1차 | 10 PASS / 2 FAIL | 아래의 테스트 기대 정의 두 건을 보정했다. 제품 결함으로 집계하지 않는다. | +| 기존 35개 + 신규 12개 전체 회귀 | 47 PASS / 0 FAIL | 실제 전체 실행 결과 | +| 긴 한글 확대 캡처 재실행 + 50,000자 경계 추가 | 2 PASS / 0 FAIL | 앞의 47개와 중복 1개, 신규 1개 | +| 세 테마 선택·caret 상태 추가 | 1 PASS / 0 FAIL | 범위 선택·caret 표시 자동검사는 통과했으나 이미지 검수에서 대비 문제를 발견했다. | +| 선택 대비 수정 후 테마·선택·모바일 관련 재실행 | 3 PASS / 0 FAIL | 실제 계산색 기준 8개 토큰 종류 모두 4.5:1 이상. 정상·선택·caret·모바일 캡처 갱신 | + +브라우저 고유 시나리오 49개와 문법 단위검사 4개를 실제 검증했다. 최종 파일의 모든 조건을 한 차례에 49개 실행했다고 주장하지 않는다. 전체 47개 실행 이후 추가 경계와 시각 조건, 수정된 CSS 관련 조건을 위 표처럼 선별 재실행했다. + +1차의 테스트 정의 보정: + +1. 검색 결과가 0개가 되면 선택 수업이 해제되고 복구 후 이론 단계로 돌아간다. 테마 보존 검사에서 코드 실습으로 다시 진입한 뒤 원문을 대조하도록 변경했다. +2. CodeMirror의 Windows redo는 `Ctrl+Y`다. 잘못 사용한 `Ctrl+Shift+Z`를 플랫폼에 맞는 키로 바꾸고 실제 undo/redo를 다시 검증했다. + +## 수용 기준과 확인 결과 + +| 영역 | 실제 확인 내용 | 결과 | +| --- | --- | --- | +| 문법 강조 | 예제 원문 불변, keyword/class/type/variable의 서로 다른 계산색, 예제·IDE 팔레트 일치, method/string/comment/number 토큰, HTML처럼 보이는 코드의 텍스트 처리 | PASS | +| 편집기 계약 | 실제 contenteditable textbox, 접근 가능한 이름·multiline, 정상 모드에 숨겨진 textarea proxy 없음 | PASS | +| 테마·줄바꿈 | 3테마 전환, 저장 키 `java-edu:editor-preferences:v1`, 수업·탭·버전·검색·트랙 전환 및 reload 후 코드와 설정 유지 | PASS | +| 선택·이력 | 같은 편집 DOM 유지, 선택 범위 보존, theme 변경 후 Ctrl+Z/Y, 범위 교체, 초기 코드와 되돌리기, 선택 상태 8종 토큰 대비 | PASS | +| 키보드 | Enter Java 블록 공백 4칸, 들여쓰기 도구 선택 줄 +4칸과 undo, Tab의 다음 컨트롤 이동 | PASS | +| 선택·복사 | 실제 마우스 드래그, 선택 원문, 합성 ClipboardEvent의 선택 복사, 공개 전체복사 동작, 복사 거부 후 직접 복사 선택 fallback | PASS | +| 한글 조합 | 합성 compositionstart/update/end와 실제 insertText 연계, 빈 조합 종료, 중복·누락 없는 원문·요청·저장 비교 | PASS, OS IME는 미검증 | +| 빈값·원문 경계 | 빈 코드 전송 금지와 focus, 끝 빈 줄 3개 유지, 전체 원문을 복사·요청 body·저장·reload에서 비교 | PASS | +| 긴 코드 | 100줄 한글 코드의 전체 원문, DOM 가상화와 무관한 전체복사, 가로·세로 내부 스크롤, 줄바꿈, 320px·가로 회전·200% 글자 확대 후 보존 | PASS | +| 최대 길이 | 정확히 50,000자 허용, 초과 1자 입력의 원자적 거절과 기존 원문 보존, 독립 UTF-8 32 KiB 전송 제한, 경계 크기 reset undo | PASS | +| 실행 snapshot | 테마·줄바꿈 변경은 같은 코드의 채점 유지, 입력·수업·버전 변경·취소 후 오래된 응답은 완료에 미반영 | PASS, API mock | +| 저장 실패 | 손상된 테마 JSON은 기본값, 저장 거부 시 안내와 방문 중 테마·편집·실행 유지 | PASS | +| 확장 로드 실패 | editor bundle 차단 시 예제 원문과 노출된 실제 textarea 사용, 안내·테마·wrap·편집·실행·reset undo·320px 흐름 유지 | PASS | +| 반응형 | 320/360/390/768/1440px, 1280×600, 844×390, 회전·root 글자 크기 200%, 활성 탭·버튼 경계·코드 내부 overflow | PASS | + +기존 35개 회귀에는 12수업×5단계, Java 8/11/17/21 제한과 잘못된 hash, 검색 0건, 객관식 미응답·오답·재시도·완료·새로고침, 설계 메모, 초안 격리, run과 submit 구분, 객관식+현재 코드의 이중 완료, 같은 코드 재제출 실패의 완료 철회, HTTP·네트워크·JSON 오류, 불일치 응답, 저장 거부·손상, 초기화 취소·확인, 키보드·모바일 드로어를 포함한다. 기존 상세 정의는 [M4 QA 기록](qa-report.md)을 참조하되, 이번 표의 PASS는 교체된 CodeMirror와 함께 실제 재실행한 결과다. + +## 발견한 제품 결함 + +### 해결: P2 — 선택한 코드의 일부 토큰 대비 부족 + +재현: 코드 실습에 키워드·클래스·타입·변수·메서드·문자열·주석·숫자가 있는 Java 코드 입력 → 다크/라이트/고대비 각각에서 편집기를 focus → Ctrl+A 전체선택. + +기대: 일반 크기 코드 텍스트가 선택 중에도 4.5:1 이상의 대비를 유지한다. + +실제: 불투명 `.cm-selectionBackground` 배경에 대해 다크 주석 3.25:1·키워드 3.75:1·메서드 3.96:1, 라이트 주석 3.64:1·문자열 4.17:1, 고대비 주석·키워드 4.25:1 등을 확인했다. QA와 독립 시각 검수자가 원본 이미지 및 sRGB 계산을 각각 확인했다. 선택 해제 상태의 텍스트와 별개로 선택 중 가독성 문제다. + +구현 담당자는 `dist/styles.css`에서 선택 배경을 다크 `#304751`, 라이트 `#d2e0db`, 고대비 `#304650`으로 바꾸고 일부 토큰 색을 조정했다. QA는 앱 코드를 직접 수정하지 않았다. 수정 후 같은 선택 시나리오에서 `getComputedStyle`로 읽은 실제 배경·전경의 대비를 자동 계산했다. + +| 테마 | 선택한 8종 토큰의 최저 대비 | 결과 | +| --- | --- | --- | +| 다크 | 4.859:1 | PASS | +| 라이트 | 4.810:1 | PASS | +| 고대비 | 5.772:1 | PASS | + +`editor-{dark,light,contrast}-selection.png`는 수정 후 화면으로 갱신했다. 선택 해제 일반 상태의 예제·IDE, caret, 모바일 테마 화면도 함께 갱신하여 독립 시각 검수자에게 전달했다. + +## 화면 증거 + +로컬 증거 루트: `C:/Users/USER/Documents/Codex/2026-10-12/new-chat/work/java-edu-editor-qa/`. + +- `editor-{dark,light,contrast}-{example,ide}.png`: 세 테마 예제·IDE. +- `editor-{dark,light,contrast}-{selection,caret}.png`: 실제 전체선택과 선택 해제 후 caret. 복사 toast 없이 촬영했다. +- `editor-mobile-{dark,light,contrast}.png`: 390px의 세 테마. +- `editor-mobile-200-theme.png`, `editor-mobile-200-execution-controls.png`: 390px·200% 글자 확대. +- `editor-mobile-200-long-korean.png`, `editor-320-200-controls.png`: 긴 한글 코드·좁은 화면. 복사 toast가 사라진 뒤 재촬영했다. +- `editor-fallback-320.png`: 확장 로드 실패 상태의 실제 기본 편집기. +- 이전 35개 회귀로 생성한 `desktop-*`, `mobile-*`, `viewport-*`도 동일 루트에 보관했다. + +`failure-editor-theme-wrap-persist-across-navigation-and-reload.png`와 `failure-editor-theme-preserves-selection-and-keyboard-undo.png`는 1차 테스트 정의 오류 시점의 진단 자료다. 최종 성공·시각 승인 근거로 사용하지 않는다. + +## 변경 파일과 한계 + +QA 변경: `tests/browser.mjs`, `tests/editor.test.mjs`, 이 보고서. 스크린샷은 전용 `work/java-edu-editor-qa/`에만 기록했다. 다른 담당자의 앱·서버·문서 파일을 수정하지 않았다. + +최종 선별 재검증 시점 SHA-256: + +| 파일 | SHA-256 | +| --- | --- | +| `dist/editor.js` | `284A9BE4E2CE098C7F05A9B0697D94FD46BA28D28646E703A22D7D4657591E13` | +| `dist/app.js` | `9827054696499FE8D38C35A9DE6E76656613E271EEA3602C956F711904E0CA22` | +| `dist/styles.css` | `442AF25EE89BCE84A266F58DE346AD089F3462609D7E2D1AF7C3201C6831FD46` | +| `dist/assets/editor.bundle.js` | `BC95D48A58E7C20F2A14481CED42D11B28B8CE5E1442B71AD795F6A5DDFD4DF9` | +| `tests/browser.mjs` | `CB4225C9FB8FCE00AAE6CBDC438F11FC5051532F9998974F02307BD13F8D339E` | +| `tests/editor.test.mjs` | `BAF867586F6C539C1D747B2B64B5A8BE699F6E5CE1255ADCC060E1CB20434209` | + +전체 47개 실행 뒤 번들에 의존성 LICENSE 주석이 추가되었고, 편집 동작 JavaScript는 변경되지 않았다. 이 주석 변경만으로 전체 회귀를 반복하지 않았다. 선택 대비 CSS 수정은 영향을 받는 테마·선택·모바일 3개를 실제 재실행했다. + +미확인: 실제 Windows/macOS/모바일 IME 및 후보 창, 물리 키보드, 실제 OS 클립보드 권한·복사, 모바일 길게 누르기·선택 핸들·가상 키보드, 실제 기기, Safari·Firefox·스크린리더, 브라우저 자체 page zoom, 배포 환경, 실제 Java provider 실행. root font-size 200%는 글자 확대 검사이며 브라우저 전체 확대와 동일하다고 주장하지 않는다. + +독립 시각 담당자는 수정 후 세 테마 선택 합성 화면과 라이트·다크 기본 IDE를 직접 재확인하여 M4.1 시각 PASS를 전달했다. 선택 경계 식별성과 글자 대비 개선, 독립 계산 최솟값 일치를 확인했다. 기능 QA에서 미해결 중요 결함은 없다. 이 판정은 위에 명시한 로컬 브라우저·모의 API 범위에 한정된다. diff --git a/docs/education-roadmap.md b/docs/education-roadmap.md new file mode 100644 index 0000000..99a8a8a --- /dev/null +++ b/docs/education-roadmap.md @@ -0,0 +1,40 @@ +# Java Edu 교육 로드맵 v0.3 + +기준일 2026-10-12. 전환 경로는 진단→Java 코어→테스트·빌드→Spring·DB→인증·운영→통합 프로젝트, 현직자 경로는 진단→부족한 코어→설계·동시성·정합성→버전 전환→개선 프로젝트다. 경력·객관식 총점만으로 단원을 면제하지 않는다. + +| 단계 | 학습 내용 | 직접 코딩·성취 조건 | +| --- | --- | --- | +| 1 객체·타입 | 값 전달·참조·boxing·동등성·오버로딩/오버라이딩 | 실행 결과 설명·결함 수정 | +| 2 깊은 OOP | 책임·협력·캡슐화·불변식·다형성·LSP·상속/합성·SOLID의 비용 | 예약·재고 모델의 행동과 설계 근거 | +| 3 제네릭·컬렉션 | 소거·variance·PECS·equals/hashCode·변경 가능성·자료구조 계약 | 타입 안전한 API·가변 키 오류 수정 | +| 4 실패·자원·함수형 | 예외 처리 책임·원인 보존·자원 해제·람다·Stream·Optional·java.time | 누수·예외 은폐·부수 효과·경계 검사 | +| 5 빌드·테스트·라이브러리 | Maven·Gradle·JUnit·Mockito·AssertJ·Lombok | 재현 빌드·행동 테스트·생성 코드 해석 | +| 6 동시성·JVM | JMM·원자성·가시성·순서·happens-before·락·취소·GC·관측 | 경쟁 조건 반례·수정 근거·종료/취소 검증 | +| 7 웹·DB 기반 | HTTP·SQL·제약조건·인덱스·실행계획·JDBC·연결 풀 | 요청 계약·SQL·자원·롤백 | +| 8 Spring Core | IoC·DI·빈 생명주기·스코프·설정·AOP·프록시·자동 설정 | 객체 조립 전환·호출 경계 설명 | +| 9 MVC·REST | 계층 책임·Jackson·Validation·예외 응답·API 문서화 | 정상·잘못된 입력·오류 HTTP 검사 | +| 10 영속성 | MyBatis SQL/XML/매핑, JPA/Hibernate 상태/조회, Spring Data | 같은 도메인 두 구현 비교·선택한 방식 심화 | +| 11 트랜잭션·정합성 | 전파·격리·롤백·프록시·락·재시도·Flyway | 부분 실패·경쟁 요청 통합 검증 | +| 12 인증·인가 | Security·세션·OAuth2·OIDC·JWT·CSRF/CORS | 준비된 인증 환경·만료/권한 부족 거부 | +| 13 Redis·Kafka | 만료·무효화·중복·순서·재처리·장애 | 캐시·이벤트 정합성 | +| 14 관측·배포 | 로그·지표·추적·Actuator·Micrometer·OpenTelemetry·Docker·CI·통합 테스트 환경 | 실패 추적·깨끗한 빌드·출시 검증 | +| 15 버전 전환 | 8→11→17→21 문법/API/빌드/의존성/Spring 변화 | 실제 JDK별 회귀·이행 근거 | +| 16 통합 프로젝트 | 설계·DB·보안·정합성·운영·변경 대응 | 실패 시나리오·테스트·설계 결정·관측 증거 | + +OOP·타입→컬렉션·실패→테스트→Spring 실행 모델→영속성→정합성의 선수 관계를 유지한다. HTTP·SQL/JDBC는 해당 Spring·영속성 실습의 공통 기반이다. MyBatis를 JPA 선수 조건으로 강제하지 않는다. SQL/JDBC 기반에서 두 방식 비교→선택 심화→트랜잭션으로 합류한다. + +버전 차이는 각 수업에서 설명하고15단계에서 종합한다. 8은 공통 기반·람다/Stream/time,11은 표준 HTTP와 문자열/파일 API,17은 switch 표현식/text block/record/sealed,21은 record pattern/pattern switch/virtual thread를 다룬다. 최초 도입 버전은 별도 표시한다. + +공통 프로젝트는 주문·재고 예약·취소다. 도메인 모델→테스트→HTTP API→영속화/트랜잭션→권한/중복/동시 요청→캐시/이벤트/관측→버전 상향을 연결한다. 모든 도구를 한 프로젝트에 무조건 끼워 넣지 않는다. + +선택 심화는 Batch·스케줄링, QueryDSL·검색, WebFlux, Kubernetes·분산 설계로 분기한다. 수료는 원리 설명·반례·코드 수정·행동 검증·설계 대안과 비용 설명으로 판정한다. 학습 효과는 실제 학습자 검토 전까지 미확인이다. + +## 웹·설치형 학습 순서 + +1. Java 코어12단원에서 버전별 API, OOP, 타입·컬렉션·실패·동시성·설계 검증을 학습한다. +2. Maven → JUnit·AssertJ → Mockito → Lombok → Spring DI·AOP → MVC·Validation으로 진행한다. +3. SQL/JDBC 뒤 MyBatis와 JPA를 비교하고 Flyway·트랜잭션·낙관적 잠금으로 정합성을 검증한다. +4. Security·CSRF → OAuth2/OIDC → JWT의 역할·검증 경계를 학습한다. +5. Redis/Kafka 선택 실습, Micrometer 관측, Gradle/Docker/CI 비교를 거쳐 주문·재고·취소 통합 과제를 수행한다. + +실무22단원의 선수 관계·공식 근거·실습 파일은 [실무 과정 명세](professional-curriculum.md)와 curriculum/professional/catalog.json에 연결한다. 현대 Spring 실습은 Java17 대상으로 컴파일하고 JDK17/21 실행을 검증한다. Java8/11은 코어·레거시 이행 교재에 사용한다. 웹에서 읽고 프로젝트 ZIP을 내려받거나, Windows 앱에서 같은 코드와 고정 테스트를 직접 실행한다. 기본20개 실무 단원에는 별도 DB 서버가 필요하지 않다. Redis/Kafka 두 단원은 선택 자원을 별도로 준비한다. diff --git a/docs/execution-contract.md b/docs/execution-contract.md new file mode 100644 index 0000000..deb36ff --- /dev/null +++ b/docs/execution-contract.md @@ -0,0 +1,139 @@ +# Java 실행 API 계약 + +기준일: 2026-10-12. 이 문서는 소유자 전용 Sites 프로토타입의 실행 범위를 설명한다. 공개 GitHub 저장소와 사이트의 접근 범위는 별개다. + +## 실제 실행과 대상 버전 + +- 제공자: Wandbox, 컴파일러 ID `openjdk-jdk-21+35`, 실제 JVM **21**. +- 과제의 `minVersion`에 따라 서버가 `--release 8`, `11`, `17`, `21` 중 하나를 지정한다. 문법·표준 API 대상 확인이며 JDK 8·11·17에서 실행했다는 의미가 아니다. +- 클라이언트가 컴파일러·명령·옵션·파일 경로·외부 의존성 URL을 지정할 수 없다. 현재 실행 과제는 표준 Java 단일 소스이며 Spring·Lombok·DB 프로젝트 실행을 지원한다고 표시하지 않는다. +- 컴파일러 목록의 언어·ID·raw 옵션 지원을 확인한다. 성공 목록은 5분, 실패는 15초 동안 프로세스 메모리에 캐시한다. 목록 확인 성공은 매 실행의 성공이나 SLA를 보장하지 않는다. + +공식 근거: [Wandbox 프로젝트](https://github.com/melpon/wandbox), [현재 API 형식](https://github.com/melpon/wandbox/blob/develop/feline/src/types.rs), [실행과 기록 처리](https://github.com/melpon/wandbox/blob/develop/feline/src/api_compile_json.rs), [Oracle javac 21](https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html). + +## 신뢰 경계와 데이터 + +배포는 private Sites 게이트웨이가 사이트 접근 권한을 검사하고 `oai-authenticated-user-id`를 주입·덮어쓰는 환경을 전제로 한다. Worker의 `REQUIRE_IDENTITY` 기본값은 인증 필수다. `TRUSTED_IDENTITY_HEADER`를 설정한다면 위 고정 이름만 허용한다. 이메일은 읽거나 외부로 전송하지 않는다. + +헤더의 존재 확인만으로 일반 인터넷 공개 서비스의 인증을 구현한 것은 아니다. Worker를 게이트웨이 외부에 직접 노출하거나 사이트를 공개할 때에는 독립 인증·권한 검사와 공유 저장소 기반 제한을 먼저 마련해야 한다. 로컬 서버만 명시적으로 `{ REQUIRE_IDENTITY: false }`를 전달하며 루프백 주소에 바인딩한다. + +브라우저의 Origin이 있으면 요청 URL의 Origin과 정확히 일치해야 한다. `null` Origin과 cross-site 요청은 거절하고 CORS 허용 헤더를 제공하지 않는다. 비브라우저 요청의 인증은 Sites 게이트웨이의 책임이다. + +학습자가 실행을 누르면 작성한 Java 코드와 서버 검사 코드가 Wandbox에 전달된다. 쿠키·인증 헤더·사용자 ID·이메일은 전달하지 않는다. 요청은 `save: false`를 지정해 공개 permalink 생성을 요청하지 않는다. **이는 제공자가 소스·실행 정보를 기록하지 않는다는 뜻이 아니다.** 제공자의 현재 구현은 저장 옵션과 별도로 실행 코드를 기록한다. 실행 UI에서 전송 대상을 알리고 개인정보·비밀정보 입력을 피하도록 안내한다. 이 애플리케이션 코드는 사용자 소스나 출력을 콘솔에 기록하지 않는다. + +## 엔드포인트 + +### GET `/api/runtime` + +```json +{ + "provider": "Wandbox", + "available": true, + "runtime": { "javaVersion": 21, "compiler": "openjdk-jdk-21+35" }, + "supportedTargets": [8, 11, 17, 21], + "checkedAt": "2026-10-12T00:00:00.000Z", + "notice": "실제 실행 버전, 대상 컴파일, 외부 코드 전송 안내" +} +``` + +위 시간은 형식 예시다. 사용 불가 시 HTTP 200에 `available: false`, `supportedTargets: []`, `reason`을 반환한다. 인증과 Origin 오류는 별도의 401/403이다. + +### POST `/api/execute` + +Content-Type은 `application/json`이며 본문 전체는 UTF-8 기준 32 KiB 이하다. 본문의 알 수 없는 추가 필드는 실행에 사용하지 않는다. + +```json +{ + "exerciseId": "java-basics-lab", + "source": "class Solution { /* 학습자가 작성한 코드 */ }", + "action": "submit", + "requestId": "b70d96d6-6618-4489-a79e-8a4a2ce40343" +} +``` + +- `exerciseId`: 서버의 `exerciseDefinitions`에 등록된 ID. +- `action`: `run`은 예제 검사, `submit`은 전체 교육 검사. +- `requestId`: 영문·숫자·밑줄·하이픈 1~80자. 같은 사용자·같은 isolate에서 2분 내 중복 ID는 409이며 제공자에 재전송하지 않는다. 영구적인 전역 멱등성은 보장하지 않는다. +- `source`: `class Solution` 템플릿을 완성한 소스. package와 별도의 Main은 사용하지 않는다. 파일명은 제공자의 `prog.java`이므로 `public class Solution`도 사용하지 않는다. import 선언은 사용할 수 있다. + +일반 결과 형식: + +```json +{ + "requestId": "b70d96d6-6618-4489-a79e-8a4a2ce40343", + "status": "passed", + "provider": "Wandbox", + "runtimeVersion": 21, + "targetVersion": 8, + "stdout": "", + "stderr": "", + "compilerOutput": "", + "checks": [{ "name": "내용이 같은 별도 객체", "passed": true }], + "passed": 1, + "total": 1, + "durationMs": 1000 +} +``` + +검사 개수·시간은 형식 예시다. `status`는 `passed`, `failed`, `compile_error`, `runtime_error`, `timeout`, `provider_error` 중 하나다. 컴파일 경고는 실패로 처리하지 않는다. 검사 하나가 예외를 던지면 해당 검사는 실패하고 다음 검사를 수집한다. JVM의 비정상 종료, 누락·중복된 검사 마커는 통과 처리하지 않는다. + +입력 오류는 `{ "requestId": "유효한 ID가 있을 때", "error": { "code": "분류", "message": "안내" } }` 형태다. 제공자 오류에는 일반 결과 필드와 `error`를 함께 반환한다. + +| HTTP | 의미 | +| --- | --- | +| 200 | 실행 결과 또는 환경 조회. 테스트 실패·컴파일 오류도 결과로 반환 | +| 400 / 404 | 잘못된 입력 / 미등록 과제·API | +| 401 / 403 | 인증 누락 / Origin 거절 | +| 405 / 415 | 잘못된 HTTP 방식 / JSON 아닌 요청·압축 본문 | +| 409 / 429 | 중복 요청 ID / 실행 중·호출 한도 | +| 413 | 요청의 UTF-8 바이트 제한 초과 | +| 499 | HTTP 응답 대기 취소 | +| 500 | 서버가 작성한 과제 검사 설정 오류 | +| 502 / 503 | 제공자 응답 오류 / 연결·컴파일러·제공자 한도 문제 | +| 504 | 결과 대기 시간 초과 | + +## 검사와 보안 채점의 차이 + +검사 표현식은 서버의 교육 데이터에서만 선택한다. 요청 본문에 보내는 테스트·예상 결과·옵션은 사용하지 않는다. 서버는 사용자 소스 뒤에 Main을 붙이므로 사용자 소스의 기존 줄 번호를 보존한다. Main이 원본 코드 뒤에 있으므로 일부 오류는 추가된 검사 코드의 줄 번호로 표시될 수 있다. + +실행마다 128-bit 무작위 nonce를 만들고 시작·순서가 있는 검사·종료 마커를 확인한다. 다른 실행의 마커, 단순 `passed` 문자열, JSON 모양의 표준 출력은 정답 판정에 사용하지 않는다. 불완전한 프로토콜은 실패로 처리한다. + +**학습 피드백용 검사이며 부정행위에 강한 시험 채점기가 아니다.** 학습 코드와 검사기가 같은 JVM에서 실행되므로 악의적인 리플렉션·클래스 파일 접근·출력 조작을 완전히 막는다고 주장하지 않는다. 수료·성적·인증의 단독 근거로 사용하지 않는다. 공개 시험용 서비스는 별도 격리된 채점기와 공격 검증이 필요하다. + +## 제한·취소·부분 실패 + +- 요청/소스 32 KiB, 작성된 검사까지 합친 내부 소스 64 KiB. JSON 이스케이프와 메타데이터도 요청 바이트에 포함된다. +- 컴파일 결과 응답 본문 전체를 64 KiB까지 스트리밍 수집하며 초과 시 읽기를 중단한다. Wandbox가 출력 필드를 중복해 보내므로 실제 표시 가능한 표준 출력은 64 KiB보다 작을 수 있다. 제한 초과를 통과로 표시하지 않는다. +- 제공자 목록은 2 MiB 이내, 목록 조회 5초 이내. 목록 확인을 포함한 개별 컴파일 대기는 15초 이내. 요청 본문 읽기에는 별도 5초 제한이 있다. +- 같은 인증 ID는 동시에 하나, isolate 전체는 동시에 두 개, 60초당 여섯 번으로 제한한다. 큐와 자동 재시도는 두지 않는다. 프로토타입의 낮은 동시 사용량과 외부 제공자 부하를 고려한 보수적인 운영 제한이며 성능 벤치마크에서 도출한 값이 아니다. +- 메모리 기반 제한은 isolate 간 공유되지 않고 재시작 시 초기화된다. 전역 호출 제한·사용자별 할당량을 보장하지 않는다. 제공자도 자체 한도를 적용하며 429를 반환할 수 있다. +- 취소·15초 초과는 HTTP 대기를 종료하고 로컬 실행 슬롯을 반환한다. 원격 JVM 종료를 확인할 API가 없으므로 **원격 실행 중단을 보장하지 않는다.** 취소 직후 새 실행이 원격에서 이전 작업과 겹칠 가능성이 남는다. UI는 '응답 대기 취소'로 표시하고 오래된 결과를 반영하지 않아야 한다. +- CPU·메모리·프로세스·네트워크의 샌드박스 강제는 Wandbox가 담당한다. 이 코드가 제공자 내부 제한의 실제 수치나 적용 상태를 보증하지 않는다. 이 경계 때문에 프로토타입을 제한 없이 공개 실행 서비스로 바꾸지 않는다. +- 불명확한 네트워크 실패 뒤 자동 재시도를 하지 않는다. 결과를 받지 못한 경우에도 이미 원격 코드가 실행되었을 수 있다. + +## 검증 + +`node --test tests/executor.test.mjs`는 외부 요청 없이 native fetch mock으로 실행한다. 입력·바이트 제한·Origin·기본 인증·서버 검사 선택·nonce·마커 누락·경고·예외·출력 제한·제공자 오류·타임아웃·취소·중복·호출 제한·컴파일러 목록 TTL을 검사한다. + +실제 제공자 검사는 명시적으로 `JAVA_EDU_LIVE=1`을 지정해야 한다. 교육자가 작성한 공개용 정답과 의도적인 오류만 전송하며 여섯 번 순차 호출하고 재시도하지 않는다. 기본 CI에서 활성화하지 않는다. 제공자 IP 제한을 존중해 짧은 간격으로 반복하지 않는다. + +Windows의 검증 환경은 Node 22.17.0이다. 상속된 `NODE_TLS_REJECT_UNAUTHORIZED=0`이 최초 실행에서 발견되어 해당 결과만을 TLS 검증 근거로 사용하지 않는다. 인증서 검증을 켜고 시스템 CA를 사용한 GET을 재확인했다. 시스템 사설 CA가 필요한 이 환경에서 안전한 명령은 다음과 같다. + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED = '1' +$env:JAVA_EDU_LIVE = '1' +node --use-system-ca --test --test-name-pattern='^live provider:' tests/executor.test.mjs +``` + +2026-10-12 위 명령으로 인증서 검증을 켠 채 실제 여섯 번 순차 호출을 완료했다. + +| 검증 | 실제 결과 | +| --- | --- | +| `java-basics-lab`, 대상 8 | 6/6 검사 통과, 실제 JVM 21 | +| `modern-apis-lab`, 대상 11 | 5/5 검사 통과, 실제 JVM 21 | +| `records-sealed-lab`, 대상 17 | 6/6 검사 통과, 실제 JVM 21 | +| `java21-lab`, 대상 21 | 6/6 검사 통과, 실제 JVM 21 | +| 잘못된 Java 구문 | 실제 javac 오류와 `compile_error` 반환 | +| 항상 false를 반환하는 오답 | 3/6, `failed` 반환 | + +애플리케이션에는 인증서 검증을 해제하는 설정을 추가하지 않는다. 실제 배포 Worker의 네트워크·인증·학습 화면 검증은 통합 담당의 별도 출시 게이트다. 서버 단위·제공자 연결 검증이 배포 환경의 권한·격리 또는 브라우저 사용성 검증을 대신하지 않는다. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..1c5f122 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,30 @@ +# Windows 설치와 학습 시작 + +Windows 10/11 x64용 [v0.3.1 통합 패키지와 SHA-256](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1)를 제공한다. Electron/Node 실행 환경, Eclipse Temurin JDK21.0.12.1+1, Apache Maven3.10.0이 포함되어 학습자가 Java·Maven·Node.js를 따로 설치하거나 폴더를 등록할 필요가 없다. 시스템 PATH를 바꾸지 않는다. + +## EXE 또는 ZIP으로 시작 + +1. 설치하려면 [설치 EXE 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.1/Java-Edu-Setup-0.3.1-x64.exe)를 눌러 받은 파일을 실행하고 설치 위치를 선택한다. 현재 사용자용이며 관리자 권한을 요구하지 않는다. +2. 설치 없이 사용하려면 [전체 ZIP 다운로드](https://github.com/jbsim999/java-edu/releases/download/v0.3.1/Java-Edu-0.3.1-x64.zip)를 **전체 해제**하고 Java Edu.exe를 실행한다. resources 등 함께 있는 파일을 보존한다. exe만 다른 폴더로 옮기면 실행되지 않는다. +3. 앱에 포함된 Java21·Maven이 자동으로 표시된다. 학습 코드 실행에 동의하고 코어 실습의 실행·제출을 사용한다. 앱 실행만으로 학습 코드가 실행되지는 않는다. +4. Spring/H2 실무는 **환경 준비 → 파일 편집 → 실행·제출** 순서다. 첫 환경 준비는 앱이 고정 의존성을 내려받으므로 인터넷이 필요하다. 별도 프로그램 설치는 필요 없으며 준비 후 학생 제출은 오프라인 모드다. 앱 재시작·도구 변경 후에는 준비 상태를 다시 확인한다. + +EXE와 ZIP 중 하나만 받는다. 릴리스의 대응 JDK·제3자 소스와 SHA256SUMS는 출처·검증용이며 학습 실행을 위해 설치하는 파일이 아니다. 배포 파일은 코드 서명되지 않은 프리뷰다. + +## 버전과 고급 설정 + +Java8/11/17/21 교재와 컴파일 대상을 제공하되 기본 실제 JVM은21이다. 네 실제 JVM을 모두 포함한 패키지는 아니다. 다른 실제 JDK8/11/17 또는 기존 Maven을 사용하려는 사람만 고급 설정에서 등록한다. 기존 외부 도구 선택은 보존한다. 포함 도구는 앱 위치가 바뀌어도 현재 resources 기준으로 다시 등록된다. + +설치형의 작성 코드는 Wandbox로 전송하지 않는다. 학습 코드는 사용자 PC의 파일·네트워크 접근 권한으로 실행하며 앱은 OS 보안 샌드박스가 아니다. 진도·초안·메모·테마는 앱 데이터에 저장하고 제거 시에도 기본 보존한다. [로컬 실행 계약](local-execution.md) · [번들 출처와 대응 소스](bundled-runtime.md) · [ZIP·설치 앱 QA](bundled-desktop-qa-report.md) + +## 실무 과정 + +기본20개 프로젝트는 추가 DB 서버 없이 H2·MockMvc·로컬 테스트로 실행한다. Redis/Kafka 실제 연동 두 단원은 선택 환경이며 해당 교재의 고정 loopback 주소·포트를 사용한다. 해당 서버는 패키지에 포함하지 않으며 실제 연동은 미검증이다. Gradle/Docker/CI 비교 파일은 교재 자료이며 앱의 실행 도구는 Maven이다. + +## 웹에서 학습 + +웹도 같은34단원의 이론·예제·퀴즈·메모·문법 강조 편집기를 제공한다. 실무 프로젝트는 ZIP을 내려받아 IDE에서 사용하거나 Windows 앱에서 실행한다. 프로젝트 ZIP의 부모 pom.xml과 lessons/<단원>/starter·reference·grader의 상대 위치를 유지하고 starter 또는 reference에서 mvn test를 수행한다. + +웹 Java 코어 실행은 Wandbox의 실제 JVM21이며 교재 기준·컴파일 대상·실제 JVM을 구분해 표시한다. 웹 진도는 해당 브라우저에 저장되며 앱과 자동 동기화되지 않는다. Sites 웹 배포는 소유자 전용 접근을 유지한다. 과거 v0.2는 도구 별도 등록 방식이며 통합 패키지와 구분한다. + +현재 v0.3.1은 교재·레이아웃 독립 검수와 EXE·ZIP 전체 파일 대조를 통과했다. 기존 앱과 학습 기록을 보존하여 신버전 설치·기동 재검사는 수행하지 않았다. [이번 검증 범위](readability-release-qa-report.md)를 따른다. diff --git a/docs/installed-desktop-qa-report.md b/docs/installed-desktop-qa-report.md new file mode 100644 index 0000000..58d666f --- /dev/null +++ b/docs/installed-desktop-qa-report.md @@ -0,0 +1,97 @@ +# 실제 Windows 설치 패키지 QA + +판정: **PASS**. 2026-10-12 06:57:04–06:58:08 UTC에 실제 NSIS 설치 앱으로 실행한 기능 검사는 8/8 PASS였고 앱을 정상 종료했다. 총괄이 실제 설치 제거를 확인했고 독립 시각 담당도 두 보완 화면을 직접 확인하여 Windows 앱 시각 검수를 PASS로 완료했다. + +## 실제 실행 결과 + +| 검사 | 실제 결과 | +| --- | --- | +| 설치 ASAR 실행 코드 및 unpacked helper 8개 원본 바이트 대조 | PASS; 모든 파일 길이·SHA-256 일치 | +| 실제 packaged 앱·기본 사용자 데이터·보안 설정 | PASS; 설치 exe/ASAR, contextIsolation=true, nodeIntegration=false, sandbox=true, webSecurity=true, allowRunningInsecureContent=false | +| 첫 실행 안내·실제 JDK 21/Maven 등록·명시적 동의 | PASS; 미등록 실행 차단, 등록만으로 코드 실행 없음 | +| 실제 JVM 21의 코어 제출 | Java 8 대상 6/6, Java 21 대상 6/6 PASS | +| Spring/H2 tx-proxy 환경 준비 | 실제 Maven 2/2 PASS; 기존 소스 유지, 객관식·코드 성취 모두 미완료 | +| Spring/H2 tx-proxy 학습자 제출 | 실제 Maven 2/2 PASS; 편집 파일 원문만 제출, 읽기 전용 POM 제외, 성취 기록 | +| 앱 종료·재시작 | 포트 57164→59278; 진도·테마·코어 및 파일별 초안 보존, 준비 상태 재검증 필요, 완료 성취 유지 | +| 최소 창·200% 설정 끝 | 외곽 720×560, renderer 704×521, 문서 글꼴 32px; 닫기 버튼 전체 표시와 닫기 동작 PASS | + +오류 이벤트는 없었다. 기록된 앱 API 요청은 앱의 `127.0.0.1` 엔드포인트뿐이며 외부 Java 실행 제공자는 호출하지 않았다. 최초 Maven 환경 준비에는 정상 라이브러리 다운로드가 포함된다. + +실행 명령은 승인된 도구 경로와 정상 TLS 설정을 적용한 `node tests/installed-desktop.mjs`다. + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED='1' +$env:NODE_OPTIONS='--use-system-ca' +$env:JAVA_EDU_JDK_21='/jdk21/jdk-21.0.12.1+1' +$env:JAVA_EDU_MAVEN_HOME='/maven/apache-maven-3.10.0' +node tests/installed-desktop.mjs +``` + +실제 도구는 이전에 승인된 작업 폴더의 Temurin JDK 21.0.12.1+1, Maven 3.10.0이다. 앱 자식 프로세스에는 `JAVA_EDU_QA_DATA`, `ELECTRON_RUN_AS_NODE`, `NODE_OPTIONS`를 전달하지 않았고 기본 사용자 데이터를 사용했다. + +## 설치·산출물 근거 + +총괄이 실제 NSIS를 사용자 범위의 전용 경로에 설치했다. `installation.json`은 06:50:28 UTC 설치 exit 0을 기록한다. 총괄의 `package-installed.json`은 검토 커밋 `6ba4b8aa124f5ce78e79240253946c9fdac3e770`의 원본 195개와 생성 파일 2개가 설치 ASAR·helper에 일치함을 기록하며, 이 QA는 추가로 핵심 실행 파일 8개를 직접 바이트 대조했다. + +- 설치 EXE SHA-256: `6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74` +- 설치된 앱 exe SHA-256: `31e0610596ea65191bb964dd4f20426bad6da72eb6eca0cb06200900d2b9e636` +- 설치 ASAR SHA-256: `e14c590d6415db74250a293271c97cf1ad8a910e880cd426ef67f3b4a71cbf6b` +- 실행한 테스트 SHA-256: `66db3afc4b719203a958d4efa8705f6f597c54e62bee1078976e95c7b27124cf` +- 최종 실제 결과: `work/installed-desktop-qa/installed-results-1791788288577.json` +- 설정 끝 캡처: `installed-settings-minimum200-close-button.png` +- 준비와 성취 분리 캡처: `installed-prepare-complete-still-incomplete.png` + +모든 근거는 저장소 `work/installed-desktop-qa`에 있다. 두 번째 캡처는 준비 결과 카드와 상단 미완료 성취를 동시에 보여주기 위해 실제 창 content viewport를 1424×1794로 설정했다. 전체 페이지 합성이나 DOM 이동은 하지 않았다. 요청한 설정·준비 상태를 QA가 직접 이미지로 확인했고 독립 시각 담당에게도 전달했다. + +총괄이 수행한 실제 제거는 07:01:27 UTC의 `uninstallation.json`에 기록되어 있다. NSIS exit 0, 전용 설치 폴더 없음, 제거 등록 0개, 설치 소유 바로가기 0개, 학습 데이터 보존을 확인했다. 최초 제거 후 확인은 무관한 바탕 화면 하위 폴더의 접근 거부로 중단되었으나 제거 자체는 완료되었고, 설치 소유 바로가기 5개 정확한 경로만 확인하는 후속 검사로 보완했다. 이 설치·제거 사실은 총괄의 실행 근거이며 QA가 제거 명령을 실행한 것으로 기록하지 않는다. + +독립 시각 담당은 최소 창·글자 200%의 설정 닫기 버튼과 준비 완료/학생 미완료 상태가 함께 보이는 캡처를 직접 확인하여 기존 두 검증 공백이 해소되었다고 판정했다. 해당 Windows 앱 화면에 대한 시각 판정은 PASS다. + +## 사전 검사 + +2026-10-12 06:25:27 UTC에 실제 사용자 환경에서 다음을 읽기 전용으로 확인했다. + +| 대상 | 결과 | +| --- | --- | +| `%APPDATA%/java-edu`, `%APPDATA%/Java Edu` | 모두 없음 | +| 실제 `%USERPROFILE%/AppData/Local/java-edu`, `Java Edu` | 모두 없음 | +| 실제 Local/Programs/java-edu | 없음 | +| 사용자·공용 바탕 화면 및 시작 메뉴의 Java Edu 바로가기 | 없음 | +| HKCU·HKLM·WOW6432Node의 Java Edu 제거 등록 | 없음 | +| 저장소 `work/installed-java-edu-test` | 없음 | + +샌드박스 프로세스의 `LOCALAPPDATA`가 컨테이너 경로로 바뀌는 것을 확인해 실제 사용자 Local 경로로 다시 검사했다. 기존 사용자 파일·레지스트리를 수정하지 않았고 사전 검사 결과만 저장소 `work/installed-desktop-qa/preflight.json`에 기록했다. + +## 검사 범위와 모의 처리 + +`tests/installed-desktop.mjs`는 설치를 자동 수행하지 않는다. 총괄이 확정한 NSIS를 작업 전용 경로에 설치한 이후 실행했다. 준비된 실행 파일과 JDK·Maven이 없거나 기존 사용자 앱 데이터가 발견되면 앱을 시작하지 않는다. + +검사 계약: + +1. 앱 실행 전에 설치된 `app.asar`의 `server/local/{process,engine,http,settings}.mjs`와 `desktop/{main,preload,reference-policy}.cjs`를 `@electron/asar.extractFile`로 읽어 저장소 원본과 바이트 단위 대조. 실제 실행되는 `app.asar.unpacked/desktop/job-runner.ps1`도 원본과 대조하며 파일별 길이·SHA-256·일치 여부 기록. 불일치는 FAIL로 처리하고 앱 실행 중단. +2. 실제 `app.isPackaged`, 설치 경로의 exe·ASAR, 일반 사용자 AppData 경로와 `contextIsolation`, `nodeIntegration`, `sandbox`, `webSecurity`, `allowRunningInsecureContent` 보안 설정 확인. `JAVA_EDU_QA_DATA` 우회 없음. 설치 앱이 제공하는 `app.js`, `styles.css`, `lesson-visuals.js`도 원본 대조. +3. 실제 JDK 21·Maven 검사, 명시적 실행 동의. 모의 처리는 OS 폴더 선택 대화상자의 반환값과 편집기 전체 원문 대조용 `navigator.clipboard.writeText` 수집 두 가지다. 실제 OS 클립보드 읽기·쓰기는 검증하지 않는다. 앱 API·JDK·Maven 실행은 모의 처리하지 않는다. +4. 실제 JVM 21에서 코어 Java 8 대상 및 Java 21 대상 제출. +5. Spring/H2 `tx-proxy` 실제 준비와 제출. 준비만으로 학생 성취가 생기지 않음 확인. +6. 앱 종료·재시작 후 다른 로컬 포트에서 진도·테마·코어 및 프로젝트 초안 보존. +7. 최소 외곽 창 720×560·문서 글꼴 200%에서 설정 마지막 닫기 버튼 전체 표시와 실제 닫기. +8. 준비 완료의 결과 카드 제목·안내와 학생 미완료 상태가 함께 보이는 실제 viewport 캡처. +9. 설치·제거는 총괄이 별도 기록한다. QA 스크립트는 앱을 정상 종료하며 레지스트리 변경·데이터 삭제·앱 제거를 수행하지 않는다. + +`node --check tests/installed-desktop.mjs`는 PASS다. 이는 실제 앱 검증 결과가 아니다. + +2026-10-12 06:50:57 UTC의 읽기 전용 API 점검에서 빌드 폴더 `desktop-release/win-unpacked`의 위 8개 파일과 원본의 바이트 일치가 모두 PASS였다. 이 점검은 `extractFile`의 Windows 경로 정규화까지 확인했으며 설치본 검증을 대체하지 않는다. 근거는 `work/installed-desktop-qa/build-api-proof.json`이다. 설치 EXE의 SHA-256도 총괄이 전달한 `6d4354e0b4a5c8b471591f0ee29c106f9ec65c2561847416555c38e623fd2f74`와 일치했다. + +설치 위치: `/work/installed-java-edu-test`. 산출물: `/work/installed-desktop-qa`. 공개 교재의 기준 코드와 이전에 승인된 JDK 21·Maven 경로만 사용한다. 외부 Java 제공자에 학습 코드를 보내지 않는다. + +## 첫 실행 오류와 재검증 + +첫 실행에서 설치 ASAR/helper 8개 대조, packaged/default userData·보안 설정, 실제 도구 등록은 PASS였다. JVM 21의 Java 8 대상 제출 6/6, Java 21 대상 제출 6/6 및 Spring/H2 준비 2/2도 실제 실행했다. 준비 응답의 정상 제공자는 `Local Maven`인데 검사 코드가 `Local JDK`로만 기대하여 중단했다. 이는 검사 기대값 오류이며 제품 실패로 판정하지 않는다. 수정 전 근거 `installed-results-1791787998728.json`은 보존한다. + +앱은 정상 종료했으며 이 검사로 생성한 사용자 데이터는 `C:/Users/USER/AppData/Roaming/java-edu`다. 사전 검사에서 없던 경로임을 확인했고 QA가 삭제하거나 생산 데이터 경로를 우회하지 않았다. 총괄은 첫 검사 데이터의 원래 경로·사전 부재·프로세스 종료를 확인하고 `work/installed-desktop-qa/app-data-first-run`에 보관했다. 보호 guard를 유지한 재실행은 8/8 PASS였고 새로 생성된 같은 사용자 데이터 경로를 결과에 기록했다. 총괄이 제거 후 보존된 최종 QA 데이터도 동일한 경로·사전 부재·종료·reparse 여부를 확인하여 `work/installed-desktop-qa/app-data-final-run`에 보관했고, 원래 AppData 경로가 없던 상태로 복원했다. 두 검사 데이터 백업과 첫 실패 로그는 보존했다. + +## 미확인 및 판정 경계 + +이 결과는 실제 Windows 설치 앱의 위 8개 시나리오에 대한 **PASS**다. 앱 API·JDK·Maven 응답은 모의 처리하지 않았다. 모의 폴더 선택은 실제 Windows 파일 대화상자 조작 검증을 뜻하지 않으며, 모의 복사 원문 수집은 OS 클립보드 작동 검증을 뜻하지 않는다. + +실제 모바일 기기, 인터넷에서 내려받은 unsigned 설치 파일의 SmartScreen 흐름, 다른 Windows 사용자·OS 환경, 설치본의 실제 JDK 8/11/17, Redis/Kafka 실행은 이 검사에서 수행하지 않았다. 문서 글꼴 확대는 시스템 배율·보조기술 검사와 구분한다. 이전 개발 Electron·웹 검증은 별도 보고서의 범위이며 이번 설치 앱 PASS 수에 합산하지 않았다. diff --git a/docs/java-version-matrix.md b/docs/java-version-matrix.md new file mode 100644 index 0000000..9a62fb2 --- /dev/null +++ b/docs/java-version-matrix.md @@ -0,0 +1,25 @@ +# Java 버전 기준 + +Oracle 공식 본문 확인일 2026-10-12. 언어는 JLS, 표준 API는 해당 판본의 계약·Since, 런타임은 JVMS·공식 가이드를 사용한다. 문헌 대조와 코드 컴파일/실행 결과를 구분한다. + +| 기능 | 최초 정식 버전 | 이 과정 대상 중 최소 버전 | +| --- | --- | --- | +| 람다·Stream·Optional·java.time | 8 | 8 | +| List.of | 9 | 11 | +| 지역 변수 var | 10 | 11 | +| 람다 매개변수 var·HttpClient·String.isBlank/strip/lines/repeat·Files.readString/writeString·Optional.isEmpty | 11 | 11 | +| switch 표현식 | 14 | 17 | +| text block | 15 | 17 | +| record·instanceof 패턴·Stream.toList | 16 | 17 | +| sealed | 17 | 17 | +| record pattern·pattern switch·virtual thread·SequencedCollection | 21 | 21 | + +Java17 pattern switch는 preview이므로 정식 예제에서 제외한다. record의 컴포넌트 필드는final이지만 참조하는 객체까지 깊은 불변성을 보장하지 않는다. 가상 스레드는 CPU 속도·모든 요청 지연 감소를 보장하지 않는다. Java8의Stream예제에는toList()/List.of()를 섞지 않는다. + +공식 근거: [JLS8](https://docs.oracle.com/javase/specs/jls/se8/html/index.html), [공식 언어 변경표](https://docs.oracle.com/en/java/javase/21/language/java-language-changes-release.html), [Stream8](https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html), [HttpClient11](https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpClient.html), [String11](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html), [Stream17](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/stream/Stream.html), [record17](https://docs.oracle.com/en/java/javase/17/language/records.html), [가상 스레드21](https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html). + +Spring Boot2.7/Framework5.3과 Boot3/Framework6 및 Boot4/Framework7을 분리한다. 후자 두 세대는Java17이상이다. Boot2.7.18은Java21까지 호환한다고 명시하므로 레거시=높은JDK실행불가로 가르치지 않는다. 유지보수 지원과 실행 호환성은 별개다. [2.7 요구사항](https://docs.spring.io/spring-boot/docs/2.7.18/reference/html/getting-started.html), [3.5 요구사항](https://docs.spring.io/spring-boot/3.5/system-requirements.html), [현재 요구사항](https://docs.spring.io/spring-boot/system-requirements.html). + +MyBatis-Spring2.1→Framework5/Java8+,3.0→Framework6/Java17+,4.0→Framework7/Java17+를 구분한다. [공식 표](https://mybatis.org/spring/). JUnit5는Java8+,JUnit6은Java17+,Mockito5는Java11+로 테스트 도구를 일괄 최신화하지 않는다. [JUnit5](https://docs.junit.org/5.14.4/overview.html), [JUnit current](https://docs.junit.org/current/user-guide/), [Mockito](https://github.com/mockito/mockito). + +직접JEP본문 접근은 조사 도구에서403이었으므로 검증 완료로 세지 않았다. 동일 기능의 Oracle 공식 설명을 확인했다. 이 문서는 전체 예제 실제실행 PASS나 최종patch선정을 주장하지 않는다. diff --git a/docs/job-evidence.md b/docs/job-evidence.md new file mode 100644 index 0000000..c205668 --- /dev/null +++ b/docs/job-evidence.md @@ -0,0 +1,19 @@ +# 실무 주제의 채용 공고 근거 + +확인일: 2026-10-12, Asia/Seoul. 5개 기업·4개 도메인의 공개 공고를 선정한 관찰 표본이다. 기술 사용률이나 Java 버전별 채용 수요를 의미하지 않는다. 마감 공고는 과거 실무 요구의 근거다. 확인일은 게시일이 아니며 검색 도구의 추출에는 캐시가 포함될 수 있다. `full`은 주요 업무와 요건 본문을 읽었다는 의미다. + +| 기업 / 공고 | 관찰 내용 | 상태 및 한계 | +| --- | --- | --- | +| [토스플레이스 — Server Developer, Platform](https://toss.im/career/job-detail?company=%ED%86%A0%EC%8A%A4%ED%94%8C%EB%A0%88%EC%9D%B4%EC%8A%A4&detailedPosition=Platform&job_id=4071141003) | 테스트·호환성 관리, 동시성·재시도·중복·부분 실패 대응. Java/Kotlin·Spring 경험 | 명시적 마감·모집 상태 미확인. Platform 본문 선택 URL | +| [알라딘커뮤니케이션 — 백엔드 엔지니어-개발2팀](https://jumpit.saramin.co.kr/position/52759934) | 테스트 작성, 부하·쿼리 튜닝. 동시성·분산 처리 우대 | 마감 표시 및 2026-02-05 마감일 확인. Kotlin 별도 요건, Java는 언어 선택지 | +| [코비젼 — 경력직 개발자 채용](https://jumpit.saramin.co.kr/position/50154427) | 백엔드 요건: SQL·RDBMS, Spring·JPA, 컨테이너 운영, API 설계 | 마감 표시 및 2025-03-06 마감일 확인. 여러 직군 요건을 합산하지 않음 | +| [와탭랩스 — Kubernetes 에이전트 개발자](https://career.rememberapp.co.kr/job/posting/291531) | Java 또는 Go, 자료구조·멀티스레드·소켓, 메모리 효율, 비동기 처리 | 채용 시 마감 표시. 실제 모집 지속 미확인. 경력 에이전트 개발 직무 | +| [뮤즈라이브 — 백엔드 엔지니어(Java/Spring)](https://www.jobplanet.co.kr/job/search?entry=job_posting_keyword&posting_ids%5B%5D=1679758) | 코드 품질 개선, Spring Boot·MySQL·Redis·AWS 환경, 대용량 처리 우대 | 마감일 2026-12-27 표시, 실제 모집 상태 미확인. Java 세부 버전 없음 | + +## 교육 설계상의 연결 + +기업이 요구한 과제와 교육자가 제안한 과제를 구분한다. 객체 설계(`oop`), 자료구조 선택(`collections`), 실패 정책(`exceptions`), HTTP 계약(`modern-apis`), 경계·실패 테스트(`testing-design`), 공유 상태·타임아웃·취소(`concurrency`)를 공고에서 관찰한 주제와 연결했다. 실제 기업의 채용 과제가 아니다. + +이 표본에는 Java 8·11·17·21의 사용 여부, records·sealed·virtual threads 또는 JUnit의 직접 요구를 입증하는 근거가 없다. 이 기능들의 교육 근거는 Java 또는 해당 도구의 공식 문서다. + +선정한 5개 공고는 원문 본문을 직접 확인했다. 원티드 와디즈 공고 344971은 원문 열기 오류로 제외했다. 공고 전문과 기업 홍보 내용을 재게시하지 않고 필요한 사실을 요약했다. 모집 상태를 다시 확인하려면 원문을 열어야 한다. diff --git a/docs/local-execution.md b/docs/local-execution.md new file mode 100644 index 0000000..3caebdc --- /dev/null +++ b/docs/local-execution.md @@ -0,0 +1,100 @@ +# Windows 설치형 실행 계약 + +Windows 설치형 앱과 웹 교재는 같은 교육 화면을 사용한다. 설치형의 Java/Maven 실행은 학습자 PC에서만 수행하며 원격 Java 제공자로 코드를 보내지 않는다. 웹은 교재·문제·코드 편집과 기존 코어 실행 환경을 제공한다. 이 앱은 개인 IDE이며 Java 코드의 파일·네트워크 접근을 막는 보안 샌드박스가 아니다. + +## 설치와 도구 등록 + +- Windows 10 이상 x64 현재 사용자용 NSIS 설치 EXE 및 전체 ZIP. v0.3은 Temurin JDK21·Maven3.10.0·Electron/Node를 포함한다. 학습자의 별도 설치·시스템 PATH 변경·자동 업데이트는 필요하지 않다. [번들 계약](bundled-runtime.md)을 따른다. +- 포함 JDK21·Maven은 전체604파일 해시 검사 후 자동 등록한다. 고급 설정에서 다른 실제 JDK8·11·17·21과 Maven3.9 이상을 선택할 수 있고 기존 외부 설정을 보존한다. 실제 실행 때 java/javac 버전을 함께 검사한다. +- 학습자 코드 실행에 대한 명시적인 동의가 필요하다. 앱을 여는 것만으로 학습 코드를 실행하지 않는다. 동의 문구는 파일·네트워크 접근 권한을 설명한다. 동의를 철회하면 진행 중인 실행도 취소한다. +- 기본 실제 JVM은 등록한 JDK 중 수업의 최소 버전을 만족하는 가장 높은 버전이다. 실행 요청의 runtimeVersion으로 다른 등록 JDK를 선택할 수 있다. 교재의 Java 버전·컴파일 대상·실제 JVM을 구분한다. +- 실무 프로젝트 환경 준비는 명시적인 버튼으로 고정 기준 답안의 Maven 테스트를 실행한다. Maven Central에서 고정 버전 의존성을 내려받는다. 이후 학습자 Maven 실행은 오프라인 모드다. 앱 재시작 또는 JDK 변경 뒤에는 환경 준비를 다시 확인한다. + +## 브라우저 연결과 IPC + +Electron은 contextIsolation=true, nodeIntegration=false, sandbox=true, webSecurity=true로 설정한다. 정적 교재와 API는 127.0.0.1의 임의 포트에만 바인딩한다. Host·Origin을 실제 바인딩 주소와 대조하며 모든 요청에는 앱 세션의 HttpOnly/SameSite=Strict 쿠키가 필요하다. 상태 변경에는 정확한 Origin과 Electron main이 붙이는 CSRF 헤더도 필요하다. 세션 토큰을 URL·쿼리·localStorage에 저장하지 않는다. 외부 탐색·새 앱 창·권한 요청은 차단하고 허용한 출처 링크만 OS 브라우저로 연다. + +외부 출처 정책은 `desktop/reference-policy.cjs`의 명시적 호스트 목록을 사용한다. 교재 공식 문서·검토한 채용 공고·도구 다운로드·저장소 릴리스만 포함하며 와일드카드를 사용하지 않는다. HTTPS라도 사용자 정보, 명시적 포트, 인코딩된 호스트 이름 또는 다른 호스트는 거절한다. 34개 수업의 출처 90개와 채용 근거 5개, UI·설치 안내 링크를 실제 데이터와 대조하는 회귀 검사가 새 출처 누락을 탐지한다. + +`window.javaEduDesktop`이 있으면 설치형이다. 브리지는 다음 메서드만 노출한다. main은 기본 창의 mainFrame과 같은 origin인지 검사한다. + +| 메서드 | 입력 | 결과 | +| --- | --- | --- | +| getSettings() | 없음 | 아래 runtime 응답 | +| chooseJdk(version) | 8/11/17/21 숫자 | OS 폴더 선택 후 runtime 응답 | +| chooseMaven() | 없음 | OS 폴더 선택 후 runtime 응답 | +| setExecutionConsent(accepted) | boolean | 동의 저장 후 runtime 응답 | +| storage.getItem(key) | java-edu: 접두 키 | 문자열 또는 null | +| storage.setItem(key,value) | 접두 키·문자열 | 동기 저장 | +| storage.removeItem(key) | 접두 키 | 동기 삭제 | + +교재 UI는 `window.javaEduDesktop?.storage ?? window.localStorage` 어댑터를 사용한다. 설치형 학습 데이터는 고정 userData/learning-state.json에 저장하므로 임의 포트와 앱 재시작에 영향을 받지 않는다. 총 8 MiB를 넘으면 저장 실패를 UI에 알린다. 렌더러에 임의 파일·경로·명령·환경 변수 IPC는 제공하지 않는다. + +## HTTP API + +일반 fetch를 사용한다. Electron main이 인증 쿠키와 CSRF 헤더를 관리한다. + +- GET `/api/runtime`: 기존 provider/available/runtime/supportedTargets/notice 외에 `mode:'local'`, `runtimes:[{javaVersion,available,path,source}]`, `maven:{available,version,path,source}`, `bundledRuntime:{id,javaVersions,mavenVersion}|null`, `consent:{accepted,notice}`, `dependencyNotice`, `reason`, `limits`, `preparedProjects:[{exerciseId,runtimeVersion,jdkPath,mavenPath,mavenVersion}]`. 도구 미등록/동의 전에도 200이며 available=false다. 실제 실행에서 도구 버전을 다시 확인한다. 준비 상태는 현재 등록한 JDK 경로와 Maven 경로·버전에 일치하는 항목만 반환한다. +- GET `/api/local/projects/`: `{exerciseId,lessonId,title,profileId,runtimeRequirements,files:[{path,source,editable}],notice}`. 파일은 교재의 고정 allowlist에 해당한다. +- POST `/api/execute`: 코어는 `{exerciseId,source,action:'run'|'submit',requestId,runtimeVersion?}`. 실무는 source 대신 `files:{고정상대경로:소스}`. 편집 가능 파일 전부를 제출하며 pom.xml·grader·명령·URL은 변경할 수 없다. +- POST `/api/local/prepare`: `{exerciseId,requestId,runtimeVersion?}`. 기준 답안으로 환경 준비. 결과에 `prepared:boolean`이 추가된다. +- POST `/api/local/cancel`: `{requestId}` → `{cancelled:boolean}`. HTTP 응답 연결이 끊어져도 해당 실행을 취소한다. + +실행 결과는 `{requestId,provider,runtimeVersion,targetVersion,status,stdout,stderr,compilerOutput,checks:[{name,passed}],passed,total,durationMs,error?}`다. status는 기존 passed/failed/compile_error/runtime_error/timeout/provider_error 집합을 유지한다. 로컬 설정·입력 오류는 HTTP 400/404/409/413/429/503과 `{error:{code,message}}`다. 실무의 실행/제출은 모두 교재에 고정된 테스트 전체를 수행한다. + +## 실행·실패 경계 + +- 한 번에 한 실행. 동시 요청은 429, 2분 안의 동일 requestId 재사용은 409. 자동 재시도는 없다. +- 앱의 검증된 전용 jobs/job-* 디렉터리에만 교재 파일을 만든다. core 소스 32 KiB, 실무 파일별 64 KiB/합계 256 KiB, 요청 384 KiB, 출력 128 KiB 제한. +- Java 컴파일 20초, 코어 실행 5초, 실무 테스트 120초, 최초 환경 준비 300초. 초기 보호 한도이며 측정 성능 보장이 아니다. +- Windows trusted PowerShell helper가 PROC_THREAD_ATTRIBUTE_JOB_LIST를 통해 CreateProcess(CREATE_SUSPENDED)와 Job Object 배정을 원자적으로 수행한 뒤 Resume한다. 표준 입출력 핸들만 명시적으로 상속하며 job handle은 상속하지 않고 KILL_ON_JOB_CLOSE를 사용한다. helper는 최초에 앱 부모의 process handle을 열고 앱 종료·자식 종료·timeout을 함께 감시한다. 생성 직후 Resume 전 helper가 종료되더라도 Job에 배정된 자식이 정리된다. Resume 실패 경로도 자식을 종료한다. +- 코어 Job memory 512 MiB/자식 8개, Maven 1536 MiB/자식 16개. Java heap은 코어 128 MiB, 컴파일러 256 MiB, Maven+테스트 512 MiB다. Windows 메모리 한도는 전체 프로세스 묶음이며 JVM heap과 다르다. +- Maven은 선택한 JDK로 classworlds launcher를 직접 호출한다. shell=false이며 cmd/mvn.cmd, 학습자 wrapper·POM·프로파일·명령을 실행하지 않는다. 전용 저장소·고정 settings·고정 Maven Central mirror를 사용한다. JAVA_TOOL_OPTIONS/JDK_JAVA_OPTIONS/_JAVA_OPTIONS/CLASSPATH/MAVEN_OPTS/MAVEN_ARGS 등 사용자의 실행 옵션·자격증명을 상속하지 않는다. +- Surefire는 forkCount=0으로 매 작업의 독립 Maven JVM 안에서 테스트한다. 이 설정은 Windows 경로의 `%이름%` 문자가 Surefire의 내부 cmd 실행에서 확장되는 문제를 피한다. 수업 간 JVM은 공유하지 않는다. +- Windows Maven TLS는 Windows-ROOT 신뢰 저장소를 사용하며 인증서 검증을 유지한다. JVM·OS의 인증서 저장소를 변경하지 않는다. +- 코드 실행 권한과 전체 PC 접근 가능성은 별개로 남는다. 제어된 파일 편집 API·Job Object·timeout은 악성 학습 코드의 완전한 격리를 보장하지 않는다. 테스트 판정은 교육 피드백이며 부정행위 방지용 보안 채점 서버가 아니다. +- H2·MockMvc·협력 객체 테스트는 교재의 해당 범위만 검증한다. 실제 Redis/Kafka/외부 OAuth 서비스 운영 검증은 별도 환경과 실제 연결 시험이 필요하다. +- Redis·Kafka 2개 선택 단원은 학습 전용 127.0.0.1:16379 / 127.0.0.1:19092 고정 주소만 전달한다. 먼저 750ms TCP 준비 확인을 수행하며 미준비면 unsupported_resources/409로 안내한다. 포트 연결 성공은 서비스 동작 검증이 아니며 뒤의 실제 통합 테스트가 성공해야 완료된다. 임의 주소·포트·사용자 환경변수를 받지 않는다. 나머지 기본 실무 단원에는 외부 서버를 요구하지 않는다. + +## 근거 + +- [Electron security](https://www.electronjs.org/docs/latest/tutorial/security/), [context isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation), [process sandboxing](https://www.electronjs.org/docs/latest/tutorial/sandbox). +- [Windows Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects), [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw), [WaitForMultipleObjects](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitformultipleobjects). +- [UpdateProcThreadAttribute / JOB_LIST](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute), [JSSE trust store settings](https://docs.oracle.com/en/java/javase/21/security/java-secure-socket-extension-jsse-reference-guide.html). +- [Maven CLI options](https://maven.apache.org/ref/3.9.11/maven-embedder/cli.html), [Maven settings](https://maven.apache.org/settings.html), [electron-builder NSIS](https://www.electron.build/nsis.html). + +## 2026-10-12 로컬 엔진 검증 + +환경: Windows x64, Node 22.17.0, OpenJDK 8u265, Temurin JDK 21.0.12.1, Apache Maven 3.10.0. 기준 답안은 저장소에 작성된 고정 교재 코드만 사용했으며 원격 Java 실행 제공자를 호출하지 않았다. + +| 검사 | 실제 결과 | +| --- | --- | +| `node --test tests/local/*.test.mjs` | 16 PASS. 입력/파일 allowlist·동의·1작업 한도·중복 요청·취소·원자적 저장·Maven 설정·선택 자원·HTTP 인증·외부 링크 정책 계약 | +| 외부 문서·채용·설치 링크 | 34수업 출처 90개, 공고 5개, 실제 UI·설치 안내의 HTTPS URL 허용 PASS. 악성 프로토콜·사용자 정보·명시 포트·호스트 혼동 거절 PASS. OS 브라우저의 원격 페이지 로드 검증과는 구분 | +| Windows 프로세스 supervisor | 공백·한글·&·%·따옴표 인수 보존, timeout 자손 종료, 출력 초과 종료, 앱 부모 강제 종료 뒤 자손 종료 PASS | +| 생성 직후 취소 경계 | 제품 helper의 별도 계측 복사본을 CreateProcess 직후 중지한 다음 helper를 종료. Resume 전 자식도 ESRCH로 종료 확인 PASS | +| 로컬 실제 JVM 8 | 코어 9과제·56 행동 검사 PASS | +| 로컬 실제 JVM 21 | 코어 12과제·73 행동 검사 PASS | +| 한국어 컴파일 진단 | Java 8·21 각각 `없는변수` 식별자가 포함된 실패 진단의 UTF-8 원문 보존 PASS | +| Maven build-maven | 환경 준비 2/2·오프라인 제출 2/2 PASS | +| Maven mvc-rest | 환경 준비 3/3·오프라인 제출 3/3 PASS | +| Maven mybatis-mapping | 환경 준비 2/2·오프라인 제출 2/2 PASS | +| Maven jpa-state | 환경 준비 3/3·오프라인 제출 3/3 PASS | +| Maven security-csrf | 환경 준비 4/4·오프라인 제출 4/4 PASS | +| Maven jwt-validation | 환경 준비 5/5·오프라인 제출 5/5 PASS | +| Maven tx-proxy | 환경 준비 2/2·오프라인 제출 2/2 PASS | +| Maven 특수 경로 회귀 | 한글·공백·&·`%JAVA_HOME%`를 포함한 작업/저장소 경로에서 위 7개 실습의 준비와 오프라인 제출 전부 PASS. 최종 forkCount=0 실행 방식으로 확인 | + +실제 Java 11·17 PC 런타임은 이 환경에 설치되지 않아 로컬 엔진으로 실행하지 않았다. 다른 JDK의 CI 결과와 별개다. Redis·Kafka 실제 서비스 연결은 미검증이며 미준비 시 409 반환과 고정 loopback 주소 정책만 검증했다. Electron 전체 화면 흐름·설치 파일·패키지 실행 검증은 독립 QA 및 최종 릴리스 기록을 따른다. + +실행 중 발견한 실패는 숨기지 않았다. Java 8 도구 검사에서 자동 heap 크기가 Job 메모리 한도를 넘는 문제는 명시적 heap 한도로 수정했다. Maven의 첫 TLS 실패는 Windows-ROOT의 정상 인증서 검증으로 해결했다. Surefire 자식 JVM은 PATHEXT 누락과 `%JAVA_HOME%` 경로의 내부 셸 확장에서 실패했으며, 최종 구현은 forkCount=0으로 매 작업의 독립 Maven JVM에서 테스트하여 내부 셸 호출을 없앴다. JDK 21의 표준 오류 인코딩은 file.encoding만으로 고정되지 않아 stdout/stderr 인코딩도 명시했다. JWT 기준 테스트의 모호한 RSAKey import는 콘텐츠 담당자가 고친 후 5/5로 재검증했다. + +## Windows 감독 초기화와 임시 경로 + +Windows Server CI에서 PowerShell 모듈 자동 탐색 지연과 관리자 .NET CodeDom의 임시 디렉터리 생성 오류를 발견했다. 감독 helper는 Windows 기본 Utility manifest만 명시적으로 로드한다. CodeDom 컴파일에는 OS 기본 TEMP 아래 호출별 고유 디렉터리를 사용하고, 실제 Java/Maven 시작 전 TEMP/TMP를 원래 작업별 폴더로 복원한다. 학습자 작업 폴더의 한글·공백·`&`·`%`를 제거하는 방식이 아니다. 기존 CodeDom ACL 보호와 Job·시간·출력 제한은 유지한다. + +[.NET TempFileCollection 원문](https://github.com/microsoft/referencesource/blob/main/System/compmod/system/codedom/compiler/TempFiles.cs)과 [NativeMethods 원문](https://github.com/microsoft/referencesource/blob/main/System/compmod/microsoft/win32/NativeMethods.cs)의 관리자 분기·ANSI 경로 호출이 오류 스택과 일치했다. 로컬 코드페이지 밖 문자의 직접 호출에서도 Win32 오류123을 재현했다. 비관리자 로컬 수정 검사는16/16, JDK8 56/56·JDK21 73/73 및 양쪽 한글 진단 PASS다. 관리자 Windows CI의 수정 결과는 이후 실제 결과로 확인한다. + +감독 초기화에는 ANSI로 표현 가능한 OS 기본 TEMP 경로가 필요하다(ASCII 경로 권장). 기본 TEMP 자체가 현재 코드페이지로 표현되지 않는 경로일 때는 이 .NET 경로 제약이 남는다. 정상 완료·취소·감독 중단 후에는 이번 호출의 디렉터리 실제 부모·고유 접두사를 확인해 정리한다. 앱 부모가 강제로 종료되어 finally가 실행되지 않은 경우에는 임시 디렉터리가 남을 수 있다. 자식 프로세스 종료 검증을 모든 임시 파일의 자동 정리 보장으로 확대하지 않는다. + +수정 후 [CI38213755330](https://github.com/jbsim999/java-edu/actions/runs/38213755330)의 Windows8/11/17/21 네 환경은 모두 실제 관리자·코드페이지1252였다. 각 로컬16/16, 실제 JVM56/61/67/73검사 및 한글 진단이 성공했다. 이 결과는 앞서 미확인이던 관리자 경로 회귀를 보완하며 ANSI로 표현 불가한 OS 기본 TEMP 자체의 제약을 없애지는 않는다. diff --git a/docs/milestones.md b/docs/milestones.md new file mode 100644 index 0000000..3d5a81d --- /dev/null +++ b/docs/milestones.md @@ -0,0 +1,26 @@ +# 개발 마일스톤 + +사용자가 2026-10-12에 마일스톤별 커밋·푸시 및 완성된 프로토타입/서비스의 배포를 승인했다. 문서·코드·검사 정의가 존재하는 것과 실행 성공은 구분한다. + +| ID | 범위 | 완료 게이트 | 현재 상태 | +| --- | --- | --- | --- | +| M1 | 기획·교육 로드맵·버전/실행/협업 계약 | 독립 문서 리뷰·형식/링크/범위 확인·커밋·원격 ref 확인 | 완료: `796b4cf` 원격 반영 | +| M2 | Java 코어12수업·과제·UI·진도·출처·CI | 콘텐츠 정합성·예제/답안·UI mock 검사·독립 리뷰·커밋/푸시 | 완료: `731b4a2` 원격 반영·CI PASS | +| M3 | 실제 JVM21 외부 실행·샘플/제출 API·Worker 패키징 | API 단위 검사·실제 코드 실행·오류/버전 계약·독립 리뷰·커밋/푸시 | 완료: `fe68aef` 원격 반영·CI PASS | +| M4 | 프로토타입 통합·배포 | 반응형·키보드·진도·오래된 응답 무효화·합성 화면·CI 확인·출시 커밋/푸시·배포 성공 | 완료: `165e113` 원격 반영·CI PASS·private 배포 성공 | +| M4.1 | Java 문법 색상·3테마·편집 기능 | 구문·브라우저 회귀·선택 대비·새 체크아웃 빌드·독립 코드/시각 검수 | 완료: `a4d277e` 원격·CI PASS·private 배포 성공 | +| M5 | Windows 설치형·로컬 JDK8/11/17/21·Maven 실행 | 세션·도구 등록·프로세스 정리·앱 재시작 진도·실제 로컬 실행·설치 파일 | 완료: `6ba4b8a` CI12 성공, 설치 앱8/8·설치/제거·독립 시각 PASS, v0.2.0 프리뷰 출시 | +| M6 | Spring/REST/Lombok/MyBatis/테스트 대표 수업 | 실제 의존성·다중 파일·DB·HTTP 코딩 실습 | 22실무수업 통합, 기본20프로젝트 기준54JUnit PASS·starter 실패 확인 | +| M7 | JPA/보안/Redis/Kafka/관측·운영 과정 확장 | 단원별 실행 환경·실패 과제·출처·독립 리뷰 | 구현·독립 코드/출처 PASS, 웹 교재 배포 완료; Redis/Kafka 실제 서비스 미검증·Windows v0.2.0 프리뷰 출시 | +| M7.1 | 이론 도식과 GitHub 프로젝트 소개 | 34수업 채택 평가·공식 출처·독립 코드/기능/시각 검수·README 실제 화면 | main 소개 반영, 12도식 채택·22보류; 도식19/19·회귀49/49·내용/시각 PASS, `a0d1257` 원격·웹 배포 성공 | +| M8 | 별도 도구 설치 없는 통합 EXE·ZIP | 공식 원본·고지/소스·604파일 무결성·실제 ZIP/NSIS·독립 검수·출시 | 완료: `0e1ff5e` CI12, 최종 ZIP10/10·NSIS10/10·설치/제거/데이터 보존·시각 PASS; v0.3 프리뷰 | + +M4는 전체 교육 과정 완료를 뜻하지 않는다. 2026-10-12 사용자는 **Windows 설치형 + 웹 교재**를 선택했다. 학습자 로컬 PC에서 Java/Spring/H2를 실행하며, 호스팅 컨테이너 서버는 기본 과정에 필요하지 않다. Redis/Kafka 실제 연동은 별도 선택 환경이다. 웹의 기존 코어 실행은 Wandbox 실제 JVM21을 사용한다. 교재 버전·컴파일 대상·실제 JVM을 구분한다. 마일스톤마다 관련 파일만 stage하고 강제 푸시하지 않는다. 배포는 검증한 커밋의 동일 소스를 사용한다. + +M4.1 검증: 브라우저 고유49개를 전체47개와 추가/수정 영향 검사로 나눠 확인, 토큰 단위4개 PASS, 전체 Node31 PASS/실제 제공자1 SKIP. 선택 상태 최저 대비4.859/4.810/5.772:1. 생성 번들이 없는 작업 복사본에서 npm ci→build→Worker 검사→Node 검사 PASS. npm audit은 scoped global-agent 보완 후0건이며 실제 설치 파일 빌드는 M5에서 검사한다. 상세 범위·미확인은 docs/editor-qa-report.md를 따른다. + +M5–M7 출시 게이트는 이후 완료했다. 출시 소스 6ba4b8a의 CI12작업, 실제 설치 앱8/8, 설치·제거·시각 검수 PASS. [v0.2.0 프리뷰](https://github.com/jbsim999/java-edu/releases/tag/v0.2.0)와34수업 웹 교재를 배포했다. 선택 Redis/Kafka 실제 연동, 실기기·스크린리더·장기 부하는 미검증이며 프리뷰 범위를 유지한다. 최신 상태는 [개발 인계](development-handoff.md)를 따른다. + +## M9 — 교재 가독성·본문 확장 + +앱 소스 `6ce0428`의 CI12와 독립 정적5·읽기24·기존49·도식19검사 PASS. 34수업의 구체적 읽기 안내와 반복 정보 정리, 본문 확장을 완료했다. [v0.3.1](https://github.com/jbsim999/java-edu/releases/tag/v0.3.1) EXE·ZIP은 전체 파일·도구 무결성 대조 후 공개했다. 새 실제 설치·기동 검사는 사용자 앱·기록 보존 때문에 미실행이다. 소유자 전용 웹 버전5 배포와 main README 첫 화면 반영도 완료했다. 실제 ID·소스·검증 경계는 [배포 기록](deployment.md)을 따른다. diff --git a/docs/product-plan.md b/docs/product-plan.md new file mode 100644 index 0000000..86c906c --- /dev/null +++ b/docs/product-plan.md @@ -0,0 +1,51 @@ +# Java Edu 기획안 v0.3 + +기준일 2026-10-12 (Asia/Seoul). 사용자가 승인한 교육 방향과 Windows 설치형 + 웹 교재 구성을 기록한다. + +## 목표와 대상 + +Java 동작 원리를 이해하고 설계 선택을 설명하며 실제 서비스를 구현·검증하는 교육 프로그램을 만든다. 다른 언어 경험자의 전환 경로와 현직자의 심화 경로가 공통 교재를 공유한다. 완전 프로그래밍 입문 과정은 제외하되 OOP·타입 시스템·동시성은 깊게 학습한다. 코드 해석·결함 수정·근거 설명 진단으로 필요한 단원을 선택한다. + +## 교육 범위 + +| 영역 | 내용 | +| --- | --- | +| Java 코어 | 객체·타입·값 전달, 캡슐화·불변식, 다형성·계약·LSP, 상속·합성·의존 방향, 제네릭·컬렉션·예외·람다·Stream·시간 | +| 실행 모델 | JMM·원자성·가시성·순서·happens-before, 락·Executor·취소, JVM·GC·관측, 리플렉션·애너테이션 | +| Spring | Framework·Boot·IoC·DI·빈 생명주기·스코프·AOP·프록시·MVC·자동 설정 | +| 라이브러리 | Lombok 생성 코드, Jackson·Validation, API 문서화 | +| 영속성 | SQL·JDBC·HikariCP·MyBatis·JPA/Hibernate·Spring Data JPA·Flyway·트랜잭션 | +| 보안 | Spring Security·세션·OAuth2·OIDC·JWT·인증/인가 경계 | +| 테스트·빌드 | JUnit·Mockito·AssertJ·Testcontainers·Maven·Gradle | +| 분산·운영 | Redis·Kafka, Actuator·Micrometer·OpenTelemetry·Prometheus/Grafana, Docker·CI/CD | +| 선택 심화 | Batch·WebFlux·QueryDSL·Kubernetes·검색·분산 설계 | + +Java 명세, 도구의 공식 계약, 교육자의 설계 지침을 구분한다. OAuth2는 인가 규약, OIDC는 인증 계층, Spring Security는 그 구현을 지원하는 프레임워크다. JPA 명세·Hibernate 구현·Spring Data 추상화도 구분한다. SOLID·패턴 사용 개수 자체로 평가하지 않는다. + +## 수업과 평가 + +모든 공개 수업은 이론 → 예시 → 문제풀이 → 실무 심화 → 웹 IDE 직접 코딩이다. 클래스 골격형은 구조·불변식·책임 설계, 메서드 본문형은 API·경계 처리·로직 구현에 사용한다. 이후 프로젝트형은 Java·XML·SQL·설정 등 여러 파일을 다룬다. 외부 호출 계약은 명시하고 내부 설계 선택권을 둔다. + +웹 IDE는 코드 보존·실행·피드백·수정·제출을 연결한다. 샘플 실행과 제출 검증, 실행 성공과 행동 테스트 통과, 설계 설명 기록과 설계 검토 완료를 구분한다. 코드가 바뀌면 이전 결과를 현재 코드의 성취로 표시하지 않는다. + +## 버전·실무 근거 + +교육 버전 8·11·17·21, 최초 정식 도입 버전·최소 예제 버전·실제 실행 JVM을 구분한다. preview/incubator는 기본 과정에서 제외한다. Java8/11의 기존 Spring 세대와 Java17/21 현대 Spring 세대는 검증한 의존성 조합으로 분리한다. 기술적 호환성과 유지보수 지원은 별도다. + +공고는 관찰 표본이다. 기업·직무·URL·확인일·모집 상태·필수/우대·접근 범위를 기록한다. 수업 연결은 교육자의 제안이며 시장 사용률이나 기업의 실제 채용 과제로 확대하지 않는다. + +## 공개 범위와 확장 + +첫 **프로토타입**은 Java 코어 12개 수업, 두 종류의 실행 과제, 검색·버전·트랙·출처·진도·웹 IDE를 연결한다. 외부 실행 JVM은21이며 Java8/11/17/21 컴파일 대상 검증을 제공한다. 이것은 전체 과정이나 네 JVM 실행 서비스 완성본이 아니다. + +후속 **실무 MVP**는 Spring·REST·Lombok·MyBatis·테스트의 대표 수업을 공개한다. 해당 라이브러리·다중 파일·DB·HTTP 실행 환경 검증이 공개 조건이다. 그 뒤 JPA·보안·캐시·메시징·운영 단원을 확장한다. 필요한 배포 계정·자원은 명시하고 비용·권한을 임의로 확대하지 않는다. + +## 선택한 제품 구성 + +Windows 현재 사용자용 설치 앱과 웹 교재가 동일한 교육 화면을 공유한다. 코어12개와 실무22개 단원을 연결하고, 코드에는 Java 구문 색상과 다크·라이트·고대비 테마를 적용한다. 실무 단원의 Java/XML/SQL/설정 파일은 탭으로 탐색하며 학습자가 편집할 파일과 고정된 빌드·테스트를 구분한다. + +설치형 v0.3은 Temurin JDK21·Maven3.10.0·Electron/Node를 포함한 설치 EXE와 전체 ZIP을 제공한다. 학습자의 별도 도구 설치·폴더 등록 없이 자동 설정하며, 다른 실제 JDK8/11/17/21 또는 Maven 등록은 고급 설정의 선택 기능이다. 기존 외부 설정을 보존한다. 기본 실제 JVM은21이며 교재·컴파일 대상8/11/17/21과 구분한다. 코드 실행은 PC에서 수행하고, 최초 환경 준비에서 고정된 Maven 의존성을 내려받은 뒤 과제는 오프라인으로 실행한다. 기본 DB는 H2이며 필수 Docker 서버를 요구하지 않는다. Redis/Kafka 서버는 번들에 포함하지 않는 선택 환경이다. [통합 패키지 계약](bundled-runtime.md)을 따른다. Gradle/Docker/CI 비교 자료와 실제 자동 실행 범위도 구분한다. + +웹은 같은 이론·예제·퀴즈·설계 메모·편집기 및 프로젝트 ZIP을 제공한다. 실무 프로젝트 실행 버튼은 설치형에서 사용한다. 웹의 기존 Java 코어 실행은 Wandbox를 유지한다. 설치형 진도·초안·테마는 앱 데이터에 보존하고, 웹 진도는 브라우저에 보존한다. 기기 사이 자동 동기화는 제공하지 않는다. + +구현자 외 독립 리뷰, 실제 기능 검사, 합성 화면 검수 후 마일스톤별 커밋·푸시한다. 완성된 프로토타입은 owner-private Sites에 배포한다. 공개 GitHub는 서비스 공개 권한과 구분한다. diff --git a/docs/professional-curriculum.md b/docs/professional-curriculum.md new file mode 100644 index 0000000..e5c2479 --- /dev/null +++ b/docs/professional-curriculum.md @@ -0,0 +1,129 @@ +# 실무 교육 과정: 주문·재고·취소 + +기준일: 2026-10-12. 이 문서는 기존 Java 코어 12단원 뒤에 연결하는 실무 22단원의 교육·소스 계약입니다. 전체 교육 지도의 단계는 [교육 로드맵](education-roadmap.md)을 따릅니다. 완전 초보자의 첫 프로그래밍 과정이나 모든 운영 환경을 보장하는 인증 과정으로 소개하지 않습니다. + +## 학습자와 선수 진단 + +다른 언어 경험자는 Java의 값 전달·타입·객체 불변식·동등성·제네릭·예외 소유권을 진단한 뒤 부족한 공통 코어를 학습합니다. 현직자는 같은 진단과 실행 과제로 공통 코어를 건너뛸 수 있습니다. 두 경로를 별도 교재로 복제하지 않습니다. + +진단은 용어 암기만으로 통과시키지 않습니다. 실패 후 상태가 보존되는 이유, equals/hashCode와 컬렉션의 관계, checked 예외의 소유권, 한 스레드의 관찰과 JMM의 차이, 프록시와 실제 객체의 호출 경로를 코드와 반례로 설명해야 합니다. 다형성·LSP·상속과 합성·책임 배분이 부족하면 코어 OOP로 돌아갑니다. + +이 실무 묶음의 컴파일 대상은 Java 17, 실행 요구는 Java 17 이상이고 기본 권장 JVM은 21입니다. Java 8·11·17·21의 언어·API 변화는 코어 수업에서 버전별로 학습합니다. 이 묶음에 Java 8/11용 Spring Boot 3 프로젝트가 있다고 표시하지 않습니다. Boot 2.7 유지보수와 8→11→17→21 마이그레이션은 별도 후속 단원으로 분리합니다. 날짜나 고정 학습 기간으로 숙련을 약속하지 않으며 lesson의 duration은 미실측 편집 추정치입니다. + +## 모든 단원의 다섯 단계 + +1. **이론**: 단원이 해결할 질문과 객체·트랜잭션·전송 경계를 읽습니다. +2. **예시**: 하나의 Java 파일뿐 아니라 POM·SQL·XML·설정까지 함께 읽고 기준 구현의 선택 이유를 추적합니다. +3. **문제풀이**: 정상 사례 암기보다 개념 오해와 실제로 혼동할 대안을 구분하는 객관식 2문항을 풉니다. +4. **실무 심화**: 부분 실패·중복 요청·동시성·권한·운영 비용의 반례를 설명하고 대안의 비용을 비교합니다. +5. **직접 코딩**: 설치형 앱에서 지정된 파일을 편집하고 실제 JDK/Maven 및 필요한 학습 자원으로 실행합니다. 웹은 교재·예제·퀴즈·해설·공개 정답 비교·프로젝트 다운로드를 제공합니다. + +코어의 클래스 골격형과 메서드 몸체형을 실무의 고정 멀티파일 프로젝트로 확장합니다. Java 과제는 클래스와 의존 관계를 읽으며 메서드 구현을 완성하고, MyBatis/Flyway는 SQL/XML 계약을 직접 수정합니다. POM·채점 테스트·일부 보조 파일은 실행 조건을 보존하기 위해 편집 대상에서 제외합니다. 공개 정답과 테스트는 학습용이며 부정행위를 막는 경쟁 채점 자료가 아닙니다. + +## 22개 단원 지도 + +아래 순서는 안내 순서이며 실제 진입은 prerequisites를 따릅니다. 특히 JDBC 이후 MyBatis와 JPA는 비교 가능한 독립 선택 경로입니다. 두 기술을 반드시 차례대로 모두 익혀야 다음 단계로 갈 수 있다고 강제하지 않습니다. 트랜잭션 공통 경계에서 다시 만나며 낙관적 잠금 단원은 JPA 경로에 속합니다. + +| 순서 | 단원 | 필수 선수 | 행동 기준 | +| --- | --- | --- | --- | +| 1 | [Maven으로 재현 가능한 주문 계산 빌드](../curriculum/professional/lessons/build-maven/lesson.json) | 코어:oop, 코어:testing-design | 정상 곱셈 / 0원 상품 / 잘못된 수량 거절 / 정수 오버플로 거절 | +| 2 | [JUnit·AssertJ로 경계와 상태 전이 검증](../curriculum/professional/lessons/test-contracts/lesson.json) | build-maven | 전량 예약 / 실패 후 상태 보존 / 생성 불변식 | +| 3 | [Mockito로 외부 협력의 실패를 통제](../curriculum/professional/lessons/test-collaborators/lesson.json) | test-contracts, 코어:polymorphism | 결제 한 번 호출 / 잘못된 입력에서 미호출 / 협력 오류 전파 | +| 4 | [Lombok 생성 코드와 동등성의 범위](../curriculum/professional/lessons/lombok-values/lesson.json) | test-contracts, 코어:collections | 같은 ID 동등성 / 가변 표시 값 제외 / HashSet 조회 유지 | +| 5 | [Spring IoC·DI와 객체 수명](../curriculum/professional/lessons/spring-di/lesson.json) | test-collaborators | 컨테이너 조립 / 정책 계약 / 기본 singleton 확인 | +| 6 | [프록시·AOP 호출 경로의 반례](../curriculum/professional/lessons/spring-aop/lesson.json) | spring-di | 외부 호출 advice / 자기 호출 우회 반례 | +| 7 | [MVC·REST의 상태 코드와 자원 계약](../curriculum/professional/lessons/mvc-rest/lesson.json) | spring-di | 기존 주문 조회 200 / 부재 주문 404 / 반복 조회 상태 보존 | +| 8 | [Jackson·Validation의 입력 경계](../curriculum/professional/lessons/json-validation/lesson.json) | mvc-rest | 정상 생성 201 / 0 수량·공백 sku 400 / 깨진 JSON·미지 필드 400 | +| 9 | [SQL·JDBC로 재고 변경과 롤백](../curriculum/professional/lessons/sql-jdbc/lesson.json) | test-contracts, 코어:exceptions | 성공 시 두 변경 커밋 / 재고 부족 상태 보존 / 중복 키 실패 재고 롤백 / 잘못된 입력 거절 | +| 10 | [MyBatis 파라미터 바인딩과 XML 매핑](../curriculum/professional/lessons/mybatis-mapping/lesson.json) | sql-jdbc | 명시적 열·속성 매핑 / 정확한 SKU 조회 / SQL 조각 입력을 값으로 취급 | +| 11 | [JPA·Hibernate의 상태와 변경 감지](../curriculum/professional/lessons/jpa-state/lesson.json) | sql-jdbc, lombok-values | 관리 엔티티 변경 감지 / 분리 객체 변경 비반영 / 실패 시 객체 수량 보존 | +| 12 | [Flyway로 스키마 변경을 기록](../curriculum/professional/lessons/flyway-migrations/lesson.json) | sql-jdbc | 두 버전 적용 / 재실행 시 0개 적용 / 중복 요청 키 거절 | +| 13 | [Spring 트랜잭션 프록시와 롤백 경계](../curriculum/professional/lessons/tx-proxy/lesson.json) | spring-aop, sql-jdbc | 외부 프록시 호출 롤백 / 자기 호출 반례 재현 / 호출 경로 설명 | +| 14 | [낙관적 잠금과 중복 취소의 의미](../curriculum/professional/lessons/optimistic-cancel/lesson.json) | jpa-state, tx-proxy | 취소 상태 전이 / 반복 취소 복원량 0 / 오래된 버전 커밋 거절 | +| 15 | [Spring Security의 인증·권한·CSRF](../curriculum/professional/lessons/security-csrf/lesson.json) | json-validation | 미인증 401 / 역할 부족 403 / CSRF 누락 403 / 허용된 요청 200 | +| 16 | [OAuth2·OIDC의 역할과 계정 식별 경계](../curriculum/professional/lessons/oidc-identity/lesson.json) | security-csrf | 미인증 401 / 신뢰한 issuer·subject 전달 / 비신뢰 issuer 403 / 이메일로 신원 병합하지 않음 | +| 17 | [JWT 서명·발급자·대상·만료 검증](../curriculum/professional/lessons/jwt-validation/lesson.json) | security-csrf | 정상 토큰 수용 / issuer·audience 불일치 거절 / 만료·미래 nbf 거절 / 다른 키 서명 거절 / 필수 claim 누락 거절 | +| 18 | [Redis TTL·무효화·키 소유권](../curriculum/professional/lessons/redis-cache/lesson.json) | tx-proxy | 문자열 수량 저장·조회 / 양수 TTL 설정 / 지정 키만 무효화 / 잘못된 캐시 입력 거절 | +| 19 | [Kafka 재전달과 DB 멱등 처리](../curriculum/professional/lessons/kafka-idempotency/lesson.json) | tx-proxy, flyway-migrations | 중복 이벤트 효과 한 번 / DB 실패 시 이벤트 기록 롤백 / 다른 payload 중복 ID 거절 / Kafka 전송 후 수동 오프셋 커밋 | +| 20 | [Micrometer 지표와 관측 실패 신호](../curriculum/professional/lessons/observability/lesson.json) | spring-di, test-collaborators | 성공·실패 계수 / 성공·실패 타이머 기록 / 예외 보존과 Observation 종료 / 주문 ID 태그 제외 | +| 21 | [Gradle·Docker·CI의 재현 조건](../curriculum/professional/lessons/build-gradle-ci/lesson.json) | build-maven, test-contracts | 동일 업무 합계 계약 / 잘못된 수량·오버플로 거절 / 두 빌드의 테스트 경로 설명 | +| 22 | [주문·예약·취소 통합 계약](../curriculum/professional/lessons/orders-capstone/lesson.json) | json-validation, tx-proxy, security-csrf, flyway-migrations | 입력·미인증 거절 / 동일 요청 멱등 처리 / payload 충돌과 재고 부족 롤백 / 다른 사용자 취소 거절 / 반복 취소 재고 한 번 복원 | + +tx-proxy는 mybatis-mapping 또는 jpa-state 경험을 권장하지만 실제 필수 선수는 Spring AOP와 JDBC입니다. orders-capstone은 JDBC·Spring 트랜잭션 경로로 구현되어 JPA 선택자에게 JPA 엔티티 구현을 강제하지 않습니다. 보안·캐시·메시징은 주문의 기능 경계와 실패 의미가 준비된 뒤 학습합니다. + +## 라이브러리를 배우는 깊이 + +- **JUnit·AssertJ·Mockito**: 상태와 반환값, 경계, 필수 협력 계약을 검증합니다. mock 호출 횟수만 통과했다고 업무 규칙이 완성됐다고 보지 않습니다. 테스트에서는 인터페이스 mock용 subclass mock maker를 명시합니다. +- **Lombok**: 줄 수 축소를 넘어 생성된 equals/hashCode·생성자·toString·setter의 실제 의미를 검토합니다. 가변 필드와 엔티티 연관관계에 모든 메서드를 기계적으로 생성하지 않습니다. +- **Spring IoC·DI·AOP**: 컨테이너별 수명, 공유 상태, 생성자 의존성, 외부 프록시 호출과 자기 호출을 구분합니다. SOLID를 클래스와 인터페이스 개수를 늘리는 규칙으로 적용하지 않습니다. +- **MVC·Jackson·Validation**: JSON 파싱·타입 변환·필드 검증·업무 불변식과 HTTP 상태의 책임을 구분합니다. 알 수 없는 필드 처리도 호환성 정책입니다. +- **SQL·JDBC·MyBatis·JPA·Flyway**: SQL 영향 행 수와 트랜잭션, 매핑, 영속 상태, 스키마 이력을 학습합니다. H2에서 통과한 결과를 PostgreSQL·MySQL·Oracle의 잠금·격리·DDL 호환성 검증으로 확대하지 않습니다. +- **Security·OAuth2·OIDC·JWT**: 인증과 URL 역할 권한, 자원 소유권, CSRF, issuer/subject 식별, 토큰 수용 정책을 분리합니다. 예제의 계정·키는 테스트 전용이며 실제 자격증명을 요구하지 않습니다. +- **Redis·Kafka**: 조회 캐시와 원본 DB, 만료와 일관성, 전달과 업무 효과를 구분합니다. Redis 분산 락이나 Kafka와 임의 DB의 자동 exactly-once를 구현했다고 표기하지 않습니다. +- **Micrometer·Gradle·Docker·CI**: 지표 태그 비용, 실패 신호, 검증한 커밋과 산출물의 연결을 학습합니다. 원격 exporter·운영 배포·이미지 보안·실제 Gradle 실행은 이 Maven 자동 검사에서 인증하지 않습니다. + +## 완료 조건과 설명 루브릭 + +컴파일 성공, 테스트 통과, 설계 숙련은 서로 다른 상태로 표시합니다. 실행 성공만으로 수업을 완료하지 않습니다. + +| 단계 | 확인할 증거 | 자동화 범위 | +| --- | --- | --- | +| 준비 | 컴파일 대상·실제 JVM·빌드 도구·의존성·자원 확인 | 설치형 환경 검사 | +| 실행 | 지정 파일이 컴파일되고 실제 프로세스가 종료됨 | 프로세스 결과 | +| 행동 | 정상·경계·실패 후 상태·권한 계약 검사를 통과 | 공개 ContractTest | +| 설명 | 실패 경계와 책임, 대안의 비용, 검증하지 못한 범위를 설명 | 학습자 기록 또는 별도 검토 | +| 통합 | 같은 업무 불변식이 HTTP·DB·보안 경계에서 유지됨 | capstone 검사와 설계 설명 | + +설명 루브릭은 각 항목을 미제시 / 일부 근거 / 반례와 대안까지 설명으로 구분합니다. (1) 객체의 책임·상태 불변식, (2) 실패 후 남는 상태와 트랜잭션 소유권, (3) 의존 관계와 테스트 가능성, (4) 구현 대안의 비용, (5) 실제 실행 증거의 범위가 대상입니다. 이 루브릭을 자동 객관식 점수로 환산해 설계 능력을 보증하지 않습니다. + +간단한 알고리즘 문제는 입력과 반환의 수학적 계약을 검사할 수 있지만 객체 설계는 협력·상태·실패·변경 비용까지 설명해야 합니다. capstone의 정답 구조를 그대로 복사한 경우도 대안 비교와 실패 설명이 남습니다. + +## 소스와 실행 프로필 계약 + +정식 원본은 [catalog.json](../curriculum/professional/catalog.json), [profiles.json](../curriculum/professional/profiles.json), 각 lesson.json입니다. catalog의 exerciseId는 수업 ID에 -project를 붙인 값이고 모든 파일 경로는 curriculum/professional 기준 상대경로입니다. + +```text +curriculum/professional/ + pom.xml 공통 고정 빌드와 BOM + profiles.json Java·의존성·자원 요구 + catalog.json 수업 순서·선수·허용 파일 + lessons// + lesson.json 교재·퀴즈·과제 메타데이터 + reference/ 공개 기준 구현과 읽기 전용 설정 + starter/ 학습자의 출발 파일 + grader/src/test/java/ 같은 계약을 검사하는 테스트 + grader/src/test/resources/ 필요한 고정 테스트 설정 +``` + +프로젝트 다운로드와 설치형 준비는 공통 parent pom.xml과 해당 단원의 reference 또는 starter 및 grader를 함께 보존해야 합니다. reference/pom.xml의 parent 경로는 ../../../pom.xml이고 테스트 경로는 ../grader/src/test/java입니다. Java 파일만 분리하면 이 빌드 계약이 유지되지 않습니다. + +lesson의 example.files는 여러 기준 파일을 보여 주며 professionalExercise.files는 path·templatePath·referencePath·editable을 제공합니다. testFiles는 path·sourcePath를 갖습니다. editableFiles에 없는 파일을 임의로 편집하는 기능은 이 계약에 없습니다. 출판용 웹 코드와 실제 설치형 파일 로더는 별도 구현 담당자가 통합합니다. + +프로필은 19종이며 compilerTarget=17, runtimeRequirement.minimumJava=17, recommendedJava=21을 분리합니다. Spring Boot BOM은 3.5.16으로 고정합니다. 실제 해석된 라이브러리 버전과 실제 JVM은 실행 증거에서 확인합니다. 공식 Boot 3.5 요구 사항은 Java 17 이상을 명시합니다. [Spring Boot 시스템 요구 사항](https://docs.spring.io/spring-boot/3.5/system-requirements.html) + +dependencyDownload=explicit-toolchain-setup-only는 설치형의 명시적 환경 준비 단계에만 의존성 다운로드가 필요하다는 계약입니다. reference 테스트를 실행해 필요한 의존성·테스트 공급자를 준비한 뒤 학습자 테스트는 offline Maven 경로를 사용합니다. offline Maven은 Java 프로그램의 네트워크·파일 접근을 OS 차원에서 차단하는 샌드박스가 아닙니다. profiles.execution.network는 required와 enforcement=not-os-sandboxed를 구분합니다. + +## 외부 자원과 검증 범위 + +| 단원/능력 | 준비된 범위 | 별도 검증 또는 미지원 상태 | +| --- | --- | --- | +| 일반 Java·JUnit·Spring/H2 | 고정 멀티파일 reference·starter·grader | 실제 JDK/Maven 실행 기록과 설치형 통합 승인 필요 | +| MVC·Security | MockMvc 요청·응답·보안 필터 검사 | 실제 소켓·TLS·역방향 프록시·운영 인증 저장소 | +| OIDC | 이미 인증된 테스트 principal의 issuer/subject 처리 | redirect·state·nonce·PKCE·code 교환·JWKS 등 전체 프로토콜 | +| JWT | 실행 중 생성한 테스트 RSA 키와 고정 Clock | 운영 키 보관·회전·폐기·원격 키 서버 | +| Redis | TTL·키별 무효화, 전용 loopback 인스턴스 계약 | Docker 설치·이미지 실행·공유 서비스 접근은 자동 보장하지 않음 | +| Kafka | 전용 토픽, 중복 payload 전송, DB 멱등 효과 | 실제 장애 복구·고부하·다중 파티션·운영 브로커 | +| Observability | 메모리 지표·Observation 생명주기 | exporter·대시보드·분산 추적 인프라 | +| Gradle·Docker·CI | 비교용 설정 파일과 설명 과제 | 해당 도구 실행·CI 도입·배포 검증 | + +Redis 환경변수는 JAVA_EDU_REDIS_HOST와 JAVA_EDU_REDIS_PORT이며 예시 포트는 127.0.0.1:16379입니다. Kafka는 JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:19092 형식의 단일 학습 브로커를 전제로 합니다. 실제 설치형이 자원을 지원하지 않으면 unsupported_resources로 알려야 하며 단위 테스트만 수행한 결과를 단원 통합 성공으로 바꾸지 않습니다. 외부 주소를 입력하는 범용 실행 서비스를 제공하지 않습니다. + +## 편집·출판 정책과 현재 상태 + +Java 언어·표준 라이브러리는 Oracle/OpenJDK 문서, 프레임워크·라이브러리는 해당 프로젝트 공식 문서를 기준으로 합니다. 버전이 있는 Spring 6.2·Security 6.5·Redis 3.5 문서를 사용하고, 변경 가능한 문서는 edition과 확인일을 기록합니다. checkedAt은 출처를 확인한 날짜이며 모든 예제의 실행 완료 일자가 아닙니다. + +jobTags는 수업 주제를 연결하는 태그입니다. 태그가 있다는 이유로 해당 기술이 실제 공고에서 조사되었다거나 요구 빈도가 높다고 주장하지 않습니다. 실제 공고 근거·조사 날짜·표본 한계는 별도의 채용 조사 자료와 연결합니다. + +현재 22개 수업·44개 개념 문항·22개 reference/starter/grader를 소스로 준비했습니다. 작성 담당자는 학습 Java/Maven 코드를 호스트에서 실행하거나 외부 실행기에 전송하지 않았습니다. 통합 담당자는 추가 서버가 없는20개 기준 프로젝트를 JDK21·Java17 컴파일 대상·Maven3.10.0에서 실제 실행해 JUnit54개 PASS와 미완성 starter 실패를 확인했습니다. 원본과 실행 복사본의 해시를 대조해 sourceFingerprint 및 executionVerified를20개에만 등록했습니다. Redis/Kafka 두 단원은 실제 서비스 연동 미검증이며 false를 유지합니다. 설치형의 실제 UI·출시 검증은 별도 보고서를 따릅니다. + +출판 게이트는 (1) 출처와 계약 검토, (2) JSON/경로/선수 관계 정합성, (3) 준비된 reference가 실제 테스트를 통과, (4) starter가 행동 기준을 아직 만족하지 않음, (5) 관련 잘못된 구현을 검사가 거절, (6) 설치형 파일 편집·실행·오류·중단과 웹의 실제 화면 검토입니다. 소스만 제공한 단계에서는 고정 교재와 다운로드를 표시하고 실제 실행 결과를 꾸며 내지 않습니다. diff --git a/docs/qa-report.md b/docs/qa-report.md new file mode 100644 index 0000000..0fe5df3 --- /dev/null +++ b/docs/qa-report.md @@ -0,0 +1,116 @@ +# 독립 QA 기록 + +기준일: 2026-10-12 (Asia/Seoul) + +**판정: PASS — 아래에 명시한 로컬 브라우저 QA 범위.** 최종 테스트 파일의 35개 시나리오는 전체 검사와 변경 영향에 따른 선택 재검사를 합쳐 모두 실행·통과했다. 최종 35개를 한 번의 명령으로 실행했다는 뜻은 아니다. 독립 시각 검수 담당자도 최종 합성 화면을 직접 확인한 뒤 검토 범위에 대해 PASS를 전달했다. + +QA 담당자는 `tests/browser.mjs`, 이 문서와 지정된 화면 증거만 작성했다. 앱·교육 데이터·API 구현은 수정하지 않았다. 대상은 다른 언어 경험자의 Java 전환 학습과 현직자 심화 학습이다. + +## 환경과 경계 + +- Windows, Node.js `v22.17.0`, Playwright `1.62.1`, 시스템 Edge `155.0.4283.45`, headless 실행. +- 부모 담당자가 소유한 개발 서버 `http://127.0.0.1:4173`. QA는 서버를 시작·변경하지 않았다. +- 각 시나리오는 별도 브라우저 컨텍스트를 사용했다. 실제 사용자 기록은 사용하거나 변경하지 않았다. +- `/api/runtime`과 `/api/execute`를 Playwright route로 대체했다. QA의 실제 외부 코드 실행 요청은 **0건**이다. 화면의 성공·오류 응답은 UI 검사용 데이터이며 실제 Java 채점 결과를 증명하지 않는다. +- 클립보드 성공·거부도 API 모의 구현으로 검사했다. 사용자 OS 클립보드는 변경하지 않았다. +- 320·360·390·768·1440 CSS px, 1280×600 및 844×390을 사용했다. 실제 휴대전화·태블릿 검사가 아니다. +- 글자 확대는 루트 글꼴 크기 200% 변경이다. 브라우저 메뉴 확대나 OS 디스플레이 배율을 검사한 것은 아니다. + +## 명령과 실제 결과 + +작업 디렉터리: `outputs/java-learning-lab`. + +```powershell +$env:PLAYWRIGHT_CHANNEL = 'msedge' +node tests/browser.mjs +``` + +기본 브라우저는 Chromium이다. `QA_FILTER`가 있으면 이름이 정규식과 일치하는 시나리오만 실행하며, 선택된 시나리오가 0개이면 실패한다. `QA_TRACE_SCREENSHOTS=1`은 캡처 시 초점·스크롤·활성 탭 경계를 출력한다. + +| 실행 | 실제 결과 | +| --- | --- | +| `node --check tests/browser.mjs` | 종료 코드 0 | +| 초기 서버 준비 확인 | sandbox에서는 EACCES, 상승 실행에서는 ECONNREFUSED. 브라우저 검사 시작 전 중단. 부모 담당자가 서버 실행 경계를 바로잡은 뒤 연결됨. | +| 전체 33개 시나리오 | 32 PASS, 1 FAIL. 빈 코드에 validation_error를 요구한 테스트 기대값 오류였다. 실제 계약인 입력 안내 toast·편집기 초점·요청 차단은 정상 동작했다. | +| `QA_FILTER='empty-source-and-utf8-payload-limit'` | 기대값을 계약에 맞춘 뒤 1 PASS, 0 FAIL, 종료 코드 0. 빈 코드와 UTF-8 32 KiB 초과 요청 모두 서버 전송 전 차단. | +| 첫 CSS 수정 후 선택 검사 | 11 PASS, 0 FAIL, 종료 코드 0 | +| 최종 탭·번호 수정 후 선택 검사 | 15 PASS, 0 FAIL, 종료 코드 0. 새 활성 탭 경계·모바일 긴 오류문 검사 2개 포함. | + +최종 선택 재검사 명령: + +```powershell +$env:PLAYWRIGHT_CHANNEL = 'msedge' +$env:QA_FILTER = 'lesson-steps|run-submit-and-two|execution-error-states|keyboard-tabs|mobile-drawer|responsive-|resize-orientation|initial-text|mobile-long' +$env:QA_TRACE_SCREENSHOTS = '1' +node tests/browser.mjs +``` + +## 검증 매트릭스 + +| 범위 | 실제 확인한 조건 | 결과 | +| --- | --- | --- | +| 수업·근거 | 12개 수업의 5개 단계, 실습 초기 코드, 공식 링크와 공고 표본 한계 안내 | PASS | +| 버전·트랙·검색 | Java 8/11/17/21 수업 집합, 공통 수업을 포함한 두 트랙, 제목 검색, 검색 결과 없음·복구, HTML 형태 검색어 | PASS | +| 링크·이동 | 잘못된 수업/단계/버전, 상위 버전 수업 직접 접근 차단, 뒤로·앞으로, 검색 실패 후 이어 학습하기 | PASS | +| 퀴즈 | 미응답·오답 미완료, 재시도, 탭 이동 중 선택 유지, 모두 정답인 경우 성취와 reload 후 유지 | PASS | +| 기록·초안 | 메모 작성만으로 성취를 만들지 않음, 수업별 코드 분리, 탭·수업 이동·즉시 reload 시 보존 | PASS | +| 초기화 | 코드 초기화와 undo, 전체 기록 초기화 취소·확인, 선택 버전 보존, 초기화 후 reload | PASS | +| 성취 정합성 | 예시 실행·일부 제출 실패는 미완료, 퀴즈와 제출 모두 통과해야 완료, 코드 변경 시 이전 통과와 현재 상태 분리 | PASS | +| 재제출 회귀 | 같은 코드의 통과 이후 failed/compile_error/runtime_error/timeout 제출 시 성취·완료 해제와 reload 후 유지 | PASS | +| 입력·응답 오류 | 빈 코드·UTF-8 크기 제한, 컴파일/런타임/타임아웃/제공자 오류, HTTP 400/413/429/502/503, 네트워크 실패, 비 JSON 응답, 실행 환경 복구 | PASS | +| 거짓 성공 방지 | 검사 0개, 통과 수치와 실패 항목의 모순, 다른 requestId 응답이 완료를 만들지 않음 | PASS | +| 비동기 경계 | 대기 중 편집·수업 이동·버전 변경·취소 시 낡은 응답 무효화, 중복 버튼 비활성화, 자동 재시도 없음 | PASS | +| 출력·복사 | HTML 형태 출력이 문자로 표시됨, 복사 성공 및 거부 시 직접 복사를 위한 선택·초점 | PASS | +| 저장소 제한 | 손상 JSON 및 읽기/쓰기 거부 시 수업·임시 코드·퀴즈 사용 가능, 저장 제한 안내 | PASS | +| 키보드·초점 | 입력 레이블, 탭 Arrow/Home/End, 라디오, 편집기 입력·Tab 이탈, dialog·모바일 목차 Escape와 초점 복귀 | PASS | +| 화면 조합 | 7개 크기에서 5개 단계, 페이지 가로 넘침 없음, 버튼 경계, 긴 코드·컴파일 진단의 박스 내부 스크롤 | PASS | +| 확대·리사이즈 | 입력 보존, 가로·세로 전환, 200% 글자, 확대 전/후 선택 모두 활성 탭의 상하좌우 경계 보장 | PASS | + +## 재현 문제와 수정 확인 + +| 문제·심각도 | 재현·실제 결과 | 기대·조치와 확인 | +| --- | --- | --- | +| 같은 코드의 새 제출 실패 이후 과거 성취 유지 — 코드 리뷰에서 제기 | 코드 리뷰 담당자의 요청으로 회귀 추가. QA는 수정 후 상태를 검사했다. | 최신 유효 제출 실패 시 성취·완료 해제. 4개 실패 상태 및 reload PASS. | +| 200% 버전 라벨 분절 — 시각 P2 | 고정 사이드바 안에서 select와 라벨이 경쟁하여 한 글자씩 분절됨. 독립 시각 검수에서 재현. | 버전 선택기를 단일 열로 수정. 최종 이미지에서 해결 확인. | +| 200% 로고 돌출 — 시각 P2 | 글자는 확대되나 배경은 고정 크기여서 밖으로 돌출됨. | 비례 크기와 헤더 줄바꿈 적용. 최종 이미지에서 해결 확인. | +| 실행 안내 가독성 — 시각 개선 항목 | 실행 환경·실행 전 결과 안내의 낮은 대비를 독립 시각 검수에서 지적. | 텍스트 색상·크기 수정. 최종 이미지에서 해결 확인. | +| 390px·200% 활성 탭 가림 — 시각 P2 | 코드 실습 선택 상태에서 확대·리사이즈하면 글자·밑줄이 내부 스크롤 영역 밖으로 가려짐. 페이지 가로 넘침 검사만으로 검출되지 않음. | 좁은 화면 탭 줄바꿈, 활성 탭 경계 검사 추가. 확대 전/후 선택 모두 PASS. 최종 탭 영역 x=36.19~353.81, 활성 코드 실습 x=169.17~328.22. | +| 320px 이론 번호 분절 — 시각 P3 | 절 번호 03이 분절됨. | 번호 축소·줄바꿈 방지. 최종 이미지에서 해결 확인. | + +기능·입력·화면 경계에서 남은 차단 결함은 없다. 시각 검수 담당자는 수정된 확대 탭 두 상태, 320px 번호, 모의 성공·오류 결과를 직접 재확인한 뒤 검토한 로컬 합성 화면에 PASS를 전달했다. + +## 화면 증거 + +로컬 작업 공간의 `work/java-edu-qa/`에 PNG를 저장했다. 이 경로는 개발 작업 공간 산출물이며 외부 호스팅 URL이 아니다. + +| 화면 | 주요 파일 | +| --- | --- | +| 데스크톱 학습 | desktop-theory.png, desktop-example.png, desktop-ide.png, desktop-practice-long-content.png | +| 모바일 학습 | mobile-quiz.png, mobile-practice.png, mobile-ide.png, mobile-drawer-360.png | +| 모바일·태블릿 경계 | viewport-320-theory.png, viewport-320-lab.png, viewport-360-theory.png, viewport-360-lab.png, viewport-768-theory.png, viewport-768-lab.png | +| 낮은 높이·조작부 | viewport-320x720-lab-controls.png, viewport-360x800-lab-controls.png, viewport-768x1024-lab-controls.png, viewport-1280x600-lab-controls.png, viewport-844x390-lab-controls.png | +| 데스크톱 확대 | desktop-text-200-percent.png, desktop-text-200-top.png, desktop-text-200-version.png | +| 모바일 확대 | mobile-text-200-percent.png, mobile-text-200-top.png, mobile-text-200-tabs.png, mobile-text-200-tabs-selected-before-enlargement.png, mobile-text-200-editor.png, mobile-text-200-controls.png, mobile-text-200-output.png, mobile-text-200-drawer.png | +| 모의 제출 결과 | desktop-submit-passed.png, desktop-submit-passed-result.png, mobile-compile-error.png, mobile-compile-error-result.png | + +초기 fullPage 캡처에서 화면 밖 고정 skip-link가 문서 중간에 포함되는 현상을 확인했다. 최종 fullPage는 스크롤을 맨 위로 이동해 촬영하고 위치를 복원한다. 실제 viewport와 DOM 측정에서 skip-link는 초점이 없었으며 100% y=-96~-48.125, 200% y=-192~-96.21875로 화면 밖이었다. 항상 노출되는 링크로 오인하지 않는다. + +## 검증 소스 식별 + +최종 화면 회귀 당시 SHA-256: + +| 파일 | SHA-256 | +| --- | --- | +| dist/app.js | C176883B44692D1EF89ECB1719B4040558348C9EEF5196A5E5FD422AD286566C | +| dist/styles.css | C2D433075AC2B66F60403AB4208E7C0AE46DC22320A2BE5E6E05CDFD0F6CC9B9 | +| dist/data/curriculum.js | CDB6607660F282C523A68771B3C9A86EAB9ABDB83909197BD2084E77EB98E73F | +| dist/data/exercises.js | 8F859F0641A3D8E59540DD0BD0EBF306ECE9A583B65437A2864D7E084551ACDE | +| tests/browser.mjs | A68CDD638669B8E6CB82953B9EBE5178128D48698E82BFCAD5D02376AC3EB166 | + +## 미검증·해석 제한 + +- 실제 Java 컴파일·실행, JDK별 언어/API 정합성, 외부 제공자 연결·제한·보안은 이 모의 API UI 검사 결과에 포함하지 않는다. 담당자의 별도 결과를 확인해야 한다. +- 실제 기기, iOS Safari·Firefox·CI Chromium, 스크린리더, 모바일 길게 누르기·IME 조합 입력은 실행하지 않았다. +- OS 클립보드 연동, 브라우저 메뉴 확대, 네트워크 단절 뒤 서비스 워커 기반 오프라인 재접속은 검사하지 않았다. +- 공식 문서·공고 링크의 화면 표시는 검사했지만 원문 사실 검증이나 현재 채용 상태 재조사는 별도 조사 결과의 영역이다. +- 테스트 완료는 학습자의 교육 효과 실측이나 설계 과제에 대한 전문가 승인이 아니다. \ No newline at end of file diff --git a/docs/readability-qa-report.md b/docs/readability-qa-report.md new file mode 100644 index 0000000..73f34a0 --- /dev/null +++ b/docs/readability-qa-report.md @@ -0,0 +1,108 @@ +# 교재 읽기 안내 독립 QA + +2026-10-13 KST. **웹 교재·레이아웃·기존 브라우저 기능 판정: PASS.** 최종 동결본에서 정적 5/5, 읽기 안내 24/24, 기존 기능 49/49, 기존 도식 19/19가 통과했다. 발견한 가로 잘림과 활성 탭 문제를 수정본에서 재검증했으며, 이 검사 범위에 남은 재현 결함은 없다. + +**새 설치 패키지 실행 판정: BLOCKED·미실행.** 총괄 preflight에서 기존 앱과 학습 데이터를 확인했다. 사용자가 기존 앱을 이용 중이라 학습 데이터 보존 조건을 충족할 수 없어 총괄이 이번 신규 설치·앱 실행 검사를 보류했다. 이는 기존 앱과 기록을 보존하기 위한 조치이며 사용자에게 새 승인을 요청하는 상태가 아니다. QA는 기존 앱·AppData를 실행·변경·이동·삭제하지 않았다. 아래 최소창 검사는 웹 브라우저의 viewport 에뮬레이션이다. + +## 수행 범위와 환경 + +목표는 Java 경력자가 34개 수업의 실제 상황·용어 정의·기존 코드·변수/값 추적·예측과 이유·실무 정리를 따라 읽도록 보완하고, 반복 정보를 정리해 본문 폭을 확보하는 것이다. README 소개 다음의 설치 절차와 직접 다운로드도 검증했다. + +실제 부모 소유 HTTP 프리뷰 `http://127.0.0.1:4173`에 접속했다. Windows의 Edge 155.0.4283.45, Node v22.17.0, Playwright 1.62.1을 사용했고 매 시나리오마다 별도 임시 브라우저 컨텍스트를 만들었다. 기본 샌드박스의 localhost/브라우저 접근 제한은 허용된 확장 권한으로 해결했다. + +외부 요청과 실행 API는 가로챘다. 읽기 안내의 성취 보존 검사에서 제출 1건은 명시적인 모의 응답이다. 기존 기능 검사도 실행 API를 모의 처리한다. 공식 링크는 키보드로 새 창을 여는 동작·정확한 URL·opener 분리를 검사하고 응답은 로컬에서 대신 제공했다. 원격 문서나 코드 실행 제공자에 학습 코드를 보내지 않았다. + +## 실제 검사와 결과 + +| 명령 | 최종 결과 | 근거 | +| --- | --- | --- | +| `node --test tests/readability.test.mjs` | 5/5 PASS | final-verified/static-readability.log | +| `node tests/readability.mjs` | 24/24 PASS | final-verified/results-1791860453905.json | +| `node work/readability-qa/run-baseline.mjs` | 49/49 PASS | final-verified/baseline/results.json | +| `node tests/theory-visuals.mjs` | 19/19 PASS | final-verified/theory/results-1791859939453.json | +| 담당 테스트 3개 `node --check` | PASS | readability.test.mjs, readability.mjs, bundled-desktop.mjs 구문 검사 | + +위 산출물 경로의 기준은 `work/readability-qa/`다. 브라우저 실행 환경변수는 `PLAYWRIGHT_CHANNEL=msedge`, `QA_BASE_URL=http://127.0.0.1:4173`이며, `QA_ARTIFACT_DIR`를 각 표의 디렉터리로 분리했다. 기존 기능 49개는 원본 tests/browser.mjs를 수정하지 않고 외부 요청 차단 wrapper로 실행했다. 기존 tests/theory-visuals.mjs도 수정 없이 실행했다. + +`final-verified/final-summary.json`은 결과 개수, 실행 환경, 소스 해시, 실제 본문 폭 및 설치형 미실행 상태를 모은 파일이다. 최종 24개 실행 전후의 모든 기록 대상 해시가 같고, 종료 후 현재 파일과도 다시 일치했다. + +| 요구사항·위험 | 실제 검증 | 판정 | +| --- | --- | --- | +| 34개 교재 안내 | 상황·질문·용어/정의·기존 코드·순서 추적·예측/답/이유·실무 정리 필수 구조, 34개 실제 DOM과 원본 데이터의 일치 | PASS | +| 기존 예제와의 연결 | 파일명이 가리키는 기존 example 원문의 연속 발췌인지 검사; 22개 작성 JSON과 배포 모듈의 안내 일치 | PASS | +| 성취·실행 계약 | 이전 예제 코드/출력, 퀴즈 ID/질문/선지/정답, 과제 ID/대상 버전·실행 필드·trusted checks 해시 보존 | PASS | +| 단계와 fallback | 34개 수업의 5단계, 안내가 없는 34개 수업, 잘못된 안내 값, 문법 모듈 장애, HTML처럼 생긴 텍스트 | PASS | +| 읽기 정보의 중복 | 학습 목표는 정보 영역에 1회만 존재; 수업 정보·출처·색상 안내는 기본 닫힘; 상세 이론과 12개 도식은 기본 노출·기존 순서 유지 | PASS | +| 접기·성취·입력 보존 | Enter/Space 반복, 초점 표시/복귀, 예상 답 열람의 성취 불변, 기존 퀴즈·모의 제출 성취·코어/파일별 초안·설계 메모·재로드 | PASS | +| 코드 표시와 조작 | 3개 테마의 실제 계산 색 대비 4.5:1 이상, 구문 강조, 키보드 가로 스크롤, 모의 복사와 권한 거절 시 전체 원문 선택 | PASS | +| 좁은 화면·확대 | 34수업 × 320/360/390/768/1440 CSS px × 100/200% 문서 글자. 안내·예상 답과 학습 정보/출처/있는 경우 색상 안내를 모두 펼쳐 경계 검사 | PASS | +| 도식 회귀 | 12도식 × 같은 5개 폭·2개 글자 크기, 원문/접근성 연결/공식 출처/afterSection 위치/오류 fallback | PASS | +| 최소창 | 704×521·689×521 각각 100/200%에서 목차 열기·Escape·초점 복귀, 5탭 방향키, 직접 hash, 정보 접기, 긴 코드 내부 스크롤, resize·재로드 후 선택 탭과 초안 유지 | PASS | +| 이동·작은 높이 | 390×844↔844×390, 1280×600, 펼친 답 유지·터치 에뮬레이션·실제 viewport 캡처 | PASS | +| 관련 기능 | 정상/오류/취소/반복/늦은 응답·저장 장애·초안/되돌리기·버전/검색/히스토리·편집기 회귀 | PASS | +| README 설치 접근 | 소개 바로 다음 설치, EXE/ZIP 직접 URL, 전체 압축 해제와 환경 준비 안내, 게시 메타데이터 대조 | PASS | + +34개 안내를 직접 읽어 입력값·분기·변경 후 상태·반환값과 예측을 대조했다. Redis/Kafka·Docker·OIDC의 코드 읽기와 실제 서비스 검증 범위를 구분했다. 이 검토는 실제 학습자의 이해도 개선을 측정한 연구가 아니다. + +이전 계약은 Git 기준 `b2dda6d4f2225e5f2d9b31a5287539bd1b0685b6`의 원문과 대조했다. 테스트에는 정규화한 계약 해시만 고정해 CI의 얕은 checkout에서도 실행할 수 있다. example/quiz의 설명 문구는 보존 대상에서 제외했다. + +## 본문 폭과 실제 합성 화면 + +| viewport | 코드·도식 실제 폭 | 상주 목차 폭 | +| --- | ---: | ---: | +| 1280px | 938.8px | 220px | +| 1440px | 1092.4px | 220px | +| 1920px | 1554px | 228px | + +1280→1250→1280 전환에서 코드 폭은 938.8→910→938.8px로 복원됐다. 1280×600 첫 진입에 실제 상황 문장이 노출됐다. 일반 문장과 코드·도식의 폭을 따로 확인했다. + +QA가 직접 본 최종 viewport에는 데스크톱 첫 화면, 320px·200% 상단, 390px·200% 예상 답/이유, guide→상세 이론→도식 경계, 펼친 목차, 라이트 테마와 선택, 강조 모듈 실패의 원문 코드, 최소창 실행 버튼이 포함된다. 200%에서는 세로 스크롤로 긴 내용을 읽으며 코드의 내부 가로 스크롤을 유지한다. + +대표 산출물: + +- `final-verified/reading-layout-1280-top.png`, `reading-layout-1440-top.png`, `reading-layout-1920-top.png` +- `final-verified/reading-layout-320-200-top.png` +- `final-verified/java-basics-1440-guide-to-theory-boundary.png`, `java-basics-1440-theory-to-visual-boundary.png` +- `final-verified/jpa-state-390-guide-to-theory-boundary.png`, `jpa-state-390-theory-to-visual-boundary.png` +- `final-verified/jpa-state-390-open-curriculum-drawer.png` +- `final-verified/java-basics-1440-expanded-lesson-info.png`, `java-basics-1440-expanded-references.png`, `java-basics-1440-expanded-syntax-legend.png` +- `final-verified/theory/*-desktop-figure.png`: 원래 도식 12종의 실제 요소 캡처 +- `final-verified/*-desktop-guide.png`: 안내 34개 전체 요소 캡처 + +별도 visual_director에게 위 캡처와 12개 도식을 전달했다. 전체 시각적 조화의 최종 승인은 그 독립 검수 결과를 따른다. 요소 전체 캡처와 실제 viewport 캡처를 구분하며, 순간적으로 나타나는 복사 토스트는 원래 UI 상태 그대로 기록했다. + +## 발견·수정·재검증 + +| 결함 | 재현 근거 | 최종 상태 | +| --- | --- | --- | +| P2: 기존 공고 카드 가로 넘침 | 최초 320px·200% exceptions에서 문서 337px/viewport 320px. 최초 results-1791856409669.json, overflow-exceptions-320-200.json | 34수업의 닫힘/펼침 행렬에서 재검증 PASS | +| P2: 새 정보 영역의 목표 잘림 | build-maven, 320/360/390px·200%. LI right 379.47px, 카드 clip으로 오른쪽 글자 손실. final/build-maven-320-200-visibility-probe.json, info-open-probe.png | 펼친 실제 요소·텍스트 경계를 포함해 재검증 PASS | +| P2: 최소창의 선택 탭 잘림 | 704/689×521·200%, ArrowRight로 lab 선택. 600ms 뒤도 tab right 764.14 > strip right 688.92, scrollLeft 0. minimum-probe/704-200-keyboard-tabs.json와 viewport.png | 키보드·직접 hash·resize·재로드에서 재검증 PASS | + +수정 확인 실행 `fix-verification/results-1791859832771.json`의 3/3 PASS 뒤 전체 최종 검사를 실행했다. 이전 실패 산출물은 삭제하지 않고 유지하며 최신 PASS 근거는 `final-verified/`로 분리했다. + +QA 조건도 근거에 맞춰 보정했다. 첫 문장의 높이가 28.5px인 경우 전체가 보여도 40px을 요구하던 조건을 수정했고, 닫힌 native details가 남기는 레이아웃 rect는 실제 가시성으로 제외했다. 초안은 기존 450ms 저장 지연이 끝나 저장값이 나타난 뒤 보존을 검사한다. 원문·성취·펼친 내용의 경계 조건은 유지했다. + +## README와 변경 파일 + +검사 시점의 README 직접 링크는 v0.3.0 EXE 362,799,820바이트와 ZIP 362,369,704바이트였다. 총괄이 확보한 `work/bundled-desktop-qa/release-published.json`의 릴리스 ID 410409363, uploaded 상태, URL·크기·SHA-256과 일치했다. 대용량 파일은 재다운로드하지 않았다. 대조 기록은 `work/readability-qa/readme-assets-audit.json`이다. 후속 v0.3.1 공개/다운로드 검증은 총괄의 새 릴리스 기록으로 관리한다. + +QA 변경 파일은 `tests/readability.test.mjs`, `tests/readability.mjs`, `tests/bundled-desktop.mjs`, 이 보고서와 `work/readability-qa/` 산출물이다. 교재·UI·README·실행기·Git·배포는 QA가 수정하지 않았다. + +추가 승인된 bundled-desktop 테스트에는 package.json의 버전 비교, 독립 산출물 디렉터리 환경변수, 실제 패키지의 34개 안내와 예상 답 열람 시 성취 불변 검사를 준비했다. 기존 사용자 데이터 부재를 요구하는 preflight를 보존했으며 실제 패키지 테스트는 실행하지 않았다. + +## 검증한 소스와 미확인 범위 + +| 파일 | SHA-256 | +| --- | --- | +| dist/index.html | 4300648bde32c51c134d4d0d9d12659b5149630995041c7b588cd121a4105269 | +| dist/app.js | 1f0d9746ee94a8d0a4e51f7c2b798e797026303bad179119680ea7d129258a0b | +| dist/styles.css | 8baf9bf900bdbcb2f8a7ab82fb0a17b1fada55dc7e94532c144c2ee3e911816d | +| dist/data/curriculum.js | e61c9547892d3df1b9709a3191699026f608b806e57be04d1b1cc88057fa1e20 | +| dist/data/professional.js | fdd4c80e335c941ea936afbc14f75be18ddcc15d3b72088cad9c35c5fa582ce0 | +| dist/data/lesson-visuals.js | 6a1a2085a55b9e41ca33f972052953adf4445fd7c49dd738f6b6ed4cc67cc442 | +| tests/readability.mjs | 06190fa7911ad97929c8cc1ab94a69fa79776bcb1b13c10a7eaa20c96e8d3b12 | + +물리 휴대전화, 실제 Windows 한글 IME, OS 클립보드, 화면 읽기 프로그램의 음성 출력, OS 배율·실제 브라우저 페이지 줌, 학습자의 이해도 개선은 미검증이다. 200%는 문서 기본 글자 확대이고 터치와 최소창은 브라우저 에뮬레이션이다. 기존 기능 검사의 한글 composition 이벤트 모의 검증은 실제 IME 검증과 구분한다. + +이번 웹 QA는 JVM 실행, Redis/Kafka 연동, 설치형 실행, 배포/다운로드 후 설치 검증을 대신하지 않는다. 새 설치 패키지는 기존 사용자 데이터 보호 때문에 **BLOCKED·미실행** 상태로 남긴다. 웹·정적 검사 범위의 최종 판정은 **PASS**다. diff --git a/docs/readability-release-qa-report.md b/docs/readability-release-qa-report.md new file mode 100644 index 0000000..4329d39 --- /dev/null +++ b/docs/readability-release-qa-report.md @@ -0,0 +1,47 @@ +# v0.3.1 교재·수업 화면과 패키지 검증 + +2026-10-13. 검증한 앱 소스는 `6ce0428a802be7a8a757a27a40853996a5b7fa04`다. 후속 QA·설치 안내·출시 기록 문서 커밋과 구분한다. 출시와 웹 배포 결과는 [배포 기록](deployment.md)에 기록한다. + +## 내용과 화면 + +34개 수업에 상황·질문·풀어쓴 용어·기존 원문 발췌·값과 상태의 변화·결과 예측·실무 판단을 추가했다. 공식 규칙을 과도하게 압축했던 설명을 보완했으며 명세의 보장과 예제가 선택한 정책을 구분했다. 예제·정답·버전·과제·실행 계약은 보존했다. + +반복된 큰 소개·통계·워크스페이스 안내를 정리하고 목차와 본문 2열로 바꿨다. 수업 정보·목표·공식 자료·색상 안내는 펼쳐 확인하며 기존 상세 이론과 12개 도식은 기본 표시한다. 실제 코드·도식 폭은 1280px에서938.8px, 1440px에서1092.4px, 1920px에서1554px다. + +## 실제 검사 + +| 검사 | 실제 결과·근거 | +| --- | --- | +| 내용·구문·계약 | Node40 PASS, 원격 제공자 실제 호출1 SKIP; `work/readability-final-check.log` | +| 독립 읽기 안내 QA | 정적5/5, 신규 브라우저24/24, 기존 기능49/49, 도식19/19 PASS; `work/readability-qa/final-verified/final-summary.json` | +| 독립 내용·코드 검토 | 34개 안내와 원문·보호 필드·화면 코드·마지막 수정의 해시 대조 PASS | +| 독립 시각 검수 | 실제 34가이드·12도식·대표 반응형·200% 글자·3테마·README 소개→설치 PASS | +| Windows 실행 정책 | 로컬23/23 PASS; `work/readability-local.log` | +| Worker | 최종 빌드와 인증·CSP·정적 경로·비공개 파일 제외 검사 PASS | +| 원격 CI | [38338837092](https://github.com/jbsim999/java-edu/actions/runs/38338837092): 검증 소스6ce0428의12작업 모두 성공 | +| 새 Windows 파일 | EXE·ZIP 빌드 완료, 실제 설치·앱 기동은 미실행 | + +초기 QA에서 320px·200%의 자료 카드 넘침, 이동한 학습 목표의 긴 식별자 잘림, 확대 시 상단 공백, 최소 창의 활성 탭 잘림을 발견해 수정했다. 탭은 목차·딥링크·새로고침·리사이즈·글자 확대에서도 확인했다. 최초 FAIL 기록을 최종 PASS로 덮어 해석하지 않는다. [독립 QA](readability-qa-report.md)에 재현·수정 후 검사·미확인 범위를 기록했다. + +## 새 패키지의 정적 대조 + +빌드 ASAR, ZIP 추출본, NSIS 내부 payload의 **197개 소스+24개 생성물**을 검증 소스와 비교했다. 포함 도구604개의 manifest·파일 목록·크기·해시를 검사했으며 bundle ID는 `548c060050b821dbcb99fd90e3f9e992e47e6ab3899d12551c9d7fb965581760`다. ASAR SHA256은 `b78441c83a849aa728145cecb8d993b8e2cf8afbfdc7de303ad6de205720b00f`다. + +ZIP은 전체678파일, NSIS payload는 전체679파일을 빌드본과 대조했다. 초기 집합 비교에서는 ZIP의 `resources/elevate.exe` 부재 때문에 실패했다. electron-builder26.15.3의 NSIS 단계가 해당 vendor helper를 나중에 복사하는 타깃 차이를 원본 코드와 실제 파일로 확인했다. ZIP에서 이 한 경로만 명시적으로 제외하고, 빌드본·NSIS payload·공식 NSIS3.0.4.1 원본의 helper 해시 `9b1fbf0c11c520ae714af8aa9af12cfd48503eedecd7398d8992ee94d1b4dc37`가 일치함을 확인했다. 다른 누락·추가 파일은 허용하지 않았다. 포함 도구와 제품 패키징 설정은 변경하지 않았다. + +추출 입력 EXE·ZIP의 해시를 추출 전후와 결과 대조에 연결했다. 7-Zip의 NSIS EXE 내부 ZIP 읽기는 후미 데이터 경고와 exit0을 반환하며, 이는 설치 실행 성공을 뜻하지 않는다. 증거는 `work/readable-desktop-qa/extract-*.json`, `package-*.json`, 추출 로그다. + +| 파일 | 바이트 | SHA256 | +| --- | ---: | --- | +| Java-Edu-Setup-0.3.1-x64.exe | 362890668 | `85b709c8c4efeeaed697bd5921b9f0135068e0f25be4de12f3efc983172ca1ad` | +| Java-Edu-0.3.1-x64.zip | 362460548 | `ed8ea284972cca006370ac379603400c8d623af9ebdc998220755d4627e5ddd3` | + +JDK·제3자 원본과 고지는 v0.3.0과 동일하다. 대응 소스 파일명과 기존의 유효한 대응 소스 URL을 보존한다. 실제 EXE 서명 검사 결과는 `NotSigned`다. + +## 검증의 경계 + +사용자가 Downloads의 v0.3.0 앱을 사용 중이고 AppData 학습 기록이 존재해 absence preflight를 통과하지 않았다. 이 앱을 종료하거나 데이터를 옮기거나 삭제하지 않았다. **새 v0.3.1의 실제 설치·앱 실행 검사는 BLOCKED·미실행**이며 정적 payload 비교와 브라우저 검증으로 구분한다. v0.3.0의 실제 설치/제거·앱10/10 결과를 신버전의 실제 실행 결과로 사용하지 않는다. + +브라우저 API는 모의 응답으로 격리했으며 실제 Java 제출 성공이 아니다. 200%는 문서 글자 확대, 모바일·터치는 브라우저 에뮬레이션이다. 실기기·스크린리더·OS IME/클립보드·실제 학습 효과·선택 Redis/Kafka 연동은 미검증이다. 넓어진 CSRF/JWT 도식의 마지막 행 빈 공간은 비차단 시각 개선 사항으로 남겼다. + +웹 배포 체크아웃에서는 소스 동기화 완료 전의 선행 빌드가 v0.3.0/35 PASS 로그를 반환하는 작업 순서 오류를 확인했다. 중간 결과를 폐기하고 전체274파일의 Git blob 일치를 확인한 뒤 같은 v0.3.1 소스로 다시 빌드·Worker 검사·Node40 PASS/1 SKIP를 확인했다. 배포에는 이 최종 소스와 산출물만 사용한다. diff --git a/docs/theory-visual-qa-report.md b/docs/theory-visual-qa-report.md new file mode 100644 index 0000000..fd15a41 --- /dev/null +++ b/docs/theory-visual-qa-report.md @@ -0,0 +1,86 @@ +# 이론 시각 자료 독립 QA + +2026-10-12 기능 판정: **PASS**. 새 도식 검사 19/19와 기존 브라우저 회귀 49/49가 실제 실행에서 통과했다. 미적 완성도와 설명의 교육 효과는 자동 검사 통과로 승인하지 않으며, 독립 시각·내용 검토의 별도 판정을 따른다. + +## 환경과 실제 실행 + +Windows, Node.js 22.17.0, Playwright 1.62.1, Edge 155.0.4283.45를 사용했다. 부모가 관리하는 `http://127.0.0.1:4173`의 동결본을 별도 브라우저 컨텍스트에서 검사했다. API 응답은 모의 처리했고, 공식 출처 링크 활성화도 새 탭의 요청을 가로채 로컬 응답으로 확인했다. 실제 외부 출처 방문 또는 코드 전송은 하지 않았다. + +공통 `boot()`의 선택 모듈 import와 이론 렌더링·스타일이 바뀌므로 기존 49개 회귀도 전체 재실행했다. 기존 테스트 파일은 이번 작업에서 수정하지 않았다. + +```powershell +$env:NODE_TLS_REJECT_UNAUTHORIZED = '1' +$env:NODE_OPTIONS = '--use-system-ca' +$env:PLAYWRIGHT_CHANNEL = 'msedge' +$env:QA_ARTIFACT_DIR = '/work/theory-independent-qa' +node tests/theory-visuals.mjs + +$env:QA_ARTIFACT_DIR = '/work/theory-independent-qa/baseline' +node tests/browser.mjs +``` + +- `node --check tests/theory-visuals.mjs`: PASS. +- `node tests/theory-visuals.mjs`: **19 PASS, 0 FAIL**, 종료 코드 0. +- `node tests/browser.mjs`: **49 PASS, 0 FAIL**, 종료 코드 0. 이번 실행은 49개 전체가 한 번의 실행에서 통과한 결과다. +- 이후 QA 파일의 기본 산출물 경로만 저장소 내부로 변경했다. `QA_ARTIFACT_DIR` 없이 `QA_FILTER=keyboard-description-and-reference-activation`으로 1개 시나리오를 재실행해 **1/1 PASS** 및 기본 위치의 JSON·화면 저장을 확인했다. 제품 코드는 변경되지 않았다. + +## 확인한 수용 조건 + +| 범위 | 실제 확인 결과 | +| --- | --- | +| 전체 수업 본문 | 34개 수업의 기존 이론 절 제목과 원문을 데이터와 정확히 대조. 도식이 없는 22개 수업에도 원문이 유지되고 불필요한 figure가 없음 | +| 선정된 12개 도식 | 수업 ID, 도식 ID·종류, 이론 절 다음 삽입 위치, 제목·caption·읽는 순서·텍스트 설명·한계가 일치 | +| 내부 관계 | 25개 패널, 80개 노드의 DOM 순서·label·detail, 54개 연결의 수·from/to·보이는 라벨·스크린리더용 양 끝 설명을 데이터와 대조. 데이터에 없는 연결이 추가되지 않음 | +| 출처 | 26개 출처 참조의 제목·HTTPS URL·판본·확인일이 일치. 새 탭과 `noopener noreferrer` 확인. 키보드와 새 탭 활성화는 모의 네트워크에서 확인 | +| 키보드 | `details`에 Enter/Space로 열기·닫기, Tab/Shift+Tab으로 설명과 첫 출처 링크 사이 이동, 실제 초점과 펼친 설명 확인 | +| 기존 상태 보존 | UI에서 퀴즈 통과·설계 노트·코어 초안·프로젝트 파일 초안·테마 설정 후 도식 수업 왕복, 버전·트랙·검색 변경, 새로고침 수행. `quizPassed`, `labPassed`, `drafts`, `fileDrafts`, `notes`가 동일하게 유지됨 | +| 실행·성취 분리 | 이론의 탐색과 자료 오류 상황에서 실행 API 요청 0건. 도식 열람으로 실습 완료가 생기지 않음 | +| 선택 자료 로딩 실패 | 도식 모듈 404, 모듈 구문 오류, 배열 형태의 잘못된 export 각각에서 선정된 12개 수업의 기존 본문과 다음 예제 단계 유지 | +| 개별 잘못된 자료 | 중복 노드 ID, 없는 도착 노드, 알 수 없는 kind, `javascript:` 출처를 주입. 해당 도식만 빠지고 정상 도식·본문 유지 | +| 안전한 텍스트 처리 | 제목과 노드에 ``·`'; + api.respond = request => ({ body: executionResult(request, { status: 'runtime_error', stdout: payload, stderr: payload, compilerOutput: payload, checks: [], passed: 0, total: 0 }) }); + await page.locator('#run-code').click(); + await waitForOutput(page, 'runtime_error'); + assert.ok((await page.locator('#execution-output').innerText()).includes(payload), 'provider output appears literally'); + assert.equal(await page.locator('#execution-output img, #execution-output script').count(), 0); + assert.equal(await page.evaluate(() => window.qaOutputExecuted), undefined); + assert.equal(await achievement(page, 'lab'), 'false'); + }); + + await run(browser, 'new-failing-submit-revokes-the-same-source-achievement', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first); + await completeQuiz(page, first); + await stepTo(page, 'lab'); + const source = await readEditorSource(page); + for (const status of ['failed', 'compile_error', 'runtime_error', 'timeout']) { + api.respond = request => ({ body: executionResult(request) }); + await page.locator('#submit-code').click(); + await waitForOutput(page, 'passed'); + assert.equal(await achievement(page, 'lab'), 'true'); + assert.equal(await page.locator('#overall-completed').textContent(), '1'); + api.respond = request => ({ body: executionResult(request, { status, checks: [{ name: '재검증 실패', passed: false }], passed: 0, total: 1 }) }); + await page.locator('#submit-code').click(); + await waitForOutput(page, status); + assert.equal(await readEditorSource(page), source, 'same source is re-evaluated'); + assert.equal(await achievement(page, 'lab'), 'false', `${status} revokes current code achievement`); + assert.equal(await achievement(page, 'quiz'), 'true'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + } + await page.reload(); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await achievement(page, 'lab'), 'false', 'revocation survives reload'); + }); + + await run(browser, 'mobile-long-diagnostic-output-stays-internal', async ({ context, page }) => { + const diagnostic = `Solution.java:3: error: cannot find symbol\n${'long_identifier_'.repeat(120)}\n location: class Solution\n1 error`; + await mockApi(context, { respond: request => ({ body: executionResult(request, { status: 'compile_error', stdout: '', stderr: '', compilerOutput: diagnostic, checks: [], passed: 0, total: 0 }) }) }); + await openLesson(page, first, 'lab'); + await page.locator('#submit-code').click(); + await waitForOutput(page, 'compile_error'); + await noViewportOverflow(page); + const output = page.locator('#execution-output pre'); + const scroll = await output.evaluate(element => { + const width = element.clientWidth; + element.scrollLeft = 120; + const result = { width, scrollWidth: element.scrollWidth, left: element.scrollLeft }; + element.scrollLeft = 0; + return result; + }); + assert.ok(scroll.scrollWidth > scroll.width && scroll.left > 0, 'long compiler diagnostics have their own horizontal scroll'); + assert.equal(await achievement(page, 'lab'), 'false'); + await screenshot(page, 'mobile-compile-error'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await screenshot(page, 'mobile-compile-error-result', false); + }, { viewport: { width: 390, height: 844 } }); + + await run(browser, 'empty-source-and-utf8-payload-limit', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + await editorInput(page).fill(''); + await page.locator('#run-code').click(); + assert.match(await page.locator('#toast').textContent(), /코드를 입력/); + assert.equal(await editorInput(page).evaluate(element => document.activeElement === element), true, 'empty source returns focus to editor'); + assert.equal(api.requests.length, 0, 'empty source is rejected before network submission'); + await editorInput(page).fill(`class Solution { /* ${'한'.repeat(12_000)} */ }`); + await page.locator('#run-code').click(); + await waitForOutput(page, 'validation_error'); + assert.equal(api.requests.length, 0, 'oversized UTF-8 source is rejected before network submission'); + assert.equal(await achievement(page, 'lab'), 'false'); + }); + + await run(browser, 'http-network-and-invalid-json-errors', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + for (const status of [400, 413, 429, 502, 503]) { + const message = `QA provider refusal ${status}`; + api.respond = request => ({ status, body: { requestId: request.requestId, error: { code: `QA_${status}`, message } } }); + await page.locator('#submit-code').click(); + await expectEventually(() => page.locator('#execution-output').innerText(), value => value.includes(message), `HTTP ${status} error is explained`); + assert.notEqual(await page.locator('#execution-output').getAttribute('data-status'), 'passed'); + assert.equal(await achievement(page, 'lab'), 'false'); + } + for (const response of [{ abort: true }, { raw: '{ not-json' }]) { + const before = api.requests.length; + api.respond = () => response; + await page.locator('#submit-code').click(); + await expectEventually(() => api.requests.length, before + 1, 'failed request was attempted once'); + await expectEventually(() => page.locator('#submit-code').isEnabled(), true, 'execution controls recover after response failure'); + assert.notEqual(await page.locator('#execution-output').getAttribute('data-status'), 'passed'); + assert.equal(await achievement(page, 'lab'), 'false'); + } + }); + + await run(browser, 'unavailable-runtime-keeps-learning-and-draft-usable', async ({ context, page }) => { + const api = await mockApi(context, { runtime: { available: false, supportedTargets: [], notice: 'QA runtime unavailable' } }); + await openLesson(page, first, 'lab'); + await page.locator('#runtime-retry').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + const draft = `${firstExercise.template}\n// offline draft`; + await editorInput(page).fill(draft); + await stepTo(page, 'theory'); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), draft); + api.runtime = {}; + await page.locator('#runtime-retry').click(); + await expectEventually(() => page.locator('#run-code').isEnabled(), true, 'runtime retry restores execution capability'); + assert.equal(await readEditorSource(page), draft); + assert.equal(api.requests.length, 0, 'availability checks do not send the source'); + }); + + await run(browser, 'clipboard-success-and-denied-fallback', async ({ context, page }) => { + await mockApi(context); + // Mock only the clipboard API to avoid overwriting the user's OS clipboard. + await context.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { + writeText: async text => { + if (window.qaClipboardDenied) throw new DOMException('QA denied', 'NotAllowedError'); + window.qaClipboardText = text; + }, + } }); + }); + await openLesson(page, first, 'example'); + await page.locator('#copy-example').click(); + assert.equal(await page.evaluate(() => window.qaClipboardText), first.example.code); + await page.evaluate(() => { window.qaClipboardDenied = true; }); + await page.locator('#copy-example').click(); + assert.equal(await page.evaluate(() => window.getSelection().toString()), first.example.code, 'copy denial selects example text for manual copying'); + await stepTo(page, 'lab'); + await page.locator('#copy-code').click(); + const selection = await editorInput(page).evaluate(element => ({ text: window.getSelection().toString(), focused: document.activeElement === element })); + assert.equal(selection.text, firstExercise.template, 'copy denial selects the current document for manual copying'); + assert.equal(selection.focused, true); + assert.match(await page.locator('#toast').textContent(), /직접 복사/); + }); + + await run(browser, 'inconsistent-success-response-does-not-complete', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + for (const overrides of [ + { passed: 0, total: 0, checks: [] }, + { passed: 2, total: 2, checks: [{ name: '정상 입력', passed: true }, { name: '경계 입력', passed: false }] }, + { requestId: 'unrelated-response-id' }, + ]) { + const before = api.requests.length; + api.respond = request => ({ body: executionResult(request, overrides) }); + await page.locator('#submit-code').click(); + await expectEventually(() => api.requests.length, before + 1, 'submit request reaches mock'); + await expectEventually(() => page.locator('#submit-code').isEnabled(), true, 'invalid success settles'); + assert.equal(await achievement(page, 'lab'), 'false', `inconsistent response is not proof of success: ${JSON.stringify(overrides)}`); + } + }); + + for (const interaction of ['edit', 'lesson', 'version', 'cancel']) { + await run(browser, `pending-response-${interaction}-cannot-grant-achievement`, async ({ context, page }) => { + const gate = deferred(); + context.once('close', () => gate.resolve()); + const api = await mockApi(context, { respond: async request => { await gate.promise; return { body: executionResult(request, { stdout: 'old-response-proof' }) }; } }); + await openLesson(page, first, 'lab'); + await page.locator('#submit-code').click(); + await expectEventually(() => api.requests.length, 1, 'submission is pending'); + assert.equal(await page.locator('#run-code').isDisabled(), true, 'duplicate execution is blocked'); + assert.equal(await page.locator('#submit-code').isDisabled(), true, 'duplicate submission is blocked'); + const edited = `${firstExercise.template}\n// edited during execution`; + if (interaction === 'edit') await editorInput(page).fill(edited); + if (interaction === 'lesson') { + await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); + await stepTo(page, 'lab'); + } + if (interaction === 'version') await page.locator('#version-select').selectOption('11'); + if (interaction === 'cancel') await page.locator('#cancel-execution').click(); + gate.resolve(); + await expectEventually(() => api.settled.length, 1, 'delayed mock response has settled'); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.equal(await achievement(page, 'lab'), 'false', 'obsolete or cancelled response grants no achievement'); + assert.notEqual(await page.locator('#execution-output').getAttribute('data-status'), 'passed'); + if (interaction === 'edit') { + assert.equal(await readEditorSource(page), edited); + assert.equal(await page.locator('#execution-output').getAttribute('data-status'), 'outdated'); + } + if (interaction === 'lesson') { + assert.ok((await page.locator('#lesson-title').textContent()).includes(other.title)); + await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).click(); + assert.equal(await achievement(page, 'lab'), 'false', 'abandoned lesson was not silently passed'); + } + assert.equal(api.requests.length, 1, 'no automatic retry'); + }); + } + + await run(browser, 'corrupt-storage-falls-back-without-completion', async ({ context, page }) => { + await mockApi(context); + await context.addInitScript(({ key }) => localStorage.setItem(key, '{ invalid-json'), { key: storageKey }); + await openLesson(page, first, 'lab'); + assert.equal(await achievement(page, 'quiz'), 'false'); + assert.equal(await achievement(page, 'lab'), 'false'); + assert.equal(await readEditorSource(page), firstExercise.template); + await stepTo(page, 'theory'); + await noViewportOverflow(page); + }); + + await run(browser, 'blocked-storage-preserves-in-memory-interaction', async ({ context, page }) => { + await mockApi(context); + await context.addInitScript(() => { + Storage.prototype.getItem = function () { throw new DOMException('QA storage denied', 'SecurityError'); }; + Storage.prototype.setItem = function () { throw new DOMException('QA storage denied', 'SecurityError'); }; + }); + await openLesson(page, first, 'lab'); + const source = `${firstExercise.template}\n// in-memory QA draft`; + await editorInput(page).fill(source); + await stepTo(page, 'theory'); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), source); + const storageLabel = await page.locator('#storage-label').textContent(); + assert.ok(storageLabel.trim().length > 0 && storageLabel !== '이 브라우저에 저장', 'storage limitation is disclosed'); + await completeQuiz(page, first); + assert.equal(await achievement(page, 'quiz'), 'true', 'in-memory quiz remains usable'); + }); + + await run(browser, 'reset-records-cancel-and-confirm', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const draft = `${firstExercise.template}\n// record-reset QA`; + await editorInput(page).fill(draft); + await completeQuiz(page, first); + await stepTo(page, 'lab'); + await page.locator('#version-select').selectOption('11'); + const before = await storedProgress(page); + await page.locator('#reset-progress-open').click(); + await page.locator('#reset-dialog').waitFor({ state: 'visible' }); + await page.locator('#reset-dialog [data-close-dialog="reset-dialog"]').click(); + assert.deepEqual(await storedProgress(page), before, 'cancel leaves all records intact'); + assert.equal(await readEditorSource(page), draft); + await page.locator('#reset-progress-open').click(); + await page.locator('#confirm-reset-progress').click(); + assert.equal(await achievement(page, 'quiz'), 'false'); + assert.equal(await achievement(page, 'lab'), 'false'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + assert.equal(await page.locator('#version-select').inputValue(), '11', 'record reset retains selected version'); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), firstExercise.template); + await page.reload(); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.equal(await achievement(page, 'quiz'), 'false'); + }); + + await run(browser, 'keyboard-tabs-radio-editor-and-dialog-focus', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + for (const selector of ['#version-select', '#lesson-search']) { + assert.equal(await page.locator(selector).evaluate(element => Boolean(element.labels?.length || element.getAttribute('aria-label') || element.getAttribute('aria-labelledby'))), true, `${selector} has an accessible label`); + } + await page.locator('#tab-theory').focus(); + await page.keyboard.press('ArrowRight'); + await expectEventually(() => page.locator('#tab-example').getAttribute('aria-selected'), 'true', 'right arrow changes the selected tab'); + assert.equal(await page.locator('#tab-example').evaluate(element => document.activeElement === element), true); + await page.keyboard.press('End'); + await expectEventually(() => page.locator('#tab-lab').getAttribute('aria-selected'), 'true', 'End selects the last tab'); + await page.keyboard.press('Home'); + await expectEventually(() => page.locator('#tab-theory').getAttribute('aria-selected'), 'true', 'Home selects the first tab'); + await stepTo(page, 'quiz'); + const group = page.locator(`#quiz-form input[name="quiz-${first.quiz[0].id}"]`); + await group.first().focus(); + await page.keyboard.press('Space'); + await page.keyboard.press('ArrowRight'); + assert.equal(await group.nth(1).isChecked(), true, 'native radio keyboard selection works'); + await stepTo(page, 'lab'); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.insertText('\n// 키보드 입력 QA'); + assert.ok((await readEditorSource(page)).endsWith('// 키보드 입력 QA')); + await page.keyboard.press('Tab'); + assert.equal(await editorInput(page).evaluate(element => element.contains(document.activeElement)), false, 'Tab leaves the code editor instead of trapping keyboard users'); + await page.locator('#guide-open').click(); + await page.locator('#guide-dialog').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#guide-dialog').evaluate(element => element.contains(document.activeElement)), true, 'dialog receives focus'); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#guide-dialog').isVisible(), false); + assert.equal(await page.locator('#guide-open').evaluate(element => document.activeElement === element), true, 'closing dialog restores opener focus'); + }); + + await run(browser, 'mobile-drawer-keyboard-and-lesson-navigation', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + await page.locator('#mobile-menu-toggle').click(); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'true'); + assert.equal(await page.locator('#curriculum-sidebar').evaluate(element => element.contains(document.activeElement)), true, 'drawer receives focus'); + await screenshot(page, 'mobile-drawer-360', false); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'false'); + assert.equal(await page.locator('#mobile-menu-toggle').evaluate(element => document.activeElement === element), true, 'drawer closure restores focus'); + await page.locator('#mobile-menu-toggle').click(); + await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); + await expectEventually(() => page.locator('#lesson-title').textContent(), title => title.includes(other.title), 'drawer lesson navigation'); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'false'); + await noViewportOverflow(page); + }, { viewport: { width: 360, height: 800 }, isMobile: true, hasTouch: true }); + + for (const viewport of [ + { width: 320, height: 720 }, + { width: 360, height: 800 }, + { width: 390, height: 844 }, + { width: 768, height: 1024 }, + { width: 1440, height: 1000 }, + { width: 1280, height: 600 }, + { width: 844, height: 390 }, + ]) { + await run(browser, `responsive-${viewport.width}x${viewport.height}`, async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + for (const step of ['theory', 'example', 'quiz', 'practice', 'lab']) { + await stepTo(page, step); + await noViewportOverflow(page); + if (viewport.width === 390 && ['quiz', 'practice', 'lab'].includes(step)) await screenshot(page, `mobile-${step === 'lab' ? 'ide' : step}`); + if ([320, 360, 768].includes(viewport.width) && ['theory', 'lab'].includes(step)) await screenshot(page, `viewport-${viewport.width}-${step}`); + } + for (const id of ['run-code', 'submit-code', 'lab-reset']) { + const rectangle = await page.locator(`#${id}`).boundingBox(); + assert.ok(rectangle && rectangle.width > 0 && rectangle.x >= -1 && rectangle.x + rectangle.width <= viewport.width + 1, `${id} stays inside viewport`); + } + if ([320, 360, 768, 1280, 844].includes(viewport.width)) { + await page.locator('#run-code').scrollIntoViewIfNeeded(); + await screenshot(page, `viewport-${viewport.width}x${viewport.height}-lab-controls`, false); + } + await editorInput(page).fill(`${firstExercise.template}\n// ${'long_identifier_'.repeat(80)}`); + await noViewportOverflow(page); + const internalScroll = await editorScroller(page).evaluate(element => { + element.scrollLeft = 150; + return { width: element.clientWidth, scrollWidth: element.scrollWidth, left: element.scrollLeft }; + }); + assert.ok(internalScroll.scrollWidth > internalScroll.width && internalScroll.left > 0, 'long code scrolls inside editor'); + }, { viewport }); + } + + await run(browser, 'resize-orientation-and-two-hundred-percent-text', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const draft = `${firstExercise.template}\n// resize persists input`; + await editorInput(page).fill(draft); + for (const viewport of [{ width: 390, height: 844 }, { width: 844, height: 390 }, { width: 390, height: 844 }, { width: 1440, height: 1000 }]) { + await page.setViewportSize(viewport); + assert.equal(await readEditorSource(page), draft); + await noViewportOverflow(page); + await activeTabVisible(page); + } + const before = await page.locator('html').evaluate(element => parseFloat(getComputedStyle(element).fontSize)); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + const after = await page.locator('html').evaluate(element => parseFloat(getComputedStyle(element).fontSize)); + assert.ok(after >= before * 1.99, 'root text size is doubled'); + for (const step of ['theory', 'example', 'quiz', 'practice', 'lab']) { + await stepTo(page, step); + await noViewportOverflow(page); + } + await screenshot(page, 'desktop-text-200-percent'); + await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); + await screenshot(page, 'desktop-text-200-top', false); + await page.locator('#version-select').scrollIntoViewIfNeeded(); + await screenshot(page, 'desktop-text-200-version', false); + await page.setViewportSize({ width: 390, height: 844 }); + await noViewportOverflow(page); + await activeTabVisible(page); + await screenshot(page, 'mobile-text-200-percent'); + await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); + await screenshot(page, 'mobile-text-200-top', false); + for (const [selector, label] of [['.lesson-tabs', 'tabs'], ['#code-editor', 'editor'], ['#run-code', 'controls'], ['#execution-output', 'output']]) { + await page.locator(selector).scrollIntoViewIfNeeded(); + await screenshot(page, `mobile-text-200-${label}`, false); + } + await page.locator('#mobile-menu-toggle').click(); + await screenshot(page, 'mobile-text-200-drawer', false); + await page.keyboard.press('Escape'); + assert.equal(await readEditorSource(page), draft); + }); + + await run(browser, 'initial-text-enlargement-keeps-mobile-tabs-visible', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + for (const step of ['theory', 'example', 'quiz', 'practice', 'lab']) { + await stepTo(page, step); + await activeTabVisible(page); + await noViewportOverflow(page); + } + await page.locator('html').evaluate(element => { element.style.fontSize = '100%'; }); + await activeTabVisible(page); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await activeTabVisible(page); + await page.locator('.lesson-tabs').scrollIntoViewIfNeeded(); + await screenshot(page, 'mobile-text-200-tabs-selected-before-enlargement', false); + }, { viewport: { width: 390, height: 844 } }); + await run(browser, 'editor-three-themes-and-syntax-colors', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'example'); + const palettes = {}; + for (const theme of ['dark', 'light', 'contrast']) { + await chooseTheme(page, theme); + assert.equal(await page.locator('.code-block code').textContent(), first.example.code, 'syntax highlighting preserves the exact example'); + const colors = await tokenPalette(page, '.code-block'); + for (const category of ['keyword', 'class', 'type', 'variable', 'method', 'string', 'number']) assert.ok(colors[category], `${theme} example has ${category} tokens`); + assert.equal(new Set(['keyword', 'class', 'type', 'variable'].map(category => colors[category])).size, 4, 'keyword, class, type, and variable colors are distinct'); + palettes[theme] = colors; + await page.locator('.code-shell').scrollIntoViewIfNeeded(); + await screenshot(page, `editor-${theme}-example`, false); + await noViewportOverflow(page); + } + const source = 'class Palette {\n String label = "한글";\n int count = 7;\n // \n void run() { count++; }\n}\n'; + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').getAttribute('data-editor-engine'), 'codemirror'); + assert.equal(await page.locator('#code-editor textarea').count(), 0, 'normal editor has no hidden textarea proxy'); + assert.equal(await editorInput(page).getAttribute('contenteditable'), 'true'); + assert.equal(await editorInput(page).getAttribute('aria-multiline'), 'true'); + await editorInput(page).fill(source); + for (const theme of ['dark', 'light', 'contrast']) { + await chooseTheme(page, theme); + const colors = await tokenPalette(page, '#code-editor'); + for (const category of ['keyword', 'class', 'type', 'variable', 'method', 'string', 'comment', 'number']) assert.ok(colors[category], `${theme} editor has ${category} tokens`); + for (const category of ['keyword', 'class', 'type', 'variable']) assert.equal(colors[category], palettes[theme][category], 'example and editor use the same category palette'); + await page.locator('.editor-shell').scrollIntoViewIfNeeded(); + await screenshot(page, `editor-${theme}-ide`, false); + } + assert.notEqual(palettes.dark.class, palettes.light.class, 'theme switching changes the computed colors'); + assert.notEqual(palettes.dark.class, palettes.contrast.class); + assert.equal(await page.locator('#code-editor img').count(), 0, 'code-looking HTML is rendered as text'); + assert.equal(await page.evaluate(() => window.qaCodeExecuted), undefined); + assert.equal(await readEditorSource(page), source); + assert.equal(api.requests.length, 0, 'color changes do not submit source'); + }); + + await run(browser, 'editor-theme-wrap-persist-across-navigation-and-reload', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const source = `${firstExercise.template}\n// 테마와 이동 후에도 보존\n\n`; + await editorInput(page).fill(source); + await chooseTheme(page, 'light'); + await page.locator('#code-wrap-toggle').click(); + assert.equal(await page.locator('#code-wrap-toggle').getAttribute('aria-pressed'), 'true'); + assert.deepEqual(await page.evaluate(key => JSON.parse(localStorage.getItem(key)), editorPreferencesKey), { theme: 'light', wrap: true }); + await page.locator('#lesson-search').fill('no-result-theme-regression'); + await page.locator('#lesson-search').fill(''); + await page.locator('[data-track="transition"]').click(); + await page.locator('[data-track="all"]').click(); + await page.locator('#version-select').selectOption('11'); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), source); + await stepTo(page, 'example'); + assert.equal(await page.locator('.code-shell').getAttribute('data-editor-theme'), 'light'); + await page.locator(`#lesson-list [data-lesson-id="${other.id}"]`).click(); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'light'); + assert.equal(await page.locator('#code-wrap-toggle').getAttribute('aria-pressed'), 'true'); + await page.locator(`#lesson-list [data-lesson-id="${first.id}"]`).click(); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), source); + await page.reload(); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'light'); + assert.equal(await page.locator('#code-wrap-toggle').getAttribute('aria-pressed'), 'true'); + assert.equal(await readEditorSource(page), source); + assert.equal(api.requests.length, 0); + }); + + await run(browser, 'editor-theme-preserves-selection-and-keyboard-undo', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const source = 'class Palette { int count = 7; }'; + await editorInput(page).fill(source); + const editorNode = await editorInput(page).elementHandle(); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+Home'); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + assert.equal(await page.evaluate(() => getSelection().toString()), 'class'); + await chooseTheme(page, 'light'); + await chooseTheme(page, 'contrast'); + assert.equal(await editorNode.evaluate(element => element.isConnected), true, 'theme reconfiguration keeps the input DOM instance'); + await editorInput(page).focus(); + assert.equal(await page.evaluate(() => getSelection().toString()), 'class', 'theme changes preserve the selected source range'); + await page.keyboard.insertText('interface'); + const replaced = source.replace('class', 'interface'); + assert.equal(await readEditorSource(page), replaced); + await chooseTheme(page, 'dark'); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+z'); + assert.equal(await readEditorSource(page), source, 'undo survives theme changes'); + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Shift+z' : 'Control+y'); + assert.equal(await readEditorSource(page), replaced, 'redo restores the typed replacement'); + await editorNode.dispose(); + }); + + await run(browser, 'editor-enter-indent-tab-and-reset-undo', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + await editorInput(page).fill('class Demo {'); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.press('Enter'); + await page.keyboard.insertText('int number = 1;'); + const indented = 'class Demo {\n int number = 1;'; + assert.equal(await readEditorSource(page), indented, 'Enter applies a four-space Java block indentation'); + await page.keyboard.press('Tab'); + assert.equal(await editorInput(page).evaluate(element => element.contains(document.activeElement)), false, 'Tab is navigation'); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+End'); + await page.locator('#indent-code').click(); + const moreIndented = 'class Demo {\n int number = 1;'; + assert.equal(await readEditorSource(page), moreIndented, 'indent toolbar adds four spaces to the selected line'); + await page.keyboard.press('ControlOrMeta+z'); + assert.equal(await readEditorSource(page), indented, 'indent action is undoable'); + await chooseTheme(page, 'light'); + await page.locator('#lab-reset').click(); + assert.equal(await readEditorSource(page), firstExercise.template); + await page.locator('#lab-undo').click(); + assert.equal(await readEditorSource(page), indented, 'public reset undo restores the entire previous source'); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'light'); + }); + + await run(browser, 'editor-mouse-selection-and-simulated-copy-event', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const source = 'class MouseSelection { int count = 7; }'; + await editorInput(page).fill(source); + const token = page.locator('#code-editor .tok-class').filter({ hasText: 'MouseSelection' }); + await token.scrollIntoViewIfNeeded(); + const bounds = await token.boundingBox(); + assert.ok(bounds && bounds.width > 0); + await page.mouse.move(bounds.x + 0.2, bounds.y + bounds.height / 2); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width + 0.2, bounds.y + bounds.height / 2, { steps: 14 }); + await page.mouse.up(); + assert.equal(await page.evaluate(() => getSelection().toString()), 'MouseSelection', 'pointer drag selects source text'); + const copied = await editorInput(page).evaluate(element => { + const clipboardData = new DataTransfer(); + const event = new ClipboardEvent('copy', { clipboardData, bubbles: true, cancelable: true }); + element.dispatchEvent(event); + return { text: clipboardData.getData('text/plain'), prevented: event.defaultPrevented }; + }); + assert.equal(copied.text, 'MouseSelection', 'editor copy handler writes only the selection to the simulated clipboard'); + assert.equal(copied.prevented, true); + assert.equal(await readEditorSource(page), source, 'toolbar copy remains a whole-document action'); + }); + + await run(browser, 'editor-simulated-korean-composition-retains-source', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const before = 'class 한글 { String value = ""; }\n// '; + await editorInput(page).fill(before); + await page.keyboard.press('ControlOrMeta+End'); + await editorInput(page).dispatchEvent('compositionstart', { data: '' }); + await editorInput(page).dispatchEvent('compositionupdate', { data: '한글 입력' }); + await page.keyboard.insertText('한글 입력'); + await editorInput(page).dispatchEvent('compositionend', { data: '한글 입력' }); + const expected = `${before}한글 입력`; + assert.equal(await readEditorSource(page), expected, 'synthetic composition sequence does not duplicate committed Korean text'); + await editorInput(page).dispatchEvent('compositionstart', { data: '' }); + await editorInput(page).dispatchEvent('compositionend', { data: '' }); + await page.locator('#run-code').click(); + await expectEventually(() => api.requests.length, 1, 'composition result reaches the mock API'); + assert.equal(api.requests[0].source, expected); + await waitForOutput(page, 'passed'); + await assertDraft(page, firstExercise.id, expected); + // This is a synthetic browser event test, not validation of an OS IME, + // candidate window, physical keyboard, or mobile composition behavior. + }); + + await run(browser, 'editor-empty-trailing-newline-and-request-roundtrip', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + await editorInput(page).fill(''); + assert.equal(await readEditorSource(page), ''); + await page.locator('#run-code').click(); + assert.equal(api.requests.length, 0, 'empty input is not submitted'); + assert.equal(await editorInput(page).evaluate(element => document.activeElement === element), true); + const source = 'class Solution {\n // 마지막 빈 줄도 원문의 일부\n}\n\n\n'; + await editorInput(page).fill(source); + assert.equal(await readEditorSource(page), source); + await page.locator('#run-code').click(); + await expectEventually(() => api.requests.length, 1, 'source is submitted once'); + assert.equal(api.requests[0].source, source); + await waitForOutput(page, 'passed'); + await assertDraft(page, firstExercise.id, source); + await page.reload(); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await readEditorSource(page), source, 'reload retains all trailing newlines'); + }); + + await run(browser, 'editor-long-korean-document-wrap-scroll-and-resize', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const source = `class Solution {\n${Array.from({ length: 100 }, (_, index) => ` // ${index}: ${'긴한글식별자와문자열 '.repeat(8)}`).join('\n')}\n}\n\n`; + await editorInput(page).fill(source); + assert.equal(await readEditorSource(page), source, 'virtualized long document is copied in full'); + await page.locator('#run-code').click(); + await expectEventually(() => api.requests.length, 1, 'long Unicode source reaches the mock API'); + assert.equal(api.requests[0].source, source); + await waitForOutput(page, 'passed'); + await assertDraft(page, firstExercise.id, source); + for (const viewport of [{ width: 320, height: 720 }, { width: 844, height: 390 }, { width: 390, height: 844 }]) { + await page.setViewportSize(viewport); + await noViewportOverflow(page); + const scroll = await editorScroller(page).evaluate(element => { + element.scrollTop = element.scrollHeight; + element.scrollLeft = 180; + return { left: element.scrollLeft, top: element.scrollTop, width: element.clientWidth, scrollWidth: element.scrollWidth }; + }); + assert.ok(scroll.top > 0 && scroll.left > 0 && scroll.scrollWidth > scroll.width, 'long document scrolls inside CodeMirror'); + assert.equal(await readEditorSource(page), source); + } + await chooseTheme(page, 'light'); + await page.locator('#code-wrap-toggle').click(); + await expectEventually(() => editorScroller(page).evaluate(element => element.scrollWidth <= element.clientWidth + 1), true, 'wrap contains long lines inside the editor'); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await noViewportOverflow(page); + await activeTabVisible(page); + await expectEventually(() => page.locator('#toast').evaluate(element => element.classList.contains('is-visible')), false, 'copy confirmation has dismissed before layout evidence'); + await page.locator('#code-editor').scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-mobile-200-long-korean', false); + await page.setViewportSize({ width: 320, height: 720 }); + await noViewportOverflow(page); + await page.locator('.code-settings').scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-320-200-controls', false); + await page.locator('#code-wrap-toggle').click(); + assert.equal(await readEditorSource(page), source); + await page.reload(); + await editorInput(page).waitFor({ state: 'visible' }); + assert.equal(await readEditorSource(page), source); + }); + + await run(browser, 'editor-document-character-limit-is-atomic', async ({ context, page }) => { + const api = await mockApi(context); + await openLesson(page, first, 'lab'); + const accepted = `//${'가'.repeat(49_998)}`; + await editorInput(page).fill(accepted); + assert.equal(await readEditorSource(page), accepted, 'exactly 50,000 characters are accepted'); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.insertText('나'); + assert.match(await page.locator('#toast').textContent(), /50,000/); + assert.equal(await readEditorSource(page), accepted, 'an over-limit insertion leaves the prior document intact'); + await assertDraft(page, firstExercise.id, accepted); + await page.locator('#run-code').click(); + await waitForOutput(page, 'validation_error'); + assert.equal(api.requests.length, 0, 'the independent UTF-8 request limit is enforced before transmission'); + await page.locator('#lab-reset').click(); + await page.locator('#lab-undo').click(); + assert.equal(await readEditorSource(page), accepted, 'reset undo preserves a boundary-size document'); + }); + + await run(browser, 'editor-theme-during-request-preserves-execution-snapshot', async ({ context, page }) => { + const gate = deferred(); + context.once('close', () => gate.resolve()); + const api = await mockApi(context, { respond: async request => { await gate.promise; return { body: executionResult(request) }; } }); + await openLesson(page, first, 'lab'); + const source = `${firstExercise.template}\n// immutable request snapshot`; + await editorInput(page).fill(source); + await page.locator('#submit-code').click(); + await expectEventually(() => api.requests.length, 1, 'pending snapshot is captured'); + await chooseTheme(page, 'contrast'); + await page.locator('#code-wrap-toggle').click(); + assert.equal(api.requests[0].source, source); + gate.resolve(); + await waitForOutput(page, 'passed'); + assert.equal(await achievement(page, 'lab'), 'true', 'presentation-only changes do not invalidate matching source'); + assert.equal(await readEditorSource(page), source); + assert.equal(api.requests.length, 1, 'theme change sends no additional execution request'); + }); + + await run(browser, 'editor-invalid-and-denied-preferences-keep-learning-usable', async ({ context, page }) => { + const api = await mockApi(context); + await context.addInitScript(key => localStorage.setItem(key, '{ invalid-json'), editorPreferencesKey); + await openLesson(page, first, 'lab'); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'dark'); + await page.evaluate(() => { Storage.prototype.setItem = function () { throw new DOMException('QA blocked preferences', 'SecurityError'); }; }); + await chooseTheme(page, 'light'); + assert.match(await page.locator('#storage-label').textContent(), /이번 방문|현재 화면/); + const source = `${firstExercise.template}\n// memory-only preferences`; + await editorInput(page).fill(source); + await stepTo(page, 'example'); + assert.equal(await page.locator('.code-shell').getAttribute('data-editor-theme'), 'light'); + await stepTo(page, 'lab'); + assert.equal(await readEditorSource(page), source); + await page.locator('#run-code').click(); + await waitForOutput(page, 'passed'); + assert.equal(api.requests[0].source, source); + }); + + await run(browser, 'editor-bundle-failure-keeps-plain-source-editing-and-execution', async ({ context, page }) => { + const api = await mockApi(context); + await context.route('**/assets/editor.bundle.js', route => route.abort('failed')); + await openLesson(page, first, 'example'); + assert.equal(await page.locator('.code-block code').textContent(), first.example.code); + assert.match(await page.locator('.syntax-note').textContent(), /원문/); + await stepTo(page, 'lab'); + assert.equal(await page.locator('#code-editor').getAttribute('data-editor-engine'), 'textarea-fallback'); + const fallback = page.locator('#code-editor [data-testid="code-editor-input"]'); + assert.equal(await fallback.evaluate(element => element.tagName), 'TEXTAREA'); + assert.match(await page.locator('#editor-hint').textContent(), /기본 텍스트 편집기/); + const source = `${firstExercise.template}\n// fallback source\n\n`; + await fallback.fill(source); + await chooseTheme(page, 'light'); + await page.locator('#code-wrap-toggle').click(); + assert.equal(await fallback.inputValue(), source); + assert.equal(await fallback.getAttribute('wrap'), 'soft'); + await page.locator('#run-code').click(); + await waitForOutput(page, 'passed'); + assert.equal(api.requests[0].source, source); + await page.locator('#lab-reset').click(); + await page.locator('#lab-undo').click(); + assert.equal(await fallback.inputValue(), source); + await page.setViewportSize({ width: 320, height: 720 }); + await noViewportOverflow(page); + await fallback.scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-fallback-320', false); + }); + + await run(browser, 'editor-selection-and-caret-visual-states', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + const source = 'class Palette {\n String label = "한글 문자열";\n int count = 7;\n // 선택 영역과 커서 가독성\n void run() { count++; }\n}'; + await editorInput(page).fill(source); + const selectionContrasts = []; + for (const theme of ['dark', 'light', 'contrast']) { + await chooseTheme(page, theme); + await editorInput(page).focus(); + await page.keyboard.press('ControlOrMeta+a'); + assert.equal(await page.evaluate(() => getSelection().toString()), source); + await page.locator('.editor-shell').scrollIntoViewIfNeeded(); + await screenshot(page, `editor-${theme}-selection`, false, { caret: 'initial' }); + const background = await page.locator('#code-editor .cm-selectionBackground').first().evaluate(element => getComputedStyle(element).backgroundColor); + const palette = await tokenPalette(page, '#code-editor'); + for (const [category, foreground] of Object.entries(palette)) selectionContrasts.push({ theme, category, ratio: contrastRatio(foreground, background) }); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowRight'); + assert.equal(await page.evaluate(() => getSelection().isCollapsed), true); + await expectEventually(() => page.locator('#code-editor .cm-cursor').first().evaluate(element => getComputedStyle(element).visibility), 'visible', 'focused editor caret is visible'); + await screenshot(page, `editor-${theme}-caret`, false, { caret: 'initial' }); + } + console.log('SELECTION_CONTRAST', JSON.stringify(selectionContrasts)); + for (const { theme, category, ratio } of selectionContrasts) assert.ok(ratio >= 4.5, `${theme}/${category} selected text contrast ${ratio.toFixed(2)}:1 is below 4.5:1`); + await noViewportOverflow(page); + }); + + await run(browser, 'editor-mobile-theme-palette-and-controls', async ({ context, page }) => { + await mockApi(context); + await openLesson(page, first, 'lab'); + for (const theme of ['dark', 'light', 'contrast']) { + await chooseTheme(page, theme); + await noViewportOverflow(page); + await page.locator('.code-settings').scrollIntoViewIfNeeded(); + await screenshot(page, `editor-mobile-${theme}`, false); + } + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await noViewportOverflow(page); + await activeTabVisible(page); + await page.locator('.code-settings').scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-mobile-200-theme', false); + await page.locator('#run-code').scrollIntoViewIfNeeded(); + await screenshot(page, 'editor-mobile-200-execution-controls', false); + }, { viewport: { width: 390, height: 844 } }); + } finally { + await browser.close(); + } + console.log(JSON.stringify({ browser: process.env.PLAYWRIGHT_CHANNEL || 'chromium', browserVersion, node: process.version, mode: 'mocked API; browser emulation, not real devices', passed: results.filter(result => result.status === 'PASS').length, failed: results.filter(result => result.status === 'FAIL').length, artifacts: artifactRoot, results }, null, 2)); + if (!results.length || results.some(result => result.status === 'FAIL')) process.exitCode = 1; +} + +main().catch(error => { + console.error(error.stack); + process.exitCode = 1; +}); diff --git a/tests/bundled-desktop.mjs b/tests/bundled-desktop.mjs new file mode 100644 index 0000000..9b9ded1 --- /dev/null +++ b/tests/bundled-desktop.mjs @@ -0,0 +1,551 @@ +import assert from 'node:assert/strict'; +import { access, lstat, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { _electron as electron } from 'playwright'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +// Manual packaged-app gate. The release owner installs/extracts the package and +// records absence of pre-existing user data before authorizing this script. +// This script never installs, uninstalls, deletes data, redirects userData, +// registers external tools, or replaces API/JVM/Maven/clipboard responses. +const root = path.resolve(fileURLToPath(new URL('../', import.meta.url))); +const expectedVersion = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).version; +const option = (name, fallback) => { + const index = process.argv.indexOf(name); + return index === -1 ? fallback : process.argv[index + 1]; +}; +const kind = option('--kind', process.env.BUNDLED_PACKAGE_KIND); +const executableInput = option('--executable', process.env.BUNDLED_APP_EXE); +const executablePath = executableInput && path.resolve(executableInput); +const artifactsRoot = path.resolve(root, process.env.BUNDLED_QA_ARTIFACT_DIR || 'work/bundled-desktop-qa'); +const artifacts = path.join(artifactsRoot, kind === 'zip' || kind === 'nsis' ? kind : 'invalid-input'); +const preflightPath = path.join(artifactsRoot, 'preflight.json'); +const progressKey = 'java-edu:progress:v1'; +const preferencesKey = 'java-edu:editor-preferences:v1'; +const coreCases = [8, 11, 17, 21].map(version => coreLessons.find(lesson => lesson.minVersion === version)); +const project = professionalLessons.find(lesson => lesson.id === 'tx-proxy'); +const projectExercise = professionalExercises.find(exercise => exercise.id === project.exerciseId); +const results = []; +const errors = []; +const apiRequests = []; +const evidence = { + startedAt: new Date().toISOString(), packageKind: kind, executablePath, + scope: 'Actual production packaged Electron and bundled JDK/Maven; no API, Java, Maven or clipboard mocks. Folder-dialog calls are forbidden, counted and rejected without supplying a path.', + excluded: ['Native folder dialog operation', 'OS clipboard', 'SmartScreen downloaded-file flow', 'Physical mobile devices'], +}; +let app; +let page; +let runtime; +let userData; + +const normalize = value => path.resolve(value).toLowerCase(); +const inside = (child, parent) => normalize(child).startsWith(`${normalize(parent)}${path.sep}`); +const exists = file => access(file).then(() => true, () => false); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)); +const editor = () => page.locator('#code-editor [role="textbox"]'); +const badge = type => page.locator(`#lesson-achievements [data-achievement="${type}"]`).getAttribute('data-complete'); +const executionCount = () => apiRequests.filter(request => request.method === 'POST' && ['/api/execute', '/api/local/prepare'].includes(request.path)).length; + +async function eventually(read, expected, message, timeout = 15000) { + const until = Date.now() + timeout; + let value; + while (Date.now() < until) { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await pause(100); + } + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function captureGeometry() { + // Observe the actual viewport. Do not scroll, disable motion, change styles, + // blur the editor, or alter focus to obtain a more attractive screenshot. + return page.evaluate(() => { + const number = value => Math.round(value * 100) / 100; + const selectors = ['#site-header', '#intro', '.workspace-heading', '#workspace-grid', '#curriculum-sidebar', '#lesson-card', '#lesson-title', '#lesson-achievements', '.lesson-tabs', '#lesson-panel', '#code-editor', '#code-editor .cm-scroller', '#reference-column', '#desktop-settings-dialog[open]', '.settings-bundle-tools', '#desktop-settings-dialog .dialog-actions']; + return { + hash: location.hash, + activeStep: document.querySelector('[data-step][aria-selected="true"]')?.dataset.step, + scrollX, scrollY, width: innerWidth, height: innerHeight, + rootFont: getComputedStyle(document.documentElement).fontSize, + scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior, + document: { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }, + visualViewport: visualViewport && { width: number(visualViewport.width), height: number(visualViewport.height), pageTop: number(visualViewport.pageTop), offsetTop: number(visualViewport.offsetTop), scale: visualViewport.scale }, + nodes: Object.fromEntries(selectors.map(selector => { + const node = document.querySelector(selector); + if (!node || !node.getClientRects().length) return [selector, null]; + const rect = node.getBoundingClientRect(); + return [selector, { x: number(rect.x), y: number(rect.y), width: number(rect.width), height: number(rect.height), scrollLeft: number(node.scrollLeft), scrollTop: number(node.scrollTop), scrollWidth: node.scrollWidth, scrollHeight: node.scrollHeight }]; + })), + }; + }); +} + +async function stableCaptureGeometry(samples) { + const started = Date.now(); + let previous; + let stableSince = started; + while (Date.now() - started < 10000) { + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const geometry = await captureGeometry(); + samples.push({ elapsedMs: Date.now() - started, geometry }); + const fingerprint = JSON.stringify(geometry); + if (fingerprint !== previous) stableSince = Date.now(); + else if (Date.now() - stableSince >= 600) return geometry; + previous = fingerprint; + await pause(100); + } + assert.fail('viewport/scroll/major bounds did not remain stable for 600ms within 10s'); +} + +async function capture(name) { + const record = { name, file: path.join(artifacts, `${name}.png`), startedAt: new Date().toISOString(), samples: [] }; + evidence.captures ??= []; + evidence.captures.push(record); + await page.locator('#toast.is-visible').waitFor({ state: 'hidden', timeout: 6000 }); + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + record.before = await stableCaptureGeometry(record.samples); + await page.screenshot({ path: record.file, fullPage: false }); + record.after = await captureGeometry(); + assert.deepEqual(record.after, record.before, `${name}: geometry changed while capturing`); + if (name === 'bundled-restarted-achievement') { + // A second independently settled viewport provides visual evidence when a + // compositor frame disagrees with otherwise stable DOM geometry. + record.confirmationSamples = []; + record.confirmationBefore = await stableCaptureGeometry(record.confirmationSamples); + assert.deepEqual(record.confirmationBefore, record.before, 'restarted viewport remains at the same natural scroll position'); + record.confirmationFile = path.join(artifacts, `${name}-confirmation.png`); + await page.screenshot({ path: record.confirmationFile, fullPage: false }); + record.confirmationAfter = await captureGeometry(); + assert.deepEqual(record.confirmationAfter, record.confirmationBefore, 'restarted confirmation geometry is unchanged'); + } + record.finishedAt = new Date().toISOString(); +} + +async function assertNoFolderDialog() { + const calls = await app.evaluate(() => globalThis.__javaEduQAFolderDialogCalls); + evidence.folderDialogCalls = [...(evidence.folderDialogCalls || []), ...calls]; + assert.deepEqual(calls, [], 'bundled setup must not ask for a JDK or Maven directory'); +} + +async function check(name, body) { + const started = Date.now(); + console.log(`START ${kind} ${name}`); + try { + await body(); + if (app) await assertNoFolderDialog(); + results.push({ name, status: 'PASS', durationMs: Date.now() - started }); + console.log(`PASS ${kind} ${name}`); + } catch (error) { + if (page) await capture(`failure-${name}`).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started }); + throw error; + } +} + +async function launch() { + const env = { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: '1' }; + const forbidden = new Set(['PATH', 'JAVA_HOME', 'MAVEN_HOME', 'M2_HOME', 'JDK_HOME', 'JAVA_EDU_QA_DATA', 'ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS']); + for (const name of Object.keys(env)) if (forbidden.has(name.toUpperCase())) delete env[name]; + // Node on Windows restores the parent's PATH when it is omitted, even with + // shell:false. An explicit empty value prevents that fallback and contains no + // host tool directory. JAVA_HOME/MAVEN_HOME remain absent. + env.PATH = ''; + app = await electron.launch({ executablePath, args: [], env, timeout: 90000 }); + app.context().on('request', request => { + const url = new URL(request.url()); + if (url.pathname.startsWith('/api/')) apiRequests.push({ origin: url.origin, path: url.pathname, method: request.method() }); + }); + await app.evaluate(({ dialog }) => { + globalThis.__javaEduQAFolderDialogCalls = []; + for (const method of ['showOpenDialog', 'showOpenDialogSync']) { + dialog[method] = (...args) => { + globalThis.__javaEduQAFolderDialogCalls.push({ method, title: args.find(arg => arg?.title)?.title || '' }); + const error = new Error('QA: bundled startup unexpectedly requested a folder'); + if (method === 'showOpenDialog') return Promise.reject(error); + throw error; + }; + } + }); + page = await app.firstWindow(); + page.setDefaultTimeout(15000); + page.on('pageerror', error => errors.push(error.message)); + await page.locator('#workspace-grid').waitFor({ state: 'visible', timeout: 60000 }); + evidence.application = await app.evaluate(({ app }) => ({ packaged: app.isPackaged, version: app.getVersion(), executable: app.getPath('exe'), appPath: app.getAppPath(), userData: app.getPath('userData') })); + const inherited = await app.evaluate((_electron, names) => Object.fromEntries(names.map(name => [name, Object.keys(process.env).filter(key => key.toUpperCase() === name).map(key => process.env[key])])), [...forbidden]); + evidence.sanitizedChildEnvironment = inherited; + // Playwright's Windows cmd launch removes empty environment keys, so either + // absence or an empty value proves that no host tool path was inherited. + assert.ok(inherited.PATH.every(value => value === ''), 'child PATH must be absent or empty'); + assert.ok(Object.entries(inherited).filter(([key]) => key !== 'PATH').every(([, values]) => values.length === 0), JSON.stringify(inherited)); +} + +async function openLesson(lesson, step = 'lab') { + await page.evaluate(hash => { location.hash = hash; }, `lesson=${lesson.id}&step=${step}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), 'requested lesson is selected'); + await eventually(() => page.locator(`[data-step="${step}"]`).getAttribute('aria-selected'), 'true', 'requested learning stage is selected'); +} + +async function settings() { + await page.locator('#desktop-settings-open').click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'visible' }); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'settings loaded'); +} + +async function closeSettings() { + await page.locator('#desktop-settings-dialog [data-close-dialog="desktop-settings-dialog"]').first().click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'hidden' }); +} + +const stored = () => page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [progressKey, preferencesKey]); +const progress = async () => JSON.parse((await stored())[progressKey] || '{}'); + +async function quiz(lesson) { + await openLesson(lesson, 'quiz'); + for (const question of lesson.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await eventually(() => badge('quiz'), 'true', 'quiz achievement recorded'); + await openLesson(lesson); +} + +// Observe only this app's transient command specifications; never modify them. +// These are the exact arguments consumed by the packaged Windows job runner. +function observeCommands() { + let stopped = false; + const seen = new Map(); + const jobs = path.join(userData, 'java-edu/jobs'); + const pending = (async () => { + while (!stopped) { + for (const job of await readdir(jobs, { withFileTypes: true }).catch(() => [])) { + if (!job.isDirectory() || !job.name.startsWith('job-')) continue; + const base = path.join(jobs, job.name); + for (const relative of ['', 'professional/lessons/tx-proxy/reference', 'professional/lessons/tx-proxy/starter']) { + const directory = path.join(base, relative); + for (const name of await readdir(directory).catch(() => [])) { + if (!/^\.command-[a-f0-9-]+\.json$/i.test(name)) continue; + const file = path.join(directory, name); + if (seen.has(file)) continue; + try { + const command = JSON.parse(await readFile(file, 'utf8')); + if (typeof command.executable === 'string' && Array.isArray(command.args)) seen.set(file, { executable: command.executable, args: command.args, cwd: command.cwd }); + } catch { /* The writer may be finishing this new file; retry next poll. */ } + } + } + } + await pause(100); + } + })(); + return { async stop() { stopped = true; await pending; return [...seen.values()]; } }; +} + +async function execute(button, timeout = 60000) { + const preparation = button === '#prepare-project'; + const pathname = preparation ? '/api/local/prepare' : '/api/execute'; + const pending = page.waitForResponse(response => new URL(response.url()).pathname === pathname && response.request().method() === 'POST', { timeout }); + void pending.catch(() => {}); + const observer = observeCommands(); + let commands; + let response; + try { await page.locator(button).click(); response = await pending; } + finally { commands = await observer.stop(); } + const result = await response.json(); + const request = response.request().postDataJSON(); + evidence.executions ??= []; + evidence.executions.push({ exerciseId: request.exerciseId, action: request.action || 'prepare', httpStatus: response.status(), commands, ...result }); + console.log('ACTUAL_BUNDLED_RESULT', JSON.stringify({ kind, exerciseId: request.exerciseId, action: request.action || 'prepare', status: result.status, provider: result.provider, runtimeVersion: result.runtimeVersion, targetVersion: result.targetVersion, passed: result.passed, total: result.total, commands: commands.length, error: result.error })); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), status => status !== 'running', 'execution result rendered'); + assert.equal(response.status(), 200); + assert.equal(result.provider, request.exerciseId === project.exerciseId ? 'Local Maven' : 'Local JDK'); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.status, 'passed', result.stderr || result.compilerOutput || result.stdout); + assert.ok(result.total > 0 && result.passed === result.total && result.checks.every(check => check.passed)); + const jdk = runtime.runtimes.find(item => item.javaVersion === 21); + assert.ok(commands.length > 0, 'actual command specifications were observed'); + assert.ok(commands.every(command => inside(command.executable, jdk.path)), 'every Java/javac command uses this package JDK'); + if (request.exerciseId === project.exerciseId) { + const command = commands.find(command => command.args.includes('org.codehaus.plexus.classworlds.launcher.Launcher')); + assert.ok(command, 'actual Maven launcher invocation observed'); + assert.equal(command.args.includes('--offline'), !preparation, 'only explicit environment preparation permits Maven dependency downloads'); + assert.equal(normalize(command.args.find(arg => arg.startsWith('-Dmaven.home=')).slice('-Dmaven.home='.length)), normalize(runtime.maven.path)); + const classpath = command.args[command.args.indexOf('-classpath') + 1]; + assert.ok(inside(classpath, runtime.maven.path), 'actual Maven launcher comes from this package'); + } else { + const compiler = commands.find(command => path.basename(command.executable).toLowerCase() === 'javac.exe'); + assert.ok(compiler, 'actual bundled javac invocation observed'); + assert.equal(compiler.args[compiler.args.indexOf('--release') + 1], String(result.targetVersion)); + } + return { result, request }; +} + +async function verifyBundledRuntime(value) { + assert.equal(value.mode, 'local'); + assert.deepEqual(value.supportedTargets, [8, 11, 17, 21]); + assert.deepEqual(value.runtimes.filter(item => item.available).map(item => item.javaVersion), [21]); + const jdk = value.runtimes.find(item => item.javaVersion === 21); + assert.equal(jdk.source, 'bundled'); + assert.equal(value.maven.source, 'bundled'); + assert.equal(value.maven.available, true); + assert.ok(value.bundledRuntime?.id && typeof value.bundledRuntime.id === 'string'); + assert.deepEqual(value.bundledRuntime.javaVersions, [21]); + assert.equal(value.bundledRuntime.mavenVersion, value.maven.version); + const resources = path.join(path.dirname(executablePath), 'resources'); + assert.ok(inside(jdk.path, resources), 'automatically registered JDK is contained in this product'); + assert.ok(inside(value.maven.path, resources), 'automatically registered Maven is contained in this product'); + await access(path.join(jdk.path, 'bin/java.exe')); + await access(path.join(jdk.path, 'bin/javac.exe')); + await access(path.join(value.maven.path, 'boot')); +} + +async function main() { + assert.ok(kind === 'zip' || kind === 'nsis', 'pass --kind zip|nsis'); + assert.ok(executableInput && path.isAbsolute(executableInput), 'pass an explicitly approved absolute --executable path'); + assert.equal(path.basename(executablePath), 'Java Edu.exe'); + if (kind === 'nsis') assert.equal(normalize(executablePath), normalize(path.join(root, 'work/한글 설치 & %JAVA_HOME%/Java Edu.exe'))); + else { + assert.ok(inside(executablePath, path.join(root, 'work/bundled-zip'))); + for (const text of ['한글', ' ', '&', '%JAVA_HOME%']) assert.ok(executablePath.includes(text), `ZIP extraction path preserves literal ${text}`); + } + assert.ok((await lstat(executablePath)).isFile()); + assert.equal(normalize(await realpath(executablePath)), normalize(executablePath), 'approved package executable is not redirected'); + await mkdir(artifacts, { recursive: true }); + const preflight = JSON.parse((await readFile(preflightPath, 'utf8')).replace(/^\uFEFF/, '')); + assert.ok(Array.isArray(preflight.AppData) && preflight.AppData.length >= 2, 'root preflight of default application-data paths is required'); + assert.ok(preflight.AppData.every(item => item.Exists === false), 'do not overwrite existing user data'); + for (const item of preflight.AppData) assert.equal(await exists(item.Path), false, `STOP: existing application data at ${item.Path}`); + evidence.preflight = preflight; + evidence.executableSha256 = digest(await readFile(executablePath)); + evidence.asarSha256 = digest(await readFile(path.join(path.dirname(executablePath), 'resources/app.asar'))); + const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); + await launch(); + const firstOrigin = new URL(page.url()).origin; + + await check('first-launch-automatic-bundled-tools-without-host-environment', async () => { + const facts = await app.evaluate(({ app, BrowserWindow }) => ({ packaged: app.isPackaged, version: app.getVersion(), executable: app.getPath('exe'), appPath: app.getAppPath(), userData: app.getPath('userData'), preferences: BrowserWindow.getAllWindows()[0].webContents.getLastWebPreferences() })); + userData = facts.userData; + evidence.application = facts; + assert.equal(facts.packaged, true); + assert.equal(facts.version, expectedVersion, 'packaged version matches the canonical release package'); + assert.equal(normalize(facts.executable), normalize(executablePath)); + assert.ok(preflight.AppData.some(item => normalize(item.Path) === normalize(userData)), 'default production AppData only'); + for (const [key, value] of Object.entries({ contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true, allowRunningInsecureContent: false })) assert.equal(facts.preferences[key], value); + runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + evidence.runtimeBeforeConsent = runtime; + await verifyBundledRuntime(runtime); + assert.equal(runtime.consent.accepted, false); + assert.equal(runtime.available, false); + await openLesson(coreCases[0]); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + await settings(); + assert.equal(await page.locator('#execution-consent').isChecked(), false); + assert.match(await page.locator('#desktop-bundle-title').textContent(), /별도 설치 없이/); + assert.deepEqual(await page.locator('#desktop-bundle-status .settings-bundle-tools li span').allTextContents(), ['앱에 포함', '앱에 포함']); + assert.match(await page.locator('#desktop-bundle-status').textContent(), /환경 준비 시 인터넷/); + assert.equal(await page.locator('#desktop-advanced-settings').getAttribute('open'), null, 'optional external-tool settings start collapsed'); + assert.equal(executionCount(), 0, 'opening the app/settings does not run learner code or prepare a project'); + await capture('bundled-first-launch-settings'); + await page.locator('#desktop-advanced-summary').focus(); + await page.keyboard.press('Enter'); + await eventually(() => page.locator('#desktop-advanced-settings').evaluate(node => node.open), true, 'optional settings are keyboard accessible'); + for (const version of [8, 11, 17]) assert.match(await page.locator(`#jdk-register-${version}`).locator('..').textContent(), /선택 사항/); + await page.locator('#desktop-advanced-summary').focus(); + await page.keyboard.press('Enter'); + await eventually(() => page.locator('#desktop-advanced-settings').evaluate(node => node.open), false, 'optional settings close without choosing a directory'); + await closeSettings(); + }); + + await check('all34-packaged-reading-guides-and-prediction-without-achievement', async () => { + const lessons = [...coreLessons, ...professionalLessons]; + assert.equal(lessons.length, 34); + const beforeRequests = executionCount(); + const learningState = async () => { + const stored = await progress(); + return Object.fromEntries(['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes'].map(field => [field, stored[field] || []])); + }; + const before = await learningState(); + for (const lesson of lessons) { + assert.ok(lesson.learningGuide, `${lesson.id}: expected release guide exists`); + await openLesson(lesson, 'theory'); + assert.equal(await page.locator('.learning-guide').count(), 1, `${lesson.id}: packaged guide renders`); + assert.equal(await page.locator('.guide-scenario-text').textContent(), lesson.learningGuide.scenario); + assert.equal(await page.locator('#theory-code-panel code').textContent(), lesson.learningGuide.code.source); + assert.deepEqual(await page.locator('.guide-walkthrough li').allTextContents(), lesson.learningGuide.walkthrough); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null); + await page.locator('.guide-prediction summary').focus(); + await page.keyboard.press('Enter'); + assert.equal(await page.locator('.guide-answer-text').isVisible(), true); + assert.equal(await page.locator('.guide-answer-text').textContent(), lesson.learningGuide.prediction.answer); + assert.equal(await page.locator('.guide-answer-explanation').textContent(), lesson.learningGuide.prediction.explanation); + await page.keyboard.press('Space'); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null); + } + assert.deepEqual(await learningState(), before, 'reading and revealing answers do not grant or erase learning state'); + assert.equal(executionCount(), beforeRequests, 'reading guides never execute or prepare code'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + evidence.packagedReadingGuides = { verified: lessons.map(lesson => lesson.id), predictionGrantsAchievement: false }; + await openLesson(professionalLessons.find(lesson => lesson.id === 'jpa-state'), 'theory'); + await page.locator('#theory-code-panel').scrollIntoViewIfNeeded(); + await capture('bundled-reading-guide-code'); + await openLesson(coreCases[0]); + }); + + await check('consent-enables-bundled-tools-without-directory-selection', async () => { + await settings(); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'explicit consent persisted'); + runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + await verifyBundledRuntime(runtime); + await closeSettings(); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'bundled runtime enables execution'); + assert.equal(executionCount(), 0, 'accepting consent alone does not execute code'); + }); + + for (const lesson of coreCases) await check(`bundled-jvm21-core-target${lesson.minVersion}`, async () => { + await openLesson(lesson); + await editor().fill(solutions[lesson.exerciseId]); + await quiz(lesson); + const { result, request } = await execute('#submit-code'); + assert.equal(result.targetVersion, lesson.minVersion); + assert.equal(request.source, solutions[lesson.exerciseId]); + assert.equal(await badge('lab'), 'true'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture(`bundled-core-target${lesson.minVersion}-passed`); + }); + + await check('spring-h2-explicit-online-preparation-not-achievement', async () => { + await openLesson(project); + await eventually(() => page.locator('#prepare-project').isEnabled(), true, 'project metadata and bundled tools ready'); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + const file = projectExercise.files.find(item => item.editable); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(`${file.source}\n// preparation preserves this learner draft\n`); + await openLesson(project, 'theory'); + await openLesson(project); + const before = (await progress()).fileDrafts; + const { result, request } = await execute('#prepare-project', 360000); + assert.equal(result.targetVersion, 17); + assert.equal(result.prepared, true); + assert.equal(request.source, undefined); + assert.equal(request.files, undefined); + assert.deepEqual((await progress()).fileDrafts, before); + assert.equal(await badge('quiz'), 'false'); + assert.equal(await badge('lab'), 'false'); + await eventually(() => page.locator('#project-preparation-state').getAttribute('data-prepared'), 'true', 'environment marked prepared'); + assert.equal(await page.locator('#execution-output .execution-result-label').textContent(), '환경 준비 완료'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture('bundled-spring-h2-prepared'); + }); + + await check('spring-h2-actual-bundled-maven-offline-submit', async () => { + const expectedFiles = {}; + for (const file of project.professionalExercise.files.filter(item => item.editable)) { + expectedFiles[file.path] = await readFile(path.join(root, 'curriculum/professional', file.referencePath), 'utf8'); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(expectedFiles[file.path]); + } + await quiz(project); + const { request } = await execute('#submit-code', 180000); + assert.deepEqual(request.files, expectedFiles); + assert.equal(Object.hasOwn(request.files, 'pom.xml'), false); + assert.equal(await badge('lab'), 'true'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await capture('bundled-spring-h2-submitted'); + }); + + await check('restart-retains-bundled-registration-progress-theme-and-drafts', async () => { + const core = coreCases[0]; + const marker = `bundled ${kind} restart draft`; + await openLesson(core); + await editor().fill(`${solutions[core.exerciseId]}\n// ${marker}\n`); + await page.locator('#editor-theme-select').selectOption('contrast'); + await openLesson(project); + const file = projectExercise.files.find(item => item.editable); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(`${file.source}\n// ${marker} project\n`); + await openLesson(coreCases[3], 'theory'); + const before = await stored(); + const registered = await page.evaluate(() => window.javaEduDesktop.getSettings()); + await assertNoFolderDialog(); + await app.close(); app = null; + await launch(); + evidence.origins = [firstOrigin, new URL(page.url()).origin]; + assert.notEqual(evidence.origins[0], evidence.origins[1]); + const after = await stored(); + assert.equal(after[preferencesKey], before[preferencesKey]); + for (const field of ['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes']) assert.deepEqual(JSON.parse(after[progressKey])[field], JSON.parse(before[progressKey])[field], `${field} survives port change`); + runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + await verifyBundledRuntime(runtime); + assert.equal(runtime.consent.accepted, true); + assert.deepEqual(runtime.bundledRuntime, registered.bundledRuntime); + assert.equal(normalize(runtime.runtimes.find(item => item.javaVersion === 21).path), normalize(registered.runtimes.find(item => item.javaVersion === 21).path)); + assert.equal(normalize(runtime.maven.path), normalize(registered.maven.path)); + evidence.runtimeAfterRestart = runtime; + await openLesson(core); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); + await editor().press('Control+End'); + await eventually(() => editor().innerText(), text => text.includes(marker), 'restored core draft is visible in the actual editor'); + await openLesson(project); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().press('Control+End'); + await eventually(() => editor().innerText(), text => text.includes(`${marker} project`), 'restored project draft is visible in the actual editor'); + assert.equal(await page.locator('#prepare-project').getAttribute('data-prepared'), 'false'); + await openLesson(coreCases[3]); + assert.equal(await badge('quiz'), 'true'); + assert.equal(await badge('lab'), 'true'); + await capture('bundled-restarted-achievement'); + }); + + await check('bundled-settings-minimum-window-and-200-percent', async () => { + await settings(); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(720, 560)); + await eventually(() => page.evaluate(() => innerWidth), width => width < 800, 'minimum window applied'); + await page.locator('html').evaluate(node => { node.style.fontSize = '200%'; }); + const dialog = page.locator('#desktop-settings-dialog'); + await dialog.evaluate(node => { node.scrollTop = 0; }); + await capture('bundled-settings-minimum200-top'); + const includedTools = dialog.locator('.settings-bundle-tools'); + await includedTools.evaluate(node => node.scrollIntoView({ behavior: 'instant', block: 'center' })); + const toolsGeometry = await includedTools.evaluate(node => { + const dialog = node.closest('dialog').getBoundingClientRect(); + return { width: innerWidth, height: innerHeight, dialog: { top: dialog.top, bottom: dialog.bottom, left: dialog.left, right: dialog.right }, nodes: [...node.querySelectorAll('li, li strong, li span')].map(item => { const r = item.getBoundingClientRect(); return { text: item.textContent, top: r.top, bottom: r.bottom, left: r.left, right: r.right }; }) }; + }); + assert.ok(toolsGeometry.nodes.every(node => node.top >= Math.max(0, toolsGeometry.dialog.top) && node.bottom <= Math.min(toolsGeometry.height, toolsGeometry.dialog.bottom) && node.left >= toolsGeometry.dialog.left && node.right <= toolsGeometry.dialog.right), 'included Java/Maven names and source labels are fully visible at 200%'); + evidence.minimumBundledToolsViewport = toolsGeometry; + await capture('bundled-settings-minimum200-tools'); + const close = dialog.locator('.dialog-actions [data-close-dialog="desktop-settings-dialog"]'); + await close.evaluate(node => node.scrollIntoView({ behavior: 'instant', block: 'end' })); + const geometry = await close.evaluate(node => { const b = node.getBoundingClientRect(); const d = node.closest('dialog').getBoundingClientRect(); return { width: innerWidth, height: innerHeight, rootFont: getComputedStyle(document.documentElement).fontSize, button: { top: b.top, bottom: b.bottom, left: b.left, right: b.right }, dialog: { top: d.top, bottom: d.bottom, scrollWidth: node.closest('dialog').scrollWidth, clientWidth: node.closest('dialog').clientWidth } }; }); + assert.equal(geometry.rootFont, '32px'); + assert.ok(geometry.button.top >= Math.max(0, geometry.dialog.top) && geometry.button.bottom <= Math.min(geometry.height, geometry.dialog.bottom)); + assert.ok(geometry.button.left >= 0 && geometry.button.right <= geometry.width); + assert.ok(geometry.dialog.scrollWidth <= geometry.dialog.clientWidth + 1, 'dialog has no horizontal overflow'); + evidence.minimumSettingsViewport = geometry; + await capture('bundled-settings-minimum200-close'); + await close.click(); + await dialog.waitFor({ state: 'hidden' }); + }); + assert.deepEqual(errors, []); + assert.ok(apiRequests.every(request => new URL(request.origin).hostname === '127.0.0.1')); + await assertNoFolderDialog(); +} + +try { await main(); } +catch (error) { + if (!results.some(result => result.status === 'FAIL')) results.push({ name: 'environment-or-preflight', status: 'BLOCKED', message: error.message }); + console.error(error.stack); + process.exitCode = 1; +} finally { + await app?.close().catch(() => {}); + evidence.finishedAt = new Date().toISOString(); + evidence.results = results; + evidence.uncaughtBrowserErrors = errors; + evidence.apiRequests = apiRequests; + await mkdir(artifacts, { recursive: true }); + const resultFile = path.join(artifacts, `bundled-results-${Date.now()}.json`); + await writeFile(resultFile, JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify({ results, resultFile, application: evidence.application, origins: evidence.origins }, null, 2)); +} diff --git a/tests/content.test.mjs b/tests/content.test.mjs new file mode 100644 index 0000000..5b5dfb6 --- /dev/null +++ b/tests/content.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, access } from 'node:fs/promises'; +import { lessons } from '../dist/data/curriculum.js'; +import { exercises } from '../dist/data/exercises.js'; +import { exerciseDefinitions } from '../server/exercises.mjs'; +import { jobEvidence } from '../dist/data/job-evidence.js'; + +const versions = [8, 11, 17, 21]; +const referenceAnswers = JSON.parse(await readFile(new URL('../examples/solutions.json', import.meta.url), 'utf8')); +const exampleManifest = JSON.parse(await readFile(new URL('../examples/expected-output.json', import.meta.url), 'utf8')); + +test('12 published lessons have meaningful five-stage content and two distinct template modes', () => { + assert.equal(lessons.length, 12); + assert.equal(new Set(lessons.map((lesson) => lesson.id)).size, lessons.length); + assert.equal(exercises.length, lessons.length); + assert.equal(exerciseDefinitions.length, lessons.length); + assert.deepEqual(new Set(exercises.map((exercise) => exercise.mode)), new Set(['class', 'method'])); + for (const lesson of lessons) { + assert.ok(lesson.title && lesson.description && lesson.category); + assert.ok(versions.includes(lesson.minVersion), lesson.id); + assert.equal(lesson.preview, false, lesson.id); + assert.ok(['transition', 'advanced', 'both'].includes(lesson.track)); + assert.ok(lesson.goals.length >= 2 && lesson.theory.length >= 2 && lesson.quiz.length >= 2, lesson.id); + for (const section of lesson.theory) assert.ok(section.heading && section.text.length >= 35, lesson.id); + assert.ok(lesson.example.code.includes('class Main') && lesson.example.output && lesson.example.explanation, lesson.id); + assert.ok(lesson.practice.title && lesson.practice.scenario && lesson.practice.tasks.length >= 2 && lesson.practice.takeaway, lesson.id); + assert.ok(lesson.sources.length >= 1, lesson.id); + for (const source of lesson.sources) { + assert.equal(new URL(source.url).protocol, 'https:'); + assert.ok(source.title && source.edition && /^2026-10-12$/.test(source.checkedAt)); + } + for (const question of lesson.quiz) { + assert.ok(question.id && question.question && question.explanation); + assert.equal(question.options.length, 4); + assert.equal(new Set(question.options).size, 4); + assert.ok(Number.isInteger(question.correctIndex) && question.correctIndex >= 0 && question.correctIndex < 4); + } + const exercise = exercises.find((item) => item.id === lesson.exerciseId); + const definition = exerciseDefinitions.find((item) => item.id === lesson.exerciseId); + assert.ok(exercise && definition, lesson.id); + assert.equal(exercise.lessonId, lesson.id); + assert.equal(definition.lessonId, lesson.id); + assert.equal(exercise.minVersion, lesson.minVersion); + assert.equal(definition.minVersion, lesson.minVersion); + assert.equal(exercise.template, definition.template); + assert.ok(exercise.instructions.length >= 2 && exercise.rubric.length >= 1); + assert.ok(typeof referenceAnswers[exercise.id] === 'string', exercise.id); + assert.ok(definition.sampleChecks.length >= 1 && definition.checks.length > definition.sampleChecks.length); + assert.equal(new Set(definition.checks.map((check) => check.name)).size, definition.checks.length); + for (const sample of definition.sampleChecks) assert.ok(definition.checks.some((check) => check.name === sample.name && check.expression === sample.expression)); + } +}); + +test('authored expected examples exist and match the displayed code at every target version', async () => { + assert.equal(Object.keys(exampleManifest).length, lessons.length); + for (const [relativeFile, example] of Object.entries(exampleManifest)) { + assert.ok(versions.includes(example.minVersion)); + assert.equal(example.mainClass, 'Main'); + assert.ok(relativeFile.startsWith(`java${example.minVersion}/`) && !relativeFile.includes('..')); + const code = await readFile(new URL(`../examples/${relativeFile}`, import.meta.url), 'utf8'); + const matches = lessons.filter((lesson) => lesson.example.code.trim() === code.trim()); + assert.equal(matches.length, 1, relativeFile); + assert.equal(matches[0].minVersion, example.minVersion); + assert.equal(matches[0].example.output.trim(), example.stdout.trim()); + } + for (const version of versions) assert.ok(lessons.some((lesson) => lesson.minVersion === version)); +}); + +test('Java 8 examples exclude later standard collection APIs and preview syntax', () => { + for (const lesson of lessons.filter((item) => item.minVersion === 8)) { + assert.doesNotMatch(lesson.example.code, /List\.of\(|(? lesson.id === 'modern-apis'); + assert.equal(modern.minVersion, 11); + assert.equal(lessons.find((lesson) => lesson.id === 'java21').minVersion, 21); +}); + +test('public evidence has real URLs, dated status, and valid lesson associations', () => { + assert.ok(jobEvidence.length >= 5); + assert.equal(new Set(jobEvidence.map((item) => item.id)).size, jobEvidence.length); + const ids = new Set(lessons.map((lesson) => lesson.id)); + for (const item of jobEvidence) { + assert.ok(item.company && item.title && item.summary && item.status); + assert.equal(new URL(item.url).protocol, 'https:'); + assert.equal(item.checkedAt, '2026-10-12'); + assert.ok(['full', 'partial'].includes(item.access)); + assert.ok(item.technologies.length && item.lessonIds.every((id) => ids.has(id))); + } +}); + +test('public static assets do not contain reference solutions or trusted server tests', async () => { + const html = await readFile(new URL('../dist/index.html', import.meta.url), 'utf8'); + assert.doesNotMatch(html, /examples\/solutions\.json|server\/exercises\.mjs/); + for (const file of ['../dist/app.js', '../dist/styles.css']) await access(new URL(file, import.meta.url)); +}); diff --git a/tests/desktop.mjs b/tests/desktop.mjs new file mode 100644 index 0000000..a7a9750 --- /dev/null +++ b/tests/desktop.mjs @@ -0,0 +1,562 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, readdir, access, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { _electron as electron, chromium } from 'playwright'; +import { unzipSync, strFromU8 } from 'fflate'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +// Web APIs are mocked. Electron uses its actual authenticated local server and +// registered JDKs. No test sends learner source to a remote execution provider. +const projectRoot = fileURLToPath(new URL('../', import.meta.url)); +const artifactRoot = path.resolve(process.env.QA_ARTIFACT_DIR || path.join(projectRoot, 'work/java-edu-desktop-qa')); +const baseUrl = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; +const mode = process.env.JAVA_EDU_QA_MODE || 'all'; +const results = []; +const lessons = [...coreLessons, ...professionalLessons]; +const first = coreLessons.find(lesson => lesson.id === 'java-basics'); +const classLesson = coreLessons.find(lesson => coreExercises.find(exercise => exercise.id === lesson.exerciseId)?.mode === 'class'); +const project = professionalLessons.find(lesson => lesson.id === 'tx-proxy'); +const projectExercise = professionalExercises.find(exercise => exercise.lessonId === project.id); +const firstProject = professionalLessons[0]; +const firstProjectExercise = professionalExercises[0]; +const storageKey = 'java-edu:progress:v1'; +const preferencesKey = 'java-edu:editor-preferences:v1'; +const editor = page => page.locator('#code-editor [data-testid="code-editor-input"]'); + +async function eventually(read, expected, message, timeout = 8000) { + const deadline = Date.now() + timeout; + let value; + while (Date.now() < deadline) { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await new Promise(resolve => setTimeout(resolve, 80)); + } + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function capture(page, name, fullPage = false) { + await page.screenshot({ path: path.join(artifactRoot, `${name}.png`), fullPage, caret: 'initial' }); +} + +async function caseOf(name, page, test) { + const started = Date.now(); + console.log(`START ${name}`); + try { + await test(); + results.push({ name, status: 'PASS', durationMs: Date.now() - started }); + console.log(`PASS ${name}`); + return true; + } catch (error) { + await capture(page, `failure-${name}`, true).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started }); + console.error(`FAIL ${name}: ${error.stack}`); + return false; + } +} + +async function step(page, value) { + await page.locator(`[data-step="${value}"]`).click(); + await eventually(() => page.locator(`[data-step="${value}"]`).getAttribute('aria-selected'), 'true', 'selected learning stage'); +} + +async function openLesson(page, lesson, stage = 'lab') { + // Hash navigation stays inside the authenticated Electron document. + await page.evaluate(hash => { location.hash = hash; }, `lesson=${lesson.id}&step=${stage}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), 'requested lesson title'); + await eventually(() => page.locator(`[data-step="${stage}"]`).getAttribute('aria-selected'), 'true', 'requested lesson stage'); +} + +async function clipboardMock(page) { + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async value => { window.qaClipboardText = value; } } }); + }); +} + +async function source(page) { + await page.evaluate(() => { window.qaClipboardText = undefined; }); + await page.locator('#copy-code').click(); + return eventually(() => page.evaluate(() => window.qaClipboardText), value => typeof value === 'string', 'complete source copy'); +} + +async function quiz(page, lesson) { + await step(page, 'quiz'); + for (const question of lesson.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await eventually(() => badge(page, 'quiz'), 'true', 'quiz achievement'); +} + +const badge = (page, type) => page.locator(`#lesson-achievements [data-achievement="${type}"]`).getAttribute('data-complete'); +async function noOverflow(page) { + const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) })); + assert.ok(dimensions.scroll <= dimensions.width + 1, `page overflow ${JSON.stringify(dimensions)}`); +} + +async function chooseFile(page, file, example = false) { + await page.locator(`${example ? '#lesson-panel' : '#project-file-tabs'} [data-file-path="${file}"]`).click(); +} + +async function readonlyPom(page, exercise) { + const expected = exercise.files.find(file => file.path === 'pom.xml').source; + await chooseFile(page, 'pom.xml'); + assert.equal(await editor(page).getAttribute('aria-readonly'), 'true'); + assert.equal(await page.locator('#indent-code').isDisabled(), true); + assert.equal(await page.locator('#lab-reset').isDisabled(), true); + await editor(page).focus(); + await page.keyboard.press('ControlOrMeta+Home'); + await page.keyboard.insertText('QA_FORBIDDEN_EDIT'); + assert.equal(await source(page), expected, 'readonly POM rejects actual keyboard insertion'); +} + +async function webSuite() { + const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); + const requests = []; + await context.route('**/api/**', async route => { + const url = new URL(route.request().url()); + requests.push({ path: url.pathname, method: route.request().method() }); + if (url.pathname === '/api/runtime') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ provider: 'Wandbox', available: true, runtime: { javaVersion: 21 }, supportedTargets: [8, 11, 17, 21], notice: 'QA mock runtime only' }) }); + return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { code: 'qa_forbidden_execution', message: 'Web professional QA must not submit source.' } }) }); + }); + const page = await context.newPage(); + page.setDefaultTimeout(8000); + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error.message)); + try { + await page.goto(baseUrl); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + await clipboardMock(page); + await caseOf('web-combined-catalog-version-gates', page, async () => { + assert.equal(lessons.length, 34); + assert.equal(await page.evaluate(() => typeof window.javaEduDesktop), 'undefined'); + for (const version of [8, 11, 17, 21]) { + await page.locator('#version-select').selectOption(String(version)); + const visible = await page.locator('#lesson-list [data-lesson-id]').evaluateAll(nodes => nodes.map(node => node.dataset.lessonId).sort()); + assert.deepEqual(visible, lessons.filter(lesson => lesson.minVersion <= version).map(lesson => lesson.id).sort()); + if (version < 17) assert.equal(visible.some(id => professionalLessons.some(lesson => lesson.id === id)), false); + } + await page.locator('[data-track="project"]').click(); + assert.deepEqual(await page.locator('#lesson-list [data-lesson-id]').evaluateAll(nodes => nodes.map(node => node.dataset.lessonId).sort()), professionalLessons.map(lesson => lesson.id).sort()); + await page.locator('#version-select').selectOption('8'); + assert.equal(await page.locator('#lesson-list [data-lesson-id]').count(), 0); + await page.locator('#version-select').selectOption('21'); + await page.locator('[data-track="all"]').click(); + await openLesson(page, firstProject, 'theory'); + await capture(page, 'web-professional-theory'); + }); + + await caseOf('web-all-professional-stages-quizzes-and-disabled-execution', page, async () => { + for (const lesson of professionalLessons) { + await openLesson(page, lesson, 'theory'); + assert.ok(await page.locator('#source-list a[href^="https://"]').count() > 0, `${lesson.id} official references`); + await step(page, 'example'); + for (const file of lesson.example.files) { + await chooseFile(page, file.path, true); + assert.equal(await page.locator('#example-file-panel code').textContent(), file.code, `${lesson.id}/${file.path} visible example`); + } + await quiz(page, lesson); + assert.equal(await badge(page, 'lab'), 'false', 'quiz alone does not certify an unexecuted project'); + await step(page, 'practice'); + assert.ok((await page.locator('#lesson-panel').innerText()).length > 100); + await step(page, 'lab'); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + assert.equal(await page.locator('#prepare-project').count(), 0, 'web cannot prepare local Maven'); + assert.match(await page.locator('#runtime-info').textContent(), /Windows/); + assert.equal(await page.locator('#project-download').getAttribute('href'), `/downloads/${lesson.id}.zip`); + } + assert.deepEqual(requests.filter(request => request.path !== '/api/runtime'), [], 'web project browsing does not call execute or local APIs'); + }); + + await caseOf('web-files-readonly-draft-isolation-and-download', page, async () => { + await openLesson(page, firstProject); + const file = firstProjectExercise.files.find(item => item.editable); + const draft = `${file.source}\n// 웹 초안 보존\n\n`; + await chooseFile(page, file.path); + await editor(page).fill(draft); + await readonlyPom(page, firstProjectExercise); + await chooseFile(page, file.path); + assert.equal(await source(page), draft); + await openLesson(page, project); + await openLesson(page, firstProject); + assert.equal(await source(page), draft, 'draft survives another project'); + await page.locator('#editor-theme-select').selectOption('light'); + await page.reload(); + await editor(page).waitFor({ state: 'visible' }); + await clipboardMock(page); + assert.equal(await source(page), draft); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'light'); + const pending = page.waitForEvent('download'); + await page.locator('#project-download').click(); + const download = await pending; + const filename = path.join(artifactRoot, `download-${firstProject.id}.zip`); + await download.saveAs(filename); + const archive = unzipSync(await readFile(filename)); + assert.match(strFromU8(archive['README.txt']), /Maven/); + assert.equal(strFromU8(archive[`lessons/${firstProject.id}/starter/${file.path}`]), file.source, 'download remains the trusted starter, not an implicitly uploaded draft'); + assert.equal(await source(page), draft, 'download leaves the draft untouched'); + await page.locator('.editor-shell').scrollIntoViewIfNeeded(); + await capture(page, 'web-project-editor'); + await readonlyPom(page, firstProjectExercise); + await capture(page, 'web-project-readonly-pom'); + assert.deepEqual(requests.filter(request => request.path !== '/api/runtime'), []); + }); + + await caseOf('web-professional-responsive-and-text-enlargement', page, async () => { + const capstone = professionalLessons.find(lesson => lesson.id === 'orders-capstone'); + await openLesson(page, capstone); + const capstoneFiles = professionalExercises.find(exercise => exercise.lessonId === capstone.id).files; + for (const theme of ['dark', 'light', 'contrast']) { + await page.locator('#editor-theme-select').selectOption(theme); + for (const [suffix, file] of [['java', capstoneFiles.find(file => file.editable)], ['xml', capstoneFiles.find(file => file.path === 'pom.xml')], ['sql', capstoneFiles.find(file => file.path.endsWith('.sql'))]]) { + await chooseFile(page, file.path); + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, `web-project-${theme}-${suffix}`); + } + } + for (const viewport of [{ width: 320, height: 720 }, { width: 390, height: 844 }, { width: 768, height: 1024 }, { width: 1440, height: 900 }, { width: 844, height: 390 }]) { + await page.setViewportSize(viewport); + await noOverflow(page); + for (const file of capstoneFiles) { + await chooseFile(page, file.path); + await noOverflow(page); + } + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, `web-project-${viewport.width}x${viewport.height}`); + } + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await noOverflow(page); + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, 'web-project-mobile-200-files'); + await page.locator('#runtime-info').scrollIntoViewIfNeeded(); + await capture(page, 'web-project-mobile-200-runtime'); + await page.setViewportSize({ width: 320, height: 720 }); + await noOverflow(page); + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, 'web-project-320-200'); + await page.locator('#desktop-install-open').click(); + await page.locator('#install-dialog').waitFor({ state: 'visible' }); + await noOverflow(page); + await capture(page, 'web-install-dialog-320-200'); + await page.keyboard.press('Escape'); + assert.deepEqual(pageErrors, []); + }); + } finally { await context.close(); await browser.close(); } +} + +async function launchDesktop(userData) { + const executablePath = process.env.JAVA_EDU_ELECTRON_PATH || path.join(projectRoot, 'node_modules/electron/dist/electron.exe'); + await access(executablePath); // Fail explicitly; never let electron's package import download a missing binary. + const env = { ...process.env, JAVA_EDU_QA_DATA: userData }; + env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; + delete env.ELECTRON_RUN_AS_NODE; + const app = await electron.launch({ executablePath, args: [path.resolve(projectRoot)], env, timeout: 60000 }); + const page = await app.firstWindow(); + page.setDefaultTimeout(10000); + await page.locator('#workspace-grid').waitFor({ state: 'visible', timeout: 45000 }); + await clipboardMock(page); + return { app, page }; +} + +async function mockDirectoryDialog(app, chosenPath, canceled = false) { + await app.evaluate(({ dialog }, value) => { + dialog.showOpenDialog = async () => ({ canceled: value.canceled, filePaths: value.canceled ? [] : [value.chosenPath] }); + }, { chosenPath, canceled }); +} + +async function settingsDialog(page) { + await page.locator('#desktop-settings-open').click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'visible' }); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'settings metadata loaded'); +} + +async function closeSettings(page) { + await page.locator('#desktop-settings-dialog [data-close-dialog="desktop-settings-dialog"]').first().click(); +} + +async function resultAfter(page, button, timeout = 45000, runningCapture) { + const response = page.waitForResponse(response => new URL(response.url()).pathname === (button === '#prepare-project' ? '/api/local/prepare' : '/api/execute') && response.request().method() === 'POST', { timeout }); + void response.catch(() => {}); + await page.locator(button).click(); + if (runningCapture) { + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, runningCapture); + } + const received = await response; + const body = await received.json(); + console.log('LOCAL_RESULT', JSON.stringify({ exerciseId: received.request().postDataJSON().exerciseId, status: body.status, runtimeVersion: body.runtimeVersion, targetVersion: body.targetVersion, passed: body.passed, total: body.total, error: body.error })); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), value => value !== 'running', 'UI settles after local response'); + return { body, request: received.request().postDataJSON(), httpStatus: received.status() }; +} + +async function desktopSuite() { + const jdk8 = process.env.JAVA_EDU_JDK_8; + const jdk21 = process.env.JAVA_EDU_JDK_21; + const maven = process.env.JAVA_EDU_MAVEN_HOME; + if (!jdk8 || !jdk21 || !maven) throw new Error('Desktop QA requires JAVA_EDU_JDK_8, JAVA_EDU_JDK_21 and JAVA_EDU_MAVEN_HOME pointing to approved installed tools.'); + const userData = await mkdtemp(path.join(artifactRoot, 'user-data-한글 & 25%-')); + console.log(`ISOLATED_USER_DATA ${userData}`); + let { app, page } = await launchDesktop(userData); + const solutions = JSON.parse(await readFile(path.join(projectRoot, 'examples/solutions.json'), 'utf8')); + const initialOrigin = new URL(page.url()).origin; + const executionRequests = []; + const observe = currentPage => currentPage.on('request', request => { if (new URL(request.url()).pathname === '/api/execute') executionRequests.push(request.postDataJSON()); }); + observe(page); + let savedCore; + let savedProject; + try { + const initial = await caseOf('desktop-first-open-tools-consent-and-isolation', page, async () => { + assert.equal(await page.evaluate(() => window.javaEduDesktop.platform), 'win32'); + assert.equal(await app.evaluate(({ app }) => app.getPath('userData')), userData); + const preferences = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.getLastWebPreferences()); + assert.equal(preferences.contextIsolation, true); + assert.equal(preferences.nodeIntegration, false); + assert.equal(preferences.sandbox, true); + await openLesson(page, first); + const runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + assert.equal(runtime.mode, 'local'); + assert.equal(runtime.available, false); + assert.equal(runtime.consent.accepted, false); + assert.equal(runtime.runtimes.some(item => item.available), false); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.match(await page.locator('#runtime-info').textContent(), /JDK/); + await settingsDialog(page); + assert.equal(await page.locator('#execution-consent').isChecked(), false); + await capture(page, 'desktop-first-settings'); + await mockDirectoryDialog(app, '', true); + await page.locator('#jdk-register-8').click(); + await eventually(() => page.locator('#jdk-register-8').isEnabled(), true, 'cancelled folder selection settles'); + assert.equal((await page.evaluate(() => window.javaEduDesktop.getSettings())).runtimes.some(item => item.available), false); + assert.equal(executionRequests.length, 0, 'startup and cancelled tool selection run no student code'); + }); + if (!initial) return; + + const configured = await caseOf('desktop-tool-registration-real-probes-and-explicit-consent', page, async () => { + await mockDirectoryDialog(app, jdk21); + await page.locator('#jdk-register-8').click(); + await eventually(() => page.locator('#jdk-register-8').isEnabled(), true, 'wrong-version tool inspection settles', 30000); + assert.equal((await page.evaluate(() => window.javaEduDesktop.getSettings())).runtimes.find(item => item.javaVersion === 8).available, false); + assert.match(await page.locator('#desktop-settings-status').textContent(), /8/); + for (const [version, directory] of [[8, jdk8], [21, jdk21]]) { + await mockDirectoryDialog(app, directory); + await page.locator(`#jdk-register-${version}`).click(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).runtimes.find(item => item.javaVersion === version).available, true, `JDK ${version} is actually inspected`, 30000); + await eventually(() => page.locator(`#jdk-register-${version}`).isEnabled(), true, 'settings controls settle'); + } + await mockDirectoryDialog(app, maven); + await page.locator('#maven-register').click(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).maven.available, true, 'Maven distribution registered'); + assert.equal((await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, false); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'explicit consent persisted'); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'consent saving settles'); + await capture(page, 'desktop-configured-settings'); + await closeSettings(page); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'registered tools and consent enable core execution'); + assert.equal(executionRequests.length, 0, 'settings do not execute learner code'); + }); + if (!configured) return; + + await caseOf('desktop-core-method-real-jvm8-and21-run-submit', page, async () => { + await editor(page).fill(solutions[first.exerciseId]); + await page.locator('#runtime-version-select').selectOption('8'); + const sample = await resultAfter(page, '#run-code', 60000, 'desktop-core-running'); + assert.equal(sample.body.status, 'passed'); + assert.equal(sample.body.runtimeVersion, 8); + assert.equal(sample.body.targetVersion, 8); + assert.equal(await badge(page, 'lab'), 'false', 'run is not submit'); + await quiz(page, first); + await step(page, 'lab'); + const submission8 = await resultAfter(page, '#submit-code'); + assert.equal(submission8.body.status, 'passed'); + assert.equal(submission8.body.runtimeVersion, 8); + assert.ok(submission8.body.total > sample.body.total); + assert.equal(await badge(page, 'lab'), 'true'); + await page.locator('#runtime-version-select').selectOption('21'); + const submission21 = await resultAfter(page, '#submit-code'); + assert.equal(submission21.body.status, 'passed'); + assert.equal(submission21.body.runtimeVersion, 21); + assert.equal(submission21.request.source, solutions[first.exerciseId]); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-core-passed-jvm21'); + }); + + await caseOf('desktop-core-class-and-real-failure-results', page, async () => { + await openLesson(page, classLesson); + await editor(page).fill(solutions[classLesson.exerciseId]); + const classResult = await resultAfter(page, '#submit-code'); + assert.equal(classResult.body.status, 'passed'); + assert.equal(classResult.body.runtimeVersion, 21); + await openLesson(page, first); + await editor(page).fill('class Solution { public boolean sameCode(String a,String b) { return 없는변수; } }'); + const compile = await resultAfter(page, '#submit-code'); + assert.equal(compile.body.status, 'compile_error'); + assert.match(compile.body.compilerOutput, /없는변수/); + assert.equal(await badge(page, 'lab'), 'false'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-korean-compiler-error'); + await editor(page).fill(coreExercises.find(item => item.id === first.exerciseId).template); + const failed = await resultAfter(page, '#submit-code'); + assert.equal(failed.body.status, 'failed'); + assert.ok(failed.body.passed < failed.body.total); + await editor(page).fill('class Solution { public boolean sameCode(String a,String b) { System.exit(7); return false; } }'); + const runtime = await resultAfter(page, '#run-code'); + assert.equal(runtime.body.status, 'runtime_error'); + }); + + await caseOf('desktop-consent-revocation-blocks-code-without-losing-draft', page, async () => { + const before = executionRequests.length; + const draft = `${solutions[first.exerciseId]}\n// 동의 철회 중에도 유지`; + await editor(page).fill(draft); + await settingsDialog(page); + await page.locator('#execution-consent').uncheck(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, false, 'consent revocation is saved'); + await closeSettings(page); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + assert.equal(await source(page), draft); + await settingsDialog(page); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'consent can be granted again'); + await closeSettings(page); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'run becomes available after explicit consent'); + assert.equal(executionRequests.length, before, 'consent changes never run learner code automatically'); + }); + + await caseOf('desktop-running-java-cancel-and-draft-preservation', page, async () => { + const cancellable = 'class Solution { public boolean sameCode(String a,String b) { try { new java.io.File("qa-cancel-started.txt").createNewFile(); } catch(java.io.IOException e) { throw new RuntimeException(e); } while(true) {} } }'; + await editor(page).fill(cancellable); + const before = executionRequests.length; + await page.locator('#run-code').click(); + await eventually(() => executionRequests.length, before + 1, 'actual local execution requested'); + const jobsRoot = path.join(userData, 'java-edu/jobs'); + const job = await eventually(async () => { + for (const entry of await readdir(jobsRoot).catch(() => [])) { + const directory = path.join(jobsRoot, entry); + if (entry.startsWith('job-') && await access(path.join(directory, 'qa-cancel-started.txt')).then(() => true, () => false)) return directory; + } + return null; + }, value => Boolean(value), 'Java itself writes a marker before cancellation', 30000); + await page.locator('#cancel-execution').click(); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), 'cancelled', 'user cancellation is visible'); + await eventually(() => access(job).then(() => true, () => false), false, 'cancelled job workspace is cleaned', 10000); + assert.equal(await source(page), cancellable); + assert.equal(await badge(page, 'lab'), 'false'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-cancelled'); + }); + + const prepared = await caseOf('desktop-spring-h2-prepare-is-required-and-not-achievement', page, async () => { + await openLesson(page, project); + await eventually(() => page.locator('#prepare-project').isEnabled(), true, 'project metadata ready'); + assert.equal(await page.locator('#run-code').isDisabled(), true); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + assert.match(await page.locator('#execution-state').textContent(), /준비/); + const before = await source(page); + await readonlyPom(page, projectExercise); + await chooseFile(page, projectExercise.files.find(file => file.editable).path); + const preparation = await resultAfter(page, '#prepare-project', 360000, 'desktop-spring-h2-preparing'); + assert.equal(preparation.body.status, 'passed', preparation.body.stderr || preparation.body.stdout); + assert.equal(preparation.body.prepared, true); + assert.equal(preparation.body.runtimeVersion, 21); + assert.equal(preparation.body.targetVersion, 17); + assert.equal(preparation.request.files, undefined, 'prepare does not submit the learner draft'); + assert.equal(preparation.request.source, undefined); + assert.equal(await badge(page, 'lab'), 'false', 'reference preparation is not learner completion'); + assert.equal(await source(page), before, 'preparation leaves learner source untouched'); + await eventually(() => page.locator('#run-code').isEnabled(), true, 'successful preparation enables project execution'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-spring-h2-prepared'); + }); + + if (prepared) await caseOf('desktop-spring-h2-reference-files-real-submit', page, async () => { + const expectedFiles = {}; + for (const file of project.professionalExercise.files.filter(file => file.editable)) { + const text = await readFile(path.join(projectRoot, 'curriculum/professional', file.referencePath), 'utf8'); + expectedFiles[file.path] = text; + await chooseFile(page, file.path); + await editor(page).fill(text); + } + await quiz(page, project); + await step(page, 'lab'); + const submitted = await resultAfter(page, '#submit-code', 150000); + assert.equal(submitted.body.status, 'passed', submitted.body.stderr || submitted.body.stdout); + assert.equal(submitted.body.runtimeVersion, 21); + assert.equal(submitted.body.targetVersion, 17); + assert.ok(submitted.body.checks.length > 0 && submitted.body.checks.every(check => check.passed)); + assert.deepEqual(submitted.request.files, expectedFiles); + assert.equal(Object.hasOwn(submitted.request.files, 'pom.xml'), false); + assert.equal(await badge(page, 'lab'), 'true'); + await page.locator('#execution-output').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-spring-h2-submitted'); + }); + + await caseOf('desktop-restart-new-port-preserves-progress-theme-and-drafts', page, async () => { + await openLesson(page, first); + savedCore = `${solutions[first.exerciseId]}\n// 재시작 초안\n\n`; + await editor(page).fill(savedCore); + await page.locator('#editor-theme-select').selectOption('contrast'); + await openLesson(page, project); + const editable = projectExercise.files.find(file => file.editable); + await chooseFile(page, editable.path); + savedProject = `${editable.source}\n// 프로젝트 재시작 초안\n`; + await editor(page).fill(savedProject); + await chooseFile(page, 'pom.xml'); + const before = await page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [storageKey, preferencesKey]); + assert.ok(before[storageKey].includes('재시작 초안')); + await app.close(); + ({ app, page } = await launchDesktop(userData)); + observe(page); + const newOrigin = new URL(page.url()).origin; + assert.notEqual(newOrigin, initialOrigin, 'actual app restart binds a different port'); + const restored = await page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [storageKey, preferencesKey]); + assert.equal(restored[preferencesKey], before[preferencesKey]); + const beforeProgress = JSON.parse(before[storageKey]); + const afterProgress = JSON.parse(restored[storageKey]); + for (const key of ['quizPassed', 'drafts', 'fileDrafts']) assert.deepEqual(afterProgress[key], beforeProgress[key], `${key} survives port change`); + await openLesson(page, first); + assert.equal(await source(page), savedCore); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); + assert.equal(await badge(page, 'quiz'), 'true'); + await openLesson(page, project); + await chooseFile(page, editable.path); + assert.equal(await source(page), savedProject); + assert.equal(await page.locator('#run-code').isDisabled(), true, 'new app session requires fresh project preparation'); + assert.equal(await page.locator('#prepare-project').getAttribute('data-prepared'), 'false'); + await page.locator('#runtime-info').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-restarted-project'); + await page.locator('html').evaluate(element => { element.style.fontSize = '200%'; }); + await noOverflow(page); + await settingsDialog(page); + await capture(page, 'desktop-settings-200'); + await noOverflow(page); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(720, 560)); + await eventually(() => page.evaluate(() => innerWidth), width => width < 800, 'native app minimum window size'); + await noOverflow(page); + await capture(page, 'desktop-settings-minimum-200'); + await page.locator('#execution-consent').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-settings-minimum-200-consent'); + console.log('APP_VIEWPORT', JSON.stringify(await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootFont: getComputedStyle(document.documentElement).fontSize })))); + await closeSettings(page); + await page.locator('#project-file-tabs').scrollIntoViewIfNeeded(); + await capture(page, 'desktop-project-200'); + }); + } finally { await app.close().catch(() => {}); } +} + +await mkdir(artifactRoot, { recursive: true }); +try { + if (!['all', 'web', 'desktop'].includes(mode)) throw new Error('JAVA_EDU_QA_MODE must be all, web, or desktop.'); + if (mode !== 'desktop') await webSuite(); + if (mode !== 'web') await desktopSuite(); +} catch (error) { + results.push({ name: 'environment', status: 'BLOCKED', message: error.message }); + console.error(error.stack); +} +const report = { recordedAt: new Date().toISOString(), mode, node: process.version, artifacts: artifactRoot, results }; +await writeFile(path.join(artifactRoot, `results-${mode}-${Date.now()}.json`), JSON.stringify(report, null, 2)); +console.log(JSON.stringify(report, null, 2)); +if (!results.length || results.some(result => result.status !== 'PASS')) process.exitCode = 1; diff --git a/tests/editor.test.mjs b/tests/editor.test.mjs new file mode 100644 index 0000000..c06b379 --- /dev/null +++ b/tests/editor.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { javaHighlightSpans } from '../dist/editor.js'; + +function tokens(source) { + const spans = javaHighlightSpans(source); + let end = 0; + for (const span of spans) { + assert.ok(Number.isInteger(span.from) && Number.isInteger(span.to)); + assert.ok(span.from >= end && span.to > span.from && span.to <= source.length, 'highlight spans stay ordered and within the source'); + assert.match(span.classes, /^tok-[a-z]+(?: tok-[a-z]+)*$/); + end = span.to; + } + return spans.map(span => ({ text: source.slice(span.from, span.to), classes: span.classes })); +} + +function contains(entries, text, category) { + assert.ok(entries.some(entry => entry.text === text && entry.classes.split(' ').includes(`tok-${category}`)), `${JSON.stringify(text)} is highlighted as ${category}`); +} + +test('Java syntax distinguishes class declarations, types, variables, and method declarations and calls', () => { + const entries = tokens('class Palette { String label = "한글"; int count = 7; void run() { count++; run(); } }'); + for (const [text, category] of [ + ['class', 'keyword'], ['Palette', 'class'], ['String', 'type'], ['int', 'type'], + ['label', 'variable'], ['count', 'variable'], ['run', 'method'], ['"한글"', 'string'], ['7', 'number'], + ]) contains(entries, text, category); + assert.equal(entries.filter(entry => entry.text === 'run' && entry.classes === 'tok-method').length, 2, 'declaration and invocation share the method category'); +}); + +test('Comments and string literals keep Java-looking and HTML-looking text in their own category', () => { + const comment = '// class Fake { int unsafe = 1; }'; + const literal = '" class Fake"'; + const entries = tokens(`class Safe { ${comment}\nString value = ${literal}; }`); + contains(entries, comment, 'comment'); + contains(entries, literal, 'string'); + assert.equal(entries.some(entry => entry.text === 'Fake' && entry.classes === 'tok-class'), false); +}); + +test('Supported lesson syntax keeps class and type categories for generic, record, sealed, and text-block examples', () => { + const entries = tokens('sealed interface Shape permits Box {}\nrecord Box(T value) implements Shape {}\nclass Demo { String text = """\n한글\n"""; }'); + for (const [text, category] of [['Shape', 'class'], ['Box', 'class'], ['Demo', 'class'], ['sealed', 'keyword'], ['record', 'keyword'], ['String', 'type']]) contains(entries, text, category); + assert.ok(entries.some(entry => entry.classes === 'tok-string' && entry.text.includes('한글'))); +}); + +test('Empty, incomplete, Unicode, and trailing-newline source yields bounded syntax spans without mutation', () => { + for (const source of ['', '\n\n', 'class', 'class 한글 { String 문자 = "😀', 'class A {\r\n// 끝\r\n}\r\n', ' '.repeat(1000)]) { + const original = source; + tokens(source); + assert.equal(source, original); + } +}); diff --git a/tests/executor.test.mjs b/tests/executor.test.mjs new file mode 100644 index 0000000..a9f909d --- /dev/null +++ b/tests/executor.test.mjs @@ -0,0 +1,367 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { buildRunner, createExecutionState, handleApi, MAX_REQUEST_BYTES, parseCheckOutput } from '../server/executor.mjs'; +import { COMPILER, createProvider, MAX_PROVIDER_RESPONSE_BYTES, ProviderError } from '../server/provider.mjs'; +import { exerciseDefinitions } from '../server/exercises.mjs'; + +const localEnv = { REQUIRE_IDENTITY: false }; +const definitions = [{ + id: 'test-lab', lessonId: 'test', minVersion: 8, template: 'class Solution {}', mode: 'method', + sampleChecks: [{ name: 'sample', expression: 'new Solution().value() == 1' }], + checks: [{ name: 'first', expression: 'new Solution().value() == 1' }, { name: 'second', expression: 'new Solution().value() > 0' }], +}]; +const compilerList = [{ name: COMPILER, language: 'Java', 'compiler-option-raw': true }]; +const nonce = 'abcdef0123456789abcdef0123456789'; + +function request(body = {}, init = {}) { + return new Request('https://java-edu.example/api/execute', { + method: 'POST', headers: { 'Content-Type': 'application/json', ...init.headers }, + body: JSON.stringify({ exerciseId: 'test-lab', source: 'class Solution { int value() { return 1; } }', action: 'submit', requestId: crypto.randomUUID(), ...body }), + ...init, ...(init.headers ? { headers: { 'Content-Type': 'application/json', ...init.headers } } : {}), + }); +} + +function rawResult(overrides = {}) { + return { status: '0', signal: '', compiler_output: '', compiler_error: '', compiler_message: '', program_output: '', program_error: '', program_message: '', ...overrides }; +} + +function protocol(payload, verdicts) { + const prefix = payload.code.match(/__JAVA_EDU_[a-f0-9]{32}__/)[0]; + const count = [...payload.code.matchAll(/CHECK:\d+:\"/g)].length; + return `${prefix}START\n${Array.from({ length: count }, (_, index) => `${prefix}CHECK:${index}:${verdicts?.[index] ?? 'true'}`).join('\n')}\n${prefix}END\n`; +} + +function harness(compilation = (payload) => rawResult({ program_output: protocol(payload) })) { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, init }); + if (url.endsWith('/list.json')) return Response.json(compilerList); + const payload = JSON.parse(init.body); + const result = await compilation(payload, init); + return result instanceof Response ? result : Response.json(result); + }; + const options = { fetchImpl, state: createExecutionState(), exercises: definitions }; + return { calls, options, call: (input, env = localEnv) => handleApi(input, env, options) }; +} + +test('default authentication fails closed before any upstream call', async () => { + const h = harness(); + assert.equal((await h.call(request(), {})).status, 401); + assert.equal(h.calls.length, 0); + assert.equal((await h.call(request({}, { headers: { 'oai-authenticated-user-id': 'owner-id' } }), { REQUIRE_IDENTITY: true })).status, 200); + assert.equal((await h.call(request(), { REQUIRE_IDENTITY: true, TRUSTED_IDENTITY_HEADER: 'user-supplied-id' })).status, 503); +}); + +test('foreign origins and null origins cannot trigger execution', async () => { + const h = harness(); + for (const origin of ['https://other.example', 'null', 'https://java-edu.example.evil']) { + assert.equal((await h.call(request({}, { headers: { Origin: origin } }))).status, 403); + } + assert.equal((await h.call(request({}, { headers: { 'Sec-Fetch-Site': 'cross-site' } }))).status, 403); + assert.equal(h.calls.length, 0); + assert.equal((await h.call(request({}, { headers: { Origin: 'https://java-edu.example' } }))).status, 200); +}); + +test('invalid JSON, fields, method, content type and unknown exercise reject before provider', async () => { + const h = harness(); + const cases = [ + [new Request('https://java-edu.example/api/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{broken' }), 400], + [request({ requestId: '' }), 400], + [request({ requestId: 123 }), 400], + [request({ exerciseId: 'unknown-lab' }), 404], + [request({ action: 'shell' }), 400], + [request({ source: ' ' }), 400], + [request({ source: '\u0000' }), 400], + [request({}, { headers: { 'Content-Type': 'text/plain' } }), 415], + [request({}, { headers: { 'Content-Encoding': 'gzip' } }), 415], + [new Request('https://java-edu.example/api/execute'), 405], + [new Request('https://java-edu.example/api/unknown'), 404], + ]; + for (const [input, expected] of cases) assert.equal((await h.call(input)).status, expected); + assert.equal(h.calls.length, 0); +}); + +test('UTF-8 request limit holds with missing or forged Content-Length', async () => { + const h = harness(); + for (const headers of [{}, { 'Content-Length': '1' }]) { + const response = await h.call(request({ source: '가'.repeat(12_000) }, { headers })); + assert.equal(response.status, 413); + } + const body = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(MAX_REQUEST_BYTES + 1).fill(32)); controller.close(); } }); + const streamed = new Request('https://java-edu.example/api/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, duplex: 'half' }); + assert.equal((await h.call(streamed)).status, 413); + assert.equal(h.calls.length, 0); +}); + +test('compile options, endpoint, tests and privacy fields are server controlled', async () => { + const h = harness(); + const response = await h.call(request({ + compiler: 'bash', targetVersion: 21, checks: [{ expression: 'true' }], + url: 'http://localhost/admin', options: '--evil', save: true, + }, { headers: { Cookie: 'secret=session', Authorization: 'Bearer private', 'oai-authenticated-user-id': 'private-user' } })); + const result = await response.json(); + assert.equal(result.status, 'passed'); + assert.equal(result.targetVersion, 8); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.total, 2); + const call = h.calls.find((item) => item.init.method === 'POST'); + assert.equal(call.url, 'https://wandbox.org/api/compile.json'); + assert.equal(call.init.redirect, 'error'); + const sent = JSON.parse(call.init.body); + assert.deepEqual(Object.keys(sent).sort(), ['code', 'compiler', 'compiler-option-raw', 'options', 'save', 'stdin'].sort()); + assert.equal(sent.compiler, COMPILER); + assert.equal(sent['compiler-option-raw'], '--release\n8'); + assert.equal(sent.save, false); + assert.match(sent.code, /new Solution\(\)\.value\(\) > 0/); + assert.deepEqual(call.init.headers, { 'Content-Type': 'application/json', Accept: 'application/json' }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.has('access-control-allow-origin'), false); +}); + +test('run uses sample checks, submit uses full checks and nonce changes per request', async () => { + const h = harness(); + const sample = await (await h.call(request({ action: 'run' }))).json(); + const submitted = await (await h.call(request())).json(); + assert.equal(sample.total, 1); + assert.equal(submitted.total, 2); + const nonces = h.calls.filter((call) => call.init.method === 'POST').map((call) => JSON.parse(call.init.body).code.match(/__JAVA_EDU_([a-f0-9]{32})__/)[1]); + assert.notEqual(nonces[0], nonces[1]); +}); + +test('javac warnings do not turn successful execution into a compile error', async () => { + const h = harness((payload) => rawResult({ compiler_error: 'warning: source value 8 is obsolete\n', compiler_message: 'warning: source value 8 is obsolete\n', program_output: protocol(payload) })); + const result = await (await h.call(request())).json(); + assert.equal(result.status, 'passed'); + assert.match(result.compilerOutput, /warning/); +}); + +test('failing checks remain failed even when arbitrary stdout claims success', async () => { + const h = harness((payload) => rawResult({ program_output: `{"passed":999,"total":999}\n${protocol(payload, ['true', 'false'])}ALL PASSED\n` })); + const result = await (await h.call(request())).json(); + assert.equal(result.status, 'failed'); + assert.equal(result.passed, 1); + assert.equal(result.total, 2); + assert.match(result.stdout, /ALL PASSED/); +}); + +test('wrong nonce, truncated protocol, duplicate markers and out-of-order checks cannot pass', () => { + const prefix = `__JAVA_EDU_${nonce}__`; + const correct = `${prefix}START\n${prefix}CHECK:0:true\n${prefix}CHECK:1:true\n${prefix}END\n`; + assert.equal(parseCheckOutput(correct, definitions[0].checks, nonce).complete, true); + const invalid = [ + correct.replaceAll(nonce, '0'.repeat(32)), + correct.replace(`${prefix}END\n`, ''), + `${correct}${prefix}CHECK:0:true\n`, + correct.replace('CHECK:0:true', 'CHECK:1:true'), + correct.replace('CHECK:0:true', 'CHECK:0:true EXTRA'), + correct.replace(`${prefix}START`, `${prefix}END`), + ]; + for (const output of invalid) { + const result = parseCheckOutput(output, definitions[0].checks, nonce); + assert.equal(result.complete, false); + assert.equal(result.checks.some((check) => check.passed), false); + } +}); + +test('missing runner completion is runtime_error, not a false positive', async () => { + const h = harness(() => rawResult({ program_output: 'passed 2/2\n' })); + const result = await (await h.call(request())).json(); + assert.equal(result.status, 'runtime_error'); + assert.equal(result.passed, 0); +}); + +test('protocol removal preserves learner output whitespace exactly', () => { + const prefix = `__JAVA_EDU_${nonce}__`; + const output = `\n${prefix}START\n kept\t\n\n${prefix}CHECK:0:true\n tail \n${prefix}CHECK:1:true\n\n${prefix}END\n`; + const parsed = parseCheckOutput(output, definitions[0].checks, nonce); + assert.equal(parsed.complete, true); + assert.equal(parsed.stdout, ' kept\t\n tail '); +}); + +test('compile error, runtime exception and per-check exception have distinct results', async () => { + const compile = harness(() => rawResult({ status: '1', compiler_error: 'prog.java:2: error: cannot find symbol' })); + assert.equal((await (await compile.call(request())).json()).status, 'compile_error'); + const runtime = harness(() => rawResult({ status: '1', compiler_error: 'warning: old target', program_error: 'Exception in thread main' })); + assert.equal((await (await runtime.call(request())).json()).status, 'runtime_error'); + const partial = harness((payload) => rawResult({ program_output: protocol(payload, ['ERROR', 'true']), program_error: '검사 1: java.lang.IllegalStateException' })); + const result = await (await partial.call(request())).json(); + assert.equal(result.status, 'failed'); + assert.equal(result.passed, 1); +}); + +test('nonzero process exit or signal cannot pass even after all check markers', async () => { + for (const failure of [{ status: '1' }, { signal: 'Killed' }]) { + const h = harness((payload) => rawResult({ ...failure, program_output: protocol(payload) })); + const result = await (await h.call(request())).json(); + assert.equal(result.status, 'runtime_error'); + assert.equal(result.passed, 0); + } +}); + +test('oversized provider output is stopped and never returned as a passed result', async () => { + const h = harness(() => new Response('x'.repeat(MAX_PROVIDER_RESPONSE_BYTES + 1), { headers: { 'Content-Length': '1' } })); + const response = await h.call(request()); + const result = await response.json(); + assert.equal(response.status, 502); + assert.equal(result.status, 'provider_error'); + assert.equal(result.error.code, 'provider_output_limit'); + assert.equal(result.stdout, ''); +}); + +test('provider malformed JSON, invalid output fields, rate limiting and server errors fail closed', async () => { + const upstreamResults = [ + new Response('not json'), + Response.json({ status: 0, program_output: 'passed' }), + Response.json({ status: '0', program_output: [], program_error: '' }), + new Response('rate limited', { status: 429 }), + new Response('private provider stack trace', { status: 500 }), + ]; + for (const upstream of upstreamResults) { + const h = harness(() => upstream); + const response = await h.call(request()); + const result = await response.json(); + assert.ok([502, 503].includes(response.status)); + assert.equal(result.status, 'provider_error'); + assert.equal(result.passed, 0); + assert.doesNotMatch(JSON.stringify(result), /private provider stack trace/); + assert.equal(h.calls.filter((call) => call.init.method === 'POST').length, 1); + } +}); + +test('upstream timeout aborts waiting, does not retry, and releases the local concurrency slot', async () => { + let hang = true; + const h = harness((payload) => hang ? new Promise(() => {}) : rawResult({ program_output: protocol(payload) })); + h.options.providerTimeoutMs = 25; + const response = await h.call(request()); + const result = await response.json(); + assert.equal(response.status, 504); + assert.equal(result.status, 'timeout'); + assert.match(result.stderr, /종료는 보장하지/); + assert.equal(h.calls.filter((call) => call.init.method === 'POST').length, 1); + hang = false; + assert.equal((await (await h.call(request())).json()).status, 'passed'); +}); + +test('client cancellation aborts waiting without promising upstream process cancellation', async () => { + const controller = new AbortController(); + const h = harness(() => { controller.abort(); return new Promise(() => {}); }); + const response = await h.call(request({}, { signal: controller.signal })); + const result = await response.json(); + assert.equal(response.status, 499); + assert.equal(result.error.code, 'request_cancelled'); + assert.equal(h.options.state.active, 0); +}); + +test('in-flight, duplicate and rolling-window requests do not fan out upstream', async () => { + let resolveCompilation; + const h = harness((payload) => new Promise((resolve) => { resolveCompilation = () => resolve(rawResult({ program_output: protocol(payload) })); })); + const first = h.call(request({ requestId: 'same-request' })); + while (!resolveCompilation) await new Promise((resolve) => setImmediate(resolve)); + assert.equal((await h.call(request({ requestId: 'other-request' }))).status, 429); + assert.equal((await h.call(request({ requestId: 'same-request' }))).status, 409); + resolveCompilation(); + assert.equal((await first).status, 200); + assert.equal((await h.call(request({ requestId: 'same-request' }))).status, 409); + assert.equal(h.calls.filter((call) => call.init.method === 'POST').length, 1); + + const rate = harness(); + let now = 100_000; + rate.options.now = () => now; + for (let index = 0; index < 6; index += 1) assert.equal((await rate.call(request({ requestId: `r-${index}` }))).status, 200); + assert.equal((await rate.call(request())).status, 429); + assert.equal(rate.calls.filter((call) => call.init.method === 'POST').length, 6); + now += 60_001; + assert.equal((await rate.call(request())).status, 200); +}); + +test('runtime availability reflects compiler inventory and caches only for its TTL', async () => { + let now = 100_000; + let listed = true; + let calls = 0; + const provider = createProvider({ now: () => now, fetchImpl: async () => { calls += 1; return Response.json(listed ? compilerList : []); } }); + const first = await provider.getRuntime(); + assert.equal(first.available, true); + assert.deepEqual(first.supportedTargets, [8, 11, 17, 21]); + assert.equal(first.runtime.javaVersion, 21); + listed = false; + assert.equal((await provider.getRuntime()).available, true); + assert.equal(calls, 1); + now += 300_001; + const missing = await provider.getRuntime(); + assert.equal(missing.available, false); + assert.deepEqual(missing.supportedTargets, []); + await assert.rejects(provider.compile('class Main {}', 8), (error) => error instanceof ProviderError && error.code === 'compiler_unavailable'); + assert.equal(calls, 2); + listed = true; + now += 15_001; + assert.equal((await provider.getRuntime()).available, true); +}); + +test('compiler without raw release support is unavailable; unsupported targets never fetch', async () => { + let calls = 0; + const provider = createProvider({ fetchImpl: async () => { calls += 1; return Response.json([{ ...compilerList[0], 'compiler-option-raw': false }]); } }); + await assert.rejects(provider.compile('class Main {}', 22), (error) => error.code === 'unsupported_target'); + assert.equal(calls, 0); + assert.equal((await provider.getRuntime()).available, false); +}); + +test('runner preserves learner source line numbers and supports Java 8 checks', () => { + const source = 'class Solution {\n int value() { return 1; }\n}'; + const code = buildRunner(source, definitions[0].checks, nonce); + assert.equal(code.slice(0, source.length), source); + assert.match(code, /class Main \{/); + assert.match(code, /java\.lang\.Throwable/); + assert.doesNotMatch(code, /public class Main/); + assert.throws(() => buildRunner(source, definitions[0].checks, 'invalid')); +}); + +test('every authored exercise has nonempty sample and submission checks with supported targets', () => { + assert.ok(exerciseDefinitions.length >= 1); + const ids = new Set(); + for (const definition of exerciseDefinitions) { + assert.equal(ids.has(definition.id), false); + ids.add(definition.id); + assert.ok([8, 11, 17, 21].includes(definition.minVersion)); + for (const checks of [definition.sampleChecks, definition.checks]) { + assert.ok(checks.length > 0); + assert.ok(buildRunner(definition.template, checks, nonce).length > definition.template.length); + } + } +}); + +test('live provider: actual Java 21 executes four release targets and reports failure cases', { skip: process.env.JAVA_EDU_LIVE !== '1' }, async (t) => { + // Opt-in only: transmits authored, non-sensitive exercise solutions to Wandbox. + // Six sequential POSTs, no retries, no parallelism. Never run in default CI. + const solutions = JSON.parse(await readFile(new URL('../examples/solutions.json', import.meta.url), 'utf8')); + const state = createExecutionState(); + for (const target of [8, 11, 17, 21]) { + await t.test(`--release ${target}, actual JVM 21`, async () => { + const definition = exerciseDefinitions.find((item) => item.minVersion === target); + assert.ok(definition, `Authored exercise for Java ${target} is required.`); + const response = await handleApi(request({ exerciseId: definition.id, source: solutions[definition.id] }), localEnv, { state }); + const result = await response.json(); + assert.equal(response.status, 200, JSON.stringify(result)); + assert.equal(result.status, 'passed', JSON.stringify(result)); + assert.equal(result.passed, definition.checks.length); + assert.equal(result.targetVersion, target); + assert.equal(result.runtimeVersion, 21); + console.info(`LIVE ${definition.id}: ${result.passed}/${result.total}, --release ${target}, JVM ${result.runtimeVersion}, ${result.durationMs} ms`); + }); + } + const definition = exerciseDefinitions.find((item) => item.id === 'java-basics-lab'); + await t.test('compiler diagnostic from actual javac', async () => { + const response = await handleApi(request({ exerciseId: definition.id, source: 'class Solution { THIS_IS_NOT_JAVA }' }), localEnv, { state }); + const result = await response.json(); + assert.equal(result.status, 'compile_error', JSON.stringify(result)); + assert.match(result.compilerOutput, /error/i); + console.info(`LIVE compile_error: ${result.durationMs} ms`); + }); + await t.test('wrong implementation fails actual checks', async () => { + const response = await handleApi(request({ exerciseId: definition.id, source: 'class Solution { public boolean sameCode(String a, String b) { return false; } }' }), localEnv, { state }); + const result = await response.json(); + assert.equal(result.status, 'failed', JSON.stringify(result)); + assert.ok(result.passed < result.total); + console.info(`LIVE failed: ${result.passed}/${result.total}, ${result.durationMs} ms`); + }); +}); diff --git a/tests/installed-desktop.mjs b/tests/installed-desktop.mjs new file mode 100644 index 0000000..aed8aa4 --- /dev/null +++ b/tests/installed-desktop.mjs @@ -0,0 +1,364 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { _electron as electron } from 'playwright'; +import { extractFile } from '@electron/asar'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +// Manual release gate, run only after the owner has authorized and completed the +// NSIS test installation. This script never installs/uninstalls software, changes +// registry values, redirects production userData, or deletes application data. +const root = path.resolve(fileURLToPath(new URL('../', import.meta.url))); +const installRoot = path.join(root, 'work/installed-java-edu-test'); +const executablePath = path.join(installRoot, 'Java Edu.exe'); +const artifacts = path.join(root, 'work/installed-desktop-qa'); +const preflightPath = path.join(artifacts, 'preflight.json'); +const jdk = process.env.JAVA_EDU_JDK_21; +const maven = process.env.JAVA_EDU_MAVEN_HOME; +const core = coreLessons.find(lesson => lesson.id === 'java-basics'); +const modern = coreLessons.find(lesson => lesson.id === 'java21'); +const project = professionalLessons.find(lesson => lesson.id === 'tx-proxy'); +const projectExercise = professionalExercises.find(exercise => exercise.id === project.exerciseId); +const storageKey = 'java-edu:progress:v1'; +const preferencesKey = 'java-edu:editor-preferences:v1'; +const results = []; +const evidence = { startedAt: new Date().toISOString(), kind: 'Installed production Electron; actual local JDK/Maven; directory dialog return value and navigator.clipboard.writeText readback are mocked; no real OS clipboard verification', executablePath, installRoot, artifacts }; +const errors = []; +const requests = []; +let app; +let page; + +const exists = file => access(file).then(() => true, () => false); +const normalize = value => path.resolve(value).toLowerCase(); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const editor = () => page.locator('#code-editor [role="textbox"]'); +const badge = type => page.locator(`#lesson-achievements [data-achievement="${type}"]`).getAttribute('data-complete'); + +async function eventually(read, expected, message, timeout = 15000) { + const until = Date.now() + timeout; + let value; + while (Date.now() < until) { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await new Promise(resolve => setTimeout(resolve, 80)); + } + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function screenshot(name) { + await page.locator('#toast.is-visible').waitFor({ state: 'hidden', timeout: 6000 }); + await page.screenshot({ path: path.join(artifacts, `${name}.png`), fullPage: false }); +} + +async function check(name, body) { + const started = Date.now(); + console.log(`START ${name}`); + try { + await body(); + results.push({ name, status: 'PASS', durationMs: Date.now() - started }); + console.log(`PASS ${name}`); + } catch (error) { + if (page) await screenshot(`failure-${name}`).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started }); + throw error; + } +} + +async function launch() { + const env = { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: '1' }; + delete env.JAVA_EDU_QA_DATA; + delete env.ELECTRON_RUN_AS_NODE; + delete env.NODE_OPTIONS; + app = await electron.launch({ executablePath, args: [], env, timeout: 60000 }); + page = await app.firstWindow(); + page.setDefaultTimeout(15000); + page.on('pageerror', error => errors.push(error.message)); + page.on('request', request => { + const url = new URL(request.url()); + if (url.pathname.startsWith('/api/')) requests.push({ origin: url.origin, path: url.pathname, method: request.method() }); + }); + await page.locator('#workspace-grid').waitFor({ state: 'visible', timeout: 45000 }); + await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => { window.qaClipboardText = text; } } })); +} + +async function openLesson(lesson, step = 'lab') { + await page.evaluate(hash => { location.hash = hash; }, `lesson=${lesson.id}&step=${step}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), 'requested lesson is selected'); + await eventually(() => page.locator(`[data-step="${step}"]`).getAttribute('aria-selected'), 'true', 'requested learning stage is selected'); +} + +async function source() { + await page.evaluate(() => { window.qaClipboardText = undefined; }); + await page.locator('#copy-code').click(); + return eventually(() => page.evaluate(() => window.qaClipboardText), text => typeof text === 'string', 'current entire source is copied'); +} + +async function quiz(lesson) { + await openLesson(lesson, 'quiz'); + for (const question of lesson.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await eventually(() => badge('quiz'), 'true', 'quiz achievement is recorded'); + await openLesson(lesson); +} + +async function settings() { + await page.locator('#desktop-settings-open').click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'visible' }); + await eventually(() => page.locator('#execution-consent').isEnabled(), true, 'settings loaded'); +} + +async function mockDirectorySelection(directory) { + await app.evaluate(({ dialog }, selected) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selected] }); }, directory); +} + +async function execute(button, timeout = 60000) { + const pathname = button === '#prepare-project' ? '/api/local/prepare' : '/api/execute'; + const pending = page.waitForResponse(response => new URL(response.url()).pathname === pathname && response.request().method() === 'POST', { timeout }); + void pending.catch(() => {}); + await page.locator(button).click(); + const response = await pending; + const result = await response.json(); + const request = response.request().postDataJSON(); + evidence.executions ??= []; + evidence.executions.push({ exerciseId: request.exerciseId, action: request.action || 'prepare', httpStatus: response.status(), ...result }); + console.log('ACTUAL_INSTALLED_RESULT', JSON.stringify({ exerciseId: request.exerciseId, action: request.action || 'prepare', status: result.status, runtimeVersion: result.runtimeVersion, targetVersion: result.targetVersion, passed: result.passed, total: result.total, error: result.error })); + await eventually(() => page.locator('#execution-output').getAttribute('data-status'), status => status !== 'running', 'execution response is rendered'); + assert.equal(response.status(), 200); + const expectedProvider = professionalExercises.some(exercise => exercise.id === request.exerciseId) ? 'Local Maven' : 'Local JDK'; + assert.equal(result.provider, expectedProvider); + assert.equal(result.runtimeVersion, 21); + return { result, request }; +} + +async function preparedEvidence() { + const selectors = ['#lesson-achievements', '#project-preparation-state', '#execution-output .execution-panel-heading', '#execution-output .execution-result-label']; + const measure = () => page.evaluate(selectors => { + const rectangles = selectors.map(selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { selector, top: r.top + scrollY, bottom: r.bottom + scrollY }; }); + return { min: Math.min(...rectangles.map(r => r.top)), max: Math.max(...rectangles.map(r => r.bottom)), rectangles }; + }, selectors); + const before = await measure(); + // A taller native viewport preserves the real page layout and simultaneously + // shows preparation success plus incomplete learner achievements. No DOM is moved. + const height = Math.max(920, Math.ceil(before.max - before.min) + 100); + await app.evaluate(({ BrowserWindow }, height) => BrowserWindow.getAllWindows()[0].setContentSize(1424, height), height); + await eventually(() => page.evaluate(() => innerHeight), actual => actual >= height, 'native viewport includes preparation summary and learner badges'); + const after = await measure(); + await page.evaluate(top => scrollTo({ top: Math.max(0, top - 30), behavior: 'instant' }), after.min); + const visibility = await page.evaluate(selectors => ({ width: innerWidth, height: innerHeight, nodes: selectors.map(selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { selector, top: r.top, bottom: r.bottom }; }) }), selectors); + assert.ok(visibility.nodes.every(r => r.top >= 0 && r.bottom <= visibility.height), JSON.stringify(visibility)); + evidence.preparedViewport = visibility; + await screenshot('installed-prepare-complete-still-incomplete'); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1440, 940)); +} + +async function main() { + await mkdir(artifacts, { recursive: true }); + const preflight = JSON.parse((await readFile(preflightPath, 'utf8')).replace(/^\uFEFF/, '')); + assert.ok(Array.isArray(preflight.AppData) && preflight.AppData.length === 4, 'recorded real-user preflight is required'); + assert.ok(preflight.AppData.every(item => item.Exists === false), 'pre-existing application data must be preserved'); + assert.equal(preflight.Shortcuts.length, 0, 'pre-existing shortcuts must be preserved'); + assert.equal(preflight.InstalledEntries.length, 0, 'pre-existing installation must be preserved'); + assert.equal(normalize(preflight.TestInstallPath), normalize(installRoot)); + for (const item of preflight.AppData) assert.equal(await exists(item.Path), false, `STOP: user application data exists: ${item.Path}`); + assert.ok(jdk && maven, 'approved JDK21 and Maven paths must be supplied'); + await access(path.join(jdk, 'bin/java.exe')); + await access(path.join(maven, 'boot')); + await access(executablePath); + await access(path.join(installRoot, 'resources/app.asar')); + await access(path.join(installRoot, 'resources/app.asar.unpacked/desktop/job-runner.ps1')); + evidence.preflight = preflight; + evidence.executableSha256 = createHash('sha256').update(await readFile(executablePath)).digest('hex'); + evidence.asarSha256 = createHash('sha256').update(await readFile(path.join(installRoot, 'resources/app.asar'))).digest('hex'); + await check('installed-package-execution-byte-parity', async () => { + evidence.installedExecutionFiles = []; + const asarPath = path.join(installRoot, 'resources/app.asar'); + for (const relative of ['server/local/process.mjs', 'server/local/engine.mjs', 'server/local/http.mjs', 'server/local/settings.mjs', 'desktop/main.cjs', 'desktop/preload.cjs', 'desktop/reference-policy.cjs', 'desktop/job-runner.ps1']) { + const sourceBytes = await readFile(path.join(root, relative)); + const unpacked = relative === 'desktop/job-runner.ps1'; + const installedBytes = unpacked + ? await readFile(path.join(installRoot, 'resources/app.asar.unpacked', relative)) + : extractFile(asarPath, path.normalize(relative)); + const comparison = { path: relative, location: unpacked ? 'app.asar.unpacked' : 'app.asar', sourceBytes: sourceBytes.length, installedBytes: installedBytes.length, sourceSha256: sha256(sourceBytes), installedSha256: sha256(installedBytes), equal: installedBytes.equals(sourceBytes) }; + evidence.installedExecutionFiles.push(comparison); + assert.ok(comparison.equal, `Installed ${comparison.location}/${relative} differs from the frozen source: expected ${comparison.sourceSha256}, actual ${comparison.installedSha256}`); + } + }); + const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); + await launch(); + const firstOrigin = new URL(page.url()).origin; + await check('installed-packaged-startup-and-default-user-data', async () => { + const facts = await app.evaluate(({ app, BrowserWindow }) => ({ packaged: app.isPackaged, executable: app.getPath('exe'), appPath: app.getAppPath(), userData: app.getPath('userData'), preferences: BrowserWindow.getAllWindows()[0].webContents.getLastWebPreferences() })); + assert.equal(facts.packaged, true); + assert.equal(normalize(facts.executable), normalize(executablePath)); + assert.ok(facts.appPath.endsWith('app.asar')); + assert.ok(preflight.AppData.some(item => normalize(item.Path) === normalize(facts.userData)), 'production default AppData, no QA override'); + assert.equal(facts.preferences.contextIsolation, true); + assert.equal(facts.preferences.nodeIntegration, false); + assert.equal(facts.preferences.sandbox, true); + assert.equal(facts.preferences.webSecurity, true); + assert.equal(facts.preferences.allowRunningInsecureContent, false); + evidence.application = { ...facts, preferences: { contextIsolation: facts.preferences.contextIsolation, nodeIntegration: facts.preferences.nodeIntegration, sandbox: facts.preferences.sandbox, webSecurity: facts.preferences.webSecurity, allowRunningInsecureContent: facts.preferences.allowRunningInsecureContent } }; + evidence.installedAssetHashes = {}; + for (const relative of ['app.js', 'styles.css', 'data/lesson-visuals.js']) { + const actual = await page.evaluate(async relative => { + const response = await fetch(`/${relative}`); + if (!response.ok) throw new Error(`Installed asset ${relative}: HTTP ${response.status}`); + return response.text(); + }, relative); + assert.equal(actual, await readFile(path.join(root, 'dist', relative), 'utf8'), `installed ${relative} matches the frozen source`); + evidence.installedAssetHashes[relative] = createHash('sha256').update(actual).digest('hex'); + } + await openLesson(core, 'theory'); + assert.equal(await page.locator('.theory-visual').count(), 1, 'new theory data ships inside the installed package'); + await openLesson(core); + const runtime = await page.evaluate(() => window.javaEduDesktop.getSettings()); + assert.equal(runtime.mode, 'local'); + assert.equal(runtime.consent.accepted, false); + assert.equal(runtime.runtimes.some(item => item.available), false); + assert.equal(await page.locator('#run-code').isDisabled(), true); + await screenshot('installed-first-launch'); + }); + + await check('installed-real-tool-registration-and-consent', async () => { + await settings(); + await mockDirectorySelection(jdk); + await page.locator('#jdk-register-21').click(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).runtimes.find(item => item.javaVersion === 21).available, true, 'packaged helper inspects real JDK21', 40000); + await eventually(() => page.locator('#jdk-register-21').isEnabled(), true, 'JDK registration finished'); + await mockDirectorySelection(maven); + await page.locator('#maven-register').click(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).maven.available, true, 'Maven distribution inspected'); + await page.locator('#execution-consent').check(); + await eventually(async () => (await page.evaluate(() => window.javaEduDesktop.getSettings())).consent.accepted, true, 'explicit consent saved'); + await screenshot('installed-configured-settings'); + await page.locator('#desktop-settings-dialog [data-close-dialog="desktop-settings-dialog"]').first().click(); + assert.equal(requests.some(request => request.path === '/api/execute'), false); + }); + + await check('installed-jdk21-core-target8-and21-submissions', async () => { + for (const lesson of [core, modern]) { + await openLesson(lesson); + await editor().fill(solutions[lesson.exerciseId]); + await quiz(lesson); + const { result, request } = await execute('#submit-code'); + assert.equal(result.status, 'passed', result.stderr || result.compilerOutput); + assert.equal(result.targetVersion, coreExercises.find(exercise => exercise.id === lesson.exerciseId).minVersion); + assert.ok(result.total > 0 && result.passed === result.total && result.checks.every(check => check.passed)); + assert.equal(request.source, solutions[lesson.exerciseId]); + assert.equal(await badge('lab'), 'true'); + } + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await screenshot('installed-java21-submitted'); + }); + + await check('installed-spring-h2-preparation-not-learner-completion', async () => { + await openLesson(project); + await eventually(() => page.locator('#prepare-project').isEnabled(), true, 'packaged project metadata loaded'); + assert.equal(await page.locator('#submit-code').isDisabled(), true); + const before = await source(); + const { result, request } = await execute('#prepare-project', 360000); + assert.equal(result.status, 'passed', result.stderr || result.stdout); + assert.equal(result.targetVersion, 17); + assert.equal(result.prepared, true); + assert.equal(request.source, undefined); + assert.equal(request.files, undefined); + assert.equal(await source(), before); + assert.equal(await badge('quiz'), 'false'); + assert.equal(await badge('lab'), 'false'); + await eventually(() => page.locator('#project-preparation-state').getAttribute('data-prepared'), 'true', 'preparation availability updated'); + assert.equal(await page.locator('#execution-output .execution-result-label').textContent(), '환경 준비 완료'); + await preparedEvidence(); + }); + + await check('installed-spring-h2-reference-submit', async () => { + const expectedFiles = {}; + for (const file of project.professionalExercise.files.filter(item => item.editable)) { + expectedFiles[file.path] = await readFile(path.join(root, 'curriculum/professional', file.referencePath), 'utf8'); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(expectedFiles[file.path]); + } + await quiz(project); + const { result, request } = await execute('#submit-code', 180000); + assert.equal(result.status, 'passed', result.stderr || result.stdout); + assert.equal(result.targetVersion, 17); + assert.ok(result.total > 0 && result.passed === result.total && result.checks.every(check => check.passed)); + assert.deepEqual(request.files, expectedFiles); + assert.equal(Object.hasOwn(request.files, 'pom.xml'), false); + assert.equal(await badge('lab'), 'true'); + await page.locator('#execution-output .execution-panel-heading').scrollIntoViewIfNeeded(); + await screenshot('installed-spring-h2-submitted'); + }); + + await check('installed-restart-preserves-progress-theme-and-drafts', async () => { + await openLesson(core); + const draft = `${solutions[core.exerciseId]}\n// installed restart draft\n`; + await editor().fill(draft); + await page.locator('#editor-theme-select').selectOption('contrast'); + await openLesson(project); + const file = projectExercise.files.find(item => item.editable); + const fileDraft = `${file.source}\n// installed project restart draft\n`; + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + await editor().fill(fileDraft); + await openLesson(modern, 'theory'); + const stored = () => page.evaluate(keys => Object.fromEntries(keys.map(key => [key, window.javaEduDesktop.storage.getItem(key)])), [storageKey, preferencesKey]); + const before = await stored(); + await app.close(); + app = null; + await launch(); + const after = await stored(); + evidence.origins = [firstOrigin, new URL(page.url()).origin]; + assert.notEqual(evidence.origins[0], evidence.origins[1]); + assert.equal(after[preferencesKey], before[preferencesKey]); + for (const field of ['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes']) assert.deepEqual(JSON.parse(after[storageKey])[field], JSON.parse(before[storageKey])[field], `${field} persists across the port change`); + await openLesson(core); + assert.equal(await source(), draft); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); + await openLesson(project); + await page.locator(`#project-file-tabs [data-file-path="${file.path}"]`).click(); + assert.equal(await source(), fileDraft); + assert.equal(await page.locator('#prepare-project').getAttribute('data-prepared'), 'false'); + await openLesson(modern); + assert.equal(await badge('quiz'), 'true'); + assert.equal(await badge('lab'), 'true'); + await screenshot('installed-restarted-achievement'); + }); + + await check('installed-minimum-window200-settings-close-is-visible', async () => { + await settings(); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(720, 560)); + await eventually(() => page.evaluate(() => innerWidth), width => width < 800, 'minimum native window size applied'); + await page.locator('html').evaluate(node => { node.style.fontSize = '200%'; }); + const close = page.locator('#desktop-settings-dialog .dialog-actions [data-close-dialog="desktop-settings-dialog"]'); + await close.evaluate(node => node.scrollIntoView({ behavior: 'instant', block: 'end' })); + const visibility = await close.evaluate(node => { const r = node.getBoundingClientRect(); const d = node.closest('dialog').getBoundingClientRect(); return { width: innerWidth, height: innerHeight, rootFont: getComputedStyle(document.documentElement).fontSize, button: { top: r.top, bottom: r.bottom, left: r.left, right: r.right }, dialog: { top: d.top, bottom: d.bottom } }; }); + assert.equal(visibility.rootFont, '32px'); + assert.ok(visibility.button.top >= 0 && visibility.button.bottom <= visibility.height && visibility.button.top >= visibility.dialog.top && visibility.button.bottom <= visibility.dialog.bottom, JSON.stringify(visibility)); + assert.ok(visibility.button.left >= 0 && visibility.button.right <= visibility.width); + evidence.minimumSettingsViewport = visibility; + await screenshot('installed-settings-minimum200-close-button'); + await close.click(); + await page.locator('#desktop-settings-dialog').waitFor({ state: 'hidden' }); + }); + assert.deepEqual(errors, []); + assert.ok(requests.every(request => new URL(request.origin).hostname === '127.0.0.1')); +} + +try { await main(); } +catch (error) { + if (!results.some(result => result.status === 'FAIL')) results.push({ name: 'environment-or-preflight', status: 'BLOCKED', message: error.message }); + console.error(error.stack); + process.exitCode = 1; +} finally { + await app?.close().catch(() => {}); + evidence.finishedAt = new Date().toISOString(); + evidence.results = results; + evidence.uncaughtBrowserErrors = errors; + evidence.apiRequests = requests; + await mkdir(artifacts, { recursive: true }); + await writeFile(path.join(artifacts, `installed-results-${Date.now()}.json`), JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify({ results, artifacts, application: evidence.application, origins: evidence.origins }, null, 2)); +} diff --git a/tests/local/bundled-runtime-smoke.mjs b/tests/local/bundled-runtime-smoke.mjs new file mode 100644 index 0000000..0e8e73e --- /dev/null +++ b/tests/local/bundled-runtime-smoke.mjs @@ -0,0 +1,72 @@ +// Opt-in actual Windows toolchain check: no chooseJdk/chooseMaven call, no external tool install. +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadBundledRuntime } from '../../desktop/bundled-runtime.cjs'; +import { createSettingsStore } from '../../server/local/settings.mjs'; +import { createLocalEngine } from '../../server/local/engine.mjs'; +import { exerciseDefinitions } from '../../server/exercises.mjs'; + +if (process.platform !== 'win32') throw new Error('The bundled distribution is Windows x64 only.'); +const root = fileURLToPath(new URL('../../', import.meta.url)); +const bundleRoot = path.join(root, 'work/desktop-bundle'); +const dataRoot = path.join(root, 'work', `bundled-runtime-smoke-${randomUUID()}`); +await mkdir(dataRoot); +const report = { startedAt: new Date().toISOString(), bundleRoot, dataRoot, results: [] }; +let engine; +try { + const bundle = await loadBundledRuntime(bundleRoot); + report.bundleId = bundle.id; + const settings = createSettingsStore(dataRoot, { bundledRuntime: bundle }); + const initial = await settings.initialize(); + assert.equal(initial.executionConsent, false); + assert.equal(initial.jdks[21].source, 'bundled'); + assert.equal(initial.maven.source, 'bundled'); + assert.deepEqual(Object.keys(initial.jdks), ['21']); + await settings.setConsent(true); + engine = await createLocalEngine({ dataRoot, projectRoot: path.join(root, 'curriculum/professional'), settings }); + assert.equal(engine.runtime().runtime.javaVersion, 21); + const solutions = JSON.parse(await readFile(path.join(root, 'examples/solutions.json'), 'utf8')); + for (const definition of exerciseDefinitions) { + const result = await engine.execute({ exerciseId: definition.id, action: 'submit', source: solutions[definition.id], requestId: `bundled-${definition.id}` }); + report.results.push({ exerciseId: definition.id, runtime: result.runtimeVersion, target: result.targetVersion, status: result.status, passed: result.passed, total: result.total }); + console.log(JSON.stringify(report.results.at(-1))); + if (result.status !== 'passed') console.error(result); + assert.equal(result.status, 'passed'); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.targetVersion, definition.minVersion); + } + for (const lessonId of (process.env.JAVA_EDU_BUNDLE_SMOKE_PROJECT || '').split(',').filter(Boolean)) { + const exerciseId = `${lessonId}-project`; + const prepared = await engine.prepare({ exerciseId, requestId: `prepare-${lessonId}` }); + report.results.push({ exerciseId, action: 'prepare', status: prepared.status, passed: prepared.passed, total: prepared.total }); + console.log(JSON.stringify(report.results.at(-1))); + if (prepared.status !== 'passed') console.error(prepared); + assert.equal(prepared.status, 'passed'); + const lesson = JSON.parse(await readFile(path.join(root, 'curriculum/professional/lessons', lessonId, 'lesson.json'), 'utf8')); + const files = Object.fromEntries(await Promise.all(lesson.professionalExercise.files.filter((file) => file.editable).map(async (file) => [file.path, await readFile(path.join(root, 'curriculum/professional', file.referencePath), 'utf8')]))); + const result = await engine.execute({ exerciseId, action: 'submit', files, requestId: `submit-${lessonId}` }); + report.results.push({ exerciseId, action: 'submit', runtime: result.runtimeVersion, target: result.targetVersion, status: result.status, passed: result.passed, total: result.total }); + console.log(JSON.stringify(report.results.at(-1))); + if (result.status !== 'passed') console.error(result); + assert.equal(result.status, 'passed'); + } + const restarted = createSettingsStore(dataRoot, { bundledRuntime: await loadBundledRuntime(bundleRoot) }); + const restored = await restarted.initialize(); + assert.equal(restored.jdks[21].source, 'bundled'); + assert.equal(restored.maven.source, 'bundled'); + assert.equal(restored.executionConsent, true); + report.status = 'passed'; +} catch (error) { + report.status = 'failed'; + report.error = error.message; + throw error; +} finally { + await engine?.close(); + report.finishedAt = new Date().toISOString(); + const evidence = path.join(dataRoot, 'results.json'); + await writeFile(evidence, `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify({ evidence, status: report.status })); +} diff --git a/tests/local/bundled-runtime.test.mjs b/tests/local/bundled-runtime.test.mjs new file mode 100644 index 0000000..7ebbcb9 --- /dev/null +++ b/tests/local/bundled-runtime.test.mjs @@ -0,0 +1,190 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm, realpath, rename, symlink, cp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { inventoryTree, loadBundledRuntime, manifestIdentity } from '../../desktop/bundled-runtime.cjs'; +import { createSettingsStore } from '../../server/local/settings.mjs'; +import { createLocalEngine } from '../../server/local/engine.mjs'; + +async function scratch(t) { + const parent = await realpath(tmpdir()); + const root = await mkdtemp(path.join(parent, 'java-edu-bundle-test-')); + t.after(async () => { + const resolved = await realpath(root); + assert.equal(path.dirname(resolved), parent); + assert.ok(path.basename(resolved).startsWith('java-edu-bundle-test-')); + await rm(resolved, { recursive: true, force: true }); + }); + return root; +} + +async function fixture(root, name = 'toolchains') { + const directory = path.join(root, name); + const files = { + 'jdks/21/bin/java.exe': 'fixture Java runtime', 'jdks/21/bin/javac.exe': 'fixture Java compiler', + 'jdks/21/release': 'JAVA_VERSION="21.0.12.1"\nOS_ARCH="x86_64"\nOS_NAME="Windows"\n', + 'jdks/21/legal/java.base/LICENSE': 'fixture license', + 'maven/boot/plexus-classworlds-2.9.0.jar': 'fixture launcher', 'maven/bin/m2.conf': 'fixture config', + 'maven/lib/maven-core-3.10.0.jar': 'fixture Maven', 'maven/LICENSE': 'fixture Maven license', + 'THIRD-PARTY-NOTICES.txt': 'fixture provenance', + }; + for (const [relative, content] of Object.entries(files)) { + const file = path.join(directory, ...relative.split('/')); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + } + const manifest = { schemaVersion: 1, platform: 'win32', arch: 'x64', + jdks: [{ major: 21, home: 'jdks/21', distribution: 'Fixture JDK', version: '21.0.12.1+1', sourceUrl: 'https://example.org/jdk.zip', archiveSha256: 'a'.repeat(64), license: 'fixture-only', licenseFiles: ['legal/java.base/LICENSE'] }], + maven: { home: 'maven', distribution: 'Fixture Maven', version: '3.10.0', sourceUrl: 'https://example.org/maven.zip', archiveSha256: 'b'.repeat(64), license: 'fixture-only', licenseFiles: ['LICENSE'] }, + files: await inventoryTree(directory), + }; + await saveManifest(directory, manifest); + return { directory, manifest }; +} + +async function saveManifest(directory, manifest) { + manifest.id = manifestIdentity(manifest); + await writeFile(path.join(directory, 'manifest.json'), JSON.stringify(manifest)); +} + +const invalidBundle = { code: 'invalid_bundled_runtime' }; + +test('bundle metadata is resolved only from the verified current resources root', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const bundle = await loadBundledRuntime(directory); + assert.equal(bundle.jdks[0].version, 21); + assert.equal(bundle.jdks[0].home, path.join(await realpath(directory), 'jdks', '21')); + assert.equal(bundle.maven.home, path.join(await realpath(directory), 'maven')); + assert.equal(bundle.fileCount, 9); + assert.equal(await loadBundledRuntime(path.join(root, 'absent'), { required: false }), null); + await assert.rejects(loadBundledRuntime(path.join(root, 'absent')), invalidBundle); +}); + +test('changed, missing and unlisted toolchain files fail integrity before registration', async (t) => { + const root = await scratch(t); + for (const scenario of ['changed', 'missing', 'extra', 'manifest']) { + const { directory } = await fixture(root, scenario); + if (scenario === 'changed') await writeFile(path.join(directory, 'jdks/21/bin/java.exe'), 'changed'); + if (scenario === 'missing') await rm(path.join(directory, 'maven/LICENSE')); + if (scenario === 'extra') await writeFile(path.join(directory, 'maven/extra.jar'), 'unlisted'); + if (scenario === 'manifest') { + const manifest = JSON.parse(await readFile(path.join(directory, 'manifest.json'))); + manifest.maven.version = '3.9.0'; + await writeFile(path.join(directory, 'manifest.json'), JSON.stringify(manifest)); + } + await assert.rejects(loadBundledRuntime(directory), invalidBundle, scenario); + } +}); + +test('self-consistent metadata cannot redirect homes, inventory paths or case aliases', async (t) => { + const root = await scratch(t); + const cases = [ + (manifest) => { manifest.jdks[0].home = '../outside'; }, + (manifest) => { manifest.files[0].path = '../outside/bin/java.exe'; }, + (manifest) => { manifest.files[0].path = 'jdks/21/bin/java.exe:stream'; }, + (manifest) => { manifest.files.push({ ...manifest.files[0], path: manifest.files[0].path.toUpperCase() }); }, + (manifest) => { manifest.jdks[0].licenseFiles = ['../LICENSE']; }, + ]; + for (const [index, mutate] of cases.entries()) { + const { directory, manifest } = await fixture(root, `invalid-${index}`); + mutate(manifest); + await saveManifest(directory, manifest); + await assert.rejects(loadBundledRuntime(directory), invalidBundle); + } +}); + +test('directory links in a bundle cannot point at an external toolchain', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const outside = path.join(root, 'outside-bin'); + await rename(path.join(directory, 'jdks/21/bin'), outside); + const link = path.join(directory, 'jdks/21/bin'); + await symlink(outside, link, process.platform === 'win32' ? 'junction' : 'dir'); + try { await assert.rejects(loadBundledRuntime(directory), invalidBundle); } + finally { await rm(link); } +}); + +test('fresh bundled settings need no folder selection, no startup execution and explicit consent', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const bundle = await loadBundledRuntime(directory); + let probes = 0; + const settings = createSettingsStore(path.join(root, 'data'), { bundledRuntime: bundle, runner: async () => { probes++; throw new Error('No startup process'); } }); + const value = await settings.initialize(); + assert.equal(probes, 0); + assert.equal(value.jdks[21].source, 'bundled'); + assert.equal(value.maven.source, 'bundled'); + assert.equal(value.executionConsent, false); + const engine = await createLocalEngine({ dataRoot: path.join(root, 'data'), settings, projects: [] }); + const runtime = engine.runtime(); + assert.equal(runtime.available, false); + assert.deepEqual(runtime.supportedTargets, [8, 11, 17, 21]); + assert.equal(runtime.runtime.javaVersion, 21); + assert.deepEqual(runtime.runtimes.filter((entry) => entry.available).map((entry) => [entry.javaVersion, entry.source]), [[21, 'bundled']]); + assert.equal(runtime.maven.source, 'bundled'); + assert.deepEqual(runtime.bundledRuntime, { id: bundle.id, javaVersions: [21], mavenVersion: '3.10.0' }); + await assert.rejects(engine.execute({ exerciseId: 'java-basics-lab', source: 'class Solution {}', action: 'run', requestId: 'no-consent' }), { code: 'execution_consent_required' }); + assert.equal(probes, 0); + await settings.setConsent(true); + assert.equal(engine.runtime().available, true); + await engine.close(); +}); + +test('reinstall and ZIP move repair bundled paths while old absolute paths are ignored', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const dataRoot = path.join(root, 'data'); + const first = createSettingsStore(dataRoot, { bundledRuntime: await loadBundledRuntime(directory) }); + await first.initialize(); + await first.setConsent(true); + const saved = JSON.parse(await readFile(path.join(dataRoot, 'settings.json'))); + saved.jdks[21].home = path.join(root, 'attacker-selected-home'); + saved.maven.home = path.join(root, 'attacker-selected-maven'); + await writeFile(path.join(dataRoot, 'settings.json'), JSON.stringify(saved)); + const moved = path.join(root, 'moved toolchains 한글'); + await rename(directory, moved); + const next = createSettingsStore(dataRoot, { bundledRuntime: await loadBundledRuntime(moved) }); + const value = await next.initialize(); + assert.equal(value.jdks[21].home, path.join(await realpath(moved), 'jdks', '21')); + assert.equal(value.maven.home, path.join(await realpath(moved), 'maven')); + assert.equal(value.executionConsent, true); + const withoutBundle = createSettingsStore(dataRoot); + const absent = await withoutBundle.initialize(); + assert.deepEqual(absent.jdks, {}); + assert.equal(absent.maven, null); +}); + +test('legacy or explicit external selections are preserved across bundled startup and probes', async (t) => { + const root = await scratch(t); + const { directory } = await fixture(root); + const externalJdk = path.join(root, 'external-jdk21'); + const externalMaven = path.join(root, 'external-maven'); + await cp(path.join(directory, 'jdks/21'), externalJdk, { recursive: true }); + if (process.platform !== 'win32') { + await cp(path.join(externalJdk, 'bin/java.exe'), path.join(externalJdk, 'bin/java')); + await cp(path.join(externalJdk, 'bin/javac.exe'), path.join(externalJdk, 'bin/javac')); + } + await cp(path.join(directory, 'maven'), externalMaven, { recursive: true }); + const dataRoot = path.join(root, 'data'); + await mkdir(dataRoot); + await writeFile(path.join(dataRoot, 'settings.json'), JSON.stringify({ schema: 1, executionConsent: true, jdks: { 21: { home: externalJdk, version: 21 } }, maven: { home: externalMaven } })); + const optionsSeen = []; + const runner = async (executable, _args, options) => { + optionsSeen.push(options); + return { reason: 'exited', exitCode: 0, stdout: executable.includes('javac') ? 'javac 21.0.12.1' : '', stderr: executable.includes('javac') ? '' : 'openjdk version "21.0.12.1"' }; + }; + const settings = createSettingsStore(dataRoot, { runner, bundledRuntime: await loadBundledRuntime(directory) }); + const value = await settings.initialize(); + assert.equal(value.jdks[21].source, 'external'); + assert.equal(value.jdks[21].home, externalJdk); + assert.equal(value.maven.home, await realpath(externalMaven)); + assert.equal(value.maven.source, 'external'); + assert.equal(optionsSeen.length, 0); + const verified = await settings.verifyJdk(21); + assert.equal(verified.source, 'external'); + assert.equal(verified.home, await realpath(externalJdk)); + assert.equal(optionsSeen.length, 2); + for (const options of optionsSeen) for (const name of ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS', 'MAVEN_ARGS', 'CLASSPATH']) assert.equal(Object.hasOwn(options.env, name), false); +}); diff --git a/tests/local/contracts.test.mjs b/tests/local/contracts.test.mjs new file mode 100644 index 0000000..664e2d4 --- /dev/null +++ b/tests/local/contracts.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createLocalEngine } from '../../server/local/engine.mjs'; +import { createJobWorkspace, safeRelativePath } from '../../server/local/workspace.mjs'; +import { processEnvironment } from '../../server/local/process.mjs'; +import { parseJavaVersion } from '../../server/local/settings.mjs'; +import { validateSubmittedFiles, mavenCommand, readTestReports } from '../../server/local/projects.mjs'; +import { createDesktopStorage } from '../../desktop/storage.cjs'; +import { prepareServiceEnvironment, requiredServices } from '../../server/local/resources.mjs'; + +async function scratch(t) { + const parent = await realpath(tmpdir()); + const root = await mkdtemp(path.join(parent, 'java-edu-local-test-')); + t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-local-test-')); await rm(resolved, { recursive: true, force: true }); }); + return root; +} + +const definition = { id: 'unit-lab', lessonId: 'unit', minVersion: 8, checks: [{ name: 'contract', expression: 'new Solution().value() == 7' }], sampleChecks: [{ name: 'sample', expression: 'new Solution().value() == 7' }] }; +function mockSettings({ consent = true, versions = [8, 21] } = {}) { + const jdks = Object.fromEntries(versions.map((version) => [version, { version, home: path.resolve(`jdk${version}`), java: path.resolve(`jdk${version}/bin/java.exe`), javac: path.resolve(`jdk${version}/bin/javac.exe`) }])); + return { snapshot: () => ({ executionConsent: consent, jdks, maven: null }), verifyJdk: async (version) => jdks[version] }; +} + +test('path policy rejects traversal, drive paths, ADS and Windows device names', () => { + for (const name of ['../answer.java', 'src/../../Main.java', 'C:/x.java', 'src\\x.java', '/tmp/x', 'src/x.java:stream', 'src/NUL.java', 'src/CON', 'src//X.java']) assert.throws(() => safeRelativePath(name)); + assert.equal(safeRelativePath('src/main/resources/mappers/OrderMapper.xml'), 'src/main/resources/mappers/OrderMapper.xml'); +}); + +test('registered exercise allowlist rejects added/omitted files and learner POM', () => { + const project = { editableFiles: ['src/main/java/X.java'], files: [{}], testFiles: [{}] }; + for (const supplied of [{}, { 'pom.xml': '' }, { 'src/main/java/X.java': '', 'evil.java': 'x' }, { 'src/main/java/X.java': '\0' }]) assert.throws(() => validateSubmittedFiles(project, supplied)); + assert.deepEqual(validateSubmittedFiles(project, { 'src/main/java/X.java': 'class X {}' }), { 'src/main/java/X.java': 'class X {}' }); +}); + +test('core executes the trusted checks on the actual chosen JVM and removes workspace', async (t) => { + const root = await scratch(t); + const calls = []; + let workspace; + const runner = async (file, args, options) => { + calls.push({ file, args, options }); + workspace = options.cwd; + if (file.endsWith('javac.exe')) return { exitCode: 0, reason: 'exited', stdout: '', stderr: 'warning' }; + const source = await readFile(path.join(options.cwd, 'Main.java'), 'utf8'); + const nonce = source.match(/__JAVA_EDU_([a-f0-9]{32})__/)[1]; + return { exitCode: 0, reason: 'exited', stdout: `__JAVA_EDU_${nonce}__START\n__JAVA_EDU_${nonce}__CHECK:0:true\n__JAVA_EDU_${nonce}__END\n`, stderr: '' }; + }; + const engine = await createLocalEngine({ dataRoot: root, settings: mockSettings(), runner, definitions: [definition], projects: [] }); + const result = await engine.execute({ exerciseId: 'unit-lab', source: 'class Solution { int value() { return 7; } }', requestId: 'core-pass', action: 'submit' }); + assert.equal(result.status, 'passed'); + assert.equal(result.runtimeVersion, 21); + assert.equal(result.targetVersion, 8); + assert.equal(result.passed, 1); + assert.ok(calls[0].args.includes('--release')); + assert.equal(calls[0].args[calls[0].args.indexOf('--release') + 1], '8'); + await assert.rejects(readFile(path.join(workspace, 'Main.java')), /ENOENT/); +}); + +test('consent, unavailable JDK, unknown exercise and oversized code fail before process execution', async (t) => { + const root = await scratch(t); + let calls = 0; + const runner = () => { calls++; throw new Error('Must not spawn'); }; + const input = { exerciseId: 'unit-lab', source: 'class Solution {}', action: 'run', requestId: 'invalid-case' }; + const noConsent = await createLocalEngine({ dataRoot: root, settings: mockSettings({ consent: false }), runner, definitions: [definition], projects: [] }); + await assert.rejects(noConsent.execute(input), { code: 'execution_consent_required' }); + assert.equal(noConsent.runtime().available, false); + const noJdk = await createLocalEngine({ dataRoot: root, settings: mockSettings({ versions: [] }), runner, definitions: [definition], projects: [] }); + await assert.rejects(noJdk.execute(input), { code: 'jdk_required' }); + const engine = await createLocalEngine({ dataRoot: root, settings: mockSettings(), runner, definitions: [definition], projects: [] }); + await assert.rejects(engine.execute({ ...input, exerciseId: 'unknown' }), { code: 'exercise_not_found' }); + await assert.rejects(engine.execute({ ...input, source: '가'.repeat(12_000) }), { code: 'invalid_source' }); + await assert.rejects(engine.execute({ ...input, runtimeVersion: 22 }), { code: 'invalid_runtime' }); + assert.equal(calls, 0); +}); + +test('active work is bounded to one, duplicate requests reject and cancellation reaches child supervisor', async (t) => { + const root = await scratch(t); + let started; + const ready = new Promise((resolve) => { started = resolve; }); + const runner = (_file, _args, { signal }) => new Promise((resolve) => { started(); signal.addEventListener('abort', () => resolve({ exitCode: -1, reason: 'cancelled', stdout: '', stderr: '' }), { once: true }); }); + const engine = await createLocalEngine({ dataRoot: root, settings: mockSettings(), runner, definitions: [definition], projects: [] }); + const input = { exerciseId: 'unit-lab', source: 'class Solution {}', action: 'run', requestId: 'active' }; + const operation = engine.execute(input); + await ready; + await assert.rejects(engine.execute({ ...input, requestId: 'other' }), { code: 'execution_busy' }); + await assert.rejects(engine.execute(input), { code: 'duplicate_request' }); + assert.deepEqual(engine.cancel('active'), { cancelled: true }); + const result = await operation; + assert.equal(result.error.code, 'request_cancelled'); + await engine.close(); + await assert.rejects(engine.execute({ ...input, requestId: 'closed' }), { code: 'app_closing' }); +}); + +test('Maven command fixes launcher/settings/repository and never invokes a shell or learner command', async (t) => { + const root = await scratch(t); + const workspace = await createJobWorkspace(root); + try { + const maven = { home: path.join(root, 'Maven & 한글 %'), launcher: path.join(root, 'Maven & 한글 %/boot/plexus.jar'), configuration: path.join(root, 'Maven & 한글 %/bin/m2.conf') }; + const jdk = { java: path.join(root, 'Java & 한글 %/bin/java.exe') }; + const command = await mavenCommand({ workspace, moduleDirectory: path.join(workspace.directory, 'module'), dataRoot: root, jdk, maven }); + assert.equal(command.executable, jdk.java); + assert.ok(command.args.includes('--offline')); + assert.ok(command.args.includes('-DforkCount=0')); + assert.equal(command.args.at(-1), 'test'); + assert.ok(command.args.includes(maven.launcher)); + assert.equal(command.args.filter((arg) => arg === '--settings' || arg === '--global-settings').length, 2); + const settings = await readFile(command.args[command.args.indexOf('--settings') + 1], 'utf8'); + assert.match(settings, /\*<\/mirrorOf>/); + assert.match(settings, /https:\/\/repo.maven.apache.org\/maven2/); + } finally { await workspace.dispose(); } +}); + +test('Surefire reports distinguish passing, failing and skipped behavior without evaluating XML entities', async (t) => { + const root = await scratch(t); + const reports = path.join(root, 'target/surefire-reports'); + await mkdir(reports, { recursive: true }); + const file = path.join(reports, 'TEST-edu.lab.ContractTest.xml'); + await writeFile(file, ''); + const checks = await readTestReports(root); + assert.deepEqual(checks.map((entry) => entry.passed), [true, false, false]); + assert.equal(checks[2].skipped, true); + await writeFile(file, ']>'); + await assert.rejects(readTestReports(root), { code: 'invalid_test_report' }); +}); + +test('desktop storage survives new origins/restarts and cannot address arbitrary files', async (t) => { + const root = await scratch(t); + const first = createDesktopStorage(root); + first.setItem('java-edu:progress:v1', '{"lesson":"oop"}'); + assert.equal(createDesktopStorage(root).getItem('java-edu:progress:v1'), '{"lesson":"oop"}'); + assert.throws(() => first.setItem('../settings.json', 'bad')); + assert.throws(() => first.setItem('java-edu:test', 'x'.repeat(8 * 1024 * 1024))); + first.removeItem('java-edu:progress:v1'); + assert.equal(createDesktopStorage(root).getItem('java-edu:progress:v1'), null); +}); + +test('toolchain parsing separates old/new Java version forms and strips inherited process options', () => { + assert.equal(parseJavaVersion('openjdk version "1.8.0_265"'), 8); + assert.equal(parseJavaVersion('javac 21.0.12.1'), 21); + assert.equal(parseJavaVersion('java version "17.0.1"'), 17); + const env = processEnvironment(path.resolve('jdk'), path.resolve('tmp')); + for (const key of ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS', 'CLASSPATH', 'MAVEN_OPTS', 'MAVEN_ARGS', 'HTTP_PROXY', 'GITHUB_TOKEN']) assert.equal(Object.hasOwn(env, key), false); +}); + +test('optional services use fixed training loopback addresses and reject missing infrastructure before execution', async () => { + const project = { profile: { resources: [{ type: 'redis', host: 'attacker.invalid', port: 6379 }, { type: 'kafka' }] } }; + assert.deepEqual(requiredServices(project).map(({ host, port }) => ({ host, port })), [{ host: '127.0.0.1', port: 16379 }, { host: '127.0.0.1', port: 19092 }]); + await assert.rejects(prepareServiceEnvironment(project, undefined, async () => false), { code: 'unsupported_resources', httpStatus: 409 }); + assert.deepEqual(await prepareServiceEnvironment(project, undefined, async () => true), { JAVA_EDU_REDIS_HOST: '127.0.0.1', JAVA_EDU_REDIS_PORT: '16379', JAVA_EDU_KAFKA_BOOTSTRAP_SERVERS: '127.0.0.1:19092' }); + assert.deepEqual(await prepareServiceEnvironment({ profile: { resources: [{ type: 'h2' }] } }), {}); +}); diff --git a/tests/local/http.test.mjs b/tests/local/http.test.mjs new file mode 100644 index 0000000..1b46b29 --- /dev/null +++ b/tests/local/http.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import http from 'node:http'; +import { startLocalServer, SESSION_COOKIE, CSRF_HEADER } from '../../server/local/http.mjs'; + +test('loopback authenticates static/API access and validates Host, Origin and CSRF before execution', async (t) => { + let calls = 0; + const engine = { runtime: () => ({ mode: 'local', available: false }), execute: async () => { calls++; return { status: 'passed' }; }, close: async () => {}, cancel: () => ({ cancelled: true }) }; + const local = await startLocalServer({ distRoot: path.resolve(fileURLToPath(new URL('../../dist', import.meta.url))), engine }); + t.after(() => local.close()); + assert.match(local.origin, /^http:\/\/127\.0\.0\.1:\d+$/); + const cookie = `${SESSION_COOKIE}=${local.sessionToken}`; + const auth = { Cookie: cookie }; + assert.equal((await fetch(`${local.origin}/api/runtime`)).status, 401); + assert.equal((await fetch(`${local.origin}/api/runtime`, { headers: auth })).status, 200); + assert.equal((await fetch(`${local.origin}/`, { headers: auth })).status, 200); + const forgedHostStatus = await new Promise((resolve, reject) => { + const request = http.get(`${local.origin}/api/runtime`, { headers: { ...auth, Host: 'evil.test' } }, (response) => { response.resume(); resolve(response.statusCode); }); + request.on('error', reject); + }); + assert.equal(forgedHostStatus, 403); + assert.equal((await fetch(`${local.origin}/api/runtime`, { headers: { ...auth, Origin: 'null' } })).status, 403); + assert.equal((await fetch(`${local.origin}/server/executor.mjs`, { headers: auth })).status, 404); + const post = (headers) => fetch(`${local.origin}/api/execute`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json', ...headers }, body: '{}' }); + assert.equal((await post({})).status, 403); + assert.equal((await post({ Origin: local.origin })).status, 403); + assert.equal((await post({ Origin: 'https://other.example', [CSRF_HEADER]: local.csrfToken })).status, 403); + assert.equal(calls, 0); + assert.equal((await post({ Origin: local.origin, [CSRF_HEADER]: local.csrfToken })).status, 200); + assert.equal(calls, 1); +}); diff --git a/tests/local/java-smoke.mjs b/tests/local/java-smoke.mjs new file mode 100644 index 0000000..c5bce74 --- /dev/null +++ b/tests/local/java-smoke.mjs @@ -0,0 +1,64 @@ +// Opt-in integration check. Runs repository-authored reference answers only. +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, realpath, rm, mkdir, cp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSettingsStore } from '../../server/local/settings.mjs'; +import { createLocalEngine } from '../../server/local/engine.mjs'; +import { exerciseDefinitions } from '../../server/exercises.mjs'; + +const repo = fileURLToPath(new URL('../../', import.meta.url)); +const ephemeralParent = await realpath(tmpdir()); +const root = process.env.JAVA_EDU_SMOKE_ROOT ? path.resolve(process.env.JAVA_EDU_SMOKE_ROOT) : await mkdtemp(path.join(ephemeralParent, 'java-edu-java-smoke-한글 & %JAVA_HOME%-')); +await mkdir(root, { recursive: true }); +if (process.env.JAVA_EDU_SMOKE_CACHE && !process.env.JAVA_EDU_SMOKE_ROOT) await cp(process.env.JAVA_EDU_SMOKE_CACHE, path.join(root, 'maven-repository'), { recursive: true, errorOnExist: true, force: false }); +const settings = createSettingsStore(root); +let engine; +try { + await settings.initialize(); + for (const version of [8, 11, 17, 21]) { + const home = process.env[`JAVA_EDU_JDK_${version}`]; + if (home) await settings.chooseJdk(home, version); + } + await settings.setConsent(true); + if (process.env.JAVA_EDU_MAVEN_HOME) await settings.chooseMaven(process.env.JAVA_EDU_MAVEN_HOME); + engine = await createLocalEngine({ dataRoot: root, projectRoot: path.join(repo, 'curriculum/professional'), settings }); + const solutions = JSON.parse(await readFile(path.join(repo, 'examples/solutions.json'), 'utf8')); + if (process.env.JAVA_EDU_SMOKE_PROJECT) { + for (const id of process.env.JAVA_EDU_SMOKE_PROJECT.split(',')) { + const result = await engine.prepare({ exerciseId: `${id}-project`, requestId: `prepare-${Date.now()}`, runtimeVersion: 21 }); + console.log(JSON.stringify(result.status === 'passed' ? { exerciseId: `${id}-project`, operation: 'prepare', status: result.status, runtimeVersion: result.runtimeVersion, passed: result.passed, total: result.total, durationMs: result.durationMs } : result, null, 2)); + assert.equal(result.status, 'passed'); + const project = await engine.project(`${id}-project`); + const lesson = JSON.parse(await readFile(path.join(repo, `curriculum/professional/lessons/${id}/lesson.json`), 'utf8')); + const files = Object.fromEntries(await Promise.all(lesson.professionalExercise.files.filter((file) => file.editable).map(async (file) => [file.path, await readFile(path.join(repo, 'curriculum/professional', file.referencePath), 'utf8')]))); + const submitted = await engine.execute({ exerciseId: project.exerciseId, files, action: 'submit', requestId: `submit-${Date.now()}`, runtimeVersion: 21 }); + console.log(JSON.stringify({ exerciseId: project.exerciseId, status: submitted.status, runtimeVersion: submitted.runtimeVersion, passed: submitted.passed, total: submitted.total, stderr: submitted.stderr }, null, 2)); + assert.equal(submitted.status, 'passed'); + } + } else { + for (const version of [8, 11, 17, 21]) { + if (!process.env[`JAVA_EDU_JDK_${version}`]) continue; + for (const exercise of exerciseDefinitions.filter((definition) => definition.minVersion <= version && (!process.env.JAVA_EDU_SMOKE_EXERCISE || definition.id === process.env.JAVA_EDU_SMOKE_EXERCISE))) { + const result = await engine.execute({ exerciseId: exercise.id, source: solutions[exercise.id], action: 'submit', requestId: `smoke-${version}-${exercise.id}`, runtimeVersion: version }); + console.log(JSON.stringify({ exerciseId: exercise.id, runtime: result.runtimeVersion, target: result.targetVersion, status: result.status, passed: result.passed, total: result.total, durationMs: result.durationMs })); + if (result.status !== 'passed') console.error(result.compilerOutput, result.stderr, result.stdout); + assert.equal(result.status, 'passed'); + assert.equal(result.runtimeVersion, version); + } + const compilationError = await engine.execute({ exerciseId: 'java-basics-lab', source: 'class Solution { public boolean sameCode(String left, String right) { return 없는변수; } }', action: 'submit', requestId: `smoke-korean-error-${version}`, runtimeVersion: version }); + assert.equal(compilationError.status, 'compile_error'); + assert.match(compilationError.compilerOutput, /없는변수/); + console.log(JSON.stringify({ case: 'Korean compiler diagnostics', runtime: version, status: 'passed' })); + } + } +} finally { + await engine?.close(); + if (!process.env.JAVA_EDU_SMOKE_ROOT) { + const verified = await realpath(root); + assert.equal(path.dirname(verified), ephemeralParent); + assert.ok(path.basename(verified).startsWith('java-edu-java-smoke-')); + await rm(verified, { recursive: true, force: true }); + } +} diff --git a/tests/local/process-fixture.mjs b/tests/local/process-fixture.mjs new file mode 100644 index 0000000..e022bac --- /dev/null +++ b/tests/local/process-fixture.mjs @@ -0,0 +1,17 @@ +// Fixed maintenance fixtures, never learner-supplied commands. +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +if (process.argv[2] === 'echo') process.stdout.write(JSON.stringify(process.argv.slice(3))); +else if (process.argv[2] === 'overflow') { for (let index = 0; index < 5_000; index++) process.stdout.write('x'.repeat(100)); } +else if (process.argv[2] === 'sleep') { writeFileSync(process.argv[3], String(process.pid)); setInterval(() => {}, 1000); } +else if (process.argv[2] === 'tree') { + const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'sleep', process.argv[3]], { stdio: 'inherit', shell: false, windowsHide: true }); + process.stdout.write(`CHILD:${child.pid}\n`); + setInterval(() => {}, 1000); +} +else if (process.argv[2] === 'supervisor-parent') { + const { runProcess, processEnvironment } = await import('../../server/local/process.mjs'); + await runProcess(process.execPath, [fileURLToPath(import.meta.url), 'tree', process.argv[4]], { cwd: process.argv[3], env: processEnvironment(undefined, process.argv[3]), timeoutMs: 20_000 }); +} diff --git a/tests/local/process.test.mjs b/tests/local/process.test.mjs new file mode 100644 index 0000000..c7ef69f --- /dev/null +++ b/tests/local/process.test.mjs @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, realpath, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { runProcess, processEnvironment } from '../../server/local/process.mjs'; + +const fixedFixture = fileURLToPath(new URL('./process-fixture.mjs', import.meta.url)); + +test('Windows Job supervisor preserves Unicode/metacharacter argv and stops timeout process trees', { skip: process.platform !== 'win32' }, async (t) => { + const parent = await realpath(tmpdir()); + const root = await mkdtemp(path.join(parent, 'java-edu-local-test-공백 & % 🚀-')); + t.after(async () => { const resolved = await realpath(root); assert.equal(path.dirname(resolved), parent); assert.ok(path.basename(resolved).startsWith('java-edu-local-test-')); await rm(resolved, { recursive: true, force: true }); }); + const env = processEnvironment(undefined, root); + // Diagnose cold-host failures without adding logging or test hooks to the product helper. + const supervisorSource = await readFile(new URL('../../desktop/job-runner.ps1', import.meta.url), 'utf8'); + const diagnosticHelper = path.join(root, 'diagnostic-helper.ps1'); + let diagnosticSource = supervisorSource; + for (const [needle, stage] of [ + ["$ErrorActionPreference = 'Stop'", 'helper-entered'], + [' Import-Module -Name', 'loading-fixed-utility-module'], + [' $spec = [System.IO.File]::ReadAllText', 'reading-spec'], + [' Add-Type -TypeDefinition', 'compiling-supervisor'], + [' $result = [JavaEduJob]::Run', 'supervisor-compiled'], + [' $report = @{', 'supervisor-returned'], + ]) { + assert.ok(diagnosticSource.includes(needle), `Missing PowerShell diagnostic point: ${stage}`); + diagnosticSource = diagnosticSource.replace(needle, `[Console]::Error.WriteLine('Java Edu CI stage: ${stage} ' + [DateTime]::UtcNow.ToString('o'))\n${needle}`); + } + diagnosticSource = diagnosticSource.replace(' Add-Type -TypeDefinition', `[Console]::Error.WriteLine('Java Edu CI platform: codepage=' + [System.Text.Encoding]::Default.CodePage + '; elevated=' + (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))\n Add-Type -TypeDefinition`); + diagnosticSource = diagnosticSource.replace(" + $_.Exception.Message)", " + $_.Exception.ToString())"); + for (const [needle, stage] of [ + [' parent = OpenProcess', 'opening-parent'], + [' job = CreateJobObject', 'creating-job'], + [' Check(CreateProcess', 'creating-child'], + [' if (ResumeThread', 'resuming-child'], + [' var reason = "exited";', 'waiting-child'], + [' uint code; Check(GetExitCodeProcess', 'child-wait-finished'], + ]) { + assert.ok(diagnosticSource.includes(needle), `Missing native diagnostic point: ${stage}`); + diagnosticSource = diagnosticSource.replace(needle, ` Console.Error.WriteLine("Java Edu CI stage: ${stage} " + DateTime.UtcNow.ToString("o"));\n${needle}`); + } + await writeFile(diagnosticHelper, `\ufeff${diagnosticSource}`); + const argv = ['공백 포함', 'a&b%PATH%', 'a"quoted"', 'trailing\\', '']; + let compilerTemp; + const echo = await runProcess(process.execPath, [fixedFixture, 'echo', ...argv], { + cwd: root, env, + spawnImpl: (command, args, options) => { + const fileIndex = args.indexOf('-File'); + assert.ok(fileIndex >= 0, 'Windows supervisor must execute its fixed script.'); + const diagnosticArgs = [...args]; + diagnosticArgs[fileIndex + 1] = diagnosticHelper; + compilerTemp = options.env.TEMP; + assert.equal(options.env.TMP, compilerTemp); + assert.equal(path.dirname(compilerTemp), parent); + assert.notEqual(compilerTemp, root); + return spawn(command, diagnosticArgs, options); + }, + }); + assert.equal(echo.exitCode, 0, `Node ${process.version}; supervisor echo result: ${JSON.stringify(echo)}`); + t.diagnostic(`Node ${process.version}; supervisor echo completed in ${echo.durationMs}ms`); + t.diagnostic(echo.stderr.split(/\r?\n/).find((line) => line.startsWith('Java Edu CI platform:'))); + assert.deepEqual(JSON.parse(echo.stdout), argv); + await assert.rejects(realpath(compilerTemp), { code: 'ENOENT' }); + assert.equal(env.TEMP, root, 'Starting the helper must not mutate the learner environment object.'); + let nextCompilerTemp; + const childEnvironment = await runProcess(process.execPath, ['-p', 'JSON.stringify([process.env.TEMP,process.env.TMP,process.cwd()])'], { cwd: root, env, spawnImpl: (command, args, options) => { nextCompilerTemp = options.env.TEMP; return spawn(command, args, options); } }); + assert.equal(childEnvironment.exitCode, 0, JSON.stringify(childEnvironment)); + assert.deepEqual(JSON.parse(childEnvironment.stdout), [root, root, root]); + assert.notEqual(nextCompilerTemp, compilerTemp, 'Supervisor compilation directories must not be shared across invocations.'); + await assert.rejects(realpath(nextCompilerTemp), { code: 'ENOENT' }); + const foreignModules = path.join(root, 'foreign-modules'); + const foreignUtility = path.join(foreignModules, 'Microsoft.PowerShell.Utility'); + await mkdir(foreignUtility, { recursive: true }); + await writeFile(path.join(foreignUtility, 'Microsoft.PowerShell.Utility.psd1'), "@{ RootModule = 'Microsoft.PowerShell.Utility.psm1'; ModuleVersion = '99.0'; FunctionsToExport = @('ConvertFrom-Json', 'Add-Type') }"); + await writeFile(path.join(foreignUtility, 'Microsoft.PowerShell.Utility.psm1'), "function ConvertFrom-Json { throw 'Unexpected third-party module discovery' }; function Add-Type { throw 'Unexpected third-party module discovery' }"); + const fixedModule = await runProcess(process.execPath, [fixedFixture, 'echo', 'fixed-os-module'], { cwd: root, env: { ...env, PSModulePath: foreignModules } }); + assert.equal(fixedModule.exitCode, 0, JSON.stringify(fixedModule)); + assert.deepEqual(JSON.parse(fixedModule.stdout), ['fixed-os-module']); + const pidfile = path.join(root, 'child.pid'); + const tree = await runProcess(process.execPath, [fixedFixture, 'tree', pidfile], { cwd: root, env, timeoutMs: 1200 }); + assert.equal(tree.reason, 'timeout', tree.stderr); + const childPid = Number(await readFile(pidfile, 'utf8')); + assert.throws(() => process.kill(childPid, 0), { code: 'ESRCH' }); + let overflowCompilerTemp; + const flood = await runProcess(process.execPath, [fixedFixture, 'overflow'], { cwd: root, env, outputLimit: 1024, spawnImpl: (command, args, options) => { overflowCompilerTemp = options.env.TEMP; return spawn(command, args, options); } }); + assert.equal(flood.reason, 'output_limit'); + assert.ok(Buffer.byteLength(flood.stdout) <= 1024); + await assert.rejects(realpath(overflowCompilerTemp), { code: 'ENOENT' }); + const orphanPidfile = path.join(root, 'orphan-child.pid'); + const parentProcess = spawn(process.execPath, [fixedFixture, 'supervisor-parent', root, orphanPidfile], { cwd: root, env: { ...env, TEMP: parent, TMP: parent }, shell: false, windowsHide: true, stdio: 'ignore' }); + t.after(() => { if (parentProcess.exitCode === null) parentProcess.kill(); }); + let orphanPid; + for (let retry = 0; retry < 60 && !orphanPid; retry++) { await delay(100); orphanPid = Number(await readFile(orphanPidfile, 'utf8').catch(() => '')); } + assert.ok(orphanPid, 'Supervised descendant must have started.'); + parentProcess.kill(); + let alive = true; + for (let retry = 0; retry < 30 && alive; retry++) { await delay(100); try { process.kill(orphanPid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; else throw error; } } + assert.equal(alive, false, 'Opened parent-process handle must trigger descendant cleanup after app termination.'); + // Instrument only a private test copy to pause exactly after creation and before ResumeThread. + const createdPidfile = path.join(root, 'suspended.pid'); + const escapedPidfile = createdPidfile.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + const instrumented = supervisorSource.replace(/(Check\(CreateProcess\(executable,[^\r\n]+\);\r?\n)/, `$1 System.IO.File.WriteAllText("${escapedPidfile}", pi.pid.ToString()); System.Threading.Thread.Sleep(30000);\n`); + assert.notEqual(instrumented, supervisorSource); + const helperFile = path.join(root, 'instrumented-helper.ps1'); + const specFile = path.join(root, 'boundary-command.json'); + const boundaryCompilerTemp = await mkdtemp(path.join(parent, 'java-edu-local-helper-test-')); + t.after(async () => { const verified = await realpath(boundaryCompilerTemp); assert.equal(path.dirname(verified), parent); assert.ok(path.basename(verified).startsWith('java-edu-local-helper-test-')); await rm(verified, { recursive: true, force: true }); }); + await writeFile(helperFile, `\ufeff${instrumented}`); // Windows PowerShell 5 must read the injected Unicode fixture path as UTF-8. + await writeFile(specFile, JSON.stringify({ executable: process.execPath, args: [fixedFixture, 'echo', 'boundary'], cwd: root, timeoutMs: 5000, memoryMiB: 384, maxProcesses: 8, parentPid: process.pid, resultPath: path.join(root, 'boundary-result.json') })); + const powershell = path.join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe'); + const helper = spawn(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helperFile, '-SpecPath', specFile], { cwd: root, env: { ...env, TEMP: boundaryCompilerTemp, TMP: boundaryCompilerTemp }, shell: false, windowsHide: true, stdio: 'ignore' }); + t.after(() => { if (helper.exitCode === null) helper.kill(); }); + let suspendedPid; + for (let retry = 0; retry < 60 && !suspendedPid; retry++) { await delay(100); suspendedPid = Number(await readFile(createdPidfile, 'utf8').catch(() => '')); } + assert.ok(suspendedPid, 'Suspended child must have been created.'); + t.after(() => { try { process.kill(suspendedPid); } catch { /* supervisor already cleaned up */ } }); + helper.kill(); + let suspendedAlive = true; + for (let retry = 0; retry < 30 && suspendedAlive; retry++) { await delay(100); try { process.kill(suspendedPid, 0); } catch (error) { if (error.code === 'ESRCH') suspendedAlive = false; else throw error; } } + assert.equal(suspendedAlive, false, 'Atomic Job-list creation must clean up even before ResumeThread.'); +}); diff --git a/tests/local/reference-policy.test.mjs b/tests/local/reference-policy.test.mjs new file mode 100644 index 0000000..06c1239 --- /dev/null +++ b/tests/local/reference-policy.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { lessons as coreLessons } from '../../dist/data/curriculum.js'; +import { jobEvidence } from '../../dist/data/job-evidence.js'; +import { externalReferenceUrl } from '../../desktop/reference-policy.cjs'; + +const projectRoot = new URL('../../', import.meta.url); +const text = (relative) => readFile(new URL(relative, projectRoot), 'utf8'); + +test('every core and professional lesson source is permitted by the desktop external link policy', async () => { + const catalog = JSON.parse(await text('curriculum/professional/catalog.json')); + // Read canonical inputs, so this guard also runs before generated assets exist. + const professionalLessons = await Promise.all(catalog.lessons.map(async (entry) => JSON.parse(await text(`curriculum/professional/${entry.lessonPath}`)))); + const lessons = [...coreLessons, ...professionalLessons]; + assert.equal(lessons.length, 34); + let checked = 0; + for (const lesson of lessons) { + assert.ok(lesson.sources.length > 0, `Missing sources for ${lesson.id}`); + for (const source of lesson.sources) { + assert.equal(externalReferenceUrl(source.url), new URL(source.url).href, `${lesson.id}: ${source.url}`); + checked++; + } + } + assert.equal(checked, 90); +}); + +test('hiring evidence, actual UI links and installation guide downloads remain accessible', async () => { + const uiFiles = await Promise.all(['dist/index.html', 'dist/app.js'].map(text)); + const uiUrls = uiFiles.flatMap((content) => [...content.matchAll(/https:\/\/[^\s'"<>`]+/g)].map((match) => match[0])); + const installUrls = [...(await text('docs/installation.md')).matchAll(/\]\((https:\/\/[^\s)]+)\)/g)].map((match) => match[1]); + assert.ok(uiUrls.some((url) => url.startsWith('https://github.com/jbsim999/java-edu/releases'))); + // Bundled releases replaced the former mandatory JDK/Maven download steps. + // Require the actual learner downloads while retaining the host allowlist + // checks for every documented and rendered link below. + for (const extension of ['exe', 'zip']) { + assert.ok(installUrls.some((value) => { + const url = new URL(value); + return url.hostname === 'github.com' + && new RegExp(`^/jbsim999/java-edu/releases/download/v[^/]+/[^/]+\\.${extension}$`, 'i').test(url.pathname); + }), `Missing direct ${extension.toUpperCase()} bundled release download`); + } + assert.equal(jobEvidence.length, 5); + for (const url of [...jobEvidence.map((entry) => entry.url), ...uiUrls, ...installUrls]) { + assert.equal(externalReferenceUrl(url), new URL(url).href, url); + } +}); + +test('external links reject protocols, credentials, ports and host confusion', () => { + for (const value of [ + undefined, null, {}, '', 'not a URL', + 'http://docs.junit.org/5.11.4/user-guide/', + 'javascript:alert(1)', 'file:///C:/Windows/System32/cmd.exe', 'data:text/html,hello', + 'mailto:help@docs.junit.org', '//docs.junit.org/', + 'https://attacker.invalid/', 'https://docs.junit.org.attacker.invalid/', + 'https://subdomain.docs.junit.org/', 'https://docs.junit.org./', + 'https://docs.junit.org@attacker.invalid/', 'https://user:secret@docs.junit.org/', + 'https://@docs.junit.org/', 'https://docs.junit.org:443/', 'https://docs.junit.org:8443/', + 'https://docs.junit.org:/', 'https://%64ocs.junit.org/', + 'https://docs.junit.org\\@attacker.invalid/', 'https:\\docs.junit.org/', + ' https://docs.junit.org/', 'https://docs.ju\tnit.org/', 'https://docs.junit.org/\n', + 'https://127.0.0.1/', 'https://[::1]/', + ]) assert.equal(externalReferenceUrl(value), null, String(value)); +}); + +test('approved reference links preserve document paths, query strings and fragments', () => { + for (const url of [ + 'https://docs.hibernate.org/orm/6.6/userguide/html_single/#locking-optimistic', + 'https://adoptium.net/temurin/releases/?version=21', + 'https://github.com/jbsim999/java-edu/releases', + ]) assert.equal(externalReferenceUrl(url), url); +}); diff --git a/tests/professional.test.mjs b/tests/professional.test.mjs new file mode 100644 index 0000000..48b9edd --- /dev/null +++ b/tests/professional.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { unzipSync, strFromU8 } from 'fflate'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const course = path.join(root, 'curriculum/professional'); +const catalog = JSON.parse(await readFile(path.join(course, 'catalog.json'), 'utf8')); +const profiles = JSON.parse(await readFile(path.join(course, 'profiles.json'), 'utf8')).profiles; + +function safeRelative(value) { + assert.equal(typeof value, 'string'); + assert.ok(value && !path.isAbsolute(value) && !value.includes('\\')); + assert.ok(value.split('/').every(segment => segment && segment !== '.' && segment !== '..'), `safe relative path: ${value}`); + return value; +} + +test('Published course contains 12 core and 22 distinct professional lessons with resolvable learning contracts', () => { + assert.equal(coreLessons.length, 12); + assert.equal(professionalLessons.length, 22); + assert.equal(professionalExercises.length, 22); + assert.equal(catalog.lessons.length, 22); + const allLessons = [...coreLessons, ...professionalLessons]; + assert.equal(new Set(allLessons.map(lesson => lesson.id)).size, 34); + assert.equal(new Set(allLessons.map(lesson => lesson.exerciseId)).size, 34); + const questionIds = []; + for (const lesson of professionalLessons) { + const authored = catalog.lessons.find(item => item.id === lesson.id); + const exercise = professionalExercises.find(item => item.id === lesson.exerciseId); + const profile = profiles.find(item => item.id === exercise?.profileId); + assert.ok(authored && exercise && profile, lesson.id); + assert.equal(exercise.lessonId, lesson.id); + assert.equal(lesson.minVersion, profile.compilerTarget); + assert.equal(exercise.minVersion, lesson.minVersion); + assert.ok(lesson.minVersion >= 17 && [17, 21].includes(lesson.minVersion)); + assert.equal(lesson.preview, false); + assert.equal(exercise.mode, 'project'); + assert.equal(exercise.requiresDesktop, true); + assert.ok(lesson.theory.length >= 2 && lesson.goals.length >= 2 && lesson.quiz.length >= 2); + assert.ok(lesson.example.files.length >= 2 && lesson.practice.tasks.length >= 2); + assert.ok(lesson.sources.length > 0 && lesson.sources.every(source => new URL(source.url).protocol === 'https:' && source.title && source.checkedAt)); + for (const question of lesson.quiz) { + questionIds.push(question.id); + assert.equal(question.options.length, 4); + assert.equal(new Set(question.options).size, 4); + assert.ok(Number.isInteger(question.correctIndex) && question.correctIndex >= 0 && question.correctIndex < 4); + assert.ok(question.question && question.explanation); + } + } + assert.equal(new Set(questionIds).size, questionIds.length, 'professional quiz answers cannot collide by id'); +}); + +test('Professional prerequisite graph resolves against the combined course and has no cycles', () => { + const coreIds = new Set(coreLessons.map(lesson => lesson.id)); + const lessons = new Map(professionalLessons.map(lesson => [lesson.id, lesson])); + const visiting = new Set(); + const visited = new Set(); + function visit(id) { + if (visited.has(id)) return; + assert.equal(visiting.has(id), false, `cyclic prerequisite at ${id}`); + visiting.add(id); + for (const prerequisite of lessons.get(id).prerequisites) { + if (prerequisite.startsWith('core:')) assert.ok(coreIds.has(prerequisite.slice(5)), `${id}: ${prerequisite}`); + else { assert.ok(lessons.has(prerequisite), `${id}: ${prerequisite}`); visit(prerequisite); } + } + visiting.delete(id); + visited.add(id); + } + for (const id of lessons.keys()) visit(id); +}); + +test('Published editable files and readonly POM match authored templates and example reference sources', async () => { + for (const lesson of professionalLessons) { + const exercise = professionalExercises.find(item => item.id === lesson.exerciseId); + const contract = lesson.professionalExercise; + assert.equal(new Set(exercise.files.map(file => file.path)).size, exercise.files.length); + assert.ok(exercise.files.some(file => file.editable), lesson.id); + assert.equal(exercise.files.find(file => file.path === 'pom.xml')?.editable, false, `${lesson.id} build file is immutable`); + assert.deepEqual(exercise.files.map(file => file.path).sort(), contract.files.map(file => file.path).sort()); + for (const file of exercise.files) { + safeRelative(file.path); + const authored = contract.files.find(item => item.path === file.path); + assert.equal(file.editable, authored.editable === true); + assert.equal(file.source, await readFile(path.join(course, safeRelative(authored.templatePath)), 'utf8'), `${lesson.id}/${file.path} template`); + const example = lesson.example.files.find(item => item.path === file.path); + assert.ok(example, `${lesson.id}/${file.path} has a visible reference`); + assert.equal(example.code, await readFile(path.join(course, safeRelative(authored.referencePath)), 'utf8'), `${lesson.id}/${file.path} reference`); + } + } +}); + +test('Every downloadable ZIP is readable and contains the exact parent POM, starter, reference, and trusted tests', async () => { + const parentPom = await readFile(path.join(course, 'pom.xml'), 'utf8'); + for (const lesson of professionalLessons) { + const exercise = professionalExercises.find(item => item.id === lesson.exerciseId); + assert.equal(exercise.downloadUrl, `/downloads/${lesson.id}.zip`); + const archive = unzipSync(await readFile(path.join(root, 'dist', exercise.downloadUrl.slice(1)))); + for (const name of Object.keys(archive)) { + safeRelative(name); + assert.doesNotMatch(name, /(?:^|\/)(?:target|node_modules)\//, 'downloads exclude generated directories'); + assert.doesNotMatch(name, /\.(?:exe|dll|cmd|bat|ps1)$/i, 'downloads contain learning source, not executables'); + } + assert.equal(strFromU8(archive['pom.xml']), parentPom); + assert.match(strFromU8(archive['README.txt']), /Maven/); + for (const file of lesson.professionalExercise.files) { + for (const [kind, relative] of [['starter', file.templatePath], ['reference', file.referencePath]]) { + const name = `lessons/${lesson.id}/${kind}/${file.path}`; + assert.ok(archive[name], name); + assert.equal(strFromU8(archive[name]), await readFile(path.join(course, safeRelative(relative)), 'utf8'), name); + } + } + for (const file of lesson.professionalExercise.testFiles) { + const name = `lessons/${lesson.id}/grader/${file.path}`; + assert.ok(archive[name], name); + assert.equal(strFromU8(archive[name]), await readFile(path.join(course, safeRelative(file.sourcePath)), 'utf8'), name); + } + } +}); diff --git a/tests/readability.mjs b/tests/readability.mjs new file mode 100644 index 0000000..d97d4cb --- /dev/null +++ b/tests/readability.mjs @@ -0,0 +1,687 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; +import { lessonVisuals } from '../dist/data/lesson-visuals.js'; + +// Isolated browser emulation only. Every execution API and external navigation +// is intercepted; no learner source or source-reference request leaves the PC. +const root = fileURLToPath(new URL('../', import.meta.url)); +const base = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; +const artifacts = path.resolve(process.env.QA_ARTIFACT_DIR || path.join(root, 'work/readability-qa')); +const filter = process.env.QA_FILTER ? new RegExp(process.env.QA_FILTER) : null; +const lessons = [...coreLessons, ...professionalLessons]; +const storageKey = 'java-edu:progress:v1'; +const results = []; +const filesToHash = ['dist/index.html', 'dist/app.js', 'dist/styles.css', 'dist/data/curriculum.js', 'dist/data/professional.js', 'dist/data/lesson-visuals.js', 'tests/readability.mjs']; +let browser; + +async function hashes() { + return Object.fromEntries(await Promise.all(filesToHash.map(async relative => + [relative, createHash('sha256').update(await readFile(path.join(root, relative))).digest('hex')]))); +} + +async function eventually(read, expected, message, timeout = 8000) { + const deadline = Date.now() + timeout; + let value; + do { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await new Promise(resolve => setTimeout(resolve, 40)); + } while (Date.now() < deadline); + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function openLesson(page, id, step = 'theory') { + const lesson = lessons.find(item => item.id === id); + await page.evaluate(hash => { location.hash = hash; }, `lesson=${id}&step=${step}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), `${id} selected`); + await eventually(() => page.locator(`[data-step="${step}"]`).getAttribute('aria-selected'), 'true', `${step} selected`); +} + +async function snapshot(page) { + return page.evaluate(key => { + const stored = JSON.parse(localStorage.getItem(key) || '{}'); + return Object.fromEntries(['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes'].map(field => [field, stored[field] || []])); + }, storageKey); +} + +async function originalTheory(page, lesson) { + const sections = await page.locator('#lesson-panel > .theory-section').evaluateAll(nodes => + nodes.map(node => ({ heading: node.querySelector('h3').textContent, text: node.querySelector('p').textContent }))); + assert.deepEqual(sections, lesson.theory.map(({ heading, text }) => ({ heading, text })), `${lesson.id}: original theory remains accessible`); + for (const section of await page.locator('#lesson-panel > .theory-section').all()) assert.equal(await section.isVisible(), true, `${lesson.id}: complete theory is expanded by default`); +} + +async function keyboardDetails(page, selector, open) { + const details = page.locator(selector); + const summary = details.locator(':scope > summary'); + await summary.focus(); + if (await details.evaluate(node => node.open) !== open) await page.keyboard.press(open ? 'Enter' : 'Space'); + assert.equal(await details.evaluate(node => node.open), open, `${selector}: keyboard disclosure state`); + assert.equal(await summary.evaluate(node => node === document.activeElement), true, `${selector}: summary retains focus`); + const outline = await summary.evaluate(node => ({ width: getComputedStyle(node).outlineWidth, style: getComputedStyle(node).outlineStyle })); + assert.ok(parseFloat(outline.width) > 0 && outline.style !== 'none', `${selector}: visible keyboard focus`); +} + +async function inspectSupport(page, lesson) { + assert.equal(await page.locator('#lesson-info').evaluate(node => node.open), false); + assert.equal(await page.locator('#reference-column').evaluate(node => node.open), false); + assert.equal(await page.locator('.learning-goals').count(), 1, 'goals appear once'); + assert.equal(await page.locator('#lesson-panel .learning-goals').count(), 0, 'goals do not repeat within the reading flow'); + assert.equal(await page.locator('#lesson-description').isVisible(), false); + assert.equal(await page.locator('#source-list').isVisible(), false); + assert.equal(await page.locator('#lesson-description').textContent(), lesson.description); + assert.deepEqual(await page.locator('#lesson-goals li span').allTextContents(), lesson.goals); + await keyboardDetails(page, '#lesson-info', true); + assert.equal(await page.locator('#lesson-description').isVisible(), true); + await keyboardDetails(page, '#reference-column', true); + const sources = await page.locator('#source-list a').evaluateAll(nodes => nodes.map(node => ({ + title: node.querySelector('strong').textContent, url: node.href, target: node.target, rel: node.rel, metadata: node.querySelector('span').textContent, + }))); + assert.deepEqual(sources.map(({ title, url }) => ({ title, url })), lesson.sources.map(({ title, url }) => ({ title, url: new URL(url).href }))); + sources.forEach((source, index) => { + assert.equal(source.target, '_blank'); + assert.ok(source.rel.includes('noopener') && source.rel.includes('noreferrer')); + if (lesson.sources[index].edition) assert.ok(source.metadata.includes(lesson.sources[index].edition)); + if (lesson.sources[index].checkedAt) assert.ok(source.metadata.includes(lesson.sources[index].checkedAt)); + }); + assert.match(await page.locator('.evidence-disclaimer').textContent(), /통계가 아닙니다/); + assert.match(await page.locator('.evidence-disclaimer').textContent(), /확인일.*게시일.*채용 여부/); + await keyboardDetails(page, '#reference-column', false); + await keyboardDetails(page, '#lesson-info', false); + const legend = page.locator('.code-legend-disclosure'); + if (await legend.count()) { + assert.equal(await legend.evaluate(node => node.open), false); + assert.equal(await legend.locator('.syntax-legend').isVisible(), false); + await keyboardDetails(page, '.code-legend-disclosure', true); + assert.equal(await legend.locator('.syntax-legend').isVisible(), true); + assert.ok(await legend.locator('.syntax-legend-item').count() >= 3); + await keyboardDetails(page, '.code-legend-disclosure', false); + } +} + +async function inspectGuide(page, lesson) { + const guide = lesson.learningGuide; + assert.equal(await page.locator('.learning-guide').count(), 1, `${lesson.id}: single guide`); + for (const [selector, expected] of [ + ['.guide-scenario-text', guide.scenario], ['.guide-question', guide.question], + ['.guide-code-filename', guide.code.file], ['#theory-code-panel code', guide.code.source], + ['.guide-prediction-question', guide.prediction.question], ['.guide-answer-text', guide.prediction.answer], + ['.guide-answer-explanation', guide.prediction.explanation], ['.guide-takeaway > p:last-child', guide.takeaway], + ]) assert.equal(await page.locator(selector).textContent(), expected, `${lesson.id}: ${selector}`); + const terms = await page.locator('.guide-term').evaluateAll(nodes => nodes.map(node => + ({ term: node.querySelector('dt').textContent, meaning: node.querySelector('dd').textContent }))); + assert.deepEqual(terms, guide.terms, `${lesson.id}: accessible term-definition pairs`); + assert.deepEqual(await page.locator('.guide-walkthrough li').allTextContents(), guide.walkthrough, `${lesson.id}: numbered code trace`); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null, `${lesson.id}: answer initially hidden`); + assert.equal(await page.locator('.guide-answer-text').isVisible(), false); + const order = await page.locator('.learning-guide').evaluate(node => [...node.children].map(child => child.className)); + assert.deepEqual(order, ['guide-scenario', 'guide-section guide-terms', 'guide-section guide-code-section', 'guide-section guide-walkthrough', 'guide-section guide-prediction-section', 'guide-takeaway']); + assert.equal(await page.locator('#theory-code-panel').getAttribute('tabindex'), '0', 'long code block accepts keyboard focus'); + assert.match(await page.locator('#theory-code-panel').getAttribute('aria-label'), /이론.*발췌/); + const invalidIds = await page.evaluate(() => { + const ids = [...document.querySelectorAll('[id]')].map(node => node.id); + const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index); + const unresolved = [...document.querySelectorAll('.learning-guide [aria-describedby],.learning-guide [aria-labelledby]')] + .flatMap(node => ['aria-describedby', 'aria-labelledby'].flatMap(attribute => (node.getAttribute(attribute) || '').split(/\s+/).filter(Boolean))) + .filter(id => !document.getElementById(id)); + return { duplicates, unresolved }; + }); + assert.deepEqual(invalidIds, { duplicates: [], unresolved: [] }); + await originalTheory(page, lesson); +} + +async function geometry(page, id) { + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const value = await page.evaluate(() => { + const guide = document.querySelector('.learning-guide'); + const boundary = guide?.getBoundingClientRect(); + const bad = guide ? [...guide.querySelectorAll('h3,p,dt,dd,li,summary,button,select,pre,.guide-code-filename,.syntax-legend-item')].flatMap(node => { + if (!node.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true })) return []; + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + const outside = rect.left < boundary.left - 1 || rect.right > boundary.right + 1; + const clipped = node.tagName !== 'PRE' && node.clientWidth > 0 && ['clip', 'hidden'].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1; + return outside || clipped ? [{ tag: node.tagName, text: node.textContent.slice(0, 100), outside, clipped, left: rect.left, right: rect.right }] : []; + }) : []; + const card = document.querySelector('#lesson-card').getBoundingClientRect(); + for (const node of document.querySelectorAll('.lesson-support p,.lesson-support a,.lesson-support summary,.lesson-support li,.lesson-support span,.lesson-support strong,.lesson-support h3,.reference-card')) { + // Closed native details can retain old layout rectangles in Chromium. + // Only visible descendants participate; the suite also opens every card. + if (!node.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true })) continue; + const rect = node.getBoundingClientRect(); + if (rect.left < card.left - 1 || rect.right > card.right + 1) bad.push({ tag: node.tagName, text: node.textContent.slice(0, 100), left: rect.left, right: rect.right, reason: 'expanded support escapes lesson card' }); + } + return { width: document.documentElement.clientWidth, scroll: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth), bad }; + }); + assert.ok(value.scroll <= value.width + 1, `${id}: page horizontal overflow ${JSON.stringify(value)}`); + assert.deepEqual(value.bad, [], `${id}: visible lesson content escapes or clips its container`); +} + +async function activeTabGeometry(page, label) { + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const value = await page.locator('.lesson-tabs [aria-selected="true"]').evaluate(node => { + const rect = node.getBoundingClientRect(); + const strip = node.parentElement.getBoundingClientRect(); + return { tab: node.id, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, stripLeft: strip.left, stripRight: strip.right, stripTop: strip.top, stripBottom: strip.bottom, scrollLeft: node.parentElement.scrollLeft }; + }); + assert.ok(value.left >= value.stripLeft - 1 && value.right <= value.stripRight + 1 && value.top >= value.stripTop - 1 && value.bottom <= value.stripBottom + 1, `${label}: selected tab fully visible inside strip ${JSON.stringify(value)}`); + return value; +} + +async function capture(page, filename, selector = '.guide-scenario') { + await page.locator(selector).first().scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(artifacts, `${filename}.png`), fullPage: false }); +} + +async function captureBoundary(page, filename, nextSelector) { + // Ordinary document scrolling: retain the real composed viewport, including + // the preceding content and the start of the next existing section. + await page.locator(nextSelector).first().evaluate(node => { + const top = node.getBoundingClientRect().top + scrollY; + window.scrollTo({ top: Math.max(0, top - innerHeight * 0.4), behavior: 'instant' }); + }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await page.screenshot({ path: path.join(artifacts, `${filename}.png`), fullPage: false }); +} + +function contrastRatio(foreground, background) { + const luminance = rgb => rgb.match(/[\d.]+/g).slice(0, 3).map(value => Number(value) / 255) + .map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) + .reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0); + const [a, b] = [luminance(foreground), luminance(background)]; + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +async function run(name, test, options = {}) { + if (filter && !filter.test(name)) return; + const context = await browser.newContext({ viewport: options.viewport || { width: 1440, height: 1000 }, hasTouch: options.touch || false, serviceWorkers: 'block' }); + const requests = [], external = [], pageErrors = []; + await context.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { + writeText: async value => { + if (window.qaClipboardDenied) throw new DOMException('QA denied', 'NotAllowedError'); + window.qaClipboardText = value; + }, + } }); + }); + await context.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin !== new URL(base).origin) { + external.push(url.href); + return route.fulfill({ contentType: 'text/plain', body: 'QA blocks remote network.' }); + } + if (url.pathname.startsWith('/api/')) { + requests.push({ path: url.pathname, method: route.request().method() }); + if (url.pathname === '/api/runtime') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ provider: 'Wandbox', available: true, runtime: { javaVersion: 21 }, supportedTargets: [8, 11, 17, 21], notice: 'QA 모의 실행. 외부 코드 전송 없음.' }) }); + if (url.pathname === '/api/execute' && options.mockSubmit) { + const request = route.request().postDataJSON(); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ requestId: request.requestId, status: 'passed', runtimeVersion: 21, targetVersion: 8, stdout: 'QA mocked result', stderr: '', compilerOutput: '', checks: [{ name: 'QA mock check', passed: true }], passed: 1, total: 1, provider: 'QA mock', durationMs: 1 }) }); + } + return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { code: 'qa_no_execution', message: 'This suite blocks execution.' } }) }); + } + if (url.pathname === '/data/curriculum.js' && options.core) return route.fulfill({ contentType: 'text/javascript', body: `export const lessons = ${JSON.stringify(options.core)};` }); + if (url.pathname === '/data/professional.js' && options.professional) return route.fulfill({ contentType: 'text/javascript', body: `export const professionalLessons = ${JSON.stringify(options.professional)}; export const professionalExercises = ${JSON.stringify(professionalExercises)};` }); + if (url.pathname === '/assets/editor.bundle.js' && options.noEditor) return route.abort('failed'); + return route.continue(); + }); + const page = await context.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', error => pageErrors.push(error.message)); + const started = Date.now(); + try { + await page.goto(base); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + if (options.font === 200) await page.locator('html').evaluate(node => { node.style.fontSize = '200%'; }); + const evidence = await test({ page, context, requests, external }); + assert.deepEqual(pageErrors, [], 'no uncaught browser errors'); + assert.equal(requests.filter(request => request.path !== '/api/runtime').length, options.mockSubmit ? 1 : 0, 'only the explicitly requested mock submission is permitted'); + if (options.activateReference) assert.equal(external.length, 1, 'one explicitly tested source activation is intercepted'); + else assert.deepEqual(external, [], 'this case does not activate remote links'); + results.push({ name, status: 'PASS', durationMs: Date.now() - started, requests, ...(evidence ? { evidence } : {}) }); + console.log(`PASS ${name}`); + } catch (error) { + await page.screenshot({ path: path.join(artifacts, `failure-${name}.png`), fullPage: true }).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started, requests, pageErrors }); + console.error(`FAIL ${name}: ${error.stack}`); + } finally { await context.close(); } +} + +async function main() { + await mkdir(artifacts, { recursive: true }); + assert.equal(lessons.length, 34); + assert.ok(lessons.every(lesson => lesson.learningGuide), 'wait until all 34 guides and the generated professional distribution are ready'); + const response = await fetch(base, { signal: AbortSignal.timeout(5000) }); + assert.ok(response.ok, 'parent-owned preview is available'); + const before = await hashes(); + browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) }); + const browserVersion = browser.version(); + try { + await run('all34-guide-content-order-and-original-theory', async ({ page }) => { + for (const lesson of lessons) { + await openLesson(page, lesson.id); + await inspectGuide(page, lesson); + await geometry(page, lesson.id); + await page.locator('.learning-guide').screenshot({ path: path.join(artifacts, `${lesson.id}-desktop-guide.png`) }); + } + for (const [id, viewport] of [['java-basics', { width: 1440, height: 1000 }], ['jpa-state', { width: 390, height: 844 }]]) { + await page.setViewportSize(viewport); + await openLesson(page, id); + await geometry(page, id); + await captureBoundary(page, `${id}-${viewport.width}-guide-to-theory-boundary`, '.theory-reading-title'); + await captureBoundary(page, `${id}-${viewport.width}-theory-to-visual-boundary`, '.theory-visual'); + if (viewport.width === 390) { + await page.locator('#mobile-menu-toggle').click(); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'true'); + await page.screenshot({ path: path.join(artifacts, `${id}-390-open-curriculum-drawer.png`), fullPage: false }); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#mobile-menu-toggle').getAttribute('aria-expanded'), 'false'); + assert.equal(await page.locator('#mobile-menu-toggle').evaluate(node => node === document.activeElement), true); + } + } + }); + + await run('all34-support-disclosures-keyboard-focus-and-source-access', async ({ page, context, external }) => { + const before = await snapshot(page); + for (const lesson of lessons) { + await openLesson(page, lesson.id); + await inspectSupport(page, lesson); + assert.equal(await page.locator('.guide-scenario > h3').count(), 1); + assert.equal(await page.locator('.guide-scenario > .small-label').count(), 0); + if (lesson.id === 'java-basics') { + for (const [selector, name, target] of [['#lesson-info', 'lesson-info', '.lesson-information'], ['#reference-column', 'references', '.reference-content'], ['.code-legend-disclosure', 'syntax-legend', '.guide-code-tools']]) { + await keyboardDetails(page, selector, true); + await capture(page, `java-basics-1440-expanded-${name}`, target); + await keyboardDetails(page, selector, false); + } + } + } + await openLesson(page, 'exceptions'); + await keyboardDetails(page, '#reference-column', true); + const source = page.locator('#source-list a').first(); + await source.focus(); + const expected = await source.getAttribute('href'); + const popupPromise = context.waitForEvent('page'); + await page.keyboard.press('Enter'); + const popup = await popupPromise; + await popup.waitForLoadState('domcontentloaded'); + assert.equal(popup.url(), new URL(expected).href); + assert.equal(await popup.evaluate(() => window.opener), null); + assert.deepEqual(external, [new URL(expected).href]); + assert.match(await popup.locator('body').innerText(), /QA blocks remote network/); + await popup.close(); + await keyboardDetails(page, '#reference-column', false); + assert.deepEqual(await snapshot(page), before, 'all informational disclosure actions preserve records'); + return { sourceUrl: expected, sourceActivation: 'intercepted local mock; external source content not fetched' }; + }, { activateReference: true }); + + await run('wide-reading-layout-and-twelve-original-diagram-boundaries', async ({ page }) => { + const widths = []; + for (const [width, minimum] of [[1280, 880], [1440, 1000], [1920, 1200]]) { + await page.setViewportSize({ width, height: width === 1280 ? 600 : 1000 }); + await openLesson(page, 'java-basics'); + await geometry(page, `wide-${width}`); + const measurements = await page.evaluate(() => { + const widthOf = selector => document.querySelector(selector).getBoundingClientRect().width; + return { viewport: innerWidth, lesson: widthOf('#lesson-card'), panel: widthOf('#lesson-panel'), code: widthOf('.guide-code'), visual: widthOf('.theory-visual'), sidebar: widthOf('#curriculum-sidebar'), columns: getComputedStyle(document.querySelector('#workspace-grid')).gridTemplateColumns, referenceInsideArticle: document.querySelector('#lesson-card').contains(document.querySelector('#reference-column')) }; + }); + assert.ok(measurements.code >= minimum && measurements.visual >= minimum, `wide reading target: ${JSON.stringify(measurements)}`); + assert.equal(measurements.columns.split(' ').length, 2, 'only navigation and lesson occupy persistent columns'); + assert.equal(measurements.referenceInsideArticle, true); + widths.push(measurements); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + if (width === 1280) { + const visible = await page.locator('.guide-scenario-text').evaluate(node => { + const rect = node.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, height: rect.height, visibleHeight: Math.min(innerHeight, rect.bottom) - Math.max(0, rect.top) }; + }); + assert.ok(visible.visibleHeight >= Math.min(visible.height, 40) - 1, `1280×600 starts with readable scenario: ${JSON.stringify(visible)}`); + } + await page.screenshot({ path: path.join(artifacts, `reading-layout-${width}-top.png`), fullPage: false }); + } + const transitions = []; + for (const width of [1280, 1250, 1280]) { + await page.setViewportSize({ width, height: 600 }); + await geometry(page, `desktop-transition-${width}`); + transitions.push({ width, codeWidth: (await page.locator('.guide-code').boundingBox()).width }); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await page.screenshot({ path: path.join(artifacts, `reading-layout-${width}-transition-top.png`), fullPage: false }); + } + assert.ok(Math.abs(transitions[0].codeWidth - transitions[1].codeWidth) < 100, '1250 breakpoint does not suddenly narrow the reading area'); + assert.equal(transitions[0].codeWidth, transitions[2].codeWidth, 'restored desktop viewport restores reading width'); + assert.equal(Object.keys(lessonVisuals).length, 12); + const diagrams = []; + await page.setViewportSize({ width: 1440, height: 1000 }); + for (const [id, visual] of Object.entries(lessonVisuals)) { + await openLesson(page, id); + const figure = page.locator('#lesson-panel > figure.theory-visual'); + assert.equal(await figure.count(), 1); + assert.equal(await figure.isVisible(), true); + assert.equal(await figure.getAttribute('data-visual-id'), visual.id); + assert.equal(await figure.locator('.visual-title').textContent(), visual.title); + assert.equal(await figure.locator('.visual-description').textContent(), visual.caption); + assert.equal(await figure.locator('.visual-alt-text').textContent(), visual.altText); + const preceding = await figure.evaluate(node => [...node.parentElement.children].slice(0, [...node.parentElement.children].indexOf(node)).filter(item => item.classList.contains('theory-section')).length); + assert.equal(preceding, visual.afterSection + 1, `${id}: original theory boundary remains`); + assert.equal(await figure.evaluate(node => Boolean(node.closest('details'))), false, 'diagram is not hidden in an information disclosure'); + diagrams.push({ id, visual: visual.id, precedingTheorySections: preceding }); + } + return { widths, transitions, diagrams }; + }); + + await run('all34-five-stages-retain-example-quiz-practice-and-lab', async ({ page }) => { + for (const lesson of lessons) { + for (const step of ['theory', 'example', 'quiz', 'practice', 'lab']) { + await openLesson(page, lesson.id, step); + assert.ok((await page.locator('#lesson-panel').innerText()).trim().length > 40, `${lesson.id}/${step}`); + assert.equal(await page.locator('#lesson-panel').getAttribute('aria-labelledby'), `tab-${step}`); + if (step !== 'theory') assert.equal(await page.locator('.learning-guide').count(), 0); + } + } + }); + + await run('keyboard-disclosure-repetition-never-grants-achievement', async ({ page }) => { + for (const id of ['java-basics', 'jpa-state', 'jwt-validation']) { + await openLesson(page, id); + const before = await snapshot(page); + const summary = page.locator('.guide-prediction summary'); + await summary.focus(); + for (let repeat = 0; repeat < 3; repeat++) { + await page.keyboard.press('Enter'); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), ''); + assert.equal(await page.locator('.guide-answer-text').isVisible(), true); + await page.keyboard.press('Space'); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null); + assert.equal(await summary.evaluate(node => node === document.activeElement), true); + } + await page.keyboard.press('Enter'); + assert.deepEqual(await snapshot(page), before, 'prediction has no persistence side effects'); + assert.equal(await page.locator('#overall-completed').textContent(), '0'); + assert.equal(await page.locator('#lesson-achievements [data-achievement="quiz"]').getAttribute('data-complete'), 'false'); + assert.equal(await page.locator('#lesson-achievements [data-achievement="lab"]').getAttribute('data-complete'), 'false'); + await capture(page, `${id}-keyboard-answer`, '.guide-prediction'); + } + }); + + await run('three-themes-copy-selection-and-keyboard-code-scroll', async ({ page }) => { + await openLesson(page, 'java-basics'); + const source = coreLessons.find(lesson => lesson.id === 'java-basics').learningGuide.code.source; + const palettes = []; + for (const theme of ['dark', 'light', 'contrast']) { + await page.locator('#theory-theme-select').selectOption(theme); + assert.equal(await page.locator('.guide-code').getAttribute('data-editor-theme'), theme); + const palette = await page.locator('#theory-code-panel').evaluate(node => ({ + background: getComputedStyle(node.closest('.code-shell')).backgroundColor, + foreground: getComputedStyle(node).color, + colors: [...new Set([...node.querySelectorAll('[class*="tok-"]')].map(token => getComputedStyle(token).color))], + })); + assert.ok(palette.colors.length >= 3, `${theme}: syntax colors are rendered`); + for (const color of [...palette.colors, palette.foreground]) assert.ok(contrastRatio(color, palette.background) >= 4.5, `${theme}: code color contrast ${color}/${palette.background}`); + palettes.push(palette); + await page.locator('#copy-theory-code').focus(); + await page.keyboard.press('Enter'); + assert.equal(await page.evaluate(() => window.qaClipboardText), source); + await page.evaluate(() => { window.qaClipboardDenied = true; }); + await page.locator('#copy-theory-code').click(); + assert.equal(await page.evaluate(() => getSelection().toString()), source, 'denied clipboard selects the complete source'); + await capture(page, `java-basics-${theme}-selected-code`, '#theory-code-panel'); + await page.evaluate(() => { window.qaClipboardDenied = false; getSelection().removeAllRanges(); }); + } + assert.notDeepEqual(palettes[0], palettes[1]); + assert.notDeepEqual(palettes[0], palettes[2]); + await page.setViewportSize({ width: 320, height: 720 }); + const pre = page.locator('#theory-code-panel'); + await pre.focus(); + const scrollable = await pre.evaluate(node => node.scrollWidth > node.clientWidth); + if (scrollable) { + await page.keyboard.press('ArrowRight'); + await eventually(() => pre.evaluate(node => node.scrollLeft), value => value > 0, 'keyboard can scroll long code without page overflow'); + } + await geometry(page, 'keyboard-code-scroll'); + await page.reload(); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#theory-theme-select').inputValue(), 'contrast'); + }); + + await run('guides-preserve-earned-progress-core-and-project-drafts-and-notes', async ({ page, requests }) => { + const first = coreLessons[0]; + await openLesson(page, first.id, 'quiz'); + for (const question of first.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await openLesson(page, first.id, 'practice'); + const note = '코드를 따라 읽은 뒤에도 보존할 QA 설계 기록'; + await page.locator('#design-notes').fill(note); + await openLesson(page, first.id, 'lab'); + const draft = `${coreExercises.find(exercise => exercise.id === first.exerciseId).template}\n// independent readability QA draft\n`; + await page.locator('#code-editor [role="textbox"]').fill(draft); + await page.locator('#submit-code').click(); + await eventually(() => page.locator('#lesson-achievements [data-achievement="lab"]').getAttribute('data-complete'), 'true', 'mock submission establishes a prior earned snapshot'); + await openLesson(page, professionalLessons[0].id, 'lab'); + const file = professionalExercises[0].files.find(item => item.editable); + const projectDraft = `${file.source}\n// independent QA project draft\n`; + await page.locator('#code-editor [role="textbox"]').fill(projectDraft); + await openLesson(page, first.id); + const before = await snapshot(page); + assert.ok(before.quizPassed.includes(first.id)); + assert.ok(before.labPassed.some(item => item.source === draft)); + assert.ok(before.drafts.some(item => item.source === draft)); + assert.ok(before.fileDrafts.some(item => item.source === projectDraft)); + for (const id of ['java-basics', 'oop', 'jpa-state', 'security-csrf', 'jwt-validation', 'kafka-idempotency']) { + await openLesson(page, id); + for (const selector of ['#lesson-info', '#reference-column']) { + await keyboardDetails(page, selector, true); + await keyboardDetails(page, selector, false); + } + await page.locator('.guide-prediction summary').click(); + await page.locator('#theory-theme-select').selectOption('light'); + await page.locator('#copy-theory-code').click(); + } + await page.reload(); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.deepEqual(await snapshot(page), before); + assert.equal(requests.filter(request => request.path === '/api/execute').length, 1); + await openLesson(page, first.id, 'practice'); + assert.equal(await page.locator('#design-notes').inputValue(), note); + await openLesson(page, first.id); + assert.equal(await page.locator('#overall-completed').textContent(), '1'); + assert.equal(await page.locator('#theory-theme-select').inputValue(), 'light'); + }, { mockSubmit: true }); + + const withoutGuides = values => values.map(({ learningGuide, ...lesson }) => lesson); + await run('all34-missing-guide-fallback-keeps-original-theory-and-five-stages', async ({ page }) => { + for (const lesson of lessons) { + await openLesson(page, lesson.id); + assert.equal(await page.locator('.learning-guide').count(), 0); + await originalTheory(page, lesson); + for (const step of ['example', 'quiz', 'practice', 'lab']) { + await openLesson(page, lesson.id, step); + assert.ok((await page.locator('#lesson-panel').innerText()).trim().length > 40); + } + } + }, { core: withoutGuides(coreLessons), professional: withoutGuides(professionalLessons) }); + + const malformed = structuredClone(coreLessons); + [null, [], 5, { terms: [null, {}, { term: '빈 정의' }], code: { source: 4 }, walkthrough: [null, 5] }].forEach((value, index) => { malformed[index].learningGuide = value; }); + await run('malformed-optional-guide-values-do-not-break-original-learning', async ({ page }) => { + for (const lesson of malformed.slice(0, 4)) { + await openLesson(page, lesson.id); + assert.equal(await page.locator('.learning-guide').count(), 0); + await originalTheory(page, lesson); + await page.locator('#lesson-panel .next-step-row button').click(); + assert.equal(await page.locator('[data-step="example"]').getAttribute('aria-selected'), 'true'); + } + }, { core: malformed }); + + const injected = structuredClone(coreLessons); + const payload = ''; + Object.assign(injected[0].learningGuide, { scenario: payload, question: payload, terms: [{ term: payload, meaning: payload }], code: { file: 'Main.java', source: payload }, walkthrough: [payload], prediction: { question: payload, answer: payload, explanation: payload }, takeaway: payload }); + await run('guide-content-is-rendered-as-text-not-executable-markup', async ({ page }) => { + await openLesson(page, injected[0].id); + await page.locator('.guide-prediction summary').click(); + for (const selector of ['.guide-scenario-text', '.guide-question', '#theory-code-panel code', '.guide-answer-text']) assert.equal(await page.locator(selector).textContent(), payload); + assert.equal(await page.locator('.learning-guide img,.learning-guide script').count(), 0); + assert.equal(await page.evaluate(() => window.qaGuideInjected), undefined); + }, { core: injected }); + + await run('syntax-module-failure-keeps-exact-guide-code-copy-and-prediction', async ({ page }) => { + for (const id of ['java-basics', 'jpa-state', 'jwt-validation']) { + await openLesson(page, id); + const lesson = lessons.find(item => item.id === id); + assert.equal(await page.locator('#theory-code-panel code').textContent(), lesson.learningGuide.code.source); + assert.equal(await page.locator('#theory-code-panel [class*="tok-"]').count(), 0); + assert.match(await page.locator('.guide-code-hint').textContent(), /원문/); + await page.locator('#copy-theory-code').click(); + assert.equal(await page.evaluate(() => window.qaClipboardText), lesson.learningGuide.code.source); + await page.locator('.guide-prediction summary').click(); + assert.equal(await page.locator('.guide-answer-text').isVisible(), true); + } + await capture(page, 'jwt-fallback-code', '#theory-code-panel'); + }, { noEditor: true }); + + for (const width of [320, 360, 390, 768, 1440]) for (const font of [100, 200]) { + await run(`all34-layout-${width}px-${font}percent-text`, async ({ page }) => { + for (const lesson of lessons) { + await openLesson(page, lesson.id); + await page.locator('.guide-prediction summary').click(); + await geometry(page, lesson.id); + // The prior overflow lived in the references column. A collapsed + // replacement must not merely conceal it, so inspect both states. + await page.locator('#lesson-info > summary').press('Enter'); + await page.locator('#reference-column > summary').press('Enter'); + if (await page.locator('.code-legend-disclosure').count()) await page.locator('.code-legend-disclosure > summary').press('Enter'); + await geometry(page, `${lesson.id}/expanded-support`); + } + for (const id of ['java-basics', 'jpa-state', 'jwt-validation']) { + await openLesson(page, id); + if (id === 'java-basics') { + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await page.screenshot({ path: path.join(artifacts, `reading-layout-${width}-${font}-top.png`), fullPage: false }); + } + await capture(page, `${id}-${width}-${font}-scenario`); + await capture(page, `${id}-${width}-${font}-code`, '#theory-code-panel'); + if ((width === 320 || width === 390) && font === 200) { + await page.locator('.guide-prediction summary').click(); + await capture(page, `${id}-${width}-${font}-answer`, '.guide-prediction-answer'); + await capture(page, `${id}-${width}-${font}-trace`, '.guide-walkthrough'); + await keyboardDetails(page, '#reference-column', true); + await capture(page, `${id}-${width}-${font}-expanded-references`, '.reference-content'); + } + } + }, { viewport: { width, height: width < 500 ? 844 : 1000 }, font }); + } + + for (const width of [704, 689]) { + await run(`desktop-minimum-web-viewport-${width}x521-at-100-and-200percent-text`, async ({ page }) => { + const checks = []; + for (const font of [100, 200]) { + await page.locator('html').evaluate((node, value) => { node.style.fontSize = `${value}%`; }, font); + await openLesson(page, 'exceptions'); + const before = await snapshot(page); + const menu = page.locator('#mobile-menu-toggle'); + await menu.focus(); + await page.keyboard.press('Enter'); + assert.equal(await menu.getAttribute('aria-expanded'), 'true'); + assert.equal(await page.locator('#curriculum-sidebar').evaluate(node => node.contains(document.activeElement)), true); + await page.keyboard.press('Escape'); + assert.equal(await menu.getAttribute('aria-expanded'), 'false'); + assert.equal(await menu.evaluate(node => node === document.activeElement), true, 'drawer restores opener focus'); + for (const selector of ['#lesson-info', '#reference-column']) await keyboardDetails(page, selector, true); + await geometry(page, `minimum-${width}-${font}/open-support`); + await capture(page, `minimum-${width}-${font}-expanded-references`, '.reference-content'); + await page.setViewportSize({ width: width + 80, height: 650 }); + await geometry(page, `minimum-${width}-${font}/resized`); + for (const selector of ['#lesson-info', '#reference-column']) assert.equal(await page.locator(selector).evaluate(node => node.open), true, 'resize preserves expanded information'); + await page.setViewportSize({ width, height: 521 }); + for (const selector of ['#reference-column', '#lesson-info']) await keyboardDetails(page, selector, false); + assert.deepEqual(await snapshot(page), before); + await openLesson(page, 'java-basics'); + const pre = page.locator('#theory-code-panel'); + await pre.focus(); + const preDimensions = await pre.evaluate(node => ({ width: node.clientWidth, scroll: node.scrollWidth })); + if (preDimensions.scroll > preDimensions.width) { + await page.keyboard.press('ArrowRight'); + await eventually(() => pre.evaluate(node => node.scrollLeft), value => value > 0, 'long guide code scrolls internally'); + } + await page.locator('#tab-theory').focus(); + for (const step of ['example', 'quiz', 'practice', 'lab']) { + await page.keyboard.press('ArrowRight'); + await eventually(() => page.locator(`#tab-${step}`).getAttribute('aria-selected'), 'true', `minimum window ${step} keyboard stage`); + assert.equal(await page.locator(`#tab-${step}`).evaluate(node => node === document.activeElement), true); + await activeTabGeometry(page, `${width}px/${font}%/${step}/keyboard`); + await geometry(page, `minimum-${width}-${font}/${step}`); + } + await openLesson(page, 'java-basics', 'theory'); + await openLesson(page, 'java-basics', 'lab'); + await activeTabGeometry(page, `${width}px/${font}%/direct-hash-lab`); + const draft = `${coreExercises.find(item => item.id === coreLessons[0].exerciseId).template}\n// ${width}px ${font}% ${'long_identifier_'.repeat(80)}\n`; + await page.locator('#code-editor [role="textbox"]').fill(draft); + const drafted = await eventually(() => snapshot(page), value => value.drafts.some(item => item.source === draft), 'the edited source is saved before testing persistence'); + const scroll = await page.locator('#code-editor .cm-scroller').evaluate(node => { + node.scrollLeft = 180; + return { width: node.clientWidth, scroll: node.scrollWidth, left: node.scrollLeft }; + }); + assert.ok(scroll.scroll > scroll.width && scroll.left > 0, 'long editor source stays inside its scroller'); + await keyboardDetails(page, '#lesson-info', true); + await keyboardDetails(page, '#reference-column', true); + await page.setViewportSize({ width: 844, height: 390 }); + await geometry(page, `minimum-${width}-${font}/landscape-editor`); + await activeTabGeometry(page, `${width}px/${font}%/landscape-editor`); + await page.setViewportSize({ width, height: 521 }); + await geometry(page, `minimum-${width}-${font}/restored-editor`); + await activeTabGeometry(page, `${width}px/${font}%/restored-editor`); + await page.locator('#copy-code').click(); + assert.equal(await page.evaluate(() => window.qaClipboardText), draft, 'draft remains exact after information toggles and resize'); + assert.deepEqual(await snapshot(page), drafted); + await capture(page, `minimum-${width}-${font}-editor`, '#run-code'); + await page.locator('#tab-lab').focus(); + await page.keyboard.press('Home'); + await eventually(() => page.locator('#tab-theory').getAttribute('aria-selected'), 'true', 'Home returns to theory'); + assert.equal(await page.locator('#tab-theory').evaluate(node => node === document.activeElement), true); + await keyboardDetails(page, '#reference-column', false); + await keyboardDetails(page, '#lesson-info', false); + assert.deepEqual(await snapshot(page), drafted); + await openLesson(page, 'java-basics', 'lab'); + await page.reload(); + await page.locator('#code-editor [role="textbox"]').waitFor({ state: 'visible' }); + await page.locator('html').evaluate((node, value) => { node.style.fontSize = `${value}%`; }, font); + await activeTabGeometry(page, `${width}px/${font}%/reload-lab`); + await page.locator('#copy-code').click(); + assert.equal(await page.evaluate(() => window.qaClipboardText), draft, 'complete editor source survives reload at the minimum viewport'); + assert.deepEqual(await snapshot(page), drafted); + checks.push({ font, guideCode: preDimensions, editorScroll: scroll, draftPreserved: true, drawerFocusRestored: true, allFiveTabsKeyboard: true, directHashAndResizeAndReloadTabVisible: true }); + } + return { environment: 'web viewport emulation, not an Electron app launch', viewport: { width, height: 521 }, checks }; + }, { viewport: { width, height: 521 } }); + } + + await run('touch-emulation-resize-and-short-height-keep-open-prediction', async ({ page }) => { + await openLesson(page, 'jpa-state'); + await page.locator('.guide-prediction summary').tap(); + for (const viewport of [{ width: 844, height: 390 }, { width: 1280, height: 600 }, { width: 390, height: 844 }, { width: 844, height: 390 }]) { + await page.setViewportSize(viewport); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), ''); + assert.equal(await page.locator('.guide-answer-text').isVisible(), true); + await geometry(page, 'jpa-state'); + await capture(page, `jpa-state-${viewport.width}x${viewport.height}-open-answer`, '.guide-prediction-answer'); + } + await page.locator('.guide-prediction summary').tap(); + assert.equal(await page.locator('.guide-prediction').getAttribute('open'), null); + }, { viewport: { width: 390, height: 844 }, touch: true }); + } finally { await browser.close(); } + const after = await hashes(); + const changed = filesToHash.filter(file => before[file] !== after[file]); + if (changed.length) results.push({ name: 'verified-source-did-not-change-during-run', status: 'BLOCKED', changed }); + const report = { recordedAt: new Date().toISOString(), browser: process.env.PLAYWRIGHT_CHANNEL || 'chromium', browserVersion, node: process.version, mode: 'Isolated browser and touch emulation; mocked API; 200% document text, not physical devices or OS scaling', before, after, results }; + await writeFile(path.join(artifacts, `results-${Date.now()}.json`), JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); + if (!results.length || results.some(result => result.status !== 'PASS')) process.exitCode = 1; +} + +main().catch(error => { console.error(error.stack); process.exitCode = 1; }); diff --git a/tests/readability.test.mjs b/tests/readability.test.mjs new file mode 100644 index 0000000..4ff84f0 --- /dev/null +++ b/tests/readability.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { exerciseDefinitions } from '../server/exercises.mjs'; +import { professionalLessons } from '../dist/data/professional.js'; + +const course = new URL('../curriculum/professional/', import.meta.url); +const catalog = JSON.parse(await readFile(new URL('catalog.json', course), 'utf8')); +const authoredProfessional = await Promise.all(catalog.lessons.map(async entry => + JSON.parse(await readFile(new URL(entry.lessonPath, course), 'utf8')))); +const exampleManifest = JSON.parse(await readFile(new URL('../examples/expected-output.json', import.meta.url), 'utf8')); +const lessons = [...coreLessons, ...authoredProfessional]; +const nonempty = value => typeof value === 'string' && value.trim().length > 0; +const canonical = value => Array.isArray(value) ? value.map(canonical) + : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) + : value; +const digest = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex'); +const contracts = values => values.map(({ id, minVersion, preview, exerciseId, example, quiz, professionalExercise, executionVerified }) => + ({ id, minVersion, preview, exerciseId, example: { code: example.code, output: example.output, files: example.files }, + quiz: quiz.map(({ explanation, ...question }) => question), professionalExercise, executionVerified })); + +// Captured independently before learningGuide was added, 2026-10-13, and +// checked against Git b2dda6d4f2225e5f2d9b31a5287539bd1b0685b6. +// This readability change must not revise examples, grading, target versions, +// starter files, or trusted checks under already-earned achievement IDs. +const originalContracts = { + core: 'fd73ccfc033780f4419e59f4c1dd84abc64a8d0c1d4c8235c4bd4b132e43a509', + professional: '0a9ab6fadae8aa061705168a8f898c49eb91b2a3e1cd9b97dfb7fbaf13e2dcfb', + exercises: '88d31bde3c83d8f82d5b95b6cbdf88426d8db637339daf104766483f55bdd5e6', + definitions: '2550a87d02ad27c2a274df56bc36fc5d132e4ee3420a2073ec29064cee460a0e', +}; + +test('all 34 lessons have a concrete situation, defined terms, code trace, prediction, and practical takeaway', () => { + assert.equal(coreLessons.length, 12); + assert.equal(authoredProfessional.length, 22); + assert.equal(new Set(lessons.map(lesson => lesson.id)).size, 34); + for (const lesson of lessons) { + const guide = lesson.learningGuide; + assert.ok(guide && !Array.isArray(guide), `${lesson.id}: learningGuide is required`); + for (const field of ['scenario', 'question', 'takeaway']) { + assert.ok(nonempty(guide[field]), `${lesson.id}: ${field}`); + assert.match(guide[field], /[가-힣]/u, `${lesson.id}: ${field} must explain the lesson in Korean`); + } + assert.ok(Array.isArray(guide.terms) && guide.terms.length >= 2, `${lesson.id}: terms are unpacked before use`); + assert.equal(new Set(guide.terms.map(item => item.term)).size, guide.terms.length, `${lesson.id}: unique term definitions`); + for (const item of guide.terms) { + assert.ok(nonempty(item.term) && nonempty(item.meaning), `${lesson.id}: complete term definition`); + assert.notEqual(item.term.trim(), item.meaning.trim(), `${lesson.id}: definition does not merely repeat the term`); + assert.match(item.meaning, /[가-힣]/u, `${lesson.id}/${item.term}: plain-language definition`); + } + assert.ok(guide.code && nonempty(guide.code.file) && nonempty(guide.code.source), `${lesson.id}: source and file name`); + assert.ok(Array.isArray(guide.walkthrough) && guide.walkthrough.length >= 2 && guide.walkthrough.every(nonempty), `${lesson.id}: an ordered explanation of the code`); + assert.ok(guide.prediction && ['question', 'answer', 'explanation'].every(field => nonempty(guide.prediction[field])), `${lesson.id}: predict, answer, and explain why`); + assert.notEqual(guide.prediction.answer.trim(), guide.prediction.explanation.trim(), `${lesson.id}: explanation adds a reason to the answer`); + assert.ok(lesson.theory.length >= 2 && lesson.quiz.length >= 2 && lesson.practice.tasks.length >= 2, `${lesson.id}: the original five-stage lesson remains present`); + } + for (const field of ['scenario', 'question']) { + assert.equal(new Set(lessons.map(lesson => lesson.learningGuide[field].trim())).size, 34, `${field} is specific to each lesson`); + } +}); + +test('every guide code block is one contiguous verbatim excerpt of its named existing example', async () => { + for (const lesson of lessons) { + const guide = lesson.learningGuide; + assert.ok(guide?.code, `${lesson.id}: guide code exists`); + let files = lesson.example.files; + if (!files?.length) { + const relative = guide.code.file.replace(/^examples\//, ''); + assert.ok(Object.hasOwn(exampleManifest, relative), `${lesson.id}: file exists in the original executable example manifest`); + const original = await readFile(new URL(`../examples/${relative}`, import.meta.url), 'utf8'); + assert.equal(original.trim(), lesson.example.code.trim(), `${lesson.id}: named source is this lesson's published example`); + files = [{ path: guide.code.file, code: lesson.example.code }]; + } + const file = files.find(item => item.path === guide.code.file); + assert.ok(file, `${lesson.id}: ${guide.code.file} is a published example file`); + assert.ok(guide.code.source.trim().length > 0, `${lesson.id}: excerpt is not empty`); + assert.ok(file.code.includes(guide.code.source), `${lesson.id}: excerpt is exact, consecutive original source; no omitted/reordered/rewritten lines`); + assert.ok(guide.code.source.split('\n').length >= 2, `${lesson.id}: excerpt contains a traceable code sequence`); + } +}); + +test('published professional guides exactly match all 22 authored lessons', () => { + assert.equal(professionalLessons.length, 22); + for (const lesson of authoredProfessional) { + assert.deepEqual(professionalLessons.find(item => item.id === lesson.id)?.learningGuide, lesson.learningGuide, `${lesson.id}: regenerate the professional distribution before verification`); + assert.ok(lesson.learningGuide, `${lesson.id}: authored guide is not missing`); + } +}); + +test('readability additions preserve the pre-change examples, quiz answers, execution targets, and exercise contracts', () => { + assert.equal(digest(contracts(coreLessons)), originalContracts.core, 'core examples, quiz, minimum versions, and exercise IDs'); + assert.equal(digest(contracts(authoredProfessional)), originalContracts.professional, 'professional examples, quiz, and local project execution contracts'); + assert.equal(digest(coreExercises), originalContracts.exercises, 'published core templates and criteria'); + assert.equal(digest(exerciseDefinitions), originalContracts.definitions, 'trusted runtime/grading checks'); +}); + +test('README puts installation directly after the introduction with explicit EXE and ZIP asset links', async () => { + const readme = await readFile(new URL('../README.md', import.meta.url), 'utf8'); + const sections = [...readme.matchAll(/^##\s+(.+)$/gm)].map(match => ({ title: match[1].trim(), index: match.index })); + const introduction = sections.findIndex(section => section.title === '소개'); + assert.ok(introduction >= 0, 'introduction is present'); + assert.match(sections[introduction + 1]?.title || '', /설치/, 'installation immediately follows the introduction'); + const installation = readme.slice(sections[introduction + 1].index, sections[introduction + 2]?.index); + const links = [...installation.matchAll(/\[[^\]]+\]\((https:\/\/[^\s)]+)\)/g)].map(match => new URL(match[1])); + for (const extension of ['exe', 'zip']) { + assert.ok(links.some(url => url.hostname === 'github.com' && new RegExp(`^/jbsim999/java-edu/releases/download/v[^/]+/[^/]+\\.${extension}$`, 'i').test(url.pathname)), `${extension}: direct GitHub release asset link`); + } + assert.match(installation, /Windows\s*x64/i); + assert.match(installation, /Java Edu\.exe/); + assert.match(installation, /압축.*해제/); + assert.match(installation, /환경 준비/); +}); diff --git a/tests/theory-visuals.mjs b/tests/theory-visuals.mjs new file mode 100644 index 0000000..cae0099 --- /dev/null +++ b/tests/theory-visuals.mjs @@ -0,0 +1,339 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { lessons as coreLessons } from '../dist/data/curriculum.js'; +import { exercises as coreExercises } from '../dist/data/exercises.js'; +import { professionalLessons, professionalExercises } from '../dist/data/professional.js'; +import { lessonVisuals } from '../dist/data/lesson-visuals.js'; + +// Independent browser QA. Execution APIs and activated external references are +// intercepted: this suite never submits learner code or visits a remote source. +const root = fileURLToPath(new URL('../', import.meta.url)); +const base = process.env.QA_BASE_URL || 'http://127.0.0.1:4173'; +const artifacts = path.resolve(process.env.QA_ARTIFACT_DIR || path.join(root, 'work/theory-independent-qa')); +const filter = process.env.QA_FILTER ? new RegExp(process.env.QA_FILTER) : null; +const lessons = [...coreLessons, ...professionalLessons]; +const selected = ['java-basics', 'oop', 'polymorphism', 'collections', 'generics', 'streams', 'concurrency', 'jpa-state', 'tx-proxy', 'security-csrf', 'jwt-validation', 'kafka-idempotency']; +const storageKey = 'java-edu:progress:v1'; +const results = []; +let browser; + +async function eventually(read, expected, message, timeout = 8000) { + const deadline = Date.now() + timeout; + let value; + while (Date.now() < deadline) { + value = await read(); + if (typeof expected === 'function' ? expected(value) : value === expected) return value; + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.fail(`${message}: ${JSON.stringify(value)}`); +} + +async function waitForServer() { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { if ((await fetch(base, { signal: AbortSignal.timeout(2000) })).ok) return; } catch {} + await new Promise(resolve => setTimeout(resolve, 300)); + } + throw new Error(`Parent-owned preview is unavailable at ${base}`); +} + +async function run(name, test, options = {}) { + if (filter && !filter.test(name)) return; + const context = await browser.newContext({ viewport: options.viewport || { width: 1440, height: 1000 } }); + const requests = []; + const external = []; + const pageErrors = []; + await context.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin !== new URL(base).origin) { + external.push(url.href); + return route.fulfill({ contentType: 'text/plain', body: 'QA reference activation: remote network intentionally not used.' }); + } + if (url.pathname.startsWith('/api/')) { + requests.push({ path: url.pathname, method: route.request().method() }); + if (url.pathname === '/api/runtime') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ provider: 'Wandbox', available: true, runtime: { javaVersion: 21 }, supportedTargets: [8, 11, 17, 21] }) }); + return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { code: 'qa_no_execution', message: 'This suite does not execute code.' } }) }); + } + if (url.pathname === '/data/lesson-visuals.js' && options.visualModule) { + return options.visualModule === '404' + ? route.fulfill({ status: 404, contentType: 'text/plain', body: 'Optional diagrams unavailable' }) + : route.fulfill({ contentType: 'text/javascript', body: options.visualModule }); + } + return route.continue(); + }); + const page = await context.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', error => pageErrors.push(error.message)); + const started = Date.now(); + try { + await page.goto(base); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + if (options.font === 200) await page.locator('html').evaluate(node => { node.style.fontSize = '200%'; }); + await test({ page, context, requests, external }); + assert.deepEqual(pageErrors, [], 'no uncaught browser errors'); + assert.equal(requests.filter(request => request.path !== '/api/runtime').length, 0, 'theory interaction never executes code'); + results.push({ name, status: 'PASS', durationMs: Date.now() - started }); + console.log(`PASS ${name}`); + } catch (error) { + await page.screenshot({ path: path.join(artifacts, `failure-${name}.png`), fullPage: true }).catch(() => {}); + results.push({ name, status: 'FAIL', message: error.message, durationMs: Date.now() - started }); + console.error(`FAIL ${name}: ${error.stack}`); + } finally { await context.close(); } +} + +async function openLesson(page, id, stage = 'theory') { + const lesson = lessons.find(item => item.id === id); + assert.ok(lesson, id); + await page.evaluate(hash => { location.hash = hash; }, `lesson=${id}&step=${stage}&version=21`); + await eventually(() => page.locator('#lesson-title').textContent(), text => text?.includes(lesson.title), `${id} lesson is selected`); + await eventually(() => page.locator(`[data-step="${stage}"]`).getAttribute('aria-selected'), 'true', `${stage} tab is selected`); +} + +async function originalTheory(page, lesson) { + const actual = await page.locator('#lesson-panel > .theory-section').evaluateAll(nodes => nodes.map(node => ({ heading: node.querySelector('h3').textContent, text: node.querySelector('p').textContent }))); + assert.deepEqual(actual, lesson.theory.map(({ heading, text }) => ({ heading, text })), `${lesson.id} original sections are unchanged`); +} + +async function inspectVisual(page, lesson, visual) { + const figure = page.locator('figure.theory-visual'); + assert.equal(await figure.count(), 1, lesson.id); + assert.equal(await figure.getAttribute('data-visual-id'), visual.id); + assert.equal(await figure.getAttribute('data-visual-kind'), visual.kind); + assert.equal(await figure.locator('.visual-title').textContent(), visual.title); + assert.equal(await figure.locator('.visual-description').textContent(), visual.caption); + assert.ok((await figure.locator('.visual-reading-order').textContent()).includes(visual.readingOrder)); + const insertion = await figure.evaluate(node => [...node.parentElement.children].slice(0, [...node.parentElement.children].indexOf(node)).filter(sibling => sibling.classList.contains('theory-section')).length); + assert.equal(insertion, visual.afterSection + 1, `${lesson.id} appears after the intended explanation`); + const aria = await figure.evaluate(node => Object.fromEntries(['aria-labelledby', 'aria-describedby'].map(attribute => [attribute, document.getElementById(node.getAttribute(attribute))?.textContent]))); + assert.equal(aria['aria-labelledby'], visual.title); + assert.equal(aria['aria-describedby'], visual.caption); + const panels = figure.locator('.visual-panel'); + assert.equal(await panels.count(), visual.panels.length); + for (let index = 0; index < visual.panels.length; index++) { + const expected = visual.panels[index]; + const panel = panels.nth(index); + assert.equal(await panel.locator('.visual-panel-title').textContent(), expected.title); + const nodes = await panel.locator('.visual-node').evaluateAll(items => items.map(node => ({ id: node.dataset.nodeId, label: node.querySelector('.visual-node-label').textContent, detail: node.querySelector('.visual-node-detail')?.textContent || '' }))); + assert.deepEqual(nodes, expected.nodes.map(({ id, label, detail }) => ({ id, label, detail })), `${lesson.id} panel ${index} reading order and labels`); + const edges = await panel.locator('.visual-transition,.visual-relationship').evaluateAll(items => items.map(node => ({ from: node.dataset.from, to: node.dataset.to, text: node.querySelector('.visual-edge-label').textContent }))); + assert.equal(edges.length, expected.edges.length); + for (const edge of expected.edges) { + const from = expected.nodes.find(node => node.id === edge.from).label; + const to = expected.nodes.find(node => node.id === edge.to).label; + assert.ok(edges.some(item => item.from === edge.from && item.to === edge.to && item.text === `${from}에서 ${to}로: ${edge.label}`), `${lesson.id}: ${edge.from}→${edge.to} retains endpoints and meaning`); + } + } + assert.equal(await figure.locator('.visual-alt-text').textContent(), visual.altText); + assert.deepEqual(await figure.locator('.visual-limits li').allTextContents(), visual.limits); + const sources = await figure.locator('.visual-source-link').evaluateAll(items => items.map(link => ({ title: link.textContent, url: link.href, target: link.target, rel: link.rel }))); + assert.deepEqual(sources.map(({ title, url }) => ({ title, url })), visual.sources.map(source => ({ title: source.title, url: new URL(source.url).href }))); + for (const source of sources) { + assert.equal(new URL(source.url).protocol, 'https:'); + assert.equal(source.target, '_blank'); + assert.ok(source.rel.includes('noopener') && source.rel.includes('noreferrer')); + } + for (const source of visual.sources) { + const metadata = await figure.locator('.visual-source-meta').allTextContents(); + assert.ok(metadata.some(text => text.includes(source.edition) && text.includes(source.checkedAt))); + } +} + +async function geometry(page, id) { + const value = await page.evaluate(() => { + const figure = document.querySelector('.theory-visual'); + const bound = figure.getBoundingClientRect(); + const bad = [...figure.querySelectorAll('.visual-node,.visual-edge-label,.visual-panel-title,.visual-title,.visual-source-link,.visual-endpoint')].flatMap(node => { + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + const outside = rect.left < bound.left - 1 || rect.right > bound.right + 1; + const clipped = ['hidden', 'clip'].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1; + return outside || clipped ? [{ text: node.textContent.slice(0, 100), outside, clipped, left: rect.left, right: rect.right }] : []; + }); + const order = [...figure.querySelectorAll('.visual-panel')].map((node, index) => { const rect = node.getBoundingClientRect(); return { index, top: Math.round(rect.top), left: Math.round(rect.left) }; }); + return { width: document.documentElement.clientWidth, scroll: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth), bad, order }; + }); + assert.ok(value.scroll <= value.width + 1, `${id}: viewport overflow ${JSON.stringify(value)}`); + assert.deepEqual(value.bad, [], `${id}: diagram label escapes/clips its figure`); + assert.deepEqual([...value.order].sort((a, b) => a.top - b.top || a.left - b.left).map(panel => panel.index), value.order.map(panel => panel.index), `${id}: visual panel order agrees with DOM order`); +} + +async function captureViewport(page, id, suffix, selector = '.visual-title') { + await page.locator(`.theory-visual ${selector}`).first().scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(artifacts, `${id}-${suffix}.png`), fullPage: false }); +} + +async function main() { + await mkdir(artifacts, { recursive: true }); + await waitForServer(); + assert.equal(lessons.length, 34); + assert.deepEqual(Object.keys(lessonVisuals).sort(), [...selected].sort()); + browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}) }); + const version = browser.version(); + try { + await run('all34-original-theory-and12-visual-contracts', async ({ page }) => { + for (const lesson of lessons) { + await openLesson(page, lesson.id); + await originalTheory(page, lesson); + const visual = lessonVisuals[lesson.id]; + if (visual) { + await inspectVisual(page, lesson, visual); + await geometry(page, lesson.id); + await page.locator('.theory-visual').screenshot({ path: path.join(artifacts, `${lesson.id}-desktop-figure.png`) }); + } else assert.equal(await page.locator('.theory-visual').count(), 0, `${lesson.id}: no unnecessary diagram`); + } + }); + + await run('keyboard-description-and-reference-activation', async ({ page, context, external }) => { + await openLesson(page, 'jwt-validation'); + const details = page.locator('.visual-text-description'); + const summary = details.locator('summary'); + await summary.focus(); + await page.keyboard.press('Enter'); + assert.equal(await details.getAttribute('open'), ''); + assert.equal(await details.locator('.visual-alt-text').isVisible(), true); + await page.keyboard.press('Space'); + assert.equal(await details.getAttribute('open'), null); + await page.keyboard.press('Tab'); + assert.equal(await page.locator('.visual-source-link').first().evaluate(node => node === document.activeElement), true); + await page.keyboard.press('Shift+Tab'); + assert.equal(await summary.evaluate(node => node === document.activeElement), true); + await page.keyboard.press('Enter'); + await captureViewport(page, 'jwt-validation', 'keyboard-expanded-description', '.visual-text-description'); + const popupPromise = context.waitForEvent('page'); + await page.locator('.visual-source-link').first().click(); + const popup = await popupPromise; + await popup.waitForLoadState(); + assert.equal(popup.url(), lessonVisuals['jwt-validation'].sources[0].url); + assert.ok(external.includes(popup.url())); + assert.equal(await popup.evaluate(() => window.opener), null); + await popup.close(); + assert.equal(await page.locator('.theory-visual').count(), 1); + }); + + await run('theory-navigation-preserves-quiz-notes-and-file-drafts', async ({ page }) => { + const first = coreLessons[0]; + await openLesson(page, first.id, 'quiz'); + for (const question of first.quiz) await page.locator(`#quiz-form [name="quiz-${question.id}"][value="${question.correctIndex}"]`).check(); + await page.locator('#quiz-submit').click(); + await openLesson(page, first.id, 'practice'); + const note = '도식을 읽어도 유지할 설계 기록'; + await page.locator('#design-notes').fill(note); + await openLesson(page, first.id, 'lab'); + const draft = `${coreExercises.find(exercise => exercise.id === first.exerciseId).template}\n// preserved visual QA draft\n`; + await page.locator('#code-editor [role="textbox"]').fill(draft); + await page.locator('#editor-theme-select').selectOption('contrast'); + await openLesson(page, professionalLessons[0].id, 'lab'); + const file = professionalExercises[0].files.find(item => item.editable); + const fileDraft = `${file.source}\n// preserved project visual QA\n`; + await page.locator('#code-editor [role="textbox"]').fill(fileDraft); + await openLesson(page, first.id); + const snapshot = async () => page.evaluate(key => { const stored = JSON.parse(localStorage.getItem(key)); return Object.fromEntries(['quizPassed', 'labPassed', 'drafts', 'fileDrafts', 'notes'].map(field => [field, stored[field]])); }, storageKey); + const before = await snapshot(); + assert.ok(before.quizPassed.includes(first.id)); + assert.ok(before.drafts.some(item => item.source === draft)); + assert.ok(before.fileDrafts.some(item => item.source === fileDraft)); + for (const id of ['generics', 'streams', 'jpa-state', 'jwt-validation']) { + await openLesson(page, id); + await page.locator('.visual-text-description summary').click(); + } + await page.locator('#version-select').selectOption('8'); + await page.locator('[data-track="transition"]').click(); + await page.locator('#lesson-search').fill('qa-no-match-visual'); + await page.locator('#lesson-search').fill(''); + await page.locator('[data-track="all"]').click(); + await page.locator('#version-select').selectOption('21'); + assert.deepEqual(await snapshot(), before); + await page.reload(); + await page.locator('#workspace-grid').waitFor({ state: 'visible' }); + assert.deepEqual(await snapshot(), before); + await openLesson(page, first.id, 'practice'); + assert.equal(await page.locator('#design-notes').inputValue(), note); + await openLesson(page, first.id, 'lab'); + assert.equal(await page.locator('#editor-theme-select').inputValue(), 'contrast'); + assert.equal(await page.locator('#lesson-achievements [data-achievement="quiz"]').getAttribute('data-complete'), 'true'); + assert.equal(await page.locator('#lesson-achievements [data-achievement="lab"]').getAttribute('data-complete'), 'false'); + }); + + for (const [name, visualModule] of [['optional-module-404', '404'], ['invalid-export-shape', 'export const lessonVisuals = [];'], ['optional-module-syntax-error', 'export const lessonVisuals = {;']]) { + await run(`${name}-retains-original-learning`, async ({ page }) => { + for (const id of selected) { + await openLesson(page, id); + await originalTheory(page, lessons.find(lesson => lesson.id === id)); + assert.equal(await page.locator('.theory-visual').count(), 0); + } + await page.locator('#lesson-panel .next-step-row button').click(); + assert.equal(await page.locator('[data-step="example"]').getAttribute('aria-selected'), 'true'); + assert.ok((await page.locator('#lesson-panel').innerText()).length > 100); + }, { visualModule }); + } + + const damaged = structuredClone(lessonVisuals); + damaged['java-basics'].panels[0].nodes[1].id = damaged['java-basics'].panels[0].nodes[0].id; + damaged.oop.kind = 'unknown'; + damaged.streams.sources[0].url = 'javascript:window.qaDiagramInjected=true'; + damaged['tx-proxy'].panels[0].edges[0].to = 'nonexistent-node'; + await run('invalid-individual-diagrams-do-not-break-other-lessons', async ({ page }) => { + for (const id of selected) { + await openLesson(page, id); + await originalTheory(page, lessons.find(lesson => lesson.id === id)); + assert.equal(await page.locator('.theory-visual').count(), ['java-basics', 'oop', 'streams', 'tx-proxy'].includes(id) ? 0 : 1); + } + assert.equal(await page.evaluate(() => window.qaDiagramInjected), undefined); + }, { visualModule: `export const lessonVisuals = ${JSON.stringify(damaged)};` }); + + const textOnly = structuredClone(lessonVisuals); + textOnly['java-basics'].title = ''; + textOnly['java-basics'].panels[0].nodes[0].label = ''; + await run('diagram-text-is-not-executed-as-markup', async ({ page }) => { + await openLesson(page, 'java-basics'); + assert.equal(await page.locator('.visual-title').textContent(), textOnly['java-basics'].title); + assert.equal(await page.locator('.theory-visual img,.theory-visual script').count(), 0); + assert.equal(await page.evaluate(() => window.qaDiagramInjected), undefined); + await originalTheory(page, coreLessons[0]); + }, { visualModule: `export const lessonVisuals = ${JSON.stringify(textOnly)};` }); + + for (const width of [320, 360, 390, 768, 1440]) for (const font of [100, 200]) { + await run(`all12-diagram-layout-${width}px-${font}percent`, async ({ page }) => { + for (const id of selected) { + await openLesson(page, id); + await geometry(page, id); + } + for (const id of ['java-basics', 'security-csrf', 'jwt-validation']) { + await openLesson(page, id); + await captureViewport(page, id, `${width}-${font}-title`); + if (width === 320 && font === 200 || width === 390 && font === 200) { + await captureViewport(page, id, `${width}-${font}-nodes`, '.visual-node'); + await page.locator('.visual-text-description summary').click(); + await captureViewport(page, id, `${width}-${font}-description`, '.visual-alt-text'); + await captureViewport(page, id, `${width}-${font}-sources`, '.visual-sources'); + await geometry(page, id); + } + } + }, { viewport: { width, height: width < 500 ? 844 : 1000 }, font }); + } + + await run('short-height-orientation-and-expanded-details', async ({ page }) => { + for (const viewport of [{ width: 844, height: 390 }, { width: 1280, height: 600 }, { width: 390, height: 844 }, { width: 844, height: 390 }]) { + await page.setViewportSize(viewport); + for (const id of ['oop', 'streams', 'jwt-validation', 'kafka-idempotency']) { + await openLesson(page, id); + await page.locator('.visual-text-description summary').click(); + await geometry(page, id); + } + await captureViewport(page, 'kafka-idempotency', `${viewport.width}x${viewport.height}-orientation`, '.visual-node'); + } + }); + } finally { await browser.close(); } + const hashes = {}; + for (const relative of ['dist/app.js', 'dist/styles.css', 'dist/data/lesson-visuals.js', 'tests/theory-visuals.mjs']) hashes[relative] = createHash('sha256').update(await readFile(path.join(root, relative))).digest('hex'); + const report = { recordedAt: new Date().toISOString(), browser: process.env.PLAYWRIGHT_CHANNEL || 'chromium', browserVersion: version, mode: 'Isolated browser emulation; mocked API/reference activation; no real devices', selectedLessons: selected, hashes, results }; + await writeFile(path.join(artifacts, `results-${Date.now()}.json`), JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); + if (!results.length || results.some(result => result.status !== 'PASS')) process.exitCode = 1; +} + +main().catch(error => { console.error(error.stack); process.exitCode = 1; }); diff --git a/worker/index.js b/worker/index.js new file mode 100644 index 0000000..e12e935 --- /dev/null +++ b/worker/index.js @@ -0,0 +1,26 @@ +import { handleApi } from '../server/executor.mjs'; +import { assets } from './assets.generated.mjs'; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname.startsWith('/api/')) { + return handleApi(request, { ...env, REQUIRE_IDENTITY: true, TRUSTED_IDENTITY_HEADER: 'oai-authenticated-user-id' }); + } + if (!['GET', 'HEAD'].includes(request.method)) return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } }); + const pathname = url.pathname === '/' ? '/index.html' : url.pathname; + const asset = assets[pathname]; + if (!asset) return new Response('Not found', { status: 404 }); + const body = asset.encoding === 'base64' ? Uint8Array.from(atob(asset.body), (c) => c.charCodeAt(0)) : asset.body; + return new Response(request.method === 'HEAD' ? null : body, { + headers: { + 'Content-Type': asset.type, + ...(asset.download ? { 'Content-Disposition': `attachment; filename="${asset.download}"` } : {}), + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'", + }, + }); + }, +};