Neoself의 기술 블로그

전동 킥보드 대여 서비스 iOS 앱 개발기 본문

개발지식 정리/Swift

전동 킥보드 대여 서비스 iOS 앱 개발기

Neoself 2024. 12. 25. 20:13

이 글을 통해 전동 킥보드 대여 서비스 iOS 앱을 팀원들과 함께 개발하면서 겪었던 경험과 기술적 고민들을 공유하고자 합니다.

 

가장 먼저 전동 킥보드 대여 서비스를 만들기 위해 사용했던 기술 스택입니다.

1. 기술 스택

  • UI Framework: UIKit, SnapKit
  • Architecture: Clean Architecture + MVVM/MVC
  • State Management: Combine
  • Database: CoreData
  • Map Services: MapKit, CoreLocation
  • Security: CryptoKit (비밀번호 암호화)

2. 아키텍처 설계

2.1 Clean Architecture와 각 레이어

프로젝트를 시작하면서 가장 큰 고민은 아키텍처 설계였습니다. 초기에는 단순 MVC 패턴으로 시작했지만, 다음과 같은 문제들이 발생했습니다:

  1. Controller의 비대화
  2. 비즈니스 로직의 분산
  3. 데이터 접근 계층의 불명확성

이러한 문제들을 해결하기 위해 Clean Architecture를 도입했습니다. 모바일 개발환경에서 Clean Architecture는 세 개의 주요 레이어로 구성됩니다:

출처: https://medium.com/delightroom/%ED%81%B4%EB%A6%B0%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-cleanarchitecture-%EB%8A%94-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EA%B0%9C%EB%B0%9C%EC%97%90-%EB%8F%84%EC%9B%80%EC%9D%B4-%EB%90%98%EB%8A%94%EA%B0%80-1-169296dd5b81

 

Domain Layer

이는 엔티티 (Entities) 계층이라고도 불리는 계층이며, 하나 이상의 프로그램 간에 공유될 수 있다는 가정 하에 만드는 수명이 긴 객체입니다. 가장 안쪽에 위치하는 계층입니다.

  • 비즈니스 로직의 핵심
  • 다른 레이어에 대한 의존성이 없음
  • Entities와 UseCases로 구성
// Entities 예시
struct Kickboard {
    let kickboardCode: String
    var longitude: Double
    var latitude: Double
    var isRented: Bool
    var batteryStatus: Int16
    let type: KickboardType
}

struct User {
    let username: String
    var isAdmin: Bool
}

struct History {
    let cost: Int16
    let rentDate: Date
    let totalRentTime: Int16
    let kickboard: Kickboard
    let user: User
}

enum KickboardType: String {
    case basic
    case power
}

// UseCase 예시는 앞서 살펴본 LoginUseCase, SignupUseCase 등이 있습니다
 

Data Layer (중간 레이어)

  • Domain Layer의 추상화된 요구사항을 실제로 구현
  • 데이터 저장소(CoreData) 접근 로직 포함
  • Repository 구현체들이 위치
// Repository 구현체
struct KickboardRepository: KickboardRepositoryProtocol {
    private let coreDataStack: CoreDataStack
    
    init(coreDataStack: CoreDataStack = .shared) {
        self.coreDataStack = coreDataStack
    }
    
    // Repository 프로토콜 구현
}

// 데이터 모델
class KickboardEntity: NSManagedObject {
    @NSManaged public var batteryStatus: Int16
    @NSManaged public var isRented: Bool
    @NSManaged public var kickboardCode: String?
    @NSManaged public var latitude: Double
    @NSManaged public var longitude: Double
    @NSManaged public var histories: NSSet?
    @NSManaged public var kickboardType: KickboardTypeEntity?
}

 

Presentation Layer (바깥쪽 레이어)

  • UI 관련 로직
  • 사용자 입력 처리
  • Domain Layer의 UseCase 활용
class MainViewController: UIViewController {
    // UI 컴포넌트
    private let mainView = MainView()
    
    // Domain Layer와의 상호작용
    private let kickboardRepository: KickboardRepositoryProtocol
    private let manager = KickboardManager.shared
    
    // 상태 관리
    private var cancellables = Set<AnyCancellable>()
}

// ViewModel (MVVM 패턴 적용 시)
class KickboardDetailViewModel {
    @Published private(set) var currentStatus: KickboardStatus = .idle
    private let kickboardRepository: KickboardRepositoryProtocol
    
    init(repository: KickboardRepositoryProtocol) {
        self.kickboardRepository = repository
    }
    
    func requestKickboard(_ kickboard: Kickboard) {
        // 비즈니스 로직 처리
    }
}
위 3개의 레이어를 중심으로 레이어 간의 데이터 흐름을 설명드리자면 크게 아래 케이스들이 있습니다.
  1. 사용자 입력 → Presentation Layer
  2. UseCase 실행 → Domain Layer
  3. 데이터 접근 → Data Layer
  4. 결과 반환 → Domain Layer → Presentation Layer

각 레이어는 안쪽 레이어의 인터페이스(Protocol)만 알고 있으며, 구체적인 구현은 알 수 없습니다. 이를 통해:

  • 관심사 분리
  • 테스트 용이성
  • 유지보수성 향상
  • 확장성 확보

