Skip to content

몸이 불편하신 분들을 위해 여행 정보 제공하는 앱입니다.

Notifications You must be signed in to change notification settings

constdreamcoder/KoreaOneStep

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

47 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Korea One Step - 대한민국 한걸음


대한민국 한걸음

  • 서비스 소개: 몸이 불편하신 분들을 위한 여행 정보 제공 앱
  • 개발 인원: 1인
  • 개발 기간: 24.03.08 ~ 24.03.21(총 14일)
  • 개발 환경
    • 최소버전: iOS 15
    • Portrait Orientation 지원
    • 라이트 모드 지원
  • 링크

💪 주요 기능

  • 주변 여행지 검색 기능
    • 유저 현재 위치 기반 거리별, 제목별, 수정일순별, 생성일순별, 카테고리별 검색 가능
  • 지역별 여행지 검색 기능
  • 여행지별 무장애 정보 조회 기능
    • 몸이 불편하신 분들이 여행지에서 도움받을 수 있는 공공서비스 조회 가능
  • 여행지 북마크 기능

🛠 기술 소개

  • UIKit, SnapKit, Custom Observable
  • MVVM, Input/Output, Singleton
  • Alamofire, Kingfisher, Realm
  • 프로토콜를 이용한 UI 규격화, Accordion UI
  • Floating Panel, TTGTags, Toast
  • Firebase Crashlytics, Firebase Analytics

💻 구현 내용

