- νλ‘μ νΈ κ΅¬μ‘°
- μμνκΈ°
- μν€ν μ²
- μ체 ORM
- λ°μ΄ν° νλ‘μ°
- μΉλ·° κΈ°λ₯
- κ°λ° κ°μ΄λ
- λΉλ λ° λ°°ν¬
Clean Architecture κΈ°λ° 4κ³μΈ΅ κ΅¬μ‘°λ‘ Entity-Repository-UseCase-Presentation ν¨ν΄ μ μ©
src/
βββ assets/ # μ΄λ―Έμ§, μμ΄μ½ λ± μ μ 리μμ€
βββ config/ # νκ²½λ³μ μ€μ
βββ entities/ # λλ©μΈ νμ
μ μ
βββ features/ # λλ©μΈλ³ νλ©΄ ꡬμ±
βββ repositories/ # λ°μ΄ν° κ³μΈ΅ (μ체 ORM)
βββ shared/ # κ³΅ν΅ μ»΄ν¬λνΈ/μμ/컨ν
μ€νΈ/ν
/μ νΈ
βββ tabs/ # ν λ€λΉκ²μ΄μ
ꡬμ±
βββ usecases/ # λΉμ¦λμ€ λ‘μ§
entities/: user.ts, club.ts, review.ts, category.tsrepositories/: κ° λλ©μΈλ³ CRUD + API ν΅μusecases/: λΉμ¦λμ€ λ‘μ§ + Repository DIfeatures/: club, home, mypage, webview νλ©΄ λλ©μΈshared/contexts/serviceContext.ts: μ 체 μλΉμ€ μμ‘΄μ± μ£Όμshared/utils/api.ts: APIConnector ν΄λμ€ (HTTP ν΄λΌμ΄μΈνΈ)
Node.js 22.13+ λ° pnpm νκ²½μμ React Native μ± μ€ν
# μμ‘΄μ± μ€μΉ
pnpm install && cd ios && pod install && cd ..
# κ°λ° μλ² μ€ν
pnpm start
pnpm ios:local # iOS
pnpm android:debug # Android.env.local, .env.prod νμΌμ μ€μ :
API_SERVER_BASE_URL=https://your-api-server.com
PROFILE=dev|staging|prodClean Architecture 4κ³μΈ΅μΌλ‘ μμ‘΄μ± μμ κ³Ό κ³μΈ΅ λΆλ¦¬ λ¬μ±
βββββββββββββββββββ
β Presentation β β features/, shared/, tabs/ (UI + μν)
βββββββββββββββββββ€
β Use Cases β β usecases/ (λΉμ¦λμ€ λ‘μ§)
βββββββββββββββββββ€
β Repositories β β repositories/ (λ°μ΄ν° μ κ·Ό)
βββββββββββββββββββ€
β Entities β β entities/ (λλ©μΈ λͺ¨λΈ)
βββββββββββββββββββ
1. Entities - μμ TypeScript νμ
export type User = {
id: string
nickname: string
college: string // λ¨κ³Όλ
major: string // νκ³Ό
grade: number | null
}2. Repositories - λ°μ΄ν° μΆμν + μ체 ORM
export const getUserRepository = (): UserRepository => ({
getUser: async () => {
const token = await AsyncStorage.getItem(LOGIN_TOKEN)
return apiConnector.get<GetUserResponse>('/v1/users/me')
},
})3. Use Cases - λΉμ¦λμ€ λ‘μ§ + DI
export const getUserService = ({ repositories }: Deps): UserService => ({
getUser: () => repositories[0].getUser(),
})4. Presentation - UI + μ΄λ²€νΈ λ‘κΉ
const HomeScreen = () => {
const { logClickEvent } = useClickEventLog()
return (
<WithViewEventLog params={{ screen_name: 'home_screen' }}>
{/* UI μ»΄ν¬λνΈ */}
</WithViewEventLog>
)
}TypeScript + Repository ν¨ν΄μΌλ‘ λ³λ ORM λΌμ΄λΈλ¬λ¦¬ μμ΄ νμ μμ ν λ°μ΄ν° κ³μΈ΅ ꡬν
export class APIConnector {
async get<T>(path: string, params?: any): Promise<T> {
const token = await AsyncStorage.getItem(LOGIN_TOKEN)
const headers = token ? { Authorization: `Bearer ${token}` } : {}
return this.request(path, 'GET', { params, headers })
}
}
export const apiConnector = new APIConnector()// μΈν°νμ΄μ€ μ μ
export type ClubRepository = {
searchClubs: (req: SearchClubsRequest) => Promise<SearchClubsResponse>
getClub: (req: GetClubRequest) => Promise<Club>
}
// ꡬν체
export const getClubRepository = (): ClubRepository => ({
searchClubs: async req => apiConnector.get(`/v1/clubs/search`, req),
getClub: async req => apiConnector.get(`/v1/clubs/${req.uuid}`),
})λͺ¨λ Request/Responseμ TypeScript νμ μ μλ‘ μ»΄νμΌνμ κ²μ¦
μ₯μ : νμ μμ μ± + κ²½λν + ν μ€νΈ μ©μ΄μ± + λͺ νν κ³μΈ΅ λΆλ¦¬
App.tsxμμ μ 체 μμ‘΄μ± μ£Όμ ν Contextλ‘ μ ννλ ꡬ쑰
// App.tsx
function App() {
// 1. Repository μμ±
const userRepository = getUserRepository()
const clubRepository = getClubRepository()
// 2. Serviceμ Repository μ£Όμ
const userService = getUserService({ repositories: [userRepository] })
const clubService = getClubService({ repositories: [clubRepository] })
// 3. Contextλ‘ μλΉμ€ μ 곡
const services = { userService, clubService }
return (
<ServiceProvider value={services}>
<ProfileProvider>
{/* μ± μ»΄ν¬λνΈ */}
</ProfileProvider>
</ServiceProvider>
)
}export const ProfileProvider = ({ children }) => {
const { userService } = useContext(serviceContext)
useEffect(() => {
userService.getUser().then(setUser)
}, [])
return <profileContext.Provider value={{ user }}>{children}</profileContext.Provider>
}React Native WebViewλ₯Ό νμ©ν νμ΄λΈλ¦¬λ κΈ°λ₯ μ 곡
μ± λ΄μμ μΉ νμ΄μ§λ₯Ό νμν μ μλ WebView νλ©΄μ μ 곡ν©λλ€. μ£Όλ‘ λμ리 μ 보 νΈμ§κ³Ό κ°μ΄ 볡μ‘ν νΌμ΄ νμν κΈ°λ₯μ μΉμΌλ‘ ꡬννμ¬ μ±μμ μ¬μ©ν©λλ€.
1. μΈμ± λΈλΌμ°μ
// νμΌ: src/screens/WebviewScreen/index.tsx:12-54
<WebView
source={{
uri,
headers: authorization ? { 'x-authorization': `Bearer ${authorization}` } : {},
}}
javaScriptEnabled
domStorageEnabled
sharedCookiesEnabled
/>2. μΈμ¦ ν€λ μ§μ
- Authorization ν ν°μ
x-authorizationν€λλ‘ μ λ¬ - λ‘κ·ΈμΈμ΄ νμν μΉ νμ΄μ§ μ κ·Ό κ°λ₯
3. μΉ-λ€μ΄ν°λΈ ν΅μ
// μΉμμ μ±μΌλ‘ λ©μμ§ μ μ‘
// νμΌ: src/screens/WebviewScreen/index.tsx:17-24
const onMessage = (e: WebViewMessageEvent) => {
const event = JSON.parse(e.nativeEvent.data)
switch (event.method) {
case 'CLOSE_WEBVIEW':
return navigation.goBack()
}
}4. λ‘λ© μν νμ
- μΉ νμ΄μ§ λ‘λ© μ€ ActivityIndicator νμ
onLoadStart,onLoadEndμ΄λ²€νΈλ‘ λ‘λ© μν κ΄λ¦¬
5. λ€λΉκ²μ΄μ
- 컀μ€ν ν€λ with λ€λ‘κ°κΈ° λ²νΌ
- μΉμμ
CLOSE_WEBVIEWλ©μμ§λ‘ νλ©΄ λ«κΈ° κ°λ₯
λμ리 νΈμ§ νμ΄μ§ μ΄κΈ°
// νμΌ: src/screens/MyPageScreen/index.tsx:69-71
navigation.navigate(SCREEN_TYPE.WEBVIEW, {
uri: ENV.WEB_URL + '/c/edit/' + club.uuid,
authorization, // μ¬μ©μ ν ν°
})// νμΌ: src/entities/screen.ts:37
type WebViewParams = {
uri: string // νμν μΉ νμ΄μ§ URL
authorization?: string // μ νμ μΈμ¦ ν ν°
}μΉ νμ΄μ§μμ λ€μκ³Ό κ°μ΄ λ©μμ§ μ μ‘:
// μΉ νμ΄μ§ JavaScript μ½λ
window.ReactNativeWebView.postMessage(
JSON.stringify({
method: 'CLOSE_WEBVIEW',
}),
)- JavaScript μ€ν
- DOM Storage
- μΏ ν€ κ³΅μ (μ±κ³Ό μΉ κ°)
- Third-party μΏ ν€
- μλ μ°½ μ΄κΈ°
- Bounce ν¨κ³Ό λΉνμ±ν (iOS)
- OverScroll λΉνμ±ν (Android)
- λμ리 κ΄λ¦¬: λμ리 μ 보 νΈμ§ νΌ (
/c/edit/:uuid) - 볡μ‘ν νΌ: μ¬λ¬ νλμ κ²μ¦μ΄ νμν μ λ ₯ νΌ
- μΈλΆ μ°λ: μΉμμλ§ μ 곡λλ μλΉμ€ ν΅ν©
src/screens/WebviewScreen/
βββ index.tsx # WebView λ©μΈ νλ©΄
βββ Header/
βββ index.tsx # 컀μ€ν
λ€λΉκ²μ΄μ
ν€λ
μ κΈ°λ₯ κ°λ° μ Entity β Repository β UseCase β UI μμλ‘ μ§ν
- Entity νμ μ μ
export type NewFeature = { id: string; name: string }- Repository ꡬν
export const getNewFeatureRepository = (): NewFeatureRepository => ({
getFeature: async id => apiConnector.get(`/v1/features/${id}`),
})- UseCase ꡬν
export const getNewFeatureService = ({ repositories }) => ({
getFeature: id => repositories[0].getFeature(id),
})- μμ‘΄μ± μ£Όμ + UI μ¬μ©
// App.tsxμμ μλΉμ€ μΆκ° ν Contextμμ μ¬μ©
const { newFeatureService } = useContext(serviceContext)pnpm lint # Biome κ²μ¬
pnpm typecheck # TypeScript νμ
체ν¬- λ€λΉκ²μ΄μ
:
@react-navigation/native - μνκ΄λ¦¬:
@tanstack/react-query+ React Context - UI:
react-native-elements,@gorhom/bottom-sheet - HTTP:
axios - μ€ν 리μ§:
@react-native-async-storage/async-storage - λ‘κ·ΈμΈ:
@react-native-seoul/kakao-login,@invertase/react-native-apple-authentication
const mockRepository: UserRepository = {
getUser: jest.fn().mockResolvedValue(mockUser),
}
const userService = getUserService({ repositories: [mockRepository] })
test('should get user', async () => {
const user = await userService.getUser()
expect(user).toEqual(mockUser)
})npx react-native start --reset-cache # Metro μΊμ ν΄λ¦¬μ΄
cd ios && pod deintegrate && pod install # iOS μμ‘΄μ± μ¬μ€μΉ
cd android && ./gradlew clean # Android ν΄λ¦¬μ΄
pnpm reset # μ 체 리μ
# λλ²κ·Έ
pnpm ios:local / pnpm android:debug
# 릴리μ€
pnpm build:ios:prod:release / pnpm build:android:release
src/
βββ assets/ # μ μ 리μμ€
β βββ icons/ # μ± μμ΄μ½ (apple, kakao, trophy λ±)
β βββ images/ # μ΄λ―Έμ§ 리μμ€ (header, mypage, theme, tab λ±)
β
βββ config/ # νκ²½λ³μ/λ°νμ μ€μ
β βββ ENV.ts # νκ²½λ³μ κ²μ¦ λ° κ΄λ¦¬
β
βββ entities/ # λλ©μΈ λͺ¨λΈ (νμ
μ μ)
β βββ user.ts # μ¬μ©μ μ 보 (User, CollegeMajor)
β βββ club.ts # λμ리 μ 보 (Club, ClubRanking, ReviewKeyword)
β βββ category.ts # λμ리 μΉ΄ν
κ³ λ¦¬ (9κ° μΉ΄ν
κ³ λ¦¬λ³ μμ/μ΄λ―Έμ§)
β βββ review.ts # 리뷰 ν€μλ μμ€ν
β βββ eventLog.ts # μ¬μ©μ νλ λ‘κΉ
(view, click, expose)
β βββ screen.ts # React Navigation μ€ν¬λ¦° νμ
β
βββ repositories/ # λ°μ΄ν° μ κ·Ό κ³μΈ΅ (μ체 ORM)
β βββ auth.ts # μΈμ¦ (μΉ΄μΉ΄μ€/μ ν λ‘κ·ΈμΈ, νμνν΄)
β βββ category.ts # μΉ΄ν
κ³ λ¦¬ λ°μ΄ν° κ΄λ¦¬
β βββ club.ts # λμ리 CRUD (κ²μ, λͺ©λ‘, μμΈ, μ μ₯, λνΉ)
β βββ review.ts # 리뷰 λ° νμ μμ€ν
β βββ user.ts # μ¬μ©μ κ΄λ¦¬ (νλ‘ν, νΌλλ°±, νκ³Όλͺ©λ‘)
β
βββ features/ # λλ©μΈλ³ νλ©΄ μ»΄ν¬λνΈ
β βββ club/screens/ # λμ리 λλ©μΈ νλ©΄
β β βββ ClubDetailScreen/
β β βββ ClubListScreen/
β β βββ ClubRankingScreen/
β β β βββ RankedClubs/
β β βββ ClubReviewScreen/
β β βββ SearchResultClubListScreen/
β βββ home/screens/
β β βββ HomeScreen/ # ν νλ©΄ (Header, CategoryBoard, RecommendClubs)
β βββ mypage/screens/
β β βββ EditProfileScreen/
β β βββ ManageClubListScreen/
β β βββ MyPageScreen/
β β βββ SavedClubListScreen/
β βββ webview/screens/
β βββ WebviewScreen/
β
βββ shared/ # κ³΅ν΅ λͺ¨λ
β βββ components/ # κ³΅ν΅ UI μ»΄ν¬λνΈ
β β βββ AlertModal.tsx
β β βββ Button.tsx
β β βββ ClubListItem.tsx
β β βββ HeaderProfile.tsx
β β βββ HtmlView.tsx
β β βββ LoginView.tsx
β β βββ ManageClubView.tsx
β β βββ TextField.tsx
β β βββ UserVoiceView.tsx
β βββ constants/ # κ³΅ν΅ μμ
β β βββ colors.ts
β β βββ fixtures.ts
β β βββ localStorage.ts
β βββ contexts/ # React Context
β β βββ loginBottomSheetContext.tsx
β β βββ manageClubBottomSheet.tsx
β β βββ profileContext.tsx
β β βββ serviceContext.ts
β β βββ userVoiceBottomSheetContext.tsx
β βββ hocs/
β β βββ WithViewEventLog.tsx
β βββ hooks/
β β βββ useClickEventLog.tsx
β β βββ useExposeEventLog.tsx
β βββ utils/
β βββ api.ts
β βββ navigation.ts
β
βββ tabs/ # ν λ€λΉκ²μ΄μ
β βββ HomeTab.tsx
β βββ MyPageTab.tsx
β βββ RankingTab.tsx
β βββ TabNavigator.tsx
β
βββ usecases/ # λΉμ¦λμ€ λ‘μ§ κ³μΈ΅
βββ auth.ts # μΈμ¦ λΉμ¦λμ€ λ‘μ§
βββ category.ts # μΉ΄ν
κ³ λ¦¬ λΉμ¦λμ€ λ‘μ§
βββ club.ts # λμ리 λΉμ¦λμ€ λ‘μ§
βββ eventLog.ts # μ΄λ²€νΈ λ‘κΉ
λΉμ¦λμ€ λ‘μ§
βββ review.ts # 리뷰 λΉμ¦λμ€ λ‘μ§
βββ user.ts # μ¬μ©μ λΉμ¦λμ€ λ‘μ§
πΈ Domain Layer (entities/)
μμ TypeScript νμ
μΌλ‘ λΉμ¦λμ€ λλ©μΈ λͺ¨λΈ μ μ
πΈ Data Layer (repositories/)
API ν΅μ κ³Ό λ°μ΄ν° CRUD λ΄λΉ. μ체 ORM μν μν
πΈ Business Layer (usecases/)
Repositoryλ₯Ό μ‘°ν©ν λΉμ¦λμ€ λ‘μ§. μμ‘΄μ± μ£Όμ
ν¨ν΄
πΈ Presentation Layer (features/, shared/, tabs/)
UI μ»΄ν¬λνΈμ μ μ μν κ΄λ¦¬
πΈ Infrastructure (config/, shared/)
HTTP ν΄λΌμ΄μΈνΈ, νκ²½μ€μ , κ³΅ν΅ μ νΈλ¦¬ν°
πΈ Cross-Cutting (hooks/, hocs/)
μ΄λ²€νΈ λ‘κΉ
μμ€ν
κ³Ό μ¬μ¬μ© λ‘μ§