해당 프로젝트를 통하여너무나도 많은 것을 배울 수 있었다. 해당 포스트에는 모든 내용을 담을 수 없었으므로, 부족한 부분은 깃헙 내 PR comment와 commit을 다시 보자.
그리고, 나의 짝이었던 Mason 너무 감사했습니다!
프로젝트 기간: 23.01.02 ~ 23.01.13
Name | Type | 역할과 책임 |
---|---|---|
JuiceMaker | struct | FruitJuice를 제조하는 쥬스메이커 |
FruitStore | final class (싱글턴) | Fruit의 재고를 관리하는 싱글턴 객체 |
Fruit | enum | 과일의 종류 |
FruitJuice | enum | 과일 쥬스의 종류 (제조 레시피를 [Fruit:Int] 형태로 가짐) |
StockError | enum | 재고 관련 Error |
Constants | enum | 스토리보드 ID값, 유저메시지, 디자인(UI), 매직넘버 등의 상수값을 static let 으로 묶어서 관리 |
JuiceViewController | final class | 과일쥬스 제조의 역할을 하는 뷰컨트롤러 (JuiceMaker를 가짐) |
StockViewController | final class | 과일의 재고를 관리하는 뷰컨트롤러 (싱글턴 FruitStore를 참조) |
IBOutlet을 outlet collection으로 생성해주고, 생성된 IBOutlet의 +
를 스토리보드 내의 다른 element에 끌어서 이어준다!
@IBOutlet var fruitStockLabels: [UILabel]!
→ fruitStockLables는 UILabel들이 보여있는 array이다.
따라서, 아래와 같이 각각의 label에 접근을 할 수 있다.
fruitStockLabels[0].text
(각각의 stepper는 fruit와 동일하게 tag를 걸어두었다.)
// viewController A
@IBAction private func stepperValueChanged(_ sender: UIStepper) {
guard let fruit = Fruit(rawValue: sender.tag) else { return }
...
}
// viewController B
enum Fruit: Int, CaseIterable {
case strawberry // 0
case banana // 1
case pineapple // 2
case kiwi
case mango
}
@IBAction private func changedStepperValue(_ sender: UIStepper) {
guard let fruitStepper = sender as? FruitStepperProtocol else { return }
let fruit = fruitStepper.fruit
let changedStock = Int(sender.value)
...
}
private func stockLabel(of fruit: Fruit) -> UILabel? {
return fruitStockLabels.first { stockLabel in
guard let fruitLabel = stockLabel as? FruitLabelProtocol else {
return false
}
return fruitLabel.fruit == fruit
}
}
protocol JuiceButtonProtocol {
var fruitJuice: FruitJuice { get }
}
class StrawberryButton: UIButton, JuiceButtonProtocol {
var fruitJuice: FruitJuice = .strawberry
}
class StrawberryBananaButton: UIButton, JuiceButtonProtocol {
var fruitJuice: FruitJuice = .strawberryBanana
}
...
@IBAction private func juiceOrderButtonTapped(_ sender: UIButton) {
guard let button = sender as? JuiceButtonProtocol else { return }
let fruitJuice = button.fruitJuice
...
}
@IBOutlet var fruitStockLabels: [UILabel]!
enum FruitJuice {
case strawberryBanana
case strawberry
case banana
case pineapple
case mangoKiwi
case kiwi
case mango
var recipe: [Fruit: Int] {
switch self {
case .strawberryBanana:
return [Fruit.strawberry: 10, Fruit.banana: 1]
case .strawberry:
return [Fruit.strawberry: 16]
case .banana:
return [Fruit.banana: 2]
case .pineapple:
return [Fruit.pineapple: 2]
case .mangoKiwi:
return [Fruit.mango: 2, Fruit.kiwi: 1]
case .kiwi:
return [Fruit.kiwi: 3]
case .mango:
return [Fruit.mango: 3]
}
}
}
print(FruitJuice.mango.recipe) // [Fruit.mango: 3]
import Foundation
enum StockError: LocalizedError {
case noCorrespondingFruit
case notEnoughToMakeJuice
case notEnoughToChange
var errorDescription: String? {
switch self {
case .noCorrespondingFruit:
return "일치하는 과일이 없습니다."
case .notEnoughToMakeJuice:
return "재료가 모자라요."
case .notEnoughToChange:
return "더 이상 변경할 수 없어요."
}
}
}
func tryError(if x: Int) throws {
if x == 0 {
throw StockError.noCorrespondingFruit
} else if x == 1 {
throw StockError.notEnoughToMakeJuice
} else {
throw StockError.notEnoughToChange
}
}
func catchError(if x: Int) {
do {
try tryError(if: x)
} catch {
print(error.localizedDescription)
}
}
catchError(if: 0) // 일치하는 과일이 없습니다.
catchError(if: 1) // 재료가 모자라요.
catchError(if: 2) // 더 이상 변경할 수 없어요.
enum Constants {
enum Identifier {
static let stockViewController = "StockVC"
}
enum UserMessage {
static let servingJuiceExtra = "맛있게 드세요!"
static let servingJuiceConfirm = "잘 먹을게요!"
static let failedJuiceConfirm = "네"
static let failedJuiceCancel = "아니요"
}
enum Number {
static let initialFruitStockQuantity = 10
}
}
FruitStore
class
로 구현하는 것이 적절하다 판단 (이후 STEP 에서 싱글톤 패턴 선택)JuiceMaker
FruitStore
에게 위임하므로 구조체로 구현하는 것이 적절하다 판단enum Fruit
와 enum FruitJuice
의 case 명이 겹치는 문제🌟 리뷰어 의견: 1번은 enum 명과 case 가 juice 로 겹치니, 2번처럼 Fruit.strawberry 라고 명시적으로 적어서 헷갈림을 방지하는 것이 좋을 듯 하다!
enum Fruit: CaseIterable {
case strawberry
case banana ...
enum FruitJuice {
case strawberry
case banana
case strawberryBanana ...
var recipe: [Fruit: Int] {
switch self {
case .strawberry:
return [Fruit.strawberry: 16]
case .banana:
return [Fruit.banana: 2]
...
.allSatisfy
내부에서 옵셔널 바인딩 처리.allSatisfy
의 내부에서 어떻게 옵셔널 바인딩을 처리 할 것인가?.allSatisfy
클로저 내부에서 작성하는 return 은 함수의 return 이 아니라, 파라미터로 받는 조건을 체크하는 클로저의 return 이므로, 각기 return 으로 처리 가능func allSatisfy(_ predicate: (Self.Element) throws -> Bool) rethrows -> Bool
private func hasEnoughStock(for fruitJuice: FruitJuice) -> Bool {
return fruitJuice.recipe.allSatisfy { fruit, numberOfUse in
guard let fruitStock = fruitStore.stock[fruit] else {
return false
}
return fruitStock >= numberOfUse
}
}
private func hasEnoughStock(for fruitJuice: FruitJuice) -> Bool {
return fruitJuice.recipe.allSatisfy { fruit, numberOfUse in
(fruitStore.stock[fruit] ?? 0) >= numberOfUse
}
}
CaseIterable
프로토콜 채택을 통한 Fruit.allCases
사용FruitStore
객체 생성 시 initializer를 통하여 FruitStore
프로퍼티 stock의 기본 재고값을 [Fruit:Int]
로 각 과일별로 10개만큼 채워 넣기 위하여 allCases
메소드를 사용CaseIterable
프로토콜을 채택changeStock(of:by:)
함수를 고차함수 (forEach) 에서 호출하니, try 고차함수 { try 함수 } 형식으로 중첩해서 try 를 해줘야 했음// struct JuiceMaker
func makeJuice(of fruitJuice: FruitJuice) throws {
guard hasEnoughStock(for: fruitJuice) else {
throw StockError.notEnoughToMakeJuice
}
try fruitJuice.recipe.forEach { fruit, numberOfUse in
try fruitStore.changeStock(of: fruit, by: -numberOfUse)
}
}
🌟 리뷰어 의견: 해당 코드를 고차함수가 아닌 반복문을 사용하는걸 추천. 고차함수는 순수함수(pure function)를 인자로 받아야 하는데 현재 클로져 코드가 순수하지 않기 때문. 내용이 순수하지 않으면 for 문을 쓰는게 정석이다.
→ 아래와 같이 for 문 안에 try 구문이 있게 변경하였다.
// struct JuiceMaker
func makeJuice(of fruitJuice: FruitJuice) throws {
guard hasEnoughStock(for: fruitJuice) else {
throw StockError.notEnoughToMakeJuice
}
for (fruit, numberOfUse) in fruitJuice.recipe {
try fruitStore.changeStock(of: fruit, by: -numberOfUse)
}
}
### 1. 뷰 컨트롤러 간의 데이터 공유/전달 방법?
final class FruitStore {
static let shared = FruitStore()
private(set) var stock: [Fruit: Int] = [:]
...
}
🌟 리뷰어 의견: reset 이라는 메소드를 FruitStore에 추가해서 보일러플레이트 줄여보시면 될 듯 하다! 싱글톤의 단점이 맞긴하지만, FruitStore가 여러 화면에서 사용할 수 있는 객체로 보이기에 싱글톤을 쓰는 게 더 이득일 듯!
→ 아래와 같은 resetStockValue()함수를 정의하여 test할 때마다 호출하는 방식으로 해결하였다.
// FruitStore.swift
func resetStockValue() {
Fruit.allCases.forEach { fruit in
stock[fruit] = Constants.Number.initialFruitStockQuantity
}
Identifier
, UserMessage
, Design
, Number
로 나누고 enum 타입의 Constants
namespace 로 묶어주었다.enum Constants {
enum Identifier {
static let stockViewController = "StockVC"
}
enum UserMessage {
static let servingJuiceExtra = "맛있게 드세요!"static let servingJuiceConfirm = "잘 먹을게요!"static let failedJuiceConfirm = "네"static let failedJuiceCancel = "아니요"
}
enum Design {
static let stockViewControllerNavigationTitle = "과일 재고 수정"
}
enum Number {
static let initialFruitStockQuantity = 10
}
}
역할
을 표현하는 이름으로 상수명을 설정한 케이스이다.static let servingJuiceConfirm = "잘 먹을게요!" // 선택 한 방식
의미
를 그대로 전달하는 이름으로 상수명을 설정한 케이스이다.static let thanksForServing = "잘 먹을게요!" // 선택하지 않았지만, 고민 한 방식
🌟 리뷰어 의견: 역할을 중점으로 둬서 상수명을 설정하는 게 더 좋을 듯! ex) confirm
, cancel
StockViewController
에서 스테퍼로 재고를 수정한 뒤, JuiceViewController
로 돌아왔을 때 수정한 재고값을 가져와 Label 들에 표시 해 주는 방식으로 로직을 구성하였다.updateAllStockLabels()
함수를 viewWillAppear 에서 호출하도록 구현하였다.🌟 리뷰어 의견: 위와 같은 문제는 viewWillAppear를 통하여 함수를 호출할 때 생길 수 있는 문제이다! 따라서 delegate 패턴을 통하여 로직을 구현하는 게 좋을 듯 싶다!
→ 재고 변경이 되었는 지 아닌 지를 isStockChanged
를 통하여 확인하고, StockViewControllerDelegate
protocol을 통하여 delegate 패턴을 구현하였다.
1 → 2 → 3 → 4 의 순서로 고민하며 리팩터링을 진행
- 스토리보드에서 모든 요소를 각각 연결시키는 방식
- tag & enum 의 rawValue 를 쓰는 방식
- tag & enum 의 custom init 방식
- custom Class 와 protocol 을 채택하여 구현하는 방식 → tag, rawValue 를 쓰지 않음
[ 1 → 2 ]
🌟 리뷰어 의견: Fruit의 rawvalue와 tag 값과 아무런 관련이 없는데 tag 값을 rawValue에 넣어 Fruit 객체를 만드는 것은 어색함. custom init 으로 tag 값을 넣는 것이 더 적절 해 보임.
[ 2 → 3 ]
🌟 리뷰어 의견: tag를 쓰지말고 button들의 Custom Class 로 구현하는 것이 좋을 듯 함. tag는 동적으로 생성된 뷰를 취급할 때 방법이 없을때 사용하기 좋은 값이지, 지금처럼 이미 화면에 보이는 버튼들에 사용하기 좋은 방법은 아님 tag를 사용하면 앱은 tag를 통해 런타임적으로 어떤 뷰인지 판별하기에 이미 알고 있는 뷰라면 tag를 쓰는 것이 비효율적이다.
[ 3 → 4 ]
protocol JuiceButtonProtocol {
var fruitJuice: FruitJuice { get }
}
final class StrawberryButton: UIButton, JuiceButtonProtocol {
var fruitJuice: FruitJuice = .strawberry
}
// ViewController
@IBAction private func tappedJuiceOrderButton(_ sender: UIButton) {
guard let button = sender as? JuiceButtonProtocol else { return }
let fruitJuice = button.fruitJuice
...
Q. protocol 로 구현했기 때문에 runtime 에 type casting 을 하는 과정이 있는데 해당 과정의 비용은 크지 않을까?
🌟 리뷰어 의견: type casting 때문에 발생하는 비용은 앱 운영하는데 그렇게 크지 않을걸로 판단 됨. type casting 을 피하려면 직접 코드로 구현을 하는것도 괜찮을 것 같다.
네비게이션
의 경우, 정보의 깊이와 흐름을 갖고 더 깊게 들어가는 설정의 형태를 가질 때에 적합한 방식모달
의 경우, 이용자가 흐름에서 잠깐 벗어나게 하는 목적으로 사용됨모달
의 방식이 의미에는 좀 더 적합할 것이라 판단🌟 리뷰어 의견: HIG의 기준으로 애니메이션을 선택해야 함. 모달이 더 어울릴 듯 하다.
delegate 에 왠만하면 weak를 붙이고, 클로저가 self를 캡쳐할 때도 weak self 를 붙이자. (당장은 순환참조가 일어나지 않을 수도 있지만, 추후 유지보수 시 어떻게 될지도 모르고 그 순간을 확신할 수 없기에!)