프로젝트/협업

Pro.gg - 프로젝트 회고록(6) : 팀 구성 및 매칭

방구석 대학생 2021. 10. 22. 02:30

 

프로젝트의 핵심 기능이자 목표인 팀 구성 기능

LOL 은 5명이 한 팀을 이뤄 상대방의 넥서스를 파괴하여 최종적으로 승리를 목표로 하는 협동 AOS 게임이니 만큼 자신의 팀을 구성하는것이 굉장히 중요하다.

자신만의 고정적인 팀이 존재하고, 각 멤버들의 실력이 검증되어 있다면 게임을 하는데 있어서 팀원들과의 긴밀한 협업과 약속된 전술을 통해 더 다채롭고 재미있는 플레이가 가능해 진다.

내가 요즘 재밌게 플레이 하고 있는 로스트아크 또한 군단장 레이드를 하는데 있어 자신의 고정적인 팀원들이 있다면 매주 마다 힘들 일 없이 안정적으로 클리어가 가능한 것을 보면 팀원들과의 협동이 요구되는 게임에서 자신만의 고정적인 팀이 존재한다는 것은 굉장한 플러스 요인이 될 것이다.

 

하지만 말했듯이 말 그대로 '검증된' 팀원이 필요하다는 것이 중요할 것이다.

그렇기 때문에 이번에 팀 구성 기능을 구현하는데 있어 팀을 생성할 때 팀에 지원할 때 소환사 데이터에서 솔로 랭크를 기준으로 신청 제한 티어를 두어, 해당 제한을 넘지 못하는 티어를 가진 소환사는 팀에 지원 할 수 없도록 하는 기능을 구현했다.

생성된 팀에 팀장이 설정해 놓은 티어 제한이 걸려있다.

팀 테이블 - 팀 지원 테이블을 따로 만들어서 관리하자.

팀이 구성되어 있는 팀 테이블과 그 팀에 대한 신청내역이 담겨있는 팀 지원 테이블은 따로 관리를 해주었다.

기본적으로 팀 자체에 대한 정보와 해당 팀에 대한 지원 내역의 경우는 1 : N 의 부모 - 자식 관계를 가지게 되므로 당연히 테이블 또한 따로 관리를 하는것이 맞았다.

그러면서 팀에 지원할 때 어느 포지션에 지원하는것인지 구분하기 쉽게 하기 위해 지원 테이블엔 line 컬럼을 두어 top(탑), middle(미드), jungle(정글), bottom(바텀), suppoter(서포터) 등의 지원 포지션을 입력 받도록 하고, 해당 컬럼에 저장된 정보에 따라 팀 자체 테이블에서 그와 동일한 컬럼을 확인 후(팀 자체 테이블은 top, middle, jungle, bottom, suppoter 로 명명된 컬럼을 가지고 있다.) 팀 테이블에서 해당하는 라인이 비어있다면 지원 성공, 비어있지 않다면 이미 지정된 소환사가 있다는 경고문을 출력해주는 방식으로 지원 실패를 처리했다.

 

팀 탈퇴 - 여러가지 경우의 수

팀 탈퇴 기능의 경우 팀에 소속된 상태에서 탈퇴 버튼을 누르면 확인 메세지 출력 후 정상적으로 팀에서 탈퇴가 되게끔 해주는 기능을 기본으로 하되, 다른 경우의 수 를 염두해 두고 자동으로 팀에서 탈퇴가 되게끔 만드는 기능까지 구현해야 했다.

예를 들어 등록되어 있는 소환사명을 소환사명 변경하기 기능을 통해 바꿨다면 원래 등록되어 있던 소환사명을 데이터베이스에서 삭제 시키기 때문에 팀에서 소환사 명을 참조하여 티어, 승률과 같은 데이터를 가져오지 못하는 데이터베이스 참조 오류가 발생하게 되므로, 소환사 명이 변경되었을 시 팀에 소속되어 있을 경우 자동으로 팀에서 탈퇴 시키는 기능또한 구현했다.

이제와서 생각해보면 팀에서 탈퇴 시키는것 보다 그냥 팀 데이터 측에서 참조할 소환사 명을 다시 잡아주면 되지 않았었나 싶긴 하지만, 이런 기능개선과 같은 부분은 일단 뒤로 미루도록 하자.

 

