본문 바로가기
  • 개발공부 및 일상적인 내용을 작성하는 블로그 입니다.
프로젝트/협업

Pro.gg - 프로젝트 회고록(3) : 회원 정보관리

by 방구석 대학생 2021. 10. 14.

 

회원 정보관리 - 기능 세분화

회원 정보는 앞으로 웹 사이트에서 여러가지 기능을 활용하는데 있어 중요한 자원으로서 역할을 수행한다.

그렇기 때문에 기본적으로 갖추고 있어야 할 아이디, 비밀번호, 닉네임과 같은 데이터들과 함께 소속팀 이름, 추천 및 비추천 게시글 번호, 추천 및 비추천 댓글 번호 등과 같은 데이터들 까지 함께 구성되어야 한다.

 

- 회원 정보 클래스 다이어그램(최종)

회원 정보 클래스 다이어그램

회원 정보 관리와 관련되어 구현된 기능들의 세부 내용은 다음과 같다.

 

- 회원가입 및 로그인

- 네이버, 카카오 등 SNS 로그인(페이스북, 구글은 https 보안 연결 이슈로 현재 지원하지 않음)

- 아이디, 비밀번호 찾기

- 이름, 닉네임, 이메일등 회원 정보 변경

- 회원 탈퇴

 

기본적인 기술을 익히는 데 도움이 된 초반 작업

회원가입, 로그인, 아이디 및 비밀번호 찾기와 같은 회원 정보관리 기능들은 프로젝트 초반에 구현이 진행되었던 작업들인데 이 작업들로 인해 웹 개발에 있어 기초적인 MVC 디자인 패턴 작업 방식과, Mybatis 를 이용한 객체 - 데이터베이스간 데이터 저장, 검색 등의 데이터베이스 통신에 대한 기본적인 노하우를 쌓을 수 있었다.

특히나 그간 인터넷 강의, 대학교 강의를 통해서 객체 - 데이터베이스간 통신의 경우 JPA 라는 ORM 을 주로 활용해 왔는데 이번엔 MyBatis 라는 걸 사용하게 되서 꽤나 신선했던것 같다.

다만 불편했던 점이 있었다면 Spring DATA JPA 를 잠깐 쓸때는 자체적으로 제공해주는 메소드 덕분에 간단한 쿼리 문은 직접 작성해줄 필요가 없었던 것 과 다르게 MyBatis 의 경우 아주 기본적인 검색 쿼리 조차 직접 전부 작성해 줘야 했어서 조금 번거로웠던 것 같다.

로컬에서는 잘 되던 https 연결....실제 서버에서는?

회원가입 및 로그인 기능을 성공적으로 구현하고 난 이후, 소환사 관련 기능, 팀 관련 기능 등 다른 기능들에 집중을 해오다 SNS 로그인을 구현해야 겠다 싶어 총 4가지 종류의 SNS 계정으로 로그인을 할 수 있도록 지원하는 기능을 구현하는 한 편, 같이 작업을 하는 형은 네이버와 카카오, 나는 구글과 페이스북 로그인 기능을 구현하기로 했다.

 

내가 구현하기로 한 페이스북과 구글 로그인 기능은 로그인 기능을 구현하려면 API 를 사용하고자 하는 웹 사이트가   반드시 https 보안 연결을 지원해야만 했는데, 그때 당시엔 지금과 같이 네이버 클라우드 플랫폼을 통해서 배포 파일을 서버 상에 적재해놓은 상태가 아니라 각자 로컬에서 개발을 하고 있던 상황이었기에 웹 사이트에 대한 접속은 스프링 부트에 자체적으로 내장 되어 있는 톰캣 서버의 구동을 통해서만 가능했다.(localhost 연결을 통한 접속만 가능했음)

그래서 로컬에서도 https 연결을 할 수 있는 방법을 찾아서 적용시켜야 했는데 우여곡절 끝에 방법을 찾아서 최종적으로 로컬 서버에서 https 연결을 구현하는데 성공했고, 그 덕분에 처음으로 해보는 것이라 샘플 코드들을 이해하는데 꽤나 어려움이 있었지만 페이스북과 구글 로그인 기능을 무사히 적용시켜 볼 수 있었다.

(솔직히 아직도 코드들 전부 다 설명해보라고 하면 자신없다;;)

 

- 실제로 적용된 페이스북 로그인 API

