Today Sangmin Learned
article thumbnail
728x90

프로미스

자바스크립트와 노드에서는 주로 비동기를 접한다. 특히 이벤트 리스너를 사용할 때 콜백 함수를 자주 사용한다. ES2015부터는 자바스크립트와 노드의 API들이 콜백 대신 프로미스(Promise) 기반으로 재구성되며, 악명 높은 콜백 지옥 현상을 극복했다는 평가를 받고 있따. 프로미스는 반드시 알아둬야 하는 객체이므로 여기뿐만 아니라 다른 자료들을 참고해서라도 반드시 숙지해야 한다.

프로미스는 다음과 같은 규칙이 있다. 먼저 프로미스 객체를 생성해야 한다.

const condition = true; // true면 resolve, false면 reject
const promise = new Promise((resolve, reject) => {
  if (condition) {
    resolve("성공");
  } else {
    reject("실패");
  }
});
// 다른 코드가 들어갈 수 있음
promise
  .then((message) => {
    console.log(message); // 성공(resolve)한 경우 실행
  })
  .catch((error) => {
    console.error(error); // 실패(eject)한 경우 실행
  })
  .finally(() => {
    // 끝나고 무조건 실행
    console.log("무조건");
  });

new Promise로 프로미스를 생성할 수 있으며, 그 내부에 resolve와 reject를 매개변수로 갖는 콜백 함수를 넣는다. 이렇게 만든 promise 변수에 then과 catch 메소드를 붙일 수 있다. 프로미스 내부에서 resolve가 호출되면 then이 실행되고, reject가 호출되면 catch가 실행된다. finally 부분은 성공/실패 여부와 상관없이 실행된다.

resolve와 reject에 넣어준 인수는 각각 then과 catch의 매개변수에서 받을 수 있다. 즉 resolve('성공')이 호출되면 then의 message가 '성공'이 된다. 만약 reject('실패')가 호출되면 catch의 error가 '실패'가 되는 것이다. condition 변수를 false로 바꿔보면 catch에서 에러가 로깅된다.

프로미스를 쉽게 설명하자면, 실행은 바로 하되 결괏값은 나중에 받는 객체이다. 결괏값은 실행이 완료된 후 then이나 catch 메소드를 통해 받는다. 위 예제에서는 new Promise와 promise, then 사이에 다른 코드가 들어갈 수도 있다. new Promise는 바로 실행되지만, 결괏값은 then을 붙였을 때 받게 된다.

then이나 catch에서 다시 다른 then이나 catch를 붙일 수 있다. 이전 then의 return 값을 다음 then의 매개변수로 넘긴다. 프로미스를 return한 경우에는 프로미스가 수행된 후 다음 then이나 catch가 호출된다.

promise
  .then((message) => {
    return new Promise((resolve, reject) => {
      resolve(message);
    });
  })
  .then((message2) => {
    console.log(message2);
    return new Promise((resolve, reject) => {
      resolve(message2);
    });
  })
  .then((message3) => {
    console.log(message3);
  });
  .catch((error) => {
    console.log(error);
  });

처음 then에서 message를 resolve하면 다음 then에서 message2로 받을 수 있다. 여기서 다시 message2를 resolve한 것을 다음 then에서 message3으로 받았다. 단 then에서 new Promise를 return해야 다음 then에서 받을 수 있다는 것을 기억해야 한다.

이것을 활용해서 콜백을 프로미스로 바꿀 수 있다. 다음은 콜백을 쓰는 패턴 중 하나이다. 이를 프로미스로 바꿔보자.

function findAndSaveUser(Users) {
  Users.findOne({}, (err, user) => {
    // 첫번째 콜백
    if (err) {
      return console.error(err);
    }
    user.name = "zero";
    user.save((err) => {
      // 두번째 콜백
      if (err) {
        return console.error(err);
      }
      Users.findOne({ gender: "m" }, (err, user) => {
        // 생략
      });
    });
  });
}

콜백 함수가 세 번 중첩되어 있다. 콜백 함수가 나올 때마다 코드의 깊이가 깊어진다. 각 콜백 함수마다 에러도 따로 처리해줘야 한다. 이 코드를 다음과 같이 바꿀 수 있다.

function findAndSaveUser(Users) {
  Users.findOne({})
    .then((user) => {
      user.name = "zero";
      return user.save();
    })
    .then((user) => {
      return Users.findOne({ gender: "m" });
    })
    .then((user) => {
      // 생략
    })
    .catch((err) => {
      console.error(err);
    });
}

코드의 깊이가 세 단계 이상 깊어지지 않는다. 위 코드에서 then 메소드들은 순차적으로 실행된다. 콜백에서 매번 따로 처리해야 했던 에러도 마지막 catch에서 한 번에 처리할 수 있다. 하지만 모든 콜백 함수를 위와 같이 바꿀 수 있는 것은 아니다. 메소드가 프로미스 방식을 지원해야 한다.

예제의 코드는 findOne과 save 메소드가 내부적으로 프로미스 객체를 갖고 있다고 가정했기에 가능하다. (new Promise가 함수 내부에 구현되어 있어야 한다). 지원하지 않는 경우 콜백 함수를 프로미스로 바꿀 수 있는 방법은 3.5.6에 나와 있다.

프로미스 여러 개를 한 번에 실행할 수 있는 방법이 있다. 기존의 콜백 패턴이었다면 콜백을 여러 번 중첩해서 사용해야 했을 것이다. 하지만 Promise.all을 활용하면 간단히 할 수 있다.

const promise1 = Promise.resolve("성공1");
const promise2 = Promise.resolve("성공2");
Promise.all([promise1, promise2])
  .then((result) => {
    console.log(result); // ['성공1', '성공2'];
  })
  .catch((error) => {
    console.error(error);
  });

Promise.resolve는 즉시 resolve하는 프로미스를 만드는 방법이다. 비슷한 것으로 즉시 reject하는 Promise.reject도 있다. 프로미스가 여러 개 있을 때 Promise.all에 넣으면 모두 resolve될 때까지 기다렸다가 then으로 넘어간다. result 매개변수에 각각의 프로미스 결괏값이 배열로 들어 있다. Promise 중 하나라도 reject가 되면 catch로 넘어간다.

async/await

노드 7.6 버전부터 지원되는 기능이다. ES2017에서 추가되었으며, 알아두면 정말 편리한 기능이다. 특히 노드처럼 비동기 위주로 프로그래밍을 해야 할 때 도움이 많이 된다.

프로미스가 콜백 지옥을 해결했다지만, 여전히 코드가 장황하다. then과 catch가 계속 반복되기 때문이다. async/await 문법은 프로미스를 사용한 코드를 한 번 더 깔끔하게 줄인다.

2-1-7의 프로미스 코드를 다시 보자.

function findAndSaveUser(Users) {
  Users.findOne({})
    .then((user) => {
      user.name = "zero";
      return user.save();
    })
    .then((user) => {
      return Users.findOne({ gender: "m" });
    })
    .then((user) => {
      // 생략
    })
    .catch((err) => {
      console.error(err);
    });
}

콜백과 다르게 코드의 깊이가 깊어지지는 않지만, 코드는 여전히 길다. async/await 문법을 사용하면 다음과 같이 바꿀 수 있다. async function이라는 것이 추가되었다.

async fucntion findAndSaveUser(Users) {
  let user = await.Users.findOne({});
  user.name = 'zero';
  user = await user.save();
  user = await Users.findOne({gender: 'm'});
  // 생략
}

놀라울 정도로 코드가 짧아졌다. 함수 선언부를 일반 함수 대신 async function으로 교체한 후, 프로미스 앞에 await을 붙였다. 이제 함수는 해당 프로미스가 resolve될 때까지 기다린 뒤 다음 로직으로 넘어간다. 예를 들면 await Users.findOne({})이 resolve될 때까지 기다린 다음에 user 변수를 초기화하는 것이다.

위 코드는 에러를 처리하는 부분(프로미스가 reject된 경우)이 없으므로 다음과 같은 추가 작업이 필요하다.

async function findAndSaveUser(Users) {
  try {
    let user = await Users.findOne({});
    user.name = "zero";
    user = await user.save();
    user = await Users.findOne({ gender: "m" });
    // 생략
  } catch (error) {
    console.log(error);
  }
}

프론트엔드 자바스크립트

이 절에서는 여기에 나오는 예제들의 프론트엔드에 사용되는 기능들을 설명한다. HTML에서 script 태그 안에 작성하는 부분이다. 여기에선 프론트엔드를 깊게 다루지 않지만, 예제 코드의 이해를 돕기 위해 몇 가지를 소개한다.

Ajax

AJAX(Asnychronous Javascript And XML)는 비동기적 웹서비스를 개발할 때 사용하는 기법이다. 이름에 XML이 들어 있지만 꼭 XML을 사용해야 하는 것은 아니며, 요즘에는 JSON을 많이 사용한다. 쉽게 말해 페이지 이동 없이 서버에 요청을 보내고 응답을 받는 기술이다. 요청과 응답은 4.1에 설명되어 있다. 웹 사이트 중에서 페이지 전환 없이 새로운 데이터를 불러오는 사이트는 대부분 Ajax 기술을 사용하고 있다고 보면 된다.

보통 Ajax 요청은 jQuery나 axios 같은 라이브러리를 이용해서 보낸다. 브라우저에서 기본적으로 XMLHttpRequest 객체를 제공하긴 하지만, 사용 방법이 복잡하고 서버에서는 사용할 수 없으므로 여기서는 전반적으로 axios를 사용할 것이다.

프론트엔드에서 사용하려면 HTML 파일을 하나 만들고 그 안에 script 태그를 추가해야 한다. 두 번째 script 태그 안에 앞으로 나오는 프론엔드 예제 코드를 넣으면 된다.

front.html

<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
  // 여기에 예제 코드 넣기
</script>

먼저 요청의 한 종류인 GET 요청을 보내보겠다. 요청의 종류는 4.2에서 REST API를 다루며 살펴본다.

axios.get 함수의 인수로 요청을 보낼 주소를 넣으면 된다.

axios
  .get("https://www.naer.com")
  .then((result) => {
    console.log(result);
    console.log(result.data); // {}
  })
  .catch((error) => {
    console.error(error);
  });

axios.get도 내부에 new Promise가 들어 있으므로 then과 catch를 사용할 수 있다. result.data에는 서버로부터 보낸 데이터가 들어있다. 이는 개발자 도구 Console에서 확인할 수 있다.

프로미스이므로 async/await 방식으로 변경할 수 있다. 익명 함수라서 즉시 실행을 위해 코드를 소괄호로 감싸서 호출했다.

(async () => {
  try {
    const result = await axios.get("https://naver.com");
    console.log(result);
    console.log(result.data); // {}
  } catch (error) {
    console.error(error);
  }
})();

이번에는 POST 방식의 요청을 보내볼 것이다. POST 요청에서는 데이터를 서버로 보낼 수 있다.

(async () => {
  try {
    const result = await axios.post("https://sangminpark.me", {
      name: "steadily-worked",
      birth: 1996,
    });
    console.log(result);
    console.log(result.data); // {}
  } catch (error) {
    console.error(error);
  }
})();

전체적인 구조는 비슷한데 두 번째 인수로 데이터를 넣어 보내는 것이 다르다. GET 요청이면 axios.get을, POST 요청이면 axios.post를 사용한다.

다음으로 서버에 폼 데이터를 보내는 경우를 알아볼 것이다.

FormData

HTML form 태그의 데이터를 동적으로 제어할 수 있는 기능이다. 주로 Ajax와 함께 사용된다.

먼저 FormData 생성자로 formData 객체를 만든다. 다음 코드를 한 줄씩 Console 탭에 입력해보자.

const formData = new FormData();
formData.append("name", "steadily-worked");
formData.append("item", "orange");
formData.append("item", "melon");
formData.has("item"); // true
formData.has("money"); // false;
formData.get("item"); // orange
formData.getAll("item"); // ['orange', 'melon'];
formData.append("test", ["hi", "zero"]);
formData.get("test"); // hi, zero
formData.delete("test");
formData.get("test"); // null
formData.set("item", "apple");
formData.getAll("item"); // ['apple'];

생성된 객체의 append 메소드로 키-값 형식의 데이터를 저장할 수 있다. append 메소드를 여러 번 사용해서 키 하나에 여러 개의 값을 추가해도 된다. has 메소드는 주어진 키에 해당하는 값이 있는지 여부를 알린다. get 메소드는 주어진 키에 해당하는 값 하나를 가져오고, getAll 메소드는 해당하는 모든 값을 가져온다. delete 메소드는 현재 키를 제거하는 메소드고, set은 현재 키를 수정하는 메소드이다.

이제 axios로 폼 데이터를 서버에 보내면 된다.

