Advances CollectionvView - Custom Cell 로 구성해보기

|

개인공부 후 자료를 남기기 위한 목적임으로 내용 상에 오류가 있을 수 있습니다.


개요

일반적으로 컬렉션뷰는 dataSource를 통해 셀에 표시할 정보를 가져옵니다.

뷰 컨트롤러에서 dataSource 프로토콜을 채택하게되면 기본적으로 실행해야할 메소드들이 있고, 이를 통해 우리는 셀에 대한 데이터를 가져와 화면에 보여줄 수 있게 됩니다. 그런데 WWDC19부터 이번에 발표했던 WWDC21까지 dataSource를 넘어서 cell에 대한 새로운 기능, 더 확장되고 심화된 개념의 컬렉션뷰가 등장하였습니다.

애플에서도 이를 Advances CollectionView라 칭하면서 그 안에 다양한 기능들을 소개하고 있습니다.

이번 포스팅에서 저는 이 Advances CollectionView에서 소개하는 크게 총 2가지의 기능을 사용해 프로젝트를 구성해보려고 합니다.

  1. Diffable DataSource / Section Snapshot
  2. UICollectionViewCompositionalLayout

만들어볼 화면 구성은 다음과 같습니다.

  1. CollectionView를 통해 json 파일 내 데이터를 보여준다.
  2. CollectionView 내 데이터 구성은 총 3개의 Section으로 나누어 보여준다.
  3. 해당 데이터는 DiffableDataSource를 통해 Cell에 나타내도록 한다.
  4. 각 셀은 커스텀 셀로 구성되어진다.
  • 추가적으로 확인해 볼 내용은 다음과 같습니다.
  1. 셀이 무한 스크롤 되는 경우 dataSource와 layout이 어떻게 호출되는 지 확인
  2. 섹션 사이에 다른 섹션이 들어갈 수 있는지 확인

1~4까지는 기본적으로 만들어볼 화면이며, 추가 1,2는 글 하단에 추가적으로 다뤄볼 예정입니다.


Advances CollectionView - CustomCell로 구성해보기

우선 프로젝트에 들어가기 앞서 저는 json 파일을 제 프로젝트 내에 넣어놓고 해당 파일을 불러와 데이터를 가져올 것입니다.

json 파일은 아래와 같습니다. (Home.json)

{
    "companyInfoList" : [
        {
            "companyName" : "병원",
            "addrRoad" : "서울 특별시",
            "introPath" : ""
        },
    ],
    "expertList" : [
        {
            "expertInfo" : {
                "expertTypeName" : "한의사",
                "name" : "박지혜",
                "profilePath": ""
            }
        }
    ],
    "consultList" : [
        {
            "regDate" : 1635472394000,
            "readCnt" : 22,
            "title" : "감기에 걸렸을때 어느 병원으로 가야할까요?",
            "lastAnswer" : {
                "content" : "안녕하세요. 박지혜입니다.\n",
                "profileImg" : "",
            }
        }
    ]
}

해당 데이터를 불러오기 위해 모델을 구성해보겠습니다.
json 파일 데이터를 불러오기 위해 swiftyJSON을 사용하였습니다.


Model Company

import UIKit
import SwiftyJSON

struct Company {
    var addrRoad: String?
    var companyName: String?
    var introPath: UIImage? = nil

    init(_ json: [String: JSON]?) {
        let json = json ?? [:]
        self.addrRoad = json["addrRoad"]?.string ?? ""
        self.companyName = json["companyName"]?.string ?? ""
        if let url = URL(string: json["introPath"]?.stringValue ?? ""),
           let image = try? Data(contentsOf: url) {
            self.introPath = UIImage(data: image)
        }
    }

    init(_ json: JSON?) {
        self.init(json?.dictionaryValue)
    }
}

extension Company: Hashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(addrRoad)
        hasher.combine(companyName)
        hasher.combine(introPath)
    }
}


Model Expert

import UIKit
import SwiftyJSON

struct Expert {
    var expertTypeName: String?
    var name: String?
    var profilePath: UIImage? = nil

    init(_ json: [String:JSON]?) {
        let json = json ?? [:]
        self.expertTypeName = json["expertInfo"]?["expertTypeName"].string ?? ""
        self.name = json["expertInfo"]?["name"].string ?? ""
        if let url = URL(string: json["expertInfo"]?["profilePath"].stringValue ?? ""),
           let image = try? Data(contentsOf: url) {
            self.profilePath = UIImage(data: image)
        }
    }

    init(_ json: JSON?) {
        self.init(json?.dictionaryValue)
    }
}

extension Expert: Hashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(expertTypeName)
        hasher.combine(name)
        hasher.combine(profilePath)
    }
}


Model Consult

import UIKit
import SwiftyJSON

struct Consult {
    let title: String?
    let readCnt: Int?
    let content: String?
    var regDate: Date?
    var profileImg: UIImage? = nil

    init(_ json: [String:JSON]?) {
        let json = json ?? [:]
        self.title = json["title"]?.string ?? ""
        self.readCnt = json["readCnt"]?.int ?? 0
        self.content = json["lastAnswer"]?["content"].string ?? ""
        if let url = URL(string: json["lastAnswer"]?["profileImg"].stringValue ?? ""),
           let image = try? Data(contentsOf: url) {
            self.profileImg = UIImage(data: image)
        }
        self.regDate = Date(timeIntervalSince1970: (json["regDate"]!.doubleValue/1000))
    }

    init(_ json: JSON?) {
        self.init(json?.dictionaryValue)
    }
}

extension Consult: Hashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(title)
        hasher.combine(readCnt)
        hasher.combine(content)
        hasher.combine(profileImg)
        hasher.combine(regDate)
    }
}

이렇게 모델을 구성하고 나면 실제 json 파일을 해당 모델에 맞게 불어와야겠죠.


ViewController

import UIKit
import SwiftyJSON

class ViewController: UIViewController {
    var companyList = Array<Company>()
    var consultList = Array<Consult>()
    var expertList = Array<Expert>()

    func setData() {
        if let path = Bundle.main.path(forResource: "Home", ofType: "json"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { print("path : \(path)")
            do {
                let jsonResult = try JSON(data: data)

                let companyResult = jsonResult["companyInfoList"]
                self.companyList = companyResult.arrayValue.compactMap({Company($0)})

                let consultResult = jsonResult["consultList"]
                self.consultList = consultResult.arrayValue.compactMap({Consult($0)})

                let expertResult = jsonResult["expertList"]
                self.expertList = expertResult.arrayValue.compactMap({Expert($0)})

            }catch {
                print("error : \(error)")
            }
        }
    }
}

이렇게 하면 프로젝트 내 있는 jons파일을 불러와 제가 원하는 데이터를 가지고 있을 수 있게 됩니다.
이때 각 모델에서 Hashable 프로토콜을 채택한 이유는 무엇일까요?

우리는 앞으로 Diffable DataSource를 사용할 것입니다.

이때 해당 데이터들은 반드시 각각 본인이 고유한 데이터임을 증명해주어야 합니다. 그렇기 때문에 이렇게 Hashable을 통해 진행을 하게 되는데요.
사실상 더 많은 데이터를 다루기에는 위 같은 방법은 옳지 않습니다.

프로젝트 진행시 넘어오는 json 안에 seq 혹은 uid 같이 반드시 고유할것이라는 증명된 변수가 있다면
이 변수를 고유하게 지정해주면 됩니다.


Section과 Item

저는 우선 화면 구성을 크게 3개의 section으로 구분지어 표현할 예정입니다.

  1. Consult Section
  2. Company Section
  3. Expert Section

그리고 각 섹션에는 Json 파일로부터 긁어온 데이터들을 아이템으로 넣어줄 것입니다.
이것을 코드로 표현해보겠습니다.


class ViewController: UIViewController {

    enum Section: Int, Hashable, CaseIterable, CustomStringConvertible {
        case consult, company, expert

        var description: String {
            switch self {
            case .consult: return "Consult"
            case .company: return "Company"
            case .expert: return "Expert"
            }
        }
    }

    struct Item: Hashable {
        let expert: Expert?
        let consult: Consult?
        let company: Company?

        init(consult: Consult? = nil, company: Company? = nil, expert: Expert? = nil) {
            self.expert = expert
            self.consult = consult
            self.company = company
        }

        private let identifier = UUID()
    }
}


Section과 Item들의 레이아웃 설정

각 섹션과 아이템들의 위치 즉 레이아웃을 잡아주기 위해 우리는 UICollectionViewCompositionalLayout을 사용할 것입니다.

이 개념을 이해하기 위한 사진 하나를 보여드리겠습니다.

해당 사진에서 유추할 수 있듯 각 레이아웃을 잡아주는 순서는 다음과 같습니다.

