본문 바로가기

내일배움캠프

Trello프로젝트(심화 프로젝트) 4일차

이번 포스트를 쓰기에 앞서 제목상으로는 4일차 이지만 4일차였던 월요일만 이야기하는 것이 아니라 주말간 있었던 모든 에피소드들도 함께 다루려는 것임을 알린다.

 

우선 잠시 모여서 팀 회의를 하다가 필자가 구현한 수정모드를 신기하다고 했다. 그러면서 팀장님은 이 기능을 보드 리스트 페이지에 똑같이 구현해야 겠다고 말씀을 하셨다. 그런데 나는 이걸 나한테 부탁하는 걸로 오해하고 작업을 진행해 버렸다.

결국 구현은 완성됐고 팀장님은 이걸 보더니 자기도 같은 기능을 구현했다면서 곤란해 하셨다. 이건 팀장님이 하셨던 말씀을 잘못 이해한 순전히 필자의 책임이니 팀장님이 구현한 기능을 반영하기로 하고 필자는 이 기능을 커밋하기 이전으로 브랜치를 리셋시키고 다른 작업을 진행했다.

 

회의가 끝나고 한 팀원이 자신이 드래그 앤 드롭을 구현했다고 가져왔다. 기능을 시연하면서 카드의 첫 번째 지점과 마지막지점으로는 카드를 옮길 수 없다고 하셨다. 필자는 문제 해결을 위해 코드가 실행되는 로직 부분에 문제가 있는 것 같다고 조언을 해줬다. 그게 무슨 말씀이냐고 물어보셨는데 필자는 말 그대로 서비스단에서 코드가 실행되는 논리적인 부분에서 문제가 있다고 했는데 그게 무슨말씀이냐고 어디가 문제냐고 계속 되물으면서 이해하지 못하는 모습을 보여주셨다. 

결국 구체적으로 서비스단의 메소드에 들어가는 매개변수가 잘못 입력되면서 문제가 생기거나 아니면 매개변수를 받고 실행되는 코드 어딘가가 문제라는 등 이런 수많은 가능성이 있다면서 설명을 드렸다.

 

솔직히 이때 왜 서비스단 코드에 로직이 있다는 말을 왜 이해를 못하지? 라는 답답함에 언성을 좀 높였던 것 같았다. 잘못한 건 코드 로직을 이해하지 못한 나인데 마치 난 잘못없고 내 조언을 이해못한 팀원 잘못이다. 라고 생각한 것 같았다.

그 팀원은 api를 실행할 때 매개변수로 순서를 바꿀 카드의 id를 받고 위치를 변경할 position이라는 매개변수를 받는다고 하셨다. 그러면 입력한 id와 일치하는 카드는 엔티티의 position 컬럼을 입력받은 position값으로 바꾸는 원리라고 설명을 해주셨다.

 

그 팀원분께서 처음에 순서변경 기능을 어떻게 짜야 하는지 모르겠다고 물어보셨을 때 필자가 짠 코드랑 필자가 참고한 사이트를 알려줬으니까 똑같은 로직으로 돌아갈 거라고 착각을 했던 것이다. 잘못 이해한 것은 필자인데 필자가 괜히 성냈던 것이었다. 제가 로직을 몰라서 엉뚱한 말을 했다면서 정중히 사과를 드리고 버그가 수정된 코드를 주시면서 기존에 구현했던 kanbanboard에 어떻게 적용해야 할지 모르겠다고 하셔서 필자가 코드를 이식하는 것을 도왔다.

 

드래그 앤 드롭의 원리는 이랬다.

먼저 카드를 마우스로 드래그한 html태그의 data-id라는 값을 가져오고 마우스를 놓은 지점의 html태그의 순서를 가져온다. 컬럼을 표현하는 div태그 안에 여러개의 카드를 표현하는 태그들의 리스트가 있을 거고 그 리스트 안에서의 순서를 가져오는 것이다.

두 가지의 값을 파라미터로 카드 위치를 변경하는 api를 호출하고 새로고침을 한다.

간단히 설명하면 이런 원리이다.

var boardContainer = document.querySelector('.board');
// Event listener for drop operation
boardContainer.addEventListener('dragover', function (event) {
event.preventDefault();
});

