JavaScript에서 객체는 정말 참조로 전달될까?

객체를 함수에 넘기면 원본이 바뀌더라. 그래서 “참조로 전달된다”고 알고 있는데, 정말 그게 전부일까요?


먼저, 용어부터 정리하기

“값 전달”과 “참조 전달”이라는 용어는 많이 들어봤지만, 정확한 의미를 아는 사람은 의외로 적습니다. 면접에서 “JavaScript는 값 전달인가요, 참조 전달인가요?”라는 질문을 받으면 어떻게 대답하시겠어요? JavaScript의 동작을 제대로 이해하려면 이 개념부터 명확히 정리해야 합니다.

값에 의한 전달 (Pass by Value)

가장 단순한 방식입니다. 함수에 값을 전달하면 복사본이 만들어지고, 원본은 절대 영향을 받지 않습니다.

function changeValue(x) {
  x = 100;  // 매개변수 x에 새 값 할당
  console.log('함수 내부:', x);  // 100
}

let num = 42;
changeValue(num);
console.log('함수 외부:', num);  // 42 - 변하지 않음!

시각화:

changeValue(num) 호출 시:

1. 호출 전
   num ──> [42]

2. 함수 호출 - 값이 복사됨
   num ──> [42]
   x   ──> [42]  (복사본)

3. x = 100 실행
   num ──> [42]  (영향 없음)
   x   ──> [100]

4. 함수 종료 후
   num ──> [42]  (그대로)

원시 타입(number, string, boolean 등)은 값 자체가 복사되어 전달됩니다. 함수 내부에서 아무리 변경해도 원본에는 영향이 없습니다.

참조에 의한 전달 (Pass by Reference)

C++에서 사용하는 진정한 “참조 전달”을 보겠습니다:

// C++ 코드 - JavaScript가 아님!
void changeValue(int& x) {  // & = 참조
  x = 100;
}

int num = 42;
changeValue(num);
// num은 이제 100

시각화:

C++의 참조 전달:

1. 호출 전
   num ──> [42]

2. 함수 호출 - x는 num의 별명(alias)이 됨
   num ──┬──> [42]
   x   ──┘

3. x = 100 실행 - 같은 메모리를 변경
   num ──┬──> [100]
   x   ──┘

JavaScript는 이 방식을 지원하지 않습니다. 그럼 객체는 어떻게 동작하는 걸까요?


JavaScript의 진짜 동작: 값에 의한 참조 전달

그렇다면 JavaScript는 어디에 해당할까요? 답을 먼저 말하면, 둘 다 아닙니다. JavaScript에서 객체를 전달할 때 일어나는 일을 정확히 표현하면 “참조 값이 복사되어 전달된다”입니다. 이를 Pass by Sharing 또는 Call by Sharing이라고 부르기도 합니다. 말로는 어려우니 코드로 확인해봅시다.

객체 전달의 실체

객체를 함수에 넘기면 무슨 일이 일어날까요? 참조(메모리 주소)가 복사되어 전달됩니다. 두 변수가 같은 객체를 가리키게 되는 거죠.

function modifyObject(obj) {
  obj.name = 'Bob';  // 원본 객체 수정됨
}

const person = { name: 'Alice' };
modifyObject(person);
console.log(person.name);  // 'Bob' - 변경됨!

시각화:

modifyObject(person) 호출 시:

1. person 변수 생성
   person ──> { name: 'Alice' }
              [메모리 주소: 0x1234]

2. 함수 호출 - 참조(주소)가 복사됨
   person ──┐
            ├──> { name: 'Alice' }
   obj   ───┘    [0x1234]

3. obj.name = 'Bob' 실행
   person ──┐
            ├──> { name: 'Bob' }  (같은 객체)
   obj   ───┘    [0x1234]

4. 함수 종료 후
   person ──> { name: 'Bob' }

핵심 포인트: objperson과 같은 객체를 가리키는 별도의 변수입니다. 둘 다 같은 객체를 참조하기 때문에, 객체의 속성을 변경하면 원본에도 반영됩니다.

하지만 재할당은 다르다

여기서 핵심 차이가 드러납니다. 속성을 수정하는 것과 변수 자체를 재할당하는 것은 완전히 다른 동작입니다. 진짜 참조 전달이었다면 재할당도 원본에 반영되어야 하지만, JavaScript는 그렇지 않습니다.

function replaceObject(obj) {
  obj = { name: 'Charlie' };  // 새 객체로 재할당
  console.log('함수 내부:', obj.name);  // 'Charlie'
}

const person = { name: 'Alice' };
replaceObject(person);
console.log('함수 외부:', person.name);  // 'Alice' - 변하지 않음!

시각화:

replaceObject(person) 호출 시:

1. 함수 호출 - 참조가 복사됨
   person ──┐
            ├──> { name: 'Alice' }
   obj   ───┘    [0x1234]