  1. item에서 group
  2. group에서 section
  3. 이렇게 결합한 뒤 전체 layout으로 구성요소 결합

각 섹션에 대한 코드는 다음과 같습니다.


func createLayout() -> UICollectionViewLayout {
    print("createLayout")
    let sectionProvider = { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
        let section: NSCollectionLayoutSection
        if sectionKind == .consult{
            print("layout consult")
            // item의 width와 height 지정
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

            let groupHeight = NSCollectionLayoutDimension.absolute(200)
            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: groupHeight)
            // 그룹 내 1.0 배율 안에서 보여질 아이템의 수는 1개라는 의미
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

            section = NSCollectionLayoutSection(group: group)

        } else if sectionKind == .company {
            print("layout company")
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)

            let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(350), heightDimension: .absolute(200))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

            section = NSCollectionLayoutSection(group: group)
            section.interGroupSpacing = 10
            section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
            section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

        } else if sectionKind == .expert {
            print("layout expert")
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)

            let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(150), heightDimension: .absolute(150))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

            section = NSCollectionLayoutSection(group: group)
            section.interGroupSpacing = 10
            section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
            section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
        }
        else {
            fatalError("Unknown section!")
        }
        return section
    }
    return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
}

코드를 살펴보면 Consult Section의 경우 수직으로 스크롤이 되고 있으며, 나머지 Compant, Expert Section은 수평으로 스크롤이 되고있음을 알 수 있습니다.

이를 구분짓는 코드는 section.orthogonalScrollingBehavior입니다.
하나의 섹션 단위의 스크롤 방향을 지정해주는 것으로 이 행위를 넣지 않으면 디폴트는 수직입니다.
그래서 데이터들이 아래로 쌓여져 보이게 될 것입니다.

추가로 코드 안에서 NSCollectionLayoutSize를 통해 item과 group의 사이즈(width, height)를 구성하고 있음을 볼 수 있습니다.

  • absolute: 절대값, 정확한 치수를 지정할 때 사용
  • estimated: 런타임에 컨텐츠의 크기가 변경될 수 있는 경우 예상값을 사용
  • fractional: 분구삾을 통해 item 컨테이너의 너비 기준으로 상대적인 값 정의


Custom Cell

저는 커스텀셀을 만들어와 커스텀 셀을 연결해줄 예정입니다.

커스텀 셀은 아래와 같이 정의하였습니다.

import UIKit

class CompanyCollectionViewCell: UICollectionViewCell {
    private let profielImgView: UIImageView = {
        let imgView = UIImageView()
        imgView.contentMode = .scaleAspectFill
        imgView.clipsToBounds = true
        imgView.backgroundColor = .clear
        return imgView
    }()

    private let companyLbl: UILabel = {
        let lbl = UILabel()
        lbl.font = .systemFont(ofSize: 16)
        lbl.textAlignment = .center
        return lbl
    }()

    private let addressLbl: UILabel = {
        let lbl = UILabel()
        lbl.font = .systemFont(ofSize: 14)
        lbl.textAlignment = .center
        return lbl
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.initView()
    }

    required init?(coder: NSCoder) {
        fatalError("init")
    }

    func initView() {
        self.contentView.backgroundColor = .clear

        self.contentView.addSubview(profielImgView)
        self.contentView.addSubview(companyLbl)
        self.contentView.addSubview(addressLbl)

        self.profielImgView.snp.makeConstraints {(make) in
            make.trailing.equalToSuperview().inset(12)
            make.leading.equalToSuperview()
            make.height.equalTo(140)
            make.top.equalToSuperview()
        }

        self.companyLbl.snp.makeConstraints{(make) in
            make.top.equalTo(self.profielImgView.snp.bottom).inset(-8)
            make.trailing.leading.equalToSuperview().inset(24)
        }

        self.addressLbl.snp.makeConstraints {(make) in
            make.top.equalTo(self.companyLbl.snp.bottom).inset(-8)
            make.leading.trailing.equalTo(self.companyLbl)
            make.bottom.lessThanOrEqualToSuperview().inset(8)
        }
    }

    func configure(_ data: Company) {
        self.profielImgView.image = data.introPath
        self.companyLbl.text = data.companyName
        self.addressLbl.text = data.addrRoad
    }
}

Cell 파일에서는 단순히 셀에 들어가는 요소들의 객체를 만들어주고 각각의 위치를 잡아주고 있습니다.
특징이라면 configure 메소드에서 모델의 데이터들을 각각 연결해주고 있다는 것이죠.

이렇게 만들어놓은 셀 파일을 이제 뷰컨트롤러에서 등록해주고 연결할 것입니다.


func createCell() {
    print("createCell")
    self.collectionView.register(CompanyCollectionViewCell.self, forCellWithReuseIdentifier: "CompanyCell")
    self.collectionView.register(ConsultCollectionViewCell.self, forCellWithReuseIdentifier: "ConsultCell")
    self.collectionView.register(ExpertCollectionViewCell.self, forCellWithReuseIdentifier: "ExpertCell")
}

func createDataSource() {
    print("createDataSource")
        self.dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: self.collectionView) {(collectionView, indexPath, item) -> UICollectionViewCell? in
        guard let section = Section(rawValue: indexPath.section) else { fatalError() }
        switch section {
        case .company:
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CompanyCell", for: indexPath) as? CompanyCollectionViewCell else { preconditionFailure() }
            cell.configure(item.company!)
            return cell
        case .consult:
            print("datasource consult")
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ConsultCell", for: indexPath) as? ConsultCollectionViewCell else { preconditionFailure() }
            cell.configure(item.consult!)
            return cell
        case .expert:
            print("datasource expert")
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ExpertCell", for: indexPath) as? ExpertCollectionViewCell else { preconditionFailure() }
            cell.configure(item.expert!)
            return cell
        }
    }
}

위 코드는 우리가 일반적으로 컬렉션뷰 데이터소스 프로토콜을 채택했을 때 사용하는 CellForItem 메소드 내 구성과 비슷한것을 볼수 있죠?


Snapshot

이렇게 구성한 데이터소소를 가지고 이제 snapshot을 구성해보도록 하겠습니다.

func applySnapshot() {
    print("appleSnapshot")
    let sections = Section.allCases
    var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
    snapshot.appendSections(sections)
    print(sections)
    print(snapshot)

    let consultItems = self.consultLis.map { Item(consult: $0) }
    var consultSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
    consultSnapshot.append(consultItems)

    let companyItems = self.companyList.map { Item(company: $0) }
    companySnapshot.append(companyItems)


    let expertItems = self.expertList.map { Item(expert: $0) }
    var expertSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
    expertSnapshot.append(expertItems)

    dataSource.apply(consultSnapshot, to: .consult, animatingDifferences: false)
    dataSource.apply(companySnapshot, to: .company, animatingDifferences: false)
    dataSource.apply(expertSnapshot, to: .expert, animatingDifferences: false)
}

이렇게 적용을 하면 제가 원하는 대로 실행이 될 것입니다.


아래는 전체 소스코드 입니다.

import UIKit
import SwiftyJSON

class ViewController: UIViewController {

    enum Section: Int, Hashable, CaseIterable, CustomStringConvertible {
        case consult, company, expert

        var description: String {
            switch self {
            case .consult: return "Consult"
            case .company: return "Company"
            case .expert: return "Expert"
            }
        }
    }

    struct Item: Hashable {
        let expert: Expert?
        let consult: Consult?
        let company: Company?

        init(consult: Consult? = nil, company: Company? = nil, expert: Expert? = nil) {
            self.expert = expert
            self.consult = consult
            self.company = company
        }

        private let identifier = UUID()
    }

    var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

    @IBOutlet weak var collectionView: UICollectionView!
    var companyList = Array<Company>()
    var consultList = Array<Consult>()
    var expertList = Array<Expert>()

    override func viewDidLoad() {
        overrideUserInterfaceStyle = .light
        super.viewDidLoad()

        setData()
        setNavigation()
        createDataSource()
        self.collectionView.collectionViewLayout = self.createLayout()
        createCell()
        applySnapshot()
    }

    func setData() {
        print("setData")
        if let path = Bundle.main.path(forResource: "Home", ofType: "json"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { print("path : \(path)")
            do {
                let jsonResult = try JSON(data: data)

                let companyResult = jsonResult["companyInfoList"]
                self.companyList = companyResult.arrayValue.compactMap({Company($0)})

                let consultResult = jsonResult["consultList"]
                self.consultList = consultResult.arrayValue.compactMap({Consult($0)})

                let expertResult = jsonResult["expertList"]
                self.expertList = expertResult.arrayValue.compactMap({Expert($0)})

            }catch {
                print("error : \(error)")
            }
        }
    }

    func createLayout() -> UICollectionViewLayout {
        print("createLayout")
        let sectionProvider = { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
            let section: NSCollectionLayoutSection
            if sectionKind == .consult{
                print("layout consult")
                let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)
                item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

                let groupHeight = NSCollectionLayoutDimension.absolute(200)
                let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: groupHeight)
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

                section = NSCollectionLayoutSection(group: group)

            } else if sectionKind == .company {
                print("layout company")
                let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)

                let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(350), heightDimension: .absolute(200))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

