프론트엔드에서 클래스와 즉시 실행 함수 쓰기

이문기
23 min readAug 19, 2023

--

시작

한창 이직을 준비하던 때에 면접을 보러가면 종종 이런 질문을 받곤 했습니다.

“프론트엔드 개발을 하면서 클래스를 사용해본 경험이 있으신가요?”

마침 공통 로직을 빼고 상속할 용도로 클래스를 사용하고 있었고 그 경험을 기반으로 대답했습니다. 하지만 내심 이런 생각을 했습니다. ‘나는 지금 특수한 상황이고, 프론트엔드 개발을 하면서 클래스를 사용할 일이 있을까?’ 클래스 뿐만이 아니었습니다. 자바스크립트의 즉시 실행 함수 역시 언제 무슨 용도로 사용하면 좋을지 어설픈 고민만 있을 뿐 실제로 사용해본 경험은 없었습니다.

이번 글에선 당시와 비교해보며 스스로에게 질문을 해봤습니다. ‘클래스 또는 즉시 실행 함수를 써본 경험이 있는지? 있다면 사례를 들어주세요.’

즉시 실행 함수

즉시 실행 함수는 함수가 정의되는 즉시 실행되는 함수 입니다.¹

(() => {
// 즉시 실행되는 함수
})();

얼핏보면 ‘이걸 왜 쓰지?’ 하는 의문이 듭니다. 웹 개발을 시작했을 땐 지금보다 즉시 실행 함수를 사용할 이유가 더 많았습니다. 왜냐하면 당시엔 ES5를 사용하고 있었고 변수의 스코프를 결정하는 방법 중 하나로 함수는 중요한 의미를 가졌기 때문입니다. 예를 들어

var buttons = document.querySelectorAll('button');

for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(i);
});
}

는 항상 buttons.length를 콘솔에 출력합니다. 왜냐하면 varfor 문에 의해 스코프가 정해지지 않기 때문에 호이스팅 되어 글로벌 변수가 되고, 버튼을 클릭할 때 i의 값은 buttons.length이기 때문입니다. 이럴 땐 즉시 실행 함수를 활용하여 아래와 같이 해결합니다.

for (var i = 0; i < buttons.length; i++) {
(function (index) {
buttons[index].addEventListener('click', function() {
console.log(index);
});
})(i);
}

이렇게 하면 즉시 실행 함수가 순환 마다 정의 및 실행되고 매개변수 index의 스코프는 즉시 실행 함수로 정해지므로 버튼을 클릭할 때 서로 다른 숫자를 콘솔에 출력합니다.²

즉시 실행 함수의 또 다른 용도는 역시 스코프와 관련되어 있습니다. jQuery가 한참 유행하던 시절 스코프는 골치아픈 문제 중 하나였습니다. 왜냐하면 자바스크립트 라이브러리 파일을 아래와 같이 가져와 사용했기 때문입니다.

<script type="text/javascript" src="https://code.jquery.com/jquery-version.min.js"></script>
<script type="text/javascript" src="https://unpkg.com/react@18/umd/react.development.js"></script>
...

이렇게 했을 때 네임스페이스 문제가 발생하곤 합니다. 예를 들어 어떤 모듈에서 name이라는 변수를 사용하고 있는데 다른 라이브러리에서 name이라는 변수를 같이 사용한다면 두 라이브러리 중 하나는 문제가 발생합니다. 그래서 라이브러리 이름만 전역 스코프에서 정의하고 나머지 이름은 즉시 실행 함수 내부에 정의하고 사용합니다. 즉, 모듈을 만들 때 즉시 실행 함수를 사용하는 것입니다.³ 아래는 jQuery의 $를 정의하는 예시 입니다.

// 실제 구현은 이와 다릅니다.
var $ = (() => {
var name = ...;

// ...

return { ... };
})();

하지만 이 패턴들은 ES6와 번들러의 등장으로 대부분 사용되지 않습니다. 이벤트를 등록했던 예시의 경우 letconst로 문제를 해결할 수 있습니다.

