6. 이제 그만 var는 놓아줍시다.


앞서 ‘이제 var는 없다고 생각하자’고 했습니다. 왜냐하면 var에는 지금으로서는 이해하기 어려운 특이한 현상들이 다수 존재하고, 이러한 현상들은 자바스크립트를 혼란스럽게 하는 주범이 되곤 하기 때문입니다. 이미 var를 전혀 사용하고 있지 않는 환경에 있는 분은 이번 포스트는 건너뛰어도 괜찮습니다. var의 문제가 무엇인지, 어떤 특이한 현상들이 있는지 궁금한 분들은 재미 삼아 가볍게 읽어보세요.

1. 변수의 유효범위(스코프)

var로 선언한 변수의 유효범위는 전역스코프를 제외하면 오직 ‘함수스코프’ 뿐입니다. 블록스코프는 var에 아무런 영향을 주지 않습니다. 이 성질은 자바나 C, 파이썬 등 다른 언어에 익숙한 개발자들이 가장 먼저 혼란을 느끼게 되는 포인트입니다.

2. 중복 선언

var로 선언한 변수는 같은 스코프 내에서 다시 선언할 수 있습니다. 이로 인해 문제가 되는 경우는 생각보다 많지는 않습니다. ‘값을 변경하고, 다음 줄에서는 변경된 값을 활용’하는 일반적인 코딩 습관에 따르면 원하는 대로 동작하곤 합니다. 그러나 일단 문제가 생겼을 때엔 원인을 찾아내기가 상당히 까다로운 경우가 많습니다. 특히 블록스코프 내에서 변수를 선언하고는 ‘중복 선언’인 줄 인지하지 못하는 경우가 그렇습니다.

1) 기본 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var getQueryInfo = url => {
var index = url.indexOf('query=') + 6
var query = ''
var lastIndex = 0
var croppedUrl = url.slice(index)
if (index > -1) {
var index = croppedUrl.indexOf('&') - 1
if (index < -1) index = croppedUrl.length - 1
query = croppedUrl.substring(0, index + 1)
lastIndex = index
}
return {
query,
start: index,
end: lastIndex + index,
}
}
console.log(getQueryInfo('http://abc.com/search?sd=20200720&query=javascript&ed=20200820'))
console.log(getQueryInfo('http://abcdef.com/search?sd=20200720&query=java'))

안티 패턴이긴 하지만 var에 대한 변수의 유효범위 및 중복 선언의 문제점을 확인할 수 있는 예제 코드를 만들어 보았습니다.

  • 1행의 getQueryInfo는 파라미터로 url 문자열을 받아 ‘query=’ 뒤에 오는 검색어를 찾고, 검색어 정보와 시작 위치, 끝 위치를 반환하는 함수입니다.
  • 2행에서는 url에서 ‘query=’의 시작 위치를 찾아내어 변수 index에 할당합니다.
  • 5행에서는 url에서 ‘query=’까지의 문자열을 잘라내고 뒷부분만 croppedUrl에 할당하였습니다.
  • 6행에서는 만약 이 시작위치가 0 이상인 경우(문자열 내에 ‘query=’가 존재하는 경우) 6행부터 11행까지의 블록스코프 내부를 실행하도록 했습니다.
  • 7행은 뒷부분에서 다시 ‘&’가 등장하는 위치를 찾아내어 “새로 선언한” 변수 index에 할당합니다.
  • 8행에서는 뒤에 ‘&’가 없는 경우에는 index에는 문자열의 마지막 위치에 1을 더한 값을 할당하도록 했습니다.
  • 9행에서는 지금까지 찾아낸 인덱스 정보들로부터 검색어를 특정하여 query 변수에 할당하였습니다.
  • 10행에서는 블록스코프 내에서 선언한 변수 index의 값을 외부 변수인 nextLastIndex에 할당하였습니다.