                section = NSCollectionLayoutSection(group: group)
                section.interGroupSpacing = 10
                section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
                section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

            } else if sectionKind == .expert {
                print("layout expert")
                let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)

                let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(150), heightDimension: .absolute(150))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

                section = NSCollectionLayoutSection(group: group)
                section.interGroupSpacing = 10
                section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
                section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            }
            else {
                fatalError("Unknown section!")
            }
            return section
        }
        return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
    }

    func createCell() {
        print("createCell")
        self.collectionView.register(CompanyCollectionViewCell.self, forCellWithReuseIdentifier: "CompanyCell")
        self.collectionView.register(ConsultCollectionViewCell.self, forCellWithReuseIdentifier: "ConsultCell")
        self.collectionView.register(ExpertCollectionViewCell.self, forCellWithReuseIdentifier: "ExpertCell")
    }

    func createDataSource() {
        print("createDataSource")
            self.dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: self.collectionView) {(collectionView, indexPath, item) -> UICollectionViewCell? in
            guard let section = Section(rawValue: indexPath.section) else { fatalError() }
            switch section {
            case .company:
                guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CompanyCell", for: indexPath) as? CompanyCollectionViewCell else { preconditionFailure() }
                cell.configure(item.company!)
                return cell
            case .consult1, .consult2:
                print("datasource consult")
                guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ConsultCell", for: indexPath) as? ConsultCollectionViewCell else { preconditionFailure() }
                cell.configure(item.consult!)
                return cell
            case .expert:
                print("datasource expert")
                guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ExpertCell", for: indexPath) as? ExpertCollectionViewCell else { preconditionFailure() }
                cell.configure(item.expert!)
                return cell
            }
        }
    }

    func setNavigation() {
        navigationItem.title = "홈"    
    }

    func applySnapshot() {
        print("appleSnapshot")
        let sections = Section.allCases
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections(sections)
        print(sections)
        print(snapshot)

        let consultItems = self.consultList.map { Item(consult: $0) }
        var consultSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
        consultSnapshot.append(consultItems)

        let companyItems = self.companyList.map { Item(company: $0) }
        companySnapshot.append(companyItems)

        let expertItems = self.expertList.map { Item(expert: $0) }
        var expertSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
        expertSnapshot.append(expertItems)

        dataSource.apply(consultSnapshot, to: .consult, animatingDifferences: false)
        dataSource.apply(companySnapshot, to: .company, animatingDifferences: false)
        dataSource.apply(expertSnapshot, to: .expert, animatingDifferences: false)
    }
}


추가적으로 알아본 것

  1. 셀이 무한 스크롤 되는 경우 dataSource와 layout이 어떻게 호출되는 지 확인
  2. 섹션 사이에 다른 섹션이 들어갈 수 있는지 확인


1. 셀이 무한 스크롤 되는 경우 dataSource와 layout이 어떻게 호출되는 지 확인

결과적으로 해당 레이아웃과 데이터소스를 불러오는 메소드가 계속해서 호출되는 것을 볼 수 있었습니다.

하단에 아래와 같은 코드를 추가해보았습니다.

func appendCompany() {
    print("appendCompany")
    let companyItems = self.companyList.map { Item(company: $0) }
    companySnapshot.append(companyItems)
    dataSource.apply(companySnapshot, to: .company, animatingDifferences: false)
}

저는 Company Section에서 데이터 뒤에 계속해서 무한스크롤이 되게 만들어보았습니다.

그리고 확인해보니 해당 섹션의 셀이 무한 스크롤 될때마다 전체레이아웃은 계속 불려오고 해당 섹션의 Company DataSource가 계속해서 호출되는것을 발견할 수 있었습니다. 상당히 비효율적임을 볼 수 있겠죠.


2. 섹션 사이에 다른 섹션이 들어갈 수 있는지 확인

결과적으로는 불가능하다 입니다.

이를 정 가능하게 하고싶다면, 임의로 제가 데이터를 잘라서 섹션을 구분해 넣어 만드는 방식입니다.
아래와 같은 방식이라면 가능합니다.

import UIKit
import SwiftyJSON

class ViewController: UIViewController {

    enum Section: Int, Hashable, CaseIterable, CustomStringConvertible {
        case consult1, company, consult2, expert

        var description: String {
            switch self {
            case .consult1: return "Consult"
            case .company: return "Company"
            case .consult2: return "Consult"
            case .expert: return "Expert"
            }
        }
    }

    struct Item: Hashable {
        let expert: Expert?
        let consult: Consult?
        let company: Company?

        init(consult: Consult? = nil, company: Company? = nil, expert: Expert? = nil) {
            self.expert = expert
            self.consult = consult
            self.company = company
        }

        private let identifier = UUID()
    }

    var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

    @IBOutlet weak var collectionView: UICollectionView!
    var companyList = Array<Company>()
    var consultList = Array<Consult>()
    var expertList = Array<Expert>()

    var companySnapshot = NSDiffableDataSourceSectionSnapshot<Item>()

    override func viewDidLoad() {
        overrideUserInterfaceStyle = .light
        super.viewDidLoad()

        setData()
        setNavigation()
        createDataSource()
        self.collectionView.collectionViewLayout = self.createLayout()
        createCell()
        applySnapshot()
    }

    func setData() {
        print("setData")
        if let path = Bundle.main.path(forResource: "Home", ofType: "json"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { print("path : \(path)")
            do {
                let jsonResult = try JSON(data: data)

                let companyResult = jsonResult["companyInfoList"]
                self.companyList = companyResult.arrayValue.compactMap({Company($0)})

                let consultResult = jsonResult["consultList"]
                self.consultList = consultResult.arrayValue.compactMap({Consult($0)})

                let expertResult = jsonResult["expertList"]
                self.expertList = expertResult.arrayValue.compactMap({Expert($0)})

            }catch {
                print("error : \(error)")
            }
        }
    }

    func createLayout() -> UICollectionViewLayout {
        print("createLayout")
        let sectionProvider = { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
            let section: NSCollectionLayoutSection
            if sectionKind == .consult1 || sectionKind == .consult2 {
                print("layout consult")
                let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)
                item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

                let groupHeight = NSCollectionLayoutDimension.absolute(200)
                let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: groupHeight)
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

                section = NSCollectionLayoutSection(group: group)

            } else if sectionKind == .company {
                print("layout company")
                let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)
                let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(350), heightDimension: .absolute(200))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
                section = NSCollectionLayoutSection(group: group)
                section.interGroupSpacing = 10
                section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
                section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            } else if sectionKind == .expert {
                print("layout expert")
                let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
                let item = NSCollectionLayoutItem(layoutSize: itemSize)
                let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(150), heightDimension: .absolute(150))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
                section = NSCollectionLayoutSection(group: group)
                section.interGroupSpacing = 10
                section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
                section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            }
            else {
                fatalError("Unknown section!")
            }
            return section
        }
        return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
    }

    func createCell() {
        print("createCell")
        self.collectionView.register(CompanyCollectionViewCell.self, forCellWithReuseIdentifier: "CompanyCell")
        self.collectionView.register(ConsultCollectionViewCell.self, forCellWithReuseIdentifier: "ConsultCell")
        self.collectionView.register(ExpertCollectionViewCell.self, forCellWithReuseIdentifier: "ExpertCell")
    }

    func createDataSource() {
        print("createDataSource")
            self.dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: self.collectionView) {(collectionView, indexPath, item) -> UICollectionViewCell? in
            guard let section = Section(rawValue: indexPath.section) else { fatalError() }
            switch section {
            case .company:
                print("datasource company")
                if indexPath.row == self.companyList.count - 1 {
                    self.appendCompany()
                }
                guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CompanyCell", for: indexPath) as? CompanyCollectionViewCell else { preconditionFailure() }
                cell.configure(item.company!)
                return cell
            case .consult1, .consult2:
                print("datasource consult")
                guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ConsultCell", for: indexPath) as? ConsultCollectionViewCell else { preconditionFailure() }
                cell.configure(item.consult!)
                return cell
            case .expert:
                print("datasource expert")
                guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ExpertCell", for: indexPath) as? ExpertCollectionViewCell else { preconditionFailure() }
                cell.configure(item.expert!)
                return cell
            }
        }
    }

    func setNavigation() {
        navigationItem.title = "홈"
        if #available(iOS 15, *) {
            let appearance = UINavigationBarAppearance()
            appearance.configureWithTransparentBackground()
            appearance.backgroundColor = .systemGreen
            navigationItem.scrollEdgeAppearance = appearance
            navigationItem.standardAppearance = appearance
            navigationItem.compactAppearance = appearance
            navigationController?.setNeedsStatusBarAppearanceUpdate()
        }

    }

    func applySnapshot() {
        print("appleSnapshot")
        let sections = Section.allCases
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections(sections)
        print(sections)
        print(snapshot)

        let consultCount = self.consultList.count
        let consultItems1 = self.consultList[0..<consultCount/2].map { Item(consult: $0) }
        var consultSnapshot1 = NSDiffableDataSourceSectionSnapshot<Item>()
        consultSnapshot1.append(consultItems1)

        let companyItems = self.companyList.map { Item(company: $0) }
        companySnapshot.append(companyItems)

        let consultItems2 = self.consultList[consultCount/2..<consultCount].map { Item(consult: $0) }
        var consultSnapshot2 = NSDiffableDataSourceSectionSnapshot<Item>()
        consultSnapshot2.append(consultItems2)

        let expertItems = self.expertList.map { Item(expert: $0) }
        var expertSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
        expertSnapshot.append(expertItems)

        dataSource.apply(consultSnapshot1, to: .consult1, animatingDifferences: false)
        dataSource.apply(companySnapshot, to: .company, animatingDifferences: false)
        dataSource.apply(consultSnapshot2, to: .consult2, animatingDifferences: false)
        dataSource.apply(expertSnapshot, to: .expert, animatingDifferences: false)
    }

    func appendCompany() {
        print("appendCompany")
        let companyItems = self.companyList.map { Item(company: $0) }
        companySnapshot.append(companyItems)
        dataSource.apply(companySnapshot, to: .company, animatingDifferences: false)
    }

}

위 코드와 같이 기존의 Consult Section을 consult1, consult2 로 나누어 그 사이에 원하는 섹션 데이터를 넣는 것은 가능합니다. 그러나 이는 근본적으로 섹션안에 섹션을 넣는 방식은 아니죠.

Advances CollectionvView

|

개인공부 후 자료를 남기기 위한 목적임으로 내용 상에 오류가 있을 수 있습니다.


이전 글에서는 CustomCell을 register해서 사용하는 방식을 보여드렸습니다.
사실 커스텀셀을 사용하는 방법은 구글에 검색을 해봐도 잘 나오지 않더군요..

있어도 제 입맛에 맞게 수정하는것에 하나하나 제약사항도 너무 많았구요.

CustomCell 로 보는 Advances CollectionView 포스팅 바로가기

이번에는 애플에서 기본적으로 제공해주는 방식으로 셀을 구성해 데이터를 보여주는 컬렉션뷰를 한번 보여드리도록 하겠습니다.

코드는 애플에서 제공해주는 코드입니다.

Apple Developer


Advances CollectionvView

우선 모델 코드부터 보여드리겠습니다.

  • Emoji 구조체는 Hashable 프로토콜을 채택
  • 각 카테고리에 해당하는 케이스, 리턴값을 정의
import UIKit

struct Emoji: Hashable {

    enum Category: CaseIterable, CustomStringConvertible {
        case recents, smileys, nature, food, activities, travel, objects, symbols
    }

    let text: String
    let title: String
    let category: Category
    private let identifier = UUID()
}

extension Emoji.Category {

    var description: String {
        switch self {
        case .recents: return "Recents"
        case .smileys: return "Smileys"
        case .nature: return "Nature"
        case .food: return "Food"
        case .activities: return "Activities"
        case .travel: return "Travel"
        case .objects: return "Objects"
        case .symbols: return "Symbols"
        }
    }

    var emojis: [Emoji] {
        switch self {
        case .recents:
            return [
                Emoji(text: "🤣", title: "Rolling on the floor laughing", category: self),
                Emoji(text: "🥃", title: "Whiskey", category: self),
                Emoji(text: "😎", title: "Cool", category: self),
                Emoji(text: "🏔", title: "Mountains", category: self),
                Emoji(text: "⛺️", title: "Camping", category: self),
                Emoji(text: "⌚️", title: " Watch", category: self),
                Emoji(text: "💯", title: "Best", category: self),
                Emoji(text: "✅", title: "LGTM", category: self)
            ]

        case .smileys:
            return [
                Emoji(text: "😀", title: "Happy", category: self),
                Emoji(text: "😂", title: "Laughing", category: self),
                Emoji(text: "🤣", title: "Rolling on the floor laughing", category: self)
            ]

        case .nature:
            return [
                Emoji(text: "🦊", title: "Fox", category: self),
                Emoji(text: "🐝", title: "Bee", category: self),
                Emoji(text: "🐢", title: "Turtle", category: self)
            ]

        case .food:
            return [
                Emoji(text: "🥃", title: "Whiskey", category: self),
                Emoji(text: "🍎", title: "Apple", category: self),
                Emoji(text: "🍑", title: "Peach", category: self)
            ]
        case .activities:
            return [
                Emoji(text: "🏈", title: "Football", category: self),
                Emoji(text: "🚴‍♀️", title: "Cycling", category: self),
                Emoji(text: "🎤", title: "Singing", category: self)
            ]

        case .travel:
            return [
                Emoji(text: "🏔", title: "Mountains", category: self),
                Emoji(text: "⛺️", title: "Camping", category: self),
                Emoji(text: "🏖", title: "Beach", category: self)
            ]

        case .objects:
            return [
                Emoji(text: "🖥", title: "iMac", category: self),
                Emoji(text: "⌚️", title: " Watch", category: self),
                Emoji(text: "📱", title: "iPhone", category: self)
            ]

        case .symbols:
            return [
                Emoji(text: "❤️", title: "Love", category: self),
                Emoji(text: "☮️", title: "Peace", category: self),
                Emoji(text: "💯", title: "Best", category: self)
            ]

        }
    }
}


모델은 단순합니다.

위에서 정리했던 것처럼 Emoji 구조체는 Hashable 프로토콜을 채택하고 있습니다.
그 이유는 전에도 말씀드렸던 것처럼 Diffable DataSource는 넘어오는 데이터의 고유함을 반드시 지켜주어야하기 때문입니다.

그리고 아래는 Emoji.Category에 들어가는 각 케이스별 리턴값들을 정의해주고 있습니다.


Section과 Item

실제 우리가 보여줄 Section과 Item을 정의해봅니다.

import UIKit

class ViewController: UIViewController {
    enum Section: Int, Hashable, CaseIterable, CustomStringConvertible {  
        // 섹션에 들어갈 데이터 정의
        case recents, outline, list, custom

        var description: String {
            switch self {
            case .recents: return "Recents"
            case .outline: return "Outline"
            case .list: return "List"
            case .custom: return "Custom"
            }
        }
    }

    struct Item: Hashable {  
        // 섹션 속 아이템에 들어갈 데이터 정의
        let title: String?
        let emoji: Emoji?
        let hasChild: Bool

        init(emoji: Emoji? = nil, title: String? = nil, hasChild: Bool = false) {
            self.emoji = emoji
            self.title = title
            self.hasChild = hasChild
        }

        private let identifier = UUID()
    }
}
  • caseIterable > 배열과 같이 순회 가능
  • customStringConveertible > 사용자 정의에 따른 텍스트 출력 가능


UICollectionView 정의

var collectionView: UICollectionView!

func configureHierarchy() {
    collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
    collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    collectionView.backgroundColor = .systemGroupedBackground
    view.addSubview(collectionView)
}

collectionView 객체를 만들어주고 실제 화면에 정의해주는 부분입니다.
해당 컬렉션 뷰 위에 이제 우리가 보여주고싶은 데이터를 뿌려주게 됩니다.


Create Section Layout

이제 각 섹션들의 레이아웃을 정의해줍니다.

func createLayout() -> UICollectionViewLayout {
    let sectionProvider = { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
        let section: NSCollectionLayoutSection

        if sectionKind == .recents {
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)

            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.28), heightDimension: .fractionalHeight(0.1))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

            section = NSCollectionLayoutSection(group: group)
            section.interGroupSpacing = 0
            section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
            section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)

        } else if sectionKind == .outline {
            var configuration = UICollectionLayoutListConfiguration(appearance: .sidebar)
            configuration.leadingSwipeActionsConfigurationProvider = { [weak self] (indexPath) in
                guard let self = self else { return nil }
                guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil }
                return self.leadingSwipeActionConfigurationForListCellItem(item)
            }
            section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment)

        } else if sectionKind == .list {
            var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
            configuration.leadingSwipeActionsConfigurationProvider = { [weak self] (indexPath) in
                guard let self = self else { return nil }
                guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil }
                return self.leadingSwipeActionConfigurationForListCellItem(item)
            }
            section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment)

        } else if sectionKind == .custom {
            section = NSCollectionLayoutSection.list(using: .init(appearance: .plain), layoutEnvironment: layoutEnvironment)
            section.contentInsets = NSDirectionalEdgeInsets(top: 100, leading: 10, bottom: 0, trailing: 10)

        } else {
            fatalError("Unknown section!")
        }
        return section
    }
    return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
}

