|
1 | 1 | --- |
2 | 2 | sidebar_position: 4 |
3 | | -sidebar_class_name: sidebar-item--wip |
4 | 3 | pagination_next: reference/layers |
5 | 4 | --- |
6 | 5 |
|
7 | | -import WIP from '@site/src/shared/ui/wip/tmpl.mdx' |
8 | | - |
9 | 6 | # Cross-import |
10 | 7 |
|
11 | | -<WIP ticket="220" /> |
| 8 | +Cross-import는 **같은 `layer` 내부에 있는 서로 다른 `slice` 간의 import**를 의미합니다. |
| 9 | + |
| 10 | +예를 들어 `features/cart`에서 `features/product`를 import하거나, |
| 11 | +`widgets/header`에서 `widgets/sidebar`를 import하는 경우입니다. |
| 12 | + |
| 13 | +Cross-import는 code smell이며, slice 간의 결합도가 높아지고 있다는 경고 신호입니다. |
| 14 | +불가피하게 사용해야 한다면, 단순히 가져다 쓰는 게 아닌 왜 필요한지 명확히 판단하고 사용해야 합니다. |
| 15 | +그리고 이런 예외적인 의존 관계는 반드시 팀 내에서 서로 합의된 내용이어야 합니다. |
| 16 | + |
| 17 | +:::note |
| 18 | +`shared`와 `app` layer는 `slice` 개념이 없으므로, 해당 layer 내부 import는 cross-import로 보지 않습니다. |
| 19 | +::: |
| 20 | + |
| 21 | + |
| 22 | +## Cross-import는 왜 code smell 인가요? |
| 23 | + |
| 24 | +Cross-import는 단순히 코딩 스타일의 문제가 아닙니다. |
| 25 | +도메인 간의 경계를 흐리고, 변경 사항이 어디까지 영향을 줄지 알 수 없게 만드는 위험 신호로 간주됩니다. |
| 26 | + |
| 27 | +예를 들어 `cart` slice가 `product`의 비즈니스 로직에 직접 의존하는 상황을 생각해 봅시다. |
| 28 | +당장은 구현이 편해 보일 수 있지만, 시간이 지나면 다음과 같은 문제들이 발생합니다. |
| 29 | + |
| 30 | +1. **코드의 책임과 관리 주체가 모호해집니다** |
| 31 | + `cart`가 `product` 내부 로직을 직접 가져다 쓰기 시작하면, 해당 로직의 '적절한 위치'가 어디인지 판단하기 어려워집니다. |
| 32 | + `product` 내부 코드를 리팩토링하거나 로직을 바꿀 때, 그 로직을 `cart`도 함께 사용한다는 점을 놓치면 `cart`에서 런타임 오류나 동작 변경이 발생할 수 있습니다. |
| 33 | + 이런 숨은 의존성은 코드 탐색과 수정 난도를 올리고, 문제 발생 시 어느 `slice`에서 고쳐야 하는지 판단을 어렵게 만들어 결과적으로 리뷰/커뮤니케이션 비용을 키울 수 있습니다. |
| 34 | + |
| 35 | +2. **독립적인 실행과 테스트가 어려워집니다** |
| 36 | + Sliced Architecture의 가장 큰 장점은 각 Slice를 독립적으로 개발하고 테스트하며 배포할 수 있다는 점입니다. |
| 37 | + 하지만 Cross-import가 늘어나면 이 격리 상태가 깨집니다. `cart`만 테스트하고 싶어도 `product`의 정보를 불러와야 하며, |
| 38 | + 한 Slice에서의 수정이 다른 Slice의 기능이 예기치 않게 오작동하는 부작용이 발생할 수 있습니다 |
| 39 | + |
| 40 | +3. **코드 탐색 비용이 증가합니다.** |
| 41 | + `cart`를 수정할 때 의존 중인 `product`의 설계와 동작 방식까지 모두 파악해야 합니다. |
| 42 | + 하나의 Slice 안에서 작업을 끝내지 못하고, 연관된 여러 Slice 파일을 오가며 로직의 흐름을 쫓아야 합니다. |
| 43 | + 작은 부분을 수정할 때도 여러 Slice의 맥락을 동시에 고려해야 하므로 실수가 잦아집니다. |
| 44 | + |
| 45 | +4. **순환 의존성의 원인이 됩니다** |
| 46 | + cross-import는 처음엔 A→B 단방향으로 시작하더라도 시간이 지나면서 B→A가 생겨 **양방향(순환) 의존성**이 되기 쉽습니다. |
| 47 | + 이렇게 되면 slice들이 사실상 하나로 묶여버리고, 의존성을 풀거나 리팩터링하는 비용이 크게 올라갑니다. |
| 48 | + |
| 49 | +도메인 경계를 나누는 목적은 각 Slice가 자신의 책임에 집중하고 독립적으로 변화하게 만들기 위함입니다. |
| 50 | +의존 관계가 느슨할수록 변경 영향을 예측하기 쉽고, 리뷰와 테스트 범위도 좁게 유지할 수 있습니다. |
| 51 | +cross-import는 이 분리를 약화시키기 때문에, 일반적으로는 피하는 것이 좋은 의존성으로 다루는 편이 안전합니다. |
| 52 | + |
| 53 | + |
| 54 | +## Entities Layer cross-imports |
| 55 | + |
| 56 | +`entities`에서 cross-import가 자주 보인다면, 우선 `entity` 경계를 너무 잘게 쪼갠 것은 아닌지 점검해야 합니다. |
| 57 | +`@x` 패턴을 도입하기 전에, 분리된 Slice들을 하나로 병합하는 것이 설계상 더 자연스럽지 않은지 먼저 검토하세요. |
| 58 | + |
| 59 | +일부 팀에서는 `entities` 간의 불가피한 교차 참조를 해결하기 위해 `@x`를 별도의 entry point로 운영하기도 합니다. |
| 60 | +하지만 `@x`는 권장되는 패턴이라기보다 불가피한 타협에 가깝습니다. |
| 61 | +따라서 다른 대안이 없을 때 선택하는 마지막 수단으로 취급하는 것이 좋습니다. |
| 62 | + |
| 63 | +`@x`는 도메인 간의 피할 수 없는 의존 관계를 감추지 않고 명확히 드러내기 위한 공식 통로에 가깝습니다. |
| 64 | +이를 남발하면 `entity` 경계가 서로 강하게 엮이고, 시간이 지날수록 리팩터링 비용이 커지기 쉽습니다. |
| 65 | + |
| 66 | +`@x`에 대한 자세한 내용은 [Public API 문서](/docs/reference/public-api)를 참고하세요. |
| 67 | + |
| 68 | +비즈니스 `entity` 간 상호 참조(타입/관계)의 구체 예시는 아래 문서를 참고하세요: |
| 69 | +- [Types 가이드 — 비즈니스 엔티티와 상호 참조](/docs/guides/examples/types#business-entities-and-their-cross-references) |
| 70 | +- [Layers 레퍼런스 — Entities](/docs/reference/layers#entities) |
| 71 | + |
| 72 | + |
| 73 | +## Features와 Widgets: 다양한 설계 전략 |
| 74 | + |
| 75 | +`features`와 `widgets`에서는 cross-import를 항상 금지라고 선언하기보다, |
| 76 | +실제로는 팀/도메인/제품 상황에 따라 선택 가능한 여러 전략이 있다고 보는 편이 현실적입니다. |
| 77 | +이 섹션은 코드 자체보다, 상황에 따라 고를 수 있는 패턴 중심으로 설명합니다. |
| 78 | + |
| 79 | + |
| 80 | +### Strategy A: Slice merge |
| 81 | + |
| 82 | +두 slice가 실제로 독립적이지 않고 항상 같이 변경된다면, 둘을 하나의 더 큰 slice로 합치는 방법이 있습니다. |
| 83 | + |
| 84 | +예시 (before): |
| 85 | + |
| 86 | +- `features/profile` |
| 87 | +- `features/profileSettings` |
| 88 | + |
| 89 | +서로 계속 cross-import하고 사실상 한 단위로 움직인다면, 사실상 하나의 기능에 가까울 가능성이 큽니다. |
| 90 | +그 경우 `features/profile`로 합치는 편이 구조적으로 더 단순하고 유지보수도 쉬워지는 경우가 많습니다. |
| 91 | + |
| 92 | + |
| 93 | +### Strategy B: 여러 `features`에서 공유하는 도메인 로직을 `entities`로 내리기 |
| 94 | + |
| 95 | +여러 `feature`가 같은 도메인 로직(예: 세션 검증)을 반복해서 사용한다면, |
| 96 | +그 로직을 `entities` 내부의 도메인 slice(예: `entities/session`)로 옮길 수 있습니다. |
| 97 | + |
| 98 | +**핵심 원칙**: |
| 99 | +- `entities`에는 도메인 타입과 로직만 둡니다. (예: createSessionFromToken(), isSessionExpired()) |
| 100 | +- UI는 `features` / `widgets`에 유지합니다. |
| 101 | +- 각 `feature`들은 `entities`의 도메인 로직을 import하여 재사용합니다. |
| 102 | + |
| 103 | +예를 들어 `features/auth`와 `features/profile`이 모두 세션 검증이 필요하다면, |
| 104 | +세션 관련 도메인 함수들을 `entities/session`에 두고 두 feature에서 공통으로 사용합니다. |
| 105 | + |
| 106 | +자세한 예시는 [Layers reference — Entities](/docs/reference/layers#entities)를 참고하세요. |
| 107 | + |
| 108 | + |
| 109 | +### Strategy C: 상위 레이어(pages / app)에서 조립하기 |
| 110 | + |
| 111 | +같은 layer 내부에서 slice끼리 cross-import 하는 대신, 상위 `pages` / `app`에서 필요한 것들을 조립하는 방식입니다. |
| 112 | +Slice들이 서로를 직접 참조하지 않도록 하고, 상위 layer가 화면/플로우를 구성하면서 연결을 담당하는 방식으로 **Inversion of Control (IoC)** 패턴을 적용합니다. |
| 113 | + |
| 114 | +대표적인 IoC 기법은 아래와 같습니다. |
| 115 | +- **Render props (React)**: Component 또는 render 함수를 props로 전달해 조립합니다 |
| 116 | +- **Slots (Vue)**: named slot을 사용해 부모가 콘텐츠를 주입합니다. |
| 117 | +- **Dependency injection**: props 또는 Context로 의존성을 전달합니다. |
| 118 | + |
| 119 | +#### Basic composition example (React) |
| 120 | + |
| 121 | +```tsx title="features/userProfile/index.ts" |
| 122 | +export { UserProfilePanel } from './ui/UserProfilePanel'; |
| 123 | +``` |
| 124 | + |
| 125 | +```tsx title="features/activityFeed/index.ts" |
| 126 | +export { ActivityFeed } from './ui/ActivityFeed'; |
| 127 | +``` |
| 128 | + |
| 129 | +```tsx title="pages/UserDashboardPage.tsx" |
| 130 | +import React from 'react'; |
| 131 | +import { UserProfilePanel } from '@/features/userProfile'; |
| 132 | +import { ActivityFeed } from '@/features/activityFeed'; |
| 133 | + |
| 134 | +export function UserDashboardPage() { |
| 135 | + return ( |
| 136 | + <div> |
| 137 | + <UserProfilePanel /> |
| 138 | + <ActivityFeed /> |
| 139 | + </div> |
| 140 | + ); |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +이 구조에서는 `features/userProfile`와 `features/activityFeed`가 서로를 import하지 않습니다. |
| 145 | +대신 `pages/UserDashboardPage`가 두 `feature`를 조합해 화면을 구성합니다. |
| 146 | + |
| 147 | +#### Render props example (React) |
| 148 | + |
| 149 | +특정 feature가 다른 feature의 UI를 렌더링해야 하는 경우, |
| 150 | +render props를 통해 렌더링을 주입함으로써 의존성을 상위 layer로 이동시킬 수 있습니다. |
| 151 | + |
| 152 | +```tsx title="features/commentList/ui/CommentList.tsx" |
| 153 | +interface CommentListProps { |
| 154 | + comments: Comment[]; |
| 155 | + renderUserAvatar?: (userId: string) => React.ReactNode; |
| 156 | +} |
| 157 | + |
| 158 | +export function CommentList({ comments, renderUserAvatar }: CommentListProps) { |
| 159 | + return ( |
| 160 | + <ul> |
| 161 | + {comments.map(comment => ( |
| 162 | + <li key={comment.id}> |
| 163 | + {renderUserAvatar?.(comment.userId)} |
| 164 | + <span>{comment.text}</span> |
| 165 | + </li> |
| 166 | + ))} |
| 167 | + </ul> |
| 168 | + ); |
| 169 | +} |
| 170 | +``` |
| 171 | + |
| 172 | +```tsx title="pages/PostPage.tsx" |
| 173 | +import { CommentList } from '@/features/commentList'; |
| 174 | +import { UserAvatar } from '@/features/userProfile'; |
| 175 | + |
| 176 | +export function PostPage() { |
| 177 | + return ( |
| 178 | + <CommentList |
| 179 | + comments={comments} |
| 180 | + renderUserAvatar={(userId) => <UserAvatar userId={userId} />} |
| 181 | + /> |
| 182 | + ); |
| 183 | +} |
| 184 | +``` |
| 185 | + |
| 186 | +이렇게 하면 `CommentList`는 `UserAvatar`를 직접 import하지 않습니다. |
| 187 | +대신 `pages/PostPage`에서 `UserAvatar`를 import한 뒤, `renderUserAvatar` prop으로 전달합니다. |
| 188 | + |
| 189 | +#### Slots example (Vue) |
| 190 | + |
| 191 | +Vue에서는 named slot으로 부모(pages)가 콘텐츠를 주입해 cross-import 없이 조립할 수 있습니다. |
| 192 | + |
| 193 | +```vue title="features/commentList/ui/CommentList.vue" |
| 194 | +<template> |
| 195 | + <ul> |
| 196 | + <li v-for="comment in comments" :key="comment.id"> |
| 197 | + <slot name="avatar" :userId="comment.userId" /> |
| 198 | + <span>{{ comment.text }}</span> |
| 199 | + </li> |
| 200 | + </ul> |
| 201 | +</template> |
| 202 | +
|
| 203 | +<script setup lang="ts"> |
| 204 | +defineProps<{ |
| 205 | + comments: Comment[]; |
| 206 | +}>(); |
| 207 | +</script> |
| 208 | +``` |
| 209 | + |
| 210 | +```vue title="pages/PostPage.vue" |
| 211 | +<template> |
| 212 | + <CommentList :comments="comments"> |
| 213 | + <template #avatar="{ userId }"> |
| 214 | + <UserAvatar :userId="userId" /> |
| 215 | + </template> |
| 216 | + </CommentList> |
| 217 | +</template> |
| 218 | +
|
| 219 | +<script setup lang="ts"> |
| 220 | +import { CommentList } from '@/features/commentList'; |
| 221 | +import { UserAvatar } from '@/features/userProfile'; |
| 222 | +</script> |
| 223 | +``` |
| 224 | + |
| 225 | +`CommentList`는 `userProfile`을 import하지 않고, `PostPage`가 `slot`으로 조립합니다. |
| 226 | + |
| 227 | +### Strategy D: Cross-feature 재사용은 Public API를 통해서만 |
| 228 | + |
| 229 | +A–C는 cross-import 자체를 없애려는 전략입니다. |
| 230 | +반면 D는 cross-import가 불가피한 경우, 공개된 Public API만 사용하게 제한해서 결합과 변경 영향을 관리하는 전략입니다. |
| 231 | + |
| 232 | +cross-feature 재사용이 필요한 상황이라면, 다른 slice의 내부 구현(예: store/model/internal)에 직접 접근하지 말고, |
| 233 | +Public API를 통해서만 사용하도록 제한합니다. |
| 234 | + |
| 235 | +#### Example code: |
| 236 | + |
| 237 | +```tsx title="features/auth/index.ts" |
| 238 | +export { useAuth } from './model/useAuth'; |
| 239 | +export { AuthButton } from './ui/AuthButton'; |
| 240 | +``` |
| 241 | + |
| 242 | +```tsx title="features/profile/ui/ProfileMenu.tsx" |
| 243 | + |
| 244 | +import React from 'react'; |
| 245 | +import { useAuth, AuthButton } from '@/features/auth'; |
| 246 | + |
| 247 | +export function ProfileMenu() { |
| 248 | + const { user } = useAuth(); |
| 249 | + |
| 250 | + if (!user) { |
| 251 | + return <AuthButton />; |
| 252 | + } |
| 253 | + |
| 254 | + return <div>{user.name}</div>; |
| 255 | +} |
| 256 | +``` |
| 257 | + |
| 258 | +예를 들어 `features/profile`이 `features/auth/model/internal/*` 같은 경로에 직접 접근하지 않도록 합니다. |
| 259 | +`features/auth`가 공식적으로 공개한 Public API만 사용하도록 제한합니다. |
| 260 | + |
| 261 | +## cross-import를 문제로 간주해야 하는 경우 |
| 262 | + |
| 263 | +위에서 여러 전략을 살펴봤다면, 이제 다음 질문이 생길 수 있습니다. |
| 264 | + |
| 265 | +> cross-import를 그냥 두어도 되는 상황은 언제일까? |
| 266 | +> 그리고 언제는 “이건 code smell이다”라고 보고 리팩터링을 검토해야 할까? |
| 267 | +
|
| 268 | +대표적인 경고 신호는 다음과 같습니다. |
| 269 | + |
| 270 | +- 다른 slice의 store/model/비즈니스 로직에 직접 의존하는 경우 |
| 271 | +- slice들 간에 서로 직접 의존하는 **양방향 의존성**이 생긴 경우 |
| 272 | +- 하나의 slice 변경이 거의 항상 다른 slice를 함께 깨뜨리는 경우 |
| 273 | +- 원래는 상위 layer(`pages` / `app`)에서 조립해야 할 플로우를, |
| 274 | + 같은 layer의 cross-import로 억지로 구현하고 있는 경우 |
| 275 | + |
| 276 | +이런 신호들이 보인다면, |
| 277 | +그 cross-import는 **code smell**로 보고 위에서 소개한 전략들 중 최소 하나는 적용할 수 있는지 검토해야 합니다. |
| 278 | + |
| 279 | +## 적용 범위와 기준은 팀 및 프로젝트의 결정 사항입니다. |
| 280 | + |
| 281 | +마지막으로, 다음 내용에서는 이 규칙들을 얼마나 엄격하게 적용할지는 팀과 프로젝트에 따라 달라진다는 점을 강조합니다. |
| 282 | + |
| 283 | +예를 들어: |
| 284 | + |
| 285 | +- **초기 단계 제품**처럼 요구사항 변동이 크고 빠른 반복이 잦은 서비스라면, |
| 286 | + 단기적인 개발 속도를 위해 어느 정도의 cross-import를 허용할 수도 있습니다. |
| 287 | +- 반대로, **장기 운영이 전제되거나 규제 요구사항이 높은 시스템**은 |
| 288 | + 더 엄격한 경계와 layer 설계를 통해 장기적인 안정성과 유지보수성을 얻는 편이 낫습니다. |
| 289 | + |
| 290 | +우리는 cross-import를 **절대 금지 규칙**으로 보지 않습니다. |
| 291 | +대신, **일반적으로는 피해야 하는 의존성**으로 취급합니다. |
| 292 | + |
| 293 | +cross-import를 도입할 때에는, 그것이 **의도적이고 의식적인 설계 선택**인지 인지해야 합니다. |
| 294 | +또한 이 선택을 문서화하고, 시스템이 진화함에 따라 주기적으로 다시 검토하는 것이 좋습니다. |
| 295 | + |
| 296 | +팀은 다음과 같은 점들에 대해 서로 합의를 맞출 필요가 있습니다. |
12 | 297 |
|
13 | | -> Cross-import는 Layer나 추상화가 원래의 책임 범위를 넘어설 때 발생합니다. 방법론에서는 이러한 Cross-import를 해결하기 위한 별도의 Layer를 정의합니다. |
| 298 | +- 우리 팀/프로젝트에서 원하는 엄격함 수준은 어느 정도인지 |
| 299 | +- 그 엄격함을 lint, 코드 리뷰, 문서 등에 어떻게 반영할지 |
| 300 | +- 도메인과 아키텍처가 성숙해지면서 cross-import를 어떤 주기와 기준으로 다시 점검할지 |
14 | 301 |
|
15 | 302 | ## 참고 자료 |
16 | 303 |
|
17 | 304 | - [(스레드) Cross-import가 불가피한 상황 논의](https://t.me/feature_sliced/4515) |
18 | 305 | - [(스레드) Entity에서 Cross-import 해결 방법](https://t.me/feature_sliced/3678) |
19 | 306 | - [(스레드) Cross-import와 책임 범위 관계](https://t.me/feature_sliced/3287) |
20 | 307 | - [(스레드) Segment 간 import 이슈 해결](https://t.me/feature_sliced/4021) |
21 | | -- [(스레드) Shared 내부 구조의 Cross-import 해결](https://t.me/feature_sliced/3618) |
| 308 | +- [(스레드) Shared 내부 구조의 Cross-import 해결](https://t.me/feature_sliced/3618) |
0 commit comments