꿈깨

온라인 3D 방탈출 게임 서비스 😴



💭 About

‘zzz’ 는 꿈을 꾸는 상태를 표현한 단어이자, 게임 프로젝트의 이름입니다

‘꿈’ 이라는 매체를 이용하여 상상하고 문제를 풀면서 해결하여 방탈출을 하는 것이 본 게임을 이어나갈 수 있는 방법입니다

당신은 꿈 속에서 얼마만큼의 역량을 발휘할 수 있는지 궁금하지 않으신가요?


📅 프로젝트 기간

  • 2022.2.25 ~ 2022.4.9
  • 1차 배포 : 2022.3.30

🎉 5일 동안 550여명의 유저들이 550여개의 방을 만들고, 375팀이 게임을 시작하였으며, 9팀이 탈출에 성공하였습니다!!


📌 바로가기


📂 백엔드의 고민과 공부 기록은 → https://github.com/HangHae99Zzz/RoomEscape_BE/wiki

✨ 주요 기능

  • 방탈출 게임을 위한 방을 만들고, 랭킹조회 및 게임 설명을 확인할 수 있습니다.
  • 대기 현재 대기중인 인원을 체크하고 게임을 시작할 수 있습니다. 링크로 친구를 초대하고, 보이스 채팅도 가능해요!
  • 게임 팀원들과 보이스채팅을 나누며 방에 배치된 3D 물체를 클릭해 주어진 문제를 풀고 탈출할 수 있습니다. 제한시간 안에 방을 탈출해보세요.
페이지 뷰 자세히 보기

🍎 Team Member - BackEnd

김가은 최규원 반원재
@Kim gaeun @Choi kyuwon @Ban wonjae

📚 기술스택




Tools


  • Java 1.8.0
  • Springboot 2.6.4
  • Gradle 7.4
  • MySQL 8.0.23
  • Node.js 16.14.0
  • Express 4.17.1
  • Socket.io 2.3.0
  • Nginx 1.14.0
  • Mockito 4.4.0

🕹 Convention

Coding Convention · Commit Convention

Coding Convention

📍 네이밍 Convention

  • 폴더명은 소문자, Class명은 첫 글자 대문자
  • Method는 lowerCamelCase을 사용하고, 동사나 전치사로 시작한다. ex) get/set, init, is/has/can, create, find, to, A-By-B …
  • JUnit Test Method : Method명_테스트상태_기대행위 ex) isAdult_AgeLessThan18_False
Commit Convention

📍 Commit Convention

✅ 유다시티 커밋 메시지 스타일 가이드 : 참고
✅ 본문에는 변경한 class 이름과 어떻게, 무엇을, 왜 변경했는지 자세히 적기

커밋 타입: 제목
  //띄어쓰기
 본문
  //띄어쓰기
(꼬리말 타입: #이슈 번호)

🚨 커밋 타입

Docs: 문서 작업
Feat: 새로운 기능 추가
Fix: 버그를 고친 경우
Refactor: 리팩토링
Comment: 주석 추가 및 변경
Rename: 파일 혹은 폴더명 수정, 경로 변경
Remove: 파일 혹은 기능 삭제
Test: 테스트 관련 작업
Resolve conflicts: 충돌 발생 commit에서 사용(본문, 꼬리말 생략)

🚨 꼬리말 타입

Fixes: 이슈 수정중(아직 해결되지 않은 경우)
Resolves: 이슈 해결했을 때
Ref: 참고할 이슈가 있을 때

⚠️ Error

Error 관리

✅ 모든 에러는 Error Code로 관리

  • Error Code마다 httpStatus / errorCode / errorMessage 작성
  • ErrorCode는 httpStatus마다 일련번호를 붙인다("httpStatus_number") ex) "400_3", "404_4"

Error Code 보기


🐾 Branch Strategy

브렌치 관리

✅ 개인별 브렌치(gaeun, kyuwon, wonjae)에서 작업

  • push 전에 테스트코드를 통과하는지 확인하기
  • 팀원에게 변경된 사항 공유 후 main에 PR
  • 개인별 브렌치는 main을 pull하여 변경된 최신사항 업데이트

✅ NodeJS는 별도의 Repository에서 관리하며, main에 바로 push/pull

✅ 기능 개발을 위해 별도로 테스트하는 경우에도 새로운 브렌치에서 작업 : 이후 반영 시 main으로 PR 후 Close
  • springRTC 브랜치는 spring을 기반으로 webRTC를 구현함.
  • 다만 1대1 P2P연결은 성공하였으나 N:N 연결이 되지 않는다는 한계가 존재함.
  • 이후에 Spring이 아닌 Node.js의 socket.io를 활용하게 되는 계기가 됨.
  • redis 브랜치는 redis를 부분적으로 적용해보는 브랜치임.
  • 팀 프로젝트 기간 제한 때문에 제대로 적용하지 못하였으나 spring으로 redis에 Clue객체를 저장하고 불러오는 것은 성공함.

이슈 관리

✅ 새로운 Issue가 생기면 먼저 GitHub Issues에 생성

  • bug, feature 중 해당되는 Issue template 사용
  • issue 작성 내용 중 변경사항이 있는 경우에는 해당 글에 comment나 별도 이슈로 생성하여 업데이트

✅ 완료된 이슈는 commit Resolves 사용해서 Close

✅ 관련된 이슈가 많을 경우에는 Milestones를 사용해서 관리

📺 Detail

아키텍처

해당 아키텍처를 도입하게 된 배경

ERD
API 명세서

🚨 API 설계규칙

Rest API URI 설계규칙을 따른다.
  1. 후행 /는 URI에 포함하지 않는다.
  2. 계층관계를 나타낼 때 슬래시 구분자를 사용한다. ex) /rooms/{roomId}/quizzes/{quizType}
  3. 긴 path를 표현하는 경우에는 가독성을 높이기 위해 하이픈(-)을 사용한다.
  4. 언더바(_)는 URI에 사용하지 않는다.
  5. URI는 모두 소문자로 작성한다.
  6. 파일확장자는 URI에 포함하지 않는다.
  7. 모든 resource는 복수형을 사용한다.

🔨 Trouble Shooting

공통

메인페이지 서버 부하 문제

✅ 문제상황

메인페이지에서 변경된 방 정보를 업데이트하기 위해 1초 간격으로 Room 리스트 조회하기 api를 요청(Polling)

메인페이지에 접속자가 집중되면 서버 부하 증가 → 배포 이후 메인페이지 40명 정도 접속하면서 CPU 90%로 급증

📍 서버를 t3.micro으로 변경(CPU 1 → 2)하여 우선 조치(메모리는 Swap으로 늘려놓은 3G로 충분하다고 판단)


🔍 테스트

메인페이지 접속자 수에 따른 서버 부하를 확인하기 위해 테스트 진행

Client의 메인페이지 접속자 수를 10 단위로 증가시키면서 CPU 사용량을 실시간 관찰

① CPU 사용량이 급증 ② 전체 200 중 180%까지 올라가는 지점을 한계로 봄

📑 테스트 결과 : api 요청 간격을 2초로 늘리면 현재보다 30명 더 접속 가능

- api 요청 간격 1초(현재 상태) : 70명
- api 요청 간격 1.5초 : 80명
- api 요청 간격 2초 : 100명

📍 api 요청 간격을 1초로 유지하자!

현재 서비스 수준에서 70명 이상이 메인페이지에 접속할 가능성은 낮고,

업데이트 간격을 2초로 늘리면 오히려 유저 경험이 안좋아 질거라고 판단

서비스가 성장한다면, Polling이 아니라 다른 방법으로 문제 해결을 시도하는 것이 더 나을 것!


백엔드

WebRTC 서버 구축 문제

✅ P2P(signalling server) vs MCU/SFU

❓ 4명까지 보이스 채팅이 가능한 환경을 만들기 위해 어떤 서버를 사용해야 하는가?

---> ❕ signalling server를 구축하자!


📑 오디오만 사용하고, 4명까지만 연결하기 때문에 signalling server로도 client 부담이 크지 않을 거라고 생각했고, MCU, SFU는 프로젝트 기한 내에 구현하기 어려울 것으로 판단했다.


✅ Springboot vs NodeJS

❓ 다대다 WebRTC를 위한 signalling server를 어떻게 구현할 것인가?

---> ❕ NodeJS의 Socket.io를 사용하여 signalling server를 구현하자!


📑 Springboot를 사용하면 하나의 서버만 관리하면 되고, 팀원들 모두가 익숙한 프레임워크를 사용할 수 있다.

그러나 참고자료가 많지 않다.

📑 NodeJS를 사용하면 Socket.io 라이브러리를 사용해서 비교적 쉽게 구현이 가능하나,

서버를 2개 관리해야 되기 때문에 유지관리에 비용이 더 소모되고, 익숙하지 않은 언어와 프레임워크를 사용해야 한다.

📑 Springboot로 signalling server를 구축하면 시간이 더 오래 걸릴 것으로 예상했고,

제한된 시간 안에 서비스의 완성도를 높이기 위해서는 NodeJS의 Socket.io를 사용하는 것이 더 적합하다고 판단!


유저 disconnect 처리 문제

✅ 문제상황

📑 유저가 브라우저를 종료하면 socket.io의 disconnect 이벤트가 발생

📑 Client는 방장이 나가면 새로운 방장을 알아야한다(방장만 게임 시작 가능!)

📑 DB에서는 disconnect된 유저 정보를 삭제하고, 방장이 변경된 경우 업데이트 필요


해결방안 1️⃣ nodeJS → Client →← Spring

📑 nodeJS에서 disconnect시 event를 통해 disconnect된 유저의 socket.id를 Client로 보냄

📑 Client는 Spring으로 HTTP 통신을 통해 socket.id를 넘겨주고, 방장이 바뀐 경우 return 값을 받음

📑 유저가 1명 남았는데 disconnect가 되면 Client가 없으므로 nodeJS에서 DB로 쿼리를 보냄

⚠️ Client에서 동시에 여러 번 업데이트/삭제 요청이 발생하여 에러 발생!!

---→ ❕ DB에 한 번만 요청하자!


해결방안 2️⃣ disconnect와 관련된 모든 DB처리는 nodeJS에서 처리

📑 disconnect시 DB에 필요한 업데이트/삭제 쿼리를 보내고, 방장이 변경되면 event로 해당 방 Client에게 알려줌


게임 플레이 중 동시성 제어 문제

✅ 문제상황

📑 게임 중 맞춘 문제 수(스코어), 찬스가 변경될 경우 해당 방 Client 모두에게 해당 정보를 업데이트해주어야 함


❕ Socket.io의 이벤트를 활용해서 스코어나 찬스 변경 이벤트 발생 시 해당 방에 데이터 변경 사실 알려주자!

📑 HTTP 통신에서는 Client 요청 없이 Server가 Response 할 수 없으므로 socket 통신을 이용하면 해결할 수 있음!

📑 퀴즈를 동시에 보고 있을 때도 한 명이 문제를 풀면 이벤트를 활용해 이미 푼 문제로 변경


CI/CD 적용

✅ 문제상황

📑 프론트와 백엔드를 합친 이후 예기치 못했던 많은 에러가 발생함

📑 잦은 에러수정으로 인한 수동 배포에 드는 시간 소모가 점점 많아져 시간 절약을 위하여 배포 자동화 필요


✅ Travis vs Github Actions

📑 Travis를 더 많이 쓰고 블로그 자료도 많았지만 따로 서버 설치를 해야함

📑 Github Actions는 별도의 서버 설치없이 Github을 통해 바로 사용이 가능함

📑 기간이 한정되어 있어서 배포 자동화 구축에 많은 시간을 쏟을 수가 없다


✅ Github Actions로 결정한 이유

📑 Travis를 사용할 만큼 프로젝트의 규모가 크지 않고 서버 설치에 대한 시간제약, 그리고 Github의 다양한 기능들을 사용해보고 싶었던 마음이 있어서 Github Actions를 이용하여 배포 자동화를 구축하기로 결정


테스트 코드 적용

✅ 테스트코드를 도입한 이유!

📑 배포 자동화를 도입했기 때문에 검증되지 않은 코드들이 자동으로 배포될 수 있어 차후에 문제 파악 어려움이 존재.

---> 테스트코드를 통해 사전 검증의 필요성 존재.

테스트 코드를 통해서 코드 작성시에 고려하지 못했던 case에 대한 확인과 개선이 가능.

리팩토링시에 빠르게 코드를 검증 가능.


✅ 문제상황

📑 단위 테스트(QuizServiceTimeTest)에서 ClueRepository와 QuizRepository를 @Mock으로 처리하지 못하는 문제 발생.

📑 통합 테스트에서 DI 방법으로 @RequiredArgsConstructor를 통한 생성자 주입 방식이 적용 안되는 문제 발생.


✅ 문제 원인

📑 단위 테스트시에 실제 Quizservice에 존재하는 quizRepository.save(roomId)과 clueRepository.findAllByRoomId(room.getId())때문.

@Mock으로 만들려면 when().thenReturn()같은 메서드를 반드시 명시해줘야하는데 테스트시 정확한 RoomId를 알아내는 것이 불가능.

---> when().thenReturn() 메서드 작동 안함.

📑 통합 테스트에서 DI 방법으로 생성자 주입 방식(@RequiredArgsConstructor)안되는 이유는 difference in autowire handling between Spring and Spring integration with JUnit때문.

즉, JUNIT5가 DI를 스스로 지원하기 때문에 생성자나 lombok 방식으로 DI가 되질 않음.


✅ 해결방안

📑 단위테스트에서 따라서 @Spy를 통해서 Stubbing 하지 않은 실제 객체들을 @InjectMocks를 통해서 quizService에 주입시키는 방식으로 해결.

--->단위 테스트의 목적이 퀴즈 생성 시간 측정에 있었기 때문에 Mock이 아닌 실제 객체들로 주입하는 것이 오히려 더 낫다 판단(실제로 걸리는 시간 측정 가능).

📑 통합테스트에서 DI 방법으로 생성자 주입 방식말고 @Autowired 방식 선택.


Quiz 랜덤 문제

✅ 요구사항

📑 게임성을 위해 동일한 Quiz라도 Quiz의 답이 랜덤으로 정해지게 하자!

📑 그렇지만 해당 방 안에서는 같은 문제가 보여야 함


✅ 문제상황

📑 방마다 다른 값으로 Quiz가 구성되도록 퀴즈 생성 알고리즘에 Random을 포함하면서, Quiz 조회 API가 요청될 때마다 Quiz를 새로 생성 → Quiz 클릭 시 매번 Quiz가 달라지는 문제 발생

❕ 방마다 같은 문제가 보이려면 DB에 저장 필요!!


✅ 해결방안

📑 방 안에서만 동일한 문제를 보여주기 위해 방 마다 생성된 Quiz를 DB에 저장

📑 Quiz를 생성하는 API가 호출되는 시점은 방 개설이 아닌 게임 시작 이후가 적절하다고 판단

: 방 개설 때 Quiz 생성하면 방만 만들고 게임을 시작하지 않았을 경우 추가 처리 필요

📑 방의 유저 중 한 명이 Quiz 오브젝트를 클릭했을 때 DB에 해당 Quiz가 없으면 생성, 있으면 조회하도록 구현

: 이미 게임 시작 때 API가 여러 개 호출되고 있어서 요청을 분산시키기 위함



📋 Review

프로젝트를 마무리하면서 아쉬움이 남았던 부분들을 기록한다. 회고를 통해 다음 프로젝트에서는 더 잘하자!

redis
✏️ DB에 저장되는 데이터 중 게임 종료 후 삭제되는 데이터는 인메모리 DB를 사용해도 좋았을 것 같다.
또, redis 브렌치를 통해 일부 데이터로 테스트해본 결과 조회 성능 개선 가능성을 확인할 수 있었다.
프로젝트 초기에 우리 데이터의 특성을 고려하여 redis를 도입했다면 더 성능 개선을 할 수 있었을 것 같다는 아쉬움이 남는다.
로그 관리
✏️ 프로젝트 마무리 단계에서 로그 관리를 위해 logback을 설정하였다.
이전에도 console에 뜨는 로그는 확인했지만 파일로 저장하면 나중에 문제가 발생했을 때 확인할 수 있고,
코드를 짜면서 중간 중간에 필요한 로그를 남겨 확인하면 훨씬 더 좋았을 것 같다.
다음에 프로젝트를 한다면 일단 설정을 해놓고 시작할 것 같다!
테스트코드
✏️ 테스트코드 역시 프로젝트 마무리 단계에 도입했다.
도입 이후 리팩토링 하면서 바로바로 테스트코드로 코드가 정상적으로 작동하는지 확인할 수 있어서 좋았다.
프로젝트 초기에 테스트코드 전략을 구상해서 단위테스트 혹은 통합테스트를 개발 일정에 따라 도입하면 좋을 것 같다.

📢 User Test

오류제보 사례

⚠️ 게임 플레이 중 맞춘 문제 수나 남은 찬스 수가 정상적으로 변경되지 않는 문제 제보

NodeJS의 undefined 에러로 인해 서버가 재시작되면서 각 브라우저의 roomID 초기화

socket.io의 방 구분 기능이 정상적으로 작동하지 않음

📍 NodeJS의 에러를 해결하여 서버가 재시작되지 않도록 조치

개선사항 사례

✏️ "마이크를 차단했을 때 쉽게 해결할 수 있는 방법이 적혀 있으면 좋겠습니다."

브라우저의 마이크 사용 권한을 제한하면 게임 플레이 불가

브라우저에 따라 권한 허용 방법을 설명하는 창을 띄워 다시 서비스 이용할 수 있도록 안내


🔧 Fight

ban wonjae

1️⃣ Trouble Shooting에서 유저 disconnect 해결방안 1과 관련된 삽질

📑 처음에 node.js는 보이스 채팅만 다루고 나머지 역할은 spring에서 담당하기로 했었음  
-> 스프링에서 한 방의 인원들이 전부 로딩이 다 되었는지 체크.

📑 클라이언트들이 각자 게임 로딩이 다 완료되면 spring에 request를 보냄  
->spring에서는 request가 올때마다 count를 세서 count가 현재 한 방의 인원들의 숫자와 같아지면 게임을 시작.

📑 여기서 로딩중에 누군가가 나가면 무한대기현상이 발생할 수 있다고 생각.  
왜냐하면 나간 사람은 영원히 spring에 로딩이 다 되었다는 request를 보내지 않기 때문.

📑 구체적으로 당시 노드 socket에서 유저 disconnect가 발생  
-> 스프링에서 1. 방장이 나간 경우: 새로운 방장 userId response.	2. 일반인이 나간 경우: null response.
-> 그리고 나간 유저의 정보 DB에서 삭제하는 로직 실행.

📑 spring에서 게임 로딩 체크  
-> 1. false response 2. 마지막 인원한테는 true response.

📑 문제는 위의 로직들이 동시에 발생하는 경우  
-> 게임 로딩중에 방장이 disconnect가 된다면 최악의 경우 새로운 방장 userId,   
게임 무한 대기 현상을 방지하기 위해 마지막 인원까지 로딩이 완료되었다는 true값도 보내줘야함.

📑 따라서 disconnect시 responsedto와 게임 로딩체크 responsedto는 같아야함.  
즉, 누군가가 나간다면 userID만 넘겨주는 것이 아니라 userId와 true, false값을 같이 보내줌,  
반대로 게임 로딩중에도 true, false뿐만 아니라 userId까지 보내줌.

📑 이런 방식으로 프론트쪽에서 true 또는 false값도 받는게 가능  
-> 무한대기현상을 해결할 수 있다 생각함.

📑 즉, 상황에 따라 1. 게임로딩 X, 누군가가 나감 -> 1. 방장이 나간경우: {"userId" : "새로운 ID", "check": null}  
2. 일반인이 나간경우: {"userId": null, "check":   null}

📑 2. 게임로딩 O, 누군가가 나감 -> 1. 방장이 나갔고 나머지 인원 전부 로딩 완료:{"userId": "새로운ID", "check": "true"},  
2. 방장이 나갔지만 나머지 인원이 전부 로딩 X:  {"userId": "새로운ID", "check":null},  
3. 일반인이 나갔는데 나머지 전부 로딩: {"userId" : null, "check": "true"},  
4. 일반인이 나갔는데 나머지 전부 로딩X: {"userId" :   null, "check": "null"}

📑 3. 일반적인 게임 로딩  
--> 1. {"userId": null, "check": null} ... 2. 제일 마지막 인원 로딩: {"userId": null, "check":"true"}로 응답하는 것으로 해결하고자 함. 

📑 하지만 disconnect가 발생 -> 방 전체 인원들이 Spring으로 request를 보냄  
-> disconnect 유저를 삭제하고 새로운 방장을 만드는 로직이 여러번 발생하는 문제 존재.

📑 결론적으로 node에서 socket disconnect시에 DB에서 유저 한번만 삭제하고 방장 변경까지 처리.
    게임 로딩도 node에서 진행.