본문 바로가기
iOS앱개발

iOS실무 11주차

by 김경래 2026. 5. 19.

 

 

 

 

 

오토레이아웃을 구현하는 방법은 5가지가 있다.

 

 

 

 

 

 

 

 

크기 지정을 안하고 상대적인 크기로 지정한다.

픽셀이 아닌 포이트 단위로 지정하다.

지정하는 제약조건을 constraint라고 한다.

 

 

 

 

NSLayoutAnchor를 실무적으로 사용하는 예

 

 

실무에서 NSLayoutAnchor는 스토리보드나 UI 데이터가 아닌 100% 코드로 UI를 작성할 때(Code-based UI) 오토레이아웃을 잡는 가장 표준적이고 직관적인 방법입니다.

예전 방식인 NSLayoutConstraint(item:attribute:relatedBy:...)에 비해 타입 안정성(Type Safety)이 보장되어, 예를 들어 세로축(top)을 가로축(leading)에 잘못 연결하는 실수를 컴파일 단계에서 막아줍니다.

 

 

 

가장 흔한 실무 예시: 카드 레이아웃 구성하기

프로필 이미지, 제목 레이블, 설명 레이블이 포함된 카드를 코드로 배치하는 실무적인 예제입니다.

 

import UIKit

class CustomCardView: UIView {
    
    // 1. UI 컴포넌트 선언
    let profileImageView: UIImageView = {
        let iv = UIImageView()
        iv.backgroundColor = .lightGray
        iv.layer.cornerRadius = 8
        iv.clipsToBounds = true
        iv.translatesAutoresizingMaskIntoConstraints = false
        return iv
    }()
    
    let titleLabel: UILabel = {
        let label = UILabel()
        label.text = "Gemini"
        label.font = .boldSystemFont(ofSize: 18)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    let descriptionLabel: UILabel = {
        let label = UILabel()
        label.text = "iOS Developer / AI Collaborator"
        label.font = .systemFont(ofSize: 14)
        label.textColor = .darkGray
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // 2. 레이아웃 설정
    private func setupUI() {
        backgroundColor = .white
        layer.cornerRadius = 12
        
        // 반드시 부모 뷰에 먼저 addSubview를 해야 앵커를 잡을 수 있습니다.
        addSubview(profileImageView)
        addSubview(titleLabel)
        addSubview(descriptionLabel)
        
        // 3. NSLayoutConstraint.activate를 사용해 한 번에 활성화 (성능상 이점)
        NSLayoutConstraint.activate([
            // 이미지 뷰: 탑, 리딩에서 16pt 띄우고 크기는 60x60 고정
            profileImageView.topAnchor.constraint(equalTo: self.topAnchor, constant: 16),
            profileImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16),
            profileImageView.widthAnchor.constraint(equalToConstant: 60),
            profileImageView.heightAnchor.constraint(equalToConstant: 60),
            
            // 타이틀 레이블: 이미지의 오른쪽(Trailing)에 위치, 탑은 이미지와 맞춤
            titleLabel.topAnchor.constraint(equalTo: profileImageView.topAnchor),
            titleLabel.leadingAnchor.constraint(equalTo: profileImageView.trailingAnchor, constant: 12),
            titleLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -16),
            
            // 설명 레이블: 타이틀 레이블 아래에 위치, 바텀은 카드 바닥과 연결하여 동적 높이 확보
            descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
            descriptionLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            descriptionLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
            descriptionLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -16)
        ])
    }
}

 

 

 

 

 

 

 

AutoLayout에서 가장 많이 사용하는 외부 라이브러리가 Snapkit이다.

 

 

 

 

 

 

 

 

 

Leading/Trailing, Top/Bottom, Heigh/Width

 

Leading: 왼쪽, Trailing: 오른쪽

 

 

 

 

 

 

 

 

위 4개의 툴을 이용하여 constraint를 잡는다.

 

 

 

 

size inspector

 

 

 

 

 

 

 

 

콘텐츠 허깅(Content Hugging)과 컴프레션 저항(Compression Resistance)

 

허깅과 컴프레션 조건은, 뷰의 사이즈가 담고 있는 콘텐츠보다 크지도 않고(허깅), 작지도 않게 (컴프레션) 맞춰주는 조건

 

허깅

    -고유 사이즈 이상으로 '늘어나지 않으려고 하는' 조건

    -줄어드는 것은 아님, 뷰가 가능한 작게 유지하려는 성질

 

