Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.

Ajax를 사용할 때 웹브라우저 "뒤로 가기"의 구현

전에 블로그 글 주제를 요청받기로 하고 올라온 이슈 중에 Ajax를 사용할 때 "뒤로 가기"에 대한 요청이 있었다. 난이도가 아주 높은 건 아니므로 재밌겠다는 생각이 들었다.

일단 최근에는 Ajax를 쓸 때 페이지 뒤로 가기를 직접 구현해 본 적이 없다. 나는 "문서로서의 웹"이라는 기본 개념을 중시하기 때문에 Ajax를 본문 중심으로 헤비하게 쓰지 않는 편이고 SPA(Single Page Application)을 많이 선호하진 않지만, SPA를 하더라도 Angular.js나 React, Backbone 같은 프레임워크에서 제공하는 라우팅을 썼기 때문에 직접 구현해 본 적은 없다. 이 글은 내가 아는 지식 선에서 Ajax를 사용할 때의 "뒤로 가기"에 대해서 정리한 글이지만 그사이에 뭔가 새로운 방법이나 접근이 있을 수도 있는데 그런 경우 알려주면 좋겠다.

글 목록을 보여주는 방식

해당 이슈를 보면 "'페이스북'처럼 목록이 로딩된 상태 (페이징 처리X) 에서 뒤로 가기 처리하는 방법"이라고 나와 있는데 내 지식 범위에서 목록에 대한 관리는 2가지로 나뉜다. 그래서 이해를 도우려면 이 2가지를 먼저 구분하고 설명하는 게 더 좋겠다.

전통적인 방식의 게시판 같은 것을 생각해 보자. 게시판의 글 목록이 화면에 나오고 다음 페이지로 이동하면 글 목록만 Ajax로 불러오게 된다. 이 경우 URL 등으로 정해진 각 페이지 번호에 따라 보여야 하는 글의 목록이 정해져 있다. 물론 새로운 글이 올라오면 목록이 달라지겠지만, 프로그램 관점에서는 불러와야 하는 글의 목록이 정해져 있다.

대신 페이스북이나 트위터 같은 경우의 목록을 생각하면 무한스크롤이다. 페이지별로 나오는 글 목록이 정해져 있는 것이 아니라 URL이 달라지지 않은 상태에서 무한 스크롤을 하게 된다. 그래서 목록의 URL을 복사해서 다른 사람에게 공유한다고 해서 같은 화면을 보지 않는다.(위에서 설명한 상황에서는 같은 주소는 같은 글 목록을 본다.) 이런건 웹사이트의 요구사항에 따라 달라지지만 보통은 URL에 따라서 보여주는 글의 목록을 정하지 않겠다는 의도이다. 그래서 페이스북이든 트위터든 글 목록에서 무한스크롤로 계속 로딩해서 보다가 특정 글을 클릭해서 이동했다가 뒤로 가기로 돌아오면 글 목록을 처음부터 다시 로딩한다.

후자의 경우는 기술적인 문제라기 보다는 서비스의 특성에 관한 것이다. 이어서 설명한 방법을 이용해서 이전에 어디까지 무한스크롤을 했는지 기억했다가 페이지를 로딩하고 사용자를 이 위치에 스크롤 시켜놓는 것은 가능하지만 나는 이 방식은 무한 스크롤과는 좀 맞지 않는다고 생각한다. 이런 상황이 있다면 구현하기 보다는 실제로 무한 스크롤이 정말 필요한가를 고민해 볼 필요가 있다고 생각한다. 그래도 요구하면 할수 없기는 하지만 여기서는 무한스크롤로 보여지는 목록이 아닌 페이징 처리가 된 상태에서 뒤로가기를 구현하는 방식에 대해서 설명하도록 하겠다. 이슈를 올려주신 분이 무한스크롤쪽을 원하는 것 같기도 한데 그렇다면 이슈를 새로 올려주시거나 추가 설명을 해주시면 좋겠다. 그리고 기술적으로 방식은 동일하기 때문에 무한스크롤에서 뒤로가기를 구현하더라도 이글에서 설명하는 방식을 먼저 이해해야 한다.

링크를 이용한 방식

링크 방식의 데모

위 방식을 보자. Ajax 등을 쓰지 않고 링크로 목록을 보여주고 페이지관리를 하는 방식이다. 이 방식이 웹의 기본 방식이고 특별한 이유가 없으면 링크로 모든 페이지를 연결하면 된다. 이렇게 하면 모든 페이지에 고유 URL이 있으므로 SEO나 소셜을 대응하는데도 아무런 문제가 없다. "다음"을 클릭하면 다음 10개의 목록을 가져오고 개별 URL을 가지므로 상세 목록에 들어갔다가 뒤로 가기를 해도 이전에 보던 페이지로 잘 돌아간다.

동작하는 예제는 여기에서 볼 수 있다.

