Study
13. 타입스크립트와 객체 지향
이 장에서는 타입스크립트와 리액트 환경에서 객체 지향을 어떻게 활용하고 더 나은 방향으로 발전시킬 수 있는지 알아본다.
13.1 타입스크립트의 객체 지향
자바스크립트로도 객체 지향 프로그래밍을 할 수 있다. 자바스크립트는 프로토타입 기반의 객체 지향 언어로 분류된다. 그러나 자바스크립트는 전통적인 객체 지향 프로그래밍 언어에서 기대할 수 있는 일부 기능을 지원하지 않아 객체 지향을 온전히 구현하는 데 부족함이 있다. 타입스크립트는 이러한 제약을 해결할 수 있는 private과 같은 접근 제어자, 추상 클래스, 추상 메서드 등을 지원한다. 즉, 타입스크립트는 객체 지향을 구현할 수 있도록 도와주는 자바스크립트의 슈퍼셋으로 볼 수 있다.
타입스크립트는 점진적 타이핑, 구조적 타이핑 그리고 덕 타이핑이 결합한 언어이다. (구조적 타이핑, 덕 타이핑과 점진적 타이핑)
- 타입스크립트 같은 구조적 타이핑 언어는 하나의 클래스에 여러 인터페이스가 연결될 수 있으며, 하나의 인터페이스에 여러 클래스가 연결될 수 있다.
- 타입스크립트는 기존 웹 환경과 잘 호환되며, 객체 지향을 자바스크립트보다 더 쉽게 표현할 수 있게 해준다.
➡ 복잡한 비즈니스 로직을 타입스크립트로 좀 더 수월하게 구현할 수 있게 되었다.
타입스크립트를 활용한 프론트엔드에서의 객체지향 구현
컴포넌트 역시 스스로 책임을 져야하는 역할을 수행하면서 다른 컴포넌트 객체와 협력하는 독립적인 객체이다. 컴포넌트를 조합하는 것도 객체 지향을 활용하는 것이라고 볼 수 있다. 우리는 컴포넌트를 개발하면서 사실상 객체 지향을 구현하고 있다.
객체 지향의 관점에서 타입스크립트가 프론트엔드에 주는 이점
- 타입스크립트는 의존성 주입(DI) 패턴을 명확하게 표현할 수 있게 해준다.
타입스크립트를 사용하면 prop을 인터페이스로 정의할 수 있어, 컴포넌트 간의 협력 관계를 표현할 수 있게 해준다. 또한 객체 자체가 아니라 프레임워크에 의해 객체의 의존성이 주입되는 DI(Dependency Injection. 의존성 주입) 패턴을 따르는데, 이러한 패턴을 더욱 명확하게 표현할 수 있게 해주는 것이 타입스크립트이다. DI는 객체 간의 의존 관계를 설정하는데 사용된다. DI 패턴을 따르면 객체 간의 결합도를 낮출 수 있다.
- 타입스크립트 자체가 객체 지향적으로 다양한 측면을 표현하는 데 큰 장점을 가지고 있다.
타입스크립트는 점진적 타이핑, 구조적 타이핑, 덕 타이핑을 결합한 언어로 객체 지향의 폭을 넓혀준다. 이는 선언적인 언어를 사용하는 웹 개발 환경에서 객체 간의 협력과 역할에 집중하기 어려운 문제를 해결해 준다. 특히, 리액트와 같은 라이브러리에서 컴포넌트 간의 관계 및 상태관리를 객체 지향적으로 접근하게 해줌으로서, 더 유기적이고 유지보수가 용이한 설계를 가능하게 한다. MobX와 같은 상태 관리 라이브러리는 객체 지향 패러다임을 기반으로 하여 이를 더욱 강화한다.
어떻게 프론트엔드에서 객체 지향을 효과적으로 활용할 수 있을까
다양한 접근 방식이 존재하겠지만, 레이아웃은 예상치 못한 변동 사항이 생길 가능성이 높기 때문에 미확정 영역으로 두고 공통으로 사용되는 컴포넌트와 비즈니스 영역에서 객체 지향 원칙을 적용하여 설계하면 좋은 구조를 개발할 수 있을 것이다.
13.2 우아한 형제들의 활용 방식
우아한 형제들의 한 팀에서는 다음과 같은 설계 방식을 사용한다.
- 온전히 레이아웃만 담당하는 컴포넌트 영역
- 컴포넌트 영역 위에서 레이아웃과 비즈니스 로직을 연결해주는 커스텀 훅 영역
- 훅 영역 위에서 객체로서 상호 협력을 하는 모델 영역
- 모델 영역 위에서 API를 해석하여 모델로 전달하는 API 레이어 영역
13.2.1 컴포넌트 영역
아래는 장바구니 관련 다이얼로그 컴포넌트 코드다. 이 컴포넌트 영역은 온전히 레이아웃 영역만 담당한다.
// components/CartCloseoutDialog.tsx
import { useCartStore } from "store/modules/cart";
const CartCloseoutDialog: React.VFC = () => {
const cartStore = useCartStore();
return (
<Dialog
opened={cartStore.PresentationTracker.isDialogOpen("closeout")}
title="마감 세일이란?"
onRequestClose={cartStore.PresentationTracker.closeDialog}
>
<div
css={css`
margin-top: 8px;
`}
>
지점별 한정 수량으로 제공되는 할인 상품입니다. 재고 소진 시 가격이
달라질 수 있습니다. 유통기한이 다소 짧으나 좋은 품질의 상품입니다.
</div>
</Dialog>
);
};
export default CartCloseoutDialog;
그리고 비즈니스 로직은 useCartStore 내부에 존재할 것이다.
13.2.2 커스텀 훅 영역
전역 상태를 관리하는 스토어 내의 useCartStore를 살펴보자.
// store/cart.ts
class CartStore {
public async add(target: RecommendProduct): Promise<void> {
const response = await addToCart(
// addToCart 호출
addToCartRequest({
auths: this.requestInfo.AuthHeaders,
cartProducts: this.productsTracker.PurchasableProducts,
shopID: this.shopID,
target,
})
// 장바구니에 상품을 추가하기 위한 요청을 서버로 보내기 전
// 필요한 정보를 서버가 처리할 수 있는 요청 형식으로 포맷하는 함수.
);
return response.fork(
(error, _, statusCode) => {
switch (statusCode) {
case ResponseStatus.FAILURE:
this.presentationTracker.pushToast(error);
break;
case ResponseStatus.CLIENT_ERROR:
this.presentationTracker.pushToast(
"네트워크가 연결되지 않았습니다."
);
break;
default:
this.presentationTracker.pushToast(
"연결 상태가 일시적으로 불안정합니다."
);
}
},
(message) => this.applyAddedProduct(target, message)
);
}
}
const [CartStoreProvider, useCartStore] = setupContext<CartStore>("CartStore");
export { CartStore, CartStoreProvider, useCartStore };
훅이 아닌 스토어로 들어가는 이유는, 해당 스토어 객체에서 최종적으로 사용되는 setupContext
는 컨텍스트와 관련된 훅을 다루는 유틸리티 함수이기 때문에 훅 영역의 로직으로 봐도 된다. 즉, 장바구니에 상품을 담는 비즈니스 로직을 레이아웃과 연결해주기 위한 커스텀 훅 영역이다. 그리고 해당 스토어 객체 내에서 addToCart를 호출하고 있다. addToCart는 API를 호출하는 함수이다. 내부에서는 addToCartRequest 시리얼라이저 함수를 호출하고 있다.
addToCartRequest가 "시리얼라이저 함수"라는 것은, 이 함수가 데이터를 서버로 전송하기 전에 적절한 형태로 변환(직렬화)하는 역할을 한다는 뜻이다.
💡 Serialization(직렬화)
데이터 구조나 object 상태를 저장하거나 전송할 수 있는 포맷(ex. JSON, XML)으로 변환하는 과정을 의미한다. 이 과정은 네트워크를 통해 데이터를 전송하거나 데이터를 파일에 저장할 때 필요하다.
- 컴퓨터 메모리 상에 존재하는 객체(Object) → 문자열(string)로 변환 = 직렬화(Serialization)
- 문자열(string) → 자바스크립트 객체(Object)로 반환하는 것 = 역직렬화(Deserialization) or 파싱(Parsing)
// serializers/cart/addToCartRequest.ts
import { AddToCartRequest } from "models/externals/Cart/Request";
import { IRequestHeader } from "models/externals/lib";
import {
RecommendProduct,
RecommendProductItem,
} from "models/internals/Cart/RecommendProduct";
import { Product } from "models/internals/Stuff/Product";
interface Params {
auths: IRequestHeader;
cartProducts: Product[];
shopID: number;
target: RecommendProduct;
}
function addToCartRequest({
auths,
cartProducts,
shopID,
target,
}: Params): AddToCartRequest {
// cartProducts 배열에 target 상품과 동일한 ID가 있는지 확인.
const productAlreadyInCart = cartProducts.find(
(product) => product.getId() === target.getId()
);
// AddToCartRequest 타입의 객체 반환.
return {
body: {
items: target.getItems().map((item) => ({
itemId: item.id,
quantity: getItemQuantityFor(productAlreadyInCart, item),
salePrice: item.price,
})),
productId: target.getId(),
shopId: shopID,
},
headers: auths,
};
}
export { addToCartRequest };
addToCartRequest 함수는 장바구니에 상품을 추가하기 위한 API 호출에 필요한 데이터를 준비하는 역할을 한다. 이 함수는 사용자가 추가하려는 상품(target), 현재 장바구니에 있는 상품들(cartProducts), shopID, 그리고 인증 정보(auths)를 입력 받아, 이를 서버가 이해할 수 있는 요청 형식으로 변환하여 반환한다.
addToCartRequest 함수는 AddToCartRequest 타입의 객체를 반환하며, 매개변수(파라미터)로 받는 target은 RecommendProduct 타입을 가진다. RecommendProduct 타입은 모델 영역에 정의되어 있다.
13.2.3 모델 영역
models/Cart.ts
// models/Cart.ts
export interface AddToCartRequest {
body: {
shopId: number;
items: { itemId: number; quantity: number; salePrice: number }[];
productId: number;
};
headers: IRequestHeader;
}
/**
* 추천 상품 관련 class
*/
// 추천 상품을 나타내는 클래스 객체
export class RecommendProduct {
// 클래스 속성에 접근할 수 있는 메소드들.
public getId(): number {
return this.id;
}
public getName(): string {
return this.name;
}
public getThumbnail(): string {
return this.thumbnailImageUrl;
}
public getPrice(): RecommendProductPrice {
return this.price;
}
public getCalculatedPrice(): number {
const price = this.getPrice();
return price.sale?.price ?? price.origin;
}
public getItems(): RecommendProductItem[] {
return this.items;
}
public getType(): string {
return this.type;
}
public getRef(): string {
return this.ref;
}
// 클래스 인스턴스를 생성할 때 초기화 과정을 담당함.
constructor(init: any) {
this.id = init.id;
this.name = init.displayName;
this.thumbnailImageUrl = init.thumbnailImageUrl;
this.price = {
// 할인 가격이 있을 경우, 할인가격과 할인율 포함
// 없을 경우, 원래 가격만 설정함.
sale: init.displayDiscounted
? {
price: Math.floor(init.salePrice),
percent: init.discountPercent,
}
: null,
origin: Math.floor(init.retailPrice),
};
this.type = init.saleUnit;
this.items = init.items.map((item) => {
return {
id: item.id,
minQuantity: item.minCount,
price: Math.floor(item.salePrice),
};
});
this.ref = init.productRef;
}
private id: number;
private name: string;
private thumbnailImageUrl: string;
private price: RecommendProductPrice;
private items: RecommendProductItem[];
private type: string;
private ref: string;
}
RecommendProduct는 클래스로 표현된 객체로 추천 상품을 나타내며, 이 객체는 다른 컴포넌트 및 모델 객체와 함께 협력하게 된다. 이 클래스는 추천 상품의 정보와 관련 기능을 캡슐화하는 타입스크립트 클래스로, 이 클래스의 구조와 메서드를 통해 상품에 대한 다양한 정보를 처리는 방법을 제공한다.
13.2.4 API 레이어 영역
마지막으로 훅에서 실제로 실행되는 addToCart 함수를 살펴보자.
// apis/Cart.ts
// APIResponse는 데이터 로드에 성공한 상태와 실패한 상태의 반환 값을 제네릭하게 표현해주는 API 응답 객체이다
// (APIResponse<OK, Error>)
interface APIResponse<OK, Error> {
// API 응답에 성공한 경우의 데이터 형식
ok: OK;
// API 응답에 실패한 경우의 에러 형식
error: Error;
}
export const addToCart = async (
param: AddToCartRequest
): Promise<APIResponse<string, string>> => {
return (await GatewayAPI.post<IAddCartResponse>("/v3/cart", param)).map(
(data) => data.message
);
};
addToCart 함수는 AddToCartRequest 타입의 param을 매개변수로 받아, 비동기적으로 장바구니에 상품을 추가하는 API 요청을 수행한다. 이 함수는 API 호출의 성공 또는 실패 여부에 따라 문자열을 반환하는 APIResponse 객체를 프로미스로 반환한다. API 호출의 세부적인 처리는 [GatewayAPI.post]
함수를 통해 이루어지며, 호출 결과는 .map
으로 처리된다.
지금까지의 예시들을 통해 어떤 식으로 각 객체에 적절한 역할과 책임을 할당하여 올바른 협력을 구성하고 설계해야 하는 지에 대한 이해할 수 있을 것이다.
클래스형과 함수형
클래스는 객체를 표현하는 방법의 도구일 뿐, 클래스만이 객체 지향 구현이 아니다. 컴포넌트를 함수형으로 선언하든 클래스형으로 선언하든 모두 객체를 나타낸다. 함수 컴포넌트의 사용률이 높아졌고, 리액트 공식문서 역시 함수 컴포넌트를 권장하고 있지만 우리는 둘 중 상황에 맞는 적절한 방법을 선택해야 한다.
일관된 템플릿에 맞춘 컴포넌트를 많이 생성해야할 때는 클래스 컴포넌트 방식을 따를 때도 있다. 예를 들어 페이지 템플릿을 클래스 컴포넌트로 만들어서 공통으로 정의되어야 할 행동(ex. 네비게이션의 뒤로가기 버튼을 눌렀을 때의 동작 등)을 abstract 메서드로 만들어 사용하기도 한다.
공통으로 쓰이는 템플릿을 클래스 컴포넌트로 정의한다면 해당 코드에 익숙할 경우 빠르고 쉽게 컴포넌트를 만들어낼 수 있지만 상속 구조가 복잡해지면 코드 해석이 어려워지고 디버깅도 힘들어진다. 또한 처음 코드를 보는 사람은 이해하는 데 시간이 걸릴 수 있다.
따라서 프로젝트에 어떤 방식이 적합한지를 심도 있게 고민하면서 설계해야 한다.
13.3 캡슐화와 추상화
프로젝트 설계의 궁극적인 목표는 객체들이 유기적으로 협력하게끔 만들어서 적절하게 도메인 분리를 하는 것이다. 이를 위해 캡슐화는 중요한 도구가 될 수 있다. 추상화는 객체들을 모델링하는 과정 자체라고 할 수 있는데, 이 객체들을 좀 더 사람이 인지할 수 있도록 적합한 설계를 하는 것이 곧 추상화다.
캡슐화란 다른 객체 내부의 데이터를 꺼내와서 직접 다루지 않고, 해당 객체에서 처리할 행위를 따로 요청함으로써 협력하는 것이다. 앞서 컴포넌트 역시 객체라고 했다. 그렇다면 컴포넌트의 내부 데이터인 상태(state)가 바로 캡슐화의 대상이 될 수 있다. 결국 컴포넌트 내의 상태와 prop을 잘 다루는 것도 캡슐화의 개념에 부합하는 것이다.
Prop Drilling이 심할수록 컴포넌트 간의 결합도는 높아지며 내부 처리 로직이 외부로 드러나기 마련이다. 즉, Prop Drilling은 좋지 못한 관계를 만들고 캡슐화를 저해한다. 이런 문제를 해결하기 위해 옵저버 패턴이 등장했으며 더 나아가 Context API 및 Redux, MobX, Recoil과 같은 다양한 상태 관리 라이브러리가 생겨났다.
적절하게 캡슐화되고 추상화된 컴포넌트를 활용하면 애플리케이션을 더 유기적으로 구성할 수 있다. 어떻게 하면
13.4 정리
객체 지향 패러다임의 원론적인 부분에 집중하기 보다는 실제 코드를 작성하며 객체 지향을 경험해보는 것이 중요하다. 우리는 이미 객체 지향을 구현하고 있다는 것을 명심하자.
객체 그 자체보다는 객체의 책임을 먼저 생각하는 것이 중요하다. 우선 객체나 책임을 신경 쓰지 않고 화면 전체를 만든 후 각 부분의 역할과 책임을 나누면서 리팩터링 하는 것도 객체 지향을 구현하기 위한 연습이 될 수 있다. 이렇게 경험이 쌓이다보면 화면을 구성하기 전에 개별적인 컴포넌트를 먼저 생각할 수 있게 될지도 모른다.
생산성 측면에선 단지 코드를 빨리 작성하는게 도움이 될수도 있다. 객체지향은 유지보수를 위한 개념이므로, 유지보수가 필요하지 않다면 꼭 따를 필요가 없을지도 모른다. 어디에나 반드시 객체 지향이 적용되어야 하는 것은 아니자만 객체 지향 관점으로 개발하는 것은 중요하다. 좋은 추상화를 어떻게 만들지 고민하며 개발한다면 변경에 유연하고 유지보수하기 쉬운 설계를 구축할 수 있을 것이다.