Choose a default theme

You can change your theme from the menu

JavaScript

JavaScript는 웹 브라우저와 서버 등에서 사용되는 프로그래밍 언어다. 이 문서는 값과 타입 변환, 객체와 클로저, 비동기 실행, 화면과 데이터 경계를 예제로 설명하고, 고객별 주문 합계 기능을 구현하고 검증하는 과정까지 다룬다.

프로그래밍 언어

언어

Python · Java · JavaScript · TypeScript · Rust

언어와 실행 환경: 브라우저 기능은 어디에서 오는가

JavaScript는 웹 페이지의 동작뿐 아니라 서버와 여러 실행 환경에서 사용하는 범용 프로그래밍 언어다. ECMAScript는 핵심 언어 규칙을 정의하고, 브라우저나 Node.js 같은 호스트는 파일·네트워크·화면 조작 등의 기능을 제공한다. 배열, 함수, Promise 같은 언어 요소와 document 같은 브라우저 API를 구별하면 코드를 다른 환경으로 옮길 때 무엇이 달라지는지 이해하기 쉽다.

예를 들어 배열의 map으로 가격을 계산하는 함수는 브라우저와 Node.js에서 함께 사용할 수 있다. 반면 화면의 버튼을 찾는 코드는 DOM이 있는 환경을 필요로 한다. 서버에서 document가 없다는 오류가 나면 언어 문법이 잘못되었다기보다 실행 환경이 다를 가능성이 있다. 패키지가 브라우저 전용인지 서버 전용인지, 어떤 모듈 형식을 사용하는지도 확인한다.

이 문서의 출력 예제는 특별한 안내가 없으면 외부 패키지와 네트워크 없이 실행할 수 있다. 각 코드 블록은 독립적인 예제이며 같은 콘솔에서 const 선언을 반복하면 이름 재선언 오류가 날 수 있으므로 새 실행 문맥에서 실행한다. 비동기 예제는 작업이 끝날 때까지 결과를 관찰한다. Java와 이름이 비슷하지만 별개의 언어이며 실행 모델을 동일하게 보지 않는다.

첫 예제: 계산 값과 표시 문자열

다음 코드는 원 단위 정수 가격 목록에 설명용 10%를 더하고 반올림한 결과를 합산한다. map은 원소별 변환으로 새 배열을 만들고 reduce는 누적값을 계산한다. 출력은 1100,2750,4400과 8250이다. join은 배열을 쉼표로 연결한 표시 문자열을 만드는 기능이며 숫자 배열 자체를 바꾸지 않는다.

price => ...는 각 가격을 받아 계산 결과를 반환하는 화살표 함수다. reduce에는 초기값 0을 주었으므로 빈 배열에서도 합계 0이라는 의미를 표현할 수 있다. 초기값 없이 누적 연산을 사용하면 빈 입력에서 오류가 발생하는 등 계약이 달라진다.

이 예제는 계산 흐름을 설명하기 위한 것이며 실제 세금 규칙을 구현한 것은 아니다. 통화의 최소 단위, 품목별 반올림과 총액 반올림의 차이, 할인 순서, 큰 정수 범위는 실제 업무에서 명시해야 한다. 결과가 화면에 자연스럽게 보인다고 내부 수치 규칙까지 맞는 것은 아니다.

예제 코드

const prices = [1000, 2500, 4000];
const included = prices.map(price => Math.round(price * 1.1));
const total = included.reduce((sum, price) => sum + price, 0);
console.log(included.join(","));
console.log(total);

실행 결과

1100,2750,4400
8250

let과 const: 재대입과 객체 변경을 구분하기

let과 const는 블록 범위의 바인딩을 만든다. let은 재대입할 수 있고 const는 그 이름에 다른 값을 다시 대입할 수 없다. 그러나 const로 가리키는 객체 내부의 속성을 자동으로 불변으로 만드는 것은 아니다. 이 차이를 모르면 const를 사용했으니 상태가 바뀌지 않는다는 잘못된 가정을 하게 된다.

아래 state.count를 1로 바꾸는 것은 허용된다. 그다음 state에 새 객체를 대입하려 하면 TypeError가 발생한다. 출력은 1과 TypeError다. 객체를 바꾸는 권한과 이름을 다른 객체로 연결하는 권한을 나누어 생각해야 한다. 배열에 push하는 것도 같은 이유로 const 배열에서 가능하다.

Object.freeze는 객체의 직접 속성 변경을 제한하는 데 쓰일 수 있지만 중첩 객체까지 자동으로 모두 동결하는 것은 아니다. 또한 언어 차원의 불변성 도구와 애플리케이션이 상태를 어떤 흐름으로 업데이트할지는 다른 문제다. 프레임워크가 새 객체를 기대하는 경우에는 직접 변경이 화면 갱신 규칙과 맞는지 확인한다.

예제 코드

const state = { count: 0 };
state.count = 1;
console.log(state.count);
try {
  state = { count: 2 };
} catch (error) {
  console.log(error.name);
}

실행 결과

1
TypeError

동적 타입과 명시적 변환

JavaScript에서는 값에 따라 연산이 결정되며 자동 형 변환이 일어날 수 있다. 아래 "5" + 1은 문자열 연결로 "51"이 되고 Number("5") + 1은 숫자 계산으로 6이 된다. 같은 겉보기 숫자가 문자열인지 Number인지에 따라 결과가 달라진다. 폼 입력과 URL 매개변수는 문자열로 들어오는 경우가 많아 특히 주의해야 한다.

숫자 변환에 성공한 듯 보여도 업무 입력으로 유효한지 다시 검사해야 한다. Number("")가 0이 되는 규칙 때문에 빈 입력을 0개 주문으로 받아들이는 실수가 생길 수 있다. 아래 Number.isFinite(Number("many"))는 false다. 문자열 형식, 정수 여부, 최소·최대값을 단계별로 검증하면 변환과 업무 판단을 구분할 수 있다.

자동 변환 규칙을 모두 없앨 수는 없지만 입력 경계에서 기대 타입을 명확히 하고 내부 함수는 정해진 타입의 값을 받도록 구성하면 추적하기 쉽다. 함수가 숫자 문자열도 받고 숫자도 받으면서 빈 문자열과 null을 제각각 처리하면 작은 수정이 다른 호출자의 의미를 바꿀 수 있다. 유연한 API일수록 허용 입력을 분명히 기록해야 한다.

예제 코드

console.log("5" + 1);
console.log(Number("5") + 1);
console.log(Number("") === 0);
console.log(Number.isFinite(Number("many")));

실행 결과

51
6
true
false

동등 비교와 객체 동일성

===는 일반적인 ==와 달리 타입을 맞추는 자동 변환을 수행하지 않는다. 아래 0 == "0"은 true지만 0 === "0"은 false다. 값의 타입이 중요하다면 엄격한 비교를 사용하고, 변환이 필요하면 비교 전에 명시적으로 수행하는 편이 의도를 읽기 쉽다.

객체 비교는 내용의 깊은 비교가 아니다. 서로 따로 만든 {x: 1} 두 객체를 ===로 비교하면 false다. 같은 필드와 값을 가졌더라도 서로 다른 객체이기 때문이다. 반대로 같은 객체를 두 변수가 가리키면 한쪽의 변경을 다른 쪽에서도 볼 수 있다. 값 기반 비교가 필요하면 어떤 필드와 구조가 동등성을 결정하는지 따로 정의해야 한다.

NaN은 자신과도 === 비교에서 같지 않다. Number.isNaN 같은 적절한 판별을 사용한다. JSON 문자열화 결과를 무조건 객체 동등성의 기준으로 사용하는 것도 위험하다. 표현할 수 없는 값, 속성 순서와 타입 차이가 있을 수 있기 때문이다. 비교 방법을 정할 때 “같은 객체”, “같은 표시”, “같은 업무 값” 중 무엇을 뜻하는지 구분한다.

예제 코드

console.log(0 == "0");
console.log(0 === "0");
console.log({ x: 1 } === { x: 1 });
console.log(Number.isNaN(NaN));

실행 결과

true
false
false
true

기본값: OR와 null 병합의 차이

논리 OR는 왼쪽이 falsy이면 오른쪽을 선택한다. 0, 빈 문자열, false도 falsy이므로 유효한 값일 때 이를 기본값으로 덮어쓸 수 있다. ??는 null이나 undefined인 경우에만 오른쪽 값을 사용한다. 아래 0 || 10은 10, 0 ?? 10은 0이다.

설정값 0이 “대기하지 않음”처럼 유효한 의미라면 OR로 기본값을 주는 코드가 뜻을 바꿀 수 있다. 반대로 빈 문자열도 기본 표시로 바꾸고 싶다면 ??만으로는 부족하다. 어떤 값이 “미설정”을 뜻하는지 먼저 정해야 한다. 구문을 최신 형태로 바꾸는 것이 모든 상황에서 정답은 아니다.

선택적 연결 ?.은 중간 객체가 null이나 undefined일 때 속성 접근을 멈추는 데 쓰인다. 그러나 필수 필드의 누락을 모두 ?.과 ??로 숨기면 잘못된 입력이 정상값처럼 처리될 수 있다. 선택적 자료와 반드시 있어야 하는 자료를 스키마에서 구분하고, 필수 조건 위반은 입력 경계에서 드러낸다.

예제 코드

console.log(0 || 10);
console.log(0 ?? 10);
const settings = { theme: null };
console.log(settings.theme ?? "default");
console.log(settings.user?.name ?? "guest");

실행 결과

10
0
default
guest

Number와 BigInt: 정수처럼 보여도 범위가 있다

일반 Number는 이진 부동소수점 표현을 사용한다. 소수 0.1과 0.2를 더한 값이 0.3과 정확히 같지 않을 수 있고, 매우 큰 정수는 인접한 값을 구별하지 못할 수 있다. 정수를 다루는 API는 Number.isSafeInteger로 의도한 범위인지 검사하는 방법을 고려한다.

아래 소수 비교는 false이고 MAX_SAFE_INTEGER는 안전한 정수이지만 그보다 1 큰 값의 검사는 false다. BigInt는 큰 정수를 정확하게 표현하는 별도 타입이며 n 접미사로 리터럴을 쓴다. 예제의 BigInt 덧셈은 문자열로 변환해 9007199254740994를 출력한다.

BigInt와 Number를 대부분의 산술 연산에서 암묵적으로 섞어 사용할 수 없으며, BigInt가 소수 계산을 제공하는 것도 아니다. 큰 식별자를 JSON의 Number로 받기 전에 이미 반올림되었다면 나중에 BigInt로 바꿔도 원래 값이 복원되지 않는다. 전송 형식과 파싱 단계부터 정확한 표현을 정해야 한다. 금액 계산도 통화 단위와 반올림 규칙을 명시하고 적합한 표현을 선택한다.

예제 코드

console.log(0.1 + 0.2 === 0.3);
console.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER));
console.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER + 1));
console.log((9007199254740993n + 1n).toString());

실행 결과

false
true
false
9007199254740994

객체 공유와 얕은 복사

객체를 다른 변수에 대입하면 같은 객체를 가리키게 된다. 전개 문법 {...original}은 바깥 객체의 속성을 새 객체에 복사하지만 중첩 객체를 모두 복제하지는 않는다. 아래 copied는 새 바깥 객체지만 nested는 original과 공유한다. copied.nested.count를 바꾸면 original에서도 2가 보인다.

반면 copied.name을 changed로 바꾸어도 original.name은 first로 남는다. 이름 문자열은 복사된 바깥 속성이고 nested는 내부 객체 참조라는 차이 때문이다. 출력은 first changed와 2다. 화면 상태나 설정을 복사한 뒤 예상하지 못한 곳이 함께 바뀌면 어느 깊이에서 객체를 공유하는지 확인한다.

structuredClone은 지원하는 데이터 구조를 깊게 복제할 수 있는 환경 API지만 모든 함수·DOM 객체·사용자 정의 동작을 그대로 복제하는 만능 기능은 아니다. JSON.parse(JSON.stringify(...))도 표현할 수 없는 타입과 값이 있어 일반적인 깊은 복사로 사용하기 어렵다. 필요한 부분만 새로 구성하거나 변경 책임을 한곳에 두는 설계를 먼저 고려한다.

예제 코드

const original = { name: "first", nested: { count: 1 } };
const copied = { ...original };
copied.name = "changed";
copied.nested.count = 2;
console.log(original.name, copied.name);
console.log(original.nested.count);

실행 결과

first changed
2

배열 메서드: 새 배열을 만든다는 말의 범위

map은 각 원소를 변환하고 filter는 조건에 맞는 원소를 고르며 reduce는 누적 결과를 만든다. 아래는 60점 이상을 고른 뒤 합계를 계산하여 67,91,60과 218을 출력한다. 원본 숫자 배열은 바뀌지 않는다. 처리 목적이 구분되어 있어 입력에서 결과로 가는 흐름을 읽기 쉽다.

새 배열을 반환한다는 말이 그 안의 객체까지 독립적이라는 뜻은 아니다. 객체 배열에서 filter로 고른 원소는 원본 객체를 그대로 참조할 수 있다. map의 콜백 안에서 원본 객체의 속성을 바꾸면 새 배열을 반환해도 부수 효과가 생긴다. 불변 업데이트가 필요하면 변경할 객체도 새로 만드는지 확인한다.

반복을 수행하기 위해 map을 호출하고 반환값을 버리는 코드보다 부수 효과가 목적이면 for...of 등으로 의도를 드러내는 편이 좋다. 여러 번의 map·filter는 큰 배열에서 중간 배열과 반복 비용을 만들 수 있다. 가독성과 실제 데이터 크기를 함께 보고 필요한 경우 한 번의 루프나 다른 처리 방식을 비교한다.

예제 코드

const scores = [42, 67, 91, 60];
const passed = scores.filter(score => score >= 60);
const total = passed.reduce((sum, score) => sum + score, 0);
console.log(passed.join(","));
console.log(total);

실행 결과

67,91,60
218

정렬: 기본 sort가 숫자 순서가 아닌 이유

Array.prototype.sort는 비교 함수를 주지 않으면 값을 문자열로 변환한 비교 방식으로 정렬한다. 숫자 배열 [2, 10, 1]의 기본 정렬 결과가 1,10,2인 이유다. 숫자의 오름차순은 (a, b) => a - b 같은 비교 함수를 사용한다. 아래 두 번째 출력은 1,2,10이다.

sort는 원본 배열을 바꾼다. 예제는 [...values]로 바깥 배열을 복사하고 정렬하여 원본이 2,10,1로 남도록 했다. 지원 환경에서는 toSorted처럼 원본을 바꾸지 않는 메서드도 검토할 수 있다. 객체 배열을 복사해 정렬해도 객체 자체가 복제되는 것은 아니라는 점은 여전히 같다.

비교 함수는 음수·0·양수의 의미를 일관되게 반환해야 한다. a > b 같은 불리언 반환은 필요한 음수 방향을 표현하지 못해 올바른 비교 계약이 되지 않을 수 있다. 동점의 순서를 업무적으로 정해야 하면 보조 키를 추가한다. 사용자 언어의 문자열 정렬은 단순 코드 단위 순서와 다를 수 있어 Intl.Collator 같은 기능을 검토한다.

예제 코드

const values = [2, 10, 1];
console.log([...values].sort().join(","));
console.log([...values].sort((a, b) => a - b).join(","));
console.log(values.join(","));

실행 결과

1,10,2
1,2,10
2,10,1

함수와 클로저: 호출이 끝난 뒤 남는 상태

함수는 값으로 전달하거나 반환할 수 있다. 클로저는 함수가 만들어진 어휘적 환경의 이름에 접근할 수 있게 한다. 아래 createCounter가 반환한 함수는 바깥 호출이 끝난 뒤에도 count를 사용한다. 카운터 a를 두 번 호출하면 1과 2, 별도로 만든 b를 한 번 호출하면 1이 나온다.

각 createCounter 호출에서 별도의 count 바인딩이 만들어지므로 두 카운터는 독립적이다. 반환된 함수들이 모두 하나의 외부 객체를 참조하도록 만들면 상태가 공유될 수도 있다. “클로저는 값을 복사해 저장한다”라고 이해하기보다 어떤 바인딩과 객체에 접근하는지 살핀다.

이벤트 처리기나 캐시 함수에 클로저를 사용하면 상태를 숨기고 기능을 묶기 편하지만, 오래 살아 있는 함수가 큰 객체를 계속 참조하면 메모리가 유지될 수 있다. 화면이 제거된 뒤에도 구독이나 타이머가 함수를 보관하는지 점검한다. 필요 없는 리스너와 작업을 해제하는 수명 관리가 중요하다.

예제 코드

function createCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}
const a = createCounter();
const b = createCounter();
console.log(a(), a(), b());

실행 결과

1 2 1

this와 화살표 함수: 호출 방식이 의미를 바꾼다

일반 함수의 this는 호출 방식에 영향을 받는다. 객체의 메서드로 호출할 때와 메서드 값을 변수로 꺼내 독립 함수로 호출할 때 같은 this가 유지된다고 가정하면 안 된다. 화살표 함수는 자체 this를 만들지 않고 바깥 어휘적 문맥의 this를 사용하므로 일반 함수의 모든 용도를 그대로 대체하지 않는다.

아래 counter.increment()는 메서드 호출이므로 this가 counter다. 메서드를 bind(counter)로 묶은 increase는 독립적으로 호출해도 같은 객체를 사용한다. 결과는 1, 2, 2다. 콜백에 메서드를 전달할 때 원하는 수신 객체를 유지하는 한 가지 방식이다.

객체 리터럴 안에 화살표 함수를 넣었다는 이유만으로 그 객체가 this가 되는 것은 아니다. 이벤트 처리나 클래스 메서드에서 this가 필요한지, 필요한 값만 인자로 넘기면 더 단순한지 검토한다. bind가 새 함수를 만든다는 점도 리스너 해제에 영향을 준다. 등록할 때와 해제할 때 서로 다른 bind 결과를 전달하면 같은 콜백으로 인식되지 않을 수 있다.

예제 코드

const counter = {
  value: 0,
  increment() { this.value += 1; return this.value; }
};
console.log(counter.increment());
const increase = counter.increment.bind(counter);
console.log(increase());
console.log(counter.value);

실행 결과

1
2
2

프로토타입과 클래스: 메서드는 어디에서 찾아지는가

JavaScript 객체는 속성을 찾을 때 자신의 속성뿐 아니라 프로토타입 체인을 따라 찾을 수 있다. class 문법은 익숙한 형태로 생성자와 메서드를 작성하게 하지만 기반의 프로토타입 동작을 없애는 별도 객체 체계는 아니다. 인스턴스의 상태와 공유 메서드가 어디에 있는지 이해하면 메모리와 변경 범위를 파악하기 쉽다.

클래스 생성자에서 this.items = []를 실행하면 인스턴스마다 새 배열을 만들 수 있다. 반면 공유 객체를 프로토타입이나 외부 변수에 두면 여러 인스턴스가 같은 상태를 볼 수 있다. 공유가 의도인지 실수인지 구분해야 한다. 구조를 보기 쉽게 만들겠다는 이유로 깊은 상속 계층을 만들면 변경 영향 범위가 커질 수 있으므로 구성 요소 조합도 비교한다.

외부 데이터의 키를 객체에 병합할 때는 원형 관련 특수 이름과 상속 속성의 영향을 고려한다. 데이터 사전 용도에는 Map이나 프로토타입 없는 객체를 검토할 수 있다. 객체에 어떤 속성이 있는지 검사할 때도 자신의 속성인지 상속된 속성까지 포함하는지 API 의미를 확인한다. 문법상 객체처럼 보이는 외부 JSON도 애플리케이션의 도메인 객체와 같은 보장을 갖지는 않는다.

Map과 Set: 키로 찾기와 중복 제거

배열은 순서가 있는 목록에 적합하지만, 고객 번호로 합계를 계속 찾는 작업을 배열의 find로 반복하면 입력이 커질수록 불필요한 탐색이 늘어난다. Map은 키와 값을 대응시키는 용도를 드러내고, Set은 값의 중복 여부를 표현한다. 도구를 선택할 때 API가 짧은지만 보지 말고 데이터가 목록인지 사전인지 집합인지 먼저 구분한다.

아래 예제는 고객 a의 두 주문을 합쳐 3500을 얻는다. get의 결과가 undefined이면 아직 누적 값이 없으므로 0에서 시작한다. Set은 같은 문자열 a를 하나로 취급해 a,b만 남긴다. 그러나 내용이 같은 객체 두 개는 별개의 참조이므로 둘 다 남는다. API에서 받은 고객 객체를 그대로 Set에 넣는 것은 고객 ID 중복 제거와 같지 않다.

Map의 키도 객체라면 객체의 동일성을 사용한다. 서버에서 다시 읽은 동일 ID 객체가 이전 객체와 같은 키가 된다고 가정하지 않는다. ID 문자열을 키로 삼고, ID의 대소문자나 앞뒤 공백을 어떻게 처리할지는 입력 규칙으로 정한다. Map을 JSON으로 저장할 때도 기본 직렬화가 항목을 알아서 보존한다고 가정하지 말고 배열이나 명시적 객체 형식으로 변환해야 한다.

예제 코드

const totals = new Map();
for (const order of [{id: 'a', amount: 1000}, {id: 'a', amount: 2500}]) {
  totals.set(order.id, (totals.get(order.id) ?? 0) + order.amount);
}
console.log(totals.get('a'));
console.log([...new Set(['a', 'b', 'a'])].join(','));
console.log(new Set([{id: 1}, {id: 1}]).size);

실행 결과

3500
a,b
2

문자열과 유니코드: length가 화면의 글자 수는 아니다

JavaScript 문자열의 length는 UTF-16 코드 단위 수다. 모든 문자가 코드 단위 하나로 표현되는 것은 아니므로 화면에 보이는 글자 수와 다를 수 있다. 이모지 하나가 두 코드 단위를 차지할 수 있고, 여러 코드 포인트가 결합해 사용자에게 한 글자로 보이기도 한다.

아래 A😀는 length가 3이지만 문자열 이터레이터를 펼친 배열은 두 코드 포인트를 순회하므로 길이가 2다. 이것도 모든 상황에서 사용자가 보는 글자 수를 세는 해법은 아니다. 결합 문자나 여러 이모지가 연결된 시퀀스를 다룰 때는 글자 경계에 맞는 분할이 필요하다. 입력 길이 제한, 커서 이동, 자르기 기능은 무엇을 하나로 세는지 결정해야 한다.

같은 모양의 문자열이 서로 다른 코드 포인트 조합으로 들어오는 경우도 있다. 검색이나 ID 비교에서 정규화가 필요한지 검토하되, 비밀번호나 서명 대상 바이트를 임의로 바꾸는 것은 별개의 문제다. 문자열은 불변 값이므로 replace나 slice를 호출해도 원래 문자열 변수가 자동으로 바뀌지 않는다. 반환 값을 사용해야 한다.

예제 코드

const text = 'A😀';
console.log(text.length);
console.log([...text].length);
const name = '  Ada  ';
console.log(name.trim());
console.log(name.length);

실행 결과

3
2
Ada
7

예외와 입력 검증: 실패를 정상 값으로 숨기지 않기

함수의 입력 조건을 정하고 잘못된 입력을 초기에 거절하면 뒤에서 생기는 원인 불명의 NaN이나 잘못된 합계를 줄일 수 있다. 아래 수량 함수는 정수이며 1 이상이라는 조건을 검사한다. 문자열 "3"은 자동 변환하지 않는다. 화면 입력을 숫자로 변환하는 계층과 도메인 조건을 검사하는 계층을 분리하려는 선택이다.

try/catch는 실패할 수 있는 작업을 감싸고 실패를 처리할 수 있는 위치에서 사용한다. 모든 예외를 잡아 0이나 빈 배열을 반환하면 실제 0건과 서버 오류가 구분되지 않는다. 예제는 오류 종류와 메시지를 표시하지만 운영 서비스에서는 사용자용 설명과 내부 진단 정보를 구분한다.

finally는 성공과 실패 양쪽에서 필요한 정리에 쓰인다. 자원 해제처럼 반드시 수행해야 하는 일과, 성공했을 때만 해야 하는 후속 처리를 같은 블록에 섞지 않는다. finally에서 return을 하면 앞서 발생한 반환이나 예외를 가릴 수 있으므로 제어 흐름을 단순하게 유지하는 편이 좋다.

