본문 바로가기

FrontEnd

자바스크립트 참조에 관한 시각적 가이드 [aka 자바스크립트 포인터]

반응형
자바스크립트의 참조에 대해 시각적으로 알아봅니다.
코딩 학습 1일차에 누군가가 이렇게 설명합니다.
"변수는 상자와 같습니다. let thing = 5는 thing 상자에 5를 넣습니다."
변수가 실제로 동작하는 방식은 아니지만 계속 진행하기에 충분합니다.
수학 수업에서 큰 그림에 대해 거짓말을 하는 것과 같습니다.
큰 그림을 바로 보여주면 뇌를 폭발시킬 것이기 때문입니다.

하지만 얼마 후 이상한 문제가 보이기 시작합니다.
변경하지 않았을 때 값이 변경되는 변수.

"나는 그것을 복사했다고 생각했습니다! 왜 값이 변했어요?” <- 참조 버그가 있습니다!
이 게시물이 끝나면 왜 그런 일이 발생하고 해결 방법을 이해하게 될 것입니다.

변수는 상자가 아닙니다. 변수는 상자를 가리키는 이름표 입니다.

참조(Reference)가 뭐죠?

참조는 JS의 어디에나 있고 어디에도 없습니다.
그것들은 단지 변수처럼 보입니다.
C와 같은 일부 언어는 고유한 구문을 사용하여 이러한 항목을 포인터로 명시적으로 호출합니다.
그러나 JS에는 포인터가 없습니다. 적어도 그 이름만큼은 없습니다.
그리고 JS에도 포인터를 위한 특별한 구문이 없습니다.
 
예를 들어 이 JavaScript 줄을 살펴보겠습니다.
"hello" 문자열을 저장하는 word라는 변수를 만듭니다.
let word = "hello"​

단어가 "hello"가 있는 상자를 어떻게 가리키는지 주목하세요.
여기에 간접 계층이 있습니다.

변수는 상자가 아닙니다. 변수는 상자를 가리키는 이름표 입니다.

이제 할당 연산자 =를 사용하여 이 변수에 새 값을 지정해 보겠습니다.

word = "world"
여기서 실제로 일어나는 일은
"hello"가 "world"로 바뀌는 것이 아니라 완전히 새로운 상자가 생성되고
단어가 새 상자를 가리키도록 재할당되는 것과 같습니다.
(그리고 어느 시점에서 "hello" 상자는 아무 것도 사용하지 않기 때문에 가비지 수집기에 의해 정리됩니다.)

함수 매개변수에 값을 할당하려고 시도한 적이 있다면 이것이 함수 외부의 어떤것도 변경하지 않는다는 것을 깨달았을 것입니다.
이런 일이 발생하는 이유는 함수 매개변수를 재할당하면 전달된 원래 변수가 아닌 지역 변수에만 영향을 미치기 때문입니다.
다음은 예입니다.

function reassignFail(word) {
  // this assignment does not leak out
  word = "world"
}

let test = "hello"
reassignFail(test)
console.log(test) // prints "hello"
처음에는 test만 "hello" 값을 가리키고 있습니다.
하지만 함수 내부에 들어가면 테스트와 단어가 모두 같은 상자를 가리키고 있습니다.

할당(word = "world") 후에 단어 변수는 새 값 "world"를 가리킵니다. 그러나 우리는 test를 변경하지 않았습니다.
test 변수는 여전히 이전 값을 가리킵니다.

이것이 JavaScript에서 할당이 작동하는 방식입니다.
변수를 재할당하면 해당 변수 하나만 변경됩니다.
해당 값을 가리키는 다른 변수는 변경하지 않습니다.
문자열, 부울, 숫자, 객체, 배열, 함수 등 모든 데이터 타입에 대해 이는 사실입니다.

타입의 두 카테고리

JavaScript에는 두 가지 타입의 광범위한 범주가 있으며 할당 및 참조 동등에 대해 서로 다른 규칙이 있습니다.
그것들에 대해 이야기해 봅시다.

자바스크립트의 원시 타입

string, number, boolean(또한 symbol, undefined 및 null)과 같은 기본 타입이 있습니다.
이것들은 불변입니다. (a.k.a. 읽기 전용)

즉 값을 변경할 수 없습니다.

 

변수가 이러한 기본 타입 중 하나를 가리키고 있으면, 값을 수정할 수는 없습니다.

