훌륭한 개발자가 되고 싶다면 누구나 클린 코드와 아키텍처를 공부합니다. 그런데 정작 실력 있는 개발자란 무엇일까요? 한마디로 정의하자면, 빨리 만들고, 프로덕트가 커져도 그 속도를 유지하는 사람입니다.
초보 개발자와 고수 개발자가 빈 프로젝트에서 첫 기능을 구현하는 속도는 비슷할 수 있습니다. 하지만 프로젝트가 커질수록 격차가 벌어집니다. 잘 짠 코드는 10번째 기능도 첫 번째만큼 빠르게 추가할 수 있지만, 못 짠 코드는 갈수록 느려집니다.
실무에서 이런 경험들, 한 번쯤 해보셨나요?
1. 재사용 가능하게 공통화했습니다.
포스팅 카드와 제품 카드가 똑같이 썸네일, 제목, 설명, 댓글 수, 좋아요 수를 가지고 있길래 CardView
컴포넌트를 만들었죠. 그런데 시간이 지나자 포스팅에는 작성자 정보가 필요했고, 제품에는 장바구니 버튼이 추가됐습니다. 묘하게 달라지는 요구사항들을 처리하다 보니 결국 분기문 투성이가 되었습니다. 차라리 따로 만드는 게 나았을까요?
2. 결합을 피하려고 따로 만들었더니
팔로워 수를 표시할 때를 생각해봅시다. 서버에서는 1234567
같은 숫자로 오지만, 화면에는 1.2M
으로 보여줘야 합니다. 데이터와 표시는 다른 관심사니까 UI 컴포넌트에서 포맷팅하도록 했죠.
작성일자도 마찬가지입니다. 서버에서는 2024-01-15
형식이지만, 화면에는 3일 전
같은 상대 시간으로 보여줘야 합니다. 이것도 UI에서 처리했습니다.
그런데 결국 모든 UI 컴포넌트에서 똑같은 포맷팅 로직을 반복하고 있었습니다. 급하게 utils
로 빼봤지만, 여전히 쓰는 곳마다 import해서 호출해야 했죠. 애초에 서버에서 데이터를 받아오는 부분에서 이런 전처리를 결합해서 제공했다면, UI는 그냥 표시만 하면 됐을 텐데요.
3. 함수를 작게 쪼개면 유연해질 거라 생각했습니다.
큰 함수 하나로 되어 있으면 일부만 수정이 필요해도 전체를 다시 작성해야 하니까요. A, B, C, D로 나누면 C만 C'로 바꿔서 다시 조합하면 되겠죠?
예를 들어 블로그 포스팅에 좋아요를 누르면, (1) 해당 포스팅의 좋아요 수를 증가시키고, (2) 내가 좋아요를 체크했다는 표시를 하고, (3) 리스트에도 반영하기 위해 캐시를 해제하거나 옵티미스틱 업데이트를 해야 합니다. 각각을 따로 정의해두면 필요할 때 하나씩만 호출할 수도 있을 테니 유연하겠죠?
그런데 실제로는 A, B, C, D 중 어느 것 하나 따로 사용하거나 교체한 적이 없었습니다. 항상 세트로 움직였고, 호출하는 곳에서는 네 가지를 매번 순서대로 호출해야 했습니다. 하나라도 누락되면 정보 갱신이 덜 되는 버그가 생겼죠. 그제야 깨달았습니다. 이건 유연함이 아니라, 함께 일어나야 하는 하나의 트랜잭션이었던 거죠.
무조건 나누는 것도 아니고, 무조건 반복하는 것도 아니고, 무조건 공통화하는 것도 아니라면, 대체 어떻게 해야 하는 건가요? 미래를 예측할 수 있는 무당이어야만 좋은 코드를 짤 수 있는 걸까요?
그런데 우리는 왜 이런 고민을 해야 할까요? 어디를 재사용하고, 어디를 따로 구분해야 할지 고민하는 이유는 무엇일까요?
역설적이게도, 한번 정해놓고 절대 바꾸지 않는다면 사실 어떻게 짜든 크게 문제가 없습니다. 반복하든, 재사용하든, 분기문을 난사하든 말이죠.
하지만 수정이 일어나면 이야기가 달라집니다:
그러다 수정이 일어나면 어딜 고쳐야 할지, 어딜 빼먹으면 안 되는지, 어디까지 영향을 받는지 찾아야 하죠. 이게 문제입니다.
우리는 괴롭힘을 받고 있습니다. 수정이라는 괴롭힘이죠.
그렇다면 왜 우리는 수정을 해야 할까요? 처음부터 제대로 만들면 되는 거 아닌가요?
일단 우리는 자의로 만들지 않습니다. 소프트웨어는 돈 받고 만듭니다. 그리고 돈 주는 사람이 말을 바꿉니다.
비즈니스 요구사항이 있고, 엔지니어는 이를 코드로 반영합니다. 엔지니어는 타의로 움직입니다. 시키는 사람의 마음이 변하면, 우리가 짠 코드도 바뀌어야 합니다.
시키는 대로 짰는데, 말을 번복하고 수정을 다시 요청받습니다. 그래서 이 사람이 자꾸 말을 번복하니까 내가 괴로운 거죠.
진짜 범인은 한번 말한 걸 번복하는 사장님이죠.
처음에는 사장님이 답답하게 느껴질 수 있습니다. "처음부터 확 정해주면 되는 거 아냐?"
만약 사장님이 모든 변화를 예측해서 처음부터 완벽한 제품 방향을 정해줬다면? 애초에 그런 사람이랑 일할 기회도 없었겠죠. 이미 그 회사는 유니콘이 되어있을 테니까요.
그리고 다 한번만에 정해지면 애초에 개발자 실력 차이가 안 납니다. 이 변경에 얼마만큼 빠르게 대처하느냐가 변별력입니다.
만약 모두가 한 번 정한 것을 절대 안 바꾼다면, 사실 어떻게 개발하든 문제가 없습니다. 반대로 "한 번 정한 것은 수정 못 하겠다"며 낙장불입을 외치는 개발자만큼 꼴불견도 없습니다.
변경은 우리의 적이 아닙니다. 변경은 우리가 더 나은 개발자가 될 수 있는 기회입니다. 그 변경을 어떻게 다루느냐가 실력의 차이를 만듭니다.
소프트웨어의 본질을 생각해봅시다. 소프트웨어의 모습은 엔지니어가 결정하지 않습니다. 언제나 시장이 변하고, 사용자 피드백이 쌓이고, 경쟁사가 새로운 기능을 출시합니다. 개발자가 아니라 주변 비즈니스가 결정합니다.
소프트웨어란 개발자가 아니라 비즈니스에 의해 만들어진다는 사실을 받아들이면, 코드를 보는 시선도 달라집니다.
비즈니스 요구사항이 바뀌는 범위대로 코드를 구분해두면, 적어도 바뀌는 범위를 한정할 수 있습니다. 이것이 바로 도메인 중심 설계가 등장한 이유입니다.
예전에는 기술 중심, 레이어 중심으로 코드를 나눠왔습니다. ui/
, hooks/
, api/
, utils/
같은 구조로요.
이제는 사장님의 생각대로, 기획자가 말하는 방식대로 코드를 나누기 시작합니다. user/
, product/
, payment/
처럼요.
기획자들은 어떻게 말할까요? 이들은 React hook이나 API가 무엇인지, UI 컴포넌트가 무엇인지 알까요?
아니요. 그들은 이렇게 말합니다:
개발자들은 이런 요청을 듣고 ui, 상태, api 파일들을 뒤집니다.
여기서 흥미로운 패턴을 발견할 수 있습니다. 이런 수정 범위에는 경계가 있습니다.
유저에 대해 이야기할 때는 장바구니는 상관없습니다. 장바구니 이야기를 할 때는 유저 프로필은 무관합니다. 팔로워 수 표시 방식을 바꾸면 팔로워 수가 보이는 모든 곳이 영향을 받지만, 장바구니 가격과는 아무 관계가 없죠.
도메인 단위로 수정사항이 전파되고, 도메인 단위로 내용이 달라집니다.
그리고 이미 당신은 도메인 중심으로 코드를 짜고 있을지도 모릅니다. slice란 글자를 봤다면 말이죠.
도메인 중심 설계라고 하면 뭔가 거창하고 새로운 개념처럼 들릴 수 있습니다. 백엔드 개발자들이나 쓰는 DDD(Domain-Driven Design) 같은 거 아닌가요?
하지만 프론트엔드에서도 이미 도메인 중심 사고는 널리 퍼져있습니다. 어쩌면 당신도 이미 사용하고 있을지 모릅니다.
slice라는 단어를 본 적이 있나요?
Redux Toolkit을 사용해보셨다면 createSlice
를 써보셨을 겁니다. Redux에서 slice는 특정 기능이나 도메인에 관련된 상태와 리듀서를 하나로 묶은 단위입니다.
// userSlice.js
import { createSlice } from '@reduxjs/toolkit';
const userSlice = createSlice({
name: 'user',
initialState: { profile: null, isLoggedIn: false },
reducers: {
login: (state, action) => { /* ... */ },
logout: (state) => { /* ... */ },
updateProfile: (state, action) => { /* ... */ }
}
});
예전 Redux 방식을 기억하시나요? 액션 타입 상수를 정의하고, 액션 크리에이터를 만들고, 리듀서를 따로 작성했습니다. 이런 파일들이 actions/
, reducers/
, constants/
디렉토리에 흩어져 있었죠.
src/
actions/
userActions.js
productActions.js
cartActions.js
reducers/
userReducer.js
productReducer.js
cartReducer.js
constants/
actionTypes.js
유저 프로필 업데이트 기능을 수정하려면? userActions.js
, userReducer.js
, actionTypes.js
세 파일을 왔다갔다 해야 했습니다.
하지만 slice를 사용하면 이야기가 달라집니다:
src/
store/
slices/
userSlice.js // 유저 관련 모든 것
productSlice.js // 제품 관련 모든 것
cartSlice.js // 장바구니 관련 모든 것
유저 프로필을 수정해야 한다면? userSlice.js
하나만 열면 됩니다. 상태 구조, 액션, 리듀서 로직이 모두 한 곳에 있으니까요.
"유저"라는 도메인과 관련된 것들을 한 곳에 모아둔 것입니다.
Redux 공식 문서를 보면 slice의 이름들이 어떻게 지어지는지 주목해보세요. users
, posts
, comments
, todos
- 모두 비즈니스 도메인입니다. ui
, hooks
, utils
같은 기술적 분류가 아닙니다.
Feature-Sliced Design(FSD)를 들어보셨나요? 최근 프론트엔드 커뮤니티에서 주목받고 있는 아키텍처 방법론입니다.
FSD의 핵심 개념 중 하나도 바로 slice입니다. FSD에서는 각 레이어(layer) 아래에 slice를 두어 도메인별로 코드를 구분합니다.
src/
entities/ ## 레이어
user/ ## slice (도메인)
ui/
UserCard.tsx
UserAvatar.tsx
model/
userStore.ts
types.ts
api/
userApi.ts
index.ts
product/ ## slice (도메인)
ui/
ProductCard.tsx
ProductGrid.tsx
model/
productStore.ts
api/
productApi.ts
index.ts
cart/ ## slice (도메인)
ui/
CartItem.tsx
model/
cartStore.ts
api/
cartApi.ts
index.ts
FSD에서 slice는 하나의 비즈니스 엔티티나 기능을 나타냅니다. 각 slice 안에는 그 도메인에 필요한 UI 컴포넌트(ui/
), 비즈니스 로직과 상태(model/
), API 호출(api/
) 등이 모여있습니다.
제품(product) 관련 수정사항이 생기면? entities/product/
폴더만 열면 됩니다. UI도, 상태 관리도, API 호출 로직도 모두 거기 있으니까요.
여기서도 slice는 도메인 단위입니다. user
, product
, cart
- 사장님이 말하는 방식 그대로죠.
Redux의 slice와 FSD의 slice. 둘 다 같은 단어를 쓰는 건 우연이 아닙니다. 이들은 모두 수직 슬라이스 아키텍처(Vertical Slice Architecture)라는 개념에서 영감을 받았습니다.
전통적인 아키텍처는 수평(horizontal)으로 나눕니다:
src/
components/ ## UI 레이어
hooks/ ## 로직 레이어
services/ ## API 레이어
utils/ ## 유틸리티 레이어
이런 구조에서 새로운 기능을 추가하거나 수정하려면 어떻게 될까요? 여러 레이어를 오가며 파일을 수정해야 합니다.
"팔로워 수 표시 방식 변경" 요청이 왔다고 해봅시다:
1. components/
에서 팔로워 수를 표시하는 모든 컴포넌트 찾기
2. hooks/
에서 팔로워 데이터를 가져오는 훅 수정
3. utils/
에서 숫자 포맷팅 로직 수정
4. services/
에서 API 호출 확인
수직 슬라이스는 다르게 접근합니다. 수정이 일어나는 방향대로 코드를 자릅니다:
src/
user/ ## 수직 슬라이스
components/
hooks/
api/
utils/
product/ ## 수직 슬라이스
components/
hooks/
api/
utils/
"팔로워 수 표시 방식 변경" 요청이 오면? user/
폴더만 열면 됩니다. UI 컴포넌트도, 데이터 훅도, 포맷팅 유틸도, API 호출도 모두 거기 있으니까요.
핵심은 "수정할 때 여러 계층을 넘나들지 않는 것"입니다.
서론에서 이야기했던 문제들을 기억하시나요?
수직 슬라이스는 이 범위를 명확하게 만듭니다. 유저 관련 수정이면 user/
슬라이스를, 제품 관련 수정이면 product/
슬라이스를 보면 됩니다.
slice를 직역하면 "조각"입니다. 하지만 여기서는 "얇게 자른 한 조각"이라는 의미가 더 중요합니다.
피자를 떠올려보세요. 피자를 수평으로 자르면 도우 층, 토핑 층, 치즈 층으로 나뉩니다(레이어 아키텍처). 하지만 피자를 먹을 때는 수직으로 자릅니다. 한 조각에 도우부터 치즈까지 모든 층이 다 들어있죠(수직 슬라이스).
소프트웨어도 마찬가지입니다. 하나의 slice에는 UI부터 데이터 레이어까지 필요한 모든 것이 들어있습니다. 그리고 그 slice는 하나의 비즈니스 도메인을 나타냅니다.
Redux의 userSlice
, FSD의 entities/user/
, 수직 슬라이스의 user/
- 모두 같은 이야기를 하고 있습니다.
도메인 단위로 코드를 묶어두면, 변경도 도메인 단위로 일어납니다.
당신이 Redux Toolkit을 쓰고 있다면, 당신은 이미 도메인 중심으로 상태를 관리하고 있는 겁니다. FSD를 적용하고 있다면, 당신은 이미 도메인 중심으로 전체 코드베이스를 구조화하고 있는 거죠.
정답은 없습니다. 하지만 저는 이렇게 짜고 있습니다.
저는 스타트업에서 제품을 만드는 사람 기준으로 이야기하겠습니다. 당연히 이것이 정답은 아니고, 재사용의 기준도 프로젝트마다 다를 수 있습니다. 다만 제 경험을 공유하면서, 여러분이 각자의 맥락에서 적용할 수 있는 인사이트를 드리고자 합니다.
FSD에서도 그렇지만, 도메인별로 나눈다 하더라도 레이어를 따로 두기 마련입니다.
FSD에서는 pages, app, features, entities, shared 같은 레이어를 둡니다. 레이어는 필요에 의해 기업마다, 프로젝트마다 다를 수 있습니다.
저는 FSD에는 없지만 api/
를 레이어로 따로 모아두고, 거기서 도메인별로 분리합니다. 반대로 FSD의 entities와 features는 state/
로 합쳤습니다. 이 둘이 불필요하게 도메인을 분할한다고 보기 때문입니다.
그 밖에 Storybook을 쓴다면 stories/
, 테스트 코드는 test/
로 나눠서 관리합니다.
src/
pages/ ## 페이지별 UI 구성
state/ ## 도메인 상태 관리
api/ ## API 호출 및 데이터 변환
stories/ ## Storybook
test/ ## 테스트
각 레이어를 하나씩 살펴보겠습니다.
Pages에서는 페이지별로, 즉 라우트별로 분리합니다. 하나의 페이지가 있으면:
pages/
home/
index.tsx
hero-section.tsx
feature-section.tsx
이런 식으로 구획별로 쪼갠 다음에 index.tsx
에서 모아서 구성합니다.
이때 컴포넌트 분리는 재사용성이라기보단 구획을 나눈다는 느낌으로 합니다.
서론에서 CardView 예시를 기억하시나요? 포스팅 카드와 제품 카드가 비슷해 보여서 공통화했다가 결국 분기문 투성이가 됐던 이야기 말입니다.
저는 도메인과 관련된 UI는 페이지별로 격리해서 놓습니다. 겉보기엔 비슷한 UI라도 시간이 지나면 아예 달라지는 경우가 많기 때문입니다.
같은 정보를 보여주고 같은 동작을 한다 하더라도, 보여주는 페이지가 강조하고 싶은 정보가 다르기 때문에 UI는 달라지기 마련입니다.
도메인과 관련된 UI는 재사용이 잘 안 됩니다. 단순한 캐러셀이나 토글 버튼 같은 공통 컴포넌트 말고, 실제 비즈니스 도메인과 결합된 UI 말입니다.
저의 전략은 명확합니다: "상태와 로직은 재사용하되, UI는 반복한다."
저는 pages에서는 단일 책임 원칙 같은 것은 없다고 생각합니다. 어떤 도메인이든 참조될 수 있어야 합니다.
왜냐하면 기획을 하다 보면 "이 정보를 이 페이지에서도 필요하다고?" 하는 경우들이 정말 많기 때문입니다.
예를 들어:
그래서 도메인의 상태와 기능들은 pages 어디서든 재사용할 수 있도록 합니다.
도메인과 관련된 상태는 항상 글로벌 최상단으로 둡니다.
state/
user.ts
post.ts
cart.ts
각 파일은 Redux의 slice나 Zustand store를 떠올리시면 됩니다. 상태와 상태를 바라보는 값(computed values)을 한 번에 정의합니다.
Post 같은 경우는 list로 불러온 것과 현재 보고 있는 Post 객체를 모두 다룹니다.
// state/post.ts
const usePost = create({
state: {
posts: [], // 리스트
currentPost: null // 현재 보고 있는 포스트
},
actions: (state) => ({
likePost: () => {
// currentPost 좋아요 증가
state.currentPost.likeCount += 1;
state.currentPost.isLiked = true;
// posts 배열에서도 동기화
const post = state.posts.find(p => p.id === state.currentPost.id);
if (post) {
post.likeCount += 1;
post.isLiked = true;
}
},
addComment: (content) => {
// postId를 인자로 받지 않음!
// 내부에서 currentPost 참조
const postId = state.currentPost.id;
// 댓글 추가
const comment = { postId, content, createdAt: new Date() };
state.currentPost.comments.push(comment);
// posts 배열의 댓글 수도 동기화
const post = state.posts.find(p => p.id === postId);
if (post) {
post.commentCount += 1;
}
}
})
});
// 사용
const { posts, currentPost } = usePost();
이렇게 했을 때 현재 보고 있는 post에 좋아요나 댓글 같은 동작을 수행할 때 list 뷰도 자동으로 동기화됩니다.
서론에서 이야기했던 문제를 기억하시나요? 좋아요를 누르면 여러 함수를 차례차례 호출해야 했고, 하나라도 빼먹으면 정보 갱신이 덜 되는 버그가 생겼던 것 말입니다.
저는 하나의 action에 이런 파생 상태 갱신을 모두 담습니다. UI에서 직접 차례차례 호출하기보다는, 하나의 동작 안에 상태의 갱신을 모두 포함시키는 겁니다.
저는 action을 순수함수로 두지 않습니다. 상태지향적인 함수로 둡니다.
// ❌ 순수함수 스타일 - UI에서 상태를 꺼내서 전달
function addComment(postId, content) {
// ...
}
// UI에서
const { currentPost } = usePost();
addComment(currentPost.id, content); // postId를 매번 전달
// ✅ 상태지향적 스타일 - 내부에서 상태 참조
function addComment(content) {
const postId = state.currentPost.id; // 내부 참조
// ...
}
// UI에서
addComment(content); // 간단!
현재 보고 있는 currentPost
가 있다면, 댓글 쓰기에서 인자로 postId
를 UI에서 받을 필요가 있을까요?
action이 내부적으로 상태를 참조하게 하면, 동작이 수정될 때 UI 호출부는 꿈쩍도 하지 않습니다.
currentPost
가 없는데 호출하면? 에러를 내거나 무시하게 합니다. 왜냐하면 어차피 순수함수로 두면 UI에서 지속해서 상태를 꺼내서 action 인자로 넣어야 하기 때문입니다.
저는 Zustand를 쓰고 있는데, state/
에서는 Zustand를 직접 참조하지 않습니다. utils/state
를 따로 만들어 거기서 만든 함수로 상태를 정의합니다.
// utils/state - 추상화 레이어
export function createState(config) {
// 내부적으로 Zustand 사용
// 하지만 나중에 Redux로 바꿀 수도 있음
}
// state/post.ts
export const usePost = createState({
state: { /* ... */ },
actions: { /* ... */ }
});
이렇게 하면 구현체를 Zustand에서 Redux로 바꿔도 사용하는 곳은 상관없습니다.
더 나아가, 상태 관리 방식 자체를 바꿀 수도 있습니다. Zustand나 Redux 같은 전역 상태 관리 대신 React Query로 서버 상태를 관리하더라도, 인터페이스는 여전히 같습니다:
// UI 컴포넌트에서
const { posts, currentPost, addComment } = usePost();
// 내부 구현이 Zustand든, Redux든, React Query든
// 사용하는 쪽은 동일한 인터페이스
UI 컴포넌트에서는 특정 상태 라이브러리를 직접적으로 호출하지 않습니다. 항상 추상화된 훅을 통해 접근합니다.
API에서는 UI에 보여야 할 정보를 미리 전처리합니다.
서론에서 이야기했던 팔로워 수나 "며칠 전" 같은 처리들 말입니다:
1234567
→ 1.2M
2024-01-15
→ 3일 전
서버의 리소스를 프론트엔드 도메인에 맞게 가공하는 겁니다.
예를 들어 포스트의 좋아요 중에서 내가 누른 게 있는지 표시해야 한다면 (하트가 빨간색으로 나와야 하니까), UI나 State에서 여러 정보를 결합해서 계산할 수도 있습니다.
하지만 이런 계산이 계속 반복되면 어떻게 될까요? 모든 곳에서 같은 로직을 작성하게 됩니다.
저는 API 레이어에서 이미 계산해서 전달합니다:
// api/post.ts
async function getPost(id) {
const [post, myLikes] = await Promise.all([
fetchPost(id),
fetchMyLikes()
]);
return {
...post,
isLikedByMe: myLikes.includes(post.id), // 이미 계산됨
followerCountDisplay: formatNumber(post.followerCount), // "1.2M"
createdAtDisplay: formatRelativeTime(post.createdAt) // "3일 전"
};
}
UI에서는 그냥 post.isLikedByMe
를 쓰기만 하면 됩니다.
서버의 리소스가 여러 엔드포인트로 나눠져 있다면, API 레이어에서 이것들을 모아서 병합해줍니다.
이것은 BFF(Backend For Frontend) 패턴과 비슷합니다. 프론트엔드가 필요한 형태로 데이터를 재구성하는 거죠.
UI는 그냥 받아서 표시만 하면 됩니다.
컴포넌트에서 글로벌 도메인 상태가 필요하면 props로 받지 않고 usePost()
같은 훅으로 직접 접근합니다.
// ❌ props로 전달받기
function PostDetail({ currentPost, posts }) {
// ...
}
// ✅ 직접 접근
function PostDetail() {
const { currentPost, posts } = usePost();
// ...
}
왜냐하면 도메인은 pages 내에서 언제 어디서 어떻게 호출될지가 기획할 때마다 계속 바뀌기 때문입니다.
카드 같은 컴포넌트의 경우, 필요한 요소(title, thumbnail, description)를 각각 받는 것보다:
// ❌ 필요한 것만
<PostCard
title={post.title}
thumbnail={post.thumbnail}
description={post.description}
/>
// ✅ 도메인 객체 통째로
<PostCard post={post} />
도메인 객체를 통째로 받습니다.
왜냐하면 도메인 객체 내부적으로는 필드가 추가되거나 수정될 확률이 높기 때문입니다. 필요한 것만 넘긴다는 게 오히려 수정의 전파로 이어집니다.
예를 들어 도메인의 어떤 정보를 추가로 보여줘야 한다고 하면, PostCard를 호출하는 쪽도 코드가 수정되어야 합니다. 하지만 통째로 넘기면 이 컴포넌트만 수정하면 됩니다.
"함수가 필요한 최소 정보만 전해준다"는 원칙을 여기서는 쓰지 않습니다. 도메인에 관련해서는 가변성이 크다고 판단하니까요.
pages → state → api
이런 단방향 구조로 계층을 놓습니다.
요지는 명확합니다. 도메인 관점으로 레이어와 코드 담당 범위를 나눕니다.
각 레이어는 도메인별로 구분되어 있고, 변경이 일어나면 그 도메인의 레이어들만 수정하면 됩니다. 여러 계층을 넘나들며 수정할 필요가 없습니다.
이것이 제가 실전에서 도메인 중심 사고를 적용하는 방식입니다.
사실 지금 이런 코드 패턴을 다시 정리하는 이유가 있습니다. 바로 AI에게 컨텍스트로 제공하기 위해서입니다.
사용자(개발자)가 AI에게 요청하고 수정하는 것도 결국 도메인 단위입니다. "유저 프로필에 팔로워 수 추가해줘", "장바구니에서 할인 금액 보여줘" - 이런 식으로 말이죠.
그렇다면 AI 프롬프트도 도메인에 최적화되어 있으면, 바이브 코딩할 때 코드가 꼬이고 복잡해져서 만들었던 거 또 만들거나 수정이 누락되는 일을 막을 수 있지 않을까요?
프로덕트에 코드가 차곡차곡 정리되어서 쌓여있을 수 있지 않을까요?
저는 이런 내용들을 모두 프로젝트 루트의 CLAUDE.md
에 넣고, 각 레이어별로도 AI.md
를 넣어서 해당 레이어의 코드를 수정할 때는 AI가 꼭 지침을 읽도록 하고 있습니다.
바이브 코딩의 문제가 뭔가요? 처음엔 잘 되다가 프로젝트가 커지면 코드를 수정하지 못하는 겁니다.
그런데 생각해보면, 이것은 사람도 마찬가지입니다. 그래서 우리가 도메인 중심 설계를 이야기하고 있는 거죠.
명확한 지침으로 최대한 안 무너지게, 일관되게 코드 맥락을 정해주면 프로젝트가 커져도 괜찮습니다. 사람도, AI도요.
저희도 이런 노하우를 반영해서 노코드 툴을 만들고 있습니다. (내부적으로는 사설 Git을 호스팅해서 코드를 직접 받을 수도 있고, 푸시하면 바로 사이트에 연동까지 됩니다)
LLM엔진은 Claude Code로 돌리고 있어요.
더 자세한 내용이 궁금하시다면: MVP스타
응원해주세요!
글 잘읽었습니다. 클린 코드를 지향하며 이곳 저곳에서 보고 배운것을 실제 코드에 적용하려고 하면 이게 맞나..? 싶을 때가 많은 거 같습니다. 이번 글에서는 인터넷에 많이 퍼져있는 대표적인 개념들을 부정하는 부분도 많았던 거 같습니다. 개념 자체를 부정한다기 보다는 "이러한 상황에서는 해당 개념은 사용하지 않는다." 가 더 명확한 표현 같지만 실제 프로젝트 코드를 보면서 공부해보고 싶네요.
좋은 글 감사합니다!
글 중간에 도메인 객체를 통체로 넘긴다와 usePost 처럼 커스텀 훅을 사용하는 것에 다른 의견이 있어 댓글을 남깁니다.
props는 컴포넌트가 의존 하는 것을 추상화 하는 것이라고 생각을 합니다. 그런대 같은 도메인이라고 도메인 객체를 통체로 넘긴다면 이 컴포넌트는 무엇을 의존 하는지 코드로는 알 수가 없게 됩니다. 커스텀 훅도 같은 의견으로 이런 데이터는 props로 넘겨서 무엇을 의존 하는지 모르게 됩니다.
이런 의존 하는 것 도메인 타입이나 인터페이스 같은 추상적인 것에 의존을 하면 나중에 타입을 수정 하는 것만으로도 컴파일으로도 수정 할 곳이 눈에 바로 보이고 안전하게 유지보수 있다고 생각 합니다.
폴더 구조 및 ui 에서 상태관리를 다 하고 있어서 고민이였는데 좋은글 잘읽었습니다.
궁금한게 있는데 ui,model,api 이렇게 3개 폴더에서 관리하고 있는데 함수관리는 어디에 하시는건가요? 예를들어 이동 함수라던가 다른 여러 함수들은 어디서 관리하는지 궁금하네요