Repository Pattern에 대해서

devming
17 min readDec 9, 2020

--

본 글은 Repository Pattern in Swift을 해석 및 재해석 했습니다.

이 아티클에 관심을 가지게 된 계기

회사에서 클린 아키텍처에 관심을 가지게 된 이후로, 여러가지 구현에 대한 예시와 클린 아키텍처에 대한 설명들을 보았는데, Data Layer에서 Repository Pattern을 활용하여 레이어를 나누는 예시를 많이 접하게 되었습니다.

그래서 Repository Pattern에 대해 더 알아보고자 이 아티클을 통해 공부하게 되었습니다.

본 글은 이 아티클을 완벽하게 해석한다기 보다는 저의 생각을 넣어 해석 후 재가공 하였습니다.

본론에 앞선 배경

모든 앱은 크던, 작던, 이미지나 텍스트나 어떠한 형태로든 데이터를 가지고 있습니다.

그리고 그 데이터는 일반적으로 서버를 통해 통신하여 CRUD 작업을 수행합니다.

이러한 데이터들은 모두 다른 포맷을 가지고 있고, 우리는 이 데이터를 일련의 구조로 만들어 사용합니다.

이렇게 만들어진 구조화된 데이터들은 모두 각기 다른 struct 혹은 class의 형태로 가지고 있게 되고, Repository Pattern을 사용하지 않은 앱은 일반적으로 ViewController나 ViewModel에서 직접 이 데이터를 가지게 됩니다.

Repository Pattern은 이런 배경들에 대해 충분히 공감을 해야합니다.

문제가 무엇일까요?

그렇다면 이렇게 ViewController와 ViewModel에 서버로부터 받은 데이터를 직접 저장하는 것이 어떤 문제가 있기에 그러는 걸까요?

왜 이게 문제가 있고 굳이 Repository Pattern이라는 것을 써야한다고 할까요?

문제는 바로 유지보수가 점점 힘들어 진다는 점입니다.

앱이 화면이 몇 개 없는 소규모일 때는 별로 힘들지 않죠.

그러나 점점 앱이 커지고, 화면이 점점 많아지며, 하나의 앱에 개발자가 점점 늘어난다면 문제가 되어갑니다.

이 문제를 알아보기 위해 한 가지 예시를 들어보겠습니다.

로컬DB에 저장하기 위해 CoreData를 사용하던 앱이 있습니다. 어느날, 안드로이드와 비슷한 로직으로 관리하기 위해, Realm으로 갈아탄다고 해볼게요.

그러면 NSManagedObject 객체로 덕지덕지 도배가 되어있는 앱을 고친다면 과연 어떤 일이 일어날까요??

앱이 크면 클수록, 에러로 인해 Build조차 하지 못하는 시간이 매우 커질겁니다.

이와 비슷하게, 네트워크를 통해 API call로 받아온 JSON Data를 가지고 있는 경우도 마찬가지입니다.

만약 이렇게 Codable을 conform해서 만든 객체를 앱 전역에서 사용하고 있는데, 만약 서버단에서 이 API에 내려주는 구조가 바뀐다면 어떻게 될까요?

이것도 다 바꿔줘야겠죠?

뿐만 아니라, 서드파티 라이브러리나 프레임워크를 사용하는 경우도 마찬가지입니다. 서드파티 라이브러리에서 제공하는 데이터가 바뀌고 이를 그대로 앱에서 사용하는 경우엔, 모든것을 바꿔줘야하는 치명적인 이슈가 발생하게 되는 것입니다.

위의 이미지는 Core Data를 사용한 앱의 예시입니다.

Data Access 클래스에서 Core Data Stack에 접근하고 있습니다.

그리고 여기서 CRUD되는 데이터들을 직접 각각의 ViewModel과 ViewController에서 사용하게 되는 예시입니다.

만약 위와 같은 상황에서 ViewModel과 ViewController가 훨씬더 많아져서 50개정도 되는데, 여기서 Realm으로 바꾼다면…. 정말 상상도 하기 싫네요..

Domain Objects와 Repository를 분리해야 합니다.

Domain Objects라는 것은 해당 앱에서 정의하는 데이터 구조라고 볼 수 있습니다.

도메인 객체는 앱에서 정말 보여질 때 사용하는 객체라고 생각해도 무방할 것 같습니다.

우리는 Repository를 사용하여 도메인 객체에 Mapping을 해줘야 하는 것입니다.

이렇게 해주게 되면 위에서 제시한 여러가지 문제상황들이 왔을 때, Repository 부분만 바꿔주면 되고, 이것을 우리가 정의해 놓았던 Domain Objects에 Mapping만 해주면 되는 일이기 때문에, 데이터에 대해 다루지 않는 앱 내부에서 사용하고 있는 객체들을 일일이 수정을 할 일이 없어지게 됩니다.

