LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLAB Coreteam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사, Map/Set의 메모리 감각

2026년 6월 28일·12분 읽기

오늘의 문제

js
const user = { name: "Jingyu", profile: { level: 1 } };
const copied = { ...user };

copied.name = "SEOJing";
copied.profile.level = 2;

const cache = new Map();
cache.set(user, "cached");

console.log(user.name);
console.log(user.profile.level);
console.log(cache.get(copied));

정답은 Jingyu, 2, undefined입니다. 최상위 name은 새 객체에서만 바뀌었지만, profile은 원본과 복사본이 같은 내부 객체를 보고 있습니다. copied도 user와 내용은 비슷해 보여도 Map의 key로는 다른 객체입니다.

오늘은 “객체를 복사했다”를 더 잘게 읽습니다. 새 껍데기만 만든 것인지, 내부 객체까지 새로 만든 것인지, 컬렉션이 어떤 참조를 붙잡고 있는지까지 봐야 프론트엔드 버그가 보입니다.

이 글의 원천 앵커

자바스크립트 퀴즈북의 객체·컬렉션 문제를 그대로 요약하면 “참조 타입은 조심하자” 정도로 끝나기 쉽습니다. 실제 코드 리뷰에서는 그 말이 너무 넓습니다. 이번 글은 아래 네 질문으로 좁혀 읽습니다.

대입과 spread가 새 값을 만든 것인지, 새 참조 이름만 만든 것인지? 얕은 복사 때문에 어느 내부 객체가 공유되는지? Object, Map, Set이 key를 어떤 기준으로 구분하는지? Map/Set 캐시가 객체 수명을 불필요하게 늘리고 있지 않은지?

Day 4가 “객체 안의 프로퍼티가 어떤 규칙으로 붙어 있는가”를 봤다면, Day 5는 “그 객체를 누가 같이 보고 있는가”를 봅니다. Day 6 이후의 스코프·클로저 글과도 연결됩니다. 클로저가 객체 참조를 닫아두면, 얕은 복사 버그는 시간차를 두고 더 늦게 터집니다.

참조와 컬렉션을 한 장으로 보기

객체 참조와 Map, Set, WeakMap의 참조 구조

객체 대입은 값을 하나 더 만드는 게 아니라 같은 객체를 가리키는 이름을 하나 더 만드는 일에 가깝습니다.

js
const a = { count: 1 };
const b = a;
b.count = 2;

console.log(a.count); // 2

spread도 안심하면 안 됩니다. `, Object.assign`, 배열 spread는 보통 얕은 복사입니다.

js
const original = { settings: { theme: "dark" } };
const next = { ...original };
next.settings.theme = "light";

console.log(original.settings.theme); // "light"

여기서 next는 새 객체가 맞습니다. 하지만 settings는 새 객체가 아닙니다. 프론트엔드 상태 업데이트에서 이 차이를 놓치면 “렌더는 됐는데 이전 스냅샷도 같이 바뀐 것처럼 보이는” 버그가 납니다. 특히 undo stack, optimistic update, query cache, form draft처럼 과거 상태를 따로 보관하는 코드에서 흔합니다.

깊은 복사를 무조건 답으로 외우는 것도 위험합니다.

js
const copied = structuredClone(original);

structuredClone은 많은 경우 유용하지만 함수, DOM node, class instance, library object 같은 값을 앱 의도대로 복원해주지는 않습니다. 리뷰 질문은 “깊게 복사했나?”가 아니라 “어느 경로가 바뀌며, 그 경로를 새 객체로 만들어야 하는가?”입니다. 상태 업데이트라면 바뀌는 경로만 새로 만들고, 나머지는 공유해도 되는 경우가 많습니다.

리뷰 질문은 이렇습니다.

질문확인할 것자주 생기는 버그
같은 객체인가?a === b한쪽 수정이 다른 UI 상태까지 바꿈
얕은 복사인가?nested object 공유 여부spread 후 내부 필드 수정이 원본에 반영됨
컬렉션이 붙잡는가?Map/Set의 강한 참조캐시가 객체를 계속 잡아 메모리 누수