의 이점을 얻을 수 있었습니다.

 

2.2 데이터 공유 전략

MainViewController와 KickboardDetailView 간의 데이터 공유는 큰 도전 과제였습니다. 여러 옵션을 고려한 끝에 Singleton 패턴을 적용한 KickboardManager를 도입해 전역에서 단일 클래스를 접근할 수 있도록 하고, Combine을 활용해 반응형으로 상태관리를 구현하기로 했습니다.

// 공유 데이터를 보유하는 싱글톤 클래스
final class KickboardManager {
    static let shared = KickboardManager()
    
    // Published 프로퍼티로 상태 변화 감지
    @Published private(set) var currentKickboard: Kickboard?
    @Published private(set) var currentStatus: KickboardStatus = .idle
    @Published private(set) var elapsedTime: Int = 0
    @Published private(set) var price: Double = 0.0
    
    // 상태값을 변경하는 public 메서드
    func startRiding() {
        currentStatus = .riding
        // ...
    }
}

// 사용 예시 (KickboardDetailViewController)
class KickboardDetailViewController: UIViewController {
    
    private var cancellables = Set<AnyCancellable>()
    private let manager = KickboardManager.shared
    
    private func actionButtonClicked{
        // ...
        manager.startRiding() // 싱글톤 클래스 내부 상태값 변경
    }
}

// 사용 예시 (MainViewController)
class MainViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        ...
        setupBindings()
    }
    
    func setupBindings() {
        // 상태 변화 구독
        KickboardManager.shared.$currentStatus
            .sink { [weak self] status in
                self?.updateUI(for: status) // 구독하는 상태값 변경 시 호출되는 메서드
            }
            .store(in: &cancellables)
            
        KickboardManager.shared.$currentKickboard
            .sink { [weak self] kickboard in
                self?.updateMapAnnotations(with: kickboard)
            }
            .store(in: &cancellables)
    }
}

위와 같이 KickboardManager 싱글톤 클래스 내부에 공유가 필요한 속성들을 Combine 프레임워크에서 제공하는 Published 래퍼 적용을 통해 출판 대상에 포함시켰고, 해당 상태값의 변화를 감지해야하는 뷰 컨트롤러에서 초기화 시점에 setupBindings 메서드를 호출해 출판된 데이터를 직접 구독하는 로직을 구현하였습니다. 

위 구조 채택 시, 아래와 같이 특정 뷰 컨트롤러에서 공유 상태값 변경 시, 다른 뷰 컨트롤러에서 이를 감지할 수 있게 됩니다. 

  1. KickboardDetailViewController에서 싱글톤 클래스 내부 메서드 startRiding를 호출해 공유 데이터인 currentStatus 수정
  2. 싱글톤 클래스에서 상태값 변경내용을 출판
  3. MainViewController에서 currentStatus 변경 감지 및 setupBinding으로 연결했던 self.updateUI 메서드 실행

이로써, 상태 관리 로직 자체가 중앙화되는 장점도 얻을 수 있게됩니다.

 

3. Repository 패턴

Repository 패턴을 도입한 주된 이유는 데이터 접근 계층을 추상화하기 위해서였습니다. 이는 CoreData와 같은 구체적인 데이터 저장소 구현 세부사항을 애플리케이션의 다른 계층으로부터 분리하는 데 도움이 됩니다.

3.1 Repository 프로토콜 정의

우선 각 도메인 엔티티(Kickboard, User, History)별로 Repository 프로토콜을 정의했습니다:

protocol KickboardRepositoryProtocol {
    func saveKickboard(_ kickboard: Kickboard) throws
    func fetchKickboard(by kickboardCode: String) throws -> Kickboard
    func fetchKickboardsInAreaOf(minLat: Double, maxLat: Double, minLng: Double, maxLng: Double) throws -> [Kickboard]
    func updateKickboard(by kickboardCode: String, to newKickboard: Kickboard) throws
    func deleteKickboard(by kickboardCode: String) throws
}

protocol UserRepositoryProtocol {
    func saveUser(username: String, password: String, isAdmin: Bool)
    func fetchUser(by username: String) throws -> User
    func authenticateUser(by username: String, hashedPassword: String) throws -> User?
    func updateUserPassword(of user: User, newPassword: String) throws
    func deleteUser(by username: String) throws
}

protocol HistoryRepositoryProtocol {
    func saveHistory(_ history: History) throws
    func fetchAllHistories(of user: User) throws -> [History]
    func deleteAllhistories() throws
}

3.2 CoreData 기반 Repository 구현

예를 들어, KickboardRepository의 실제 구현은 다음과 같습니다:

struct KickboardRepository: KickboardRepositoryProtocol {
    func saveKickboard(_ kickboard: Kickboard) throws {
        try CoreDataStack.shared.createKickboard(
            kickboardCode: kickboard.kickboardCode,
            batteryStatus: kickboard.batteryStatus,
            isRented: kickboard.isRented,
            latitude: kickboard.latitude,
            longitude: kickboard.longitude,
            type: kickboard.type
        )
    }
    
    func fetchKickboard(by kickboardCode: String) throws -> Kickboard {
        return try CoreDataStack.shared.readKickboard(kickboardCode: kickboardCode)
    }
    
