문제 상황


최근 React의 새로운 기능인 `useEffectEvent`를 알게 되었고, 실제 서비스에 적용해보려고 했다.

공식 문서 기준으로 보면:

* React 19.2 이상 → 안정적으로 사용 가능
* React 19.0 ~ 19.1 → 실험적 (동작할 수도, 안 할 수도 있음)
* 그 이하 → 미지원

우리 프로젝트는 **React 19.2.3**을 사용 중이었기 때문에
자연스럽게 “문제없이 사용할 수 있겠지”라고 생각했다.

 이번 테스트 환경

이번에 확인한 환경은 다음과 같았다.

* next: 15.5.9
* react: 19.2.3
* react-dom: 19.2.3
* 실행: `next dev --turbopack`

👉 즉, package 레벨만 보면 `useEffectEvent`를 써도 이상하지 않은 조합이었다.


 왜 useEffectEvent를 도입하려고 했는가

내가 이 기능에 관심을 가지게 된 이유는 `/consult` 페이지의 채팅 구조 때문이었다.

이 페이지에서는:

* 담당자 ↔ 고객 간 채팅
* 브라우저 알림
* sidebar / menu에 unread 표시 (green dot)

같은 기능이 동시에 동작한다.

그래서 채팅 로직을 페이지 단이 아니라 **Layout 레벨로 끌어올려 `ChatProvider`로 구성**했고:

* 채팅방 접속 상태
* 구독 변경
* 딥링크 진입

등은 `/consult` 페이지에서 제어하도록 설계했다.

 

 문제의 본질


문제는 여기서 시작됐다.

이 서비스는:

* 사용자 권한에 따라 UI가 달라지고
* 전역 Provider가 많고
* 상태 흐름이 꽤 복잡하다

그래서 현재는:

* “처음 진입 여부”
* “로딩 상태”

같은 내부 변수를 두고
👉 **소켓 연결 타이밍을 제어하는 구조**를 쓰고 있었다.

 

 기존 코드의 한계

기존 구조에서는 이런 문제가 있었다:

* pathname 변경 → 소켓 effect 재실행
* 알림 설정 변경 → 재연결 발생 가능
* stale closure 방지를 위해 ref 동기화 필요
* (`showBannerRef`, `activeIdRef` 등)

👉 결과적으로 코드가 점점 복잡해지고 있었다

 그래서 useEffectEvent를 도입하려고 했다


의도는 명확했다:

> “연결의 생명주기(effect deps)와
> 최신 상태 읽기(callback)를 분리하자”

즉:

* 소켓 연결은 최소한의 deps로 유지하고
* 이벤트 핸들러는 최신 state를 읽게 하자

 그런데


바로 터진 에러

useEffectEvent is not a function



런타임에서 바로 터졌다.

“React 버전은 맞는데 왜 안 되지?”

처음에는 단순히 의아했다.

그래서 확인을 해봤다:

npm list react react-dom

→ 둘 다 19.2.3

 

node -e "require('react').useEffectEvent"

→ function

여기까지는 정상


---

 그런데 진짜 문제는

Next.js 환경에서 실제 클라이언트 런타임은
단순히 `react` 패키지를 쓰지 않는다.

👉 내부적으로:

next/dist/compiled/react


를 사용한다.

그리고 여기서 확인해보면:

node -e "require('next/dist/compiled/react').useEffectEvent"


👉 결과: `undefined`


 핵심 원인



👉 **내가 설치한 React와
실제 런타임에서 사용되는 React가 다르다**

즉:

> “package.json에서 React 버전이 맞다”
> = “해당 API를 런타임에서 쓸 수 있다”

가 아니었다.

그래서 내린 결론은

이 상태에서 계속 진행하면:

👉 기능 도입이 아니라 장애 생성

그래서:

-  useEffectEvent 적용 시도 → 전부 revert
-  기존 패턴 유지 (ref 동기화 + effect 분리)

로 방향을 잡았다.


 같은 문제를 만났을 때 체크리스트


혹시 나와 같은 에러를 만났다면 아래를 먼저 확인해보자.

# 1. 설치 버전 확인
npm list react react-dom

# 2. 앱 기준 React export 확인
node -e "console.log(require('react').version, typeof require('react').useEffectEvent)"

# 3. Next 런타임 React export 확인
node -e "console.log(require('next/dist/compiled/react').version, typeof require('next/dist/compiled/react').useEffectEvent)"



👉 이 결과가 다르다면
package.json만 보고 기능 도입을 판단하면 안 된다.

 이번에 배운 것


1. 문서만 믿으면 안 된다

버전 조건만 보고 도입하면 안 된다.
👉 실제 런타임 export를 확인해야 한다



2. Next.js 환경은 다르게 동작한다

Next는 내부적으로 compiled dependency를 사용한다.

👉 내가 설치한 패키지 ≠ 실제 실행 패키지



3. 기능 도입보다 중요한 것

👉 되돌릴 수 있는 단위로 실험해야 한다

이번 케이스도:

* 작은 범위에서 시도했기 때문에
* 빠르게 원복 가능했다

4. ai 끼리 업무를 보게 했을 때 인간이 필요한 이유

👉 명세서끼리 읽게 시켰을 때 서로 오류가 없어서 진행하게 했지만 실제로 그것을 확인하고 검증하는 내 역할이 없었다면 잘못된 파일이 서버에 올라갈 뻔했다. 작은 단위로 계속 모니터링 하는 습관이 사고를 예방했다.



 오해 방지를 위해



여기서 중요한 건
👉 “Next.js에서는 useEffectEvent를 못 쓴다”가 아니다

정확히는:

> 내가 테스트한 버전 조합 / 런타임에서는 사용할 수 없었다

즉, 기능 자체의 문제가 아니라
👉 런타임과 패키지 간의 불일치 문제에 가깝다.


 마치며



`useEffectEvent`는 분명 좋은 기능이다.

특히:

* socket
* polling
* subscription

같은 케이스에서는 코드 복잡도를 크게 줄일 수 있다.

이번 포스팅은 JS 엔진의 작동 원리에 대한 시리즈 중 두 번째 글입니다.

  1. JS엔진은 우리가 작성한 코드를 어떻게 처리하나? (이전 포스팅)
  2. 스코프는 무엇인가? (현재 포스팅)
  3. 스코프의 작동 방식과 쓰임새 (예정)
  4. 스코프를 다룰 때 주의할 점 (예정)

지난 포스팅에서 자바스크립트 코드가 어떻게 컴파일되고 실행되는지 살펴봤습니다. 이번에는 그 과정에서 핵심적인 역할을 하는 '스코프'에 대해 깊이 있게 알아보겠습니다.

 

왜 스코프를 이해해야 하는가?

스코프는 단순히 알면 좋은 개념이 아닌, 자바스크립트 프로그래밍의 근간이 되는 필수 개념입니다. 스코프를 제대로 이해하지 못하면 다음과 같은 문제들이 발생합니다:

  1. 예상치 못한 버그 발생: 변수가 어디서 접근 가능한지 모르면 의도치 않은 동작이 발생합니다.
  2. 메모리 누수: 불필요하게 긴 변수 생존 기간으로 인해 메모리가 낭비됩니다.
  3. 코드 유지보수 어려움: 변수의 범위를 명확히 알지 못하면 코드 수정 시 부작용이 생깁니다.
  4. 디버깅 복잡성 증가: 어디서 변수가 변경되었는지 추적하기 어려워집니다.

실제로 많은 자바스크립트 초보자들이 겪는 혼란의 상당 부분은 스코프에 대한 이해 부족에서 비롯됩니다. "왜 이 변수가 여기서는 접근 가능하고 저기서는 안 되지?", "왜 이 함수는 외부 변수를 볼 수 있는데 저 함수는 못 보지?" 같은 질문들의 답이 모두 스코프에 있습니다.

 

스코프의 정의와 중요성

스코프(Scope)는 프로그래밍 언어에서 변수와 함수의 접근성과 생존 기간을 결정하는 규칙의 집합입니다. 쉽게 말해 "이 변수는 어디서부터 어디까지 유효한가?"를 정의하는 경계입니다.

자바스크립트에서 스코프의 중요성:

  • 변수 충돌 방지: 동일한 이름의 변수를 서로 다른 스코프에 안전하게 사용할 수 있습니다.
  • 정보 은닉과 캡슐화: 특정 데이터와 기능을 외부로부터 보호할 수 있습니다.
  • 메모리 효율성: 변수가 필요한 범위에서만 살아있도록 하여 메모리를 효율적으로 사용합니다.
  • 코드 가독성: 변수의 사용 범위가 명확해져 코드 이해가 쉬워집니다.

 

자바스크립트의 스코프 종류

자바스크립트에는 크게 세 가지 유형의 스코프가 있습니다:

1. 전역 스코프(Global Scope)

전역 스코프는 코드의 가장 바깥쪽 층으로, 어디서든 접근할 수 있는 변수와 함수가 위치합니다.

 
javascript
// 전역 스코프에 선언된 변수와 함수
var globalVar = "전역 변수입니다";
let globalLet = "전역 let 변수입니다";
const globalConst = "전역 const 상수입니다";

function globalFunction() {
  console.log("전역 함수입니다");
}

// 다른 함수나 블록 내에서도 접근 가능
function testAccess() {
  console.log(globalVar);     // "전역 변수입니다"
  console.log(globalLet);     // "전역 let 변수입니다"
  console.log(globalConst);   // "전역 const 상수입니다"
  globalFunction();           // "전역 함수입니다"
}

testAccess();

전역 스코프에 대한 추가 정보:

  • 브라우저 환경: 전역 객체는 window입니다. 전역 변수는 window의 속성이 됩니다.
  • Node.js 환경: 전역 객체는 global입니다.
  • 모듈 시스템: ES 모듈에서는 최상위 레벨 변수도 모듈 스코프에 속하며 자동으로 전역이 되지 않습니다.
  • 전역 오염: 전역 스코프에 너무 많은 변수를 선언하면 이름 충돌 위험이 높아지고, 메모리 사용량이 증가합니다.
 
javascript
// 브라우저 환경에서 전역 변수와 window 객체의 관계
var x = 10;
console.log(window.x);  // 10

// let과 const로 선언한 전역 변수는 window 객체의 속성이 되지 않음
let y = 20;
console.log(window.y);  // undefined

2. 함수 스코프(Function Scope)

함수 스코프는 함수 내부에 선언된 변수와 함수가 속하는 영역입니다. var 키워드로 선언된 변수는 함수 스코프를 가집니다.

 
javascript
function exampleFunction() {
  var functionScopedVar = "함수 스코프 변수";
  let blockScopedLet = "블록 스코프지만 함수 내부에 있음";
  
  console.log(functionScopedVar);  // "함수 스코프 변수"
  console.log(blockScopedLet);     // "블록 스코프지만 함수 내부에 있음"
  
  function innerFunction() {
    console.log("내부 함수");
    // 외부 함수의 변수에 접근 가능
    console.log(functionScopedVar);  // "함수 스코프 변수"
  }
  
  innerFunction();  // "내부 함수", "함수 스코프 변수"
  
  if (true) {
    var sameVarAgain = "var는 함수 스코프라 if 블록 외부에서도 접근 가능";
  }
  
  console.log(sameVarAgain);  // "var는 함수 스코프라 if 블록 외부에서도 접근 가능"
}

exampleFunction();
// console.log(functionScopedVar);  // ReferenceError
// innerFunction();                 // ReferenceError

함수 스코프의 중요한 특징:

  • 함수 내부에서 선언된 var 변수는 함수 전체에서 접근 가능합니다.
  • 함수 내부의 블록(if, for 등)에서 선언된 var 변수도 함수 전체에서 접근 가능합니다.
  • 함수 스코프는 함수가 호출될 때마다 새로 생성됩니다.

 

3. 블록 스코프(Block Scope)

ES6에서 도입된 let과 const 키워드로 선언된 변수는 블록 스코프를 가집니다. 블록은 중괄호({})로 묶인 코드 영역입니다.

 
javascript
function blockScopeExample() {
  // 함수 레벨의 변수
  var functionVar = "함수 레벨 var";
  let functionLet = "함수 레벨 let";
  
  if (true) {
    // 블록 레벨의 변수
    var blockVar = "var는 함수 스코프";
    let blockLet = "let은 블록 스코프";
    const blockConst = "const도 블록 스코프";
    
    console.log(functionVar);  // "함수 레벨 var"
    console.log(functionLet);  // "함수 레벨 let"
    console.log(blockVar);     // "var는 함수 스코프"
    console.log(blockLet);     // "let은 블록 스코프"
    console.log(blockConst);   // "const도 블록 스코프"
  }
  
  console.log(functionVar);  // "함수 레벨 var"
  console.log(functionLet);  // "함수 레벨 let"
  console.log(blockVar);     // "var는 함수 스코프"
  // console.log(blockLet);  // ReferenceError: blockLet is not defined
  // console.log(blockConst); // ReferenceError: blockConst is not defined
  
  for (let i = 0; i < 3; i++) {
    // 'i'는 이 for 루프 블록에서만 유효
  }
  // console.log(i);  // ReferenceError: i is not defined
  
  for (var j = 0; j < 3; j++) {
    // 'j'는 함수 전체에서 유효
  }
  console.log(j);  // 3
}

blockScopeExample();

블록 스코프의 중요한 특징:

  • let과 const로 선언된 변수는 해당 블록 내에서만 접근 가능합니다.
  • 블록 스코프는 더 정확한 변수 생명주기 제어를 가능하게 합니다.
  • 블록 스코프는 코드 블록이 끝나면 메모리에서 변수가 제거될 수 있어 메모리 효율이 높습니다.

스코프 체인(Scope Chain)

스코프 체인은 중첩된 스코프 간의 연결을 의미합니다. 자바스크립트 엔진은 변수를 찾을 때 현재 스코프에서 시작하여 변수를 찾지 못하면 바깥쪽 스코프로 순차적으로 검색합니다.

 
javascript
var globalVar = "전역 변수";

function outerFunction() {
  var outerVar = "외부 함수 변수";
  
  function innerFunction() {
    var innerVar = "내부 함수 변수";
    
    console.log(innerVar);   // "내부 함수 변수" (현재 스코프)
    console.log(outerVar);   // "외부 함수 변수" (바깥 스코프)
    console.log(globalVar);  // "전역 변수" (전역 스코프)
  }
  
  innerFunction();
  console.log(innerVar);  // ReferenceError (내부 함수의 변수에 접근 불가)
}

outerFunction();

이 코드에서 스코프 체인은 다음과 같이 구성됩니다:

  • innerFunction 스코프 → outerFunction 스코프 → 전역 스코프

스코프 체인의 동작 방식:

  1. 현재 스코프에서 변수를 찾습니다.
  2. 찾지 못하면 바로 바깥쪽 스코프로 이동합니다.
  3. 계속해서 바깥쪽으로 이동하며 변수를 찾습니다.
  4. 전역 스코프까지 검색했는데도 변수를 찾지 못하면 ReferenceError가 발생합니다.

스코프 체인과 관련된 중요 개념

변수 섀도잉(Variable Shadowing)

안쪽 스코프에서 바깥쪽 스코프의 변수와 같은 이름의 변수를 선언하면, 안쪽 변수가 바깥쪽 변수를 '가리는' 현상입니다.

 
javascript
var name = "전역 이름";

function printName() {
  var name = "함수 이름";  // 전역 변수 'name'을 가림
  console.log(name);       // "함수 이름"
  
  if (true) {
    let name = "블록 이름";  // 함수 변수 'name'을 가림
    console.log(name);      // "블록 이름"
  }
  
  console.log(name);  // "함수 이름"
}

printName();
console.log(name);    // "전역 이름"

전역 언섀도잉(Global Unshadowing)

window 객체(브라우저 환경)를 통해 가려진 전역 변수에 직접 접근할 수 있습니다.

 
javascript
var count = 10;

function updateCount() {
  var count = 100;           // 전역 변수 'count'를 가림
  console.log(count);        // 100
  console.log(window.count); // 10 (전역 변수 직접 접근)
  
  // 전역 변수 수정
  window.count = 20;
}

updateCount();
console.log(count);  // 20 (전역 변수가 수정됨)

스코프와 호이스팅(Hoisting)

호이스팅은 자바스크립트 엔진이 코드를 실행하기 전에 변수와 함수 선언을 메모리에 저장하는 동작을 말합니다. 이로 인해 선언 전에도 변수와 함수에 접근할 수 있는 것처럼 보입니다.

변수 호이스팅

 
javascript
console.log(varVariable);  // undefined
var varVariable = "var 변수";

// console.log(letVariable);  // ReferenceError: letVariable is not defined
let letVariable = "let 변수";

// console.log(constVariable);  // ReferenceError: constVariable is not defined
const constVariable = "const 변수";

var로 선언한 변수는 선언 전에 접근해도 undefined가 출력되지만, let과 const로 선언한 변수는 선언 전에 접근하면 에러가 발생합니다. 이는 let과 const가 TDZ(Temporal Dead Zone)에 의해 초기화 전 접근이 제한되기 때문입니다.

함수 호이스팅

 
javascript
// 함수 선언문: 호이스팅됨
sayHello();  // "안녕하세요!"
function sayHello() {
  console.log("안녕하세요!");
}

// 함수 표현식: 변수만 호이스팅됨
// sayHi();  // TypeError: sayHi is not a function
var sayHi = function() {
  console.log("안녕하세요!");
};

// 화살표 함수: 변수만 호이스팅됨
// sayHola();  // ReferenceError: Cannot access 'sayHola' before initialization
let sayHola = () => {
  console.log("Hola!");
};

함수 선언문은 완전히 호이스팅되어 선언 전에도 호출 가능하지만, 함수 표현식과 화살표 함수는 변수 호이스팅 규칙을 따릅니다.

ES 모듈과 스코프

ES6부터 도입된 모듈 시스템은 스코프에 중요한 변화를 가져왔습니다. 모듈의 최상위에 선언된 변수와 함수는 전역 스코프가 아닌 모듈 스코프에 속합니다.

 
javascript
// module.js
var studentName = "카일";

function hello() {
  console.log(`${studentName} 님, 안녕하세요!`);
}

hello();
// 카일 님, 안녕하세요!

export hello;
 
javascript
// main.js
import { hello } from './module.js';

// module.js의 studentName에는 접근할 수 없음
// console.log(studentName);  // ReferenceError

hello();  // "카일 님, 안녕하세요!"

모듈에서 studentName과 hello 함수는 모듈 범위(module-wide) 스코프의 변수가 되며, 전역 변수로 등록되지 않습니다.

예제 1: 루프 내 비동기 함수와 스코프

 
javascript
function createButtons() {
  // 버튼 5개 생성하기
  for (var i = 0; i < 5; i++) {
    var button = document.createElement("button");
    button.innerText = "버튼 " + i;
    
    // 클릭 이벤트 핸들러 추가
    button.addEventListener("click", function() {
      console.log("버튼 " + i + "가 클릭됨");
    });
    
    document.body.appendChild(button);
  }
}

createButtons();

 

예상 결과: 각 버튼을 클릭하면 "버튼 0가 클릭됨", "버튼 1가 클릭됨" 등이 출력될 것 같습니다.

실제 결과: 모든 버튼이 "버튼 5가 클릭됨"을 출력합니다!

원인 분석

이 문제는 스코프와 클로저의 이해 부족에서 발생합니다:

  1. var i는 함수 스코프를 가집니다 (for 블록 내부가 아님).
  2. 이벤트 리스너 함수는 버튼 클릭 시 나중에 실행됩니다.
  3. 그 시점에 for 루프는 이미 종료되었고 i의 값은 5가 되었습니다.
  4. 모든 이벤트 리스너는 같은 i 변수를 참조하고 있습니다.

해결 방법 1: 블록 스코프 변수 사용하기

 
javascript
function createButtons() {
  // let으로 변경하여 블록 스코프 사용
  for (let i = 0; i < 5; i++) {
    const button = document.createElement("button");
    button.innerText = "버튼 " + i;
    
    button.addEventListener("click", function() {
      console.log("버튼 " + i + "가 클릭됨");
    });
    
    document.body.appendChild(button);
  }
}

createButtons();

이제 각 루프 반복마다 새로운 i 변수가 생성되므로, 각 이벤트 리스너는 자신의 고유한 i 값을 참조합니다.

해결 방법 2: 즉시 실행 함수로 스코프 생성하기

 
javascript
function createButtons() {
  for (var i = 0; i < 5; i++) {
    // 즉시 실행 함수를 사용하여 새로운 스코프 생성
    (function(index) {
      var button = document.createElement("button");
      button.innerText = "버튼 " + index;
      
      button.addEventListener("click", function() {
        console.log("버튼 " + index + "가 클릭됨");
      });
      
      document.body.appendChild(button);
    })(i);
  }
}

createButtons();

즉시 실행 함수는 각 반복마다 새로운 스코프를 생성하고, 현재 i 값을 매개변수 index로 복사합니다. 이렇게 하면 각 이벤트 리스너는 자신의 스코프에 있는 index 값을 참조합니다.

이 시뮬레이션은 스코프의 중요성과 특히 비동기 코드에서 발생할 수 있는 문제를 보여줍니다. var와 let의 스코프 차이를 이해하고, 클로저가 어떻게 외부 스코프의 변수를 "기억"하는지 이해하면 이러한 문제를 쉽게 해결할 수 있습니다.

실제 개발에서의 스코프 활용

1. 전역 스코프 오염 방지

전역 스코프에 너무 많은 변수를 선언하면 이름 충돌과 의도치 않은 부작용이 발생할 수 있습니다. 이를 방지하는 방법:

 
javascript
// 즉시 실행 함수 표현식(IIFE)으로 스코프 생성
(function() {
  var privateVar = "비공개 변수";
  
  function privateFunction() {
    console.log("비공개 함수");
  }
  
  // 필요한 경우만 전역으로 노출
  window.myApp = window.myApp || {};
  window.myApp.publicAPI = {
    doSomething: function() {
      privateFunction();
      return "작업 완료!";
    }
  };
})();

// privateVar, privateFunction에 직접 접근 불가
// window.myApp.publicAPI.doSomething()으로만 접근 가능

2. 모듈 패턴

스코프를 활용한 모듈 패턴:

 
javascript
var Counter = (function() {
  // 비공개 변수
  var count = 0;
  
  // 비공개 함수
  function validateCount(newCount) {
    return newCount >= 0;
  }
  
  // 공개 API
  return {
    increment: function() {
      count++;
      return count;
    },
    decrement: function() {
      if (validateCount(count - 1)) {
        count--;
      }
      return count;
    },
    getValue: function() {
      return count;
    },
    reset: function() {
      count = 0;
      return count;
    }
  };
})();

console.log(Counter.getValue());  // 0
Counter.increment();
Counter.increment();
console.log(Counter.getValue());  // 2
Counter.reset();
console.log(Counter.getValue());  // 0
// console.log(count);  // ReferenceError (비공개 변수에 접근 불가)

3. 블록 스코프 활용하기

 
javascript
// 임시 변수의 생명 주기를 제한하기
{
  let temp = calculateSomething();
  processTempData(temp);
  // temp는 이 블록 외부에서 접근 불가
}

// 루프에서 블록 스코프 활용
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);  // 0, 1, 2, 3, 4
}

// var를 사용하면 다른 결과
for (var j = 0; j < 5; j++) {
  setTimeout(() => console.log(j), 100);  // 5, 5, 5, 5, 5
}

마치며: 스코프의 중요성과 올바른 활용

지금까지 자바스크립트에서 스코프의 개념과 종류, 스코프 체인의 작동 방식에 대해 살펴봤습니다. 스코프는 자바스크립트의 가장 기본적이면서도 중요한 개념 중 하나로, 이를 제대로 이해하면 더 깨끗하고 유지보수하기 쉬운 코드를 작성할 수 있습니다.

특히 스코프 체인과 전역 스코프에 대한 이해는 자바스크립트 개발자로서 성장하는 데 필수적입니다. 변수가 어느 스코프에 속하는지, 스코프 체인을 통해 어떻게 변수를 찾는지, 그리고 각 선언 키워드(var, let, const)가 스코프에 어떤 영향을 미치는지 이해하면 많은 일반적인 자바스크립트 문제를 방지할 수 있습니다.

다음 포스팅에서는 "스코프의 작동 방식과 쓰임새"에 대해 더 깊이 알아보겠습니다. 특히 클로저, 렉시컬 환경, 그리고 스코프 체인의 실제 활용 사례를 자세히 다룰 예정입니다.

참고 자료:

  • "You Don't Know JS Yet" by Kyle Simpson
  • ECMAScript 명세서
  • MDN Web Docs - JavaScript 가이드

이번 포스팅에서는 JS 엔진이 우리가 작성한 코드를 어떻게 처리하는지 깊이 있게 알아보겠습니다.

많은 개발자들이 자바스크립트를 사용하면서도 그 내부 동작 원리에 대해서는 깊이 이해하지 못하는 경우가 많습니다. 그러나 코드가 어떻게 처리되는지 이해하면 더 효율적인 코드 작성은 물론, 디버깅 능력도 크게 향상됩니다. 특히 스코프와 클로저 같은 고급 개념을 마스터하기 위한 기본 토대가 됩니다.

본 포스팅은 4번에 걸쳐서 업로드 됩니다.

  1. JS엔진은 우리가 작성한 코드를 어떻게 처리하나?
  2. 스코프는 무엇인가?
  3. 스코프의 작동 방식과 쓰임새
  4. 스코프를 다룰 때 주의할 점

 

자바스크립트와 컴파일 과정

자바스크립트 코드는 실행되기 전에 반드시 처리 과정을 거칩니다. 이 과정은 명확히 "컴파일레이션(compilation)"이라고 할 수 있습니다. 왜냐하면:

  1. 코드는 실행되기 전에 먼저 파싱됩니다
  2. 다양한 최적화가 수행됩니다
  3. 실행 가능한 형태로 변환됩니다

전통적인 컴파일 언어(C++, Java)와의 가장 큰 차이점은 자바스크립트에서는 컴파일과 실행 사이의 시간이 매우 짧다는 것입니다. 그러나 분명한 컴파일 단계가 존재합니다.

 

코드 컴파일 과정

자바스크립트 엔진이 코드를 처리하는 과정을 더 구체적으로 살펴봅시다. 이 과정은 크게 세 단계로 나뉩니다:

1. 토크나이징/렉싱(Tokenizing/Lexing)

토큰화는 문자열을 의미 있는 토큰(token)으로 나누는 과정입니다. 예를 들어:

var greeting = "안녕하세요";

위 코드는 다음과 같은 토큰으로 나뉩니다:

  • var (키워드)
  • greeting (식별자)
  • = (할당 연산자)
  • "안녕하세요" (문자열 리터럴)
  • ; (세미콜론)

이 단계에서 구문 오류가 발견되면 바로 SyntaxError가 발생합니다:

var greeting = "안녕하세요; // 닫는 따옴표 누락
// Uncaught SyntaxError: Invalid or unexpected token

2. 파싱, AST 생성

파싱 단계에서는 토큰 배열을 프로그램 문법에 맞는 중첩 구조인 AST(Abstract Syntax Tree, 추상 구문 트리)로 변환합니다.

var greeting = "안녕하세요"; 코드의 AST는 대략 다음과 같은 구조를 가집니다:

VariableDeclaration
  ├── kind: "var"
  └── declarations: [
        VariableDeclarator
          ├── id: Identifier(name: "greeting")
          └── init: Literal(value: "안녕하세요", raw: "\"안녕하세요\"")
      ]

