[아이티센 부트캠프] ES6

이언덕·2026년 5월 13일

아이티센 부트캠프

목록 보기
92/115
post-thumbnail

ES6 중심 JavaScript

이 정리본은 ES6를 중심으로, 강의 흐름에서 함께 다룬 현대 JavaScript 문법까지 초보자 기준으로 다시 풀어쓴 최종본이다.
각 문법은 단순히 뜻만 외우는 것이 아니라, 왜 필요한지와 코드에서 어떻게 쓰이는지까지 연결해서 이해하는 것을 목표로 한다.


엄밀히 말하면 이 글의 모든 문법이 ES6 자체에만 포함되는 것은 아니다.
async, await, Promise.allSettled(), Promise.any(), Promise.prototype.finally(), 객체 Rest/Spread, # 비공개 프로퍼티처럼 이후 표준에서 추가된 문법도 함께 다룬다.
또 forEach(), map(), reduce(), filter()처럼 ES6 이전부터 사용되던 배열 메서드도 현대 JavaScript 코드에서 자주 함께 쓰이므로 같이 정리한다.
따라서 이 글은 ES6에만 들어 있는 문법 목록을 외우는 글이 아니라, ES6를 기준으로 현대 JavaScript에서 자주 이어지는 문법 흐름을 함께 이해하는 정리본이다.




ES6와 ECMAScript의 의미

ES6를 이해하려면 먼저 ECMAScript와 JavaScript의 관계를 구분해야 한다.
ECMAScript는 JavaScript가 따라야 하는 표준 규칙이다.
여기서 표준 규칙은 문법을 어떻게 써야 하는지, 변수와 함수 같은 기능이 어떤 방식으로 동작해야 하는지를 정해 둔 기준이다.


JavaScript는 실제로 우리가 웹 페이지나 프로그램에서 사용하는 언어이다.
버튼을 눌렀을 때 동작하게 만들고, 화면 내용을 바꾸고, 서버에서 데이터를 받아 처리할 때 사용한다.
이때 JavaScript가 사용하는 문법과 동작 방식의 기준이 바로 ECMAScript이다.


ECMAScript는 표준이고, JavaScript는 그 표준을 바탕으로 실제로 사용하는 언어이다.
ECMAScript는 JavaScript의 기준이 되는 표준 문법이고, ES6는 그 표준 중 현대적인 JavaScript 문법을 이해할 때 중요한 기준이 되는 버전이다.


ECMAScript는 시간이 지나면서 계속 보완되었고, ES6는 현대적인 JavaScript 문법을 이해할 때 중요한 기준이 된다.


ES6는 ECMAScript 6를 줄여 부르는 말이다.
기존 JavaScript에서도 코드를 작성할 수는 있었다.
하지만 코드가 길어지면 변수 관리가 어렵고, 문자열 연결이 불편하고, 함수 작성도 반복적으로 길어지는 경우가 많았다.
ES6는 이런 불편함을 줄이기 위해 여러 문법을 추가했다.


예를 들어 let, const는 변수를 더 안전하게 선언하기 위해 사용한다.
템플릿 리터럴은 문자열을 더 읽기 쉽게 작성하기 위해 사용한다.
화살표 함수는 함수를 더 짧게 작성하기 위해 사용한다.
클래스는 객체를 만드는 구조를 더 익숙한 형태로 표현하기 위해 사용한다.
Promise는 비동기 작업의 성공과 실패를 다루기 위해 사용한다.
async, await은 ES6 자체 문법은 아니지만, Promise 기반 비동기 코드를 더 읽기 쉽게 작성하기 위해 함께 이해해야 하는 현대 JavaScript 문법이다.


즉, ES6는 단순히 새로운 문법을 많이 외우는 구간이 아니다.
기존 방식으로도 할 수 있었던 일을 더 간결하고, 더 읽기 쉽고, 더 실수하기 어렵게 작성하기 위한 문법 흐름이다.


기존 방식과 ES6 방식 비교

아래 예제는 같은 문자열을 출력하지만 작성 방식이 다르다.
기존 방식은 var로 변수를 만들고 문자열과 변수를 +로 이어 붙인다.
ES6 방식은 const로 변수를 만들고 템플릿 리터럴을 사용해 한 문장처럼 작성한다.

// es6-intro-string-example.js
var oldLanguage = "JavaScript"; // 기존 방식의 변수 선언
console.log("기존 방식: 안녕하세요 " + oldLanguage); // +로 문자열 연결

const newLanguage = "JavaScript"; // ES6 방식의 변수 선언
console.log(`ES6 방식: 안녕하세요 ${newLanguage}`); // 템플릿 리터럴로 값 삽입
// 출력결과
// 기존 방식: 안녕하세요 JavaScript
// ES6 방식: 안녕하세요 JavaScript

두 코드는 출력 결과가 같다.
하지만 ES6 방식은 문자열을 중간에 끊지 않고 문장 안에 값을 자연스럽게 넣는다.
이처럼 ES6 문법은 완전히 새로운 일을 하기 위한 문법이라기보다, 기존에 하던 일을 더 읽기 쉽게 작성하도록 도와주는 경우가 많다.


ES6와 ECMAScript 핵심 정리

  • ECMAScript는 JavaScript의 표준 규칙이다.
  • JavaScript는 실제로 사용하는 언어이다.
  • ES6는 현대적인 JavaScript 문법을 이해할 때 중요한 기준이 되는 버전이다.
  • ES6 문법은 코드를 더 간결하고 읽기 쉽게 만들기 위해 사용한다.
  • async/await, Promise.allSettled(), Promise.any(), Promise.prototype.finally(), 객체 Rest/Spread, # 비공개 프로퍼티는 ES6 이후에 추가된 문법이지만, 현대 JavaScript 흐름에서 함께 이해하면 좋다.
  • forEach(), map(), reduce(), filter()는 ES6 자체 문법은 아니지만 배열 처리 흐름을 이해하기 위해 함께 정리한다.

ES6를 배우는 이유는 문법 이름을 외우기 위해서가 아니라, JavaScript 코드를 더 안전하고 간결하며 읽기 쉽게 작성하기 위해서이다.




변수 선언 방식 비교

JavaScript에서 값을 사용하려면 먼저 변수를 만들어야 한다.
변수는 값을 저장해 두고, 나중에 다시 꺼내 쓰기 위한 이름 붙은 공간이라고 이해하면 된다.


예를 들어 숫자 10을 여러 번 사용해야 한다고 생각해 보자.
그때마다 10을 직접 쓰는 대신 score라는 이름에 10을 저장해 두면, 코드에서 score라는 이름으로 값을 다시 사용할 수 있다.
이처럼 변수는 값을 기억해 두기 위한 기본 도구이다.


그런데 ES6에서는 변수를 선언하는 방식이 중요해졌다.
기존에는 주로 var를 사용했지만, ES6부터는 let과 const를 함께 사용한다.
이 세 가지는 모두 변수를 만드는 문법이지만, 같은 이름을 다시 만들 수 있는지, 저장된 값을 다시 바꿀 수 있는지, 변수가 어디까지 유효한지가 다르다.


var, let, const의 차이를 제대로 이해하려면 재선언, 재할당, scope를 먼저 구분해야 한다.
이 세 개념을 구분하지 못하면 var는 왜 위험한지, let과 const는 왜 더 많이 권장되는지 이해하기 어렵다.


재선언과 재할당은 다르다

재선언은 똑같은 이름의 변수를 다시 만드는 것이다.
이미 score라는 변수를 만들었는데, 다시 var score처럼 같은 이름의 변수를 또 만드는 상황을 말한다.
즉, 변수 이름 자체를 다시 선언하는 것이다.


재할당은 이미 만들어진 변수에 다른 값을 다시 넣는 것이다.
변수 이름을 새로 만드는 것이 아니라, 기존 변수 안의 값만 바꾸는 것이다.
예를 들어 score에 10이 들어 있었는데, 나중에 score = 20처럼 값을 바꾸면 재할당이다.


두 개념은 비슷해 보이지만 다르다.
재선언은 변수를 다시 만드는 것이고, 재할당은 기존 변수의 값을 바꾸는 것이다.
초보자는 이 둘을 같은 것으로 생각하기 쉽지만, let과 const를 이해할 때 반드시 나누어 봐야 한다.


차이를 짧게 정리하면 이렇다.

  • 재선언은 같은 이름의 변수를 다시 만드는 것이다.
  • 재할당은 이미 있는 변수에 다른 값을 다시 넣는 것이다.
  • 재선언은 변수 이름을 다시 선언하는 문제이다.
  • 재할당은 변수 안의 값이 바뀌는 문제이다.

이 차이를 알아야 var, let, const가 무엇을 허용하고 무엇을 막는지 이해할 수 있다.


변수 유효 범위는 변수를 사용할 수 있는 범위이다

scope는 변수나 함수 같은 이름을 사용할 수 있는 유효 범위이다.
쉽게 말하면 “이 변수 이름을 어디까지 사용할 수 있는가”를 정하는 범위이다.


어떤 변수는 코드 전체에서 사용할 수 있고, 어떤 변수는 특정 블록 안에서만 사용할 수 있다.
여기서 블록은 {}로 감싼 코드 영역을 말한다.


scope가 중요한 이유는 변수의 영향 범위를 정하기 때문이다.
변수가 너무 넓은 범위에서 사용되면, 코드가 길어졌을 때 어디에서 값이 바뀌었는지 찾기 어려워진다.
반대로 변수가 필요한 곳에서만 사용되면, 코드 흐름을 더 쉽게 따라갈 수 있다.


즉, scope는 단순히 문법 용어가 아니다.
변수가 영향을 주는 범위를 제한해서 코드 실수를 줄이는 기준이다.


세 가지 변수 선언 방식의 기본 차이

이제 var, let, const를 비교하면 차이가 분명해진다.
세 문법은 모두 변수를 만들 때 사용하지만, 허용하는 동작이 다르다.


var는 재선언과 재할당이 모두 가능하다.
그래서 같은 이름의 변수를 다시 만들어도 오류가 나지 않고, 값도 다시 바꿀 수 있다.
겉으로 보면 편해 보이지만, 코드가 길어질수록 같은 이름을 실수로 다시 선언해도 발견하기 어렵다.


let은 재할당은 가능하지만 재선언은 불가능하다.
즉, 값을 나중에 바꿀 수는 있지만, 같은 이름의 변수를 같은 범위 안에서 다시 만들 수는 없다.
그래서 값이 변해야 하는 변수에는 let을 사용할 수 있다.


const는 재선언과 재할당이 모두 불가능하다.
한 번 값을 넣으면 같은 변수에 다른 값을 다시 넣을 수 없다.
그래서 처음 정한 값 자체를 다른 값으로 바꾸면 안 되는 경우에 사용한다.


차이를 정리하면 아래와 같다.

  • var는 재선언이 가능하다.
  • var는 재할당도 가능하다.
  • let은 재선언이 불가능하다.
  • let은 재할당이 가능하다.
  • const는 재선언이 불가능하다.
  • const는 재할당도 불가능하다.

정리하면 기준은 단순하다.
값이 바뀌면 let, 값이 바뀌지 않으면 const를 사용한다.
그리고 특별한 이유가 없다면 var는 피하는 것이 좋다.
var는 너무 유연해서 실수를 막아 주지 못하고, let과 const는 잘못된 재선언이나 불필요한 값 변경을 줄여 준다.


기본 예제로 차이 확인하기

아래 예제는 var, let, const의 차이를 가장 기본적인 흐름으로 비교한다.

// variable-declaration-basic.js
var oldScore = 10; // var로 변수 선언
oldScore = 20; // var는 재할당 가능
var oldScore = 30; // var는 같은 이름으로 재선언 가능
console.log(oldScore); // 마지막 값 출력

let changeScore = 10; // let으로 변수 선언
changeScore = 20; // let은 재할당 가능
console.log(changeScore); // 바뀐 값 출력

const fixedScore = 10; // const로 변수 선언
console.log(fixedScore); // 처음 저장한 값 출력
// 출력결과
// 30
// 20
// 10

첫 번째 부분에서 oldScore는 var로 선언했다.
처음에는 10을 넣었고, 그다음 20으로 값을 바꾸었다.
그리고 다시 var oldScore = 30으로 같은 이름의 변수를 재선언했다.
var는 이것을 허용하기 때문에 마지막에는 30이 출력된다.


두 번째 부분에서 changeScore는 let으로 선언했다.
let은 값을 다시 넣는 재할당은 가능하다.
그래서 10으로 시작한 값이 20으로 바뀌고, 마지막에는 20이 출력된다.


세 번째 부분에서 fixedScore는 const로 선언했다.
const는 한 번 값을 정하면 같은 변수에 다른 값을 다시 넣을 수 없다.
예제에서는 값을 바꾸지 않았기 때문에 처음 저장한 10이 그대로 출력된다.


const는 재할당을 막지만 객체 내부 변경까지 막지는 않는다

const를 처음 배울 때 가장 많이 오해하는 부분이 있다.
const는 “값이 절대 바뀌지 않는다”라고만 외우면 객체에서 헷갈릴 수 있다.


정확히는 const는 변수에 다른 값을 다시 대입하는 것을 막는다.
즉, const user = ...라고 선언했다면 user라는 변수 이름이 다른 값을 가리키도록 다시 바꿀 수 없다.


하지만 user가 객체를 가리키고 있다면, 그 객체 안의 프로퍼티를 바꾸는 것은 가능하다.
여기서 프로퍼티는 객체가 가지고 있는 이름과 값의 묶음이다.

// const-object-property-change.js
const user = { // const로 객체 저장
    nickname: "짱구", // 객체의 프로퍼티
    age: 5 // 객체의 프로퍼티
};

user.age = 6; // 객체 내부 프로퍼티 변경은 가능

console.log(user); // 변경된 객체 출력
// 출력결과
// { nickname: '짱구', age: 6 }

이 코드에서 user는 const로 선언되었다.
그런데도 user.age = 6은 가능하다.
이유는 user 변수 자체에 새 객체를 다시 넣은 것이 아니라, user가 가리키는 객체 안의 age 값을 바꾼 것이기 때문이다.


반대로 아래 코드는 오류가 난다.

// const-object-reassign-error.js
const user = { // const로 객체 저장
    nickname: "짱구", // 객체의 프로퍼티
    age: 5 // 객체의 프로퍼티
};

user = { nickname: "짱아", age: 1 }; // const 변수에 새 객체 재할당 시도

console.log(user); // 실행되지 않음
// 출력결과
// TypeError: Assignment to constant variable.

이 코드는 user라는 변수에 완전히 다른 객체를 다시 넣으려고 했다.
const는 재할당을 막기 때문에 오류가 발생한다.


정리하면 아래처럼 이해하면 된다.

  • const는 변수 이름이 다른 값을 다시 가리키는 것을 막는다.
  • const로 저장한 객체의 프로퍼티 변경은 가능하다.
  • 객체 자체를 완전히 못 바꾸게 만드는 문법은 아니다.
  • 객체 내부 변경까지 막고 싶다면 별도의 방식이 필요하다.

const는 변수 재할당을 막는 문법이지, 객체 내부 프로퍼티 변경을 자동으로 막는 문법은 아니다.
이 차이를 알아야 뒤에서 객체 Spread Operator로 새 객체를 만드는 이유도 쉽게 이해할 수 있다.


새 선언 방식이 막아 주는 실수

let과 const는 일부 동작을 막는다.
처음에는 이 제한이 불편해 보일 수 있다.
하지만 실제로는 실수를 빨리 발견하게 도와준다.


먼저 let은 같은 범위에서 같은 이름의 변수를 다시 만드는 것을 막는다.
아래 코드는 잘못된 예시이다.

// variable-let-redeclare-error.js
let userName = "kim"; // let으로 변수 선언
let userName = "lee"; // 같은 범위에서 let 재선언은 불가능
// 출력결과
// SyntaxError: Identifier 'userName' has already been declared

이 예제에서 let userName = "lee"는 문제가 된다.
이미 같은 범위 안에 userName이라는 변수가 있는데, 다시 let userName으로 같은 이름의 변수를 만들려고 했기 때문이다.
let은 같은 범위 안에서 재선언을 허용하지 않는다.


이번에는 const의 재할당 제한을 보면 된다.
아래 코드도 잘못된 예시이다.

// variable-const-reassign-error.js
const birthYear = 2000; // const로 변수 선언
birthYear = 2001; // const 재할당은 불가능
// 출력결과
// TypeError: Assignment to constant variable.

birthYear = 2001은 문제가 된다.
birthYear는 const로 선언했기 때문에 값을 다시 바꿀 수 없다.
const는 처음 저장한 값을 유지해야 하는 변수에 사용한다.
그래서 재할당을 시도하면 오류가 난다.


이 제한은 코드를 안전하게 만든다.
같은 이름의 변수를 실수로 다시 만들면 기존 값이 덮여서 예상하지 못한 결과가 나올 수 있다.
바뀌면 안 되는 값이 중간에 바뀌면 오류의 원인을 찾기 어려워진다.
let과 const는 이런 실수를 코드 작성 단계에서 빨리 발견하게 도와준다.


기존 선언 방식의 유효 범위 문제

var의 또 다른 문제는 scope이다.
var는 함수 레벨 scope를 가진다.
함수 레벨 scope는 함수 안에서 선언한 변수는 함수 안에서 유효하고, 함수가 아닌 {} 블록만으로는 새로운 범위가 만들어지지 않는다는 뜻이다.


반면 let과 const는 블록 레벨 scope를 가진다.
블록 레벨 scope는 {} 안에서 선언한 변수가 그 블록 안에서만 유효하다는 뜻이다.
그래서 if, for 같은 블록 안에서 만든 변수는 블록 밖에서 함부로 사용할 수 없다.


아래 예제로 차이를 보면 더 쉽다.

// variable-scope-example.js
if (true) {
    var varMessage = "var 변수"; // var는 블록 밖에서도 접근 가능
    let letMessage = "let 변수"; // let은 블록 안에서만 접근 가능
}

console.log(varMessage); // var는 출력 가능
console.log(letMessage); // let은 블록 밖에서 접근 불가능
// 출력결과
// var 변수
// ReferenceError: letMessage is not defined

varMessage는 if 블록 안에서 선언했지만, 블록 밖에서도 출력된다.
var는 {} 블록만으로 범위가 제한되지 않기 때문이다.
이런 특징 때문에 코드가 길어지면 변수가 어디서부터 어디까지 영향을 주는지 헷갈릴 수 있다.


반면 letMessage는 if 블록 안에서만 사용할 수 있다.
블록 밖에서 letMessage를 출력하려고 하면 오류가 난다.
처음에는 불편해 보일 수 있지만, 실제로는 변수가 영향을 주는 범위를 좁혀 준다.
그래서 코드가 길어져도 변수가 어디에서 사용되는지 더 쉽게 파악할 수 있다.


기존 선언 방식의 호이스팅 문제

var에는 호이스팅 문제도 있다.
호이스팅은 변수 선언이 실제 코드 위치보다 위로 끌어올려진 것처럼 동작하는 현상이다.
초보자 입장에서는 “아직 변수를 만들기 전인데도 변수 이름을 사용할 수 있는 것처럼 보이는 현상”이라고 이해하면 된다.


var로 선언한 변수는 선언문보다 먼저 사용해도 not found가 아니라 undefined가 나올 수 있다.
여기서 undefined는 값이 아직 정해지지 않았다는 의미이다.
즉, 변수는 있는 것으로 처리되지만 값은 아직 들어가지 않은 상태로 보이는 것이다.

// var-hoisting-example.js
console.log(score); // 선언보다 먼저 사용
var score = 100; // var 변수 선언과 값 저장
console.log(score); // 값 저장 후 사용
// 출력결과
// undefined
// 100

이 코드는 이상해 보일 수 있다.
score를 선언하기 전에 출력했는데 오류가 아니라 undefined가 나온다.
이유는 var score 선언이 위로 끌어올려진 것처럼 처리되기 때문이다.
하지만 값 100까지 같이 올라가는 것은 아니다.
그래서 첫 번째 출력에서는 값이 없는 상태인 undefined가 나오고, 두 번째 출력에서는 100이 나온다.


이런 동작은 초보자에게 혼란을 줄 수 있다.
코드를 위에서 아래로 읽었을 때 아직 변수를 만들지 않은 것처럼 보이는데도 오류가 바로 나지 않기 때문이다.
그래서 var를 사용하면 실수를 늦게 발견할 수 있다.


변수 선언 방식 핵심 정리

변수 선언 방식은 단순 취향 문제가 아니다.
코드가 길어질수록 변수를 안전하게 관리하는 방식이 중요해진다.


사용 기준은 아래처럼 잡으면 된다.

  • 기본적으로 const를 먼저 사용한다.
  • 값이 나중에 바뀌어야 하면 let을 사용한다.
  • 특별한 이유가 없다면 var는 사용하지 않는다.

처음부터 const를 기본으로 사용하고, 값이 바뀌어야 할 때만 let을 사용하는 습관을 들이는 것이 좋다.
var는 재선언, 함수 레벨 scope, 호이스팅 때문에 예상하지 못한 결과를 만들 수 있으므로 특별한 이유가 없다면 사용을 피하는 것이 좋다.


정리하면 var는 재선언과 재할당이 모두 가능하지만 그만큼 실수를 막기 어렵다.
let은 재할당이 필요한 변수에 사용하고, 같은 범위 안의 재선언을 막아 준다.
const는 변수에 다른 값을 다시 대입하지 않을 때 사용하고, 재선언과 재할당을 모두 막아 준다.
다만 객체를 const로 선언해도 객체 내부 프로퍼티 변경까지 자동으로 막는 것은 아니다.
let과 const는 코드를 불편하게 만드는 문법이 아니라, 변수 사용 범위를 분명하게 하고 실수를 줄이기 위한 안전장치이다.




템플릿 리터럴

JavaScript에서 문자열은 글자 데이터를 의미한다.
기존에는 문자열을 만들 때 보통 큰따옴표나 작은따옴표를 사용했다.
문자열만 출력할 때는 이 방식도 충분하다.
하지만 문자열 안에 변수 값을 섞어 넣거나, 여러 줄로 된 문자열을 만들 때는 코드가 복잡해질 수 있다.


ES6에서는 이런 불편함을 줄이기 위해 템플릿 리터럴을 사용할 수 있다.
템플릿 리터럴은 문자열을 작성하는 새로운 방식이다.
기존처럼 큰따옴표나 작은따옴표를 사용하는 대신 백틱 기호로 문자열을 감싼다.


템플릿 리터럴은 문자열 안에 변수나 연산 결과를 자연스럽게 넣고, 줄바꿈까지 그대로 표현할 수 있게 해 주는 ES6 문자열 작성 방식이다.


기존 문자열 연결 방식의 불편함

기존 방식에서는 문자열과 변수를 함께 출력하려면 + 연산자를 사용해야 한다.
문자열이 길어질수록 따옴표와 +가 많아지고, 공백을 빼먹기도 쉽다.

// template-literal-old-string.js
const js = "자바스크립트"; // 문자열 값을 변수에 저장

console.log("이건 " + js + "입니다."); // +로 문자열과 변수 연결
// 출력결과
// 이건 자바스크립트입니다.

결과는 맞지만 문자열을 여러 조각으로 나누어야 한다.
변수가 여러 개 들어가면 문장 구조가 한눈에 보이지 않는다.


템플릿 리터럴은 백틱과 ${}를 사용한다

템플릿 리터럴은 문자열을 백틱으로 감싼다.
그리고 문자열 안에 변수나 연산식을 넣고 싶으면 ${}를 사용한다.
${} 안에는 실행하면 하나의 값으로 바뀌는 표현식을 넣을 수 있다.

// template-literal-basic.js
const js = "자바스크립트"; // 문자열 값을 변수에 저장

console.log(`이건 ${js}입니다.`); // ${} 안의 값을 문자열에 삽입
// 출력결과
// 이건 자바스크립트입니다.

"${js}"가 아니라 백틱 안의 ${js}이기 때문에 변수 값이 문자열 안에 들어간다.
템플릿 리터럴의 핵심은 문자열을 조각내서 이어 붙이는 것이 아니라, 하나의 문장 안에 값을 끼워 넣는 방식으로 작성한다는 점이다.


${} 안에는 연산도 넣을 수 있다

${} 안에는 변수만 넣을 수 있는 것이 아니다.
계산식도 넣을 수 있다.
계산식은 실행되면 하나의 값으로 바뀌기 때문에, 그 결과가 문자열 안에 들어간다.

// template-literal-expression.js
const num1 = 10; // 첫 번째 숫자 저장
const num2 = 20; // 두 번째 숫자 저장

console.log(`두 수의 합은 ${num1 + num2}입니다.`); // ${} 안에서 덧셈 실행
// 출력결과
// 두 수의 합은 30입니다.

${num1 + num2} 안에서는 먼저 10 + 20 계산이 실행된다.
그 결과인 30이 문자열 안에 들어간다.


줄바꿈이 있는 문자열도 그대로 작성할 수 있다

기존 따옴표 문자열은 줄바꿈을 그대로 작성할 수 없다.
여러 줄 문자열을 표현하려면 \n을 넣거나 문자열을 여러 번 이어 붙여야 한다.


템플릿 리터럴은 백틱 안에서 줄바꿈을 그대로 작성할 수 있다.

// template-literal-multiline.js
const message = `첫 번째 줄입니다.
두 번째 줄입니다.
세 번째 줄입니다.`; // 백틱 안의 줄바꿈이 문자열에 반영

console.log(message); // 여러 줄 문자열 출력
// 출력결과
// 첫 번째 줄입니다.
// 두 번째 줄입니다.
// 세 번째 줄입니다.

줄바꿈이 필요한 안내 문구, 여러 줄 메시지, 긴 문자열을 작성할 때 템플릿 리터럴은 훨씬 읽기 쉽다.


따옴표와 백틱은 결과가 다르다

${}는 아무 문자열에서나 동작하는 것이 아니다.
반드시 백틱으로 감싼 문자열 안에서 사용해야 한다.
큰따옴표나 작은따옴표 안에 ${}를 쓰면 변수 값이 들어가지 않고 글자 그대로 출력된다.

// template-literal-backtick-warning.js
const userName = "kim"; // 이름 값 저장

console.log("안녕하세요 ${userName}"); // 큰따옴표에서는 ${}가 동작하지 않음
console.log(`안녕하세요 ${userName}`); // 백틱에서는 ${}가 동작함
// 출력결과
// 안녕하세요 ${userName}
// 안녕하세요 kim

${}로 값을 넣고 싶다면 문자열을 큰따옴표나 작은따옴표가 아니라 백틱으로 감싸야 한다.


템플릿 리터럴 핵심 정리

  • 템플릿 리터럴은 백틱으로 문자열을 감싼다.
  • 변수나 연산 결과는 ${} 안에 넣는다.
  • ${} 안에는 변수뿐 아니라 계산식도 넣을 수 있다.
  • 백틱 안에서는 줄바꿈이 그대로 문자열에 반영된다.
  • 큰따옴표나 작은따옴표 안에서는 ${}가 동작하지 않는다.

템플릿 리터럴은 단순히 문자열을 짧게 쓰는 것만이 목적이 아니라, 문자열 안에 값이 들어가는 위치를 더 분명하게 보여 주기 위한 문법이다.




화살표 함수

JavaScript에서 함수는 특정 동작을 묶어 두고, 필요할 때 다시 실행하기 위한 코드 묶음이다.
예를 들어 두 숫자를 더하는 코드가 여러 번 필요하다면, 그 코드를 함수로 만들어 두고 필요할 때마다 호출해서 사용할 수 있다.


기존에는 함수를 만들 때 function 키워드를 사용했다.
이 방식도 문제없이 사용할 수 있지만, 간단한 동작을 하는 함수까지 매번 function, 중괄호, return을 모두 작성하면 코드가 길어질 수 있다.


ES6에서는 이런 함수를 더 짧고 간결하게 작성하기 위해 화살표 함수를 사용할 수 있다.
화살표 함수는 함수 표현식을 더 간단하게 작성하는 문법이다.
여기서 함수 표현식은 함수를 값처럼 만들어 변수에 저장하는 방식이라고 이해하면 된다.


화살표 함수는 기존 함수 표현식을 더 짧게 작성할 수 있게 해 주는 ES6 함수 문법이다.
이 구간에서는 먼저 같은 계산 결과를 만드는 함수를 더 간결하게 표현하는 방식으로 이해하면 된다.
다만 화살표 함수는 this를 다루는 방식이 기존 함수와 다르므로, 완전히 같은 함수라고 생각하면 안 된다.


기존 함수 표현식은 function 키워드를 사용한다

기존 함수 표현식은 function 키워드로 함수를 만들고, 그 함수를 변수에 저장한다.
함수를 변수에 저장해 두면, 나중에 그 변수 이름으로 함수를 호출할 수 있다.


아래 예제는 두 숫자를 더해서 결과를 반환하는 함수 표현식이다.

// arrow-function-old-basic.js
const sum = function(a, b) { // 두 값을 받는 함수 저장
    return a + b; // 두 값을 더한 결과 반환
};

console.log(sum(3, 5)); // 함수 호출 후 결과 출력
// 출력결과
// 8

이 코드에서 sum은 함수를 저장한 변수이다.
function(a, b)에서 a와 b는 매개변수이다.
매개변수는 함수가 실행될 때 바깥에서 값을 받아 오는 자리이다.


sum(3, 5)처럼 함수를 호출하면 a에는 3, b에는 5가 들어간다.
그리고 return a + b가 실행되어 8이 반환된다.
여기서 return은 함수가 계산한 결과를 함수 밖으로 돌려주는 역할을 한다.


이 방식은 정확하지만 코드가 조금 길다.
특히 함수 안에서 하는 일이 한 줄뿐이라면, 매번 function, {}, return을 모두 쓰는 것이 답답하게 느껴질 수 있다.
이때 화살표 함수를 사용하면 같은 계산 흐름을 더 짧게 쓸 수 있다.


화살표 함수는 => 기호로 함수를 표현한다

화살표 함수는 function 키워드 대신 => 기호를 사용한다.
그래서 이름 그대로 화살표 함수라고 부른다.


기본 형태는 아래처럼 이해하면 된다.

  • 왼쪽에는 함수가 받을 매개변수를 적는다.
  • 가운데에는 =>를 적는다.
  • 오른쪽에는 함수가 실행할 코드를 적는다.

즉, 화살표 함수는 “이 값을 받아서 이런 결과를 만든다”는 흐름을 짧게 표현한다.


위에서 본 sum 함수를 화살표 함수로 바꾸면 아래처럼 된다.

// arrow-function-basic.js
const sum = (a, b) => a + b; // 두 값을 받아 더한 결과를 바로 반환

console.log(sum(3, 5)); // 함수 호출 후 결과 출력
// 출력결과
// 8

이 코드에서 (a, b)는 매개변수이다.
=>는 화살표 함수임을 나타내는 기호이다.
a + b는 함수가 실행할 코드이자 반환할 결과이다.


여기서는 중괄호와 return이 없다.
이유는 중괄호 없이 a + b라는 표현식 하나를 바로 작성했기 때문이다.
표현식은 실행하면 하나의 값으로 바뀌는 코드이다.
즉, (a, b) => a + b는 “a와 b를 받아서 a + b의 결과를 반환한다”는 뜻이다.


기존 함수 표현식과 비교하면 아래처럼 볼 수 있다.

// arrow-function-compare.js
const oldSum = function(a, b) { // 기존 함수 표현식
    return a + b; // 결과 반환
};

const arrowSum = (a, b) => a + b; // 화살표 함수 표현식

console.log(oldSum(10, 20)); // 기존 함수 호출
console.log(arrowSum(10, 20)); // 화살표 함수 호출
// 출력결과
// 30
// 30

두 함수는 이 예제에서 같은 결과를 만든다.
하지만 화살표 함수는 더 짧다.
function 키워드를 쓰지 않고, 중괄호 없이 표현식 하나를 바로 작성하면 그 결과가 자동으로 반환되기 때문이다.


화살표 함수는 간단한 함수를 한 줄로 표현할 때 코드의 의도를 더 빠르게 보여 준다.


단순한 매개변수 하나라면 괄호를 생략할 수 있다

화살표 함수에서 매개변수가 단순한 이름 하나뿐이라면 매개변수를 감싸는 괄호를 생략할 수 있다.
여기서 매개변수는 함수가 바깥에서 값을 받는 자리이다.


아래 예제는 숫자 하나를 받아 두 배로 만드는 함수이다.

// arrow-function-one-parameter.js
const double = number => number * 2; // 단순한 매개변수 하나라 괄호 생략

console.log(double(7)); // 함수 호출 후 결과 출력
// 출력결과
// 14

이 코드에서 number => number * 2는 number 하나를 받아서 number * 2를 반환한다는 뜻이다.
매개변수가 단순한 이름 하나뿐이기 때문에 (number)처럼 괄호로 감싸지 않아도 된다.


괄호를 써도 결과는 같다.

// arrow-function-one-parameter-with-parentheses.js
const double = (number) => number * 2; // 괄호를 써도 같은 의미

console.log(double(7)); // 함수 호출 후 결과 출력
// 출력결과
// 14

즉, 단순한 매개변수 하나일 때 괄호 생략은 선택이다.
코드를 더 짧게 쓰고 싶으면 생략할 수 있고, 통일감을 위해 괄호를 남길 수도 있다.


하지만 매개변수가 여러 개라면 괄호를 생략할 수 없다.
여러 값을 받는 함수는 (a, b)처럼 괄호 안에 매개변수를 나열해야 한다.


이 단계에서는 “매개변수 이름 하나만 단순히 받는 경우에는 괄호를 생략할 수 있다” 정도로 이해하면 충분하다.
나중에 기본값이나 구조분해할당처럼 더 복잡한 매개변수 형태가 나오면 괄호가 필요할 수 있다.


매개변수가 없으면 괄호를 반드시 써야 한다

화살표 함수에서 매개변수가 하나도 없을 때는 빈 괄호를 써야 한다.
받을 값이 없더라도, 함수가 매개변수를 받지 않는다는 자리를 표시해야 하기 때문이다.


아래 예제는 매개변수 없이 정해진 문자열을 반환하는 함수이다.

// arrow-function-no-parameter.js
const sayHello = () => "안녕하세요"; // 매개변수가 없어서 빈 괄호 사용

console.log(sayHello()); // 함수 호출 후 결과 출력
// 출력결과
// 안녕하세요

이 코드에서 ()는 받을 매개변수가 없다는 뜻이다.
그 뒤의 => "안녕하세요"는 이 함수가 "안녕하세요"라는 문자열을 반환한다는 뜻이다.


매개변수가 없는데 괄호를 생략하면 올바른 화살표 함수 형태가 되지 않는다.
그래서 매개변수가 없을 때는 반드시 ()를 써야 한다.


정리하면 매개변수 괄호 규칙은 아래처럼 볼 수 있다.

  • 매개변수가 여러 개면 괄호가 필요하다.
  • 단순한 매개변수 하나면 괄호를 생략할 수 있다.
  • 매개변수가 없으면 빈 괄호가 필요하다.

이 규칙을 알면 화살표 함수 모양이 달라도 같은 문법 흐름으로 읽을 수 있다.


중괄호를 생략하면 표현식 결과가 자동 반환된다

화살표 함수에서 중괄호 없이 표현식 하나를 바로 작성하면, 그 표현식의 결과가 자동으로 반환된다.
여기서 중요한 기준은 단순히 줄 수가 아니라 중괄호를 사용했는지, 표현식 하나를 바로 작성했는지이다.


아래 예제는 숫자 하나를 받아 1을 더한 값을 반환한다.

// arrow-function-implicit-return.js
const addOne = number => number + 1; // 표현식 결과를 자동 반환

console.log(addOne(9)); // 함수 호출 후 결과 출력
// 출력결과
// 10

이 코드에는 return이 없다.
하지만 number + 1의 결과가 자동으로 반환된다.
이런 방식은 함수가 간단한 계산 하나만 처리할 때 유용하다.


하지만 중괄호를 사용하면 이야기가 달라진다.
중괄호는 함수 몸체를 블록으로 작성한다는 뜻이다.
블록 안에서는 여러 줄의 코드를 작성할 수 있지만, 결과를 반환하려면 return을 직접 써야 한다.

// arrow-function-explicit-return.js
const addOne = number => { // 중괄호를 사용한 함수 몸체
    return number + 1; // return으로 결과를 직접 반환
};

console.log(addOne(9)); // 함수 호출 후 결과 출력
// 출력결과
// 10

이 예제는 중괄호 안에서 return number + 1을 직접 작성했다.
그래서 결과가 정상적으로 10으로 반환된다.


반대로 중괄호를 사용했는데 return을 쓰지 않으면 함수는 계산 결과를 밖으로 돌려주지 않는다.
아래 코드는 잘못 이해하기 쉬운 예시이다.

// arrow-function-missing-return.js
const addOne = number => { // 중괄호를 사용한 함수 몸체
    number + 1; // 계산만 하고 반환하지 않음
};

console.log(addOne(9)); // 반환값 확인
// 출력결과
// undefined

이 코드에서 number + 1은 계산되지만, 그 결과가 return으로 반환되지 않는다.
그래서 addOne(9)의 결과는 undefined가 된다.
여기서 undefined는 함수가 돌려준 값이 없다는 의미로 이해하면 된다.


화살표 함수에서 중괄호 없이 표현식 하나를 쓰면 결과가 자동 반환되고, 중괄호를 사용하면 return을 직접 써야 한다.
이 차이를 모르면 함수 안에서 계산은 했는데 결과가 나오지 않는 상황을 만나게 된다.


여러 줄 코드가 필요하면 중괄호를 사용한다

함수 안에서 해야 할 일이 여러 줄이라면 중괄호를 사용한다.
예를 들어 중간 값을 변수에 저장하고, 그 값을 다시 가공해서 반환해야 한다면 한 줄로 끝내기 어렵다.


아래 예제는 가격에 배송비를 더한 뒤, 최종 금액 안내 문장을 반환한다.

// arrow-function-multiple-lines.js
const makeTotalMessage = (price, deliveryFee) => { // 가격과 배송비를 받는 함수
    const total = price + deliveryFee; // 최종 금액 계산
    return `최종 금액은 ${total}원입니다.`; // 안내 문장 반환
};

console.log(makeTotalMessage(10000, 3000)); // 함수 호출 후 결과 출력
// 출력결과
// 최종 금액은 13000원입니다.

이 함수는 한 줄 계산만 하는 함수가 아니다.
먼저 total이라는 변수를 만들어 최종 금액을 계산한다.
그다음 템플릿 리터럴을 사용해서 안내 문장을 만든 뒤 반환한다.


이처럼 함수 안에서 여러 줄의 처리가 필요하면 중괄호를 사용한다.
그리고 중괄호를 사용했기 때문에 마지막 결과를 밖으로 보내려면 return을 직접 써야 한다.


화살표 함수는 this를 직접 만들어 가지지 않는다

화살표 함수는 기존 함수 표현식을 짧게 쓰기 위한 문법이지만, 기존 함수와 완전히 같은 것은 아니다.
가장 중요한 차이 중 하나는 this를 다루는 방식이다.


this는 함수가 실행될 때 “지금 이 함수가 어떤 대상과 연결되어 실행되는가”를 가리키는 값이다.
객체 메서드에서는 보통 그 메서드를 호출한 객체를 가리키는 데 사용한다.


일반 메서드에서는 this가 메서드를 호출한 객체를 가리킬 수 있다.

// arrow-function-this-normal-method.js
const user = {
    nickname: "짱구", // 객체 프로퍼티
    sayName() { // 일반 메서드
        return this.nickname; // this는 user 객체를 가리킴
    }
};

console.log(user.sayName()); // 메서드 호출 결과 출력
// 출력결과
// 짱구

이 코드에서 user.sayName()으로 메서드를 호출했다.
그래서 sayName() 안의 this는 user 객체를 가리킨다.
그 결과 this.nickname은 "짱구"가 된다.


반면 화살표 함수는 자기만의 this를 새로 만들지 않는다.
그래서 객체의 메서드로 작성했을 때 “이 객체 자신”을 가리키는 용도로 쓰면 예상과 다르게 동작할 수 있다.


초보자 단계에서는 이렇게 기준을 잡으면 된다.

  • 단순 계산 함수나 배열 메서드 안의 짧은 callback 함수에는 화살표 함수를 자주 사용한다.
  • 객체가 자기 자신의 프로퍼티를 this로 읽어야 하는 메서드라면 일반 메서드 문법을 사용하는 것이 안전하다.

화살표 함수는 짧게 쓰기 편하지만, 자기만의 this를 만들지 않으므로 객체 메서드로 사용할 때는 주의해야 한다.
이 구간에서는 화살표 함수를 “간단한 함수 표현식과 callback을 짧게 쓰는 문법”으로 먼저 이해하면 된다.


화살표 함수 핵심 정리

화살표 함수는 ES6에서 추가된 함수 작성 방식이다.
기존 함수 표현식보다 짧게 작성할 수 있고, 특히 간단한 계산이나 값을 반환하는 함수에서 코드가 더 읽기 쉬워진다.


핵심은 아래처럼 정리할 수 있다.

  • 화살표 함수는 function 키워드 대신 =>를 사용한다.
  • 매개변수가 여러 개면 괄호를 사용한다.
  • 단순한 매개변수 하나면 괄호를 생략할 수 있다.
  • 매개변수가 없으면 빈 괄호를 반드시 사용한다.
  • 중괄호 없이 표현식 하나를 작성하면 그 결과가 자동 반환된다.
  • 중괄호를 사용하면 결과 반환을 위해 return을 직접 써야 한다.
  • 화살표 함수는 자기만의 this를 만들지 않는다.
  • 객체가 자기 자신의 값을 this로 읽어야 하는 메서드라면 일반 메서드 문법이 더 안전하다.

화살표 함수는 함수를 무조건 짧게 쓰기 위한 문법이 아니라, 간단한 함수의 입력과 결과를 더 분명하게 보여 주기 위한 문법이다.
특히 배열 메서드처럼 함수를 다른 함수의 인자로 전달하는 코드에서 자주 사용되므로, 기본 형태를 정확히 읽을 수 있어야 한다.




모듈과 export / import

JavaScript 코드가 짧을 때는 하나의 파일에 모든 코드를 작성해도 크게 불편하지 않다.
하지만 코드가 길어지면 변수, 함수, 클래스가 한 파일 안에 계속 쌓인다.
그러면 어떤 코드가 어떤 역할을 하는지 찾기 어려워지고, 수정할 때도 다른 코드에 영향을 줄 수 있다.


이 문제를 줄이기 위해 사용하는 개념이 모듈이다.
모듈은 영어로 module이고, 코드를 역할별로 나누어 관리하는 방식이다.
다만 파일을 여러 개로 나누었다고 해서 모든 파일이 자동으로 모듈이 되는 것은 아니다.
브라우저 기준에서는 script type="module"로 실행되는 파일이 모듈의 시작점이 되고, 그 파일에서 import로 연결되는 파일들도 모듈 흐름으로 함께 동작한다.


모듈은 단순히 파일을 여러 개로 나누는 것이 아니라, export와 import를 통해 필요한 값만 공개하고 가져오도록 연결하는 구조이다.


모듈은 왜 필요한가

모듈은 코드를 역할별로 나누기 위해 필요하다.
사용자 정보와 관련된 코드는 사용자 정보 파일에 두고, 계산과 관련된 코드는 계산 파일에 두고, 실제 실행 흐름은 실행 파일에 둘 수 있다.
이렇게 나누면 한 파일이 담당하는 일이 분명해진다.


모듈이 필요한 이유는 아래처럼 정리할 수 있다.

  • 코드가 길어졌을 때 역할별로 나눌 수 있다.
  • 필요한 코드만 다른 파일에서 가져다 쓸 수 있다.
  • 파일마다 맡은 역할이 분명해져서 유지보수가 쉬워진다.
  • 같은 기능을 여러 곳에서 다시 사용할 수 있다.
  • 어떤 값이 외부에 공개되는지 분명하게 관리할 수 있다.

즉, 모듈은 코드를 멋있게 나누기 위한 문법이 아니라 코드가 커졌을 때 관리하기 쉽게 만들기 위한 구조이다.


모듈은 독립적인 scope를 가진다

모듈은 자신만의 독립적인 scope를 가진다.
즉, 모듈 파일 안에서 만든 변수나 함수는 기본적으로 그 파일 안에서만 사용할 수 있다.
다른 파일에서 자동으로 보이지 않는다.


다른 파일에서 사용하려면 해당 값을 export로 공개해야 하고, 사용하는 파일에서 import로 가져와야 한다.
이 구조가 중요한 이유는 이름 충돌을 줄여 주기 때문이다.


모듈 내부에서 선언한 변수와 함수는 기본적으로 비공개이고, 다른 파일에서 사용하려면 반드시 export와 import로 연결해야 한다.


Named export와 import

export는 모듈 안에 있는 변수, 함수, 클래스 등을 다른 파일에서 사용할 수 있도록 공개하는 문법이다.
Named export는 이름이 있는 값을 그대로 내보내는 방식이다.
여러 개를 내보낼 수 있고, 가져올 때도 내보낸 이름과 같은 이름으로 가져와야 한다.

// profile.js
export const userName = "kim"; // userName 변수를 외부에 공개
export const userAge = 20; // userAge 변수를 외부에 공개

export function introduce() { // introduce 함수를 외부에 공개
    return `${userName}은 ${userAge}살입니다.`; // 사용자 소개 문장 반환
}
// app.js
import { userName, userAge, introduce } from "./profile.js"; // 공개된 값 가져오기

console.log(userName); // 가져온 이름 출력
console.log(userAge); // 가져온 나이 출력
console.log(introduce()); // 가져온 함수 실행
// 출력결과
// kim
// 20
// kim은 20살입니다.

Named export로 내보낸 값은 import할 때 이름을 맞춰야 하고, 반드시 중괄호 안에 적어야 한다.


Default export

Default export는 한 파일에서 대표로 내보낼 값 하나를 정하는 방식이다.
default는 기본값이라는 뜻으로, 이 파일을 대표하는 기본 내보내기라고 이해하면 된다.
Default export는 한 파일에서 하나만 사용할 수 있다.

// calculator.js
export default function add(a, b) { // 이 파일의 대표 함수로 공개
    return a + b; // 두 값을 더한 결과 반환
}
// calculator-app.js
import addNumber from "./calculator.js"; // default export는 원하는 이름으로 가져올 수 있음

console.log(addNumber(10, 20)); // 가져온 함수 실행
// 출력결과
// 30

Default export는 가져올 때 중괄호를 사용하지 않는다.
그리고 가져오는 쪽에서 이름을 자유롭게 정할 수 있다.


이름을 맞춰 중괄호로 가져오면 Named export, 중괄호 없이 자유로운 이름으로 가져오면 Default export라고 구분하면 된다.


as와 * as

as는 가져온 이름을 현재 파일에서 다른 이름으로 사용할 때 쓴다.
* as는 해당 모듈에서 공개한 모든 값을 하나의 객체처럼 묶어 가져오는 방식이다.

// profile-alias-app.js
import { userName as name, userAge as age } from "./profile.js"; // 가져온 이름을 바꿔 사용

console.log(name); // 바꾼 이름으로 출력
console.log(age); // 바꾼 이름으로 출력
// 출력결과
// kim
// 20
// profile-all-app.js
import * as Profile from "./profile.js"; // 공개 값을 Profile로 묶어 가져오기

console.log(Profile.userName); // 묶음 안의 userName 사용
console.log(Profile.userAge); // 묶음 안의 userAge 사용
console.log(Profile.introduce()); // 묶음 안의 introduce 함수 실행
// 출력결과
// kim
// 20
// kim은 20살입니다.



브라우저에서 모듈을 사용하려면 type="module"이 필요하다

브라우저에서 import와 export를 직접 사용하려면 script 태그에 type="module"을 적어야 한다.
이 속성은 “이 JavaScript 파일을 일반 스크립트가 아니라 모듈로 실행하겠다”는 뜻이다.

// module-index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>module example</title>
</head>
<body>
    <h1>module example</h1>

    <script type="module" src="./app.js"></script> <!-- app.js를 모듈 시작 파일로 실행 -->
</body>
</html>

브라우저에서 import와 export를 사용하는 파일은 <script type="module">로 연결해야 하고, 그 파일이 모듈의 시작점이 된다.


모듈은 자동으로 엄격 모드와 지연 실행 특징을 가진다

모듈은 단순히 파일을 나누는 기능만 가진 것이 아니다.
브라우저에서 모듈로 실행되는 JavaScript 파일은 일반 script와 다른 특징을 가진다.


첫 번째 특징은 Strict Mode이다.
Strict Mode는 실수하기 쉬운 문법 사용을 더 엄격하게 검사하는 실행 방식이다.
일반 script에서는 "use strict"를 직접 적어야 엄격 모드가 적용될 수 있지만, 모듈에서는 따로 적지 않아도 자동으로 엄격 모드가 적용된다.
그래서 모듈 안에서는 더 안전한 기준으로 코드가 실행된다.


두 번째 특징은 Deferred Evaluation이다.
Deferred Evaluation은 실행이 뒤로 미뤄지는 특징이라고 이해하면 된다.
브라우저에서 <script type="module">로 연결된 모듈 스크립트는 HTML 문서가 파싱된 뒤 실행된다.
즉, 일반 script처럼 문서 읽는 흐름을 바로 막기보다, 기본적으로 defer 속성을 가진 것처럼 동작한다.


세 번째 특징은 Singleton이다.
Singleton은 같은 대상이 여러 번 만들어지지 않고 하나만 유지되는 구조를 말한다.
모듈에서는 같은 파일을 여러 곳에서 import하더라도 그 모듈 파일이 매번 새로 실행되는 것이 아니다.
처음 한 번 실행된 결과가 캐싱되고, 이후에는 그 결과를 다시 사용한다.
여기서 캐싱은 한 번 만들어 둔 결과를 저장해 두었다가 다시 사용하는 것을 의미한다.


정리하면 모듈의 주요 특징은 아래와 같다.

  • 모듈은 자동으로 Strict Mode가 적용된다.
  • <script type="module">은 기본적으로 defer처럼 동작한다.
  • 같은 모듈을 여러 번 import해도 모듈은 한 번만 실행되고 결과가 재사용된다.

모듈은 필요한 값을 공개하고 가져오는 구조일 뿐 아니라, 독립적인 scope, 자동 엄격 모드, 지연 실행, 캐싱 특징까지 함께 가진 실행 단위이다.


모듈 핵심 정리

  • 모듈은 코드를 역할별 파일로 나누어 연결하는 구조이다.
  • 브라우저에서는 type="module"로 실행되는 파일이 모듈의 시작점이다.
  • 모듈은 자신만의 독립적인 scope를 가진다.
  • 모듈 내부 코드는 자동으로 Strict Mode로 실행된다.
  • 모듈 스크립트는 기본적으로 문서 파싱 후 실행되는 defer 흐름을 가진다.
  • 같은 모듈을 여러 번 import해도 한 번만 실행되고 결과가 재사용된다.
  • 다른 파일에서 사용할 값은 export로 공개한다.
  • 공개된 값은 import로 가져온다.
  • Named export는 이름을 맞춰 중괄호로 가져온다.
  • Default export는 중괄호 없이 원하는 이름으로 가져온다.
  • as는 가져온 이름을 현재 파일에서 바꿔 쓸 때 사용한다.
  • * as는 공개된 값을 하나의 묶음처럼 가져올 때 사용한다.

모듈은 코드를 파일로 나누는 문법이 아니라, 필요한 값만 명확하게 공개하고 가져오면서 코드의 역할과 연결 관계를 정리하는 구조이다.




클래스

JavaScript에서 객체는 여러 값을 하나로 묶어 다루기 위한 데이터이다.
클래스는 이런 객체를 만들기 위한 틀이다.
여기서 틀이라는 말은 같은 구조의 객체를 계속 만들 수 있게 해 주는 설계도라고 이해하면 된다.


ES6에서는 class 문법을 사용해서 객체를 만드는 구조를 더 읽기 쉽게 표현할 수 있다.
기존에도 함수와 프로토타입을 사용해서 객체 생성 구조를 만들 수 있었지만, 초보자가 읽기에는 흐름이 복잡할 수 있다.
class는 객체를 만들기 위한 구조를 더 명확한 형태로 보여 준다.


클래스는 객체를 직접 의미하는 것이 아니라, 객체를 만들기 위한 설계도이다.
클래스를 작성한 뒤 new를 사용하면 그 설계도를 바탕으로 실제 객체를 만들 수 있다.
이렇게 클래스로 만든 실제 객체를 인스턴스라고 한다.


클래스는 정의한 뒤 new로 객체를 만든다

클래스는 객체를 만들기 위한 설계도이지만, 클래스 자체가 곧 객체는 아니다.
클래스를 사용하려면 먼저 클래스를 정의하고, 그다음 new를 사용해서 실제 객체를 만들어야 한다.


클래스는 함수처럼 보이는 부분이 있지만 일반 함수처럼 직접 호출할 수 없다.
예를 들어 Person()처럼 그냥 호출하는 것이 아니라, new Person()처럼 객체 생성 문법으로 호출해야 한다.
그렇지 않으면 클래스 생성 규칙에 맞지 않아 오류가 난다.


또 클래스 선언은 let, const처럼 블록 scope를 가진다.
그래서 클래스가 선언된 블록 안에서만 사용할 수 있다.
그리고 클래스를 사용하려면 클래스 선언이 먼저 코드에 나와 있어야 한다.
초보자 단계에서는 “클래스는 먼저 만들고, 그 아래에서 new로 객체를 생성한다”라고 이해하면 안전하다.


아래 코드는 클래스를 정의한 뒤 객체를 만드는 기본 흐름이다.

// class-create-after-define.js
class Person { // Person 클래스 정의
    constructor(name) { // 객체 생성 시 이름을 받음
        this.name = name; // 객체에 name 저장
    }
}

const person = new Person("kim"); // new로 실제 객체 생성

console.log(person.name); // 생성된 객체의 값 출력
// 출력결과
// kim

이 코드에서 Person은 객체를 만들기 위한 클래스이고, person은 new Person("kim")으로 실제 만들어진 객체이다.
즉, 클래스와 인스턴스는 같은 것이 아니다.
클래스는 설계도이고, 인스턴스는 그 설계도로 만들어진 실제 객체이다.


아래 코드는 잘못된 예시이다.

// class-call-without-new-error.js
class Person { // Person 클래스 정의
    constructor(name) { // 객체 생성 시 이름을 받음
        this.name = name; // 객체에 name 저장
    }
}

const person = Person("kim"); // new 없이 클래스 호출 시도

console.log(person); // 실행되지 않음
// 출력결과
// TypeError: Class constructor Person cannot be invoked without 'new'

클래스는 일반 함수처럼 직접 호출할 수 없다.
Person("kim")처럼 호출하면 객체 생성 흐름이 아니므로 오류가 발생한다.


클래스는 먼저 정의한 뒤 new로 인스턴스를 만들어 사용해야 하며, 일반 함수처럼 직접 호출하면 안 된다.


constructor와 this

constructor는 객체가 만들어질 때 처음 실행되는 특별한 메서드이다.
새 객체에 처음 필요한 값을 저장할 때 사용한다.
this는 지금 만들어지는 객체 자신을 가리킨다고 이해하면 된다.


아래 예제는 Person 클래스로 사람 객체를 만드는 코드이다.

// class-person-basic.js
class Person {
    constructor(name, age) { // 객체 생성 시 이름과 나이를 받음
        this.name = name; // 객체의 name 값 저장
        this.age = age; // 객체의 age 값 저장
    }

    nextYearAge() { // 내년 나이를 계산하는 메서드
        return Number(this.age) + 1; // 나이를 숫자로 바꾼 뒤 1 더하기
    }
}

const person = new Person("kim", "23"); // Person 클래스로 객체 생성

console.log(person.name); // 객체의 이름 출력
console.log(person.age); // 객체의 나이 출력
console.log(person.nextYearAge()); // 메서드 호출 결과 출력
// 출력결과
// kim
// 23
// 24

new Person("kim", "23")을 실행하면 Person 클래스를 바탕으로 새 객체가 만들어진다.
그 순간 constructor(name, age)가 실행되고, 전달한 값이 this.name, this.age에 저장된다.


nextYearAge()는 클래스 안에 작성한 메서드이다.
메서드는 객체가 가지고 있는 함수라고 이해하면 된다.
person.nextYearAge()처럼 객체를 통해 호출하면, this.age에 저장된 값을 사용해서 내년 나이를 계산한다.


constructor는 객체가 만들어지는 순간 초기값을 받아 객체 안에 저장하는 역할을 한다.


extends와 super

상속은 기존 클래스가 가진 기능을 다른 클래스가 이어받는 것이다.
extends는 다른 클래스를 상속해서 기능을 확장한다는 뜻이다.
super()는 부모 클래스의 constructor를 호출한다.


여기서 부모 클래스는 기능을 물려주는 클래스이고, 자식 클래스는 그 기능을 물려받아 확장하는 클래스이다.
자식 클래스에서 부모의 초기값 저장 과정이 필요하면 super()를 먼저 호출해야 한다.

// class-extends-basic.js
class Person {
    constructor(name, age) { // 부모 클래스 생성자
        this.name = name; // 이름 저장
        this.age = age; // 나이 저장
    }

    nextYearAge() { // 부모 클래스 메서드
        return Number(this.age) + 1; // 내년 나이 반환
    }
}

class IntroducePerson extends Person {
    constructor(name, age, city, futureHope) { // 자식 클래스 생성자
        super(name, age); // 부모 생성자 호출
        this.city = city; // 도시 저장
        this.futureHope = futureHope; // 장래희망 저장
    }

    introduce() { // 소개 문장을 만드는 메서드
        return `저는 ${this.city}에 사는 ${this.name}입니다.
내년엔 ${super.nextYearAge()}살이며,
장래희망은 ${this.futureHope}입니다.`; // 부모 메서드 결과 포함
    }
}

const kim = new IntroducePerson("kim", "23", "seoul", "개발자"); // 자식 클래스로 객체 생성

console.log(kim.introduce()); // 소개 문장 출력
// 출력결과
// 저는 seoul에 사는 kim입니다.
// 내년엔 24살이며,
// 장래희망은 개발자입니다.

IntroducePerson은 Person을 상속한다.
그래서 Person이 가지고 있던 name, age, nextYearAge() 흐름을 사용할 수 있다.


자식 클래스의 constructor 안에서 super(name, age)를 호출하면 부모 클래스의 constructor가 먼저 실행된다.
그 결과 this.name, this.age가 부모 클래스 방식으로 저장된다.
그 뒤 자식 클래스에서 필요한 city, futureHope를 추가로 저장한다.


extends는 부모 클래스의 기능을 이어받기 위한 문법이고, super()는 부모 클래스의 생성자를 호출해서 부모 쪽 초기화를 먼저 처리하는 문법이다.


static 메서드는 객체를 만들지 않고 클래스 이름으로 호출한다

static이 붙은 메서드는 인스턴스를 만들지 않고 클래스 이름으로 바로 호출할 수 있다.
인스턴스는 클래스를 바탕으로 실제로 만들어진 객체이다.


일반 메서드는 객체가 가진 데이터를 사용해서 동작하는 경우가 많다.
반면 static 메서드는 특정 객체 하나의 데이터보다, 클래스와 관련된 공통 기능을 만들 때 사용할 수 있다.

// class-static-method.js
class Calculator {
    static add(a, b) { // static 메서드 선언
        return a + b; // 두 값을 더한 결과 반환
    }
}

console.log(Calculator.add(10, 20)); // 클래스 이름으로 static 메서드 호출
// 출력결과
// 30

이 예제에서는 new Calculator()로 객체를 만들지 않았다.
대신 Calculator.add(10, 20)처럼 클래스 이름으로 바로 메서드를 호출했다.


static 메서드는 객체마다 따로 필요한 동작이 아니라, 클래스 이름으로 직접 호출할 공통 기능을 만들 때 사용한다.


getter와 setter는 값을 읽고 바꾸는 통로를 만든다

getter는 객체의 값을 읽을 때 사용하는 메서드이다.
setter는 객체의 값을 바꿀 때 사용하는 메서드이다.


겉으로는 프로퍼티를 읽고 쓰는 것처럼 보이지만, 실제로는 클래스 안의 메서드가 실행된다.
이 구조를 사용하면 값을 그냥 바꾸는 것이 아니라, 바꾸기 전에 조건을 확인하거나 필요한 형태로 가공할 수 있다.


아래 예제는 이름을 읽을 때 getter를 사용하고, 이름을 바꿀 때 setter를 사용하는 코드이다.

// class-getter-setter.js
class Person {
    constructor(name) { // 객체 생성 시 이름을 받음
        this._name = name; // 내부에서 사용할 이름 저장
    }

    get name() { // name 값을 읽을 때 실행
        return this._name; // 저장된 이름 반환
    }

    set name(newName) { // name 값을 바꿀 때 실행
        if (newName.length === 0) { // 빈 문자열인지 확인
            return; // 빈 문자열이면 변경하지 않음
        }

        this._name = newName; // 새 이름 저장
    }
}

const person = new Person("kim"); // 객체 생성

console.log(person.name); // getter 실행

person.name = "lee"; // setter 실행

console.log(person.name); // 변경된 이름 확인
// 출력결과
// kim
// lee

console.log(person.name)처럼 값을 읽으면 get name()이 실행된다.
person.name = "lee"처럼 값을 대입하면 set name(newName)이 실행된다.


이 예제에서는 새 이름이 빈 문자열이면 값을 바꾸지 않도록 조건을 넣었다.
이처럼 setter를 사용하면 값을 변경하기 전에 검사 로직을 넣을 수 있다.


여기서 _name은 이름 앞에 _를 붙인 일반 프로퍼티이다.
개발자가 내부에서 쓰겠다는 표시로 붙인 이름일 뿐, 클래스 밖에서 접근을 막는 진짜 비공개 문법은 아니다.
실제로 클래스 밖에서 접근을 막고 싶다면 다음 구간처럼 #name 형태의 비공개 프로퍼티를 사용해야 한다.


getter와 setter는 객체의 값을 직접 읽고 바꾸는 것처럼 보이게 하면서, 실제로는 클래스 안의 메서드를 통해 값을 관리하게 해 준다.


비공개 프로퍼티는 클래스 안에서만 사용할 수 있다

비공개 프로퍼티는 #name처럼 #을 붙여 선언한다.
이 문법은 ES6의 기본 class 문법 자체에 포함된 문법이라기보다, 이후 현대 JavaScript에서 클래스의 값을 더 안전하게 관리하기 위해 추가된 문법이다.
여기서는 클래스에서 값을 어디까지 공개하고 어디부터 숨길지 이해하기 위해 함께 정리한다.
비공개 프로퍼티는 클래스 밖에서 직접 접근할 수 없고, 클래스 안에서만 사용할 수 있다.


이 기능이 필요한 이유는 객체 안의 중요한 값을 아무 곳에서나 직접 바꾸지 못하게 하기 위해서이다.
값을 외부에 보여 주거나 바꿔야 한다면, 메서드를 통해 필요한 만큼만 공개할 수 있다.

// class-private-property.js
class Person {
    #name; // 비공개 프로퍼티 선언

    constructor(name) { // 객체 생성 시 이름을 받음
        this.#name = name; // 클래스 내부에서 비공개 값 저장
    }

    getName() { // 비공개 값을 밖으로 보여 주는 메서드
        return this.#name; // 클래스 내부에서는 접근 가능
    }
}

const person = new Person("kim"); // 객체 생성

console.log(person.getName()); // 메서드를 통해 값 확인
// 출력결과
// kim

#name은 클래스 안에서만 사용할 수 있다.
그래서 외부에서는 person.#name처럼 직접 접근할 수 없다.
대신 getName()처럼 공개된 메서드를 통해 필요한 값만 확인할 수 있다.


공개 프로퍼티는 밖에서 직접 읽고 바꿀 수 있고, 비공개 프로퍼티는 클래스 안에서만 다룰 수 있다.
이 차이를 알면 클래스에서 값을 어디까지 공개하고 어디부터 숨길지 판단할 수 있다.


클래스 핵심 정리

  • class는 객체를 만들기 위한 설계도이다.
  • 클래스는 일반 함수처럼 직접 호출할 수 없다.
  • 클래스는 먼저 정의한 뒤 사용해야 한다.
  • new를 사용하면 클래스로 실제 객체를 만들 수 있다.
  • 클래스로 만든 실제 객체를 인스턴스라고 한다.
  • constructor는 객체가 만들어질 때 처음 실행되어 초기값을 저장한다.
  • this는 지금 만들어진 객체 자신을 가리킨다.
  • extends는 부모 클래스를 상속할 때 사용한다.
  • super()는 부모 클래스의 constructor를 호출할 때 사용한다.
  • static 메서드는 객체를 만들지 않고 클래스 이름으로 호출한다.
  • getter는 값을 읽을 때 실행되는 메서드이다.
  • setter는 값을 바꿀 때 실행되는 메서드이다.
  • _name처럼 _를 붙이는 방식은 내부용 표시일 뿐, 진짜 비공개 문법은 아니다.
  • 비공개 프로퍼티는 #을 붙이고 클래스 안에서만 사용할 수 있다.

클래스는 객체를 반복해서 만들기 위한 틀이면서, 상속과 접근 범위를 통해 객체 구조를 더 체계적으로 관리하게 해 주는 문법이다.




Rest Operator와 Spread Operator

Rest Operator와 Spread Operator는 둘 다 ... 기호를 사용한다.
그래서 처음 보면 같은 문법처럼 보일 수 있다.
하지만 둘은 역할이 다르다.

Rest Operator는 여러 값을 하나로 묶는다.
반대로 Spread Operator는 하나로 묶여 있는 값을 여러 개로 펼친다.
배열과 함수 매개변수에서 사용하는 Rest/Spread 흐름은 ES6에서 자주 다루는 문법이고, 객체에서 사용하는 Rest/Spread 흐름은 이후 표준에서 추가된 현대 JavaScript 문법이다.
여기서는 실제 코드에서 함께 자주 쓰이므로 묶어서 정리한다.
Rest Operator는 값을 모아서 배열이나 객체로 묶는 역할이고, Spread Operator는 배열, 문자열, 객체처럼 묶인 값을 개별 값으로 펼치는 역할이다.


Rest Operator는 나머지 값을 하나로 묶는다

Rest Operator는 나머지 값들을 하나로 묶어 받는 문법이다.

// rest-array-basic.js
const numbers = [1, 2, 3, 4, 5, 6]; // 여러 숫자가 담긴 배열

const [first, second, ...others] = numbers; // 앞의 두 값은 따로 받고, 나머지는 배열로 묶음

console.log(first); // 첫 번째 값 출력
console.log(second); // 두 번째 값 출력
console.log(others); // 나머지 값 배열 출력
// 출력결과
// 1
// 2
// [3, 4, 5, 6]

배열에서 Rest Operator는 남은 여러 값을 하나의 배열로 모아 받는다.
함수 매개변수에서도 같은 흐름으로 사용할 수 있다.

// rest-function-args.js
function showNumbers(first, second, ...others) { // 앞의 두 값은 따로 받고 나머지는 배열로 묶음
    console.log(first); // 첫 번째 인자 출력
    console.log(second); // 두 번째 인자 출력
    console.log(others); // 나머지 인자 배열 출력
}

showNumbers(1, 2, 3, 4, 5); // 여러 값을 함수에 전달
// 출력결과
// 1
// 2
// [3, 4, 5]



객체에서도 Rest Operator를 사용할 수 있다

객체에서는 먼저 꺼낸 프로퍼티를 제외하고 남은 프로퍼티들이 새 객체로 묶인다.

// rest-object-basic.js
const user = {
    nickname: "짱구", // 닉네임 프로퍼티
    species: "human", // 종족 프로퍼티
    age: 5, // 나이 프로퍼티
    city: "떡잎마을" // 도시 프로퍼티
};

const { nickname, ...profile } = user; // nickname은 따로 받고, 나머지는 객체로 묶음

console.log(nickname); // 따로 꺼낸 nickname 출력
console.log(profile); // 나머지 프로퍼티가 묶인 객체 출력
// 출력결과
// 짱구
// { species: 'human', age: 5, city: '떡잎마을' }

배열의 Rest Operator는 남은 요소를 배열로 묶고, 객체의 Rest Operator는 남은 프로퍼티를 객체로 묶는다.


Rest Operator는 마지막에만 올 수 있다

Rest Operator는 나머지 값을 모으는 문법이다.
그래서 값을 받는 자리에서 마지막에 와야 한다.

// rest-operator-position-error.js
function wrongExample(...wrong, second) { // Rest Operator 뒤에 다른 매개변수가 와서 오류
    console.log(wrong); // 실행되지 않음
    console.log(second); // 실행되지 않음
}

wrongExample(1, 2, 3); // 함수 호출 시도
// 출력결과
// SyntaxError: Rest parameter must be last formal parameter

Rest Operator는 나머지를 모으는 문법이기 때문에 배열 구조분해할당, 객체 구조분해할당, 함수 매개변수에서 모두 마지막 위치에 작성해야 한다.


Spread Operator는 묶인 값을 개별 값으로 펼친다

Spread Operator는 배열, 문자열, 객체처럼 묶여 있는 값을 개별 값으로 펼치는 문법이다.

// spread-array-basic.js
const numbers = [1, 2, 3, 4, 5]; // 여러 숫자가 담긴 배열

console.log(numbers); // 배열 그대로 출력
console.log(...numbers); // 배열 안의 값을 개별 값으로 펼쳐 출력
// 출력결과
// [1, 2, 3, 4, 5]
// 1 2 3 4 5

문자열도 여러 글자가 순서대로 모여 있는 값이므로 Spread Operator로 펼칠 수 있다.

// spread-string-basic.js
const language = "javascript"; // 문자열 저장

console.log(...language); // 문자열을 문자 단위로 펼쳐 출력
// 출력결과
// j a v a s c r i p t



배열과 객체에서 Spread Operator 활용하기

Spread Operator는 배열을 복사하거나 합칠 때 사용할 수 있다.

// spread-array-merge.js
const firstNumbers = [1, 2]; // 첫 번째 배열
const secondNumbers = [3, 4]; // 두 번째 배열

const mergedNumbers = [...firstNumbers, ...secondNumbers]; // 두 배열의 값을 펼쳐 새 배열 생성

console.log(mergedNumbers); // 합쳐진 배열 출력
// 출력결과
// [1, 2, 3, 4]

객체에서도 Spread Operator를 사용할 수 있다.
객체에서 ...을 사용하면 객체의 프로퍼티를 펼쳐서 새 객체에 넣을 수 있다.

// object-spread-add.js
const user = { nickname: "짱구", species: "human" }; // 기존 객체

const newUser = { ...user, age: 5 }; // 기존 객체를 펼친 뒤 age를 추가한 새 객체 생성

console.log(user); // 기존 객체 출력
console.log(newUser); // 새 객체 출력
// 출력결과
// { nickname: '짱구', species: 'human' }
// { nickname: '짱구', species: 'human', age: 5 }

user.age = 5처럼 직접 추가하면 기존 객체가 수정된다.
반면 { ...user, age: 5 }는 기존 객체의 프로퍼티를 펼쳐 새 객체를 만든다.
객체 Spread Operator의 핵심은 기존 객체를 직접 수정하는 것이 아니라, 기존 객체의 프로퍼티를 펼쳐 새 객체를 만드는 데 있다.


객체 Spread Operator는 얕은 복사를 만든다

객체 Spread Operator로 새 객체를 만들면 겉 객체는 새로 만들어진다.
하지만 객체 안에 또 다른 객체가 들어 있다면, 그 안쪽 객체까지 모두 새로 복사되는 것은 아니다.
이런 복사를 얕은 복사라고 한다.

// object-spread-shallow-copy.js
const user = {
    nickname: "짱구", // 바깥 객체의 프로퍼티
    profile: { // 안쪽 객체
        city: "떡잎마을" // 안쪽 객체의 프로퍼티
    }
};

const copiedUser = { ...user }; // 바깥 객체만 새로 복사

copiedUser.profile.city = "서울"; // 안쪽 객체의 값을 변경

console.log(user.profile.city); // 원본 객체의 안쪽 값 확인
console.log(copiedUser.profile.city); // 복사본 객체의 안쪽 값 확인
console.log(user === copiedUser); // 바깥 객체 비교
console.log(user.profile === copiedUser.profile); // 안쪽 객체 비교
// 출력결과
// 서울
// 서울
// false
// true

user === copiedUser는 false이므로 바깥 객체는 새로 만들어졌다.
하지만 user.profile === copiedUser.profile은 true이므로 안쪽 객체는 같은 대상을 공유한다.


객체 Spread Operator는 바깥 객체를 새로 만들지만, 중첩 객체까지 자동으로 깊게 복사하지는 않는다.


객체 전개에서는 뒤에 있는 값이 앞의 값을 덮어쓴다

객체에서 Spread Operator를 사용할 때 같은 프로퍼티 이름이 여러 번 나오면, 뒤에 작성한 값이 앞의 값을 덮어쓴다.

// spread-object-overwrite.js
let user = { nickname: "짱구", species: "human" }; // 기존 객체

user = { ...user, age: 5 }; // 기존 객체를 펼치고 age 추가
console.log(user); // age가 추가된 객체 출력

user = { ...user, nickname: "짱아", age: 1 }; // 기존 값 중 nickname과 age를 뒤에서 덮어쓰기
console.log(user); // 덮어쓴 결과 출력
// 출력결과
// { nickname: '짱구', species: 'human', age: 5 }
// { nickname: '짱아', species: 'human', age: 1 }

객체에서 Spread Operator를 사용할 때는 같은 프로퍼티가 있으면 뒤에 작성한 값이 최종 값이 된다.


Rest와 Spread 핵심 정리

  • Rest Operator는 나머지 값을 하나로 묶는다.
  • 배열에서 Rest Operator는 남은 요소를 배열로 묶는다.
  • 객체에서 Rest Operator는 남은 프로퍼티를 객체로 묶는다.
  • 값을 받는 자리에서 Rest Operator는 마지막에 와야 한다.
  • Spread Operator는 배열, 문자열, 객체처럼 묶인 값을 개별 값으로 펼친다.
  • 객체 Spread Operator를 사용하면 기존 객체의 값을 바탕으로 새 객체를 만들 수 있다.
  • 객체 Spread Operator는 얕은 복사이므로 중첩 객체는 공유될 수 있다.
  • 같은 ...이라도 값을 받아 모으는 자리이면 Rest, 묶인 값을 펼쳐 사용하는 자리이면 Spread이다.

Rest Operator와 Spread Operator는 같은 기호를 쓰지만, Rest는 여러 값을 모으고 Spread는 묶인 값을 펼친다.




구조분해할당

구조분해할당은 배열이나 객체 안에 들어 있는 값을 꺼내서 변수에 바로 담는 문법이다.
구조를 분해해서 값을 할당한다는 의미이다.
여기서 구조는 배열이나 객체처럼 여러 값이 묶여 있는 형태를 말한다.


배열 구조분해할당은 순서를 기준으로 값을 꺼내고, 객체 구조분해할당은 key 이름을 기준으로 값을 꺼낸다.


배열 구조분해할당

배열은 값들이 순서대로 들어 있는 구조이다.
그래서 배열 구조분해할당은 왼쪽 변수의 순서와 오른쪽 배열 값의 순서를 맞춰서 값을 꺼낸다.

// destructuring-array-basic.js
const numbers = [10, 20, 30]; // 숫자 배열 생성

const [first, second] = numbers; // 배열의 첫 번째, 두 번째 값을 변수에 저장

console.log(first); // 첫 번째 값 출력
console.log(second); // 두 번째 값 출력
// 출력결과
// 10
// 20

배열 구조분해할당에서는 변수 이름보다 위치가 중요하다.
첫 번째 자리에 적힌 변수가 첫 번째 값을 받고, 두 번째 자리에 적힌 변수가 두 번째 값을 받는다.


필요 없는 값은 건너뛸 수 있다.

// destructuring-array-skip.js
const colors = ["red", "green", "blue"]; // 색상 배열 생성

const [firstColor, , thirdColor] = colors; // 두 번째 값은 건너뛰고 첫 번째와 세 번째 값만 저장

console.log(firstColor); // 첫 번째 색상 출력
console.log(thirdColor); // 세 번째 색상 출력
// 출력결과
// red
// blue



배열 구조분해할당과 Rest Operator

배열 구조분해할당은 Rest Operator와 함께 사용할 수 있다.
앞의 값은 각각 꺼내고, 나머지 값은 배열로 묶고 싶을 때 사용한다.

// destructuring-array-rest.js
const scores = [100, 90, 80, 70, 60]; // 점수 배열 생성

const [firstScore, secondScore, ...otherScores] = scores; // 앞의 두 값은 따로 받고 나머지는 배열로 묶음

console.log(firstScore); // 첫 번째 점수 출력
console.log(secondScore); // 두 번째 점수 출력
console.log(otherScores); // 나머지 점수 배열 출력
// 출력결과
// 100
// 90
// [80, 70, 60]

배열 구조분해할당에서 Rest Operator는 앞에서 꺼내고 남은 요소들을 새 배열로 묶는다.


객체 구조분해할당

객체는 배열과 다르게 순서보다 key 이름이 중요하다.
객체는 { key: value } 형태로 값을 저장한다.

// destructuring-object-basic.js
const user = {
    nickname: "kim", // 닉네임 프로퍼티
    age: 20, // 나이 프로퍼티
    city: "seoul" // 도시 프로퍼티
};

const { nickname, age } = user; // key 이름과 같은 변수로 값 꺼내기

console.log(nickname); // nickname 값 출력
console.log(age); // age 값 출력
// 출력결과
// kim
// 20

객체 구조분해할당에서는 위치가 기준이 아니다.
객체 안에서 nickname이 먼저 있든 뒤에 있든, key 이름이 맞으면 값을 꺼낼 수 있다.


객체의 key와 다른 변수명으로 값을 꺼낼 수도 있다.

// destructuring-object-rename.js
const user = {
    nickname: "kim", // 닉네임 프로퍼티
    age: 20 // 나이 프로퍼티
};

const { nickname: userNickname, age: userAge } = user; // key 이름과 다른 변수명으로 값 꺼내기

console.log(userNickname); // 바꾼 변수명으로 닉네임 출력
console.log(userAge); // 바꾼 변수명으로 나이 출력
// 출력결과
// kim
// 20

nickname: userNickname에서 왼쪽의 nickname은 객체의 key 이름이고, 오른쪽의 userNickname은 새로 만드는 변수 이름이다.
객체 구조분해할당에서 key: 새변수명 형태를 쓰면, 실제로 만들어지는 변수는 오른쪽의 새 변수명이다.


객체 구조분해할당과 Rest Operator

객체 구조분해할당에서도 Rest Operator를 사용할 수 있다.
배열에서는 남은 요소가 배열로 묶였지만, 객체에서는 남은 프로퍼티가 새 객체로 묶인다.

// destructuring-object-rest.js
const user = {
    nickname: "kim", // 닉네임 프로퍼티
    age: 20, // 나이 프로퍼티
    city: "seoul", // 도시 프로퍼티
    job: "developer" // 직업 프로퍼티
};

const { nickname, ...profile } = user; // nickname은 따로 받고 나머지는 객체로 묶음

console.log(nickname); // 따로 꺼낸 nickname 출력
console.log(profile); // 나머지 프로퍼티 객체 출력
// 출력결과
// kim
// { age: 20, city: 'seoul', job: 'developer' }

배열 구조분해할당에서 Rest Operator는 남은 요소를 배열로 묶는다.
객체 구조분해할당에서 Rest Operator는 남은 프로퍼티를 객체로 묶는다.


구조분해할당 핵심 정리

  • 구조분해할당은 묶여 있는 값을 분해해서 변수에 저장하는 문법이다.
  • 배열 구조분해할당은 순서를 기준으로 값을 꺼낸다.
  • 객체 구조분해할당은 key 이름을 기준으로 값을 꺼낸다.
  • 객체에서 key: 새변수명 형태로 다른 변수명을 사용할 수 있다.
  • 구조분해할당에서 Rest Operator는 마지막에 작성해야 한다.

구조분해할당을 읽을 때는 배열인지 객체인지 먼저 보고, 배열이면 순서 기준, 객체면 key 이름 기준으로 값을 꺼낸다고 판단해야 한다.




배열 처리 메서드

JavaScript에서 배열은 여러 값을 순서대로 담아 두는 구조이다.
배열에 값이 여러 개 들어 있으면 보통 그 값을 하나씩 꺼내서 처리해야 한다.
배열 처리 메서드는 이런 반복 작업을 더 읽기 쉽게 작성하기 위한 도구이다.


forEach(), map(), reduce(), filter()는 ES6에서 새로 추가된 문법은 아니다.
하지만 ES6 문법인 화살표 함수와 함께 자주 사용되고, 현대 JavaScript 코드에서 배열 데이터를 처리할 때 기본처럼 쓰인다.
그래서 이 정리본에서는 ES6 흐름과 연결해서 함께 정리한다.


배열 처리 메서드는 배열 안의 요소를 하나씩 꺼내 처리하는 흐름을 더 분명하게 작성하기 위한 도구이다.
대표적으로 forEach(), map(), reduce(), filter()가 있다.


callback 함수

배열 처리 메서드를 이해하려면 먼저 callback 함수를 알아야 한다.
callback 함수는 다른 함수에게 전달되어 나중에 실행되는 함수이다.
배열 메서드에서는 callback 함수가 배열 요소마다 반복 실행된다.


대부분의 배열 메서드에서 자주 쓰는 callback 함수의 매개변수는 현재 요소, 인덱스, 원본 배열이다.
모든 예제에서 세 매개변수를 전부 사용할 필요는 없고, 필요한 것만 사용하면 된다.


forEach는 배열 요소마다 함수를 실행한다

forEach()는 배열의 요소를 하나씩 꺼내면서 callback 함수를 실행하는 메서드이다.
처리 결과를 모아 새 배열을 만들어 주지는 않는다.

// array-foreach-basic.js
const fruits = ["apple", "banana", "orange"]; // 과일 이름 배열

fruits.forEach((fruit, index) => { // 배열 요소와 인덱스를 하나씩 꺼내 실행
    console.log(`${index}번 과일: ${fruit}`); // 인덱스와 값 출력
});
// 출력결과
// 0번 과일: apple
// 1번 과일: banana
// 2번 과일: orange

forEach()는 배열 요소마다 어떤 동작을 실행할 때 사용하고, 실행 결과를 새 배열로 반환하지 않는다.


map은 배열 요소를 변환한 결과로 새 배열을 만든다

map()은 배열의 요소를 하나씩 처리한 뒤, 각 처리 결과를 모아 새 배열을 만드는 메서드이다.

// array-map-basic.js
const numbers = [1, 2, 3, 4]; // 숫자 배열

const doubledNumbers = numbers.map((number) => { // 각 요소를 처리해 새 배열 생성
    return number * 2; // 현재 숫자를 2배로 계산한 값 반환
});

console.log(doubledNumbers); // 새 배열 출력
console.log(numbers); // 원본 배열 확인
// 출력결과
// [2, 4, 6, 8]
// [1, 2, 3, 4]

map()은 반환값을 하나씩 모아 새 배열을 만든다.
원본 배열은 그대로 남아 있다.
map()의 핵심은 원본 배열을 직접 바꾸는 것이 아니라, 배열 요소를 변환한 결과로 같은 길이의 새 배열을 만든다는 점이다.


forEach와 map은 반환값이 다르다

forEach()와 map()은 둘 다 배열 요소마다 함수를 실행한다.
하지만 forEach()는 새 배열을 반환하지 않고, map()은 새 배열을 반환한다.

// array-foreach-map-difference.js
const numbers = [1, 2, 3]; // 숫자 배열

const forEachResult = numbers.forEach((number) => { // 각 요소마다 실행
    return number * 2; // 반환해도 새 배열로 모이지 않음
});

const mapResult = numbers.map((number) => { // 각 요소마다 실행
    return number * 2; // 반환값을 모아 새 배열 생성
});

console.log(forEachResult); // forEach 결과 확인
console.log(mapResult); // map 결과 확인
// 출력결과
// undefined
// [2, 4, 6]

forEach()는 반복 실행용이고, map()은 변환 결과로 새 배열을 만들기 위한 메서드이다.


reduce와 filter

reduce()는 배열의 여러 값을 하나의 결과값으로 줄이는 메서드이다.
배열의 요소를 하나씩 처리하면서 누적값을 만들고, 마지막에 하나의 결과를 반환한다.

// array-reduce-sum.js
const numbers = [10, 20, 30]; // 숫자 배열

const sum = numbers.reduce((total, number) => { // total은 누적값, number는 현재값
    return total + number; // 누적값에 현재값 더하기
}, 0); // 초기 누적값은 0

console.log(sum); // 합계 출력
// 출력결과
// 60

reduce()의 두 번째 인자는 초깃값이다.
위 예제에서는 초깃값을 0으로 정했기 때문에 누적값 total은 0에서 시작한다.
그다음 배열의 첫 번째 값 10, 두 번째 값 20, 세 번째 값 30을 차례대로 더해 최종 결과 60을 만든다.


초깃값을 생략하면 배열의 0번째 요소가 처음 누적값으로 사용된다.
예를 들어 [10, 20, 30]에서 초깃값을 생략하면 10이 처음 누적값이 되고, 그다음 값인 20부터 계산이 시작된다.
초보자 단계에서는 누적 흐름을 예측하기 쉽도록 초깃값을 직접 적는 편이 좋다.
reduce()는 초깃값을 명시하면 누적 계산이 어디서 시작되는지 분명해진다.


filter()는 배열 요소 중 조건을 만족하는 값만 골라 새 배열을 만드는 메서드이다.

// array-filter-basic.js
const numbers = [1, 2, 3, 4, 5, 6]; // 숫자 배열

const evenNumbers = numbers.filter((number) => { // 조건에 맞는 요소만 새 배열로 생성
    return number % 2 === 0; // 짝수이면 true
});

console.log(evenNumbers); // 짝수 배열 출력
// 출력결과
// [2, 4, 6]

filter()와 map()은 이어서 사용할 수 있다.

// array-filter-map-chain.js
const numbers = [1, 2, 3, 4, 5, 6]; // 숫자 배열

const result = numbers
    .filter(number => number % 2 === 0) // 짝수만 고르기
    .map(number => number * 10); // 고른 값들을 10배로 변환

console.log(result); // 최종 배열 출력
// 출력결과
// [20, 40, 60]

filter()는 조건에 맞는 요소를 고르고, map()은 고른 요소를 다른 값으로 변환한다.


배열 처리 메서드 핵심 정리

  • forEach(), map(), reduce(), filter()는 ES6 자체에서 새로 추가된 문법은 아니지만 현대 JavaScript 배열 처리에서 자주 사용한다.
  • forEach()는 배열 요소마다 함수를 실행한다.
  • forEach()는 새 배열을 반환하지 않는다.
  • map()은 배열 요소를 변환해서 새 배열을 반환한다.
  • reduce()는 배열 요소를 누적해서 하나의 결과값으로 만든다.
  • reduce()의 두 번째 인자는 초깃값이다.
  • reduce()에서 초깃값을 생략하면 배열의 첫 번째 요소가 처음 누적값으로 사용된다.
  • filter()는 조건을 만족하는 요소만 골라 새 배열을 만든다.
  • filter()와 map()은 이어서 사용할 수 있다.

배열 처리 메서드를 사용할 때는 “반복 실행인지, 새 배열 만들기인지, 하나의 값으로 줄이기인지, 조건으로 거르기인지”를 먼저 판단해야 한다.




Promise

Promise는 비동기 작업의 결과를 나중에 받기 위해 사용하는 객체이다.
여기서 비동기 작업은 코드가 실행되자마자 결과가 바로 나오지 않는 작업을 말한다.


예를 들어 서버에서 데이터를 받아 오거나, 일정 시간이 지난 뒤 실행되는 작업은 결과가 바로 준비되지 않는다.
이런 작업은 “지금 당장 결과는 없지만, 나중에 성공하거나 실패할 수 있는 작업”으로 봐야 한다.


Promise는 이처럼 나중에 끝나는 작업을 하나의 객체로 감싸서 다룰 수 있게 해 준다.
작업이 성공하면 성공 결과를 전달하고, 작업이 실패하면 실패 이유를 전달한다.


