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}
-
-
+
퍼블릭 서브넷 — 인터넷 게이트웨이(정문)와 연결된 대로변.
웹 서버처럼 손님(사용자)을 직접 맞아야 하는 것만 여기에 둬요.
@@ -288,7 +287,6 @@ export default function CloudNetworkPage() {
아무리 두드려도 애초에 길이 없어요.