아예 새로운 값을 가리키도록 할 수만 있습니다.
즉, 해당 변수만 새 값으로 재할당할 수 있습니다.

차이는 미묘하지만 중요합니다!
즉, 상자 안의 값이 string/number/boolean/symbol/undefined/null인 경우 값을 변경할 수 없습니다.
새 상자만 만들 수 있습니다.

즉 아래와 같이 동작하지 않습니다.

이것이 예를 들어 문자열의 모든 메서드가 문자열을 수정하는 대신 새 문자열을 반환하는 이유이며, 
새 값을 원하면 어딘가에 저장해야 합니다.

let name = "Dave"
name.toLowerCase();
console.log(name) // still capital-D "Dave"

name = name.toLowerCase()
console.log(name) // now it's "dave"

기타 모든 타입: 객체, 배열 등

다른 범주는 객체 타입입니다.
여기에는 객체, 배열, 함수 및 Map 및 Set과 같은 기타 데이터 구조가 포함됩니다.
그것들은 모두 객체입니다.

 

기본 유형과의 가장 큰 차이점은 객체가 변경 가능하다는 것입니다!
즉, 상자에서 값을 변경할 수 있습니다.

불변은 예측가능합니다.

함수에 기본(primitive) 값을 전달하면 전달한 원래 변수가 그대로 유지됩니다.
함수는 그 안에 있는 것을 수정할 수 없습니다.
함수 호출 후 변수는 항상 동일하므로 안심할 수 있습니다.
그러나 인자가 객체와 배열(및 기타 객체 유형)의 경우에는 그러한 확신이 없습니다.
객체를 함수에 전달하면 해당 함수가 객체를 변경할 수 있습니다.
배열을 전달하면 함수가 배열에 새 항목을 추가하거나 완전히 비울 수 있습니다.
이것이 JS 커뮤니티의 많은 사람들이 변경할 수 없는 방식으로 코드를 작성하려고 하는 한 가지 이유입니다.
변수가 예기치 않게 변경되지 않을 것이라고 확신할 때 코드가 수행하는 작업을 파악하는 것이 더 쉽습니다.
모든 함수가 관습에 따라 불변으로 작성되었다면 어떤 일이 일어날지 궁금해할 필요가 없습니다.
인수나 자신 외부의 어떤 것도 변경하지 않는 함수를 순수 함수라고 합니다.
인수 중 하나에서 무언가를 변경해야 하는 경우 대신 새 값을 반환하여 이를 수행합니다.
이는 호출 코드가 새 값으로 무엇을 할 것인지 결정하기 때문에 더 유연합니다.

요약: 변수는 상자를 가리키는 태그입니다. 기본 타입은 변경할 수 없습니다.

우리는 변수를 할당하거나 재할당하는 것이 변수라는 이름표가 값을 포함하는 "상자를 가리키는" 방법임을 이야기했습니다.
그리고 리터럴 값(변수와 반대)을 할당하면 새 상자가 생성되고 변수가 해당 상자를 가리킵니다.

let num = 42
let name = "Dave"
let yes = true
let no = false
let person = {
  firstName: "Dave",
  lastName: "Ceddia"
}
let numbers = [4, 8, 12, 37]​

 

이것은 기본 및 객체 타입에 해당되며 첫 번째 할당이든 재할당이든 마찬가지입니다.
우리는 원시 타입이 어떻게 불변하는지에 대해 이야기했습니다. 원래 값을 변경할 수 없으며 변수를 다른 것으로 재할당할 수만 있습니다.
이제 객체의 속성을 수정할 때 어떤 일이 발생하는지 살펴보겠습니다.

박스 안의 내용물을 변경하기

우리는 대출할 수 있는 도서관의 book을 나타내는 book 객체로 시작할 것입니다.
title과 author 및 isCheckedOut 플래그가 있습니다.

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}
다음은 객체와 그 값을 상자로 나타낸 것입니다.

그리고 다음 코드를 실행한다고 상상해 봅시다.
book.isCheckedOut = true
우리가 객체에 대해 수행한 작업은 다음과 같습니다.
 

book 변수가 어떻게 변경되지 않는지 확인하십시오.
같은 객체를 들고 있는 같은 상자를 가키립니다.
변경된 것은 해당 객체의 속성 중 하나일 뿐입니다.

 

