Skip to content

⏳ Socket 통신에서 비동기 작업 순서를 어떻게 보장할 수 있을까?

PARK NA HYUN edited this page Dec 1, 2024 · 1 revision

📌 목차

구현하며 겪은 문제
💣 첫 번째 문제
💣 두 번째 문제
🤔 궁금증: 소켓 통신과 비동기 작업, 무엇 때문일까?

실시간 소켓 통신에서 비동기 작업 순서를 보장하는 방법
1. ACK(Acknowledgment)를 사용한 확인
2. 시퀀스 번호(Sequence Number) 사용
3. Queue(대기열) 사용
4. Retry 및 타임아웃 구현
5. Promise Chain을 통한 순차 처리


구현하며 겪은 문제

게임 진행의 흐름은 다음과 같이 이루어집니다.

  1. 클라이언트 ↔ 게임 서버: 게임 시작 버튼을 누르면 startGame 이벤트를 발행하고, turnChanged 이벤트를 수신해 현재 차례 사용자 정보 데이터를 받습니다.
  2. 클라이언트 → 음성 처리 서버: 현재 차례 사용자 정보를 받아 startRecording 이벤트를 발행해 해당 사용자의 음성 데이터를 전달 후 결과를 기다립니다. 이는 음성 분석과 채점 시간이 필요한 비동기 작업입니다. 즉, 작업이 끝날 때까지 기다리지 않고 병렬적으로 다른 작업을 진행할 수 있습니다.
  3. 음성 처리 서버 → 게임 서버: 채점 결과를 게임 서버에 전달합니다.
  4. 게임 서버 → 클라이언트: 클라이언트는 따로 이벤트를 발행하지 않고 게임 서버에서 보내는 voiceProcessingResult 이벤트를 수신해 결과 데이터를 받습니다.

💣 첫 번째 문제

문제 상황 인지

게임 시작을 했을 때 제대로 동작할 때도 있고 에러가 뜨면서 동작하지 않을 때도 있었습니다.
처음에는 잘 돼서 커밋까지 남겼었는데, 몇 번 테스트를 더 해보면서 동작하지 않을 때가 더 많다는 걸 깨닫게 됐습니다.

원인 파악

startGame → turnChanged 수신해 turnData 상태 저장 → startRecording → voiceProcessingResult 이벤트를 수신해 result 상태 저장
위와 같이 진행되어야 하는데, 게임 시작 버튼을 클릭하는 이벤트 핸들러 함수 내에서 startGame과 startRecording을 둘 다 진행해 버려서 turnData가 상태로 저장되기 전에 startRecording을 실행해 버리기 때문이었습니다. startRecording을 할 때에는 반드시 turnData의 현재 차례 사용자 정보 등의 데이터를 전달해야 합니다. 즉, turnData가 아직 없을 수도 있는데 startRecording을 해버려서 동작할 때도 있고 안 할 때도 있는 그런 문제가 발생했던 것입니다.

해결

useEffect를 사용해 turnData의 상태가 변할 때 startRecording을 하도록 바꾸었습니다. 처음에는 isGameStarted라는 flag를 두고 턴이 끝날 때마다 flag를 바꿔주면서 차례대로 게임을 진행할 수 있도록 했는데, UI 화면 전환 구성이 좀 더 추가 되면서 gamePhase: intro, gamePlay, grading, result 단계를 두고 setTimeout으로 각 단계에 맞는 화면을 띄울 수 있도록 했습니다. (하지만 여전히 문제는 있습니다. 게임 페이즈가 startRecording의 결과와 무관하게 바뀔 수 있다는 점입니다. startRecording을 동기 처리해 주고 resolve 여부를 나타내는 상태 하나를 더 두고 관리해야 할 것 같습니다.)

💣 두 번째 문제

문제 상황 인지

결과 화면이 나오지 않고 바로 다음 차례가 시작되는 문제가 있었습니다.

원인 파악

로그를 찍어보면서 결과(result) 데이터를 받기 전에 turnData(현재 차례 정보)를 받아 상태가 변경되고 있다는 것을 알게 됐습니다.
음성 데이터 전달 → 채점 → 결과 → 다음 턴, 이와 같은 순서로 진행되어야 하는데 결과를 받기도 전에 다음 턴 정보가 들어와 결과를 보여주지 못하고 intro 게임 페이즈로 변경되어 버리는 것입니다.

