일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- SwiftUI
- 파노라마 뷰
- 스켈레톤 통합
- launchscreen
- requirenativecomponent
- Android
- ssot
- react-native-fast-image
- 360도 이미지
- privacyinfo.plist
- 360도 이미지 뷰어
- 네이티브
- 360도 뷰어
- 뷰 생명주기
- data driven construct
- panorama view
- 3b52.1
- 리액트 네이티브
- 리액트
- launch screen
- ios
- 뷰 정체성
- 구조적 정체성
- 라이브러리 없이
- react
- native
- React Native
- 앱 성능 개선
- 명시적 정체성
- React-Native
- Today
- Total
Neoself의 기술 블로그
전동 킥보드 대여 서비스 iOS 앱 개발기 본문
이 글을 통해 전동 킥보드 대여 서비스 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 패턴으로 시작했지만, 다음과 같은 문제들이 발생했습니다:
- Controller의 비대화
- 비즈니스 로직의 분산
- 데이터 접근 계층의 불명확성
이러한 문제들을 해결하기 위해 Clean Architecture를 도입했습니다. 모바일 개발환경에서 Clean Architecture는 세 개의 주요 레이어로 구성됩니다:
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) {
// 비즈니스 로직 처리
}
}
- 사용자 입력 → Presentation Layer
- UseCase 실행 → Domain Layer
- 데이터 접근 → Data Layer
- 결과 반환 → 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 메서드를 호출해 출판된 데이터를 직접 구독하는 로직을 구현하였습니다.
위 구조 채택 시, 아래와 같이 특정 뷰 컨트롤러에서 공유 상태값 변경 시, 다른 뷰 컨트롤러에서 이를 감지할 수 있게 됩니다.
- KickboardDetailViewController에서 싱글톤 클래스 내부 메서드 startRiding를 호출해 공유 데이터인 currentStatus 수정
- 싱글톤 클래스에서 상태값 변경내용을 출판
- 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 패턴이 갖고 있는 장점들은 아래와 같습니다.
- 데이터 소스 독립성: CoreData를 다른 저장소로 교체하더라도 애플리케이션의 다른 부분은 수정할 필요가 없기에, Repository 프로토콜만 준수하면 어떤 구현체로든 교체 가능(CoreData -> UserDefaults로의 교체)
- 테스트 용이성: Repository 프로토콜을 Mock 객체로 구현하여 단위 테스트를 할수 있게 됩니다.
- 관심사 분리: 데이터 접근 로직이 Repository에 캡슐화되며, ViewController나 UseCase는 데이터 저장소의 구체적인 구현을 알 필요가 없어집니다. 이는 코드 가독성과 유지보수성 향상과도 이어집니다.
- 코드 재사용: 동일한 데이터 접근 로직을 여러 곳에서 재사용 가능
4. UseCase 패턴
UseCase는 Clean Architecture의 핵심 요소로써 비즈니스 로직을 캡슐화하는 데 사용했습니다. 각각의 UseCase는 단일 책임 원칙에 따라 특정 기능의 비즈니스 로직을 독립적으로 처리하며, 이를 통해 코드의 재사용성과 테스트 용이성을 확보했습니다.
이때 모든 UseCase에 Input/Output 패턴을 적용하여, 각 UseCase의 입력과 출력 타입을 명확하게 정의했습니다. 이는 다음과 같은 이점을 제공합니다:
- 컴파일 타임에 타입 안정성을 보장하여 런타임 에러를 사전에 방지
- 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
'개발지식 정리 > Swift' 카테고리의 다른 글
RxSwift 정리 (0) | 2025.01.06 |
---|---|
CoreData를 활용한 오프라인 동기화 시스템 구축하기 (1) | 2024.12.30 |
WidgetKit 활용해 캘린더 위젯 구현하기 (0) | 2024.12.24 |
SwiftUI 심층정리 (0) | 2024.12.16 |
Combine에서 Swift Concurrency로의 전환기 (1) | 2024.12.05 |