feat: SQL 코딩 문제 추가 — 브라우저 SQLite(sql.js)로 실행·자동채점

- 문제 5개 시드: SELECT* → WHERE → ORDER BY+LIMIT → GROUP BY → JOIN
  (직원·부서 연습 데이터, SQL 입문 강좌 진도 순서와 일치)
- 실행 방식: sql.js(SQLite WASM)로 학생 브라우저 안 1회용 메모리 DB에서 실행
  → 서버 DB 무접촉. DROP/DELETE도 자유롭게 실험 가능 (runJs.js와 같은 격리 철학)
- 채점: 값만 비교(컬럼 별칭 허용), ORDER BY 문제만 행 순서 채점(orderMatters)
- 화면: 데이터 살펴보기(테이블+데이터), 실행 결과 표 vs 기대 결과 표 나란히 비교
- CodingProblem: setupSql 컬럼, isAutoGraded에 SQL 포함(JAVA만 수동)
- CodingServiceTest 3개 추가: SQL 채점값 저장·PASSED, JAVA 채점값 폐기(조작 방지), 빈 코드 400
  → 전체 17개 테스트 통과

운영 검증(브라우저 E2E): 정답 실행 '통과', 오답 시 기대 결과 비교 표시,
제출 시 서버에 PASSED 1/1 저장 확인. 문제 수 20→25 재시드.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 09:47:54 +09:00
parent cb0fb54185
commit a5d4437b27
13 changed files with 1134 additions and 98 deletions

View File

@ -24,6 +24,7 @@ import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
/**
* 파일이 하는 :
@ -180,19 +181,23 @@ public class SeedDataLoader implements ApplicationRunner {
}
int sortOrder = 0;
for (CodingProblemSeed seed : seeds) {
// 채점 데이터는 언어마다 모양이 다르다 JS는 테스트케이스 배열, SQL은 기대 결과 객체.
// 어느 쪽이든 DB에는 JSON 문자열 하나(testsJson) 들어간다.
String testsJson = null;
if (seed.tests() != null) {
try {
try {
if (seed.sqlExpected() != null) {
testsJson = objectMapper.writeValueAsString(seed.sqlExpected());
} else if (seed.tests() != null) {
testsJson = objectMapper.writeValueAsString(seed.tests());
} catch (Exception e) {
log.error("테스트케이스 직렬화 실패 — 건너뜁니다: {}", seed.title(), e);
continue;
}
} catch (Exception e) {
log.error("채점 데이터 직렬화 실패 — 건너뜁니다: {}", seed.title(), e);
continue;
}
codingProblemRepository.save(new CodingProblem(
seed.language(), seed.title(), seed.difficulty(), seed.topic(),
seed.description(), seed.starterCode(), seed.functionName(),
testsJson, sortOrder++));
seed.setupSql(), testsJson, sortOrder++));
}
log.info("코딩 문제 시드 {}건을 저장했습니다.", seeds.size());
}
@ -237,10 +242,14 @@ public class SeedDataLoader implements ApplicationRunner {
Integer answerIndex, String explanation) {
}
/** coding-problems.json 한 건 (tests는 JAVA 문제면 null) */
/**
* coding-problems.json .
* tests는 JS 전용, setupSql·sqlExpected는 SQL 전용, JAVA는 null.
*/
record CodingProblemSeed(String language, String title, String difficulty, String topic,
String description, String starterCode, String functionName,
List<TestCaseSeed> tests) {
List<TestCaseSeed> tests,
String setupSql, Map<String, Object> sqlExpected) {
}
/** 테스트케이스 한 건 — args를 함수에 넣었을 때 expected가 나와야 한다 */

View File