이것이 어떻게 이전과 동일한 규칙을 따르는지 주목하세요.
유일한 차이점은 변수가 이제 객체 내부에 있다는 것입니다.
최상위 isCheckedOut 변수 대신 book.isCheckedOut으로 액세스하지만
재할당하는 것은 똑같은 방식으로 작동합니다.

 

이해해야 할 중요한 것은 객체가 변경되지 않았다는 것입니다.
사실, book을 수정하기 전에 다른 변수에 저장하여 책의 "사본"을 만들더라도
여전히 새 객체를 만들지 않을 것입니다.

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

let backup = book

book.isCheckedOut = true

console.log(backup === book)  // true!
console.log(backup.isCheckedOut)  // also true!!
let backup = book 행은 기존 book 객체를 가리키고 있는 백업 변수를 가리킵니다. (복사가 아닙니다!)
실행은 다음과 같이 됩니다.

마지막에 있는 console.log는 요점을 추가로 증명합니다.
book은 동일한 객체를 가리키고 있고,

book의 속성을 수정해도 객체의 껍데기가 변경되지 않고 내부만 변경되었기 때문에
여전히 backup과 동일합니다.

 

변수는 항상 상자를 가리키고 다른 변수(이름표)는 가리키지 않습니다.
backup = book을 지정하면 JS는 즉시 book이 가리키는 상자를 조회하는 작업을 하고,
backup이 같은 것을 가리키게 합니다. backup은 book을 가리키지 않습니다.
만약 book에 새로운 객체 리터럴을 할당하면, 둘은 따로가게 됩니다.
 

이것은 좋은 점입니다.
모든 변수가 독립적이며 변수가 다른 변수를 가리키는 거대한 지도를 머리 속에 보관할 필요가 없다는 것을 의미합니다.
만약 그렇다면 추적하기가 매우 어려울 것입니다!

 

함수 안에서 객체 변경하기

나는 때떄로 변경이 함수 내에 머물 수도 있지만, 함수 외부로 누출될 수 있음을 이미 언급했습니다.

즉, 이미 book.isCheckedOut 또는 house.address.city와 같은 하위 속성이 아닌
book 또는 house와 같은 최상위 변수인 한 함수 내부의 변수 재할당이 외부에 누출되지 않음을 이야기했습니다.

function doesNotLeak(word) {
  // this assignment does not leak out
  word = "world"
}

let test = "hello"
reassignFail(test)
console.log(test) // prints "hello"

그리고 어쨌든 이 예제에서는 문자열을 사용했기 때문에 수정을 시도해도 수정할 수 없었습니다.
(문자열은 변경할 수 없으므로 ,기억하십시오!)
 
그러나 객체를 인수로 받는 함수가 있다면 어떻게 될까요? 그런 다음 속성을 변경했나요?
function checkoutBook(book) {
  // this change will leak out!
  book.isCheckedOut = true
}

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

checkoutBook(book);

그러면 다음과 같은 일이 발생합니다.

친숙해 보이죠? 최종 결과가 정확히 동일하기 때문에 이전과 동일한 애니메이션입니다.
book.isCheckedOut = true가 함수 내부에서 발생하는지 외부에서 발생하는지 여부는 중요하지 않습니다.
할당은 어느 쪽이든 book 객체의 내부를 수정하기 때문입니다.
이를 방지하려면 복사본을 만든 다음 복사본을 변경해야 합니다.
function pureCheckoutBook(book) {
  let copy = { ...book }

  // this change will only affect the copy
  copy.isCheckedOut = true

  // gotta return it, otherwise the change will be lost
  return copy
}

let book = {
  title: "Tiny Habits",
  author: "BJ Fogg",
  isCheckedOut: false
}

// This function returns a new book,
// instead of modifying the existing one,
// so replace `book` with the new checked-out one
book = pureCheckoutBook(book);

이와 같이 변경할 수 없는 함수를 작성하는 방법에 대해 자세히 알아보려면 불변성에 대한 제 가이드를 읽어보세요.

(guide to immutability)
React와 Redux를 염두에 두고 작성되었지만 대부분의 예제는 일반 JavaScript입니다.

주 : 걍 immer쓰세요

실제 세계 사례

참조에 대한 새로운 지식을 바탕으로 문제를 일으킬 수 있는 몇 가지 예를 살펴보겠습니다.
솔루션을 읽기 전에 문제를 발견할 수 있는지 확인하십시오.