여기서는 주소에 /link/page1처럼 되어 있으므로 1페이지에서 1~10의 글 목록을 보여주게 된다. 여기서 어떤 글 목록을 보여줄지는 모두 서버가 관리하고 있으므로 이 목록을 HTML로 만들어서 내려주고 다음 버튼에 대한 링크로 만들어서 내려준다. 브라우저는 다운받은 HTML을 그냥 보여주는 방식이고 "다음" 버튼을 눌러서 /link/page2로 이동하게 되면 서버가 11~20의 글 목록을 HTML로 만들어서 내려주게 된다. 이때는 링크방식이므로 클릭할 때마다 새로운 URL로 이동하게 되고 모든 페이지를 항상 새로 로딩한다. 항상 새로운 URL을 가지므로 "뒤로 가기"를 눌렀을 때도 아무런 문제 없이 동작한다. 이게 전통적인 방식이다.

그래서 /link/page1로 접속하면 다음과 같은 HTML 문서를 내려받게 된다.

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
  <h1>Ajax 뒤로 가기 예제</h1>
  <h3>게시글 목록</h3>
  <ul id="list">
    <li><a href="/detail/1">제목 1</a></li>
    <li><a href="/detail/2">제목 2</a></li>
    <li><a href="/detail/3">제목 3</a></li>
    <li><a href="/detail/4">제목 4</a></li>
    <li><a href="/detail/5">제목 5</a></li>
    <li><a href="/detail/6">제목 6</a></li>
    <li><a href="/detail/7">제목 7</a></li>
    <li><a href="/detail/8">제목 8</a></li>
    <li><a href="/detail/9">제목 9</a></li>
    <li><a href="/detail/10">제목 10</a></li>
  </ul>
  <a href="/link/page2" id="next">다음 </a>
</body>
</html>


Ajax를 이용한 방식

앞의 방식에서는 링크로 연결되어 있으므로 클릭할 때마다 항상 전체 페이지를 새로 로딩하게 된다. 이 데모 페이지는 목록 외에 스타일이나 다른 문서가 없으므로 문제가 될게 거의 없지만 복잡한 페이지의 경우 페이지마다 헤더나 사이드바, 푸터를 로딩하는 건 속도 저하의 영향을 준다. 그래서 등장한 게 Ajax(Asynchronous JavaScript and XML)다. Ajax는 이미 보편화한 기술이지만 약간 설명하지만 제시 제임스 가렛(Jesse James Garrett)이 만든 용어로 Ajax를 이용하면 웹 페이지에서 일부만 로딩해서 갈아치우는 것이 가능해졌고 이후 웹사이트 작성에 큰 영향을 주었다.

Ajax 방식의 데모

이 예제의 동작을 확인하려면 여기서 볼 수 있다.

이 주소는 /ajax 주소를 가지는데 다음과 같은 HTML을 내려주게 된다.

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
  <h1>Ajax 뒤로 가기 예제</h1>
  <h3>게시글 목록</h3>
  <ul id="list">
  </ul>
  <a href="/pages/2" id="next">다음 </a>
</body>
</html>

앞의 HTML과 크게 다른 점은 <ul id="list"> 부분에 내용이 안 채워져 있다는 것이다. 이는 빈 내용이 서버에서 내려오고 JavaScript로 데이터를 가져와서 채워 넣게 된다. 이 방식이 Ajax이다. 페이지가 로딩되면 자바스크립트가 실행되는데 여기서 서버에서 글 목록을 가져와서 HTML을 갈아치우게 된다. 게시글을 보여주는 부분이 <ul id="list"></ul>이고 다음 페이지를 보는 버튼이 <a href="/pages/2" id="next">다음</a>이다.

뒤에도 그렇지만 여기의 예제는 데모용으로 최적화된 코드이다. 코드의 품질보다는 동작 방식을 봐주길 바란다. 마지막 두 라인을 보면 위의 다음 버튼 #next에 링크된 주소에서 마지막 페이지 번호를 가져와서 currentPage 변수에 저장한다. 최초에 로딩되었을 때는 다음 페이지는 2페이지이지만 현재 페이지에서 보여줄 페이지는 1페이지이므로 -1을 해서 getList를 호출한다. 여기서 getList는 서버에서 목록을 가져오는 함수인데 서버에 /pages/페이지 번호로 GET 요청을 보내서 받아온 결과를 <li> 목록으로 만들어서 #list 안에 넣어주게 된다. 그리고 #next 페이지도 다음 페이지로 바꿔줘서 클릭 시 다음 목록을 다시 불러오도록 한다. $('#next').click() 부분은 클릭 이벤트를 받아서 버튼의 URL에서 페이지 번호로 다음 목록을 불러오도록 하는 것이다. #next에서 굳이 이렇게 할 필요는 없지만, JavaScript가 없는 경우 최소한의 동작을 보장하기 위해서이다. 물론 이 보장을 하기 위해서는 추가적인 코딩이 더 필요하다. 여기서는 글 목록을 내려주는(여기서는 JSON으로 내려준다.) /pages/페이지 번호의 URL이 하나 더 필요하다. /ajax에서 이 URL로 요청을 보내면 요청받은 글 목록을 JSON으로 내려주게 된다. 자바스크립트는 아래와 같다.

var getList = function(page) {
  $.getJSON('/pages/' + page, function(data) {
    var list = $.map(data.list, function(l) {
      return '<li><a href="/detail/' + l.id + '">' + l.title + '</a></li>'
    }).join('');
    $('#list').html(list);
    $('#next').attr('href', '/pages/' + (+page+1));
  });
};

$('#next').click(function(event) {
  event.preventDefault();
  var page = $(this).attr('href').slice(7);
  getList(page);
});

var currentPage = $('#next').attr('href').slice(7);
getList(--currentPage);

이 방식은 앞의 링크방식과 큰 차이점이 있다. 일단 페이지의 URL이 /ajax가 되고 이 페이지 내에서 목록만 계속 달라진다. 그래서 "다음"을 눌러서 몇 페이지로 가든지 글 제목을 클릭해서 상세페이지로 갔다가 "뒤로 가기"를 누르면 다시 1페이지로 돌아온다. 그 이유는 위 스크립트와 링크에서 보듯이 /ajax가 로딩되면 최초 1페이지를 불러오게 되어 있기 때문이다. 그래서 2, 3, 4, 5 페이지를 눌러서 봤다고 하더라도 페이지가 바뀌지 않았으므로 "뒤로 가기"를 누르면 이전 페이지로 가게 된다.

앵커를 이용한 방식

앞에서 설명한 대로 Ajax를 이용하면 전체 페이지를 매번 로딩할 필요 없이 웹페이지의 핵심 콘텐츠 부분만 가져와서 로딩할 수 있다. 그래서 속도가 빠르고 사용자 편의성도 증가하지만 웹 브라우저 입장에서는 페이지의 URL은 바뀌지 않았다. 그래서 "다음" 버튼을 눌러서 새로운 목록을 보더라도 "뒤로 가기"를 누르면 사용자의 기대와 다르게 이전 목록이 나오는 게 아니라 이전 페이지 여기서는 /ajax에 오기 전의 페이지로 이동하게 된다. 이 부분은 사용자의 기대와는 완전히 다른 것이므로 사용자 경험을 몹시 나쁘게 만든다.

그래서 이를 해결하는 방법으로 이용한 것이 앵커다. 예를 들어 babel 프로젝트의 README를 보자. 상단에 목차가 있는데 README 의 주소는 https://github.com/babel/babel/blob/7.0/README.md이지만 각 목차를 누르면 https://github.com/babel/babel/blob/7.0/README.md#faq, https://github.com/babel/babel/blob/7.0/README.md#team같은 식으로 뒤에 #faq, #team 등이 붙는다. 이를 앵커라고 하는데 문서에서 #뒤에 붙은 id를 가진 요소로 자동 이동하게 되는데 이는 서버에 요청을 보내는 것이 아니라 현재 페이지 내에서 이동만 하게 된다. 그리고 이 설명해서 중요한 부분은 브라우저가 이를 URL이 바뀐 것으로 인식하기 때문에 앵커를 이동할 때마다 "뒤로 가기"가 된다. 여러 번 앵커를 이동하더라도 "뒤로 가기"를 누르면 각 부분으로 알아서 이동하게 된다.

앵커방식의 데모

동작하는 예제는 여기서 볼 수 있고 이 페이지에 접속하면 다음과 같은 HTML을 내려주게 된다.

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
  <h1>Ajax 뒤로 가기 예제</h1>
  <h3>게시글 목록</h3>
  <ul id="list"></ul>
  <a href="/pages/2" id="next">다음 </a>
</body>
</html>

이전에 본 ajax와 거의 비슷하다. 서버가 목록을 내려주는 게 아니라 페이지가 로딩된 후 자바스크립트에서 서버에 목록을 받아와서 화면에 보여준다. 가장 크게 다른 점은 getList에서 목록을 보여준 뒤 window.location.hash = '#page' + page; 부분이다. 그래서 /hash로 접속하면 주소가 /hash#page1로 바뀌게 되고 "다음"을 누르면 /hash#page2로 주소가 달라진다. 새로운 페이지 목록을 불러올 때마다 앵커를 이용해서 주소를 바꾸게 되므로 웹 브라우저는 이를 기록하고 "뒤로 가기"가 가능하게 된다.

