feat: 코딩 문제 20문항 — 브라우저 자동채점 + 멘토 코드 리뷰

읽고 아는 것과 짤 수 있는 것을 나눠 확인하는 세 번째 축(과제·퀴즈에 이어).

- 문제 20개: JS 12(자동채점) + Java 8(멘토 리뷰), 난이도 EASY 8/NORMAL 8/HARD 4
- JS 채점은 Web Worker에서 실행 — 서버 샌드박스 없이 무한루프(2초 타임아웃)·
  DOM 접근을 막는다. 테스트 48건은 모범답안으로 전수 검증함
- Java는 실행하지 않음(격리 비용·보안). 코드를 멘토가 읽고 피드백.
  서버가 자바 제출의 클라이언트 채점값을 버리므로 위조 불가
- 멘토 화면: 코드 원문·테스트별 통과/실패·학생별 진도·피드백 작성
- (문제, 학생)당 1행 유지 + attempts 증가. 에디터는 무의존성(textarea 기반)

검증: 백엔드 컴파일·프론트 빌드·시드 20건 적재·API 전 흐름(제출/재제출/
권한 403/빈코드 400)·브라우저 실사용(오답 3/4 → 정답 4/4 → 멘토 조회)
This commit is contained in:
AWESOMEDEV 2026-07-17 00:27:42 +09:00
parent e53b318ddf
commit 5a82ecb291
17 changed files with 1989 additions and 0 deletions

View File

@ -4,11 +4,13 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.awesomedev.mirim.domain.Assignment;
import dev.awesomedev.mirim.domain.ChecklistItem;
import dev.awesomedev.mirim.domain.CodingProblem;
import dev.awesomedev.mirim.domain.Document;
import dev.awesomedev.mirim.domain.QuizQuestion;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.AssignmentRepository;
import dev.awesomedev.mirim.repository.ChecklistItemRepository;
import dev.awesomedev.mirim.repository.CodingProblemRepository;
import dev.awesomedev.mirim.repository.DocumentRepository;
import dev.awesomedev.mirim.repository.QuizQuestionRepository;
import dev.awesomedev.mirim.repository.UserRepository;
@ -43,6 +45,7 @@ public class SeedDataLoader implements ApplicationRunner {
private final ChecklistItemRepository checklistItemRepository;
private final AssignmentRepository assignmentRepository;
private final QuizQuestionRepository quizQuestionRepository;
private final CodingProblemRepository codingProblemRepository;
private final PasswordEncoder passwordEncoder;
private final ObjectMapper objectMapper;
@ -51,6 +54,7 @@ public class SeedDataLoader implements ApplicationRunner {
ChecklistItemRepository checklistItemRepository,
AssignmentRepository assignmentRepository,
QuizQuestionRepository quizQuestionRepository,
CodingProblemRepository codingProblemRepository,
PasswordEncoder passwordEncoder,
ObjectMapper objectMapper) {
this.userRepository = userRepository;
@ -58,6 +62,7 @@ public class SeedDataLoader implements ApplicationRunner {
this.checklistItemRepository = checklistItemRepository;
this.assignmentRepository = assignmentRepository;
this.quizQuestionRepository = quizQuestionRepository;
this.codingProblemRepository = codingProblemRepository;
this.passwordEncoder = passwordEncoder;
this.objectMapper = objectMapper;
}
@ -69,6 +74,7 @@ public class SeedDataLoader implements ApplicationRunner {
seedChecklist();
seedAssignments();
seedQuizzes();
seedCodingProblems();
}
/**
@ -158,6 +164,39 @@ public class SeedDataLoader implements ApplicationRunner {
log.info("퀴즈 시드 {}건을 저장했습니다.", seeds.size());
}
/**
* 코딩 문제 시드.
* 학습 포인트: 테스트케이스는 JSON 배열 그대로 문자열로 넣는다(CodingProblem.testsJson).
* 시드 파일에서는 사람이 읽기 좋은 객체로 두고, 저장할 다시 문자열로 직렬화한다
* "파일에서 편한 모양" "DB에 담는 모양" 달라도 되며, 변환이 여기서 일어난다.
*/
private void seedCodingProblems() {
if (codingProblemRepository.count() > 0) {
return;
}
List<CodingProblemSeed> seeds = readSeedFile("seed/coding-problems.json", new TypeReference<>() {});
if (seeds == null) {
return;
}
int sortOrder = 0;
for (CodingProblemSeed seed : seeds) {
String testsJson = null;
if (seed.tests() != null) {
try {
testsJson = objectMapper.writeValueAsString(seed.tests());
} 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++));
}
log.info("코딩 문제 시드 {}건을 저장했습니다.", seeds.size());
}
/**
* classpath에서 시드 JSON 파일을 읽는다.
* 학습 포인트: 파일이 없어도 앱이 죽지 않고 경고 로그만 남기고 넘어간다.
@ -197,4 +236,14 @@ public class SeedDataLoader implements ApplicationRunner {
String option1, String option2, String option3, String option4,
Integer answerIndex, String explanation) {
}
/** coding-problems.json 한 건 (tests는 JAVA 문제면 null) */
record CodingProblemSeed(String language, String title, String difficulty, String topic,
String description, String starterCode, String functionName,
List<TestCaseSeed> tests) {
}
/** 테스트케이스 한 건 — args를 함수에 넣었을 때 expected가 나와야 한다 */
record TestCaseSeed(String name, List<Object> args, Object expected) {
}
}

View File

@ -0,0 +1,137 @@
package dev.awesomedev.mirim.domain;
import jakarta.persistence.*;
/**
* 파일이 하는 :
* 코딩 문제 하나를 담는 JPA 엔티티다.
* 퀴즈(4지선다) "아는지" 묻는다면, 코딩 문제는 "짤 수 있는지" 묻는다.
*
* 언어(language) 따라 채점 방식이 다르다:
* - JS : 브라우저(Web Worker) 테스트케이스를 돌려 즉시 통과/실패를 가린다.
* - JAVA : 실행하지 않는다. 코드를 제출하면 멘토가 직접 읽고 피드백한다.
*
* 학습 포인트: 자바는 서버에서 실행하지 않을까?
* 남이 보낸 코드를 서버에서 그대로 돌리는 "내 집 열쇠를 모르는 사람에게 주는 것" 같다.
* 무한루프로 CPU를 잡아먹거나(System.exit로 서버를 끄거나) 파일을 뒤질 있다.
* 안전하게 하려면 Docker 같은 격리된 방을 제출마다 만들어야 하는데, 비용과 복잡도가 크다.
* 그래서 우리 규모에서는 "자바는 사람(멘토)이 읽는다" 가장 합리적인 선택이다.
* 기술적으로 가능한 것과 지금 규모에 맞는 것은 다르다.
*/
@Entity
@Table(name = "coding_problem")
public class CodingProblem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 문제 언어: JS | JAVA */
@Column(nullable = false, length = 8)
private String language;
/** 문제 제목 */
@Column(nullable = false)
private String title;
/** 난이도: EASY | NORMAL | HARD */
@Column(nullable = false, length = 8)
private String difficulty;
/** 관련 학습 주제(코스 slug 느낌의 분류 — 예: javascript, java-basics) */
@Column(name = "topic", nullable = false, length = 60)
private String topic;
/** 문제 설명 (마크다운 아님 — 줄바꿈만 살려서 그대로 보여준다) */
@Column(nullable = false, columnDefinition = "text")
private String description;
/** 학생 화면에 처음 채워져 있는 뼈대 코드 */
@Column(name = "starter_code", nullable = false, columnDefinition = "text")
private String starterCode;
/**
* 학생이 구현해야 하는 함수 이름 (JS 전용 채점기가 이름으로 함수를 찾아 호출한다).
* JAVA 문제는 실행하지 않으므로 null.
*/
@Column(name = "function_name", length = 60)
private String functionName;
/**
* 테스트케이스 묶음을 JSON 문자열로 통째로 담는다 (JS 전용, JAVA는 null).
* 형식: [{"name":"설명","args":[1,2],"expected":3}, ...]
*
* 학습 포인트: 테스트케이스용 테이블을 따로 만들지 않고 JSON 덩어리로 넣을까?
* 테스트케이스는 (1) 항상 문제와 함께 통째로 읽고, (2) 따로 검색하거나 조인할 일이 없다.
* 이럴 테이블을 쪼개는 것보다 JSON으로 두는 편이 단순하다.
* 반대로 "테스트케이스별 통과율 통계"처럼 개별 조회가 필요해지면 그때 테이블로 승격하면 된다.
*/
@Column(name = "tests_json", columnDefinition = "text")
private String testsJson;
/** 목록에서의 정렬 순서 */
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
protected CodingProblem() {
}
public CodingProblem(String language, String title, String difficulty, String topic,
String description, String starterCode, String functionName,
String testsJson, Integer sortOrder) {
this.language = language;
this.title = title;
this.difficulty = difficulty;
this.topic = topic;
this.description = description;
this.starterCode = starterCode;
this.functionName = functionName;
this.testsJson = testsJson;
this.sortOrder = sortOrder;
}
/** 브라우저에서 자동채점이 가능한 문제인가 (= JS 문제인가) */
public boolean isAutoGraded() {
return "JS".equals(language);
}
public Long getId() {
return id;
}
public String getLanguage() {
return language;
}
public String getTitle() {
return title;
}
public String getDifficulty() {
return difficulty;
}
public String getTopic() {
return topic;
}
public String getDescription() {
return description;
}
public String getStarterCode() {
return starterCode;
}
public String getFunctionName() {
return functionName;
}
public String getTestsJson() {
return testsJson;
}
public Integer getSortOrder() {
return sortOrder;
}
}

