Skip to content

Reimos7/EyeMon

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

92 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EyeMon App Icon

EyeMon

안과 의료진 매칭부터 PG 결제 기반 1:1 채팅 상담, HLS VOD 스트리밍까지 — 비대면 의료 서비스의 전 과정을 구현한 안구 질환 헬스케어 솔루션


Screenshots

로그인 병원 상세
로그인 홈 병원 상세
실시간 채팅 채팅 목록 VOD 재생 화면
실시간 채팅 채팅 목록 VOD 재생
리뷰 게시판 예약 및 결제 내역 마이페이지
리뷰 게시판 예약 및 결제 마이페이지

Tech Stack

분류 기술 선택 이유
UI UIKit (Code-based) SnapKit 기반 코드 UI, 홈 화면 CompositionalLayout + 나머지 FlowLayout
Architecture MVVM-C (Coordinator) Coordinator로 화면 전환 분리, Input/Output 패턴으로 단방향 데이터 흐름
Reactive RxSwift / RxCocoa UI 이벤트 ↔ 네트워크 바인딩, ViewModel 테스트 용이성 확보
Network Alamofire URLRequestConvertible 기반 APIRouter, AuthInterceptor 419 자동 갱신
Realtime Socket.IO Namespace 기반(/chats-{roomId}) 채팅방별 독립 연결 관리
Auth Apple Sign In, Kakao SDK Identity Token / OAuth Token → 서버 검증 → 자체 JWT 발급
Media AVPlayer (HLS) m3u8 스트리밍, 480p/720p/1080p 화질 전환 + WebVTT 자막
Storage Realm + Keychain 채팅 메시지 로컬 캐싱(Realm), 토큰 보안 저장(Keychain)
Payment iamport SDK PG사 결제 → impUid 서버 영수증 검증, Repository Pattern 분리
Image Kingfisher 이미지 캐싱 및 다운샘플링으로 메모리 최적화

Architecture

1. MVVM-C + Repository

graph TB
    subgraph Coordinator Layer
        CO[Coordinator]
    end

    subgraph View Layer
        VC[ViewController]
    end

    subgraph ViewModel Layer
        VM[ViewModel]
        IN[Input - Observable]
        OUT[Output - Driver/Signal]
    end

    subgraph Domain Layer
        REPO[Repository Protocol]
    end

    subgraph Data Layer
        IMPL[Repository Implementation]
        NET[NetworkService]
        DB[RealmDatabase]
        SOCK[SocketIOManager]
    end

    CO -->|create & inject| VC
    VC -->|navigation delegate| CO
    VC -->|User Action| IN
    IN --> VM
    VM --> OUT
    OUT -->|UI Binding| VC

    VM --> REPO
    REPO --> IMPL
    IMPL --> NET
    IMPL --> DB
    IMPL --> SOCK
Loading

SceneDelegateAppCoordinator로 시작되며, 토큰 유무에 따라 로그인 또는 메인 탭을 분기합니다. AppCoordinatorHomeCoordinator, ReviewCoordinator 등 자식 Coordinator를 생성하고, 각 Coordinator가 ViewController를 생성하며 자신을 weak 프로퍼티로 주입합니다. ViewController는 화면 전환 시 Coordinator에 위임하여 ViewController 간 직접 참조를 제거했습니다. ViewModel은 Input(Observable)Output(Driver/Signal) 변환만 담당하며 Repository Protocol에만 의존하므로, 테스트 시 Mock으로 교체하여 네트워크 없이 검증할 수 있습니다.

// Coordinator Protocol
protocol Coordinator: AnyObject {
    var navigationController: UINavigationController { get }
    var childCoordinators: [Coordinator] { get set }
    func start()
}

// ViewController → Coordinator 위임 (화면 전환 로직 분리)
weak var coordinator: HomeCoordinator?
coordinator?.showEstateDetail(estateId: id)

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

sequenceDiagram
    participant UI as ChatDetailVC
    participant VM as ChatDetailVM
    participant DB as Realm
    participant API as Server API
    participant WS as Socket.IO

    UI->>VM: viewDidLoad
    VM->>DB: 로컬 캐시 조회
    DB-->>VM: 저장된 메시지
    VM->>API: 마지막 메시지 이후 동기화
    API-->>VM: 최신 메시지
    VM->>VM: 병합 + 중복 제거 (Set<ID>)
    VM-->>UI: messagesRelay 업데이트

    Note over WS,VM: 실시간 수신
    WS->>VM: 새 메시지 이벤트
    VM->>VM: appendMessageIfNeeded()
    VM-->>UI: 메시지 추가 + 스크롤
Loading

