사운드 리소스 전달 WebApp 만들기

문태근

안녕하세요, 저는 데브시스터즈에서 Frontend R&D Engineer로 근무중인 문태근 이라고 합니다. 게임 개발과 운영에 필요한 다양한 백오피스 개발 및 기술 연구를 담당하고 있습니다.

이 글에서는 게임 개발에 필요한 웹앱을 개발했던 경험을 공유하고, 그 과정에서 가장 중요하게 생각했던 가치관인 “단순한 설계”에 대해 이야기해 보고자 합니다.

문제 상황

게임 클라이언트를 만드는 데는 소스 코드 외에도 게임에 필요한 사운드나 이미지 같은 리소스들이 필요합니다. 일반적으로 리소스 제작팀은 Dropbox, Google Drive 등의 웹 드라이브를 통해서 클라이언트팀에게 리소스를 전달합니다.

하지만 웹 드라이브를 통한 전달에는 단점이 있습니다. 바로 이미지_최종_진짜최종_final(2).png과 같이 버전 컨트롤이 어렵다는 것입니다. 특히 게임 개발처럼 업데이트가 빌드 단위로 진행되는 프로젝트의 경우, 파일 개별의 버전이 아니라 전체 파일 단위에 대한 버전 컨트롤이 필요합니다. (특정 시점으로 되돌려야 하는 경우 있음)

그렇다면 왜 처음부터 Git과 같은 VCS를 바로 쓰지 않고 웹 드라이브를 통해 전달하는 것일까요? 여러 가지 이유가 있지만, 가장 큰 이유는 프로그래머가 아닌 팀원들에게 Git의 진입장벽이 높기 때문입니다.

Sourcetree나 Fork 같은 GUI 클라이언트를 쓰면 어느 정도 해결할 수 있으나, 여전히 아래와 같은 문제점이 있습니다.

  • Git을 써야 한다는 심리적 부담감
  • Merge Conflict 해결의 어려움
  • 비용 발생 (GUI Client 라이센스, Github 계정 → 사용자 수에 비례)

이렇듯 프로그래머가 아닌 분들이 Git을 사용하기 위해서는 많은 심리적, 물질적 부담이 불가피해 보입니다.

그래서 쿠키런: 킹덤 개발팀은 버전 컨트롤이 가능한 사운드 파일 전달 Web App(a.k.a Sonic) 을 직접 만들기로 결정했습니다.

  • Git을 써야 한다는 심리적 부담감 → 사용자에게는 최소한의 필요한 interface만 노출 (push, checkout)
  • Merge Conflict 해결 어려움 → 무조건 덮어쓰도록 설정
  • 비용 발생 → 최소한의 서비스 호스팅 비용만 발생 (사용자 수 비례 X)

0. 설계 원칙

전 마이크로소프트 CTO인 Ray Ozzie는 2005년 내부 문건에서 다음과 같이 말했습니다.

복잡성은 살인적입니다. (Complexity Kills)

복잡성은 사용자, 개발자, IT 부서의 생명을 앗아갑니다. 복잡성은 제품을 계획, 구축, 테스트 및 사용하기 어렵게 만듭니다. 복잡성은 보안 문제를 초래합니다. 복잡성은 관리자의 좌절을 초래합니다.

실제로 저도 불필요하게 복잡하게 작성된 코드와 시스템으로 인한 문제를 느꼈던 적이 굉장히 많았습니다. 그래서 Sonic을 제작할 때는 UIArchitecture 두 가지 측면에서 복잡성을 최소화하기 위해 노력했습니다.

  1. User Interface : 사용자에게 노출되는 인터페이스 최소화
  2. Architecture : 코드 및 컴포넌트 최소화

이제 각각의 관점에서 어떻게 서비스를 구현했는지 알아보겠습니다.

1. User Interface

Sonic의 사용자는 사운드팀과 클라이언트팀 두 가지 그룹으로 나뉘어 집니다. 요구사항을 정리한 결과, 사운드팀은 전달 (push), 클라이언트팀은 동기화 (checkout) 인터페이스만 있으면 충분하다는 결론을 내렸습니다.

그 외에 잘 사용하지 않으면서 서비스의 복잡도만 증가시키는 기능들은 모두 제외하기로 하였는데, 자세한 내용은 뒤쪽(2. 스펙 줄이기)에서 다루어 보겠습니다.

User Interface
User Interface

💡 용어 정의

  • Bundle : 모든 사운드 파일의 묶음
  • Revision : 번들의 버전 (Git의 commit에 대응). 사운드팀이 내부적으로 관리
  • push : 특정 revision을 서버에 업로드하는 행위 (by 사운드팀)
  • checkout : 특정 revision으로 리소스 파일을 동기화 하는 행위 (by 클라이언트팀)

2. System Architecture

서비스는 Next.js를 사용한 풀스택 웹앱으로 docker image를 통해 배포하였습니다. API로는 tRPC를 사용했는데, 백엔드에 엔드포인트를 추가하면 프론트엔드에 이에 대응되는 함수를 자동 생성해주어 API 개발 공수를 크게 줄일 수 있었습니다. 풀스택 앱 개발을 할 일이 있다면 한번 사용해 보시길 추천합니다.

2.1. 핵심 기능

우리의 목적은 사운드팀이 업로드한 여러 파일중 (1) 변경된 파일만 추려내어 (2) 시스템에 기록하고, (3) 이 파일들만 클라이언트팀에서 다운로드하는 것입니다 (변경사항이 있는 파일만 전달하는게 중요한 이유는, 클라이언트팀에서는 전달받은 파일에 암호화 등 추가 가공을 하기 때문에 변경사항이 없는 파일까지 불필요하게 전달되면 오버헤드가 크게 늘어나기 때문입니다).

위 3가지 핵심 기능을 어떻게 구현했는지 사용 플로우와 함께 전체 흐름을 알아보겠습니다.

2.1.1. 변경된 파일 추려내기 (Diff Files)

xxHash32의 WASM 빌드를 사용해 이전에 업로드 된 파일의 해시값과 첨부된 파일의 해시값을 비교했습니다. SHA-256은 전체 파일 업로드시 약 1분, xxHash32는 약 3초 정도가 걸려서 최종적으로 xxHash32를 선택했습니다.

2.1.2. 번들 데이터 시스템에 기록하기 (Persistence)

번들 데이터는 크게 두가지로 구성됩니다.

  • 사운드 파일들
  • 번들 메타데이터
    • 제목
    • 세부 설명
    • Revision
    • 작성자
    • 생성 시간
    • 파일명 및 VersionId

사운드 파일들은 AWS S3의 Versioning Bucket 에 저장하였습니다. 메타데이터와 별도로 저장한 이유는 사운드 리소스는 바이너리 파일이기 때문에, 텍스트 형태인 메타데이터와 함께 저장하기 어렵기 때문입니다.

  • 일반 Bucket : 이름만으로 파일 식별
  • Versioning Bucket : Versioning 옵션이 켜진 버킷에서 파일들은 이름 외에 VersionId라는 추가 식별자를 부여받습니다. 다운로드시 이름만 제공하면 최신 버전을, 특정 versionId까지 명시하면 해당 버전의 파일 다운

그리고 파일 목록 (이름, versionId)을 메타데이터에 넣기로 하였습니다.

이를 통해 메타데이터만 있으면 해당 번들에 어떤 파일이 있는지와 이들의 S3 versionId를 알 수 있었습니다.

메타데이터의 경우, DBMS를 사용해 직접 저장하지 않고 local git을 DB처럼 사용하기로 했습니다.

  1. git에 저장되는 데이터 schema가 Sonic 요구사항에 충분함 → schema 관리 필요 X

    $ echo $CONTENTS > sound_file_meta.csv

$ git add sound_file_meta.csv $ git commit
--author $YOUR_EMAIL
-am $TITLE
-m $DESCRIPTION $ git tag -a $REVISION -m $REVISION ```

- **제목, 세부 설명** → Git commit message (multi line)
- **Revision** → Git tag
- **작성자** → Git author
- **생성 시간** → Git commit time
- **파일들의 이름 및 Version Id** → CSV 로 만들어 파일로 커밋
  1. DBMS에는 없는 버전 컨트롤 기능 제공 → 어플리케이션 로직 (Revision 간 변경사항 찾는 코드) 작성 필요 X
    • git diff Rev.98 Rev.100
  2. git cli 명령어로 DBMS가 제공해주는 query 기능 대체 가능 → 조회 성능 이슈 X
    • select * from limit 10git log -n 10

물론 성능 등 여러 측면에서 차이가 있겠지만, 당장의 요구사항에서 이런 DBMS의 기능들은 큰 의미가 없는 반면, DBMS 도입으로 인한 복잡성을 줄일 수 있는 이점이 더 크다고 판단하였습니다.

Next.js 에서 git cli 접근을 위해, node:child_process 모듈의 exec 을 사용했습니다. 이때 git의 pretty formats 을 사용해 하나의 커밋에 들어가는 정보는 한 줄에 출력되도록 설정했습니다. 이를 통해 stdout으로 출력된 결과물을 쉽게 파싱할 수 있었습니다.

즉, Sonic은 S3 파일 업로드 + Git 커밋을 해주는 웹앱으로, 사운드팀의 push flow를 정리하면 아래와 같습니다.

Push flow
Push flow

  1. 첨부 파일 해시값 비교를 통해 변경사항이 있는 파일만 S3에 업로드 후
  2. S3 업로드 response에 담긴 S3 file versionId를 포함해 번들 메타데이터 생성
  3. Sonic 서버에 메타데이터 push
  4. Sonic 서버 내 local git 에 커밋

2.1.3. 클라이언트팀에서 다운로드 (Zip Streaming)

쿠키런: 킹덤은 Unity를 통해 개발되었습니다. Unity는 IDE 내에 커스텀 UI를 추가할 수 있는데, 클라이언트 개발자 한 분께서 이 기능을 활용해 번들 목록을 표시하고 특정 Revision으로 checkout 하는 UI를 만들어 주셨습니다.

Unity UI는 REST API를 통해 Sonic 서버에서 전체 Revision 목록을 받아와 표시합니다. 이때 Sonic 서버는 내부적으로 git history -n 20 커맨드를 사용해, 최근 20개 히스토리만 보내줍니다.

현재 Unity 에서 가지고 있는 사운드 파일이 Rev.98, 새로 Sonic을 통해 checkout 하고 싶은 번들을 Rev.100라 가정해 보겠습니다. Rev.98에서 Rev.100으로 checkout하는 플로우는 아래 그림과 같습니다.

Checkout flow
Checkout flow

  1. Sonic 서버에 Rev.100 checkout 요청 (REST GET).
  2. Sonic 서버는 git diff Rev.98 Rev.100를 통해 차이가 있는 파일 리스트업.
  3. S3에서 해당 Revision에 있는 파일들의 Download stream들을 열고

→ 이를 Archiver의 input stream으로 넣고 → Archiver의 output stream을 클라이언트로 향하는 Response로 pipe.

  1. Unity는 Response로 Rev.98Rev.100 사이 변경된 파일들의 zip 파일을 받음.

그다음 다운로드 받은 파일에 암호화 + 최적화 작업을 한 후, 소스코드에 commit 하게 됩니다.

3. 스펙 줄이기

3.1. Push된 번들 수정 / 삭제

Sonic은 단순한 구조와 안정성을 위해 (마치 블록체인처럼) 수정/삭제가 불가능하게 설계되었습니다.

수정/삭제는 자주 있는 상황이 아닌데 반해, 이를 구현하려면 복잡성이 너무 커진다고 판단했기 때문입니다.

내부적으로 Git 을 쓰기 때문에 수정/삭제는 force push를 사용해야 하는데, 아래와 같은 문제가 생깁니다.

  • 수정된 commit 이후의 commit hash 가 모두 변함
    • git tag 로 관리되는 revision 정보가 꼬임
  • 이미 발송된 slack 알림과 정보 불일치 발생 (진실의 근원이 한곳이 아니게 됨)

대신 아래와 같은 우회법으로 대응하였습니다.

  • 수정 : 해당 revision에 추가적인 suffix를 붙여 한번 더 push하도록 요청 (ex. 100-2)
  • 삭제 : 삭제 전용 revision을 만드는 기능 추가 (ex. 100-DEL)

3.2. Race Condition (Merge Conflict)

만약 사운드팀에서 두 명의 팀원이 동시에 push를 하면 어떻게 될까요?

(push가 꼬여버리는 상황 방지를 위해 critical section에 lock은 걸어 두었습니다.)

백엔드 시점으로 먼저 요청이 도착한 유저를 A, 늦게 도착한 유저를 B라 하겠습니다.

Git이었다면 A는 요청이 성공하고, B는 요청이 실패해 다시 최신 pull을 받은 다음 push 해야 합니다.

Sonic도 이렇게 구현할 수 있지만, 사용자가 이런 경우를 신경쓰지 않아도 되도록 backend에서 B를 자동으로 rebase한 뒤 commit 하게 하였습니다.

이런 구조가 가능했던 이유는 Sonic에서 데이터는 단방향으로만 흐르기 때문입니다.

Merge Conflict 는 push 한 팀이 다시 pull 을 받는 경우에 필요한데, Architecture Diagram을 다시 보면 사운드팀이 push한 파일을 사운드팀이 다시 pull 받지는 않습니다.

실제로 Revision 이라는 단위는 사운드팀 내부적으로 버전 컨트롤이 끝난 단위이며, Sonic은 단순히 이를 전달해 주기만 할 뿐입니다. 때문에 Merge conflict는 Sonic에서는 불필요한 개념인 것입니다.

마치며

여러분들은 맥도날드에서 키오스크로 주문을 해 보신 적이 있으신가요? 주문 완료까지 10번에 가까운 터치를 해야 하는 키오스크를 보며 *“이 키오스크는 내가 할 수 있는 게 많아서 좋아!”*라고 생각하셨나요, *“햄버거 하나 주문하는게 왜 이리 복잡해!”*라고 생각하셨나요? 아마 후자가 더 많을 것이라 생각합니다.

Sonic은 필요한 인터페이스만 사용자에게 노출했으며, 내부 구현도 바닥부터 로직을 구현한 것이 아니라 이미 있는 시스템을 잘 연결해 주기만 했습니다.

단순하고 제한적인 스펙에도 불구하고 사용자분들께 사운드 리소스 동기화를 위한 공수가 거의 없어졌다는 피드백을 들을수 있었으며, 실제로 첫 배포 이후 8개월간 큰 이슈 없이 쿠키런: 킹덤에 들어가는 모든 사운드 리소스의 전달을 수행해 낼 수 있었습니다. 여러분들께서도 앞으로 서비스 기획이나 코드를 작성하실 때, 정말로 필요한 것이 무엇인지 고민해 보실 수 있으면 좋을 것 같습니다.

데브시스터즈는 최고의 인재를 찾고 있습니다.

자세한 내용은 채용 사이트를 확인해주세요!

© 2024 Devsisters Corp. All Rights Reserved.