View File

@ -0,0 +1,155 @@
package dev.awesomedev.mirim.domain;
import jakarta.persistence.*;
import java.time.Instant;
/**
* 파일이 하는 :
* 학생이 코딩 문제에 대해 제출한 코드 건을 담는 JPA 엔티티다.
* 같은 문제를 다시 풀면 행을 만들지 않고 기존 행을 갱신한다(Submission과 같은 방식).
*
* 학습 포인트: (학생, 문제) 행만 둘까?
* 제출을 쌓으면 "이 학생이 이 문제를 풀었나?" 때마다 최신 행을 골라내야 하고,
* 멘토 화면도 같은 문제가 여러 나와 지저분해진다. 지금 필요한 "현재 상태"이지 이력이 아니다.
* 훗날 "몇 번 만에 통과했나" 궁금해지면 시도 횟수(attempts) 세면 되고,
* 코드 이력 전체가 필요해지면 그때 별도 이력 테이블을 만들면 된다.
*/
@Entity
@Table(name = "coding_submission",
uniqueConstraints = @UniqueConstraint(columnNames = {"problem_id", "user_id"}))
public class CodingSubmission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 어떤 문제에 대한 제출인지. DB에는 problem_id 외래키 컬럼이 된다. */
@ManyToOne(optional = false)
@JoinColumn(name = "problem_id")
private CodingProblem problem;
/** 제출한 학생. DB에는 user_id 외래키 컬럼이 된다. */
@ManyToOne(optional = false)
@JoinColumn(name = "user_id")
private User user;
/** 학생이 작성한 코드 원문 — 멘토가 그대로 읽는다 */
@Column(nullable = false, columnDefinition = "text")
private String code;
/**
* 상태: SUBMITTED(제출됨) | PASSED(자동채점 전부 통과) | REVIEWED(멘토 피드백 완료)
* JAVA 문제는 자동채점이 없으므로 SUBMITTED REVIEWED로만 움직인다.
*/
@Column(nullable = false, length = 12)
private String status;
/** 통과한 테스트 수 (JAVA 문제는 null) */
@Column(name = "passed_count")
private Integer passedCount;
/** 전체 테스트 수 (JAVA 문제는 null) */
@Column(name = "total_count")
private Integer totalCount;
/**
* 테스트별 채점 결과를 JSON 문자열로 담는다 (JS 전용).
* 형식: [{"name":"1+2=3","passed":true,"expected":"3","actual":"3"}, ...]
* 멘토가 "어디서 틀렸는지" 있게 하기 위한 것이다.
*/
@Column(name = "results_json", columnDefinition = "text")
private String resultsJson;
/** 시도 횟수 — 제출할 때마다 1씩 는다 */
@Column(nullable = false)
private Integer attempts;
/** 제출(또는 재제출) 시각 */
@Column(name = "submitted_at", nullable = false)
private Instant submittedAt;
/** 멘토의 피드백. 아직 리뷰 전이면 null. */
@Column(columnDefinition = "text")
private String feedback;
protected CodingSubmission() {
}
public CodingSubmission(CodingProblem problem, User user, String code,
Integer passedCount, Integer totalCount, String resultsJson) {
this.problem = problem;
this.user = user;
this.attempts = 0;
submit(code, passedCount, totalCount, resultsJson);
}
/**
* 제출(또는 재제출): 코드와 채점 결과를 갱신하고 시도 횟수를 올린다.
*
* 학습 포인트: 상태를 바깥에서 set으로 바꾸지 않고 메서드 안에서만 정한다.
* "전부 통과했으면 PASSED"라는 규칙이 엔티티 안에 번만 쓰이므로,
* 규칙이 바뀌어도 고칠 곳이 곳뿐이다. (피드백은 이전 것을 지운다 코드가 바뀌었으니까.)
*/
public void submit(String code, Integer passedCount, Integer totalCount, String resultsJson) {
this.code = code;
this.passedCount = passedCount;
this.totalCount = totalCount;
this.resultsJson = resultsJson;
this.attempts = this.attempts + 1;
this.submittedAt = Instant.now();
this.feedback = null;
boolean allPassed = totalCount != null && totalCount > 0 && totalCount.equals(passedCount);
this.status = allPassed ? "PASSED" : "SUBMITTED";
}
/** 멘토 피드백 등록: 피드백을 저장하고 상태를 REVIEWED로 바꾼다. */
public void review(String feedback) {
this.feedback = feedback;
this.status = "REVIEWED";
}
public Long getId() {
return id;
}
public CodingProblem getProblem() {
return problem;
}
public User getUser() {
return user;
}
public String getCode() {
return code;
}
public String getStatus() {
return status;
}
public Integer getPassedCount() {
return passedCount;
}
public Integer getTotalCount() {
return totalCount;
}
public String getResultsJson() {
return resultsJson;
}
public Integer getAttempts() {
return attempts;
}
public Instant getSubmittedAt() {
return submittedAt;
}
public String getFeedback() {
return feedback;
}
}

View File

@ -0,0 +1,15 @@
package dev.awesomedev.mirim.repository;
import dev.awesomedev.mirim.domain.CodingProblem;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 파일이 하는 : coding_problem 테이블의 DB 접근 창구.
*/
public interface CodingProblemRepository extends JpaRepository<CodingProblem, Long> {
/** 문제 목록 (정렬 순서대로) */
List<CodingProblem> findAllByOrderBySortOrderAsc();
}

View File

@ -0,0 +1,28 @@
package dev.awesomedev.mirim.repository;
import dev.awesomedev.mirim.domain.CodingProblem;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 파일이 하는 : coding_submission 테이블의 DB 접근 창구.
*/
public interface CodingSubmissionRepository extends JpaRepository<CodingSubmission, Long> {
/**
* 학생이 문제에 제출물 (있으면).
* 학습 포인트: (문제, 학생) 행만 두기로 했으므로 List가 아니라 Optional이다.
* 엔티티의 uniqueConstraint와 반환 타입이 같은 약속을 서로 다른 층에서 표현한다.
*/
Optional<CodingSubmission> findByProblemAndUser(CodingProblem problem, User user);
/** 이 학생의 모든 제출물 */
List<CodingSubmission> findByUser(User user);
/** 전체 제출물 — 멘토 화면용 (최신 제출 먼저) */
List<CodingSubmission> findAllByOrderBySubmittedAtDesc();
}

View File

@ -0,0 +1,124 @@
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.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 파일이 하는 :
* 코딩 문제의 비즈니스 로직 문제 조회, 제출 저장, 진도 요약, 멘토 피드백.
*/
@Service
public class CodingService {
private final CodingProblemRepository codingProblemRepository;
private final CodingSubmissionRepository codingSubmissionRepository;
public CodingService(CodingProblemRepository codingProblemRepository,
CodingSubmissionRepository codingSubmissionRepository) {
this.codingProblemRepository = codingProblemRepository;
this.codingSubmissionRepository = codingSubmissionRepository;
}
/** 전체 문제 목록 (정렬 순서대로) */
@Transactional(readOnly = true)
public List<CodingProblem> allProblems() {
return codingProblemRepository.findAllByOrderBySortOrderAsc();
}
/** 문제 한 건 — 없으면 404 */
@Transactional(readOnly = true)
public CodingProblem problem(Long id) {
return codingProblemRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "문제를 찾을 수 없습니다."));
}
/** 이 학생의 제출물을 문제 id로 찾아보기 쉽게 Map으로 */
@Transactional(readOnly = true)
public Map<Long, CodingSubmission> mySubmissionsByProblemId(User user) {
return codingSubmissionRepository.findByUser(user).stream()
.collect(Collectors.toMap(s -> s.getProblem().getId(), Function.identity()));
}
/** 이 학생이 이 문제에 낸 제출물 (없을 수 있음) */
@Transactional(readOnly = true)
public Optional<CodingSubmission> mySubmission(User user, Long problemId) {
return codingSubmissionRepository.findByProblemAndUser(problem(problemId), user);
}
/**
* 코드를 제출한다. 이미 적이 있으면 행을 갱신한다( 행을 만들지 않는다).
*
* 학습 포인트: 채점 결과(passedCount/totalCount) 브라우저가 계산해서 보내온다.
* 마음먹으면 "다 통과했다" 거짓말할 있다 개발자도구로 요청을 조작하면 된다.
* 그런데도 이렇게 만든 이유는, 값의 용도가 "성적" 아니라 "즉시 피드백"이기 때문이다.
* 진짜 판정은 멘토가 코드 원문을 읽고 내린다(그래서 code를 반드시 함께 저장한다).
* 대회 채점기라면 절대 이렇게 하면 된다. QuizQuestion을 보면 정답은 서버에만 두었다.
* "무엇을 지켜야 하는가" 따라 설계가 달라진다는 기능을 비교하며 보자.
*/
@Transactional
public CodingSubmission submit(User user, Long problemId, String code,
Integer passedCount, Integer totalCount, String resultsJson) {
if (code == null || code.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "코드가 비어 있습니다.");
}
CodingProblem problem = problem(problemId);
// 자바 문제는 자동채점이 없다 브라우저가 보내오든 채점 값은 버린다.
Integer passed = problem.isAutoGraded() ? passedCount : null;
Integer total = problem.isAutoGraded() ? totalCount : null;
String results = problem.isAutoGraded() ? resultsJson : null;
return codingSubmissionRepository.findByProblemAndUser(problem, user)
.map(existing -> {
existing.submit(code, passed, total, results);
return existing;
})
.orElseGet(() -> codingSubmissionRepository.save(
new CodingSubmission(problem, user, code, passed, total, results)));
}
/** 멘토 피드백 등록 — 없으면 404 */
@Transactional
public CodingSubmission giveFeedback(Long submissionId, String feedback) {
CodingSubmission submission = codingSubmissionRepository.findById(submissionId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "제출물을 찾을 수 없습니다."));
submission.review(feedback);
return submission;
}
/** 전체 제출물 — 멘토 화면용 (최신순) */
@Transactional(readOnly = true)
public List<CodingSubmission> allSubmissions() {
return codingSubmissionRepository.findAllByOrderBySubmittedAtDesc();
}
/** 학생 한 명의 진도 요약: 전체 문제 수, 제출 수, 통과 수 */
public record ProgressSummary(int total, int submitted, int passed) {
}
/**
* 학생의 진도 요약.
* 학습 포인트: 문제 총수는 count() 번으로 얻고, 제출은 학생 것만 가져온다.
* 학생이 4~5명이라 정도면 충분히 빠르다(MentorController의 N+1 주석과 같은 판단이다).
*/
@Transactional(readOnly = true)
public ProgressSummary progressOf(User user) {
int total = (int) codingProblemRepository.count();
List<CodingSubmission> mine = codingSubmissionRepository.findByUser(user);
int passed = (int) mine.stream().filter(s -> "PASSED".equals(s.getStatus())).count();
return new ProgressSummary(total, mine.size(), passed);
}
}

View File

@ -0,0 +1,110 @@
package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.domain.CodingProblem;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.CodingService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 파일이 하는 :
* 학생용 코딩 문제 API 문제 목록, 문제 상세(+ 제출물), 코드 제출, 진도.
*/
@RestController
@RequestMapping("/api/coding")
public class CodingController {
private final CodingService codingService;
private final AuthService authService;
public CodingController(CodingService codingService, AuthService authService) {
this.codingService = codingService;
this.authService = authService;
}
/**
* 목록용 요약 문제 설명·테스트케이스는 빼고 필요한 것만 담는다.
* 학습 포인트: 목록 20건마다 description을 실어 보내면 응답이 쓸데없이 커진다.
* "화면이 필요로 하는 만큼만 준다" DTO를 따로 두는 이유다.
*/
public record ProblemSummary(Long id, String language, String title, String difficulty,
String topic, String status, Integer passedCount, Integer totalCount) {
}
/** GET /api/coding/problems — 문제 목록 + 내 풀이 상태 */
@GetMapping("/problems")
public List<ProblemSummary> problems(Authentication authentication) {
User user = authService.requireUser(authentication);
Map<Long, CodingSubmission> mine = codingService.mySubmissionsByProblemId(user);
return codingService.allProblems().stream()
.map(problem -> {
CodingSubmission submission = mine.get(problem.getId());
return new ProblemSummary(
problem.getId(), problem.getLanguage(), problem.getTitle(),
problem.getDifficulty(), problem.getTopic(),
submission == null ? "TODO" : submission.getStatus(),
submission == null ? null : submission.getPassedCount(),
submission == null ? null : submission.getTotalCount());
})
.toList();
}
/** 문제 상세 — 테스트케이스(testsJson)는 브라우저가 채점해야 하므로 함께 내려준다 */
public record ProblemDetail(Long id, String language, String title, String difficulty,
String topic, String description, String starterCode,
String functionName, String testsJson,
String myCode, String status, Integer passedCount,
Integer totalCount, String resultsJson, Integer attempts,
String feedback) {
}
/** GET /api/coding/problems/{id} — 문제 상세 + 내가 마지막에 낸 코드/결과 */
@GetMapping("/problems/{id}")
public ProblemDetail problem(@PathVariable Long id, Authentication authentication) {
User user = authService.requireUser(authentication);
CodingProblem problem = codingService.problem(id);
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(),
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(),
null, "TODO", null, null, null, 0, null));
}
/** 제출 요청 — 코드와, 브라우저가 매긴 채점 결과(JS 문제일 때) */
public record SubmitRequest(String code, Integer passedCount, Integer totalCount, String resultsJson) {
}
/** 제출 응답 */
public record SubmitResponse(Long id, String status, Integer passedCount, Integer totalCount,
Integer attempts) {
}
/** POST /api/coding/problems/{id}/submit — 코드 제출(재제출이면 기존 행 갱신) */
@PostMapping("/problems/{id}/submit")
public SubmitResponse submit(@PathVariable Long id,
@RequestBody SubmitRequest request,
Authentication authentication) {
User user = authService.requireUser(authentication);
CodingSubmission submission = codingService.submit(user, id, request.code(),
request.passedCount(), request.totalCount(), request.resultsJson());
return new SubmitResponse(submission.getId(), submission.getStatus(),
submission.getPassedCount(), submission.getTotalCount(), submission.getAttempts());
}
/** GET /api/coding/progress/me — 내 진도(전체/제출/통과) */
@GetMapping("/progress/me")
public CodingService.ProgressSummary myProgress(Authentication authentication) {
User user = authService.requireUser(authentication);
return codingService.progressOf(user);
}
}

