비동기 요청 순차 처리와 버전 충돌 대응기

2025.07.04
대표 이미지

백오피스 비동기 처리와 버전 충돌 해결기

이 글은 블로그 서비스의 백오피스를 개발하면서 마주친 두 가지 문제와 그것을 해결하기 위해 설계한 구조를 정리한 것이다:


  1. 사용자 단위 문서 버전 충돌 문제

  2. 클라이언트 측 비동기 요청의 동기화 처리 문제


그리고 요청 간 데이터 전달 방식, 특히 **버전 정보의 연속 전달**도 함께 설명한다.


문제 1: 사용자 단위 문서 버전 충돌

서비스는 JWT 기반 인증을 사용하며, 사용자는 여러 디바이스나 탭에서 동시에 로그인할 수 있다. 이로 인해 동일한 사용자가 동일한 문서에 대해 거의 동시에 PATCH나 DELETE 요청을 여러 개 보낼 수 있다. 이 요청들이 모두 수락되면 최종 데이터가 의도치 않게 덮어씌워지게 된다.


예를 들어 A 탭에서 문서를 수정하고 거의 동시에 B 탭에서도 같은 문서를 수정한다면, A의 변경이 B의 요청에 의해 사라질 수 있다.


대응: 서버 측 버전 검사

이 문제를 방지하기 위해 서버는 각 요청이 **최신 상태에 기반했는지 검증**해야 한다.

프로젝트의 전제는 다음과 같다:

  • 여러 사용자가 동시에 같은 문서를 수정하는 일은 없다고 본다.

  • 한 사용자가 여러 탭에서 작업하는 상황만 고려한다.


이 전제 아래, 문서 단위가 아니라 **사용자 단위로 마지막 수정 시각(버전)을 기록**하고 관리하는 방식으로 단순화했다.


  • 클라이언트는 요청마다 `updatedAt` 시각을 함께 보낸다.

  • 서버는 이를 기준으로 요청의 유효성을 판단하고, 충돌 시 409 오류를 응답한다.

  • 요청이 성공하면 서버는 새로운 `updatedAt` 값을 응답으로 내려준다.


문제 2: 클라이언트 비동기 요청 동시 처리

서버가 충돌을 방지하더라도 클라이언트 측에서는 다른 문제가 발생한다.

사용자가 저장 버튼을 연속 클릭하면, 비동기 요청이 병렬로 서버에 전달되고, 일부 요청은 충돌로 실패하며 UX가 불편해진다.


대응: 비동기 요청 동기화 큐

클라이언트 측에서는 모든 비동기 요청을 **큐에 등록해 순차적으로 처리하는 구조**가 필요했다.

구조는 다음과 같다:

  • 각 요청은 비동기 작업 단위로 큐에 등록된다.

  • 큐는 작업을 하나씩 실행하며, 앞선 작업이 끝나야 다음 작업이 진행된다.

  • 작업이 실패하면 큐는 중단되며, 이후 작업은 실행되지 않는다.

  • 요청 성공 시 응답 데이터(예: 새로운 버전 정보)는 **자동으로 다음 작업에 전달된다.**


요청 간 데이터 전달: 버전 정보 연결

요청 간에는 독립성이 아니라 **데이터 연속성**이 필요하다.

특히 PATCH 요청의 경우, 서버는 새로운 `updatedAt` 값을 반환하는데, 이 값은 반드시 다음 요청에 포함되어야 한다.


이 구조에서는 앞선 요청의 결과에서 받은 `updatedAt` 값을 **다음 요청 함수의 인자로 자동 전달**함으로써 이 연속성을 보장한다.


예시 흐름:

  1. 첫 번째 요청 실행 → 서버 응답에서 `updatedAt = "2024-01-01T12:00:00Z"`

  2. 두 번째 요청 대기 중 → 요청 본문에 `updatedAt: "2024-01-01T12:00:00Z"` 자동 포함

  3. 이렇게 각 요청은 앞선 응답으로부터 이어받은 정보를 바탕으로 실행됨


이 방식은 사용자가 빠르게 연속된 작업을 하더라도 항상 최신 상태를 기준으로 서버와 동기화된 요청을 보장한다.


구조 요약

  • **비동기 요청은 큐에 순차 등록되며, 동시에 실행되지 않는다.**

  • **각 요청은 이전 요청의 결과값(버전 정보 등)을 기반으로 실행된다.**

  • **성공 시에는 다음 요청으로 결과를 넘기고, 실패 시 전체 작업을 중단한다.**

  • **외부에서는 작업 상태를 구독하고 실시간으로 처리 상황을 감지할 수 있다.**


정리

이 구조는 다음 세 가지를 동시에 해결한다:

  • 서버 측 충돌 방지

  • 클라이언트 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
}


비동기 요청 순차 처리와 버전 충돌 대응기 - sim-log