예제 코드

function quantity(value) {
  if (!Number.isInteger(value) || value < 1) {
    throw new RangeError('quantity must be a positive integer');
  }
  return value;
}
console.log(quantity(3));
try { quantity('3'); }
catch (error) { console.log(error.name); }

실행 결과

3
RangeError

JSON과 데이터 경계: 파싱 성공은 검증 완료가 아니다

JSON.parse는 JSON 문법을 해석한다. 파싱 결과가 애플리케이션에서 기대하는 고객이나 주문이라는 보장은 하지 않는다. 숫자를 기대한 필드에 문자열이나 null이 들어올 수 있고 필드 자체가 없을 수도 있다. 외부 입력은 파싱 다음에 형태와 범위를 검증한다.

아래 예제는 count가 숫자인 JSON과 문자열인 JSON을 각각 읽는다. 둘 다 파싱에는 성공하지만 첫 번째만 정수 검사에 통과한다. HTTP 응답의 Content-Type이 JSON이라고 해도 데이터 규칙은 별도로 확인해야 한다. 프런트엔드에서 검증한 값이라고 해서 서버가 검증을 생략할 수 있는 것도 아니다. 클라이언트의 코드는 호출자가 우회할 수 있다.

JSON은 JavaScript 객체 전체를 그대로 저장하는 형식이 아니다. 함수, undefined, 순환 참조, BigInt 같은 값은 일반 JSON 직렬화에서 제약이 있다. 날짜가 문자열로 바뀌는 경우에도 읽을 때 시간대와 의미를 복원하는 규칙이 필요하다. 깊은 복사를 JSON 왕복으로 간단히 해결하려다 데이터 의미를 잃지 않도록 값의 종류를 먼저 확인한다.

예제 코드

for (const raw of ['{"count":3}', '{"count":"3"}']) {
  const value = JSON.parse(raw);
  console.log(Number.isInteger(value.count));
}
try { JSON.parse('{broken}'); }
catch (error) { console.log(error.name); }

실행 결과

true
false
SyntaxError

이벤트 루프와 Promise: 코드 순서와 실행 순서

JavaScript의 동기 코드는 현재 실행 흐름에서 차례로 처리된다. Promise의 then에 등록한 반응은 이미 이행된 Promise라도 현재 동기 코드 사이에 즉시 끼어들지 않는다. 아래 코드는 A, C, B 순서로 출력된다. 등록 순서만 보고 A, B, C를 기대하면 비동기 상태를 읽는 시점을 잘못 판단할 수 있다.

Promise는 미래에 완료될 결과를 다루는 객체다. Promise를 만든다고 작업이 자동으로 별도 CPU 스레드에서 실행되는 것은 아니다. 무거운 반복문을 Promise 안에 넣어도 같은 실행 스레드를 오래 점유할 수 있다. 브라우저 화면이 멈추는 계산은 작업 분할이나 Worker 같은 실행 구조를 따로 검토한다.

브라우저 이벤트 루프와 Node.js의 호스트 동작에는 차이가 있다. 여기서는 동기 코드 뒤에 Promise 반응이 실행된다는 작은 예제를 확인한다. 타이머, 파일 I/O, 렌더링을 모두 포함한 순서를 이 예제 하나에서 일반화하지 않는다. 타이머의 지연 시간 역시 정확한 실행 시각을 보장하는 예약 시간표로 해석하면 안 된다.

예제 코드

console.log('A');
Promise.resolve().then(() => console.log('B'));
console.log('C');

실행 결과

A
C
B

async와 await: 반환 값과 실패 경로를 함께 읽기

async 함수는 Promise를 반환한다. 함수 안에서 평범한 값을 return하더라도 호출자는 비동기 결과를 받는다는 계약이 유지된다. await는 해당 async 함수의 진행을 결과가 정해질 때까지 이어서 처리하도록 구성한다. 프로그램 전체를 멈추거나 다른 모든 요청을 막는 명령은 아니다.

아래 loadName은 ID가 1이면 이름을 반환하고 다른 ID이면 예외를 던진다. 호출한 쪽은 await를 사용한 try/catch에서 실패를 처리한다. Promise를 기다리지 않고 호출만 한 뒤 동기 try/catch가 비동기 거부까지 잡을 것으로 기대하는 것은 흔한 실수다. 호출 결과를 반환하거나 await하거나 catch를 붙여 소유하는 경로가 있어야 한다.

await를 반복문 안에서 쓰면 각 작업을 순서대로 기다린다. 앞 결과가 다음 입력이라면 맞는 방식이다. 서로 독립적인 요청이라면 병렬 대기를 검토할 수 있지만 무제한 요청을 한꺼번에 시작하면 연결 수와 서버 부하가 문제가 된다. 의존 관계와 동시 실행 한도를 먼저 결정한다.

예제 코드

async function loadName(id) {
  if (id !== 1) throw new Error('user not found');
  return 'Ada';
}
console.log(await loadName(1));
try { await loadName(2); }
catch (error) { console.log(error.message); }

실행 결과

Ada
user not found

Promise.all: 함께 기다리는 것과 취소하는 것의 차이

Promise.all은 여러 결과를 모두 필요로 할 때 사용한다. 결과 배열은 완료 순서가 아니라 입력 순서에 대응한다. 아래에서는 second를 먼저 완료시키지만 결과는 first,second다. 이름과 가격을 각각 가져와 한 화면을 구성하는 상황처럼 둘 다 있어야 하는 작업에 맞는다.

입력 중 하나가 거부되면 합쳐진 Promise도 거부된다. 이것이 다른 작업을 자동으로 취소한다는 뜻은 아니다. 이미 시작한 네트워크 요청이나 저장 작업은 별도로 계속될 수 있다. 여러 쓰기 요청을 Promise.all로 감쌌다고 데이터베이스 트랜잭션처럼 전체 성공 또는 전체 취소가 보장되지 않는다.

일부 결과만 성공해도 표시할 수 있다면 개별 실패를 표현하거나 allSettled 같은 결과 수집 방법을 검토한다. 다만 실패를 빈 값으로 바꾸는 것과 실패 상태를 명시적으로 보여주는 것은 다르다. 재시도 역시 읽기 요청과 결제 같은 쓰기 요청의 의미가 다르므로 중복 실행의 결과를 고려해야 한다.

