[Main Projects]/[DISMU]

[DISMU] 코드 단에서 요청을 직렬화해 동시성 문제를 해결해보자 (Promise Chain, Single Source of Truth)

Juyear 2026. 9. 5. 14:15
728x90

[DISMU] 내부 테스트를 통한 피드백 반영 및 버그 해결 (앨범 기능, refresh token, 로고 제작)

 

[DISMU] 내부 테스트를 통한 피드백 반영 및 버그 해결 (앨범 기능, refresh token, 로고 제작)

[DISMU] Discord 노래봇의 새로운 기준, AI 노래 플랫폼의 가능성 (프로젝트 소개, 시장 분석) [DISMU] Discord 노래봇의 새로운 기준, AI 노래 플랫폼의 가능성 (프로젝트 소개, 시장 분석)[DISMU] WebSocket을

blog.juyear.dev

이전 글 읽으러 가기!

 

👋 소개

안녕하세요! 대학생 개발자 주이어입니다.

오늘은 진짜 오랜만에 DISMU 프로젝트 글로 돌아왔습니다.

제가 최근에 DISMU UI를 좀 수정하고(간단한 수정이지만..) 운영 초기부터 있던 여러 버그를 수정했습니다. 

 

사실 운영 초기 때부터 수정해야겠다고 생각을 하긴 했었는데... 그 이후로 SW마에스트로 준비와 활동을 하면서 투자할 시간이 많이 없었습니다..

최근에 중간발표가 끝나면서 시간적 여유가 생겨서 수정을 하게 되었고, 공유드리고 싶은 내용들이 있어 공유해 보려고 합니다.


🔒 동시성 문제

배포 전에 문제를 못 찾은 이유

우선 가장 큰 문제는 동시성 문제였습니다.

여러 사용자가 동시에 노래를 추가했을 때, 화면에 재생 중이라고 나오는 곡이 실제 재생 중인 곡과 다른 경우가 있었고, 사용자마다 플레이리스트 순서가 다른 경우도 있었습니다.

또한 bot 서버에 동시에 요청이 들어왔을 때 한 곡이 실패되면서 서비스 전체가 정지되는 경우도 있었습니다.

 

기능 제작 당시에 동시성 문제를 테스트하지 않았던 것은 아니었습니다.

동시에 여러 명이 수정 가능한 공용 플레이리스트 기능을 제작하고 있었기 때문에 이 부분에 대해서 테스트를 진행했었습니다.

 

근데 지금 와서 생각해 보니 개인적으로 이 테스트에는 크게 세 가지 문제가 있었습니다.

1. 좋은 네트워크 환경에서 좋은 성능의 로컬 컴퓨터에서 테스트를 진행.
2. 동일한 네트워크를 통한 동시성 테스트를 진행.
3. backend 서버와 bot 서버가 https 기반으로 의존적인 느린 통신을 한다는 점을 고려하지 못함.


이러한 환경에서는 요청 간 네트워크 지연이나 서버 처리 시간의 편차가 작기 때문에, 테스트 과정에서 다양한 요청 처리 순서를 테스트하지 못했습니다. 또한 backend 서버와 bot 서버가 https 기반으로 통신을 하는데 테스트에서는 이 부분을 크게 고려하지 못했습니다.

즉, 실제 사용 환경에서 발생할 수 있는 다양한 요청 도착, 처리 순서 및 지연을 충분히 재현하지 못했습니다.

 

이후 실제 서버에 배포하고 서로 다른 네트워크 환경에서 여러 사용자가 동시에 요청을 보내면서, 각 요청의 도착 및 처리 시점에 차이가 발생했고, 기존 테스트에서는 볼 수 없었던 동시성 문제가 발생하게 되었습니다. backend 서버의 처리 속도와 bot 서버의 처리 속도가 달라 생기는 문제점도 있었습니다.

 

 

솔직히 말하면, 이 문제는 테스트를 잘하지 못해서 생긴 문제는 아닙니다.

물론 테스트를 잘했다면, 배포 전에 수정할 수 있었던 것은 맞지만... 고치면서 든 생각이 '1년 전에 코드를 왜 이렇게 짰지?' 였습니다... 약간 스포긴 하지만, 왜 클라이언트에서 backend에도 요청을 날리고 bot에도 요청을 날리는 방식으로 만들었는지 모르겠고, 플레이리스트 기능도 순서 정합성을 전혀 고려하지 않은 코드 같았습니다.

 

즉, 코드도 애초에 잘 못 짰었고, 테스트 환경도 제대로 맞추지 못해서 잡지 못 했던 문제였습니다.

 

문제의 원인은 뭐였을까?

위에서 살짝 스포를 하긴 했지만, 문제의 원인이 무엇인지 그림을 통해서 좀 더 자세히 보여드리도록 하겠습니다.

기존 요청 방식

위 그림은 기존 client가 요청을 보내던 방식입니다.

사용자가 playlist에 노래를 추가하면, bot 서버로 재생 요청을 backend 서버로 playlist db에 노래 추가 요청을 보냈습니다.

 

대충 생각해도 둘 중 하나의 처리 속도가 늦어지거나, 오류가 난다면 playlist 정합성이 바로 깨지는 방식입니다.

하지만 이 문제는 정합성의 문제지 동시성 문제의 원인은 아니었습니다.

Bot 서버의 동시 요청에 대한 처리 문제

 

서비스가 정지되는 진짜 문제는 bot 서버의 상태 변경과 그 사이에 들어오는 요청 때문이었습니다.

bot 서버는 현재 재생 중이라면 queue에 추가하고, 재생 중이지 않다면 바로 재생을 하는 형식으로 구현되어 있었습니다.

 

즉, 동시에 2개의 요청이 들어왔을 때, 2개의 요청 모두 현재 재생 중이지 않다고 판단하면 한 곡은 재생되지만 나머지 한 곡은 실패처리가 됩니다.(Check-Then-Act)

여기서 아까 client 요청 방식 문제와 연결됩니다. 위에서 보여드렸 듯이 client는 backend에 db 저장 요청을 보내고, bot 서버에 재생 요청을 보냅니다. 따라서 playlist db에는 재생되어야 할 다음 노래가 저장되어 있지만 bot 서버 queue는 비어있어 재생되지 않고, playlist db에는 영원히 완료 처리가 되지 않을 노래가 남게 됩니다. 어떻게 보면 하나로 이어져야 할 로직을 client에서 둘로 나누면서 트랜잭션을 걸기에도 애매한 설계였습니다.

 

 

사실 여기서도 의문이 하나 생기긴 합니다.

1년 전에 저는 왜 bot 서버를 별도의 queue로 관리했을까요..?

진실의 원천(Single Source of Truth)을 db라는 아주 적절한 곳에 두고 backend 서버와 bot 서버가 같이 공유를 하도록 설계했다면 어디엔 있는데 어디엔 없는 그런 문제는 발생하지 않았을 것입니다. (물론 동시성 문제를 해결하기 위한 트랜잭션이나 보상처리는 필요하겠지만요...)

그리고 이렇게 별도의 queue로 설계를 했다면, 실패했을 때 backend 서버로 정합성을 맞추기 위한 어떠한 보상처리가 필요했을 텐데 그런 코드도 전혀 없었습니다.

 

아무래도 이때 당시 Sangmyung AI 프로젝트를 진행하면서 토이 프로젝트 느낌으로 간단히 만들던 프로젝트라 더 신경 쓰지 못했던 것 같습니다. (자기합리화..)

 

Promise Chain으로 문제를 해결해 보자

변경된 요청 로직

우선 저는 분리되어 있던 client의 요청을 backend에서 관리하는 방식으로 바꿔주었습니다.

어떤 방법으로 문제를 해결하든 이 부분은 수정하는 게 맞다고 생각했습니다.