이게 바로 Repository Pattern의 핵심입니다.

요약하자면 다음과 같습니다.

Repository는 데이터 소스에 접근하기 위해 캡슐화된 구성요소입니다. 데이터에 접근하는 기능을 한군데에 집중시켜서, 유지보수에 용이하고, 데이터 구조에 대한 의존성 낮추게 됩니다. 이로써 앱이 데이터를 다루는 곳과 이 데이터를 표현하는 곳으로 명확하게 나뉘게 되는 것입니다.

이를 Repository Pattern을 활용하여 나누게 되면 다음과 같이 됩니다.

이 그림에서는 CoreData Stack에 Repository 패턴을 활용하여 접근하는데, NSManagedObject를 사용하여 앱의 Presentation Layer에서 직접 사용하는 것이 아니라, 필요한 Domain Object를 별도로 만들어서 NSManagedObject에서 뽑아온 객체를 각 Domain Object에 맞게 맵핑해주게 됩니다.

샘플 프로젝트

무료 API를 호출해서 API로 데이터를 불러오는 예제를 구현해볼 예정입니다.

제가 참조한 원문에서는 샘플 예제가 간단한 플레이 그라운드로만 나와있기 때문에 저는 직접 샘플 프로젝트로 만들어 봤습니다.

Repository Pattern을 사용하지 않은 예제

먼저 Repository Pattern을 사용하지 않고 구현을 해보겠습니다.

struct User: Decodable {
let id: Int
let name, username, email: String
let address: Address
let phone, website: String
let company: Company
}
struct Address: Decodable {
let street, suite, city, zipcode: String
let geo: Geo
}
struct Geo: Decodable {
let lat, lng: String
}
struct Company: Decodable {
let name, catchPhrase, bs: String
}
struct Users: Decodable {
let results: [Result]
let info: Info
}
struct Info: Decodable {
let seed: String
let results: Int
let page: Int
let version: String
}
struct Result: Decodable {
let gender: String
let name: Name
let location: Location
let email: String
let login: Login
let dob: Dob
let registered: Dob
let phone: String
let cell: String
let id: ID
let picture: Picture
let nat: String
}
struct Dob: Decodable {
let date: String
let age: Int
}
struct ID: Decodable {
let name: String
let values: String?
}
struct Location: Decodable {
let street: Street
let city: String
let state: String
let country: String
let postcode: Int
let coordinates: Coordinates
let timezone: Timezone
}
struct Coordinates: Decodable {
let latitude: String
let longitude: String
}
struct Street: Decodable {
let number: Int
let name: String
}
struct Timezone: Decodable {
let offset: String
let timezoneDescription: String

enum CodingKeys: String, CodingKey {
case offset
case timezoneDescription = "description"
}
}
struct Login: Decodable {
let uuid, username, password, salt: String
let md5, sha1, sha245: String
}
struct Name: Decodable {
let title, first, last: String
}
struct Picture: Decodable {
let large, medium, thumbnail: String
}

그리고 다음과 같은 UserApiService 클래스가 있습니다.

class UserApiService {
let firstUrl = URL(string: "https://jsonplaceholder.typicode.com/users/1")!

func fetchUserInfo(completionHandler: @escaping (User?) -> Void){
URLSession.shared.dataTask(with: firstUrl) { (user: User?, response, error) in

if let error = error {
print(error.localizedDescription)
return
}

if let user = user {
print("\(user.name)")
print("\(user.address.street)")
print("\(user.address.city)")
print("\(user.address.zipcode)")
print("\(user.address.geo.lat)")
print("\(user.address.geo.lng)")
}
completionHandler(user)
}.resume()
}
}

위의 코드는 첫번째 URL에서 받아온 경우의 코드입니다.

만약 API 구조가 변경되어 두번째 URL에서 받아오는 것처럼 바뀌는 경우가 생겼다고 가정해보겠습니다.

그럼 이 구조는 다음과 같이 바뀝니다.

class UserApiService {
let secondUrl = URL(string: "https://randomuser.me/api/")!
func fetchUserInfo(completionHandler: @escaping (Users?) -> Void) {
URLSession.shared.dataTask(with: secondUrl) { (user: Users?, response, error) in

if let error = error {
print(error.localizedDescription)
return
}

if let user = user?.results.first {
print("\(user.name)")
print("\(user.location.street)")
print("\(user.location.city)")
print("\(user.location.postcode)")
print("\(user.location.coordinates.latitude)")
print("\(user.location.coordinates.longitude)")
}
completionHandler(user)
}.resume()
}
}

차이가 보이시나요??

user를 똑같이 넘기긴 하지만 안에 있는 DTO가 달라서 completionHandler로 넘겨준 뒤에, 이를 활용해야하는 Presentation Layer로 넘어가게 되면 수정해야 할 것들이 겉잡을 수 없이 많아지게 됩니다.

Repository Pattern을 사용해보자

드디어.. Repository Pattern을 활용할 차례가 되었습니다.

Repository Pattern에서는 Repository로 분류된 곳에서 DTO를 사용하고, Presentation Layer에서는 Domain Object 를 사용하게 됩니다.

즉, Repository Pattern에서는 Domain Object를 별도로 구성해야합니다.

위에서 보여준 예제를 토대로 Domain Object인 DomainUser구조체를 구성해보겠습니다.

struct DomainUser {
let name: String
let street: String
let city: String
let postcode: String
let latitude: String
let longitude: String
}

앱에서 필요한 도메인 정보를 객체로 담아보았습니다.

그리고 UserApiService에서 request에 대한 응답으로 DomainUser를 넘겨주는 코드를 구현해볼게요.

  • 아래는 첫번째 URL에 대한 구현입니다.
class UserApiService: Repository {

typealias T = DomainUser

let firstUrl = URL(string: "https://jsonplaceholder.typicode.com/users/1")!
func fetchUserInfo(completionHandler: @escaping (DomainUser?, Error?) -> Void) {
URLSession.shared.dataTask(with: firstUrl) { (user: User?, response, error) in

if let error = error {
print(error.localizedDescription)
return
}

guard let user = user else {
completionHandler(nil, RepositoryError.notFound)
return
}

let domainUser = DomainUser(name: user.name,
street: user.address.street,
city: user.address.city,
postcode: user.address.zipcode,
latitude: user.address.geo.lat,
longitude: user.address.geo.lng)

print("\(domainUser.name)")
print("\(domainUser.street)")
print("\(domainUser.city)")
print("\(domainUser.postcode)")
print("\(domainUser.latitude)")
print("\(domainUser.longitude)")

completionHandler(domainUser, nil)
}.resume()
}
}
  • 아래 코드는 두번째 URL에 대한 구현입니다.
class UserApiService: Repository {

typealias T = DomainUser

let secondUrl = URL(string: "https://randomuser.me/api/")!
func fetchUserInfo(completionHandler: @escaping (DomainUser?, Error?) -> Void) {
URLSession.shared.dataTask(with: secondUrl) { (user: Users?, response, error) in

if let error = error {
print(error.localizedDescription)
return
}

guard let user = user?.results.first else {
completionHandler(nil, RepositoryError.notFound)
return
}

let domainUser = DomainUser(name: "\(user.name.first) \(user.name.last)",
street: user.location.street.name,
city: user.location.city,
postcode: "\(user.location.postcode)",
latitude: user.location.coordinates.latitude,
longitude: user.location.coordinates.longitude)

print("\(domainUser.name)")
print("\(domainUser.street)")
print("\(domainUser.city)")
print("\(domainUser.postcode)")
print("\(domainUser.latitude)")
print("\(domainUser.longitude)")

completionHandler(domainUser, nil)
}.resume()
}
}

여기서 주목해야할 점은 completionHandler로 데이터응답을 전달할 때는 domainUser객체를 전달 한다는 점입니다.

이 덕분에 DomainObject하나로 PresentationLayer와 독립적인 데이터가 만들어지게 되는 것입니다.

결국 위에서 본 이 그림이 완성 되게 되는 것이죠

결론

지금까지 Repository Pattern에 대해 알아보았습니다.

데이터를 가져오는 곳(Data Source)과 데이터를 표현하는 곳이 강하게 결합되어 있는 문제를 Domain Object를 디자인하고 Repository에서 이를 변환함으로써 Decoupling 할 수 있는 것을 보았습니다.

이것이 Repository Pattern의 가장 큰 장점이고 사용하는 이유가 되겠네요.

원문에서 제시한 장점과 단점을 끝으로 글 마무리 하겠습니다.

장점

  • 데이터의 구조를 전환해야하는 경우 코드가 잘 분리되어 있어서 변경이 쉽다.
  • 데이터가 변경되더라도 Data Source를 제외한 앱의 나머지 부분에서 일일이 대처할 필요가 없다.

단점

  • 코드가 많아지고 복잡해진다.
  • 각 객체별로 모두 Domain Object에 맵핑하는 추가 작업이 필요하다.
  • 작은 프로젝트에서는 크게 필요하지 않을 수도 있다.

끝 👻

Reference

--

--