LODY/정리

UIKit로 화면 만들기 / 타깃액션과 네트워크 데이터로 화면 채우기

타깃액션과 네트워크 데이터로 화면 채우기

🟣 버튼을 누르면 왜 내 함수가 불릴까

지금까지 화면을 그렸지만, 아직 사용자 입력을 받지 못했다. 버튼을 눌러도 아무 일이 안 일어난다. 입력을 코드에 연결하는 UIKit의 방식이 타깃액션(target-action)이다.

발상은 단순하다. 버튼에게 "네가 눌리면 이 객체의 이 메서드를 불러라"라고 미리 등록해 두는 것이다. 등록할 객체가 타깃(target)이고, 부를 메서드가 액션(action)이다. 버튼은 자기가 무슨 일을 해야 하는지 모른 채, 그저 눌리는 순간 등록된 곳에 연락만 한다.

사용자 탭UIButton뷰컨트롤러@objc 메서드.touchUpInside등록된 액션 호출
button.addTarget(self,
                 action: #selector(didTapStart),
                 for: .touchUpInside)
 
@objc private func didTapStart() {
    print("시작 버튼이 눌렸다")
}

.touchUpInside는 "버튼 영역 안에서 눌렀다 뗐을 때"라는 이벤트다. 손가락을 버튼 밖으로 끌고 나가서 떼면 발동하지 않는데, 이 덕분에 "실수로 눌렀다"를 사용자가 취소할 수 있다.

🚀 #selector와 @objc는 왜 붙나

#selector는 "이 이름의 메서드를 나중에 부르겠다"고 메서드를 값으로 가리키는 문법이다. 여기서 걸리는 게 @objc다. 타깃액션은 Objective-C 시절의 런타임 메커니즘 위에서 동작해서, 이름만으로 메서드를 찾아 부르려면 그 메서드가 Objective-C 런타임에 노출돼 있어야 한다. @objc가 그 노출을 표시한다. 이걸 빠뜨리면 컴파일 단계에서 셀렉터를 못 찾는다는 오류가 난다. 입문기에 이유를 모른 채 "빨간 줄 뜨면 @objc 붙이기"로 넘겼는데, 배경을 알고 나니 왜 필요한지가 납득됐다.


🟣 타깃액션과 delegate: 두 콜백 방식

앞 장의 테이블뷰는 delegate로 탭을 전달받았고, 버튼은 타깃액션으로 탭을 전달받는다. 둘 다 "어떤 일이 생기면 알려 줘"라는 콜백이지만 결이 다르다.

타깃액션은 버튼 하나에 메서드 하나를 거는, 단발성 이벤트에 맞는 가벼운 방식이다. delegate는 하나의 대상이 여러 질문과 알림을 주고받는, 관계가 있는 방식이다. 테이블뷰가 "몇 줄이야?", "이 줄 눌렸어", "이 줄 지워도 돼?"처럼 여러 시점에 여러 번 소통해야 하기에 delegate가 맞는다.

정리하면 단순한 한 번의 액션은 타깃액션, 여러 시점에 걸친 소통은 delegate로 나뉜다. 이 둘의 결을 구분하고 나면, 새 API를 만났을 때 콜백을 어떤 방식으로 받는지가 자연스럽게 읽힌다.


🟣 JSON을 스위프트 타입으로: Codable

지금까지 목록을 코드에 하드코딩했다. 실제 앱은 서버에서 데이터를 받는다. 서버는 보통 JSON이라는 텍스트 형식으로 데이터를 준다. 이 JSON을 스위프트 값으로 바꾸는 표준 도구가 Codable이다.

Codable을 채택한 구조체를 선언하면, JSON의 키와 프로퍼티 이름을 맞춰 JSONDecoder가 알아서 값을 채워 준다.

struct Item: Codable {
    let id: Int
    let name: String
}
 
let json = """
[ { "id": 1, "name": "사과" }, { "id": 2, "name": "바나나" } ]
""".data(using: .utf8)!
 
let items = try JSONDecoder().decode([Item].self, from: json)
// items == [Item(id: 1, name: "사과"), Item(id: 2, name: "바나나")]

