LODY/정리

UIKit로 화면 만들기 / 뷰컨트롤러 생명주기와 화면 전환

뷰컨트롤러 생명주기와 화면 전환

🟣 화면 하나에 담당자 하나

앞 장에서 화면은 뷰의 트리라고 했다. 그런데 그 트리를 누가 만들고, 데이터를 채우고, 화면이 나타나고 사라질 때 뒷정리를 하는가. 그 담당자가 UIViewController다. iOS에서는 화면 하나에 뷰컨트롤러 하나를 대응시키는 것이 기본이다. 로그인 화면, 목록 화면, 상세 화면이 각각 별도의 뷰컨트롤러다.

뷰컨트롤러는 자기 소유의 루트 뷰(view 프로퍼티)를 하나 갖는다. 우리가 만드는 버튼이나 레이블은 전부 이 view의 서브뷰로 붙는다. 그래서 뷰컨트롤러는 "화면 한 장의 뷰 트리를 소유하고 그 수명을 관리하는 객체"라고 보면 정확하다.

처음엔 뷰와 뷰컨트롤러의 경계가 흐릿했다. 뷰는 그리는 일만 하고, 언제 무엇을 채우고 비울지를 정하는 판단은 뷰컨트롤러의 몫이라고 나누고 나니 역할이 선명해졌다.


🟣 생명주기: 화면이 나타나고 사라지는 순간들

뷰컨트롤러에는 정해진 순서로 불리는 메서드들이 있다. 이 순서를 생명주기라 부른다. 어떤 코드를 어디에 둘지가 계속 헷갈렸는데, 각 메서드가 "언제" 불리는지를 알고 나서 자리가 정해졌다.

viewDidLoad(딱 한 번)viewWillAppearviewDidAppearviewWillDisappearviewDidDisappear화면 벗어남다시 등장

viewDidLoad는 뷰가 메모리에 처음 올라온 직후 딱 한 번 불린다. 그래서 서브뷰를 붙이고 제약을 걸고 색을 칠하는, 한 번만 해도 되는 초기 설정을 여기 둔다. 여기서 흔한 실수가 화면 크기에 의존하는 계산을 하는 것이다. 이 시점엔 아직 최종 크기가 정해지지 않아 값이 어긋난다.

viewWillAppear는 화면이 나타나기 직전마다 불린다. "직전마다"가 핵심이다. 화면에 들어올 때마다 매번 실행되므로, 다시 돌아왔을 때 갱신해야 하는 것을 여기 둔다. viewDidAppear는 완전히 나타난 뒤라, 화면에 실제로 보인 다음에 시작해야 하는 애니메이션이나 트래킹에 맞다.

viewWillDisappearviewDidDisappear는 반대로 화면이 사라질 때 불린다. 편집 중이던 내용을 저장하거나, 켜 둔 타이머를 멈추는 뒷정리를 여기서 한다.

🚀 viewDidLoad와 viewWillAppear를 가르는 기준

둘 사이에서 가장 많이 헷갈렸다. 기준은 단순하다. 한 번만 해도 되면 viewDidLoad, 돌아올 때마다 다시 해야 하면 viewWillAppear. 예를 들어 버튼을 만들어 붙이는 건 한 번이면 되니 viewDidLoad, 다른 화면에서 값을 고치고 돌아왔을 때 목록을 다시 불러오는 건 매번 필요하니 viewWillAppear다. 이 구분을 뒤에서 화면 전환과 함께 다시 짚는다.


🟣 화면을 쌓는 두 가지 방법

화면을 새로 띄우는 방식은 크게 두 가지다. 위에서 덮듯이 올리는 방식(present)과, 뒤로 갈 수 있게 쌓는 방식(push)이다.

present는 새 화면을 현재 화면 위로 모달처럼 띄운다. 아래에서 위로 슬라이드되어 올라오는 그 동작이다. 로그인, 사진 선택처럼 하나의 작업을 끝내고 원래 자리로 돌아오는 흐름에 맞는다. 닫을 때는 dismiss를 호출한다.

let detail = DetailViewController()
present(detail, animated: true)   // 위로 덮어 띄운다
// 닫을 때
dismiss(animated: true)

push는 네비게이션 컨트롤러가 화면을 스택에 쌓는 방식이다. 오른쪽에서 왼쪽으로 밀려 들어오고, 상단에 자동으로 뒤로 가기 버튼이 생긴다. 목록에서 상세로, 다시 더 깊은 상세로 파고드는 계층적 이동에 맞는다.


🟣 네비게이션 컨트롤러: 화면을 쌓는 스택

push를 쓰려면 화면들을 담아 줄 UINavigationController가 필요하다. 이름 그대로 화면을 스택으로 쌓아 관리하는 특수한 컨트롤러다. 맨 아래에 첫 화면(루트)을 두고, pushViewController로 위에 새 화면을 올리고, 뒤로 가기(popViewController)로 한 장씩 걷어낸다.

UINavigationController(스택 관리)화면 A (루트)화면 B화면 C(현재 최상단)pop은 위에서부터 한 장씩 걷어낸다스택 바닥pushpush

