Saturday, October 18, 2025

JavaScript의 진화: ES6+가 바꾼 개발 패러다임

JavaScript는 웹의 탄생과 함께 시작된 이래로, 정적인 웹 페이지에 동적인 생명력을 불어넣는 핵심적인 역할을 수행해왔습니다. 그러나 초기의 JavaScript는 간단한 스크립팅 언어로 설계되었으며, 오늘날 우리가 마주하는 복잡한 웹 애플리케이션을 구축하기에는 많은 한계와 불편함을 안고 있었습니다. 전역 변수의 남용, 비동기 처리의 복잡성, 그리고 일관성 없는 문법 등은 개발자들에게 끊임없는 골칫거리였습니다.

이러한 상황 속에서 2015년, ECMAScript 2015, 흔히 ES6라 불리는 새로운 표준의 등장은 가히 혁명적이었습니다. ES6는 단순히 몇 가지 편의 기능을 추가하는 수준을 넘어, JavaScript의 근본적인 패러다임을 바꾸고 언어 자체를 한 단계 성숙시키는 계기가 되었습니다. 이는 마치 오랫동안 비포장도로를 달리던 개발자들에게 잘 닦인 고속도로를 열어준 것과 같았습니다. letconst의 도입으로 예측 가능한 스코프 관리가 가능해졌고, 화살표 함수는 함수형 프로그래밍 스타일을 더욱 편리하게 만들었으며, Promise와 async/await는 지옥 같았던 비동기 코드에 질서를 부여했습니다.

이후 매년 새로운 기능들이 꾸준히 추가되면서, JavaScript는 이제 프론트엔드뿐만 아니라 Node.js를 통해 백엔드, React Native나 Electron을 통해 모바일 및 데스크톱 애플리케이션 개발까지 아우르는 전천후 언어로 자리매김했습니다. 따라서 모던 JavaScript, 즉 ES6 이상의 문법과 개념을 이해하고 활용하는 것은 더 이상 선택이 아닌, 현대적인 웹 개발자가 갖추어야 할 필수 소양이 되었습니다.

이 글에서는 ES6 이후 JavaScript에 도입된 핵심적인 기능들을 깊이 있게 탐구하고, 이들이 어떻게 기존의 문제점들을 해결했으며 개발의 패러다임을 바꾸었는지 구체적인 코드 예제와 함께 상세히 분석해보고자 합니다. 단순히 문법을 나열하는 것을 넘어, 각 기능의 탄생 배경과 철학, 그리고 실제 프로젝트에서 효과적으로 활용할 수 있는 방안까지 함께 고민해볼 것입니다.

1. 스코프의 새로운 질서: `let`, `const` 그리고 블록 스코프

ES6 이전, JavaScript에서 변수를 선언하는 유일한 방법은 var 키워드를 사용하는 것이었습니다. var는 편리했지만, 다른 프로그래밍 언어에 익숙한 개발자들을 혼란스럽게 만드는 몇 가지 독특한 특징, 즉 함수 스코프(Function Scope)호이스팅(Hoisting)이라는 문제점을 가지고 있었습니다.

1.1. `var`의 시대: 혼란의 함수 스코프와 호이스팅

대부분의 C-family 언어들은 중괄호 {}로 둘러싸인 코드 블록을 기준으로 변수의 유효 범위를 결정하는 블록 스코프(Block Scope)를 따릅니다. 하지만 var로 선언된 변수는 오직 함수 단위로만 스코프를 가집니다.


function calculate(isTrue) {
  if (isTrue) {
    var price = 100;
    console.log('if 블록 내부:', price); // 100
  }
  console.log('if 블록 외부:', price); // 100
}

calculate(true);

위 예제에서 price 변수는 if 블록 안에서 선언되었지만, if 블록이 끝난 후에도 여전히 접근 가능합니다. 이는 var가 함수 스코프를 따르기 때문이며, pricecalculate 함수 전체에서 유효한 변수가 됩니다. 이러한 특징은 의도치 않은 변수 값의 변경이나 충돌을 야기할 수 있는 잠재적인 위험 요소였습니다.

호이스팅은 더욱 예측하기 어려운 문제를 만들었습니다. JavaScript 엔진은 코드를 실행하기 전, var로 선언된 변수와 함수 선언문을 해당 스코프의 최상단으로 끌어올리는 것처럼 동작합니다. 이때 변수는 선언부만 끌어올려지고, 할당은 원래 위치에서 이루어집니다.