서로 아예 다른 기능이라면 client에서 요청을 나눠서 보낼 수도 있겠지만 서로 밀접하게 연결되어 있는 기능을 나눠서 보내는 것은 적절하지 않다고 생각했습니다.

 

그럼 이제 진짜 문제를 해결해 보겠습니다.

우선 저는 해당 문제를 코드 단에서 해결하고 싶었습니다.

여러 이유가 있지만, DISMU의 큰 설계 자체를 바꿔가면서 문제를 해결하기에는 갈 길이 너무 멀었고, 시간이 많지 않았습니다. 

 

그래서 제가 생각한 방법은 Promise Chain을 사용하여 코드 단에서 작업 순서를 제어하는 방법이었습니다.

트랜잭션을 활용할 수도 있었지만, 외부 요청을 하나의 트랜잭션 안에 넣어버리면 db connection pool을 오랫동안 잡아두는 문제도 있고, 동시성 문제의 원인이 db 정합성이 아니었기 때문에 Promise Chain을 선택하였습니다.

Promise Chain 동작 방식 (In-Memory Queue 구현)

Promise Chain은 이름에서 추측할 수 있듯이 코드 단에서 여러 비동기 작업을 .then 문법으로 순차적으로 연결하여 이전 요청이 완료된 후 다음 요청이 실행되도록 보장하는 기능을 합니다.(요청 직렬화) 로직은 단순합니다. 요청이 들어올 때 마다 실행 중인 앞의 요청과 연결하고, .then을 사용하여 앞의 요청이 끝난 이후에 실행될 수 있도록 순서를 보장하겠다는 겁니다.

현재 코드에서도 바로 적용할 수 있는 방법이었습니다.

const tails = new Map<string, Promise<unknown>>();

export function runExclusive<T>(
  guildId: string,
  task: () => Promise<T>,
): Promise<T> {
  const prev = tails.get(guildId) ?? Promise.resolve();
  const next = prev.catch(() => undefined).then(task);

  tails.set(guildId, next);

  next.finally(() => {
      if (tails.get(guildId) === next) tails.delete(guildId);
    })
    .catch(() => undefined);

  return next;
}

Promise Chain을 적용하기 위한 함수를 위와 같이 구현해 주었습니다.

 

핵심은 tails와 prev, next 입니다.

tails는 현재 chain(연결된 요청들)의 마지막 요청을 저장하고 있습니다.

prev는 이 마지막 요청을 의미하고, next는 현재 들어온 새로운 요청을 의미합니다.

 

*엄밀히 보자면 tails와 prev는 마지막 요청보다는 현재 연결되어 있는 전체 chain을 의미합니다.
* next(현재 들어온 요청)는 이미 연결되어 있는 전체 chain의 꼬리에 이어서 연결하는 것입니다.

  const prev = tails.get(guildId) ?? Promise.resolve();
  const next = prev.catch(() => undefined).then(task);

즉, 이 부분은 tails에 저장된 마지막 요청을 가져오고, 이 마지막 요청이 끝나면 현재 요청이 실행될 수 있도록 .then으로 이어주는 부분입니다.

Promise.resolve는 현재 들어온 요청이 첫 번째 요청일 경우를 위해 넣어주었고,

prev.catch는 혹시나 이전 요청이 실패했더라도 Queue 전체가 멈추지 않고 다음 요청을 실행할 수 있도록 넣어준 코드입니다.

 

예를 들어 현재 A 요청이 실행 중이고, 이후 B 요청이 들어와 tails에 B 요청이 저장되어 있는 상태라고 가정해봅시다.

이후 C 요청이 들어오면, prev는 B가 되고 C 요청은 B 요청이 끝난 이후(.then) 실행됩니다.

A(실행중) -> B 요청 -> C 요청

즉 위와 같이 순서가 보장되며 실행되는 것입니다.

  tails.set(guildId, next);

그리고 tails에 마지막 요청을 C로 바꿔줍니다.