const buttons = document.querySelectorAll('button');

for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(i);
});
}

(물론 스코프와 관계 없이 Event Delegation 패턴으로 해결하는게 바람직할 수 있습니다.) 그리고 모듈을 정의하는 문제의 경우 <script>module 타입과 ES6의 import, export 문으로 해결할 수 있고 특히 webpack이나 Vite 등 번들러를 사용하는 환경에선 크게 고민하지 않고 해결할 수 있습니다.

그럼 즉시 실행 함수는 더이상 활용가치가 없을까요? 그렇게 생각해왔지만 최근에 다른 목적으로 활용하기 시작하면서 즉시 실행 함수로 소소한 재미를 느끼고 있습니다.

코드를 작성하다보면 아래와 같이 중첩된 삼항 연산자를 사용하는 경우가 빈번하게 발생합니다.

const serviceDiscount = await getServiceDiscount();
const hasServiceDiscount = serviceDiscount > 0;
const hasProductDiscount = product.discount > 0;

const price = hasServiceDiscount
? hasProductDiscount
? serviceDiscount > productDiscount
? (1 - serviceDiscount) * product.regularPrice
: (1 - product.discount) * product.regularPrice
: (1 - serviceDiscount) * product.regularPrice
: hasProductDiscount
? (1 - product.discount) * product.regularPrice
: product.regularPrice;

예시를 위한 코드지만 정말 어질어질 합니다. serviceDiscount는 이벤트 할인을 의미합니다. product.discount는 상품에 설정한 할인을 의미합니다. 이 로직은 두 할인 중 하나만 존재 한다면 해당 할인을 적용합니다. 만약 두 할인 모두 존재한다면 보다 큰 할인을 적용합니다. 만약 두 할인 모두 존재하지 않는다면 정가 product.regularPrice를 사용합니다. 이 코드를 개선하는 방법은 다양합니다. 그 중 가장 먼저 떠오르는 건 함수로 추출하기 입니다. 어떻게 추출할지는 다른 예시를 더 살펴본 후에 알아보겠습니다.

여기 또 다른 예시가 있습니다.

const users = [
// ...
];
const slicedUsers = users.length > 10 ? users.slice(0, 10) : users;
// slicedUsers 사용

// ...

const usersOrderByName = users.sort(/* ... */);
// 같은 변수명 slicedUsers를 사용하게 되면서 네임스페이스에 충돌 발생
const slicedUsers = usersOrderByName.length > 10 ? usersOrderByName.slice(0, 10) : usersOrderByName;
// slicedUsers 사용

이 코드는 사용자가 10명 이상이라면 10명까지 잘라내고 그렇지 않다면 그대로 사용하는 코드 입니다. 문제는 비슷한 로직이 들어가면서 네이밍을 하는게 어려워졌습니다. 10명까지 잘라낸 결과를 각각 활용해야 하는데 같은 스코프에 있다보니 이름에 충돌이 생깁니다. 이 예시 역시 해결하는 방법 중 가장 먼저 떠오르는 방법은 함수를 만드는 것입니다.

이 두 예시를 보여드린 이유는 이 두 경우 모두 로직을 위한 별도의 함수가 있다면 정말 좋지만 함수를 만드려는 시도 자체가 피곤할 수 있다는 사실을 보여줍니다.

첫 번째 예시 처럼 삼항 연산자를 자주 사용하는 이유는 간단하기 때문입니다. 예를 들어

let price = product.regularPrice;

if (...) {...}
// ...

와 같이 코드를 작성한다고 하면 몇 가지 고민거리가 생깁니다. price에 값을 할당할 수 있기 때문에 다른 위치에서 의도치 않게 문제가 있는 값이 할당 될 수 있습니다.

let price = product.regularPrice;

if (...) {...}
// ...
price = 10; // 할인 된 값이 1000원 미만일 수 없어 ! 넣으면 안 돼!!! (문제가 있는 값의 할당)

그리고 무엇보다 삼항 연산자를 사용하면 price에 값을 할당하는 구조가 단 하나만 있다보니 다른 로직이 끼어들 여지가 없어서 코드를 파악하는 데 좋습니다. 그에 반해 if 문을 사용하게 되면 변수와 조건문의 구조 또는 위치를 고민을 하게 됩니다.

let price = product.regularPrice;

// 다른 로직들

// price와 if 문이 너무 떨어져있는데 붙여야 하나?
if (...) {
price = ...;
}

하지만 그렇게 사용한 삼항 연산자는 위의 예시처럼 종종 복잡한 구조를 만들게 됩니다.

두 번째 예시 처럼 이름을 짓는게 피곤함에도 함수를 만들지 않는 이유는 함수 역시 이름을 지어야 하고 타입스크립트 또는 JSDoc을 사용하면 추가적인 비용이 더 들기 때문입니다.

const users = [
// ...
];

/** JSDoc */
const doSomething1 = (users: User[]) => {
const slicedUsers = ...;
// 추가 로직
return ...;
};

/** JSDoc */
const doSomething2 = (users: User[]) => {/* ... */}

doSomething1(users);
doSomething2(users);

이런 문제들이 발생한 이유는 개발자가 편하게 개발하는 방법과 좋은 코드를 작성하는 방법 사이에 정신적 비용에 차이가 있기 때문이라고 생각합니다. 개발자들은 편하게 개발하기 위해 글을 쓰듯 위에서 아래로 큰 고민 없이 코드를 써내려 갑니다. 하지만 좋은 코드는 종종 함수 만들기, 흐름 제어 등을 요구 합니다. 저는 이 사이에서 고민이 깊어질 때면(게으르고 싶을 때) 즉시 실행 함수를 사용 합니다.

첫 번째 예시는 이렇게 수정 할 수 있습니다.

const price = (() => {
const serviceDiscount = await getServiceDiscount();
const hasServiceDiscount = serviceDiscount > 0;
const hasProductDiscount = product.discount > 0;

if (hasServiceDiscount && hasProductDiscount) {
if (serviceDiscount > productDiscount) {
return (1 - serviceDiscount) * product.regularPrice;
}

return (1 - product.discount) * product.regularPrice;
}

if (hasServiceDiscount) {
return (1 - serviceDiscount) * product.regularPrice;
}

if (hasProductDiscount) {
return (1 - product.discount) * product.regularPrice;
}

return product.regularPrice
})();

이렇게 하면 price를 구하는 로직만 즉시 실행 함수 내부에 갇힘으로써 다른 로직들과 섞일 일도 드뭅니다.

두 번째 예시는 이렇게 수정 할 수 있습니다.

const users = [
// ...
];

const firstNUsers = (() => {
let result = [];
const slicedUsers = users.length > 10 ? users.slice(0, 10) : users;

// slicedUsers 사용

return result;
})();


// ...

const orderedByNameNUsers = (() => {
let result = [];
const usersOrderByName = users.sort(/* ... */);
const slicedUsers = usersOrderByName.length > 10 ? usersOrderByName.slice(0, 10) : usersOrderByName;

// slicedUsers 사용

return result;
})();

물론 더 좋은 방법들이 많지만 이렇게 했을 때 장점은 아래와 같습니다.

  1. 좋은 코드 작성을 위한 정신적 노력, 즉 네이밍, 매개변수 설정, 함수 위치 등을 고민하는 비용이 감소 합니다.
  2. 함수로 추출하기 위한 예비 작업의 성격을 띄기 때문에 나중에 함수로 추출하기 수월 합니다.

실제로 이렇게 작성하고나면 함수로 분리하기 좋을 만한 부분들이 즉시 실행 함수로 표현되어 있게 되고, 추후에 함수 추출이 수월해지는 경험을 다수 했습니다. 요약하자면 즉시 실행 함수를 통해 기존 코드 흐름과 동일성을 유지하면서 스코프를 나눌수 있게 되고 네임스페이스 그리고 로직 등 역시 분리하게 됩니다.