상단의 네비게이션 바는 이 스택을 보여 주는 표시줄이다. 현재 화면의 제목이 가운데에 뜨고, 스택에 이전 화면이 있으면 왼쪽에 뒤로 가기 버튼이 자동으로 생긴다. 제목은 각 뷰컨트롤러의 title 프로퍼티로 정하고, 오른쪽 버튼 같은 건 navigationItem으로 붙인다.

navigationController?.pushViewController(next, animated: true)
// 뒤로
navigationController?.popViewController(animated: true)

🟣 rootViewController: 앱이 처음 보여 줄 화면

앱을 켜면 무엇이 가장 먼저 뜰지는 UIWindowrootViewController가 정한다. 윈도우는 화면 전체를 덮는 최상위 컨테이너이고, 그 위에 올릴 첫 뷰컨트롤러를 지정하는 자리가 rootViewController다. 스토리보드를 쓰지 않을 때는 이 연결을 코드로 직접 해 준다.

// SceneDelegate에서
let window = UIWindow(windowScene: scene)
let root = UINavigationController(rootViewController: HomeViewController())
window.rootViewController = root
window.makeKeyAndVisible()
self.window = window

첫 화면을 네비게이션 컨트롤러로 감싸 rootViewController에 넣으면, 그 안의 모든 화면이 push로 쌓일 수 있게 된다. 로그인 성공 후 메인 화면으로 통째로 바꾸고 싶을 때도 이 rootViewController를 교체하는 방식을 쓴다.


🟣 돌아왔더니 화면이 그대로다: 갱신 시점 문제

입문기에 크게 데인 문제가 있다. 상세 화면에서 값을 고치고 목록으로 돌아왔는데 목록이 옛날 값 그대로인 것이다. 원인은 목록을 채우는 코드를 viewDidLoad에 둔 데 있었다. viewDidLoad는 처음 한 번만 불리므로, 돌아왔을 때는 다시 실행되지 않는다.

해결은 갱신 코드를 viewWillAppear로 옮기는 것이다. 화면이 다시 나타날 때마다 불리므로, 돌아올 때마다 최신 데이터로 다시 그린다. 이때 앞 장의 두 방식이 다르게 동작한다는 점도 같이 알아야 한다.

dismiss로 모달을 닫고 아래 화면으로 돌아오면 그 아래 화면의 viewWillAppear가 불린다. pop으로 네비게이션 스택에서 빠져도 마찬가지로 이전 화면의 viewWillAppear가 불린다. 어느 방식이든 돌아온 화면의 viewWillAppear가 갱신 지점이라는 규칙 하나로 정리된다. 최신 데이터를 어디서 받아 오는지와 무관하게, 화면 갱신의 진입점은 이 메서드로 통일해 두면 실수가 확 줄었다.

한 가지 주의할 점이 있다. viewWillAppear에서 매번 무거운 네트워크 요청을 그대로 던지면 돌아올 때마다 낭비가 생긴다. 그래서 값이 실제로 바뀌었을 때만 다시 그리도록, 이전 화면에서 변경 여부를 전달받아 조건부로 갱신하는 방식으로 다듬게 된다.


🟣 정리

  • 화면 하나에 뷰컨트롤러 하나를 대응시킨다. 뷰컨트롤러는 루트 view를 소유하고 그 수명을 관리한다.
  • viewDidLoad는 한 번만 불려 초기 설정에 쓰고, viewWillAppear는 나타날 때마다 불려 갱신에 쓴다. "한 번이면 되는가, 매번 필요한가"가 기준이다.
  • present는 위로 덮는 모달, push는 네비게이션 스택에 쌓는 계층 이동이다. 닫기는 각각 dismisspop이다.
  • UINavigationController가 화면을 스택으로 관리하고, 상단 네비게이션 바가 그 상태를 보여 준다.
  • 앱의 첫 화면은 UIWindowrootViewController로 정한다. 스토리보드 없이 코드로 이 연결을 직접 만든다.
  • 돌아왔을 때 화면이 안 바뀌는 문제는 갱신 코드를 viewWillAppear로 옮겨 해결한다. dismisspop이든 돌아온 화면의 이 메서드가 진입점이다.

실무에서 생명주기 이해는 "왜 이 값이 아직 nil인가", "왜 화면이 갱신 안 되는가" 같은 문제를 짚는 첫 단추다. 면접에서도 "viewDidLoadviewWillAppear의 차이"는 거의 빠지지 않는데, 호출 시점과 갱신 시나리오를 함께 설명할 수 있으면 실제로 화면을 다뤄 본 사람으로 읽힌다.

여기까지 뷰를 붙이고 화면을 넘기는 흐름을 잡았다. 그런데 지금까지는 뷰의 위치를 좌표 숫자로 직접 박아 넣었다. 이러면 기기 크기가 조금만 달라져도 배치가 깨진다. 다음 장에서 어떤 화면에서도 깨지지 않게 잡아 주는 오토레이아웃을 본다.