ES2016 이후 모던 JavaScript 완벽 가이드 한번에 끝내기

 

ES2016 이후 모던 JavaScript 완벽 가이드 한번에 끝내기

커피 한 잔과 함께 코드를 짜다가 문득 이런 생각 해보신 적 없으신가요?

'어라… 내 자바스크립트 코드, 너무 옛날 스타일 아닌가?' 하는 생각 말입니다.

ECMAScript 6, 우리가 흔히 ES6라고 부르는 버전이 나온 게 벌써 2015년인데요.

Promise, 클래스, 화살표 함수 같은 강력한 기능들이 대거 추가되면서 거의 10년간 정체되어 있던 JavaScript 생태계에 새로운 숨을 불어넣은 역사적인 순간이었습니다.

지금 당장 TypeScript 프로젝트의 tsconfig.json 파일을 열어보면 아마 높은 확률로 컴파일 타겟이 ES6ES2015로 되어 있을 거거든요.

그만큼 ES6는 하나의 시대적 기준이 되었습니다.

그런데 중요한 사실이 있는데요.

ES6를 기점으로 JavaScript는 매년 새로운 버전이 꾸준히 출시되고 있다는 점입니다.

ES2016, ES2017, … 이렇게 계속 발전해서 지금은 ES2025 명세까지 나와 있거든요.

하지만 JavaScript에는 축복이자 저주인 '하위 호환성'이라는 대원칙이 있습니다.

이 원칙 덕분에 오래된 코드도 깨지지 않고 잘 돌아가지만, 바로 그 이유 때문에 '일단 돌아가니까'라며 낡은 코드 스타일이 그대로 방치되기 쉽거든요.

더 편하고 좋은 기능이 있는데도 그걸 몰라서 예전 방식으로 힘들게 코딩하고 있는 상황, 다들 한 번쯤은 겪어보셨을 겁니다.

예를 들면 이런 거죠.

// 예전 방식 (물론 지금도 잘 돌아갑니다)
var lastItem = array[array.length - 1];

// 훨씬 세련된 최신 방식 (ES2022+)
const lastItem = array.at(-1);

 

이 글에서는 ES2016 이후에 추가된, 그중에서도 '이건 정말 내일부터 당장 써먹을 수 있겠다' 싶은 유용한 기능들만 엄선해서 소개해 드리려고 하는데요.

이번 기회에 최신 JavaScript 지식을 싹 업데이트해서, 앞으로의 개발을 한결 더 즐겁고 편하게 만들어보는 건 어떨까요?

더 똑똑해진 언어 기본 기능

globalThis (ES2020)

JavaScript 코드를 짜다 보면 현재 실행 환경의 '전역 객체'에 접근해야 할 때가 있는데요.

이게 참 골치 아프게도 환경마다 이름이 전부 달랐습니다.

브라우저에서는 window, Node.js에서는 global, 웹 워커에서는 self였거든요.

그래서 예전에는 지금 코드가 어디서 도는지 확인하는 분기문을 매번 작성해야만 했습니다.

// Before - 환경마다 다른 전역 객체를 찾기 위한 눈물겨운 코드
const globalObj = (() => {
  if (typeof window !== 'undefined') return window;
  if (typeof global !== 'undefined') return global;
  if (typeof self !== 'undefined') return self;
  throw new Error('전역 객체를 찾을 수 없습니다!');
})();

// After - 이제 globalThis 하나면 끝납니다!
const globalObj = globalThis;

 

globalThis는 어떤 환경에서든 동일한 이름으로 전역 객체에 접근할 수 있도록 표준화된 기능인데요.

이제 더 이상 지저분한 환경 판별 코드는 필요 없게 된 아주 반가운 소식입니다.

객체와 함수를 위한 후행 쉼표 (ES2017)

배열이나 객체 리터럴의 마지막 항목 뒤에 쉼표를 붙여도 에러가 나지 않는 '후행 쉼표(Trailing Comma)'는 사실 꽤 오래전부터 있던 기능인데요.

ES2017부터는 함수의 매개변수 목록에서도 이 후행 쉼표를 사용할 수 있게 되었습니다.