키 이름이 스위프트 관례와 다를 때가 많다. 서버가 user_name으로 주는데 스위프트에서는 userName으로 쓰고 싶다면, 디코더의 keyDecodingStrategy.convertFromSnakeCase로 두거나 CodingKeys로 직접 매핑한다. 디코딩이 실패하는 원인의 대부분은 키 이름 불일치나 타입 불일치다. 서버가 숫자를 문자열로 주는데 Int로 받으면 그 자리에서 디코딩이 멈춘다.


🟣 받아서 그리기: 전체 흐름을 잇는다

이제 조각을 모은다. 서버에서 데이터를 받아(URLSession), 스위프트 타입으로 바꾸고(Codable), 테이블뷰에 채워(dataSource) 다시 그린다(reloadData). 이 흐름이 실무에서 목록 화면을 만드는 가장 기본적인 골격이다.

서버 (JSON)URLSession디코딩된 모델[Item]테이블뷰reloadData()응답JSONDecoder메인 스레드에서 갱신
private func loadItems() {
    let url = URL(string: "https://example.com/items")!
    URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
        guard let data, error == nil else { return }
        guard let decoded = try? JSONDecoder().decode([Item].self, from: data)
        else { return }
 
        DispatchQueue.main.async {
            self?.items = decoded
            self?.tableView.reloadData()     // 반드시 메인 스레드에서
        }
    }.resume()
}

이 코드에 입문기의 함정 두 개가 압축돼 있다. 첫째, URLSession의 완료 콜백은 백그라운드 스레드에서 불린다. 그런데 UIKit의 화면 갱신은 반드시 메인 스레드에서 해야 한다. 그래서 reloadDataDispatchQueue.main.async로 감싼다. 이걸 빠뜨리면 화면이 가끔 멎거나 경고가 뜨는, 원인 찾기 어려운 버그가 된다.

둘째, 콜백에서 self[weak self]로 붙든 점이다. 네트워크 응답이 오기 전에 사용자가 화면을 닫으면 뷰컨트롤러가 사라져야 하는데, 콜백이 self를 강하게 붙들면 사라지지 못한다. 약하게 참조해 이 순환을 끊는다. 화면을 넘나드는 비동기 작업에서는 이 참조 관계를 늘 의식해야 한다는 걸 이 패턴에서 처음 배웠다.

마지막으로 .resume()을 빼먹으면 요청 자체가 시작되지 않는다. 만든 작업(task)은 명시적으로 시작해야 한다.


🟣 정리

  • 타깃액션은 "눌리면 이 메서드를 불러라"를 버튼에 등록하는 방식이다. #selector로 메서드를 가리키고, 그 메서드에는 Objective-C 런타임 노출을 위해 @objc를 붙인다.
  • 단발성 액션은 타깃액션, 여러 시점에 걸친 소통은 delegate로 나뉜다. 둘 다 콜백이지만 쓰임의 결이 다르다.
  • CodableJSONDecoder로 JSON을 스위프트 타입으로 바꾼다. 디코딩 실패는 대개 키 이름이나 타입 불일치에서 온다.
  • 서버 응답으로 화면을 채우는 골격은 요청·디코딩·갱신의 순서다. 화면 갱신은 반드시 메인 스레드에서 하고, 비동기 콜백에서는 [weak self]로 참조 순환을 끊는다.
  • URLSession 작업은 .resume()으로 시작해야 실제로 요청이 나간다.

실무에서 "화면이 가끔 멎는다"의 상당수는 메인 스레드 밖에서 UI를 건드린 경우이고, "뷰컨트롤러가 메모리에서 안 사라진다"의 상당수는 비동기 콜백의 강한 참조다. 면접에서 "네트워크 응답을 받아 화면을 갱신할 때 주의할 점"을 물으면 이 두 가지, 즉 메인 스레드 갱신과 참조 순환을 짚는 것이 정답에 가깝다.

여기까지가 스토리보드 없이 코드로 iOS 화면 하나를 처음부터 끝까지 세우는 흐름이다. 뷰를 붙이고, 화면을 넘기고, 어떤 기기에서도 깨지지 않게 배치하고, 컴포넌트를 MVC로 정리하고, 입력을 연결해 서버 데이터로 채웠다. 이 골격이 손에 붙으면, 이후 만나는 SwiftUI나 다른 아키텍처 패턴도 "무엇을 대체하려는 것인지"를 기준으로 훨씬 빠르게 읽힌다.