FrontEnd 개발이 극단적으로 쉬워진 구조 개편 기록
본문에 있는 구조 및 코드는 모두 예시입니다.
필드에서 마주한 첫 프로젝트 초반, 우리도 수많은 스타트업들이 겪는 상황을 똑같이 겪고 있었다.
즉, 모든 게 동시에 움직이는 상태였다.
"디자인 | 서버 스펙 다 나올 때까지 기다렸다가 개발 시작"은 현실적으로 불가능했다.
그래서 나는 당시 선택할 수 있는 가장 현실적인 전략을 택했다.
사실 내가 경험이 없어서 저게 최선이었는지는 모르겠다.
"일단 있는 UI부터 만들고, 스펙이 나오는 대로 맞춰나가자."
초기에는 이 방식이 꽤 효율적이었다.
프로토타입을 빠르게 뽑을 수 있었고, 팀 전체가 "서비스가 어떤 흐름으로 동작하는지"를 눈으로 확인할 수 있었다.
문제는 "나중에 맞추자"의 그 나중이 왔을 때 터졌다.
이미 예상은 한 지점이지만, 생각보다 스노우볼이 더 크게 다가왔다.

서버에서 내려주는 실제 포맷은 내가 혼자 가정해둔 타입들과 많이 달랐다.
userName vs user_name)솔직히 말하면 맞는 게 더 적은 상태였다.
기획도 바뀌고, 디자인도 디벨롭 중이고, 백엔드도 바쁘게 움직이는 상황에서
내가 먼저 달릴 수밖에 없었던 것의 자연스러운 결과였다.
예상은 한 부분이나, 생각보다 문제가 커져있었다.
그러나 진짜 문제는 따로 있었다.
컴포넌트, 훅, 유틸, 페이지…
같은 개념의 타입이 파일마다 다 다른 형태로 정의되어 있었다.
스펙 하나 바뀌면 프로젝트 전체를 grep 돌려야 했다.
이 구조는 지속 가능한 구조가 아니었으며, 개발자로서 이를 유지할 수는 없다는 생각이 들었다. (나름의 곤조였다)
'일단 돌아가면 건드리지 말 것.' 이라는 말은 내 스스로 도저히 용납할 수 없었다.
그러던 중,
기획과 디자인을 전면적으로 다시 잡아야 하는 시점이 왔고,
기존 화면 위에 계속 덧칠하는 건 더 이상 효율적이지 않았다.
그래서 디자인 및 기획을 새로 다듬는 시간을 갖기로 했다.
이 기간 동안 프론트 작업은 홀딩하였다.
나는 이게 아키텍처를 재설계할 유일한 기회라는 걸 직감했던 것 같다.
조건이 맞아떨어졌다:
이 2주 동안 나는:
이 기간이 없었다면, 지금의 구조는 절대 탄생하지 못했을 것이다.
새로운 구조를 설계하기 전에 몇 가지 아키텍처 패턴을 참고했다.
하지만 어디까지나 프론트엔드 현실에 맞게 절충해서 적용했다.
핵심은 세 패턴의 장점을 조합하되,
프론트엔드 환경에 맞게 단순화한 것이다.
위의 개념에 대해서는 추후 포스팅으로 다시 작성하도록 하겠다.
"발명은 아니다. 하지만 조합 자체는 충분히 독창적이다."
- Claude의 답변
나는 전형적인 INTJ인간이다. 따라서 나는 구조화에 진심인 사람이기에, 우리만의 구조를 만들기 시작했다.
아키텍처는 완전히 무(無)에서 탄생하지 않는다.
모든 패턴은 기존 개념의 조합이다.
중요한 것은 "무엇을 어떤 맥락에서 어떤 방식으로 조합했는가"이다.
내가 만든 구조는 Vertical Slice/DDD/FSD에서 영감을 받았지만,
그대로 가져온 것은 분명 아니다.
| 관점 | FSD | 내 구조 |
|---|---|---|
| DTO/Entity 분리 | 강조하지 않음 | 핵심 원칙 |
| Repository 역할 | 애매함 | Repository = ACL 명확 |
| 레이어 개수 | 6~7개 | 2개(domains, pages)로 단순화 |
| 경계 기준 | Feature 중심 | Business Domain 중심 |
특히 DTO → Entity 변환을 Repository에서만 일관되게 수행한다는 구조는 FSD에도 없는 명시적인 설계라고 생각한다.
이 구조의 가장 큰 목표는:
서버 스펙 및 디자인 변경에 프론트가 흔들리지 않게 하는 것
그로 인해 지속 가능한 프로덕트로 전환하는 것
이다.
솔직히 말하면, 이 구조를 만들 수 있었던, 그리고 자신 있었던 결정적인 이유가 있다.
내가 이 서비스의 기획자이기도 했기 때문이다.
도메인 경계를 어떻게 정의할지,
어떤 기능이 어떻게 확장될지,
핵심 기능이 무엇이고 주변 기능이 무엇인지…
이건 단순한 기술적 판단이 아니며,
비즈니스 맥락을 아는 사람만 할 수 있는 설계라고 생각한다.
예를 들어:
auth와 user를 왜 분리했는가?post와 comment는 왜 별도의 도메인인가?artist의 경계는 어디까지인가?이건 기획자/PM이 "앞으로 어떤 기능이 어떻게 변할지"를 알고 있어야
제대로 경계를 나눌 수 있다.
나는 이 서비스의 PO였고, 전체 서비스의 흐름과 미래 변경 가능성을 가장 깊게 알고 있었기 때문에, 도메인 구조를 기술적 + 비즈니스적 기준으로 정확하게 설계할 수 있었다고 생가한다.
이건 내 역할이 개발자만이었다면 절대 만들 수 없었던 구조다.
개인적으로 자기소개 문구로 항상 쓰는 기술과 비즈니스의 접점을 잇는 개발자라는 지점이 빛을 발한 순간이었다.
사실 요즘들어 너무 기획 | PM 역량만 강조되는건 아닐까? 하는 우려가 있었다.
그런데, 개발적으로도 이렇게 빛을 발하게 되어 매우 기쁘다.
src/
├── shared/ # 공통 (도메인 무관)
│ ├── components/
│ │ ├── atomics/ # Button, Avatar, Badge...
│ │ └── commons/ # InputText, BottomSheet...
│ ├── hooks/
│ ├── styles/
│ └── utils/
│
├── domains/ # 도메인별 분리
│ ├── auth/
│ ├── profile/
│ ├── post/
│ ├── messaging/
│ ├── notification/
│ ├── search/
│ └── home/
│
│ # 각 도메인 내부 구조:
│ └── [domain]/
│ ├── entities/ # 순수 도메인 모델
│ ├── repositories/ # 데이터 접근 + DTO→Entity 변환 (ACL)
│ ├── dtos/ # API 요청/응답 객체
│ ├── components/ # UI 컴포넌트
│ ├── hooks/ # React 훅
│ └── mocks/ # 모의 데이터
│
└── app/ # 페이지 (라우팅)
이 구조의 핵심은 DTO와 Entity를 명확히 분리하는 것이다.
| 구분 | DTO | Entity |
|---|---|---|
| 역할 | 서버 통신용 객체 | 프론트 내부 도메인 모델 |
| 형태 | 서버 스펙 그대로 | 프론트에서 쓰기 편한 형태 |
| 변경 주체 | 서버 스펙 변경 시 | 프론트 요구사항 변경 시 |
| 위치 | dtos/ | entities/ |
// dtos/UserDto.ts - 서버에서 내려주는 형태
interface UserResponse {
user_id: string;
user_name: string;
profile_image_url: string | null;
follower_count: number;
}
// entities/User.ts - 프론트에서 쓰는 형태
interface User {
id: string;
name: string;
profileImage: string; // null 처리 완료
followerCount: number;
}
ACL의
도메인을 외부 변화로부터 보호한다는 개념을 부분적으로 차용하였습니다.
다만 여러 외부 소스를 통합하는 ACL과 달리, 현재 구조는 단일 소스 기반이기에 Repository는 어디까지나 API 스펙 변화의 ‘진동’을 도메인 내부로 전달하지 않는 완충 지대의 역할에 집중합니다.
Repository는 DTO → Entity 변환이 일어나는 유일한 장소다.
// repositories/userRepository.ts
import type { UserResponse } from '../dtos/UserDto';
import type { User } from '../entities/User';
const DEFAULT_PROFILE_IMAGE = '/images/default-avatar.png';
export const userRepository = {
toEntity(dto: UserResponse): User {
return {
id: dto.user_id,
name: dto.user_name,
profileImage: dto.profile_image_url ?? DEFAULT_PROFILE_IMAGE,
followerCount: dto.follower_count,
};
},
async getUser(userId: string): Promise<User> {
const response = await api.get<UserResponse>(`/users/${userId}`);
return this.toEntity(response.data);
},
};
이렇게 하면:
toEntity만 수정하면 된다
src/
├── components/
│ └── UserProfile.tsx # 여기서 User 타입 정의
├── hooks/
│ └── useUser.ts # 여기서도 User 타입 정의 (다른 형태)
├── pages/
│ └── profile.tsx # 여기서도 또 정의
└── types/
└── user.ts # 이것도 있음 (아무도 안 씀)
문제: 서버 스펙 변경 → 4곳 다 찾아서 수정 → 하나 빠뜨림 → 런타임 에러
src/domains/profile/
├── entities/
│ └── User.ts # User 타입의 단일 진실 공급원
├── dtos/
│ └── UserDto.ts # 서버 응답 타입
├── repositories/
│ └── userRepository.ts # DTO→Entity 변환 (유일한 장소)
├── hooks/
│ └── useProfileUser.ts # Entity만 사용
└── components/
└── ProfileCard.tsx # Entity만 사용
효과: 서버 스펙 변경 → dtos/ + repositories/ 수정 → 끝
도메인을 나누는 기준이 가장 어려웠다.
결국 다음 질문들로 경계를 정했다:
post는 user 없이도 존재 가능 (작성자 정보는 참조)comment는 post 없이 의미 없음 → post 도메인에 포함notification은 앞으로 독립적인 설정, 필터링 등이 붙을 예정 → 별도 도메인┌─────────────────────────────────────────┐
│ pages │ ← 도메인 조립
├─────────────────────────────────────────┤
│ profile │ post │ mission │ ... │ ← 각 도메인
├─────────────────────────────────────────┤
│ shared │ ← 공통 유틸/컴포넌트
└─────────────────────────────────────────┘
규칙:
pages에서 조립shared로 추출// ❌ Bad: 도메인이 다른 도메인 직접 참조
// domains/post/components/PostCard.tsx
import { User } from '@/domains/profile/entities/User';
// ✅ Good: 필요한 데이터는 props로 받음
// domains/post/components/PostCard.tsx
interface PostCardProps {
authorName: string;
authorImage: string;
// ...
}
// ❌ 모든 게 shared로 가면 의미 없음
shared/
├── components/
│ ├── PostCard.tsx # 이건 post 도메인 전용
│ ├── UserAvatar.tsx # 이건 진짜 공통
│ └── MissionBadge.tsx # 이건 mission 도메인 전용
기준: 2개 이상의 도메인에서 쓰이면 shared, 아니면 해당 도메인에.
// ❌ Repository가 너무 많은 일을 함
const userRepository = {
async getUser(userId: string) {
const response = await api.get(`/users/${userId}`);
const user = this.toEntity(response.data);
// 이건 Repository가 할 일이 아님
if (user.followerCount > 10000) {
user.badge = 'influencer';
}
return user;
},
};
// ✅ Repository는 변환만, 로직은 hook이나 usecase에서
// ❌ Entity가 서버 형태를 따라감
interface User {
user_id: string; // snake_case = 서버 스펙 침투
profile_image_url: string | null; // null 처리도 안 됨
}
// ✅ Entity는 프론트 관점으로
interface User {
id: string;
profileImage: string; // 기본값 처리 완료
}
한 번에 다 바꾸는 건 불가능했다. 점진적으로 진행했다.
shared/, domains/ 폴더 생성notification 등)마이그레이션 후 체감한 변화들:
dtos/ + repositories/ 수정 → 끝이번에 새로 적용한 구조는 전통적인 DDD도 아니고,
분리 배포되는 MSA도 아니며,
FSD를 그대로 따른 것도 아니었다.
하지만 세 가지 패턴에서 영감을 받아,
프론트엔드에서 가장 문제가 되는 "서버 스펙 변경, 기획 변경, 디자인 변경"
이 세 가지 변동성을 흡수할 수 있도록 재구성한 구조라고 생각한다.
이 구조가 탄생할 수 있었던 결정적 배경:
이건 무언가를 발명한 게 아니라,
여러 패턴의 장점을 조합하여 우리 팀의 맥락에 맞도록 재해석한 결과물이다.
무엇보다, 지속 가능하며 혼돈을 견딜 수 있는 구조를 갖게 되었다.
기존 8번의 섹션의 제목은 Repository: Anti-Corruption-Layer 이었습니다. 이 부분 관련해서 감사하게도 항해 학습메이트 분께서 질문을 주셨어요🤗
제가 경험했었던 ACL이라고한다면 보통 두개의 소스 원천 혹은 레거시 소스 원천에서 신규 엔티티로 변환이 필요한경우 중간에 레이어를 두어 깨끗해지도록 만드는건데, 언급해주신 ACL은 단순히 직렬화되어있던 응답을 Dto 객체로 변환후 -> Entity 객체로 변환한거같은데 ACL이라고 생각하신건지 궁금함다
라는 질문을 받았습니다🤗. 여쭤봐주셔서 정말 감사합니다🫡
이에 대한 제 답은,
엄밀히 보면 제가 실제 구현한 것은 Translator/Mapper에 가깝고, ACL의 일부 컨셉만 차용한 것 이라고 생각합니다.
제가 글을 쓸 때 강조하고 싶었던 포인트는,
중간에 한 번 ‘충격을 흡수해주는 층’을 두자
는 취지에서 외부 스펙 변화가 바로 도메인 안쪽까지 파고들지 않도록 보호막을 두는 구조였어요.
글 상단에서 언급한 것과 같이 외부 스펙 변화가 너무 잦았기 때문(지금도 바뀌는 중🤦🏼♂️)에, 고민 끝에 저런 구조를 택하게 되었습니다.
개념은 ACL에서 차용하였으나, 현재 단계에서는 하나의 소스만 존재하기에, 단순한 매핑 기능만을 수행하고 있습니다.
였답니다. ACL을 공부하는 다른 분들에게 혼선을 줄 수 있으니 해당 섹션의 네이밍은 교체했답니다.
와,, 진짜 소름,,
최근 회사에서 프로젝트를 진행하면서 이와 비슷한 구조로 설계했습니다. 제가 고민했던 포인트도 거의 똑같고, 레퍼런스로 삼았던 개념들도 비슷하고, “3가지 레이어의 장점만 모아서 섞어보자!”라는 결론도 비슷하네요 ㅋㅋㅋㅋ
그래서 결과물도 거의 비슷한데, 다만 저는 domain 대신 features라는 네이밍을 썼고, 별도의 formatter 레이어를 두고 TanStack Query의 select 옵션을 활용해서 서버 응답을 바로 entity 형태로 변환해서 사용하는 식으로 구현했습니다.
저 또한 서버 변경 사항이 너무 많고, 기획이 명확하지 않아서 페이지를 역으로 분석해서 UX랑 기획을 다시 정의했는데, 그런 경험 덕분에 엄청 공감하면서 읽었습니다. 좋은 인사이트 감사합니다