console.log(myVar); // undefined (에러가 아님!)
var myVar = 'Hello, JavaScript!';
console.log(myVar); // 'Hello, JavaScript!'

// 위 코드는 JavaScript 엔진에 의해 아래와 같이 해석됩니다.
var myVar; // 선언이 호이스팅됨
console.log(myVar); // undefined
myVar = 'Hello, JavaScript!'; // 할당은 원래 위치에서
console.log(myVar); // 'Hello, JavaScript!'

변수를 선언하기도 전에 참조했음에도 불구하고 에러가 발생하지 않고 undefined가 출력되는 현상은 코드의 흐름을 파악하기 어렵게 만들고, 디버깅을 까다롭게 하는 주된 원인이었습니다.

1.2. `let`과 `const`의 등장: 예측 가능한 코드의 시작

ES6는 이러한 var의 문제점을 해결하기 위해 letconst라는 새로운 변수 선언 키워드를 도입했습니다. 이들의 가장 큰 특징은 우리에게 익숙한 블록 스코프를 따른다는 것입니다.


function calculate(isTrue) {
  if (isTrue) {
    let price = 100; // 블록 스코프 변수
    const tax = 10;   // 블록 스코프 상수
    console.log('if 블록 내부:', price, tax); // 100, 10
  }
  // console.log('if 블록 외부:', price, tax); // ReferenceError: price is not defined
}

calculate(true);

letconst로 선언된 변수는 자신이 선언된 {} 블록 내부와 그 하위 블록에서만 유효합니다. 따라서 if 블록 외부에서 pricetax에 접근하려고 하면 참조 에러(ReferenceError)가 발생합니다. 이는 변수의 생명주기를 명확하게 하고, 의도치 않은 값의 변경을 원천적으로 차단하여 코드의 안정성을 크게 높여줍니다.

`let`: 재할당이 가능한 변수

let은 값을 변경할 필요가 있는 변수를 선언할 때 사용됩니다.


let count = 0;
count = count + 1; // 가능
console.log(count); // 1

`const`: 재할당이 불가능한 상수 (그러나 불변은 아님)

const는 'constant'의 약자로, 한 번 할당된 값을 재할당할 수 없는 상수를 선언할 때 사용됩니다.


const PI = 3.14159;
// PI = 3.14; // TypeError: Assignment to constant variable.

여기서 매우 중요한 점은 const가 보장하는 것은 재할당의 금지이지, 값의 불변성(immutability)이 아니라는 것입니다. 만약 const로 객체나 배열과 같은 참조 타입의 값을 선언했다면, 그 객체의 프로퍼티나 배열의 요소는 변경할 수 있습니다.


const user = {
  name: '홍길동',
  age: 30
};

// user = {}; // TypeError: Assignment to constant variable. (객체 자체를 재할당하는 것은 불가능)

user.age = 31; // 가능! 객체의 프로퍼티를 변경하는 것은 허용됨
console.log(user); // { name: '홍길동', age: 31 }

const colors = ['red', 'green'];
colors.push('blue'); // 가능! 배열에 요소를 추가하는 것은 허용됨
console.log(colors); // ['red', 'green', 'blue']

이러한 특징 때문에 모던 JavaScript 개발에서는 기본적으로 모든 변수를 const로 선언하고, 재할당이 꼭 필요한 경우에만 let을 사용하는 것이 권장됩니다. 이는 개발자의 실수를 줄이고 코드의 의도를 명확하게 드러내는 좋은 습관입니다.

1.3. 심층 분석: Temporal Dead Zone (TDZ)

letconst는 호이스팅이 발생하지 않는 것처럼 보이지만, 사실은 그렇지 않습니다. var와 마찬가지로 선언이 스코프 최상단으로 끌어올려지지만, 한 가지 중요한 차이점이 있습니다. 바로 Temporal Dead Zone (TDZ, 일시적 사각지대)의 존재입니다.

TDZ는 스코프의 시작 지점부터 변수의 선언문이 있는 위치까지의 구간을 의미합니다. 이 구간에서 해당 변수에 접근하려고 하면 ReferenceError가 발생합니다.


{
  // TDZ 시작
  // console.log(myName); // ReferenceError: Cannot access 'myName' before initialization

  let myName = 'Alice'; // TDZ 종료
  console.log(myName); // 'Alice'
}

TDZ는 var의 호이스팅이 야기했던 "선언 전에 사용해도 에러 없이 undefined를 반환하는" 혼란스러운 동작을 방지합니다. 변수는 반드시 선언된 이후에만 사용해야 한다는 프로그래밍의 기본 원칙을 강제함으로써, 더욱 견고하고 예측 가능한 코드를 작성하도록 유도합니다.

2. 함수의 재정의: 화살표 함수(Arrow Functions)

화살표 함수는 ES6에서 가장 많이 사용되고 사랑받는 기능 중 하나입니다. 단순히 function 키워드를 =>로 바꾼 문법적 설탕(Syntactic Sugar)을 넘어, 기존 함수의 고질적인 문제였던 this 바인딩 문제를 우아하게 해결합니다.

2.1. 간결해진 문법

화살표 함수는 기존의 함수 표현식보다 훨씬 간결한 문법을 제공합니다.


// 기존 함수 표현식
const add_old = function(a, b) {
  return a + b;
};

// 화살표 함수
const add_new = (a, b) => {
  return a + b;
};

// 함수 본문이 한 줄의 return 문으로만 이루어져 있다면, 중괄호와 return 키워드를 생략할 수 있습니다.
const add_shorter = (a, b) => a + b;

// 매개변수가 하나라면 괄호도 생략할 수 있습니다.
const square = x => x * x;

이러한 간결함은 특히 배열의 고차 함수(map, filter, reduce 등)와 함께 사용될 때 코드의 가독성을 극적으로 향상시킵니다.


const numbers = [1, 2, 3, 4, 5];

// 기존 방식
const squares_old = numbers.map(function(n) {
  return n * n;
});

// 화살표 함수 방식
const squares_new = numbers.map(n => n * n);

console.log(squares_new); // [1, 4, 9, 16, 25]

2.2. 화살표 함수의 핵심: 렉시컬 `this`

화살표 함수의 진정한 가치는 this를 다루는 방식에 있습니다. 기존의 일반 함수는 함수가 호출되는 시점this가 동적으로 결정됩니다. 이로 인해 콜백 함수나 이벤트 핸들러 내부에서 this가 의도와 다르게 동작하는 경우가 빈번했습니다.

다음은 일반 함수의 this 바인딩 문제를 보여주는 전형적인 예시입니다.


function Counter() {
  this.count = 0;

  setInterval(function() {
    // 여기서 'this'는 Counter 인스턴스가 아닌, window 객체 (또는 strict mode에서는 undefined)를 가리킵니다.
    // 따라서 this.count는 NaN이 됩니다.
    this.count++; 
    console.log(this.count);
  }, 1000);
}

// const myCounter = new Counter(); // 예상대로 동작하지 않음

setInterval의 콜백 함수는 일반 함수로, 특정 객체의 메서드로 호출된 것이 아니기 때문에 this는 전역 객체(window)를 가리키게 됩니다. 이 문제를 해결하기 위해 과거에는 다음과 같은 방법을 사용했습니다.


// 해결책 1: that, self 등의 변수에 this를 할당
function Counter_Solution1() {
  this.count = 0;
  const self = this; // Counter 인스턴스를 self에 저장
  setInterval(function() {
    self.count++;
    console.log(self.count);
  }, 1000);
}

// 해결책 2: bind() 메서드 사용
function Counter_Solution2() {
  this.count = 0;
  setInterval(function() {
    this.count++;
    console.log(this.count);
  }.bind(this), 1000); // this를 명시적으로 바인딩
}

화살표 함수는 이러한 번거로운 과정을 완벽하게 해결합니다. 화살표 함수는 자신만의 this를 가지지 않습니다. 대신, 함수가 선언된 시점의 상위 스코프(enclosing scope)로부터 this를 그대로 물려받습니다. 이를 렉시컬 `this` (Lexical `this`)라고 합니다.


function Counter_Arrow() {
  this.count = 0;

  setInterval(() => {
    // 화살표 함수는 상위 스코프(Counter_Arrow)의 this를 그대로 사용합니다.
    // 따라서 여기서의 this는 Counter_Arrow의 인스턴스를 정확히 가리킵니다.
    this.count++;
    console.log(this.count);
  }, 1000);
}

const myArrowCounter = new Counter_Arrow(); // 1, 2, 3... 정상적으로 동작

화살표 함수 덕분에 더 이상 this를 임시 변수에 저장하거나 bind를 사용할 필요 없이, 직관적이고 일관된 방식으로 this를 다룰 수 있게 되었습니다.

2.3. 화살표 함수의 다른 특징 및 주의사항

렉시컬 this 외에도 화살표 함수는 일반 함수와 몇 가지 중요한 차이점을 가집니다.

  • `arguments` 객체가 없음: 화살표 함수는 자신만의 arguments 객체를 가지지 않습니다. 상위 스코프의 arguments를 참조합니다. 가변 인자를 다루고 싶다면 나머지 매개변수(Rest Parameters) ...args를 사용하는 것이 현대적인 방법입니다.
  • 생성자로 사용할 수 없음: 화살표 함수는 prototype 프로퍼티를 가지지 않으며, new 키워드로 호출할 수 없습니다. 생성자로 사용하려고 하면 에러가 발생합니다.
  • `yield` 키워드를 사용할 수 없음: 따라서 제너레이터(Generator) 함수로 사용할 수 없습니다.

이러한 특징들 때문에 화살표 함수를 무조건 일반 함수 대신 사용하는 것은 바람직하지 않습니다. 예를 들어, 객체의 메서드를 정의할 때 화살표 함수를 사용하면 this가 객체 자신을 가리키지 않고 상위 스코프를 가리키게 되어 의도와 다른 결과를 낳을 수 있습니다.


const person = {
  name: '이순신',
  sayHello: function() {
    console.log(`안녕하세요, ${this.name}입니다.`); // '안녕하세요, 이순신입니다.'
  },
  sayHelloArrow: () => {
    // 여기서 this는 상위 스코프인 전역 객체를 가리킵니다.
    console.log(`안녕하세요, ${this.name}입니다.`); // '안녕하세요, undefined입니다.' (또는 전역에 name이 있다면 그 값)
  }
};

person.sayHello();
person.sayHelloArrow();

결론적으로, 콜백 함수나 간결한 함수 표현이 필요할 때, 그리고 상위 스코프의 this를 그대로 사용해야 할 때 화살표 함수는 최적의 선택입니다. 반면, 객체의 메서드를 정의하거나 생성자 함수가 필요할 때는 기존의 일반 함수를 사용해야 합니다.

3. 비동기 처리의 혁명: Promise와 `async/await`

JavaScript의 가장 큰 특징 중 하나는 싱글 스레드 기반의 비동기 처리 모델입니다. 이는 한 번에 하나의 작업만 처리할 수 있지만, 시간이 오래 걸리는 작업(예: 네트워크 요청, 파일 읽기)을 기다리지 않고 다음 코드를 실행하여 애플리케이션의 응답성을 유지하는 방식입니다. 과거에는 이러한 비동기 작업을 처리하기 위해 콜백 함수를 사용했지만, 이는 극심한 가독성 저하와 에러 처리의 어려움을 동반하는 콜백 헬(Callback Hell) 문제를 낳았습니다.

3.1. 지옥의 문: 콜백 헬

사용자 정보를 가져오고, 그 정보로 게시글을 가져온 다음, 마지막으로 해당 게시글의 댓글을 가져오는 시나리오를 상상해봅시다. 콜백 기반으로 작성하면 코드는 다음과 같은 형태가 됩니다.


getUser(1, function(user) {
  console.log('사용자 정보:', user);
  getPosts(user.id, function(posts) {
    console.log('게시글 목록:', posts);
    getComments(posts[0].id, function(comments) {
      console.log('첫 번째 게시글의 댓글:', comments);
      // 또 다른 비동기 작업이 있다면...
        // 코드는 점점 더 깊어집니다...
    }, function(error) {
      console.error('댓글 로딩 실패:', error);
    });
  }, function(error) {
    console.error('게시글 로딩 실패:', error);
  });
}, function(error) {
  console.error('사용자 정보 로딩 실패:', error);
});

이처럼 콜백 함수가 계속해서 중첩되는 구조는 "파멸의 피라미드(Pyramid of Doom)"라고도 불립니다. 이러한 코드는 다음과 같은 심각한 문제점을 가집니다.

  • 가독성 저하: 코드의 실행 흐름이 들여쓰기 안으로 계속 파고들어 전체적인 로직을 이해하기 어렵습니다.
  • 에러 처리의 복잡성: 각 비동기 단계마다 별도의 에러 처리 콜백을 구현해야 하며, 에러가 발생했을 때 전체 로직을 중단하거나 복구하는 것이 매우 까다롭습니다.
  • 제어 흐름의 어려움: 조건부 분기나 반복과 같은 일반적인 제어 흐름을 비동기 코드에 적용하기 어렵습니다.

3.2. 구원자, `Promise`의 등장

ES6에 도입된 Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다. 콜백을 직접 전달하는 대신, 비동기 작업의 결과를 담고 있는 '약속' 객체를 반환하는 방식입니다. Promise는 세 가지 상태를 가집니다.

  • 대기(Pending): 비동기 작업이 아직 완료되지 않은 초기 상태.
  • 이행(Fulfilled): 비동기 작업이 성공적으로 완료된 상태. resolve 함수가 호출됩니다.
  • 거부(Rejected): 비동기 작업이 실패한 상태. reject 함수가 호출됩니다.

Promise를 사용하면 콜백 헬 예제를 다음과 같이 개선할 수 있습니다.


// 각 비동기 함수가 Promise를 반환한다고 가정
getUser(1)
  .then(user => {
    console.log('사용자 정보:', user);
    return getPosts(user.id); // 다음 then으로 결과를 전달하기 위해 Promise를 반환
  })
  .then(posts => {
    console.log('게시글 목록:', posts);
    return getComments(posts[0].id);
  })
  .then(comments => {
    console.log('첫 번째 게시글의 댓글:', comments);
  })
  .catch(error => {
    // 모든 단계에서 발생한 에러를 한 곳에서 처리할 수 있습니다.
    console.error('오류 발생:', error);
  })
  .finally(() => {
    // 성공/실패 여부와 관계없이 항상 실행됩니다. (예: 로딩 스피너 숨기기)
    console.log('모든 작업 완료');
  });

.then() 메서드를 이용한 체이닝(Chaining)을 통해 들여쓰기 구조가 사라지고, 코드의 실행 흐름이 위에서 아래로 명확하게 이어집니다. 또한, .catch()를 통해 체인 전체에서 발생하는 모든 에러를 한 곳에서 일관되게 처리할 수 있게 되어 에러 핸들링이 매우 간결해졌습니다.

`Promise` 정적 메서드 활용하기

Promise는 여러 개의 비동기 작업을 동시에 처리하는 데 유용한 정적 메서드들을 제공합니다.

  • `Promise.all(promises)`: 배열로 받은 모든 프로미스가 이행될 때까지 기다렸다가, 모든 결과를 담은 배열을 반환합니다. 중간에 하나라도 거부되면 즉시 거부 상태가 됩니다. 여러 API를 동시에 호출하고 모든 데이터가 준비되었을 때 화면을 렌더링하는 경우에 유용합니다.
  • `Promise.race(promises)`: 배열로 받은 프로미스 중 가장 먼저 완료(이행 또는 거부)되는 것의 결과를 그대로 반환합니다.
  • `Promise.allSettled(promises)`: (ES2020) 배열로 받은 모든 프로미스가 완료(이행 또는 거부)될 때까지 기다렸다가, 각 프로미스의 상태(status)와 결과(value 또는 reason)를 담은 객체 배열을 반환합니다. Promise.all과 달리 일부가 실패하더라도 모든 결과를 확인할 수 있습니다.

const promise1 = Promise.resolve('첫 번째 성공');
const promise2 = new Promise((resolve, reject) => setTimeout(resolve, 100, '두 번째 성공'));
const promise3 = Promise.reject('세 번째 실패');

// Promise.all: 하나라도 실패하면 전체가 실패
Promise.all([promise1, promise2, promise3])
  .then(values => console.log('all 성공:', values))
  .catch(error => console.error('all 실패:', error)); // 'all 실패: 세 번째 실패' 출력

// Promise.allSettled: 성공/실패 여부와 관계없이 모든 결과를 확인
Promise.allSettled([promise1, promise2, promise3])
  .then(results => console.log('allSettled 결과:', results));
/*
allSettled 결과: [
  { status: 'fulfilled', value: '첫 번째 성공' },
  { status: 'fulfilled', value: '두 번째 성공' },
  { status: 'rejected', reason: '세 번째 실패' }
]
*/

3.3. 궁극의 가독성: `async/await`

ES2017(ES8)에 도입된 async/awaitPromise를 기반으로 동작하는 문법적 설탕입니다. 비동기 코드를 마치 동기 코드처럼 보이게 만들어, 가독성과 유지보수성을 한 차원 더 끌어올렸습니다.

  • `async` 함수: 함수 선언 앞에 async 키워드를 붙이면, 해당 함수는 항상 Promise를 반환합니다. 함수 내부에서 값을 반환하면 그 값으로 이행되는 Promise가, 예외를 던지면 그 예외로 거부되는 Promise가 반환됩니다.
  • `await` 연산자: async 함수 내에서만 사용할 수 있으며, Promise가 완료될 때까지 함수의 실행을 일시 중지하고 기다립니다. Promise가 이행되면 그 결과값을, 거부되면 예외를 던집니다.

Promise 체이닝으로 작성했던 코드를 async/await로 다시 작성해 보겠습니다.


async function fetchAllData() {
  try {
    const user = await getUser(1);
    console.log('사용자 정보:', user);

    const posts = await getPosts(user.id);
    console.log('게시글 목록:', posts);

    const comments = await getComments(posts[0].id);
    console.log('첫 번째 게시글의 댓글:', comments);

  } catch (error) {
    // 동기 코드와 동일한 try...catch 구문으로 에러를 처리할 수 있습니다.
    console.error('오류 발생:', error);
  } finally {
    console.log('모든 작업 완료');
  }
}

fetchAllData();

코드가 .then()의 중첩 없이, 위에서 아래로 순차적으로 실행되는 동기 코드처럼 보입니다. 에러 처리 또한 익숙한 try...catch 구문을 그대로 사용할 수 있어 훨씬 직관적입니다.

`async/await`와 병렬 처리

async/await를 사용할 때 흔히 저지르는 실수 중 하나는 여러 비동기 작업을 불필요하게 순차적으로 처리하는 것입니다.


// 비효율적인 순차 처리
async function getPostData_slow() {
  const post = await getPost(1); // 1초 소요
  const comments = await getCommentsForPost(1); // 1초 소요
  return { post, comments }; // 총 2초 이상 소요
}

위 코드에서 게시글을 가져오는 작업과 댓글을 가져오는 작업은 서로 의존성이 없으므로 동시에 처리할 수 있습니다. 이럴 때 Promise.all을 함께 사용하면 성능을 향상시킬 수 있습니다.


// 효율적인 병렬 처리
async function getPostData_fast() {
  const [post, comments] = await Promise.all([
    getPost(1),
    getCommentsForPost(1)
  ]); // 두 작업이 동시에 시작되어 약 1초 소요

  return { post, comments };
}

Promise.allawait과 함께 사용함으로써, 비동기 작업의 병렬 처리를 `async/await`의 간결한 문법 안에서 효과적으로 구현할 수 있습니다.

결론적으로, Promise는 비동기 처리의 기반을 다지는 중요한 개념이며, async/await는 이를 가장 인간 친화적인 방식으로 사용할 수 있게 해주는 강력한 도구입니다. 현대 JavaScript에서 비동기 코드를 작성할 때는 async/await를 기본으로 사용하되, 그 근간에 Promise가 있다는 사실을 항상 인지하고 필요에 따라 Promise.all과 같은 메서드를 적극적으로 활용하는 것이 바람직합니다.

4. 코드의 유연성을 더하는 문법들

ES6+는 앞서 다룬 핵심적인 변화 외에도, 코드를 더 간결하고 유연하며 가독성 높게 만들어주는 다양한 문법적 개선을 포함하고 있습니다.

4.1. 템플릿 리터럴 (Template Literals)

기존의 문자열 처리는 + 연산자를 이용한 결합과 이스케이프 시퀀스(\n)의 사용으로 인해 번거롭고 가독성이 떨어졌습니다. 템플릿 리터럴은 이러한 문제점을 해결합니다.

  • 백틱(`) 사용: 작은따옴표(')나 큰따옴표(") 대신 백틱(`)으로 문자열을 감쌉니다.
  • 표현식 삽입(Expression Interpolation): ${...} 구문을 사용하여 문자열 내부에 변수나 표현식의 결과를 쉽게 삽입할 수 있습니다.
  • 멀티라인 문자열: 별도의 처리 없이 여러 줄에 걸쳐 문자열을 작성할 수 있습니다.

const name = '홍길동';
const age = 30;

// 기존 방식
const message_old = '안녕하세요, 제 이름은 ' + name + '이고,\n나이는 ' + age + '살입니다.';

// 템플릿 리터럴 방식
const message_new = `안녕하세요, 제 이름은 ${name}이고,
나이는 ${age}살입니다. 내년엔 ${age + 1}살이 되겠네요.`;

console.log(message_new);
/*
안녕하세요, 제 이름은 홍길동이고,
나이는 30살입니다. 내년엔 31살이 되겠네요.
*/

4.2. 구조 분해 할당 (Destructuring Assignment)

구조 분해 할당은 객체나 배열의 속성을 추출하여 개별 변수에 쉽게 할당할 수 있게 해주는 강력한 기능입니다. 코드의 양을 줄이고 데이터의 구조를 명확하게 보여줍니다.

객체 구조 분해


const user = {
  id: 1,
  username: 'gildong',
  email: 'gildong@example.com',
  profile: {
    avatar: '/path/to/avatar.jpg'
  }
};

// 기본 사용
const { username, email } = user;
console.log(username); // 'gildong'
console.log(email); // 'gildong@example.com'

// 변수 이름 변경 및 기본값 할당
const { username: nickname, country = 'Korea' } = user;
console.log(nickname); // 'gildong'
console.log(country); // 'Korea'

// 중첩된 객체 구조 분해
const { profile: { avatar } } = user;
console.log(avatar); // '/path/to/avatar.jpg'

// 함수의 매개변수로 활용
function printUser({ username, email }) {
  console.log(`사용자명: ${username}, 이메일: ${email}`);
}
printUser(user);

배열 구조 분해


const colors = ['red', 'green', 'blue', 'yellow'];

// 기본 사용
const [firstColor, secondColor] = colors;
console.log(firstColor); // 'red'
console.log(secondColor); // 'green'

// 일부 요소 건너뛰기
const [, , thirdColor] = colors;
console.log(thirdColor); // 'blue'

// 나머지 요소 할당 (Rest syntax)
const [mainColor, ...subColors] = colors;
console.log(mainColor); // 'red'
console.log(subColors); // ['green', 'blue', 'yellow']

4.3. 전개 구문 (Spread Syntax)과 나머지 매개변수 (Rest Parameters)

점 세 개(...)를 사용하는 이 구문은 문맥에 따라 두 가지 다른 의미로 사용되지만, 모두 '모으거나 펼치는' 역할을 합니다.

전개 구문 (Spread Syntax)

배열이나 객체와 같은 이터러블(iterable)을 그 요소들로 '펼쳐'줍니다.


// 배열 합치기
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combinedArray = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6]

// 배열 복사 (얕은 복사)
const originalArray = ['a', 'b'];
const copiedArray = [...originalArray];
copiedArray.push('c');
console.log(originalArray); // ['a', 'b'] (원본은 변경되지 않음)

// 객체 병합 (동일한 키는 뒤의 값으로 덮어씀)
const obj1 = { x: 1, y: 2 };
const obj2 = { y: 3, z: 4 };
const mergedObject = { ...obj1, ...obj2 }; // { x: 1, y: 3, z: 4 }

나머지 매개변수 (Rest Parameters)

함수 정의 시, 정해지지 않은 수의 인자들을 배열로 '모아'줍니다. arguments 객체를 대체하는 현대적인 방법입니다.


function sum(...numbers) {
  // numbers는 모든 인자를 담은 배열
  return numbers.reduce((total, current) => total + current, 0);
}

console.log(sum(1, 2, 3)); // 6
console.log(sum(10, 20, 30, 40)); // 100

4.4. 최신 기능: 옵셔널 체이닝과 null 병합 연산자

ES2020에서는 중첩된 객체의 프로퍼티에 접근할 때 발생할 수 있는 TypeError를 방지하고, null 또는 undefined 값을 더 안전하게 처리하기 위한 두 가지 유용한 연산자가 추가되었습니다.

옵셔널 체이닝 (Optional Chaining) `?.`

체인의 각 참조가 유효한지 명시적으로 검증하지 않고, 연결된 객체 체인 내의 깊숙한 곳에 위치한 속성 값을 읽을 수 있게 해줍니다. 참조가 null 또는 undefined이면 에러를 발생시키는 대신 undefined를 반환합니다.


const user = {
  name: '김유신',
  // address 프로퍼티가 없음
};

// 기존 방식
const city_old = user && user.address && user.address.city; // undefined

// 옵셔널 체이닝
const city_new = user?.address?.city; // undefined (에러 없음)

console.log(city_new);

Null 병합 연산자 (Nullish Coalescing Operator) `??`

왼쪽 피연산자가 null 또는 undefined일 때만 오른쪽 피연산자를 반환하고, 그 외의 경우에는 왼쪽 피연산자를 그대로 반환합니다. 이는 0이나 빈 문자열('')과 같은 falsy 값을 유효한 값으로 처리하고 싶을 때 논리적 OR(||) 연산자보다 유용합니다.


let volume; // undefined

// || 연산자는 0을 falsy로 취급하여 기본값을 할당
const setting1 = 0 || 50; // 50

// ?? 연산자는 0을 유효한 값으로 취급
const setting2 = 0 ?? 50; // 0

const setting3 = volume ?? 100; // 100 (volume이 null 또는 undefined이므로)
console.log(setting2, setting3); // 0, 100