Object, Map, Set을 구분해서 쓰기

일반 객체를 key-value 저장소처럼 쓸 수는 있지만, key는 기본적으로 문자열 또는 Symbol 중심입니다.

js
const a = { id: 1 };
const b = { id: 2 };
const store = {};

store[a] = "A";
store[b] = "B";

console.log(store); // { "[object Object]": "B" }

객체 자체를 key로 구분해야 하면 Map이 더 솔직합니다.

js
const store = new Map();
store.set(a, "A");
store.set(b, "B");

Set은 “중복 없는 배열”보다 “존재 여부 모델”로 읽는 편이 좋습니다. 다만 객체를 넣을 때는 내용 비교가 아니라 참조 비교입니다.

js
new Set([{ id: 1 }, { id: 1 }]).size; // 2

id 기준 중복 제거가 목적이면 객체를 그대로 Set에 넣는 게 아니라 id를 key로 삼아야 합니다.

Map/Set의 비교는 Day 2의 equality 글과도 이어집니다. Set은 NaN을 하나로 보고, 0과 -0도 같은 값으로 봅니다. 객체는 내용이 아니라 참조로 봅니다. 이 규칙을 섞어서 생각하면 중복 제거 코드가 애매해집니다.

js
const ids = new Set([NaN, NaN, 0, -0]);
console.log(ids.size); // 2

const users = new Set([{ id: 1 }, { id: 1 }]);
console.log(users.size); // 2

첫 줄의 NaN 두 개는 같은 값처럼 정리됩니다. 마지막 줄의 객체 두 개는 모양이 같아도 서로 다른 참조입니다. 그래서 Set을 볼 때는 “중복이 없다”보다 “무엇을 같은 것으로 볼 것인가”를 먼저 물어야 합니다.

js
const usersById = new Map(usersFromApi.map((user) => [user.id, user]));
const uniqueUsers = [...usersById.values()];

이 코드는 id를 중복 기준으로 삼겠다는 의도를 드러냅니다. 순서 정책도 함께 드러납니다. 같은 id가 여러 번 나오면 뒤에 온 user가 앞의 값을 덮습니다. 이게 맞는지, 첫 값을 유지해야 하는지까지 리뷰해야 합니다.

WeakMap은 캐시의 수명 문제를 드러낸다

Map과 Set은 객체를 강하게 참조합니다. 로컬 변수에서 놓아도 컬렉션이 key나 value로 붙잡고 있으면 객체가 바로 사라졌다고 말할 수 없습니다.

js
const metadata = new WeakMap();

function attachMetadata(element, data) {
  metadata.set(element, data);
}

DOM element나 임시 객체별 metadata처럼 “객체가 사라지면 부가 정보도 사라져야 하는” 경우에는 WeakMap이 후보입니다. 대신 key를 순회할 수 없습니다. 순회가 필요하면 WeakMap이 아니라 명시적인 cleanup 설계를 먼저 봐야 합니다.

WeakSet도 같은 방향의 도구입니다. “이 객체를 이미 처리했는가” 같은 표시를 붙이되, 객체가 사라질 때 표시도 같이 사라져도 되는 경우에 어울립니다.

js
const visited = new WeakSet();

function hydrateNode(node) {
  if (visited.has(node)) return;
  visited.add(node);
  attachListeners(node);
}

다만 Weak 계열은 디버깅을 어렵게 만들 수 있습니다. 전체 목록을 뽑을 수 없고, 언제 GC가 일어나는지도 코드가 결정하지 않습니다. 그래서 “메모리 누수가 걱정되니 WeakMap을 쓰자”에서 멈추지 말고, 관찰 가능성이 필요한 캐시인지부터 나눠야 합니다.

