JavaScript는 웹 브라우저에서 동적인 동작을 구현하기 위해 만들어진 프로그래밍 언어다. 현재는 브라우저뿐만 아니라 Node.js 같은 런타임을 통해 서버, CLI, 자동화 도구 등에서도 사용된다.
JavaScript의 특징은 단순히 문법이 간단하다는 데 있지 않다. 동적 타입, 프로토타입 기반 객체 모델, 일급 함수, 클로저, 이벤트 루프, 비동기 프로그래밍 같은 특성이 하나의 실행 모델을 구성하고 있다.
브라우저에서는 HTML과 CSS로 구성된 페이지를 JavaScript가 조작하고, 서버에서는 Node.js 같은 런타임이 JavaScript 코드를 실행한다.
JavaScript -> JavaScript Runtime -> Execution
브라우저에서는 V8, SpiderMonkey, JavaScriptCore 같은 JavaScript 엔진이 사용된다. Node.js 역시 V8을 기반으로 한다.
JavaScript의 기본 구조
가장 기본적인 코드는 다음과 같다.
const name = "Player";
console.log(name);
변수는 let, const로 선언한다.
let score = 100;
const maxScore = 1000;
과거에는 var도 많이 사용했지만 현재는 특별한 이유가 없다면 let과 const를 사용하는 것이 일반적이다.
const는 변수 자체의 재할당을 막는다.
const value = 10;
// value = 20; // 오류
하지만 객체 내부의 값을 변경하는 것까지 막는 것은 아니다.
const player = {
health: 100
};
player.health = 50;
여기서 player라는 참조 자체는 변경되지 않았기 때문에 가능하다.
동적 타입
JavaScript는 동적 타입 언어다.
let value = 10;
value = "Hello";
value = true;
value = { x: 10 };
하나의 변수에 서로 다른 타입의 값을 할당할 수 있다.
변수 선언 시 타입을 지정하지 않고 런타임에 실제 값에 따라 타입이 결정된다.
대표적인 데이터 타입은 다음과 같다.
Primitive -> string / number / bigint / boolean / undefined / symbol / null
Object -> Object / Array / Function / ...
JavaScript의 number는 정수와 실수를 별도의 타입으로 구분하지 않고 일반적으로 IEEE 754 배정밀도 부동소수점 형식으로 표현한다.
정수 범위가 매우 큰 경우에는 BigInt를 사용할 수 있다.
const value = 12345678901234567890n;
원시 타입과 객체
JavaScript의 값은 크게 원시 값과 객체로 나눠서 생각할 수 있다.
let a = 10;
let b = a;
b = 20;
이 경우 a는 그대로 10이다.
객체는 참조를 통해 접근한다.
const a = {
health: 100
};
const b = a;
b.health = 50;
a와 b는 같은 객체를 가리키기 때문에 a.health도 50이 된다.
이 차이는 함수에 값을 전달할 때도 이어진다.
JavaScript는 기본적으로 값에 의한 전달(pass-by-value)을 사용한다. 객체의 경우 전달되는 값이 객체에 대한 참조이기 때문에 결과적으로 객체의 내부 상태가 공유되는 것처럼 보인다.
객체
JavaScript의 객체는 키와 값의 형태로 데이터를 저장할 수 있다.
const player = {
name: "Player",
health: 100,
attack() {
console.log("Attack");
}
};
프로퍼티에 접근할 수 있다.
player.health;
player.attack();
동적으로 프로퍼티를 추가하는 것도 가능하다.
player.level = 10;
삭제도 가능하다.
delete player.level;
JavaScript 객체는 정해진 클래스의 인스턴스만을 의미하는 것이 아니라 상당히 범용적인 데이터 구조로 사용된다.
배열
JavaScript의 배열은 여러 값을 저장하는 객체다.
const scores = [100, 200, 300];
인덱스로 접근한다.
scores[0];
scores[1];
크기도 동적으로 변경할 수 있다.
scores.push(400);
다양한 타입의 값을 하나의 배열에 넣는 것도 문법적으로 가능하다.
const values = [10, "Hello", true, null];
다만 실제 코드에서는 특별한 이유가 없다면 하나의 배열에 서로 다른 타입을 섞는 방식은 피하는 편이 관리하기 좋다.
함수
JavaScript에서 함수는 매우 중요한 개념이다.
function add(a, b)
{
return a + b;
}
함수를 변수에 저장할 수도 있다.
const add = function(a, b)
{
return a + b;
};
더 짧게는 화살표 함수를 사용할 수 있다.
const add = (a, b) => a + b;
함수를 변수에 저장하고 다른 함수에 전달할 수 있다는 것은 JavaScript가 함수를 일급 객체(first-class object)로 취급하기 때문이다.
이 특성은 콜백, 이벤트, 비동기 처리, 함수형 프로그래밍과 직접 연결된다.
콜백
함수를 다른 함수에 전달할 수 있기 때문에 콜백 패턴을 사용할 수 있다.
function process(value, callback)
{
const result = value * 2;
callback(result);
}
process(10, value =>
{
console.log(value);
});
여기서 callback에는 함수가 전달된다.
JavaScript에서는 브라우저 이벤트부터 비동기 작업까지 콜백이 매우 많이 사용되어 왔다.
다만 콜백을 계속 중첩하면 코드가 복잡해질 수 있다.
callback -> callback -> callback -> callback
이를 개선하기 위해 Promise와 async/await가 사용된다.
스코프
JavaScript에는 변수의 접근 범위를 결정하는 스코프가 있다.
let과 const는 블록 스코프를 가진다.
if (true)
{
let value = 10;
}
// value에 접근할 수 없음
반면 var는 함수 스코프를 사용한다.
function test()
{
var value = 10;
}
현대 JavaScript에서는 var보다 let, const를 사용하는 것이 일반적이다.
클로저
JavaScript를 이해할 때 클로저도 중요하다.
function createCounter()
{
let count = 0;
return function()
{
count++;
return count;
};
}
const counter = createCounter();
console.log(counter());
console.log(counter());
createCounter()가 종료된 이후에도 내부 함수가 count를 사용할 수 있다.
내부 함수가 자신이 생성된 렉시컬 환경을 계속 참조하기 때문이다.
이런 구조를 클로저라고 한다.
클로저는 단순한 문법 기능이 아니라 상태를 캡슐화하거나 콜백에 데이터를 유지하는 등 다양한 곳에서 사용된다.
this
JavaScript의 this는 다른 객체지향 언어와 비교하면 상당히 특이하다.
const player =
{
name: "Player",
printName()
{
console.log(this.name);
}
};
player.printName();
이때 this는 메서드를 호출한 객체를 가리킨다.
하지만 호출 방식에 따라 this가 달라질 수 있다.
화살표 함수는 일반 함수와 다르게 자체적인 this를 갖지 않고 바깥쪽의 this를 사용한다.
const player =
{
name: "Player",
start()
{
setTimeout(() =>
{
console.log(this.name);
}, 1000);
}
};
JavaScript에서 this 관련 문제를 제대로 이해하려면 “현재 클래스의 객체”라고 단순하게 외우기보다 함수가 어떤 방식으로 호출되었는가를 봐야 한다.
프로토타입
JavaScript의 객체 모델은 프로토타입 기반이다.
객체는 다른 객체를 프로토타입으로 참조할 수 있다.
const animal =
{
speak()
{
console.log("Animal");
}
};
const dog = Object.create(animal);
dog.speak();
dog에 speak()가 직접 존재하지 않더라도 프로토타입을 통해 찾을 수 있다.
dog -> animal -> Object.prototype -> null
프로퍼티를 찾을 때 자신의 객체에서 찾고 없으면 프로토타입 체인을 따라가며 찾는다.
이 구조가 JavaScript의 상속 모델의 기반이다.
class
JavaScript에서도 class 문법을 사용할 수 있다.
class Player
{
constructor(health)
{
this.health = health;
}
attack()
{
console.log("Attack");
}
}
객체를 생성한다.
const player = new Player(100);
상속도 가능하다.
class Monster extends Player
{
attack()
{
console.log("Monster Attack");
}
}
다만 JavaScript의 class가 C++이나 Java의 클래스와 완전히 같은 방식으로 동작하는 것은 아니다.
JavaScript의 class는 프로토타입 기반 객체 모델 위에 제공되는 문법적 추상화다.
구조 분해 할당
JavaScript에서는 객체나 배열의 값을 쉽게 꺼낼 수 있다.
const player =
{
name: "Player",
health: 100
};
const { name, health } = player;
배열도 가능하다.
const scores = [100, 200];
const [first, second] = scores;
함수 매개변수에서도 사용할 수 있다.
function printPlayer({ name, health })
{
console.log(name, health);
}
웹 개발에서는 API 응답 객체를 다룰 때 상당히 자주 사용한다.
Spread와 Rest
Spread 문법은 배열이나 객체를 펼치는 데 사용한다.
const a = [1, 2, 3];
const b = [...a, 4, 5];
객체에서도 사용할 수 있다.
const player =
{
name: "Player",
health: 100
};
const updatedPlayer =
{
...player,
health: 50
};
Rest 문법은 여러 값을 하나로 모은다.
function add(...values)
{
return values.reduce(
(sum, value) => sum + value,
0
);
}
같은 ... 문법이 위치에 따라 다른 역할을 한다.
Map과 Set
객체 외에도 Map과 Set을 사용할 수 있다.
Map은 키와 값을 저장한다.
const players = new Map();
players.set(1, "Player");
players.set(2, "Monster");
console.log(players.get(1));
Set은 중복되지 않는 값을 저장한다.
const ids = new Set();
ids.add(1);
ids.add(1);
ids.add(2);
결과적으로 1, 2만 존재한다.
단순한 키-값 데이터에는 객체를 사용할 수도 있지만, 명확한 Map semantics가 필요한 경우에는 Map을 사용하는 것이 좋다.
고차 함수
JavaScript에서는 함수를 다른 함수에 전달할 수 있기 때문에 고차 함수를 많이 사용한다.
대표적으로 배열 메서드가 있다.
const scores = [10, 20, 30, 40];
const result = scores
.filter(x => x >= 20)
.map(x => x * 2);
여기서 filter()와 map()에 함수를 전달하고 있다.
대표적인 배열 처리 메서드는 다음과 같다.
map / filter / reduce / find / some / every / forEach / sort
이런 기능을 이용하면 반복문을 직접 작성하지 않고 데이터 처리 과정을 표현할 수 있다.
Promise
JavaScript의 비동기 처리를 이해하려면 Promise를 알아야 한다.
const promise = fetch("/api/player");
Promise는 미래에 완료될 수 있는 비동기 작업의 결과를 표현한다.
상태는 크게 다음과 같이 생각할 수 있다.
Pending -> Fulfilled / Rejected
Promise를 이용하면 다음과 같이 처리할 수 있다.
fetch("/api/player")
.then(response => response.json())
.then(data =>
{
console.log(data);
})
.catch(error =>
{
console.error(error);
});
이 구조는 콜백 중첩을 줄이고 비동기 작업의 성공과 실패를 체계적으로 연결할 수 있게 한다.
async와 await
Promise를 더 읽기 쉬운 형태로 작성하기 위해 async와 await를 사용할 수 있다.
async function loadPlayer()
{
const response = await fetch("/api/player");
const data = await response.json();
return data;
}
호출하는 쪽에서는:
const player = await loadPlayer();
처럼 사용할 수 있다.
여기서 await가 스레드를 멈추는 것은 아니다.
JavaScript의 비동기 처리는 이벤트 루프와 Promise 등의 메커니즘을 기반으로 실행된다.
이벤트 루프
JavaScript를 제대로 이해하려면 이벤트 루프를 알아야 한다.
브라우저나 Node.js에서 JavaScript 코드가 실행되는 방식은 단순한 함수 호출의 연속이 아니다.
개념적으로 다음과 같은 구조로 볼 수 있다.
Call Stack -> JavaScript Execution
Web APIs / Runtime -> Task Queue / Microtask Queue -> Event Loop -> Call Stack
예를 들어:
console.log("A");
setTimeout(() =>
{
console.log("B");
}, 0);
console.log("C");
결과는:
A -> C -> B
setTimeout(..., 0)은 즉시 실행되는 것이 아니라 해당 콜백이 나중에 실행될 수 있도록 런타임에 등록한다.
현재 실행 중인 동기 코드가 끝난 뒤 이벤트 루프가 콜백을 실행한다.
Promise의 continuation 같은 microtask는 일반적인 task보다 먼저 처리되는 등 더 세부적인 실행 규칙이 존재한다.
비동기 프로그래밍
JavaScript에서 네트워크 요청이나 파일 처리 같은 작업을 동기적으로 기다리면 실행 흐름이 막힐 수 있다.
그래서 비동기 API를 사용한다.
Request -> Wait -> Other JavaScript Execution -> Response -> Callback / Promise Continuation
Node.js에서도 이 구조를 이용해 많은 I/O 작업을 효율적으로 처리한다.
다만 JavaScript 코드 자체가 CPU 연산을 병렬로 처리해주는 것은 아니다.
CPU를 오래 점유하는 작업은 이벤트 루프를 막을 수 있다.
이런 작업은 Worker Threads나 Web Workers 같은 별도의 실행 모델을 고려해야 한다.
모듈
JavaScript는 코드를 여러 파일로 나누고 모듈 단위로 관리할 수 있다.
ES Module에서는 다음과 같이 작성한다.
// math.js
export function add(a, b)
{
return a + b;
}
다른 파일에서 가져온다.
import { add } from "./math.js";
console.log(add(10, 20));
모듈을 사용하면 전역 변수에 모든 코드를 넣는 구조를 피하고 기능별로 코드를 분리할 수 있다.
Node.js에서도 ES Module을 사용할 수 있고, 기존에는 CommonJS의 require()와 module.exports도 널리 사용됐다.
const fs = require("fs");
현재는 프로젝트 설정에 따라 ES Module과 CommonJS를 선택하게 된다.
npm과 JavaScript 생태계
JavaScript는 언어 자체보다 생태계가 상당히 큰 편이다.
Node.js 환경에서는 npm을 이용해 패키지를 설치한다.
npm install express
package.json에 프로젝트의 의존성과 설정을 관리한다.
{
"dependencies": {
"express": "^5.1.0"
}
}
웹 개발에서는 JavaScript 코드만 작성하는 것이 아니라 패키지 매니저, 빌드 도구, 테스트 도구, 프레임워크까지 함께 사용하게 된다.
React, Vue, Express 같은 도구는 JavaScript 언어 자체가 아니라 JavaScript 생태계에 속하는 라이브러리나 프레임워크다.
브라우저에서 JavaScript
브라우저에서는 JavaScript가 DOM을 조작할 수 있다.
const button =
document.querySelector("#button");
button.addEventListener("click", () =>
{
console.log("Clicked");
});
HTML 요소를 가져오고 이벤트를 등록하고 내용을 변경할 수 있다.
HTML / CSS / JavaScript -> Web Page
JavaScript는 브라우저의 Web API와 연결되어 DOM, 네트워크, 저장소, 타이머 등의 기능을 사용할 수 있다.
이때 DOM이나 fetch()가 JavaScript 언어 자체에 포함된 기능이라고 생각하면 안 된다. 브라우저가 제공하는 API다.
Node.js
Node.js는 JavaScript를 서버와 일반적인 애플리케이션 환경에서 실행할 수 있도록 만든 런타임이다.
브라우저 JavaScript와 같은 언어를 사용하지만 제공하는 API가 다르다.
Browser -> DOM / Web APIs / JavaScript Engine
Node.js -> File System / Network / Process / JavaScript Engine
Node.js에서는 파일 시스템이나 TCP 서버 같은 기능을 사용할 수 있다.
const fs = require("fs");
const data = fs.readFileSync("data.txt", "utf8");
console.log(data);
Node.js 자체가 JavaScript 언어는 아니다.
JavaScript는 언어이고 Node.js는 JavaScript를 실행하기 위한 런타임이다.
타입 변환
JavaScript는 동적 타입 언어이기 때문에 타입 변환이 자주 발생한다.
const value = "100";
const number = Number(value);
문자열로 변환할 수도 있다.
const text = String(100);
JavaScript에는 암묵적 타입 변환도 존재한다.
"10" + 20;
결과는 문자열이 된다.
이런 동작 때문에 비교 연산에서는 ==보다 ===를 사용하는 경우가 일반적이다.
10 === "10"; // false
10 == "10"; // true
===는 타입 변환을 하지 않고 값과 타입을 함께 비교한다.
null과 undefined
JavaScript에는 값이 없음을 표현하는 방식이 두 가지 있다.
let a = null;
let b;
null은 개발자가 명시적으로 값이 없음을 나타내는 경우가 많다.
undefined는 값이 할당되지 않은 상태 등을 나타낸다.
let value;
console.log(value);
// undefined
둘은 서로 다른 값이다.
null === undefined;
// false
실제 코드에서는 null과 undefined를 어떤 의미로 사용할지 프로젝트 수준에서 일관되게 정하는 것이 좋다.
오류 처리
JavaScript에서는 try, catch를 이용해 예외를 처리할 수 있다.
try
{
throw new Error("Something went wrong");
}
catch (error)
{
console.error(error);
}
비동기 코드에서는 Promise rejection도 처리해야 한다.
async function load()
{
try
{
const response = await fetch("/api");
return await response.json();
}
catch (error)
{
console.error(error);
}
}
Promise의 실패를 처리하지 않으면 예상하지 못한 오류가 발생할 수 있다.
메모리 관리
JavaScript는 일반적으로 개발자가 직접 메모리를 해제하지 않는다.
객체가 더 이상 도달할 수 없는 상태가 되면 가비지 컬렉터가 메모리를 회수할 수 있다.
let player = {
health: 100
};
player = null;
다른 참조가 없다면 기존 객체는 GC의 대상이 될 수 있다.
하지만 이벤트 리스너, 타이머, 전역 객체 등의 참조가 객체를 계속 잡고 있으면 예상보다 오래 살아남을 수 있다.
따라서 GC가 있다고 해서 메모리 관리가 완전히 사라지는 것은 아니다.
JavaScript의 실행 모델
JavaScript를 전체적으로 보면 여러 개념이 연결된다.
Source Code -> JavaScript Engine -> Call Stack -> Runtime API -> Task / Microtask Queue -> Event Loop -> Async Continuation
여기에 언어 차원의 기능들이 들어간다.
JavaScript -> Dynamic Typing / Object / Prototype / Function / Closure / Class / Promise / async / await / Module
그리고 실행 환경이 기능을 추가한다.
Browser -> DOM / Web APIs
Node.js -> File System / Network / Process
이 구조를 구분해서 보면 JavaScript 자체와 브라우저, Node.js, 프레임워크의 역할을 혼동할 일이 줄어든다.
JavaScript를 사용할 때 생각해야 하는 것
JavaScript는 문법만 보면 상당히 접근하기 쉬운 언어다.
하지만 규모가 커지면 동적 타입과 객체 참조, 비동기 실행, 이벤트 루프 같은 부분에서 문제가 발생하기 쉽다.
특히 다음 부분은 계속 신경 쓰게 된다.
타입 / 스코프 / 참조 / 클로저 / this / 프로토타입 / Promise / Event Loop / 메모리 / 모듈
언어 자체가 유연한 만큼 코드 작성 방식도 상당히 자유롭다.
그래서 프로젝트가 커질수록 TypeScript 같은 정적 타입 시스템을 추가하거나, 모듈 구조와 코딩 규칙을 명확하게 정하는 경우가 많다.
정리
JavaScript를 큰 흐름으로 정리하면 다음과 같다.
기본 문법 -> Dynamic Typing -> Object / Array -> Function -> Scope / Closure -> Prototype / Class -> Module -> Promise / async / await -> Event Loop -> Runtime -> Browser / Node.js
JavaScript를 처음 보면 C++, Java, C# 같은 언어와 문법이 비슷한 부분이 많다.
하지만 내부 모델은 상당히 다르다.
클래스가 있지만 프로토타입 기반 객체 모델 위에서 동작하고, 함수가 일반적인 메서드 이상의 의미를 가지며, 비동기 처리는 스레드를 직접 생성하는 방식보다 이벤트 루프와 런타임의 작업 큐를 중심으로 돌아간다.
특히 JavaScript에서 중요한 것은 언어 자체와 실행 환경을 분리해서 이해하는 것이다.
JavaScript는 언어이고, 브라우저와 Node.js는 그 언어를 실행하면서 각각 다른 API와 실행 환경을 제공한다.
결국 JavaScript를 제대로 이해하려면 문법보다 Function, Object, Prototype, Closure, Promise, Event Loop 사이의 관계를 잡는 것이 더 중요하다.