예제 코드

let finishFirst;
const first = new Promise(resolve => { finishFirst = resolve; });
const second = Promise.resolve('second');
const result = Promise.all([first, second]);
finishFirst('first');
console.log((await result).join(','));

실행 결과

first,second

응답 순서 역전: 오래된 검색 결과가 새 화면을 덮는 문제

사용자가 검색어를 빠르게 바꾸면 요청 순서와 완료 순서가 달라질 수 있다. 먼저 요청한 A가 느리게 끝나고 나중에 요청한 B가 먼저 끝나면 A의 결과가 B의 화면을 덮는 문제가 생긴다. 단순히 각 요청에 await를 붙이는 것으로 서로 다른 호출 사이의 순서 문제가 해결되지는 않는다.

아래 예제는 네트워크 대신 직접 완료시키는 Promise 두 개로 이 상황을 재현한다. 요청마다 증가하는 번호를 부여하고, 결과가 도착할 때 최신 번호와 같은 경우에만 화면 상태를 바꾼다. B를 먼저 완료하고 A를 나중에 완료해도 표시 값은 B다. 시간에 의존하는 타이머 없이 순서를 강제하므로 재현도 일정하다.

이 방식은 오래된 결과의 반영을 막지만 실제 요청 비용을 취소하지는 않는다. 요청 중단 기능을 병행할 수 있어도 중단 직전에 완료된 결과와 화면 상태의 관계는 여전히 검토해야 한다. 컴포넌트가 사라진 뒤 응답이 도착하는 경우, 로딩 표시와 오류 표시 역시 최신 요청에 속하는지 같은 기준으로 처리한다.

예제 코드

let latest = 0;
let displayed = '';
async function displayWhenReady(promise) {
  const request = ++latest;
  const value = await promise;
  if (request === latest) displayed = value;
}
let finishA, finishB;
const a = new Promise(resolve => { finishA = resolve; });
const b = new Promise(resolve => { finishB = resolve; });
const taskA = displayWhenReady(a);
const taskB = displayWhenReady(b);
finishB('B');
await taskB;
finishA('A');
await taskA;
console.log(displayed);

실행 결과

B

DOM과 이벤트: 화면 변화의 책임을 분리하기

브라우저에서 JavaScript는 DOM을 통해 문서 요소를 읽고 변경한다. DOM은 JavaScript 언어 자체의 객체 모음이 아니라 브라우저가 제공하는 문서 인터페이스다. 따라서 Node.js에서 document를 사용하면 별도 환경 없이는 같은 방식으로 동작하지 않는다. 언어 예제와 브라우저 화면 예제의 실행 위치를 구분해야 한다.

버튼을 누르면 숫자가 증가하는 화면이라면 상태를 바꾸는 부분과 그 상태를 화면에 표시하는 부분을 나눠 생각한다. 이벤트가 발생할 때마다 DOM의 문자열을 읽어 다시 숫자로 바꾸는 설계보다, 상태의 원본을 명확히 하고 화면을 그 결과로 갱신하는 설계가 복잡한 UI에서 추적하기 쉽다. 다만 상태를 여러 곳에 복제하면 서로 어긋나는 문제가 생기므로 소유 위치를 정한다.

사용자 입력을 단순한 문자열로 표시할 때는 textContent처럼 텍스트를 넣는 인터페이스를 사용한다. innerHTML은 문자열을 HTML로 해석하므로 외부 입력을 그대로 넣으면 마크업과 실행 가능한 콘텐츠가 섞이는 위험을 만든다. 풍부한 HTML을 허용하는 기능이라면 허용 정책과 검증된 정화 과정이 필요하다. 문자열 이스케이프 한 번이 모든 출력 문맥을 안전하게 만든다고 생각하지 않는다.

이벤트 리스너의 수명도 중요하다. 화면을 반복해서 열 때 리스너를 계속 추가하면 클릭 한 번에 여러 번 처리될 수 있다. 등록과 해제를 어디에서 수행하는지, 콜백 참조를 어떻게 유지하는지 정한다. 버튼 대신 클릭 가능한 div만 사용하는 방식은 키보드 접근성과 기본 동작을 추가로 구현해야 하므로 적절한 HTML 요소 선택부터 검토한다.

fetch와 HTTP: 응답을 받았다는 사실과 업무 성공

fetch를 사용하면 HTTP 요청을 보내고 Response를 받을 수 있다. 네트워크 전송 실패와 HTTP 오류 상태는 같은 경로로만 표현되지 않는다. 404나 500 응답도 Response가 도착할 수 있으므로 response.ok 또는 상태 코드를 확인한 뒤 본문을 해석한다. JSON 파싱 실패와 응답 데이터 검증 실패까지 각각 구분하면 오류 원인을 찾기 쉽다.

예를 들어 상품 목록 요청이 200을 반환해도 본문이 예상한 배열이 아닐 수 있다. 먼저 상태를 확인하고, JSON을 읽고, 필수 필드와 값의 범위를 검증한 다음 화면 상태를 갱신하는 흐름이 된다. 로그인 만료나 권한 부족도 빈 상품 목록과 다르게 표시해야 한다. 모든 catch에서 빈 배열을 반환하면 사용자에게 장애가 숨겨진다.

AbortController를 사용한 중단은 지원하는 작업에 signal을 전달하고 abort를 호출하는 방식이다. 사용자가 검색어를 바꾸거나 화면을 떠날 때 더 이상 필요 없는 요청을 중단하는 데 활용할 수 있다. 클라이언트가 기다림을 중단했다고 서버의 쓰기 작업까지 반드시 취소됐다는 뜻은 아니다. 서버가 이미 처리한 요청은 별도의 상태 확인이 필요할 수 있다.

재시도 정책에는 횟수와 간격뿐 아니라 중복 실행의 의미가 포함된다. 결제나 주문 생성 요청을 응답이 없었다는 이유만으로 반복하면 중복 처리 가능성이 있다. 서버의 멱등성 계약과 요청 식별자를 함께 설계한다. 이 절은 API 사용 원리를 설명하며 실제 외부 서버에 요청하는 코드를 실행하거나 모든 브라우저의 네트워크 동작을 시험한 것은 아니다.

