Skip to content

๐Ÿ” ๋ง›์Š๋žญ - ๋‚˜๋งŒ์˜ ๋ง›์ง‘ ์ปฌ๋ ‰์…˜ 2.0 ์„œ๋ฒ„ ๋ฒ„์ „

Notifications You must be signed in to change notification settings

Lavender-SH/MatchelinServer-RX-MVVM

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

27 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

๋ง›์Š๋žญ - ๋‚˜๋งŒ์˜ ๋ง›์ง‘ ์ปฌ๋ ‰์…˜


๋ง›์Š๋žญ ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ

์•ฑ ์„ค๋ช…

  • ๋ง›์Š๋žญ์€ ๋ง›์ง‘์„ ์‚ฌ๋ž‘ํ•˜๋Š” ์‚ฌ์šฉ์ž๋“ค์„ ์œ„ํ•ด ์„ค๊ณ„๋œ ์•ฑ์œผ๋กœ, ๋ฐฉ๋ฌธํ•œ ๋ง›์ง‘์„ ๊ธฐ๋กํ•˜๊ณ , ๋‚˜๋งŒ์˜ ๋ฏธ์‹ ์ง€๋„๋ฅผ ๋งŒ๋“ค์–ด ํŠน๋ณ„ํ•œ ์ถ”์–ต์„ ๊ฐ„์งํ•  ์ˆ˜ ์žˆ๋„๋ก ๋•์Šต๋‹ˆ๋‹ค.

  • ์—ฌ๋Ÿฌ๋ถ„์€ ์ง์ ‘ ๋ง›์ง‘์— "๋ง›์Š๋žญ ์Šคํƒ€"๋ฅผ ๋ถ€์—ฌํ•˜๋ฉฐ, ๋‚˜๋งŒ์˜ ๋ฏธ์‹ ๊ธฐ์ค€์„ ๋งŒ๋“ค์–ด๊ฐˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ์•ฑ์€ ๋‹จ์ˆœํžˆ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๊ฒƒ์„ ๋„˜์–ด, ์—ฌ๋Ÿฌ๋ถ„์˜ ๋ฏธ์‹ ์—ฌ์ •์„ ์‹œ๊ฐ์ ์œผ๋กœ ํ‘œํ˜„ํ•˜๊ณ , ์ถ”์–ต์„ ๊ณต์œ ํ•˜๋ฉฐ, ์ƒˆ๋กœ์šด ๊ฒฝํ—˜์„ ๋ฐœ๊ฒฌํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

  • ์‚ฌ์ง„๊ณผ ๋ฆฌ๋ทฐ๋ฅผ ํ†ตํ•ด ๋ง›์ง‘์„ ํ•œ๋ˆˆ์— ๊ด€๋ฆฌํ•˜๊ณ , ์ง€๋„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋‚˜๋งŒ์˜ ๋ฏธ์‹ ์ง€๋„๋ฅผ ๋งŒ๋“ค์–ด๋ณด์„ธ์š”.

  • ๋ง›์Š๋žญ๊ณผ ํ•จ๊ป˜๋ผ๋ฉด, ํ‰๋ฒ”ํ•œ ํ•˜๋ฃจ๋„ ํŠน๋ณ„ํ•œ ๋ฏธ์‹ ๊ฒฝํ—˜์œผ๋กœ ๋ฐ”๋€Œ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋ง›์žˆ๋Š” ์ˆœ๊ฐ„์„ ๊ธฐ๋กํ•˜๊ณ , ์ƒˆ๋กœ์šด ๋ฏธ์‹ ์„ธ๊ณ„๋ฅผ ํƒํ—˜ํ•ด๋ณด์„ธ์š”!


์„ฑ๊ณผ

  • ์•ฑ์Šคํ† ์–ด ์Œ์‹ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐจํŠธ ์ตœ๊ณ  ์ˆœ์œ„ 30์œ„
  • MAU ํ‰๊ท  200๋ช…, ๋‹ค์šด๋กœ๋“œ ์ˆ˜ 1200ํšŒ
  • ํ‰๊ท  ๋ณ„์  (4.8/5.0)์ 



ํ”„๋กœ์ ํŠธ ๊ธฐ๊ฐ„

  • 2024.10.01 ~ 2024.11.1 (4์ฃผ) + ํ˜„์žฌ ์ง„ํ–‰์ค‘

ํ”„๋กœ์ ํŠธ ์ฐธ์—ฌ ์ธ์›

  • ๊ฐœ์ธ(1์ธ) ํ”„๋กœ์ ํŠธ

๋ง›์Š๋žญ 2.0 ์„œ๋ฒ„ ๋ฒ„์ „ โญ๏ธ

ํ˜„์žฌ ๋ง›์Š๋žญ์€ 1.0์˜ ์„ฑ๊ณต์„ ๊ธฐ๋ฐ˜์œผ๋กœ, ์„œ๋ฒ„์™€ AI ๊ธฐ์ˆ ์„ ๋„์ž…ํ•˜์—ฌ ๋”์šฑ ๋ฐœ์ „๋œ 2.0 ๋ฒ„์ „์„ ๊ฐœ๋ฐœ ์ค‘์ž…๋‹ˆ๋‹ค.

  • ๋ง›์Š๋žญ ํ”„๋กœ์ ํŠธ ๊ธฐํš์„œ

  • ์„œ๋ฒ„ ๊ธฐ๋ฐ˜ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ: ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ์ €์žฅํ•˜๊ณ , ์—ฌ๋Ÿฌ ๊ธฐ๊ธฐ ๊ฐ„ ๋™๊ธฐํ™” ๊ธฐ๋Šฅ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

  • ์†Œ์…œ ๋„คํŠธ์›Œํฌ ํ™•์žฅ: ์‚ฌ์šฉ์ž๊ฐ€ ์นœ๊ตฌ์™€ ๋ง›์ง‘ ์ •๋ณด๋ฅผ ๊ณต์œ ํ•˜๊ณ  ์„œ๋กœ์˜ ์ปฌ๋ ‰์…˜์„ ํƒ์ƒ‰ํ•  ์ˆ˜ ์žˆ๋Š” ์†Œ์…œ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

  • AI ์ถ”์ฒœ ๊ธฐ๋Šฅ ๊ฐ•ํ™”: ์‚ฌ์šฉ์ž ๋ฆฌ๋ทฐ ๋ฐ ๋ฐฉ๋ฌธ ๊ธฐ๋ก์„ ๋ถ„์„ํ•˜์—ฌ ๊ฐœ์ธํ™”๋œ ๋ง›์ง‘ ์ถ”์ฒœ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.



๊ธฐ์ˆ  ์Šคํƒ

  • Framework UIKit, RXSwift, RealmSwift, MapKit, Alamofire, Kingfisher, WebKit, Zip, PhotosUI, SideMenu ,SnapKit, MessageUI, Cosmos, Firebase Analytics, Firebase Crashlytics

  • Design Pattern MVVM, MVC