1. TableView를 활용한 Accordion UI

  • 구현 과정
    • 열거형을 통하여 TableView의 전체적인 구조 구성

      코드
      enum DetailTableViewSection: Int, CaseIterable {
        case descriptionSection = 0
        case serviceDetailSection = 1
      
        enum DescriptionSection: CaseIterable {
          case regionDetailCell
          case phoneNumberCell
          case addressCell
          case serviceProvidedCell
        }
      
        enum ServiceDetailSection: CaseIterable {
          case physicalDisability
          case visualImpairment
          case hearingImpairment
          case familiesWithInfantsAndToddlers
          case elderlyPeople
          
          var providedServiceNumber: Int {
              switch self {
              case .physicalDisability:
                  return PhysicalDisability.allCases.count
              case .visualImpairment:
                  return VisualImpairment.allCases.count
              case .hearingImpairment:
                  return HearingImpairment.allCases.count
              case .familiesWithInfantsAndToddlers:
                  return FamiliesWithInfantsAndToddlers.allCases.count
              case .elderlyPeople:
                  return ElderlyPeople.allCases.count
              }
          }
          
          var serviceImage: UIImage {
              switch self {
              case .physicalDisability:
                  return UIImage.physicalDisability
              case .visualImpairment:
                  return UIImage.visualImpairment
              case .hearingImpairment:
                  return UIImage.hearingImpairment
              case .familiesWithInfantsAndToddlers:
                  return UIImage.familiesWithInfantsAndToddlers
              case .elderlyPeople:
                  return UIImage.elderlyPeople
              }
          }
          
          var serviceTitleList: [String] {
              switch self {
              case .physicalDisability:
                  return PhysicalDisability.allCases.map { $0.rawValue }
              case .visualImpairment:
                  return VisualImpairment.allCases.map { $0.rawValue }
              case .hearingImpairment:
                  return HearingImpairment.allCases.map { $0.rawValue }
              case .familiesWithInfantsAndToddlers:
                  return FamiliesWithInfantsAndToddlers.allCases.map { $0.rawValue }
              case .elderlyPeople:
                  return ElderlyPeople.allCases.map { $0.rawValue }
              }
          }
          
          enum PhysicalDisability: String, CaseIterable {
              case parkingStatus = "주차여부"
              case publicTransport = "대중교통"
              case coreMovementLine = "핵심동선"
              case ticketBox = "매표소"
              case promotionalMaterial = "홍보물"
              case wheelchair = "휠체어"
              case elevator = "엘리베이터"
              case restroom = "화장실"
              case seat = "관람석(좌석)"
          }
          
          enum VisualImpairment: String, CaseIterable {
              case bailieBlock = "점자블록"
              case guide = "안내요원"
              case audioGuidance = "음성안내"
              case LargePrintOrBraillePromotionalMaterials = "큰활자/점자 홍보물"
              case brailleSign = "점자표지판"
              case guidanceFacility = "유도안내설비"
          }
          
          enum HearingImpairment: String, CaseIterable {
              case signLanguageGuidance = "수어안내"
              case subtitle = "자막"
          }
          
          enum FamiliesWithInfantsAndToddlers: String, CaseIterable {
              case strollerRent = "유아차 대여"
          }
          
          enum ElderlyPeople: String, CaseIterable {
              case wheelChairRent = "휠체어 대여"
              case mobilityAidsRent = "이동보조도구 대여"
          }
        }
      }
    • TableView Cell에 사용할 Cell 모델 정의

      코드
      // Cell 정의
      struct cellData {
        var opened: Bool // 하위 항목이 열렸는지에 대한 상태값 저장
        var sectionData: [String] // Cell에 표시될 데이터 저장
      }
    • TableView에서 필요한 항목만큼 Section 생성

      코드
      // 필요 항목수만큼 Section 생성
      func numberOfSections(in tableView: UITableView) -> Int {
          return DetailTableViewSection.allCases.count - 1 + selectedService.providedServiceNumber
      }
    • TableView Section의 하위항목 open 여부에 따라 Section 하위 항목수만큼 Cell 생성

      코드
      func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
          if section == DetailTableViewSection.descriptionSection.rawValue {
            // ...
          } else {
              // TableView Section의 하위항목 open 여부에 따라 Section 하위 항목수만큼 Cell 생성하고
              // 그렇지 않으면 해당 Section 항목만 생성
              if tableViewData[section - 1].opened {
                  return tableViewData[section - 1].sectionData.count + 1
              } else {
                  return 1
              }
          }
      }
      
      // TableViewCell 정의
      func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if indexPath.section == DetailTableViewSection.descriptionSection.rawValue {
            // ...
        } else {
            if indexPath.row == 0 {
                guard let cell = tableView.dequeueReusableCell(withIdentifier: ServiceProvidedDetailTableViewCell.identifier, for: indexPath) as? ServiceProvidedDetailTableViewCell else { return UITableViewCell() }
                cell.selectionStyle = .none
                
                // 선택된 항목 제목 표시
                cell.serviceTitleLabel.text = selectedService.serviceTitleList[indexPath.section - 1]
                
                // 하위 항목 open 여부에 따른 이미지 변경
                if tableViewData[indexPath.section - 1].opened {
                    cell.chevronImageView.image = UIImage(systemName: "chevron.up")
                } else {
                    cell.chevronImageView.image = UIImage(systemName: "chevron.down")
                }
                return cell
            } else {
                // 하위 항목 Cell 정의
                guard let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.identifier) else { return UITableViewCell() }
                let providedImpairmentAidServiceDescription = tableViewData[indexPath.section - 1].sectionData[indexPath.row - 1]
                cell.textLabel?.text = providedImpairmentAidServiceDescription == "" ? "없음" : providedImpairmentAidServiceDescription
                cell.textLabel?.numberOfLines = 0
                return cell
            }
        }
        return UITableViewCell()
      }
    • Section을 선택하면 Cell 모델의 opened 값이 true가 되고, 이때 해당 Section을 reload로 갱신시켜줌으로써 하위 항목들 open

      코드
      func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
          if indexPath.section == DetailTableViewSection.descriptionSection.rawValue {
            // ...
          } else {
              tableView.deselectRow(at: indexPath, animated: true)
              
              if indexPath.row == 0 {
                  // Cell 모델에 정의되어 있는 opened 값을 반전
                  tableViewData[indexPath.section - 1].opened = !tableViewData[indexPath.section - 1].opened
                  
                  // 선택된 Section 갱신
                  tableView.reloadSections([indexPath.section], with: .none)
                
                  // 선택된 Section의 하위 항목중 가장 마지막 항목으로 Scroll
                  if indexPath.section == selectedService.providedServiceNumber && tableViewData[selectedService.providedServiceNumber - 1].opened {
                      tableView.scrollToRow(at: IndexPath(row: 1, section: selectedService.providedServiceNumber), at: .bottom, animated: true)
                  }
              }
          }
      }

관련 Blog TableView Cell로 아코디언 형식 만들기

2. Custom Observable 구성을 통한 반응형 MVVM 패턴 구성

Custom Observable
import Foundation

final class Observable<T> {
    
    private var closure: ((T) -> Void)?
    
    var value: T {
        didSet {
            closure?(value)
        }
    }
    
    init(_ value: T) {
        self.value = value
    }
    
    func bind(_ closure: @escaping (T) -> Void) {
        closure(value)
        self.closure = closure
    }
}

3. 열거형을 통한 API 호출의 Endpoint 중앙화 관리

