feat: 고도화 배치 7건 — 보안헤더·다운감시·로그회전·개인정보파기·문서정합성

[보안]
- Caddy 보안 응답헤더(HSTS·X-Frame-Options:DENY·X-Content-Type-Options·Referrer-Policy) +
  Server 헤더 제거. :80 catch-all 제거로 HTTP→HTTPS 자동 리다이렉트 복원(평문 표면 제거)
[운영 안정성]
- 헬스체크 스크립트 + systemd 타이머(2분): 공개 URL 두드려 UP/DOWN 변화 시에만 텔레그램 알림
- Docker 로그 회전(json-file 10m×3, YAML 앵커 공통적용) — 디스크 풀로 인한 조용한 다운 방지
[개인정보]
- 학생 파기: DELETE /api/mentor/students/{id} (멘토 권한) → 자식6테이블 먼저·계정 나중,
  @Transactional. 동의화면이 약속한 '파기' 이행. 멘토 화면에 이름입력 확인 파기 버튼
  StudentPurgeService + 테스트3(삭제순서·멘토거부·404)
[문서 정합성 — 지금 쓰는 학생 대상]
- STUDY.md: 없는 파일 가리키던 코드지도 정정(controller/→web/, ChecklistPage→DashboardPage,
  DocumentsPage→DocsPage, SubmissionsPage→AssignmentsPage)
- README: '6주'→8주, 학습센터/퀴즈/코딩 소개 추가, 폴더구조·초기계정(멘토만 시드·랜덤비번) 현행화
- 퀴즈 만점 안내: 없는 '코스 완료' 버튼 대신 '[← 코스로] 돌아가 완료' 로 정정

검증: 테스트29 통과, 운영에서 헤더4종·308리다이렉트·헬스체크·파기 E2E(무인증401·멘토200·
자식전체0건) 확인. 나머지 13건은 티켓 풀 확장감으로 남김.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 14:58:00 +09:00
parent bb12d072b1
commit 0a4c769d00
18 changed files with 388 additions and 38 deletions

View File

@ -2,15 +2,16 @@
> **이 저장소는 여러분(수습생)의 것입니다.** > **이 저장소는 여러분(수습생)의 것입니다.**
> 미림마이스터고 수습생 4명이 직접 보고, 배우고, 고치는 학습용 웹 앱입니다. > 미림마이스터고 수습생 4명이 직접 보고, 배우고, 고치는 학습용 웹 앱입니다.
> 처음에는 문서를 읽는 곳이지만, 5~6주차부터는 여러분이 직접 기능을 붙이고 버그를 고치는 실전 연습장이 됩니다. > 처음에는 문서를 읽는 곳이지만, 5주차부터는 여러분이 직접 기능을 붙이고 버그를 고치는 실전 연습장이 됩니다.
> 코드를 읽는 법이 궁금하면 [STUDY.md](./STUDY.md)부터 열어 보세요. > 코드를 읽는 법이 궁금하면 [STUDY.md](./STUDY.md)부터 열어 보세요.
## 무엇을 하는 앱인가? ## 무엇을 하는 앱인가?
- 수습 기간(6주) 동안 필요한 **학습 문서**(가이드·커리큘럼·수업자료·과제)를 한곳에서 봅니다. - 수습 기간(**8주**, 9월~10월) 동안 필요한 **학습 문서**(가이드·커리큘럼·수업자료·과제·실습가이드)를 한곳에서 봅니다.
- 주차별 **체크리스트**로 오늘 할 일을 확인하고 체크합니다. - **학습 센터**에서 컴퓨터 기초부터 디자인까지 **강좌 71개**를 순서대로 배우고, 각 코스 끝에서 **퀴즈**로 확인합니다.
- **과제를 제출**하고, 멘토에게 **피드백**을 받습니다. - **코딩 문제 25개**(JavaScript·SQL·Java)를 브라우저에서 직접 풀고 자동 채점받습니다.
- 멘토는 전체 제출물을 한 화면에서 검토합니다. - 주차별 **체크리스트**로 오늘 할 일을 확인하고, **과제를 제출**하면 멘토가 **피드백**을 남깁니다.
- 멘토는 학생별 진도·제출물·인적사항을 한 화면에서 관리합니다.
## 기술 스택 ## 기술 스택
@ -36,17 +37,18 @@ mirim-app/
│ │ ├── domain/ ← 엔티티(DB 테이블과 짝이 되는 클래스) │ │ ├── domain/ ← 엔티티(DB 테이블과 짝이 되는 클래스)
│ │ ├── repository/ ← DB를 읽고 쓰는 계층 │ │ ├── repository/ ← DB를 읽고 쓰는 계층
│ │ ├── service/ ← 비즈니스 로직(규칙) 계층 │ │ ├── service/ ← 비즈니스 로직(규칙) 계층
│ │ ├── controller/ ← HTTP 요청을 받는 계층 │ │ ├── web/ ← HTTP 요청을 받는 계층(컨트롤러들)
│ │ └── config/ ← 보안·시드 등 설정 │ │ └── config/ ← 보안·시드 등 설정
│ └── resources/ │ └── resources/
│ ├── application.yml ← 서버 설정(DB 접속 정보 등) │ ├── application.yml ← 서버 설정(DB 접속 정보 등)
│ └── seed/ ← 초기 데이터 JSON (문서·체크리스트·과제) │ └── seed/ ← 초기 데이터 JSON (문서·체크리스트·과제·퀴즈·코딩문제)
└── frontend/ ← React 화면 └── frontend/ ← React 화면
├── package.json ← JS 의존성 목록 ├── package.json ← JS 의존성 목록
├── index.html ← 브라우저가 처음 여는 파일 ├── index.html ← 브라우저가 처음 여는 파일
├── public/docs/ ← 학습 문서 HTML 17개 (앱이 iframe으로 보여줌) ├── public/docs/ ← 학습 문서 HTML 19개 (앱이 iframe으로 보여줌)
└── src/ └── src/
├── pages/ ← 화면 단위 컴포넌트 (로그인, 대시보드, 문서, 과제…) ├── pages/ ← 화면 단위 컴포넌트 (로그인, 대시보드, 문서, 과제…)
│ └── courses/ ← 학습 센터의 강좌 페이지들 (코스 하나당 파일 하나)
├── components/ ← 여러 화면에서 재사용하는 조각 컴포넌트 ├── components/ ← 여러 화면에서 재사용하는 조각 컴포넌트
└── api/ ← 백엔드 API를 부르는 함수 모음 └── api/ ← 백엔드 API를 부르는 함수 모음
``` ```
@ -82,12 +84,14 @@ npm run dev
## 초기 계정 ## 초기 계정
시드로는 **멘토 계정(mentor1) 하나만** 만들어집니다. 학생은 첫날 각자 **회원가입**으로 등록해요
(개인정보를 본인이 입력하는 게 정확하고, "받은 비밀번호를 바꾸는 것이 첫 일"을 배우니까요).
| 아이디 | 비밀번호 | 역할 | | 아이디 | 비밀번호 | 역할 |
|---|---|---| |---|---|---|
| student1 ~ student4 | `mirim2026!` | 수습생 (STUDENT) | | mentor1 | `MENTOR_INITIAL_PASSWORD` 환경변수, 없으면 부팅 로그에 1회 출력되는 랜덤값 | 멘토 (MENTOR) |
| mentor1 | `mirim2026!` | 멘토 (MENTOR) |
> ⚠️ **실서비스로 쓰기 전에는 반드시 비밀번호를 변경하세요.** 이 계정들은 로컬 학습용입니다. > ⚠️ 소스에 박힌 기본 비밀번호는 없습니다 — 첫 부팅 로그에서 멘토 비밀번호를 확인하고, **로그인 후 즉시 변경**하세요.
## 환경변수 ## 환경변수