๋ง›์Š๋žญ 1.0 ํ•ต์‹ฌ ๊ธฐ๋Šฅ๊ณผ ์ฝ”๋“œ ์„ค๋ช…

  • 1. ๋ง›์ง‘์„ ๊ธฐ๋กํ•˜๊ณ  ๊ด€๋ฆฌํ•˜๋Š” ๊ธฐ๋Šฅ
    RealmSwift๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ตฌ์„ฑ
    1-1. Realm ๋ชจ๋ธ์— ๋ฆฌ๋ทฐ๋กœ ์ €์žฅํ•  ๋‚ด์šฉ ์ •์˜
    1-2. Realm Repository๋ฅผ ํ™œ์šฉํ•œ CRUD ๊ตฌํ˜„
    1-3. ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ„์ ์ˆœ, ์‹œ๊ฐ„์ˆœ, ๋ฐฉ๋ฌธ์ˆœ ์ •๋ ฌํ•˜๋Š” ๊ธฐ๋Šฅ

  • 2. ๋‚˜๋งŒ์˜ ๋ง›์ง‘ ์•จ๋ฒ”์„ ๋งŒ๋“ค์–ด ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ๋ถ„๋ฅ˜ํ•˜๋Š” ๊ธฐ๋Šฅ
    2-1. To-Many Relationship์„ ํ™œ์šฉํ•œ ์•จ๋ฒ” ์ƒ์„ฑ ๊ธฐ๋Šฅ
    2-2. ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด๋ฐ” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์นดํ…Œ๊ณ ๋ฆฌ ํƒ์ƒ‰ ์ง€์›

  • 3. ์Œ์‹์  ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ
    3-1. ์นด์นด์˜ค ๋กœ์ปฌ API ํ™œ์šฉ

  • 4. WebView๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์Œ์‹์  ์‚ฌ์ดํŠธ๋กœ ๋ฐ”๋กœ ์ด๋™ํ•˜๋Š” ๊ธฐ๋Šฅ

  • 5. ์ง€๋„์œ„์— ๋‚˜๋งŒ์˜ ๋ง›์ง‘์„ ๋ณผ ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ
    5-1. MapKit์˜ Annotation์„ ํ™œ์šฉํ•˜์—ฌ ์ง€๋„์— ํ•€์„ ์‚ฌ์ง„์œผ๋กœ ํ‘œํ˜„
    5-2. ์ง€๋„์œ„์˜ ํ•€์„ ํด๋Ÿฌ์Šคํ„ฐ๋ง์œผ๋กœ ๊ตฌํ˜„
    5-3. SearchBar๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ €์žฅํ•œ ๋ง›์ง‘์„ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ

  • 6. ๋ฐฑ์—… ํŒŒ์ผ ์ƒ์„ฑ ๋ฐ ๊ณต์œ /๋ณต๊ตฌ ๊ธฐ๋Šฅ
    6-1. ๋ฐฑ์—… ํŒŒ์ผ ์ƒ์„ฑ ๋ฐ ZIP ํŒŒ์ผ๋กœ ์••์ถ•
    6-2. ๋ฐฑ์—… ํŒŒ์ผ ๋ณต๊ตฌ ๋ฐ ZIP ํ•ด์ œ
    6-3. ๋ฐฑ์—… ํŒŒ์ผ ๊ณต์œ 
    6-4. ๋ฐฑ์—… ํŒŒ์ผ ์‚ญ์ œ

  • 7. ์•ฑ์—์„œ ์ง์ ‘ ์ด๋ฉ”์ผ์„ ํ†ตํ•ด ๋ฌธ์˜๋‚˜ ์˜๊ฒฌ์„ ์ˆ˜์ง‘ํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ
    7-1. ์ด๋ฉ”์ผ ์ž‘์„ฑ ๋ฐ ์ „์†ก
    7-2. ์ด๋ฉ”์ผ ์ „์†ก ๊ฒฐ๊ณผ ์ฒ˜๋ฆฌ


1. ๋ง›์ง‘์„ ๊ธฐ๋กํ•˜๊ณ  ๊ด€๋ฆฌํ•˜๋Š” ๊ธฐ๋Šฅ

์ด ๊ธฐ๋Šฅ์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋ง›์ง‘ ๋ฆฌ๋ทฐ๋ฅผ ์ฒด๊ณ„์ ์œผ๋กœ ๊ธฐ๋กํ•˜๊ณ , ๋ฐ์ดํ„ฐ๋ฅผ ์ง๊ด€์ ์œผ๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. RealmSwift ๊ธฐ๋ฐ˜์˜ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์™€ ์ง๊ด€์ ์ธ ์ •๋ ฌ/๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์€ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ทน๋Œ€ํ™”ํ•˜๋ฉฐ, ์•จ๋ฒ” ๊ด€๋ฆฌ์™€ ์ด๋ฏธ์ง€ ํŒŒ์ผ ์ฒ˜๋ฆฌ ๋“ฑ ์ƒ์„ธํ•œ ๊ธฐ๋Šฅ์€ ์•ฑ์˜ ์œ ์šฉ์„ฑ์„ ํ•œ์ธต ๋” ๋†’์˜€์Šต๋‹ˆ๋‹ค.

1-1. Realm ๋ชจ๋ธ์— ๋ฆฌ๋ทฐ๋กœ ์ €์žฅํ•  ๋‚ด์šฉ ์ •์˜

  • ๋ง›์ง‘ ๋ฆฌ๋ทฐ๋ฅผ ์ €์žฅํ•  ReviewTable๊ณผ ์•จ๋ฒ” ๊ด€๋ฆฌ๋ฅผ ์œ„ํ•œ AlbumTable ํด๋ž˜์Šค ์ •์˜
  • ๋ฆฌ๋ทฐ ๋ฐ์ดํ„ฐ์—๋Š” ๋ณ„์ , ๋ฐฉ๋ฌธ ํšŸ์ˆ˜, ๋ฉ”๋ชจ, ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ, ์œ„์น˜ ์ •๋ณด(์œ„๋„/๊ฒฝ๋„) ๋“ฑ์ด ํฌํ•จ๋จ
  • ์•จ๋ฒ”๊ณผ ๋ฆฌ๋ทฐ ๊ฐ„์˜ To-Many ๊ด€๊ณ„๋ฅผ ์„ค์ •ํ•˜์—ฌ ์•จ๋ฒ”๋ณ„ ๋ฆฌ๋ทฐ๋ฅผ ๊ด€๋ฆฌ
class ReviewTable: Object {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var storeName: String
    @Persisted var starCount: Double
    @Persisted var reviewDate: Date
    @Persisted var memo: String
    @Persisted var imageView1URL: String?  // ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ
    @Persisted var visitCount: Int?
    @Persisted var album: LinkingObjects<AlbumTable> = LinkingObjects(fromType: AlbumTable.self, property: "reviews")
}

class AlbumTable: Object {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var albumName: String
    @Persisted var reviews: List<ReviewTable>  // To-Many ๊ด€๊ณ„
}

1-2. Realm Repository๋ฅผ ํ™œ์šฉํ•œ CRUD ๊ตฌํ˜„

ReviewTableRepository๋Š” ๋ง›์ง‘ ๋ฆฌ๋ทฐ ๋ฐ์ดํ„ฐ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. CRUD ์ž‘์—… ์™ธ์—๋„ ์ด๋ฏธ์ง€ ํŒŒ์ผ ์ €์žฅ ๋ฐ ์‚ญ์ œ, ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™”์™€ ๊ฐ™์€ ์œ ํ‹ธ๋ฆฌํ‹ฐ ๊ธฐ๋Šฅ์„ ํฌํ•จํ•˜์—ฌ ์‚ฌ์šฉ์ž์˜ ํŽธ๋ฆฌํ•œ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ๋ฅผ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์„ธ๋ถ€ ๊ตฌํ˜„์€ ์•ฑ์ด ๋ฐ์ดํ„ฐ๋ฅผ ์‹ ๋ขฐ์„ฑ ์žˆ๊ฒŒ ์ฒ˜๋ฆฌํ•˜๊ณ , ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํ–ฅ์ƒ์‹œํ‚ค๋Š” ๋ฐ ๊ธฐ์—ฌํ•ฉ๋‹ˆ๋‹ค.

  1. ์ฝ๊ธฐ(Read)
  • ๋ชจ๋“  ๋ฆฌ๋ทฐ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ฑฐ๋‚˜ ํŠน์ • ์กฐ๊ฑด์— ๋งž๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ํ•„ํ„ฐ๋ง
  • ๋ฐ์ดํ„ฐ๋ฅผ ์ •๋ ฌ(๋ณ„์ ์ˆœ, ๋ฆฌ๋ทฐ ๋‚ ์งœ์ˆœ, ๋ฐฉ๋ฌธ ํšŸ์ˆ˜์ˆœ)ํ•˜์—ฌ ์ œ๊ณต
  1. ์ƒ์„ฑ ๋ฐ ์ €์žฅ (Create)
  • ์ƒˆ ๋ฆฌ๋ทฐ๋ฅผ ์ €์žฅํ•˜๊ณ  ์•จ๋ฒ”๊ณผ ์—ฐ๊ณ„
  1. ์ˆ˜์ •(Update)
  • ๊ธฐ์กด ๋ฆฌ๋ทฐ ๋ฐ์ดํ„ฐ๋ฅผ ์—…๋ฐ์ดํŠธ
  1. ์‚ญ์ œ(Delete)
  • ๋ฆฌ๋ทฐ ์‚ญ์ œ ์‹œ ๊ด€๋ จ ์ด๋ฏธ์ง€ ํŒŒ์ผ๋„ ํ•จ๊ป˜ ์ œ๊ฑฐ
  1. ์œ ํ‹ธ๋ฆฌํ‹ฐ ๊ธฐ๋Šฅ
  • ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™”, ์ด๋ฏธ์ง€ ์ €์žฅ/๊ด€๋ฆฌ, ํŒŒ์ผ ๊ฒฝ๋กœ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ
class ReviewTableRepository: ReviewTableRepositoryType {
   private let realm = try! Realm()
   
   // **์ฝ๊ธฐ (Read)**
   func fetch() -> Results<ReviewTable> {
       return realm.objects(ReviewTable.self).sorted(byKeyPath: "reviewDate", ascending: false)
   }

   // **์ƒ์„ฑ ๋ฐ ์ €์žฅ (Create)**
   func saveReview(_ review: ReviewTable) {
       try! realm.write {
           realm.add(review)
       }
   }

   // **์ˆ˜์ • (Update)**
   func updateReview(_ existingReview: ReviewTable, with updatedReview: ReviewTable) {
       try! realm.write {
           existingReview.starCount = updatedReview.starCount
           existingReview.memo = updatedReview.memo
           existingReview.reviewDate = updatedReview.reviewDate
           existingReview.imageView1URL = updatedReview.imageView1URL
           existingReview.visitCount = updatedReview.visitCount
       }
   }

   // **์‚ญ์ œ (Delete)**
   func deleteReview(_ review: ReviewTable) {
       try! realm.write {
           // ๋ฆฌ๋ทฐ ์‚ญ์ œ ์ „์— ๊ด€๋ จ ์ด๋ฏธ์ง€ ์ œ๊ฑฐ
           if let imageURL = review.imageView1URL {
               removeImageFromDocument(imageURL: imageURL)
           }
           realm.delete(review)
       }
   }

   // **์ด๋ฏธ์ง€ ํŒŒ์ผ ์ €์žฅ**
   func saveImageToDocument(fileName: String, image: UIImage) -> String? {
       guard let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
       let fileURL = documentDirectory.appendingPathComponent(fileName)
       guard let data = image.jpegData(compressionQuality: 0.5) else { return nil }
       do {
           try data.write(to: fileURL)
           return fileURL.absoluteString
       } catch {
           print("Image save error: \(error)")
           return nil
       }
   }

   // **์ด๋ฏธ์ง€ ํŒŒ์ผ ์‚ญ์ œ**
   func removeImageFromDocument(imageURL: String) {
       if let filePath = URL(string: imageURL)?.path {
           try? FileManager.default.removeItem(atPath: filePath)
       }
   }

   // **๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™”**
   func clearAllData() {
       try! realm.write {
           realm.deleteAll()
       }
   }
}

1-3. ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ„์ ์ˆœ, ์‹œ๊ฐ„์ˆœ, ๋ฐฉ๋ฌธ์ˆœ ์ •๋ ฌํ•˜๋Š” ๊ธฐ๋Šฅ

  • byKeyPath: starCount, reviewDate, visitCount ์ž…๋ ฅ
func fetchSortedReviews(by key: String, ascending: Bool) -> Results<ReviewTable> {
    return realm.objects(ReviewTable.self).sorted(byKeyPath: key, ascending: ascending)
}

2. ๋‚˜๋งŒ์˜ ๋ง›์ง‘ ์•จ๋ฒ”์„ ๋งŒ๋“ค์–ด ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ๋ถ„๋ฅ˜ํ•˜๋Š” ๊ธฐ๋Šฅ

์ด ๊ธฐ๋Šฅ์€ ์‚ฌ์šฉ์ž๊ฐ€ ๊ฐœ์ธ์ ์œผ๋กœ ์†Œ์ค‘ํ•œ ๋ง›์ง‘ ๋ฆฌ๋ทฐ๋ฅผ ์ •๋ฆฌํ•˜๋Š” ๋ฐ ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ๋ฅผ ์ œ๊ณตํ•˜๋ฉฐ, ์ด๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ๋งŒ์กฑ๋„๋ฅผ ๊ทน๋Œ€ํ™”ํ•˜๊ณ  ์นดํ…Œ๊ณ ๋ฆฌ ๊ธฐ๋ฐ˜ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ๋ฅผ ๋”์šฑ ์ง๊ด€์ ์œผ๋กœ ๋งŒ๋“ฆ์œผ๋กœ์จ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐ์ดํ„ฐ๋ฅผ ์‰ฝ๊ฒŒ ํƒ์ƒ‰ํ•˜๊ณ  ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก ๋•์Šต๋‹ˆ๋‹ค.

2-1. To-Many Relationship์„ ํ™œ์šฉํ•œ ์•จ๋ฒ” ์ƒ์„ฑ ๊ธฐ๋Šฅ

  1. ์ƒˆ๋กœ์šด AlbumTable ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑ
  2. Realm ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ
  3. ์ƒˆ๋กœ์šด ์•จ๋ฒ”์ด ์ƒ์„ฑ๋˜๋ฉด ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด์™€ ์—ฐ๋™๋˜์–ด UI์— ์‹ค์‹œ๊ฐ„ ๋ฐ˜์˜
class AlbumTable: Object {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var albumName: String
    @Persisted var reviews: List<ReviewTable> // To-Many Relationship
    
    convenience init(albumName: String) {
        self.init()
        self.albumName = albumName
    }


@objc func addAlbumButtonTapped() {
    let alertController = UIAlertController(title: "์ƒˆ๋กœ์šด ์•จ๋ฒ”", message: "์•จ๋ฒ” ์ด๋ฆ„์„ ์ž…๋ ฅํ•˜์„ธ์š”.", preferredStyle: .alert)
    alertController.addTextField { textField in
        textField.placeholder = "์•จ๋ฒ” ์ด๋ฆ„"
    }
    let addAction = UIAlertAction(title: "์ถ”๊ฐ€", style: .default) { _ in
        guard let albumName = alertController.textFields?.first?.text, !albumName.isEmpty else { return }
        let newAlbum = AlbumTable(albumName: albumName)
        try! self.realm.write {
            self.realm.add(newAlbum)
        }
        self.sideMenuTableViewController.tableView.reloadData()
    }
    let cancelAction = UIAlertAction(title: "์ทจ์†Œ", style: .cancel)
    alertController.addAction(addAction)
    alertController.addAction(cancelAction)
    present(alertController, animated: true)
}

2-2. ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด๋ฐ” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์นดํ…Œ๊ณ ๋ฆฌ ํƒ์ƒ‰ ์ง€์›

  • SideMenu ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ํ–ฅ์ƒ
  • ์‚ฌ์ด๋“œ ๋ฉ”๋‰ด์— ํ‘œ์‹œ๋˜๋Š” ์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก(์•จ๋ฒ” ์ด๋ฆ„)
  • "+ ์•จ๋ฒ” ์ถ”๊ฐ€" ๋ฒ„ํŠผ์„ ํ†ตํ•ด ์ƒˆ๋กœ์šด ์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ ๊ฐ€๋Šฅ
  • UITableView๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์•จ๋ฒ” ๋ชฉ๋ก ํ‘œ์‹œ
func setupSideMenu() {
    sideMenuTableViewController = UITableViewController()
    sideMenuTableViewController.tableView.delegate = self
    sideMenuTableViewController.tableView.dataSource = self
    sideMenu = SideMenuNavigationController(rootViewController: sideMenuTableViewController)
    sideMenu?.leftSide = true
    SideMenuManager.default.leftMenuNavigationController = sideMenu
}