여기서 주의할 점은 앵커는 서버에 전달되는 값이 아니라는 것이다. /hash#page1로 접속하더라도 서버에는 /hash만 전달되고 뒤의 #page1는 브라우저가 처리하는 부분이고 뒤로 가기를 할 때도 서버에 새로 요청을 보내지 않는다. 그래서 히스토리는 남지만, 서버가 인식하는 주소는 모두 같기 때문에 각 앵커에 따라 적절한 글 목록을 보여주는 부분을 따로 구현해야 한다. 그 부분이 $(window).on('hashchange', function() {});이다. 주소에서 해시(앵커를 얘기한다)가 달라지는 이벤트를 받아서 해시가 달라시면 해시를 파싱해서 페이지 번호를 가져오고 새로운 목록을 가져오게 된다. 이렇게 하면 매번 글 목록은 요청하게 되지만 사용자로서는 "뒤로 가기"가 기대대로 동작한다. 자바스크립트는 아래와 같다.

var getList = function(page) {
  $.getJSON('/pages/' + page, function(data) {
    var list = $.map(data.list, function(l) {
      return '<li><a href="/detail/' + l.id + '">' + l.title + '</a></li>'
    }).join('');
    $('#list').html(list);
    $('#next').attr('href', '/pages/' + (+page+1));
    window.location.hash = '#page' + page;
  });
};

$('#next').click(function(event) {
  event.preventDefault();
  var page = $(this).attr('href').slice(7);
  getList(page);
});

$(window).on('hashchange', function() {
  var page = parseInt(location.hash.slice(5));
  if (!!page && currentPage !== page) {
    getList(page);
  }
});

var currentPage = $('#next').attr('href').slice(7);
if (location.hash) {
  currentPage = parseInt(location.hash.slice(5)) + 1;
}
getList(--currentPage);

동작 원리는 같지만 좀 더 주소처럼 보이려고 만든 것이 해시뱅이다. 해시뱅의 차이점은 /hash#page1를 쓰는 대신 /hash/#!/page1처럼 만들어서 좀 더 URL 주소처럼 만들었다는 것뿐이고 실제 동작은 위에 설명한 앵커방식과 같다. 예전의 트위터도 이 방식이었고 Backbone이나 Angular의 기본 라우팅(HTML5 모드가 아니라면)도 이 방식이었다. 이 방식을 사용하는 이유는 브라우저 지원 폭이 좋기 때문이다. 앵커 자체야 모든 브라우저에서 동작하지만 hashchange 이벤트를 이용하려면 IE 8 이상이어야 한다.

pushState를 이용한 방식

pushState는 ajax가 등장하고 이를 해결하기 위한 편법인 해시뱅이 유행하면서 "뒤로 가기"" 문제를 해결하기 위해 HTML5에 새로 추가된 History API의 새 메서드다. 자바스크립트를 약간 다뤄봤다면 폼 처리를 하면서 history.go(-1);이나 history.forward();같은 기능을 사용해 봤을 텐데 이 History API에 새롭게 추가된 메서드라고 보면 된다. 브라우저는 URL 혹은 앵커가 달라질 때마다 이를 리스트처럼 관리하고 있고 "뒤로 가기"나 "앞으로 가기"를 할 때 이 리스트를 기준으로 이동하게 된다. 그리고 앵커가 달라진 경우가 아니라면 URL이 바뀔 때마다 항상 서버에 요청을 보내게 된다.

하지만 pushState를 사용하면 URL을 바꾸면서 서버에 요청을 보내지 않을 수 있다.

pushState 방식의 데모

이 예제의 동작을 확인하려면 여기서 볼 수 있다. 이 페이지는 서버에서 다음과 같은 HTML을 내려준다.

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
  <h1>Ajax 뒤로 가기 예제</h1>
  <h3>게시글 목록</h3>
  <ul id="list">
    <li><a href="/detail/1">제목 1</a></li>
    <li><a href="/detail/2">제목 2</a></li>
    <li><a href="/detail/3">제목 3</a></li>
    <li><a href="/detail/4">제목 4</a></li>
    <li><a href="/detail/5">제목 5</a></li>
    <li><a href="/detail/6">제목 6</a></li>
    <li><a href="/detail/7">제목 7</a></li>
    <li><a href="/detail/8">제목 8</a></li>
    <li><a href="/detail/9">제목 9</a></li>
    <li><a href="/detail/10">제목 10</a></li>
  </ul>
  <a href="/pushstate/page2" id="next">다음 </a>
</body>
</html>

HTML을 자세히 보면 처음에 본 링크방식과 유사하게 <ul id="list"> 안에 글 목록이 내려온 것을 볼 수 있다. 이는 서버에서 받아온 HTML 이므로 글 목록은 서버에서 만들어 준 것이다. pushstate는 새롭게 추가된 API이지만 "뒤로 가기" 및 웹에 맞게 구현하려면 해야 할 작업이 좀 더 있다. "다음"을 눌렀을 때는 ajax와 똑같이 동작한다. "다음" 버튼에서 페이지 번호를 파싱해서 getList를 호출하고 서버에서 글 목록을 받아와서 <ul id="list"></ul>안에 새로운 목록을 채워주고 버튼도 다음 페이지를 가리키도록 바꿔준다. 자바스크립트는 아래와 같다.

var getList = function(page) {
  $.getJSON('/pushstate/page' + page, function(data) {
    var list = $.map(data.list, function(l) {
      return '<li><a href="/detail/' + l.id + '">' + l.title + '</a></li>'
    }).join('');
    $('#list').html(list);
    $('#next').attr('href', '/pushstate/page' + (+page+1));
    history.pushState({list: list, page: page}, 'Page '+ page, '/pushstate/page' + page);
  });
};

$('#next').click(function(event) {
  event.preventDefault();
  var page = $(this).attr('href').slice(15);
  getList(page);
});

$(window).on('popstate', function(event) {
  var data = event.originalEvent.state;
    $('#list').html(data.list);
    $('#next').attr('href', '/pushstate/page' + (+data.page+1));
});

마지막에 앵커를 바꿔주는 대신 history.pushState({list: list, page: page}, 'Page '+ page, '/pushstate/page' + page);가 있는걸 볼 수 있다. 여기서 다음 페이지가 2라면 주소는 '/pushstate/page' + page에서 '/pushstate/page2로 바뀌게 된다. 여기서 주소는 링크방식처럼 바뀌지만, 페이지를 새로 요청하지 않고 Ajax 요청만 발생한다. pushState에 전달되는 인자는 상태 객체(이는 나중에 뒤로 가기를 할 때 사용한다.), 페이지 제목, 페이지 주소가 된다. 브라우저가 관리하는 히스토리 리스트에 새로운 히스토리를 하나 추가하는 것이므로 주소와 페이지, 상태객체를 전달하게 된다.

그리고 $(window).on('popstate', function(event) {}) 부분을 통해 사용자가 "뒤로 가기"를 클릭했을 때 히스토리에서 상태가 나온 것을 탐지할 수 있다. 기본이라면 window.onpopstate로 이벤트를 받아서 event.state를 사용하지만 여기서는 jQuery를 사용했으므로 event.originalEvent.state로 앞에서 pushState에서 넣은 상태객체를 가져왔다. 앞에서 이 상태객체에 목록에 넣은 리스트와 "다음" 버튼에 넣을 페이지 번호를 넣었으므로 그대로 꺼내와서 다시 화면에 보여주도록 했다. 이렇게 하면 앵커를 이용한 방식과는 달리 "뒤로 가기"를 했을 때 서버에 Ajax 요청이 전혀 발생하지 않으면서도 예전 목록을 그대로 화면에 보여줄 수 있다.

추가로 앞의 예제와 크게 다른 점 중 하나가 해당 페이지의 주소가 /pushstate/page1인데 Ajax로 목록을 받아오는 주소도 /pushstate/page2로 완전히 같은 것을 볼 수 있다. 앞의 Ajax나 앵커를 이용한 방식이 화면이 뜬 후에 브라우저에서 목록을 가져온 방식인데 반해 pushState는 URL 자체를 바꿔버리므로 /pushstate/page1에서 "다음"을 눌러서 /pushstate/page2로 이동했다고 하더라도 브라우저의 동작을 생각하면 /pushstate/page2를 복사해서 새 창에 붙여넣으면 똑같은 목록이 나와야 한다.

그러므로 서버는 주소에 따라 해당 목록을 HTML로 내려줄 수 있어야 한다. 이는 서버에서 렌더링하는 방식과 브라우저에서 Ajax로 하는 방식이 섞여 있는 것이므로 서버에서 요청의 Content-Typetext/html이면 목록을 채운 HTML 페이지를 내려주고 application/json이면 해당 페이지의 목록만 JSON으로 내려주도록 구현한 것이다. pushState를 사용함으로써 할 일은 좀 더 복잡해졌지만 사용자가 느끼는 경험은 훨씬 자연스러워졌다. pushState는 IE 10 이상에서 사용할 수 있다.


Node.js에서 express와 jade를 이용해서 간단하게 작성한 예제이지만 전체 소스를 확인하고 싶다면 저장소를 참고하면 된다.

2017/02/28 10:02 2017/02/28 10:02

유닛테스트에 대한 생각

요즘 SNS에서 왠지 TDD 혹은 테스트에 대한 글을 많이 봤다. 나름 테스트를 열심히 작성하는 처지에서 다른 개발자의 테스트 글을 보다 보니 나도 한번 정리해봐야겠다고 생각했다.

한 6~8년 정도 전에는 TDD가 한창 뜨겁던 시절이었는데 가장 유명한 책은 역시 켄트 벡의 TDDBE인데 여기서도 꽤 많은 통찰력을 얻었지만, 실용적으로 나에게 다가오기 시작한 것은 채수원 님의 테스트 주도 개발을 읽고 나서부터였던 것 같다. 이때까지만 해도 업무환경에서 TDD는커녕 테스트 작성도 쉽지 않았던 터였다가 내가 알아서 TDD를 할 수 있는 환경에 오고 나서는 열심히 TDD를 적용하려고 노력했고 한 달 정도 노력한 뒤에 TDD를 한 달여 정도 해보고 나서....라는 글을 남겼다.

