diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ae4e8a..3e5ee75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,11 @@ jobs: uses: actions/checkout@v4 with: persist-credentials: false + - name: Java 21 설정 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" - name: Node.js 설정 uses: actions/setup-node@v4 with: diff --git a/README.md b/README.md index 96a2fa5..c011da2 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ BAM.dev는 프로그래밍 문법을 읽는 데서 멈추지 않고, 개념을 설명하고 직접 구현하며 한 명의 개발자로 성장하도록 돕는 학습 가이드입니다. -현재 구현 범위는 **0~6차와 확장 검증 게이트**입니다. JavaScript·HTML·CSS 3개 언어를 정식 과정으로, Java를 언어 독립성 검증용 샘플로 제공합니다. 전체 콘텐츠는 교안 19개(JavaScript 7·HTML 5·CSS 6·Java 1), 객관식 39문항(14·12·12·1), Code Quest 14개(JavaScript 5·HTML 5·CSS 4), JavaScript 코딩테스트 6문제와 HTML·CSS Web Project 1개입니다. +현재 구현 범위는 **0~7차와 확장 검증 게이트**입니다. JavaScript·HTML·CSS·Java 4개 언어를 정식 과정으로 제공합니다. 전체 콘텐츠는 교안 24개(JavaScript 7·HTML 5·CSS 6·Java 6), 객관식 50문항(14·12·12·12), Code Quest 19개(JavaScript 5·HTML 5·CSS 4·Java 5), JavaScript·Java 코딩테스트 각 6문제와 HTML·CSS Web Project 1개입니다. -Code Quest에서는 언어의 실제 작성 단위를 그대로 편집합니다. JavaScript는 함수를, HTML은 JavaScript 래퍼 없는 마크업을, CSS는 JavaScript 래퍼 없는 스타일시트를 작성합니다. HTML은 소스를 실행하지 않고 inert DOM과 doctype을 공개 검사하며, CSS는 안전 검사를 통과한 고정 HTML fixture에 스타일을 적용해 CSSOM 규칙·미디어 조건·계산 스타일을 공개 검사합니다. Web Project에서는 HTML과 CSS 두 파일을 함께 편집하고 안전 미리보기, 공개 자동 검사 70점과 자가평가 30점으로 결과를 점검합니다. 코딩테스트의 `테스트 실행`과 `제출 및 채점`을 포함해 브라우저에 전달되는 모든 테스트와 assertion은 공개됩니다. +Code Quest에서는 언어의 실제 작성 단위를 그대로 편집합니다. JavaScript는 함수를, HTML은 JavaScript 래퍼 없는 마크업을, CSS는 JavaScript 래퍼 없는 스타일시트를, Java는 `public class Solution`의 `public static` 메서드를 작성합니다. HTML은 inert DOM과 doctype을, CSS는 고정 HTML fixture에 적용한 CSSOM 규칙·미디어 조건·계산 스타일을 공개 검사합니다. Java는 로컬 개발 서버가 Java 21 호환 소스로 컴파일한 뒤 매 테스트를 분리 프로세스에서 실행합니다. Web Project에서는 HTML과 CSS 두 파일을 함께 편집하고 안전 미리보기, 공개 자동 검사 70점과 자가평가 30점으로 결과를 점검합니다. 화면에 전달되는 모든 테스트와 assertion은 공개됩니다. ## 바로 실행하기 -Node.js 20 이상만 필요합니다. 외부 패키지나 외부 서버는 사용하지 않습니다. +Node.js 20 이상이 필요합니다. Java 코드 실행에는 실행 중인 Docker Desktop과 로컬에 미리 준비된 고정 Java 21 이미지 `maven@sha256:3a4ab3276a087bf276f79cae96b1af04f53731bec53fb2e651aca79e4b10211e`가 필요합니다. 실행기는 이미지를 자동으로 pull하지 않으며 Docker daemon 또는 이미지가 없으면 Java 실행만 명시적으로 거부합니다. host JDK, 외부 채점 API, 클라우드 계정이나 유료 리소스는 사용하지 않습니다. ```bash npm run dev @@ -29,12 +29,12 @@ npm run check ```text content/ 언어 비종속 메타데이터, 교안·fixture, 객관식·Quest·코딩테스트·Web Project src/core/ 콘텐츠·내비게이션·평가 도메인 로직 -src/grading/ Worker 및 inert DOM·CSSOM 기반 Quest·코딩테스트 채점 어댑터 +src/grading/ Worker·Java API·inert DOM·CSSOM 기반 채점 어댑터 src/repositories/ 사용자 진도 저장소 추상화와 localStorage 구현 src/ui/ 안전한 제한형 Markdown·평가 화면 렌더러 src/workers/ 공개 JavaScript 테스트를 실행하는 일회성 Worker styles/ 디자인 토큰과 반응형 앱 UI -scripts/ 로컬 서버, 콘텐츠 검증, 정적 빌드 +scripts/ 로컬 서버·Java 격리 채점기, 콘텐츠 검증, 정적 빌드 tests/ Node 내장 테스트 러너 기반 단위 테스트 docs/ 아키텍처·결정·참고자료 기록 ``` @@ -47,4 +47,4 @@ docs/ 아키텍처·결정·참고자료 기록 ## 참고 자료 -UI 참고 코드에서는 구조, 색상, 간격, 사용자 흐름만 분석했습니다. React, MUI, Tailwind 구성요소나 의존성은 복사하지 않았습니다. JavaScript 교안은 사용자가 제공한 읽기 전용 학습자료를 프로젝트 내부에 복제한 뒤, 오프라인 실행·접근성·설명 정확성을 위해 필요한 부분만 보완했습니다. HTML·CSS 정식 교안은 교안에 명시한 WHATWG·W3C·MDN 표준 및 가이드를, Java 샘플은 언어 명세를 근거로 프로젝트에서 새로 작성했습니다. 원본과 변경 내용, 프로젝트가 직접 작성한 문제·기준 답안·대표 오답 fixture의 출처는 [참고자료 감사 기록](docs/reference-audit.md)에 남겼습니다. +UI 참고 코드에서는 구조, 색상, 간격, 사용자 흐름만 분석했습니다. React, MUI, Tailwind 구성요소나 의존성은 복사하지 않았습니다. JavaScript 교안은 사용자가 제공한 읽기 전용 학습자료를 프로젝트 내부에 복제한 뒤, 오프라인 실행·접근성·설명 정확성을 위해 필요한 부분만 보완했습니다. HTML·CSS 정식 교안은 교안에 명시한 WHATWG·W3C·MDN 표준 및 가이드를, Java 정식 교안은 Java SE 25 언어 명세와 API 문서를 근거로 프로젝트에서 새로 작성했으며 예제는 Java 21 호환 범위로 제한했습니다. 원본과 변경 내용, 프로젝트가 직접 작성한 문제·기준 답안·대표 오답 fixture의 출처는 [참고자료 감사 기록](docs/reference-audit.md)에 남겼습니다. diff --git a/content/coding-tests/java.json b/content/coding-tests/java.json new file mode 100644 index 0000000..53dc89f --- /dev/null +++ b/content/coding-tests/java.json @@ -0,0 +1,710 @@ +{ + "schemaVersion": 1, + "contractVersion": 1, + "languageId": "java", + "title": "Java 코딩테스트", + "problems": [ + { + "id": "coding-test-java-count-vowels", + "slug": "java-count-vowels", + "revision": 1, + "order": 1, + "lessonId": "java-01-types-methods", + "conceptIds": ["java.types", "java.methods"], + "difficulty": "beginner", + "type": "string", + "tags": ["문자열", "메서드", "카운팅"], + "estimatedMinutes": 15, + "title": "영문 모음 개수 세기", + "summary": "문자열을 순회하며 대소문자 영문 모음을 모두 같은 규칙으로 셉니다.", + "description": "`public class Solution`의 `public static int countVowels(String text)` 메서드를 작성하세요. text에 등장하는 영문 모음 a, e, i, o, u와 A, E, I, O, U의 총개수를 반환합니다. 다른 문자와 공백은 세지 않습니다.", + "functionContract": { + "parameters": [ + { + "name": "text", + "type": "String", + "description": "길이 0 이상 1,000 이하인 문자열입니다." + } + ], + "returns": { + "type": "int", + "description": "대소문자를 모두 포함한 영문 모음의 개수입니다." + }, + "constraints": [ + "text는 null이 아닙니다.", + "영문 모음은 a, e, i, o, u의 대문자와 소문자만 뜻합니다.", + "한글, 숫자, 기호와 공백은 결과에 포함하지 않습니다." + ], + "complexity": { + "time": "O(n), n = text의 길이", + "space": "O(1)" + } + }, + "entryPoint": "countVowels", + "starterCode": "public class Solution {\n public static int countVowels(String text) {\n int count = 0;\n // 각 문자가 영문 모음인지 확인해 count를 갱신하세요.\n return count;\n }\n}\n", + "examples": [ + { + "args": ["Java CODE"], + "expected": 4, + "explanation": "Java의 a 두 개와 CODE의 O, E를 합쳐 4개입니다." + } + ], + "publicTests": [ + { + "id": "java-vowels-empty", + "label": "빈 문자열", + "args": [""], + "expected": 0 + }, + { + "id": "java-vowels-none", + "label": "모음이 없는 문자열", + "args": ["rhythm 123"], + "expected": 0 + }, + { + "id": "java-vowels-lowercase", + "label": "소문자 모음이 섞인 단어", + "args": ["education"], + "expected": 5 + }, + { + "id": "java-vowels-uppercase", + "label": "대문자 모음 다섯 개", + "args": ["AEIOU"], + "expected": 5 + }, + { + "id": "java-vowels-mixed-case", + "label": "대소문자와 공백이 섞인 문자열", + "args": ["Java CODE"], + "expected": 4 + }, + { + "id": "java-vowels-repeated", + "label": "같은 모음이 반복되는 문자열", + "args": ["bbbAaaa"], + "expected": 4 + } + ], + "runTestIds": ["java-vowels-lowercase", "java-vowels-uppercase", "java-vowels-mixed-case"], + "failureExplanations": [ + { + "testId": "java-vowels-empty", + "message": "빈 문자열에서는 반복문이 실행되지 않으므로 초기 개수 0을 반환해야 합니다." + }, + { + "testId": "java-vowels-none", + "message": "자음, 숫자와 공백을 모음으로 세지 않았는지 확인하세요." + }, + { + "testId": "java-vowels-lowercase", + "message": "a, e, i, o, u 중 하나를 조건에서 빠뜨리지 않았는지 확인하세요." + }, + { + "testId": "java-vowels-uppercase", + "message": "대문자 모음도 소문자와 같은 모음으로 세어야 합니다." + }, + { + "testId": "java-vowels-mixed-case", + "message": "각 문자를 한 가지 대소문자로 바꿔 비교하거나 대문자 조건을 함께 처리하세요." + }, + { + "testId": "java-vowels-repeated", + "message": "모음을 한 번 찾은 뒤 끝내지 말고 문자열 전체에서 등장할 때마다 누적하세요." + } + ] + }, + { + "id": "coding-test-java-merge-alternating", + "slug": "java-merge-alternating", + "revision": 1, + "order": 2, + "lessonId": "java-02-control-flow-arrays", + "conceptIds": ["java.control-flow", "java.arrays"], + "difficulty": "beginner", + "type": "array", + "tags": ["배열", "인덱스", "병합"], + "estimatedMinutes": 20, + "title": "두 배열 번갈아 합치기", + "summary": "길이가 다른 두 int 배열을 같은 인덱스끼리 번갈아 배치하고 남은 원소를 이어 붙입니다.", + "description": "`public class Solution`의 `public static int[] mergeAlternating(int[] left, int[] right)` 메서드를 작성하세요. 인덱스 0부터 left[i], right[i] 순으로 결과에 넣습니다. 한 배열의 원소가 먼저 끝나면 다른 배열의 남은 원소를 원래 순서대로 모두 넣으세요.", + "functionContract": { + "parameters": [ + { + "name": "left", + "type": "int[]", + "description": "길이 0 이상 100 이하인 첫 번째 정수 배열입니다." + }, + { + "name": "right", + "type": "int[]", + "description": "길이 0 이상 100 이하인 두 번째 정수 배열입니다." + } + ], + "returns": { + "type": "int[]", + "description": "두 배열을 번갈아 배치하고 남은 원소까지 포함한 새 배열입니다." + }, + "constraints": [ + "left와 right는 null이 아닙니다.", + "반환 배열의 길이는 left.length + right.length입니다.", + "입력 배열의 원소 순서는 각각 유지합니다." + ], + "complexity": { + "time": "O(n + m), n = left의 길이, m = right의 길이", + "space": "O(n + m)" + } + }, + "entryPoint": "mergeAlternating", + "starterCode": "public class Solution {\n public static int[] mergeAlternating(int[] left, int[] right) {\n int[] merged = new int[left.length + right.length];\n // 두 입력의 현재 인덱스와 merged의 쓰기 위치를 관리하세요.\n return merged;\n }\n}\n", + "examples": [ + { + "args": [[1, 2, 3], [10, 20]], + "expected": [1, 10, 2, 20, 3], + "explanation": "두 배열에서 두 번씩 번갈아 가져온 뒤 left에 남은 3을 마지막에 넣습니다." + } + ], + "publicTests": [ + { + "id": "java-merge-both-empty", + "label": "두 배열이 모두 빈 경우", + "args": [[], []], + "expected": [] + }, + { + "id": "java-merge-equal-length", + "label": "길이가 같은 두 배열", + "args": [[1, 2], [10, 20]], + "expected": [1, 10, 2, 20] + }, + { + "id": "java-merge-left-longer", + "label": "왼쪽 배열이 더 긴 경우", + "args": [[1, 2, 3], [10, 20]], + "expected": [1, 10, 2, 20, 3] + }, + { + "id": "java-merge-right-longer", + "label": "오른쪽 배열이 더 긴 경우", + "args": [[1], [7, 8, 9]], + "expected": [1, 7, 8, 9] + }, + { + "id": "java-merge-left-empty", + "label": "왼쪽 배열만 빈 경우", + "args": [[], [-2, -1]], + "expected": [-2, -1] + }, + { + "id": "java-merge-right-empty", + "label": "오른쪽 배열만 빈 경우", + "args": [[4, 5], []], + "expected": [4, 5] + } + ], + "runTestIds": ["java-merge-equal-length", "java-merge-left-longer", "java-merge-right-longer"], + "failureExplanations": [ + { + "testId": "java-merge-both-empty", + "message": "두 길이의 합이 0이면 길이 0인 int[]을 반환해야 합니다." + }, + { + "testId": "java-merge-equal-length", + "message": "같은 인덱스에서 left 원소를 먼저, right 원소를 다음에 배치하세요." + }, + { + "testId": "java-merge-left-longer", + "message": "번갈아 넣는 구간이 끝난 뒤 left에 남은 원소를 결과 끝에 복사하세요." + }, + { + "testId": "java-merge-right-longer", + "message": "left가 먼저 끝나도 right의 현재 인덱스부터 마지막까지 계속 복사해야 합니다." + }, + { + "testId": "java-merge-left-empty", + "message": "한 배열이 처음부터 비어 있어도 다른 배열의 모든 원소를 결과에 보존해야 합니다." + }, + { + "testId": "java-merge-right-empty", + "message": "right가 비어 있으면 left를 같은 순서로 담은 새 배열이 결과입니다." + } + ] + }, + { + "id": "coding-test-java-classify-students", + "slug": "java-classify-students", + "revision": 1, + "order": 3, + "lessonId": "java-03-classes-objects", + "conceptIds": ["java.classes", "java.objects", "java.constructors", "java.encapsulation"], + "difficulty": "beginner", + "type": "object", + "tags": ["클래스", "생성자", "배열"], + "estimatedMinutes": 24, + "title": "학생 결과 객체로 분류하기", + "summary": "이름과 점수를 객체 상태로 묶고 인스턴스 메서드가 합격 여부를 표시하게 합니다.", + "description": "`public class Solution`의 `public static String[] classifyStudents(String[] names, int[] scores)` 메서드를 작성하세요. 같은 인덱스의 이름과 점수를 학생 한 명으로 보고, 점수가 60 이상이면 `name:PASS`, 60 미만이면 `name:RETRY`를 반환 배열에 담습니다. 제공된 private 중첩 클래스와 생성자는 이름과 점수를 캡슐화해 보는 권장 구현 방식입니다. 스스로 객체를 사용했는지 확인할 수 있지만 자동 채점은 반환 배열의 값과 순서만 검사하며 내부 클래스 구조는 판정하지 않습니다.", + "functionContract": { + "parameters": [ + { + "name": "names", + "type": "String[]", + "description": "학생 이름을 순서대로 담은 길이 0 이상 100 이하의 배열입니다." + }, + { + "name": "scores", + "type": "int[]", + "description": "names와 같은 길이이며 0 이상 100 이하의 점수를 담은 배열입니다." + } + ], + "returns": { + "type": "String[]", + "description": "입력 순서대로 만든 `name:PASS` 또는 `name:RETRY` 문자열 배열입니다." + }, + "constraints": [ + "names와 scores는 null이 아니며 길이가 같습니다.", + "각 name은 null이 아니고 각 score는 0 이상 100 이하입니다.", + "합격 경계값 60은 PASS에 포함합니다.", + "private 중첩 클래스 사용은 권장 구현의 스스로 확인 항목이며 자동 채점은 반환 배열만 검사합니다." + ], + "complexity": { + "time": "O(n + c), n = names의 길이, c = 모든 이름 길이의 합", + "space": "O(n + c), n = 반환 항목 수, c = 결과에 복사되는 이름 길이의 합" + } + }, + "entryPoint": "classifyStudents", + "starterCode": "public class Solution {\n private static class StudentResult {\n private final String name;\n private final int score;\n\n StudentResult(String name, int score) {\n this.name = name;\n this.score = score;\n }\n\n String label() {\n // score가 60 이상이면 name + \":PASS\", 아니면 name + \":RETRY\"를 반환하세요.\n return name;\n }\n }\n\n public static String[] classifyStudents(String[] names, int[] scores) {\n // 각 인덱스의 값으로 StudentResult를 만들고 label() 결과를 담으세요.\n return new String[0];\n }\n}\n", + "examples": [ + { + "args": [["A", "B", "C"], [0, 60, 61]], + "expected": ["A:RETRY", "B:PASS", "C:PASS"], + "explanation": "0점은 재도전이고, 합격 경계 60점과 그보다 높은 61점은 모두 합격입니다." + } + ], + "publicTests": [ + { + "id": "java-students-empty", + "label": "학생이 없는 경우", + "args": [[], []], + "expected": [] + }, + { + "id": "java-students-below", + "label": "경계 바로 아래 점수", + "args": [["Bo"], [59]], + "expected": ["Bo:RETRY"] + }, + { + "id": "java-students-boundary", + "label": "합격 경계와 같은 점수", + "args": [["Ara"], [60]], + "expected": ["Ara:PASS"] + }, + { + "id": "java-students-maximum", + "label": "최고 점수", + "args": [["Mina"], [100]], + "expected": ["Mina:PASS"] + }, + { + "id": "java-students-mixed", + "label": "여러 경계 점수가 섞인 경우", + "args": [["A", "B", "C"], [0, 60, 61]], + "expected": ["A:RETRY", "B:PASS", "C:PASS"] + }, + { + "id": "java-students-repeated-name", + "label": "같은 이름에 서로 다른 결과", + "args": [["Kim", "Kim"], [59, 60]], + "expected": ["Kim:RETRY", "Kim:PASS"] + } + ], + "runTestIds": ["java-students-below", "java-students-boundary", "java-students-mixed"], + "failureExplanations": [ + { + "testId": "java-students-empty", + "message": "입력 길이가 0이면 결과도 길이 0인 String[]이어야 합니다." + }, + { + "testId": "java-students-below", + "message": "60보다 작은 점수에는 이름 뒤에 `:RETRY`를 붙이세요." + }, + { + "testId": "java-students-boundary", + "message": "60점은 PASS입니다. 비교 연산자에 같은 값이 포함되는지 확인하세요." + }, + { + "testId": "java-students-maximum", + "message": "점수를 문자열에 붙이는 것이 아니라 합격 여부인 `:PASS`를 붙여야 합니다." + }, + { + "testId": "java-students-mixed", + "message": "각 인덱스마다 새 객체를 만들고 그 학생의 score로 독립적으로 분류하세요." + }, + { + "testId": "java-students-repeated-name", + "message": "이름이 같아도 서로 다른 학생 항목입니다. 이름을 키로 합치지 말고 입력 인덱스를 보존하세요." + } + ] + }, + { + "id": "coding-test-java-frequency-report", + "slug": "java-frequency-report", + "revision": 1, + "order": 4, + "lessonId": "java-04-collections-generics", + "conceptIds": ["java.collections", "java.generics", "java.list", "java.map"], + "difficulty": "intermediate", + "type": "object", + "tags": ["Map", "제네릭", "빈도"], + "estimatedMinutes": 28, + "title": "단어 빈도 보고서 만들기", + "summary": "단어별 빈도를 Map에 누적하고 최초 등장 순서의 String 배열로 변환합니다.", + "description": "`public class Solution`의 `public static String[] frequencyReport(String[] words)` 메서드를 작성하세요. 서로 같은 문자열의 등장 횟수를 세고, 각 고유 단어를 처음 등장한 순서대로 `word=count` 형식에 담아 반환하세요. 비교는 대소문자를 구분합니다.", + "functionContract": { + "parameters": [ + { + "name": "words", + "type": "String[]", + "description": "길이 0 이상 200 이하이며 null 원소가 없는 단어 배열입니다." + } + ], + "returns": { + "type": "String[]", + "description": "최초 등장 순서로 정렬된 `word=count` 문자열 배열입니다." + }, + "constraints": [ + "words와 각 원소는 null이 아닙니다.", + "String 비교는 대소문자를 구분합니다.", + "각 고유 단어는 반환 배열에 정확히 한 번만 등장합니다." + ], + "complexity": { + "time": "O(n + c + u log(n + 1)) 평균, n = words의 길이, c = 모든 단어 길이의 합, u = 고유 단어 수 (LinkedHashMap 연산 평균 O(1) 가정)", + "space": "O(u + c + u log(n + 1)), c = 모든 단어 길이의 합, u = 고유 단어 수" + } + }, + "entryPoint": "frequencyReport", + "starterCode": "import java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class Solution {\n public static String[] frequencyReport(String[] words) {\n Map counts = new LinkedHashMap<>();\n // 빈도를 누적한 뒤 `word=count` 형식의 배열을 만드세요.\n return new String[0];\n }\n}\n", + "examples": [ + { + "args": [["red", "blue", "red", "green", "blue", "red"]], + "expected": ["red=3", "blue=2", "green=1"], + "explanation": "빈도는 모두 누적하지만 항목 순서는 red, blue, green의 최초 등장 순서를 따릅니다." + } + ], + "publicTests": [ + { + "id": "java-frequency-empty", + "label": "빈 단어 배열", + "args": [[]], + "expected": [] + }, + { + "id": "java-frequency-single", + "label": "단어 하나", + "args": [["code"]], + "expected": ["code=1"] + }, + { + "id": "java-frequency-repeated", + "label": "한 단어만 반복", + "args": [["go", "go", "go"]], + "expected": ["go=3"] + }, + { + "id": "java-frequency-first-order", + "label": "빈도와 최초 등장 순서가 다른 경우", + "args": [["red", "blue", "red", "green", "blue", "red"]], + "expected": ["red=3", "blue=2", "green=1"] + }, + { + "id": "java-frequency-case-sensitive", + "label": "대소문자가 다른 단어", + "args": [["java", "Java", "java"]], + "expected": ["java=2", "Java=1"] + }, + { + "id": "java-frequency-empty-word", + "label": "빈 문자열도 하나의 단어인 경우", + "args": [["", "a", ""]], + "expected": ["=2", "a=1"] + } + ], + "runTestIds": ["java-frequency-repeated", "java-frequency-first-order", "java-frequency-case-sensitive"], + "failureExplanations": [ + { + "testId": "java-frequency-empty", + "message": "고유 단어가 없으면 길이 0인 String[]을 반환해야 합니다." + }, + { + "testId": "java-frequency-single", + "message": "처음 본 단어의 초기 빈도는 1이어야 하며 `word=count` 형식을 지켜야 합니다." + }, + { + "testId": "java-frequency-repeated", + "message": "같은 키를 다시 만나면 기존 빈도에 1을 더하고 항목을 새로 만들지 마세요." + }, + { + "testId": "java-frequency-first-order", + "message": "빈도순이나 사전순으로 정렬하지 말고 Map에서 최초 등장 순서를 보존하세요." + }, + { + "testId": "java-frequency-case-sensitive", + "message": "java와 Java를 같은 키로 바꾸지 마세요. 원본 String을 그대로 Map 키로 사용하세요." + }, + { + "testId": "java-frequency-empty-word", + "message": "빈 문자열도 null이 아닌 유효한 키입니다. `=count` 형식으로 결과에 포함하세요." + } + ] + }, + { + "id": "coding-test-java-sum-valid-integers", + "slug": "java-sum-valid-integers", + "revision": 1, + "order": 5, + "lessonId": "java-05-exceptions-debugging", + "conceptIds": ["java.exceptions", "java.debugging"], + "difficulty": "intermediate", + "type": "string", + "tags": ["예외", "문자열 변환", "누적"], + "estimatedMinutes": 25, + "title": "변환 가능한 정수만 합산하기", + "summary": "여러 문자열을 독립적으로 변환하고 실패한 항목만 건너뛰며 합계를 유지합니다.", + "description": "`public class Solution`의 `public static int sumValidIntegers(String[] tokens)` 메서드를 작성하세요. 각 문자열에 Integer.parseInt를 적용해 성공한 int만 합산하고 NumberFormatException이 발생한 문자열은 건너뛰세요. 한 항목의 실패 때문에 나머지 항목 처리를 중단하면 안 됩니다.", + "functionContract": { + "parameters": [ + { + "name": "tokens", + "type": "String[]", + "description": "길이 0 이상 200 이하이며 null 원소가 없는 문자열 배열입니다." + } + ], + "returns": { + "type": "int", + "description": "Integer.parseInt에 성공한 모든 값의 합입니다." + }, + "constraints": [ + "tokens와 각 원소는 null이 아닙니다.", + "문자열을 trim하지 않고 Integer.parseInt(token)의 계약을 사용합니다.", + "변환에 성공한 값의 합은 int 범위 안에 있습니다." + ], + "complexity": { + "time": "O(c), c = 모든 문자열 길이의 합", + "space": "O(1)" + } + }, + "entryPoint": "sumValidIntegers", + "starterCode": "public class Solution {\n public static int sumValidIntegers(String[] tokens) {\n int total = 0;\n // 각 token을 별도의 try-catch에서 변환해 성공한 값만 더하세요.\n return total;\n }\n}\n", + "examples": [ + { + "args": [["7", "x", "-2", "4.5", "3"]], + "expected": 8, + "explanation": "7, -2, 3만 int로 변환되므로 합은 8입니다." + } + ], + "publicTests": [ + { + "id": "java-sum-empty", + "label": "빈 문자열 배열", + "args": [[]], + "expected": 0 + }, + { + "id": "java-sum-positive", + "label": "모두 양의 정수인 경우", + "args": [["1", "20", "300"]], + "expected": 321 + }, + { + "id": "java-sum-negative", + "label": "음수와 양수가 섞인 경우", + "args": [["-5", "2", "-3"]], + "expected": -6 + }, + { + "id": "java-sum-invalid-between", + "label": "유효하지 않은 문자열 사이의 정수", + "args": [["7", "x", "-2", "4.5", "3"]], + "expected": 8 + }, + { + "id": "java-sum-whitespace-invalid", + "label": "앞뒤 공백이 있는 문자열", + "args": [[" 5", "5 ", "6"]], + "expected": 6 + }, + { + "id": "java-sum-overflow-invalid", + "label": "int 범위를 넘는 문자열", + "args": [["2147483648", "-2147483649", "9"]], + "expected": 9 + }, + { + "id": "java-sum-plus-sign", + "label": "더하기 부호가 있는 정수", + "args": [["+8", "0"]], + "expected": 8 + } + ], + "runTestIds": ["java-sum-positive", "java-sum-invalid-between", "java-sum-overflow-invalid"], + "failureExplanations": [ + { + "testId": "java-sum-empty", + "message": "입력이 비어 있으면 초기 합계 0을 반환해야 합니다." + }, + { + "testId": "java-sum-positive", + "message": "변환에 성공한 모든 항목을 끝까지 순회하며 누적하세요." + }, + { + "testId": "java-sum-negative", + "message": "음수 문자열도 유효한 int입니다. 부호를 제거하거나 해당 값을 건너뛰지 마세요." + }, + { + "testId": "java-sum-invalid-between", + "message": "한 문자열의 변환 실패를 처리한 뒤에도 다음 문자열 순회를 계속해야 합니다." + }, + { + "testId": "java-sum-whitespace-invalid", + "message": "문제는 trim을 요구하지 않습니다. 공백이 포함된 두 항목은 실패로 건너뛰고 6만 더하세요." + }, + { + "testId": "java-sum-overflow-invalid", + "message": "숫자 문자만 있어도 int 범위를 넘으면 변환 실패입니다. NumberFormatException을 처리하세요." + }, + { + "testId": "java-sum-plus-sign", + "message": "Integer.parseInt는 앞의 더하기 부호를 허용하므로 +8도 합계에 포함해야 합니다." + } + ] + }, + { + "id": "coding-test-java-maximum-window-sum", + "slug": "java-maximum-window-sum", + "revision": 1, + "order": 6, + "lessonId": "java-06-review-practice", + "conceptIds": ["java.problem-decomposition", "java.testing", "java.complexity"], + "difficulty": "advanced", + "type": "array", + "tags": ["배열", "슬라이딩 윈도", "복잡도"], + "estimatedMinutes": 35, + "title": "고정 길이 구간의 최대 합", + "summary": "첫 구간 합을 만든 뒤 빠지는 값과 들어오는 값만 갱신해 모든 연속 구간을 O(n)에 비교합니다.", + "description": "`public class Solution`의 `public static int maximumWindowSum(int[] values, int windowSize)` 메서드를 작성하세요. values에서 서로 이어진 정확히 windowSize개 원소의 합 중 최댓값을 반환하세요. 첫 구간의 합을 구한 뒤 창을 한 칸 옮길 때 빠지는 값과 들어오는 값만 반영해 O(n)에 해결하세요. 자동 채점은 반환한 최댓값만 검사하며 시간 복잡도는 판정하지 않습니다.", + "functionContract": { + "parameters": [ + { + "name": "values", + "type": "int[]", + "description": "길이 1 이상 10,000 이하인 정수 배열입니다." + }, + { + "name": "windowSize", + "type": "int", + "description": "1 이상 values.length 이하인 연속 구간 길이입니다." + } + ], + "returns": { + "type": "int", + "description": "길이가 windowSize인 모든 연속 구간 합의 최댓값입니다." + }, + "constraints": [ + "values는 null이 아니며 길이는 1 이상 10,000 이하입니다.", + "windowSize는 1 이상 values.length 이하입니다.", + "모든 구간 합은 int 범위 안에 있습니다." + ], + "complexity": { + "time": "O(n), n = values의 길이", + "space": "O(1)" + } + }, + "entryPoint": "maximumWindowSum", + "starterCode": "public class Solution {\n public static int maximumWindowSum(int[] values, int windowSize) {\n int windowSum = 0;\n // 첫 구간 합을 만든 뒤 한 칸씩 옮기며 최댓값을 갱신하세요.\n return windowSum;\n }\n}\n", + "examples": [ + { + "args": [[1, 4, 2, 10, -3, 2], 3], + "expected": 16, + "explanation": "길이 3인 구간 중 [4, 2, 10]의 합 16이 가장 큽니다." + } + ], + "publicTests": [ + { + "id": "java-window-size-one", + "label": "창 길이가 1인 경우", + "args": [[3, -2, 5], 1], + "expected": 5 + }, + { + "id": "java-window-full-length", + "label": "창 길이가 배열 전체인 경우", + "args": [[2, -1, 4], 3], + "expected": 5 + }, + { + "id": "java-window-middle-maximum", + "label": "가운데 구간의 합이 최대인 경우", + "args": [[1, 4, 2, 10, -3, 2], 3], + "expected": 16 + }, + { + "id": "java-window-all-negative", + "label": "모든 값이 음수인 경우", + "args": [[-8, -3, -5, -4], 2], + "expected": -8 + }, + { + "id": "java-window-last-maximum", + "label": "마지막 구간의 합이 최대인 경우", + "args": [[9, -10, 1, 2, 20], 2], + "expected": 22 + }, + { + "id": "java-window-tied-maximum", + "label": "여러 구간의 최대 합이 같은 경우", + "args": [[2, 2, 2, 2], 2], + "expected": 4 + }, + { + "id": "java-window-single-negative", + "label": "음수 하나만 있는 배열", + "args": [[-7], 1], + "expected": -7 + } + ], + "runTestIds": ["java-window-size-one", "java-window-middle-maximum", "java-window-all-negative"], + "failureExplanations": [ + { + "testId": "java-window-size-one", + "message": "windowSize가 1이면 각 원소 하나가 구간이며 가장 큰 원소가 답입니다." + }, + { + "testId": "java-window-full-length", + "message": "창이 배열 전체를 덮으면 가능한 구간은 하나뿐이며 모든 값을 더한 합을 반환해야 합니다." + }, + { + "testId": "java-window-middle-maximum", + "message": "창을 옮길 때 이전 창의 첫 값을 빼고 새 끝 값을 더한 뒤 최댓값과 비교하세요." + }, + { + "testId": "java-window-all-negative", + "message": "최댓값을 0으로 시작하면 실제 합이 모두 음수일 때 틀립니다. 첫 번째 구간 합으로 초기화하세요." + }, + { + "testId": "java-window-last-maximum", + "message": "마지막으로 시작할 수 있는 인덱스까지 창을 옮겼는지 반복문의 끝 조건을 확인하세요." + }, + { + "testId": "java-window-tied-maximum", + "message": "최대 구간의 위치가 아니라 합만 반환하므로 동률이어도 같은 최댓값을 유지하면 됩니다." + }, + { + "testId": "java-window-single-negative", + "message": "원소가 하나이고 windowSize가 1이면 그 음수 자체가 유일한 구간 합입니다." + } + ] + } + ] +} diff --git a/content/curriculum.json b/content/curriculum.json index f293a95..c847476 100644 --- a/content/curriculum.json +++ b/content/curriculum.json @@ -30,9 +30,9 @@ "id": "java", "name": "Java", "shortName": "Java", - "status": "sample", + "status": "available", "accent": "java", - "description": "타입과 객체 중심으로 프로그램을 설계합니다." + "description": "타입과 객체부터 컬렉션·예외·테스트까지 연결합니다." } ], "lessons": [ @@ -383,18 +383,114 @@ "languageId": "java", "order": 1, "slug": "types-and-methods", - "title": "타입과 메서드 계약", - "summary": "값의 타입과 메서드의 입력·반환 타입을 읽어 코드의 약속을 설명합니다.", - "essentialQuestion": "Java 코드는 값과 메서드가 주고받는 약속을 어떻게 드러낼까요?", + "title": "타입·컴파일·메서드 계약", + "summary": "소스 코드가 컴파일되는 흐름을 관찰하고 변수와 메서드의 타입 계약을 읽습니다.", + "essentialQuestion": "Java는 실행 전에 값과 메서드의 약속을 어떻게 확인할까요?", "objectives": [ "기본 타입과 참조 타입이 표현하는 값의 역할을 구분할 수 있습니다.", - "메서드 선언에서 매개변수 타입과 반환 타입을 찾을 수 있습니다.", - "호출 코드가 메서드 계약과 맞는지 타입을 기준으로 설명할 수 있습니다." + "변수의 선언 타입과 대입되는 값의 타입 관계를 설명할 수 있습니다.", + "메서드 선언에서 매개변수 타입과 반환 타입을 입력·출력 계약으로 읽을 수 있습니다.", + "소스 파일이 컴파일되어 클래스 파일이 되는 흐름과 컴파일 오류의 의미를 설명할 수 있습니다." ], - "estimatedMinutes": 20, - "conceptIds": ["java.types", "java.methods"], + "estimatedMinutes": 50, + "conceptIds": ["java.types", "java.variables", "java.methods", "java.compilation"], "contentFile": "content/lessons/java/types-and-methods.md", - "source": { "kind": "bam-authored", "verifiedAt": "2026-08-18" } + "source": { "kind": "bam-authored", "verifiedAt": "2026-08-19" } + }, + { + "id": "java-02-control-flow-arrays", + "languageId": "java", + "order": 2, + "slug": "operators-control-flow-and-arrays", + "title": "연산·제어문·배열", + "summary": "연산 결과를 조건과 반복에 연결하고 고정 길이 배열의 경계를 안전하게 순회합니다.", + "essentialQuestion": "여러 값을 조건에 따라 처리하는 실행 흐름을 어떻게 예측할까요?", + "objectives": [ + "산술·비교·논리 연산자의 결과 타입과 평가 순서를 확인할 수 있습니다.", + "if와 반복문이 boolean 조건으로 실행 흐름을 나누는 과정을 설명할 수 있습니다.", + "배열의 길이와 0부터 시작하는 인덱스 경계를 구분할 수 있습니다.", + "배열을 순회하며 합계나 개수를 계산하는 최소 메서드를 구현할 수 있습니다." + ], + "estimatedMinutes": 55, + "conceptIds": ["java.operators", "java.control-flow", "java.arrays"], + "contentFile": "content/lessons/java/operators-control-flow-and-arrays.md", + "source": { "kind": "bam-authored", "verifiedAt": "2026-08-19" } + }, + { + "id": "java-03-classes-objects", + "languageId": "java", + "order": 3, + "slug": "classes-objects-and-encapsulation", + "title": "클래스·객체·생성자·캡슐화", + "summary": "상태와 동작을 클래스로 묶고 생성자와 접근 제어로 유효한 객체를 만듭니다.", + "essentialQuestion": "관련된 데이터와 규칙을 한 객체의 책임으로 어떻게 묶을까요?", + "objectives": [ + "클래스 선언과 new 표현식으로 생성된 객체를 구분할 수 있습니다.", + "필드와 메서드가 객체의 상태와 동작을 나타내는 방식을 설명할 수 있습니다.", + "생성자가 새 객체의 초기 상태를 정하는 과정을 읽을 수 있습니다.", + "private 필드와 공개 메서드로 상태 변경 규칙을 보호할 수 있습니다." + ], + "estimatedMinutes": 60, + "conceptIds": ["java.classes", "java.objects", "java.constructors", "java.encapsulation"], + "contentFile": "content/lessons/java/classes-objects-and-encapsulation.md", + "source": { "kind": "bam-authored", "verifiedAt": "2026-08-19" } + }, + { + "id": "java-04-collections-generics", + "languageId": "java", + "order": 4, + "slug": "collections-generics-list-and-map", + "title": "컬렉션·제네릭·List·Map", + "summary": "크기가 변하는 데이터와 키·값 관계를 제네릭 컬렉션으로 안전하게 표현합니다.", + "essentialQuestion": "여러 데이터를 목적에 맞는 컬렉션과 타입으로 어떻게 구조화할까요?", + "objectives": [ + "배열과 컬렉션의 크기·연산 계약 차이를 설명할 수 있습니다.", + "제네릭 타입 인수가 컬렉션 원소의 타입 계약을 드러내는 방식을 이해합니다.", + "순서와 인덱스가 필요한 경우 List를 선택하고 기본 연산을 사용할 수 있습니다.", + "고유한 키로 값을 찾는 경우 Map을 선택하고 put·get·containsKey를 구분할 수 있습니다." + ], + "estimatedMinutes": 60, + "conceptIds": ["java.collections", "java.generics", "java.list", "java.map"], + "contentFile": "content/lessons/java/collections-generics-list-and-map.md", + "source": { "kind": "bam-authored", "verifiedAt": "2026-08-19" } + }, + { + "id": "java-05-exceptions-debugging", + "languageId": "java", + "order": 5, + "slug": "exceptions-and-debugging", + "title": "예외·디버깅", + "summary": "예외의 원인과 전파 경로를 읽고 컴파일·실행·논리 문제를 단계적으로 좁힙니다.", + "essentialQuestion": "예상하지 못한 상황과 잘못된 결과의 원인을 어떤 근거로 좁힐까요?", + "objectives": [ + "예외가 정상 흐름을 중단하고 호출자 쪽으로 전파될 수 있음을 설명할 수 있습니다.", + "검사 예외와 비검사 예외가 컴파일 단계에서 다르게 다뤄지는 이유를 이해합니다.", + "try·catch와 throws 중 책임에 맞는 처리 방식을 선택할 수 있습니다.", + "컴파일 오류·런타임 예외·잘못된 결과를 구분해 재현 입력과 관찰 지점을 기록할 수 있습니다." + ], + "estimatedMinutes": 55, + "conceptIds": ["java.exceptions", "java.checked-exceptions", "java.debugging"], + "contentFile": "content/lessons/java/exceptions-and-debugging.md", + "source": { "kind": "bam-authored", "verifiedAt": "2026-08-19" } + }, + { + "id": "java-06-review-practice", + "languageId": "java", + "order": 6, + "slug": "review-problem-solving-and-testing", + "title": "복습·문제 분해·테스트·복잡도", + "summary": "요구사항을 작은 메서드로 나누고 경계 테스트와 시간 복잡도로 풀이를 점검합니다.", + "essentialQuestion": "동작하는 코드를 넘어 설명하고 검증할 수 있는 풀이를 어떻게 만들까요?", + "objectives": [ + "문제를 입력·출력·규칙·경계 사례로 나눌 수 있습니다.", + "각 책임을 작은 메서드와 명확한 타입 계약으로 표현할 수 있습니다.", + "정상·경계·잘못된 입력 사례를 독립적으로 확인하는 테스트를 작성할 수 있습니다.", + "반복 횟수를 기준으로 O(1)·O(n)·O(n²)의 차이를 설명할 수 있습니다." + ], + "estimatedMinutes": 65, + "conceptIds": ["java.problem-decomposition", "java.testing", "java.complexity"], + "contentFile": "content/lessons/java/review-problem-solving-and-testing.md", + "source": { "kind": "bam-authored", "verifiedAt": "2026-08-19" } } ] } diff --git a/content/lessons/java/classes-objects-and-encapsulation.md b/content/lessons/java/classes-objects-and-encapsulation.md new file mode 100644 index 0000000..08358a3 --- /dev/null +++ b/content/lessons/java/classes-objects-and-encapsulation.md @@ -0,0 +1,177 @@ +# 03. 클래스·객체·생성자·캡슐화 + +## 학습 목표 + +- 클래스 선언과 `new` 표현식으로 생성된 객체를 구분할 수 있습니다. +- 필드와 메서드가 객체의 상태와 동작을 나타내는 방식을 설명할 수 있습니다. +- 생성자가 새 객체의 초기 상태를 정하는 과정을 읽을 수 있습니다. +- `private` 필드와 공개 메서드로 상태 변경 규칙을 보호할 수 있습니다. + +## 먼저 관찰하기 + +```java +class LearningProgress { + private final String learnerName; + private int completedLessons; + + LearningProgress(String learnerName) { + this.learnerName = learnerName; + this.completedLessons = 0; + } + + void completeOne() { + completedLessons++; + } + + int completedLessons() { + return completedLessons; + } +} +``` + +1. 클래스 이름, 필드, 생성자, 메서드를 각각 찾아보세요. +2. 생성자에 반환 타입이 없는 이유를 일반 메서드와 비교해 보세요. +3. 외부 코드가 `completedLessons`를 직접 음수로 바꿀 수 있나요? +4. `new LearningProgress("Bam")`에서 새로 만들어지는 것은 클래스인가요, 객체인가요? + +## 클래스는 설계, 객체는 실행 중의 개별 상태 + +클래스 선언은 필드와 메서드, 생성자 같은 구성원을 정의합니다. 객체는 클래스 인스턴스 생성 표현식으로 만들어진 개별 인스턴스입니다. + +```java +LearningProgress first = new LearningProgress("Bam"); +LearningProgress second = new LearningProgress("Mina"); +``` + +`first`와 `second`는 각각 다른 객체를 가리키는 참조 변수입니다. 같은 클래스의 설계를 따르지만 각 객체는 자신의 필드 상태를 가집니다. + +클래스와 객체를 “틀과 제품”으로 비유할 수 있지만, 비유만으로는 부족합니다. 코드에서는 클래스 선언, `new` 표현식, 참조 변수, 객체의 필드와 메서드를 구체적으로 구분해야 합니다. + +## 필드와 메서드로 책임 묶기 + +필드는 객체가 기억할 상태를, 메서드는 그 상태를 사용하거나 바꾸는 동작을 나타냅니다. + +```java +class Counter { + private int value; + + void increase() { + value++; + } + + int value() { + return value; + } +} +``` + +상태를 바꾸는 규칙을 `increase()` 안에 두면 호출자는 증가 방식보다 “무엇을 요청하는가”에 집중할 수 있습니다. + +## 생성자로 유효한 시작 상태 만들기 + +생성자 이름은 클래스의 단순 이름과 같고 반환 타입을 적지 않습니다. 객체 생성 시 전달된 인수는 생성자 매개변수에 연결됩니다. + +```java +class Course { + private final String title; + private final int totalLessons; + + Course(String title, int totalLessons) { + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("과정 이름이 필요합니다."); + } + if (totalLessons <= 0) { + throw new IllegalArgumentException("교안 수는 양수여야 합니다."); + } + this.title = title; + this.totalLessons = totalLessons; + } +} +``` + +생성자에서 유효성 규칙을 확인하면 잘못된 시작 상태의 객체가 이후 코드로 퍼지는 것을 막을 수 있습니다. `this.title`은 현재 객체의 필드이고, 오른쪽 `title`은 생성자 매개변수입니다. + +## 캡슐화는 변경 규칙 보호하기 + +`private` 필드는 해당 최상위 클래스의 본문 범위에서만 직접 접근할 수 있습니다. 캡슐화의 목적은 모든 필드에 단순 getter와 setter를 붙이는 것이 아니라, 객체가 지켜야 할 규칙을 공개 메서드 뒤에 두는 것입니다. + +```java +class Wallet { + private int balance; + + Wallet(int initialBalance) { + if (initialBalance < 0) { + throw new IllegalArgumentException("초기 잔액은 음수일 수 없습니다."); + } + balance = initialBalance; + } + + boolean spend(int amount) { + if (amount <= 0 || amount > balance) { + return false; + } + balance -= amount; + return true; + } + + int balance() { + return balance; + } +} +``` + +`setBalance(-100)`처럼 임의 상태를 허용하는 setter 대신 `spend()`가 금액과 잔액 규칙을 함께 지킵니다. + +## 최소 구현 + +완료 수가 전체 교안 수를 넘지 않게 하는 메서드를 구현해 보세요. + +```java +class StudyPlan { + private final int totalLessons; + private int completedLessons; + + StudyPlan(int totalLessons) { + if (totalLessons <= 0) { + throw new IllegalArgumentException("전체 교안 수는 양수여야 합니다."); + } + this.totalLessons = totalLessons; + } + + boolean completeNext() { + if (completedLessons >= totalLessons) { + return false; + } + completedLessons++; + return true; + } +} +``` + +실행 전에 다음을 설명하세요. + +1. 새 객체의 `completedLessons`는 어떤 초기값에서 시작하는가? +2. 이미 모두 완료했다면 어느 분기가 상태 변경을 막는가? +3. 외부 코드가 필드를 직접 바꾸지 못하는 이유는 무엇인가? + +## 흔한 실수 + +- 생성자에 `void`를 붙이면 생성자가 아니라 같은 이름의 메서드가 됩니다. +- 매개변수와 필드 이름이 같을 때 `this.field`를 생략하면 의도한 필드 대입이 되지 않을 수 있습니다. +- 모든 필드를 공개하면 객체가 지켜야 할 조건을 우회할 수 있습니다. +- 참조 변수를 복사하면 객체 전체가 자동 복제되는 것이 아니라 같은 객체를 가리킬 수 있습니다. + +## 확인 문제 + +1. 클래스 선언과 객체를 각각 코드의 어느 부분에서 확인할 수 있나요? +2. 생성자와 일반 메서드의 선언 형태에서 가장 눈에 띄는 차이는 무엇인가요? +3. `private` 필드가 상태 규칙을 보호하는 데 어떻게 도움이 되나요? +4. 단순 setter 대신 의도를 드러내는 `spend()`를 둔 이유를 설명해 보세요. +5. `StudyPlan.completeNext()`가 `boolean`을 반환하면 호출자는 어떤 정보를 얻나요? + +## 공식 근거 자료 + +- [Java Language Specification SE 25 §8: 클래스](https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html) +- [Java Language Specification SE 25 §8.2: 클래스 구성원](https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.2) +- [Java Language Specification SE 25 §8.8: 생성자 선언](https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.8) +- [Java Language Specification SE 25 §15.9: 클래스 인스턴스 생성](https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html#jls-15.9) diff --git a/content/lessons/java/collections-generics-list-and-map.md b/content/lessons/java/collections-generics-list-and-map.md new file mode 100644 index 0000000..9b361be --- /dev/null +++ b/content/lessons/java/collections-generics-list-and-map.md @@ -0,0 +1,148 @@ +# 04. 컬렉션·제네릭·List·Map + +## 학습 목표 + +- 배열과 컬렉션의 크기·연산 계약 차이를 설명할 수 있습니다. +- 제네릭 타입 인수가 컬렉션 원소의 타입 계약을 드러내는 방식을 이해합니다. +- 순서와 인덱스가 필요한 경우 `List`를 선택하고 기본 연산을 사용할 수 있습니다. +- 고유한 키로 값을 찾는 경우 `Map`을 선택하고 `put`·`get`·`containsKey`를 구분할 수 있습니다. + +## 먼저 관찰하기 + +```java +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +List topics = new ArrayList<>(); +topics.add("타입"); +topics.add("배열"); + +Map scores = new HashMap<>(); +scores.put("Bam", 80); +``` + +1. `List`의 `String`은 어떤 값을 넣겠다는 계약인가요? +2. `topics`에서는 위치와 순서 중 무엇을 관찰할 수 있나요? +3. `scores`의 키 타입과 값 타입은 각각 무엇인가요? +4. `scores.put("Bam", 90)`을 추가하면 같은 키에 두 값이 공존할까요? + +## 배열과 컬렉션 선택하기 + +배열은 생성 시 길이가 정해집니다. 컬렉션은 원소 추가·삭제 같은 연산을 인터페이스 계약으로 제공합니다. + +```java +String[] fixedTopics = new String[3]; +List growingTopics = new ArrayList<>(); +``` + +길이가 고정되고 인덱스로 다루는 단순 데이터에는 배열이 적합할 수 있습니다. 항목 수가 바뀌고 추가·삭제·검색 연산이 필요하다면 컬렉션을 검토하세요. “항상 컬렉션이 더 좋다”가 아니라 요구사항의 연산을 먼저 적는 것이 중요합니다. + +## 제네릭으로 원소 타입 드러내기 + +```java +List names = new ArrayList<>(); +names.add("Bam"); +// names.add(42); // String 목록의 타입 계약과 맞지 않음 +``` + +`List`의 타입 인수 `String`은 이 목록의 원소 타입을 드러냅니다. 값을 꺼낼 때도 `String` 계약을 사용할 수 있습니다. + +```java +String firstName = names.get(0); +``` + +원시 타입인 `int`를 타입 인수로 직접 사용할 수는 없습니다. 숫자 목록에는 래퍼 클래스 `Integer`를 사용합니다. + +```java +List scores = new ArrayList<>(); +scores.add(80); +``` + +## List: 순서와 위치가 있는 컬렉션 + +`List`는 순서가 있고 정수 인덱스로 원소에 접근합니다. 인덱스는 배열처럼 0부터 시작하며, 일반적으로 중복 원소도 허용합니다. + +```java +List lessons = new ArrayList<>(); +lessons.add("타입"); +lessons.add("배열"); +lessons.add("타입"); + +String second = lessons.get(1); +int count = lessons.size(); +boolean alreadyAdded = lessons.contains("타입"); +``` + +`contains(value)`는 목록을 앞에서부터 확인하며 같은 원소가 있는지 알려 줍니다. `String` 원소는 `equals` 규칙으로 비교하므로 대소문자가 다른 문자열은 서로 다른 값입니다. `ArrayList`의 `contains`는 필요한 원소를 찾거나 목록 끝에 도달할 때까지 순서대로 비교하므로, 목록이 길어질수록 비교 횟수도 늘어날 수 있습니다. + +변수 타입을 구현 클래스 `ArrayList` 대신 인터페이스 `List`로 선언하면 호출 코드는 필요한 목록 계약에 집중할 수 있습니다. + +`List.of("타입", "배열")`이 만드는 목록은 수정할 수 없습니다. 이후 `add`가 필요한 예제라면 `new ArrayList<>()`처럼 수정 가능한 구현을 선택하세요. + +## Map: 키로 값을 찾는 컬렉션 + +`Map`는 키를 값에 연결합니다. 하나의 맵에는 같은 키가 하나만 존재하며, `put`으로 이미 존재하는 키를 넣으면 이전 값이 교체됩니다. + +```java +Map progress = new HashMap<>(); +progress.put("java.types", 50); +progress.put("java.types", 100); + +Integer percent = progress.get("java.types"); +boolean hasQuiz = progress.containsKey("java.quiz"); +``` + +`get` 결과가 `null`이라고 해서 항상 키가 없다고 단정할 수는 없습니다. `HashMap`은 `null` 값도 허용하므로 키 존재 여부를 구분해야 할 때는 `containsKey`를 사용하세요. + +`HashMap`은 반복 순서를 보장하지 않습니다. 출력 순서가 요구사항이라면 순서를 보장하는 다른 구현의 계약을 검토해야 합니다. + +## 최소 구현 + +완료 상태 맵에서 `true`인 값의 개수를 셉니다. + +```java +static int countCompleted(Map progress) { + int count = 0; + + for (boolean completed : progress.values()) { + if (completed) { + count++; + } + } + + return count; +} +``` + +관찰 질문: + +1. 키가 하나도 없으면 반복 본문은 몇 번 실행되나요? +2. 값 타입이 `Boolean`인데 향상된 `for`문 변수는 왜 `boolean`으로 쓸 수 있나요? +3. 개수 계산에 키가 필요한가요, 값만 필요하나요? +4. `null` 값이 들어올 수 있는 계약이라면 현재 코드에 어떤 위험이 있나요? + +## 흔한 실수 + +- `List.of()` 결과를 수정 가능한 목록으로 생각하고 `add`를 호출합니다. +- `Map.get()`의 `null`만 보고 키가 없다고 단정합니다. +- `HashMap`의 반복 순서를 입력 순서로 가정합니다. +- 제네릭을 생략한 원시 타입을 사용해 컴파일 시점의 원소 타입 검사를 약하게 만듭니다. +- 목록을 순회하면서 같은 목록을 구조적으로 수정해 예측하기 어려운 흐름을 만듭니다. + +## 확인 문제 + +1. 고정 길이 배열과 `ArrayList` 중 항목 추가가 잦은 요구사항에 무엇을 선택할지 설명해 보세요. +2. `List`에서 `String`이 제공하는 계약은 무엇인가요? +3. `List`와 `Map`을 선택하는 핵심 질문을 각각 하나씩 적어 보세요. +4. 같은 키로 `Map.put()`을 두 번 호출하면 어떤 계약에 따라 값이 바뀌나요? +5. `Map.get()`과 `containsKey()`를 함께 써야 하는 상황을 설명해 보세요. + +## 공식 근거 자료 + +- [Java Language Specification SE 25 §4.5: 매개변수화 타입](https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html#jls-4.5) +- [Java SE 25 API: `List`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/List.html) +- [Java SE 25 API: `ArrayList`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ArrayList.html) +- [Java SE 25 API: `Map`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Map.html) +- [Java SE 25 API: `HashMap`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/HashMap.html) diff --git a/content/lessons/java/exceptions-and-debugging.md b/content/lessons/java/exceptions-and-debugging.md new file mode 100644 index 0000000..612935b --- /dev/null +++ b/content/lessons/java/exceptions-and-debugging.md @@ -0,0 +1,148 @@ +# 05. 예외·디버깅 + +## 학습 목표 + +- 예외가 정상 흐름을 중단하고 호출자 쪽으로 전파될 수 있음을 설명할 수 있습니다. +- 검사 예외와 비검사 예외가 컴파일 단계에서 다르게 다뤄지는 이유를 이해합니다. +- `try`·`catch`와 `throws` 중 책임에 맞는 처리 방식을 선택할 수 있습니다. +- 컴파일 오류·런타임 예외·잘못된 결과를 구분해 재현 입력과 관찰 지점을 기록할 수 있습니다. + +## 먼저 관찰하기 + +```java +static int parseLevel(String text) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException error) { + return 1; + } +} +``` + +1. 정상 흐름과 예외 흐름은 각각 어느 `return`에 도달하나요? +2. 어떤 입력에서 `Integer.parseInt`가 숫자를 만들지 못할 수 있나요? +3. `catch`가 모든 예외를 잡지 않고 `NumberFormatException`만 잡는 이유는 무엇일까요? +4. 잘못된 입력을 항상 기본값 `1`로 바꾸는 것이 제품 요구사항에 맞는지도 별도로 판단해 보세요. + +## 예외는 정상 흐름과 다른 완료 방식 + +문장이나 표현식이 예외를 던지면 현재의 정상 실행 흐름은 중단됩니다. 일치하는 `catch`가 있으면 그 블록에서 처리하고, 없으면 호출한 쪽으로 전파될 수 있습니다. + +```java +static int divide(int total, int count) { + if (count == 0) { + throw new IllegalArgumentException("개수는 0일 수 없습니다."); + } + return total / count; +} +``` + +예외는 조건문을 대신하는 평범한 분기 도구가 아닙니다. 호출자가 지켜야 할 계약을 위반했거나 정상 결과를 만들 수 없는 상황인지 먼저 판단하세요. + +## 검사 예외와 비검사 예외 + +Java의 예외 클래스 중 `RuntimeException`과 그 하위 클래스는 비검사 예외입니다. `Error` 계열도 비검사 예외입니다. 그 밖의 예외 클래스는 검사 예외이며, 컴파일러가 `catch`로 처리하거나 `throws`로 선언하는지를 확인합니다. + +```java +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +static String loadText(Path path) throws IOException { + return Files.readString(path); +} +``` + +`Files.readString`은 I/O 오류에서 `IOException`을 던질 수 있습니다. 위 메서드는 복구 방법을 임의로 정하지 않고 호출자에게 가능성을 `throws`로 전달합니다. + +호출 지점에서 사용자에게 다른 경로를 요청하는 등 복구 책임이 있다면 구체적으로 처리할 수 있습니다. + +```java +static String loadOrExplain(Path path) { + try { + return Files.readString(path); + } catch (IOException error) { + return "파일을 읽을 수 없습니다: " + path; + } +} +``` + +실제 서비스에서는 예외 원인을 로그나 상위 계층에 보존할지, 사용자에게 어떤 정보를 노출할지 별도 정책이 필요합니다. 비어 있는 `catch`로 오류를 숨기지 마세요. + +## try·catch와 throws 선택 질문 + +다음 순서로 판단하세요. + +1. 이 메서드가 예외 상황을 실제로 복구할 수 있는가? +2. 대체 값이 요구사항에 정의되어 있는가? +3. 더 높은 계층이 파일 경로, 재시도, 사용자 안내를 더 잘 결정할 수 있는가? +4. 잡는다면 가장 구체적으로 어떤 예외를 처리해야 하는가? + +`catch (Exception error)`로 모든 문제를 한꺼번에 삼키면 프로그래밍 오류까지 정상 결과처럼 보일 수 있습니다. + +## 디버깅은 문제 종류부터 나누기 + +### 1. 컴파일 오류 + +- 클래스 파일 생성 전에 발생합니다. +- 진단의 파일·줄·기대 타입과 실제 타입을 확인합니다. +- 첫 진단을 고친 뒤 다시 컴파일해 연쇄 오류가 줄었는지 봅니다. + +### 2. 런타임 예외 + +- 컴파일된 프로그램 실행 중 발생합니다. +- 예외 타입과 메시지를 읽고, 스택 추적에서 자신의 코드가 처음 등장하는 호출 지점을 찾습니다. +- 같은 입력으로 재현하고 해당 줄 직전의 값과 객체 상태를 확인합니다. + +### 3. 잘못된 결과 + +- 프로그램이 끝나도 요구한 결과와 다릅니다. +- 입력, 예상값, 실제 확인값을 기록하고 메서드 경계마다 중간 값을 좁힙니다. +- 아직 실행하지 않은 출력은 실제값으로 기록하지 않습니다. + +## 최소 구현 + +문자열을 1 이상인 레벨로 변환하고, 형식이나 범위가 잘못되면 `-1`을 반환합니다. + +```java +static int parsePositiveLevel(String text) { + try { + int level = Integer.parseInt(text); + return level > 0 ? level : -1; + } catch (NumberFormatException error) { + return -1; + } +} +``` + +테스트할 입력을 세 부류로 나누어 보세요. + +- 정상: 양의 정수 문자열 +- 경계: `"0"`, 음수 문자열 +- 형식 오류: 빈 문자열, 글자가 섞인 문자열 + +각 입력의 예상값을 먼저 쓰고 실제 실행 결과와 비교하세요. + +## 흔한 실수 + +- 예외를 잡고 아무 처리도 하지 않아 실패 원인을 없앱니다. +- 검사 예외를 무조건 `RuntimeException`으로 감싸기만 하고 책임 경계를 설명하지 않습니다. +- 스택 추적의 마지막 줄만 보고 실제로 예외가 시작된 자기 코드 위치를 놓칩니다. +- 재현 입력 없이 코드를 여러 곳 동시에 바꿉니다. +- 잘못된 결과를 런타임 예외와 같은 문제로 취급합니다. + +## 확인 문제 + +1. 검사 예외와 비검사 예외는 컴파일러의 처리 요구에서 어떻게 다른가요? +2. `Files.readString`을 호출하는 메서드가 `throws IOException`을 선언하는 의미는 무엇인가요? +3. `catch`에서 대체 값을 반환하기 전에 확인해야 할 제품 요구사항은 무엇인가요? +4. 컴파일 오류·런타임 예외·잘못된 결과를 각각 어느 단계에서 관찰하나요? +5. 디버깅할 때 재현 입력과 예상값을 먼저 기록하는 이유를 설명해 보세요. + +## 공식 근거 자료 + +- [Java Language Specification SE 25 §11: 예외](https://docs.oracle.com/javase/specs/jls/se25/html/jls-11.html) +- [Java Language Specification SE 25 §14.20: try 문](https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.20) +- [Java SE 25 API: `RuntimeException`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/RuntimeException.html) +- [Java SE 25 API: `Files.readString`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/Files.html#readString(java.nio.file.Path)) +- [Java SE 25 API: `Integer.parseInt`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Integer.html#parseInt(java.lang.String)) diff --git a/content/lessons/java/operators-control-flow-and-arrays.md b/content/lessons/java/operators-control-flow-and-arrays.md new file mode 100644 index 0000000..141d74a --- /dev/null +++ b/content/lessons/java/operators-control-flow-and-arrays.md @@ -0,0 +1,151 @@ +# 02. 연산·제어문·배열 + +## 학습 목표 + +- 산술·비교·논리 연산자의 결과 타입과 평가 순서를 확인할 수 있습니다. +- `if`와 반복문이 `boolean` 조건으로 실행 흐름을 나누는 과정을 설명할 수 있습니다. +- 배열의 길이와 0부터 시작하는 인덱스 경계를 구분할 수 있습니다. +- 배열을 순회하며 합계나 개수를 계산하는 최소 메서드를 구현할 수 있습니다. + +## 먼저 관찰하기 + +코드를 실행하기 전에 반복 횟수와 `passedCount`가 바뀌는 조건을 손으로 표시해 보세요. + +```java +int[] scores = {55, 60, 90}; +int passedCount = 0; + +for (int index = 0; index < scores.length; index++) { + if (scores[index] >= 60) { + passedCount++; + } +} +``` + +1. 첫 번째와 마지막으로 유효한 인덱스는 무엇인가요? +2. 반복 조건이 `index <= scores.length`라면 어떤 경계가 배열 밖을 가리키나요? +3. `scores[index] >= 60`은 어떤 타입의 값을 만드나요? +4. 점수가 정확히 `60`일 때 증가해야 하는지 요구사항과 비교해 보세요. + +## 연산 결과의 타입부터 확인하기 + +산술 연산은 숫자 값을 계산하고, 비교 연산은 `boolean` 값을 만듭니다. + +```java +int total = 7 + 5; +int quotient = 7 / 2; +boolean enough = total >= 10; +``` + +두 피연산자가 `int`인 나눗셈은 정수 나눗셈입니다. 소수 부분이 필요한 요구사항이라면 적어도 한쪽을 `double`로 바꾸는 등 타입을 의도적으로 설계해야 합니다. + +```java +double average = (double) total / 2; +``` + +연산자를 많이 이어 붙이기 전에 중간 결과를 변수로 나누면 타입과 의미를 관찰하기 쉽습니다. + +## 논리 연산과 단축 평가 + +`&&`는 왼쪽이 `true`일 때만 오른쪽을 평가하고, `||`는 왼쪽이 `false`일 때만 오른쪽을 평가합니다. + +```java +boolean validIndex = index >= 0 && index < scores.length; +``` + +왼쪽부터 읽으면 “인덱스가 음수가 아니고, 배열 길이보다 작다”는 조건입니다. 단축 평가는 편리하지만, 오른쪽에 반드시 실행되어야 하는 상태 변경 코드를 넣으면 흐름을 이해하기 어려워집니다. 조건식은 가능하면 관찰 가능한 질문으로 유지하세요. + +## 조건문으로 한 갈래 선택하기 + +```java +static String grade(int score) { + if (score >= 90) { + return "A"; + } else if (score >= 80) { + return "B"; + } + return "C"; +} +``` + +`if` 조건은 `boolean` 표현식이어야 합니다. 위에서부터 처음 참인 갈래만 실행되므로 넓은 조건을 먼저 쓰면 뒤 조건이 도달하지 못할 수 있습니다. 경계값을 표로 적지 말고 실제 조건에 넣어 어느 갈래인지 한 단계씩 추적해 보세요. + +## 반복문과 배열 경계 + +배열은 생성될 때 길이가 정해지고, 인덱스는 `0`부터 `length - 1`까지입니다. + +```java +String[] topics = {"타입", "배열", "클래스"}; + +for (int index = 0; index < topics.length; index++) { + String topic = topics[index]; + System.out.println(topic); +} +``` + +인덱스가 필요한 작업에는 기본 `for`문이 유용합니다. 위치가 필요하지 않고 모든 원소를 읽기만 한다면 향상된 `for`문이 더 단순합니다. + +```java +for (String topic : topics) { + System.out.println(topic); +} +``` + +두 예제의 출력을 아직 실행하지 않았다면 실제 출력이 확인되었다고 말하지 마세요. 각 반복에서 선택되는 원소를 먼저 적고, 실행 환경에서 확인한 뒤 예상과 비교하세요. + +## 최소 구현 + +양수인 값의 개수를 세는 메서드를 완성해 보세요. + +```java +static int countPositive(int[] numbers) { + int count = 0; + + for (int number : numbers) { + if (number > 0) { + count++; + } + } + + return count; +} +``` + +관찰 순서: + +1. 빈 배열이면 반복 본문은 몇 번 실행되는가? +2. `0`은 양수에 포함되는가? +3. 음수에서는 `count`가 바뀌는가? +4. 반환 타입 `int`와 실제 반환 값의 타입이 맞는가? + +## 흔한 실수 + +### 배열 길이를 마지막 인덱스로 사용하기 + +`numbers.length`는 원소 개수이며 마지막 인덱스는 `numbers.length - 1`입니다. 순회 조건은 보통 `index < numbers.length`입니다. + +### 정수 나눗셈 뒤에 형 변환하기 + +```java +double wrong = (double) (7 / 2); +``` + +괄호 안의 정수 나눗셈이 먼저 끝납니다. 소수 부분이 필요하면 나눗셈 전에 피연산자 타입을 바꿔야 합니다. + +### 조건 경계를 말로만 확인하기 + +`>`와 `>=`는 경계값에서 결과가 달라집니다. 요구사항의 포함 여부를 확인하고 바로 아래·경계·바로 위 값을 각각 검토하세요. + +## 확인 문제 + +1. `7 / 2`와 `7.0 / 2`는 어떤 타입의 연산인가요? +2. `index < values.length`가 배열 경계를 지키는 이유를 설명해 보세요. +3. 기본 `for`문과 향상된 `for`문을 각각 선택할 상황을 한 가지씩 적어 보세요. +4. `score > 60`과 `score >= 60`은 점수 `60`에서 어떻게 다른가요? +5. 빈 배열을 `countPositive`에 전달하면 어떤 흐름으로 반환 지점에 도달하는지 추적해 보세요. + +## 공식 근거 자료 + +- [Java Language Specification SE 25 §10: 배열](https://docs.oracle.com/javase/specs/jls/se25/html/jls-10.html) +- [Java Language Specification SE 25 §14: 블록과 문장](https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html) +- [Java Language Specification SE 25 §15: 표현식과 연산자](https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html) diff --git a/content/lessons/java/review-problem-solving-and-testing.md b/content/lessons/java/review-problem-solving-and-testing.md new file mode 100644 index 0000000..fca44ab --- /dev/null +++ b/content/lessons/java/review-problem-solving-and-testing.md @@ -0,0 +1,171 @@ +# 06. 복습·문제 분해·테스트·복잡도 + +## 학습 목표 + +- 문제를 입력·출력·규칙·경계 사례로 나눌 수 있습니다. +- 각 책임을 작은 메서드와 명확한 타입 계약으로 표현할 수 있습니다. +- 정상·경계·잘못된 입력 사례를 독립적으로 확인하는 테스트를 작성할 수 있습니다. +- 반복 횟수를 기준으로 `O(1)`·`O(n)`·`O(n²)`의 차이를 설명할 수 있습니다. + +## 먼저 관찰하기 + +요구사항은 “점수 배열에서 기준 이상인 점수의 개수를 반환한다”입니다. + +```java +static int countAtLeast(int[] scores, int threshold) { + int count = 0; + + for (int score : scores) { + if (score >= threshold) { + count++; + } + } + + return count; +} +``` + +1. 입력 타입과 출력 타입은 무엇인가요? +2. 배열이 비어 있으면 어떤 흐름으로 반환 지점에 도달하나요? +3. 점수가 기준과 정확히 같을 때 포함되나요? +4. 배열 원소가 두 배가 되면 조건 비교 횟수는 대략 어떻게 변하나요? + +## 문제를 네 부분으로 나누기 + +코드를 쓰기 전에 다음을 한 문장씩 적습니다. + +1. 입력: `int[] scores`, `int threshold` +2. 출력: 기준 이상인 원소의 개수인 `int` +3. 규칙: 모든 원소를 한 번씩 확인하고 `score >= threshold`면 증가 +4. 경계: 빈 배열, 기준과 같은 값, 모두 미달, 모두 통과 + +요구사항이 모호하다면 구현 전에 확인해야 합니다. 예를 들어 `scores == null`을 빈 배열로 볼지 예외로 볼지는 언어가 자동으로 정해 주는 제품 규칙이 아닙니다. + +## 작은 메서드로 책임 나누기 + +입력 검증과 계산을 한 덩어리에 숨기지 않고 이름으로 책임을 드러낼 수 있습니다. + +```java +static int countAtLeast(int[] scores, int threshold) { + requireScores(scores); + + int count = 0; + for (int score : scores) { + if (isAtLeast(score, threshold)) { + count++; + } + } + return count; +} + +static void requireScores(int[] scores) { + if (scores == null) { + throw new IllegalArgumentException("점수 배열이 필요합니다."); + } +} + +static boolean isAtLeast(int score, int threshold) { + return score >= threshold; +} +``` + +메서드를 무조건 잘게 나누는 것이 목표는 아닙니다. 이름이 규칙을 설명하고, 독립적으로 확인할 가치가 있는 책임인지 기준으로 나누세요. + +## 외부 의존성 없는 최소 테스트 + +다음 보조 메서드는 학습용 최소 검사입니다. 전문 테스트 프레임워크를 대신하는 완전한 도구가 아니라, 예상값과 실제값을 명시적으로 비교하는 연습입니다. + +```java +static void checkEquals(int expected, int actual, String label) { + if (expected != actual) { + throw new AssertionError( + label + ": expected=" + expected + ", actual=" + actual + ); + } +} + +public static void main(String[] args) { + checkEquals(2, countAtLeast(new int[]{59, 60, 90}, 60), "일반"); + checkEquals(0, countAtLeast(new int[]{}, 60), "빈 배열"); + checkEquals(1, countAtLeast(new int[]{60}, 60), "경계값"); +} +``` + +실행 전에 각 호출의 예상값이 요구사항과 맞는지 설명하세요. 실행했다면 성공 여부만 적지 말고 사용한 JDK 버전, 명령, 실패한 사례의 실제 메시지를 기록하세요. + +테스트 사례는 서로 독립적이어야 합니다. 앞 테스트가 바꾼 상태에 다음 테스트가 의존하면 실패 원인을 좁히기 어렵습니다. + +## 복잡도는 입력 증가에 따른 작업량 + +시간 복잡도는 특정 컴퓨터에서 측정한 한 번의 밀리초가 아니라, 입력 크기 `n`이 커질 때 주요 작업 횟수가 어떻게 늘어나는지를 표현합니다. + +### O(1) + +`ArrayList.get(index)`처럼 입력 원소 수와 무관하게 일정한 단계로 위치에 접근하는 연산을 상수 시간이라고 설명할 수 있습니다. 이는 해당 구현의 API 계약을 근거로 해야 합니다. + +### O(n) + +`countAtLeast`는 배열의 각 원소를 한 번씩 확인하므로 원소 수에 비례해 비교가 늘어납니다. + +```java +for (int score : scores) { + if (score >= threshold) { + count++; + } +} +``` + +### O(n²) + +모든 원소 쌍을 비교하는 중첩 반복은 입력이 커질 때 비교 횟수가 제곱에 비례할 수 있습니다. + +```java +for (int left = 0; left < values.length; left++) { + for (int right = left + 1; right < values.length; right++) { + if (values[left] == values[right]) { + return true; + } + } +} +``` + +중첩 반복문이 항상 무조건 `O(n²)`인 것은 아닙니다. 각 반복의 범위가 입력 크기와 어떻게 연결되는지 세어야 합니다. + +`HashMap.get`과 `put`은 해시 함수가 원소를 버킷에 적절히 분산한다는 가정 아래 기본 연산이 상수 시간 성능을 제공합니다. 복잡도 설명에는 구현과 가정을 함께 적으세요. + +## 최소 구현 과제 + +두 단계로 작성하세요. + +1. `containsNegative(int[] numbers)`: 음수가 하나라도 있으면 `true` +2. 정상·빈 배열·첫 원소가 음수·마지막 원소가 음수인 테스트 + +구현 전에 다음을 결정합니다. + +- 발견 즉시 반환해도 되는가? +- 빈 배열의 결과는 무엇인가? +- 모든 원소를 확인해야 하는 최악의 경우는 언제인가? +- 시간 복잡도와 추가 공간 복잡도는 무엇인가? + +## 흔한 실수 + +- 예시 하나만 통과하고 구현이 끝났다고 판단합니다. +- 예상값을 코드 결과에 맞춰 바꾸어 요구사항 검증을 잃습니다. +- 여러 책임을 한 메서드에 넣어 실패 위치를 찾기 어렵게 만듭니다. +- 작은 입력의 한 번 측정만으로 Big-O를 결정합니다. +- 라이브러리 연산의 복잡도를 문서 확인 없이 추측합니다. + +## 최종 확인 문제 + +1. 문제를 입력·출력·규칙·경계 사례로 나누는 이유를 설명해 보세요. +2. `countAtLeast`의 빈 배열과 기준값 동일 사례가 각각 무엇을 검증하나요? +3. 테스트 사례가 서로 독립적이어야 하는 이유는 무엇인가요? +4. 한 번의 배열 순회와 모든 원소 쌍 비교의 작업량 증가를 비교해 보세요. +5. `HashMap` 기본 연산을 상수 시간이라고 설명할 때 함께 적어야 할 가정은 무엇인가요? + +## 공식 근거 자료 + +- [Java Language Specification SE 25 §14.14: for 문](https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.14) +- [Java SE 25 API: `AssertionError`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/AssertionError.html) +- [Java SE 25 API: `ArrayList` 성능 계약](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ArrayList.html) +- [Java SE 25 API: `HashMap` 성능 계약](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/HashMap.html) diff --git a/content/lessons/java/types-and-methods.md b/content/lessons/java/types-and-methods.md index d4ede9e..393de17 100644 --- a/content/lessons/java/types-and-methods.md +++ b/content/lessons/java/types-and-methods.md @@ -1,163 +1,162 @@ -# 01. 타입과 메서드 계약 +# 01. 타입·컴파일·메서드 계약 ## 학습 목표 -- Java의 타입을 기본 타입과 참조 타입으로 나누어 각 역할을 설명할 수 있습니다. -- 변수 선언에서 타입이 값의 종류와 가능한 연산을 제한한다는 뜻을 설명할 수 있습니다. -- 메서드 선언에서 매개변수 타입과 반환 타입을 찾아 입력·출력 계약으로 읽을 수 있습니다. -- 메서드가 선언한 반환 타입과 실제로 반환하는 값이 맞는지 확인할 수 있습니다. - -## 20분 학습 순서 - -1. 3분: 코드를 먼저 관찰하고 타입을 표시합니다. -2. 6분: 기본 타입과 참조 타입의 역할을 구분합니다. -3. 7분: 메서드의 매개변수·반환 타입을 계약으로 읽습니다. -4. 4분: 최소 코드를 손으로 바꾸고 확인 문제에 답합니다. - -> 이 교안은 Java 콘텐츠 구조를 확인하기 위한 읽기·추론 샘플입니다. 현재 페이지에서 Java 코드를 실행하거나 채점하지 않습니다. 실행하지 않은 결과를 맞다고 가정하지 말고, 먼저 타입 관계를 설명해 보세요. +- 기본 타입과 참조 타입이 표현하는 값의 역할을 구분할 수 있습니다. +- 변수의 선언 타입과 대입되는 값의 타입 관계를 설명할 수 있습니다. +- 메서드의 매개변수 타입과 반환 타입을 입력·출력 계약으로 읽을 수 있습니다. +- 소스 파일이 컴파일되어 클래스 파일이 되는 흐름과 컴파일 오류의 의미를 설명할 수 있습니다. ## 먼저 관찰하기 -아래 코드의 주석을 가린다고 생각하고 세 질문에 답해 보세요. +다음 코드를 실행하기 전에 타입 이름, 변수 이름, 메서드 이름에 각각 표시해 보세요. ```java -int score = 80; -boolean passed = true; -String learner = "Bam"; -int[] recentScores = {70, 80}; +public class ScoreGuide { + static String makeLabel(int score) { + return score + "점"; + } + + public static void main(String[] args) { + int currentScore = 80; + String label = makeLabel(currentScore); + System.out.println(label); + } +} ``` -1. 타입 이름은 각 줄의 어디에 있나요? -2. `int`, `boolean`, `String`, `int[]` 중 Java가 미리 정의한 기본 타입은 무엇일까요? -3. `String`과 `int[]` 변수에는 객체 자체가 들어간다고 설명해야 할까요, 객체를 가리키는 참조 값이 들어간다고 설명해야 할까요? +1. `currentScore`가 담을 수 있는 값의 종류는 어디에 적혀 있나요? +2. `makeLabel`이 받는 값과 돌려주는 값의 타입은 무엇인가요? +3. `makeLabel("80")`처럼 호출하면 어느 계약이 어긋날까요? +4. 파일 이름을 `ScoreGuide.java`로 정한 이유를 공개 클래스 이름과 연결해 설명해 보세요. -답을 바로 외우기보다 선언의 왼쪽 타입과 오른쪽 값을 짝지어 보세요. +## 소스 코드에서 실행까지 -## 기본 타입과 참조 타입 +Java 소스 파일의 확장자는 `.java`입니다. `javac`는 소스의 선언과 표현식을 읽고 타입·문법 규칙을 검사한 뒤, 성공하면 JVM에서 실행할 클래스 파일을 만듭니다. -Java의 타입은 크게 **기본 타입(primitive type)**과 **참조 타입(reference type)**으로 나뉩니다. +```text +ScoreGuide.java + ↓ javac --release 21 ScoreGuide.java +ScoreGuide.class + ↓ java ScoreGuide +JVM에서 main 메서드 실행 +``` -### 기본 타입: 값 자체를 다루는 정해진 종류 +`--release 21`은 Java 21 언어 규칙과 해당 릴리스의 공개 API를 기준으로 컴파일하도록 요청합니다. 위 명령을 아직 실행하지 않았다면 클래스 파일이 만들어졌다고 단정하지 마세요. 먼저 파일 경로와 JDK 버전을 확인하고, 컴파일러가 보여 준 실제 진단을 읽어야 합니다. -기본 타입은 Java 언어가 미리 정의합니다. +컴파일 오류는 “프로그램이 실행되다가 실패했다”는 뜻이 아닙니다. 문법, 타입, 접근 규칙처럼 실행 전에 확인할 수 있는 계약을 만족하지 못해 클래스 파일 생성 단계가 완료되지 않았다는 뜻입니다. + +## 기본 타입과 참조 타입 -- 정수 계열: `byte`, `short`, `int`, `long`, `char` -- 실수 계열: `float`, `double` -- 논리값: `boolean` +Java의 타입은 크게 기본 타입과 참조 타입으로 나뉩니다. + +- 기본 타입: `byte`, `short`, `int`, `long`, `char`, `float`, `double`, `boolean` +- 참조 타입: 클래스 타입, 인터페이스 타입, 배열 타입 ```java int level = 1; boolean completed = false; +String learner = "Bam"; +int[] recentScores = {70, 80}; ``` -`level`은 `int` 값, `completed`는 `boolean` 값을 담습니다. 타입은 변수가 가질 수 있는 값과 그 값에 허용되는 연산을 제한하여, 맞지 않는 사용을 컴파일 단계에서 찾는 데 도움을 줍니다. +`level`과 `completed`는 기본 값을 담습니다. `learner`와 `recentScores`는 객체를 가리키는 참조 값을 담습니다. `String`은 클래스 타입이고 `int[]`는 배열 타입이므로 둘 다 참조 타입입니다. -### 참조 타입: 객체를 가리키는 참조 값을 다루는 종류 +기본 타입과 참조 타입을 “작은 데이터와 큰 데이터”로 나누지 마세요. 핵심은 변수가 기본 값을 담는지, 객체를 가리키는 참조 값을 담는지입니다. 참조 타입에는 `null`이 대입될 수 있으므로 객체의 기능을 호출하기 전에 실제 객체를 가리키는지 고려해야 합니다. -클래스 타입, 인터페이스 타입, 배열 타입은 참조 타입입니다. `String`은 클래스 타입이고 `int[]`는 배열 타입이므로 둘 다 참조 타입입니다. +## 변수 선언을 계약으로 읽기 ```java -String courseName = "Java 입문"; -int[] scores = {70, 80}; +int retryCount = 2; +String courseName = "Java"; ``` -`courseName`과 `scores`에는 각각 객체를 가리키는 참조 값이 들어갑니다. 참조 타입 변수에는 `null`이 들어갈 수도 있으므로, 그 참조로 객체의 기능을 사용하기 전에 실제 객체를 가리키는지 확인해야 하는 상황이 있습니다. +선언의 왼쪽 타입은 변수가 가질 수 있는 값과 허용되는 연산을 제한합니다. + +```java +// int retryCount = "두 번"; // 선언 타입과 대입 값의 타입이 다름 +``` -기본 타입과 참조 타입의 차이를 단순히 “작은 값과 큰 값”으로 나누면 안 됩니다. 핵심은 변수가 **기본 값**을 담는지, 객체를 가리키는 **참조 값**을 담는지입니다. +오류를 만났다면 무조건 형 변환을 붙이기보다 다음을 먼저 확인하세요. -## 메서드 선언을 입력·출력 계약으로 읽기 +1. 변수의 선언 타입은 무엇인가? +2. 오른쪽 표현식이 만드는 값의 타입은 무엇인가? +3. 이 값이 선언 타입에 대입될 수 있는가? +4. 요구사항 자체가 숫자인가, 문자열인가? -다음 메서드는 점수와 통과 여부를 받아 안내 문장을 만듭니다. 아직 실행하지 말고 선언부부터 읽어 보세요. +## 메서드의 입력·출력 계약 ```java static String makeResult(int score, boolean passed) { if (passed) { return score + "점: 통과"; } - return score + "점: 재도전"; } ``` -선언부를 왼쪽부터 나누면 다음과 같습니다. - -- `static`: 이 샘플에서는 객체를 만들지 않고 호출할 수 있게 하는 변경자입니다. -- `String`: 메서드가 호출한 곳에 돌려주기로 선언한 **반환 타입**입니다. -- `makeResult`: 메서드 이름입니다. -- `int score`: `int` 값을 받는 첫 번째 **매개변수**입니다. -- `boolean passed`: `boolean` 값을 받는 두 번째 **매개변수**입니다. - -메서드를 호출할 때 전달하는 값은 인수입니다. - -```java -String message = makeResult(80, true); -``` - -여기서 `80`과 `true`는 인수이고, 각각 선언된 `int`, `boolean` 매개변수에 대응합니다. 호출 결과를 받는 `message`의 타입도 메서드의 반환 타입인 `String`과 맞습니다. +- `String`: 반환 타입 +- `makeResult`: 메서드 이름 +- `int score`, `boolean passed`: 형식 매개변수 +- `makeResult(80, true)`의 `80`, `true`: 호출 인수 -이를 작은 계약으로 읽어 보세요. +이를 다음 계약으로 읽을 수 있습니다. ```text 입력: int 값 하나, boolean 값 하나 출력: String 참조 값 하나 ``` -반환 타입이 `void`라면 결과 값을 돌려주지 않는 메서드입니다. 반환 타입이 `void`가 아니라면 메서드가 정상적으로 끝나는 경로에서는 선언한 타입과 맞는 값을 `return`해야 합니다. +반환 타입이 `void`가 아니라면 정상적으로 끝나는 실행 경로는 선언한 타입과 대입 가능한 값을 `return`해야 합니다. `void`는 결과 값을 돌려주지 않는다는 뜻입니다. -## 단계별로 타입 오류 좁히기 +## 최소 구현 -아래 코드는 반환 타입과 반환 표현식의 타입이 맞지 않습니다. +빈칸의 타입과 조건을 먼저 말로 설명한 뒤 작성해 보세요. ```java -static int makeLabel(int score) { - return score + "점"; +static boolean isPassing(int score) { + return score >= 60; } ``` -정답을 먼저 고치지 말고 다음 순서로 관찰하세요. +확인 순서: + +1. 입력 `score`는 `int`입니다. +2. `score >= 60`의 결과는 `boolean`입니다. +3. 반환 타입과 `return` 표현식의 타입이 일치합니다. +4. 경계값 `59`, `60`, `61`에서 어떤 조건이 참인지 실행 전에 예상합니다. -1. 선언된 반환 타입에 밑줄을 긋습니다: `int` -2. `return` 뒤 표현식이 만드는 값의 종류를 말합니다: 문자열 연결 결과 -3. 두 타입이 같은지 비교합니다: `int`와 `String`은 다름 -4. 요구사항을 정합니다: 점수가 붙은 문장을 돌려주려는가, 숫자만 돌려주려는가? -5. 요구사항이 문장 반환이라면 반환 타입을 `String`으로 바꿉니다. +## 흔한 실수 + +### 반환 타입만 보고 본문을 확인하지 않기 ```java -static String makeLabel(int score) { +static int makeLabel(int score) { return score + "점"; } ``` -컴파일 오류를 만났을 때 강제로 형 변환부터 시도하지 마세요. 먼저 매개변수 타입, 반환 타입, 실제 인수와 `return` 표현식의 타입을 차례로 비교하면 계약이 어긋난 지점을 좁힐 수 있습니다. +문자열 연결 결과는 `String`인데 반환 타입은 `int`입니다. 요구사항이 문장 반환이라면 반환 타입을 `String`으로 고쳐야 합니다. -## 최소 코드 연습 +### 출력과 반환을 같은 것으로 생각하기 -다음 메서드의 빈칸에 들어갈 타입을 먼저 말로 설명한 뒤 작성해 보세요. - -```java -static _____ isAdult(int age) { - return age >= 19; -} -``` +`System.out.println(value)`는 값을 출력하지만 호출자에게 결과 값을 돌려주지는 않습니다. 다른 메서드에서 결과를 이어 사용해야 한다면 반환 타입과 `return`을 설계하세요. -관찰 힌트: +### 컴파일 성공을 정답 보장으로 생각하기 -1. 매개변수 `age`는 어떤 타입인가요? -2. 비교식 `age >= 19`가 만드는 값은 숫자인가요, 참·거짓인가요? -3. 그렇다면 반환 타입은 어떤 기본 타입이어야 하나요? +컴파일러는 타입·문법 계약을 확인하지만 요구사항을 올바르게 구현했는지까지 증명하지 않습니다. 컴파일 뒤에는 경계 사례를 포함한 테스트가 필요합니다. ## 확인 문제 1. `int`, `boolean`, `String`, `int[]`를 기본 타입과 참조 타입으로 나누어 보세요. -2. 참조 타입 변수가 객체 자체가 아니라 참조 값을 담는다는 말을 자신의 표현으로 설명해 보세요. -3. `static String makeResult(int score, boolean passed)`에서 매개변수 타입 두 개와 반환 타입을 찾아보세요. -4. `makeResult("80", true)`가 선언된 입력 계약과 맞는지, 첫 번째 인수와 매개변수의 타입을 비교해 설명해 보세요. -5. `return age >= 19;`를 사용하는 메서드의 반환 타입을 고르고, 그렇게 판단한 관찰 근거를 말해 보세요. +2. 선언 `String label = makeLabel(80);`의 변수 타입, 인수 타입, 반환 타입을 각각 찾아보세요. +3. `static boolean isPassing(int score)`를 입력·출력 계약 한 문장으로 설명해 보세요. +4. 컴파일 오류와 프로그램 실행 중 발생한 오류는 어느 단계가 다른가요? +5. `--release 21`을 사용하는 이유를 언어 규칙과 공개 API 기준으로 설명해 보세요. ## 공식 근거 자료 -- [Java Language Specification SE 26 §4.1: 타입과 값의 종류](https://docs.oracle.com/javase/specs/jls/se26/html/jls-4.html#jls-4.1) -- [Java Language Specification SE 26 §4.2: 기본 타입과 값](https://docs.oracle.com/javase/specs/jls/se26/html/jls-4.html#jls-4.2) -- [Java Language Specification SE 26 §4.3: 참조 타입과 값](https://docs.oracle.com/javase/specs/jls/se26/html/jls-4.html#jls-4.3) -- [Java Language Specification SE 26 §8.4.1: 형식 매개변수](https://docs.oracle.com/javase/specs/jls/se26/html/jls-8.html#jls-8.4.1) -- [Java Language Specification SE 26 §8.4.5: 메서드 결과](https://docs.oracle.com/javase/specs/jls/se26/html/jls-8.html#jls-8.4.5) +- [Java Language Specification SE 25 §4: 타입·값·변수](https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html) +- [Java Language Specification SE 25 §8.4: 메서드 선언](https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.4) +- [Java Language Specification SE 25 §8.4.5: 메서드 결과](https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.4.5) +- [Java SE 25 `javac` 명령 문서](https://docs.oracle.com/en/java/javase/25/docs/specs/man/javac.html) diff --git a/content/quests/java.json b/content/quests/java.json new file mode 100644 index 0000000..e2e036d --- /dev/null +++ b/content/quests/java.json @@ -0,0 +1,693 @@ +{ + "schemaVersion": 1, + "contractVersion": 1, + "languageId": "java", + "title": "Java Code Quest", + "quests": [ + { + "id": "quest-java-level-label", + "slug": "java-level-label", + "revision": 1, + "order": 1, + "lessonId": "java-01-types-methods", + "conceptIds": ["java.types", "java.variables", "java.methods"], + "difficulty": "beginner", + "estimatedMinutes": 12, + "title": "레벨 이름표 만들기", + "summary": "String과 int 매개변수를 하나의 표시 문자열로 조합해 메서드의 반환 계약을 연습합니다.", + "instructions": "`public class Solution`의 `public static String formatLevelLabel(String name, int level)` 메서드를 완성하세요. 결과는 이름, 가운데점, `Lv.`와 레벨을 차례로 이어 붙인 `name · Lv.level` 형식이어야 합니다. 레벨이 0이어도 생략하지 마세요.", + "functionContract": { + "parameters": [ + { + "name": "name", + "type": "String", + "description": "길이 0 이상 20 이하인 이름입니다. 전달된 문자열을 그대로 사용합니다." + }, + { + "name": "level", + "type": "int", + "description": "0 이상 99 이하인 레벨입니다." + } + ], + "returns": { + "type": "String", + "description": "`name · Lv.level` 형식의 이름표입니다." + }, + "constraints": [ + "name은 null이 아닙니다.", + "level은 0 이상 99 이하입니다.", + "이름과 가운데점 사이, 가운데점과 Lv. 사이에 공백을 하나씩 둡니다." + ], + "complexity": { + "time": "O(n), n = name의 길이", + "space": "O(n), n = 반환 문자열의 길이" + } + }, + "entryPoint": "formatLevelLabel", + "starterCode": "public class Solution {\n public static String formatLevelLabel(String name, int level) {\n // name과 level을 요구된 순서와 구분 기호로 연결하세요.\n return name;\n }\n}\n", + "examples": [ + { + "args": ["Ada", 3], + "expected": "Ada · Lv.3", + "explanation": "이름 뒤에 가운데점과 Lv.3을 공백과 함께 붙입니다." + } + ], + "publicTests": [ + { + "id": "java-level-zero", + "label": "레벨이 0인 이름표", + "args": ["Ada", 0], + "expected": "Ada · Lv.0" + }, + { + "id": "java-level-one", + "label": "한글 이름과 레벨 1", + "args": ["민수", 1], + "expected": "민수 · Lv.1" + }, + { + "id": "java-level-two-digits", + "label": "두 자리 레벨", + "args": ["Kai", 12], + "expected": "Kai · Lv.12" + }, + { + "id": "java-level-empty-name", + "label": "빈 이름 문자열", + "args": ["", 7], + "expected": " · Lv.7" + }, + { + "id": "java-level-example", + "label": "입출력 예의 일반 이름표", + "args": ["Ada", 3], + "expected": "Ada · Lv.3" + }, + { + "id": "java-level-maximum", + "label": "최대 레벨", + "args": ["Zoe", 99], + "expected": "Zoe · Lv.99" + } + ], + "failureExplanations": [ + { + "testId": "java-level-zero", + "message": "0도 유효한 int 값입니다. level이 0일 때도 `Lv.0`을 결과에 포함하세요." + }, + { + "testId": "java-level-one", + "message": "name을 변경하거나 영문으로 가정하지 말고 전달된 문자열을 그대로 연결하세요." + }, + { + "testId": "java-level-two-digits", + "message": "level을 한 글자로 처리하지 말고 int 전체를 문자열 연결에 사용하세요." + }, + { + "testId": "java-level-empty-name", + "message": "name이 빈 문자열이어도 구분 기호와 레벨 부분은 정해진 형식으로 남아야 합니다." + }, + { + "testId": "java-level-example", + "message": "이름, 공백, 가운데점, 공백, `Lv.`와 숫자의 순서를 다시 확인하세요." + }, + { + "testId": "java-level-maximum", + "message": "최대 레벨 99도 잘리지 않고 결과 문자열에 모두 포함되어야 합니다." + } + ], + "hints": [ + { + "level": 1, + "stage": "concept", + "title": "문자열 연결의 결과 타입", + "content": "Java에서 String과 int를 `+`로 연결하면 int가 문자열 표현으로 바뀌고 전체 결과는 String이 됩니다." + }, + { + "level": 2, + "stage": "observation", + "title": "고정 부분과 바뀌는 부분 나누기", + "content": "` · Lv.`는 항상 같고 name과 level만 호출마다 달라집니다. 예시 결과에서 각 조각의 경계를 표시해 보세요." + }, + { + "level": 3, + "stage": "implementation", + "title": "왼쪽부터 차례로 연결하기", + "content": "`name + \" · Lv.\" + level`처럼 String 조각을 먼저 두고 왼쪽부터 연결하면 덧셈과 혼동하지 않을 수 있습니다." + } + ], + "commonMistakes": [ + { + "id": "java-omit-zero-level", + "title": "레벨 0을 없는 값처럼 생략하기", + "explanation": "int의 0은 유효한 레벨입니다. 조건으로 0을 제외하면 이름표의 반환 형식이 입력에 따라 달라집니다." + } + ] + }, + { + "id": "quest-java-count-at-least", + "slug": "java-count-at-least", + "revision": 1, + "order": 2, + "lessonId": "java-02-control-flow-arrays", + "conceptIds": ["java.operators", "java.control-flow", "java.arrays"], + "difficulty": "beginner", + "estimatedMinutes": 18, + "title": "기준 이상 점수 세기", + "summary": "int 배열을 순회하며 경계값을 포함하는 비교 조건으로 개수를 누적합니다.", + "instructions": "`public class Solution`의 `public static int countAtLeast(int[] scores, int minimum)` 메서드를 완성하세요. scores에서 minimum 이상인 값의 개수를 반환하세요. minimum과 같은 값도 포함해야 하며 빈 배열이면 0을 반환합니다.", + "functionContract": { + "parameters": [ + { + "name": "scores", + "type": "int[]", + "description": "길이 0 이상 100 이하인 정수 점수 배열입니다." + }, + { + "name": "minimum", + "type": "int", + "description": "포함 여부를 판단할 기준 정수입니다." + } + ], + "returns": { + "type": "int", + "description": "minimum 이상인 배열 원소의 개수입니다." + }, + "constraints": [ + "scores는 null이 아니며 길이는 0 이상 100 이하입니다.", + "scores의 각 값과 minimum은 -1,000 이상 1,000 이하입니다.", + "minimum과 같은 값은 결과 개수에 포함합니다." + ], + "complexity": { + "time": "O(n), n = scores의 길이", + "space": "O(1)" + } + }, + "entryPoint": "countAtLeast", + "starterCode": "public class Solution {\n public static int countAtLeast(int[] scores, int minimum) {\n int count = 0;\n // 모든 점수를 순회하며 minimum 이상인 값을 세세요.\n return count;\n }\n}\n", + "examples": [ + { + "args": [[70, 85, 85, 40], 85], + "expected": 2, + "explanation": "85와 같은 두 값이 모두 기준 이상입니다." + } + ], + "publicTests": [ + { + "id": "java-at-least-empty", + "label": "빈 점수 배열", + "args": [[], 50], + "expected": 0 + }, + { + "id": "java-at-least-below", + "label": "모든 점수가 기준 미만", + "args": [[10, 20, 30], 40], + "expected": 0 + }, + { + "id": "java-at-least-equal-boundary", + "label": "기준과 같은 점수가 반복", + "args": [[70, 85, 85, 40], 85], + "expected": 2 + }, + { + "id": "java-at-least-mixed", + "label": "기준 미만·같음·초과가 섞인 배열", + "args": [[-2, 0, 3, 4, 9], 3], + "expected": 3 + }, + { + "id": "java-at-least-all", + "label": "모든 점수가 기준 이상", + "args": [[5, 6, 7], 5], + "expected": 3 + }, + { + "id": "java-at-least-negative-threshold", + "label": "음수 기준의 경계값", + "args": [[-1000, -5, -4, 0], -5], + "expected": 3 + } + ], + "failureExplanations": [ + { + "testId": "java-at-least-empty", + "message": "빈 배열에서는 반복문이 실행되지 않으므로 초기 개수 0을 그대로 반환해야 합니다." + }, + { + "testId": "java-at-least-below", + "message": "기준보다 작은 값은 count를 늘리지 않아야 합니다." + }, + { + "testId": "java-at-least-equal-boundary", + "message": "`이상`은 같은 값도 포함합니다. `>`가 아니라 `>=` 비교가 필요합니다." + }, + { + "testId": "java-at-least-mixed", + "message": "조건을 만족한 모든 원소를 끝까지 순회하며 하나씩 누적했는지 확인하세요." + }, + { + "testId": "java-at-least-all", + "message": "첫 번째 일치에서 반환하지 말고 배열 전체의 일치 개수를 세어야 합니다." + }, + { + "testId": "java-at-least-negative-threshold", + "message": "음수에서도 대소 비교 규칙은 같습니다. -5와 같은 값도 포함하세요." + } + ], + "hints": [ + { + "level": 1, + "stage": "concept", + "title": "배열 순회와 누적", + "content": "결과 개수는 0에서 시작하고 조건을 만족하는 원소를 만날 때마다 1씩 증가합니다." + }, + { + "level": 2, + "stage": "observation", + "title": "경계값 직접 비교하기", + "content": "minimum이 85일 때 84, 85, 86이 각각 포함되는지 표로 적으면 필요한 비교 연산자가 드러납니다." + }, + { + "level": 3, + "stage": "implementation", + "title": "향상된 for문 사용하기", + "content": "`for (int score : scores)`로 각 값을 읽고 `score >= minimum`일 때 count를 증가시켜 보세요." + } + ], + "commonMistakes": [ + { + "id": "java-strict-threshold", + "title": "기준보다 큰 값만 세기", + "explanation": "`>`를 사용하면 minimum과 정확히 같은 경계값이 빠집니다. 문제의 `이상` 조건은 `>=`에 해당합니다." + } + ] + }, + { + "id": "quest-java-remaining-seats", + "slug": "java-remaining-seats", + "revision": 1, + "order": 3, + "lessonId": "java-03-classes-objects", + "conceptIds": ["java.classes", "java.objects", "java.constructors", "java.encapsulation"], + "difficulty": "beginner", + "estimatedMinutes": 22, + "title": "좌석 객체의 남은 수 계산하기", + "summary": "생성자로 객체 상태를 초기화하고 캡슐화된 필드를 인스턴스 메서드에서 계산합니다.", + "instructions": "`public class Solution`의 `public static int calculateRemainingSeats(int capacity, int reserved)`가 전체 좌석에서 예약 좌석을 뺀 값을 반환하도록 완성하세요. 제공된 `SeatCounter` 생성자와 `remaining()` 메서드를 완성하는 방식은 클래스·캡슐화를 연습하기 위한 권장 구현입니다. 스스로 필드를 private로 유지하고 `this`로 초기화했는지 확인하세요. 자동 채점은 공개 진입점의 반환값만 검사하며 내부 클래스 구조는 판정하지 않습니다.", + "functionContract": { + "parameters": [ + { + "name": "capacity", + "type": "int", + "description": "1 이상 10,000 이하인 전체 좌석 수입니다." + }, + { + "name": "reserved", + "type": "int", + "description": "0 이상 capacity 이하인 예약 좌석 수입니다." + } + ], + "returns": { + "type": "int", + "description": "capacity - reserved로 계산한 남은 좌석 수입니다." + }, + "constraints": [ + "capacity는 1 이상 10,000 이하입니다.", + "reserved는 0 이상 capacity 이하입니다.", + "SeatCounter의 두 필드를 private로 유지하는 것은 권장 구현의 스스로 확인 항목이며 자동 채점은 반환값만 검사합니다." + ], + "complexity": { + "time": "O(1)", + "space": "O(1)" + } + }, + "entryPoint": "calculateRemainingSeats", + "starterCode": "public class Solution {\n private static class SeatCounter {\n private int capacity;\n private int reserved;\n\n SeatCounter(int capacity, int reserved) {\n // 매개변수를 같은 이름의 필드에 저장하세요.\n this.capacity = 0;\n this.reserved = 0;\n }\n\n int remaining() {\n return 0;\n }\n }\n\n public static int calculateRemainingSeats(int capacity, int reserved) {\n SeatCounter counter = new SeatCounter(capacity, reserved);\n return counter.remaining();\n }\n}\n", + "examples": [ + { + "args": [30, 12], + "expected": 18, + "explanation": "SeatCounter 객체가 capacity 30과 reserved 12를 저장하고 30 - 12를 반환합니다." + } + ], + "publicTests": [ + { + "id": "java-seats-none-reserved", + "label": "예약 좌석이 없는 경우", + "args": [10, 0], + "expected": 10 + }, + { + "id": "java-seats-partial", + "label": "일부 좌석이 예약된 경우", + "args": [30, 12], + "expected": 18 + }, + { + "id": "java-seats-full", + "label": "모든 좌석이 예약된 경우", + "args": [20, 20], + "expected": 0 + }, + { + "id": "java-seats-single-open", + "label": "좌석 하나가 비어 있는 경우", + "args": [1, 0], + "expected": 1 + }, + { + "id": "java-seats-maximum", + "label": "최대 전체 좌석에서 하나가 남은 경우", + "args": [10000, 9999], + "expected": 1 + } + ], + "failureExplanations": [ + { + "testId": "java-seats-none-reserved", + "message": "reserved가 0이면 capacity 전체가 남습니다. 생성자가 입력 capacity를 필드에 저장했는지 확인하세요." + }, + { + "testId": "java-seats-partial", + "message": "remaining()은 reserved - capacity가 아니라 capacity - reserved를 반환해야 합니다." + }, + { + "testId": "java-seats-full", + "message": "두 필드가 같을 때 남은 좌석은 0입니다. 별도의 최소 1 보정은 필요하지 않습니다." + }, + { + "testId": "java-seats-single-open", + "message": "가장 작은 capacity에서도 생성자에 전달된 1과 0을 그대로 보존해야 합니다." + }, + { + "testId": "java-seats-maximum", + "message": "큰 입력을 고정값으로 바꾸지 말고 객체 필드에 전달된 값을 그대로 계산하세요." + } + ], + "hints": [ + { + "level": 1, + "stage": "concept", + "title": "객체마다 갖는 상태", + "content": "SeatCounter 객체는 capacity와 reserved라는 두 필드에 생성 시점의 상태를 저장합니다." + }, + { + "level": 2, + "stage": "observation", + "title": "같은 이름 구분하기", + "content": "생성자의 `capacity`는 매개변수이고 `this.capacity`는 지금 만드는 객체의 필드입니다. 두 이름이 같을 때 this가 구분 기준입니다." + }, + { + "level": 3, + "stage": "implementation", + "title": "생성 후 인스턴스 메서드 호출하기", + "content": "생성자에서 `this.capacity = capacity`와 같이 저장한 뒤 remaining()에서 두 필드의 차를 반환하세요." + } + ], + "commonMistakes": [ + { + "id": "java-shadowed-constructor-fields", + "title": "생성자 매개변수를 자기 자신에게 대입하기", + "explanation": "`capacity = capacity`는 필드를 바꾸지 않습니다. 왼쪽에 `this.capacity`를 사용해야 새 객체의 필드가 초기화됩니다." + } + ] + }, + { + "id": "quest-java-distinct-names", + "slug": "java-distinct-names", + "revision": 2, + "order": 4, + "lessonId": "java-04-collections-generics", + "conceptIds": ["java.collections", "java.generics", "java.list"], + "difficulty": "beginner", + "estimatedMinutes": 24, + "title": "처음 등장한 이름만 남기기", + "summary": "제네릭 컬렉션으로 중복을 확인하면서 최초 등장 순서를 보존한 String 배열을 만듭니다.", + "instructions": "`public class Solution`의 `public static String[] distinctNames(String[] names)` 메서드를 완성하세요. 같은 문자열이 여러 번 나오면 처음 등장한 한 번만 남기고, 처음 등장한 순서를 유지한 새 String[]을 반환하세요. 대소문자가 다른 문자열은 서로 다른 이름입니다.", + "functionContract": { + "parameters": [ + { + "name": "names", + "type": "String[]", + "description": "길이 0 이상 100 이하이며 null 원소가 없는 이름 배열입니다." + } + ], + "returns": { + "type": "String[]", + "description": "각 이름을 최초 등장 순서로 한 번씩 담은 새 배열입니다." + }, + "constraints": [ + "names와 각 원소는 null이 아닙니다.", + "문자열 비교는 대소문자를 구분합니다.", + "중복 제거를 위해 결과의 순서를 정렬하면 안 됩니다." + ], + "complexity": { + "time": "O(n² + n·c) 최악, n = names의 길이, c = 모든 이름 길이의 합 (List.contains의 선형 탐색과 String 비교 비용 포함)", + "space": "O(n) 추가 공간, n = names의 길이" + } + }, + "entryPoint": "distinctNames", + "starterCode": "import java.util.ArrayList;\nimport java.util.List;\n\npublic class Solution {\n public static String[] distinctNames(String[] names) {\n List result = new ArrayList<>();\n // result에 아직 없는 이름만 추가하세요.\n return result.toArray(new String[0]);\n }\n}\n", + "examples": [ + { + "args": [["Mina", "Joon", "Mina", "Ara"]], + "expected": ["Mina", "Joon", "Ara"], + "explanation": "두 번째 Mina만 제거하고 나머지 이름의 최초 등장 순서를 유지합니다." + } + ], + "publicTests": [ + { + "id": "java-names-empty", + "label": "빈 이름 배열", + "args": [[]], + "expected": [] + }, + { + "id": "java-names-unique-order", + "label": "이미 순서대로 고유한 이름", + "args": [["Ara", "Bora", "Mina"]], + "expected": ["Ara", "Bora", "Mina"] + }, + { + "id": "java-names-consecutive-duplicate", + "label": "연속 중복 이름", + "args": [["Ana", "Ana", "Bo"]], + "expected": ["Ana", "Bo"] + }, + { + "id": "java-names-separated-repeat", + "label": "떨어져 다시 등장한 이름", + "args": [["Mina", "Joon", "Mina", "Ara"]], + "expected": ["Mina", "Joon", "Ara"] + }, + { + "id": "java-names-case-sensitive", + "label": "대소문자가 다른 이름", + "args": [["java", "Java", "java"]], + "expected": ["java", "Java"] + }, + { + "id": "java-names-many-repeats", + "label": "순서가 섞인 여러 중복", + "args": [["z", "a", "z", "m", "a", "b"]], + "expected": ["z", "a", "m", "b"] + } + ], + "failureExplanations": [ + { + "testId": "java-names-empty", + "message": "입력에 이름이 없으면 길이 0인 String[]을 반환해야 합니다." + }, + { + "testId": "java-names-unique-order", + "message": "중복이 없으면 모든 이름을 원래 순서 그대로 반환해야 합니다." + }, + { + "testId": "java-names-consecutive-duplicate", + "message": "result.contains(name)이 true인 이름은 result에 다시 추가하지 마세요." + }, + { + "testId": "java-names-separated-repeat", + "message": "직전 이름만 비교하지 말고 앞에서 한 번이라도 본 모든 이름을 기억해야 합니다." + }, + { + "testId": "java-names-case-sensitive", + "message": "문자열을 소문자로 바꾸지 마세요. Java의 String equals는 대소문자를 구분합니다." + }, + { + "testId": "java-names-many-repeats", + "message": "중복을 제거한 뒤 결과를 정렬하지 말고 입력에서 처음 등장한 순서를 List에 그대로 기록하세요." + } + ], + "hints": [ + { + "level": 1, + "stage": "concept", + "title": "List에서 같은 값 찾기", + "content": "List의 contains는 같은 값이 이미 들어 있는지 boolean으로 알려 줍니다. 결과 List 자체를 앞에서 본 이름의 기록으로 사용할 수 있습니다." + }, + { + "level": 2, + "stage": "observation", + "title": "추가 전 contains 결과 관찰하기", + "content": "result.contains(name)이 false라면 아직 남기지 않은 이름입니다. String 비교는 대소문자를 구분하므로 java와 Java는 서로 다릅니다." + }, + { + "level": 3, + "stage": "implementation", + "title": "처음 본 순간에만 List에 추가하기", + "content": "names를 순회하면서 `!result.contains(name)`일 때만 result.add(name)을 실행하고 마지막에 배열로 바꾸세요." + } + ], + "commonMistakes": [ + { + "id": "java-sort-instead-of-preserve", + "title": "결과 List를 정렬해 처음 등장 순서를 바꾸기", + "explanation": "중복 제거와 정렬은 다른 작업입니다. 고유한 이름을 모은 List를 다시 정렬하면 최초 등장 순서 계약을 잃습니다." + } + ] + }, + { + "id": "quest-java-parse-fallback", + "slug": "java-parse-fallback", + "revision": 1, + "order": 5, + "lessonId": "java-05-exceptions-debugging", + "conceptIds": ["java.exceptions", "java.debugging"], + "difficulty": "beginner", + "estimatedMinutes": 18, + "title": "정수 변환 실패에 기본값 사용하기", + "summary": "문자열을 int로 변환하고 NumberFormatException이 발생한 경로만 기본값으로 복구합니다.", + "instructions": "`public class Solution`의 `public static int parseOrFallback(String value, int fallback)` 메서드를 완성하세요. value가 Java의 유효한 int 문자열이면 Integer.parseInt의 결과를 반환하고, 빈 문자열·숫자가 아닌 문자·int 범위 초과 등으로 NumberFormatException이 발생하면 fallback을 반환하세요.", + "functionContract": { + "parameters": [ + { + "name": "value", + "type": "String", + "description": "길이 0 이상 30 이하인 변환 대상 문자열입니다." + }, + { + "name": "fallback", + "type": "int", + "description": "정수 변환에 실패했을 때 반환할 값입니다." + } + ], + "returns": { + "type": "int", + "description": "변환된 int 또는 변환 실패 시 fallback입니다." + }, + "constraints": [ + "value는 null이 아닙니다.", + "공백을 임의로 제거하지 말고 Integer.parseInt(value)의 계약을 따릅니다.", + "NumberFormatException만 변환 실패로 처리합니다." + ], + "complexity": { + "time": "O(n), n = value의 길이", + "space": "O(1)" + } + }, + "entryPoint": "parseOrFallback", + "starterCode": "public class Solution {\n public static int parseOrFallback(String value, int fallback) {\n // Integer.parseInt(value)를 시도하고 NumberFormatException을 처리하세요.\n return fallback;\n }\n}\n", + "examples": [ + { + "args": ["42", -1], + "expected": 42, + "explanation": "42는 int 범위의 유효한 정수 문자열이므로 변환값을 반환합니다." + } + ], + "publicTests": [ + { + "id": "java-parse-positive", + "label": "양의 정수 문자열", + "args": ["42", -1], + "expected": 42 + }, + { + "id": "java-parse-negative", + "label": "음의 정수 문자열", + "args": ["-17", 9], + "expected": -17 + }, + { + "id": "java-parse-plus-sign", + "label": "더하기 부호가 있는 정수", + "args": ["+8", 0], + "expected": 8 + }, + { + "id": "java-parse-empty", + "label": "빈 문자열", + "args": ["", 5], + "expected": 5 + }, + { + "id": "java-parse-invalid-character", + "label": "숫자 아닌 문자가 섞인 문자열", + "args": ["12x", 7], + "expected": 7 + }, + { + "id": "java-parse-overflow", + "label": "int 최댓값을 넘는 문자열", + "args": ["2147483648", -3], + "expected": -3 + } + ], + "failureExplanations": [ + { + "testId": "java-parse-positive", + "message": "변환에 성공하면 fallback이 아니라 Integer.parseInt가 만든 값을 반환해야 합니다." + }, + { + "testId": "java-parse-negative", + "message": "앞의 빼기 부호는 유효한 정수 표현입니다. 직접 문자만 검사하기보다 parseInt의 결과를 사용하세요." + }, + { + "testId": "java-parse-plus-sign", + "message": "Integer.parseInt는 앞의 더하기 부호도 허용합니다. 변환을 먼저 시도하세요." + }, + { + "testId": "java-parse-empty", + "message": "빈 문자열에서 발생하는 NumberFormatException을 잡아 fallback을 반환해야 합니다." + }, + { + "testId": "java-parse-invalid-character", + "message": "일부 숫자만 읽지 말고 문자열 전체가 int로 변환되지 않으면 fallback을 반환하세요." + }, + { + "testId": "java-parse-overflow", + "message": "숫자 문자로만 이루어져도 int 범위를 넘으면 NumberFormatException이 발생합니다. 같은 실패 경로로 처리하세요." + } + ], + "hints": [ + { + "level": 1, + "stage": "concept", + "title": "정상 경로와 예외 경로", + "content": "try 블록에는 성공할 때 필요한 변환을 두고, catch 블록에는 변환할 수 없을 때의 복구값을 둡니다." + }, + { + "level": 2, + "stage": "observation", + "title": "실패 종류를 하나씩 관찰하기", + "content": "빈 문자열, 문자가 섞인 문자열, int 범위를 넘는 문자열은 원인은 달라도 모두 NumberFormatException으로 관찰됩니다." + }, + { + "level": 3, + "stage": "implementation", + "title": "필요한 예외만 잡기", + "content": "try에서 `return Integer.parseInt(value);`를 실행하고 `catch (NumberFormatException error)`에서 fallback을 반환하세요." + } + ], + "commonMistakes": [ + { + "id": "java-reject-signed-integers", + "title": "숫자 문자만 허용해 부호 있는 정수를 거부하기", + "explanation": "직접 `0`부터 `9`까지만 검사하면 `-17`과 `+8`도 실패로 처리됩니다. parseInt의 정수 문법을 그대로 활용하세요." + } + ] + } + ] +} diff --git a/content/quizzes/java.json b/content/quizzes/java.json index d6b94f7..54d9dd6 100644 --- a/content/quizzes/java.json +++ b/content/quizzes/java.json @@ -1,39 +1,412 @@ { "schemaVersion": 1, "languageId": "java", - "title": "Java 샘플 객관식 복습", + "title": "Java 객관식 복습", "questions": [ + { + "id": "quiz-java-type-kinds", + "lessonId": "java-01-types-methods", + "conceptId": "java.types", + "difficulty": "basic", + "prompt": "다음 지역 변수 선언에서 기본 타입 변수와 참조 타입 변수를 정확히 분류한 것은 무엇인가요?", + "code": "int score = 80;\nboolean passed = true;\nString learner = \"Bam\";\nint[] recentScores = {70, 80};", + "options": [ + { + "id": "a", + "text": "기본 타입: int, boolean, String / 참조 타입: int[]", + "isCorrect": false, + "feedback": "틀립니다. String은 Java가 미리 정의한 기본 타입 키워드가 아니라 클래스 타입이므로 참조 타입입니다." + }, + { + "id": "b", + "text": "기본 타입: int / 참조 타입: boolean, String, int[]", + "isCorrect": false, + "feedback": "틀립니다. boolean은 true와 false를 표현하는 Java의 기본 타입입니다. String과 배열 타입인 int[]만 참조 타입입니다." + }, + { + "id": "c", + "text": "기본 타입: int, boolean / 참조 타입: String, int[]", + "isCorrect": true, + "feedback": "맞습니다. int와 boolean은 기본 타입이고, 클래스 타입인 String과 배열 타입인 int[]는 객체를 가리키는 참조 값을 다루는 참조 타입입니다." + }, + { + "id": "d", + "text": "네 변수의 타입은 모두 참조 타입이다.", + "isCorrect": false, + "feedback": "틀립니다. String과 int[]는 참조 타입이지만 int와 boolean은 기본 타입입니다." + } + ] + }, { "id": "quiz-java-method-return", "lessonId": "java-01-types-methods", "conceptId": "java.methods", "difficulty": "application", - "prompt": "다음 메서드가 점수에 `점`을 붙인 문자열을 반환하도록 본문은 그대로 둘 때, 선언된 입력·출력 타입 계약을 맞추는 수정은 무엇인가요?", + "prompt": "다음 메서드가 점수에 '점'을 붙인 문자열을 반환하도록 본문은 그대로 둘 때, 선언된 입력·출력 타입 계약을 맞추는 수정은 무엇인가요?", "code": "static int makeLabel(int score) {\n return score + \"점\";\n}", "options": [ { "id": "a", - "text": "반환 타입만 `String`으로 바꿔 `static String makeLabel(int score)`로 선언한다.", + "text": "반환 타입만 String으로 바꿔 static String makeLabel(int score)로 선언한다.", + "isCorrect": true, + "feedback": "맞습니다. score + \"점\"은 문자열 연결 결과인 String을 만들고, 입력 점수는 계속 int로 받으므로 반환 타입만 String으로 바꾸면 선언과 본문이 같은 계약을 표현합니다." + }, + { + "id": "b", + "text": "매개변수 타입만 String으로 바꿔 static int makeLabel(String score)로 선언한다.", + "isCorrect": false, + "feedback": "틀립니다. 매개변수를 String으로 바꿔도 score + \"점\"의 결과는 여전히 String인데 선언된 반환 타입은 int이므로 출력 계약의 불일치가 남습니다." + }, + { + "id": "c", + "text": "반환 타입을 void로 바꿔 static void makeLabel(int score)로 선언한다.", + "isCorrect": false, + "feedback": "틀립니다. void는 결과 값을 반환하지 않는 메서드를 나타내므로 return score + \"점\";처럼 값을 돌려주는 현재 본문과 맞지 않습니다." + }, + { + "id": "d", + "text": "반환 타입을 boolean으로 바꿔 static boolean makeLabel(int score)로 선언한다.", + "isCorrect": false, + "feedback": "틀립니다. boolean은 true 또는 false를 나타내지만 현재 반환 표현식은 String을 만듭니다. 반환 타입은 실제 반환 값의 타입과 맞아야 합니다." + } + ] + }, + { + "id": "quiz-java-control-flow-branch", + "lessonId": "java-02-control-flow-arrays", + "conceptId": "java.control-flow", + "difficulty": "basic", + "prompt": "다음 Java 프로그램이 출력하는 문자열은 무엇인가요?", + "code": "class Main {\n public static void main(String[] args) {\n int score = 59;\n boolean submitted = true;\n\n if (score >= 60 && submitted) {\n System.out.println(\"통과\");\n } else {\n System.out.println(\"재도전\");\n }\n }\n}", + "options": [ + { + "id": "a", + "text": "통과", + "isCorrect": false, + "feedback": "틀립니다. submitted는 true이지만 score >= 60이 false입니다. &&로 연결된 두 조건이 모두 참이어야 첫 번째 분기가 실행됩니다." + }, + { + "id": "b", + "text": "true", + "isCorrect": false, + "feedback": "틀립니다. 조건식은 boolean으로 평가되지만 프로그램은 조건값 자체를 출력하지 않습니다. 선택된 분기 안의 문자열을 출력합니다." + }, + { + "id": "c", + "text": "아무것도 출력하지 않는다.", + "isCorrect": false, + "feedback": "틀립니다. 왼쪽 조건이 거짓이면 &&의 오른쪽 평가는 생략되지만 if 문 전체가 끝나는 것은 아닙니다. 조건이 거짓이므로 else 블록이 실행됩니다." + }, + { + "id": "d", + "text": "재도전", + "isCorrect": true, + "feedback": "맞습니다. 59 >= 60이 거짓이므로 전체 조건이 거짓이 되고 else 블록이 재도전을 출력합니다." + } + ] + }, + { + "id": "quiz-java-array-loop-bound", + "lessonId": "java-02-control-flow-arrays", + "conceptId": "java.arrays", + "difficulty": "application", + "prompt": "다음 프로그램이 예외 없이 모든 배열 요소의 합 240을 출력하게 하는 가장 작은 올바른 수정은 무엇인가요?", + "code": "class Main {\n public static void main(String[] args) {\n int[] scores = {70, 80, 90};\n int total = 0;\n\n for (int i = 0; i <= scores.length; i++) {\n total += scores[i];\n }\n\n System.out.println(total);\n }\n}", + "options": [ + { + "id": "a", + "text": "반복문의 시작값을 int i = 1로 바꾼다.", + "isCorrect": false, + "feedback": "틀립니다. 첫 번째 요소를 합산하지 못할 뿐 아니라 조건이 그대로이므로 결국 i == 3에서 존재하지 않는 scores[3]에 접근합니다." + }, + { + "id": "b", + "text": "반복 조건의 i <= scores.length를 i < scores.length로 바꾼다.", "isCorrect": true, - "feedback": "맞습니다. `score + \"점\"`은 문자열 연결 결과인 `String`을 만들고, 입력 점수는 계속 `int`로 받으므로 반환 타입만 `String`으로 바꾸면 선언과 본문이 같은 계약을 표현합니다." + "feedback": "맞습니다. 길이가 3인 배열의 유효한 인덱스는 0, 1, 2입니다. i < scores.length는 세 요소를 정확히 한 번씩 방문하고 i == 3인 접근을 막습니다." + }, + { + "id": "c", + "text": "scores[i]를 scores[1]로 바꾼다.", + "isCorrect": false, + "feedback": "틀립니다. 범위를 벗어난 접근은 피하지만 값 80만 네 번 더해 320이 됩니다. 모든 요소의 합을 구한다는 요구사항을 충족하지 않습니다." + }, + { + "id": "d", + "text": "반복 조건의 scores.length를 scores.length + 1로 바꾼다.", + "isCorrect": false, + "feedback": "틀립니다. 반복 범위를 더 늘리므로 i == 3에서 이미 배열 범위를 벗어나며, 이후 인덱스도 유효하지 않습니다." + } + ] + }, + { + "id": "quiz-java-constructor-field-initialization", + "lessonId": "java-03-classes-objects", + "conceptId": "java.constructors", + "difficulty": "basic", + "prompt": "다음 프로그램이 Java를 출력하는 이유를 가장 정확히 설명한 것은 무엇인가요?", + "code": "class Course {\n private String title;\n\n Course(String title) {\n this.title = title;\n }\n\n String title() {\n return title;\n }\n}\n\nclass Main {\n public static void main(String[] args) {\n Course course = new Course(\"Java\");\n System.out.println(course.title());\n }\n}", + "options": [ + { + "id": "a", + "text": "new Course(\"Java\")가 생성자를 호출하고, this.title = title이 인수로 받은 값을 새 객체의 필드에 대입하기 때문이다.", + "isCorrect": true, + "feedback": "맞습니다. 생성자의 매개변수 title은 인수 \"Java\"를 받고, this.title은 지금 생성 중인 객체의 필드를 가리킵니다." + }, + { + "id": "b", + "text": "생성자가 문자열 Java를 반환하고 그 반환값이 course에 저장되기 때문이다.", + "isCorrect": false, + "feedback": "틀립니다. 생성자는 메서드처럼 반환 타입을 선언하거나 문자열을 반환하지 않습니다. new 표현식은 생성자를 사용해 만든 Course 객체의 참조를 결과로 냅니다." + }, + { + "id": "c", + "text": "this.title = title이 호출 코드의 문자열 인수 자체를 새 값으로 변경하기 때문이다.", + "isCorrect": false, + "feedback": "틀립니다. 대입문은 생성자 매개변수의 참조 값을 객체 필드에 저장합니다. 호출 코드의 문자열 객체를 변경하는 동작이 아닙니다." + }, + { + "id": "d", + "text": "private 필드는 자동으로 클래스 이름과 같은 문자열로 초기화되기 때문이다.", + "isCorrect": false, + "feedback": "틀립니다. 초기화하지 않은 String 인스턴스 필드의 기본값은 null입니다. 이 코드에서 Java가 되는 이유는 생성자 대입문이 실행되기 때문입니다." + } + ] + }, + { + "id": "quiz-java-private-balance-update", + "lessonId": "java-03-classes-objects", + "conceptId": "java.encapsulation", + "difficulty": "application", + "prompt": "주석 위치에 한 줄을 넣어 100을 출력하면서 Account가 정한 양수 입금 규칙과 접근 제한을 지키려면 무엇을 작성해야 하나요?", + "code": "class Account {\n private int balance = 0;\n\n public void deposit(int amount) {\n if (amount > 0) {\n balance += amount;\n }\n }\n\n public int getBalance() {\n return balance;\n }\n}\n\nclass Main {\n public static void main(String[] args) {\n Account account = new Account();\n // 이 자리에 한 줄을 작성합니다.\n System.out.println(account.getBalance());\n }\n}", + "options": [ + { + "id": "a", + "text": "account.balance = 100;", + "isCorrect": false, + "feedback": "틀립니다. balance는 private이므로 Account 클래스 본문 밖의 Main에서 직접 접근할 수 없습니다. 또한 직접 대입하면 deposit()의 검증 규칙을 우회합니다." }, { "id": "b", - "text": "매개변수 타입만 `String`으로 바꿔 `static int makeLabel(String score)`로 선언한다.", + "text": "account.getBalance() = 100;", "isCorrect": false, - "feedback": "틀립니다. 매개변수를 `String`으로 바꿔도 `score + \"점\"`의 결과는 여전히 `String`인데 선언된 반환 타입은 `int`이므로 출력 계약의 불일치가 남습니다." + "feedback": "틀립니다. 메서드 호출 결과는 대입할 수 있는 필드가 아닙니다. getBalance()는 현재 잔액 값을 읽어 반환할 뿐 상태를 변경하지 않습니다." + }, + { + "id": "c", + "text": "account.deposit(100);", + "isCorrect": true, + "feedback": "맞습니다. 공개된 deposit() 메서드가 양수인지 확인한 뒤 객체 내부의 private 필드를 변경하므로 클래스가 정한 규칙을 유지합니다." + }, + { + "id": "d", + "text": "account.deposit(-100);", + "isCorrect": false, + "feedback": "틀립니다. deposit()은 양수인 금액만 더하므로 -100은 잔액을 바꾸지 않습니다. 따라서 getBalance()는 100이 아니라 기존 값 0을 반환합니다." + } + ] + }, + { + "id": "quiz-java-generic-list-element", + "lessonId": "java-04-collections-generics", + "conceptId": "java.generics", + "difficulty": "basic", + "prompt": "다음 프로그램이 names.add(10)에서 컴파일되지 않는 이유는 무엇인가요?", + "code": "import java.util.ArrayList;\nimport java.util.List;\n\nclass Main {\n public static void main(String[] args) {\n List names = new ArrayList<>();\n names.add(\"Bam\");\n names.add(10);\n }\n}", + "options": [ + { + "id": "a", + "text": "ArrayList에는 요소를 한 개만 넣을 수 있기 때문이다.", + "isCorrect": false, + "feedback": "틀립니다. ArrayList는 여러 요소를 담을 수 있습니다. 문제는 현재 요소 개수가 아니라 선언된 요소 타입과 두 번째 인수의 타입이 다르다는 점입니다." + }, + { + "id": "b", + "text": "List의 add는 String 요소를 요구하지만 10은 String으로 변환되지 않기 때문이다.", + "isCorrect": true, + "feedback": "맞습니다. List으로 매개변수화하면 이 목록의 요소 타입은 String입니다. 정수 리터럴 10은 필요하다면 Integer로 박싱될 수 있지만 String으로 자동 변환되지는 않습니다." + }, + { + "id": "c", + "text": "타입이 다른 값은 일단 저장된 뒤 get()으로 읽을 때만 오류가 발생하기 때문이다.", + "isCorrect": false, + "feedback": "틀립니다. 원시 타입을 사용한 오래된 코드와 달리 List은 잘못된 타입의 add 호출을 컴파일 단계에서 거부합니다." + }, + { + "id": "d", + "text": "List는 변경 불가능한 컬렉션이라 두 번째 add 호출부터 항상 거부하기 때문이다.", + "isCorrect": false, + "feedback": "틀립니다. 이 변수는 변경 가능한 ArrayList 객체를 가리킵니다. 같은 String 타입의 값이라면 두 번째 이후에도 추가할 수 있습니다." + } + ] + }, + { + "id": "quiz-java-map-put-replacement", + "lessonId": "java-04-collections-generics", + "conceptId": "java.map", + "difficulty": "application", + "prompt": "다음 Java 프로그램이 출력하는 두 값의 조합은 무엇인가요?", + "code": "import java.util.HashMap;\nimport java.util.Map;\n\nclass Main {\n public static void main(String[] args) {\n Map scores = new HashMap<>();\n scores.put(\"bam\", 70);\n Integer previous = scores.put(\"bam\", 90);\n\n System.out.println(previous + \" \" + scores.get(\"bam\"));\n }\n}", + "options": [ + { + "id": "a", + "text": "null 70", + "isCorrect": false, + "feedback": "틀립니다. 두 번째 put() 시점에는 bam 키에 이미 값 70이 연결되어 있습니다. 새 값 90으로 교체되므로 최종 조회값도 70이 아닙니다." + }, + { + "id": "b", + "text": "null 90", + "isCorrect": false, + "feedback": "틀립니다. 최종 조회값 90은 맞지만, 두 번째 put()은 새 키 추가가 아니라 기존 키의 값 교체이므로 이전 값 70을 반환합니다." + }, + { + "id": "c", + "text": "90 70", + "isCorrect": false, + "feedback": "틀립니다. put()은 새로 저장한 값이 아니라 그 키에 연결되어 있던 이전 값을 반환합니다. 그리고 맵에는 새 값 90이 남습니다." + }, + { + "id": "d", + "text": "70 90", + "isCorrect": true, + "feedback": "맞습니다. 두 번째 put(\"bam\", 90)은 이전 값 70을 반환하고 같은 키의 매핑을 90으로 교체합니다." + } + ] + }, + { + "id": "quiz-java-number-format-catch", + "lessonId": "java-05-exceptions-debugging", + "conceptId": "java.exceptions", + "difficulty": "basic", + "prompt": "다음 Java 프로그램이 출력하는 문자열은 무엇인가요?", + "code": "class Main {\n public static void main(String[] args) {\n try {\n int score = Integer.parseInt(\"80점\");\n System.out.println(score);\n } catch (NumberFormatException error) {\n System.out.println(\"형식 오류\");\n }\n }\n}", + "options": [ + { + "id": "a", + "text": "80", + "isCorrect": false, + "feedback": "틀립니다. Integer.parseInt()는 숫자 뒤의 한글을 자동으로 무시하지 않습니다. 전체 문자열이 십진수 int 형식이어야 합니다." + }, + { + "id": "b", + "text": "80점", + "isCorrect": false, + "feedback": "틀립니다. parseInt()의 반환 타입은 int이며 원본 문자열을 출력하는 메서드가 아닙니다. 이 입력에서는 정상 반환도 하지 않습니다." + }, + { + "id": "c", + "text": "형식 오류", + "isCorrect": true, + "feedback": "맞습니다. \"80점\"은 파싱 가능한 십진 정수 문자열이 아니므로 NumberFormatException이 발생하고, 대응하는 catch 블록이 실행됩니다." + }, + { + "id": "d", + "text": "NumberFormatException을 throws로 선언하지 않았다는 컴파일 오류", + "isCorrect": false, + "feedback": "틀립니다. NumberFormatException은 RuntimeException의 하위 클래스인 비검사 예외입니다. 반드시 throws로 선언할 필요가 없으며 이 코드에서는 직접 잡고 있습니다." + } + ] + }, + { + "id": "quiz-java-checked-read-all-lines", + "lessonId": "java-05-exceptions-debugging", + "conceptId": "java.checked-exceptions", + "difficulty": "application", + "prompt": "Files.readAllLines(path)가 선언한 검사 예외 때문에 다음 메서드가 컴파일되지 않습니다. 본문을 그대로 두고 호출자에게 예외 처리를 맡기려면 어떤 선언으로 바꿔야 하나요?", + "code": "import java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\nclass Notes {\n static String firstLine(Path path) {\n return Files.readAllLines(path).get(0);\n }\n}", + "options": [ + { + "id": "a", + "text": "static String firstLine(Path path) throws IOException", + "isCorrect": true, + "feedback": "맞습니다. Files.readAllLines(Path)는 읽기 실패 시 검사 예외인 IOException을 던질 수 있으므로 메서드가 직접 잡지 않는다면 throws IOException으로 호출자에게 전파해야 합니다." + }, + { + "id": "b", + "text": "static String firstLine(Path path) throws NumberFormatException", + "isCorrect": false, + "feedback": "틀립니다. 본문에는 숫자 파싱이 없고 NumberFormatException을 선언해도 Files.readAllLines()의 검사 예외인 IOException을 처리하거나 전파한 것이 되지 않습니다." + }, + { + "id": "c", + "text": "static String firstLine(Path path) throws RuntimeException", + "isCorrect": false, + "feedback": "틀립니다. RuntimeException 선언은 비검사 예외 계층만 나타냅니다. IOException은 그 하위 타입이 아니므로 컴파일러가 요구하는 검사 예외 계약을 충족하지 않습니다." + }, + { + "id": "d", + "text": "static String firstLine(Path path) throws Error", + "isCorrect": false, + "feedback": "틀립니다. Error와 IOException은 서로 다른 예외 계층입니다. throws Error는 readAllLines()가 선언한 IOException을 전파하는 계약이 아닙니다." + } + ] + }, + { + "id": "quiz-java-problem-decomposition-contracts", + "lessonId": "java-06-review-practice", + "conceptId": "java.problem-decomposition", + "difficulty": "basic", + "prompt": "점수 기능을 입력 유효성 검사, 통과 판정, 표시 문장 생성의 세 역할로 나누고 각 단계의 결과를 다음 단계에 명시적으로 전달하려 합니다. 가장 잘 맞는 메서드 계약 묶음은 무엇인가요?", + "options": [ + { + "id": "a", + "text": "static String process(int score) 하나에서 검증, 판정, 문자열 생성을 모두 처리한다.", + "isCorrect": false, + "feedback": "틀립니다. 최종 동작은 만들 수 있지만 세 역할을 독립적으로 확인하려는 요구사항과 맞지 않습니다. 어느 단계에서 문제가 생겼는지도 좁히기 어렵습니다." + }, + { + "id": "b", + "text": "static void validate(), static void calculate(), static void print()가 모두 공유 필드만 읽고 쓴다.", + "isCorrect": false, + "feedback": "틀립니다. 이름은 나뉘었지만 입력과 출력이 계약에 드러나지 않고 숨은 공유 상태에 의존합니다. 단계 사이에 어떤 값이 전달되는지 호출 코드에서 확인하기 어렵습니다." + }, + { + "id": "c", + "text": "static boolean isValidScore(String score), static int isPassing(int score), static boolean formatResult(boolean passed)", + "isCorrect": false, + "feedback": "틀립니다. 점수 입력을 int로 다룬다는 요구와 첫 계약이 맞지 않고, 통과 판정은 boolean을 반환해야 하며 표시 생성은 String을 반환해야 합니다." + }, + { + "id": "d", + "text": "static boolean isValidScore(int score), static boolean isPassing(int score), static String formatResult(int score, boolean passed)", + "isCorrect": true, + "feedback": "맞습니다. 각 메서드가 한 역할을 맡고 필요한 입력과 반환 타입을 선언에 드러냅니다. 유효성, 판정, 표현 단계를 따로 호출하고 검증할 수 있습니다." + } + ] + }, + { + "id": "quiz-java-boundary-test-expectations", + "lessonId": "java-06-review-practice", + "conceptId": "java.testing", + "difficulty": "application", + "prompt": "유효 범위의 바로 바깥과 안쪽, 통과 기준의 양쪽을 확인하기 위해 -1, 0, 59, 60, 100, 101을 테스트합니다. 다음 메서드의 올바른 기대 결과 조합은 무엇인가요?", + "code": "class Grader {\n static String grade(int score) {\n if (score < 0 || score > 100) {\n return \"invalid\";\n }\n if (score >= 60) {\n return \"pass\";\n }\n return \"retry\";\n }\n}", + "options": [ + { + "id": "a", + "text": "-1→invalid, 0→invalid, 59→retry, 60→pass, 100→pass, 101→invalid", + "isCorrect": false, + "feedback": "틀립니다. 0은 score < 0도 score > 100도 아니므로 유효 범위 안입니다. 통과 기준에는 못 미쳐 retry가 되어야 합니다." + }, + { + "id": "b", + "text": "-1→invalid, 0→retry, 59→retry, 60→pass, 100→pass, 101→invalid", + "isCorrect": true, + "feedback": "맞습니다. -1과 101은 유효 범위 밖이고, 0과 59는 유효하지만 60 미만이며, 60과 100은 유효하면서 통과 기준 이상입니다." }, { "id": "c", - "text": "반환 타입을 `void`로 바꿔 `static void makeLabel(int score)`로 선언한다.", + "text": "-1→invalid, 0→retry, 59→pass, 60→retry, 100→pass, 101→invalid", "isCorrect": false, - "feedback": "틀립니다. `void`는 결과 값을 반환하지 않는 메서드를 나타내므로 `return score + \"점\";`처럼 값을 돌려주는 현재 본문과 맞지 않습니다." + "feedback": "틀립니다. 통과 조건은 score >= 60입니다. 따라서 59는 retry, 경계값 60은 pass여야 합니다." }, { "id": "d", - "text": "반환 타입을 `boolean`으로 바꿔 `static boolean makeLabel(int score)`로 선언한다.", + "text": "-1→invalid, 0→retry, 59→retry, 60→pass, 100→invalid, 101→invalid", "isCorrect": false, - "feedback": "틀립니다. `boolean`은 `true` 또는 `false`를 나타내지만 현재 반환 표현식은 `String`을 만듭니다. 반환 타입은 실제 반환 값의 타입과 맞아야 합니다." + "feedback": "틀립니다. 잘못된 상한 조건은 score > 100이므로 100은 유효합니다. 또한 통과 기준 이상이므로 pass가 되어야 합니다." } ] } diff --git a/docs/architecture.md b/docs/architecture.md index 00405ed..d54662c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,16 +10,18 @@ curriculum.json + Markdown ──► 학습 화면 ───────── ├── quest JSON ──► CodeQuestRunnerRouter ─────────────┤ │ ├── JavaScript ─► one-shot Worker │ │ ├── HTML ───────► inert DOM 검사 │ - │ └── CSS ────────► CSSOM/iframe 검사 + │ ├── CSS ────────► CSSOM/iframe 검사 + │ └── Java ───────► same-origin 로컬 Docker 채점기 │ - ├── coding-test JSON ─► 목록·풀이·제출 ─► one-shot Worker + ├── coding-test JSON ─► 목록·풀이·제출 ─┬► one-shot Worker (JavaScript) + │ └► 로컬 Docker 채점기 (Java) │ └── web-project JSON ─► 두 파일 편집·안전 미리보기·공개 평가 │ └──► WebProjectRepository ─► localStorage ``` -콘텐츠는 정적 읽기 전용 데이터이고, 진도는 사용자별 변경 데이터입니다. 객관식·Quest·코딩테스트·Web Project를 안정적인 `lesson.id`·`conceptId`로 연결하고 실행 문제는 ID와 `revision`으로 식별하여, 콘텐츠 수정이 사용자 상태 형식을 불필요하게 바꾸지 않도록 합니다. 현재 정식 언어는 JavaScript·HTML·CSS 3개이고 Java는 샘플 1개를 유지합니다. +콘텐츠는 정적 읽기 전용 데이터이고, 진도는 사용자별 변경 데이터입니다. 객관식·Quest·코딩테스트·Web Project를 안정적인 `lesson.id`·`conceptId`로 연결하고 실행 문제는 ID와 `revision`으로 식별하여, 콘텐츠 수정이 사용자 상태 형식을 불필요하게 바꾸지 않도록 합니다. 현재 정식 언어는 JavaScript·HTML·CSS·Java 4개입니다. ## 브라우저 앱 @@ -27,11 +29,12 @@ curriculum.json + Markdown ──► 학습 화면 ───────── - 콘텐츠 로딩: `curriculum.json` 검증 후 선택한 Markdown과 언어별 객관식·Quest·코딩테스트 JSON 및 Web Project 컬렉션을 각 계약으로 검증합니다. 교안의 실행 예제 데이터는 `content/fixtures//`에 두고 same-origin으로 불러오며 `content/` 전체와 함께 정적 빌드에 포함합니다. - Markdown: 프로젝트가 신뢰하는 제한된 문법만 HTML로 변환하며 원시 HTML은 항상 이스케이프합니다. - 객관식: 콘텐츠 검증, 한 문제 채점, 전체 요약을 순수 도메인 함수로 분리합니다. 선택 전에는 정답 정보를 화면에 렌더링하지 않고 채점 후 네 선택지의 근거를 모두 표시합니다. -- Quest 라우팅: `CodeQuestRunnerRouter`가 JavaScript의 기존 함수 요청과 `html-dom-v1`, `css-style-v1` 직접 소스 요청을 분리합니다. 공통 UI·진도 DTO는 유지하되 각 언어의 작성 단위와 평가기만 교체합니다. +- Quest 라우팅: `CodeQuestRunnerRouter`가 JavaScript·Java 함수 요청과 `html-dom-v1`, `css-style-v1` 직접 소스 요청을 분리합니다. 공통 UI·진도 DTO는 유지하되 각 언어의 작성 단위와 평가기만 교체합니다. - JavaScript Quest: 문제 계약, 시작 코드, 입출력 예시, 공개 테스트와 실패 설명을 콘텐츠로 관리합니다. 공개 테스트마다 새 module Worker를 만들고 문법·런타임·시간·출력 제한·취소를 구분합니다. +- Java Quest: `functionContract`의 타입을 `parameterTypes`·`returnType`으로 정규화하고 same-origin 로컬 API에 전달합니다. 로컬 서버는 고정 digest의 Java 21 Docker 이미지에서 별도 `Solution.java`와 harness를 `javac --release 21`로 컴파일하고 공개 테스트마다 제한된 컨테이너/JVM을 실행합니다. 브라우저 취소는 정확한 컨테이너 이름의 종료·정리로 전파합니다. - HTML Quest: 학습자가 JavaScript 함수가 아닌 HTML 마크업을 작성합니다. doctype 검사는 source 첫 선언과 `DOMParser` 결과를 함께 사용해 정확한 HTML5 doctype인지 확인하고 나머지 구조 검사는 주 문서에 삽입하지 않은 `