그리고 회원탈퇴를 했을 경우에도 소속팀이 있을 경우 자동으로 팀에서 탈퇴가 되게끔 구현했다.

회원 탈퇴를 통해 회원 정보가 데이터베이스에서 완전히 말소되면 팀 데이터에서 참조하는 소환사명을 바꾸는 등으로 다른 방향조차 잡을 수 없게 되므로 아예 팀에서 자동으로 탈퇴되게끔 구현했다.

그런데 여기서 팀원이 탈퇴하는 경우와 팀장이 탈퇴하는 경우 다른 동작을 수행하게끔 만들었다.

팀원이 탈퇴하는 경우는 그냥 팀 데이터에서 삭제하면 그만이지만 팀장이 탈퇴하는 경우는 아예 팀이 해체되어 팀 데이터 자체가 데이터베이스에서 말소되게끔 구현했다.

그런데 이것또한 이제와서 생각해보니 그냥 다른 팀원들이 존재하는 경우와 그렇지 않은 경우를 나눠서, 팀원이 존재하지 않을 경우엔 팀장이 탈퇴하면 팀이 해체되는 방식 그대로 하면 되겠지만, 팀원이 존재할 경우엔 팀원들 중에서 승률이 가장 높은 사람에게 팀장 권한을 이양시키고 기존 팀장은 정상적으로 탈퇴 처리 시키면서 팀은 해체 시키지 않는 방향으로 개발했으면 어땠을까 하는 생각이 든다.

이런 부분은 위에서도 말했듯이 일단은 넘어가도록 하자. 

 

- TeamController.java

@GetMapping("/captinsecession.do")
    public String captinSecession(@RequestParam("teamName") String teamName){
        // 팀장 탈퇴 메소드
        HttpSession session = MemberController.session;
        MemberDTO memberDTO_captin = (MemberDTO) session.getAttribute("member");

        TeamDTO teamDTO = new TeamDTO();
        teamDTO.setTeamName(teamName);
        teamDTO = teamService.selectTeam(teamDTO);

        List<String> lineList = new ArrayList<>();
        // 각 라인별로 존재하는 팀원이 있을경우 리스트에 삽입
        if (teamDTO.getTop() != null){
            lineList.add(teamDTO.getTop());
        }
        if (teamDTO.getMiddle() != null){
            lineList.add(teamDTO.getMiddle());
        }
        if (teamDTO.getJungle() != null){
            lineList.add(teamDTO.getJungle());
        }
        if (teamDTO.getBottom() != null){
            lineList.add(teamDTO.getBottom());
        }
        if (teamDTO.getSuppoter() != null){
            lineList.add(teamDTO.getSuppoter());
        }

        // 연관관계 매핑 해제 - 회원 닉네임을 통해 회원 데이터 검색 후 해당 데이터의 teamName 필드값을 null 로 업데이트
        for (int i = 0; i < lineList.size(); i++){
            MemberDTO memberDTO = memberService.findByNickname(lineList.get(i));

            if (memberDTO != null) {
                memberDTO.setTeamName(null);
                memberService.updateTeamName(memberDTO);
            }
        }

        // 회원 측 연관관계 해제 이후 팀 삭제기능 동작
        teamService.deleteTeam(teamDTO);
        // 세션에서 또한 teamName 필드 데이터 삭제
        memberDTO_captin.setTeamName(null);
        session.setAttribute("member", memberDTO_captin);
        return "redirect:/move/searchTeamName.do";
    }

    @GetMapping("/crewsecession.do")
    public String crewSecession(@RequestParam("teamName") String teamName, @RequestParam("target") String target, Model model){
        // 팀원 탈퇴 메소드
        // 팀에서 탈퇴하는 팀원 라인 null 값 처리
        // 팀원 개인 member 데이터에서 teamName 필드 null 값 처리
        HttpSession session = MemberController.session;
        MemberDTO memberDTO_crew = (MemberDTO) session.getAttribute("member");

        TeamDTO teamDTO = new TeamDTO();
        teamDTO.setTeamName(teamName);
        teamDTO = teamService.selectTeam(teamDTO);

        // 단 하나의 메소드로 5가지 라인 각각에 지정되어 있는 팀원의 닉네임 값을 삭제해주기 위한 TeamApplyDTO 객체 사용
        TeamApplyDTO teamApplyDTO = new TeamApplyDTO();
        teamApplyDTO.setTeamName(teamName);
        teamApplyDTO.setNickname(null);

        // 어떤 라인의 회원이 탈퇴하는지 확인하기 위한 조건문
        if (memberDTO_crew.getNickname().equals(teamDTO.getTop())){
          teamApplyDTO.setLine("top");
        } else if (memberDTO_crew.getNickname().equals(teamDTO.getMiddle())){
            teamApplyDTO.setLine("middle");
        } else if (memberDTO_crew.getNickname().equals(teamDTO.getJungle())){
            teamApplyDTO.setLine("jungle");
        } else if (memberDTO_crew.getNickname().equals(teamDTO.getBottom())){
            teamApplyDTO.setLine("bottom");
        } else if (memberDTO_crew.getNickname().equals(teamDTO.getSuppoter())){
            teamApplyDTO.setLine("suppoter");
        }

        // 어떤 라인의 회원이 탈퇴하는지 판별 완료 후 해당 라인 필드 값 null 로 업데이트
        teamService.updateTeamLine(teamApplyDTO);
        // 회원 측 teamName 필드 null 로 업데이트
        memberDTO_crew.setTeamName(null);
        memberService.updateTeamName(memberDTO_crew);
        session.setAttribute("member", memberDTO_crew);
        if (target.equals("updateSummonerName")){
            model.addAttribute("member", memberDTO_crew);
            return "updateSummonerName";
        }
        return "redirect:/move/searchTeamName.do";
    }

 