@ -9,6 +9,8 @@ import jakarta.persistence.*;
*
* 언어(language) 따라 채점 방식이 다르다:
* - JS : 브라우저(Web Worker) 테스트케이스를 돌려 즉시 통과/실패를 가린다.
* - SQL : 브라우저 안의 SQLite(sql.js, WASM) 쿼리를 실행해 기대 결과와 비교한다.
* 서버 DB에는 손끝 하나 닿지 않는다 학생마다 자기 브라우저에 1회용 DB가 생긴다.
* - JAVA : 실행하지 않는다. 코드를 제출하면 멘토가 직접 읽고 피드백한다.
*
* 학습 포인트: 자바는 서버에서 실행하지 않을까?
@ -26,7 +28,7 @@ public class CodingProblem {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 문제 언어: JS | JAVA */
/** 문제 언어: JS | SQL | JAVA */
@Column(nullable = false, length = 8)
private String language;
@ -58,8 +60,18 @@ public class CodingProblem {
private String functionName;
/**
* 테스트케이스 묶음을 JSON 문자열로 통째로 담는다 (JS 전용, JAVA는 null).
* 형식: [{"name":"설명","args":[1,2],"expected":3}, ...]
* 문제를 브라우저 SQLite에 먼저 실행할 준비 SQL (SQL 전용, 나머지는 null).
* CREATE TABLE + INSERT "문제의 데이터 세트" 안에 통째로 들어 있다.
* 학습 포인트: 같은 준비 SQL을 여러 문제가 복사해서 갖는다. 정규화하면 테이블이 하나 늘고
* 조인이 생기는데, 문제 5개 규모에선 "그냥 들고 다니는" 쪽이 단순하다.
*/
@Column(name = "setup_sql", columnDefinition = "text")
private String setupSql;
/**
* 테스트케이스 묶음을 JSON 문자열로 통째로 담는다 (JS·SQL 전용, JAVA는 null).
* JS 형식: [{"name":"설명","args":[1,2],"expected":3}, ...]
* SQL 형식: {"expectedColumns":[...],"expectedRows":[[...]],"orderMatters":false}
*
* 학습 포인트: 테스트케이스용 테이블을 따로 만들지 않고 JSON 덩어리로 넣을까?
* 테스트케이스는 (1) 항상 문제와 함께 통째로 읽고, (2) 따로 검색하거나 조인할 일이 없다.
@ -78,7 +90,7 @@ public class CodingProblem {
public CodingProblem(String language, String title, String difficulty, String topic,
String description, String starterCode, String functionName,
String testsJson, Integer sortOrder) {
String setupSql, String testsJson, Integer sortOrder) {
this.language = language;
this.title = title;
this.difficulty = difficulty;
@ -86,13 +98,14 @@ public class CodingProblem {
this.description = description;
this.starterCode = starterCode;
this.functionName = functionName;
this.setupSql = setupSql;
this.testsJson = testsJson;
this.sortOrder = sortOrder;
}
/** 브라우저에서 자동채점이 가능한 문제인가 (= JS 문제인가) */
/** 브라우저에서 자동채점이 가능한 문제인가 (JS와 SQL — JAVA만 멘토 수동 채점) */
public boolean isAutoGraded() {
return "JS".equals(language);
return "JS".equals(language) || "SQL".equals(language);
}
public Long getId() {
@ -127,6 +140,10 @@ public class CodingProblem {
return functionName;
}
public String getSetupSql() {
return setupSql;
}
public String getTestsJson() {
return testsJson;
}

View File

@ -76,7 +76,7 @@ public class CodingService {
}
CodingProblem problem = problem(problemId);
// 자바 문제는 자동채점이 없다 브라우저가 보내오든 채점 값은 버린다.
// 자바 문제는 자동채점이 없다 브라우저가 보내오든 채점 값은 버린다. (JS·SQL만 저장)
Integer passed = problem.isAutoGraded() ? passedCount : null;
Integer total = problem.isAutoGraded() ? totalCount : null;
String results = problem.isAutoGraded() ? resultsJson : null;

View File

@ -54,10 +54,10 @@ public class CodingController {
.toList();
}
/** 문제 상세 — 테스트케이스(testsJson)는 브라우저가 채점해야 하므로 함께 내려준다 */
/** 문제 상세 — 채점 데이터(testsJson)와 SQL 준비 스크립트(setupSql)는 브라우저가 쓰므로 함께 내려준다 */
public record ProblemDetail(Long id, String language, String title, String difficulty,
String topic, String description, String starterCode,
String functionName, String testsJson,
String functionName, String setupSql, String testsJson,
String myCode, String status, Integer passedCount,
Integer totalCount, String resultsJson, Integer attempts,
String feedback) {
@ -71,12 +71,14 @@ public class CodingController {
return codingService.mySubmission(user, id)
.map(s -> new ProblemDetail(problem.getId(), problem.getLanguage(), problem.getTitle(),
problem.getDifficulty(), problem.getTopic(), problem.getDescription(),
problem.getStarterCode(), problem.getFunctionName(), problem.getTestsJson(),
problem.getStarterCode(), problem.getFunctionName(),
problem.getSetupSql(), problem.getTestsJson(),
s.getCode(), s.getStatus(), s.getPassedCount(), s.getTotalCount(),
s.getResultsJson(), s.getAttempts(), s.getFeedback()))
.orElseGet(() -> new ProblemDetail(problem.getId(), problem.getLanguage(), problem.getTitle(),
problem.getDifficulty(), problem.getTopic(), problem.getDescription(),
problem.getStarterCode(), problem.getFunctionName(), problem.getTestsJson(),
problem.getStarterCode(), problem.getFunctionName(),
problem.getSetupSql(), problem.getTestsJson(),
null, "TODO", null, null, null, 0, null));
}

View File

@ -8,11 +8,41 @@
"starterCode": "function solution(a, b) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "1 + 2 = 3", "args": [1, 2], "expected": 3 },
{ "name": "0 + 0 = 0", "args": [0, 0], "expected": 0 },
{ "name": "음수도 된다 (-5 + 3)", "args": [-5, 3], "expected": -2 },
{ "name": "큰 수 (10000 + 23456)", "args": [10000, 23456], "expected": 33456 }
]
{
"name": "1 + 2 = 3",
"args": [
1,
2
],
"expected": 3
},
{
"name": "0 + 0 = 0",
"args": [
0,
0
],
"expected": 0
},
{
"name": "음수도 된다 (-5 + 3)",
"args": [
-5,
3
],
"expected": -2
},
{
"name": "큰 수 (10000 + 23456)",
"args": [
10000,
23456
],
"expected": 33456
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -23,11 +53,37 @@
"starterCode": "function solution(n) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "4는 짝수", "args": [4], "expected": true },
{ "name": "7은 홀수", "args": [7], "expected": false },
{ "name": "0은 짝수", "args": [0], "expected": true },
{ "name": "-3은 홀수", "args": [-3], "expected": false }
]
{
"name": "4는 짝수",
"args": [
4
],
"expected": true
},
{
"name": "7은 홀수",
"args": [
7
],
"expected": false
},
{
"name": "0은 짝수",
"args": [
0
],
"expected": true
},
{
"name": "-3은 홀수",
"args": [
-3
],
"expected": false
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -38,11 +94,37 @@
"starterCode": "function solution(s) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "'hello' → 'olleh'", "args": ["hello"], "expected": "olleh" },
{ "name": "한 글자는 그대로", "args": ["a"], "expected": "a" },
{ "name": "빈 문자열", "args": [""], "expected": "" },
{ "name": "한글도 된다", "args": ["가나다"], "expected": "다나가" }
]
{
"name": "'hello' → 'olleh'",
"args": [
"hello"
],
"expected": "olleh"
},
{
"name": "한 글자는 그대로",
"args": [
"a"
],
"expected": "a"
},
{
"name": "빈 문자열",
"args": [
""
],
"expected": ""
},
{
"name": "한글도 된다",
"args": [
"가나다"
],
"expected": "다나가"
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -53,11 +135,51 @@
"starterCode": "function solution(numbers) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "[1, 5, 3] → 5", "args": [[1, 5, 3]], "expected": 5 },
{ "name": "원소 하나", "args": [[42]], "expected": 42 },
{ "name": "전부 음수", "args": [[-1, -9, -4]], "expected": -1 },
{ "name": "중복이 있어도", "args": [[7, 7, 2]], "expected": 7 }
]
{
"name": "[1, 5, 3] → 5",
"args": [
[
1,
5,
3
]
],
"expected": 5
},
{
"name": "원소 하나",
"args": [
[
42
]
],
"expected": 42
},
{
"name": "전부 음수",
"args": [
[
-1,
-9,
-4
]
],
"expected": -1
},
{
"name": "중복이 있어도",
"args": [
[
7,
7,
2
]
],
"expected": 7
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -68,11 +190,37 @@
"starterCode": "function solution(n) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "n=5 → 15", "args": [5], "expected": 15 },
{ "name": "n=1 → 1", "args": [1], "expected": 1 },
{ "name": "n=100 → 5050", "args": [100], "expected": 5050 },
{ "name": "n=10 → 55", "args": [10], "expected": 55 }
]
{
"name": "n=5 → 15",
"args": [
5
],
"expected": 15
},
{
"name": "n=1 → 1",
"args": [
1
],
"expected": 1
},
{
"name": "n=100 → 5050",
"args": [
100
],
"expected": 5050
},
{
"name": "n=10 → 55",
"args": [
10
],
"expected": 55
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -83,11 +231,37 @@
"starterCode": "function solution(s) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "'hello' → 2", "args": ["hello"], "expected": 2 },
{ "name": "모음 없음", "args": ["xyz"], "expected": 0 },
{ "name": "전부 모음", "args": ["aeiou"], "expected": 5 },
{ "name": "빈 문자열", "args": [""], "expected": 0 }
]
{
"name": "'hello' → 2",
"args": [
"hello"
],
"expected": 2
},
{
"name": "모음 없음",
"args": [
"xyz"
],
"expected": 0
},
{
"name": "전부 모음",
"args": [
"aeiou"
],
"expected": 5
},
{
"name": "빈 문자열",
"args": [
""
],
"expected": 0
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -98,11 +272,54 @@
"starterCode": "function solution(numbers) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "[1,2,3,4] → [2,4]", "args": [[1, 2, 3, 4]], "expected": [2, 4] },
{ "name": "짝수 없음", "args": [[1, 3, 5]], "expected": [] },
{ "name": "빈 배열", "args": [[]], "expected": [] },
{ "name": "0도 짝수", "args": [[0, 1]], "expected": [0] }
]
{
"name": "[1,2,3,4] → [2,4]",
"args": [
[
1,
2,
3,
4
]
],
"expected": [
2,
4
]
},
{
"name": "짝수 없음",
"args": [
[
1,
3,
5
]
],
"expected": []
},
{
"name": "빈 배열",
"args": [
[]
],
"expected": []
},
{
"name": "0도 짝수",
"args": [
[
0,
1
]
],
"expected": [
0
]
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -113,11 +330,65 @@
"starterCode": "function solution(n) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "n=5", "args": [5], "expected": [1, 2, "Fizz", 4, "Buzz"] },
{ "name": "n=15 (마지막이 FizzBuzz)", "args": [15], "expected": [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz"] },
{ "name": "n=1", "args": [1], "expected": [1] },
{ "name": "n=3", "args": [3], "expected": [1, 2, "Fizz"] }
]
{
"name": "n=5",
"args": [
5
],
"expected": [
1,
2,
"Fizz",
4,
"Buzz"
]
},
{
"name": "n=15 (마지막이 FizzBuzz)",
"args": [
15
],
"expected": [
1,
2,
"Fizz",
4,
"Buzz",
"Fizz",
7,
8,
"Fizz",
"Buzz",
11,
"Fizz",
13,
14,
"FizzBuzz"
]
},
{
"name": "n=1",
"args": [
1
],
"expected": [
1
]
},
{
"name": "n=3",
"args": [
3
],
"expected": [
1,
2,
"Fizz"
]
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -128,11 +399,61 @@
"starterCode": "function solution(numbers) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "[1,2,2,3,1] → [1,2,3]", "args": [[1, 2, 2, 3, 1]], "expected": [1, 2, 3] },
{ "name": "중복 없음", "args": [[1, 2, 3]], "expected": [1, 2, 3] },
{ "name": "전부 같은 값", "args": [[5, 5, 5]], "expected": [5] },
{ "name": "빈 배열", "args": [[]], "expected": [] }
]
{
"name": "[1,2,2,3,1] → [1,2,3]",
"args": [
[
1,
2,
2,
3,
1
]
],
"expected": [
1,
2,
3
]
},
{
"name": "중복 없음",
"args": [
[
1,
2,
3
]
],
"expected": [
1,
2,
3
]
},
{
"name": "전부 같은 값",
"args": [
[
5,
5,
5
]
],
"expected": [
5
]
},
{
"name": "빈 배열",
"args": [
[]
],
"expected": []
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -143,11 +464,37 @@
"starterCode": "function solution(s) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "'level' → true", "args": ["level"], "expected": true },
{ "name": "'hello' → false", "args": ["hello"], "expected": false },
{ "name": "대소문자 무시 'Level'", "args": ["Level"], "expected": true },
{ "name": "공백 무시 'a b a'", "args": ["a b a"], "expected": true }
]
{
"name": "'level' → true",
"args": [
"level"
],
"expected": true
},
{
"name": "'hello' → false",
"args": [
"hello"
],
"expected": false
},
{
"name": "대소문자 무시 'Level'",
"args": [
"Level"
],
"expected": true
},
{
"name": "공백 무시 'a b a'",
"args": [
"a b a"
],
"expected": true
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -158,11 +505,46 @@
"starterCode": "function solution(sentence) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "'a b a'", "args": ["a b a"], "expected": { "a": 2, "b": 1 } },
{ "name": "단어 하나", "args": ["hello"], "expected": { "hello": 1 } },
{ "name": "빈 문자열 → {}", "args": [""], "expected": {} },
{ "name": "전부 다른 단어", "args": ["x y z"], "expected": { "x": 1, "y": 1, "z": 1 } }
]
{
"name": "'a b a'",
"args": [
"a b a"
],
"expected": {
"a": 2,
"b": 1
}
},
{
"name": "단어 하나",
"args": [
"hello"
],
"expected": {
"hello": 1
}
},
{
"name": "빈 문자열 → {}",
"args": [
""
],
"expected": {}
},
{
"name": "전부 다른 단어",
"args": [
"x y z"
],
"expected": {
"x": 1,
"y": 1,
"z": 1
}
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JS",
@ -173,11 +555,51 @@
"starterCode": "function solution(numbers) {\n // 여기에 코드를 작성하세요\n}",
"functionName": "solution",
"tests": [
{ "name": "[3,1,3,2] → 2", "args": [[3, 1, 3, 2]], "expected": 2 },
{ "name": "전부 같으면 null", "args": [[5, 5, 5]], "expected": null },
{ "name": "두 자리 수 주의 [10, 9]", "args": [[10, 9]], "expected": 9 },
{ "name": "원소 하나면 null", "args": [[1]], "expected": null }
]
{
"name": "[3,1,3,2] → 2",
"args": [
[
3,
1,
3,
2
]
],
"expected": 2
},
{
"name": "전부 같으면 null",
"args": [
[
5,
5,
5
]
],
"expected": null
},
{
"name": "두 자리 수 주의 [10, 9]",
"args": [
[
10,
9
]
],
"expected": 9
},
{
"name": "원소 하나면 null",
"args": [
[
1
]
],
"expected": null
}
],
"setupSql": null,
"sqlExpected": null
},
{
"language": "JAVA",
@ -187,7 +609,9 @@
"description": "정수 두 개 중 더 큰 값을 돌려주는 메서드를 완성하세요. 같으면 그 값을 돌려줍니다.\n\n이 문제는 자동채점이 없습니다. 코드를 제출하면 멘토가 직접 읽고 피드백합니다.\n제출 전 IntelliJ에서 직접 실행해 확인해 보세요.\n\n힌트: if문으로 풀어도 되고 Math.max를 써도 됩니다. 둘 다 해보고 어느 쪽이 읽기 좋은지 생각해 보세요.",
"starterCode": "public class Solution {\n\n public int solution(int a, int b) {\n // 여기에 코드를 작성하세요\n return 0;\n }\n}",
"functionName": null,
"tests": null
"tests": null,
"setupSql": null,
"sqlExpected": null
},
{
"language": "JAVA",
@ -197,7 +621,9 @@
"description": "int 배열의 모든 원소를 더한 값을 돌려주세요. 빈 배열이면 0입니다.\n\n이 문제는 자동채점이 없습니다 — 멘토가 코드를 읽고 피드백합니다.\n\n힌트: 향상된 for문(for (int n : numbers))을 쓰면 인덱스 실수를 줄일 수 있습니다.",
"starterCode": "public class Solution {\n\n public int solution(int[] numbers) {\n // 여기에 코드를 작성하세요\n return 0;\n }\n}",
"functionName": null,
"tests": null
"tests": null,
"setupSql": null,
"sqlExpected": null
},
{
"language": "JAVA",
@ -207,7 +633,9 @@
"description": "문자열이 null이거나, 비어 있거나, 공백뿐이면 true를 돌려주세요. 그 외에는 false입니다.\n\n예) null → true, \"\" → true, \" \" → true, \" a \" → false\n\n힌트: null 검사를 가장 먼저 해야 합니다. 순서를 바꾸면 NullPointerException이 납니다 —\n왜 그런지 설명할 수 있게 되는 것이 이 문제의 진짜 목표입니다. isBlank()를 찾아보세요.",
"starterCode": "public class Solution {\n\n public boolean solution(String s) {\n // 여기에 코드를 작성하세요\n return false;\n }\n}",
"functionName": null,
"tests": null
"tests": null,
"setupSql": null,
"sqlExpected": null
},
{
"language": "JAVA",
@ -217,7 +645,9 @@
"description": "1부터 n까지의 정수 중 짝수만 더한 값을 돌려주세요.\n\n예) solution(10) → 30 (2+4+6+8+10)\n\n힌트: for문의 증가식을 i += 2로 두면 짝수만 밟을 수 있습니다.\nif로 걸러내는 방식과 비교해 보고, 어느 쪽이 더 좋은지 근거를 들어 설명해 보세요.",
"starterCode": "public class Solution {\n\n public int solution(int n) {\n // 여기에 코드를 작성하세요\n return 0;\n }\n}",
"functionName": null,
"tests": null
"tests": null,
"setupSql": null,
"sqlExpected": null
},
{
"language": "JAVA",
@ -227,7 +657,9 @@
"description": "문자열을 거꾸로 뒤집어 돌려주세요.\n\n힌트: StringBuilder에는 reverse()가 있습니다.\n생각해 볼 것 — String을 +로 계속 이어 붙이며 뒤집으면 왜 느릴까요?\n(자바의 String은 한 번 만들면 바뀌지 않는다 = 불변. 이어 붙일 때마다 새 객체가 생깁니다.)\n제출할 때 이 이유를 주석으로 적어 주세요.",
"starterCode": "public class Solution {\n\n public String solution(String s) {\n // 여기에 코드를 작성하세요\n return \"\";\n }\n}",
"functionName": null,
"tests": null
"tests": null,
"setupSql": null,
"sqlExpected": null
},
{
"language": "JAVA",
@ -237,7 +669,9 @@
"description": "int 배열에서 가장 큰 값을 돌려주세요. 배열은 항상 1개 이상입니다.\n\n힌트: 최댓값의 시작값을 0으로 두면 안 됩니다 — 전부 음수인 배열에서 틀립니다.\n첫 원소(numbers[0])로 시작하세요. 이런 실수를 '경계 조건 버그'라고 부릅니다.",
"starterCode": "public class Solution {\n\n public int solution(int[] numbers) {\n // 여기에 코드를 작성하세요\n return 0;\n }\n}",
"functionName": null,
"tests": null
"tests": null,
"setupSql": null,
"sqlExpected": null
},
{
"language": "JAVA",
@ -247,7 +681,9 @@
"description": "공백으로 구분된 문장에서 각 단어의 등장 횟수를 Map<String, Integer>로 돌려주세요.\n\n예) \"a b a\" → {a=2, b=1}\n\n힌트: split(\" \")으로 나눈 뒤 Map에 넣습니다.\nmap.getOrDefault(word, 0) + 1 패턴을 찾아보세요 — merge()로도 됩니다.\n빈 문자열일 때 split이 무엇을 돌려주는지 직접 찍어 보고 처리하세요.",
"starterCode": "import java.util.HashMap;\nimport java.util.Map;\n\npublic class Solution {\n\n public Map<String, Integer> solution(String sentence) {\n // 여기에 코드를 작성하세요\n return new HashMap<>();\n }\n}",
"functionName": null,
"tests": null
"tests": null,
"setupSql": null,
"sqlExpected": null
},
{
"language": "JAVA",
@ -257,6 +693,234 @@
"description": "Student(name, score) 목록을 점수가 높은 순으로 정렬해 돌려주세요.\n점수가 같으면 이름 오름차순으로 정렬합니다.\n\n힌트: list.sort(Comparator.comparingInt(Student::getScore).reversed().thenComparing(Student::getName))\n메서드 참조(::)와 Comparator 조합을 익히는 문제입니다.\n한 줄로 쓰기 전에, 익명 클래스로 먼저 써 보고 무엇이 줄어드는지 비교해 보세요.",
"starterCode": "import java.util.Comparator;\nimport java.util.List;\n\npublic class Solution {\n\n public static class Student {\n private final String name;\n private final int score;\n\n public Student(String name, int score) {\n this.name = name;\n this.score = score;\n }\n\n public String getName() { return name; }\n public int getScore() { return score; }\n }\n\n public List<Student> solution(List<Student> students) {\n // 여기에 코드를 작성하세요\n return students;\n }\n}",
"functionName": null,
"tests": null
"tests": null,
"setupSql": null,
"sqlExpected": null
},
{
"language": "SQL",
"title": "모든 직원 조회하기 (SELECT *)",
"difficulty": "EASY",
"topic": "sql",
"description": "employee 테이블의 모든 행, 모든 컬럼을 조회하세요.\n\n왼쪽 아래 [데이터 살펴보기]에서 테이블 구조와 데이터를 먼저 확인해 보세요.\n\n힌트: SELECT * 는 \"모든 컬럼\"이라는 뜻입니다. FROM 뒤에 테이블 이름을 씁니다.\nSQL 문장 끝에는 세미콜론(;)을 붙이는 습관을 들이세요.",
"starterCode": "-- 여기에 SQL을 작성하세요\nSELECT ",
"functionName": null,
"tests": null,
"setupSql": "CREATE TABLE department (\n id INTEGER PRIMARY KEY, -- 부서 번호\n name TEXT NOT NULL -- 부서 이름\n);\n\nCREATE TABLE employee (\n id INTEGER PRIMARY KEY, -- 사번\n name TEXT NOT NULL, -- 이름\n dept_id INTEGER NOT NULL, -- 소속 부서 번호 (department.id)\n salary INTEGER NOT NULL, -- 연봉 (만원)\n joined_year INTEGER NOT NULL -- 입사 연도\n);\n\nINSERT INTO department (id, name) VALUES\n (1, '개발팀'), (2, '디자인팀'), (3, '경영지원팀');\n\nINSERT INTO employee (id, name, dept_id, salary, joined_year) VALUES\n (1, '김하늘', 1, 3200, 2023),\n (2, '이준호', 1, 4100, 2021),\n (3, '박서연', 2, 3600, 2022),\n (4, '최민준', 1, 2900, 2024),\n (5, '정유진', 2, 3300, 2023),\n (6, '한지우', 3, 3800, 2020),\n (7, '오세훈', 3, 2700, 2024),\n (8, '서지민', 1, 4500, 2019);",
"sqlExpected": {
"expectedColumns": [
"id",
"name",
"dept_id",
"salary",
"joined_year"
],
"expectedRows": [
[
1,
"김하늘",
1,
3200,
2023
],
[
2,
"이준호",
1,
4100,
2021
],
[
3,
"박서연",
2,
3600,
2022
],
[
4,
"최민준",
1,
2900,
2024
],
[
5,
"정유진",
2,
3300,
2023
],
[
6,
"한지우",
3,
3800,
2020
],
[
7,
"오세훈",
3,
2700,
2024
],
[
8,
"서지민",
1,
4500,
2019
]
],
"orderMatters": false
}
},
{
"language": "SQL",
"title": "조건으로 거르기 (WHERE)",
"difficulty": "EASY",
"topic": "sql",
"description": "연봉(salary)이 3500만원 이상인 직원의 이름(name)만 조회하세요.\n\n힌트: WHERE 절이 \"행을 거르는 조건\"입니다. 이상은 >= 로 씁니다.\n필요한 컬럼(name)만 SELECT 하는 것도 문제의 일부예요 — *를 쓰면 컬럼이 남아서 틀립니다.",
"starterCode": "-- 연봉 3500 이상 직원의 이름만\nSELECT ",
"functionName": null,
"tests": null,
"setupSql": "CREATE TABLE department (\n id INTEGER PRIMARY KEY, -- 부서 번호\n name TEXT NOT NULL -- 부서 이름\n);\n\nCREATE TABLE employee (\n id INTEGER PRIMARY KEY, -- 사번\n name TEXT NOT NULL, -- 이름\n dept_id INTEGER NOT NULL, -- 소속 부서 번호 (department.id)\n salary INTEGER NOT NULL, -- 연봉 (만원)\n joined_year INTEGER NOT NULL -- 입사 연도\n);\n\nINSERT INTO department (id, name) VALUES\n (1, '개발팀'), (2, '디자인팀'), (3, '경영지원팀');\n\nINSERT INTO employee (id, name, dept_id, salary, joined_year) VALUES\n (1, '김하늘', 1, 3200, 2023),\n (2, '이준호', 1, 4100, 2021),\n (3, '박서연', 2, 3600, 2022),\n (4, '최민준', 1, 2900, 2024),\n (5, '정유진', 2, 3300, 2023),\n (6, '한지우', 3, 3800, 2020),\n (7, '오세훈', 3, 2700, 2024),\n (8, '서지민', 1, 4500, 2019);",
"sqlExpected": {
"expectedColumns": [
"name"
],
"expectedRows": [
[
"이준호"
],
[
"박서연"
],
[
"한지우"
],
[
"서지민"
]
],
"orderMatters": false
}
},
{
"language": "SQL",
"title": "정렬해서 상위 3명만 (ORDER BY + LIMIT)",
"difficulty": "NORMAL",
"topic": "sql",
"description": "연봉이 높은 순서대로 상위 3명의 이름(name)과 연봉(salary)을 조회하세요.\n\n이 문제는 순서까지 채점합니다 — 연봉이 높은 사람이 첫 줄에 와야 해요.\n\n힌트: ORDER BY salary DESC 가 \"연봉 내림차순 정렬\"입니다.\nLIMIT 3 을 붙이면 앞에서 3행만 가져옵니다. (실무에서 \"톱 N\" 조회의 기본 패턴)",
"starterCode": "-- 연봉 상위 3명: 이름, 연봉 (높은 순)\nSELECT ",
"functionName": null,
"tests": null,
"setupSql": "CREATE TABLE department (\n id INTEGER PRIMARY KEY, -- 부서 번호\n name TEXT NOT NULL -- 부서 이름\n);\n\nCREATE TABLE employee (\n id INTEGER PRIMARY KEY, -- 사번\n name TEXT NOT NULL, -- 이름\n dept_id INTEGER NOT NULL, -- 소속 부서 번호 (department.id)\n salary INTEGER NOT NULL, -- 연봉 (만원)\n joined_year INTEGER NOT NULL -- 입사 연도\n);\n\nINSERT INTO department (id, name) VALUES\n (1, '개발팀'), (2, '디자인팀'), (3, '경영지원팀');\n\nINSERT INTO employee (id, name, dept_id, salary, joined_year) VALUES\n (1, '김하늘', 1, 3200, 2023),\n (2, '이준호', 1, 4100, 2021),\n (3, '박서연', 2, 3600, 2022),\n (4, '최민준', 1, 2900, 2024),\n (5, '정유진', 2, 3300, 2023),\n (6, '한지우', 3, 3800, 2020),\n (7, '오세훈', 3, 2700, 2024),\n (8, '서지민', 1, 4500, 2019);",
"sqlExpected": {
"expectedColumns": [
"name",
"salary"
],
"expectedRows": [
[
"서지민",
4500
],
[
"이준호",
4100
],
[
"한지우",
3800
]
],
"orderMatters": true
}
},
{
"language": "SQL",
"title": "부서별로 묶어서 세기 (GROUP BY)",
"difficulty": "NORMAL",
"topic": "sql",
"description": "부서 번호(dept_id)별로 직원이 몇 명인지 조회하세요.\n결과 컬럼: dept_id, 인원 수 — 2개 컬럼이어야 합니다.\n\n힌트: GROUP BY dept_id 는 \"부서 번호가 같은 행끼리 묶어라\"입니다.\n묶고 나면 COUNT(*)로 각 묶음의 행 수를 셀 수 있어요.\nWHERE는 묶기 전에 거르고, 집계 결과로 거르려면 HAVING을 씁니다 — 차이를 기억해 두세요.",
"starterCode": "-- 부서 번호별 직원 수\nSELECT ",
"functionName": null,
"tests": null,
"setupSql": "CREATE TABLE department (\n id INTEGER PRIMARY KEY, -- 부서 번호\n name TEXT NOT NULL -- 부서 이름\n);\n\nCREATE TABLE employee (\n id INTEGER PRIMARY KEY, -- 사번\n name TEXT NOT NULL, -- 이름\n dept_id INTEGER NOT NULL, -- 소속 부서 번호 (department.id)\n salary INTEGER NOT NULL, -- 연봉 (만원)\n joined_year INTEGER NOT NULL -- 입사 연도\n);\n\nINSERT INTO department (id, name) VALUES\n (1, '개발팀'), (2, '디자인팀'), (3, '경영지원팀');\n\nINSERT INTO employee (id, name, dept_id, salary, joined_year) VALUES\n (1, '김하늘', 1, 3200, 2023),\n (2, '이준호', 1, 4100, 2021),\n (3, '박서연', 2, 3600, 2022),\n (4, '최민준', 1, 2900, 2024),\n (5, '정유진', 2, 3300, 2023),\n (6, '한지우', 3, 3800, 2020),\n (7, '오세훈', 3, 2700, 2024),\n (8, '서지민', 1, 4500, 2019);",
"sqlExpected": {
"expectedColumns": [
"dept_id",
"count"
],
"expectedRows": [
[
1,
4
],
[
2,
2
],
[
3,
2
]
],
"orderMatters": false
}
},
{
"language": "SQL",
"title": "두 테이블 이어 붙이기 (JOIN)",
"difficulty": "HARD",
"topic": "sql",
"description": "모든 직원의 이름과 \"부서 이름\"을 나란히 조회하세요.\n결과 컬럼: 직원 이름, 부서 이름 — 2개 컬럼이어야 합니다.\n\n부서 이름은 employee에 없고 department에 있습니다. 두 테이블을 이어야 해요.\n\n힌트: JOIN department ON employee.dept_id = department.id\n두 테이블에 같은 이름의 컬럼(name)이 있으니 employee.name 처럼 테이블을 붙여 구분하세요.\n테이블 이름이 길면 FROM employee e 처럼 별칭(alias)을 쓸 수 있습니다.",
"starterCode": "-- 직원 이름 + 부서 이름\nSELECT ",
"functionName": null,
"tests": null,
"setupSql": "CREATE TABLE department (\n id INTEGER PRIMARY KEY, -- 부서 번호\n name TEXT NOT NULL -- 부서 이름\n);\n\nCREATE TABLE employee (\n id INTEGER PRIMARY KEY, -- 사번\n name TEXT NOT NULL, -- 이름\n dept_id INTEGER NOT NULL, -- 소속 부서 번호 (department.id)\n salary INTEGER NOT NULL, -- 연봉 (만원)\n joined_year INTEGER NOT NULL -- 입사 연도\n);\n\nINSERT INTO department (id, name) VALUES\n (1, '개발팀'), (2, '디자인팀'), (3, '경영지원팀');\n\nINSERT INTO employee (id, name, dept_id, salary, joined_year) VALUES\n (1, '김하늘', 1, 3200, 2023),\n (2, '이준호', 1, 4100, 2021),\n (3, '박서연', 2, 3600, 2022),\n (4, '최민준', 1, 2900, 2024),\n (5, '정유진', 2, 3300, 2023),\n (6, '한지우', 3, 3800, 2020),\n (7, '오세훈', 3, 2700, 2024),\n (8, '서지민', 1, 4500, 2019);",
"sqlExpected": {
"expectedColumns": [
"name",
"name"
],
"expectedRows": [
[
"김하늘",
"개발팀"
],
[
"이준호",
"개발팀"
],
[
"박서연",
"디자인팀"
],
[
"최민준",
"개발팀"
],
[
"정유진",
"디자인팀"
],
[
"한지우",
"경영지원팀"
],
[
"오세훈",
"경영지원팀"
],
[
"서지민",
"개발팀"
]
],
"orderMatters": false
}
}
]

View File

@ -0,0 +1,93 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.CodingProblem;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.CodingProblemRepository;
import dev.awesomedev.mirim.repository.CodingSubmissionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* 파일이 하는 :
* CodingService의 제출 규칙을 검증한다 특히 "언어별 채점 값 처리" 핵심 규칙이다.
*
* 검증하는 규칙:
* (1) 자동채점 언어(JS·SQL) 브라우저가 보낸 채점 값을 저장하고, 맞으면 PASSED.
* (2) 수동채점 언어(JAVA) 브라우저가 보내오든 채점 값을 버린다
* 조작된 "다 통과" 값이 성적처럼 저장되면 되니까.
* (3) 코드는 400.
*/
class CodingServiceTest {
private CodingProblemRepository codingProblemRepository;
private CodingSubmissionRepository codingSubmissionRepository;
private CodingService codingService;
private User student;
@BeforeEach
void setUp() {
codingProblemRepository = mock(CodingProblemRepository.class);
codingSubmissionRepository = mock(CodingSubmissionRepository.class);
codingService = new CodingService(codingProblemRepository, codingSubmissionRepository);
student = new User("student1", "해시", "수습생1", "STUDENT", "DEV");
}
private CodingProblem problemOf(String language) {
return new CodingProblem(language, "문제", "EASY", "topic", "설명", "뼈대", null, null, null, 0);
}
@Test
@DisplayName("SQL 문제: 채점 값이 저장되고, 다 맞으면 상태가 PASSED가 된다")
void submit_sql_keeps_grading_and_passes() {
CodingProblem problem = problemOf("SQL");
when(codingProblemRepository.findById(1L)).thenReturn(Optional.of(problem));
when(codingSubmissionRepository.findByProblemAndUser(problem, student)).thenReturn(Optional.empty());
when(codingSubmissionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
codingService.submit(student, 1L, "SELECT 1", 1, 1, "[{\"passed\":true}]");
ArgumentCaptor<CodingSubmission> captor = ArgumentCaptor.forClass(CodingSubmission.class);
verify(codingSubmissionRepository).save(captor.capture());
CodingSubmission saved = captor.getValue();
assertEquals(1, saved.getPassedCount());
assertEquals("PASSED", saved.getStatus());
}
@Test
@DisplayName("JAVA 문제: 브라우저가 '다 통과'라고 보내와도 채점 값을 버린다 (조작 방지)")
void submit_java_discards_grading() {
CodingProblem problem = problemOf("JAVA");
when(codingProblemRepository.findById(1L)).thenReturn(Optional.of(problem));
when(codingSubmissionRepository.findByProblemAndUser(problem, student)).thenReturn(Optional.empty());
when(codingSubmissionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// 개발자도구로 요청을 조작해 "5/5 통과"라고 보낸 상황
codingService.submit(student, 1L, "class Solution {}", 5, 5, "[]");
ArgumentCaptor<CodingSubmission> captor = ArgumentCaptor.forClass(CodingSubmission.class);
verify(codingSubmissionRepository).save(captor.capture());
CodingSubmission saved = captor.getValue();
assertNull(saved.getPassedCount()); // 채점 값은 버려졌다
assertEquals("SUBMITTED", saved.getStatus()); // PASSED가 아니라 멘토 확인 대기
}
@Test
@DisplayName("빈 코드를 제출하면 400을 던진다")
void submit_rejects_blank_code() {
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
() -> codingService.submit(student, 1L, " ", null, null, null));
assertEquals(400, ex.getStatusCode().value());
verify(codingSubmissionRepository, never()).save(any());
}
}

View File

@ -11,7 +11,8 @@
"axios": "^1.7.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
"react-router-dom": "^7.1.0",
"sql.js": "^1.14.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
@ -2049,6 +2050,12 @@
"node": ">=0.10.0"
}
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",

View File

@ -12,7 +12,8 @@
"axios": "^1.7.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
"react-router-dom": "^7.1.0",
"sql.js": "^1.14.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",

View File

@ -171,7 +171,7 @@ export default function MentorCodingSection() {
<td>{s.problemTitle}</td>
<td>
<span className={`lang-badge lang-badge--${s.language.toLowerCase()}`}>
{s.language === 'JS' ? 'JavaScript' : 'Java'}
{{ JS: 'JavaScript', SQL: 'SQL', JAVA: 'Java' }[s.language] ?? s.language}
</span>
</td>
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>

View File

@ -0,0 +1,91 @@
/**
* 파일이 하는 :
* 학생이 작성한 SQL을 브라우저 안에서 실행하고, 기대 결과와 비교해 채점한다.
*
* 핵심 아이디어 sql.js (SQLite를 WebAssembly로 컴파일한 ).
* 학습 포인트: "SQL을 실행한다" 하면 서버 DB부터 떠올리기 쉽지만, 그건 위험하다.
* 학생 SQL에 DROP TABLE이 들어 있으면? 서버가 학생마다 DB를 만들어 줘야 하나?
* sql.js는 진짜 SQLite 엔진을 브라우저 안에서 돌린다. 문제를 때마다
* 메모리 위에 1회용 DB를 새로 만들고, 닫으면 사라진다.
* 무엇을 실행하든 서버에는 아무 일도 일어나지 않는다. DROP도 DELETE도 자유.
* runJs.js(Web Worker) 같은 철학이다: "남의 코드는 그 사람의 브라우저에서만".
*
* JS 채점(runJs.js) 달리 Web Worker를 쓰지 않는 이유:
* SELECT는 코드가 아니라 질의라서 무한루프를 만들기가 어렵고(재귀 CTE 정도가 예외),
* 데이터가 실행이 순식간이다. Worker로 감싸는 복잡함이 이득보다 크다.
* 같은 문제(격리)라도 위험의 크기에 따라 장치의 무게를 달리한다.
*/
import initSqlJs from 'sql.js';
// 학습 포인트: ?url 은 Vite 문법 — "이 파일을 번들에 포함하고, 그 주소(URL)를 달라".
// WASM은 JS처럼 import할 수 없어서, 주소를 sql.js에 알려 주고 직접 내려받게 한다.
import sqlWasmUrl from 'sql.js/dist/sql-wasm.wasm?url';
/** WASM 로딩은 느리다(수백 KB) — 한 번만 하고 계속 재사용한다(캐싱). */
let sqlJsPromise = null;
function loadSqlJs() {
if (!sqlJsPromise) {
sqlJsPromise = initSqlJs({ locateFile: () => sqlWasmUrl });
}
return sqlJsPromise;
}
/**
* 준비 SQL(테이블+데이터) 1회용 DB에서 학생 쿼리를 실행한다.
*
* @param {string} setupSql CREATE TABLE + INSERT (문제가 제공)
* @param {string} query 학생이 작성한 SQL
* @returns {Promise<{columns?: string[], rows?: Array[], error?: string}>}
*/
export async function runSql(setupSql, query) {
const SQL = await loadSqlJs();
const db = new SQL.Database(); // 메모리 위 1회용 DB — 새 것으로 시작
try {
db.run(setupSql);
// exec는 [{columns, values}] 배열을 돌려준다. 문장이 여러 개면 결과도 여러 개인데,
// 채점은 "마지막 SELECT의 결과"로 한다 — 학생이 확인용 문장을 앞에 써도 괜찮도록.
const results = db.exec(query);
if (results.length === 0) {
return { error: '결과가 없습니다. SELECT 문을 작성했는지 확인해 보세요.' };
}
const last = results[results.length - 1];
return { columns: last.columns, rows: last.values };
} catch (e) {
// SQLite의 에러 메시지는 영어지만 원문 그대로 보여준다 —
// "near \"FORM\": syntax error" 같은 메시지를 읽어내는 것도 배워야 할 기술이다.
return { error: 'SQL 오류: ' + e.message };
} finally {
db.close(); // WASM 메모리는 가비지 컬렉터가 못 치운다 — 반드시 직접 닫는다.
}
}
/**
* 실행 결과를 기대 결과와 비교한다.
*
* 채점 규칙:
* - 값만 비교한다. 컬럼 "이름" 비교하지 않는다 SELECT salary AS 연봉 처럼
* 별칭을 붙여도 정답이다. (컬럼 개수가 다르면 내용이 달라져 어차피 틀린다)
* - orderMatters가 false면 순서는 무시한다. ORDER BY를 요구하는 문제만 true로 둔다.
*
* 학습 포인트: 순서를 무시하는 비교는 "정렬해서 비교" 구현한다.
* 결과를 같은 규칙으로 정렬해 놓으면, 내용이 같은지 순서와 무관하게 확인할 있다.
*
* @param {{columns: string[], rows: Array[]}} actual 학생 쿼리의 실행 결과
* @param {{expectedRows: Array[], orderMatters: boolean}} expected 문제의 기대 결과
* @returns {boolean}
*/
export function gradeSql(actual, expected) {
// 행을 JSON 문자열로 바꿔 비교한다 — runJs.js의 배열 비교와 같은 요령이다.
const normalize = (rows) => rows.map((row) => JSON.stringify(row));
const actualRows = normalize(actual.rows);
const expectedRows = normalize(expected.expectedRows);
if (actualRows.length !== expectedRows.length) {
return false;
}
if (!expected.orderMatters) {
actualRows.sort();
expectedRows.sort();
}
return actualRows.every((row, i) => row === expectedRows[i]);
}

View File

@ -89,7 +89,7 @@ export default function CodingPage() {
<div className="coding-item__title">{problem.title}</div>
<div className="coding-item__meta">
<span className={`lang-badge lang-badge--${problem.language.toLowerCase()}`}>
{problem.language === 'JS' ? 'JavaScript' : 'Java'}
{{ JS: 'JavaScript', SQL: 'SQL', JAVA: 'Java' }[problem.language] ?? problem.language}
</span>
<span className="coding-item__difficulty">
{DIFFICULTY_LABEL[problem.difficulty] ?? problem.difficulty}

View File

@ -3,14 +3,39 @@
// :
// - JS : [] (Web Worker) / .
// [] + .
// - SQL : [] SQLite(sql.js) ,
// / . DB .
// - JAVA : . .
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import client from '../api/client';
import CodeEditor from '../components/CodeEditor';
import { runJs } from '../lib/runJs';
import { runSql, gradeSql } from '../lib/runSql';
const DIFFICULTY_LABEL = { EASY: '쉬움', NORMAL: '보통', HARD: '어려움' };
const LANGUAGE_LABEL = { JS: 'JavaScript', SQL: 'SQL', JAVA: 'Java' };
/** SQL 실행 결과를 표로 보여주는 작은 부품 (실행 결과·기대 결과 양쪽에서 쓴다) */
function SqlResultTable({ columns, rows }) {
return (
<div style={{ overflowX: 'auto' }}>
<table className="sql-table">
<thead>
<tr>{columns.map((c, i) => <th key={i}>{c}</th>)}</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => <td key={j}>{cell === null ? 'NULL' : String(cell)}</td>)}
</tr>
))}
</tbody>
</table>
<p className="muted" style={{ fontSize: 12.5, marginTop: 4 }}>{rows.length}</p>
</div>
);
}
export default function CodingProblemPage() {
const { id } = useParams();
@ -18,6 +43,7 @@ export default function CodingProblemPage() {
const [problem, setProblem] = useState(null);
const [code, setCode] = useState('');
const [results, setResults] = useState(null); // [{name, passed, expected, actual}]
const [sqlOutput, setSqlOutput] = useState(null); // SQL {columns, rows, passed}
const [runError, setRunError] = useState(''); // ·
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
@ -57,13 +83,49 @@ export default function CodingProblemPage() {
}, [id]);
const isJs = problem?.language === 'JS';
const tests = problem?.testsJson ? JSON.parse(problem.testsJson) : [];
const isSql = problem?.language === 'SQL';
// testsJson JS , SQL
const tests = isJs && problem?.testsJson ? JSON.parse(problem.testsJson) : [];
const sqlExpected = isSql && problem?.testsJson ? JSON.parse(problem.testsJson) : null;
/**
* SQL을 실행하고 채점한다. [실행] [제출] 양쪽에서 쓴다.
* 결과 요약은 JS와 같은 형식([{name, passed, ...}])으로도 만든다
* 서버 저장·멘토 화면·재방문 복원이 언어 구분 없이 가지 형식으로 굴러가게 하기 위해서다.
*/
async function runAndGradeSql() {
const outcome = await runSql(problem.setupSql, code);
if (outcome.error) {
return { error: outcome.error };
}
const passed = gradeSql(outcome, sqlExpected);
const summary = [{
name: '쿼리 결과가 기대 결과와 일치',
passed,
expected: `${sqlExpected.expectedRows.length}`,
actual: `${outcome.rows.length}`,
}];
return { outcome: { ...outcome, passed }, summary };
}
/** [실행] — 브라우저에서 테스트를 돌린다 (서버에 저장하지 않는다) */
async function handleRun() {
setRunning(true);
setRunError('');
setMessage('');
if (isSql) {
const { error: sqlError, outcome, summary } = await runAndGradeSql();
if (sqlError) {
setRunError(sqlError);
setSqlOutput(null);
setResults(null);
} else {
setSqlOutput(outcome);
setResults(summary);
}
setRunning(false);
return;
}
const outcome = await runJs(code, problem.functionName, tests);
if (outcome.error) {
setRunError(outcome.error);
@ -82,7 +144,24 @@ export default function CodingProblemPage() {
try {
let payload = { code, passedCount: null, totalCount: null, resultsJson: null };
if (isJs) {
if (isSql) {
const { error: sqlError, outcome, summary } = await runAndGradeSql();
if (sqlError) {
// SQL JS .
setRunError(sqlError);
setSqlOutput(null);
setSubmitting(false);
return;
}
setSqlOutput(outcome);
setResults(summary);
payload = {
code,
passedCount: outcome.passed ? 1 : 0,
totalCount: 1,
resultsJson: JSON.stringify(summary),
};
} else if (isJs) {
const outcome = await runJs(code, problem.functionName, tests);
if (outcome.error) {
// .
@ -133,7 +212,7 @@ export default function CodingProblemPage() {
<section className="hero compact">
<div className="chip-row">
<span className={`lang-badge lang-badge--${problem.language.toLowerCase()}`}>
{isJs ? 'JavaScript' : 'Java'}
{LANGUAGE_LABEL[problem.language] ?? problem.language}
</span>
<span className="coding-item__difficulty">
{DIFFICULTY_LABEL[problem.difficulty] ?? problem.difficulty}
@ -148,12 +227,24 @@ export default function CodingProblemPage() {
<p className="coding-description">{problem.description}</p>
</div>
{/* SQL 문제: 테이블 구조와 데이터를 먼저 보여준다 — 데이터를 모르고 쿼리를 쓸 수는 없다 */}
{isSql && problem.setupSql && (
<div className="card">
<details>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>
데이터 살펴보기 (테이블 구조 + 들어 있는 데이터)
</summary>
<pre className="sql-setup">{problem.setupSql}</pre>
</details>
</div>
)}
<div className="card">
<h2>코드 작성</h2>
<CodeEditor value={code} onChange={setCode} />
<div className="coding-actions">
{isJs && (
{(isJs || isSql) && (
<button type="button" className="btn" onClick={handleRun} disabled={running || submitting}>
{running ? '실행 중…' : '실행해 보기'}
</button>
@ -172,6 +263,7 @@ export default function CodingProblemPage() {
onClick={() => {
setCode(problem.starterCode);
setResults(null);
setSqlOutput(null);
setRunError('');
}}
disabled={submitting || running}
@ -185,8 +277,34 @@ export default function CodingProblemPage() {
{error && <p className="error-text">{error}</p>}
</div>
{/* JS 문제: 테스트별 채점 결과 */}
{isJs && results && (
{/* SQL 문제: 실행 결과 표 vs 기대 결과 표 — 눈으로 비교하며 고칠 수 있게 */}
{isSql && sqlOutput && (
<div className="card">
<h2>
실행 결과{' '}
<span className={sqlOutput.passed ? 'ok' : 'ng'}>
{sqlOutput.passed ? '통과' : '기대 결과와 다름'}
</span>
</h2>
<SqlResultTable columns={sqlOutput.columns} rows={sqlOutput.rows} />
{!sqlOutput.passed && sqlExpected && (
<>
<h3 style={{ marginTop: 16 }}>기대 결과</h3>
<SqlResultTable columns={sqlExpected.expectedColumns} rows={sqlExpected.expectedRows} />
{!sqlExpected.orderMatters && (
<p className="muted" style={{ fontSize: 12.5 }}>
문제는 순서는 채점하지 않아요 값이 같으면 통과입니다.
</p>
)}
</>
)}
</div>
)}
{/* JS : .
SQL 문제도 재방문 시에는 저장된 요약이 형식이라 여기로 보여준다
(다시 [실행]하면 위의 결과 표로 바뀐다). */}
{(isJs || (isSql && !sqlOutput)) && results && (
<div className="card">
<h2>
채점 결과{' '}
@ -211,7 +329,7 @@ export default function CodingProblemPage() {
)}
{/* 자바 문제: 자동채점이 없다는 걸 분명히 알려 준다 */}
{!isJs && (
{problem.language === 'JAVA' && (
<div className="card">
<h2>채점 안내</h2>
<p>

View File

@ -950,6 +950,40 @@ inline-code, .icode {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
.lang-badge--sql {
background: color-mix(in srgb, var(--teal) 12%, transparent);
color: var(--teal);
}
/* SQL 문제 — 실행 결과 표 */
.sql-table {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
.sql-table th,
.sql-table td {
border: 1px solid var(--line);
padding: 6px 10px;
text-align: left;
}
.sql-table th {
background: var(--surface-2, var(--primary-soft));
font-weight: 700;
}
/* SQL 문제 — 준비 SQL(테이블+데이터) 보기 */
.sql-setup {
margin-top: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-2, var(--primary-soft));
font-size: 12.5px;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
}
/* 풀이 상태 배지 */
.status-badge {