이 프로젝트는 중단되었으며 호스팅을 내렸습니다.
Angular를 공부하기위해 진행한 프로젝트
그냥 오목두는 사이트
닉네임을 등록하면 바로 게임 큐를 돌릴 수 있다.
큐가 잡히면 상대방과 오목을 두면 된다. 간단한 채팅 기능이 있다.
유저간의 통신은 socket.io 를 사용하여 구현했고 서버에서 클라이언트를 기억하기 위한 방법으로는 간단하게 JWT를 사용해봤다.
Azure의 vm(가상머신) 제품을 사용하여 서버를 돌리고있다.
그림상엔 컴포넌트가 5개지만 TopNav와 Footer는 자리만 차지하고 사실상 아무기능도 안하기 때문에 주로 일하는 컴포넌트는 Board, Chat, Modal 3개이다.
앱에서 사용되는 서비스에 대해서만 설명하면 앱을 전체적으로 파악하는데 충분하다고 생각하기 때문에 서비스만 알아본다
앱에서 사용되는 서비스중 내가 직접 작성한건 소켓 통신을 다루는 socket.service.ts 하나밖에 없다.
이를위해 socket.io를 Angular에 맞게 포팅한 라이브러리인 ngx-socket-io 라는걸 사용했다.
각각의 이벤트를 설명하자면 다음과 같다.
register & token
맨 처음 사이트에 접속하게 되면 닉네임을 묻는 모달창이 뜬다.
닉네임이 입력되면 register 이벤트를 발송하는데 이에 대한 답장으로 서버는 token 이벤트에 JWT 토큰을 담아서 보내준다.
클라이언트는 JWT 토큰을 받아서 로컬스토리지에 저장한다.
이후 사용자의 모든 요청에는 이 토큰을 같이 보내게 되며, 서버는 토큰을 검증한뒤 올바른 토큰일 경우에만 답신을 한다. 검증에 실패할경우 그냥 무시한다.
딱히 토큰의 사용기한이라던가 refresh 관련 구현은 없다. 그냥 받아서 영원히(사용자가 직접 지우지 않는한) 저장한다.
queueStart & queueStop
닉네임을 등록한다음엔 큐를 돌리는 모달창이 뜬다.
게임시작! 버튼을 누르면 queueStart를 발송하며, 서버는 플레이어를 대기자큐에 추가한다.
서버의 대기자큐는 힙으로 구현했는데 자세한 내용은 밑에서 더 설명하겠다.
게임시작! 버튼을 또 누르면 queueStop을 발송하며, 서버는 플레이어를 '닷지한사람' 큐에 추가한다.
'닷지한사람' 큐 역시 힙으로 구현되어있다.
disconnect
사용자가 브라우져를 그냥 꺼버리거나 하면 disconnect 이벤트가 발생한다.
이 경우 케이스가 두갈래로 나뉘는데
- 게임은 아직 시작하지 않았지만 큐를 돌리던 중이었다면 '닷지한사람' 큐에 해당 플레이어를 추가한다.
- 게임이 진행되던 도중이면 상대방에게 '당신이 이겼다' 라는 이벤트를 보낸다. 이걸 받은 상대방 클라이언트는 "상대방이 탈주했습니다" 라는 메세지와 함께 승리했다는 모달을 띄운다.
그리고 그림엔 안나와 있지만 큐를 돌리던중도 아니고 게임을 하던중도 아니라면 그냥 아무동작도 하지 않는다.
put
오목판의 돌을 놓을때 발생하는 이벤트이다.
A와 B가 게임을 하는데, A 차례가 되서 A가 오목판에 수를 두면 A의 클라이언트는 해당 자리에 자신의 돌(파란색돌)을 놓은다음 put 이벤트를 발송한다.
서버는 이를 수신한뒤 그대로 상대편인 B에게 put 을 보내준다.
B의 클라이언트가 put을 수신받으면 해당위치에 상대편의 돌(빨간색돌)을 놓는다.
passTurn & getTurn
put 과 같다. 본인 차례가 되서 오목판에 수를 두면 put 이벤트 말고도 passTurn을 같이 발송한다.
getTurn을 수신받은 상대방은 이제 본인의 수를 둘수 있다.
win & gameOver
한 수를 둘때마다 본인이 게임이 이겼는지 졌는지 체크하게 되는데 이겼을경우 win을 발송한다.
이를 수신받은 서버는 양 클라이언트에 gameOver를 발송하는데, 여기에는 승리인지 패배인지 알려주는 데이터도 같이 보내진다.
엄밀히 말하면, 어떤 못된 사용자가 직접 클라이언트 소스코드를 조작한뒤 win 이벤트를 보내서 그 자리에서 그냥 이겨버리는 치팅을 시도 할 가능성이 없진 않으나 어짜피 많이 이긴다고 어떤 보상이 있는것도 아니고 그런 실력자가 이런 조그만 토이프로젝트까지 와서 그런일을 벌이진 않을거라고는 생각되지 때문에 관련 대응은 생략한다.
경기의 승패를 서버가 아닌 클라이언트측에서 처리한다는 점도 같은 맥락에서 그냥 넘어가기로 했다.
chat
말 그대로 채팅 이벤트이다.
클라이언트는 나의 메세지는 ui에 직접 그리고 상대방의 메세지만 chat이벤트로 수신받아 브라우져에 그린다.
matched
그림에는 안나왔지만 matched 이벤트가 있다.
게임이 잡히고 상대방이 정해지면 서버에서 클라이언트로 matched가 발송되며, matched를 수신받은 클라이언트는 큐를 돌리는 중인지 아닌지를 표현하는 'isQueueing' 프로퍼티를 true에서 false로 바꾼다.
게임시작! 버튼을 눌러서 큐를 돌리기 시작한 플레이어들을 서로 매치해줄 장치가 필요하다.
예를들어 어떤 플레이어가 게임시작! 버튼을 눌러서 큐를 돌리면 이 사람을 대기자큐(우선순위큐가 아닌 그냥 큐)에 집어넣는다. 그뒤 다른 플레이어가 게임시작! 버튼을 눌러서 큐를 돌리면 대기자큐에서 하나 뽑아 서로 매치시켜주는 방법이 있겠다.
근데 만약 큐를 돌리던 플레이어가 다시 게임시작! 버튼을 눌러서 닷지를 한다면, 여러 플레이어들이 섞여있는 대기자큐에서 이 플레이어를 어떻게 찾아 제거해야될까? 문제는 여기서 발생한다.
사실, 오목은 1:1 게임이기 때문에 큐와 같은 자료구조는 이론상 필요가 없다. 그냥 '대기자' 변수 하나만 둔다음
- 사용자 A가 게임시작! 버튼을 누르면 A의 소켓 아이디를 '대기자' 변수에 할당한다.
- 이후 사용자 B가 게임시작! 버튼을 누르면 A와 B를 매치시키고 '대기자' 변수에는 null 을 할당해서 비운다.
이 두가지 과정을 반복하기만 하면 충분하다.
하지만 실제 서비스에서는 타이밍이 어떻게 꼬일지 모르기 때문에 좀더 넉넉하게 처리할수 있게끔 우선순위 큐를 사용했다.
과정은 다음과 같다.
1. 일단 소켓이 서버와 처음 연결되면 서버는 Date.now() 로 해당 소켓에 타임스탬프를 할당한다.
그림상에 보이는 숫자가 바로 이 타임스탬프이며 우선순위큐(heap)은 이 타임스탬프를 기준으로 정렬한다.
2. 어떤 사용자가 게임시작! 을 누르면 대기자큐에서 heapq.pop으로 하나 빼서 매치시킨다.
3. 큐가 비었을 경우엔 큐에 [socket.timestamp, socket.id] 를 추가하는 방식이다.
문제는 큐를 돌리는 중에 닷지를한 경우이다.
사용자가 의도적으로 닷지하지 않았더라도 정전으로 브라우져가 그냥 꺼지거나 하는 돌발상황은 충분히 있을 수 있다.
이를 처리하기위해 '닷지큐' 가 필요하다.
위의 '소켓 이벤트' 항목에서도 설명했듯이 사용자가 큐잡기를 중단하거나 연결이 끊기면 닷지큐에 해당 플레이어가 추가된다.
이후 어떤 사용자가 게임시작! 버튼을 눌러서 큐를 잡기 시작하면 서버는 대기자 큐에서 플레이어를 하나를 꺼내는데, 이 플레이어가 닷지큐 루트의 플레이어와 같다면 얘는 상쇄되고 매치시키지 않는다. 이미 닷지한 플레이어기 때문이다.
위에서 말했듯 힙의 정렬은 타임스탬프를 기준으로 하기 때문에
대기자 큐에서 뽑은 플레이어가 이미 닷지한 플레이어인데, 얘가 닷지큐 루트의 플레이어와 다른사람인 경우는 없다
즉 닷지한 플레이어는 무조건 닷지큐에 의해 상쇄된다.
그림상에는 설명을 위해 내용물이 주렁주렁 달려있는걸로 묘사했지만 실제 서버에서는 '큐에 내용물이 1개만 있거나' 혹은 '큐가 비었거나' 두가지 상태밖에 없다. 위에서 말했듯이, 애초에 이론상으론 변수 하나만으로 충분하기 때문이다.
서버가 사용자가 누구인지 기억하는 방법으로는 세션이나 쿠키가 아닌 JWT를 사용한다.
사실 서버가 기억해야하는 데이터라고는 '닉네임' 하나밖에 없긴 하지만 JWT 공부도 할겸 그냥 사용해봤다.
닉네임은 그자체가 사용자의 게임 아이디이자 비밀번호가 된다.
이 말은 즉, 닉네임만 같게 바꾸면 사칭이 가능하다는 말이지만 그런짓을 해도 얻는 보상이 없으므로 고려하지 않는다.
원래 JWT의 의도대로라면 서버는 사용자의 아이디와 비밀번호를 가지고 있으며, 사용자가 인증요청을 해오면 이를 인증한뒤 JWT 토큰에 리소스의 접근권한 등을 담아서 보내줘야 하지만..
이 앱에는 회원가입 UI가 없으며 처음 사이트에 방문하면 닉네임을 물어보는게 전부다.
왜냐면 여기는 리소스의 접근권한이 어쩌고하는 개념이 필요한 큰 웹사이트도 아니고 애초에 JWT를 그냥 한번 사용해보는것에 의의를 두기때문에 그런 복잡한 구현은 생략한다.
클라이언트 프레임워크 Angular
데이터 흐름을 관리하는 라이브러리 RxJS
UI 컴포넌트 라이브러리 Prime Ng
소켓 라이브러리 ngx-socket-io
서버 프레임워크 Express
소켓 라이브러리 socket.io
JWT 라이브러리 jsonwebtoken
클라우드 서버 플랫폼 Azure
프로젝트를 시작하기 전엔 angular는 러닝커브가 가파르기로 악명이 높다고 들어서 걱정을 많이했었다.
공식 문서를 봐도 다 영어로 되있어서 이걸 언제 다 보나.. 하고 매우 귀찮았지만 다행히도 매우 설명이 잘되있는 강의 블로그 를 발견해서 무난하게 끝낼수 있었다
서비스와 Ioc가 어쩌고 rxjs와 옵저버블이 어쩌고하는 내용들은 배우기전에는 막막했지만 막상 익숙해지고 나니까 생각보다 할만했다. 위 블로그를 정독하고 공식문서를 다시 읽어보니 공식문서 또한 설명이 잘되있는 편이라 그것도 나쁘지 않았다.
소켓통신과 관련된 코드를 작성하던중, 점점 복잡해지는 모양새를 보니 Vue 의 메인 컨셉인 '선언형 프로그래밍'의 필요성이 왜 대두됬는지 어렴풋하게 느낄수 있었다.
혼자만 쓰는 개인 프로젝트일 경우엔 상관 없겠지만 여러사람이 쓰는 API를 개발할 일이 생긴다면 선언형 프로그래밍에 대해 더 자세히 알아봐야 할것 같다.
다른 프레임워크에 비해 앵귤러에서 맘에 들었던것은 라우팅 관련 지원을 해준다는 점이다.
ActiveRoute 라는 컴포넌트를 불러오면 여기에 현재url, path, path Params, query Params 등등 다 들어있어서 상당히 편리했다.
리액트의 react-router는 제대로된 공식문서도 없고 사용법도 구글링하거나 직접 console.log 찍어가며 힘들게 익혀야 했는데 앵귤러는 이게 되서 편했다. Vue 도 공식문서를 잠깐 본 바로는 앵귤러처럼 이렇게 구체적인 지원을 해주는거 같진 않았다.
또한 타입스크립트역시 상당히 맘에 들었다. 타입스크립트의경우 나에게는 그저 Angular 배우는걸 더 힘들게 하는 러닝커브중 하나일 뿐이었는데 쓰다보니 생각보다 어렵지 않았다.
오히려 (vscode기준) 거의 무슨 코드를 쓰는순간 런타임에러와 버그들을 실시간으로 알려주는 수준이라 상당히 편리했다.
내가 생각하는 Angular의 단점은 역시나 급격한 러닝커브, 그리고 개인 또는 소규모의 간단한 프로젝트를 진행하기엔 프레임워크가 너무 방대하다는 것이다.
컴포넌트는 사용자에게 보여지는 뷰기능만 담당하고 구체적인 로직은 Service라는 컨셉으로 분리한다는 점은 상당히 매력적이지만 어쨌든 그 점 자체가 프레임워크를 크고 복잡하게 만드는지라 장점이자 단점이 될수도 있다고 생각한다.
여튼 이렇게 react, vue, angualr 하나씩 사용해보기 계획중 하나인 angular 프로젝트가 끝이 났다.