Repository: ESTSOFT-iOS-01/RunLog
Pull request #68: [feature] 운동데이터 연결, 날씨, 대기질 연결
Author: login: Syrbear
Labels: name: ✨ feature
Created: 2025-03-21T03:50:58Z | Merged: 2025-03-21T05:15:00Z

<!-- PR 제목 컨벤션: [이슈 라벨] 작업한 내용 요약 -->

## 💡 PR 유형
<!-- 해당하는 유형에 "x"를 입력하세요. -->
- [x] Feature: 기능 추가
- [ ] Hotfix: 작은 버그 수정
- [ ] Bugfix: 큰 버그 수정
- [ ] Refactor: 코드 개선
- [ ] Chore: 환경 설정

## ✏️ 변경 사항
<!-- 이 PR에서 작업한 내용을 간단히 요약해주세요. -->
운동 관련 데이터 연결 및 걸음 수, 거리, 시간 측정 완료
날씨 정보, 대기질 정보 받아옴

## 🚨 관련 이슈
<!-- 관련된 이슈 번호를 적어주세요. 여러 개인 경우 쉼표로 구분하세요. -->
- close #47 

## 🧪 테스트
<!-- 이 PR에서 테스트한 내용을 설명해주세요. -->
- [x] 목표한 구현 정상 동작 확인

## 🎨 스크린샷
<!-- UI 변경사항이 있는 경우 스크린샷을 첨부해주세요. -->
<!-- img src "이부분에 gif파일 넣어주세요" -->
|기능|스크린샷|
|:--:|:--:|
|경로 라인|<img src = "https://github.com/user-attachments/assets/a36aad02-bebd-4d6c-b4bd-eff0caa41b64" width ="250">|
|데이터 측정|<img src = "https://github.com/user-attachments/assets/694daf44-cb89-4519-9c50-7a913756bb1d" width ="250">|
|종료시 데이터 확인|<img src = "https://github.com/user-attachments/assets/023f5902-8e38-4c0c-9631-8dc9cfd87ec7" width ="250">|

## ✅ 체크리스트
<!-- 꼭 모두 체크하고 PR을 생성해주세요. -->
- [x] 코드/커밋이 정해진 컨벤션을 잘 따르고 있나요?
- [x] PR의 Assignees와 Reviewers를 설정했나요?
- [x] 불필요한 코드가 없고, 정상적으로 동작하는지 확인했나요?
- [x] 관련 이슈 번호를 작성했나요?

## 🔥 추가 설명
<!-- 리뷰어가 알아야 할 추가적인 정보가 있다면 여기에 적어주세요. -->
<!-- 코드 리뷰를 받고 싶은 코드나, 설명하고 싶은 코드가 있다면 적어주세요. -->
해당 코드로 실제로 측정해서 데이터를 확인해야할 거 같습니다
이후 saveLog불러오는 쪽에서 데이터 저장하면될 것같습니다

Linked issue #47: [feature] 운동데이터 연결, 날씨, 대기질 연결
<!-- PR 제목 컨벤션: [이슈 라벨] 작업한 내용 요약 -->

## 💡 Issue
<!-- 이슈에 대해 간단하게 설명해 주세요 -->
운동 정보들 실제로 받아오기 및 날씨 관련 데이터 연결

## 🔥 Tasks
<!-- 해야 할 작업들을 나열해 주세요 -->
- 걸음 수 데이터 연결
- 거리 데이터 연결
- WeatherKit API 연결
- openWeatherMap API 연결 

## 🎨 스크린샷(선택)
<!-- img src "이부분에 gif파일 넣어주세요" -->
|기능|스크린샷|
|:--:|:--:|
|GIF|<img src = "" width ="250">|

Linked issue comments: user: login: github-actions[bot]
created_at: 2025-03-19T01:15:38Z
body: Branch feature/#47 created for issue: [feature] 운동데이터 연결, 날씨, 대기질 연결
author_association: NONE
user: login: Syrbear
created_at: 2025-03-19T23:39:26Z
body: WeatherKit 대신 OpenWeather에서 날씨 받아오기로 결정
author_association: CONTRIBUTOR

Diff:
diff --git a/RunLog/RunLog.xcodeproj/project.pbxproj b/RunLog/RunLog.xcodeproj/project.pbxproj
index 494917e..61d0ad9 100644
--- a/RunLog/RunLog.xcodeproj/project.pbxproj
+++ b/RunLog/RunLog.xcodeproj/project.pbxproj
@@ -11,6 +11,7 @@
 		16F688C52D82972900C2163F /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 16F688C42D82972900C2163F /* Then */; };
 		16F688C82D82974E00C2163F /* CombineMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 16F688C72D82974E00C2163F /* CombineMoya */; };
 		16F688CA2D82974E00C2163F /* Moya in Frameworks */ = {isa = PBXBuildFile; productRef = 16F688C92D82974E00C2163F /* Moya */; };
+		5EB036452D8A57120030DA8F /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 5EB036442D8A57120030DA8F /* Secrets.xcconfig */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -26,6 +27,7 @@
 /* Begin PBXFileReference section */
 		16811EF62D8A9E520013461D /* RpTest.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RpTest.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		16F686B72D827D6C00C2163F /* RunLog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RunLog.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		5EB036442D8A57120030DA8F /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -85,6 +87,7 @@
 		16F686AE2D827D6C00C2163F = {
 			isa = PBXGroup;
 			children = (
+				5EB036442D8A57120030DA8F /* Secrets.xcconfig */,
 				16F686B92D827D6C00C2163F /* RunLog */,
 				16811EF72D8A9E520013461D /* RpTest */,
 				16F686B82D827D6C00C2163F /* Products */,
@@ -213,6 +216,14 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		16F686B52D827D6C00C2163F /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				5EB036452D8A57120030DA8F /* Secrets.xcconfig in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXResourcesBuildPhase section */
 
 /* Begin PBXSourcesBuildPhase section */
@@ -281,6 +292,7 @@
 		};
 		16F686CB2D827D6F00C2163F /* Debug */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 5EB036442D8A57120030DA8F /* Secrets.xcconfig */;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -292,6 +304,7 @@
 				INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "운동 거리 기록 및 시각화 정보 제공을 위한 위치 권한을 항상 혹은 앱 활성 시에만 허용하시겠습니까?";
 				INFOPLIST_KEY_NSLocationAlwaysUsageDescription = "운동 거리 기록 및 시각화 정보 제공을 위한 위치 권한을 항상 허용하시겠습니까?";
 				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "운동 거리 기록 및 시각화 정보 제공을 위한 위치 권한을 앱 활성 시에만 허용하시겠습니까?";
