Skip to content

[week03] MVC

serena edited this page May 20, 2023 · 16 revisions

3주차 토요스터디 / 주제: MVC / (23.05.13)

🔎 실험 1

xcode 시뮬레이터 추가

상단 메뉴에서 window>Device and Simulators 클릭 후, 좌측 하단 + 버튼을 눌러 원하는 기종 추가한다.

UIButton을 통해 등록정보 입력하기

TextFieldLabel@IBOutlet weak var로 연결한다. 이때 접근제어자 private으로 캡슐화를 해주면 좋다. 사용자 입력 정보를 받기 위해 UserInfo Struct를 생성하여 Register Button 구동 시 textField에 입력된 정보를 받아온다. 이때 공백을 없애기 위해 .trimmingCharacters(in: .whitespacesAndnewlines)를 사용한다. 받아온 사용자 입력 정보를 Struct안에 저장한 뒤 Check Button 구동 시 저장된 정보를 label에 넣어 준다.

실험 코드 보기
struct UserInfo {
    var name: String
    var phoneNumber: String
}

class ViewController: UIViewController {

    @IBOutlet weak private var nameTextField: UITextField!
    @IBOutlet weak private var phoneNumberTextField: UITextField!
    @IBOutlet weak private var nameLabel: UILabel!
    @IBOutlet weak private var phoneNumberLabel: UILabel!
    
    var registrantList: [UserInfo] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }

    @IBAction func hitRegisterButton(_ sender: Any) {
        let name = nameTextField
            .text?
            .trimmingCharacters(in: .whitespacesAndNewlines)
        let phoneNumber = phoneNumberTextField
            .text?
            .trimmingCharacters(in: .whitespacesAndNewlines)
        let registrant = UserInfo(name: name!, phoneNumber: phoneNumber!)
        registrantList.append(registrant)
    }
    
    @IBAction func hitCheckButton(_ sender: Any) {
        let lbName = registrantList.last?.name
        let lbPhoneNumber = registrantList.last?.phoneNumber
        nameLabel.text = lbName
        phoneNumberLabel.text = lbPhoneNumber
    }
}

🔎 실험 2

ViewController.swift 코드보기
import UIKit

struct Registrant {
    let name: String
    let phoneNumber: String
}

class ViewController: UIViewController {
    
    @IBOutlet weak var nameTextField: UITextField!
    @IBOutlet weak var phoneNumberTextField: UITextField!
    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var phoneNumberLabel: UILabel!
    
    var registrantList: [Registrant] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
    }

    @IBAction func hitRegisterButton(_ sender: Any) {
        register()
    }
    
    @IBAction func hitCheckButton(_ sender: Any) {
        let registrant = registrantList.last
        nameLabel.text = registrant?.name
        phoneNumberLabel.text = registrant?.phoneNumber
    }
    
    func register() {
        let registrant = Registrant(
            name: nameTextField.text!,
            phoneNumber: phoneNumberTextField.text!
        )
        
        registrantList.append(registrant)
        
        nameTextField.text = ""
        phoneNumberTextField.text = ""
    }
}

실험 2에서는 ViewController의 예시 코드 안에서 ViewController를 구분해 보자고 이야기한다.

해당 코드에는 View가 존재하지 않는다. 오직 Controller만 있다.

이렇게 생각하게 된 이유를 설명하기 위해 Main.storyboard파일을 열어보자.
프로젝트에 작업해 둔 Scene이 보일것이다.
이 상태에서 탭바의 우측을 보면, 가 상하로 배치된 메뉴(Enable Code Review)가 보일 것이다.
이 아이콘을 클릭하면 해당 Secene이 코드 형태로 변경되어 보인다.

