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) => ({CODE_SUBNET}
-