내가 JavaScript의 [] + []를 보고 멘붕한 날
프로그래밍 언어를 배우면서 늘 궁금했던 게 있다. 같은 코드인데 왜 어떤 언어는 에러를 뱉고, 어떤 언어는 "알아서" 돌아가는 걸까? Python에서 "3" + 3을 하면 TypeError가 터지는데, JavaScript에서는 "33"이 된다. 처음엔 "JavaScript가 똑똑한 건가?"라고 생각했다. 하지만 [] + []가 빈 문자열 ""이 되고, [] + {}가 "[object Object]"가 되는 걸 보고 깨달았다. 이건 똑똑한 게 아니라 타입 시스템이 포기한 거구나.
Static/Dynamic Typing과 자주 헷갈리는 개념이 바로 Strong/Weak Typing이다. Static/Dynamic은 언제 타입을 체크하느냐의 문제다 (컴파일 vs 런타임). 반면 Strong/Weak은 얼마나 엄격하게 타입을 지키느냐의 문제다. 좀 더 정확히 말하면, 암묵적 타입 변환(Implicit Type Coercion)을 얼마나 허용하느냐다.
오늘은 내가 JavaScript의 악명 높은 타입 변환 규칙을 마주하며 겪은 좌절과, Python의 명쾌함을 통해 얻은 깨달음, 그리고 TypeScript와 린터로 이 혼돈을 제어하는 법까지 정리해본다.
처음엔 편해 보였던 JavaScript의 "친절함"
프로젝트에서 간단한 계산기 기능을 만들 일이 있었다. 사용자 입력을 받아서 더하는 건데, HTML input에서 가져온 값은 당연히 문자열이다.
const userInput = document.getElementById('number').value; // "5"
const result = userInput + 3;
console.log(result); // 내 예상: 8, 실제: "53"
첫 번째 멘붕. + 연산자가 문자열 연결로 작동했다. "아 그럼 -는 안 되겠네?"라고 생각하고 테스트했다.
const userInput = "5";
console.log(userInput - 3); // 2 (???)
두 번째 멘붕. - 연산자는 숫자로 변환해서 계산한다. 같은 input인데 +와 -의 동작이 다르다. 이게 바로 Weak Typing의 본질이다. 연산자마다 타입 변환 규칙이 제멋대로다.
Gary Bernhardt의 유명한 "Wat" 발표에서 나온 예시들을 직접 돌려봤다.
// JavaScript의 암묵적 변환 쇼케이스
console.log([] + []); // "" (빈 문자열)
console.log([] + {}); // "[object Object]"
console.log({} + []); // 0 (브라우저 콘솔에서는 다를 수도)
console.log(true + true); // 2
console.log("5" + null); // "5null"
console.log("5" - null); // 5
console.log(null == 0); // false
console.log(null >= 0); // true (????)
마지막 두 줄은 정말 충격이었다. null == 0은 false인데 null >= 0은 true다. 왜냐하면 ==는 타입 변환을 하되 null과 undefined는 특별 취급하고, >=는 숫자 변환을 강제하기 때문이다. 이런 걸 누가 다 외우고 코딩하나?
Python을 만나며 찾은 명쾌함
같은 시기에 데이터 분석 작업으로 Python을 처음 써봤다. JavaScript에 익숙해진 상태라 당연히 이렇게 썼다.
user_input = "5"
result = user_input + 3
TypeError: can only concatenate str (not "int") to str
"에러가 났다. 그런데 기분이 좋다." 이상한 소리 같지만 진심이었다. Python은 나한테 거짓말을 하지 않는다. 문자열과 숫자를 섞으면 안 된다고 명확히 말해준다. JavaScript처럼 "알아서 해줄게~" 하면서 "53"이라는 이상한 결과를 만들지 않는다.
Python에서 타입을 섞고 싶으면 명시적으로 변환해야 한다.
user_input = "5"
result = int(user_input) + 3 # 명시적 변환
print(result) # 8
# 또는
result = user_input + str(3) # "53"
이게 Strong Typing이다. 타입이 안 맞으면 에러를 낸다. 개발자가 의도를 명확히 밝히길 요구한다. 처음엔 번거로워 보이지만, 버그를 미리 잡아준다는 점에서 훨씬 안전하다.
재밌는 건 Python이 Dynamic Typing 언어라는 점이다. 변수 선언할 때 타입을 안 적는다 (x = 5). 런타임에 타입을 체크한다. 그런데도 Strong Typing이다. 즉, 언제 체크하느냐와 얼마나 엄격하냐는 별개 개념이다.
Weak Typing의 진짜 공포 - C 언어
JavaScript가 웃긴 결과를 만든다면, C는 위험한 결과를 만든다. C도 Weak Typing 언어인데, 포인터 캐스팅 때문에 타입 안전성이 완전히 무너진다.
int main() {
int num = 1025;
int *ptr = #
char *char_ptr = (char *)ptr; // 강제 캐스팅
printf("%d\n", *char_ptr); // 1 (하위 바이트만 읽음)
// 더 위험한 예시: 버퍼 오버플로우
char buffer[8];
int *evil_ptr = (int *)buffer; // char 배열을 int로 취급
evil_ptr[5] = 42; // 버퍼 범위를 벗어난 메모리 조작
}
포인터 캐스팅은 메모리를 다른 타입으로 "해석"하게 만든다. 컴파일러는 경고만 하거나 아예 허용한다. 이게 버퍼 오버플로우 같은 보안 취약점의 원인이다. 잘못된 타입으로 메모리를 읽고 쓰면서 프로그램이 크래시하거나, 더 나쁜 경우 해커가 악용할 수 있는 구멍이 생긴다.
JavaScript의 [] + []는 웃긴 결과를 만들지만, C의 포인터 캐스팅은 시스템 전체를 날릴 수 있다. Weak Typing의 위험성은 언어마다 다르지만, 공통점은 컴파일러가 타입을 제대로 안 지켜준다는 것이다.
JavaScript의 타입 변환 규칙 - 외울 게 아니라 피해야 할 것
JavaScript 입문서를 보면 "타입 변환 규칙"을 표로 정리해놓은 걸 볼 수 있다. Truthy/Falsy 값, + 연산자의 우선순위, ==와 ===의 차이 등등. 하지만 내 경험상, 이걸 외우려고 하면 안 된다. 대신 "피하는 규칙"을 세워야 한다.
1. 무조건 ===를 쓴다
// 절대 쓰지 마
if (value == true) { }
// 이렇게 써
if (value === true) { }
// 또는 Boolean으로 명시적 변환
if (Boolean(value)) { }
2. + 연산자는 숫자 계산에만 쓴다
// 이러지 마
const result = userId + 100; // userId가 문자열이면?
// 이렇게 해
const result = Number(userId) + 100;
// 또는
const result = parseInt(userId, 10) + 100;
3. Truthy/Falsy에 의존하지 마
// 위험한 코드
if (count) { doSomething(); }
// count가 0이면? 0은 falsy라 doSomething이 안 돌아간다.
// 명시적으로 써
if (count !== 0) { doSomething(); }
if (count > 0) { doSomething(); }
4. ESLint no-implicit-coercion 규칙 켜기
// ESLint가 금지하는 패턴들
const num = +str; // Number(str)로 써
const str = val + ""; // String(val)로 써
const bool = !!val; // Boolean(val)로 써
TypeScript라는 구원자
JavaScript의 Weak Typing 문제를 근본적으로 해결하는 방법은 정적 타입 시스템을 얹는 것이다. 그게 TypeScript다.
// TypeScript는 컴파일 시점에 잡아낸다
const userInput: string = "5";
const result = userInput + 3; // 에러: string + number
// 명시적 변환 강제
const result = Number(userInput) + 3; // OK
TypeScript의 strict 모드를 켜면 더 강력해진다. strictNullChecks나 noImplicitAny 같은 옵션들을 켜면 JavaScript의 "알아서 해줄게" 마법이 대부분 차단된다. 물론 런타임에는 JavaScript가 되지만, 개발 단계에서 이미 대부분의 버그를 잡을 수 있다.
마치며
Strong Typing과 Weak Typing의 차이는 단순히 "엄격함의 정도"가 아니다. 누가 타입 안전성에 책임을 지느냐의 문제다. Weak Typing은 컴파일러가 "알아서 해줄게"라며 책임을 회피한다. 그 결과는 [] + []가 ""가 되는 혼돈이다. Strong Typing은 개발자에게 "너가 명확히 말해"라고 요구한다. 번거로워 보이지만, 버그를 미리 막아준다.
JavaScript는 태생적으로 Weak Typing 언어지만, TypeScript와 린터로 무장하면 Strong Typing의 이점을 대부분 가져올 수 있다. 중요한 건 "언어가 알아서 해주겠지"라는 기대를 버리는 것이다. Number(), String(), === 같은 명시적 도구를 쓰면서, 코드를 읽는 사람(미래의 나 포함)에게 의도를 명확히 전달하자. 그게 타입 안전성의 시작이다.