컴프레션

    -고유 사이즈 이하로 '줄어들지 않으려고 하는' 조건

    -늘어나는 것은 아님, 뷰가 내용이 잘리지 않도록 유지하려는 성질

 

허깅의 우선순위 디폴트 값은 250,컴프레션은 750

    -A뷰의 컴프레션 constraint와 B뷰의 허깅 constraint가 충돌하면 컴프레션 constraint가 우선

    -컨텐츠가 잘리는 것보다는 뷰가 늘어나게 함

        -개발자가 설정하는 constraint 우선순위 기본 값: 1000

        -컴프레션 우선순위: 750

        -허깅 선순위: 250

 

내가 설정한 constraint 보다 콘텐츠 사이즈 조건이 작을 때 (공간이 부족)

    -잘리면 안되는 UI의 컴프레션 우선순위를 높임

 

내가 설정한 constraint 보다 컨텐츠 사이즈 조건이 클 때 (공간이 남음)

    -늘어나면 안되는 UI의 허깅 우선순위를 높임

 

 

 

 

 

 

 

 

 

 

 

 

 

//
//  ViewController.swift
//  Moviekkr
//
//  Created by 1 on 2026/04/28.
//

import UIKit

// MARK: - [1] API 응답 데이터 파싱을 위한 JSON 데이터 모델 (Codable)

/// JSON 데이터의 최상위 루트 구조체
struct MovieData : Codable {
    let boxOfficeResult : BoxOfficeResult // "boxOfficeResult" 키의 객체와 매핑
}

/// 박스오피스 결과 데이터를 담고 있는 구조체
struct BoxOfficeResult : Codable {
    let dailyBoxOfficeList : [DailyBoxOfficeList] // "dailyBoxOfficeList" 키의 배열 데이터와 매핑
}

/// 영화 한 편의 상세 정보를 담고 있는 구조체
struct DailyBoxOfficeList : Codable {
    let movieNm : String  // 영화 제목
    let audiCnt : String  // 해당 일자 관객 수
    let audiAcc : String  // 누적 관객 수
    let rank : String     // 박스오피스 순위
}