필요한 것더 자연스러운 선택
객체별 부가 정보, 전체 순회 불필요WeakMap
이미 처리한 객체 표시WeakSet
전체 key 목록, 수동 무효화 필요Map + cleanup
id 기준 중복 제거Map/plain object에 명시적인 id key 사용

이 표는 API 선택표라기보다 수명 선택표입니다. 프론트엔드에서는 “데이터 구조가 편한가”보다 “이 구조가 객체를 얼마나 오래 붙잡는가”가 더 중요한 순간이 있습니다.

코드 리뷰 체크포인트

상태 업데이트가 얕은 복사에 기대고 있지 않은가? 바뀐 경로만 새 객체로 만들었는가, 아니면 필요 이상으로 전체를 깊은 복사하고 있지 않은가? Object를 임의 key 컬렉션처럼 쓰고 있지 않은가? Set으로 객체 구조 중복 제거를 기대하고 있지 않은가? id 기준 dedupe라면 “첫 값 유지”와 “마지막 값 덮어쓰기” 정책이 코드에 보이는가? 캐시가 객체나 DOM node를 계속 붙잡고 있지 않은가? WeakMap을 쓰면서 key 목록 순회를 요구하고 있지 않은가?

AI가 만든 코드에서 자주 보이는 실수

AI가 객체 복사 코드를 만들 때 가장 자주 하는 실수는 “spread를 썼으니 안전하다”는 전제입니다. 예를 들어 React state를 갱신하면서 최상위 객체만 복사하고 nested object를 직접 바꾸는 코드가 자주 나옵니다.

js
setUser((prev) => {
  const next = { ...prev };
  next.profile.level += 1;
  return next;
});

이 코드는 next라는 새 객체를 반환하므로 겉보기에는 immutable update처럼 보입니다. 하지만 profile은 여전히 이전 객체와 공유됩니다. 다른 memoized selector, undo stack, optimistic cache가 같은 nested 객체를 들고 있다면 예상보다 넓은 범위가 같이 바뀔 수 있습니다.

더 안전한 갱신은 바뀌는 경로를 따라 새 객체를 만듭니다.

js
setUser((prev) => ({
  ...prev,
  profile: {
    ...prev.profile,
    level: prev.profile.level + 1,
  },
}));

두 번째 실수는 Set을 구조적 중복 제거 도구처럼 쓰는 겁니다.

js
const uniqueUsers = [...new Set(users)];

users가 같은 객체 참조를 중복으로 담고 있다면 동작합니다. 하지만 API에서 온 `` 객체가 매번 새로 만들어진다면 내용이 같아도 제거되지 않습니다. id 기준 중복 제거가 목적이면 기준을 코드에 드러내야 합니다.

js
const uniqueUsers = [...new Map(users.map((user) => [user.id, user])).values()];

세 번째 실수는 Map 캐시의 수명을 닫지 않는 겁니다. 컴포넌트, DOM node, request object를 key로 캐싱하면서 cleanup이 없다면 캐시가 오래 살아남습니다. WeakMap은 “key가 사라지면 metadata도 같이 사라져도 되는” 상황에 맞고, 순회·디버깅·명시적 무효화가 필요하면 Map과 cleanup 정책을 같이 설계해야 합니다.

네 번째 실수는 성능을 이유로 참조 공유를 너무 빨리 정당화하는 겁니다. “복사하면 느리다”는 말은 맞을 때도 있지만, 바뀌는 경로가 작다면 구조적 공유가 더 읽기 쉽습니다. 반대로 매번 전체 객체를 JSON.parse(JSON.stringify(...))로 복사하면 Date, undefined, 함수, class instance 같은 값이 깨질 수 있습니다. AI가 이런 코드를 내놓으면 성능보다 먼저 의미 손상을 봐야 합니다.

js
const copied = JSON.parse(JSON.stringify(state));

이 한 줄은 편해 보이지만, 앱 상태가 순수 JSON이라고 보장될 때만 안전합니다. 프론트엔드 상태에는 Date, File, URLSearchParams, Map 같은 값이 섞이기 쉽습니다. 복사 전략은 데이터 모양을 따라가야 합니다.