각 레이아웃의 환경은 애플에서 제공해주는 요소들로 정의되어져 있습니다.


여기서 중요하게 보아야 할 개념은 sectionKind가 outline, list일때 입니다.

var configuration = UICollectionLayoutListConfiguration(appearance: .sidebar)

이때 등장하는 UICollectionLayoutListConfiguration 이건 무엇일까요?

iOS14에서는 UICollectionLayoutListConfiguration 라는 새로운 유형을 제공합니다.
list configuration은 테이블뷰 스타일(.plain, .grouped, insetGrouped)과 같은 모양을 제공합니다.

또한 콜렉션 뷰 List 전용 .sideBar, .sideBarPlain 이라는 새로운 스타일 또한 제공함으로써 다중 열 앱을 구축할 수 있게 되었습니다.

따라서 위와 같은 configuration들을 지정해줌으로써 컬렉션뷰에서도 다양한 리스트 형태의 레이아웃을 만들어 낼 수 있게 된것입니다.

그게 바로 지금 저희가 만들 앱 화면의 모습이기도 합니다.


Create Cell and Resister

이제 컬렉션 뷰 내 보여질 섹션과 아이템들의 레이아웃 구성을 하였으니 본격적으로 셀을 만들어주고 지정해보도록 합니다.

// recent > grid cell registration
func createGridCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewCell, Emoji> {
    // 셀 등록 > iOS14부터는 cell registration을 통해 새롭게 cell을 구성할 수 있음
    return UICollectionView.CellRegistration<UICollectionViewCell, Emoji> { (cell, indexPath, emoji) in
        // 테이블 뷰와 같이 셀에 대한 표준화된 레이아웃을 제공
        var content = UIListContentConfiguration.cell()
        content.text = emoji.text
        content.textProperties.font = .boldSystemFont(ofSize: 38)
        content.textProperties.alignment = .center
        content.directionalLayoutMargins = .zero
        cell.contentConfiguration = content
        var background = UIBackgroundConfiguration.listPlainCell()
        background.cornerRadius = 8
        background.strokeColor = .systemGray3
        background.strokeWidth = 1.0 / cell.traitCollection.displayScale
        cell.backgroundConfiguration = background
    }
}

// outline header cell registration
func createOutlineHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
    return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, indexPath, title) in
        var content = cell.defaultContentConfiguration()
        content.text = title
        cell.contentConfiguration = content
    }
}

// outline cell registration
func createOutlineCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, Emoji> {
    return UICollectionView.CellRegistration<UICollectionViewListCell, Emoji> { (cell, indexPath, emoji) in
        var content = cell.defaultContentConfiguration()
        content.text = emoji.text
        content.secondaryText = emoji.title
        cell.contentConfiguration = content
    }
}

// list > list cell registration
func createListCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, Item> {
    return UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [weak self] (cell, indexPath, item) in
        guard let self = self, let emoji = item.emoji else { return }
        var content = UIListContentConfiguration.valueCell()
        content.text = emoji.text
        content.secondaryText = String(describing: emoji.category)
        cell.contentConfiguration = content
    }
}

이때 또 새로운 개념이 보이죠?

  1. UIListContentConfiguration
  2. defaultContentConfiguration

UIListContentConfiguration은 list based content view에 대한 content configuration을 의미합니다.

defaultContentConfiguration은 우리가 기본적으로 테이블뷰 혹은 컬렉션뷰를 사용할때 각 셀안에 이미 존재하는 textLabel, imageView등을 사용하였습니다. 근데 이제 이러한 접근이 iOS14에서부터는 deprecated 되어 접근이 불가능해졌습니다.

그래서 이때 셀에 각 데이터에 접근하기 위해 defaultContentConfiguration에 접근해야합니다.

그리고 이제 셀을 등록하기 위해 UICollectionView.CellRegistration를 통해 등록하는 것을 볼 수 있습니다.


Diffable DataSource

이제 이렇게 만들어 놓은 셀에 대한 데이터 작업을 해봅니다.

var dataSource: UICollectionViewDiffableDataSource<Section, Item>!


func configureDataSource() {
    // create registrations up front, then choose the appropriate one to use in the cell provider
    let gridCellRegistration = createGridCellRegistration()
    let listCellRegistration = createListCellRegistration()
    let outlineHeaderCellRegistration = createOutlineHeaderCellRegistration()
    let outlineCellRegistration = createOutlineCellRegistration()
    let createPlaceRegistration = createPlainCellRegistration()

    // data source
    dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
        (collectionView, indexPath, item) -> UICollectionViewCell? in
        guard let section = Section(rawValue: indexPath.section) else { fatalError("Unknown section") }
        switch section {
        // recent
        case .recents:
            return collectionView.dequeueConfiguredReusableCell(using: gridCellRegistration, for: indexPath, item: item.emoji)
        // 맨 아래 list
        case .list:
            return collectionView.dequeueConfiguredReusableCell(using: listCellRegistration, for: indexPath, item: item)
        // outline > header, cell
        case .outline:
            if item.hasChild {
                return collectionView.dequeueConfiguredReusableCell(using: outlineHeaderCellRegistration, for: indexPath, item: item.title!)
            } else {
                return collectionView.dequeueConfiguredReusableCell(using: outlineCellRegistration, for: indexPath, item: item.emoji)
            }

        case .custom:
            return collectionView.dequeueConfiguredReusableCell(using: createPlaceRegistration, for: indexPath, item: item)

        }
    }
}


Snapshot

만들어놓은 데이터소스에 스냅샷을 적용하는 코드입니다.

// 스냅샷 적용
    // NSDiffableDataSourceSnapshot > 데이터 접근, 특정 인덱스에 데이터 삽입 및 삭제 가능 > apply 통해 변경사항 적용
    func applyInitialSnapshots() {
        let sections = Section.allCases
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections(sections)
        dataSource.apply(snapshot, animatingDifferences: false)

        // recents
        let recentItems = Emoji.Category.recents.emojis.map { Item(emoji: $0) }
        var recentsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
        recentsSnapshot.append(recentItems)

        // list + outlines
        var allSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
        var outlineSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()

        for category in Emoji.Category.allCases where category != .recents {
            // append to the "all items" snapshot
            let allSnapshotItems = category.emojis.map { Item(emoji: $0) }
            allSnapshot.append(allSnapshotItems)

            // setup our parent/child relations
            let rootItem = Item(title: String(describing: category), hasChild: true)
            outlineSnapshot.append([rootItem])
            let outlineItems = category.emojis.map { Item(emoji: $0) }
            outlineSnapshot.append(outlineItems, to: rootItem)
        }

        let customItems = Emoji.Category.recents.emojis.map { Item(emoji: $0) }
        var cusomSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
        cusomSnapshot.append(customItems)

        dataSource.apply(recentsSnapshot, to: .recents, animatingDifferences: false)
        dataSource.apply(allSnapshot, to: .list, animatingDifferences: false)
        dataSource.apply(outlineSnapshot, to: .outline, animatingDifferences: false)
        dataSource.apply(cusomSnapshot, to: .list, animatingDifferences: false)
    }
}

iOS iOS13 Deprecated scanHexInt32 수정하기

|

개인공부 후 자료를 남기기 위한 목적임으로 내용 상에 오류가 있을 수 있습니다.


프로젝트의 최소 버전을 iOS13으로 올리면서 발견한 Deprecated가 있었다.

scanHexInt32 was deprecated in iOS13

이전코드는 아래와 같았다.

extension UIColor {

  convenience init(hex: String, alpha: CGFloat = 1.0) {
    var cString:String = hex.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).uppercased()

    if (cString.hasPrefix("#")) {
        cString = String(cString[cString.index(cString.startIndex, offsetBy: 1)...])
    }

    if (cString.count != 6) {
        return UIColor.gray
    }

    var rgbValue:UInt32 = 0

    Scanner(string: cString).scanHexInt32(&rgbValue)  // 해당 부분에서 위와 같은 경고 발생

    return UIColor(
        red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
        green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
        blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
        alpha: CGFloat(1.0)
    )
  }

}

이는 아래와 같이 수정해주면 된다.

