velog에서 다른 분이 하신 사이드 프로젝트를 통해 numble을 접하게 되었다. 할 만한 거 있나 구경이나 해 볼까 했는데 정신 차려 보니 신청하고 있었다...
신청한 이유는 이랬다.
개발 기간은 총 2주였다.
아래는 결과물의 스크린샷이다. 크게 세 가지 화면으로 나누어 볼 수 있다.
우선 파일 구조는 아래와 같다.
주요 코드에 대한 설명은 다른 포스팅에 해 두었다.
👀 코드 설명 포스팅 보기
1. SPA 라우터 구현하기
2. REST API로 백엔드와 통신하기
본 프로젝트의 주요 로직은 단연 라우팅이라고 생각한다. SPA의 가장 큰 장점인 빠른 사용자 경험에 주요한 영향을 주는 것이 라우팅이기 때문이다.
전통적인 링크 방식을 따르면 페이지를 이동할 때마다 서버에서 새로운 파일을 불러와야 했고, 이는 매끄럽지 못한 화면 전환을 보여준다. 반면 SPA는 새로고침이 발생하지 않아 화면 깜빡임 없이 빠르게 화면을 이동한다.
라우팅은 클릭 이벤트를 감지해 실행되도록 했다. 기본적으로는 한 버튼마다 하나의 라우팅 주소를 가진다. 그러나 일부 버튼은 어떤 경로를 통해 그 페이지에 들어갔는지에 따라서도 다르게 라우팅된다. 예를 들면 수정을 통해 /edit에 들어가면 상단 뒤로가기(<)를 눌렀을 때 /가 아니라 /show로 들어간다.
라우팅이 일어나는 모든 상황을 정리해 보면 아래와 같다.
개별 포스트 페이지로 라우팅을 할 때 postId가 필요하다. 처음에는 이렇게 했다.
getPostId(){
return location.pathname.split("/")[2];
}
이유는... 로컬 환경에서 하고 있었고 주소가 localhost:4000/show/123
이었기 때문이다. 그래서 location.pathname을 구분자 "/"로 나눈 것 중에 index가 2인 것은 항상 postId였다.
그런데 배포를 하면서 문제가 발생한다. 배포된 주소는 https://funny-seahorse-6263d1.netlify.app
였다. 구분자 "/"로 나눈 것 중에 index가 2인 것이 postId가 아니게 된다...
어떻게 하면 확실하게 postid를 가져올 수 있을까 고민해 보다가, getPostId()를 이렇게 고쳤다.
getPostId(){
const lastidx = location.pathname.lastIndexOf("/");
const locateTo = location.pathname.slice(lastidx+1, location.pathname.length);
return locateTo;
}
앞에 몇 개의 슬래시가 있는지 상관없이, 맨 뒤에서부터 슬래시를 찾는다. 그리고 맨 뒤의 슬래시 index로부터 맨 끝까지를 잘라온다.
하지만 이 경우에도 문제가 있었다. 게시글 업로드 창의 url은 https://funny-seahorse-6263d1.netlify.app/edit
일텐데... 그럼 이 경우에는 getPostId()를 했을 때 "edit"을 반환하게 된다. 여기서는 항상 숫자만을 반환해야 한다.
숫자인지 검사를 위해 정규식을 추가해주었다. 아래가 최종 코드이다.
getPostId(){
const lastidx = location.pathname.lastIndexOf("/");
const locateTo = location.pathname.slice(lastidx+1, location.pathname.length);
const numcheck = /^\d+$/;
if (numcheck.test(locateTo)) return locateTo;
}
새 포스트를 작성하는 페이지에서...
만약 처음 불러온 그림이 마음에 안 들면 어떻게 행동할까?
그림을 클릭해보지 않을까?
그래서 챌린지 요구 사항은 아니었지만, /edit 에서 이미지를 클릭하면 새 이미지를 받아올 수 있도록 구현했다. 사실 그렇게 어렵지 않았다. 그냥 이미지를 받아오는 getImage()를 초기 렌더링할 때와, 클릭이벤트를 감지했을 때 실행해주면 되었다.
async getImage(clicked){
const res = await fetch(UNSPLASH_URL);
const data = await res.json();
const pic = data.urls.raw;
IMAGEURL = pic;
if (clicked){
const imgdiv = document.getElementById("EditImage");
imgdiv.innerHTML=`<img id="EditImageChange" src=${IMAGEURL}></img>`
}
}
초기 렌더링할 때에는 파라미터가 들어오지 않게 했고, 클릭이벤트가 감지되면 파라미터가 "clicked"로 들어오게 했다. 그래서 클릭이벤트가 있으면 이미지를 감싸는 div 안을 새로 받아온 이미지 url로 고쳐 작성해 주었다.
라이브러리는 server.js를 위한 express를 제외하고는 아무것도 사용하지 않았다. 진정한 바닐라js를 맛보고 싶었고... 좀 더 솔직하게 말하면 왕초보라 무슨 라이브러리를 쓰는 게 좋을지 감이 전혀 오지 않아서 아무것도 안 썼다.
🎇 정규표현식, 전역변수 및 생성자로 해결!
팀원 중 한 분이 코드 리뷰 중 어려움을 겪었던 파트로 말씀하셨는데, 당시에는 해결하지 못했다. 문제는 두 가지였다.
http://myurl/에서 클릭한 postid가 123이면, http://myurl/show/123로 라우팅해야 했다. 이는 정규표현식을 통해 해결했다.
원래는 라우팅할 path 주소를 string으로 넘기고, 일치 여부를 검사했다.
const router = async()=>{
const routes=[
{path:"/", view: List},
{path: "/edit", view: Edit},
{path: "/show", view: Show},
];
const potentialMatches = routes.map(route=>{
return {
route: route,
isMatch: route.path === location.pathname;
}
});
//후략
하지만 라우팅할 path 주소를 정규식으로 넘기고, test() 메소드를 통해 통과여부를 검사했다.
const router = async()=>{
const routes=[
{path:/^\/$/, view: List},
{path: /^\/edit\/?[0-9]*$/, view: Edit},
{path: /^\/show\/[0-9]+$/, view: Show},
];
const potentialMatches = routes.map(route=>{
return {
route: route,
isMatch: route.path.test(location.pathname)
}
});
http://myurl/show/123에서, postid 123에 맞는 포스트를 불러와야 했다. 이는 전역변수와 생성자를 통해 해결했다.
Show.js에서, POSTID
를 전역변수로 선언했다. 그리고 생성자에서 getPostId()로 url의 postId를 가져왔다.
// 전략
let POSTID = null;
export default class Show extends AbstractView{
constructor(){
// 중략
POSTID = this.getPostId();
}
// 후략
getPostId는 개별 페이지를 보여줄 때(Show.js)와 수정할 때(Edit.js) 모두 필요해서, 이 둘의 부모 클래스인 AbstractView.js에 선언해 주었다.
getPostId 자체에 대한 설명은 "개별 포스트 postId를 어떻게 가져오지?"에 있다.
getPostId(){
const lastidx = location.pathname.lastIndexOf("/");
const locateTo = location.pathname.slice(lastidx+1, location.pathname.length);
const numcheck = /^\d+$/;
if (numcheck.test(locateTo)) return locateTo;
}
🎇 addEventListener는 함수 바깥에 쓰기!!
모든 클릭 이벤트를 addEventListener로 처리해 주었는데, 클릭할 때마다 이벤트가 하나씩 증식하는 버그를 겪었다. 그러니까 새 포스트를 추가하려고 "등록하기"를 눌렀는데, 포스트 등록 후에 '중복된 포스트는 입력할 수 없다'는 에러가 뜨는 것이다... 그것도 클릭할 때마다 한 개씩 더...
무슨 연유인지 생각해보다가 onclick과 addEventListener의 차이에 대해 공부했던 게 생각났다.
addEventListener는 말 그대로 이벤트 리스너를 추가하는 것이다. 그런데 나는 모든 함수 실행마다 addEventListener를 써버리면서 이벤트 리스너를 추가하고 있었던 것이다.
폴더 구조가 이런 식이다.
/views 안의 js 파일들은 클래스로 작성되어 있다. 예컨대 Edit.js에는 새 포스트를 추가하는 addPost(), html 코드를 리턴하는 getHttml() 메소드가 있다.
index.js 파일은 라우팅을 담당한다. 라우팅 후에 /views 안의 클래스를 객체로 생성하고 getHtml()을 통해 html을 화면에 보여준다.
처음에는 getHtml() 안에다가 addEventListener를 집어넣고 클릭 시 addPost()를 실행하게 했다. 즉, 해당 페이지로 라우팅될 때마다 이벤트리스너를 추가한 것이다. 수정 페이지를 5번 방문하면 이벤트리스너 5개가 이빨을 드러내고 호시탐탐 클릭이벤트를 기다리게 된다. 그러다가 클릭을 하면 addPost()가 5번 실행된다...! 그러니까 중복이라는 에러를 4번 뱉어내는 거고...
그래서 모든 addEventListener를 index.js에 넣어줬다. 아래와 같이 DOM 로드가 완료되면 이벤트 리스너를 하나만 대기시키고 끝날 수 있게 했다.
// 전략
document.addEventListener("DOMContentLoaded",()=>{
router();
document.body.addEventListener("click", async(e)=>{
if (e.target.id==="EditAddBtn") {
if (edit.getPostId()) await edit.editPost();
else await edit.addPost();
// 중략
if (e.target.matches("[data-link]")){
navigateTo(e.target.dataset.link);
// 후략
클릭 이벤트는 순서에 맞게 비동기적으로 작동하게 했다. 만약 새로운 포스트를 추가할 때 서버에 포스트를 다 보내기 전에 새 페이지로 라우팅되면? 그건 편지가 도착하기 전에 우편함을 열어서 보여주는 것이다. 당연히 편지가 없다. 즉, 전체 포스트를 보는 페이지에 새로운 포스트가 아직 없는 기존의 서버 데이터가 전달되기 때문에, 새로운 포스트를 볼 수 없다.
그래서 await edit.addPost()가 완료될 때까지 기다렸다가, 새 페이지로 라우팅하는 navigateTo()를 실행할 수 있게 해 주었다.
🎇 css로 숨겨두고, 삭제 버튼 클릭시 서버에서 삭제 후에 라우팅 (비동기)!
개별 게시물을 보여주는 페이지에서 포스트 삭제 시 "정말 삭제하시겠습니까?" 하는 경고창을 띄워주는 것을 하고 싶었다. 마무리 단계에서 가볍게 추가해줘야징~ 했는데 고려할 게 생각보다 많아서 애먹었던 기억이 있다.
먼저, 삭제 경고창은 새로운 클래스로 만들어서 또 import 해오기에는 너무 작은 것 같아서 개별 포스트 페이지(views/Show.js)에 추가하기로 했다. createElement
로 넣기엔 또 조금 많은 것 같아서, html 최하단에 미리 작성하고 css에서 display: "none"
으로 숨겨두었다.
포스트 하단 삭제 버튼을 누르면, 일단 이 요소가 보일 수 있도록 하는 함수를 실행시켜서 element.style.display="block";
로 css를 바꿔주었다. 그러면 경고창이 보이게 된다.
취소하면 그냥 다시 element.style.display="none";
로 css를 바꿔서 숨겨두었다.
삭제 함수 또한 역시 비동기로 작성되어야 한다. 그래야 해당 포스트가 삭제된 뒤에 전체 포스트를 불러와서, 사라진 포스트를 보여주는 버그를 방지할 수 있다.
바닐라js? ㅋㅋ 껌이겠지~ 했다가 생각보다 어려웠다. 특히 배포할때 고생했다... aws로도 해보고 gh-pages도 해보고 하다가 몽땅 실패했는데, 넘블 디스코드 채널에서 도움을 받아 netlify에서 성공했다!!
그리고 넘블 쪽에서 팀빌딩을 해주셨는데 팀과 교류하는 것도 큰 도움이 됐다. 팀장분께서 첫 모임때 노션 페이지를 뚝딱 만들어오셔서 깜짝 놀랐다. 엄청 든든했다...!!! 코드리뷰 때도 팀원분들이 이것저것 물어봐 주셔서 답변해보고 수정해보는 과정에서 공부가 많이 됐다.
챌린지 요구사항에 맞추다 보니 혼자 할 때 생각도 해 보지 못한 부분을 구현해 볼 수 있었다. 그리고 전에 동아리 면접 갔을 때 백엔드 api 데이터를 받아와 보신 적이 있냐고 물어봤을 때 무슨 말인지 몰라서 아무말이나 했는데, 이제는 자신있게 네! 할 수 있을 것 같다.
다만 아쉬운 점은, 좀더 실용적인 사이트를 만들지 못한 점이다. 결과물을 친구들한테 자랑하고 싶었는데 배운 걸 바탕으로 정말 다른 사람들이 이용할 수 있는 서비스를 만들어 보고 싶다! 좀더 레벨업해서 다른 챌린지도 참가해 보고 싶다.
그리고 다른 분들 회고록을 보니 번들러를 사용하셨다... 번들러에 대해서도 알아보고 포스팅해야겠다.