From 0336213ca22d03dffdbe55aa108dfb6eb7327468 Mon Sep 17 00:00:00 2001 From: AWESOMEDEV Date: Fri, 17 Jul 2026 11:42:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B8=B0=EB=B3=B8=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EC=88=98=20=EA=B0=90=EC=82=AC(38=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8,=20172=ED=8C=8C=EC=9D=BC)=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=EB=90=9C=20=EB=B2=84=EA=B7=B8=2010=EA=B1=B4?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [major] - client.js 401 인터셉터: /auth/** 전체를 예외로 — 비밀번호 변경에서 현재 비번 틀리면 세션 만료로 오인해 /login으로 튕기던 버그 (에러 안내 도달 불가였음) - Submission (assignment_id,user_id) 유니크 제약 추가(엔티티+운영DB) — 동시 제출 시 중복 행 → 이후 해당 과제 재제출 영구 500 방지. 자매 엔티티와 일관화 [minor] - AuthContext: 마운트 시 /auth/me의 늦은 401이 로그인 성공을 덮는 경쟁 → 함수형 업데이트로 보존 - AssignmentsPage: key에 제출 id 포함 → 첫 제출 직후 카드 리마운트로 성공 메시지 소실 → key를 과제 id로 고정, 메시지는 접히는 영역 밖으로 - CodeEditor: 텍스트 선택 후 Tab 시 커서가 선택 길이만큼 밀림 (selectionEnd+2 → selectionStart+2) - div.olist>ol 마크업 오류로 목록 번호 이중 표시 — 7개 강좌 10곳 전부 ol.olist로 통일 (감사가 3파일 발견, 전역 grep으로 4파일 추가 확보) - SignupPage 검증을 서버 규칙과 일치: 아이디 4자+영숫자, 생년월일 과거만 - LoginRequest @NotBlank + @Valid: username 누락 시 NPE 500 → 400 - server.error.include-message: always — 서버의 한국어 에러 사유가 화면까지 전달 ('이미 사용 중인 아이디입니다' 등, 그동안 폴백 문구만 보였음) 검증: 테스트 22개 통과, 운영에서 400/409 message/비번변경 에러표시 확인. 각 버그의 원리를 소스 주석에 학습 포인트로 기록. Co-Authored-By: Claude Opus 4.8 --- .../awesomedev/mirim/domain/Submission.java | 9 +++++++- .../awesomedev/mirim/web/AuthController.java | 2 +- .../mirim/web/dto/LoginRequest.java | 12 ++++++++++- backend/src/main/resources/application.yml | 8 +++++++ frontend/src/AuthContext.jsx | 11 ++++++++-- frontend/src/api/client.js | 14 ++++++++----- frontend/src/components/CodeEditor.jsx | 8 +++++-- frontend/src/pages/AssignmentsPage.jsx | 21 +++++++++++++------ frontend/src/pages/SignupPage.jsx | 9 +++++++- .../src/pages/courses/CloudNetworkPage.jsx | 4 +--- frontend/src/pages/courses/DebuggingPage.jsx | 8 ++----- .../src/pages/courses/DesignSystemPage.jsx | 4 +--- frontend/src/pages/courses/TcpUdpPage.jsx | 8 ++----- .../src/pages/courses/ToolIntellijPage.jsx | 4 +--- .../src/pages/courses/ToolShortcutsPage.jsx | 8 ++----- .../src/pages/courses/ToolTerminalPage.jsx | 4 +--- 16 files changed, 85 insertions(+), 49 deletions(-) diff --git a/backend/src/main/java/dev/awesomedev/mirim/domain/Submission.java b/backend/src/main/java/dev/awesomedev/mirim/domain/Submission.java index 2726e6e..2d63560 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/domain/Submission.java +++ b/backend/src/main/java/dev/awesomedev/mirim/domain/Submission.java @@ -11,7 +11,14 @@ import java.time.Instant; * 멘토가 피드백을 남기면 status가 SUBMITTED → REVIEWED로 바뀐다. */ @Entity -@Table(name = "submissions") +// 학습 포인트: "학생당 과제당 제출물 1개" 규칙의 최종 방어선은 DB 유니크 제약이다. +// 서비스의 '있으면 갱신, 없으면 생성' 검사(check-then-insert)는 거의 동시에 온 +// 두 요청(두 탭에서 동시 제출 등)이 둘 다 "없네? 생성!"을 통과할 수 있다. +// 그러면 중복 행이 생기고, 이후 findByAssignmentAndUser(Optional 반환)가 2건을 만나 +// 그 과제의 재제출이 전부 500으로 죽는다. 제약이 있으면 최악의 경우에도 DB가 한쪽을 거절한다. +// (CodingSubmission·Progress·CourseProgress도 같은 이유로 전부 이 제약을 갖고 있다.) +@Table(name = "submissions", + uniqueConstraints = @UniqueConstraint(columnNames = {"assignment_id", "user_id"})) public class Submission { @Id diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java b/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java index 25e5f8e..49cab74 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/AuthController.java @@ -55,7 +55,7 @@ public class AuthController { /** POST /api/auth/login — 로그인. 성공 시 세션이 만들어지고 사용자 정보를 돌려준다. */ @PostMapping("/login") - public UserResponse login(@RequestBody LoginRequest loginRequest, + public UserResponse login(@Valid @RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) { // 0) 무차별 대입 방어 — 비밀번호를 검증하기 "전에" 잠금부터 확인한다. diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/dto/LoginRequest.java b/backend/src/main/java/dev/awesomedev/mirim/web/dto/LoginRequest.java index 2dda2bf..4b7d581 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/web/dto/LoginRequest.java +++ b/backend/src/main/java/dev/awesomedev/mirim/web/dto/LoginRequest.java @@ -1,11 +1,21 @@ package dev.awesomedev.mirim.web.dto; +import jakarta.validation.constraints.NotBlank; + /** * 이 파일이 하는 일: * 로그인 요청 본문 {username, password}를 담는 DTO다. * * 학습 포인트: record는 필드·생성자·getter를 자동으로 만들어 주는 자바 문법으로, * "데이터를 담기만 하는 그릇"인 DTO에 딱 맞는다. + * + * ⚠️ @NotBlank가 없던 시절의 버그: {"password":"x"}처럼 username 없이 요청하면 + * 컨트롤러 첫 줄의 username.toLowerCase()에서 NullPointerException → 500 서버 오류. + * 사용자 입력은 "당연히 있겠지"가 통하지 않는다 — 없을 수 있는 모든 것을 검증한다. + * (@Valid + @NotBlank 조합이면 null·빈 문자열 모두 400으로 정중히 거절된다.) */ -public record LoginRequest(String username, String password) { +public record LoginRequest( + @NotBlank(message = "아이디를 입력해 주세요.") String username, + @NotBlank(message = "비밀번호를 입력해 주세요.") String password +) { } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 25c27c1..9fde9ad 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -27,6 +27,14 @@ spring: server: port: 8080 + error: + # 학습 포인트: 스프링 기본값은 에러 응답에 message를 "안 싣는" 것이다(never). + # 우리는 ResponseStatusException에 한국어 사유("이미 사용 중인 아이디입니다" 등)를 + # 정성껏 담는데, 이 설정이 없으면 그 문구가 클라이언트에 전달되지 않아 + # 프론트의 err.response.data?.message가 항상 undefined였다(사용자는 두루뭉술한 + # 폴백 문구만 봄). always로 켜서 서버가 쓴 안내가 화면까지 닿게 한다. + # 주의: 예외 메시지가 노출되므로, 예외에는 내부 정보(SQL, 경로 등)를 담지 말 것. + include-message: always servlet: session: timeout: 8h # 하루 근무 동안 로그인 유지 diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx index 2624bea..65f8378 100644 --- a/frontend/src/AuthContext.jsx +++ b/frontend/src/AuthContext.jsx @@ -21,10 +21,17 @@ export function AuthProvider({ children }) { useEffect(() => { // 앱 최초 로딩 시 세션 쿠키가 살아 있는지 서버에 물어본다. + // + // 학습 포인트 — 비동기 경쟁(race condition) 방어. + // 이 요청이 끝나기 전에 사용자가 로그인을 마칠 수 있다(자동완성 + 빠른 제출). + // 그때 뒤늦게 도착한 /auth/me의 401이 setUser(null)로 로그인 결과를 덮으면, + // 로그인은 성공했는데 화면은 로그인 페이지로 튕기는 황당한 버그가 된다. + // 그래서 함수형 업데이트로 "이미 로그인돼 있으면 건드리지 않는다": + // prev가 있으면(=login()이 먼저 끝남) 그대로 두고, 없을 때만 결과를 적용한다. client .get('/auth/me') - .then((res) => setUser(res.data)) - .catch(() => setUser(null)) // 401이면 비로그인 상태 + .then((res) => setUser((prev) => prev ?? res.data)) + .catch(() => setUser((prev) => prev)) // 401 = 비로그인. 단, 먼저 끝난 로그인은 보존 .finally(() => setLoading(false)); }, []); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 97322cf..18c5d01 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -21,11 +21,15 @@ client.interceptors.response.use( (error) => { const status = error.response?.status; const url = error.config?.url || ''; - // 예외: 로그인 시도 자체의 401(비밀번호 틀림)과 - // 로그인 여부 확인용 /auth/me의 401은 리다이렉트하면 안 된다. - // (로그인 페이지에서 무한 새로고침이 되어버린다.) - const isAuthCheck = url.includes('/auth/login') || url.includes('/auth/me'); - if (status === 401 && !isAuthCheck && window.location.pathname !== '/login') { + // 예외: /auth/** 의 401은 "세션 만료"가 아니라 그 기능 자체의 결과다 — + // 로그인 실패(비밀번호 틀림), /auth/me(비로그인 확인), + // 비밀번호 변경(현재 비밀번호 틀림) 모두 각 페이지가 직접 안내한다. + // ⚠️ 실제로 났던 버그: 처음엔 login/me만 예외로 뒀더니, 비밀번호 변경에서 + // 현재 비밀번호를 틀리면 이 인터셉터가 "세션 만료"로 오인해 /login으로 튕겨 버려 + // 사용자가 에러 안내를 영영 못 봤다. 인터셉터처럼 "전역으로 가로채는" 코드는 + // 예외 목록이 좁으면 멀쩡한 기능을 잡아먹는다 — 의미 단위(/auth/ 전체)로 묶는 이유. + const isAuthEndpoint = url.includes('/auth/'); + if (status === 401 && !isAuthEndpoint && window.location.pathname !== '/login') { window.location.href = '/login'; } return Promise.reject(error); diff --git a/frontend/src/components/CodeEditor.jsx b/frontend/src/components/CodeEditor.jsx index 8bf8d9b..9705e7c 100644 --- a/frontend/src/components/CodeEditor.jsx +++ b/frontend/src/components/CodeEditor.jsx @@ -34,9 +34,13 @@ export default function CodeEditor({ value, onChange, readOnly = false, minRows const next = value.slice(0, selectionStart) + ' ' + value.slice(selectionEnd); onChange(next); // 값이 바뀐 뒤에 커서를 옮겨야 하므로 다음 렌더 시점으로 미룬다. + // 커서는 "삽입한 공백 2칸 뒤" = selectionStart + 2. + // ⚠️ 원래 selectionEnd + 2로 잘못 계산했었다 — 텍스트를 선택한 채 Tab을 누르면 + // 선택 영역이 공백으로 치환되는데, 커서가 선택 길이만큼 오른쪽으로 밀렸다. + // (선택이 없을 때는 start == end라 우연히 맞아서 눈에 안 띄었던 버그.) requestAnimationFrame(() => { - textarea.selectionStart = selectionEnd + 2; - textarea.selectionEnd = selectionEnd + 2; + textarea.selectionStart = selectionStart + 2; + textarea.selectionEnd = selectionStart + 2; }); } diff --git a/frontend/src/pages/AssignmentsPage.jsx b/frontend/src/pages/AssignmentsPage.jsx index ba71199..f2ff809 100644 --- a/frontend/src/pages/AssignmentsPage.jsx +++ b/frontend/src/pages/AssignmentsPage.jsx @@ -89,9 +89,6 @@ function AssignmentCard({ assignment, submission, onSubmitted }) { /> {error &&

