Skip to content

Commit 5e46ee6

Browse files
authored
feat : 채점 중 중복 제출 방지 기능 구현 (#70)
* refactor : 쓰레드 풀 추가 * refactor : 예외처리 고도화 * refactor : Redis Stream Cleanup * feat : Discord 알림 * fix : 비동기 메서드에서 시큐리티 컨텍스트 전파 되지 않는 현상 수정 * fix : CI 환경 변수 추가 * refactor : 로깅 처리 변경 * refactor : 예외 처리 변경 * refactor : 예외처리 고도화 # Conflicts: # src/main/java/org/ezcode/codetest/common/base/exception/GlobalExceptionHandler.java * fix : 비동기 메서드에서 시큐리티 컨텍스트 전파 되지 않는 현상 수정 * refactor : 토끼 리뷰 반영 * refactor : 토끼 리뷰 반영 * hotfix : 제출 안 되는 부분 해결 * feat : 채점하는 동안 중복 제출 방지 구현 * refactor : 콜백 함수 위치 변경
1 parent c847375 commit 5e46ee6

File tree

7 files changed

+126
-52
lines changed

7 files changed

+126
-52
lines changed

src/main/java/org/ezcode/codetest/application/submission/port/EmitterStore.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66

77
public interface EmitterStore {
88

9-
void save(String key, SseEmitter emitter);
9+
void saveWithCallbacks(String key, SseEmitter emitter);
1010

1111
Optional<SseEmitter> get(String key);
1212

13+
SseEmitter getOrElseThrow(String key);
14+
1315
void remove(String key);
1416

1517
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.ezcode.codetest.application.submission.port;
2+
3+
public interface LockManager {
4+
5+
boolean tryLock(Long userId, Long problemId);
6+
7+
void releaseLock(Long userId, Long problemId);
8+
9+
}

src/main/java/org/ezcode/codetest/application/submission/service/SubmissionService.java

Lines changed: 48 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.ezcode.codetest.application.submission.service;
22

33
import java.util.List;
4+
import java.util.Optional;
45
import java.util.UUID;
56
import java.util.concurrent.CompletableFuture;
67
import java.util.concurrent.Executor;
@@ -14,6 +15,7 @@
1415
import org.ezcode.codetest.application.submission.model.ReviewResult;
1516
import org.ezcode.codetest.application.submission.model.SubmissionContext;
1617
import org.ezcode.codetest.application.submission.port.ExceptionNotifier;
18+
import org.ezcode.codetest.application.submission.port.LockManager;
1719
import org.ezcode.codetest.application.submission.port.QueueProducer;
1820
import org.ezcode.codetest.domain.submission.exception.SubmissionException;
1921
import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode;
@@ -61,24 +63,21 @@ public class SubmissionService {
6163
private final QueueProducer queueProducer;
6264
private final Executor judgeTestcaseExecutor;
6365
private final ExceptionNotifier exceptionNotifier;
66+
private final LockManager lockManager;
6467

6568
public SseEmitter enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) {
69+
70+
boolean acquired = lockManager.tryLock(authUser.getId(), problemId);
71+
72+
if (!acquired) {
73+
throw new SubmissionException(SubmissionExceptionCode.ALREADY_JUDGING);
74+
}
75+
6676
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);
6777
String emitterKey = authUser.getId() + "_" + UUID.randomUUID();
6878

69-
emitter.onCompletion(() -> log.info("[SSE 완료] 정상 종료됨"));
70-
emitter.onTimeout(() -> {
71-
log.warn("[SSE 타임아웃] 연결 시간이 초과되었습니다");
72-
emitter.completeWithError(new SubmissionException(SubmissionExceptionCode.EMITTER_SEND_ERROR));
73-
emitterStore.remove(emitterKey);
74-
});
75-
emitter.onError(e -> {
76-
log.error("[SSE 에러 발생] 예외: {}", e.toString(), e);
77-
emitterStore.remove(emitterKey);
78-
});
79-
8079
log.info("[SSE 저장] emitterKey: {}", emitterKey);
81-
emitterStore.save(emitterKey, emitter);
80+
emitterStore.saveWithCallbacks(emitterKey, emitter);
8281

8382
queueProducer.enqueue(
8483
new SubmissionMessage(emitterKey, problemId, request.languageId(), authUser.getId(), request.sourceCode())
@@ -95,49 +94,24 @@ public void submitCodeStream(SubmissionMessage msg) {
9594
User user = userDomainService.getUserById(msg.userId());
9695
Language language = languageDomainService.getLanguage(msg.languageId());
9796
ProblemInfo problemInfo = problemDomainService.getProblemInfo(msg.problemId());
98-
SseEmitter emitter = emitterStore.get(msg.emitterKey()).orElseThrow(
99-
() -> new SubmissionException(SubmissionExceptionCode.EMITTER_NOT_FOUND)
100-
);
97+
SseEmitter emitter = emitterStore.getOrElseThrow(msg.emitterKey());
10198

10299
int totalTestcaseCount = problemInfo.getTestcaseCount();
103100
SubmissionContext context = SubmissionContext.initialize(totalTestcaseCount);
104101

105102
for (Testcase tc : problemInfo.testcaseList()) {
106-
CompletableFuture.runAsync(() -> {
107-
try {
108-
log.info("[Judge RUN] Thread = {}", Thread.currentThread().getName());
109-
String token = judgeClient.submitAndGetToken(
110-
new CodeCompileRequest(msg.sourceCode(), language.getJudge0Id(), tc.getInput())
111-
);
112-
JudgeResult result = judgeClient.pollUntilDone(token);
113-
114-
AnswerEvaluation evaluation = submissionDomainService.handleEvaluationAndUpdateStats(
115-
TestcaseEvaluationInput.from(tc, result), problemInfo, context
116-
);
117-
emitter.send(JudgeResultResponse.fromEvaluation(result, evaluation));
118-
} catch (Exception e) {
119-
if (context.notified().compareAndSet(false, true)) {
120-
emitter.completeWithError(e);
121-
emitterStore.remove(msg.emitterKey());
122-
exceptionNotificationHelper(e);
123-
}
124-
} finally {
125-
context.countDown();
126-
}
127-
}, judgeTestcaseExecutor);
103+
runTestcaseAsync(tc, msg, language.getJudge0Id(), problemInfo, context, emitter);
128104
}
129105

130106
if (!context.latch().await(60, TimeUnit.SECONDS)) {
131107
emitter.completeWithError(new SubmissionException(SubmissionExceptionCode.TESTCASE_TIMEOUT));
132-
emitterStore.remove(msg.emitterKey());
133108
return;
134109
}
135110

136111
emitter.send(SseEmitter.event()
137112
.name("final")
138113
.data(context.toFinalResult(totalTestcaseCount)));
139-
emitter.complete();
140-
emitterStore.remove(msg.emitterKey());
114+
emitter.complete();
141115

142116
SubmissionData submissionData = SubmissionData.base(
143117
user, problemInfo, language, msg.sourceCode(), context.getCurrentMessage()
@@ -148,8 +122,10 @@ public void submitCodeStream(SubmissionMessage msg) {
148122
);
149123
} catch (Exception e) {
150124
emitterStore.get(msg.emitterKey()).ifPresent(emitter -> emitter.completeWithError(e));
151-
emitterStore.remove(msg.emitterKey());
152125
exceptionNotificationHelper(e);
126+
} finally {
127+
emitterStore.remove(msg.emitterKey());
128+
lockManager.releaseLock(msg.userId(), msg.problemId());
153129
}
154130
}
155131

@@ -162,7 +138,6 @@ public List<GroupedSubmissionResponse> getSubmissions(AuthUser authUser) {
162138
}
163139

164140
public CodeReviewResponse getCodeReview(Long problemId, CodeReviewRequest request) {
165-
166141
Problem problem = problemDomainService.getProblem(problemId);
167142
Language language = languageDomainService.getLanguage(request.languageId());
168143

@@ -171,8 +146,36 @@ public CodeReviewResponse getCodeReview(Long problemId, CodeReviewRequest reques
171146
return new CodeReviewResponse(reviewResult.reviewContent());
172147
}
173148

174-
private void exceptionNotificationHelper(Exception e) {
175-
if (e instanceof SubmissionException se) {
149+
private void runTestcaseAsync(
150+
Testcase tc, SubmissionMessage msg, Long judge0Id,
151+
ProblemInfo problemInfo, SubmissionContext context, SseEmitter emitter
152+
) {
153+
CompletableFuture.runAsync(() -> {
154+
try {
155+
log.info("[Judge RUN] Thread = {}", Thread.currentThread().getName());
156+
String token = judgeClient.submitAndGetToken(
157+
new CodeCompileRequest(msg.sourceCode(), judge0Id, tc.getInput())
158+
);
159+
JudgeResult result = judgeClient.pollUntilDone(token);
160+
161+
AnswerEvaluation evaluation = submissionDomainService.handleEvaluationAndUpdateStats(
162+
TestcaseEvaluationInput.from(tc, result), problemInfo, context
163+
);
164+
emitter.send(JudgeResultResponse.fromEvaluation(result, evaluation));
165+
} catch (Exception e) {
166+
if (context.notified().compareAndSet(false, true)) {
167+
emitter.completeWithError(e);
168+
emitterStore.remove(msg.emitterKey());
169+
exceptionNotificationHelper(e);
170+
}
171+
} finally {
172+
context.countDown();
173+
}
174+
}, judgeTestcaseExecutor);
175+
}
176+
177+
private void exceptionNotificationHelper(Throwable t) {
178+
if (t instanceof SubmissionException se) {
176179
var code = se.getResponseCode();
177180
exceptionNotifier.sendEmbed(
178181
"채점 예외",
@@ -192,10 +195,9 @@ private void exceptionNotificationHelper(Exception e) {
192195
• 성공 여부: false
193196
• 상태코드: 500
194197
• 메시지: %s
195-
""".formatted(e.getMessage()),
198+
""".formatted(Optional.ofNullable(t.getMessage()).orElse("No message")),
196199
"submitCodeStream"
197200
);
198201
}
199-
200202
}
201203
}

src/main/java/org/ezcode/codetest/domain/submission/exception/code/SubmissionExceptionCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ public enum SubmissionExceptionCode implements ResponseCode {
1515
COMPILE_SERVER_ERROR(false, HttpStatus.BAD_GATEWAY, "컴파일 서버 오류"),
1616
COMPILE_TIMEOUT(false, HttpStatus.GATEWAY_TIMEOUT, "컴파일 서버로부터 응답이 지연되고 있습니다."),
1717
TESTCASE_TIMEOUT(false, HttpStatus.GATEWAY_TIMEOUT, "테스트케이스 채점 시간이 초과되었습니다."),
18-
REDIS_SERVER_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "Redis 서버 연결 실패")
18+
REDIS_SERVER_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "Redis 서버 연결 실패"),
19+
ALREADY_JUDGING(false, HttpStatus.CONFLICT, "이미 해당 문제에 대한 채점이 진행 중입니다."),
1920
;
2021
private final boolean success;
2122
private final HttpStatus status;

src/main/java/org/ezcode/codetest/infrastructure/event/listener/RedisJudgeQueueConsumer.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ public void onMessage(MapRecord<String, String, String> message) {
3737
try {
3838
log.info("[컨슈머 수신] {}", msg.emitterKey());
3939
submissionService.submitCodeStream(msg);
40+
41+
log.info("[컨슈머 ACK] messageId={}", message.getId());
42+
redisTemplate.opsForStream().acknowledge("judge-group", message);
4043
} catch (Exception e) {
4144
log.error("채점 메시지 처리 실패: {}", message.getId(), e);
4245
throw new SubmissionException(SubmissionExceptionCode.REDIS_SERVER_ERROR);
43-
} finally {
44-
log.info("[컨슈머 ACK] messageId={}", message.getId());
45-
redisTemplate.opsForStream().acknowledge("judge-group", message);
4646
}
4747
}
4848
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.ezcode.codetest.infrastructure.lock;
2+
3+
import java.time.Duration;
4+
5+
import org.ezcode.codetest.application.submission.port.LockManager;
6+
import org.springframework.data.redis.core.StringRedisTemplate;
7+
import org.springframework.stereotype.Component;
8+
9+
import lombok.RequiredArgsConstructor;
10+
11+
@Component
12+
@RequiredArgsConstructor
13+
public class RedisLockManager implements LockManager {
14+
15+
private final StringRedisTemplate redisTemplate;
16+
17+
private static final String LOCK_KEY_FORMAT = "submission-lock:user:%d:problem:%d";
18+
private static final Duration LOCK_DURATION = Duration.ofMinutes(5);
19+
20+
@Override
21+
public boolean tryLock(Long userId, Long problemId) {
22+
String key = getKey(userId, problemId);
23+
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "LOCKED", LOCK_DURATION);
24+
return Boolean.TRUE.equals(success);
25+
}
26+
27+
@Override
28+
public void releaseLock(Long userId, Long problemId) {
29+
redisTemplate.delete(getKey(userId, problemId));
30+
}
31+
32+
private String getKey(Long userId, Long problemId) {
33+
return LOCK_KEY_FORMAT.formatted(userId, problemId);
34+
}
35+
}

src/main/java/org/ezcode/codetest/infrastructure/sse/InMemoryEmitterStore.java

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,49 @@
55
import java.util.concurrent.ConcurrentHashMap;
66

77
import org.ezcode.codetest.application.submission.port.EmitterStore;
8+
import org.ezcode.codetest.domain.submission.exception.SubmissionException;
9+
import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode;
810
import org.springframework.stereotype.Component;
911
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
1012

13+
import lombok.extern.slf4j.Slf4j;
14+
15+
@Slf4j
1116
@Component
1217
public class InMemoryEmitterStore implements EmitterStore {
1318

1419
private final Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();
1520

1621
@Override
17-
public void save(String key, SseEmitter emitter) {
22+
public void saveWithCallbacks(String key, SseEmitter emitter) {
1823
emitterMap.put(key, emitter);
24+
25+
emitter.onCompletion(() -> log.info("[SSE 완료] 정상 종료됨"));
26+
27+
emitter.onTimeout(() -> {
28+
log.warn("[SSE 타임아웃] 연결 시간이 초과되었습니다");
29+
emitter.completeWithError(new SubmissionException(SubmissionExceptionCode.EMITTER_SEND_ERROR));
30+
remove(key);
31+
});
32+
33+
emitter.onError(e -> {
34+
log.error("[SSE 에러 발생] 예외: {}", e.toString(), e);
35+
remove(key);
36+
});
37+
1938
}
2039

2140
@Override
2241
public Optional<SseEmitter> get(String key) {
2342
return Optional.ofNullable(emitterMap.get(key));
2443
}
2544

45+
@Override
46+
public SseEmitter getOrElseThrow(String key) {
47+
return Optional.ofNullable(emitterMap.get(key))
48+
.orElseThrow(() -> new SubmissionException(SubmissionExceptionCode.EMITTER_NOT_FOUND));
49+
}
50+
2651
@Override
2752
public void remove(String key) {
2853
emitterMap.remove(key);

0 commit comments

Comments
 (0)