View File

@ -25,7 +25,7 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService
- **관찰 포인트**: 왜 페이지마다 fetch를 직접 쓰지 않고 한 파일에 모았을까? (주소가 바뀌면 한 곳만 고치면 됨, 에러 처리를 한 번만 작성하면 됨.) - **관찰 포인트**: 왜 페이지마다 fetch를 직접 쓰지 않고 한 파일에 모았을까? (주소가 바뀌면 한 곳만 고치면 됨, 에러 처리를 한 번만 작성하면 됨.)
- 여기서 브라우저를 떠나 HTTP 요청이 네트워크를 타고 백엔드(8080)로 갑니다. 개발자도구(F12) → Network 탭에서 이 요청을 직접 눈으로 확인해 보세요. - 여기서 브라우저를 떠나 HTTP 요청이 네트워크를 타고 백엔드(8080)로 갑니다. 개발자도구(F12) → Network 탭에서 이 요청을 직접 눈으로 확인해 보세요.
### ③ `backend/.../controller/AuthController.java` — 서버의 현관문 ### ③ `backend/.../web/AuthController.java` — 서버의 현관문
- `@PostMapping("/api/auth/login")`이 붙은 메서드를 찾으세요. - `@PostMapping("/api/auth/login")`이 붙은 메서드를 찾으세요.
- 컨트롤러는 얇습니다: JSON을 받아서 → 서비스에 넘기고 → 결과를 JSON으로 돌려줄 뿐. 판단하지 않습니다. - 컨트롤러는 얇습니다: JSON을 받아서 → 서비스에 넘기고 → 결과를 JSON으로 돌려줄 뿐. 판단하지 않습니다.
- **관찰 포인트**: 요청 JSON이 어떻게 자바 객체로 변하는지(`@RequestBody`). - **관찰 포인트**: 요청 JSON이 어떻게 자바 객체로 변하는지(`@RequestBody`).
@ -43,7 +43,7 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService
- 여기까지 오면 여행 끝. 응답은 왔던 길을 **거꾸로** 타고 브라우저까지 돌아가고, LoginPage는 받은 사용자 정보로 화면을 바꿉니다. - 여기까지 오면 여행 끝. 응답은 왔던 길을 **거꾸로** 타고 브라우저까지 돌아가고, LoginPage는 받은 사용자 정보로 화면을 바꿉니다.
> ✅ **확인 과제**: 같은 방식으로 "체크리스트에 체크했을 때"의 여행 경로를 종이에 그려 보세요. > ✅ **확인 과제**: 같은 방식으로 "체크리스트에 체크했을 때"의 여행 경로를 종이에 그려 보세요.
> 힌트: `POST /api/progress/{itemId}/toggle`에서 출발해서, ChecklistPage → client.js → ProgressController → ProgressService → ProgressRepository 순서로 찾으면 됩니다. > 힌트: `POST /api/progress/{itemId}/toggle`에서 출발해서, DashboardPage(주차별 체크리스트가 여기 있어요) → client.js → ProgressController → ProgressService → ProgressRepository 순서로 찾으면 됩니다.
--- ---
@ -56,13 +56,13 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService
한 문장으로: **pages는 조립, components는 부품, api는 배달.** 한 문장으로: **pages는 조립, components는 부품, api는 배달.**
### 백엔드: controller / service / repository / domain ### 백엔드: web / service / repository / domain
- **controller/** — HTTP를 아는 유일한 계층. 요청을 받고 응답을 돌려주는 현관문. - **web/** — HTTP를 아는 유일한 계층(컨트롤러들이 여기 있어요). 요청을 받고 응답을 돌려주는 현관문.
- **service/** — 규칙과 판단. "멘토만 피드백을 쓸 수 있다", "같은 과제를 다시 제출하면 덮어쓴다" 같은 우리 서비스의 법. - **service/** — 규칙과 판단. "멘토만 피드백을 쓸 수 있다", "같은 과제를 다시 제출하면 덮어쓴다" 같은 우리 서비스의 법.
- **repository/** — DB를 읽고 쓰는 일만. SQL 걱정은 여기(와 JPA)가 다 합니다. - **repository/** — DB를 읽고 쓰는 일만. SQL 걱정은 여기(와 JPA)가 다 합니다.
- **domain/** — 데이터의 모양 정의. User, Document, Assignment 같은 "명사"들. - **domain/** — 데이터의 모양 정의. User, Document, Assignment 같은 "명사"들.
왜 나누냐면: **한 파일에 다 쓰면 처음엔 빠르지만, 고칠 때 지옥이 됩니다.** "비밀번호 규칙을 바꿔라"라는 요청이 오면 service만 보면 되고, "응답 JSON에 필드를 추가해라"면 controller 근처만 보면 됩니다. 계층은 "어디를 고쳐야 하는지"를 알려주는 주소 체계입니다. 왜 나누냐면: **한 파일에 다 쓰면 처음엔 빠르지만, 고칠 때 지옥이 됩니다.** "비밀번호 규칙을 바꿔라"라는 요청이 오면 service만 보면 되고, "응답 JSON에 필드를 추가해라"면 web(컨트롤러) 근처만 보면 됩니다. 계층은 "어디를 고쳐야 하는지"를 알려주는 주소 체계입니다.
--- ---
@ -77,29 +77,29 @@ LoginPage.jsx → api/client.js → (HTTP) → AuthController → AuthService
### 과제 2. 문서 카드에 즐겨찾기 버튼 달기 — 프론트만 (난이도 ★★) ### 과제 2. 문서 카드에 즐겨찾기 버튼 달기 — 프론트만 (난이도 ★★)
- **목표**: 문서 목록의 각 카드에 ☆ 버튼을 달고, 누르면 ★로 바뀐다. 새로고침하면 사라져도 됩니다(서버 저장 없음). - **목표**: 문서 목록의 각 카드에 ☆ 버튼을 달고, 누르면 ★로 바뀐다. 새로고침하면 사라져도 됩니다(서버 저장 없음).
- **건드릴 파일**: `frontend/src/pages/DocumentsPage.jsx` (카드가 별도 컴포넌트라면 `frontend/src/components/` 안의 카드 파일) - **건드릴 파일**: `frontend/src/pages/DocsPage.jsx` (카드는 `frontend/src/components/Card.jsx`)
- **힌트**: `useState`로 즐겨찾기된 문서 id 배열을 들고, 버튼 클릭 시 배열에 넣거나 빼면 됩니다. 도전 과제: `localStorage`에 저장해서 새로고침을 버텨 보세요. - **힌트**: `useState`로 즐겨찾기된 문서 id 배열을 들고, 버튼 클릭 시 배열에 넣거나 빼면 됩니다. 도전 과제: `localStorage`에 저장해서 새로고침을 버텨 보세요.
### 과제 3. 체크리스트 항목 검색 (난이도 ★★) ### 과제 3. 체크리스트 항목 검색 (난이도 ★★)
- **목표**: 체크리스트 페이지에 검색창을 달아서, 입력한 글자가 포함된 항목만 보여준다. - **목표**: 체크리스트 페이지에 검색창을 달아서, 입력한 글자가 포함된 항목만 보여준다.
- **건드릴 파일**: `frontend/src/pages/ChecklistPage.jsx` - **건드릴 파일**: `frontend/src/pages/DashboardPage.jsx` (주차별 체크리스트가 여기 있어요)
- **힌트**: 검색어를 `useState`로 들고, 화면에 그리기 직전에 `items.filter(item => item.label.includes(검색어))`. 서버 API를 바꿀 필요가 없다는 것 자체가 학습 포인트입니다 — 이미 받아 온 데이터는 프론트에서 거를 수 있습니다. - **힌트**: 검색어를 `useState`로 들고, 화면에 그리기 직전에 `items.filter(item => item.label.includes(검색어))`. 서버 API를 바꿀 필요가 없다는 것 자체가 학습 포인트입니다 — 이미 받아 온 데이터는 프론트에서 거를 수 있습니다.
### 과제 4. API 응답에 필드 추가해 보기 (난이도 ★★★, 백엔드 첫 수정) ### 과제 4. API 응답에 필드 추가해 보기 (난이도 ★★★, 백엔드 첫 수정)
- **목표**: `GET /api/auth/me` 응답에 `createdAt`(가입 시각)을 추가하고, 화면 어딘가에 "함께한 지 N일째"를 표시한다. - **목표**: `GET /api/auth/me` 응답에 `createdAt`(가입 시각)을 추가하고, 화면 어딘가에 "함께한 지 N일째"를 표시한다.
- **건드릴 파일**: `backend/.../controller/AuthController.java`(응답 DTO에 필드 추가) → `frontend/src/api/client.js`는 그대로 → 표시할 페이지 컴포넌트 - **건드릴 파일**: `backend/.../web/AuthController.java`(응답 DTO에 필드 추가) → `frontend/src/api/client.js`는 그대로 → 표시할 페이지 컴포넌트
- **힌트**: User 엔티티에는 `createdAt`이 이미 있습니다. 응답으로 내보내는 record/DTO에 한 필드만 추가하면 됩니다. 백엔드를 재시작한 뒤 브라우저 Network 탭에서 응답 JSON에 필드가 생겼는지 먼저 확인하고, 그다음 화면을 고치세요. **한 번에 한 층씩 확인하는 습관**이 이 과제의 진짜 목표입니다. - **힌트**: User 엔티티에는 `createdAt`이 이미 있습니다. 응답으로 내보내는 record/DTO에 한 필드만 추가하면 됩니다. 백엔드를 재시작한 뒤 브라우저 Network 탭에서 응답 JSON에 필드가 생겼는지 먼저 확인하고, 그다음 화면을 고치세요. **한 번에 한 층씩 확인하는 습관**이 이 과제의 진짜 목표입니다.
### 과제 5. 빈 상태(empty state) 화면 넣기 (난이도 ★★) ### 과제 5. 빈 상태(empty state) 화면 넣기 (난이도 ★★)
- **목표**: 제출한 과제가 하나도 없을 때, 휑한 빈 목록 대신 "아직 제출한 과제가 없어요. 이번 주 과제부터 시작해 볼까요?" 같은 안내를 보여준다. - **목표**: 제출한 과제가 하나도 없을 때, 휑한 빈 목록 대신 "아직 제출한 과제가 없어요. 이번 주 과제부터 시작해 볼까요?" 같은 안내를 보여준다.
- **건드릴 파일**: `frontend/src/pages/SubmissionsPage.jsx` (내 제출물 페이지) - **건드릴 파일**: `frontend/src/pages/AssignmentsPage.jsx` (과제 목록·제출 화면)
- **힌트**: `submissions.length === 0`일 때 다른 JSX를 그리면 됩니다. 좋은 서비스는 "데이터가 없을 때"를 항상 설계합니다 — 실무에서 정말 자주 하는 일입니다. - **힌트**: `submissions.length === 0`일 때 다른 JSX를 그리면 됩니다. 좋은 서비스는 "데이터가 없을 때"를 항상 설계합니다 — 실무에서 정말 자주 하는 일입니다.
--- ---
## 4. 이 저장소는 여러분의 연습장입니다 ## 4. 이 저장소는 여러분의 연습장입니다
5~6주차 실전 티켓 기간에는 이 앱 자체가 작업 대상이 됩니다. 지금 문서를 보고 과제를 제출하는 데 쓰는 바로 이 코드에, 여러분이 만든 기능이 붙습니다. 5주차부터 시작하는 실전 티켓 기간에는 이 앱 자체가 작업 대상이 됩니다. 지금 문서를 보고 과제를 제출하는 데 쓰는 바로 이 코드에, 여러분이 만든 기능이 붙습니다.
- 위 미니 과제 5개는 실전 티켓의 축소판입니다. 티켓도 결국 "어느 파일을, 왜, 어떻게 고칠지"를 찾는 일입니다. - 위 미니 과제 5개는 실전 티켓의 축소판입니다. 티켓도 결국 "어느 파일을, 왜, 어떻게 고칠지"를 찾는 일입니다.
- 브랜치를 파서 마음껏 실험하세요. 망가뜨려도 `git checkout`으로 돌아올 수 있고, 망가뜨려 본 만큼 빨리 늡니다. - 브랜치를 파서 마음껏 실험하세요. 망가뜨려도 `git checkout`으로 돌아올 수 있고, 망가뜨려 본 만큼 빨리 늡니다.

View File

@ -25,4 +25,7 @@ public interface CodingSubmissionRepository extends JpaRepository<CodingSubmissi
/** 전체 제출물 — 멘토 화면용 (최신 제출 먼저) */ /** 전체 제출물 — 멘토 화면용 (최신 제출 먼저) */
List<CodingSubmission> findAllByOrderBySubmittedAtDesc(); List<CodingSubmission> findAllByOrderBySubmittedAtDesc();
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
void deleteByUser(User user);
} }