    func fetchKickboardsInAreaOf(
        minLat: Double,
        maxLat: Double,
        minLng: Double,
        maxLng: Double
    ) throws -> [Kickboard] {
        try CoreDataStack.shared.requestKickboards(
            minLat: minLat,
            maxLat: maxLat,
            minLng: minLng,
            maxLng: maxLng
        )
    }
    
    func updateKickboard(by kickboardCode: String, to newKickboard: Kickboard) throws {
        try CoreDataStack.shared.updateKickboard(
            kickboardCode: kickboardCode,
            batteryStatus: newKickboard.batteryStatus,
            isRented: newKickboard.isRented,
            latitude: newKickboard.latitude,
            longitude: newKickboard.longitude,
            type: newKickboard.type
        )
    }
    
    func deleteKickboard(by kickboardCode: String) throws {
        try CoreDataStack.shared.deleteKickboard(kickboardCode: kickboardCode)
    }
}

킥보드 Entity를 영구저장소에 저장하는 메서드와 같이 데이터 접근을 위한 메서드 구현체들을 KickboardRepository 구조체에서 중앙관리하고 있습니다.

 

3.3 CoreDataStack 구현

각 Repository에서 영구저장소에 접근하는 과정에서 동시성 업데이트를 방지하고자 CoreData 프레임워크로 영구 저장소에 접근하는 기본 CRUD 메서드들을 아래와 같이 CoreDataStack 싱글톤 클래스로 분리해주었습니다.

final class CoreDataStack {
    static let shared = CoreDataStack() // 싱글톤
    
    var persistentContainer: NSPersistentContainer // CoreData 스택들
    var context: NSManagedObjectContext // CoreData 스택들
    
    private init() {
        let container = NSPersistentContainer(name: "nbc_kickboard")
        
        container.loadPersistentStores { _, error in
            if let error {
                fatalError("Failed to load persistent stores: \(error.localizedDescription)")
            }
        }
        
        persistentContainer = container
        context = container.viewContext
    }
    
    func save() {
        guard persistentContainer.viewContext.hasChanges else { return }
        
        do {
            try persistentContainer.viewContext.save()
        } catch {
            print("Failed to save the context:", error.localizedDescription)
        }
    }
}

// MARK: - Kickboard CRUD Extension
extension CoreDataStack {
    func createKickboard(
        kickboardCode: String,
        batteryStatus: Int16,
        isRented: Bool,
        latitude: Double,
        longitude: Double,
        type: KickboardType
    ) throws {
        guard let entity = NSEntityDescription.entity(forEntityName: "KickboardEntity", in: context) else {
            return
        }
        
        let newKickboard = NSManagedObject(entity: entity, insertInto: context)
        newKickboard.setValue(kickboardCode, forKey: KickboardEntity.Key.kickboardCode)
        newKickboard.setValue(batteryStatus, forKey: KickboardEntity.Key.batteryStatus)
        newKickboard.setValue(isRented, forKey: KickboardEntity.Key.isRented)
        newKickboard.setValue(latitude, forKey: KickboardEntity.Key.latitude)
        newKickboard.setValue(longitude, forKey: KickboardEntity.Key.longitude)
        
        let kickboardTypeEntity = try findKickboardTypeEntity(with: type.rawValue)
        newKickboard.setValue(kickboardTypeEntity, forKey: KickboardEntity.Key.kickboardType)
        
        save()
    }
    ...
}

이로써 PersistantContainer와 같은 CoreData 스택은 앱 전역에 단일로 존재하게 됩니다.

 

3.4 Repository 패턴의 장점

Repository 패턴이 갖고 있는 장점들은 아래와 같습니다.

  1. 데이터 소스 독립성: CoreData를 다른 저장소로 교체하더라도 애플리케이션의 다른 부분은 수정할 필요가 없기에, Repository 프로토콜만 준수하면 어떤 구현체로든 교체 가능(CoreData -> UserDefaults로의 교체)
  2. 테스트 용이성: Repository 프로토콜을 Mock 객체로 구현하여 단위 테스트를 할수 있게 됩니다.
  3. 관심사 분리: 데이터 접근 로직이 Repository에 캡슐화되며, ViewController나 UseCase는 데이터 저장소의 구체적인 구현을 알 필요가 없어집니다. 이는 코드 가독성과 유지보수성 향상과도 이어집니다.
  4. 코드 재사용: 동일한 데이터 접근 로직을 여러 곳에서 재사용 가능

4. UseCase 패턴

UseCase는 Clean Architecture의 핵심 요소로써 비즈니스 로직을 캡슐화하는 데 사용했습니다. 각각의 UseCase는 단일 책임 원칙에 따라 특정 기능의 비즈니스 로직을 독립적으로 처리하며, 이를 통해 코드의 재사용성과 테스트 용이성을 확보했습니다.

이때 모든 UseCase에 Input/Output 패턴을 적용하여, 각 UseCase의 입력과 출력 타입을 명확하게 정의했습니다. 이는 다음과 같은 이점을 제공합니다:

  1. 컴파일 타임에 타입 안정성을 보장하여 런타임 에러를 사전에 방지
  2. UseCase의 인터페이스가 명확해져 팀 협업 시 코드 이해도 향상