채팅 화면 진입 시 Realm에서 로컬 캐시를 먼저 표시하고, API로 최신 메시지를 동기화한 뒤, Socket.IO를 통해 실시간 메시지를 수신합니다. 세 가지 경로에서 유입되는 메시지는 Set<ID> 기반 중복 체크를 거쳐 BehaviorRelay<[Message]> 하나에 통합됩니다. 이 Single Source of Truth 구조로 데이터 불일치와 중복 렌더링을 방지합니다.


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

sequenceDiagram
    participant App as ViewModel
    participant AF as Alamofire Session
    participant INT as AuthInterceptor
    participant API as Server

    App->>AF: API 요청
    AF->>API: Request
    API-->>AF: 419 (토큰 만료)
    AF->>INT: retry() 호출
    INT->>API: /auth/refresh (별도 Session)
    API-->>INT: 새 Access Token
    INT->>INT: TokenManager.saveTokens()
    INT->>AF: .retry (원래 요청 재시도)
    AF->>API: 원래 요청 (새 토큰)
    API-->>AF: 200 OK
    AF-->>App: 응답 전달
Loading

419 응답을 감지하면 AuthInterceptor가 Interceptor 미적용 별도 Session()으로 토큰 갱신 API를 호출합니다. 같은 Session을 사용할 경우 갱신 요청이 다시 Interceptor를 타면서 무한 루프에 빠지는 것을 방지하기 위한 설계입니다. 갱신 성공 시 원래 요청을 자동 재시도하고, 갱신 실패(418) 시에는 토큰을 삭제하고 강제 로그아웃을 트리거합니다.


4. 결제 검증 플로우 (iamport + Server Validation)

sequenceDiagram
    participant UI as PaymentVC
    participant VM as PaymentVM
    participant PG as iamport PG사
    participant API as Server

    UI->>VM: viewDidAppear
    VM-->>UI: startPayment (결제 정보 전달)
    UI->>PG: PG 결제 요청
    PG-->>UI: 결제 완료 (impUid)
    UI->>VM: paymentCompleted(impUid)
    VM->>API: 영수증 검증 요청 (impUid + orderCode)
    API-->>VM: PaymentValidation 응답
    VM-->>UI: validationResult (검증 성공)
Loading

화면 진입 시 ViewModel이 결제 정보(주문 코드, 상품명, 금액)를 UI에 전달하면, iamport SDK가 PG사 결제 화면을 표시합니다. 결제 완료 후 PG사로부터 받은 impUid를 서버에 전달하여 영수증 검증을 수행합니다. 결제 로직은 OrderRepository Protocol로 분리하여, Unit Test에서 Mock을 통해 성공/실패 시나리오를 독립적으로 검증할 수 있습니다.


5. VOD 스트리밍 플로우 (HLS + 화질/자막)

sequenceDiagram
    participant UI as VideoPlayerVC
    participant VM as VideoPlayerVM
    participant PM as PlayerManager
    participant API as Server

    UI->>VM: viewDidLoad (videoId)
    VM->>API: 스트림 URL 요청
    API-->>VM: m3u8 URL + 화질 목록 + 자막 목록
    VM-->>UI: 화질/자막 옵션 전달
    VM->>PM: loadVideo(url, qualities, subtitles)
    PM-->>UI: 영상 재생 시작

    Note over UI,PM: 화질 변경 시
    UI->>VM: qualitySelected(720p)
    VM->>PM: currentTime 저장 → URL 교체
    PM->>PM: readyToPlay 후 seek(to: savedTime)
    PM-->>UI: 이어서 재생
Loading

videoId로 서버에 HLS 스트림 URL을 요청하면, m3u8 주소와 함께 사용 가능한 화질(auto/480p/720p/1080p) 및 자막(한/영/일) 목록을 받습니다. 화질 변경 시 현재 재생 위치를 CMTime으로 저장한 뒤 새 URL을 로딩하고, readyToPlay 상태 확인 후 저장된 위치로 seek하여 끊김 없는 재생을 보장합니다.


Key Features

1. 로그인 (이메일 회원가입 / 카카오 / Apple)

이메일 회원가입, 카카오 OAuth, Apple Sign In 3가지 방식을 지원합니다. Apple 로그인은 ASAuthorizationController에서 Identity Token을 추출하고, 서버에서 검증 후 자체 JWT를 발급받는 구조입니다. 카카오 로그인은 앱 설치 여부를 감지하여 앱 로그인과 웹 로그인을 자동으로 분기합니다.

2. 실시간 채팅

Socket.IO Namespace 기반으로 채팅방별 독립 연결을 관리합니다. 텍스트 메시지, 이미지, PDF 파일 전송을 지원하며, 파일은 Multipart 업로드 후 URL을 메시지로 전송하는 2단계 구조입니다. Realm 로컬 캐싱으로 오프라인 진입 시에도 이전 대화를 즉시 표시합니다.

3. VOD 스트리밍