Promise는 비동기 작업의 성공 또는 실패 결과를 나중에 처리하기 위한 약속 객체이다.
즉, 결과가 아직 준비되지 않은 작업을 정리된 흐름으로 다루기 위해 사용한다.


Promise는 바로 값이 아니라 나중의 결과를 담는 약속이다

Promise를 처음 볼 때 가장 헷갈리는 부분은 Promise가 결과값 그 자체가 아니라는 점이다.
Promise는 “나중에 결과가 생길 작업”을 표현한다.


예를 들어 서버에 사용자 정보를 요청했다고 생각해 보자.
요청을 보낸 순간에는 아직 사용자 정보가 도착하지 않았다.
네트워크 상황에 따라 조금 뒤에 도착할 수도 있고, 오류 때문에 실패할 수도 있다.


이때 Promise는 이런 상태를 표현한다.
지금 당장은 결과가 없지만, 시간이 지나면 성공 결과가 생기거나 실패 이유가 생긴다.


쉽게 말하면 아래처럼 볼 수 있다.

  • 지금 결과가 없지만 작업이 진행 중이면 Promise가 대기 상태를 가진다.
  • 작업이 성공하면 성공 결과를 가진다.
  • 작업이 실패하면 실패 이유를 가진다.

그래서 Promise는 비동기 작업을 “성공할 수도 있고 실패할 수도 있는 하나의 흐름”으로 다루게 해 준다.


setTimeout만 사용하면 작업 순서가 기대와 다를 수 있다

Promise가 필요한 이유를 이해하려면 먼저 비동기 작업의 실행 순서를 봐야 한다.
setTimeout()은 일정 시간이 지난 뒤 코드를 실행하는 비동기 처리 함수이다.
그래서 setTimeout() 안의 코드가 끝날 때까지 전체 코드가 멈춰서 기다리는 것이 아니다.


아래 예제는 setTimeout()만 사용했을 때의 흐름이다.

// exam26.html
<h1>setTimeout() 비동기 처리 함수라서...</h1>
<script>
function work() {
    console.log("작업 시작"); // ① 먼저 실행

    setTimeout(() => {
        console.log("작업 완료"); // ③ 2초 뒤 실행
    }, 2000);

    console.log("다음 작업"); // ② 기다리지 않고 바로 실행
}

work(); // 함수 실행
</script>
// 출력결과
// 작업 시작
// 다음 작업
// 작업 완료

코드를 위에서 아래로 보면 "작업 완료"가 "다음 작업"보다 먼저 나올 것처럼 보일 수 있다.
하지만 setTimeout()은 비동기 처리 함수이기 때문에 2초를 기다리는 동안 다음 코드가 먼저 실행된다.
그래서 출력 순서는 "작업 시작" → "다음 작업" → "작업 완료"가 된다.


이 흐름이 항상 나쁜 것은 아니다.
하지만 “작업이 완료된 다음에 다음 작업을 실행해야 하는 상황”이라면 이 순서는 문제가 될 수 있다.
예를 들어 서버에서 데이터를 받아 온 뒤 화면에 출력해야 하는데, 데이터가 오기 전에 화면 출력 코드가 먼저 실행되면 원하는 결과가 나오지 않는다.


이럴 때 Promise를 사용하면 비동기 작업이 끝난 뒤 다음 흐름을 연결할 수 있다.

// exam26_1.html
<h2>Promise 객체를 사용하는 이유 : 비동기 작업 순서를 깔끔하게 처리하기 위해</h2>
<h3>Promise는 지금 바로 결과는 없지만, 작업이 끝나면 결과를 알려주겠다는 객체</h3>
<script>
function work() {
    return new Promise((resolve, reject) => { // Promise 객체 반환
        console.log("작업 시작"); // ① 먼저 실행

        setTimeout(() => {
            resolve("작업 완료"); // ② 2초 뒤 성공 결과 전달
        }, 2000);
    });
}

work()
    .then((result) => {
        console.log(result); // ② resolve 값 출력
    })
    .finally(() => {
        console.log("다음 작업"); // ③ 작업 완료 후 마지막 실행
    });
</script>
// 출력결과
// 작업 시작
// 작업 완료
// 다음 작업

이번에는 work()가 Promise를 반환한다.
setTimeout() 안에서 resolve("작업 완료")가 호출되면 작업이 성공 상태가 되고, .then()에서 그 결과를 받는다.
그리고 .finally()에서 마지막 작업을 실행한다.


이렇게 작성하면 "작업 시작" → "작업 완료" → "다음 작업" 순서가 된다.
Promise는 비동기 작업의 성공과 실패를 다루는 객체이면서, 비동기 작업이 끝난 뒤 다음 흐름을 깔끔하게 연결하기 위한 도구이다.


Promise는 세 가지 상태를 가진다

Promise는 작업 진행 상황에 따라 상태가 바뀐다.
대표 상태는 Pending, Fulfilled, Rejected이다.


Pending은 대기 상태이다.
아직 작업이 끝나지 않았고, 성공인지 실패인지 결정되지 않은 상태이다.


Fulfilled는 이행 상태이다.
작업이 성공적으로 끝났고, 성공 결과가 준비된 상태이다.


Rejected는 거부 상태이다.
작업이 실패했고, 실패 이유가 준비된 상태이다.


이 세 상태를 정리하면 아래와 같다.

  • Pending은 아직 작업 결과가 결정되지 않은 상태이다.
  • Fulfilled는 작업이 성공한 상태이다.
  • Rejected는 작업이 실패한 상태이다.

Promise는 처음에는 Pending 상태로 시작하고, 작업 결과에 따라 Fulfilled 또는 Rejected 상태로 바뀐다.
한 번 성공하거나 실패한 Promise는 다시 대기 상태로 돌아가지 않는다.


Promise는 처음에는 Pending 상태에서 기다리고, 작업이 성공하면 Fulfilled, 실패하면 Rejected 상태로 바뀐다.
resolve()는 성공 흐름으로 보내고, reject()는 실패 흐름으로 보낸다.


resolve는 성공 결과를 전달한다

Promise를 만들 때는 new Promise()를 사용한다.
그리고 그 안에 함수를 전달한다.
이 함수 안에서 작업이 성공하면 resolve()를 호출한다.


resolve는 작업이 성공했을 때 결과를 전달하는 함수이다.
resolve() 안에 넣은 값은 나중에 .then()에서 받을 수 있다.


아래 예제는 성공하는 Promise이다.

// promise-resolve-basic.js
const promise = new Promise((resolve, reject) => { // Promise 객체 생성
    resolve("작업 성공"); // 성공 결과 전달
});

promise.then((result) => { // 성공 결과 받기
    console.log(result); // 전달받은 결과 출력
});
// 출력결과
// 작업 성공

이 코드에서 new Promise() 안의 함수가 실행된다.
그 안에서 resolve("작업 성공")을 호출한다.
그러면 이 Promise는 성공 상태가 된다.


그다음 .then()이 실행된다.
resolve()에 넣었던 "작업 성공" 값이 .then()의 result로 전달된다.
그래서 "작업 성공"이 출력된다.


정리하면 resolve()는 성공 결과를 넘겨 주고, .then()은 그 성공 결과를 받아 처리한다.


reject는 실패 이유를 전달한다

작업이 항상 성공하는 것은 아니다.
서버 요청이 실패하거나, 필요한 데이터가 없거나, 조건이 맞지 않으면 실패할 수 있다.


이때 사용하는 함수가 reject이다.
reject는 작업이 실패했을 때 실패 이유를 전달하는 함수이다.
reject() 안에 넣은 값은 나중에 .catch()에서 받을 수 있다.


아래 예제는 실패하는 Promise이다.

// promise-reject-basic.js
const promise = new Promise((resolve, reject) => { // Promise 객체 생성
    reject("작업 실패"); // 실패 이유 전달
});

promise.catch((error) => { // 실패 이유 받기
    console.log(error); // 전달받은 오류 출력
});
// 출력결과
// 작업 실패

이 코드에서 reject("작업 실패")가 실행된다.
그러면 이 Promise는 실패 상태가 된다.


실패 상태가 되면 .then()이 아니라 .catch()에서 실패 이유를 처리한다.
reject()에 넣었던 "작업 실패" 값이 .catch()의 error로 전달된다.
그래서 "작업 실패"가 출력된다.


정리하면 reject()는 실패 이유를 넘겨 주고, .catch()는 그 실패 이유를 받아 처리한다.


then, catch, finally는 Promise 결과를 처리한다

Promise의 결과를 처리할 때는 .then(), .catch(), .finally()를 사용한다.
이 세 메서드는 각각 역할이 다르다.


.then()은 Promise가 성공했을 때 실행된다.
성공 결과를 받아서 다음 작업을 처리할 수 있다.


.catch()는 Promise가 실패했을 때 실행된다.
실패 이유를 받아서 오류 처리 흐름을 작성할 수 있다.


.finally()는 성공하든 실패하든 마지막에 실행된다.
Promise.prototype.finally()는 Promise 흐름에서 자주 쓰이지만, ES6 이후 표준에서 추가된 메서드이다.
여기서는 성공과 실패 후 공통 마무리 흐름을 이해하기 위해 함께 정리한다.
성공 결과나 실패 이유와 상관없이 공통으로 처리해야 하는 작업에 사용한다.
예를 들어 로딩 표시를 끄거나, 마무리 로그를 남기는 작업이 여기에 해당한다.


아래 예제는 성공 흐름에서 .then()과 .finally()가 실행되는 코드이다.

// promise-then-finally.js
const promise = new Promise((resolve, reject) => { // Promise 객체 생성
    resolve("데이터 조회 성공"); // 성공 결과 전달
});

promise
    .then((result) => { // 성공했을 때 실행
        console.log(result); // 성공 결과 출력
    })
    .catch((error) => { // 실패했을 때 실행
        console.log(error); // 실패 이유 출력
    })
    .finally(() => { // 성공과 실패에 상관없이 실행
        console.log("작업 종료"); // 마무리 문장 출력
    });
// 출력결과
// 데이터 조회 성공
// 작업 종료

resolve("데이터 조회 성공")이 실행되었기 때문에 Promise는 성공 상태가 된다.
그래서 .then()이 실행되고, "데이터 조회 성공"이 출력된다.


실패한 것이 아니므로 .catch()는 실행되지 않는다.
하지만 .finally()는 성공과 실패에 상관없이 실행된다.
그래서 마지막에 "작업 종료"가 출력된다.


이번에는 실패 흐름을 보면 된다.

// promise-catch-finally.js
const promise = new Promise((resolve, reject) => { // Promise 객체 생성
    reject("데이터 조회 실패"); // 실패 이유 전달
});

promise
    .then((result) => { // 성공했을 때 실행
        console.log(result); // 성공 결과 출력
    })
    .catch((error) => { // 실패했을 때 실행
        console.log(error); // 실패 이유 출력
    })
    .finally(() => { // 성공과 실패에 상관없이 실행
        console.log("작업 종료"); // 마무리 문장 출력
    });
// 출력결과
// 데이터 조회 실패
// 작업 종료

reject("데이터 조회 실패")가 실행되었기 때문에 Promise는 실패 상태가 된다.
그래서 .then()은 실행되지 않고, .catch()가 실행된다.


그리고 .finally()는 실패한 경우에도 실행된다.
그래서 "데이터 조회 실패" 다음에 "작업 종료"가 출력된다.


.then()은 성공 처리, .catch()는 실패 처리, .finally()는 성공과 실패에 상관없는 마무리 처리에 사용한다.


Promise 체이닝은 작업을 순서대로 이어 붙이는 방식이다

Promise는 .then()을 이어 붙여서 다음 작업으로 결과를 넘길 수 있다.
이것을 Promise 체이닝이라고 한다.
체이닝은 메서드를 사슬처럼 계속 이어서 호출하는 방식이다.


Promise 체이닝이 필요한 이유는 비동기 작업의 결과를 다음 작업에서 계속 사용해야 할 때가 있기 때문이다.
예를 들어 첫 번째 작업에서 숫자를 받고, 두 번째 작업에서 그 숫자에 2를 곱하고, 세 번째 작업에서 다시 10을 더할 수 있다.


아래 예제는 .then()을 이어 붙이는 기본 흐름이다.

// promise-chaining-basic.js
const promise = new Promise((resolve, reject) => { // Promise 객체 생성
    resolve(10); // 첫 번째 성공 결과 전달
});

promise
    .then((number) => { // 첫 번째 then
        console.log(number); // 10 출력
        return number * 2; // 다음 then으로 20 전달
    })
    .then((number) => { // 두 번째 then
        console.log(number); // 20 출력
        return number + 10; // 다음 then으로 30 전달
    })
    .then((number) => { // 세 번째 then
        console.log(number); // 30 출력
    });
// 출력결과
// 10
// 20
// 30

첫 번째 .then()은 resolve(10)에서 전달한 10을 받는다.
그 안에서 number * 2를 return했기 때문에 다음 .then()으로 20이 전달된다.


두 번째 .then()은 20을 받아 출력하고, number + 10을 return한다.
그래서 세 번째 .then()으로 30이 전달된다.


이처럼 .then() 안에서 값을 return하면, 그 값이 다음 .then()으로 넘어간다.
Promise 체이닝은 이전 작업의 결과를 다음 작업으로 넘기면서 비동기 흐름을 순서대로 연결하는 방식이다.


Promise 안의 작업은 바로 실행된다

초보자가 헷갈리기 쉬운 부분이 있다.
Promise 객체를 만들기만 해도 new Promise() 안에 전달한 함수는 바로 실행된다.


.then()을 붙였을 때 작업이 처음 시작되는 것이 아니다.
Promise를 생성하는 순간 내부 함수가 먼저 실행되고, .then()은 그 결과를 나중에 처리하는 역할을 한다.


아래 예제로 확인할 수 있다.

// promise-executor-run.js
const promise = new Promise((resolve, reject) => { // Promise 생성과 동시에 실행
    console.log("Promise 내부 실행"); // 먼저 출력
    resolve("성공 결과"); // 성공 결과 전달
});

console.log("then 등록 전"); // Promise 생성 후 출력

promise.then((result) => { // 성공 결과 처리
    console.log(result); // 성공 결과 출력
});
// 출력결과
// Promise 내부 실행
// then 등록 전
// 성공 결과

이 코드에서 new Promise() 안의 console.log("Promise 내부 실행")이 먼저 실행된다.
그다음 "then 등록 전"이 출력된다.
마지막으로 .then()에서 "성공 결과"가 출력된다.


즉, Promise는 생성되는 순간 내부 작업이 실행된다.
.then()은 그 작업이 성공했을 때 결과를 받아 처리하는 흐름이다.


Promise 유틸리티는 여러 Promise의 결과를 기준에 맞게 처리한다

Promise.all(), Promise.allSettled(), Promise.race(), Promise.any()는 여러 Promise를 함께 다룰 때 사용하는 메서드이다.


이 메서드들은 모두 여러 Promise를 다루는 흐름에서 함께 자주 쓰이지만, 모두 ES6에서 한 번에 추가된 문법은 아니다.
Promise.all()과 Promise.race()는 ES6부터 사용할 수 있고, Promise.allSettled()와 Promise.any()는 이후 표준에서 추가된 메서드이다.
여기서는 여러 Promise 결과를 비교해서 이해하기 위해 함께 정리한다.


여기서 중요한 점은 이 메서드들이 비동기 작업을 직접 새로 시작하는 도구가 아니라는 점이다.
이미 만들어진 여러 Promise를 받아서, 각각의 기준에 맞게 성공 결과나 실패 결과를 처리한다.


예제에서 사용하는 Promise.resolve()는 이미 성공한 Promise를 만드는 메서드이다.
Promise.reject()는 이미 실패한 Promise를 만드는 메서드이다.
예제에서는 여러 Promise의 성공과 실패 흐름을 짧게 보여 주기 위해 사용한다.


Promise 유틸리티는 여러 작업을 직접 실행하는 문법이 아니라, 전달받은 여러 Promise의 결과를 어떤 기준으로 처리할지 정하는 메서드이다.


Promise.all은 여러 Promise가 모두 성공해야 성공한다

여러 비동기 작업의 결과를 한 번에 기다리고, 모든 작업이 성공했을 때 결과를 모아야 할 때가 있다.
이때 Promise.all()을 사용할 수 있다.


Promise.all()은 여러 Promise를 배열로 받는다.
그리고 모든 Promise가 성공하면, 각 성공 결과를 배열로 모아 .then()에 전달한다.
이때 결과 배열은 먼저 끝난 순서가 아니라, Promise.all()에 전달한 배열의 순서를 따른다.


아래 예제는 세 작업이 모두 성공하는 경우이다.

// promise-all-basic.js
const promise1 = Promise.resolve("첫 번째 성공"); // 이미 성공한 Promise
const promise2 = Promise.resolve("두 번째 성공"); // 이미 성공한 Promise
const promise3 = Promise.resolve("세 번째 성공"); // 이미 성공한 Promise

Promise.all([promise1, promise2, promise3]) // 모든 Promise가 성공해야 성공
    .then((results) => { // 성공 결과 배열 받기
        console.log(results); // 결과 배열 출력
    });
// 출력결과
// ['첫 번째 성공', '두 번째 성공', '세 번째 성공']

세 Promise가 모두 성공했기 때문에 .then()이 실행된다.
results에는 각 Promise의 성공 결과가 배열로 들어간다.


하지만 하나라도 실패하면 Promise.all()은 실패한다.

// promise-all-reject.js
const promise1 = Promise.resolve("첫 번째 성공"); // 성공 Promise
const promise2 = Promise.reject("두 번째 실패"); // 실패 Promise
const promise3 = Promise.resolve("세 번째 성공"); // 성공 Promise

Promise.all([promise1, promise2, promise3]) // 하나라도 실패하면 실패
    .then((results) => { // 모두 성공해야 실행
        console.log(results); // 실행되지 않음
    })
    .catch((error) => { // 실패 처리
        console.log(error); // 실패 이유 출력
    });
// 출력결과
// 두 번째 실패

promise2가 실패했기 때문에 Promise.all()은 전체 실패로 처리된다.
그래서 .then()은 실행되지 않고 .catch()가 실행된다.


다만 Promise.all()이 실패 흐름으로 넘어간다고 해서 이미 시작된 다른 작업이 자동으로 취소된다는 뜻은 아니다.
여기서는 결과 처리 흐름이 실패로 넘어간다고 이해하면 된다.


Promise.all()은 여러 작업이 모두 성공해야 다음 단계로 넘어갈 수 있을 때 사용한다.
하나라도 실패하면 전체가 실패 흐름으로 넘어가지만, 이미 시작된 다른 Promise 작업 자체를 자동으로 멈추는 것은 아니다.


Promise.allSettled는 성공과 실패를 모두 기다린다

Promise.allSettled()는 여러 Promise가 성공하든 실패하든 모두 끝날 때까지 기다린다.
여기서 settled는 성공 또는 실패로 결과가 결정된 상태를 의미한다.


Promise.all()은 하나라도 실패하면 실패 흐름으로 넘어간다.
반면 Promise.allSettled()는 실패한 작업이 있어도 나머지 작업까지 모두 끝난 뒤 결과를 모아 준다.


아래 예제는 성공과 실패가 섞여 있는 경우이다.

// promise-allsettled-basic.js
const promise1 = Promise.resolve("첫 번째 성공"); // 성공 Promise
const promise2 = Promise.reject("두 번째 실패"); // 실패 Promise
const promise3 = Promise.resolve("세 번째 성공"); // 성공 Promise

Promise.allSettled([promise1, promise2, promise3]) // 성공과 실패를 모두 기다림
    .then((results) => { // 모든 결과 받기
        console.log(results); // 결과 상태 배열 출력
    });
// 출력결과
// [
//   { status: 'fulfilled', value: '첫 번째 성공' },
//   { status: 'rejected', reason: '두 번째 실패' },
//   { status: 'fulfilled', value: '세 번째 성공' }
// ]

결과 배열에는 각 Promise의 상태가 들어간다.
성공한 작업은 status가 "fulfilled"이고, 성공 값은 value에 들어간다.
실패한 작업은 status가 "rejected"이고, 실패 이유는 reason에 들어간다.


즉, Promise.allSettled()는 중간에 실패가 있어도 전체 작업 결과를 끝까지 확인할 수 있다.


Promise.race는 가장 먼저 끝난 Promise의 결과를 사용한다

Promise.race()는 여러 Promise 중 가장 먼저 결과가 결정된 Promise의 결과를 사용한다.
여기서 결과가 결정된다는 말은 성공 또는 실패 중 하나로 끝났다는 뜻이다.


race는 경주라는 뜻이다.
이미 만들어진 여러 Promise 중 가장 먼저 끝난 작업의 결과를 사용하는 방식이라고 이해하면 된다.


아래 예제는 가장 먼저 성공한 작업의 결과가 사용되는 경우이다.

// promise-race-basic.js
const slowPromise = new Promise((resolve, reject) => { // 느린 Promise
    setTimeout(() => resolve("느린 작업 성공"), 2000); // 2초 뒤 성공
});

const fastPromise = new Promise((resolve, reject) => { // 빠른 Promise
    setTimeout(() => resolve("빠른 작업 성공"), 1000); // 1초 뒤 성공
});

Promise.race([slowPromise, fastPromise]) // 가장 먼저 끝난 Promise 사용
    .then((result) => { // 먼저 성공한 결과 받기
        console.log(result); // 결과 출력
    });
// 출력결과
// 빠른 작업 성공

fastPromise는 1초 뒤에 성공하고, slowPromise는 2초 뒤에 성공한다.
그래서 가장 먼저 끝난 fastPromise의 결과인 "빠른 작업 성공"이 출력된다.


중요한 점은 Promise.race()가 가장 먼저 성공한 것만 보는 것이 아니라는 점이다.
가장 먼저 끝난 작업이 실패라면 실패 흐름으로 넘어간다.


또 하나 더 주의할 점이 있다.
Promise.race()가 가장 먼저 끝난 결과를 선택한다고 해서 나머지 Promise 작업이 자동으로 취소되는 것은 아니다.
이미 시작된 작업은 계속 진행될 수 있고, race()는 그중 가장 먼저 결정된 결과를 최종 처리 흐름으로 사용하는 것이다.


Promise.race()는 가장 먼저 끝난 결과를 선택하지만, 선택되지 않은 다른 작업을 자동으로 멈추는 문법은 아니다.


Promise.any는 가장 먼저 성공한 Promise의 결과를 사용한다

Promise.any()는 여러 Promise 중 가장 먼저 성공한 결과를 사용한다.
Promise.race()와 비슷해 보이지만 기준이 다르다.


Promise.race()는 성공이든 실패든 가장 먼저 끝난 작업을 기준으로 한다.
반면 Promise.any()는 실패는 최종 성공 결과로 보지 않고, 가장 먼저 성공한 결과를 찾는다.


아래 예제는 먼저 실패가 나오지만, 나중에 성공한 결과가 사용되는 경우이다.

// promise-any-basic.js
const failPromise = new Promise((resolve, reject) => { // 실패 Promise
    setTimeout(() => reject("첫 번째 실패"), 1000); // 1초 뒤 실패
});

const successPromise = new Promise((resolve, reject) => { // 성공 Promise
    setTimeout(() => resolve("두 번째 성공"), 2000); // 2초 뒤 성공
});

Promise.any([failPromise, successPromise]) // 가장 먼저 성공한 Promise 사용
    .then((result) => { // 성공 결과 받기
        console.log(result); // 결과 출력
    });
// 출력결과
// 두 번째 성공

failPromise가 먼저 끝나지만 실패 결과이다.
Promise.any()는 이 실패 결과를 최종 결과로 삼지 않는다.
그다음 successPromise가 성공하면 그 성공 결과를 사용한다.


만약 모든 Promise가 실패하면 Promise.any()도 실패한다.
이때는 모든 작업이 실패했다는 의미로 AggregateError가 발생한다.
AggregateError는 여러 실패 이유를 한 번에 담을 수 있는 오류 객체라고 이해하면 된다.


Promise.race()는 성공과 실패를 가리지 않고 가장 먼저 완료된 Promise의 결과를 사용한다.
반면 Promise.any()는 실패를 건너뛰고 가장 먼저 성공한 Promise의 결과를 사용한다.


Promise 유틸리티는 성공 조건과 완료 기준이 다르다

Promise.all(), Promise.allSettled(), Promise.race(), Promise.any()는 여러 Promise를 함께 다룰 때 사용한다.
하지만 언제 성공으로 볼지, 언제 결과를 넘길지가 다르다.


차이를 짧게 정리하면 아래와 같다.

  • Promise.all()은 모든 작업이 성공해야 성공한다.
  • Promise.allSettled()는 성공과 실패에 상관없이 모두 끝날 때까지 기다린다.
  • Promise.race()는 성공이든 실패든 가장 먼저 끝난 작업의 결과를 사용한다.
  • Promise.any()는 가장 먼저 성공한 작업의 결과를 사용한다.

여러 Promise를 함께 다룰 때는 “모두 성공해야 하는지, 모두 끝나기만 하면 되는지, 가장 빠른 결과가 필요한지, 가장 빠른 성공이 필요한지”를 먼저 판단해야 한다.
이 기준을 잡으면 Promise 유틸리티를 더 쉽게 선택할 수 있다.


Promise에서 async/await로 이어진다

Promise를 사용하면 비동기 작업의 성공과 실패를 정리해서 처리할 수 있다.
하지만 .then()을 계속 이어 붙이면 코드가 길어질 수 있다.
특히 앞 작업의 결과를 다음 작업에서 계속 사용해야 하면 체이닝 구조가 길어지고, 흐름을 위에서 아래로 읽기 어려워질 수 있다.


이 문제를 더 읽기 쉽게 풀기 위해 사용하는 방식이 async/await이다.
async/await은 Promise를 없애는 문법이 아니다.
Promise 기반 비동기 코드를 동기 코드처럼 위에서 아래로 읽기 쉽게 작성하게 도와주는 문법이다.


이 흐름은 다음 주제에서 더 자세히 정리한다.
여기서는 Promise가 비동기 작업의 결과를 표현하고, async/await은 그 Promise 흐름을 더 읽기 쉽게 쓰는 방식이라는 정도로 이해하면 된다.


Promise 핵심 정리

Promise는 비동기 작업의 결과를 나중에 처리하기 위한 객체이다.
작업이 성공하면 성공 결과를 전달하고, 실패하면 실패 이유를 전달한다.


핵심은 아래처럼 정리할 수 있다.

  • Promise는 비동기 작업의 성공 또는 실패 결과를 나중에 처리하기 위한 객체이다.
  • Promise는 비동기 작업이 끝난 뒤 다음 흐름을 연결할 때 사용한다.
  • Promise는 처음에 Pending 상태로 시작한다.
  • 작업이 성공하면 Fulfilled 상태가 된다.
  • 작업이 실패하면 Rejected 상태가 된다.
  • resolve()는 성공 결과를 전달한다.
  • reject()는 실패 이유를 전달한다.
  • .then()은 성공 결과를 처리한다.
  • .catch()는 실패 이유를 처리한다.
  • .finally()는 성공과 실패에 상관없이 마지막에 실행되며, ES6 이후 표준에서 추가된 메서드이다.
  • .then()을 이어 붙이면 이전 결과를 다음 작업으로 넘길 수 있다.
  • Promise를 생성하면 내부 작업은 바로 실행된다.
  • Promise 유틸리티는 전달받은 여러 Promise의 결과를 기준에 맞게 처리한다.
  • Promise.all()은 모든 작업이 성공해야 성공한다.
  • Promise.all()은 하나라도 실패하면 전체 실패 흐름으로 넘어가지만, 다른 작업을 자동 취소하지는 않는다.
  • Promise.allSettled()는 모든 작업이 끝날 때까지 기다린다.
  • Promise.race()는 가장 먼저 끝난 작업의 결과를 사용한다.
  • Promise.race()는 가장 먼저 끝난 결과를 선택할 뿐, 나머지 작업을 자동 취소하지는 않는다.
  • Promise.any()는 가장 먼저 성공한 작업의 결과를 사용한다.
  • async/await은 Promise 기반 흐름을 더 읽기 쉽게 작성하는 방식이다.

Promise를 이해할 때는 비동기 작업을 “대기 → 성공 또는 실패 → 결과 처리 → 다음 흐름 연결”로 읽어야 한다.
이 흐름을 잡으면 .then(), .catch(), .finally()와 여러 Promise 유틸리티의 역할도 자연스럽게 이어진다.




async/await

async/await은 Promise 기반 비동기 코드를 더 읽기 쉽게 작성하기 위한 문법이다.
async/await은 ES6 자체에 포함된 문법은 아니지만, Promise를 실제 코드에서 읽기 쉽게 다룰 때 자주 사용하는 현대 JavaScript 문법이다.
Promise를 사용하면 .then(), .catch()로 성공과 실패를 처리할 수 있다.
하지만 비동기 작업이 여러 단계로 이어지면 .then()이 계속 연결되고, 코드 흐름을 따라가기 어려워질 수 있다.


async/await은 이런 비동기 흐름을 위에서 아래로 읽을 수 있게 도와준다.
겉으로는 일반적인 동기 코드처럼 보이지만, 실제로는 Promise를 기반으로 동작한다.


async/await은 Promise를 없애는 문법이 아니라, Promise를 더 읽기 쉬운 형태로 작성하게 해 주는 문법이다.
그래서 async/await을 이해하려면 먼저 Promise의 성공, 실패, 대기 흐름을 알고 있어야 한다.


async/await이 등장한 이유

비동기 작업은 한 번만 실행될 때보다 여러 단계가 순서대로 이어질 때 더 복잡해진다.
예전에는 이런 비동기 작업을 callback 함수로 처리하는 경우가 많았다.
callback 함수는 다른 함수에 전달해 두었다가 나중에 실행되는 함수이다.