4.1 UseCase 프로토콜 정의

protocol UseCaseProtocol {
    associatedtype Input  // UseCase의 입력 타입
    associatedtype Output // UseCase의 출력 타입
    
    func execute(_ input: Input) -> Output
}

4.2 로그인 UseCase 구현

struct LoginUseCase: UseCaseProtocol {
    typealias Input = (username: String, password: String)
    typealias Output = Result<UserEntity, ValidationError>
    
    private let userEntityRepository: UserEntityRepositoryProtocol
    
    init(userEntityRepository: UserEntityRepositoryProtocol) {
        self.userEntityRepository = userEntityRepository
    }
    
    func execute(_ input: Input) -> Output {
        var errors: [String] = []
        let (username, password) = input
        
        // 1. 비밀번호 암호화
        do {
            let hashedPw = try PasswordManager.encryptPassword(password)
            
            // 2. 사용자 인증
            guard let result = userEntityRepository.getAuthenticatedUser(
                username: username, 
                hashedPw: hashedPw
            ) else {
                errors.append("사용자 정보가 없습니다.")
                return .failure(ValidationError(messages: errors))
            }
            
            // 3. 유효성 검증
            guard result.username == username else {
                errors.append("Bad Request")
                return .failure(ValidationError(messages: errors))
            }
            
            return .success(result)
            
        } catch {
            errors.append("패스워드 암호화 및 저장에 실패하였습니다.")
            return .failure(ValidationError(messages: errors))
        }
    }
}

4.3 ViewController에서의 UseCase 활용

class AuthViewController: UIViewController {
    private let userEntityRepository: UserEntityRepositoryProtocol
    private let loginUseCase: LoginUseCase
    
    init(userEntityRepository: UserEntityRepositoryProtocol = UserEntityRepository()) {
        self.userEntityRepository = userEntityRepository
        self.loginUseCase = LoginUseCase(userEntityRepository: self.userEntityRepository)
        super.init(nibName: nil, bundle: nil)
    }
    
    func handleLogin(username: String, password: String) {
        let result = loginUseCase.execute((username: username, password: password))
        
        switch result {
        case .success(let userEntity):
            // UserDefaults에 로그인 정보 저장
            UserDefaults.standard.set(userEntity.username, forKey: "username")
            UserDefaults.standard.set(userEntity.isAdmin, forKey: "isAdmin")
            UserDefaults.standard.set(username, forKey: "lastLoginUsername")
            UserDefaults.standard.set(password, forKey: "lastLoginPassword")
            
            // 메인 화면으로 이동
            navigationController?.pushViewController(CustomTabBarController(), animated: false)
            
        case .failure(let error):
            // 에러 메시지 표시
            let errorMessage = error.messages.reduce("") { "\($0)\n- \($1)" }
            let alertView = AlertView(title: "로그인 실패", message: errorMessage)
            let _ = ModalManager.createGlobalModal(content: alertView)
        }
    }
}

5. Delegate 패턴

화면 간 데이터 전달과 이벤트 처리를 위해는 부분적으로 Delegate 패턴을 사용했습니다.

5.1 검색 결과 위치 전달 (Search → Main)

// 1. Delegate 프로토콜 정의
protocol SearchViewControllerDelegate: AnyObject {
    func didSelectLocation(latitude: Double, longitude: Double)
}

// 2. 검색 화면 구현
class SearchViewController: UIViewController {
    weak var delegate: SearchViewControllerDelegate?
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let place = searchResults[indexPath.row]
        
        if let latitude = Double(place.y),
           let longitude = Double(place.x) {
            // 3. 선택된 위치 delegate를 통해 전달
            delegate?.didSelectLocation(
                latitude: latitude, 
                longitude: longitude
            )
            dismiss(animated: true)
        }
    }
}

// 4. delegate 구현부 (MainViewController)
extension MainViewController: SearchViewControllerDelegate {
    func didSelectLocation(latitude: Double, longitude: Double) {
        // 선택된 위치로 지도 이동
        let coordinate = CLLocationCoordinate2D(
            latitude: latitude,
            longitude: longitude
        )
        let camera = MKMapCamera(
            lookingAtCenter: coordinate,
            fromDistance: 1000,
            pitch: 0,
            heading: 0
        )
        mainView.mapView.setCamera(camera, animated: true)
    }
}

5.2 로그인/로그아웃 처리

protocol LoginViewDelegate: AnyObject {
    func navigateToSignup()
    func getAuthentication(username: String, password: String)
}

class LoginView: UIView {
    weak var delegate: LoginViewDelegate?
    
    func tapSignupButton() {
        delegate?.navigateToSignup()
    }
    
    func tapLoginButton() {
        if let username = inputUsername.text,
           let password = inputPassword.text {
            delegate?.getAuthentication(
                username: username, 
                password: password
            )
        }
    }
}

extension AuthViewController: LoginViewDelegate {
    func navigateToSignup() {
        navigationController?.pushViewController(
            SignupViewController(),
            animated: true
        )
    }
    