// MARK: - [2] 메인 뷰 컨트롤러 클래스 선언 및 프로토콜 채택
/// UITableViewDelegate(테이블뷰 동작 관리)와 UITableViewDataSource(테이블뷰 데이터 공급) 프로토콜을 채택함
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
    
    // MARK: - 인스턴스 변수 및 프로퍼티
    @IBOutlet weak var table: UITableView! // 스토리보드의 테이블 뷰와 연결된 아웃렛 변수
    
    var movieData : MovieData? // 서버에서 받아와서 디코딩한 영화 데이터를 저장할 옵셔널 변수
    
    // 영화진흥위원회 일별 박스오피스 API 주소 (맨 뒤의 targetDt= 값은 동적으로 결합 예정)
    var movieURL = "https://kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/searchDailyBoxOfficeList.json?key=eaa1c89ff79f8f288c9366ac031f7510&targetDt="
    
    // MARK: - 라이프사이클 메서드
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 테이블 뷰의 대리자(Delegate)와 데이터 소스(DataSource)를 현재 뷰 컨트롤러(self)로 지정
        table.dataSource = self
        table.delegate = self
        
        // URL 주소 끝에 어제 날짜 문자열(예: 20260518)을 붙여 완전한 주소 생성
        movieURL += makeYesterdayString()
        
        // API 서버로부터 데이터를 요청하고 테이블 뷰를 갱신하는 함수 호출
        getData()
    }
    
    // MARK: - 날짜 계산 유틸리티 함수
    /// 어제 날짜를 "yyyyMMdd" 형태의 문자열로 반환하는 함수
    func makeYesterdayString() -> String {
        // 1. 현재 날짜(Date())에서 하루(.day, value: -1)를 뺀 날짜 계산 (실패 시 오늘 날짜 반환)
        let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
        
        // 2. 날짜를 문자열로 바꾸기 위한 Formatter 생성
        let formatter = DateFormatter()
        
        // 3. API가 요구하는 형식(yyyyMMdd) 지정
        formatter.dateFormat = "yyyyMMdd"
        
        // 4. 변환된 어제 날짜 문자열 반환
        return formatter.string(from: yesterday)
    }
    
    // MARK: - 네트워킹 및 데이터 파싱 함수
    /// API 서버에서 데이터를 비동기적으로 가져와 파싱하고 화면을 갱신하는 함수
    func getData() {
        // 1. 완성된 문자열 URL을 URL 구조체 객체로 안전하게 변환 (실패 시 함수 종료)
        guard let url = URL(string: movieURL) else { return }
        
        // 2. 기본 설정(.default)을 사용하는 URLSession 인스턴스 생성
        let session = URLSession(configuration: .default)
        
        // 3. 특정 URL에 데이터를 요청하는 비동기 데이터 태스크(Data Task) 정의
        let task = session.dataTask(with: url) { data, response, error in
            
            // 3-1. 에러가 발생했다면 에러 처리를 따로 하지 않고 즉시 리턴(통신 실패 대응)
            if error != nil { return }
            
            // 3-2. 옵셔널인 data를 안전하게 언래핑하여 JSONdata에 할당 (데이터가 없으면 리턴)
            guard let JSONdata = data else { return }
            
            // 3-3. 받아온 Raw Binary 데이터를 UTF-8 형태의 문자열로 바꾼 뒤 콘솔에 출력 (디버깅용)
            let dataString = String(data: JSONdata, encoding: .utf8)
            print(dataString!)
            
            // 4. JSON 데이터를 Swift 구조체로 변환해줄 디코더 인스턴스 생성
            let decoder = JSONDecoder()
            
            do {
                // 4-1. JSONdata를 위에서 정의한 MovieData 구조체 타입으로 변환(디코딩) 시도
                let decodedData = try decoder.decode(MovieData.self, from: JSONdata)
                
                // 4-2. 디코딩이 잘 되었는지 확인하기 위해 1등 영화의 제목을 콘솔에 출력
                print(decodedData.boxOfficeResult.dailyBoxOfficeList[0].movieNm)
                
                // 4-3. 파싱 완료된 데이터를 클래스 멤버 변수인 movieData에 저장
                self.movieData = decodedData
                
                // 🚨 주의/피드백: 백그라운드 스레드에서 UI를 바꿀 때는 반드시 Main 스레드에서 실행해야 합니다.
                // 단, 실무에서는 간혹 데드락(Deadlock) 위험이 있는 .sync 대신 안전한 .async 사용을 권장합니다.
                DispatchQueue.main.sync {
                    self.table.reloadData() // 데이터를 새로 받았으니 테이블 뷰 화면을 다시 그리도록 명령
                }
                
            } catch {
                // 4-4. 디코딩 과정에서 구조체 매핑 실패 등의 에러가 발생하면 에러 내용을 콘솔에 출력
                print(error)
            }
        }
        
        // 5. 정의한 데이터 태스크를 실제로 실행(네트워크 요청 시작)
        task.resume()
    }
    
    
    // MARK: - [3] UITableViewDataSource & Delegate 구현 메서드
    
    /// 테이블 뷰의 한 섹션당 표시할 셀(행)의 개수를 설정하는 메서드
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // 일별 박스오피스는 항상 10개의 데이터를 주기 때문에 고정값 10을 리턴
        return 10
    }
    
    /// 테이블 뷰의 전체 섹션 개수를 설정하는 메서드 (기본값 1개)
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    /// 유저가 특정 행(Row)을 터치하여 선택했을 때 실행되는 동작 정의 메서드
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // 선택된 행의 섹션과 행 번호 정보를 콘솔에 출력 (디버깅용)
        print(indexPath.description)
    }
    
    /// 테이블 뷰의 각 행에 들어갈 셀을 설정하고 데이터를 바인딩하는 핵심 메서드
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        // 1. 재사용 큐에서 "myCell" 식별자를 가진 셀을 꺼내오고, 커스텀 셀 클래스인 MyTableViewCell로 강제 타입 캐스팅
        let cell = tableView.dequeueReusableCell(withIdentifier: "myCell", for: indexPath) as! MyTableViewCell
        
        // 2. [기본 데이터 세팅] 데이터 모델에서 현재 행(indexPath.row)에 맞는 영화 정보를 꺼내 셀의 각 레이블에 기본 바인딩
        cell.movieName.text = movieData?.boxOfficeResult.dailyBoxOfficeList[indexPath.row].movieNm
        cell.audiAccumulate.text = movieData?.boxOfficeResult.dailyBoxOfficeList[indexPath.row].audiAcc
        cell.audiCount.text = movieData?.boxOfficeResult.dailyBoxOfficeList[indexPath.row].audiCnt
        
        // 3. [어제 관객 수 포맷팅] 당일 관객 수 값이 존재한다면 안전하게 꺼내옴
        if let aCnt = movieData?.boxOfficeResult.dailyBoxOfficeList[indexPath.row].audiCnt {
            let numF = NumberFormatter()
            numF.numberStyle = .decimal // 3자리마다 쉼표(,)를 찍어주는 스타일 설정
            
            let aCount = Int(aCnt)! // 문자열인 관객 수를 정수형(Int)으로 변환
            let result = numF.string(for: aCount)! + "명" // 숫자를 포맷팅하고 뒤에 "명"을 붙임
            
            cell.audiCount.text = "어제 관객 : \(result)" // 셀의 당일 관객 수 레이블 텍스트 갱신
        }
        
        // 4. [누적 관객 수 포맷팅] 누적 관객 수 값이 존재한다면 안전하게 꺼내옴
        if let aAcc = movieData?.boxOfficeResult.dailyBoxOfficeList[indexPath.row].audiAcc {
            // 숫자를 천 단위로 콤마(,) 찍어주는 NumberFormatter 생성
            let numF = NumberFormatter()
            numF.numberStyle = .decimal // 숫자 스타일을 3자리마다 쉼표가 찍히는 형식으로 지정
            
            // aAcc는 문자열로 되어 있으니 Int로 변환 (무조건 변환된다고 가정해서 강제 언래핑)
            let aAcc1 = Int(aAcc)!
            
            // 숫자를 포맷해서 문자열로 변환한 뒤, "명"을 붙임
            let result = numF.string(for: aAcc1)! + "명"
            
            // 셀의 레이블에 "누적 : 123,456명" 같은 문구를 셋팅
            cell.audiAccumulate.text = "누적 : \(result)"
        }
        
        // 데이터 설정이 끝난 최종 셀 객체를 반환하여 화면에 표시함
        return cell
    }
    
    /// 테이블 뷰 상단에 표시될 헤더(Header) 타이틀을 설정하는 메서드
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        // 박스오피스 문구와 함께 어제 날짜를 보여줌
        return "🍿박스오피스(영화진흥위원회제공:" + makeYesterdayString() + ")🍿"
    }
    
    /// 테이블 뷰 하단에 표시될 푸터(Footer) 타이틀을 설정하는 메서드
    func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        // 제작자 정보를 하단 푸터에 상시 표기
        return "made by ggongs 김경래"
    }
}

 

 

 

 

리팩토링 코드

1. Model (데이터 모델 구조체 개편)

구조체 이름과 프로퍼티명을 Swift 스타일(CamelCase)로 유지하되, 코드가 깔끔해지도록 내부 배열을 바로 꺼내 쓸 수 있게 연산 프로퍼티를 추가했습니다.

import Foundation

struct MovieData: Codable {
    let boxOfficeResult: BoxOfficeResult
    
    // ViewController에서 접근하기 쉽도록 헬퍼 프로퍼티 추가
    var dailyMovies: [DailyBoxOfficeList] {
        return boxOfficeResult.dailyBoxOfficeList
    }
}

struct BoxOfficeResult: Codable {
    let dailyBoxOfficeList: [DailyBoxOfficeList]
}

struct DailyBoxOfficeList: Codable {
    let movieNm: String  // 영화 제목
    let audiCnt: String  // 당일 관객수
    let audiAcc: String  // 누적 관객수
    let rank: String     // 순위
}

 

 

 

2. Service (네트워크 전송 및 비동기 처리 분리)

네트워크 통신만 전담하는 구조체입니다. 탈출 클로저(@escaping)를 사용하여 결과를 뷰 컨트롤러에 안전하게 던져줍니다.

 

import Foundation

struct MovieService {
    private let apiKey = "eaa1c89ff79f8f288c9366ac031f7510"
    private let baseURL = "https://kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/searchDailyBoxOfficeList.json"
    
    /// API에서 데이터를 비동기로 받아오는 함수
    func fetchDailyBoxOffice(completion: @escaping (MovieData?) -> Void) {
        let targetDate = makeYesterdayString()
        let urlString = "\(baseURL)?key=\(apiKey)&targetDt=\(targetDate)"
        
        guard let url = URL(string: urlString) else {
            completion(nil)
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, _, error in
            // 에러가 있거나 데이터가 없으면 nil 반환
            guard error == nil, let jsonData = data else {
                completion(nil)
                return
            }
            
            // 디코딩 시도
            do {
                let decodedData = try JSONDecoder().decode(MovieData.self, from: jsonData)
                completion(decodedData)
            } catch {
                print("디코딩 실패: \(error)")
                completion(nil)
            }
        }.resume()
    }
    