AST Explorer(https://astexplorer.net/)와 같은 도구를 사용하면 실제 자바스크립트 코드의 AST를 시각적으로 확인할 수 있습니다. 복잡한 코드의 동작 방식을 이해하는 데 큰 도움이 됩니다.

3. 코드 생성

마지막으로, AST는 실행 가능한 코드로 변환됩니다. 이 단계에서 다음과 같은 중요한 작업이 수행됩니다:

  • 변수와 함수 선언을 처리하고 스코프와 연결
  • 코드 최적화
  • 바이트코드 또는 기계어 생성

코드 생성 단계에서는 변수나 함수 선언들의 스코프가 결정되고, 이후 실행 단계에서 활용될 준비가 됩니다.

코드 실행 과정

컴파일 과정이 완료되면 생성된 코드가 실행됩니다. 실행 단계에서는 앞서 결정된 스코프를 기반으로 변수와 함수들이 메모리에 할당되고 접근됩니다.

실행 흐름 이해하기

간단한 예제를 통해 전체 프로세스를 이해해 봅시다:

console.log(greeting);  // undefined
var greeting = "안녕하세요";
console.log(greeting);  // "안녕하세요"

function sayHello() {
  console.log("Hello!");
}
sayHello();  // "Hello!"

이 코드의 컴파일 및 실행 흐름:

  1. 컴파일 단계:
    • greeting 변수 선언 인식 및 스코프에 등록
    • sayHello 함수 선언 인식 및 스코프에 등록
    • 실행 코드 생성
  2. 실행 단계:
    • 첫 번째 console.log(greeting) 실행: greeting은 선언만 되고 아직 할당되지 않아 undefined 출력
    • greeting = "안녕하세요" 실행: 변수에 값 할당
    • 두 번째 console.log(greeting) 실행: 할당된 값 "안녕하세요" 출력
    • sayHello() 함수 호출: "Hello!" 출력

여기서 greeting이 첫 번째 로그에서 undefined로 출력되는 현상이 바로 호이스팅(hoisting)이라고 하는데, 이는 컴파일 단계에서 선언문이 먼저 처리되기 때문에 발생합니다. 호이스팅에 대해서는 다음 포스팅에서 더 자세히 다루겠습니다.

타깃과 소스의 개념

자바스크립트 엔진이 코드를 컴파일할 때 마주하는 중요한 과제 중 하나는 모든 변수와 함수의 스코프를 올바르게 결정하는 것입니다. 이 과정에서 컴파일러는 각 식별자(변수, 함수명 등)가 "타깃(target)"인지 "소스(source)"인지 판단해야 합니다.

타깃과 소스란?

  • 타깃(Target): 값이 할당되는 변수 (할당문의 왼쪽에 위치)
  • 소스(Source): 값을 제공하는 변수 (할당문의 오른쪽이나 표현식에 위치)

다음 코드에서 타깃과 소스를 식별해봅시다:

var students = [
  { id: 14, name: "카일" },
  { id: 73, name: "수지" },
  { id: 112, name: "지영" },
  { id: 6, name: "푸른" }
];

function getStudentName(studentID) {
  for (let student of students) {
    if (student.id == studentID) {
      return student.name;
    }
  }
}

var nextStudent = getStudentName(73);
console.log(nextStudent);  // "수지"

이 코드에서:

  • students는 배열 리터럴이 할당되는 타깃
  • studentID는 함수 호출 시 인자 값(73)이 할당되는 타깃
  • student는 for 루프에서 배열의 각 요소가 할당되는 타깃
  • getStudentName 함수 호출에서 73은 소스
  • nextStudent = getStudentName(73)에서 nextStudent는 타깃, 함수 호출 결과는 소스
  • console.log에서 nextStudent는 소스

실제 개발에서의 응용

타깃과 소스 개념을 이해하면 코드의 흐름과 변수 사용을 더 명확하게 파악할 수 있습니다. 예를 들어 다음과 같은 코드가 있다고 가정합시다:

var x = 10;
var y = x + 5;
var z = y * 2;

여기서 변수들의 역할을 분석하면:

  • 첫 번째 라인: x는 타깃, 10은 소스(리터럴)
  • 두 번째 라인: y는 타깃, x와 5는 소스
  • 세 번째 라인: z는 타깃, y와 2는 소스

이렇게 코드를 분석하면 데이터의 흐름을 더 명확하게 이해할 수 있습니다. 특히 복잡한 함수나 클로저를 다룰 때 이러한 분석이 도움이 됩니다.

렉시컬 스코프와 컴파일의 관계

지금까지 살펴본 자바스크립트의 컴파일 과정은 렉시컬 스코프의 개념과 직접적으로 연결됩니다. 자바스크립트에서 스코프는 컴파일 타임에 결정되며, 이를 렉시컬 스코프(어휘적 스코프)라고 합니다.

렉시컬 스코프의 핵심 원리

렉시컬 스코프의 가장 중요한 특징은 함수나 블록, 변수 선언의 스코프가 전적으로 코드의 물리적 배치에 따라 결정된다는 점입니다. 간단히 말해, 코드를 작성할 때 변수와 함수를 어디에 배치하느냐에 따라 스코프가 정해집니다.

var globalVar = "전역 변수";

function outer() {
  var outerVar = "외부 함수 변수";
  
  function inner() {
    var innerVar = "내부 함수 변수";
    console.log(globalVar);  // 전역 스코프에서 찾음
    console.log(outerVar);   // outer 함수 스코프에서 찾음
    console.log(innerVar);   // 현재 스코프에서 찾음
  }
  
  inner();
}

outer();

이 코드에서:

  • inner 함수는 자신의 렉시컬 스코프(inner 함수 내부), outer 함수의 스코프, 그리고 전역 스코프에 접근할 수 있습니다.
  • 반면, outer 함수는 inner 함수의 스코프에 접근할 수 없습니다.

컴파일 시 스코프 맵 생성

컴파일 과정에서 JS 엔진은 프로그램 전체의 스코프 구조를 담은 일종의 "지도"를 생성합니다. 이 지도는 어떤 변수가 어떤 스코프에 속하는지를 정의하며, 런타임에 변수를 찾을 때 사용됩니다.

중요한 점은 컴파일레이션 중에는 스코프를 식별하기만 하고, 실제 스코프 객체는 코드가 실행되는 런타임에 생성된다는 것입니다. 컴파일 단계에서는 스코프와 변수의 메모리 예약 관점에서 실제로는 아무것도 실행되지 않습니다.

스코프 체인과 변수 검색

변수를 참조할 때, JS 엔진은 현재 스코프에서 시작해 외부 스코프로 단계적으로 이동하며 변수를 찾습니다:

  1. 현재 스코프에서 변수명 검색
  2. 찾지 못하면 바로 바깥 스코프로 이동하여 검색
  3. 다시 찾지 못하면 또 바깥 스코프로 이동
  4. 전역 스코프까지 검색했는데도 찾지 못하면 ReferenceError 발생

이 과정은 변수가 어디에서 선언되었는지를 기준으로 한 렉시컬 스코핑이며, 코드가 어디서 호출되는지가 아니라 어디에 작성되었는지가 중요합니다.

렉시컬 스코프의 실용적 의미

렉시컬 스코프를 이해하면 코드를 더 예측 가능하게 작성할 수 있습니다. 몇 가지 실용적인 팁:

  1. 변수 선언은 사용 범위에 가장 가까운 스코프에서 하기: 전역 변수 사용을 최소화하고, 필요한 스코프에서만 변수를 선언합니다.
  2. 같은 이름의 변수 중복 선언 피하기: 특히 중첩된 스코프에서 같은 이름의 변수를 사용하면 예상치 못한 결과가 발생할 수 있습니다.
  3. 블록 스코프 활용하기: ES6의 let과 const를 사용해 변수의 스코프를 최소화합니다.
// 피해야 할 패턴
var userId = 123;

function processUser() {
  // 의도치 않게 전역 변수를 덮어씀
  userId = 456;
  // ...
}

// 권장하는 패턴
var userId = 123;

function processUser() {
  // 지역 변수로 선언하여 스코프 분리
  let userId = 456;
  // ...
}

마치며: 렉시컬 스코프와 코드 작성의 중요성

지금까지 JS 엔진이 코드를 처리하는 방식에 대해 살펴봤습니다. 자바스크립트는 컴파일 과정을 거쳐 코드를 실행하며, 이 과정에서 렉시컬 스코프가 결정됩니다.

자바스크립트에서 스코프가 컴파일 타임에 결정된다는 사실은 코드를 작성하는 방식이 매우 중요하다는 것을 의미합니다. 변수와 함수를 어디에 배치하느냐에 따라 전체 프로그램의 동작이 결정됩니다.

컴파일 중에는 스코프를 식별하기만 하고, 실제 각 스코프를 실행해야만 하는 런타임 전까지는 스코프가 생성되지 않습니다. 컴파일 단계에서는 프로그램 실행에 필요한 모든 렉시컬 스코프가 들어간 '지도'를 만들어냅니다. 이것이 런타임에 사용할 모든 코드가 들어간 계획안이라고 생각하면 됩니다.

이러한 이해를 바탕으로 코드를 작성하면:

  • 의도하지 않은 변수 참조나 충돌을 방지할 수 있습니다
  • 코드의 예측 가능성과 유지보수성이 향상됩니다
  • 스코프 관련 디버깅을 더 쉽게 할 수 있습니다

다음 포스팅에서는 "스코프는 무엇인가?"라는 주제로, 자바스크립트의 다양한 스코프 유형과 작동 방식에 대해 더 깊이 알아보겠습니다. 특히 함수 스코프와 블록 스코프의 차이, 중첩 스코프, 그리고 스코프 체인의 동작 원리를 자세히 살펴볼 예정입니다.


참고 자료:

  • "You Don't Know JS Yet" by Kyle Simpson
  • ECMA-262 명세서
  • MDN Web Docs

이 내용은 nginx를 쓰고 있는 가상서버에서는 공통적으로 가능한 ssl 세팅입니다.

1. SSL 인증서 파일 준비하기

일반적인 웹사이트의 경우, SSL 인증서를 구매하거나 Let's Encrypt 같은 무료 서비스에서 발급받아 다음과 같은 파일들을 준비합니다:

  • ssl.crt 또는 certificate.crt: SSL 인증서 파일
  • ssl.key 또는 private.key: 프라이빗 키 파일

 

2. 필요한 디렉토리 생성하기

# 해당하는 폴더를 만듭니다.
sudo mkdir -p /etc/ssl/certs
sudo mkdir -p /etc/ssl/private

 

3. 인증서와 키 파일 복사 및 권한 설정

# 준비된 crt와 key 파일에 대한 권한을 설정합니다.
# 보통 filezilla를 통해 파일을 업로드한 뒤 작업합니다.
sudo chmod 644 /etc/ssl/certs/ssl.crt
sudo chmod 600 /etc/ssl/private/ssl.key

 

4. 암호화된 키 파일 처리하기

키 파일에 암호가 설정되어 있다면, 서버 재시작시 자동으로 SSL이 적용되도록 암호를 제거합니다:

# 원본 키 파일 백업
cp /etc/ssl/private/ssl.key /etc/ssl/private/ssl.key.orig

# 암호 없는 키 파일로 변환
openssl rsa -in /etc/ssl/private/ssl.key.orig -out /etc/ssl/private/ssl.key

# 적절한 권한 다시 설정
chmod 600 /etc/ssl/private/ssl.key

 

5. Nginx 설정 파일 수정하기

웹사이트의 설정 파일을 수정합니다:

# nginx의 경우 sites-available 에서 http, https 등의 블록을 위해 파일을 손봐야합니다.
# 파일의 경로가 꼭 이렇진 않습니다. sites-available만 참고해주세요
vim /etc/nginx/sites-available/{YOUR-WEBSITE}

- 일반적인 웹사이트의 Nginx 설정 예시:

# HTTP 설정 - HTTPS로 리다이렉트
server {
    listen 80;
    server_name example.com www.example.com;
    
    # HTTPS로 리다이렉트
    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS 설정
server {
    listen 443 ssl;
    server_name example.com www.example.com;

    # SSL 인증서 설정
    ssl_certificate /etc/ssl/certs/ssl.crt;
    ssl_certificate_key /etc/ssl/private/ssl.key;

    # SSL 프로토콜 설정
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384;
    
    # 정적 파일이 있는 루트 디렉토리 설정
    root /var/www/html;
    index index.html index.htm index.php;
    
    # 기본 위치 설정
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
    
    # PHP 처리 설정 (PHP 사용 시)
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; # PHP 버전에 맞게 수정
    }
    
    # 접근 로그와 에러 로그 위치
    access_log /var/log/nginx/example.com-access.log;
    error_log /var/log/nginx/example.com-error.log;
}

 

6. Nginx 설정 테스트 및 재시작

# 설정이 올바른지 테스트
nginx -t

# 설정이 올바르면 Nginx 재시작
systemctl restart nginx

 

7. 방화벽 설정 확인 (필요한 경우)

# UFW 방화벽 사용 시, HTTPS 포트 개방
sudo ufw allow 443/tcp

 

8. 확인 및 문제 해결

웹 브라우저에서 https://example.com으로 접속하여 SSL이 제대로 적용되었는지 확인합니다. 주소 표시줄에 자물쇠 아이콘이 표시되면 성공적으로 적용된 것입니다.

문제가 발생한 경우 다음 로그 파일을 확인합니다:

# Nginx 오류 로그 확인
tail -n 100 /var/log/nginx/error.log

 

이런 과정으로 가상서버에서 호스팅하는 웹사이트에 SSL을 적용하는 과정이 완료되었습니다.

가비아나 AWS에서는 간편하게 설정할 수 있었고, 자동으로 적용이 되었던 부분인데

SSL을 신청 받고 /.well-known/pki-validation/ 에 http 인증용 파일을 올렸는데도 발급이 안돼서 무슨 문제인가 했어요

다른 분들에게도 도움이 되었으면 좋겠습니다

이전 포스팅 https://ipjaworld.tistory.com/63 에서 이어지는 내용입니다.

포스팅은 3편에 나눠서 업로드 됩니다.

A. 자바스크립트 조망하기
1. 강제변환과 깊은비교, 일치비교와 동등비교,
2. js의 this 키워드와 프로토타입, 그리고 메모리주소

B. 추가 주제
1. es6 모듈에서 class를 사용하지 않는 이유

 

B. 추가 주제

1. ES6 모듈에서 class를 사용하지 않는 이유

ES6(ECMAScript 2015)에서는 클래스 문법과 모듈 시스템이 동시에 도입되었다. 그러나 "You Don't Know JS Yet"을 읽으면서 흥미로웠던 점은 많은 자바스크립트 개발자들, 특히 함수형 프로그래밍을 선호하는 개발자들이 ES6 모듈에서 클래스 사용을 지양한다는 것이다. 왜 그럴까?

클래스의 겉모습과 실제 동작의 차이

자바스크립트의 클래스는 다른 언어(Java, C++ 등)의 클래스와 문법적으로 유사하지만, 내부적으로는 여전히 프로토타입 기반으로 동작한다. 이런 "문법적 설탕(syntactic sugar)"은 자바스크립트의 객체 지향 특성을 이해하지 못한 채 다른 언어의 패러다임을 그대로 가져오게 만든다.

// 클래스 문법
class Person {
  constructor(name) {
    this.name = name;
  }
  
  greet() {
    return `Hello, I'm ${this.name}`;
  }
}

// 위 코드는 내부적으로 다음과 같이 동작한다
function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  return `Hello, I'm ${this.name}`;
};