    func getAuthentication(username: String, password: String) {
        let result = loginUseCase.execute((
            username: username,
            password: password
        ))
        
        handleLoginResult(result)
    }
}

 

6. 주요 기능 구현

6.1 실시간 위치 기반 킥보드 조회

위치 기반 기능을 구현하기 위해 MapKit과 CoreLocation을 활용했습니다.

class MainViewController: UIViewController {
    private let mainView = MainView()
    private let locationManager = CLLocationManager()
    private let kickboardRepository: KickboardRepositoryProtocol
    private let maxRange = 0.036  // 최대 조회 범위
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupLocationManager()
        mainView.mapView.delegate = self
    }
    
    private func setupLocationManager() {
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
    }
    
    private func moveToCurrentLocation() {
        guard let coordinate = locationManager.location?.coordinate else { return }
        let region = MKCoordinateRegion(
            center: coordinate,
            latitudinalMeters: 1000,
            longitudinalMeters: 1000
        )
        mainView.mapView.setRegion(region, animated: true)
    }
}

// MARK: - Location Authorization
extension MainViewController: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        switch manager.authorizationStatus {
        case .authorizedWhenInUse, .authorizedAlways:
            locationManager.startUpdatingLocation()
            moveToCurrentLocation()
        case .denied, .restricted:
            showLocationPermissionAlert()
        case .notDetermined:
            locationManager.requestWhenInUseAuthorization()
        @unknown default:
            break
        }
    }
}

// MARK: - Map Interaction
extension MainViewController: MKMapViewDelegate {
    func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
        let center = CLLocation(
            latitude: mapView.centerCoordinate.latitude,
            longitude: mapView.centerCoordinate.longitude
        )
        loadNearbyKickboards(at: center)
    }
    
    private func loadNearbyKickboards(at location: CLLocation) {
        let spanRange = min(mainView.mapView.region.span.latitudeDelta / 2, maxRange / 2)
        
        let minLat = location.coordinate.latitude - spanRange
        let maxLat = location.coordinate.latitude + spanRange
        let minLng = location.coordinate.longitude - spanRange
        let maxLng = location.coordinate.longitude + spanRange
        
        do {
            kickboards = try kickboardRepository.fetchKickboardsInAreaOf(
                minLat: minLat,
                maxLat: maxLat,
                minLng: minLng,
                maxLng: maxLng
            )
            updateAnnotations()
        } catch {
            print("Failed to fetch kickboards: \(error)")
        }
    }
    
    private func updateAnnotations() {
        let existingAnnotations = mainView.mapView.annotations.filter { !($0 is MKUserLocation) }
        mainView.mapView.removeAnnotations(existingAnnotations)
        
        let annotations = kickboards.map { kickboard -> CustomAnnotation in
            CustomAnnotation(
                coordinate: CLLocationCoordinate2D(
                    latitude: kickboard.latitude,
                    longitude: kickboard.longitude
                ),
                title: "배터리: \(kickboard.batteryStatus)%",
                subtitle: kickboard.kickboardCode,
                kickboardType: kickboard.type
            )
        }
        
        mainView.mapView.addAnnotations(annotations)
    }
}

6.2 킥보드 대여 시스템

6.2.1 KickboardManager

KickboardManager는 싱글톤 패턴으로 구현되어 앱 전체에서 일관된 상태 관리를 제공하며, Combine을 활용한 반응형 프로그래밍으로 UI 업데이트를 자동화했습니다.

enum KickboardStatus {
    case idle        // 초기 상태
    case isComing    // 킥보드가 이동중
    case isReady     // 대여 가능 상태
    case riding      // 주행중
}

final class KickboardManager {
    static let shared = KickboardManager()
    
    @Published private(set) var currentKickboard: Kickboard?
    @Published private(set) var currentStatus: KickboardStatus = .idle
    @Published private(set) var elapsedTime: Int = 0
    @Published private(set) var price: Double = 0.0
    
    private var timer: Timer?
    private var cancellables = Set<AnyCancellable>()
    private let historyRepository: HistoryRepositoryProtocol
    private let kickboardRepository: KickboardRepositoryProtocol
    
    func requestKickboard(_ kickboard: Kickboard) {
        guard currentStatus == .idle else { return }
        currentStatus = .isComing
        currentKickboard = kickboard
        updateKickboardCoreData(isRented: true)
        startKickboardAnimation(from: routeCoordinates.first!)
    }
    
    func startRiding() {
        guard currentStatus == .isReady else { return }
        
        currentRoute = nil
        currentStatus = .riding
        elapsedTime = 0
        startTimer()
    }
    
    func endRiding() {
        guard currentStatus == .riding,
              let kickboard = currentKickboard else { return }
        
        do {
            guard let username = UserDefaults.standard.value(forKey: "username") as? String,
                  let isAdmin = UserDefaults.standard.value(forKey: "isAdmin") as? Bool else {
                print("Failed to Create riding history: no user in userdefaults")
                return
            }
            
            let newHistory = History(
                cost: Int16(price),
                rentDate: Date(),
                totalRentTime: Int16(elapsedTime),
                kickboard: kickboard,
                user: User(username: username, isAdmin: isAdmin)
            )
            
            try historyRepository.saveHistory(newHistory)
            
            var newKickboard = kickboard
            newKickboard.isRented = false
            newKickboard.batteryStatus = max(0, kickboard.batteryStatus - Int16(elapsedTime / 60))
            
            guard let movingAnnotation = movingAnnotation else { return }
            newKickboard.latitude = movingAnnotation.coordinate.latitude
            newKickboard.longitude = movingAnnotation.coordinate.longitude
            
            try kickboardRepository.updateKickboard(by: kickboard.kickboardCode, to: newKickboard)
            
            resetState()
        } catch {
            print("Failed to create riding history: \(error)")
        }
    }
    
    private func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.elapsedTime += 1
            self?.price = Double(self?.elapsedTime ?? 0) * 100.0
        }
    }
    
    private func resetState() {
        currentStatus = .idle
        currentRoute = nil
        currentKickboard = nil
        price = 0
        elapsedTime = 0
        
        timer?.invalidate()
        timer = nil
    }
}

 

6.2.2 KickboardDetailViewController

사용자와의 직접적인 상호작용을 담당하는 화면으로, KickboardManager의 상태 변화를 구독하여 UI를 업데이트합니다.

class KickboardDetailViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>()
    private let manager = KickboardManager.shared
    
    private func setupBindings() {
        manager.$currentStatus
            .sink { [weak self] status in
                self?.kickboardDetailView.updateUI(for: status)
                
                self?.kickboardDetailView.actionButtonTappedAction = { [weak self] in
                    self?.actionButtonTapped()
                }
                
                self?.kickboardDetailView.timeLabel.isHidden = status != .riding
                self?.kickboardDetailView.priceLabel.isHidden = status != .riding
            }
            .store(in: &cancellables)
        
        manager.$elapsedTime
            .sink { [weak self] time in
                self?.kickboardDetailView.updateTimeLabel(time)
            }
            .store(in: &cancellables)
        
        manager.$price
            .sink { [weak self] price in
                self?.kickboardDetailView.priceLabel.text = "\(Int(price))원"
            }
            .store(in: &cancellables)
    }
    
    @objc private func actionButtonTapped() {
        switch manager.currentStatus {
        case .idle:
            if let kickboard = manager.currentKickboard {
                manager.requestKickboard(kickboard)
            }
        case .isComing:
            print("isComing")
        case .isReady:
            manager.startRiding()
        case .riding:
            manager.endRiding()
            dismiss(animated: true)
        }
    }
}

 6.2.3 MainViewController

해당 뷰 컨트롤러는 다음과 같은 주요 기능들을 구현합니다:

1. 킥보드 마커 표시

- 킥보드 타입별 다른 마커 이미지 사용

- 클릭 시 배터리 상태 및 대여 버튼 표시

- MovingAnnotation과 일반 킥보드 구분

2. 경로 안내

- 사용자 위치에서 킥보드까지의 경로 계산

- 경로 시각화 (파란색 라인)

- 경로가 잘 보이도록 지도 영역 조정

3. 실시간 상태 관리

- KickboardManager의 상태 변화 구독

-현재 대여 상태에 따른 UI 업데이트

- 킥보드 이동 애니메이션 처리

4. 주변 킥보드 조회

- 현재 지도 영역 내 킥보드 검색

- Repository를 통한 데이터 조회

- 어노테이션 업데이트

class MainViewController: UIViewController {
    private let mainView = MainView()
    private let locationManager = CLLocationManager()
    private let manager = KickboardManager.shared
    private let kickboardRepository: KickboardRepositoryProtocol = KickboardRepository()
    private var kickboards: [Kickboard] = []
    private let maxRange = 0.036
    private var cancellables = Set<AnyCancellable>()

    override func loadView() {
        view = mainView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupActions()
        setupLocationManager()
        setupBindings()
        mainView.mapView.delegate = self
    }