<!--페이스북 로그인-->
	<script>
		window.fbAsyncInit = function(){
			FB.init({
				appId : '***************', // Facebook for developers 에서 샏성한 프로젝트 appId 명
				autoLogAppEvents : true,
				xfbml : true,
				version : 'v11.0'
			});

			FB.AppEvents.logPageView();

			FB.getLoginStatus(function(response){
				console.log(response.status);
			});
		};

		(function(d, s, id){
			var js, fjs = d.getElementsByTagName(s)[0];
			if (d.getElementById(id)) {return;}
			js = d.createElement(s); js.id = id;
			js.src = "https://connect.facebook.net/ko_KR/sdk.js";
			fjs.parentNode.insertBefore(js, fjs);
		}(document, 'script', 'facebook-jssdk'));

		function checkLoginState(){
			FB.getLoginStatus(function(response){
				if(response.status === 'connected'){
					console.log("Facebook Login test");
					FB.api(
						'/me',
						'GET',
						{"fields":"id,name,email"},
						function(response) {

							var facebookName = response.name;
							var facebookId = response.id;
							var facebookEmail = response.email;

							$.ajax({
								type:'post',
								url:'${pageContext.request.contextPath}/facebookLogin.do?facebookName='+encodeURI(facebookName)+'&facebookId='+facebookId+'&facebookEmail='+facebookEmail,
								data:'',
								dataType:'',
								success:function(data){
									$("body").html(data);
									window.location.replace('/');
								}
							})

						}
					);
				}else if(response.status === 'not_authorized'){
					alert('로그인 되어 있지않은 상태입니다.');
				}
			});
		};

	</script>
    <script async defer crossorigin="anonymous" src="https://connect.facebook.net/ko_KR/sdk.js" nonce="86tvEXGE"></script>

 

 - 실제로 적용된 구글 로그인 API

<meta name ="google-signin-client_id" content="260796672294-2ohafah614eufqbajaunso754sjuqjtq.apps.googleusercontent.com">
<script src="https://apis.google.com/js/platform.js?onload=init" async defer></script>
<!-- 구글 로그인 -->
	<script>
		function init() {
			gapi.load('auth2', function() {
				gapi.auth2.init();
				options = new gapi.auth2.SigninOptionsBuilder();
				options.setPrompt('select_account');
				// 추가는 Oauth 승인 권한 추가 후 띄어쓰기 기준으로 추가
				options.setScope('email profile openid https://www.googleapis.com/auth/user.birthday.read');
				// 인스턴스의 함수 호출 - element에 로그인 기능 추가
				// GgCustomLogin은 li태그안에 있는 ID, 위에 설정한 options와 아래 성공,실패시 실행하는 함수들
				gapi.auth2.getAuthInstance().attachClickHandler('GgCustomLogin', options, onSignIn, onSignInFailure);
			})
		}

		function onSignIn(googleUser) {
			var access_token = googleUser.getAuthResponse().access_token
			$.ajax({
				// people api를 이용하여 프로필 및 생년월일에 대한 선택동의후 가져온다.
				url: 'https://people.googleapis.com/v1/people/me'
				// key에 자신의 API 키를 넣습니다.
				, data: {personFields:'birthdays', key:'**************************', 'access_token': access_token}
				, method:'GET'
			})
			.done(function(e){
				//프로필을 가져온다.
				var profile = googleUser.getBasicProfile();
				console.log(profile)
				//수집해온 데이터를 ajax 를 활용해 컨트롤러로 넘겨준다.
				$.ajax({
				type:'post',
				url:'${pageContext.request.contextPath}/googleLogin.do?profile='+encodeURI(JSON.stringify(profile)),
				data:'',
				dataType:'',
				success:function(data){
					$("body").html(data);
					window.location.replace('/');
				}
			})
			})
			.fail(function(e){
				console.log(e);
			})
		}
		function onSignInFailure(t){
			console.log(t);
		}
	</script>

 

배포는 jar 파일이 아닌 war 파일?

외부 서버에 대한 배포는 war 파일로 하게 되었다.

그 이유로는, 원래 Spring boot 에서 사용된 템플릿 엔진에 따른 배포 파일 형식 지원 문제 때문이다.

원래 Spring boot 는 템플릿 엔진으로 JSP 가 아니라 Thymeleaf 를 추천한다.

왜 그런지 알아봤더니 Thymeleaf 를 활용해야 Spring boot 에서 웹 배포 파일로 jar 형식으로 지원해 줄 수 있는것이었다.

즉, JSP 를 템플릿 엔진으로 활용할 경우 jar 형식의 배포 파일을 만들 수 없게 되는 것이다.

나는 개인적으로 국비 학원 강의를 수강하기 전 인터넷 강의나 대학교 강의 등으로 Thymeleaf 를 사용해 본적이 있긴했지만 아직 기초적인 이해도 잘 못하고 있는 상황이었다.

