Skip to content

Commit c0597cb

Browse files
authored
fix : submission-stream (#66)
* 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 : 토끼 리뷰 반영
1 parent e9c72b7 commit c0597cb

File tree

17 files changed

+317
-108
lines changed

17 files changed

+317
-108
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ jobs:
3434
CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
3535
REDIRECT_URI: ${{ secrets.REDIRECT_URI }}
3636
SPRING_MONGODB_URI: ${{ secrets.SPRING_MONGODB_URI}}
37+
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
3738

3839
steps:
3940
- name: Checkout Repository

src/main/java/org/ezcode/codetest/application/submission/model/SubmissionContext.java

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.ezcode.codetest.domain.submission.model.SubmissionAggregator;
55

66
import java.util.concurrent.CountDownLatch;
7+
import java.util.concurrent.atomic.AtomicBoolean;
78
import java.util.concurrent.atomic.AtomicInteger;
89
import java.util.concurrent.atomic.AtomicReference;
910

@@ -17,23 +18,26 @@ public record SubmissionContext(
1718

1819
AtomicReference<String> message,
1920

20-
CountDownLatch latch
21+
CountDownLatch latch,
22+
23+
AtomicBoolean notified
2124
) {
2225
public static SubmissionContext initialize(int totalTestcaseCount) {
2326
return new SubmissionContext(
24-
new SubmissionAggregator(),
25-
new AtomicInteger(0),
26-
new AtomicInteger(0),
27-
new AtomicReference<>("Accepted"),
28-
new CountDownLatch(totalTestcaseCount)
27+
new SubmissionAggregator(),
28+
new AtomicInteger(0),
29+
new AtomicInteger(0),
30+
new AtomicReference<>("Accepted"),
31+
new CountDownLatch(totalTestcaseCount),
32+
new AtomicBoolean(false)
2933
);
3034
}
3135

3236
public FinalResultResponse toFinalResult(int totalTestcaseCount) {
3337
return new FinalResultResponse(
34-
totalTestcaseCount,
35-
this.getProcessedCount(),
36-
this.getCurrentMessage()
38+
totalTestcaseCount,
39+
this.getPassedCount(),
40+
this.getCurrentMessage()
3741
);
3842
}
3943

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package org.ezcode.codetest.application.submission.port;
2+
3+
public interface ExceptionNotifier {
4+
5+
void sendEmbed(String title, String description, String exception, String methodName);
6+
7+
}

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

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

3-
import java.io.IOException;
43
import java.util.List;
54
import java.util.UUID;
65
import java.util.concurrent.CompletableFuture;
@@ -14,6 +13,7 @@
1413
import org.ezcode.codetest.application.submission.model.JudgeResult;
1514
import org.ezcode.codetest.application.submission.model.ReviewResult;
1615
import org.ezcode.codetest.application.submission.model.SubmissionContext;
16+
import org.ezcode.codetest.application.submission.port.ExceptionNotifier;
1717
import org.ezcode.codetest.application.submission.port.QueueProducer;
1818
import org.ezcode.codetest.domain.submission.exception.SubmissionException;
1919
import org.ezcode.codetest.domain.submission.exception.code.SubmissionExceptionCode;
@@ -39,7 +39,6 @@
3939
import org.ezcode.codetest.domain.user.model.entity.User;
4040
import org.ezcode.codetest.domain.user.service.UserDomainService;
4141
import org.springframework.scheduling.annotation.Async;
42-
import org.springframework.security.core.context.SecurityContextHolder;
4342
import org.springframework.stereotype.Service;
4443
import org.springframework.transaction.annotation.Transactional;
4544
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -61,27 +60,36 @@ public class SubmissionService {
6160
private final EmitterStore emitterStore;
6261
private final QueueProducer queueProducer;
6362
private final Executor judgeTestcaseExecutor;
63+
private final ExceptionNotifier exceptionNotifier;
6464

65-
public SseEmitter enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) {
65+
public SseEmitter enqueueCodeSubmission(Long problemId, CodeSubmitRequest request, AuthUser authUser) {
6666
SseEmitter emitter = new SseEmitter(10 * 60 * 1000L);
6767
String emitterKey = authUser.getId() + "_" + UUID.randomUUID();
6868

69-
emitterStore.save(emitterKey, emitter);
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+
});
7079

71-
SubmissionMessage message = new SubmissionMessage(
72-
emitterKey, problemId, request.languageId(), authUser.getId(), request.sourceCode()
73-
);
80+
emitterStore.save(emitterKey, emitter);
7481

75-
queueProducer.enqueue(message);
82+
queueProducer.enqueue(
83+
new SubmissionMessage(emitterKey, problemId, request.languageId(), authUser.getId(), request.sourceCode())
84+
);
7685

7786
return emitter;
7887
}
7988

80-
@Async("judgeSubmissionExecutor")
89+
@Async("judgeSubmissionExecutor")
8190
public void submitCodeStream(SubmissionMessage msg) {
82-
try {
91+
try {
8392
log.info("[Submission RUN] Thread = {}", Thread.currentThread().getName());
84-
log.info("Principal = {}", SecurityContextHolder.getContext().getAuthentication());
8593
User user = userDomainService.getUserById(msg.userId());
8694
Language language = languageDomainService.getLanguage(msg.languageId());
8795
ProblemInfo problemInfo = problemDomainService.getProblemInfo(msg.problemId());
@@ -93,41 +101,37 @@ public void submitCodeStream(SubmissionMessage msg) {
93101
SubmissionContext context = SubmissionContext.initialize(totalTestcaseCount);
94102

95103
for (Testcase tc : problemInfo.testcaseList()) {
96-
CompletableFuture
97-
.supplyAsync(() -> {
98-
try {
99-
log.info("[Judge RUN] Thread = {}", Thread.currentThread().getName());
100-
log.info("Principal = {}", SecurityContextHolder.getContext().getAuthentication());
101-
String token = judgeClient.submitAndGetToken(
102-
new CodeCompileRequest(msg.sourceCode(), language.getJudge0Id(), tc.getInput())
103-
);
104-
JudgeResult result = judgeClient.pollUntilDone(token);
105-
106-
AnswerEvaluation evaluation = submissionDomainService.handleEvaluationAndUpdateStats(
107-
TestcaseEvaluationInput.from(tc, result), problemInfo, context
108-
);
109-
110-
emitter.send(JudgeResultResponse.fromEvaluation(result, evaluation));
111-
context.countDown();
112-
return result;
113-
} catch (IOException e) {
114-
throw new SubmissionException(SubmissionExceptionCode.EMITTER_SEND_ERROR);
115-
} catch (Exception e) {
116-
throw new SubmissionException(SubmissionExceptionCode.COMPILE_SERVER_ERROR);
104+
CompletableFuture.runAsync(() -> {
105+
try {
106+
log.info("[Judge RUN] Thread = {}", Thread.currentThread().getName());
107+
String token = judgeClient.submitAndGetToken(
108+
new CodeCompileRequest(msg.sourceCode(), language.getJudge0Id(), tc.getInput())
109+
);
110+
JudgeResult result = judgeClient.pollUntilDone(token);
111+
112+
AnswerEvaluation evaluation = submissionDomainService.handleEvaluationAndUpdateStats(
113+
TestcaseEvaluationInput.from(tc, result), problemInfo, context
114+
);
115+
emitter.send(JudgeResultResponse.fromEvaluation(result, evaluation));
116+
} catch (Exception e) {
117+
if (context.notified().compareAndSet(false, true)) {
118+
emitter.completeWithError(e);
119+
emitterStore.remove(msg.emitterKey());
120+
exceptionNotificationHelper(e);
117121
}
118-
}, judgeTestcaseExecutor);
122+
} finally {
123+
context.countDown();
124+
}
125+
}, judgeTestcaseExecutor);
119126
}
120127

121-
if (!context.latch().await(30, TimeUnit.SECONDS)) {
128+
if (!context.latch().await(60, TimeUnit.SECONDS)) {
122129
throw new SubmissionException(SubmissionExceptionCode.TESTCASE_TIMEOUT);
123130
}
124131

125132
emitter.send(SseEmitter.event()
126133
.name("final")
127-
.data(context.toFinalResult(totalTestcaseCount)
128-
)
129-
);
130-
134+
.data(context.toFinalResult(totalTestcaseCount)));
131135
emitter.complete();
132136
emitterStore.remove(msg.emitterKey());
133137

@@ -138,9 +142,11 @@ public void submitCodeStream(SubmissionMessage msg) {
138142
submissionDomainService.finalizeSubmission(
139143
submissionData, context.aggregator(), context.getPassedCount()
140144
);
141-
} catch (Exception e) {
142-
emitterStore.get(msg.emitterKey()).ifPresent(emitter -> emitter.completeWithError(e));
143-
}
145+
} catch (Exception e) {
146+
emitterStore.get(msg.emitterKey()).ifPresent(emitter -> emitter.completeWithError(e));
147+
emitterStore.remove(msg.emitterKey());
148+
exceptionNotificationHelper(e);
149+
}
144150
}
145151

146152
@Transactional(readOnly = true)
@@ -160,4 +166,32 @@ public CodeReviewResponse getCodeReview(Long problemId, CodeReviewRequest reques
160166

161167
return new CodeReviewResponse(reviewResult.reviewContent());
162168
}
169+
170+
private void exceptionNotificationHelper(Exception e) {
171+
if (e instanceof SubmissionException se) {
172+
var code = se.getResponseCode();
173+
exceptionNotifier.sendEmbed(
174+
"채점 예외",
175+
"채점 중 SubmissionException 발생",
176+
"""
177+
• 성공 여부: %s
178+
• 상태코드: %s
179+
• 메시지: %s
180+
""".formatted(code.isSuccess(), code.getStatus(), code.getMessage()),
181+
"submitCodeStream"
182+
);
183+
} else {
184+
exceptionNotifier.sendEmbed(
185+
"채점 예외",
186+
"채점 중 알 수 없는 예외 발생",
187+
"""
188+
• 성공 여부: false
189+
• 상태코드: 500
190+
• 메시지: %s
191+
""".formatted(e.getMessage()),
192+
"submitCodeStream"
193+
);
194+
}
195+
196+
}
163197
}

src/main/java/org/ezcode/codetest/common/base/exception/GlobalExceptionHandler.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
import org.ezcode.codetest.common.dto.CommonResponse;
44
import org.springframework.http.HttpStatus;
55
import org.springframework.http.ResponseEntity;
6+
import org.springframework.security.authorization.AuthorizationDeniedException;
67
import org.springframework.validation.FieldError;
78
import org.springframework.web.bind.MethodArgumentNotValidException;
89
import org.springframework.web.bind.annotation.ExceptionHandler;
910
import org.springframework.web.bind.annotation.RestControllerAdvice;
1011

1112
import jakarta.servlet.http.HttpServletRequest;
13+
14+
import jakarta.servlet.http.HttpServletResponse;
15+
1216
import lombok.extern.slf4j.Slf4j;
1317

1418
@Slf4j
@@ -39,6 +43,31 @@ public ResponseEntity<CommonResponse<Void>> handleBaseException(
3943
.body(CommonResponse.from(e.getResponseCode()));
4044
}
4145

46+
47+
@ExceptionHandler(AuthorizationDeniedException.class)
48+
public ResponseEntity<CommonResponse<Void>> handleSseAuthorizationDenied(
49+
AuthorizationDeniedException ex,
50+
HttpServletRequest request,
51+
HttpServletResponse response
52+
) {
53+
54+
String requestUri = request.getRequestURI();
55+
boolean isSseRequest = requestUri.startsWith("/api/problems/")
56+
&& request.getHeader("Accept") != null
57+
&& request.getHeader("Accept").toLowerCase().contains("text/event-stream");
58+
59+
if (isSseRequest) {
60+
if (response.isCommitted()) {
61+
log.warn("SSE 응답 커밋 후 AuthorizationDeniedException 발생: {}", requestUri);
62+
return null;
63+
}
64+
return ResponseEntity
65+
.status(HttpStatus.FORBIDDEN)
66+
.body(CommonResponse.of(false, ex.getMessage(), 403, null));
67+
}
68+
throw ex;
69+
}
70+
4271
@ExceptionHandler(Exception.class)
4372
public ResponseEntity<CommonResponse<String>> handleAllException(Exception e
4473
) {

src/main/java/org/ezcode/codetest/common/config/AsyncEnableConfig.java

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/main/java/org/ezcode/codetest/common/config/ExecutorConfig.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@
44

55
import org.springframework.context.annotation.Bean;
66
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.scheduling.annotation.EnableAsync;
78
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
8-
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
99

10+
@EnableAsync
1011
@Configuration
1112
public class ExecutorConfig {
1213

14+
@Bean(name = "consumerExecutor")
15+
public Executor consumerExecutor() {
16+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
17+
executor.setCorePoolSize(5);
18+
executor.setMaxPoolSize(10);
19+
executor.setQueueCapacity(100);
20+
executor.setThreadNamePrefix("consumer-");
21+
executor.initialize();
22+
return executor;
23+
}
24+
1325
@Bean(name = "judgeSubmissionExecutor")
1426
public Executor judgeSubmissionExecutor() {
1527
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
@@ -18,7 +30,7 @@ public Executor judgeSubmissionExecutor() {
1830
executor.setQueueCapacity(100);
1931
executor.setThreadNamePrefix("submission-");
2032
executor.initialize();
21-
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
33+
return executor;
2234
}
2335

2436
@Bean(name = "judgeTestcaseExecutor")
@@ -29,6 +41,6 @@ public Executor judgeTestcaseExecutor() {
2941
executor.setQueueCapacity(500);
3042
executor.setThreadNamePrefix("testcase-");
3143
executor.initialize();
32-
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
44+
return executor;
3345
}
3446
}

src/main/java/org/ezcode/codetest/common/security/config/SecurityConfig.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import org.ezcode.codetest.common.security.util.ExceptionHandlingFilter;
66
import org.ezcode.codetest.common.security.util.JwtFilter;
77
import org.ezcode.codetest.common.security.util.JwtUtil;
8-
import org.springframework.boot.web.servlet.FilterRegistrationBean;
98
import org.springframework.context.annotation.Bean;
109
import org.springframework.context.annotation.Configuration;
1110
import org.springframework.data.redis.core.RedisTemplate;
@@ -18,6 +17,7 @@
1817
import org.springframework.security.web.SecurityFilterChain;
1918
import org.springframework.security.web.access.AccessDeniedHandler;
2019
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
20+
import org.springframework.security.web.util.matcher.DispatcherTypeRequestMatcher;
2121

2222
import jakarta.servlet.DispatcherType;
2323
import jakarta.servlet.http.HttpServletResponse;
@@ -61,8 +61,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
6161
// 인증 URL 범위 설정
6262
.authorizeHttpRequests(authorizeRequests ->
6363
authorizeRequests
64-
// cleanup(Async dispatch) 요청만 무조건 허용
65-
.requestMatchers(request -> request.getDispatcherType() == DispatcherType.ASYNC).permitAll()
64+
.requestMatchers(new DispatcherTypeRequestMatcher(DispatcherType.ASYNC)).permitAll()
6665
.requestMatchers(
6766
"/api/auth/**",
6867
"/login",

src/main/java/org/ezcode/codetest/common/security/util/.gitkeep

Whitespace-only changes.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ 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 서버 연결 실패")
1819
;
1920
private final boolean success;
2021
private final HttpStatus status;

0 commit comments

Comments
 (0)