ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 멋쟁이사자처럼 IOS 앱스쿨 2기를 마치며
    스터디/멋쟁이사자처럼iOS앱스쿨 2023. 10. 25. 21:40

    2023.10.25 기준 오늘 멋쟁이사자처럼 IOS 앱스쿨 2기가 마무리 되었습니다!

     

    9.19 ~ 10.25일 까지 약 한달동안 밤낮 없이 마지막 프로젝트 작업에 몰두 했고 오늘 발표를 마지막으로 교육 기간이 끝났네요.

    부트캠프를 수료 했지만, 뭔가 끝났다는 느낌은 들지 않고 "Apple developer가 되기 위한 여러 관문중 문 하나를 넘었다."란 느낌입니다. 

    마지막 프로젝트를 진행하면서 오랜만에 재밌는 개발을 했습니다. 팀원들이 의견도 많고 의욕도 넘쳐 저도 최선을 다한거 같네요. 

     

    거두절미 하고, 프로젝트를 진행하면서 느꼈던 점이 있어 이렇게 글을 남기고자 합니다. 이번 Yedi 프로젝트에서 제가 담당했던 역할은 다음과 같습니다. 아래 기능들을 간략하게 설명하고 고민했던점과 아쉬웠던 점을 말씀 드리겠습니다. 

    채팅방 리스트

    저희 앱은 디자이너와 고객의 소통 원활함을 목적으로 채팅 기능을 추가 했습니다. 

    Yedi 채팅방 리스트

    Firebase store database VS Realtime database

    실시간으로 사용자가 메세지를 받는 것을 채팅 리스트View에 표출하고 읽지 않은 메세지 갯수를 표출하는 기능을 구현했습니다. 보통 채팅같은 경우엔 웹소켓을 통해 통신하는 것으로 알고 있습니다. 하지만 저흰 백엔드 포지션을 맡은 팀원은 없었고 그 기능을 담당해줄 Firebase를 선택했습니다. Firebase에서도 제품이 두가지로 나뉘었습니다. Store database, Realtime database.

    Firebase 공식문서를 참고해 Latency가 짧은 Realtime database를 사용하는 것으로 결정했고 작업했습니다. 

    하지만 복잡한 쿼리 불가, 정렬 제한 등 채팅 기능에 필요한 로직들을 Realtime database를 사용하는 것에 한계가 명확했고, 중간에 Store database에 observer 를 사용하는 것으로 실시간 통신 기능을 구현했습니다. 


    채팅 모델 구조 설계

    저희 Yedi에선 사용자가 보낼 수 있는 채팅 메세지 유형은 이미지를 가지는 이미지 메세지, 택스트만 존재하는 텍스트 메세지, 이 둘 다를 사용하는 게시글 메세지가 있습니다. 이런 메세지들을 가지는 채팅방 리스트 모델이 존재했고 파이어베이스에서 Parsing도 용이한 모델링을 해야 했습니다.

     

    초기 모델링은 다음과 같이 채팅방이 각 메세지 유형별 구조체를 배열 형태로 가지는 방향으로 진행 했습니다.

    struct ChatRoom: Codable {
        var id: String = UUID().uuidString
        var textBubbles: [TextBubble]?
        var imageBubbles: [ImageBubble]?
        var boardBubbles: [BoardBubble]?
    }
    struct TextBubble: Codable {
        /// 구조체 ID
        var id: String
        /// 내용
        var content: String
        /// 보낸시간
        var date: String
        /// 보낸사람 UserID
        var sender: String
    
        var messageType: MessageType = MessageType.textBubble
    
        init(id: String = UUID().uuidString, content: String, date: String, sender: String) {
        self.id = id
        self.content = content
        self.date = date
        self.sender = sender
        }
    }
    
    // 나머지 변수들은 TextBubble과 동일해서 생략
    struct ImageBubble: Codable {
        /// 이미지경로
        var imagePath: String
    }
    
    struct BoardBubble: Codable {
        /// 텍스트내용
        var content: String
        /// 내용
        var imagePath: String
    }

    하지만 파이어베이스 data parsing과정중에 불필요한 작업들이 생겨 다음과 같이 변경해 좀 더 간단하게 데이터를 파싱할 수 있는 구조로 변경했습니다. 

    struct ChatRoom: Codable, Equatable {
        var id: String = UUID().uuidString
        var chattingBubles: [CommonBubble]?
        var totalUnReadCount: Int?
    }
    
    struct CommonBubble: Codable, Identifiable, Hashable {
        var id: String = UUID().uuidString
        var content: String?
        var imagePath: String?
        var date: String
        var sender: String
        var isRead: Bool
        var messageType: MessageType
        
        enum CodingKeys: CodingKey {
            case id
            case content
            case imagePath
            case date
            case sender
            case isRead
            case messageType
        }
        // 메세지 유형별 데이터 파싱 코드
        init(from decoder : Decoder) throws {
            let values = try decoder.container(keyedBy:CodingKeys.self)
            id = try values.decode(String.self, forKey:.id)
            date = try values.decode(String.self, forKey:.date)
            sender = try values.decode(String.self, forKey:.sender)
            isRead = try values.decode(Bool.self, forKey: .isRead)
            
            let typeString = try values.decode(String.self, forKey:.messageType)
            messageType = MessageType(rawValue:typeString) ?? .textBubble
            
            switch messageType {
            case .imageBubble:
                imagePath = try? values.decode(String.self, forKey:.imagePath)
            case .textBubble:
                content  =  try? values.decode(String.self, forKey:.content)
            case .boardBubble:
                imagePath = try? values.decode(String.self, forKey:.imagePath)
                content  =  try? values.decode(String.self, forKey:.content)
            }
        }
        
        init(content: String, date: String, sender: String, isRead: Bool) { //text bubble
            self.content = content
            self.date = date
            self.sender = sender
            self.isRead = isRead
            self.messageType = MessageType.textBubble
        }
        
        init(imagePath: String, date: String, sender: String, isRead: Bool) { //image bubble
            self.imagePath = imagePath
            self.date = date
            self.sender = sender
            self.isRead = isRead
            self.messageType = MessageType.imageBubble
        }
    
        init(content: String, imagePath: String, date: String, sender: String, isRead: Bool) { //board bubble
            self.content = content
            self.imagePath = imagePath
            self.date = date
            self.sender = sender
            self.isRead = isRead
            self.messageType = MessageType.boardBubble
        }
    //파싱 예제 
    
    //변환 전
    let bubble = try JSONDecoder().decode(CommonBubble.self, from: jsonData)
        guard let messageType = value["messageType"] as? String,
        	  let date = value["date"] as? String,
              let sender = value["sender"] as? String else {
    			return nil // 필수 필드가 없는 경우는 무시
    	}
    	switch messageType {
            case MessageType.boardBubble.rawValue:
                guard let content = value["content"] as? String,
                	  let imagePath = value["ImagePath"] as? String else {
                       return nil
                    }
            return BoardBubble(id: key, content: content, imagePath: imagePath, date: date, sender: sender)
            // 아래 case들은 생략
            case MessageType.textBubble.rawValue:
            case MessageType.imageBubble.rawValue:
            return ImageBubble(id: key, imagePath: imagePath, date: date, sender: sender)
            default:
            return nil // 알 수 없는 messageType인 경우 무시
    }
            
    // 변환 후
    let jsonData = try JSONSerialization.data(withJSONObject: value)
    let bubble = try JSONDecoder().decode(CommonBubble.self, from: jsonData)

    위와같이 Common bubble 구조체를 만들어 공통적으로 처리해 데이터 파싱할 불필요한 작업을 줄이고 간단한 데이터 파싱을 유도 할 수 있었습니다. 여기서 Swift의 옵셔널의 장점을 느낄 수 있었습니다.


    앱에서 공통적으로 쓰일 싱글톤 DateFommatter 개발

    초기엔 파이어베이스에 저장되는 날짜 형식이 통일되지 않아 개발하는데 어려움이 있었습니다. 이를 해결하고자 데이트 포맷 형식을 강제화하는 기능이 필요하다 생각하였고 포맷은 ISO8601(yyyy-MM-dd'T'HH:mm:ssZ)을 사용하는 것으로 개발하였습니다. 이렇게 개발한 DateFormmatter는 리소스 사용 이슈를 고려해 싱글톤 패턴으로 작성했습니다.

    final class FirebaseDateFormatManager {
        static var sharedDateFormmatter: FirebaseDateFormatManager = FirebaseDateFormatManager()
        
        private init() {}
        
        private let dateFormatter: DateFormatter = {
            let dateFormatter = DateFormatter()
            dateFormatter.locale = Locale(identifier: "ko_KR")
            return dateFormatter
        }()
        
        /// 지정한 dateformat 형식으로 DateInstace 반환 Method
        /// - Parameter from: 변환하고자 하는 Date형식의 string값
        /// - Parameter String: "yyyy-MM-dd'T'HH:mm:ssZ" 형식(Firebase date format)의  String
        /// - Returns: 라미터로 전달된 foramt String, 변환 실패시 ISO8601형식의 현재 날짜 반환
        func changeDateString(transition format: String, from string: String) -> String {
            dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
            
            if let date = dateFormatter.date(from: string) {
                dateFormatter.dateFormat = format
                return dateFormatter.string(from: date)
            } else {
                return dateFormatter.string(from: Date())
            }
        }
        
        /// 날짜형식의 스트링을 Date로 변환
        /// - Parameter date: ISO8601 형식(yyyy-MM-dd'T'HH:mm:ssZ) DateString
        ///  - Returns: 변환 실패시 현재날짜 기준 Date 인스턴스 반환
        func changeStringToDate(dateString: String) -> Date {
            if let date = dateFormatter.date(from: dateString) {
                return date
            } else {
                return Date()
            }
        }
        
        /// Firebase 날짜 포맷 Method
        ///  - Returns: ISO8601 형식(yyyy-MM-dd'T'HH:mm:ssZ) DateFormatter 반환
        func firebaseDateFormat() -> DateFormatter {
            dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
            return dateFormatter
        }
        
        /// Firebase에 저장할 Date 포맷에 맞는 String 반환 Method
        /// - Parameter date: date Instance
        /// - Returns date String: ISO8601 형식(yyyy-MM-dd'T'HH:mm:ssZ) date String
        func firebaseDate(from date: Date) -> String {
            dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
            return dateFormatter.string(from: date)
        }
    }

     

    하지만 예외상황이 존재. 데이트 포맷에 자유를 줄 것인가?

    저희 앱에선 소비자가 예약하고 디자이너가 예약을 관리하는 로직이 있습니다. FirebaseDateFormatManager에 구현된 메소드만으로 구현하기엔 한계가 있었고 이 내부 API에 다른 관심사에 처리되는 기능을 추가하기에 유지보수 측면에선 맞지 않다고 생각했습니다.

    하지만 DateFormatter를 인스턴스화할 때 발생하는 리소스 비용을 고려해 이미 생성된 DateFormatter 인스턴스를 참조하는 firebaseDateFormat() 메소드를 구현했습니다.


    이미지 캐싱 구현

    저희 Yedi는 Firebase Storage에 이미지 데이터를 관리하고 있었습니다. 하지만 일일 무료 사용량이 1GB라, 조금만 테스트해도 락이 걸려버리는 이슈가 발생해  SwiftUI AsnycImage 사용시 메모리캐싱 우선 조회 및 캐싱처리하는 로직을 개발했습니다.

    import SwiftUI
    import Combine
    import Foundation
    
    class ImageLoader: ObservableObject {
        @Published var image: UIImage?
        
        private let imageCache = NSCacheManager.sharedNSCache.memoryCache
        private var cancellable: AnyCancellable?
        
        func load(from url: String) {
            if let cachedImage = imageCache.object(forKey: url as NSString) {
                self.image = cachedImage
                return
            }
            
            guard let imageURL = URL(string: url) else {
                return
            }
            
            cancellable = URLSession.shared.dataTaskPublisher(for: imageURL)
                .map { UIImage(data: $0.data) }
                .replaceError(with: nil)
                .receive(on: DispatchQueue.main)
                .sink { [self] in
                    if let image = $0 {
                        DispatchQueue.global(qos: .default).async {
                            let imageCache = self.imageCache
                            imageCache.setObject(image, forKey: url as NSString)
                        }
                    }
                    self.image = $0
                }
        }
    }
    final class NSCacheManager {
        static var sharedNSCache: NSCacheManager = NSCacheManager()
        private init() {  memoryCache.delegate = delegate }
        private let delegate = CacheDelegate()
        
        let memoryCache: NSCache<NSString, UIImage> = {
            let cache = NSCache<NSString, UIImage>()
            
            #if DEBUG
            let limitCount = 200
            #else
            let limitCount = 50
            #endif
            
            cache.countLimit = limitCount
            
            return cache
        }()
    }
    
    // 메모리 버퍼 evicted 갯수 모니터링 목적으로 구현
    class CacheDelegate: NSObject, NSCacheDelegate {
        func cache(_ cache: NSCache<AnyObject, AnyObject>, willEvictObject obj: Any) {
            debugPrint("An object was evicted from the cache:", obj)
        }
    }

     

    결과

    캐싱이후 Storage가 전송한 트래픽 양을 모니터링 해봤습니다. 그래프만 보더라도 확실히 감소되었고 평균을 계산해보니 (캐싱적용일자 10/12일 전후로 계산) 대략 49% 트래픽 전송량이 감소되었습니다.

     

     

    고민

    totalCostLimit vs. CountLimit

    evicated 되는 정책을 설정할 때 캐싱되는 수를 제한것과 용량 제한정책을 설정을 할 것인지 고민했습니다. 저희 앱에서 이미지 캐싱 목적은 네트워크 통신 최소화와 빠른 이미지 로딩 목적으로 이미지 캐싱 로직을 도입 했고 가장 많이 이미지 포출되는 뷰인 홈탭 게시글 목록에서 한 번에 이미지가 보이는 숫자가 10개 정도로 였습니다. 파이어스토지에 저장된 이미지를 참고할 당시 가장 큰 이미지는3-4MB였고 최악의 경우 한 번에 10 * 4MB = 40MB 캐싱하게 되니 여유 10MB를 더해 50MB로 정책 설정을 했습니다.

     

    싱글톤 디자인패턴 채택

    위에서 언급한 dateFommatter처럼 인스턴스 생성 비용이 크고 이미지 캐싱 뿐만 아니라 다른 텍스트(예로 채팅방 텍스트) 캐싱 가능성도 염두해 재사용성을 고려해야 했습니다. 결과로 어느 곳에서 가져다 캐싱 할 수 있는 싱글톤 인스턴스로 캐싱 부분을 개발하였습니다.

     

    캐싱 부분에서 아쉬웠던 점은 메모리 캐싱만 했다는 점입니다. 이미지 저장소로 무료버전의 Firebase Storage를 사용했는데, 하루 무료 트래픽이 1GB였고, 메모리 캐싱을 해도 아직까진 트래픽량이 많았습니다

    또한 메모리 캐싱 용량제한을 50MB로만 설정했을 때 생각보다 빈번하게 evicated가 발생했다는 점입니다. 이미지마다 데이터 크기 편차가 컸고 잦은 evicated가 발생, overhead 가능성을 생각했어야 했습니다. 만약 기회가 된다면 용량이 아닌 캐싱 갯수를 제한 하는 것으로 변경 디스크 캐싱 도입도 고려해야 할 거 같습니다.


    백엔드 개발자로 1년 6개월 정도 일하면서 프론트를 깊게 파본적이 없었지만 이번 프로젝트에선 경험한 것 같아 새로운 영역에 발을 딛은 느낌이 들어 재밌었습니다. 프론트는 뷰만 잘 그리는 게 아니란 걸 깨달았고 성능 면에서도 깊게 파고 공부해야 할 거 같습니다. 마지막으로 앱스쿨을 진행하면서 좋은분들과 같이 공부 할 수 있어서 너무 좋았습니다. 부트캠프가 끝났지만 이제  본격적인 시작인 거 같습니다.

     

    읽어주셔서 감사합니다!

Designed by Tistory.