여기까지 보면 코드상으로는 그다지 문제가 없어 보입니다. 그런데 출력을 해보면 결과가 좀 이상합니다.

1
2
// { query: "javascript", start: 9, end: 18 }
// { query: "java", start: 3, end: 6 }

검색어는 정확하게 잘 찾아내었습니다. 그런데 해당 검색어의 시작 위치 및 끝 위치가 이상합니다. start, end 값을 바탕으로 다시 query의 문자열을 찾아낼 수는 없을 것 같습니다. 자칫 검색어가 잘 나오는 것만 확인하고 안심하며 배포했다가는 큰 일이 날 수도 있겠습니다. 어떠한 에러 메시지도 없이 조용하게 문제를 일으키니 디버깅도 쉽지 않겠네요.

위 코드의 문제 원인은 독자 모두가 짐작하시듯 var가 블록 스코프의 영향을 받지 않으면서 심지어 중복 선언도 가능하기 때문입니다. 2행의 index와 7행의 index는 동일한 함수스코프 내에 존재하는 동일한 변수입니다. 즉 2행에서 선언한 index 변수를 7행의 index가 덮어버린 것이죠. 그러니까 함수의 마지막에 반환할 start, end에 대입되는 ‘index’는 2행의 index가 아닌 7행 또는 8행에 의해 변경된 index의 값이 되는 것입니다.

위 문제를 해결하는 방법은 몇 가지가 있는데, 가장 먼저 떠올릴 수 있는 방법은 2행과 7행의 변수명을 서로 다르게 하는 것입니다.

2) 변수명을 서로 다르게 지정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var getQueryInfo = url => {
var queryIndex = url.indexOf('query=') + 6
var query = ''
var lastIndex = 0
var croppedUrl = url.slice(queryIndex)
if (queryIndex > 5) {
var lastIndex = croppedUrl.indexOf('&') - 1
if (lastIndex < -1) lastIndex = croppedUrl.length - 1
query = croppedUrl.substring(0, lastIndex + 1)
}
return {
query,
start: queryIndex,
end: lastIndex + queryIndex,
}
}
console.log(getQueryInfo('http://abc.com/search?sd=20200720&query=javascript&ed=20200820'))
console.log(getQueryInfo('http://abcdef.com/search?sd=20200720&query=java'))

// { query: "javascript", start: 40, end: 49 }
// { query: "java", start: 43, end: 46 }

이것만으로 일단 문제는 해결되었지만, if문 내부에서 var 변수를 선언하는 것이 스코프를 착각하게 할 여지가 있으므로 좀 더 고쳐봅시다. var 변수 선언을 모두 함수스코프의 최상단으로 올려둔다면 혼란의 여지가 없어질 것입니다.

3) var 선언을 스코프 최상단으로 이동

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var getQueryInfo = url => {
var queryIndex = url.indexOf('query=') + 6
var query = ''
var lastIndex = 0
var croppedUrl = url.slice(queryIndex)
var lastIndex
if (queryIndex > 5) {
lastIndex = croppedUrl.indexOf('&') - 1
if (lastIndex < -1) lastIndex = croppedUrl.length - 1
query = croppedUrl.substring(0, lastIndex + 1)
}
return {
query,
start: queryIndex,
end: lastIndex + queryIndex,
}
}

이제 스코프를 착각할 여지는 사라졌습니다. 덤으로 변수 선언이 모두 함수 스코프의 최상단에 모여있게 됨으로써 혹시라도 중복 선언된 변수가 있는지를 확인하기가 용이해진 측면이 있네요. 다만 상단에서 선언한 변수와 실제로 할당하려는 변수가 동일한 식별자를 가지고 있는지를 체크하기가 쉽지 않고, 변수명을 수정하고자 할 때에도 마찬가지이겠습니다. 또한 선언과 할당이 분리되어 코드가 다소 길어진 것도 불만스럽네요. 이렇듯 못마땅한 부분이 있긴 하지만, 그럼에도 불구하고 var를 이용하는 한은 이렇게 하는 것이 최선입니다. “변수 선언은 함수스코프 최상단에서만 하라”는 말은 암묵적인 관행 또는 ‘바람직한 코딩 습관’으로 널리 알려져 왔습니다.