모듈과 의존성: 파일 분할과 실행 환경의 계약

ES 모듈은 export와 import로 파일 사이의 기능을 명시적으로 연결한다. 전역 변수에 모든 함수를 넣는 방식보다 의존 관계를 추적하기 쉽지만, 파일을 나누기만 하면 설계가 좋아지는 것은 아니다. 모듈마다 책임과 공개 인터페이스를 작게 유지하고 내부 구현을 불필요하게 외부로 노출하지 않는다.

브라우저의 모듈 로딩과 Node.js의 모듈 해석, 번들러가 처리하는 경로는 완전히 같은 설정이 아니다. 패키지 이름으로 import하는 코드가 모든 브라우저에서 아무 설정 없이 실행되는 것은 아니다. Node.js에서도 파일 확장자와 패키지의 모듈 설정을 확인해야 한다. 이 문서의 실행 예제는 .mjs 파일로 저장해 ES 모듈 문맥에서 검사했다.

CommonJS와 ES 모듈을 섞는 프로젝트에서는 export 형태와 로딩 방식의 차이를 확인한다. 빌드 도구가 개발 중에는 처리해 주던 코드가 다른 실행 환경에서 실패할 수 있다. 라이브러리를 공개할 때는 소비자가 사용하는 환경과 진입점, 타입 선언, 지원 버전을 문서화한다. 브라우저 전용 전역을 모듈 최상위에서 즉시 읽으면 서버 렌더링 환경에서 import 자체가 실패할 수 있다.

외부 패키지는 구현 시간을 줄이지만 유지보수와 배포 책임을 함께 추가한다. 작은 기능 하나 때문에 큰 의존성 묶음을 가져오는 비용, 업데이트 호환성, 라이선스와 실제 사용하는 코드 범위를 검토한다. 잠금 파일은 의존성 해석의 재현성을 돕지만 모든 환경 차이와 공급망 문제를 자동으로 해결하는 장치는 아니다.

실전 예제: 취소 주문을 제외한 고객별 합계

지금까지의 자료형, 배열, Map, 입력 검증을 하나의 작은 기능으로 연결해 보자. 요구사항은 주문 배열을 받아 결제 완료 주문만 고객별로 합치는 것이다. 금액은 원 단위의 음이 아닌 안전한 정수로 제한하고, 상태는 paid와 cancelled만 받는다. 빈 고객 이름이나 알 수 없는 상태는 조용히 무시하지 않고 오류로 처리한다.

아래 입력에서 Ada의 결제 완료 주문은 1200과 800이므로 합계는 2000이다. 900짜리 취소 주문은 제외되며 Lin은 500이다. 결과 순서는 처음 결제 완료 주문이 등장한 고객 순서를 따른다. 정렬이 필요하다면 별도 규칙으로 추가한다. 고객 문자열의 앞뒤 공백은 유효성 검사에만 사용하며 실제 키는 원래 문자열이라는 점도 계약의 일부다. 운영에서는 화면 이름보다 안정적인 고객 ID를 키로 사용하는 편이 보통 더 적절하다.

입력 주문 각각의 금액이 안전한 정수여도 합계가 범위를 넘을 수 있으므로 누적 결과를 다시 검사한다. 이 검사를 생략하면 개별 입력 검증이 충분하다고 착각하기 쉽다. 소수 금액이나 환율 계산이 필요한 시스템은 이 정수 모델을 그대로 적용할 수 없고 통화별 단위와 반올림 정책을 정해야 한다.

함수는 입력 배열과 주문 객체를 변경하지 않고 새 결과를 반환한다. 네트워크와 화면 갱신도 포함하지 않는다. 이렇게 계산을 분리하면 브라우저 없이 테스트할 수 있고, 실패가 데이터 문제인지 화면 문제인지 구분하기 쉽다. 다만 외부 입력이 임의의 getter나 Proxy를 포함하는 적대적인 JavaScript 객체인 상황까지 방어하도록 설계한 함수는 아니다. JSON에서 읽은 일반 데이터 같은 입력 경계를 전제로 한다.

예제 코드

function summarizeOrders(orders) {
  if (!Array.isArray(orders)) throw new TypeError('orders must be an array');
  const totals = new Map();
  for (const order of orders) {
    if (order === null || typeof order !== 'object' ||
        typeof order.customer !== 'string' || order.customer.trim() === '' ||
        !Number.isSafeInteger(order.amount) || order.amount < 0 ||
        !['paid', 'cancelled'].includes(order.status)) {
      throw new TypeError('invalid order');
    }
    if (order.status === 'cancelled') continue;
    const total = (totals.get(order.customer) ?? 0) + order.amount;
    if (!Number.isSafeInteger(total)) throw new RangeError('total is too large');
    totals.set(order.customer, total);
  }
  return [...totals].map(([customer, amount]) => ({customer, amount}));
}
const orders = [
  {customer: 'Ada', amount: 1200, status: 'paid'},
  {customer: 'Ada', amount: 900, status: 'cancelled'},
  {customer: 'Lin', amount: 500, status: 'paid'},
  {customer: 'Ada', amount: 800, status: 'paid'}
];
console.log(JSON.stringify(summarizeOrders(orders)));

실행 결과

[{"customer":"Ada","amount":2000},{"customer":"Lin","amount":500}]

테스트: 정상 결과보다 경계 조건을 먼저 정하기

위 함수를 테스트할 때 합계가 맞는 한 사례만 확인하면 입력 계약의 상당 부분을 놓친다. 빈 배열, 취소 주문, 잘못된 자료형, 음수 금액, 알 수 없는 상태, 합계 범위 초과를 각각 다룬다. 아래 검사는 앞 절의 summarizeOrders 함수 뒤에 붙여 같은 .mjs 파일에서 실행한다. node:assert/strict는 Node.js 내장 모듈이며 브라우저에 그대로 붙여 넣는 코드는 아니다.

deepEqual은 결과 구조와 값을 비교하고 throws는 특정 종류의 실패가 발생하는지 확인한다. 구현이 예외를 없애거나 문자열 금액을 받아들이도록 바뀌면 계약 변경을 테스트가 드러낸다. 입력을 바꾸지 않는다는 약속도 호출 전후 직렬화 결과로 이 예제 데이터에 대해 확인한다. 이것은 모든 객체 구조의 불변성을 증명하는 범용 검사는 아니다.

