온라인 3D 방탈출 게임 서비스 😴
‘zzz’ 는 꿈을 꾸는 상태를 표현한 단어이자, 게임 프로젝트의 이름입니다
‘꿈’ 이라는 매체를 이용하여 상상하고 문제를 풀면서 해결하여 방탈출을 하는 것이 본 게임을 이어나갈 수 있는 방법입니다
당신은 꿈 속에서 얼마만큼의 역량을 발휘할 수 있는지 궁금하지 않으신가요?
- 2022.2.25 ~ 2022.4.9
- 1차 배포 : 2022.3.30
🎉 5일 동안 550여명의 유저들이 550여개의 방을 만들고, 375팀이 게임을 시작하였으며, 9팀이 탈출에 성공하였습니다!!
📂 백엔드의 고민과 공부 기록은 → https://github.com/HangHae99Zzz/RoomEscape_BE/wiki
홈
방탈출 게임을 위한 방을 만들고, 랭킹조회 및 게임 설명을 확인할 수 있습니다.대기
현재 대기중인 인원을 체크하고 게임을 시작할 수 있습니다. 링크로 친구를 초대하고, 보이스 채팅도 가능해요!게임
팀원들과 보이스채팅을 나누며 방에 배치된 3D 물체를 클릭해 주어진 문제를 풀고 탈출할 수 있습니다. 제한시간 안에 방을 탈출해보세요.
김가은 | 최규원 | 반원재 |
---|---|---|
@Kim gaeun | @Choi kyuwon | @Ban wonjae |
- 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
Coding Convention
· Commit Convention
Coding 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
✅ 유다시티 커밋 메시지 스타일 가이드 : 참고
✅ 본문에는 변경한 class 이름과 어떻게, 무엇을, 왜 변경했는지 자세히 적기
커밋 타입: 제목
//띄어쓰기
본문
//띄어쓰기
(꼬리말 타입: #이슈 번호)
🚨 커밋 타입
Docs: 문서 작업
Feat: 새로운 기능 추가
Fix: 버그를 고친 경우
Refactor: 리팩토링
Comment: 주석 추가 및 변경
Rename: 파일 혹은 폴더명 수정, 경로 변경
Remove: 파일 혹은 기능 삭제
Test: 테스트 관련 작업
Resolve conflicts: 충돌 발생 commit에서 사용(본문, 꼬리말 생략)
🚨 꼬리말 타입
Fixes: 이슈 수정중(아직 해결되지 않은 경우)
Resolves: 이슈 해결했을 때
Ref: 참고할 이슈가 있을 때
Error 관리
✅ 모든 에러는 Error Code로 관리
- Error Code마다 httpStatus / errorCode / errorMessage 작성
- ErrorCode는 httpStatus마다 일련번호를 붙인다("httpStatus_number") ex) "400_3", "404_4"
브렌치 관리
✅ 개인별 브렌치(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를 사용해서 관리
API 명세서
🚨 API 설계규칙
Rest API URI 설계규칙을 따른다.
1. 후행 /는 URI에 포함하지 않는다.
2. 계층관계를 나타낼 때 슬래시 구분자를 사용한다. ex) /rooms/{roomId}/quizzes/{quizType}
3. 긴 path를 표현하는 경우에는 가독성을 높이기 위해 하이픈(-)을 사용한다.
4. 언더바(_)는 URI에 사용하지 않는다.
5. URI는 모두 소문자로 작성한다.
6. 파일확장자는 URI에 포함하지 않는다.
7. 모든 resource는 복수형을 사용한다.
메인페이지 서버 부하 문제
메인페이지에서 변경된 방 정보를 업데이트하기 위해 1초 간격으로 Room 리스트 조회하기 api를 요청(Polling)
메인페이지에 접속자가 집중되면 서버 부하 증가 → 배포 이후 메인페이지 40명 정도 접속하면서 CPU 90%로 급증
📍 서버를 t3.micro으로 변경(CPU 1 → 2)하여 우선 조치(메모리는 Swap으로 늘려놓은 3G로 충분하다고 판단)
메인페이지 접속자 수에 따른 서버 부하를 확인하기 위해 테스트 진행
Client의 메인페이지 접속자 수를 10 단위로 증가시키면서 CPU 사용량을 실시간 관찰
① CPU 사용량이 급증 ② 전체 200 중 180%까지 올라가는 지점을 한계로 봄
- api 요청 간격 1초(현재 상태) : 70명
- api 요청 간격 1.5초 : 80명
- api 요청 간격 2초 : 100명
현재 서비스 수준에서 70명 이상이 메인페이지에 접속할 가능성은 낮고,
업데이트 간격을 2초로 늘리면 오히려 유저 경험이 안좋아 질거라고 판단
서비스가 성장한다면, Polling이 아니라 다른 방법으로 문제 해결을 시도하는 것이 더 나을 것!
WebRTC 서버 구축 문제
📑 오디오만 사용하고, 4명까지만 연결하기 때문에 signalling server로도 client 부담이 크지 않을 거라고 생각했고, MCU, SFU는 프로젝트 기한 내에 구현하기 어려울 것으로 판단했다.
📑 Springboot를 사용하면 하나의 서버만 관리하면 되고, 팀원들 모두가 익숙한 프레임워크를 사용할 수 있다.
그러나 참고자료가 많지 않다.
📑 NodeJS를 사용하면 Socket.io 라이브러리를 사용해서 비교적 쉽게 구현이 가능하나,
서버를 2개 관리해야 되기 때문에 유지관리에 비용이 더 소모되고, 익숙하지 않은 언어와 프레임워크를 사용해야 한다.
📑 Springboot로 signalling server를 구축하면 시간이 더 오래 걸릴 것으로 예상했고,
제한된 시간 안에 서비스의 완성도를 높이기 위해서는 NodeJS의 Socket.io를 사용하는 것이 더 적합하다고 판단!
유저 disconnect 처리 문제
📑 유저가 브라우저를 종료하면 socket.io의 disconnect 이벤트가 발생
📑 Client는 방장이 나가면 새로운 방장을 알아야한다(방장만 게임 시작 가능!)
📑 DB에서는 disconnect된 유저 정보를 삭제하고, 방장이 변경된 경우 업데이트 필요
📑 nodeJS에서 disconnect시 event를 통해 disconnect된 유저의 socket.id를 Client로 보냄
📑 Client는 Spring으로 HTTP 통신을 통해 socket.id를 넘겨주고, 방장이 바뀐 경우 return 값을 받음
📑 유저가 1명 남았는데 disconnect가 되면 Client가 없으므로 nodeJS에서 DB로 쿼리를 보냄
📑 disconnect시 DB에 필요한 업데이트/삭제 쿼리를 보내고, 방장이 변경되면 event로 해당 방 Client에게 알려줌
게임 플레이 중 동시성 제어 문제
📑 게임 중 맞춘 문제 수(스코어), 찬스가 변경될 경우 해당 방 Client 모두에게 해당 정보를 업데이트해주어야 함
📑 HTTP 통신에서는 Client 요청 없이 Server가 Response 할 수 없으므로 socket 통신을 이용하면 해결할 수 있음!
📑 퀴즈를 동시에 보고 있을 때도 한 명이 문제를 풀면 이벤트를 활용해 이미 푼 문제로 변경
CI/CD 적용
📑 프론트와 백엔드를 합친 이후 예기치 못했던 많은 에러가 발생함
📑 잦은 에러수정으로 인한 수동 배포에 드는 시간 소모가 점점 많아져 시간 절약을 위하여 배포 자동화 필요
📑 Travis를 더 많이 쓰고 블로그 자료도 많았지만 따로 서버 설치를 해야함
📑 Github Actions는 별도의 서버 설치없이 Github을 통해 바로 사용이 가능함
📑 기간이 한정되어 있어서 배포 자동화 구축에 많은 시간을 쏟을 수가 없다
📑 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가 달라지는 문제 발생
📑 방 안에서만 동일한 문제를 보여주기 위해 방 마다 생성된 Quiz를 DB에 저장
📑 Quiz를 생성하는 API가 호출되는 시점은 방 개설이 아닌 게임 시작 이후가 적절하다고 판단
: 방 개설 때 Quiz 생성하면 방만 만들고 게임을 시작하지 않았을 경우 추가 처리 필요
📑 방의 유저 중 한 명이 Quiz 오브젝트를 클릭했을 때 DB에 해당 Quiz가 없으면 생성, 있으면 조회하도록 구현
: 이미 게임 시작 때 API가 여러 개 호출되고 있어서 요청을 분산시키기 위함
프로젝트를 마무리하면서 아쉬움이 남았던 부분들을 기록한다. 회고를 통해 다음 프로젝트에서는 더 잘하자!
redis
✏️ DB에 저장되는 데이터 중 게임 종료 후 삭제되는 데이터는 인메모리 DB를 사용해도 좋았을 것 같다.
또, redis 브렌치를 통해 일부 데이터로 테스트해본 결과 조회 성능 개선 가능성을 확인할 수 있었다.
프로젝트 초기에 우리 데이터의 특성을 고려하여 redis를 도입했다면 더 성능 개선을 할 수 있었을 것 같다는 아쉬움이 남는다.
로그 관리
✏️ 프로젝트 마무리 단계에서 로그 관리를 위해 logback을 설정하였다.
이전에도 console에 뜨는 로그는 확인했지만 파일로 저장하면 나중에 문제가 발생했을 때 확인할 수 있고,
코드를 짜면서 중간 중간에 필요한 로그를 남겨 확인하면 훨씬 더 좋았을 것 같다.
다음에 프로젝트를 한다면 일단 설정을 해놓고 시작할 것 같다!
테스트코드
✏️ 테스트코드 역시 프로젝트 마무리 단계에 도입했다.
도입 이후 리팩토링 하면서 바로바로 테스트코드로 코드가 정상적으로 작동하는지 확인할 수 있어서 좋았다.
프로젝트 초기에 테스트코드 전략을 구상해서 단위테스트 혹은 통합테스트를 개발 일정에 따라 도입하면 좋을 것 같다.
오류제보 사례
NodeJS의 undefined 에러로 인해 서버가 재시작되면서 각 브라우저의 roomID 초기화
socket.io의 방 구분 기능이 정상적으로 작동하지 않음
📍 NodeJS의 에러를 해결하여 서버가 재시작되지 않도록 조치
개선사항 사례
브라우저의 마이크 사용 권한을 제한하면 게임 플레이 불가
브라우저에 따라 권한 허용 방법을 설명하는 창을 띄워 다시 서비스 이용할 수 있도록 안내
ban wonjae
📑 처음에 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에서 진행.