[FE] 혼돈 속에서도 지속되는 프로덕트를 위해 — 도메인 기반 구조 리빌드 기록
FE
- 01 [FE] 새로고침 해도 이미지를 유지하는 방법
- 02 [FE] React Native에서 Amplitude 쿠키 에러 해결하기
- 03 [FE] 혼돈 속에서도 지속되는 프로덕트를 위해 — 도메인 기반 구조 리빌드 기록
FrontEnd 개발이 극단적으로 쉬워진 구조 개편 기록 본문에 있는 구조 및 코드는 모두 예시입니다.
들어가며
필드에서 마주한 첫 프로젝트 초반, 우리도 수많은 스타트업들이 겪는 상황을 똑같이 겪고 있었다.
- 기획은 수시로 수정되어야 했고
- 디자인은 레이아웃만 확정된 상태였으며 (이마저도 인터랙션이 정의되지 않은 상태였다)
- 서버 스펙 또한 확정되지 않은 상태였다
즉, 모든 게 동시에 움직이는 상태였다. “디자인 | 서버 스펙 다 나올 때까지 기다렸다가 개발 시작”은 현실적으로 불가능했다.
그래서 나는 당시 선택할 수 있는 가장 현실적인 전략을 택했다. 사실 내가 경험이 없어서 저게 최선이었는지는 모르겠다.
“일단 있는 UI부터 만들고, 스펙이 나오는 대로 맞춰나가자.”
초기에는 이 방식이 꽤 효율적이었다. 프로토타입을 빠르게 뽑을 수 있었고, 팀 전체가 “서비스가 어떤 흐름으로 동작하는지”를 눈으로 확인할 수 있었다.
문제는 “나중에 맞추자”의 그 나중이 왔을 때 터졌다.
이미 예상은 한 지점이지만, 생각보다 스노우볼이 더 크게 다가왔다.

