August 09, 2024
recatch 의 프론트엔드 구조는 nextjs로 만들어진 recatch-nextjs와 도메인들을 정의해둔 여러 도메인 package 들로 구성되어 있는데요. 앱 전반을 두고 보자면 크게 domain, data, presentation layer로 구분 되어 있고 이 layer의 경계가 뚜렷하게 나뉘어져있어요.
domain: 도메인 model, repository interface를 정의해두는 곳
data: server에서 전달받는 dto, dto ↔ model converter, repositoryImpl
presentation: react의 components, hooks, context 정의
이러한 컨벤션을 좀 더 쉽게 유지 하기 위해서 import 관련 eslint rule도 설정해두었습니다. 레이어 분리에서 가장 중요한 건 어떤 레이어던 상위에 있는 레이어를 사용할 수 는 없다는 것입니다.
{
files: ['/**/domain/**'],
rules: {
'no-restricted-imports': [
'error',
{ patterns: ['/**/presentation/**', '/**/app/**'] },
],
},
},
이렇게 layer 간의 분리를 신경쓰는 이유는 결국 재사용을 쉽게 하고 변경에 유연하게 하기 위함 입니다. 컴포넌트 내에서 도메인 로직이 정의되어 있는 경우를 예를 들어 볼게요.
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const [user, setUser] = useState<{
id: string
name: string
email: string
age: number
} | null>(null)
useEffect(() => {
const fetchUser = async () => {
const response = await axios.get(`/api/users/${userId}`)
const userData = response.data
// 도메인 논리: 나이 기반 사용자 등급 설정
if (userData.age > 18) {
userData.userType = 'adult'
} else {
userData.userType = 'minor'
}
setUser(userData)
}
fetchUser()
}, [userId])
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<p>Type: {user.userType}</p> // 프레젠테이션 논리에서 도메인 논리를 활용한
부분
</div>
)
}
export default UserProfile
예를 들어, 클라이언트에서 유저의 나이에 따라 유저 타입을 정해야하는 도메인 로직이 컴포넌트 내에서 정의한 경우입니다. 만약 유저 타입이 새롭게 추가되거나 조건이 변경 되는 경우 도메인이 아닌 이를 정의한 component 로직을 수정해야하는 불편함이 생깁니다. 도메인 로직이 응집되어 있지 않고 컴포넌트 내에 있으니 변경 지점들이 많아지게 되겠죠.
컴포넌트에서 직접 repositoryImpl, 즉 데이터 레이어에 접근하기 보다는 domain 레이어만 알게끔 만들고 싶었어요. 컴포넌트에서 실질적으로 호출해야할 repositoryImpl을 직접 import 한다면 의존성이 컴포넌트 내에서 만들어지게 됩니다. 의존성을 만들어주는 곳을 외부의 매개체에게 역할을 위임하면서 컴포넌트는 domain 레이어만 알 수 있도록 만들었어요.
React 환경에서 가장 쉽게 injection 할 수 있는 형태인 Context 를 사용하여 외부 의존성을 만들어주는 diContainer를 만들었습니다.
// app/di.tsx
import { ActionRepository } from '@recatch/action'
import ActionRepositoryImpl from '../data/repositoryImpl'
import recatchAxios from '../data/axios'
interface DiContainer {
actionRepository: ActionRepository
}
const actionAPI = new ActionAPI(recatchAxios)
const actionRepository = new ActionRepositoryImpl(actionAPI)
const diContainer: DiContainer {
actionRepository: actionRepositoryImpl
}
export function DiContainerContextProvider({
children,
}: {
children: ReactNode
}) {
return (
<DiContainerContext.Provider value={diContainer}>
{children}
</DiContainerContext.Provider>
)
}
export function useDiContainer() {
return useContext(DiContainerContext)
}
app의 최상단에서 app 전역에서 사용할 수 있는 diContainer의 interface를 정의하고 앱 내에서 사용하게 될 instance들을 만듭니다. 위에서 설명한 대로 최상단의 context에서 diContainer 값을 주입합니다.
// app/action/components/ActionSettingModal.tsx
function ActionSettingModal() {
const { actionRepository } = useDiContainer()
const [action, setAction] = useState()
useEffect(() => {
const _action = await actionRepository.getAction(id)
setAction(_action)
}, [])
}
만약 아래와 같이 repository를 import 했다면 아래 컴포넌트를 테스트 해야할 때, import 문을 mocking 하지 않아도 됩니다.
import actionRepository from '../data/actionRepositoryImpl'
function ActionSettingModal() {
const [action, setAction] = useState()
useEffect(() => {
const _action = await actionRepository.getAction(id)
setAction(_action)
}, [])
}
컴포넌트(presentation)에서는 useDiContainer에서 반환하는 diContainer의 interface만 사용하고 있어요. 그렇기 때문에 이후 repository 구현체를 바꿔야할 일이 생겼다면 쉽게 변경할 수 있어요.
예. feature flag 도메인을 다루는 featureflagRepository가 기존에 localStorage로 관리 되고 있었던게 firestore 로 변경되어야한다면, 이 경우 interface는 유지되고 구현체만 바꿈으로써 코드의 변경을 최소화할 수 있어요.