View File

@ -1,7 +1,9 @@
package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.Submission;
import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.ProgressService;
import dev.awesomedev.mirim.service.QuizService;
@ -32,17 +34,20 @@ public class MentorController {
private final CourseProgressService courseProgressService;
private final ProgressService progressService;
private final QuizService quizService;
private final CodingService codingService;
private final UserRepository userRepository;
public MentorController(SubmissionService submissionService,
CourseProgressService courseProgressService,
ProgressService progressService,
QuizService quizService,
CodingService codingService,
UserRepository userRepository) {
this.submissionService = submissionService;
this.courseProgressService = courseProgressService;
this.progressService = progressService;
this.quizService = quizService;
this.codingService = codingService;
this.userRepository = userRepository;
}
@ -114,4 +119,60 @@ public class MentorController {
Submission submission = submissionService.giveFeedback(id, feedbackRequest.feedback());
return MentorSubmissionResponse.from(submission);
}
// --- 코딩 문제 ---
/**
* 코딩 제출물 (멘토 화면용).
* 학생이 코드 원문(code) 테스트별 채점 결과(resultsJson) 그대로 담는다
* 멘토가 "무엇을 어떻게 짰고, 어디서 틀렸는지" 화면에서 보게 하기 위해서다.
*/
public record MentorCodingSubmissionResponse(Long id, Long problemId, String problemTitle,
String language, String difficulty,
Long userId, String studentName, String track,
String code, String status,
Integer passedCount, Integer totalCount,
String resultsJson, Integer attempts,
String submittedAt, String feedback) {
static MentorCodingSubmissionResponse from(CodingSubmission s) {
return new MentorCodingSubmissionResponse(
s.getId(), s.getProblem().getId(), s.getProblem().getTitle(),
s.getProblem().getLanguage(), s.getProblem().getDifficulty(),
s.getUser().getId(), s.getUser().getName(), s.getUser().getTrack(),
s.getCode(), s.getStatus(), s.getPassedCount(), s.getTotalCount(),
s.getResultsJson(), s.getAttempts(),
s.getSubmittedAt().toString(), s.getFeedback());
}
}
/** GET /api/mentor/coding-submissions — 전체 코딩 제출물 (최신순) */
@GetMapping("/coding-submissions")
public List<MentorCodingSubmissionResponse> codingSubmissions() {
return codingService.allSubmissions().stream()
.map(MentorCodingSubmissionResponse::from)
.toList();
}
/** 학생 한 명의 코딩 진도 */
public record StudentCodingProgress(Long userId, String name, String track,
CodingService.ProgressSummary progress) {
}
/** GET /api/mentor/coding-progress — 학생별 코딩 진도(전체/제출/통과) */
@GetMapping("/coding-progress")
public List<StudentCodingProgress> codingProgress() {
return userRepository.findByRoleOrderByNameAsc("STUDENT").stream()
.map(student -> new StudentCodingProgress(
student.getId(), student.getName(), student.getTrack(),
codingService.progressOf(student)))
.toList();
}
/** POST /api/mentor/coding-submissions/{id}/feedback — 코드에 피드백 등록 */
@PostMapping("/coding-submissions/{id}/feedback")
public MentorCodingSubmissionResponse giveCodingFeedback(@PathVariable Long id,
@RequestBody FeedbackRequest feedbackRequest) {
return MentorCodingSubmissionResponse.from(
codingService.giveFeedback(id, feedbackRequest.feedback()));
}
}

View File

@ -0,0 +1,262 @@
[
{
"language": "JS",
"title": "두 수의 합",
"difficulty": "EASY",
"topic": "javascript",
"description": "정수 두 개 a, b를 받아 그 합을 돌려주는 함수를 완성하세요.\n\n예) solution(1, 2) → 3\n\n힌트: return 문으로 값을 돌려줍니다. return이 없으면 undefined가 나갑니다.",
"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 }
]
},
{
"language": "JS",
"title": "짝수인지 판별하기",
"difficulty": "EASY",
"topic": "javascript",
"description": "정수 n이 짝수면 true, 홀수면 false를 돌려주세요.\n\n힌트: 나머지 연산자 %를 쓰면 2로 나눈 나머지를 알 수 있습니다. 0은 짝수입니다.",
"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 }
]
},
{
"language": "JS",
"title": "문자열 뒤집기",
"difficulty": "EASY",
"topic": "javascript",
"description": "문자열 s를 거꾸로 뒤집어 돌려주세요.\n\n예) solution('hello') → 'olleh'\n\n힌트: 문자열을 split('')으로 배열로 만들고 reverse() 후 join('')으로 다시 붙일 수 있습니다.",
"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": "다나가" }
]
},
{
"language": "JS",
"title": "배열에서 가장 큰 수 찾기",
"difficulty": "EASY",
"topic": "javascript",
"description": "숫자 배열 numbers에서 가장 큰 값을 돌려주세요. 배열은 항상 1개 이상입니다.\n\n힌트: Math.max(...numbers)처럼 펼침 연산자(...)를 쓰거나, 반복문으로 직접 비교할 수 있습니다.",
"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 }
]
},
{
"language": "JS",
"title": "1부터 n까지 더하기",
"difficulty": "EASY",
"topic": "javascript",
"description": "1부터 n까지의 합을 돌려주세요. n은 1 이상입니다.\n\n예) solution(5) → 15 (1+2+3+4+5)\n\n힌트: for문으로 더해도 되고, 공식 n*(n+1)/2를 써도 됩니다.",
"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 }
]
},
{
"language": "JS",
"title": "모음 개수 세기",
"difficulty": "NORMAL",
"topic": "javascript",
"description": "영어 소문자 문자열 s에서 모음(a, e, i, o, u)이 몇 개인지 세어 돌려주세요.\n\n예) solution('hello') → 2\n\n힌트: 문자열도 for...of로 한 글자씩 돌 수 있습니다. includes()가 유용합니다.",
"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 }
]
},
{
"language": "JS",
"title": "배열에서 짝수만 골라내기",
"difficulty": "NORMAL",
"topic": "javascript",
"description": "숫자 배열에서 짝수만 순서를 유지한 채 새 배열로 돌려주세요.\n\n예) solution([1,2,3,4]) → [2, 4]\n\n힌트: filter()는 조건을 만족하는 원소만 모아 새 배열을 만듭니다. 원본은 바뀌지 않습니다.",
"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] }
]
},
{
"language": "JS",
"title": "FizzBuzz",
"difficulty": "NORMAL",
"topic": "javascript",
"description": "1부터 n까지를 배열로 돌려주되,\n3의 배수는 'Fizz', 5의 배수는 'Buzz', 3과 5의 공배수는 'FizzBuzz'로 바꿔 넣으세요.\n그 외는 숫자 그대로(문자열 아님) 넣습니다.\n\n예) solution(5) → [1, 2, 'Fizz', 4, 'Buzz']\n\n힌트: 15의 배수를 가장 먼저 검사해야 합니다. 순서를 바꾸면 'FizzBuzz'가 절대 나오지 않습니다.",
"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"] }
]
},
{
"language": "JS",
"title": "중복 제거하기",
"difficulty": "NORMAL",
"topic": "javascript",
"description": "배열에서 중복된 값을 없애고, 처음 나온 순서를 유지한 새 배열을 돌려주세요.\n\n예) solution([1,2,2,3,1]) → [1, 2, 3]\n\n힌트: Set은 중복을 자동으로 없애 줍니다. [...new Set(arr)]로 배열로 되돌릴 수 있습니다.",
"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": [] }
]
},
{
"language": "JS",
"title": "회문(팰린드롬) 판별",
"difficulty": "NORMAL",
"topic": "javascript",
"description": "문자열이 앞뒤 어느 쪽에서 읽어도 같으면 true, 아니면 false를 돌려주세요.\n대소문자는 구분하지 않고, 공백은 무시합니다.\n\n예) solution('level') → true, solution('a b a') → true\n\n힌트: 먼저 소문자로 바꾸고 공백을 없앤 다음, 뒤집은 것과 비교하세요. replaceAll(' ', '')이 쓸 만합니다.",
"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 }
]
},
{
"language": "JS",
"title": "단어 개수 세기 (객체 다루기)",
"difficulty": "HARD",
"topic": "javascript",
"description": "공백으로 구분된 문자열에서 각 단어가 몇 번 나오는지 세어 객체로 돌려주세요.\n\n예) solution('a b a') → { a: 2, b: 1 }\n빈 문자열이면 빈 객체 {} 를 돌려주세요.\n\n힌트: split(' ')으로 나누고, 객체에 obj[word] = (obj[word] || 0) + 1 패턴을 쓰면 됩니다.\n(왜 || 0 이 필요할까? 처음 보는 단어면 obj[word]가 undefined이고, undefined + 1은 NaN이기 때문입니다.)",
"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 } }
]
},
{
"language": "JS",
"title": "정렬해서 두 번째로 큰 수 찾기",
"difficulty": "HARD",
"topic": "javascript",
"description": "배열에서 (중복을 제외하고) 두 번째로 큰 수를 돌려주세요.\n두 번째로 큰 수가 없으면 null을 돌려주세요.\n\n예) solution([3,1,3,2]) → 2 (중복 제외하면 3,2,1 이므로 두 번째는 2)\n\n힌트: 중복을 먼저 없애고 내림차순 정렬하세요.\n주의! sort()는 기본이 '문자열' 정렬이라 [10, 9]를 [10, 9]가 아니라 [10, 9]... 헷갈립니다.\n숫자는 반드시 sort((a, b) => b - a)처럼 비교 함수를 줘야 합니다.",
"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 }
]
},
{
"language": "JAVA",
"title": "두 수 중 큰 값 돌려주기",
"difficulty": "EASY",
"topic": "java-basics",
"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
},
{
"language": "JAVA",
"title": "배열의 합 구하기",
"difficulty": "EASY",
"topic": "java-basics",
"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
},
{
"language": "JAVA",
"title": "문자열이 비었는지 안전하게 검사하기",
"difficulty": "EASY",
"topic": "java-basics",
"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
},
{
"language": "JAVA",
"title": "1부터 n까지 짝수만 더하기",
"difficulty": "NORMAL",
"topic": "java-basics",
"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
},
{
"language": "JAVA",
"title": "문자열 뒤집기 (StringBuilder)",
"difficulty": "NORMAL",
"topic": "java-basics",
"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
},
{
"language": "JAVA",
"title": "배열에서 최댓값 찾기",
"difficulty": "NORMAL",
"topic": "java-basics",
"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
},
{
"language": "JAVA",
"title": "단어 개수 세기 (Map 다루기)",
"difficulty": "HARD",
"topic": "java-basics",
"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
},
{
"language": "JAVA",
"title": "학생 목록을 점수순으로 정렬하기",
"difficulty": "HARD",
"topic": "java-basics",
"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
}
]