Main.storyboard 코드보기
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="k5J-0D-7xo">
    <device id="retina6_1" orientation="portrait" appearance="light"/>
    <dependencies>
        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/>
        <capability name="Safe area layout guides" minToolsVersion="9.0"/>
        <capability name="System colors in document resources" minToolsVersion="11.0"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <scenes>
        <!--등록-->
        <scene sceneID="6cm-jK-n7h">
            <objects>
                <viewController id="k5J-0D-7xo" customClass="ViewController" customModule="Experiment_MVC_KVO_NotificationCenter" customModuleProvider="target" sceneMemberID="viewController">
                    <view key="view" contentMode="scaleToFill" id="cUl-28-3Xq">
                        <rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                        <subviews>
                            <textField opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="kyH-h1-fUV">
                                <rect key="frame" x="114" y="196" width="259" height="34"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                <textInputTraits key="textInputTraits"/>
                            </textField>
                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="이름" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Fll-e2-U2n">
                                <rect key="frame" x="41" y="203" width="30" height="21"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" type="system" pointSize="17"/>
                                <nil key="textColor"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <textField opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="02K-4W-2ex">
                                <rect key="frame" x="114" y="253" width="259" height="34"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                <textInputTraits key="textInputTraits"/>
                            </textField>
                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="전화번호" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="asD-6l-Tb5">
                                <rect key="frame" x="41" y="260" width="59" height="21"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" type="system" pointSize="17"/>
                                <nil key="textColor"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="coda 팬사인회 대기 등록" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1gA-MN-g7z">
                                <rect key="frame" x="50" y="101" width="364" height="37"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/>
                                <nil key="textColor"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="등록 정보" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZtO-Zl-Yfn">
                                <rect key="frame" x="142" y="549" width="131" height="37"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/>
                                <nil key="textColor"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="512-8h-q7c">
                                <rect key="frame" x="41" y="346" width="332" height="46"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <state key="normal" title="Button"/>
                                <buttonConfiguration key="configuration" style="filled" title="지금 등록하기"/>
                                <connections>
                                    <action selector="hitRegisterButton:" destination="k5J-0D-7xo" eventType="touchUpInside" id="RjL-Op-i4O"/>
                                </connections>
                            </button>
                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="이름" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nK4-hr-JFy">
                                <rect key="frame" x="50" y="651" width="30" height="21"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" type="system" pointSize="17"/>
                                <nil key="textColor"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="전화번호" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7Q9-GK-x0p">
                                <rect key="frame" x="50" y="708" width="59" height="21"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" type="system" pointSize="17"/>
                                <nil key="textColor"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="준비중입니다." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="IQv-od-aqc">
                                <rect key="frame" x="142" y="651" width="231" height="21"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" type="system" pointSize="17"/>
                                <nil key="textColor"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="준비중입니다." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="695-BK-uot">
                                <rect key="frame" x="142" y="707" width="231" height="21"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <fontDescription key="fontDescription" type="system" pointSize="17"/>
                                <nil key="textColor"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="mwd-Z5-7B9">
                                <rect key="frame" x="41" y="416" width="332" height="46"/>
                                <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
                                <state key="normal" title="Button"/>
                                <buttonConfiguration key="configuration" style="tinted" title="등록 확인하기"/>
                                <connections>
                                    <action selector="hitCheckButton:" destination="k5J-0D-7xo" eventType="touchUpInside" id="MDb-9A-blI"/>
                                </connections>
                            </button>
                        </subviews>
                        <viewLayoutGuide key="safeArea" id="6Az-pu-XPI"/>
                        <color key="backgroundColor" systemColor="systemBackgroundColor"/>
                    </view>
                    <tabBarItem key="tabBarItem" title="등록" id="wvB-cG-y7t"/>
                    <connections>
                        <outlet property="nameLabel" destination="IQv-od-aqc" id="oXx-TZ-d2w"/>
                        <outlet property="nameTextField" destination="kyH-h1-fUV" id="VL2-0D-ZPh"/>
                        <outlet property="phoneNumberLabel" destination="695-BK-uot" id="lkb-6A-DSJ"/>
                        <outlet property="phoneNumberTextField" destination="02K-4W-2ex" id="wTy-Lg-QgE"/>
                    </connections>
                </viewController>
                <placeholder placeholderIdentifier="IBFirstResponder" id="1nW-ZH-GWh" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
            </objects>
            <point key="canvasLocation" x="-726.08695652173924" y="-384.375"/>
        </scene>
    </scenes>
    <resources>
        <systemColor name="systemBackgroundColor">
            <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
        </systemColor>
    </resources>
</document>

코드는 xml로 작성되어있고 document의 타입은 com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB이다.
대충 훑어 보면 익숙한 단어가 보인다. viewController, textField, label 등등.
여기까지 보면 알 수 있다. 우리가 Scene에서 컴포넌트를 추가하고, 위치를 잡아주고, 크기를 변경하는 작업이 사실은 xib 코드를 작성하고 있었던 것이다.
이제 요소들의 속성을 천천히 살펴보자. 모든 요소는 공통적으로 id를 가지고 있다.
누가 봐도 직접 입력한 값이 아닌 랜덤으로 부여된 값으로 보인다. 이 중 예제코드 기준으로 textField의 속성이 id="kyH-h1-fUV"인 요소를 보자.
이것이 우리가 사용하는 컴포넌트 중 textField라는 것은 알 수 있으나, ViewController의 어떤곳과 연결되어 있는지 알 수 없다.
하지만 코드 내부에서 kyH-h1-fUV를 검색해본다면 다음과 같은 코드를 찾을 수 있다.

<connections>
    ...
    <outlet property="nameTextField" destination="kyH-h1-fUV" id="VL2-0D-ZPh"/>
    ...
</connections>

힌트를 찾았다. 이것은 아마도 ViewController에서 @IBOutlet으로 선언된 nameTextField 프로퍼티에 연결되어 있을것이다.
또한, 코드를 직접 볼 수는 없지만 내부적으로 VL2-0D-ZPh라는 id를 통해 연결되어 있을것이라고 유추할 수 있다.

그렇기 때문에 위 예제에서 ViewControllerView가 존재하지 않고, 오직 Controller만 있다고 이야기 할 수 있다.
또한, 코드로 전환되어 있는 모든 Scene의 요소를 보았기 때문에 StoryBoardMVC 패턴 관점에서 View라고 볼 수 있다.

하지만 모든 StoryBoard 프로젝트의 ViewContollerView가 존재하지 않는다는 이야기가 아니다.
해당 예시 코드에만 존재하지 않는다는 것이다.
우리는 여러가지 언어로 개발할 수 있는 사람이지만, 언어마다 문법이 다르다.
누군가는 여러 언어를 번갈아가며 작성해도 문제가 없겠지만, 대부분의 사람들에게는 어렵고 스트레스 받는 일일 것이다.
직설적으로, 이 xib 문서는 사람이 직접 코드로 작성하기에 너무 복잡하다.
그렇기 때문에 사람들은 ViewController에 코드베이스로 컴포넌트를 생성하고, 위치를 조정하고, 값을 할당하는 작업을 수행한다.
작성된 코드를 해석하는 것은 Cocoa Touch 프레임워크와 컴파일러가 해야 할 일이다.

이런 관점에서 보면 Cocoa MVC패턴은 ViewController를 분리하기 어려운 이유에 대해 명확하게 설명이 가능하다.
실제로는 ControllerView가 명확하게 나뉘어 있지만, 사람들은 빌드가 완료된 코드를 보는 것이 아니기 때문이다.
우리가 코드베이스로 작성한 시점에서 UI를 그리는 코드와 명령을 수행하는 코드가 동시에 ViewController에 존재하는데 어떻게 이것을 분리하기 쉽겠는가?

실험 2는 여기서 마무리 하겠다.
우리 스티디는 실험 2에 대해 다양한 의견이 있었지만, 해당 내용이 신선하다는 평가가 있어 위키에는 이것만 작성되었다.
위 내용은 의견일 뿐, 해석은 자유다.

🔎 실험 3

Notification

  • NotificationCenter를 통해 정보를 저장하기 위한 구조체이다. observer를 등록시켜서 변화를 관찰한다. NotificationCenter에서 발송된 notification 메세지를 전달받아 observer에서 처리한다.
  • 옵저버 등록 코드
 NotificationCenter.default.addObserver(self, selector: #selector(didRecieveTestNotification(_:)), name: NSNotification.Name("TestNotification"), object: nil)

 @objc func didRecieveTestNotification(_ notification: Notification) {
         print("Test Notification")
 }
  • notification 발송 코드
 NotificationCenter.default.post(name: NSNotification.Name("TestNotification"), object: nil, userInfo: nil)

  • 위와 같이 코드를 짜게될 경우 addObserver가 버튼이 클릭할 때마다 반복적으로 호출되기 때문에 viewDidLoad에 넣어 한번만 호출될 수 있도록 수정해야 한다
실험 코드 보기
    struct UserInfo {
        var name: String
        var phoneNumber: String
    }

    class ViewController: UIViewController {
        var registrantList: [UserInfo] = []

        @IBOutlet weak private var nameTextField: UITextField!
        @IBOutlet weak private var phoneNumberTextField: UITextField!
        @IBOutlet weak private var nameLabel: UILabel!
        @IBOutlet weak private var phoneNumberLabel: UILabel!

        override func viewDidLoad() {
            super.viewDidLoad()
            // 노티피케이션 옵저버 등록
            NotificationCenter.default.addObserver(self, selector: #selector(update), name: NSNotification.Name.register, object: nil)
        }

        @IBAction func hitRegisterButton(_ sender: Any) {
            let name = nameTextField
                .text?
                .trimmingCharacters(in: .whitespacesAndNewlines)
            let phoneNumber = phoneNumberTextField
                .text?
                .trimmingCharacters(in: .whitespacesAndNewlines)
            let registrant = UserInfo(name: name!, phoneNumber: phoneNumber!)
            registrantList.append(registrant)
            // 노티피케이션 발송 / 등록된 옵저버와 이름이 동일하기 때문에 selector 실행
            NotificationCenter.default.post(name: NSNotification.Name.register, object: nil)
        }

        @objc func update() {
            let lbName = registrantList.last?.name
            let lbPhoneNumber = registrantList.last?.phoneNumber
            nameLabel.text = lbName
            phoneNumberLabel.text = lbPhoneNumber
        }

        @IBAction func hitCheckButton(_ sender: Any) {
            update()
        }
    }

    extension Notification.Name {
        static let register = Notification.Name("register")
    }

Property Observer-didset, willset

  • Property Observers(프로퍼티 옵저버)를 정의해서 프로퍼티 값의 변화 관찰이 가능하다. 프로퍼티 옵저버는 자신이 정의한 "저장 프로퍼티"에 추가 할 수 있으며, super class(부모클래스)를 상속한 프로퍼티에도 추가 할 수 있다. 프로퍼티 옵저버를 사용하기 위해서는 프로퍼티의 값이 반드시 초기화 되어야 한다.

    • willSet : 값이 저장되기 직전에 호출한다. 새로운 프로퍼티의 값에 newValue을 준다.
    • didSet : 새로운 값이 저장된 직후에 호출한다. 이전 프로퍼티의 값에 oldValue 을 준다.
  • 새 값이 현재 값과 동일한 경우에도 속성에 새 값이 할당될 때 마다 및 옵저버가 호출된다.

Apple 예제 코드
class StepCounter {
    var totalSteps: Int = 0 {
        willSet(newTotalSteps) {
            print("About to set totalSteps to \(newTotalSteps)")
        }
        didSet {
            if totalSteps > oldValue  {
                print("Added \(totalSteps - oldValue) steps")
            }
        }
    }
}
let stepCounter = StepCounter()
stepCounter.totalSteps = 200
// About to set totalSteps to 200
// Added 200 steps
stepCounter.totalSteps = 360
// About to set totalSteps to 360
// Added 160 steps
stepCounter.totalSteps = 896
// About to set totalSteps to 896
// Added 536 steps
    

📚 참조링크