어제와 오늘은 지난주부터 진행해온 과제를 보다 완성도 있게 수정하며 겪은 문제점과 이를 통해 알게 된 점, 해결하기 위해 접근한 방법 등을 정리하는 시간을 가졌다. 완벽하다고는 할 수 없지만, 최대한 완성도를 높이기 위해 노력했다는 점은 분명하다. 과제를 진행하며 사용하지는 않았지만, 좀 더 효율적인 로직을 구현하기 위해 다른 방법이 있는가에 대한 생각과 의견 공유를 팀원과 할 수 있었으며, 이 시간을 통해 아직 배우지 못한 것들에 대한 흥미를 느끼며 공부했다.
어제 정리와 이어지는 과제를 하며 마주한 문제점, 구상 및 해결에 대한 정리이다.
기존의 ViewController는 랜더링하는 View를 내부에 포함하고 있어 Controller 역할만을 수행하고 있지 않았다. 이를 수정하고자 MainView.swift 파일을 생성하고 View 로직을 분리해주었다.
하지만 생각하지 못한 부분들로 인해 수정을 한번에 완료하지 못했는데 ..
import UIKit
import SnapKit
class MainView: UIView {
let titleText = UILabel()
// let seriesButton = SeriesButton()
let seriesStackView = UIStackView()
var seriesButtons: [SeriesButton] = []
let scrollView = UIScrollView() // 스크롤 뷰 생성
let contentView = UIStackView() // 스크롤 뷰 내부 메인 뷰
let bookInfoView = BookInfoStackView() // bookInfoView 생성
let bookSummaryStackView = BookSummaryStackView()
let bookChapterStackView = BookChapterStackView()
override init(frame: CGRect) {
super.init(frame: frame)
setAttributes()
setLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension MainView {
private func setAttributes() {
self.backgroundColor = .white
// titleText.text = "ASDFASDFASDFASDFSADFSADFASDFSADFSADFSADFSADS"
titleText.textColor = .black
titleText.font = .systemFont(ofSize: 24, weight: .bold)
titleText.numberOfLines = 0 // 줄 바꿈 제한 x
titleText.textAlignment = .center // 텍스트 중앙 정렬
// seriesButton.setTitle("1", for: .normal)
// seriesButton.setTitleColor(.white , for: .normal)
// seriesButton.titleLabel?.font = .systemFont(ofSize: 16)
// seriesButton.backgroundColor = .systemBlue
// seriesButton.layer.cornerRadius = 8
seriesStackView.axis = .horizontal
seriesStackView.spacing = 6
seriesStackView.distribution = .fillEqually
seriesStackView.alignment = .center
scrollView.showsVerticalScrollIndicator = false // 스크롤 바 숨기기
contentView.axis = .vertical
contentView.spacing = 24 // contentView 내부 컴포넌트들의 거리 24
contentView.alignment = .leading
}
private func setLayout() {
[titleText, seriesStackView, scrollView].forEach { self.addSubview($0) }
scrollView.addSubview(contentView)
// view.addSubview(bookInfoView) // bookInfoView 추가
// view.addSubview(bookSummaryStackView) //bookSummaryStackView 추가
[bookInfoView, bookSummaryStackView, bookChapterStackView].forEach {
contentView.addArrangedSubview($0)
}
titleText.snp.makeConstraints {
$0.leading.trailing.equalToSuperview().inset(20)
$0.top.equalTo(self.safeAreaLayoutGuide).offset(10)
}
seriesStackView.snp.makeConstraints {
// $0.leading.trailing.equalToSuperview().inset(20)
$0.leading.trailing.greaterThanOrEqualToSuperview().inset(20) // leading, trailing 추가
$0.centerX.equalToSuperview()
$0.top.equalTo(titleText.snp.bottom).offset(16)
/*$0.width.equalTo(seriesButton.snp.height)*/ // height에 width 고정 -> 가로, 세로 비율 유지
}
//scrollView 속성 정의
scrollView.snp.makeConstraints {
$0.top.equalTo(seriesStackView.snp.bottom).offset(20) // seriesStackView 기준으로 변경
$0.leading.trailing.equalToSuperview().inset(20)
$0.bottom.equalToSuperview()
}
//contentView 속성 정의 -> scrollView에 맞춤
contentView.snp.makeConstraints {
$0.edges.equalTo(scrollView.contentLayoutGuide)
$0.width.equalTo(scrollView.frameLayoutGuide)
}
// bookInfoView.snp.makeConstraints {
// $0.top.equalTo(seriesButton.snp.bottom).offset(20)
// $0.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(20)
// }
//
// bookSummaryStackView.snp.makeConstraints {
// $0.top.equalTo(bookInfoView.snp.bottom).offset(24)
// $0.leading.trailing.equalToSuperview().inset(20)
// }
}
}
extension MainView {
// 기존 : 버튼 1개 생성 -> 배열로 받아와서 개수만큼 버튼 생성
func setSeriesButton(with books: [Book], target: Any, action: Selector) { // 시리즈 버튼 생성 함수 분리 : private 안됨
// 기본 버튼 생성, 속성 정의
for idx in books.indices {
let button = SeriesButton()
button.setTitle("\(idx + 1)", for: .normal)
button.setTitleColor(.systemBlue, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16)
button.backgroundColor = .systemGray5
button.tag = idx
button.addTarget(target, action: action, for: .touchDown)
button.snp.makeConstraints {
$0.width.equalTo(button.snp.height)
}
self.seriesStackView.addArrangedSubview(button)
self.seriesButtons.append(button)
}
}
}
// UIButton 상속받는 커스텀 SeriesButton 생성 : ( 레이아웃 결정 이후 cornerRadius 적용되기 때문 )
class SeriesButton: UIButton {
override func layoutSubviews() {
super.layoutSubviews()
self.layer.cornerRadius = self.frame.height / 2
self.clipsToBounds = true
}
}
import UIKit
import SnapKit
class ViewController: UIViewController {
let dataService = DataService() // DataService 생성
var books: [Book] = [] //받아온 데이터 저장용 배열
let mainView = MainView()
override func loadView() { // 기존의 View를 mainView로 변경하기 (레이아웃 설정 불필요)
self.view = mainView
}
override func viewDidLoad() {
super.viewDidLoad()
// 기존의 View 위에 덮어씌우기 (레이아웃 설정 필요)
// view.addSubview(mainView)
// mainView.snp.makeConstraints {
// $0.edges.equalToSuperview()
// }
setDelegate()
loadBooks()
}
}
extension ViewController {
private func setDelegate() {
mainView.bookSummaryStackView.delegate = self // bookSummaryStackView의 delegate는 ViewController 자신이다.
}
}
// data.json 파싱 이후 titleText에 적용, book에도 적용
extension ViewController {
func loadBooks() {
dataService.loadBooks { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let books):
self.books = books
mainView.setSeriesButton(with: books, target: self, action: #selector(seriesButtonTapped(_:))) // 기존 : ViewController 내부에서 실행하기 때문에 target을 self로 선언, action을 바로 사용가능했지만 mainView로 분리하여 매개변수로 넘겨줘야 함.
if let firstBook = books.first {
self.infoUpdate(with: firstBook, idx: 0) // 업데이트 정보가 많아져서 함수로 분리
selectedSeriesButton(0) // 기본 앱 실행 시 1권 표시 : 1번 버튼 선택
}
case .failure(let error):
let alert = UIAlertController(title: "Error", message: "\(error)", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
DispatchQueue.main.async {
self.present(alert, animated: true)
}
// self.present(alert, animated: true)
}
}
}
// 정보 업데이트 함수 분리
func infoUpdate(with book: Book, idx: Int) {
mainView.titleText.text = book.title
mainView.bookInfoView.configure(with: book, idx: idx) // bookInfoView.configure 함수에 idx 넘겨주기
// isFolded_\(book.title) 상대로 저장하는 이유 : 다음 챕터에서 책에 따라 버튼 생성 시 개별적으로 상태 저장하기 위해
let isSaved = UserDefaults.standard.object(forKey: "isFolded_\(book.title)") != nil // UserDefauls에 isFolded_isFolded_\(book.title) 상태로 저장된 값 유무 확인
mainView.bookSummaryStackView.config(dedication: book.dedication, summary: book.summary, folded: isSaved)
mainView.bookChapterStackView.config(with: book.chapters)
}
}
// 버튼 관련 메소드 관리
extension ViewController {
// 버튼을 눌렀을 때, 동작하는 메서드 정의
@objc
private func seriesButtonTapped(_ sender: SeriesButton) {
let idx = sender.tag
let book = books[idx]
infoUpdate(with: book, idx: idx) // infoUpdate에 idx 넘겨주기
mainView.scrollView.setContentOffset(.zero, animated: false) // 스크롤 위치 초기화
selectedSeriesButton(idx)
}
// 버튼 눌렸을 때 상태 변화 메서드 정의
private func selectedSeriesButton(_ selectedSeriesIdx: Int) {
for (idx, btn) in mainView.seriesButtons.enumerated() {
btn.backgroundColor = (idx == selectedSeriesIdx) ? .systemBlue : .systemGray5
let titleColor: UIColor = (idx == selectedSeriesIdx) ? .white : .systemBlue
btn.setTitleColor(titleColor, for: .normal)
}
}
}
// Delegate 사용
extension ViewController: BookSummaryStackViewDelegate {
func onTapExtraButton(isFolded: Bool) {
guard let title = mainView.titleText.text else { return }
if isFolded {
UserDefaults.standard.set(true, forKey: "isFolded_\(title)") // 접혀있는 상태일 경우, UserDefaults에 isFolded_\(title) 형태로 저장
} else {
UserDefaults.standard.removeObject(forKey: "isFolded_\(title)") // 더보기 상태일 경우, UserDefaults에 저장된 isFolded_\(title) 제거
}
}
}
또한, 디렉토리 구조를 확실하게 분리했다.

기존의 로직은 releaseDate를 String 타입으로 파싱하여 형식을 변경하고 다시 UILabel에 넣는 과정동안 String -> Date -> String 이렇게 3번의 과정을 거쳐야 했다. 또한, 파싱된 데이터를 저장한 상태에서 다시 타입을 변경하는 과정은 매우 비효율적이었기에, data.json의 release_date를 파싱할 때 Date 타입으로 반환하고자 로직을 변경했다.
let decoder = JSONDecoder() // 디코더 생성
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd" // 형식 생성
decoder.dateDecodingStrategy = .formatted(dateFormatter) // decoder에 형식을 주입
let bookResponse = try decoder.decode(BookResponse.self, from: data) // 형식에 맞는 데이터 발견할 시 Date로 변환
디코더를 생성한 후, 해당 디코더에 Date 타입으로 변경할 형식을 저장하여 넘겨준다. 본인은 이해를 쉽게 하기 위해 붕어빵 틀을 만들어서 저장한다고 생각했다. 해당 로직을 통해, 데이터를 순회하며 파싱할 때 해당 형식에 맞는 String 타입 데이터를 발견하면 Date 타입으로 변경하여 저장하게 된다.
만약 data.json 파일에 문제가 생겨서 데이터를 읽을 수 없게되면 어떻게 해야할까? 당연히, 사용자에게 문제를 알려줘야 한다. 이를 위해 Alert를 사용해야 하는데 처음 구현한 코드는 아래와 같다.
case .failure(let error):
print("에러 : \(error)")
let alert = UIAlertController(title: "Error", message: "\(error)", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
self.present(alert, animated: true)
}
}
}
위와 같이 구현하면, 레이아웃을 설정할 수 없기에 화면은 흰배경이 보여야하며 Alert가 표시될거라고 생각했지만, 아무것도 표시되지 않았다.
ViewController에는 LifeCycle이 존재하는데, 그 중 viewDidLoad는 view가 화면에 올라가 있는 상태가 아니라 이제 view를 메모리에 로드한 상태이다. 현재 코드는 viewDidLoad에서 loadBooks()를 호출하고 있기 때문에, Alert(view)는 화면에 출력되지 않는다.
DispatchQueue.main.async {
self.present(alert, animated: true)
}
이를 해결하기 위한 방법으로 Alert를 표시하는 구문을 DispatchQueue.main.async를 사용할 수 있다. 이는, LifeCycle의 특징을 이용한 방법으로 클로저 내부의 코드는 즉시 실행되지 않는다. 현재 진행 중인 viewDidLoad와 그 이후의 뷰 로딩 절차(viewWillAppear 등)가 메인 스레드에서 먼저 끝날 때까지 기다리고 시스템이 뷰 계층을 화면에 안착시킨 직후, 비어있는 메인 스레드에서 대기하던 Alert 코드를 꺼내어 실행한다. 쉽게 말해서, 화면에 view가 표시되기 위해서는 viewDidAppear 상태여야 하는데, 시스템이 이제 화면에 뭘 띄워도 되는 상태가 되었을 때 Alert를 알아서 표시해준다는 것이다.
또한, 다른 방법으로 loadBooks()를 viewDidLoad가 아니라 viewDidAppear에서 사용해도 출력된다.
// override func viewDidAppear(_ animated: Bool) {
// super.viewDidAppear(animated)
//
// loadBooks()
// }
기존(6번)에 ViewController를 MainView를 통해 분리하였지만, 코드를 읽어보다가 문득 이 부분도 Controller의 역할보다는 View에 있어야하지않을까 생각이 드는 곳이 있었다. infoUpdate() 메소드에서 각 StackView들이 config를 적용해주는 코드가 존재하는데, 이 코드들은 MainView로 분리해야 할 것 같다고 생각했다. 또한, 버튼을 눌렀을 때 선택된 버튼을 표시하는 메소드 selectedSeriesButton()에서도 View의 변경을 Controller에서 적용하는 것보다는 View에 정의된 색상 변경 메소드를 불러와서 사용하도록 구현하는 게 낫다고 생각하여 리팩토링을 진행했다.
extension MainView {
func config(with book: Book, idx: Int, isFolded: Bool) {
titleText.text = book.title
bookInfoStackView.config(with: book, idx: idx) // bookInfoView.config 함수에 idx 넘겨주기
bookSummaryStackView.config(dedication: book.dedication, summary: book.summary, folded: isFolded)
bookChapterStackView.config(with: book.chapters)
}
}
func updateButtonColor(_ selectedSeriesIdx: Int) {
for (idx, btn) in self.seriesButtons.enumerated() {
btn.backgroundColor = (idx == selectedSeriesIdx) ? .systemBlue : .systemGray5
let titleColor: UIColor = (idx == selectedSeriesIdx) ? .white : .systemBlue
btn.setTitleColor(titleColor, for: .normal)
}
}
기존의 코드를 실행하고 시뮬레이터상에서 Landscape를 사용하면 문제되는 부분이 하나 있었다. Landscape 모드에서는 시리즈 버튼의 크기가 매우 커진다는 것이다. 이 문제를 해결하기 위해 왜 버튼의 크기가 커지는 지에 대해 생각해볼 필요가 있었다.
생각
현재 코드에서는 글자의 크기를 통해 버튼의 크기가 정해진다. 즉, 버튼의 크기가 직접적으로 명시되어있지 않다. 또한, 시리즈 버튼을 담고있는 스택뷰의 크기는 leading과 trailing이 greaterThanOrEqualToSuperView().inset(20)으로 선언되어 있다. 이 말은 스택뷰의 크기가 SuperView보다 20보다 작거나 같게 배치된다는 말이다. Landscape에서는 가로의 길이가 기존의 길이보다 길어지기 때문에 스택뷰가 동적으로 늘어난다?
그런데 우리는 EqualToSuperView가 아니라 greaterThanOrEqualToSuperView를 사용했는데, 왜 늘어나는 것인가?
직접 적용해보며 내린 결론
먼저, 본인은 StackView에 우선순위 제약을 걸어보았다.
$0.leading.trailing.greaterThanOrEqualToSuperview().inset(20).priority(.high)
seriesStackView.setContentHuggingPriority(.required, for: .horizontal)
버튼의 크기는 고정되지 않았다.
단순히 스택뷰의 크기가 커지는 것의 우선순위를 줄여보았지만, 버튼의 크기가 커진다.. 버튼의 크기가 커지지 않도록 우선순위를 높여보자
button.setContentHuggingPriority(.required, for: .horizontal)
버튼이 커지지 않는다. 또한, 스택뷰의 크기도 늘어나지 않는다. 기존의 Portrait 모드에서도 스택뷰의 크기가 leading, trailing이 20이라는 제약에 걸리지 않고 버튼의 크기, 개수에 따라 적용된다.
왜 이럴까?
찾아본 바에 의하면, StackView의 기본 우선순위는 1000이다.(즉, required) 또한 Button의 기본 우선 순위는 250(즉, Low)다.
때문에, StackView에만 우선 순위를 적용했을 때는 button의 우선순위가 밀리기 때문에, 버튼의 크기가 스택뷰에 최대로 당겨지게 되는 것이다.
결론적으로, 아래와 같이 코드를 구성했다. StackView의 leading과 trailing의 우선 순위를 high(750)으로 주고 Button의 우선 순위를 required(1000)으로 주게 된다면, 버튼의 크기는 글자의 크기에 맞게 무조건 고정시킬거야라고 말하게 되고 스택뷰는 내부의 버튼의 크기에 맞출게라고 말하게 되는 것이다.
$0.leading.trailing.greaterThanOrEqualToSuperview().inset(20).priority(.high)
// seriesStackView.setContentHuggingPriority(.required, for: .horizontal)
button.setContentHuggingPriority(.required, for: .horizontal)