-
왜 비즈니스 로직을 나눠야 할까?Programming/iOS 2022. 6. 13. 23:29
지난 글에서는 도메인과 비즈니스의 뜻을 알아보고 앱 코드가 UI 로직, 비즈니스 로직, 애플리케이션 서비스 로직, 데이터 처리로 나뉜다는것을 정리했다. https://beepeach.tistory.com/728
하지만 비즈니스 로직을 왜 UI 로직과 분리해야하는지 다루지 않았다.
이번 글에서는 분리하기 전의 코드를 먼저 살펴보고 어떤 문제가 생기는지 확인한 뒤 영화 검색 앱과 웹툰 앱을 예시로 실제 기능에서 책임이 어떻게 나뉘는지 그리고 앱에도 비즈니스 로직이 있는지 살펴보자.
주요 단어 정리
- 비즈니스 룰: 서비스가 정한 기준. 예: ‘시작 연도는 종료 연도보다 늦을 수 없다’
- 비즈니스 로직: 비즈니스 룰을 적용해 판단하거나 계산하는 코드
- UI 로직: 입력을 받고 화면 상태, 표시, 전환을 처리하는 코드
- 애플리케이션 서비스 로직: 필요한 작업을 호출하고 순서를 정하는 코드
- Repository: 데이터를 어디서 가져오는지 감추고 서버 응답이나 DB 형식을 도메인 모델로 바꿔 돌려주는 객체
왜 비즈니스 로직을 나눠야 할까?
비즈니스 로직을 UI 로직과 분리한다는 말은 서비스의 기준을 판단하는 코드와 화면을 표시하고 조작하는 코드를 나누어서 구성한다는 뜻이다.
나누지 않으면 무엇이 문제인지 분리하기 전의 코드로 먼저 확인해 보자.
아래는 검색 화면 하나에 연도 범위 기준, 화면 반응, 검색 요청이 모두 들어있는 ViewController이다.
이 시점에 SearchCondition은 값만 담는 도메인 데이터다.
import UIKit final class MovieSearchViewController: UIViewController { private let searchButton = UIButton(type: .system) private let errorLabel = UILabel() // 연도 선택 UI가 이 두 값을 갱신한다고 가정한다 private var startYear = 2020 { didSet { updateValidationUI() } } private var endYear = 2023 { didSet { updateValidationUI() } } private let repository: MovieRepository init(repository: MovieRepository) { self.repository = repository super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError() } override func viewDidLoad() { super.viewDidLoad() searchButton.addTarget(self, action: #selector(didTapSearch), for: .touchUpInside) updateValidationUI() } private func updateValidationUI() { let isValidRange = startYear <= endYear searchButton.isEnabled = isValidRange errorLabel.isHidden = isValidRange } @objc private func didTapSearch() { let condition = SearchCondition(genre: nil, startYear: startYear, endYear: endYear) repository.searchMovies(condition) { [weak self] result in DispatchQueue.main.async { self?.showMovies((try? result.get()) ?? []) // 레이아웃 및 showMovies 구현은 생략 } } } }- start <= end 는 '시작 연도는 종료 연도보다 늦을 수 없다'라는 검색 기준을 적용하는 비즈니스 로직이다. 그런데 이 로직이 버튼과 안내 문구를 바꾸는 updateValidationUI() 안에 화면 코드와 같이 있다.
- 이 비교에 주석이나 설명이 없어서 코드를 읽는 사람은 이것이 화면 표시를 위한 조건인지 서비스가 정한 규칙인지 알 수 없다. 화면용 조건이라면 디자인이 바뀔때 지워도 되지만 서비스 규칙이라면 함부로 지우면 안되고 다른 화면과 서버에서 똑같이 적용되어야한다.
위 코드는 지금 당장은 잘 동작한다. 문제는 코드를 수정해야 할 때 나타난다.
1. 화면을 바꾸면 기준도 함께 다시 작성해야 한다.
연도 범위가 잘못되면 검색 버튼을 비활성화 하던 방식에서 버튼은 항상 켜 두고 누르면 경고 문구를 보여주는 방식으로 변경됐다고 하자.
바뀌는 것은 화면의 반응뿐이지만 연도 비교가 updateValidationUI() 안에 있으므로 이 함수를 다시 작성하면서 비교도 함께 옮겨야 한다.
옮기는 과정에서 실수로 <=가 <로 바뀌어도 시작 연도와 종료 연도가 같을 때만 결과가 달라서 엣지 케이스를 생각하지 않고 테스트를 진행하는것으론 알아채기 힘들다.
UI만 바꾸려다 검색 기준을 건드리게 되는 상황이다.
2. 같은 기준이 여러 화면에 복사된다.
즐겨찾기 화면에도 개봉 연도로 리스트를 보여주는 필터가 생겼다고 생각해보자.
기준이 검색 화면 안에 있으니 즐겨찾기 화면은 start <= end를 복사해서 사용하게 된다.
이후 '연도 범위는 최대 10년 까지만 허용한다.'라는 기준이 추가되면 두 화면 파일을 모두 고쳐야 한다.
한 곳을 빠뜨리면 같은 서비스 안에서 화면마다 다른 기준이 적용된다.
3. 기준 하나를 확인하려면 화면 전체가 필요하다.
'2023년부터 2020년까지는 잘못된 범위다'라는 테스트 코드를 작성하려면 ViewController를 만들고 뷰를 로드하고 private인 startYear, endYear에 값을 넣은 뒤 버튼 상태를 읽어야 한다.
테스트하고 싶은 것은 비교 한 줄이지만 이것을 테스트 하기 위해서 화면 전체를 띄워야 한다.
로직을 나눠보자
이제 연도 범위를 판단하는 로직을 ViewController 밖으로 꺼내보자
struct SearchCondition { let genre: Genre? let startYear: Int let endYear: Int // 비즈니스 로직: 연도 범위가 유효한지 판단한다 var isValidYearRange: Bool { startYear <= endYear } }- 판단 로직이 isValidYearRange 라는 이름을 가진다. 이 비교가 서비스 기준을 적용한다는 사실이 코드에 드러나게 된다.
- UIKit을 import 하지 않는 값타입이다. UIKit에서 SwiftUI로 변경되어도 이 코드는 그대로 남아있다.
- 값만 담던 SearchCondition이 연도 범위 규칙까지 가지게 되면서 도메인 모델이 되었다.
이제 MovieSearchViewController 코드를 분리해 보자.
import UIKit final class MovieSearchViewController: UIViewController { private let searchButton = UIButton(type: .system) private let errorLabel = UILabel() // 연도 선택 UI가 이 두 값을 갱신한다고 가정한다 private var startYear = 2020 { didSet { updateValidationUI() } } private var endYear = 2023 { didSet { updateValidationUI() } } // 바뀐 곳: 입력 상태를 SearchCondition으로 묶는다 private var condition: SearchCondition { SearchCondition(genre: nil, startYear: startYear, endYear: endYear) } private let repository: MovieRepository init(repository: MovieRepository) { self.repository = repository super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError() } override func viewDidLoad() { super.viewDidLoad() searchButton.addTarget(self, action: #selector(didTapSearch), for: .touchUpInside) updateValidationUI() } // UI 로직: 판단 결과를 화면에 반영한다 private func updateValidationUI() { let isValidRange = condition.isValidYearRange // 바뀐 곳: 직접 비교하지 않고 묻는다 searchButton.isEnabled = isValidRange errorLabel.isHidden = isValidRange } @objc private func didTapSearch() { // 바뀐 곳: SearchCondition을 직접 만들지 않고 condition을 넘긴다 repository.searchMovies(condition) { [weak self] result in DispatchQueue.main.async { self?.showMovies((try? result.get()) ?? []) // 레이아웃, showMovies 구현은 생략 } } } }- 분리 전 코드와 비교하면 바뀐 곳은 주석으로 표시한 세 곳이다. 입력 상태를 SearchCondition으로 묶는 condition이 생겼고 updateValidationUI()는 startYear <= endYear를 직접 계산하는 대신 condition.isValidYearRange에 묻고 didTapSearch()는 SearchCondition을 직접 만들지 않고 condition을 그대로 넘긴다. didSet, 버튼 연결, 콜백 처리는 분리 전과 같다.
- ViewController에 남은 책임은 입력 상태를 SearchCondition으로 묶고 판단 결과를 버튼과 안내문구에 반영하는 것이다.
- 검색 요청을 UseCase로 옮기는 것은 이후 포스팅에서 다루기로 한다.
상황 분리 전 분리 후 버튼 비활성화 대신 경고 문구를 보여준다. updateValidationUI()를 다시 짜면서 그 안의 start <= end도 함께 옮겨 써야 한다. 옮기다 <=가 <로 바뀌어도 같은 연도에서만 결과가 달라 알아채기 어렵다 화면 코드에서 버튼 대신 경고 문구를 띄우도록만 바꾼다. 판단은 isValidYearRange를 그대로 호출하므로 기준을 건드릴 일이 없다 즐겨찾기 화면에 같은 필터를 추가한다. 검색 화면의 start <= end를 복사한다. ‘최대 10년’ 기준이 추가되면 복사한 곳을 모두 찾아 고쳐야 하고, 하나라도 빠뜨리면 화면마다 기준이 달라진다 두 화면이 같은 SearchCondition을 쓴다. 기준이 바뀌면 isValidYearRange 한 곳만 고치면 두 화면에 함께 적용된다 기준 유닛테스트를 작성한다. ViewController를 만들고 뷰를 로드한 뒤, private인 startYear·endYear를 바꿀 방법을 따로 마련하고 버튼 상태를 읽어 간접적으로 확인해야 한다 SearchCondition(genre: nil, startYear: 2023, endYear: 2023) 값 하나를 만들고 isValidYearRange를 바로 확인한다. 화면도 네트워크도 필요 없다 테스트코드도 다음과 같이 간단하게 작성할 수 있다.
import XCTest final class SearchConditionTests: XCTestCase { func test_isValidYearRange_whenStartIsAfterEnd_returnsFalse() { let condition = SearchCondition(genre: nil, startYear: 2023, endYear: 2020) XCTAssertFalse(condition.isValidYearRange) } func test_isValidYearRange_whenStartEqualsEnd_returnsTrue() { let condition = SearchCondition(genre: nil, startYear: 2023, endYear: 2023) XCTAssertTrue(condition.isValidYearRange) } }정리하면 분리의 목적은 바뀌는 이유가 다른 코드끼리 서로 영향을 주지 않게 하는 것이다.
같은 기준을 여러 화면에서 재사용하고 화면 없이 검증할 수 있는 것은 그 결과로 얻는 이점이다.
다만 분리한다고 모든 수정이 사라지는게 아니다.
검색 기준 자체가 바뀌면 비즈니스 로직은 수정이 필요하다.
대신 고칠 곳이 한 곳으로 모인다.
타입과 파일이 늘어나는 비용도 있으므로 판단이 없는 코드까지 계층을 나눌지는 이후 포스팅에서 살펴보기로 하자.
이제 두 가지 예시를 단계별로 따라가며 책임을 나누어 보자.
예시에서는 단계를 잘게 나눴지만 실제 코드에서는 함수 하나에 여러 책임이 섞여 있을 수 있다.
서버에도 비즈니스 로직, 작업 조율, 데이터 접근이 모두 있으므로 실행 위치와 책임은 따로 표시한다.
예시1 - 영화를 검색할 때
사용자가 장르: SF, 개봉 연도: 2020~2023, 최신순으로 검색을 했다.
이 예시에서는 서버가 조건에 맞는 영화를 고르고 정렬하며 앱은 그 결과를 받아 표시한다.
서비스 기준은 다음과 같다.
- 장르를 선택하면 해당 장르에 속한 영화만 검색 결과에 포함한다.
- 개봉 연도 범위를 지정하면 시작 연도와 종료 연도를 포함한 범위 안의 영화만 보여준다.
- 최신순을 선택하면 개봉일이 늦은 영화부터 보여준다.
순서 실행 위치 처리하는 일 책임 1 앱 선택한 장르, 연도를 화면에 표시하고 검색을 시작하면 로딩 인디케이터를 표시한다. UI 로직 2 앱 조건을 받아 Repository에 영화 목록을 요청한다. 애플리케이션 서비스 로직 3 앱 검색어, 장르, 연도, 정렬 기준을 URL 파라미터로 만들고 서버에 보낸다. 네트워크 처리 4 서버 검색에 필요한 영화 데이터를 저장소에 요청한다. 애플리케이션 서비스 로직 5 서버 DB에서 영화 데이터를 읽어 온다. 데이터 접근 6 서버 영화가 장르, 연도 조건에 맞는지 판단하고 최신 기준으로 결과를 정렬한다. 비즈니스 로직 7 서버 검색 결과를 JSON으로 바꿔 HTTP 응답으로 보낸다. 데이터 변환, 네트워크 처리 8 앱 응답 JSON을 파싱해 앱에서 쓰는 도메인 모델(Movie)로 변환한다. 데이터 변환 9 앱 캐시를 사용한다면 Repository에서 검색 조건과 결과를 로컬 DB에 저장한다. 데이터 접근, 저장 10 앱 받은 영화 목록을 화면에 넘긴다. 애플리케이션 서비스 로직 11 앱 로딩 인디케이터를 숨기고 영화 목록을 표시한다. 결과가 없거나 요청에 실패하면 그에 맞는 안내를 보여준다. UI 로직 - 2번과 10번은 Repository에 영화 목록을 요청하고 받은 결과를 화면에 넘기는 일이다.
- 3번은 HTTP 통신의 구현이다.
- 4~7번은 앱이 아닌 서버에서 일어나는 일이다. 앱 개발자는 이에 대한 구현을 자세히 알기 어렵다.
- 6번은 어떤 영화를 결과에 포함하고 어떤 순서로 보여 줄 지 정하는 일이다.
- 모두 검색 기능에 필요한 일이지만 맡는 책임이 다르다.
6번에서 일어나는 일을 실제 데이터로 자세히 살펴보자.
영화 장르 개봉 연도 판단 결과 듄 SF 2021 포함 오펜하이머 드라마 2023 장르가 다르므로 제외 인터스텔라 SF 2014 연도 범위에 벗어나므로 제외 테넷 SF 2020 포함 정렬은 최신순이므로 결과는 듄 -> 테넷이 된다.
이 포함 여부와 순서를 결정하는 처리가 비즈니스 로직이다.
그 결과를 화면에 보여주는 11번은 UI 로직이다.
9번의 로컬 캐시는 필수 구현 영역이 아니다. 여기서는 같은 검색을 다시 서버에 요청하지 않으려고 구현한 것이며 캐시를 사용하지 않으면 해당 단계가 생략된다. 캐시를 저장한다는 이유만으로 비즈니스 로직이 되는것은 아니다.
이 흐름에서 핵심 판단은 서버에 있다. 그래서 앱이 서버에 조건을 보내고 결과를 받는 코드는 비즈니스 로직이 아니라 작업 조율(2, 10번)과 통신(3번)이다. 앱이 직접 판단하는 경우는 뒤에서 살펴보자.
예시2 - 웹툰 미리보기 회차를 구매할 때
이번 예시는 사용자가 재화로 웹툰 미리보기 회차를 구매해서 보는 서비스다.
이 예시에서는 서버가 구매 가능 여부와 재화 차감량을 판단하고 구매를 확정한다.
서비스 기준은 다음과 같다.
- 처음 구매하면 재화 3개를 차감한다.
- 잔액이 부족하면 구매를 허용하지 않는다.
- 이미 구매한 회차는 추가 차감 없이 다시 볼 수 있다.
- 재화 차감과 구매 내역 반영은 함께 성공해야한다.
순서 실행 위치 처리하는 일 책임 1 앱 구매 버튼을 누르면 진행 중 표시를 보여준다. UI 로직 2 앱 회차 ID를 받아 Repository에 구매를 요청한다. 애플리케이션 서비스 로직 3 앱 회차 ID를 요청 본문에 담아 서버에 보낸다. 네트워크 처리 4 서버 구매에 필요한 가격, 잔액, 구매내역을 저장소에 요청한다. 애플리케이션 서비스 로직 5 서버 DB에서 가격, 잔액, 구매 내역을 읽는다. 데이터 접근 6 서버 이미 구매했는지, 잔액이 충분한지 판단하고 차감량과 구매 후 상태를 결정한다. 비즈니스 로직 7 서버 판단 결과에 따라 저장을 요청하고 저장이 끝나면 결과를 돌려준다. 애플리케이션 서비스 로직 8 서버 새 구매라면 DB 트랜잭션으로 잔액과 구매 내역을 함께 반영한다. 실패하면 함께 취소한다. 데이터 저장, 트랜잭션 처리 9 서버 -> 앱 구매 결과를 HTTP 응답으로 전달하고 앱에서 사용할 데이터로 변환한다. 네트워크 처리, 데이터 변환 10 앱 받은 구매 결과를 화면에 넘긴다. 애플리케이션 서비스 로직 11 앱 성공하면 잔액 표시를 갱신하고 회차를 연다. 잔액 부족 결과라면 충전 안내를 표시한다. UI 로직 - 7번은 조회 -> 판단 -> 필요한 경우 저장 -> 결과 반환 순서로 작업을 연결한다.
- 8번은 위 흐름안에서 호출되는 저장 처리다.
- 이미 구매했거나 구매가 거절된 경우에는 새로 차감, 저장하지 않는다.
6번에서 일어나는 일을 자세히 보자
구매 전 상태 비즈니스 로직의 판단 처리 결과 미구매, 보유 재화 5개 구매 허용, 3개 차감 잔액 2개, 회차 열람 가능 미구매, 보유 재화 2개 잔액 부족으로 구매 거절 잔액 2개 유지, 회차 열람 불가능 이미 구매, 보유 재화 0개 추가 차감 없이 열람 허용 잔액 0개 유지, 회차 열람 가능 구매 내역과 잔액을 보고 차감 여부, 차감량을 결정하는 처리는 비즈니스 로직이다.
잔액과 구매내역을 함께 저장해달라고 요청하는 흐름은 작업 조율이고 실제로 함께 반영되고 실패하면 되돌리는 8번의 처리는 데이터 저장의 구현이다.
“차감과 구매 내역 반영이 함께 성공해야 한다”는 비즈니스 룰이다. 이를 보장하기 위해 트랜잭션 같은 기술적인 방법을 사용할 수 있다. 비즈니스 로직과 데이터 저장 처리는 이렇게 서로 협력한다.
여기서 앱이 잔액 부족 결과를 보고 충전 안내창을 띄우는 처리는 UI 로직이다. 서버가 판단한 결과에 맞춰 화면을 바꾸는 것이며 구매 허용 기준을 새로 판단하는 처리는 아니다.
두 예시처럼 코드의 책임을 나눌 때는 화면에 표현하는가(UI 로직), 서비스 기준에 따라 판단하거나 계산하는가(비즈니스 로직), 데이터를 주고받고 저장하는가(데이터 처리), 이 작업들을 연결하는가(애플리케이션 서비스 로직)를 물어보면 된다.
앱에도 비즈니스 로직이 있을까
앞의 두 예시는 핵심 판단을 모두 서버가 맡고있다.
실제로 많은 서비스가 이렇게 동작한다.
그러면 앱 코드에는 비즈니스 로직이 없는 걸까?
대부분의 앱에는 있다.
핵심 판단을 서버가 맡는 기능에서도 입력 검사, 결과 정렬, 기기 안에서만 동작하는 기능처럼 앱이 서비스 기준을 직접 적용하는 곳이 생기기 때문이다.
다만 앱 코드 전체가 비즈니스 로직인 것은 아니다.
어떤 코드가 비즈니스 로직인지는 그 코드가 서비스 기준을 직접 적용해 판단하는가로 정해진다.
판단을 서버에 맡긴 코드는 작업을 이어 주는 코드로 남고 앱이 직접 판단하는 코드가 앱의 비즈니스 로직이 된다.
판단을 서버에 맡긴 경우
final class SearchMoviesUseCase { private let repository: MovieRepository init(repository: MovieRepository) { self.repository = repository } func execute( _ condition: SearchCondition, completion: @escaping (Result<[Movie], Error>) -> Void ) { repository.searchMovies(condition, completion: completion) } }- 이 UseCase는 아무것도 판단하지 않는다. 조건을 받아 Repository에 넘기고 결과를 그대로 돌려줄 뿐이다.
- 판단을 서버가 맡아서 앱의 UseCase에는 작업 조율만 남은것이다. 잘못 만든 코드가 아니라 판단을 서버가 한다는 사실이 코드에 드러나는 것이다.
- 이처럼 판단 없이 전달만 하는 UseCase를 둘지는 팀마다 다르다.
- UseCase를 두면 ViewController가 Repository를 직접 알지 않아도 되고 나중에 앱쪽에 판단이 생겼을때 수정이 간단해진다.
- 대신 같은 호출을 한 번 더 감싸는 코드와 파일이 늘어난다.
앱이 직접 판단하는 경우
다음과 같은 경우에는 앱 코드가 서비스 기준을 직접 적용해 판단한다. 이때 그 판단 코드가 앱의 비즈니스 로직이다.
번호 상황 앱이 판단하는 내용(비즈니스 로직) 그 결과에 따른 화면 반응 (UI 로직) 1 요청 전에 입력을 검사한다. 시작 연도가 종료 연도보다 늦으면 유효하지 않은 조건이다. 검색 버튼을 비활성화하고 안내 문구를 보여준다. 2 받은 결과를 앱에서 다시 가공한다. 서버 재요청 없이 제목순으로 재정렬한다. 정렬된 순서대로 목록을 다시 그린다. 3 서버 판단을 미리 안내한다. 잔액이 가격보다 적으면 구매할 수 없다고 예상한다. 구매 버튼 옆에 충전 필요를 표시한다. 4 서버없이 앱에서만 동작한다. 즐겨찾기한 영화 중 SF 장르만 필터링한다. 필터링된 즐겨찾기 목록을 보여준다. 1. 요청 전에 입력을 검사한다.
이 경우는 앞의 왜 비즈니스 로직을 나눠야 할까? 섹션에서 이미 살펴봤다.
판단 로직은 SearchCondition.isValidYearRange에 화면 반응은 ViewController의 updateValidationUI()에 있었다.
위 구현에서 UseCase를 추가하면 연도 범위 기준 하나를 세 코드가 각자 다른 책임으로 다루게 된다.
SearchCondition은 범위가 유효한지 판단하고(비즈니스 로직), UseCase는 그 결과로 요청을 보낼지 정하고(애플리케이션 서비스 로직), ViewController는 결과를 화면에 반영한다 (UI 로직).
func execute( _ condition: SearchCondition, completion: @escaping (Result<[Movie], Error>) -> Void ) { // 애플리케이션 서비스 로직: 판단 결과에 따라 다음 작업을 정한다 guard condition.isValidYearRange else { completion(.failure(SearchError.invalidYearRange)) return } repository.searchMovies(condition, completion: completion) }- UseCase는 연도 범위를 직접 판단하지 않는다. SearchCondition에 판단을 맡기고 그 결과에 따라 서버 요청을 보낼지 말지만 정한다.
- 판단(연도 범위가 유효한가)과 흐름(유효하지 않으면 요청하지 않는다)을 나누었기때문에 기준이 바뀌면 SearchCondition만 고치면 된다.
- 화면 코드는 앞에서 살펴본 updateValidationUI() 그대로다. ViewController는 repository 대신 UseCase를 호출하면 된다.
2. 받은 결과를 앱에서 다시 가공한다.
서버가 최신순으로 보내 준 영화 목록을 사용자가 '제목순'으로 바꿔보는 경우다.
서버에 요청을 다시 하지 않고 앱이 직접 정렬한다.
// Domain 계층 enum MovieSortOrder { case latest // 최신순 case title // 제목순 // 비즈니스 로직: 선택한 정렬 기준에 따라 순서를 정한다 func sorted(_ movies: [Movie]) -> [Movie] { switch self { case .latest: return movies.sorted { $0.releaseYear > $1.releaseYear } case .title: return movies.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } } } }- 이전 포스팅에서 검색 조건이 나타내는 것 중 하나로 ‘정렬 기준’이 있었다. 여기서는 서버가 준 결과를 앱이 다시 정렬하므로 그 정렬 기준을 MovieSortOrder라는 도메인 타입으로 따로 빼냈다. 이렇게 하면 어떤 정렬 방식이 있는지도 한눈에 보인다.
- '제목순'이 어떤 순서인지도 서비스가 정하는 기준이다.
- 단순한 문자열 비교(<)를 사용하면 "10편"이 "2편" 보다 앞에 오게 된다. localizedStandardCompare를 사용하면 숫자를 값으로 비교해서 우리가 기대하는 순서로 정렬한다.
- 화면은 사용자가 고른 MovieSortOrder 값을 넘기고 정렬된 목록을 다시 그리기만 한다. 정렬 기준이 바뀌어도 화면 코드는 고칠 필요가 없다.
3. 서버 판단을 미리 안내한다.
예시 2의 웹툰 구매에서 구매 버튼을 누르기 전에 '충전 필요'라는 문구를 미리 보여준다고 생각해보자.
앱이 서버와 같은 기준으로 구매 여부를 예상한다.
// Domain 계층 struct Episode { let id: Int let price: Int let isPurchased: Bool } struct Wallet { let balance: Int // 비즈니스 로직: 구매할 수 있는지 미리 판단한다 (최종 결정은 서버가 한다) func canPurchase(_ episode: Episode) -> Bool { episode.isPurchased || balance >= episode.price } }- 예시 2의 서비스 기준인 잔액이 부족하면 구매 불가와 이미 구매한 회차는 다시 볼 수 있음을 앱에서 그대로 적용한다.
- 다만 이 판단은 빠른 안내용일 뿐이다. 앱에서 구매 가능이라고 예상해도 다른 기기에서 잔액을 먼저 사용했다면 서버는 구매를 거절하게 된다. 최종 결정은 언제나 서버의 판단이다.
- 같은 기준이 서버와 앱 양쪽에 생기므로 기준이 바뀌면 두 곳을 함께 고쳐야한다.
4. 서버 없이 앱에서만 동작한다.
기기에 저장한 즐겨찾기 목록을 장르로 필터링 하는 경우다. 서버가 관여하지 않으므로 앱의 판단이 곧 결과다.
// Domain 계층 extension Array where Element == Movie { // 비즈니스 로직: 선택한 장르에 속한 영화만 남긴다 func filtered(by genre: Genre) -> [Movie] { filter { $0.genres.contains(genre) } } }- 검증할 서버가 없으니 이 코드가 틀리면 사용자는 틀린 결과를 보게 된다. 그래서 테스트를 추가할 이유가 생긴다.
- 즐겨찾기를 Core Data의 NSPredicate로 처음부터 필터링해서 읽어 와도 ‘SF만 남긴다’는 기준은 여전히 비즈니스 룰이다. 조회 코드는 그 기준을 Core Data의 문법으로 옮겨 쓴 것일 뿐이다. 이렇게 할 때 Repository를 어떻게 나누는지는 Repository 글에서 다룬다.
네 경우를 다시 보면 같은 앱의 비즈니스 로직이라도 무게가 다르다.
- 1번(입력 검사)과 3번(충전 필요 안내)은 최종 결정을 서버가 한다. 앱의 판단은 요청을 보내기 전에 결과를 미리 알려 주는 역할이라 앱이 틀려도 서버가 다시 걸러 준다. 대신 서버와 같은 기준을 앱에도 두는 것이므로 기준이 바뀌면 두 곳을 함께 고쳐야 한다.
- 2번(제목순 정렬)과 4번(즐겨찾기 필터)은 서버가 관여하지 않는다. 앱의 판단이 그대로 사용자가 보는 결과가 되므로 앱이 틀리면 바로잡아 줄 곳이 없다. 그래서 테스트를 작성해 둘 이유가 더 크다.
그래서 앱에 비즈니스 로직을 둘 때는 먼저 이 판단을 최종적으로 누가 확정하는가를 확인한다. 서버라면 앱의 판단은 안내이고 앱이라면 앱의 판단이 곧 결과다.
마무리
이번 글에서는 ‘비즈니스 로직을 UI와 분리해야 한다’는 말이 무엇을 위한 것인지 코드로 확인했다. 연도 범위 판단을 ViewController에서 꺼내자, 화면을 바꾸거나 같은 기준을 다른 화면에서 쓰거나 기준을 테스트할 때 고칠 곳이 한 곳으로 모였다. 두 예시에서는 기능 하나에 화면·판단·데이터·조율 책임이 함께 있다는 것을 보았고, 앱도 서비스 기준을 직접 적용하는 순간 비즈니스 로직을 갖게 된다는 것을 확인했다.
그런데 책임을 나눠야 한다는 것을 알아도, 화면이 수십 개인 앱에서 매번 어디에 무엇을 둘지 새로 정할 수는 없다. 그래서 개발자들은 책임을 배치하는 약속, 즉 아키텍처 패턴을 만들어 왔다. 다음 글에서는 iOS 개발자가 가장 먼저 만나는 패턴인 MVC가 이 책임들을 어떻게 나누는지 살펴본다.
정리
- 비즈니스 로직을 UI와 나누는 목적은 바뀌는 이유가 다른 코드끼리 서로 영향을 주지 않게 하는 것이다.
- 판단 로직을 화면 코드에서 꺼내 이름을 붙이면(isValidYearRange) 화면을 바꿔도 기준이 흔들리지 않고, 같은 기준을 여러 화면에서 재사용하며, 화면 없이 기준을 테스트할 수 있다.
- 코드의 책임은 화면에 표현하는가, 서비스 기준에 따라 판단하는가, 데이터를 다루는가, 이 작업들을 연결하는가로 구분한다. if나 UseCase 같은 겉모습으로 정하지 않는다.
- 대부분의 앱에는 비즈니스 로직이 있다. 어떤 코드가 비즈니스 로직인지는 그 코드가 서비스 기준을 직접 적용해 판단하는가로 정해진다.
728x90'Programming > iOS' 카테고리의 다른 글
도메인과 비즈니스 그리고 비즈니스 로직 (0) 2022.05.24 iOS - Firebase 기본 설정하기 (0) 2022.03.16 iOS - SwiftUI를 이용해서 Preview 보기 (0) 2022.03.12 iOS - Project의 Storyboard 삭제하기 (0) 2022.03.12 iOS - URLSession과 URLSessionTask (0) 2022.03.09