본문 바로가기

01. 데이터 타입

본 내용은 책 코어 자바스크립트(2019, 정재남)를 세 번 정독 후, 글쓴이가 이해한 내용과 실제 업무에서 겪은 경험을 기술하였습니다. 다소 형식적이지 못합니다.

 

수정 이력

  • 2021/06/06 그림 및 설명 수정

 


1. 변수 저장

자바스크립트는 메모리 관점에서 어떻게 변수를 저장할까.

쉽게 생각하면 메모리 영역을 저장되는 값에 따라 두 부분으로 나누어 저장하며 한 영역은 변수의 정보를, 또 하나의 영역은 실제 데이터를 저장하고 있다. 각각 변수 영역, 데이터 영역으로 부르기로 한다.*

 

* 실제로는 변수 영역은 stack에, 데이터 영역은 heap 영역에 저장된다.

 

let a = 10;

예제 1.

 

예제 1과 같이 a를 선언하면, 각 영역에 어떻게 저장될까.

그림 1과 같이, 변수 영역에는 식별자와 실제 데이터의 주소값이 저장되고, 데이터 영역에는 실제 값이 저장된다.

그림 1.

이렇게 변수 영역에 값을 바로 대입하지 않고 데이터 영역을 분리했을까.

 

abc라는 문자열을 abcdef 로 바꾼 경우를 보자.

데이터 영역을 따로 가져가면 추가로 메모리를 늘리는 작업을 할 필요 없이 0x1002에 abcdef를 할당하고 변수 영역에서 식별자 a가 가리키는 값을 0x1002로 바꾸기만 하면 된다.

 

즉 가변적인 크기를 갖는 데이터를 저장하는 변수의 값을 바꿀 때 효율적이다.

그림 2.

 

2. 불변값과 상수는 같은 개념일까?

javascript에서는 기본형 데이터는 모두 불변값이라고 한다.

또한 상수는 변수와 달리, 한번 선언하면 그 값을 변경할 수 없다.

그렇다면 상수는 불변한 값인걸까. 아래 예시를 보자.

const a = 'javascript';

a += ' is fun!'; // 불가능

let b = 'java';

b += 'script'; // 'javascript'

예제 3.

 

상수로 선언된 a의 경우, 변수 영역의 값이 'javascript'로 선언된 이후에는 변경할 수 없다.

반대로 변수 b가 참조하고 있는 0x1001에 'javascript'라는 값이 할당되었다가, 'javscript is fun!'이라는 값을 기존의 0x1001이 아닌 전혀 다른 주소 0x1002에 할당하고 b가 참조하는 주소값을 변경한다. 따라서 b는 상수가 아니다.

그림 3.

위 예시가 상수와 변수의 차이점이었다면 불변값은 어떤 차이점이 있을까.

데이터 영역에서, 'javascript'와 'javascript is fun'은 전혀 다른 공간에서 별개의 값으로 할당되었다.

즉 두 문자열은 완전히 별개의 데이터이다.

이렇게 데이터 영역에서 주소값의 값이 바뀌지 않은 경우, '불변하다'라고 한다.

 

즉, 상수는 '변수 영역'의 값을 바꿀 수 있는지의 여부이며, 불변값이란 데이터 영역의 변경 가능성을 말한다.

 

값의 재활용

let a = 'javascript';
a += ' is fun!';

let b = 'javascript';

예제 4.

 

예제 4에서는 a를 먼저 선언하고 'javascript'를 할당하였다. 그 후  b를 선언하면서 a의 값인 'javascript'를 할당하면 메모리에서는 어떻게 저장될까?

컴퓨터는 기존의 메모리 영역에서 'javascript'가 존재하는지 찾고, 이미 0x1001에 'javascript'가 있으므로 같은 주소를 b에 저장한다.

그림 4.

 

3. 참조형 데이터의 메모리 할당 원리

기본형 데이터 타입의 할당 과정을 보았으니, 이제 참조형 데이터의 할당 과정을 보자.

let obj = {
    lang: 'javascript',
    version: 6
};

예제 5.

 

원리는 기본형 데이터 타입의 할당 과정과 똑같다.

다만 데이터 영역에 실제 값이 바로 대입되는 것이 아니라 실제 데이터가 담긴 주소를 값으로 갖는 변수 영역의 데이터의 주소를 저장한다.  책의 설명을 빌려 말하자면 '객체의 변수 영역(프로퍼티)'이 별도로 존재한다.

그림 5.

 

obj 식별자가 참조하고 있는 주소값의 값은 기본형 데이터가 아니라 또다른 주소값의 리스트인 0x201~이다. 

즉, obj 객체의 프로퍼티또한 변수 영역에 저장하여, obj 식별자는 해당 프로퍼티 변수들의 주소값 리스트가 저장된 주소값을 참조한다.

 

obj 프로퍼티 중 version을 6에서 7로 변경하면 메모리는 어떻게 변할까?

let obj = {
    lang: 'javascript',
    version: 6
};

obj.version = 7;

예제 6.

 

그림 6.

obj가 참조하는 데이터 영역이 저장하는 주소값 0x1001은 변하지 않는다.

그러나 프로퍼티 변수 영역의 version이 참조하는 주소값은 0x1003에서 7을 할당한 0x1004로 변경되었다.

 

데이터 영역의 값은 불변이지만 변수 영역의 값은 얼마든지 바뀔 수 있다.

따라서 참조형 데이터를 불변하지 않다고 한다.

 

심화

중첩된 객체의 경우 또한 다음의 원리를 그래도 따르기 때문에 몇 번 연습해보면 더욱 익숙해진다.

// 예시 1.
let obj1 = {
    name: 'newjeong',
    age: 26,
    family: ['sister', 'parents', 'cats']
    major: 'software'
};

// ----- 그림으로 그려보세요 :) -----

// 예시 2.
let obj2 = {
    name: 'newjeong',
    background: {
        highestLevelOfEducation: 'college',
        major: 'software',
        entrance: 2013,
        graduation: 2018
    }
};

문제 1.

 

4. 데이터의 복사

일반적으로 기본형 데이터는 값을 복사하고, 참조형 데이터는 주소값을 복사한다고 알고 있다.

그러나 복사 과정은 변수 영역의 새 주소에 동일한 값으로 할당한다는 것은 데이터 타입과 관계없이 동일하다.

let a = 6;
let b = a;

예제 7.

그림 7.

let obj1 = {
    lang: 'javascript',
    version: 6
};

let obj2 = obj1;

예제 8.

그림 8.

 

이렇게 복사 과정은 동일하지만 복사된 변수의 값을 변경할 때 기본형과 참조형은 차이가 존재한다.

let a = 6;
let b = 7;

console.log(a, b); // 6 7

a === b // false

예제 9.

그림 9.

let obj1 = {
    lang: 'javascript',
    version: 6
};

let obj2 = obj1;

obj2.version = 7;

console.log(obj1.version, obj2.version); // 7 7

obj1 === obj2 // true

예제 10.

그림 10.

obj1이 참조하는 0x1001은 변하지 않고 프로퍼티 변수 영역이 참조하는 주소값이 변하였다.

따라서 obj2의 version 프로퍼티의 값이 바뀌면 obj1.version또한 변경된다.

 

따라서 참조형 데이터 타입인 변수를 복사하여 값을 변경하면 이들이 참조하는 데이터 영역의 프로퍼티의 주소값이 불변이기 때문에 원본 데이터도 영향을 받게 된다.

 

반대로 객체 자체를 재할당하면 변수의 참조값이 달라져 더이상 같은 프로퍼티를 참조하지 않게 된다.

let obj1 = {
  lang: 'javascript',
  version: 6
};

let obj2 = {
  lang: 'javascript',
  version: 7
};

obj1 === obj2 // false

예제 11.

그림 11.

 

참조형 데이터가 '가변값'이라고 설명할 때의 '가변'은 참조형 데이터 자체를 변경할 경우가 아니라 그 내부의 프로퍼티를 변경할 때만 성립합니다.

 

5. 불변 객체

위에서 본 객체를 복사할 때 생기는 차이점으로 인해 원치 않는 부수효과를 겪을 수 있다.

예를 들어 인자로 받은 객체에 변경을 가하더라도 원본 객체는 변하지 말아야 하는 함수 등이 그렇다.

let user = {
  name: 'newjeong',
  age: 26,
  birthday: '0902'
};

const addAfterBirthday = (user) => {
  const newUser = user;
  const today = moment().format('YYYYMMDD');
  const currentYear = today.format('YYYY');
  
  if (moment(`${currentYear}${user.birthday}`, 'YYYYMMDD').isSameOrAfter(today, 'day')) {
    newUser.age += 1
  }
  
  return newUser;
};

const userAfterBirthday = addAfterBirthday(user)

if (userAfterBirthday !== user) {
  console.log('생일이 지나서 나이륾 먹었습니다!')
}

예제 12.

 

생일이 지나면 age 프로퍼티에 1을 더하는 addAfterBirth 함수에서 아래 코드는 원본 객체에 영향을 준다.

const newUser = user;

따라서 userAfterBirthday 와 user는 늘 같은 값을 참조하고 있으므로 콘솔 로그가 불리는 일은 영원히 없다.

 

이 문제를 해결하려면 userAfterBirthday를 재할당해야 한다.

책에서는 얕은 복사와 깊은 복사를 단계적으로 구현하면서 소개하였지만 최종적으로 사용할 수 있는 가장  쉬운 방법은 JSON을 활용하여 깊은 복사를 하는 것이다.

let newUser = JSON.parse(JSON.stringify(user));

 

6. 실제 트러블 슈팅

2021년도에 보니 잘 구현된 코드는 절대 아님 😢 문제 원인만 확실하게 알고 가자.

 

책을 2번째 정독하고 있을 때 React 기반의 실무 프로젝트에서 기존의 클래스 컴포넌트를 hook을 활용한 함수형 컴포넌트로 교체하는 작업을 했었다.

이전의 state 객체의 구조(nested object)를 그대로 유지하면서 모든 렌더링 함수를 교체하였다.

 

그러나 하나의 기능이 기존과 동일하게 동작하지 않는 문제가 발생했다.

교체 후 입력값의 변경 내역이 있을 때에만 저장 버튼이 활성화되는 기능이 이미 저장된 후에도 버튼이 disabled되지 않았다.

const BlockLayout = props => {
    const [block, setBlock] = useState({
        parameters: [],
        // ...
    });
    
    // 수정되었거나, 새로 생성을 다시 눌렀을 때
    useEffect(_ => {
        if (!!prevBlock 
        && prevBlock._id === block._id 
        && JSON.stringify(prevBlock) !== JSON.stringify(block)
        && props.intentId !== "rollback" && props.intentId !== "select"
        && !props.edited) {
            dispatch(emitEditStatus()); // 저장 버튼 활성화
        }
    }, [block]);
    
    const handleWrite = _ => {
        // 응답 내 이미지 파일 분리 및 파싱
        const setAskAgain = (sendData, obj, tag) => {
            // 변경된 객체 반환
        }

        const parametersWithoutFile = nonEmptyParameters.map(parameter => {
            /**
             * @description 참조 객체 문제로 인해 deepcopy
             * block.parameters 자체는 filter로 인해 주소값이 달라짐
             * but, block.parameters 각각 elements는 주소값이 변동이 없음
             */
            const param = JSON.parse(JSON.stringify(parameter)); // 깊은 복사로 해결

            const parseAskAgain = setAskAgain(sendData, param.askAgain, `askAgain_${param.name}`);
            param.askAgain = go(
                parseAskAgain,
                obj => extend(obj, { "forceAskAgain": param.askAgain.forceAskAgain })
            );

            const parseFallbackMessage = setAskAgain(sendData, param.fallbackMessage, `fallbackMessage_${param.name}`);
            param.fallbackMessage = parseFallbackMessage;
            return param;
        });
    };
};

예제 13.

 

handleWrite 함수에서 setAskAgain함수는 state를 인자로 받아서 재할당한 값을 반경하여 반환하는데 하필 인자로 받는 state 객체가 중첩객체로, 해당 중첩객체의 프로퍼티의 값의 주소값이 달라졌다.

 

따라서 저장버튼을 활성화하는 useEffect문에 계속해서 걸린 것이었다.

 

React의 state는 immutable하기 때문에 기본적으로 데이터가 절대 변동이 없을거라고 생각했던 건 내 착각이었고, react는 state 내 중첩된 프로퍼티는 신경을 쓰지 않는다.

How to update nested state properties in React
 

How to update nested state properties in React

I'm trying to organize my state by using nested property like this: this.state = { someProperty: { flag:true } } But updating state like this, this.setState({ someProperty.flag: fals...

stackoverflow.com

또 하나 함수형 컴포넌트로 교체하면서 저지른? 잘못된 방법은 기존의 클래스 컴포넌트의 state 구조를 그대로 유지했다는 점이다.

클래스 컴포넌트 때야 컴포넌트를 분리하지 않는 이상 어쩔 수 없이 다중으로 중첩된 객체를 state로 쓸 수 밖에 없었지만, hook에서는 각각 독립적으로 state를 분할할 수 있다.

각 데이터를 독립적으로 분리하여 상태를 관리하고 있었으면 이런 문제는 더욱 발생하지 않을수도 있었겠다 싶다.

'Books > 코어 자바스크립트' 카테고리의 다른 글

06. 프로토타입  (0) 2020.04.16
05. 클로저  (0) 2020.04.12
04. 콜백 함수  (0) 2020.04.04
03. this와 객체  (0) 2020.03.05
02. 실행 컨텍스트  (0) 2019.12.31