boardContainer.addEventListener('drop', function (event) {
event.preventDefault();
var taskId = event.dataTransfer.getData('text/plain');
var targetTask = event.target.closest('.task');
console.log(taskId);
console.log(targetTask);
if (targetTask) {
  var newPosition = Array.from(targetTask.parentNode.children).indexOf(targetTask);
  // var newPosition = targetTask.getAttribute("data-id"); // 1-based index
  console.log(newPosition);
  reorderCardApi(taskId, newPosition + 1);
}
});

그래서 필자는 이 코드만 붙여넣으면 드래그 앤 드롭이 가능할 거라고 생각했다. 하지만 드래그 하고 마우스를 놓아도 아무런 반응이 없었다. 그래서 위 코드의 taskId 값과 targetTask 가져오려고 했는데 targetTask는 잘 가져오는데 taskId는 가져오지 못했다.

원인이 뭔지 찾기 위해 원본 코드에서 내가 놓친 부분이 있는지 살펴보았다.

cardElement.addEventListener('dragstart', function (event) {
event.dataTransfer.setData('text/plain', card.id.toString());
})

자세히 보니 카드의 id를 지정하는 코드가 있는걸 놓친 거였다.

var cardElement = document.getElementById(`card${card.id}`);
cardElement.addEventListener('dragstart', function (event) {
event.dataTransfer.setData('text/plain', card.id.toString());
})

그래서 필자는 다음과 같은 코드를 카드를 생성하는 코드 바로 아래에 넣어줬고 실행을 했다.

그랬더니 id값과 순번을 잘 받고 api를 호출했다. 그런데 순서 변경이 의도한대로 동작하지 않았다. 그래서 targetTask의 순번을 출력하려고 newPosition을 출력해 봤는데 내가 예상한 값이랑 전혀 상관없는 엉뚱한 값이 나왔다.

 

<button id="reorder-btn5" class="reorder-card-btn" 
onclick="toggleReorderCardModule(5)" style="display: none">reorder</button>
<div class="task" draggable="false" data-id="5" id="card5">
    <a class="card" id="read-href5" href="/view/read/card/5">
      <div id="card-id0" style="display: none">5</div>
      <div class="task-title">카드3</div>
      <div class="task-description">내용3</div>
      <div class="task-assignee">qwer1234</div>
    </a>
</div>
<div id="reorder-card-module5" style="display: none">
    <input type="text" id="card-new-position5">
    <button onclick="reorderCardApi(5)">앞으로 이동</button>
</div>

그 이유는 위 코드처럼 reorder버튼과 카드, 순서이동 모듈이 다 따로 index값을 계산해서 리턴했기 때문이다. (버튼은 0, 카드는1, 순서이동 모듈은 2, ...) 그래서 저 코드들을 하나의 div태그로 묶어줘야 했다.

<div>
    <button id="reorder-btn5" class="reorder-card-btn" 
    onclick="toggleReorderCardModule(5)" style="display: none">reorder</button>
    <div class="task" draggable="false" data-id="5" id="card5">
        <a class="card" id="read-href5" href="/view/read/card/5">
          <div id="card-id0" style="display: none">5</div>
          <div class="task-title">카드3</div>
          <div class="task-description">내용3</div>
          <div class="task-assignee">qwer1234</div>
        </a>
    </div>
    <div id="reorder-card-module5" style="display: none">
        <input type="text" id="card-new-position5">
        <button onclick="reorderCardApi(5)">앞으로 이동</button>
    </div>
</div>

이렇게 묶어주고 다시 테스트를 했는데 이번엔 아예 순서가 반환되지 않았다. 

var newPosition = Array.from(targetTask.parentNode.children).indexOf(targetTask);

아마 이부분이 문제일 거라고 생각하고 이 곳을 수정하면 해결될 것이라고 예상했다.

원본 코드에서는 reorder 버튼과 순서이동 모듈이 없어서 저렇게 해도 잘 동작했지만 여기에는 reorder 버튼과 순서이동 모듈 때문에 div태그로 감싸줘야 했고 그에 따라 html 코드구조가 변해서 위의 코드도 바꿔줘야 했다.

<div class="cards" id="columns2">
    <div>
      <button id="reorder-btn5" class="reorder-card-btn" onclick="toggleReorderCardModule(5)" style="display: none">reorder</button>
      <div class="task" draggable="false" data-id="5" id="card5">
        <a class="card" id="read-href5" href="/view/read/card/5">
          <div id="card-id0" style="display: none">5</div>
          <div class="task-title">카드3</div>
          <div class="task-description">내용3</div>
        </a>
      </div>
      <div id="reorder-card-module5" style="display: none"><input type="text" id="card-new-position5"><button onclick="reorderCardApi(5)">앞으로 이동</button></div>
    </div>
    ...