2. obj = { name: 'Charlie' } 실행
   person ──> { name: 'Alice' }   [0x1234]
   obj   ──> { name: 'Charlie' }  [0x5678] (새 객체)

3. 함수 종료 후
   person ──> { name: 'Alice' }  (영향 없음)

진짜 참조 전달이었다면 person도 새 객체를 가리켜야 합니다. 하지만 JavaScript에서는 obj라는 복사된 참조만 새 객체를 가리키게 되고, 원본 person은 그대로입니다.


이 차이가 왜 중요한가?

“속성 수정은 원본에 영향, 재할당은 영향 없음” - 이 규칙을 모르면 실무에서 예상치 못한 버그를 만나게 됩니다. 특히 함수에 객체를 넘기고 내부에서 조작할 때 자주 발생하는 함정들을 살펴봅시다.

함정 1: 객체 초기화 실패

함수 매개변수로 받은 변수에 새 객체를 할당해서 초기화하려는 시도입니다. 재할당은 원본에 영향을 주지 않기 때문에, 이 방식은 동작하지 않습니다.

// ❌ 의도대로 동작하지 않음
function initUser(user) {
  user = {
    name: 'Guest',
    role: 'visitor'
  };
}

const currentUser = null;
initUser(currentUser);
console.log(currentUser);  // null - 초기화되지 않음!
// ✅ 올바른 방법 1: 반환값 사용
function createUser() {
  return {
    name: 'Guest',
    role: 'visitor'
  };
}

const currentUser = createUser();
console.log(currentUser);  // { name: 'Guest', role: 'visitor' }
// ✅ 올바른 방법 2: 객체 속성 수정
function initUser(user) {
  user.name = 'Guest';
  user.role = 'visitor';
}

const currentUser = {};  // 먼저 빈 객체 생성
initUser(currentUser);
console.log(currentUser);  // { name: 'Guest', role: 'visitor' }

함정 2: 배열도 객체다

배열은 특별해 보이지만, JavaScript에서는 객체입니다. 똑같은 규칙이 적용됩니다. push, splice 같은 메서드는 원본을 수정하고, 재할당은 원본에 영향을 주지 않습니다.

function addItem(arr) {
  arr.push('new item');  // 원본 배열 수정됨
}

const items = ['a', 'b', 'c'];
addItem(items);
console.log(items);  // ['a', 'b', 'c', 'new item']
function replaceArray(arr) {
  arr = ['x', 'y', 'z'];  // 재할당 - 원본에 영향 없음
}

const items = ['a', 'b', 'c'];
replaceArray(items);
console.log(items);  // ['a', 'b', 'c'] - 그대로!

함정 3: 콜백에서의 객체 참조

비동기 콜백이 실행될 때 참조하는 객체는 콜백이 등록된 시점의 객체가 아니라 실행되는 시점의 객체입니다. 그 사이에 객체가 수정되었다면, 콜백은 수정된 값을 보게 됩니다.

const config = { debug: false };

// 나중에 실행되는 콜백
setTimeout(() => {
  console.log('debug mode:', config.debug);
}, 1000);

// 콜백 등록 후 설정 변경
config.debug = true;

// 1초 후 출력: "debug mode: true"
// config 객체가 참조로 공유되기 때문

원본을 보호하는 방법

함정을 알았으니, 이제 해결책을 알아볼 차례입니다. 의도치 않은 변경을 막으려면 어떻게 해야 할까요? 상황에 따라 여러 방법이 있습니다.

방법 1: 얕은 복사 (Shallow Copy)

가장 간단한 방법입니다. 스프레드 연산자(...)나 Object.assign()으로 객체의 1단계 속성만 복사합니다. 대부분의 경우 이것으로 충분합니다.

function processUser(user) {
  // 얕은 복사로 새 객체 생성
  const copy = { ...user };
  copy.processed = true;
  return copy;
}

const original = { name: 'Alice', age: 30 };
const result = processUser(original);

console.log(original);  // { name: 'Alice', age: 30 } - 변경 없음
console.log(result);    // { name: 'Alice', age: 30, processed: true }

주의: 중첩 객체는 여전히 참조를 공유합니다.

const original = {
  name: 'Alice',
  address: { city: 'Seoul' }  // 중첩 객체
};

const copy = { ...original };
copy.address.city = 'Busan';

console.log(original.address.city);  // 'Busan' - 원본도 변경됨!

시각화:

얕은 복사 후:

original ──> {
               name: 'Alice',
               address ──┬──> { city: 'Busan' }  (공유!)
             }           │
                         │
copy ──────> {           │
               name: 'Alice',
               address ──┘
             }

방법 2: 깊은 복사 (Deep Copy)

중첩된 객체까지 모두 새로 만들어야 할 때 사용합니다. structuredClone()은 ES2022에서 추가된 표준 메서드로, 중첩 객체, Date, Map, Set 등도 제대로 복사합니다.