문제는 비동기 작업이 여러 단계로 이어질 때 생긴다.
첫 번째 작업이 끝난 뒤 두 번째 작업을 실행하고, 두 번째 작업이 끝난 뒤 세 번째 작업을 실행해야 하면 callback이 안쪽으로 계속 들어간다.
이렇게 코드가 오른쪽으로 깊어지는 구조를 Callback Hell이라고 부른다.
초보자 기준에서는 “작업이 이어질수록 코드가 점점 안쪽으로 파고들어 읽기 어려워지는 상태”라고 이해하면 된다.


Promise는 이 문제를 개선했다.
Promise를 사용하면 .then()을 이어 붙여 작업 흐름을 한 줄 방향으로 연결할 수 있다.
그래서 callback이 계속 중첩되는 구조보다 읽기 쉬워진다.


하지만 .then()이 많아지면 여전히 코드가 길어진다.
또 실패 처리를 위해 .catch() 흐름을 따로 봐야 하므로, 일반적인 위에서 아래로 읽는 코드와는 모양이 다르다.


이때 등장하는 방식이 async/await이다.
async/await은 Promise의 기능은 그대로 사용하면서, 코드를 작성하는 사람이 더 읽기 쉽게 만든 문법이다.
이런 문법을 문법적 설탕이라고 한다.
문법적 설탕은 기능 자체를 새로 만드는 것이 아니라, 같은 기능을 더 읽기 쉽고 쓰기 편한 형태로 제공하는 문법을 의미한다.


callback 방식은 작업이 이어질수록 코드가 안쪽으로 깊어진다.
Promise는 .then() 체이닝으로 흐름을 연결한다.
async/await은 같은 비동기 흐름을 위에서 아래로 읽히게 만들어 코드 흐름을 더 직관적으로 보여 준다.


정리하면 흐름은 아래처럼 볼 수 있다.

  • callback 방식은 비동기 작업이 많아질수록 코드가 중첩된다.
  • Promise는 .then() 체이닝으로 비동기 흐름을 연결한다.
  • async/await은 Promise 기반 흐름을 위에서 아래로 읽기 쉽게 만든다.

async/await은 callback 중첩과 긴 .then() 체이닝의 가독성 문제를 줄이기 위해 사용한다.


async 함수는 항상 Promise를 반환한다

async는 함수를 비동기 함수로 만들 때 사용하는 키워드이다.
함수 앞에 async를 붙이면 그 함수는 항상 Promise를 반환한다.


여기서 중요한 점은 함수 안에서 일반 값을 return해도, 바깥에서는 그 값이 Promise로 감싸져서 나온다는 것이다.
즉, async 함수의 결과는 바로 값처럼 보이더라도 실제로는 Promise 흐름으로 처리된다.


아래 이미지는 async 함수, await 키워드, 비동기 흐름의 핵심을 함께 보여 준다.


async 함수는 항상 Promise를 반환하고, await은 Promise가 완료될 때까지 해당 위치에서 기다린다.
다만 기다리는 동안 전체 스레드를 막는 것은 아니며, 다른 코드는 계속 실행될 수 있다.


아래 예제는 async 함수가 문자열을 반환하는 코드이다.

// async-function-return.js
async function getMessage() { // async 함수 선언
    return "작업 성공"; // 일반 값을 반환
}

const result = getMessage(); // async 함수 호출

console.log(result instanceof Promise); // Promise 객체인지 확인

result.then((message) => { // Promise 성공 결과 처리
    console.log(message); // 반환된 값 출력
});
// 출력결과
// true
// 작업 성공

getMessage() 안에서는 "작업 성공"이라는 문자열을 return했다.
하지만 getMessage()를 호출한 결과는 문자열 자체가 아니라 Promise이다.


그래서 result instanceof Promise의 결과가 true로 나온다.
이 말은 result가 일반 문자열이 아니라 Promise 객체라는 뜻이다.
그리고 result.then()을 사용하면 그 Promise의 성공 결과를 받을 수 있다.


async 함수는 함수 안에서 어떤 값을 반환하더라도 항상 Promise를 반환한다.
이 점을 모르면 async 함수의 결과를 바로 일반 값처럼 사용하려고 해서 헷갈릴 수 있다.


await은 Promise가 처리될 때까지 해당 위치에서 기다린다

await은 Promise의 처리 결과가 나올 때까지 해당 async 함수 안의 다음 흐름을 잠시 기다리게 하는 문법이다.
Promise가 성공하면 성공 결과를 꺼내고, 실패하면 그 실패가 오류처럼 catch로 넘어간다.
await은 보통 async 함수 안에서 사용한다.


await을 사용하면 .then()으로 결과를 받는 대신, 비동기 작업의 성공 결과를 변수에 바로 담는 것처럼 작성할 수 있다.
그래서 코드가 위에서 아래로 순서대로 실행되는 것처럼 읽힌다.


아래 예제는 Promise 성공 결과를 await으로 받는 코드이다.

// async-await-basic.js
function fetchData() { // 데이터 조회 함수
    return Promise.resolve("데이터 조회 성공"); // 성공 Promise 반환
}

async function run() { // async 함수 선언
    const data = await fetchData(); // Promise 완료 후 성공 결과 받기
    console.log(data); // 받은 결과 출력
}

run(); // async 함수 실행
// 출력결과
// 데이터 조회 성공

fetchData()는 성공한 Promise를 반환한다.
await fetchData()는 그 Promise가 완료될 때까지 기다린 뒤, 성공 결과인 "데이터 조회 성공"을 꺼낸다.
그 결과가 data에 들어가고, console.log(data)에서 출력된다.


await은 Promise의 성공 결과를 변수에 담아 다음 줄에서 사용할 수 있게 해 준다.
그래서 .then() 체이닝보다 코드 흐름을 읽기 쉬워진다.


await은 전체 프로그램을 멈추는 것이 아니다

await이라는 단어 때문에 전체 프로그램이 멈춘다고 오해하기 쉽다.
하지만 await은 해당 async 함수 안에서 다음 줄로 넘어가는 것을 기다리게 할 뿐, 전체 실행 환경을 완전히 멈추는 것은 아니다.


아래 예제는 await이 있는 함수와 바깥 코드의 실행 순서를 보여 준다.

// async-await-order.js
function delayMessage() { // 지연된 Promise 반환 함수
    return new Promise((resolve) => { // Promise 생성
        setTimeout(() => resolve("비동기 작업 완료"), 1000); // 1초 뒤 성공
    });
}

async function run() { // async 함수 선언
    console.log("run 시작"); // 먼저 출력
    const message = await delayMessage(); // Promise가 완료될 때까지 run 내부에서 대기
    console.log(message); // 완료 후 출력
}

run(); // async 함수 실행
console.log("run 바깥 코드"); // run을 기다리지 않고 먼저 실행될 수 있음
// 출력결과
// run 시작
// run 바깥 코드
// 비동기 작업 완료

run()이 실행되면 먼저 "run 시작"이 출력된다.
그다음 await delayMessage()에서 run() 함수 내부의 흐름은 잠시 기다린다.


하지만 바깥 코드까지 모두 멈추는 것은 아니다.
그래서 "run 바깥 코드"가 먼저 출력되고, 1초 뒤 Promise가 완료되면 "비동기 작업 완료"가 출력된다.


await은 해당 async 함수의 흐름을 기다리게 하는 것이지, 전체 프로그램을 완전히 멈추는 문법은 아니다.


async/await은 then 체이닝을 더 읽기 쉽게 바꾼다

Promise의 .then()을 계속 이어 붙이면 작업 흐름이 길어질 수 있다.
이때 async/await을 사용하면 같은 흐름을 위에서 아래로 읽는 코드로 바꿀 수 있다.


먼저 .then()을 사용한 코드 흐름을 보면 아래와 같다.

// promise-then-chain-example.js
function getUser() { // 사용자 조회 함수
    return Promise.resolve({ id: 1, name: "짱구" }); // 사용자 Promise 반환
}

function getPosts(userId) { // 게시글 조회 함수
    return Promise.resolve(["게시글1", "게시글2"]); // 게시글 Promise 반환
}

getUser() // 사용자 조회
    .then((user) => { // 사용자 결과 받기
        return getPosts(user.id); // 사용자 id로 게시글 조회
    })
    .then((posts) => { // 게시글 결과 받기
        console.log(posts); // 게시글 출력
    });
// 출력결과
// ['게시글1', '게시글2']

위 코드는 틀린 코드는 아니다.
하지만 작업이 더 많아지면 .then()이 계속 이어져서 흐름을 따라가기 어려울 수 있다.


같은 흐름을 async/await으로 작성하면 아래처럼 볼 수 있다.

// async-await-chain-example.js
function getUser() { // 사용자 조회 함수
    return Promise.resolve({ id: 1, name: "짱구" }); // 사용자 Promise 반환
}

function getPosts(userId) { // 게시글 조회 함수
    return Promise.resolve(["게시글1", "게시글2"]); // 게시글 Promise 반환
}

async function run() { // async 함수 선언
    const user = await getUser(); // 사용자 조회 결과 받기
    const posts = await getPosts(user.id); // 사용자 id로 게시글 조회 결과 받기
    console.log(posts); // 게시글 출력
}

run(); // async 함수 실행
// 출력결과
// ['게시글1', '게시글2']

이 코드는 먼저 user를 받고, 그 user.id를 사용해 posts를 받는다.
흐름이 위에서 아래로 이어지기 때문에 동작 순서를 읽기 쉽다.


async/await은 복잡한 Promise 체이닝을 위에서 아래로 읽히는 코드로 바꿔 가독성을 높인다.


exam26_1과 exam26_2 흐름 비교

앞에서 본 exam26_1.html은 Promise와 .then(), .finally()를 사용해서 비동기 작업 순서를 연결했다.
이번에는 같은 흐름을 async/await으로 바꿔 보면 차이가 더 분명하다.


Promise 방식은 작업이 끝난 뒤 실행할 코드를 .then()과 .finally()에 등록한다.
반면 async/await 방식은 await으로 작업 완료를 기다린 뒤, 다음 줄에 이어서 코드를 작성한다.

// exam26_2.html
<h2>async/await는 비동기 코드를 순서대로 실행되는 것처럼 보이게 해주는 문법적 설탕이다.</h2>
<h3>work()를 await으로 호출하면 Promise 작업이 끝날 때까지 기다리며, resolve() 호출 시 전달되는 값을 결과로 받게 된다.</h3>
<h3>await은 async로 선언된 함수 안에서만 사용할 수 있다.</h3>
<script>
function work() {
    return new Promise((resolve, reject) => { // Promise 객체 반환
        console.log("작업 시작"); // ① 먼저 실행

        setTimeout(() => {
            resolve("작업 완료"); // ② 2초 뒤 성공 결과 전달
        }, 2000);
    });
}

async function run() { // async 함수 선언
    const result = await work(); // Promise 완료까지 기다린 뒤 결과 받기
    console.log(result); // ② 작업 완료 출력
    console.log("다음 작업"); // ③ 그 다음 작업 실행
}

run(); // async 함수 실행
</script>
// 출력결과
// 작업 시작
// 작업 완료
// 다음 작업

이 예제에서 run()은 async 함수이다.
그 안에서 await work()를 실행하면 work()가 반환한 Promise가 완료될 때까지 run() 내부 흐름이 기다린다.


setTimeout()이 끝나고 resolve("작업 완료")가 호출되면 그 값이 result에 들어간다.
그다음 console.log(result)가 실행되고, 마지막으로 "다음 작업"이 출력된다.


exam26_1.html과 exam26_2.html은 출력 흐름은 같다.
하지만 코드 모양이 다르다.
Promise 방식은 .then()과 .finally()에 다음 작업을 등록하고, async/await 방식은 위에서 아래로 이어지는 문장처럼 작성한다.
async/await은 Promise로 만든 비동기 순서 제어를 더 읽기 쉬운 코드 흐름으로 바꿔 준다.


실패 처리는 try/catch로 한다

Promise에서 실패는 .catch()로 처리했다.
async/await에서는 실패 처리를 보통 try/catch로 작성한다.


try는 오류가 날 수 있는 코드를 시도하는 영역이다.
catch는 try 안에서 오류가 발생했을 때 그 오류를 받아 처리하는 영역이다.


아래 예제는 실패하는 Promise를 try/catch로 처리하는 코드이다.

// async-await-try-catch.js
function fetchData() { // 데이터 조회 함수
    return Promise.reject("데이터 조회 실패"); // 실패 Promise 반환
}

async function run() { // async 함수 선언
    try { // 오류가 날 수 있는 코드
        const data = await fetchData(); // 실패하면 catch로 이동
        console.log(data); // 실행되지 않음
    } catch (error) { // 실패 이유 받기
        console.log(error); // 오류 출력
    }
}

run(); // async 함수 실행
// 출력결과
// 데이터 조회 실패

fetchData()는 실패한 Promise를 반환한다.
그래서 await fetchData()에서 실패가 발생하고, 바로 catch 영역으로 이동한다.


catch의 error에는 실패 이유인 "데이터 조회 실패"가 들어간다.
그래서 "데이터 조회 실패"가 출력된다.


Promise에서 .catch()로 실패를 처리했다면, async/await에서는 보통 try/catch로 실패를 처리한다고 이해하면 된다.


finally는 성공과 실패에 상관없이 실행된다

try/catch와 함께 finally를 사용할 수 있다.
finally는 성공하든 실패하든 마지막에 실행되는 영역이다.


Promise의 .finally()가 성공과 실패에 상관없이 실행된 것처럼, try/catch/finally의 finally도 마지막 정리 작업에 사용할 수 있다.


아래 예제는 실패하더라도 마지막에 "작업 종료"를 출력하는 코드이다.

// async-await-finally.js
function fetchData() { // 데이터 조회 함수
    return Promise.reject("데이터 조회 실패"); // 실패 Promise 반환
}

async function run() { // async 함수 선언
    try { // 성공 시도 영역
        const data = await fetchData(); // 실패하면 catch로 이동
        console.log(data); // 실행되지 않음
    } catch (error) { // 실패 처리 영역
        console.log(error); // 실패 이유 출력
    } finally { // 항상 실행되는 영역
        console.log("작업 종료"); // 마무리 출력
    }
}

run(); // async 함수 실행
// 출력결과
// 데이터 조회 실패
// 작업 종료

fetchData()가 실패했기 때문에 catch가 실행된다.
그 뒤 finally도 실행되어 "작업 종료"가 출력된다.


async/await에서 성공 결과는 await으로 받고, 실패 처리는 try/catch로 다룬다.
마지막 정리 작업이 필요하면 finally를 함께 사용하면 된다.


async/await의 장점

async/await의 가장 큰 장점은 비동기 코드를 읽는 흐름이 좋아진다는 점이다.
await을 사용하면 비동기 작업이 끝난 뒤 다음 작업을 이어서 작성할 수 있으므로, 코드가 위에서 아래로 읽힌다.


두 번째 장점은 오류 처리를 try/catch로 통합할 수 있다는 점이다.
Promise 체이닝에서는 .catch()를 따로 연결해 실패를 처리한다.
async/await에서는 동기 코드에서 쓰던 try/catch 구조와 비슷하게 실패를 처리할 수 있다.
그래서 성공 흐름과 실패 흐름을 한 함수 안에서 함께 정리하기 쉽다.


세 번째 장점은 디버깅이 비교적 편하다는 점이다.
디버깅은 코드가 어디서 어떤 순서로 실행되는지 확인하면서 문제를 찾는 과정이다.
async/await은 코드가 위에서 아래로 이어지는 형태에 가까워서, 작업 흐름을 한 줄씩 따라가기 쉽다.


정리하면 장점은 아래와 같다.

  • 코드가 위에서 아래로 읽혀 가독성이 좋아진다.
  • 비동기 실패 처리를 try/catch로 정리할 수 있다.
  • 코드 실행 흐름을 따라가며 디버깅하기 쉽다.

async/await은 비동기 작업을 동기 코드처럼 보이게 만들어, 읽기 쉬운 흐름과 통합된 오류 처리를 제공한다.


async/await 핵심 정리

async/await은 Promise 기반 코드를 더 읽기 쉽게 작성하기 위한 문법이다.
비동기 작업 자체가 사라지는 것이 아니라, 비동기 작업의 결과를 다루는 코드 형태가 더 읽기 쉬워진다.


핵심은 아래처럼 정리할 수 있다.

  • async/await은 Promise 기반 비동기 코드를 더 읽기 쉽게 작성하는 문법이다.
  • async/await은 callback 중첩과 긴 .then() 체이닝의 가독성 문제를 줄이기 위해 사용한다.
  • async/await은 기능을 새로 만드는 문법이라기보다, Promise를 더 읽기 좋게 쓰게 해 주는 문법적 설탕이다.
  • async 함수는 항상 Promise를 반환한다.
  • async 함수 안에서 일반 값을 return해도 결과는 Promise로 감싸진다.
  • await은 Promise의 처리 결과가 나올 때까지 해당 async 함수 안의 다음 흐름을 기다리게 한다.
  • await은 보통 async 함수 안에서 사용한다.
  • await은 Promise가 성공하면 성공 결과를 변수에 담아 사용할 수 있게 한다.
  • await 중인 Promise가 실패하면 실패 흐름은 try/catch의 catch로 넘어간다.
  • await은 전체 프로그램을 멈추는 것이 아니라 해당 async 함수의 흐름을 기다리게 한다.
  • .then() 체이닝이 길어질 때 async/await을 사용하면 위에서 아래로 읽기 쉬워진다.
  • 실패 처리는 보통 try/catch로 작성한다.
  • 성공과 실패에 상관없는 마무리 작업은 finally에서 처리할 수 있다.

async/await을 이해할 때는 Promise를 없애는 문법이 아니라, Promise의 성공과 실패 흐름을 더 읽기 쉽게 작성하는 문법이라고 봐야 한다.
Promise의 상태와 결과 처리 흐름을 알고 있으면 async/await도 자연스럽게 이어서 이해할 수 있다.



Proxy

Proxy는 객체를 바로 사용하지 않고, 중간에서 객체 접근을 가로채서 제어할 수 있게 해 주는 문법이다.
여기서 가로챈다는 말은 객체의 값을 읽거나 바꾸는 순간에 중간 코드를 먼저 실행할 수 있다는 뜻이다.


일반 객체는 값을 읽으면 바로 값이 나오고, 값을 바꾸면 바로 바뀐다.
하지만 Proxy를 사용하면 값을 읽기 전에 확인하거나, 값을 바꾸기 전에 검사하거나, 없는 값을 요청했을 때 기본값을 돌려주는 식으로 동작을 조절할 수 있다.


Proxy는 원본 객체 앞에 중간 관리자를 세워서, 객체의 읽기와 쓰기 같은 동작을 제어하는 문법이다.
객체 자체를 바꾸는 문법이라기보다, 객체를 다루는 방식을 중간에서 조절하는 문법이라고 이해하면 된다.


Proxy는 원본 객체와 handler로 만든다

Proxy를 만들 때는 new Proxy()를 사용한다.
new Proxy()에는 두 가지가 들어간다.


첫 번째는 target이다.
target은 실제로 보호하거나 감시할 원본 객체이다.


두 번째는 handler이다.
handler는 원본 객체에 어떤 동작이 일어났을 때 중간에서 실행할 규칙을 담은 객체이다.


형태는 아래처럼 볼 수 있다.

// proxy-basic-structure.js
const target = { // 원본 객체
    nickname: "짱구", // nickname 프로퍼티
    age: 5 // age 프로퍼티
};

const handler = { // 중간에서 동작을 제어할 규칙
    get(target, property) { // 값을 읽을 때 실행
        return target[property]; // 원본 객체의 값 반환
    }
};

const proxy = new Proxy(target, handler); // Proxy 객체 생성

console.log(proxy.nickname); // Proxy를 통해 nickname 읽기
console.log(proxy.age); // Proxy를 통해 age 읽기
// 출력결과
// 짱구
// 5

이 코드에서 target은 실제 데이터를 가진 원본 객체이다.
handler는 값을 읽을 때 어떻게 처리할지 정하는 규칙 객체이다.
proxy는 원본 객체를 감싼 중간 객체이다.


중요한 점은 값을 읽을 때 target.nickname이 아니라 proxy.nickname을 사용했다는 것이다.
Proxy의 규칙은 proxy를 통해 접근할 때 실행된다.


Proxy를 만들었다면 원본 객체를 직접 사용하는 것이 아니라, Proxy 객체를 통해 접근해야 중간 제어 코드가 실행된다.


get은 값을 읽을 때 실행된다

get은 객체의 프로퍼티 값을 읽을 때 실행되는 함수이다.
여기서 프로퍼티는 객체 안에 들어 있는 이름과 값의 묶음이다.
예를 들어 { nickname: "짱구" }에서 nickname이 프로퍼티 이름이고, "짱구"가 값이다.


proxy.nickname처럼 값을 읽으면 handler 안의 get()이 실행된다.
그래서 값을 그냥 반환할 수도 있고, 읽기 전에 로그를 남기거나, 없는 값에 기본값을 줄 수도 있다.


아래 예제는 어떤 프로퍼티를 읽는지 확인하는 코드이다.

// proxy-get-log.js
const target = { // 원본 객체
    nickname: "짱구", // nickname 프로퍼티
    age: 5 // age 프로퍼티
};

const handler = {
    get(target, property) { // 값을 읽을 때 실행
        console.log(`${property} 값을 읽습니다.`); // 읽는 프로퍼티 이름 출력
        return target[property]; // 원본 객체의 값 반환
    }
};

const proxy = new Proxy(target, handler); // Proxy 객체 생성

console.log(proxy.nickname); // nickname 읽기
console.log(proxy.age); // age 읽기
// 출력결과
// nickname 값을 읽습니다.
// 짱구
// age 값을 읽습니다.
// 5

proxy.nickname을 읽는 순간 get()이 실행된다.
이때 property에는 "nickname"이 들어간다.
그래서 "nickname 값을 읽습니다."가 먼저 출력되고, 원본 객체의 nickname 값인 "짱구"가 반환된다.


proxy.age를 읽을 때도 같은 흐름이다.
property에는 "age"가 들어가고, 원본 객체의 age 값인 5가 반환된다.


없는 프로퍼티를 읽을 때 기본값을 줄 수 있다

일반 객체에서 존재하지 않는 프로퍼티를 읽으면 undefined가 나온다.
여기서 undefined는 값이 정의되어 있지 않다는 뜻이다.


Proxy의 get()을 사용하면 없는 프로퍼티를 읽었을 때 원하는 기본값을 돌려줄 수 있다.
아래 예제는 존재하지 않는 값이면 "값 없음"을 반환한다.

// proxy-get-default.js
const target = { // 원본 객체
    nickname: "짱구" // nickname 프로퍼티
};

const handler = {
    get(target, property) { // 값을 읽을 때 실행
        if (property in target) { // 원본 객체에 해당 프로퍼티가 있는지 확인
            return target[property]; // 있으면 원래 값 반환
        }

        return "값 없음"; // 없으면 기본값 반환
    }
};

const proxy = new Proxy(target, handler); // Proxy 객체 생성

console.log(proxy.nickname); // 존재하는 값 읽기
console.log(proxy.age); // 존재하지 않는 값 읽기
// 출력결과
// 짱구
// 값 없음

proxy.nickname은 원본 객체에 있는 프로퍼티이다.
그래서 "짱구"가 반환된다.


하지만 proxy.age는 원본 객체에 없다.
일반 객체라면 undefined가 나오지만, 이 예제에서는 get()에서 "값 없음"을 반환하도록 만들었다.


이처럼 Proxy를 사용하면 객체를 읽는 동작에 규칙을 추가할 수 있다.
get()은 객체 값을 읽는 순간에 실행되므로, 읽기 로그, 기본값 처리, 접근 제한 같은 작업에 사용할 수 있다.


set은 값을 바꿀 때 실행된다

set은 객체의 프로퍼티 값을 저장하거나 수정할 때 실행되는 함수이다.
즉, proxy.age = 6처럼 값을 넣는 순간 handler 안의 set()이 실행된다.


set()을 사용하면 값을 저장하기 전에 검사할 수 있다.
예를 들어 나이는 숫자여야 하고, 음수가 되면 안 된다는 규칙을 만들 수 있다.


아래 예제는 age에 음수를 넣지 못하게 막는 코드이다.

// proxy-set-validation.js
const target = { // 원본 객체
    nickname: "짱구", // nickname 프로퍼티
    age: 5 // age 프로퍼티
};

const handler = {
    set(target, property, value) { // 값을 저장할 때 실행
        if (property === "age" && value < 0) { // age에 음수를 넣는지 확인
            console.log("나이는 음수가 될 수 없습니다."); // 오류 메시지 출력
            return false; // 저장 실패
        }

        target[property] = value; // 조건을 통과하면 값 저장
        return true; // 저장 성공
    }
};

const proxy = new Proxy(target, handler); // Proxy 객체 생성

proxy.age = 6; // 정상 값 저장
console.log(proxy.age); // 변경된 age 출력

proxy.age = -1; // 잘못된 값 저장 시도
console.log(proxy.age); // 기존 age 유지 확인
// 출력결과
// 6
// 나이는 음수가 될 수 없습니다.
// 6

처음에 proxy.age = 6을 실행하면 set()이 실행된다.
value는 6이고, 음수가 아니므로 원본 객체에 저장된다.
그래서 proxy.age를 출력하면 6이 나온다.


그다음 proxy.age = -1을 실행하면 다시 set()이 실행된다.
이번에는 value가 -1이므로 조건에 걸린다.
그래서 "나이는 음수가 될 수 없습니다."가 출력되고, 값은 저장되지 않는다.


set()은 객체 값을 바꾸기 전에 검사하거나, 잘못된 값 저장을 막을 때 사용할 수 있다.


set은 성공 여부를 반환해야 한다

set()은 값을 저장한 뒤 성공 여부를 반환해야 한다.
값 저장이 성공했으면 true를 반환하고, 저장을 막았다면 false를 반환한다.


초보자 입장에서는 return true가 없어도 값만 넣으면 될 것처럼 보일 수 있다.
하지만 Proxy의 set()은 저장 작업이 성공했는지 알려 주는 구조이다.
특히 엄격 모드에서는 set()이 false를 반환하면 오류가 발생할 수 있다.


그래서 값을 정상적으로 저장했다면 return true를 작성하는 습관이 좋다.

// proxy-set-return-true.js
const target = { // 원본 객체
    count: 0 // count 프로퍼티
};

const handler = {
    set(target, property, value) { // 값을 저장할 때 실행
        target[property] = value; // 원본 객체에 값 저장
        return true; // 저장 성공 반환
    }
};

const proxy = new Proxy(target, handler); // Proxy 객체 생성

proxy.count = 10; // count 값 변경

console.log(proxy.count); // 변경된 count 출력
// 출력결과
// 10

이 예제에서 proxy.count = 10을 실행하면 set()이 실행된다.
target[property] = value로 원본 객체의 값을 바꾼 뒤 true를 반환한다.


이렇게 하면 Proxy에게 “값 저장이 정상적으로 끝났다”는 신호를 주는 흐름이 된다.


Reflect를 사용하면 기본 동작을 안전하게 호출할 수 있다

get()과 set() 안에서 target[property]처럼 직접 값을 읽고 쓸 수도 있다.
하지만 기본 동작을 그대로 실행하고 싶을 때는 Reflect를 사용할 수 있다.


Reflect는 객체의 기본 동작을 함수 형태로 실행할 수 있게 해 주는 도구이다.
예를 들어 Reflect.get(target, property)는 원본 객체의 값을 읽는 기본 동작을 실행한다.
Reflect.set(target, property, value)는 원본 객체에 값을 저장하는 기본 동작을 실행한다.


아래 예제는 Reflect로 기본 읽기와 쓰기를 처리하는 코드이다.

// proxy-reflect-basic.js
const target = { // 원본 객체
    nickname: "짱구", // nickname 프로퍼티
    age: 5 // age 프로퍼티
};

const handler = {
    get(target, property) { // 값을 읽을 때 실행
        console.log(`${property} 읽기`); // 읽기 로그 출력
        return Reflect.get(target, property); // 기본 읽기 동작 실행
    },

    set(target, property, value) { // 값을 저장할 때 실행
        console.log(`${property} 변경`); // 변경 로그 출력
        return Reflect.set(target, property, value); // 기본 저장 동작 실행
    }
};

const proxy = new Proxy(target, handler); // Proxy 객체 생성

console.log(proxy.nickname); // nickname 읽기
proxy.age = 6; // age 변경
console.log(proxy.age); // age 읽기
// 출력결과
// nickname 읽기
// 짱구
// age 변경
// age 읽기
// 6

이 코드에서 Reflect.get()은 원래 객체에서 값을 읽는 기본 동작을 대신 실행한다.
Reflect.set()은 원래 객체에 값을 저장하는 기본 동작을 대신 실행한다.


즉, Proxy로 중간 동작을 추가하되, 실제 읽기와 쓰기는 기본 방식에 맡길 수 있다.
초보자 단계에서는 Reflect를 “원래 객체 동작을 안전하게 대신 실행해 주는 도구” 정도로 이해하면 된다.


Proxy를 거치지 않으면 handler가 실행되지 않는다

Proxy에서 가장 중요한 점은 proxy를 통해 접근해야 handler가 실행된다는 것이다.
원본 객체인 target을 직접 사용하면 Proxy의 get()이나 set()은 실행되지 않는다.


아래 예제를 보면 차이가 확실하다.

// proxy-target-direct.js
const target = { // 원본 객체
    nickname: "짱구" // nickname 프로퍼티
};

