반응형

네트워크 계층 — DTO / Domain 분리

 

1. 들어가며

iOS 실무에서 가장 빠르게 망가지는 영역이 바로 네트워크 계층(Network Layer)입니다.
많은 프로젝트가 초기에는 간단했지만 점점 기능이 늘면서 아래와 같은 문제가 발생합니다.

  • ViewController에서 직접 API 호출 → 유지보수 지옥
  • JSON 구조가 변경되면 앱 전체가 깨짐
  • 재사용 불가능한 API 코드
  • 테스트 불가능한 구조
  • Domain과 DTO가 섞여서 역할이 모호해짐

이를 해결하는 핵심 개념이 바로 DTO / Domain 분리입니다.

 

2. DTO / Domain 분리를 왜 해야 하는가?


2.1 서버 응답 구조는 언제든 바뀐다

서버의 JSON 형식은 새로운 필드가 추가되거나 타입이 변경될 수 있다.

이걸 그대로 UI나 로직에서 사용하면 앱이 깨진다.

2.2 Domain Model은 앱 내부 불변 규칙

앱 내부에서 사용하는 모델은 다음과 같은 특징이 있다.
- 앱의 비즈니스 로직 규칙을 따른다

- 앱이 필요로 하는 구조만 유지

- 서버 변경과 무관하게 UI와 비즈니스 로직이 안정됨 

2.3 테스트 가능성 향상

Repository를 Mock 버전으로 대체하여 테스트 가능.

 

3. 네트워크 계층 전체 구조

Presentation
 └── View / ViewModel
        ↓
Domain
 └── UseCase (비즈니스 로직)
        ↓
Data
 ├── Repository (Domain Interface)
 ├── RepositoryImpl (구현체)
 ├── DTO (서버 응답 전용)
 ├── Mapper (DTO → Domain)
 └── APIClient (Alamofire / URLSession)

 

4. 실무 예제 — 유저 프로필 조회 API


4.1 서버 응답(JSON)

{
    "user_id": 123,
    "nickname": "까칠코더",
    "profile_image": "https://....",
    "created_at": "2023-10-01T12:00:00"
}

 

5. DTO 정의

DTO는 서버 응답 구조 그대로 매핑한다.

struct UserProfileDTO: Decodable {
    let user_id: Int
    let nickname: String
    let profile_image: String
    let created_at: String
}

 

6. Domain Model 정의

Domain Model은 “앱 내부에서 실제로 사용하는 형태”로 변환한다.

struct UserProfile {
    let id: Int
    let name: String
    let imageURL: URL?
    let createdAt: Date
}

 

7. Mapper — DTO → Domain 변환

extension UserProfileDTO {
    func toDomain() -> UserProfile {
        UserProfile(
            id: user_id,
            name: nickname,
            imageURL: URL(string: profile_image),
            createdAt: ISO8601DateFormatter().date(from: created_at) ?? Date()
        )
    }
}

 

8. Repository Interface

Domain에서는 Repository의 존재만 알고, 구현은 모름.

protocol UserRepository {
    func fetchUserProfile(id: Int) async throws -> UserProfile
}

 

9. Repository 구현

final class UserRepositoryImpl: UserRepository {

    private let api: APIClient

    init(api: APIClient) {
        self.api = api
    }

    func fetchUserProfile(id: Int) async throws -> UserProfile {
        let dto = try await api.request(
            endpoint: "/user/\(id)",
            method: .get,
            responseType: UserProfileDTO.self
        )
        return dto.toDomain()
    }
}

 

10. API Client — URLSession 기반

final class APIClient {

    func request(
        endpoint: String,
        method: HTTPMethod,
        responseType: T.Type
    ) async throws -> T {

        let url = URL(string: "https://api.server.com\(endpoint)")!
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue

        let (data, _) = try await URLSession.shared.data(for: request)

        return try JSONDecoder().decode(T.self, from: data)
    }
}

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
}

 

11. UseCase — Domain 비즈니스 로직

protocol FetchUserProfileUseCase {
    func execute(id: Int) async throws -> UserProfile
}

final class FetchUserProfileUseCaseImpl: FetchUserProfileUseCase {

    private let repo: UserRepository

    init(repo: UserRepository) {
        self.repo = repo
    }

    func execute(id: Int) async throws -> UserProfile {
        return try await repo.fetchUserProfile(id: id)
    }
}

 

12. ViewModel — UI 바인딩

@MainActor
final class UserViewModel: ObservableObject {

    @Published var profile: UserProfile?
    @Published var isLoading = false

    private let useCase: FetchUserProfileUseCase

    init(useCase: FetchUserProfileUseCase) {
        self.useCase = useCase
    }

    func load(id: Int) {
        Task {
            isLoading = true
            defer { isLoading = false }

            do {
                let data = try await useCase.execute(id: id)
                profile = data
            } catch {
                print("Error: \(error)")
            }
        }
    }
}

 

13. DTO / Domain 분리의 장점


13.1 서버 변경에 영향 최소화

API 변경이 발생해도 DTO와 Mapper만 바꾸면 됨.

13.2 UI 코드가 안정됨

Domain Model은 앱 내부 규칙만 따르므로 UI 변경 X.

13.3 테스트 작성 용이

Repository를 Mock으로 대체하면 네트워크 없는 상태에서도 테스트 가능.

 

14. Mock Repository 예시

final class MockUserRepository: UserRepository {
    func fetchUserProfile(id: Int) async throws -> UserProfile {
        UserProfile(id: id, name: "테스트", imageURL: nil, createdAt: Date())
    }
}

Unit test에서 매우 유용.

 

15. 실무에서 발생하는 나쁜 사례(안티패턴)


❌ DTO를 그대로 UI에서 사용

let dto = try await api.fetch()
label.text = dto.nickname

— 서버 필드 삭제/변경 시 UI 전체 크래시


❌ ViewModel에서 직접 JSON parsing

let decoded = try JSONDecoder().decode(UserProfileDTO.self, from: data)

→ Domain layer와 UI layer가 얽힘


❌ 서버 응답을 그대로 캐싱

Domain Model을 캐싱해야 안전하다.

 

16. DTO / Domain 분리 체크리스트

  •  UI에서는 Domain 모델만 사용
  •  DTO는 Data 계층 내부에서만 사용
  •  Mapper는 분리
  •  Repository Interface는 Domain에 위치
  •  Repository 구현은 Data 계층
  •  ViewModel은 UseCase만 호출
  •  테스트는 Mock Repository 기반으로 작성

 

17. 결론

네트워크 계층을 DTO/Domain으로 분리하는 것은 iOS 실무 유지보수 품질에서 가장 중요한 요소 중 하나입니다.
분리가 되어 있으면 서버 변경이 와도 UI는 흔들리지 않으며, 테스트 가능한 구조가 완성됩니다.

반응형
Posted by 까칠코더
,