    private func setupActions() {
        mainView.locationButton.addTarget(self, action: #selector(locationButtonTapped), for: .touchUpInside)
        mainView.searchButton.addTarget(self, action: #selector(searchButtonTapped), for: .touchUpInside)
    }
    
    private func setupBindings() {
        manager.$movingAnnotation
            .sink { [weak self] annotation in
                guard let self = self else { return }
                // 기존의 annotation 제거
                let existingAnnotations = self.mainView.mapView.annotations.filter { !($0 is MKUserLocation) }
                self.mainView.mapView.removeAnnotations(existingAnnotations)
                
                // 새 annotation 추가
                if let newAnnotation = annotation {
                    self.mainView.mapView.addAnnotation(newAnnotation)
                }
            }
            .store(in: &cancellables)
        
        
        manager.$currentRoute
            .sink { [weak self] route in
                guard let self = self else { return }
                
                if let existingRoute = manager.currentRoute {
                    mainView.mapView.removeOverlay(existingRoute.polyline)
                }
            }
            .store(in: &cancellables)
        
        manager.$currentStatus
            .sink { [weak self] status in
                guard let self = self else { return }

                if status == .idle {
                    moveToCurrentLocation()
                }
            }
            .store(in: &cancellables)
    }
    
    @objc private func searchButtonTapped() {
        let searchVC = SearchViewController()
        searchVC.modalPresentationStyle = .pageSheet
        searchVC.delegate = self
        
        if let sheet = searchVC.sheetPresentationController {
            let screenHeight = UIScreen.main.bounds.height
            let customDetent = UISheetPresentationController.Detent.custom { context in
                return screenHeight * 0.85 // 화면의 85%만 차지하도록 설정
            }
            sheet.detents = [customDetent] // 높이 조절
            sheet.prefersGrabberVisible = true // 드래그 가능한 grabber 추가
            sheet.preferredCornerRadius = 24 // 상단에 둥근 모서리 추가 (선택 사항)
        }
        
        present(searchVC, animated: true, completion: nil)
    }

    private func setupLocationManager() {
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestWhenInUseAuthorization()
    }
    
    private func loadNearbyKickboards(at location: CLLocation) {
        let spanRange = min(mainView.mapView.region.span.latitudeDelta / 2, maxRange / 2)

        let minLat = location.coordinate.latitude - spanRange
        let maxLat = location.coordinate.latitude + spanRange
        let minLng = location.coordinate.longitude - spanRange
        let maxLng = location.coordinate.longitude + spanRange
        
        do {
            kickboards = try kickboardRepository.fetchKickboardsInAreaOf(
                minLat: minLat,
                maxLat: maxLat,
                minLng: minLng,
                maxLng: maxLng
            )
            
            updateAnnotations()
        } catch {
            print("Failed to fetch kickboards: \(error)")
        }
    }
    
    private func drawRoute(to coordinate: CLLocationCoordinate2D) {
        guard let userLocation = locationManager.location?.coordinate else { return }
        
        if let existingRoute = manager.currentRoute {
            mainView.mapView.removeOverlay(existingRoute.polyline)
        }
        
        let request = MKDirections.Request()
        request.source = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
        request.destination = MKMapItem(placemark: MKPlacemark(coordinate: userLocation))
        request.transportType = .walking
        
        let directions = MKDirections(request: request)
        directions.calculate { [weak self] response, error in
            guard let self = self,
                  let route = response?.routes.first else { return }
            
            self.mainView.mapView.addOverlay(route.polyline)
            self.manager.setCurrentRoute(route)
            
            let rect = route.polyline.boundingMapRect
            self.mainView.mapView.setVisibleMapRect(rect, edgePadding: UIEdgeInsets(top: 80, left: 40, bottom: 300, right: 40), animated: true)
        }
    }

    
    private func showLocationPermissionAlert() {
        let alert = UIAlertController(
            title: "위치 권한 필요",
            message: "주변 킥보드를 확인하기 위해 위치 권한이 필요합니다. 설정에서 위치 권한을 허용해주세요.",
            preferredStyle: .alert
        )
        
        let settingsAction = UIAlertAction(title: "설정", style: .default) { _ in
            if let settingsURL = URL(string: UIApplication.openSettingsURLString) {
                UIApplication.shared.open(settingsURL)
            }
        }
        
        let cancelAction = UIAlertAction(title: "취소", style: .cancel)
        
        alert.addAction(settingsAction)
        alert.addAction(cancelAction)
        
        present(alert, animated: true)
    }
    
    
    // MARK: - Map Functions
    private func updateAnnotations() {
        let existingAnnotations = mainView.mapView.annotations.filter { !($0 is MKUserLocation) }
        mainView.mapView.removeAnnotations(existingAnnotations)
        
        let annotations = kickboards.map { kickboard -> CustomAnnotation in
            CustomAnnotation(
                coordinate:CLLocationCoordinate2D(latitude: kickboard.latitude, longitude: kickboard.longitude),
                title: "배터리: \(kickboard.batteryStatus)%",
                subtitle: kickboard.kickboardCode,
                kickboardType: kickboard.type
            )
        }
        
        mainView.mapView.addAnnotations(annotations)
    }
    
    private func moveToCurrentLocation() {
        guard let coordinate = locationManager.location?.coordinate else { return }
        let region = MKCoordinateRegion(
            center: coordinate,
            latitudinalMeters: 1000,
            longitudinalMeters: 1000
        )
        mainView.mapView.setRegion(region, animated: true)
    }
    
    
    //MARK: - 콜백함수
    @objc private func locationButtonTapped() {
         moveToCurrentLocation()
     }
}


// MARK: - CLLocationManagerDelegate = 현재 사용자 위치 수집하기 위한 위임자
extension MainViewController: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        switch manager.authorizationStatus {
        case .authorizedWhenInUse, .authorizedAlways:
            locationManager.startUpdatingLocation()
            moveToCurrentLocation()
        case .denied, .restricted:
            showLocationPermissionAlert()
            break
        case .notDetermined:
            locationManager.requestWhenInUseAuthorization()
        @unknown default:
            break
        }
    }
}


// MARK: - MKMapViewDelegate
extension MainViewController: MKMapViewDelegate {
    /// 마커 디자인 관련 정의 메서드
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        if annotation is MKUserLocation {
            return nil
        }
        
        if annotation is MovingAnnotation {
            let identifier = "MovingKickboard"
            var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
            
            if annotationView == nil {
                annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: identifier)
                annotationView?.image = UIImage(named: "map_pin_moving") // 움직이는 킥보드용 이미지
            }
            