결론적으로 In-Memory Queue를 구현했다고 생각하시면 됩니다.

 

근데 코드를 보면 guildId가 계속 같이 따라다니는 것을 알 수 있습니다.

GuildId를 같이 사용하지 않으면?

그 이유는 위와 같은 문제 때문입니다.

현재 발생하는 동시성 문제는 '같은 길드'에서 동시에 playlist에 노래를 추가했을 때 발생하는 문제입니다.

근데 만약 GuildId를 사용하지 않는다면, 모든 Guild가 하나의 Chain으로 묶이고, 다른 Guild의 요청까지 기다려야 하는 문제가 발생합니다.

위 그림처럼, A Guild의 요청을 처리하기 위해 B Guild의 요청이 기다리는 것입니다.

GuildId를 사용한다면

이러한 문제를 해결하기 위해 GuildId를 사용하여 Guild 별로 Queue를 만들어 주었습니다. 

이렇게 하면 같은 Guild 내에서는 동기적으로 요청이 처리되지만, 다른 Guild 끼리는 비동기적으로 요청이 처리되며 효율을 높일 수있었고, 다른 Guild의 요청 처리를 기다려야 하는 문제를 해결할 수 있었습니다.  

  async addQueue(
    guildId: string,
    userId: string,
    songId: number,
    playedBy: string,
  ) {
    return runExclusive(guildId, async () => {
      try {
        await this.prisma.playlist.upsert({
          where: { guildId },
          create: {
            guildId,
            PlaylistSongs: {
              create: [
                {
                  songId,
                  playedBy,
                },
              ],
            },
          },
          update: {
            PlaylistSongs: {
              create: {
                songId,
                playedBy,
              },
            },
          },
          include: {
            PlaylistSongs: {
              include: { song: true },
            },
          },
        });

        await fetch(`${process.env.BOT_SERVER}/addQueue`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            guild_id: guildId,
            user_id: userId,
            filename: `${String(songId)}_mp3`,
          }),
        });

        const editPlaylist = await this.readQueue(guildId);

        this.gateway.handleQueue(guildId, editPlaylist);
      } catch (err) {
        console.log(err);
      }
    });
  }

이렇게 만든 Promise Chain을 적용하기 위해 addQueue 서비스 함수를 위와 같이 수정해주었습니다.

기존에 client에서 보내던 bot 서버 요청을 addQueue 함수 안으로 넣어주었고, addQueue 로직 전체를 하나로 묶어 Promise Chain의 task로 넣어주었습니다. 

 

즉, playlist db에 데이터 추가부터 bot 서버로 노래 재생 요청, client로 playlist 데이터 전송까지 순서를 보장할 수 있도록 설계해 주었습니다.

이렇게 하면, 한 번에 하나의 요청만 bot 서버에 접근이 가능하기 때문에 더 이상 Check-Then-Act 문제가 발생하지 않을 것입니다.


🛠️ Playlist 정합성 문제

문제의 원인은 뭐였을까?

그 다음으로 수정한 문제는 동시에 노래를 추가했을 때 생기는 Playlist 정합성 문제였습니다.

어떻게 보면 동시성 문제로 볼 수 있지만 원인이 아까와 달랐습니다.

기존 Playlist 수정 방식

기존에는 Playlist를 설정하는 곳이 두 곳으로 나눠져 있었습니다.

노래를 추가하는 사용자는 setQueue(상태 변수 수정)를 사용하여 Playlist에 바로 반영될 수 있도록 했고,

나머지 사용자는 WebSocket으로 연결되어, Backend에서 전달받은 값으로 반영하도록 설계했었습니다.

 

이렇게 설계했던 이유는 낙관적 업데이트(Optimistic Update)를 위해서로 기억합니다.

문제는 낙관적 업데이트의 경우 Backend에서 실패하거나 위와 같이 정합성 문제가 생겼을 때 이를 맞춰주는 로직이 필요한데 이를 구현하지 않았습니다.

 

