Thursday, July 13, 2023

자바스크립트 객체 프로퍼티 추가 및 수정 심층 분석

자바스크립트에서 객체(Object)는 언어의 핵심을 이루는 가장 중요한 데이터 구조입니다. 원시 타입을 제외한 거의 모든 것이 객체로 취급되며, 개발자는 끊임없이 객체를 생성하고, 수정하며, 확장하는 작업을 수행합니다. 객체는 키(key)와 값(value)의 쌍으로 이루어진 프로퍼티(property)들의 동적인 컬렉션으로, 이러한 프로퍼티를 어떻게 효율적으로 다루는지는 코드의 품질과 직결됩니다.

많은 개발자들이 자바스크립트 객체를 단순히 'JSON과 유사한 것' 또는 '맵(Map)과 비슷한 것'으로 간주하곤 합니다. 하지만 이는 정확한 이해가 아닙니다. JSON(JavaScript Object Notation)은 데이터를 교환하기 위한 경량의 문자열 형식이며, 자바스크립트 객체 리터럴({})에서 파생되었지만 둘은 엄연히 다릅니다. 또한, ES6에서 도입된 Map 객체는 키-값 쌍을 저장한다는 점은 같지만, 키로 다양한 타입을 허용하고 순서를 보장하는 등 고유한 특징과 메서드를 가진 별개의 자료구조입니다. 이 글에서는 순수한 자바스크립트 일반 객체를 중심으로, 프로퍼티를 추가하고 수정하는 다양한 기법들을 기초부터 심화까지 면밀히 분석하고 각 방법의 장단점과 적절한 사용 시나리오를 제시합니다.


1. 가장 기본적인 접근: 직접 할당 (Direct Assignment)

객체 프로퍼티를 추가하는 가장 직관적이고 기본적인 방법은 객체에 직접 값을 할당하는 것입니다. 자바스크립트는 이를 위해 두 가지 표기법을 제공합니다: 점 표기법(Dot Notation)대괄호 표기법(Bracket Notation)입니다. 이 두 방식은 유사해 보이지만, 결정적인 차이점을 가지며 상황에 따라 선택적으로 사용해야 합니다.

점 표기법 (Dot Notation)

점 표기법은 가장 흔하게 사용되는 방식으로, 객체 이름 뒤에 점(.)을 찍고 프로퍼티 이름을 명시하여 값을 할당하거나 접근합니다. 코드가 간결하고 가독성이 높다는 장점이 있습니다.


const person = {
  name: 'Alice'
};

// 'age' 프로퍼티 추가
person.age = 30;

// 'email' 프로퍼티 추가
person.email = 'alice@example.com';

console.log(person);
// 출력: { name: 'Alice', age: 30, email: 'alice@example.com' }

하지만 점 표기법에는 명확한 한계가 존재합니다. 프로퍼티 이름(키)이 반드시 유효한 자바스크립트 식별자(Identifier) 규칙을 따라야 합니다.

  • 문자, 밑줄(_), 또는 달러 기호($)로 시작해야 합니다.
  • 두 번째 글자부터는 숫자도 포함할 수 있습니다.
  • 공백이나 하이픈(-), 예약어 등은 사용할 수 없습니다.

만약 이러한 규칙을 어기는 프로퍼티 이름을 사용하려고 하면 구문 오류(SyntaxError)가 발생합니다.


const car = {};

// 유효한 식별자
car.model_name = 'Model S'; // (O)

// 유효하지 않은 식별자 - SyntaxError 발생
// car.top-speed = 300; // 하이픈(-)은 뺄셈 연산자로 해석됨
// car.1st_owner = 'Bob'; // 숫자로 시작할 수 없음

대괄호 표기법 (Bracket Notation)

대괄호 표기법은 점 표기법의 한계를 극복하는 강력한 대안입니다. 이 방식은 프로퍼티 이름을 문자열로 평가하여 사용하기 때문에, 자바스크립트 식별자 규칙에 얽매이지 않고 어떤 문자열이든 키로 사용할 수 있습니다.