1. 기획/디자인/서버가 동시에 유동적인 상황에서 먼저 달린 결과
서버에서 내려주는 실제 포맷은 내가 혼자 가정해둔 타입들과 많이 달랐다.
- 자료형 다름
- 필드명 다름 (
userNamevsuser_name) - optional 여부 다름
- nested depth 다름
- enum 값 다름
솔직히 말하면 맞는 게 더 적은 상태였다.
기획도 바뀌고, 디자인도 디벨롭 중이고, 백엔드도 바쁘게 움직이는 상황에서 내가 먼저 달릴 수밖에 없었던 것의 자연스러운 결과였다. 예상은 한 부분이나, 생각보다 문제가 커져있었다.
그러나 진짜 문제는 따로 있었다.
타입이 전역에 퍼져 있었다
컴포넌트, 훅, 유틸, 페이지… 같은 개념의 타입이 파일마다 다 다른 형태로 정의되어 있었다.
스펙 하나 바뀌면 프로젝트 전체를 grep 돌려야 했다.
이 구조는 지속 가능한 구조가 아니었으며, 개발자로서 이를 유지할 수는 없다는 생각이 들었다. (나름의 곤조였다)
‘일단 돌아가면 건드리지 말 것.’ 이라는 말은 내 스스로 도저히 용납할 수 없었다.
2. 골든타임: 2주간의 디자인 리셋 기간
그러던 중,
기획과 디자인을 전면적으로 다시 잡아야 하는 시점이 왔고, 기존 화면 위에 계속 덧칠하는 건 더 이상 효율적이지 않았다. 그래서 디자인 및 기획을 새로 다듬는 시간을 갖기로 했다.
이 기간 동안 프론트 작업은 홀딩하였다.
나는 이게 아키텍처를 재설계할 유일한 기회라는 걸 직감했던 것 같다.
조건이 맞아떨어졌다:
- 프론트 신규 작업 거의 없음
- 기존 코드를 유지할 필요 없음 (어차피 화면이 다 바뀜)
이 2주 동안 나는:
- 기존 코드의 문제점 분석
- 참고할 패턴들(FSD, DDD, Vertical Slice) 학습
- 우리 서비스에 맞는 도메인 경계 정의
- 새로운 폴더 구조 설계 및 마이그레이션 진행
이 기간이 없었다면, 지금의 구조는 절대 탄생하지 못했을 것이다.
3. 참고한 개념들
새로운 구조를 설계하기 전에 몇 가지 아키텍처 패턴을 참고했다. 하지만 어디까지나 프론트엔드 현실에 맞게 절충해서 적용했다.
Vertical Slice Architecture
- 기존: 기술 레이어별 분리 (controllers/, services/, models/)
- Vertical Slice: 기능 단위로 수직 분리
- 가져온 것: 기능(도메인) 단위 응집
Domain-Driven Design (DDD)
- Bounded Context: 도메인 간 명확한 경계
- Anti-Corruption Layer (ACL): 외부 시스템과의 변환 레이어
- 가져온 것: DTO → Entity 변환을 Repository에서만 수행
Feature-Sliced Design (FSD)
- 프론트엔드 특화 아키텍처
- 6~7개 레이어로 분리
- 가져온 것: 프론트엔드에서의 도메인 슬라이싱 개념
핵심은 세 패턴의 장점을 조합하되, 프론트엔드 환경에 맞게 단순화한 것이다.
위의 개념에 대해서는 추후 포스팅으로 다시 작성하도록 하겠다.
4. 기존 패턴들과 내 구조의 차별점
“발명은 아니다. 하지만 조합 자체는 충분히 독창적이다.”
- Claude의 답변
나는 전형적인 INTJ인간이다. 따라서 나는 구조화에 진심인 사람이기에, 우리만의 구조를 만들기 시작했다.
아키텍처는 완전히 무(無)에서 탄생하지 않는다. 모든 패턴은 기존 개념의 조합이다.
- MVC도 Observer + Strategy 조합
- Clean Architecture도 Hexagonal의 발전형
- FSD도 Feature-first + DDD 용어 체계의 조합
중요한 것은 **“무엇을 어떤 맥락에서 어떤 방식으로 조합했는가”**이다.
내가 만든 구조는 Vertical Slice/DDD/FSD에서 영감을 받았지만, 그대로 가져온 것은 분명 아니다.
FSD와의 주요 차이점
| 관점 | FSD | 내 구조 |
|---|---|---|
| DTO/Entity 분리 | 강조하지 않음 | 핵심 원칙 |
| Repository 역할 | 애매함 | Repository = ACL 명확 |
| 레이어 개수 | 6~7개 | 2개(domains, pages)로 단순화 |
| 경계 기준 | Feature 중심 | Business Domain 중심 |
특히 DTO → Entity 변환을 Repository에서만 일관되게 수행한다는 구조는 FSD에도 없는 명시적인 설계라고 생각한다.
이 구조의 가장 큰 목표는:
서버 스펙 및 디자인 변경에 프론트가 흔들리지 않게 하는 것 그로 인해 지속 가능한 프로덕트로 전환하는 것
이다.
5. 이 구조가 가능했던 이유: 기획자이기도 했기 때문
솔직히 말하면, 이 구조를 만들 수 있었던, 그리고 자신 있었던 결정적인 이유가 있다.
내가 이 서비스의 기획자이기도 했기 때문이다.
도메인 경계를 어떻게 정의할지, 어떤 기능이 어떻게 확장될지, 핵심 기능이 무엇이고 주변 기능이 무엇인지…
이건 단순한 기술적 판단이 아니며, 비즈니스 맥락을 아는 사람만 할 수 있는 설계라고 생각한다.
예를 들어:
auth와user를 왜 분리했는가?post와comment는 왜 별도의 도메인인가?artist의 경계는 어디까지인가?
이건 기획자/PM이 “앞으로 어떤 기능이 어떻게 변할지”를 알고 있어야 제대로 경계를 나눌 수 있다.
나는 이 서비스의 PO였고, 전체 서비스의 흐름과 미래 변경 가능성을 가장 깊게 알고 있었기 때문에, 도메인 구조를 기술적 + 비즈니스적 기준으로 정확하게 설계할 수 있었다고 생가한다.
이건 내 역할이 개발자만이었다면 절대 만들 수 없었던 구조다.
개인적으로 자기소개 문구로 항상 쓰는 기술과 비즈니스의 접점을 잇는 개발자라는 지점이 빛을 발한 순간이었다. 사실 요즘들어 너무 기획 | PM 역량만 강조되는건 아닐까? 하는 우려가 있었다. 그런데, 개발적으로도 이렇게 빛을 발하게 되어 매우 기쁘다.
6. 새로운 폴더 구조
src/
├── shared/ # 공통 (도메인 무관)
│ ├── components/
│ │ ├── atomics/ # Button, Avatar, Badge...
│ │ └── commons/ # InputText, BottomSheet...
│ ├── hooks/
│ ├── styles/
│ └── utils/
│
├── domains/ # 도메인별 분리
│ ├── auth/
│ ├── profile/
│ ├── post/
│ ├── feed/
│ ├── mission/
│ ├── messaging/
│ ├── notification/
│ ├── search/
│ ├── artist/
│ └── home/
│
│ # 각 도메인 내부 구조:
│ └── [domain]/
│ ├── entities/ # 순수 도메인 모델
│ ├── repositories/ # 데이터 접근 + DTO→Entity 변환 (ACL)
│ ├── dtos/ # API 요청/응답 객체
│ ├── components/ # UI 컴포넌트
│ ├── hooks/ # React 훅
│ └── mocks/ # 모의 데이터
│
└── app/ # 페이지 (라우팅)
7. 핵심 개념: DTO vs Entity
이 구조의 핵심은 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;
}
8. Repository: 실제로 충격을 견디는 Shock Absorption Layer (완충 지대)
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);
},
};
이렇게 하면:
- 서버 스펙이 바뀌어도 Repository의
toEntity만 수정하면 된다 - 컴포넌트, 훅, 페이지는 Entity만 바라보기 때문에 영향 없음
- 변경 범위가 명확하게 격리된다
9. Before → After
Before: 타입이 전역에 흩어짐

