go to post list

상태 관리 지옥 탈출하기 (feat. websocket)

2024. 05. 10 • 라이브 경매 프로젝트 리팩토링하기

저는 개발을 배우기 시작한 후에 첫 번째 프로젝트로 라이브 경매 사이트를 제작했었습니다. React + Typescript를 프로젝트에 처음 적용해 봐서 이런저런 라이브러리들을 적극적으로 도입하지 못해 개인적으로 참 아쉬움이 많이 남는 프로젝트였습니다. 그래서 시간이 지난 지금 프로젝트에서 제가 구현했던 부분에 대한 리팩토링을 진행해 보려고 합니다. 특히 상태 관리에 대한 이해가 없는 상태였어서 구현 피로도가 정말 높아졌었는데요, Props drilling을 걷어내고 필요한 곳에 상태 관리 라이브러리를 도입해 컴포넌트를 다시 작성하려고 합니다.

라이브 경매 화면 API

라이브 경매 페이지 - 구매자(입찰자) 화면

라이브 경매 페이지 - 구매자(입찰자) 화면

제가 첫 프로젝트로 개발했던 라이브 경매 화면은 이렇게 생겼었습니다. 이걸 사실 상태 라이브러리나 Context API같은 것들을 쓰지 않고 구현했었기 때문에 컴포넌트 간에 여러 층으로 props를 내려 줘야 했습니다. 이 화면은 구매자가 입장한 모습이고, 판매자는 아래처럼 다른 UI를 보게 됩니다. (편의상 라이트, 다크 모드로 구분했습니다)

라이브 경매 페이지 - 판매자 화면

라이브 경매 페이지 - 판매자 화면

상태 설계

라이브 경매방에 입장했을 때, 다음과 같은 절차로 데이터를 받아오게 됩니다.

  • Spring 서버에서 인증 처리 및 기본 정보 요청 (AJAX)
  • 가져온 기본 정보를 가지고 Express 서버에 소켓 연결 요청

사용자가 컨트롤하는 경매 액션(시작, 입찰 등) 은 Spring 서버에 AJAX로 전달하게 되고, 아래와 같은 정보들을 Node.js 서버에서 웹소켓으로 전달받습니다.

  • 남은 시간
  • 현재 입찰가
  • 입찰자 (최고 입찰자)
  • 진행 물품
  • 낙찰/유찰 결과

즉 채팅을 제외하고 경매 프로세스 자체는 Client(React) → Spring → Express → Client의 단방향으로 구성된 통신 흐름을 따릅니다.

채팅 기능의 경우 두 가지 종류가 존재하는데요, 좌측 하단의 긴 채팅창은 시스템 공지 및 판매자가 공지용으로 사용하는 채팅이고, 우측 하단(경매 시스템 아래)에 있는 것은 전체 채팅입니다. 소켓에서 낙찰/유찰 이벤트가 발생하면 해당 데이터를 이용해 시스템 공지로 결과를 알려줍니다. (위쪽 스크린샷 좌측 하단의 민트색 글씨)

리팩토링 진행 및 결과

PR LINK

1. 상태 의존성 풀기 w. React Query

  • 알림 기능 구현에만 사용하고 있던 스택인 react query로 마이그레이션합니다.
  • 컴포넌트의 depth를 맞추고, 직관적으로 보이지 않던 구조를 분리하거나 합쳐서 재설계합니다.

1-1. React Query를 서버 상태 관리자로서 활용하기

  • 기존에 프로젝트에서 사용하고 있던 단순 data fetching 로직을 React Query로 마이그레이션해서 전역 상태 관리자처럼 사용합니다.
  • 한 페이지의 여러 컴포넌트에서 같은 query key를 가진 데이터를 동시에 요청할 때 실제로는 한 번만 서버에 요청하고, 해당 서버 응답을 재사용하기 때문입니다. (deduping)

1-2. 특수한 컴포넌트는 재사용할 수 있도록 하기

  • useModal hook을 개인 패키지에 구현해 배포한 후 프로젝트에 설치해 사용했습니다.
  • 기존의 Alert 컴포넌트에서 사용되던 기능으로, 컴포넌트 내부에서 따로 구현해 사용하던 것을 좀 더 넓은 범위에서 범용적으로 사용할 수 있도록 테스트 코드와 함께 구현했습니다.

