이야 다시한번 가지고 왔습니다.
저번에 예고 했었다 시피 이번엔 컴포지셔널로 레이아웃으로 구현해 보았는데요..
난.이.도.가.상.당.합.니.다…!집중해서 읽어 주시길 바랍니다...!!!!!
기본적인 그림을 한번 보면서 진행해 볼까 합니다.
저희가 원하는 그림은 이런 사진과 같은 레이아웃이에요
레이아웃 크기를 주는것이 아니라 사용자 지정 그룹을 만들고
프레임과 인덱스 별로 각 항목을 집어 넣으면 컴포지셔널로도 가능한 구조가 됩니다.
만약 “음..? 그냥 ESTIMATE 주면 그만 아닌가..?” 라고 생각한다면
컴포지셔널 레이아웃을 처음부터 다시 학습하셔야 합니다. 완전 기초마저 없는 상태… (소곤 소곤)
그런분들을 위해..! 그냥 하면 어떻게 되는 지를 바로 설명해 드리겠습니다.
아래의 코드를 보시면 오? 이렇게만 해도 되는거 아닌가 라고 생각이 들수도 있었을것 같습니다.
func createTwoColumnsLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(250))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(250))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)
let spacing = CGFloat(10)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
하지만 아쉽게도 이렇게만 한다고 해서 가능한 구조가 될수 없겠죠.
이유가 뭘까요?
- 그룹사이즈가 미리 정해지고 나서 구겨 넣는 스타일
- 이렇게 해버리게 되면 빈공간이 날수 밖에 없는구조
1편을 보시고 오신분이라면 아시겠지만 제가 컴포지셔널 레이아웃으로 하지않고
플로우 레이아웃으로 구현하려 했던 이유가 바로 저 두 문제 였습니다.
어떻게 되는지 사진으로 더 이해해 볼까요?
이제 무엇이 문제인지 아시겠나요..?
그럼 이 문제를 어떻게 해결해야 할까요..?
자 바로 들어갑니다.
시작에 앞서 1편을 보지 않고 2편부터 보시는분들은 1편부터 봐주시길 권장드립니다.
1편의 개념이 이어집니다.
// 일단 간단하게 이름만 있는 친구를 만들어 보아요~
final class PinterestCompostionalLayout {
//여기서부터 내용이 들어갑니다
}
구조체가 필수는 아니지만 매개변수가 많아지니 configuration으로 하나 만들어 줍니다.
/// 핀터레스트 레이아웃 구성에 필요한 세부사항들을 정의하는 구조체입니다.
struct Configuration {
let numberOfColumns: Int // 열 갯수
let interItemSpacing: CGFloat // 열과 행 간격 -> 아이템 간격
let contentInsetsReference: UIContentInsetsReference // 섹션 ( 컨텐츠 ) 인셋
let itemHeightProvider: (_ index: Int,_ itemWidth: CGFloat) -> CGFloat // 특정 인뎃스 아이템 높이 클로저
let itemCountProfider: () -> Int // 섹션의 항목(아이템)수 제공하는 클로저
init( numberOfColumns: Int = 2, // 상동
interItemSpacing: CGFloat = 8, // 상동
contentInsetsReference: UIContentInsetsReference = .automatic, // 상동
itemHeightProvider: @escaping (_: Int, _: CGFloat) -> CGFloat, // 상동 -> 개발자 깃허브 조사해보니 사이즈를 미리 알아야 하니 클로저로 해결한 케이스
itemCountProfider: @escaping () -> Int // 상동
) {
self.numberOfColumns = numberOfColumns
self.interItemSpacing = interItemSpacing
self.contentInsetsReference = contentInsetsReference
self.itemHeightProvider = itemHeightProvider
self.itemCountProfider = itemCountProfider
}
}
주석으로 남겨 놓았듯이 클로저들은 계산된 값을 가져오기 위함이에요!
// 레이아웃을 구성하는데에 필요한 데이터를 계산하는 클래스 입니다.
final class LayoutBuilder {
private var columnHeights: [CGFloat] // 열 높이들을 저장하죠
private let numberOfColumns: CGFloat // 열 갯수
private let interItemSpacing: CGFloat // 열과 행 (아이템) 간격
private let itemHeightProvider: (_ index: Int,_ itemWidth: CGFloat) -> CGFloat // 특정 아이템 높이 계산하는 클로저
private let collectionWidth: CGFloat // 컬렉션뷰 넓이
init(configuration: Configuration, collectionWidth: CGFloat) {
// 초기화시 기본값 설정 합니당
// 모든 열의 초기 높이를 0으로 설정해요
columnHeights = [CGFloat](repeating: 0, count: configuration.numberOfColumns)
numberOfColumns = CGFloat(configuration.numberOfColumns)
itemHeightProvider = configuration.itemHeightProvider
interItemSpacing = configuration.interItemSpacing
self.collectionWidth = collectionWidth
}
/// 각 열의 너비를 계산하는 계산 속성
private
var columnWidth: CGFloat {
// 행 갯수 - 1 (총 인덱스) * 아이템 간격
let spacing = (numberOfColumns - 1) * interItemSpacing
// 전체 컬렉션뷰 넓이에서 간격을 제거 -> 행 갯수를 나눔 -> 하나의 행 넓이
return (collectionWidth - spacing) / numberOfColumns
}
/// 특정 아이템의 프레임을 계산하는 메서드에요
private
func frame(for row: Int) -> CGRect {
let width = columnWidth // 열의 너비를 가져옵니다
let height = itemHeightProvider(row, width) // 클로저를 사용하여 아이템의 높이를 계산해요
let size = CGSize(width: width, height: height) // 계산된 높이와 너비를 통해 사이즈 만들기
let origin = itemOrigin(width: size.width) // 아이템의 위치를 어디에 할지 계산합니다.
return CGRect(origin: origin, size: size) // 위치와 사이즈를 통해 REACT ( 사각형 알죠? ) 생성해요
}
// 행 레이아웃 아이템 생성
func makeLayoutItem(for row: Int) -> NSCollectionLayoutGroupCustomItem {
let frame = frame(for: row) // 아이템 프레임을 계산해요
columnHeights[columnIndex()] = frame.maxY + interItemSpacing // 계산된 프레임 maxY 간격을 더한값을 현재 열의 높이로 설정합니다.
print("frame: \(frame)!")
return NSCollectionLayoutGroupCustomItem(frame: frame)
}
// 가장 낮은 열의 인덱스를 반환하는 메서드입니다.
private
func columnIndex() -> Int {
columnHeights
.enumerated() // 인덱스화 했는데..? -> 각 열의 높이, 인덱스를 포함하는 [0:0.0] 시퀀스를 만들어요
.min(by: { $0.element < $1.element })? .offset ?? 0 // 두 인덱스 튜플에서 더 작은 값을 찾고, 가장 작은 높이값은 같는 튜플을 반환합니당 이때 .offset 은 인덱스를 가져온다는 의미!
//만약 min(by: _ ) 에서 nil 일때는, 0를 반환 하죠
}
// item x,y 좌표가 어딘지 계산해주는 메서드에요
private
func itemOrigin(width: CGFloat) -> CGPoint {
let y = columnHeights[columnIndex()].rounded() // 선택된 열의 높이를 반올림 하고, Y좌표로 사용합니다.
let x = (width + interItemSpacing) * CGFloat(columnIndex()) // 선택된 열의 인덱스를 이용하여 X 좌표를 계산해요
return CGPoint(x: x, y: y) // 계산된 x, y 좌표를 통해 CGPoint를 생성하여 반환합니다
}
/*
columnHeights[columnIndex()] 의 이해
만약 [100.0, 200.0, 50.0] 이라고 가정할때,
가장 낮은값은 50.0 이고 이값은 "2" 인덱스에 위치하기에 "2" 를 반환할것입니다.
그럼 자연스럽게 columnHeights[columnIndex()].rounded() 는 반올림 하게 되면 50이 되겠죠?
100이 될것 같겠지만 CGFloat에서의 반올림은 소수점을 반올림 하는 개념이기에 50이 됩니다.
*/
// 모든 열중 가장 높은 높이를 반환해줘요
fileprivate
func maxcolumHeight() -> CGFloat {
// 가장 높은 열의 높이를 받아요
print("columnHeights\(columnHeights)!!!")
return columnHeights.max() ?? 0
}
}
// 지정된 설정, 레이아웃 상태에 따라 컬렉션뷰 섹션의 레이아웃을 생성하는 메서드
static
func makeLayoutSection(
config: Configuration, // 위에서 만든 구조체를 활용
environment: NSCollectionLayoutEnvironment, // 레이아웃 상태
sectionIndex: Int // 섹션 인덱스
) -> NSCollectionLayoutSection {
var items: [NSCollectionLayoutGroupCustomItem] = [] // 아이템을 저장할 배열이죠
let itemProvider = LayoutBuilder(
configuration: config,
collectionWidth: environment.container.contentSize.width
)
for i in 0..<config.itemCountProfider() {
let item = itemProvider.makeLayoutItem(for: i) // 아이템 별 레이아웃 생성
items.append(item) // 생성된 레이아웃 아이템을 배열에 추가할 거에요
}
let groupLayoutSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1), // 그룹에 너비는 컨테이너의 전체 너비
heightDimension: .absolute(itemProvider.maxcolumHeight()) // 그룹의 높이는 가장 높은 열의 높이를 사용
)
let group = NSCollectionLayoutGroup.custom(
layoutSize: groupLayoutSize) { _ in // 레이아웃 그룹 생성후, 설정된 아이템 배열을 반환해요
return items
}
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = config.contentInsetsReference // 색션 여백 설정
return section
}
컴포지셔널 레이아웃은 확실이 개인차 스타일이 존재할 것입니다.
그점 감안 부탁 드리겠습니다.
// 저는 좋아요 모아보기가 컴포지셔널 레이아웃이다 보니 여기서 해볼께요!
final class LikeViewController: RxBaseViewController { }
typealias DataSourceSnapShot = NSDiffableDataSourceSnapshot<Int, SNSDataModel>
typealias DataSource = UICollectionViewDiffableDataSource<Int, SNSDataModel>
// ANYHashable 이여도 무방합니다.
private
let collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: .init())
.then {
$0.register(
PinterestCell.self,
forCellWithReuseIdentifier: PinterestCell.reusableIdenti
)
}
컬렉션뷰 레이아웃은 초기값으로 빈 레이아웃을 주어도 무방합니다.
후에 가서 갈아 끼워줄수 있거든요!
저처럼 바로 셀 디큐를 하시어도 되고 아니면 따로 레지스트 생성하시고 연결하셔도 무방합니다.
...
var dataSource: DataSource?
...
private
func makeDataSource() {
dataSource = DataSource(
collectionView: collectionView,
cellProvider: { collectionView, indexPath, itemIdentifier in
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: PinterestCell.reusableIdenti,
for: indexPath
) as? PinterestCell else {
print("PinterestCell Error")
return .init()
}
cell.setModel(itemIdentifier)
return cell
}
)
}
// 다른방법으론
private
func makeDataSource() {
let register = colelctionCellRegiter()
dataSource = DataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
collectionView.dequeueConfiguredReusableCell(using: register, for: indexPath, item: itemIdentifier)
})
}
private
func colelctionCellRegiter() -> UICollectionView.CellRegistration<PinterestCell, SNSDataModel> {
UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in
cell.setModel(itemIdentifier)
}
}
private
func createLayout() -> UICollectionViewCompositionalLayout {
let viewWidth = view.bounds.width
let layout = UICollectionViewCompositionalLayout { [weak self] section, env in
guard let self else { return nil }
return createPinterstLayout(env: env, models: viewModel.realModel.value, viewWidth: viewWidth)
}
return layout
}
//
// PinterestCompostionalLayout.swift
// CategoryZ
//
// Created by Jae hyung Kim on 5/7/24.
//
import UIKit
/// 핀터레스트 컴포지셔널 레이아웃입니다.
final class PinterestCompostionalLayout {
/// 핀터레스트 레이아웃 구성에 필요한 세부사항들을 정의하는 구조체입니다.
struct Configuration {
let numberOfColumns: Int // 열 갯수
let interItemSpacing: CGFloat // 열과 행 간격 -> 아이템 간격
let contentInsetsReference: UIContentInsetsReference // 섹션 ( 컨텐츠 ) 인셋
let itemHeightProvider: (_ index: Int,_ itemWidth: CGFloat) -> CGFloat // 특정 인뎃스 아이템 높이 클로저
let itemCountProfider: () -> Int // 섹션의 항목(아이템)수 제공하는 클로저
init( numberOfColumns: Int = 2, // 상동
interItemSpacing: CGFloat = 8, // 상동
contentInsetsReference: UIContentInsetsReference = .automatic, // 상동
itemHeightProvider: @escaping (_: Int, _: CGFloat) -> CGFloat, // 상동 -> 개발자 깃허브 조사해보니 사이즈를 미리 알아야 하니 클로저로 해결한 케이스
itemCountProfider: @escaping () -> Int // 상동
) {
self.numberOfColumns = numberOfColumns
self.interItemSpacing = interItemSpacing
self.contentInsetsReference = contentInsetsReference
self.itemHeightProvider = itemHeightProvider
self.itemCountProfider = itemCountProfider
}
}
// 레이아웃을 구성하는데에 필요한 데이터를 계산하는 클래스 입니다.
final class LayoutBuilder {
private var columnHeights: [CGFloat] // 열 높이들을 저장하죠
private let numberOfColumns: CGFloat // 열 갯수
private let interItemSpacing: CGFloat // 열과 행 (아이템) 간격
private let itemHeightProvider: (_ index: Int,_ itemWidth: CGFloat) -> CGFloat // 특정 아이템 높이 계산하는 클로저
private let collectionWidth: CGFloat // 컬렉션뷰 넓이
init(configuration: Configuration, collectionWidth: CGFloat) {
// 초기화시 기본값 설정 합니당
// 모든 열의 초기 높이를 0으로 설정해요
columnHeights = [CGFloat](repeating: 0, count: configuration.numberOfColumns)
// 구조체의 열의 수를 저장해요
numberOfColumns = CGFloat(configuration.numberOfColumns)
itemHeightProvider = configuration.itemHeightProvider
interItemSpacing = configuration.interItemSpacing
self.collectionWidth = collectionWidth
}
/// 각 열의 너비를 계산하는 계산 속성
private
var columnWidth: CGFloat {
// 행 갯수 - 1 (총 인덱스) * 아이템 간격
let spacing = (numberOfColumns - 1) * interItemSpacing
// 전체 컬렉션뷰 넓이에서 간격을 제거 -> 행 갯수를 나눔 -> 하나의 행 넓이
return (collectionWidth - spacing) / numberOfColumns
}
/// 특정 아이템의 프레임을 계산하는 메서드에요
private
func frame(for row: Int) -> CGRect {
let width = columnWidth // 열의 너비를 가져옵니다
let height = itemHeightProvider(row, width) // 클로저를 사용하여 아이템의 높이를 계산해요
let size = CGSize(width: width, height: height) // 계산된 높이와 너비를 통해 사이즈 만들기
let origin = itemOrigin(width: size.width) // 아이템의 위치를 어디에 할지 계산합니다.
return CGRect(origin: origin, size: size) // 위치와 사이즈를 통해 REACT ( 사각형 알죠? ) 생성해요
}
// 행 레이아웃 아이템 생성
func makeLayoutItem(for row: Int) -> NSCollectionLayoutGroupCustomItem {
let frame = frame(for: row) // 아이템 프레임을 계산해요
columnHeights[columnIndex()] = frame.maxY + interItemSpacing // 계산된 프레임 maxY 간격을 더한값을 현재 열의 높이로 설정합니다.
print("frame: \(frame)!")
return NSCollectionLayoutGroupCustomItem(frame: frame)
}
// 가장 낮은 열의 인덱스를 반환하는 메서드입니다.
private
func columnIndex() -> Int {
columnHeights
.enumerated() // 인덱스화 했는데..? -> 각 열의 높이, 인덱스를 포함하는 [0:0.0] 시퀀스를 만들어요
.min(by: { $0.element < $1.element })? .offset ?? 0 // 두 인덱스 튜플에서 더 작은 값을 찾고, 가장 작은 높이값은 같는 튜플을 반환합니당 이때 .offset 은 인덱스를 가져온다는 의미!
//만약 min(by: _ ) 에서 nil 일때는, 0를 반환 하죠
}
// item x,y 좌표가 어딘지 계산해주는 메서드에요
private
func itemOrigin(width: CGFloat) -> CGPoint {
let y = columnHeights[columnIndex()].rounded() // 선택된 열의 높이를 반올림 하고, Y좌표로 사용합니다.
let x = (width + interItemSpacing) * CGFloat(columnIndex()) // 선택된 열의 인덱스를 이용하여 X 좌표를 계산해요
return CGPoint(x: x, y: y) // 계산된 x, y 좌표를 통해 CGPoint를 생성하여 반환합니다
}
/*
columnHeights[columnIndex()] 의 이해
만약 [100.0, 200.0, 50.0] 이라고 가정할때,
가장 낮은값은 50.0 이고 이값은 "2" 인덱스에 위치하기에 "2" 를 반환할것입니다.
그럼 자연스럽게 columnHeights[columnIndex()].rounded() 는 반올림 하게 되면 50이 되겠죠?
100이 될것 같겠지만 CGFloat에서의 반올림은 소수점을 반올림 하는 개념이기에 50이 됩니다.
*/
// 모든 열중 가장 높은 높이를 반환해줘요
fileprivate
func maxcolumHeight() -> CGFloat {
// 가장 높은 열의 높이를 받아요
print("columnHeights\(columnHeights)!!!")
return columnHeights.max() ?? 0
}
}
// 지정된 설정, 레이아웃 상태에 따라 컬렉션뷰 섹션의 레이아웃을 생성하는 메서드
static
func makeLayoutSection(
config: Configuration, // 위에서 만든 구조체를 활용
environment: NSCollectionLayoutEnvironment, // 레이아웃 상태
sectionIndex: Int // 섹션 인덱스
) -> NSCollectionLayoutSection {
var items: [NSCollectionLayoutGroupCustomItem] = [] // 아이템을 저장할 배열이죠
let itemProvider = LayoutBuilder(
configuration: config,
collectionWidth: environment.container.contentSize.width
)
for i in 0..<config.itemCountProfider() {
let item = itemProvider.makeLayoutItem(for: i) // 아이템 별 레이아웃 생성
items.append(item) // 생성된 레이아웃 아이템을 배열에 추가할 거에요
}
let groupLayoutSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1), // 그룹에 너비는 컨테이너의 전체 너비
heightDimension: .absolute(itemProvider.maxcolumHeight()) // 그룹의 높이는 가장 높은 열의 높이를 사용
)
let group = NSCollectionLayoutGroup.custom(
layoutSize: groupLayoutSize) { _ in // 레이아웃 그룹 생성후, 설정된 아이템 배열을 반환해요
return items
}
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = config.contentInsetsReference // 색션 여백 설정
return section
}
}
//
// LikeViewController.swift
// CategoryZ
//
// Created by Jae hyung Kim on 4/28/24.
//
import UIKit
import RxSwift
import RxCocoa
import SnapKit
import Then
final class LikeViewController: RxBaseViewController {
typealias DataSourceSnapShot = NSDiffableDataSourceSnapshot<Int, SNSDataModel>
typealias DataSource = UICollectionViewDiffableDataSource<Int, SNSDataModel>
private
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()).then {
$0.register(PinterestCell.self, forCellWithReuseIdentifier: PinterestCell.reusableIdenti)
}
private
let viewModel = LikeViewModel()
var dataSource: DataSource?
override func viewDidLoad() {
super.viewDidLoad()
navigationSetting()
}
override func subscriver() {
let startTriggerSub = BehaviorRelay<Void> (value: ())
let currentCellItemAt = PublishRelay<Int> ()
let input = LikeViewModel.Input(
startTriggerSub: startTriggerSub,
currentCellItemAt: currentCellItemAt
)
let output = viewModel.transform(input)
networkError(output.networkError)
collectionViewRxSetting(output.successData)
collectionView.rx.itemSelected
.bind(with: self) { owner, indexPath in
let vc = SingleSNSViewController()
let model = output.successData.value[indexPath.item]
model.currentRow = indexPath.item
vc.setModel(model)
vc.ifChangeOfLikeDelegate = owner.viewModel
owner.navigationController?.pushViewController(vc, animated: true)
}
.disposed(by: disPoseBag)
collectionView.rx.willDisplayCell
.bind { cellInfo in
currentCellItemAt.accept(cellInfo.at.item)
}
.disposed(by: disPoseBag)
}
private
func networkError(_ error: Driver<NetworkError>) {
error.drive(with: self) { owner, error in
owner.errorCatch(error)
}
.disposed(by: disPoseBag)
}
private
func collectionViewRxSetting(_ models: BehaviorRelay<[SNSDataModel]>) {
models
.bind(with: self) { owner, models in
owner.makeSnapshot(models: models)
}
.disposed(by: disPoseBag)
}
override func configureHierarchy() {
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
collectionView.setCollectionViewLayout(createLayout(), animated: true)
makeDataSource()
makeSnapshot(models: [])
}
override func navigationSetting(){
navigationItem.title = "좋아요 모아요"
}
private
func createPinterstLayout(env: NSCollectionLayoutEnvironment, models: [SNSDataModel], viewWidth: CGFloat) -> NSCollectionLayoutSection {
let layout = PinterestCompostionalLayout.makeLayoutSection(
config: .init(
numberOfColumns: 2, // 몇줄?
interItemSpacing: 8, // 간격은?
contentInsetsReference: UIContentInsetsReference.automatic, // 알아서
itemHeightProvider: { item, _ in
let aspectString = models[item].content3
let aspect = CGFloat(Double(aspectString) ?? 1)
let result = (viewWidth / 2 / aspect) + 60
return result
},
itemCountProfider: {
return models.count
}
),
environment: env,
sectionIndex: 0
)
return layout
}
private
func makeDataSource() {
let register = colelctionCellRegiter()
dataSource = DataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
collectionView.dequeueConfiguredReusableCell(using: register, for: indexPath, item: itemIdentifier)
})
}
private
func colelctionCellRegiter() -> UICollectionView.CellRegistration<PinterestCell, SNSDataModel> {
UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in
cell.setModel(itemIdentifier)
}
}
private
func makeSnapshot(models: [SNSDataModel]) {
var snapshot = DataSourceSnapShot()
snapshot.deleteAllItems()
snapshot.appendSections([0])
snapshot.appendItems(models.map{$0}, toSection: 0)
dataSource?.apply(snapshot, animatingDifferences: false)
}
private
func createLayout() -> UICollectionViewCompositionalLayout {
let viewWidth = view.bounds.width
let layout = UICollectionViewCompositionalLayout { [weak self] section, env in
guard let self else { return nil }
return createPinterstLayout(env: env, models: viewModel.realModel.value, viewWidth: viewWidth)
}
return layout
}
}
플로우로 먼저 해보길 잘했다는 생각이 들었던 미션이였습니다.
플로우에서 먼저 레이아웃이 이런식으로 그려질수 있구나를 배웠었죠
여기서도 똑같이 그 개념을 이용해 만든 방법 이였어요!!!
다시 한번 과거를 돌아볼수 있었던 좋은 시간 이였습니다.!!!
이상입니당
오호... 유용하게 잘 쓰겠습니다.