해결

처음에 저는 이게 저의 역량 부족은 아닐까 하는 생각이 들어서 이틀 동안 꼬박 12시간을 바쳐 해결해 보려고 했습니다. turnData를 수신하는 곳에서 setTimeout을 걸기도 해보고, 게임 진행 로직에서도 setTimeout으로 여러 시도를 다 해봤는데 안 돼서, 백엔드 파트에 말씀드렸고 ACK 역할을 하는 이벤트가 하나 더 필요할 것 같다는 이야기가 나왔습니다.
결국 next 이벤트를 하나 더 만들어서 클라이언트에서 결과 데이터를 받고 next를 발행해야 다음 turnData를 받을 수 있도록 하여 해결했습니다.

🤔 궁금증: 소켓 통신과 비동기 작업, 무엇 때문일까?

이러한 문제들을 겪으면서 저는 작업 순서를 보장하기 어려운 문제가 소켓 통신이어서 그런 건지, 비동기 작업이 있어 그런 건지 궁금해졌습니다.

  1. 소켓 통신: 순서 보장은 된다!
    • 소켓 통신은 TCP를 기반으로 동작하여 메시지가 전송된 순서는 보장됩니다. 예를 들어, 게임 서버에서 turnChanged와 voiceProcessingResult 이벤트를 순서대로 발행하면, 클라이언트는 동일한 순서로 해당 메시지를 수신하게 됩니다.
    • 따라서 제가 겪은 문제는 소켓 통신이 순서를 바꿔 생긴 것은 아닙니다.
  2. 비동기 작업: 처리 순서를 보장하지 않는다!
    • 클라이언트에서 turnChanged, voiceProcessingResult 처리하는 로직은 동기적으로 실행되어야 합니다. 그래서 일단은 useEffect로 startRecording을 하도록 한 것입니다. (여전히 문제가 있어서 개선해야 합니다.) startGame을 async 함수로 작성해도 됐을 수 있지만 이벤트 기반이기 때문에 Promise로 감싸고 이벤트를 한 번씩만 발행하도록(once) 한 후 resolve의 경우에만 emit하도록 처리해야 해서 까다롭다고 느꼈습니다.
    • 위와 같이 처리한 경우가 있습니다. 게임방 입장 joinRoom 이벤트를 발행할 때 중복 닉네임 체크로 서버에서 에러를 받아야 해서 이렇게 처리하게 되었는데 try-catch문으로 인해 코드가 상당히 복잡해졌어요.
  3. 실시간 소켓 통신에서 비동기 작업이 필요할 때 어떻게 작업 순서를 보장할 수 있을까?
    • 저희 팀에서는 next라는 이벤트를 하나 더 두어 순서를 제어할 수 있도록 했는데, 어떤 방법들이 있는지 궁금해져서 알아보게 되었습니다.

실시간 소켓 통신에서 비동기 작업 순서를 보장하는 방법

1. ACK(Acknowledgment)를 사용한 확인

ACK는 송신자가 메시지를 보낸 후 수신자로부터 확인 응답을 받을 때까지 다음 작업을 진행하지 않는 방식입니다. 이를 통해 메시지의 전송 순서와 성공 여부를 보장할 수 있습니다. 클라이언트는 서버로부터 응답을 받을 때까지 대기하고, 성공적인 응답을 받으면 다음 작업을 진행합니다. 콜백 함수로 확인 응답을 전달받아 처리할 수 있습니다.

구현 예 ([Socket.IO](http://Socket.IO))

// 클라이언트
socket.emit('updateState', { state: 'playing' }, (ackResponse) => {
  if (ackResponse.success) {
    console.log('서버가 메시지를 정상적으로 받았습니다.');
  } else {
    console.error('메시지 전달 실패!');
  }
});

// 서버
socket.on('updateState', (data, callback) => {
  console.log('받은 데이터:', data);
  // 작업 처리 후 클라이언트에 ACK 전송
  callback({ success: true });
});

장점

  • 메시지의 성공적인 전달을 보장
  • 중요한 작업에 대해 확실한 응답을 받을 수 있음

단점

  • 대기 시간이 증가할 수 있음
  • 메시지가 많아지면 성능에 영향을 미칠 수 있음

2. 시퀀스 번호(Sequence Number) 사용

각 메시지에 고유한 시퀀스 번호를 부여하여 순서를 관리하는 방식으로 수신 측은 이 번호를 기준으로 메시지 순서를 재정렬합니다.

구현 예

// 클라이언트에서 메시지 전송
let sequenceNumber = 0;
function sendMessage(data) {
  sequenceNumber++;
  socket.emit('message', { seq: sequenceNumber, data });
}

// 서버에서 메시지 처리
let expectedSequence = 1;
socket.on('message', (message) => {
  if (message.seq === expectedSequence) {
    console.log('순서대로 메시지 처리:', message.data);
    expectedSequence++;
  } else {
    console.warn('순서가 맞지 않습니다. 대기 중:', message.seq);
  }
});

장점:

  • 메시지 순서 문제를 명확하게 해결
  • 누락된 메시지 감지 가능

단점:

  • 메시지 누락 시 처리 로직이 필요
  • 구현이 조금 더 복잡

3. Queue(대기열) 사용

비동기 메시지를 대기열에 순차적으로 저장하고 하나씩 처리하는 방식으로 특히 다수의 메시지가 빠르게 수신될 때 유용합니다.

구현 예

const taskQueue = [];
let isProcessing = false;

function processNextTask() {
  if (taskQueue.length === 0) {
    isProcessing = false;
    return;
  }

  isProcessing = true;
  const task = taskQueue.shift();
  console.log('작업 처리 시작:', task);

  // 비동기 작업 처리
  setTimeout(() => {
    console.log('작업 완료:', task);
    isProcessing = false;
    processNextTask(); // 다음 작업 처리
  }, 1000);
}

// 작업 대기열에 추가
socket.on('newTask', (task) => {
  taskQueue.push(task);
  if (!isProcessing) processNextTask();
});

장점:

  • 작업이 많아도 안정적인 순서 처리 가능
  • 복잡한 작업 흐름 제어 가능

단점:

  • Queue 관리를 위한 추가 코드 필요
  • 긴 작업이 있으면 처리 지연 가능

4. Retry 및 타임아웃 구현

ACK 응답이나 시퀀스 번호 확인 중 응답이 없을 경우, 재시도하거나 타임아웃을 설정하는 방식입니다.

구현 예

function sendMessageWithRetry(event, data, maxRetries = 3) {
  let attempt = 0;

  function trySend() {
    attempt++;
    socket.emit(event, data, (ackResponse) => {
      if (ackResponse.success) {
        console.log('메시지 전달 성공:', ackResponse);
      } else if (attempt < maxRetries) {
        console.warn(`재시도 중 (${attempt}/${maxRetries})`);
        trySend();
      } else {
        console.error('메시지 전달 실패:', ackResponse);
      }
    });
  }

  trySend();
}

// 메시지 발송
sendMessageWithRetry('startGame', { roomId: 123 });

장점:

  • 네트워크 오류 발생 시 재전송 가능
  • 응답 시간이 길어도 안전하게 처리 가능

단점:

  • 재시도 횟수에 따라 성능 저하 가능

5. Promise Chain을 통한 순차 처리

Promise 체인 또는 async/await를 활용해 작업을 순차적으로 실행하는 방식입니다.

구현 예

async function processTasksSequentially(tasks) {
  for (const task of tasks) {
    await performTask(task);
  }
}

function performTask(task) {
  return new Promise((resolve) => {
    console.log('작업 처리 중:', task);
    setTimeout(() => {
      console.log('작업 완료:', task);
      resolve();
    }, 1000);
  });
}

// 작업 처리
const tasks = ['Task 1', 'Task 2', 'Task 3'];
processTasksSequentially(tasks)

장점:

  • 간결한 비동기 코드 작성 가능
  • 작업 순서를 명확히 보장

단점:

  • 긴 작업 체인은 디버깅이 어려울 수 있음
Clone this wiki locally