JavaScript에서 모든 값은 두 가지 방식 중 하나로 메모리에 저장됩니다. 원시 타입(Primitive Type)은 값 자체를 변수에 직접 저장하고, 참조 타입(Reference Type)은 실제 데이터가 위치한 메모리 주소를 변수에 저장합니다. 이 근본적인 차이는 단순한 구현 세부 사항이 아니라, 변수 복사, 함수 인자 전달, 동등 비교 등 JavaScript의 거의 모든 동작 방식에 영향을 미칩니다. 이 두 타입의 차이를 이해하지 못하면 코드가 왜 예상과 다르게 동작하는지 파악하기 어렵고, 원인을 찾기 힘든 버그를 반복적으로 마주하게 됩니다.
핵심 문제점
- 💡 원시 타입(숫자, 문자열, 불리언, null, undefined, Symbol, BigInt)은 복사 시 값이 독립적으로 복제됩니다
- 💡 참조 타입(객체, 배열, 함수)은 복사 시 같은 메모리 주소를 가리키므로 한쪽을 수정하면 다른 쪽도 영향을 받습니다
- 한 변수에 할당된 객체를 다른 변수에 넘기면 두 변수가 동일한 객체를 공유하게 되어 의도치 않은 변경이 발생합니다
- 원시 타입과 달리 참조 타입은
===비교 시 값이 같아도 서로 다른 메모리 주소를 가리키면false를 반환합니다 - 함수에 객체나 배열을 인자로 전달하면 함수 내부에서 그 내용을 수정했을 때 원본 데이터까지 바뀔 수 있습니다
왜 중요한가?
실무 개발에서 원시 타입과 참조 타입의 차이를 정확히 이해하는 것은 버그를 예방하는 가장 중요한 지식 중 하나입니다. React와 같은 프론트엔드 프레임워크에서는 상태(state)가 객체나 배열인 경우가 많은데, 이때 참조를 그대로 전달하면 불변성(immutability) 원칙이 깨져 화면이 올바르게 갱신되지 않는 문제가 생깁니다. API로부터 받은 데이터를 가공할 때도 원본 배열을 실수로 수정해 서버와 클라이언트 데이터가 불일치하는 상황이 발생하기도 합니다. 또한 팀 프로젝트에서 공유 상태 객체를 여러 모듈이 참조할 때, 한 모듈의 수정이 다른 모듈에 예기치 않은 부작용(side effect)을 일으키는 원인도 대부분 참조 복사에 대한 이해 부족에서 비롯됩니다. 이 개념을 확실히 익혀두면 깊은 복사(deep copy)와 얕은 복사(shallow copy)를 언제 어떻게 사용해야 하는지 판단할 수 있게 되고, 더 안전하고 예측 가능한 코드를 작성할 수 있습니다.
핵심 개념
원시 타입과 값 저장
입문
원시 타입은 마치 공책에 숫자나 글자를 직접 써넣는 것과 같아요. 변수 안에 값 자체가 쏙 들어가 있답니다!
📦 원시 타입이란 무엇인가요? 원시 타입은 JavaScript에서 가장 기본이 되는 데이터 종류예요. 숫자(42), 글자(“안녕”), 참/거짓(true/false), null, undefined, Symbol, BigInt가 여기에 속해요. 마치 동전처럼 그 자체가 바로 값이에요.
🗃️ 변수 안에 무엇이 들어있나요? 원시 타입을 변수에 담으면 그 변수 안에 값 자체가 직접 들어가요. 예를 들어 나이를 나타내는 변수에 16이라고 저장하면, 그 변수 상자 안에 숫자 16이 실제로 들어있는 거예요. 마치 지갑 속에 현금 만원권을 직접 넣어두는 것과 같아요.
🔒 원시 타입은 변경이 안 되나요? 원시 타입은 한번 만들어지면 그 값 자체를 바꿀 수 없어요. 이걸 ‘불변(immutable)‘이라고 해요. 변수에 새 값을 넣는 건 가능하지만, 기존 값 자체가 바뀌는 게 아니라 새로운 값으로 교체되는 거예요. 예를 들어 1이라는 숫자가 갑자기 2로 변하는 게 아니라, 변수가 1을 가리키다가 2를 가리키도록 바뀌는 거죠.
💡 원시 타입에는 어떤 종류가 있나요? JavaScript에는 7가지 원시 타입이 있어요: 숫자(Number), 문자열(String), 불리언(Boolean, true나 false), null(아무것도 없음), undefined(아직 값이 없음), Symbol(유일한 식별자), BigInt(아주 큰 숫자). 이 7가지가 전부예요!
중급
원시 타입(Primitive Type)은 JavaScript에서 가장 기본적인 데이터 유형으로, 변수에 값 자체가 직접 저장됩니다. ECMAScript 명세에서 정의하는 7가지 원시 타입은 number, string, boolean, null, undefined, symbol, bigint입니다.
원시 타입의 핵심 특성은 **불변성(immutability)**입니다. 원시 값은 한번 생성되면 수정할 수 없습니다. 변수에 새 값을 할당하면 기존 값이 변경되는 것이 아니라 변수가 새 값을 참조하게 됩니다.
let a = 42;
let b = a; // a의 값(42)이 b에 복사됨
b = 100; // b를 변경해도
console.log(a); // 42 - a는 그대로
// 문자열도 동일하게 동작
let str1 = "hello";
let str2 = str1;
str2 = "world";
console.log(str1); // "hello" - 영향 없음
스택(Stack) 메모리와 원시 타입 원시 타입 값은 일반적으로 스택(Stack) 메모리에 저장됩니다. 스택은 빠르게 접근할 수 있고 고정된 크기를 갖는 메모리 영역으로, 함수 호출과 지역 변수 관리에 사용됩니다. 원시 타입은 크기가 고정되어 있어 스택에 바로 저장하기 적합합니다.
심화
ECMAScript 명세에서 원시 타입은 언어 타입(Language Type)의 하위 집합으로 정의되며, 각 타입은 고유한 표현 방식과 연산 의미론을 가집니다.
ECMAScript 명세의 원시 타입 정의 (Section 6) ECMAScript 2023 명세 Section 6 (ECMAScript Data Types and Values)에 따르면, 원시 타입(Primitive Type)은 Object 타입을 제외한 모든 ECMAScript 언어 타입을 의미합니다. 각 원시 값은 명세에서 다음과 같이 처리됩니다:
- Number: IEEE 754-2019 배정밀도(double-precision) 64비트 부동소수점 형식으로 표현. NaN, +Infinity, -Infinity 포함
- String: UTF-16 코드 단위(code unit)의 시퀀스. 인덱스는 0부터 시작하며 최대 길이는 2^53 - 1
- BigInt: 임의 정밀도(arbitrary precision) 정수. MV(Mathematical Value) 추상 연산으로 변환
- Symbol: 고유하고 불변한 원시 값. [[Description]] 내부 슬롯을 가짐
불변성과 인터닝(Interning) 최적화 원시 타입의 불변성은 V8, SpiderMonkey 등 현대 JS 엔진에서 중요한 최적화 기반이 됩니다.
문자열 인터닝(String Interning): V8 엔진은 동일한 문자열 리터럴을 하나의 메모리 위치에 저장하고 재사용합니다(String Pool). "hello" === "hello"가 항상 true인 이유는 동일한 인터닝된 객체를 가리키기 때문입니다.
소형 정수 캐싱(Small Integer Caching): V8에서 -2^31 ~ 2^31-1 범위의 정수(Smi, Small Integer)는 포인터 태깅(Pointer Tagging) 기법으로 별도 힙 할당 없이 즉시값(Immediate Value)으로 처리됩니다. 이로 인해 작은 정수 연산은 힙 할당 없이 O(1) 복잡도로 처리됩니다.
참조 타입과 메모리 주소
입문
참조 타입은 마치 지도에 보물이 있는 장소를 표시해두는 것과 같아요. 변수 안에는 값이 아니라 값이 있는 ‘장소’가 들어있어요!
🗺️ 참조 타입은 무엇인가요? 참조 타입은 객체({}), 배열([]), 함수처럼 여러 데이터를 담을 수 있는 복잡한 값들이에요. 이 값들은 너무 크고 복잡해서 변수 상자에 직접 들어갈 수 없어요. 그래서 실제 데이터는 다른 큰 창고(힙 메모리)에 보관해두고, 변수 상자에는 그 창고의 주소만 넣어둬요.
📍 주소란 무엇인가요? 컴퓨터 메모리는 수많은 칸으로 나뉘어져 있고 각 칸마다 고유한 번호(주소)가 있어요. 참조 타입을 만들면 컴퓨터는 큰 창고의 빈 칸에 데이터를 저장하고, 그 칸의 번호(주소)를 변수에 알려줘요. 변수는 “내 데이터는 0x001A번 칸에 있어요”라고 기억하는 셈이죠.
🏪 편의점 냉장고에 비유하면? 편의점 냉장고를 생각해보세요. 음료수 캔이 어디 있는지 직접 가져오는 게 아니라, 점원이 “3번 냉장고 2번째 칸에 있어요”라고 말해주는 거예요. 변수는 그 위치 정보를 갖고 있고, 실제 음료수(데이터)는 냉장고(힙 메모리) 안에 있는 거예요.
❓ 왜 이렇게 복잡하게 하나요? 객체나 배열은 크기가 얼마나 될지 미리 알 수 없어요. 친구 목록이 10명일 수도, 1000명일 수도 있잖아요. 그래서 크기가 유동적인 데이터는 변수 상자에 직접 넣지 않고, 크기에 맞게 공간을 조절할 수 있는 큰 창고에 보관하는 거예요.
중급
참조 타입(Reference Type)은 객체(Object), 배열(Array), 함수(Function)를 포함하며, 변수에 값 자체가 아닌 해당 값이 저장된 **메모리 주소(참조, Reference)**가 저장됩니다.
참조 타입의 실제 데이터는 힙(Heap) 메모리에 저장됩니다. 힙은 동적으로 크기가 결정되는 데이터를 보관하는 메모리 영역으로, 가비지 컬렉터(Garbage Collector)에 의해 관리됩니다.
const user = { name: "Alice", age: 25 };
// user 변수에는 객체 자체가 아닌 메모리 주소가 저장됨
// 실제 { name: "Alice", age: 25 } 데이터는 힙에 존재
const nums = [1, 2, 3];
// nums에는 배열의 메모리 주소가 저장됨
// 같은 내용의 객체도 서로 다른 메모리 주소를 가짐
const obj1 = { x: 1 };
const obj2 = { x: 1 };
console.log(obj1 === obj2); // false - 서로 다른 주소
참조 타입의 종류 ECMAScript에서 Object 타입은 단일 타입이지만, 여러 내장 객체로 분류됩니다: 일반 객체(Plain Object), 배열(Array), 함수(Function), Date, RegExp, Map, Set, WeakMap, WeakSet 등. 이들 모두 힙 메모리에 저장되고 참조를 통해 접근합니다.
function printUser(obj) {
// 매개변수 obj는 user 객체와 같은 주소를 가리킴
console.log(obj.name);
}
const user = { name: "Bob" };
printUser(user); // "Bob" - 참조를 통해 힙의 데이터에 접근
심화
ECMAScript 명세에서 Object 타입은 원시 타입과 달리 프로퍼티(Property)의 컬렉션으로 정의되며, 각 프로퍼티는 데이터 프로퍼티(Data Property) 또는 접근자 프로퍼티(Accessor Property)로 분류됩니다.
ECMAScript 명세의 Object 타입 정의 (Section 6.1.7) ECMAScript 2023 명세 Section 6.1.7에 따르면, Object는 프로퍼티(Property)들의 집합으로 정의됩니다. 각 프로퍼티는 키(String 또는 Symbol)와 프로퍼티 디스크립터(Property Descriptor)로 구성됩니다.
참조(Reference)는 명세 Section 6.2.5 (The Reference Record Specification Type)에서 정의됩니다. Reference Record는 다음 필드를 포함합니다:
- [[Base]]: 참조의 기반 값 (Object, Undefined, Boolean, String, Symbol, Number, BigInt, 또는 Environment Record)
- [[ReferencedName]]: 프로퍼티 이름
- [[Strict]]: strict mode 여부
- [[ThisValue]]: super 참조 시 사용
V8 엔진의 힙 메모리 구조와 Hidden Class V8 엔진에서 힙(Heap) 메모리는 여러 영역으로 나뉩니다. 새로 생성된 객체는 Young Generation(신세대)의 New Space에 할당되고, 생존한 객체는 Old Generation(구세대)으로 승격됩니다.
Hidden Class (내부적으로 Map이라 불림): V8은 동일한 구조의 객체들에 대해 Hidden Class를 공유하여 프로퍼티 접근을 최적화합니다. { x: 1, y: 2 } 형태의 객체들은 같은 Hidden Class를 공유하고, Inline Cache(IC)를 통해 프로퍼티 접근이 O(1)에 처리됩니다.
포인터 태깅(Pointer Tagging): V8에서 힙 객체에 대한 포인터는 LSB(Least Significant Bit)를 1로 설정하여 즉시 정수(Smi)와 구분합니다. 이를 통해 타입 체크 없이 값의 종류를 빠르게 판별합니다.
값 복사와 참조 복사
입문
원시 타입을 복사하면 진짜 복사본이 만들어지지만, 참조 타입을 복사하면 같은 것을 가리키는 화살표가 하나 더 생길 뿐이에요!
📋 원시 타입 복사는 어떻게 되나요? 원시 타입을 다른 변수에 담으면 완전한 복사본이 만들어져요. 마치 시험지를 복사기로 복사하면 원본과 똑같은 새 종이가 생기는 것처럼요. 이후 복사본에 뭔가를 써도 원본에는 전혀 영향이 없어요.
🔗 참조 타입 복사는 어떻게 되나요? 참조 타입을 복사하면 데이터가 복사되는 게 아니라, 같은 데이터를 가리키는 주소가 복사돼요. 마치 같은 구글 문서 파일의 링크를 친구에게 공유하는 것과 같아요. 친구가 그 문서를 수정하면 나도 똑같이 수정된 걸 보게 되죠.
😱 이게 왜 문제가 되나요? 친구한테 내 공책을 복사해줬더니 친구가 공책 내용을 지웠어요. 그런데 알고 보니 공책을 복사한 게 아니라 같은 공책을 함께 쓰고 있었던 거예요! 참조 타입도 마찬가지예요. 복사했다고 생각했는데 사실 같은 객체를 공유하고 있어서, 한 쪽에서 수정하면 다른 쪽도 바뀌어버려요.
🛡️ 어떻게 진짜 복사를 만드나요? 참조 타입을 진짜로 복사하려면 특별한 방법을 써야 해요. 스프레드 연산자(…) 나 Object.assign()으로 새 객체를 만들거나, JSON으로 변환했다가 다시 되돌리는 방법 등이 있어요. 이렇게 하면 새로운 독립적인 복사본이 만들어져요.
중급
변수를 다른 변수에 할당할 때, 원시 타입은 **값 복사(Value Copy)**가, 참조 타입은 **참조 복사(Reference Copy)**가 일어납니다. 이 차이가 JavaScript에서 가장 많은 버그를 발생시키는 원인 중 하나입니다.
// 값 복사 (원시 타입)
let num1 = 10;
let num2 = num1; // num1의 값(10)이 복사됨
num2 = 20;
console.log(num1); // 10 - 독립적인 복사본
// 참조 복사 (참조 타입)
let obj1 = { name: "Alice" };
let obj2 = obj1; // 같은 메모리 주소가 복사됨
obj2.name = "Bob";
console.log(obj1.name); // "Bob" - 같은 객체를 공유!
함수 인자 전달에서의 참조 복사 함수에 참조 타입을 인자로 전달할 때도 참조 복사가 일어납니다. 함수 내부에서 객체를 수정하면 원본이 변경됩니다.
function addItem(arr, item) {
arr.push(item); // 원본 배열 수정!
}
const fruits = ["apple", "banana"];
addItem(fruits, "cherry");
console.log(fruits); // ["apple", "banana", "cherry"] - 원본이 변경됨
// 해결: 새 배열 반환
function addItemSafe(arr, item) {
return [...arr, item]; // 새 배열 생성
}
얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy) 얕은 복사는 최상위 프로퍼티만 새로운 객체로 만들고, 중첩된 객체는 여전히 참조를 공유합니다. 깊은 복사는 모든 중첩 구조를 새로 만들어 완전히 독립적인 복사본을 생성합니다.
const original = { a: 1, nested: { b: 2 } };
// 얕은 복사
const shallow = { ...original };
shallow.a = 99; // 독립적
shallow.nested.b = 99; // 원본의 nested도 변경!
console.log(original.a); // 1 (독립)
console.log(original.nested.b); // 99 (공유됨)
// 깊은 복사
const deep = JSON.parse(JSON.stringify(original));
deep.nested.b = 42;
console.log(original.nested.b); // 99 (영향 없음)
심화
값 복사(Value Copy)와 참조 복사(Reference Copy)는 ECMAScript 명세에서 각각 추상 연산 CopyDataProperties와 참조 전달의 의미론에 기반하며, 실제 구현에서는 엔진의 메모리 관리 전략과 밀접하게 연관됩니다.
ECMAScript 명세의 할당 의미론 (Section 13.15)
ECMAScript 2023 명세 Section 13.15 (Assignment Operators)에 따르면, 할당 연산 a = b는 PutValue 추상 연산을 호출합니다. 원시 타입의 경우 값 자체가 복사되지만, Object 타입의 경우 객체에 대한 참조(Reference)가 전달됩니다. 명세에서 “pass by value”와 “pass by reference”를 직접 언급하지 않지만, 이 동작은 언어 타입(Language Type)의 정의에서 파생됩니다.
구조적 공유(Structural Sharing)와 불변 데이터 패턴 현대 JavaScript 생태계에서는 참조 복사의 위험을 관리하기 위해 불변성(Immutability) 패턴이 광범위하게 사용됩니다.
Object.freeze()와 그 한계: Object.freeze()는 얕은(shallow) 동결만 수행합니다. ECMAScript 명세 Section 20.1.2.16에 따르면 freeze는 객체의 직접 프로퍼티(own property)만 수정 불가로 만들며, 중첩 객체에는 영향을 주지 않습니다.
Immer.js의 구조적 공유: Immer는 Proxy를 이용해 변경된 노드만 새 객체로 만들고 변경되지 않은 노드는 참조를 공유하는 구조적 공유(Structural Sharing) 기법을 구현합니다. 이는 O(n) 전체 복사 대신 O(log n) 부분 복사로 성능을 최적화합니다.
V8의 Copy-on-Write(COW) 최적화
V8 엔진은 배열 리터럴에 대해 Copy-on-Write(쓰기 시 복사) 최적화를 적용합니다. const arr = [1, 2, 3]처럼 상수 배열은 초기에 읽기 전용 공유 저장소를 사용하다가 수정 시도가 있을 때 비로소 복사본을 만들어냅니다. 이는 불필요한 메모리 할당을 줄이는 중요한 최적화입니다.
동등 비교의 차이
입문
원시 타입은 값이 같으면 같다고 나오지만, 참조 타입은 값이 같아도 다른 물건이면 다르다고 나와요. 마치 완전히 똑같이 생긴 쌍둥이도 다른 사람인 것처럼요!
⚖️ 원시 타입 비교는 어떻게 하나요? 원시 타입을 비교할 때는 값 자체를 비교해요. 5 === 5는 당연히 참이고, “안녕” === “안녕”도 참이에요. 내용이 같으면 같다고 판단해요. 마치 두 저울에 똑같은 무게의 물건을 올려놓으면 균형이 맞는 것처럼요.
🪪 참조 타입 비교는 어떻게 하나요? 참조 타입을 비교할 때는 값이 아니라 ‘주소’를 비교해요. 두 객체의 내용이 완전히 똑같아도, 서로 다른 메모리 위치에 저장되어 있다면 다르다고 판단해요. 마치 똑같이 생긴 쌍둥이도 주민등록번호가 다른 것처럼, 다른 사람(다른 객체)으로 취급받아요.
😮 그럼 언제 같다고 나오나요? 두 변수가 정말 같은 객체(같은 메모리 주소)를 가리킬 때만 같다고 나와요. 한 변수를 다른 변수에 그냥 대입했을 때처럼, 두 변수가 똑같은 주소를 갖고 있을 때요. 이건 복사본이 아니라 진짜 같은 물건을 둘 다 가리키고 있는 상황이에요.
🔍 내용이 같은지 어떻게 비교하나요? 객체의 내용이 같은지 확인하려면 특별한 방법이 필요해요. JSON으로 변환해서 문자열로 비교하거나, 프로퍼티 하나씩 직접 비교하는 방법을 써요. 아니면 Lodash 라이브러리의 _.isEqual() 같은 도구를 이용하면 편리하게 비교할 수 있어요.
중급
동등 비교 연산자(===)는 원시 타입과 참조 타입에서 서로 다른 방식으로 동작합니다.
- 원시 타입: 값 자체를 비교합니다
- 참조 타입: 메모리 주소(참조)를 비교합니다
// 원시 타입 - 값 비교
console.log(5 === 5); // true
console.log("hello" === "hello"); // true
console.log(true === true); // true
// 참조 타입 - 주소 비교
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3]; // 내용은 같지만 다른 객체
console.log(arr1 === arr2); // false - 다른 메모리 주소
const obj1 = { x: 1 };
const obj2 = obj1; // 같은 주소를 공유
console.log(obj1 === obj2); // true - 같은 메모리 주소
객체의 내용(값) 비교 방법 객체의 내용이 동일한지 비교하려면 직접 구현하거나 라이브러리를 활용해야 합니다. 얕은 비교(Shallow Equality)는 최상위 프로퍼티만 비교하고, 깊은 비교(Deep Equality)는 중첩된 모든 프로퍼티를 재귀적으로 비교합니다.
const a = { name: "Alice", score: 100 };
const b = { name: "Alice", score: 100 };
// 방법 1: JSON 직렬화 (간단하지만 함수/undefined 처리 불가)
console.log(JSON.stringify(a) === JSON.stringify(b)); // true
// 방법 2: 수동 얕은 비교
function shallowEqual(obj1, obj2) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
return keys1.every(key => obj1[key] === obj2[key]);
}
console.log(shallowEqual(a, b)); // true
심화
ECMAScript 명세의 추상 동등 비교(Abstract Equality Comparison)와 엄격 동등 비교(Strict Equality Comparison)는 원시 타입과 참조 타입에 대해 근본적으로 다른 의미론을 적용합니다.
ECMAScript 명세의 엄격 동등 비교 (Section 7.2.16)
ECMAScript 2023 명세 Section 7.2.16 (IsStrictlyEqual)에 따르면, === 연산의 Object 타입에 대한 비교는 SameObjectValue 추상 연산을 사용합니다. 두 Object 값이 동일한 객체를 참조할 때만 true를 반환합니다. 이는 객체 동일성(Object Identity) 검사로, 값 동등성(Value Equality)과 구분됩니다.
React의 참조 동등성과 재렌더링 최적화
React는 상태 업데이트 시 참조 동등성(Reference Equality, Object.is() 사용)을 기반으로 리렌더링 여부를 결정합니다. ECMAScript 명세 Section 7.2.13 (SameValueZero)과 관련된 Object.is()는 ===와 달리 NaN === NaN을 true로, +0 === -0을 false로 처리합니다.
이 동작은 React의 불변성 요구사항의 근거가 됩니다. useState의 setter에 기존 상태 객체의 변경된 참조를 전달하면, React는 참조가 동일하다고 판단하여 리렌더링을 건너뜁니다. 따라서 상태 업데이트 시 반드시 새 객체/배열을 생성해야 합니다.
SameValue와 Object.is()의 엔진 구현
V8에서 Object.is()는 C++ 수준의 포인터 비교로 구현됩니다. 힙에 할당된 두 객체의 포인터(주소)를 직접 비교하므로 O(1) 시간 복잡도를 보장합니다. 반면 깊은 비교(Deep Equality)는 구조의 크기에 따라 O(n) 복잡도를 가지며, React의 reconciliation 성능에 직접적인 영향을 줍니다.