this 바인딩 문제

클래스 메서드를 이벤트 핸들러나 콜백으로 전달할 때 this 바인딩 문제가 자주 발생한다.

class Counter {
  constructor() {
    this.count = 0;
    this.button = document.getElementById('button');
    // this 바인딩을 수동으로 처리해야 함
    this.button.addEventListener('click', this.increment.bind(this));
  }
  
  increment() {
    this.count++;
    console.log(this.count);
  }
}

이런 코드는 this 바인딩을 수동으로 처리해야 하므로 실수하기 쉽고, 코드의 복잡성이 증가한다.

상태 관리와 불변성

함수형 프로그래밍에서는 상태 변경을 최소화하고 불변성(immutability)을 중요시한다. 클래스는 본질적으로 내부 상태를 가지며, 메서드를 통해 그 상태를 변경하는 방식으로 동작한다.

// 클래스 기반 접근법 (상태 변경)
class Cart {
  constructor() {
    this.items = [];
  }
  
  addItem(item) {
    this.items.push(item);  // 내부 상태 변경
  }
  
  getTotal() {
    return this.items.reduce((total, item) => total + item.price, 0);
  }
}

// 함수형 접근법 (불변성 유지)
const createCart = (items = []) => ({
  items,
  addItem: (item) => createCart([...items, item]),  // 새 객체 반환
  getTotal: () => items.reduce((total, item) => total + item.price, 0)
});

함수형 접근법은 원본 데이터를 변경하지 않고 새로운 상태를 반환하므로, 예측 가능성이 높아지고 부작용(side effects)이 감소한다.

모듈 패턴의 유연성

ES6 모듈은 그 자체로 격리된 스코프를 제공하므로, 클래스 없이도 캡슐화를 쉽게 구현할 수 있다.

// counter.js
let count = 0;  // 모듈 내부에서만 접근 가능한 private 상태

export const increment = () => ++count;
export const decrement = () => --count;
export const getCount = () => count;

// 사용
import { increment, getCount } from './counter.js';
increment();
console.log(getCount());  // 1

이 패턴은 클래스보다 더 간결하고, 필요한 기능만 노출할 수 있어 API 설계가 명확해진다.

상속보다 합성

클래스 상속은 종종 "깨지기 쉬운 기반 클래스(fragile base class)" 문제를 일으킨다. 부모 클래스의 변경이 예기치 않게 자식 클래스에 영향을 미칠 수 있기 때문이다.

// 상속 기반 접근법
class Animal {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  speak() {
    console.log(`${this.name} barks.`);
  }
}

// 합성 기반 접근법
const createAnimal = (name) => ({
  name,
  speak: () => console.log(`${name} makes a noise.`)
});

const createDog = (name) => ({
  ...createAnimal(name),
  speak: () => console.log(`${name} barks.`)
});

합성(composition)은 "has-a" 관계를 표현하며, 상속보다 더 유연하고 느슨한 결합을 제공한다.

테스트 용이성

함수형 접근법은 일반적으로 테스트하기 더 쉽다. 순수 함수(pure functions)는 동일한 입력에 대해 항상 동일한 출력을 반환하며, 외부 상태에 의존하지 않는다.

// 클래스 메서드 테스트 (상태에 의존)
test('Cart.addItem', () => {
  const cart = new Cart();
  cart.addItem({ name: 'Product', price: 10 });
  expect(cart.items.length).toBe(1);
});

// 함수형 접근법 테스트 (상태 독립적)
test('addItem', () => {
  const cart = createCart();
  const newCart = cart.addItem({ name: 'Product', price: 10 });
  expect(newCart.items.length).toBe(1);
  expect(cart.items.length).toBe(0);  // 원본은 변경되지 않음
});

클래스 vs 함수형 패턴 비교표

아래 표는 클래스 기반 접근법과 함수형 패턴의 주요 차이점을 비교한 것이다:

특성 클래스 기반 접근법 함수형 패턴

상태 관리 내부 상태를 변경 (mutable) 불변 상태 (immutable) 지향
코드 구조 객체와 메서드 중심 함수와 데이터 중심
상속 방식 클래스 상속 (is-a 관계) 객체 합성 (has-a 관계)
this 처리 컨텍스트 관리 필요 컨텍스트 의존성 최소화
부작용 객체 내부 상태 변경 흔함 부작용 최소화 지향
테스트 용이성 상태 의존적, 복잡할 수 있음 순수 함수로 테스트 용이
메모리 사용 인스턴스마다 메모리 할당 함수 공유, 효율적 메모리 사용 가능
디버깅 상태 추적이 어려울 수 있음 데이터 흐름 추적 용이
확장성 상속을 통한 확장 합성을 통한 확장
가독성 객체지향 개발자에게 친숙 함수형 패러다임 이해 필요

실제 프레임워크 사례: React의 함수형 컴포넌트 전환

React는 초기에 클래스 컴포넌트를 주로 사용했지만, 16.8 버전에서 Hooks가 도입되면서 함수형 컴포넌트로의 전환이 가속화되었다. 이는 프론트엔드 개발에서 함수형 접근법의 장점을 잘 보여주는 사례다.

// 클래스 컴포넌트
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.increment = this.increment.bind(this);
  }

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.increment}>Increment</button>
      </div>
    );
  }
}

// 함수형 컴포넌트 + Hooks
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

함수형 컴포넌트는 더 간결하고, this 바인딩 문제가 없으며, Hooks를 통해 상태 관리와 생명주기 기능을 필요한 만큼만 사용할 수 있다. React 팀은 공식적으로 함수형 컴포넌트와 Hooks 사용을 권장하고 있으며, 이는 자바스크립트 생태계에서 함수형 프로그래밍의 영향력을 보여준다.