그전까지는 책이나 글로 배운 이론상의 TDD가 머릿속에 있었으면 이때부터 내가 실제로 적용해 보면서 내가 편한 부분이나 책에서는 좋다고 했는데 나는 잘 모르겠는 부분 등이 구분되기 시작했던 것 같다. 이때의 경험을 TDD를 한 달여 정도 해보고 나서....에 정리해 놓았는데 오랜만에 읽어보니 지금 읽어도 고민의 수준이 크게 달라지진 않은듯한 기분이 들어서 약간 좌절 중이다. 그리고 1년 정도 뒤에 TDD에 대한 생각을 좀 더 적은 글도 있다.

예전에는 고민했던 내용


테스트를 정말 작성해야 하는가?

이 부분은 지금은 완전히 종결된 질문이다. 이젠 테스트의 유용성 같은 건 고민하지 않고 오히려 테스트가 없으면 코드를 작성할 수 없는 지경이 이르렀다. 아주 간단한 코드가 아니라면 테스트 없이는 코드를 작성하지 않는다. 단 몇십 줄의 코드를 작성하더라도 내 코드가 의도대로 동작하는지 확인하려면 REPL에서 돌려보던 main() 메서드를 쓰던 HTTP로 찔러보든 간에 테스트가 필요한데 어차피 테스트할 거 유닛테스트로 만들어서 계속 확인하지 않을 이유가 없다.

내가 작성한 코드가 수정 후에도 계속 동작하고 이번 릴리스가 배포된 후에도 문제가 생기지 않을 거라는 보장을 하는 방법에서 테스트 코드 이상의 방법을 알지 못한다. (잘 작성되었다는 하에) 테스트코드가 있으면 CI에서 계속 돌려보면서 코드의 버그를 파악할 수 있고 배포 전에도 최소한의 안정성을 기대할 수 있다. 그러다 보니 테스트를 항상 같이 작성하게 되었고 특히 테스트가 없으면 리팩토링은 전혀 못 하는 상황까지 왔다.

내가 TDD를 잘하고 있나?

처음 TDD로 접해서 그런지 예전에는 TDD에 집착을 많이 했다.(TDD와 유닛테스트를 혼용해서 쓰기도 하는데 여기선 구분해서 쓰고 내 입장은 이젠 이 둘을 구분하는 것 자체가 큰 의미 없다고 보는 편이다) 예를 들면 테스트를 먼저 작성한다는 게 어느 정도인지? 테스트를 먼저 작성해서 Fail을 보고 코드를 작성해서 Pass를 보라는데 항상 그래야 하나? 간단한 로직이라도? 이런 걸 고민하면서 TDD를 하려고 노력하면서 내가 하는 게 TDD가 맞나? 방금 한 것은 TDD 규칙을 어긴 건가? 하는 고민을 했다.

지금은 이런 규칙을 거의 신경 쓰지 않는다. 난 내가 하는 걸 TDD라고 부르지는 않고 그냥 유닛테스트라고 부르는데 TDD의 규칙에서 벗어나기 위해서인지도 모르겠다. 내가 테스트를 작성할 때의 느낌을 생각해 보면 코드와 같이 테스트를 작성한다는 말이 정확할 것 같다. 그냥 코드를 작성하는 과정에서 테스트 코드를 작성하는 게 포함되어 있으므로 테스트가 먼저인지 나중인지는 크게 신경 쓰지 않지만, 코드를 작성하면 테스트도 다 작성되어 있다. 코드만 먼저 작성하고 나중에 테스트를 작성하는 식으로 분리되어 있지 않다.

via GIPHY



테스트를 작성해서 좋은 점


기술부채를 갚기 위한 기반

테스트를 열심히 작성하는 이유는 실제로 도움이 되기 때문이다. 위에서 말한 대로 코드를 돌려보지 않고 직접 버튼을 눌러보던 호출해 보던 테스트를 해보지 않을 수는 없다. 이런 테스트는 반복해서 하게 되는데 이를 유닛테스트로 작성해 놓으면 쉽게 코드를 돌려볼 수 있다. 코드를 작성하다 보면 자연히 기술부채가 쌓이게 마련이다. 설계를 잘못했을 수도 있고 일정에 쫓기다 보니 어설픈 코드가 들어갈 수도 있다. 아니 잘못한 게 없다고 하더라도 빠르게 기술도 달라지게 마련이고 비즈니스에 맞추다 보면 요구사항도 많이 달라져서 기술부채가 쌓이기도 한다.