View File

@ -20,4 +20,7 @@ public interface CourseProgressRepository extends JpaRepository<CourseProgress,
/** 이 사용자가 이 코스를 완료했는지 (있으면 완료) */ /** 이 사용자가 이 코스를 완료했는지 (있으면 완료) */
Optional<CourseProgress> findByUserAndCourseSlug(User user, String courseSlug); Optional<CourseProgress> findByUserAndCourseSlug(User user, String courseSlug);
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
void deleteByUser(User user);
} }

View File

@ -19,4 +19,7 @@ public interface ProgressRepository extends JpaRepository<Progress, Long> {
/** 특정 사용자의 전체 체크 기록 조회 */ /** 특정 사용자의 전체 체크 기록 조회 */
List<Progress> findByUser(User user); List<Progress> findByUser(User user);
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
void deleteByUser(User user);
} }

View File

@ -21,4 +21,7 @@ public interface QuizResultRepository extends JpaRepository<QuizResult, Long> {
* "순서가 필요하면 반드시 ORDER BY로 말한다" 우연히 맞는 코드는 언젠가 틀린다. * "순서가 필요하면 반드시 ORDER BY로 말한다" 우연히 맞는 코드는 언젠가 틀린다.
*/ */
List<QuizResult> findByUserOrderBySubmittedAtDesc(User user); List<QuizResult> findByUserOrderBySubmittedAtDesc(User user);
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
void deleteByUser(User user);
} }