</div>

원본에서는 task라는 class가 붙은 태그를 가져오면 그 태그가 저장된 리스트의 index값만 가져오면 문제없이 가져올 수 있었다. 하지만 여기서는 div태그가 한번 더 감싸졌기 때문에 task 태그의 부모 태그가 저장된 리스트의 index값을 가져와야 했다. 다시말해 "<div class="cards" id="columns2">"에서 "<div class="task" draggable="false" data-id="5" id="card5"></div>"가 아닌 "<div></div>"태그의 순서를 가져와야 했다.

 

위의 targetTask.parentNode.children을 보면 task태그의 부모 태그를 가져오고 그 태그의 자식을 가져오면 리스트를 반환하는 것 같았다. 여기서는 task태그의 부모의 부모태그를 가져오고 그 부모태그의 리스트를 가져오면 되는 것 같았다.

var newPosition = Array.from(targetTask.parentNode).indexOf(targetTask.parentNode);

그래서 이렇게 쓰면 버튼, 카드 등등을 묶은 div태그를 가져오고 가져온 값의 index를 가져오면 될 거라고 생각는데 내 생각이 틀렸었다. 

내 생각이 틀린 이유를 찾기 위해 parentNode의 기능과 children의 기능을 살펴보았다. 

parentNode는 잘은 모르겠지만 부모 태그 한개를 리턴하는 것 같았고 children은 태그 안에 포함된 태그들을 컬렉션 형태로 반환하는 것 같았다. 그래서 Array.from(targetTask.parentNode.children)의 파라미터가 children으로 끝나는 것도 children을 가져와야만 리스트 형태로 가져올 수 있었던 것 같았다. 

var newPosition = Array.from(targetTask.parentNode.parentNode.children).indexOf(targetTask.parentNode);

그래서 필자는 parentNode를 두개 중첩시켜서 <div class="cards" id="columns2">를 가져오고 children을 써서 하위 태그인 <div>태그들을 리스트로 가져왔다. 가져올 index값으로 task 태그의 부모 노드를 가져오고 그 노드의 index를 가져오게 했더니 이번에는 의도한 대로 순서를 가져왔다.

이렇게 코드를 바꾸고 다시 api를 호출하니까 의도한 대로 잘 순서가 변경되었다.

이렇게 우여곡절 끝에 순서 변경코드 이식은 끝이 났다.

 

이제 로그인 기능이 구현되었다고 하니 kanbanboard에도 회원 인증 기능을 적용시켜야 했다. 그런데 인증을 위한 jwt토큰을 어떻게 가져오는지 몰라서 팀원분들께 계속 질문을 하면서 작업을 했다.

필자가 헤멘 이유를 코드를 적용시키고 나서야 알게 됐다. 처음 로그인 뷰를 구현하신 분이 jwt토큰이 생성되면 쿠키에 저장된 채로 index뷰를 호출했었다. 그런데 spring security과 연관이 있는 authorization필터에는 헤더에서 키 이름이 'Authorization'인 밸류를 가져오게끔 했던 것이다. 이것 때문에 백엔드 단에서는 토큰을 가져올 방법이 없었고 프론트엔드 단에서 쿠키의 jwt토큰을 가져온 뒤 rest api를 호출할 때 수동으로 헤더에 토큰을 입력해서 호출하는 방법을 썼어야 했다.

 

그리고 필터가 당연히 제 기능을 못했기 때문에 서비스 단에서 토큰을 검증하고 권한이 없으면 예외를 발생시키는 로직까지 추가해야 했다.

이런 번거로움 때문에 인증기능 구현 난이도가 쉽지 않았다.