팀 수정 - 생각보다 어려웠던 작업

팀 수정의 경우 팀장이 팀 수정 페이지에서 팀원 추방이나 팀원 포지션 이동 등의 동작을 가능케 하며, 포지션 중복이 있는걸 감지할 경우 그에대한 경고 메시지를 출력하고 그렇지 않을 경우 정상적으로 팀 수정이 이루어지게끔 구현해야 했다.

그러려면 포지션 중복이 없는 경우 기존에 각 포지션에 배속된 팀원이 있는지 확인하는 것 부터 시작해서, 배속된 팀원이 있는 경우 그 팀원의 포지션이 변경되었는지 그렇지 않은지 또한 판별하는 과정까지 거쳐야 했다.

 

팀 수정 기능을 구현하는데 있어 필요한 로직을 생각해내는데도 조금 오래 걸리긴 했지만 그래도 충분히 구현이 가능한 수준이었기에 처음 기능 구현을 시작할땐 별 문제가 없겠거니 생각했다.

그런데 기능을 구현하는데 필요한 로직을 뷰 측의 자바스크립트를 통해서 작성하는것이 아닌 컨트롤러 측에서 작성하는 부분에서 굉장히 많은 부분이 꼬였던 것으로 기억난다.

아마 정확히 기억은 안 나지만 컨트롤러에서 기능 처리를 위한 로직을 제대로 동작시키기 위해 뷰 측에서 컨트롤러 쪽으로 보내는 정보를 처리하는데 꽤 많이 애를 먹었고, 결과적으로 추후에 다시 기능을 살펴봤을때 포지션 수정 기능이 제대로 동작하지 않는 버그를 발견해버리고 말았다.

 

결국 이 버그를 해결하기 위해 컨트롤러에서 처리하던 로직을 뷰 측으로 보내서 자바스크립트로 동작하도록 처리했더니 기능의 구현이 한결 쉬워졌다.

애초부터 팀이 수정된 것에 대한 데이터를 직접적으로 먼저 받는 뷰 측에서 먼저 처리하게끔 했으면 일이 괜히 늘어날일은 없었을텐데 꽤나 아쉬울 따름이다.

 

- teamUpdateForm.jsp