한 단계 더 보는 리팩터링 예시

아래 코드는 API 응답을 화면용 모델로 정리하면서 원본 객체를 그대로 캐시에 넣습니다.

js
const responseCache = new Map();

function normalizeUser(apiUser) {
  responseCache.set(apiUser, Date.now());

  return {
    id: apiUser.id,
    name: apiUser.name,
    profile: apiUser.profile,
  };
}

겉보기에는 필요한 필드만 뽑은 것 같습니다. 하지만 profile은 여전히 원본 응답 객체 안의 내부 참조입니다. 나중에 화면에서 profile.avatarUrl을 고치면 원본 응답을 재사용하는 다른 코드까지 같이 영향을 받을 수 있습니다. 게다가 responseCache가 apiUser를 key로 잡고 있어 응답 객체 수명도 늘어납니다.

리팩터링 방향은 두 갈래입니다.

js
function normalizeUser(apiUser) {
  return {
    id: apiUser.id,
    name: apiUser.name,
    profile: {
      avatarUrl: apiUser.profile.avatarUrl,
      level: apiUser.profile.level,
    },
  };
}

화면 모델이 원본 응답과 분리돼야 한다면 필요한 경로를 명시적으로 새로 만듭니다. 반대로 원본 객체별 metadata가 꼭 필요하고, 원본 객체가 사라지면 metadata도 사라져도 된다면 WeakMap을 씁니다.

js
const responseMeta = new WeakMap();

function markFetched(apiUser) {
  responseMeta.set(apiUser, { fetchedAt: Date.now() });
}

이렇게 나누면 “복사”와 “캐시”가 한 덩어리로 뭉개지지 않습니다. 복사는 데이터 소유권 문제이고, 캐시는 수명 문제입니다.

연결해서 읽기

Day 2 equality 글과 이어집니다. Map/Set의 객체 key 비교는 내용 비교가 아니라 참조 동일성 쪽에 가깝습니다. Day 4 property descriptor 글과 이어집니다. freeze를 써도 얕은 freeze인지 깊은 freeze인지 구분해야 합니다. Day 6 scope/hoisting 글과 이어집니다. 함수가 객체 참조를 닫아두면, 복사 버그는 콜백 실행 시점에 드러납니다. Effective TypeScript Day 6과도 연결됩니다. API response와 앱 내부 model을 같은 객체로 계속 넘기면 참조 공유 범위가 커집니다.

참고문헌

프런트엔드 레벨을 높이는 자바스크립트 퀴즈북, 객체/컬렉션 관련 장 진규 Notion export: [0225] 객체 마무리, 2 객체 질문 정리 MDN Web Docs: Map, Set, WeakMap, WeakSet

Quiz1 / 5
Q.다음 코드의 출력으로 맞는 것은 무엇일까요?
js
"const a = { nested: { n: 1

Post Q&A

오케이징에게 물어보기

자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사, Map/Set의 메모리 감각 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

0/500

포스트 목록

/study/javascript-quizbook
파일 13개, 폴더 0개
자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을 수 없을까자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네 가지 방법자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와 ToPrimitive 흐름자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티 descriptor와 freeze의 경계자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사, Map/Set의 메모리 감각자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와 Promise 타이밍자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이 정한다자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도 프로토타입을 읽는다자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는 target에서 끝나지 않는다자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다

같은 섹션의 대표 이미지

13 posts · latest first
ESM live binding과 dynamic import, circular dependency 리뷰 포인트를 보여주는 module graph 다이어그램
Study26. 07. 09.

자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.

ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
DOM 이벤트가 capture 단계로 내려가고 bubble 단계로 올라가는 경로 다이어그램
Study26. 07. 08.

자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는.

DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 08.SEOJing
인스턴스에서 프로토타입과 Object.prototype으로 이어지는 프로퍼티 조회 경로 다이어그램
Study26. 07. 07.