이런 기술부채를 쌓지 않으려고 노력해야 하지만 노력한다고 완전히 없어지진 않는다. 완전히 없앨 수 있다면 나중에 기술부채를 쉽게 갚을 수 있는 기반을 마련해 두는 게 좋다는 게 내 생각이다. 그리고 기술부채를 쉽게 갚을 수 있는 가장 좋은 방법이 유닛테스트를 잘 작성해 놓는 것이다. 더 좋은 방법이 있다면 사용하겠지만, 아직 찾지 못했다. 테스트를 잘 작성해 놓으면 기능 추가할 때도 큰 걱정 없이 할 수 있고 리팩토링할 때도 맘 놓고 할 수 있다. 다른 곳에 영향이 없을 거라고 생각하고 수정했는데 테스트가 깨져서 다른 부분에 영향을 준 것을 발견하게 되면 정말 테스트 작성해 놓길 잘했다는 생각이 든다.

코드의 사용자 입장

테스트를 작성하면 내가 작성한 메서드나 클래스의 사용자 입장이 되는 기분이 든다. 예전에는 테스트를 어떻게 작성해야 하는지 고민하기 바빠서 이런 생각을 못 했는데 점점 익숙해질수록 코드의 사용자 입장이 되어 본다는 것은 중요하게 느껴진다. 여기서 사용자 입장이라는 것은 라이브러리를 만들어서 다른 사람에서 제공하기 때문에 필요하다기보다는 내가 혼자 작성하더라도 그 코드를 다른 코드에서 다시 불러서 사용해야 하므로 항상 사용자와 작성자 입장이 반복되게 된다. 코드를 작성할 때 필요한 기능을 고민하고 코드를 어느 정도 머릿속에서 그리면서 코드를 작성하게 되는데 뜻밖에 실제로 사용하려고 하다 보면 인터페이스 등이 사용패턴에 잘 맞지 않는 경우가 있다. 머릿속에서 한 번에 잘 그려지는 사람은 상관없겠지만 나 같은 경우에는 테스트코드를 작성하면서 이런 경험을 많이 하게 된다. 이렇게 파라미터를 받고 값을 반환하면 될 거라고 생각했는데 테스트를 작성하다 보니 생각한 형태로 사용하기 어렵다거나 다른 값이 더 필요하다거나 하는 것을 발견하게 된다.

코드 사용법의 문서화

테스트를 잘 갖춰놓으면 문서를 따로 만들지 않더라도 자연히 코드 사용법을 알려주는 문서가 된다. 오픈소스를 볼 때 문서를 보기도 하지만 문서는 최신화가 안 된 경우도 많으므로 어떻게 사용하는지 확인할 때는 테스트 코드를 보는 경우가 많다. 마찬가지로 내가 작성한 코드에서도 이 코드를 어떻게 사용하고 예외는 어떤 식으로 다루길 원하는지가 테스트코드에 자연스럽게 나타난다. 일일이 사용법 설명을 굳이 적지 않아도 해당 메서드를 사용할 때 테스트코드를 참고할 수 있다.

코드 작성 방식의 변환

정확히 예전엔 어떻게 코드를 작성했는지 다 기억나진 않지만, 예전에는 코드를 테스트(유닛테스트 말고)해봐야 하니 위에서부터 아래로 만들었다면 지금은 밑에서부터 쌓아 올리는 느낌이다. API를 구현한다거나 화면에 무슨 기능을 추가하면 API 엔트리포인트나 화면에 버튼 등을 만들고 이를 테스트해보면서 구현했던 것 같다. 코드를 작성하면서 계속 테스트해보려고... 지금은 유닛테스트로 어떤 단위의 코드든 실행해 볼 수 있으므로 모델에서 기능을 구현하면서 필요한 유틸리티 함수를 만들면서 올라가서 마지막에 API 엔트리포인트나 화면 등은 붙이는 식으로 작업할 때가 많다. 어느 쪽이 더 좋은지는 모르겠지만 나는 지금의 방식이 더 견고한 느낌이 들어서 좋고 함수의 재사용이나 오류를 어느 단계에서 처리할지가 더 명확해져서 좋다.

유닛 테스트 코드


여전히 고민하는 부분


어떤 테스트가 잘 작성한 테스트인가?

계속해서 테스트를 작성하지만 지금 테스트를 잘 작성하고 있는가는 여전히 고민인 부분이다. 어떤 코드가 좋은 코드인가를 계속 고민하듯이 어떤 테스트가 좋은 테스트인지도 아마 계속 고민해야 할 것 같다. 이건 커버리지를 얘기하는 게 아니다. 경험상 코드를 다 작성하고 나중에 테스트를 추가하는 게 아니라 코드를 작성하면서 같이 유닛테스트를 작성한다면 커버리지는 80% 안팎으로는 나온다. 커버리지는 테스트에서 빠진 부분이 있는지 확인하는 기본 지표이고 최대한 간결한 테스트코드로 문제가 생기면 발견할 수 있도록 다양한 경우를 커버하는 것이다.

