비동기 요청 순차 처리와 버전 충돌 대응기
2025.07.04백오피스 비동기 처리와 버전 충돌 해결기
이 글은 블로그 서비스의 백오피스를 개발하면서 마주친 두 가지 문제와 그것을 해결하기 위해 설계한 구조를 정리한 것이다:
사용자 단위 문서 버전 충돌 문제
클라이언트 측 비동기 요청의 동기화 처리 문제
그리고 요청 간 데이터 전달 방식, 특히 **버전 정보의 연속 전달**도 함께 설명한다.
문제 1: 사용자 단위 문서 버전 충돌
서비스는 JWT 기반 인증을 사용하며, 사용자는 여러 디바이스나 탭에서 동시에 로그인할 수 있다. 이로 인해 동일한 사용자가 동일한 문서에 대해 거의 동시에 PATCH나 DELETE 요청을 여러 개 보낼 수 있다. 이 요청들이 모두 수락되면 최종 데이터가 의도치 않게 덮어씌워지게 된다.
예를 들어 A 탭에서 문서를 수정하고 거의 동시에 B 탭에서도 같은 문서를 수정한다면, A의 변경이 B의 요청에 의해 사라질 수 있다.
대응: 서버 측 버전 검사
이 문제를 방지하기 위해 서버는 각 요청이 **최신 상태에 기반했는지 검증**해야 한다.
프로젝트의 전제는 다음과 같다:
여러 사용자가 동시에 같은 문서를 수정하는 일은 없다고 본다.
한 사용자가 여러 탭에서 작업하는 상황만 고려한다.
이 전제 아래, 문서 단위가 아니라 **사용자 단위로 마지막 수정 시각(버전)을 기록**하고 관리하는 방식으로 단순화했다.
클라이언트는 요청마다 `updatedAt` 시각을 함께 보낸다.
서버는 이를 기준으로 요청의 유효성을 판단하고, 충돌 시 409 오류를 응답한다.
요청이 성공하면 서버는 새로운 `updatedAt` 값을 응답으로 내려준다.
문제 2: 클라이언트 비동기 요청 동시 처리
서버가 충돌을 방지하더라도 클라이언트 측에서는 다른 문제가 발생한다.
사용자가 저장 버튼을 연속 클릭하면, 비동기 요청이 병렬로 서버에 전달되고, 일부 요청은 충돌로 실패하며 UX가 불편해진다.
대응: 비동기 요청 동기화 큐
클라이언트 측에서는 모든 비동기 요청을 **큐에 등록해 순차적으로 처리하는 구조**가 필요했다.
구조는 다음과 같다:
각 요청은 비동기 작업 단위로 큐에 등록된다.
큐는 작업을 하나씩 실행하며, 앞선 작업이 끝나야 다음 작업이 진행된다.
작업이 실패하면 큐는 중단되며, 이후 작업은 실행되지 않는다.
요청 성공 시 응답 데이터(예: 새로운 버전 정보)는 **자동으로 다음 작업에 전달된다.**
요청 간 데이터 전달: 버전 정보 연결
요청 간에는 독립성이 아니라 **데이터 연속성**이 필요하다.
특히 PATCH 요청의 경우, 서버는 새로운 `updatedAt` 값을 반환하는데, 이 값은 반드시 다음 요청에 포함되어야 한다.
이 구조에서는 앞선 요청의 결과에서 받은 `updatedAt` 값을 **다음 요청 함수의 인자로 자동 전달**함으로써 이 연속성을 보장한다.
예시 흐름:
첫 번째 요청 실행 → 서버 응답에서 `updatedAt = "2024-01-01T12:00:00Z"`
두 번째 요청 대기 중 → 요청 본문에 `updatedAt: "2024-01-01T12:00:00Z"` 자동 포함
이렇게 각 요청은 앞선 응답으로부터 이어받은 정보를 바탕으로 실행됨
이 방식은 사용자가 빠르게 연속된 작업을 하더라도 항상 최신 상태를 기준으로 서버와 동기화된 요청을 보장한다.
구조 요약
**비동기 요청은 큐에 순차 등록되며, 동시에 실행되지 않는다.**
**각 요청은 이전 요청의 결과값(버전 정보 등)을 기반으로 실행된다.**
**성공 시에는 다음 요청으로 결과를 넘기고, 실패 시 전체 작업을 중단한다.**
**외부에서는 작업 상태를 구독하고 실시간으로 처리 상황을 감지할 수 있다.**
정리
이 구조는 다음 세 가지를 동시에 해결한다:
서버 측 충돌 방지
클라이언트 UX 안정화
요청 간 연속된 버전 정보 전달
즉, 하나의 사용자가 빠르게 연속 작업을 하더라도 서버는 항상 올바른 순서로 요청을 처리하며, 사용자에게는 부자연스러운 오류 메시지나 상태 꼬임이 발생하지 않는다.
부록: 구현 코드
/**
* @description 이전 값의 타입
*/
type PrevValue = string | null;
/**
* @description 에러 결과 타입
* @template E 에러 데이터의 타입
*/
type ErrorResult<E> = {
status: "error";
data: E
}
/**
* @description 성공 결과 타입
* @template T 성공 데이터의 타입
*/
type SuccessResult<T> = {
status: "success"
data: T,
nextValue: PrevValue
}
/**
* @description 비동기 작업의 결과 타입
* @template T 성공 데이터의 타입
* @template E 에러 데이터의 타입
*/
type AsyncTaskResult<T, E> = ErrorResult<E> | SuccessResult<T>;
/**
* @description 비동기 작업을 수행하는 함수 타입
* @template T 성공 데이터의 타입
* @template E 에러 데이터의 타입
*/
type AsyncTask<T, E> = (prevValue: PrevValue) => Promise<AsyncTaskResult<T, E>>;
/**
* @description 에러 발생 시 호출되는 콜백 함수 타입
* @template E 에러 데이터의 타입
*/
type ErrorCallback<E> = (err: E) => string;
/**
* @description 성공 시 호출되는 콜백 함수 타입
* @template T 성공 데이터의 타입
*/
type SuccessCallback<T> = (data: T) => void;
/**
* @description 비동기 작업 단위의 타입
* @template T 성공 데이터의 타입
* @template E 에러 데이터의 타입
*/
export type AsyncTaskUnit<T = any, E = any> = {
name: string;
content: string;
time: Date;
asyncTask: AsyncTask<T, E>;
errorCallback: ErrorCallback<E>;
successCallback: SuccessCallback<T>;
};
/**
* @description 비동기 작업 단위에서 name, content, time만 선택한 타입
*/
export type CommitUnit = Pick<AsyncTaskUnit<unknown, unknown>, 'name' | 'content' | 'time'>
/**
* @description 비동기 작업 상태를 관찰하는 옵저버 함수의 타입
*/
export type Observer = (info: {
completedCount: number,
recentCompleted: CommitUnit[], // 최대 10개까지 완료 기록
pending: CommitUnit[], // 현재 대기 중 작업 전체 배열
isIdle: boolean,
isError: boolean,
errorMessage: string,
}) => void;
/**
* @description 비동기 작업을 관리하는 클래스
*/
class AsyncTaskManager {
private asyncTaskQueue = new Set<AsyncTaskUnit<any, any>>();
private prevValue: PrevValue = null;
private isIdle: boolean = true;
private isError: boolean = false;
private errorMessage: string = "";
private recentCompleted: CommitUnit[] = [];
private completedCount = 0;
private observers = new Set<Observer>();
constructor() {
}
/**
* @description 옵저버 등록
* @param observer 등록할 옵저버 함수
*/
subscribe(observer: Observer) {
this.observers.add(observer);
}
/**
* @description 옵저버 해제
* @param observer 해제할 옵저버 함수
*/
unsubscribe(observer: Observer) {
this.observers.delete(observer);
}
/**
* @description 최근 완료된 작업 추가
* @param unit 완료된 작업 단위
*/
private addRecentCompleted(unit: CommitUnit) {
this.recentCompleted.push(unit);
if (this.recentCompleted.length > 10) {
this.recentCompleted.shift();
}
}
/**
* @description 비동기 작업 추가
* @template T 성공 데이터의 타입
* @template E 에러 데이터의 타입
*/
addAsyncTask<T, E>({
name,
content,
time,
asyncTask,
errorCallback,
successCallback,
}: AsyncTaskUnit<T, E>) {
// 작업 큐에 추가
this.asyncTaskQueue.add({
name,
content,
time,
asyncTask,
errorCallback,
successCallback,
})
// 추가 되면 알리기
this.notify();
// 에러 상태로 인한 아이들
if (this.isIdle && this.isError) {
return false;
}
// 모든 작업이 끝나고 아이들 -> 작업 재개
if (this.isIdle) {
this.runAsyncTask();
return true;
}
// 작업 중 -> 그냥 작업
if (!this.isIdle) {
return true;
}
}
/**
* @description 비동기 작업 실행
*/
private async runAsyncTask() {
for (const asyncTaskUnit of this.asyncTaskQueue) {
this.isIdle = false;
this.notify();
const {asyncTask, errorCallback, successCallback} = asyncTaskUnit;
const result = await asyncTask(this.prevValue);
if (result.status === "success") {
this.prevValue = result.nextValue
this.asyncTaskQueue.delete(asyncTaskUnit);
this.addRecentCompleted(asyncTaskUnit);
this.completedCount += 1;
successCallback(result.data);
this.notify();
} else {
this.isError = true;
this.isIdle = true;
this.errorMessage = errorCallback(result.data);
this.notify();
// 에러 나면 멈추기
break;
}
}
this.isIdle = true;
this.notify();
}
/**
* @description 비동기 작업 큐 초기화
*/
clearAsyncTask() {
this.asyncTaskQueue.clear();
}
/**
* @description 옵저버들에게 상태 변경 알림
*/
private notify() {
const info = {
completedCount: this.completedCount,
recentCompleted: [...this.recentCompleted],
pending: [...this.asyncTaskQueue].map(task => ({
name: task.name,
content: task.content,
time: task.time,
})),
isIdle: this.isIdle,
isError: this.isError,
errorMessage: this.errorMessage,
};
this.observers.forEach(cb => cb(info));
}
}
const instanceMap: Map<string, AsyncTaskManager> = new Map();
/**
* @description AsyncTaskManager 인스턴스를 가져오는 함수
* @param id 인스턴스의 식별자
*/
export function getAsyncTaskManager(id: string): AsyncTaskManager {
if (typeof document === "undefined") {
throw new Error("getAsyncTaskManager must be called in the browser");
}
if (!instanceMap.has(id)) {
instanceMap.set(id, new AsyncTaskManager());
}
// 타입 단언 필요 (타입스크립트 한계, 런타임에 TError 일치 강제 불가)
return instanceMap.get(id)!;
}
/**
* @description AsyncTaskUnit의 타입을 보장하는 유틸리티 함수
* @template T 성공 데이터의 타입
* @template E 에러 데이터의 타입
*/
export function typedAsyncTaskUnit<T, E>(params: AsyncTaskUnit<T, E>): AsyncTaskUnit<T, E> {
return params
}