자바스크립트의 재밌는 동작들.

jh_leitmotif·2023년 9월 22일
0
post-thumbnail

회사에 취업하고 나서야 타입스크립트를 쓰게 됐고, 지금은 자연스럽게 쓰고 있는 와중에

.js 로 되어있는 파일을 봐야할 때 쉽지 않음을 느끼기도 하는데,
타입뿐만 아니라 js의 웃긴 점들을 알고 있지 않으면 예상치 못한 동작을 발견할 때가 있다.

내 경험과 medium(시니어 면접을 위한 8가지 자바스크립트 질문들)을 적당히 볶아서... 이 글을 써보려고 한다.

undefined 웃기게 정의하기

자바스크립트에서 'undefined'란 영어 단어 그대로 정의되지 않음을 의미한다.

예를 들면,

const a = undefined;

이렇게 정의하고, 콘솔에 a를 찍어보면 undefined로 나온다.

또는 이런 케이스

const a = {
 b : '나는 b지롱'
}

이렇게 해놓고, 콘솔에 a.c 를 한번 찍어보면, a 객체는 b 프로퍼티만 가지고 있으니깐 당연히 undefined를 뱉게 된다.

근데 웃긴 건, 변수명으로 undefined 를 사용할 수 있다.

cosnt undefined = 1;

const undefined = function(){
 ...
}

비슷한 예로,

const String = 'hi'
const Object = 'This is variable, which called "Object"'

이런 것도 있다.

이것이 가능한 이유는, 자바스크립트가 변수를 정의할 때에 사용할 수 없는 예약어를 만들어놨는데, 위에 제시된 키워드들은 그 테이블에 포함되지 않기 때문... ㅡ.ㅡ;;;

https://www.w3bai.com/ko/js/js_reserved.html#gsc.tab=0

찾아보니, 위 링크에 어떤 예약어가 있는지 잘 정리되어 있다. 한 편으론 왜 이 친구들도 예약어로 두지 않았을까? 싶은 생각이 든다.

console.log 개조하기

이건 웃긴 점이라기보다는, 트리키한 케이스라고 볼 수 있겠다.

개발자 도구를 열고,

this.__proto__

를 찍어보면, WindowPrototype 이라는 것으로 구성되어 있음을 알 수 있다.

또.. this.console.log('hi') 라고 찍어보면은 일반적으로 알고 있는 console.log의 기능이 동작된다.

이는 즉, this(window) 객체는 여러 미리 정의된 값들과 함수로 이뤄진 프로토타입이 있다는 것과 같다.

그래서 예를 들면..

console.log = () => {};

이러면 아무 동작도 안한다.

그래서 이를테면, 개발 환경에서는 콘솔 로그를 동작시키지만, 프로덕션에서는 동작시키기 싫으면

if (__DEV__){
	console.log = () =>{}
}

이런식으로 할 수도 있긴 한데.... 애초에 배포시 console.log를 정리하는 습관을 들여야한다고 생각한다. 필요하다면 웹팩 또는 바벨에 플러그인 적용하면 되는 거고..

const originConsoleLog = console.log;

console.log = (args) =>{
	originConsoleLog(args);
    
    ... 하고 싶은 무언가 ...
}

이러면 기존에 있는 콘솔 로그 동작은 수행하되, 개발자가 원하는 동작을 수행할 수 있다.
예를 들면 별도의 로깅 툴을 사용한다고 했을 때, 그곳에 콘솔창에 뜨는 것들을 기록할 수 있다.
console에 포함되는 log, warn, error, info 등등... 뭐 다 가능하고, 구글에 검색해보면 여럿 뜬다.

Object.freeze는 완벽한 방법이 아니다.

원시 타입은 const로 선언하면 더는 값을 변경하거나 할 수 없지만, 참조 타입은 const로 선언되어도 내부의 프로퍼티를 편집하거나, 추가하거나, 할 수 있다.

이에 대한 대응책에 대해 객체를 잠근다는 의미로 Object를 선언한 뒤,

Object.freeze() // 일괄적으로, 객체의 편집을 막는다. 필드 추가가 안되고, 삭제도 안되고, 기존 필드의 편집이 불가능하다.
Object.seal() // 객체를 밀봉한다. 필드 추가가 안되고, 삭제가 안되고, 기존 필드는 편집할 수 있다.
Object.preventExtension() // 객체의 확장을 막는다. 즉 프로퍼티 추가가 안된다.

위와 같은 대책을 쓰는데, 문제는 중첩된 Object에 대해서는 반영되지 않는다는 점이다. 예를 들면

const sample = {
 a : {
   aValue:'hi!'
 }
}

Object.freeze(sample)

이러면 a 프로퍼티 자체는 편집도, 삭제도 불가능하지만..
a 프로퍼티는 내부에서 또 하나의 Object가 되어버리면서, 한편으론 aValue는 편집이 가능해진다.

sample.a.aValue='bye'; // 뭐 이렇게?

그러면 사실 이렇게 하면 되긴한다.

Object.freeze(sample.a)

아하, 감이 온다. 프로퍼티가 프로퍼티를 가지는 경우, 해당 프로퍼티는 Object로서 간주되며, 별도의 freeze를 해줘야된다는 것...

그런데 이걸 언제 하나하나 다 해주고 있어야하나? 그것은 재귀 함수로 해결할 수 밖에.

Object 타입이라면 프리징을 해주고, Object 타입이 아니라면 그냥 함수를 돌지 않게 만들면 된다.

const deepFreeze = (args) =>{
	if (typeof args!=='object' || args === null){
     return
    }
    
    Object.freeze(args);
    
    const objectKeys = Object.keys(args):
    
    objectKeys.forEach((key)=>{
    	deepFreeze(args[key])
    }
}

'1' + 1 은 뭐게요?

일반적으로 C라던가, Java라던가... 하는 다른 언어들은 위와 같은 경우 에러로 간주되지만

자바스크립트는 특이하게 그렇지 않다. 그 이유는 연산자에 따라 어떤 결과를 내는가? 에 대한 룰이 규정되어 있어서 라고 하는데...

이를테면 더하기 기호인 '+' 는 좌변/우변 상관 없이 문자열이 포함되어 있는 연산에서는 문자열을 우선한다.

무슨 이야기냐면... '1' + 1은 '11'이 된다는 소리다.

console.log('1'+1+3+4+5)

이 경우, '11345' 가 출력된다.

그럼, 문자열을 기준으로 Number가 아닌 Object를 더해볼까?

const a = {
 b:'hi!'
}

console.log('1' + a);

이 경우, 정답은 결국 덧셈 연산자는 문자열을 우선시하기 때문에

'1[object Object]`

로 표기된다.

아니 왜 a가 [object Object]로 변환될까?

에 대한 이야기는 다음 항목에서 보자.

슬기로운(?) 자바스크립트 연산 생활

자바스크립트의 모든 객체에는 valueOf()와 toString()을 가지고 있다.

예를 들어 정상적인 Number에 대응되는 1+1은 겉으로는 1+1로 보이지만 실제로는

(1).valueOf() + (1).valueOf()

이런식으로 연산된다.

이 때 valueOf는 각각 Number(1)을 반환하게 되면서, 결론으로는 2라는 해답이 나온다.

그런데 만약 valueOf를 찍어봤는데, 서로 연산되는 타입이 다른 경우는 어떻게 되는가?

이를테면, 위의 경우와 같이 '1' + 1에 대한 연산을 한다고 하자.

그 때 둘 다 valueOf를 찍었을 때, '1'이 문자열이고, 연산에 대한 우선 순위를 가지기 때문에

문자열이 아닌 Number(1)에 대해 연산을 진행하기 위해 toString()으로 묵시적 형변환을 시도한다.

그러므로 결론은,

('1').valueOf() + (1).toString()

이렇게 되서 결과값이 '11'으로 나오는 것이다.

이러한 동작을 위에 예시로 적어둔 Object 변수에 대응해보면.

Object.valueOf()는 { b:'hi' } 가 출력되고, 이 값의 타입은 'object' 에 해당한다.

따라서 타입이 다르므로, 아래와 같이 해당 Object에 대한 String으로의 형변환을 진행하여 연산한다.

('1').valueOf() + ({}).toString()

아하! 그러면 Object를 toString으로 말았을 때 [object Object] 라는 결과가 나와서 그렇군요!

인데... 그러면 object Object는 당췌 왜 나온 건지 하는 의문이 생겼다.

해당 내용에 대해서는 다른 아티클로 정리하든지...해야겠다.
참고 링크를 살펴보면 그 내용이 있는데... 딥한 이야기라 공부가 좀 필요하다.

Object를 Object의 키로 삼으면 어떻게 될까?

이게 되게 웃긴데, 예를 들면 이런 경우다.

const a = { 
 b:'hi!'
}

const temp = {};

이렇게 해놓고서는, 뜬금없이 temp에 속성을 추가하는데, 그 속성값의 키로 a 객체를 넣어버리는 것이다.

temp[a] = 'hi!

이러고 나서, temp를 조회해보면 이렇게들 나온다.

temp = {
 [object Object] : 'hi!'
}

그 이유는, 자바스크립트에서 객체의 키는 string으로 묵시적으로 변환되기 때문이다.

그런 이유로... 다음과 같은 경우가 발생한다.

const sample1 = { b : 'hi!' }
const sample2 = { c : 'hi!' }

temp = {};

temp[sample1] = 'hi!'
temp[sample2] = 'bye!'

이 경우, temp 객체는 하나의 프로퍼티만 가지게 된다.

왜냐하면, sample1과 sample2 객체는 키로서 String 형변환이 될 때 "[object Object]" 가 되어버려서, 결론적으로 같은 키를 가리키게 되버리기 때문이다.

이걸 어떻게 해결하냐면, JSON.stringify를 이용해 각 객체를 String으로 표현하면 되긴 하는데... ㅋㅋㅋ

한편, 이런 경우도 있다.

const sample = ['12345','asdfasd']

const temp = {};

temp[sample] = 'hi!'

이렇게 하고 나서 temp를 조회해보면

temp = {
 '12345,asdfasd' : 'hi'
}

이렇게 나온다. 똑같이, 예를 들어 sample 배열에 어떤 Object를 집어넣어서 해보면

const sample=['12345','asdfasd',{ b: 'hi!' }]

temp[sample] = 'hi!'

temp = {
 '12345,asdfasd,[object Object]' : 'hi'
}

이렇게 나온다. 배열의 경우, toString 형변환시 각 인덱스에 해당하는 값들을 전부 문자열로 형변환하고 각 인덱스 사이에 ','를 넣어 concat을 해버리는 모양이다.

잠깐만요, 다시 돌아와서 '1'-1은 어떻게 돼요?

재밌는 건 덧셈 연산에 대해서는 문자열을 우선순위로 두지만, 뺄셈 연산에 대해선 Number가 더 우선순위가 높다.

그래서 '1' - 1은 1 - 1로 변환되어 0이란 값을 얻을 수 있다.

Number가 더 우선순위가 되다보니, 아래와 같은 값이 나올 때도 있다.

'123' - '100' = 23

'123' - 'asdf' = NaN

위와 같이, 문자열끼리의 뺄셈일 때는 둘 다 Number로 암묵적 변환이 이루어져 숫자 연산이 되버리고, Number로 형변환이 불가능한 케이스에 대해서는 '잘못된 값에 대한 연산' 임을 나타내는 NaN이 표기된다.

자주 실수할 수 있는 참조타입 핸들링

원시타입으로 전달된 변수는 원본이 알아서 보호될테지만, 참조타입은 전혀 그렇지 않다.

C언어의 '포인터' 챕터를 넘어간 사람들은 자연스레 이해할텐데, 사실은 나도 구두로 설명하라고 하면 말문이 막히긴 한다.
배운지 오래되기도 해서 기억도 잘 안나고..

어쨌든, 그 예시를 보여주면.

const arr = [1,2,3,4,5]

const handleArray = (argsArr) =>{
	for (let i=0; i<argsArr.length; i++){
    	argsArr.push(i);
	}    
	return argsArr;
}

handleArray(arr);

어떤 결과가 나올 것 같은가?

혹자는 이렇게 대답할 수도 있다.

[1,2,3,4,5,0,1,2,3,4]가 나오지 않을까요?

만약 면접 때에 이렇게 대답했다면, 면접관의 찌푸린 미간을 볼 수 있을테다...

정답은, 무한루프에 빠져 브라우저가 죽는다.

그 이유는, 함수에 전달된 인자인 argsArr의 원본 주소를 참조하는 상태에서 원본 배열에 값을 추가할 때에 반복문의 끝나는 조건을 '원본 배열의 길이'로 지정했기 때문이다.

i = 0 일 때. 루프를 돌기전 argsArr.length = 5.
i = 0 일 때. 루프를 한 번 돌았고, 그러면 이 시점에 argsArr.length = 6.

그러므로 i = 1 일 때, 루프를 돌기전 argsArr.length 는 6이 된다.

위와 같은 동작이 계속 일어나면서 결국 루프는 끝나지 않는다.

이 문제는 2가지 정도로 해결 방법이 있겠다.

const handleArray = (argsArr) =>{
	const argsLength = argsArr.length;
	for (let i=0; i<argsLength; i++){
    	argsArr.push(i);
    }
    
    return argsArr;
}

첫 번째로, 함수의 스코프가 시작될 때 미리 전달된 인자배열의 길이값을 따로 저장해둔다.
이 경우, 정적인 값이 반복문의 종료 조건으로 심어지기 때문에 기대했던 응답값을 얻을 수 있다.

const handleArray = (argsArr) =>{
	const resultArray = [...argsArr]
    // 또는 Object.assign([],argsArr)
    
    for (let i=0; i<argsArr.length; i++){
    	resultArray.push(i);
    }
    
    return resultArray;
}

두 번째로, 반복문의 종료조건은 인자배열의 것으로 하되, 내부적으로 사본배열을 만들어 결과값으로 사본을 반환하면 된다.
새로운 객체를 생성하기에 원본을 건드리지 않게 되므로 두 번째 방법이 안전하게 보장되었다 라고 얘기하고 싶다.

실수하면 안되는 스코프 핸들링

let temp = 1234;

function a(){
  console.log(temp)
}

function b(){
  let temp = 56789 
  a();
}

이 때, 콘솔에 뭐라고 나올까?

1234랑 56789 나오지 않을까요?

라고 대답하는 사람이 있다면, 굉장히 유감스럽다.

a는 선언되었을 때 전역 스코프에 선언된다. 그러므로, a가 선언되었을 당시 내부에서 콘솔에 찍고 있는 temp는 전역에 선언된 let temp = 1234를 따라간다.

b는 내부에서 temp라는 변수를 56789라는 값과 함께 선언했다.

이 경우, 렉시컬 환경에 의해 b에서 'temp'라는 이름의 변수가 내부에 선언되었기 때문에
b 함수에서의 temp는 영역상 더 가까운 내부에 정의된 temp 변수를 따라가게 된다.

그러면 a 함수가 호출은 b 함수 내부에서 되었으니 b에서 따로 정의된 temp가 찍혀야되는거 아니에요? 할 수...도 있는데.

a 함수는 b에서 호출만 된거지.. 선언된 것이 아니다.

이미 선언된 시점에서 전역에 존재하는 temp변수를 가리키기 때문에 호출의 시점이 어떻든 별 상관이 없다.

1. a가 선언되었을 때, console.log(temp)는.
2. 렉시컬 환경에 의해 a 내부에 temp로 선언된 변수가 있는지 찾고.
3. 없으면 외부 환경에 temp를 찾는데, 전역 스코프에 temp가 있으니 그 쪽 temp와 연결된다.
4. b가 선언되었을 때, 내부에서 let temp = 56789가 있으니.
5. 만약 b 내부에서 console.log(temp) 가 작성된다면. 이 때 temp는 렉시컬 환경에 의해 b 내부의 temp와 연결된다.

그렇기 때문에 외부 함수를 내부에 호출시키면서, 호출 시점에 따라 다른 동작을 할 수 있게끔 하는 방법 등등이 가능한거다.

만약 호출된 시점에 선언시점과 같은 동작이 일어났다면... 와우.. 끔찍..

그러면 b함수 내에서 a가 호출됐을 때, temp값을 56789로 출력할려면 어떡해요?

간단하다. 그냥 a 함수의 선언 시점을 b 내부로 옮겨버리면 그만이다.

let temp = 1234;

function b(){
	let temp = 56789;
    
    function a(){
    	console.log(temp);
    }
    a();
}

이렇게 하면.

  1. 전역 스코프에 temp 변수가 1234로 정의되었다.
  2. b 함수가 전역 스코프 내에 정의되었다.
  3. b 함수에서 temp변수를 별도로 정의했다. b 함수 스코프에 temp에 접근한다면 렉시컬 환경에 의해 b에 별도로 정의된 temp를 따라가게 된다.
  4. a 함수가 b 함수 스코프 내에 정의되었다.
  5. a 함수 내에서 호출하는 temp는 a 스코프에 별도로 선언된 temp가 없으니, 외부 스코프인 b 함수 스코프에서 찾게 된다.
  6. b 함수 내부에 별도로 선언된 temp를 찾았다. 그러므로, a 함수의 console.log(temp)는 b 함수에 선언된 temp를 따라간다.

이 부분은 사실 자바스크립트의 재밌는 면이라고 하기 보다는... 기초 상식 부분인 것 같다.

이건 좀 재밌는 스코프 핸들링.

const arr = [10, 12, 15, 21];

for (var i = 0; i < arr.length; i++) {
  setTimeout(function() {
    console.log('Index: ' + i + ', element: ' + arr[i]);
  }, 500);
}

이 경우, 어떤 결과가 나올 것 같은가?

아래와 같이 나오는 게 아닌가? 싶을 수 있는데,

0, 10
1, 12
2, 15
3, 21

실상은

4 undefined
4 undefined
4 undefined
4 undefined

이렇게 나오더라.

그 이유는 선언 키워드에 따라 스코프가 다르기 때문이다.
더해서, 자바스크립트의 동작 원리 (feat. 이벤트 루프) 를 알고 있다면 이해하기 쉽다.

반복문은 종료조건에 당도할 때까지 내부의 동작을 수행하는데, i가 var로 선언되면서
함수 레벨 스코프를 따라가게된다. 반복문이 따로 함수 내부에서 돌고 있는 상태가 아니니, 이 경우 i는 전역 레벨에 등록된 것이나 마찬가지다.

따라서 setTimeout 내부에 있는 console.log(i)에서, i가 가리키는 것은 전역 레벨의 i가 되어버린다.

setTimeout은 실행 즉시 동작하는 것이 아니라, Web API에서 실행이 위임되고, 태스크 큐에 들어가 반복문이 모두 끝나고나서야 동작할 수 있게 되는데...

이 때, 각 반복된 4번의 setTimeout은 모두 전역 레벨에 있는 i를 참조하게 되면서

i를 매번 4로 칭하게 되고, arr 배열엔 4 인덱스에 할당된 값이 없으니 undefined를 내뿜게 된다.

그렇다면 어떻게 해야 기대값과 같이 나올까요?

간단히, 블록 레벨 스코프를 따라가는 let 키워드로 i를 선언하면 된다.

그렇게 되면 반복문은 매 반복마다 블록 레벨 스코프를 생성하게 되고,

자연스레 setTimeout의 콜백은 각각의 스코프가 가지고 있는 i 인 [0, 1, 2, 3] 을 기억하고, 기대값을 출력해줄 수 있게 된다.


참고 링크들. 하나하나 주옥같아서 혹시나 이 글을 읽는 분들이 있다면 꼭 읽어보셨으면 좋겠다...

https://sentry.io/answers/naming-variables-in-javascript/
https://levelup.gitconnected.com/8-advanced-javascript-interview-questions-for-senior-roles-c59e1b0f83e1
https://poiemaweb.com/js-type-coercion
https://stackoverflow.com/questions/4750225/what-does-object-object-mean

profile
Define the undefined.

0개의 댓글