logo

Blog

홈서버에 내 집 만들기

#react#ryooniverse#nestjs#docker#websocket
ryxxn profileryxxn
2025.04.13
thumbnail

홈서버를 구축하고, 사람들이 놀러올 수 있는 공간을 만들어야 겠다고 생각했다.

1. 이름을 뭘로 짓지?

항상 서비스 이름을 짓는 게 가장 큰 고민거리 중 하나이다.
아무래도 나의 공간이고 내 세상이다보니
ryoon + universe로 Ryooniverse라는 이름이 탄생했다.


2. 기능 정하기

이름도 정했으니 필요 기능과 개발 스택을 정할 차례이다.

우선, 사람들이 캐릭터로 방문하고 상호작용할 수 있는 공간을 상상했다.
이에 따라 아래와 같은 기능이 필요하다고 판단했다.

기능설명
실시간 접속자 표시현재 방문자의 정보와 위치 표시
휘발성 채팅서버에 저장하지 않는 실시간 채팅 기능
방명록 작성방문자의 방명록 기능
캐릭터 닉네임 랜덤 생성사용자가 별도 가입 없이 방문 가능

위 기능을 우선으로 잡았다.
처음엔 IP 기반으로 유저 ID를 생성하는 것으로 생각했다.
하지만 사설 IP를 할당받아 사용하는 경우, 중복이 발생할 수 있어 UUID 생성으로 변경했다.

멀티 캐릭터 위치 공유 및 휘발성 채팅을 위해 web socket,
현재 방문자(접속자) 정보를 임시 저장할 cache 서버인 redis,
DB는 사용을 고려했지만, 사용자 정보, 로그를 남기기 위해 postgresql을 사용하기로 했다.


3. 개발 스택 정하기

- Client

SEO가 필요한 것도 아니고, 굳이 프론트엔드 서버 기능을 사용할 일이 없을 것이라 판단했다.
그래서 React로 결정했다.

- Server

bash
/client/server...

와 같이 모노레포 환경에서 만들 것이기 때문에 패키지 및 언어 통일을 위해 NestJs로 결정했다.


4. 개발 시작

우선 백엔드 API를 만들어야 했다.
웹소켓, redis 연결은 후순위로 미뤄두고, 사용자 생성, 방명록 API를 만들었다.

API 생성 이후 프론트와 연동하기 위해 캐릭터와 맵(내 방) assets을 만들어야 했다.
캐릭터는 일단 5개 고정으로 생각했고, 바로 챗지피티한테 만들어달라고 했다.

made-characters

생각보다 귀엽다.
물론 노란색 캐릭터는 귀엽지 않다.


원래는 새로고침마다 사용자를 새로 생성하려고 했지만, 방명록도 있고, 사용자 정보가 저장되는 것이 좋을 것 같았다.
그래서 Cookie를 사용해서 사용자 정보가 일주일간 유지되도록 했다.
추후 영구 저장 기능도 추가할 예정이다.


5. 캐릭터 변경 요청을 엄청 많이 한다면?

나는 짠돌이 개발자다.
SaaS 서비스를 써오면서 API 요청을 최대한으로 줄이는 것이 습관이 되었다.

그래서 사용자가 캐릭터 변경을 할 때마다 API 요청을 보낸다면 물론 악의를 가지고 다량 변경을 하지는 않을 것이라 믿지만
그런 경우를 막기 위해 debounce를 사용해 마지막 변경 이후 5초 뒤에 API 요청을 날리도록 했다.

typescript
// ...  const queryClient = useQueryClient();  const updateMutation = useUpdateUser();
  // 마지막으로 변경한지 5초 후 요청  const debouncedUpdate = debounce(updateMutation.mutate, 5_000);
  const onChangeCharacter = (character: string) => {    debouncedUpdate({ character });    // optimistic update    queryClient.setQueryData(queryKeyFactory.users.me, (oldData: User) => {      if (!oldData) return oldData;
      return {        ...oldData,        character,      };    });  };// ...

결과는 다음과 같다.

debouncing-when-character-change

6. 캐릭터 이동을 구현하자

맵에 캐릭터를 띄우기 위해 html 태그 내에서 포지션 계산 및 제어를 하려 했다.
그러나, 캐릭터가 실시간으로 움직이는 구조를 고려했을 때, DOM 요소를 움직이는 방식보다 canvas를 사용하면 훨씬 부드럽고 일관된 프레임 렌더링이 가능하기에,
canvas에서 캐릭터를 렌더링했다.

또한 keyDown 상태에서 requestAnimationFrame 렌더링마다 웹소켓에 위치정보를 보낸다면..
내 서버는 그만큼 대단하지 않았다.

그래서 이번에는 throttle을 적용했다.
물론 캐릭터 이동이 아주 조금 끊겨 보이겠지만, 뭐 움직이는 것만 보이면 됐다.

매 프레임마다 요청을 보낸다면 1초에 최대 60회의 요청이 날아간다.
그래서 50ms throttle을 적용해 소켓 emit 요청을 단위초 기준 최대 20회로 감소시켰다.

typescript
// ...const throttledEmitMove = throttle(  (user: { id: string; x: number; y: number; character: string }) => {    const { x, y, ...rest } = user;    const position = { x, y };    socket.emit('move', { position, ...rest });  },  50);// ...

그 결과이다.

throttling-when-moving-character

7. 캐시 서버 활용하기

그냥 웹소켓만 사용한다면, 사용자가 처음 접속했을 때 기존에 있던 접속자의 정보는 뜨지 않는다.
그래서 선택한 것이 캐시 서버인 redis이다.
사용자가 접속 또는 정보가 변하면 redis에 일정 시간동안 저장하여
접속자 목록을 redis에 담고 있는다.

그래서 처음 접속시 다른 사용자들의 위치 및 정보가 바로 보이도록 했다.


8. 채팅 만들기

이제 채팅만 만들면 끝이다.
피그마 채팅에서 영감을 받아 사용자 캐릭터 위에 채팅이 뜨도록 만들었다.
캐릭터와 맵은 canvas에서 렌더링했지만, 채팅 input과 같은 UI를 캔버스에서 렌더링하기 힘들어서
좌표를 가져와 position absolute로 채팅 UI를 구현했다.

/를 누르면 채팅 입력 UI가 뜨고, Enter를 누르면 채팅이 전송된다.

아래는 친구들이 놀러온 최종 화면이다.

ryooniverse-result-screenshot

오픈소스로 공개했습니다. 코드가 궁금하시다면 여기로 하하

https://github.com/ryxxn/ryooniverse

감사합니다.

Related Articles