또한 React의 state 업데이트 철학은 불변성에 기반하며, 이는 함수형 프로그래밍의 핵심 원칙과 일치한다:

// 권장되지 않는 방식 (직접 상태 변경)
const handleClick = () => {
  const newItems = this.state.items;
  newItems.push('New Item');
  this.setState({ items: newItems }); // 잘못된 패턴
};

// 권장되는 방식 (불변성 유지)
const handleClick = () => {
  this.setState(prevState => ({
    items: [...prevState.items, 'New Item'] // 새 배열 생성
  }));
};

다른 라이브러리와 프레임워크에서의 함수형 접근

React 외에도 많은 현대적인 자바스크립트 라이브러리와 프레임워크들이 함수형 접근법을 채택하고 있다:

  1. Redux: 예측 가능한 상태 관리를 위해 순수 함수인 리듀서(reducer)와 불변성 원칙을 따른다.
  2. Vue 3: Composition API를 도입하여 함수형 접근법을 지원한다.
  3. Svelte: 선언적 템플릿과 반응형 상태를 사용하며, 클래스 대신 함수형 패러다임을 채택한다.
  4. Functional CSS Libraries (Tailwind, Tachyons): 작은 단일 기능 클래스를 합성하여 스타일을 구성하는 방식은 합성 개념과 일치한다.

성능 고려사항

클래스 인스턴스 생성은 프로토타입 체인 설정과 new 연산자 오버헤드가 있다. 단순 객체나 클로저를 사용하는 팩토리 함수는 경우에 따라 더 효율적일 수 있다.

물론 현대 자바스크립트 엔진은 최적화가 잘 되어 있어 대부분의 경우 성능 차이는 미미하지만, 성능이 중요한 애플리케이션에서는 고려할 만한 요소다.

결론: 상황에 맞는 도구 선택하기

클래스가 항상 나쁜 것은 아니다. 특히 다음과 같은 경우에는 클래스가 적합할 수 있다:

  1. 객체 지향 설계가 자연스러운 도메인(예: UI 컴포넌트)
  2. 상속이 명확하게 "is-a" 관계를 표현할 때
  3. 팀이 클래스 기반 패러다임에 더 익숙한 경우
  4. 기존 클래스 기반 라이브러리/프레임워크와 통합해야 할 경우

하지만 모듈 시스템을 사용할 때는 항상 가장 단순하고 명확한 방법을 선택하는 것이 좋다. 대부분의 경우, 간단한 팩토리 함수나 객체 리터럴, 또는 순수 함수의 집합으로도 충분하다.

"You Don't Know JS Yet"에서 배운 가장 중요한 것 중 하나는 자바스크립트의 유연성을 이해하고, 특정 패러다임에 얽매이지 않고 상황에 가장 적합한 도구를 선택하는 것이다. 클래스가 필요하다면 사용하되, 단순히 다른 언어의 패턴을 모방하기 위해 사용하는 것은 피하자.

이전 포스팅 https://ipjaworld.tistory.com/63 에서 이어지는 내용입니다.

포스팅은 3편에 나눠서 업로드 됩니다.

A. 자바스크립트 조망하기
1. 강제변환과 깊은비교, 일치비교와 동등비교,
2. js의 this 키워드와 프로토타입, 그리고 메모리주소

B. 추가 주제
1. es6 모듈에서 class를 사용하지 않는 이유

 

A. 자바스크립트 조망하기

2. js의 this 키워드와 프로토타입, 그리고 메모리주소

"You Don't Know JS Yet"을 읽기 전까지 자바스크립트의 this 키워드와 프로토타입 시스템은 나에게 미지의 영역과 같았다. 특히 다른 언어에서의 this와 다르게 동작하는 자바스크립트의 this는 많은 혼란을 가져왔다. 이번에는 this와 프로토타입, 그리고 자바스크립트의 메모리 주소 관리에 대해 내가 새롭게 이해한 내용을 정리해보려 한다.

this 키워드: 호출 시점에 결정되는 컨텍스트

자바스크립트의 this는 다른 언어와 달리 함수가 호출되는 방식에 따라 결정된다. 이것은 내가 기존에 알고 있던 객체 지향 언어(예: Java, C++)의 this와 완전히 다른 개념이다.

가장 헷갈렸던 코드 예시를 살펴보자:

const person = {
  name: "Alice",
  greet: function() {
    console.log(`Hello, my name is ${this.name}`);
  }
};

person.greet();  // "Hello, my name is Alice"

const greetFunction = person.greet;
greetFunction();  // "Hello, my name is undefined"

왜 같은 함수를 호출했는데 다른 결과가 나올까? 그것은 this가 함수를 어떻게 호출했느냐에 따라 결정되기 때문이다.

this 바인딩 규칙