$(document).ready(function () {
      const token = Cookies.get('Authorization');
      console.log(token);
      if (!token) {
        window.location.href = 'http://localhost:8080/'; // 토큰이 없으면 로그인 페이지로 리다이렉트
        return;
      }
      const parseJwt = (token) => {
        if (token.startsWith('Bearer ')) {
          // Remove 'Bearer ' from the token
          token = token.slice(7); // Slice off the first 7 characters (length of 'Bearer ')
        }
        var base64Url = token.split('.')[1];
        var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
        var jsonPayload = decodeURIComponent(atob(base64).split('').map(function (c) {
          return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
        }).join(''));

        return JSON.parse(jsonPayload);
      };

      const decodedToken = parseJwt(token);
 $.ajax({
        type: 'GET',
        url: `/api/boards/[[${id}]]`,
        headers: {
          'Authorization': token
        },
        success: function (response) {
          result = {
            'columnsList': response.data.columnsList
          }
          addHTML(result);
        }
      })

위 코드는 프론트엔드 뷰에서 쿠키에서 토큰을 가져오고 restapi를 호출할 때 헤더에 토큰을 추가해서 요청하는 자바스크립트 코드이다.

 

 public ColumnsResponseDto createColumns(Long boardId, ColumnsRequestDto requestDto, User user) {
    Board board = boardRepository.findById(boardId).orElseThrow(() -> new CustomException(ErrorEnum.BOARD_NOT_FOUND));
    Long maxOrderNum = columnsRepository.findMaxOrderNum().orElse(0L);
    Columns columns = Columns.builder()
            .board(board)
            .category(CategoryEnum.valueOf(requestDto.getCategory()))
            .orderNum(maxOrderNum + 1L)
            .build();
    columns.checkUser(user);
    columnsRepository.save(columns);
    return new ColumnsResponseDto(columns.getCategory());
}

위 코드는 서비스 단에서 컬럼을 만들 때 엔티티로 토큰의 user 객체의 권한을 검증하는 메소드를 호출하는 코드이다.

public boolean checkUser(User user) {
    if (!user.getRole().equals(Role.MANAGER)) {
        throw new IllegalArgumentException("해당 컬럼에 접근 권한이 없는 유저입니다.");
    } return true;
}

이런 권한 인증 코드를 엔티티로 넘겨서 처리해야 했다.

 

이렇게 비효율적인 로직이 완성된 이유는 여러가지 요인들이 복합적으로 작용해서 이렇게 된 것 같다.

팀원 간의 소통이 부족해서 이렇게 인증 로직이 꼬인 것도 있고 뷰를 thymeleaf 템플릿을 사용해서 표현한 것도 있고 여러가지 요인이 있는 것 같다.

특히 thymeleaf같은 ssr렌더링 방식을 사용하는 프론트 엔진에서는 헤더에 값을 추가해서 뷰를 호출할 방법이 없어서 인증/인가 구현이 번거롭다고 한다. react.js같은 csr방식의 프론트 엔진은 뷰를 호출할 때 손쉽게 헤더에 원하는 밸류를 넣어서 호출이 가능하다고 한다.

 

이렇게 인증/인가도 구현한 후 구현한 기능은 회원별로 작성한 카드 리스트 조회였다.

일단 회원별 카드 리스트 기능을 구현하기 전에 등록된 회원을 보여주는 드롭다운 메뉴를 구현해야 했다.

이런 느낌으로 구현하려고 했다. 저 드롭다운 메뉴는 다른 팀원이 구현을 해주셨다. 그런데 이제 구현한 기능을 저 kanbanboard뷰에 적용시킬 방법을 모르겠다고 하셔서 필자가 드롭다운 메뉴를 이식하기로 했다.

코드를

 $.ajax({
    type: 'GET',
    url: `/api/boards/[[${id}]]/member`,
    success: function (response) {
      result = {
        'data': response.data
      }
      addInvitedUserHTML(result);
    }
  })

등록된 회원을 조회하는 코드를 $(document).ready() 함수에 넣고

function addInvitedUserHTML(result) {
  result.data.forEach(row => {
    let invitedUserTag = `<a href="#" onclick="getCardsByUsername('${row.username}')" data-member="all">${row.username}</a>`
    $('#filterDropdown').append(invitedUserTag);
  })
}

filter by team member버튼 아래에 드롭다운 리스트를 추가하는 코드를 추가했다.

$(".filter-btn").click(function () {
    $("#filterDropdown").toggleClass("show");
  });

마지막으로 버튼을 누르면 드롭다운 메뉴가 보이는 코드를 추가해줬다.

이렇게 필요한 코드를 다 추가하고 테스트 하는데 사진처럼 메뉴가 나오지 않았다. 이 문제를 해결하기 위해서는 어떤 식으로 드롭다운 메뉴가 작동하는지 알 필요가 있었다.

 

위의 버튼을 누르면 메뉴가 나오는 코드를 보면 toogleClass()라는 함수를 사용하는데 저 함수는 html태그의 class 속성 값을 바꿔주는 기능을 한다. 함수를 실행하면 css 클래스를 바꿔주고 한번 더 실행하면 원상태로 되돌리는 함수이다.

<div id="filterDropdown" class="filter-content show">
	<a href="#" data-member="all">All Members</a>
    <a href="#" onclick="getCardsByUsername('asdf0001')" data-member="all">asdf0001</a>
    <a href="#" onclick="getCardsByUsername('asdf0002')" data-member="all">asdf0002</a>
    <a href="#" onclick="getCardsByUsername('asdf0003')" data-member="all">asdf0003</a>
    <a href="#" onclick="getCardsByUsername('asdf0004')" data-member="all">asdf0004</a>
    <a href="#" onclick="getCardsByUsername('asdf0005')" data-member="all">asdf0005</a>
</div>

위의 버튼의 기능은 위의 div 태그의 클래스를 filter-content show로 바꿔주고 한번 더 누르면 filter-content로 원상복구를 시켜주는 기능이었다.

  .filter-content {
    display: none;
    position: absolute;
    background-color: #f9f9f9;
    min-width: 160px;
    box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
    z-index: 1;
  }
  .filter-content a {
    color: black;
    padding: 12px 16px;
    text-decoration: none;
    display: block;
  }
  .filter-content a:hover {
    background-color: #f1f1f1;
  }

그래서 관련 css 속성을 보니 filter-content 클래스와 a태그에 적용되는 filter-content 클래스 두 종류가 정의되어 있었다. 그런데 show라는 클래스는 정의되어 있지 않았다.

그래서 팀원이 작업하신 원본 뷰 파일과 비교를 해봤는데 예상대로 show클래스가 없어서 버튼을 눌러도 div태그 안에 있는 a태그들이 보이지 않았던 것이었다.

.filter-content {
    display: none;
    position: absolute;
    background-color: #f9f9f9;
    min-width: 160px;
    box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
    z-index: 1;
  }
  .filter-content a {
    color: black;
    padding: 12px 16px;
    text-decoration: none;
    display: block;
  }
  .filter-content a:hover {
    background-color: #f1f1f1;
  }
  .show {
    display: block;
  }

 그래서 show클래스 까지 넣고 테스트 해 보니까 사진처럼 드롭다운 메뉴가 잘 나타났다.

이제 드롭다운 메뉴 문제를 해결하니 산 넘어 산이었다. 이번에는 저 메뉴를 누르면 회원이 작성한 카드가 나타나지 않았다.

사진처럼 400에러가 나타났다. 에러의 원인을 조사하기 위해 에러 메세지를 확인해보니

msg: 
"Required request body is missing: public org.springframework.http.ResponseEntity<com.sparta.trello.common.ApiResponse<java.util.List<com.sparta.trello.card.dto.CardResponseDto>>> com.sparta.trello.card.controller.CardController.getAllCards(com.sparta.trello.card.dto.CardSearchCondDto,com.sparta.trello.auth.security.UserDetailsImpl)"
statuscode: 
400

이런 내용의 메세지가 나왔다. 대충 해석해보니 request body에서 넘겨준 파라미터를 찾지 못했다는 에러 같았다.

필자는 저 사진처럼 url을 보고 글자가 깨져서 나온줄 알고 인코딩 문제라고 짐작을 했다. 인코딩 문제를 해결하기 위해 인코딩과 관련된 온갖 코드를 넣고 테스트를 해보았는데 똑같은 에러 메세지가 계속 나왔다.

 

그러다 어떤 블로그 글을 보고 원인을 알게 됐는데(출처는 기록을 아무리 뒤져봐도 안나와서 적을 수 없다.) get메소드를 호출할 때는 파라미터를 query string으로만 전달할 수 있어서 body에서 값을 받을 수 없다는 내용의 글을 봤다. 사실 엄밀히 말하면 get메소드의 request body는 존재하지 않는게 맞다. 그래서 두가지 해결책을 고안했는데 하나는 메소드 타입을 post나 다른 타입으로 바꾸는 것이고 나머지 하나는 파라미터를 body에서 가져오는 것 대신 query string에서 가져오도록 바꾸는 것이었다.

필자는 @GetMapping에서 @PutMapping 한단어만 바꾸면 해결되는 메소드 타입을 변경하는 방법을 썼다.

이렇게 메소드 타입을 put으로 변경하고 다시 테스트를 해보니까 사진처럼 결과가 잘 나왔다.

이제 마감시간이 다가오는 관계로 기능 구현은 여기서 마무리 하기로 했다. 내일은 프로젝트가 마무리 되었기에 kpt회고를 가져볼 것이다.