리액트 개발을 위해 꼭 알아야할 자바스크립트

리액트 딥다이브 스터디를 현재 진행하고 있습니다. 이 내용을 포스팅하여 공유하고자 합니다.

1.5 이벤트 루프와 비동기 통신의 이해

자바스크립트는 싱글 스레드에서 작동합니다. 한 번에 하나의 작업만 동기 방식으로 처리할 수 있습니다. 이는 매우 직관적이지만 한 번에 많은 작업을 처리할 수 없다는 단점도 있습니다.

비동기란 직렬 방식이 아니라 병렬 방식으로 작업을 처리하는 방식을 의미합니다. 즉, 한 번에 여러 작업이 동시에 실행될 수 있는 것처럼 보입니다. 하지만 자바스크립트의 비동기 처리는 실제로 병렬 처리를 하는 것이 아니라, 비동기 작업의 완료를 기다리지 않고 다음 작업으로 넘어가는 방식입니다.

1.5.1 싱글 스레드 자바스크립트

과거에는 프로그램을 실행하는 단위가 프로세스뿐이었지만, 프로그램이 복잡해지면서 더 작은 실행 단위인 스레드가 탄생했습니다. 그런데 왜 자바스크립트는 싱글 스레드일까요?

자바스크립트가 처음 만들어졌을 때는 멀티스레딩이 보편화되지 않았으며, 당시에는 자바스크립트가 지금처럼 복잡한 작업을 처리할 것이라고 예상하지 않았습니다. 또한, 멀티스레딩을 지원하게 되면, 동시에 같은 자원을 접근하여 타이밍 이슈나 데이터 경쟁 상태(race condition) 등이 발생할 수 있습니다. 자바스크립트는 싱글 스레드이며, 동기식으로 한 번에 하나씩 작업을 처리하는 방식으로 이러한 문제를 피합니다.

console.log(1);
 
setTimeout(() => {
  console.log(2);
}, 0);
 
setTimeout(() => {
  console.log(3);
}, 100);
 
console.log(4);

왜 위의 결과가 1, 2, 3(0,1초 후), 4가 아닌 1 > 4 > 2 > 3 순으로 되는지에 대한 설명은 이벤트 루프를 이해해야 합니다.

1.5.2 이벤트 루프란?

이벤트 루프는 ECMAScript (자바스크립트 표준)에 명시된 내용은 아닙니다. 자바스크립트 런타임 외부에서 비동기 작업의 실행을 관리하기 위해 만들어진 장치입니다.

자바스크립트 런타임이란 자바스크립트 코드를 실행할 수 있는 환경으로, 브라우저와 Node.js가 이에 해당합니다.

호출 스택과 이벤트 루프

호출 스택(Call Stack)은 자바스크립트에서 수행해야 할 코드나 함수를 순차적으로 담아두는 스택 자료구조입니다.

function bar() {
  console.log("bar");
}
 
function baz() {
  console.log("baz");
}
 
function foo() {
  console.log("foo");
  bar();
  baz();
}
 
foo(); // foo, bar, baz

이 코드는 foo를 호출하고 내부에서 bar와 baz를 순차적으로 호출하는 구조로 되어있습니다. 이 코드는 다음과 같이 호출 스택에서 쌓이고 비워지게 됩니다.

  1. foo()가 호출 스택에 먼저 들어간다.
  2. foo() 내부에 console.log가 존재하므로 호출 스택에 들어간다.
  3. 2의 실행이 완료된 이후에 다음 코드로 넘어간다. (foo() 존재)
  4. bar()가 호출 스택에 들어간다.
  5. bar() 내부에서 console.log가 존재하므로 호출 스택에 들어간다.
  6. 5의 실행이 완료된 이후에 다음 코드로 넘어간다. (foo(), bar() 존재)
  7. 더 이상 bar()에 남은 것이 없으므로 호출 스택에서 제거
  8. baz가 호출 스택에 들어간다.
  9. bar() 내부에서 console.log가 존재하므로 호출 스택에 들어간다.
  10. 9의 실행이 완료된 이후에 다음 코드로 넘어간다.(foo(), baz() 존재)
  11. 더 이상 baz()에 남은 것이 없으므로 호출 스택에서 제거
  12. 더 이상 foo()에 남은 것이 없으므로 호출 스택에서 제거
  13. 모두 제거 완료