    /// 어제 날짜를 yyyyMMdd 형식으로 반환하는 헬퍼 함수
    func makeYesterdayString() -> String {
        let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyyMMdd"
        return formatter.string(from: yesterday)
    }
}

 

 

3. Custom Cell (데이터 매핑 역할을 셀에게 위임)

NumberFormatter 세팅과 텍스트 변경 로직을 커스텀 셀 내부로 숨깁니다. ViewController 코드가 획기적으로 줄어듭니다.

 

import UIKit

class MyTableViewCell: UITableViewCell {
    @IBOutlet weak var movieName: UILabel!
    @IBOutlet weak var audiCount: UILabel!
    @IBOutlet weak var audiAccumulate: UILabel!
    
    // 숫자를 3자리마다 쉼표(,)를 찍어주는 포맷터 (매번 생성하지 않도록 정적 변수로 선언하여 성능 최적화)
    private static let numberFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        return formatter
    }()
    
    /// 셀에 데이터를 안전하게 설정하는 함수
    func configure(with movie: DailyBoxOfficeList) {
        movieName.text = movie.movieNm
        
        // 당일 관객수 문자열 -> Int 안전하게 변환 후 포맷팅
        if let countInt = Int(movie.audiCnt),
           let formattedCount = MyTableViewCell.numberFormatter.string(from: NSNumber(value: countInt)) {
            audiCount.text = "어제 관객 : \(formattedCount)명"
        } else {
            audiCount.text = "어제 관객 : 정보 없음"
        }
        
        // 누적 관객수 문자열 -> Int 안전하게 변환 후 포맷팅
        if let accInt = Int(movie.audiAcc),
           let formattedAcc = MyTableViewCell.numberFormatter.string(from: NSNumber(value: accInt)) {
            audiAccumulate.text = "누적 : \(formattedAcc)명"
        } else {
            audiAccumulate.text = "누적 : 정보 없음"
        }
    }
}

 

 

 

4. ViewController (컨트롤러 역할에만 집중)

네트워크와 데이터 가공 로직이 빠져나가서 프로토콜 구현 및 UI 제어라는 본연의 역할만 남았습니다. 매우 깔끔하고 직관적입니다.

import UIKit

class ViewController: UIViewController {
    
    // MARK: - Outlets
    @IBOutlet weak var table: UITableView!
    
    // MARK: - Properties
    private let movieService = MovieService()
    private var movies: [DailyBoxOfficeList] = [] // 옵셔널 대신 빈 배열로 시작하여 런타임 에러 방지
    
    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        loadMovieData()
    }
    
    // MARK: - Setup
    private func setupTableView() {
        table.dataSource = self
        table.delegate = self
    }
    
    private func loadMovieData() {
        movieService.fetchDailyBoxOffice { [weak self] movieData in
            guard let self = self, let data = movieData else { return }
            
            // 받아온 데이터를 저장
            self.movies = data.dailyMovies
            
            // 🚨 반드시 .async를 사용하여 메인 스레드에서 안정적으로 UI 리로드
            DispatchQueue.main.async {
                self.table.reloadData()
            }
        }
    }
}

// MARK: - UITableViewDataSource, UITableViewDelegate Extension으로 분리 (가독성 향상)
extension ViewController: UITableViewDataSource, UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // 하드코딩된 10 대신 실제 데이터의 개수를 유동적으로 반환
        return movies.count
    }
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "myCell", for: indexPath) as? MyTableViewCell else {
            return UITableViewCell()
        }
        
        // 현재 행에 맞는 영화 데이터를 셀에 넘겨서 스스로 그리게 함
        let movie = movies[indexPath.row]
        cell.configure(with: movie)
        
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print("선택된 행: \(indexPath.row), 영화 제목: \(movies[indexPath.row].movieNm)")
        tableView.deselectRow(at: indexPath, animated: true) // 터치 후 하이라이트 자연스럽게 해제
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        let dateString = movieService.makeYesterdayString()
        return "🍿박스오피스(영화진흥위원회제공: \(dateString))🍿"
    }
    
    func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        return "made by ggongs 김경래"
    }
}

 

 

 

 

 

 

'iOS앱개발' 카테고리의 다른 글

iOS실무 13주차  (0) 2026.06.02
iOS실무 12주차  (0) 2026.05.26
iOS실무 10주차  (0) 2026.05.12
iOS실무 9주차  (0) 2026.04.28
iOS실무 7주차  (0) 2026.04.14