Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ public record CodeSubmitRequest(
example = "public class Main { public static void main(String[] args) { System.out.println(\"Hello World\"); } }"
)
@NotBlank(message = "소스 코드는 필수 입력 값입니다.")
String sourceCode
String sourceCode,

@NotBlank(message = "세션 키는 필수 입력 값입니다.")
String sessionKey

) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ public class SubmissionService {
private final JudgementService judgementService;
private final GitHubPushService gitHubPushService;

public SubmitResponse enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) {

public SubmitResponse prepareSubmission(Long problemId, AuthUser authUser) {
boolean acquired = lockManager.tryLock("submission", authUser.getId(), problemId);
if (!acquired) {
throw new SubmissionException(SubmissionExceptionCode.ALREADY_JUDGING);
Expand All @@ -65,11 +64,13 @@ public SubmitResponse enqueueCodeSubmission(Long problemId, CodeSubmitRequest re
String sessionKey = authUser.getId() + "_" + UUID.randomUUID();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

세션 키 생성 방식 검토 권장

현재 세션 키 생성 방식(userId + "_" + UUID.randomUUID())은 기본적으로 안전하지만, 동시성 환경에서의 유일성 보장을 위해 추가 검증이 필요할 수 있습니다.

다음 스크립트로 세션 키 생성 방식의 사용 현황을 확인하세요:


🏁 Script executed:

#!/bin/bash
# 세션 키 생성 패턴과 관련된 코드를 검색합니다.
rg -A 3 -B 3 "sessionKey.*UUID" --type java
rg -A 3 -B 3 "UUID\.randomUUID" --type java

Length of output: 6023


세션 키 생성 로직 강화 검토 요청

프로젝트 전역에서 UUID.randomUUID() 기반 세션 키 생성은 SubmissionService.java 64행에서만 사용되고 있음을 확인했습니다.
현재 userId + "_" + UUID.randomUUID() 방식은 충돌 확률이 극히 낮지만, 동시성 환경에서는 중복 키가 생성될 가능성을 완전히 배제할 수 없으므로 유일성 보장을 위한 추가 검증이 필요합니다.

제안 사항:

  • 재생성 루프(do–while)로 중복 키 검증 후 재생성 로직 추가
  • ULID, Snowflake 등 충돌 방지에 특화된 ID 생성 라이브러리 도입
  • Spring Session, JWT 등 검증된 세션 관리 프레임워크 사용

예시:

String sessionKey;
do {
    sessionKey = authUser.getId() + "_" + UUID.randomUUID();
} while (sessionRepository.existsByKey(sessionKey));
🤖 Prompt for AI Agents
In
src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java
at line 64, the current session key generation using userId + "_" +
UUID.randomUUID() lacks a uniqueness check which may cause collisions in
concurrent environments. Modify the code to include a do-while loop that
generates a session key and checks its existence in the session repository,
regenerating if a duplicate is found. Alternatively, consider integrating a
collision-resistant ID generator like ULID or Snowflake, or use a robust session
management framework such as Spring Session or JWT for better uniqueness
guarantees.

List<Testcase> testcaseList = testcaseDomainService.getTestcaseListByProblemId(problemId);

return SubmitResponse.of(sessionKey, testcaseList);
}

public void enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) {
queueProducer.enqueue(
new SubmissionMessage(sessionKey, problemId, request.languageId(), authUser.getId(), request.sourceCode())
SubmissionMessage.of(request, problemId, authUser.getId())
);

return SubmitResponse.of(sessionKey, testcaseList);
}

@Async("judgeSubmissionExecutor")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.ezcode.codetest.infrastructure.event.dto.submission;

import org.ezcode.codetest.application.submission.dto.request.submission.CodeSubmitRequest;

public record SubmissionMessage(

String sessionKey,
Expand All @@ -13,4 +15,13 @@ public record SubmissionMessage(
String sourceCode

) {
public static SubmissionMessage of(CodeSubmitRequest request, Long problemId, Long userId) {
return new SubmissionMessage(
request.sessionKey(),
problemId,
request.languageId(),
userId,
request.sourceCode()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,37 +32,46 @@ public class SubmissionController {

private final SubmissionService submissionService;

@PostMapping("/problems/{problemId}/submit-ws")
@PostMapping("/problems/{problemId}/submit-prepare")
public ResponseEntity<SubmitResponse> prepareSubmission(
@PathVariable Long problemId,
@AuthenticationPrincipal AuthUser authUser
) {
return ResponseEntity
.status(HttpStatus.OK)
.body(submissionService.prepareSubmission(problemId, authUser));
}

@PostMapping("/problems/{problemId}/submit-ready")
@Operation(
summary = "코드 제출 (WebSocket)",
description = """
문제에 대한 코드를 제출하면 채점 큐에 등록되고,
서버는 WebSocket(STOMP)을 통해 채점 결과를 실시간으로 전송합니다.

반환된 sessionKey를 사용해 다음 경로로 구독하세요.

• /user/queue/submission/{sessionKey}/case

• /user/queue/submission/{sessionKey}/final

• /topic/submission/{sessionKey}/error

• /user/queue/submission/{sessionKey}/git-status
"""
문제에 대한 코드를 제출하면 채점 큐에 등록되고,
서버는 WebSocket(STOMP)을 통해 채점 결과를 실시간으로 전송합니다.
반환된 sessionKey를 사용해 다음 경로로 구독하세요.
• /user/queue/submission/{sessionKey}/case
• /user/queue/submission/{sessionKey}/final
• /topic/submission/{sessionKey}/error
• /user/queue/submission/{sessionKey}/git-status
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "코드 제출 성공 및 sessionKey 반환"),
@ApiResponse(responseCode = "400", description = "유효하지 않은 요청 데이터"),
@ApiResponse(responseCode = "409", description = "이미 해당 문제를 채점 중인 경우"),
})
public ResponseEntity<SubmitResponse> submitCodeStream(
public ResponseEntity<Void> submitCodeStream(
@Parameter(description = "제출할 문제 ID", required = true) @PathVariable Long problemId,
@RequestBody @Valid CodeSubmitRequest request,
@AuthenticationPrincipal AuthUser authUser
) {
return ResponseEntity
.status(HttpStatus.OK)
.body(submissionService.enqueueCodeSubmission(problemId, request, authUser));
submissionService.enqueueCodeSubmission(problemId, request, authUser);
return ResponseEntity.status(HttpStatus.OK).build();
}

@Operation(
Expand Down
45 changes: 32 additions & 13 deletions src/main/resources/templates/test-submit.html
Original file line number Diff line number Diff line change
Expand Up @@ -953,32 +953,45 @@ <h3>채점 결과</h3>
const pid = window.problemId;
const languageId = +document.getElementById('language').value;
const sourceCode = window.getEditorCode();
const payload = { languageId, sourceCode };
const judgeEl = document.getElementById('judgeResult');
judgeEl.innerHTML = '';
reviewBox.style.display = 'none';
reviewBtn.disabled = true;
finalIsCorrect = null;

try {
const res = await fetch(`/api/problems/${pid}/submit-ws`, {
const res = await fetch(`/api/problems/${pid}/submit-prepare`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...tokenHeader()
},
body: JSON.stringify(payload)
});
const resp = await res.json();
Comment on lines 962 to 970
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

submit-prepare 호출 시 불필요한 Content-Type: application/json 헤더 제거 필요

바디가 없는 POST 요청에 Content-Type: application/json 헤더를 넣으면 서버(특히 Spring MVC)는 빈 JSON 파싱을 시도하다가 HttpMessageNotReadableException 을 던질 수 있습니다. 실제 바디가 필요 없다면 헤더를 제거하거나, 바디(JSON {})를 전달해 주세요.

-                    headers: {
-                        'Content-Type': 'application/json',
-                        ...tokenHeader()
-                    },
+                    headers: {
+                        ...tokenHeader()
+                    },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const res = await fetch(`/api/problems/${pid}/submit-ws`, {
const res = await fetch(`/api/problems/${pid}/submit-prepare`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...tokenHeader()
},
body: JSON.stringify(payload)
});
const resp = await res.json();
try {
const res = await fetch(`/api/problems/${pid}/submit-prepare`, {
method: 'POST',
headers: {
...tokenHeader()
},
});
const resp = await res.json();
🤖 Prompt for AI Agents
In src/main/resources/templates/test-submit.html around lines 962 to 970, the
POST request to `/api/problems/${pid}/submit-prepare` includes a 'Content-Type:
application/json' header without a request body, which can cause server errors.
Remove the 'Content-Type' header from the request headers or add an empty JSON
body '{}' to the fetch call to prevent the server from attempting to parse a
non-existent JSON body.

console.log('[DEBUG] 전체 응답:', resp);
console.log('[DEBUG] result:', resp.result);
if (!resp.success) {
judgeEl.textContent = `Error: ${resp.message}`;
judgeEl.textContent = `${resp.message}`;
return;
}
const { sessionKey, testcaseIds } = resp.result;
initWebSocket(sessionKey);
renderInitSlots(testcaseIds);

initWebSocket(sessionKey, async () => {
renderInitSlots(testcaseIds);

await fetch(`/api/problems/${pid}/submit-ready`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...tokenHeader()
},
body: JSON.stringify({
sessionKey,
languageId,
sourceCode
})
});
});
Comment on lines +979 to +994
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

submit-ready 요청 실패 시 UI 피드백·예외 처리 누락

onReady 콜백 내부에서 fetch 결과를 확인하지 않아 4xx/5xx 응답, 네트워크 오류가 발생해도 사용자에게 아무런 안내가 되지 않습니다. 최소한 res.ok 체크와 try/catch 를 추가해 주세요.

-                initWebSocket(sessionKey, async () => {
-                    renderInitSlots(testcaseIds);
-
-                    await fetch(`/api/problems/${pid}/submit-ready`, {
+                initWebSocket(sessionKey, async () => {
+                    renderInitSlots(testcaseIds);
+
+                    try {
+                        const readyRes = await fetch(`/api/problems/${pid}/submit-ready`, {
                         ...
-                    });
+                        });
+                        if (!readyRes.ok) {
+                            judgeEl.textContent = `채점 큐 등록 실패 (HTTP ${readyRes.status})`;
+                            return;
+                        }
+                    } catch (e) {
+                        console.error(e);
+                        judgeEl.textContent = '채점 큐 등록 중 오류 발생';
+                    }
                 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
initWebSocket(sessionKey, async () => {
renderInitSlots(testcaseIds);
await fetch(`/api/problems/${pid}/submit-ready`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...tokenHeader()
},
body: JSON.stringify({
sessionKey,
languageId,
sourceCode
})
});
});
initWebSocket(sessionKey, async () => {
renderInitSlots(testcaseIds);
try {
const readyRes = await fetch(`/api/problems/${pid}/submit-ready`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...tokenHeader()
},
body: JSON.stringify({
sessionKey,
languageId,
sourceCode
})
});
if (!readyRes.ok) {
judgeEl.textContent = `채점 큐 등록 실패 (HTTP ${readyRes.status})`;
return;
}
} catch (e) {
console.error(e);
judgeEl.textContent = '채점 큐 등록 중 오류 발생';
}
});
🤖 Prompt for AI Agents
In src/main/resources/templates/test-submit.html around lines 979 to 994, the
fetch call inside the onReady callback does not handle errors or check the
response status. To fix this, wrap the fetch call in a try/catch block and check
if the response's ok property is true. If not, provide appropriate UI feedback
or error handling to inform the user of the failure.

} catch (err) {
console.error(err);
judgeEl.textContent = '채점 요청 실패';
Expand Down Expand Up @@ -1058,7 +1071,7 @@ <h3>채점 결과</h3>
// submitCode() 에서 호출할 함수
window.getEditorCode = () => editor.getValue();

function initWebSocket(sessionKey) {
function initWebSocket(sessionKey, onReady) {
const tokenOnly = (sessionStorage.getItem('accessToken') || '').replace(/^Bearer /, '');
const socket = new SockJS(`/ws?token=${encodeURIComponent(tokenOnly)}`);
stompClient = Stomp.over(socket);
Expand All @@ -1082,7 +1095,13 @@ <h3>채점 결과</h3>
else if (status === 'FAILED') gitDot.style.backgroundColor = '#ff4d4d';
else gitDot.style.backgroundColor = 'gray';
});
}, err => console.error('[STOMP] 연결 오류:', err));

if (typeof onReady === 'function') {
onReady();
}
}, err => {
console.error('[STOMP] 연결 오류:', err);
});
}

function handleCase(data) {
Expand Down Expand Up @@ -1431,7 +1450,7 @@ <h3>채점 결과</h3>
if (res.ok) {
// 성공: 목록 새로고침
if (item.classList.contains('discussion-item')) {
loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best');
await loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best');
} else {
const discussionId = item.closest('.discussion-item').dataset.discussionId;
const container = document.getElementById(`replies-for-${discussionId}`);
Expand All @@ -1457,7 +1476,7 @@ <h3>채점 결과</h3>
const discussionId = item.closest('.discussion-item').dataset.discussionId;
url = `/api/problems/${window.problemId}/discussions/${discussionId}/replies/${e.target.dataset.id}`;
}
(async () => {
await (async () => {
const res = await fetch(url, {
method,
headers: {
Expand All @@ -1466,7 +1485,7 @@ <h3>채점 결과</h3>
});
if (res.ok) {
if (item.classList.contains('discussion-item')) {
loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best');
await loadDiscussions(window.problemId, 0, 10, document.getElementById('discussion-sort')?.value || 'best');
} else {
const discussionId = item.closest('.discussion-item').dataset.discussionId;
const container = document.getElementById(`replies-for-${discussionId}`);
Expand All @@ -1493,7 +1512,7 @@ <h3>채점 결과</h3>
body: JSON.stringify({ voteType })
});
if (res.ok) {
updateDiscussionVoteUI(discussionId);
await updateDiscussionVoteUI(discussionId);
} else {
alert('추천/비추천에 실패했습니다.');
}
Expand Down Expand Up @@ -1534,7 +1553,7 @@ <h3>채점 결과</h3>
body: JSON.stringify({ voteType })
});
if (res.ok) {
updateReplyVoteUI(discussionId, replyId);
await updateReplyVoteUI(discussionId, replyId);
} else {
alert('추천/비추천에 실패했습니다.');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.ezcode.codetest.domain.language.service.LanguageDomainService;
import org.ezcode.codetest.domain.problem.model.ProblemInfo;
import org.ezcode.codetest.domain.problem.model.entity.Problem;
import org.ezcode.codetest.domain.problem.model.entity.Testcase;
import org.ezcode.codetest.domain.problem.service.ProblemDomainService;
import org.ezcode.codetest.domain.problem.service.TestcaseDomainService;
import org.ezcode.codetest.domain.submission.exception.SubmissionException;
Expand Down Expand Up @@ -125,28 +126,44 @@ void setup() {
class SuccessCases {

@Test
@DisplayName("락 획득 성공 -> 메세지 큐에 전송하고 sessionKey 반환")
@DisplayName("락 획득 성공 -> sessionKey, testcaseList 반환")
void prepareSubmissionSuccess() {

// given
given(authUser.getId()).willReturn(userId);
given(lockManager.tryLock("submission", userId, problemId)).willReturn(true);

List<Testcase> testcaseList = List.of(mock(Testcase.class), mock(Testcase.class));
given(testcaseDomainService.getTestcaseListByProblemId(problemId)).willReturn(testcaseList);

// when
SubmitResponse result = submissionService.prepareSubmission(problemId, authUser);

// then
assertThat(result.sessionKey()).startsWith(userId + "_");
assertThat(result.testcaseIds()).hasSize(2);
then(lockManager).should().tryLock("submission", userId, problemId);
then(testcaseDomainService).should().getTestcaseListByProblemId(problemId);
}

@Test
@DisplayName("큐에 메세지 전송 확인")
void enqueueCodeSubmissionSucceeds() {

// given
given(authUser.getId()).willReturn(userId);
given(request.languageId()).willReturn(languageId);
given(request.sourceCode()).willReturn(sourceCode);
given(lockManager.tryLock("submission", authUser.getId(), problemId))
.willReturn(true);
given(testcaseDomainService.getTestcaseListByProblemId(problemId)).willReturn(List.of());

// when
SubmitResponse result = submissionService.enqueueCodeSubmission(problemId, request, authUser);
submissionService.enqueueCodeSubmission(problemId, request, authUser);

then(queueProducer).should()
.enqueue(argThat(msg ->
msg.sessionKey().startsWith(sessionKey) &&
msg.problemId().equals(problemId) &&
msg.languageId().equals(languageId) &&
msg.sourceCode().equals(sourceCode)
));
assertThat(result.sessionKey()).startsWith(sessionKey);
}

@Test
Expand Down Expand Up @@ -251,7 +268,7 @@ class FailureCases {

@Test
@DisplayName("락 획득 실패 -> ALREADY_JUDGING 예외")
void enqueueCodeSubmissionFailed() {
void prepareSubmissionFailed() {

// given
given(authUser.getId()).willReturn(userId);
Expand All @@ -260,7 +277,7 @@ void enqueueCodeSubmissionFailed() {

// when & then
assertThatThrownBy(() ->
submissionService.enqueueCodeSubmission(problemId, request, authUser)
submissionService.prepareSubmission(problemId, authUser)
)
.isInstanceOf(SubmissionException.class)
.hasMessage(SubmissionExceptionCode.ALREADY_JUDGING.getMessage());
Expand Down