프로토콜 초기 구현을 통해 Repository에서 DTO를 Entity로 변환하는 코드를 개선한 과정을 기록한다.
아래 코드는 댓글 생성 API이다.
이 포스팅에서 집중적으로 다룰 곳은 노란색 박스로 표시한 부분이다.
박스 안에서는 API를 요청하고 결과를 반환한다. API 요청 결과인 dto와 error 프로퍼티에 대해 map operator를 사용해 도메인에서 사용하는 entity로 변환해 준다.
이렇게 API를 요청하고 모델을 변환하는 과정은 댓글 생성 API만 사용하는 것이 아니라, Repository 내의 모든 API 요청 메서드가 거쳐야 하는 작업이다.
나는 모든 메서드마다 작성해줘야 하는 최소 5줄의 중복 코드에 대해 불편함을 느꼈다.
또한 프로젝트에서 API 응답 결과로 빈 값이 아니라, 모델을 반환받는 경우에는 아래와 같이 extension에 toDomain() 메서드를 선언하여 이를 통해 Entity로 변환해주고 있었다. toDomain 메서드를 사용하는 것도 동일한데, 더더욱 이걸 매번 내가 작성하고 있어야 하나?? 라는 생각이 들었다.
extension CommentResponseDTO {
func toDomain() -> Comment {
.init(
commentId: commentId,
topicId: topicId,
writer: writer.toDomain(),
votedOption: Mapper.entity(choiceOption: writersVotedOption),
content: content,
likeCount: likeCount,
hateCount: hateCount,
isLike: liked,
isHate: hated,
createdAt: createdAt
)
}
}
ResponseDTO 구조체들에 선언되어 있던 toDomain() 메서드를 추상화하여 프로토콜을 생성했다.
이 프로토콜을 각 ResponseDTO에 채택시켰다.
public protocol Domainable where Self: Decodable{
associatedtype Output
func toDomain() -> Output
}
extension CommentResponseDTO: Domainable {
func toDomain() -> Comment {
.init(
commentId: commentId,
topicId: topicId,
writer: writer.toDomain(),
votedOption: Mapper.entity(choiceOption: writersVotedOption),
content: content,
likeCount: likeCount,
hateCount: hateCount,
isLike: liked,
isHate: hated,
createdAt: createdAt
)
}
}
NetworkService의 싱글턴 인스턴스에 접근해 API를 요청하고, 응답 모델을 변환하는 과정을 하나의 메서드에서 처리되도록 분리해야겠다는 생각을 했다.
이때 마주한 문제는 이 메서드를 어디에 구현하지? 였다.
CommentRepository 구현체와 같이 Repository의 구현체에 선언한다면, 각 구현체마다 이 메서드를 선언해줘야 한다. 적합하지 않다.
이때 마침 Repository 프로토콜을 사용하고 있음을 인지했다. protocol은 extension을 통해 초기 구현이 가능하기 때문에, Repository 프로토콜을 채택하고 있는 모든 구현체들은 초기 구현된 메서드를 사용할 수 있는 것이다.
현재 프로젝트에서 Repository 구조를 댓글 Repository로 설명해 보면 아래와 같다.
public protocol Repository { }
public protocol CommentRepository: Repository {
func generateComment(request: GenerateCommentUseCaseRequestValue) -> NetworkResultPublisher<Comment?>
func fetchComments(topicId: Int, page: Int) -> NetworkResultPublisher<(Paging, [Comment])?>
func patchLikeState(commentId: Int, isLike: Bool) -> NetworkResultPublisher<Any?>
func patchDislikeState(commentId: Int, isDislike: Bool) -> NetworkResultPublisher<Any?>
func deleteComment(commentId: Int) -> NetworkResultPublisher<Any?>
}
CommentRepository, TopicRepository 등을 선언하는 과정에서 Repository 프로토콜도 생성할 필요가 있을까? 고민을 했었었다. 결국에는 혹시 모를 확장성을 대비하여 Repository 프로토콜을 생성했고, 이를 CommentRepository 등에 채택을 했었다.
결론적으로는 위에서 한 고민과 결정으로 이번 문제의 해결 방안을 조금 더 빠르게 찾을 수 있게 했으며, 리팩토링 속도도 빠르게 진행될 수 있었다. 좋은 선택이었던 것 같다.
public extension Repository {
func dataTask<DTO: Domainable>(request: URLRequest, responseType: DTO.Type) -> NetworkResultPublisher<DTO.Output?> {
return NetworkService.shared.dataTask(request: request, type: DTO.self)
.map{ (isSuccess, dto, error) in
return (isSuccess, dto?.toDomain(), error?.toDomain())
}
.eraseToAnyPublisher()
}
}
잠시 NetworkService의 dataTask 메서드 내부를 살펴보겠다.
if httpResponse.statusCode == 200 {
if data.isEmpty {
return (true, nil, nil)
}
return (true, try decode(data, DTO.self), nil)
}
빈 응답이 오는 경우, data 프로퍼티로 nil을 반환한다.
public func patchDislikeState(commentId: Int, isDislike: Bool) -> NetworkResultPublisher<Any?>
도메인 영역에서 사용하기 위해 선언한 NetworkResultPublisher에서는 제네릭으로 Any?를 선언했다.
즉, 여기서 Any가 빈 응답값에 대한 Entity 역할을 대신하는 것이다.
그럼 dataTask를 호출할 때 responseType으로 DTO 타입은 누가 대신해야 할까?
public extension Repository {
func dataTask<DTO: Domainable>(request: URLRequest, responseType: DTO.Type) -> NetworkResultPublisher<DTO.Output?> {
return NetworkService.shared.dataTask(request: request, type: DTO.self)
.map{ (isSuccess, dto, error) in
return (isSuccess, dto?.toDomain(), error?.toDomain())
}
.eraseToAnyPublisher()
}
}
빈 응답값이 오는 경우만을 처리하기 위해 새로운 로직을 구현한다면, dataTask 메서드도 따로 만들어야 하고, NetworkResultPublisher 도 빈 응답값을 위한 타입을 새로 생성해야 한다. 이 방법은 비용이 크다고 생각했다.
로직 통일을 목표로 갖고 이를 해결하기 위해 고민을 해본 결과, 그냥 빈 데이터를 담을 수 있는 구조체를 생성하는 것이 가장 적합하다는 판단을 하게 되었다.
빈 응답값이 오는 경우는 생각보다 많고, EmptyData를 사용해야 한다는 사실을 까먹을 수도 있다 생각했다.
이를 위해 responseType의 기본값으로 EmptyData 타입을 지정해 주었다. 응답 모델이 오는 경우에만 responseType을 직접 지정해 주면 된다.
public struct EmptyData: Decodable, Domainable {
public typealias Output = Any
public func toDomain() -> Output {
(Any).self
}
}
public extension Repository {
func path(_ path: Any) -> String {
"/\(path)"
}
func dataTask<DTO: Domainable>(request: URLRequest, responseType: DTO.Type = EmptyData.self) -> NetworkResultPublisher<DTO.Output?> {
return NetworkService.shared.dataTask(request: request, type: DTO.self)
.map{ (isSuccess, dto, error) in
return (isSuccess, dto?.toDomain(), error?.toDomain())
}
.eraseToAnyPublisher()
}
func arrayDataTask<DTO: Domainable>(request: URLRequest, elementType: DTO.Type) -> NetworkResultPublisher<[DTO.Output]> {
return NetworkService.shared.dataTask(request: request, type: [DTO].self)
.map{ (isSuccess, dto, error) in
return (isSuccess, dto?.map{ $0.toDomain() } ?? [], error?.toDomain())
}
.eraseToAnyPublisher()
}
}
초기 구현된 메서드 호출을 통해 5줄에 걸친 API 요청과 모델 변환 과정이 1줄로 개선되었다.
public func generateComment(request: GenerateCommentUseCaseRequestValue) -> NetworkResultPublisher<Comment?> {
var urlComponents = networkService.baseUrlComponents
urlComponents?.path = basePath
guard let requestBody = try? JSONEncoder().encode(makeDTO()),
let urlRequest = urlComponents?.toURLRequest(method: .post, httpBody: requestBody) else {
fatalError("json encoding or url parsing error")
}
return dataTask(request: urlRequest, responseType: CommentResponseDTO.self)
func makeDTO() -> GenerateCommentRequestDTO{
.init(topicId: request.topicId, content: request.content)
}
}
반복문 안의 작업들을 동시에 비동기로 처리하기 (0) | 2024.01.06 |
---|---|
Timer 사용해보기 (0) | 2023.12.24 |
토스트 메시지 싱글톤으로 관리하기 (1) | 2023.12.19 |
enum을 configuration으로 사용할 때 발생하는 안티 패턴 제거해보기 (0) | 2023.12.14 |
protocol 상속과 메서드의 매개변수를 사용하여 코드 재사용성 높이기 (0) | 2023.12.09 |