JavaScript를 학습하다 보면 함수에서 매개변수가 어떻게 전달되는지에 대해 혼란을 겪는 경우가 많습니다. 특히 객체를 다룰 때 예상과 다른 결과가 나와서 당황하게 됩니다. 오늘은 JavaScript의 매개변수 전달 방식을 완전히 이해하고, 실무에서 활용할 수 있는 지식을 얻어보겠습니다.
시작하기 전에: 문제 상황 분석
먼저 다음 코드를 살펴보겠습니다.
function change(a, b, c) {
a = 'a changed'
b = { b: 'changed' };
c.c = 'changed';
}
let a = 'a unchanged';
let b = { b: 'unchanged' };
let c = { c: 'unchanged' };
change(a, b, c);
console.log(a, b, c); // 결과는?
이 코드의 실행 결과를 예측해보세요. 정답은 다음과 같습니다:
// 출력: "a unchanged" {b: 'unchanged'} {c: 'changed'}
왜 이런 결과가 나올까요? 이를 이해하기 위해서는 JavaScript의 매개변수 전달 방식을 깊이 있게 알아야 합니다.
JavaScript의 매개변수 전달 방식: Call by Value
기본 원리
JavaScript는 Call by Value 방식으로 매개변수를 전달합니다. 이는 함수 호출 시 값의 복사본이 전달된다는 의미입니다. 하지만 여기서 중요한 것은 "값"이 무엇인지 정확히 이해하는 것입니다.
원시 타입과 참조 타입의 차이
타입 | 저장되는 값 | 함수 전달 시 복사되는 것 |
---|---|---|
원시 타입 | 실제 데이터 값 | 데이터 값 자체 |
참조 타입 | 메모리 주소(참조) | 메모리 주소 값 |
// 원시 타입: 값 자체가 저장
let num = 42; // 변수에 42라는 값이 직접 저장
let str = "hello"; // 변수에 "hello"라는 값이 직접 저장
// 참조 타입: 메모리 주소가 저장
let obj = { x: 1 }; // 변수에는 객체가 있는 메모리 주소가 저장
let arr = [1, 2, 3]; // 변수에는 배열이 있는 메모리 주소가 저장
상세 분석: 각 케이스별 동작 원리
1. 원시 타입 (문자열, 숫자, 불린 등)
function changeString(str) {
str = 'changed'; // 새로운 값 할당
console.log('함수 내부:', str); // "changed"
}
let originalString = 'original';
changeString(originalString);
console.log('함수 외부:', originalString); // "original" (변경되지 않음)
메모리 관점에서 보기:
함수 호출 전:
originalString → "original"
함수 호출 시:
originalString → "original"
str (복사본) → "original"
함수 내부에서 str 변경 후:
originalString → "original" (변경 없음)
str (복사본) → "changed" (지역 변수만 변경)
2. 참조 타입 - 새로운 객체 할당
function changeObject(obj) {
obj = { newProperty: 'new value' }; // 새로운 객체 할당
console.log('함수 내부:', obj); // {newProperty: 'new value'}
}
let originalObject = { oldProperty: 'old value' };
changeObject(originalObject);
console.log('함수 외부:', originalObject); // {oldProperty: 'old value'} (변경되지 않음)
메모리 관점에서 보기:
함수 호출 전:
originalObject → 주소A → {oldProperty: 'old value'}
함수 호출 시:
originalObject → 주소A → {oldProperty: 'old value'}
obj (복사본) → 주소A → {oldProperty: 'old value'}
함수 내부에서 obj에 새 객체 할당 후:
originalObject → 주소A → {oldProperty: 'old value'} (변경 없음)
obj (복사본) → 주소B → {newProperty: 'new value'} (새로운 주소 참조)
3. 참조 타입 - 객체 속성 변경
function modifyObject(obj) {
obj.property = 'modified'; // 기존 객체의 속성 변경
console.log('함수 내부:', obj); // {property: 'modified'}
}
let originalObject = { property: 'original' };
modifyObject(originalObject);
console.log('함수 외부:', originalObject); // {property: 'modified'} (변경됨!)
메모리 관점에서 보기:
함수 호출 전:
originalObject → 주소A → {property: 'original'}
함수 호출 시:
originalObject → 주소A → {property: 'original'}
obj (복사본) → 주소A → {property: 'original'}
함수 내부에서 객체 속성 변경 후:
originalObject → 주소A → {property: 'modified'} (같은 객체가 변경됨)
obj (복사본) → 주소A → {property: 'modified'} (같은 객체 참조)
다양한 데이터 타입별 동작 예시
배열 조작
// 배열 재할당 vs 배열 요소 변경
function arrayOperations(arr1, arr2) {
// 새로운 배열 할당 - 원본에 영향 없음
arr1 = [10, 20, 30];
// 기존 배열의 요소 변경 - 원본에 영향 있음
arr2.push(4);
arr2[0] = 100;
}
let array1 = [1, 2, 3];
let array2 = [1, 2, 3];
arrayOperations(array1, array2);
console.log('array1:', array1); // [1, 2, 3] (변경 없음)
console.log('array2:', array2); // [100, 2, 3, 4] (변경됨)
중첩 객체
function modifyNestedObject(obj) {
// 최상위 속성 변경
obj.level1 = 'changed';
// 중첩 객체 속성 변경
obj.nested.level2 = 'also changed';
// 중첩 객체 자체 교체
obj.anotherNested = { newProp: 'new' };
}
let complexObject = {
level1: 'original',
nested: { level2: 'original nested' },
anotherNested: { oldProp: 'old' }
};
modifyNestedObject(complexObject);
console.log(complexObject);
// {
// level1: 'changed',
// nested: { level2: 'also changed' },
// anotherNested: { newProp: 'new' }
// }
함수도 참조 타입
function modifyFunction(fn) {
// 함수에 속성 추가 (함수도 객체이므로 가능)
fn.customProperty = 'added property';
// 함수 자체를 새로운 함수로 교체 (원본에 영향 없음)
fn = function() { console.log('new function'); };
}
function originalFunction() {
console.log('original function');
}
modifyFunction(originalFunction);
console.log(originalFunction.customProperty); // "added property"
originalFunction(); // "original function" (함수는 변경되지 않음)
실무에서의 주의사항과 해결책
1. 의도치 않은 객체 변경 방지
// ❌ 문제가 될 수 있는 코드
function processUserData(user) {
user.isProcessed = true; // 원본 객체를 변경
user.processedAt = new Date();
// 다른 로직들...
return user;
}
const originalUser = { name: 'John', age: 30 };
const processedUser = processUserData(originalUser);
console.log(originalUser); // {name: 'John', age: 30, isProcessed: true, processedAt: ...}
// 원본 객체가 의도치 않게 변경됨!
해결책 1: 객체 복사
// ✅ 얕은 복사 (Shallow Copy)
function processUserData(user) {
const userCopy = { ...user }; // 스프레드 연산자 사용
// 또는: const userCopy = Object.assign({}, user);
userCopy.isProcessed = true;
userCopy.processedAt = new Date();
return userCopy;
}
// ✅ 깊은 복사 (Deep Copy) - 중첩 객체가 있는 경우
function processUserDataDeep(user) {
const userCopy = JSON.parse(JSON.stringify(user)); // 간단한 방법
// 또는 Lodash의 cloneDeep 사용
userCopy.isProcessed = true;
userCopy.processedAt = new Date();
return userCopy;
}
// ✅ structuredClone (최신 브라우저)
function processUserDataModern(user) {
const userCopy = structuredClone(user);
userCopy.isProcessed = true;
userCopy.processedAt = new Date();
return userCopy;
}
해결책 2: 불변성 라이브러리 사용
// Immer 라이브러리 사용 예시
import { produce } from 'immer';
function processUserData(user) {
return produce(user, draft => {
draft.isProcessed = true;
draft.processedAt = new Date();
});
}
2. 배열 조작 시 주의사항
// ❌ 원본 배열을 변경하는 메서드들
function processArray(arr) {
arr.push('new item'); // 원본 변경
arr.sort(); // 원본 변경
arr.reverse(); // 원본 변경
return arr;
}
// ✅ 새로운 배열을 반환하는 메서드들 사용
function processArraySafe(arr) {
return [...arr, 'new item'] // 스프레드로 복사 후 추가
.slice() // 복사본 생성
.sort() // 복사본 정렬
.reverse(); // 복사본 뒤집기
}
// ✅ 함수형 프로그래밍 스타일
function processArrayFunctional(arr) {
return arr
.concat(['new item']) // 새 배열 반환
.map(item => item) // 변형이 필요한 경우
.filter(item => item) // 필터링이 필요한 경우
.slice() // 복사본 생성
.sort()
.reverse();
}
3. 함수 매개변수 기본값과 구조 분해
// ✅ 기본값과 구조 분해를 활용한 안전한 함수
function updateUser(user = {}, updates = {}) {
// 구조 분해와 기본값을 활용하여 안전하게 처리
const {
name = 'Anonymous',
age = 0,
email = '',
...otherProps
} = user;
return {
name,
age,
email,
...otherProps,
...updates,
updatedAt: new Date()
};
}
// 사용 예시
const user = { name: 'John', age: 30 };
const updatedUser = updateUser(user, { age: 31 });
console.log(user); // 원본: {name: 'John', age: 30}
console.log(updatedUser); // 새 객체: {name: 'John', age: 31, updatedAt: ...}
성능 고려사항
얕은 복사 vs 깊은 복사 성능 비교
// 성능 테스트 함수
function performanceTest() {
const largeObject = {
data: new Array(10000).fill(0).map((_, i) => ({ id: i, value: `item-${i}` })),
metadata: { created: new Date(), version: 1 }
};
console.time('Shallow Copy');
for (let i = 0; i < 1000; i++) {
const copy = { ...largeObject };
}
console.timeEnd('Shallow Copy');
console.time('Deep Copy (JSON)');
for (let i = 0; i < 1000; i++) {
const copy = JSON.parse(JSON.stringify(largeObject));
}
console.timeEnd('Deep Copy (JSON)');
console.time('structuredClone');
for (let i = 0; i < 1000; i++) {
const copy = structuredClone(largeObject);
}
console.timeEnd('structuredClone');
}
// 실행 결과 예시:
// Shallow Copy: 0.5ms
// Deep Copy (JSON): 45ms
// structuredClone: 15ms
메모리 효율적인 접근 방법
// ✅ 필요한 부분만 복사
function updateUserEfficient(user, updates) {
// 변경이 필요한 속성만 복사
const needsUpdate = Object.keys(updates).some(key => user[key] !== updates[key]);
if (!needsUpdate) {
return user; // 변경이 없으면 원본 반환
}
return { ...user, ...updates };
}
// ✅ 지연 복사 (Lazy Copy)
function createLazyCopy(original) {
let copied = false;
let copy = original;
return new Proxy(original, {
set(target, property, value) {
if (!copied) {
copy = { ...original };
copied = true;
}
copy[property] = value;
return true;
},
get(target, property) {
return copied ? copy[property] : target[property];
}
});
}
다른 언어와의 비교
Java와의 비교
// Java에서의 매개변수 전달
public class ParameterPassing {
public static void modifyPrimitive(int num) {
num = 100; // 원본에 영향 없음
}
public static void modifyObject(List<String> list) {
list.add("new item"); // 원본에 영향 있음
list = new ArrayList<>(); // 원본에 영향 없음
}
}
Python과의 비교
# Python에서의 매개변수 전달
def modify_data(num, lst, dct):
num = 100 # 원본에 영향 없음 (불변 타입)
lst.append("new") # 원본에 영향 있음 (가변 타입)
dct["key"] = "new" # 원본에 영향 있음 (가변 타입)
lst = [] # 원본에 영향 없음 (재할당)
dct = {} # 원본에 영향 없음 (재할당)
공통점: 대부분의 언어에서 매개변수 전달 방식은 유사합니다.
React에서의 실무 활용
State 업데이트 시 주의사항
// ❌ 잘못된 State 업데이트
function UserProfile() {
const [user, setUser] = useState({ name: 'John', preferences: {} });
const updatePreferences = (newPrefs) => {
user.preferences = { ...user.preferences, ...newPrefs }; // 직접 변경!
setUser(user); // React가 변경을 감지하지 못함
};
// ...
}
// ✅ 올바른 State 업데이트
function UserProfile() {
const [user, setUser] = useState({ name: 'John', preferences: {} });
const updatePreferences = (newPrefs) => {
setUser(prevUser => ({
...prevUser,
preferences: { ...prevUser.preferences, ...newPrefs }
}));
};
// 또는 useCallback과 함께 사용
const updatePreferencesCallback = useCallback((newPrefs) => {
setUser(prevUser => ({
...prevUser,
preferences: { ...prevUser.preferences, ...newPrefs }
}));
}, []);
// ...
}
useReducer에서의 불변성
// Reducer에서 불변성 유지
function userReducer(state, action) {
switch (action.type) {
case 'UPDATE_PROFILE':
return {
...state,
profile: { ...state.profile, ...action.payload }
};
case 'ADD_SKILL':
return {
...state,
skills: [...state.skills, action.payload]
};
case 'REMOVE_SKILL':
return {
...state,
skills: state.skills.filter(skill => skill.id !== action.payload.id)
};
default:
return state;
}
}
디버깅 팁과 도구
1. 객체 변경 추적
// 객체 변경 감지 유틸리티
function createChangeTracker(obj, name = 'object') {
return new Proxy(obj, {
set(target, property, value) {
console.log(`${name}.${String(property)} changed from`, target[property], 'to', value);
target[property] = value;
return true;
},
deleteProperty(target, property) {
console.log(`${name}.${String(property)} deleted`);
delete target[property];
return true;
}
});
}
// 사용 예시
const trackedUser = createChangeTracker({ name: 'John', age: 30 }, 'user');
trackedUser.age = 31; // "user.age changed from 30 to 31"
2. 함수 호출 추적
// 함수 매개변수 변경 추적
function trackParameterChanges(fn, fnName) {
return function(...args) {
const originalArgs = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : arg
);
console.log(`${fnName} called with:`, originalArgs);
const result = fn.apply(this, args);
const modifiedArgs = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : arg
);
console.log(`${fnName} args after execution:`, modifiedArgs);
return result;
};
}
// 사용 예시
const trackedFunction = trackParameterChanges(
function modifyData(obj) {
obj.modified = true;
},
'modifyData'
);
베스트 프랙티스 요약
DO (권장사항)
// ✅ 함수 매개변수를 변경하지 말고 새로운 값 반환
function processData(data) {
return { ...data, processed: true };
}
// ✅ 배열 메서드 체이닝 시 불변성 유지
function transformArray(arr) {
return arr
.filter(item => item.active)
.map(item => ({ ...item, transformed: true }))
.sort((a, b) => a.priority - b.priority);
}
// ✅ 명확한 함수명과 문서화
/**
* 사용자 데이터를 업데이트합니다 (원본 객체는 변경되지 않음)
* @param {Object} user - 원본 사용자 객체
* @param {Object} updates - 업데이트할 속성들
* @returns {Object} 새로운 사용자 객체
*/
function updateUser(user, updates) {
return { ...user, ...updates, updatedAt: new Date() };
}
DON'T (피해야 할 것들)
// ❌ 매개변수 직접 변경
function badProcessData(data) {
data.processed = true; // 원본 변경
return data;
}
// ❌ 배열 원본 변경 메서드 사용
function badTransformArray(arr) {
arr.push(newItem); // 원본 변경
arr.sort(); // 원본 변경
return arr;
}
// ❌ 예측하기 어려운 부수 효과
function confusingFunction(obj) {
obj.someProperty = 'changed'; // 예상치 못한 변경
// 다른 로직들...
return 'some result';
}
결론
JavaScript의 매개변수 전달 방식을 이해하는 것은 예측 가능하고 안전한 코드를 작성하는 데 필수적입니다.
핵심 포인트 요약:
- 모든 값은 복사되어 전달되지만, 객체의 경우 참조 값이 복사됨
- 원시 타입: 함수 내 변경이 원본에 영향 없음
- 참조 타입: 속성 변경은 원본에 영향, 재할당은 영향 없음
- 불변성 유지가 버그 예방과 코드 예측성에 중요
실무에서는 의도치 않은 객체 변경을 방지하기 위해 객체 복사, 불변성 라이브러리, 함수형 프로그래밍 패턴을 적극 활용하세요. 특히 React와 같은 라이브러리에서는 불변성이 성능 최적화와 직결되므로 더욱 중요합니다.
마치 요리할 때 원본 재료를 보존하면서 새로운 요리를 만드는 것처럼, 코드에서도 원본 데이터를 보존하면서 필요한 변경사항을 적용하는 습관을 기르는 것이 중요합니다.
'Frontend Development' 카테고리의 다른 글
CSS 쌓임 맥락의 모든 것: Z-Index가 작동하지 않는 이유 (1) | 2025.07.03 |
---|---|
Next.js Server Action: 서버와 클라이언트를 연결하는 새로운 방식 (0) | 2025.07.02 |
CSS 위치 조정: Transform vs Position, 언제 무엇을 써야 할까? (2) | 2025.06.27 |
CORS 없이 SOP 우회하기: 프록시 서버를 활용한 스마트한 해결책 (0) | 2025.06.24 |
JavaScript 프로토타입 상속: 객체 간 상속의 핵심 메커니즘 완전 정복 (2) | 2025.06.20 |