자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도.

프로토타입 조회, class 문법, 인스턴스 필드와 prototype 메서드 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 07.SEOJing
입력, 순수 계산, 출력, 부수 효과 경계를 나누어 함수 설계를 보여주는 다이어그램
Study26. 07. 06.

자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다.

고차 함수, 순수 함수, 부수 효과, async 함수의 Promise 반환을 코드 리뷰에서 어떻게 읽을지 정리합니다.

26. 07. 06.SEOJing
메서드 호출, 분리된 함수, 이벤트 콜백, bind와 arrow function의 this 차이를 보여주는 다이어그램
Study26. 07. 05.

자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이.

this 바인딩을 정의 위치가 아니라 호출 형태, 메서드 분리, arrow function, bind 기준으로 읽는 방법을 코드 리뷰 관점에서 정리합니다.

26. 07. 05.SEOJing
콜 스택이 비워진 뒤 마이크로태스크 큐가 먼저 실행되고 다음 태스크로 넘어가는 이벤트 루프 다이어그램
Study26. 07. 04.

자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와.

콜 스택, 태스크 큐, 마이크로태스크 큐를 기준으로 Promise와 setTimeout 실행 순서를 코드 리뷰 관점에서 정리합니다.

26. 07. 04.SEOJing
클로저가 생성 위치의 바인딩을 기억해 나중 실행에서 오래된 값을 읽을 수 있음을 보여주는 다이어그램
Study26. 06. 29.

자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된.

클로저가 값 복사가 아니라 렉시컬 환경 참조라는 점을 stale closure, 루프 콜백, React 이벤트 리뷰 관점에서 정리합니다.

26. 06. 29.SEOJing
스코프 체인과 호이스팅 관계를 단순화한 다이어그램
Study26. 06. 28.

자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅.

스코프 체인, 렉시컬 환경, var/let/const 호이스팅 차이를 콜백·상태 버그 리뷰 관점에서 다시 정리합니다.

26. 06. 28.SEOJing
JavaScript 객체 참조와 Map, Set, WeakMap의 참조 구조를 비교한 다이어그램
Study26. 06. 27.

자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사,.

객체 참조와 얕은 복사, Object와 Map/Set의 차이, WeakMap/WeakSet이 필요한 메모리 상황을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 27.SEOJing
JavaScript 프로퍼티 descriptor가 data descriptor와 accessor descriptor로 갈라지는 구조 다이어그램
Study26. 06. 26.

자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티.

객체 프로퍼티를 key/value가 아니라 descriptor로 읽는 법, getter/setter와 defineProperty, preventExtensions/seal/freeze의 경계를 코드 리뷰 관점에서 정리합니다.

26. 06. 26.SEOJing
객체가 ToPrimitive를 거쳐 + 연산자의 문자열 연결 또는 숫자 덧셈으로 갈라지는 흐름 다이어그램
Study26. 06. 25.

자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와.

객체가 원시값으로 바뀌는 ToPrimitive 흐름, + 연산자의 문자열 연결과 숫자 덧셈 분기, Symbol.toPrimitive가 코드 리뷰에서 왜 중요한지 정리합니다.

26. 06. 25.SEOJing
==, ===, Object.is, SameValueZero의 차이를 정리한 JavaScript equality 다이어그램
Study26. 06. 24.

자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네.

==, ===, Object.is, SameValueZero가 각각 어떤 비교 알고리즘을 쓰는지 정리하고, includes와 indexOf, Map/Set 키 비교에서 생기는 프론트엔드 리뷰 포인트를 잡습니다.

26. 06. 24.SEOJing
JavaScript Number, safe integer, NaN, -0, BigInt를 한 장으로 정리한 다이어그램
Study26. 06. 23.

자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을.

자바스크립트의 Number가 왜 정수처럼 보여도 부동소수점 모델 위에서 움직이는지, NaN과 -0, safe integer, BigInt를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.

26. 06. 23.SEOJing