안녕하세요 요즘 블로그 활동이 뜸했는데요...^^ 그동안 이것저것 프로젝트 하면서 살았답니다 ㅎㅎ
제가 스위프 앱 1기에서 진행한 near 앱을 소개하겠습니다!
바쁜 현대 사회에서 소중한 친구들과의 관계를 놓치지 않고 더 가깝게 이어갈 수 있도록 돕는 것을 목표로 iOS 앱 'near'를 개발했습니다.
이번 포스팅에서는 'near' 앱 개발에 사용된 핵심 기술 스택, 아키텍처 결정 과정, 그리고 개발 중 겪었던 도전과 배움의 순간들을 공유하고자 합니다.
1. 왜 'near'인가? : 앱 개발 배경 및 목표
"연락해야 하는데..." 생각만 하고 놓쳐버린 친구, 있지 않으신가요? 'near'는 이런 아쉬움을 해결하기 위해 탄생했습니다. 저희는 사용자가 친구들의 생일, 기념일, 설정한 연락 주기 등을 놓치지 않고 관리하며, 꾸준하고 의미 있는 관계를 유지할 수 있도록 돕는 것을 목표로 삼았습니다.
주요 기능은 다음과 같습니다.
연락처 및 카카오톡 친구 연동: 손쉽게 친구 목록을 가져와 관리합니다.
맞춤 연락 주기 설정: 친구별로 원하는 연락 주기를 설정하고 리마인더를 받습니다.
챙김 기록 및 관리: 친구를 챙긴 기록을 남기고, 관계 유지 현황을 파악합니다.
홈 화면 대시보드: 이번 달 챙겨야 할 친구와 전체 친구 목록을 한눈에 확인합니다.
2. 기술 스택 Deep Dive: 'near'를 구성하는 핵심 기술들
안정적이면서도 확장 가능하고, 효율적인 개발 경험을 위해 신중하게 기술 스택을 선택했습니다.
2.1. 프로젝트 관리 및 구조화: Tuist
선택 이유: Xcode 프로젝트 파일(.xcodeproj, .xcworkspace)의 복잡성과 충돌 가능성을 줄이고, 프로젝트 설정을 코드로 명확하게 관리하기 위해 Tuist를 도입했습니다.
Project.swift 파일을 통해 앱 타겟, 테스트 타겟, 설정, 종속성, 리소스 등을 정의했습니다.
모듈화된 구조를 쉽게 구현하고, 팀원 간 설정 일관성을 유지하며 빌드 시간을 단축하는 효과를 얻었습니다.
.xcconfig 파일을 활용하여 Debug/Release 환경별 설정을 분리하고, KAKAO_APP_KEY, DEV_BASE_URL 등 민감 정보를 안전하게 관리했습니다.
선택 이유: View의 역할(UI 표시 및 사용자 입력)과 Business Logic(데이터 처리, 상태 관리)을 명확히 분리하여 코드의 가독성, 테스트 용이성, 유지보수성을 높이기 위해 MVVM 패턴을 채택했습니다.
구현: 각 View에 대응하는 ViewModel을 만들어 데이터 흐름과 상태 변화를 관리했습니다. SwiftUI와의 조합을 통해 데이터 바인딩을 효과적으로 활용했습니다.
2.3. UI 프레임워크: SwiftUI
선택 이유: iOS 17.0을 타겟으로 하고 MVVM 패턴을 사용하는 점, 그리고 선언적이고 현대적인 UI 개발 방식의 이점을 고려하여 SwiftUI를 주력 UI 프레임워크로 사용했습니다.
장점: 코드의 양을 줄이고, 실시간 프리뷰를 통해 UI 개발 생산성을 크게 향상시킬 수 있었습니다. MVVM 패턴과의 자연스러운 통합도 장점입니다.
2.4. 네트워킹: Alamofire & Backend API
Alamofire: 백엔드 서버와의 안정적이고 편리한 HTTP 통신을 위해 검증된 라이브러리인 Alamofire를 사용했습니다. (dependencies: [.external(name: "Alamofire")])
Backend API 연동: 자체 백엔드 서버와 RESTful API 통신을 통해 로그인, 친구 목록 조회/추가/수정/삭제, 프로필 관리, 챙김 기록 등 앱의 핵심 기능을 구현했습니다.
2.5. 사용자 인증: Kakao SDK, Sign in with Apple, Token Management
Kakao SDK: 핵심 로그인 방식으로 카카오 로그인을 사용하며, 사용자 정보 및 친구 목록(동의 시)을 가져오는 데 활용했습니다. (KakaoSDKCommon, Auth, User, Friend 등 다수 모듈 사용)
Sign in with Apple: Apple 사용자를 위한 간편 로그인 옵션을 제공합니다. (entitlements: "Tuist/SignInWithApple.entitlements")
Token 기반 인증: 백엔드와의 안전한 통신을 위해 Access Token과 Refresh Token을 관리하고, Keychain에 안전하게 저장하며 자동 로그인 및 토큰 갱신 로직을 구현했습니다.
2.6. 비동기 이미지 처리: Kingfisher & Presigned URL
Kingfisher: 서버로부터 받은 친구 프로필 이미지 URL 등을 비동기적으로 다운로드하고 캐싱하여 UI에 효율적으로 표시하기 위해 Kingfisher를 사용했습니다. (dependencies: [.external(name: "Kingfisher")])
Presigned URL: 친구 프로필 이미지 업로드/다운로드 시 AWS S3 Presigned URL 방식을 사용하여 서버 부하를 줄이고 효율성을 높였습니다.
2.7. 로컬 데이터 관리: Core Data
활용: 알림(Notification) 관련 데이터를 로컬에 저장하고 관리하는 데 Core Data를 사용했습니다. (coreDataModels 설정: NotificationContainer.xcdatamodeld) 향후 오프라인 지원 강화 등 로컬 데이터 활용 범위를 넓힐 가능성을 염두에 두었습니다.
2.8. 시스템 연동: Contacts Framework
사용자의 주소록에 접근하여 친구를 가져오는 기능을 구현하기 위해 Contacts Framework를 사용했습니다. 사용자에게 명확한 권한 요청 안내를 제공합니다.
2.9. UI/UX 커스터마이징
Custom Fonts (Pretendard): 앱 전체 디자인의 일관성을 위해 Pretendard 폰트를 적용했습니다.
Light Mode 고정: 초기 버전에서는 디자인 리소스 및 개발 집중도를 고려하여 Light 모드만 지원하도록 설정했습니다.
LaunchScreen: 앱 시작 시 브랜드 로고 등을 보여주는 LaunchScreen 스토리보드를 사용합니다.
3. 주요 기능 구현 하이라이트
기술 스택을 바탕으로 다음과 같은 주요 기능들을 구현했습니다.
로그인 및 온보딩: 카카오/애플 SDK를 이용한 간편 로그인 후, 서비스 이용 약관 동의, 연락처/카카오 친구 가져오기, 연락 주기 설정으로 이어지는 자연스러운 온보딩 플로우를 설계했습니다.
친구 관리: 연락처 및 카카오톡에서 가져온 친구를 '챙길 친구'로 등록하고, 맞춤 연락 주기를 설정합니다. Alamofire를 통해 백엔드 API와 연동하여 친구 정보를 생성, 조회, 수정, 삭제합니다.
홈 화면: '이번 달 챙길 친구'와 '내 사람들' 목록을 보여주며, 친구를 챙겼음을 기록하는 기능을 제공합니다.
프로필 상세 및 수정: 친구의 상세 정보(연락처, 기념일, 메모 등)를 확인하고 수정하는 기능을 구현했습니다.
4. 개발 과정에서의 도전과 배움 (Challenges & Learnings)
개발 과정은 순탄하지만은 않았습니다. 몇 가지 기술적 어려움에 직면했고, 이를 해결하며 값진 경험을 얻었습니다.
Tuist와 Kakao SDK 리소스 번들: Kakao SDK 중 친구 목록 관련 기능을 사용할 때, SDK 내부 리소스 번들을 Tuist 프로젝트에 올바르게 포함시키는 과정에서 어려움을 겪었습니다. Tuist의 리소스 처리 방식과 Kakao SDK 문서 검토를 통해 해결했습니다.
데이터 동기화 및 상태 관리: 친구 추가/수정 후 홈 화면 등 다른 View에 변경 사항이 즉시 반영되지 않는 문제가 있었습니다. SwiftUI의 @State, @ObservedObject, @EnvironmentObject 등을 활용한 상태 관리 로직을 개선하고, 데이터 흐름을 명확히 하여 해결했습니다.
비동기 처리: 여러 친구의 프로필 이미지를 동시에 업로드하거나, 다수의 API 호출 후 UI를 업데이트하는 과정에서 DispatchGroup 등을 활용하여 비동기 코드를 안정적이고 효율적으로 관리했습니다.
Tuist와 Core Data 연동: 초기 설정 시 Core Data 모델 파일(.xcdatamodeld) 경로를 Tuist 프로젝트 설정(Project.swift)에 정확히 명시하여 인식시키는 데 약간의 시행착오가 있었습니다.
API 명세 변경 대응: 개발 중 백엔드 API 명세가 변경되는 경우가 있었고, 이에 맞춰 모델과 네트워킹 코드를 신속하게 수정하며 백엔드 팀과의 긴밀한 소통의 중요성을 다시 한번 느꼈습니다.
5. 마무리하며
'near' 앱은 Tuist를 통한 효율적인 프로젝트 관리, MVVM 아키텍처 기반의 구조화된 코드, SwiftUI를 활용한 현대적인 UI 구현, 그리고 Alamofire, Kingfisher, Kakao SDK 등 검증된 라이브러리 및 Core Data, Contacts 등 시스템 프레임워크를 적극적으로 활용하여 개발되었습니다.
개발 과정에서의 도전들은 저희 팀을 더욱 성장시키는 계기가 되었습니다. 앞으로도 사용자분들이 친구들과의 소중한 관계를 'near'를 통해 더 쉽고 의미 있게 가꿔나갈 수 있도록, 꾸준히 기능을 개선하고 안정화해 나갈 예정입니다.
긴 글 읽어주셔서 감사합니다! 'near' 앱 개발 과정이나 사용된 기술에 대해 궁금한 점이 있다면 언제든지 댓글로 남겨주세요.
@Published 로 선언한 변수에서 Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
//TODO: - gitignore에 NaverStorage.swift 추가하기..
import Foundation
struct NaverStorage {
let naverClientID: String = "Your naverClientID"
let naverClientSecret: String = "Your naverClientSecret"
}
앗 그전에 까먹은 것이 있네요..
Alamofire를 사용해 HTTP통신을 하여 네이버 API를 사용해야하니 Alamofire또한 Cocopod를 통해 설치해 줍니다.
(킹피셔로 이미지를 보여줄것이니 같이 다운 받아요 ㅎㅎ)
pod 'Alamofire'
pod 'Kingfisher', '~> 7.0'
pod install을 하였다면,, 이제 HTTP통신을 해야 하겠죠?
HTTP 통신에 사용되는 메소드를 편하게 사용하기 위해 enum에 저장하여 사용합니다.
- HTTPMethod.swift -
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case head = "HEAD"
case delete = "DELETE"
case patch = "PATCH"
case trace = "TRACE"
case options = "OPTIONS"
case connect = "CONNECT"
}
Alamofire를 사용해 네트워크 통신 부분을 작성 해보겠습니다.
- NaverNetwork.swift -
import Foundation
import Alamofire
protocol NaverNetworkDelegate: AnyObject {
func imageDataUpdated(_ imageData: NaverImageData?)
}
class NaverNetwork: ObservableObject {
static let shared = NaverNetwork()
private init() {}
@Published var imageData: NaverImageData?
weak var delegate: NaverNetworkDelegate?
func requestSearchImage(query: String, completion: @escaping () -> Void) {
let baseURL = "https://openapi.naver.com/v1/search/image"
let headers: HTTPHeaders = [
"X-Naver-Client-Id": NaverStorage().naverClientID,
"X-Naver-Client-Secret": NaverStorage().naverClientSecret,
]
let parameters: Parameters = [
"query": query,
"display": 50
]
AF.request(baseURL,
method: .get,
parameters: parameters,
encoding: URLEncoding.default,
headers: headers)
.validate(statusCode: 200...500)
.responseDecodable(of: NaverImageData.self) { response in
switch response.result {
case .success(let data):
guard let statusCode = response.response?.statusCode else { return }
if statusCode == 200 {
DispatchQueue.main.async {
self.imageData = data
self.delegate?.imageDataUpdated(data)
completion()
}
}
print("\(#file) > \(#function) :: SUCCESS")
case .failure(let error):
print("\(#file) > \(#function) :: FAILURE : \(error)")
}
}
}
}
(NaverNetworkDelegate이 뒤에 이미지를 QuizView에서 보여져야 하지만 이미지가 보이지 않았어서 추가하였습니다.. 이부분은 추가로 글쓸 예정!)
QuizView에서 보여질 이미지를 위해 QuizViewModel에서 메소드들을 작성해 줍니다.
//MARK: - KoreanWordSearchAPI
// 단어 데이터 처리 메서드
func handleWordData(word: String, wordData: WordData?) {
if let wordData = wordData {
wordDataDictionary[word] = wordData
// 정답 단어의 설명을 가져오기
if word == correctWord {
fetchCorrectWordDefinition()
fetchImageForWord(correctWord)
}
} else {
print("\(word) 단어 데이터를 가져오지 못함 ")
isLoading = false // 데이터를 가져오지 못한 경우 isLoading을 false로 설정하여 프로그레스 뷰를 숨김
}
isLoading = false
}
// 정답 단어의 설명 가져오기
func fetchCorrectWordDefinition() {
isLoading = true
guard let wordData = wordDataDictionary[correctWord] else {
correctWordDefinition = "설명을 가져올 수 없습니다."
isLoading = false // 작업 완료
return
}
if let firstSense = wordData.channel.item.first?.sense.first {
correctWordDefinition = firstSense.definition
print(correctWordDefinition)
} else {
correctWordDefinition = "설명을 찾을 수 없습니다."
}
isLoading = false // 작업 완료
}
//MARK: - NaverSearchAPI
func fetchImageForWord(_ word: String) {
isLoading = true
naverNetwork.requestSearchImage(query: word){ [weak self] in
// 이미지 데이터 로드 완료 시에만 isLoading을 false로 설정
self?.isLoading = false
}
}
//MARK: - NaverNetworkDelegate
func imageDataUpdated(_ imageData: NaverImageData?) {
DispatchQueue.main.async {
self.imageData = imageData
}
}
}
Kingfisher
킹피셔를 사용하여 이미지를 띄워 줍니다.
//MARK: - 문제의 이미지
VStack{
if quizViewModel.isLoading {
ProgressView("Loading...")
.frame(width: 200, height: 200)
} else {
if let imageData = quizViewModel.imageData {
if let firstImage = imageData.items.first {
KFImage(URL(string: firstImage.link))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
}
}
}
}
.padding()
디자인은 잼병이라,, 다른앱을 참고하며 바꿔보도록 하겠습니다.
이렇게 이미지를 가져올수 있게 되었습니다!!
문제 발생..
문제라 함은 2개가 발생 하였는데요...
문제 1.
//TODO: - gitignore에 NaverStorage.swift 추가하기..
import Foundation
struct NaverStorage {
let naverClientID: String = "Your naverClientID"
let naverClientSecret: String = "Your naverClientSecret"
}
이부분은 큰 문제는 아니지만, 조금더 보안을 신경 써준다면 키체인을 사용하면 좋아 보입니다!!!
네~! 저번엔 간단히 api를 호출해서 단어의 뜻과 설명등을 가져오는 코드를 작성했습니다,,
현재까지 얼마나 했나,,, 보고아닌 보고를 하기위해 작성하였습니다 ^_^/
아직 구상을 하는 중입니다만,,
아마 만들어가면서 계속 구상을 할것같아요 하하
간단히 4개의 보기를 주고, 문제의 뜻에 대한 단어를 찾는
간단한 뷰를 만들어보았습니다.
나름대로 view와 viewModel을 만들어 보았지만,, view의 코드가 깨끗하지 않은점,,
양해 부탁드립니다 ㅎ
저번 글에서 URLSession을 통해 api통신을 하는 코드중 수정을 조금 하였습니다.
func searchWord(_ searchWord: String, completion: @escaping (WordData?) -> Void) {
let urlString = "https://opendict.korean.go.kr/api/search?certkey_no=6282&key=\(myApiKey)&target_type=search&req_type=json&part=word&q=\(searchWord)&sort=dict&start=1&num=10"
if let url = URL(string: urlString) {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) { data, response, error in
if error == nil {
if let safeData = data {
do {
let decodedData = try JSONDecoder().decode(WordData.self, from: safeData)
DispatchQueue.main.async {
self.wordData = decodedData
completion(decodedData) // 완료 핸들러 호출
}
} catch {
print(error)
completion(nil)
}
}
} else {
print(error)
completion(nil)
}
}
task.resume()
}
}
차이가 조금 보이시나요? ㅎㅎ
저도 아직 감은 잡히지 않았지많,, 완료 핸들러를 추가하였습니다.
흠 추가한 이유는! 4개의 보기의 단어를 검색해서 가져온 데이터를 저장해 주기위해서 입니다,,
추가한 코드를 설명해 드리겠습니다.
QuizViewModel.swift
위에서 보여진 앱은 QuizView.swift 입니다.
이 뷰에 보여지는 데이터 추가를 위해 QuizViewModel을 추가하였는데요!
프로퍼티
let hardKoreanWords = HardKoreanWords()
let wordNetwork = WordNetwork()
var choiceWords = [String]()
var correctWord: String = ""
var correctWordDefinition: String = ""
var wordDataDictionary = [String: WordData]()
@Published var isLoading = false
어려운 단어를 하드코딩해서 넣어둔 hardKoreanWords
우리샘 api를 사용하여 네트워크 통신을 하는 wordNetwork
정답인 단어를 넣어두는 choiceWord
정답인 단어의 뜻을 넣어두는 correctWordDefinition
보기의 단어들을 넣어두는 딕셔너리인wordDataDictionary 등이 있습니다! (isLoading은 프로그래스를 추가해서 검색하는 시간동안 보여주려 하는 함수이지만,, 아직 작동은 안하는..)
보기생성 generateChoices()
func generateChoices() {
choiceWord.append(correctWord)
while choiceWord.count < 4 {
let randomWord = hardKoreanWords.hardWords.randomElement()!
if !choiceWord.contains(randomWord) {
choiceWord.append(randomWord)
}
}
//보기 섞기
choiceWord.shuffle()
//단어 검색 및 데이터 저장
for word in choiceWord {
wordNetwork.searchWord(word) { wordData in
self.handleWordData(word: word, wordData: wordData)
}
}
print(choiceWord)
}
보기를 생성하는 메소드 입니다.
for word in choiceWord를 위해서 앞서 말한 (URLSession을 통해 api통신을 하는 코드중 수정)부분 입니다.
완료 핸들러를 넘겨 받아서 handleWordData를 통해
wordDataDictionary에 보기 단어를 저장합니다.
단어 데이터 처리 handleWordData()
func handleWordData(word: String, wordData: WordData?) {
if let wordData = wordData {
wordDataDictionary[word] = wordData
// 정답 단어의 설명을 가져오기
if word == correctWord {
fetchCorrectWordDefinition()
}
} else {
print("\(word) 단어 데이터를 가져오지 못함 ")
}
}
URLSession을 통해 api통신을 하는 코드를 완료하게 된다면
handleWordData를 호출하여 wordDataDictionary에 저장을 합니다.
정답 단어의 설명 가져오기fetchCorrectWordDefinition()
func fetchCorrectWordDefinition() {
isLoading = true
// choiceWords 배열에서 랜덤하게 정답 선택
correctWord = choiceWord.randomElement() ?? ""
guard let wordData = wordDataDictionary[correctWord] else {
correctWordDefinition = "설명을 가져올 수 없습니다."
isLoading = false
return
}
if let firstSense = wordData.channel.item.first?.sense.first {
correctWordDefinition = firstSense.definition
print(correctWordDefinition)
} else {
correctWordDefinition = "설명을 찾을 수 없습니다."
}
isLoading = false
}
4개의 단어중 무작위로 정답을 선택합니다.
guard let은 프로그래스뷰를 설정하기 위해 추가를 하였지만,,,ㅎ 아직 성공은 못했습니다.
데이터가 있는 경우 첫번째의 뜻을 correctWordDefinition에 추가합니다. (정렬이 우리말샘순 이여서 첫번째가 가장 적합하였습니다.)