const member = {
  name: '홍길동',
  role: 'developer', // ← 객체에서의 후행 쉼표
};

function findUser(
  name,
  role, // ← 함수 매개변수에서의 후행 쉼표
) {
  // ...
}

이게 별거 아닌 것 같아도 정말 실용적이거든요.

항목을 추가하거나 순서를 바꿀 때, 마지막 줄에 쉼표를 붙였다 뗐다 하는 귀찮은 일을 할 필요가 없어집니다.

게다가 Git으로 버전을 관리할 때도 엄청난 이점이 있는데요.

항목 하나만 추가했을 뿐인데 엉뚱한 줄까지 변경 내역(diff)에 잡히는 걸 막아주기 때문입니다.

// 후행 쉼표가 없을 때의 diff (두 줄이 변경됨)
  findUser(
    '홍길동',
-   'developer'
+   'developer',
+   'Seoul'
  );

// 후행 쉼표가 있을 때의 diff (추가된 한 줄만 깔끔하게 보임)
  findUser(
    '홍길동',
    'developer',
+   'Seoul',
  );

다만 Prettier 같은 코드 포맷터가 이 쉼표를 자동으로 제거해 버릴 수 있거든요.

설정에서 trailingComma: "all" 옵션을 켜두시면 이 편리함을 온전히 누릴 수 있습니다.

Optional Catch Binding (ES2019)

try...catch 문을 쓰다 보면 에러 객체의 내용은 딱히 필요 없고, 그냥 에러가 났다는 사실 자체가 중요할 때가 있거든요.

예전에는 이럴 때도 catch (e)처럼 쓰지도 않을 변수를 억지로 선언해야만 했습니다.

이제 ES2019부터는 이 변수를 깔끔하게 생략할 수 있게 되었는데요.

코드가 한결 간결해지는 아주 반가운 변화입니다.

// Before - 쓰지도 않을 'e'를 선언해야만 했습니다.
try {
  doSomethingRisky();
} catch (e) {
  console.log('에러가 발생했지만 괜찮아요. 복구합니다.');
  rollback();
}

// After - 필요 없다면 과감하게 생략!
try {
  doSomethingRisky();
} catch { // 정말 깔끔하죠?
  console.log('에러가 발생했지만 괜찮아요. 복구합니다.');
  rollback();
}

물론 에러의 세부 정보를 무시하는 것이기 때문에, 정말로 에러 내용을 확인할 필요가 없는 경우에만 신중하게 사용해야 한다는 점은 기억해 주세요.

객체를 위한 Rest와 Spread (ES2018)

배열에서 정말 유용하게 쓰이던 Rest/Spread(...) 문법을 ES2018부터는 객체에서도 사용할 수 있게 되었는데요.

특히 React 개발자라면 아마 매일같이 쓰고 계실 겁니다.

// Spread: 객체의 속성을 펼쳐서 복사하거나 확장합니다.
const user = { name: '홍길동', age: 30 };
const updatedUser = { ...user, age: 31 }; // { name: '홍길동', age: 31 }

// Rest: 특정 속성을 제외한 나머지를 새로운 객체로 모읍니다.
const { password, ...publicUserData } = userData;
// 이제 publicUserData에는 password가 제외되어 안전합니다.

// 두 객체를 병합할 때도 유용합니다.
const defaults = { theme: 'light', fontSize: 14 };
const userPrefs = { fontSize: 16, theme: 'dark' };
const finalSettings = { ...defaults, ...userPrefs }; // { fontSize: 16, theme: 'dark' }

문법 모양이 똑같아서 헷갈릴 수 있는데요.

등호(=)를 기준으로 '왼쪽에 있으면 나머지를 모으는 Rest, 오른쪽에 있으면 펼치는 Spread'라고 기억하시면 쉽습니다.

Null 병합 연산자 ?? (ES2020)

?? 연산자는 왼쪽에 있는 값이 null 또는 undefined일 경우에만 오른쪽 값을 반환하는 아주 스마트한 연산자인데요.

기존의 || (OR) 연산자가 가진 오랜 문제를 해결해 줍니다.

|| 연산자는 false, 0, ''(빈 문자열)처럼 'falsy'한 값들을 전부 걸러냈거든요.

그래서 0이나 빈 문자열을 유효한 값으로 쓰고 싶어도 의도치 않게 기본값으로 대체되는 문제가 있었습니다.

// || 연산자와 ?? 연산자의 결정적 차이
const count1 = 0 || 10;        // 10 (0이 falsy라서)
const count2 = 0 ?? 10;        // 0 (0은 nullish가 아니므로)

const name1 = '' || '익명';   // '익명' (빈 문자열이 falsy라서)
const name2 = '' ?? '익명';   // '' (빈 문자열은 nullish가 아니므로)

?? 연산자는 오직 nullundefined, 즉 'nullish'한 값만 판단하기 때문에 이런 버그를 원천적으로 막을 수 있습니다.

옵셔널 체이닝 ?. (ES2020)

JavaScript 개발자라면 누구나 한 번쯤은 겪어봤을 끔찍한 에러가 있거든요.

바로 Cannot read properties of undefined 입니다.

객체의 중첩된 속성에 접근하려는데, 중간 어디선가 값이 undefined라서 터지는 경우죠.

옵셔널 체이닝(?.)은 바로 이 에러를 막아주는 구세주 같은 기능인데요.

. 대신 ?.를 사용하면, 만약 접근하려는 속성이 null이나 undefined일 경우 에러를 내는 대신 그냥 undefined를 반환하고 안전하게 멈춥니다.

// Before - 언제 터질지 모르는 불안한 코드
const city = user.address.city; // user나 user.address가 없으면 바로 에러!

// After - ?. 로 안전하게 접근
const city = user?.address?.city; // 중간에 값이 없어도 에러 없이 undefined가 반환됩니다.

?.는 바로 앞에서 배운 ?? 연산자와 정말 환상의 궁합을 자랑하는데요.

'안전하게 값에 접근해보고, 만약 없다면 기본값을 사용해라'는 로직을 한 줄로 우아하게 표현할 수 있습니다.

// 안전하게 도시 이름을 가져오고, 없다면 '정보 없음'을 출력
const city = user?.address?.city ?? '정보 없음';
console.log(city);

이제 더 이상 if (user && user.address && user.address.city) 같은 코드를 길게 쓸 필요가 없습니다.

한 단계 진화한 클래스 문법

진짜 비공개 멤버를 만드는 # (ES2022)

그동안 JavaScript 클래스에는 진짜 '비공개(private)' 필드라는 개념이 없었거든요.

보통 밑줄(_)을 붙여서 '이건 건드리지 마세요'라는 약속을 할 뿐, 사실 외부에서 얼마든지 접근하고 수정할 수 있었습니다.

ES2022부터는 필드 이름 앞에 #을 붙여서 외부에서는 절대 접근할 수 없는 '진짜' 비공개 멤버를 만들 수 있게 되었는데요.
이제서야 진정한 캡슐화가 가능해진 겁니다.

class BankAccount {
  #balance = 0;  // 외부에서 절대 접근 불가한 비공개 필드

  constructor(initialBalance) {
    this.#balance = initialBalance;
  }

  // #가 붙은 메서드 역시 비공개
  #logTransaction(amount) {
    console.log(`거래액: ${amount}, 잔액: ${this.#balance}`);
  }

  deposit(amount) {
    this.#balance += amount;
    this.#logTransaction(amount);
  }
}

const account = new BankAccount(10000);
account.deposit(5000);

// console.log(account.#balance); // SyntaxError! 외부에서 접근하면 문법 에러가 납니다.

TypeScript의 private와는 무엇이 다른가

TypeScript를 쓰시는 분들은 '그냥 private 키워드랑 같은 거 아냐?'라고 생각하실 수 있는데요.

아주 결정적인 차이가 있습니다.

TypeScript의 private는 컴파일 시점에만 오류를 체크할 뿐, JavaScript로 변환되고 나면 그냥 일반적인 속성이 되어버리거든요.

하지만 #은 런타임에서도 완벽하게 비공개를 보장합니다.

즉, JavaScript 엔진 레벨에서 접근을 막아버리기 때문에 훨씬 더 강력하고 안전하다고 할 수 있습니다.

없으면 서운한 필수 유틸리티 메서드

객체 다루기

Object.values()Object.entries() (ES2017)는 객체를 배열처럼 다룰 수 있게 해주는 정말 유용한 메서드인데요.

여기에 Object.fromEntries() (ES2019)가 더해지면서 객체를 변환하는 작업이 훨씬 쉬워졌습니다.

const scores = { math: 90, english: 85, science: 92 };

// 값들만 배열로 뽑아내기
Object.values(scores); // [90, 85, 92]

// [키, 값] 쌍의 배열로 만들기
Object.entries(scores); // [['math', 90], ['english', 85], ['science', 92]]

// entries로 만든 배열을 다시 객체로 되돌리기 (fromEntries)
const newObject = Object.fromEntries(
  Object.entries(scores).map(([subject, score]) => [subject, score + 5])
);
// newObject = { math: 95, english: 90, science: 97 }

그리고 Object.hasOwn() (ES2022)도 꼭 알아둬야 하는데요.

기존의 hasOwnProperty는 특정 상황에서 오작동할 위험이 있었지만, Object.hasOwn()은 그런 문제점들을 모두 해결한 훨씬 안전하고 신뢰할 수 있는 방법입니다.

배열 다루기

배열에도 정말 편리한 기능들이 많이 추가되었는데요.

includes() (ES2016)는 indexOf보다 훨씬 직관적으로 요소의 존재 여부를 확인할 수 있습니다.

flat()flatMap() (ES2019)은 중첩된 배열을 평탄하게 펴주거나, map을 실행한 뒤 바로 평탄화하는 작업을 한 번에 처리해 주거든요.

여러 API에서 받은 데이터를 하나로 합칠 때 정말 유용합니다.

const nested = [1, [2, 3], [4, [5]]];
nested.flat(2); // [1, 2, 3, 4, 5] (2단계 깊이까지 평탄화)

const sentences = ['Hello World', 'Modern JS'];
sentences.flatMap(s => s.split(' ')); // ['Hello', 'World', 'Modern', 'JS']

at() (ES2022)는 드디어 JavaScript에서도 음수 인덱스를 사용할 수 있게 해준 고마운 기능인데요.

array[array.length - 1] 같은 복잡한 코드 대신 array.at(-1)로 마지막 요소를 깔끔하게 가져올 수 있습니다.

불변성을 위한 새로운 배열 메서드 (ES2023)

React나 Vue처럼 '불변성(Immutability)'을 중요하게 여기는 환경에서 개발하다 보면 항상 고민되는 지점이 있었거든요.

sort()splice() 같은 메서드들이 원본 배열을 직접 수정해 버린다는 점이었습니다.

ES2023에서는 이런 문제를 해결하기 위해 원본은 그대로 두고 '새로운 배열'을 반환하는 착한 버전의 메서드들이 대거 추가되었는데요.

toSorted(), toReversed(), toSpliced()가 바로 그 주인공들입니다.

const numbers = [3, 1, 2];
const sortedNumbers = numbers.toSorted();

console.log(sortedNumbers); // [1, 2, 3] (새로운 정렬된 배열)
console.log(numbers);       // [3, 1, 2] (원본은 그대로!)

// React의 상태 업데이트가 얼마나 깔끔해지는지 보세요.
// Before
setTodos([...todos].sort((a, b) => a.id - b.id));

// After
setTodos(todos.toSorted((a, b) => a.id - b.id));

이제 더 이상 원본을 해치지 않기 위해 매번 [...array] 같은 코드를 쓰지 않아도 됩니다.

비동기 처리의 혁신 async/await (ES2017)

async/await는 ES6 기능으로 착각하는 분들이 많지만 사실은 ES2017에서 등장했는데요.

콜백 지옥을 해결하기 위해 나온 Promise를 훨씬 더 동기적인 코드처럼 읽고 쓸 수 있게 만들어준 '문법적 설탕(Syntactic Sugar)'입니다.

