온라인 교육 사이트를 운영하면서 수강생들의 학습 성취도를 평가하기 위해 정기적으로 온라인 시험을 진행하게 되었습니다. 우리가 준비한 시험의 동시 응시자 규모는 약 100명 수준이었습니다. 평소 단순 조회 위주의 웹 트래픽으로 보면 그리 크지 않은 규모라고 생각할 수 있습니다.
하지만 온라인 시험이라는 도메인의 특성을 면밀히 검토하는 과정에서 한 가지 치명적인 위험 요소를 발견했습니다. 바로 시험 종료 시각 정각에 모든 수강생의 답안 저장 요청과 제출 요청이 1초라는 짧은 순간에 동시에 몰린다는 점이었습니다. 여기에 불안정한 개인 네트워크 환경으로 인한 클라이언트 재시도와 프론트엔드 타이머의 자동 제출 요청까지 겹칠 경우 단 한 명의 수강생이라도 작성한 답안이 누락될 가능성이 존재했습니다.
실제 시험 운영 중 답안 유실 사고가 발생하면 수강생의 정당한 노력이 훼손될 뿐만 아니라 교육 플랫폼 전체의 신뢰도가 큰 타격을 입게 됩니다. 따라서 우리 팀은 실제 시험을 오픈하기 전에 잠재적인 답안 누락 시나리오를 정의하고 이를 확실하게 예방하기 위한 세 가지 핵심 기술을 선제적으로 도입했습니다. 본 글에서는 시험 종료 시점의 요청 폭주와 동시성 문제를 해결하기 위해 어떤 기술을 선정하여 구현했고 어떻게 답안 누락 없이 시험을 안정적으로 처리했는지 그 과정을 공유합니다.
1. 사전 점검으로 발견한 답안 누락 위험 시나리오
본격적인 기술 도입에 앞서 일반적인 동기식 데이터베이스 저장 구조에서 시험 종료 직전에 발생할 수 있는 문제점들을 점검해 보았습니다. 그 결과 실제 시험에서 충분히 일어날 수 있는 세 가지 구체적인 답안 누락 경로를 확인했습니다.
1.1 종료 시점 요청 집중과 패킷 역전 현상
수강생들은 보통 시험 종료 직전까지 주관식 답안을 다듬거나 객관식 마킹을 수정합니다. 100여 명의 수강생이 종료 직전 수초 동안 일제히 답안을 수정하고 자동 저장이 실행되면 순간적으로 수많은 쓰기 요청이 데이터베이스로 몰립니다. 이때 와이파이나 모바일 핫스팟 등 불안정한 네트워크를 사용하는 수강생의 경우 먼저 보낸 수정 요청보다 나중에 보낸 최신 수정 요청이 서버에 먼저 도착하는 패킷 역전 현상이 발생할 수 있습니다. 단순 덮어쓰기 구조에서는 뒤늦게 도착한 과거 요청이 최신 답안을 덮어써버리는 갱신 유실 문제가 발생합니다.
1.2 수동 제출과 타이머 자동 제출의 동시 경합
시험 종료 1초 전 수강생이 직접 제출하기 버튼을 클릭함과 동시에 브라우저의 타이머가 만료되어 자동 제출 API를 호출하는 상황입니다. 여기에 네트워크 지연으로 인한 클라이언트의 재시도 요청까지 겹치면 동일한 시험 응시 데이터에 대해 수 밀리초 간격으로 여러 개의 제출 및 저장 트랜잭션이 동시에 접근합니다. 이때 상태 제어가 엄격하지 않으면 자동 제출이 완료된 직후 뒤늦게 실행된 저장 요청이 확정된 답안을 건드리거나 중복 제출 에러가 발생하여 정상적인 마감 처리가 실패할 위험이 있었습니다.
1.3 클라이언트 시계 오차와 마감 시간 판정 문제
프론트엔드 타이머는 수강생 PC의 시스템 시계를 따릅니다. 만약 수강생 기기의 시계가 서버보다 느리게 가고 있거나 종료 직전에 보낸 패킷이 네트워크 지연으로 인해 1초 늦게 서버에 도착한다면 서버 기준으로는 마감 시간이 지난 요청이 됩니다. 서버가 이를 단순히 거절해 버리면 수강생은 제시간에 제출했음에도 마지막 답안이 누락되는 결과를 겪게 됩니다.
2. 답안 유실 방지를 위한 핵심 기술 도입과 선정 이유
우리는 위에서 확인한 문제들을 근본적으로 차단하기 위해 트래픽 완충과 버전 제어를 담당할 Apache Kafka 기반 자동 저장 파이프라인과 동시성 경합을 막아줄 제출 상태 머신 및 재시도 가능한 제출 API 그리고 시간 판정의 공정성을 보장할 서버 기준 마감 시간 검증이라는 세 가지 기술을 도입했습니다.
2.1 Kafka 기반 비동기 자동 저장과 버전 기반 업데이트
-
도입 기술: Apache Kafka 이벤트 스트리밍 및 클라이언트 시퀀스 번호를 활용한 DB 조건부 업데이트
-
선정 이유: 시험 도중 발생하는 수많은 자동 저장 요청과 시험 종료 직전에 폭주하는 쓰기 트래픽을 데이터베이스가 직접 동기식으로 받아내면 커넥션 지연과 락 경합이 발생할 수밖에 없습니다. 따라서 순간적으로 몰리는 저장 요청을 유실 없이 빠르게 받아내고 데이터베이스가 감당할 수 있는 속도로 안전하게 반영하기 위해 Kafka를 도입했습니다.
또한 Kafka를 통해 비동기로 데이터를 처리하거나 네트워크 패킷이 역전되더라도 과거 데이터가 최신 데이터를 덮어쓰지 못하도록 버전 기반 업데이트를 함께 적용했습니다. 클라이언트가 답안을 전송할 때 단조 증가하는 시퀀스 번호(clientSeq)를 함께 보내고 Kafka의 파티션 키를 응시 세션 ID(attemptId)로 지정하여 동일 수강생의 이벤트 순서를 보장했습니다. 나아가 데이터베이스에 최종 반영할 때는 현재 저장된 시퀀스보다 더 큰 시퀀스를 가진 요청만 반영되도록 조건부 업데이트 쿼리를 구성했습니다.
package com.edu.exam.producer;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import java.time.Instant;
@Service
public class AnswerAutoSaveProducer {
private static final String TOPIC_NAME = "exam-answer-autosave-events";
private final KafkaTemplate<String, AnswerSaveEvent> kafkaTemplate;
public AnswerAutoSaveProducer(KafkaTemplate<String, AnswerSaveEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
/**
* Publishes auto-save requests to Kafka partitioned by attemptId
* to guarantee ordering per examinee and absorb traffic spikes.
*/
public void publishAutoSaveEvent(long attemptId, long questionId, String content, long clientSeq) {
AnswerSaveEvent event = new AnswerSaveEvent(
attemptId,
questionId,
content,
clientSeq,
Instant.now().toEpochMilli()
);
// Using attemptId as the partition key ensures sequential processing for the same user
kafkaTemplate.send(TOPIC_NAME, String.valueOf(attemptId), event);
}
public record AnswerSaveEvent(
long attemptId,
long questionId,
String answerContent,
long clientSeq,
long serverReceivedAtMs
) {}
}
Kafka 컨슈머는 토픽에서 이벤트를 읽어 데이터베이스에 반영합니다. 이때 네트워크 단에서 이미 순서가 뒤바뀌어 도착한 과거 패킷이 있더라도 아래의 조건부 쿼리가 이를 에러 없이 안전하게 무시합니다.
package com.edu.exam.consumer;
import com.edu.exam.producer.AnswerAutoSaveProducer.AnswerSaveEvent;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.sql.Timestamp;
import java.time.Instant;
@Component
public class AnswerAutoSaveConsumer {
private final NamedParameterJdbcTemplate jdbcTemplate;
public AnswerAutoSaveConsumer(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* Consumes auto-save events and performs a version-checked upsert.
* Prevents out-of-order network packets from overwriting newer answers.
*/
@KafkaListener(topics = "exam-answer-autosave-events", groupId = "exam-autosave-group")
public void consumeAndSaveAnswer(AnswerSaveEvent event) {
String sql = """
INSERT INTO exam_answer
(attempt_id, question_id, answer_text, last_client_seq, updated_at)
VALUES
(:attemptId, :questionId, :answerText, :clientSeq, :updatedAt)
ON DUPLICATE KEY UPDATE
answer_text = IF(:clientSeq > last_client_seq, :answerText, answer_text),
updated_at = IF(:clientSeq > last_client_seq, :updatedAt, updated_at),
last_client_seq = IF(:clientSeq > last_client_seq, :clientSeq, last_client_seq)
""";
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("attemptId", event.attemptId())
.addValue("questionId", event.questionId())
.addValue("answerText", event.answerContent())
.addValue("clientSeq", event.clientSeq())
.addValue("updatedAt", Timestamp.from(Instant.ofEpochMilli(event.serverReceivedAtMs())));
jdbcTemplate.update(sql, params);
}
}
이 구조를 통해 시험 종료 직전에 저장 요청이 한꺼번에 몰리더라도 API 서버는 Kafka에 빠르게 이벤트를 발행하고 응답을 반환하므로 병목이 사라졌습니다. 또한 데이터베이스 수준에서 :clientSeq > last_client_seq 조건을 검증하므로 어떠한 패킷 역전 상황에서도 수강생이 가장 마지막에 작성한 최신 답안만 정확히 보존됩니다.
2.2 서버 기준 마감 시간 검증 (Server Time Authority & Grace Period)
-
도입 기술: 서버 시간 단일 권위 체계 및 네트워크 지연을 고려한 유예 시간(Grace Period) 검증
-
선정 이유: 클라이언트의 시스템 시간은 언제든 오차가 발생하거나 조작될 수 있으므로 마감 시간 판정의 기준은 반드시 서버가 되어야 합니다. 하지만 서버 시간만을 기계적으로 적용하면 시험 종료 직전에 정상적으로 전송된 요청이 네트워크 전송 시간 때문에 간발의 차로 거절당하는 문제가 생깁니다. 따라서 모든 요청의 도착 시각은 서버 시계를 기준으로 기록하되 공식 종료 시각 이후 수초간의 유예 시간(Grace Period)을 두어 네트워크 지연으로 인한 누락을 방지하도록 설계했습니다.
또한 시험 시작 시점에 서버의 현재 시각을 클라이언트에 전달하여 화면의 타이머가 서버 시계와 동일한 기준으로 동작하도록 보정했습니다.
package com.edu.exam.service;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
@Component
public class ExamTimeValidator {
// Allow a 3-second grace period to absorb network latency at the exact moment of exam termination
private static final Duration NETWORK_GRACE_PERIOD = Duration.ofSeconds(3);
/**
* Validates whether the incoming request arrived within the authoritative server deadline
* including the allowed network grace period.
*/
public boolean isWithinValidSubmissionWindow(Instant officialEndTime, Instant serverArrivedAt) {
Instant validCutoffTime = officialEndTime.plus(NETWORK_GRACE_PERIOD);
return !serverArrivedAt.isAfter(validCutoffTime);
}
}
이 검증기를 통해 수강생이 종료 직전 마지막 순간까지 작성한 답안이 네트워크를 타고 서버에 도달하는 데 걸리는 물리적 지연 시간을 안전하게 흡수하여 정당한 답안이 버려지는 현상을 막았습니다.
2.3 제출 상태 머신과 재시도 가능한 멱등성 제출 API
-
도입 기술: 단방향 제출 상태 머신(State Machine) 및 상태 수렴형 멱등성(Idempotency) 제출 API
-
선정 이유: 시험 종료 순간에는 사용자의 수동 제출 클릭과 타이머의 자동 제출 요청 그리고 네트워크 타임아웃으로 인한 클라이언트 재시도가 동시에 겹칩니다. 이때 중복 요청이 들어왔다고 해서 서버가 에러를 반환하면 수강생 화면에는 제출 실패 메시지가 뜨고 큰 혼란이 발생합니다. 또한 제출 버튼을 누르기 직전 1~2초 사이에 작성하여 아직 자동 저장되지 않은 마지막 답안이 유실될 수도 있습니다.
이를 해결하기 위해 우리는 시험의 상태를 IN_PROGRESS -> SUBMITTING -> SUBMITTED(또는 AUTO_SUBMITTED)로만 흐르는 단방향 상태 머신으로 정의하고 두 가지 안전장치를 구현했습니다.
-
원자적 상태 전이(CAS)와 멱등성 응답: 데이터베이스의 조건부 업데이트(WHERE status = 'IN_PROGRESS')를 통해 동시에 들어온 수동 제출과 자동 제출 중 단 하나만 상태 변경 권한을 얻도록 했습니다. 뒤이어 도착한 중복 요청이나 재시도 요청은 이미 시험이 마감 상태(SUBMITTED 또는 AUTO_SUBMITTED)임을 확인하고 에러 대신 정상 제출 완료 응답을 동일하게 반환하도록 만들어 언제든 안전하게 재시도할 수 있는 API를 구축했습니다.
-
최종 답안 동반 전송(Piggybacking): 제출 API를 호출할 때 마지막으로 작성된 답안 변경분을 요청 본문에 함께 실어 보내도록 하여 Kafka 컨슈머 지연과 무관하게 제출 트랜잭션 안에서 마지막 답안을 즉시 반영하고 상태를 마감하도록 구현했습니다.
package com.edu.exam.service;
import com.edu.exam.consumer.AnswerAutoSaveConsumer;
import com.edu.exam.producer.AnswerAutoSaveProducer.AnswerSaveEvent;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
@Service
public class ExamSubmissionService {
private final NamedParameterJdbcTemplate jdbcTemplate;
private final ExamTimeValidator timeValidator;
private final AnswerAutoSaveConsumer answerSaver;
public ExamSubmissionService(
NamedParameterJdbcTemplate jdbcTemplate,
ExamTimeValidator timeValidator,
AnswerAutoSaveConsumer answerSaver) {
this.jdbcTemplate = jdbcTemplate;
this.timeValidator = timeValidator;
this.answerSaver = answerSaver;
}
/**
* Processes final exam submission using a unidirectional state machine.
* Guarantees idempotency when manual submit, auto submit, and client retries overlap.
*/
@Transactional
public SubmissionResult submitExam(long attemptId, boolean isAutoSubmit, List<AnswerSaveEvent> finalAnswers) {
Instant serverNow = Instant.now();
ExamAttemptRecord attempt = findAttemptById(attemptId);
// 1. Idempotency check: If already finalized by a prior request, return success immediately
if ("SUBMITTED".equals(attempt.status()) || "AUTO_SUBMITTED".equals(attempt.status())) {
return new SubmissionResult(attemptId, attempt.status(), "ALREADY_COMPLETED_SUCCESS");
}
// 2. Atomic State Machine Transition: IN_PROGRESS -> SUBMITTING
String casSql = """
UPDATE exam_attempt
SET status = 'SUBMITTING', updated_at = :now
WHERE attempt_id = :attemptId AND status = 'IN_PROGRESS'
""";
int updatedRows = jdbcTemplate.update(casSql, new MapSqlParameterSource()
.addValue("attemptId", attemptId)
.addValue("now", Timestamp.from(serverNow)));
if (updatedRows == 0) {
// Another concurrent request is already processing or has just finished submission
ExamAttemptRecord refreshed = findAttemptById(attemptId);
if ("SUBMITTED".equals(refreshed.status()) || "AUTO_SUBMITTED".equals(refreshed.status())) {
return new SubmissionResult(attemptId, refreshed.status(), "CONVERGED_SUCCESS");
}
throw new IllegalStateException("Submission is currently in progress. Please retry.");
}
// 3. Validate server deadline and flush final piggybacked answers using version check
if (timeValidator.isWithinValidSubmissionWindow(attempt.officialEndTime(), serverNow) && finalAnswers != null) {
for (AnswerSaveEvent answer : finalAnswers) {
answerSaver.consumeAndSaveAnswer(new AnswerSaveEvent(
attemptId,
answer.questionId(),
answer.answerContent(),
answer.clientSeq(),
serverNow.toEpochMilli()
));
}
}
// 4. Finalize state transition: SUBMITTING -> SUBMITTED or AUTO_SUBMITTED
String targetStatus = isAutoSubmit ? "AUTO_SUBMITTED" : "SUBMITTED";
String finalizeSql = """
UPDATE exam_attempt
SET status = :targetStatus, submitted_at = :now, updated_at = :now
WHERE attempt_id = :attemptId AND status = 'SUBMITTING'
""";
jdbcTemplate.update(finalizeSql, new MapSqlParameterSource()
.addValue("attemptId", attemptId)
.addValue("targetStatus", targetStatus)
.addValue("now", Timestamp.from(serverNow)));
return new SubmissionResult(attemptId, targetStatus, "NEWLY_SUBMITTED_SUCCESS");
}
private ExamAttemptRecord findAttemptById(long attemptId) {
String sql = "SELECT attempt_id, status, official_end_time FROM exam_attempt WHERE attempt_id = :id";
return jdbcTemplate.queryForObject(sql, new MapSqlParameterSource("id", attemptId), (rs, rowNum) ->
new ExamAttemptRecord(
rs.getLong("attempt_id"),
rs.getString("status"),
rs.getTimestamp("official_end_time").toInstant()
)
);
}
public record ExamAttemptRecord(long attemptId, String status, Instant officialEndTime) {}
public record SubmissionResult(long attemptId, String finalStatus, String resultCode) {}
}
이 설계를 통해 제출이 한 번 완료된 이후에는 Kafka 큐에 남아있던 지연된 자동 저장 이벤트가 뒤늦게 컨슈머에서 처리되더라도 이미 제출 시점에 가장 높은 시퀀스 번호를 가진 최종 답안이 DB에 반영되어 있으므로 데이터가 안전하게 보호됩니다. 또한 PC 전원 꺼짐 등으로 인해 아예 제출 요청을 보내지 못한 수강생을 위해서도 서버 스케줄러가 주기적으로 마감 시간이 지난 IN_PROGRESS 세션을 찾아 위와 동일한 상태 머신 로직으로 안전하게 자동 마감 처리하도록 보완했습니다.
3. 도입 결과 및 마무리
실제 시험을 진행하기 전 위에 소개한 세 가지 핵심 기술을 시스템에 적용하고 테스트를 거친 뒤 100여 명의 수강생을 대상으로 온라인 시험을 운영했습니다.
시험이 진행되는 동안과 종료 시각 정각에 걸쳐 수강생들의 답안 수정 요청과 제출 요청이 동시에 몰렸지만 Kafka를 활용한 비동기 자동 저장 파이프라인 덕분에 서버와 데이터베이스는 어떠한 병목이나 지연도 겪지 않고 모든 요청을 안정적으로 소화해 냈습니다. 네트워크 환경이 불안정한 일부 수강생에게서 패킷 순서가 뒤바뀌어 들어오는 현상도 있었으나 시퀀스 번호 기반의 조건부 업데이트가 과거 데이터를 정확히 걸러내어 가장 마지막에 작성된 답안이 온전히 유지되었습니다.
특히 시험 종료 직전까지 문제를 풀다가 수동 제출 버튼을 누름과 동시에 타이머 자동 제출이 작동하거나 일시적인 네트워크 순단으로 인해 재시도 요청이 발생한 경우에도 단방향 제출 상태 머신과 멱등성 API가 정상적으로 동작했습니다. 중복 요청으로 인한 에러 화면 노출 없이 모든 수강생의 제출 프로세스가 매끄럽게 수렴되었고 서버 기준 마감 시간과 유예 시간 검증을 통해 종료 직전에 전송된 마지막 답안까지 단 하나의 누락도 없이 모두 정상적으로 채점에 반영되었습니다.
비록 수만 명이 동시에 접속하는 초대형 시험은 아니었지만 시험 종료 시점에 트래픽이 집중되고 여러 요청이 경합을 벌이는 온라인 시험 특유의 기술적 난제는 규모와 상관없이 동일하게 존재했습니다. 문제가 실제로 터진 뒤에 대응하는 대신 발생 가능한 유실 시나리오를 미리 짚어보고 Kafka 기반의 버전 제어 자동 저장과 서버 시간 기준 검증 그리고 상태 머신 기반의 재시도 가능한 제출 API를 선제적으로 도입한 덕분에 수강생들에게 신뢰할 수 있는 안정적인 시험 환경을 제공할 수 있었습니다.
dwmoon