convenience init(hex: String, alpha: CGFloat = 1.0) {
    var hexFormatted: String = hex.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).uppercased()

    if hexFormatted.hasPrefix("#") {
        hexFormatted = String(hexFormatted.dropFirst())
    }

    assert(hexFormatted.count == 6, "Invalid hex code used.")

    var rgbValue: UInt64 = 0
    Scanner(string: hexFormatted).scanHexInt64(&rgbValue)

    self.init(red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
              green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
              blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
              alpha: alpha)
}

이렇게 UInt64 및 scanHexInt64로 업데이트해주면 됨!

iOS 13,14,15에서 달라진 점은 무엇이 있을까?

|

개인공부 후 자료를 남기기 위한 목적임으로 내용 상에 오류가 있을 수 있습니다.


UIButton

https://zeddios.tistory.com/1291

  • 버튼 내부적으로 Activity Indicator 지원

UITableView, UICollectionView DataSource: Diffable Data Source의 등장

https://velog.io/@ellyheetov/UI-Diffable-Data-Source

DataSource: 테이블뷰 혹은 컬렉션 뷰를 그리기 위한 데이터를 관리하고 UI를 업데이트 하는 역할을 함
DataSource와 달리 데이터가 달라진 부분을 추적하여 자연스럽게 UI 업데이트 함

UITableViewDataSource, UICollectionViewDataSource를 대체하는 클래스의 등장
기본적으로 두개가 하는 역할은 같다.

Diffable Data Source를 사용하면 테이블뷰나 컬렉션뷰의 달라진 부분을 자동으로 알아차리고 새로운 부분만 다시 그린다.

  • 추가적인 코드작업 없이도, 퀄리티 있는 에니메이션 적용이 가능하다.
  • 개선된 Data Source 매커니즘은 완벽하게 동기적인 버그나, 예외, 충돌 상황들을 피할 수 있게 해준다.
  • UI 데이터의 동기화 부분 대신 앱의 동적인 데이터와 내용에 집중할 수 있다.
  • identifier와 snapshot을 사용하는 간소화 된 Data 모델을 정의 하고, 이를 이용하여 UI를 업데이트 한다.

데이터가 업데이트 되기 전과 후의 section 수의 변화가 나타나는 경우 보통 에러가 나는데, 그럴때마다 우리는 reloadData()를 해줌.

사용자 경험(UX)에 있다.
reloadData()를 사용하는 경우 한번의 업데이트마다 뚝뚝 끊기는 UI가 보이는 반면 Diffable Data Source를 사용하면 변경된 데이터 부분에 대해 스스륵 사라지고 추가되는 UI 효과가 적용됨.


import UIKit

class ViewController: UIViewController {

    var dataSource: UITableViewDiffableDataSource<Int, UUID>!
    var color: [UIColor] = [.blue, .black, .brown, .cyan, .gray, .green, .magenta]


    @IBOutlet weak var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // tableView cell 등록
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "myCell")

        // cell 모양 정의 > cellForRowAt 델리게이트 메소드 역할
        dataSource = UITableViewDiffableDataSource<Int, UUID>(tableView: tableView, cellProvider: {tableView, indexPath, itemIdentifier in
            let cell = tableView.dequeueReusableCell(withIdentifier: "myCell", for: indexPath)
            cell.backgroundColor = self.color[indexPath.row % self.color.count]

            return cell
        })

        // tableView dataSource 프로퍼티에 위 dataSource 프로퍼티 대입
        tableView.dataSource = dataSource

        // snapShop: 현재 UI 상태를 의미
        // appendSection 후 snapShot을 테이블 뷰에 대입되어잇는 dataSource객체에 apply > 자동으로 셀 업데이트
        var snapShot = NSDiffableDataSourceSnapshot<Int, UUID>()
        snapShot.appendSections([0])
//        snapShot.appendItems([UUID(), UUID(), UUID()])
        dataSource.apply(snapShot)
    }


    @IBAction func appendBtn(_ sender: Any) {
        var snapShot = dataSource.snapshot()
        snapShot.appendItems([UUID()])
        dataSource.apply(snapShot)
    }

    @IBAction func insetBtn(_ sender: Any) {
        var snapShot = dataSource.snapshot()
        // 첫번째 item에 삽입
        if let first = snapShot.itemIdentifiers.first {
            snapShot.insertItems([UUID()], afterItem: first)
        }

        dataSource.apply(snapShot)
    }

    @IBAction func deleteBtn(_ sender: Any) {
        var snapShot = dataSource.snapshot()
        // 마지막 item 삭제
        if let lastItem = snapShot.itemIdentifiers.last {
            snapShot.deleteItems([lastItem])
        }
        dataSource.apply(snapShot)
    }
}

UITableView, UICollectionView List Configuration

https://developer.apple.com/videos/play/wwdc2020/10097

https://shoveller.tistory.com/entry/WWDC20-Lists-in-UICollectionView

https://unnnyong.me/2020/07/02/wwdc-2020-advances-in-uicollectionview/

SheetPresentationController

https://beenii.tistory.com/189

Network Socket통신이란? (+HTTP통신이란?)

|

개인적인 연습 내용을 정리한 글입니다.
더 좋은 방법이 있거나, 잘못된 부분이 있으면 편하게 의견 주세요. :)


네트워크를 통해 서버로부터 데이터를 가져오기 위한 통신을 구현하기 위해서 크게 Http 프로그래밍과 Socket 프로그래밍 두가지가 있습니다. 각각의 특징에 대해 정리해보겠습니다.

Socket 프로그래밍

서버와 클라이언트가 특정 포트를 통해 실시간으로 양방향 통신 을 하는 방식

소켓 연결은 TCP/CP 프로토콜을 기반으로 맺어진 네트워크 연결 방식을 의미합니다. 이러한 소켓 연결방식으로 프로그래밍 하는 것을 소켓 프로그래밍이라고 하는데, 소켓 프로그래밍은 서버와 클라이언트가 특정 포트를 통해 연결을 유지하고 있고, 실시간으로 양방향 통신을 할 수 있는 방식을 의미합니다. 필요한 경우 클라이언트만 요청을 보낼 수 있는 HTTP 프로그래밍과 달리 소켓 프로그램이은 서버 역시 클라이언트에게 요청을 보낼 수 있는 것이 가장 큰 특징입니다.

뿐만 아니라, 계속 연결을 유지하는 연결지향형 방식을 지니고 있기 때문에 실시간 통신이 필요한 경우 자주 사용됩니다.

예로들어, 실시간 스트리밍 중계나 실시간 채팅과 같이 즉각적으로 정보를 주고받는 경우 사용하는 것이 특징입니다. 만약 실시간 동영상 스트리밍 방식을 HTTP 프로그래밍으로 구현했다고 가정하게 된다면, 사용자가 서버로 동영상을 ㅛ청하기 위해 동영상이 종료되는 순간까지 계속해서 Request를 보내야하고, 이러한 구조는 연결을 계속적으로 요청하기 때문에 부하가 걸리게 됩니다. 따라서 이러한 경우에는 소켓 프로그래밍을 통해 구현하는 것이 적합합니다.

  • 서버와 클라이언트가 계속 연결을 유지하는 양방향 프로그래밍 방식
  • 서버와 클라이언트가 실시간으로 데이터를 주고받는 상황이 플요한 경우 사용
  • 실시간 동영상 스트리밍이나 온라인 게임 등에 자주 사용

Http 프로그래밍

클라이언트의 요청이 있을때만 서버가 응답하여 해당 정보를 전송하고 곧바로 연결을 종료하는 방식

HTTP 프로그래밍의 가장 큰 특징은 클라이언트의 요청이 있을 때만 서버가 응답하여 처리한 후 바로 연결을 끊는 것입니다. 이러한 통신방식은 클라이언트가 요청을 보내는 경우에만 서버가 응답하는 단방향적 통신 으로 서버가 클라이언트로는 요청을 보낼 수 없는것이 큰 특징입니다.

클라이언트가 웹에서 어떤 링크를 클린한 순간에 클라이언트는 서버로 링크에 포함된 내용을 보내달라고 요청을 보내게 됩니다. 그리고 그 요청에 대한 응답을 받은 즉시 바로 둘 사이의 연결은 끊기게 됩니다. 이러한 Http 프로그래밍 방식은 실시간 연결이 아닌, 필요한 경우에만 서버로 접근하는 콘텐츠 위주의 데이터를 사용할 때 용이합니다. 만약 게시물에 대한 내용을 요청하기 위해 계속해서 실시간으로 연결을 유지하는 소켓 프로그래밍을 사용하게 된다면, 게시물을 받은 후에도 계속 통신을 위한 연결이 성립되어 부하가 걸리게 될 것입니다.