View File

@ -18,6 +18,8 @@ import DocViewerPage from './pages/DocViewerPage';
import AssignmentsPage from './pages/AssignmentsPage';
import SetupGuidePage from './pages/SetupGuidePage';
import QuizPage from './pages/QuizPage';
import CodingPage from './pages/CodingPage';
import CodingProblemPage from './pages/CodingProblemPage';
import MentorPage from './pages/MentorPage';
// : " " .
@ -71,6 +73,9 @@ function Layout() {
<NavLink to="/assignments" className={({ isActive }) => (isActive ? 'active' : '')}>
과제
</NavLink>
<NavLink to="/coding" className={({ isActive }) => (isActive ? 'active' : '')}>
코딩 문제
</NavLink>
{user?.role === 'MENTOR' && (
<NavLink to="/mentor" className={({ isActive }) => (isActive ? 'active' : '')}>
멘토 리뷰
@ -130,6 +135,9 @@ export default function App() {
<Route path="/docs" element={<DocsPage />} />
<Route path="/docs/:slug" element={<DocViewerPage />} />
<Route path="/assignments" element={<AssignmentsPage />} />
{/* 코딩 문제: 목록 + 문제별 풀이 화면 (:id는 문제 번호) */}
<Route path="/coding" element={<CodingPage />} />
<Route path="/coding/:id" element={<CodingProblemPage />} />
{/* 멘토 전용 영역 */}
<Route element={<RequireMentor />}>
<Route path="/mentor" element={<MentorPage />} />

View File

@ -0,0 +1,75 @@
import { useRef } from 'react';
/**
* 파일이 하는 :
* 코드를 입력하는 에디터다. 겉보기엔 줄번호가 붙은 코드 창이지만,
* 속은 그냥 <textarea> 하나다.
*
* 학습 포인트: Monaco(VS Code 엔진) CodeMirror 같은 라이브러리를 썼을까?
* 라이브러리들은 MB짜리다. 우리가 필요한 "고정폭 글꼴 + 탭키 + 줄번호" 전부인데,
* 그걸 위해 로딩을 늦추는 손해다.
* 라이브러리를 고를 때는 "이게 해 주는 일" 말고 "내가 진짜 필요한 일" 먼저 적어 보자.
* 나중에 자동완성·문법 하이라이팅이 정말 필요해지면 그때 바꾸면 된다.
*/
export default function CodeEditor({ value, onChange, readOnly = false, minRows = 12 }) {
const textareaRef = useRef(null);
// . minRows .
const lineCount = Math.max(value.split('\n').length, minRows);
const lineNumbers = Array.from({ length: lineCount }, (_, i) => i + 1);
/**
* Tab 처리.
* 학습 포인트: textarea는 원래 Tab을 누르면 "다음 입력칸으로 이동"한다(브라우저 기본 동작).
* 코드 창에서는 들여쓰기가 돼야 하므로 preventDefault() 기본 동작을 막고
* 우리가 직접 공백 2칸을 끼워 넣는다.
*/
function handleKeyDown(event) {
if (event.key !== 'Tab') {
return;
}
event.preventDefault();
const textarea = event.target;
const { selectionStart, selectionEnd } = textarea;
const next = value.slice(0, selectionStart) + ' ' + value.slice(selectionEnd);
onChange(next);
// .
requestAnimationFrame(() => {
textarea.selectionStart = selectionEnd + 2;
textarea.selectionEnd = selectionEnd + 2;
});
}
// .
function handleScroll(event) {
const gutter = event.target.previousSibling;
if (gutter) {
gutter.scrollTop = event.target.scrollTop;
}
}
return (
<div className="code-editor">
<div className="code-editor__gutter" aria-hidden="true">
{lineNumbers.map((n) => (
<div key={n}>{n}</div>
))}
</div>
<textarea
ref={textareaRef}
className="code-editor__area"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
readOnly={readOnly}
spellCheck={false}
// : / .
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
rows={lineCount}
/>
</div>
);
}

View File

@ -0,0 +1,260 @@
// : " " ,
// .
//
// : MentorPage 400 .
// " " , .
// .
import { useEffect, useState } from 'react';
import client from '../api/client';
const STATUS_LABEL = {
SUBMITTED: '제출됨',
PASSED: '통과',
REVIEWED: '피드백 완료',
};
/** 학생별 코딩 진도표 (전체 20문제 중 몇 개 제출/통과) */
function CodingProgressTable({ rows }) {
return (
<div className="card" style={{ padding: 0, overflowX: 'auto' }}>
<table className="table table-static" style={{ minWidth: 480 }}>
<thead>
<tr>
<th>학생</th>
<th>트랙</th>
<th style={{ textAlign: 'center' }}>제출</th>
<th style={{ textAlign: 'center' }}>통과</th>
<th style={{ textAlign: 'center' }}>전체</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.userId}>
<td>{row.name}</td>
<td>{row.track}</td>
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
{row.progress.submitted}
</td>
<td
style={{
textAlign: 'center',
fontVariantNumeric: 'tabular-nums',
// " "
color: row.progress.passed === 0 ? 'var(--muted, #888)' : undefined,
fontWeight: row.progress.passed > 0 ? 600 : 400,
}}
>
{row.progress.passed}
</td>
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
{row.progress.total}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function MentorCodingSection() {
const [submissions, setSubmissions] = useState([]);
const [progress, setProgress] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [feedbackDraft, setFeedbackDraft] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false);
function load() {
// MentorPage allSettled .
return Promise.allSettled([
client.get('/mentor/coding-submissions'),
client.get('/mentor/coding-progress'),
]).then(([subsRes, progRes]) => {
if (subsRes.status === 'fulfilled') setSubmissions(subsRes.value.data);
if (progRes.status === 'fulfilled') setProgress(progRes.value.data);
setLoadError([subsRes, progRes].some((r) => r.status === 'rejected'));
});
}
useEffect(() => {
load();
}, []);
const selected = submissions.find((s) => s.id === selectedId) || null;
function selectRow(submission) {
if (selectedId === submission.id) {
setSelectedId(null);
return;
}
setSelectedId(submission.id);
setFeedbackDraft(submission.feedback || '');
setError('');
}
async function saveFeedback(e) {
e.preventDefault();
if (!feedbackDraft.trim()) {
setError('피드백 내용을 적어 주세요.');
return;
}
setSaving(true);
setError('');
try {
await client.post(`/mentor/coding-submissions/${selectedId}/feedback`, {
feedback: feedbackDraft,
});
await load();
} catch {
setError('피드백 저장 중 문제가 생겼어요. 다시 시도해 주세요.');
} finally {
setSaving(false);
}
}
// (JSON ) . .
let selectedResults = null;
if (selected?.resultsJson) {
try {
selectedResults = JSON.parse(selected.resultsJson);
} catch {
selectedResults = null;
}
}
return (
<>
<h2 className="section-title" style={{ marginTop: 24 }}>
코딩 문제 진도
</h2>
{loadError && (
<p className="error-text" style={{ marginBottom: 14 }}>
코딩 데이터를 일부 불러오지 못했어요.
</p>
)}
{progress.length === 0 ? (
<p className="empty">아직 학생 계정이 없어요.</p>
) : (
<CodingProgressTable rows={progress} />
)}
<h2 className="section-title" style={{ marginTop: 24 }}>
코딩 제출물
</h2>
{submissions.length === 0 ? (
<p className="empty">아직 제출된 코드가 없어요.</p>
) : (
<div className="card" style={{ padding: 0, overflowX: 'auto' }}>
<table className="table" style={{ minWidth: 640 }}>
<thead>
<tr>
<th>학생</th>
<th>문제</th>
<th>언어</th>
<th style={{ textAlign: 'center' }}>채점</th>
<th style={{ textAlign: 'center' }}>시도</th>
<th>상태</th>
</tr>
</thead>
<tbody>
{submissions.map((s) => (
<tr
key={s.id}
onClick={() => selectRow(s)}
style={{ cursor: 'pointer' }}
className={selectedId === s.id ? 'is-selected' : ''}
>
<td>{s.studentName}</td>
<td>{s.problemTitle}</td>
<td>
<span className={`lang-badge lang-badge--${s.language.toLowerCase()}`}>
{s.language === 'JS' ? 'JavaScript' : 'Java'}
</span>
</td>
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
{/* 자바는 자동채점이 없으므로 점수 칸이 비어 있는 게 정상이다 */}
{s.totalCount == null ? '—' : `${s.passedCount}/${s.totalCount}`}
</td>
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
{s.attempts}
</td>
<td>
<span className={`status-badge status-badge--${s.status.toLowerCase()}`}>
{STATUS_LABEL[s.status] ?? s.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 선택한 제출물 상세 — 코드 원문 + 채점 결과 + 피드백 */}
{selected && (
<div className="card" style={{ marginTop: 16 }}>
<h3>
{selected.studentName} {selected.problemTitle}
</h3>
<h4>제출한 코드</h4>
{/* : <pre> .
<p> 넣으면 공백이 뭉개져서 코드 리뷰가 불가능해진다. */}
<pre className="code-view">
<code>{selected.code}</code>
</pre>
{selectedResults && (
<>
<h4>채점 결과</h4>
<ul className="test-results">
{selectedResults.map((r, i) => (
<li key={i} className={r.passed ? 'test--pass' : 'test--fail'}>
<span className="test__mark">{r.passed ? '통과' : '실패'}</span>
<span className="test__name">{r.name}</span>
{!r.passed && (
<span className="test__detail">
기대: <code>{r.expected}</code> / 실제: <code>{r.actual}</code>
</span>
)}
</li>
))}
</ul>
</>
)}
{selected.language === 'JAVA' && (
<p className="empty" style={{ marginTop: 8 }}>
자바 문제는 자동채점이 없습니다 코드를 직접 읽고 피드백해 주세요.
</p>
)}
<form onSubmit={saveFeedback} style={{ marginTop: 12 }}>
<div className="field">
<label>피드백</label>
<textarea
className="textarea"
value={feedbackDraft}
onChange={(e) => setFeedbackDraft(e.target.value)}
placeholder="잘한 점과 다음에 시도해 볼 점을 구체적으로 적어 주세요."
/>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? '저장 중...' : '피드백 저장'}
</button>
<button
className="btn btn-ghost"
type="button"
onClick={() => setSelectedId(null)}
style={{ marginLeft: 8 }}
>
닫기
</button>
</form>
</div>
)}
</>
);
}

111
frontend/src/lib/runJs.js Normal file
View File

@ -0,0 +1,111 @@
/**
* 파일이 하는 :
* 학생이 작성한 JavaScript 코드를 "안전하게" 실행하고 테스트케이스로 채점한다.
*
* 핵심 아이디어 Web Worker.
* 학습 포인트: 브라우저에서 남의 코드를 그냥 eval() 돌리면 가지가 위험하다.
* 1) while(true) 같은 무한루프가 들어오면 화면이 통째로 얼어붙는다(멈출 방법이 없다).
* 2) 코드가 document를 만져 화면을 부수거나 쿠키를 훔쳐볼 있다.
* Web Worker는 "다른 스레드에서 도는 별도의 방"이다.
* 1) 오래 걸리면 terminate() 방을 통째로 없애 버릴 있다 무한루프 방어
* 2) Worker 안에는 document도 window도 없다 화면을 만질 없다
* 그래서 서버에 Docker 샌드박스를 짓지 않고도, 브라우저가 이미 가진 격리 장치를 빌려 쓴다.
*/
/** 코드 한 번 실행에 허용할 시간(ms). 넘으면 무한루프로 보고 Worker를 없앤다. */
const TIMEOUT_MS = 2000;
/**
* Worker 안에서 코드.
* 학습 포인트: Worker는 별도 파일이 필요한데, 파일을 따로 두는 대신
* 문자열을 Blob으로 만들어 URL처럼 쓴다(createObjectURL).
* "파일 하나 늘리지 않고 Worker 쓰기" 관용구다.
*/
const workerSource = `
self.onmessage = function (event) {
var code = event.data.code;
var functionName = event.data.functionName;
var tests = event.data.tests;
var results = [];
// 학생 코드를 평가해서 함수를 꺼낸다.
var fn;
try {
// (0, eval) 형태로 부르면 전역 스코프에서 평가된다.
var factory = new Function(code + '\\nreturn typeof ' + functionName + ' === "function" ? ' + functionName + ' : null;');
fn = factory();
} catch (e) {
self.postMessage({ error: '코드에 문법 오류가 있습니다: ' + e.message });
return;
}
if (!fn) {
self.postMessage({ error: '"' + functionName + '" 함수를 찾을 수 없습니다. 함수 이름을 바꾸지 마세요.' });
return;
}
for (var i = 0; i < tests.length; i++) {
var test = tests[i];
var actual;
var errorMessage = null;
try {
// 인자를 복사해서 넘긴다 — 학생 코드가 배열을 바꿔도 다음 테스트에 영향이 없도록.
var args = JSON.parse(JSON.stringify(test.args));
actual = fn.apply(null, args);
} catch (e) {
errorMessage = e.message;
}
var expectedText = JSON.stringify(test.expected);
var actualText = errorMessage ? null : JSON.stringify(actual);
// 학습 포인트: 배열/객체는 ===로 비교하면 "내용이 같아도" false다(주소를 비교하니까).
// JSON 문자열로 바꿔 비교하면 내용 비교가 된다. 키 순서까지 같아야 하는 한계는 있지만
// 우리 문제 범위에서는 충분하다.
var passed = !errorMessage && actualText === expectedText;
results.push({
name: test.name,
passed: passed,
expected: expectedText,
actual: errorMessage ? ('에러: ' + errorMessage) : (actualText === undefined ? 'undefined' : actualText)
});
}
self.postMessage({ results: results });
};
`;
/**
* 학생 코드를 테스트케이스로 채점한다.
*
* @param {string} code 학생이 작성한 코드
* @param {string} functionName 호출할 함수 이름
* @param {Array} tests [{name, args, expected}]
* @returns {Promise<{results?: Array, error?: string}>}
*/
export function runJs(code, functionName, tests) {
return new Promise((resolve) => {
const blob = new Blob([workerSource], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
// 끝났든 시간이 지났든, 뒷정리는 한 곳에서 한다.
let settled = false;
function finish(value) {
if (settled) return;
settled = true;
clearTimeout(timer);
worker.terminate();
URL.revokeObjectURL(url);
resolve(value);
}
const timer = setTimeout(() => {
finish({ error: `실행이 ${TIMEOUT_MS / 1000}초를 넘겼습니다. 무한루프가 아닌지 확인해 보세요.` });
}, TIMEOUT_MS);
worker.onmessage = (event) => finish(event.data);
worker.onerror = (event) => finish({ error: '실행 중 오류: ' + event.message });
worker.postMessage({ code, functionName, tests });
});
}

View File

@ -0,0 +1,116 @@
// : 20 · ,
// ( / / / ) .
//
// : API(/coding/problems) .
// (/coding/problems/{id}) .
// " " " " .
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import client from '../api/client';
/** 상태값을 사람이 읽는 말로 바꾼다 */
const STATUS_LABEL = {
TODO: '안 풂',
SUBMITTED: '제출함',
PASSED: '통과',
REVIEWED: '피드백 받음',
};
const DIFFICULTY_LABEL = { EASY: '쉬움', NORMAL: '보통', HARD: '어려움' };
export default function CodingPage() {
const [problems, setProblems] = useState([]);
const [progress, setProgress] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
// : API . await .
Promise.all([client.get('/coding/problems'), client.get('/coding/progress/me')])
.then(([problemsRes, progressRes]) => {
if (cancelled) return;
setProblems(problemsRes.data);
setProgress(progressRes.data);
})
.catch(() => {
if (!cancelled) setError('문제를 불러오지 못했어요.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (loading) {
return <p className="empty">불러오는 </p>;
}
return (
<div>
<section className="hero compact">
<p className="eyebrow">코딩 문제</p>
<h1>직접 보기</h1>
<p>
읽어서 아는 것과 있는 것은 다릅니다. 20문제를 풀며 손에 익혀 보세요.
<br />
자바스크립트 문제는 <strong>브라우저에서 바로 채점</strong>되고, 자바 문제는{' '}
<strong>멘토가 코드를 읽고 피드백</strong>합니다.
</p>
</section>
{progress && (
<div className="chip-row">
<div className="stat">
<div className="stat-num">{progress.total}</div>
<div className="stat-label">전체 문제</div>
</div>
<div className="stat">
<div className="stat-num">{progress.submitted}</div>
<div className="stat-label">제출함</div>
</div>
<div className="stat">
<div className="stat-num">{progress.passed}</div>
<div className="stat-label">통과</div>
</div>
</div>
)}
{error && <p className="error-text">{error}</p>}
<div className="coding-list">
{problems.map((problem, index) => (
<Link key={problem.id} to={`/coding/${problem.id}`} className="card coding-item">
<div className="coding-item__main">
<span className="coding-item__no">{String(index + 1).padStart(2, '0')}</span>
<div>
<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'}
</span>
<span className="coding-item__difficulty">
{DIFFICULTY_LABEL[problem.difficulty] ?? problem.difficulty}
</span>
</div>
</div>
</div>
<div className="coding-item__status">
<span className={`status-badge status-badge--${problem.status.toLowerCase()}`}>
{STATUS_LABEL[problem.status] ?? problem.status}
</span>
{/* 자동채점 문제만 통과/전체 점수가 있다 */}
{problem.totalCount != null && (
<span className="coding-item__score">
{problem.passedCount}/{problem.totalCount}
</span>
)}
</div>
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,233 @@
// : , , , .
//
// :
// - JS : [] (Web Worker) / .
// [] + .
// - 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';
const DIFFICULTY_LABEL = { EASY: '쉬움', NORMAL: '보통', HARD: '어려움' };
export default function CodingProblemPage() {
const { id } = useParams();
const [problem, setProblem] = useState(null);
const [code, setCode] = useState('');
const [results, setResults] = useState(null); // [{name, passed, expected, actual}]
const [runError, setRunError] = useState(''); // ·
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
client
.get(`/coding/problems/${id}`)
.then((res) => {
if (cancelled) return;
const data = res.data;
setProblem(data);
// , .
setCode(data.myCode ?? data.starterCode);
// .
if (data.resultsJson) {
try {
setResults(JSON.parse(data.resultsJson));
} catch {
// .
}
}
})
.catch(() => {
if (!cancelled) setError('문제를 불러오지 못했어요.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id]);
const isJs = problem?.language === 'JS';
const tests = problem?.testsJson ? JSON.parse(problem.testsJson) : [];
/** [실행] — 브라우저에서 테스트를 돌린다 (서버에 저장하지 않는다) */
async function handleRun() {
setRunning(true);
setRunError('');
setMessage('');
const outcome = await runJs(code, problem.functionName, tests);
if (outcome.error) {
setRunError(outcome.error);
setResults(null);
} else {
setResults(outcome.results);
}
setRunning(false);
}
/** [제출] — JS면 먼저 채점하고, 코드와 결과를 함께 서버에 저장한다 */
async function handleSubmit() {
setSubmitting(true);
setMessage('');
setError('');
try {
let payload = { code, passedCount: null, totalCount: null, resultsJson: null };
if (isJs) {
const outcome = await runJs(code, problem.functionName, tests);
if (outcome.error) {
// .
setRunError(outcome.error);
setResults(null);
setSubmitting(false);
return;
}
setResults(outcome.results);
const passed = outcome.results.filter((r) => r.passed).length;
payload = {
code,
passedCount: passed,
totalCount: outcome.results.length,
resultsJson: JSON.stringify(outcome.results),
};
}
const res = await client.post(`/coding/problems/${id}/submit`, payload);
setProblem((prev) => ({ ...prev, ...res.data, feedback: null }));
setMessage(
res.data.status === 'PASSED'
? '통과! 제출했습니다. 멘토가 코드를 확인합니다.'
: '제출했습니다. 멘토가 코드를 읽고 피드백을 남길 거예요.'
);
} catch {
setError('제출하지 못했어요. 잠시 뒤 다시 시도해 주세요.');
} finally {
setSubmitting(false);
}
}
if (loading) {
return <p className="empty">불러오는 </p>;
}
if (!problem) {
return <p className="error-text">{error || '문제를 찾을 수 없습니다.'}</p>;
}
const passedCount = results ? results.filter((r) => r.passed).length : 0;
return (
<div>
<p className="eyebrow">
<Link to="/coding"> 코딩 문제 목록</Link>
</p>
<section className="hero compact">
<div className="chip-row">
<span className={`lang-badge lang-badge--${problem.language.toLowerCase()}`}>
{isJs ? 'JavaScript' : 'Java'}
</span>
<span className="coding-item__difficulty">
{DIFFICULTY_LABEL[problem.difficulty] ?? problem.difficulty}
</span>
</div>
<h1>{problem.title}</h1>
</section>
<div className="card">
{/* : white-space: pre-wrap \n() .
그냥 넣으면 HTML은 줄바꿈을 공백 하나로 취급해 문단이 붙어 버린다. */}
<p className="coding-description">{problem.description}</p>
</div>
<div className="card">
<h2>코드 작성</h2>
<CodeEditor value={code} onChange={setCode} />
<div className="coding-actions">
{isJs && (
<button type="button" className="btn" onClick={handleRun} disabled={running || submitting}>
{running ? '실행 중…' : '실행해 보기'}
</button>
)}
<button
type="button"
className="btn btn-primary"
onClick={handleSubmit}
disabled={submitting || running}
>
{submitting ? '제출 중…' : '제출하기'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setCode(problem.starterCode);
setResults(null);
setRunError('');
}}
disabled={submitting || running}
>
처음으로 되돌리기
</button>
</div>
{runError && <p className="error-text">{runError}</p>}
{message && <p className="success-text">{message}</p>}
{error && <p className="error-text">{error}</p>}
</div>
{/* JS 문제: 테스트별 채점 결과 */}
{isJs && results && (
<div className="card">
<h2>
채점 결과{' '}
<span className={passedCount === results.length ? 'ok' : 'ng'}>
{passedCount}/{results.length} 통과
</span>
</h2>
<ul className="test-results">
{results.map((result, i) => (
<li key={i} className={result.passed ? 'test--pass' : 'test--fail'}>
<span className="test__mark">{result.passed ? '통과' : '실패'}</span>
<span className="test__name">{result.name}</span>
{!result.passed && (
<span className="test__detail">
기대: <code>{result.expected}</code> / 실제: <code>{result.actual}</code>
</span>
)}
</li>
))}
</ul>
</div>
)}
{/* 자바 문제: 자동채점이 없다는 걸 분명히 알려 준다 */}
{!isJs && (
<div className="card">
<h2>채점 안내</h2>
<p>
자바 문제는 자동으로 실행하지 않습니다. IntelliJ에서 직접 돌려 확인한 제출하면,
멘토가 코드를 읽고 피드백을 남깁니다.
</p>
</div>
)}
{/* 멘토 피드백 */}
{problem.feedback && (
<div className="card feedback-card">
<h2>멘토 피드백</h2>
<p className="coding-description">{problem.feedback}</p>
</div>
)}
</div>
);
}

View File

@ -2,6 +2,7 @@
// .
import { useEffect, useState } from 'react';
import client from '../api/client';
import MentorCodingSection from '../components/MentorCodingSection';
import Card from '../components/Card';
import Badge from '../components/Badge';
import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog';
@ -299,6 +300,9 @@ export default function MentorPage() {
<QuizResultsTable quizResults={quizResults} />
)}
{/* ── 코딩 문제 (진도표 + 제출된 코드 리뷰) ── */}
<MentorCodingSection />
{/* ── 제출물 리뷰 ── */}
<h2 className="section-title" style={{ marginTop: 24 }}>제출물</h2>
{submissions.length === 0 ? (

View File

@ -879,3 +879,244 @@ inline-code, .icode {
.fold[open] summary {
margin-bottom: 10px;
}
/* 코딩 문제
학습 포인트: 색을 직접 쓰지 않고 :root의 변수(--ink, --line ) 쓴다.
그래야 다크 모드에서 자동으로 같이 바뀐다 파일 :root 정의를 보자. */
/* 성공 메시지 (에러의 반대쪽 짝) */
.success-text {
color: var(--teal);
font-size: 14px;
margin-top: 10px;
}
/* 선택된 표의 행 */
.is-selected {
background: var(--primary-soft);
}
/* 언어 배지 */
.lang-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--line);
}
.lang-badge--js {
background: color-mix(in srgb, var(--amber) 12%, transparent);
color: var(--amber);
}
.lang-badge--java {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
/* 풀이 상태 배지 */
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--line);
color: var(--muted);
}
.status-badge--passed {
background: color-mix(in srgb, var(--teal) 14%, transparent);
color: var(--teal);
border-color: transparent;
}
.status-badge--submitted {
background: color-mix(in srgb, var(--amber) 14%, transparent);
color: var(--amber);
border-color: transparent;
}
.status-badge--reviewed {
background: var(--primary-soft);
color: var(--primary);
border-color: transparent;
}
/* 문제 목록 */
.coding-list {
display: grid;
gap: 10px;
}
.coding-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-decoration: none;
color: inherit;
}
.coding-item:hover {
border-color: var(--primary);
}
.coding-item__main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.coding-item__no {
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
font-weight: 700;
}
.coding-item__title {
font-weight: 600;
margin-bottom: 4px;
}
.coding-item__meta {
display: flex;
align-items: center;
gap: 8px;
}
.coding-item__difficulty {
font-size: 12px;
color: var(--muted);
}
.coding-item__status {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.coding-item__score {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
/* 문제 설명·피드백 — \n 줄바꿈을 그대로 살린다 */
.coding-description {
white-space: pre-wrap;
line-height: 1.7;
margin: 0;
}
.coding-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.feedback-card {
border-left: 3px solid var(--primary);
}
/* 코드 에디터 — 줄번호(왼쪽) + textarea(오른쪽) */
.code-editor {
display: flex;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: var(--code-bg);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
}
.code-editor__gutter {
padding: 12px 8px 12px 12px;
text-align: right;
color: var(--ink-faint);
user-select: none;
overflow: hidden;
border-right: 1px solid var(--line);
min-width: 42px;
}
.code-editor__area {
flex: 1;
border: 0;
outline: none;
resize: vertical;
padding: 12px;
background: transparent;
color: var(--ink);
font: inherit;
line-height: inherit;
/* 학습 포인트: 탭 문자를 2칸으로 보이게 한다(에디터 설정과 화면을 맞추기 위해) */
tab-size: 2;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
/* 멘토가 읽는 코드 — 읽기 전용 표시 */
.code-view {
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px;
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
margin: 0 0 12px;
}
/* 테스트 채점 결과 */
.test-results {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 6px;
}
.test-results li {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--line);
font-size: 14px;
}
.test--pass {
background: color-mix(in srgb, var(--teal) 8%, transparent);
}
.test--fail {
background: color-mix(in srgb, var(--rose) 8%, transparent);
}
.test__mark {
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
padding: 1px 7px;
border-radius: 999px;
}
.test--pass .test__mark {
background: var(--teal);
color: #fff;
}
.test--fail .test__mark {
background: var(--rose);
color: #fff;
}
.test__name {
flex: 1;
min-width: 0;
}
.test__detail {
font-size: 12px;
color: var(--muted);
}
.test__detail code {
background: var(--code-bg);
padding: 1px 5px;
border-radius: 4px;
}
/* 채점 요약 색 */
.ok {
color: var(--teal);
}
.ng {
color: var(--rose);
}