5. 코드의 재사용과 구조화: 모듈 시스템

ES6 이전의 JavaScript에는 공식적인 모듈 시스템이 없었습니다. 이로 인해 개발자들은 IIFE(즉시 실행 함수 표현식), CommonJS(Node.js), AMD(RequireJS) 등 다양한 패턴과 라이브러리를 사용하여 코드의 스코프를 격리하고 재사용성을 높이려 노력해야 했습니다. ES6에서는 마침내 언어 자체에 importexport 키워드를 기반으로 한 공식 모듈 시스템을 도입했습니다.

ES6 모듈은 다음과 같은 장점을 가집니다.

  • 명시적인 의존성: 파일 상단에 import 구문을 통해 해당 파일이 어떤 모듈에 의존하는지 명확하게 알 수 있습니다.
  • 스코프 격리: 각 모듈 파일은 자체적인 독립된 스코프를 가집니다. 모듈 내에서 선언된 변수, 함수, 클래스는 기본적으로 해당 모듈 외부에서 접근할 수 없습니다.
  • 재사용성: export를 통해 외부에서 사용할 기능을 명시적으로 노출시킬 수 있으며, 다른 파일에서 import하여 재사용할 수 있습니다.

`export`: 모듈 기능 내보내기

이름으로 내보내기 (Named Exports)

여러 개의 변수, 함수, 클래스를 각각의 이름으로 내보낼 수 있습니다.


// 📁 lib/math.js
export const PI = 3.14;

export function sum(a, b) {
  return a + b;
}

export class Circle {
  constructor(radius) {
    this.radius = radius;
  }
}

기본으로 내보내기 (Default Export)

모듈당 단 하나만 존재할 수 있으며, 해당 모듈을 대표하는 값을 내보낼 때 사용합니다.


// 📁 lib/logger.js
export default function log(message) {
  console.log(message);
}

`import`: 모듈 기능 가져오기

이름으로 내보낸 기능 가져오기

중괄호 {} 안에 가져올 기능의 이름을 명시합니다. as 키워드를 사용하여 이름을 바꿔서 가져올 수도 있습니다.


// 📁 main.js
import { PI, sum as add, Circle } from './lib/math.js';

console.log(PI); // 3.14
console.log(add(2, 3)); // 5
const c = new Circle(10);

기본으로 내보낸 기능 가져오기

중괄호 없이 원하는 이름으로 가져올 수 있습니다.


// 📁 main.js
import myLogger from './lib/logger.js';

myLogger('모듈 시스템 동작 중!');

브라우저에서 ES6 모듈을 사용하려면 <script> 태그에 type="module" 속성을 추가해야 합니다.


<!-- index.html -->
<script type="module" src="main.js"></script>

이러한 모듈 시스템의 도입으로 JavaScript 프로젝트는 비로소 체계적인 구조를 갖출 수 있게 되었으며, 대규모 애플리케이션 개발에 필수적인 코드의 분리, 관리, 재사용이 훨씬 용이해졌습니다.

마치며

ES6를 기점으로 시작된 JavaScript의 발전은 과거의 불편하고 혼란스러웠던 언어에 새로운 질서와 강력한 기능들을 부여했습니다. 블록 스코프를 제공하는 letconst는 변수 관리의 안정성을 높였고, 렉시컬 this를 가진 화살표 함수는 콜백 함수의 작성을 단순화했으며, Promiseasync/await는 비동기 프로그래밍의 패러다임을 완전히 바꾸어 놓았습니다.

뿐만 아니라 템플릿 리터럴, 구조 분해 할당, 전개 구문, 그리고 공식 모듈 시스템에 이르기까지, 이 모든 기능들은 개발자가 더 적은 코드로 더 명확한 의도를 표현하고, 더 견고하며 유지보수하기 쉬운 애플리케이션을 구축할 수 있도록 돕는 강력한 도구입니다.

ECMAScript 표준은 지금 이 순간에도 계속해서 진화하고 있습니다. 매년 새로운 제안들이 논의되고 표준에 추가되고 있습니다. 따라서 모던 JavaScript 개발자에게 가장 중요한 덕목 중 하나는 현재의 표준을 깊이 이해하고 활용하는 능력과 더불어, 앞으로의 변화에 지속적으로 관심을 갖고 새로운 지식을 습득하려는 자세일 것입니다. 이 글에서 다룬 내용들이 여러분의 JavaScript 여정에 튼튼한 발판이 되기를 바랍니다.


0 개의 댓글:

Post a Comment