ES5 이하의 자바스크립트에서는 첫 예제에서와 같은 문제가 생각보다 자주 발생하곤 했습니다. 개발자들이 자바스크립트의 여러 규칙을 정확히 이해하지 못한 상태에서 코딩을 했기 때문이라고 볼 수 있습니다. 그러나 이는 자바스크립트가 많은 부분에서 기존 유명 언어(C+, Java 등)와 흡사하기 때문이기도 합니다. 다른 언어에 익숙한 개발자 입장에서는 그 언어의 시선에서 바라보게 되기 마련이니까요.

4) let, const로 변경

반면 ES6에서 등장한 블록 스코프와 let 또는 const를 이용하면 위에서 언급한 모든 문제나 불만이 해소됩니다. ‘블록에 의해 스코프가 생긴다’라는 일반적인 예상에 부합하고, 코드가 불필요하게 길어지지 않으며, 중복 선언시 또는 선언 전 호출시 에러가 발생하므로 문제를 즉시 해결할 수 있습니다. 또한 변수 선언을 무조건 맨 위에서 하는 것이 ‘권장’되지 않고, 오히려 정확히 필요한 위치에서 선언하도록 하여 코드를 읽어 내려가다가 다시 위로 올라가서 확인해야 하는 부담이 한결 줄어듭니다. 7행의 index는 getQueryInfo 함수 스코프가 아닌 if문에 의한 블록스코프에 속하는 지역변수로써 2행의 index와 별도로 동작합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const getQueryInfo = url => {
const index = url.indexOf('query=') + 6
let query = ''
let lastIndex = 0
const croppedUrl = url.slice(index)
if (index > -1) {
let index = croppedUrl.indexOf('&') - 1
if (index < -1) index = croppedUrl.length - 1
query = croppedUrl.substring(0, index + 1)
lastIndex = index
}
return {
query,
start: index,
end: lastIndex + index,
}
}

‘var에 대한 스코프와 중복 선언의 문제점’에 대한 소개는 여기까지입니다. 그렇지만 기왕 예시가 나온 김에 한 발 더 나아가 봅시다. url로부터 검색어 정보와 시작 위치, 끝 위치를 가져오는 함수는 다양한 방식으로 구현할 수 있을 것입니다.

5) split 메서드 활용

문자열을 정규표현식 없이 분석하는 가장 손쉬운 방법은 split 문자열 메서드를 이용하는 것입니다. split 메서드는 문자열을 지정한 문자를 기준으로 분리하여 배열로 반환해 줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const getQueryInfo = url => {
const croppedUrl = url.slice(url.indexOf('?') + 1)
const searchParams = croppedUrl.split('&')
const queryParam = searchParams.find(param => param.startsWith('query'))
if (!queryParam) throw Error('query가 없습니다.')
const query = queryParam.split('=')[1]
const start = url.indexOf(query)
return {
query,
start,
end: start + query.length - 1,
}
}
  • 2행에서는 url의 ‘?’를 기준으로 뒤에 있는 내용이 모두 ‘searchParam’에 속하므로, ‘?’의 인덱스 바로 다음만을 잘라내었습니다.
  • 3행에서는 이렇게 잘라낸 문자열을 다시 ‘&’를 기준으로 분리하였습니다.
  • 4행에서는 분리된 배열에서 ‘query’로 시작하는 요소를 찾아내었습니다. find 메서드는 배열 요소들을 처음부터 하나씩 순회하면서 콜백함수를 실행하여 콜백함수의 반환값이 true인 요소를 찾아냅니다. startsWith는 단어 그대로 해당 문자열(param)이 파라미터에 지정한 값(‘query’)으로 시작하는지 여부를 판단하여 true / false를 반환합니다. 즉 searchParams의 각 요소들 중에 ‘query’로 시작하는 요소가 있다면 그 값이 queryParams에 담길 것입니다.
  • 만약 ‘query’로 시작하는 요소가 없다면 5행에 의해 에러를 반환할 것입니다.
  • 6행에서는 4행에서 찾아낸 query로 시작하는 요소를 다시 ‘=’을 기준으로 분리하여, ‘=’ 뒤의 요소를 선택했습니다. 이것이 실제 검색어에 해당할 것입니다.
  • 이제 검색어의 시작 위치(7행)와 끝 위치를 찾아내어 반환하면 됩니다.

