파이널 프로젝트의 주제는 협업툴이다. 우리 조는 나를 포함하여 총 네 명이었는데, 나는 프로젝트, 프로젝트 통계, 업무 피드백, 채팅을 구현하기로 했다.
설계를 하고 선생님께 검사를 맡으며 두근거리던 감정이 아직도 선하다.
그리고 신나게 나눴지만… 나중에 반정규화하고 싶었다.
Safari라는 프로젝트명은 ‘우리 넷 다 맥북 쓰니까 사파리 해!’로 3초만에 지어졌다. 심지어 이 시점엔 프로젝트명도 아니고 팀명이었다. 솔직하게는 ‘이렇게 지어도 되는거야?’라고 생각했지만… 자기도 고유명사라고 이제는 Safari가 아닌 다른 이름은 상상하기 어렵게 되었다.
사파리의 주요기능 흐름은 다음과 같다.
- 로그인을 한 유저는 워크스페이스에 진입한다.
- 그곳엔 해당 워크스페이스에 속한 프로젝트 목록이 있다.
- 프로젝트를 클릭하면 해당 프로젝트의 업무 리스트와 스케줄 탭을 확인할 수 있다.
이 중 내가 맡은 프로젝트 기능에서 하나의 프로젝트를 추가하기 위해선 프로젝트 이름, 시작일, 마감일, 프로젝트 관리자, 프로젝트 멤버 등의 정보가 필요하다. 프로젝트 최초 생성 시 만든이가 프로젝트 관리자로 등록되고 만든이는 워크스페이스에 속한 이들 중 프로젝트 멤버를 선택할 수 있다. 결론적으로 CRUD 중 Create은 꽤나 순조로웠다. 그런데……
이 시점에서 내가 만들어야 했던 로직은 다음과 같다.
- 프로젝트 관리자와 프로젝트 멤버는 모두 복수 배정 가능하다.
- 해당 워크스페이스에 속한 멤버만이 프로젝트 관리자/멤버가 될 수 있다.
- 동일인이 프로젝트 관리자이자 멤버일 수는 없다.
결론: 모르겠다. 어떻게 하지?
조장이었던 구구는 늘 update가 빌런이라는 명언을 남긴 바 있다. 내가 맞닥뜨린 상황과 몹시 부합했다. 그러나 이것은 내가 가장 사랑하는 ‘내 머리론 안 될 것 같은데’ 상황이었다.
A, B, C가 속해있던 프로젝트의 멤버가 A, B, D로 수정되었다고 가정하자. 나는 project_member 테이블에서 C의 active 값을 N로 수정하고, D를 새로이 추가해야 한다. 이를 위해 서비스레이어에서 차집합을 구현하기로 했다.
@Transactional
@Override
public boolean modifyProject(Map<String, Object> map) {
boolean result = false;
int row = projectMapper.updateProject(map);
////// projectMember 수정 시작
log.debug(TeamColor.CSK + "projectMember 수정 시작");
// 프로젝트 멤버에 변동 사항이 없을 경우 프로젝트멤버 테이블 업데이트 X
if("".equals(tmp)) {
return true;
}
// 문자열로 받은 프로젝트 멤버 수정 정보를 배열로 변환
String[] tmpArr = tmp.split(",");
// tmpArr를 List로 변환 1) 리스트로 변환 2) 스트림 3) 형변환 4) 최종연산
List<Integer> newProjectMemberList = Arrays.asList(tmpArr).stream().map(Integer::parseInt).collect(Collectors.toList());
log.debug(TeamColor.CSK + "newProjectMemberList: " + newProjectMemberList);
// map에서 뽑아낸 projectNo를 가공
int projectNo = Integer.parseInt(String.valueOf(map.get("projectNo")));
// 수정 전 프로젝트 멤버리스트
List<Map<String, Object>> list = projectMemberMapper.selectProjectMemberList(projectNo);
// 대입해서 복사하면 얕은 복사 -> 복사한 객체가 변경되면 기존 객체도 변경됨 -> 깊은 복사 필요
List<Integer> deleteProjectMemberList = new ArrayList<>(); // workMemberNo만 추출하여 담을 list
List<Integer> prevProjectMemberList = new ArrayList<>(); // 깊은 복사를 위한 list
for(Map<String, Object> m : list) {
// 기존 프로젝트 멤버 리스트에서 workMemberNo만 추출,
// 메소드 실행 후 데이터 유실을 막기 위해 두 개의 리스트에 저장
deleteProjectMemberList.add((int)m.get("workMemberNo"));
prevProjectMemberList.add((int)m.get("workMemberNo"));
}
log.debug(TeamColor.CSK + "prevProjectMemberList: " + prevProjectMemberList);
deleteProjectMemberList.removeAll(newProjectMemberList); // 차집합 - 프로젝트에서 삭제된 멤버
newProjectMemberList.removeAll(prevProjectMemberList); // 차집합 - 프로젝트에 새로 추가된 멤버
log.debug(TeamColor.CSK + "삭제할 멤버: " + deleteProjectMemberList);
log.debug(TeamColor.CSK + "추가할 멤버: " + newProjectMemberList);
// vo 세팅
ProjectMember projectMember = new ProjectMember();
projectMember.setProjectNo(projectNo);
// 프로젝트 멤버의 active 값을 N으로
for(int workMemberNo : deleteProjectMemberList) {
projectMember.setWorkMemberNo(workMemberNo); // 해당 멤버의 workMemberNo 세팅
projectMemberMapper.updateProjectMemberActive(projectMember);
// update project_member set active = 'N' where work_member_no = #{workMemberNo} and project_no = #{projectNo};
}
// 프로젝트에 새롭게 추가 메소드
for(int workMemberNo : newProjectMemberList) {
projectMember.setWorkMemberNo(workMemberNo); // 해당 멤버의 workMemberNo 세팅
projectMemberMapper.insertProjectMember(projectMember);
}
result = true;
return result;
고군분투의 흔적...이다. 나름대로 자랑스럽다.
그러나 차집합을 굳이 서비스 레이어에서 구할 필요가 없었다. 또한 이 시점에서 나는 프로젝트 수정을 동기 방식으로 진행하고 있었던 탓에 구성원 한 명을 수정할 때마다 새로운 멤버리스트를 받아오기 위해 리로드를 해야했다. 그건 너무나... 멋지지 않았다.
이에 차집합 구현부를 자바스크립트 메소드로 보내고, RestController를 통해 비동기 방식으로 바뀐 데이터를 받아오기로 결정하였다.
// 프로젝트 수정폼을 띄우는 ajax
$(document).ready(function(){
let prevProjectManagerArr = new Array(); // 기존 관리자들의 번호를 저장해놓을 배열
let prevProjectMemberArr = new Array(); // 기존 멤버들의 번호를 저장해놓을 배열
let projectKeep = null;
let prevProjectName = "";
$.ajax({
type : 'get',
url : '/member/restModifyProject',
data : {projectNo : $("#projectNo").val()},
success : function(json){
console.log(json);
$(json).each(function(index, item){
$('#projectName').val(item.project.projectName);
prevProjectName = item.project.projectName;
$('#projectExpl').val(item.project.projectExpl);
$('#projectAuth').val(item.project.projectAuth);
$('#date1').val(item.project.projectStart);
$('#date2').val(item.project.projectDeadline);
$('#date3').val(item.project.projectEnd);
projectKeep = item.project.projectKeep;
// 프로젝트 멤버 리스트 반복문
for(let i = 0; i < item.projectMemberList.length; i++){
if(item.projectMemberList[i].projectMemberAuth == null){
$("#projectMemberList").append("<option value='" + item.projectMemberList[i].workMemberNo + "'>" + item.projectMemberList[i].workMemberName + "</option>");
$("#projectManagerList").append("<option value='" + item.projectMemberList[i].workMemberNo + "'>" + item.projectMemberList[i].workMemberName + "</option>");
} else if(item.projectMemberList[i].projectMemberAuth == 'Y'){
$("#projectManagerList").append("<option value='" + item.projectMemberList[i].workMemberNo + "' selected>" + item.projectMemberList[i].workMemberName + "</option>");
prevProjectManagerArr.push(String(item.projectMemberList[i].workMemberNo));
} else {
$("#projectMemberList").append("<option value='" + item.projectMemberList[i].workMemberNo + "'selected>" + item.projectMemberList[i].workMemberName + "</option>");
prevProjectMemberArr.push(String(item.projectMemberList[i].workMemberNo));
}
}
// ... 중간 생략 ...
// 프로젝트 관리자
$("#projectManagerList").change(function(){
if($("#projectManagerList").val() == ""){
alert("최소 한 명의 프로젝트 관리자가 필요합니다.");
return;
}
const select = $("#projectManagerList").val();
const newManager = $(select).not(prevProjectManagerArr).get();
const deleteManager = $(prevProjectManagerArr).not(select).get();
// boolean -> true면 매니저 delete, false면 매니저 update
const inOrOut = newManager.length == 0;
console.log(inOrOut ? "delete" : "update");
$.ajax({
type : 'put',
url : '/member/modifyMember',
data : {projectNo : $("#projectNo").val(),
workMemberNo: (inOrOut) ? deleteManager[0] : newManager[0],
projectMemberAuth: "Y",
active : (inOrOut) ? "N" : "Y"},
success : function(json){
prevProjectManagerArr = new Array(); // 관리자 배열 초기화
$("#projectMemberList").empty();
$("#projectManagerList").empty();
$(json).each(function(index, item){
if(item.projectMemberAuth == null){
$("#projectMemberList").append("<option value='" + item.workMemberNo + "'>" + item.workMemberName + "</option>");
$("#projectManagerList").append("<option value='" + item.workMemberNo + "'>" + item.workMemberName + "</option>");
} else if(item.projectMemberAuth == 'Y'){
$("#projectManagerList").append("<option value='" + item.workMemberNo + "' selected>" + item.workMemberName + "</option>");
prevProjectManagerArr.push(String(item.workMemberNo));
} else {
$("#projectMemberList").append("<option value='" + item.workMemberNo + "'selected>" + item.workMemberName + "</option>");
}
})
}, // end for success call back function
error : function(error){
console.log("error!");
}
}); // end
}); // end for projectManager change
// 프로젝트 멤버 메소드도 로직 동일
})
이에 프로젝트 멤버 수정 메소드의 로직도 함께 개선하였다.
@Override
public List<Map<String, Object>> modifyProjectMember(int workNo, ProjectMember projectMember) {
// 일단 UPDATE
int row = projectMemberMapper.updateProjectMember(projectMember);
if(row == 0) {
// projectNo와 workMemberNo 조건으로 업데이트할 항목이 없을 때,
// 즉 미리 속해있지 않은 멤버라서 INSERT의 대상일 때
projectMemberMapper.insertProjectMember(projectMember);
}
return projectMemberMapper.selectPossibleProjectMemberListByWorkNoAndProjectNo(workNo, (int)projectMember.getProjectNo());
}
이미 자바스크립트로 해당 멤버의 workMemberNo와 추가/삭제 여부를 받아왔으므로, 무작정 UPDATE를 실행한 뒤 영향받은 row가 없으면 INSERT를 진행한다. Safari 설계 과정에서 우리는 프로젝트에서 프로젝트 멤버가 삭제되어도 그가 업로드한 자료를 보존하기 위해 멤버를 삭제하지 않고 active 컬럼의 값(Y/N)으로 포함 여부를 구분하기로 결정했다. UPDATE를 우선 실행하였을 때의 장점은 다음과 같다.
1) 삭제했던 멤버를 다시 추가했을 때 과거 자신이 만든 자료의 권한을 되돌려줄 수 있고,
2) 무의미한 데이터의 증가를 막을 수 있다. (이 방법이 아닐 시 만약 'A'라는 멤버가 '추가 - 삭제 - 재추가 - 삭제 - 재추가' 과정을 거친다면, 한 프로젝트의 같은 멤버에 대해 다섯 줄의 데이터가 쌓이게 된다)
워크스페이스에 속한 멤버리스트를 받아오는 SELECT문에 프로젝트에 이미 속한 멤버 리스트를 반환하는 SELECT문을 LEFT JOIN 한다. 이후 projectMemberAuth가 'Y'면 프로젝트 관리자에 <option selected>
로, 'N'이면 프로젝트 멤버에 <option selected>
로, null이면 둘 모두에 <option>
으로 append한다.
SELECT
w.work_member_no workMemberNo
, w.work_member_name workMemberName
, m.project_no projectNo
, m.project_member_auth projectMemberAuth
FROM
workspace_member w
LEFT JOIN
(SELECT
work_member_no
, project_no
, project_member_auth
FROM
project_member
WHERE
project_no = #{projectNo} AND active = 'Y') m
ON
w.work_member_no = m.work_member_no
WHERE
w.work_no = #{workNo}
AND
w.active = 'Y'
2편으로 이어집니다.