이 호출 스택이 비어 있는지 여부를 확인하는 것이 이벤트 루프의 역할입니다. 수행해야 할 코드가 있다면 자바스크립트 엔진을 이용해 실행합니다.

이 모든 것은 단일 스레드에서 일어나기 때문에, 동시에 일어날 수 없고 순차적으로 일어납니다.

function bar() {
  console.log("bar");
}
 
function baz() {
  console.log("baz");
}
 
function foo() {
  console.log("foo");
  setTimeout(bar, 0); // 정오표 참고: 이는 잘못된 코드입니다. 'bar' 함수의 참조만 넘겨야 오류가 없습니다. 'bar()'를 하면 'bar' 함수가 즉시 실행된 이후에 'undefined'를 반환합니다.
  baz();
}
 
foo();

비동기 안에서 함수 호출은 그 자리에서 즉시 호출되고 함수 자체를 가리키는 참조를 해야 우리가 생각하는 비동기처럼 실행됩니다.

  1. foo()가 호출 스택에 먼저 들어간다.
  2. foo() 내부에 console.log가 존재하므로 호출 스택에 들어간다.
  3. 2의 실행이 완료된 이후에 다음 코드로 넘어간다. (foo() 존재)
  4. setTimeout(bar, 0)이 호출 스택에 들어간다.
  5. 4번에 대해 타이머 이벤트가 실행되며 태스크 큐로 들어가고, 그 대신 바로 스택에서 제거된다.
  6. baz()가 호출 스택에 들어간다.
  7. baz() 내부에서 console.log가 존재하므로 호출 스택에 들어간다.
  8. 7의 실행이 완료된 이후에 다음 코드로 넘어간다.(foo(), baz() 존재)
  9. 더 이상 baz()에 남은 것이 없으므로 호출 스택에서 제거
  10. 더 이상 foo()에 남은 것이 없으므로 호출 스택에서 제거
  11. 모두 제거 완료
  12. 이벤트 루프가 호출 스택이 비워져 있다는 것을 확인했다. 그리고 태스크 큐를 확인하니 4번에 들어갔던 내용이 있어 bar를 호출 스택에 들여보낸다.
  13. bar 내부에 console.log가 존재하므로 호출 스택에 들어간다.
  14. 13의 실행이 끝나고 다음 코드로 넘어간다.
  15. 더 이상 bar에 남은 것이 없으므로 호출 스택에서 제거

위 코드를 보면 setTimeout은 0초 뒤에 실행된다는 것을 보장하지 못한다는 것을 이해할 수 있다. 태스크 큐란 무엇인가? 자료구조인 큐는 FIFO(First in First Out) 형식으로 꺼내지만 태스크 큐는 그렇지 않습니다. 태스크 큐에서 의미하는 실행해야 하는 태스크 라는 것은 비동기 함수의 콜백 함수나 이베느 핸들러 등을 의미합니다.

이 비동기 함수는 누가 실행을 할까? 이는 메인 스레드가 아닌 별도의 스레드에서 수행되며 브라우저나 Node.js가 실행한다.

즉 자바스크립트는 싱글 스레드지만 혼자 모든 것을 다 처리하는 것이 아닌 외부의 도움을 받는 언어이다.

1.5.3 태스크 큐와 마이크로 태스크 큐

위 코드를 보면, setTimeout은 0초 뒤에 실행된다고 보장할 수 없다는 것을 이해할 수 있습니다. 이는 태스크 큐의 비동기 작업 관리 방식 때문입니다.

태스크 큐와 마이크로태스크 큐

태스크 큐는 FIFO(First In, First Out) 형식으로 작업을 꺼내지만, 우선순위가 낮습니다. 예를 들어, setTimeout, setInterval, setImmediate 등이 여기에 포함됩니다.

