개발을 하기 위해 리액트를 공부하고자 한다면 자바스크립트에 대해서 어느정도 알아야 한다.
리액트만 공부하는 건 불가능하다 !!
따라서 오늘은 리액트를 공부하기 이전에 꼭!!! 필요한 JS 개념에 대해서 알아보려고 한다.
원시타입
- boolean
- null
- undefined
- number
- string
- symbol
- bigint
객체타입
- object
undefined: 선언한 후 값을 할당하지 않은 변수 또는 값이 주어지지 않은 인수에 자동으로 할당되는 값
null: 아직 값이 없거나 비어 있는 값을 표현할 때 사용
let a;
console.log(a); // undefined (값이 할당되지 않음)
let b = null;
console.log(b); // null (명시적으로 '비어 있음'을 표현)
undefined == null; // true (값이 없다는 의미로 느슨하게 같음)
undefined === null; // false (타입이 다름)
JSON.stringify({ a: undefined, b: null });
// '{"b":null}'
typeof undefined; // "undefined"
typeof null; // "object"
null의 특별한 점!
=> null의 type을 확인하면 object라는 결과가 나온다 !
typeof null === 'object' 는 자바스크립트 초기 버전의 비트 기반 타입 태그 설계에서 비롯된 버그로 null이 객체 타입 + null 포인터로 표현되었기 때문에 발생한다.
이는 현재로써 해결할 수 있는 기술이지만 이 문제가 수정되면 기존 코드에 부정적인 영향을 미칠 수 있기 때문에 오늘날까지 유지되고 있는 버그이다.
undefined와 null의 차이점을 한 줄로 설명하자면,
undefined는 선언됐지만 할당되지 않은 값이고, null은 명시적으로 비어 있음을 나타내는 값으로 사용하는 것이 일반적이다.
let hello = ’hello world'
let hi = 'hello world’
console.log(hello === hi) // true
// 다음 객체는 완벽하게 동일한 내용을 가지고 있다.
var hello = {
greet: 'hello, world',
}
var hi = {
greet: 'hello, world',
}
// 그러나 동등 비교를 하면 false가 나온다.
console.log(hello === hi) // false
// 원시값인 내부 속성값을 비교하면 동일하다.
console.log(hello.greet === hi.greet) // true
왜 객체는 객체 안에 동일한 값을 가지고 있어도 값을 비교하면 false이 나올까?
값을 복사한다면 ?
var hello = {
greet: ’hello, world',
}
var hi = hello
console.log(hi === hello) // true
var hi = hello 를 실행하면 객체의 값이 아니라 “주소”가 복사되기 때문에 true !
==,===와는 또다른 리액트에서 제공하는 비교 방법
사용방법
-0 === +0 // true
Object.is(-0, +0) // false
Number.NaN === NaN // false
Object.is(Number.NaN, NaN) //true
NaN === 0 / 0 // false
Object.is(NaN, 0/0) //true
==, === 만으로는 정확하게 구별하지 못하는 특이한 케이스를 비교하기 위해 만들어짐하지만 객체 비교에서는 여전히 참조(주소) 비교를 하기에 개발자가 의도한대로 원할한 비교가 되지 않는다 ㅜ
Object.is({}, {}) // false
Object.js의 객체 비교 문제를 해결하기 위한 얕은 비교 함수
function shallowEqual(objA, objB) {
// 1. Object.is로 먼저 완전 동일한지 비교
if (is(objA, objB)) return true;
// 2. 둘 중 하나라도 객체가 아니면 false
if (typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null) return false;
// 3. 키 배열 길이가 다르면 false
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
// 4. 같은 키를 순회하며 Object.is로 각 값 비교
for (let key of keysA) {
if (!objB.hasOwnProperty(key) || !is(objA[key], objB[key])) return false;
}
return true;
}
얕은 비교의 한계
리액트에서는 props가 대부분 단순한 값(문자열, 숫자) 이라서 1 depth까지만 비교해도 대부분 충분하지만 깊은 객체에서는 문제가 생긴다.
const Component = memo((props) => {
console.log("렌더링됨");
return <h1>{props.counter}</h1>;
});
const DeeperComponent = memo((props) => {
console.log("렌더링됨");
return <h1>{props.counter.counter}</h1>;
});
<Component counter={100} />
<DeeperComponent counter={{ counter: 100 }} />
첫 번째 Component는 counter가 원시값이므로 비교 가능 → memo가 잘 작동함
두 번째 DeeperComponent는 counter가 객체라서 매 렌더링마다 { counter: 100 }이 새로 생성됨 → 참조가 달라짐 → shallowEqual에서 false
=> 즉, 내부 값은 같지만 객체의 참조가 바뀌어 memo가 무효
근데도 깊은 비교가 아닌 얕은 비교를 하는 이유?
성능의 문제점
객체 안에 객체가 몇 개 들어있을지 모르기 때문에 재귀로 비교하다 보면 렌더링마다 모든 값 전체를 순회해야 한다.
그래서 리액트는 얕은 비교까지만 지원하고 더 깊은 구조를 비교해야 할 땐 개발자가 직접 useMemo, useCallback 등으로 불필요한 객체 생성이나 참조 변경을 막아야 한다.
hello(); // "hi"
function hello(){ console.log("hi"); }
console.log(typeof hello); // "undefined"
hello(); // TypeError
var hello = function(){};
const add = new Function('a', 'b', 'return a + b');
add() //error
const add = (a, b) => a + b;
클래스나 객체 메서드 내부에서는 화살표 함수 대신 일반 함수를 쓰기
선언문과 표현식의 가장 크고 중요한 차이는 바로 호이스팅 !!
호이스팅이 뭔데 ?
MDN 공식문서에서 말하는 호이스팅이란
인터프리터가 코드를 실행하기 전에 함수, 변수, 클래스 또는 임포트(import)의 선언문을 해당 범위의 맨 위로 끌어올리는 것처럼 보이는 현상
즉 함수 선언문이 마치 코드 맨 앞단에 작성된 것처럼 작동하는 현상을 의미한다.
hello() // hello
function hello() {
console.log('hello')
}
hello() // hello
위의 코드를 보면 함수 선언문 전에 hello() 함수를 실행해도 함수가 잘 실행되는 걸 볼 수 있다. 어떻게 이런일이 일어날 수 있는걸까?
바로 호이스팅이 일어났기 때문이다.
function hello() {
console.log('hello')
}
hello() // hello
hello() // hello
이런식으로 함수 표현식은 위로 끌어올려진 것처럼 작동한다.
실제 호이스팅이 일어나는 이유는 함수 자체가 미리 메모리에 등록하기 때문이다. 따라서 선언 전에 호출이 가능한 것 !
console.log(typeof hello === 'undefined') // true
hello() // Uncaught TypeError: hello is not a function
var hello = function () {
console.log('hello')
}
hello() // hello
함수 표현식은 함수 선언문과 다르게 정상적으로 호출되지 않고 undefined로 남아있는 것을 볼 수 있다.
//var hello; // 선언만 호이스팅되어 undefined로 초기화됨
hello; // undefind => 식별자는 이미 존재함 (위로 호이스팅 되었기에)
hello(); // 함수 호출 불가 => hello가 아직 undefined 상태이기 때문
var hello = function () {
console.log('hello')
}
hello(); //hello
함수 표현식은 호이스팅이 일어나지 않는 걸 볼 수 있다.
함수와 다르게 변수는 런타임 이전에 undefined로 초기화되고 할당문이 실행되는 시점, 즉 런타임 시점에 함수가 할당되어 작동하기 때문이다.
함수의 부수 효과란
함수 내의 작동으로 인해 함수가 아닌 함수 외부에 영향을 끼치는 것을 의
미
이러한 부수 효과가 없는 함수를 순수 함수라 하고 부수 효과가 존재하는 함수를 비순수 함수라고 한다.
부수효과들
| 상황 | 부수 효과의 예 | 이유 |
|---|---|---|
| 데이터 요청 | fetch() | 서버에 요청(외부 영향) |
| 콘솔 출력 | console.log() | 브라우저 콘솔 조작 |
| 문서 제목 변경 | document.title = ... | DOM 조작(외부 변경) |
| setTimeout | 브라우저 타이머에 영향 | 외부 환경 의존 |
부수 효과를 완전히 없앨 수는 없고 제한된 곳에서만 안전하게 처리해야 한다는 게 핵심 !
리액트는 useEffect를 통해 이런 부수 효과를 제어된 방식으로 실행
function Example({ userId }) {
const [data, setData] = useState(null);
// 외부 요청(부수 효과)은 useEffect 내부에서만 수행
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(setData);
}, [userId]);
return <div>{data?.name}</div>;
}
핵심 포인트:
하지만 useEffect는 DOM을 생성 후 paint 이후에 동작하기 때문에 렌더링에 영향을 주는 로직에서는 사용하면 X !!
| 상황 | 예시 | 이유 |
|---|---|---|
| 1. 외부 데이터 요청 / API 호출 | fetch, axios, supabase, firebase 등 | 네트워크 I/O는 렌더링과 독립적 |
| 2. DOM 직접 조작 (Ref 기반) | element.focus(), scrollIntoView() 등 | 리액트가 관리하지 않는 영역 |
| 3. 타이머/인터벌 관리 | setTimeout, setInterval | 브라우저 자원과의 상호작용 |
| 4. 구독/리스너 등록 및 해제 | addEventListener, WebSocket, Firebase 등 | 컴포넌트가 “외부 이벤트”를 듣는 행위 |
위와 같이 렌더링에 영향을 안주고 외부 세계에 영향을 주거나 의존하는 코드를 처리할 때만 사용해야한다.
| 이유 | 설명 |
|---|---|
| 예측 가능성 | 같은 입력에 대해 결과가 일정해야 버그 추적이 쉬움 |
| 테스트 용이성 | 순수 함수는 테스트하기 쉽고 빠름 |
| 컴포넌트 안정성 | 불필요한 재렌더링, 상태 꼬임 방지 |
| 협업과 유지보수 | 다른 개발자가 읽고 이해하기 쉬움 |
함수는 한 가지 일만 해야 한다. 한 함수에 여러 기능들이 존재한다면 유지보수성도 어렵고 가독성도 좋지 않고 재사용에 있어서도 좋지 않다.
따라서 단일책임 원칙을 따라야 한다.
ESLint의 max-lines-per-function 규칙은 하나의 함수가 너무 길면 경고를 띄워라라는 규칙이다.
"max-lines-per-function": ["warn", { "max": 50 }]
기본 ESLint 규칙에 있을 정도로 생각하면서 작성해야 할 부분이다.
상황에 따라 다르지만, 짧을수록 좋다
하지만 실무에서는 보통 20줄 이내면 읽기 편하고 10줄 내외면 가장 이상적이다.
하지만 한 함수에 하나의 기능을 하지만 코드가 길어질 수 있기 때문에
무조건 짧아야 한다는건 잘못된 생각이다 !!
| 문제 | 해결 방법 |
|---|---|
| 한 함수가 여러 일을 함 | 작은 함수로 분리 |
| 비슷한 로직 반복 | 공통 함수 추출 |
| 긴 조건문 | 의미 있는 변수/함수로 치환 |
| 중첩 콜백 많음 | async/await, map/filter 등 활용 |
나쁜 예시
function processOrder(order) {
if (!order.user) return;
if (!order.items.length) return;
// 재고 확인
for (const item of order.items) {
if (item.stock === 0) return;
}
// 결제 처리
const result = pay(order.total);
if (!result.success) return;
// 알림 전송
sendEmail(order.user.email);
sendSMS(order.user.phone);
}
좋은 예시
function validateOrder(order) { /* ... */ }
function checkStock(items) { /* ... */ }
function processPayment(order) { /* ... */ }
function notifyUser(user) { /* ... */ }
function processOrder(order) {
if (!validateOrder(order)) return;
if (!checkStock(order.items)) return;
if (!processPayment(order)) return;
notifyUser(order.user);
}
작은 프로젝트는 코드의 양이 적고, 작성자 본인만 이해하면 충분하기 때문에 temp, data, result 같은 이름을 써도 큰 문제가 없다.
하지만 프로젝트가 커지고 기능이 복잡해지며 여러 사람이 함께 작업하게 되면
이름이 불분명한 변수나 함수는 가독성을 떨어뜨리고 유지보수를 어렵게 만든다.
좋은 네이밍의 조건
이름만 보고 역할이 드러나는 코드
// ❌ 나쁜 예
function processData(a, b) {
return a * b;
}
// ✅ 좋은 예
function calculateInsuranceFee(insurance, years) {
return insurance * years;
}
좋은 이름을 짓기 위한 세 가지 원칙
| 원칙 | 설명 |
|---|---|
| 명확하게 | 함수 이름만 보고 “무슨 일을 하는지” 알 수 있어야 한다. |
| 일관성 있게 | 프로젝트 내 동일한 역할의 함수는 동일한 패턴으로 이름을 짓는다. 예: handleClick, handleSubmit, fetchData 등 |
| 읽기 쉽게 | 약어나 축약어는 피하고, 가능한 한 자연어처럼 읽히게 작성한다. |
// ✅ 좋은 패턴
handleSubmit(); // 동작 수행
fetchUserData(); // 외부 요청
calculatePrice(); // 계산
getUserInfo(); // 반환
리액트에서 함수 컴포넌트와 훅이 등장한 16.8 버전을 기점으로 이 클로저라는 개념이 리액트에서 적극적으로 사용되기 시작하면서 클로저를 빼놓고서는 리액트가 어떤 식으로 작동하는지 이해할 수 없다.
MDN 공식문서에서 말하는 클로저 정의
함수와 함수가 선언된 어휘적 환경(Lexical Scope)의 조합
스코프: 변수의 유효 범위
function add() {
const a = 10
function innerAdd() {
const b = 20
console.log(a + b)
}
innerAdd() // 30
}
add()
즉 렉시컬 스코프는 변수가 코드 내부에서 어디서 선언됐는지를 말하는 것
전역스코프: 변수를 전역 레벨에 선언하는 것
var global = 'global scope'
function heUo() {
console.log(global)
}
console.log(global) // global scope
hello() // global scope
console.log(global === window.global) // true
자바스크립트는 기본적으로 함수 레벨 스코프를 따른다. 즉, {} 블록이 스코프 범위를 결정하지 X
if (true) {
var global = 'global scope'
}
console.log(global) // 'global scope'
console.log(global === window.global) // true
function hello() {
var local = 'local variable'
console.log(local) // local variable
}
hello()
console.log(local) // Uncaught ReferenceError: local is not defined
function Component() {
const [count, setCount] = useState(0);
function handleClick() {
setCount((prev) => prev + 1);
}
}
setCount 함수는 useState 내부에서 만들어질 때
count가 저장된 환경(Lexical Environment) 을 함께 기억한다.
그래서 나중에 setCount를 호출해도 useState 실행이 끝난 이후임에도 불구하고 그 당시의 state 변수에 접근할 수 있다.
즉, useState는 내부적으로 클로저를 이용한 상태 은닉 구조를 사용한다.
클로저는 유용하지만 성능이나 메모리에 영향을 줄 수 있다.
1. 잘못된 스코프 바인딩
for (var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
}, i * 1000);
}
의도: 1초 간격으로 0, 1, 2, 3, 4 출력
결과: 전부 5만 출력
이유:
2. 메모리 낭비
function heavyJobWithClosure() {
const longArr = Array.from({ length: 10000000 }, (_, i) => i + 1);
return function () {
console.log(longArr.length);
};
}
const innerFunc = heavyJobWithClosure();
button.addEventListener('click', innerFunc);
| 문제 | 원인 | 해결 방법 |
|---|---|---|
| 메모리 점유 | 외부 변수를 계속 기억함 | 클로저 내부에서 필요한 최소한의 값만 유지 |
| 예기치 않은 참조 | var로 선언된 전역 스코프 공유 | let / const 사용 |
| 디버깅 어려움 | 내부 상태가 외부에서 안 보임 | 주석, 명확한 함수 이름으로 보완 |
undefined vs null 비교를 생성 방식부터 직렬화까지 구체적으로 정리한 점이 좋았어요.
shallowEqual 한계 → memo 실패 사례(객체 리터럴 참조 변경) 흐름을 정리해주셔서 이해가 잘 되었고, 대응 전략을 useMemo/useCallback으로 연결한 것도 도움이 많이 되었습니다 ..!! 🙂
memo 컴포넌트에 객체 props를 넘길 때, 참조 안정화를 위해 useMemo를 쓰는 기준은 어떻게 잡으시나요? (빈도, 비용, 구조 중 어떤 걸 우선 고려하시는지 궁금합니다!)
커스텀훅을 구현하면서 줄이 너무 길어지고 가독성이 안 좋아져서 고민이 많았는데
좋은 함수 작성 원칙덕에 도움이 많이 됐습니다!! 커스텀 훅을 조금 더 기능 별로 세세하게 나누고 알맞는 네이밍을 하면 코드가 더 좋아질 것 같아요함수의 부수효과를 최소화하기 위해 useEffect 사용을 보여주셨는데, 덕분에 useEffect를 어떤 용도로 사용할 수 있을지 명확히 알게 되었네요.