const settings = {};

// 공백이 포함된 프로퍼티 이름
settings['user interface theme'] = 'dark';

// 하이픈이 포함된 프로퍼티 이름
settings['font-size'] = 16;

// 숫자로 시작하는 프로퍼티 이름
settings['1st-priority'] = 'performance';

console.log(settings);
// 출력: {
//   'user interface theme': 'dark',
//   'font-size': 16,
//   '1st-priority': 'performance'
// }

대괄호 표기법의 진정한 힘은 프로퍼티 이름을 동적으로 생성할 때 드러납니다. 변수나 표현식의 결과를 프로퍼티 키로 사용할 수 있기 때문입니다. 이는 런타임에 키가 결정되는 상황에서 필수적입니다.


const dataStore = {};
const keyName = 'user_id';
const value = 12345;

// 변수 `keyName`의 값인 'user_id'를 프로퍼티 키로 사용
dataStore[keyName] = value;

console.log(dataStore); // 출력: { user_id: 12345 }

// 함수와 반복문을 이용한 동적 프로퍼티 추가
function addDynamicProperties(obj, propArray) {
  for (let i = 0; i < propArray.length; i++) {
    const propName = `property_${i + 1}`;
    obj[propName] = propArray[i];
  }
}

const myObject = {};
addDynamicProperties(myObject, ['A', 'B', 'C']);

console.log(myObject);
// 출력: { property_1: 'A', property_2: 'B', property_3: 'C' }

위 예시처럼, 변수나 표현식을 사용해 프로퍼티 이름을 동적으로 결정하는 것은 점 표기법으로는 불가능합니다. 따라서 서버로부터 받은 데이터의 키를 기반으로 객체를 구성하거나, 사용자의 입력에 따라 객체의 구조를 변경해야 할 때 대괄호 표기법은 لا غنى عنه인 도구입니다.

핵심: 직접 할당 방식은 기존 객체를 직접 수정(mutate)합니다. 즉, 원본 객체 자체가 변경됩니다. 이는 간단한 스크립트에서는 문제가 되지 않지만, 객체의 불변성(immutability)이 중요한 React나 Vue와 같은 프레임워크 환경에서는 의도치 않은 부작용(side effects)을 유발할 수 있으므로 주의해야 합니다.


2. 여러 프로퍼티의 병합: Object.assign()

ES6(ECMAScript 2015)에서 도입된 Object.assign() 메서드는 하나 이상의 출처(source) 객체로부터 대상(target) 객체로 프로퍼티를 복사하는 데 사용됩니다. 이를 이용해 여러 프로퍼티를 한 번에 추가하거나 객체들을 병합할 수 있습니다.

메서드의 시그니처는 다음과 같습니다: Object.assign(target, ...sources)

  • target: 프로퍼티를 복사하여 붙여넣을 대상 객체. 이 객체는 직접 수정됩니다.
  • sources: 프로퍼티를 복사해 올 출처 객체들. 여러 개를 전달할 수 있습니다.

Object.assign()은 출처 객체들의 열거 가능한(enumerable) 자체(own) 프로퍼티만을 대상 객체에 복사합니다. 상속받은 프로퍼티나 열거 불가능한 프로퍼티는 복사되지 않습니다.

기존 객체에 프로퍼티 추가 (Mutable Pattern)

기존 객체를 target으로 지정하면, 해당 객체에 새로운 프로퍼티들이 추가되거나 덮어씌워집니다.


const user = {
  name: 'John Doe',
  age: 30
};

const details = {
  city: 'New York',
  age: 31 // 'user' 객체의 'age' 프로퍼티와 충돌
};

// user 객체에 details 객체의 프로퍼티를 병합
// user 객체가 직접 변경됨
Object.assign(user, details);

console.log(user);
// 출력: { name: 'John Doe', age: 31, city: 'New York' }

