Loading the catalog…
Loading the catalog…
큐를 처음 배우면 먼저 들어온 데이터가 먼저 나오는 FIFO(First In, First Out) 자료구조라고 설명한다. const queue: string[] = []; queue.push("video-101"); queue.push("video-102"); const nextVideo = queue.shift(); // "video-101" video-101 이 먼저 들어왔으므로 먼저 꺼내진다. 줄을 먼저 선 사람이 먼저 서비스를 받는 모습으로 이해하면 큐의 기본 동작을 쉽게 기억할 수 있다. 입문 단계에서는 유용한 설명이지만, 실제 서비스의 처리 순서는 push() 와 shift() 만으로 결정되지 않는다. 크리스가 동영상 두 개를 차례로 업로드하더라도 첫 번째 영상의 변환 작업이 더 오래 걸리면 두 번째 영상이 먼저 완료될 수 있다. 작업이 실패해 재시도되거나 여러 서버가 동시에 처리하면 요청 순서와 시작 순서, 완료 순서가 서로 달라진다. 사용자는 업로드 요청이 끝날 때까지 영상 변환을 기다려야 할까? 작업 서버가 감당할 수 있는 양보다 요청이 빠르게 들어오면 어떻게 해야 할까? 실패한 작업은 언제 다시 처리하며, 같은 메시지가 두 번 전달되면 결과도 두 번 만들어야 할까? 실제 서비스에서 큐는 먼저 들어온 작업을 먼저 꺼내는 저장 공간이 아니라, 작업이 들어오는 속도와 처리되는 속도를 분리하고 어떤 작업을 언제 실행할지 통제하는 구조다. 오래 걸리는 작업을 요청 안에서 끝내야 할까? 크리스가 2GB짜리 영상을 업로드했다고 해보자. 서버는 원본 파일을 저장한 뒤 여러 해상도로 변환하고 썸네일을 생성해야 한다. 처음에는 하나의 API 요청에서 모든 작업을 처리할 수 있다. async function uploadVideo(input: UploadInput) { const video = await saveOriginalVideo(input.file); await transcodeVideo(video.id); await createThumbnail(video.id); return { videoId: video.id, status: "ready" }; } 코드는 단순하지만 변환에 10분이 걸리면 API 응답도 10분 동안 끝나지 않는다. 연결이 끊기거나 서버가 재시작되면 어디까지 처리했는지 확인하기도 어렵다. 동시에 여러 사용자가 영상을 올리면 업로드 요청이 변환 작업에 서버 자원을 빼앗길 수 있다. 영상 업로드와 영상 변환은 같은 시점에 시작할 수 있지만 생명주기가 다르다. 업로드 요청은 원본 파일이 안전하게 저장되고 작업이 등록되면 끝낼 수 있다. 영상 변환은 별도의 작업 서버가 이후에 처리해도 된다. async function uploadVideo( userId: string, input: UploadInput ) { const validatedFile = validateVideoFile(input.file); const video = await saveOriginalVideo(userId, validatedFile); const job = await videoJobRepository.create({ videoId: video.id, type: "transcode", status: "queued", attemptCount: 0 }); await videoQueue.enqueue({ jobId: job.id }); return { videoId: video.id, status: "processing" }; } 서버는 파일 형식과 크기, 사용자의 업로드 권한을 확인한 뒤 원본 영상을 저장한다. 그다음 변환 작업을 등록하고 큐에는 작업 ID를 넣는다. 클라이언트는 변환이 끝날 때까지 연결을 유지하는 대신 processing 상태를 받고 나중에 결과를 조회한다. 현실의 영상 처리 과정은 다음과 같이 코드로 변환된다. 입력 사용자 ID, 원본 영상, 변환 설정 상태 영상 ID, 원본 파일 위치, 작업 상태, 시도 횟수 출력 변환된 영상과 썸네일 또는 실패 상태 큐를 도입하면서 요청 처리와 작업 처리가 분리된다. 이제 API 서버는 작업을 안전하게 등록할 책임을 지고, 작업 서버는 큐에서 작업을 가져와 실행하고 결과를 기록할 책임을 진다. 먼저 꺼낸 작업이 먼저 완료된다는 보장은 없다 하나의 작업 서버가 모든 영상을 차례로 처리한다면 큐에 들어온 순서와 작업 시작 순서가 대부분 일치한다. 그러나 처리량을 높이기 위해 작업 서버를 세 개로 늘리면 결과가 달라진다. async function worker() { const message = await videoQueue.dequeue(); const job = await videoJobRepository.findById(message.jobId); await transcodeVideo(job.videoId); } 첫 번째 서버가 30분짜리 영상을 처리하는 동안 두 번째 서버는 1분짜리 영상을 처리할 수 있다. 먼저 등록된 영상이 늦게 끝나는 것은 오류가 아니라 동시 처리의 자연스러운 결과다. video-101: 30분 영상 ───────────────── 완료 video-102: 1분 영상 ── 완료 따라서 큐에서 말하는 순서는 구분해서 생각해야 한다. 큐에 등록된 순서 작업 서버가 가져간 순서 작업을 시작한 순서 결과가 완료된 순서 일반적인 영상 변환에서는 완료 순서가 바뀌어도 문제가 없다. 각 영상이 독립적이기 때문이다. 하지만 한 영상에 대해 원본 분석 → 변환 → 썸네일 생성 → 공개 순서가 반드시 지켜져야 한다면 각 작업의 선행 조건을 확인해야 한다. async function publishVideo(videoId: string) { const video = await videoRepository.findById(videoId); if (video.transcodeStatus !== "completed") { throw new Error("Video has not been transcoded"); } if (video.thumbnailStatus !== "completed") { throw new Error("Thumbnail has not been created"); } await videoRepository.updateStatus(videoId, "published"); } 큐에 먼저 넣었다는 사실만으로 작업 의존성이 보장되지는 않는다. 공개 작업은 필요한 결과가 준비되었는지 원본 상태를 확인해야 한다. 순서가 비즈니스 규칙이라면 큐의 FIFO 동작에만 기대지 말고 상태와 선행 조건으로 표현해야 한다. 큐의 메시지를 원본 데이터로 사용해도 될까? 변환에 필요한 모든 정보를 메시지에 넣을 수도 있다. await videoQueue.enqueue({ videoId: "video-101", ownerId: "user-20", sourceUrl: "https://storage.example.com/original.mp4", resolution: "1080p", status: "queued" }); 이 메시지가 오래 대기하는 동안 사용자가 영상을 삭제하거나 관리자가 공개를 제한할 수 있다. 저장 위치가 변경되거나 변환 설정이 수정될 수도 있다. 작업 서버가 메시지에 들어 있는 오래된 상태를 그대로 사용하면 삭제된 영상을 다시 처리하거나 현재 정책과 다른 결과를 만들 수 있다. 큐에는 가능한 한 작업을 식별할 수 있는 최소한의 정보만 넣고, 실행 직전에 신뢰할 수 있는 저장소에서 현재 상태를 조회하는 편이 안전하다. await videoQueue.enqueue({ jobId: job.id }); async function processVideoJob(jobId: string) { const job = await videoJobRepository.findById(jobId); if (!job || job.status === "completed") { return; } const video = await videoRepository.findById(job.videoId); if (!video || video.status === "deleted") { await videoJobRepository.markCancelled(job.id); return; } await transcodeVideo(video); await videoJobRepository.markCompleted(job.id); } 데이터베이스의 작업 레코드가 작업 상태의 Source of Truth가 되고, 큐 메시지는 해당 작업을 실행하라는 신호가 된다. 큐에 메시지가 남아 있더라도 데이터베이스에서 작업이 취소된 상태라면 실행하지 않는다. 외부에서 작업 ID를 받았다고 해서 바로 처리해서도 안 된다. 작업의 존재 여부, 영상의 소유자와 상태, 허용된 변환 설정을 확인해야 한다. 큐가 내부 시스템에 있더라도 메시지는 지연되거나 중복될 수 있으므로 검증 전까지 현재 상태라고 가정할 수 없다. 같은 작업이 두 번 전달되면 어떻게 해야 할까? 많은 작업 대기열은 메시지가 절대로 중복되지 않는다고 보장하지 않는다. 작업 서버가 변환을 완료한 직후 결과 확인 메시지를 보내기 전에 종료되면, 큐는 처리되지 않은 것으로 판단해 같은 작업을 다시 전달할 수 있다. async function processVideoJob(jobId: string) { const job = await videoJobRepository.findById(jobId); await transcodeVideo(job.videoId); await videoJobRepository.markCompleted(job.id); } 이 구현에서는 같은 메시지가 두 번 전달될 때 변환 파일이 중복 생성될 수 있다. 사용자에게 완료 알림을 보내는 작업이라면 같은 알림이 여러 번 전송될 수도 있다. 작업 서버는 이미 완료된 작업인지 확인하고, 실행 중 상태로 안전하게 변경한 서버만 실제 처리를 시작해야 한다. async function processVideoJob(jobId: string) { const claimed = await videoJobRepository.claimIfQueued(jobId); if (!claimed) { return; } try { await transcodeVideo(claimed.videoId); await videoJobRepository.markCompleted(jobId); } catch (error) { await videoJobRepository.markFailed(jobId); throw error; } } claimIfQueued() 는 작업 상태가 queued 일 때만 processing 으로 바꾸고, 동시에 여러 작업 서버가 요청하더라도 하나만 성공하도록 구현해야 한다. 데이터베이스의 조건부 업데이트나 트랜잭션이 이 책임을 맡을 수 있다. 이처럼 같은 작업이 여러 번 전달되어도 최종 결과가 한 번 처리된 것과 같도록 만드는 성질을 멱등성이라고 한다. 큐를 사용하는 서비스에서는 메시지가 정확히 한 번만 전달될 것이라고 기대하기보다, 중복 전달을 견딜 수 있도록 작업을 설계하는 편이 안전하다. 실패한 작업을 무조건 다시 넣어도 될까? 영상 변환이 실패했다고 해서 모두 같은 방식으로 재시도할 수 있는 것은 아니다. 작업 서버의 일시적인 메모리 부족은 잠시 뒤 성공할 수 있지만, 손상된 영상 파일은 몇 번을 반복해도 실패할 가능성이 높다. async function handleFailure( job: VideoJob, error: Error ) { if (job.attemptCount < 3 && isTemporary(error)) { await videoJobRepository.incrementAttempt(job.id); await videoQueue.retry(job.id); return; } await videoJobRepository.markPermanentlyFailed( job.id, error.message ); } 재시도 횟수와 간격을 제한하지 않으면 실패 작업이 계속 큐로 돌아와 정상 작업의 처리를 방해할 수 있다. 실패 원인을 구분하고, 일시적인 오류만 일정 시간 후 다시 처리하며, 반복해서 실패한 작업은 별도로 격리해야 한다. 요청이 작업 서버의 처리 속도보다 빠르게 들어오는 상황도 고려해야 한다. 한 시간에 1,000개의 영상이 등록되는데 500개만 처리할 수 있다면 큐는 계속 길어진다. 큐가 있다는 이유만으로 처리 능력이 증가하지는 않는다. 대기 작업 수와 가장 오래된 작업의 대기 시간, 실패율을 관찰하고 작업 서버를 늘리거나 업로드 속도를 제한해야 한다. 큐는 부하를 없애는 장치가 아니라 부하가 한꺼번에 작업 서버로 전달되지 않도록 완충하고, 운영자가 처리량을 조절할 시간을 제공하는 장치다. 영상 처리 큐를 설계하기 전에 확인할 질문 API 요청에서 즉시 끝내야 하는 일과 나중에 처리해도 되는 일은 무엇인가? 큐에 들어간 순서, 작업 시작 순서와 완료 순서 중 어떤 순서를 보장해야 하는가? 여러 작업 서버가 동시에 처리해도 각 작업은 서로 독립적인가? 선행 작업이 필요한 경우 그 조건을 큐 순서가 아니라 상태로 검증하고 있는가? 작업 상태의 Source of Truth는 큐 메시지인가, 데이터베이스의 작업 레코드인가? 오래된 메시지를 처리하기 전에 영상의 현재 상태와 접근 권한을 다시 확인하는가? 같은 메시지가 두 번 전달돼도 결과가 중복 생성되지 않는가? 일시적인 실패와 다시 시도해도 해결되지 않는 실패를 구분하는가? 재시도 횟수와 간격, 영구 실패 후의 처리 방법이 정해져 있는가? 작업 유입 속도가 처리 속도보다 빨라질 때 대기 시간과 서버 용량을 어떻게 통제할 것인가? 큐는 작업의 속도와 실행 순서를 통제하는 경계다 큐의 기본 동작은 먼저 들어온 항목을 먼저 꺼내는 것이다. 그러나 실제 영상 서비스에서는 작업을 꺼내는 순서만으로 전체 처리 순서를 설명할 수 없다. 여러 작업 서버가 동시에 실행되고, 오래 걸리는 작업과 실패한 작업이 섞이며, 같은 메시지가 다시 전달될 수 있기 때문이다. 영상 업로드 요청과 변환 작업을 분리하면 사용자는 긴 처리를 기다리지 않아도 되고, 서버는 감당할 수 있는 속도로 작업을 가져갈 수 있다. 대신 작업 상태를 저장하고, 중복 실행을 방지하고, 실패와 재시도를 통제하며, 순서가 필요한 작업의 선행 조건을 검증해야 한다. 큐를 선택한다는 것은 단순히 FIFO 자료구조를 사용하는 일이 아니다. 요청이 들어오는 시점과 실제 작업이 실행되는 시점 사이에 경계를 만들고, 그 경계에서 처리량·순서·실패를 관리하겠다는 설계 판단이다. 다음 글에서는 힙과 우선순위 큐가 모든 작업을 정렬하지 않고도 지금 처리해야 할 가장 중요한 작업을 선택하는 방법을 살펴본다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
큐는 먼저 들어온 데이터를 먼저 처리하는 구조가 아니다. 큐를 처음 배우면 먼저 들어온 데이터가 먼저 나오는 FIFO(First In, First Out) 자료구조라고 설명한다. const queue: string[] = []; queue.push("video-101"); queue.push("video-102"); const nextVideo = queue.shift(); // "video-101" video-101 이 먼저 들어왔으므로 먼저 꺼내진다. 줄을 먼저 선 사람이 먼저 서비스를 받는 모습으로 이해하면 큐의 기본 동작을 쉽게 기억할 수 있다. 입문 단계에서는 유용한 설명이지만, 실제 서비스의 처리 순서는 push() 와 shift() 만으로 결정되지 않는다. 크리스가 동영상 두 개를 차례로…
Open source