Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

27 Commits

Folders and files

Repository files navigation

EyeMonDoctor App Icon

EyeMonDoctor

안과 의료진 전용 iPad 앱 — 환자와의 실시간 채팅, 이미지·PDF 파일 공유, VOD 스트리밍이 가능한 의료진 전용 비대면 진료 솔루션


Screenshots

로그인 (가로) 로그인 (세로)
Simulator Screenshot - iPad Pro 11-inch (M5) - 2026-02-08 at 22 07 31 Simulator Screenshot - iPad Pro 11-inch (M5) - 2026-02-08 at 22 08 27
회원가입 (가로) 회원가입 (세로)
Simulator Screenshot - iPad Pro 11-inch (M5) - 2026-02-08 at 22 07 45 Simulator Screenshot - iPad Pro 11-inch (M5) - 2026-02-08 at 22 08 12
채팅 (가로) 채팅 (세로)
Simulator Screenshot - iPad Pro 11-inch (M5) - 2026-02-08 at 23 06 27 Simulator Screenshot - iPad Pro 11-inch (M5) - 2026-02-08 at 23 06 19
프로필 (가로) 프로필 (세로)
Simulator Screenshot - iPad Pro 11-inch (M5) - 2026-02-08 at 22 10 29 Simulator Screenshot - iPad Pro 11-inch (M5) - 2026-02-08 at 22 10 16

Tech Stack

분류 기술 선택 이유
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 안전 검증

Architecture

1. TCA 단방향 데이터 흐름

%%{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"]
Loading

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 ... }
    }
}

2. 채팅 메시지 로딩 전략 (3-Stage Loading)

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: 메시지 추가
Loading

채팅 화면 진입 시 Realm에서 로컬 캐시를 먼저 표시하고, API로 최신 메시지를 동기화한 뒤, Socket.IO를 통해 실시간 메시지를 수신합니다. 세 가지 경로에서 유입되는 메시지는 Set<String> 기반 receivedMessageIds로 중복을 체크하여 데이터 불일치와 중복 렌더링을 방지합니다.


3. 토큰 갱신 플로우 (AuthInterceptor - actor)

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: 응답 전달
Loading

419 응답을 감지하면 AuthInterceptor(actor)가 토큰 갱신을 수행합니다. Swift Concurrency의 actor 격리로 동시 갱신 요청이 안전하게 직렬화되며, CheckedContinuation으로 대기 중인 요청들을 일괄 처리합니다. EyeMon(UIKit)에서는 별도 Session 분리로 무한 루프를 방지했다면, EyeMonDoctor(SwiftUI)에서는 actor + async/await로 동시성 문제까지 해결한 설계입니다. 갱신 실패(418) 시에는 토큰을 삭제하고 강제 로그아웃을 트리거합니다.


4. 파일 업로드 전략 (2-Stage Upload)

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: 메시지 목록에 추가
Loading

파일 업로드와 메시지 전송을 분리하는 2단계 전략을 채택했습니다. 1단계에서 파일을 서버에 업로드하여 URL을 획득하고, 2단계에서 해당 URL을 포함한 메시지를 전송합니다. 각 단계의 에러를 독립적으로 처리하여, 업로드 실패와 전송 실패를 명확하게 구분할 수 있습니다.


Key Features

1. 이메일 로그인 / 회원가입

이메일 중복 확인과 유효성 검증(이메일 형식, 비밀번호 규칙)을 거쳐 계정을 생성합니다. JWT 기반 토큰을 Keychain에 저장하고, 419 토큰 만료 시 actor 기반 AuthInterceptor가 자동 갱신합니다. 갱신 실패(418) 시에는 토큰을 삭제하고 로그인 화면으로 강제 전환합니다.

2. 실시간 채팅 (NavigationSplitView)

iPad NavigationSplitView로 채팅 목록과 상세를 2-Column 레이아웃으로 동시에 표시합니다. Socket.IO를 AsyncStream으로 래핑하여 TCA의 Effect 체계와 통합했으며, cancellable(id:)로 화면 이탈 시 소켓 연결을 자동 정리합니다. 텍스트, 이미지, PDF 파일 전송을 지원하며, Realm 로컬 캐싱으로 오프라인에서도 이전 대화를 즉시 표시합니다.

3. VOD 스트리밍

HLS(m3u8) 기반 비디오 스트리밍을 프로필 화면에서 인라인 재생합니다. 제휴 VOD 섹션에서 첫 번째 영상을 자동 로드하고, 재생/정지 제어를 제공합니다.

4. 프로필 및 설정

의사 프로필 정보(이름, 이메일, 소개)를 표시하며, 예약 환자 수를 채팅방 목록과 연동하여 실시간으로 표시합니다. 로그아웃 시 Keychain 토큰과 UserDefaults 사용자 정보를 안전하게 삭제합니다.

5. Unit Test (Swift Testing + TCA TestStore)

@Test 매크로 기반으로 10개의 Unit Test를 구현했습니다. withDependencies로 Mock을 주입하고, TestStore를 통해 Action → State 변화를 단계별로 검증합니다. LockIsolated를 활용하여 Swift 6 Concurrency 환경에서도 Side Effect를 안전하게 캡처합니다.


Troubleshooting

1. 채팅 메시지 중복 — Set 기반 중복 제거

문제 채팅 화면에서 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))

2. 토큰 갱신 동시 요청 — actor + CheckedContinuation

문제 여러 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
    }
}

3. Socket.IO + TCA 통합 — AsyncStream 래핑

문제 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)

4. View에서 @Dependency 직접 접근 — State 기반 전환

문제 초기 구현에서 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)

Project Structure

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 인코딩 헬퍼

License

이 프로젝트는 개인 포트폴리오 프로젝트이며, 상업적 사용을 금지합니다.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages