Item 58 모던 자바스크립트로 작성하기

About

마이그레이션을 어디서부터 시작해야 할지 몰라 막막하다면 옛날 버전의 JS 코드를 최신 버전의 JS로 바꾸는 작업부터 시작해보면 좋다.

이번 Item 58은 모던 JS의 주요 기능 몇 가지를 간략히 다룬다.

ECMAScript 모듈 사용하기

ES2015부터는 임포트(import)와 익스포트(export)를 사용하는 ECMAScript가 표준이 되었다.

  • 만약 마이그레이션 대상인 JS 코드가 단일 파일이거나 비표준 모듈 시스템을 사용 중이라면 ES 모듈로 전환하는 것이 좋다.

  • ES 모듈 시스템을 사용하기 위해서 프로젝트 종류에 따라 웹팩(webpack)이나 ts-node 같은 도구가 필요한 경우도 있다. ES 모듈 시스템은 TS에서도 잘 동작하며, 모듈 단위로 전환할 수 있게 해 주기 때문에 점진적 마이그레이션이 원활해진다. (Item 61)

다음은 CommonJS 모듈 시스템을 이용한 전형적인 예제이다.

// CommonJS
// a.js
const b = require('./b')
console.log(b.name)

// b.js
const name = 'Module B'
module.exports = { name }

이를 ES 모듈로 표현하면 다음과 같다.

// ECMAScript module
// a.ts
import * as b from './b'
console.log(b.name)

// b.ts
export const name = 'Module B'

프로토타입 대신 클래스 사용하기

과거에는 JS에서 프로토타입 기반의 객체 모델을 사용했으나, 개발자들의 선호도에 따라 결국 ES2015에 class 키워드를 사용하는 클래스 기반 모델이 도입되었다.

  • 마이그레이션하려는 코드에서 단순한 객체를 다룰 때 프로토토타입을 사용하고 있었다면 클래스로 바꾸는 것이 좋다.

다음은 단순 객체를 프로토타입으로 구현한 예제이다.

function Person(first, last) {
  this.first = first
  this.last = last
}

Person.prototype.getName = function() {
  return this.first + ' ' + this.last
}

const marie = new Person('Marie', 'Curie')
const personName = marie.getName()

다음은 프로토타입 기반 객체를 클래스 기반 객체로 바꾼 예시이다.

class Person {
  first: string
  last: string
  
  constructor(first: string, last: string) {
    this.first = first
    this.last = last
  }
  
  getName() {
    return this.first + ' ' + this.last
  }
}

const marie = new Person('Marie', 'Curie')
const personName = marie.getName()

참고로 편집기에서 프로토타입 객체에 마우스를 올려 간단히 클래스 객체로 변환할 수 있다.

var 대신 let/const 사용하기

JS var 키워드의 스코프(scope) 규칙에 문제가 있다는 것은 널리 알려진 사실이다.

스코프 문제를 자세히 알지 못하더라도 var 대신 let/const를 사용하면 스코프 문제를 피할 수 있다. letconst는 제대로 된 블록 스코프 규칙을 가지며, 개발자들이 일반적으로 기대하는 방식으로 동작한다.

만약 var 키워드를 let/const로 변경하면 일부 코드에서 TS가 문제를 표시할 수도 있다. 오류가 발생한 부분은 잠재적으로 스코프 문제가 존재하므로 수정해야 한다.

중첩된 함수 구문에도 var의 경우와 비슷한 스코프 문제가 존재한다. 예를 들어보면:

function foo() {
  bar()
  function bar() {
    console.log('hello')
  }
}

foo 함수를 호출하면 bar 함수의 정의가 호이스팅(hoisting)되어 가장 먼저 수행되기 때문에 bar 함수가 문제없이 호출되고 hello가 출력된다. 호이스팅은 실행 순서를 예상하기 어렵게 만들고 직관적이지 않다. 대신 함수 표현식(const bar = () => { ... })을 사용하여 호이스팅 문제를 피하는 것이 좋다.

for(;;) 대신 for-of 또는 배열 메서드 사용하기

과거에는 JS에서 배열을 순회할 때 C-style for 루프를 사용했다.

for (var i = 0; i < array.length; i++) {
  const el = array[i]
  // ...
}

모던 JS에는 for-of 루프가 존재한다.

for (const el of array) {
  // ...
}

for-of 루프는 코드가 짧고 인덱스 변수를 사용하지도 않기 때문에 실수를 줄일 수 있다.