2. 소켓 로직 분산시키기

  • 한 곳에 모여 있는 소켓 로직을 각 컴포넌트에서 명확히 보이도록 분리합니다.
  • 예를 들어 채팅 컴포넌트에는 채팅 로직만, 경매 컨트롤 컴포넌트에는 경매 관련 송수신 기능을 쓸 수 있도록 합니다.

2-1. useRef → Context API

  • forwardRef + props drilling을 걷어내고, 웹소켓 인스턴스의 참조값 전달은 Context API를 이용합니다.
  • 웹소켓은 직렬화가 불가능해서 상태 관리 라이브러리를 사용하기에 적절하지 못하다고 판단했습니다.

2-2. useSocket Hook

  • 소켓 이벤트를 수신하고, 송신하는 훅을 만들어 로직을 재사용할 수 있도록 합니다.
  • 소켓 로직을 담당하는 백엔드 서버에 전송할 데이터(useSocketEmitter), 소켓 이벤트에 대한 핸들링 로직(useSocketListener) 만 작성해 훅에 전달하면 이전보다 훨씬 선언적으로 사용할 수 있습니다.

useSocketEmitter

소켓에 이벤트를 전달할 때 데이터와 함께 넘겨줄 수 있습니다. 구현 코드는 다음과 같습니다.

Typescript

export const useSocketEmitter = <Req extends socketEmitRequest, Res = unknown>(event: string, data: Req) => {
  const [err, setError] = useState<unknown[]>([]);
  const [isExecuting, setIsExecuting] = useState<boolean>(false);
  const [response, setResponse] = useState<Res | null>(null);
  const socket = useContext(SocketContext);
  const roomId = data.roomId;

  const EMIT = useCallback(() => {
    return new Promise((resolve, reject) => {
      try {
        {
          setIsExecuting(true);
          socket.emit(event, { data }, (res: Res) => {
            setResponse(res);
            resolve(res);
          });
        }
      } catch (error) {
        reject(error);
      }
    });
  }, [socket, event, data]);

  useEffect(() => {
    EMIT()
      .then(() => setIsExecuting(false))
      .catch(error => setError(prev => [...prev, error]));

    return () => {
      socket.emit('leaveRoom', { roomId });
    };
  }, [EMIT]);

  return { EMIT, response, errors: err, isExecuting };
};

리팩토링 이전 아래와 같이 여러 파일에 흩어져 있었고 지나치게 장황하게 작성되어 있던 것을 지금 보신 커스텀 훅으로 분리해냈습니다. 아래는 리팩토링 전의 코드입니다.

Typescript

// api/live.ts
export function live(ws: Socket | null) {
  return {
    send: {
      connect: (roomId: number, nickname: string, seller: boolean) =>
        ws?.emit('enterRoom', { nickname, roomId, seller }),
      ...
    },
    receive: {
    ...
    },
  }
} //이 파일 안에 소켓 API에 대한 메서드가 선언되어 있었고,
// 세부 로직들은 위의 메서드로 작성되어 가독성이 떨어지고 유지보수가 어려운 상태였습니다.
...
useEffect(() => {
  if (roomInfo) {
    const { auctionRoomId, nickname, seller: type } = roomInfo;
    try {
      live(socket).send.connect(auctionRoomId, nickname, type);
    } catch (err) {
      catchError(err);
    }
    return () => {
      live(socket).send.leave(auctionRoomId);
    };
  }
}, [userId, roomInfo?.auctionRoomId]);
...

아래 코드는 위에서 작성한 훅을 이용한 리팩토링 결과인데요, 추가적인 API로직을 작성하지 않고 데이터만 전달해 사용할 수 있습니다. 이벤트 이름과 보내야 하는 자료가 명시적으로 보이면서 이펙트 훅 안의 복잡한 로직이 드러나지 않아 이전보다 선언적으로 사용할 수 있습니다.

Typescript

// 라이브 경매 컴포넌트
const { error } = useSocketEmitter('enterRoom', {
  nickname: roomInfo?.nickname,
  roomId: roomInfo?.auctionRoomId,
  seller: roomInfo?.seller,
});

useSocketListener

소켓에서 이벤트를 수신했을 때 수행할 로직을 작성해 전달할 수 있습니다. 핸들러는 내부에서 useCallback으로 메모이제이션 처리하고 있고, deps는 내부 useCallback의 deps로 전달됩니다. (deps를 입력하지 않을 경우 빈 배열이 전달됩니다)

