[TOJ] 개발기록 - 실시간 채점 서비스 구현하기

조민호·2023년 11월 9일
0

팀 프로젝트를 하면서 가장 크게 팀원들간의 커뮤니케이션이 중요하다는 것을 깨닫게 된 순간이다. 혼자서 생각했다면 이걸 스스로 구현을 하는데 굉장히 많은 시간이 필요했던 사항을 팀원들과의 대화를 통해 쉽게 해결안을 찾게 됐다.


이번 프로젝트에서 가장 핵심적인 기능을 꼽으라 하면 바로 채점 시스템이다.

채점 과정 자체는 당연히 백엔드 서버측에서 구현을 한다. 팀원분의 말을 들어보면

파일시스템을 통해서 제출한 코드를 각 테스트케이스에 대입해보고 에러 여부에 따라

정/오답 여부를 판별한다고 한다.

BOJ나 프로그래머스를 보면 이처럼 채점되는 상황을 실시간으로 보여주고 있다.

우리 서비스 역시 클라이언트 측에서 채점이 되는 상황을 실시간으로 보여줘야 한다.




이를 구현하기 방법은 최초에 2가지로 간추려졌다

  1. webRTC

    • HTTP 통신이 아닌 TCP에서 동작한다.

    • Stateless한 HTTP와 달리 webRTC는 Statuful하며 Polling처럼 주기적으로 요청받을 필요도 없다.

    • 양측에서 언제든지 원하는 데이터를 보낼 수 있는 구조를 가지고 있다.

    그렇지만 P2P 방식인 만큼, 사용자가 많아질수록 성능이 안좋아지며 초기 인프라 구축에 비용을 쏟아야하는데 해당 서비스에서는 굳이 양방향 통신도 필요하지 않기에 사용하지 않기로 했다

  2. SSE

    • 서버에서 클라이언트로 단방향 실시간 데이터를 전송한다

    • 주로 실시간으로 변경되는 정보나 알림 등을 클라이언트에게 즉시 전달할 때 사용된다

    • 단순하고 쉽게 구현할 수 있다.

    • 웹 브라우저가 지원하는 경우 추가 라이브러리나 플러그인 없이 바로 사용할 수 있다.

    • 웹소켓에 비해 오버헤드가 적다.

    우리 서비스에서는 단지 서버에서 채점 현황만 계속 알려주는 단방향 통신만 필요하기도 했고 간단히 구현할 수 있어서 SSE를 사용기로 했다




그렇지만 실제 개발 당시 2개의 문제점이 발생했다

  1. UX 최적화 문제

    1. 현재 내가 접속해서 1번~10번의 제출 목록을 보고 있다

    2. 이때, 다른 누군가가 제출을 해서 제출 목록이 3개가 추가됐다

    3. 이렇게 되면 나는 1번~10번의 목록을 보고 있다가 자동으로 3개의 제출 목록이 새로 추가된다.

    4. 내가 기존에 보고 있던 1번~10번 목록이 뒤로 갑자기 뒤로 밀리게 된다

    5. 다른 사람들이 제출을 할 때마다 이런 현상이 반복된다.

    직접 새로고침을 하면 모를까, 내가 보고 있던 목록이 의도치 않게 계속해서 새로운 목록으로 인해 뒤로 밀리는 현상은 UX적으로 최악이므로 이걸 방지해야만 했다.


  2. 불필요한 요청

    1번 문제를 해결하기 위해 만약 현재 채점중일때, 새로운 목록이 추가되지 않도록 한다면 어떻게 될까?

    이 방법은 불필요한 요청이 누적된다는 문제점이 발생한다

    1번~10번의 목록을 보고 있다가 다른 곳에서 3개의 제출이 추가되어도 클라이언트 측에서 따로 추가하지 않고 1번~10번만 보여주고 있으면 해결이 되겠지만

    SSE는 서버에서 클라이언트로만 데이터를 전송하는 일방향 통신이다

    즉, 클라이언트에서 추가된 3개의 목록을 보여주지는 않게 할 수 있지만 계속해서 서버에서 전달되는 불필요한 데이터가 쌓이게 되는것이다


그래서 다음과 같은 방법을 사용했다

  1. 제출 현황 페이지 진입 시, 제출 리스트들을 서버에서 받는다

  2. 각 리스트들은 채점결과 정보들을 가지고 있다.

    아래와 같이 각각의 값에 대해 채점 상황을 알려주는 것이다.

    -6테스트케이스가 존재하지 않음
    -5오류 발생 (채점에 사용 될 타입이 존재하지 않음)
    -4채점을 기다리는 중
    -3채점 진행 중
    -2채점 하나가 완료 됨 (currentTestCase / totalTestCaseLength 값이 제공됨)
    -1채점 완료 (최종 점수 값score이 제공됨)
  3. 이 정보를 바탕으로 아직 채점이 완료되지 않은 제출 리스트들 (값이 -4 ,-3, -2인 것들)만 선택해서,

    값이 -1이 될 때까지 setTimeout이나 setInterval을 통해 초단위 간격으로 채점 정보를

    계속해서 업데이트 하는 것이다


구현 코드


