์๊ณผ ์๋ฃ์ง ์ ์ฉ iPad ์ฑ โ ํ์์์ ์ค์๊ฐ ์ฑํ , ์ด๋ฏธ์งยทPDF ํ์ผ ๊ณต์ , VOD ์คํธ๋ฆฌ๋ฐ์ด ๊ฐ๋ฅํ ์๋ฃ์ง ์ ์ฉ ๋น๋๋ฉด ์ง๋ฃ ์๋ฃจ์
| ๋ก๊ทธ์ธ (๊ฐ๋ก) | ๋ก๊ทธ์ธ (์ธ๋ก) |
|---|---|
![]() |
![]() |
| ํ์๊ฐ์ (๊ฐ๋ก) | ํ์๊ฐ์ (์ธ๋ก) |
|---|---|
![]() |
![]() |
| ์ฑํ (๊ฐ๋ก) | ์ฑํ (์ธ๋ก) |
|---|---|
![]() |
![]() |
| ํ๋กํ (๊ฐ๋ก) | ํ๋กํ (์ธ๋ก) |
|---|---|
![]() |
![]() |
| ๋ถ๋ฅ | ๊ธฐ์ | ์ ํ ์ด์ |
|---|---|---|
| UI | SwiftUI (iPad ์ ์ฉ) | NavigationSplitView ๊ธฐ๋ฐ 2-Column ๋ ์ด์์, ์ข์ธก ์ฑํ ๋ชฉ๋ก + ์ฐ์ธก ์ฑํ ์์ธ ๋์ ํ์ |
| Architecture | TCA (The Composable Architecture) | @Reducer ๋งคํฌ๋ก ๊ธฐ๋ฐ ๋จ๋ฐฉํฅ ๋ฐ์ดํฐ ํ๋ฆ, @Dependency๋ก ์ธ๋ถ ์์กด์ฑ ์ฃผ์
๋ฐ Mock ๊ต์ฒด |
| Network | URLSession + async/await | Alamofire ์์ด ์์ URLSession, APIRouter enum ์๋ํฌ์ธํธ ์ ์ + APIClient ์์ฒญยทํ ํฐ ๊ฐฑ์ |
| Realtime | Socket.IO | Namespace ๊ธฐ๋ฐ(/chats-{roomId}) ์ฑํ
๋ฐฉ๋ณ ๋
๋ฆฝ ์ฐ๊ฒฐ, AsyncStream ๋ํ์ผ๋ก TCA Effect ํตํฉ |
| Auth | ์ด๋ฉ์ผ ํ์๊ฐ์ /๋ก๊ทธ์ธ | JWT ํ ํฐ Keychain ์ ์ฅ, actor ๊ธฐ๋ฐ AuthInterceptor๋ก ๋์ ๊ฐฑ์ ์์ฒญ ์ง๋ ฌํ |
| Media | AVPlayer (HLS) | m3u8 ๊ธฐ๋ฐ ์คํธ๋ฆฌ๋ฐ, ํ๋กํ ํ๋ฉด ์ธ๋ผ์ธ VOD ์ฌ์ |
| Storage | Realm + Keychain | ์ฑํ ๋ฉ์์ง ๋ก์ปฌ ์บ์ฑ(Realm), ํ ํฐ ๋ณด์ ์ ์ฅ(Keychain), 3-Stage Loading ์ ๋ต |
| Image | Kingfisher | ImageDownloadRequestModifier ๊ธฐ๋ฐ ์ธ์ฆ ํค๋ Extension ํต์ผ, ๋ค์ด์ํ๋ง ๋ฉ๋ชจ๋ฆฌ ์ต์ ํ |
| Testing | Swift Testing + TCA TestStore | @Test ๋งคํฌ๋ก ๊ธฐ๋ฐ 10๊ฐ Unit Test, withDependencies Mock ์ฃผ์
+ LockIsolated Concurrency ์์ ๊ฒ์ฆ |
%%{init: {'flowchart': {'htmlLabels': false, 'useMaxWidth': false}} }%%
flowchart TD
View["SwiftUI View"]
Action["Action enum"]
Reducer["Reducer @Reducer"]
State["State @ObservableState"]
View -->|"์ฌ์ฉ์ ์ด๋ฒคํธ"| Action
Action --> Reducer
Reducer -->|"์ํ ๋ณ๊ฒฝ"| State
State -->|"UI ๋ฐ์ธ๋ฉ"| View
Reducer -.->|"@Dependency"| APIClient["APIClient"]
Reducer -.->|"@Dependency"| SocketClient["SocketClient"]
Reducer -.->|"@Dependency"| KeychainClient["KeychainClient"]
Reducer -.->|"@Dependency"| RealmClient["RealmClient"]
View๋ State๋ง ์ฝ์ด ํ๋ฉด์ ๊ทธ๋ฆฌ๊ณ , ์ฌ์ฉ์ ์ด๋ฒคํธ๋ฅผ Action์ผ๋ก ์ ๋ฌํฉ๋๋ค. Reducer๊ฐ ๋น์ฆ๋์ค ๋ก์ง์ ์ฒ๋ฆฌํ๊ณ State๋ฅผ ๋ณ๊ฒฝํ๋ฉด, SwiftUI๊ฐ ์๋์ผ๋ก UI๋ฅผ ๊ฐฑ์ ํฉ๋๋ค. ๋ชจ๋ ์ธ๋ถ ์์กด์ฑ์ @Dependency๋ก ์ฃผ์
๋์ด ํ
์คํธ ์ Mock์ผ๋ก ๊ต์ฒดํ ์ ์์ต๋๋ค.
@Reducer
struct ChatDetailFeature {
@ObservableState
struct State: Equatable { ... }
enum Action { ... }
@Dependency(\.apiClient) var apiClient
@Dependency(\.socketClient) var socketClient
var body: some ReducerOf<Self> {
Reduce { state, action in ... }
}
}sequenceDiagram
participant UI as ChatDetailView
participant R as ChatDetailFeature
participant DB as Realm
participant API as Server API
participant WS as Socket.IO
UI->>R: onAppear
R->>DB: ๋ก์ปฌ ์บ์ ์กฐํ
DB-->>R: ์ ์ฅ๋ ๋ฉ์์ง
R->>R: receivedMessageIds ์ด๊ธฐํ
par API ๋๊ธฐํ์ Socket ์ฐ๊ฒฐ์ ๋์ ์คํ
R->>API: ์ต์ ๋ฉ์์ง ์์ฒญ
R->>WS: AsyncStream ์ฐ๊ฒฐ
end
API-->>R: ์ต์ ๋ฉ์์ง
R->>R: Set ID ์ค๋ณต ์ฒดํฌ ํ ๋ณํฉ
R-->>UI: messages ์
๋ฐ์ดํธ
Note over WS,R: ์ค์๊ฐ ์์
WS->>R: socketMessageReceived
R->>R: receivedMessageIds ์ค๋ณต ์ฒดํฌ
R-->>UI: ๋ฉ์์ง ์ถ๊ฐ
์ฑํ
ํ๋ฉด ์ง์
์ Realm์์ ๋ก์ปฌ ์บ์๋ฅผ ๋จผ์ ํ์ํ๊ณ , API๋ก ์ต์ ๋ฉ์์ง๋ฅผ ๋๊ธฐํํ ๋ค, Socket.IO๋ฅผ ํตํด ์ค์๊ฐ ๋ฉ์์ง๋ฅผ ์์ ํฉ๋๋ค. ์ธ ๊ฐ์ง ๊ฒฝ๋ก์์ ์ ์
๋๋ ๋ฉ์์ง๋ Set<String> ๊ธฐ๋ฐ receivedMessageIds๋ก ์ค๋ณต์ ์ฒดํฌํ์ฌ ๋ฐ์ดํฐ ๋ถ์ผ์น์ ์ค๋ณต ๋ ๋๋ง์ ๋ฐฉ์งํฉ๋๋ค.
sequenceDiagram
participant F as Feature Reducer
participant AC as APIClient
participant INT as AuthInterceptor
participant API as Server
F->>AC: API ์์ฒญ
AC->>API: Request with accessToken
API-->>AC: 419 ํ ํฐ ๋ง๋ฃ
AC->>INT: refresh ํธ์ถ
INT->>API: /auth/refresh with refreshToken
API-->>INT: ์ Access Token + Refresh Token
INT-->>AC: TokenPair ๋ฐํ
AC->>AC: Keychain์ ์ ํ ํฐ ์ ์ฅ
AC->>API: ์๋ ์์ฒญ ์ฌ์๋
API-->>AC: 200 OK
AC-->>F: ์๋ต ์ ๋ฌ
419 ์๋ต์ ๊ฐ์งํ๋ฉด AuthInterceptor(actor)๊ฐ ํ ํฐ ๊ฐฑ์ ์ ์ํํฉ๋๋ค. Swift Concurrency์ actor ๊ฒฉ๋ฆฌ๋ก ๋์ ๊ฐฑ์ ์์ฒญ์ด ์์ ํ๊ฒ ์ง๋ ฌํ๋๋ฉฐ, CheckedContinuation์ผ๋ก ๋๊ธฐ ์ค์ธ ์์ฒญ๋ค์ ์ผ๊ด ์ฒ๋ฆฌํฉ๋๋ค. EyeMon(UIKit)์์๋ ๋ณ๋ Session ๋ถ๋ฆฌ๋ก ๋ฌดํ ๋ฃจํ๋ฅผ ๋ฐฉ์งํ๋ค๋ฉด, EyeMonDoctor(SwiftUI)์์๋ actor + async/await๋ก ๋์์ฑ ๋ฌธ์ ๊น์ง ํด๊ฒฐํ ์ค๊ณ์
๋๋ค. ๊ฐฑ์ ์คํจ(418) ์์๋ ํ ํฐ์ ์ญ์ ํ๊ณ ๊ฐ์ ๋ก๊ทธ์์์ ํธ๋ฆฌ๊ฑฐํฉ๋๋ค.
sequenceDiagram
participant UI as ChatDetailView
participant R as ChatDetailFeature
participant AC as APIClient
participant API as Server
UI->>R: imageSelected ๋๋ pdfSelected
Note over R: isUploading = true
R->>AC: Multipart Upload
AC->>API: POST /chats/roomId/files
API-->>AC: ์
๋ก๋๋ ํ์ผ URL
AC-->>R: uploadFilesResponse
R->>AC: sendMessage with ํ์ผ URL
AC->>API: POST /chats/roomId
API-->>AC: ChatMessageDTO
AC-->>R: sendMessageResponse
R-->>UI: ๋ฉ์์ง ๋ชฉ๋ก์ ์ถ๊ฐ
ํ์ผ ์ ๋ก๋์ ๋ฉ์์ง ์ ์ก์ ๋ถ๋ฆฌํ๋ 2๋จ๊ณ ์ ๋ต์ ์ฑํํ์ต๋๋ค. 1๋จ๊ณ์์ ํ์ผ์ ์๋ฒ์ ์ ๋ก๋ํ์ฌ URL์ ํ๋ํ๊ณ , 2๋จ๊ณ์์ ํด๋น URL์ ํฌํจํ ๋ฉ์์ง๋ฅผ ์ ์กํฉ๋๋ค. ๊ฐ ๋จ๊ณ์ ์๋ฌ๋ฅผ ๋ ๋ฆฝ์ ์ผ๋ก ์ฒ๋ฆฌํ์ฌ, ์ ๋ก๋ ์คํจ์ ์ ์ก ์คํจ๋ฅผ ๋ช ํํ๊ฒ ๊ตฌ๋ถํ ์ ์์ต๋๋ค.
์ด๋ฉ์ผ ์ค๋ณต ํ์ธ๊ณผ ์ ํจ์ฑ ๊ฒ์ฆ(์ด๋ฉ์ผ ํ์, ๋น๋ฐ๋ฒํธ ๊ท์น)์ ๊ฑฐ์ณ ๊ณ์ ์ ์์ฑํฉ๋๋ค.
JWT ๊ธฐ๋ฐ ํ ํฐ์ Keychain์ ์ ์ฅํ๊ณ , 419 ํ ํฐ ๋ง๋ฃ ์ actor ๊ธฐ๋ฐ AuthInterceptor๊ฐ ์๋ ๊ฐฑ์ ํฉ๋๋ค.
๊ฐฑ์ ์คํจ(418) ์์๋ ํ ํฐ์ ์ญ์ ํ๊ณ ๋ก๊ทธ์ธ ํ๋ฉด์ผ๋ก ๊ฐ์ ์ ํํฉ๋๋ค.
iPad NavigationSplitView๋ก ์ฑํ
๋ชฉ๋ก๊ณผ ์์ธ๋ฅผ 2-Column ๋ ์ด์์์ผ๋ก ๋์์ ํ์ํฉ๋๋ค.
Socket.IO๋ฅผ AsyncStream์ผ๋ก ๋ํํ์ฌ TCA์ Effect ์ฒด๊ณ์ ํตํฉํ์ผ๋ฉฐ, cancellable(id:)๋ก ํ๋ฉด ์ดํ ์ ์์ผ ์ฐ๊ฒฐ์ ์๋ ์ ๋ฆฌํฉ๋๋ค.
ํ
์คํธ, ์ด๋ฏธ์ง, PDF ํ์ผ ์ ์ก์ ์ง์ํ๋ฉฐ, Realm ๋ก์ปฌ ์บ์ฑ์ผ๋ก ์คํ๋ผ์ธ์์๋ ์ด์ ๋ํ๋ฅผ ์ฆ์ ํ์ํฉ๋๋ค.
HLS(m3u8) ๊ธฐ๋ฐ ๋น๋์ค ์คํธ๋ฆฌ๋ฐ์ ํ๋กํ ํ๋ฉด์์ ์ธ๋ผ์ธ ์ฌ์ํฉ๋๋ค. ์ ํด VOD ์น์ ์์ ์ฒซ ๋ฒ์งธ ์์์ ์๋ ๋ก๋ํ๊ณ , ์ฌ์/์ ์ง ์ ์ด๋ฅผ ์ ๊ณตํฉ๋๋ค.
์์ฌ ํ๋กํ ์ ๋ณด(์ด๋ฆ, ์ด๋ฉ์ผ, ์๊ฐ)๋ฅผ ํ์ํ๋ฉฐ, ์์ฝ ํ์ ์๋ฅผ ์ฑํ ๋ฐฉ ๋ชฉ๋ก๊ณผ ์ฐ๋ํ์ฌ ์ค์๊ฐ์ผ๋ก ํ์ํฉ๋๋ค. ๋ก๊ทธ์์ ์ Keychain ํ ํฐ๊ณผ UserDefaults ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์์ ํ๊ฒ ์ญ์ ํฉ๋๋ค.
@Test ๋งคํฌ๋ก ๊ธฐ๋ฐ์ผ๋ก 10๊ฐ์ Unit Test๋ฅผ ๊ตฌํํ์ต๋๋ค. withDependencies๋ก Mock์ ์ฃผ์
ํ๊ณ , TestStore๋ฅผ ํตํด Action โ State ๋ณํ๋ฅผ ๋จ๊ณ๋ณ๋ก ๊ฒ์ฆํฉ๋๋ค. LockIsolated๋ฅผ ํ์ฉํ์ฌ Swift 6 Concurrency ํ๊ฒฝ์์๋ Side Effect๋ฅผ ์์ ํ๊ฒ ์บก์ฒํฉ๋๋ค.
๋ฌธ์ ์ฑํ ํ๋ฉด์์ DB ์บ์ ๋ฉ์์ง, API ๋๊ธฐํ ๋ฉ์์ง, Socket ์ค์๊ฐ ๋ฉ์์ง๊ฐ ๋์์ ์ ์ ๋๋ฉด์ ๋์ผํ ๋ฉ์์ง๊ฐ ์ค๋ณต์ผ๋ก ํ์๋๋ ํ์์ด ๋ฐ์ํ์ต๋๋ค. ํนํ Socket ๋ฉ์์ง๊ฐ API ์๋ต๋ณด๋ค ๋จผ์ ๋์ฐฉํ๋ ๊ฒฝ์ฐ, ๊ฐ์ ๋ฉ์์ง๊ฐ 2๋ฒ ๋ ๋๋ง๋์์ต๋๋ค.
์์ธ ์ธ ๊ฐ์ง ๋ฐ์ดํฐ ์์ค(DB/API/Socket)๊ฐ ๊ฐ๊ฐ ๋ ๋ฆฝ์ ์ผ๋ก ๋ฉ์์ง๋ฅผ State์ ์ถ๊ฐํ๋ฉด์, ๋ฉ์์ง ID ๊ธฐ๋ฐ ์ค๋ณต ์ฒดํฌ๊ฐ ๋๋ฝ๋ ๊ฒฝ๋ก๊ฐ ์กด์ฌํ์ต๋๋ค.
ํด๊ฒฐ
State์ receivedMessageIds: Set<String>์ ๋๊ณ , ๋ชจ๋ ๋ฉ์์ง ์ ์
๊ฒฝ๋ก(DB/API/Socket/์ ์ก ์ฑ๊ณต)์์ ID ์ค๋ณต ์ฌ๋ถ๋ฅผ ํ์ธํ ํ ์ถ๊ฐํ๋๋ก ํต์ผํ์ต๋๋ค.
case .socketMessageReceived(let dto):
guard !state.receivedMessageIds.contains(dto.chatId) else {
return .none // ์ด๋ฏธ ์์ ํ ๋ฉ์์ง๋ ๋ฌด์
}
state.receivedMessageIds.insert(dto.chatId)
state.messages.append(Message(dto: dto))๋ฌธ์ ์ฌ๋ฌ API ์์ฒญ์ด ๋์์ 419๋ฅผ ๋ฐ์ผ๋ฉด ํ ํฐ ๊ฐฑ์ ์ด ์ค๋ณต ํธ์ถ๋์ด, ์ด๋ฏธ ๊ฐฑ์ ๋ Refresh Token์ผ๋ก ์ฌ๊ฐฑ์ ์ ์๋ํ๋ฉด์ 418(๊ฐ์ ๋ก๊ทธ์์)์ด ๋ฐ์ํ๋ ๋ฌธ์ ๊ฐ ์์์ต๋๋ค.
์์ธ async/await ํ๊ฒฝ์์ ์ฌ๋ฌ Task๊ฐ ๋์์ 419 ์๋ต์ ๋ฐ์ผ๋ฉด, ๊ฐ๊ฐ ๋ ๋ฆฝ์ ์ผ๋ก ๊ฐฑ์ API๋ฅผ ํธ์ถํ์ฌ Race Condition์ด ๋ฐ์ํ์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๊ฐฑ์ ์ด ์ฑ๊ณตํ๋ฉด Refresh Token์ด ๊ต์ฒด๋๋ฏ๋ก, ๋๋จธ์ง ์์ฒญ์ ๊ฐฑ์ ์ ๋ง๋ฃ๋ ํ ํฐ์ผ๋ก ์๋๋์ด ์คํจํฉ๋๋ค.
ํด๊ฒฐ
AuthInterceptor๋ฅผ Swift actor๋ก ๊ตฌํํ์ฌ ๊ฐฑ์ ์์ฒญ์ ์๋ ์ง๋ ฌํํ์ต๋๋ค. ์ฒซ ๋ฒ์งธ ์์ฒญ๋ง ์ค์ ๊ฐฑ์ ์ ์ํํ๊ณ , ๋๋จธ์ง ์์ฒญ์ CheckedContinuation์ผ๋ก ๋๊ธฐ์ํจ ๋ค ๊ฐฑ์ ์๋ฃ ์ ์ผ๊ด ์ฒ๋ฆฌํฉ๋๋ค.
actor AuthInterceptor {
private var isRefreshing = false
private var pendingContinuations: [CheckedContinuation<TokenPair, Error>] = []
func refresh(...) async throws -> TokenPair {
if isRefreshing {
// ์ด๋ฏธ ๊ฐฑ์ ์ค์ด๋ฉด ๋๊ธฐ
return try await withCheckedThrowingContinuation { continuation in
pendingContinuations.append(continuation)
}
}
isRefreshing = true
// ์ค์ ๊ฐฑ์ ์ํ โ ๋๊ธฐ ์ค์ธ continuation๋ค ์ผ๊ด resume
}
}๋ฌธ์
Socket.IO์ ์ฝ๋ฐฑ ๊ธฐ๋ฐ ์ด๋ฒคํธ ํธ๋ค๋ง์ TCA์ Effect<Action> ์ฒด๊ณ์ ํตํฉํ๋ ๊ฒ์ด ๊ณผ์ ์์ต๋๋ค. ์ง์ ์ ์ธ ์ฝ๋ฐฑ ์ฌ์ฉ์ TCA์ ๋จ๋ฐฉํฅ ๋ฐ์ดํฐ ํ๋ฆ์ ๊นจ๋จ๋ฆฌ๋ ๊ตฌ์กฐ์์ต๋๋ค.
์์ธ
Socket.IO๋ on("chat") { data, ack in } ํํ์ ์ฝ๋ฐฑ API๋ง ์ ๊ณตํ์ฌ, TCA Reducer์ Effect โ Action ํ์ดํ๋ผ์ธ์ ์ง์ ์ฐ๊ฒฐํ ์ ์์์ต๋๋ค.
ํด๊ฒฐ
Socket.IO ์ด๋ฒคํธ๋ฅผ AsyncStream<ChatMessageDTO>๋ก ๋ํํ์ฌ, TCA์ .run Effect์์ for await ๋ฃจํ๋ก ๋ฉ์์ง๋ฅผ ์์ ํฉ๋๋ค. cancellable(id:)๋ฅผ ์ ์ฉํ์ฌ ํ๋ฉด ์ดํ ์ ์์ผ ์ฐ๊ฒฐ์ ์๋ ์ ๋ฆฌํฉ๋๋ค.
.run { [roomId, socketClient, keychain] send in
guard let token = keychain.getAccessToken() else { return }
let stream = socketClient.connect(roomId, token)
for await dto in stream {
await send(.socketMessageReceived(dto))
}
}
.cancellable(id: CancelID.socketStream)๋ฌธ์
์ด๊ธฐ ๊ตฌํ์์ SwiftUI View๊ฐ @Dependency(\.keychainClient)๋ก Keychain์ ์ง์ ์ ๊ทผํ๊ณ ์์์ต๋๋ค. TCA์ "View๋ State๋ง ์ฝ๋๋ค" ์์น์ ์๋ฐฐ๋๋ฉฐ, TestStore๋ก ํด๋น ๋ฐ์ดํฐ๋ฅผ ๊ฒ์ฆํ ์ ์๋ ๊ตฌ์กฐ์์ต๋๋ค.
์์ธ
TCA์ @Dependency๋ Reducer ๋ด๋ถ์์ ์ฌ์ฉํ๋๋ก ์ค๊ณ๋์ด ์์ผ๋, SwiftUI View์์๋ ์ ๊ทผ์ด ๊ฐ๋ฅํ๊ธฐ ๋๋ฌธ์ ํธ์์ View์์ ์ง์ ํธ์ถํ๋ ์ฝ๋๊ฐ ์์ฑ๋์์ต๋๋ค. ์ด๋ก ์ธํด ๋ฐ์ดํฐ ํ๋ฆ์ด State โ View ๋จ๋ฐฉํฅ์ด ์๋, Dependency โ View ์ฐํ ๊ฒฝ๋ก๊ฐ ์๊ฒผ์ต๋๋ค.
ํด๊ฒฐ
View์ @Dependency๋ฅผ ๋ชจ๋ ์ ๊ฑฐํ๊ณ , Reducer์ onAppear์์ ํ์ํ ๋ฐ์ดํฐ๋ฅผ State์ ์ ์ฅํ๋ ๋ฐฉ์์ผ๋ก ์ ํํ์ต๋๋ค. ์ด๋ฅผ ํตํด ๋ฐ์ดํฐ ํ๋ฆ์ด State โ View ๋จ๋ฐฉํฅ์ผ๋ก ํต์ผ๋๊ณ , TestStore์์ ๋ชจ๋ ์ํ๋ฅผ ๊ฒ์ฆํ ์ ์๊ฒ ๋์์ต๋๋ค.
// Reducer
case .onAppear:
state.accessToken = keychain.getAccessToken()
state.currentUserId = userDefaults.getUserId()
// View โ State์์๋ง ์ฝ๊ธฐ
ProfileImageView(accessToken: store.accessToken)EyeMonDoctor/
โโ EyeMonDoctorApp.swift # @main ์ง์
์
โโ Features/
โ โโ App/ # AppFeature (์ธ์ฆ ๋ถ๊ธฐ), MainTabFeature
โ โโ Auth/ # LoginFeature, SignUpFeature
โ โโ ChatList/ # ChatListFeature (๋ชฉ๋ก + NavigationSplitView)
โ โโ ChatDetail/ # ChatDetailFeature (Socket + Realm + ํ์ผ ์ ์ก)
โ โโ Profile/ # ProfileFeature (VOD ์คํธ๋ฆฌ๋ฐ)
โโ Core/
โ โโ Network/ # APIClient, APIRouter, APIError
โ โ โโ DTOs/ # Auth, Chat, Video, User, ServerError DTO
โ โ โโ Interceptor/ # AuthInterceptor (actor ๊ธฐ๋ฐ ํ ํฐ ๊ฐฑ์ )
โ โโ Socket/ # SocketClient (AsyncStream ๋ํ)
โ โโ Clients/ # KeychainClient, UserDefaultsClient, VideoClient
โ โโ Database/ # RealmClient, ChatMapper, Realm Models
โ โโ DesignSystem/ # ColorSystem, Typography, ProfileImageView
โโ Models/ # User, ChatRoom, Message, Video
โโ Resource/ # Assets, Fonts (Pretendard)
โโ Secret/ # APIConfig (.gitignore)
EyeMonDoctorTests/
โโ AppFeatureTests.swift # ์ธ์ฆ ๋ถ๊ธฐ ํ
์คํธ (2๊ฐ)
โโ ChatListFeatureTests.swift # ์ฑํ
๋ชฉ๋ก ํ
์คํธ (4๊ฐ)
โโ LoginFeatureTests.swift # ๋ก๊ทธ์ธ ํ
์คํธ (4๊ฐ)
โโ Helpers/
โโ TestFixtures.swift # ํ
์คํธ์ฉ ์ํ ๋ฐ์ดํฐ
โโ DTOEncodable+Test.swift # DTO ์ธ์ฝ๋ฉ ํฌํผ
์ด ํ๋ก์ ํธ๋ ๊ฐ์ธ ํฌํธํด๋ฆฌ์ค ํ๋ก์ ํธ์ด๋ฉฐ, ์์ ์ ์ฌ์ฉ์ ๊ธ์งํฉ๋๋ค.