공식 문서를 찾아봐도 온통 영어라 정확히 내가 원하는 기능을 구현하기 위해 어떤 방식으로 이걸 활용해야 하는지에 대한 설명을 찾기도 쉽지 않았다.

Thymeleaf 를 직접 사용해본 나도 이럴진대 프로젝트를 끝까지 같이 하기로 한 형은 학원을 다니면서 템플릿 엔진이라고는 JSP 밖에 만져보지 않은 사람이었어서 이번 프로젝트의 템플릿 엔진으로 JSP 를 선택하는 것은 예정된 수순이었기에 자연스럽게 Spring boot 에서 서버 배포를 위해 만들어질 파일은 war 형식이 될 수 밖에 없었던 것이다. 

 

외부 톰캣 서버 https 연결 실패.....

그런데 프로젝트 작업을 사실상 끝마친 후 로컬이 아닌 네이버 클라우드 플랫폼과 같은 외부 서버를 활용해 톰캣 서버를 구동시켜 놓고 그곳에 war 파일을 배포했더니 문제가 발생했다.

https 연결을 해야 구글, 페이스북 로그인 API 가 제대로 기능을 수행하는데 배포 파일을 올려둔 외부 톰캣 서버에 https 연결이 제대로 되질 않는 것이다.

기껏 구현해 놓은 SNS 로그인 기능인데 이대로 버릴 순 없다 싶어서 여기저기 알아보고 시도를 해보았지만 도대체 뭐가 문제인건지 아직까지 https 연결을 해결하지 못하고 있다.

 

반대로 네이버와 카카오 로그인 API 는 웹 서버에 https 보안 연결이 되어 있지 않아도 로그인 기능을 정상적으로 제공 해주는 것이 가능했기 때문에 불행중 다행으로 SNS 로그인 기능 자체를 완전히 없애는 상황까지는 막을 수 있었다.

SNS 로그인 기능은 일반적인 회원가입 보다 훨씬 간편하고 쉽게 웹 서비스를 이용할 수 있게끔 만들어줄 수 있는 수단이기에 웹 서비스를 개발해서 제공해주는 개발자들의 입장에서는 사용자에 대한 접근성에 있어서 굉장히 좋은 효율을 얻을 수 있다.

그렇기 때문에 요즘 모바일, 웹 등 인터넷 환경에서 제공되는 왠만한 서비스들은 거의 다 SNS 로그인 기능을 제공하고 있다. 이러한 이유 들 때문에 SNS 로그인을 구현해보고자 했는데 기껏 구현하는데 성공한 기능이 https 연결 실패로 인해 제대로 동작을 못하고 있는 사정이 꽤 많이 아쉽지만 서도, 취직을 해서 실무에 뛰어들기 전 미리 SNS 로그인 기능을 구현해보면서 이런 문제 상황들을 마주친 경험 만큼은 꽤나 값진것이 아닌가 생각한다.

-> 2022.03.14 기준 서버에 https 보안 연결을 성공하여 현재는 구글, 페이스북 로그인 기능 또한 정상적으로 제공된다. 

 

 

회원 탈퇴 시 연관관계로 매핑된 다른 객체 데이터들 처리 문제

회원 탈퇴 기능은 언뜻 보면 그냥 단순히 데이터베이스 에서 유저 정보를 삭제하기만 하면 되는 쉬운 작업처럼 보이나, 실상을 들여다 보면 절대 그렇지 않다.

회원 정보라는 것은 그 자체가 단독으로 존재할 수 있는 것이 아니다.

여러가지 웹 서비스를 제공하다 보면 제공하고자 하는 서비스의 기능들과 연계되어서 회원 정보 데이터가 다루어지는 경우가 많다.

그렇기 때문에 회원 정보 데이터는 거의 항상 다른 기능들을 제공하는 객체 데이터들과 연관관계로 매핑되어 있다고 생각하면 된다.

 

그런데 여기서 회원 정보만을 함부로 삭제하려고 들면 반드시 문제가 발생한다.

서로 다른 객체들끼리 연관관계가 형성된 경우 (단방향이든 양방향이든)라고 가정했을때 반드시 기본키(PK), 외래키(PK) 와 같이 서로를 참조하는 속성들이 존재하기 마련이다.

 그런데 여기서 한쪽을 일방적으로 삭제하려고 하면 데이터베이스의 참조 무결성에 위배되어 오류가 발생하게 된다.

 

* 여기서 참조 무결성 이란 간단히 말해서 서로 다른 테이블 간에 데이터 행 끼리 참조 관계가 존재하고, 이를 참조하는 참조키를 가지고 있는 데이터 행은 해당 키가 존재하는 한 삭제 될 수 없다는 것이다.

(데이터베이스의 개체(entity) 간 부모 - 자식 관계에서 자식에 해당 하는 개체(entity) 가 부모 개체의 기본키를 외래키로 하여 참조키를 포함하게 되는 데, 이 경우 부모 개체(entity) 의 데이터 행을 함부로 삭제할 수 없게 된다. - 참조 무결성에 위배됨) -> 이번 프로젝트의 경우 회원 - 소환사 데이터 관계에서 회원 개체가 부모, 소환사 데이터가 자식 관계에 해당되고 소속 팀 또한 마찬가지이다.

 

그렇기 때문에 회원 탈퇴를 하는 과정에 있어서 단순히 회원 데이터베이스에서 회원 정보를 삭제만 해서 되는 것이 아니라, 연관관계를 가지고 있는 데이터 간의 관계를 모두 끊어주어야 비로소 삭제가 가능하게 된다.

즉, 회원 정보에 소환사 데이터가 등록되어 있다면 해당 소환사 데이터를 삭제해주고, 팀에 소속되어 있다면 팀장인지 팀원인지 판별 후 팀장일 경우 즉시 팀 해체, 팀원일 경우 즉시 팀 추방과 같이 서로 다른 객체간의 연관관계를 끊어주어야 참조 무결성 오류없이 안전하게 회원 정보를 데이터베이스에서 삭제 시키는게 가능하다는 것이다. 

 

- MemberController.java

@PostMapping("/memberSecession.do")
    public String memberSecession(){

        MemberDTO memberDTO = (MemberDTO) session.getAttribute("member");
        // 소속된 팀이 있는지 확인하고, 있을시 팀원인지, 팀장인지 확인
        // 팀원일 경우 팀 추방, 팀장일 경우 팀 해체
        if (memberDTO.getTeamName() != null){
            // 소속된 팀이 있는 경우 처리
            TeamDTO teamDTO = new TeamDTO();
            teamDTO.setTeamName(memberDTO.getTeamName());
            teamDTO = teamService.selectTeam(teamDTO);
            
            if (teamDTO.getCaptinName().equals(memberDTO.getNickname())){
                // 팀장일 경우 팀 해체 처리
                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_teamCrew = memberService.findByNickname(lineList.get(i));

                    if (memberDTO_teamCrew != null) {
                        memberDTO_teamCrew.setTeamName(null);
                        memberService.updateTeamName(memberDTO_teamCrew);
                    }
                }
                // 회원 측 연관관계 해제 이후 팀 삭제기능 동작
                teamService.deleteTeam(teamDTO);
            }else{
                // 팀원일 경우 팀 추방 처리
                TeamApplyDTO teamApplyDTO = new TeamApplyDTO();
                teamApplyDTO.setTeamName(teamDTO.getTeamName());
                teamApplyDTO.setNickname(memberDTO.getNickname());

                if (teamApplyDTO.getNickname().equals(teamDTO.getTop())){
                    teamApplyDTO.setLine("top");
                }else if (teamApplyDTO.getNickname().equals(teamDTO.getMiddle())){
                    teamApplyDTO.setLine("middle");
                }else if (teamApplyDTO.getNickname().equals(teamDTO.getJungle())){
                    teamApplyDTO.setLine("jungle");
                }else if (teamApplyDTO.getNickname().equals(teamDTO.getBottom())){
                    teamApplyDTO.setLine("bottom");
                }else if (teamApplyDTO.getNickname().equals(teamDTO.getSuppoter())){
                    teamApplyDTO.setLine("suppoter");
                }

                teamApplyDTO.setNickname(null);
                teamService.updateTeamLine(teamApplyDTO);
                
                memberDTO.setTeamName(null);
                memberService.updateTeamName(memberDTO);
            }
        }

        // 등록되어 있는 소환사 데이터가 있는지 확인후, 있을시 데이터 삭제 처리
        SummonerDTO summonerDTO = summonerService.findByUserid(memberDTO.getUserid());

        if (summonerDTO != null){
            // 탈퇴하는 회원에게 등록되어 있는 소환사 데이터가 있는 경우
            memberDTO.setSummoner_name(summonerDTO.getSummoner_name());
            memberService.deleteSummonerName(memberDTO);
        }
        memberService.deleteMember(memberDTO);
        session.removeAttribute("member");
        return "../popup/currentPasswd_popup";
    }

 

개발자가 편하면 사용자가 힘들고, 사용자가 편하면 개발자가 힘들다고 했던가.

웹 프로젝트를 하면서 화면상으로는 간단한 기능처럼 보이지만 그 내부에서는 복잡한 처리 과정을 거쳐야 한다는걸 정말 많이 체감하게 된 듯 하다.