const [newItem, setNewItem] = useState<StatusType>(item);

const { data: checkCorrect, refetch: checkCorrectRefetch } = useGetSubmitItem({
    type: 'correct',
    id: item.id,
  });

  useEffect(() => {
    if (![-5, -6].includes(newItem.correct_score) && !(newItem.correct_score >= 0)) {
      const intervalId = setInterval(() => {
        const correctJudgeStatus = checkCorrect?.data.data.judgeStatus;

        if (correctJudgeStatus !== undefined) {
          if ([-4, -3, -2].includes(correctJudgeStatus.state)) {
            checkCorrectRefetch().then((res) => {
              if (res !== undefined) {
                setNewItem((prev) => ({
                  ...prev,
                  correct_score: res.status === 'error' ? -4 : correctJudgeStatus.state,
                }));
              }
            });
          } else {
            setNewItem((prev) => ({
              ...prev,
              correct_score:
                correctJudgeStatus.state === -1
                  ? correctJudgeStatus.score
                  : correctJudgeStatus.state,
            }));
          }
        } else {
          checkCorrectRefetch();
        }
      }, 3000);

      return () => {
        clearInterval(intervalId);
      };
    }
  }, [checkCorrect, checkCorrectRefetch, newItem]);

현재 컴포넌트는 제출 리스트 테이블의 tr에 해당하는 컴포넌트이다.

즉, 각각의 제출 리스트에 1개에 해당하는 컴포넌트인 것이다.

  • useQuery를 사용하는 커스텀 훅을 통해 현재 제출에 대한 정보를

    가져와서 checkCorrect변수에 할당한다.

  • 채점 상태가 -5, -6이 아니며, 점수가 이미 존재하는 문제를 제외하고

    setInterval을 진행한다.

  • correctJudgeStatus 변수에는 현재 채점 상태관련 객체가 들어간다

    // 채점 상태, 현재 채점중인 테케, 전체 테케 갯수
    {state: -2, currentTestCase: 1, totalTestCaseLength: 3}

    채점이 시작되기 전에는 undefined가 들어가지만 이후 채점이 진행될때 아래 사진과 같이 실시간으로 채점 상태가 들어가게 된다

    (근데 -4와 -3일때도 undefined가 뜨긴함 )

  • correctJudgeStatus에 해당하는 현재 채점 상태가 -4,-3,-2 셋중 하나라면 제출에 대한 정보를 가져오는 useQuery를 refetch한다

    다시 한번 서버로 api를 재호출해서 현재 제출 상태를 최신 상태로 업데이트 하는 것이다

    이 refetch를 채점 상태가 -1이 나올 때까지(=최종 채점 완료) 반복하는 것이다

    • 매번 refetch를 할 때, 만약 업데이트한 채점 상태의 값이 undefined라면 (res !== undefined) 아직 제대로 채점이 진행중인 상태가 아닌 것이다.

      • 이 때, fetch의 .status프로퍼티가 ‘error’라면 -4로

      • 아니라면 기존 값인correctJudgeStatus.state로 설정한다

  • refetch를 하다 채점 상태값이 -1이 나와서 채점이 완료됐다면 해당 점수에 맞게 상태를 업데이트 setNewItem() 해주고 이 상태를 브라우저 화면에 보여주면 된다

이어서 제출 내역들의 제출 시간들을 현재 시점으로부터 얼마나 지났는지 실시간으로 보여주는 코드이다

const [diffDate, setDiffDate] = useState<number>(-1);

useEffect(() => {
    const intervalId = setInterval(() => {
      if (newItem != null) {
        if (diffDate === -1) {
          const submitDate = new Date(newItem.createdAt);
          const nowDate = new Date();

          setDiffDate(
            Math.floor((nowDate.getTime() + 9 * 60 * 60 * 1000 - submitDate.getTime()) / 1000),
          );
        } else {
          setDiffDate((prev) => prev + 1);
        }
      }
    }, 1000);

    return () => {
      clearInterval(intervalId);
    };
  }, [diffDate, newItem]);

백준에서 보면 제출 날짜를 보여주는 게 아니라 1분 전, 1초 전 이렇게 보여주고 있다.

이 useEffect는 1초마다 계속 그 값을 변경시켜주는 코드이다.

diffDate는 초 단위로 얼마나 지났는지 표시하는 값이 들어간다. 만약 몇 초 전이라면 아래와 같이 추가한다

setDiffDate((prev) => prev + 1);

그게 아니라면(=분,시간,일 단위) Date객체를 이용해서 시간차를 구한다

 setDiffDate(
            Math.floor((nowDate.getTime() + 9 * 60 * 60 * 1000 - submitDate.getTime()) / 1000),
  );

초깃값을 -1로 지정한 이유는 최초에는 현재 시간과 제출 시간을 계산해야 했기 때문에 맨 처음이란 지표가 필요했는데 지표로 절대 나올 수 없는 숫자중 하나인 -1을 초깃값으로 설정한 것이다




실시간 채점이 제대로 반영되고, 추가적인 새로고침 동작을 하지 않는 이상 자동으로 새로운 제출 목록이 추가되지도 않는다.

profile
웰시코기발바닥

0개의 댓글