function teamLineUpdate(){

			var teamName = "${team.teamName}";

			var positionTop = document.getElementById("positionTop");
			var positionMiddle = document.getElementById("positionMiddle");
			var positionJungle = document.getElementById("positionJungle");
			var positionBottom = document.getElementById("positionBottom");
			var positionSuppoter = document.getElementById("positionSuppoter");

			var positionJSON = {
				top:'',
				middle:'',
				jungle:'',
				bottom:'',
				suppoter:''
			};
			
			var changePosition = '';
			var crewName = '';

			var overlapCheck = [];

			// 겹치는 포지션이 있는지 없는지 판별
			if("${team.top}".length !== 0){
				overlapCheck.push(positionTop.options[positionTop.selectedIndex].value);
			}
			if("${team.middle}".length !== 0){
				overlapCheck.push(positionMiddle.options[positionMiddle.selectedIndex].value);
			}
			if("${team.jungle}".length !== 0){
				overlapCheck.push(positionJungle.options[positionJungle.selectedIndex].value);
			}
			if("${team.bottom}".length !== 0){
				overlapCheck.push(positionBottom.options[positionBottom.selectedIndex].value);
			}
			if("${team.suppoter}".length !== 0){
				overlapCheck.push(positionSuppoter.options[positionSuppoter.selectedIndex].value);
			}

			var overlapSet = new Set(overlapCheck);
			if(overlapCheck.length !== overlapSet.size){ // 포지션 중복이 있을 경우 처리
				$.ajax({
					type:'get',
					url:'${pageContext.request.contextPath}/teamdetail.do?teamName='+encodeURI(teamName)+'&target=overlap',
					data:'',
					dataType:'',
					success:function(data){
						$("body").html(data);
					}
				})
			}else{ // 포지션 중복이 없을 경우 처리
				
				if("${team.top}".length !== 0){ // 기존에 top 이었던 회원이 있었을 경우
					// 포지션이 변경 되었는지 확인
					changePosition = positionTop.options[positionTop.selectedIndex].value;
					crewName = "${team.top}";
					if(changePosition !== "top"){ // 포지션이 변경 되었을 경우
						jsonAdd(changePosition, crewName);			
					} else{ // 포지션이 변경되지 않았을 경우
						jsonAdd(changePosition, crewName);
					}
				}

				if("${team.middle}".length !== 0){
					changePosition = positionMiddle.options[positionMiddle.selectedIndex].value;
					crewName = "${team.middle}";
					if(changePosition !== "middle"){
						jsonAdd(changePosition, crewName);
					} else{ // 포지션이 변경되지 않았을 경우
						jsonAdd(changePosition, crewName);
					}
				}

				if("${team.jungle}".length !== 0){
					changePosition = positionJungle.options[positionJungle.selectedIndex].value;
					crewName = "${team.jungle}";
					if(changePosition !== "jungle"){
						jsonAdd(changePosition, crewName);
					} else{ // 포지션이 변경되지 않았을 경우
						jsonAdd(changePosition, crewName);
					}
				}

				if("${team.bottom}".length !== 0){
					changePosition = positionBottom.options[positionBottom.selectedIndex].value;
					crewName = "${team.bottom}";
					if(changePosition !== "bottom"){
						jsonAdd(changePosition, crewName);
					} else{ // 포지션이 변경되지 않았을 경우
						jsonAdd(changePosition, crewName);
					}
				}

				if("${team.suppoter}".length !== 0){
					changePosition = positionSuppoter.options[positionSuppoter.selectedIndex].value;
					crewName = "${team.suppoter}";
					if(changePosition !== "suppoter"){
						jsonAdd(changePosition, crewName);
					} else{ // 포지션이 변경되지 않았을 경우
						jsonAdd(changePosition, crewName);
					}
				}

				$.ajax({
					type:'get',
					url:'${pageContext.request.contextPath}/teamLineUpdate.do?positionJSON='+encodeURI(JSON.stringify(positionJSON))+'&teamName='+encodeURI(teamName),
					data:'',
					dataType:'',
					success:function(data){
						$("body").html(data);
					}
				})
			}

			function jsonAdd(changePosition, crewName){
				if(changePosition === "top"){
					positionJSON.top = crewName;
				}
				else if(changePosition === "middle"){
						positionJSON.middle = crewName; // JSON 변수에 데이터 추가
				}
				else if(changePosition === "jungle"){
					positionJSON.jungle = crewName;
				}
				else if(changePosition === "bottom"){
					positionJSON.bottom = crewName;
				}
				else if(changePosition === "suppoter"){
					positionJSON.suppoter = crewName;
				}
			}

 

- TeamController.java

@GetMapping("/teamLineUpdate.do")
    public String teamUpdate(@RequestParam("positionJSON") String positionJSON, @RequestParam("teamName") String teamName) throws UnsupportedEncodingException {

        TeamDTO teamDTO = new TeamDTO();
        // positionJSON 은 라인 변경이 반영된 JSON 데이터
        try{
            JSONObject jsonObject = new JSONObject(positionJSON);

            String top = jsonObject.getString("top");
            String middle = jsonObject.getString("middle");
            String jungle = jsonObject.getString("jungle");
            String bottom = jsonObject.getString("bottom");
            String suppoter = jsonObject.getString("suppoter");

            if (!top.equals(""))
                teamDTO.setTop(top);
            if (!middle.equals(""))
                teamDTO.setMiddle(middle);
            if (!jungle.equals(""))
                teamDTO.setJungle(jungle);
            if (!bottom.equals(""))
                teamDTO.setBottom(bottom);
            if (!suppoter.equals(""))
                teamDTO.setSuppoter(suppoter);

            teamDTO.setTeamName(teamName);

            teamService.updateTeam(teamDTO);
        }catch (Exception e){
            System.out.println(e.getMessage());
        }

        return "redirect:/teamdetail.do?teamName="+URLEncoder.encode(teamName, "UTF-8")+"&target=detail";
    }

 

 

팀 매칭 - 팀 평균 티어를 기준으로 위 아래 4계단 간격으로 매칭

이번 프로젝트의 핵심 기능이다.

자신이 소속된 팀을 기준으로 팀원들의 솔로 랭크 평균 티어를 보고, 그 티어를 기준으로 하여 데이터베이스에서 내부적으로 각 티어별로 가중치를 적용해둔 값을 기준으로 각 나눔별 최저 평균티어 팀 아래로 4계단, 각 나눔별 최대 평균티어 팀 위로 4계단씩 차이가 나는 팀들이 존재할 경우 결과를 리스트 형태로 화면에 뿌려주는 방식으로 기능을 구현했다. 

그와중에 평균 티어가 굉장히 상위인 팀들 끼리는 거기서 거기라고 판단하여 매칭 기준을 굉장히 넓게 잡아줬다.

최상위 티어 팀에서 부터 7계단 아래 까지의 평균 티어를 가진 팀들이 매칭 기능을 이용할 때는 위 아래 4계단 이 아니라 최상위 티어의 팀 기준으로 봤을땐 아래로 10계단, 그 아래 7계단 아래까지의 팀들은 나눔별 최저 평균티어팀으로 부터 4계단 아래 티어까지의 팀 끼리 매칭을 시켜주는 방향으로 잡았다.

 

- TeamController.java

@GetMapping("/matchList.do")
    public String matchList(Model model, HttpServletRequest request) {
    	HttpSession session = request.getSession();
    	MemberDTO member = (MemberDTO)session.getAttribute("member");
		TeamDTO teamDTO = new TeamDTO();
	    teamDTO.setTeamName(member.getTeamName());
	    TeamDTO team = teamService.selectTeam(teamDTO);
	    HashMap<String,Integer> idx = new HashMap<String, Integer>();
	    List<TeamDTO> teamDTOList = null;
	    if(team.getTop() != null || team.getMiddle() != null || team.getJungle() != null || team.getBottom() != null || team.getSuppoter() != null) {
	    	if(team.getTier_average() > 0 && team.getTier_average()<=4) {
	    		idx.put("startIdx", 1);
	    		idx.put("endIdx", 8);
	    	}else if(team.getTier_average() > 4 && team.getTier_average()<=8) {
	    		idx.put("startIdx", 1);
	    		idx.put("endIdx", 12);
	    	}else if(team.getTier_average() > 8 && team.getTier_average()<=12) {
	    		idx.put("startIdx", 5);
	    		idx.put("endIdx", 16);
	    	}else if(team.getTier_average() > 12 && team.getTier_average()<=16) {
	    		idx.put("startIdx", 9);
	    		idx.put("endIdx", 20);
	    	}else if(team.getTier_average() > 16 && team.getTier_average()<=20) {
	    		idx.put("startIdx", 13);
	    		idx.put("endIdx", 24);
	    	}else if(team.getTier_average() > 21 && team.getTier_average()<=27) {
	    		idx.put("startIdx", 17);
	    		idx.put("endIdx", 27);
	    	}
	    	teamDTOList = teamService.selectMatchList(idx);
	    }
	    System.out.println(teamDTOList);
        model.addAttribute("teamList", teamDTOList);
    	return "matchList";
    }

 

* 팀 객체 클래스 다이어그램(최종)