// structuredClone() - 모던 브라우저 지원
const original = {
  name: 'Alice',
  address: { city: 'Seoul' }
};

const deepCopy = structuredClone(original);
deepCopy.address.city = 'Busan';

console.log(original.address.city);  // 'Seoul' - 원본 보존!
console.log(deepCopy.address.city);  // 'Busan'
// JSON 방식 (함수, undefined 등은 복사 안됨)
const deepCopy = JSON.parse(JSON.stringify(original));

시각화:

깊은 복사 후:

original ──> {
               name: 'Alice',
               address ──> { city: 'Seoul' }
             }

deepCopy ──> {
               name: 'Alice',
               address ──> { city: 'Busan' }  (별도 객체)
             }

방법 3: 불변 패턴 적용

복사보다 근본적인 해결책입니다. 애초에 원본을 수정하는 메서드 대신 새 배열/객체를 반환하는 메서드를 사용하는 습관을 들이면 됩니다.

// 배열: 원본 수정 메서드 vs 새 배열 반환 메서드

// ❌ 원본 수정
const arr = [1, 2, 3];
arr.push(4);      // arr이 변경됨
arr.splice(0, 1); // arr이 변경됨
arr.sort();       // arr이 변경됨

// ✅ 새 배열 반환 (원본 보존)
const arr = [1, 2, 3];
const added = [...arr, 4];           // arr 그대로
const removed = arr.filter((_, i) => i !== 0);  // arr 그대로
const sorted = [...arr].sort();       // arr 그대로

실전에서 왜 중요한가?

이론을 알았으니, 실제로 이 개념이 어디서 쓰이는지 살펴봅시다. React나 상태 관리 라이브러리를 사용해 본 적 있다면 “불변성”이라는 말을 들어봤을 겁니다.

React에서의 상태 업데이트

React는 상태 변경을 감지할 때 참조 비교를 사용합니다. 같은 객체를 수정하면 React는 변경을 감지하지 못합니다.

// ❌ React가 변경을 감지하지 못함
function handleClick() {
  user.name = 'Bob';  // 같은 객체 수정
  setUser(user);      // 참조가 같으므로 리렌더링 안됨
}

// ✅ 새 객체를 만들어야 함
function handleClick() {
  setUser({ ...user, name: 'Bob' });  // 새 객체 → 리렌더링
}

Redux의 불변성 원칙

Redux 공식 문서에서는 리듀서에서 상태를 직접 수정하지 말라고 강조합니다. 이유가 뭘까요?

// ❌ 상태 직접 수정 - 시간 여행 디버깅 불가능
function reducer(state, action) {
  state.items.push(action.payload);  // 원본 수정
  return state;  // 같은 참조 반환
}

// ✅ 새 상태 반환
function reducer(state, action) {
  return {
    ...state,
    items: [...state.items, action.payload]  // 새 배열
  };
}

Redux DevTools의 “시간 여행” 기능은 각 액션 시점의 상태를 저장해둡니다. 만약 상태를 직접 수정하면, 모든 시점이 같은 객체를 참조하게 되어 과거 상태를 볼 수 없습니다.

비유: 문서 편집

이해를 돕기 위해 비유를 들어볼게요.

  • 참조 공유 = 같은 Google Docs 문서를 여러 명이 편집
    • 누군가 수정하면 모두에게 즉시 반영됨
  • 복사본 생성 = 문서를 복사해서 각자 편집
    • 내 복사본을 수정해도 원본은 그대로
참조 공유:
┌─────────────┐
│  원본 문서   │ ← A, B, C 모두 같은 문서 편집
└─────────────┘

복사본 생성:
┌─────────────┐     ┌─────────────┐
│  원본 문서   │     │   A의 복사   │
└─────────────┘     └─────────────┘
                    ┌─────────────┐
                    │   B의 복사   │
                    └─────────────┘

정리: 핵심 개념

배운 내용을 한눈에 정리해봅시다.

┌─────────────────────────────────────────────────────────────┐
│                JavaScript의 전달 방식                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  원시 타입 (number, string, boolean, null, undefined, symbol, bigint)
│  ─────────                                                  │
│  • 값 자체가 복사됨                                          │
│  • 함수 내부 변경 → 원본 영향 없음                            │
│                                                             │
│  객체 타입 (object, array, function)                        │
│  ─────────                                                  │
│  • 참조(주소)가 복사됨                                       │
│  • 속성 변경 → 원본 영향 있음                                │
│  • 재할당 → 원본 영향 없음                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
작업 원본에 영향? 이유
obj.prop = value O 같은 객체의 속성 수정
obj = newObj X 복사된 참조만 변경
arr.push(item) O 같은 배열 수정
arr = [...arr, item] X 새 배열 생성

참고 자료

댓글