split을 활용한 방법은 새로운 접근 방식이긴 하지만 근본 원리는 기본 예제와 완전히 같습니다. ‘query’라는 문자열을 찾고, 뒤따르는 ‘=’의 다음부터 그 뒤의 ‘&’ 또는 마지막까지가 실제 검색어에 해당할 것이라는 접근입니다. 5행의 에러 처리 기법도 함께 눈여겨 보시면 좋겠습니다.

6) 정규표현식 활용

1
2
3
4
5
6
7
8
9
10
11
const getQueryInfo = url => {
const regExp = /query=([A-z가-힣0-9]{1,})/
const [, query] = regExp.exec(url) || []
if (!query) throw Error('query가 없습니다.')
const start = url.indexOf(query)
return {
query,
start,
end: start + query.length - 1,
}
}

2행에서 ‘query=’ 다음에 이어서 영어나 한글 또는 숫자로 이뤄진 1개 이상의 문자열을 찾아내어 그룹핑 하는 정규표현식을 만들었습니다. 3행에서 이를 url에 적용하고, 그 결과 중 인덱스가 1인 요소만을 query 변수에 할당하였습니다(해체할당 - 나중에 다룹니다). 정규표현식을 실행한 결과 조건에 맞는 문자열이 없는 경우에는 빈배열을 반환하게 함으로써 query 변수에는 undefined가 할당 되고, 4행에 의해 에러 메시지를 출력하게 했습니다. 5행부터는 위 (5)와 동일합니다. 정규표현식은 자바스크립트 고유의 문법이 아닌 대부분의 프로그래밍 언어에서 제공하는 공통의 형식 언어이므로 정규식 내용에 대해서는 자세한 설명을 하지 않겠습니다.

필자의 개인적인 의견으로는, 정규표현식은 자바스크립트 학습에 필수적인 요소는 아닌 것 같습니다. 위 코드에서의 [A-z가-힣0-9]와 같은 표현은 영어, 한글을 제외한 다른 언어는 찾아내지 못합니다. 그렇다고 [\w\W]와 같이 모든 문자열을 허용하도록 하면 ‘&’ 마저 검색 조건을 충족해 버리게 되므로 검색어만을 정확히 특정하지 못하게 됩니다. 이런 문제들을 잘 보완하여 일견 잘 동작하는 것처럼 보이는 표현식을 완성한 것 같다가도, 좀 더 테스트를 거치면 또다른 오류가 발견되는 경우가 많습니다. 따라서 충분한 테스트를 거쳐 예외 케이스들을 정밀히 검토하여 수정하는 노력이 필요한데, 예외 사항들을 더 많이 반영할수록 가독성이 떨어지고 난이도가 올라감과 동시에 성능은 저하될 수 밖에 없습니다. 따라서 간단하면서도 정확하게 구현할 수 있는 경우가 아닌 한 가급적 다른 방안을 먼저 고려하고, 정규표현식은 부득이한 경우에 제한적으로 사용하는 것이 바람직할 것입니다.

7) URLSearchParams 활용

1
2
3
4
5
6
7
8
9
10
11
const getQueryInfo = url => {
const searchParams = new URLSearchParams(url)
const query = searchParams.get('query')
if (!query) throw Error('query가 없습니다.')
const start = url.indexOf(query)
return {
query,
start,
end: start + query.length - 1,
}
}