+				INFOPLIST_KEY_NSMotionUsageDescription = "이 앱은 걸음 수를 측정하여 운동 데이터를 제공합니다.";
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchStoryboardName = "";
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -317,6 +330,7 @@
 		};
 		16F686CC2D827D6F00C2163F /* Release */ = {
 			isa = XCBuildConfiguration;
+			baseConfigurationReference = 5EB036442D8A57120030DA8F /* Secrets.xcconfig */;
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -328,6 +342,7 @@
 				INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "운동 거리 기록 및 시각화 정보 제공을 위한 위치 권한을 항상 혹은 앱 활성 시에만 허용하시겠습니까?";
 				INFOPLIST_KEY_NSLocationAlwaysUsageDescription = "운동 거리 기록 및 시각화 정보 제공을 위한 위치 권한을 항상 허용하시겠습니까?";
 				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "운동 거리 기록 및 시각화 정보 제공을 위한 위치 권한을 앱 활성 시에만 허용하시겠습니까?";
+				INFOPLIST_KEY_NSMotionUsageDescription = "이 앱은 걸음 수를 측정하여 운동 데이터를 제공합니다.";
 				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
 				INFOPLIST_KEY_UILaunchStoryboardName = "";
 				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
diff --git a/RunLog/RunLog/Info.plist b/RunLog/RunLog/Info.plist
index b92b905..0ffcd92 100644
--- a/RunLog/RunLog/Info.plist
+++ b/RunLog/RunLog/Info.plist
@@ -2,6 +2,8 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<key>API_KEY</key>
+	<string>$(API_KEY)</string>
 	<key>NSAppTransportSecurity</key>
 	<dict>
 		<key>NSAllowsArbitraryLoads</key>
diff --git a/RunLog/RunLog/Sources/Data/DistanceManager.swift b/RunLog/RunLog/Sources/Data/DistanceManager.swift
new file mode 100644
index 0000000..c1dc2f6
--- /dev/null
+++ b/RunLog/RunLog/Sources/Data/DistanceManager.swift
@@ -0,0 +1,56 @@
+//
+//  DistanceManager.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/21/25.
+//
+
+import Foundation
+import Combine
+import CoreLocation
+
+final class DistanceManager {
+    // MARK: - Singleton
+    static let shared = DistanceManager()
+    // MARK: - Input & Output
+    enum Input {
+        case requestDistance(CLLocation, CLLocation)
+    }
+    enum Output {
+        case responseDistance(Double)
+    }
+    let input = PassthroughSubject<Input, Never>()
+    let output = PassthroughSubject<Output, Never>()
+    // MARK: - Properties
+    private var cancellables = Set<AnyCancellable>()
+    // MARK: - Init
+    private init() {
+        bind()
+    }
+    // MARK: - Bind (Input -> Output)
+    private func bind() {
+        self.input
+            .sink { [weak self] input in
+                switch input {
+                case .requestDistance(let location, let previous):
+                    self?.calculateDistance(
+                        location: location,
+                        previous: previous
+                    )
+                }
+                
+            }
+            .store(in: &cancellables)
+    }
+}
+// MARK: - 거리 계산
+extension DistanceManager {
+    // 이동 거리를 측정해서 output으로 send
+    private func calculateDistance(location: CLLocation, previous: CLLocation) {
+        let distance = location.distance(from: previous)
+        if distance >= 1 {
+            output.send(.responseDistance(distance))
+        }
+    }
+}
+
diff --git a/RunLog/RunLog/Sources/Data/LocationManager.swift b/RunLog/RunLog/Sources/Data/LocationManager.swift
new file mode 100644
index 0000000..853d509
--- /dev/null
+++ b/RunLog/RunLog/Sources/Data/LocationManager.swift
@@ -0,0 +1,129 @@
+//
+//  LocationManager.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/15/25.
+//
+
+import Foundation
+import UIKit
+import MapKit
+import CoreLocation
+import Combine
+
+final class LocationManager: NSObject, CLLocationManagerDelegate {
+    // MARK: - Singleton
+    static let shared = LocationManager()
+    // MARK: - Properties
+    private var locationManager = CLLocationManager()
+    private var cancellables = Set<AnyCancellable>()
+    var currentLocation: CLLocation {
+        // 현재 위치를 반환, 못찾으면 서울을 기본값으로 반환
+        return locationManager.location ?? CLLocation(latitude: 37.5665, longitude: 126.9780)
+    }
+    // GPS 값 검증을 위한 이전 위치
+    private var previousLocation: CLLocation?
+    // MARK: - Subjects
+    private let locationSubject = PassthroughSubject<CLLocation, Never>()
+    private let locationNameSubject = PassthroughSubject<(CLLocation,CLPlacemark), Never>()
+    // MARK: - Publishers
+    var locationPublisher: AnyPublisher<CLLocation, Never> {
+        locationSubject.eraseToAnyPublisher()
+    }
+    var locationNamePublisher: AnyPublisher<(CLLocation,CLPlacemark), Never> {
+        locationNameSubject.eraseToAnyPublisher()
+    }
+    // MARK: - Init
+    private override init() {
+        super.init()
+        setupLocationManager()
+    }
+    deinit {
+        locationManager.stopUpdatingLocation()
+    }
+    // MARK: - CLLocationManager 설정
+    private func setupLocationManager() {
+        locationManager.delegate = self
+        locationManager.desiredAccuracy = kCLLocationAccuracyBest
+        locationManager.distanceFilter = 5  // Q) 지피티 피셜 걷기+달리기면 5m가 적당하다 - 실제로 3, 5로 해서 측정해보고 결정
+        locationManager.allowsBackgroundLocationUpdates = true // 백그라운드 상태에서도 위치 업데이트
+        locationManager.pausesLocationUpdatesAutomatically = true //사용자가 멈춰있으면 업데이트 일시정지
+        getLocationUsagePermission()
+    }
+    // MARK: - 사용자가 위치를 이동하면 위치를 뿌려줌
+    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
+        guard let location = locations.last else { return }
+        // GPS 신호가 불안정한 경우 필터링
+        if previousLocation != nil && (location.horizontalAccuracy < 0 || location.horizontalAccuracy > 10) {
+            print("GPS 신호 불안정 - 위치 무시")
+            return
+        }
+        // 이전 위치와 비교하여 1m 이하 이동 시 무시
+        if let previous = previousLocation, location.distance(from: previous) < 1 {
+            print("이동 거리 1m 이하 - 위치 업데이트 안함")
+            return
+        }
+        self.previousLocation = location
+        self.fetchCityName(location: location)
+        self.locationSubject.send(location)
+    }
+}
+// MARK: - 도시명 가져오기
+extension LocationManager {
+    // MARK: - 도시명 가져오기
+    private func fetchCityName(location: CLLocation) {
+        let geocoder = CLGeocoder()
+        geocoder.reverseGeocodeLocation(location) {
+            [weak self] placemarks, error in
+            guard let self = self else { return }
+            guard let placemark = placemarks?.first else {
+                print("Geocoding 실패: \(error!.localizedDescription)")
+                return
+            }
+            self.locationNameSubject.send((location, placemark))
+        }
+    }
+}
+// MARK: - 위치 정보 권한 요청
+extension LocationManager {
+    // MARK: - 권한 정보가 바뀌면 실행
+    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
+        getLocationUsagePermission()
+    }
+    // MARK: - 권한 정보에 따른 분기 처리
+    private func getLocationUsagePermission() {
+        let status = locationManager.authorizationStatus
+        switch status {
+        case .notDetermined: // 허용 안한 상태
+            locationManager.requestWhenInUseAuthorization()
+        case .authorizedWhenInUse: // 앱을 사용동안 허용
+            locationManager.requestAlwaysAuthorization()
+        case .restricted, .denied: // 거부 상태
+            print("위치 권한이 거부됨 - 설정에서 변경 필요")
+            openAppSettings()
+        case .authorizedAlways: // 항상 허용
+            locationManager.startUpdatingLocation()
+        @unknown default:
+            return
+        }
+    }
+    // MARK: - 앱 설정 열기
+    private func openAppSettings() {
+        guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+        if UIApplication.shared.canOpenURL(url) {
+            UIApplication.shared.open(url, options: [:], completionHandler: nil)
+        }
+    }
+}
+
+
+
+
+// MARK: - 테스트용 더미 셋
+extension LocationManager {
+    func setDummy(location: CLLocation) {
+        self.previousLocation = location
+        self.fetchCityName(location: location)
+        self.locationSubject.send(location)
+    }
+}
diff --git a/RunLog/RunLog/Sources/Data/Network/API.swift b/RunLog/RunLog/Sources/Data/Network/API.swift
new file mode 100644
index 0000000..0ce7359
--- /dev/null
+++ b/RunLog/RunLog/Sources/Data/Network/API.swift
@@ -0,0 +1,12 @@
+//
+//  API.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/19/25.
+//
+
+import Foundation
+
+enum API {
+    static let openWeatherURL = "https://api.openweathermap.org/data/2.5"
+}
diff --git a/RunLog/RunLog/Sources/Data/Network/OpenWeatherEndpoint.swift b/RunLog/RunLog/Sources/Data/Network/OpenWeatherEndpoint.swift
new file mode 100644
index 0000000..6ad00a9
--- /dev/null
+++ b/RunLog/RunLog/Sources/Data/Network/OpenWeatherEndpoint.swift
@@ -0,0 +1,65 @@
+//
+//  OpenWeatherEndpoint.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/20/25.
+//
+
+import Foundation
+import Moya
+
+enum OpenWeatherEndpoint {
+    case airQuality(lat: Double, lon: Double)
+    case weather(lat: Double, lon: Double)
+}
+extension OpenWeatherEndpoint: TargetType {
+    // MARK: - 기본 도메인
+    public var baseURL: URL {
+        guard let url = URL(string: API.openWeatherURL) else {
+            fatalError("잘못된 URL")
+        }
+        return url
+    }
+    // MARK: - 도메인 하위 상세 주소 정의
+    public var path: String {
+        switch self {
+        case .airQuality:
+            return "/air_pollution"
+        case .weather:
+            return "/weather"
+        }
+    }
+    // MARK: - 메서드 방식
+    public var method: Moya.Method {
+        return .get
+    }
+    // MARK: - 파라미터
+    public var task: Moya.Task {
+        switch self {
+        case .airQuality(let lat, let lon):
+            return .requestParameters(
+                parameters: [
+                    "lat": lat,
+                    "lon": lon,
+                    "appid": Bundle.main.weatherKey ?? ""
+                ],
+                encoding: URLEncoding.queryString)
+        case .weather(let lat, let lon):
+            return .requestParameters(
+                parameters: [
+                    "lat": lat,
+                    "lon": lon,
+                    "appid": Bundle.main.weatherKey ?? "",
+                    "units": "metric",
+                    "lang": "kr"
+                ],
+                encoding: URLEncoding.queryString)
+        }
+    }
+    // MARK: - 헤더, 보통 비슷하게 사용
+    public var headers: [String : String]? {
+        return [
+            "Content-Type": "application/json"
+        ]
+    }
+}
diff --git a/RunLog/RunLog/Sources/Data/Network/OpenWeatherService.swift b/RunLog/RunLog/Sources/Data/Network/OpenWeatherService.swift
new file mode 100644
index 0000000..3eadc6c
--- /dev/null
+++ b/RunLog/RunLog/Sources/Data/Network/OpenWeatherService.swift
@@ -0,0 +1,125 @@
+//
+//  OpenWeatherService.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/19/25.
+//
+
+import Foundation
+import Moya
+import Combine
+import CombineMoya
+import MapKit
+
+// MARK: - OpenWeather Current Weather Response 형식
+struct WeatherResponse: Codable {
+    struct Main: Codable {
+        let temp: Double
+    }
+    struct Weather: Codable {
+        let description: String
+    }
+    
+    let main: Main
+    let weather: [Weather]
+}
+// MARK: - OpenWeather AQI Response 형식
+struct AQIResponse: Codable {
+    struct AQIList: Codable {
+        struct Main: Codable {
+            let aqi: Int
+        }
+        let main: Main
+    }
+    let list: [AQIList]
+}
+// MARK: - API 요청을 담당
+final class OpenWeatherService: NetworkService {
+    static let shared = OpenWeatherService()
+    // MARK: - Input & Output
+    enum Input {
+        case requestUpdate(CLLocation)
+    }
+    let input = PassthroughSubject<Input, Never>()
+    private var cancellables = Set<AnyCancellable>()
+    // MARK: - Subject
+    private let weatherUpdateSubject = PassthroughSubject<(String, Double), Never>()
+    private let aqiUpdateSubject = PassthroughSubject<Int, Never>()
+    // MARK: - Publisher
+    var weatherUpdatePublisher: AnyPublisher<(String, Double), Never> {
+        weatherUpdateSubject.eraseToAnyPublisher()
+    }
+    var aqiUpdatePublisher: AnyPublisher<Int, Never> {
+        aqiUpdateSubject.eraseToAnyPublisher()
+    }
+    // MARK: - Properties
+    let provider = MoyaProvider<OpenWeatherEndpoint>()
+    
+    private init() {
+        bind()
+    }
+    // MARK: - Bind
+    private func bind() {
+        self.input
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] input in
+                guard let self = self else { return }
+                // 날씨 및 대기질 요청 받으면 두가지 실행 -> 결과는 subject로 뿌림
+                switch input {
+                case .requestUpdate(let location):
+                    self.fetchWeatherData(location: location)
+                    self.fetchAqiData(location: location)
+                }
+            }
+            .store(in: &cancellables)
+    }
+}
+
+// MARK: - 날씨 받아오기
+extension OpenWeatherService {
+    // MARK: - openWeather로 날씨를 받아옴
+    private func fetchWeatherData(location: CLLocation) {
+        self.fetchWeather(
+            lat: location.coordinate.latitude,
+            lon: location.coordinate.longitude
+        )
+        .sink { completion in
+            if case let .failure(error) = completion {
+                print("데이터 요청 실패: \(error.errorMessage)")
+            }
+        } receiveValue: { response in
+            let temperature: Double = response.main.temp
+            let condition: String = response.weather.first?.description ?? "알 수 없음"
+            self.weatherUpdateSubject.send((condition, temperature))
+        }
+        .store(in: &cancellables)
+    }
+    private func fetchWeather(lat: Double, lon: Double) -> AnyPublisher<WeatherResponse, NetworkError> {
+        return request(.weather(lat: lat, lon: lon), responseType: WeatherResponse.self)
+    }
+}
+
+// MARK: - 대기질 받아오기
+extension OpenWeatherService {
+    // MARK: - openWeather로 대기질을 받아옴
+    private func fetchAqiData(location: CLLocation) {
+        self.fetchAqi(
+            lat: location.coordinate.latitude,
+            lon: location.coordinate.longitude
+        )
+        .sink { completion in
+            if case let .failure(error) = completion {
+                print("데이터 요청 실패: \(error.errorMessage)")
+                self.aqiUpdateSubject.send(-1)
+            }
+        } receiveValue: { response in
+            let aqi = response.list.first?.main.aqi ?? 3 // 기본값 '나쁨(3)' 설정
+            print("현재 Aqi 값: \(aqi)")
+            self.aqiUpdateSubject.send(aqi)
+        }
+        .store(in: &cancellables)
+    }
+    private func fetchAqi(lat: Double, lon: Double) -> AnyPublisher<AQIResponse, NetworkError> {
+        return request(.airQuality(lat: lat, lon: lon), responseType: AQIResponse.self)
+    }
+}
diff --git a/RunLog/RunLog/Sources/Data/PedometerManager.swift b/RunLog/RunLog/Sources/Data/PedometerManager.swift
new file mode 100644
index 0000000..1790757
--- /dev/null
+++ b/RunLog/RunLog/Sources/Data/PedometerManager.swift
@@ -0,0 +1,64 @@
+//
+//  PedometerManager.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/19/25.
+//
+
+import Foundation
+import Combine
+import CoreMotion
+
+final class PedometerManager {
+    // MARK: - Input & Output
+    enum Input {
+        case startPedometer
+        case stopPedometer
+    }
+    let input = PassthroughSubject<Input, Never>()
+    private var cancellables = Set<AnyCancellable>()
+    private let pedometerSubject = PassthroughSubject<Int, Never>()
+    var pedometerPublisher: AnyPublisher<Int, Never> {
+        pedometerSubject.eraseToAnyPublisher()
+    }
+    // MARK: - Singleton
+    static let shared = PedometerManager()
+    // MARK: - Propertise
+    private let pedometer = CMPedometer()
+    // MARK: - Init
+    private init() {
+        bind()
+    }
+    // MARK: - Bind
+    private func bind() {
+        self.input
+            .sink { [weak self] input in
+                switch input {
+                case .startPedometer:
+                    self?.startPedometerUpdate()
+                case .stopPedometer:
+                    self?.stopPedometerUpdate()
+                }
+            }
+            .store(in: &cancellables)
+    }
+    // MARK: - 걸음 수 측정 시작
+    func startPedometerUpdate() {
+        guard CMPedometer.isStepCountingAvailable() else {
+            print("측정 불가 기기")
+            return
+        }
+        pedometer.startUpdates(from: Date()) { [weak self] data, error in
+            guard let self = self,
+                  let data = data,
+                  error == nil
+            else { return }
+            let stepCount = data.numberOfSteps.intValue
+            self.pedometerSubject.send(stepCount)
+        }
+    }
+    // MARK: - 걸음 수 측정 종료
+    func stopPedometerUpdate() {
+        pedometer.stopUpdates()
+    }
+}
diff --git a/RunLog/RunLog/Sources/Presentation/Common/Dummy/Dummy.swift b/RunLog/RunLog/Sources/Presentation/Common/Dummy/Dummy.swift
new file mode 100644
index 0000000..f3b8b2a
--- /dev/null
+++ b/RunLog/RunLog/Sources/Presentation/Common/Dummy/Dummy.swift
@@ -0,0 +1,58 @@
+import CoreLocation
+
+struct DummyLocation {
+    static let route: [CLLocation] = {
+        let center = CLLocationCoordinate2D(latitude: 37.554722, longitude: 126.970833) // 원의 중심 (서울역 근처)
+        let radius: Double = 0.00135 // 150m (위도/경도 변환값)
+        let totalPoints = 50 // 100개 좌표
+        
+        var locations: [CLLocation] = []
+        
+        for i in 0..<totalPoints {
+            let angle = Double(i) / Double(totalPoints) * 2 * .pi // 0 ~ 2π (360도)
+            let latOffset = radius * cos(angle) // 원 형태의 위도 변동
+            let lonOffset = radius * sin(angle) // 원 형태의 경도 변동
+            
+            let newLat = center.latitude + latOffset
+            let newLon = center.longitude + lonOffset
+            
+            locations.append(CLLocation(latitude: newLat, longitude: newLon))
+        }
+        locations.append(locations.first!)
+        return locations
+    }()
+    
+    
+    
+    static func distanceCheck() {
+        let preLocation = LocationManager.shared.currentLocation
+        // 거리 변환값 (각 거리(m)를 위도/경도 값으로 변환)
+        let metersToLatitude = 1.0 / 111_000.0 // 위도 기준 1m당 변환값
+        let metersToLongitude = 1.0 / 88_000.0  // 서울 기준 경도 1m당 변환값
+        
+        // 새로운 위치들 계산
+        let currentLocation100m = CLLocation(
+            latitude: preLocation.coordinate.latitude + (100 * metersToLatitude),
+            longitude: preLocation.coordinate.longitude + (100 * metersToLongitude)
+        )
+        
+        let currentLocation540m = CLLocation(
+            latitude: preLocation.coordinate.latitude + (540 * metersToLatitude),
+            longitude: preLocation.coordinate.longitude + (540 * metersToLongitude)
+        )
+        
+        let currentLocation2400m = CLLocation(
+            latitude: preLocation.coordinate.latitude + (2400 * metersToLatitude),
+            longitude: preLocation.coordinate.longitude + (2400 * metersToLongitude)
+        )
+        DistanceManager.shared.input.send(.requestDistance(currentLocation2400m, preLocation))
+    }
+    
+    static func lineCheck() {
+        for (index, rou) in self.route.enumerated() {
+            DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 2) {
+                LocationManager.shared.setDummy(location: rou)
+            }
+        }
+    }
+}
diff --git a/RunLog/RunLog/Sources/Presentation/Common/ViewModel/LocationManager.swift b/RunLog/RunLog/Sources/Presentation/Common/ViewModel/LocationManager.swift
deleted file mode 100644
index 96db186..0000000
--- a/RunLog/RunLog/Sources/Presentation/Common/ViewModel/LocationManager.swift
+++ /dev/null
@@ -1,189 +0,0 @@
-//
-//  LocationManager.swift
-//  RunLog
-//
-//  Created by 심근웅 on 3/15/25.
-//
-
-import Foundation
-import UIKit
-import MapKit
-import WeatherKit
-import CoreLocation
-import Combine
-
-// MARK: - WeatherData (날씨 + 대기질 정보)
-struct WeatherData {
-    let temperature: Int
-    let condition: WeatherCondition
-    let airQuality: Int
-}
-struct DummyWeather {
-    let temperature: Int
-    let condition: WeatherCondition
-    //    let aqi: Int
-}
-// MARK: - 더미 데이터 (Air Quality)
-struct DummyAirQuality {
-    let aqi: Int
-}
-final class LocationManager: NSObject, CLLocationManagerDelegate {
-    // MARK: - Dummy
-    private var dummyIndex = 0
-    private var timer: Timer?
-    func startDummyLocationUpdates() {
-        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
-            guard let self = self else { return }
-            guard dummyIndex < DummyLocation.route.count else {
-                self.timer?.invalidate()
-                return
-            }
-            let dummyLocation = DummyLocation.route[self.dummyIndex]
-            self.dummyIndex += 1
-            self.locationSubject.send(dummyLocation) // ViewModel로 위치 데이터 전송
-        }
-    }
-    
-    // MARK: - Singleton
-    static let shared = LocationManager()
-    // MARK: - Property
-    private var locationManager = CLLocationManager()
-    private let weatherService = WeatherService()
-    var currentLocation: CLLocation {
-        // 현재 위치를 반환, 못찾으면 서울을 기본값으로 반환
-//        return dummyLocation
-        return locationManager.location ?? CLLocation(latitude: 37.5665, longitude: 126.9780)
-    }
-    
-    // MARK: - Combine
-    private let locationSubject = PassthroughSubject<CLLocation, Never>()
-    private let locationNameSubject = PassthroughSubject<CLPlacemark, Never>()
-    private let weatherSubject = PassthroughSubject<WeatherData, Never>()
-    var locationPublisher: AnyPublisher<CLLocation, Never> {
-        locationSubject.eraseToAnyPublisher()
-    }
-    var locationNamePublisher: AnyPublisher<CLPlacemark, Never> {
-        locationNameSubject.eraseToAnyPublisher()
-    }
-    var weatherPublisher: AnyPublisher<WeatherData, Never> {
-        weatherSubject.eraseToAnyPublisher()
-    }
-    // MARK: - Init
-    private override init() {
-        super.init()
-        setupLocationManager()
-    }
-    deinit {
-        timer?.invalidate()
-        locationManager.stopUpdatingLocation()
-    }
-    // MARK: - CLLocationManager 설정
-    private func setupLocationManager() {
-        locationManager.delegate = self
-        locationManager.desiredAccuracy = kCLLocationAccuracyBest
-        // 배터리를 아낄려면 kCLLocationAccuracyHundredMeters를 이용 - 정확도를 조절
-        locationManager.distanceFilter = 100  //100미터를 이동하면 다시 업데이트
-        locationManager.allowsBackgroundLocationUpdates = true
-//        locationManager.pausesLocationUpdatesAutomatically = false // 이동이 없으면 업데이트를 멈출지
-        getLocationUsagePermission()
-    }
-    // MARK: - 이동하면 위치를 받아 ViewModel에 input넣음
-    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
-        guard let location = locations.last else { return }
-        print("현재 위치: \(location.coordinate.latitude), \(location.coordinate.longitude)")
-//        self.locationSubject.send(location) // 더미 지우고 여기 풀면 현재 위치 기준으로 작성
-        fetchCityName(location: location)
-        fetchWeatherData(location: location)
-    }
-    // MARK: - 도시명 가져오기
-    private func fetchCityName(location: CLLocation) {
-        let geocoder = CLGeocoder()
-        geocoder.reverseGeocodeLocation(location) {
-            [weak self] placemarks, error in
-            guard let self = self else { return }
-            guard let placemark = placemarks?.first else {
-                print("Geocoding 실패: \(error!.localizedDescription)")
-                return
-            }
-            self.locationNameSubject.send(placemark)
-        }
-    }
-    // MARK: - 날씨 데이터 가져오기
-    private func fetchWeatherData(location: CLLocation) {
-        Task {
-            async let weather = fetchWeatherKitData(location: location)
-            async let aqi = fetchOpenWeatherData(location: location)
-            
-            let weatherData = await WeatherData(
-                temperature: weather.temperature,
-                condition: weather.condition,
-                airQuality: aqi.aqi
-            )
-            self.weatherSubject.send(weatherData)
-        }
-    }
-    // MARK: - weatherKit으로 날씨를 받아옴
-    private func fetchWeatherKitData(location: CLLocation) async -> DummyWeather {
-        return DummyWeather(temperature: Int.random(in: -10...35), condition: .clear)
-    }
-    // MARK: - openWeatherMap으로 대기질을 받아옴
-    private func fetchOpenWeatherData(location: CLLocation) async -> DummyAirQuality {
-        return DummyAirQuality(aqi: Int.random(in: 1...5))
-    }
-}
-
-extension LocationManager {
-    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
-        getLocationUsagePermission()
-    }
-    private func getLocationUsagePermission() {
-        let status = locationManager.authorizationStatus
-        switch status {
-        case .notDetermined: // 허용 안한 상태
-            locationManager.requestWhenInUseAuthorization()
-        case .authorizedWhenInUse: // 앱을 사용동안 허용
-            locationManager.requestAlwaysAuthorization()
-        case .restricted, .denied: // 거부 상태
-            print("위치 권한이 거부됨 - 설정에서 변경 필요")
-            openAppSettings()
-        case .authorizedAlways: // 항상 허용
-            locationManager.startUpdatingLocation()
-        @unknown default:
-            return
-        }
-    }
-    private func openAppSettings() {
-        guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
-        if UIApplication.shared.canOpenURL(url) {
-            UIApplication.shared.open(url, options: [:], completionHandler: nil)
-        }
-    }
-}
-
-
-
-
-
-// -MARK: 더미데이터
-struct DummyLocation {
-    static let route: [CLLocation] = {
-        let center = CLLocationCoordinate2D(latitude: 37.554722, longitude: 126.970833) // 원의 중심 (서울역 근처)
-        let radius: Double = 0.00135 // 150m (위도/경도 변환값)
-        let totalPoints = 20 // 100개 좌표
-        
-        var locations: [CLLocation] = []
-        
-        for i in 0..<totalPoints {
-            let angle = Double(i) / Double(totalPoints) * 2 * .pi // 0 ~ 2π (360도)
-            let latOffset = radius * cos(angle) // 원 형태의 위도 변동
-            let lonOffset = radius * sin(angle) // 원 형태의 경도 변동
-            
-            let newLat = center.latitude + latOffset
-            let newLon = center.longitude + lonOffset
-            
-            locations.append(CLLocation(latitude: newLat, longitude: newLon))
-        }
-        
-        return locations
-    }()
-}
diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/CardElementView.swift b/RunLog/RunLog/Sources/Presentation/Run/View/CardElementView.swift
index dcabcb0..85cbfd9 100644
--- a/RunLog/RunLog/Sources/Presentation/Run/View/CardElementView.swift
+++ b/RunLog/RunLog/Sources/Presentation/Run/View/CardElementView.swift
@@ -10,7 +10,6 @@ import SnapKit
 import Then
 
 final class CardElementView: UIView {
-    // record로 안받고 value로 값만 받을려면 Any로 선언해서 configue에서 캐스팅 해줘도 될듯?
     enum ElementType: String {
         case time = "시간"
         case distance = "거리"
@@ -39,9 +38,9 @@ final class CardElementView: UIView {
         }
     }
     // MARK: - UI Components 선언
-    var type: ElementType
-    var title = UILabel()
-    var value = UILabel()
+    private var type: ElementType
+    private var title = UILabel()
+    private var value = UILabel()
     // MARK: - Init
     init(type: ElementType) {
         self.type = type
diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/CardView.swift b/RunLog/RunLog/Sources/Presentation/Run/View/CardView.swift
index 8fbfa8f..20f5e52 100644
--- a/RunLog/RunLog/Sources/Presentation/Run/View/CardView.swift
+++ b/RunLog/RunLog/Sources/Presentation/Run/View/CardView.swift
@@ -67,7 +67,7 @@ final class CardView: UIView {
     private func configure() {
         // 뷰 설정
         timeLabel.setConfigure(text: "00 : 00")
-        distanceLabel.setConfigure(text: "0.0km")
+        distanceLabel.setConfigure(text: "0.00km")
         stepsLabel.setConfigure(text: "0")
     }
 }
diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/MapBlurView.swift b/RunLog/RunLog/Sources/Presentation/Run/View/MapBlurView.swift
index 5281ffd..7af85ad 100644
--- a/RunLog/RunLog/Sources/Presentation/Run/View/MapBlurView.swift
+++ b/RunLog/RunLog/Sources/Presentation/Run/View/MapBlurView.swift
@@ -12,16 +12,16 @@ import Then
 final class MapBlurView: UIView {
     
     // MARK: - UI Components 선언
-    var background1 = UIView().then {
+    private var background1 = UIView().then {
         $0.backgroundColor = .black
     }
-    var background2 = UIView().then {
+    private var background2 = UIView().then {
         $0.backgroundColor = .clear
     }
-    var background3 = UIView().then {
+    private var background3 = UIView().then {
         $0.backgroundColor = .black
     }
-    var gradientLayer = CAGradientLayer().then {
+    private var gradientLayer = CAGradientLayer().then {
         $0.type = .radial
     }
     // MARK: - Init
diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/RunHomeViewController.swift b/RunLog/RunLog/Sources/Presentation/Run/View/RunHomeViewController.swift
index f9390f3..ef14e06 100644
--- a/RunLog/RunLog/Sources/Presentation/Run/View/RunHomeViewController.swift
+++ b/RunLog/RunLog/Sources/Presentation/Run/View/RunHomeViewController.swift
@@ -18,29 +18,30 @@ final class RunHomeViewController: UIViewController {
     private var cancellables = Set<AnyCancellable>()
     
     // MARK: - UI
-    lazy var mapView = MKMapView().then {
+    private lazy var mapView = MKMapView().then {
         $0.showsUserLocation = true
     }
-    var totalLabel = UILabel().then {
+    private var totalLabel = UILabel().then {
         $0.numberOfLines = 3
     }
-    var weatherLabel = RLLabel().then {
+    private var weatherLabel = RLLabel().then {
         $0.setImage(image: UIImage(systemName: RLIcon.weather.name))
     }
-    var blurView = MapBlurView()
-    var locationLabel = UILabel()
-    var startButton = RLButton(title: "운동 시작하기", titleColor: .Gray900).then {
+    private var blurView = MapBlurView()
+    private var locationLabel = UILabel()
+    private var startButton = RLButton(
+        title: "운동 시작하기",
+        titleColor: .Gray900
+    ).then {
         $0.clipsToBounds = true
     }
     // MARK: - Init
     init() {
         super.init(nibName: nil, bundle: nil)
     }
-
     required init?(coder: NSCoder) {
         fatalError("init(coder:) has not been implemented")
     }
-    
     // MARK: - Lifecycle
     override func viewDidLoad() {
         super.viewDidLoad()
@@ -51,18 +52,15 @@ final class RunHomeViewController: UIViewController {
         setupData()
         bindViewModel()
     }
-    
     override func viewWillAppear(_ animated: Bool) {
         super.viewWillAppear(animated)
         self.navigationController?.setNavigationBarHidden(false, animated: animated)
         self.mapView.centerToLocation(LocationManager.shared.currentLocation)
     }
-    
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
         self.navigationController?.setNavigationBarHidden(true, animated: false)
     }
-    
     // MARK: - Setup UI
     private func setupUI() {
         // UI 요소 추가
@@ -94,7 +92,6 @@ final class RunHomeViewController: UIViewController {
             $0.centerX.equalToSuperview()
         }
     }
-    
     // MARK: - Setup Navigation Bar
     private func setupNavigationBar() {
         // 네비게이션바 디테일 설정
@@ -110,21 +107,21 @@ final class RunHomeViewController: UIViewController {
     private func bindGesture() {
         // 제스처 추가
         startButton.publisher
+            .receive(on: DispatchQueue.main)
             .sink {
-                print("운동 시작하기 버튼 클릭")
+                // +) 코디네이터를 쓰면 해당 부분들이 전부 거기로 넘어갈듯
+                PedometerManager.shared.input.send(.startPedometer)
                 let vc = RunningViewController()
                 vc.modalPresentationStyle = .fullScreen
                 self.present(vc, animated: false)
             }
             .store(in: &cancellables)
     }
-    
     // MARK: - Setup Data
     private func setupData() {
         // 초기 데이터 로드
-        totalLabelCreate()
+        totalLabelCreate(value: ("올레길", "2.5"))
     }
-
     // MARK: - Bind ViewModel
     private func bindViewModel() {
         viewModel.output
@@ -144,11 +141,11 @@ final class RunHomeViewController: UIViewController {
 }
 // MARK: - private functions
 extension RunHomeViewController {
-    private func totalLabelCreate() {
+    private func totalLabelCreate(value: (String, String)) {
         // 여기서 사용자의 데이터를 받아오면 될듯
         let nickname = "행복한 쿼카러너화이팅"
-        let road = "올레길"
-        let number = "2.5"
+        let road = value.0
+        let number = value.1
         let string: String = "\(nickname) 님은\n지금까지 \(road) \(number)회\n거리만큼 걸었습니다!"
         totalLabel.attributedText = string.styledText(
             highlightText: "\(road) \(number)회",
diff --git a/RunLog/RunLog/Sources/Presentation/Run/View/RunningViewController.swift b/RunLog/RunLog/Sources/Presentation/Run/View/RunningViewController.swift
index d23919a..f673080 100644
--- a/RunLog/RunLog/Sources/Presentation/Run/View/RunningViewController.swift
+++ b/RunLog/RunLog/Sources/Presentation/Run/View/RunningViewController.swift
@@ -15,6 +15,8 @@ final class RunningViewController: UIViewController {
     
     // MARK: - Property
     private let viewModel = RunningViewModel()
+    private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
     private var cancellables = Set<AnyCancellable>()
     // MARK: - UI
     lazy var mapView = MKMapView().then {
@@ -25,11 +27,14 @@ final class RunningViewController: UIViewController {
         $0.showsUserLocation = true
         $0.showsUserTrackingButton = true
         $0.pitchButtonVisibility = .visible
-//        $0.overrideUserInterfaceStyle = .light  밝은 지도 - 궁금해서 넣어봄
     }
-    var cardView = CardView()
-    var foldButton = RLButton().then {
-        $0.configureTitle(title: "닫기", titleColor: .Gray000, font: .RLLabel2)
+    private var cardView = CardView()
+    private var foldButton = RLButton().then {
+        $0.configureTitle(
+            title: "닫기",
+            titleColor: .Gray000,
+            font: .RLLabel2
+        )
         $0.setHeight(32)
         $0.configureRadius(8)
         $0.configureBackgroundColor(.Gray700)
@@ -44,7 +49,7 @@ final class RunningViewController: UIViewController {
         )
         $0.configuration = config
     }
-    var unfoldButton = UIButton().then {
+    private var unfoldButton = UIButton().then {
         $0.backgroundColor = .LightGreen
         $0.layer.cornerRadius = 40
         $0.setImage(UIImage(systemName: RLIcon.unfold.name), for: .normal)
@@ -53,17 +58,23 @@ final class RunningViewController: UIViewController {
         $0.setPreferredSymbolConfiguration(sfConfig, forImageIn: .normal)
         $0.isHidden = true
     }
-    
     // MARK: - Init
     init() {
         super.init(nibName: nil, bundle: nil)
     }
-
     required init?(coder: NSCoder) {
         fatalError("init(coder:) has not been implemented")
     }
-    
-    // MARK: - Lifecycle
+//    // MARK: - 더미 테스트용: 테스트 끝나면지우기
+//    override func viewDidAppear(_ animated: Bool) {
+//        super.viewDidAppear(animated)
+//        // 2초 후 특정 함수 실행
+//        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+////            DummyLocation.distanceCheck()
+////            DummyLocation.lineCheck()
+//        }
+//    }
+    // MARK: - Life Cycle
     override func viewDidLoad() {
         super.viewDidLoad()
         setupUI()
@@ -71,26 +82,20 @@ final class RunningViewController: UIViewController {
         bindGesture()
         setupData()
         bindViewModel()
-        
-        LocationManager.shared.startDummyLocationUpdates()
     }
-    
     override func viewWillAppear(_ animated: Bool) {
         super.viewWillAppear(animated)
         self.navigationController?.setNavigationBarHidden(false, animated: animated)
     }
-    
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
         self.navigationController?.setNavigationBarHidden(true, animated: false)
     }
-    
     // MARK: - Setup UI
     private func setupUI() {
         // UI 요소 추가
         view.backgroundColor = .systemBackground
         view.addSubviews(mapView, cardView, foldButton, unfoldButton)
-        
         // 맵킷
         mapView.snp.makeConstraints {
             $0.top.bottom.leading.trailing.equalToSuperview()
@@ -112,61 +117,70 @@ final class RunningViewController: UIViewController {
             $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(24)
         }
     }
-    
     // MARK: - Setup Navigation Bar
     private func setupNavigationBar() {
         // 네비게이션바 디테일 설정
     }
-
     // MARK: - Bind Gesture
     private func bindGesture() {
-        // 제스처 추가
+        // 종료 버튼 클릭
         cardView.finishButton.publisher
             .sink { [weak self] in
-                
-                self?.dismiss(animated: false)
+                guard let self = self else { return }
+                // 걸음 측정 종료
+                pedometerManager.input.send(.stopPedometer)
+                viewModel.input.send(.runningStop)
+                // +) 세이브 구현 시 saveLog는 지움 -> ViewModel에서 데이터 저장
+                DispatchQueue.main.async {// 현재 단순 확인 용
+                    self.saveLog() // 결과 확인용 alert띄움
+                }
+//                self.dismiss(animated: false)
             }
             .store(in: &cancellables)
+        // 접기 버튼 클릭
         foldButton.publisher
+            .receive(on: DispatchQueue.main)
             .sink { [weak self] in
-                self?.toggleCardView()
+                guard let self = self else { return }
+                self.toggleCardView()
             }
             .store(in: &cancellables)
+        // 펼치기 버튼 클릭
         unfoldButton.publisher
+            .receive(on: DispatchQueue.main)
             .sink { [weak self] in
-                self?.toggleCardView()
+                guard let self = self else { return }
+                self.toggleCardView()
             }
             .store(in: &cancellables)
     }
-    
     // MARK: - Setup Data
     private func setupData() {
         // 맵뷰 초기 데이터 설정
-        let currentLocation = LocationManager.shared.currentLocation
+        let currentLocation = locationManager.currentLocation
         mapView.setUserTrackingMode(.follow, animated: true)
         mapView.centerToLocation(currentLocation)
-        // 뷰가 로드되면 운동이 시작된 상태
-        viewModel.input.send(.runningStart)
         
+        viewModel.input.send(.runningStart)
     }
-
     // MARK: - Bind ViewModel
     private func bindViewModel() {
         viewModel.output
             .receive(on: DispatchQueue.main)
             .sink { [weak self] output in
+                guard let self = self else { return }
                 switch output {
                 case .locationUpdate(let location):
-                    self?.mapView.centerToLocation(location, region: self?.mapView.region)
+                    self.mapView.centerToLocation(location, region: self.mapView.region)
                 case .timerUpdate(let time):
-                    self?.cardView.timeLabel.setConfigure(text: time)
-                case .distanceUpdate:
-                    print("거리 변경")
-                case .stepsUpdate:
-                    print("걸음 수 변경")
+                    self.cardView.timeLabel.setConfigure(text: time)
+                case .distanceUpdate(let distance):
+                    self.cardView.distanceLabel.setConfigure(text: distance)
+                case .stepsUpdate(let step):
+                    self.cardView.stepsLabel.setConfigure(text: step)
                 case .lineDraw(let lineDraw):
-                    print("라인 그리는 중")
-                    self?.mapView.addOverlay(lineDraw)
+//                    print("라인 그리는 중")
+                    self.mapView.addOverlay(lineDraw)
                 }
             }
             .store(in: &cancellables)
@@ -174,18 +188,19 @@ final class RunningViewController: UIViewController {
 }
 
 extension RunningViewController: MKMapViewDelegate {
+    // MARK: - 카드 뷰 접었다 펴기
     private func toggleCardView() {
         self.cardView.isHidden.toggle()
         self.foldButton.isHidden.toggle()
         self.unfoldButton.isHidden.toggle()
     }
-    // 트래킹모드 .none이 되면 시야조정
+    // MARK: - 맵뷰 딜리게이트 함수들
     func mapView(_ mapView: MKMapView, didChange mode: MKUserTrackingMode, animated: Bool) {
         guard let userLocation = mapView.userLocation.location else { return }
-//        if mode == .none {
-//            
-//        }
-        mapView.centerToLocation(userLocation, region: self.mapView.region)
+        if mode == .none {
+            // 지도의 위치로 변경
+            mapView.centerToLocation(userLocation, region: self.mapView.region)
+        }
     }
     func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
         guard let polyLine = overlay as? MKPolyline
@@ -201,3 +216,35 @@ extension RunningViewController: MKMapViewDelegate {
         return renderer
     }
 }
+// MARK: - 외부에서 테스트 할때는 log 확인을 못하니 alert로 띄워서 확인
+// 데이터 저장 부분이 다 되면 지워도 됨
+extension RunningViewController {
+    private func saveLog() {
+        guard let startTime = viewModel.section.route.first?.timestamp,
+              let endTime = viewModel.section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+            """
+            ⏹ 운동 종료 ⏹
+            시작 시간: \(startTime.formattedString(.fullTime))초
+            종료 시간: \(endTime.formattedString(.fullTime))초
+            총 운동 시간: \(totalTime)초
+            최종 경로 핀 수: \(viewModel.section.route.count)
+            최종 걸음 수: \(viewModel.section.steps)
+            """
+        let alert = UIAlertController(
+            title: nil,
+            message: message,
+            preferredStyle: .alert
+        )
+        let okAction = UIAlertAction(title: "확인", style: .default) { _ in
+            self.dismiss(animated: false)
+        }
+        alert.addAction(okAction)
+        self.present(alert,animated: true)
+    }
+}
diff --git a/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift b/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift
index a88a564..7013e32 100644
--- a/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift
+++ b/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift
@@ -8,7 +8,6 @@
 import UIKit
 import Combine
 import MapKit
-import WeatherKit
 
 final class RunHomeViewModel {
     // MARK: - Input & Output
@@ -19,49 +18,75 @@ final class RunHomeViewModel {
     }
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Properties
     private let locationManager = LocationManager.shared
-    
+    private let openWeatherService = OpenWeatherService.shared
+    private var previousCity: String? // 이전 도시명
     // MARK: - Init
     init() {
         bind()
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
-        // 사용자 위치 변경 구독
+        // 사용자 위치 변경 구독 -> location에 맞는 지도 이동
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] locaiton in
-                self?.output.send(.locationUpdate(locaiton))
+            .sink { [weak self] location in
+                guard let self = self else { return }
+                self.output.send(.locationUpdate(location))
             }
             .store(in: &cancellables)
         // 도시명 변경 구독
         locationManager.locationNamePublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] placemark in
-                let city = self?.placemarksToString(placemark) ?? "알 수 없음"
-                self?.output.send(.locationNameUpdate(city))
-            }
-            .store(in: &cancellables)
-        // 날씨 변경 구독
-        locationManager.weatherPublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] weather in
-                print("날씨: \(weather)")
-                let condition = self?.conditionToString(weather.condition) ?? weather.condition.rawValue
-                let temperature = "\(weather.temperature)°C"
-                let aqi = self?.aqiToString(weather.airQuality) ?? "정보 없음"
-                
-                let formattedString = "\(condition) | \(temperature), 미세먼지 \(aqi)"
-                self?.output.send(.weatherUpdate(formattedString))
+            .sink { [weak self] value in
+                guard let self = self else { return }
+                let location = value.0
+                let placemark = value.1
+                let city = self.placemarksToString(placemark)
+                if previousCity != city {
+                    print("날씨정보 업데이트")
+                    self.openWeatherService.input.send(.requestUpdate(location))
+                    self.output.send(.locationNameUpdate(city))
+                }
+                previousCity = city
             }
             .store(in: &cancellables)
+        // 날씨 및 대기질 변경 구독
+        Publishers.Zip(
+            openWeatherService.weatherUpdatePublisher,
+            openWeatherService.aqiUpdatePublisher
+        )
+        .sink { [weak self] weather, aqi in
+            guard let self = self else { return }
+            let condition = weather.0
+            let temperature = weather.1.toString(withDecimal: 1)
+            let aqi = self.aqiToString(aqi)
+            let formattedString = "\(condition) | \(temperature)°C 대기질 \(aqi)"
+            self.output.send(.weatherUpdate(formattedString))
+        }
+        .store(in: &cancellables)
+    }
+}
+// MARK: - 정보 한글화
+extension RunHomeViewModel {
+    // MARK: - 대기질 정보 -> 한글
+    private func aqiToString(_ aqi: Int) -> String {
+        switch aqi {
+        case 1: return "좋음"
+        case 2: return "보통"
+        case 3: return "나쁨"
+        case 4: return "매우 나쁨"
+        case 5: return "위험"
+        default: return "정보 없음"
+        }
     }
-    // MARK: - 위치 정보 데이터 -> 한글
+    // MARK: - 위치 정보 -> 한글
     private func placemarksToString(_ placemark: CLPlacemark) -> String {
         var state: String = "" // 도, 광역시
         var city: String = "" // 시, 군, 구
         var district: String = "" // 동, 읍, 면
-        
+        if let subLocal = placemark.subLocality, subLocal.hasSuffix("동") {
+            district = subLocal
+        }
         let description: String = String(
             placemark
                 .description
@@ -71,35 +96,14 @@ final class RunHomeViewModel {
             ?? "")
         let components = description.split(separator: " ").map { String($0) }
         for component in components {
-            if component.hasSuffix("특별시") || component.hasSuffix("광역시") || component.hasSuffix("도") {
+            if state == "" && (component.hasSuffix("특별시") || component.hasSuffix("광역시") || component.hasSuffix("도")) {
                 state = component
-            }else if component.hasSuffix("시") || component.hasSuffix("군") || component.hasSuffix("구") {
+            }else if city == "" && (component.hasSuffix("시") || component.hasSuffix("군") || component.hasSuffix("구")) {
                 city = component
-            }else if component.hasSuffix("동") || component.hasSuffix("읍") || component.hasSuffix("면") {
+            }else if district == "" && (component.hasSuffix("동") || component.hasSuffix("읍") || component.hasSuffix("면") || component.hasSuffix("로")) {
                 district = component
             }
         }
         return district.isEmpty ? "\(state) \(city)에서" : "\(state) \(city) \(district)에서"
     }
-    // MARK: - 날씨 정보 데이터 -> 한글
-    private func conditionToString(_ condition: WeatherCondition) -> String {
-        switch condition {
-        case .clear: return "맑음"
-        case .cloudy: return "흐림"
-        case .rain: return "비"
-        case .snow: return "눈"
-        case .strongStorms: return "폭풍"
-        default: return condition.rawValue
-        }
-    }
-    private func aqiToString(_ aqi: Int) -> String {
-        switch aqi {
-        case 1: return "좋음"
-        case 2: return "보통"
-        case 3: return "나쁨"
-        case 4: return "매우 나쁨"
-        case 5: return "위험"
-        default: return "정보 없음"
-        }
-    }
 }
diff --git a/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift b/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
index efc9ff1..938cdec 100644
--- a/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
+++ b/RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
@@ -10,118 +10,180 @@ import Combine
 import MapKit
 
 final class RunningViewModel {
-    
-    // MARK: - save Datas - 종료 버튼 누르면 저장 될 데이터들
-    struct SectionRecord {
-        var sectionTime: TimeInterval // 시간
-        var distance: Double // 거리
-        var steps: Int // 걸음 수
-    }
-    // 섹션
+    // MARK: - 운동 시작 부터 종료 전까지의 정보
+    // +) save 기능 넣으면 private으로 변경
     var section: Section = Section(
         distance: 0,
         steps: 0,
         route: []
     )
-    var record: SectionRecord = SectionRecord(
-        sectionTime: 0,
-        distance: 0,
-        steps: 0
-    )
-    // MARK: - Property
-    var previousCoordinate: CLLocationCoordinate2D?
-    var timer: Timer?
+    private var timeRecord: TimeInterval = 0
     // MARK: - Input & Output
     enum Input {
         case runningStart // 운동 시작 -> 시간 업데이트 필요
-        // Q) 아마 거리랑 걸음 수도 location처럼 처리를 해야할 듯
-        case distanceUpdate(Double) // 거리 업데이트 필요
-        case stepsUpdate(Int) // 걸음 수 업데이트 필요
+        case runningStop // 운동 종료 -> 결과 저장
     }
     enum Output {
         case locationUpdate(CLLocation) // 사용자 위치 데이터
         case timerUpdate(String) // 운동 시간 업데이트
-        case distanceUpdate // 운동 거리 업데이트
-        case stepsUpdate // 운동 걸음 수 업데이트
+        case distanceUpdate(String) // 운동 거리 업데이트
+        case stepsUpdate(String) // 운동 걸음 수 업데이트
         case lineDraw(MKPolyline) // 지도에 라인을 그림
     }
     let input = PassthroughSubject<Input, Never>()
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Property
     private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
+    private let distanceManager = DistanceManager.shared
+    private var timer: Timer?
     
     // MARK: - Init
     init() {
         bind()
     }
-    deinit {
-        timer?.invalidate()
-        timer = nil
-        saveLog()
-    }
+    // viewController에도 동일 함수 존재 - 실제 저장할 때는 여기서 작동(Controller는 실기기에서 확인용 alert띄우기용)
     private func saveLog() {
-        print("⏹ 운동 종료 ⏹")
-        print("최종 시간: \(record.sectionTime)초")
-        print("최종 경로 핀 수: \(section.route.count)")
+        guard let startTime = section.route.first?.timestamp,
+              let endTime = section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+        """
+        ⏹ 운동 종료 ⏹
+        시작 시간: \(startTime.formattedString(.fullTime))초
+        종료 시간: \(endTime.formattedString(.fullTime))초
+        총 운동 시간: \(totalTime)초
+        최종 경로 핀 수: \(section.route.count)
+        최종 걸음 수: \(section.steps)
+        """
+        print(message)
         print("최종 경로")
         for location in section.route {
             print("경도: \(location.latitude), 위도: \(location.longitude)")
-            print("시간: \(location.timestamp.formattedString)")
+            print("시간: \(location.timestamp.formattedString(.fullTime))")
         }
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
         // 사용자 위치 변경 구독
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
             .sink { [weak self] location in
-                // 경로 그리기
-                if let previousCoordinate = self?.previousCoordinate {
-                    let point1 = CLLocationCoordinate2DMake(
-                        previousCoordinate.latitude,
-                        previousCoordinate.longitude
-                    )
-                    let point2 = CLLocationCoordinate2DMake(
-                        location.coordinate.latitude,
-                        location.coordinate.longitude
+                guard let self = self else { return }
+                if let last = self.section.route.last {
+                    let previous = CLLocation(
+                        latitude: last.latitude,
+                        longitude: last.longitude
                     )
-                    let points: [CLLocationCoordinate2D] = [point1, point2]
-                    let lineDraw = MKPolyline(coordinates: points, count: points.count)
-                    self?.output.send(.lineDraw(lineDraw))
+                    // 이전 위치가 있으면 이동 거리를 요청
+                    self.distanceManager.input.send(.requestDistance(location, previous))
+                    // 이전 위치가 있으면 라인을 그림
+                    self.lindDraw(location: location, previous: previous)
                 }
-                self?.previousCoordinate = location.coordinate
-                // 위치 변경
-                let point: Point = Point(
+                // 현재 위치를 저장
+                let current: Point = Point(
                     latitude: location.coordinate.latitude,
                     longitude: location.coordinate.longitude,
                     timestamp: Date())
-                self?.section.route.append(point)
-                self?.output.send(.locationUpdate(location))
+                self.section.route.append(current)
+                // 이전 위치와 상관없이 현위치로 이동
+                self.output.send(.locationUpdate(location))
+            }
+            .store(in: &cancellables)
+        // 사용자 걸음 수 변경 구독
+        pedometerManager.pedometerPublisher
+            .sink { [weak self] step in
+                guard let self = self else { return }
+                // 걸음 수 업데이트
+                self.section.steps = step
+                let stepString = "\(step)"
+                self.output.send(.stepsUpdate(stepString))
             }
             .store(in: &cancellables)
         // input에 따라 처리
         self.input
-            .receive(on: DispatchQueue.main)
             .sink { [weak self] input in
                 guard let self = self else { return }
                 switch input {
                 case .runningStart:
-                    timerUpdate()
-                case .distanceUpdate(let distance):
-                    print(distance)
-                case .stepsUpdate(let steps):
-                    print(steps)
+                    runningStart()
+                case .runningStop:
+                    runnungStop()
+                }
+            }
+            .store(in: &cancellables)
+        // 사용자 이동 거리 변경 구독
+        distanceManager.output
+            .sink { [weak self] output in
+                guard let self = self else { return }
+                switch output {
+                case .responseDistance(let distance):
+                    self.section.distance += distance
+                    // 거리에 따라 단위 변경
+                    let distanceString = "\((self.section.distance / 1000).toString(withDecimal: 2))km"
+                    self.output.send(.distanceUpdate(distanceString))
                 }
             }
             .store(in: &cancellables)
     }
+    // 운동 시작
+    private func runningStart() {
+        // 시작 위치를 경로에 저장
+        let currentLocation = locationManager.currentLocation
+        let point: Point = Point(
+            latitude: currentLocation.coordinate.latitude,
+            longitude: currentLocation.coordinate.longitude,
+            timestamp: Date())
+        self.section.route.append(point)
+        // 타이머 시작
+        self.timerUpdate()
+    }
+    // 운동 종료
+    private func runnungStop() {
+        // 타이머 종료
+        self.timer?.invalidate()
+        self.timer = nil
+        // 마지막 위치를 경로에 저장
+        let currentLocation = locationManager.currentLocation
+        let point: Point = Point(
+            latitude: currentLocation.coordinate.latitude,
+            longitude: currentLocation.coordinate.longitude,
+            timestamp: Date())
+        self.section.route.append(point)
+        // 내용 저장
+        self.saveLog()
+    }
+    // 운동 종료
     // 운동 시작하면 초당 운동시간 업데이트
     private func timerUpdate() {
-        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
+        timer = Timer.scheduledTimer(
+            withTimeInterval: 1,
+            repeats: true
+        ) { [weak self] _ in
             guard let self = self else { return }
-            self.record.sectionTime += 1
-            let timeString = self.record.sectionTime.asTimeString
+            self.timeRecord += 1
+            let timeString = timeRecord.asTimeString
             self.output.send(.timerUpdate(timeString))
         }
     }
 }
+// MARK: - 지도에 그리기
+extension RunningViewModel {
+    private func lindDraw(location: CLLocation, previous: CLLocation) {
+        let point1 = CLLocationCoordinate2DMake(
+            previous.coordinate.latitude,
+            previous.coordinate.longitude
+        )
+        let point2 = CLLocationCoordinate2DMake(
+            location.coordinate.latitude,
+            location.coordinate.longitude
+        )
+        let points: [CLLocationCoordinate2D] = [point1, point2]
+        let lineDraw = MKPolyline(coordinates: points, count: points.count)
+        self.output.send(.lineDraw(lineDraw))
+    }
+}
diff --git a/RunLog/RunLog/Sources/Util/Extension/Bundle+.swift b/RunLog/RunLog/Sources/Util/Extension/Bundle+.swift
new file mode 100644
index 0000000..2c2ba94
--- /dev/null
+++ b/RunLog/RunLog/Sources/Util/Extension/Bundle+.swift
@@ -0,0 +1,16 @@
+//
+//  Bundle+.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/19/25.
+//
+
+import Foundation
+extension Bundle {
+    var weatherKey: String? {
+        guard let key = object(forInfoDictionaryKey: "API_KEY") as? String else {
+            fatalError("❌ API_KEY가 Info.plist에서 설정되지 않았습니다.")
+        }
+        return key
+    }
+}
diff --git a/RunLog/RunLog/Sources/Util/Extension/Date+.swift b/RunLog/RunLog/Sources/Util/Extension/Date+.swift
index d2d84af..b20981f 100644
--- a/RunLog/RunLog/Sources/Util/Extension/Date+.swift
+++ b/RunLog/RunLog/Sources/Util/Extension/Date+.swift
@@ -22,13 +22,14 @@ extension Date {
 }
 
 enum DateFormatStyle {
-    case fullDate      // "2025. 02. 13."
-    case monthDay     // "0월 0일 (수)"
-    case yearMonth     // "2025년 3월"
-    case yearMonthShort     // "25년 3월"
+    case fullDate       // "2025. 02. 13."
+    case monthDay       // "0월 0일 (수)"
+    case yearMonth      // "2025년 3월"
+    case yearMonthShort // "25년 3월"
+    case fullTime       // "12:23:34:456"
     case detailedFull  // "2025년 3월 3일 수요일"
     case weekDay      // "월요일"
-    
+        
     var format: String {
         switch self {
         case .fullDate:
@@ -39,6 +40,8 @@ enum DateFormatStyle {
             return "yyyy년 M월"
         case .yearMonthShort:
             return "yy년 M월"
+        case .fullTime:
+            return "HH:mm:ss:SSS"
         case .detailedFull:
             return "yyyy년 M월 d일 E요일"
         case .weekDay:


Reviews:
user: login: doyeonk429
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-21T04:21:00Z
user: login: Monfi98
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T04:22:14Z
user: login: Syrbear
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T04:27:53Z
user: login: Syrbear
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T04:36:25Z
user: login: Syrbear
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T04:36:41Z
user: login: Syrbear
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T04:44:41Z
user: login: Syrbear
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T04:51:23Z
user: login: Monfi98
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T05:10:39Z

Review comments:
path: RunLog/RunLog/Info.plist
user: login: doyeonk429
body: 처리 굿입니다
created_at: 2025-03-21T04:11:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,57 @@
+//
+//  DistanceManager.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/21/25.
+//
+
+import Foundation
+import Combine
+import CoreLocation
+
+final class DistanceManager {
+    // MARK: - Singleton
+    static let shared = DistanceManager()
+    // MARK: - Input & Output
+    enum Input {
+        case requestDistance(CLLocation, CLLocation)
+    }
+    enum Output {
+        case responseDistance(Double)
+    }
+    let input = PassthroughSubject<Input, Never>()
+    let output = PassthroughSubject<Output, Never>()
+    // MARK: - Properties
+    private var cancellables = Set<AnyCancellable>()
+    // MARK: - Init
+    private init() {
+        bind()
+    }
+    // MARK: - Bind (Input -> Output)
+    private func bind() {
+        self.input
+            .sink { [weak self] input in
+                guard let self = self else { return }
+                switch input {
+                case .requestDistance(let location, let previous):
+                    self.calculateDistance(
+                        location: location,
+                        previous: previous
+                    )
+                }
path: RunLog/RunLog/Sources/Data/DistanceManager.swift
user: login: Monfi98
body: 요거 case가 더많아질까요? 없다면 굳이 guard let self를 안쓰셔도 될것같습니둥
created_at: 2025-03-21T04:12:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,118 +10,171 @@ import Combine
 import MapKit
 
 final class RunningViewModel {
-    
-    // MARK: - save Datas - 종료 버튼 누르면 저장 될 데이터들
-    struct SectionRecord {
-        var sectionTime: TimeInterval // 시간
-        var distance: Double // 거리
-        var steps: Int // 걸음 수
-    }
-    // 섹션
+    // MARK: - 운동 시작 부터 종료 전까지의 정보
+    // +) save 기능 넣으면 private으로 변경
     var section: Section = Section(
         distance: 0,
         steps: 0,
         route: []
     )
-    var record: SectionRecord = SectionRecord(
-        sectionTime: 0,
-        distance: 0,
-        steps: 0
-    )
-    // MARK: - Property
-    var previousCoordinate: CLLocationCoordinate2D?
-    var timer: Timer?
+    private var timeRecord: TimeInterval = 0
     // MARK: - Input & Output
     enum Input {
         case runningStart // 운동 시작 -> 시간 업데이트 필요
-        // Q) 아마 거리랑 걸음 수도 location처럼 처리를 해야할 듯
-        case distanceUpdate(Double) // 거리 업데이트 필요
-        case stepsUpdate(Int) // 걸음 수 업데이트 필요
+        case runningStop // 운동 종료 -> 결과 저장
     }
     enum Output {
         case locationUpdate(CLLocation) // 사용자 위치 데이터
         case timerUpdate(String) // 운동 시간 업데이트
-        case distanceUpdate // 운동 거리 업데이트
-        case stepsUpdate // 운동 걸음 수 업데이트
+        case distanceUpdate(String) // 운동 거리 업데이트
+        case stepsUpdate(String) // 운동 걸음 수 업데이트
         case lineDraw(MKPolyline) // 지도에 라인을 그림
     }
     let input = PassthroughSubject<Input, Never>()
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Property
     private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
+    private let distanceManager = DistanceManager.shared
+    private var timer: Timer?
     
     // MARK: - Init
     init() {
         bind()
     }
-    deinit {
-        timer?.invalidate()
-        timer = nil
-        saveLog()
-    }
+    // viewController에도 동일 함수 존재 - 실제 저장할 때는 여기서 작동(Controller는 실기기에서 확인용 alert띄우기용)
     private func saveLog() {
-        print("⏹ 운동 종료 ⏹")
-        print("최종 시간: \(record.sectionTime)초")
-        print("최종 경로 핀 수: \(section.route.count)")
+        guard let startTime = section.route.first?.timestamp,
+              let endTime = section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+        """
+        ⏹ 운동 종료 ⏹
+        시작 시간: \(startTime.formattedString(.fullTime))초
+        종료 시간: \(endTime.formattedString(.fullTime))초
+        총 운동 시간: \(totalTime)초
+        최종 경로 핀 수: \(section.route.count)
+        최종 걸음 수: \(section.steps)
+        """
+        print(message)
         print("최종 경로")
         for location in section.route {
             print("경도: \(location.latitude), 위도: \(location.longitude)")
-            print("시간: \(location.timestamp.formattedString)")
+            print("시간: \(location.timestamp.formattedString(.fullTime))")
         }
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
         // 사용자 위치 변경 구독
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
             .sink { [weak self] location in
-                // 경로 그리기
-                if let previousCoordinate = self?.previousCoordinate {
-                    let point1 = CLLocationCoordinate2DMake(
-                        previousCoordinate.latitude,
-                        previousCoordinate.longitude
-                    )
-                    let point2 = CLLocationCoordinate2DMake(
-                        location.coordinate.latitude,
-                        location.coordinate.longitude
+                guard let self = self else { return }
+                if let last = self.section.route.last {
+                    let previous = CLLocation(
+                        latitude: last.latitude,
+                        longitude: last.longitude
                     )
-                    let points: [CLLocationCoordinate2D] = [point1, point2]
-                    let lineDraw = MKPolyline(coordinates: points, count: points.count)
-                    self?.output.send(.lineDraw(lineDraw))
+                    // 이전 위치가 있으면 이동 거리를 요청
+                    self.distanceManager.input.send(.requestDistance(location, previous))
+                    // 이전 위치가 있으면 라인을 그림
+                    self.lindDraw(location: location, previous: previous)
                 }
-                self?.previousCoordinate = location.coordinate
-                // 위치 변경
-                let point: Point = Point(
+                // 현재 위치를 저장
+                let current: Point = Point(
                     latitude: location.coordinate.latitude,
                     longitude: location.coordinate.longitude,
                     timestamp: Date())
-                self?.section.route.append(point)
-                self?.output.send(.locationUpdate(location))
+                self.section.route.append(current)
+                // 이전 위치와 상관없이 현위치로 이동
+                self.output.send(.locationUpdate(location))
+            }
+            .store(in: &cancellables)
+        // 사용자 걸음 수 변경 구독
+        pedometerManager.pedometerPublisher
+            .sink { [weak self] step in
+                guard let self = self else { return }
+                // 걸음 수 업데이트
+                self.section.steps = step
+                let stepString = "\(step)"
+                self.output.send(.stepsUpdate(stepString))
             }
             .store(in: &cancellables)
         // input에 따라 처리
         self.input
-            .receive(on: DispatchQueue.main)
             .sink { [weak self] input in
                 guard let self = self else { return }
                 switch input {
                 case .runningStart:
-                    timerUpdate()
-                case .distanceUpdate(let distance):
-                    print(distance)
-                case .stepsUpdate(let steps):
-                    print(steps)
+                    // 시작 위치를 경로에 저장
+                    let currentLocation = locationManager.currentLocation
+                    let point: Point = Point(
+                        latitude: currentLocation.coordinate.latitude,
+                        longitude: currentLocation.coordinate.longitude,
+                        timestamp: Date())
+                    self.section.route.append(point)
+                    // 타이머 시작
+                    self.timerUpdate()
+                case .runningStop:
+                    // 타이머 종료
+                    timer?.invalidate()
+                    timer = nil
+                    // 마지막 위치를 경로에 저장
+                    let currentLocation = locationManager.currentLocation
+                    let point: Point = Point(
+                        latitude: currentLocation.coordinate.latitude,
+                        longitude: currentLocation.coordinate.longitude,
+                        timestamp: Date())
+                    self.section.route.append(point)
+                    // 내용 저장
+                    self.saveLog()
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
user: login: doyeonk429
body: 내부 로직이 길어지는 경우 별도 함수로 분리하면 더 좋을 것 같습니다.
created_at: 2025-03-21T04:15:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,15 @@
+//
+//  API.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/19/25.
+//
+
+import Foundation
+
+//final class API {
+//    static let openWeatherURL: String = "https://api.openweathermap.org/data/2.5"
+//}
+enum API: String {
+    case openWeatherURL = "https://api.openweathermap.org/data/2.5"
path: RunLog/RunLog/Sources/Data/Network/API.swift
user: login: Monfi98
body: ```suggestion
   static let openWeatherURL = "https://api.openweathermap.org/data/2.5"
```
enum 내에서도 static let을 사용할 수 있습니다.
이렇게하면 .rawValue 를 안하고도 가져올 수 있기 때문에 이 방식이 더 좋아보입니다.
created_at: 2025-03-21T04:16:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,118 +10,171 @@ import Combine
 import MapKit
 
 final class RunningViewModel {
-    
-    // MARK: - save Datas - 종료 버튼 누르면 저장 될 데이터들
-    struct SectionRecord {
-        var sectionTime: TimeInterval // 시간
-        var distance: Double // 거리
-        var steps: Int // 걸음 수
-    }
-    // 섹션
+    // MARK: - 운동 시작 부터 종료 전까지의 정보
+    // +) save 기능 넣으면 private으로 변경
     var section: Section = Section(
         distance: 0,
         steps: 0,
         route: []
     )
-    var record: SectionRecord = SectionRecord(
-        sectionTime: 0,
-        distance: 0,
-        steps: 0
-    )
-    // MARK: - Property
-    var previousCoordinate: CLLocationCoordinate2D?
-    var timer: Timer?
+    private var timeRecord: TimeInterval = 0
     // MARK: - Input & Output
     enum Input {
         case runningStart // 운동 시작 -> 시간 업데이트 필요
-        // Q) 아마 거리랑 걸음 수도 location처럼 처리를 해야할 듯
-        case distanceUpdate(Double) // 거리 업데이트 필요
-        case stepsUpdate(Int) // 걸음 수 업데이트 필요
+        case runningStop // 운동 종료 -> 결과 저장
     }
     enum Output {
         case locationUpdate(CLLocation) // 사용자 위치 데이터
         case timerUpdate(String) // 운동 시간 업데이트
-        case distanceUpdate // 운동 거리 업데이트
-        case stepsUpdate // 운동 걸음 수 업데이트
+        case distanceUpdate(String) // 운동 거리 업데이트
+        case stepsUpdate(String) // 운동 걸음 수 업데이트
         case lineDraw(MKPolyline) // 지도에 라인을 그림
     }
     let input = PassthroughSubject<Input, Never>()
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Property
     private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
+    private let distanceManager = DistanceManager.shared
+    private var timer: Timer?
     
     // MARK: - Init
     init() {
         bind()
     }
-    deinit {
-        timer?.invalidate()
-        timer = nil
-        saveLog()
-    }
+    // viewController에도 동일 함수 존재 - 실제 저장할 때는 여기서 작동(Controller는 실기기에서 확인용 alert띄우기용)
     private func saveLog() {
-        print("⏹ 운동 종료 ⏹")
-        print("최종 시간: \(record.sectionTime)초")
-        print("최종 경로 핀 수: \(section.route.count)")
+        guard let startTime = section.route.first?.timestamp,
+              let endTime = section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+        """
+        ⏹ 운동 종료 ⏹
+        시작 시간: \(startTime.formattedString(.fullTime))초
+        종료 시간: \(endTime.formattedString(.fullTime))초
+        총 운동 시간: \(totalTime)초
+        최종 경로 핀 수: \(section.route.count)
+        최종 걸음 수: \(section.steps)
+        """
+        print(message)
         print("최종 경로")
         for location in section.route {
             print("경도: \(location.latitude), 위도: \(location.longitude)")
-            print("시간: \(location.timestamp.formattedString)")
+            print("시간: \(location.timestamp.formattedString(.fullTime))")
         }
     }
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
user: login: doyeonk429
body: 요거 나중에 유즈케이스로 바꾸실 때, vc -> 저장 요청 -> vm -> 저장 함수 호출 -> 성공/실패 여부 다시 Output으로 방출 -> vc에서 그거에 따라서 화면 분기 및 후속 처리 가능하도록 데이터 흐름 바꾸시면 될 것 같습니다! 
created_at: 2025-03-21T04:19:45Z
author_association: MEMBER
diff_hunk: @@ -19,49 +18,75 @@ final class RunHomeViewModel {
     }
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Properties
     private let locationManager = LocationManager.shared
-    
+    private let openWeatherService = OpenWeatherService.shared
+    private var previousCity: String? // 이전 도시명
     // MARK: - Init
     init() {
         bind()
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
-        // 사용자 위치 변경 구독
+        // 사용자 위치 변경 구독 -> location에 맞는 지도 이동
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] locaiton in
-                self?.output.send(.locationUpdate(locaiton))
+            .sink { [weak self] location in
+                guard let self = self else { return }
+                self.output.send(.locationUpdate(location))
             }
             .store(in: &cancellables)
         // 도시명 변경 구독
         locationManager.locationNamePublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] placemark in
-                let city = self?.placemarksToString(placemark) ?? "알 수 없음"
-                self?.output.send(.locationNameUpdate(city))
-            }
-            .store(in: &cancellables)
-        // 날씨 변경 구독
-        locationManager.weatherPublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] weather in
-                print("날씨: \(weather)")
-                let condition = self?.conditionToString(weather.condition) ?? weather.condition.rawValue
-                let temperature = "\(weather.temperature)°C"
-                let aqi = self?.aqiToString(weather.airQuality) ?? "정보 없음"
-                
-                let formattedString = "\(condition) | \(temperature), 미세먼지 \(aqi)"
-                self?.output.send(.weatherUpdate(formattedString))
+            .sink { [weak self] value in
+                guard let self = self else { return }
+                let location = value.0
+                let placemark = value.1
+                let city = self.placemarksToString(placemark)
+                if previousCity != city {
+                    print("날씨정보 업데이트")
+                    self.openWeatherService.input.send(.requestUpdate(location))
+                    self.output.send(.locationNameUpdate(city))
+                }
+                previousCity = city
             }
             .store(in: &cancellables)
+        // 날씨 및 대기질 변경 구독
+        Publishers.Zip(
+            openWeatherService.weatherUpdatePublisher,
+            openWeatherService.aqiUpdatePublisher
+        )
+        .sink { [weak self] weather, aqi in
+            guard let self = self else { return }
+            let condition = weather.0
+            let temperature = weather.1.toString(withDecimal: 1)
+            let aqi = self.aqiToString(aqi)
+            let formattedString = "\(condition) | \(temperature)°C 대기질 \(aqi)"
+            self.output.send(.weatherUpdate(formattedString))
+        }
+        .store(in: &cancellables)
+    }
+}
+// MARK: - 정보 한글화
+extension RunHomeViewModel {
+    // MARK: - 대기질 정보 -> 한글
+    private func aqiToString(_ aqi: Int) -> String {
+        switch aqi {
+        case 1: return "좋음"
+        case 2: return "보통"
+        case 3: return "나쁨"
+        case 4: return "매우 나쁨"
+        case 5: return "위험"
+        default: return "정보 없음"
+        }
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift
user: login: Monfi98
body: enum으로 처리가 좋아보이지만 우선순위는 아니니 나중에 리팩토링할때 하시면 될 것 같습니다.
created_at: 2025-03-21T04:20:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,118 +10,171 @@ import Combine
 import MapKit
 
 final class RunningViewModel {
-    
-    // MARK: - save Datas - 종료 버튼 누르면 저장 될 데이터들
-    struct SectionRecord {
-        var sectionTime: TimeInterval // 시간
-        var distance: Double // 거리
-        var steps: Int // 걸음 수
-    }
-    // 섹션
+    // MARK: - 운동 시작 부터 종료 전까지의 정보
+    // +) save 기능 넣으면 private으로 변경
     var section: Section = Section(
         distance: 0,
         steps: 0,
         route: []
     )
-    var record: SectionRecord = SectionRecord(
-        sectionTime: 0,
-        distance: 0,
-        steps: 0
-    )
-    // MARK: - Property
-    var previousCoordinate: CLLocationCoordinate2D?
-    var timer: Timer?
+    private var timeRecord: TimeInterval = 0
     // MARK: - Input & Output
     enum Input {
         case runningStart // 운동 시작 -> 시간 업데이트 필요
-        // Q) 아마 거리랑 걸음 수도 location처럼 처리를 해야할 듯
-        case distanceUpdate(Double) // 거리 업데이트 필요
-        case stepsUpdate(Int) // 걸음 수 업데이트 필요
+        case runningStop // 운동 종료 -> 결과 저장
     }
     enum Output {
         case locationUpdate(CLLocation) // 사용자 위치 데이터
         case timerUpdate(String) // 운동 시간 업데이트
-        case distanceUpdate // 운동 거리 업데이트
-        case stepsUpdate // 운동 걸음 수 업데이트
+        case distanceUpdate(String) // 운동 거리 업데이트
+        case stepsUpdate(String) // 운동 걸음 수 업데이트
         case lineDraw(MKPolyline) // 지도에 라인을 그림
     }
     let input = PassthroughSubject<Input, Never>()
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Property
     private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
+    private let distanceManager = DistanceManager.shared
+    private var timer: Timer?
     
     // MARK: - Init
     init() {
         bind()
     }
-    deinit {
-        timer?.invalidate()
-        timer = nil
-        saveLog()
-    }
+    // viewController에도 동일 함수 존재 - 실제 저장할 때는 여기서 작동(Controller는 실기기에서 확인용 alert띄우기용)
     private func saveLog() {
-        print("⏹ 운동 종료 ⏹")
-        print("최종 시간: \(record.sectionTime)초")
-        print("최종 경로 핀 수: \(section.route.count)")
+        guard let startTime = section.route.first?.timestamp,
+              let endTime = section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+        """
+        ⏹ 운동 종료 ⏹
+        시작 시간: \(startTime.formattedString(.fullTime))초
+        종료 시간: \(endTime.formattedString(.fullTime))초
+        총 운동 시간: \(totalTime)초
+        최종 경로 핀 수: \(section.route.count)
+        최종 걸음 수: \(section.steps)
+        """
+        print(message)
         print("최종 경로")
         for location in section.route {
             print("경도: \(location.latitude), 위도: \(location.longitude)")
-            print("시간: \(location.timestamp.formattedString)")
+            print("시간: \(location.timestamp.formattedString(.fullTime))")
         }
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
         // 사용자 위치 변경 구독
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
             .sink { [weak self] location in
-                // 경로 그리기
-                if let previousCoordinate = self?.previousCoordinate {
-                    let point1 = CLLocationCoordinate2DMake(
-                        previousCoordinate.latitude,
-                        previousCoordinate.longitude
-                    )
-                    let point2 = CLLocationCoordinate2DMake(
-                        location.coordinate.latitude,
-                        location.coordinate.longitude
+                guard let self = self else { return }
+                if let last = self.section.route.last {
+                    let previous = CLLocation(
+                        latitude: last.latitude,
+                        longitude: last.longitude
                     )
-                    let points: [CLLocationCoordinate2D] = [point1, point2]
-                    let lineDraw = MKPolyline(coordinates: points, count: points.count)
-                    self?.output.send(.lineDraw(lineDraw))
+                    // 이전 위치가 있으면 이동 거리를 요청
+                    self.distanceManager.input.send(.requestDistance(location, previous))
+                    // 이전 위치가 있으면 라인을 그림
+                    self.lindDraw(location: location, previous: previous)
                 }
-                self?.previousCoordinate = location.coordinate
-                // 위치 변경
-                let point: Point = Point(
+                // 현재 위치를 저장
+                let current: Point = Point(
                     latitude: location.coordinate.latitude,
                     longitude: location.coordinate.longitude,
                     timestamp: Date())
-                self?.section.route.append(point)
-                self?.output.send(.locationUpdate(location))
+                self.section.route.append(current)
+                // 이전 위치와 상관없이 현위치로 이동
+                self.output.send(.locationUpdate(location))
+            }
+            .store(in: &cancellables)
+        // 사용자 걸음 수 변경 구독
+        pedometerManager.pedometerPublisher
+            .sink { [weak self] step in
+                guard let self = self else { return }
+                // 걸음 수 업데이트
+                self.section.steps = step
+                let stepString = "\(step)"
+                self.output.send(.stepsUpdate(stepString))
             }
             .store(in: &cancellables)
         // input에 따라 처리
         self.input
-            .receive(on: DispatchQueue.main)
             .sink { [weak self] input in
                 guard let self = self else { return }
                 switch input {
                 case .runningStart:
-                    timerUpdate()
-                case .distanceUpdate(let distance):
-                    print(distance)
-                case .stepsUpdate(let steps):
-                    print(steps)
+                    // 시작 위치를 경로에 저장
+                    let currentLocation = locationManager.currentLocation
+                    let point: Point = Point(
+                        latitude: currentLocation.coordinate.latitude,
+                        longitude: currentLocation.coordinate.longitude,
+                        timestamp: Date())
+                    self.section.route.append(point)
+                    // 타이머 시작
+                    self.timerUpdate()
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
user: login: doyeonk429
body: 여기도 함수로 묶어주시는게 코드 흐름이 더 잘 보일 것 같습니다!
created_at: 2025-03-21T04:20:11Z
author_association: MEMBER
diff_hunk: @@ -10,118 +10,171 @@ import Combine
 import MapKit
 
 final class RunningViewModel {
-    
-    // MARK: - save Datas - 종료 버튼 누르면 저장 될 데이터들
-    struct SectionRecord {
-        var sectionTime: TimeInterval // 시간
-        var distance: Double // 거리
-        var steps: Int // 걸음 수
-    }
-    // 섹션
+    // MARK: - 운동 시작 부터 종료 전까지의 정보
+    // +) save 기능 넣으면 private으로 변경
     var section: Section = Section(
         distance: 0,
         steps: 0,
         route: []
     )
-    var record: SectionRecord = SectionRecord(
-        sectionTime: 0,
-        distance: 0,
-        steps: 0
-    )
-    // MARK: - Property
-    var previousCoordinate: CLLocationCoordinate2D?
-    var timer: Timer?
+    private var timeRecord: TimeInterval = 0
     // MARK: - Input & Output
     enum Input {
         case runningStart // 운동 시작 -> 시간 업데이트 필요
-        // Q) 아마 거리랑 걸음 수도 location처럼 처리를 해야할 듯
-        case distanceUpdate(Double) // 거리 업데이트 필요
-        case stepsUpdate(Int) // 걸음 수 업데이트 필요
+        case runningStop // 운동 종료 -> 결과 저장
     }
     enum Output {
         case locationUpdate(CLLocation) // 사용자 위치 데이터
         case timerUpdate(String) // 운동 시간 업데이트
-        case distanceUpdate // 운동 거리 업데이트
-        case stepsUpdate // 운동 걸음 수 업데이트
+        case distanceUpdate(String) // 운동 거리 업데이트
+        case stepsUpdate(String) // 운동 걸음 수 업데이트
         case lineDraw(MKPolyline) // 지도에 라인을 그림
     }
     let input = PassthroughSubject<Input, Never>()
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Property
     private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
+    private let distanceManager = DistanceManager.shared
+    private var timer: Timer?
     
     // MARK: - Init
     init() {
         bind()
     }
-    deinit {
-        timer?.invalidate()
-        timer = nil
-        saveLog()
-    }
+    // viewController에도 동일 함수 존재 - 실제 저장할 때는 여기서 작동(Controller는 실기기에서 확인용 alert띄우기용)
     private func saveLog() {
-        print("⏹ 운동 종료 ⏹")
-        print("최종 시간: \(record.sectionTime)초")
-        print("최종 경로 핀 수: \(section.route.count)")
+        guard let startTime = section.route.first?.timestamp,
+              let endTime = section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+        """
+        ⏹ 운동 종료 ⏹
+        시작 시간: \(startTime.formattedString(.fullTime))초
+        종료 시간: \(endTime.formattedString(.fullTime))초
+        총 운동 시간: \(totalTime)초
+        최종 경로 핀 수: \(section.route.count)
+        최종 걸음 수: \(section.steps)
+        """
+        print(message)
         print("최종 경로")
         for location in section.route {
             print("경도: \(location.latitude), 위도: \(location.longitude)")
-            print("시간: \(location.timestamp.formattedString)")
+            print("시간: \(location.timestamp.formattedString(.fullTime))")
         }
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
         // 사용자 위치 변경 구독
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
user: login: Monfi98
body: UI업데이트와 관련이 있을것같은데 왜 빠졌는지 궁금합니다 !
created_at: 2025-03-21T04:21:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,118 +10,171 @@ import Combine
 import MapKit
 
 final class RunningViewModel {
-    
-    // MARK: - save Datas - 종료 버튼 누르면 저장 될 데이터들
-    struct SectionRecord {
-        var sectionTime: TimeInterval // 시간
-        var distance: Double // 거리
-        var steps: Int // 걸음 수
-    }
-    // 섹션
+    // MARK: - 운동 시작 부터 종료 전까지의 정보
+    // +) save 기능 넣으면 private으로 변경
     var section: Section = Section(
         distance: 0,
         steps: 0,
         route: []
     )
-    var record: SectionRecord = SectionRecord(
-        sectionTime: 0,
-        distance: 0,
-        steps: 0
-    )
-    // MARK: - Property
-    var previousCoordinate: CLLocationCoordinate2D?
-    var timer: Timer?
+    private var timeRecord: TimeInterval = 0
     // MARK: - Input & Output
     enum Input {
         case runningStart // 운동 시작 -> 시간 업데이트 필요
-        // Q) 아마 거리랑 걸음 수도 location처럼 처리를 해야할 듯
-        case distanceUpdate(Double) // 거리 업데이트 필요
-        case stepsUpdate(Int) // 걸음 수 업데이트 필요
+        case runningStop // 운동 종료 -> 결과 저장
     }
     enum Output {
         case locationUpdate(CLLocation) // 사용자 위치 데이터
         case timerUpdate(String) // 운동 시간 업데이트
-        case distanceUpdate // 운동 거리 업데이트
-        case stepsUpdate // 운동 걸음 수 업데이트
+        case distanceUpdate(String) // 운동 거리 업데이트
+        case stepsUpdate(String) // 운동 걸음 수 업데이트
         case lineDraw(MKPolyline) // 지도에 라인을 그림
     }
     let input = PassthroughSubject<Input, Never>()
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Property
     private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
+    private let distanceManager = DistanceManager.shared
+    private var timer: Timer?
     
     // MARK: - Init
     init() {
         bind()
     }
-    deinit {
-        timer?.invalidate()
-        timer = nil
-        saveLog()
-    }
+    // viewController에도 동일 함수 존재 - 실제 저장할 때는 여기서 작동(Controller는 실기기에서 확인용 alert띄우기용)
     private func saveLog() {
-        print("⏹ 운동 종료 ⏹")
-        print("최종 시간: \(record.sectionTime)초")
-        print("최종 경로 핀 수: \(section.route.count)")
+        guard let startTime = section.route.first?.timestamp,
+              let endTime = section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+        """
+        ⏹ 운동 종료 ⏹
+        시작 시간: \(startTime.formattedString(.fullTime))초
+        종료 시간: \(endTime.formattedString(.fullTime))초
+        총 운동 시간: \(totalTime)초
+        최종 경로 핀 수: \(section.route.count)
+        최종 걸음 수: \(section.steps)
+        """
+        print(message)
         print("최종 경로")
         for location in section.route {
             print("경도: \(location.latitude), 위도: \(location.longitude)")
-            print("시간: \(location.timestamp.formattedString)")
+            print("시간: \(location.timestamp.formattedString(.fullTime))")
         }
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
         // 사용자 위치 변경 구독
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
             .sink { [weak self] location in
-                // 경로 그리기
-                if let previousCoordinate = self?.previousCoordinate {
-                    let point1 = CLLocationCoordinate2DMake(
-                        previousCoordinate.latitude,
-                        previousCoordinate.longitude
-                    )
-                    let point2 = CLLocationCoordinate2DMake(
-                        location.coordinate.latitude,
-                        location.coordinate.longitude
+                guard let self = self else { return }
+                if let last = self.section.route.last {
+                    let previous = CLLocation(
+                        latitude: last.latitude,
+                        longitude: last.longitude
                     )
-                    let points: [CLLocationCoordinate2D] = [point1, point2]
-                    let lineDraw = MKPolyline(coordinates: points, count: points.count)
-                    self?.output.send(.lineDraw(lineDraw))
+                    // 이전 위치가 있으면 이동 거리를 요청
+                    self.distanceManager.input.send(.requestDistance(location, previous))
+                    // 이전 위치가 있으면 라인을 그림
+                    self.lindDraw(location: location, previous: previous)
                 }
-                self?.previousCoordinate = location.coordinate
-                // 위치 변경
-                let point: Point = Point(
+                // 현재 위치를 저장
+                let current: Point = Point(
                     latitude: location.coordinate.latitude,
                     longitude: location.coordinate.longitude,
                     timestamp: Date())
-                self?.section.route.append(point)
-                self?.output.send(.locationUpdate(location))
+                self.section.route.append(current)
+                // 이전 위치와 상관없이 현위치로 이동
+                self.output.send(.locationUpdate(location))
+            }
+            .store(in: &cancellables)
+        // 사용자 걸음 수 변경 구독
+        pedometerManager.pedometerPublisher
+            .sink { [weak self] step in
+                guard let self = self else { return }
+                // 걸음 수 업데이트
+                self.section.steps = step
+                let stepString = "\(step)"
+                self.output.send(.stepsUpdate(stepString))
             }
             .store(in: &cancellables)
         // input에 따라 처리
         self.input
-            .receive(on: DispatchQueue.main)
             .sink { [weak self] input in
                 guard let self = self else { return }
                 switch input {
                 case .runningStart:
-                    timerUpdate()
-                case .distanceUpdate(let distance):
-                    print(distance)
-                case .stepsUpdate(let steps):
-                    print(steps)
+                    // 시작 위치를 경로에 저장
+                    let currentLocation = locationManager.currentLocation
+                    let point: Point = Point(
+                        latitude: currentLocation.coordinate.latitude,
+                        longitude: currentLocation.coordinate.longitude,
+                        timestamp: Date())
+                    self.section.route.append(point)
+                    // 타이머 시작
+                    self.timerUpdate()
+                case .runningStop:
+                    // 타이머 종료
+                    timer?.invalidate()
+                    timer = nil
+                    // 마지막 위치를 경로에 저장
+                    let currentLocation = locationManager.currentLocation
+                    let point: Point = Point(
+                        latitude: currentLocation.coordinate.latitude,
+                        longitude: currentLocation.coordinate.longitude,
+                        timestamp: Date())
+                    self.section.route.append(point)
+                    // 내용 저장
+                    self.saveLog()
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
user: login: Syrbear
body: 분리 했습니다!
created_at: 2025-03-21T04:27:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+//
+//  API.swift
+//  RunLog
+//
+//  Created by 심근웅 on 3/19/25.
+//
+
+import Foundation
+
+//final class API {
+//    static let openWeatherURL: String = "https://api.openweathermap.org/data/2.5"
+//}
+enum API: String {
+    case openWeatherURL = "https://api.openweathermap.org/data/2.5"
path: RunLog/RunLog/Sources/Data/Network/API.swift
user: login: Syrbear
body: 이런 방법이 있었네요 수정했습니다!
created_at: 2025-03-21T04:36:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,49 +18,75 @@ final class RunHomeViewModel {
     }
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Properties
     private let locationManager = LocationManager.shared
-    
+    private let openWeatherService = OpenWeatherService.shared
+    private var previousCity: String? // 이전 도시명
     // MARK: - Init
     init() {
         bind()
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
-        // 사용자 위치 변경 구독
+        // 사용자 위치 변경 구독 -> location에 맞는 지도 이동
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] locaiton in
-                self?.output.send(.locationUpdate(locaiton))
+            .sink { [weak self] location in
+                guard let self = self else { return }
+                self.output.send(.locationUpdate(location))
             }
             .store(in: &cancellables)
         // 도시명 변경 구독
         locationManager.locationNamePublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] placemark in
-                let city = self?.placemarksToString(placemark) ?? "알 수 없음"
-                self?.output.send(.locationNameUpdate(city))
-            }
-            .store(in: &cancellables)
-        // 날씨 변경 구독
-        locationManager.weatherPublisher
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] weather in
-                print("날씨: \(weather)")
-                let condition = self?.conditionToString(weather.condition) ?? weather.condition.rawValue
-                let temperature = "\(weather.temperature)°C"
-                let aqi = self?.aqiToString(weather.airQuality) ?? "정보 없음"
-                
-                let formattedString = "\(condition) | \(temperature), 미세먼지 \(aqi)"
-                self?.output.send(.weatherUpdate(formattedString))
+            .sink { [weak self] value in
+                guard let self = self else { return }
+                let location = value.0
+                let placemark = value.1
+                let city = self.placemarksToString(placemark)
+                if previousCity != city {
+                    print("날씨정보 업데이트")
+                    self.openWeatherService.input.send(.requestUpdate(location))
+                    self.output.send(.locationNameUpdate(city))
+                }
+                previousCity = city
             }
             .store(in: &cancellables)
+        // 날씨 및 대기질 변경 구독
+        Publishers.Zip(
+            openWeatherService.weatherUpdatePublisher,
+            openWeatherService.aqiUpdatePublisher
+        )
+        .sink { [weak self] weather, aqi in
+            guard let self = self else { return }
+            let condition = weather.0
+            let temperature = weather.1.toString(withDecimal: 1)
+            let aqi = self.aqiToString(aqi)
+            let formattedString = "\(condition) | \(temperature)°C 대기질 \(aqi)"
+            self.output.send(.weatherUpdate(formattedString))
+        }
+        .store(in: &cancellables)
+    }
+}
+// MARK: - 정보 한글화
+extension RunHomeViewModel {
+    // MARK: - 대기질 정보 -> 한글
+    private func aqiToString(_ aqi: Int) -> String {
+        switch aqi {
+        case 1: return "좋음"
+        case 2: return "보통"
+        case 3: return "나쁨"
+        case 4: return "매우 나쁨"
+        case 5: return "위험"
+        default: return "정보 없음"
+        }
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunHomeViewModel.swift
user: login: Syrbear
body: 넵
created_at: 2025-03-21T04:36:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,118 +10,171 @@ import Combine
 import MapKit
 
 final class RunningViewModel {
-    
-    // MARK: - save Datas - 종료 버튼 누르면 저장 될 데이터들
-    struct SectionRecord {
-        var sectionTime: TimeInterval // 시간
-        var distance: Double // 거리
-        var steps: Int // 걸음 수
-    }
-    // 섹션
+    // MARK: - 운동 시작 부터 종료 전까지의 정보
+    // +) save 기능 넣으면 private으로 변경
     var section: Section = Section(
         distance: 0,
         steps: 0,
         route: []
     )
-    var record: SectionRecord = SectionRecord(
-        sectionTime: 0,
-        distance: 0,
-        steps: 0
-    )
-    // MARK: - Property
-    var previousCoordinate: CLLocationCoordinate2D?
-    var timer: Timer?
+    private var timeRecord: TimeInterval = 0
     // MARK: - Input & Output
     enum Input {
         case runningStart // 운동 시작 -> 시간 업데이트 필요
-        // Q) 아마 거리랑 걸음 수도 location처럼 처리를 해야할 듯
-        case distanceUpdate(Double) // 거리 업데이트 필요
-        case stepsUpdate(Int) // 걸음 수 업데이트 필요
+        case runningStop // 운동 종료 -> 결과 저장
     }
     enum Output {
         case locationUpdate(CLLocation) // 사용자 위치 데이터
         case timerUpdate(String) // 운동 시간 업데이트
-        case distanceUpdate // 운동 거리 업데이트
-        case stepsUpdate // 운동 걸음 수 업데이트
+        case distanceUpdate(String) // 운동 거리 업데이트
+        case stepsUpdate(String) // 운동 걸음 수 업데이트
         case lineDraw(MKPolyline) // 지도에 라인을 그림
     }
     let input = PassthroughSubject<Input, Never>()
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Property
     private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
+    private let distanceManager = DistanceManager.shared
+    private var timer: Timer?
     
     // MARK: - Init
     init() {
         bind()
     }
-    deinit {
-        timer?.invalidate()
-        timer = nil
-        saveLog()
-    }
+    // viewController에도 동일 함수 존재 - 실제 저장할 때는 여기서 작동(Controller는 실기기에서 확인용 alert띄우기용)
     private func saveLog() {
-        print("⏹ 운동 종료 ⏹")
-        print("최종 시간: \(record.sectionTime)초")
-        print("최종 경로 핀 수: \(section.route.count)")
+        guard let startTime = section.route.first?.timestamp,
+              let endTime = section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+        """
+        ⏹ 운동 종료 ⏹
+        시작 시간: \(startTime.formattedString(.fullTime))초
+        종료 시간: \(endTime.formattedString(.fullTime))초
+        총 운동 시간: \(totalTime)초
+        최종 경로 핀 수: \(section.route.count)
+        최종 걸음 수: \(section.steps)
+        """
+        print(message)
         print("최종 경로")
         for location in section.route {
             print("경도: \(location.latitude), 위도: \(location.longitude)")
-            print("시간: \(location.timestamp.formattedString)")
+            print("시간: \(location.timestamp.formattedString(.fullTime))")
         }
     }
     // MARK: - Bind (Input -> Output)
     private func bind() {
         // 사용자 위치 변경 구독
         locationManager.locationPublisher
-            .receive(on: DispatchQueue.main)
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
user: login: Syrbear
body: 따로 설명드렸습니다!
created_at: 2025-03-21T04:44:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,118 +10,171 @@ import Combine
 import MapKit
 
 final class RunningViewModel {
-    
-    // MARK: - save Datas - 종료 버튼 누르면 저장 될 데이터들
-    struct SectionRecord {
-        var sectionTime: TimeInterval // 시간
-        var distance: Double // 거리
-        var steps: Int // 걸음 수
-    }
-    // 섹션
+    // MARK: - 운동 시작 부터 종료 전까지의 정보
+    // +) save 기능 넣으면 private으로 변경
     var section: Section = Section(
         distance: 0,
         steps: 0,
         route: []
     )
-    var record: SectionRecord = SectionRecord(
-        sectionTime: 0,
-        distance: 0,
-        steps: 0
-    )
-    // MARK: - Property
-    var previousCoordinate: CLLocationCoordinate2D?
-    var timer: Timer?
+    private var timeRecord: TimeInterval = 0
     // MARK: - Input & Output
     enum Input {
         case runningStart // 운동 시작 -> 시간 업데이트 필요
-        // Q) 아마 거리랑 걸음 수도 location처럼 처리를 해야할 듯
-        case distanceUpdate(Double) // 거리 업데이트 필요
-        case stepsUpdate(Int) // 걸음 수 업데이트 필요
+        case runningStop // 운동 종료 -> 결과 저장
     }
     enum Output {
         case locationUpdate(CLLocation) // 사용자 위치 데이터
         case timerUpdate(String) // 운동 시간 업데이트
-        case distanceUpdate // 운동 거리 업데이트
-        case stepsUpdate // 운동 걸음 수 업데이트
+        case distanceUpdate(String) // 운동 거리 업데이트
+        case stepsUpdate(String) // 운동 걸음 수 업데이트
         case lineDraw(MKPolyline) // 지도에 라인을 그림
     }
     let input = PassthroughSubject<Input, Never>()
     let output = PassthroughSubject<Output, Never>()
     private var cancellables = Set<AnyCancellable>()
+    // MARK: - Property
     private let locationManager = LocationManager.shared
+    private let pedometerManager = PedometerManager.shared
+    private let distanceManager = DistanceManager.shared
+    private var timer: Timer?
     
     // MARK: - Init
     init() {
         bind()
     }
-    deinit {
-        timer?.invalidate()
-        timer = nil
-        saveLog()
-    }
+    // viewController에도 동일 함수 존재 - 실제 저장할 때는 여기서 작동(Controller는 실기기에서 확인용 alert띄우기용)
     private func saveLog() {
-        print("⏹ 운동 종료 ⏹")
-        print("최종 시간: \(record.sectionTime)초")
-        print("최종 경로 핀 수: \(section.route.count)")
+        guard let startTime = section.route.first?.timestamp,
+              let endTime = section.route.last?.timestamp
+        else {
+            print("루트가 없음")
+            return
+        }
+        let totalTime = endTime.timeIntervalSince(startTime)
+        let message: String =
+        """
+        ⏹ 운동 종료 ⏹
+        시작 시간: \(startTime.formattedString(.fullTime))초
+        종료 시간: \(endTime.formattedString(.fullTime))초
+        총 운동 시간: \(totalTime)초
+        최종 경로 핀 수: \(section.route.count)
+        최종 걸음 수: \(section.steps)
+        """
+        print(message)
         print("최종 경로")
         for location in section.route {
             print("경도: \(location.latitude), 위도: \(location.longitude)")
-            print("시간: \(location.timestamp.formattedString)")
+            print("시간: \(location.timestamp.formattedString(.fullTime))")
         }
     }
path: RunLog/RunLog/Sources/Presentation/Run/ViewModel/RunningViewModel.swift
user: login: Syrbear
body: 지금은 vc의 버튼에 바인드로 vm에 운동종료를 보내고 그러면 운동종료하면서 저장! 이라는 흐름을 가지고 있는데 이부분은 괜찮을까요? 제 생각에는 "성공/실패 여부 다시 Output으로 방출 -> vc에서 그거에 따라서 화면 분기 및 후속 처리 가능" 이부분을 추가 하는 것이 어떤가!? 하는 의견으로 보여서 혹시나 싶어 질문합니다!
created_at: 2025-03-21T04:51:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: doyeonk429
created_at: 2025-03-21T04:22:32Z
body: + 코드 중간 중간에 적절한 줄바꿈/공백줄을 주시면 리뷰어의 가독성이 올라갈 것 같습니다!
+ 함수를 extension으로 분리하였다면, 해당 익스텐션 안에 있는 함수들의 공통점(분리한 이유)이 명확하면 좋겠습니다!
author_association: MEMBER
user: login: Syrbear
created_at: 2025-03-21T04:48:07Z
body: 넵 extension으로 분리할때 이때의 생각으로는 이거랑 이거랑은 같은 결인것같은데! 했는데 
아마도 이후 수정을 거치다 보니까 최초의 생각이 희석되어버린것 같습니다ㅎㅎ
추후 작업하면서는 수정을 거치면 다시한번 생각해보는 습관을 가지겠습니다
줄바꿈 공백줄은!! 신경써서 한다고 넣은건데 아직 부족한것같습니다ㅠㅠ
author_association: CONTRIBUTOR