Endpoint 관리 중앙화
enum KoreaTravelingAPI {
    //...
    
    case providedImpairmentAidServices(contentId: String)
    case touristDestionationCommonInformation(
        contentId: String,
        contentTypeId: String
    )
    case locationBasedTourismInformation(
        latitude: Double,
        longitude: Double,
        radius: Int,
        arrange: String,
        contentTypeId: String
    )
    case areaCode(areaCode: String)
    case keywordBasedSearching(
        keyword: String,
        areaCode: String,
        sigunguCode: String
    )
    
    // Base URL 정의
    var baseURL: String {
        return baseURL
    }
    
    // EndPoint 정의
    var endpoint: String {
        switch self {
        case .providedImpairmentAidServices:
            return "\(baseURL)/detailWithTour1"
        case .touristDestionationCommonInformation:
            return "\(baseURL)/detailCommon1"
        case .locationBasedTourismInformation:
            return "\(baseURL)/locationBasedList1"
        case .areaCode:
            return "\(baseURL)/areaCode1"
        case .keywordBasedSearching:
            return "\(baseURL)/searchKeyword1"
        }
    }
    
    // HTTP Method 정의
    var method: HTTPMethod {
        return .get
    }
    
    // 파라미터 정의
    var parameters: [String: String] {
        switch self {
        case .providedImpairmentAidServices(let contentId):
            return [
                "serviceKey": APIKeys.serviceKey, // 필수, 인증키(서비스키)
                "numOfRows": "30", // 한페이지결과수
                "pageNo": "1", // 페이지번호
                "MobileOS": "IOS", // OS 구분 : IOS (아이폰), AND (안드로이드), WIN (윈도우폰), ETC(기타)
                "MobileApp": "AppTest", // 필수, 서비스명(어플명)
                "contentId": contentId, // 필수, 콘텐츠ID
                "_type": "json" // 응답메세지 형식 : REST방식의 URL호출 시 json값 추가(디폴트 응답메세지 형식은XML)
            ]
        case .touristDestionationCommonInformation(let contentId, let contentTypeId):
            return [
                "serviceKey": APIKeys.serviceKey, // 필수, 인증키(서비스키)
                "numOfRows": "30", // 한페이지결과수
                "pageNo": "1", // 페이지번호
                "MobileOS": "IOS", // 필수, OS 구분 : IOS (아이폰), AND (안드로이드), WIN (윈도우폰), ETC(기타)
                "MobileApp": "AppTest", // 필수, 서비스명(어플명)
                "contentId": contentId, // 필수, 콘텐츠ID
                "defaultYN": "Y", // 기본정보조회여부( Y,N )
                "firstImageYN": "Y", // 원본, 썸네일대표 이미지, 이미지 공공누리유형정보 조회여부( Y,N )
                "areacodeYN": "Y", // 지역코드, 시군구코드조회여부( Y,N )
                "catcodeYN": "Y", // 대,중,소분류코드조회여부( Y,N )
                "addrinfoYN": "Y", // 주소, 상세주소조회여부( Y,N )
                "mapinfoYN": "Y", // 좌표X, Y 조회여부( Y,N )
                "overviewYN": "Y", // 콘텐츠개요조회여부( Y,N )
                "_type": "json", // 응답메세지 형식 : REST방식의 URL호출 시 json값 추가(디폴트 응답메세지 형식은XML)
                "contentTypeId": contentTypeId // 관광타입(관광지, 숙박 등) ID
            ]
        case .locationBasedTourismInformation(let latitude, let longitude, let radius, let arrange, let contentTypeId):
            return [
                "serviceKey": APIKeys.serviceKey, // 인증키
                "numOfRows": "30", // 한페이지 결과수
                "pageNo": "1", // 페이지 번호
                "MobileOS": "IOS", // 필수, OS 구분 : IOS (아이폰), AND (안드로이드), WIN (윈도우폰), ETC(기타)
                "MobileApp": "AppTest", // 필수, 서비스명(어플명)
                "listYN": "Y", // 목록구분(Y=목록, N=개수)
                "arrange": arrange, // 정렬구분(A=제목순, C=수정일순, D=생성일순, E=거리순) 대표이미지가반드시있는정렬 (O=제목순, Q=수정일순, R=생성일순,S=거리순)
                "mapX": "\(longitude)", // 필수, GPS X좌표(WGS84 경도좌표)
                "mapY": "\(latitude)", // 필수, GPS Y좌표(WGS84 위도좌표)
                "radius": "\(radius)", // 필수, 거리반경(단위:m) , Max값 20000m=20Km
                "_type": "json", // 응답메세지 형식 : REST방식의 URL호출 시 json값 추가(디폴트 응답메세지 형식은XML)
                "contentTypeId": contentTypeId // 관광타입(12:관광지, 14:문화시설, 15:축제공연행사, 25:여행코스, 28:레포츠, 32:숙박, 38:쇼핑, 39:음식점) ID
            ]
        case .areaCode(let areaCode):
            return [
                "serviceKey": APIKeys.serviceKey, // 필수, 인증키(서비스키)
                "numOfRows": "500", // 한페이지 결과수
                "pageNo": "1", // 페이지 번호
                "MobileOS": "IOS", // 필수, OS 구분 : IOS (아이폰), AND (안드로이드), WIN (윈도우폰), ETC(기타)
                "MobileApp": "AppTest", // 필수, 서비스명(어플명)
                "_type": "json", // 응답메세지 형식 : REST방식의 URL호출 시 json값 추가(디폴트 응답메세지 형식은XML)
                "areaCode": areaCode // 지역코드
            ]
        case .keywordBasedSearching(let keyword, let areaCode, let sigunguCode):
            return [
                "serviceKey": APIKeys.serviceKey, // 필수, 인증키(서비스키)
                "numOfRows": "300", // 한페이지 결과수
                "pageNo": "1", // 페이지 번호
                "MobileOS": "IOS", // 필수, OS 구분 : IOS (아이폰), AND (안드로이드), WIN (윈도우폰), ETC(기타)
                "MobileApp": "AppTest", // 필수, 서비스명(어플명)
                "listYN": "Y", // 목록구분(Y=목록, N=개수)
                "arrange": "O", // 정렬구분(A=제목순, C=수정일순, D=생성일순)대표이미지가반드시있는정렬(O=제목순, Q=수정일순, R=생성일순)
                "keyword": keyword, // 검색요청할키워드(국문=인코딩필요)
                "_type": "json", // 응답메세지 형식 : REST방식의 URL호출 시 json값 추가(디폴트 응답메세지 형식은XML)
                "areaCode": areaCode, // 지역코드
                "sigunguCode": sigunguCode // 시군구 코드(지역코드 필수)
            ]
        }
    }
}

🔥 트러블 슈팅

1. App Transport Security 오류 대응

문제상황

  • 공공데이터로 네트워크 통신 중 아래와 같은 오류 발생

    sessionTaskFailed(error: Error Domain=NSURLErrorDomain Code=-1200 "An SSL error has occurred and a secure connection to the server cannot be made."

문제 원인 파악

  • 공공데이터의 통신규약이 Apple에서 기본적으로 권장하는 네트워크 통신 규약인 HTTPS가 아닌 HTTP로 이루어 졌었기 때문

해결방법

  • Info.plist에서 통신 도메인 주소에 대해 ATS 도메인 예외 처리

관련 Blog ATS에 대하여

2. Firebase Analytics 오류 대응

문제상황

  • 앱 사용성에 대한 실시간 분석을 위해 Firebase Analytics를 구성하였지만 프로젝트 실행시 미작동

문제 원인 파악

  • Xcode의 앱 Project의 Building Settings 탭에서 Other Linker Flags 항목에 -ObjC 플래그를 추가하지 않아 문제 발생

해결방법

3. AppStore 배포 후, 리젝 대응

문제상황

  • 앱을 AppStore에 배포 후, 리젝 발생

문제 원인 파악

  • 유저의 권한 거부 상황에 대한 미흡한 대응’과 ‘상세하지 않은 권한 요청 문구 작성’으로 인한 리젝

    리젝 사유
    • 유저의 권한 거부에 대한 미흡한 대응으로 인한 리젝

    • 상세하지 않은 권한 문구에 대한 리젝

해결방법

  • 각각의 리젝 사유에 대한 대응후 재심사 요청

    리젝 사유 해결
    • 유저의 권한 거부에 대한 미흡한 대응으로 인한 리젝

    • 상세하지 않은 권한 문구에 대한 리젝


📚 회고

1. 누락된 기능들

  • 제한된 기간 내 개발로 인해 초기 기획했었던 일부 기능들의 누락

2. 테스트 코드

  • 출시전 테스트 코드 미작성으로 인한 앱 정상적 동작 미보장

관련 Blog 출시 프로젝트 회고

About

몸이 불편하신 분들을 위해 여행 정보 제공하는 앱입니다.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published