회고.
이전 글에서는 HWJ에서 C++ WASM runner를 붙이며 겪은 I/O 병목, WASI 전환, 표준 checker fast path 같은 이야기를 정리했습니다. 그때의 결론은 꽤 단순했습니다. 브라우저 안에서 채점을 하려면 모든 것을 WASM으로 밀어 넣는 것이 정답이 아니라, WASM이 필요한 곳과 JavaScript에서 바로 끝내야 하는 곳을 구분해야 한다는 것이었습니다.
이번에는 JavaScript 제출에 대한 고민입니다. 처음부터 지금 구조를 정답으로 잡은 것은 아니었습니다. 출발점은 훨씬 단순했습니다. 브라우저 native V8 JIT로 사용자 JS를 실행하면 빠릅니다. 그런데 온라인 저지처럼 “이 제출이 메모리 제한을 넘었는가?”를 판정하려고 하니, 갑자기 문제가 흐릿해졌습니다.
그래서 한동안 다른 아키텍처를 선택하려고 했습니다. JS를 격리된 sandbox에서 실행할 수 있는지, 경량 interpreter를 WASM으로 올리면 메모리를 단단히 묶을 수 있는지, Porffor나 AssemblyScript 같은 다른 JS 컴파일러 계열은 쓸 만한지 찾아봤습니다. 결론부터 말하면 지금의 선택은 최고라기보다는 차선에 가까운 것 같습니다. 다만 HWJ가 중요하게 보는 UX와 서버 비용을 생각하면, 이쪽이 그나마 현실적으로 보였습니다.
출발점: V8 JIT는 빠르지만 메모리가 애매하다
브라우저에서 JavaScript를 가장 빠르게 실행하는 방법은 사실 이미 브라우저 안에 있습니다. V8입니다. 사용자가 작성한 코드를 worker 안에서 new Function()으로 감싸고, fs.readFileSync(0, "utf8"), console.log, process.exit 같은 최소 Node.js shim만 제공하면 PS 스타일 코드는 꽤 자연스럽게 돌아갑니다.
stdin 준비
generator가 만든 입력을 worker 안의 JS runner에 문자열 또는 byte buffer로 전달합니다.
Native V8 JIT 실행
new Function()으로 감싼 사용자 JS를 브라우저 V8 위에서 실행해 빠른 정답성 피드백을 만듭니다.
stdout hash 계산
출력 문자열을 canonical byte stream으로 보고 case별 output hash를 계산합니다.
case index에 저장
worker 완료 순서가 섞여도 안정적으로 fold할 수 있게 caseHashes[caseIndex]에 꽂아둡니다.
정답성만 보면 이 방식은 꽤 좋았습니다. 간단한 N log N 성격의 테스트 문제에서 native V8 JIT 경로는 약 30ms 정도가 나왔습니다. 사용자가 제출 버튼을 눌렀을 때 바로 로그가 움직이고, 오답이면 빠르게 떨어집니다. 온라인 저지에서 이 감각은 생각보다 큽니다. 틀린 코드를 오래 기다리는 것은 이미 충분히 어려운 알고리즘 디버깅을 더 질척하게 만듭니다.
문제는 메모리였습니다. 사용자가 큰 배열을 만들거나, 문자열을 많이 이어 붙이거나, ArrayBuffer 비슷한 큰 데이터를 잡기 시작하면 브라우저의 JS heap, external memory, native allocation, GC pressure가 섞입니다. judge가 알고 싶은 질문은 단순합니다.
이 제출은 문제의 memory limit을 넘겼는가?
그런데 native V8 path에서 제출 하나의 peak memory를 온라인 저지처럼 깔끔하게 뽑는 것은 쉽지 않았습니다. V8은 JS heap과 native 영역을 나눠 관리하고, GC 타이밍도 런타임이 결정합니다. 브라우저 worker에서는 서버 Node처럼 RSS, external, heapUsed를 제출 단위로 안정적으로 회계 처리하기도 어렵습니다. 시간은 빨리 보이는데, 메모리 판정은 묘하게 손에 안 잡히는 상태였습니다.
그럼 JS를 격리하면 되지 않을까
그래서 다음 생각은 sandbox였습니다. “사용자 JS를 완전히 격리된 환경에서 실행하고, 그 환경 자체에 메모리 제한을 걸면 되지 않을까?”라는 방향이었습니다. worker를 따로 띄우고, 전역 객체를 제한하고, fetch, WebSocket, indexedDB 같은 API를 막고, Node.js shim만 노출하면 꽤 닫힌 세계처럼 보였습니다.
하지만 이것은 보안과 메모리 격리 관점에서 생각보다 애매했습니다. 브라우저가 제공하는 worker는 OS process나 container 같은 judge sandbox가 아니었습니다. worker를 죽일 수는 있지만, “이 제출에게만 V8 heap을 128MB로 제한한다” 같은 식의 강한 격리를 웹 API로 직접 얻기는 어려웠습니다. 전역 API를 가리는 것은 편의 기능을 막는 일에 가까웠고, 런타임 내부 메모리 accounting을 해결해주지는 못했습니다.
worker sandbox로 할 수 있는 것:
API 노출 제한
timeout 후 worker terminate
stdout/stderr 수집
worker sandbox로 어려운 것:
제출 단위의 정확한 V8 heap limit
OS/container 수준의 강한 격리
native/external allocation까지 포함한 memory peak 판정
실제로 native JS runner에는 restrictNativeJsGlobals() 같은 방어 코드를 넣었습니다. fetch, XMLHttpRequest, WebSocket, EventSource, indexedDB, caches, importScripts 같은 전역 API를 막고, 실행이 끝나면 원래 descriptor로 복구합니다. 그런데 이건 어디까지나 API masking입니다. 브라우저 전역 중에는 configurable이 아닌 것도 있고, 실패해도 worker 안의 문제로만 격리할 수 있을 뿐입니다. 즉, 이 시도는 “유저 코드가 네트워크를 만지지 못하게 하자”에는 도움이 됐지만, “이 제출의 메모리를 judge처럼 정확히 제한하자”에는 답이 아니었습니다.
즉, V8 native JIT는 빠르지만 메모리 판정이 어렵고, worker sandbox는 실행을 분리해주는 느낌은 있지만 judge가 원하는 강한 memory limit이 되지는 않았습니다. 그래서 생각이 한 번 더 꺾였습니다. “V8을 억지로 통제하려 하지 말고, 통제 가능한 런타임 위에서 JS를 실행하면 되지 않을까?”
WASM 위에 경량 인터프리터를 올려보자는 생각
그 다음 후보가 QuickJS, Javy 같은 JS-on-WASM 계열이었습니다. JS 엔진 자체를 WASM으로 빌드해서 브라우저 worker 안에 올리는 방식입니다. 그러면 사용자 코드는 native V8에서 도는 것이 아니라, WASM module 안의 JS interpreter에서 실행됩니다. 속도는 손해를 보더라도 메모리 제한은 훨씬 다루기 쉬워질 것 같았습니다.
WASM runtime 제한
runtime.setMemoryLimit()으로 문제의 memory limit을 QuickJS runtime에 적용합니다.
stdout discard
정답성은 이미 fast check에서 봤기 때문에 출력 문자열을 누적하지 않고 byte 수만 셉니다.
memoryBytes 측정
computeMemoryUsage()로 QuickJS runtime이 보고한 byte sample을 읽습니다.
MLE 판정
측정값이 문제 제한을 넘으면 matching hash가 있어도 최종 verdict를 MLE로 남깁니다.
여기서 삽질은 “QuickJS를 직접 커스텀 빌드하다가 망했다” 쪽은 아니었습니다. 지금 구현은 quickjs-emscripten 런타임을 public/runtimes/quickjs/index.global.js로 복사하고, worker에서 importScripts()로 로드합니다. 모듈 promise는 캐시하지만, 제출 실행마다 newRuntime()과 newContext()를 새로 만듭니다. 처음에는 재사용하면 빨라질 것 같았습니다. 그런데 제출 사이의 전역 상태와 handle, memory sample이 섞이면 memory probe의 의미가 무너질 것 같았습니다. 그래서 런타임 로드는 캐시하고, 실행 context는 매번 버리는 쪽으로 타협했습니다.
실제 memory probe에서는 runtime.setMemoryLimit(problem.memoryLimitMb * 1024 * 1024)를 걸고, stack도 1MB로 제한했습니다. 실행 후에는 runtime.computeMemoryUsage()를 읽어 byte 단위 memory sample을 뽑습니다. 그리고 stdout은 정답 검증용으로 다시 보지 않기로 했습니다. 이미 V8 fast check와 서버 precheck에서 answer hash를 확인했기 때문에, QuickJS phase에서는 출력 문자열을 누적하지 않고 TextEncoder로 byte 수만 셉니다. discardOutput이 켜져 있으면 stdoutStr += addition을 하지 않는 식입니다. 이걸 안 하면 메모리 측정이 유저 알고리즘 메모리가 아니라 “출력을 저장한 비용”에 오염될 것 같았습니다.
처음에는 이게 꽤 괜찮을 줄 알았습니다. PS 코드는 일반적인 웹 애플리케이션 JS와 다르게 동기적이고, 외부 의존성이 거의 없고, 입력을 읽고 계산한 뒤 출력을 만드는 CPU bound 코드입니다. 경량 JS interpreter를 WASM으로 구워서 브라우저에 올리면, 적당한 성능과 tight한 메모리 관리를 동시에 얻을 수 있지 않을까 생각했습니다.
그런데 숫자가 생각보다 냉정했습니다.
N log N 테스트에서 본 대략적인 차이:
native V8 JIT 약 30ms
fast JS/WASM runtime 약 300ms
의미:
WASM sandbox는 메모리 관리에는 좋아 보이지만
사용자가 느끼는 1차 피드백 경로로는 너무 무거울 수 있음
30ms와 300ms는 단순한 숫자 차이가 아니었습니다. 사용자가 “한 번 제출해볼까?” 하고 누르는 순간에 10배 차이가 나면, 이건 구현 취향이 아니라 UX 문제라고 봤습니다. 특히 HWJ는 블로그 글을 읽다가 바로 실행하고 고쳐보는 경험을 목표로 하고 있었습니다. 그 자리에서 매번 느린 interpreter를 기다리게 하면 글의 흐름이 끊길 것 같았습니다.
그래서 다른 JS 컴파일러도 찾아봤다
여기서 자연스럽게 다른 선택지를 봤습니다. “JS interpreter를 WASM 위에서 돌리는 게 느리다면, 아예 JS를 WASM으로 컴파일하면 안 되나?” C++은 WASI WASM으로 굽고, Java도 TeaVM 같은 선택지가 있습니다. 그러면 JS도 뭔가 있을 것 같았습니다.
문제는 JavaScript가 정적 컴파일과 잘 맞지 않는 언어라는 점입니다. JavaScript에서는 같은 변수가 숫자였다가 문자열이 되고, 객체가 되고, 배열이 되는 것이 자연스럽습니다.
let value = 1;
value = "hello";
value = [1, 2, 3];
V8은 이런 동적 성질을 실행 중에 관찰하고, hidden class, inline cache, speculative optimization 같은 런타임 기법으로 빠르게 만듭니다. JS의 빠름은 “처음부터 C++처럼 확정적으로 컴파일된다”보다 실행 중 관찰한 형태를 바탕으로 낙관적으로 최적화한다에 가깝다고 봤습니다. 그래서 JS를 WASM으로 굽는다는 말은 생각보다 까다로워 보였습니다.
01. AssemblyScript
Static Subset- JavaScript/TypeScript와 비슷한 문법을 공유하지만, 정적 타입을 강하게 요구합니다.
- 기존 JavaScript 생태계를 그대로 수용하기보다는, JS처럼 생긴 별도 언어에 가까워 보였습니다.
02. Porffor
AOT Compiler- JS/TS 소스코드를 WASM 또는 C 쪽으로 정적 컴파일하려는 흥미로운 시도입니다.
- PS 도메인에는 좋아 보였지만, 표준 JS 호환성과 edge behavior를 judge 기본 런타임으로 믿기에는 아직 이르다고 봤습니다.
03. Javy / QuickJS WASM
WASM Interpreter- JS 엔진 자체를 WASM module로 들고 와서 브라우저 안에서 기존 JS 코드를 실행합니다.
- 표준 JS 경험을 가장 덜 깨지만, native V8보다 느리고 runtime overhead가 큽니다.
PS 도메인만 보면 AssemblyScript나 Porffor류가 꽤 좋아 보였습니다. 입력, 반복문, 배열, 정렬, Map, Set 정도만 잘 되면 많은 문제가 풀릴 것 같았습니다. 하지만 사용자가 “JavaScript 제출”이라고 기대하는 것은 Node.js 스타일의 fs.readFileSync(0, "utf8"), Array.prototype.sort(), 문자열 메서드, BigInt 같은 표준 JS 경험이었습니다. 그 기대를 버리고 “우리 JS는 사실 AssemblyScript입니다”라고 말하는 순간, 이건 JS 지원이 아니라 새 언어를 하나 추가하는 일에 가까워질 것 같았습니다.
결국 이중 구조가 좋아 보였다
이쯤 오니 각 선택지의 장단점이 꽤 또렷해졌습니다. V8 native JIT는 빠르지만 메모리 판정이 어렵고, WASM interpreter는 메모리 판정은 쉬워 보이지만 느렸습니다. AssemblyScript나 Porffor는 흥미롭긴 했지만 사용자가 기대하는 “일반 JS 제출”과는 거리가 있어 보였습니다. 그러면 한쪽만 고르는 대신, 각자의 장점을 나눠 쓰는 구조가 떠올랐습니다.
Source 저장
제출 코드를 먼저 서버의 source_codes에 저장하고, 이후 precheck와 최종 기록을 같은 source에 묶습니다.
Fast JIT Check
브라우저 native V8 JIT로 정답성과 시간 통계를 빠르게 확인하고 aggregate answer hash를 만듭니다.
Server Precheck
서버가 공개 문제의 answer_hash_manifest와 클라이언트 hash를 비교해 precheck attempt를 남깁니다.
WASM Memory Probe
precheck를 통과한 제출만 QuickJS WASM sandbox에서 다시 실행해 memory limit을 확인합니다.
최종 기록
hash와 memory probe가 모두 통과한 경우에만 AC로 저장하고, MLE/RTE는 그대로 보존합니다.
이 방식은 처음 보기에는 꽤 좋아 보였습니다. 오답 대부분은 정답성 단계에서 빠르게 떨어질 것이고, 정답 hash까지 맞은 제출만 느린 memory probe를 태우면 될 것 같았습니다. 서버는 여전히 테스트를 직접 실행하지 않고, 공개 문제의 answer_hash_manifest와 클라이언트가 만든 aggregate hash를 비교해 precheck 경계만 잡으면 됐습니다.
구현하면서 조심한 부분은 worker 완료 순서였습니다. 브라우저 worker pool은 동적 큐로 케이스를 가져가므로, case 7이 case 3보다 먼저 끝날 수 있습니다. 그래서 fast check에서는 완료되는 순서대로 hash를 접지 않고, caseHashes[caseIndex]에 canonical output hash를 꽂아둔 뒤 마지막에 case index 순서로 foldCaseOutputHashes()를 호출합니다. 이 디테일을 빼먹으면 같은 코드도 worker scheduling에 따라 aggregate hash가 달라질 수 있습니다. 이건 꽤 사소해 보이지만, 서버 precheck가 붙는 순간 사소하지 않습니다.
하지만 곧 다른 문제가 보였습니다. 두 번의 전체 채점은 사용자로 하여금 피로감을 느끼게 만들 수 있을 것 같았습니다. 1차에서 “오, 맞는 것 같은데?”라고 느꼈는데 2차가 길게 따라오면, 시스템이 무겁고 답답하게 느껴질 것 같았습니다. 특히 블로그 안에서 학습 흐름을 이어가야 하는 HWJ에서는 제출 하나가 긴 의식처럼 느껴지면 곤란할 것 같았습니다.
그래서 관점을 조금 바꿨습니다. 모든 제출을 두 번 엄격하게 채점하는 것이 아니라, 빠른 JIT로 먼저 정답성을 확인하고, 정답성이 확인된 제출에 대해서만 2차 메모리 검증을 수행하는 방식입니다. 메모리 문제는 물론 별개의 문제지만, 일반적인 알고리즘 학습 흐름에서는 시간 복잡도와 정답성이 해결되면 메모리도 같이 해결되는 경우가 많습니다. 항상 그렇다는 뜻은 아닙니다. 다만 모든 오답과 실험 제출에 느린 memory probe를 태울 만큼 흔한 실패 원인은 아니라는 뜻입니다.
시간과 메모리 기준은 새롭게 세우면 된다
여기서 한 가지 생각이 정리됐습니다. 백준 같은 온라인 저지에서도 제출 기록에 시간과 메모리를 보여줍니다. 그런데 그 숫자는 코드의 절대적인 본질이라기보다는, 여러 테스트케이스 중에서 가장 오래 걸렸거나 가장 많이 쓴 케이스를 judge 환경 기준으로 요약한 값에 가까워 보였습니다. 즉, 시간과 메모리는 언제나 “어떤 환경에서, 어떤 기준으로, 어떤 케이스들을 돌렸는가”에 묶여 있다고 생각했습니다.
그렇다면 HWJ도 기준을 새롭게 세울 수 있겠다고 생각했습니다. V8 fast path에서 측정한 시간은 “사용자가 실제 브라우저에서 빠르게 검증받은 시간”으로 보면 되고, QuickJS WASM memory probe에서 나온 메모리는 “통제된 WASM sandbox 안에서 같은 제출이 요구한 메모리”로 보면 될 것 같았습니다. 둘이 완전히 같은 runtime의 숫자는 아니지만, 기준을 명확히 설명하고 일관되게 적용하면 기록으로 쓸 수 있겠다고 봤습니다.
timeMs
native V8 JIT fast check에서 측정한 시간입니다. 사용자가 체감하는 1차 실행 시간에 가장 가깝습니다.
memoryKb
QuickJS WASM memory probe에서 얻은 peak memory를 KB 정수로 반올림해 기록합니다.
final verdict
server precheck와 memory probe를 모두 통과해야 AC입니다. 둘 중 하나가 실패하면 그 verdict를 그대로 남깁니다.
그래서 기록 단위도 의도적으로 단순하게 만들었습니다. worker가 주는 memory sample은 byte지만, 제출 기록과 UI에는 KB 정수로만 남깁니다. 실제 테스트도 1536 bytes -> 2KB, 91.6KB -> 92KB처럼 소수 없이 반올림하는 정책을 박았습니다. 이건 예쁘게 보이려고 한 것이 아니라, 브라우저 기반 runner의 memory sample을 지나치게 정밀한 척하지 않기 위한 선택이었습니다. KB 아래 단위까지 보여주면 숫자는 더 과학적으로 보이지만, 실제 신뢰도는 올라가지 않습니다.
현재 선택한 쪽: UX를 우선한 차선
결국 현재 HWJ는 “가장 이론적으로 완벽한 JS judge”보다 “사용자가 덜 답답한 judge” 쪽을 우선했습니다. 이 말은 정확히 해야 할 것 같습니다. 지금의 해결책이 최고라는 뜻은 아닙니다. 오히려 차선에 가까웠습니다. 다만 HWJ의 목적을 생각하면 꽤 괜찮은 차선이라고 봤습니다.
HWJ는 대회용 보안 judge가 아닙니다. 지금 단계에서는 사용자가 악의적으로 런타임을 깨는 것을 막는 것보다, 블로그 글을 읽다가 바로 코드를 실행하고, 빠르게 실패하고, 다시 고치는 흐름이 더 중요하다고 봤습니다. 그러면 판단 기준도 달라질 수밖에 없었습니다. 모든 edge case를 완벽하게 잡는 구조보다, 대부분의 학습 흐름에서 빠르고 일관된 피드백을 주는 구조가 더 맞아 보였습니다.
그래서 이 구조를 이렇게 해석하고 있습니다.
최고의 정답:
서버 수준의 완전한 격리
언어별 동일한 memory accounting
강한 보안
대신 서버 비용과 대기 시간 증가
HWJ의 현재 차선:
브라우저 실행
빠른 JIT 피드백
필요한 범위의 서버 precheck
가능한 범위의 WASM memory probe
대신 완벽한 contest judge와는 다른 신뢰 모델
이 차선은 포기라기보다 방향을 맞춘 것에 가까웠습니다. 서비스가 무엇을 가장 중요하게 보는지에 맞춰 구조를 고르는 문제라고 생각했습니다. HWJ는 서버 비용을 줄이고, 글 안에서 바로 실행되는 경험을 만들고, 사용자가 코드를 만지며 배우게 하는 쪽이라고 봤습니다. 그렇다면 “완벽한 메모리 검증”보다 “빠른 피드백과 적당히 통제된 검증”이 더 맞을 수 있겠다고 봤습니다.
정답성은 hash로, 기록은 precheck로 묶는다
그렇다고 클라이언트 결과를 그대로 믿는 것은 아닙니다. 공개 문제에는 출제 검증 단계에서 만든 answer_hash_manifest가 있습니다. 브라우저는 generator를 돌리고 제출 코드를 실행한 뒤 case별 canonical output hash를 만듭니다. Worker 완료 순서는 섞일 수 있으므로 caseHashes[caseIndex]에 저장한 뒤, case index 순서대로 aggregate hash를 접습니다.
case별 output hash
각 테스트케이스의 canonical output을 hash로 바꾸고, 원래 case index에 맞춰 저장합니다.
index 순서로 fold
worker 완료 순서가 아니라 case index 순서로 접어 aggregate answer hash를 만듭니다.
server manifest 비교
서버가 저장해둔 answer_hash_manifest와 aggregate hash가 같은지 확인합니다.
서버는 이 aggregate hash가 manifest와 맞는지 확인합니다. 그리고 JavaScript 제출의 경우, 최종 AC 기록에는 precheckId가 필요합니다. 즉, 클라이언트가 “나 AC야”라고 말한다고 바로 기록하지 않습니다. 먼저 서버가 “이 sourceCodeId로 만든 fast check hash가 이 문제의 manifest와 맞다”는 attempt를 남겨야 합니다.
이 경계는 중요합니다. HWJ가 서버에서 테스트를 실행하지 않더라도, 최소한 제출 기록은 서버가 알고 있는 문제 manifest와 source attempt에 묶여야 합니다. 이게 없으면 브라우저 기반 judge는 너무 쉽게 “좋은 말만 하는 클라이언트”가 됩니다. 음, 클라이언트가 착하면 좋겠지만 시스템 설계가 착함에 기대면 나중에 대체로 머리가 아픕니다.
시간은 V8, 메모리는 QuickJS가 맡는다
여기서 중요한 점은 QuickJS가 V8과 다른 런타임이라서 못 믿겠다는 이야기가 아니라는 것입니다. 오히려 반대에 가깝습니다. V8은 JavaScript를 빠르게 실행하는 데 제일 좋아 보였고, QuickJS WASM은 메모리 제한을 걸고 memory sample을 읽는 데 더 좋아 보였습니다. 그러면 각자 잘하는 역할을 나눠 맡기면 됩니다.
C++ WASI 쪽은 하나의 런타임이 두 역할을 어느 정도 같이 할 수 있었습니다. 같은 WASM 실행에서 시간도 보고, memory도 보고, stdout도 잡으면 됩니다. 그런데 JavaScript는 달랐습니다. V8 native path는 시간과 정답성 확인에는 빨랐지만, 제출 단위 memory limit을 정밀하게 걸기 어려웠습니다. 반대로 QuickJS WASM은 memory limit과 측정은 훨씬 통제하기 좋았지만, 시간 측정용으로 쓰기에는 너무 느렸습니다.
그래서 QuickJS에서의 시간은 알고리즘 시간으로 보지 않기로 했습니다. 시간은 이미 V8 JIT fast check에서 봤습니다. QuickJS phase에서 보고 싶은 것은 “이 코드가 제한된 memory sandbox 안에서도 버티는가”였습니다. 만약 QuickJS가 느려서 오래 걸린다면 그것은 사용자 알고리즘의 시간초과라기보다, 2차 probe가 실패한 상태로 봐야 한다고 생각했습니다. 역할이 다른 것입니다.
출력은 materialize하지 않고 버립니다. stdout을 계속 문자열로 쌓으면 메모리 측정 자체가 출력 저장 비용에 오염되기 때문입니다. memory sample은 byte 단위로 받고, 제출 기록에는 KB 정수로 반올림해 저장하기로 했습니다. 이 값은 “V8에서 실제로 이렇게 썼다”가 아니라, “메모리 측정을 위해 선택한 QuickJS sandbox 기준에서 이 제출이 요구한 peak”입니다. 이쪽이 더 정직한 설명이라고 봤습니다.
출력 누적 제거
discardOutput으로 stdout 문자열을 버리고 outputBytes만 증가시킵니다.
runtime memory 측정
QuickJS runtime의 memoryBytes를 읽고, 여러 케이스 중 가장 큰 값을 peak로 잡습니다.
KB 정수 기록
byte sample은 Math.round(bytes / 1024)로 반올림해 제출 기록에 저장합니다.
초과 시 MLE
문제 memory limit을 넘으면 즉시 SOLUTION_LIMIT_FAILED로 보고 최종 MLE를 유지합니다.
worker 쪽 판정도 단순하게 유지했습니다. solutionResult.limitKind === "memory"이면 바로 memory failure로 보고, 아니더라도 solutionResult.memoryBytes / 1024 / 1024 > memoryLimitMb이면 SOLUTION_LIMIT_FAILED로 바꿉니다. 상위 JS hybrid judge는 각 케이스의 memoryBytes 중 최댓값을 memoryPeakBytes로 잡고, 첫 실패가 나오면 stopRequested를 세워 더 이상 의미 없는 케이스를 밀어붙이지 않습니다. 즉, 이 값은 “V8이 실제로 이만큼 썼다”가 아니라 “memory probe runtime에서 이 제출이 제한을 넘겼고, 그때까지 본 peak가 이 정도였다”에 가깝다고 보는 게 맞을 것 같습니다.
여기서 중요한 정책은 matching hash가 있어도 memory probe의 verdict를 덮지 않는다는 것이었습니다. fast check가 정답성을 통과했고 서버 precheck도 통과했더라도, 2차 memory probe에서 MLE가 나오면 최종 기록은 MLE로 남깁니다. 정답은 맞지만 제한을 넘은 코드로 보는 게 맞다고 생각했습니다.
이 정책은 테스트로도 고정했습니다. resolvePrecheckedJavaScriptJudgeResult("MLE", 2, 3, manifest, matchingHash)는 AC가 아니라 { verdict: "MLE", passedCases: 2, totalCases: 3 }를 반환해야 합니다. 반대로 fast-check hash가 없으면 precheck path에서 AC를 기록하지 못하게 했습니다. 이 지점이 없으면 “1차에서 hash가 맞았으니 AC”라는 편한 결론으로 미끄러지기 쉽습니다. 하지만 그 순간 2차 memory probe는 장식이 됩니다.
이 구조가 최고가 아닌 이유
이 구조에도 마음에 걸리는 점은 남아 있습니다.
첫째, 한 런타임이 모든 역할을 해주지는 않는다는 점입니다. C++ WASI에서는 하나의 실행 경로가 시간과 메모리를 같이 담당할 수 있었지만, JS에서는 그렇게 하면 한쪽이 망가졌습니다. V8으로 메모리를 잡으려 하면 회계가 흐릿해지고, QuickJS로 시간을 보려 하면 UX가 느려졌습니다. 결국 이 구조는 “하나의 절대 런타임”이 아니라 “역할이 나뉜 두 런타임”을 받아들이는 방식입니다.
둘째, UX 상태가 복잡해집니다. 사용자는 fast check를 통과했는데, 그 뒤 memory check에서 실패할 수 있습니다. 그래서 화면에서는 상태를 아예 분리해서 보여주는 게 맞다고 봤습니다. 1차는 “정답성/시간 precheck 통과”, 2차는 “memory probe 진행 중”, 마지막에만 “최종 AC”라고 말해야 합니다. “정답입니다”라고 먼저 말해놓고 나중에 “아니었습니다”라고 하면 기분이 썩 좋지 않을 것 같았습니다.
셋째, 비용은 서버에서 브라우저로 이동했을 뿐 사라진 것이 아닙니다. HWJ는 서버 비용을 줄이지만, 사용자의 기기와 브라우저가 일을 합니다. 이것은 프로젝트의 철학이기도 하지만, 동시에 예의가 필요한 지점입니다. worker 수를 무식하게 늘리지 않고, 하드 킬 타이머와 진행 로그를 잘 보여주고, 멈춘 느낌을 주지 않아야 합니다.
그래서 현재 구조를 최고라고 말하고 싶지는 않습니다. 대신 지금 HWJ가 풀고 있는 문제에는 꽤 잘 맞는 차선이라고 보고 있습니다. 빠른 피드백, 낮은 서버 비용, 브라우저 로컬 실행, 그리고 최소한의 서버 검증. 이 네 가지를 동시에 만족하려면 어느 정도의 찝찝함은 남았습니다. 시스템 설계는 가끔 이런 식인 것 같습니다. 완벽한 정답 대신, 덜 나쁜 타협을 예쁘게 포장하지 않고 정확히 이름 붙이는 일.
결론
JavaScript 제출에 대해 처음 꿈꿨던 것은 조금 더 매끈한 구조였습니다. V8 JIT로 시간은 빠르게 보고, QuickJS WASM으로 메모리는 엄격하게 보고, 서버는 hash precheck만 하는 구조. 실제로 그 방향은 아직도 좋아 보입니다. 각자의 장점이 분명했습니다.
다만 구현을 진행하면서 느낀 것은, judge architecture에서 기술적으로 멋진 구조와 사용자가 편하게 느끼는 구조가 항상 같지는 않다는 점이었습니다. 특히 HWJ처럼 블로그와 학습 경험 안에 judge를 넣으려는 프로젝트에서는, 엄격성 하나만으로 설계를 고르면 전체 경험이 무거워질 수 있겠다고 생각했습니다.
그래서 지금은 UX를 중심에 둔 차선을 선택했습니다. 사용자는 빠르게 피드백을 받고, 서버는 최소한의 precheck와 기록만 맡고, 메모리 검증은 가능한 범위에서 WASM sandbox로 보강합니다. 완벽한 contest judge는 아니지만, HWJ가 만들고 싶은 “글 안에서 바로 실행되는 알고리즘 학습 경험”에는 이 방향이 더 맞는 것 같았습니다.
어쩌면 지금의 결론은 기술적으로 대단히 멋진 무언가를 한 것은 아닙니다. 팩트는, 타협을 했을 뿐입니다. 다만 그 타협은 아무렇게나 한 것이 아니라, V8과 QuickJS가 각각 잘하는 일을 나눠 맡기는 쪽으로 정리한 타협이었습니다.
그리고 이 지점에서 조금 더 긴 생각도 생겼습니다. 지금의 구조는 JS를 그대로 지원하려고 하니 생긴 타협입니다. JS는 동적이고, V8은 빠르고, QuickJS는 메모리를 잡기 좋고, 브라우저라는 실행환경은 생각보다 제약이 많았습니다. 그래서 시간과 메모리를 하나의 런타임에서 동시에 예쁘게 해결하지 못했습니다.
그래서 장기적으로는 JS 비슷한 언어에 관심이 생겼습니다. 문법과 사용감은 JS를 따라가지만, 변수 타입과 실행 모델은 더 제한해서 컴파일과 메모리 관리가 쉬운 언어입니다. 지금 당장 HWJ의 결론은 아닙니다. 이번 글의 결론은 어디까지나 JS 제출은 V8으로 빠르게 검증하고, QuickJS WASM으로 메모리를 확인한다는 것입니다. 다만 이 타협을 하고 나니, 왜 사람들이 TypeScript의 타입 시스템을 더 밀어붙이거나 JS-like 언어를 새로 만들고 싶어 하는지 조금 알 것 같았습니다.
'개발 일지' 카테고리의 다른 글
| Judge Blog 구축 일지 01 - MEMFS 채택 그리고 폐기 (0) | 2026.06.14 |
|---|