클래스

전 클래스를 생각보다 자주 사용하는 편입니다. 특히 타입스크립트를 사용하게 되면서 그리고 ES2022에서 private property가 새로 도입되면서 더욱 자주 사용하게 됐습니다.⁴ 하지만 무작정 클래스부터 사용하진 않습니다. 몇 가지 사전 조건들이 있는데 가장 많이 사용되는 조건을 소개하려고 합니다.

첫 번째: 클래스

첫 번째는 서로 관련 있는 함수와 값을 담는 변수가 늘어날 때 입니다. 예를 들어 상품 상세 페이지에서 장바구니에 상품을 담기 전 옵션을 선택할 수 있다고 해보겠습니다. 그런데 여러 종류의 옵션이 있고 몇몇 옵션은 이전에 선택한 옵션에 따라 옵션의 종류가 달라집니다. 아래는 해당 기능을 고려한 옵션 데이터 구조 입니다. id2인 옵션은 id11인 선택지(selections)를 선택해야 선택할 수 있는 옵션 입니다. 반면 id3인 옵션은 id12인 선택지를 선택해야 선택할 수 있는 옵션입니다. (optionsselections는 서로 테이블이 다르게 관리한다고 전제합니다. 사실 이쯤되니 예시가 불필요하게 너무 길다는 생각이 듭니다.)

{
options: [
{
id: 1,
preconditionSelectionId: null,
selections: [
{ id: 11, label: '옵션1의 선택지 1', additionalPrice: 0 },
{ id: 12, label: '옵션1의 선택지 2', additionalPrice: 0 },
]
},
{
id: 2,
preconditionSelectionId: 11,
selections: [
{ id: 13, label: '옵션2의 선택지 1', additionalPrice: 1000 },
{ id: 14, label: '옵션2의 선택지 2', additionalPrice: 20000 },
]
},
{
id: 3,
preconditionSelectionId: 12,
selections: [
{ id: 15, label: '옵션3의 선택지 1', additionalPrice: 500 },
{ id: 16, label: '옵션4의 선택지 2', additionalPrice: 0 },
]
}
]
}

아래 코드는 선택지를 선택했을 때 preconditionSelectionId의 존재 등을 검토해 옵션을 활성화하는 기능을 담고 있습니다.

const options = await getProductOptions(productId);

document.querySelector('.options').addEventListener('change', (event) => {
const selectionId = event.target.value;
const optionToActivate = options.find(
(option) => option.preconditionSelectionId === selectionId
);

if (optionToActivate) {
// 옵션 활성화
}
});

그런데 여기서 끝이 아닙니다. 옵션이 바뀐다는 건 상품의 가격도 바뀐다는 걸 의미합니다.

document.querySelector('.options').addEventListener('change', (event) => {
const selectionId = event.target.value;

// 위와 동일한 로직

const option = options.find((option) => {
const selections = option.selections;
const isOptionContainSelectedSelection = selections.find(
(selection) => selection.id === selectionId
);

return isOptionContainSelectedSelection;
});

const additionalPrice = option.additionalPrice;

// 가격 추가
});

벌써 코드가 복잡해지기 시작합니다. 이럴 때 우린 함수로 추출하기 시작합니다.

document.querySelector('.options').addEventListener('change', (event) => {
const selectionId = event.target.value;
const optionToActivate = getOptionToActivate(selectionId);

if (optionToActivate) {
// 옵션 활성화
}

const option = getAdditionalPrice(selectionId);
// 가격 추가
});

그리고 getOptionToActivategetAdditionalPrice 모두 option을 찾는 과정이 들어있으므로 아래와 같이 getOption 함수도 생깁니다.

const getOption = (..) => {...};

const getOptionToActivate = (selectionId) => {
const result = getOption(/* ... */);
return result;
};

const getAdditionalPrice = (selectionId) => {
const option = getOption(/* ... */);
// ...
return result;
};