const handler = {
    get(target, property) { // Proxy를 통해 값을 읽을 때 실행
        console.log("Proxy를 통해 읽었습니다."); // 읽기 로그 출력
        return target[property]; // 원본 값 반환
    }
};

const proxy = new Proxy(target, handler); // Proxy 객체 생성

console.log(proxy.nickname); // Proxy를 통해 읽기
console.log(target.nickname); // 원본 객체를 직접 읽기
// 출력결과
// Proxy를 통해 읽었습니다.
// 짱구
// 짱구

첫 번째 console.log(proxy.nickname)은 proxy를 통해 값을 읽는다.
그래서 get()이 실행되고 "Proxy를 통해 읽었습니다."가 출력된다.


두 번째 console.log(target.nickname)은 원본 객체를 직접 읽는다.
이 경우에는 Proxy를 거치지 않았으므로 get()이 실행되지 않는다.


Proxy의 규칙을 적용하려면 원본 객체가 아니라 Proxy 객체를 통해 값을 읽거나 바꿔야 한다.
이 점을 놓치면 handler를 작성했는데도 아무 동작이 일어나지 않는 것처럼 보일 수 있다.


Proxy 핵심 정리

Proxy는 원본 객체 앞에서 객체의 기본 동작을 가로채는 중간 객체이다.
객체 값을 읽거나 바꾸는 순간에 원하는 코드를 먼저 실행할 수 있다.


핵심은 아래처럼 정리할 수 있다.

  • Proxy는 객체 접근을 중간에서 가로채서 제어하는 문법이다.
  • target은 실제 데이터를 가진 원본 객체이다.
  • handler는 중간에서 실행할 규칙을 담은 객체이다.
  • new Proxy(target, handler)로 Proxy 객체를 만든다.
  • get()은 프로퍼티 값을 읽을 때 실행된다.
  • set()은 프로퍼티 값을 저장하거나 수정할 때 실행된다.
  • get()을 사용하면 읽기 로그를 남기거나 없는 값에 기본값을 줄 수 있다.
  • set()을 사용하면 값을 저장하기 전에 검사할 수 있다.
  • set()은 저장 성공 여부를 true 또는 false로 반환해야 한다.
  • Reflect.get()과 Reflect.set()은 원래 객체의 기본 읽기와 쓰기 동작을 실행한다.
  • 원본 객체를 직접 사용하면 handler가 실행되지 않는다.
  • Proxy의 규칙을 적용하려면 Proxy 객체를 통해 접근해야 한다.

Proxy의 핵심은 원본 객체를 직접 다루지 않고, 중간 객체를 통해 읽기와 쓰기 동작을 감시하거나 제어할 수 있다는 점이다.
이 구조를 이해하면 값 검증, 기본값 처리, 접근 로그처럼 객체 사용 흐름을 더 세밀하게 관리할 수 있다.



JSON

JSON은 데이터를 문자열 형태로 표현하기 위한 형식이다.
여기서 문자열 형태라는 말은 사람이 보기에는 객체나 배열처럼 보일 수 있지만, 실제로는 글자 데이터로 주고받는다는 뜻이다.


JavaScript 객체는 코드 안에서 바로 사용할 수 있는 데이터 구조이다.
반면 JSON은 서버와 클라이언트가 데이터를 주고받거나, 데이터를 저장할 때 자주 사용하는 텍스트 형식이다.


예를 들어 사용자 정보를 서버로 보낸다고 생각해 보자.
JavaScript 객체 그대로 보내는 것이 아니라, 보통 문자열 형태의 JSON으로 바꿔서 전송한다.
반대로 서버에서 받은 JSON 문자열은 다시 JavaScript 객체나 배열로 바꿔야 코드에서 사용할 수 있다.


JSON은 객체 자체가 아니라, 데이터를 주고받기 위해 정해진 문자열 형식이다.
이 차이를 이해해야 JSON.stringify()와 JSON.parse()가 왜 필요한지 알 수 있다.


JSON은 데이터를 표현하는 문자열 형식이다

JSON은 JavaScript Object Notation의 줄임말이다.
뜻을 풀면 JavaScript 객체 표기법이라는 의미이다.
이름에 JavaScript가 들어가지만, 실제로는 여러 환경에서 데이터를 주고받을 때 사용하는 공통 데이터 형식으로 많이 쓰인다.


JSON은 객체나 배열과 비슷하게 생겼지만, 코드에서 바로 사용하는 객체나 배열과는 다르다.
JSON은 문자열이다.
즉, 데이터를 글자 형태로 표현한 것이다.


아래 예제는 사용자 정보를 JSON 문자열로 표현한 모습이다.

// json-string-basic.js
const jsonText = '{"nickname":"짱구","age":5,"city":"떡잎마을"}'; // JSON 문자열

console.log(jsonText); // JSON 문자열 출력
console.log(typeof jsonText); // 자료형 확인
// 출력결과
// {"nickname":"짱구","age":5,"city":"떡잎마을"}
// string

jsonText에 들어 있는 값은 객체처럼 보인다.
하지만 양쪽에 따옴표가 있는 문자열이다.
그래서 typeof jsonText의 결과는 object가 아니라 string이다.


이 상태에서는 jsonText.nickname처럼 객체 프로퍼티에 접근할 수 없다.
아직 JSON 문자열일 뿐이기 때문이다.


JSON은 객체나 배열처럼 생길 수 있는 문자열이고, 실제 객체나 배열로 사용하려면 변환 과정이 필요하다.


JavaScript 객체와 JSON은 비슷하지만 다르다

JavaScript 객체와 JSON은 모양이 비슷하다.
그래서 처음에는 같은 것처럼 보일 수 있다.
하지만 문법 규칙이 다르다.


JavaScript 객체는 코드 안에서 사용하는 실제 데이터이다.
객체의 key에 따옴표를 붙이지 않아도 되고, 문자열에 작은따옴표를 사용할 수도 있다.
여기서 key는 객체에서 값을 구분하는 이름이다.


아래 예제는 JavaScript 객체이다.

// javascript-object-basic.js
const user = { // JavaScript 객체
    nickname: "짱구", // key에 따옴표를 쓰지 않아도 됨
    age: 5, // 숫자 값
    city: "떡잎마을" // 문자열 값
};

console.log(user.nickname); // 객체 프로퍼티 접근
console.log(typeof user); // 자료형 확인
// 출력결과
// 짱구
// object

이 코드는 실제 JavaScript 객체이다.
그래서 user.nickname처럼 점 표기법으로 값을 꺼낼 수 있다.
typeof user의 결과도 object이다.


반면 JSON에서는 규칙이 더 엄격하다.
객체의 key는 반드시 큰따옴표로 감싸야 한다.
문자열 값도 큰따옴표로 감싸야 한다.
작은따옴표를 사용할 수 없고, 주석도 넣을 수 없다.


아래 예제는 올바른 JSON 문자열이다.

// json-valid-string.js
const jsonText = '{"nickname":"짱구","age":5,"city":"떡잎마을"}'; // 올바른 JSON 문자열

console.log(jsonText); // JSON 문자열 출력
// 출력결과
// {"nickname":"짱구","age":5,"city":"떡잎마을"}

차이를 정리하면 아래처럼 볼 수 있다.

  • JavaScript 객체는 코드 안에서 바로 사용하는 실제 객체이다.
  • JSON은 데이터를 표현한 문자열 형식이다.
  • JSON의 key는 반드시 큰따옴표로 감싸야 한다.
  • JSON의 문자열 값도 반드시 큰따옴표로 감싸야 한다.
  • JSON에는 함수, undefined, 주석을 넣을 수 없다.

JSON은 JavaScript 객체와 비슷하게 생겼지만, 더 엄격한 문자열 데이터 형식이다.


JSON.stringify는 JavaScript 값을 JSON 문자열로 바꾼다

JSON.stringify()는 객체나 배열 같은 JavaScript 값을 JSON 문자열로 바꾸는 메서드이다.
여기서 메서드는 객체가 가지고 있는 함수라고 이해하면 된다.


서버로 데이터를 보내거나, 문자열로 저장해야 할 때는 객체나 배열 같은 값을 그대로 사용할 수 없는 경우가 많다.
이때 JSON.stringify()를 사용해서 JavaScript 값을 문자열로 바꾼다.


아래 예제는 JavaScript 객체를 JSON 문자열로 바꾸는 코드이다.

// json-stringify-basic.js
const user = { // JavaScript 객체
    nickname: "짱구", // 닉네임
    age: 5, // 나이
    city: "떡잎마을" // 도시
};

const jsonText = JSON.stringify(user); // 객체를 JSON 문자열로 변환

console.log(jsonText); // 변환된 JSON 문자열 출력
console.log(typeof jsonText); // 자료형 확인
// 출력결과
// {"nickname":"짱구","age":5,"city":"떡잎마을"}
// string

user는 처음에 JavaScript 객체이다.
그래서 코드 안에서 user.nickname처럼 사용할 수 있다.


JSON.stringify(user)를 실행하면 객체가 JSON 문자열로 바뀐다.
그 결과가 jsonText에 저장된다.
그래서 typeof jsonText의 결과는 string이다.


JSON.stringify()는 코드에서 쓰는 객체나 배열 같은 JavaScript 값을 전송하거나 저장하기 쉬운 문자열 형태로 바꾼다.


JSON.parse는 JSON 문자열을 JavaScript 값으로 바꾼다

JSON.parse()는 JSON 문자열을 코드에서 사용할 수 있는 JavaScript 값으로 바꾸는 메서드이다.
대부분 객체나 배열 형태의 JSON 문자열을 객체나 배열로 바꿀 때 사용한다.


서버에서 받은 데이터가 JSON 문자열이라면, 코드에서 사용하기 위해 객체나 배열로 바꿔야 한다.
문자열 상태에서는 점 표기법이나 배열 인덱스로 원하는 값을 제대로 다루기 어렵기 때문이다.


아래 예제는 JSON 문자열을 객체로 바꾸는 코드이다.

// json-parse-basic.js
const jsonText = '{"nickname":"짱구","age":5,"city":"떡잎마을"}'; // JSON 문자열

const user = JSON.parse(jsonText); // JSON 문자열을 JavaScript 객체로 변환

console.log(user.nickname); // 객체 프로퍼티 접근
console.log(user.age); // 객체 프로퍼티 접근
console.log(typeof user); // 자료형 확인
// 출력결과
// 짱구
// 5
// object

jsonText는 처음에는 문자열이다.
하지만 JSON.parse(jsonText)를 실행하면 JavaScript 객체로 변환된다.


그 결과를 user에 저장했기 때문에 user.nickname, user.age처럼 객체의 값을 꺼낼 수 있다.
typeof user의 결과도 object가 된다.


JSON.parse()는 문자열로 받은 JSON 데이터를 코드에서 사용할 수 있는 객체나 배열 같은 JavaScript 값으로 바꾼다.


JSON 문자열이 올바르지 않으면 parse 오류가 난다

JSON.parse()는 아무 문자열이나 JavaScript 값으로 바꿔 주지 않는다.
문자열이 올바른 JSON 형식이어야 한다.


특히 JSON에서는 key와 문자열 값을 큰따옴표로 감싸야 한다.
작은따옴표를 사용하거나, key에 따옴표를 붙이지 않으면 오류가 날 수 있다.


아래 코드는 잘못된 예시이다.

// json-parse-error.js
const wrongJsonText = "{nickname:'짱구',age:5}"; // 잘못된 JSON 문자열

const user = JSON.parse(wrongJsonText); // JSON 형식이 아니므로 오류 발생

console.log(user); // 실행되지 않음
// 출력결과
// SyntaxError

이 코드가 오류 나는 이유는 wrongJsonText가 올바른 JSON 형식이 아니기 때문이다.
nickname이라는 key에 큰따옴표가 없고, "짱구" 문자열도 작은따옴표로 작성되어 있다.


올바른 JSON 문자열은 아래처럼 작성해야 한다.

// json-parse-correct.js
const correctJsonText = '{"nickname":"짱구","age":5}'; // 올바른 JSON 문자열

const user = JSON.parse(correctJsonText); // JSON 문자열을 객체로 변환

console.log(user.nickname); // 변환된 객체 값 출력
// 출력결과
// 짱구

JSON.parse()를 사용할 때는 문자열이 반드시 올바른 JSON 형식인지 확인해야 한다.
형식이 맞지 않으면 객체나 배열 같은 JavaScript 값으로 변환되지 않고 오류가 발생한다.


JSON에서 사용할 수 있는 값은 정해져 있다

JSON에는 아무 값이나 넣을 수 있는 것이 아니다.
JSON에서 사용할 수 있는 값은 정해져 있다.


JSON에서 사용할 수 있는 대표적인 값은 문자열, 숫자, 참과 거짓, 배열, 객체, null이다.
여기서 null은 값이 없음을 의도적으로 나타내는 값이다.


반대로 undefined, 함수, 주석은 JSON 값으로 사용할 수 없다.
undefined는 값이 정의되지 않았다는 뜻이고, 함수는 실행 가능한 코드이다.
JSON은 데이터를 표현하는 형식이므로 실행 코드인 함수는 들어갈 수 없다.


아래 예제는 JSON.stringify()를 사용할 때 undefined와 함수가 어떻게 처리되는지 보여 준다.

// json-stringify-unsupported.js
const user = { // JavaScript 객체
    nickname: "짱구", // 문자열 값
    age: 5, // 숫자 값
    hobby: undefined, // JSON에서 표현할 수 없는 값
    sayHello: function () { // JSON에서 표현할 수 없는 함수
        return "안녕";
    }
};

const jsonText = JSON.stringify(user); // 객체를 JSON 문자열로 변환

console.log(jsonText); // 변환 결과 출력
// 출력결과
// {"nickname":"짱구","age":5}

JSON.stringify()는 객체를 JSON 문자열로 바꿀 때 undefined와 함수 값을 제외한다.
그래서 결과에는 nickname과 age만 남는다.


이 예제에서 중요한 점은 JSON이 코드 전체를 저장하는 형식이 아니라는 것이다.
JSON은 실행할 함수나 정의되지 않은 값이 아니라, 주고받을 데이터만 표현하는 형식이다.


정리하면 아래처럼 볼 수 있다.

  • JSON에서 문자열은 큰따옴표로 감싼다.
  • JSON에서 숫자는 그대로 작성할 수 있다.
  • JSON에서 참과 거짓은 true, false로 표현한다.
  • JSON에서 값이 없음을 나타낼 때는 null을 사용할 수 있다.
  • JSON에는 undefined, 함수, 주석을 넣을 수 없다.

JSON은 데이터를 주고받기 위한 형식이므로, 실행 코드나 정의되지 않은 값은 표현 대상이 아니다.


객체와 배열 모두 JSON 문자열로 바꿀 수 있다

JSON.stringify()는 객체뿐 아니라 배열에도 사용할 수 있다.
배열은 여러 값을 순서대로 담는 구조이다.
배열도 서버로 보내거나 저장할 때 문자열로 바꿔야 할 수 있다.


아래 예제는 객체 배열을 JSON 문자열로 바꾸고 다시 객체 배열로 되돌리는 코드이다.

// json-array-stringify-parse.js
const users = [ // 객체가 들어 있는 배열
    { nickname: "짱구", age: 5 }, // 첫 번째 사용자
    { nickname: "철수", age: 5 } // 두 번째 사용자
];

const jsonText = JSON.stringify(users); // 배열을 JSON 문자열로 변환
const parsedUsers = JSON.parse(jsonText); // JSON 문자열을 다시 배열로 변환

console.log(jsonText); // JSON 문자열 출력
console.log(parsedUsers[0].nickname); // 변환된 배열의 첫 번째 객체 값 출력
// 출력결과
// [{"nickname":"짱구","age":5},{"nickname":"철수","age":5}]
// 짱구

users는 객체가 들어 있는 배열이다.
JSON.stringify(users)를 사용하면 배열 전체가 JSON 문자열로 바뀐다.


그 문자열을 JSON.parse(jsonText)로 다시 변환하면 배열 구조를 되찾을 수 있다.
그래서 parsedUsers[0].nickname처럼 첫 번째 객체의 값을 꺼낼 수 있다.


이 흐름은 데이터를 저장했다가 다시 꺼내 쓰거나, 서버에서 받은 배열 데이터를 화면에 보여 줄 때 자주 사용된다.


JSON 변환 흐름은 JavaScript 값과 문자열 사이를 오간다

JSON을 사용할 때 가장 중요한 흐름은 두 가지이다.
객체나 배열 같은 JavaScript 값을 보낼 때는 문자열로 바꾸고, 문자열을 받았을 때는 다시 코드에서 사용할 수 있는 값으로 바꾼다.


JSON.stringify()는 객체나 배열 같은 JavaScript 값을 JSON 문자열로 바꾼다.
JSON.parse()는 JSON 문자열을 객체나 배열 같은 JavaScript 값으로 바꾼다.


아래 예제로 전체 흐름을 한 번에 볼 수 있다.

// json-convert-flow.js
const user = { // JavaScript 객체
    nickname: "짱구", // 닉네임
    age: 5 // 나이
};

const jsonText = JSON.stringify(user); // 객체를 JSON 문자열로 변환
const parsedUser = JSON.parse(jsonText); // JSON 문자열을 다시 객체로 변환

console.log(typeof user); // 원래 객체의 자료형 확인
console.log(typeof jsonText); // JSON 문자열의 자료형 확인
console.log(typeof parsedUser); // 다시 변환한 객체의 자료형 확인
console.log(parsedUser.nickname); // 변환된 객체 값 확인
// 출력결과
// object
// string
// object
// 짱구

처음 user는 JavaScript 객체이다.
JSON.stringify(user)를 실행하면 jsonText는 문자열이 된다.
다시 JSON.parse(jsonText)를 실행하면 parsedUser는 객체가 된다.


즉, JSON 변환 흐름은 아래처럼 정리할 수 있다.

  • 객체 또는 배열 같은 JavaScript 값 → JSON.stringify() → JSON 문자열
  • JSON 문자열 → JSON.parse() → 객체 또는 배열 같은 JavaScript 값

데이터를 보낼 때는 JSON.stringify(), 데이터를 받아서 코드에서 사용할 때는 JSON.parse()를 사용한다.


JSON 핵심 정리

JSON은 데이터를 주고받거나 저장하기 위한 문자열 형식이다.
객체나 배열처럼 생겼지만 실제로는 문자열이므로, 코드에서 객체나 배열처럼 사용하려면 변환이 필요하다.


핵심은 아래처럼 정리할 수 있다.

  • JSON은 데이터를 표현하는 문자열 형식이다.
  • JSON은 JavaScript 객체나 배열과 비슷하게 생길 수 있지만 실제 객체나 배열은 아니다.
  • JavaScript 객체와 배열은 코드 안에서 바로 사용할 수 있다.
  • JSON 문자열은 JSON.parse()로 객체나 배열 같은 JavaScript 값으로 바꿔야 사용할 수 있다.
  • JSON.stringify()는 객체나 배열 같은 JavaScript 값을 JSON 문자열로 바꾼다.
  • JSON.parse()는 JSON 문자열을 객체나 배열 같은 JavaScript 값으로 바꾼다.
  • JSON의 key는 반드시 큰따옴표로 감싸야 한다.
  • JSON의 문자열 값도 반드시 큰따옴표로 감싸야 한다.
  • JSON에는 undefined, 함수, 주석을 넣을 수 없다.
  • 올바르지 않은 JSON 문자열을 JSON.parse()로 변환하면 오류가 발생한다.

JSON의 핵심은 데이터를 문자열로 바꿔 주고받은 뒤, 다시 객체나 배열 같은 JavaScript 값으로 바꿔 코드에서 사용하는 흐름이다.
JSON.stringify()와 JSON.parse()의 방향을 구분하면 JSON을 훨씬 쉽게 이해할 수 있다.



Module

Module은 코드를 기능 단위로 나누어 관리하는 방식이다.
하나의 파일에 모든 코드를 몰아서 작성하면 처음에는 편해 보이지만, 코드가 길어질수록 어디에 어떤 기능이 있는지 찾기 어려워진다.


예를 들어 사용자 정보 처리, 계산 기능, 화면 출력 기능을 한 파일에 모두 넣으면 파일이 점점 복잡해진다.
이때 기능별로 파일을 나누고, 필요한 기능만 가져와서 사용하면 코드 흐름이 훨씬 정리된다.


다만 파일을 나누었다고 해서 모든 파일이 자동으로 Module이 되는 것은 아니다.
JavaScript에서 Module은 보통 import와 export를 사용해서 다른 파일과 값을 주고받는 파일을 말한다.
브라우저에서는 script type="module"로 실행되는 파일이 Module로 동작한다.


Module에서 다른 파일로 값을 내보내는 문법이 export이고, 다른 파일에서 값을 가져오는 문법이 import이다.


Module의 핵심은 코드를 파일 단위로 나누고, 필요한 값이나 함수만 export와 import로 연결해서 사용하는 것이다.
이 구조를 이해하면 큰 코드도 기능별로 나누어 관리할 수 있다.


Module이 필요한 이유

Module이 필요한 가장 큰 이유는 코드를 나누어 관리하기 위해서이다.
코드가 짧을 때는 한 파일에 작성해도 크게 문제가 없다.
하지만 기능이 많아지면 한 파일 안에서 변수, 함수, 실행 코드가 뒤섞인다.


이렇게 되면 수정할 때도 불편하다.
어떤 함수가 어디에서 사용되는지 찾기 어렵고, 같은 이름의 변수나 함수가 충돌할 가능성도 커진다.


Module을 사용하면 기능별로 파일을 나눌 수 있다.
예를 들어 계산 기능은 계산 파일에 두고, 화면 출력 기능은 화면 파일에 둘 수 있다.
그리고 필요한 파일에서 필요한 기능만 가져와 사용할 수 있다.


정리하면 Module을 사용하는 이유는 아래와 같다.

  • 기능별로 코드를 나누어 관리할 수 있다.
  • 필요한 코드만 가져와 사용할 수 있다.
  • 파일의 역할이 분명해진다.
  • 변수나 함수 이름 충돌을 줄일 수 있다.
  • 코드 재사용이 쉬워진다.

Module은 코드를 작게 나누고 다시 필요한 곳에서 연결하기 위한 구조이다.
파일을 나누는 것 자체가 목적이 아니라, 역할이 분명한 코드 구조를 만들기 위해 사용한다.


export는 다른 파일에서 사용할 수 있게 내보내는 문법이다

export는 현재 파일 안에 있는 변수나 함수를 다른 파일에서 사용할 수 있게 내보내는 문법이다.
파일 안에 변수나 함수를 만들었다고 해서 다른 파일에서 자동으로 사용할 수 있는 것은 아니다.


다른 파일에서 사용하려면 반드시 export로 내보내야 한다.
export하지 않은 값은 그 파일 내부에서만 사용할 수 있다고 이해하면 된다.


아래 예제는 사용자 이름과 인사 함수를 내보내는 코드이다.

// user-module.js
export const nickname = "짱구"; // 다른 파일에서 사용할 수 있게 변수 내보내기

export function sayHello() { // 다른 파일에서 사용할 수 있게 함수 내보내기
    return "안녕하세요"; // 인사 문장 반환
}

이 코드에서 nickname과 sayHello() 앞에 export가 붙어 있다.
그래서 다른 파일에서 이 두 값을 가져와 사용할 수 있다.


여기서 중요한 점은 export가 값을 실행하는 문법이 아니라는 것이다.
export는 “이 값을 다른 파일에서 가져갈 수 있게 공개하겠다”는 의미이다.


export는 현재 파일의 값이나 함수를 외부 파일에서 사용할 수 있도록 공개하는 문법이다.


import는 다른 파일에서 내보낸 값을 가져오는 문법이다

import는 다른 파일에서 export한 값을 현재 파일로 가져오는 문법이다.
가져온 값은 현재 파일 안에서 변수나 함수처럼 사용할 수 있다.


아래 예제는 앞에서 만든 user-module.js의 값을 가져와 사용하는 코드이다.

// main-import-basic.js
import { nickname, sayHello } from "./user-module.js"; // 다른 파일에서 내보낸 값 가져오기

console.log(nickname); // 가져온 변수 출력
console.log(sayHello()); // 가져온 함수 실행 결과 출력
// 출력결과
// 짱구
// 안녕하세요

import { nickname, sayHello } from "./user-module.js"는 user-module.js 파일에서 nickname과 sayHello를 가져오겠다는 뜻이다.


중괄호 안에는 가져올 이름을 적는다.
이때 이름은 export한 이름과 같아야 한다.
그래서 nickname으로 내보냈다면 가져올 때도 nickname이라고 적어야 한다.


from "./user-module.js"는 어느 파일에서 가져올지 경로를 적는 부분이다.
여기서 ./는 현재 파일과 같은 위치를 기준으로 찾겠다는 뜻이다.


import는 다른 파일에서 export한 값을 현재 파일로 가져와 사용하는 문법이다.
export와 import는 따로 떨어진 문법이 아니라, 내보내기와 가져오기로 서로 연결된다.


named export는 이름을 기준으로 내보내고 가져온다

named export는 이름이 있는 값을 내보내는 방식이다.
여기서 named는 “이름이 붙은”이라는 뜻이다.
즉, 변수 이름이나 함수 이름을 기준으로 내보내고 가져온다.


앞에서 본 export const nickname, export function sayHello()가 named export이다.
이 방식에서는 내보낸 이름과 가져오는 이름이 같아야 한다.


아래 예제는 여러 값을 named export로 내보내는 코드이다.

// math-module.js
export const add = (a, b) => { // 더하기 함수 내보내기
    return a + b; // 두 수의 합 반환
};

export const subtract = (a, b) => { // 빼기 함수 내보내기
    return a - b; // 두 수의 차 반환
};

아래 코드는 math-module.js에서 내보낸 두 함수를 가져와 사용한다.

// main-named-import.js
import { add, subtract } from "./math-module.js"; // 이름을 기준으로 함수 가져오기

console.log(add(10, 5)); // 더하기 결과 출력
console.log(subtract(10, 5)); // 빼기 결과 출력
// 출력결과
// 15
// 5

add와 subtract는 이름이 있는 상태로 내보내졌다.
그래서 가져올 때도 중괄호 안에 { add, subtract }처럼 정확한 이름을 적는다.


만약 이름을 잘못 적으면 해당 값을 찾을 수 없다.
named export는 이름이 기준이기 때문이다.


정리하면 아래처럼 볼 수 있다.

  • named export는 여러 값을 내보낼 수 있다.
  • 가져올 때 중괄호 {}를 사용한다.
  • 내보낸 이름과 가져오는 이름이 같아야 한다.

named export는 이름을 기준으로 연결되므로, export한 이름과 import하는 이름을 맞춰야 한다.


as를 사용하면 가져오는 이름을 바꿀 수 있다

named export는 원래 내보낸 이름과 같은 이름으로 가져와야 한다.
하지만 현재 파일에서 다른 이름으로 사용하고 싶을 때가 있다.


이때 as를 사용한다.
as는 가져온 값에 다른 이름을 붙이는 문법이다.


아래 예제는 add 함수를 가져오면서 plus라는 이름으로 바꾸는 코드이다.

// main-import-alias.js
import { add as plus } from "./math-module.js"; // add를 plus라는 이름으로 가져오기

console.log(plus(3, 4)); // plus 이름으로 함수 실행
// 출력결과
// 7

import { add as plus }는 math-module.js에서 add라는 이름으로 내보낸 값을 가져오되, 현재 파일에서는 plus라는 이름으로 사용하겠다는 뜻이다.


이때 실제로 현재 파일에서 사용할 이름은 plus이다.
그래서 add(3, 4)가 아니라 plus(3, 4)로 호출한다.


as는 export된 이름을 현재 파일에서 다른 이름으로 사용하고 싶을 때 사용한다.
이름 충돌을 피하거나, 현재 파일의 의미에 맞게 이름을 바꾸고 싶을 때 유용하다.


default export는 파일에서 기본으로 내보내는 값이다

default export는 파일에서 기본값처럼 하나를 내보내는 방식이다.
default는 “기본”이라는 뜻이다.


named export는 이름을 기준으로 여러 값을 내보낼 수 있다.
반면 default export는 한 파일에서 하나만 사용할 수 있다.
파일의 대표 기능이나 대표 값을 내보낼 때 주로 사용한다.


아래 예제는 사용자 객체를 default export로 내보내는 코드이다.

// user-default-module.js
const user = { // 사용자 객체 생성
    nickname: "짱구", // 닉네임
    age: 5 // 나이
};

export default user; // 파일의 기본값으로 user 내보내기

이 값을 가져올 때는 중괄호를 사용하지 않는다.

// main-default-import.js
import userInfo from "./user-default-module.js"; // default export 값 가져오기

console.log(userInfo.nickname); // 가져온 객체의 nickname 출력
console.log(userInfo.age); // 가져온 객체의 age 출력
// 출력결과
// 짱구
// 5

default export는 가져올 때 이름을 자유롭게 정할 수 있다.
위 코드에서는 user라는 이름으로 내보냈지만, 가져올 때는 userInfo라는 이름으로 받았다.


이것이 가능한 이유는 default export가 이름 기준이 아니라, 그 파일의 기본 내보내기 값을 가져오는 방식이기 때문이다.


default export는 한 파일에서 하나만 사용할 수 있고, 가져올 때 중괄호 없이 원하는 이름으로 받을 수 있다.


named export와 default export는 가져오는 방식이 다르다

named export와 default export는 모두 다른 파일에서 사용할 값을 내보내는 문법이다.
하지만 가져오는 방식이 다르다.


named export는 이름을 기준으로 가져온다.
그래서 중괄호를 사용하고, 내보낸 이름과 같은 이름을 적어야 한다.