반면, **마이크로태스크 큐(Microtask Queue)**는 태스크 큐보다 우선순위가 높은 작업들이 들어가는 큐입니다. 이 큐는 기존의 태스크 큐와는 다른 작업을 처리합니다. 이벤트 루프는 항상 마이크로태스크 큐를 먼저 비운 후에 태스크 큐를 처리합니다. 따라서 Promise의 .then이나 process.nextTick 같은 작업은 항상 setTimeout이나 setInterval보다 먼저 실행됩니다.

function foo() {
  console.log(`foo`);
}
 
function bar() {
  console.log(`bar`);
}
 
function baz() {
  console.log(`baz`);
}
 
setTimeout(foo, 0);
 
Promise.resolve().then(bar).then(baz); // 실행 순서: bar, baz, foo

태스크 큐: setTimeout, setInterval, setImmediate 등. 마이크로태스크 큐: process.nextTick, Promises (.then, .catch, .finally), queueMicroTask, MutationObserver 등.

1.6 리액트에서 자주 사용하는 자바스크립트 문법

일반적인 자바스크립트나 Node.js 기반으로 한 코드와 리액트 코드를 비교하면 리액트는 상대적으로 독특한 모습을 띤다는 것을 알 수 있습니다.

이러한 독특함은 JSX 구문 내에서 객체를 조작하거나 객체의 얕은 동등 비교 문제를 피하기 위해 객체 분할 할당을 하는 등 여러가지 특징에서 비교 됩니다.

또한 자바스크립트는 매년 새로운 버전과 함께 새로운 기능이 나오는데 이 표준을 ECMAScript라고 합니다. 이 버전을 항시 주시해야 하는 이유는

모든 브라우저와 자바스크립트 런타임이 항상 새로운 문법을 지원하는 것이 아니기 때문입니다.

이러한 불편함을 해결하기 위해서 나온 것이 바벨입니다.

바벨은 최신 문법을 다양한 브라우저에서도 일관적으로 사용할 수 있도록 트랜스파일합니다.

1.6.1 구조 분해 할당

구조 분해 할당이란 배열 또는 객체의 값을 말 그대로 분해하여 개별 변수에 할당 하는 것을 말합니다.

const array = [1, 2, 3, 4, 5];
 
const [first, second, third, ...arrayRest] = array;
 
console.log(first, second, third, arrayRest); // 1, 2, 3, [4, 5]

배열의 객체 구조 분해 할당은 ,의 위치에 따라 값이 결정된다. 따라서 앞의 예제에서 중간 인덱스에 대한 할당을 생략하고 싶다면 다음처럼 하면 됩니다.

const array = [1, 2, 3, 4, 5];
const [first, , , , fifth] = array;
 
console.log(first, fifth); // 1, 5

특정 값 이후의 값을 다시금 배열로 선언하고 싶다면 전개 연산자(spread operator)를 사용할 수도 있다.

const array = [1, 2, 3, 4, 5];
const [first, ...rest] = array;
 
console.log(first, rest);

배열 분해 할당에는 기본 값을 선언할 수도 있습니다.

const array = [1, 2];
const [a = 10, b = 10, c = 10] = array;
 
console.log(a, b, c);

객체 구조 분해 할당

객체 구조 분해 할당은 말 그대로 객체에서 값을 꺼내온 뒤 할당 하는 것을 말합니다.

const object = {
  a: 1,
  b: 2,
  c: 3,
  d: 4,
  e: 5,
};
 
const { a, b, c, ...objectRest } = object;
 
console.log(a, b, c, objectRest); // 1, 2, 3, {d: 4, e: 5}

새로운 이름으로 다시 할당하는 것도 가능합니다.

const object = {
  a: 1,
  b: 2,
};
 
const { a: first, b: second } = object;
 
console.log(first, second); // 1, 2

기본값을 주는 것도 가능합니다.

const object = {
  a: 1,
  b: 1,
};
 
const { a = 10, b = 10, c = 10 } = object;
 
console.log(a, b, c); // 1, 1, 10

10 10 10이 아닌 이유는 배열과 마찬가지로 a, b는 이미 객체에서 존재하기 때문에 기본 값이 적용되지 않습니다. c는 없기에 적용됩니다.

변수에 있는 값으로 꺼내오는 계산된 속성 이름 방식도 가능합니다.

const key = "a";
 