자바스크립트에서 this는 다음 규칙에 따라 결정된다(우선순위 순):

  1. 명시적 바인딩: call, apply, bind 메서드를 사용해 this를 명시적으로 지정할 수 있다.
  2. greetFunction.call(person); // "Hello, my name is Alice"
  3. new 바인딩: 생성자 함수를 new 키워드로 호출하면 this는 새로 생성된 객체를 가리킨다.
  4. function Person(name) { this.name = name; } const alice = new Person("Alice"); // this는 새로 생성된 객체를 가리킴
  5. 암시적 바인딩: 객체의 메서드로 호출되면 this는 그 객체를 가리킨다.
  6. person.greet(); // this는 person을 가리킴
  7. 기본 바인딩: 일반 함수 호출 시 this는 전역 객체(브라우저에서는 window, Node.js에서는 global)를 가리킨다. 엄격 모드('use strict')에서는 undefined가 된다.
  8. function showThis() { console.log(this); } showThis(); // window 또는 global (또는 엄격 모드에서는 undefined)
  9. 화살표 함수: 화살표 함수는 자신만의 this를 갖지 않고, 외부 스코프의 this를 그대로 사용한다.화살표 함수는 선언될 때의 외부 스코프 this를 유지하므로, 객체 메서드나 이벤트 핸들러에서 원래 객체의 컨텍스트를 유지하고 싶을 때 유용하다:
  10. const counter = { count: 0, increase: function() { // 화살표 함수는 counter의 this를 유지 setInterval(() => { console.log(++this.count); // counter.count를 증가 }, 1000); } }; counter.increase();
  11. const person = { name: "Alice" }; const normalFunction = function() { return this.name; }; const arrowFunction = () => this.name; console.log(normalFunction.call(person)); // "Alice" console.log(arrowFunction.call(person)); // undefined (또는 전역 객체의 name)

이 규칙들을 이해하기 전에는 this의 동작이 너무 혼란스러웠다. 특히 콜백 함수에서 this가 예상과 다르게 동작하는 경우가 많았는데, ES6의 화살표 함수는 이런 문제를 해결하는 데 큰 도움이 되었다.

프로토타입: 자바스크립트의 상속 메커니즘

자바스크립트의 프로토타입 시스템은 클래스 기반 언어와는 다른 방식으로 상속을 구현한다. 이를 "프로토타입 기반 상속"이라고 한다.

프로토타입 체인

모든 자바스크립트 객체는 내부 프로퍼티 [[Prototype]](또는 __proto__)를 갖고 있으며, 이는 다른 객체를 가리킨다. 객체의 프로퍼티를 참조할 때 그 객체에 해당 프로퍼티가 없으면, 자바스크립트 엔진은 프로토타입 체인을 따라 프로퍼티를 찾는다.

const animal = {
  eat: function() {
    console.log("Eating...");
  }
};

const dog = Object.create(animal);
dog.bark = function() {
  console.log("Woof!");
};

dog.eat();  // "Eating..." - dog 객체에 없지만 프로토타입 체인을 통해 animal에서 찾음

이 코드에서 dog의 프로토타입은 animal 객체다. dog.eat()을 호출하면, dog 객체에 eat 메서드가 없기 때문에 프로토타입 체인을 따라 animal 객체에서 eat 메서드를 찾는다.

__proto__와 prototype의 차이점

자바스크립트에서 __proto__와 prototype은 다른 개념이다:

  • __proto__: 모든 객체가 가지는 내부 프로퍼티로, 해당 객체의 프로토타입을 가리킨다.
  • prototype: 함수 객체만 가지는 프로퍼티로, 해당 함수를 생성자로 사용했을 때 만들어지는 객체의 프로토타입이 된다.
function Dog(name) {
  this.name = name;
}

Dog.prototype.bark = function() {
  console.log(`${this.name} says woof!`);
};

const rex = new Dog("Rex");

console.log(rex.__proto__ === Dog.prototype);  // true
console.log(Dog.__proto__ === Function.prototype);  // true

생성자 함수와 prototype 속성

함수는 특별한 prototype 속성을 갖는데, 이는 해당 함수를 생성자로 사용해 만든 객체의 프로토타입이 된다.

function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  console.log(`Hello, my name is ${this.name}`);
};

const alice = new Person("Alice");
alice.greet();  // "Hello, my name is Alice"

이 코드에서 alice의 프로토타입은 Person.prototype이다. 따라서 alice 객체는 Person.prototype에 정의된 greet 메서드에 접근할 수 있다.

클래스 문법과 프로토타입

ES6에서 도입된 class 문법은 프로토타입 기반 상속을 더 쉽고 명확하게 사용할 수 있게 해준다. 하지만 내부적으로는 여전히 프로토타입을 사용한다.

class Animal {
  constructor(name) {
    this.name = name;
  }
  
  eat() {
    console.log(`${this.name} is eating.`);
  }
}

class Dog extends Animal {
  bark() {
    console.log(`${this.name} says woof!`);
  }
}

const rex = new Dog("Rex");
rex.eat();  // "Rex is eating."
rex.bark();  // "Rex says woof!"

이 코드에서 rex의 프로토타입 체인은 Dog.prototype -> Animal.prototype -> Object.prototype이다.

클로저와 프로토타입의 관계

클로저는 자바스크립트의 또 다른 강력한 개념이다. 클로저는 함수와 그 함수가 선언된 렉시컬 환경의 조합으로, 내부 함수가 외부 함수의 변수에 접근할 수 있게 해준다.

프로토타입과 클로저를 함께 사용하면 강력한 패턴을 만들 수 있다:

function createCounter() {
  let count = 0;  // 클로저를 통해 접근 가능한 private 변수
  
  function Counter() {}
  
  Counter.prototype.increment = function() {
    count++;
    return count;
  };
  
  Counter.prototype.decrement = function() {
    count--;
    return count;
  };
  
  return new Counter();
}

const counter = createCounter();
console.log(counter.increment());  // 1
console.log(counter.increment());  // 2
console.log(counter.decrement());  // 1

이 예제에서 count 변수는 클로저를 통해 Counter 프로토타입의 메서드들이 접근할 수 있지만, 외부에서는 직접 접근할 수 없는 private 변수처럼 동작한다.

메모리 주소와 가비지 컬렉션

자바스크립트에서 변수의 메모리 관리는 대부분 자동으로 이루어진다. 하지만 그 동작 원리를 이해하는 것은 효율적인 코드를 작성하는 데 큰 도움이 된다.

원시 타입과 참조 타입의 메모리 관리

  1. 원시 타입: 원시 타입(string, number, boolean, null, undefined, symbol, bigint)은 값 자체가 변수에 저장된다. 변수에 다른 원시 값을 할당하면 이전 값은 덮어써진다.
  2. 참조 타입: 참조 타입(object, array, function 등)은 실제 객체는 힙(heap) 메모리에 저장되고, 변수에는 그 객체의 메모리 주소가 저장된다.
let x = { name: "John" };
let y = x;  // y는 x와 같은 객체를 참조

y.name = "Jane";
console.log(x.name);  // "Jane" - x와 y는 같은 객체를 참조하기 때문

가비지 컬렉션

자바스크립트는 자동으로 더 이상 참조되지 않는 객체를 메모리에서 해제하는 가비지 컬렉션을 수행한다. 이를 "도달 가능성(reachability)" 알고리즘이라고 한다.

let obj = { data: "some data" };
// 여기서 obj는 객체를 참조

obj = null;  
// obj가 더 이상 객체를 참조하지 않으므로, 그 객체는 가비지 컬렉션의 대상이 됨

클로저와 메모리 누수

클로저는 함수가 외부 환경의 변수를 참조할 수 있게 해주는데, 이로 인해 의도치 않은 메모리 누수가 발생할 수 있다.

function setupHandler() {
  let element = document.getElementById("button");
  let counter = 0;
  
  element.addEventListener("click", function() {
    console.log(++counter);
  });
}

이 코드에서 클로저(이벤트 핸들러 함수)는 counter 변수를 참조하고 있다. 만약 이벤트 리스너를 제거하지 않으면, counter 변수는 가비지 컬렉션의 대상이 되지 않아 메모리 누수가 발생할 수 있다.

메모리 누수를 방지하려면 다음과 같이 이벤트 리스너를 제거해야 한다:

function setupHandler() {
  let element = document.getElementById("button");
  let counter = 0;
  
  const clickHandler = function() {
    console.log(++counter);
  };
  
  element.addEventListener("click", clickHandler);
  
  // 나중에 이벤트 리스너 제거
  return function cleanup() {
    element.removeEventListener("click", clickHandler);
    element = null; // DOM 요소에 대한 참조도 제거
  };
}

const cleanup = setupHandler();
// 필요 없어지면 cleanup 함수 호출
// cleanup();

이터러블과 메모리 효율성

ES6에서 도입된 이터러블(iterable)과 제너레이터(generator)는 메모리 효율적인 데이터 처리를 가능하게 한다.

function* infiniteSequence() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

const iterator = infiniteSequence();
console.log(iterator.next().value);  // 0
console.log(iterator.next().value);  // 1

이 제너레이터 함수는 무한한 숫자 시퀀스를 생성하지만, 모든 값을 메모리에 저장하지 않고 필요할 때만 값을 생성한다. 이는 대용량 데이터를 처리할 때 매우 유용하다.

비동기 코드에서의 this 바인딩

비동기 코드에서 this 바인딩은 특히 주의해야 할 부분이다. 콜백 함수는 원래 컨텍스트와 다른 컨텍스트에서 실행될 수 있기 때문이다.

const user = {
  name: "Alice",
  greetLater: function() {
    setTimeout(function() {
      console.log(`Hello, my name is ${this.name}`);
    }, 1000);
  }
};

user.greetLater();  // "Hello, my name is undefined"

이 문제는 화살표 함수나 bind 메서드를 사용하여 해결할 수 있다:

// 화살표 함수 사용
const user1 = {
  name: "Alice",
  greetLater: function() {
    setTimeout(() => {
      console.log(`Hello, my name is ${this.name}`);
    }, 1000);
  }
};

// bind 메서드 사용
const user2 = {
  name: "Bob",
  greetLater: function() {
    setTimeout(function() {
      console.log(`Hello, my name is ${this.name}`);
    }.bind(this), 1000);
  }
};

user1.greetLater();  // "Hello, my name is Alice"
user2.greetLater();  // "Hello, my name is Bob"

결론: 자바스크립트의 유연한 객체 시스템

자바스크립트의 this, 프로토타입, 메모리 관리는 처음에는 복잡하게 느껴질 수 있지만, 그 원리를 이해하면 매우 강력하고 유연한 시스템임을 알 수 있다.

  1. this 키워드는 호출 시점에 결정되며, 함수가 어떻게 호출되는지에 따라 달라진다.
  2. 프로토타입은 객체 간 상속 관계를 구현하는 자바스크립트만의 방식이다.
  3. 메모리 관리는 대부분 자동으로 이루어지지만, 참조 타입과 클로저를 사용할 때는 메모리 누수에 주의해야 한다.
  4. 클로저는 함수와 그 함수가 선언된 환경의 조합으로, 강력한 캡슐화를 가능하게 한다.
  5. 이터러블과 제너레이터는 메모리 효율적인 데이터 처리를 가능하게 한다.

이러한 개념들은 서로 밀접하게 연결되어 있으며, 자바스크립트의 특성을 이해하는 데 핵심이다. 특히 this와 프로토타입은 자바스크립트만의 독특한 개념이기 때문에, 다른 언어에서 온 개발자들이 자주 혼란을 겪는 부분이기도 하다.

하지만 이제 이런 개념들의 동작 원리를 더 명확하게 이해했으니, 자바스크립트의 강력한 기능들을 더 효과적으로 활용할 수 있을 것이다.

+ Recent posts