HLS(m3u8) 기반 비디오 스트리밍을 지원합니다. auto/480p/720p/1080p 화질 전환과 한국어/영어/일본어 WebVTT 자막 선택 기능을 제공합니다.

4. 진료 예약 및 결제

의사 프로필 조회 → 날짜/시간 선택 → iamport PG 결제 → 서버 영수증 검증까지의 전체 플로우를 구현했습니다. 결제 검증은 Repository Pattern으로 분리하여 Mock 기반 Unit Test가 가능합니다.

5. 마이페이지 및 리뷰

프로필 수정, 예약 내역 조회, 나의 활동(작성 리뷰) 확인 기능을 제공합니다.


Troubleshooting

1. 채팅 메시지 중복 — Single Source of Truth 패턴 적용

문제 채팅 화면에서 DB 캐시 메시지, API 동기화 메시지, Socket 실시간 메시지가 동시에 유입되면서 동일한 메시지가 중복으로 표시되는 현상이 발생했습니다. 특히 Socket 메시지가 API 응답보다 먼저 도착하는 경우, 시간차로 인해 같은 메시지가 2번 렌더링되었습니다.

원인 세 가지 데이터 소스(DB/API/Socket)가 각각 독립적으로 메시지를 messagesRelay에 추가하면서, 메시지 ID 기반 중복 체크가 누락된 경로가 존재했습니다.

해결 BehaviorRelay<[Message]>를 Single Source of Truth로 설정하고, Set<String>으로 수신된 메시지 ID를 관리하는 구조를 설계했습니다. 모든 메시지 유입 경로(DB/API/Socket/전송 성공)에서 appendMessageIfNeeded() 헬퍼를 통해 중복 체크 후 추가하도록 통일하여, 3곳에 분산되어 있던 중복 로직을 하나의 메서드로 응집시켰습니다.

private func appendMessageIfNeeded(_ message: Message, scroll: Bool) {
    guard !receivedMessageIds.contains(message.id) else { return }
    var currentMessages = messagesRelay.value
    currentMessages.append(message)
    messagesRelay.accept(currentMessages)
    receivedMessageIds.insert(message.id)
    if scroll { scrollToBottomRelay.accept(()) }
}

2. 토큰 갱신 시 무한 루프 — 별도 Session 분리

문제 AuthInterceptor에서 419 응답을 감지해 토큰을 갱신하는데, 갱신 API 자체도 같은 Alamofire Session을 사용하면 갱신 요청이 다시 Interceptor를 타면서 무한 루프에 빠지는 위험이 있었습니다.

원인 Alamofire의 RequestInterceptor가 Session 단위로 적용되므로, 토큰 갱신 요청도 동일한 Session에서 실행되면 419 → 재갱신 → 419 무한 루프가 발생합니다.

해결 토큰 갱신 전용 refreshSession을 Interceptor가 없는 별도의 Session()으로 생성하여, 갱신 요청이 retry 로직을 우회하도록 설계했습니다. 갱신 실패(418) 시에는 저장된 토큰을 모두 삭제하고 NotificationCenter를 통해 강제 로그아웃을 트리거합니다.

// AuthInterceptor 내부
private let refreshSession: Session

init() {
    // 토큰 갱신용 별도 세션 (무한 루프 방지)
    self.refreshSession = Session()
}

func retry(_ request: Request, ...) {
    guard let statusCode = response?.statusCode, statusCode == 419 else {
        completion(.doNotRetry)
        return
    }
    // refreshSession으로 토큰 갱신 → 재시도
}

3. HLS 화질 전환 시 재생 위치 유실 — CMTime 기반 위치 보존

문제 VOD 재생 중 사용자가 화질을 변경하면, AVPlayer가 새로운 m3u8 URL로 교체되면서 재생 위치가 처음으로 돌아가는 문제가 있었습니다. 사용자가 30분 시청 후 화질을 변경했을 때 처음부터 다시 재생되는 것은 치명적인 UX 문제였습니다.

원인 AVPlayer의 replaceCurrentItem(with:) 호출 시 새 AVPlayerItem이 로드되면 재생 헤드가 0초로 초기화되는 기본 동작입니다.

해결 화질 변경 시점의 currentTime()을 CMTime으로 저장하고, 새 URL 로딩 후 seek(to:) 호출로 이전 위치를 복원하는 전략을 적용했습니다. AVPlayerItem의 .readyToPlay 상태를 KVO로 관찰하여, 버퍼링 완료 후에만 seek을 실행함으로써 타이밍 이슈를 방지했습니다.


4. Repository Pattern 도입으로 ViewModel 테스트 가능성 확보

문제 초기 구현에서 ViewModel이 NetworkService를 직접 호출하는 구조였기 때문에, Unit Test 시 실제 서버 통신이 필요했습니다. 네트워크 상태에 따라 테스트가 불안정하고, 특정 에러 시나리오를 재현하기 어려웠습니다.