View File

@ -33,4 +33,7 @@ public interface StudentProfileRepository extends JpaRepository<StudentProfile,
*/ */
@Query("SELECT p FROM StudentProfile p JOIN FETCH p.user ORDER BY p.id ASC") @Query("SELECT p FROM StudentProfile p JOIN FETCH p.user ORDER BY p.id ASC")
List<StudentProfile> findAllWithUser(); List<StudentProfile> findAllWithUser();
/** 이 사용자의 프로필을 지운다 (학생 계정 파기 시). */
void deleteByUser(User user);
} }

View File

@ -26,4 +26,7 @@ public interface SubmissionRepository extends JpaRepository<Submission, Long> {
/** 전체 제출물을 최신 제출 순으로 조회 (멘토 리뷰 화면용) */ /** 전체 제출물을 최신 제출 순으로 조회 (멘토 리뷰 화면용) */
List<Submission> findAllByOrderBySubmittedAtDesc(); List<Submission> findAllByOrderBySubmittedAtDesc();
/** 이 사용자의 모든 행을 지운다 (학생 계정 파기 시 자식 데이터부터 정리). */
void deleteByUser(User user);
} }

View File

@ -0,0 +1,90 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.CodingSubmissionRepository;
import dev.awesomedev.mirim.repository.CourseProgressRepository;
import dev.awesomedev.mirim.repository.ProgressRepository;
import dev.awesomedev.mirim.repository.QuizResultRepository;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import dev.awesomedev.mirim.repository.SubmissionRepository;
import dev.awesomedev.mirim.repository.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
/**
* 파일이 하는 :
* 학생 계정과 학생의 모든 개인정보·학습기록을 "완전히" 지운다(파기).
*
* 학습 포인트 기능이 필요한가(법적 의무).
* 가입 화면에서 "퇴직 시 관계 법령이 정한 기간이 지나면 지체 없이 파기한다" 약속했다.
* 약속했으면 실제로 지울 수단이 있어야 한다 없으면 동의문은 거짓말이 된다.
* 개인정보보호법상 목적이 끝난 개인정보는 파기해야 하고, 특히 미성년 정보라 엄격하다.
*
* 학습 포인트 삭제 순서(외래키 제약).
* users 테이블은 여러 자식 테이블(제출·진도·퀴즈결과·프로필) user_id로 참조한다.
* 부모(users) 먼저 지우려 하면 "아직 널 가리키는 자식이 있어"라며 DB가 거부한다(FK 제약).
* 그래서 반드시 "자식 먼저, 부모 나중" 순서로 지운다. 순서 자체가 데이터 관계의 지도다.
*
* 학습 포인트 @Transactional로 "전부 아니면 전무".
* 여러 테이블을 지우다 중간에 실패하면 "제출만 지워지고 프로필은 남은" 반쪽 상태가 된다.
* 트랜잭션으로 묶어, 하나라도 실패하면 전부 되돌린다(원자성).
*/
@Service
public class StudentPurgeService {
private final UserRepository userRepository;
private final StudentProfileRepository studentProfileRepository;
private final SubmissionRepository submissionRepository;
private final CodingSubmissionRepository codingSubmissionRepository;
private final ProgressRepository progressRepository;
private final CourseProgressRepository courseProgressRepository;
private final QuizResultRepository quizResultRepository;
private final TelegramNotifier telegramNotifier;
public StudentPurgeService(UserRepository userRepository,
StudentProfileRepository studentProfileRepository,
SubmissionRepository submissionRepository,
CodingSubmissionRepository codingSubmissionRepository,
ProgressRepository progressRepository,
CourseProgressRepository courseProgressRepository,
QuizResultRepository quizResultRepository,
TelegramNotifier telegramNotifier) {
this.userRepository = userRepository;
this.studentProfileRepository = studentProfileRepository;
this.submissionRepository = submissionRepository;
this.codingSubmissionRepository = codingSubmissionRepository;
this.progressRepository = progressRepository;
this.courseProgressRepository = courseProgressRepository;
this.quizResultRepository = quizResultRepository;
this.telegramNotifier = telegramNotifier;
}
/**
* 학생의 모든 것을 파기한다. 멘토만 호출할 있다(컨트롤러에서 권한 보장).
* 되돌릴 없는 작업이라, 대상이 STUDENT가 아니면(멘토 ) 막는다.
*/
@Transactional
public void purge(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "학생을 찾을 수 없습니다."));
if (!"STUDENT".equals(user.getRole())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "학생 계정만 파기할 수 있습니다.");
}
// 1) 자식 데이터부터 (참조하는 먼저)
submissionRepository.deleteByUser(user);
codingSubmissionRepository.deleteByUser(user);
progressRepository.deleteByUser(user);
courseProgressRepository.deleteByUser(user);
quizResultRepository.deleteByUser(user);
studentProfileRepository.deleteByUser(user);
// 2) 마지막으로 계정 본체
userRepository.delete(user);
// 멘토에게 파기 사실을 알린다 개인정보 파기는 기록으로 남겨야 하는 "사건"이다.
telegramNotifier.send("🗑️ 학생 계정 파기 완료: " + user.getName() + " (" + user.getUsername() + ")"
+ "\n제출·진도·퀴즈·프로필·계정을 모두 삭제했습니다.");
}
}

View File

@ -9,6 +9,7 @@ import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.ProgressService; import dev.awesomedev.mirim.service.ProgressService;
import dev.awesomedev.mirim.service.QuizService; import dev.awesomedev.mirim.service.QuizService;
import dev.awesomedev.mirim.service.StudentProfileService; import dev.awesomedev.mirim.service.StudentProfileService;
import dev.awesomedev.mirim.service.StudentPurgeService;
import dev.awesomedev.mirim.service.SubmissionService; import dev.awesomedev.mirim.service.SubmissionService;
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse; import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
import dev.awesomedev.mirim.web.dto.FeedbackRequest; import dev.awesomedev.mirim.web.dto.FeedbackRequest;
@ -48,6 +49,7 @@ public class MentorController {
private final CodingService codingService; private final CodingService codingService;
private final AuthService authService; private final AuthService authService;
private final StudentProfileService studentProfileService; private final StudentProfileService studentProfileService;
private final StudentPurgeService studentPurgeService;
private final UserRepository userRepository; private final UserRepository userRepository;
public MentorController(SubmissionService submissionService, public MentorController(SubmissionService submissionService,
@ -57,6 +59,7 @@ public class MentorController {
CodingService codingService, CodingService codingService,
AuthService authService, AuthService authService,
StudentProfileService studentProfileService, StudentProfileService studentProfileService,
StudentPurgeService studentPurgeService,
UserRepository userRepository) { UserRepository userRepository) {
this.submissionService = submissionService; this.submissionService = submissionService;
this.courseProgressService = courseProgressService; this.courseProgressService = courseProgressService;
@ -65,6 +68,7 @@ public class MentorController {
this.codingService = codingService; this.codingService = codingService;
this.authService = authService; this.authService = authService;
this.studentProfileService = studentProfileService; this.studentProfileService = studentProfileService;
this.studentPurgeService = studentPurgeService;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
@ -169,6 +173,18 @@ public class MentorController {
authService.resetPasswordByMentor(id, request.newPassword()); authService.resetPasswordByMentor(id, request.newPassword());
} }
/**
* DELETE /api/mentor/students/{id} 학생 계정과 모든 개인정보·학습기록을 파기한다.
*
* 학습 포인트: 되돌릴 없는 작업이라 (1) 멘토 권한(/api/mentor/**)으로 보호하고,
* (2) 서비스가 대상이 정말 STUDENT인지 다시 확인하며, (3) DELETE 메서드로 "삭제"라는
* 의도를 HTTP 동사로 분명히 한다. 동의 화면에서 약속한 "파기" 실제로 지키는 손이다.
*/
@DeleteMapping("/students/{id}")
public void purgeStudent(@PathVariable Long id) {
studentPurgeService.purge(id);
}
/** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */ /** POST /api/mentor/submissions/{id}/feedback — 피드백 등록, 상태는 REVIEWED로 변경 */
@PostMapping("/submissions/{id}/feedback") @PostMapping("/submissions/{id}/feedback")
public MentorSubmissionResponse giveFeedback(@PathVariable Long id, public MentorSubmissionResponse giveFeedback(@PathVariable Long id,

View File

@ -0,0 +1,91 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.CodingSubmissionRepository;
import dev.awesomedev.mirim.repository.CourseProgressRepository;
import dev.awesomedev.mirim.repository.ProgressRepository;
import dev.awesomedev.mirim.repository.QuizResultRepository;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import dev.awesomedev.mirim.repository.SubmissionRepository;
import dev.awesomedev.mirim.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* 파일이 하는 :
* 학생 파기의 핵심 규칙을 검증한다 (1) 자식 데이터를 먼저, 계정을 나중에 지우는 순서,
* (2) 멘토 계정 STUDENT가 아닌 대상은 파기하지 못하게 막는 .
*/
class StudentPurgeServiceTest {
private UserRepository userRepository;
private StudentProfileRepository studentProfileRepository;
private SubmissionRepository submissionRepository;
private CodingSubmissionRepository codingSubmissionRepository;
private ProgressRepository progressRepository;
private CourseProgressRepository courseProgressRepository;
private QuizResultRepository quizResultRepository;
private StudentPurgeService service;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
studentProfileRepository = mock(StudentProfileRepository.class);
submissionRepository = mock(SubmissionRepository.class);
codingSubmissionRepository = mock(CodingSubmissionRepository.class);
progressRepository = mock(ProgressRepository.class);
courseProgressRepository = mock(CourseProgressRepository.class);
quizResultRepository = mock(QuizResultRepository.class);
service = new StudentPurgeService(userRepository, studentProfileRepository,
submissionRepository, codingSubmissionRepository, progressRepository,
courseProgressRepository, quizResultRepository, mock(TelegramNotifier.class));
}
@Test
@DisplayName("학생 파기: 자식 데이터를 먼저 지우고 계정을 마지막에 지운다 (외래키 순서)")
void purge_deletes_children_before_user() {
User student = new User("s1", "해시", "임학생", "STUDENT", "DEV");
when(userRepository.findById(1L)).thenReturn(Optional.of(student));
service.purge(1L);
// 계정 삭제(userRepository.delete) 반드시 자식 삭제들 "뒤에" 일어나야 한다.
InOrder order = inOrder(submissionRepository, studentProfileRepository, userRepository);
order.verify(submissionRepository).deleteByUser(student);
order.verify(studentProfileRepository).deleteByUser(student);
order.verify(userRepository).delete(student); // 마지막
}
@Test
@DisplayName("학생 파기: 대상이 멘토면 403을 던지고 아무것도 지우지 않는다")
void purge_rejects_non_student() {
User mentor = new User("mentor1", "해시", "멘토", "MENTOR", "NONE");
when(userRepository.findById(9L)).thenReturn(Optional.of(mentor));
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
() -> service.purge(9L));
assertEquals(403, ex.getStatusCode().value());
verify(userRepository, never()).delete(any());
verify(submissionRepository, never()).deleteByUser(any());
}
@Test
@DisplayName("학생 파기: 없는 사용자면 404")
void purge_throws_404_when_missing() {
when(userRepository.findById(99L)).thenReturn(Optional.empty());
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
() -> service.purge(99L));
assertEquals(404, ex.getStatusCode().value());
}
}

View File

@ -4,8 +4,28 @@
# - /api/* 요청은 백엔드 컨테이너(backend:8080)로 넘김 개발 vite 프록시와 같은 역할 # - /api/* 요청은 백엔드 컨테이너(backend:8080)로 넘김 개발 vite 프록시와 같은 역할
# - 도메인이 연결되면 HTTPS 인증서를 자동으로 받아요 (Let's Encrypt) # - 도메인이 연결되면 HTTPS 인증서를 자동으로 받아요 (Let's Encrypt)
# ── 공통 보안 응답 헤더 (snippet) ──
# 학습 포인트: 브라우저는 서버가 주는 헤더를 보고 "이 사이트를 어떻게 다룰지" 정한다.
# 아래 헤더 줄이 흔한 공격을 브라우저 단에서 막아 준다. 학생 세션쿠키·개인정보를
# 다루는 사이트라 특히 중요하다. (import security 사이트 블록에서 끌어 쓴다)
(security) {
header {
# HTTPS만 쓰라고 브라우저에 각인 (1년) 평문 접속·다운그레이드 공격 차단
Strict-Transport-Security "max-age=31536000; includeSubDomains"
# 다른 사이트가 우리 화면을 iframe에 넣는 금지 클릭재킹 방어
X-Frame-Options "DENY"
# 브라우저가 파일 종류를 멋대로 추측(MIME 스니핑)하지 못하게
X-Content-Type-Options "nosniff"
# 다른 사이트로 이동할 우리 주소(경로)를 흘리지 않음
Referrer-Policy "no-referrer"
# 서버 종류를 굳이 알려주지 않음
-Server
}
}
# 도메인 접속 (DNS 연결 자동 HTTPS) # 도메인 접속 (DNS 연결 자동 HTTPS)
edu.awesomedevapp.com { edu.awesomedevapp.com {
import security
encode gzip encode gzip
handle /api/* { handle /api/* {
@ -19,17 +39,7 @@ edu.awesomedevapp.com {
} }
} }
# IP 직접 접속용 (DNS 연결 확인용, HTTP) # 학습 포인트: 예전엔 여기에 `:80 { }`(모든 HTTP를 평문으로 서빙) 블록이 있었다.
:80 { # 그런데 그게 Caddy의 자동 HTTP→HTTPS 리다이렉트를 "가로채" 평문 접속을 열어 뒀다.
encode gzip # 블록을 지우면 Caddy가 edu.awesomedevapp.com의 80포트를 자동으로 443으로 돌려보낸다
# 평문으로 로그인 정보가 오갈 표면 자체가 사라진다. (DNS가 붙었으니 IP 평문 접속은 불필요.)
handle /api/* {
reverse_proxy backend:8080
}
handle {
root * /srv
try_files {path} /index.html
file_server
}
}

View File

@ -5,10 +5,21 @@
# - 비밀번호는 .env 파일로 주입 (코드/이미지에 안 넣음) # - 비밀번호는 .env 파일로 주입 (코드/이미지에 안 넣음)
# - 재부팅해도 자동 시작(restart: unless-stopped) # - 재부팅해도 자동 시작(restart: unless-stopped)
# 학습 포인트 — 로그도 자원이다(디스크). Docker 기본값은 로그를 "무제한"으로 쌓아서,
# 작은 EC2 디스크가 몇 달이면 꽉 차고 그 순간 스택 전체가 조용히 멈춘다("아무도 모르는 다운").
# 아래 앵커(&logging)로 각 서비스에 "파일당 10MB, 3개까지만" 회전 정책을 공통 적용한다.
# YAML 앵커(&이름)와 병합(<<: *이름)은 "같은 설정을 한 번 쓰고 여러 곳에 붙이는" 문법이다.
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services: services:
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
container_name: mirim-postgres container_name: mirim-postgres
logging: *logging
environment: environment:
POSTGRES_DB: mirim POSTGRES_DB: mirim
POSTGRES_USER: mirim POSTGRES_USER: mirim
@ -25,6 +36,7 @@ services:
backend: backend:
build: ../backend build: ../backend
container_name: mirim-backend container_name: mirim-backend
logging: *logging
environment: environment:
DB_HOST: postgres # 학습 포인트: 컨테이너끼리는 서비스 이름이 곧 주소예요 DB_HOST: postgres # 학습 포인트: 컨테이너끼리는 서비스 이름이 곧 주소예요
DB_PORT: 5432 DB_PORT: 5432
@ -54,6 +66,7 @@ services:
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
container_name: mirim-caddy container_name: mirim-caddy
logging: *logging
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
@ -71,6 +84,7 @@ services:
gitea: gitea:
image: gitea/gitea:1.23 image: gitea/gitea:1.23
container_name: mirim-gitea container_name: mirim-gitea
logging: *logging
environment: environment:
GITEA__server__ROOT_URL: http://${GIT_HOST:-edu.awesomedevapp.com}:3000/ GITEA__server__ROOT_URL: http://${GIT_HOST:-edu.awesomedevapp.com}:3000/
GITEA__server__SSH_PORT: "222" GITEA__server__SSH_PORT: "222"

55
deploy/healthcheck.sh Normal file
View File

@ -0,0 +1,55 @@
#!/bin/bash
# 이 파일이 하는 일: 학습 플랫폼이 살아 있는지 밖에서 확인하고, 상태가 "바뀔 때만" 텔레그램으로 알린다.
# systemd 타이머가 2분마다 실행한다(mirim-health.timer).
#
# 학습 포인트 ① — 왜 "앱 밖에서" 감시하나?
# 백엔드가 죽으면 백엔드 스스로는 "나 죽었어요"를 못 보낸다(죽었으니까!).
# 그래서 감시는 반드시 앱 바깥의 별도 프로세스가 해야 한다. 여기선 호스트의 이 스크립트가
# 실제 사용자와 똑같이 공개 URL(HTTPS)을 두드려 본다 — 이게 되면 Caddy·백엔드·인증서가 다 정상.
#
# 학습 포인트 ② — "상태 변화"에만 알린다.
# 2분마다 "정상입니다"를 보내면 알림이 스팸이 돼 아무도 안 본다.
# 그래서 직전 상태를 파일에 적어 두고, UP→DOWN(장애 발생)과 DOWN→UP(복구)의
# "바뀌는 순간"에만 한 번씩 보낸다. 좋은 알림은 조용하다가 필요할 때만 운다.
set -uo pipefail # -e는 안 쓴다: curl 실패(=사이트 다운)는 "에러"가 아니라 우리가 감지하려는 "정상 흐름"이다.
# ── 설정 ──
URL="https://edu.awesomedevapp.com/"
STATE_FILE="/home/ec2-user/.mirim-health-state" # 직전 상태(UP/DOWN)를 기억
FAIL_THRESHOLD=2 # 연속 N회 실패해야 DOWN으로 본다(일시적 깜빡임 무시)
TIMEOUT=10
# 텔레그램 설정은 백엔드 .env에서 그대로 가져온다(같은 봇·같은 방).
ENV_FILE="/home/ec2-user/mirim-app/deploy/.env"
BOT_TOKEN="$(grep -E '^TELEGRAM_BOT_TOKEN=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)"
CHAT_ID="$(grep -E '^TELEGRAM_CHAT_ID=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)"
notify() {
# 봇 설정이 없으면 조용히 넘어간다(알림만 꺼지고 감시 자체는 계속).
[ -z "$BOT_TOKEN" ] || [ -z "$CHAT_ID" ] && return 0
curl -s -m "$TIMEOUT" -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json; charset=utf-8" \
--data-binary "$(printf '{"chat_id":%s,"text":%s}' "$CHAT_ID" "$(printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" >/dev/null || true
}
# ── 현재 상태 확인 ──
# 연속 FAIL_THRESHOLD회 실패해야 DOWN. 한 번이라도 성공하면 즉시 UP.
now="DOWN"
for i in $(seq 1 "$FAIL_THRESHOLD"); do
if code="$(curl -s -o /dev/null -m "$TIMEOUT" -w '%{http_code}' "$URL")" && [ "$code" = "200" ]; then
now="UP"
break
fi
sleep 3
done
# ── 직전 상태와 비교, 바뀌었을 때만 알림 ──
prev="$(cat "$STATE_FILE" 2>/dev/null || echo "UP")" # 첫 실행은 UP으로 가정(정상 시작 전제)
if [ "$now" != "$prev" ]; then
if [ "$now" = "DOWN" ]; then
notify "🔴 학습 플랫폼 응답 없음 — $URL 이 열리지 않습니다. 서버를 확인해 주세요. (연속 ${FAIL_THRESHOLD}회 실패)"
else
notify "🟢 학습 플랫폼 정상 복구 — $URL 이 다시 열립니다."
fi
echo "$now" > "$STATE_FILE"
fi

View File

@ -0,0 +1,8 @@
[Unit]
Description=mirim platform: uptime health check + telegram alert
After=docker.service
[Service]
Type=oneshot
User=ec2-user
ExecStart=/home/ec2-user/mirim-app/deploy/healthcheck.sh

View File

@ -0,0 +1,9 @@
[Unit]
Description=mirim platform: run health check every 2 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=2min
[Install]
WantedBy=timers.target

View File

@ -191,11 +191,30 @@ function WeeklyChecklistTable({ overview }) {
// CSV . // CSV .
// : , . // : , .
function StudentRoster({ students }) { function StudentRoster({ students, onChanged }) {
// : axios(JSON) . // : axios(JSON) .
// withCredentials , . // withCredentials , .
const csvUrl = '/api/mentor/students/export.csv'; const csvUrl = '/api/mentor/students/export.csv';
// " " ( ).
async function purge(s) {
const typed = window.prompt(
`⚠️ ${s.name} 님의 계정과 모든 개인정보·학습기록을 영구히 삭제합니다. 되돌릴 수 없어요.\n` +
`확인하려면 학생 이름을 그대로 입력하세요: ${s.name}`,
);
if (typed === null) return; //
if (typed.trim() !== s.name) {
window.alert('이름이 일치하지 않아 취소했어요.');
return;
}
try {
await client.delete(`/mentor/students/${s.userId}`);
onChanged(); //
} catch {
window.alert('삭제 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
}
}
return ( return (
<Card style={{ padding: 0, overflowX: 'auto' }}> <Card style={{ padding: 0, overflowX: 'auto' }}>
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}> <div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
@ -217,6 +236,7 @@ function StudentRoster({ students }) {
<th>연락처</th> <th>연락처</th>
<th>학교</th> <th>학교</th>
<th>비상 연락</th> <th>비상 연락</th>
<th>파기</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -231,10 +251,25 @@ function StudentRoster({ students }) {
<td style={{ whiteSpace: 'nowrap' }} className="muted"> <td style={{ whiteSpace: 'nowrap' }} className="muted">
{s.emergencyName}({s.emergencyRelation}) {s.emergencyPhone} {s.emergencyName}({s.emergencyRelation}) {s.emergencyPhone}
</td> </td>
<td>
{/* 퇴직·수습종료 시 개인정보 파기. 동의 화면이 약속한 '파기'를 실제로 지키는 버튼 */}
<button
className="btn btn-ghost"
style={{ fontSize: 12.5, color: 'var(--rose)', padding: '2px 8px' }}
onClick={() => purge(s)}
title={`${s.name} 개인정보 파기`}
>
파기
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
<p className="muted" style={{ fontSize: 12, padding: '0 16px 12px' }}>
[파기] 학생의 계정·제출물·진도·퀴즈·인적사항을 <b>영구 삭제</b>합니다
수습 종료/퇴직 개인정보 파기 의무를 지키는 수단이에요. 되돌릴 없습니다.
</p>
</Card> </Card>
); );
} }
@ -394,7 +429,7 @@ export default function MentorPage() {
{roster.length === 0 ? ( {roster.length === 0 ? (
<p className="empty">아직 가입한 수습생이 없어요. (학생이 회원가입하면 여기에 표시됩니다)</p> <p className="empty">아직 가입한 수습생이 없어요. (학생이 회원가입하면 여기에 표시됩니다)</p>
) : ( ) : (
<StudentRoster students={roster} /> <StudentRoster students={roster} onChanged={load} />
)} )}
{/* ── 학생별 코스 진도 ── */} {/* ── 학생별 코스 진도 ── */}

View File

@ -90,7 +90,7 @@ export default function QuizPage() {
<> <>
<p> <p>
{result.score === result.total {result.score === result.total
? '만점! 이 코스를 완전히 소화했어요 🎉 아래에서 코스 완료를 눌러주세요.' ? '만점! 이 코스를 완전히 소화했어요 🎉 [← 코스로] 돌아가 "이 코스 완료하기"를 눌러 진도를 남겨주세요.'
: result.score >= Math.ceil(result.total * 0.6) : result.score >= Math.ceil(result.total * 0.6)
? '좋아요! 틀린 문제의 해설을 읽고 다시 도전해 보세요.' ? '좋아요! 틀린 문제의 해설을 읽고 다시 도전해 보세요.'
: '괜찮아요, 원래 처음엔 그래요. 코스를 한 번 더 읽고 다시 풀어봐요.'} : '괜찮아요, 원래 처음엔 그래요. 코스를 한 번 더 읽고 다시 풀어봐요.'}