이제 슬슬 미래가 그려지기 시작합니다. 이런식으로 코드가 늘어나면서 함수도 늘어날 것입니다. 지금 이 상황은 중복 함수를 제거하고 유틸리티 함수로 만드는 과정과는 조금 다릅니다. 여기에 있는 함수들은 지금 이 문맥에서만 사용됩니다. 그리고 함수끼리 관계가 형성됩니다. 또한 getOption과 같은 함수는 이벤트에서 직접 사용되지 않고 분리된 함수 내부에서 사용됩니다. 마지막으로 options라는 값은 모든 곳에서 사용되지만 이벤트에서 직접 사용하지 않습니다. 사실 그렇게 하는 게 위험하기도 합니다. options를 변경했다가 다른 곳에서 원본 데이터를 원하는 경우엔 API를 다시 호출해야하는 최악의 상황이 발생할 수도 있기 때문입니다.

정말 긴 예시였지만, 이럴 때 전 클래스를 활용합니다.

class Options {
#options

constructor(options) {
this.#options = options;
}

#getOption(...) {...}

getOptionToActivate(selectionId) {...}

getAdditionalPrice(selectionId) {...}
}

이렇게 했을 때 장점은 아래와 같습니다.

  1. options를 캡슐화 함으로써 외부에서 실수로라도 options에 직접 접근할 수 없도록 합니다.
  2. getOption을 캡슐화 함으로써 이 메서드가 변하더라도 살펴봐야 할 범위가 좁아 유지보수성이 개선됩니다.
  3. 앞으로 옵션과 관련된 함수나 공유할 변수를 추가할 일이 발생한다면 활용할 수 있는 공간이 생깁니다.
  4. 함수를 사용할 때 어떤 문맥(Options)에서 사용하는지 파악할 수 있습니다. 이전엔 getAdditionalPrice과 같이 다소 모호한 이름의 함수를 사용했다면 이젠 Options.getAdditionalPrice를 사용함으로써 옵션과 관련된 추가 금액이라는 걸 파악할 수 있게 됩니다.
  5. 이전과 달리 테스트 작성이 수월해집니다.

이렇게 만든 클래스를 리액트에서 활용하는 방법은 프론트엔드 아키텍처: 비지니스 로직과 사례에서 확인할 수 있습니다.

이러한 접근법이 무조건 좋은 방법은 아닙니다. 사실 처음 코드를 작성할 때 필요한 객체를 알아보고 어떤 방식으로 협력할 것인지 고민해야 합니다.⁵ 하지만 프론트엔드 환경 특성상 객체 위주의 개발을 하는 경우가 드물기 때문에 위에 보여드린 접근 방법이 보다 자주 사용되곤 합니다.

두 번째: 값 객체

두 번째는 값 객체 입니다. 값 객체는 언어에서 제공하는 타입으로 서비스를 구현하는 데 어려움이 있을 때, 타입을 확장하고 값으로 활용하기 위해 사용합니다. 프론트엔드 아키텍처: Business Logic의 분리에서 사용한 EventDate가 그 예시 중 하나 입니다.

class EventDate {
#lastDateOfEvent;
#offset = 0;

constructor(lastDateOfEvent, offset = 0) {
this.lastDateOfEvent = lastDateOfEvent;

// lastDateOfEvent보다 offset만큼 과거를 계산할 때 사용합니다.
this.offset = offset;
}

/** ... */
canShowEventBannerIf(agreeWithTerm) {
if (agreeWithTerm) {
const now = new Date();
const lastDateOfEventPastByOffset =
this.lastDateOfEvent - this.offset;

return now < lastDateOfEventPastByOffset;
}

return false;
}

/** ... */
get() {
return this.lastDateOfEvent;
}

/**
* @description 값을 비교할 때 사용합니다.
* @param {EventDate} anotherEventDate
* @returns {boolean}
*/
isEqual(anotherEventDate) {
// ...
}
}