일반적으로 모바일 어플리케이션은 필요한 경우에만 서버로 정보를 요청하는 경우가 많아 서버로 Http 요청을 통해 필요한 경우에 짧게 연결을 유지함으로써 비용 및 유지 보수등 대부분의 방면에서 많은 장점을 얻을 수 있게 될 것입니다.

  • 클라이언트가 요청을 보내는 경우에만 서버가 응답하는 단방향 프로그래밍 방식
  • 서버로부터 소켓 연결을 하고 응답을 받은 후에 연결이 바로 종료
  • 실시간 연결이 아닌 응답이 필요한 경우에만 서버와 연결을 맺어 요청을 보내는 상황에 유용

Socket

소켓의 사전적 의미로는 ‘구멍’, ‘연결’, ‘콘센트’의 의미를 가집니다. 주로 전기 부품을 규격에 따라 연결할 수 있게 만들어진 ‘구멍 형태의 연결부’를 일컫는 단어인데, 가정에서 흔히 볼 수 있는 콘센트 구멍을 떠올리면 쉽게 이해할 수 있습니다. 네트워크 프로그래밍에서 소켓에 대한 의미도 사전적의미를 크게 벗어나지 않습니다. 프로그램이 네트워크에서 송수신할 수 있도록, 네트워크 환경에 연결할 수 있게 만들어진 연결부 를 바로 소켓이라고 합니다.

이러한 네트워크에 연결하기 위한 소켓은 통신을 위한 프로토콜에 맞게 만들어져야 합니다. 소켓으로 네트워크 통신 기능을 구현하기 위해서는 소켓을 만드는 것과, 만들어진 소켓을 통해 데이터를 주고 받는 절차에 대한 이해가 필요하고, 운영체제 및 프로그래밍 언어에 종속적으로 제공되는 소켓 API 사용법을 숙지해야합니다.

덤으로 케이블 분리로 인한 네트워크 단절, 트래픽 증가에 따른 데이터 전송 지연, 시스템 리소스 관리 문제로 인한 에러 등 네트워크 환경에서 발생할 수 있는 다양한 예외사항에 대해서도 처리가 필요하기 때문에 소켓 프로그래밍은 더욱 어렵게 느껴질 수도 있습니다.

클라이언트 소켓(Client Socket)과 서버 소켓(Server Socket)

두 개의 시스템(또는 프로세스)가 소켓을 통해 네트워크 연결을 만들어내기 위해서는 최초 어느 한곳에서 그 대상이 되는 곳으로 연결을 요청해야합니다. IP 주소와 포트 번호 를 통해 식별되는 대상에게 자신의 데이터 송수신을 위한 네트워크 연결을 수립할 의사가 있음을 알려야하죠. 그런데, 최초 한 곳에서 무작정 연결을 시도한다고 해서, 그 요청이 무조건 받아들여지고 연결이 되어 데이터를 주고받을 수 있는 것은 아닙니다. 한곳에서 요청을 보낸다고 하더라고 그 대상 시스템이 요청을 받아들일 준비가 되어있지 않다면 해당 요청은 무시되고 연결은 이루어지지 않습니다.

그렇기 때문에 요청을 받아들이는 곳에서도 어떤 연결 요청을 받아들일 것인지를 미리 시스템에 등록하는 절차가 필요하고, 그 이후 요청이 수신되었을 때 해당 요청을 처리할 수 있도록 준비해야 합니다.

따라서 두개의 시스템(또는 프로세스)이 소켓을 통해 데이터 통신을 위한 연결을 만들기 위해서는 연결 요청을 보내는지 또는 요청을 받아들이는 지에 따라 소켓의 역할이 나뉘게 뙤는데, 전자에 사용되는 소켓을 클라이언트 소켓이라 하고 후자에 사용되는 소켓을 서버 소켓이라고 합니다.

그런데 여기서 중요한 점은 앞서 클라이언트 소켓과 서버소켓이 전혀 별개의 소켓이라고 생각하면 안되는 것 입니다. 소켓의 역할과 구현 절차 구분을 위해 다르게 부르는 것일 뿐 전혀 다른 형태의 소켓이 아니고 단지 역할에 따라 호출되는 API 함수 종류와 순서만 다를뿐 동일한 소켓임 을 알아야합니다. 그리고 이 두 소켓은 절대로 직접 데이터를 주고 받지 않습니다. 서버 소켓은 클라이언트 소켓의 연결 요청을 받아들이는 역할만 수행할 뿐, 직접적인 데이터 송수신은 서버 소켓의 연결, 요청, 수락의 결과로 만들어지는 새로운 소켓을 통해 처리 됩니다.

소켓 API(Socket API)의 실행흐름

클라이언트 소켓(Client Socket)

클라이언트 소켓은 처음 소켓을 생성(create) 한 다음, 서버 측에 연결(connect) 을 요청합니다. 그리고 서버 소켓에서의 연결이 받아들여지면 데이터를 송수신(send/recv) 하고, 모든 처리가 완료되면 소켓을 닫습니다(close).

서버 소켓(server socket)

서버 소켓은 클라이언트보다는 조금 복잡한 과정을 거칩니다. 우선 클라이언트와 마찬가지로 처음 소켓을 생성(create) 합니다. 그리고 서버가 사용할 IP주소와 포트 번호를 생성해 소켓에 결합(bind) 합니다. 이후 클라이언트로부터 연결 요청이 수신되는 지 주시(listen) 하고, 요청이 수신되면 요청을 받아들어(accept) 데이터 통신을 위한 새로운 소켓을 생성합니다. 그렇게 새로운 소켓을 통해 연결이 수립(ESTABLISH)되면, 클라이언트와 마찬가지로 데이터를 송수신(send/recv) 할 수 있게 되고 데이터 송수신이 완료되면 소켓을 닫습니다(close).

클라이언트 소켓 프로그래밍(Client Socket Programming)

클라이언트 소켓생성(socket())

소켓 통신을 위해 가장 먼저 해야할 일은 소켓을 생성하는 것입니다. 이때 소켓의 종류를 지정할 수 있는데, TCP 소켓을 위해서는 스트림(stream)타입, UDP 소켓을 위해서는 데이터그램(Datagram) 타입을 지정할 수 있습니다.

이 최초 소켓이 만들어지는 시점에는 어떠한 연결 대상 에 대한 정보는 들어있지 않습니다. 그저 껍데기 뿐인 소켓 하나가 만들어진 것 뿐입니다.

연결 요청(connect())

connect() API는 IP주소포트번호 로 식별되는 대상에 연결 요청을 보냅니다.

이 API는 블럭방식으로 동작하는데, 블럭 방식이라는 것은 연결 요청에 대한 결과(성공, 거절 등)가 결정되기 전에는 connect()의 실행이 끝나지 않는 것을 의미합니다. 그렇기 때문에 connect API가 실행되지 마자 실행결과와 관계없이 무조건 결과가 리턴될 것이라고 가정해서는 안됩니다. 이 connect API 호출이 성공하면 이제부터 데이터의 송수신(send/recv) API를 통해 데이터를 주고받을 수 있게 됩니다.

데이터 송수신(send()/recv())

연결된 소켓을 통해 데이터를 보낼때는 send(), 데이터를 받을 때는 recv API를 사용합니다. 이 두 API 또한 블럭 방식으로 동작됩니다. 즉, 두 API 모두 실행결과가 결정되기 전까지는 API에 대한 결과가 리턴되지 않는 것을 의미하죠. 그중에서도 특히 recv() API는 데이터가 수신되지 않거나 에러가 발생하기 전에는 실행이 종료되지 않기 땜누에 데이터 수신 작업은 단순하게 처리하기가 쉽지는 않습니다.

send()의 경우 데이터를 보내는 주체가 자기 자신이기 때문에 얼마만큼의 데이터를 보낼지, 언제 보낼지를 알수 있지만, recv()의 경우에는 통신 대상이 언제, 언떤 데이터를 보낼 지 특정할 수 없기 때문에 해당 API는 한번 실행되면 언제 끝날지 모르는 상태가 됩니다.

따라서 데이터 수신을 위한 recv() API는 별도의 스레드에서 작업이 이루어집니다. 소켓의 생성과 연결이 완료된 후 새로운 스레드를 하나 더 만들어 그곳에서 recv()를 실행하고 데이터가 수신되길 기다리는 것이죠.

소켓닫기(close())

더 이상 데이터 송수신이 필요없게 되면 소켓을 닫기 위해 close() API를 호출합니다. 이렇게 close()에 의해 닫힌 소켓은 더 이상 유효한 소켓이 아니기 때문에 해당 소켓을 사용해 데이터를 송수신할 수 없게 됩니다. 만약 소켓 연결이 종료된 후 다시 데이터를 주고받고자 한다면 다시 한번 소켓의 생성, 연결 과정을 통해 소켓이 데이터를 송수신할 수 있는 상태가 되어야 합니다.