default export는 파일의 기본 내보내기 값을 가져온다.
그래서 중괄호를 사용하지 않고, 가져오는 쪽에서 원하는 이름을 붙일 수 있다.


아래 예제는 두 방식을 한 번에 비교하는 코드이다.

// module-mixed-export.js
export const version = "1.0.0"; // named export

const serviceName = "회원 서비스"; // 기본으로 내보낼 값

export default serviceName; // default export

아래 코드는 default export와 named export를 함께 가져온다.

// main-mixed-import.js
import service, { version } from "./module-mixed-export.js"; // default와 named를 함께 가져오기

console.log(service); // default export 값 출력
console.log(version); // named export 값 출력
// 출력결과
// 회원 서비스
// 1.0.0

service는 default export로 가져온 값이다.
그래서 중괄호 없이 작성했고, 이름도 가져오는 쪽에서 정했다.


version은 named export로 가져온 값이다.
그래서 중괄호 안에 { version }처럼 작성했고, 내보낸 이름과 맞춰야 한다.


정리하면 아래처럼 볼 수 있다.

  • named export는 중괄호를 사용한다.
  • named export는 내보낸 이름과 가져오는 이름이 같아야 한다.
  • default export는 중괄호를 사용하지 않는다.
  • default export는 가져오는 쪽에서 이름을 정할 수 있다.
  • 한 파일에서 default export는 하나만 사용할 수 있다.

named export와 default export를 구분할 때는 중괄호 사용 여부와 이름 기준 여부를 보면 된다.


import * as를 사용하면 모듈 전체를 객체처럼 가져올 수 있다

여러 값을 한 번에 가져오고 싶을 때 import * as를 사용할 수 있다.
이 방식은 해당 파일에서 내보낸 값들을 하나의 객체처럼 묶어서 가져온다.


아래 예제는 math-module.js의 모든 named export를 math라는 이름으로 가져오는 코드이다.

// main-import-all.js
import * as math from "./math-module.js"; // math-module의 named export 전체 가져오기

console.log(math.add(2, 3)); // math 객체를 통해 add 사용
console.log(math.subtract(10, 4)); // math 객체를 통해 subtract 사용
// 출력결과
// 5
// 6

import * as math는 math-module.js에서 내보낸 이름 있는 값들을 math라는 객체처럼 묶어 가져온다.
그래서 math.add(), math.subtract()처럼 접근한다.


이 방식은 가져올 값이 많거나, 어떤 모듈에서 온 기능인지 드러내고 싶을 때 사용할 수 있다.


다만 모든 값을 무조건 이렇게 가져오는 것이 좋은 것은 아니다.
필요한 값이 분명하면 필요한 것만 가져오는 편이 코드 의도를 더 명확하게 보여 줄 수 있다.


브라우저에서 Module을 사용하려면 type="module"이 필요하다

브라우저에서 import와 export를 사용하는 파일을 실행하려면 script 태그에 type="module"을 붙여야 한다.
일반 script로 실행하면 import 문법을 사용할 수 없다.


브라우저에서는 type="module"이 붙은 script 파일이 시작점이 된다.
그 시작점 파일 안에서 import한 다른 파일들도 Module로 연결되어 실행된다.
그래서 main-import-basic.js를 type="module"로 연결하면, 그 안에서 가져오는 user-module.js도 Module 방식으로 해석된다.


아래 예제는 브라우저에서 Module 파일을 연결하는 코드이다.

<!-- module-index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"> <!-- 문자 인코딩 설정 -->
    <title>Module Example</title> <!-- 문서 제목 -->
</head>
<body>
    <h1>Module 예제</h1> <!-- 화면 제목 -->

    <script type="module" src="./main-import-basic.js"></script> <!-- Module 시작 파일 연결 -->
</body>
</html>

type="module"을 붙이면 브라우저가 해당 script를 Module로 해석한다.
그래서 main-import-basic.js 안에서 import 문법을 사용할 수 있다.


또 Module에서는 파일 경로를 명확히 적어야 한다.
현재 위치의 파일을 가져올 때는 보통 ./파일명.js처럼 작성한다.


브라우저에서 import와 export를 사용하려면 시작 파일을 script type="module"로 실행해야 한다.
이 설정이 없으면 일반 script로 해석되기 때문에 Module 문법이 정상적으로 동작하지 않는다.


Module은 자체 scope를 가진다

Module은 자체 scope를 가진다.
여기서 scope는 변수나 함수가 유효한 범위를 말한다.


일반적으로 Module 파일 안에서 만든 변수나 함수는 다른 파일에서 자동으로 보이지 않는다.
다른 파일에서 사용하려면 export로 내보내고, 사용하는 쪽에서 import로 가져와야 한다.


아래 예제에서 secret은 export하지 않았기 때문에 다른 파일에서 직접 가져올 수 없다.

// scope-module.js
const secret = "비공개 값"; // export하지 않은 값

export const publicValue = "공개 값"; // export한 값

아래 코드에서는 publicValue만 가져올 수 있다.

// main-scope-import.js
import { publicValue } from "./scope-module.js"; // export된 값만 가져오기

console.log(publicValue); // 공개 값 출력
// 출력결과
// 공개 값

secret은 같은 파일 안에서는 사용할 수 있지만, export하지 않았으므로 다른 파일에서 가져올 수 없다.
반면 publicValue는 export했기 때문에 import로 가져올 수 있다.


Module 파일 안의 값은 기본적으로 그 파일 안에서만 유효하고, 외부에서 사용하려면 반드시 export해야 한다.
이 구조 덕분에 불필요한 전역 변수 충돌을 줄일 수 있다.


Module 핵심 정리

Module은 코드를 기능 단위로 나누고, 필요한 값이나 함수만 다른 파일에서 사용할 수 있게 연결하는 구조이다.
큰 코드를 여러 파일로 나누면 역할이 분명해지고, 유지보수가 쉬워진다.


핵심은 아래처럼 정리할 수 있다.

  • Module은 코드를 파일 단위로 나누어 관리하는 방식이다.
  • 파일을 나누었다고 모두 Module이 되는 것은 아니며, import/export를 사용하거나 type="module"로 실행되는 파일이 Module로 동작한다.
  • export는 현재 파일의 값이나 함수를 다른 파일에서 사용할 수 있게 내보낸다.
  • import는 다른 파일에서 export한 값을 현재 파일로 가져온다.
  • named export는 이름을 기준으로 내보내고 가져온다.
  • named export는 가져올 때 중괄호 {}를 사용한다.
  • as를 사용하면 가져온 값의 이름을 현재 파일에서 바꿔 사용할 수 있다.
  • default export는 한 파일에서 하나만 사용할 수 있다.
  • default export는 가져올 때 중괄호를 사용하지 않는다.
  • import * as는 여러 named export를 하나의 객체처럼 묶어서 가져온다.
  • 브라우저에서 Module을 사용하려면 시작 파일에 script type="module"이 필요하다.
  • Module은 자체 scope를 가지므로, export하지 않은 값은 다른 파일에서 사용할 수 없다.

Module을 이해할 때는 파일을 나누는 것보다, 어떤 값을 외부에 공개하고 어떤 값을 내부에 감출지 구분하는 것이 중요하다.
export와 import의 방향을 구분하면 여러 파일로 나누어진 코드도 자연스럽게 읽을 수 있다.



Symbol

Symbol은 ES6에서 추가된 원시 자료형이다.
원시 자료형은 더 이상 작은 단위로 나눌 수 없는 기본 값이라고 이해하면 된다.
예를 들어 문자열, 숫자, 참과 거짓 같은 값이 원시 자료형에 해당한다.


Symbol은 고유한 값을 만들 때 사용한다.
특히 Symbol()을 호출하면 매번 새로운 고유 값이 만들어진다.
겉으로는 같은 설명을 가진 Symbol처럼 보여도, 실제로는 서로 다른 값이다.


예를 들어 객체에 새로운 프로퍼티를 추가해야 하는데, 기존 프로퍼티 이름과 충돌하면 문제가 생길 수 있다.
이때 Symbol을 프로퍼티 이름으로 사용하면 다른 이름과 겹칠 가능성을 줄일 수 있다.
또 Symbol 프로퍼티는 일반적인 프로퍼티 목록 조회에서 잘 드러나지 않는다.


Symbol()의 핵심은 같은 설명을 넣어도 호출할 때마다 새로운 고유 값이 만들어진다는 점이다.
그래서 객체의 프로퍼티 이름 충돌을 줄이거나, 일반적인 조회에서 덜 드러나는 프로퍼티를 만들 때 사용할 수 있다.


Symbol은 고유한 값을 만든다

Symbol은 Symbol() 함수를 사용해서 만든다.
이때 괄호 안에 설명을 넣을 수 있다.
이 설명은 사람이 구분하기 쉽게 붙이는 이름일 뿐, 실제 값이 같아지는 기준은 아니다.


여기서 주의할 점은 Symbol은 new를 붙여 만들지 않는다는 것이다.
Promise나 Proxy는 new Promise(), new Proxy()처럼 만들지만, Symbol은 Symbol()처럼 함수 호출 형태로 만든다.


아래 예제는 같은 설명을 가진 Symbol 두 개를 비교하는 코드이다.

// symbol-unique-basic.js
const symbol1 = Symbol("id"); // id 설명을 가진 Symbol 생성
const symbol2 = Symbol("id"); // 같은 설명을 가진 Symbol 생성

console.log(symbol1 === symbol2); // 두 Symbol이 같은지 비교
console.log(symbol1); // 첫 번째 Symbol 출력
console.log(symbol2); // 두 번째 Symbol 출력
// 출력결과
// false
// Symbol(id)
// Symbol(id)

symbol1과 symbol2는 둘 다 Symbol("id")로 만들었다.
출력하면 둘 다 Symbol(id)처럼 보인다.


하지만 symbol1 === symbol2의 결과는 false이다.
같은 설명을 넣어도 Symbol()을 호출할 때마다 새로운 고유 값이 만들어지기 때문이다.


Symbol()은 호출할 때마다 새로운 고유 값을 만든다.
그래서 설명이 같아도 두 Symbol은 같은 값이 아니다.


Symbol의 설명은 값을 구분하는 기준이 아니다

Symbol("id")에서 "id"는 설명이다.
이 설명은 개발자가 어떤 용도로 만든 Symbol인지 알아보기 쉽게 붙이는 글자이다.


하지만 설명이 같다고 해서 같은 Symbol이 되는 것은 아니다.
Symbol의 고유성은 설명으로 결정되지 않는다.
Symbol()을 호출한 순간마다 서로 다른 값이 만들어진다.


아래 예제는 설명을 확인하는 코드이다.

// symbol-description.js
const userId = Symbol("userId"); // userId 설명을 가진 Symbol 생성

console.log(userId.description); // Symbol 설명 확인
console.log(String(userId)); // Symbol을 문자열 형태로 확인
// 출력결과
// userId
// Symbol(userId)

description은 Symbol을 만들 때 넣은 설명을 확인할 때 사용한다.
String(userId)는 Symbol을 문자열처럼 확인하기 위한 방법이다.


다만 여기서 중요한 점은 description이 같다고 해서 같은 값이 되는 것이 아니라는 점이다.
설명은 말 그대로 사람이 알아보기 위한 표시이다.


정리하면 아래처럼 볼 수 있다.

  • Symbol("userId")에서 "userId"는 설명이다.
  • 설명은 사람이 알아보기 위한 표시이다.
  • 설명이 같아도 Symbol 값은 서로 다를 수 있다.

Symbol의 설명은 고유 값을 만드는 기준이 아니라, 사람이 구분하기 쉽게 붙이는 표시이다.


Symbol은 객체의 프로퍼티 이름으로 사용할 수 있다

Symbol은 객체의 프로퍼티 이름으로 사용할 수 있다.
객체의 프로퍼티 이름은 보통 문자열을 사용하지만, Symbol도 프로퍼티 이름으로 사용할 수 있다.


이때 Symbol을 프로퍼티 이름으로 쓰려면 대괄호 []를 사용해야 한다.
객체에서 대괄호를 사용하면 변수에 들어 있는 값을 프로퍼티 이름으로 사용할 수 있다.


아래 예제는 Symbol을 객체의 프로퍼티 이름으로 사용하는 코드이다.

// symbol-object-property.js
const idKey = Symbol("id"); // 고유한 프로퍼티 이름으로 사용할 Symbol 생성

const user = {
    nickname: "짱구", // 일반 문자열 프로퍼티
    [idKey]: 1001 // Symbol을 프로퍼티 이름으로 사용
};

console.log(user.nickname); // 일반 프로퍼티 값 출력
console.log(user[idKey]); // Symbol 프로퍼티 값 출력
// 출력결과
// 짱구
// 1001

idKey는 Symbol 값이다.
객체 안에서 [idKey]: 1001이라고 작성했기 때문에 idKey에 들어 있는 Symbol이 프로퍼티 이름으로 사용된다.


Symbol로 만든 프로퍼티는 점 표기법으로 접근할 수 없다.
user.idKey처럼 쓰면 idKey라는 문자열 이름의 프로퍼티를 찾게 된다.
그래서 Symbol 프로퍼티에 접근할 때는 user[idKey]처럼 대괄호를 사용해야 한다.


Symbol을 객체의 프로퍼티 이름으로 사용할 때는 반드시 대괄호 []로 접근해야 한다.


Symbol을 사용하면 프로퍼티 이름 충돌을 줄일 수 있다

객체에 여러 코드가 값을 추가하다 보면 같은 프로퍼티 이름을 사용할 수 있다.
이때 문자열 이름을 사용하면 기존 값이 덮어써질 수 있다.


아래 예제는 같은 문자열 프로퍼티 이름을 사용해서 값이 덮어써지는 경우이다.

// symbol-key-conflict-string.js
const user = {
    nickname: "짱구", // 닉네임
    id: 1001 // 기존 id 값
};

user.id = "external-id"; // 같은 이름의 프로퍼티에 새 값 대입

console.log(user.id); // 덮어쓴 id 값 출력
// 출력결과
// external-id

처음 user.id에는 1001이 들어 있었다.
그런데 다시 user.id = "external-id"를 실행했기 때문에 기존 값이 덮어써졌다.


문자열 프로퍼티 이름은 이름이 같으면 같은 프로퍼티를 가리킨다.
그래서 의도하지 않은 충돌이 생길 수 있다.


이번에는 Symbol을 사용해 보자.

// symbol-key-conflict-symbol.js
const userIdKey = Symbol("id"); // 내부 id용 Symbol
const externalIdKey = Symbol("id"); // 외부 id용 Symbol

const user = {
    nickname: "짱구", // 닉네임
    [userIdKey]: 1001 // 내부 id 저장
};

user[externalIdKey] = "external-id"; // 다른 Symbol 프로퍼티에 외부 id 저장

console.log(user[userIdKey]); // 내부 id 출력
console.log(user[externalIdKey]); // 외부 id 출력
console.log(userIdKey === externalIdKey); // 두 Symbol 비교
// 출력결과
// 1001
// external-id
// false

userIdKey와 externalIdKey는 둘 다 설명이 "id"이다.
하지만 두 값은 서로 다른 Symbol이다.


그래서 같은 객체 안에서 사용해도 서로 다른 프로퍼티 이름으로 동작한다.
기존 userIdKey 값이 덮어써지지 않고, externalIdKey 값이 따로 저장된다.


Symbol을 프로퍼티 이름으로 사용하면 같은 설명을 가진 값이라도 서로 다른 키가 되기 때문에 이름 충돌을 줄일 수 있다.


Symbol 프로퍼티는 일반 반복에서 잘 보이지 않는다

Symbol로 만든 프로퍼티는 일반적인 객체 탐색에서 잘 보이지 않는다.
예를 들어 Object.keys()나 for...in은 문자열 프로퍼티 이름을 대상으로 한다.


그래서 Symbol 프로퍼티는 객체 안에 있지만, 일반 프로퍼티 목록에는 나오지 않는다.
이 점 때문에 Symbol은 숨겨진 값처럼 보일 수 있다.
하지만 완전히 비공개 값은 아니다.


아래 예제를 보면 차이를 확인할 수 있다.

// symbol-property-enumeration.js
const idKey = Symbol("id"); // Symbol 프로퍼티 이름 생성

const user = {
    nickname: "짱구", // 일반 문자열 프로퍼티
    age: 5, // 일반 문자열 프로퍼티
    [idKey]: 1001 // Symbol 프로퍼티
};

console.log(Object.keys(user)); // 문자열 프로퍼티 이름 목록 출력

for (const key in user) { // 문자열 프로퍼티를 반복
    console.log(key); // 반복되는 key 출력
}

console.log(Object.getOwnPropertySymbols(user)); // Symbol 프로퍼티 목록 출력
// 출력결과
// ['nickname', 'age']
// nickname
// age
// [Symbol(id)]

Object.keys(user)는 nickname, age만 보여 준다.
for...in 반복에서도 nickname, age만 출력된다.


하지만 Object.getOwnPropertySymbols(user)를 사용하면 객체 안의 Symbol 프로퍼티를 확인할 수 있다.
즉, Symbol 프로퍼티가 완전히 사라진 것이 아니다.
일반적인 문자열 프로퍼티 목록에 포함되지 않을 뿐이다.


Symbol 프로퍼티는 일반적인 프로퍼티 탐색에서 잘 드러나지 않지만, 완전히 숨겨진 비공개 값은 아니다.
필요하면 Object.getOwnPropertySymbols()로 확인할 수 있다.


JSON.stringify는 Symbol 프로퍼티를 문자열로 바꾸지 않는다

객체를 JSON.stringify()로 JSON 문자열로 바꿀 때 Symbol 프로퍼티는 포함되지 않는다.
JSON은 데이터를 문자열 형식으로 표현하는 규칙이고, Symbol 프로퍼티는 그 변환 대상에 포함되지 않는다.


아래 예제는 Symbol 프로퍼티가 JSON 문자열에 포함되지 않는 흐름을 보여 준다.

// symbol-json-stringify.js
const idKey = Symbol("id"); // Symbol 프로퍼티 이름 생성

const user = {
    nickname: "짱구", // 일반 프로퍼티
    [idKey]: 1001 // Symbol 프로퍼티
};

const jsonText = JSON.stringify(user); // 객체를 JSON 문자열로 변환

console.log(jsonText); // JSON 변환 결과 출력
// 출력결과
// {"nickname":"짱구"}

user 객체에는 nickname과 Symbol(id) 프로퍼티가 있다.
하지만 JSON.stringify(user) 결과에는 nickname만 포함된다.


이 말은 Symbol 프로퍼티가 객체 안에 없는 것이 아니라, JSON 문자열로 변환될 때 제외된다는 뜻이다.


이 흐름은 앞에서 본 JSON의 특징과 연결된다.
JSON은 데이터를 주고받기 위한 문자열 형식이므로, Symbol처럼 고유한 프로퍼티 키는 일반적인 JSON 데이터로 표현되지 않는다.


Symbol.for는 전역 Symbol 저장소를 사용한다

일반적인 Symbol()은 호출할 때마다 새로운 고유 값을 만든다.
하지만 같은 이름으로 같은 Symbol을 다시 찾아 사용해야 할 때도 있다.


이때 사용할 수 있는 것이 Symbol.for()이다.
Symbol.for()는 전역 Symbol 저장소를 사용한다.
전역 저장소는 프로그램 전체에서 공유되는 보관 공간이라고 이해하면 된다.


Symbol.for("id")를 호출하면 먼저 전역 저장소에 "id"라는 이름의 Symbol이 있는지 찾는다.
이미 있으면 그 값을 그대로 가져오고, 없으면 새로 만들어 저장한 뒤 반환한다.


아래 예제는 Symbol()과 Symbol.for()의 차이를 보여 준다.

// symbol-for-basic.js
const symbol1 = Symbol("id"); // 매번 새로운 Symbol 생성
const symbol2 = Symbol("id"); // 매번 새로운 Symbol 생성

const globalSymbol1 = Symbol.for("id"); // 전역 저장소에서 id Symbol 생성 또는 조회
const globalSymbol2 = Symbol.for("id"); // 전역 저장소에서 같은 id Symbol 조회

console.log(symbol1 === symbol2); // 일반 Symbol 비교
console.log(globalSymbol1 === globalSymbol2); // 전역 Symbol 비교
// 출력결과
// false
// true

Symbol("id")는 호출할 때마다 새로운 값을 만든다.
그래서 symbol1 === symbol2는 false이다.


반면 Symbol.for("id")는 전역 저장소에서 같은 이름의 Symbol을 찾는다.
처음에는 없으므로 만들고, 두 번째 호출에서는 이미 만든 값을 가져온다.
그래서 globalSymbol1 === globalSymbol2는 true이다.


Symbol()은 매번 새로운 고유 값을 만들고, Symbol.for()는 전역 저장소에서 같은 이름의 Symbol을 공유한다.


Symbol.keyFor는 전역 Symbol의 이름을 찾는다

Symbol.keyFor()는 Symbol.for()로 만든 전역 Symbol의 이름을 찾을 때 사용한다.
여기서 이름은 Symbol.for("id")에 넣었던 "id" 같은 값을 말한다.


중요한 점은 Symbol.keyFor()가 모든 Symbol의 설명을 찾아 주는 것이 아니라는 점이다.
Symbol.for()로 전역 저장소에 등록된 Symbol에 대해서만 이름을 찾을 수 있다.


아래 예제를 보면 차이를 확인할 수 있다.

// symbol-keyfor-basic.js
const localSymbol = Symbol("id"); // 일반 Symbol 생성
const globalSymbol = Symbol.for("id"); // 전역 Symbol 생성 또는 조회

console.log(Symbol.keyFor(localSymbol)); // 일반 Symbol은 전역 이름이 없음
console.log(Symbol.keyFor(globalSymbol)); // 전역 Symbol의 이름 조회
// 출력결과
// undefined
// id

localSymbol은 Symbol("id")로 만든 일반 Symbol이다.
전역 저장소에 등록된 값이 아니므로 Symbol.keyFor(localSymbol)의 결과는 undefined이다.


globalSymbol은 Symbol.for("id")로 만든 전역 Symbol이다.
그래서 Symbol.keyFor(globalSymbol)을 실행하면 전역 저장소에 등록된 이름인 "id"가 나온다.


Symbol.keyFor()는 Symbol.for()로 등록된 전역 Symbol의 이름을 찾을 때 사용한다.
일반 Symbol()로 만든 값의 설명을 찾는 용도로 사용하는 것이 아니다.


Symbol 핵심 정리

Symbol은 고유한 값을 만들기 위한 원시 자료형이다.
특히 Symbol()은 같은 설명을 넣어도 매번 새로운 값을 만들기 때문에, 객체의 프로퍼티 이름 충돌을 줄일 때 사용할 수 있다.


핵심은 아래처럼 정리할 수 있다.

  • Symbol은 고유한 값을 만드는 원시 자료형이다.
  • Symbol()은 호출할 때마다 새로운 고유 값을 만든다.
  • Symbol은 new를 붙여 만들지 않는다.
  • 같은 설명을 넣어도 서로 다른 Symbol이 만들어진다.
  • Symbol의 설명은 사람이 구분하기 위한 표시일 뿐, 같은 값을 만드는 기준이 아니다.
  • Symbol은 객체의 프로퍼티 이름으로 사용할 수 있다.
  • Symbol 프로퍼티에 접근할 때는 대괄호 []를 사용한다.
  • Symbol을 프로퍼티 이름으로 사용하면 이름 충돌을 줄일 수 있다.
  • Symbol 프로퍼티는 Object.keys()나 for...in에 드러나지 않는다.
  • Symbol 프로퍼티는 완전히 비공개 값은 아니다.
  • Symbol 프로퍼티는 Object.getOwnPropertySymbols()로 확인할 수 있다.
  • JSON.stringify()는 Symbol 프로퍼티를 JSON 문자열에 포함하지 않는다.
  • Symbol.for()는 전역 저장소에서 같은 이름의 Symbol을 공유한다.
  • Symbol.keyFor()는 Symbol.for()로 등록된 전역 Symbol의 이름을 찾는다.

Symbol을 이해할 때는 “설명이 같은가”가 아니라 “같은 Symbol 값을 공유하는가”를 기준으로 봐야 한다.
이 기준을 잡으면 Symbol(), Symbol.for(), 객체 프로퍼티 키로 사용하는 흐름을 더 쉽게 구분할 수 있다.



Set

Set은 중복된 값을 허용하지 않는 데이터 묶음이다.
배열처럼 여러 값을 담을 수 있지만, 같은 값을 여러 번 저장하지 않는다는 점이 다르다.


예를 들어 같은 이름이 여러 번 들어 있는 목록에서 중복을 제거해야 한다고 생각해 보자.
배열만 사용하면 직접 반복하면서 이미 있는 값인지 확인해야 한다.
하지만 Set을 사용하면 같은 값은 한 번만 저장되므로 중복 제거 흐름을 더 쉽게 만들 수 있다.


Set의 핵심은 여러 값을 저장하되, 같은 값은 하나만 유지한다는 점이다.
그래서 중복 없는 목록이 필요할 때 사용할 수 있다.


Set은 중복 값을 저장하지 않는다

Set은 new Set()으로 만든다.
Set에 값을 추가할 때는 add()를 사용한다.


같은 값을 여러 번 추가해도 Set 안에는 하나만 남는다.
즉, Set은 값을 추가할 때 이미 같은 값이 있는지 확인하고, 있으면 다시 저장하지 않는다.


아래 예제는 같은 값을 여러 번 추가하는 코드이다.

// set-unique-basic.js
const numbers = new Set(); // 빈 Set 생성

numbers.add(1); // 1 추가
numbers.add(2); // 2 추가
numbers.add(2); // 이미 있는 2 다시 추가
numbers.add(3); // 3 추가
numbers.add(3); // 이미 있는 3 다시 추가

console.log([...numbers]); // Set을 배열로 펼쳐 출력
console.log(numbers.size); // Set에 저장된 값 개수 출력
// 출력결과
// [1, 2, 3]
// 3

2와 3을 두 번씩 추가했지만 결과에는 한 번씩만 들어 있다.
그래서 Set을 배열로 바꿔 출력하면 [1, 2, 3]이 나온다.


size는 Set에 들어 있는 값의 개수를 나타내는 프로퍼티이다.
중복 값은 저장되지 않기 때문에 size는 3이다.


Set은 같은 값을 여러 번 추가해도 한 번만 저장한다.
이 점이 배열과 가장 큰 차이이다.


Set은 배열과 비슷하지만 인덱스로 접근하지 않는다

Set은 여러 값을 담는다는 점에서 배열과 비슷해 보인다.
하지만 배열과 같은 구조는 아니다.


배열은 값의 위치를 인덱스로 관리한다.
인덱스는 배열에서 값의 순서를 나타내는 번호이며, 0부터 시작한다.
그래서 배열은 arr[0], arr[1]처럼 위치로 값을 꺼낼 수 있다.


반면 Set은 중복 없는 값의 묶음이다.
값이 들어간 순서는 유지되지만, 배열처럼 인덱스 번호로 직접 접근하지 않는다.


아래 예제를 보면 차이를 알 수 있다.

// set-array-difference.js
const fruitsArray = ["apple", "banana", "apple"]; // 중복이 있는 배열
const fruitsSet = new Set(["apple", "banana", "apple"]); // 중복이 제거되는 Set

console.log(fruitsArray[0]); // 배열은 인덱스로 접근 가능
console.log(fruitsSet[0]); // Set은 인덱스로 접근하지 않음
console.log([...fruitsSet]); // Set을 배열로 바꿔 확인
// 출력결과
// apple
// undefined
// ['apple', 'banana']

배열인 fruitsArray는 fruitsArray[0]으로 첫 번째 값을 꺼낼 수 있다.
하지만 fruitsSet[0]은 사용할 수 없다.
Set은 배열처럼 인덱스 번호로 값을 꺼내는 구조가 아니기 때문이다.


Set 안의 값을 배열처럼 보고 싶다면 Spread Operator를 사용해 [...fruitsSet]처럼 배열로 바꿀 수 있다.


배열은 순서와 인덱스를 기준으로 값을 다루고, Set은 중복 없는 값의 묶음을 기준으로 값을 다룬다.


add는 값을 추가한다

add()는 Set에 값을 추가하는 메서드이다.
메서드는 객체가 가지고 있는 함수라고 이해하면 된다.


add()는 값을 추가한 뒤 다시 자기 자신인 Set을 반환한다.
그래서 add()를 이어서 사용할 수도 있다.


아래 예제는 add()를 여러 번 이어서 사용하는 코드이다.

// set-add-chain.js
const animals = new Set(); // 빈 Set 생성

animals
    .add("dog") // dog 추가
    .add("cat") // cat 추가
    .add("dog") // 이미 있는 dog 다시 추가
    .add("rabbit"); // rabbit 추가

console.log([...animals]); // Set을 배열로 바꿔 출력
console.log(animals.size); // 저장된 값 개수 출력
// 출력결과
// ['dog', 'cat', 'rabbit']
// 3

dog를 두 번 추가했지만 한 번만 저장된다.
add()를 이어서 사용해도 결과는 중복 없는 값만 남는다.


정리하면 add()는 값을 추가하는 메서드이고, 이미 있는 값은 다시 추가하지 않는다.


has는 값이 있는지 확인한다

has()는 Set 안에 특정 값이 있는지 확인하는 메서드이다.
값이 있으면 true, 없으면 false를 반환한다.


여기서 true는 참, false는 거짓을 의미한다.
조건문에서 어떤 값이 있는지 검사할 때 자주 사용할 수 있다.


아래 예제는 특정 값이 있는지 확인하는 코드이다.

// set-has-basic.js
const fruits = new Set(["apple", "banana", "orange"]); // 과일 Set 생성

console.log(fruits.has("banana")); // banana가 있는지 확인
console.log(fruits.has("grape")); // grape가 있는지 확인
// 출력결과
// true
// false

banana는 Set 안에 있으므로 true가 나온다.
grape는 Set 안에 없으므로 false가 나온다.


has()는 Set에 어떤 값이 포함되어 있는지 확인할 때 사용한다.
배열에서 includes()로 포함 여부를 확인하는 것과 비슷한 역할로 이해하면 된다.


delete는 값을 삭제한다

delete()는 Set에서 특정 값을 삭제하는 메서드이다.
삭제에 성공하면 true, 삭제할 값이 없으면 false를 반환한다.


아래 예제는 Set에서 값을 삭제하는 코드이다.

// set-delete-basic.js
const fruits = new Set(["apple", "banana", "orange"]); // 과일 Set 생성

console.log(fruits.delete("banana")); // banana 삭제 시도
console.log([...fruits]); // 삭제 후 Set 확인

console.log(fruits.delete("grape")); // 없는 값 삭제 시도
console.log([...fruits]); // Set 상태 다시 확인
// 출력결과
// true
// ['apple', 'orange']
// false
// ['apple', 'orange']

banana는 Set 안에 있었기 때문에 삭제되고 true가 반환된다.
삭제 후에는 apple, orange만 남는다.


반면 grape는 처음부터 없던 값이다.
그래서 삭제할 수 없고 false가 반환된다.


이처럼 delete()는 값 삭제뿐 아니라 삭제가 실제로 되었는지도 알려 준다.


clear는 모든 값을 삭제한다

clear()는 Set 안의 모든 값을 한 번에 삭제하는 메서드이다.
특정 값 하나만 삭제하는 delete()와 다르게, clear()는 전체를 비운다.


아래 예제는 Set을 비우는 코드이다.

// set-clear-basic.js
const numbers = new Set([1, 2, 3, 4]); // 숫자 Set 생성

console.log(numbers.size); // 삭제 전 개수 확인

numbers.clear(); // 모든 값 삭제

console.log([...numbers]); // 비워진 Set 확인
console.log(numbers.size); // 삭제 후 개수 확인
// 출력결과
// 4
// []
// 0

처음에는 Set 안에 1, 2, 3, 4가 들어 있으므로 size는 4이다.
clear()를 실행하면 모든 값이 삭제된다.


그래서 배열로 바꿔 출력하면 빈 배열 []이 나오고, size는 0이 된다.


delete()는 특정 값 하나를 지우고, clear()는 Set 전체를 비운다.
두 메서드는 삭제 범위가 다르다.


Set은 반복해서 값을 꺼낼 수 있다

Set은 여러 값을 담고 있으므로 반복해서 값을 꺼낼 수 있다.
대표적으로 for...of를 사용할 수 있다.


for...of는 배열이나 Set처럼 여러 값을 가진 구조에서 값을 하나씩 꺼내는 반복문이다.
Set은 값이 추가된 순서를 유지하므로, 반복할 때도 추가된 순서대로 값이 나온다.


아래 예제는 Set의 값을 하나씩 출력하는 코드이다.

// set-for-of-basic.js
const fruits = new Set(["apple", "banana", "orange"]); // 과일 Set 생성

for (const fruit of fruits) { // Set의 값을 하나씩 꺼냄
    console.log(fruit); // 현재 값 출력
}
// 출력결과
// apple
// banana
// orange

for...of는 fruits 안의 값을 하나씩 꺼낸다.
첫 번째 반복에서는 "apple", 두 번째 반복에서는 "banana", 세 번째 반복에서는 "orange"가 출력된다.


이처럼 Set은 배열처럼 인덱스로 접근하지는 않지만, 반복문으로 값을 하나씩 꺼낼 수 있다.


Set에서도 forEach를 사용할 수 있다

Set은 forEach() 메서드도 사용할 수 있다.
forEach()는 여러 값을 하나씩 꺼내면서 전달한 함수를 실행하는 메서드이다.


배열의 forEach()와 비슷하게 동작하지만, Set에는 배열 인덱스가 없다.
그래서 Set에서는 현재 값 자체를 중심으로 처리한다고 이해하면 된다.


아래 예제는 Set의 값을 forEach()로 출력하는 코드이다.

// set-foreach-basic.js
const numbers = new Set([10, 20, 30]); // 숫자 Set 생성

numbers.forEach((number) => { // Set의 값을 하나씩 꺼내 실행
    console.log(number); // 현재 값 출력
});
// 출력결과
// 10
// 20
// 30

forEach()는 Set 안의 값을 순서대로 꺼낸다.
각 값은 number에 들어가고, console.log(number)가 실행된다.


Set은 중복 값을 저장하지 않기 때문에, 반복할 때도 중복 없는 값만 나온다.


Set은 배열의 중복을 제거할 때 자주 사용한다

Set은 중복 값을 허용하지 않는다.
그래서 배열에서 중복을 제거할 때 자주 사용한다.


흐름은 간단하다.
먼저 배열을 Set으로 바꾸면 중복이 제거된다.
그다음 Spread Operator로 다시 배열로 펼치면 중복 없는 배열을 만들 수 있다.


아래 예제는 숫자 배열의 중복을 제거하는 코드이다.

// set-remove-duplicates.js
const numbers = [1, 2, 2, 3, 3, 4, 5, 5]; // 중복이 있는 배열

const uniqueNumbers = [...new Set(numbers)]; // Set으로 중복 제거 후 배열로 변환

console.log(uniqueNumbers); // 중복 제거 결과 출력
// 출력결과
// [1, 2, 3, 4, 5]

new Set(numbers)를 실행하면 배열의 값들이 Set에 들어간다.
이때 중복된 2, 3, 5는 한 번씩만 저장된다.


그다음 [...new Set(numbers)]는 Set 안의 값을 펼쳐 새 배열로 만든다.
그래서 uniqueNumbers에는 중복이 제거된 [1, 2, 3, 4, 5]가 들어간다.


배열의 중복을 제거할 때는 Set으로 바꾼 뒤 다시 배열로 펼치는 방식을 사용할 수 있다.
이 흐름은 앞에서 배운 Spread Operator와 자연스럽게 연결된다.


Set에서 객체는 내용이 같아도 다른 값으로 본다

Set은 중복 값을 저장하지 않는다.
하지만 객체를 저장할 때는 주의해야 한다.


객체는 안의 내용이 같아 보여도, 서로 다른 객체로 만들어졌다면 다른 값으로 취급된다.
즉, { id: 1 }과 { id: 1 }은 모양은 같지만 서로 다른 객체이다.


아래 예제는 내용이 같은 객체 두 개를 Set에 추가하는 코드이다.

// set-object-reference.js
const user1 = { id: 1, nickname: "짱구" }; // 첫 번째 객체
const user2 = { id: 1, nickname: "짱구" }; // 내용은 같지만 새로 만든 두 번째 객체

const users = new Set(); // 빈 Set 생성

users.add(user1); // 첫 번째 객체 추가
users.add(user2); // 두 번째 객체 추가

console.log(users.size); // Set에 저장된 객체 개수 확인
// 출력결과
// 2

user1과 user2는 내용이 같다.
하지만 각각 따로 만든 객체이다.
그래서 Set은 이 둘을 같은 값으로 보지 않는다.


Set은 객체의 내부 내용을 비교해서 중복을 판단하지 않는다.
객체가 같은 참조인지, 즉 같은 객체 자체인지를 기준으로 본다.


Set은 숫자나 문자열 같은 원시값은 값 자체로 중복을 판단하지만, 객체는 같은 객체를 가리키는지로 판단한다.
이 점을 모르면 객체 배열에서 중복 제거가 예상대로 되지 않을 수 있다.


Set 핵심 정리

Set은 중복 없는 값을 저장하기 위한 데이터 구조이다.
배열처럼 여러 값을 담을 수 있지만, 배열처럼 인덱스로 접근하지 않고 같은 값을 중복 저장하지 않는다.


핵심은 아래처럼 정리할 수 있다.

  • Set은 중복 값을 허용하지 않는 데이터 묶음이다.
  • new Set()으로 Set을 만들 수 있다.
  • add()는 값을 추가한다.
  • has()는 특정 값이 있는지 확인한다.
  • delete()는 특정 값을 삭제한다.
  • clear()는 모든 값을 삭제한다.
  • size는 Set에 저장된 값의 개수를 나타낸다.
  • Set은 값이 추가된 순서를 유지한다.
  • Set은 배열처럼 인덱스로 접근하지 않는다.
  • for...of나 forEach()로 값을 반복해서 꺼낼 수 있다.
  • 배열을 Set으로 바꾸면 중복 값을 제거할 수 있다.
  • Spread Operator를 사용하면 Set을 다시 배열로 바꿀 수 있다.
  • 객체는 내용이 같아도 서로 다른 객체이면 Set에서 다른 값으로 취급된다.

Set을 이해할 때는 “여러 값을 담는다”보다 “중복을 허용하지 않는다”는 특징을 먼저 봐야 한다.
이 기준을 잡으면 배열과 Set의 차이, 중복 제거 흐름, 객체 중복 판단 기준까지 자연스럽게 이어서 이해할 수 있다.



Map

Map은 key와 value를 한 쌍으로 저장하는 데이터 구조이다.
여기서 key는 값을 찾기 위한 기준이고, value는 실제로 저장되는 값이다.


객체도 key와 value 형태로 값을 저장할 수 있다.
하지만 Map은 객체보다 더 명확하게 “값을 저장하고, key로 다시 찾는 구조”를 제공한다.
또 Map의 key에는 문자열뿐 아니라 숫자, 객체, 함수 같은 값도 사용할 수 있다.


예를 들어 회원 번호로 회원 이름을 찾거나, 상품 이름으로 가격을 찾거나, 객체 자체를 기준으로 데이터를 저장해야 할 때 Map을 사용할 수 있다.


Map의 핵심은 key와 value를 한 쌍으로 저장하고, key를 기준으로 value를 꺼내는 것이다.
배열이 여러 값을 순서대로 담는 구조라면, Map은 특정 기준으로 값을 찾아 쓰는 구조라고 이해하면 된다.


Map은 key와 value를 한 쌍으로 저장한다

Map은 new Map()으로 만든다.
처음에는 비어 있는 Map을 만들고, set()으로 값을 추가할 수 있다.


set()에는 두 값이 들어간다.
첫 번째 값은 key이고, 두 번째 값은 value이다.
즉, set(key, value) 형태로 저장한다.


아래 예제는 회원 번호를 key로 사용하고, 회원 이름을 value로 저장하는 코드이다.

// map-key-value-basic.js
const members = new Map(); // 빈 Map 생성

members.set(1, "짱구"); // key 1에 value 짱구 저장
members.set(2, "철수"); // key 2에 value 철수 저장
members.set(3, "유리"); // key 3에 value 유리 저장

console.log(members.get(1)); // key 1의 value 조회
console.log(members.get(2)); // key 2의 value 조회
console.log(members.get(3)); // key 3의 value 조회
// 출력결과
// 짱구
// 철수
// 유리

members.set(1, "짱구")는 1이라는 key에 "짱구"라는 value를 저장한다.
나중에 members.get(1)을 실행하면 key가 1인 값을 찾아 "짱구"를 반환한다.


이처럼 Map은 값을 순서대로 꺼내는 것보다, 특정 key를 기준으로 값을 찾는 데 초점이 있다.


Map은 값을 저장할 때도 key와 value를 함께 저장하고, 값을 꺼낼 때도 key를 사용한다.


set은 값을 저장하고 같은 key의 value를 바꾼다

set()은 Map에 값을 저장하는 메서드이다.
처음 사용하는 key라면 새로운 값이 추가된다.
이미 존재하는 key라면 기존 value가 새 value로 바뀐다.


즉, Map에서는 같은 key가 여러 개 존재하지 않는다.
하나의 key는 하나의 value만 가질 수 있다.


아래 예제는 같은 key에 값을 다시 저장하는 코드이다.

// map-set-overwrite.js
const scores = new Map(); // 빈 Map 생성

scores.set("짱구", 80); // 짱구 점수 저장
scores.set("철수", 90); // 철수 점수 저장

scores.set("짱구", 100); // 같은 key인 짱구의 value 변경

console.log(scores.get("짱구")); // 짱구 점수 조회
console.log(scores.get("철수")); // 철수 점수 조회
console.log(scores.size); // Map에 저장된 key 개수 확인
// 출력결과
// 100
// 90
// 2

처음에는 "짱구"라는 key에 80이 저장되었다.
그런데 다시 scores.set("짱구", 100)을 실행했기 때문에 "짱구"의 value가 100으로 바뀐다.


"짱구"라는 key가 두 개 생기는 것이 아니다.
같은 key는 하나만 유지되고, 그 key의 value가 바뀐다.
그래서 size는 3이 아니라 2이다.


Map에서 같은 key로 다시 set()을 실행하면 새 항목이 추가되는 것이 아니라 기존 value가 변경된다.


get은 key로 value를 꺼낸다

get()은 Map에서 특정 key에 연결된 value를 꺼내는 메서드이다.
get(key) 형태로 사용한다.


해당 key가 있으면 연결된 value를 반환한다.
해당 key가 없으면 undefined를 반환한다.
여기서 undefined는 값이 정의되어 있지 않다는 뜻이다.


아래 예제는 존재하는 key와 존재하지 않는 key를 조회하는 코드이다.

// map-get-basic.js
const prices = new Map(); // 빈 Map 생성

prices.set("apple", 1000); // apple 가격 저장
prices.set("banana", 1500); // banana 가격 저장

console.log(prices.get("apple")); // 존재하는 key 조회
console.log(prices.get("banana")); // 존재하는 key 조회
console.log(prices.get("orange")); // 존재하지 않는 key 조회
// 출력결과
// 1000
// 1500
// undefined

"apple"과 "banana"는 Map에 저장된 key이다.
그래서 각각 1000, 1500이 반환된다.


하지만 "orange"는 저장된 key가 아니다.
그래서 prices.get("orange")의 결과는 undefined이다.


get()은 key를 기준으로 value를 꺼내고, 없는 key를 조회하면 undefined를 반환한다.


has는 key가 있는지 확인한다

has()는 Map 안에 특정 key가 있는지 확인하는 메서드이다.
있으면 true, 없으면 false를 반환한다.


get()으로도 값을 꺼낼 수 있지만, 값이 없는지 확인할 때는 has()를 사용하는 것이 더 분명하다.
특히 value 자체가 undefined일 수도 있는 상황에서는 has()로 key 존재 여부를 확인하는 편이 안전하다.


아래 예제는 Map에 특정 key가 있는지 확인하는 코드이다.

// map-has-basic.js
const members = new Map(); // 빈 Map 생성

members.set("user1", "짱구"); // user1 저장
members.set("user2", "철수"); // user2 저장

console.log(members.has("user1")); // user1 key 존재 여부 확인
console.log(members.has("user3")); // user3 key 존재 여부 확인
// 출력결과
// true
// false

"user1"은 Map에 저장된 key이다.
그래서 members.has("user1")은 true를 반환한다.


반면 "user3"은 저장된 key가 아니다.
그래서 members.has("user3")은 false를 반환한다.


has()는 Map에서 특정 key가 존재하는지 확인할 때 사용한다.
값을 꺼내는 목적이면 get(), 존재 여부를 확인하는 목적이면 has()를 사용하면 된다.


delete는 key와 value 쌍을 삭제한다

delete()는 Map에서 특정 key에 해당하는 데이터를 삭제하는 메서드이다.
Map은 key와 value를 한 쌍으로 저장하기 때문에, key를 삭제하면 연결된 value도 함께 사라진다.


삭제에 성공하면 true를 반환한다.
삭제할 key가 없으면 false를 반환한다.


아래 예제는 Map에서 데이터를 삭제하는 코드이다.

// map-delete-basic.js
const members = new Map(); // 빈 Map 생성

members.set(1, "짱구"); // key 1 저장
members.set(2, "철수"); // key 2 저장

console.log(members.delete(1)); // key 1 삭제
console.log(members.get(1)); // 삭제된 key 조회
console.log(members.size); // 삭제 후 개수 확인

console.log(members.delete(3)); // 없는 key 삭제 시도
// 출력결과
// true
// undefined
// 1
// false

members.delete(1)은 key가 1인 데이터를 삭제한다.
삭제에 성공했기 때문에 true가 반환된다.


삭제 후 members.get(1)을 실행하면 더 이상 값이 없으므로 undefined가 나온다.
그리고 남은 데이터는 key가 2인 "철수" 하나뿐이므로 size는 1이다.


마지막의 members.delete(3)은 존재하지 않는 key를 삭제하려고 한 것이다.
삭제할 데이터가 없으므로 false가 반환된다.


clear는 Map 안의 모든 값을 삭제한다

clear()는 Map 안에 저장된 모든 key와 value 쌍을 한 번에 삭제한다.
특정 key 하나만 삭제하는 delete()와 다르게, clear()는 전체를 비운다.


아래 예제는 Map 전체를 비우는 코드이다.

// map-clear-basic.js
const members = new Map(); // 빈 Map 생성

members.set(1, "짱구"); // key 1 저장
members.set(2, "철수"); // key 2 저장
members.set(3, "유리"); // key 3 저장

console.log(members.size); // 삭제 전 개수 확인

members.clear(); // 전체 삭제

console.log(members.size); // 삭제 후 개수 확인
console.log([...members]); // 비워진 Map 확인
// 출력결과
// 3
// 0
// []

처음에는 세 개의 데이터가 저장되어 있으므로 size는 3이다.
clear()를 실행하면 모든 데이터가 삭제된다.


그래서 size는 0이 되고, Map을 배열로 펼쳐 확인하면 빈 배열 []이 나온다.


delete()는 특정 key 하나를 삭제하고, clear()는 Map 전체를 비운다.


size는 Map에 저장된 데이터 개수를 나타낸다

size는 Map에 저장된 key와 value 쌍의 개수를 나타내는 프로퍼티이다.
배열에서 길이를 확인할 때 length를 사용한다면, Map에서는 size를 사용한다.


아래 예제는 값이 추가되고 삭제될 때 size가 어떻게 바뀌는지 보여 준다.

// map-size-basic.js
const map = new Map(); // 빈 Map 생성

console.log(map.size); // 처음 개수 확인

map.set("a", 10); // a 저장
map.set("b", 20); // b 저장

console.log(map.size); // 저장 후 개수 확인

map.delete("a"); // a 삭제

console.log(map.size); // 삭제 후 개수 확인
// 출력결과
// 0
// 2
// 1

처음에는 저장된 값이 없으므로 size는 0이다.
"a", "b"를 저장하면 데이터가 두 개가 되므로 size는 2가 된다.


그다음 "a"를 삭제하면 데이터가 하나만 남기 때문에 size는 1이 된다.


Map에 저장된 데이터 개수를 확인할 때는 length가 아니라 size를 사용한다.


Map은 추가된 순서대로 반복된다

Map은 데이터를 저장한 순서를 유지한다.
그래서 반복할 때도 추가된 순서대로 key와 value가 나온다.


Map은 for...of로 반복할 수 있다.
이때 각 반복에서 [key, value] 형태의 배열이 하나씩 나온다.


아래 예제는 Map을 반복하면서 key와 value를 출력하는 코드이다.

// map-for-of-basic.js
const members = new Map(); // 빈 Map 생성

members.set(1, "짱구"); // 첫 번째 저장
members.set(2, "철수"); // 두 번째 저장
members.set(3, "유리"); // 세 번째 저장

for (const [id, nickname] of members) { // key와 value를 구조분해할당으로 꺼냄
    console.log(`${id}: ${nickname}`); // key와 value 출력
}
// 출력결과
// 1: 짱구
// 2: 철수
// 3: 유리

members에는 1, 2, 3 순서로 데이터가 저장되었다.
그래서 반복할 때도 같은 순서로 값이 나온다.


for (const [id, nickname] of members)에서 [id, nickname]은 구조분해할당이다.
Map에서 나온 [key, value] 배열을 바로 id와 nickname으로 나누어 받는 것이다.


Map은 저장된 순서를 유지하며, 반복할 때 [key, value] 형태로 값을 꺼낼 수 있다.


keys, values, entries로 원하는 부분만 꺼낼 수 있다

Map에서는 key만 꺼내거나, value만 꺼내거나, 둘을 함께 꺼낼 수 있다.
이때 사용하는 메서드가 keys(), values(), entries()이다.


각 역할은 아래와 같다.

  • keys()는 Map의 key만 꺼낸다.
  • values()는 Map의 value만 꺼낸다.
  • entries()는 key와 value 쌍을 함께 꺼낸다.

아래 예제는 세 메서드의 차이를 보여 준다.

// map-keys-values-entries.js
const members = new Map(); // 빈 Map 생성

members.set(1, "짱구"); // key 1, value 짱구
members.set(2, "철수"); // key 2, value 철수
members.set(3, "유리"); // key 3, value 유리

console.log([...members.keys()]); // key만 배열로 확인
console.log([...members.values()]); // value만 배열로 확인
console.log([...members.entries()]); // key와 value 쌍을 배열로 확인
// 출력결과
// [1, 2, 3]
// ['짱구', '철수', '유리']
// [[1, '짱구'], [2, '철수'], [3, '유리']]

keys()는 1, 2, 3처럼 key만 꺼낸다.
values()는 "짱구", "철수", "유리"처럼 value만 꺼낸다.


entries()는 [1, "짱구"], [2, "철수"], [3, "유리"]처럼 key와 value를 한 쌍으로 꺼낸다.
그리고 Map 자체를 반복하면 기본적으로 entries()와 같은 흐름으로 동작한다.


Map에서도 forEach를 사용할 수 있다

Map은 forEach() 메서드도 사용할 수 있다.
forEach()는 저장된 데이터를 하나씩 꺼내면서 전달한 함수를 실행한다.


Map의 forEach()에서 첫 번째 매개변수는 value이고, 두 번째 매개변수는 key이다.
배열의 forEach()에서는 첫 번째가 현재 요소, 두 번째가 인덱스였기 때문에 헷갈릴 수 있다.
Map에서는 value, key 순서라는 점을 주의해야 한다.


아래 예제는 Map의 forEach()로 데이터를 출력하는 코드이다.

// map-foreach-basic.js
const members = new Map(); // 빈 Map 생성

members.set(1, "짱구"); // key 1 저장
members.set(2, "철수"); // key 2 저장
members.set(3, "유리"); // key 3 저장

members.forEach((nickname, id) => { // 첫 번째는 value, 두 번째는 key
    console.log(`${id}: ${nickname}`); // key와 value 출력
});
// 출력결과
// 1: 짱구
// 2: 철수
// 3: 유리

members.forEach((nickname, id) => {})에서 nickname은 value이고, id는 key이다.
그래서 출력하면 1: 짱구, 2: 철수, 3: 유리 순서로 나온다.


Map의 forEach()는 매개변수 순서가 value, key 순서이다.
이 순서를 배열의 forEach()와 헷갈리지 않아야 한다.


Map은 객체도 key로 사용할 수 있다

Map의 큰 특징 중 하나는 객체도 key로 사용할 수 있다는 점이다.
일반 객체에서는 프로퍼티 이름이 보통 문자열이나 Symbol로 처리된다.
하지만 Map은 객체 자체를 key로 사용할 수 있다.


아래 예제는 객체를 key로 사용해서 값을 저장하는 코드이다.

// map-object-key-basic.js
const user1 = { id: 1, nickname: "짱구" }; // 첫 번째 사용자 객체
const user2 = { id: 2, nickname: "철수" }; // 두 번째 사용자 객체

const loginTimes = new Map(); // 로그인 시간 저장용 Map

loginTimes.set(user1, "09:00"); // user1 객체를 key로 사용
loginTimes.set(user2, "10:00"); // user2 객체를 key로 사용

console.log(loginTimes.get(user1)); // user1 객체로 value 조회
console.log(loginTimes.get(user2)); // user2 객체로 value 조회
// 출력결과
// 09:00
// 10:00

이 예제에서 user1과 user2는 객체이다.
Map은 이 객체들을 key로 사용하고, 각각 로그인 시간을 value로 저장한다.


나중에 loginTimes.get(user1)처럼 같은 객체를 key로 전달하면 연결된 값을 꺼낼 수 있다.


Map은 문자열뿐 아니라 객체 같은 값도 key로 사용할 수 있다.
이 점이 일반 객체와 Map을 구분하는 중요한 차이이다.


객체 key는 같은 내용을 가진 객체라도 같은 key가 아니다

Map에서 객체를 key로 사용할 때는 주의할 점이 있다.
객체는 내용이 같아 보여도, 서로 따로 만든 객체라면 다른 key로 취급된다.


즉, Map은 객체의 내부 내용을 비교해서 같은지 판단하지 않는다.
같은 객체를 가리키고 있는지를 기준으로 판단한다.


아래 예제는 내용이 같은 객체 두 개를 key로 사용하는 코드이다.

// map-object-key-reference.js
const user1 = { id: 1, nickname: "짱구" }; // 첫 번째 객체
const user2 = { id: 1, nickname: "짱구" }; // 내용은 같지만 다른 객체

const map = new Map(); // 빈 Map 생성

map.set(user1, "첫 번째 객체의 값"); // user1을 key로 저장

console.log(map.get(user1)); // 같은 객체로 조회
console.log(map.get(user2)); // 내용만 같은 다른 객체로 조회
console.log(user1 === user2); // 두 객체가 같은 객체인지 비교
// 출력결과
// 첫 번째 객체의 값
// undefined
// false

user1과 user2는 id와 nickname이 같다.
하지만 각각 따로 만든 객체이다.
그래서 user1 === user2의 결과는 false이다.


map.set(user1, "첫 번째 객체의 값")으로 저장했기 때문에 같은 객체인 user1로 조회하면 값이 나온다.
하지만 내용만 같은 user2로 조회하면 저장된 key가 아니므로 undefined가 나온다.


Map에서 객체를 key로 사용할 때는 객체의 내용이 아니라 같은 객체를 가리키는지가 기준이다.
이 점은 앞에서 본 Set의 객체 중복 판단과 같은 흐름으로 이해할 수 있다.


Object와 Map은 key 사용 방식이 다르다

Object와 Map은 둘 다 key와 value 형태로 데이터를 저장할 수 있다.
그래서 처음에는 비슷해 보인다.
하지만 key를 다루는 방식이 다르다.


Object의 프로퍼티 이름은 보통 문자열이나 Symbol이다.
숫자를 key처럼 사용해도 내부적으로는 문자열 프로퍼티 이름처럼 다루어진다.


반면 Map은 key로 다양한 값을 사용할 수 있다.
문자열, 숫자, 객체, 함수, Symbol 같은 값도 key가 될 수 있다.


아래 예제는 Object와 Map에서 숫자 key를 사용하는 차이를 보여 준다.

// map-object-key-difference.js
const objectData = {}; // 일반 객체 생성
const mapData = new Map(); // Map 생성

objectData[1] = "객체의 숫자 key"; // 객체에 숫자처럼 보이는 key 저장
mapData.set(1, "Map의 숫자 key"); // Map에 숫자 key 저장

console.log(objectData["1"]); // 객체에서는 문자열 key로 접근
console.log(mapData.get(1)); // Map에서는 숫자 key로 접근
console.log(mapData.get("1")); // 문자열 1로 조회
// 출력결과
// 객체의 숫자 key
// Map의 숫자 key
// undefined

일반 객체에서 objectData[1]로 값을 저장하면 실제로는 "1"이라는 문자열 프로퍼티 이름처럼 접근할 수 있다.
그래서 objectData["1"]로 값을 꺼낼 수 있다.


하지만 Map에서는 숫자 1과 문자열 "1"이 다른 key이다.
mapData.get(1)은 값을 찾지만, mapData.get("1")은 다른 key를 찾는 것이므로 undefined가 나온다.


Object는 프로퍼티 이름이 주로 문자열이나 Symbol로 다루어지고, Map은 다양한 값을 그대로 key로 사용할 수 있다.


Map 핵심 정리

Map은 key와 value를 한 쌍으로 저장하는 데이터 구조이다.
객체처럼 key로 값을 찾을 수 있지만, key로 사용할 수 있는 값의 범위가 더 넓다.


핵심은 아래처럼 정리할 수 있다.

  • Map은 key와 value를 한 쌍으로 저장한다.
  • new Map()으로 Map을 만들 수 있다.
  • set(key, value)는 값을 저장한다.
  • 같은 key로 다시 set()하면 기존 value가 바뀐다.
  • get(key)는 key에 연결된 value를 꺼낸다.
  • 없는 key를 get()하면 undefined가 나온다.
  • has(key)는 특정 key가 있는지 확인한다.
  • delete(key)는 특정 key와 연결된 값을 삭제한다.
  • clear()는 Map 전체를 비운다.
  • size는 저장된 key와 value 쌍의 개수이다.
  • Map은 추가된 순서를 유지하며 반복된다.
  • keys()는 key만 꺼낸다.
  • values()는 value만 꺼낸다.
  • entries()는 key와 value 쌍을 꺼낸다.
  • Map의 forEach()는 매개변수 순서가 value, key이다.
  • Map은 객체도 key로 사용할 수 있다.
  • 객체를 key로 사용할 때는 내용이 아니라 같은 객체를 가리키는지가 기준이다.
  • Map에서는 숫자 1과 문자열 "1"이 서로 다른 key이다.

Map을 이해할 때는 “순서대로 값을 담는 배열”이 아니라 “key로 value를 찾는 저장소”라고 보는 것이 중요하다.
이 기준을 잡으면 Object와의 차이, 객체 key 사용, 반복 메서드까지 자연스럽게 이해할 수 있다.

0개의 댓글