DOM 이벤트 리스너

이벤트 리스너 함수가 작동하는 방식에 대한 간략한 배경:
  • 이벤트 리스너를 추가하려면 이벤트 이름과 함수를 사용하여 addEventListener를 호출하세요.
  • 이벤트 리스너를 제거하려면 추가한 함수와 동일한 함수 참조와 같이 동일한 이벤트 이름과 동일한 함수로 removeEventListener를 호출하세요.
    • (그렇지 않으면 이벤트에 여러 함수가 추가될 수 있으므로 브라우저는 제거할 함수를 알 수 없습니다)

이 코드를 살펴보십시오. 추가/제거 기능을 올바르게 사용하고 있습니까?

document.addEventListener('click', () => console.log('clicked'));
document.removeEventListener('click', () => console.log('clicked'));​

두 개의 화살표 함수가 참조적으로 동일하지 않기 때문에 이 코드는 이벤트 리스너를 제거하지 않습니다.
구문에 관한 한 동일하더라도 동일한 함수(참조)는 아닙니다.

 

화살표 함수() => { ... } 또는 일반 함수 함수 things() { ... }를 작성할 때마다 새 객체가 생성됩니다.

(함수는 객체임을 기억하십시오).

let a = () => {}
let b = () => {}
console.log(a === b)
false가 출력됩니다! 모든 새로운 객체(배열, 함수, Set, Map 등)는 다른 모든 상자와 다른 완전히 새로운 상자에 있습니다.
이벤트 리스너 예제가 올바르게 작동하도록 하려면 먼저 함수를 변수에 저장하고 동일한 변수를 추가 및 제거 모두에 전달합니다.
const onClick = () => console.log('clicked');
document.addEventListener('click', onClick);
document.removeEventListener('click', onClick);

의도하지 않은 변경

다른 하나를 살펴보겠습니다. 다음은 배열에서 가장 작은 항목을 먼저 정렬하고 첫 번째 항목을 가져와서 찾는 함수입니다.
function minimum(array) {
  array.sort();
  return array[0]
}

const items = [7, 1, 9, 4];
const min = minimum(items);
console.log(min)
console.log(items)
이것은 무엇을 출력하나요?
1과 [7, 1, 9, 4]라고 말하면 반만 맞습니다 :)

배열의 .sort() 메서드는 배열을 제자리에 정렬합니다.(sorts the array in place)
즉, 원래 배열의 순서를 변경합니다.

즉 예제는 1과 [1, 4, 7, 9]를 인쇄합니다.
자, 이것이 당신이 원했던 것일 수 있습니다. 하지만 아마 아닐 겁니다. 맞죠? 최소 함수를 호출할 때 배열의 항목을 재정렬할 것으로 기대하지 않습니다.
이러한 종류의 동작은 함수가 코드가 바로 앞에 있지 않은 다른 파일이나 라이브러리에 있을 때 특히 혼란스러울 수 있습니다.
이 문제를 해결하려면 아래 코드와 같이 정렬하기 전에 배열의 복사본을 만드세요.
여기서 우리는 배열의 복사본을 만들기 위해 스프레드 연산자를 사용하고 있습니다([...array] 부분).
이것은 실제로 완전히 새로운 배열을 만든 다음 이전 배열의 모든 요소를 ​​복사하는 것입니다.
function minimum(array) {
  const newArray = [...array].sort();
  return newArray[0]
}

결론

  • 변수는 이름표입니다.
  • 변수는 값을 담은 상자가 아닙니다.
  • 변수는 변수를 가리키지 않습니다.
  • 값은 상자에 담겨있습니다.
  • 변수는 상자만 가리킵니다.
    • 변수에 변수 할당은 다른 변수가 가리키는 상자를 가리키게 하는 것입니다.
    • 즉 변수는 반드시 1단계만에 상자에 도달합니다. 중간단계는 없습니다.
  • 값은 primitive 타입일 경우 불변입니다.
  • 값은 reference 타입일 경우 가변입니다.
  • reference 타입은 내부에 primitive 타입을 가질 수 있습니다.
  • 변수 재할당은 가리키는 상자를 바꾸는 것입니다.
    • 불변 값의 경우 항상 상자가 바뀝니다.
    • 가변 값의 경우 포장지가 바뀌지 않으면 상자가 바뀌지는 않습니다.

 

반응형