원인 ViewModel → NetworkService 직접 의존으로 인해 의존성 역전 원칙(DIP)이 지켜지지 않아, 구현체 교체가 불가능한 구조였습니다.

해결 Protocol 기반 Repository 계층을 도입하여 ViewModel과 데이터 소스 사이의 의존성을 역전시켰습니다. 테스트 시에는 stubbedResult로 응답을 주입하고, callCount로 호출 횟수를 검증하는 Mock Repository(Stub + Spy)를 사용합니다. 이를 통해 네트워크 없이도 성공/실패/에지 케이스를 안정적으로 검증할 수 있게 되었습니다.

// Mock Repository (Stub + Spy 패턴)
final class MockVideoRepository: VideoRepositoryProtocol {
    var stubbedFetchVideosResult: Single<VideoList> = .never()
    var fetchVideosCallCount = 0

    func fetchVideos(next: String?, limit: Int) -> Single<VideoList> {
        fetchVideosCallCount += 1
        return stubbedFetchVideosResult
    }
}

5. 파일 업로드 실패 시 메시지 유실 — 2단계 전송 전략과 에러 격리

문제 채팅에서 이미지/PDF를 전송할 때, 파일 업로드와 메시지 전송을 하나의 API 호출로 처리하면 대용량 파일 업로드 실패 시 메시지 자체가 유실되는 문제가 있었습니다. 또한 업로드 진행 중 네트워크가 불안정해지면, 어느 단계에서 실패했는지 파악하기 어려웠습니다.

원인 파일 업로드와 메시지 전송이 단일 API로 결합되어, 파일 업로드 실패 시 메시지 전송 자체가 불가능한 구조였습니다.

해결 파일 업로드(Multipart)와 메시지 전송을 분리하는 2단계 전략을 채택했습니다. 1단계에서 파일을 서버에 업로드하여 URL을 획득하고, 2단계에서 해당 URL을 포함한 메시지를 전송합니다. 각 단계의 에러를 독립적으로 처리하여, 업로드 실패 시에는 "파일 업로드 실패", 메시지 전송 실패 시에는 "전송 실패"로 명확하게 사용자에게 피드백합니다. 파일 타입은 Data의 매직 바이트를 검사하여 JPEG/PNG/PDF를 자동 감지합니다.

// 2단계 전송: 업로드 → 메시지 전송
chatRepository.uploadFiles(roomId: roomId, files: imageDataArray)
    .flatMap { uploadResult in
        // 1단계 성공: 파일 URL 획득
        chatRepository.sendMessage(roomId: roomId, files: uploadResult.files)
    }
    .subscribe(onSuccess: { [weak self] chatDTO in
        // 2단계 성공: 메시지 전송 완료
        let message = chatDTO.toDomain()
        self?.appendMessageIfNeeded(message, scroll: true)
    })

Project Structure

EyeMon/
├─ Application/               # AppDelegate, SceneDelegate
├─ Coordinators/
│  ├─ CoordinatorProtocol.swift  # Coordinator 프로토콜 + 기본 구현
│  ├─ AppCoordinator.swift       # 루트 Coordinator (인증 분기 + 탭 생성)
│  ├─ Home/                      # HomeCoordinator
│  └─ Review/                    # ReviewCoordinator
├─ Features/
│  ├─ Auth/                   # 로그인 / 회원가입 (VC + VM)
│  ├─ Home/                   # 홈 화면 (VC + VM + Cells)
│  ├─ Detail/                 # 병원 상세 (DetailCoordinator + VC + VM)
│  ├─ Chat/                   # 채팅 목록 / 상세 (VC + VM + Cells)
│  ├─ VideoPlayer/            # VOD 플레이어 (VC + VM)
│  ├─ Payment/                # 결제 (VC + VM)
│  ├─ Reservation/            # 예약 (VC + VM + Cell)
│  ├─ Review/                 # 리뷰 + 댓글 (VC + VM + Cells)
│  └─ Profile/                # 마이페이지 (VC + VM)
├─ Services/
│  ├─ Network/                # AuthInterceptor, NetworkService, APIRouter
│  ├─ Socket/                 # SocketIOManager
│  └─ Database/               # RealmManager
├─ Data/
│  └─ Repositories/           # Repository Protocol + 구현체
├─ Domain/                    # Domain Models + Mappers
├─ Models/                    # DTO 모델
├─ Common/
│  ├─ Base/                   # BaseViewModel, ViewModelType, ViewDesignProtocol
│  ├─ DesignSystem/           # AppColor, EyeMonFont
│  └─ Utils/                  # Extensions
├─ Secret/                    # APIConfig (.gitignore)
└─ Resources/                 # Assets, Fonts

License

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

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages