/ModernJavaScript

(javscript)If you go alone, you go fast, if you go together, you go far

Primary LanguageHTMLMIT LicenseMIT

ModernJavaScript

(javscript)If you go alone, you go fast, if you go together, you go far

Let Declaration

1. 자바스크립트란?

정의

자바스크립트는 ‘웹페이지에 생동감을 불어넣기 위해’ 만들어진 프로그래밍 언어입니다.

자바스크립트로 작성한 프로그램을 스크립트(script) 라고 부릅니다. 스크립트는 웹페이지의 HTML 안에 작성할 수 있는데, 웹페이지를 불러올 때 스크립트가 자동으로 실행됩니다.

스크립트는 특별한 준비나 컴파일 없이 보통의 문자 형태로 작성할 수 있고, 실행도 할 수 있습니다.

이런 관점에서 보면 자바스크립트는 자바(Java)와는 매우 다른 언어라고 할 수 있습니다.

ℹ️ 왜 자바스크립트인가요?

처음 자바스크립트가 만들어졌을 때는 LiveScript’라는 이름으로 불렸습니다. 그런데, 당시 자바의 인기가 아주 높은 상황이었습니다. 관련인들은 자바스크립트를 자바의 ‘동생’ 격인 언어로 홍보하면 도움이 될 것이라는 의사결정을 내리고 이름을 바꿨습니다.

이름은 자바에서 차용해 왔지만, 자바스크립트는 자바와는 독자적인 언어입니다. 꾸준히 발전을 거듭하면서 ECMAScript라는 고유한 명세를 갖춘 독립적인 언어가 되었죠. 자바스크립트는 자바와 아무런 연관이 없습니다.

브라우저엔 '자바스크립트 가상 머신’이라 불리는 엔진이 내장되어 있습니다.

브라우저에서 할 수 있는 일

  • 페이지에 새로운 HTML을 추가하거나 기존 HTML, 혹은 스타일 수정하기
  • 마우스 클릭이나 포인터의 움직임, 키보드 키 눌림 등과 같은 사용자 행동에 반응하기
  • 네트워크를 통해 원격 서버에 요청을 보내거나, 파일 다운로드, 업로드하기(AJAX나 COMET과 같은 기술 사용)
  • 쿠키를 가져오거나 설정하기. 사용자에게 질문을 건네거나 메시지 보여주기
  • 클라이언트 측에 데이터 저장하기(로컬 스토리지)

브라우저에서 할 수 없는 일

  • 접근 제한, 확실한 보안
  • 브라우저 내 탭과 창은 대개 서로의 정보를 알 수 없다, '동일 출처 정책'에 의해 동의와 관련된 특수한 자바스크립트 코드가 없다면 데이터 교환 X
  • 서버와 쉽게 정보를 교환 가능, 타사이트나 도메인에서는 X

자바스크립트만의 강점

  • HTML/CSS와 완전히 통합할 수 있음
  • 간단한 일은 간단하게 처리할 수 있게 해줌
  • 모든 주요 브라우저에서 지원하고, 기본 언어로 사용됨

자바스크립트 ‘너머의’ 언어들

  • CoffeeScript는 자바스크립트를 위한 'syntactic sugar’입니다. 짧은 문법을 도입하여 명료하고 이해하기 쉬운 코드를 작성할 수 있습니다. Ruby 개발자들이 좋아합니다.
  • TypeScript는 개발을 단순화 하고 복잡한 시스템을 지원하려는 목적으로 '자료형의 명시화(strict data typing)'에 집중해 만든 언어입니다. Microsoft가 개발하였습니다.
  • Flow 역시 자료형을 강제하는데, TypeScript와는 다른 방식을 사용합니다. Facebook이 개발하였습니다.
  • Dart는 모바일 앱과 같이 브라우저가 아닌 환경에서 동작하는 고유의 엔진을 가진 독자적 언어입니다. Google이 개발하였습니다.

요약

  • 자바스크립트는 브라우저에서만 쓸 목적으로 고안된 언어이지만, 지금은 다양한 환경에서 쓰이고 있습니다.
  • 오늘날 자바스크립트는 브라우저 환경에서 가장 널리 사용되는 언어로 자리매김하였습니다. HTML/CSS와 완전한 통합이 가능합니다.
  • 자바스크립트로 '트랜스파일’할 수 있는 언어는 많습니다. 각 언어마다 고유한 기능을 제공하죠. 자바스크립트에 숙달한 뒤에 이 언어들을 살펴볼 것을 추천드립니다.

2.1 Hello, World!

'script' 태그

<!DOCTYPE HTML>
<html>

<body>

  <p>스크립트 전</p>

  <script>
    alert( 'Hello, world!' );
  </script>

  <p>스크립트 후</p>

</body>

</html>
<script> 태그엔 자바스크립트 코드가 들어갑니다. 브라우저는 이 태그를 만나면 안의 코드를 자동으로 처리합니다. ## 모던 마크업 <script> 태그엔 몇 가지 속성(attribute)이 있습니다. - type 속성: <script type=…> - language 속성: <script language=…> ## 외부 스크립트 자바스크립트 코드의 양이 많은 경우엔, 파일로 소분하여 저장할 수 있습니다. 이렇게 분해해 놓은 각 파일은 `src` 속성을 사용해 HTML에 삽입합니다. ```jsx <script src="/path/to/script.js"></script>

물론 아래와 같이 URL 전체를 속성으로 사용할 수도 있습니다.

```jsx
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js"></script>

복수의 스크립트를 HTML에 삽입하고 싶다면 스크립트 태그를 여러 개 사용하면 됩니다.

<script src="/js/script1.js"></script>
<script src="/js/script2.js"></script>

src 속성이 있으면 태그 내부의 코드는 무시됩니다.

<script> 태그는 src 속성과 내부 코드를 동시에 가지지 못합니다.

다음 코드는 실행되지 않습니다.

<script *src*="file.js">
  alert(1); // src 속성이 사용되었으므로 이 코드는 무시됩니다.
</script>

따라서 <script src="…">로 외부 파일을 연결할지 아니면 <script> 태그 내에 코드를 작성할지를 선택해야 합니다.

위의 예시는 스크립트 두 개로 분리하면 정상적으로 실행됩니다.

<script src="file.js"></script>
<script>
  alert(1);
</script>

요약

  • 웹 페이지에 자바스크립트 코드를 추가하기 위해 <script> 태그를 사용합니다.
  • type 과 language 속성은 필수가 아닙니다.
  • 외부 스크립트 파일은 <script src="path/to/script.js"></script>와 같이 삽입합니다.

2.2 코드 구조

아래 코드는 'Hello World’를 두 개의 alert 문으로 나눈 예시입니다.

alert('Hello'); alert('World');

코드의 가독성을 높이기 위해 각 문은 서로 다른 줄에 작성하는 것이 일반적입니다.

alert('Hello');
alert('World');

세미콜론

줄 바꿈이 있다면 세미콜론(semicolon)을 생략할 수 있습니다.

아래 코드는 에러 없이 동작합니다.

alert('Hello')
alert('World')

자바스크립트는 줄 바꿈이 있으면 이를 ‘암시적’ 세미콜론으로 해석합니다. 이런 동작 방식을 세미콜론 자동 삽입(automatic semicolon insertion)이라 부릅니다.

대부분의 경우, 줄 바꿈은 세미콜론을 의미합니다. 하지만 '대부분의 경우’가 '항상’을 의미하진 않습니다.

아래와 같이 줄 바꿈이 세미콜론을 의미하지 않는 경우도 있습니다.

alert(3 +
1
+ 2);

세미콜론 자동 삽입이 일어나지 않았기 때문에 6이 출력됩니다. 어떤 줄이 "+" 로 끝나면, 그 줄은 '불완전한 표현식’이므로 세미콜론이 필요하지 않다는 걸 직감하실 겁니다. 위 코드도 이런 의도로 동작합니다.

대괄호[...]앞에는 세미콜론이 없을 시 세미콜론이 있다고 가정하지 않음.

alert("에러가 발생합니다.")
[1, 2].forEach(alert)

alert("에러가 발생합니다.")[1, 2].forEach(alert)

주석

한줄 // 여러줄 /* */

2.3 엄격 모드

자바스크립트는 꽤 오랫동안 호환성 이슈 없이 발전해왔습니다. 기존의 기능을 변경하지 않으면서 새로운 기능이 추가되었죠.

덕분에 기존에 작성한 코드는 절대 망가지지 않는다는 장점이 있었습니다. 하지만 자바스크립트 창시자들이 했던 실수나 불완전한 결정이 언어 안에 영원히 박제된다는 단점도 생겼습니다.

이런 상황은 ECMAScript5(ES5)가 등장하기 전인 2009년까지 지속되었습니다. 그런데 새롭게 제정된 ES5에서는 새로운 기능이 추가되고 기존 기능 중 일부가 변경되었습니다. 기존 기능을 변경하였기 때문에 하위 호환성 문제가 생길 수 있겠죠? 그래서 변경사항 대부분은 ES5의 기본 모드에선 활성화되지 않도록 설계되었습니다. 대신 use strict라는 특별한 지시자를 사용해 엄격 모드(strict mode)를 활성화 했을 때만 이 변경사항이 활성화되게 해놓았습니다.

use strict

지시자 "use strict", 혹은 'use strict'는 단순한 문자열처럼 생겼습니다. 하지만 이 지시자가 스크립트 최상단에 오면 스크립트 전체가 “모던한” 방식으로 동작합니다.

예시:

"use strict";

// 이 코드는 모던한 방식으로 실행됩니다.
...

명령어를 그룹화하는 방식인 함수에 대해선 곧 학습하도록 하겠습니다. 함수에 대해 학습하기 전에, "use strict"는 스크립트 최상단이 아닌 함수 본문 맨 앞에 올 수도 있다는 점을 알아두시기 바랍니다. 이렇게 하면 오직 해당 함수만 엄격 모드로 실행됩니다. 엄격 모드는 대개 스크립트 전체에 적용하지만 말이죠.

use strict를 취소할 방법은 없습니다.

자바스크립트 엔진을 이전 방식으로 되돌리는 "no use strict"같은 지시자는 존재하지 않습니다.

일단 엄격 모드가 적용되면 돌이킬 방법은 없습니다.

브라우저 콘솔

개발한 기능을 테스트하기 위해 브라우저 콘솔을 사용하는 경우, 기본적으로 use strict가 적용되어있지 않는다는 점에 주의하셔야 합니다.

use strict에 영향을 받는 경우라면 개발자는 기대하지 않았던 결과를 얻을 수 있기 때문입니다.

그렇다면 어떻게 해야 콘솔에서 use strict를 사용할 수 있을까요?

'use strict’를 입력한 후, Shift+Enter키를 눌러 줄 바꿈 해 원하는 스크립트를 입력하면 됩니다. 아래와 같이 말이죠.

'use strict'; <Shift+Enter를 눌러  바꿈 >
//  ...테스트하려는 코드 입력
<Enter를 눌러 실행>

이 기능은 Firefox와 Chrome 같은 유명한 브라우저에서 대부분 사용 가능합니다.

브라우저가 오래 되어서 콘솔 창에 use strict를 입력하는 게 불가능하다면, use strict를 적용하는 가장 확실한 방법은 아래와 같이 코드를 래퍼로 감싸면 됩니다.

(function() {
  'use strict';

  // ...테스트하려는 코드...
})()

'use strict’를 꼭 사용해야 하나요

"당연히 사용해야 하는 거 아니야?"라는 생각이 드시겠지만, 꼭 그렇지만은 않습니다.

누군가는 스크립트 맨 윗줄엔 "use strict"를 넣는 게 좋다고 권유할 수 있습니다. 그런데 그거 아세요?

모던 자바스크립트는 '클래스’와 '모듈’이라 불리는 진일보한 구조를 제공합니다(클래스와 모듈에 대해선 당연히 뒤에서 학습할 예정입니다). 이 둘을 사용하면 use strict가 자동으로 적용되죠. 따라서 이 둘을 사용하고 있다면 스크립트에 "use strict"를 붙일 필요가 없습니다.

결론은 이렇습니다. 코드를 클래스와 모듈을 사용해 구성한다면 "use strict"를 생략해도 됩니다. 그런데 아직은 이 둘을 배우지 않았으니 "use strict"를 귀한 손님처럼 모시도록 하겠습니다.

지금까지는 use strict의 일반적인 특징에 대해 알아보았습니다.

다음 챕터부터는 자바스크립트 언어가 제공하는 기능들을 하나씩 학습하면서 이 기능들이 엄격 모드와 비 엄격 모드에서 어떤 차이점을 보이는지 알아보겠습니다. 희소식을 알려드리자면 두 모드에서 차이를 보이는 기능이 많지 않다는 점과 엄격 모드를 사용하면 개발자의 삶의 질이 조금 더 높아진다는 점입니다.

그리고 특별한 언급이 없는 한 이 튜토리얼에 등장하는 모든 예시엔 엄격 모드를 적용할 예정입니다.

2.4 변수와 상수

대다수의 자바스크립트 애플리케이션은 사용자나 서버로부터 입력받은 정보를 처리하는 방식으로 동작합니다. 아래와 같이 말이죠.

  1. 온라인 쇼핑몰 – 판매 중인 상품이나 장바구니 등의 정보
  2. 채팅 애플리케이션 – 사용자 정보, 메시지 등의 정보

변수는 이러한 정보를 저장하는 용도로 사용됩니다.

변수

변수(variable)는 데이터를 저장할 때 쓰이는 ‘이름이 붙은 저장소’ 입니다. 온라인 쇼핑몰 애플리케이션을 구축하는 경우 상품이나 방문객 등의 정보를 저장할 때 변수를 사용하죠.

자바스크립트에선 let 키워드를 사용해 변수를 생성합니다.

아래 문(statement)은 'message’라는 이름을 가진 변수를 생성(선언)합니다.

let message;

이제 할당 연산자 =를 사용해 변수 안에 데이터를 저장해 봅시다.

let message;

*message = 'Hello'; // 문자열을 저장합니다.*

문자열이 변수와 연결된 메모리 영역에 저장되었기 때문에, 변수명을 이용해 문자열에 접근할 수 있게 되었습니다.

let message;
message = 'Hello!';

*alert(message); // 변수에 저장된 값을 보여줍니다.*

아래와 같이 변수 선언과 값 할당을 한 줄에 작성할 수도 있습니다.

let message = 'Hello!'; // 변수를 정의하고 값을 할당합니다.

alert(message); // Hello!

한 줄에 여러 변수를 선언하는 것도 가능합니다.

let user = 'John', age = 25, message = 'Hello';

이렇게 작성하면 코드가 좀 더 짧아 보이긴 하지만 권장하는 방법은 아닙니다. 가독성을 위해 한 줄에는 하나의 변수를 작성해주세요.

한 줄에 한 개의 변수를 작성하면 코드가 길어 보이지만 읽기엔 편합니다.

let user = 'John';
let age = 25;
let message = 'Hello';

어떤 사람들은 이런 방식으로도 변수를 정의합니다.

let user = 'John',
  age = 25,
  message = 'Hello';

‘쉼표가 먼저 오는’ 방식으로 작성하는 사람도 있습니다.

let user = 'John'
  , age = 25
  , message = 'Hello';

위에서 소개한 방식들에 기술적인 차이가 있지는 않습니다. 개인의 취향과 미적 감각에 따라 원하는 방식으로 코드를 작성하세요.

현실 속의 비유

‘상자’ 안에 데이터를 저장하는데, 이 상자에는 특별한 이름표가 붙어 있다고 상상해 봅시다. 이렇게 하면 '변수’를 좀 더 쉽게 이해할 수 있습니다.

예를 들어, 변수 message는 message라는 이름표가 붙어있는 상자에 "Hello!"라는 값을 저장한 것이라고 생각할 수 있습니다.

상자 속엔 어떤 값이든지 넣을 수 있습니다.

원하는 만큼 값을 변경할 수도 있습니다.

let message;

message = 'Hello!';

message = 'World!'; // 값이 변경되었습니다.

alert(message);

값이 변경되면, 이전 데이터는 변수에서 제거됩니다.

변수 두 개를 선언하고, 한 변수의 데이터를 다른 변수에 복사할 수도 있습니다.

let Hello = 'Hello world!';

let message;

*// Hello의 'Hello world' 값을 message에 복사합니다.
message = Hello;*// 이제 두 변수는 같은 데이터를 가집니다.
alert(Hello); // Hello world!
alert(message); // Hello world!

변수를 두 번 선언하면 에러가 발생합니다.

변수는 한 번만 선언해야 합니다.

같은 변수를 여러 번 선언하면 에러가 발생합니다.

let message = "This";

// 'let'을 반복하면 에러가 발생합니다.
let message = "That"; // SyntaxError: 'message' has already been declared

따라서 변수는 딱 한 번만 선언하고, 선언한 변수를 참조할 때는 let 없이 변수명만 사용해 참조해야 합니다.

변수 명명 규칙

자바스크립트에선 변수 명명 시 두 가지 제약 사항이 있습니다.

  1. 변수명에는 오직 문자와 숫자, 그리고 기호 $와 _만 들어갈 수 있습니다.
  2. 첫 글자는 숫자가 될 수 없습니다.

다음은 유효한 변수명의 예시입니다.

let userName;
let test123;

여러 단어를 조합하여 변수명을 만들 땐 카멜 표기법(camelCase)가 흔히 사용됩니다. 카멜 표기법은 단어를 차례대로 나열하면서 첫 단어를 제외한 각 단어의 첫 글자를 대문자로 작성합니다. myVeryLongName같이 말이죠.

달러 기호 '$' 와 밑줄 '_' 를 변수명에 사용할 수 있다는 점이 조금 특이하네요. 이 특수 기호는 일반 글자처럼 특별한 의미를 지니진 않습니다.

아래는 유효한 변수명에 관한 예시입니다.

let $ = 1; // '$'라는 이름의 변수를 선언합니다.
let _ = 2; // '_'라는 이름의 변수를 선언합니다.

alert($ + _); // 3

아래는 잘못된 변수명의 예시입니다.

let 1a; // 변수명은 숫자로 시작해선 안 됩니다.

let my-name; // 하이픈 '-'은 변수명에 올 수 없습니다.

ℹ️ 대·소문자 구별

apple와 AppLE은 서로 다른 변수입니다.

ℹ️ 비 라틴계 언어도 변수명에 사용할 수 있지만 권장하진 않습니다.

키릴 문자, 심지어 상형문자도 변수명에 사용할 수 있습니다. 모든 언어를 변수명에 사용할 수 있죠.

let имя = '...';
let  = '...';

위 코드에는 기술적인 에러가 없습니다. 변수명도 유효합니다. 하지만 영어를 변수명에 사용하는 것이 국제적인 관습이므로, 변수명은 영어를 사용해서 만들길 권유 드립니다. 다른 나라 사람이 스크립트를 볼 경우 등을 대비해 장기적인 안목을 가지고 코드를 작성합시다.

⚠️ 예약어

예약어(reserved name) 목록에 있는 단어는 변수명으로 사용할 수 없습니다. 이 단어들은 자바스크립트 내부에서 이미 사용 중이기 때문입니다.

예약어 예시: letclassreturnfunction

아래 코드는 문법 에러를 발생시킵니다.

let let = 5; // 'let'을 변수명으로 사용할 수 없으므로 에러!
let return = 5; // 'return'을 변수명으로 사용할 수 없으므로 에러!

⚠️ use strict 없이 할당하기

변수는 대개 정의되어 있어야 사용할 수 있습니다. 그러나 예전에는 let 없이도 단순하게 값을 할당해 변수를 생성하는 것이 가능했습니다. use strict를 쓰지 않으면 과거 스크립트와의 호환성을 유지할 수 있기 때문에 여전히 이 방식을 사용할 수 있습니다.

// 참고: 이 예제에는 "use strict"가 없습니다.

num = 5; // 변수 'num'이 정의되어있지 않더라도, 단순 할당만으로 변수가 생성됩니다.

alert(num); // 5

이렇게 변수를 생성하는 것은 나쁜 관습입니다. 엄격 모드에서 에러를 발생시키기 때문이죠.

"use strict";

*num = 5; // error: num is not defined*

상수

변화하지 않는 변수를 선언할 땐, let 대신 const를 사용합니다.

const myBirthday = '18.04.1982';

이렇게 const로 선언한 변수를 '상수(constant)'라고 부릅니다. 상수는 재할당할 수 없으므로 상수를 변경하려고 하면 에러가 발생합니다.

const myBirthday = '18.04.1982';

myBirthday = '01.01.2001'; // error, can't reassign the constant!

변숫값이 절대 변경되지 않을 것이라 확신하면, 값이 변경되는 것을 방지하면서 다른 개발자들에게 이 변수는 상수라는 것을 알리기 위해 const를 사용해 변수를 선언하도록 합시다.

대문자 상수

기억하기 힘든 값을 변수에 할당해 별칭으로 사용하는 것은 널리 사용되는 관습입니다.

이런 상수는 대문자와 밑줄로 구성된 이름으로 명명합니다.

예시로 웹에서 사용하는 색상 표기법인 16진수 컬러 코드에 대한 상수를 한번 만들어보겠습니다.

const COLOR_RED = "#F00";
const COLOR_GREEN = "#0F0";
const COLOR_BLUE = "#00F";
const COLOR_ORANGE = "#FF7F00";

// 색상을 고르고 싶을 때 별칭을 사용할 수 있게 되었습니다.
let color = COLOR_ORANGE;
alert(color); // #FF7F00

대문자로 상수를 만들어 사용하면 다음과 같은 장점이 있습니다.

  • COLOR_ORANGE는 "#FF7F00"보다 기억하기가 훨씬 쉽습니다.
  • COLOR_ORANGE를 사용하면 "#FF7F00"를 사용하는 것보다 오타를 낼 확률이 낮습니다.
  • COLOR_ORANGE가 #FF7F00보다 훨씬 유의미하므로, 코드 가독성이 증가합니다.

그렇다면 언제 일반적인 방식으로 상수를 명명하고, 언제 대문자를 사용해서 명명해야 하는 걸까요? 명확히 짚고 넘어갑시다.

'상수’는 변수의 값이 절대 변하지 않음을 의미합니다. 그중에는 (빨간색을 나타내는 16진수 값처럼) 코드가 실행되기 전에 이미 그 값을 알고 있는 상수도 있고, 런타임 과정에서 계산되지만 최초 할당 이후 값이 변하지 않는 상수도 있습니다.

예시:

const pageLoadTime = /* 웹페이지를 로드하는데 걸린 시간 */;

pageLoadTime의 값은 페이지가 로드되기 전에는 정해지지 않기 때문에 일반적인 방식으로 변수명을 지었습니다. 하지만 이 값은 최초 할당 이후에 변경되지 않으므로 여전히 상수입니다.

정리하자면, 대문자 상수는 ‘하드 코딩한’ 값의 별칭을 만들 때 사용하면 됩니다.

바람직한 변수명

변수명은 간결하고, 명확해야 합니다. 변수가 담고있는 것이 무엇인지 잘 설명할 수 있어야 하죠.

  • userName 이나 shoppingCart처럼 사람이 읽을 수 있는 이름을 사용하세요.
  • 무엇을 하고 있는지 명확히 알고 있지 않을 경우 외에는 줄임말이나 abc와 같은 짧은 이름은 피하세요.
  • 최대한 서술적이고 간결하게 명명해 주세요. data와 value는 나쁜 이름의 예시입니다. 이런 이름은 아무것도 설명해주지 않습니다. 코드 문맥상 변수가 가리키는 데이터나 값이 아주 명확할 때에만 이런 이름을 사용합시다.
  • 자신만의 규칙이나 소속된 팀의 규칙을 따르세요. 만약 사이트 방문객을 'user’라고 부르기로 했다면, 이와 관련된 변수를 currentVisitor나 newManInTown이 아닌 currentUser나 newUser라는 이름으로 지어야 합니다.

요약

varletconst를 사용해 변수를 선언할 수 있습니다. 선언된 변수엔 데이터를 저장할 수 있죠.

  • let – 모던한 변수 선언 키워드입니다.
  • var – 오래된 변수 선언 키워드입니다. 잘 사용하지 않습니다. let과의 미묘한 차이점은 오래된 'var' 챕터에서 다루도록 하겠습니다.
  • const – let과 비슷하지만, 변수의 값을 변경할 수 없습니다.

변수명은 변수가 담고 있는 것이 무엇인지 쉽게 알 수 있도록 지어져야 합니다.

2.5 자료형

자바스크립트에는 여덟 가지 기본 자료형이 있습니다.

// no error
let message = "hello";
message = 123456;

이처럼 자료의 타입은 있지만 변수에 저장되는 값의 타입은 언제든지 바꿀 수 있는 언어를 ‘동적 타입(dynamically typed)’ 언어라고 부릅니다.

숫자형

let n = 123;
n = 12.345;

숫자형(number type) 은 정수 및 부동소수점 숫자(floating point number)를 나타냅니다.

숫자형과 관련된 연산은 다양한데, 곱셈 *, 나눗셈 /, 덧셈 +, 뺄셈 - 등이 대표적입니다.

숫자형엔 일반적인 숫자 외에 Infinity-InfinityNaN같은 '특수 숫자 값(special numeric value)'이 포함됩니다.

  • Infinity는 어떤 숫자보다 큰 특수 값, 무한대(∞)를 나타냅니다.

    어느 숫자든 0으로 나누면 무한대를 얻을 수 있습니다.

    alert( 1 / 0 ); // 무한대

    Infinity를 직접 참조할 수도 있습니다.

    alert( Infinity ); // 무한대
  • NaN은 계산 중에 에러가 발생했다는 것을 나타내주는 값입니다. 부정확하거나 정의되지 않은 수학 연산을 사용하면 계산 중에 에러가 발생하는데, 이때 NaN이 반환됩니다.

    alert( "숫자가 아님" / 2 ); // NaN, 문자열을 숫자로 나누면 오류가 발생합니다.

    NaN은 여간해선 바뀌지 않습니다. NaN에 어떤 추가 연산을 해도 결국 NaN이 반환됩니다.

    alert( "숫자가 아님" / 2 + 5 ); // NaN

    연산 과정 어디에선가 NaN이 반환되었다면, 이는 모든 결과에 영향을 미칩니다.

BigInt

내부 표현 방식 때문에 자바스크립트에선 (253-1)(9007199254740991) 보다 큰 값 혹은 -(253-1) 보다 작은 정수는 '숫자형’을 사용해 나타낼 수 없습니다.

사실 대부분의 상황에서 이런 제약사항은 문제가 되지 않습니다. 그렇지만 암호 관련 작업같이 아주 큰 숫자가 필요한 상황이거나 아주 높은 정밀도로 작업을 해야 할 때는 이런 큰 숫자가 필요합니다.

BigInt형은 표준으로 채택된 지 얼마 안 된 자료형으로, 길이에 상관없이 정수를 나타낼 수 있습니다.

BigInt형 값은 정수 리터럴 끝에 n을 붙이면 만들 수 있습니다.

// 끝에 'n'이 붙으면 BigInt형 자료입니다.
const bigInt = 1234567890123456789012345678901234567890n;

BigInt형 숫자는 자주 쓰이지 않는다

문자형

자바스크립트에선 문자열(string)을 따옴표로 묶습니다.

let str = "Hello";
let str2 = 'Single quotes are ok too';
let phrase = `can embed another ${str}`;

따옴표는 세 종류가 있습니다.

  1. 큰따옴표: "Hello"
  2. 작은따옴표: 'Hello'
  3. 역 따옴표(백틱, backtick): Hello

역 따옴표로 변수나 표현식을 감싼 후 ${…}안에 넣어주면, 아래와 같이 원하는 변수나 표현식을 문자열 중간에 손쉽게 넣을 수 있습니다.

let name = "John";

// 변수를 문자열 중간에 삽입
alert( `Hello, *${name}*!` ); // Hello, John!

// 표현식을 문자열 중간에 삽입
alert( `the result is *${1 + 2}*` ); // the result is 3

${…} 안에는 name 같은 변수나 1 + 2 같은 수학 관련 표현식을 넣을 수 있습니다. 물론 더 복잡한 표현식도 넣을 수 있죠. 무엇이든 들어갈 수 있습니다. 이렇게 문자열 중간에 들어간 변수나 표현식은 평가가 끝난 후 문자열의 일부가 됩니다.

큰따옴표나 작은따옴표를 사용하면 중간에 표현식을 넣을 수 없다는 점에 주의하시기 바랍니다. 이 방법은 역 따옴표를 써야만 가능합니다.

alert( "the result is ${1 + 2}" ); // the result is ${1 + 2} (큰따옴표는 확장 기능을 지원하지 않습니다.)

불린형

불린형(논리 타입)은 true와 false 두 가지 값밖에 없는 자료형입니다.

불린형은 긍정(yes)이나 부정(no)을 나타내는 값을 저장할 때 사용합니다. true는 긍정, false는 부정을 의미합니다.

let nameFieldChecked = true; // 네, name field가 확인되었습니다(checked).
let ageFieldChecked = false; // 아니요, age field를 확인하지 않았습니다(not checked)

불린값은 비교 결과를 저장할 때도 사용됩니다.

let isGreater = 4 > 1;

alert( isGreater ); // true (비교 결과: "yes")

'null' 값

null 값은 지금까지 소개한 자료형 중 어느 자료형에도 속하지 않는 값입니다.

null 값은 오로지 null 값만 포함하는 별도의 자료형을 만듭니다.

let age = null;

자바스크립트의 null은 자바스크립트 이외 언어의 null과 성격이 다릅니다. 다른 언어에선 null을 '존재하지 않는 객체에 대한 참조’나 '널 포인터(null pointer)'를 나타낼 때 사용합니다.

하지만 자바스크립트에선 null을 ‘존재하지 않는(nothing)’ 값, ‘비어 있는(empty)’ 값, ‘알 수 없는(unknown)’ 값을 나타내는 데 사용합니다.

let age = null;은 나이(age)를 알 수 없거나 그 값이 비어있음을 보여줍니다.

'undefined' 값

undefined 값도 null 값처럼 자신만의 자료형을 형성합니다.

undefined는 '값이 할당되지 않은 상태’를 나타낼 때 사용합니다.

변수는 선언했지만, 값을 할당하지 않았다면 해당 변수에 undefined가 자동으로 할당됩니다.

let age;

alert(age); // 'undefined'가 출력됩니다.

개발자가 변수에 undefined를 명시적으로 할당하는 것도 가능하긴 합니다.

let age = 100;

// 값을 undefined로 바꿉니다.
age = undefined;

alert(age); // "undefined"

하지만 이렇게 undefined를 직접 할당하는 걸 권장하진 않습니다. 변수가 ‘비어있거나’ ‘알 수 없는’ 상태라는 걸 나타내려면 null을 사용하세요. undefined는 값이 할당되지 않은 변수의 초기값을 위해 예약어로 남겨둡시다.

객체와 심볼

객체(object)형은 특수한 자료형입니다.

객체형을 제외한 다른 자료형은 문자열이든 숫자든 한 가지만 표현할 수 있기 때문에 원시(primitive) 자료형이라 부릅니다. 반면 객체는 데이터 컬렉션이나 복잡한 개체(entity)를 표현할 수 있습니다.

이런 특징 때문에 자바스크립트에서 객체는 좀 더 특별한 취급을 받습니다. 자세한 내용은 원시형을 배우고 난 후 객체에서 다루도록 하겠습니다.

심볼(symbol)형은 객체의 고유한 식별자(unique identifier)를 만들 때 사용됩니다. 심볼형에 대해선 객체를 학습하고 난 이후에 자세히 알아보겠습니다.

typeof 연산자

typeof 연산자는 인수의 자료형을 반환합니다. 자료형에 따라 처리 방식을 다르게 하고 싶거나 변수의 자료형을 빠르게 알아내고자 할 때 유용합니다.

typeof 연산자는 두 가지 형태의 문법을 지원합니다.

  1. 연산자: typeof x
  2. 함수: typeof(x)

괄호가 있든 없든 결과가 동일합니다.

typeof x를 호출하면 인수의 자료형을 나타내는 문자열을 반환합니다.

typeof undefined // "undefined"

typeof 0 // "number"

typeof 10n // "bigint"

typeof true // "boolean"

typeof "foo" // "string"

typeof Symbol("id") // "symbol"

*typeof Math // "object"  (1)typeof null // "object"  (2)typeof alert // "function"  (3)*

마지막 세 줄은 약간의 설명이 필요해 보이네요.

  1. Math는 수학 연산을 제공하는 내장 객체이므로 "object"가 출력됩니다. Math에 대해선 숫자형 챕터에서 학습하도록 하겠습니다. 내장 객체는 객체형이라는 것을 알려주기 위해 이런 예시를 작성해 보았습니다.
  2. typeof null의 결과는 "object"입니다. null은 별도의 고유한 자료형을 가지는 특수 값으로 객체가 아니지만, 하위 호환성을 유지하기 위해 이런 오류를 수정하지 않고 남겨둔 상황입니다. 언어 자체의 오류이므로 null이 객체가 아님에 유의하시기 바랍니다.
  3. typeof는 피연산자가 함수면 "function"을 반환합니다. 그러므로 typeof alert는 "function"을 출력해줍니다. 그런데 '함수’형은 따로 없습니다. 함수는 객체형에 속합니다. 이런 동작 방식이 형식적으론 잘못되긴 했지만, 아주 오래전에 만들어진 규칙이었기 때문에 하위 호완성 유지를 위해 남겨진 상태입니다. 한편, 실무에선 이런 특징이 매우 유용하게 사용되기도 합니다.

요약

자바스크립트에는 여덟 가지 기본 자료형이 있습니다.

  • 숫자형 – 정수, 부동 소수점 숫자 등의 숫자를 나타낼 때 사용합니다. 정수의 한계는 ±2^53 입니다.
  • bigint – 길이 제약 없이 정수를 나타낼 수 있습니다.
  • 문자형 – 빈 문자열이나 글자들로 이뤄진 문자열을 나타낼 때 사용합니다. 단일 문자를 나타내는 별도의 자료형은 없습니다.
  • 불린형 – truefalse를 나타낼 때 사용합니다.
  • null – null 값만을 위한 독립 자료형입니다. null은 알 수 없는 값을 나타냅니다.
  • undefined – undefined 값만을 위한 독립 자료형입니다. undefined는 할당되지 않은 값을 나타냅니다.
  • 객체형 – 복잡한 데이터 구조를 표현할 때 사용합니다.
  • 심볼형 – 객체의 고유 식별자를 만들 때 사용합니다.

typeof 연산자는 피연산자의 자료형을 알려줍니다.

  • typeof x 또는 typeof(x) 형태로 사용합니다.
  • 피연산자의 자료형을 문자열 형태로 반환합니다.
  • null의 typeof 연산은 "object"인데, 이는 언어상 오류입니다. null은 객체가 아닙니다.

2.6 alert, prompt, confirm을 이용한 상호작용

alert

alert 함수는 앞선 예제에서 살펴본 바 있습니다. 이 함수가 실행되면 사용자가 ‘확인(OK)’ 버튼을 누를 때까지 메시지를 보여주는 창이 계속 떠있게 됩니다.

예시를 살펴봅시다.

alert("Hello");

메시지가 있는 작은 창은 모달 창(modal window) 이라고 부릅니다. '모달’이란 단어엔 페이지의 나머지 부분과 상호 작용이 불가능하다는 의미가 내포되어 있습니다. 따라서 사용자는 모달 창 바깥에 있는 버튼을 누른다든가 하는 행동을 할 수 없습니다. 확인 버튼을 누르기 전까지 말이죠.

prompt

브라우저에서 제공하는 prompt 함수는 두 개의 인수를 받습니다.

result = prompt(title, [default]);

함수가 실행되면 텍스트 메시지와 입력 필드(input field), 확인(OK) 및 취소(Cancel) 버튼이 있는 모달 창을 띄워줍니다.

title

사용자에게 보여줄 문자열

default

입력 필드의 초깃값(선택값)

let age = prompt('나이를 입력해주세요.', 100);

alert(`당신의 나이는 ${age}살 입니다.`); // 당신의 나이는 100살입니다.

컨펌 대화상자

result = confirm(question);

confirm 함수는 매개변수로 받은 question(질문)과 확인 및 취소 버튼이 있는 모달 창을 보여줍니다.

사용자가 확인버튼를 누르면 true, 그 외의 경우는 false를 반환합니다.

let isBoss = confirm("당신이 주인인가요?");

alert( isBoss ); // 확인 버튼을 눌렀다면 true가 출력됩니다.

요약

alert

메시지를 보여줍니다.

prompt

사용자에게 텍스트를 입력하라는 메시지를 띄워줌과 동시에, 입력 필드를 함께 제공합니다. 확인을 누르면 prompt 함수는 사용자가 입력한 문자열을 반환하고, 취소 또는 Esc를 누르면 null을 반환합니다.

confirm

사용자가 확인 또는 취소 버튼을 누를 때까지 메시지가 창에 보여집니다. 사용자가 확인 버튼을 누르면 true를, 취소 버튼이나 Esc를 누르면 false를 반환합니다.

2.7 형 변환

함수와 연산자에 전달되는 값은 대부분 적절한 자료형으로 자동 변환됩니다. 이런 과정을 "형 변환(type conversion)"이라고 합니다.

alert가 전달받은 값의 자료형과 관계없이 이를 문자열로 자동 변환하여 보여주는 것이나, 수학 관련 연산자가 전달받은 값을 숫자로 변환하는 경우가 형 변환의 대표적인 예시입니다.

문자형으로 변환

문자형으로의 형 변환은 문자형의 값이 필요할 때 일어납니다.

alert메서드는 매개변수로 문자형을 받기 때문에, alert(value)에서 value는 문자형이어야 합니다. 만약, 다른 형의 값을 전달받으면 이 값은 문자형으로 자동 변환됩니다.

String(value) 함수를 호출해 전달받은 값을 문자열로 변환 할 수도 있습니다.

let value = true;
alert(typeof value); // boolean

*value = String(value); // 변수 value엔 문자열 "true"가 저장됩니다.
alert(typeof value); // string*

false는 문자열 "false"로, null은 문자열 "null"로 변환되는 것과 같이, 문자형으로의 변환은 대부분 예측 가능한 방식으로 일어납니다.

숫자형으로 변환

숫자형으로의 변환은 수학과 관련된 함수와 표현식에서 자동으로 일어납니다.

숫자형이 아닌 값에 나누기 /를 적용한 경우와 같이 말이죠.

alert( "6" / "2" ); // 3, 문자열이 숫자형으로 자동변환된 후 연산이 수행됩니다.

Number(value) 함수를 사용하면 주어진 값(value)을 숫자형으로 명시해서 변환할 수 있습니다.

let str = "123";
alert(typeof str); // string

let num = Number(str); // 문자열 "123"이 숫자 123으로 변환됩니다.

alert(typeof num); // number

숫자형 값를 사용해 무언가를 하려고 하는데 그 값을 문자 기반 폼(form)을 통해 입력받는 경우엔, 이런 명시적 형 변환이 필수입니다.

한편, 숫자 이외의 글자가 들어가 있는 문자열을 숫자형으로 변환하려고 하면, 그 결과는 NaN이 됩니다. 예시를 살펴봅시다.

let age = Number("임의의 문자열 123");

alert(age); // NaN, 형 변환이 실패합니다.
alert( Number("   123   ") ); // 123
alert( Number("123z") );      // NaN ("z"를 숫자로 변환하는 데 실패함)
alert( Number(true) );        // 1
alert( Number(false) );       // 0

불린형으로 변환

불린형으로의 변환은 아주 간단합니다.

이 형 변환은 논리 연산을 수행할 때 발생합니다(논리 연산에 관한 내용은 뒤 챕터에서 다루고 있습니다). Boolean(value)를 호출하면 명시적으로 불리언으로의 형 변환을 수행할 수 있습니다.

불린형으로 변환 시 적용되는 규칙은 다음과 같습니다.

  • 숫자 0, 빈 문자열, nullundefinedNaN과 같이 직관적으로도 “비어있다고” 느껴지는 값들은 false가 됩니다.
  • 그 외의 값은 true로 변환됩니다.

예시:

alert( Boolean(1) ); // 숫자 1(true)
alert( Boolean(0) ); // 숫자 0(false)

alert( Boolean("hello") ); // 문자열(true)
alert( Boolean("") ); // 빈 문자열(false)

요약

문자, 숫자, 논리형으로의 형 변환은 자주 일어나는 형 변환입니다.

문자형으로 변환 은 무언가를 출력할 때 주로 일어납니다. String(value)을 사용하면 문자형으로 명시적 변환이 가능합니다. 원시 자료형을 문자형으로 변환할 땐, 대부분 그 결과를 예상할 수 있을 정도로 명시적인 방식으로 일어납니다.

숫자형으로 변환 은 수학 관련 연산시 주로 일어납니다. Number(value)로도 형 변환을 할 수 있습니다.

숫자형으로의 변환은 다음 규칙을 따릅니다.

Untitled

불린형으로 변환 은 논리 연산 시 발생합니다. Boolean(value)으로도 변환할 수 있습니다.

불린형으로의 형 변환은 다음 규칙을 따릅니다.

Untitled

형 변환 시 적용되는 규칙 대부분은 이해하고 기억하기 쉬운 편에 속합니다. 다만 아래는 예외적인 경우이기 때문에 실수를 방지하기 위해 따로 기억해 두도록 합시다.

  • 숫자형으로 변환 시 undefined는 0이 아니라 NaN이 됩니다.
  • 문자열 "0"과 " "같은 공백은 불린형으로 변환 시 true가 됩니다.

2.8 기본 연산자와 수학

용어: '단항','이항','피연산자'

연산자에 대해 학습하기 전에, 앞으로 자주 등장하게 될 용어 몇 가지를 정리해 보겠습니다.

  • 피연산자(operand) 는 연산자가 연산을 수행하는 대상입니다. 5 * 2에는 왼쪽 피연산자 5와 오른쪽 피연산자 2, 총 두 개의 피연산자가 있습니다. '피연산자’는 '인수(argument)'라는 용어로 불리기도 합니다.

  • 피연산자를 하나만 받는 연산자는 단항(unary) 연산자 라고 부릅니다. 피연산자의 부호를 뒤집는 단항 마이너스 연산자 ``는 단항 연산자의 대표적인 예입니다.

    let x = 1;
    
    *x = -x;*alert( x ); // -1, 단항 마이너스 연산자는 부호를 뒤집습니다.
  • 두 개의 피연산자를 받는 연산자는 이항(binary) 연산자 라고 부릅니다. 마이너스 연산자는 아래와 같이 이항 연산자로 쓸 수도 있습니다.

    let x = 1, y = 3;
    alert( y - x ); // 2, 이항 마이너스 연산자는 뺄셈을 해줍니다.

    위와 같이 부호를 반전해주는 단항 마이너스 연산자와 뺄셈에 쓰이는 이항 마이너스 연산자(뺄셈 연산자)는 기호는 같지만 수행하는 연산이 다릅니다. 두 연산을 구분하는 기준은 피연산자의 개수입니다.

수학

자바스크립트에서 지원하는 수학 연산자는 다음과 같습니다.

  • 덧셈 연산자 +,
  • 뺄셈 연산자 -,
  • 곱셈 연산자 *,
  • 나눗셈 연산자 /,
  • 나머지 연산자 %,
  • 거듭제곱 연산자 **

이항 연산자 '+'와 문자열 연결

이항 연산자 +의 피연산자로 문자열이 전달되면 덧셈 연산자는 덧셈이 아닌 문자열을 병합(연결)합니다.

let s = "my" + "string";
alert(s); // mystring

따라서 이항 연산자 +를 사용할 때는 피연산자 중 하나가 문자열이면 다른 하나도 문자열로 변환된다는 점에 주의해야 합니다.

alert( '1' + 2 ); // "12"
alert( 2 + '1' ); // "21"

첫 번째 피연산자가 문자열인지, 두 번째 피연산자가 문자열인지는 중요하지 않습니다. 피연산자 중 어느 하나가 문자열이면 다른 하나도 문자열로 변환됩니다.

좀 더 복잡한 예시를 살펴봅시다.

alert(2 + 2 + '1' ); // '221'이 아니라 '41'이 출력됩니다.
// 숫자에는 아무런 영향을 미치지 않습니다.
let x = 1;
alert( +x ); // 1

let y = -2;
alert( +y ); // -2

*// 숫자형이 아닌 피연산자는 숫자형으로 변화합니다.
alert( +true ); // 1
alert( +"" );   // 0*

단항 덧셈 연산자는 짧은 문법으로도 Number(...)와 동일한 일을 할 수 있게 해줍니다.

연산자 우선순위

하나의 표현식에 둘 이상의 연산자가 있는 경우, 실행 순서는 연산자의 우선순위(precedence) 에 의해 결정됩니다.

우선순위 테이블(precedence table)

할당 연산자

let x = 2 * 2 + 1;

alert( x ); // 5

할당 연산자 체이닝

할당 연산자는 아래와 같이 여러 개를 연결할 수도 있습니다(체이닝).

let a, b, c;

*a = b = c = 2 + 2;*alert( a ); // 4
alert( b ); // 4
alert( c ); // 4

복합 할당 연산자

let n = 2;
n = n + 5;
n = n * 2;

이때, +=와 *=연산자를 사용하면 짧은 문법으로 동일한 연산을 수행할 수 있습니다.

let n = 2;
n += 5; // n은 7이 됩니다(n = n + 5와 동일).
n *= 2; // n은 14가 됩니다(n = n * 2와 동일).

alert( n ); // 14

증가·감소 연산자

숫자를 하나 늘리거나 줄이는 것은 자주 사용되는 연산입니다.

자바스크립트에서는 이런 연산을 해주는 연산자를 제공합니다.

  • 증가(increment) 연산자 ++는 변수를 1 증가시킵니다.

    let counter = 2;
    counter++;      // counter = counter + 1과 동일하게 동작합니다. 하지만 식은 더 짧습니다.
    alert( counter ); // 3
  • 감소(decrement) 연산자 -는 변수를 1 감소시킵니다.

    let counter = 2;
    counter--;      // counter = counter - 1과 동일하게 동작합니다. 하지만 식은 더 짧습니다.
    alert( counter ); // 1

비트 연산자

  • 비트 AND ( & )
  • 비트 OR ( | )
  • 비트 XOR ( ^ )
  • 비트 NOT ( ~ )
  • 왼쪽 시프트(LEFT SHIFT) ( << )
  • 오른쪽 시프트(RIGHT SHIFT) ( >> )
  • 부호 없는 오른쪽 시프트(ZERO-FILL RIGHT SHIFT) ( >>> )

쉼표 연산자

쉼표 연산자(comma operator) ,는 좀처럼 보기 힘들고, 특이한 연산자 중 하나입니다. 코드를 짧게 쓰려는 의도로 가끔 사용됩니다. 이런 코드를 만났을 때, 어떤 연산 결과가 도출되는지 알아야 하므로 쉼표 연산자에 대해 알아보도록 합시다.

쉼표 연산자 ,는 여러 표현식을 코드 한 줄에서 평가할 수 있게 해줍니다. 이때 표현식 각각이 모두 평가되지만, 마지막 표현식의 평가 결과만 반환되는 점에 유의해야 합니다.

*let a = (1 + 2, 3 + 4);*alert( a ); // 7 (3 + 4의 결과)

위 예시에서 첫 번째 표현식 1 + 2은 평가가 되지만 그 결과는 버려집니다. 3 + 4만 평가되어 a에 할당되죠.

2.9 비교 연산자

  • 보다 큼·작음: a > ba < b.
  • 보다 크거나·작거나 같음: a >= ba <= b.
  • 같음(동등): a == b. 등호 =가 두 개 연달아 오는 것에 유의하세요. a = b와 같이 등호가 하나일 때는 할당을 의미합니다.
  • 같지 않음(부등): 같지 않음을 나타내는 수학 기호 는 자바스크립트에선 a != b로 나타냅니다. 할당연산자 = 앞에 느낌표 !를 붙여서 표시합니다.

불린형 반환

  • true가 반환되면, ‘긍정’, ‘참’, '사실’을 의미합니다.
  • false가 반환되면, ‘부정’, ‘거짓’, '사실이 아님’을 의미합니다.
alert( 2 > 1 );  // true
alert( 2 == 1 ); // false
alert( 2 != 1 ); // true

반환된 불린값은 다른 여타 값처럼 변수에 할당 할 수 있습니다.

let result = 5 > 4; // 비교 결과를 변수에 할당
alert( result ); // true

문자열 비교

alert( 'Z' > 'A' ); // true
alert( 'Glow' > 'Glee' ); // true
alert( 'Bee' > 'Be' ); // true

문자열 비교 시 적용되는 알고리즘은 다음과 같습니다.

  1. 두 문자열의 첫 글자를 비교합니다.
  2. 첫 번째 문자열의 첫 글자가 다른 문자열의 첫 글자보다 크면(작으면), 첫 번째 문자열이 두 번째 문자열보다 크다고(작다고) 결론 내고 비교를 종료합니다.
  3. 두 문자열의 첫 글자가 같으면 두 번째 글자를 같은 방식으로 비교합니다.
  4. 글자 간 비교가 끝날 때까지 이 과정을 반복합니다.
  5. 비교가 종료되었고 문자열의 길이도 같다면 두 문자열은 동일하다고 결론 냅니다. 비교가 종료되었지만 두 문자열의 길이가 다르면 길이가 긴 문자열이 더 크다고 결론 냅니다.

예시의 'Z' > 'A'는 위 알고리즘의 첫 번째 단계에서 비교 결과가 도출됩니다. 반면, 문자열 "Glow"와 "Glee"는 복수의 문자로 이루어진 문자열이기 때문에, 아래와 같은 순서로 문자열 비교가 이뤄집니다.

  1. G는 G와 같습니다.
  2. l은 l과 같습니다.
  3. o는 e보다 크기 때문에 여기서 비교가 종료되고, o가 있는 첫 번째 문자열 "Glow"가 더 크다는 결론이 도출됩니다.

ℹ️정확히는 사전순이 아니라 유니코드 순입니다.

자바스크립트의 문자열 비교 알고리즘은 사전이나 전화번호부에서 사용되는 정렬 알고리즘과 아주 유사하지만, 완전히 같진 않습니다.

차이점 중 하나는 자바스크립트는 대·소문자를 따진다는 것입니다. 대문자 "A"와 소문자 "a"를 비교했을 때 소문자 "a"가 더 큽니다. 자바스크립트 내부에서 사용되는 인코딩 표인 유니코드에선 소문자가 대문자보다 더 큰 인덱스를 갖기 때문이죠. 이와 관련한 자세한 내용은 문자열 챕터에서 다루도록 하겠습니다.

다른 형을 가진 값 간의 비교

alert( '2' > 1 ); // true, 문자열 '2'가 숫자 2로 변환된 후 비교가 진행됩니다.
alert( '01' == 1 ); // true, 문자열 '01'이 숫자 1로 변환된 후 비교가 진행됩니다.

불린값의 경우 true는 1false는 0으로 변환된 후 비교가 이뤄집니다.

alert( true == 1 ); // true
alert( false == 0 ); // true

ℹ️흥미로운 상황

같이 일어나지 않을 법한 두 상황이 동시에 일어나는 경우도 있습니다.

  • 동등 비교(==) 시 true를 반환함
  • 논리 평가 시 값 하나는 true, 다른 값 하나는 false를 반환함
let a = 0;
alert( Boolean(a) ); // false

let b = "0";
alert( Boolean(b) ); // true

alert(a == b); // true!

두 값을 비교했을 때 참이 반환되는데, 값을 논리 평가한 후 비교하면 하나는 거짓이 반환된다는 점에 고개를 갸우뚱할 수도 있습니다. 자바스크립트의 관점에선 이런 결과가 아주 자연스럽습니다. 동등 비교 연산자 ==는 (예시에서 문자열 "0"을 숫자 0으로 변환시킨 것처럼) 피연산자를 숫자형으로 바꾸지만, 'Boolean’을 사용한 명시적 변환에는 다른 규칙이 사용되기 때문입니다.

일치 연산자

동등 연산자(equality operator) ==은 0과 false를 구별하지 못합니다.

alert( 0 == false ); // true

피연산자가 빈 문자열일 때도 같은 문제가 발생하죠.

alert( '' == false ); // true

이런 문제는 동등 연산자 ==가 형이 다른 피연산자를 비교할 때 피연산자를 숫자형으로 바꾸기 때문에 발생합니다. 빈 문자열과 false는 숫자형으로 변환하면 0이 되죠.

그렇다면 0과 false는 어떻게 구별할 수 있을까요?

일치 연산자(strict equality operator) ===를 사용하면 형 변환 없이 값을 비교할 수 있습니다.

일치 연산자는 엄격한(strict) 동등 연산자입니다. 자료형의 동등 여부까지 검사하기 때문에, 피연산자 a와 b의 형이 다를 경우 a === b는 false를 즉시 반환합니다.

alert( 0 === false ); // false, 피연산자의 형이 다르기

null이나 undefined와 비교하기

일치 연산자 ===를 사용하여 null과 undefined를 비교

두 값의 자료형이 다르기 때문에 일치 비교 시 거짓이 반환됩니다.

alert( null === undefined ); // false

동등 연산자 ==를 사용하여 null과 undefined를 비교

동등 연산자를 사용해 null과 undefined를 비교하면 특별한 규칙이 적용돼 true가 반환됩니다. 동등 연산자는 null과 undefined를 '각별한 커플’처럼 취급합니다. 두 값은 자기들끼리는 잘 어울리지만 다른 값들과는 잘 어울리지 못하죠.

alert( null == undefined ); // true

산술 연산자나 기타 비교 연산자 < > <= >=를 사용하여 null과 undefined를 비교

null과 undefined는 숫자형으로 변환됩니다. null은 0undefined는 NaN으로 변합니다.

null vs 0

null과 0을 비교해 봅시다.

alert( null > 0 );  // (1) false
alert( null == 0 ); // (2) false
alert( null >= 0 ); // (3) *true*

동등 연산자 ==는 피연산자가 undefinednull일 때 형 변환을 하지 않습니다. undefinednull을 비교하는 경우에만 true를 반환하고, 그 이외의 경우(null이나 undefined를 다른 값과 비교할 때)는 무조건 false를 반환합니다. 이런 이유 때문에 (2)는 거짓을 반환합니다.

비교가 불가능한 undefined

undefined를 다른 값과 비교해서는 안 됩니다.

alert( undefined > 0 ); // false (1)
alert( undefined < 0 ); // false (2)
alert( undefined == 0 ); // false (3)
  • 일치 연산자 ===를 제외한 비교 연산자의 피연산자에 undefined나 null이 오지 않도록 특별히 주의하시기 바랍니다.
  • 또한, undefined나 null이 될 가능성이 있는 변수가 >= > < <=의 피연산자가 되지 않도록 주의하시기 바랍니다. 명확한 의도를 갖고 있지 않은 이상 말이죠. 만약 변수가 undefined나 null이 될 가능성이 있다고 판단되면, 이를 따로 처리하는 코드를 추가하시기 바랍니다.

요약

  • 비교 연산자는 불린값을 반환합니다.
  • 문자열은 문자 단위로 비교되는데, 이때 비교 기준은 '사전’순입니다.
  • 서로 다른 타입의 값을 비교할 땐 숫자형으로 형 변환이 이뤄지고 난 후 비교가 진행됩니다(일치 연산자는 제외).
  • null과 undefined는 동등 비교(==) 시 서로 같지만 다른 값과는 같지 않습니다.
  • null이나 undefined가 될 확률이 있는 변수가 > 또는 <의 피연산자로 올 때는 주의를 기울이시기 바랍니다. null/undefined 여부를 확인하는 코드를 따로 추가하는 습관을 들이길 권유합니다.

2.10 if와 '?'를 사용한 조건 처리

'if'문

if(...)문은 괄호 안에 들어가는 조건을 평가하는데, 그 결과가 true이면 코드 블록이 실행됩니다.

let year = prompt('ECMAScript-2015 명세는 몇 년도에 출판되었을까요?', '');

*if (year == 2015) alert( '정답입니다!' );*

위 예시에선 조건(year == 2015)이 간단한 경우만 다뤘는데, 조건문은 더 복잡할 수도 있습니다.

조건이 true일 때 복수의 문을 실행하고 싶다면 중괄호로 코드 블록을 감싸야 합니다.

if (year == 2015) {
  alert( "정답입니다!" );
  alert( "아주 똑똑하시네요!" );
}

if문을 쓸 때는 조건이 참일 경우 실행되는 구문이 단 한 줄이더라도 중괄호 {}를 사용해 코드를 블록으로 감싸는 것을 추천해 드립니다. 이렇게 하면 코드 가독성이 증가합니다.

불린형으로의 변환

if (…) 문은 괄호 안의 표현식을 평가하고 그 결과를 불린값으로 변환합니다.

형 변환 챕터에서 배운 형 변환 규칙을 잠시 상기해 봅시다.

  • 숫자 0, 빈 문자열""nullundefinedNaN은 불린형으로 변환 시 모두 false가 됩니다. 이런 값들은 ‘falsy(거짓 같은)’ 값이라고 부릅니다.
  • 이 외의 값은 불린형으로 변환시 true가 되므로 ‘truthy(참 같은)’ 값이라고 부릅니다.

이 규칙에 따르면 아래 예시의 코드 블록은 절대 실행되지 않습니다.

if (0) { // 0은 falsy입니다.
  ...
}

아래 예시의 코드 블록은 항상 실행됩니다.

if (1) { // 1은 truthy입니다.
  ...
}

아래와 같이 평가를 통해 확정된 불린값을 if문에 전달할 수도 있습니다.

let cond = (year == 2015); // 동등 비교를 통해 true/false 여부를 결정합니다.

if (cond) {
  ...
}

'else'절

if문엔 else 절을 붙일 수 있습니다. else 뒤에 이어지는 코드 블록은 조건이 거짓일 때 실행됩니다.

let year = prompt('ECMAScript-2015 명세는 몇 년도에 출판되었을까요?', '');

if (year == 2015) {
  alert( '정답입니다!' );
} else {
  alert( '오답입니다!' ); // 2015 이외의 값을 입력한 경우
}

'else if’로 복수 조건 처리하기

유사하지만 약간씩 차이가 있는 조건 여러 개를 처리해야 할 때가 있습니다. 이때 else if를 사용할 수 있습니다.

let year = prompt('ECMAScript-2015 명세는 몇 년도에 출판되었을까요?', '');

if (year < 2015) {
  alert( '숫자를 좀 더 올려보세요.' );
} else if (year > 2015) {
  alert( '숫자를 좀 더 내려보세요.' );
} else {
  alert( '정답입니다!' );
}

위 예시에서, 자바스크립트는 조건 year < 2015를 먼저 확인합니다. 이 조건이 거짓이라면 다음 조건 year > 2015를 확인합니다. 이 조건 또한 거짓이라면 else 절 내의 alert를 실행합니다.

else if 블록을 더 많이 붙이는 것도 가능합니다. 마지막에 붙는 else는 필수가 아닌 선택 사항입니다.

조건부 연산자 '?'

let accessAllowed;
let age = prompt('나이를 입력해 주세요.', '');

*if (age > 18) {
  accessAllowed = true;
} else {
  accessAllowed = false;
}*alert(accessAllowed);

'물음표(question mark) 연산자’라고도 불리는 '조건부(conditional) 연산자’를 사용하면 위 예시를 더 짧고 간결하게 변형할 수 있습니다.

조건부 연산자는 물음표?로 표시합니다. 피연산자가 세 개이기 때문에 조건부 연산자를 '삼항(ternary) 연산자’라고 부르는 사람도 있습니다. 참고로, 자바스크립트에서 피연산자가 3개나 받는 연산자는 조건부 연산자가 유일합니다.

문법:

let result = condition ? value1 : value2;

평가 대상인 condition이 truthy라면 value1이, 그렇지 않으면 value2가 반환됩니다.

let accessAllowed = (age > 18) ? true : false;

age > 18 주위의 괄호는 생략 가능합니다. 물음표 연산자는 우선순위가 낮으므로 비교 연산자 >가 실행되고 난 뒤에 실행됩니다.

아래 예시는 위 예시와 동일하게 동작합니다.

// 연산자 우선순위 규칙에 따라, 비교 연산 'age > 18'이 먼저 실행됩니다.
// (조건문을 괄호로 감쌀 필요가 없습니다.)
let accessAllowed = age > 18 ? true : false;

괄호가 있으나 없으나 차이는 없지만, 코드의 가독성 향상을 위해 괄호를 사용할 것을 권유합니다.

다중 '?'

let age = prompt('나이를 입력해주세요.', 18);

let message = (age < 3) ? '아기야 안녕?' :
  (age < 18) ? '안녕!' :
  (age < 100) ? '환영합니다!' :
  '나이가 아주 많으시거나, 나이가 아닌 값을 입력 하셨군요!';

alert( message );

물음표 연산자를 이런 방식으로 쓰는 걸 처음 본 분이라면 이 코드가 어떻게 동작하는지 파악하기 힘들 수 있습니다. 그러나 주의를 집중하고 보면, 단순히 여러 조건을 나열한 코드임에 불과하다는 것을 알 수 있습니다.

  1. 첫 번째 물음표에선 조건문 age < 3을 검사합니다.
  2. 그 결과가 참이면 '아기야 안녕?'를 반환합니다. 그렇지 않다면 첫 번째 콜론 ":"에 이어지는 조건문 age < 18을 검사합니다.
  3. 그 결과가 참이면 '안녕!'를 반환합니다. 그렇지 않다면 다음 콜론 ":"에 이어지는 조건문 age < 100을 검사합니다.
  4. 그 결과가 참이면 '환영합니다!'를 반환합니다. 그렇지 않다면 마지막 콜론 ":" 이후의 표현식인 '나이가 아닌 값을 입력 하셨군요!'를 반환합니다.

if..else를 사용하면 위 예시를 아래와 같이 변형할 수 있습니다.

if (age < 3) {
  message = '아기야 안녕?';
} else if (age < 18) {
  message = '안녕!';
} else if (age < 100) {
  message = '환영합니다!';
} else {
  message = '나이가 아닌 값을 입력 하셨군요!';
}

부적절한 '?'

let company = prompt('자바스크립트는 어떤 회사가 만들었을까요?', '');
(company == 'Netscape') ?
   alert('정답입니다!') : alert('오답입니다!');

조건 company == 'Netscape'의 검사 결과에 따라 ? 뒤에 이어지는 첫 번째 혹은 두 번째 표현식이 실행되어 얼럿 창이 뜹니다.

위 예시에선 평가 결과를 변수에 할당하지 않고, 결과에 따라 실행되는 표현식이 달라지도록 하였습니다.

그런데 이런 식으로 물음표 연산자를 사용하는 것은 좋지 않습니다.

개발자 입장에선 if문을 사용할 때 보다 코드 길이가 짧아진다는 점 때문에 물음표?를 if 대용으로 쓰는 게 매력적일 순 있습니다. 하지만 이렇게 코드를 작성하면 가독성이 떨어집니다.

아래는 if를 사용해 변형한 코드입니다. 어느 코드가 더 읽기 쉬운지 직접 비교해 보시기 바랍니다.

let company = prompt('자바스크립트는 어떤 회사가 만들었을까요?', '');

*if (company == 'Netscape') {
  alert('정답입니다!');
} else {
  alert('오답입니다!');
}*

코드를 읽을 때 우리의 눈은 수직으로 움직입니다. 수평으로 길게 늘어진 코드보단 여러 줄로 나뉘어 작성된 코드 블록이 더 읽기 쉽죠.

물음표 연산자?는 조건에 따라 반환 값을 달리하려는 목적으로 만들어졌습니다. 이런 목적에 부합하는 곳에 물음표를 사용하시길 바랍니다. 여러 분기를 만들어 처리할 때는 if를 사용하세요.

2.11 논리 연산자

자바스크립트엔 세 종류의 논리 연산자 ||(OR), &&(AND), !(NOT)이 있습니다.

|| (OR)

인수 중 하나라도 true이면 true를 반환하고, 그렇지 않으면 false를 반환합니다.

alert( true || true );   // true
alert( false || true );  // true
alert( true || false );  // true
alert( false || false ); // false
if (1 || 0) { // if( true || false ) 와 동일하게 동작합니다.
  alert( 'truthy!' );
}
let hour = 9;

*if (hour < 10 || hour > 18) {*alert( '영업시간이 아닙니다.' );
}

if문 안에 여러 가지 조건을 넣을 수 있습니다.

let hour = 12;
let isWeekend = true;

if (hour < 10 || hour > 18 || isWeekend) {
  alert( '영업시간이 아닙니다.' ); // 주말이기 때문임
}

첫 번째 truthy를 찾는 OR 연산 '||'

OR 연산자와 피연산자가 여러 개인 경우:

result = value1 || value2 || value3;

이때, OR ||연산자는 다음 순서에 따라 연산을 수행합니다.

  • 가장 왼쪽 피연산자부터 시작해 오른쪽으로 나아가며 피연산자를 평가합니다.
  • 각 피연산자를 불린형으로 변환합니다. 변환 후 그 값이 true이면 연산을 멈추고 해당 피연산자의 변환 전 원래 값을 반환합니다.
  • 피연산자 모두를 평가한 경우(모든 피연산자가 false로 평가되는 경우)엔 마지막 피연산자를 반환합니다.
  1. 변수 또는 표현식으로 구성된 목록에서 첫 번째 truthy 얻기

    firstNamelastNamenickName이란 변수가 있는데 이 값들은 모두 옵션 값이라고 해봅시다.

    OR ||을 사용하면 실제 값이 들어있는 변수를 찾고, 그 값을 보여줄 수 있습니다. 변수 모두에 값이 없는 경우엔 익명를 보여줍시다.

    let firstName = "";
    let lastName = "";
    let nickName = "바이올렛";
    
    *alert( firstName || lastName || nickName || "익명"); // 바이올렛*

    모든 변수가 falsy이면 "익명"이 출력되었을 겁니다.

  2. 단락 평가

    OR 연산자 ||가 제공하는 또 다른 기능은 '단락 평가(short circuit evaluation)'입니다.

    위에서 설명해 드린 바와 같이 OR||은 왼쪽부터 시작해서 오른쪽으로 평가를 진행하는데, truthy를 만나면 나머지 값들은 건드리지 않은 채 평가를 멈춥니다. 이런 프로세스를 '단락 평가’라고 합니다.

    단락 평가의 동작 방식은 두 번째 피연산자가 변수 할당과 같은 부수적인 효과(side effect)를 가지는 표현식 일 때 명확히 볼 수 있습니다.

    아래 예시를 실행하면 두 번째 메시지만 출력됩니다.

    *true* || alert("not printed");
    *false* || alert("printed");

    첫 번째 줄의 || 연산자는 true를 만나자마자 평가를 멈추기 때문에 alert가 실행되지 않습니다.

    단락 평가는 연산자 왼쪽 조건이 falsy일 때만 명령어를 실행하고자 할 때 자주 쓰입니다.

&& (AND)

전통적인 프로그래밍에서 AND 연산자는 두 피연산자가 모두가 참일 때 true를 반환합니다. 그 외의 경우는 false를 반환하죠.

alert( true && true );   // true
alert( false && true );  // false
alert( true && false );  // false
alert( false && false ); // false

아래는 if문과 AND 연산자를 함께 활용한 예제입니다.

let hour = 12;
let minute = 30;

if (hour == 12 && minute == 30) {
  alert( '현재 시각은 12시 30분입니다.' );
}
if (1 && 0) { // 피연산자가 숫자형이지만 논리형으로 바뀌어 true && false가 됩니다.
  alert( "if 문 안에 falsy가 들어가 있으므로 alert창은 실행되지 않습니다." );
}

첫 번째 falsy를 찾는 AND 연산자 ‘&&’

AND 연산자와 피연산자가 여러 개인 경우를 살펴봅시다.

result = value1 && value2 && value3;

AND 연산자 &&는 아래와 같은 순서로 동작합니다.

  • 가장 왼쪽 피연산자부터 시작해 오른쪽으로 나아가며 피연산자를 평가합니다.
  • 각 피연산자는 불린형으로 변환됩니다. 변환 후 값이 false이면 평가를 멈추고 해당 피연산자의 변환 전 원래 값을 반환합니다.
  • 피연산자 모두가 평가되는 경우(모든 피연산자가 true로 평가되는 경우)엔 마지막 피연산자가 반환됩니다.

정리해 보자면 이렇습니다. AND 연산자는 첫 번째 falsy를 반환합니다. 피연산자에 falsy가 없다면 마지막 값을 반환합니다.

위 알고리즘은 OR 연산자의 알고리즘과 유사합니다. 차이점은 AND 연산자가 첫 번째 falsy를 반환하는 반면, OR은 첫 번째 truthy를 반환한다는 것입니다.

// 첫 번째 피연산자가 truthy이면,
// AND는 두 번째 피연산자를 반환합니다.
alert( 1 && 0 ); // 0
alert( 1 && 5 ); // 5

// 첫 번째 피연산자가 falsy이면,
// AND는 첫 번째 피연산자를 반환하고, 두 번째 피연산자는 무시합니다.
alert( null && 5 ); // null
alert( 0 && "아무거나 와도 상관없습니다." ); // 0

AND 연산자에도 피연산자 여러 개를 연속해서 전달할 수 있습니다. 첫 번째 falsy가 어떻게 반환되는지 예시를 통해 살펴봅시다.

alert( 1 && 2 && null && 3 ); // null

아래 예시에선 AND 연산자의 피연산자가 모두 truthy이기 때문에 마지막 피연산자가 반환됩니다.

alert( 1 && 2 && 3 ); // 마지막 값, 3

ℹ️**&&의 우선순위가 ||보다 높습니다.**

AND 연산자 &&의 우선순위는 OR 연산자 ||보다 높습니다.

따라서 a && b || c && d는 (a && b) || (c && d)와 동일하게 동작합니다.

⚠️**if를 ||나 &&로 대체하지 마세요.**

어떤 개발자들은 AND 연산자 &&를 if문을 ‘짧게’ 줄이는 용도로 사용하곤 합니다.

let x = 1;

(x > 0) && alert( '0보다 큽니다!' );

&&의 오른쪽 피연산자는 평가가 && 우측까지 진행되어야 실행됩니다. 즉, (x > 0)이 참인 경우에만 alert문이 실행되죠.

위 코드를 if 문을 써서 바꾸면 다음과 같습니다.

let x = 1;

if (x > 0) alert( '0보다 큽니다!' );

&&를 사용한 코드가 더 짧긴 하지만 if문을 사용한 예시가 코드에서 무엇을 구현하고자 하는지 더 명백히 드러내고, 가독성도 좋습니다. 그러니 if 조건문이 필요하면 if를 사용하고 AND 연산자는 연산자 목적에 맞게 사용합시다.

! (NOT)

NOT 연산자의 문법은 매우 간단합니다.

result = !value;

NOT 연산자는 인수를 하나만 받고, 다음 순서대로 연산을 수행합니다.

  1. 피연산자를 불린형(true / false)으로 변환합니다.
  2. 1에서 변환된 값의 역을 반환합니다.
alert( !true ); // false
alert( !0 ); // true

NOT을 두 개 연달아 사용(!!)하면 값을 불린형으로 변환할 수 있습니다.

alert( !!"non-empty string" ); // true
alert( !!null ); // false

참고로, 내장 함수 Boolean을 사용하면 !!을 사용한 것과 같은 결과를 도출할 수 있습니다.

alert( Boolean("non-empty string") ); // true
alert( Boolean(null) ); // false

NOT 연산자의 우선순위는 모든 논리 연산자 중에서 가장 높기 때문에 항상 &&나 || 보다 먼저 실행됩니다.

2.12 null 병합 연산자 '??'

⚠️최근에 추가됨

스펙에 추가된 지 얼마 안 된 문법입니다. 구식 브라우저는 폴리필이 필요합니다.

null 병합 연산자(nullish coalescing operator) ??를 사용하면 짧은 문법으로 여러 피연산자 중 그 값이 ‘확정되어있는’ 변수를 찾을 수 있습니다.

a ?? b의 평가 결과는 다음과 같습니다.

  • a가 null도 아니고 undefined도 아니면 a
  • 그 외의 경우는 b

null 병합 연산자 ??없이 x = a ?? b와 동일한 동작을 하는 코드를 작성하면 다음과 같습니다.

x = (a !== null && a !== undefined) ? a : b;
let firstName = null;
let lastName = null;
let nickName = "Supercoder";

// null이나 undefined가 아닌 첫 번째 피연산자
alert(firstName ?? lastName ?? nickName ?? "Anonymous"); // Supercoder

'??'와 '||'의 차이

null 병합 연산자는 OR 연산자 ||와 상당히 유사해 보입니다. 실제로 위 예시에서 ??를 ||로 바꿔도 그 결과는 동일하기까지 하죠. 이전 챕터에서 관련 내용을 살펴본 바 있습니다.

그런데 두 연산자 사이에는 중요한 차이점이 있습니다.

  • ||는 첫 번째 truthy 값을 반환합니다.
  • ??는 첫 번째 정의된(defined) 값을 반환합니다.

null과 undefined, 숫자 0을 구분 지어 다뤄야 할 때 이 차이점은 매우 중요한 역할을 합니다.

예시를 살펴봅시다.

height = height ?? 100;

height에 값이 정의되지 않았다면 height엔 100이 할당됩니다.

이제 ??와 ||을 비교해봅시다.

let height = 0;

alert(height || 100); // 100
alert(height ?? 100); // 0

연산자 우선순위

[??의 연산자 우선순위](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#Table)는 `5`로 꽤 낮습니다.

따라서 ??는 =와 ? 보다는 먼저, 대부분의 연산자보다는 나중에 평가됩니다.

그렇기 때문에 복잡한 표현식 안에서 ??를 사용해 값을 하나 선택할 땐 괄호를 추가하는 게 좋습니다.

let height = null;
let width = null;

// 괄호를 추가!
let area = (height ?? 100) * (width ?? 50);

alert(area); // 5000

그렇지 않으면 *가 ??보다 우선순위가 높기 때문에 *가 먼저 실행됩니다.

결국엔 아래 예시처럼 동작하겠죠.

// 원치 않는 결과
let area = height ?? (100 * width) ?? 50;

??엔 자바스크립트 언어에서 규정한 또 다른 제약사항이 있습니다.

안정성 관련 이슈 때문에 ??는 &&나 ||와 함께 사용하지 못합니다.

요약

  • null 병합 연산자 ??를 사용하면 피연산자 중 ‘값이 할당된’ 변수를 빠르게 찾을 수 있습니다.

    ??는 변수에 기본값을 할당하는 용도로 사용할 수 있습니다.

    // height가 null이나 undefined인 경우, 100을 할당
    height = height ?? 100;
  • ??의 연산자 우선순위는 대다수의 연산자보다 낮고 ?와 = 보다는 높습니다.

  • 괄호 없이 ??를 ||나 &&와 함께 사용하는 것은 금지되어있습니다.

2.13 while과 for 반복문

'while' 반복문

while (condition) {
  // 코드
  // '반복문 본문(body)'이라 불림
}

condition(조건)이 truthy 이면 반복문 본문의 코드가 실행됩니다.

아래 반복문은 조건 i < 3을 만족할 동안 i를 출력해줍니다.

let i = 0;
while (i < 3) { // 0, 1, 2가 출력됩니다.
  alert( i );
  i++;
}
let i = 3;
*while (i) { // i가 0이 되면 조건이 falsy가 되므로 반복문이 멈춥니다.*
	alert( i );
  i--;
}

본문이 한 줄이면 대괄호를 쓰지 않아도 됩니다.

반복문 본문이 한 줄짜리 문이라면 대괄호 {…}를 생략할 수 있습니다.

let i = 3;
*while (i) alert(i--);*

'do...while' 반복문

do {
  // 반복문 본문
} while (condition);
let i = 0;
do {
  alert( i );
  i++;
} while (i < 3);

do..while 문법은 조건이 truthy 인지 아닌지에 상관없이, 본문을 최소한 한번이라도 실행하고 싶을 때만 사용해야 합니다. 대다수의 상황에선 do..while보다 while(…) {…}이 적합합니다.

'for' 반복문

for (begin; condition; step) {
  // ... 반복문 본문 ...
}
for (let i = 0; i < 3; i++) { // 0, 1, 2가 출력됩니다.
  alert(i);
}

for문 구성요소

일반적인 반복문 알고리즘은 다음과 같습니다.

begin을 실행함
 (condition이 truthy이면  body를 실행한 , step을 실행함)
 (condition이 truthy이면  body를 실행한 , step을 실행함)
 (condition이 truthy이면  body를 실행한 , step을 실행함)
 ...
// for (let i = 0; i < 3; i++) alert(i)

// begin을 실행함
let i = 0
// condition이 truthy이면 → body를 실행한 후, step을 실행함
if (i < 3) { alert(i); i++ }
// condition이 truthy이면 → body를 실행한 후, step을 실행함
if (i < 3) { alert(i); i++ }
// condition이 truthy이면 → body를 실행한 후, step을 실행함
if (i < 3) { alert(i); i++ }
// i == 3이므로 반복문 종료

ℹ️ 인라인 변수 선언

지금까진 ‘카운터’ 변수 i를 반복문 안에서 선언하였습니다. 이런 방식을 ‘인라인’ 변수 선언이라고 부릅니다. 이렇게 선언한 변수는 반복문 안에서만 접근할 수 있습니다.

for (*let* i = 0; i < 3; i++) {
  alert(i); // 0, 1, 2
}
alert(i); // Error: i is not defined

인라인 변수 선언 대신, 정의되어있는 변수를 사용할 수도 있습니다.

let i = 0;

for (i = 0; i < 3; i++) { // 기존에 정의된 변수 사용
  alert(i); // 0, 1, 2
}

alert(i); // 3, 반복문 밖에서 선언한 변수이므로

구성 요소 생략하기

for문의 구성 요소를 생략하는 것도 가능합니다.

반복문이 시작될 때 아무것도 할 필요가 없으면 begin을 생략하는 것이 가능하죠.

let i = 0; // i를 선언하고 값도 할당하였습니다.

for (; i < 3; i++) { // 'begin'이 필요하지 않기 때문에 생략하였습니다.
  alert( i ); // 0, 1, 2
}

step 역시 생략할 수 있습니다.

let i = 0;

for (; i < 3;) {
  alert( i++ );
}

위와 같이 for문을 구성하면 while (i < 3)과 동일해집니다.

모든 구성 요소를 생략할 수도 있는데, 이렇게 되면 무한 반복문이 만들어집니다.

for (;;) {
  // 끊임 없이 본문이 실행됩니다.
}

for문의 구성요소를 생략할 때 주의할 점은 두 개의 ; 세미콜론을 꼭 넣어주어야 한다는 점입니다. 하나라도 없으면 문법 에러가 발생합니다.

반복문 빠져나오기

특별한 지시자인 break를 사용하면 언제든 원하는 때에 반복문을 빠져나올 수 있습니다.

아래 예시의 반복문은 사용자에게 일련의 숫자를 입력하도록 안내하고, 사용자가 아무런 값도 입력하지 않으면 반복문을 '종료’합니다.

let sum = 0;

while (true) {

  let value = +prompt("숫자를 입력하세요.", '');

  *if (!value) break; // (*)*

  sum += value;

}
alert( '합계: ' + sum );

다음 반복으로 넘어가기

continue는 현재 반복을 종료시키고 다음 반복으로 넘어가고 싶을 때 사용할 수 있습니다.

아래 반복문은 continue를 사용해 홀수만 출력합니다.

for (let i = 0; i < 10; i++) {

  // 조건이 참이라면 남아있는 본문은 실행되지 않습니다.
  *if (i % 2 == 0) continue;*alert(i); // 1, 3, 5, 7, 9가 차례대로 출력됨
}

break/continue와 레이블

i와 j를 반복하면서 프롬프트 창에 (0,0)부터 (2,2)까지를 구성하는 좌표 (i, j)를 입력하게 해주는 예시를 살펴봅시다.

for (let i = 0; i < 3; i++) {

  for (let j = 0; j < 3; j++) {

    let input = prompt(`(${i},${j})의 값`, '');

    // 여기서 멈춰서 아래쪽의 `완료!`가 출력되게 하려면 어떻게 해야 할까요?
  }
}

alert('완료!');

사용자가 Cancel 버튼을 눌렀을 때 반복문을 중단시킬 방법이 필요합니다.

input 아래에 평범한 break 지시자를 사용하면 안쪽에 있는 반복문만 빠져나올 수 있습니다. 이것만으론 충분하지 않습니다(중첩 반복문을 포함한 반복문 두 개 모두를 빠져나와야 하기 때문이죠 – 옮긴이). 이럴 때 레이블을 사용할 수 있습니다.

레이블(label) 은 반복문 앞에 콜론과 함께 쓰이는 식별자입니다.

labelName: for (...) {
  ...
}

반복문 안에서 break <labelName>문을 사용하면 레이블에 해당하는 반복문을 빠져나올 수 있습니다.

*outer:* for (let i = 0; i < 3; i++) {

  for (let j = 0; j < 3; j++) {

    let input = prompt(`(${i},${j})의 값`, '');

    // 사용자가 아무것도 입력하지 않거나 Cancel 버튼을 누르면 두 반복문 모두를 빠져나옵니다.
    if (!input) *break outer*; // (*)

    // 입력받은 값을 가지고 무언가를 함
  }
}
alert('완료!');

위 예시에서 break outer는 outer라는 레이블이 붙은 반복문을 찾고, 해당 반복문을 빠져나오게 해줍니다.

따라서 제어 흐름이 (*)에서 alert('완료!')로 바로 바뀝니다.

레이블을 별도의 줄에 써주는 것도 가능합니다.

outer:
for (let i = 0; i < 3; i++) { ... }

continue 지시자를 레이블과 함께 사용하는 것도 가능합니다. 두 가지를 같이 사용하면 레이블이 붙은 반복문의 다음 이터레이션이 실행됩니다.

요약

  • while – 각 반복이 시작하기 전에 조건을 확인합니다.
  • do..while – 각 반복이 끝난 후에 조건을 확인합니다.
  • for (;;) – 각 반복이 시작하기 전에 조건을 확인합니다. 추가 세팅을 할 수 있습니다.

‘무한’ 반복문은 보통 while(true)를 써서 만듭니다. 무한 반복문은 여타 반복문과 마찬가지로 break 지시자를 사용해 멈출 수 있습니다.

현재 실행 중인 반복에서 더는 무언가를 하지 않고 다음 반복으로 넘어가고 싶다면 continue 지시자를 사용할 수 있습니다.

반복문 앞에 레이블을 붙이고, break/continue에 이 레이블을 함께 사용할 수 있습니다. 레이블은 중첩 반복문을 빠져나와 바깥의 반복문으로 갈 수 있게 해주는 유일한 방법입니다.

2.14 switch문

복수의 if 조건문은 switch문으로 바꿀 수 있습니다.

switch문을 사용한 비교법은 특정 변수를 다양한 상황에서 비교할 수 있게 해줍니다. 코드 자체가 비교 상황을 잘 설명한다는 장점도 있습니다.

switch문은 하나 이상의 case문으로 구성됩니다. 대개 default문도 있지만, 이는 필수는 아닙니다.

switch(x) {
  case 'value1':  // if (x === 'value1')
    ...
    [break]

  case 'value2':  // if (x === 'value2')
    ...
    [break]

  default:
    ...
    [break]
}
  • 변수 x의 값과 첫 번째 case문의 값 'value1'를 일치 비교한 후, 두 번째 case문의 값 'value2'와 비교합니다. 이런 과정은 계속 이어집니다.
  • case문에서 변수 x의 값과 일치하는 값을 찾으면 해당 case 문의 아래의 코드가 실행됩니다. 이때, break문을 만나거나 switch 문이 끝나면 코드의 실행은 멈춥니다.
  • 값과 일치하는 case문이 없다면, default문 아래의 코드가 실행됩니다(default 문이 있는 경우).
let a = 2 + 2;

switch (a) {
  case 3:
    alert( '비교하려는 값보다 작습니다.' );
    break;
  *case 4:
    alert( '비교하려는 값과 일치합니다.' );
    break;*case 5:
    alert( '비교하려는 값보다 큽니다.' );
    break;
  default:
    alert( "어떤 값인지 파악이 되지 않습니다." );
}

switch문은 a의 값인 4와 첫 번째 case문의 값인 3을 비교합니다. 두 값은 같지 않기 때문에 다음 case문으로 넘어갑니다.

a와 그다음 case문의 값인 4는 일치합니다. 따라서 break문을 만날 때까지 case 4 아래의 코드가 실행됩니다.

case문 안에 break문이 없으면 조건에 부합하는지 여부를 따지지 않고 이어지는 case문을 실행합니다.

break문이 없는 경우 어떤 일이 일어나는지 예시를 통해 살펴봅시다.

let a = 2 + 2;

switch (a) {
  case 3:
    alert( '비교하려는 값보다 작습니다.' );
  *case 4:
    alert( '비교하려는 값과 일치합니다.' );
  case 5:
    alert( '비교하려는 값보다 큽니다.' );
  default:
    alert( "어떤 값인지 파악이 되지 않습니다." );*}

위 예시를 실행하면 아래 3개의 alert문이 실행됩니다.

alert( '비교하려는 값과 일치합니다.' );
alert( '비교하려는 값보다 큽니다.' );
alert( "어떤 값인지 파악이 되지 않습니다." );

여러 개의 "case"문 묶기

case 3과 case 5에서 실행하려는 코드가 같은 경우에 대한 예시를 살펴봅시다.

let a = 3;

switch (a) {
  case 4:
    alert('계산이 맞습니다!');
    break;

  *case 3: // (*) 두 case문을 묶음
  case 5:
    alert('계산이 틀립니다!');
    alert("수학 수업을 다시 들어보는걸 권유 드립니다.");
    break;*

	default:
    alert('계산 결과가 이상하네요.');
}

자료형의 중요성

let arg = prompt("값을 입력해주세요.");
switch (arg) {
  case '0':
  case '1':
    alert( '0이나 1을 입력하셨습니다.' );
    break;

  case '2':
    alert( '2를 입력하셨습니다.' );
    break;

  case 3:
    alert( '이 코드는 절대 실행되지 않습니다!' );
    break;
  default:
    alert( '알 수 없는 값을 입력하셨습니다.' );
}
  1. 0이나 1을 입력한 경우엔 첫 번째 alert문이 실행됩니다.
  2. 2를 입력한 경우엔 두 번째 alert문이 실행됩니다.
  3. 3을 입력하였더라도 세 번째 alert문은 실행되지 않습니다. 앞서 배운 바와 같이 prompt 함수는 사용자가 입력 필드에 기재한 값을 문자열로 변환해 반환하기 때문에 숫자 3을 입력하더라도 prompt 함수는 문자열 '3'을 반환합니다. 그런데 세 번째 case문에선 사용자가 입력한 값과 숫자형 3을 비교하므로, 형 자체가 다르기 때문에 case 3 아래의 코드는 절대 실행되지 않습니다. 대신 default문이 실행됩니다.

2.15 함수

함수는 프로그램을 구성하는 주요 '구성 요소(building block)'입니다. 함수를 이용하면 중복 없이 유사한 동작을 하는 코드를 여러 번 호출할 수 있습니다.

함수 선언

함수 선언(function declaration) 방식을 이용하면 함수를 만들 수 있습니다(함수 선언 방식은 함수 선언문이라고 부르기도 합니다 – 옮긴이).

함수 선언 방식은 아래와 같이 작성할 수 있습니다.

function showMessage() {
  alert( '안녕하세요!' );
}
function name(parameters) {
  ...함수 본문...
}

새롭게 정의한 함수는 함수 이름 옆에 괄호를 붙여 호출할 수 있습니다. showMessage()같이 말이죠.

함수의 주요 용도 중 하나는 중복 코드 피하기입니다

지역 변수

함수 내에서 선언한 변수인 지역 변수(local variable)는 함수 안에서만 접근할 수 있습니다.

function showMessage() {
	let message = "안녕하세요!"; // 지역 변수

	**alert( message );
}

showMessage(); // 안녕하세요!

alert( message ); // ReferenceError: message is not defined (message는 함수 내 지역 변수이기 때문에 에러가 발생합니다.)

외부 변수

함수 내부에서 함수 외부의 변수인 외부 변수(outer variable)에 접근할 수 있습니다.

let *userName* = 'John';

function showMessage() {
  let message = 'Hello, ' + *userName*;
  alert(message);
}

showMessage(); // Hello, John

함수에선 외부 변수에 접근하는 것뿐만 아니라, 수정도 할 수 있습니다.

let *userName* = 'John';

function showMessage() {
  *userName* = "Bob"; // (1) 외부 변수를 수정함

  let message = 'Hello, ' + *userName*;
  alert(message);
}

alert( userName ); // 함수 호출 전이므로 *John* 이 출력됨

showMessage();

alert( userName ); // 함수에 의해 *Bob* 으로 값이 바뀜

외부 변수는 지역 변수가 없는 경우에만 사용할 수 있습니다.

함수 내부에 외부 변수와 동일한 이름을 가진 변수가 선언되었다면, 내부 변수는 외부 변수를 가립니다. 예시를 살펴봅시다. 함수 내부에 외부 변수와 동일한 이름을 가진 지역 변수 userName가 선언되어 있습니다. 외부 변수는 내부 변수에 가려져 값이 수정되지 않았습니다.

let userName = 'John';

function showMessage() {
  *let userName = "Bob"; // 같은 이름을 가진 지역 변수를 선언합니다.*let message = 'Hello, ' + userName; // *Bob*alert(message);
}

// 함수는 내부 변수인 userName만 사용합니다,
showMessage();

alert( userName ); // 함수는 외부 변수에 접근하지 않습

ℹ️전역 변수

위 예시의 userName처럼, 함수 외부에 선언된 변수는 전역 변수(global variable) 라고 부릅니다.

전역 변수는 같은 이름을 가진 지역 변수에 의해 가려지지만 않는다면 모든 함수에서 접근할 수 있습니다.

변수는 연관되는 함수 내에 선언하고, 전역 변수는 되도록 사용하지 않는 것이 좋습니다. 비교적 근래에 작성된 코드들은 대부분 전역변수를 사용하지 않거나 최소한으로만 사용합니다. 다만 프로젝트 전반에서 사용되는 데이터는 전역 변수에 저장하는 것이 유용한 경우도 있으니 이 점을 알아두시기 바랍니다.

매개변수

매개변수(parameter)를 이용하면 임의의 데이터를 함수 안에 전달할 수 있습니다. 매개변수는 인수(argument) 라고 불리기도 합니다(매개변수와 인수는 엄밀히 같진 않지만, 튜토리얼 원문을 토대로 번역하였습니다 – 옮긴이).

아래 예시에서 함수 showMessage는 매개변수 from 과 text를 가집니다.

function showMessage(*from, text*) { // 인수: from, text
  alert(from + ': ' + text);
}

*showMessage('Ann', 'Hello!'); // Ann: Hello! (*)
showMessage('Ann', "What's up?"); // Ann: What's up? (**)*

(*)(**)로 표시한 줄에서 함수를 호출하면, 함수에 전달된 인자는 지역변수 from과 text에 복사됩니다. 그 후 함수는 지역변수에 복사된 값을 사용합니다.

function showMessage(from, text) {

  *from = '*' + from + '*'; // "from"을 좀 더 멋지게 꾸며줍니다.*alert( from + ': ' + text );
}

let from = "Ann";

showMessage(from, "Hello"); // *Ann*: Hello

// 함수는 복사된 값을 사용하기 때문에 바깥의 "from"은 값이 변경되지 않습니다.
alert( from ); // Ann

기본값

매개변수에 값을 전달하지 않으면 그 값은 undefined가 됩니다.

매개변수에 값을 전달하지 않아도 그 값이 undefined가 되지 않게 하려면 '기본값(default value)'을 설정해주면 됩니다. 매개변수 오른쪽에 =을 붙이고 undefined 대신 설정하고자 하는 기본값을 써주면 되죠.

function showMessage(from, *text = "no text given"*) {
  alert( from + ": " + text );
}

showMessage("Ann"); // Ann: no text given

이젠 text가 값을 전달받지 못해도 undefined대신 기본값 "no text given"이 할당됩니다.

위 예시에선 문자열 "no text given"을 기본값으로 설정했습니다. 하지만 아래와 같이 복잡한 표현식도 기본값으로 설정할 수도 있습니다.

function showMessage(from, text = anotherFunction()) {
  // anotherFunction()은 text값이 없을 때만 호출됨
  // anotherFunction()의 반환 값이 text의 값이 됨
}

매개변수 기본값을 설정할 수 있는 또 다른 방법

가끔은 함수 선언부에서 매개변수 기본값을 설정하는 것 대신 함수가 실행되는 도중에 기본값을 설정하는 게 논리에 맞는 경우가 생기기도 합니다.

이런 경우엔 일단 매개변수를 undefined와 비교하여 함수 호출 시 매개변수가 생략되었는지를 확인합니다.

function showMessage(text) {
  *if (text === undefined) {
    text = '빈 문자열';
  }*alert(text);
}

showMessage(); // 빈 문자열

이렇게 if문을 쓰는 것 대신 논리 연산자 ||를 사용할 수도 있습니다.

// 매개변수가 생략되었거나 빈 문자열("")이 넘어오면 변수에 '빈 문자열'이 할당됩니다.
function showMessage(text) {
  text = text || '빈 문자열';
  ...
}

이 외에도 모던 자바스크립트 엔진이 지원하는 null 병합 연산자(nullish coalescing operator) ??를 사용하면 0처럼 falsy로 평가되는 값들을 일반 값처럼 처리할 수 있어서 좋습니다.

// 매개변수 'count'가 넘어오지 않으면 'unknown'을 출력해주는 함수
function showCount(count) {
  alert(count ?? "unknown");
}

showCount(0); // 0
showCount(null); // unknown
showCount(); // unknown

반환 값

함수를 호출했을 때 함수를 호출한 그곳에 특정 값을 반환하게 할 수 있습니다. 이때 이 특정 값을 반환 값(return value)이라고 부릅니다.

인수로 받은 두 값을 더해주는 간단한 함수를 만들어 반환 값에 대해 알아보도록 하겠습니다.

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

let result = sum(1, 2);
alert( result ); // 3

지시자 return은 함수 내 어디서든 사용할 수 있습니다. 실행 흐름이 지시자 return을 만나면 함수 실행은 즉시 중단되고 함수를 호출한 곳에 값을 반환합니다. 위 예시에선 반환 값을 result에 할당하였습니다.

아래와 같이 함수 하나에 여러 개의 return문이 올 수도 있습니다.

function checkAge(age) {
  if (age >= 18) {
    *return true;*
	} else {
    *return confirm('보호자의 동의를 받으셨나요?');*
	}
}

let age = prompt('나이를 알려주세요', 18);

if ( checkAge(age) ) {
  alert( '접속 허용' );
} else {
  alert( '접속 차단' );
}

아래와 같이 지시자 return만 명시하는 것도 가능합니다. 이런 경우는 함수가 즉시 종료됩니다.

function showMovie(age) {
  if ( !checkAge(age) ) {
    *return;*}

  alert( "영화 상영" ); // (*)
  // ...
}

위 예시에서, checkAge(age)가 false를 반환하면, (*)로 표시한 줄은 실행이 안 되기 때문에 함수 showMovie는 얼럿 창을 보여주지 않습니다.

함수 이름짓기

함수가 어떤 동작을 하는지 축약해서 설명해주는 동사를 접두어로 붙여 함수 이름을 만드는 게 관습입니다. 다만, 팀 내에서 그 뜻이 반드시 합의된 접두어만 사용해야 합니다.

"show"로 시작하는 함수는 대개 무언가를 보여주는 함수입니다.

이 외에 아래와 같은 접두어를 사용할 수 있습니다.

  • "get…" – 값을 반환함
  • "calc…" – 무언가를 계산함
  • "create…" – 무언가를 생성함
  • "check…" – 무언가를 확인하고 불린값을 반환함

위 접두어를 사용하면 아래와 같은 함수를 만들 수 있습니다.

showMessage(..)     // 메시지를 보여줌
getAge(..)          // 나이를 나타내는 값을 얻고 그 값을 반환함
calcSum(..)         // 합계를 계산하고 그 결과를 반환함
createForm(..)      // form을 생성하고 만들어진 form을 반환함
checkPermission(..) // 승인 여부를 확인하고 true나 false를 반환함

접두어를 적절히 활용하면 함수 이름만 보고도 함수가 어떤 동작을 하고 어떤 값을 반환하는지 쉽게 알 수 있습니다.

함수 == 주석

함수는 간결하고, 한 가지 기능만 수행할 수 있게 만들어야 합니다. 함수가 길어지면 함수를 잘게 쪼갤 때가 되었다는 신호로 받아들이셔야 합니다. 함수를 쪼개는 건 쉬운 작업은 아닙니다. 하지만 함수를 분리해 작성하면 많은 장점이 있기 때문에 함수가 길어질 경우엔 함수를 분리해 작성할 것을 권유합니다.

함수를 간결하게 만들면 테스트와 디버깅이 쉬워집니다. 그리고 함수 그 자체로 주석의 역할까지 합니다!

같은 동작을 하는 함수, showPrimes(n)를 두 개 만들어 비교해 봅시다. showPrimes(n)은 n까지의 소수(prime numbers)를 출력해줍니다.

첫 번째 showPrimes(n)에선 레이블을 사용해 반복문을 작성해보았습니다.

function showPrimes(n) {
  nextPrime: for (let i = 2; i < n; i++) {

    for (let j = 2; j < i; j++) {
      if (i % j == 0) continue nextPrime;
    }

    alert( i ); // 소수
  }
}

두 번째 showPrimes(n)는 소수인지 아닌지 여부를 검증하는 코드를 따로 분리해 isPrime(n)이라는 함수에 넣어서 작성했습니다.

function showPrimes(n) {

  for (let i = 2; i < n; i++) {
    *if (!isPrime(i)) continue;*alert(i);  // a prime
  }
}

function isPrime(n) {
  for (let i = 2; i < n; i++) {
    if ( n % i == 0) return false;
  }
  return true;
}

두 번째 showPrimes(n)가 더 이해하기 쉽지 않나요? isPrime 함수 이름을 보고 해당 함수가 소수 여부를 검증하는 동작을 한다는 걸 쉽게 알 수 있습니다. 이렇게 이름만 보고도 어떤 동작을 하는지 알 수 있는 코드를 자기 설명적(self-describing) 코드라고 부릅니다.

위와 같이 함수는 중복을 없애려는 용도 외에도 사용할 수 있습니다. 이렇게 함수를 활용하면 코드가 정돈되고 가독성이 높아집니다.

요약

함수 선언 방식으로 함수를 만들 수 있습니다.

function 함수이름(복수의, 매개변수는, 콤마로, 구분합니다) {
  /* 함수 본문 */
}
  • 함수에 전달된 매개변수는 복사된 후 함수의 지역변수가 됩니다.
  • 함수는 외부 변수에 접근할 수 있습니다. 하지만 함수 바깥에서 함수 내부의 지역변수에 접근하는 건 불가능합니다.
  • 함수는 값을 반환할 수 있습니다. 값을 반환하지 않는 경우는 반환 값이 undefined가 됩니다.

깔끔하고 이해하기 쉬운 코드를 작성하려면 함수 내부에서 외부 변수를 사용하는 방법 대신 지역 변수와 매개변수를 활용하는 게 좋습니다.

개발자는 매개변수를 받아서 그 변수를 가지고 반환 값을 만들어 내는 함수를 더 쉽게 이해할 수 있습니다. 매개변수 없이 함수 내부에서 외부 변수를 수정해 반환 값을 만들어 내는 함수는 쉽게 이해하기 힘듭니다.

함수 이름을 지을 땐 아래와 같은 규칙을 따르는 것이 좋습니다.

  • 함수 이름은 함수가 어떤 동작을 하는지 설명할 수 있어야 합니다. 이렇게 이름을 지으면 함수 호출 코드만 보아도 해당 함수가 무엇을 하고 어떤 값을 반환할지 바로 알 수 있습니다.
  • 함수는 동작을 수행하기 때문에 이름이 주로 동사입니다.
  • create…show…get…check… 등의 잘 알려진 접두어를 사용해 이름을 지을 수 있습니다. 접두어를 사용하면 함수 이름만 보고도 해당 함수가 어떤 동작을 하는지 파악할 수 있습니다.

2.16 함수 표현식

이전 챕터에서 함수 선언(Function Declaration), 함수 선언문 방식으로 함수를 만들었습니다. 아래와 같이 말이죠.

function sayHi() {
  alert( "Hello" );
}
alert(sayHi);  // 함수 코드가 보임

함수 선언 방식 외에 함수 표현식(Function Expression) 을 사용해서 함수를 만들 수 있습니다.

함수 표현식으로 함수를 생성해보겠습니다.

let sayHi = function() {
  alert( "Hello" );
};
function sayHi() {   // (1) 함수 생성
  alert( "Hello" );
}

let func = sayHi;    // (2) 함수 복사

func(); // Hello     // (3) 복사한 함수를 실행(정상적으로 실행됩니다)!
sayHi(); // Hello    //     본래 함수도 정상적으로 실행됩니다.
  1. (1)에서 함수 선언 방식을 이용해 함수를 생성합니다. 생성한 함수는 sayHi라는 변수에 저장됩니다.
  2. (2) 에선 sayHi를 새로운 변수 func에 복사합니다. 이때 sayHi 다음에 괄호가 없다는 점에 유의하시기 바랍니다. 괄호가 있었다면 func = sayHi() 가 되어 sayHi 함수 그 자체가 아니라, 함수 호출 결과(함수의 반환 값) 가 func에 저장되었을 겁니다.
  3. 이젠 sayHi() 와 func()로 함수를 호출할 수 있게 되었습니다.

콜백 함수

함수를 값처럼 전달하는 예시, 함수 표현식에 관한 예시를 좀 더 살펴보겠습니다.

매개변수가 3개 있는 함수, ask(question, yes, no)를 작성해보겠습니다. 각 매개변수에 대한 설명은 아래와 같습니다.

question

질문

yes

"Yes"라고 답한 경우 실행되는 함수

no

"No"라고 답한 경우 실행되는 함수

함수는 반드시 question(질문)을 해야 하고, 사용자의 답변에 따라 yes() 나 no()를 호출합니다.

*function ask(question, yes, no) {
  if (confirm(question)) yes()
  else no();
}*function showOk() {
  alert( "동의하셨습니다." );
}

function showCancel() {
  alert( "취소 버튼을 누르셨습니다." );
}

// 사용법: 함수 showOk와 showCancel가 ask 함수의 인수로 전달됨
ask("동의하십니까?", showOk, showCancel);

이렇게 함수를 작성하는 방법은 실무에서 아주 유용하게 쓰입니다. 면대면으로 질문하는 것보다 위처럼 컨펌창을 띄워 질문을 던지고 답변을 받으면 간단하게 설문조사를 진행할 수 있습니다. 실제 상용 서비스에선 컨펌 창을 좀 더 멋지게 꾸미는 등의 작업이 동반되긴 하지만, 일단 여기선 그게 중요한 포인트는 아닙니다.

함수 ask의 인수, showOk와 showCancel은 콜백 함수 또는 콜백이라고 불립니다.

함수를 함수의 인수로 전달하고, 필요하다면 인수로 전달한 그 함수를 "나중에 호출(called back)"하는 것이 콜백 함수의 개념입니다. 위 예시에선 사용자가 "yes"라고 대답한 경우 showOk가 콜백이 되고, "no"라고 대답한 경우 showCancel가 콜백이 됩니다.

아래와 같이 함수 표현식을 사용하면 코드 길이가 짧아집니다.

function ask(question, yes, no) {
  if (confirm(question)) yes()
  else no();
}

*ask(
  "동의하십니까?",
  function() { alert("동의하셨습니다."); },
  function() { alert("취소 버튼을 누르셨습니다."); }
);*

ask(...) 안에 함수가 선언된 게 보이시나요? 이렇게 이름 없이 선언한 함수는 익명 함수(anonymous function) 라고 부릅니다. 익명 함수는 (변수에 할당된 게 아니기 때문에) ask 바깥에선 접근할 수 없습니다. 위 예시는 의도를 가지고 이렇게 구현하였기 때문에 바깥에서 접근할 수 없어도 문제가 없습니다.

자바스크립트를 사용하다 보면 콜백을 활용한 코드를 아주 자연스레 만나게 됩니다. 이런 코드는 자바스크립트의 정신을 대변합니다.

함수 표현식 vs 함수 선언문

함수 표현식과 선언문의 차이에 대해 알아봅시다.

첫 번째는 문법입니다. 코드를 통해 어떤 차이가 있는지 살펴봅시다.

  • 함수 선언문: 함수는 주요 코드 흐름 중간에 독자적인 구문 형태로 존재합니다.

    // 함수 선언문
    function sum(a, b) {
      return a + b;
    }
  • 함수 표현식: 함수는 표현식이나 구문 구성(syntax construct) 내부에 생성됩니다. 아래 예시에선 함수가 할당 연산자 =를 이용해 만든 “할당 표현식” 우측에 생성되었습니다.

    // 함수 표현식
    let sum = function(a, b) {
      return a + b;
    };

두 번째 차이는 자바스크립트 엔진이 언제 함수를 생성하는지에 있습니다.

함수 표현식은 실제 실행 흐름이 해당 함수에 도달했을 때 함수를 생성합니다. 따라서 실행 흐름이 함수에 도달했을 때부터 해당 함수를 사용할 수 있습니다.

위 예시를 이용해 설명해 보도록 하겠습니다. 스크립트가 실행되고, 실행 흐름이 let sum = function…의 우측(함수 표현식)에 도달 했을때 함수가 생성됩니다. 이때 이후부터 해당 함수를 사용(할당, 호출 등)할 수 있습니다.

하지만 함수 선언문은 조금 다릅니다.

함수 선언문은 함수 선언문이 정의되기 전에도 호출할 수 있습니다.

따라서 전역 함수 선언문은 스크립트 어디에 있느냐에 상관없이 어디에서든 사용할 수 있습니다.

*sayHi("John"); // Hello, John*function sayHi(name) {
  alert( `Hello, ${name}` );
}

함수 선언문, sayHi는 스크립트 실행 준비 단계에서 생성되기 때문에, 스크립트 내 어디에서든 접근할 수 있습니다.

그러나 함수 표현식으로 정의한 함수는 함수가 선언되기 전에 접근하는 게 불가능합니다.

*sayHi("John"); // error!*let sayHi = function(name) {  // (*) 마술은 일어나지 않습니다.
  alert( `Hello, ${name}` );
};

함수 표현식은 실행 흐름이 표현식에 다다랐을 때 만들어집니다. 위 예시에선 (*)로 표시한 줄에 실행 흐름이 도달했을 때 함수가 만들어집니다. 아주 늦죠.

세 번째 차이점은, 스코프입니다.

엄격 모드에서 함수 선언문이 코드 블록 내에 위치하면 해당 함수는 블록 내 어디서든 접근할 수 있습니다. 하지만 블록 밖에서는 함수에 접근하지 못합니다.

let age = 16; // 16을 저장했다 가정합시다.

if (age < 18) {
  *welcome();               // \   (실행)*//  |
  function welcome() {     //  |
    alert("안녕!");        //  |  함수 선언문은 함수가 선언된 블록 내
  }                        //  |  어디에서든 유효합니다
                           //  |
  *welcome();               // /   (실행)*} else {

  function welcome() {
    alert("안녕하세요!");
  }
}

// 여기는 중괄호 밖이기 때문에
// 중괄호 안에서 선언한 함수 선언문은 호출할 수 없습니다.

*welcome(); // Error: welcome is not defined*

그럼 if문 밖에서 welcome 함수를 호출할 방법은 없는 걸까요?

함수 표현식을 사용하면 가능합니다. if문 밖에 선언한 변수 welcome에 함수 표현식으로 만든 함수를 할당하면 되죠.

이제 코드가 의도한 대로 동작합니다.

let age = prompt("나이를 알려주세요.", 18);

let welcome;

if (age < 18) {

  welcome = function() {
    alert("안녕!");
  };

} else {

  welcome = function() {
    alert("안녕하세요!");
  };

}

*welcome(); // 제대로 동작합니다.*

물음표 연산자 ?를 사용하면 위 코드를 좀 더 단순화할 수 있습니다.

let age = prompt("나이를 알려주세요.", 18);

let welcome = (age < 18) ?
  function() { alert("안녕!"); } :
  function() { alert("안녕하세요!"); };

*welcome(); // 제대로 동작합니다.*

요약

  • 함수는 값입니다. 따라서 함수도 값처럼 할당, 복사, 선언할 수 있습니다.
  • “함수 선언(문)” 방식으로 함수를 생성하면, 함수가 독립된 구문 형태로 존재하게 됩니다.
  • “함수 표현식” 방식으로 함수를 생성하면, 함수가 표현식의 일부로 존재하게 됩니다.
  • 함수 선언문은 코드 블록이 실행되기도 전에 처리됩니다. 따라서 블록 내 어디서든 활용 가능합니다.
  • 함수 표현식은 실행 흐름이 표현식에 다다랐을 때 만들어집니다.

함수를 선언해야 한다면 함수가 선언되기 이전에도 함수를 활용할 수 있기 때문에, 함수 선언문 방식을 따르는 게 좋습니다. 함수 선언 방식은 코드를 유연하게 구성할 수 있도록 해주고, 가독성도 좋습니다.

2.17 화살표 함수 기본

함수 표현식보다 단순하고 간결한 문법으로 함수를 만들 수 있는 방법이 있습니다.

바로 화살표 함수(arrow function)를 사용하는 것입니다. 화살표 함수라는 이름은 문법의 생김새를 차용해 지어졌습니다.

let func = (arg1, arg2, ...argN) => expression

아래 함수의 축약 버전이라고 할 수 있죠.

let func = function(arg1, arg2, ...argN) {
  return expression;
};

좀 더 구체적인 예시를 살펴봅시다.

let sum = (a, b) => a + b;

/* 위 화살표 함수는 아래 함수의 축약 버전입니다.

let sum = function(a, b) {
  return a + b;
};
*/

alert( sum(1, 2) ); // 3

보시는 바와 같이 (a, b) => a + b는 인수 a와 b를 받는 함수입니다. (a, b) => a + b는 실행되는 순간 표현식 a + b를 평가하고 그 결과를 반환합니다.

  • 인수가 하나밖에 없다면 인수를 감싸는 괄호를 생략할 수 있습니다. 괄호를 생략하면 코드 길이를 더 줄일 수 있습니다.

    예시:

    *let double = n => n * 2;
    // let double = function(n) { return n * 2 }과 거의 동일합니다.*
    
    alert( double(3) ); // 6
  • 인수가 하나도 없을 땐 괄호를 비워놓으면 됩니다. 다만, 이 때 괄호는 생략할 수 없습니다.

    let sayHi = () => alert("안녕하세요!");
    
    sayHi();

화살표 함수는 함수 표현식과 같은 방법으로 사용할 수 있습니다.

아래 예시와 같이 함수를 동적으로 만들 수 있습니다.

let age = prompt("나이를 알려주세요.", 18);

let welcome = (age < 18) ?
  () => alert('안녕') :
  () => alert("안녕하세요!");

welcome();

화살표 함수를 처음 접하면 가독성이 떨어집니다. 익숙지 않기 때문입니다. 하지만 문법이 눈에 익기 시작하면 적응은 식은 죽 먹기가 됩니다.

함수 본문이 한 줄인 간단한 함수는 화살표 함수를 사용해서 만드는 게 편리합니다. 타이핑을 적게 해도 함수를 만들 수 있다는 장점이 있습니다.

본문이 여러 줄인 화살표 함수

위에서 소개해 드린 화살표 함수들은 => 왼쪽에 있는 인수를 이용해 => 오른쪽에 있는 표현식을 평가하는 함수들이었습니다.

그런데 평가해야 할 표현식이나 구문이 여러 개인 함수가 있을 수도 있습니다. 이 경우 역시 화살표 함수 문법을 사용해 함수를 만들 수 있습니다. 다만, 이때는 중괄호 안에 평가해야 할 코드를 넣어주어야 합니다. 그리고 return 지시자를 사용해 명시적으로 결괏값을 반환해 주어야 합니다.

아래와 같이 말이죠.

let sum = (a, b) => {  // 중괄호는 본문 여러 줄로 구성되어 있음을 알려줍니다.
  let result = a + b;
  *return result; // 중괄호를 사용했다면, return 지시자로 결괏값을 반환해주어야 합니다.*
};

alert( sum(1, 2) ); // 3

요약

화살표 함수는 본문이 한 줄인 함수를 작성할 때 유용합니다. 본문이 한 줄이 아니라면 다른 방법으로 화살표 함수를 작성해야 합니다.

  1. 중괄호 없이 작성: (...args) => expression – 화살표 오른쪽에 표현식을 둡니다. 함수는 이 표현식을 평가하고, 평가 결과를 반환합니다.
  2. 중괄호와 함께 작성: (...args) => { body } – 본문이 여러 줄로 구성되었다면 중괄호를 사용해야 합니다. 다만, 이 경우는 반드시 return 지시자를 사용해 반환 값을 명기해 주어야 합니다.

3.1 Chrome으로 디버깅하기

디버깅(debugging)은 스크립트 내 에러를 검출해 제거하는 일련의 과정을 의미합니다. 모던 브라우저와 호스트 환경 대부분은 개발자 도구 안에 UI 형태로 디버깅 툴을 구비해 놓습니다. 디버깅 툴을 사용하면 디버깅이 훨씬 쉬워지고, 실행 단계마다 어떤 일이 일어나는지를 코드 단위로 추적할 수 있습니다.

이 글에선 Chrome 브라우저에서 제공하는 디버깅 툴을 사용하도록 하겠습니다. 기능이 다양하고, Chrome에 익숙해지면 다른 브라우저에서 지원하는 디버깅 툴은 쉽게 익힐 수 있기 때문입니다.

'Sources' 패널

Chrome 버전에 따라 보이는 화면은 약간씩 다를 수 있습니다. 하지만 버전이 바뀌어도 구성은 크게 바뀌지 않기 때문에 화면 캡쳐본과 함께 설명을 이어나가겠습니다.

  • Chrome을 사용해 예시 페이지를 엽니다.

  • F12(MacOS: )를 눌러 개발자 도구를 엽니다.

    Cmd+Opt+I

  • Sources 탭을 클릭해 Sources 패널(panel)을 엽니다.

Sources 패널을 처음 열었다면 아래와 같은 화면이 보일 겁니다.

토글 버튼 을 누르면 navigator가 열리면서 현재 사이트와 관련된 파일들이 나열됩니다.

파일 목록에서 hello.js를 클릭해 아래와 같이 화면을 바꿔봅시다.

Sources 패널은 크게 세 개의 영역으로 구성됩니다.

  1. 파일 탐색 영역 – 페이지를 구성하는 데 쓰인 모든 리소스(HTML, JavaScript, CSS, 이미지 파일 등)를 트리 형태로 보여줍니다. Chrome 익스텐션이 여기 나타날 때도 있습니다.
  2. 코드 에디터 영역 – 리소스 영역에서 선택한 파일의 소스 코드를 보여줍니다. 여기서 소스 코드를 편집할 수도 있습니다.
  3. 자바스크립트 디버깅 영역 – 디버깅에 관련된 기능을 제공합니다. 곧 자세히 살펴보겠습니다.

토글 버튼 을 다시 누르면 리소스 영역이 사라지고, 소스 코드 영역이 더 넓어집니다.

콘솔

Esc를 누르면 개발자 도구 하단부에 콘솔 창이 열립니다. 여기에 명령어를 입력하고 Enter를 누르면 입력한 명령어가 실행됩니다.

콘솔 창에 구문(statement)을 입력하고 실행하면 아랫줄에 실행 결과가 출력됩니다.

1+2를 입력하면 3이 출력되고, hello("debugger")를 입력하면 undefined가 출력되죠. undefined가 출력되는 이유는 hello("debugger")가 아무것도 반환하지 않기 때문입니다.

중단점

예시 페이지 내부에서 무슨 일이 일어나는지 자세히 살펴봅시다. hello.js를 소스 코드 영역에 띄우고 네 번째 줄 코드 좌측의 줄 번호, 4를 클릭합시다. 코드가 아닌 줄 번호 4에 마우스 커서를 옮긴 후 클릭해야 합니다.

축하합니다! 중단점을 성공적으로 설정하셨습니다. 줄 번호 8도 클릭해 중단점을 하나 더 추가해봅시다.

지금까지 잘 따라오셨다면 아래와 같은 화면이 보여야 합니다. 줄 번호 4와 8이 파란색으로 바뀐 게 보이시죠?

중단점(breakpoint) 은 말 그대로 자바스크립트의 실행이 중단되는 코드 내 지점을 의미합니다.

중단점을 이용하면 실행이 중지된 시점에 변수가 어떤 값을 담고 있는지 알 수 있습니다. 또한 실행이 중지된 시점을 기준으로 명령어를 실행할 수도 있습니다. 디버깅이 가능해지는 것이죠.

Sources 패널 우측의 디버깅 영역을 보면 중단점 목록을 확인할 수 있습니다. 파일 여러 개에 다수의 중단점을 설정해 놓은 경우, 디버깅 영역을 이용하면 아래와 같은 작업을 할 수도 있습니다.

  • 항목을 클릭해 해당 중단점이 설정된 곳으로 바로 이동할 수 있습니다.
  • 체크 박스 선택을 해제해 해당 중단점을 비활성화 할 수 있습니다.
  • 마우스 오른쪽 버튼을 클릭했을 때 나오는 ‘Remove breakpoint’ 옵션을 통해 중단점을 삭제할 수도 있습니다.
  • 이 외에도 다양한 기능이 있습니다.

debugger 명령어

아래 예시처럼 스크립트 내에 debugger 명령어를 적어주면 중단점을 설정한 것과 같은 효과를 봅니다.

function hello(name) {
  let phrase = `Hello, ${name}!`;

  *debugger;  // <-- 여기서 실행이 멈춥니다.*say(phrase);
}

debugger 명령어를 사용하면 브라우저를 켜 개발자 도구를 열고 소스 코드 영역을 띄워 중단점을 설정하는 수고를 하지 않아도 됩니다. 에디터를 떠나지 않고도 중단점을 설정할 수 있기 때문에 편리하죠.

멈추면 보이는 것들

  1. Watch – 표현식을 평가하고 결과를 보여줍니다.

    Add Expression 버튼 +를 클릭해 원하는 표현식을 입력한 후 Enter를 누르면 중단 시점의 값을 보여줍니다. 입력한 표현식은 실행 과정 중에 계속해서 재평가됩니다.

  2. Call Stack – 코드를 해당 중단점으로 안내한 실행 경로를 역순으로 표시합니다.

    실행은 index.html 안에서 hello()를 호출하는 과정 중에 멈췄습니다. 함수 hello 내에 중단점을 설정했기 때문에, 콜 스택(Call Stack) 최상단엔 hello가 위치합니다. index.html에서 함수 hello를 정의하지 않았기 때문에 콜 스택 하단엔 'anonymous’가 출력됩니다.

    콜 스택 내의 항목을 클릭하면 디버거가 해당 코드로 휙 움직이고, 변수 역시 재평가됩니다. 'anonymous’를 클릭해 직접 확인해 봅시다.

  3. Scope – 현재 정의된 모든 변수를 출력합니다.

    Local은 함수의 지역변수를 보여줍니다. 지역 변수 정보는 소스 코드 영역에서도 확인(강조 표시)할 수 있습니다.

    Global은 함수 바깥에 정의된 전역 변수를 보여줍니다.

    Local 하위 항목으로 this에 대한 정보도 출력되는데, 이에 대해선 추후에 학습하도록 하겠습니다.

실행 추적하기

– ‘Resume’: 스크립트 실행을 다시 시작함 (단축키 F8)

실행을 재개합니다. 추가 중단점이 없는 경우, 실행이 죽 이어지고 디버거는 동작하지 않습니다. 버튼을 클릭해봅시다. 실행이 다시 시작되다가 함수 say() 안에 설정한 중단점에서 실행이 멈춥니다. 이 시점에서 우측의 'Call Stack’을 살펴보면 스택 최상단에 콜(say)이 하나 더 추가된 것을 확인할 수 있습니다. 현재 실행은 say() 안에 멈춰있는 상황입니다.

 – ‘Step’: 다음 명령어를 실행함 (단축키 F9)

다음 문을 실행합니다. 클릭하면 alert 창이 뜨는 것을 확인할 수 있습니다. Step 버튼을 계속 누르면 스크립트 전체를 문 단위로 하나하나 실행할 수 있습니다.

 – ‘Step over’: 다음 명령어를 실행하되, 함수 안으로 들어가진 않음 (단축키 F10)

'Step’과 유사하지만, 다음 문이 함수 호출일 때 'Step’과는 다르게 동작합니다(alert 같은 내장함수에는 해당하지 않고, 직접 작성한 함수일 때만 동작이 다릅니다). 'Step’은 함수 내부로 들어가 함수 본문 첫 번째 줄에서 실행을 멈춥니다. 반면 'Step over’는 보이지 않는 곳에서 중첩 함수를 실행하긴 하지만 함수 내로 진입하지 않습니다. 실행은 함수 실행이 끝난 후에 즉시 멈춥니다. 'Step over’은 함수 호출 시 내부에서 어떤 일이 일어나는지 궁금하지 않을 때 유용합니다.

 – ‘Step into’ (단축키 F11)

'Step’과 유사한데, 비동기 함수 호출에서 'Step’과는 다르게 동작합니다. 이제 막 자바스크립트를 배우기 시작한 분이라면 비동기 호출에 대해 아직 배우지 않았기 때문에 'Step’과 'Step into’의 차이를 몰라도 괜찮습니다. 'Step’은 setTimeout(함수 호출 스케줄링에 쓰이는 내장 메서드)같은 비동기 동작은 무시합니다. 반면 'Step into’는 비동기 동작을 담당하는 코드로 진입하고, 필요하다면 비동기 동작이 완료될 때까지 대기합니다. 자세한 내용은 개발자 도구 매뉴얼에서 확인하시기 바랍니다.

 – ‘Step out’: 실행 중인 함수의 실행이 끝날 때 까지 실행을 계속함 (단축키 Shift+F11)

현재 실행 중인 함수의 실행을 계속 이어가다가 함수 본문 마지막 줄에서 실행을 멈춥니다. 실수로 을 눌러 내부 동작을 알고 싶지 않은 중첩 함수로 진입했거나 가능한 한 빨리 함수 실행을 끝내고 싶은 경우 유용합니다, – 모든 중단점을 활성화/비활성화모든 중단점을 일시적으로 활성화/비활성화합니다(실행에는 영향이 없습니다). ****

– 예외 발생 시 코드를 자동 중지시켜주는 기능을 활성화/비활성화

활성화되어 있고, 개발자 도구가 열려있는 상태에서 스크립트 실행 중에 에러가 발생하면 실행이 자동으로 멈춥니다. 실행이 중단되었기 때문에 변수 등을 조사해 어디서 에러가 발생했는지 찾을 수 있게 됩니다. 개발하다가 에러와 함께 스크립트가 죽었다면 디버거를 열고 이 옵션을 활성화한 후, 페이지를 새로 고침하면 에러가 발생한 곳과 에러 발생 시점의 컨텍스트를 확인할 수 있습니다.

console.log

console.log 함수를 이용하면 원하는 것을 콘솔에 출력할 수 있습니다.

아래 예시를 실행하면 콘솔창에 0부터 4까지 출력됩니다.

// 콘솔창을 열어 결과를 확인해 보세요.
for (let i = 0; i < 5; i++) {
  console.log("숫자", i);
}

요약

스크립트 실행이 중단되는 경우는 다음과 같습니다.

  1. 중단점을 만났을 때
  2. debugger문 만났을 때
  3. 에러가 발생했을 때(개발자 도구가 열려있고 ⏸️버튼이 '활성화’되어있는 경우)

스크립트 실행이 중지되면 중단 시점을 기준으로 변수에 어떤 값이 들어가 있는지 확인할 수 있습니다. 또한 단계별로 코드를 실행해 가며, 어디서 문제가 발생했는지 추적할 수도 있습니다. 이런 식으로 디버깅이 진행됩니다.

개발자 도구는 여기서 소개한 기능 이외의 다양한 기능을 지원합니다. Google에서 제공하는 개발자 도구 공식 매뉴얼은 https://developers.google.com/web/tools/chrome-devtools에서 확인할 수 있습니다.

3.2 코딩 스타일

개발자는 가능한 한 간결하고 읽기 쉽게 코드를 작성해야 합니다.

복잡한 문제를 간결하고 사람이 읽기 쉬운 코드로 작성해 해결하는 것이야말로 진정한 프로그래밍 기술입니다. 좋은 코드 스타일은 이런 기술을 연마하는 데 큰 도움을 줍니다.

문법

중괄호

if (condition) doSomething()과 같은 단 한 줄짜리 구문은 중요하게 다뤄야 할 에지 케이스입니다. 이런 예외상황에도 중괄호를 써야 할까요?

어떻게 코드를 작성해야 가독성이 좋을지 직접 판단해 보시라고 주석과 함께 몇 가지 예시를 만들어보았습니다.

  1. 😠 초보 개발자들은 아래처럼 코드를 작성하곤 하는데, 중괄호가 필요하지 않기 때문에 추천하지 않습니다.

    if (n < 0) *{*alert(`Power ${n} is not supported`);*}*
  2. 😠 중괄호 없이 새로운 줄에 코드를 작성할 수도 있는데, 이렇게 하면 새로운 코드 라인을 추가할 때 에러가 발생합니다. 절대 이 방법은 쓰지 마세요.

    if (n < 0)
      alert(`Power ${n} is not supported`);
  3. 😏 코드가 짧다면 중괄호 없이 한 줄에 쓰는 방법도 괜찮습니다.

    if (n < 0) alert(`Power ${n} is not supported`);
  4. 😃 가장 추천하는 방법은 다음과 같습니다.

    if (n < 0) {
      alert(`Power ${n} is not supported`);
    }

if (cond) return null처럼 코드가 간단하다면 세 번째 예시같이 한 줄에 몰아서 작성해도 괜찮습니다. 그렇지만 네 번째 예시처럼 코드 블록을 사용하는 방법이 가장 가독성이 좋으므로 이 방법을 추천합니다.

가로 길이

가로로 길게 늘어진 코드를 읽는 걸 좋아하는 개발자는 없습니다. 코드의 가로 길이가 길어진다면 여러 줄로 나눠 작성하는 게 좋습니다.

// 백틱(`)을 사용하면 문자열을 여러 줄로 쉽게 나눌 수 있습니다.
let str = `
  ECMA International's TC39 is a group of JavaScript developers,
  implementers, academics, and more, collaborating with the community
  to maintain and evolve the definition of JavaScript.
`;

if문이라면 아래와 같이 작성할 수 있을겁니다.

if (
  id === 123 &&
  moonPhase === 'Waning Gibbous' &&
  zodiacSign === 'Libra'
) {
  letTheSorceryBegin();
}

최대 가로 길이는 팀원들과 합의해 정하는게 좋습니다. 대개 80자나 120자로 제한하는 게 일반적입니다.

들여쓰기

들여쓰기에는 두 종류가 있습니다.

  • 가로 들여쓰기: 스페이스 두 개 혹은 네 개를 사용해 만듦

    가로 들여쓰기는 스페이스 두 개 혹은 네 개를 사용하거나 탭 키(Tab)를 이용해 만들 수 있습니다. 어떤 방법을 쓸지에 대한 논쟁은 오래전부터 있었는데, 요즘엔 탭 대신 스페이스를 이용하는 게 더 우위에 있는 것 같습니다.

    탭 대신 스페이스를 이용했을 때의 장점 중 하나는 들여쓰기 정도를 좀 더 유연하게 변경할 수 있다는 점입니다.

    아래 예시처럼 인수 모두의 위치를 여는 괄호와 맞출 수 있죠.

    show(parameters,
         aligned, // 스페이스 다섯 개를 이용해 들여쓰기 함
         one,
         after,
         another
      ) {
      // ...
    }
  • 세로 들여쓰기: 논리 블록 사이에 넣어 코드를 분리해주는 새 줄

    함수 하나에 논리 블록 여러 개가 들어갈 수 있습니다. 아래 예시에서 변수 선언, 반복문, 리턴문 사이에 세로 들여쓰기를 해주는 빈 줄을 넣어 코드를 분리해 보았습니다.

    function pow(x, n) {
      let result = 1;
      //              <--
      for (let i = 0; i < n; i++) {
        result *= x;
      }
      //              <--
      return result;
    }

    이렇게 여분의 줄을 넣어주면 코드의 가독성이 좋아집니다. 읽기 쉬운 코드를 만들려면 세로 들여쓰기 없이 코드를 아홉 줄 이상 연속해서 쓰지 마세요.

세미콜론

자바스크립트 엔진에 의해 무시되더라도 모든 구문의 끝엔 세미콜론을 써주는 것이 좋습니다.

구문 끝에 세미콜론을 적는 게 완전히 선택사항인 언어가 몇몇 있는데 이런 언어들에선 세미콜론을 잘 쓰지 않습니다. 그러나 자바스크립트에선 줄 바꿈이 세미콜론으로 해석되지 않는 몇몇 상황이 있기 때문에 세미콜론을 생략하고 코딩하는 습관을 들이면 에러를 발생시키는 코드를 만들 수 있습니다. 자세한 사례는 코드 구조 챕터에서 살펴보세요.

경험이 많은 자바스크립트 개발자라면 StandardJS에서 제시하는 스타일 가이드처럼 세미콜론 없이 코드를 작성할 수도 있습니다. 초보 개발자라면 에러를 만들 확률을 줄이기 위해서라도 세미콜론을 사용하는 게 좋습니다.

중첩 레벨

가능하면 너무 깊은 중첩문은 사용하지 않도록 합시다.

반복문을 사용할 때 중첩문의 깊이가 깊어지면 [continue](https://ko.javascript.info/while-for#continue) 지시자를 쓰는 게 좋은 대안이 될 수도 있습니다.

if문으로 조건을 처리하는 예시를 통해 이를 살펴봅시다.

for (let i = 0; i < 10; i++) {
  if (cond) {
    ... // <- 중첩 레벨이 하나 더 늘어났습니다.
  }
}

위 코드는 continue를 써서 아래와 같이 바꿀 수 있습니다.

for (let i = 0; i < 10; i++) {
  if (!cond) *continue*;
  ...  // <- 추가 중첩 레벨이 추가되지 않습니다.
}

if/else와 return문을 조합하면 위 예시와 유사하게 중첩 레벨을 줄여 코드의 가독성을 높일 수 있습니다.

함수의 위치

‘헬퍼’ 함수 여러 개를 만들어 사용하고 있다면 아래와 같은 방법을 사용해 코드 구조를 정돈할 수 있습니다.

  1. 헬퍼 함수를 사용하는 코드 에서 헬퍼 함수를 모아 선언하기

    // *함수 선언*
    function createElement() {
      ...
    }
    
    function setHandler(elem) {
      ...
    }
    
    function walkAround() {
      ...
    }
    
    // *헬퍼 함수를 사용하는 코드*
    let elem = createElement();
    setHandler(elem);
    walkAround();
  2. 코드를 먼저, 함수는 그 다음에 선언하기

    // *헬퍼 함수를 사용하는 코드*
    let elem = createElement();
    setHandler(elem);
    walkAround();
    
    // --- *헬퍼 함수* ---
    function createElement() {
      ...
    }
    
    function setHandler(elem) {
      ...
    }
    
    function walkAround() {
      ...
    }
  3. 혼합: 코드 바로 위에서 필요한 헬퍼 함수 그때그때 선언하기

대개는 두 번째 방법으로 코드를 정돈하는 걸 선호합니다.

사람들은 이 코드가 '무엇을 하는지’를 생각하며 코드를 읽기 때문에 코드가 먼저 나오는 것이 자연스럽기 때문입니다. 이름만 보고도 헬퍼 함수의 역할을 쉽게 유추할 수 있게 헬퍼 함수 이름을 명명했다면 함수 본문을 읽을 필요도 없습니다.

스타일 가이드

코딩 스타일 가이드는 코드를 '어떻게 작성할지’에 대한 전반적인 규칙을 담은 문서로, 어떤 따옴표를 쓸지, 들여쓰기할 때 스페이스를 몇 개 사용할지, 최대 가로 길이는 몇까지 제한할지 등의 내용이 담겨있습니다.

팀원 전체가 동일한 스타일 가이드를 따라 코드를 작성하면, 누가 코드를 작성했나에 관계없이 동일한 스타일의 코드를 만들 수 있습니다.

팀원들이 모여 팀 전용 스타일 가이드를 만들 수도 있는데, 요즘엔 이미 작성된 가이드 중 하나를 선택해 팀의 가이드로 삼는 편입니다.

유명 스타일 가이드:

초보 개발자라면 상단 치트 시트를 시작으로 본인만의 스타일을 가이드를 만들어 보시기 바랍니다. 유명 스타일 가이드 등을 살펴보며 아이디어를 얻고, 마음에 드는 규칙은 본인의 스타일 가이드에 반영해 보시기 바랍니다.

Linter

inter라는 도구를 사용하면 내가 작성한 코드가 스타일 가이드를 준수하고 있는지를 자동으로 확인할 수 있고, 스타일 개선과 관련된 제안도 받을 수 있습니다.

이렇게 자동으로 스타일을 체크받다 보면, 변수나 함수 이름에 난 오타 등이 유발하는 버그를 미리 발견할 수 있어서 좋습니다. 아직 '코드 스타일’을 정하지 않았더라도 linter를 사용하면 버그를 예방할 수 있기 때문에 linter 사용을 권유 드립니다.

유명 linter:

  • JSLint – 역사가 오래된 linter
  • JSHint – JSLint보다 세팅이 좀 더 유연한 linter
  • ESLint – 가장 최근에 나온 linter

위 linter 모두 훌륭한 기능을 제공합니다. 글쓴이는 ESLint를 사용하고 있습니다.

대부분의 linter는 플러그인 형태로 유명 에디터와 통합해 사용할 수 있습니다. 원하는 스타일을 설정하는 것 역시 가능합니다.

ESLint를 사용한다고 가정했을 때 아래 절차를 따르면 에디터와 linter를 통합해 사용할 수 있습니다.

  1. Node.js를 설치합니다.
  2. npm(자바스크립트 패키지 매니저)을 사용해 다음 명령어로 ESLint를 설치합니다. npm install -g eslint
  3. 현재 작성 중인 자바스크립트 프로젝트의 루트 폴더(프로젝트 관련 파일이 담긴 폴더)에 .eslintrc라는 설정 파일을 생성합니다.
  4. 에디터에 ESLint 플러그인을 설치하거나 활성화합니다. 주요 에디터들은 모두 ESLint 플러그인을 지원합니다.

아래는 .eslintrc 파일의 예시입니다.

{
  "extends": "eslint:recommended",
  "env": {
    "browser": true,
    "node": true,
    "es6": true
  },
  "rules": {
    "no-console": 0,
    "indent": ["warning", 2]
  }
}

위 예시에서 지시자 "extends"는 "eslint:recommended"를 기반으로 이를 확장해 스타일 가이드를 설정하겠다는 걸 의미합니다. 이렇게 세팅한 이후에 자신만의 스타일을 설정하면 됩니다.

스타일 규칙을 모아놓은 세트를 웹에서 다운로드해 이를 기반으로 스타일 가이드를 설정하는 것도 가능합니다. 설치 방법에 대한 자세한 내용은 http://eslint.org/docs/user-guide/getting-started에서 확인해 보시기 바랍니다.

몇몇 IDE에서는 자체 lint 도구가 있어 편리하긴 하지만 ESLint처럼 쉽게 설정을 변경하는 게 불가능하다는 단점이 있습니다.

요약

이 챕터에서 소개해 드린 문법 규칙과 스타일 가이드 관련 참고자료들은 코드 가독성을 높이기 위해 만들어졌습니다.

‘더 좋은’ 코드를 만들려면 "가독성이 좋고 이해하기 쉬운 코드를 만들려면 무엇을 해야 할까?"라는 질문과 "에러를 피하려면 어떤 일을 해야 할까?"라는 질문을 스스로에게 던져야 합니다. 어떤 코딩 스타일을 따를지 결정할 때와 이에 대한 논쟁을 할 땐 이런 질문을 기반으로 해야 하죠.

유명 스타일 가이드를 읽다 보면 코드 스타일에 관한 경향과 모범 사례에 대한 최신 정보를 유지할 수 있습니다.

3.3 주석

코드 구조에서 알아본 바와 같이 한 줄짜리 주석은 //로, 여러 줄의 주석은 /* ... */로 시작합니다.

좋지 않은 주석

초심자들은 주석에 '코드에서 무슨 일이 일어나는지’에 대한 내용을 적곤 합니다. 아래와 같이 말이죠.

// 이 코드는 (...)과 (...)을 수행합니다
// A라는 개발자가 이 기능에 대해 알고 있으며...
very;
complex;
code;

그러나 좋은 코드엔 ‘설명이 담긴(explanatory)’ 주석이 많아선 안 됩니다. 주석 없이 코드 자체만으로 코드가 무슨 일을 하는지 쉽게 이해할 수 있어야 합니다.

이와 관련된 좋은 규칙도 있습니다. “코드가 불분명해서 주석 작성이 불가피하다면 코드를 다시 작성해야 하는 지경에 이른 걸 수 있습니다.”

리팩토링 팁: 함수 분리하기

함수 내 코드 일부를 새로운 함수로 옮기는 게 유익할 때도 있습니다. 아래와 같이 말이죠.

function showPrimes(n) {
  nextPrime:
  for (let i = 2; i < n; i++) {

    *// i가 소수인지를 확인함
    for (let j = 2; j < i; j++) {
      if (i % j == 0) continue nextPrime;
    }*alert(i);
  }
}

코드 일부를 함수 isPrime으로 옮기면 더 나은 코드를 작성할 수 있습니다.

function showPrimes(n) {

  for (let i = 2; i < n; i++) {
    *if (!isPrime(i)) continue;*alert(i);
  }
}

function isPrime(n) {
  for (let i = 2; i < n; i++) {
    if (n % i == 0) return false;
  }

  return true;
}

함수 이름 자체가 주석 역할을 하므로 코드를 쉽게 이해할 수 있게 되었습니다. 이런 코드를 자기 설명적인(self-descriptive) 코드라 부릅니다.

리팩토링 팁: 함수 만들기

아래와 같이 코드가 ‘아래로 죽 늘어져 있는’ 경우를 생각해 봅시다.

// 위스키를 더해줌
for(let i = 0; i < 10; i++) {
  let drop = getWhiskey();
  smell(drop);
  add(drop, glass);
}

// 주스를 더해줌
for(let t = 0; t < 3; t++) {
  let tomato = getTomato();
  examine(tomato);
  let juice = press(tomato);
  add(juice, glass);
}

// ...

이럴 땐 새로운 함수를 만들고, 코드 일부를 새로 만든 함수에 옮기는 게 좋습니다. 아래와 같이 말이죠.

addWhiskey(glass);
addJuice(glass);

function addWhiskey(container) {
  for(let i = 0; i < 10; i++) {
    let drop = getWhiskey();
    //...
  }
}

function addJuice(container) {
  for(let t = 0; t < 3; t++) {
    let tomato = getTomato();
    //...
  }
}

함수는 주석이 없어도 그 존재 자체가 무슨 역할을 하는지 설명할 수 있어야 합니다. 코드를 분리해 작성하면 더 나은 코드 구조가 되죠. 이런 가이드를 잘 지켜 코드를 작성하면 함수가 어떤 동작을 하는지, 무엇을 받고 무엇을 반환하는지가 명확해집니다.

좋은 주석

아키텍처를 설명하는 주석

고차원 수준 컴포넌트 개요, 컴포넌트 간 상호작용에 대한 설명, 상황에 따른 제어 흐름 등은 주석에 넣는 게 좋습니다. 이런 주석은 조감도 역할을 해줍니다. 고차원 수준의 아키텍처 다이어그램을 그리는 데 쓰이는 언어인 UML도 시간을 내어 공부해 보는걸 추천해 드립니다.

함수 용례와 매개변수 정보를 담고 있는 주석

JSDoc이라는 특별한 문법을 사용하면 함수에 관한 문서를 쉽게 작성할 수 있습니다. 여기엔 함수 용례, 매개변수, 반환 값 정보가 들어갑니다.

/**
 * x를 n번 곱한 수를 반환함
 *
 * @param {number} x 거듭제곱할 숫자
 * @param {number} n 곱할 횟수, 반드시 자연수여야 함
 * @return {number} x의 n 거듭제곱을 반환함
 */
function pow(x, n) {
  ...
}

이렇게 주석을 달면 코드를 읽어보지 않고도 함수의 목적과 사용법을 한눈에 알 수 있습니다.

왜 이런 방법으로 문제를 해결했는지를 설명하는 주석

무엇이 적혀있는지는 중요합니다. 그런데 무슨 일이 일어나고 있는지 파악하려면 무엇이 적혀있지 않은 지가 더 중요할 수 있습니다. '왜 이 문제를 이런 방법으로 해결했나?'라는 질문에 코드는 답을 해 줄 수 없기 때문입니다. 문제 해결 방법이 여러 가지인데 왜 하필이면 이 방법을 택했는지 의문이 들 때가 있습니다. 선택한 방법이 가장 나은 것도 아닌데 말이죠. 왜 이런 방법을 써서 문제를 해결했는지 알려주는 주석이 없으면 다음과 같은 일이 발생할 수 있습니다.

  1. 당신(혹은 동료)은 작성된 후 시간이 꽤 흐른 코드를 열어봅니다. 그리고 그 코드에서 선택한 방식이 ‘가장 좋은 방식은 아니란 걸’ 알아냅니다.
  2. "그때는 내가 멍청했구나. 하지만 지금은 더 똑똑해졌지"라고 생각하며, 이전보단 ‘더 명확하고 올바른’ 방법으로 코드를 개선합니다.
  3. 코드를 개선하려는 시도까지는 좋았습니다. 하지만 리팩토링 과정에서 '더 명확’하다고 생각했던 방법을 적용하면 문제가 발생한다는 걸 알아냅니다. 이미 시도해봤던 방법이기 때문에 왜 이 방법이 먹히지 않는지 희미하게 기억이 떠오릅니다. 새로 작성한 코드를 되돌렸지만, 시간이 낭비되었습니다.

해결 방법을 담고 있는 주석은 아주 중요한 역할을 합니다. 이전에 했던 실수를 방지하는 안내판 역할을 하기 때문입니다.

미묘한 기능이 있고, 이 기능이 어디에 쓰이는지를 설명하는 주석

직감에 반하는 미묘한 동작을 수행하는 코드가 있다면 주석을 달아주는 게 좋습니다.

요약

주석을 보면 좋은 개발자인지 아닌지를 어느 정도 알 수 있습니다. 주석을 언제 쓰고 언제 쓰지 않는지를 보면 되죠.

주석을 잘 작성해 놓으면 시간이 지난 후 코드를 다시 살펴볼 때 효율적으로 정보를 얻을 수 있습니다. 코드 유지보수에 도움이 되죠.

주석에 들어가면 좋은 내용

  • 고차원 수준 아키텍처
  • 함수 용례
  • 당장 봐선 명확해 보이지 않는 해결 방법에 대한 설명

주석에 들어가면 좋지 않은 내용

  • '코드가 어떻게 동작하는지’와 '코드가 무엇을 하는지’에 대한 설명
  • 코드를 간결하게 짤 수 없는 상황이나 코드 자체만으로도 어떤 일을 하는지 충분히 판단할 수 없는 경우에만 주석을 넣으세요.

4.1 객체

자료형 챕터에서 배웠듯이 자바스크립트엔 여덟 가지 자료형이 있습니다. 이 중 일곱 개는 오직 하나의 데이터(문자열, 숫자 등)만 담을 수 있어 '원시형(primitive type)'이라 부릅니다.

그런데 객체형은 원시형과 달리 다양한 데이터를 담을 수 있습니다. 키로 구분된 데이터 집합이나 복잡한 개체(entity)를 저장할 수 있죠. 객체는 자바스크립트 거의 모든 면에 녹아있는 개념이므로 자바스크립트를 잘 다루려면 객체를 잘 이해하고 있어야 합니다.

객체는 중괄호 {…}를 이용해 만들 수 있습니다. 중괄호 안에는 ‘키(key): 값(value)’ 쌍으로 구성된 프로퍼티(property) 를 여러 개 넣을 수 있는데, 엔 문자형, 엔 모든 자료형이 허용됩니다. 프로퍼티 키는 ‘프로퍼티 이름’ 이라고도 부릅니다.

서랍장을 상상하면 객체를 이해하기 쉽습니다. 서랍장 안 파일은 프로퍼티, 파일 각각에 붙어있는 이름표는 객체의 키라고 생각하시면 됩니다. 복잡한 서랍장 안에서 이름표를 보고 원하는 파일을 쉽게 찾을 수 있듯이, 객체에선 키를 이용해 프로퍼티를 쉽게 찾을 수 있습니다. 추가나 삭제도 마찬가지입니다.

빈 객체(빈 서랍장)를 만드는 방법은 두 가지가 있습니다.

let user = new Object(); // '객체 생성자' 문법
let user = {};  // '객체 리터럴' 문법

중괄호 {...}를 이용해 객체를 선언하는 것을 객체 리터럴(object literal) 이라고 부릅니다. 객체를 선언할 땐 주로 이 방법을 사용합니다.

리터럴과 프로퍼티

중괄호 {...} 안에는 ‘키: 값’ 쌍으로 구성된 프로퍼티가 들어갑니다.

let user = {     // 객체
  name: "John",  // 키: "name",  값: "John"
  age: 30        // 키: "age", 값: 30
};

'콜론(:)'을 기준으로 왼쪽엔 키가, 오른쪽엔 값이 위치합니다. 프로퍼티 키는 프로퍼티 ‘이름’ 혹은 '식별자’라고도 부릅니다.

객체 user에는 프로퍼티가 두 개 있습니다.

  1. 첫 번째 프로퍼티 – "name"(이름)과 "John"(값)
  2. 두 번째 프로퍼티 – "age"(이름)과 30(값)

서랍장(객체 user) 안에 파일 두 개(프로퍼티 두 개)가 담겨있는데, 각 파일에 “name”, "age"라는 이름표가 붙어있다고 생각하시면 쉽습니다.

서랍장에 파일을 추가하고 뺄 수 있듯이 개발자는 프로퍼티를 추가, 삭제할 수 있습니다.

점 표기법(dot notation)을 이용하면 프로퍼티 값을 읽는 것도 가능합니다.

// 프로퍼티 값 얻기
alert( user.name ); // John
alert( user.age ); // 30

프로퍼티 값엔 모든 자료형이 올 수 있습니다. 불린형 프로퍼티를 추가해봅시다.

user.isAdmin = true;

delete 연산자를 사용하면 프로퍼티를 삭제할 수 있습니다.

delete user.age;

여러 단어를 조합해 프로퍼티 이름을 만든 경우엔 프로퍼티 이름을 따옴표로 묶어줘야 합니다.

let user = {
  name: "John",
  age: 30,
  "likes birds": true  // 복수의 단어는 따옴표로 묶어야 합니다.
};

마지막 프로퍼티 끝은 쉼표로 끝날 수 있습니다.

let user = {
  name: "John",
  age: 30*,*}

이런 쉼표를 ‘trailing(길게 늘어지는)’ 혹은 ‘hanging(매달리는)’ 쉼표라고 부릅니다. 이렇게 끝에 쉼표를 붙이면 모든 프로퍼티가 유사한 형태를 보이기 때문에 프로퍼티를 추가, 삭제, 이동하는 게 쉬워집니다.

ℹ️상수 객체는 수정될 수 있습니다.

주의하세요. const로 선언된 객체는 수정될 수 있습니다.

const user = {
  name: "John"
};

*user.name = "Pete"; // (*)*alert(user.name); // Pete

(*)로 표시한 줄에서 오류를 일으키는 것처럼 보일 수 있지만 그렇지 않습니다. const는 user의 값을 고정하지만, 그 내용은 고정하지 않습니다.

const는 user=...를 전체적으로 설정하려고 할 때만 오류가 발생합니다.

상수 객체 프로퍼티를 만드는 또 다른 방법이 있습니다. 이후에 프로퍼티 플래그와 설명자 챕터에서 다루겠습니다.

대괄호 표기법

여러 단어를 조합해 프로퍼티 키를 만든 경우엔, 점 표기법을 사용해 프로퍼티 값을 읽을 수 없습니다.

// 문법 에러가 발생합니다.
user.likes birds = true

자바스크립트는 위와 같은 코드를 이해하지 못합니다. user.likes까지는 이해하다가 예상치 못한 birds를 만나면 문법 에러를 뱉어냅니다.

'점’은 키가 '유효한 변수 식별자’인 경우에만 사용할 수 있습니다. 유효한 변수 식별자엔 공백이 없어야 합니다. 또한 숫자로 시작하지 않아야 하며 $와 _를 제외한 특수 문자가 없어야 합니다.

키가 유효한 변수 식별자가 아닌 경우엔 점 표기법 대신에 '대괄호 표기법(square bracket notation)'이라 불리는 방법을 사용할 수 있습니다. 대괄호 표기법은 키에 어떤 문자열이 있던지 상관없이 동작합니다.

let user = {};

// set
user["likes birds"] = true;

// get
alert(user["likes birds"]); // true

// delete
delete user["likes birds"];

이제 문법 에러가 발생하지 않네요. 대괄호 표기법 안에서 문자열을 사용할 땐 문자열을 따옴표로 묶어줘야 한다는 점에 주의하시기 바랍니다. 따옴표의 종류는 상관없습니다.

대괄호 표기법을 사용하면 아래 예시에서 변수를 키로 사용한 것과 같이 문자열뿐만 아니라 모든 표현식의 평가 결과를 프로퍼티 키로 사용할 수 있습니다.

let key = "likes birds";

// user["likes birds"] = true; 와 같습니다.
user[key] = true;

변수 key는 런타임에 평가되기 때문에 사용자 입력값 변경 등에 따라 값이 변경될 수 있습니다. 어떤 경우든, 평가가 끝난 이후의 결과가 프로퍼티 키로 사용됩니다. 이를 응용하면 코드를 유연하게 작성할 수 있습니다.

let user = {
  name: "John",
  age: 30
};

let key = prompt("사용자의 어떤 정보를 얻고 싶으신가요?", "name");

// 변수로 접근
alert( user[key] ); // John (프롬프트 창에 "name"을 입력한 경우)

그런데 점 표기법은 이런 방식이 불가능합니다.

let user = {
  name: "John",
  age: 30
};

let key = "name";
alert( user.key ) // undefined

계산된 프로퍼티

객체를 만들 때 객체 리터럴 안의 프로퍼티 키가 대괄호로 둘러싸여 있는 경우, 이를 계산된 프로퍼티(computed property) 라고 부릅니다.

let fruit = prompt("어떤 과일을 구매하시겠습니까?", "apple");

let bag = {
  *[fruit]: 5, // 변수 fruit에서 프로퍼티 이름을 동적으로 받아 옵니다.*
};

alert( bag.apple ); // fruit에 "apple"이 할당되었다면, 5가 출력됩니다.

위 예시에서 [fruit]는 프로퍼티 이름을 변수 fruit에서 가져오겠다는 것을 의미합니다.

사용자가 프롬프트 대화상자에 apple을 입력했다면 bag엔 {apple: 5}가 할당되었을 겁니다.

아래 예시는 위 예시와 동일하게 동작합니다.

let fruit = prompt("어떤 과일을 구매하시겠습니까?", "apple");
let bag = {};

// 변수 fruit을 사용해 프로퍼티 이름을 만들었습니다.
bag[fruit] = 5;

두 방식 중 계산된 프로퍼티를 사용한 예시가 더 깔끔해 보이네요.

한편, 다음 예시처럼 대괄호 안에는 복잡한 표현식이 올 수도 있습니다.

let fruit = 'apple';
let bag = {
  [fruit + 'Computers']: 5 // bag.appleComputers = 5
};

대괄호 표기법은 프로퍼티 이름과 값의 제약을 없애주기 때문에 점 표기법보다 훨씬 강력합니다. 그런데 작성하기 번거롭다는 단점이 있습니다.

이런 이유로 프로퍼티 이름이 확정된 상황이고, 단순한 이름이라면 처음엔 점 표기법을 사용하다가 뭔가 복잡한 상황이 발생했을 때 대괄호 표기법으로 바꾸는 경우가 많습니다.

대괄호 표기법은 프로퍼티 이름과 값의 제약을 없애주기 때문에 점 표기법보다 훨씬 강력합니다. 그런데 작성하기 번거롭다는 단점이 있습니다.

이런 이유로 프로퍼티 이름이 확정된 상황이고, 단순한 이름이라면 처음엔 점 표기법을 사용하다가 뭔가 복잡한 상황이 발생했을 때 대괄호 표기법으로 바꾸는 경우가 많습니다.

단축 프로퍼티

실무에선 프로퍼티 값을 기존 변수에서 받아와 사용하는 경우가 종종 있습니다.

function makeUser(name, age) {
  return {
    name: name,
    age: age,
    // ...등등
  };
}

let user = makeUser("John", 30);
alert(user.name); // John

위 예시의 프로퍼티들은 이름과 값이 변수의 이름과 동일하네요. 이렇게 변수를 사용해 프로퍼티를 만드는 경우는 아주 흔한데, 프로퍼티 값 단축 구문(property value shorthand) 을 사용하면 코드를 짧게 줄일 수 있습니다.

name:name 대신 name만 적어주어도 프로퍼티를 설정할 수 있죠.

function makeUser(name, age) {
  *return {
    name, // name: name 과 같음
    age,  // age: age 와 같음
    // ...
  };*}

한 객체에서 일반 프로퍼티와 단축 프로퍼티를 함께 사용하는 것도 가능합니다.

let user = {
  name,  // name: name 과 같음
  age: 30
};

프로퍼티 이름의 제약사항

아시다시피 변수 이름(키)엔 ‘for’, ‘let’, ‘return’ 같은 예약어를 사용하면 안됩니다.

그런데 객체 프로퍼티엔 이런 제약이 없습니다.

// 예약어를 키로 사용해도 괜찮습니다.
let obj = {
  for: 1,
  let: 2,
  return: 3
};

alert( obj.for + obj.let + obj.return );  // 6

이와 같이 프로퍼티 이름엔 특별한 제약이 없습니다. 어떤 문자형, 심볼형 값도 프로퍼티 키가 될 수 있죠(식별자로 쓰이는 심볼형에 대해선 뒤에서 다룰 예정입니다).

문자형이나 심볼형에 속하지 않은 값은 문자열로 자동 형 변환됩니다.

예시를 살펴봅시다. 키에 숫자 0을 넣으면 문자열 "0"으로 자동변환됩니다.

let obj = {
  0: "test" // "0": "test"와 동일합니다.
};

// 숫자 0은 문자열 "0"으로 변환되기 때문에 두 얼럿 창은 같은 프로퍼티에 접근합니다,
alert( obj["0"] ); // test
alert( obj[0] ); // test (동일한 프로퍼티)

이와같이 객체 프로퍼티 키에 쓸 수 있는 문자열엔 제약이 없지만, 역사적인 이유 때문에 특별 대우를 받는 이름이 하나 있습니다. 바로, __proto__입니다.

let obj = {};
obj.__proto__ = 5; // 숫자를 할당합니다.
alert(obj.__proto__); // [object Object] - 숫자를 할당했지만 값은 객체가 되었습니다. 의도한대로 동작하지 않네요.

원시값 5를 할당했는데 무시된 것을 확인할 수 있습니다.

__proto__의 본질은 프로토타입 상속에서, 이 문제를 어떻게 해결할 수 있을지에 대해선 프로토타입 메서드와 __proto__가 없는 객체에서 자세히 다룰 예정입니다.

‘in’ 연산자로 프로퍼티 존재 여부 확인하기

자바스크립트 객체의 중요한 특징 중 하나는 다른 언어와는 달리, 존재하지 않는 프로퍼티에 접근하려 해도 에러가 발생하지 않고 undefined를 반환한다는 것입니다.

이런 특징을 응용하면 프로퍼티 존재 여부를 쉽게 확인할 수 있습니다.

let user = {};

alert( user.noSuchProperty === undefined ); // true는 '프로퍼티가 존재하지 않음'을 의미합니다.

이렇게 undefined와 비교하는 것 이외에도 연산자 in을 사용하면 프로퍼티 존재 여부를 확인할 수 있습니다.

문법은 다음과 같습니다.

"key" in object
let user = { name: "John", age: 30 };

alert( "age" in user ); // user.age가 존재하므로 true가 출력됩니다.
alert( "blabla" in user ); // user.blabla는 존재하지 않기 때문에 false가 출력됩니다.

in 왼쪽엔 반드시 프로퍼티 이름이 와야 합니다. 프로퍼티 이름은 보통 따옴표로 감싼 문자열입니다.

따옴표를 생략하면 아래 예시와 같이 엉뚱한 변수가 조사 대상이 됩니다.

let user = { age: 30 };

let key = "age";
alert( *key* in user ); // true, 변수 key에 저장된 값("age")을 사용해 프로퍼티 존재 여부를 확인합니다.

그런데 이쯤 되면 "undefined랑 비교해도 충분한데 왜 in 연산자가 있는 거지?"라는 의문이 들 수 있습니다.

대부분의 경우, 일치 연산자를 사용해서 프로퍼티 존재 여부를 알아내는 방법("=== undefined")은 꽤 잘 동작합니다. 그런데 가끔은 이 방법이 실패할 때도 있습니다. 이럴 때 in을 사용하면 프로퍼티 존재 여부를 제대로 판별할 수 있습니다.

프로퍼티는 존재하는데, 값에 undefined를 할당한 예시를 살펴봅시다.

let obj = {
  test: undefined
};

alert( obj.test ); // 값이 `undefined`이므로, 얼럿 창엔 undefined가 출력됩니다. 그런데 프로퍼티 test는 존재합니다.

alert( "test" in obj ); // `in`을 사용하면 프로퍼티 유무를 제대로 확인할 수 있습니다(true가 출력됨).

obj.test는 실제 존재하는 프로퍼티입니다. 따라서 in 연산자는 정상적으로 true를 반환합니다.

undefined는 변수는 정의되어 있으나 값이 할당되지 않은 경우에 쓰기 때문에 프로퍼티 값이 undefined인 경우는 흔치 않습니다. 값을 ‘알 수 없거나(unknown)’ 값이 ‘비어 있다는(empty)’ 것을 나타낼 때는 주로 null을 사용합니다. 위 예시에서 in 연산자는 자리에 어울리지 않는 초대손님처럼 보이네요.

'for...in' 반복문

for..in 반복문을 사용하면 객체의 모든 키를 순회할 수 있습니다. for..in은 앞서 학습했던 for(;;) 반복문과는 완전히 다릅니다.

문법:

for (key in object) {
  // 각 프로퍼티 키(key)를 이용하여 본문(body)을 실행합니다.
}

아래 예시를 실행하면 객체 user의 모든 프로퍼티가 출력됩니다.

let user = {
  name: "John",
  age: 30,
  isAdmin: true
};

for (let key in user) {
  // 키
  alert( key );  // name, age, isAdmin
  // 키에 해당하는 값
  alert( user[key] ); // John, 30, true
}

for..in 반복문에서도 for(;;)문처럼 반복 변수(looping variable)를 선언(let key)했다는 점에 주목해 주시기 바랍니다.

반복 변수명은 자유롭게 정할 수 있습니다. 'for (let prop in obj)'같이 key 말고 다른 변수명을 사용해도 괜찮습니다.

객체 정렬 방식

객체와 객체 프로퍼티를 다루다 보면 "프로퍼티엔 순서가 있을까?"라는 의문이 생기기 마련입니다. 반복문은 프로퍼티를 추가한 순서대로 실행될지, 그리고 이 순서는 항상 동일할지 궁금해지죠.

답은 간단합니다. 객체는 '특별한 방식으로 정렬’됩니다. 정수 프로퍼티(integer property)는 자동으로 정렬되고, 그 외의 프로퍼티는 객체에 추가한 순서 그대로 정렬됩니다. 자세한 내용은 예제를 통해 살펴봅시다.

아래 객체엔 국제전화 나라 번호가 담겨있습니다.

let codes = {
  "49": "독일",
  "41": "스위스",
  "44": "영국",
  // ..,
  "1": "미국"
};

*for (let code in codes) {
  alert(code); // 1, 41, 44, 49
}*

현재 개발 중인 애플리케이션의 주 사용자가 독일인이라고 가정해 봅시다. 나라 번호를 선택하는 화면에서 49가 맨 앞에 오도록 하는 게 좋을 겁니다.

그런데 코드를 실행해 보면 예상과는 전혀 다른 결과가 출력됩니다.

  • 미국(1)이 첫 번째로 출력됩니다.
  • 그 뒤로 스위스(41), 영국(44), 독일(49)이 차례대로 출력됩니다.

이유는 나라 번호(키)가 정수이어서 1, 41, 44, 49 순으로 프로퍼티가 자동 정렬되었기 때문입니다.

한편, 키가 정수가 아닌 경우엔 작성된 순서대로 프로퍼티가 나열됩니다. 예시를 살펴봅시다.

let user = {
  name: "John",
  surname: "Smith"
};
user.age = 25; // 프로퍼티를 하나 추가합니다.

*// 정수 프로퍼티가 아닌 프로퍼티는 추가된 순서대로 나열됩니다.*for (let prop in user) {
  alert( prop ); // name, surname, age
}

위 예시에서 49(독일 나라 번호)를 가장 위에 출력되도록 하려면 나라 번호가 정수로 취급되지 않도록 속임수를 쓰면 됩니다. 각 나라 번호 앞에 "+"를 붙여봅시다.

아래 같이 말이죠.

let codes = {
  "+49": "독일",
  "+41": "스위스",
  "+44": "영국",
  // ..,
  "+1": "미국"
};

for (let code in codes) {
  alert( +code ); // 49, 41, 44, 1
}

이제 원하는 대로 독일 나라 번호가 가장 먼저 출력되는 것을 확인할 수 있습니다.

요약

객체는 몇 가지 특수한 기능을 가진 연관 배열(associative array)입니다.

객체는 프로퍼티(키-값 쌍)를 저장합니다.

  • 프로퍼티 키는 문자열이나 심볼이어야 합니다. 보통은 문자열입니다.
  • 값은 어떤 자료형도 가능합니다.

아래와 같은 방법을 사용하면 프로퍼티에 접근할 수 있습니다.

  • 점 표기법: obj.property
  • 대괄호 표기법 obj["property"]. 대괄호 표기법을 사용하면 obj[varWithKey]같이 변수에서 키를 가져올 수 있습니다.

객체엔 다음과 같은 추가 연산자를 사용할 수 있습니다.

  • 프로퍼티를 삭제하고 싶을 때: delete obj.prop
  • 해당 key를 가진 프로퍼티가 객체 내에 있는지 확인하고자 할 때: "key" in obj
  • 프로퍼티를 나열할 때: for (let key in obj)

지금까진 '순수 객체(plain object)'라 불리는 일반 객체에 대해 학습했습니다.

자바스크립트에는 일반 객체 이외에도 다양한 종류의 객체가 있습니다.

  • Array – 정렬된 데이터 컬렉션을 저장할 때 쓰임
  • Date – 날짜와 시간 정보를 저장할 때 쓰임
  • Error – 에러 정보를 저장할 때 쓰임
  • 기타 등등

객체마다 고유의 기능을 제공하는데, 이에 대해선 추후 학습하겠습니다. 사람들은 종종 'Array 타입’이나 'Date 타입’이라는 용어를 쓰곤 합니다. 사실 Array와 Date는 독립적인 자료형이 아니라 '객체’형에 속합니다. 객체에 다양한 기능을 넣어 확장한 또 다른 객체이죠.

4.2 참조에 의한 객체 복사

객체와 원시 타입의 근본적인 차이 중 하나는 객체는 ‘참조에 의해(by reference)’ 저장되고 복사된다는 것입니다.

원시값(문자열, 숫자, 불린 값)은 ‘값 그대로’ 저장·할당되고 복사되는 반면에 말이죠.

let message = "Hello!";
let phrase = message;

그런데 객체의 동작방식은 이와 다릅니다.

변수엔 객체가 그대로 저장되는 것이 아니라, 객체가 저장되어있는 '메모리 주소’인 객체에 대한 '참조 값’이 저장됩니다.

그림을 통해 변수 user에 객체를 할당할 때 무슨 일이 일어나는지 알아봅시다.

let user = {
  name: "John"
};

객체는 메모리 내 어딘가에 저장되고, 변수 user엔 객체를 '참조’할 수 있는 값이 저장됩니다.

따라서 객체가 할당된 변수를 복사할 땐 객체의 참조 값이 복사되고 객체는 복사되지 않습니다.

let user = { name: "John" };

let admin = user; // 참조값을 복사함

변수는 두 개이지만 각 변수엔 동일 객체에 대한 참조 값이 저장되죠.

따라서 객체에 접근하거나 객체를 조작할 땐 여러 변수를 사용할 수 있습니다.

let user = { name: 'John' };

let admin = user;

*admin.name = 'Pete'; // 'admin' 참조 값에 의해 변경됨*

alert(*user.name*); // 'Pete'가 출력됨. 'user' 참조 값을 이용해 변경사항을 확인함

객체를 서랍장에 비유하면 변수는 서랍장을 열 수 있는 열쇠라고 할 수 있습니다. 서랍장은 하나, 서랍장을 열 수 있는 열쇠는 두 개인데, 그중 하나(admin)를 사용해 서랍장을 열어 정돈한 후, 또 다른 열쇠로 서랍장을 열면 정돈된 내용을 볼 수 있습니다.

참조에 의한 비교

객체 비교 시 동등 연산자 ==와 일치 연산자 ===는 동일하게 동작합니다.

비교 시 피연산자인 두 객체가 동일한 객체인 경우에 참을 반환하죠.

두 변수가 같은 객체를 참조하는 예시를 살펴봅시다. 일치·동등 비교 모두에서 참이 반환됩니다.

let a = {};
let b = a; // 참조에 의한 복사

alert( a == b ); // true, 두 변수는 같은 객체를 참조합니다.
alert( a === b ); // true

다른 예시를 살펴봅시다. 두 객체 모두 비어있다는 점에서 같아 보이지만, 독립된 객체이기 때문에 일치·동등 비교하면 거짓이 반환됩니다.

let a = {};
let b = {}; // 독립된 두 객체

alert( a == b ); // false

obj1 > obj2 같은 대소 비교나 obj == 5 같은 원시값과의 비교에선 객체가 원시형으로 변환됩니다. 객체가 어떻게 원시형으로 변하는지에 대해선 곧 학습할 예정인데, 이러한 비교(객체끼리의 대소 비교나 원시값과 객체를 비교하는 것)가 필요한 경우는 매우 드물긴 합니다. 대개 코딩 실수 때문에 이런 비교가 발생합니다.

객체 복사, 병합과 Object.assign

객체가 할당된 변수를 복사하면 동일한 객체에 대한 참조 값이 하나 더 만들어진다는 걸 배웠습니다.

그런데 객체를 복제하고 싶다면 어떻게 해야 할까요? 기존에 있던 객체와 똑같으면서 독립적인 객체를 만들고 싶다면 말이죠.

방법은 있는데 자바스크립트는 객체 복제 내장 메서드를 지원하지 않기 때문에 조금 어렵습니다. 사실 객체를 복제해야 할 일은 거의 없습니다. 참조에 의한 복사로 해결 가능한 일이 대다수이죠.

정말 복제가 필요한 상황이라면 새로운 객체를 만든 다음 기존 객체의 프로퍼티들을 순회해 원시 수준까지 프로퍼티를 복사하면 됩니다.

아래와 같이 말이죠.

let user = {
  name: "John",
  age: 30
};

let clone = {}; // 새로운 빈 객체

// 빈 객체에 user 프로퍼티 전부를 복사해 넣습니다.
for (let key in user) {
  clone[key] = user[key];
}

**// 이제 clone은 완전히 독립적인 복제본이 되었습니다.
clone.name = "Pete"; // clone의 데이터를 변경합니다.

alert( user.name ); // 기존 객체에는 여전히 John이 있습니다.

Object.assign를 사용하는 방법도 있습니다.

문법과 동작방식은 다음과 같습니다.

Object.assign(dest, [src1, src2, src3...])
  • 첫 번째 인수 dest는 목표로 하는 객체입니다.
  • 이어지는 인수 src1, ..., srcN는 복사하고자 하는 객체입니다. ...은 필요에 따라 얼마든지 많은 객체를 인수로 사용할 수 있다는 것을 나타냅니다.
  • 객체 src1, ..., srcN의 프로퍼티를 dest에 복사합니다. dest를 제외한 인수(객체)의 프로퍼티 전부가 첫 번째 인수(객체)로 복사됩니다.
  • 마지막으로 dest를 반환합니다.

assign 메서드를 사용해 여러 객체를 하나로 병합하는 예시를 살펴봅시다.

let user = { name: "John" };

let permissions1 = { canView: true };
let permissions2 = { canEdit: true };

// permissions1과 permissions2의 프로퍼티를 user로 복사합니다.
Object.assign(user, permissions1, permissions2);

**// now user = { name: "John", canView: true, canEdit: true }

목표 객체(user)에 동일한 이름을 가진 프로퍼티가 있는 경우엔 기존 값이 덮어씌워 집니다.

let user = { name: "John" };

Object.assign(user, { name: "Pete" });

alert(user.name); // user = { name: "Pete" }

Object.assign을 사용하면 반복문 없이도 간단하게 객체를 복사할 수 있습니다.

let user = {
  name: "John",
  age: 30
};

*let clone = Object.assign({}, user);*

예시를 실행하면 user에 있는 모든 프로퍼티가 빈 배열에 복사되고 변수에 할당됩니다.

중첩 객체 복사

지금까진 user의 모든 프로퍼티가 원시값인 경우만 가정했습니다. 그런데 프로퍼티는 다른 객체에 대한 참조 값일 수도 있습니다. 이 경우는 어떻게 해야 할까요?

아래와 같이 말이죠.

let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

alert( user.sizes.height ); // 182

clone.sizes = user.sizes로 프로퍼티를 복사하는 것만으론 객체를 복제할 수 없습니다. user.sizes는 객체이기 때문에 참조 값이 복사되기 때문입니다. clone.sizes = user.sizes로 프로퍼티를 복사하면 clone과 user는 같은 sizes를 공유하게 됩니다.

아래와 같이 말이죠.

let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

let clone = Object.assign({}, user);

alert( user.sizes === clone.sizes ); // true, 같은 객체입니다.

// user와 clone는 sizes를 공유합니다.
user.sizes.width++;       // 한 객체에서 프로퍼티를 변경합니다.
alert(clone.sizes.width); // 51, 다른 객체에서 변경 사항을 확인할 수 있습니다.

이 문제를 해결하려면 user[key]의 각 값을 검사하면서, 그 값이 객체인 경우 객체의 구조도 복사해주는 반복문을 사용해야 합니다. 이런 방식을 '깊은 복사(deep cloning)'라고 합니다.

깊은 복사 시 사용되는 표준 알고리즘인 Structured cloning algorithm을 사용하면 위 사례를 비롯한 다양한 상황에서 객체를 복제할 수 있습니다.

자바스크립트 라이브러리 lodash의 메서드인 _.cloneDeep(obj)을 사용하면 이 알고리즘을 직접 구현하지 않고도 깊은 복사를 처리할 수 있으므로 참고하시기 바랍니다.

요약

객체는 참조에 의해 할당되고 복사됩니다. 변수엔 ‘객체’ 자체가 아닌 메모리상의 주소인 '참조’가 저장됩니다. 따라서 객체가 할당된 변수를 복사하거나 함수의 인자로 넘길 땐 객체가 아닌 객체의 참조가 복사됩니다.

그리고 복사된 참조를 이용한 모든 작업(프로퍼티 추가·삭제 등)은 동일한 객체를 대상으로 이뤄집니다.

객체의 '진짜 복사본’을 만들려면 '얕은 복사(shallow copy)'를 가능하게 해주는 Object.assign이나 '깊은 복사’를 가능하게 해주는 _.cloneDeep(obj)를 사용하면 됩니다. 이때 얕은 복사본은 중첩 객체를 처리하지 못한다는 점을 기억해 두시기 바랍니다.

4.3 가비지 컬렉션

자바스크립트는 눈에 보이지 않는 곳에서 메모리 관리를 수행합니다.

가비지 컬렉션 기준

자바스크립트는 도달 가능성(reachability) 이라는 개념을 사용해 메모리 관리를 수행합니다.

‘도달 가능한(reachable)’ 값은 쉽게 말해 어떻게든 접근하거나 사용할 수 있는 값을 의미합니다. 도달 가능한 값은 메모리에서 삭제되지 않습니다.

  1. 아래 소개해 드릴 값들은 그 태생부터 도달 가능하기 때문에, 명백한 이유 없이는 삭제되지 않습니다.

    • 현재 함수의 지역 변수와 매개변수
    • 중첩 함수의 체인에 있는 함수에서 사용되는 변수와 매개변수
    • 전역 변수
    • 기타 등등

    이런 값은 루트(root) 라고 부릅니다.

  2. 루트가 참조하는 값이나 체이닝으로 루트에서 참조할 수 있는 값은 도달 가능한 값이 됩니다.

    전역 변수에 객체가 저장되어있다고 가정해 봅시다. 이 객체의 프로퍼티가 또 다른 객체를 참조하고 있다면, 프로퍼티가 참조하는 객체는 도달 가능한 값이 됩니다. 이 객체가 참조하는 다른 모든 것들도 도달 가능하다고 여겨집니다. 자세한 예시는 아래에서 살펴보겠습니다.

자바스크립트 엔진 내에선 가비지 컬렉터(garbage collector)가 끊임없이 동작합니다. 가비지 컬렉터는 모든 객체를 모니터링하고, 도달할 수 없는 객체는 삭제합니다.

간단한 예시

아주 간단한 예시가 있습니다.

// user엔 객체 참조 값이 저장됩니다.
let user = {
  name: "John"
};

이 그림에서 화살표는 객체 참조를 나타냅니다. 전역 변수 "user"는 {name: "John"} (줄여서 John)이라는 객체를 참조합니다. John의 프로퍼티 "name"은 원시값을 저장하고 있기 때문에 객체 안에 표현했습니다.

user의 값을 다른 값으로 덮어쓰면 참조(화살표)가 사라집니다.

user = null;

이제 John은 도달할 수 없는 상태가 되었습니다. John에 접근할 방법도, John을 참조하는 것도 모두 사라졌습니다. 가비지 컬렉터는 이제 John에 저장된 데이터를 삭제하고, John을 메모리에서 삭제합니다.

참조 두 개

참조를 user에서 admin으로 복사했다고 가정해봅시다.

// user엔 객체 참조 값이 저장됩니다.
let user = {
  name: "John"
};

*let admin = user;*

그리고 위에서 한것 처럼 user의 값을 다른 값으로 덮어써 봅시다.

user = null;

전역 변수 admin을 통하면 여전히 객체 John에 접근할 수 있기 때문에 John은 메모리에서 삭제되지 않습니다. 이 상태에서 admin을 다른 값(null 등)으로 덮어쓰면 John은 메모리에서 삭제될 수 있습니다.

연결된 객체

이제 가족관계를 나타내는 복잡한 예시를 살펴보겠습니다.

function marry(man, woman) {
  woman.husband = man;
  man.wife = woman;

  return {
    father: man,
    mother: woman
  }
}

let family = marry({
  name: "John"
}, {
  name: "Ann"
});

함수 marry(결혼하다)는 매개변수로 받은 두 객체를 서로 참조하게 하면서 '결혼’시키고, 두 객체를 포함하는 새로운 객체를 반환합니다.

메모리 구조는 아래와 같이 나타낼 수 있습니다.

지금은 모든 객체가 도달 가능한 상태입니다.

이제 참조 두 개를 지워보겠습니다.

delete family.father;
delete family.mother.husband;

삭제한 두 개의 참조 중 하나만 지웠다면, 모든 객체가 여전히 도달 가능한 상태였을 겁니다.

하지만 참조 두 개를 지우면 John으로 들어오는 참조(화살표)는 모두 사라져 John은 도달 가능한 상태에서 벗어납니다.

외부로 나가는 참조는 도달 가능한 상태에 영향을 주지 않습니다. 외부에서 들어오는 참조만이 도달 가능한 상태에 영향을 줍니다. John은 이제 도달 가능한 상태가 아니기 때문에 메모리에서 제거됩니다. John에 저장된 데이터(프로퍼티) 역시 메모리에서 사라집니다.

가비지 컬렉션 후 메모리 구조는 아래와 같습니다.

도달할 수 없는 섬

객체들이 연결되어 섬 같은 구조를 만드는데, 이 섬에 도달할 방법이 없는 경우, 섬을 구성하는 객체 전부가 메모리에서 삭제됩니다.

근원 객체 family가 아무것도 참조하지 않도록 해 봅시다.

family = null;

이제 메모리 내부 상태는 다음과 같아집니다.

도달할 수 없는 섬 예제는 도달 가능성이라는 개념이 얼마나 중요한지 보여줍니다.

John과 Ann은 여전히 서로를 참조하고 있고, 두 객체 모두 외부에서 들어오는 참조를 갖고 있지만, 이것만으로는 충분하지 않다는걸 보여주죠.

"family" 객체와 루트의 연결이 사라지면 루트 객체를 참조하는 것이 아무것도 없게 됩니다. 섬 전체가 도달할 수 없는 상태가 되고, 섬을 구성하는 객체 전부가 메모리에서 제거되죠.

내부 알고리즘

'mark-and-sweep’이라 불리는 가비지 컬렉션 기본 알고리즘에 대해 알아봅시다.

'가비지 컬렉션’은 대개 다음 단계를 거쳐 수행됩니다.

  • 가비지 컬렉터는 루트(root) 정보를 수집하고 이를 ‘mark(기억)’ 합니다.
  • 루트가 참조하고 있는 모든 객체를 방문하고 이것들을 ‘mark’ 합니다.
  • mark 된 모든 객체에 방문하고 그 객체들이 참조하는 객체도 mark 합니다. 한번 방문한 객체는 전부 mark 하기 때문에 같은 객체를 다시 방문하는 일은 없습니다.
  • 루트에서 도달 가능한 모든 객체를 방문할 때까지 위 과정을 반복합니다.
  • mark 되지 않은 모든 객체를 메모리에서 삭제합니다.

다음과 같은 객체 구조가 있다고 해봅시다.

오른편에 '도달할 수 없는 섬’이 보이네요. 이제 가비지 컬렉터의 ‘mark-and-sweep’ 알고리즘이 이것을 어떻게 처리하는지 봅시다.

첫 번째 단계에선 루트를 mark 합니다.

이후 루트가 참조하고 있는 것들을 mark 합니다.

도달 가능한 모든 객체를 방문할 때까지, mark 한 객체가 참조하는 객체를 계속해서 mark 합니다.

방문할 수 없었던 객체를 메모리에서 삭제합니다.

루트에서 페인트를 들이붓는다고 상상하면 이 과정을 이해하기 쉽습니다. 루트를 시작으로 참조를 따라가면서 도달가능한 객체 모두에 페인트가 칠해진다고 생각하면 됩니다. 이때 페인트가 묻지 않은 객체는 메모리에서 삭제됩니다.

지금까지 가비지 컬렉션이 어떻게 동작하는지에 대한 개념을 알아보았습니다. 자바스크립트 엔진은 실행에 영향을 미치지 않으면서 가비지 컬렉션을 더 빠르게 하는 다양한 최적화 기법을 적용합니다.

최적화 기법:

  • generational collection(세대별 수집) – 객체를 '새로운 객체’와 '오래된 객체’로 나눕니다. 객체 상당수는 생성 이후 제 역할을 빠르게 수행해 금방 쓸모가 없어지는데, 이런 객체를 '새로운 객체’로 구분합니다. 가비지 컬렉터는 이런 객체를 공격적으로 메모리에서 제거합니다. 일정 시간 이상 동안 살아남은 객체는 '오래된 객체’로 분류하고, 가비지 컬렉터가 덜 감시합니다.
  • incremental collection(점진적 수집) – 방문해야 할 객체가 많다면 모든 객체를 한 번에 방문하고 mark 하는데 상당한 시간이 소모됩니다. 가비지 컬렉션에 많은 리소스가 사용되어 실행 속도도 눈에 띄게 느려지겠죠. 자바스크립트 엔진은 이런 현상을 개선하기 위해 가비지 컬렉션을 여러 부분으로 분리한 다음, 각 부분을 별도로 수행합니다. 작업을 분리하고, 변경 사항을 추적하는 데 추가 작업이 필요하긴 하지만, 긴 지연을 짧은 지연 여러 개로 분산시킬 수 있다는 장점이 있습니다.
  • idle-time collection(유휴 시간 수집) – 가비지 컬렉터는 실행에 주는 영향을 최소화하기 위해 CPU가 유휴 상태일 때에만 가비지 컬렉션을 실행합니다.

이 외에도 다양한 최적화 기법과 가비지 컬렉션 알고리즘이 있습니다. 다양한 기법과 알고리즘을 소개해 드리고 싶지만, 엔진마다 세부 사항이나 기법이 다르기 때문에 여기서 멈추도록 하겠습니다. 엔진이 발전하면 기법도 달라지기 때문에 학습해야 할 이유가 진짜 없다면 ‘심화’ 학습은 그리 가치 있지 않다고 생각합니다. 순수한 호기심 때문이라면 물론 괜찮습니다. 이런 분들을 위해 아래에 링크를 몇 개를 소개해놓았습니다.

요약

지금까지 알아본 내용을 요약해 봅시다.

  • 가비지 컬렉션은 엔진이 자동으로 수행하므로 개발자는 이를 억지로 실행하거나 막을 수 없습니다.
  • 객체는 도달 가능한 상태일 때 메모리에 남습니다.
  • 참조된다고 해서 도달 가능한 것은 아닙니다. 서로 연결된 객체들도 도달 불가능할 수 있습니다.

모던 자바스크립트 엔진은 좀 더 발전된 가비지 컬렉션 알고리즘을 사용합니다.

어떤 알고리즘을 사용하는지 궁금하다면 ‘The Garbage Collection Handbook: The Art of Automatic Memory Management’(저자 – R. Jones et al)를 참고하시기 바랍니다.

저수준(low-level) 프로그래밍에 익숙하다면, A tour of V8: Garbage Collection을 읽어보세요. V8 가비지 컬렉터에 대한 자세한 내용을 확인해 볼 수 있습니다.

V8 공식 블로그에도 메모리 관리 방법 변화에 대한 내용이 올라옵니다. 가비지 컬렉션을 심도 있게 학습하려면 V8 내부구조를 공부하거나 V8 엔지니어로 일했던 Vyacheslav Egorov의 블로그를 읽는 것도 좋습니다. 여러 엔진 중 ‘V8’ 엔진을 언급하는 이유는 인터넷에서 관련 글을 쉽게 찾을 수 있기 때문입니다. V8과 타 엔진들은 동작 방법이 비슷한데, 가비지 컬렉션 동작 방식에는 많은 차이가 있습니다.

저수준 최적화가 필요한 상황이라면, 엔진에 대한 조예가 깊어야 합니다. 먼저 자바스크립트에 익숙해진 후에 엔진에 대해 학습하는 것을 추천해 드립니다.

4.4 메서드와 'this'

객체는 사용자(user), 주문(order) 등과 같이 실제 존재하는 개체(entity)를 표현하고자 할 때 생성됩니다.

let user = {
  name: "John",
  age: 30
};

사용자는 현실에서 장바구니에서 물건 선택하기, 로그인하기, 로그아웃하기 등의 행동을 합니다. 이와 마찬가지로 사용자를 나타내는 객체 user도 특정한 행동을 할 수 있습니다.

자바스크립트에선 객체의 프로퍼티에 함수를 할당해 객체에게 행동할 수 있는 능력을 부여해줍니다.

메서드 만들기

객체 user에게 인사할 수 있는 능력을 부여해 줍시다.

let user = {
  name: "John",
  age: 30
};

*user.sayHi = function() {
  alert("안녕하세요!");
};*

user.sayHi(); // 안녕하세요!

함수 표현식으로 함수를 만들고, 객체 프로퍼티 user.sayHi에 함수를 할당해 주었습니다.

이제 객체에 할당된 함수를 호출하면 user가 인사를 해줍니다.

이렇게 객체 프로퍼티에 할당된 함수를 메서드(method) 라고 부릅니다.

위 예시에선 user에 할당된 sayHi가 메서드이죠.

메서드는 아래와 같이 이미 정의된 함수를 이용해서 만들 수도 있습니다.

let user = {
  // ...
};

*// 함수 선언
function sayHi() {
  alert("안녕하세요!");
};

// 선언된 함수를 메서드로 등록
user.sayHi = sayHi;*

user.sayHi(); // 안녕하세요!

메서드 단축 구문

객체 리터럴 안에 메서드를 선언할 때 사용할 수 있는 단축 문법을 소개해 드리겠습니다.

// 아래 두 객체는 동일하게 동작합니다.

user = {
  sayHi: function() {
    alert("Hello");
  }
};

// 단축 구문을 사용하니 더 깔끔해 보이네요.
user = {
  *sayHi() { // "sayHi: function()"과 동일합니다.*
		alert("Hello");
  }
};

위처럼 function을 생략해도 메서드를 정의할 수 있습니다.

일반적인 방법과 단축 구문을 사용한 방법이 완전히 동일하진 않습니다. 객체 상속과 관련된 미묘한 차이가 존재하는데 지금으로선 이 차이가 중요하지 않기 때문에 넘어가도록 하겠습니다.

메서드와 'this'

메서드는 객체에 저장된 정보에 접근할 수 있어야 제 역할을 할 수 있습니다. 모든 메서드가 그런 건 아니지만, 대부분의 메서드가 객체 프로퍼티의 값을 활용합니다.

user.sayHi()의 내부 코드에서 객체 user에 저장된 이름(name)을 이용해 인사말을 만드는 경우가 이런 경우에 속합니다.

메서드 내부에서 this 키워드를 사용하면 객체에 접근할 수 있습니다.

이때 '점 앞’의 this는 객체를 나타냅니다. 정확히는 메서드를 호출할 때 사용된 객체를 나타내죠.

let user = {
  name: "John",
  age: 30,

  sayHi() {
    *// 'this'는 '현재 객체'를 나타냅니다.
    alert(this.name);*}

};

user.sayHi(); // John

user.sayHi()가 실행되는 동안에 this는 user를 나타냅니다.

this를 사용하지 않고 외부 변수를 참조해 객체에 접근하는 것도 가능합니다.

let user = {
  name: "John",
  age: 30,

  sayHi() {
    *alert(user.name); // 'this' 대신 'user'를 이용함*}

};

그런데 이렇게 외부 변수를 사용해 객체를 참조하면 예상치 못한 에러가 발생할 수 있습니다. user를 복사해 다른 변수에 할당(admin = user)하고, user는 전혀 다른 값으로 덮어썼다고 가정해 봅시다. sayHi()는 원치 않는 값(null)을 참조할 겁니다.

let user = {
  name: "John",
  age: 30,

  sayHi() {
    *alert( user.name ); // Error: Cannot read property 'name' of null*}

};

let admin = user;
user = null; // user를 null로 덮어씁니다.

admin.sayHi(); // sayHi()가 엉뚱한 객체를 참고하면서 에러가 발생했습니다.

alert 함수가 user.name 대신 this.name을 인수로 받았다면 에러가 발생하지 않았을 겁니다.

자유로운 "this"

자바스크립트의 this는 다른 프로그래밍 언어의 this와 동작 방식이 다릅니다. 자바스크립트에선 모든 함수에 this를 사용할 수 있습니다.

아래와 같이 코드를 작성해도 문법 에러가 발생하지 않습니다.

function sayHi() {
  alert( *this*.name );
}

this 값은 런타임에 결정됩니다. 컨텍스트에 따라 달라지죠.

동일한 함수라도 다른 객체에서 호출했다면 'this’가 참조하는 값이 달라집니다.

let user = { name: "John" };
let admin = { name: "Admin" };

function sayHi() {
  alert( this.name );
}

*// 별개의 객체에서 동일한 함수를 사용함
user.f = sayHi;
admin.f = sayHi;*

// 'this'는 '점(.) 앞의' 객체를 참조하기 때문에
// this 값이 달라짐
user.f(); // John  (this == user)
admin.f(); // Admin  (this == admin)

admin['f'](); // Admin (점과 대괄호는 동일하게 동작함)

규칙은 간단합니다. obj.f()를 호출했다면 this는 f를 호출하는 동안의 obj입니다. 위 예시에선 obj가 user나 admin을 참조하겠죠.

'this'가 없는 화살표 함수

화살표 함수는 일반 함수와는 달리 ‘고유한’ this를 가지지 않습니다. 화살표 함수에서 this를 참조하면, 화살표 함수가 아닌 ‘평범한’ 외부 함수에서 this 값을 가져옵니다.

아래 예시에서 함수 arrow()의 this는 외부 함수 user.sayHi()의 this가 됩니다.

let user = {
  firstName: "보라",
  sayHi() {
    let arrow = () => alert(this.firstName);
    arrow();
  }
};

user.sayHi(); // 보라

별개의 this가 만들어지는 건 원하지 않고, 외부 컨텍스트에 있는 this를 이용하고 싶은 경우 화살표 함수가 유용합니다. 이에 대한 자세한 내용은 별도의 챕터, 화살표 함수 다시 살펴보기에서 다루겠습니다.

요약

  • 객체 프로퍼티에 저장된 함수를 '메서드’라고 부릅니다.
  • object.doSomthing()은 객체를 '행동’할 수 있게 해줍니다.
  • 메서드는 this로 객체를 참조합니다.

this 값은 런타임에 결정됩니다.

  • 함수를 선언할 때 this를 사용할 수 있습니다. 다만, 함수가 호출되기 전까지 this엔 값이 할당되지 않습니다.
  • 함수를 복사해 객체 간 전달할 수 있습니다.
  • 함수를 객체 프로퍼티에 저장해 object.method()같이 ‘메서드’ 형태로 호출하면 this는 object를 참조합니다.

화살표 함수는 자신만의 this를 가지지 않는다는 점에서 독특합니다. 화살표 함수 안에서 this를 사용하면, 외부에서 this 값을 가져옵니다.

4.5 'new' 연산자와 생성자 함수

객체 리터럴 {...} 을 사용하면 객체를 쉽게 만들 수 있습니다. 그런데 개발을 하다 보면 유사한 객체를 여러 개 만들어야 할 때가 생기곤 합니다. 복수의 사용자, 메뉴 내 다양한 아이템을 객체로 표현하려고 하는 경우가 그렇죠.

"new" 연산자와 생성자 함수를 사용하면 유사한 객체 여러 개를 쉽게 만들 수 있습니다.

생성자 함수

생성자 함수(constructor function)와 일반 함수에 기술적인 차이는 없습니다. 다만 생성자 함수는 아래 두 관례를 따릅니다.

  1. 함수 이름의 첫 글자는 대문자로 시작합니다.
  2. 반드시 "new" 연산자를 붙여 실행합니다.

예시:

function User(name) {
  this.name = name;
  this.isAdmin = false;
}

*let user = new User("Jack");*

alert(user.name); // Jack
alert(user.isAdmin); // false

new User(...)를 써서 함수를 실행하면 아래와 같은 알고리즘이 동작합니다.

  1. 빈 객체를 만들어 this에 할당합니다.
  2. 함수 본문을 실행합니다. this에 새로운 프로퍼티를 추가해 this를 수정합니다.
  3. this를 반환합니다.

예시를 이용해 new User(...)가 실행되면 무슨 일이 일어나는지 살펴 보도록 하겠습니다.

function User(name) {
  *// this = {};  (빈 객체가 암시적으로 만들어짐)*// 새로운 프로퍼티를 this에 추가함
  this.name = name;
  this.isAdmin = false;

  *// return this;  (this가 암시적으로 반환됨)*}

이제 let user = new User("Jack")는 아래 코드를 입력한 것과 동일하게 동작합니다.

let user = {
  name: "Jack",
  isAdmin: false
};

new User("Jack")이외에도 new User("Ann")new User("Alice") 등을 이용하면 손쉽게 사용자 객체를 만들 수 있습니다. 객체 리터럴 문법으로 일일이 객체를 만드는 방법보다 훨씬 간단하고 읽기 쉽게 객체를 만들 수 있게 되었죠.

생성자의 의의는 바로 여기에 있습니다. 재사용할 수 있는 객체 생성 코드를 구현하는 것이죠.

잠깐! 모든 함수는 생성자 함수가 될 수 있다는 점을 잊지 마시기 바랍니다. new를 붙여 실행한다면 어떤 함수라도 위에 언급된 알고리즘이 실행됩니다. 이름 "첫 글자가 대문자"인 함수는 new를 붙여 실행해야 한다는 점도 잊지 마세요. 공동의 약속이니까요.

ℹ️ new function() { … }

재사용할 필요가 없는 복잡한 객체를 만들어야 한다고 해봅시다. 많은 양의 코드가 필요할 겁니다. 이럴 땐 아래와 같이 코드를 익명 생성자 함수로 감싸주는 방식을 사용할 수 있습니다.

let user = new function() {
  this.name = "John";
  this.isAdmin = false;

  // 사용자 객체를 만들기 위한 여러 코드.
  // 지역 변수, 복잡한 로직, 구문 등의
  // 다양한 코드가 여기에 들어갑니다.
};

위 생성자 함수는 익명 함수이기 때문에 어디에도 저장되지 않습니다. 처음 만들 때부터 단 한 번만 호출할 목적으로 만들었기 때문에 재사용이 불가능합니다. 이렇게 익명 생성자 함수를 이용하면 재사용은 막으면서 코드를 캡슐화 할 수 있습니다.

생성자와 return문

생성자 함수엔 보통 return 문이 없습니다. 반환해야 할 것들은 모두 this에 저장되고, this는 자동으로 반환되기 때문에 반환문을 명시적으로 써 줄 필요가 없습니다.

그런데 만약 return 문이 있다면 어떤 일이 벌어질까요? 아래와 같은 간단한 규칙이 적용됩니다.

  • 객체를 return 한다면, this 대신 객체가 반환됩니다.
  • 원시형을 return 한다면, return문이 무시됩니다.

return 뒤에 객체가 오면 생성자 함수는 해당 객체를 반환해주고, 이 외의 경우는 this가 반환되죠.

아래 예시에선 첫 번째 규칙이 적용돼, return은 this를 무시하고 객체를 반환합니다.

function BigUser() {

  this.name = "John";

  return { name: "Godzilla" };  // <-- this가 아닌 새로운 객체를 반환함
}

alert( new BigUser().name );  // Godzilla

아무것도 return하지 않는 예시를 살펴봅시다. 원시형을 반환하는 경우와 마찬가지로 두 번째 규칙이 적용됩니다.

function SmallUser() {

  this.name = "John";

  return; // <-- this를 반환함
}

alert( new SmallUser().name );  // John

return문이 있는 생성자 함수는 거의 없습니다. 여기선 튜토리얼의 완성도를 위해 특이 케이스를 소개해보았습니다.

생성자 내 메서드

생성자 함수를 사용하면 매개변수를 이용해 객체 내부를 자유롭게 구성할 수 있습니다. 엄청난 유연성이 확보되죠.

지금까진 this에 프로퍼티를 더해주는 예시만 살펴봤는데, 메서드를 더해주는 것도 가능합니다.

아래 예시에서 new User(name)는 프로퍼티 name과 메서드 sayHi를 가진 객체를 만들어줍니다.

function User(name) {
  this.name = name;

  this.sayHi = function() {
    alert( "My name is: " + this.name );
  };
}

*let john = new User("John");

john.sayHi(); // My name is: John*/*
john = {
   name: "John",
   sayHi: function() { ... }
}
*/

class 문법을 사용하면 생성자 함수를 사용하는 것과 마찬가지로 복잡한 객체를 만들 수 있습니다. class에 대해선 추후 학습하도록 하겠습니다.

요약

  • 생성자 함수(짧게 줄여서 생성자)는 일반 함수입니다. 다만, 일반 함수와 구분하기 위해 함수 이름 첫 글자를 대문자로 씁니다.
  • 생성자 함수는 반드시 new 연산자와 함께 호출해야 합니다. new와 함께 호출하면 내부에서 this가 암시적으로 만들어지고, 마지막엔 this가 반환됩니다.

유사한 객체를 여러 개 만들 때 생성자 함수가 유용합니다.

4.6 옵셔널 체이닝 '?.'

옵셔널 체이닝(optional chaining) ?.을 사용하면 프로퍼티가 없는 중첩 객체를 에러 없이 안전하게 접근할 수 있습니다.

이제 막 자바스크립트를 배우기 시작했다면 옵셔널 체이닝이 등장하게 된 배경 상황을 직접 겪어보지 않았을 겁니다. 몇 가지 사례를 재현하면서 왜 옵셔널 체이닝이 등장했는지 알아봅시다.

사용자가 여러 명 있는데 그중 몇 명은 주소 정보를 가지고 있지 않다고 가정해봅시다. 이럴 때 user.address.street를 사용해 주소 정보에 접근하면 에러가 발생할 수 있습니다.

let user = {}; // 주소 정보가 없는 사용자

alert(user.address.street); // TypeError: Cannot read property 'street' of undefined

또 다른 사례는 브라우저에서 동작하는 코드를 개발할 때 발생할 수 있는 문제로, 페이지에 존재하지 않는 요소에 접근해 요소의 정보를 가져오려 할 때 발생합니다.

// querySelector(...) 호출 결과가 null인 경우 에러 발생
let html = document.querySelector('.my-element').innerHTML;

명세서에 ?.이 추가되기 전엔 이런 문제들을 해결하기 위해 && 연산자를 사용하곤 했습니다.

let user = {}; // 주소 정보가 없는 사용자

alert( user && user.address && user.address.street ); // undefined, 에러가 발생하지 않습니다.

중첩 객체의 특정 프로퍼티에 접근하기 위해 거쳐야 할 구성요소들을 AND로 연결해 실제 해당 객체나 프로퍼티가 있는지 확인하는 방법을 사용했었죠. 그런데 이렇게 AND를 연결해서 사용하면 코드가 아주 길어진다는 단점이 있습니다.

옵셔널 체이닝의 등장

?.은 ?.'앞’의 평가 대상이 undefined나 null이면 평가를 멈추고 undefined를 반환합니다.

설명이 장황해지지 않도록 지금부턴 평가후 결과가 null이나 undefined가 아닌 경우엔 값이 ‘있다’, '존재한다’라고 표현하겠습니다.

옵셔널 체이닝을 사용해 user.address.street에 안전하게 접근해봅시다.

let user = {}; // 주소 정보가 없는 사용자

alert( user?.address?.street ); // undefined, 에러가 발생하지 않습니다.

user?.address로 주소를 읽으면 아래와 같이 user 객체가 존재하지 않더라도 에러가 발생하지 않습니다.

let user = null;

alert( user?.address ); // undefined
alert( user?.address.street ); // undefined

위 예시를 통해 우리는 ?.은 ?. ‘앞’ 평가 대상에만 동작되고, 확장은 되지 않는다는 사실을 알 수 있습니다.

In the example above, user?. allows only user to be null/undefined.

On the other hand, if user does exist, then it must have user.address property, otherwise user?.address.street gives an error at the second dot.

단락 평가

?.는 왼쪽 평가대상에 값이 없으면 즉시 평가를 멈춥니다. 참고로 이런 평가 방법을 단락 평가(short-circuit)라고 부릅니다.

그렇기 때문에 함수 호출을 비롯한 ?. 오른쪽에 있는 부가 동작은 ?.의 평가가 멈췄을 때 더는 일어나지 않습니다.

let user = null;
let x = 0;

user?.sayHi(x++); // 아무 일도 일어나지 않습니다.

alert(x); // 0, x는 증가하지 않습니다.

?.()와 ?.[]

?.은 연산자가 아닙니다. ?.은 함수나 대괄호와 함께 동작하는 특별한 문법 구조체(syntax construct)입니다.

함수 관련 예시와 함께 존재 여부가 확실치 않은 함수를 호출할 때 ?.()를 어떻게 쓸 수 있는지 알아봅시다.

한 객체엔 메서드 admin이 있지만 다른 객체엔 없는 상황입니다.

let user1 = {
  admin() {
    alert("관리자 계정입니다.");
  }
}

let user2 = {};

*user1.admin?.(); // 관리자 계정입니다.
user2.admin?.();*

두 상황 모두에서 user 객체는 존재하기 때문에 admin 프로퍼티는 .만 사용해 접근했습니다.

그리고 난 후 ?.()를 사용해 admin의 존재 여부를 확인했습니다. user1엔 admin이 정의되어 있기 때문에 메서드가 제대로 호출되었습니다. 반면 user2엔 admin이 정의되어 있지 않았음에도 불구하고 메서드를 호출하면 에러 없이 그냥 평가가 멈추는 것을 확인할 수 있습니다.

.대신 대괄호 []를 사용해 객체 프로퍼티에 접근하는 경우엔 ?.[]를 사용할 수도 있습니다. 위 예시와 마찬가지로 ?.[]를 사용하면 객체 존재 여부가 확실치 않은 경우에도 안전하게 프로퍼티를 읽을 수 있습니다.

let user1 = {
  firstName: "Violet"
};

let user2 = null; // user2는 권한이 없는 사용자라고 가정해봅시다.

let key = "firstName";

alert( user1?.[key] ); // Violet
alert( user2?.[key] ); // undefined

alert( user1?.[key]?.something?.not?.existing); // undefined

?.은 delete와 조합해 사용할 수도 있습니다.

delete user?.name; // user가 존재하면 user.name을 삭제합니다.

요약

옵셔널 체이닝 문법 ?.은 세 가지 형태로 사용할 수 있습니다.

  1. obj?.prop – obj가 존재하면 obj.prop을 반환하고, 그렇지 않으면 undefined를 반환함
  2. obj?.[prop] – obj가 존재하면 obj[prop]을 반환하고, 그렇지 않으면 undefined를 반환함
  3. obj?.method() – obj가 존재하면 obj.method()를 호출하고, 그렇지 않으면 undefined를 반환함

여러 예시를 통해 살펴보았듯이 옵셔널 체이닝 문법은 꽤 직관적이고 사용하기도 쉽습니다. ?. 왼쪽 평가 대상이 null이나 undefined인지 확인하고 null이나 undefined가 아니라면 평가를 계속 진행합니다.

?.를 계속 연결해서 체인을 만들면 중첩 프로퍼티들에 안전하게 접근할 수 있습니다.

?.은 ?.왼쪽 평가대상이 없어도 괜찮은 경우에만 선택적으로 사용해야 합니다.

꼭 있어야 하는 값인데 없는 경우에 ?.을 사용하면 프로그래밍 에러를 쉽게 찾을 수 없으므로 이런 상황을 만들지 말도록 합시다.

4.7 심볼형

자바스크립트는 객체 프로퍼티 키로 오직 문자형과 심볼형만을 허용합니다. 숫자형, 불린형 모두 불가능하고 오직 문자형과 심볼형만 가능하죠.

지금까지는 프로퍼티 키가 문자형인 경우만 살펴보았습니다. 이번 챕터에선 프로퍼티 키로 심볼값을 사용해 보면서, 심볼형 키를 사용할 때의 이점에 대해 살펴보도록 하겠습니다.

심볼

'심볼(symbol)'은 유일한 식별자(unique identifier)를 만들고 싶을 때 사용합니다.

Symbol()을 사용하면 심볼값을 만들 수 있습니다.

// id는 새로운 심볼이 됩니다.
let id = Symbol();

심볼을 만들 때 심볼 이름이라 불리는 설명을 붙일 수도 있습니다. 심볼 이름은 디버깅 시 아주 유용합니다.

// 심볼 id에는 "id"라는 설명이 붙습니다.
let id = Symbol("id");

심볼은 유일성이 보장되는 자료형이기 때문에, 설명이 동일한 심볼을 여러 개 만들어도 각 심볼값은 다릅니다. 심볼에 붙이는 설명(심볼 이름)은 어떤 것에도 영향을 주지 않는 이름표 역할만을 합니다.

설명이 같은 심볼 두 개를 만들고 이를 비교해보겠습니다. 동일 연산자(==)로 비교 시 false가 반환되는 것을 확인할 수 있습니다.

let id1 = Symbol("id");
let id2 = Symbol("id");

*alert(id1 == id2); // false*

참고로 Ruby 등의 언어에서도 '심볼’과 유사한 개념을 사용하는데, 자바스크립트의 심볼은 이들 언어에 쓰이는 심볼과는 다르기 때문에 혼동하지 마시길 바랍니다.

'숨김’ 프로퍼티

심볼을 이용하면 ‘숨김(hidden)’ 프로퍼티를 만들 수 있습니다. 숨김 프로퍼티는 외부 코드에서 접근이 불가능하고 값도 덮어쓸 수 없는 프로퍼티입니다.

서드파티 코드에서 가지고 온 user라는 객체가 여러 개 있고, user를 이용해 어떤 작업을 해야 하는 상황이라고 가정해 봅시다. user에 식별자를 붙여주도록 합시다.

식별자는 심볼을 이용해 만들도록 하겠습니다.

let user = { // 서드파티 코드에서 가져온 객체
  name: "John"
};

let id = Symbol("id");

user[id] = 1;

alert( user[id] ); // 심볼을 키로 사용해 데이터에 접근할 수 있습니다.

그런데 문자열 "id"를 키로 사용해도 되는데 Symbol("id")을 사용한 이유가 무엇일까요?

user는 서드파티 코드에서 가지고 온 객체이므로 함부로 새로운 프로퍼티를 추가할 수 없습니다. 그런데 심볼은 서드파티 코드에서 접근할 수 없기 때문에, 심볼을 사용하면 서드파티 코드가 모르게 user에 식별자를 부여할 수 있습니다.

상황 하나를 더 가정해보겠습니다. 제3의 스크립트(자바스크립트 라이브러리 등)에서 user를 식별해야 하는 상황이 벌어졌다고 해보죠. user의 원천인 서드파티 코드, 현재 작성 중인 스크립트, 제3의 스크립트가 각자 서로의 코드도 모른 채 user를 식별해야 하는 상황이 벌어졌습니다.

제3의 스크립트에선 아래와 같이 Symbol("id")을 이용해 전용 식별자를 만들어 사용할 수 있습니다.

// ...
let id = Symbol("id");

user[id] = "제3 스크립트 id 값";

심볼은 유일성이 보장되므로 우리가 만든 식별자와 제3의 스크립트에서 만든 식별자가 충돌하지 않습니다. 이름이 같더라도 말이죠.

만약 심볼 대신 문자열 "id"를 사용해 식별자를 만들었다면 충돌이 발생할 가능성이 있습니다.

let user = { name: "John" };

// 문자열 "id"를 사용해 식별자를 만들었습니다.
user.id = "스크립트 id 값";

// 만약 제3의 스크립트가 우리 스크립트와 동일하게 문자열 "id"를 이용해 식별자를 만들었다면...

user.id = "제3 스크립트 id 값"
// 의도치 않게 값이 덮어 쓰여서 우리가 만든 식별자는 무의미해집니다.

Symbols in a literal

객체 리터럴 {...}을 사용해 객체를 만든 경우, 대괄호를 사용해 심볼형 키를 만들어야 합니다.

let id = Symbol("id");

let user = {
  name: "John",
  *[id]: 123 // "id": 123은 안됨*};

"id: 123"이라고 하면, 심볼 id가 아니라 문자열 "id"가 키가 됩니다.

심볼은 for…in 에서 배제됩니다

키가 심볼인 프로퍼티는 for..in 반복문에서 배제됩니다.

예시:

let id = Symbol("id");
let user = {
  name: "John",
  age: 30,
  [id]: 123
};

*for (let key in user) alert(key); // name과 age만 출력되고, 심볼은 출력되지 않습니다.*

// 심볼로 직접 접근하면 잘 작동합니다.
alert( "직접 접근한 값: " + user[id] );

Object.keys(user)에서도 키가 심볼인 프로퍼티는 배제됩니다. '심볼형 프로퍼티 숨기기(hiding symbolic property)'라 불리는 이런 원칙 덕분에 외부 스크립트나 라이브러리는 심볼형 키를 가진 프로퍼티에 접근하지 못합니다.

그런데 Object.assign은 키가 심볼인 프로퍼티를 배제하지 않고 객체 내 모든 프로퍼티를 복사합니다.

let id = Symbol("id");
let user = {
  [id]: 123
};

let clone = Object.assign({}, user);

alert( clone[id] ); // 123

뭔가 모순이 있는 것 같아 보이지만, 이는 의도적으로 설계된 것입니다. 객체를 복사하거나 병합할 때, 대개 id 같은 심볼을 포함한 프로퍼티 전부를 사용하고 싶어 할 것이라는 생각에서 이렇게 설계되었습니다.

전역 심볼

앞서 살펴본 것처럼, 심볼은 이름이 같더라도 모두 별개로 취급됩니다. 그런데 이름이 같은 심볼이 같은 개체를 가리키길 원하는 경우도 가끔 있습니다. 애플리케이션 곳곳에서 심볼 "id"를 이용해 특정 프로퍼티에 접근해야 한다고 가정해 봅시다.

전역 심볼 레지스트리(global symbol registry) 는 이런 경우를 위해 만들어졌습니다. 전역 심볼 레지스트리 안에 심볼을 만들고 해당 심볼에 접근하면, 이름이 같은 경우 항상 동일한 심볼을 반환해줍니다.

레지스트리 안에 있는 심볼을 읽거나, 새로운 심볼을 생성하려면 Symbol.for(key)를 사용하면 됩니다.

이 메서드를 호출하면 이름이 key인 심볼을 반환합니다. 조건에 맞는 심볼이 레지스트리 안에 없으면 새로운 심볼 Symbol(key)을 만들고 레지스트리 안에 저장합니다.

// 전역 레지스트리에서 심볼을 읽습니다.
let id = Symbol.for("id"); // 심볼이 존재하지 않으면 새로운 심볼을 만듭니다.

// 동일한 이름을 이용해 심볼을 다시 읽습니다(좀 더 멀리 떨어진 코드에서도 가능합니다).
let idAgain = Symbol.for("id");

// 두 심볼은 같습니다.
alert( id === idAgain ); // true

전역 심볼 레지스트리 안에 있는 심볼은 전역 심볼이라고 불립니다. 애플리케이션에서 광범위하게 사용해야 하는 심볼이라면 전역 심볼을 사용하세요.

Symbol.keyFor

전역 심볼을 찾을 때 사용되는 Symbol.for(key)에 반대되는 메서드도 있습니다. Symbol.keyFor(sym)를 사용하면 이름을 얻을 수 있습니다.

// 이름을 이용해 심볼을 찾음
let sym = Symbol.for("name");
let sym2 = Symbol.for("id");

// 심볼을 이용해 이름을 얻음
alert( Symbol.keyFor(sym) ); // name
alert( Symbol.keyFor(sym2) ); // id

Symbol.keyFor는 전역 심볼 레지스트리를 뒤져서 해당 심볼의 이름을 얻어냅니다. 검색 범위가 전역 심볼 레지스트리이기 때문에 전역 심볼이 아닌 심볼에는 사용할 수 없습니다. 전역 심볼이 아닌 인자가 넘어오면 Symbol.keyFor는 undefined를 반환합니다.

전역 심볼이 아닌 모든 심볼은 description 프로퍼티가 있습니다. 일반 심볼에서 이름을 얻고 싶으면 description 프로퍼티를 사용하면 됩니다.

let globalSymbol = Symbol.for("name");
let localSymbol = Symbol("name");

alert( Symbol.keyFor(globalSymbol) ); // name, 전역 심볼
alert( Symbol.keyFor(localSymbol) ); // undefined, 전역 심볼이 아님

alert( localSymbol.description ); // name

시스템 심볼

'시스템 심볼(system symbol)'은 자바스크립트 내부에서 사용되는 심볼입니다. 시스템 심볼을 활용하면 객체를 미세 조정할 수 있습니다.

명세서 내의 표, 잘 알려진 심볼(well-known symbols)에서 어떤 시스템 심볼이 있는지 살펴보세요.

  • Symbol.hasInstance
  • Symbol.isConcatSpreadable
  • Symbol.iterator
  • Symbol.toPrimitive
  • 기타 등등

객체가 어떻게 원시형으로 변환되는지 알기 위해선 Symbol.toPrimitive에 대해 알아야 하는데, 자세한 내용은 곧 다루도록 하겠습니다.

시스템 심볼 각각에 대한 내용은 연관되는 자바스크립트 기능을 학습하면서 알아보겠습니다.

요약

Symbol은 원시형 데이터로, 유일무이한 식별자를 만드는 데 사용됩니다.

Symbol()을 호출하면 심볼을 만들 수 있습니다. 설명(이름)은 선택적으로 추가할 수 있습니다.

심볼은 이름이 같더라도 값이 항상 다릅니다. 이름이 같을 때 값도 같길 원한다면 전역 레지스트리를 사용해야 합니다. Symbol.for(key)는 key라는 이름을 가진 전역 심볼을 반환합니다. key라는 이름을 가진 전역 심볼이 없으면 새로운 전역 심볼을 만들어줍니다. key가 같다면 Symbol.for는 어디서 호출하든 상관없이 항상 같은 심볼을 반환해 줍니다.

심볼의 주요 유스 케이스는 다음과 같습니다.

  1. 객체의 ‘숨김’ 프로퍼티 – 외부 스크립트나 라이브러리에 ‘속한’ 객체에 새로운 프로퍼티를 추가해 주고 싶다면 심볼을 만들고, 이를 프로퍼티 키로 사용하면 됩니다. 키가 심볼인 경우엔 for..in의 대상이 되지 않아서 의도치 않게 프로퍼티가 수정되는 것을 예방할 수 있습니다. 외부 스크립트나 라이브러리는 심볼 정보를 갖고 있지 않아서 프로퍼티에 직접 접근하는 것도 불가능합니다. 심볼형 키를 사용하면 프로퍼티가 우연히라도 사용되거나 덮어씌워 지는 걸 예방할 수 있습니다.

    이런 특징을 이용하면 원하는 것을 객체 안에 ‘은밀하게’ 숨길 수 있습니다. 외부 스크립트에선 우리가 숨긴 것을 절대 볼 수 없습니다.

  2. 자바스크립트 내부에서 사용되는 시스템 심볼은 Symbol.*로 접근할 수 있습니다. 시스템 심볼을 이용하면 내장 메서드 등의 기본 동작을 입맛대로 변경할 수 있습니다. iterable 객체에선 Symbol.iterator를, 객체를 원시형으로 변환하기에선 Symbol.toPrimitive이 어떻게 사용되는지 알아보겠습니다.

사실 심볼을 완전히 숨길 방법은 없습니다. 내장 메서드 Object.getOwnPropertySymbols(obj)를 사용하면 모든 심볼을 볼 수 있고, 메서드 Reflect.ownKeys(obj)는 심볼형 키를 포함한 객체의 모든 키를 반환해줍니다. 그런데 대부분의 라이브러리, 내장 함수 등은 이런 메서드를 사용하지 않습니다.

4.8 객체를 원시형으로 변환하기

obj1 + obj2 처럼 객체끼리 더하는 연산을 하거나, obj1 - obj2 처럼 객체끼리 빼는 연산을 하면 어떤 일이 일어날까요? alert(obj)로 객체를 출력할 때는 무슨 일이 발생할까요?

이 모든 경우에 자동 형 변환이 일어납니다. 객체는 원시값으로 변환되고, 그 후 의도한 연산이 수행됩니다.

형 변환 챕터에선 객체의 형 변환은 다루지 않았습니다. 원시형 자료가 어떻게 문자, 숫자, 논리형으로 변환되는지만 알아보았죠. 이젠 메서드와 심볼에 대한 지식을 갖추었으니 본격적으로 이 공백을 메꿔봅시다.

  1. 객체는 논리 평가 시 true를 반환합니다. 단 하나의 예외도 없죠. 따라서 객체는 숫자형이나 문자형으로만 형 변환이 일어난다고 생각하시면 됩니다.
  2. 숫자형으로의 형 변환은 객체끼리 빼는 연산을 할 때나 수학 관련 함수를 적용할 때 일어납니다. 객체 Date끼리 차감하면(date1 - date2) 두 날짜의 시간 차이가 반환됩니다. Date에 대해선 Date 객체와 날짜에서 다룰 예정입니다.
  3. 문자형으로의 형 변환은 대개 alert(obj)같이 객체를 출력하려고 할 때 일어납니다.

ToPrimitive

특수 객체 메서드를 사용하면 숫자형이나 문자형으로의 형 변환을 원하는 대로 조절할 수 있습니다.

객체 형 변환은 세 종류로 구분되는데, 'hint’라 불리는 값이 구분 기준이 됩니다. 'hint’가 무엇인지는 명세서에 자세히 설명되어 있는데, ‘목표로 하는 자료형’ 정도로 이해하시면 될 것 같습니다.

"string"

alert 함수같이 문자열을 기대하는 연산을 수행할 때는(객체-문자형 변환), hint가 string이 됩니다.

// 객체를 출력하려고 함
alert(obj);

// 객체를 프로퍼티 키로 사용하고 있음
anotherObj[obj] = 123;

"number"

수학 연산을 적용하려 할 때(객체-숫자형 변환), hint는 number가 됩니다.

// 명시적 형 변환
let num = Number(obj);

// (이항 덧셈 연산을 제외한) 수학 연산
let n = +obj; // 단항 덧셈 연산
let delta = date1 - date2;

// 크고 작음 비교하기
let greater = user1 > user2;

"default"

연산자가 기대하는 자료형이 ‘확실치 않을 때’ hint는 default가 됩니다. 아주 드물게 발생합니다.

이항 덧셈 연산자 +는 피연산자의 자료형에 따라 문자열을 합치는 연산을 할 수도 있고 숫자를 더해주는 연산을 할 수도 있습니다. 따라서 +의 인수가 객체일는 hint가 default가 됩니다.

동등 연산자 ==를 사용해 객체-문자형, 객체-숫자형, 객체-심볼형끼리 비교할 때도, 객체를 어떤 자료형으로 바꿔야 할지 확신이 안 서므로 hint는 default가 됩니다.

// 이항 덧셈 연산은 hint로 `default`를 사용합니다.
let total = obj1 + obj2;

// obj == number 연산은 hint로 `default`를 사용합니다.
if (user == 1) { ... };

크고 작음을 비교할 때 쓰이는 연산자 <> 역시 피연산자에 문자형과 숫자형 둘 다를 허용하는데, 이 연산자들은 hint를 'number’로 고정합니다. hint가 'default’가 되는 일이 없죠. 이는 하위 호환성 때문에 정해진 규칙입니다.

실제 일을 할 때는 이런 사항을 모두 외울 필요는 없습니다. Date 객체를 제외한 모든 내장 객체는 hint가 "default"인 경우와 "number"인 경우를 동일하게 처리하기 때문입니다. 우리도 커스텀 객체를 만들 땐 이런 규칙을 따르면 됩니다.

자바스크립트는 형 변환이 필요할 때, 아래와 같은 알고리즘에 따라 원하는 메서드를 찾고 호출합니다.

  1. 객체에 obj[Symbol.toPrimitive](hint)메서드가 있는지 찾고, 있다면 메서드를 호출합니다. Symbol.toPrimitive는 시스템 심볼로, 심볼형 키로 사용됩니다.
  2. 1에 해당하지 않고 hint가 "string"이라면,
    • obj.toString()이나 obj.valueOf()를 호출합니다(존재하는 메서드만 실행됨).
  3. 1과 2에 해당하지 않고, hint가 "number"나 "default"라면
    • obj.valueOf()나 obj.toString()을 호출합니다(존재하는 메서드만 실행됨).

Symbol.toPrimitive

첫 번째 메서드부터 살펴봅시다. 자바스크립트엔 Symbol.toPrimitive라는 내장 심볼이 존재하는데, 이 심볼은 아래와 같이 목표로 하는 자료형(hint)을 명명하는 데 사용됩니다.

obj[Symbol.toPrimitive] = function(hint) {
  // 반드시 원시값을 반환해야 합니다.
  // hint는 "string", "number", "default" 중 하나가 될 수 있습니다.
};

실제 돌아가는 예시를 살펴보는 게 좋을 것 같네요. user 객체에 객체-원시형 변환 메서드 obj[Symbol.toPrimitive](hint)를 구현해보겠습니다.

let user = {
  name: "John",
  money: 1000,

  [Symbol.toPrimitive](hint) {
    alert(`hint: ${hint}`);
    return hint == "string" ? `{name: "${this.name}"}` : this.money;
  }
};

// 데모:
alert(user); // hint: string -> {name: "John"}
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> 1500

이렇게 메서드를 구현해 놓으면 user는 hint에 따라 (자기 자신을 설명해주는) 문자열로 변환되기도 하고 (가지고 있는 돈의 액수를 나타내는) 숫자로 변환되기도 합니다. user[Symbol.toPrimitive]를 사용하면 메서드 하나로 모든 종류의 형 변환을 다룰 수 있습니다.

toString과 valueOf

toString과 valueOf는 심볼이 생기기 이전부터 존재해 왔던 ‘평범한’ 메서드입니다. 이 메서드를 이용하면 '구식’이긴 하지만 형 변환을 직접 구현할 수 있습니다.

객체에 Symbol.toPrimitive가 없으면 자바스크립트는 아래 규칙에 따라 toString이나 valueOf를 호출합니다.

  • hint가 'string’인 경우: toString -> valueOf 순(toString이 있다면 toString을 호출, toString이 없다면 valueOf를 호출함)
  • 그 외: valueOf -> toString 순

이 메서드들은 반드시 원시값을 반환해야합니다. toString이나 valueOf가 객체를 반환하면 그 결과는 무시됩니다. 마치 메서드가 처음부터 없었던 것처럼 되어버리죠.

일반 객체는 기본적으로 toString과 valueOf에 적용되는 다음 규칙을 따릅니다.

  • toString은 문자열 "[object Object]"을 반환합니다.
  • valueOf는 객체 자신을 반환합니다.

데모를 살펴봅시다.

let user = {name: "John"};

alert(user); // [object Object]
alert(user.valueOf() === user); // true

이런 이유 때문에 alert에 객체를 넘기면 [object Object]가 출력되는 것입니다.

여기서 valueOf는 튜토리얼의 완성도를 높이고 헷갈리는 것을 줄여주려고 언급했습니다. 앞서 본 바와 같이 valueOf는 객체 자신을 반환하기 때문에 그 결과가 무시됩니다. 왜 그런거냐고 이유를 묻지는 말아주세요. 그냥 역사적인 이유때문입니다. 우리는 그냥 이 메서드가 존재하지 않는다고 생각하면 됩니다.

이제 직접 이 메서드들을 사용한 예시를 구현해봅시다.

아래 user는 toString과 valueOf를 조합해 만들었는데, Symbol.toPrimitive를 사용한 위쪽 예시와 동일하게 동작합니다.

let user = {
  name: "John",
  money: 1000,

  // hint가 "string"인 경우
  toString() {
    return `{name: "${this.name}"}`;
  },

  // hint가 "number"나 "default"인 경우
  valueOf() {
    return this.money;
  }

};

alert(user); // toString -> {name: "John"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500

출력 결과가 Symbol.toPrimitive를 사용한 예제와 완전히 동일하다는 걸 확인할 수 있습니다.

그런데 간혹 모든 형 변환을 한 곳에서 처리해야 하는 경우도 생깁니다. 이럴 땐 아래와 같이 toString만 구현해 주면 됩니다.

let user = {
  name: "John",

  toString() {
    return this.name;
  }
};

alert(user); // toString -> John
alert(user + 500); // toString -> John500

객체에 Symbol.toPrimitive와 valueOf가 없으면, toString이 모든 형 변환을 처리합니다.

반환 타입

위에서 소개해드린 세 개의 메서드는 'hint’에 명시된 자료형으로의 형 변환을 보장해 주지 않습니다.

toString()이 항상 문자열을 반환하리라는 보장이 없고, Symbol.toPrimitive의 hint가 "number"일 때 항상 숫자형 자료가 반환되리라는 보장이 없습니다.

확신할 수 있는 단 한 가지는 객체가 아닌 원시값을 반환해 준다는 것뿐입니다.

추가 형 변환

지금까지 살펴본 바와 같이 상당수의 연산자와 함수가 피연산자의 형을 변환시킵니다. 곱셈을 해주는 연산자 *는 피연산자를 숫자형으로 변환시키죠.

객체가 피연산자일 때는 다음과 같은 단계를 거쳐 형 변환이 일어납니다.

  1. 객체는 원시형으로 변화됩니다. 변환 규칙은 위에서 설명했습니다.
  2. 변환 후 원시값이 원하는 형이 아닌 경우엔 또다시 형 변환이 일어납니다.
let obj = {
  // 다른 메서드가 없으면 toString에서 모든 형 변환을 처리합니다.
  toString() {
    return "2";
  }
};

alert(obj * 2); // 4, 객체가 문자열 "2"로 바뀌고, 곱셈 연산 과정에서 문자열 "2"는 숫자 2로 변경됩니다.
  1. obj * 2에선 객체가 원시형으로 변화되므로 toString에의해 obj는 문자열 "2"가 됩니다.
  2. 곱셈 연산은 문자열은 숫자형으로 변환시키므로 "2" * 2는 2 * 2가 됩니다.

그런데 이항 덧셈 연산은 위와 같은 상황에서 문자열을 연결합니다.

let obj = {
  toString() {
    return "2";
  }
};

alert(obj + 2); // 22("2" + 2), 문자열이 반환되기 때문에 문자열끼리의 병합이 일어났습니다.

요약

원시값을 기대하는 내장 함수나 연산자를 사용할 때 객체-원시형으로의 형 변환이 자동으로 일어납니다.

객체-원시형으로의 형 변환은 hint를 기준으로 세 종류로 구분할 수 있습니다.

  • "string" (alert 같이 문자열을 필요로 하는 연산)
  • "number" (수학 연산)
  • "default" (드물게 발생함)

연산자별로 어떤 hint가 적용되는지는 명세서에서 찾아볼 수 있습니다. 연산자가 기대하는 피연산자를 '확신할 수 없을 때’에는 hint가 "default"가 됩니다. 이런 경우는 아주 드물게 발생합니다. 내장 객체는 대개 hint가 "default"일 때와 "number"일 때를 동일하게 처리합니다. 따라서 실무에선 hint가 "default"인 경우와 "number"인 경우를 합쳐서 처리하는 경우가 많습니다.

객체-원시형 변환엔 다음 알고리즘이 적용됩니다.

  1. 객체에 obj[Symbol.toPrimitive](hint)메서드가 있는지 찾고, 있다면 호출합니다.
  2. 1에 해당하지 않고 hint가 "string"이라면,
    • obj.toString()이나 obj.valueOf()를 호출합니다.
  3. 1과 2에 해당하지 않고, hint가 "number"나 "default"라면
    • obj.valueOf()나 obj.toString()을 호출합니다.

obj.toString()만 사용해도 '모든 변환’을 다 다룰 수 있기 때문에, 실무에선 obj.toString()만 구현해도 충분한 경우가 많습니다. 반환 값도 ‘사람이 읽고 이해할 수 있는’ 형식이기 때문에 실용성 측면에서 다른 메서드에 뒤처지지 않습니다. obj.toString()은 로깅이나 디버깅 목적으로도 자주 사용됩니다.

5.1 원시값의 메서드

자바스크립트는 원시값(문자열, 숫자 등)을 마치 객체처럼 다룰 수 있게 해줍니다. 원시값에도 객체에서처럼 메서드를 호출할 수 있죠. 원시값의 메서드에 대해선 곧 학습할 예정인데 그 전에, 원시값은 객체가 아니란 것을 상기하도록 합시다.

원시값과 객체는 다음과 같은 차이점이 있습니다.

원시값:

  • 원시형 값입니다.
  • 원시형의 종류는 문자(string)숫자(number)bigint불린(boolean)심볼(symbol)nullundefined형으로 총 일곱 가지 입니다.

객체:

  • 프로퍼티에 다양한 종류의 값을 저장할 수 있습니다.
  • {name : "John", age : 30}와 같이 대괄호 {}를 사용해 만들 수 있습니다. 자바스크립트에는 여러 종류의 객체가 있는데, 함수도 객체의 일종입니다.

객체의 장점 중 하나는 함수를 프로퍼티로 저장할 수 있다는 것입니다.

let john = {
  name: "John",
  sayHi: function() {
    alert("친구야 반갑다!");
  }
};

john.sayHi(); // 친구야 반갑다!

객체 john을 만들고, 거기에 메서드 sayHi를 정의해보았습니다.

자바스크립트는 날짜, 오류, HTML 요소(HTML element) 등을 다룰 수 있게 해주는 다양한 내장 객체를 제공합니다. 이 객체들은 고유한 프로퍼티와 메서드를 가집니다.

하지만, 이런 기능을 사용하면 시스템 자원이 많이 소모된다는 단점이 있습니다.

객체는 원시값보다 “무겁고”, 내부 구조를 유지하기 위해 추가 자원을 사용하기 때문입니다.

원시값을 객체처럼 사용하기

자바스크립트 창안자(creator)는 다음과 같은 모순적인 상황을 해결해야만 했었습니다.

  • 문자열이나 숫자와 같은 원시값을 다루어야 하는 작업이 많은데, 메서드를 사용하면 작업을 수월하게 할 수 있을 것 같다는 생각이 듭니다.
  • 그런데 원시값은 가능한 한 빠르고 가벼워야 합니다.

조금 어색해 보이지만, 자바스크립트 창안자는 아래와 같은 방법을 사용해 해결책을 모색하였습니다.

  1. 원시값은 원시값 그대로 남겨둬 단일 값 형태를 유지합니다.
  2. 문자열, 숫자, 불린, 심볼의 메서드와 프로퍼티에 접근할 수 있도록 언어 차원에서 허용합니다.
  3. 이를 가능하게 하기 위해, 원시값이 메서드나 프로퍼티에 접근하려 하면 추가 기능을 제공해주는 특수한 객체, "원시 래퍼 객체(object wrapper)"를 만들어 줍니다. 이 객체는 곧 삭제됩니다.

"래퍼 객체"는 원시 타입에 따라 종류가 다양합니다. 각 래퍼 객체는 원시 자료형의 이름을 그대로 차용해, String,Number,BooleanSymbol라고 부릅니다. 래퍼 객체 마다 제공하는 메서드 역시 다릅니다.

인수로 받은 문자열의 모든 글자를 대문자로 바꿔주는 메서드 str.toUpperCase()를 예로 들어보겠습니다.

메서드는 아래와 같이 동작합니다.

let str = "Hello";

alert( str.toUpperCase() ); // HELLO

간단하죠? 아래는 str.toUpperCase ()가 호출될 때 내부에서 실제로 일어나는 일입니다.

  1. 문자열 str은 원시값이므로 원시값의 프로퍼티(toUpperCase)에 접근하는 순간 특별한 객체가 만들어집니다. 이 객체는 문자열의 값을 알고 있고, toUpperCase()와 같은 유용한 메서드를 가지고 있습니다.
  2. 메서드가 실행되고, 새로운 문자열이 반환됩니다(alert 창에 이 문자열이 출력됩니다).
  3. 특별한 객체는 파괴되고, 원시값 str만 남습니다.

이런 내부 프로세스를 통해 원시값을 가볍게 유지하면서 메서드를 호출할 수 있는 것입니다.

자바스크립트 엔진은 위 프로세스의 최적화에 많은 신경을 씁니다. 원시 래퍼 객체를 만들지 않고도 마치 원시 래퍼 객체를 생성(명세에 언급됨)한 것처럼 동작하게끔 해주죠.

숫자형도 고유한 메서드를 지원합니다. 메서드 toFixed(n)를 이용하면 원하는 자리에서 소수점 아래 숫자를 반올림할 수 있습니다.

let n = 1.23456;

alert( n.toFixed(2) ); // 1.23

숫자형문자열에서 더 많은 메서드에 대해 알아보겠습니다.

❗**String/Number/Boolean를 생성자론 쓰지 맙시다.**

Java 등의 몇몇 언어에선 new Number(1) 또는 new Boolean(false)와 같은 문법을 사용해 원하는 타입의 "래퍼 객체"를 직접 만들 수 있습니다.

자바스크립트에서도 하위 호환성을 위해 이 기능을 남겨 두었는데, 이런 식으로 래퍼 객체를 만드는 건 추천하지 않습니다. 몇몇 상황에서 혼동을 불러일으키기 때문입니다.

alert( typeof 0 ); // "number"

alert( typeof new Number(0) ); // "object"!

객체는 논리 평가 시 항상 참을 반환하기 때문에, 아래 예시에서 얼럿창은 무조건 열립니다.

let zero = new Number(0);

if (zero) { // 변수 zero는 객체이므로, 조건문이 참이 됩니다.
  alert( "그런데 여러분은 zero가 참이라는 것에 동의하시나요!?!" );
}

그런데, new를 붙이지 않고 String / Number / Boolean을 사용하는 건 괜찮습니다. new 없이 사용하면 상식에 맞게 인수를 원하는 형의 원시값(문자열, 숫자, 불린 값)으로 바꿔줍니다. 아주 유용하죠.

let num = Number("123"); // 문자열을 숫자로 바꿔줌

요약

  • 'null’과 'undefined’를 제외한 원시값에 다양한 메서드를 호출할 수 있습니다. 이에 대해선 별도의 챕터에서 곧 알아보도록 하겠습니다.
  • 원시값에 메서드를 호출하려 하면 임시 객체가 만들어집니다. 그런데 자바스크립트 엔진은 내부 최적화가 잘 되어있어 메서드를 호출해도 많은 리소스를 쓰지 않습니다.

5.2 숫자형

모던 자바스크립트는 숫자를 나타내는 두 가지 자료형을 지원합니다.

  1. 일반적인 숫자는 '배정밀도 부동소수점 숫자(double precision floating point number)'로 알려진 64비트 형식의 IEEE-754에 저장됩니다. 튜토리얼 전체에서 이 형식을 사용하여 숫자를 표현할 예정입니다.
  2. 임의의 길이를 가진 정수는 BigInt 숫자로 나타낼 수 있습니다. 일반적인 숫자는 2^53이상이거나 2^53이하일 수 없다는 제약 때문에 BigInt라는 새로운 자료형이 만들어졌습니다. BigInt는 아주 특별한 경우에만 사용되므로, 별도의 챕터 BigInt에서 자세한 내용을 다루겠습니다.

자, 그럼 일반적인 숫자에 대해서 자세히 알아봅시다.

숫자를 입력하는 다양한 방법

10억을 입력해야 한다고 상상해 봅니다. 가장 분명한 방법은 아래와 같이 직접 10억(one billion)을 써주는 것입니다.

let billion = 1000000000;

그런데 이렇게 0을 많이 사용해 숫자를 표현하다 보면 잘못 입력하기 쉽기 때문에, 실제로는 이런 방법을 잘 사용하지 않습니다. 0을 많이 입력하는 게 귀찮기도 하지요. 그래서 대개는 10억(billion)을 나타낼 땐 '1bn'을 사용하고, 73억을 나타낼 땐 '7.3bn'을 사용합니다. 큰 숫자를 나타낼 땐 이런 방법이 주로 사용되죠.

자바스크립트에서도 숫자 옆에 'e'를 붙이고 0의 개수를 그 옆에 붙여주면 숫자를 줄일 수 있습니다.

let billion = 1e9;  // 10억, 1과 9개의 0

alert( 7.3e9 );  // 73억 (7,300,000,000)

즉, 'e'는 e 왼쪽의 수에 e 오른쪽에 있는 수만큼의 10의 거듭제곱을 곱하는 효과가 있습니다.

1e3 = 1 * 1000
1.23e6 = 1.23 * 1000000

이제 아주 작은 숫자인 1마이크로초(백만 분의 1초)를 표현해보겠습니다.

let ms = 0.000001;

작은 숫자를 표현할 때도 큰 숫자를 표현할 때처럼 'e'를 사용할 수 있습니다. 0을 명시적으로 쓰고 싶지 않다면 다음과 같이 숫자를 표현할 수 있죠.

let ms = 1e-6; // 1에서 왼쪽으로 6번 소수점 이동

0.000001에서 0의 개수를 세면 6이므로 0.000001은 당연히 1e-6이 되죠.

이렇게 'e' 우측에 음수가 있으면, 이 음수의 절댓값 만큼 10을 거듭제곱한 수로 나누는 것을 의미합니다.

// 10을 세 번 거듭제곱한 수로 나눔
1e-3 = 1 / 1000 (=0.001)

// 10을 여섯 번 거듭제곱한 수로 나눔
1.23e-6 = 1.23 / 1000000 (=0.00000123)

16진수, 2진수, 8진수

16진수는 색을 나타내거나 문자를 인코딩할 때 등 다양한 곳에서 두루 쓰입니다. 다양한 곳에서 쓰이는 만큼 당연히 16진수를 짧게 표현하는 방법도 존재하겠죠. 16진수는 0x를 사용해 표현할 수 있습니다.

alert( 0xff ); // 255
alert( 0xFF ); // 255 (대·소문자를 가리지 않으므로 둘 다 같은 값을 나타냅니다.)

2진수와 8진수는 아주 드물게 쓰이긴 하지만, 접두사 0b와 0o를 사용해 간단히 나타낼 수 있습니다.

let a = 0b11111111; // 255의 2진수
let b = 0o377; // 255의 8진수

alert( a == b ); // true, 진법은 다르지만, a와 b는 같은 수임

자바스크립트에서 지원하는 진법은 3개입니다. 이 외의 진법을 사용하려면 함수 parseInt를 사용해야 합니다(챕터 후반부에서 다룸).

toString(base)

num.toString(base) 메서드는 base진법으로 num을 표현한 후, 이를 문자형으로 변환해 반환합니다.

예시:

let num = 255;

alert( num.toString(16) );  // ff
alert( num.toString(2) );   // 11111111

base는 2에서 36까지 쓸 수 있는데, 기본값은 10입니다.

base별 유스 케이스는 다음과 같습니다.

  • base=16 – 16진수 색, 문자 인코딩 등을 표현할 때 사용합니다. 숫자는 0부터 9, 10 이상의 수는 A부터 F를 사용하여 나타냅니다.

  • base=2 – 비트 연산 디버깅에 주로 쓰입니다. 숫자는 0 또는 1이 될 수 있습니다.

  • base=36 – 사용할 수 있는 base 중 최댓값으로, 0..9와 A..Z를 사용해 숫자를 표현합니다. 알파벳 전체가 숫자를 나타내는 데 사용되죠. 36 베이스는 url을 줄이는 것과 같이 숫자로 된 긴 식별자를 짧게 줄일 때 유용합니다. 예시를 살펴봅시다.

    alert( 123456..toString(36) ); // 2n9c

어림수 구하기

어림수를 구하는 것(rounding)은 숫자를 다룰 때 가장 많이 사용되는 연산 중 하나입니다.

어림수 관련 내장 함수 몇 가지를 살펴봅시다.

Math.floor

소수점 첫째 자리에서 내림(버림). 3.1은 3-1.1은 -2가 됩니다.

Math.ceil

소수점 첫째 자리에서 올림. 3.1은 4-1.1은 -1이 됩니다.

Math.round

소수점 첫째 자리에서 반올림. 3.1은 33.6은 4-1.1은 -1이 됩니다.

Math.trunc (Internet Explorer에서는 지원하지 않음)

소수부를 무시. 3.1은 3이 되고 -1.1은 -1이 됩니다.

위에서 소개한 내장 함수들만으로도 소수부에 관련된 연산 대부분을 처리할 수 있습니다. 그런데 소수점 n-th번째 수를 기준으로 어림수를 구해야 하는 상황이라면 어떻게 해야 할까요?

예를 들어 1.2345가 있는데 소수점 두 번째 자릿수까지만 남겨 1.23을 만들고 싶은 경우처럼 말이죠.

두 가지 방법이 있습니다.

  1. 곱하기와 나누기

    소수점 두 번째 자리 숫자까지만 남기고 싶은 경우, 숫자에 100 또는 100보다 큰 10의 거듭제곱 수를 곱한 후, 원하는 어림수 내장 함수를 호출하고 처음 곱한 수를 다시 나누면 됩니다.

    let num = 1.23456;
    
    alert( Math.floor(num * 100) / 100 ); // 1.23456 -> 123.456 -> 123 -> 1.23
  2. 소수점 n 번째 수까지의 어림수를 구한 후 이를 문자형으로 반환해주는 메서드인 toFixed(n)를 사용합니다.

    let num = 12.34;
    alert( num.toFixed(1) ); // "12.3"

    toFixed는 Math.round와 유사하게 가장 가까운 값으로 올림 혹은 버림해줍니다.

    let num = 12.36;
    alert( num.toFixed(1) ); // "12.4"

    toFixed를 사용할 때 주의할 점은 이 메서드의 반환 값이 문자열이라는 것입니다. 소수부의 길이가 인수보다 작으면 끝에 0이 추가됩니다.

    let num = 12.34;
    alert( num.toFixed(5) ); // "12.34000", 소수부의 길이를 5로 만들기 위해 0이 추가되었습니다.

    참고로, +num.toFixed(5)처럼 단항 덧셈 연산자를 앞에 붙이거나 Number()를 호출하면 문자형의 숫자를 숫자형으로 변환할 수 있습니다.

부정확한 계산

숫자는 내부적으로 64비트 형식 IEEE-754으로 표현되기 때문에 숫자를 저장하려면 정확히 64비트가 필요합니다. 64비트 중 52비트는 숫자를 저장하는 데 사용되고, 11비트는 소수점 위치를(정수는 0), 1비트는 부호를 저장하는 데 사용됩니다.

그런데 숫자가 너무 커지면 64비트 공간이 넘쳐서 Infinity로 처리됩니다.

alert( 1e500 ); // Infinity

원인을 이해하려면 집중이 필요하긴 하지만, 꽤 자주 발생하는 현상인 정밀도 손실(loss of precision)도 있습니다.

alert( 0.1 + 0.2 == 0.3 ); // *false*

0.1과 0.2의 합이 0.3과 일치하는지 확인 했는데 false가 출력되었습니다.

이상하네요! 합의 결과가 0.3이 아니라면 대체 무엇일까요?

alert( 0.1 + 0.2 ); // 0.30000000000000004

부정확한 비교 연산이 만들어내는 결과는 여기서 그치지 않습니다. 인터넷 쇼핑몰 사이트를 운영하고 있다고 가정해 봅시다. 사용자가 $0.10와 $0.20 짜리 물품을 장바구니에 넣었다고 상상해 보죠. 주문 총액이 $0.30000000000000004인 것을 보고 놀라지 않을 사용자는 없을 겁니다.

왜 이런 일이 발생하는 걸까요?

숫자는 0과 1로 이루어진 이진수로 변환되어 연속된 메모리 공간에 저장됩니다. 그런데 10진법을 사용하면 쉽게 표현할 수 있는 0.10.2 같은 분수는 이진법으로 표현하면 무한 소수가 됩니다.

0.1은 1을 10으로 나눈 수인 1/10입니다. 10진법을 사용하면 이러한 숫자를 쉽게 표현할 수 있죠. 1/10과 1/3을 비교해봅시다. 1/3은 무한 소수 0.33333(3)이 됩니다.

이렇게 10의 거듭제곱으로 나눈 값은 10진법에서 잘 동작하지만 3으로 나누게 되면 10진법에서 제대로 동작하지 않습니다. 같은 이유로 2진법 체계에서 2의 거듭제곱으로 나눈 값은 잘 동작하지만 1/10같이 2의 거듭제곱이 아닌 값으로 나누게 되면 무한 소수가 되어버립니다.

10진법에서 1/3을 정확히 나타낼 수 없듯이, 2진법을 사용해 0.1 또는 0.2를 정확하게 저장하는 방법은 없습니다.

IEEE-754에선 가능한 가장 가까운 숫자로 반올림하는 방법을 사용해 이런 문제를 해결합니다. 그런데 반올림 규칙을 적용하면 발생하는 '작은 정밀도 손실’을 우리가 볼 수는 없지만 실제로 손실은 발생합니다.

아래와 같이 코드를 작성하면 정밀도 손실을 눈으로 확인할 수 있죠.

alert( 0.1.toFixed(20) ); // 0.10000000000000000555

그리고 두 숫자를 합하면 '정밀도 손실’도 더해집니다.

0.1 + 0.2가 정확히 0.3이 아닌 이유가 여기에 있습니다.

문제를 해결하는 방법은 없을까요? 물론 있습니다. 가장 신뢰할만한 방법은 toFixed(n)메서드를 사용해 어림수를 만드는 것입니다.

let sum = 0.1 + 0.2;
alert( sum.toFixed(2) ); // 0.30

이때 toFixed는 항상 문자열을 반환한다는 점에 유의해야 합니다. 문자열을 반환하기 때문에 소수점 다음에 오는 숫자가 항상 2개가 될 수 있습니다. 인터넷 쇼핑몰을 구축 중이고 $0.30를 보여줘야 할 때 유용하죠. 문자형으로 바뀐 숫자를 다시 숫자형으로 강제 변환하려면 단항 덧셈 연산자를 사용하면 됩니다.

let sum = 0.1 + 0.2;
alert( +sum.toFixed(2) ); // 0.3

숫자에 임시로 100(또는 더 큰 숫자)을 곱하여 정수로 바꾸고, 원하는 연산을 한 후 다시 100으로 나누는 것도 하나의 방법이 될 수 있습니다. 정수를 대상으로 하는 수학 연산은 소수를 대상으로 하는 연산보다 에러가 적기 때문입니다. 그런데 어쨌든 마지막에 나눗셈이 들어가기 때문에 소수가 다시 등장할 수 있다는 단점이 있습니다.

alert( (0.1 * 10 + 0.2 * 10) / 10 ); // 0.3
alert( (0.28 * 100 + 0.14 * 100) / 100); // 0.4200000000000001

이렇게 10의 거듭제곱을 곱하고 다시 동일한 숫자로 나누는 전략은 오류를 줄여주긴 하지만 완전히 없애지는 못합니다.

구현을 하다 보면 무한 소수가 나오는 경우를 완전히 차단해야 하는 경우가 생기곤 합니다. 달러가 아닌 센트 단위로 물품 가격을 저장하는 쇼핑몰을 담당하고 있는데, 행사 때문에 가격을 30% 할인해야 하는 경우가 그렇죠. 무한소수를 방지하는 완벽한 방법은 사실 없습니다. 필요할 때마다 '꼬리’를 잘라 어림수를 만드는 방법뿐이죠.

isNaN과 isFinite

아래 두 특수 숫자 값이 기억나시나요?

  • Infinity와 Infinity – 그 어떤 숫자보다 큰 혹은 작은 특수 숫자 값
  • NaN – 에러를 나타내는 값

두 특수 숫자는 숫자형에 속하지만 ‘정상적인’ 숫자는 아니기 때문에, 정상적인 숫자와 구분하기 위한 특별한 함수가 존재합니다.

  • isNaN(value) – 인수를 숫자로 변환한 다음 NaN인지 테스트함

    alert( isNaN(NaN) ); // true
    alert( isNaN("str") ); // true

    그런데 굳이 이 함수가 필요할까요? "=== NaN 비교를 하면 되지 않을까?"라는 생각이 들 수 있습니다. 안타깝게도 대답은 '필요하다’입니다. NaN은 NaN 자기 자신을 포함하여 그 어떤 값과도 같지 않다는 점에서 독특합니다.

    alert( NaN === NaN ); // false
  • isFinite(value) – 인수를 숫자로 변환하고 변환한 숫자가 NaN/Infinity/-Infinity가 아닌 일반 숫자인 경우 true를 반환함

    alert( isFinite("15") ); // true
    alert( isFinite("str") ); // false, NaN이기 때문입니다.
    alert( isFinite(Infinity) ); // false, Infinity이기 때문입니다.

isFinite는 문자열이 일반 숫자인지 검증하는 데 사용되곤 합니다.

let num = +prompt("숫자를 입력하세요.", '');

// 숫자가 아닌 값을 입력하거나 Infinity, -Infinity를 입력하면 false가 출력됩니다.
alert( isFinite(num) );

빈 문자열이나 공백만 있는 문자열은 isFinite를 포함한 모든 숫자 관련 내장 함수에서 0으로 취급된다는 점에 유의하시기 바랍니다.

parseInt와 parseFloat

단항 덧셈 연산자 + 또는 Number()를 사용하여 숫자형으로 변형할 때 적용되는 규칙은 꽤 엄격합니다. 피연산자가 숫자가 아니면 형 변환이 실패합니다.

alert( +"100px" ); // NaN

엄격한 규칙이 적용되지 않는 유일한 예외는 문자열의 처음 또는 끝에 공백이 있어서 공백을 무시할 때입니다.

그런데 실무에선 CSS 등에서 '100px''12pt'와 같이 숫자와 단위를 함께 쓰는 경우가 흔합니다. 대다수 국가에서 '19€'처럼 금액 뒤에 통화 기호를 붙여 표시하기도 하죠. 숫자만 추출하는 방법이 필요해 보이네요.

내장 함수 parseInt와 parseFloat는 이런 경우를 위해 만들어졌습니다.

두 함수는 불가능할 때까지 문자열에서 숫자를 ‘읽습니다’. 숫자를 읽는 도중 오류가 발생하면 이미 수집된 숫자를 반환하죠. parseInt는 정수, parseFloat는 부동 소수점 숫자를 반환합니다.

alert( parseInt('100px') ); // 100
alert( parseFloat('12.5em') ); // 12.5

alert( parseInt('12.3') ); // 12, 정수 부분만 반환됩니다.
alert( parseFloat('12.3.4') ); // 12.3, 두 번째 점에서 숫자 읽기를 멈춥니다.

parseInt와 parseFloat가 NaN을 반환할 때도 있습니다. 읽을 수 있는 숫자가 없을 때 그렇죠.

alert( parseInt('a123') ); // NaN, a는 숫자가 아니므로 숫자를 읽는 게 중지됩니다.

기타 수학 함수

자바스크립트에서 제공하는 내장 객체 Math엔 다양한 수학 관련 함수와 상수들이 들어있습니다.

몇 가지 예시를 살펴봅시다.

Math.random()

0과 1 사이의 난수를 반환합니다(1은 제외).

alert( Math.random() ); // 0.1234567894322
alert( Math.random() ); // 0.5435252343232
alert( Math.random() ); // ... (무작위 수)

Math.max(a, b, c...) / Math.min(a, b, c...)

인수 중 최대/최솟값을 반환합니다.

alert( Math.max(3, 5, -10, 0, 1) ); // 5
alert( Math.min(1, 2) ); // 1

Math.pow(n, power)

n을 power번 거듭제곱한 값을 반환합니다.

alert( Math.pow(2, 10) ); // 2의 10제곱 = 1024

이 외에도 삼각법을 포함한 다양한 함수와 상수가 Math에 있습니다. 자세한 내용은 MDN 문서에서 읽어보시기 바랍니다.

요약

0이 많이 붙은 큰 숫자는 다음과 같은 방법을 사용해 씁니다.

  • 0의 개수를 'e' 뒤에 추가합니다. 123e6은 0이 6개인 숫자, 123000000을 나타냅니다.
  • 'e' 다음에 음수가 오면, 음수의 절댓값 만큼 10을 거듭제곱한 숫자로 주어진 숫자를 나눕니다. 123e-6은 0.000123을 나타냅니다.

다양한 진법을 사용할 수도 있습니다.

  • 자바스크립트는 특별한 변환 없이 16진수(0x), 8진수(0o), 2진수(0b)를 바로 사용할 수 있게 지원합니다.
  • parseInt(str, base)를 사용하면 str을 base진수로 바꿔줍니다(단, 2 ≤ base ≤ 36).
  • num.toString(base)는 숫자를 base진수로 바꾸고, 이를 문자열 형태로 반환합니다.

12pt나 100px과 같은 값을 숫자로 변환하는 것도 가능합니다.

  • parseInt/parseFloat를 사용하면 문자열에서 숫자만 읽고, 읽은 숫자를 에러가 발생하기 전에 반환해주는 ‘약한’ 형 변환을 사용할 수 있습니다.

소수를 처리하는 데 쓰이는 메서드는 다음과 같습니다.

  • Math.floorMath.ceilMath.truncMath.roundnum.toFixed(precision)를 사용하면 어림수를 구할 수 있습니다.
  • 소수를 다룰 땐 정밀도 손실에 주의하세요.

이 외에도 다양한 수학 함수가 있습니다.

  • 수학 연산이 필요할 때 Math 객체를 찾아보세요. 작은 객체이지만 기본적인 연산은 대부분 다룰 수 있습니다.

5.3 문자열

자바스크립트엔 글자 하나만 저장할 수 있는 별도의 자료형이 없습니다. 텍스트 형식의 데이터는 길이에 상관없이 문자열 형태로 저장됩니다.

자바스크립트에서 문자열은 페이지 인코딩 방식과 상관없이 항상 UTF-16 형식을 따릅니다.

따옴표

따옴표의 종류가 무엇이 있었는지 상기해봅시다.

문자열은 작은따옴표나 큰따옴표, 백틱으로 감쌀 수 있습니다.

let single = '작은따옴표';
let double = "큰따옴표";

let backticks = `백틱`;

작은따옴표와 큰따옴표는 기능상 차이가 없습니다. 그런데 백틱엔 특별한 기능이 있습니다. 표현식을 ${…}로 감싸고 이를 백틱으로 감싼 문자열 중간에 넣어주면 해당 표현식을 문자열 중간에 쉽게 삽입할 수 있죠. 이런 방식을 템플릿 리터럴(template literal)이라고 부릅니다.

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

alert(`1 + 2 = ${sum(1, 2)}.`); // 1 + 2 = 3.

백틱을 사용하면 문자열을 여러 줄에 걸쳐 작성할 수도 있습니다.

let guestList = `손님:
 * John
 * Pete
 * Mary
`;

alert(guestList); // 손님 리스트를 여러 줄에 걸쳐 작성함

자연스럽게 여러 줄의 문자열이 만들어졌네요. 작은따옴표나 큰따옴표를 사용하면 위와 같은 방식으로 여러 줄짜리 문자열을 만들 수 없습니다.

아래 예시를 실행해봅시다. 에러가 발생합니다.

let guestList = "손님: // Error: Invalid or unexpected token
  * John";

작은따옴표나 큰따옴표로 문자열을 표현하는 방식은 자바스크립트가 만들어졌을 때부터 있었습니다. 이때는 문자열을 여러 줄에 걸쳐 작성할 생각조차 못 했던 시기였죠. 백틱은 그 이후에 등장한 문법이기 때문에 따옴표보다 다양한 기능을 제공합니다.

백틱은 '템플릿 함수(template function)'에서도 사용됩니다. funcstring`` 같이 첫 번째 백틱 바로 앞에 함수 이름(func)을 써주면, 이 함수는 백틱 안의 문자열 조각이나 표현식 평가 결과를 인수로 받아 자동으로 호출됩니다. 이런 기능을 '태그드 템플릿(tagged template)'이라 부르는데, 태그드 템플릿을 사용하면 사용자 지정 템플릿에 맞는 문자열을 쉽게 만들 수 있습니다. 태그드 템플릿과 템플릿 함수에 대한 자세한 내용은 MDN 문서에서 확인해보세요. 참고로 이 기능은 자주 사용되진 않습니다.

특수 기호

'줄 바꿈 문자(newline character)'라 불리는 특수기호 \n을 사용하면 작은따옴표나 큰따옴표로도 여러 줄 문자열을 만들 수 있습니다.

let guestList = "손님:\n * John\n * Pete\n * Mary";

alert(guestList); // 손님 리스트를 여러 줄에 걸쳐 작성함

따옴표를 이용해 만든 여러 줄 문자열과 백틱을 이용해 만든 여러 줄 문자열은 표현 방식만 다를 뿐 차이가 없습니다.

let str1 = "Hello\nWorld"; // '줄 바꿈 기호'를 사용해 두 줄짜리 문자열을 만듦

// 백틱과 일반적인 줄 바꿈 방법(엔터)을 사용해 두 줄짜리 문자열을 만듦
let str2 = `Hello
World`;

alert(str1 == str2); // true

자바스크립트엔 줄 바꿈 문자를 비롯한 다양한 ‘특수’ 문자들이 있습니다.

alert( "\u00A9" ); // ©
alert( "\u{20331}" ); // 佫, 중국어(긴 유니코드)
alert( "\u{1F60D}" ); // 😍, 웃는 얼굴 기호(긴 유니코드)

모든 특수 문자는 '이스케이프 문자(escape character)'라고도 불리는 역슬래시 (backslash character) \로 시작합니다.

역슬래시는 문자열 내에 따옴표를 넣을 때도 사용할 수 있습니다.

alert( 'I*\'*m the Walrus!' ); // *I'm* the Walrus!

위 예시에서 살펴본 바와 같이 문자열 내의 따옴표엔 \를 꼭 붙여줘야 합니다. 이렇게 하지 않으면 자바스크립트는 해당 따옴표가 문자열을 닫는 용도로 사용된 것이라 해석하기 때문입니다.

이스케이프 문자는 문자열을 감쌀 때 사용한 따옴표와 동일한 따옴표에만 붙여주면 됩니다. 문자열 내에서 좀 더 우아하게 따옴표를 사용하려면 아래와 같이 따옴표 대신 백틱으로 문자열을 감싸주면 됩니다.

alert( `I'm the Walrus!` ); // I'm the Walrus!

역슬래시 \는 문자열을 정확하게 읽기 위한 용도로 만들어졌으므로 \는 제 역할이 끝나면 사라집니다. 메모리에 저장되는 문자열엔 \가 없습니다. 앞선 예시들을 실행했을 때 뜨는 alert 창을 통해 이를 확인할 수 있습니다.

그렇다면 문자열 안에 역슬래시 \를 보여줘야 하는 경우엔 어떻게 해야 할까요?

\\같이 역슬래시를 두 개 붙이면 됩니다.

alert( `역슬래시: \\` ); // 역슬래시: \

문자열의 길이

length 프로퍼티엔 문자열의 길이가 저장됩니다.

alert( `My\n`.length ); // 3

\n은 ‘특수 문자’ 하나로 취급되기 때문에 My\n의 길이는 3입니다.

특정 글자에 접근하기

문자열 내 특정 위치인 pos에 있는 글자에 접근하려면 [pos]같이 대괄호를 이용하거나 str.charAt(pos)라는 메서드를 호출하면 됩니다. 위치는 0부터 시작합니다.

let str = `Hello`;

// 첫 번째 글자
alert( str[0] ); // H
alert( str.charAt(0) ); // H

// 마지막 글자
alert( str[str.length - 1] ); // o

근래에는 대괄호를 이용하는 방식을 사용합니다. charAt은 하위 호환성을 위해 남아있는 메서드라고 생각하시면 됩니다.

두 접근 방식의 차이는 반환할 글자가 없을 때 드러납니다. 접근하려는 위치에 글자가 없는 경우 []는 undefined를, charAt은 빈 문자열을 반환합니다.

let str = `Hello`;

alert( str[1000] ); // undefined
alert( str.charAt(1000) ); // '' (빈 문자열)

for..of를 사용하면 문자열을 구성하는 글자를 대상으로 반복 작업을 할 수 있습니다.

for (let char of "Hello") {
  alert(char); // H,e,l,l,o (char는 순차적으로 H, e, l, l, o가 됩니다.)
}

문자열의 불변성

문자열은 수정할 수 없습니다. 따라서 문자열의 중간 글자 하나를 바꾸려고 하면 에러가 발생합니다.

직접 실습해봅시다.

let str = 'Hi';

str[0] = 'h'; // Error: Cannot assign to read only property '0' of string 'Hi'
alert( str[0] ); // 동작하지 않습니다.

이런 문제를 피하려면 완전히 새로운 문자열을 하나 만든 다음, 이 문자열을 str에 할당하면 됩니다.

let str = 'Hi';

str = 'h' + str[1]; // 문자열 전체를 교체함

alert( str ); // hi

유사한 예시는 이어지는 절에서 살펴보겠습니다.

대·소문자 변경하기

메서드 toLowerCase()와 toUpperCase()는 대문자를 소문자로, 소문자를 대문자로 변경(케이스 변경)시켜줍니다.

alert( 'Interface'.toUpperCase() ); // INTERFACE
alert( 'Interface'.toLowerCase() ); // interface

글자 하나의 케이스만 변경하는 것도 가능합니다.

alert( 'Interface'[0].toLowerCase() ); // 'i'

부분 문자열 찾기

문자열에서 부분 문자열(substring)을 찾는 방법은 여러 가지가 있습니다.

str.indexOf

첫 번째 방법은 str.indexOf(substr, pos) 메서드를 이용하는 것입니다.

이 메서드는 문자열 str의 pos에서부터 시작해, 부분 문자열 substr이 어디에 위치하는지를 찾아줍니다. 원하는 부분 문자열을 찾으면 위치를 반환하고 그렇지 않으면 -1을 반환합니다.

let str = 'Widget with id';

alert( str.indexOf('Widget') ); // 0, str은 'Widget'으로 시작함
alert( str.indexOf('widget') ); // -1, indexOf는 대·소문자를 따지므로 원하는 문자열을 찾지 못함

alert( str.indexOf("id") ); // 1, "id"는 첫 번째 위치에서 발견됨 (Widget에서 id)

str.indexOf(substr, pos)의 두 번째 매개변수 pos는 선택적으로 사용할 수 있는데, 이를 명시하면 검색이 해당 위치부터 시작됩니다.

부분 문자열 "id"는 위치 1에서 처음 등장하는데, 두 번째 인수에 2를 넘겨 "id"가 두 번째로 등장하는 위치가 어디인지 알아봅시다.

let str = 'Widget with id';

alert( str.indexOf('id', 2) ) // 12

문자열 내 부분 문자열 전체를 대상으로 무언가를 하고 싶다면 반복문 안에 indexOf를 사용하면 됩니다. 반복문이 하나씩 돌 때마다 검색 시작 위치가 갱신되면서 indexOf가 새롭게 호출됩니다.

let str = 'As sly as a fox, as strong as an ox';

let target = 'as'; // as를 찾아봅시다.

let pos = 0;
while (true) {
  let foundPos = str.indexOf(target, pos);
  if (foundPos == -1) break;

  alert( `위치: ${foundPos}` );
  pos = foundPos + 1; // 다음 위치를 기준으로 검색을 이어갑니다.
}

동일한 알고리즘을 사용해 코드만 짧게 줄이면 다음과 같습니다.

let str = "As sly as a fox, as strong as an ox";
let target = "as";

*let pos = -1;
while ((pos = str.indexOf(target, pos + 1)) != -1) {
  alert( `위치: ${pos}` );
}*

if문의 조건식에 indexOf를 쓸 때 주의할 점이 하나 있습니다. 아래와 같이 코드들 작성하면 원하는 결과를 얻을 수 없습니다.

let str = "Widget with id";

if (str.indexOf("Widget")) {
    alert("찾았다!"); // 의도한 대로 동작하지 않습니다.
}

str.indexOf("Widget")은 0을 반환하는데, if문에선 0을 false로 간주하므로 alert 창이 뜨지 않습니다.

따라서 부분 문자열 여부를 검사하려면 아래와 같이 -1과 비교해야 합니다.

let str = "Widget with id";

*if (str.indexOf("Widget") != -1) {*alert("찾았다!"); // 의도한 대로 동작합니다.
}

비트 NOT 연산자를 사용한 기법

오래전부터 전해 오는 비트(bitwise) NOT 연산자 ~를 사용한 기법 하나를 소개해드리겠습니다. 비트 NOT 연산자는 피연산자를 32비트 정수로 바꾼 후(소수부는 모두 버려짐) 모든 비트를 반전합니다.

따라서 n이 32비트 정수일 때 ~n은 -(n+1)이 됩니다.

예시:

alert( ~2 ); // -3, -(2+1)과 같음
alert( ~1 ); // -2, -(1+1)과 같음
alert( ~0 ); // -1, -(0+1)과 같음
*alert( ~-1 ); // 0, -(-1+1)과 같음*

위 예시에서 본 바와 같이 부호가 있는 32비트 정수 n 중, ~n을 0으로 만드는 경우는 n == -1일 때가 유일합니다.

이를 응용해서 indexOf가 -1을 반환하지 않는 경우를 if ( ~str.indexOf("...") )로 검사해 봅시다.

이렇게 ~str.indexOf("...")를 사용하면 코드의 길이를 줄일 수 있습니다.

let str = "Widget";

if (~str.indexOf("Widget")) {
  alert( '찾았다!' ); // 의도한 대로 동작합니다.
}

사실 이렇게 언어 특유의 기능을 사용해 직관적이지 않은 코드를 작성하는 것을 추천해 드리진 않습니다. 그렇지만 위와 같은 기법은 오래된 스크립트에서 쉽게 만날 수 있기 때문에 알아두어야 합니다.

if (~str.indexOf(...)) 패턴의 코드를 만나면 '부분 문자열인지 확인’하는 코드라고 기억해둡시다.

참고로 -1 이외에도 ~ 연산자 적용 시 0을 반환하는 숫자는 다양합니다. 아주 큰 숫자에 ~ 연산자를 적용하면 32비트 정수로 바꾸는 과정에서 잘림 현상이 발생하기 때문이죠. 이런 숫자 중 가장 큰 숫자는 4294967295입니다(~4294967295는 0임). 문자열이 아주 길지 않은 경우에만 ~ 연산자가 의도한 대로 작동한다는 점을 알고 계시길 바랍니다.

모던 자바스크립트에선 .includes 메서드(아래에서 배움)를 사용해 부분 문자열 포함 여부를 검사합니다. 이런 기법은 오래된 자바스크립트에서만 볼 수 있습니다.

includes, startsWith, endsWith

비교적 근래에 나온 메서드인 str.includes(substr, pos)는 str에 부분 문자열 substr이 있는지에 따라 true나 false를 반환합니다.

부분 문자열의 위치 정보는 필요하지 않고 포함 여부만 알고 싶을 때 적합한 메서드입니다.

alert( "Widget with id".includes("Widget") ); // true

alert( "Hello".includes("Bye") ); // false

str.includes에도 str.indexOf처럼 두 번째 인수를 넘기면 해당 위치부터 부분 문자열을 검색합니다.

alert( "Widget".includes("id") ); // true
alert( "Widget".includes("id", 3) ); // false, 세 번째 위치 이후엔 "id"가 없습니다.

메서드 str.startsWith와 str.endsWith는 메서드 이름 그대로 문자열 str이 특정 문자열로 시작하는지(start with) 여부와 특정 문자열로 끝나는지(end with) 여부를 확인할 때 사용할 수 있습니다.

alert( "Widget".startsWith("Wid") ); // true, "Widget"은 "Wid"로 시작합니다.
alert( "Widget".endsWith("get") ); // true, "Widget"은 "get"으로 끝납니다.

부분 문자열 추출하기

자바스크립트엔 부분 문자열 추출과 관련된 메서드가 세 가지 있습니다. 세 가지 메서드 substringsubstrslice를 하나씩 알아봅시다.

str.slice(start [, end])

문자열의 start부터 end까지(end는 미포함)를 반환합니다.

let str = "stringify";
alert( str.slice(0, 5) ); // 'strin', 0번째부터 5번째 위치까지(5번째 위치의 글자는 포함하지 않음)
alert( str.slice(0, 1) ); // 's', 0번째부터 1번째 위치까지(1번째 위치의 자는 포함하지 않음)

두 번째 인수가 생략된 경우엔, 명시한 위치부터 문자열 끝까지를 반환합니다.

let str = "st*ringify*";
alert( str.slice(2) ); // ringify, 2번째부터 끝까지

start와 end는 음수가 될 수도 있습니다. 음수를 넘기면 문자열 끝에서부터 카운팅을 시작합니다.

let str = "strin*gif*y";

// 끝에서 4번째부터 시작해 끝에서 1번째 위치까지
alert( str.slice(-4, -1) ); // gif

str.substring(start [, end])

start와 end 사이에 있는 문자열을 반환합니다.

substring은 slice와 아주 유사하지만 start가 end보다 커도 괜찮다는 데 차이가 있습니다.

let str = "st*ring*ify";

// 동일한 부분 문자열을 반환합니다.
alert( str.substring(2, 6) ); // "ring"
alert( str.substring(6, 2) ); // "ring"

// slice를 사용하면 결과가 다릅니다.
alert( str.slice(2, 6) ); // "ring" (같음)
alert( str.slice(6, 2) ); // "" (빈 문자열)

substring은 음수 인수를 허용하지 않습니다. 음수는 0으로 처리됩니다.

str.substr(start [, length])

start에서부터 시작해 length 개의 글자를 반환합니다.

substr은 끝 위치 대신에 길이를 기준으로 문자열을 추출한다는 점에서 substring과 slice와 차이가 있습니다.

let str = "st*ring*ify";
alert( str.substr(2, 4) ); // ring, 두 번째부터 글자 네 개

첫 번째 인수가 음수면 뒤에서부터 개수를 셉니다.

let str = "strin*gi*fy";
alert( str.substr(-4, 2) ); // gi, 끝에서 네 번째 위치부터 글자 두 개

문자열 비교하기

비교 연산자 챕터에서 알아보았듯이 문자열을 비교할 땐 알파벳 순서를 기준으로 글자끼리 비교가 이뤄집니다.

그런데 아래와 같이 몇 가지 이상해 보이는 것들이 있습니다.

  1. 소문자는 대문자보다 항상 큽니다.

    alert( 'a' > 'Z' ); // true
  2. 발음 구별 기호(diacritical mark)가 붙은 문자는 알파벳 순서 기준을 따르지 않습니다.

    alert( 'Österreich' > 'Zealand' ); // true (Österreich는 오스트리아를 독일어로 표기한 것임 - 옮긴이)

    이런 예외사항 때문에 이름순으로 국가를 나열할 때 예상치 못한 결과가 나올 수 있습니다. 사람들은 Österreich가 Zealand보다 앞서 나올 것이라 예상하는데 그렇지 않죠.

자바스크립트 내부에서 문자열이 어떻게 표시되는지 상기하며 원인을 알아봅시다.

모든 문자열은 UTF-16을 사용해 인코딩되는데, UTF-16에선 모든 글자가 숫자 형식의 코드와 매칭됩니다. 코드로 글자를 얻거나 글자에서 연관 코드를 알아낼 수 있는 메서드는 다음과 같습니다.

str.codePointAt(pos)

pos에 위치한 글자의 코드를 반환합니다.

// 글자는 같지만 케이스는 다르므로 반환되는 코드가 다릅니다.
alert( "z".codePointAt(0) ); // 122
alert( "Z".codePointAt(0) ); // 90

String.fromCodePoint(code)

숫자 형식의 code에 대응하는 글자를 만들어줍니다.

alert( String.fromCodePoint(90) ); // Z

\u 뒤에 특정 글자에 대응하는 16진수 코드를 붙이는 방식으로도 원하는 글자를 만들 수 있습니다.

// 90을 16진수로 변환하면 5a입니다.
alert( '\u005a' ); // Z

이제 이 배경지식을 가지고 코드 65와 220 사이(라틴계열 알파벳과 기타 글자들이 여기에 포함됨)에 대응하는 글자들을 출력해봅시다.

let str = '';

for (let i = 65; i <= 220; i++) {
  str += String.fromCodePoint(i);
}
alert( str );
// ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~������
// ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜ

보이시나요? 대문자 알파벳이 가장 먼저 나오고 특수 문자 몇 개가 나온 다음에 소문자 알파벳이 나오네요. Ö은 거의 마지막에 출력됩니다.

이제 왜 a > Z인지 아시겠죠?

글자는 글자에 대응하는 숫자 형식의 코드를 기준으로 비교됩니다. 코드가 크면 대응하는 글자 역시 크다고 취급되죠. 따라서 a(코드:97)는 Z(코드:90) 보다 크다는 결론이 도출됩니다.

  • 알파벳 소문자의 코드는 대문자의 코드보다 크므로 소문자는 대문자 뒤에 옵니다.
  • Ö 같은 글자는 일반 알파벳과 멀리 떨어져 있습니다. Ö의 코드는 알파벳 소문자의 코드보다 훨씬 큽니다.

문자열 제대로 비교하기

언어마다 문자 체계가 다르기 때문에 문자열을 ‘제대로’ 비교하는 알고리즘을 만드는 건 생각보다 간단하지 않습니다.

문자열을 비교하려면 일단 페이지에서 어떤 언어를 사용하고 있는지 브라우저가 알아야 합니다.

다행히도 모던 브라우저 대부분이 국제화 관련 표준인 ECMA-402를 지원합니다(IE10은 아쉽게도 Intl.js 라이브러리를 사용해야 합니다).

ECMA-402엔 언어가 다를 때 적용할 수 있는 문자열 비교 규칙과 이를 준수하는 메서드가 정의되어있습니다.

str.localeCompare(str2)를 호출하면 ECMA-402에서 정의한 규칙에 따라 str이 str2보다 작은지, 같은지, 큰지를 나타내주는 정수가 반환됩니다.

  • str이 str2보다 작으면 음수를 반환합니다.
  • str이 str2보다 크면 양수를 반환합니다.
  • str과 str2이 같으면 0을 반환합니다.
alert( 'Österreich'.localeCompare('Zealand') ); // -1

localeCompare엔 선택 인수 두 개를 더 전달할 수 있습니다. 기준이 되는 언어를 지정(아무것도 지정하지 않았으면 호스트 환경의 언어가 기준 언어가 됨)해주는 인수와 대·소문자를 구분할지나 "a"와 "á"를 다르게 취급할지에 대한 것을 설정해주는 인수가 더 있죠. 자세한 사항은 관련 페이지에서 확인해 보시기 바랍니다.

요약

  • 자바스크립트엔 세 종류의 따옴표가 있는데, 이 중 하나인 백틱은 문자열을 여러 줄에 걸쳐 쓸 수 있게 해주고 문자열 중간에 ${…}을 사용해 표현식도 넣을 수 있다는 점이 특징입니다.
  • 자바스크립트에선 UTF-16을 사용해 문자열을 인코딩합니다.
  • \n 같은 특수 문자를 사용할 수 있습니다. \u...를 사용하면 해당 문자의 유니코드를 사용해 글자를 만들 수 있습니다.
  • 문자열 내의 글자 하나를 얻으려면 대괄호 []를 사용하세요.
  • 부분 문자열을 얻으려면 slice나 substring을 사용하세요.
  • 소문자로 바꾸려면 toLowerCase, 대문자로 바꾸려면 toUpperCase를 사용하세요.
  • indexOf를 사용하면 부분 문자열의 위치를 얻을 수 있습니다. 부분 문자열 여부만 알고 싶다면 includes/startsWith/endsWith를 사용하면 됩니다.
  • 특정 언어에 적합한 비교 기준 사용해 문자열을 비교하려면 localeCompare를 사용하세요. 이 메서드를 사용하지 않으면 글자 코드를 기준으로 문자열이 비교됩니다.

이외에도 문자열에 쓸 수 있는 유용한 메서드 몇 가지가 있습니다.

  • str.trim() – 문자열 앞과 끝의 공백 문자를 다듬어 줍니다(제거함).
  • str.repeat(n) – 문자열을 n번 반복합니다.
  • 이 외의 메서드는 MDN 문서에서 확인해보시기 바랍니다.

5.4 배열

키를 사용해 식별할 수 있는 값을 담은 컬렉션은 객체라는 자료구조를 이용해 저장하는데, 객체만으로도 다양한 작업을 할 수 있습니다.

그런데 개발을 진행하다 보면 첫 번째 요소, 두 번째 요소, 세 번째 요소 등과 같이 순서가 있는 컬렉션이 필요할 때가 생기곤 합니다. 사용자나 물건, HTML 요소 목록같이 일목요연하게 순서를 만들어 정렬하기 위해서 말이죠.

순서가 있는 컬렉션을 다뤄야 할 때 객체를 사용하면 순서와 관련된 메서드가 없어 그다지 편리하지 않습니다. 객체는 태생이 순서를 고려하지 않고 만들어진 자료구조이기 때문에 객체를 이용하면 새로운 프로퍼티를 기존 프로퍼티 ‘사이에’ 끼워 넣는 것도 불가능합니다.

이럴 땐 순서가 있는 컬렉션을 저장할 때 쓰는 자료구조인 배열을 사용할 수 있습니다.

배열 선언

아래 두 문법을 사용하면 빈 배열을 만들 수 있습니다.

let arr = new Array();
let arr = [];

대부분 두 번째 방법으로 배열을 선언하는데, 이때 대괄호 안에 초기 요소를 넣어주는 것도 가능합니다.

let fruits = ["사과", "오렌지", "자두"];

각 배열 요소엔 0부터 시작하는 숫자(인덱스)가 매겨져 있습니다. 이 숫자들은 배열 내 순서를 나타냅니다.

배열 내 특정 요소를 얻고 싶다면 대괄호 안에 순서를 나타내는 숫자인 인덱스를 넣어주면 됩니다.

let fruits = ["사과", "오렌지", "자두"];

alert( fruits[0] ); // 사과
alert( fruits[1] ); // 오렌지
alert( fruits[2] ); // 자두

같은 방법으로 요소를 수정할 수 있습니다.

fruits[2] = '배'; // 배열이 ["사과", "오렌지", "배"]로 바뀜

새로운 요소를 배열에 추가하는 것도 가능합니다.

fruits[3] = '레몬'; // 배열이 ["사과", "오렌지", "배", "레몬"]으로 바뀜

length를 사용하면 배열에 담긴 요소가 몇 개인지 알아낼 수 있습니다.

let fruits = ["사과", "오렌지", "자두"];

alert( fruits.length ); // 3

alert를 사용해 요소 전체를 출력하는 것도 가능합니다.

let fruits = ["사과", "오렌지", "자두"];

alert( fruits ); // 사과,오렌지,자두

배열 요소의 자료형엔 제약이 없습니다.

// 요소에 여러 가지 자료형이 섞여 있습니다.
let arr = [ '사과', { name: '이보라' }, true, function() { alert('안녕하세요.'); } ];

// 인덱스가 1인 요소(객체)의 name 프로퍼티를 출력합니다.
alert( arr[1].name ); // 이보라

// 인덱스가 3인 요소(함수)를 실행합니다.
arr[3](); // 안녕하세요.

pop·push와 shift·unshift

큐(queue)는 배열을 사용해 만들 수 있는 대표적인 자료구조로, 배열과 마찬가지로 순서가 있는 컬렉션을 저장하는 데 사용합니다. 큐에서 사용하는 주요 연산은 아래와 같습니다.

  • push – 맨 끝에 요소를 추가합니다.
  • shift – 제일 앞 요소를 꺼내 제거한 후 남아있는 요소들을 앞으로 밀어줍니다. 이렇게 하면 두 번째 요소가 첫 번째 요소가 됩니다.

배열엔 두 연산을 가능케 해주는 내장 메서드 push와 pop이 있습니다.

화면에 순차적으로 띄울 메시지를 비축해 놓을 자료 구조를 만들 때 큐를 사용하는 것처럼 큐는 실무에서 상당히 자주 쓰이는 자료구조입니다.

배열은 큐 이외에 스택(stack)이라 불리는 자료구조를 구현할 때도 쓰입니다.

스택에서 사용하는 연산은 아래와 같습니다.

  • push – 요소를 스택 끝에 집어넣습니다.
  • pop – 스택 끝 요소를 추출합니다.

스택은 이처럼 '한쪽 끝’에 요소를 더하거나 뺄 수 있게 해주는 자료구조입니다.

스택은 흔히 카드 한 벌과 비교됩니다. 쌓여있는 카드 맨 위에 새로운 카드를 더해주거나 빼는 것처럼 스택도 '한쪽 끝’에 요소를 집어넣거나 추출 할 수 있기 때문입니다.

스택을 사용하면 가장 나중에 집어넣은 요소가 먼저 나옵니다. 이런 특징을 줄여서 후입선출(Last-In-First-Out, LIFO)이라고 부릅니다. 반면 큐를 사용하면 먼저 집어넣은 요소가 먼저 나오기 때문에 큐는 선입선출(First-In-First-Out, FIFO) 자료구조라고 부릅니다.

자바스크립트 배열을 사용하면 큐와 스택 둘 다를 만들 수 있습니다. 이 자료구조들은 배열의 처음이나 끝에 요소를 더하거나 빼는 데 사용되죠.

이렇게 처음이나 끝에 요소를 더하거나 빼주는 연산을 제공하는 자료구조를 컴퓨터 과학 분야에선 데큐(deque, Double Ended Queue)라고 부릅니다.

아래는 배열 끝에 무언가를 해주는 메서드입니다.

pop

배열 끝 요소를 제거하고, 제거한 요소를 반환합니다.

let fruits = ["사과", "오렌지", "배"];

alert( fruits.pop() ); // 배열에서 "배"를 제거하고 제거된 요소를 얼럿창에 띄웁니다.

alert( fruits ); // 사과,오렌지

push

배열 끝에 요소를 추가합니다.

let fruits = ["사과", "오렌지"];

fruits.push("배");

alert( fruits ); // 사과,오렌지,배
fruits.push(...) 호출하는 것은 fruits[fruits.length] = ...하는 것과 같은 효과를 보입니다.

아래는 배열 앞에 무언가를 해주는 메서드입니다.

shift

배열 앞 요소를 제거하고, 제거한 요소를 반환합니다.

let fruits = ["사과", "오렌지", "배"];

alert( fruits.shift() ); // 배열에서 "사과"를 제거하고 제거된 요소를 얼럿창에 띄웁니다.

alert( fruits ); // 오렌지,배

unshift

배열 앞에 요소를 추가합니다.

let fruits = ["오렌지", "배"];

fruits.unshift('사과');

alert( fruits ); // 사과,오렌지,배

push와 unshift는 요소 여러 개를 한 번에 더해줄 수도 있습니다.

let fruits = ["사과"];

fruits.push("오렌지", "배");
fruits.unshift("파인애플", "레몬");

// ["파인애플", "레몬", "사과", "오렌지", "배"]
alert( fruits );

배열의 내부 동작 원리

배열은 특별한 종류의 객체입니다. 배열 arr의 요소를 arr[0]처럼 대괄호를 사용해 접근하는 방식은 객체 문법에서 왔습니다. 다만 배열은 키가 숫자라는 점만 다릅니다.

숫자형 키를 사용함으로써 배열은 객체 기본 기능 이외에도 순서가 있는 컬렉션을 제어하게 해주는 특별한 메서드를 제공합니다. length라는 프로퍼티도 제공하죠. 그렇지만 어쨌든 배열의 본질은 객체입니다.

이렇게 배열은 자바스크립트의 일곱 가지 원시 자료형에 해당하지 않고, 원시 자료형이 아닌 객체형에 속하기 때문에 객체처럼 동작합니다.

예시를 하나 살펴봅시다. 배열은 객체와 마찬가지로 참조를 통해 복사됩니다.

let fruits = ["바나나"]

let arr = fruits; // 참조를 복사함(두 변수가 같은 객체를 참조)

alert( arr === fruits ); // true

arr.push("배"); // 참조를 이용해 배열을 수정합니다.

alert( fruits ); // 바나나,배 - 요소가 두 개가 되었습니다.

배열을 배열답게 만들어주는 것은 특수 내부 표현방식입니다. 자바스크립트 엔진은 아래쪽 그림에서처럼 배열의 요소를 인접한 메모리 공간에 차례로 저장해 연산 속도를 높입니다. 이 방법 이외에도 배열 관련 연산을 더 빠르게 해주는 최적화 기법은 다양합니다.

그런데 개발자가 배열을 '순서가 있는 자료의 컬렉션’처럼 다루지 않고 일반 객체처럼 다루면 이런 기법들이 제대로 동작하지 않습니다.

let fruits = []; // 빈 배열을 하나 만듭니다.

fruits[99999] = 5; // 배열의 길이보다 훨씬 큰 숫자를 사용해 프로퍼티를 만듭니다.

fruits.age = 25; // 임의의 이름을 사용해 프로퍼티를 만듭니다.

배열은 객체이므로 예시처럼 원하는 프로퍼티를 추가해도 문제가 발생하지 않습니다.

그런데 이렇게 코드를 작성하면 자바스크립트 엔진이 배열을 일반 객체처럼 다루게 되어 배열을 다룰 때만 적용되는 최적화 기법이 동작하지 않아 배열 특유의 이점이 사라집니다.

잘못된 방법의 예는 다음과 같습니다.

  • arr.test = 5 같이 숫자가 아닌 값을 프로퍼티 키로 사용하는 경우
  • arr[0]과 arr[1000]만 추가하고 그사이에 아무런 요소도 없는 경우
  • arr[1000]arr[999]같이 요소를 역순으로 채우는 경우

배열은 순서가 있는 자료를 저장하는 용도로 만들어진 특수한 자료구조입니다. 배열 내장 메서드들은 이런 용도에 맞게 만들어졌죠. 자바스크립트 엔진은 이런 특성을 고려하여 배열을 신중하게 조정하고, 처리하므로 배열을 사용할 땐 이런 목적에 맞게 사용해 주시기 바랍니다. 임의의 키를 사용해야 한다면 배열보단 일반 객체 {}가 적합한 자료구조일 확률이 높습니다.

성능

push와 pop은 빠르지만 shift와 unshift는 느립니다.

배열 앞에 무언가를 해주는 메서드가 배열 끝에 무언가를 해주는 메서드보다 느린 이유를 실행 흐름을 살펴보면서 알아봅시다.

fruits.shift(); // 배열 맨 앞의 요소를 빼줍니다.

shift 메서드를 호출한 것과 동일한 효과를 보려면 인덱스가 0인 요소를 제거하는 것만으론 충분하지 않습니다. 제거 대상이 아닌 나머지 요소들의 인덱스를 수정해 줘야 하죠.

shift 연산은 아래 3가지 동작을 모두 수행해야 이뤄집니다.

  1. 인덱스가 0인 요소를 제거합니다.
  2. 모든 요소를 왼쪽으로 이동시킵니다. 이때 인덱스 1은 02는 1로 변합니다.
  3. length 프로퍼티 값을 갱신합니다.

그런데 배열에 요소가 많으면 요소가 이동하는 데 걸리는 시간이 길고 메모리 관련 연산도 많아집니다.

unshift를 실행했을 때도 이와 유사한 일이 일어납니다. 요소를 배열 앞에 추가하려면 일단 기존 요소들을 오른쪽으로 이동시켜야 하는데, 이때 인덱스도 바꿔줘야 합니다.

그렇다면 push나 pop은 어떨까요? 이 둘은 요소 이동을 수반하지 않습니다. pop 메서드로 요소를 끝에서 제거하려면 마지막 요소를 제거하고 length 프로퍼티의 값을 줄여주기만 하면 되죠.

pop 메서드를 호출하면 다음과 같은 동작이 일어납니다.

fruits.pop(); // 배열 끝 요소 하나를 제거합니다.

pop 메서드는 요소를 옮기지 않으므로 각 요소는 기존 인덱스를 그대로 유지합니다. 배열 끝에 무언가를 해주는 메서드의 실행 속도가 빠른 이유는 바로 여기에 있습니다.

push 메서드를 쓸 때도 유사한 동작이 일어나므로 속도가 빠릅니다.

반복문

for문은 배열을 순회할 때 쓰는 가장 오래된 방법입니다. 순회시엔 인덱스를 사용합니다.

let arr = ["사과", "오렌지", "배"];

*for (let i = 0; i < arr.length; i++) {*alert( arr[i] );
}

배열에 적용할 수 있는 또 다른 순회 문법으론 for..of가 있습니다.

let fruits = ["사과", "오렌지", "자두"];

// 배열 요소를 대상으로 반복 작업을 수행합니다.
for (let fruit of fruits) {
  alert( fruit );
}

for..of를 사용하면 현재 요소의 인덱스는 얻을 수 없고 값만 얻을 수 있습니다. 이 정도 기능이면 원하는 것을 충분히 구현할 수 있고 문법도 짧기 때문에 배열의 요소를 대상으로 반복 작업을 할 땐 for..of를 사용해 보시기 바랍니다.

배열은 객체형에 속하므로 for..in을 사용하는 것도 가능합니다.

let arr = ["사과", "오렌지", "배"];

*for (let key in arr) {*alert( arr[key] ); // 사과, 오렌지, 배
}

그런데 for..in은 다음과 같은 특징을 지니기 때문에 배열에 for..in을 사용하면 문제가 발생하므로 되도록 다른 반복문을 사용하시길 바랍니다.

  1. for..in 반복문은 모든 프로퍼티를 대상으로 순회합니다. 키가 숫자가 아닌 프로퍼티도 순회 대상에 포함됩니다.

    브라우저나 기타 호스트 환경에서 쓰이는 객체 중, 배열과 유사한 형태를 보이는 ‘유사 배열(array-like)’ 객체가 있습니다. 유사 배열 객체엔 배열처럼 length 프로퍼티도 있고 요소마다 인덱스도 붙어 있죠. 그런데 여기에 더하여 유사 배열 객체엔 배열과는 달리 키가 숫자형이 아닌 프로퍼티와 메서드가 있을 수 있습니다. 유사 배열 객체와 for..in을 함께 사용하면 이 모든 것을 대상으로 순회가 이뤄집니다. 따라서 ‘필요 없는’ 프로퍼티들이 문제를 일으킬 가능성이 생깁니다.

  2. for..in 반복문은 배열이 아니라 객체와 함께 사용할 때 최적화되어 있어서 배열에 사용하면 객체에 사용하는 것 대비 10~100배 정도 느립니다. for..in 반복문의 속도가 대체로 빠른 편이기 때문에 병목 지점에서만 문제가 되긴 합니다만, for..in 반복문을 사용할 땐 이런 차이를 알고 적절한 곳에 사용하시길 바랍니다.

그러니 배열엔 되도록 for..in를 쓰지 마세요.

‘length’ 프로퍼티

배열에 무언가 조작을 가하면 length 프로퍼티가 자동으로 갱신됩니다. length 프로퍼티는 배열 내 요소의 개수가 아니라 가장 큰 인덱스에 1을 더한 값입니다.

따라서 배열에 요소가 하나 있고, 이 요소의 인덱스가 아주 큰 정수라면 배열의 length 프로퍼티도 아주 커집니다.

let fruits = [];
fruits[123] = "사과";

alert( fruits.length ); // 124

배열을 이렇게 사용하지 않도록 합시다.

length 프로퍼티의 또 다른 독특한 특징 중 하나는 쓰기가 가능하다는 점입니다.

length의 값을 수동으로 증가시키면 아무 일도 일어나지 않습니다. 그런데 값을 감소시키면 배열이 잘립니다. 짧아진 배열은 다시 되돌릴 수 없습니다. 예시를 통해 이를 살펴봅시다.

let arr = [1, 2, 3, 4, 5];

arr.length = 2; // 요소 2개만 남기고 잘라봅시다.
alert( arr ); // [1, 2]

arr.length = 5; // 본래 길이로 되돌려 봅시다.
alert( arr[3] ); // undefined: 삭제된 기존 요소들이 복구되지 않습니다.

이런 특징을 이용하면 arr.length = 0;을 사용해 아주 간단하게 배열을 비울 수 있습니다.

new Array()

위에서도 잠시 언급했지만 new Array() 문법을 사용해도 배열을 만들 수 있습니다.

let arr = *new Array*("사과", "배", "기타");

대괄호 []를 사용하면 더 짧은 문법으로 배열을 만들 수 있기 때문에 new Array()는 잘 사용되지 않는 편입니다. new Array()엔 다루기 까다로운 기능도 있어서 더욱더 그렇습니다.

숫자형 인수 하나를 넣어서 new Array를 호출하면 배열이 만들어지는데, 이 배열엔 요소가 없는 반면 길이는 인수와 같아집니다.

예시를 통해 new Array()의 이런 특징이 어떻게 실수를 유발할 수 있는지 알아봅시다.

let arr = new Array(2); // 이렇게 하면 배열 [2]가 만들어질까요?

alert( arr[0] ); // undefined가 출력됩니다. 요소가 하나도 없는 배열이 만들어졌네요.

alert( arr.length ); // 길이는 2입니다.

위 예시에서 확인해 본 것처럼 new Array(number)를 이용해 만든 배열의 요소는 모두 undefined 입니다.

이런 뜻밖의 상황을 마주치지 않기 위해 new Array의 기능을 잘 알지 않는 한 대부분의 개발자가 대괄호를 써서 배열을 만듭니다.

다차원 배열

배열 역시 배열의 요소가 될 수 있습니다. 이런 배열을 가리켜 다차원 배열(multidimensional array)이라 부릅니다. 다차원 배열은 행렬을 저장하는 용도로 쓰입니다.

let matrix = [
  [1, 2, 3],
  [4, 5, 6],
  [7, 8, 9]
];

alert( matrix[1][1] ); // 5, 중심에 있는 요소

toString

배열엔 toString 메서드가 구현되어 있어 이를 호출하면 요소를 쉼표로 구분한 문자열이 반환됩니다.

let arr = [1, 2, 3];

alert( arr ); // 1,2,3
alert( String(arr) === '1,2,3' ); // true
alert( [] + 1 ); // "1"
alert( [1] + 1 ); // "11"
alert( [1,2] + 1 ); // "1,21"

배열엔 Symbol.toPrimitive나 valueOf 메서드가 없습니다. 따라서 위 예시에선 문자열로의 형 변환이 일어나 []는 빈 문자열, [1]은 문자열 "1"[1,2]는 문자열 "1,2"로 변환됩니다.

이항 덧셈 연산자 "+"는 피연산자 중 하나가 문자열인 경우 나머지 피연산자도 문자열로 변환합니다. 따라서 위 예시는 아래 예시와 동일하게 동작합니다.

alert( "" + 1 ); // "1"
alert( "1" + 1 ); // "11"
alert( "1,2" + 1 ); // "1,21"

요약

배열은 특수한 형태의 객체로, 순서가 있는 자료를 저장하고 관리하는 용도에 최적화된 자료구조입니다.

  • 선언 방법:

    // 대괄호 (가장 많이 쓰이는 방법임)
    let arr = [item1, item2...];
    
    // new Array (잘 쓰이지 않음)
    let arr = new Array(item1, item2...);

    new Array(number)을 호출하면 길이가 number인 배열이 만들어지는데, 이 때 요소는 비어있습니다.

  • length 프로퍼티는 배열의 길이를 나타내줍니다. 정확히는 숫자형 인덱스 중 가장 큰 값에 1을 더한 값입니다. 배열 메서드는 length 프로퍼티를 자동으로 조정해줍니다.

  • length 값을 수동으로 줄이면 배열 끝이 잘립니다.

다음 연산을 사용하면 배열을 데큐처럼 사용할 수 있습니다.

  • push(...items) – items를 배열 끝에 더해줍니다.
  • pop() – 배열 끝 요소를 제거하고, 제거한 요소를 반환합니다.
  • shift() – 배열 처음 요소를 제거하고, 제거한 요소를 반환합니다.
  • unshift(...items) – items를 배열 처음에 더해줍니다.

아래 방법을 사용하면 모든 요소를 대상으로 반복 작업을 할 수 있습니다.

  • for (let i=0; i<arr.length; i++) – 가장 빠른 방법이고 오래된 브라우저와도 호환됩니다.
  • for (let item of arr) – 배열 요소에만 사용되는 모던한 문법입니다.
  • for (let i in arr) – 배열엔 절대 사용하지 마세요.

5.5 배열과 메서드

배열은 다양한 메서드를 제공합니다. 학습 편의를 위해 본 챕터에선 배열 메서드를 몇 개의 그룹으로 나눠 소개하도록 하겠습니다.

요소 추가·제거 메서드

배열의 맨 앞이나 끝에 요소(item)를 추가하거나 제거하는 메서드는 이미 학습한 바 있습니다.

  • arr.push(...items) – 맨 끝에 요소 추가
  • arr.pop() – 맨 끝 요소 제거
  • arr.shift() – 맨 앞 요소 제거
  • arr.unshift(...items) – 맨 앞에 요소 추가

이 외에 요소 추가와 제거에 관련된 메서드를 알아봅시다.

splice

배열에서 요소를 하나만 지우고 싶다면 어떻게 해야 할까요?

배열 역시 객체형에 속하므로 프로퍼티를 지울 때 쓰는 연산자 delete를 사용해 볼 수 있을 겁니다.

let arr = ["I", "go", "home"];

delete arr[1]; // "go"를 삭제합니다.

alert( arr[1] ); // undefined

// delete를 써서 요소를 지우고 난 후 배열 --> arr = ["I",  , "home"];
alert( arr.length ); // 3

원하는 대로 요소를 지웠지만 배열의 요소는 여전히 세 개이네요. arr.length == 3을 통해 이를 확인할 수 있습니다.

이는 자연스러운 결과입니다. delete obj.key는 key를 이용해 해당 키에 상응하는 값을 지우기 때문이죠. delete 메서드는 제 역할을 다 한 것입니다. 그런데 우리는 삭제된 요소가 만든 빈 공간을 나머지 요소들이 자동으로 채울 것이라 기대하며 이 메서드를 썼습니다. 배열의 길이가 더 짧아지길 기대하며 말이죠.

이런 기대를 충족하려면 특별한 메서드를 사용해야 합니다.

arr.splice(start)는 만능 스위스 맥가이버 칼 같은 메서드입니다. 요소를 자유자재로 다룰 수 있게 해주죠. 이 메서드를 사용하면 요소 추가, 삭제, 교체가 모두 가능합니다.

문법은 다음과 같습니다.

arr.splice(index[, deleteCount, elem1, ..., elemN])

첫 번째 매개변수는 조작을 가할 첫 번째 요소를 가리키는 인덱스(index)입니다. 두 번째 매개변수는 deleteCount로, 제거하고자 하는 요소의 개수를 나타냅니다. elem1, ..., elemN은 배열에 추가할 요소를 나타냅니다.

splice 메서드를 사용해 작성된 예시 몇 가지를 보여드리겠습니다.

먼저 요소 삭제에 관한 예시부터 살펴보겠습니다.

let arr = ["I", "study", "JavaScript"];

*arr.splice(1, 1); // 인덱스 1부터 요소 한 개를 제거*alert( arr ); // ["I", "JavaScript"]

쉽죠? 인덱스 1이 가리키는 요소부터 시작해 요소 한 개(1)를 지웠습니다.

다음 예시에선 요소 세 개(3)를 지우고, 그 자리를 다른 요소 두 개로 교체해 보도록 하겠습니다.

let arr = [*"I", "study", "JavaScript",* "right", "now"];

// 처음(0) 세 개(3)의 요소를 지우고, 이 자리를 다른 요소로 대체합니다.
arr.splice(0, 3, "Let's", "dance");

alert( arr ) // now [*"Let's", "dance"*, "right", "now"]

splice는 삭제된 요소로 구성된 배열을 반환합니다. 아래 예시를 통해 확인해 봅시다.

let arr = [*"I", "study",* "JavaScript", "right", "now"];

// 처음 두 개의 요소를 삭제함
let removed = arr.splice(0, 2);

alert( removed ); // "I", "study" <-- 삭제된 요소로 구성된 배열

splice 메서드의 deleteCount를 0으로 설정하면 요소를 제거하지 않으면서 새로운 요소를 추가할 수 있습니다.

let arr = ["I", "study", "JavaScript"];

// 인덱스 2부터
// 0개의 요소를 삭제합니다.
// 그 후, "complex"와 "language"를 추가합니다.
arr.splice(2, 0, "complex", "language");

alert( arr ); // "I", "study", "complex", "language", "JavaScript"

slice

arr.slice는 arr.splice와 유사해 보이지만 훨씬 간단합니다.

문법:

arr.slice([start], [end])

이 메서드는 "start" 인덱스부터 ("end"를 제외한) "end"인덱스까지의 요소를 복사한 새로운 배열을 반환합니다. start와 end는 둘 다 음수일 수 있는데 이땐, 배열 끝에서부터의 요소 개수를 의미합니다.

arr.slice는 문자열 메서드인 str.slice와 유사하게 동작하는데 arr.slice는 서브 문자열(substring) 대신 서브 배열(subarray)을 반환한다는 점이 다릅니다.

let arr = ["t", "e", "s", "t"];

alert( arr.slice(1, 3) ); // e,s (인덱스가 1인 요소부터 인덱스가 3인 요소까지를 복사(인덱스가 3인 요소는 제외))

alert( arr.slice(-2) ); // s,t (인덱스가 -2인 요소부터 제일 끝 요소까지를 복사)

arr.slice()는 인수를 하나도 넘기지 않고 호출하여 arr의 복사본을 만들 수 있습니다. 이런 방식은 기존의 배열을 건드리지 않으면서 배열을 조작해 새로운 배열을 만들고자 할 때 자주 사용됩니다.

concat

arr.concat은 기존 배열의 요소를 사용해 새로운 배열을 만들거나 기존 배열에 요소를 추가하고자 할 때 사용할 수 있습니다.

문법은 다음과 같습니다.

arr.concat(arg1, arg2...)

인수엔 배열이나 값이 올 수 있는데, 인수 개수엔 제한이 없습니다.

메서드를 호출하면 arr에 속한 모든 요소와 arg1arg2 등에 속한 모든 요소를 한데 모은 새로운 배열이 반환됩니다.

인수 argN가 배열일 경우 배열의 모든 요소가 복사됩니다. 그렇지 않은경우(단순 값인 경우)는 인수가 그대로 복사됩니다.

let arr = [1, 2];

// arr의 요소 모두와 [3,4]의 요소 모두를 한데 모은 새로운 배열이 만들어집니다.
alert( arr.concat([3, 4]) ); // 1,2,3,4

// arr의 요소 모두와 [3,4]의 요소 모두, [5,6]의 요소 모두를 모은 새로운 배열이 만들어집니다.
alert( arr.concat([3, 4], [5, 6]) ); // 1,2,3,4,5,6

// arr의 요소 모두와 [3,4]의 요소 모두, 5와 6을 한데 모은 새로운 배열이 만들어집니다.
alert( arr.concat([3, 4], 5, 6) ); // 1,2,3,4,5,6

concat 메서드는 제공받은 배열의 요소를 복사해 활용합니다. 객체가 인자로 넘어오면 (배열처럼 보이는 유사 배열 객체이더라도) 객체는 분해되지 않고 통으로 복사되어 더해집니다.

let arr = [1, 2];

let arrayLike = {
  0: "something",
  length: 1
};

alert( arr.concat(arrayLike) ); // 1,2,[object Object]

그런데 인자로 받은 유사 배열 객체에 특수한 프로퍼티 Symbol.isConcatSpreadable이 있으면 concat은 이 객체를 배열처럼 취급합니다. 따라서 객체 전체가 아닌 객체 프로퍼티의 값이 더해집니다.

let arr = [1, 2];

let arrayLike = {
  0: "something",
  1: "else",
  *[Symbol.isConcatSpreadable]: true,*
  length: 2
};

alert( arr.concat(arrayLike) ); // 1,2,something,else

forEach로 반복작업 하기

arr.forEach는 주어진 함수를 배열 요소 각각에 대해 실행할 수 있게 해줍니다.

문법:

arr.forEach(function(item, index, array) {
  // 요소에 무언가를 할 수 있습니다.
});

아래는 요소 모두를 얼럿창을 통해 출력해주는 코드입니다.

// for each element call alert
["Bilbo", "Gandalf", "Nazgul"].forEach(alert);

아래는 인덱스 정보까지 더해서 출력해주는 좀 더 정교한 코드입니다.

["Bilbo", "Gandalf", "Nazgul"].forEach((item, index, array) => {
  alert(`${item} is at index ${index} in ${array}`);
});

참고로, 인수로 넘겨준 함수의 반환값은 무시됩니다.

배열 탐색하기

배열 내에서 무언가를 찾고 싶을 때 쓰는 메서드에 대해 알아봅시다.

indexOf, lastIndexOf와 includes

arr.indexOf와 arr.lastIndexOfarr.includes는 같은 이름을 가진 문자열 메서드와 문법이 동일합니다. 물론 하는 일도 같습니다. 연산 대상이 문자열이 아닌 배열의 요소라는 점만 다릅니다.

  • arr.indexOf(item, from)는 인덱스 from부터 시작해 item(요소)을 찾습니다. 요소를 발견하면 해당 요소의 인덱스를 반환하고, 발견하지 못했으면 1을 반환합니다.
  • arr.lastIndexOf(item, from)는 위 메서드와 동일한 기능을 하는데, 검색을 끝에서부터 시작한다는 점만 다릅니다.
  • arr.includes(item, from)는 인덱스 from부터 시작해 item이 있는지를 검색하는데, 해당하는 요소를 발견하면 true를 반환합니다.
let arr = [1, 0, false];

alert( arr.indexOf(0) ); // 1
alert( arr.indexOf(false) ); // 2
alert( arr.indexOf(null) ); // -1

alert( arr.includes(1) ); // true

위 메서드들은 요소를 찾을 때 완전 항등 연산자 === 을 사용한다는 점에 유의하시기 바랍니다. 보시는 바와 같이 false를 검색하면 정확히 false만을 검색하지, 0을 검색하진 않습니다.

요소의 위치를 정확히 알고 싶은게 아니고 요소가 배열 내 존재하는지 여부만 확인하고 싶다면 arr.includes를 사용하는 게 좋습니다.

includes는 NaN도 제대로 처리한다는 점에서 indexOf/lastIndexOf와 약간의 차이가 있습니다.

const arr = [NaN];
alert( arr.indexOf(NaN) ); // -1 (완전 항등 비교 === 는 NaN엔 동작하지 않으므로 0이 출력되지 않습니다.)
alert( arr.includes(NaN) );// true (NaN의 여부를 확인하였습니다.)

find와 findIndex

객체로 이루어진 배열이 있다고 가정해 봅시다. 특정 조건에 부합하는 객체를 배열 내에서 어떻게 찾을 수 있을까요?

이럴 때 arr.find(fn)을 사용할 수 있습니다.

문법:

let result = arr.find(function(item, index, array) {
  // true가 반환되면 반복이 멈추고 해당 요소를 반환합니다.
  // 조건에 해당하는 요소가 없으면 undefined를 반환합니다.
});

요소 전체를 대상으로 함수가 순차적으로 호출됩니다.

  • item – 함수를 호출할 요소
  • index – 요소의 인덱스
  • array – 배열 자기 자신

함수가 참을 반환하면 탐색은 중단되고 해당 요소가 반환됩니다. 원하는 요소를 찾지 못했으면 undefined가 반환됩니다.

id와 name 프로퍼티를 가진 사용자 객체로 구성된 배열을 예로 들어보겠습니다. 배열 내에서 id == 1 조건을 충족하는 사용자 객체를 찾아봅시다.

let users = [
  {id: 1, name: "John"},
  {id: 2, name: "Pete"},
  {id: 3, name: "Mary"}
];

let user = users.find(item => item.id == 1);

alert(user.name); // John

실무에서 객체로 구성된 배열을 다뤄야 할 일이 잦기 때문에 find 메서드 활용법을 알아두면 좋습니다.

그런데 위 예시에서 find 안의 함수가 인자를 하나만 가지고 있다는 점에 주목해주시기 바랍니다(item => item.id == 1). 이런 패턴이 가장 많이 사용되는 편입니다. 다른 인자들(indexarray)은 잘 사용되지 않습니다.

arr.findIndex는 find와 동일한 일을 하나, 조건에 맞는 요소를 반환하는 대신 해당 요소의 인덱스를 반환한다는 점이 다릅니다. 조건에 맞는 요소가 없으면 -1이 반환됩니다.

filter

find 메서드는 함수의 반환 값을 true로 만드는 단 하나의 요소를 찾습니다.

조건을 충족하는 요소가 여러 개라면 arr.filter(fn)를 사용하면 됩니다.

filter는 find와 문법이 유사하지만, 조건에 맞는 요소 전체를 담은 배열을 반환한다는 점에서 차이가 있습니다.

let results = arr.filter(function(item, index, array) {
  // 조건을 충족하는 요소는 results에 순차적으로 더해집니다.
  // 조건을 충족하는 요소가 하나도 없으면 빈 배열이 반환됩니다.
});
let users = [
  {id: 1, name: "John"},
  {id: 2, name: "Pete"},
  {id: 3, name: "Mary"}
];

// 앞쪽 사용자 두 명을 반환합니다.
let someUsers = users.filter(item => item.id < 3);

alert(someUsers.length); // 2

배열을 변형하는 메서드

배열을 변형시키거나 요소를 재 정렬해주는 메서드에 대해 알아봅시다.

map

arr.map은 유용성과 사용 빈도가 아주 높은 메서드 중 하나입니다.

map은 배열 요소 전체를 대상으로 함수를 호출하고, 함수 호출 결과를 배열로 반환해줍니다.

문법:

let result = arr.map(function(item, index, array) {
  // 요소 대신 새로운 값을 반환합니다.
});

아래 예시에선 각 요소(문자열)의 길이를 출력해줍니다.

let lengths = ["Bilbo", "Gandalf", "Nazgul"].map(item => item.length);
alert(lengths); // 5,7,6

sort(fn)

arr.sort()는 배열의 요소를 정렬해줍니다. 배열 자체가 변경됩니다.

메서드를 호출하면 재정렬 된 배열이 반환되는데, 이미 arr 자체가 수정되었기 때문에 반환 값은 잘 사용되지 않는 편입니다.

let arr = [ 1, 2, 15 ];

// arr 내부가 재 정렬됩니다.
arr.sort();

alert( arr );  // *1, 15, 2*

엇! 뭔가 이상하네요.

재정렬 후 배열 요소가 1, 15, 2가 되었습니다. 기대하던 결과(1, 2, 15)와는 다르네요. 왜 이런 결과가 나왔을까요?

요소는 문자열로 취급되어 재 정렬되기 때문입니다.

모든 요소는 문자형으로 변환된 이후에 재 정렬됩니다. 앞서 배웠듯이 문자열 비교는 사전편집 순으로 진행되기 때문에 2는 15보다 큰 값으로 취급됩니다("2" > "15").

기본 정렬 기준 대신 새로운 정렬 기준을 만들려면 arr.sort()에 새로운 함수를 넘겨줘야 합니다.

인수로 넘겨주는 함수는 반드시 값 두 개를 비교해야 하고 반환 값도 있어야 합니다.

function compare(a, b) {
  if (a > b) return 1; // 첫 번째 값이 두 번째 값보다 큰 경우
  if (a == b) return 0; // 두 값이 같은 경우
  if (a < b) return -1; //  첫 번째 값이 두 번째 값보다 작은 경우
}

이제 배열 요소를 숫자 오름차순 기준으로 정렬해봅시다.

function compareNumeric(a, b) {
  if (a > b) return 1;
  if (a == b) return 0;
  if (a < b) return -1;
}

let arr = [ 1, 2, 15 ];

*arr.sort(compareNumeric);*alert(arr);  // *1, 2, 15*

이제 기대했던 대로 요소가 정렬되었습니다.

여기서 잠시 멈춰 위 예시에서 어떤 일이 일어났는지 생각해 봅시다. 사실 arr엔 숫자, 문자열, 객체 등이 들어갈 수 있습니다. 알 수 없는 무언가로 구성된 집합이 되는 거죠. 이제 이 비 동질적인 집합을 정렬해야 한다고 가정해봅시다. 무언가를 정렬하려면 기준이 필요하겠죠? 이때 정렬 기준을 정의해주는 함수(ordering function, 정렬 함수) 가 필요합니다. sort에 정렬 함수를 인수로 넘겨주지 않으면 이 메서드는 사전편집 순으로 요소를 정렬합니다.

arr.sort(fn)는 포괄적인 정렬 알고리즘을 이용해 구현되어있습니다. 대개 최적화된 퀵 소트(quicksort)를 사용하는데, arr.sort(fn)는 주어진 함수를 사용해 정렬 기준을 만들고 이 기준에 따라 요소들을 재배열하므로 개발자는 내부 정렬 동작 원리를 알 필요가 없습니다. 우리가 해야 할 일은 정렬 함수 fn을 만들고 이를 인수로 넘겨주는 것뿐입니다.

정렬 과정에서 어떤 요소끼리 비교가 일어났는지 확인하고 싶다면 아래 코드를 활용하시면 됩니다.

[1, -2, 15, 2, 0, 8].sort(function(a, b) {
  alert( a + " <> " + b );
  return a - b;
});

정렬 중에 한 요소가 특정 요소와 여러 번 비교되는 일이 생기곤 하는데 비교 횟수를 최소화 하려다 보면 이런 일이 발생할 수 있습니다.

정렬 함수는 어떤 숫자든 반환할 수 있습니다.

reverse

arr.reverse는 arr의 요소를 역순으로 정렬시켜주는 메서드입니다.

let arr = [1, 2, 3, 4, 5];
arr.reverse();

alert( arr ); // 5,4,3,2,1

반환 값은 재 정렬된 배열입니다.

split과 join

메시지 전송 애플리케이션을 만들고 있다고 가정해 봅시다. 수신자가 여러 명일 경우, 발신자는 쉼표로 각 수신자를 구분할 겁니다. John, Pete, Mary같이 말이죠. 개발자는 긴 문자열 형태의 수신자 리스트를 배열 형태로 전환해 처리하고 싶을 겁니다. 입력받은 문자열을 어떻게 배열로 바꿀 수 있을까요?

str.split(delim)을 이용하면 우리가 원하는 것을 정확히 할 수 있습니다. 이 메서드는 구분자(delimiter) delim을 기준으로 문자열을 쪼개줍니다.

아래 예시에선 쉼표와 공백을 합친 문자열이 구분자로 사용되고 있습니다.

let names = 'Bilbo, Gandalf, Nazgul';

let arr = names.split(', ');

for (let name of arr) {
  alert( `${name}에게 보내는 메시지` ); // Bilbo에게 보내는 메시지
}

split 메서드는 두 번째 인수로 숫자를 받을 수 있습니다. 이 숫자는 배열의 길이를 제한해주므로 길이를 넘어서는 요소를 무시할 수 있습니다. 실무에서 자주 사용하는 기능은 아닙니다.

let arr = 'Bilbo, Gandalf, Nazgul, Saruman'.split(', ', 2);

alert(arr); // Bilbo, Gandalf

arr.join(glue)은 split과 반대 역할을 하는 메서드입니다. 인수 glue를 접착제처럼 사용해 배열 요소를 모두 합친 후 하나의 문자열을 만들어줍니다.

let arr = ['Bilbo', 'Gandalf', 'Nazgul'];

let str = arr.join(';'); // 배열 요소 모두를 ;를 사용해 하나의 문자열로 합칩니다.

alert( str ); // Bilbo;Gandalf;Nazgul

reduce와 reduceRight

forEachforfor..of를 사용하면 배열 내 요소를 대상으로 반복 작업을 할 수 있습니다.

각 요소를 돌면서 반복 작업을 수행하고, 작업 결과물을 새로운 배열 형태로 얻으려면 map을 사용하면 되죠.

arr.reduce와 arr.reduceRight도 이런 메서드들과 유사한 작업을 해줍니다. 그런데 사용법이 조금 복잡합니다. reduce와 reduceRight는 배열을 기반으로 값 하나를 도출할 때 사용됩니다.

문법:

let value = arr.reduce(function(accumulator, item, index, array) {
  // ...
}, [initial]);

인수로 넘겨주는 함수는 배열의 모든 요소를 대상으로 차례차례 적용되는데, 적용 결과는 다음 함수 호출 시 사용됩니다.

함수의 인수는 다음과 같습니다.

  • accumulator – 이전 함수 호출의 결과. initial은 함수 최초 호출 시 사용되는 초깃값을 나타냄(옵션)
  • item – 현재 배열 요소
  • index – 요소의 위치
  • array – 배열

이전 함수 호출 결과는 다음 함수를 호출할 때 첫 번째 인수(previousValue)로 사용됩니다.

첫 번째 인수는 앞서 호출했던 함수들의 결과가 누적되어 저장되는 '누산기(accumulator)'라고 생각하면 됩니다. 마지막 함수까지 호출되면 이 값은 reduce의 반환 값이 됩니다.

복잡해 보이긴 하지만 예제를 통해 메서드를 이해해 봅시다.

reduce를 이용해 코드 한 줄로 배열의 모든 요소를 더한 값을 구해보겠습니다.

let arr = [1, 2, 3, 4, 5];

let result = arr.reduce((sum, current) => sum + current, 0);

alert(result); // 15

reduce에 전달한 함수는 오직 인수 두 개만 받고 있습니다. 대개 이렇게 인수를 두 개만 받습니다.

이제 어떤 과정을 거쳐 위와 같은 결과가 나왔는지 자세히 살펴보겠습니다.

  1. 함수 최초 호출 시, reduce의 마지막 인수인 0(초깃값)이 sum에 할당됩니다. current엔 배열의 첫 번째 요소인 1이 할당됩니다. 따라서 함수의 결과는 1이 됩니다.
  2. 두 번째 호출 시, sum = 1 이고 여기에 배열의 두 번째 요소(2)가 더해지므로 결과는 3이 됩니다.
  3. 세 번째 호출 시, sum = 3 이고 여기에 배열의 다음 요소가 더해집니다. 이런 과정이 계속 이어집니다.

이제 이전 호출의 결과가 어떻게 다음 호출의 첫 번째 인수로 전달되는지 아셨죠?

한편, 아래와 같이 초깃값을 생략하는 것도 가능합니다.

let arr = [1, 2, 3, 4, 5];

// reduce에서 초깃값을 제거함(0이 없음)
let result = arr.reduce((sum, current) => sum + current);

alert( result ); // 15

초깃값을 없애도 결과는 동일하네요. 초깃값이 없으면 reduce는 배열의 첫 번째 요소를 초깃값으로 사용하고 두 번째 요소부터 함수를 호출하기 때문입니다.

위 표에서 첫 번째 호출에 관련된 줄만 없애면 초깃값 없이 계산한 위 예제의 계산 흐름이 됩니다.

하지만 이렇게 초깃값 없이 reduce를 사용할 땐 극도의 주의를 기울여야 합니다. 배열이 비어있는 상태면 reduce 호출 시 에러가 발생하기 때문입니다.

let arr = [];

// TypeError: Reduce of empty array with no initial value
// 초깃값을 설정해 주었다면 초깃값이 반환되었을 겁니다.
arr.reduce((sum, current) => sum + current);

이런 예외상황 때문에 항상 초깃값을 명시해 줄 것을 권장합니다.

arr.reduceRight는 reduce와 동일한 기능을 하지만 배열의 오른쪽부터 연산을 수행한다는 점이 다른 메서드입니

Array.isArray로 배열 여부 알아내기

자바스크립트에서 배열은 독립된 자료형으로 취급되지 않고 객체형에 속합니다.

따라서 typeof로는 일반 객체와 배열을 구분할 수가 없죠.

alert(typeof {}); // object
alert(typeof []); // object

그런데 배열은 자주 사용되는 자료구조이기 때문에 배열인지 아닌지를 감별해내는 특별한 메서드가 있다면 아주 유용할 겁니다. Array.isArray(value)는 이럴 때 사용할 수 있는 유용한 메서드입니다. value가 배열이라면 true를, 배열이 아니라면 false를 반환해주죠.

alert(Array.isArray({})); // false

alert(Array.isArray([])); // true

배열 메서드와 ‘thisArg’

함수를 호출하는 대부분의 배열 메서드(findfiltermap 등. sort는 제외)는 thisArg라는 매개변수를 옵션으로 받을 수 있습니다.

자주 사용되는 인수가 아니어서 지금까진 이 매개변수에 대해 언급하지 않았는데, 튜토리얼의 완성도를 위해 thisArg에 대해 잠시 언급하고 넘어가도록 하겠습니다.

thisArg는 아래와 같이 활용할 수 있습니다.

arr.find(func, thisArg);
arr.filter(func, thisArg);
arr.map(func, thisArg);
// ...
// thisArg는 선택적으로 사용할 수 있는 마지막 인수입니다.

thisArg는 func의 this가 됩니다.

아래 예시에서 객체 army의 메서드를 filter의 인자로 넘겨주고 있는데, 이때 thisArg는 canJoin의 컨텍스트 정보를 넘겨줍니다.

let army = {
  minAge: 18,
  maxAge: 27,
  canJoin(user) {
    return user.age >= this.minAge && user.age < this.maxAge;
  }
};

let users = [
  {age: 16},
  {age: 20},
  {age: 23},
  {age: 30}
];

*// army.canJoin 호출 시 참을 반환해주는 user를 찾음
let soldiers = users.filter(army.canJoin, army);*

alert(soldiers.length); // 2
alert(soldiers[0].age); // 20
alert(soldiers[1].age); // 23

thisArgs에 army를 지정하지 않고 단순히 users.filter(army.canJoin)를 사용했다면 army.canJoin은 단독 함수처럼 취급되고, 함수 본문 내 this는 undefined가 되어 에러가 발생했을 겁니다.

users.filter(user => army.canJoin(user))를 사용하면 users.filter(army.canJoin, army)를 대체할 수 있긴 한데 thisArg를 사용하는 방식이 좀 더 이해하기 쉬우므로 더 자주 사용됩니다.

요약

지금까지 살펴본 배열 메서드를 요약해보도록 합시다.

  • 요소를 더하거나 지우기
    • push(...items) – 맨 끝에 요소 추가하기
    • pop() – 맨 끝 요소 추출하기
    • shift() – 첫 요소 추출하기
    • unshift(...items) – 맨 앞에 요소 추가하기
    • splice(pos, deleteCount, ...items) – pos부터 deleteCount개의 요소를 지우고, items 추가하기
    • slice(start, end) – start부터 end 바로 앞까지의 요소를 복사해 새로운 배열을 만듦
    • concat(...items) – 배열의 모든 요소를 복사하고 items를 추가해 새로운 배열을 만든 후 이를 반환함. items가 배열이면 이 배열의 인수를 기존 배열에 더해줌
  • 원하는 요소 찾기
    • indexOf/lastIndexOf(item, pos) – pos부터 원하는 item을 찾음. 찾게 되면 해당 요소의 인덱스를, 아니면 1을 반환함
    • includes(value) – 배열에 value가 있으면 true를, 그렇지 않으면 false를 반환함
    • find/filter(func) – func의 반환 값을 true로 만드는 첫 번째/전체 요소를 반환함
    • findIndex는 find와 유사함. 다만 요소 대신 인덱스를 반환함
  • 배열 전체 순회하기
    • forEach(func) – 모든 요소에 func을 호출함. 결과는 반환되지 않음
  • 배열 변형하기
    • map(func) – 모든 요소에 func을 호출하고, 반환된 결과를 가지고 새로운 배열을 만듦
    • sort(func) – 배열을 정렬하고 정렬된 배열을 반환함
    • reverse() – 배열을 뒤집어 반환함
    • split/join – 문자열을 배열로, 배열을 문자열로 변환함
    • reduce(func, initial) – 요소를 차례로 돌면서 func을 호출함. 반환값은 다음 함수 호출에 전달함. 최종적으로 하나의 값이 도출됨
  • 기타
    • Array.isArray(arr) – arr이 배열인지 여부를 판단함

sortreversesplice는 기존 배열을 변형시킨다는 점에 주의하시기 바랍니다.

지금까지 배운 메서드만으로 배열과 관련된 작업 99%를 해결할 수 있습니다. 이 외의 배열 메서드도 있긴 한데 잠시 언급하고 넘어가겠습니다.

  • arr.some(fn)과 arr.every(fn)는 배열을 확인합니다.

    두 메서드는 map과 유사하게 모든 요소를 대상으로 함수를 호출합니다. some은 함수의 반환 값을 true로 만드는 요소가 하나라도 있는지 여부를 확인하고 every는 모든 요소가 함수의 반환 값을 true로 만드는지 여부를 확인합니다. 두 메서드 모두 조건을 충족하면 true를, 그렇지 않으면 false를 반환합니다.

  • arr.fill(value, start, end)은 start부터 end까지 value를 채워 넣습니다.

  • arr.copyWithin(target, start, end)은 start부터 end까지 요소를 복사하고, 복사한 요소를 target에 붙여넣습니다. 기존 요소가 있다면 덮어씁니다.

배열에 관한 모든 메서드는 manual에서 찾아볼 수 있습니다.

배워야 할 메서드 종류가 너무 많아서 이걸 다 외워야 하나라는 생각이 들 수 있는데, 생각보다 쉬우니 너무 걱정하지 않으셨으면 좋겠습니다.

일단은 요약본을 참고해 자주 사용하는 메서드가 무엇인지 정도만 알아두어도 괜찮습니다. 아래 과제를 풀면서 충분히 연습하다 보면 배열 메서드에 대한 경험치가 쌓일 겁니다.

나중에 배열을 이용해 뭔가를 해야 하는데 방법이 떠오르지 않을 때 이곳으로 돌아와 요약본을 다시 보고 상황에 맞는 메서드를 찾으면 됩니다. 설명에 딸린 예시들이 실제 코드 작성 시 도움이 될 겁니다. 이런 과정을 반복하다 보면 특별한 노력 없이도 메서드를 저절로 외울 수 있습니다.

5.6 iterable 객체

반복 가능한(iterable, 이터러블) 객체는 배열을 일반화한 객체입니다. 이터러블 이라는 개념을 사용하면 어떤 객체에든 for..of 반복문을 적용할 수 있습니다.

배열은 대표적인 이터러블입니다. 배열 외에도 다수의 내장 객체가 반복 가능합니다. 문자열 역시 이터러블의 예입니다.

배열이 아닌 객체가 있는데, 이 객체가 어떤 것들의 컬렉션(목록, 집합 등)을 나타내고 있는 경우, for..of 문법을 적용할 수만 있다면 컬렉션을 순회하는데 유용할 겁니다. 이게 가능하도록 해봅시다.

Symbol.iterator

직접 이터러블 객체를 만들어 이터러블이라는 개념을 이해해 보도록 합시다.

for..of를 적용하기에 적합해 보이는 배열이 아닌 객체를 만들겠습니다.

예시의 객체 range는 숫자 간격을 나타내고 있습니다.

let range = {
  from: 1,
  to: 5
};

// 아래와 같이 for..of가 동작할 수 있도록 하는 게 목표입니다.
// for(let num of range) ... num=1,2,3,4,5

range를 이터러블로 만들려면(for..of가 동작하도록 하려면) 객체에 Symbol.iterator(특수 내장 심볼)라는 메서드를 추가해 아래와 같은 일이 벌어지도록 해야 합니다.

  1. for..of가 시작되자마자 for..of는 Symbol.iterator를 호출합니다(Symbol.iterator가 없으면 에러가 발생합니다). Symbol.iterator는 반드시 이터레이터(iterator, 메서드 next가 있는 객체) 를 반환해야 합니다.
  2. 이후 for..of는 반환된 객체(이터레이터)만을 대상으로 동작합니다.
  3. for..of에 다음 값이 필요하면, for..of는 이터레이터의 next()메서드를 호출합니다.
  4. next()의 반환 값은 {done: Boolean, value: any}와 같은 형태이어야 합니다. done=true는 반복이 종료되었음을 의미합니다. done=false일땐 value에 다음 값이 저장됩니다.

range를 반복 가능한 객체로 만들어주는 코드는 다음과 같습니다.

let range = {
  from: 1,
  to: 5
};

// 1. for..of 최초 호출 시, Symbol.iterator가 호출됩니다.
range[Symbol.iterator] = function() {

  // Symbol.iterator는 이터레이터 객체를 반환합니다.
  // 2. 이후 for..of는 반환된 이터레이터 객체만을 대상으로 동작하는데, 이때 다음 값도 정해집니다.
  return {
    current: this.from,
    last: this.to,

    // 3. for..of 반복문에 의해 반복마다 next()가 호출됩니다.
    next() {
      // 4. next()는 값을 객체 {done:.., value :...}형태로 반환해야 합니다.
      if (this.current <= this.last) {
        return { done: false, value: this.current++ };
      } else {
        return { done: true };
      }
    }
  };
};

// 이제 의도한 대로 동작합니다!
for (let num of range) {
  alert(num); // 1, then 2, 3, 4, 5
}

이터러블 객체의 핵심은 '관심사의 분리(Separation of concern, SoC)'에 있습니다.

  • range엔 메서드 next()가 없습니다.
  • 대신 range[Symbol.iterator]()를 호출해서 만든 ‘이터레이터’ 객체와 이 객체의 메서드 next()에서 반복에 사용될 값을 만들어냅니다.

이렇게 하면 이터레이터 객체와 반복 대상인 객체를 분리할 수 있습니다.

이터레이터 객체와 반복 대상 객체를 합쳐서 range 자체를 이터레이터로 만들면 코드가 더 간단해집니다.

다음처럼 말이죠.

let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() {
    this.current = this.from;
    return this;
  },

  next() {
    if (this.current <= this.to) {
      return { done: false, value: this.current++ };
    } else {
      return { done: true };
    }
  }
};

for (let num of range) {
  alert(num); // 1, then 2, 3, 4, 5
}

이제 range[Symbol.iterator]()가 객체 range 자체를 반환합니다. 반환된 객체엔 필수 메서드인 next()가 있고 this.current에 반복이 얼마나 진행되었는지를 나타내는 값도 저장되어 있습니다. 코드는 더 짧아졌고요. 이렇게 작성하는 게 좋을 때가 종종 있습니다.

단점은 두 개의 for..of 반복문을 하나의 객체에 동시에 사용할 수 없다는 점입니다. 이터레이터(객체 자신)가 하나뿐이어서 두 반복문이 반복 상태를 공유하기 때문이죠. 그런데 동시에 두 개의 for..of를 사용하는 것은 비동기 처리에서도 흔한 케이스는 아닙니다.

문자열은 이터러블입니다

배열과 문자열은 가장 광범위하게 쓰이는 내장 이터러블입니다.

for..of는 문자열의 각 글자를 순회합니다.

for (let char of "test") {
  // 글자 하나당 한 번 실행됩니다(4회 호출).
  alert( char ); // t, e, s, t가 차례대로 출력됨
}

서로게이트 쌍(surrogate pair)에도 잘 동작합니다.

let str = '𝒳😂';
for (let char of str) {
    alert( char ); // 𝒳와 😂가 차례대로 출력됨
}

이터레이터를 명시적으로 호출하기

이터레이터를 어떻게 명시적으로 사용할 수 있는지 살펴보면서 좀 더 깊게 이해해봅시다.

for..of를 사용했을 때와 동일한 방법으로 문자열을 순회할 건데, 이번엔 직접 호출을 통해서 순회해보겠습니다. 다음 코드는 문자열 이터레이터를 만들고, 여기서 값을 ‘수동으로’ 가져옵니다.

let str = "Hello";

// for..of를 사용한 것과 동일한 작업을 합니다.
// for (let char of str) alert(char);

*let iterator = str[Symbol.iterator]();*

while (true) {
  let result = iterator.next();
  if (result.done) break;
  alert(result.value); // 글자가 하나씩 출력됩니다.
}

이터레이터를 명시적으로 호출하는 경우는 거의 없는데, 이 방법을 사용하면 for..of를 사용하는 것보다 반복 과정을 더 잘 통제할 수 있다는 장점이 있습니다. 반복을 시작했다가 잠시 멈춰 다른 작업을 하다가 다시 반복을 시작하는 것과 같이 반복 과정을 여러 개로 쪼개는 것이 가능합니다.

이터러블과 유사 배열

비슷해 보이지만 아주 다른 용어 두 가지가 있습니다. 헷갈리지 않으려면 두 용어를 잘 이해하고 있어야 합니다.

  • 이터러블(iterable) 은 위에서 설명한 바와 같이 메서드 Symbol.iterator가 구현된 객체입니다.
  • 유사 배열(array-like) 은 인덱스와 length 프로퍼티가 있어서 배열처럼 보이는 객체입니다.

브라우저나 등의 호스트 환경에서 자바스크립트를 사용해 문제를 해결할 때 이터러블 객체나 유사 배열 객체 혹은 둘 다인 객체를 만날 수 있습니다.

이터러블 객체(for..of 를 사용할 수 있음)이면서 유사배열 객체(숫자 인덱스와 length 프로퍼티가 있음)인 문자열이 대표적인 예입니다.

이터러블 객체라고 해서 유사 배열 객체는 아닙니다. 유사 배열 객체라고 해서 이터러블 객체인 것도 아닙니다.

위 예시의 range는 이터러블 객체이긴 하지만 인덱스도 없고 length 프로퍼티도 없으므로 유사 배열 객체가 아닙니다.

아래 예시의 객체는 유사 배열 객체이긴 하지만 이터러블 객체가 아닙니다.

let arrayLike = { // 인덱스와 length프로퍼티가 있음 => 유사 배열
  0: "Hello",
  1: "World",
  length: 2
};

*// Symbol.iterator가 없으므로 에러 발생
for (let item of arrayLike) {}*

이터러블과 유사 배열은 대개 배열이 아니기 때문에 pushpop 등의 메서드를 지원하지 않습니다. 이터러블과 유사 배열을 배열처럼 다루고 싶을 때 이런 특징은 불편함을 초래합니다. range에 배열 메서드를 사용해 무언가를 하고 싶을 때처럼 말이죠. 어떻게 하면 이터러블과 유사 배열에 배열 메서드를 적용할 수 있을까요?

Array.from

범용 메서드 Array.from는 이터러블이나 유사 배열을 받아 ‘진짜’ Array를 만들어줍니다. 이 과정을 거치면 이터러블이나 유사 배열에 배열 메서드를 사용할 수 있습니다.

let arrayLike = {
  0: "Hello",
  1: "World",
  length: 2
};

*let arr = Array.from(arrayLike); // (*)*
alert(arr.pop()); // World (메서드가 제대로 동작합니다.)

(*)로 표시한 줄에 있는 Array.from은 객체를 받아 이터러블이나 유사 배열인지 조사합니다. 넘겨 받은 인수가 이터러블이나 유사 배열인 경우, 새로운 배열을 만들고 객체의 모든 요소를 새롭게 만든 배열로 복사합니다.

이터러블을 사용한 예시는 다음과 같습니다.

// range는 챕터 위쪽 예시에서 그대로 가져왔다고 가정합시다.
let arr = Array.from(range);
alert(arr); // 1,2,3,4,5 (배열-문자열 형 변환이 제대로 동작합니다.)

Array.from엔 ‘매핑(mapping)’ 함수를 선택적으로 넘겨줄 수 있습니다.

Array.from(obj[, mapFn, thisArg])

mapFn을 두 번째 인수로 넘겨주면 새로운 배열에 obj의 요소를 추가하기 전에 각 요소를 대상으로 mapFn을 적용할 수 있습니다. 새로운 배열엔 mapFn을 적용하고 반환된 값이 추가됩니다. 세 번째 인수 thisArg는 각 요소의 this를 지정할 수 있도록 해줍니다.

// range는 챕터 위쪽 예시에서 그대로 가져왔다고 가정합시다.

// 각 숫자를 제곱
let arr = Array.from(range, num => num * num);

alert(arr); // 1,4,9,16,25

아래 예시에선 Array.from를 사용해 문자열을 배열로 만들어보았습니다.

let str = '𝒳😂';

// str를 분해해 글자가 담긴 배열로 만듦
let chars = Array.from(str);

alert(chars[0]); // 𝒳
alert(chars[1]); // 😂
alert(chars.length); // 2

Array.from은 str.split과 달리, 문자열 자체가 가진 이터러블 속성을 이용해 동작합니다. 따라서 for..of처럼 서로게이트 쌍에도 제대로 적용됩니다.

위 예시는 기술적으로 아래 예시와 동일하게 동작한다고 보시면 됩니다.

let str = '𝒳😂';

let chars = []; // Array.from 내부에선 아래와 동일한 반복문이 돌아갑니다.
for (let char of str) {
  chars.push(char);
}

alert(chars);

어쨌든 Array.from을 사용한 예시가 더 짧습니다.

Array.from을 사용하면 서로게이트 쌍을 처리할 수 있는 slice를 직접 구현할 수도 있습니다.

function slice(str, start, end) {
  return Array.from(str).slice(start, end).join('');
}

let str = '𝒳😂𩷶';

alert( slice(str, 1, 3) ); // 😂𩷶

// 내장 순수 메서드는 서로게이트 쌍을 지원하지 않습니다.
alert( str.slice(1, 3) ); // 쓰레깃값 출력 (영역이 다른 특수 값)

요약

for..of을 사용할 수 있는 객체를 이터러블이라고 부릅니다.

  • 이터러블엔 메서드 Symbol.iterator가 반드시 구현되어 있어야 합니다.
    • obj[Symbol.iterator]의 결과는 이터레이터라고 부릅니다. 이터레이터는 이어지는 반복 과정을 처리합니다.
    • 이터레이터엔 객체 {done: Boolean, value: any}을 반환하는 메서드 next()가 반드시 구현되어 있어야 합니다. 여기서 done:true은 반복이 끝났음을 의미하고 그렇지 않은 경우엔 value가 다음 값이 됩니니다.
  • 메서드 Symbol.iterator는 for..of에 의해 자동으로 호출되는데, 개발자가 명시적으로 호출하는 것도 가능합니다.
  • 문자열이나 배열 같은 내장 이터러블에도 Symbol.iterator가 구현되어 있습니다.
  • 문자열 이터레이터는 서로게이트 쌍을 지원합니다.

인덱스와 length 프로퍼티가 있는 객체는 유사 배열이라 불립니다. 유사 배열 객체엔 다양한 프로퍼티와 메서드가 있을 수 있는데 배열 내장 메서드는 없습니다.

명세서를 보면 대부분의 메서드는 ‘진짜’ 배열이 아닌 이터러블이나 유사 배열을 대상으로 동작한다고 쓰여 있는걸 볼 수 있습니다. 이 방법이 더 추상적이기 때문입니다.

Array.from(obj[, mapFn, thisArg])을 사용하면 이터러블이나 유사 배열인 obj를 진짜 Array로 만들 수 있습니다. 이렇게 하면 obj에도 배열 메서드를 사용할 수 있죠. 선택 인수 mapFn와 thisArg는 각 요소에 함수를 적용할 수 있게 해줍니다.

5.7 맵과 셋

지금까진 아래와 같은 복잡한 자료구조를 학습해 보았습니다.

  • 객체 – 키가 있는 컬렉션을 저장함
  • 배열 – 순서가 있는 컬렉션을 저장함

하지만 현실 세계를 반영하기엔 이 두 자료구조 만으론 부족해서 맵(Map)과 셋(Set)이 등장하게 되었습니다.

맵(Map)은 키가 있는 데이터를 저장한다는 점에서 객체와 유사합니다. 다만, 은 키에 다양한 자료형을 허용한다는 점에서 차이가 있습니다.

맵에는 다음과 같은 주요 메서드와 프로퍼티가 있습니다.

  • new Map() – 맵을 만듭니다.
  • map.set(key, value) – key를 이용해 value를 저장합니다.
  • map.get(key) – key에 해당하는 값을 반환합니다. key가 존재하지 않으면 undefined를 반환합니다.
  • map.has(key) – key가 존재하면 true, 존재하지 않으면 false를 반환합니다.
  • map.delete(key) – key에 해당하는 값을 삭제합니다.
  • map.clear() – 맵 안의 모든 요소를 제거합니다.
  • map.size – 요소의 개수를 반환합니다.
let map = new Map();

map.set('1', 'str1');   // 문자형 키
map.set(1, 'num1');     // 숫자형 키
map.set(true, 'bool1'); // 불린형 키

// 객체는 키를 문자형으로 변환한다는 걸 기억하고 계신가요?
// 맵은 키의 타입을 변환시키지 않고 그대로 유지합니다. 따라서 아래의 코드는 출력되는 값이 다릅니다.
alert( map.get(1)   ); // 'num1'
alert( map.get('1') ); // 'str1'

alert( map.size ); // 3

맵은 객체와 달리 키를 문자형으로 변환하지 않습니다. 키엔 자료형 제약이 없습니다.

맵은 키로 객체를 허용합니다.

let john = { name: "John" };

// 고객의 가게 방문 횟수를 세본다고 가정해 봅시다.
let visitsCountMap = new Map();

// john을 맵의 키로 사용하겠습니다.
visitsCountMap.set(john, 123);

alert( visitsCountMap.get(john) ); // 123

객체를 키로 사용할 수 있다는 점은 의 가장 중요한 기능 중 하나입니다. 객체에는 문자열 키를 사용할 수 있습니다. 하지만 객체 키는 사용할 수 없습니다.

객체형 키를 객체에 써봅시다.

let john = { name: "John" };

let visitsCountObj = {}; // 객체를 하나 만듭니다.

visitsCountObj[john] = 123; // 객체(john)를 키로 해서 객체에 값(123)을 저장해봅시다.

*// 원하는 값(123)을 얻으려면 아래와 같이 키가 들어갈 자리에 `object Object`를 써줘야합니다.
alert( visitsCountObj["[object Object]"] ); // 123*

visitsCountObj는 객체이기 때문에 모든 키를 문자형으로 변환시킵니다. 이 과정에서 john은 문자형으로 변환되어 "[object Object]"가 됩니다.

맵의 요소에 반복 작업하기

다음 세 가지 메서드를 사용해 의 각 요소에 반복 작업을 할 수 있습니다.

  • map.keys() – 각 요소의 키를 모은 반복 가능한(iterable, 이터러블) 객체를 반환합니다.
  • map.values() – 각 요소의 값을 모은 이터러블 객체를 반환합니다.
  • map.entries() – 요소의 [키, 값]을 한 쌍으로 하는 이터러블 객체를 반환합니다. 이 이터러블 객체는 for..of반복문의 기초로 쓰입니다.
let recipeMap = new Map([
  ['cucumber', 500],
  ['tomatoes', 350],
  ['onion',    50]
]);

// 키(vegetable)를 대상으로 순회합니다.
for (let vegetable of recipeMap.keys()) {
  alert(vegetable); // cucumber, tomatoes, onion
}

// 값(amount)을 대상으로 순회합니다.
for (let amount of recipeMap.values()) {
  alert(amount); // 500, 350, 50
}

// [키, 값] 쌍을 대상으로 순회합니다.
for (let entry of recipeMap) { // recipeMap.entries()와 동일합니다.
  alert(entry); // cucumber,500 ...
}

여기에 더하여 은 배열과 유사하게 내장 메서드 forEach도 지원합니다.

// 각 (키, 값) 쌍을 대상으로 함수를 실행
recipeMap.forEach( (value, key, map) => {
  alert(`${key}: ${value}`); // cucumber: 500 ...
});

Object.entries: 객체를 맵으로 바꾸기

각 요소가 키-값 쌍인 배열이나 이터러블 객체를 초기화 용도로 에 전달해 새로운 을 만들 수 있습니다.

아래와 같이 말이죠.

// 각 요소가 [키, 값] 쌍인 배열
let map = new Map([
  ['1',  'str1'],
  [1,    'num1'],
  [true, 'bool1']
]);

alert( map.get('1') ); // str1

평범한 객체를 가지고 을 만들고 싶다면 내장 메서드 Object.entries(obj)를 활용해야 합니다. 이 메서드는 객체의 키-값 쌍을 요소([key, value])로 가지는 배열을 반환합니다.

let obj = {
  name: "John",
  age: 30
};

*let map = new Map(Object.entries(obj));*

alert( map.get('name') ); // John

Object.entries를 사용해 객체 obj를 배열 [ ["name","John"], ["age", 30] ]로 바꾸고, 이 배열을 이용해 새로운 을 만들어보았습니다.

Object.fromEntries: 맵을 객체로 바꾸기

방금까진 Object.entries(obj)를 사용해 평범한 객체를 으로 바꾸는 방법에 대해 알아보았습니다.

이젠 이 반대인 맵을 객체로 바꾸는 방법에 대해 알아보겠습니다. Object.fromEntries를 사용하면 가능합니다. 이 메서드는 각 요소가 [키, 값] 쌍인 배열을 객체로 바꿔줍니다.

let prices = Object.fromEntries([
  ['banana', 1],
  ['orange', 2],
  ['meat', 4]
]);

// now prices = { banana: 1, orange: 2, meat: 4 }

alert(prices.orange); // 2

Object.fromEntries를 사용해 을 객체로 바꿔봅시다.

자료가 에 저장되어있는데, 서드파티 코드에서 자료를 객체형태로 넘겨받길 원할 때 이 방법을 사용할 수 있습니다.

let map = new Map();
map.set('banana', 1);
map.set('orange', 2);
map.set('meat', 4);

*let obj = Object.fromEntries(map.entries()); // 맵을 일반 객체로 변환 (*)*

// 맵이 객체가 되었습니다!
// obj = { banana: 1, orange: 2, meat: 4 }

alert(obj.orange); // 2

map.entries()를 호출하면 맵의 [키, 값]을 요소로 가지는 이터러블을 반환합니다. Object.fromEntries를 사용하기 위해 딱 맞는 형태이죠.

(*)로 표시한 줄을 좀 더 짧게 줄이는 것도 가능합니다.

let obj = Object.fromEntries(map); // .entries()를 생략함

Object.fromEntries는 인수로 이터러블 객체를 받기 때문에 짧게 줄인 코드도 이전 코드와 동일하게 동작합니다. 꼭 배열을 전달해줄 필요는 없습니다. 그리고 map에서의 일반적인 반복은 map.entries()를 사용했을 때와 같은 키-값 쌍을 반환합니다. 따라서 map과 동일한 키-값을 가진 일반 객체를 얻게 됩니다.

셋(Set)은 중복을 허용하지 않는 값을 모아놓은 특별한 컬렉션입니다. 셋에 키가 없는 값이 저장됩니다.

주요 메서드는 다음과 같습니다.

  • new Set(iterable) – 셋을 만듭니다. 이터러블 객체를 전달받으면(대개 배열을 전달받음) 그 안의 값을 복사해 셋에 넣어줍니다.
  • set.add(value) – 값을 추가하고 셋 자신을 반환합니다.
  • set.delete(value) – 값을 제거합니다. 호출 시점에 셋 내에 값이 있어서 제거에 성공하면 true, 아니면 false를 반환합니다.
  • set.has(value) – 셋 내에 값이 존재하면 true, 아니면 false를 반환합니다.
  • set.clear() – 셋을 비웁니다.
  • set.size – 셋에 몇 개의 값이 있는지 세줍니다.

셋 내에 동일한 값(value)이 있다면 set.add(value)을 아무리 많이 호출하더라도 아무런 반응이 없을 겁니다. 셋 내의 값에 중복이 없는 이유가 바로 이 때문이죠.

방문자 방명록을 만든다고 가정해 봅시다. 한 방문자가 여러 번 방문해도 방문자를 중복해서 기록하지 않겠다고 결정 내린 상황입니다. 즉, 한 방문자는 '단 한 번만 기록’되어야 합니다.

이때 적합한 자료구조가 바로 입니다.

let set = new Set();

let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };

// 어떤 고객(john, mary)은 여러 번 방문할 수 있습니다.
set.add(john);
set.add(pete);
set.add(mary);
set.add(john);
set.add(mary);

// 셋에는 유일무이한 값만 저장됩니다.
alert( set.size ); // 3

for (let user of set) {
  alert(user.name); // // John, Pete, Mary 순으로 출력됩니다.
}

 대신 배열을 사용하여 방문자 정보를 저장한 후, 중복 값 여부는 배열 메서드인 arr.find를 이용해 확인할 수도 있습니다. 하지만 arr.find는 배열 내 요소 전체를 뒤져 중복 값을 찾기 때문에, 셋보다 성능 면에서 떨어집니다. 반면, 은 값의 유일무이함을 확인하는데 최적화되어있습니다.

셋의 값에 반복 작업하기

for..of나 forEach를 사용하면 셋의 값을 대상으로 반복 작업을 수행할 수 있습니다.

let set = new Set(["oranges", "apples", "bananas"]);

for (let value of set) alert(value);

// forEach를 사용해도 동일하게 동작합니다.
set.forEach((value, valueAgain, set) => {
  alert(value);
});

흥미로운 점이 눈에 띄네요. forEach에 쓰인 콜백 함수는 세 개의 인수를 받는데, 첫 번째는 , 두 번째도 같은 값인 valueAgain을 받고 있습니다. 세 번째는 목표하는 객체(셋)이고요. 동일한 값이 인수에 두 번 등장하고 있습니다.

이렇게 구현된 이유는 과의 호환성 때문입니다. 의 forEach에 쓰인 콜백이 세 개의 인수를 받을 때를 위해서죠. 이상해 보이겠지만 이렇게 구현해 놓았기 때문에 을 으로 혹은 을 으로 교체하기가 쉽습니다.

에도 과 마찬가지로 반복 작업을 위한 메서드가 있습니다.

  • set.keys() – 셋 내의 모든 값을 포함하는 이터러블 객체를 반환합니다.
  • set.values() – set.keys와 동일한 작업을 합니다. 과의 호환성을 위해 만들어진 메서드입니다.
  • set.entries() – 셋 내의 각 값을 이용해 만든 [value, value] 배열을 포함하는 이터러블 객체를 반환합니다. 과의 호환성을 위해 만들어졌습니다.

요약

은 키가 있는 값이 저장된 컬렉션입니다.

주요 메서드와 프로퍼티:

  • new Map([iterable]) – 맵을 만듭니다. [key,value]쌍이 있는 iterable(예: 배열)을 선택적으로 넘길 수 있는데, 이때 넘긴 이터러블 객체는 맵 초기화에 사용됩니다.
  • map.set(key, value) – 키를 이용해 값을 저장합니다.
  • map.get(key) – 키에 해당하는 값을 반환합니다. key가 존재하지 않으면 undefined를 반환합니다.
  • map.has(key) – 키가 있으면 true, 없으면 false를 반환합니다.
  • map.delete(key) – 키에 해당하는 값을 삭제합니다.
  • map.clear() – 맵 안의 모든 요소를 제거합니다.
  • map.size – 요소의 개수를 반환합니다.

일반적인 객체와의 차이점:

  • 키의 타입에 제약이 없습니다. 객체도 키가 될 수 있습니다.
  • size 프로퍼티 등의 유용한 메서드나 프로퍼티가 있습니다.

은 중복이 없는 값을 저장할 때 쓰이는 컬렉션입니다.

주요 메서드와 프로퍼티:

  • new Set([iterable]) – 셋을 만듭니다. iterable 객체를 선택적으로 전달받을 수 있는데(대개 배열을 전달받음), 이터러블 객체 안의 요소는 셋을 초기화하는데 쓰입니다.
  • set.add(value) – 값을 추가하고 셋 자신을 반환합니다. 셋 내에 이미 value가 있는 경우 아무런 작업을 하지 않습니다.
  • set.delete(value) – 값을 제거합니다. 호출 시점에 셋 내에 값이 있어서 제거에 성공하면 true, 아니면 false를 반환합니다.
  • set.has(value) – 셋 내에 값이 존재하면 true, 아니면 false를 반환합니다.
  • set.clear() – 셋을 비웁니다.
  • set.size – 셋에 몇 개의 값이 있는지 세줍니다.

과 에 반복 작업을 할 땐, 해당 컬렉션에 요소나 값을 추가한 순서대로 반복 작업이 수행됩니다. 따라서 이 두 컬렉션은 정렬이 되어있지 않다고 할 수 없습니다. 그렇지만 컬렉션 내 요소나 값을 재 정렬하거나 (배열에서 인덱스를 이용해 요소를 가져오는 것처럼) 숫자를 이용해 특정 요소나 값을 가지고 오는 것은 불가능합니다.

5.8 위크맵과 위크셋

과 위크맵의 첫 번째 차이는 위크맵의 키가 반드시 객체여야 한다는 점입니다. 원시값은 위크맵의 키가 될 수 없습니다.

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "ok"); //정상적으로 동작합니다(객체 키).

*// 문자열("test")은 키로 사용할 수 없습니다.
weakMap.set("test", "Whoops"); // Error: Invalid value used as weak map key*

위크맵의 키로 사용된 객체를 참조하는 것이 아무것도 없다면 해당 객체는 메모리와 위크맵에서 자동으로 삭제됩니다.

let john = { name: "John" };

let weakMap = new WeakMap();
weakMap.set(john, "...");

john = null; // 참조를 덮어씀

// john을 나타내는 객체는 이제 메모리에서 지워집니다!

john을 나타내는 객체는 오로지 위크맵의 키로만 사용되고 있으므로, 참조를 덮어쓰게 되면 이 객체는 위크맵과 메모리에서 자동으로 삭제됩니다.

과 위크맵의 두 번째 차이는 위크맵은 반복 작업과 keys()values()entries() 메서드를 지원하지 않는다는 점입니다. 따라서 위크맵에선 키나 값 전체를 얻는 게 불가능합니다.

위크맵이 지원하는 메서드는 단출합니다.

  • weakMap.get(key)
  • weakMap.set(key, value)
  • weakMap.delete(key)
  • weakMap.has(key)

왜 이렇게 적은 메서드만 제공할까요? 원인은 가비지 컬렉션의 동작 방식 때문입니다. 위 예시의 john을 나타내는 객체처럼, 객체는 모든 참조를 잃게 되면 자동으로 가비지 컬렉션의 대상이 됩니다. 그런데 가비지 컬렉션의 동작 시점은 정확히 알 수 없습니다.

가비지 컬렉션이 일어나는 시점은 자바스크립트 엔진이 결정합니다. 객체는 모든 참조를 잃었을 때, 그 즉시 메모리에서 삭제될 수도 있고, 다른 삭제 작업이 있을 때까지 대기하다가 함께 삭제될 수도 있습니다. 현재 위크맵에 요소가 몇 개 있는지 정확히 파악하는 것 자체가 불가능한 것이죠. 가비지 컬렉터가 한 번에 메모리를 청소할 수도 있고, 부분 부분 메모리를 청소할 수도 있으므로 위크맵의 요소(키/값) 전체를 대상으로 무언가를 하는 메서드는 동작 자체가 불가능합니다.

그럼 위크맵을 어떤 경우에 사용할 수 있을까요?

유스 케이스: 추가 데이터

위크맵은 부차적인 데이터를 저장할 곳이 필요할 때 그 진가를 발휘합니다.

서드파티 라이브러리와 같은 외부 코드에 ‘속한’ 객체를 가지고 작업을 해야 한다고 가정해 봅시다. 이 객체에 데이터를 추가해줘야 하는데, 추가해 줄 데이터는 객체가 살아있는 동안에만 유효한 상황입니다. 이럴 때 위크맵을 사용할 수 있습니다.

위크맵에 원하는 데이터를 저장하고, 이때 키는 객체를 사용하면 됩니다. 이렇게 하면 객체가 가비지 컬렉션의 대상이 될 때, 데이터도 함께 사라지게 됩니다.

weakMap.set(john, "비밀문서");
// john이 사망하면, 비밀문서는 자동으로 파기됩니다.

좀 더 구체적인 예시를 들어보겠습니다.

아래에 사용자의 방문 횟수를 세어 주는 코드가 있습니다. 관련 정보는 맵에 저장하고 있는데 맵 요소의 키엔 특정 사용자를 나타내는 객체를, 값엔 해당 사용자의 방문 횟수를 저장하고 있습니다. 어떤 사용자의 정보를 저장할 필요가 없어지면(가비지 컬렉션의 대상이 되면) 해당 사용자의 방문 횟수도 저장할 필요가 없어질 겁니다.

아래 함수는 을 사용해 사용자의 방문 횟수를 세줍니다.

// 📁 visitsCount.js
let visitsCountMap = new Map(); // 맵에 사용자의 방문 횟수를 저장함

// 사용자가 방문하면 방문 횟수를 늘려줍니다.
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

아래는 John이라는 사용자가 방문했을 때, 어떻게 방문 횟수가 증가하는지를 보여줍니다.

// 📁 main.js
let john = { name: "John" };

countUser(john); // John의 방문 횟수를 증가시킵니다.

// John의 방문 횟수를 셀 필요가 없어지면 아래와 같이 john을 null로 덮어씁니다.
john = null;

이제 john을 나타내는 객체는 가비지 컬렉션의 대상이 되어야 하는데, visitsCountMap의 키로 사용되고 있어서 메모리에서 삭제되지 않습니다.

특정 사용자를 나타내는 객체가 메모리에서 사라지면 해당 객체에 대한 정보(방문 횟수)도 우리가 손수 지워줘야 하는 상황입니다. 이렇게 하지 않으면 visitsCountMap가 차지하는 메모리 공간이 한없이 커질 겁니다. 애플리케이션 구조가 복잡할 땐, 이렇게 쓸모 없는 데이터를 수동으로 비워주는 게 꽤 골치 아픕니다.

이런 문제는 위크맵을 사용해 예방할 수 있습니다.

// 📁 visitsCount.js
let visitsCountMap = new WeakMap(); // 위크맵에 사용자의 방문 횟수를 저장함

// 사용자가 방문하면 방문 횟수를 늘려줍니다.
function countUser(user) {
  let count = visitsCountMap.get(user) || 0;
  visitsCountMap.set(user, count + 1);
}

위크맵을 사용해 사용자 방문 횟수를 저장하면 visitsCountMap을 수동으로 청소해줄 필요가 없습니다. john을 나타내는 객체가 도달 가능하지 않은 상태가 되면 자동으로 메모리에서 삭제되기 때문입니다. 위크맵의 키(john)에 대응하는 값(john의 방문 횟수)도 자동으로 가비지 컬렉션의 대상이 됩니다.

유스 케이스: 캐싱

위크맵은 캐싱(caching)이 필요할 때 유용합니다. 캐싱은 시간이 오래 걸리는 작업의 결과를 저장해서 연산 시간과 비용을 절약해주는 기법입니다. 동일한 함수를 여러 번 호출해야 할 때, 최초 호출 시 반환된 값을 어딘가에 저장해 놓았다가 그다음엔 함수를 호출하는 대신 저장된 값을 사용하는 게 캐싱의 실례입니다.

아래 예시는 함수 연산 결과를 에 저장하고 있습니다.

// 📁 cache.js
let cache = new Map();

// 연산을 수행하고 그 결과를 맵에 저장합니다.
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* 연산 수행 */ obj;

    cache.set(obj, result);
  }

  return cache.get(obj);
}

*// 함수 process()를 호출해봅시다.*// 📁 main.js
let obj = {/* ... 객체 ... */};

let result1 = process(obj); // 함수를 호출합니다.

// 동일한 함수를 두 번째 호출할 땐,
let result2 = process(obj); // 연산을 수행할 필요 없이 맵에 저장된 결과를 가져오면 됩니다.

// 객체가 쓸모없어지면 아래와 같이 null로 덮어씁니다.
obj = null;

alert(cache.size); // 1 (엇! 그런데 객체가 여전히 cache에 남아있네요. 메모리가 낭비되고 있습니다.)

process(obj)를 여러 번 호출하면 최초 호출할 때만 연산이 수행되고, 그 이후엔 연산 결과를 cache에서 가져옵니다. 그런데 을 사용하고 있어서 객체가 필요 없어져도 cache를 수동으로 청소해 줘야 합니다.

을 위크맵으로 교체하면 이런 문제를 예방할 수 있습니다. 객체가 메모리에서 삭제되면, 캐시에 저장된 결과(함수 연산 결과) 역시 메모리에서 자동으로 삭제되기 때문입니다.

// 📁 cache.js
*let cache = new WeakMap();*// 연산을 수행하고 그 결과를 위크맵에 저장합니다.
function process(obj) {
  if (!cache.has(obj)) {
    let result = /* 연산 수행 */ obj;

    cache.set(obj, result);
  }

  return cache.get(obj);
}

// 📁 main.js
let obj = {/* ... 객체 ... */};

let result1 = process(obj);
let result2 = process(obj);

// 객체가 쓸모없어지면 아래와 같이 null로 덮어씁니다.
obj = null;

// 이 예시에선 맵을 사용한 예시처럼 cache.size를 사용할 수 없습니다.
// 하지만 obj가 가비지 컬렉션의 대상이 되므로, 캐싱된 데이터 역시 메모리에서 삭제될 겁니다.
// 삭제가 진행되면 cache엔 그 어떤 요소도 남아있지 않을겁니다.

위크셋

이제 위크셋(WeakSet)에 대해 알아봅시다.

  • 위크셋은 과 유사한데, 객체만 저장할 수 있다는 점이 다릅니다. 원시값은 저장할 수 없습니다.
  • 셋 안의 객체는 도달 가능할 때만 메모리에서 유지됩니다.
  • 과 마찬가지로 위크셋이 지원하는 메서드는 단출합니다. addhasdelete를 사용할 수 있고, sizekeys()나 반복 작업 관련 메서드는 사용할 수 없습니다.

'위크’맵과 유사하게 '위크’셋도 부차적인 데이터를 저장할 때 사용할 수 있습니다. 다만, 위크셋엔 위크맵처럼 복잡한 데이터를 저장하지 않습니다. 대신 "예"나 “아니오” 같은 간단한 답변을 얻는 용도로 사용됩니다. 물론 위크셋에 저장되는 값들은 객체이겠죠.

예시와 함께 위크셋의 용도를 알아봅시다. 아래 코드에선 사용자의 사이트 방문 여부를 추적하는 용도로 위크셋을 사용하고 있습니다.

let visitedSet = new WeakSet();

let john = { name: "John" };
let pete = { name: "Pete" };
let mary = { name: "Mary" };

visitedSet.add(john); // John이 사이트를 방문합니다.
visitedSet.add(pete); // 이어서 Pete가 사이트를 방문합니다.
visitedSet.add(john); // 이어서 John이 다시 사이트를 방문합니다.

// visitedSet엔 두 명의 사용자가 저장될 겁니다.

// John의 방문 여부를 확인해보겠습니다.
alert(visitedSet.has(john)); // true

// Mary의 방문 여부를 확인해보겠습니다.
alert(visitedSet.has(mary)); // false

john = null;

// visitedSet에서 john을 나타내는 객체가 자동으로 삭제됩니다.

위크맵과 위크셋의 가장 큰 단점은 반복 작업이 불가능하다는 점입니다. 위크맵이나 위크셋에 저장된 자료를 한 번에 얻는 게 불가능하죠. 이런 단점은 불편함을 초래하는 것 같아 보이지만, 위크맵과 위크셋을 이용해 할 수 있는 주요 작업을 방해하진 않습니다. 위크맵과 위크셋은 객체와 함께 ‘추가’ 데이터를 저장하는 용도로 쓸 수 있습니다.

요약

위크맵은 과 유사한 컬렉션입니다. 위크맵을 구성하는 요소의 키는 오직 객체만 가능합니다. 키로 사용된 객체가 메모리에서 삭제되면 이에 대응하는 값 역시 삭제됩니다.

위크셋은 과 유사한 컬렉션입니다. 위크셋엔 객체만 저장할 수 있습니다. 위크셋에 저장된 객체가 도달 불가능한 상태가 되면 해당 객체는 메모리에서 삭제됩니다.

두 자료구조 모두 구성 요소 전체를 대상으로 하는 메서드를 지원하지 않습니다. 구성 요소 하나를 대상으로 하는 메서드만 지원합니다.

객체엔 ‘주요’ 자료를, 위크맵과 위크셋엔 ‘부수적인’ 자료를 저장하는 형태로 위크맵과 위크셋을 활용할 수 있습니다. 객체가 메모리에서 삭제되면, (그리고 오로지 위크맵과 위크셋의 키만 해당 객체를 참조하고 있다면) 위크맵이나 위크셋에 저장된 연관 자료들 역시 메모리에서 자동으로 삭제됩니다.

5.9 Object.keys, values, entries

개별 자료 구조에서 한발 뒤로 물러나 순회에 관해 이야기 나누어봅시다.

이전 챕터에서 map.keys()map.values()map.entries()와 같은 메서드들에 대해 알아보았습니다.

이 메서드들은 포괄적인 용도로 만들어졌기 때문에 이 메서드들이 적용될 자료구조는 일련의 합의를 준수해야 합니다. 자료구조를 직접 만들어서 사용하려면 기존에 구현된 메서드를 쓰지 못하고 직접 커스텀 메서드를 구현해야 합니다.

keys()values()entries()는 아래와 같은 자료구조에 적용할 수 있습니다.

  • Map
  • Set
  • Array

일반 객체에도 유사한 메서드를 적용할 수 있는데, MapSetArray에 적용하는 메서드와는 문법이 약간 다릅니다.

Object.keys, values, entries

일반 객체엔 다음과 같은 메서드를 사용할 수 있습니다.

MapSetArray에 적용하는 메서드와 객체에 적용하는 메서드의 차이를 맵을 기준으로 비교하면 다음과 같습니다.

Untitled

첫 번째 차이는 obj.keys()가 아닌 Object.keys(obj)를 호출한다는 점입니다.

이렇게 문법에 차이가 있는 이유는 유연성 때문입니다. 아시다시피 자바스크립트에선 복잡한 자료구조 전체가 객체에 기반합니다. 그러다 보니 객체 data에 자체적으로 메서드 data.values()를 구현해 사용하는 경우가 있을 수 있습니다. 이렇게 커스텀 메서드를 구현한 상태라도 Object.values(data)같이 다른 형태로 메서드를 호출할 수 있으면 커스텀 메서드와 내장 메서드 둘 다를 사용할 수 있습니다.

두 번째 차이는 메서드 Object.*를 호출하면 iterable 객체가 아닌 객체의 한 종류인 배열을 반환한다는 점입니다. ‘진짜’ 배열을 반환하는 이유는 하위 호환성 때문입니다.

let user = {
  name: "John",
  age: 30
};
  • Object.keys(user) = ["name", "age"]
  • Object.values(user) = ["John", 30]
  • Object.entries(user) = [ ["name","John"], ["age",30] ]

아래 예시처럼 Object.values를 사용하면 프로퍼티 값을 대상으로 원하는 작업을 할 수 있습니다.

let user = {
  name: "John",
  age: 30
};

// 값을 순회합니다.
for (let value of Object.values(user)) {
  alert(value); // John, 30
}

객체 변환하기

객체엔 mapfilter 같은 배열 전용 메서드를 사용할 수 없습니다.

하지만 Object.entries와 Object.fromEntries를 순차적으로 적용하면 객체에도 배열 전용 메서드 사용할 수 있습니다. 적용 방법은 다음과 같습니다.

  1. Object.entries(obj)를 사용해 객체의 키-값 쌍을 요소로 갖는 배열을 얻습니다.
  2. 1.에서 만든 배열에 map 등의 배열 전용 메서드를 적용합니다.
  3. 2.에서 반환된 배열에 Object.fromEntries(array)를 적용해 배열을 다시 객체로 되돌립니다.

위 방법을 사용해 가격 정보가 저장된 객체 prices의 프로퍼티 값을 두 배로 늘려보도록 합시다.

let prices = {
  banana: 1,
  orange: 2,
  meat: 4,
};

*let doublePrices = Object.fromEntries(
  // 객체를 배열로 변환해서 배열 전용 메서드인 map을 적용하고 fromEntries를 사용해 배열을 다시 객체로 되돌립니다.
  Object.entries(prices).map(([key, value]) => [key, value * 2])
);*alert(doublePrices.meat); // 8

5.10 구조 분해 할당

객체와 배열은 자바스크립트에서 가장 많이 쓰이는 자료 구조입니다.

키를 가진 데이터 여러 개를 하나의 엔티티에 저장할 땐 객체를, 컬렉션에 데이터를 순서대로 저장할 땐 배열을 사용하죠.

개발을 하다 보면 함수에 객체나 배열을 전달해야 하는 경우가 생기곤 합니다. 가끔은 객체나 배열에 저장된 데이터 전체가 아닌 일부만 필요한 경우가 생기기도 하죠.

이럴 때 객체나 배열을 변수로 '분해’할 수 있게 해주는 특별한 문법인 구조 분해 할당(destructuring assignment) 을 사용할 수 있습니다. 이 외에도 함수의 매개변수가 많거나 매개변수 기본값이 필요한 경우 등에서 구조 분해(destructuring)는 그 진가를 발휘합니다.

배열 분해하기

배열이 어떻게 변수로 분해되는지 예제를 통해 살펴봅시다.

// 이름과 성을 요소로 가진 배열
let arr = ["Bora", "Lee"]

*// 구조 분해 할당을 이용해
// firstName엔 arr[0]을
// surname엔 arr[1]을 할당하였습니다.
let [firstName, surname] = arr;*

alert(firstName); // Bora
alert(surname);  // Lee

이제 인덱스를 이용해 배열에 접근하지 않고도 변수로 이름과 성을 사용할 수 있게 되었습니다.

아래 예시처럼 split 같은 반환 값이 배열인 메서드를 함께 활용해도 좋습니다.

let [firstName, surname] = "Bora Lee".split(' ');

'…'로 나머지 요소 가져오기

배열 앞쪽에 위치한 값 몇 개만 필요하고 그 이후 이어지는 나머지 값들은 한데 모아서 저장하고 싶을 때가 있습니다. 이럴 때는 점 세 개 ...를 붙인 매개변수 하나를 추가하면 ‘나머지(rest)’ 요소를 가져올 수 있습니다.

let [name1, name2, *...rest*] = ["Julius", "Caesar", *"Consul", "of the Roman Republic"*];

alert(name1); // Julius
alert(name2); // Caesar

*// `rest`는 배열입니다.
alert(rest[0]); // Consul
alert(rest[1]); // of the Roman Republic
alert(rest.length); // 2*

rest는 나머지 배열 요소들이 저장된 새로운 배열이 됩니다. rest 대신에 다른 이름을 사용해도 되는데, 변수 앞의 점 세 개(...)와 변수가 가장 마지막에 위치해야 한다는 점은 지켜주시기 바랍니다.

기본값

할당하고자 하는 변수의 개수가 분해하고자 하는 배열의 길이보다 크더라도 에러가 발생하지 않습니다. 할당할 값이 없으면 undefined로 취급되기 때문입니다.

*let [firstName, surname] = [];*

alert(firstName); // undefined
alert(surname); // undefined

=을 이용하면 할당할 값이 없을 때 기본으로 할당해 줄 값인 '기본값(default value)'을 설정할 수 있습니다.

*// 기본값
let [name = "Guest", surname = "Anonymous"] = ["Julius"];*

alert(name);    // Julius (배열에서 받아온 값)
alert(surname); // Anonymous (기본값)

복잡한 표현식이나 함수 호출도 기본값이 될 수 있습니다. 이렇게 기본식으로 표현식이나 함수를 설정하면 할당할 값이 없을 때 표현식이 평가되거나 함수가 호출됩니다.

기본값으로 두 개의 prompt 함수를 할당한 예시를 살펴봅시다. 값이 제공되지 않았을 때만 함수가 호출되므로, prompt는 한 번만 호출됩니다.

// name의 prompt만 실행됨
let [surname = prompt('성을 입력하세요.'), name = prompt('이름을 입력하세요.')] = ["김"];

alert(surname); // 김 (배열에서 받아온 값)
alert(name);    // prompt에서 받아온 값

객체 분해하기

구조 분해 할당으로 객체도 분해할 수 있습니다.

기본 문법은 다음과 같습니다.

let {var1, var2} = {var1:, var2:}

할당 연산자 우측엔 분해하고자 하는 객체를, 좌측엔 상응하는 객체 프로퍼티의 '패턴’을 넣습니다. 분해하려는 객체 프로퍼티의 키 목록을 패턴으로 사용하는 예시를 살펴봅시다.

let options = {
  title: "Menu",
  width: 100,
  height: 200
};

*let {title, width, height} = options;*

alert(title);  // Menu
alert(width);  // 100
alert(height); // 200

프로퍼티 options.title과 options.widthoptions.height에 저장된 값이 상응하는 변수에 할당된 것을 확인할 수 있습니다. 참고로 순서는 중요하지 않습니다. 아래와 같이 작성해도 위 예시와 동일하게 동작합니다.

// let {...} 안의 순서가 바뀌어도 동일하게 동작함
let {height, width, title} = { title: "Menu", height: 200, width: 100 }

할당 연산자 좌측엔 좀 더 복잡한 패턴이 올 수도 있습니다. 분해하려는 객체의 프로퍼티와 변수의 연결을 원하는 대로 조정할 수도 있습니다.

객체 프로퍼티를 프로퍼티 키와 다른 이름을 가진 변수에 저장해봅시다. options.width를 w라는 변수에 저장하는 식으로 말이죠. 좌측 패턴에 콜론(:)을 사용하면 원하는 목표를 달성할 수 있습니다.

let options = {
  title: "Menu",
  width: 100,
  height: 200
};

*// { 객체 프로퍼티: 목표 변수 }
let {width: w, height: h, title} = options;*

// width -> w
// height -> h
// title -> title

alert(title);  // Menu
alert(w);      // 100
alert(h);      // 200

콜론은 '분해하려는 객체의 프로퍼티: 목표 변수’와 같은 형태로 사용합니다. 위 예시에선 프로퍼티 width를 변수 w에, 프로퍼티 height를 변수 h에 저장했습니다. 프로퍼티 title은 동일한 이름을 가진 변수 title에 저장됩니다.

프로퍼티가 없는 경우를 대비하여 =을 사용해 기본값을 설정하는 것도 가능합니다. 아래와 같이 말이죠.

let options = {
  title: "Menu"
};

*let {width = 100, height = 200, title} = options;*

alert(title);  // Menu
alert(width);  // 100
alert(height); // 200

배열 혹은 함수의 매개변수에서 했던 것처럼 객체에도 표현식이나 함수 호출을 기본값으로 할당할 수 있습니다. 물론 표현식이나 함수는 값이 제공되지 않았을 때 평가 혹은 실행되겠죠.

아래 예시를 실행하면 width 값만 물어보고 title 값은 물어보지 않습니다.

let options = {
  title: "Menu"
};

*let {width = prompt("width?"), title = prompt("title?")} = options;*

alert(title);  // Menu
alert(width);  // prompt 창에 입력한 값

콜론과 할당 연산자를 동시에 사용할 수도 있습니다.

let options = {
  title: "Menu"
};

*let {width: w = 100, height: h = 200, title} = options;*

alert(title);  // Menu
alert(w);      // 100
alert(h);      // 200

프로퍼티가 많은 복잡한 객체에서 원하는 정보만 뽑아오는 것도 가능합니다.

let options = {
  title: "Menu",
  width: 100,
  height: 200
};

// title만 변수로 뽑아내기
let { title } = options;

alert(title); // Menu

나머지 패턴 '...'

분해하려는 객체의 프로퍼티 개수가 할당하려는 변수의 개수보다 많다면 어떨까요? '나머지’를 어딘가에 할당하면 되지 않겠냐는 생각이 들지 않으시나요?

나머지 패턴(rest pattern)을 사용하면 배열에서 했던 것처럼 나머지 프로퍼티를 어딘가에 할당하는 게 가능합니다. 참고로 모던 브라우저는 나머지 패턴을 지원하지만, IE를 비롯한 몇몇 구식 브라우저는 나머지 패턴을 지원하지 않으므로 주의해서 사용해야 합니다. 물론 바벨(Babel)을 이용하면 되지만요.

let options = {
  title: "Menu",
  height: 200,
  width: 100
};

*// title = 이름이 title인 프로퍼티
// rest = 나머지 프로퍼티들
let {title, ...rest} = options;*

// title엔 "Menu", rest엔 {height: 200, width: 100}이 할당됩니다.
alert(rest.height);  // 200
alert(rest.width);   // 100

중첩 구조 분해

객체나 배열이 다른 객체나 배열을 포함하는 경우, 좀 더 복잡한 패턴을 사용하면 중첩 배열이나 객체의 정보를 추출할 수 있습니다. 이를 중첩 구조 분해(nested destructuring)라고 부릅니다.

아래 예시에서 객체 options의 size 프로퍼티 값은 또 다른 객체입니다. items 프로퍼티는 배열을 값으로 가지고 있습니다. 대입 연산자 좌측의 패턴은 정보를 추출하려는 객체 options와 같은 구조를 갖추고 있습니다.

let options = {
  size: {
    width: 100,
    height: 200
  },
  items: ["Cake", "Donut"],
  extra: true
};

// 코드를 여러 줄에 걸쳐 작성해 의도하는 바를 명확히 드러냄
let {
  size: { // size는 여기,
    width,
    height
  },
  items: [item1, item2], // items는 여기에 할당함
  title = "Menu" // 분해하려는 객체에 title 프로퍼티가 없으므로 기본값을 사용함
} = options;

alert(title);  // Menu
alert(width);  // 100
alert(height); // 200
alert(item1);  // Cake
alert(item2);  // Donut

extra(할당 연산자 좌측의 패턴에는 없음)를 제외한 options 객체의 모든 프로퍼티가 상응하는 변수에 할당되었습니다.

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/eeffdd08-a8cb-44b8-bdad-6958c2104568/Untitled.png

변수 widthheightitem1item2엔 원하는 값이, title엔 기본값이 저장되었네요.

그런데 위 예시에서 size와 items 전용 변수는 없다는 점에 유의하시기 바랍니다. 전용 변수 대신 우리는 size와 items 안의 정보를 변수에 할당하였습니다.

똑똑한 함수 매개변수

함수에 매개변수가 많은데 이중 상당수는 선택적으로 쓰이는 경우가 종종 있습니다. 사용자 인터페이스와 연관된 함수에서 이런 상황을 자주 볼 수 있죠. 메뉴 생성에 관여하는 함수가 있다고 해 봅시다. 메뉴엔 너비, 높이, 제목, 항목 리스트 등이 필요하기 때문에 이 정보는 매개변수로 받습니다.

먼저 리팩토링 전의 메뉴 생성 함수를 살펴보겠습니다.

function showMenu(title = "Untitled", width = 200, height = 100, items = []) {
  // ...
}

이렇게 함수를 작성하면 넘겨주는 인수의 순서가 틀려 문제가 발생할 수 있습니다. 문서화가 잘 되어있다면 IDE가 순서를 틀리지 않게 도움을 주긴 하겠지만 말이죠. 이 외에도 대부분의 매개변수에 기본값이 설정되어 있어 굳이 인수를 넘겨주지 않아도 되는 경우에 문제가 발생합니다.

아래 코드를 살펴보시죠. 어떤 느낌이 드시나요?

// 기본값을 사용해도 괜찮은 경우 아래와 같이 undefined를 여러 개 넘겨줘야 합니다.
showMenu("My Menu", undefined, undefined, ["Item1", "Item2"])

꽤 지저분해 보이네요. 매개변수가 많아질수록 가독성은 더 떨어질 겁니다.

구조 분해는 이럴 때 구세주가 됩니다.

매개변수 모두를 객체에 모아 함수에 전달해, 함수가 전달받은 객체를 분해하여 변수에 할당하고 원하는 작업을 수행할 수 있도록 함수를 리팩토링해 봅시다.

// 함수에 전달할 객체
let options = {
  title: "My menu",
  items: ["Item1", "Item2"]
};

// 똑똑한 함수는 전달받은 객체를 분해해 변수에 즉시 할당함
function showMenu(*{title = "Untitled", width = 200, height = 100, items = []}*) {
  // title, items – 객체 options에서 가져옴
  // width, height – 기본값
  alert( `${title} ${width} ${height}` ); // My Menu 200 100
  alert( items ); // Item1, Item2
}

showMenu(options);

중첩 객체와 콜론을 조합하면 좀 더 복잡한 구조 분해도 가능합니다.

let options = {
  title: "My menu",
  items: ["Item1", "Item2"]
};

*function showMenu({
  title = "Untitled",
  width: w = 100,  // width는 w에,
  height: h = 200, // height는 h에,
  items: [item1, item2] // items의 첫 번째 요소는 item1에, 두 번째 요소는 item2에 할당함
}) {*alert( `${title} ${w} ${h}` ); // My Menu 100 200
  alert( item1 ); // Item1
  alert( item2 ); // Item2
}

showMenu(options);

이렇게 똑똑한 함수 매개변수 문법은 구조 분해 할당 문법과 동일합니다.

function({
  incomingProperty: varName = defaultValue
  ...
})

매개변수로 전달된 객체의 프로퍼티 incomingProperty는 varName에 할당되겠죠. 만약 값이 없다면 defaultValue가 기본값으로 사용될 겁니다.

참고로 이렇게 함수 매개변수를 구조 분해할 땐, 반드시 인수가 전달된다고 가정되고 사용된다는 점에 유의하시기 바랍니다. 모든 인수에 기본값을 할당해 주려면 빈 객체를 명시적으로 전달해야 합니다.

showMenu({}); // 모든 인수에 기본값이 할당됩니다.

showMenu(); // 에러가 발생할 수 있습니다.

이 문제를 예방하려면 빈 객체 {}를 인수 전체의 기본값으로 만들면 됩니다.

function showMenu({ title = "Menu", width = 100, height = 200 } *= {}*) {
  alert( `${title} ${width} ${height}` );
}

showMenu(); // Menu 100 200

이렇게 인수 객체의 기본값을 빈 객체 {}로 설정하면 어떤 경우든 분해할 것이 생겨서 함수에 인수를 하나도 전달하지 않아도 에러가 발생하지 않습니다.

요약

  • 구조 분해 할당을 사용하면 객체나 배열을 변수로 연결할 수 있습니다.

  • 객체 분해하기:

    let {prop : varName = default, ...rest} = object

    object의 프로퍼티 prop의 값은 변수 varName에 할당되는데, object에 prop이 없으면 default가 varName에 할당됩니다.

    연결할 변수가 없는 나머지 프로퍼티들은 객체 rest에 복사됩니다.

  • 배열 분해하기:

    let [item1 = default, item2, ...rest] = array

    array의 첫 번째 요소는 item1에, 두 번째 요소는 변수 item2에 할당되고, 이어지는 나머지 요소들은 배열 rest 저장됩니다.

  • 할당 연산자 좌측의 패턴과 우측의 구조가 같으면 중첩 배열이나 객체가 있는 복잡한 구조에서도 원하는 데이터를 뽑아낼 수 있습니다.

5.11 Date 객체와 날짜

날짜를 저장할 수 있고, 날짜와 관련된 메서드도 제공해주는 내장 객체 Date에 대해 알아봅시다(이 글에선 일시(날짜/시간)를 날짜와 혼용해서 사용하겠습니다 – 옮긴이).

Date 객체를 활용하면 생성 및 수정 시간을 저장하거나 시간을 측정할 수 있고, 현재 날짜를 출력하는 용도 등으로도 활용할 수 있습니다.

객체 생성하기

new Date()를 호출하면 새로운 Date 객체가 만들어지는데, new Date()는 다음과 같은 형태로 호출할 수 있습니다.

new Date()

인수 없이 호출하면 현재 날짜와 시간이 저장된 Date 객체가 반환됩니다.

let now = new Date();
alert( now ); // 현재 날짜 및 시간이 출력됨

new Date(milliseconds)

UTC 기준(UTC+0) 1970년 1월 1일 0시 0분 0초에서 milliseconds 밀리초(1/1000 초) 후의 시점이 저장된 Date 객체가 반환됩니다.

// 1970년 1월 1일 0시 0분 0초(UTC+0)를 나타내는 객체
let Jan01_1970 = new Date(0);
alert( Jan01_1970 );
// 1970년 1월 1일의 24시간 후는 1970년 1월 2일(UTC+0)임
let Jan02_1970 = new Date(24 * 3600 * 1000);
alert( Jan02_1970 );

1970년의 첫날을 기준으로 흘러간 밀리초를 나타내는 정수는 타임스탬프(timestamp) 라고 부릅니다.

타임스탬프를 사용하면 날짜를 숫자 형태로 간편하게 나타낼 수 있습니다. new Date(timestamp)를 사용해 타임스탬프를 사용해 특정 날짜가 저장된 Date 객체를 손쉽게 만들 수 있고 date.getTime() 메서드를 사용해 Date 객체에서 타임스탬프를 추출하는 것도 가능합니다(자세한 사항은 아래에서 다루도록 하겠습니다).

1970년 1월 1일 이전 날짜에 해당하는 타임스탬프 값은 음수입니다. 예시를 살펴봅시다.

// 31 Dec 1969
let Dec31_1969 = new Date(-24 * 3600 * 1000);
alert( Dec31_1969 );

new Date(datestring)

인수가 하나인데, 문자열이라면 해당 문자열은 자동으로 구문 분석(parsed)됩니다. 구문 분석에 적용되는 알고리즘은 Date.parse에서 사용하는 알고리즘과 동일한데, 자세한 내용은 아래에서 다루도록 하겠습니다.

let date = new Date("2017-01-26");
alert(date);
// 인수로 시간은 지정하지 않았기 때문에 GMT 자정이라고 가정하고
// 코드가 실행되는 시간대(timezone)에 따라 출력 문자열이 바뀝니다.
// 따라서 얼럿 창엔
// Thu Jan 26 2017 11:00:00 GMT+1100 (Australian Eastern Daylight Time)
// 혹은
// Wed Jan 25 2017 16:00:00 GMT-0800 (Pacific Standard Time)등이 출력됩니다.

new Date(year, month, date, hours, minutes, seconds, ms)

주어진 인수를 조합해 만들 수 있는 날짜가 저장된 객체가 반환됩니다(지역 시간대 기준). 첫 번째와 두 번째 인수만 필수값입니다.

  • year는 반드시 네 자리 숫자여야 합니다. 2013은 괜찮고 98은 괜찮지 않습니다.
  • month는 0(1월)부터 11(12월) 사이의 숫자여야 합니다.
  • date는 일을 나타내는데, 값이 없는 경우엔 1일로 처리됩니다.
  • hours/minutes/seconds/ms에 값이 없는 경우엔 0으로 처리됩니다.
new Date(2011, 0, 1, 0, 0, 0, 0); // 2011년 1월 1일, 00시 00분 00초
new Date(2011, 0, 1); // hours를 비롯한 인수는 기본값이 0이므로 위와 동일

최소 정밀도는 1밀리초(1/1000초)입니다.

let date = new Date(2011, 0, 1, 2, 3, 4, 567);
alert( date ); // 2011년 1월 1일, 02시 03분 04.567초

요약

  • 자바스크립트에선 Date 객체를 사용해 날짜와 시간을 나타냅니다. Date 객체엔 ‘날짜만’ 혹은 ‘시간만’ 저장하는 것은 불가능하고, 항상 날짜와 시간이 함께 저장됩니다.
  • 월은 0부터 시작합니다(0은 1월을 나타냅니다).
  • 요일은 getDay()를 사용하면 얻을 수 있는데, 요일 역시 0부터 시작합니다(0은 일요일을 나타냅니다).
  • 범위를 넘어가는 구성요소를 설정하려 할 때 Date 자동 고침이 활성화됩니다. 이를 이용하면 월/일/시간을 쉽게 날짜에 추가하거나 뺄 수 있습니다.
  • 날짜끼리 빼는 것도 가능한데, 이때 두 날짜의 밀리초 차이가 반환됩니다. 이게 가능한 이유는 Date 가 숫자형으로 바뀔 때 타임스탬프가 반환되기 때문입니다.
  • Date.now()를 사용하면 현재 시각의 타임스탬프를 빠르게 구할 수 있습니다.

자바스크립트의 타임스탬프는 초가 아닌 밀리초 기준이라는 점을 항상 유의하시기 바랍니다.

간혹 밀리초보다 더 정확한 시간 측정이 필요할 때가 있습니다. 자바스크립트는 마이크로초(1/1,000,000초)를 지원하진 않지만 대다수의 호스트 환경은 마이크로초를 지원합니다. 브라우저 환경의 메서드 performance.now()는 페이지 로딩에 걸리는 밀리초를 반환해주는데, 반환되는 숫자는 소수점 아래 세 자리까지 지원합니다.

alert(`페이지 로딩이 ${performance.now()}밀리초 전에 시작되었습니다.`);
// 얼럿 창에 "페이지 로딩이 34731.26000000001밀리초 전에 시작되었습니다."와 유사한 메시지가 뜰 텐데
// 여기서 '.26'은 마이크로초(260마이크로초)를 나타냅니다.
// 소수점 아래 숫자 세 개 이후의 숫자는 정밀도 에러때문에 보이는 숫자이므로 소수점 아래 숫자 세 개만 유효합니다.

Node.js에선 microtime 모듈 등을 사용해 마이크로초를 사용할 수 있습니다. 자바스크립트가 구동되는 대다수의 호스트 환경과 기기에서 마이크로초를 지원하고 있는데 Date 객체만 마이크로초를 지원하지 않습니다.

5.12 JSON과 메서드

복잡한 객체를 다루고 있다고 가정해 봅시다. 네트워크를 통해 객체를 어딘가에 보내거나 로깅 목적으로 객체를 출력해야 한다면 객체를 문자열로 전환해야 할겁니다.

이때 전환된 문자열엔 원하는 정보가 있는 객체 프로퍼티 모두가 포함되어야만 합니다.

아래와 같은 메서드를 구현해 객체를 문자열로 전환해봅시다.

let user = {
  name: "John",
  age: 30,

  *toString() {
    return `{name: "${this.name}", age: ${this.age}}`;
  }*};

alert(user); // {name: "John", age: 30}

그런데 개발 과정에서 프로퍼티가 추가되거나 삭제, 수정될 수 있습니다. 이렇게 되면 위에서 구현한 toString을 매번 수정해야 하는데 이는 아주 고통스러운 작업이 될 겁니다. 프로퍼티에 반복문을 돌리는 방법을 대안으로 사용할 수 있는데, 중첩 객체 등으로 인해 객체가 복잡한 경우 이를 문자열로 변경하는 건 까다로운 작업이라 이마저도 쉽지 않을 겁니다.

다행히 자바스크립트엔 이런 문제를 해결해주는 방법이 있습니다. 관련 기능이 이미 구현되어 있어서 우리가 직접 코드를 짤 필요가 없습니다.

JSON.stringify

JSON (JavaScript Object Notation)은 값이나 객체를 나타내주는 범용 포맷으로, RFC 4627 표준에 정의되어 있습니다. JSON은 본래 자바스크립트에서 사용할 목적으로 만들어진 포맷입니다. 그런데 라이브러리를 사용하면 자바스크립트가 아닌 언어에서도 JSON을 충분히 다룰 수 있어서, JSON을 데이터 교환 목적으로 사용하는 경우가 많습니다. 특히 클라이언트 측 언어가 자바스크립트일 때 말이죠. 서버 측 언어는 무엇이든 상관없습니다.

자바스크립트가 제공하는 JSON 관련 메서드는 아래와 같습니다.

  • JSON.stringify – 객체를 JSON으로 바꿔줍니다.
  • JSON.parse – JSON을 객체로 바꿔줍니다.

객체 student에 JSON.stringify를 적용해봅시다.

let student = {
  name: 'John',
  age: 30,
  isAdmin: false,
  courses: ['html', 'css', 'js'],
  wife: null
};

*let json = JSON.stringify(student);*

alert(typeof json); // 문자열이네요!

alert(json);
*/* JSON으로 인코딩된 객체:
{
  "name": "John",
  "age": 30,
  "isAdmin": false,
  "courses": ["html", "css", "js"],
  "wife": null
}
*/*

JSON.stringify(student)를 호출하자 student가 문자열로 바뀌었습니다.

이렇게 변경된 문자열은 JSON으로 인코딩된(JSON-encoded)직렬화 처리된(serialized)문자열로 변환된(stringified)결집된(marshalled) 객체라고 부릅니다. 객체는 이렇게 문자열로 변환된 후에야 비로소 네트워크를 통해 전송하거나 저장소에 저장할 수 있습니다.

JSON으로 인코딩된 객체는 일반 객체와 다른 특징을 보입니다.

  • 문자열은 큰따옴표로 감싸야 합니다. JSON에선 작은따옴표나 백틱을 사용할 수 없습니다('John'이 "John"으로 변경된 것을 통해 이를 확인할 수 있습니다).
  • 객체 프로퍼티 이름은 큰따옴표로 감싸야 합니다(age:30이 "age":30으로 변한 것을 통해 이를 확인할 수 있습니다).

JSON.stringify는 객체뿐만 아니라 원시값에도 적용할 수 있습니다.

적용할 수 있는 자료형은 아래와 같습니다.

  • 객체 { ... }
  • 배열 [ ... ]
  • 원시형:
    • 문자형
    • 숫자형
    • 불린형 값 true와 false
    • null
// 숫자를 JSON으로 인코딩하면 숫자입니다.
alert( JSON.stringify(1) ) // 1

// 문자열을 JSON으로 인코딩하면 문자열입니다(다만, 큰따옴표가 추가됩니다).
alert( JSON.stringify('test') ) // "test"

alert( JSON.stringify(true) ); // true

alert( JSON.stringify([1, 2, 3]) ); // [1,2,3]

JSON은 데이터 교환을 목적으로 만들어진 언어에 종속되지 않는 포맷입니다. 따라서 자바스크립트 특유의 객체 프로퍼티는 JSON.stringify가 처리할 수 없습니다.

JSON.stringify 호출 시 무시되는 프로퍼티는 아래와 같습니다.

  • 함수 프로퍼티 (메서드)
  • 심볼형 프로퍼티 (키가 심볼인 프로퍼티)
  • 값이 undefined인 프로퍼티
let user = {
  sayHi() { // 무시
    alert("Hello");
  },
  [Symbol("id")]: 123, // 무시
  something: undefined // 무시
};

alert( JSON.stringify(user) ); // {} (빈 객체가 출력됨)

대개 이 프로퍼티들은 무시 되어도 괜찮습니다. 그런데 이들도 문자열에 포함시켜야 하는 경우가 생기곤 하는데 이에 대해선 아래에서 다루도록 하겠습니다.

JSON.stringify의 장점 중 하나는 중첩 객체도 알아서 문자열로 바꿔준다는 점입니다.

let meetup = {
  title: "Conference",
  *room: {
    number: 23,
    participants: ["john", "ann"]
  }*};

alert( JSON.stringify(meetup) );
/* 객체 전체가 문자열로 변환되었습니다.
{
  "title":"Conference",
  "room":{"number":23,"participants":["john","ann"]},
}
*/

JSON.stringify를 사용할 때 주의하셔야 할 점이 하나 있습니다. 순환 참조가 있으면 원하는 대로 객체를 문자열로 바꾸는 게 불가능합니다.

let room = {
  number: 23
};

let meetup = {
  title: "Conference",
  participants: ["john", "ann"]
};

meetup.place = room;       // meetup은 room을 참조합니다.
room.occupiedBy = meetup; // room은 meetup을 참조합니다.

*JSON.stringify(meetup); // Error: Converting circular structure to JSON*

room.occupiedBy는 meetup을, meetup.place는 room을 참조하기 때문에 JSON으로의 변환이 실패했습니다.

replacer로 원하는 프로퍼티만 직렬화하기

JSON.stringify의 전체 문법은 아래와 같습니다.

let json = JSON.stringify(value[, replacer, space])

value

인코딩 하려는 값

replacer

JSON으로 인코딩 하길 원하는 프로퍼티가 담긴 배열. 또는 매핑 함수 function(key, value)

space

서식 변경 목적으로 사용할 공백 문자 수

대다수의 경우 JSON.stringify엔 인수를 하나만 넘겨서 사용합니다. 그런데 순환 참조를 다뤄야 하는 경우같이 전환 프로세스를 정교하게 조정하려면 두 번째 인수를 사용해야 합니다.

JSON으로 변환하길 원하는 프로퍼티가 담긴 배열을 두 번째 인수로 넘겨주면 이 프로퍼티들만 인코딩할 수 있습니다.

let room = {
  number: 23
};

let meetup = {
  title: "Conference",
  participants: [{name: "John"}, {name: "Alice"}],
  place: room // meetup은 room을 참조합니다.
};

room.occupiedBy = meetup; // room references meetup

alert( JSON.stringify(meetup, *['title', 'participants']*) );
// {"title":"Conference","participants":[{},{}]}

배열에 넣어준 프로퍼티가 잘 출력된 것을 확인할 수 있습니다. 그런데 배열에 name을 넣지 않아서 출력된 문자열의 participants가 텅 비어버렸네요. 규칙이 너무 까다로워서 발생한 문제입니다.

순환 참조를 발생시키는 프로퍼티 room.occupiedBy만 제외하고 모든 프로퍼티를 배열에 넣어봅시다.

let room = {
  number: 23
};

let meetup = {
  title: "Conference",
  participants: [{name: "John"}, {name: "Alice"}],
  place: room // meetup references room
};

room.occupiedBy = meetup; // room references meetup

alert( JSON.stringify(meetup, *['title', 'participants', 'place', 'name', 'number']*) );
/*
{
  "title":"Conference",
  "participants":[{"name":"John"},{"name":"Alice"}],
  "place":{"number":23}
}
*/

occupiedBy를 제외한 모든 프로퍼티가 직렬화되었습니다. 그런데 배열이 좀 길다는 느낌이 듭니다.

replacer 자리에 배열 대신 함수를 전달해 이 문제를 해결해 봅시다(매개변수 replacer는 '대신하다’라는 뜻을 가진 영단어 replace에서 그 이름이 왔습니다 – 옮긴이).

replacer에 전달되는 함수(replacer 함수)는 프로퍼티 (키, 값) 쌍 전체를 대상으로 호출되는데, 반드시 기존 프로퍼티 값을 대신하여 사용할 값을 반환해야 합니다. 특정 프로퍼티를 직렬화에서 누락시키려면 반환 값을 undefined로 만들면 됩니다.

아래 예시는 occupiedBy를 제외한 모든 프로퍼티의 값을 변경 없이 “그대로” 직렬화하고 있습니다. occupiedBy는 undefined를 반환하게 해 직렬화에 포함하지 않은 것도 확인해 보시길 바랍니다.

let room = {
  number: 23
};

let meetup = {
  title: "Conference",
  participants: [{name: "John"}, {name: "Alice"}],
  place: room // meetup은 room을 참조합니다
};

room.occupiedBy = meetup; // room은 meetup을 참조합니다

alert( JSON.stringify(meetup, function replacer(key, value) {
  alert(`${key}: ${value}`);
  return (key == 'occupiedBy') ? undefined : value;
}));

/* replacer 함수에서 처리하는 키:값 쌍 목록
:             [object Object]
title:        Conference
participants: [object Object],[object Object]
0:            [object Object]
name:         John
1:            [object Object]
name:         Alice
place:        [object Object]
number:       23
*/

replacer 함수가 중첩 객체와 배열의 요소까지 포함한 모든 키-값 쌍을 처리하고 있다는 점에 주목해주시기 바랍니다. replacer 함수는 재귀적으로 키-값 쌍을 처리하는데, 함수 내에서 this는 현재 처리하고 있는 프로퍼티가 위치한 객체를 가리킵니다.

첫 얼럿창에 예상치 못한 문자열(":[object Object]")이 뜨는걸 볼 수 있는데, 이는 함수가 최초로 호출될 때 {"": meetup} 형태의 "래퍼 객체"가 만들어지기 때문입니다. replacer함수가 가장 처음으로 처리해야하는 (key, value) 쌍에서 키는 빈 문자열, 값은 변환하고자 하는 객체(meetup) 전체가 되는 것이죠.

이렇게 replacer 함수를 사용하면 중첩 객체 등을 포함한 객체 전체에서 원하는 프로퍼티만 선택해 직렬화 할 수 있습니다.

space로 가독성 높이기

JSON.stringify(value, replacer, space)의 세 번째 인수 space는 가독성을 높이기 위해 중간에 삽입해 줄 공백 문자 수를 나타냅니다.

지금까진 space 없이 메서드를 호출했기 때문에 인코딩된 JSON에 들여쓰기나 여분의 공백문자가 하나도 없었습니다. space는 가독성을 높이기 위한 용도로 만들어졌기 때문에 단순 전달 목적이라면 space 없이 직렬화하는 편입니다.

아래 예시처럼 space에 2를 넘겨주면 자바스크립트는 중첩 객체를 별도의 줄에 출력해주고 공백 문자 두 개를 써 들여쓰기해 줍니다.

let user = {
  name: "John",
  age: 25,
  roles: {
    isAdmin: false,
    isEditor: true
  }
};

alert(JSON.stringify(user, null, 2));
/* 공백 문자 두 개를 사용하여 들여쓰기함:
{
  "name": "John",
  "age": 25,
  "roles": {
    "isAdmin": false,
    "isEditor": true
  }
}
*/

/* JSON.stringify(user, null, 4)라면 아래와 같이 좀 더 들여써집니다.
{
    "name": "John",
    "age": 25,
    "roles": {
        "isAdmin": false,
        "isEditor": true
    }
}
*/

이처럼 매개변수 space는 로깅이나 가독성을 높이는 목적으로 사용됩니다.

커스텀 “toJSON”

toString을 사용해 객체를 문자형으로 변환시키는 것처럼, 객체에 toJSON이라는 메서드가 구현되어 있으면 객체를 JSON으로 바꿀 수 있을 겁니다. JSON.stringify는 이런 경우를 감지하고 toJSON을 자동으로 호출해줍니다.

let room = {
  number: 23
};

let meetup = {
  title: "Conference",
  date: new Date(Date.UTC(2017, 0, 1)),
  room
};

alert( JSON.stringify(meetup) );
/*
  {
    "title":"Conference",
    *"date":"2017-01-01T00:00:00.000Z",  // (1)*
    "room": {"number":23}               // (2)
  }
*/

Date 객체의 내장 메서드 toJSON이 호출되면서 date의 값이 문자열로 변환된 걸 확인할 수 있습니다((1)).

이번엔 room에 직접 커스텀 메서드 toJSON을 추가해 봅시다. 그리고 (2)로 표시한 줄이 어떻게 변경되는지 확인해 봅시다.

let room = {
  number: 23,
  *toJSON() {
    return this.number;
  }*};

let meetup = {
  title: "Conference",
  room
};

*alert( JSON.stringify(room) ); // 23*

alert( JSON.stringify(meetup) );
/*
  {
    "title":"Conference",
    *"room": 23*
  }
*/

위와 같이 toJSON은 JSON.stringify(room)를 직접 호출할 때도 사용할 수 있고, room과 같은 중첩객체에도 구현하여 사용할 수 있습니다.

JSON.parse

JSON.parse를 사용하면 JSON으로 인코딩된 객체를 다시 객체로 디코딩 할 수 있습니다.

문법:

let value = JSON.parse(str, [reviver]);

str

JSON 형식의 문자열

reviver

모든 (key, value) 쌍을 대상으로 호출되는 function(key,value) 형태의 함수로 값을 변경시킬 수 있습니다.

// 문자열로 변환된 배열
let numbers = "[0, 1, 2, 3]";

numbers = JSON.parse(numbers);

alert( numbers[1] ); // 1

JSON.parse는 아래와 같이 중첩 객체에도 사용할 수 있습니다.

let userData = '{ "name": "John", "age": 35, "isAdmin": false, "friends": [0,1,2,3] }';

let user = JSON.parse(userData);

alert( user.friends[1] ); // 1

중첩 객체나 중쳡 배열이 있다면 JSON도 복잡해지기 마련인데, 그렇더라도 결국엔 JSON 포맷 지켜야 합니다.

아래에서 디버깅 등의 목적으로 직접 JSON을 만들 때 흔히 저지르는 실수 몇 개를 간추려보았습니다. 참고하시어 이와 같은 실수를 저지르지 않으시길 바랍니다.

let json = `{
  name: "John",                     // 실수 1: 프로퍼티 이름을 큰따옴표로 감싸지 않았습니다.
  "surname": 'Smith',               // 실수 2: 프로퍼티 값은 큰따옴표로 감싸야 하는데, 작은따옴표로 감쌌습니다.
  'isAdmin': false                  // 실수 3: 프로퍼티 키는 큰따옴표로 감싸야 하는데, 작은따옴표로 감쌌습니다.
  "birthday": new Date(2000, 2, 3), // 실수 4: "new"를 사용할 수 없습니다. 순수한 값(bare value)만 사용할 수 있습니다.
  "friends": [0,1,2,3]              // 이 프로퍼티는 괜찮습니다.
}`;

JSON은 주석을 지원하지 않는다는 점도 기억해 놓으시기 바랍니다. 주석을 추가하면 유효하지 않은 형식이 됩니다.

키를 큰따옴표로 감싸지 않아도 되고 주석도 지원해주는 JSON5라는 포맷도 있는데, 이 포맷은 자바스크립트 명세서에서 정의하지 않은 독자적인 라이브러리입니다.

JSON 포맷이 까다로운 규칙을 가지게 된 이유는 개발자의 귀차니즘 때문이 아니고, 쉽고 빠르며 신뢰할 수 있을 만한 파싱 알고리즘을 구현하기 위해서입니다.

reviver 사용하기

서버로부터 문자열로 변환된 meetup 객체를 전송받았다고 가정해봅시다.

전송받은 문자열은 아마 아래와 같이생겼을겁니다.

// title: (meetup 제목), date: (meetup 일시)
let str = '{"title":"Conference","date":"2017-11-30T12:00:00.000Z"}';

이제 이 문자열을 역 직렬화(deserialize) 해서 자바스크립트 객체를 만들어봅시다.

JSON.parse를 호출해보죠.

let str = '{"title":"Conference","date":"2017-11-30T12:00:00.000Z"}';

let meetup = JSON.parse(str);

*alert( meetup.date.getDate() ); // 에러!*

엇! 에러가 발생하네요!

meetup.date의 값은 Date 객체가 아니고 문자열이기 때문에 발생한 에러입니다. 그렇다면 문자열을 Date로 전환해줘야 한다는 걸 어떻게 JSON.parse에게 알릴 수 있을까요?

이럴 때 JSON.parse의 두 번째 인수 reviver를 사용하면 됩니다. 모든 값은 “그대로”, 하지만 date만큼은 Date 객체를 반환하도록 함수를 구현해 봅시다.

let str = '{"title":"Conference","date":"2017-11-30T12:00:00.000Z"}';

*let meetup = JSON.parse(str, function(key, value) {
  if (key == 'date') return new Date(value);
  return value;
});*

alert( meetup.date.getDate() ); // 이제 제대로 동작하네요!

참고로 이 방식은 중첩 객체에도 적용할 수 있습니다.

let schedule = `{
  "meetups": [
    {"title":"Conference","date":"2017-11-30T12:00:00.000Z"},
    {"title":"Birthday","date":"2017-04-18T12:00:00.000Z"}
  ]
}`;

schedule = JSON.parse(schedule, function(key, value) {
  if (key == 'date') return new Date(value);
  return value;
});

*alert( schedule.meetups[1].date.getDate() ); // 잘 동작합니다!*

요약

  • JSON은 독자적인 표준을 가진 데이터 형식으로, 대부분의 언어엔 JSON을 쉽게 다룰 수 있게 해주는 라이브러리가 있습니다.
  • JSON은 일반 객체, 배열, 문자열, 숫자, 불린값, null을 지원합니다.
  • JSON.stringify를 사용하면 원하는 값을 JSON으로 직렬화 할 수 있고, JSON.parse를 사용하면 JSON을 본래 값으로 역 직렬화 할 수 있습니다.
  • 위 두 메서드에 함수를 인수로 넘겨주면 원하는 값만 읽거나 쓰는 게 가능합니다.
  • JSON.stringify는 객체에 toJSON 메서드가 있으면 이를 자동으로 호출해줍니다.