예시

import UIKit
import SocketIO

class SocketIOManager: NSObject {
  static let shared = SocketIOManager()
  var manager = SocketManager(socketURL: URL(string: "http://localhost:9000")!, config: [.log(true), .compress])
  var socket: SocketIOClient!

  override init() {
    super.init()
    socket = self.manager.socket(forNamespace: "/test")
    socket.on("test") { dataArray, ack in   // test로 송신된 이벤트 수신
      print(dataArray)
    }
  }

  func establishConnection() {
    socket.connect()  // 설정한 주소와 포트로 소켓 연결 시도
  }

  func closeConnection() {
    socket.disconnect()  // 소켓 연결 종료
  }

  func sendMessage(message: String, nickname: String) {
    socket.emit("event", ["message" : "This is a test message"])  // event라는 이름으로 뒤 데이터 송신
    socket.emit("event1", [["name" : "ns"], ["email" : "@naver.com"]])
    socket.emit("event2", ["name" : "ns", "email" : "@naver.com"])
    socket.emit("msg", ["nick": nickname, "msg" : message])
  }
}
  • socket.IO에서는 소켓을 룸으로 나누어 소켓을 룸단위로 구분
    • 클라이언트가 /test 룸에 속한 소켓이라면 서버에서도 /test룸으로 설정하고 처리해줘야 통신 가능

서버 소켓 프로그래밍(Server Socket Programming)

클라이언트 소켓을 처리하는 과정의 API는 비교적 간단하지만 서버 소켓의 경우 그 처리 과정이 조금 복잡합니다.

서버 소켓 생성(socket())

클라이언트 소켓과 마찬가지로 서버 소켓을 사용하려면 최초에 소켓을 생성해야 합니다.

서버 소켓 바인딩(bind())

bind의 사전적의미로 ‘결합하다’, ‘구속하다’, ‘묶다’등의 의미를 가지고 있습니다. bind() API에서 사용되는 인자는 두가지 소켓포트번호(또는 IP+포트번호) 입니다. 즉 사전적 의미로 바라보면 소켓과 포트번호를 결합한다는 의미입니다.

보통 시스템에는 많은 수의 프로세스가 동작합니다. 만약 어떤 프로세스가 TCP 또는 UDP 프로토콜을 사용한다면 각 표준에 따라 소켓은 시스템이 관리하는 포트 중 하나의 포트 번호를 사용하게 됩니다. 그런데 만약 소켓이 사용하는 포트 번호가 다른 소켓의 포트 번호와 중복된다면 어떻게 될까요?

모든 소켓이 1000이라는 동일한 포트번호를 사용하게 된다면, 네트워크를 통해 1000번 포트로 어떤 데이터가 수신될 때 어떤 소켓으로 이를 처리해야할 지 결정할 수 없는 문제가 발생하게 될 것 입니다.

그렇기 때문에 운영체제에서는 소켓들이 중복된 포트번호를 사용하지 않게 하기 위해 내부적으로 포트번호화 소켓 연결정보를 관리합니다.

그리고 bind()API에서는 해당 소켓이 지정된 포트 번호를 사용할 것이라는 것을 운영체제에 요청하는 것이 바로 해당 API의 역할입니다. 만약 지정된 포트 번호를 다른 소켓이 사용하고 있다면 bind() API는 에러를 리턴합니다. 즉 일반적으로 서버 소켓은 고정된 포트번호를 사용합니다. 그리고 그 포트 번호를 통해 클라이언트의 연결 요청을 받아들입니다. 그리고 운영체제가 특정 포트 번호를 서버 소켓이 사용하도록 만들기 위해 소켓과 포트 번호를 결합하는데 이를 결합하기 위해 사용하는 API 가 바로 bind()인 것입니다.

이를 소켓바인드, 소켓 바인딩이라고도 부릅니다.

클라이언트 연결 요청 대기(listen())

서버 소켓에 포트번호를 결합하고 나면 서버 소켓을 통해 클라이언트 연결 요청을 받아들일 준비가 되고, 이제는 클라이언트에 의한 연결요청이 수신될 때까지 기다리게 됩니다. listen()API가 그 역할을 수행합니다.

서버 소켓에 바인딩된 포트 번호를 통해 클라이언트의 연결 요청이 있는지 확인하며 대기상태에 머물게 되고, 클라이언트에서 호출된 connect() API에의해 연결요청이 수신되는지 귀 기울이고 있다가 요청이 수신되면 그 때 대기 상태를 종료하고 결과를 리턴합니다. 이렇게 listen() API가 대기 상태에서 빠져나오는 경우는 크게 두가지 입니다.

  1. 클라이언트 요청이 수신되는 경우
  2. 에러가 발생하는 경우

그런데 listen()API가 성공한 경우라도 리턴 값에는 클라이언트 요청에 대한 정보는 들어있지 않는 것이 특징입니다. 이때 반환되는 리턴값에서 판단할 수 있는 것은 단 두가지로 연결 요청이 수신되었는지(success), 그렇지 않고 에러가 발생했는지(fail) 뿐입니다.

그리고 이 클라이언트 연결 요청에 대한 정보는 시스템 내부적으로 관리되는 큐(queue)에 쌓이게 되는데, 이 시점은 클라이언트와의 연결은 아직 완전히 연결된 상태라고는 할 수없는 여전한 대기상태임을 놓치지 말아야 합니다. 이렇게 대기 중이 연결 요청을 큐로부터 꺼내와서 연결을 완료하기 위해서는 accept()API를 호출해야합니다.

클라이언트 연결 수립(accept())

다시 한번, listen() API가 클라이언트 연결 요청을 확인하고 문제없이 리턴(success)한다고 해서, 클라이언트와의 연결 과정이 모두 완료된 것은 아닙니다. 아직 실질적인 소켓 연결(connection)을 수립하는 절차가 남아있습니다. 즉 최정적으로 연결 요청을 받아들이는 역할을 수행하는 것은 accept() API입니다.

연결 요청을 받아들여 소켓 간 연결을 수립하는 것이 바로 이 API의 역할입니다. 그런데 여기서 가장 중요한 점은 최종적으로 데이터 통신을 위해 연결되는 이 소켓은 앞서 bind(), listen() API에서 사용한 서버 소켓이 아니라는 점입니다. 즉, 클라이언트 소켓과 연결이 만들어지는 소켓은 앞서 사용했던 서버 소켓이 아닌 accept()API 내부에서 만들어진 새로운 소켓이라는 점 입니다.

서버 소켓의 핵심역할은 클라이언트의 연결 요청을 수신!하는 것입니다. 이를 위해 bind() 및 listen()을 통해 소켓에 포트번호를 바인딩하고 요청 대기 큐를 생성해 클라이언트의 요청을 대기하였죠. 그리고 이후 accept() API에서 데이터 송수신을 위한 새로운 소켓을 만들고 서버 소켓의 대기 큐에 쌓여있는 첫번째 연결요청을 매핑 시킵니다. 이렇게 하나의 연결 요청을 처리하기 위한 서버 소켓의 역할은 끝나게 됩니다.

데이터 송수신(send()/recv())

이제 실질적인 데이터 송수신은 accept()API에서 생성된 연결이 수립된(Establiched)된 소켓을 통해 처리 됩니다.
데이터를 송수신하는 과정은 클라이언트 소켓 처리 과정 내용과 동일합니다.

소켓 연결 종료(close())

클라이언트 소켓 처리 과정과 마찬가지로 소켓을 닫기 위해 close() API를 호출합니다.

그런데 서버 소켓에서는 close()의 대상이 하나만 있는것이 아니란 것이 중요합니다. 최초 socket() API를 통해 생성한 서커 소켓에 더해 accept() API 호출에 의해 생성된 소켓 또한 관리해야하기 때문이죠.

예시

아래는 Socket.IO 문서에 나오는 Server를 구현하는 예시 코드입니다.

var app = require('http').createServer(handler)  // http 서버를 생성
var io = require('socket.io')(app);  // 소켓 생성
var fs = require('fs');

app.listen(80);  // 80번 포트를 연결해 클라이언트 요청을 대기

// 이제 클라이언트 소켓은 localhost:80으로 연결을 요청

io.on('connection', function (socket) {  // connection되면
  socket.emit('news', { hello: 'world' });  // 클라이언트로 news라는 키로 뒤 객체를 보냄
  socket.on('my other event', function (data) {  
    // 클라이언트에서 서버로 보낸 데이터중 'my other event'라는 키로 들어오는 값을 받아 console.log 출력
    console.log(data);
  });
});