예전에 테스트를 어느 정도까지 작성해야 하나요? 라는 질문에 "안심될 때까지"라는 말을 본 적이 있다. 나는 걱정이 많아서인지 안심이 잘 안 돼서 테스트를 많이 짜는 편이라 테스트코드가 좀 장황하다. 대형 오픈소스 프로젝트를 보면 이 정도로 테스트가 충분한가 싶을 정도로 테스트 코드가 간결한 경우를 많이 보게 된다. 익명의 다수가 참여하는 오픈소스를 관리하는 것과 팀에서 개발하는 프로젝트는 좀 관점이 다르긴 하지만 난 오픈소스가 가장 진보된 형태의 개발문화를 가지고 있다고 생각해서 충분히 안심할 수 있으면서 간결한 테스트를 작성하고 싶은데 아직은 그게 잘 안된다.

위에서 코드를 작성할 때 아래부터 작성해서 올라간다고 설명했는데 이럴 때 테스트가 너무 장황한가? 하는 고민을 하게 된다. "컨트롤러" - "서비스" - "모델" 로 구성되어 있다고 하면 모델부터 작성하므로 서비스 단계에서 테스트를 작성할 때는 당연히 모델 쪽의 코드가 다시 테스트가 되고 컨트롤러로 가면 더욱 그렇다. 아래 계층에서 테스트한 건 굳이 테스트하진 않지만 그럼에서 각 계층에 로직이 있으므로 이 부분을 테스트하다 보면 자연히 테스트 대상이 중첩된다. 이럴 때는 이렇게 하는 게 맞을까 하는 고민을 계속 하고 있다.

mocking

테스트를 작성할 때 mocking, stub, spy 등 다양한 기법이 있지만 여기서는 통칭 mocking이라고 하겠다. 처음 테스트를 공부할 때 "인터넷 선을 뽑아도 테스트는 돌아가야 한다"라는 얘기를 들었었는데 나는 심하게 mocking을 하는 편은 아니다. 대표적으로 데이터베이스는 로컬에 데이터베이스를 띄워서 데이터를 직접 넣었다 지웠다 하면서 테스트하고 다른 외부 자원도 가능하다면 직접 사용해서 테스트하는 편이다.

mocking을 많이 하면 테스트는 편하지만(mocking 코드를 작성하는 건 귀찮지만) 실제로는 안 돌아가는데 테스트만 통과할 가능성이 커진다. 프로덕션에 배포하면 mocking 없이 돌아가야 하므로 테스트에서 가능하면 실제와 비슷하게 만들려고 한다. 그런데도 mocking이 필요한 부분이 있는데 가장 많이 하는 것은 소셜로그인이나 외부 자원이 확인이 어려운 경우 정도이다. 소셜로그인이나 외부 API를 사용하는 경우에는 토큰 발급 등 테스트코드에서 자동화하기 쉽지 않은 부분이 있으므로 이 호출 자체를 mocking 해서 내가 작성한 코드만 확인한다. 타 서비스를 사용해서 이메일이나 SMS를 발송하는 때도 발송 자체는 외부 서비스에 의존하고 있고 발송 여부 자체를 내가 따로 확인할 수는 없으므로 mocking 해버린다. 아~ 그리고 외부자원을 사용할 때 과금이 되는 경우도 mocking을 한다. 돈이 나가면 안 되니까... ㅎ

내가 유닛테스트를 작성할 때는 실제로 코드가 어떻게 동작하는지 확인하는 용도이다. 외부 API를 사용한다면 실제로 외부 API가 어떻게 동작하는지 확인해야 하므로 코드를 작성할 때는 외부 API를 사용해서 개발한다. 그래서 난 이 코드를 그대로 남겨두고 코드 작성과 테스트가 완료되면 mocking을 하는 식으로 작성한다. 외부 API 변경이나 버그 등으로 나중에 다시 확인해 보고자 할 때는 이 mocking 코드만 주석 처리하면 큰 노력 없이 실제로 테스트해볼 수 있다.

복잡한 픽스쳐(Fixture)

픽스쳐에 다양한 과정이 포함되지만, 대표적으로는 데이터베이스가 있다. 위에 말한 대로 데이터베이스에 직접 테스트하므로 픽스쳐단계에서 필요한 사용자 생성이나 관련 정보 설정을 해주어야 하고 tear down 단계에서 다시 정리를 해주어야 한다. 테스트를 작성할수록 다양한 픽스쳐가 필요하므로 금세 많이 복잡해진다. 픽스처를 만드는 부분을 공통화하고 보기 좋게 하려고 노력하지만, 테스트에 필요한 준비를 하다 보면 쉽게 정리가 잘 안 된다. 픽스처를 아주 깔끔하게 관리하고 싶은데 이 부분은 여전히 큰 고민 중 하나이다.

I find that writing unit tests actually increases my programming speed. - Martin Fowler



2017/02/25 10:00 2017/02/25 10:00