(async () => {
  try {
    const formData = new FormData();
    formData.append("name", "steadily-worked");
    formData.append("birth", 1996);
    const result = await axios.post(
      "https://www.zerocho.com/api/post/formdata",
      formData
    );
    console.log(result);
    console.log(result.data);
  } catch (error) {
    console.error(error);
  }
})();

두 번째 인수에 데이터를 넣어 보낸다. 현재 설정된 주소는 실제로 동작하는 주소라서 결괏값을 받을 수 있다.

encodeURIComponent, decodeURIComponent

Ajax 요청을 보낼 때, http://localhost:4000/search/노드처럼 주소에 한글에 들어있는 경우가 있다. 서버 종류에 따라 다르지만 서버가 한글 주소를 이해하지 못하는 경우가 있는데, 이럴 때는 window 객체의 메소드인 encodeURIComponent 메소드를 사용한다. 노드에서도 사용할 수 있다.

한글 주소 부분만 encodeURIComponent 메소드로 감싼다.

(async () => {
  try {
    const result = await axios.get(
      `https://www.zerocho.com/api/search/${encodeURIComponent("노드")}`
    );
    console.log(result);
    console.log(result.data); // {}
  } catch (error) {
    console.error(error);
  }
})();

노드라는 한글 주소가 %EB%85%B8%EB%93%9C라는 문자열로 변환되었다.

받는 쪽에서는 decodeURIComponent를 사용하면 된다. 역시 브라우저뿐만 아니라 노드에서도 사용할 수 있다.

decodeURIComponent("%EB%85%B8%EB%93%9C"); // 노드

한글이 다시 원래 상태로 복구되었다. 이후에 나오는 예제에서 encodeURIComponentdecodeURIComponent를 쓰는 경우를 보게 될텐데, 한글을 처리하기 위한 것이라고 생각하면 된다.

2-2-4. 데이터 속성과 dataset

노드를 웹 서버로 사용하는 경우, 클라이언트(프론트엔드)와 빈번하게 데이터를 주고받게 된다. 이때 서버에서 보내준 데이터를 프론트엔드 어디에 넣어야 할지 고민하게 된다.

프론트엔드에 데이터를 내려보낼 때 첫 번째로 고려해야 할 점은 보안이다. 클라이언트를 믿지 말라는 말이 있을 정도로 프론트엔드에 민감한 데이터를 내려보내는 것은 실수이다. 비밀번호 같은 건 특히 절대 내려보내면 안 된다.

보안과 무관한 데이터들은 자유롭게 프론트엔드로 보내도 된다. 자바스크립트 변수에 저장해도 되지만 HTML5에도 HTML에 관련된 데이터를 저장하는 공식적인 방법이 있다. 바로 데이터 속성(data attribute)이다.

<ul>
  <li data-id="1" data-user-job="programmer">steadily</li>
  <li data-id="2" data-user-job="designer">steadily2</li>
  <li data-id="2" data-user-job="programmer">steadily3</li>
  <li data-id="2" data-user-job="ceo">steadily4</li>
</ul>
<script>
  console.log(document.querySelector("li").dataset);
  // {id: '1', userJob: 'programmer'}
</script>

위와 같이 HTML 태그의 속성으로 data-로 시작하는 것들을 넣는다. 이들이 데이터 속성이다. 여기서는 data-iddata-user-job을 주었다. 화면에 나타나지는 않지만 웹 어플리케이션 구동에 필요한 데이터들이다. 나중에 이 데이터들을 사용해 서버에 요청을 보내게 된다.

데이터 속성의 장점은 자바스크립트로 쉽게 접근할 수 있다는 점이다. script 태그를 보면 dataset 속성을 통해 첫 번째 li 태그의 데이터 속성에 접근하고 있다. 단, 데이터 속성 이름이 조금씩 변형되었다. 앞의 data- 접두어는 사라지고 - 뒤에 위치한 글자는 대문자가 된다. data-idid, data-user-jobuserJob이 되는 것이다.

반대로 dataset에 데이터를 넣어도 HTML 태그에 반영된다. dataset.monthSalary = 10000;을 넣으면 data-month-salary="10000"이라는 속성이 생긴다.

나중에 실습 시 데이터 속성을 자주 쓰게 되므로 기억해두자.

profile

Today Sangmin Learned

@steadily-worked

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!