이렇게 값 객체를 사용했을 때 가장 큰 장점은 특정 값에 대해 사용되는 함수 또는 상태들을 한 군데에 모아서 관리하여 분산되지 않도록 합니다. 또한 캡슐화가 수월해져 외부에 공개하는 메서드와 그렇지 않은 메서드 그리고 필드를 설정할 수 있습니다. 그리고 값 객체의 이름 자체가 값 사용과 관련된 문맥을 설명해주기 때문에 코드가 더욱 명확해집니다. 즉, 이벤트 일정과 관련해 Date를 사용하는 것과 EventDate를 사용하는 건 코드의 명확성에 있어 큰 차이를 만듭니다.

대신 값 객체를 다룰 때 조심해야 할 사항이 있습니다.

먼저 set 메서드를 사용하지 않는 것입니다. 즉,

class EventDate {
// ...
setLastDateOfEvent(date) {
this.#lastDateOfEvent = date;
}
// ...
}

와 같이 사용하지 않습니다. 왜냐하면

const eventDate1 = new EventDate(/* ... */);
const eventDate2 = new EventDate(/* ... */);
eventDate1.isEqual(eventDate2); // A

// 중간 로직

eventDate1.isEqual(eventDate2); // B

에서 보듯 중간에 어떤 로직이 있느냐에 따라 A와 B의 결과가 달라질 수 있기 때문입니다. ‘당연히 중간에 setLastDateOfEvent 같은 메서드를 사용했으면 결과가 달라지죠. 그리고 그걸 알고 쓰지 않을까요?’라고 의아해할 수 있지만 아래 코드를 보면 이야기가 조금 달라집니다.

const number1 = 10;
const number2 = 20;

number1 === number2; // false

// 중간 로직

number1 === number2; // true

이렇게 어떤 과정을 거쳤다는 이유로 값이 다르고 로직의 결과도 다르다면 우린 코드를 신뢰하며 작성하거나 파악할 수 없습니다. 값 객체는 말 그대로 ‘값’이기 때문에 재할당이 아닌 다른 방법으로 값을 변경하면 안 됩니다.

그리고 값이기 때문에 같은 타입의 값끼리 비교할 수 있어야 합니다. EventDate의 경우 isEqual이 그 역할을 해줍니다. 추가적으로 isLaterThan, isPastThan 등이 있을 수 있습니다. 그리고 서로 값이 다른 경우, 즉 isEqualfalse인 경우엔 두 값 객체는 서로 다릅니다.

갮 객체와 관련해 좋은 글을 소개합니다. 값 객체를 사용할 때 참고하면 좋을 것 같습니다. Practical DDD in TypeScript: Value Object

마무리

Photo by Markus Spiske on Unsplash

이렇게 즉시 실행 함수와 클래스를 사용하는 사례를 경험을 토대로 살펴봤습니다. 이 방법들은 절대적이지 않습니다. 말 그대로 경험적이기 때문에 이렇게 했을 때 안 좋은 결과로 이어질 수 있습니다. 그럼에도 이렇게 다소 장황하게 소개한 이유는 ‘경계’와 관련이 있습니다. 개발을 하다보면 미리 만들어 놓은 경계로 인해 시도 조차 하지 않는 경우가 많습니다. ‘즉시 실행 함수는 쓸 일이 없어’, ‘프론트엔드에서 클래스? 안 써도 개발하는데 문제 없던데?’.

개발을 하다보면 도움이 될거라 기대하지 않았던 경험 또는 지식이 문제를 해결하는 실마리가 되는 경우를 종종 경험 합니다. 이건 스티브 잡스의 유명한 이야기 connecting the dots와 비슷합니다.

이 글을 통해 코드를 작성하는 데 도움이 되면 좋겠습니다. 특히 경계를 넓히고 열려있는 마인드로 경험들을 맞이하면 기대하지 않던 결과들이 생길 수 있다는 사실도 전달이 되면 더욱 좋겠습니다.

--

--

이문기

사용자를 생각하고 개발자를 생각하는 프런트엔드를 만드는 데 관심이 많습니다. 표준, 접근성, 아키텍처, 테스트 등을 꾸준히 훈련하고 적용하려고 노력합니다.