            return annotationView
        } else {
            let identifier = "KickboardMarker"
            var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
            
            if annotationView == nil {
                annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: identifier)
            }
            
            annotationView?.canShowCallout = true
            
            let rightButton = UIButton(type: .system)
            rightButton.setTitle("대여", for: .normal)
            rightButton.titleLabel?.font = Fonts.subtitleBold
            rightButton.setTitleColor(Colors.white, for: .normal)
            rightButton.backgroundColor = Colors.mint
            rightButton.layer.cornerRadius = 8
            rightButton.sizeToFit()
            
            annotationView?.rightCalloutAccessoryView = rightButton
            annotationView?.annotation = annotation
            
            if let customAnnotation = annotation as? CustomAnnotation {
                switch customAnnotation.kickboardType {
                case .basic:
                    annotationView?.image = UIImage(named: "map_pin1")
                case .power:
                    annotationView?.image = UIImage(named: "map_pin2")
                }
            }
            
            return annotationView
        }
    }
    
    /// 사용자가 바라보는 위치가 변경될 때
    func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
        let center = CLLocation(
            latitude: mapView.centerCoordinate.latitude,
            longitude: mapView.centerCoordinate.longitude
        )
        loadNearbyKickboards(at: center)
    }
    
    /// 지도 줌아웃 범위 제한 용도
    func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
        var region = mapView.region
        
        if region.span.latitudeDelta > maxRange {
            region.span = MKCoordinateSpan(latitudeDelta: maxRange, longitudeDelta: maxRange)
            mapView.setRegion(region, animated: true)
        }
    }
    
    /// 경로 디자인 정의 용도
    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
        if let polyline = overlay as? MKPolyline {
            let renderer = MKPolylineRenderer(polyline: polyline)
            renderer.strokeColor = .systemBlue
            renderer.lineWidth = 5
            return renderer
        }
        return MKOverlayRenderer(overlay: overlay)
    }
    
    func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
        guard let annotation = view.annotation as? CustomAnnotation,
              let kickboard = kickboards.first(where: { $0.kickboardCode == annotation.subtitle }) else {
            return
        }
        
        if let coordinate = view.annotation?.coordinate {
            drawRoute(to: coordinate)
        }
        
    
        let detailVC = KickboardDetailViewController()
        detailVC.modalPresentationStyle = .overFullScreen
        if let sheet = detailVC.sheetPresentationController {
            let customDetent = UISheetPresentationController.Detent.custom { _ in
                return 256
            }
            sheet.detents = [customDetent]
            sheet.prefersGrabberVisible = true
            sheet.preferredCornerRadius = 20
        }
        
        manager.currentKickboard=kickboard
        
        present(detailVC, animated: true)
    }
}


extension MKPolyline {
    func coordinates() -> [CLLocationCoordinate2D] {
        var coords = [CLLocationCoordinate2D](repeating: kCLLocationCoordinate2DInvalid, count: pointCount)
        getCoordinates(&coords, range: NSRange(location: 0, length: pointCount))
        return coords
    }
}

 즉, 위 킥보드 대여 시스템은 크게 세 가지 핵심 요소로 설명이 가능합니다.

 

1. 상태 관리

- KickboardManager가 앱의 상태를 중앙 집중식으로 관리

- KickboardStatus enum을 통해 명확한 상태 정의

- @Published 프로퍼티를 통한 상태 변화 알림

2. 요금 계산

- Timer를 이용한 주행 시간 측정

- 시간 기반 요금 계산 (분당 100원)

- 실시간 요금 업데이트

3. 이력 관리

- 대여 완료 시 History 객체 생성

- CoreData를 통한 이력 저장

- Repository 패턴을 통한 데이터 접근 추상화

 

 

7. 사용한 디자인 패턴

7.1 Singleton Pattern

  • KickboardManager
  • CoreDataStack

7.2 Repository Pattern

  • 데이터 접근 추상화
  • CRUD 작업 캡슐화

7.3 Delegate Pattern

  • 화면 간 통신
  • 사용자 인터랙션 처리

7.4 Observer Pattern (Combine)

  • 실시간 상태 업데이트
  • UI 반응형 업데이트

마치며

이 프로젝트를 통해 Clean Architecture의 실제 적용 방법과 장단점을 배울 수 있었습니다. 특히 Repository 패턴과 UseCase 계층을 통해 직접 추상화 및 캡슐화를 경험해보며 Clean Architecture가 추구하는 목표에 대해 보다 구체적으로 이해하게 되어 유익했습니다.

 

읽어주셔서 감사합니다.

 


Reference

https://medium.com/delightroom/%ED%81%B4%EB%A6%B0%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-cleanarchitecture-%EB%8A%94-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EA%B0%9C%EB%B0%9C%EC%97%90-%EB%8F%84%EC%9B%80%EC%9D%B4-%EB%90%98%EB%8A%94%EA%B0%80-1-169296dd5b81

 

클린아키텍처(CleanArchitecture)는 모바일 개발에 도움이 되는가 ? — 1

클린아키텍처란 말은 몇년전에 들었지만, 미루고 미루다 최근에 책을 읽고 많은 감동(?)을 받았던 기억을 되새기며…

medium.com