Typescript

useSocketListener<UpdateBidResponse>('updateBid', ({ nickname, price }) => {
	//...수행할 로직
}, deps);

구현 코드

Typescript

export const useSocketListener = <Res>(event: string, handler: (data: Res, ...args: any[]) => void, deps?: any[]) => {
  const [err, setError] = useState<unknown[]>([]);
  const socket = useContext(SocketContext);

  const memoizedHandler = useCallback(handler, deps || []);

  useEffect(() => {
    try {
      socket.on(event, memoizedHandler);
      setError([]);
    } catch (error) {
      setError([...err, error]);
    }
    return () => {
      socket.off(event, memoizedHandler);
    };
  }, [socket, event, memoizedHandler]);

  return { error: err };
};
  • emitter가 리턴하는 EMIT()으로 특정 조건에서 데이터를 socket.emit()해야 할 때 유연하게 사용할 수 있습니다.
  • EMIT()이 Promise를 리턴하므로, EMIT 이후에 응답 결과로 추가 동작을 해야 하거나 단순히 추가 동작이 보장되어야 하는 경우에도 사용할 수 있습니다.
  • 아래는 이 경우에 대한 예시로, 경매가 종료되어 경매방이 닫힌 경우 사용자를 자동으로 경매 결과 대기 화면으로 이동시키는 코드입니다.

Typescript

const { EMIT } = useSocketEmitter('roomClosed', {
  roomId: roomInfo?.auctionRoomId,
});
useSocketListener('roomClosed', () => 
	EMIT().finally(() => (roomInfo?.seller ? navigate('/seller/exit') : navigate('/exit'))),
);

2-3. Recoil

Recoil을 함께 사용해 소켓 로직 데이터를 전역에서 관리합니다. 다음과 같은 이유에서 Recoil을 선택했습니다.

  1. 서버로부터 받아온 데이터에서 파생된 데이터가 필요해 selector가 강력하다고 판단했고,
  2. 소켓에서 어떤 이벤트가 발생했을 때 여러 컴포넌트에서 수행해야 하는 로직이 모두 달랐기 때문입니다.
  3. 또 API상 소켓에서 이벤트가 발생했을 때 특정 상태만이 선택적으로 갱신되어야 하는 상황이 많았습니다.

따라서 단일 스토어 구조를 가진 라이브러리보다 bottom-up 구조를 따르면서 렌더링을 최소화할 수 있는 라이브러리가 적합하다고 생각했고, 그 중 레퍼런스가 비교적 많은 Recoil을 선택했습니다. 어떤 값의 변화가 다른 상태의 변화를 트리거하는 경우가 많아서 Selector의 활용이 강력할 것이라고 생각했는데요, 모든 값을 한 번에 변경하는 방식 대신 상태를 잘게 쪼개 필요한 값만 골라 업데이트함으로써 과도한 리렌더를 방지할 수 있었습니다.

마무리

꼬인 상태관리 로직을 풀어내는 일을 또 했는데, 사실 이 프로젝트가 첫 프로젝트로 하기에는 구현 난이도도 그렇고 생각할 것이 정말 많은 프로젝트였던 것 같습니다. (특히 백엔드 파트는 더…) 아무것도 안 해 본 상태로 경매 기능을 어떻게 작동하게 만들었는지 신기할 정도라고 이 프로젝트 팀원들끼리 만나면 아직도 이야기를 하곤 합니다.

사실 작동하게만 만드는 건 쉬운 것 같습니다. 협업과 유지보수가 원활하게 가능하도록 설계와 코드를 잘 짜는 게 문제인 것 같고요… 또 테스트를 작성하지 않아서 케이스를 일단 눈으로 확인하면서 하나하나 해볼 수밖에 없었고, 그래서 정상적인 상황에서 문제없이 작동은 하는 상태로 프로젝트를 마무리했었습니다. 저는 이 프로젝트가 (그 때 당시 기준으로) 가장 어려웠고 재미있었기 때문에 나중에 꼭 고쳐야지 하는 생각이었는데 그때로 돌아간다면 시간을 좀 쓰더라도 일단 구현을 시작하기 전에 컴포넌트와 상태에 대한 설계를 촘촘히 할 것 같습니다.

다른 글