스쿱 트러블 슈팅 - API로부터 도메인을 안전하게 지키기

2025. 2. 17. 14:35·프로젝트 일지

본 포스팅은 음악을 쉽게 담을 수 있게 도와주는 '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 변화에 안전해지고, 모듈 내부에선 명확한 목적 분리를 통한 효율 및 가독성 개선을 이끌어낼 수 있게 되었습니다.

저작자표시 (새창열림)

'프로젝트 일지' 카테고리의 다른 글

캐플 리팩토링 네 번째 이야기 - Repository 모듈 만들기  (0) 2025.02.24
스쿱 트러블 슈팅 - 음악 추정 시간으로 정확도 개선하기  (1) 2025.02.17
스쿱 트러블 슈팅 - 유연하고 구조적인 정규표현식 만들기  (0) 2025.02.17
캐플 리팩토링 세 번째 이야기 - 트러블 슈팅  (2) 2025.02.07
캐플 리팩토링 두 번째 이야기 - 프로젝트 세팅하기  (0) 2025.01.30
'프로젝트 일지' 카테고리의 다른 글
  • 캐플 리팩토링 네 번째 이야기 - Repository 모듈 만들기
  • 스쿱 트러블 슈팅 - 음악 추정 시간으로 정확도 개선하기
  • 스쿱 트러블 슈팅 - 유연하고 구조적인 정규표현식 만들기
  • 캐플 리팩토링 세 번째 이야기 - 트러블 슈팅
thinkyside
thinkyside
스스로에게 솔직해지고 싶은 공간
  • thinkyside
    또 만드는 한톨
    thinkyside
  • 전체
    오늘
    어제
    • 모아보기 (58) N
      • 솔직해보려는 회고 (1)
      • 꾸준히 글쓰기 (9)
      • 생각을 담은 독서 (6)
      • 내게 필요한 개발 공부 (22)
      • 트러블슈팅 (4)
      • 프로젝트 일지 (8)
      • 개발 서적 (3)
      • 취준 (3) N
      • 대외활동 (1)
      • UXUI (1)
  • hELLO· Designed By정상우.v4.10.3
thinkyside
스쿱 트러블 슈팅 - API로부터 도메인을 안전하게 지키기
상단으로

티스토리툴바