  • ์„ ํƒํ•œ ์•จ๋ฒ”์— ์†ํ•œ ๋ฆฌ๋ทฐ๋งŒ ํ•„ํ„ฐ๋งํ•˜์—ฌ ํ‘œ์‹œ
  • "๋ชจ๋‘ ๋ณด๊ธฐ"๋ฅผ ์„ ํƒํ•˜๋ฉด ๋ชจ๋“  ๋ฆฌ๋ทฐ๋ฅผ ๋ณด์—ฌ์คŒ
  • UserDefaults์™€ Realm ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ํ™œ์šฉํ•ด ์„ ํƒํ•œ ์•จ๋ฒ” ์ƒํƒœ๋ฅผ ์œ ์ง€
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let selectedAlbum = albumNames[indexPath.row]
    if selectedAlbum == "๋ชจ๋‘ ๋ณด๊ธฐ" {
        reviewItems = repository.fetch()
    } else if let matchingAlbum = realm.objects(AlbumTable.self).filter("albumName == %@", selectedAlbum).first {
        reviewItems = repository.fetch().filter("ANY album._id == %@", matchingAlbum._id)
    }
    mainView.collectionView.reloadData()
    sideMenu?.dismiss(animated: true)
}

3. ์Œ์‹์  ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ

์นด์นด์˜ค ๋กœ์ปฌ API๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์Œ์‹์ ์„ ์†์‰ฝ๊ฒŒ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰๋œ ์Œ์‹์ ์˜ ์ด๋ฆ„, ์ฃผ์†Œ, ์ „ํ™”๋ฒˆํ˜ธ, ์นดํ…Œ๊ณ ๋ฆฌ ์ •๋ณด, ์œ„์น˜ ์ขŒํ‘œ ๋“ฑ์„ ๋ฐ›์•„์™€ ์ง๊ด€์ ์œผ๋กœ ์ •๋ณด๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž๋Š” ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ ๋น ๋ฅด๊ฒŒ ํ™•์ธํ•˜๊ณ , ์•ฑ ๋‚ด์—์„œ ํšจ์œจ์ ์œผ๋กœ ์Œ์‹์ ์„ ํƒ์ƒ‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Search.MP4

  1. ์นด์นด์˜ค ๋กœ์ปฌ API ์—ฐ๋™
  • Alamofire๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์นด์นด์˜ค ๋กœ์ปฌ API์™€ ํ†ต์‹ 
  • ๊ฒ€์ƒ‰์–ด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ API์š”์ฒญ์„ ๋ณด๋‚ด๊ณ , ์Œ์‹์  ์ •๋ณด๋ฅผ JSON ํ˜•ํƒœ๋กœ ๋ฐ›์•„์™€ ๋””์ฝ”๋”ฉ

  1. ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์ฒ˜๋ฆฌ
  • ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋Š” Document ๋ชจ๋ธ๋กœ ๋””์ฝ”๋”ฉํ•˜์—ฌ, ์Œ์‹์  ์ด๋ฆ„, ์ฃผ์†Œ, ์ „ํ™”๋ฒˆํ˜ธ ๋“ฑ ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ ๊ตฌ์กฐํ™”

  1. API ์š”์ฒญ ์ตœ์ ํ™”
  • ๊ณตํ†ต ํ—ค๋” ๋ฐ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๊ตฌ์„ฑํ•˜์—ฌ ๊ฐ„๊ฒฐํ•œ ์ฝ”๋“œ ์œ ์ง€
  • ์ƒํƒœ ์ฝ”๋“œ ๊ฒ€์ฆ ๋ฐ ์—๋Ÿฌ ํ•ธ๋“ค๋ง ์ถ”๊ฐ€๋กœ ์•ˆ์ •์ ์ธ ํ†ต์‹  ๊ตฌํ˜„

  1. ํ™•์žฅ ๊ฐ€๋Šฅ์„ฑ ๊ณ ๋ ค
  • API ์‘๋‹ต ๋ฐ์ดํ„ฐ๋Š” responseDecodable์„ ํ™œ์šฉํ•˜์—ฌ Food์™€ Document ๊ตฌ์กฐ์ฒด๋กœ ๋งคํ•‘
  • ๋ฐ์ดํ„ฐ ๋ชจ๋ธ ์„ค๊ณ„ ์‹œ ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ์ž‘์„ฑํ•˜์—ฌ, ์ƒˆ๋กœ์šด ์š”๊ตฌ์‚ฌํ•ญ์— ์‰ฝ๊ฒŒ ๋Œ€์‘ ๊ฐ€๋Šฅ

func searchPlaceCallRequest(query: String, page: Int = 1, size: Int = 45, completion: @escaping ([Document]?) -> Void) {
    let baseURL = "https://dapi.kakao.com/v2/local/search/keyword.json"
    let header: HTTPHeaders = [
        "Authorization": "KakaoAK \(APIKey.key)",
        "Content-Type": "application/json; charset=UTF-8"
    ]
    let parameters: [String: Any] = ["query": query]
    
    AF.request(baseURL, method: .get, parameters: parameters, headers: header)
        .validate(statusCode: 200...500)
        .responseDecodable(of: Food.self) { response in
            switch response.result {
            case .success(let value):
                completion(value.documents) // ์„ฑ๊ณต ์‹œ ์Œ์‹์  ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜
            case .failure(let error):
                print("Error: \(error)") // ์‹คํŒจ ์‹œ ์—๋Ÿฌ ๋กœ๊ทธ ์ถœ๋ ฅ
                completion(nil)
            }
        }
}

  • ์‚ฌ์šฉ๋œ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ
struct Food: Decodable {
    let documents: [Document]
}

struct Document: Decodable {
    let addressName: String?
    let categoryName: String?
    let phone: String?
    let placeName: String?
    let placeURL: String?
    let roadAddressName: String?
    let x: String?  // ๊ฒฝ๋„
    let y: String?  // ์œ„๋„

    var finalCategory: String? {
        return categoryName?.split(separator: ">").last?.trimmingCharacters(in: .whitespaces)
    }
}

4. WebView๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์Œ์‹์  ์‚ฌ์ดํŠธ๋กœ ๋ฐ”๋กœ ์ด๋™ํ•˜๋Š” ๊ธฐ๋Šฅ

WebView ๊ธฐ๋Šฅ์€ WebKit์„ ํ™œ์šฉํ•˜์—ฌ ๋ง›์ง‘ ๋ฆฌ๋ทฐ์™€ ๊ด€๋ จ๋œ ์™ธ๋ถ€ ์ •๋ณด๋ฅผ ์‰ฝ๊ฒŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž๋Š” ๋ณ„๋„์˜ ๋ธŒ๋ผ์šฐ์ € ์—†์ด๋„ ์•ฑ ๋‚ด์—์„œ ๋งํฌ๋œ ์‚ฌ์ดํŠธ๋ฅผ ํƒ์ƒ‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

WebView.MP4

  • WKWebView๋Š” WebKit ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์™ธ๋ถ€ ์›น ํŽ˜์ด์ง€๋ฅผ ์•ฑ ๋‚ด๋ถ€์—์„œ ๋กœ๋“œํ•˜๋Š” ๋ฐ ์‚ฌ์šฉ
  • ๋ฆฌ๋ทฐ ๋ฐ์ดํ„ฐ์—์„œ ์Œ์‹์ ์˜ URL์„ placeURL ๋ณ€์ˆ˜์— ์ €์žฅ
  • internetButton ํด๋ฆญ ์‹œ openWebView ๋ฉ”์„œ๋“œ ์‹คํ–‰
  • URL ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ํ›„ WebViewController๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ํ™”๋ฉด ์ „ํ™˜
class WebViewController: UIViewController, WKUIDelegate {
    var webView: WKWebView!
    var urlToLoad: URL
    
    init(url: URL) {
        self.urlToLoad = url
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // WebView ์„ค์ •
        let webConfiguration = WKWebViewConfiguration()
        webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.uiDelegate = self
        view.addSubview(webView)
        
        // URL ์š”์ฒญ ๋ฐ ๋กœ๋“œ
        let request = URLRequest(url: urlToLoad)
        webView.load(request)
    }
}


@objc func openWebView() {        
        // URL ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
        guard var urlString = placeURL else { return }
        if urlString.starts(with: "http://") {
            urlString = urlString.replacingOccurrences(of: "http://", with: "https://")
        }
        guard let url = URL(string: urlString) else { return }
        
        // WebViewController๋กœ ์ „ํ™˜
        let webVC = WebViewController(url: url)
        present(webVC, animated: true, completion: nil)
    }

5. ์ง€๋„ ์œ„์— ๋‚˜๋งŒ์˜ ๋ง›์ง‘์„ ๋ณผ ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ

์‚ฌ์šฉ์ž๊ฐ€ ์ €์žฅํ•œ ๋ง›์ง‘ ์ •๋ณด๋ฅผ ์ง€๋„์— ํ‘œ์‹œํ•˜์—ฌ ์‹œ๊ฐ์ ์œผ๋กœ ํ‘œํ˜„ํ•˜๊ณ , ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ์„ ํ†ตํ•ด ๋ง›์ง‘์„ ์‰ฝ๊ฒŒ ํƒ์ƒ‰ํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์„ ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค.


5-1. MapKit์˜ Annotation์„ ํ™œ์šฉํ•˜์—ฌ ์ง€๋„์— ํ•€์„ ์‚ฌ์ง„์œผ๋กœ ํ‘œํ˜„

  • MapKit ํ™œ์šฉ: MKMapView์™€ MKAnnotation์„ ์‚ฌ์šฉํ•˜์—ฌ ๋ง›์ง‘ ์ •๋ณด๋ฅผ ์ง€๋„์— ํ‘œ์‹œ
  • Custom Annotation View: ๋ง›์ง‘์˜ ๋Œ€ํ‘œ ์ด๋ฏธ์ง€๋ฅผ ๋ณด์—ฌ์ฃผ๊ธฐ ์œ„ํ•ด MKAnnotationView๋ฅผ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•˜์—ฌ ImageAnnotationView๋ฅผ ๊ตฌํ˜„

func loadAnnotations() {
    let reviews = reviewRepository.fetch()
    var newAnnotations: [MKPointAnnotation] = []
    
    for review in reviews {
        if let latitude = Double(review.latitude ?? ""), let longitude = Double(review.longitude ?? "") {
            let annotation = MKPointAnnotation()
            annotation.title = review.storeName
            annotation.subtitle = review.imageView1URL
            annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
            newAnnotations.append(annotation)
        }
    }
    
    mapView.addAnnotations(newAnnotations)
}

5-2. ์ง€๋„ ์œ„์˜ ํ•€์„ ํด๋Ÿฌ์Šคํ„ฐ๋ง์œผ๋กœ ๊ตฌํ˜„

  • ํด๋Ÿฌ์Šคํ„ฐ๋ง ์ง€์›: ์—ฌ๋Ÿฌ ํ•€์ด ๋ชจ์—ฌ ์žˆ์„ ๊ฒฝ์šฐ, ImageClusterView๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํด๋Ÿฌ์Šคํ„ฐ๋ง๋œ ํ•€๊ณผ ๋ง›์ง‘ ๊ฐœ์ˆ˜๋ฅผ ์‹œ๊ฐ์ ์œผ๋กœ ํ‘œํ˜„
class ImageClusterView: MKAnnotationView {
    private var imageView: UIImageView!
    private var countLabel: UILabel!
    
    override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
        super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
        self.frame = CGRect(x: 0, y: 0, width: 58, height: 58)
        self.backgroundColor = .clear
        
        imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.layer.cornerRadius = 10
        imageView.clipsToBounds = true
        self.addSubview(imageView)
        
        countLabel = UILabel()
        countLabel.textAlignment = .center
        countLabel.textColor = .white
        countLabel.backgroundColor = UIColor(named: "countGold")
        countLabel.layer.cornerRadius = 10
        countLabel.clipsToBounds = true
        self.addSubview(countLabel)
        
        countLabel.snp.makeConstraints { make in
            make.top.equalToSuperview().inset(2)
            make.right.equalToSuperview().inset(2)
            make.width.height.equalTo(20)
        }
    }
    
    override var annotation: MKAnnotation? {
        willSet {
            if let cluster = newValue as? MKClusterAnnotation {
                countLabel.text = "\(cluster.memberAnnotations.count)"
                
                if let firstAnnotation = cluster.memberAnnotations.first as? MKPointAnnotation,
                   let imageUrlString = firstAnnotation.subtitle,
                   let imageUrl = URL(string: imageUrlString) {
                    imageView.kf.setImage(with: imageUrl) // ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ ๋กœ๋“œ
                }
            }
        }
    }
}

5-3. SearchBar๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ €์žฅํ•œ ๋ง›์ง‘์„ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ

UISearchBar๋ฅผ ์ด์šฉํ•ด ์ง€๋„ ์œ„์— ํ‘œ์‹œ๋˜์–ด ์žˆ๋Š” ๋ง›์ง‘์„ ์Œ์‹์ ์˜ ์ด๋ฆ„๊ณผ ๋ฉ”๋ชจ์žฅ์— ์ ํžŒ ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ๋น ๋ฅด๊ฒŒ ์ €์žฅ๋œ ๋ง›์ง‘์„ ์ฐพ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
    guard !searchText.isEmpty else { return }
    
    let storeNamePredicate = NSPredicate(format: "storeName CONTAINS[c] %@", searchText)
    let memoPredicate = NSPredicate(format: "memo CONTAINS[c] %@", searchText)
    let combinedPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [storeNamePredicate, memoPredicate])
    
    let results = reviewRepository.fetch().filter(combinedPredicate)
    guard let firstResult = results.first,
          let latitude = Double(firstResult.latitude ?? ""),
          let longitude = Double(firstResult.longitude ?? "") else { return }
    
    let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
    mapView.setRegion(MKCoordinateRegion(center: coordinate, latitudinalMeters: 500, longitudinalMeters: 500), animated: true)
}

6. ๋ฐฑ์—… ํŒŒ์ผ ์ƒ์„ฑ ๋ฐ ๊ณต์œ /๋ณต๊ตฌ ๊ธฐ๋Šฅ

๋ง›์Š๋žญ์€ ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ๋ณด๊ด€ํ•˜๊ณ , ๋‹ค๋ฅธ ๋””๋ฐ”์ด์Šค๋กœ ์‰ฝ๊ฒŒ ๋ณต๊ตฌํ•  ์ˆ˜ ์žˆ๋„๋ก ๋ฐฑ์—… ๋ฐ ๋ณต๊ตฌ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
๋ฐฑ์—…์€ ์•ฑ ๋‚ด๋ถ€ ๋ฐ์ดํ„ฐ(default.realm)๋ฅผ ์••์ถ•ํ•˜์—ฌ ZIP ํŒŒ์ผ๋กœ ์ €์žฅํ•˜๋ฉฐ, ๊ณต์œ ์™€ ๋ณต๊ตฌ๊ฐ€ ์šฉ์ดํ•ฉ๋‹ˆ๋‹ค.

default.MP4

6-1. ๋ฐฑ์—… ํŒŒ์ผ ์ƒ์„ฑ ๋ฐ ZIP ์••์ถ•

  • ์•ฑ ๋‚ด๋ถ€ ๋ฐ์ดํ„ฐ(default.realm)๋ฅผ ZIP ํŒŒ์ผ๋กœ ์••์ถ•
  • ํ˜„์žฌ ๋‚ ์งœ์™€ ์‹œ๊ฐ„์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํŒŒ์ผ๋ช… ์ƒ์„ฑ
@objc func backupButtonTapped() {
    Analytics.logEvent("backup_initiated", parameters: nil)
    var urlPaths = [URL]()
    
    guard let path = documentDirectoryPath() else {
        print("๋„ํ๋จผํŠธ ์œ„์น˜์— ์˜ค๋ฅ˜๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.")
        return
    }
    
    let realmFile = path.appendingPathComponent("default.realm")
    
    guard FileManager.default.fileExists(atPath: realmFile.path) else {
        print("๋ฐฑ์—…ํ•  ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.")
        return
    }
    urlPaths.append(realmFile)
    
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyy.MM.dd_HH:mm:ss"
    let currentDateTime = dateFormatter.string(from: Date())
    let fileName = "matchelin_\(currentDateTime)"
    
    do {
        let zipFilePath = try Zip.quickZipFiles(urlPaths, fileName: fileName)
        print("Backup location: \(zipFilePath)")
        backUpView.backupTableView.reloadData()
    } catch {
        print("๋ฐฑ์—… ์‹คํŒจ: \(error)")
    }
}

6-2. ๋ฐฑ์—… ํŒŒ์ผ ๋ณต๊ตฌ ๋ฐ ZIP ํ•ด์ œ

  • ์‚ฌ์šฉ์ž๋กœ๋ถ€ํ„ฐ ZIP ํŒŒ์ผ์„ ์„ ํƒ๋ฐ›์•„ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณต๊ตฌ
  • ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฅผ ๋ฎ์–ด์“ฐ๊ธฐ ์ „ ์‚ฌ์šฉ์ž์—๊ฒŒ ๊ฒฝ๊ณ  ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œ
@objc func restoreButtonTapped() {
    Analytics.logEvent("restore_initiated", parameters: nil)
    let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.archive], asCopy: true)
    documentPicker.delegate = self
    present(documentPicker, animated: true)
}

func unzipAndRestore(fileURL: URL) {
    guard let destinationPath = documentDirectoryPath() else {
        print("๋ณต๊ตฌ ๊ฒฝ๋กœ ์˜ค๋ฅ˜")
        return
    }
    do {
        try Zip.unzipFile(fileURL, destination: destinationPath, overwrite: true, password: nil)
        print("๋ณต๊ตฌ ์™„๋ฃŒ!")
    } catch {
        print("๋ณต๊ตฌ ์‹คํŒจ: \(error)")
    }
}

6-3. ๋ฐฑ์—… ํŒŒ์ผ ๊ณต์œ 

  • ZIP ํŒŒ์ผ์„ ๋‹ค๋ฅธ ์•ฑ์ด๋‚˜ ํด๋ผ์šฐ๋“œ ์„œ๋น„์Šค๋กœ ๊ณต์œ 
func showActivityViewController(fileName: String) {
    guard let path = documentDirectoryPath() else {
        print("๋„ํ๋จผํŠธ ์œ„์น˜์— ์˜ค๋ฅ˜๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.")
        return
    }
    let backupFileURL = path.appendingPathComponent(fileName)
    
    let activityVC = UIActivityViewController(activityItems: [backupFileURL], applicationActivities: nil)
    present(activityVC, animated: true)
}

6-4. ๋ฐฑ์—… ํŒŒ์ผ ์‚ญ์ œ

  • ํ…Œ์ด๋ธ” ๋ทฐ์—์„œ ์Šฌ๋ผ์ด๋“œ ๋™์ž‘์œผ๋กœ ๋ฐฑ์—… ํŒŒ์ผ ์‚ญ์ œ
  • ์‚ญ์ œ ์ „ ์‚ฌ์šฉ์ž ํ™•์ธ ๋Œ€ํ™” ์ƒ์ž ํ‘œ์‹œ
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
    if editingStyle == .delete {
        let fileName = fetchZipList()[indexPath.row].name
        Analytics.logEvent("backup_file_deleted", parameters: ["fileName": fileName])
        guard let path = documentDirectoryPath() else { return }
        let fileURL = path.appendingPathComponent(fileName)
        
        do {
            try FileManager.default.removeItem(at: fileURL)
            tableView.deleteRows(at: [indexPath], with: .fade)
        } catch {
            print("ํŒŒ์ผ ์‚ญ์ œ ์‹คํŒจ: \(error)")
        }
    }
}

7. ์•ฑ์—์„œ ์ง์ ‘ ์ด๋ฉ”์ผ์„ ํ†ตํ•ด ๋ฌธ์˜๋‚˜ ์˜๊ฒฌ์„ ์ˆ˜์ง‘ํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ

๋ง›์Š๋žญ์€ ์‚ฌ์šฉ์ž์˜ ํ”ผ๋“œ๋ฐฑ์„ ์ˆ˜์ง‘ํ•˜๊ธฐ ์œ„ํ•ด ์ด๋ฉ”์ผ์„ ํ†ตํ•œ ๊ฐ„ํŽธํ•œ ๋ฌธ์˜/์˜๊ฒฌ ์ˆ˜์ง‘ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ด ๊ธฐ๋Šฅ์€ MessageUI ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์•ฑ ๋‚ด๋ถ€์—์„œ ์ด๋ฉ”์ผ์„ ์ž‘์„ฑํ•˜๊ณ  ๋ณด๋‚ผ ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

Mail.MP4

7-1. ์ด๋ฉ”์ผ ์ž‘์„ฑ ๋ฐ ์ „์†ก

  • ์ด๋ฉ”์ผ ์ž‘์„ฑ ๋ฐ ์ „์†ก์€ MFMailComposeViewController๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌํ˜„
  • ๋””๋ฐ”์ด์Šค ๋ชจ๋ธ, ๋””๋ฐ”์ด์Šค OS ๋ฒ„์ „, ์•ฑ ๋ฒ„์ „์„ ๊ธฐ๊ธฐ์—์„œ ์ž…๋ ฅ์—†์ด ์ž๋™์œผ๋กœ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.
func sendEmail() {
   let bodyString = """
                    ๋ฌธ์˜ ์‚ฌํ•ญ ๋ฐ ์˜๊ฒฌ์„ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”.
                    
                    
                    
                    -------------------
                    Device Model : \(Utils.getDeviceModelName())
                    Device OS : \(UIDevice.current.systemVersion)
                    App Version : \(Utils.getAppVersion())
                    -------------------
                    """
   
   if MFMailComposeViewController.canSendMail() {
       let mail = MFMailComposeViewController()
       mail.mailComposeDelegate = self
       mail.setToRecipients(["[email protected]"]) // ๋ฌธ์˜ ์ด๋ฉ”์ผ ์ฃผ์†Œ
       mail.setSubject("๋ง›์Š๋žญ / ๋ฌธ์˜,์˜๊ฒฌ")
       mail.setMessageBody(bodyString, isHTML: false)
       present(mail, animated: true)
   } else {
       // ์ด๋ฉ”์ผ ์„ค์ •์ด ๋˜์–ด ์žˆ์ง€ ์•Š์€ ๊ฒฝ์šฐ
       let alert = UIAlertController(title: "์ด๋ฉ”์ผ ์ „์†ก ๋ถˆ๊ฐ€", message: "์ด๋ฉ”์ผ ๊ณ„์ • ์„ค์ • ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.", preferredStyle: .alert)
       alert.addAction(UIAlertAction(title: "ํ™•์ธ", style: .default))
       present(alert, animated: true)
   }
}

7-2. ์ด๋ฉ”์ผ ์ „์†ก ๊ฒฐ๊ณผ ์ฒ˜๋ฆฌ

func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
    isEmailBeingSent = false
    switch result {
    case .sent:
        print("์ด๋ฉ”์ผ ์ „์†ก ์™„๋ฃŒ")
    case .cancelled:
        print("์ด๋ฉ”์ผ ์ „์†ก ์ทจ์†Œ")
    case .failed:
        print("์ด๋ฉ”์ผ ์ „์†ก ์‹คํŒจ")
    case .saved:
        print("์ด๋ฉ”์ผ ์ž„์‹œ ์ €์žฅ")
    @unknown default:
        break
    }
    controller.dismiss(animated: true)
}


๋ง›์Š๋žญ 2.0 ํ•ต์‹ฌ ๊ธฐ๋Šฅ๊ณผ ์ฝ”๋“œ ์„ค๋ช… โญ๏ธ

๋ง›์Š๋žญ 2.0์€ ํšŒ์› ๊ด€๋ฆฌ, ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ, ๋Œ“๊ธ€, ํ”„๋กœํ•„ ๊ด€๋ฆฌ์™€ ๊ฐ™์€ ๋‹ค์–‘ํ•œ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋ฉฐ, ์ด๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ทน๋Œ€ํ™”ํ•ฉ๋‹ˆ๋‹ค. ์ด ๊ณผ์ •์—์„œ MVVM ๋””์ž์ธ ํŒจํ„ด, Alamofire, RxSwift, ๊ทธ๋ฆฌ๊ณ  ํ”„๋กœํ† ์ฝœ ์ง€ํ–ฅ ์„ค๊ณ„๋ฅผ ํ™œ์šฉํ•˜์—ฌ ํšจ์œจ์ ์ด๊ณ  ์œ ์ง€๋ณด์ˆ˜ ๊ฐ€๋Šฅํ•œ ๋„คํŠธ์›Œํฌ ๊ณ„์ธต์„ ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. ์•„๋ž˜๋Š” ์ด ํ”„๋กœ์ ํŠธ์˜ ํ•ต์‹ฌ ์„ค๊ณ„์™€ ๊ตฌํ˜„ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.

  • 1. MVVM ๋””์ž์ธ ํŒจํ„ด

  • 2. RxSwift์™€ Alamofire๋ฅผ ํ™œ์šฉํ•œ ๋น„๋™๊ธฐ ๋„คํŠธ์›Œํฌ ์ฒ˜๋ฆฌ

  • 3. ๊ณตํ†ต ๋กœ์ง๊ณผ ํ”„๋กœํ† ์ฝœ์„ ํ™œ์šฉํ•œ ๋„คํŠธ์›Œํฌ ๊ณ„์ธต ์„ค๊ณ„

  • 4. ์ƒํƒœ ์ฝ”๋“œ์™€ ์—๋Ÿฌ ์ฒ˜๋ฆฌ


<์„ฑ๊ณผ>

  • ์ฝ”๋“œ ์žฌ์‚ฌ์šฉ์„ฑ ๋ฐ ํ™•์žฅ์„ฑ ํ–ฅ์ƒ: ๊ณตํ†ต ๋กœ์ง๊ณผ ํ”„๋กœํ† ์ฝœ ์„ค๊ณ„๋ฅผ ํ†ตํ•ด API ์ถ”๊ฐ€๊ฐ€ ์šฉ์ด
  • ํšจ์œจ์ ์ธ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ: RxSwift๋ฅผ ์‚ฌ์šฉํ•œ MVVM ์•„ํ‚คํ…์ฒ˜๋กœ ๋ฐ์ดํ„ฐ ํ๋ฆ„ ๊ด€๋ฆฌ
  • ์œ ์ง€๋ณด์ˆ˜์„ฑ ๊ฐ•ํ™”: ํ”„๋กœํ† ์ฝœ๊ณผ ๊ณตํ†ต ๋กœ์ง ๋ถ„๋ฆฌ๋กœ ์ฝ”๋“œ ์ˆ˜์ • ๋ฐ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ ๋น„์šฉ ๊ฐ์†Œ
  • ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ: ๋„คํŠธ์›Œํฌ ๊ณ„์ธต ์ถ”์ƒํ™”๋ฅผ ํ†ตํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ
  • ๋ง›์Š๋žญ 2.0์˜ ์„ค๊ณ„๋Š” ํด๋ฆฐ ์ฝ”๋“œ์™€ ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ์ค‘์‹œํ•˜๋ฉฐ, ์‚ฌ์šฉ์ž์™€ ๊ฐœ๋ฐœ์ž๊ฐ€ ๋ชจ๋‘ ๋งŒ์กฑํ•  ์ˆ˜ ์žˆ๋Š” ์„œ๋น„์Šค๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

1. MVVM ๋””์ž์ธ ํŒจํ„ด

  • ๋ง›์Š๋žญ 2.0์€ Model-View-ViewModel(MVVM) ํŒจํ„ด์„ ์ ์šฉํ•˜์—ฌ ๋กœ์ง๊ณผ UI๋ฅผ ๋ช…ํ™•ํžˆ ๋ถ„๋ฆฌ
  • Model: ๋ฐ์ดํ„ฐ์™€ API ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ๋„คํŠธ์›Œํฌ ๊ณ„์ธต์„ ํฌํ•จ
  • ViewModel: ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋‹ด๋‹นํ•˜๋ฉฐ, RxSwift๋ฅผ ์‚ฌ์šฉํ•ด View์™€ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ”์ธ๋”ฉ
  • View: ์‚ฌ์šฉ์ž ์ธํ„ฐํŽ˜์ด์Šค(UI)๋ฅผ ๋‹ด๋‹นํ•˜๋ฉฐ, ViewModel์˜ ์ƒํƒœ ๋ณ€ํ™”๋ฅผ ๊ตฌ๋…ํ•˜์—ฌ UI๋ฅผ ์—…๋ฐ์ดํŠธ

2. RxSwift์™€ Alamofire๋ฅผ ํ™œ์šฉํ•œ ๋น„๋™๊ธฐ ๋„คํŠธ์›Œํฌ ์ฒ˜๋ฆฌ

  • Alamofire๋ฅผ ํ†ตํ•ด API ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๊ณ , RxSwift๋กœ ๊ฒฐ๊ณผ๋ฅผ Observable๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฐ์ดํ„ฐ ํ๋ฆ„์„ ๊ฐ„๊ฒฐํ•˜๊ฒŒ ๊ด€๋ฆฌ
  • ๋„คํŠธ์›Œํฌ ์š”์ฒญ์˜ ์„ฑ๊ณต/์‹คํŒจ ์ƒํƒœ๋ฅผ RxSwift์˜ PublishSubject๋กœ View์— ์ „๋‹ฌ
  • RxSwift๋กœ ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉํ•˜์—ฌ ๋น„๋™๊ธฐ ์ž‘์—…์˜ ๋ณต์žก์„ฑ์„ ๊ฐ์†Œ
class LoginViewModel {
    private var networkManager: NetworkManagerProtocol
    private let disposeBag = DisposeBag()

    // RxSwift Subjects
    let loginSuccess = PublishSubject<Void>()
    let loginFailure = PublishSubject<String>()

    init(networkManager: NetworkManagerProtocol) {
        self.networkManager = networkManager
    }

    func login(email: String, password: String) {
        Observable.create { [weak self] observer in
            self?.networkManager.login(email: email, password: password) { success, accessToken, refreshToken, message in
                if success {
                    UserDefaults.standard.set(accessToken, forKey: "accessToken")
                    UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
                    observer.onNext(())
                    observer.onCompleted()
                } else {
                    observer.onError(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: message ?? "Login failed"]))
                }
            }
            return Disposables.create()
        }
        .subscribe(
            onNext: { [weak self] _ in self?.loginSuccess.onNext(()) },
            onError: { [weak self] error in self?.loginFailure.onNext(error.localizedDescription) }
        )
        .disposed(by: disposeBag)
    }
}

  • View๋Š” ViewModel์˜ ์ƒํƒœ๋ฅผ ๊ตฌ๋…ํ•˜์—ฌ UI๋ฅผ ์—…๋ฐ์ดํŠธ
private func setupViewModelBindings() {
   viewModel.loginSuccess
       .subscribe(onNext: { [weak self] _ in self?.navigateToMain() })
       .disposed(by: disposeBag)

   viewModel.loginFailure
       .subscribe(onNext: { [weak self] message in self?.showAlert(with: message, navigateToMain: false) })
       .disposed(by: disposeBag)
}

3. ํ”„๋กœํ† ์ฝœ์„ ํ™œ์šฉํ•˜๊ณ  ๊ณตํ†ต ๋กœ์ง์„ ๋ถ„๋ฆฌํ•œ ๋„คํŠธ์›Œํฌ ๊ณ„์ธต ์„ค๊ณ„

NetworkManagerProtocol๊ณผ ๋‹ค์–‘ํ•œ API ์š”์ฒญ ๊ตฌ์กฐ์ฒด(SignUpRequest, LoginRequest)๋ฅผ ์ •์˜ํ•˜์—ฌ ์ฝ”๋“œ ์žฌ์‚ฌ์šฉ์„ฑ๊ณผ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ์„ ๋†’์˜€์Šต๋‹ˆ๋‹ค.

  • NetworkManagerProtocol ์ •์˜
  • APIRequest ํ”„๋กœํ† ์ฝœ ์ •์˜
  • ํ”„๋กœํ† ์ฝœ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ฐœ๋ณ„ API ์š”์ฒญ ๊ตฌ์กฐ์ฒด๋ฅผ ์ •์˜ํ•˜์—ฌ ํ™•์žฅ์„ฑ ๊ฐ•ํ™”
protocol NetworkManagerProtocol {
    func signUp(email: String, password: String, nick: String, phoneNum: String?, birthDay: String?, completion: @escaping (Bool, String?, Int?) -> Void)
    func validateEmail(email: String, completion: @escaping (Bool, String?, Int?) -> Void)
    func login(email: String, password: String, completion: @escaping (Bool, String?, String?, String?) -> Void)
    func refreshToken(completion: @escaping (Bool, String?) -> Void)
    func withdraw(accessToken: String, completion: @escaping (Bool, String?) -> Void)
}

protocol APIRequest {
    var method: HTTPMethod { get }
    var path: String { get }
    var parameters: Parameters? { get }
    var headers: HTTPHeaders { get }
}

-----------------------------------------------------------------------------------------
struct SignUpRequest: APIRequest {
    let email: String
    let password: String
    let nick: String

    var method: HTTPMethod { .post }
    var path: String { "/join" }
    var parameters: Parameters? { ["email": email, "password": password, "nick": nick] }
    var headers: HTTPHeaders { ["Content-Type": "application/json"] }
}

  • ๊ณตํ†ต๋กœ์ง์„ ๋ถ„๋ฆฌํ•˜์—ฌ ๋ชจ๋“  API ์š”์ฒญ๊ณผ ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋กœ์ง์„ ๋ถ„๋ฆฌํ•˜์—ฌ ์ค‘๋ณต ์ฝ”๋“œ๋ฅผ ์ตœ์†Œํ™”
// MARK: - ๊ณตํ†ต๋กœ์ง
private func performRequest(_ request: APIRequest, completion: @escaping (AFDataResponse<Any>, Error?) -> Void) {
        do {
            let urlRequest = try request.asURLRequest(baseURL: baseURL)
            AF.request(urlRequest).validate().responseJSON { response in
                completion(response, response.error)
            }
        } catch {
            completion(AFDataResponse<Any>(request: nil, response: nil, data: nil, metrics: nil, serializationDuration: 0, result: .failure(error as! AFError)), error)
        }
    }
    

4. ์ƒํƒœ ์ฝ”๋“œ์™€ ์—๋Ÿฌ ์ฒ˜๋ฆฌ

  • ์„œ๋ฒ„์—์„œ ๋ฐ˜ํ™˜๋œ ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ๊ณตํ†ต์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜์—ฌ ํด๋ผ์ด์–ธํŠธ์—์„œ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ์—๋Ÿฌ๋ฅผ ๊ด€๋ฆฌ
    // MARK: - ์ƒํƒœ์ฝ”๋“œ ๊ณตํ†ต๋กœ์ง
    private func processResponse(_ response: AFDataResponse<Any>, completion: (Bool, String?, Int?) -> Void) {
        guard let statusCode = response.response?.statusCode else {
            print("์‘๋‹ต ์ฝ”๋“œ๋ฅผ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
            completion(false, "์‘๋‹ต ์ฝ”๋“œ๋ฅผ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", nil)
            return
        }
        
        let message = parseResponseMessage(response.data)
        
        print("์ƒํƒœ ์ฝ”๋“œ: \(statusCode), ๋ฉ”์‹œ์ง€: \(message ?? "๋ฉ”์‹œ์ง€ ์—†์Œ")")
        
        switch statusCode {
        case 200:
            completion(true, "์„ฑ๊ณต", statusCode)
        case 400:
            completion(false, "ํ•„์ˆ˜ ๊ฐ’์ด ๋ˆ„๋ฝ๋์Šต๋‹ˆ๋‹ค", statusCode)
        case 401:
            completion(false, "์ ‘๊ทผ์ด ๊ฑฐ๋ถ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค", statusCode)
        case 403:
            completion(false, "๊ธˆ์ง€๋œ ์ ‘๊ทผ์ž…๋‹ˆ๋‹ค", statusCode)
        case 409:
            completion(false, "์ด๋ฏธ ๊ฐ€์ž…ํ•œ ์œ ์ €์ž…๋‹ˆ๋‹ค.", statusCode)
        case 418:
            completion(false, "๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”", statusCode)
        case 419:
            completion(false, "์•ก์„ธ์Šค ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", statusCode)
        case 420:
            completion(false, "์ž˜๋ชป๋œ SesacKey์ž…๋‹ˆ๋‹ค", statusCode)
        case 429:
            completion(false, "์š”์ฒญ์ด ๋„ˆ๋ฌด ๋งŽ์Šต๋‹ˆ๋‹ค", statusCode)
        case 444:
            completion(false, "์ž˜๋ชป๋œ URL์ž…๋‹ˆ๋‹ค", statusCode)
        case 500:
            completion(false, "์„œ๋ฒ„ ์—๋Ÿฌ", statusCode)
        default:
            completion(false, message ?? "์•Œ ์ˆ˜ ์—†๋Š” ์—๋Ÿฌ", statusCode)
        }
    }

License and Copyright

ยฉ 2024 ์ด์Šนํ˜„ (SeungHyeon Lee). All rights reserved.

  • ๋ณธ GitHub ๋ฆฌ๋“œ๋ฏธ๋Š” ๊ฐœ์ธ ํ”„๋กœ์ ํŠธ ๋ง›์Š๋žญ์„ ์†Œ๊ฐœํ•˜๊ธฐ ์œ„ํ•ด ์ž‘์„ฑ๋˜์—ˆ์œผ๋ฉฐ, ๋ฆฌ๋“œ๋ฏธ์˜ ๋ชจ๋“  ๋‚ด์šฉ์€ ์ด์Šนํ˜„(SeungHyeon Lee)์ด ์ง์ ‘ ์ž‘์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค.

  • ๋ง›์Š๋žญ์€ ๊ธฐํš, ๋””์ž์ธ, ๊ฐœ๋ฐœ, ๋ฐฐํฌ๊นŒ์ง€ ์ „ ๊ณผ์ •์„ ์ œ๊ฐ€ ๋‹จ๋…์œผ๋กœ ์ˆ˜ํ–‰ํ•œ ๊ฐœ์ธ ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค. ๋ฆฌ๋“œ๋ฏธ์— ํฌํ•จ๋œ ๋ชจ๋“  ํ…์ŠคํŠธ, ์ด๋ฏธ์ง€ ๋ฐฐ์น˜, ์„ค๋ช…, ๊ธฐ์ˆ  ์Šคํƒ ์†Œ๊ฐœ ๋“ฑ์€ ์ €์˜ ์ž‘์—…์ž„์„ ๋ฐํž™๋‹ˆ๋‹ค.

  • ๋ฆฌ๋“œ๋ฏธ์™€ ๊ด€๋ จํ•˜์—ฌ ๋ฌธ์˜๊ฐ€ ํ•„์š”ํ•˜์‹  ๊ฒฝ์šฐ ์•„๋ž˜ ์ด๋ฉ”์ผ๋กœ ์—ฐ๋ฝํ•ด ์ฃผ์„ธ์š”: [email protected]

About

๐Ÿ” ๋ง›์Š๋žญ - ๋‚˜๋งŒ์˜ ๋ง›์ง‘ ์ปฌ๋ ‰์…˜ 2.0 ์„œ๋ฒ„ ๋ฒ„์ „

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages