Intro
SSAFY 2학기 공통프로젝트인 CodeArena 서비스 개발에 대한 회고를 하려합니다.
회고를 하기엔 약간 늦은 감이 있지만 기억을 천천히 되짚어보며 전통적인 회고 방법론인 KPT를 기반으로 좋았던 점, 문제였거나 아쉬웠던 점, 개선 시도 등에 대해 정리하려합니다.
지금 생각해보니 SSAFY 내에서 첫 프로젝트였던만큼 배운 점이 정말 많지만 그만큼 기획부터 말썽이 잦았던 프로젝트였던 것 같아요.
이제 시작하겠습니다.
팀 모집
SSAFY 2학기는 자율적으로 팀을 구하는데요. 비전공을 최소한 2명 이상 포함 해야하기에 처음에는 MM이라는 소통 툴로 여러 명에게 러브콜을 날렸던 것 같네요.
전공반에서 3명과 함께 하기로 했고, 세 명 전부 백엔드 포지션 희망이였기 때문에 비전공에서 프론트 분들을 구하기엔 쉽지 않았습니다… 잘한다는 소식이 들려오는 분들은 이미 팀이 있으셔서.. ㅠ
하지만!! 다행이도 반에서 수상경력이 있으신 분과 연락이 닿았고 그 분께서 같이 프론트를 맡으실 2분을 데리고 온다는 말씀에 감격!!…
그렇게 저희는 프론트 3(비전공), 백엔드 3(전공) 이라는 완벽한 팀이 될 수 있었습니다.
기획
6명이 모여 팀 내의 그라운드 룰을 설정하고, 아이디어 해커톤을 함께하며 서로 친해지며 팀의 기반을 다져나가기 시작했습니다.
처음에는 되게 순조롭고 재밌는 하루하루를 보냈지만 문제는 브레인스토밍에서 시작됐습니다.
WebRTC를 녹여내는게 필수였고, 이렇다 할 아이디어가 나오지않아 저희는 서로 아이디어를 몇 개씩 생각해오기로 했습니다. 다음 날이 되서 서로의 아이디어를 주고받는 과정에서 살아남은 아이디어는 딱 하나였습니다.
바로 “합동 연주 매칭 플랫폼”이였고 저희는 당당하게 컨설턴트님을 찾아가 “이 아이디어 개쩔죠?!!”라는 뉘앙스로 자신만만하게 상담을 진행했지만 끝난 후에는 한 줌에 먼지가 되버린 팀원들을 발견할 수 있었죠..
그 이유는 합동 연주라면 음질과 딜레이 측면이 되게 중요한데, 그 부분이 스트리밍 특성 상 문제가 생길 여지가 너무 많아 노이즈 제거라던지 딜레이 부분의 개선을 위한 특별한 알고리즘을 생각하고 추가해야 할 것 같다.” 라는 피드백에 저희는 멘붕이 왔고, 그 후 구글 선배님께 여러 검색을 통해 “아… 되긴 해?” 라는 결론을 지었습니다. 기존의 “싱크룸”이라는 서비스가 있는데 해당 서비스에도 이러한 고질적인 문제 때문에 전용 기기를 사용한다는 것을 보고 안될거라 생각했죠…
그 후로 연속되는 컨설턴트님의 아쉬움섞인 피드백에 저희는 다시 초심으로 돌아가서 생각해보기로 했습니다. 그 때 제가 “창의적인 아이디어도 물론 좋지만, 아무래도 우리가 개발하고 싶은 혹은 좋아하는 주제로 개발하는게 제일 재밌지 않을까” 라는 의견을 냈고, 그에 따른 저의 의견은 “우리 모두 알고리즘 푸는 걸 좋아하니까 알고리즘에 관련된 프로젝트를 하자!” 였습니다.
이 때 한 팀원이 백준 사이트의 아레나라는 서비스가 있는데 이처럼 다른 유저와의 경쟁을 할 수 있었으면 좋을 것 같다라는 의견을 내주었고, 그 아이디어는 곧 저희 CodeArena의 탄생으로 이어졌습니다. (참고로 CodeArena라는 팀명은 투표로 뽑힌 저의 아이디어입니다.)
서비스 개요
“실시간 알고리즘 경쟁 사이트 CodeArena”는 다음과 같은 서비스로 기획되었습니다.
기존의 백준 온라인 저지 사이트의 PS 서비스에서 추가된 다양한 기능을 누릴 수 있습니다.
- 알고리즘 문제를 유저가 업로드 할 수 있습니다.
- 3가지 언어(C++, Java, Python)로 문제를 풀고 해당 문제의 통계를 확인할 수 있습니다.
- 틀린 테스트 케이스를 확인할 수 있습니다.
- 각 언어의 평균 시간복잡도를 확인할 수 있습니다.
- 공지사항 및 질문 게시판 기능을 이용할 수 있습니다.
저희는 우선 백준과 같은 온라인 저지 사이트의 일반적인 기능은 전부 구현하기로 했습니다.
그 뒤 추가로 “경쟁”이라는 키워드로 다양한 서비스를 기획하였습니다.
- 두 가지 모드의 랜덤 매칭을 통해 경쟁을 할 수 있습니다.
- Speed 모드를 통해 풀이 속도로 경쟁할 수 있습니다.
- Efficiency 모드를 통해 시간 복잡도로 경쟁할 수 있습니다.
- 현재 경쟁중인 유저들을 관전할 수 있습니다.
- 보유중인 포인트를 통해 원하는 플레이어에게 배팅할 수 있습니다.
- 다른 관전자들과 채팅을 할 수 있고, 플레이어 화면을 관전할 수 있습니다.
개발 KPT 회고
Keep
1. 매 주 수요일 사용할 기술에 대한 공부 및 스터디 진행
: 이 부분은 제가 적극적으로 했으면 좋겠다고 어필을 했고, 그 결과 또한 되게 좋았던 경험입니다.
저희는 1학기 알고리즘 및 기본적인 프레임워크(Vue, Spring)에 대한 공부를 했지만, 실제로 프로젝트를 하며 활용될 Git, Gerrit, JIRA와 같은 협업 툴과 수많은 라이브러리, 아키텍처 등에 대해서는 미숙하였기에 이러한 학습 일정 및 스터디를 통한 기술적인 학습 내용 공유는 정말 많은 도움이 되었다고 장담할 수 있습니다.
2. 채점 서버 설계에 백엔드가 모여 고민만 하지않고 여러 테스트를 수행한 점
: 채점 서버를 설계하면서 수많은 고려 사항이 있었습니다. 우선 Front로부터 받아온 서로 다른 코드를 어떻게 채점할 것이냐 부터 채점은 사용자 기준 비동기로 실행되어야 하며, 채점 결과에 대한 기록, 채점 기준(시 복잡도)에 대한 채점 로직 등 수많은 고민이 필요했습니다. 하지만 기술 지식이 부족한 상태로 고민만 한다해서 완벽하게 설계할 수 없다 생각 후 한 가지씩 모든 케이스들을 테스트 해보며 찾아나가는 과정은 전체 일정을 빠르게 앞당길 수 있었습니다.
3. 팀원들의 적극적인 소통을 취하는 자세
: 문제 발생, 어려운 점, 일정이 지연되는 부분 등에 대한 이야기가 너무 원활하게 이루어져 소통으로 인한 불편함이 없었습니다. 특히 매 주 9to6을 제외하고도 카페에서의 오프라인 모임이 잦았던게 핵심적인 이유지 않을까 하네요. 제 프로젝트 경험 속 인상깊은 팀원들과의 만남으로 남은 이유가 소통이였다고 단언할 수 있습니다.
Problem
1. 담당 역할 배분이 미숙
: 인프라 CICD에 대한 이론이 잡혀있지않은 상태로 막연히 EC2, Docker, Jenkins 3가지 스택들을 백엔드끼리 나눠 학습을 진행했습니다. 그 결과 CICD는 결국 세 가지 스택이 맞물려 이해가 되어야 하며, 한 사람이 구축해야 하기에 수많은 시간이 소요되어 개발일정이 지연되는 상황이 발생했습니다.
2. 코드 컨벤션의 불일치
: 첫 팀 프로젝트라는 핑계를 들고 싶네요… Gerrit으로 커밋 단위의 코드 리뷰를 진행하긴 했지만 각각의 커밋들의 양이 어마어마했고, 그로 인한 사이드 이펙트로 리뷰를 꼼꼼하게 하지 못하여 컨벤션의 불일치가 발생했습니다. 그로 인한 트러블 발생 시 코드 작성자 이외의 사람이 수정하기가 힘들어 시간이 지체되었던 경험이 있습니다.
3. 협업 일정의 문제
: 이 부분은 제가 담당했던 WebSocket 관련 문제입니다. Stateful 양방향 통신을 구현함에 있어서 프론트분과의 테스트는 제 로직을 검증하기 위해 필수였습니다. 하지만 채팅을 담당하시는 프론트 분의 일정 지연으로 인해 테스트가 늦게 이루어졌고, 그로 인한 고도화 과정이 생략되는 문제가 있었습니다. 특히 STOMP 프로토콜을 사용 중이였기에 포스트맨으로도 테스트가 되지 않았고, 추가로 알아본 apic이라는 테스트 App은 알 수 없는 이유로 사용이 중단되었기에 실질적인 테스트는 불가피했던 점이 아쉬움으로 남네요..
Try
1. 업무 배분을 할 때 시간 투자를 조금 더 하면 어떨까?
아무래도 기획이 늦어진만큼 업무 배정을 되게 성급히 했던 것 같습니다. 돌이켜 생각해보면 충분히 업무에 대해 파악하고, 그에 맞게 분배를 할 수 있었지 않았나 생각이 되기도 하구요.
2.컨벤션에 대한 가이드라인을 도입하고, 페어 리뷰 시스템을 도입하면 어땠을까?
최근에 Google Style guide를 InteliJ에 세팅하고 개발을 진행했는데, 되게 편하게 기본적인 컨벤션들을 에디터에서 자동으로 잡아주어 편리하게 사용했던 경험이 있어요. 이처럼 전반적인 가이드라인을 도입하고 세세한 부분들에 대해서 노션같은 툴을 활용해서 정립하면 좋을 것 같아요.
컨벤션이 지켜지고 있는가에 대해서는 페어 리뷰 시스템을 통해 짝궁을 정해 필수로 리뷰를 진행하고 조금의 리뷰 책임감을 가질 수 있게 하는 방향으로 점검한다면 매우 깔끔한 코드들만을 볼 수 있을 것 같아요!
3. 일일 스크럼 외에도 주간 정기 팀미팅을 가지면 좋을 것 같다!
저희 팀의 경우에는 일일 스크럼을 매일 진행했습니다. 일일 스크럼은 전반적인 일정체크 및 TO DO LIST처럼 오늘 팀원이 무엇을 진행하는지에 대해 알 수 있어 매우 유익한 시간이였죠.
하지만 일일 스크럼을 통해 전체적인 일정을 맞추긴 힘들었습니다. 특히 하루 단위로 일정을 정하는 건 데드라인이 없기 때문에 많이 느슨해질 수 있었거든요. 이를 해결하기 위해 정기적인 주간 팀미팅을 가졌다면 어땠을까 생각해요. 아무래도 주간으로 할 일을 미리 체크하고, 협업이 필요한 부분이 있다면 사전에 말할 수 있는 공적인 기회가 생기니까요.
아키텍처
앞서 비용 문제로 단일 EC2에서 진행하였음을 알립니다.
간단하게 설명드리자면, 도메인 별로 비동기 호출이 필요한 서버들에 대해 WAS를 별도로 두었습니다.
또한 Openvidu를 On premiss 환경으로 Docker를 통해 구축했고, DB의 경우 동일 클라우드 환경 내에 컨테이너로 올렸습니다.
담당 업무
- ERD 설계
- 채점서버 설계
- 게시판 및 알람 API 개발
- 관전자 기능 (채팅, 화면 스트리밍)
- 실시간 게임 로직
해당 프로젝트에서 저는 총 5개의 업무를 맡게 되었고, ERD와 채점서버의 경우 백엔드 팀원 모두가 함께 수행한 업무입니다.
게시판 및 알람 API 개발
게시판의 경우 게시판 리스트 조회, 게시판 상세 조회, 게시글 작성, 게시글 수정, 게시글 삭제로 총 5개의 API를 개발했습니다. 또한 알람의 경우 송신, 상세 조회, 송신 및 수신함 리스트 조회, 읽음 처리, 문제 생성 요청에 대한 상태 처리로 총 6개의 API를 개발했습니다.
각각의 서비스 코드는 try ~ catch를 활용하여 예외처리를 진행하였고, 패키지 구조에서 SQL Mapper인 Mybatis 기술스택을 활용했기때문에 mapper 인터페이스를 추가로 생성하였습니다.
아무래도 ORM과 다르게 Mapper의 경우 Page Navigation 처리가 까다로워 시간이 좀 걸렸던 것 같습니다.
Page Navigation의 주요 로직으로는 검색 조건 및 정렬에 관한 동적 쿼리문을 추가로 생성하였고, 코드는 아래와 같습니다.
<!--> 검색조건에 관한 동적쿼리문 -->
<sql id="search">
<if test='word != null and word != ""'>
<if test="key == 'board_title'">
AND board_title LIKE CONCAT('%', '${word}', '%')
</if>
<if test="key != 'board_title'">
AND ${key} = ${word}
</if>
</if>
</sql>
<!--> 정렬에 관한 동적쿼리문 -->
<sql id="sort">
<if test="sortType != null and sortType != ''">
<if test="sortType == 'articleNo'">
ORDER BY article_no DESC
</if>
<if test="sortType == 'time'">
ORDER BY board_date DESC
</if>
<if test="sortType == 'hit'">
ORDER BY board_hit DESC
</if>
</if>
</sql>
추가로 동적으로 받아와야 할 파라미터가 있다면 위와 같이 추가하여 사용하였습니다.
다음은 게시글 리스트 조회에 관한 쿼리입니다.
<select id="boardList" parameterType="map" resultMap="detail">
SELECT article_no, user_id as userId, (SELECT user_nickname FROM user WHERE user_id = userId) as user_nickname, problem_id, board_title, board_type, board_lang, board_content, board_code, board_hit, board_spoiler, board_date
FROM board
<where>
<include refid="type"/>
<include refid="search"/>
<include refid="lang"/>
</where>
<include refid="sort"/>
LIMIT ${start}, ${listSize}
</select>
<select id="getTotalBoardCount" parameterType="map" resultType="int">
SELECT COUNT(article_no)
FROM board
<where>
<include refid="search"></include>
</where>
</select>
서브쿼리를 활용하였고, where 조건으로 타입, 검색 조건, 언어, 정렬 4가지의 동적 조건을 걸어주었습니다.
PageNavigation을 위한 LIMIT 구문에는 페이지 시작번호인 start와 페이지 당 개수인 listSize를 넣어주었습니다.
getToTalBoardCount의 경우 게시글 전체 페이지 개수 계산을 위한 게시글의 전체 개수를 가져오는 쿼리입니다.
마지막으로 게시글 조회에 관한 서비스 로직입니다.
@Override
public BoardResultDto boardList(Map<String, String> map) {
BoardResultDto boardResultDto = new BoardResultDto();
Map<String, Object> param = new HashMap<String, Object>(); //쿼리 매개변수
param.put("word", map.get("word") == null ? "" : map.get("word")); //검색조건 있다면 put
param.put("boardType", map.get("boardType") == null ? "" : map.get("boardType")); //질문 타입도 검색조건 default : 1
param.put("langType", map.get("langType") == null ? "" : map.get("langType")); //언어 타입도 검색조건
int currentPage = Integer.parseInt(map.get("pgno") == null ? "1" : map.get("pgno")); //특정 페이지 번호 요청이 없다면 1번
int sizePerPage = Integer.parseInt(map.get("spp") == null ? "15" : map.get("spp"));
int start = currentPage * sizePerPage - sizePerPage; //쿼리로 불러올 인덱스 번호 지정
param.put("start", start);
param.put("listSize", sizePerPage);
String key = map.get("key");
param.put("key", key == null ? "" : key);
map.get("sortType");
param.put("sortType", map.get("sortType"));
// log.info(map.get("key") + " : " + map.get("word"));
try {
List<BoardDetailDto> list = boardMapper.boardList(param);
int totalBoardCount = boardMapper.getTotalBoardCount(param);
int totalPageCount = (totalBoardCount - 1) / sizePerPage + 1;
BoardListDto boardListDto = new BoardListDto();
boardListDto.setArticles(list);
boardListDto.setCurrentPage(currentPage);
boardListDto.setTotalPageCount(totalPageCount);
boardResultDto.setStatus("200");
boardResultDto.setMsg("게시글 목록 불러오기 성공");
boardResultDto.setData(boardListDto);
}
catch (Exception e) {
boardResultDto.setStatus("500");
boardResultDto.setMsg("Server Internal Error");
boardResultDto.setData(null);
}
return boardResultDto;
}
관전자 기능 (채팅, 화면 스트리밍)
해당 업무는 CodeArena를 진행하며 제일 시간을 많이 투자한 부분입니다.
이번 프로젝트의 핵심 기능이 녹아든 기능으로 활용한 기술은 다음과 같습니다.
- 채팅을 위한 WebSocket 및 1:N pub/sub 구조
- 화면 스트리밍을 위한 WebRTC 및 오픈소스인 Openvidu의 KMS(Kurento Media Server)
우선 간략하게 기능 스펙을 설명하자면 유저는 1:1 알고리즘 경쟁을 진행하고 있는 방을 관전할 수 있으며, 관전방 내에서의 채팅 및 플레이어의 화면을 볼 수 있어야 합니다.
이를 구현하기 위해 첫 번째 과제로 채팅을 위한 WebSocke, STOMP 프로토콜, Springboot의 SimpleBroker를 학습했어야 했고, 학습한 내용은 아래 포스팅을 참조하면 좋을 것 같습니다.
웹소켓
https://infinitecode.tistory.com/62
STOMP + Message Broker
https://infinitecode.tistory.com/59
Springboot에서의 구현
https://infinitecode.tistory.com/60
두 번째 과제인 WebRTC 부분은 SSAFY 전체 공통 과제였는데, 해당 부분은 Openvidu의 공식문서를 참조하여 구현했습니다.
Openvidu란 간단하게 WebRTC 기술을 기반으로 하며 일대일 통화, 화상 회의실, 대규모 라이브 스트리밍 이벤트, 드론 및 카메라 피드 관리 및 처리 등 상상할 수 있는 모든 종류의 사용 사례를 개발할 수 있습니다.
On-Premises 배포 가이드 문서를 참조하였고, 좀 더 자세한 이야기는 포스팅으로 올려두겠습니다.
https://infinitecode.tistory.com/85
실시간 게임 로직
해당 서비스 또한 실시간 처리가 필요했고, 게임 로직은 경쟁자의 코드 제출 및 유저의 이탈, 제한 시간 초과 등 다양한 이벤트 발생 시 승패 결과를 비롯한 분기 처리가 필요했습니다. 이러한 요구사항을 충족하기 위해 설계 과정에서 많은 고민을 하였고, 제가 구현한 방식은 다음과 같습니다.
우선 채팅을 구현하며 사용한 WebSocket을 활용하여 클라이언트와 실시간 통신이 가능하도록 하였고, 두 유저의 매칭이 성사될 경우 매칭서버로부터 비동기 호출로 게임 방 생성을 수행합니다.
복잡성을 제거하기 위해 한 개의 Random UUID를 생성하여 스트리밍 세션 관리와 채팅 토픽 및 게임 진행에 관련한 토픽을 관리하였습니다.
또한 게임 로직 처리의 경우에는 프론트와 사전의 메시지 타입 프로토콜을 정의하여 클라이언트에서 코드 제출 및 결과, 이탈, 제한시간 초과 등 이벤트가 발생할 시 각각의 서로다른 MessageType을 사용하여 서버로 Publish하도록 설계하였습니다.
요구사항으로 경쟁방에 대한 관전자 수 및 SimpleBroker 사용으로 인한 방 관리를 서버 내부에서 진행할 필요가 있었는데, 아래와 같이 Map을 사용하였습니다.
private Map<String, CompetitiveManageDto> gameManage; //경쟁방 (Redis로 관리)
private Map<String, Integer> privateGameParticipaints; //사설방 참여자 수 관리 리소스
이벤트 중 코드 제출의 경우 두 가지 경쟁모드(스피드전, 효율전)에 따라 제출 결과에 따른 분기처리가 필요했고, 다음과 같이 설계하였습니다.
@Operation(summary = "플레이어 제출 결과에 따른 분기")
@MessageMapping("chat/submit")
public void submit(ChatSubmitMessage message) {
String gameId = message.getGameId();
log.info(String.valueOf(message));
//스피드전
if(message.getMode() == ChatSubmitMessage.MessageType.SPEED) {
//승패 분기
if(message.getResult().equals("맞았습니다")) {
SubmitResultMessage submitResultDto = new SubmitResultMessage();
submitResultDto.setType(SubmitResultMessage.resultType.END);
submitResultDto.setGameId(message.getGameId());
submitResultDto.setWinner(message.getSender());
submitResultDto.setResult(message.getSender() + "님이 승리하였습니다.");
terminateGame(message.getGameId(), message.getSender());
messagingTemplate.convertAndSend("/sub/chat/room/" + message.getGameId(), submitResultDto);
}
//경기 속행 시 별다른 메시지는 보내지 않음.
else {
SubmitResultMessage submitResultDto = new SubmitResultMessage();
submitResultDto.setType(SubmitResultMessage.resultType.CONTINUE);
submitResultDto.setGameId(message.getGameId());
submitResultDto.setWinner(message.getSender());
submitResultDto.setResult(message.getSender() + "님이 제출하였지만 틀렸습니다.");
messagingTemplate.convertAndSend("/sub/chat/room/" + message.getGameId(), submitResultDto);
}
}
//효율전
else if(message.getMode() == ChatSubmitMessage.MessageType.EFFI){
SubmitResultMessage submitResultDto = new SubmitResultMessage();
submitResultDto.setType(SubmitResultMessage.resultType.CONTINUE);
submitResultDto.setGameId(message.getGameId());
submitResultDto.setWinner(message.getSender());
submitResultDto.setResult(message.getSender() + "님이 코드를 제출하였습니다.");
messagingTemplate.convertAndSend("/sub/chat/room/" + message.getGameId(), submitResultDto);
}
}
스피드전의 경우 클라이언트에서 채점서버로부터 받은 결과를 Message를 통해 전송 받았습니다.
정답일 시 terminateGame() 메서드 호출과 동시에 END 타입으로 메시지를 브로드캐스팅 합니다.
END 타입을 수신한 클라이언트는 그 즉시 결과 페이지로 이동하게 되며, terminateGame 메서드는 다음과 같습니다.
@Override
public String terminateGame(String gameId, String winner) {
CompetitiveManageDto competitiveManageDto = gameManage.get(gameId);
if(competitiveManageDto == null) {
log.info("이미 종료된 게임방입니다.");
return "종료";
}
log.info(String.valueOf(competitiveManageDto));
String player1 = competitiveManageDto.getPlayer1();
String player2 = competitiveManageDto.getPlayer2();
String player1Nickname = "";
String player2Nickname = "";
try {
//플레이어 닉네임 조회
player1Nickname = gameMapper.getUserNickname(player1);
player2Nickname = gameMapper.getUserNickname(player2);
}
catch (Exception e) {
log.error("Exception Msg", e);
}
int player1_rating = 0;
int player2_rating = 0;
Map<String, String> param = new HashMap<>();
param.put("player", player1);
param.put("gamemode", competitiveManageDto.getGamemode());
// 두 유저의 레이팅 점수 탐색
try {
player1_rating = gameMapper.isRating(param);
param.put("player", player2);
player2_rating = gameMapper.isRating(param);
}
catch (Exception e) {
log.error("Exception Msg", e);
}
log.info(String.valueOf(player1_rating));
log.info(String.valueOf(player2_rating));
//최종 레이팅 계산
int player1_result = 0;
int player2_result = 0;
//player1이 이겼을 경우
if(winner.equals(player1Nickname)) {
log.info("플레이어 1 우승");
player1_result = CaluRating(player1_rating, player2_rating, "승리");
player2_result = CaluRating(player2_rating, player1_rating, "패배");
}
//player2가 이겼을 경우
else if(winner.equals(player2Nickname)) {
log.info("플레이어 2 우승");
player1_result = CaluRating(player1_rating, player2_rating, "패배");
player2_result = CaluRating(player2_rating, player1_rating, "승리");
}
//무승부
else if(winner.equals("")) {
log.info("무승부");
player1_result = CaluRating(player1_rating, player2_rating, "무승부");
player2_result = CaluRating(player2_rating, player1_rating, "무승부");
}
log.info(String.valueOf(player1_result));
log.info(String.valueOf(player2_result));
try { //종료시간 및 승자 DB I/O
gameMapper.terminateGame(gameId, winner);
}
catch (Exception e) {
log.error("Exception Msg", e);
}
try { //레이팅 갱신
gameMapper.refreshRating(player1, Integer.toString(player1_result), competitiveManageDto.getGamemode());
gameMapper.refreshRating(player2, Integer.toString(player2_result), competitiveManageDto.getGamemode());
}
catch (Exception e) {
log.error("Exception Msg", e);
}
//배팅 결과 적용
try {
//두 플레이어 아이디 조회
Map<String, String> params = new HashMap<>();
params.put("gameId", gameId);
params.put("player1Id", player1);
params.put("player2Id", player2);
BatPlayerCountDto batPlayerCountDto = battingMapper.getPlayerCount(params);
//총 투자 인원
double sum = batPlayerCountDto.getPlayer1Count() + batPlayerCountDto.getPlayer2Count(); //115명
double player1_ratio = (double)1 - ((double) batPlayerCountDto.getPlayer2Count()/sum); //1 -> 0.3 >> 30%
double player2_ratio = (double)1 - ((double) batPlayerCountDto.getPlayer1Count()/sum); //1 -> 0.3 >> 30%
log.info("플레이어 1의 비율 : " + player1_ratio);
log.info("플레이어 2의 비율 : " + player2_ratio);
if(winner.isEmpty()) {
log.info("무승부입니다.");
//승자가 없을 경우
//배팅 금액만큼 다 돌려줘야됨.
List<BatUserCoinDto> list = battingMapper.getUserBatCoin(gameId, winner);
for(BatUserCoinDto dto : list) {
battingMapper.updateUserPlusCoin(dto.getUserId(), Integer.parseInt(dto.getUserCoin()));
}
}
else if(winner.equals(player1Nickname) || winner.equals(player2Nickname)){
//승자가 있을 경우
//승자에게 배팅한 유저들 조회
//for문 돌면서 한 유저당 기존 코인 꺼내오고 비율 * 배팅금액 한거 더해서 추가
List<BatUserCoinDto> list = battingMapper.getUserBatCoin(gameId, winner);
for(BatUserCoinDto dto : list) {
//한명씩 비율과 계산한 후 갱신
//이긴 유저 한명 당 기존 coin 조회
int coin = battingMapper.getUserCoin(dto.getUserId());
if(player1Nickname.equals(winner)) {
log.info("player1이 우승 시 배팅 정산");
int newCoin = (int) (coin + Integer.parseInt(dto.getUserCoin()) * (player1_ratio + 1));
battingMapper.updateUserCoin(dto.getUserId(), String.valueOf(newCoin));
}
else if(player2Nickname.equals(winner)){
log.info("player2가 우승 시 배팅 정산");
int newCoin = (int) (coin + Integer.parseInt(dto.getUserCoin()) * (player2_ratio + 1));
battingMapper.updateUserCoin(dto.getUserId(), String.valueOf(newCoin));
}
else {
log.info("ChatServiceImpl.terminateGame : winner값에 이상한 값이 들어왔습니다.");
break;
}
}
}
else {
log.error("winner 닉네임과 현재 게임방에 속한 플레이어의 닉네임과 일치하지 않습니다.");
}
}
catch (Exception e) {
log.error("Exception Msg", e);
}
//객체 제거
gameManage.remove(gameId);
return "플레이어 1 : " + player1_result + " AND " + "플레이어 2 : " + player2_result;
}
스파게티 코드에 관해서는 리팩토링 해야 할 것 같습니다.
예외처리는 API 개발과 마찬가지로 try ~ catch를 사용하였고, 게임방에 대한 유무 조회 및 두 플레이어에 대한 정보를 조회합니다.
다음으로 두 유저의 레이팅 변동을 위해 ELO 레이팅 계산식을 활용하여 승패에 따른 레이팅 변동을 주었고, 관전자들의 배팅에 관련해서도 연산 수행 후 최종적으로 DB I/O를 수행 후 게임방 객체를 제거합니다.
배팅 정산 금액에 관해서는 총 배팅자 수와 플레이어 당 배팅 수를 연산에 적용하여 계산하였습니다.
효율전의 경우 시간제한 및 두 유저가 모두 이탈할 경우 승패분기 탐색이 이루어지며, 탐색 로직은 아래와 같습니다.
@Override
public WinnerInfoDto findWinner(String gameId) {
CompetitiveManageDto competitiveManageDto = gameManage.get(gameId);
String player1 = competitiveManageDto.getPlayer1();
String player2 = competitiveManageDto.getPlayer2();
int result = 0;
//player1, player2의 '맞았습니다' 결과 개수 탐색
try {
result = gameMapper.passProblem(gameId, player1, player2);
}
catch (Exception e) {
log.error("Exception Msg", e);
}
WinnerInfoDto winnerInfoDto = new WinnerInfoDto();
if(result > 0) {
//승자 탐색
try {
winnerInfoDto = gameMapper.winnerSearch(gameId);
}
catch (Exception e) {
log.error("Exception Msg", e);
}
}
else {
winnerInfoDto = null;
}
//두 유저 모두 '맞았습니다.' 결과가 존재할 경우 승자 탐색
return winnerInfoDto;
}
<winnerSearch Query>
<select id="winnerSearch" parameterType="string" resultMap="winnerInfo">
SELECT user_id as userId, (SELECT user_nickname FROM user WHERE user_id = userId) as user_nickname
FROM arena_submit_status
WHERE game_id = #{gameId} AND submit_status = '맞았습니다.'
ORDER BY time_complexity ASC , submit_date ASC LIMIT 1;
</select>
제출 결과 테이블에서 gameId와 Accept 결과에 대해 시간 복잡도를 기준으로 정렬하여 1개의 결과를 가지고 오는 쿼리입니다.
조회 결과는 winnerInfo로 승자 객체로 데이터를 기록하고 최종적으로 응답으로 전송하게 됩니다.
ETC...
현재 취업을 위한 포트폴리오에서 해당 프로젝트를 언급하고 있습니다.
다양한 사람들에게 피드백을 받기 위해 포트폴리오를 보여드리면 항상 따라오는 공통된 질문들이 보이더라구요.
그래서 프로젝트 회고를 함과 동시에 아쉬웠던 점 혹은 왜 이렇게 구현했어야 했나?, 더 효율적인 방법은 없나? 같은 설계에 대해 고민하는 시간을 가지기로 했습니다. 또한 포트폴리오 피드백으로 받은 질문들에 대해서도요.
내용이 많이 길어질것 같아 개별 업로드 혹은 다음 포스팅에서 한번에 다루도록 하겠습니다!
'Develop > Project' 카테고리의 다른 글
[해커톤] 인생 첫 해커톤, 시작부터 수상까지. (8) | 2024.09.14 |
---|---|
채팅을 WebRTC로 하라고? (0) | 2024.08.05 |
개발 기술 블로그, Dev
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!