blog

JavaScript 헷갈리는 동작 모음

Front-end / JavaScript#JavaScript#배열#비동기#객체#팁 모음

작업하면서 헷갈렸던 JavaScript 동작들을 메모해 두었다가 정리했다.

배열

map vs forEach

  • map: 각 요소를 변환한 새 배열을 반환한다.
  • forEach: 반환값이 없다(undefined). 부수 효과(출력, 외부 변수 변경)용이다.

결과 배열이 필요 없는데 map을 쓰거나, 새 배열이 필요한데 forEach로 push하는 것은 의도를 흐린다.

some vs every

[1, 3, 5].some((n) => n > 4);  // true  - 하나라도 만족하면
[1, 3, 5].every((n) => n > 0); // true  - 모두 만족하면
[].every((n) => n > 100);      // true  - 빈 배열은 항상 true

forEach 안에서 await는 기다리지 않는다

// ❌ forEach는 콜백이 반환한 Promise를 기다리지 않음
items.forEach(async (item) => {
  await save(item);
});
console.log('끝'); // 저장이 끝나기 전에 출력

// ✅ 순서대로
for (const item of items) {
  await save(item);
}

// ✅ 병렬로
await Promise.all(items.map((item) => save(item)));

선택한 요소를 맨 앞으로 옮기기

const moveToFirst = (items, index) => {
  const [selected] = items.splice(index, 1); // 원본에서 제거
  items.unshift(selected);                   // 맨 앞에 추가
  return items;
};

splice·unshift는 원본 배열을 변경한다. React·Vue 상태처럼 불변성이 필요하면 새 배열을 만든다.

const moveToFirst = (items, index) => [items[index], ...items.filter((_, i) => i !== index)];

객체

Object.keys의 순서

Object.keys는 정해진 규칙으로 정렬된 순서를 반환한다.

  1. 정수처럼 보이는 키('1', '10')는 숫자 오름차순으로 먼저
  2. 나머지 문자열 키는 추가된 순서대로
Object.keys({ b: 1, 10: 1, a: 1, 2: 1 }); // ['2', '10', 'b', 'a']

순서가 중요한 데이터라면 배열이나 Map을 쓴다.

undefined 값의 프로퍼티는 사라지지 않는다

const obj = { name: 'kim', age: undefined };

'age' in obj;             // true - 프로퍼티는 존재
Object.keys(obj);         // ['name', 'age']
JSON.stringify(obj);      // '{"name":"kim"}' - JSON 변환 때만 빠짐

API 요청 본문으로 보낼 때 빠지는 것은 JSON.stringify가 undefined 값을 생략하기 때문이다. null은 그대로 null로 들어간다.

Date는 변경 가능한 객체다

const start = new Date('2024-05-01');
const end = start;
end.setDate(10);

start.getDate(); // 10 - 같은 객체를 가리키므로 함께 바뀜

setDate, setMonth 같은 메서드는 그 객체 자체를 바꾼다. 복사가 필요하면 new Date(start)로 새 객체를 만든다.

비동기

Promise의 resolve와 reject

  • resolve(value): Promise를 성공으로 완료한다. .then() 또는 await의 결과가 된다.
  • reject(error): 실패로 완료한다. .catch() 또는 try/catch로 받는다.

throw는 Error가 아니어도 잡힌다

try {
  throw '문자열';
} catch (e) {
  console.log(e); // '문자열'
}

무엇이든 던질 수 있고 가장 가까운 catch가 받는다. 다만 스택 정보가 없어 디버깅이 어려우므로 항상 Error(또는 하위 클래스)를 던진다. 그래서 TypeScript는 catch (e)의 e를 unknown으로 본다.

setTimeout(fn, 0)

setTimeout(() => console.log('B'), 0);
console.log('A');
// A → B

지연 0이라도 콜백은 현재 실행 중인 코드와 마이크로태스크(Promise 콜백)가 모두 끝난 뒤, 다음 태스크로 실행된다. 브라우저 렌더링 이후로 작업을 미루는 용도로 쓰이기도 하지만, 실행 시점이 보장되지 않으므로 프레임워크가 제공하는 방법(nextTick, requestAnimationFrame)을 먼저 찾는다.

alert는 실행을 멈춘다

alert, confirm, prompt는 사용자가 닫을 때까지 메인 스레드를 막는다. 타이머 콜백, 렌더링, 이벤트 처리도 그동안 멈춘다. 디버깅 용도로만 쓴다.

최상위에서 await 쓰기

ES 모듈(type="module", Vite·Next.js 번들)에서는 최상위 await를 바로 쓸 수 있다.

await initTotalCheckedOptions();

CommonJS나 일반 스크립트처럼 지원하지 않는 환경이라면 즉시 실행 함수로 감싼다.

(async () => {
  await initTotalCheckedOptions();
})();

함수와 스코프

함수는 return해야 값을 돌려준다

중괄호를 쓴 화살표 함수는 return이 없으면 undefined를 반환한다.

const double = (n) => { n * 2; };  // undefined
const double2 = (n) => n * 2;      // 2배 값

map 콜백에서 이 실수를 하면 [undefined, undefined, ...]가 나온다.

콜백은 바깥 스코프 변수에 접근할 수 있다

콜백 안에서 쓰는 변수가 콜백을 정의한 위치의 바깥 스코프에 있다면, 따로 인자로 넘기지 않아도 접근할 수 있다(클로저). 단, 비동기 콜백에서는 실행 시점이 아니라 정의 시점의 변수를 참조하므로 값이 바뀌었을 수 있다는 점에 주의한다.

기타

crypto.randomUUID()

crypto.randomUUID(); // '3b241101-e2bb-4255-8caf-4136c566a962'

브라우저와 Node.js에서 라이브러리 없이 UUID v4를 만든다. 브라우저에서는 보안 컨텍스트(HTTPS 또는 localhost)에서만 동작한다.

운영 빌드에서 console.log 제거

console.log = () => {}처럼 런타임에 덮어쓰기보다, 빌드 단계에서 아예 제거하는 편이 깔끔하다.

// vite.config.ts
export default defineConfig({
  esbuild: {
    drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : [],
  },
});

console.error까지 지우고 싶지 않다면 pure: ['console.log', 'console.debug']처럼 특정 함수만 지정한다. Next.js는 compiler.removeConsole 옵션을 쓴다.