{error}

} - {savedMessage && ( -

{savedMessage}

- )} @@ -102,6 +99,14 @@ function AssignmentCard({ assignment, submission, onSubmitted }) { )} + + {/* 성공 메시지는 details "바깥"에 둔다 — 첫 제출 시 폼이 접혀도(open={!submission}) + 메시지는 계속 보여야 하니까. 접히는 영역 안에 두면 성공하자마자 같이 숨어 버린다. */} + {savedMessage && ( +

+ ✅ {savedMessage} +

+ )} ); } @@ -213,9 +218,13 @@ export default function AssignmentsPage() { {groupAssignments.map((assignment) => ( = new Date().toISOString().slice(0, 10)) + return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.'; if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.'; if (!form.address.trim()) return '주소를 입력해 주세요.'; if (!form.school.trim()) return '학교를 입력해 주세요.'; diff --git a/frontend/src/pages/courses/CloudNetworkPage.jsx b/frontend/src/pages/courses/CloudNetworkPage.jsx index aa0a9b2..93a4268 100644 --- a/frontend/src/pages/courses/CloudNetworkPage.jsx +++ b/frontend/src/pages/courses/CloudNetworkPage.jsx @@ -276,8 +276,7 @@ export default function CloudNetworkPage() { 배치합니다. 핵심 구분은 딱 하나 — 인터넷에서 직접 들어올 수 있느냐예요.

{CODE_SUBNET} -
-
    +
    1. 퍼블릭 서브넷 — 인터넷 게이트웨이(정문)와 연결된 대로변. 웹 서버처럼 손님(사용자)을 직접 맞아야 하는 것만 여기에 둬요. @@ -288,7 +287,6 @@ export default function CloudNetworkPage() { 아무리 두드려도 애초에 길이 없어요.
    -
VPC (10.0.0.0/16) diff --git a/frontend/src/pages/courses/DebuggingPage.jsx b/frontend/src/pages/courses/DebuggingPage.jsx index 7d0c8ff..7341f21 100644 --- a/frontend/src/pages/courses/DebuggingPage.jsx +++ b/frontend/src/pages/courses/DebuggingPage.jsx @@ -313,14 +313,12 @@ export default function DebuggingPage() { 로그 하나하나가 "이 지점에서 이 값은 뭐지?"라는 질문이 되도록 찍는 게 정석이에요.

{CODE_CONSOLE_LOG} -
-
    +
    1. 라벨을 붙인다'[함수명] 변수명 =' 형식이면 어디서 찍힌 건지 바로 알아요.
    2. 관문마다 찍는다 — 함수 입구·분기 직전·반환 직전. 데이터가 어디서부터 이상해졌는지 드러납니다.
    3. 객체는 통째로 찍는다 — 필드 이름을 내 기억이 아니라 실제 데이터로 확인하세요.
    4. 다 잡으면 지운다 — 디버그 로그를 커밋에 남기면 다음 사람의 콘솔이 쓰레기장이 돼요.
    -
주의 로그가 아예 안 찍히는 것도 중요한 단서입니다! "값이 이상하다"가 아니라 "이 함수 자체가 호출되지 않는다"는 뜻이니까요. @@ -456,13 +454,11 @@ export default function DebuggingPage() { 부끄러운 일이 아니라 팀이 알아야 할 정보입니다.

{CODE_GOOD_QUESTION} -
-
    +
    1. 하려던 것 — 목표를 알아야 답도 목표에 맞출 수 있어요.
    2. 에러 전문 — 요약·스크린샷 말고 텍스트 통째로. 여러분이 지운 그 줄에 답이 있는 경우가 정말 많아요.
    3. 재현 방법 + 시도한 것 — 이걸 쓰다가 스스로 답을 찾는 일이 놀랄 만큼 자주 일어납니다.
    -
직접 확인해 보기 ③ 이번 주에 만난 에러 하나를 골라, 위 템플릿대로 Gitea(edu.awesomedevapp.com:3000) 이슈로 실제로 작성해 보세요. diff --git a/frontend/src/pages/courses/DesignSystemPage.jsx b/frontend/src/pages/courses/DesignSystemPage.jsx index 94833e6..a425ff5 100644 --- a/frontend/src/pages/courses/DesignSystemPage.jsx +++ b/frontend/src/pages/courses/DesignSystemPage.jsx @@ -375,14 +375,12 @@ export default function DesignSystemPage() { 완전히 다른 경험이에요 — 후자를 해 본 사람만 디자인 시스템을 "만들" 수 있습니다.

{CODE_DZ03_TABLE} -
-
    +
    1. Gitea(edu.awesomedevapp.com:3000)에서 티켓 DZ-03을 열고 본인을 담당자로 지정해요.
    2. 위 표의 ①번부터 순서대로 채워요 — ①은 global.css, ②·③은 F12가 무기예요.
    3. 완성한 표를 DZ-03에 댓글로 올리고, "우리 플랫폼에 빠져 있다고 느낀 상태·토큰 1가지"도 함께 적어요.
    4. 멘토 리뷰를 받으면 실습 완료! 지적받은 부분은 표를 고쳐서 다시 올려요.
    -
막히면 섹션 2의 "직접 확인해 보기 ①"로 돌아가서 :root 보는 법을 복습하세요. 값이 var(--...)로 나오면 그 변수를 :root에서 diff --git a/frontend/src/pages/courses/TcpUdpPage.jsx b/frontend/src/pages/courses/TcpUdpPage.jsx index 050b289..345f6c3 100644 --- a/frontend/src/pages/courses/TcpUdpPage.jsx +++ b/frontend/src/pages/courses/TcpUdpPage.jsx @@ -218,13 +218,11 @@ export default function TcpUdpPage() { 3-way 핸드셰이크(세 번의 악수)라고 부릅니다.

{CODE_HANDSHAKE} -
-
    +
    1. SYN — 내 PC가 "대화 시작하고 싶어요" 하고 손을 내밀어요.
    2. SYN+ACK — 서버가 "네 잘 받았어요(ACK), 저도 시작할게요(SYN)" 하고 맞잡아요.
    3. ACK — 내 PC가 "확인했어요!" 하고 악수를 완성해요. 이제 데이터 출발!
    -
내 PC @@ -390,14 +388,12 @@ export default function TcpUdpPage() { cmd → 엔터로 터미널을 열 수 있어요.
{CODE_NETSTAT} -
-
    +
    1. 원격 주소가 :443인 줄을 찾아보세요 — 전부 HTTPS 연결이에요.
    2. 로컬 주소의 5만 번대 포트에 주목 — 내 쪽 임시 포트가 자동 배정된 거예요.
    3. tasklist로 PID의 정체를 밝혀 보세요. 대부분 브라우저일 거예요.
    4. 브라우저를 전부 끄고 다시 실행 — ESTABLISHED 줄이 확 줄어드는 걸 관찰!
    -

여기서 ESTABLISHED 한 줄 한 줄이 섹션 2에서 배운 3-way 악수가 성공적으로 끝난 흔적입니다. 참고로 UDP는 연결이라는 diff --git a/frontend/src/pages/courses/ToolIntellijPage.jsx b/frontend/src/pages/courses/ToolIntellijPage.jsx index 8310f61..119868b 100644 --- a/frontend/src/pages/courses/ToolIntellijPage.jsx +++ b/frontend/src/pages/courses/ToolIntellijPage.jsx @@ -269,14 +269,12 @@ export default function ToolIntellijPage() { 디버거는 다릅니다 — 시간을 멈추고 현장을 직접 걸어다니는 방식이거든요.

{CODE_DEBUGGER} -
-
    +
    1. 의심 가는 줄에 브레이크포인트(빨간 점)를 찍고,
    2. 벌레 아이콘(Debug)으로 실행한 뒤,
    3. 멈춘 순간의 Variables 창에서 변수의 진짜 값을 확인 — 내 상상과 다른 값이 들어 있는 지점이 바로 버그의 현장입니다.
    -

F8(한 줄씩)과 F7(메서드 안으로)의 차이만 익혀도 충분해요. 특정 변수를 계속 지켜보고 싶으면 우클릭 → diff --git a/frontend/src/pages/courses/ToolShortcutsPage.jsx b/frontend/src/pages/courses/ToolShortcutsPage.jsx index 4477513..cd6b28a 100644 --- a/frontend/src/pages/courses/ToolShortcutsPage.jsx +++ b/frontend/src/pages/courses/ToolShortcutsPage.jsx @@ -219,13 +219,11 @@ export default function ToolShortcutsPage() { 머릿속 생각도 잠깐 끊깁니다. 단축키가 손에 붙으면 "창 바꿔야지"라는 생각조차 없이 손가락이 먼저 움직여요 — 자전거 탈 때 "페달을 밟아야지" 하고 생각하지 않는 것처럼요.

-
-
    +
    1. 이번 주: 섹션 2~4의 표에서 내가 자주 하는 동작 5개만 고른다.
    2. 그 5개를 쓸 때마다 마우스 대신 단축키로 — 어색해도 참는다.
    3. 손에 붙으면(대략 3일) 다음 5개를 추가한다.
    -
한꺼번에 30개 외우려 하지 마세요 — 단축키는 암기 과목이 아니라 운동이에요. 하루 5개씩, 실제 상황에서 손으로 반복하는 게 @@ -409,13 +407,11 @@ export default function ToolShortcutsPage() {

{CODE_MISSION} {CODE_BINGO} -
-
    +
    1. 미션 1(마우스 없이 창 전환)을 오늘 시작한다 — 벌점을 메모장에 정직하게 기록.
    2. 빙고판을 종이에 그리거나 인쇄해서 모니터 옆에 붙인다.
    3. 3일 안에 빙고 3줄을 완성하면 멘토에게 자랑한다. (진짜로 자랑해도 됩니다)
    -
공부 팁 새 단축키가 손에 붙는 데는 보통 3일의 어색함이 필요해요. "마우스가 더 빠른데?" 싶은 그 3일을 버티는 게 이 코스의 전부입니다. diff --git a/frontend/src/pages/courses/ToolTerminalPage.jsx b/frontend/src/pages/courses/ToolTerminalPage.jsx index 59521d4..2ef0aa4 100644 --- a/frontend/src/pages/courses/ToolTerminalPage.jsx +++ b/frontend/src/pages/courses/ToolTerminalPage.jsx @@ -276,13 +276,11 @@ export default function ToolTerminalPage() { 모아 주는 마이크로소프트의 공식 터미널 앱이에요. 크롬에서 탭 여러 개 쓰듯이, 탭마다 다른 셸을 띄울 수 있죠.

-
-
    +
    1. 윈도우 11이라면 이미 기본 설치돼 있어요 — 시작 메뉴에서 "터미널" 검색.
    2. 없다면 Microsoft Store에서 "Windows Terminal" 검색 → 설치. 끝!
    3. 열면 기본으로 PowerShell 탭이 하나 떠요. 탭 줄의 버튼을 누르면 다른 셸 목록이 나옵니다.
    -
{CODE_WT_KEYS}
직접 확인해 보기 Windows Terminal을 열고 Alt +