테스트 이름과 데이터는 업무 규칙을 설명해야 한다. 구현 내부 변수 이름이나 반복문의 형태를 그대로 검사하면 리팩터링할 때 불필요하게 깨진다. 반면 외부에서 관찰되는 결과와 실패 조건을 검사하면 구현을 바꾸어도 같은 기능을 유지하는지 판단하기 쉽다.

비동기 테스트에서는 Promise를 반환하거나 await해서 테스트가 실제 완료를 기다리도록 한다. 실패할 것으로 예상한 요청을 시작만 하고 테스트를 끝내면 거부가 나중에 발생해 잘못 통과할 수 있다. 화면 테스트와 실제 API 통합 테스트는 순수 함수 테스트와 별도로 필요하며, 이 문서의 실행 검증은 언어 예제와 계산 함수 범위에 한정된다.

예제 코드

import assert from 'node:assert/strict';
assert.deepEqual(summarizeOrders([]), []);
assert.deepEqual(summarizeOrders([
  {customer: 'A', amount: 2, status: 'paid'},
  {customer: 'A', amount: 3, status: 'paid'},
  {customer: 'A', amount: 9, status: 'cancelled'}
]), [{customer: 'A', amount: 5}]);
assert.throws(() => summarizeOrders(null), TypeError);
assert.throws(() => summarizeOrders([
  {customer: 'A', amount: '3', status: 'paid'}
]), TypeError);
assert.throws(() => summarizeOrders([
  {customer: 'A', amount: -1, status: 'paid'}
]), TypeError);
assert.throws(() => summarizeOrders([
  {customer: 'A', amount: 3, status: 'pending'}
]), TypeError);
assert.throws(() => summarizeOrders([
  {customer: 'A', amount: Number.MAX_SAFE_INTEGER, status: 'paid'},
  {customer: 'A', amount: 1, status: 'paid'}
]), RangeError);
const input = [{customer: 'A', amount: 0, status: 'paid'}];
const before = JSON.stringify(input);
summarizeOrders(input);
assert.equal(JSON.stringify(input), before);
console.log('8 checks passed');

실행 결과

8 checks passed

성능과 메모리: 측정할 대상을 먼저 정하기

JavaScript가 느리다는 말만으로 개선 방향을 정할 수는 없다. 네트워크 대기, 큰 JSON 파싱, 반복 렌더링, 긴 계산, 불필요한 객체 생성은 서로 다른 문제다. 사용자가 느끼는 응답 지연과 CPU 사용량, 메모리 증가 중 무엇이 문제인지 관찰한 뒤 해당 경로를 측정한다.

배열 메서드를 여러 번 연결하면 중간 배열이 생길 수 있다. 작은 목록에서는 명확한 코드가 더 유리할 수 있고, 큰 자료를 반복 처리하는 경로에서는 한 번의 반복으로 합치는 방식이 도움이 될 수 있다. 그러나 실제 병목이 서버 응답이라면 map을 for로 바꾼다고 전체 지연이 크게 줄지 않는다. 대표 입력과 반복 횟수, 워밍업, 실행 엔진을 명시하지 않은 미세 벤치마크를 일반 성능 순위로 쓰지 않는다.

가비지 컬렉션이 있다고 메모리 누수가 불가능한 것은 아니다. 더 이상 필요 없는 객체를 전역 배열, 캐시, 이벤트 리스너나 클로저가 계속 참조하면 수거되지 않을 수 있다. 무제한 캐시에 상한과 만료 정책을 두고, 화면이 사라질 때 구독과 리스너를 정리하는 이유다. 필요 없는 데이터를 참조하지 않게 만드는 설계가 임의로 수거 시점을 기대하는 것보다 중요하다.

긴 동기 작업은 이벤트 처리를 늦추고 브라우저 렌더링 기회를 줄일 수 있다. 작업을 나누거나 Worker로 옮길 때는 데이터 전달 비용과 동기화 방식도 계산한다. 모든 작은 계산을 Worker로 보내면 오히려 복잡성과 전달 비용이 커질 수 있다. 알고리즘의 복잡도와 데이터 크기, 실행 위치를 함께 판단한다.

TypeScript·프레임워크와의 관계

TypeScript는 JavaScript 위에 정적 타입 검사 도구를 제공하는 언어다. 타입 선언이 있다고 실행 중 외부 JSON 데이터가 자동 검증되는 것은 아니다. 서버 응답을 어떤 타입이라고 단언하는 것과 실제 필드가 그 규칙을 만족하는지 확인하는 것은 다른 작업이다. JavaScript의 객체 공유, Promise, this와 모듈을 이해하면 TypeScript에서도 많은 오류 원인을 설명할 수 있다.

React, Vue 같은 도구는 UI 구성과 상태 갱신을 조직하는 방법을 제공한다. 하지만 값과 참조의 차이, 비동기 응답 순서, 클로저가 잡는 상태 같은 언어 특성은 계속 작동한다. 프레임워크 문법만 외우면 배열을 제자리에서 변경했을 때의 영향이나 오래된 값이 콜백에 남는 현상을 파악하기 어렵다.

학습은 작은 순수 함수와 값의 변화를 추적하는 단계에서 시작해, 모듈과 테스트, DOM 이벤트, HTTP와 비동기 상태 관리로 넓혀 가면 연결이 분명해진다. 문법을 모두 외우고 시작할 필요는 없지만 실행 환경과 데이터 경계는 처음부터 표시하는 것이 좋다. 오류 메시지를 지우는 것보다 어떤 입력과 순서에서 문제가 생겼는지를 재현하는 습관이 중요하다.

JavaScript는 브라우저와 서버에서 같은 언어 지식을 활용하고 풍부한 생태계를 이용할 수 있다는 장점이 있다. 동시에 동적 값의 변환, 환경별 API 차이, 의존성 관리와 비동기 상태의 복잡성을 다뤄야 한다. 적합성은 언어 이름의 인기보다 팀의 경험, 대상 환경, 성능 요구와 유지보수 방식에 따라 판단한다.

참고 자료