즉, 노래를 추가한 사용자는 A -> B 순으로 Playlist가 설정되지만, 이외의 사용자는 B -> A 순으로 설정되는 문제가 발생하게 된 것입니다.

 

Playlist의 원천을 한 곳으로

이 문제를 해결하기 위해서 저한테는 두 가지 선택지가 있었습니다.

  1. 낙관적 업데이트를 유지하고, Playlist 정합성을 위한 로직을 추가한다.
  2. 모든 client가 backend에서 전달받은 값으로 갱신한다.

결론적으로 저는 2번을 선택했습니다.

이유는 실제로 서비스를 사용해본 경험을 생각해 봤을 때 2번이 더 적합하다고 생각했습니다.

노래를 추가했을 때 실제로 서버에 반영이 되고, 노래가 재생되는 것은 setQueue를 사용하여 바로 반영하는 것에 비해 훨씬 오래 걸리는 작업입니다.

즉, 노래를 추가한 사용자 입장에서는 이미 내 노래가 나온다고 화면에 반영이 되었는데 갑자기 Playlist 순서가 backend에 맞춰서 바뀌어 버리는 문제가 생길 수 있습니다.

 

그래서 저는 이러한 문제가 생기는 것 보단 Skeleton UI를 적용하여 노래가 추가 중이라는 것을 보여주고, backend에 맞춰서 보여주는 것이 좋겠다고 생각했습니다.

바뀐 구조

그래서 저는 노래를 추가한 사용자도 backend에서 보내주는 값으로 Playlist를 수정하도록 만들어 주었습니다.

아까 Promise Chain 부분에서 살짝 언급했었던 client로 Playlist 값 보내는 부분이 바로 이 부분입니다.

이 작업을 Promise Chain안에 넣어서 Playlist 정합성을 한 번 더 지킬 수 있었습니다. 

Skeleton UI

이후 Skeleton UI를 적용하여 낙관적 업데이트를 대신해 사용자가 추가한 노래가 반영 중이라는 것을 직관적으로 알 수 있도록 만들어 주었습니다.


📋 기타

  • session storage에 userid를 추가하여 매 요청마다 userid를 확인하는 처리 지연을 해결함.
  • 콜드 스타트 문제로 main page에서 노래 추가가 안되던 문제를 해결함.
  • 전체적으로 UI를 개선함.

😊 마무리

오늘은 서비스 사용중에 발견한 동시성 문제와 Playlist 정합성 문제를 분석하고, 이를 해결하는 과정에 대해서 정리해보았습니다.

 

사실 DISMU 프로젝트를 이렇게 오랫동안 운영하게 될 것이라고는 생각하지 못했습니다. 하지만 예상보다 내부에서 좋은 반응을 얻으며 꾸준히 사용해 주셔서 서비스를 유지하고 있습니다.

 

특히 이번에 버그를 해결하는 과정에서 1년 전 작성했던 코드를 다시 살펴보게 되었는데, 예전에는 당연하게 생각했던 코드와 설계들이 지금은 다르게 보이는 것을 보며 지난 1년 동안 제가 얼마나 성장했는지를 느낄 수 있었습니다.

물론 아직 부족한 부분도 많지만, 1년 뒤에 현재의 저를 돌아봤을 때도 '그동안 정말 많이 성장했구나'라고 느낄 수 있도록 앞으로도 꾸준히 배우고 성장하겠습니다.

 

그럼 지금까지 읽어주셔서 감사드리며, 다음에도 DISMU 글로 돌아올 수 있도록 노력하겠습니다.

by. 대학생 개발자 주이어

 

https://discord.gg/8Hh8WgM4zp

 

KYT CODING COMMUNITY Discord 서버에 가입하세요!

Discord에서 KYT CODING COMMUNITY 커뮤니티를 확인하세요. 26명과 어울리며 무료 음성 및 텍스트 채팅을 즐기세요.

discord.com

KYT CODING COMMUNITY 가입하기!

728x90