네트워크 계층 — 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는 흔들리지 않으며, 테스트 가능한 구조가 완성됩니다.
'Dev Study > iOS' 카테고리의 다른 글
| 앱 시작 속도 개선(App Launch Optimization) (0) | 2025.11.14 |
|---|---|
| Transition / Animation 최적화 (0) | 2025.11.14 |
| AutoLayout — Hugging / Compression Resistance 이해하기 (0) | 2025.11.14 |
| 모듈화(Modularization)로 빌드 속도 최적화 (0) | 2025.11.14 |
| Swift Concurrency — 구조적 동시성 완전 이해 (0) | 2025.11.14 |
| 메모리 누수 방지 — weak / unowned 정확히 사용하기 (0) | 2025.11.14 |
| UIKit ↔ SwiftUI 혼용 시 Life-Cycle 이해하기 (0) | 2025.11.14 |
| ViewController 비만화 방지 — 비즈니스 로직 분리 (0) | 2025.11.14 |