.then() 체인이 끝없이 이어지던 코드를 try...catch와 함께 순차적인 코드로 바꿔주거든요.

가독성이 극적으로 향상되었습니다.

// async/await를 사용한 비동기 코드
async function fetchAllData(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error('데이터를 가져오는 데 실패했습니다:', error);
  }
}

하지만 여기서 주의할 점이 있는데요.

서로 의존성이 없는 비동기 작업들을 await로 하나씩 기다리면 오히려 성능이 저하될 수 있습니다.

이럴 때는 Promise.all을 사용해서 병렬로 처리하는 것이 훨씬 효율적입니다.

// ❌ 나쁜 예: 순차적으로 실행되어 불필요하게 오래 걸립니다.
const user = await fetchUser();
const products = await fetchProducts();

// ✅ 좋은 예: 두 작업이 동시에 실행되어 훨씬 빠릅니다.
const [user, products] = await Promise.all([
  fetchUser(),
  fetchProducts(),
]);

async/await는 코드를 읽기 쉽게 만들어주지만, 비동기 처리의 본질을 이해하고 상황에 맞게 Promise.all 같은 도구를 함께 사용하는 지혜가 필요합니다.

최신 동향 ES2024 & ES2025

JavaScript의 진화는 지금도 계속되고 있는데요.

이미 표준으로 확정되어 최신 브라우저에서 사용할 수 있는 따끈따끈한 기능들도 있습니다.

Object.groupBy()Map.groupBy() (ES2024)는 배열의 요소들을 특정 기준으로 그룹화해주는 기능인데요.

이제 Lodash 같은 라이브러리 없이도 네이티브 기능으로 깔끔하게 처리할 수 있습니다.

const users = [
  { name: 'Alice', role: 'admin' },
  { name: 'Bob', role: 'user' },
  { name: 'Charlie', role: 'user' },
];

const usersByRole = Object.groupBy(users, user => user.role);
/*
{
  admin: [ { name: 'Alice', role: 'admin' } ],
  user: [ { name: 'Bob', role: 'user' }, { name: 'Charlie', role: 'user' } ]
}
*/

그리고 Set 자료구조에 union, intersection, difference 같은 집합 연산 메서드들이 드디어 표준으로 추가(ES2025)되어서, 데이터 처리 작업이 훨씬 더 직관적이고 편해졌습니다.

마무리하며

정말 많은 기능들을 숨 가쁘게 달려왔는데요.

어떠셨나요?

오늘 소개한 기능들은 모두 ECMAScript 명세에서 'Stage 4 (Finished)' 단계에 도달한, 즉 안정성이 검증되어 믿고 쓸 수 있는 기능들입니다.

물론 아직 Stage 3 단계에 있지만 Temporal API(새로운 날짜/시간 표준)처럼 기대를 한 몸에 받는 기능들도 계속해서 준비되고 있거든요.

JavaScript의 미래는 여전히 밝고 흥미진진합니다.

하지만 마지막으로 꼭 드리고 싶은 말씀이 있는데요.

'새로운 기능이 항상 최고의 선택은 아니다'라는 점입니다.

때로는 단순한 for 루프가 복잡한 배열 메서드 체인보다 훨씬 명확하고 읽기 쉬울 수 있거든요.

중요한 것은 '오래된 방식'과 '새로운 방식' 모두를 내 도구함에 넣어두고, 지금 마주한 문제에 가장 적합한 도구를 꺼내 쓸 수 있는 판단력입니다.

JavaScript는 오랫동안 '좋은 부분(Good Parts)'과 '나쁜 부분(Bad Parts)'이 섞여 있는 언어라고 불려왔는데요.

하지만 지난 몇 년간의 발전을 보면, '좋은 부분'이 꾸준히 늘어나면서 개발자 경험이 얼마나 좋아지고 있는지 확실히 체감할 수 있는 것 같습니다.

오늘 배운 새로운 무기들로 내일의 코딩을 더욱 즐겁게 만들어 가시길 바랍니다.