const object = {
  a: 1,
  b: 1,
};
 
const { [key]: a } = object;
 
console.log(a); // 1

key는 a라는 값을 가지고 있는데 object에서 이 a라는 값을 꺼내기 위에서는 [key] 문법을 꼭 사용해야 합니다.

배열 구조 분해 할당과 마찬가지로 전개 연산자를 사용하면 나머지 값을 모두 가져올 수 있습니다.

const object = {
  a: 1,
  b: 2,
  c: 3,
  d: 4,
  e: 5,
};
 
const { a, b, ...rest } = object;
 
console.log(a, b, rest); // 1, 2, {c: 3, d: 4, e: 5}

1.6.2 전개 구문

전개 구문은 말 그대로 전개해 간결하게 사용할 수 있는 구문이다.

const arr1 = ["a", "b"];
const arr2 = [...arr1, "c", "d", "e"];
 
console.log(arr2); // ['a', 'b', 'c', 'd', 'e']
CASE1;
const arr1 = ["a", "b"];
const arr2 = arr1;
 
arr1 === arr2; // true
 
CASE2;
const arr1 = ["a", "b"];
const arr2 = [...arr1];
 
arr1 === arr2; // false

CASE1이 true이고 CASE2가 false인 이유는 배열이 참조 타입이기 때문에 CASE1은 arr1을 arr2에 할당하였을 때 같은 배열을 참조(메모리가 같음)하여 true가 됩니다.

깊은 복사를 하는 CASE2의 경우엔 arr1의 요소를 arr2에 그대로 복사하여 다른 메모리 주소를 가지는 새로운 배열을 만들어냅니다. 즉 메모리 주소가 다르기에 false가 나옵니다.

객체의 전개 구문

const obj1 = {
  a: 1,
  b: 2,
};
 
const obj2 = {
  c: 3,
  d: 4,
};
 
const newObj = { ...obj1, ...obj2 };
 
console.log(newObj); // {a: 1, b: 2, c: 3, d: 4}

1.6.3 객체 초기자

객체를 선언할 때 객체에 넣고자 하는 키와 값을 가지고 있는 변수가 이미 존재한다면 해당 값을 간결하게 넣어줄 수 있는 방식입니다.

const a = 1
const b = 2
 
const obj = {
    a,
    b,
} {a: 1, b: 2}

1.6.4 Array 프로토타입의 메서드: map, filter, reduce, forEach

Array.prototype.map, Array.prototype.filter, Array.prototype.reduce는 모두 배열과 관련된 메서드입니다.

이 메서드는 기존 값을 건드리지 않고 새로운 값을 만들어 내기 때문에 기존 값이 변경 될 염려가 없습니다.

map

const arr = [1, 2, 3, 4, 5];
const doubledArr = arr.map((item) => item * 2);
console.log(doubledArr); // [2, 4, 6, 8, 10]

filter

콜백 함수를 인수로 받는데 이 콜백 함수는 truthy 조건을 만족하는 경우에만 해당 원소를 반환하는 메서드입니다. 즉 필터링하는 역할의 메서드이며 원본 배열 길이 이하의 배열이 반환됩니다.

const arr = [1, 2, 3, 4, 5];
const evenArr = arr.filter((item) => item % 2 === 0);
console.log(evenArr); // [2, 4]

reduce

이 메서드는 콜백 함수와 함께 초깃값을 추가로 인수로 받는데, 이 초깃값에 따라서 배열이나 객체, 또는 그 외의 다른 무언가를 반환할 수 있는 메서드입니다.

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce((result, item) => {
  console.log(result); // 0, 1, 3, 6, 10, 15
  return result + item;
}, 0);
 
console.log(sum); // 15

0은 reduce의 결과를 누적할 초깃값입니다. 첫 번쨰 인수는 앞서 선언한 초깃값의 현재값, 두 번쨰 인수는 현재 배열의 아이템을 의미합니다.

즉 콜백의 반환값을 계속해서 초깃값에 누적하며 새로운 값을 만든다고 볼 수 있습니다.

forEach

forEach는 콜백 함수를 받아 배열을 순회하면서 단순히 그 콜백함수를 실행시키만 하는 함수입니다.

