클린코드.
많은 개발자들의 관심사인데요. 혼자 공부하다보면 내 코드가 좋은 코드인지 알기 어렵습니다. 어떻게 해야 꼼꼼하게 확인할 수 있을까요?
이번 아티클은 <항해 플러스> 프론트엔드 코치진으로 참여하고 계시는 황준일 코치님께서 직접 작성해주셨습니다. 혼자서도 클린코드를 작성하고, 좋은 코드에 대한 판단의 기준을 세우고, 직접 유지보수 상황을 만들어 시뮬레이션하는 방법에 관해 알아보실 수 있습니다.
*황준일 코치님의 블로그에 게재된 글을 편집하여 소개드립니다.
나는 클린코드를 주제로 다룰 때 제일 중요한 게 요구사항이라고 생각한다. 요구사항을 떼어놓고 클린코드를 이야기할 수 있을까?
클린코드의 존재 이유는 유지보수라고 생각한다. 유지보수를 하기 위해 무수히 많은 “읽기 좋은” 것에 대한 고민을 한다. 반대로, 유지보수를 할 일이 없다면 변경을 고려해서 코드를 작성할 필요가 없다. 가령 프로토타입을 만든다거나, PoC 를 위해 코드를 작성한다거나, 빠르게 만들어 배포하고 버릴 페이지 같은 것들을 만들때는 “읽기 좋은”이 아닌 “빠른 구현”에 더 초점을 낮추는 게 좋다.
어쨌든 클린코드는 유지보수와 한 몸이다. 그렇다면 클린코드를 공부하고 적용할 때 혹은 나의 코드가 좋은 코드인지 판단할 때 유지보수 상황에 대해 시뮬레이션을 해보면 좋다. 더 나아가서, 직접 유지보수 상황을 만들어서 코드를 통해 확인해보면 더 좋다. 그래서 클린코드를 다양한 요구사항의 변화를 토대로 학습하는 과정을 다뤄볼 것이다.
프론트엔드 영역에서 경험할 수 있는 복잡한 요구사항은 쇼핑몰이다. CMS 와 연결하기도 좋고, 유지보수를 하는 상황도 무척 많이 발생한다. 쇼핑몰의 일부 기능을 정의하고 구현하는 방식으로 진행해보자.
초기 요구사항
학습을 위한 과정이기 때문에 최대한 간단하게 정의해보자.
메인 페이지
상품 목록을 조회할 수 있다.
장바구니를 확인하고 관리할 수 있다.
상품 목록
이름, 가격, 상품 이미지 등이 보여진다.
장바구니에 담을 수 있다.
장바구니
수량 변경 (+/- 버튼)
개별 상품 삭제
장바구니 비우기
개별 상품 소계
전체 총합계
1단계: Vanilla Javascript 로 구현하기
일단 이 글의 목적은 처음부터 좋은 코드를 작성하는 게 아닌 좋은 코드를 판단하기 위한 기준을 만들어가는 것이다. 그래서 일단 대충 작성한 코드가 필요하고, 코드의 문제를 정의하고 개선하는 과정으로 전개해보자.
(1) 프로젝트 세팅 (생략해도 무방)
vite + typescript 기반으로 간단하게 프로젝트를 세팅해보자.
# vanilla ts 기준으로 설치
$ pnpm create vite simple-clean-code-project --template vue
# 패키지 설치
$ cd simple-clean-code-project
$ pnpm install
# eslint, prettier 설치## 글을 작성하는 시점에 eslint는 9버전이 최신버전이다.
$ pnpm create @eslint/config@latest
$ pnpm add --save-dev --save-exact prettier
$ pnpm add -D eslint-config-prettier
# 커밋을 하기 전에 eslint, tsc, prettier 등을 실행하기 위해 husky 설치
$ pnpm add --save-dev husky
$ npm exec husky init
// ❌ 현재: 재고 정보를 저장할 구조가 없음
element.addEventListener('click', function (e) {
// 재고 체크 로직이 불가능// 상품별 재고 데이터가 어디에도 없음
});
재고 데이터 저장소 부재: 상품별 재고 정보를 관리할 중앙화된 데이터 구조가 없음
재고 차감 로직의 어려움: 장바구니 추가 시 재고를 체크하고 차감하는 로직을 구현할 방법이 없음
실시간 재고 동기화: 여러 곳에서 같은 상품을 참조할 때 재고 상태 동기화가 어려움
품절 상태 UI 반영
<!-- ❌ 현재: 버튼 상태를 동적으로 관리할 구조가 없음 --><buttonclass="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 add-to-cart-btn">
장바구니 담기
</button>
동적 UI 업데이트의 어려움: 재고 변화에 따라 버튼 상태(활성/비활성)와 텍스트를 업데이트하는 구조가 없음
UI 상태와 데이터 동기화: 재고 데이터 변경 시 관련된 모든 UI 요소들을 찾아서 업데이트해야 하는 복잡성
조건부 렌더링: 품절 시 다른 스타일이나 메시지를 보여주려면 DOM 조작 코드가 복잡해짐
3) UI 기능 추가
상품 다중 선택 기능
// ❌ 현재: 개별 상품 선택 상태를 관리할 구조가 없음const addToCartBtns = document.querySelectorAll('.add-to-cart-btn');
for (const element of addToCartBtns) {
element.addEventListener('click', function (e) {
// 단일 상품만 처리 가능
});
}
선택 상태 추적의 어려움: 체크박스 UI를 추가하더라도 어떤 상품들이 선택되었는지 추적할 중앙화된 방법이 없음
일괄 처리 로직의 복잡성: 선택된 여러 상품을 한 번에 장바구니에 추가할 때 재고 체크와 UI 업데이트를 동기화하기 어려움
UI 상태 관리: 전체선택/해제 기능 구현 시 모든 체크박스 상태를 개별적으로 관리해야 함
장바구니 다중 선택 삭제
// ❌ 현재: 장바구니 아이템들이 개별 DOM 요소로만 관리됨document.getElementById('cart-items')?.addEventListener('click', (e) => {
if (target.classList.contains('remove-btn')) {
cartItem?.remove(); // 개별 삭제만 가능
}
});
다중 선택 상태 관리: 장바구니 아이템들의 선택 상태를 추적하고 관리할 구조가 없음
일괄 삭제 로직: 선택된 여러 아이템을 찾아서 동시에 삭제하고 총합계를 다시 계산하는 로직이 복잡함
전체선택 동기화: 전체선택 체크박스와 개별 아이템 체크박스들 간의 상태 동기화가 어려움
문제 정리하기
앞에서 분석한 문제들을 종합해서 정리해보면 다음과 같다.
데이터와 UI의 강결합: 모든 데이터가 DOM에 직접 저장되어 있어 데이터 조작이 어렵다.
상태 관리 부재: 상품 목록, 장바구니, 선택 상태 등을 추적할 중앙화된 구조가 없다.
이벤트 처리의 한계: 현재의 이벤트 위임 구조로는 복잡한 상호작용을 처리하기 어려움
렌더링 로직 부재: 데이터 변경 시 UI를 일관되게 업데이트할 체계적인 방법이 없음
(4) 어떻게 개선할 수 있을까?
나는 앞에서 언급한 1~4의 문제들이 명령형 프로그래밍으로 코드로 작성되어 문제가 발생했다고 생각한다.
❗
명령형 vs 선언형
명령형 프로그래밍
"어떻게(How)" 해야 하는지에 집중
단계별로 명령을 나열
상태 변경을 직접 관리
DOM을 직접 조작
선언형 프로그래밍
"무엇을(What)" 원하는지에 집중
원하는 결과를 선언
상태에 따른 자동 렌더링
데이터 중심의 UI
지금 작성된 코드는 "무엇을 보여줄지"가 아니라 "어떻게 업데이트할지"에 초점이 맞춰져있다.
// "어떻게(How)" 해야 하는지 단계별로 기술const quantityEl = existingItem.querySelector('.quantity') asHTMLSpanElement;
let currentQuantity = parseInt(quantityEl.textContent ?? '0');
// 1단계: 수량 증가
currentQuantity++;
// 2단계: DOM 업데이트
quantityEl.textContent = currentQuantity.toString();
const subtotalEl = existingItem.querySelector('.subtotal') asHTMLSpanElement;
// 3단계: 소계 계산const newSubtotal = currentQuantity * productPrice;
// 4단계: 소계 업데이트
subtotalEl.textContent = newSubtotal.toLocaleString() + '원';
// 5단계: 총계 업데이트updateTotalPrice();
앞에서 정의한 새로운 요구사항을 추가한다고 가정해보면 이런 모습일 것이다.
// 검색/정렬 기능 > 이런 복잡한 단계가 필요functionsortProducts(sortBy: 'name' | 'price') {
// 1. 모든 DOM 요소에서 데이터 추출// 2. 추출한 데이터로 정렬 로직 수행 // 3. DOM 요소들을 제거// 4. 정렬된 순서로 DOM 요소들을 다시 생성// 5. 이벤트 리스너 다시 연결
}
// 재고관리 > 재고 변경 시 모든 UI를 수동으로 찾아서 업데이트functionupdateStock(productId: string, newStock: number) {
// 1. 상품 목록에서 해당 상품 버튼 찾기// 2. 버튼 상태 변경 (활성/비활성)// 3. 장바구니에서 해당 상품 찾기// 4. 수량 조절 버튼 상태 변경// 5. 품절 메시지 표시/숨김
}
그렇다면 선언형 프로그래밍은 어떤 모습일까? 아마 이 글을 읽는 대부분의 사람들은 React 혹은 Vue를 사용한 경험이 있을 것이다. (아마도!?)
Vue나 React의 경우 라이브러리 혹은 프레임워크 계층에서 코드를 자연스럽게 “선언형” 기반으로 작성하도록 유도하고 있다.
// 선언형은 "무엇을(What)" 보여줄지만 정의한다.functionProductList({ products, onAddToCart }) {
// 필터링 된 값을 토대로const filteredProducts = products
.filter(p => p.name.includes(searchQuery))
.sort((a, b) => sortBy === 'name' ? a.name.localeCompare(b.name) : a.price - b.price);
// 상품 목록을 보여준다. return (
<>
{filteredProducts.map(product => (
<ProductCard
{...product}
disabled={product.stock === 0} {/*사실컴포넌트내부에서계산해줘도됨*/}
// 이벤트가발생하면상태가변경되어컴포넌트렌더링시에ui에반영된다.onAddToCart={onAddToCart}
/>
))}
</>
)
}
위의 코드는 UI를 명령을 토대로 만들어 가는 것이 아닌, UI의 모습을 선언하고 데이터가 변경되면 자연스럽게 UI에 반영되는 구조다.
상태를 정의하고, 상태를 변경하는 함수가 있어야 한다.
상태를 반영한 모습의 UI를 선언한다.
UI에서 발생하는 이벤트를 통해 함수를 실행한다.
함수가 실행되면 데이터가 변경되고, 데이터가 변경되면 UI에 반영된다.
결과적으로 UI와 상태를 깔끔하게 분리할 수 있다.
🤔
프론트엔드 개발자가 접할 수 있는 선언형 프로그래밍으로 작성하도록 유도하는 코드들은 어떤게 있을까?
// ❌ 명령형이라면 이렇게 했을 것functiongetAvailableProducts(productData) {
const result = [];
// 1단계: 재고가 있는 상품만 필터링for (let i = 0; i < productData.length; i++) {
if (productData[i].stock > 0) {
// 2단계: 데이터 변환const product = {
id: productData[i].id,
name: productData[i].name,
price: productData[i].price,
stock: productData[i].stock,
displayPrice: productData[i].price.toLocaleString() + '원',
status: productData[i].stock > 0 ? 'available' : 'sold-out'
};
result.push(product);
}
}
// 3단계: 가격순 정렬for (let i = 0; i < result.length - 1; i++) {
for (let j = 0; j < result.length - i - 1; j++) {
if (result[j].price > result[j + 1].price) {
const temp = result[j];
result[j] = result[j + 1];
result[j + 1] = temp;
}
}
}
return result;
}
HTML (선언적 구조)
<!-- ✅ 선언형: 구조와 의미를 선언 --><formclass="search-form"><inputtype="search"placeholder="상품 검색..."class="search-input"><selectclass="sort-select"><optionvalue="name">이름순</option><optionvalue="price">가격순</option></select></form><divclass="product-grid"><!-- 상품들이 표시될 곳 --></div>
-- ✅ 선언형: "무엇을" 원하는지 선언SELECT
name,
price,
CASEWHEN stock >0THEN'available'ELSE'sold-out'ENDas status
FROM products
WHERE stock >0ORDERBY price ASC;
// ❌ 명령형이라면 이렇게 했을 것functiongetAvailableProductsData(products) {
const result = [];
// 1. 모든 상품을 순회for (const product of products) {
// 2. 재고가 있는지 확인if (product.stock > 0) {
// 3. 상태 결정let status;
if (product.stock > 0) {
status = 'available';
} else {
status = 'sold-out';
}
// 4. 결과에 추가
result.push({
name: product.name,
price: product.price,
status: status
});
}
}
// 5. 가격순으로 정렬
result.sort((a, b) => a.price - b.price);
return result;
}
그리고 프론트엔드에서 어플리케이션을 선언형 방식으로 만들어 가는 대표적인 방법은 MVC 패턴 혹은 MVVM 패턴을 적용하는 것이다.
MVC(Model + View + Controller)
Model: 어플리케이션의 핵심이 되는 데이터와 데이터를 조작하는 함수
View: HTML
Controller: HTML과 Model을 연결해주는 것. 가령, 이벤트 같은게 될 수 있음
Controller를 무엇이라고 정의할 수 있을지는 생각하기 나름이기 때문에 헷갈리는 경우가 무척 많다. 그래서 나는 Backend와 비교해보고자 한다.
backend의 MVC
// backend에서 사용하는 express.js의 router이다.// 사용자의 요청이 오면 이를 핸들링(controller)하고// 다양한 비즈니스 로직을 수행(model) 후 응답(view) 한다.
app.get('/', async (req, res) => {
const data = await homeService.getData();
res.json(data)
})
# 먼저 개발 서버를 실행해야 한다.
$ pnpm run dev
# playwright codegen 실행
$ pnpm exec playwright codegen localhost:5173
codegen으로 만들어진 결과물
playwright.config.ts 수정
import { defineConfig, devices } from'@playwright/test';
exportdefaultdefineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
trace: 'on-first-retry',
},
// 처음에는 chromium, webkit, firefox 등의 브라우저에 대해 동시에 테스트 하도록 설정 됨// 지금 테스트를 정교하게 할 필요는 없어서 일단 chromium 만 남기고 제거projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// 테스트를 실행하기 전에 미리 개발서버를 실행하기 위해 필요함webServer: {
command: 'pnpm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
테스트를 실행해서 확인해보기
$ pnpm run test:e2e:ui
테스트가 통과되는걸 확인했다.
각각의 단계에 대해 playwright이 스냅샷을 찍어준다.
마우스로 각 단계를 클릭하거나 키보드 방향키로 움직이면서 스냅샷을 확인해볼 수 있다.
설치부터 테스트 작성 및 실행까지 빠르면 5분 이내로 끝낼 수 있다.
(2) 상태와 상태를 변경하는 함수 정의하기
기존 main은 main__orgin.ts 로 이름을 변경하고, main.ts 를 새로 추가하여 진행하자.
./src
├── ./step1/main.ts # 기존 코드는 step1 폴더로 분리해서 유지하자.
└── ./step2/main.ts # 리팩토링을 위한 코드는 step2 폴더에 새로 만들자.
그리고 기존의 index.html을 step1.html으로 변경하고, step2.html을 새로 만들자.
여기서 잠시 고민을 할 필요가 있다. 지금은 carts가 배열로 표현 되는데, 주로 쓰이는 로직을 보면 productId를 기반으로 변경이 발생하는 것을 알 수 있다. 그렇다면 productId에 대한 Map 형태로 상태를 구성해주면 더 편리하게 함수를 구성할 수 있지 않을까?
const carts = {
// ProductId에 대한 Map으로 정의한다.value: {} asRecord<Product['id'], CartItem>,
// 배열 구조로 쓰임이 필요할 땐 `carts.items` 를 통해 호출할 수 있도록 getter를 만든다.getitems() {
returnObject.values(this.value);
},
// 값을 변경하는 메소드들이 무척 간략해졌다.add(productId: string) {
const quantity = this.value[productId]?.quantity ?? 0this.value[productId] = { productId, quantity: quantity + 1 };
},
update(productId: string, quantity: number) {
this.value[productId].quantity = Math.max(quantity, 1);
},
remove(productId: string) {
deletethis.value[productId];
},
clear() {
this.value = {};
},
};
이 때 this 라는 키워드가 노출되는게 그닥 보기 좋진 않다. 이럴 때 취할 수 있는 방법이 하나 있다. 리액트의 커스텀훅을 만드는 것 처럼, 함수로 내부 값을 추상화해서 관리하는 것이다.
constcreateCarts = (initValue: Record<Product['id'], CartItem> = {}) => {
let value = initValue;
return {
// value를 값으로 반환하는 경우, 참조가 달라질 수 있다.// 그래서 getter를 통해 정의하여 함수 내부의 value를 계속 참조하도록 해야 한다.// getter로 반환할 경우, carts.value = 1234 처럼 밖에서 할당하는게 불가능해서 안전하다.// 단, carts.value[1] = 1; 처럼 value 객체의 값은 수정이 가능해서 Object.freeze 로// 객체내부의 변화를 방지할 수 있다.getvalue() {
returnObject.freeze(value);
},
getitems(): CartItem[] {
// this.value를 사용하면 getter로 정의된 value를 사용하게 된다. 즉, 변경이 불가능한 value를 사용하게 된다.returnObject.values(this.value);
},
add(productId: string) {
const quantity = value[productId]?.quantity ?? 0
value[productId] = { productId, quantity: quantity + 1 };
},
update(productId: string, quantity: number) {
value[productId] = { productId, quantity: Math.max(quantity, 1) };
},
remove(productId: string) {
delete value[productId];
},
clear() {
value = {};
},
}
}
products와 carts를 합쳐서 store 라고 정의해보자. products에 몇 가지 함수도 추가해서 사용하면 좋을 것 같다.
constcreateProducts = (initValue = INITIAL_PRODUCTS) => {
// product도 valueMap 형태로 관리하면 편할 것 같다.const value = initValue.reduce((acc, product) => ({
...acc,
[product.id]: product
}), {});
return {
getvalue() { return value },
getitems() { returnObject.values(this.value); },
// productId를 통해 가져오는 함수를 하나 만들었다.getProduct: (id) => value[id],
}
}
const store = {
// carts와 인터페이스를 동일하게 유지하기 위해 value를 통해 참조하도록 했다.products: createProducts(),
carts: createCarts(),
// products와 carts를 조합하여 사용하는 경우도 있을 것이다.// 이럴 때는 store를 통해 참조하도록 만들면 좋다.gettotalCartPrice() {
returnthis.carts.items.reduce((total, item) => {
const product = this.products.get(item.productId);
return total + (product ? product.price * item.quantity : 0);
}, 0);
},
// products와 carts를 합친 데이터도 만들어서 관리할 수 있다.getcartsWithProduct() {
returnthis.carts.items.map(({ productId, quantity }) => {
const product = this.products.value[productId];
return {
...product,
quantity,
subtotal: product.price * quantity,
};
});
}
};
이렇게 코드를 작성하면 모든 비즈니스 로직이 store에 응집된다. 지금처럼 객체의 덩어리로 만들어서 관리할 수도 있고, 혹은 함수 뭉치의 조합으로 사용할 수도 있다. 개인적으로 지금과 같은 모습을 선호한다.
중요한건 상태와 상태를 변화시키는 함수가 응집되어 있어야 관리하기가 수월하다는 점이다.
이런걸 reducer 같은걸로 정의할 수도 있을 것이다.
(3) 상태를 기반으로 UI를 표현하기
1) 템플릿
먼저 index.html에 있는 html 태그를 템플릿으로 만들어서 관리하는 방식을 상상하면 좋다.
<!-- 상품 아이템 템플릿 --><divclass="bg-white p-4 rounded-lg shadow"><imgsrc="https://picsum.photos/id/1/200.webp"alt="맥북 프로 14인치"class="w-full h-48 object-cover mb-4 rounded"><h3class="text-lg font-bold mb-2">맥북 프로 14인치</h3><pclass="text-gray-600 mb-4">2,990,000원</p><buttonclass="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 add-to-cart-btn"data-product-id="1">
장바구니 담기
</button></div><!-- 상품 목록 템플릿 --><divid="product-list"class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"><!-- 상품 아이템들이 여기 들어가야함 --></div>
이런 방식으로 html 템플릿을 가져다 사용할 수 있다. 한 번 전체 UI를 template literal로 정의해보자.
store(model)과 ui(view)를 선언했으니, 이제 store와 ui를 연결해주는 이벤트(controller)를 선언하고 관리해야 한다.
어떤 상호작용이 필요한지 고민해보자.
상품목록에서 “장바구니 담기” 버튼을 클릭할 때
장바구니에서 +, - 버튼을 클릭할 때
장바구니에서 ‘삭제” 버튼을 클리할 때
장바구니에서 “장바구니 비우기” 버튼을 클릭할 때
이를 토대로 이벤트를 선언해보자.
먼저 이벤트와 관련된 유틸 함수를 만들어야 한다.
// 이벤트를 추가해주는 유틸 함수를 하나 선언하자. 이게 일종의 controller 역할을 수행한다.// 이벤트를 직접 등록하는 것이 아닌 이벤트 위임 방식으로 등록하도록 만든다.// 직관적인 컨트롤러의 역할을 하기 위함이다.functionaddEvent(eventType, selector, callback, parent = document) {
parent.addEventListener(eventType, event => {
if (event.target.closest(selector)) {
callback(event)
}
})
}
// 이벤트 위임 방식으로 등록할 경우, 중복 등록이 될 수 있기 때문에// setupEvents 함수를 이용하여 내부에서만 최초에 한 번 등록하는 장치를 만들어준다.const setupEvents = (() => {
let initialized = false;
return(setup: () => void) => {
if (!initialized) {
setup();
initialized = true;
}
}
})();
./src/step2
├── apps.ts # addEvent, setupEvents 등 어플리케이션 종속적인 로직
├── components.ts # ProductItem, CartItem, CartSummary 등 UI 선언 로직
├── domains.ts # carts, products 와 관련된 도메인 로직
└── main.ts # components, apps, domains 등을 엮어서 어플리케이션을 실행
domains.ts
기존에는 store의 기본값을 고정시켰는데, 이걸 기본값을 바깥에서 주입받는 방식으로 변경했다.
components.ts
기존에는 App 내부에서 store를 호출하는 방식으로 만들었는데, 바깥에서 인자를 넘겨주는 방식으로 변경했다.
apps.ts
main.ts
components 가 직접적으로 store 의존하고 있었는데 이걸 간접적으로 의존하게 하여 더 유연한 상태로 만들었다.
현재 구조에서 주목해야할 부분은 의존성의 흐름이다.
이런 방향으로 함수를 호출하고 있다.
의존성이 단방향으로 흘러야 요구사항에 민첩하게 대응할 수 있다.
마지막으로 기존에 작성된 e2e 테스트를 조금 다듬어보자.
import { expect, typePage, test } from'@playwright/test';
// 기존에 작성된 테스트 코드를 함수로 분리했다.// 그리고 테스트를 수행하는 페이지를 다르게 할 수 있도록 지정했다.asyncfunctiontestFirstAssignmentAtPage(page: Page, path: string) {
await page.goto(`http://localhost:5173/${path}.html`);
awaitexpect(page.locator('body')).toMatchAriaSnapshot(`
/* ... 길어서 생략. 기존 코드 참고. ... */
`);
await page
.locator('#product-list div')
.filter({ hasText: '맥북 프로 14인치 2,990,000원 장바구니 담기' })
.getByRole('button')
.click();
await page
.locator('#product-list div')
.filter({ hasText: '아이폰 15 Pro 1,550,000원 장바구니 담기' })
.getByRole('button')
.click();
await page
.locator('#product-list div')
.filter({ hasText: '갤럭시 S24 1,200,000원 장바구니 담기' })
.getByRole('button')
.click();
await page
.locator('#product-list div')
.filter({ hasText: '에어팟 프로 350,000원 장바구니 담기' })
.getByRole('button')
.click();
await page.getByRole('button', { name: '+' }).first().click();
await page.getByRole('button', { name: '+' }).nth(1).click();
await page.getByRole('button', { name: '+' }).nth(1).click();
await page.getByRole('button', { name: '+' }).nth(2).click();
await page.getByRole('button', { name: '+' }).nth(2).click();
awaitexpect(page.locator('body')).toMatchAriaSnapshot(`
/* ... 길어서 생략. 기존 코드 참고. ... */
`);
await page.getByRole('button', { name: '-' }).nth(2).click();
await page.getByRole('button', { name: '삭제' }).nth(1).click();
awaitexpect(page.locator('body')).toMatchAriaSnapshot(`
/* ... 길어서 생략. 기존 코드 참고. ... */
`);
await page.getByRole('button', { name: '장바구니 비우기' }).click();
awaitexpect(page.locator('body')).toMatchAriaSnapshot(`
/* ... 길어서 생략. 기존 코드 참고. ... */
`);
}
// step1, step2 에 대해 동시에 테스트할 수 있어야 한다.
test.describe('첫 번째 요구사항에 대한 테스트 > ', () => {
test('step1 > ', async ({ page }) => {
awaittestFirstAssignmentAtPage(page, 'step1');
});
test('step2 > ', async ({ page }) => {
awaittestFirstAssignmentAtPage(page, 'step2');
});
});
테스트 코드를 실행한 다음에 정상적으로 동작하는지 확인해보자.
$ pnpm run test:e2e
이렇게 학습을 위해 리팩토링을 할 때에는 기존 코드를 보존하고 테스트를 통해 내가 작성한 코드에 문제가 없는지 계속 검증을 해보면 좋다.
(6) 간단한 단위 테스트 작성
앞서 파일을 분할했으니, 이제 간단하게 단위 테스트를 작성해보자. vitest는 설정도 쉬워서 금방 진행할 수 있다.
1) vitest 환경설정
vitest와 jsdom을 먼저 설치해야 한다.
$ pnpm add -D vitest @vitest/coverage-v8 jsdom
이어서 vitest.config.ts 를 추가하고 jsdom에 대한 설정을 추가해야 한다.
import { defineConfig } from'vitest/config';
exportdefaultdefineConfig({
test: {
globals: true,
environment: 'jsdom',
// 초반에 작성한 playwright으로 작성한 e2e 테스트를 제외시켜야한다.exclude: ['./tests', './node_modules', './dist'],
},
});
마지막으로 npm script 를 추가해줘야 한다.
"scripts":{"dev":"vite","tsc":"tsc --noEmit","lint":"eslint ./src --fix","prettier":"prettier ./src --write","build":"tsc && vite build","preview":"vite preview","prepare":"husky",// 기본 테스트와 테스트 커버리지를 측정하는 스크립트를 추가한다."test":"vitest","test:coverage":"vitest --coverage","test:e2e":"playwright test","test:e2e:ui":"playwright test --ui","test:e2e:codegen":"playwright codegen localhost:5173"},
2) 테스트 스펙을 먼저 작성하기
처음부터 테스트를 다 작성하기보단, 테스트 스펙만 우선 정의해놓으면 좋다.
// ./src/__tests__/domains.test.tsdescribe('domains > ', () => {
test('상품 목록을 조회할 수 있으며, 이름/가격/상품이미지 등을 확인할 수 있다.');
test('상품을 장바구니에 담을 수 있다.');
test('수량 변경이 가능하고, 최소값은 1이다.');
test('장바구니에서 개별 상품을 삭제할 수 있다.');
test('장바구니를 한 번에 비울 수 있다.');
test('개별 상품 소계를 확인할 수 있다.');
test('장바구니에 담긴 전체 상품 가격을 확인할 수 있다.');
})
// ./src/__tests__/components.test.tsdescribe('components > ', () => {
describe('ProductItem', () => {
test('상품 정보가 올바르게 렌더링된다');
});
describe('CartItem', () => {
test('장바구니 상품 정보가 올바르게 렌더링된다');
});
describe('CartSummary', () => {
test('총 합계가 올바르게 표시된다');
});
describe('App', () => {
test('전체 애플리케이션이 올바르게 렌더링된다');
test('상품이나 장바구니가 비어있는 경우에도 렌더링된다');
});
});
// ./src/__tests__/apps.test.tsdescribe('apps > ', () => {
describe('addEvent', () => {
test('선택자에 맞는 요소 클릭 시 콜백 함수가 호출된다');
test('부모 요소를 지정하여 이벤트를 등록할 수 있다');
test('선택자와 일치하지 않는 요소 클릭 시 콜백 함수가 호출되지 않는다');
});
test('setup 함수는 처음 호출될 때만 실행된다.');
});
3) 테스트 채우기
여태까지 작성된 내용을 기반으로 AI에게 테스트를 작성해달라고 하면 작성해준다. 연습할겸 직접 작성해도 좋지만, 일단 AI에게 위임해도 충분할 것 같다.
// domain.test.tsimport { afterEach, describe, expect, test } from'vitest';
import { createStore, typeProduct } from'../domains';
constMOCK_PRODUCTS: Product[] = [
{ id: '1', name: '상품1', price: 10000, image: 'image1.jpg' },
{ id: '2', name: '상품2', price: 20000, image: 'image2.jpg' },
];
describe('Domains > ', () => {
// 사실 파일을 분리할 때 store 대신 createStore를 정의한 것은 이 구간 때문이다.// 테스트 데이터를 사용하기 위함!const store = createStore({ products: MOCK_PRODUCTS });
afterEach(() => {
store.carts.clear();
});
test('상품 목록을 조회할 수 있으며, 이름/가격/상품이미지 등을 확인할 수 있다.', () => {
expect(store.products.items).toEqual([
{ id: '1', name: '상품1', price: 10000, image: 'image1.jpg' },
{ id: '2', name: '상품2', price: 20000, image: 'image2.jpg' },
]);
expect(store.products.value).toEqual({
'1': { id: '1', name: '상품1', price: 10000, image: 'image1.jpg' },
'2': { id: '2', name: '상품2', price: 20000, image: 'image2.jpg' },
});
});
test('상품을 장바구니에 담을 수 있다.', () => {
store.carts.add('1');
expect(store.carts.value).toEqual({
'1': { productId: '1', quantity: 1 },
});
store.carts.add('2');
expect(store.carts.value).toEqual({
'1': { productId: '1', quantity: 1 },
'2': { productId: '2', quantity: 1 },
});
});
test('수량 변경이 가능하고, 최소값은 1이다.', () => {
store.carts.add('1');
store.carts.update('1', 3);
expect(store.carts.value).toEqual({
'1': { productId: '1', quantity: 3 },
});
store.carts.update('1', -100);
expect(store.carts.value).toEqual({
'1': { productId: '1', quantity: 1 },
});
});
test('장바구니에서 개별 상품을 삭제할 수 있다.', () => {
store.carts.add('1');
store.carts.update('1', 3);
expect(store.carts.value).toEqual({
'1': { productId: '1', quantity: 3 },
});
store.carts.remove('1');
expect(store.carts.value).toEqual({});
expect(store.carts.items).toEqual([]);
});
test('장바구니를 한 번에 비울 수 있다.', () => {
store.carts.add('1');
store.carts.add('2');
expect(store.carts.value).toEqual({
'1': { productId: '1', quantity: 1 },
'2': { productId: '2', quantity: 1 },
});
store.carts.clear();
expect(store.carts.value).toEqual({});
expect(store.carts.items).toEqual([]);
});
test('개별 상품 소계를 확인할 수 있다.', () => {
store.carts.add('1');
store.carts.update('1', 3); // 30000
store.carts.add('2'); // 20000expect(store.cartsWithProduct).toEqual([
{
id: '1',
image: 'image1.jpg',
name: '상품1',
price: 10000,
quantity: 3,
subtotal: 30000,
},
{
id: '2',
image: 'image2.jpg',
name: '상품2',
price: 20000,
quantity: 1,
subtotal: 20000,
},
]);
});
test('장바구니에 담긴 전체 상품 가격을 확인할 수 있따.', () => {
store.carts.add('1');
store.carts.update('1', 3); // 30000
store.carts.add('2'); // 20000expect(store.totalCartPrice).toBe(50000); // 30000 + 20000
});
});
// components.test.tsimport { describe, expect, test } from'vitest';
import { ProductItem, CartItem, CartSummary, App } from'../components';
import { typeCartWithProduct, typeProduct } from'../domains';
describe('Components > ', () => {
// 테스트용 상품 데이터constmockProduct: Product = {
id: '1',
name: '테스트 상품',
price: 10000,
image: 'test-image.jpg',
};
// 테스트용 장바구니 상품 데이터constmockCartItem: CartWithProduct = {
...mockProduct,
quantity: 2,
subtotal: 20000,
};
describe('ProductItem', () => {
test('상품 정보가 올바르게 렌더링된다', () => {
const html = ProductItem(mockProduct);
// 필수 정보 포함 확인expect(html).toContain(mockProduct.name);
expect(html).toContain('10,000원'); // 가격이 포맷팅되어 표시expect(html).toContain(`src="${mockProduct.image}"`);
expect(html).toContain(`data-product-id="${mockProduct.id}"`);
expect(html).toContain('장바구니 담기');
});
});
describe('CartItem', () => {
test('장바구니 상품 정보가 올바르게 렌더링된다', () => {
const html = CartItem(mockCartItem);
// 필수 정보 포함 확인expect(html).toContain(mockCartItem.name);
expect(html).toContain('10,000원'); // 가격이 포맷팅되어 표시expect(html).toContain(`src="${mockCartItem.image}"`);
expect(html).toContain(`data-product-id="${mockCartItem.id}"`);
expect(html).toContain(
`<span class="quantity">${mockCartItem.quantity}</span>`
);
expect(html).toContain('삭제');
});
});
describe('CartSummary', () => {
test('총 합계가 올바르게 표시된다', () => {
const totalPrice = 35000;
const html = CartSummary({ totalPrice });
expect(html).toContain('총 합계:');
expect(html).toContain('35,000원'); // 가격이 포맷팅되어 표시expect(html).toContain('장바구니 비우기');
});
});
describe('App', () => {
test('전체 애플리케이션이 올바르게 렌더링된다', () => {
constproducts: Product[] = [mockProduct];
constcarts: CartWithProduct[] = [mockCartItem];
const totalCartPrice = 20000;
// 마찬가지로, App이 store를 직접적으로 의존하지 않도록 하여 유연하게 사용할 수 있도록 만들었다.const html = App({ products, carts, totalCartPrice });
// 각 섹션이 존재하는지 확인expect(html).toContain('쇼핑몰');
expect(html).toContain('id="product-list"');
expect(html).toContain('id="cart-items"');
expect(html).toContain('20,000원'); // 총 합계 가격// 각 컴포넌트의 결과물이 포함되었는지 확인expect(html).toContain(mockProduct.name);
expect(html).toContain(`data-product-id="${mockProduct.id}"`);
expect(html).toContain('장바구니 비우기');
});
test('상품이나 장바구니가 비어있는 경우에도 렌더링된다', () => {
const html = App({ products: [], carts: [], totalCartPrice: 0 });
expect(html).toContain('쇼핑몰');
expect(html).toContain('id="product-list"');
expect(html).toContain('id="cart-items"');
expect(html).toContain('0원'); // 총 합계 가격
});
});
});
// apps.test.tsimport { describe, expect, test, vi, beforeEach, afterEach } from'vitest';
import { addEvent, setupEvents } from'../apps';
describe('apps > ', () => {
describe('addEvent', () => {
// 테스트 전 DOM 요소 설정beforeEach(() => {
// vite.config.ts 에 jsdom 설정을 해야 이런 코드를 사용할 수 있다.document.body.innerHTML = `
<div id="container">
<button id="test-button">테스트 버튼</button>
<div class="item">아이템 1</div>
<div class="item">아이템 2</div>
</div>
`;
});
// 테스트 후 DOM 요소 초기화afterEach(() => {
document.body.innerHTML = '';
});
test('선택자에 맞는 요소 클릭 시 콜백 함수가 호출된다', () => {
const mockCallback = vi.fn();
const button = document.getElementById('test-button');
addEvent('click', '#test-button', mockCallback);
// 클릭 이벤트 시뮬레이션
button?.click();
expect(mockCallback).toHaveBeenCalledTimes(1);
});
test('부모 요소를 지정하여 이벤트를 등록할 수 있다', () => {
const container = document.getElementById('container') asHTMLElement;
const mockCallback = vi.fn();
if (container) {
addEvent('click', '.item', mockCallback, container);
// 첫 번째 아이템 클릭 시뮬레이션const firstItem = document.querySelector('.item');
firstItem?.dispatchEvent(newEvent('click', { bubbles: true }));
expect(mockCallback).toHaveBeenCalledTimes(1);
}
});
test('선택자와 일치하지 않는 요소 클릭 시 콜백 함수가 호출되지 않는다', () => {
const mockCallback = vi.fn();
addEvent('click', '.non-existent', mockCallback);
// 버튼을 클릭해도 콜백이 호출되지 않음const button = document.getElementById('test-button');
button?.click();
expect(mockCallback).not.toHaveBeenCalled();
});
});
test('setup 함수는 처음 호출될 때만 실행된다.', () => {
const setupFn = vi.fn();
// 첫 번째 호출setupEvents(setupFn);
expect(setupFn).toHaveBeenCalledTimes(1);
setupEvents(setupFn);
setupEvents(setupFn);
expect(setupFn).toHaveBeenCalledTimes(1); // 여전히 1회만 호출됨
});
});
4) 테스트 커버리지를 측정해본다.
# step3 에 대해서만 측정하면 된다.
$ pnpm run test:coverage --coverage.include=src/step3
e2e가 있기 때문에 main에 대한 통합테스트는 진행하지 않아도 무방할 것 같다.
domains에 branch coverage가 조금 부족해서 확인해봤는데, carts에서 참조가 불가능한 product가 있을 때에 대한 엣지케이스가 없어서 그런 것으로 확인되었다.
gettotalCartPrice() {
returnthis.carts.items.reduce((total, item) => {
const product = this.products.get(item.productId);
// product가 없는 케이스에 대한 테스트가 없는 상태return total + (product ? product.price * item.quantity : 0);
}, 0);
},
이렇게 테스트 케이스를 하나 더 추가해보자.
test('실제로 없는 제품을 장바구니에 추가할 경우, totalCartPrice는 0이 된다.', () => {
store.carts.add('3');
expect(store.totalCartPrice).toBe(0); // 30000 + 20000
});
좋은 코드 구조는 새로운 요구사항이 왔을 때 그 진가를 발휘한다고 생각한다. 요구사항 변화에 유연하게 대응할 수 있는 코드가 바로 클린코드라고 할 수 있지 않을까?
이걸 설명하기 위해 여기까지 달려왔다.. 어휴 힘들다.
4단계: React로 전환하기
앞선 단계에서 관심사 분리를 통해 구축한 코드가 얼마나 잘 설계되었는지 확인해보기 위한 다른 방법 중 하나가 React로 다시 표현해보는 것이다. 좋은 아키텍처라면 기술 스택이 바뀌어도 핵심 비즈니스 로직은 그대로 유지되면서 최소한의 수정만으로 전환이 가능해야 한다.
가령, "Vue로 만들어진걸 React로 다시 만들자!" 같은 상황을 겪은 사람이 종종 있을 것이다. 혹은 내가 작성한 코드를 라이브러리로 배포 되어 여러 플랫폼에서 쓰이는 상황도 가정해볼 수 있다. 이 또한 요구사항의 변화, 더 정확히는 기술적 요구사항의 변화라고 할 수 있다.
(1) React 세팅하기
세팅의 경우 지금 중요한 부분은 아니라서, 중요한 부분을 제외하곤 생략하겠다. 궁금하다면 이 커밋 링크를 참고하면 된다.
중요한 부분은 폴더구조인데, 앞선 과정처럼 step3 는 유지하고 step4를 새로 만들자.
apps 는 사라질 것이다. 렌더링에 대한 부분을 React에게 위임했기 때문에 크게 신경쓰지 않아도 된다.
domains의 내용을 hooks와 분할했다. 기존에 store가 하던 역할을 hooks이 대체할 것이다.
components의 내용은 크게 다르지 않을 것이다.
(2) 각 레이어별 전환 분석
1) 비즈니스 로직
기존에는 이렇게 값과 함수를 묶어서 store로 사용했었다.
다만 리액트에서는 값을 불변성으로 다루기 때문에, 이걸 그대로 사용하기가 쉽지 않다. 이 로직에서 함수의 사이드 이펙트를 제거하는 방향으로 개선하고, 함수가 값을 반환하도록 만들어야 한다.
그 다음 핵심 로직을 cartUtils, productUtils, cartService 로 분리하고, useCarts, useProducts 등의 훅을 만든 후 도메인 로직을 가져다 사용하는 방식으로 만들수 있다. 즉, 기존의 createCarts 과 유사한 모습으로 useCarts 라는 훅을 정의한다고 생각하면 된다.
변환 과정에 대해 정리를 해보자면 다음과 같다.
store의 역할을 hook이 대체한다.
store의 로직을 utils와 service로 분리한다.
hooks에서 utils와 service를 가져다 사용한다.
사실 이걸 3단계에서 미리 했어도 좋았을 것 같다. 하지만 이렇게 경험을 해봐야 다음에 더 신중하게 작성할 수 있다.
TO-BE: 상위 컴포넌트에서 hook을 통해 정의된 핵심 로직을 가져오고, 이걸 컴포넌트에 내려주는 방식으로 만들었다.
이벤트를 다루는 로직은 사실 대부분 유사하다. 다만 "이벤트 연결"에 대한 부분을 각각의 컴포넌트에게 위임했다.
(3) 전환 과정에서 드러난 기존 설계의 장점
1) 관심사 분리의 효과
// 기존에 UI와 비즈니스 로직을 완전히 분리하여 관리하는 방식이었다.functionmain() {
// 비즈니스 로직const store = createStore({});
// UI에 반영
$root.innerHTML = App({
products: store.products.filteredItems,
carts: {
items: store.cartsWithProduct,
selectedIds: store.carts.selectedItems.map((v) => v.productId),
},
totalCartPrice: store.totalCartPrice,
});
// 이벤트 연결addEvent(/* 이벤트 연결 */); // 상호작용
}
// ✅ React 전환 시 기존의 코드를 거의 그대로 재사용할 수 있었다.functionApp() {
// 비즈니스 로직const products = useProducts();
const carts = useCarts(products.items);
const selection = carts.selectedItems.length > 0;
// UI에 반영. UI 구조는 거의 그대로 사용return (
<divclassName="container mx-auto py-20"><h1className="text-3xl font-bold mb-8 text-center">쇼핑몰</h1><divclassName="flex gap-8"><divclassName="flex-1">
{/* 이벤트는 props으로 자연스럽게 연결 */}
<ProductControlsonInputEnterKeyDown={(searchKey) =>
products.changeOptions({ searchKey })
}
onOrderChange={(orderBy) => products.changeOptions({ orderBy })}
onSortChange={(sortBy) => products.changeOptions({ sortBy })}
/>
<divid="product-list"className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
>
{products.filteredItems.map((product) => (
<ProductItemkey={product.id}
{...product}
onCartAddClick={carts.add}
/>
))}
</div></div>
{/* 장바구니 */}
<divclassName="bg-white p-6 rounded-lg shadow w-[400px]"><h2className="text-2xl font-bold mb-4">장바구니</h2><divid="cart-items">
{carts.itemsWithProduct.length > 0 && (
<divclassName="flex gap-2 mb-2 ml-3"><inputtype="checkbox"id="select-all-cart"checked={carts.allSelected}onChange={carts.toggleAllSelected}readOnly
/></div>
)}
{carts.itemsWithProduct.map((cartItem) => (
<CartItemkey={cartItem.id}
{...cartItem}
onIncrementClick={(id) => carts.incrementQuantity(id, 1)}
onDecrementClick={(id) => carts.incrementQuantity(id, -1)}
onRemoveClick={carts.remove}
onSelect={carts.toggleSelect}
/>
))}
</div><CartSummarytotalPrice={carts.totalPrice}selection={selection}onRemoveSelectedClick={carts.removeSelected}onClearCartClick={carts.clear}
/></div></div></div>
);
}
2) 순수 함수 설계의 효과
// 🎯 기존에 이미 순수 함수로 설계됨exportconst cartUtils = {
add: (carts, productId) => { /* side effect 없음 */ },
remove: (carts, productId) => { /* side effect 없음 */ },
};
// ✅ React의 immutable 업데이트와 완벽하게 호환constadd = (id) => setCarts(value => cartUtils.add(value, id));
3) 타입 정의의 효과
// 🎯 기존에 이미 명확한 인터페이스 정의interfaceProduct { id: string; name: string; /* ... */ }
interfaceCart { productId: string; quantity: number; /* ... */ }
// ✅ React 컴포넌트 props와 완벽하게 호환functionProductItem(props: Product & { onCartAddClick: (id: string) => void }) {
// 타입 변경 없이 그대로 사용 가능
}
(4) 테스트 코드 작성
테스트에 대한 부분도 빼먹으면 아쉽다.
1) e2e 테스트 작성
사실 단위 테스트는 지금 작성하지 않아도 좋다. 다만 step3 에서 추가한 기능에 대해 e2e테스트를 작성하고, 이를 그대로 step4에서도 활용하여 “내 코드에 문제가 없구나” 를 판단할 수 있다.
test.describe('두 번째 요구사항에 대한 테스트 > ', () => {
consttestSecondAssignmentAtPage = async (page: Page, path: string) => {
await page.goto(`http://localhost:5173/${path}.html`);
// 기본 화면 요소 확인awaitexpect(page.locator('h1')).toHaveText('쇼핑몰');
awaitexpect(page.locator('h2')).toHaveText('장바구니');
// 상품 컨트롤 요소 확인awaitexpect(page.locator('#search-input')).toBeVisible();
awaitexpect(page.locator('#sort-select')).toBeVisible();
awaitexpect(page.locator('#order-select')).toBeVisible();
// 초기 상태에서 총 합계가 0원인지 확인awaitexpect(page.locator('#total-price')).toHaveText('0원');
// 1. 상품 검색 기능 테스트await page.fill('#search-input', '맥북');
await page.press('#search-input', 'Enter');
// 맥북 관련 상품만 보여야 함awaitexpect(page.locator('.product-item')).toHaveCount(2); // 맥북 프로, 맥북 에어// 검색 초기화await page.fill('#search-input', '');
await page.press('#search-input', 'Enter');
// 2. 상품 정렬 기능 테스트await page.selectOption('#sort-select', 'price');
await page.selectOption('#order-select', 'asc');
// 가격 오름차순으로 정렬되는지 확인은 상품 순서로 판단// 3. 장바구니에 상품 추가await page.click('.product-item[data-product-id="1"] .add-to-cart-btn');
await page.click('.product-item[data-product-id="2"] .add-to-cart-btn');
await page.click('.product-item[data-product-id="4"] .add-to-cart-btn');
// 장바구니에 아이템이 추가되었는지 확인awaitexpect(page.locator('.cart-item')).toHaveCount(3);
awaitexpect(page.locator('#select-all-cart')).toBeVisible(); // 전체 선택 체크박스가 나타남// 4. 장바구니 수량 변경 테스트await page.click('.cart-item[data-product-id="1"] .increase-btn');
await page.click('.cart-item[data-product-id="1"] .increase-btn');
// 첫 번째 상품의 수량이 3이 되었는지 확인awaitexpect(
page.locator('.cart-item[data-product-id="1"] .quantity')
).toHaveText('3');
// 수량 감소 테스트await page.click('.cart-item[data-product-id="1"] .decrease-btn');
awaitexpect(
page.locator('.cart-item[data-product-id="1"] .quantity')
).toHaveText('2');
// 5. 장바구니 개별 선택 테스트await page.check('.cart-item[data-product-id="1"] .cart-item-checkbox');
await page.check('.cart-item[data-product-id="2"] .cart-item-checkbox');
// 선택 삭제 버튼이 활성화되었는지 확인awaitexpect(page.locator('#remove-selected-cart')).not.toHaveAttribute(
'disabled'
);
// 6. 전체 선택 기능 테스트await page.click('#select-all-cart');
// 모든 아이템이 선택되었는지 확인awaitexpect(page.locator('.cart-item-checkbox:checked')).toHaveCount(3);
// 전체 선택 해제await page.click('#select-all-cart');
awaitexpect(page.locator('.cart-item-checkbox:checked')).toHaveCount(0);
// 7. 선택한 아이템 삭제 테스트await page.check('.cart-item[data-product-id="1"] .cart-item-checkbox');
await page.check('.cart-item[data-product-id="2"] .cart-item-checkbox');
await page.click('#remove-selected-cart');
// 선택한 아이템들이 삭제되고 1개만 남았는지 확인awaitexpect(page.locator('.cart-item')).toHaveCount(1);
awaitexpect(page.locator('.cart-item[data-product-id="4"]')).toBeVisible();
// 8. 개별 상품 삭제 테스트await page.click('.cart-item[data-product-id="4"] .remove-btn');
awaitexpect(page.locator('.cart-item')).toHaveCount(0);
awaitexpect(page.locator('#total-price')).toHaveText('0원');
// 9. 재고 관리 테스트 - 재고가 있는 상품에 최대 재고까지 추가const productWithStock = page.locator('.product-item').first();
const stockText = await productWithStock.locator('p').last().textContent();
const stockMatch = stockText?.match(/재고: (\d+)개/);
const stockCount = stockMatch ? parseInt(stockMatch[1]) : 0;
if (stockCount > 0) {
// 첫 번째 상품을 장바구니에 추가await productWithStock.locator('.add-to-cart-btn').click();
// 재고만큼 수량 증가 시도for (let i = 1; i < stockCount; i++) {
await page.click('.cart-item .increase-btn');
}
// 재고 이상으로 추가 시도해도 재고 수량을 넘지 않는지 확인await page.click('.cart-item .increase-btn');
await page.click('.cart-item .increase-btn');
awaitexpect(page.locator('.cart-item .quantity')).toHaveText(
stockCount.toString()
);
}
// 10. 전체 비우기 테스트await page.click('#clear-cart');
awaitexpect(page.locator('.cart-item')).toHaveCount(0);
awaitexpect(page.locator('#total-price')).toHaveText('0원');
// 품절 테스트 (재고가 0인 상품)const soldOutProduct = page.locator('.product-item[data-product-id="3"]');
// 품절 상품의 버튼이 비활성화되어 있는지 확인awaitexpect(soldOutProduct.locator('.add-to-cart-btn')).toHaveAttribute(
'disabled'
);
awaitexpect(soldOutProduct.locator('.add-to-cart-btn')).toHaveText('품절');
awaitexpect(soldOutProduct.locator('img')).toHaveClass(/opacity-50/);
};
test('step3 > ', async ({ page }) => {
awaittestSecondAssignmentAtPage(page, 'step3');
});
test('step4 > ', async ({ page }) => {
awaittestSecondAssignmentAtPage(page, 'step4');
});
});
실제로 이렇게 e2e를 작성하면서 react로 작성한 코드 중 잘못된 부분을 찾아낼 수 있었다. 근데 좀 귀찮다
테스트는 step2에서 다뤘던 것 처럼 codegen 을 이용하는게 제일 좋다고 생각한다. 나는 그 마저도 귀찮아서 일단 AI에게 위임했다.
// 🚨 하나의 훅이 너무 많은 데이터를 반환const { products, carts, totalPrice, selectedItems } = useStore();
// 🚨 장바구니 수량만 변경해도 모든 데이터가 변경되어 불필요한 리렌더링 발생functionCartItem({ id }) {
const { products, carts } = useStore();
// ...
}
여러 개의 훅을 사용하면 컴포넌트가 정말 필요한 데이터만 구독할 수 있다. 예를 들어 ProductItem 컴포넌트는 해당 상품의 데이터만 구독하고, CartSummary 컴포넌트는 총 가격만 구독하면 된다. 이렇게 세분화된 구독은 불필요한 리렌더링을 막아 성능을 최적화할 수 있다.
상태관리 라이브러리는 "전역 상태"를 위해서가 아니라 성능 최적화와 관심사 집중화를 위해 사용한다.
🤔
상태관리 라이브러리 대신에 Context를 사용하면 어떨까?
React Context를 사용하는 방법도 고려는 해볼 수 있다. 하지만 Context를 상태관리를 위해 사용하는 것은 적절하지 않다.
// Context 사용 예시constStoreContext = createContext<StoreType | null>(null);
functionStoreProvider({ children }: { children: ReactNode }) {
const [products, setProducts] = useState(/* ... */);
const [carts, setCarts] = useState(/* ... */);
const store = useMemo(() => ({
products,
carts,
actions: {
addToCart: (id: string) => {/* ... */},
// ... 다른 액션들
}
}), [products, carts]);
return (
<StoreContext.Providervalue={store}>
{children}
</StoreContext.Provider>
);
}
functionuseStore() {
const context = useContext(StoreContext);
if (!context) {
thrownewError('useStore must be used within StoreProvider');
}
return context;
}
Context value가 변경되면 context를 사용하는 모든 구독자가 리렌더링 된다.
성능 최적화를 위해서는 여러 개의 Context로 분리해야 하는데, 이는 관리 복잡도를 증가시키기 때문에 세밀한 구독 제어가 어렵다. 가령, products만 구독하고 싶어도, 의도하지 않게 전체 context를 구독해야 할 수 있다.
// Context를 여러 개로 분리해야 함constProductContext = createContext(/* ... */);
constCartContext = createContext(/* ... */);
constUIContext = createContext(/* ... */);
// 하지만 이렇게 하면 의존성 관리가 복잡해짐functionSomeComponent() {
const products = useContext(ProductContext);
const carts = useContext(CartContext);
// products와 carts 간의 상호작용을 어떻게 처리할까?const canAddToCart = products.items[id].quantity > carts.items[id]?.quantity;
}
학습 과정 정리
클린코드는 코딩 스타일이나 네이밍 규칙의 문제만 해당하진 않으며 변화하는 요구사항에 유연하게 대응하는 것을 목적으로 작성하는 것이 클린코드라고 생각한다. 좋은 코드는 처음부터 완벽하게 작성되는 것이 아니라, 지속적인 개선과 리팩토링을 통해 만들어진다. 중요한 것은 현재 코드의 문제점을 정확히 인식하고, 이를 체계적으로 개선해나가는 역량을 기르는 것이다.
(1) 학습 과정의 전체 흐름
이 글을 통해 클린코드를 단순한 이론이 아닌 실제 요구사항 변화에 대응하는 과정으로 학습해보았다. 총 5단계에 걸쳐 동일한 쇼핑몰 기능을 구현하면서, 각 단계마다 발생하는 문제점을 분석하고 개선하는 방식으로 진행했다.
1단계: 기본적인 요구사항을 Vanilla JavaScript로 구현했다. 모든 데이터가 DOM에 저장되고, 명령형 프로그래밍 방식으로 UI를 직접 조작하는 전형적인 초기 구현 형태였다.
2단계: 새로운 요구사항을 시뮬레이션하며 기존 코드의 한계를 명확히 드러냈다. 데이터와 UI의 강결합, 상태 관리 부재, 복잡한 DOM 조작 등의 문제점을 발견하고, 이를 해결하기 위해 관심사 분리를 수행했다. 도메인 로직, UI 컴포넌트, 이벤트 핸들링을 각각 별도의 파일로 분리하여 MVC 패턴을 적용했다.
3단계: 실제로 새로운 요구사항을 구현해보며 관심사 분리의 효과를 검증했다. 상품 검색, 정렬, 재고 관리, 다중 선택 등의 복잡한 기능을 추가했음에도 불구하고, 기존 코드 구조 덕분에 체계적이고 안전하게 확장할 수 있었다.
4단계: React로 기술 스택을 전환하여 좋은 설계의 이식성을 확인했다. 기존의 도메인 로직을 거의 그대로 유지하면서도 React의 장점을 활용할 수 있었으며, 이는 관심사 분리가 제대로 이루어졌음을 증명했다.
5단계: React 환경에서의 성능 문제를 분석하고, Zustand를 활용한 상태관리로 이를 해결했다. 불필요한 렌더링을 제거하고 컴포넌트별 선택적 구독을 통해 최적화된 애플리케이션을 완성했다.
(2) 핵심 인사이트
클린코드의 진정한 가치는 요구사항 변화 대응력이다.
클린코드를 단순히 "읽기 좋은 코드"로 이해하는 것은 피상적이다. 진정한 가치는 새로운 요구사항이나 기술적 변화에 직면했을 때 최소한의 수정으로 대응할 수 있는 유연성에 있다. 이번 학습 과정에서 관심사 분리가 제대로 이루어진 2단계 이후의 코드는 새로운 기능 추가나 기술 스택 변경에도 안정적으로 대응할 수 있었다.
명령형에서 선언형으로의 패러다임 전환
초기 단계의 명령형 코드는 "어떻게(How)" 구현할지에 집중하여 복잡성을 증가시켰다. 반면 선언형 접근방식은 "무엇을(What)" 달성할지에 집중하여 코드의 의도를 명확히 하고 유지보수성을 향상시켰다. 이러한 전환은 단순한 문법 변경이 아닌 사고방식의 근본적인 변화를 요구한다.
테스트 주도적 안전망의 중요성
각 단계별 리팩토링 과정에서 E2E 테스트와 단위 테스트가 변경의 안전망 역할을 수행했다. 특히 Playwright의 codegen 기능을 활용한 E2E 테스트는 빠른 작성과 높은 신뢰성을 동시에 제공했다. 테스트는 단순한 검증 도구가 아닌 리팩토링을 가능하게 하는 핵심 인프라임을 확인할 수 있었다. 여기서 헷갈리면 안 되는 지점이, TDD를 해야한다는 이야기가 아니다. 작성된 코드에 대한 테스트가 필요하다는 이야기이다.
관심사 분리의 실질적 효과
도메인 로직, UI 컴포넌트, 이벤트 핸들링의 분리는 각각의 변경이 다른 영역에 미치는 영향을 최소화했다. 특히 4단계에서 React로 전환할 때 도메인 로직을 거의 수정 없이 재사용할 수 있었던 것은 관심사 분리의 실질적 효과를 보여주는 명확한 사례였다.
성능 최적화와 구조적 문제
5단계에서 다룬 성능 문제는 단순히 기술적 최적화 이슈가 아니었다. 상태 관리의 구조적 문제가 불필요한 렌더링을 야기했으며, 이는 상태관리 라이브러리를 통한 관심사 재구성으로 해결되었다. 성능 최적화가 기술적 트릭이 아닌 좋은 설계의 결과임을 보여준다.
(3) 학습한 내용을 실무에서 어떻게 활용할 수 있을까?
점진적 개선의 중요성: 완벽한 설계를 처음부터 구현하려고 시도하기보다는, 동작하는 코드를 먼저 만들고 문제점을 명확히 파악한 후 단계적으로 개선하는 접근방식이 현실적이다. 이번 학습 과정에서도 각 단계별로 명확한 문제 정의와 해결책 적용을 반복했다.
요구사항 시뮬레이션의 활용: 새로운 요구사항을 가정하고 현재 코드로 구현할 때의 어려움을 시뮬레이션해보는 것은 코드 품질을 평가하는 효과적인 방법이다. 실제 요구사항 변화를 기다리지 않고도 설계의 문제점을 사전에 발견할 수 있다.
기술 스택 독립적 설계: 특정 기술에 종속되지 않는 핵심 비즈니스 로직을 구성하는 것은 장기적 유지보수성을 위해 필수적이다. 4단계에서 확인했듯이, 잘 분리된 도메인 로직은 기술 스택이 변경되어도 재사용 가능하다.
`본문.md` 의 내용을 실무에서도 활용할 수 있는 코딩 컨벤션을 만들고 싶어.
먼저 https://docs.cursor.com/context/rules 의 내용을 읽어줘. 이 룰을 기반으로 mdc 형태로 만들꺼야.
본문에서 활용한 코드를 사용하는 것이 목적이 아니라, 요구사항의 변화를 고려하여 코드를 작성하는 것이 목적이야. 이 룰은 처음에 코드를 구성하거나 리팩토링을 할 때 사용할 수 있을 것 같아.
1. 작성해야 하는 코드의 요구사항을 뽑아낸다.
2. 재활용할 수 있는 코드 (데이터와 함수)는 분리하고, 재활용하기 런타임 혹은 라이브러리나 프레임워크에 의존적인 부분도 분리한다. 이를 액션이라고 부르겠다.
3. 데이터와 함수에 대해서는 현재 환경에 적합한 테스트를 작성한다. 테스트 커버리지는 가능하면 100%를 채울 수 있도록 한다. 순수 함수와 데이터이기 때문에 목킹은 최소화 해야 한다.
4. 액션에 대한 테스트는 있으면 좋지만 필수는 아니다.
5. 리액트 코드를 작성할 땐 상태의 전파를 고려하고, 최소한의 setState만 발생할 수 있도록 한다. 필요하다면 현재 프로젝트에 설치된 상태관리 라이브러리 (zustand, redux 등)를 이용하여 설계할 수 있도록 제안한다.
6. 명령형 기반으로 작성된 코드의 경우 가능한 범위 내에서 선언형 코드로 변경해줘. 기본적으로 선언형 기반의 코드를 작성할 수 있으면 좋겠어.
7. 폴더 구조는 강제가 아니라 권장 사항이야.
내가 제시한 내용을 기반으로 룰을 구체화 해서 만들어줘. 여기에 추가할만한 내용이나 주제가 더 있으면 추가해줘도 좋아.
나는 project 기능을 사용하고 있어서, project context에 여태까지 작성한 내용(본문.md)을 markdown으로 만들어서 넣어놨다. 이를 기반으로 rule을 만들어달라고 요청했다. 대략 7번 정도 프롬프트를 edit 하면서 개선했다.
이를 통해 산출된 룰(clean-code.mdc)은 다음과 같다.
# Clean Code Rules: Requirements-Driven Development
이 룰은 요구사항의 변화를 고려하여 유지보수 가능하고 확장 가능한 코드를 작성하기 위한 가이드라인입니다.
## 1. 요구사항 분석 및 설계 우선순위
### 🎯 새로운 기능 구현 전 체크리스트
- [ ] 현재 요구사항을 명확히 정의했는가?
- [ ] 예상되는 추가 요구사항을 시뮬레이션했는가?
- [ ] 기존 코드에서 변경이 필요한 부분을 식별했는가?
- [ ] 변경의 파급효과가 최소화되는 구조인가?
### 📋 요구사항 시뮬레이션 방법
...typescript
// 예시: 쇼핑몰 기능 확장 시뮬레이션// 현재: 장바구니 추가/삭제// 예상 추가: 재고 관리, 할인 쿠폰, 다중 선택, 위시리스트// ❌ 요구사항을 고려하지 않은 설계function addToCart(productId: string){
const cartItems = document.querySelectorAll('.cart-item');
// DOM 직접 조작 - 재고 관리 추가 시 복잡해짐
}
// ✅ 확장 가능한 설계const cartService = {
add: (carts: Cart[], product: Product) => {
// 순수 함수로 설계 - 재고 체크 로직 추가 용이return [...carts, { ...product, quantity: 1 }];
}
};
...
## 2. 관심사 분리: Domains vs Actions
### 🏗 아키텍처 분리 원칙
#### Domains** (순수 비즈니스 로
- 데이터 구조 (types, interfaces)
- 비즈니스 규칙 (validation, calculation)
- 순수 함수들 (utils, services)
- 프레임워크/라이브러리에 독립적
#### Actions** (런타임 의존
- UI 렌더링 (React 컴포넌트)
- 이벤트 핸들링 (click, input)
- 상태 관리 (hooks, store)
- 외부 API 호출
### 📁 권장 폴더 구조
...
src/
├── domains/ # 순수 비즈니스 로직
│ ├── types.ts # 데이터 타입 정의
│ ├── constants.ts # 상수 정의
│ ├── utils.ts # 순수 함수들
│ ├── services.ts # 비즈니스 로직 서비스
│ └── __tests__/ # 도메인 테스트 (100% 커버리지 목표)
├── hooks/ # React 상태 관리
│ ├── useStore.ts # 상태관리 스토어
│ ├── useProducts.ts # 세분화된 훅들
│ └── __tests__/ # 훅 테스트
├── components/ # UI 컴포넌트
│ ├── Product.tsx
│ ├── Cart.tsx
│ └── __tests__/ # 컴포넌트 테스트
└── utils/ # 유틸리티 함수
└── test-helpers.ts # 테스트 헬퍼
...
### 🔄 의존성 방향 규칙
domains → hooks → components
- `domains`는 어떤 것에도 의존하지 않음
- `hooks`는 `domains`만 의존
- `components`는 `hooks`와 `domains` 의존 가능
## 3. 테스트 전략
### 🎯 테스트 우선순위
#### High Priority: Domains (100% 커버리지 목표)
...typescript
// domains/cartUtils.test.tsdescribe('cartUtils', () => {
test('상품을 장바구니에 추가할 수 있다', () => {
const carts = {};
const result = cartUtils.add(carts, 'product-1');
expect(result).toEqual({
'product-1': { productId: 'product-1', quantity: 1 }
});
});
test('재고 부족 시 추가되지 않는다', () => {
const product = { id: '1', stock: 0 };
const result = cartService.addToCart({}, [product], '1');
expect(result).toEqual({});
});
});
...
#### Medium Priority: Hooks
...typescript
// hooks/useCarts.test.ts - renderHook 사용test('장바구니 추가 시 총 가격이 계산된다', () => {
const { result } = renderHook(() => useCarts());
act(() => {
result.current.add('product-1');
});
expect(result.current.totalPrice).toBe(10000);
});
...
#### Low Priority: Components (E2E로 대체 가능)
...typescript
// E2E 테스트로 통합 테스트 수행test('상품을 장바구니에 담고 결제까지의 플로우', async ({ page }) => {
await page.click('[data-testid="add-to-cart-1"]');
await page.click('[data-testid="checkout"]');
await expect(page.locator('[data-testid="success"]')).toBeVisible();
});
...
### 🚫 목킹 최소화 원칙
...typescript
// ❌ 과도한 목킹const mockCartService = jest.mock('./cartService');
// ✅ 실제 구현 사용 (순수 함수이므로 목킹 불필요)import { cartService } from './cartService';
...
## 4. React 상태 관리 최적화
### 🎯 렌더링 최적화 원칙
#### 선택적 구독 (Zustand/Redux 권장)
...typescript
// ❌ 모든 상태를 구독하여 불필요한 리렌더링
function App() {
const { products, carts, user, ui } = useStore();
// products 변경 시 전체 App 리렌더링
}
// ✅ 필요한 상태만 선택적 구독
function ProductItem({ id }: { id: string }) {
const product = useStore(state => state.products[id]);
const addToCart = useStore(state => state.addToCart);
// 해당 상품만 변경될 때만 리렌더링
}
...
#### 상태 최적화 패턴
...typescript
// 세분화된 훅 제공exportconst useProduct = (id: string) =>
useStore(state => state.products[id]);
exportconst useCartTotalPrice = () =>
useStore(state => state.getCartTotalPrice());
exportconst useAddToCart = () =>
useStore(state => state.addToCart);
...
#### memo 활용 가이드라인
...typescript
// ✅ id만 props로 받아 store에서 데이터 구독const ProductItem = memo(({ id }: { id: string }) => {
const product = useProduct(id);
const addToCart = useAddToCart();
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => addToCart(id)}>담기</button>
</div>
);
});
// ❌ 복잡한 props로 인한 memo 효과 상실const ProductItem = memo(({ product, onAdd, onRemove, isSelected }) => {
// props가 자주 변경되어 memo 효과 없음
});
...
## 5. 선언형 코드 지향
### 🎨 명령형 → 선언형 변환 패턴
#### DOM 조작 → 상태 기반 렌더링
...typescript
// ❌ 명령형: DOM 직접 조작
function updateCartUI() {
const cartEl = document.getElementById('cart');
cartEl.innerHTML = '';
carts.forEach(item => {
const itemEl = document.createElement('div');
itemEl.textContent = item.name;
cartEl.appendChild(itemEl);
});
}
// ✅ 선언형: 상태 기반 렌더링function CartView({ carts }: { carts: Cart[] }){
return (
<div id="cart">
{carts.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
...
#### 배열 처리 패턴
...typescript
// ❌ 명령형function getAvailableProducts(products){
const result = [];
for (let i = 0; i < products.length; i++) {
if (products[i].stock > 0) {
result.push({
...products[i],
displayPrice: products[i].price.toLocaleString() + '원'
});
}
}
return result;
}
// ✅ 선언형const getAvailableProducts = (products: Product[]) =>
products
.filter(product => product.stock > 0)
.map(product => ({
...product,
displayPrice: product.price.toLocaleString() + '원'
}));
...
## 6. 리팩토링 체크리스트
### 🔄 기존 코드 개선 시 확인사항
#### 코드 스멜 탐지
- [ ] 하나의 함수가 너무 많은 책임을 가지는가?
- [ ] UI와 비즈니스 로직이 강결합되어 있는가?
- [ ] 상태 변경 시 전체 컴포넌트가 리렌더링되는가?
- [ ] 새로운 요구사항 추가 시 많은 부분을 수정해야 하는가?
#### 개선 단계
1. **E2E 테스트 작성** - 기존 동작 보장
2. **도메인 로직 추출** - 순수 함수로 분리
3. **단위 테스트 작성** - 도메인 로직 검증
4. **상태 관리 개선** - 선택적 구독 적용
5. **컴포넌트 최적화** - memo 및 세분화
## 7. 프로젝트별 적용 가이드
### 🆕 새 프로젝트
1. 요구사항 분석 및 예상 확장 시나리오 작성
2. 도메인 모델링 (types, constants 정의)
3. 순수 함수로 비즈니스 로직 구현
4. 도메인 테스트 작성 (TDD)
5. 상태 관리 설계
6. UI 컴포넌트 구현
### 🔧 기존 프로젝트 리팩토링
1. 현재 아키텍처 문제점 분석
2. E2E 테스트로 기존 동작 보장
3. 도메인 로직 단계적 분리
4. 상태 관리 최적화
5. 점진적 컴포넌트 개선
### 📊 성능 모니터링
...typescript
// React DevTools 활용// 1. Profiler로 불필요한 렌더링 확인// 2. Components 탭에서 state 변화 추적// 3. memo, useMemo, useCallback 효과 검증
...
## 8. 팀 협업 가이드
### 📋 코드 리뷰 체크포인트
- [ ] 비즈니스 로직이 domains에 분리되어 있는가?
- [ ] 새로운 요구사항 추가 시 변경 범위가 최소화되는가?
- [ ] 테스트 커버리지가 적절한가?
- [ ] 컴포넌트 렌더링이 최적화되어 있는가?
- [ ] 명령형 코드가 선언형으로 개선 가능한가?
### 🤝 팀 컨벤션
- 도메인 로직 변경 시 관련 테스트 필수 수정
- 상태 구조 변경 시 성능 영향도 확인
- 새로운 컴포넌트 작성 시 memo 적용 여부 검토
- 복잡한 비즈니스 로직은 도메인 레이어에서 해결
---
💡 **핵심 원칙**: 요구사항 변화에 유연하게 대응할 수 있는 코드가 클린코드입니다. 현재 동작하는 코드가 아닌, 미래에도 쉽게 변경할 수 있는 코드를 작성하세요.
이 rule을 기반으로 리팩토링을 어떻게 해주는지 테스트해봤다. model 성능에 따라 다르긴 하지만, 기본적인 원칙들을 잘 지켜가면서 만들어준다. 한 번에 리팩토링을 해달라고 요청하기 보단 작은 단위로 시작하는게 좋을 것이다.
그리고 항상 이 rule을 통해 코드를 작성하기보단, 작성된 코드에 대해 리팩토링할 때 덕 적합할 수 있다.
`본문.md` 의 내용을 기반으로 LLM이 리팩토링을 수행하기 적합한 system prompt 를 만들고 싶어.
본문에서 활용한 코드를 사용하는 것이 목적이 아니라, 요구사항의 변화를 고려하여 코드를 작성하는 것이 목적이야.
1. 코드의 요구사항을 뽑아낸다. 현재 요구사항의 변화를 고려하여 코드를 작성한다.
2. 요구사항은 기능 요구사항과 기술 요구사항 두 가지를 모두 고려한다.
3. 재활용할 수 있는 코드 (데이터와 함수)는 분리하고, 재활용하기 런타임 혹은 라이브러리나 프레임워크에 의존적인 부분도 분리한다. 이를 액션이라고 부르겠다.
4. 명령형 기반으로 작성된 코드의 경우 가능한 범위 내에서 선언형 코드로 변경해줘. 기본적으로 선언형 기반의 코드를 작성할 수 있으면 좋겠어.
5. 폴더 구조는 강제가 아니라 권장 사항이다.
기타: 만약 리팩토링을 해야 하는 대상의 코드가 리액트라면, 땐 상태의 전파를 고려하고, 최소한의 setState만 발생할 수 있도록 한다. 특히 컴포넌트 내부 로직에 대한 관심사를 hook으로 분리하고, hook이 도메인 로직을 활용하는 방식으로 만들도록 한다. 필요하다면 현재 프로젝트에 설치된 상태관리 라이브러리 (zustand, redux 등)를 이용하여 설계할 수 있도록 제안한다.
여기에 추가할만한 내용이나 주제가 더 있으면 추가해줘도 좋아.
이건 내가 간단하게 구성한 리팩토링 초안이야.
이를 통해 산출된 시스템 프롬프트는 다음과 같다.
당신은 유지보수성과 확장성을 고려한 리팩토링 전문가입니다. 주어진 코드는 학습을 위해 작성된 실습용 코드이며, **요구사항의 변화에 유연하게 대응할 수 있는 구조**로 리팩토링되어야 합니다.
---
## 🎯 리팩토링 철학
클린코드의 존재 이유는 **유지보수**입니다. 다음과 같은 상황을 항상 염두에 두고 리팩토링하세요:
- 새로운 기능 요구사항이 추가될 때
- 기술 스택이 변경될 때 (예: Vanilla JS → React, React → Vue)
- 성능 최적화가 필요할 때
- 테스트 코드 작성이 필요할 때
---
## 1. 요구사항 기반 분석 및 시뮬레이션
### 현재 요구사항 파악
- **기능 요구사항**: 사용자가 수행할 수 있는 액션과 비즈니스 규칙
- **기술 요구사항**: 성능, 확장성, 유지보수성, 테스트 가능성
### 요구사항 변화 시뮬레이션
다음과 같은 변화 시나리오를 고려하여 코드 구조를 검증하세요:
- 새로운 UI 컴포넌트 추가
- 데이터 속성 확장 (예: 재고 관리, 할인율 등)
- 상호작용 복잡도 증가 (예: 다중 선택, 일괄 처리)
- 외부 API 연동
- 실시간 데이터 동기화
---
## 2. 명령형 → 선언형 전환 우선
### 선언형으로 전환
#### DOM 조작 → 상태 기반 렌더링
...javascript
// ✅ 선언형: "무엇을(What)" 보여줄지 선언function CartItem({ quantity, onQuantityChange }){
return `<span class="quantity">${quantity}</span>`;
}
// 상태 변경 시 UI가 자동으로 업데이트됨
...
#### 데이터 처리 → 함수형 프로그래밍
...javascript
// ✅ 선언형: "무엇을" 원하는지 선언const availableProducts = productData
.filter(product => product.stock > 0)
.map(product => ({
...product,
displayPrice: product.price.toLocaleString() + '원',
status: 'available'
}))
.sort((a, b) => a.price - b.price);
...
#### 상태 관리 → 파생 상태 활용
...javascript
// ✅ 선언형: 상태에서 자동으로 파생const useCart = () => {
const [cartItems, setCartItems] = useState([]);
// 총액은 cartItems에서 자동 계산됨const totalPrice = useMemo(() =>
cartItems.reduce((sum, item) => sum + item.price, 0),
[cartItems]
);
const addToCart = (product) => {
setCartItems(prev => [...prev, product]); // 단일 상태 변경// totalPrice와 UI는 자동으로 업데이트됨
};
return { cartItems, totalPrice, addToCart };
};
...
#### 조건부 로직 → 선언적 매핑
...javascript
// ✅ 선언형: 상태에 따른 속성 매핑const getProductButtonProps = (product) => {
const statusMap = {
outOfStock: {
disabled: true,
text: '품절',
className: 'btn-disabled'
},
lowStock: {
disabled: false,
text: '장바구니 담기 (재고 부족)',
className: 'btn-warning'
},
available: {
disabled: false,
text: '장바구니 담기',
className: 'btn-primary'
}
};
const status = product.stock === 0 ? 'outOfStock'
: product.stock < 5 ? 'lowStock'
: 'available';
return statusMap[status];
};
// React에서 사용function ProductButton({ product }){
const buttonProps = getProductButtonProps(product);
return (
<button
disabled={buttonProps.disabled}
className={buttonProps.className}
>
{buttonProps.text}
</button>
);
}
...
#### 이벤트 처리 → 선언적 바인딩
...javascript
// ✅ 선언형: 컴포넌트에서 직접 바인딩function CartItem({ id, quantity, onQuantityChange }){
return (
<div className="cart-item">
<button onClick={() => onQuantityChange(id, quantity + 1)}>
+
</button>
<span>{quantity}</span>
</div>
);
}
...
### 선언형 전환 체크리스트
#### 🔍 명령형 코드 식별
- [ ] `for` 루프나 `while` 루프로 배열 처리
- [ ] `document.querySelector`로 DOM 요소 직접 조작
- [ ] `if-else` 체인으로 UI 상태 관리
- [ ] 상태 변경 후 수동으로 다른 상태나 UI 업데이트
- [ ] 단계별 알고리즘으로 데이터 변환
#### ✅ 선언형 전환 방법
- [ ] **Array 메서드 활용**: `map`, `filter`, `reduce` 등으로 데이터 변환
- [ ] **상태 기반 렌더링**: 상태가 변경되면 UI가 자동 업데이트
- [ ] **파생 상태**: 기본 상태에서 계산된 값들을 자동 도출
- [ ] **조건부 표현식**: 삼항 연산자나 객체 매핑으로 조건 처리
- [ ] **선언적 이벤트 바인딩**: 컴포넌트에서 직접 이벤트 핸들러 연결
---
## 3. 관심사 분리 (MVC/MVVM 패턴 적용)
### 계층별 분리
- **Model (domains)**: 비즈니스 로직과 데이터 구조
- 순수 함수로 구현 (사이드 이펙트 없음)
- 프레임워크에 독립적
- 단위 테스트 가능
- **View (components)**: UI 표현 로직
- 데이터를 받아서 UI를 선언적으로 표현
- 이벤트 발생만 상위로 전달
- **Controller/ViewModel (apps/hooks)**: Model과 View 연결
- 이벤트 핸들링
- 상태 관리
- 렌더링 트리거
### 의존성 방향 확인
...
domains → hooks → components → main
...
단방향 의존성을 유지하여 순환 참조를 방지하세요.
---
## 4. React 특화 리팩토링 전략
### 상태 전파 최적화
**최소한의 setState 발생**을 위한 설계:
...javascript
// ❌ 모든 상태가 최상위 컴포넌트에 집중
function App() {
const [products, setProducts] = useState(/* ... */);
const [carts, setCarts] = useState(/* ... */);
// 어떤 상태가 변경되어도 App 전체가 리렌더링
}
// ✅ 관심사별 상태 분리 및 선택적 구독function ProductItem({ id }){
const product = useProduct(id); // 해당 상품만 구독const addToCart = useAddToCart(); // 안정적인 참조
}
...
### Hook을 통한 관심사 분리
**컴포넌트 내부 로직을 hook으로 추출**:
...javascript
// ❌ 컴포넌트에 로직이 집중function CartComponent(){
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const addItem = (item) => {
// 복잡한 비즈니스 로직setItems(/* ... */);
setTotal(/* ... */);
};
}
// ✅ hook으로 로직 분리function useCart(){
const [items, setItems] = useState([]);
const addItem = useCallback((item) => {
setItems(cartUtils.add(items, item)); // 도메인 로직 활용
}, [items]);
const total = useMemo(() =>
cartService.calculateTotal(items), [items]
);
return { items, total, addItem };
}
function CartComponent(){
const { items, total, addItem } = useCart();
return (/* UI만 집중 */);
}
...
### Hook과 도메인 로직 연결 패턴
...javascript
// domains/cartUtils.ts - 순수 함수exportconst cartUtils = {
add: (carts, item) => ({ ...carts, [item.id]: item }),
remove: (carts, id) => {
const { [id]: removed, ...rest } = carts;
return rest;
}
};
// hooks/useCart.ts - React 상태와 도메인 로직 연결exportconst useCart = () => {
const [carts, setCarts] = useState({});
const add = useCallback((item) => {
setCarts(current => cartUtils.add(current, item));
}, []);
const remove = useCallback((id) => {
setCarts(current => cartUtils.remove(current, id));
}, []);
return { carts: Object.values(carts), add, remove };
};
...
---
## 6. React 성능 최적화 체크리스트
### 🔍 성능 문제 진단
- [ ] 상태 변경 시 관련 없는 컴포넌트도 리렌더링되는가?
- [ ] `useMemo`/`useCallback`을 과도하게 사용하고 있는가?
- [ ] 큰 객체를 props로 전달하고 있는가?
- [ ] 리스트 렌더링 시 적절한 `key`를 사용하고 있는가?
### 상태관리 라이브러리 도입 검토 기준
다음 상황에서 상태관리 라이브러리 도입을 제안하세요:
- [ ] prop drilling이 3단계 이상 발생
- [ ] 형제 컴포넌트 간 상태 공유 필요
- [ ] 전역 상태 업데이트 시 불필요한 리렌더링 발생
- [ ] `useContext` 사용 시 성능 이슈 발생
- [ ] 복잡한 상태 로직으로 인한 `useReducer` 필요
### ✅ 최적화 방법
1. **구조적 해결 우선**: 메모이제이션보다 상태 분리 및 컴포넌트 분할
2. **선택적 구독**: 필요한 데이터만 구독하는 hook 설계
3. **참조 안정성**: 상태관리 라이브러리의 안정적인 액션 함수 활용
4. **지연 로딩**: 무거운 컴포넌트의 동적 임포트
---
## 7. 권장 폴더 구조
React 프로젝트에 최적화된 구조:
...
src/
├── domains/ # 비즈니스 로직 (React 독립적)
│ ├── types.ts
│ ├── services.ts
│ ├── utils.ts
├── hooks/ # 상태 관리 및 로직 연결
│ ├── useProducts.ts
│ ├── useCarts.ts
├── stores/ # 전역 상태 관리 (선택적)
│ └── useStore.ts
├── components/ # UI 컴포넌트 (순수 표현)
│ ├── ProductItem.tsx
│ ├── CartItem.tsx
└── App.tsx # 컴포넌트 조합
...
프론트엔드 교육과정이 생긴지 이제 1년이 조금 넘었고, 교육 콘텐츠를 만들면서 그리고 멘토링과 무수히 많은 피드백을 하면서 쌓아온 내용을 담았다. 사실 더 많은 이야기를 하고 싶었지만, 한 포스트에 모든 내용을 담는 게… 꽤 어렵다.
어쩌다보니 2기 때 커리큘럼에 직접적으로 관여하게 되었고, 다른 코치분들과 함께 내용을 많이 수정하여 지금의 모습이 되었다. 그리고 6기를 준비하는 지금, 조금 더 좋은 교육 콘텐츠를 만들기 위해 꽤 깊이 있는 고민을 하는 중이다.
(1) 커리큘럼
처음에는 “프론트엔드 개발” 이라는 주제로 시작한다. 프론트엔드 개발자가 어떤 이유 때문에 등장하게 되었는지, 어떤 문제들을 해결해왔는지에 대해 설명한다.
그 후에 바닐라 자바스크립트로 직접 코드를 작성하는 연습을 하게 되고, 이를 통해 라이브러리나 프레임워크가 해결하려고 하는 문제들을 직접 겪어본다. 단, 테스트 코드가 제공되고 테스트 코드를 통과해야 하는 방식이다.
그 다음에 직접 React 가 어떤 방식으로 구성되었는지 학습하고 만들어본다. 난이도가 꽤 있는 편이다.
그렇게 자바스크립트의 기초를 쌓아간 후에 클린코드 챕터로 진입한다. 변수, 함수, 폴더구조, 테스트 등 꽤 다양한 개념에 대해 점진적으로 학습한다. 이 때 중요한 것은 팀 활동이다. 사실 모든 과정에서 제일 중요한 부분은 팀원들과의 피드백이다. 우리가 작성한 코드가 정말 좋은 코드인지 팀 내에서 활발하게 논의를 해야 한다.
그 후에 마의 구간인 테스트 코드로 진입한다. 양도 많은 편이고 난이도는… 굉장히 높다. 그래서 앞선 두 챕터가 테스트 코드 기반으로 구성 되어있다. 미리 작성된 테스트 코드가 존재하고 이를 통과해야 한다. 즉, 테스트에 익숙해지는 기간이 필요했다. 그래야 테스트 챕터를 그나마 잘 진행할 수 있게 된다.
마지막으로 성능 최적화 챕터에서는 인프라와 코드 관점의 최적화에 대해서 다룬다.
1기, 2기 때는 50명 정도의 수강생이 있었고, 3기, 4기, 5기는 70~90명 정도의 수강생이 있었다.