인덱스 변수가 필요하면 다음과 같이 forEach 메서드를 사용하면 된다.

array.forEach((el, i) => {
  // ...
})

for-in 문법도 존재하지만 Item 16에서 설명했듯이 몇 가지 문제점이 있기 때문에 사용하지 않는 것이 좋다.

함수 표현식보다는 화살표 함수 사용하기

this 키워드는 JS에서 가장 어려운 개념 중 하나이다. 일반적인 변수들과는 다른 스코프 규칙을 갖기 때문이다.

일반적으로는 this가 클래스 인스턴스를 참조할거라 생각하지만, 다음 예제처럼 예상치 못한 결과가 나올 수 있다.

class Foo {
  method() [
    console.log(this)
    [1, 2].forEach(function(i) {
      console.log(this)
    })
  }
}
const f = new Foo()
f.method()
// strict 모드에서 Foo, undefined, undefined를 출력한다.
// non-strict 모드에서 Foo, window, window (!)를 출력한다.

대신 화살표 함수를 이용하면 상위 스코프의 this를 유지할 수 있다.

class Foo {
  method() [
    console.log(this)
    [1, 2].forEach((i) => {
      console.log(this)
    })
  }
}
const f = new Foo()
f.method()
// 항상 Foo, Foo, Foo를 출력한다.
  • 인라인(또는 콜백)에서는 일반 함수보다 화살표 함수가 더 직관적이며 코드도 간결해지기 때문에 가급적 화살표 함수를 사용하는 것이 좋다.

  • 컴파일러 옵션에 noImplicitThis(또는 strict)를 설정하면 TS가 this 바인딩 관련 오류도 표시해주므로 설정해주는 것이 좋다.

  • this 바인딩 관련 자세한 내용은 Item 49 참고.

단축 객체 표현과 구조 분해 할당 사용하기

pt 객체를 생성하는 다음 코드가 있다.

const x = 1, y = 2, z = 3
const pt = {
  x: x,
  y: y,
  z: z,
}

변수와 객체 속성의 이름이 같다면, 간단하게 다음 코드처럼 작성할 수 있다.

const x = 1, y = 2, z = 3
const pt = { x, y, z }

이쪽 코드가 더 간결하고 중복된 이름을 생략하므로 가독성이 좋고 실수가 적다(Item 36).

Note: ESLint의 object-shorthand를 켜면 린터가 잡아준다.

화살표 함수 내에서 객체를 반환할 땐 소괄호로 감싸야 한다. 화살표 함수에서 함수의 구현부에는 블록이나 단일 표현식이 필요하기 때문에 소괄호로 감싸서 표현식으로 만들어 준 것이다.

['A', 'B', 'C'].map((char, idx) => ({ char, idx }))
// [ { char: 'A', idx: 0 }, { char: 'B', idx: 1 }, { char: 'C', idx: 2 } ]

객체의 속성 중 함수를 축약해 표현하는 방법은 다음과 같다.

const obj = {
  onClickLong: function (e) {
    // ...
  },
  onClickCompact(e) {
    // ...
  }
}

단축 객체 표현(compact object literal)의 반대는 객체 구조 분해(object destructuring)이라고 한다. 다음 예제를 보자.

const props = obj.props
const a = props.a
const b = props.b

다음처럼 줄여서 작성이 가능하다.

const { props } = obj
const { a, b } = props

또는 한 단계 더 줄여서 이렇게도 가능하다.

const { props: { a, b } } = obj

참고로 a, b는 변수로 선언되지만 props는 변수 선언이 아니라는 것에 유의하자.

구조 분해 문법에서는 기본값을 지정하는 것도 가능하다. 다음은 if 구문으로 기본값을 지정하는 방식이다.

let { a } = obj.props
if (a === undefined) a = 'default'

이렇게 구조 분해 문법으로 기본값을 할당할 수 있다.

const { a = 'default' } = obj.props

배열에서도 구조 분해 문법이 가능하다. 배열을 튜플처럼 사용할 경우 특히 유용하다.

const point = [1, 2, 3]
const [x, y, z] = point
const [, a, b] = point  // 첫 번째 요소 무시

함수 매개변수에서도 가능하다.

const points = [
  [1, 2, 3],
  [4, 5, 6],
]
points.forEach(([x, y, z]) => console.log(x, y, z))

단축 객체 표현과 마찬가지로 객체 구조 분해를 사용하면 문법이 간결해지고 변수 사용 간 실수를 줄일 수 있으므로 적극 사용하자.