const arr = [1, 2, 3];
 
arr.forEach((item) => console.log(item)); // 1, 2, 3

주의해야하 점은 forEach는 아무런 반환값이 없습니다. 단순히 콜백 함수를 실행할 뿐입니다. 즉 map과 같이 결과를 반환하는 작업은 수행하지 않습니다.

그리고 forEach는 에러를 던지거나 프로세스를 종료하지 않는 이상 멈출 수 없습니다.

function run() {
  const arr = [1, 2, 3];
  arr.forEach((item) => {
    console.log(item);
    if (item === 1) {
      console.log("finished!");
      return;
    }
  });
}
 
run(); // 1, finished!, 2, 3

return이 존재해서 실행이 끝났음에도 계속해서 콜백이 실행되는 이유는 return이 함수의 return이 아니라 콜백 함수의 return으로 인지되기 때문입니다.

1.6.5 삼항 조건 연산자

3개의 피연산자를 취할 수 있는 유일한 문법입니다.

const value = 10;
const result = value % 2 === 0 ? "짝수" : "홀수";
console.log(result); // 짝수

조건문 ? 참의 값 : 거짓일 때의 값

1.7 선택이 아닌 필수, 타입 스크립트

동적 언어인 자바스크립트에서 런타임에만 타입을 체크할 수 있는 한계를 극복해 더욱 안전하게 작성하고 잠재적인 버그를 줄일 수 있는 타입스크립트는 필수!

타입스크립트란?

타입스크립트는 기존 자바스크립트 문법에 타입을 가미한 것이다.

동적 타입의 한계에 대한 코드

function test(a, b) {
  return a / b;
}
 
test(5, 2); // 2.5
test("안녕하세요", "하이"); // NaN

typeof를 통해 체크할 수 있으나 코드의 크기를 과도하게 키우고 너무 번거롭다.

타입스크립트는 이러한 한계를 벗어나기 위해 빌드 타임에 타입 체크를 정적으로 하게 해준다

function test(a: number, b: number) {
  return a / b;
}
 
test("안녕하세요", "하이"); // Error

이렇게 하면 코드를 작성할 때 a, b라는 변수에 오직 number만 할당할 수 있다.

다만 타입스크립트 또한 자바스크립트기 때문에 자바스크립트에서 불가능 한 것은 타입스크립트에서도 마찬가지로 불가능하다.

왜냐하면 타입스크립트 파일(ts, tsx)은 결국 자바스크립트로 변환되서 Node.js, 브라우저에서 실행 되는것이 목표기 때문입니다.

1.7.2 리액트 코드를 효과적으로 작성하기 위한 타입스크립트의 활용법

타입스크립트 코드를 작성할 때 참고하면 좋은 팁을 설명합니다.

any 대신 unknown을 사용하자.

any는 정말 불가피할 때만 사용해야 하는 타입입니다. 이는 타입스크립트가 제공하는 정적 타이핑의 이점을 모두 버리는 것이나 다름 없기 때문입니다.

function doSomething(callback: any) {
  callback();
}
 
doSomething(1); // 이는 에러를 발생시키지 않지만 런타임에선 문제가 됩니다.
function doSomething(callback: unknown) {
  callback(); // 'callback' is of type 'unknown'
}
 
doSomething(1);

unknown은 모든 값을 할당할 수 있지만 바로 그 값을 사용할 수는 없습니다.

unknown으로 선언된 변수를 사용하기 위해선 타입을 의도했던 바로 줄여야합니다.

function doSomething(callback: unknown) {
    ㅑif(typeof callback === 'function') {
        callback()
        return
    }
 
    throw new Error('callback은 함수여야해!')
}
 
doSomething(1);

이렇게하면 예상치 못한 타입을 받아들일 수 있음은 물론이고 사용하는 쪽에서도 더욱 안전하게 쓸 수 있습니다.

never는 unknown의 반대로 어떠한 타입도 들어올 수 없는 값입니다.

타입 가드를 적극 활용하자

타입은 사용하는 쪽에선 타입을 좁히는 것이 좋다. 이에 도움을 주는 것이 타입 가드입니다.

function isString(value: unknown): value is string {
    return typeof value === 'string';
}
 