위 예제에서 볼 수 있듯, 동일한 키('age')가 존재할 경우, 뒤따라오는 출처 객체(details)의 값이 대상 객체(user)의 기존 값을 덮어씁니다. 이 방식은 명시적으로 원본 객체를 변경하고자 할 때 유용합니다.

새로운 객체 생성 (Immutable Pattern)

원본 객체들을 변경하지 않고, 모든 프로퍼티가 병합된 새로운 객체를 만들고 싶다면, target 인자로 빈 객체({})를 전달하면 됩니다. 이는 불변성을 유지하는 매우 일반적인 패턴입니다.


const defaults = {
  theme: 'light',
  fontSize: 14,
  showToolbar: true
};

const userSettings = {
  fontSize: 16,
  theme: 'dark'
};

// 빈 객체를 타겟으로 하여, 원본 객체들을 변경하지 않고 새로운 객체를 생성
const finalSettings = Object.assign({}, defaults, userSettings);

console.log(finalSettings);
// 출력: { theme: 'dark', fontSize: 16, showToolbar: true }

console.log(defaults);
// 출력: { theme: 'light', fontSize: 14, showToolbar: true } (원본 불변)
console.log(userSettings);
// 출력: { fontSize: 16, theme: 'dark' } (원본 불변)

얕은 복사(Shallow Copy)의 함정

Object.assign()을 사용할 때 반드시 이해해야 할 중요한 개념은 이 메서드가 얕은 복사(Shallow Copy)를 수행한다는 점입니다. 만약 프로퍼티의 값이 객체나 배열과 같은 참조 타입이라면, 값 자체가 복사되는 것이 아니라 메모리 주소(참조)만 복사됩니다.

이로 인해 원본 객체와 복사된 객체가 내부의 중첩된 객체를 공유하게 되며, 한쪽에서 이 중첩된 객체를 변경하면 다른 쪽에도 영향을 미치는 부작용이 발생할 수 있습니다.


const original = {
  id: 1,
  meta: {
    author: 'Admin',
    tags: ['js', 'object']
  }
};

const clone = Object.assign({}, original);

// clone의 중첩 객체 프로퍼티를 수정
clone.meta.author = 'Editor';
clone.meta.tags.push('es6');

console.log(original.meta.author); // 출력: 'Editor'
console.log(original.meta.tags); // 출력: ['js', 'object', 'es6']

위 코드에서 clone 객체의 meta.author를 변경했는데, 원본인 original 객체의 값까지 함께 변경되었습니다. 이는 original.metaclone.meta가 동일한 객체를 가리키고 있기 때문입니다. 이러한 동작을 피하고 싶다면, 중첩된 구조까지 모두 복사하는 깊은 복사(Deep Copy)를 수행해야 하며, 이는 Lodash 라이브러리의 _.cloneDeep() 함수를 사용하거나 직접 재귀 함수를 구현하여 해결해야 합니다.


3. 현대적이고 간결한 방식: 전개 구문 (Spread Syntax)

ES2018(ES9)부터 객체 리터럴에서도 전개 구문(...)을 사용할 수 있게 되었습니다. 이는 Object.assign()을 이용한 불변적 병합 패턴을 훨씬 더 간결하고 가독성 높게 표현할 수 있게 해주는 현대적인 방식입니다.

전개 구문은 항상 새로운 객체를 생성하며, 기존 객체를 펼쳐서 그 프로퍼티들을 새로운 객체 리터럴 안에 포함시킵니다.


const obj1 = { a: 1, b: 2 };
const obj2 = { b: 3, c: 4 };

// obj1과 obj2의 프로퍼티를 펼쳐서 새로운 객체를 생성
const combinedObj = { ...obj1, ...obj2 };

console.log(combinedObj);
// 출력: { a: 1, b: 3, c: 4 }

프로퍼티 덮어쓰기 규칙은 Object.assign()과 동일합니다. 나중에 위치한 객체의 프로퍼티가 이전에 위치한 객체의 동일한 프로퍼티를 덮어씁니다. 전개 구문의 큰 장점 중 하나는 기존 프로퍼티를 병합하면서 동시에 새로운 프로퍼티를 쉽게 추가할 수 있다는 점입니다.


const baseUser = {
  id: 'user01',
  name: 'Jane'
};

const userWithRole = {
  ...baseUser,
  role: 'admin', // 새로운 프로퍼티 추가
  name: 'Jane Smith' // 기존 프로퍼티 덮어쓰기
};

console.log(userWithRole);
// 출력: { id: 'user01', name: 'Jane Smith', role: 'admin' }

console.log(baseUser);
// 출력: { id: 'user01', name: 'Jane' } (원본 불변)

이러한 간결함과 가독성 덕분에, React와 같은 상태 관리 라이브러리에서 이전 상태를 기반으로 새로운 상태를 만들 때 전개 구문은 거의 표준처럼 사용됩니다.


// React 상태 업데이트 예시
// this.setState(prevState => ({
//   user: {
//     ...prevState.user,
//     isLoggedIn: true
//   }
// }));

전개 구문과 얕은 복사

중요한 점은 전개 구문 역시 Object.assign()과 마찬가지로 얕은 복사를 수행한다는 것입니다. 따라서 중첩된 객체를 다룰 때는 동일한 주의가 필요합니다.


const original = {
  id: 1,
  meta: { author: 'Admin' }
};

const clone = { ...original };

clone.meta.author = 'Editor';

console.log(original.meta.author); // 출력: 'Editor' (원본 객체가 변경됨)

전개 구문은 문법적 설탕(syntactic sugar)에 가까우며, 내부 동작 원리는 Object.assign()과 매우 유사합니다. 따라서 문법의 편리함에 가려져 얕은 복사의 특성을 잊어서는 안 됩니다.


4. 정교한 제어의 세계: Object.defineProperty()

지금까지 소개된 방법들은 단순히 값을 할당하는 것에 초점이 맞춰져 있습니다. 하지만 때로는 프로퍼티의 동작 자체를 세밀하게 제어하고 싶을 때가 있습니다. 예를 들어, 읽기 전용(read-only) 프로퍼티를 만들거나, 특정 프로퍼티가 for...in 루프에서 보이지 않게 숨기거나, 삭제할 수 없도록 만들고 싶을 수 있습니다. 이때 사용하는 것이 바로 Object.defineProperty() 메서드입니다.

이 메서드는 객체에 직접 프로퍼티를 정의하거나 이미 존재하는 프로퍼티의 속성을 수정합니다. 이 때 프로퍼티의 동작을 기술하는 프로퍼티 서술자(Property Descriptor) 객체를 사용합니다.

프로퍼티 서술자에는 두 가지 유형이 있으며, 주요 키들은 다음과 같습니다.

  • 데이터 서술자 (Data Descriptor)
    • value: 프로퍼티의 값.
    • writable: true일 경우 할당 연산자로 값을 수정할 수 있습니다. (기본값: false)
  • 접근자 서술자 (Accessor Descriptor)
    • get: 프로퍼티에 접근할 때 호출되는 함수.
    • set: 프로퍼티에 값을 할당할 때 호출되는 함수.
  • 공통 키
    • enumerable: true일 경우 for...in 루프나 Object.keys()와 같은 열거 작업에 포함됩니다. (기본값: false)
    • configurable: true일 경우 프로퍼티를 삭제하거나 서술자를 다시 수정할 수 있습니다. (기본값: false)

일반적인 할당 방식(obj.prop = value)은 writable, enumerable, configurable이 모두 true인 프로퍼티를 생성하는 것과 같습니다. 반면 Object.defineProperty()는 이 값들의 기본값이 false이므로 훨씬 더 엄격한 제어가 가능합니다.


const user = {};

// 읽기 전용, 열거 가능, 삭제 불가능한 'id' 프로퍼티 정의
Object.defineProperty(user, 'id', {
  value: 'abc-123',
  writable: false,
  enumerable: true,
  configurable: false
});

console.log(user.id); // 출력: 'abc-123'

// 값 변경 시도 (엄격 모드에서는 TypeError 발생, 비엄격 모드에서는 조용히 실패)
try {
  user.id = 'def-456';
} catch(e) {
  console.error(e);
}
console.log(user.id); // 여전히 'abc-123'

// 삭제 시도 (false 반환, 엄격 모드에서는 TypeError)
delete user.id;
console.log(user.id); // 여전히 'abc-123'

// 열거는 가능
console.log(Object.keys(user)); // 출력: ['id']

// 여러 프로퍼티를 한번에 정의할 때는 Object.defineProperties() 사용
Object.defineProperties(user, {
  _name: { // 데이터를 저장할 숨겨진 프로퍼티
    value: 'Chris',
    writable: true,
    enumerable: false
  },
  name: { // 외부에 노출될 접근자 프로퍼티
    get() {
      return this._name;
    },
    set(value) {
      if (value.length > 0) {
        this._name = value;
      } else {
        console.error('이름은 비어 있을 수 없습니다.');
      }
    },
    enumerable: true,
    configurable: true
  }
});

user.name = 'Christopher';
console.log(user.name); // 출력: 'Christopher'

user.name = ''; // 에러 메시지 출력
console.log(user.name); // 여전히 'Christopher'
console.log(Object.keys(user)); // 출력: ['id', 'name'] (_name은 보이지 않음)

Object.defineProperty()는 프레임워크나 라이브러리 내부에서 객체의 상태를 안전하게 관리하거나, 외부에 공개되는 API의 동작을 정밀하게 설계할 때 필수적인 도구입니다. 일반적인 애플리케이션 개발에서는 자주 사용되지 않을 수 있지만, 자바스크립트 객체의 내부 동작을 깊이 이해하는 데 큰 도움이 됩니다.


결론: 상황별 최적의 선택 가이드

자바스크립트에서 객체 프로퍼티를 추가하는 다양한 방법을 살펴보았습니다. 어떤 방법을 선택할지는 코드의 요구사항과 컨텍스트에 따라 달라집니다. 다음은 상황별 권장 가이드입니다.

방법 주요 사용 사례 원본 객체 변경 여부 장점 주의사항
직접 할당
(점/대괄호 표기법)
단일 프로퍼티를 간단히 추가/수정할 때. 동적 키를 사용해야 할 때. 변경 (Mutable) 가장 간단하고 직관적이며 빠름. 불변성이 중요한 환경에서 부작용 유발 가능.
Object.assign() 여러 객체를 병합할 때. ES6 환경에서 불변성을 유지하며 객체를 복사/병합할 때. 불변 패턴 가능 ({} 사용 시) 여러 소스 객체를 한 번에 처리. 명시적. 얕은 복사로 인한 중첩 객체 공유 문제.
전개 구문 (...) React/Vue 등 현대 프레임워크에서의 상태 업데이트. 불변성을 유지하며 객체를 확장/병합할 때. 항상 불변 (Immutable) 간결하고 가독성이 매우 높음. 선언적. 얕은 복사로 인한 중첩 객체 공유 문제.
Object.defineProperty() 읽기 전용, 열거 불가 등 프로퍼티의 동작을 정밀하게 제어해야 할 때. (라이브러리/프레임워크 개발) 변경 (Mutable) 프로퍼티 속성에 대한 완벽한 제어 가능. 코드가 길고 복잡해짐. 일반적인 용도로는 과함.

결론적으로, 현대 자바스크립트 개발에서는 전개 구문(...)이 불변성을 지키면서 객체를 다루는 가장 일반적이고 권장되는 방법입니다. 하지만 동적인 키를 다룰 때는 대괄호 표기법이 필수적이며, 객체의 내부 동작을 깊이 있게 제어해야 하는 특별한 상황에서는 Object.defineProperty()의 존재를 기억하는 것이 중요합니다. 각 방법의 특성과 장단점을 명확히 이해하고 상황에 맞는 최적의 도구를 선택하는 것이 견고하고 유연한 코드를 작성하는 핵심입니다.


0 개의 댓글:

Post a Comment