본 포스팅은 음악을 쉽게 담을 수 있게 도와주는 'Sqoop 스쿱' 프로젝트의 트러블 슈팅 내용을 기록했습니다.
YTPlaylistExtractor
는 Youtube Data API의 영상 및 댓글 정보를 가공해 비즈니스 로직을 수행합니다. 각 API는 여러 메타데이터를 포함하고 있고 이를 다르게 해석하면, 모듈 및 프로젝트의 목적과 상관없는 데이터 또한 포함하고 있다는 말이기도 합니다. 아래는 비디오 정보를 반환하는 API의 Response이며, 이 중 실제로 사용되는 데이터는 ⭐ 표시 해두었습니다.
{
"kind": "youtube#videoListResponse",
"etag": "YxiDxVDo0f0RJfjjW6MknQNQm9s",
"items": [
{
"kind": "youtube#video",
"etag": "yUHN4wej1im4qxp3lBFeYdzuZOM",
"id": "BtS_3ek3KfY",
"snippet": {
⭐ "publishedAt": "2024-09-30T00:15:00Z",
⭐ "channelId": "UCVut4hqvrjQC4qDE3oc5qig",
⭐ "title": "𝐏𝐥𝐚𝐲𝐥𝐢𝐬𝐭 오늘은 안나갈래. 뒹굴뒹굴 방안에서 노래를 틀었어",
⭐ "description": "어쩌구저쩌구 영상 설명",
"thumbnails": {
"default": {
"url": "https://i.ytimg.com/vi/BtS_3ek3KfY/default.jpg",
"width": 120,
"height": 90
},
"medium": {
⭐ "url": "https://i.ytimg.com/vi/BtS_3ek3KfY/mqdefault.jpg",
"width": 320,
"height": 180
},
"high": {
"url": "https://i.ytimg.com/vi/BtS_3ek3KfY/hqdefault.jpg",
"width": 480,
"height": 360
},
"standard": {
"url": "https://i.ytimg.com/vi/BtS_3ek3KfY/sddefault.jpg",
"width": 640,
"height": 480
},
"maxres": {
"url": "https://i.ytimg.com/vi/BtS_3ek3KfY/maxresdefault.jpg",
"width": 1280,
"height": 720
}
},
⭐ "channelTitle": "때껄룩ᴛᴀᴋᴇ ᴀ ʟᴏᴏᴋ",
"tags": [
"플레이리스트",
"playlist"
],
"categoryId": "10",
"liveBroadcastContent": "none",
"localized": {
"title": "𝐏𝐥𝐚𝐲𝐥𝐢𝐬𝐭 오늘은 안나갈래. 뒹굴뒹굴 방안에서 노래를 틀었어",
"description": ""
},
"defaultAudioLanguage": "ko"
}
}
],
"pageInfo": {
"totalResults": 1,
"resultsPerPage": 1
}
}
위 JSON Response에서 필요한 값만 Decoding 하기 위해 아래와 같은 타입을 만들 수 있습니다. JSON 데이터 구조와 정확히 동일한 구조의 타입입니다.
// MARK: - YTVideoDTO
public struct YTVideoInfo: Codable {
public let items: [YTVideoInfo_Item]
}
// MARK: - Item
public struct YTVideoInfo_Item: Codable {
public let snippet: YTVideoInfo_Snippet
}
// MARK: - Snippet
public struct YTVideoInfo_Snippet: Codable {
public let publishedAt: String
public let channelId: String
public let title: String
public let description: String
public let thumbnails: YTVideoInfo_Thumbnails
public let channelTitle: String
}
// MARK: - Thumbnails
public struct YTVideoInfo_Thumbnails: Codable {
public let medium: YTVideoInfo_Default
}
// MARK: - Default
public struct YTVideoInfo_Default: Codable {
public let url: String
public let width: Int
public let height: Int
}
위 타입을 이용해 값을 전달하는 방식은 기능 구현 상으로 큰 문제 없이 동작할 수 있습니다. 하지만 이는 외부 API에 강하게 의존하고 있는(의존할 수 밖에 없는) 형태로, 여러 문제를 발생시킬 수 있습니다.
1. 데이터 구조를 예측하기 어려움.
모듈 외부에서 YTVideoInfo
를 반환하는 함수를 사용해 영상 제목을 받아오는 예시입니다. YTVideoInfo
라는 이름 자체에서 알 수 있듯, 비디오 정보를 반환하는 목적의 객체로 개발자는 자연스럽게 도메인에 필요한 속성들을 찾게 됩니다.
public func fetchYTVideoInfo() -> YTVideoInfo { // ... }
let extractor = Extractor()
let videoInfo = extractor.fetchYTVideoInfo()
videoInfo. // 영상의 제목을 찾기 위해 .을 찍은 상황
분명 영상 제목을 의미하는 속성명을 기대하며 .
을 찍어봤지만, items
라는 배열 값이 미리보기로 표시됩니다. 모듈을 구현한 사람 혹은 JSON 데이터 구조를 알고 있거나 타입을 직접 보는 것으로 사용 방법을 알 수 있겠지만, 이는 분명 추가 리소스를 발생시킬 가능성이 있습니다. 결과적으로 영상 제목을 얻기 위해선 다음과 같은 코드를 작성해야 합니다.
func fetchVideoTitle(ytVideoInfo: YTVideoInfo) -> String {
guard let firstVideoInfo = ytVideoInfo.items.first else {
return ""
}
return firstVideovideoInfo.snippet.title
}
JSON 데이터 구조와 동일한 형태로 타입이 만들어졌기 때문에, items
, snippet
등 과 같은 내부 구조를 캡슐화하는 속성을 이용해 title
에 접근하고 있습니다. 외부의 구체적인 타입 구조를 알아야만 코드를 이해하고, 사용할 수 있습니다. 이는 테스트를 위한 mock 객체 생성에도 어려움을 만들 수 있습니다.
2. 외부 API의 변경이 직접적으로 내부 비즈니스 로직에 영향을 줄 수 있음.
데이터 구조 예측의 어려움보다 큰 문제는, 내부 비즈니스 로직에 영향을 줄 수 있다는 것입니다. Youtube Data API의 Response가 달라지거나, 다른 API로의 교체가 필요한 경우 이는 예상보다 큰 변경 비용을 초래할 수 있습니다. 기존의 영상 정보를 여러개 반환하는 API의 비용 문제로 ‘한 개’만 반환하는 API로 변경해야하는 상황의 예시입니다. (기능 상으로는 동일한) 기존 Response와 달라진 점은, itmes
속성이 없어졌다는 것입니다.
{
"etag": "yUHN4wej1im4qxp3lBFeYdzuZOM",
"snippet": {
"publishedAt": "2024-09-30T00:15:00Z",
"channelId": "UCVut4hqvrjQC4qDE3oc5qig",
"title": "𝐏𝐥𝐚𝐲𝐥𝐢𝐬𝐭 오늘은 안나갈래. 뒹굴뒹굴 방안에서 노래를 틀었어",
"description": "",
"thumbnails": {
"medium": {
"url": "https://i.ytimg.com/vi/BtS_3ek3KfY/mqdefault.jpg",
"width": 320,
"height": 180
},
},
"channelTitle": "때껄룩ᴛᴀᴋᴇ ᴀ ʟᴏᴏᴋ",
}
}
위 Response를 Decoding 하기 위해선, 기존 타입을 아래와 같이 변경해야 합니다.
// MARK: - 변경 전
public struct YTVideoInfo: Codable {
public let items: [YTVideoInfo_Item]
}
public struct YTVideoInfo_Item: Codable {
public let snippet: YTVideoInfo_Snippet
}
public struct YTVideoInfo_Snippet: Codable {
public let publishedAt: String
public let channelId: String
public let title: String
public let description: String
public let thumbnails: YTVideoInfo_Thumbnails
public let channelTitle: String
}
public struct YTVideoInfo_Thumbnails: Codable {
public let medium: YTVideoInfo_Default
}
public struct YTVideoInfo_Default: Codable {
public let url: String
public let width: Int
public let height: Int
}
// MARK: - 변경 후
public struct YTVideoInfo: Codable {
public let snippet: YTVideoInfo_Snippet
}
public struct YTVideoInfo_Snippet: Codable {
public let publishedAt: String
public let channelId: String
public let title: String
public let description: String
public let thumbnails: YTVideoInfo_Thumbnails
public let channelTitle: String
}
public struct YTVideoInfo_Thumbnails: Codable {
public let medium: YTVideoInfo_Default
}
public struct YTVideoInfo_Default: Codable {
public let url: String
public let width: Int
public let height: Int
}
YTVideoInfo
타입을 모듈 및 프로젝트 곳곳에서 사용하고 있었다면 수많은 에러가 발생할 것입니다. 기존의 배열 형태로 접근하던 것이, snippet
으로 접근하는 방식으로 변경되었기 때문입니다. 이는 간단할 수 있는 예시지만, 더 복잡하고 어려운 형태의 요구사항이였다면(속성 자체가 사라지거나, 구조 상 위치 변동이 있을 때) 변경 비용은 예상보다 훨씬 커질 수 있게됩니다.
3. 도메인 목적에 맞는 형태로 사용, 확장의 어려움.
기존 YTVideoInfo
객체는 목적 상 필요한 데이터인 영상 제목, 게시 날짜, 채널 ID, 채널 제목, 설명, 썸네일 URL을 반환하고 있습니다. 만약 목적이 변경되거나, 새로운 목적의 객체가 필요해졌을 때 YTVideoInfo
는 변경되어야 합니다. 항상 필요한 것은 아니지만 많은 데이터를 포함할 수 있도록 속성을 추가하거나, YTVideoTagInfo
와 같은 완전히 새로운 객체를 생성해야 합니다.
// MARK: - 새로운 속성을 추가하는 예시
public struct YTVideoInfo: Codable {
public let items: [YTVideoInfo_Item]
}
public struct YTVideoInfo_Item: Codable {
public let snippet: YTVideoInfo_Snippet
}
public struct YTVideoInfo_Snippet: Codable {
public let publishedAt: String
public let channelId: String
public let title: String
public let description: String
public let thumbnails: YTVideoInfo_Thumbnails
public let channelTitle: String
public let tags: [String] // ⭐️ 새로운 속성 추가
}
public struct YTVideoInfo_Thumbnails: Codable {
public let medium: YTVideoInfo_Default
}
public struct YTVideoInfo_Default: Codable {
public let url: String
public let width: Int
public let height: Int
}
// MARK: - 새로운 타입을 생성하는 예시
public struct YTVideoTagInfo: Codable {
public let items: [YTVideoTagInfo_Item]
}
public struct YTVideoTagInfo_Item: Codable {
public let snippet: YTVideoTagInfo_Snippet
}
public struct YTVideoTagInfo_Snippet: Codable {
public let tags: [String]
}
속성 추가 시, 항상 모든 값을 받아오기 때문에 잘못된 사용에 주의해야 하고, 새로운 타입 생성 시, 기존 타입과 중복된 구조를 사용함으로써 관리 및 확장의 어려움을 만들어냅니다. 위와 같은 문제가 발생한 이유는, 데이터를 받아오는 목적과 데이터를 사용하는 목적이 혼재되어 하나의 타입으로 표현하려 했다는 것입니다.
이를 해결하고자 두 목적을 명확히 할 수 있도록 타입 분리를 시도했습니다. 데이터를 받아오는 목적은 외부에 의존할 수 밖에 없음으로, 변경되기 쉬운 저수준 타입으로 분류할 수 있었고, 데이터를 사용하는 목적은 비즈니스 로직과 연관되어 있었기 때문에 변경되기 어려운 고수준 타입으로 분류할 수 있었습니다.
이를 보편적으로 사용하는 용어 DTO(Data-Transfer-Object)
와 Entity
로 표현했고, 아래 코드 형태로 리팩토링 할 수 있었습니다.
// MARK: - DTO(데이터를 받아오는 목적, 외부에서 알 필요가 없는 구체적인 타입)
// ⭐️ internal 접근 제어자를 사용함으로써, 모듈 외부에는 노출시키지 않음.
struct YTVideoInfoDTO: Codable {
let etag: String
let items: [YTVideoInfoDTO_Item]
}
struct YTVideoInfoDTO_Item: Codable {
let etag: String
let id: String
let snippet: YTVideoInfoDTO_Snippet
}
struct YTVideoInfoDTO_Snippet: Codable {
let publishedAt: String
let channelId: String
let title: String
let description: String
let thumbnails: YTVideoInfoDTO_Thumbnails
let channelTitle: String
}
struct YTVideoInfoDTO_Thumbnails: Codable {
let medium: YTVideoInfoDTO_Default
}
struct YTVideoInfoDTO_Default: Codable {
let url: String
let width: Int
let height: Int
}
// MARK: - Entity(데이터를 사용하는 목적, 변경 되기 어려운 비즈니스 로직을 위한 구조)
public struct YTVideoInfo: Sendable {
/// 플레이리스트 URL
public let urlString: String
/// 영상을 게시한 채널 아이디
public let channelId: String
/// 영상을 게시한 채널명
public let channelTitle: String
/// 영상 제목
public let videoTitle: String
/// 영상 썸네일 URL
public let thumbnailUrl: String
/// 영상 게시 날짜
public let publishedAt: Date
/// 영상 설명
public let description: String
}
데이터를 받아올 때, 즉 YouTube Data API를 사용할 때는 DTO
타입으로 Decoding하고, 이를 프로젝트의 비즈니스 로직에 사용하기 적합한 형태의 Entity
로 변환 후 반환함으로써 두 목적을 분리할 수 있었습니다.
public func fetchYTVideoInfo() -> YTVideoInfo {
let dto = await fetchYTVideoInfoDTO() // 1. 실제 데이터 받아오기(API 호출)
let entity = convertToVideoInfo(from: dto) // 2. DTO를 Entity로 변환
return entity // 3. Entity 반환
}
타입을 목적에 맞게 분리함으로써 얻을 수 있는 이점에 대해 정리하겠습니다.
1. 데이터 구조를 예측하기 쉬움.
- 기존의 복잡한 구조와 다르게 비즈니스 로직에 특화되어 있기 때문에, 예측 및 사용이 쉬워집니다.
- 테스트를 위한 mock 객체 생성이 쉬워집니다.
2. 외부 API 변경에 대한 대응이 유연해짐.
- 어떤 형태의 불안정한 데이터가 API로 넘어오더라도, 서비스 핵심 개념을 나타내는
Entity
를 반환하기 때문에, 내부 비즈니스 로직으로의 영향이 크게 줄어듭니다. - 외부 API가 변경되더라도,
DTO
및 변환 과정의 함수를 수정하는 것만으로 변화 대응이 가능합니다.
3. 도메인 목적에 맞게 사용 및 확장이 쉬워짐.
- 데이터를 받아오는 목적의
DTO
는 하나로 유지하고, 데이터를 사용하는 목적의Entity
는 쪼갬으로써 여러 타입으로의 사용 및 확장이 쉬워집니다.
결과적으로, 모듈을 사용하는 프로젝트 내에서는 DTO
와 같은 구체적인 ‘데이터를 받아오는 방법’을 알지 못해도 비즈니스 로직에 특화된 Entity
를 사용이 가능합니다. 이에 따라 프로젝트는 모듈 및 외부 API 변화에 안전해지고, 모듈 내부에선 명확한 목적 분리를 통한 효율 및 가독성 개선을 이끌어낼 수 있게 되었습니다.
'iOS > 프로젝트 일지' 카테고리의 다른 글
캐플 리팩토링 네 번째 이야기 - Repository 모듈 만들기 (0) | 2025.02.24 |
---|---|
스쿱 트러블 슈팅 - 음악 추정 시간으로 정확도 개선하기 (1) | 2025.02.17 |
스쿱 트러블 슈팅 - 유연하고 구조적인 정규표현식 만들기 (0) | 2025.02.17 |
캐플 리팩토링 세 번째 이야기 - 트러블 슈팅 (2) | 2025.02.07 |
캐플 리팩토링 두 번째 이야기 - 프로젝트 세팅하기 (0) | 2025.01.30 |