function printLengthOrDouble(value: string | number): void {
    if (isString(value)) {
        // 타입 가드에 의해 여기서는 value가 string 타입으로 좁혀짐
        console.log(`String length: ${value.length}`);
    } else {
        // 여기서는 value가 number 타입으로 좁혀짐
        console.log(`Doubled number: ${value * 2}`);
    }
}
 
// 예시 호출
printLengthOrDouble("Hello, TypeScript!"); // 출력: String length: 17
printLengthOrDouble(21); // 출력: Doubled number: 42

in

in의 property in object로 사용되는데, 주로 어떤 객체에 키가 존재하는지 확인하는 용도로 사용됩니다.

interface Student {
  age: number;
  score: number;
}
 
interface Teacher {
  name: string;
}
 
function doSchool(person: Student | Teacher) {
  if ("age" in person) {
    // person이 Student 타입인 경우
    console.log("Person is a Student:");
    console.log(`Age: ${person.age}, Score: ${person.score}`);
  } else {
    // person이 Teacher 타입인 경우
    console.log("Person is a Teacher:");
    console.log(`Name: ${person.name}`);
  }
}
 
// 예시 호출
const student: Student = { age: 20, score: 95 };
const teacher: Teacher = { name: "Mr. Smith" };
 
doSchool(student); // 출력: Person is a Student: Age: 20, Score: 95
doSchool(teacher); // 출력: Person is a Teacher: Name: Mr. Smith

제너릭

제너릭은 함수나 클래스 내부에서 단일 타입이 아닌 다양한 타입에 대응할 수 있도록 도와주는 도구입니다.

제너릭을 사용하면 타입만 다른 비슷한 작업을 하는 컴포넌트를 단일 제너릭 컴포넌트로 선언해 간결하게 작성할 수 있습니다.

예를 들어 하나의 타입으로 이루어진 배열의 첫 번째와 마지막 요소를 반환하는 함수를 만든다고 할 때

다양한 타입에 대응해야 하기 때문에 unknown, any를 고민할 것입니다.

이 때 T라는 제너릭을 선언해 배열의 요소와 반환 값의 요소로 사용합니다.

function getFirstAndLast<T>(array: T[]): [T, T] | undefined {
  if (array.length === 0) {
    return undefined;
  }
  return [array[0], array[array.length - 1]];
}
 
// 예시 호출
const numberArray = [1, 2, 3, 4, 5];
const stringArray = ["apple", "banana", "cherry"];
 
const firstAndLastNumber = getFirstAndLast(numberArray);
const firstAndLastString = getFirstAndLast(stringArray);
 
console.log(firstAndLastNumber); // 출력: [1, 5]
console.log(firstAndLastString); // 출력: ['apple', 'cherry']

인덱스 시그니처

인덱스 시그니처란 객체의 키를 정의하는 방식을 의미합니다.

인덱스 시그니처는 객체의 키와 값이 어떤 타입이 될지를 정의하는 일종의 규칙입니다.

예를 들어, 모든 키가 string 타입이고, 모든 값이 string 타입인 객체를 정의하고 싶을 때 사용합니다.

type Hello = {
  [key: string]: string,
};
 
const hello: Hello = {
  hello: "hello",
  hi: "hi",
};
 
hello["hi"]; // hi
hello["안녕"]; // undefined

hello['hi']는 hi라는 key에 해당하는 값을 가져오며 결과는 hi입니다 하지만 '안녕'은 객체에 해당하지 않은 키를 참조하기 때문에 undefined를 반환합니다.

실행순서 맞추기

console.log(1);
 
async function asyncFunction1() {
  console.log(2);
  await asyncFunction2();
  console.log(3);
}
 
async function asyncFunction2() {
  console.log(4);
  return new Promise((resolve) => {
    console.log(5);
    resolve();
  }).then(() => {
    console.log(6);
  });
}
 
asyncFunction1().then(() => {
  console.log(7);
});
 
setTimeout(() => {
  console.log(8);
}, 0);
 
new Promise((resolve) => {
  console.log(9);
  resolve();
}).then(() => {
  console.log(10);
});
 
console.log(11);