이번에는 이전 포스트에서도 소개했던 URLSearchParams를 활용하였습니다. 전체적으로 앞서 소개했던 내용들과 동일한 로직을 따르므로 설명은 생략합니다. 이 방법이 앞서 소개한 여느 방법(정규표현식 포함)에 비해 더 안전하면서 신뢰도 높은 정보를 얻을 수 있는 좋은 방법입니다. 다만 앞서 기술한 대로 2020년 현재까지도 이를 지원하지 않는 오래된 브라우저 사용자들이 일정 비율 남아있는 실정이라, 사용자 환경에 따라 적용 여부를 달리 판단할 필요가 있겠습니다.

3. 전역공간에서의 이상한 동작들

다시 본론으로 돌아와 보죠. var의 단점을 살펴보던 중이었습니다. 전역스코프에서 선언한 var는 전역객체와의 관계에서 이상하게 동작합니다. 바로 전역스코프에서 var로 선언한 변수는 동시에 전역객체의 프로퍼티가 되는 것입니다. 심지어 이렇게 암묵적으로 추가된 전역객체의 프로퍼티는 삭제할 수도 없습니다.

1
2
3
4
5
6
var a = 1
console.log(a, window.a) // 1 1
delete a
console.log(a, window.a) // 1 1
delete window.a
console.log(a, window.a) // 1 1

다행히 let과 const에 대해서는 더이상 이런 이상한 동작을 보이지 않습니다.

1
2
3
4
5
6
7
8
let a = 1
console.log(a, window.a) // 1 undefined
window.a = 2
console.log(a, window.a) // 1 2
delete a
console.log(a, window.a) // 1 2
delete window.a
console.log(a, window.a) // 1 undefined

우리는 전역 스코프에서 var는 이상하게 동작하고, let과 const는 그렇지 않다는 점만 알고 넘어가는 것으로 충분합니다. 혹시 더 자세한 내용이 궁금하신 분은 코어 자바스크립트를 참고하세요.

4. TDZ

var로 선언한 변수에 대해서는 TDZ가 없습니다.

5. 변수 키워드 생략에 대한 오해

개발자 커뮤니티 상에는 ‘var’ 키워드 없이 처음 등장하는 식별자에 무작정 값을 할당하더라도 자바스크립트 엔진은 이를 ‘전역스코프에서의 var 선언’과 동일시 하여 아무런 문제 없이 통과시켜 버린다는 것이 정설처럼 퍼져 있습니다. 그러나 이는 사실이 아닙니다. 실제로는 ‘선언’ 없이 ‘할당’만 이루어집니다. 관건은 ‘어디에’ 할당이 되는지 이겠죠. 할당은 해당 식별자를 검색하는 과정을 거친 다음, 찾아낸 식별자에 값을 대입하는 과정으로 진행됩니다. 그런데 이 ‘검색’ 과정의 중간에 대상을 찾지 못하는 경우에는 스코프 체이닝을 타고 전역객체까지 올라갑니다. 전역객체에도 해당 식별자(프로퍼티)가 없다면 이제는 전역객체에 새로운 프로퍼티를 만들고, 그 새로운 프로퍼티에 값을 할당하는 것입니다. 즉 선언된 적 없는 식별자에 값을 할당하고자 하면, 해당 명령이 어떤 스코프에서 수행되었건 상관 없이 무조건 전역객체의 프로퍼티에 값을 할당합니다. 이는 스코프 체이닝의 최상단에 있는 전역객체가 ‘객체’이기 때문에 발생하는 현상으로, ‘var의 생략’과는 무관합니다.

1
2
3
4
5
6
7
const func = () => {
a = 1
}
func()
console.log(a, window.a) // 1 1
delete a
console.log(a, window.a) // Error: a is not defined