src/
├── components/
│ └── UserProfile.tsx # 여기서 User 타입 정의
├── hooks/
│ └── useUser.ts # 여기서도 User 타입 정의 (다른 형태)
├── pages/
│ └── profile.tsx # 여기서도 또 정의
└── types/
└── user.ts # 이것도 있음 (아무도 안 씀)
문제: 서버 스펙 변경 → 4곳 다 찾아서 수정 → 하나 빠뜨림 → 런타임 에러
After: 도메인 단위로 응집
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/ 수정 → 끝
10. 도메인 경계는 어떻게 정했나
도메인을 나누는 기준이 가장 어려웠다. 결국 다음 질문들로 경계를 정했다:
- 이 기능이 독립적으로 변경될 수 있는가?
- Yes → 별도 도메인
- No → 기존 도메인에 포함
- 이 기능이 다른 도메인 없이 의미가 있는가?
post는user없이도 존재 가능 (작성자 정보는 참조)comment는post없이 의미 없음 →post도메인에 포함
- 향후 이 기능이 어떻게 확장될 것인가?
notification은 앞으로 독립적인 설정, 필터링 등이 붙을 예정 → 별도 도메인
11. 도메인 간 의존성 규칙
┌─────────────────────────────────────────┐
│ pages │ ← 도메인 조립
├─────────────────────────────────────────┤
│ profile │ post │ mission │ ... │ ← 각 도메인
├─────────────────────────────────────────┤
│ shared │ ← 공통 유틸/컴포넌트
└─────────────────────────────────────────┘
규칙:
- 도메인은 다른 도메인을 직접 import하지 않는다
- 도메인 간 데이터 교환은
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;
// ...
}
12. 흔한 함정들
함정 1: “이건 공통이니까 shared에”
// ❌ 모든 게 shared로 가면 의미 없음
shared/
├── components/
│ ├── PostCard.tsx # 이건 post 도메인 전용
│ ├── UserAvatar.tsx # 이건 진짜 공통
│ └── MissionBadge.tsx # 이건 mission 도메인 전용
기준: 2개 이상의 도메인에서 쓰이면 shared, 아니면 해당 도메인에.
함정 2: Repository에서 비즈니스 로직 처리
// ❌ 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에서
함정 3: Entity에 서버 스펙이 침투
// ❌ Entity가 서버 형태를 따라감
interface User {
user_id: string; // snake_case = 서버 스펙 침투
profile_image_url: string | null; // null 처리도 안 됨
}
// ✅ Entity는 프론트 관점으로
interface User {
id: string;
profileImage: string; // 기본값 처리 완료
}
13. 마이그레이션 전략
한 번에 다 바꾸는 건 불가능했다. 점진적으로 진행했다.
Phase 1: 폴더 구조 생성
shared/,domains/폴더 생성- 기존 코드는 그대로 둠
Phase 2: shared 먼저 이동
- 공통 컴포넌트, 훅, 유틸 이동
- import 경로 업데이트
Phase 3: 도메인 하나씩 이동
- 가장 독립적인 도메인부터 시작 (
notification등) - entities → dtos → repositories → hooks → components 순서
Phase 4: 기존 폴더 정리
- 모든 이동 완료 후 기존 폴더 삭제
- 전체 import 경로 최종 점검
14. 이 구조가 준 효과
마이그레이션 후 체감한 변화들:
- 서버 타입 변경 대응
- Before: grep으로 전체 검색 → 여러 파일 수정 → 빠뜨린 거 런타임에 발견
- After:
dtos/+repositories/수정 → 끝
- 새 페이지 개발
- Before: 필요한 타입/훅/컴포넌트 어디 있는지 찾아다님
- After: 해당 도메인 폴더만 보면 됨
- 코드 리뷰
- Before: “이 타입 정의 어디서 가져온 거예요?”
- After: 도메인 구조가 명확해서 리뷰 시간 단축
- 버그 추적
- Before: 같은 이름의 타입이 여러 개라 혼란
- After: 단일 진실 공급원으로 추적 용이
이번 구조 개편을 통해 배운 점
- 구조는 ‘정답’을 찾는 과정이 아니라, 팀의 상황과 변수를 어떻게 받아들일지 설계하는 과정이라는 것
- 도메인 경계는 기술이 아니라 비즈니스의 흐름에서 출발해야 한다는 사실
- DTO와 Entity를 분리하는 작은 결정 하나가 구조 전체 안정성에 큰 영향을 준다는 것
- 아키텍처는 겉보기 예쁜 폴더 구조보다 “변경에 대한 면역력”이 핵심이라는 것
- 그리고 무엇보다, 경험이 짧더라도 문제를 끝까지 추적하고 해법을 스스로 설계할 수 있다는 자신감
마무리
이번에 새로 적용한 구조는 전통적인 DDD도 아니고, 분리 배포되는 MSA도 아니며, FSD를 그대로 따른 것도 아니었다.
하지만 세 가지 패턴에서 영감을 받아, 프론트엔드에서 가장 문제가 되는 “서버 스펙 변경, 기획 변경, 디자인 변경” 이 세 가지 변동성을 흡수할 수 있도록 재구성한 구조라고 생각한다.
이 구조가 탄생할 수 있었던 결정적 배경:
- 2주간의 홀딩 기간 → 구조 재설계 골든타임
- 기획자로서 비즈니스 흐름 이해 → 정확한 도메인 경계 설정
이건 무언가를 발명한 게 아니라, 여러 패턴의 장점을 조합하여 우리 팀의 맥락에 맞도록 재해석한 결과물이다.
무엇보다, 지속 가능하며 혼돈을 견딜 수 있는 구조를 갖게 되었다.
8번 관련 추가 이야기
기존 8번의 섹션의 제목은 Repository: Anti-Corruption-Layer 이었습니다. 이 부분 관련해서 감사하게도 항해 학습메이트 분께서 질문을 주셨어요🤗
제가 경험했었던 ACL이라고한다면 보통 두개의 소스 원천 혹은 레거시 소스 원천에서 신규 엔티티로 변환이 필요한경우 중간에 레이어를 두어 깨끗해지도록 만드는건데, 언급해주신 ACL은 단순히 직렬화되어있던 응답을 Dto 객체로 변환후 -> Entity 객체로 변환한거같은데 ACL이라고 생각하신건지 궁금함다
라는 질문을 받았습니다🤗. 여쭤봐주셔서 정말 감사합니다🫡
이에 대한 제 답은,
엄밀히 보면 제가 실제 구현한 것은 Translator/Mapper에 가깝고, ACL의 일부 컨셉만 차용한 것 이라고 생각합니다. 제가 글을 쓸 때 강조하고 싶었던 포인트는,
중간에 한 번 ‘충격을 흡수해주는 층’을 두자는 취지에서 외부 스펙 변화가 바로 도메인 안쪽까지 파고들지 않도록 보호막을 두는 구조였어요. 글 상단에서 언급한 것과 같이 외부 스펙 변화가 너무 잦았기 때문(지금도 바뀌는 중🤦🏼♂️)에, 고민 끝에 저런 구조를 택하게 되었습니다. 개념은 ACL에서 차용하였으나, 현재 단계에서는 하나의 소스만 존재하기에, 단순한 매핑 기능만을 수행하고 있습니다.
였답니다. ACL을 공부하는 다른 분들에게 혼선을 줄 수 있으니 해당 섹션의 네이밍은 교체했답니다.

![[FE] React Native에서 Amplitude 쿠키 에러 해결하기](https://velog.velcdn.com/images/suadesu/post/abfcde0d-b990-4d8f-932e-2710bad70b62/image.jpg)
![[FE] 새로고침 해도 이미지를 유지하는 방법](https://velog.velcdn.com/images/suadesu/post/d75099ba-9c10-45a1-9714-27e778598969/image.jpg)
![[항해플러스] 최종 회고](https://velog.velcdn.com/images/suadesu/post/2d89266f-dc6a-43c9-a491-1996be5f2c2a/image.png)