함수 매개변수 기본값 사용하기

JS의 모든 매개변수는 선택적(생략 가능)이며, 매개변수를 지정하지 않으면 undefined로 간주된다.

function log2(a, b) {
  console.log(a, b)
}
log2() // undefined undefined

옛날엔 매개변수의 기본값을 지정하고 싶을 때 다음 코드처럼 구현하곤 했다.

function parseNum(str, base) {
  base = base || 10
  return parseInt(str, base)
}

모던 JS에서는 매개변수의 기본값을 직접 지정할 수 있다.

function parseNum(str, base = 10) {
  return parseInt(str, base)
}
  • 이런 식으로 하면 코드가 간결해지고 base가 선택적 매개변수라는 것을 명시하는 효과도 준다.

  • 기본값을 기본으로 타입 추론이 가능해져서, TS로 마이그레이션 할 때 매개변수에 타입 구문을 쓰지 않아도 된다(Item 19).

저수준 프로미스나 콜백 대신 async/await 사용하기

Item 25에서 설명했듯 콜백, 프로미스보다 async/await을 권장한다.

요점은 다음과 같다.

  • async/await을 사용하면 코드가 간결해져서 버그와 실수를 방지한다.

  • 비동기 코드에 타입 정보가 전달되어 타입 추론을 가능하게 한다.

function getJSON(url: string) {
  return fetch(url).then((response) => response.json())
}

function getJSONCallback(url: string, cb: (result: unknown) => void) {
  // ...
}

위와 같이 콜백과 프로미스를 사용한 코드보다는, 아래의 async/await으로 작성한 코드가 더 깔끔하고 직관적이다.

async function getJSON(url: string) {
  const response = await fetch(url)
  return response.json()
}

연관 배열에 객체 대신 MapSet 사용하기

Item 15에서 객체의 인덱스 시그니처를 사용하는 방법을 다루었다. 인덱스 시그니처는 편리하지만, 몇 가지 문제점이 존재한다. 문자열 내의 단어 개수를 세는 함수를 예로 들어본다.

function countWords(text: string) {
  const counts: { [words: string]: number } = {}
  for (const word of text.split(/[\s,.]+/)) {
    counts[word] = 1 + (counts[word] || 0)
  }
  return counts
}

별 문제가 없어 보이는 코드지만, constructor이라는 문자열이 주어지면 문제가 발생한다.

console.log(countWords('Objects have a constructor'))

실행 결과는 다음과 같다.

{
  Objects: 1,
  have: 1,
  a: 1,
  constructor: "1function Object() { [native code] }"
}

constructor의 초깃값은 undefined가 아니라 Object.prototype에 있는 생성자 함수이다. 원치 않는 값일 뿐 아니라, 타입도 number가 아닌 string이다. 이런 문제를 방지하려면 Map을 사용하자.

function countWords(text: string) {
  const counts: Map<string, number> = {}
  for (const word of text.split(/[\s,.]+/)) {
    counts[word] = 1 + (counts[word] || 0)
  }
  return counts
}

타입스크립트에 use strict 넣지 않기

ES5에서는 버그가 될 수 있는 코드 패턴에 오류를 표시해주는 엄격 모드(strict)가 도입되었다. 코드의 제일 처음에 'use strict'를 넣으면 엄격 모드가 활성화된다.

그러나 TS에서 수행되는 안전성 검사(sanity check)가 엄격 모드보다 훨씬 더 엄격한 체크를 하므로, 이는 무의미하다.

  • 실제로 TS 컴파일러에 alwaysStrict(또는 strict) 옵션을 설정하면 엄격 모드로 코드를 파싱하고 생성하는 JS 코드에서 'use strict'가 추가된다.

  • 즉, TS 코드에는 'use strict'를 넣지 말고, alwaysStrict 설정을 사용해야 한다.

Summary

  • TS 개발 환경은 모던 JS도 실행할 수 있으므로 모던 JS 최신 기능을 적극 사용하는 것이 좋다. 코드 품질을 향상시킬 수 있고, TS의 타입 추론도 더 나아진다.

  • TS 개발 환경에서는 컴파일러와 언어 서비스를 통해 클래스, 구조 분해, async/await 같은 기능을 쉽게 배울 수 있다.

  • 'use strict'는 TS 컴파일러 수준에서 사용되므로 코드에서 제거해야 한다.

  • TC39의 GitHub repositoryTS의 릴리스 노트를 통해 최신 기능을 확인할 수 있다.

Last updated