Ting 앱 개발 회고록: 프로젝트를 위한 완벽한 매칭

호씨·2025년 2월 27일
0

Ting 앱 개발 회고록: 프로젝트를 위한 완벽한 매칭

안녕하세요! 지난 몇 주간 팀 프로젝트로 iOS 앱 'Ting'을 개발하면서 겪었던 경험과 배움을 공유하려 한다. Ting은 개발자, 디자이너, 기획자 등 프로젝트에 필요한 협업 인원을 매칭해주는 앱으로, 많은 도전과 성장의 순간들이 있었다.

📱 프로젝트 개요

Ting은 프로젝트를 위한 팀원 매칭 플랫폼으로, 개발자, 디자이너, 기획자 등 협업이 필요한 사람들이 적절한 팀원을 찾기 어려운 문제를 해결하기 위해 기획되었다. 우리 팀은 총 4명(이재건, 유태호, 나영진, 오푸른솔)으로 구성되어 약 5주간 프로젝트를 진행했다.

- MVP 개발: 2025.01.16 ~ 2025.02.17
- 유저 피드백 & 버그 수정: 2025.02.17 ~ 2025.02.19
- 리팩토링: 2025.02.19 ~ 현재

프로젝트를 시작하게 된 계기는 우리 모두가 새로운 프로젝트를 시작할 때마다 느꼈던 공통적인 어려움 때문이었다. '좋은 아이디어가 있는데, 함께할 팀원을 어디서 찾아야 할까?'라는 고민이 항상 따라다녔다. 기존의 커뮤니티나 카페에서는 원하는 직군이나 기술 스택을 가진 팀원을 찾기가 쉽지 않았고, 특히 프로젝트의 특성에 맞는 협업 방식을 선호하는 사람을 찾는 것은 더욱 어려웠다.

이러한 문제를 해결하기 위해 우리는 팀원 매칭에 특화된 앱을 만들기로 결정했다. 단순한 구인/구직 플랫폼이 아닌, 프로젝트의 특성과 개인의 선호도를 고려한 매칭 시스템을 구축하는 것이 목표였다.

💡 기획 과정

문제 정의

개발 스터디나 프로젝트를 시작할 때 가장 어려운 점은 '함께할 사람'을 찾는 것이다. 기존 커뮤니티나 플랫폼에서는 이런 매칭이 비효율적으로 이루어지고 있었다. 우리는 이 문제를 해결하기 위해 다음과 같은 핵심 기능을 구상했다:

  1. 팀원 모집/팀 합류 게시판 분리: 명확한 목적에 따라 게시판 구분
  2. 다양한 필터링 옵션: 직무, 기술 스택, 작업 방식 등 세부 조건 검색
  3. 간편한 신고 시스템: 부적절한 게시글 관리
  4. 사용자 차단 기능: 개인화된 피드 구성 지원

기획 초기에는 더 많은 기능을 포함시키고 싶었다. 1:1 채팅, 프로젝트 진행 관리, 포트폴리오 등록과 같은 기능들이었다. 하지만 한정된 시간 내에 완성도 높은 앱을 만들기 위해 MVP(Minimum Viable Product) 전략을 채택했다. 핵심 가치인 '팀원 매칭'에 집중하고, 나머지 기능은 추후 업데이트로 미루기로 했다.

사용자 페르소나

우리는 두 가지 주요 사용자 유형을 정의했다:

  1. 팀 리더/프로젝트 기획자: 자신의 아이디어를 실현하기 위해 팀원을 모집하는 사용자

    • 25-35세, 기획자나 개발자
    • 아이디어는 있지만 혼자 실현하기 어려움
    • 적합한 팀원을 효율적으로 찾고 싶음
  2. 스킬 보유자/참여 희망자: 흥미로운 프로젝트에 참여하고 싶은 사용자

    • 20-30세, 다양한 포지션 (개발자, 디자이너, 기획자 등)
    • 실무 경험을 쌓거나 포트폴리오를 만들고 싶음
    • 자신의 스킬셋에 맞는 프로젝트를 찾고 싶음

이러한 페르소나를 바탕으로 사용자 스토리와 시나리오를 작성했고, 이를 토대로 앱의 기능과 UI를 설계했다.

사용자 플로우 설계

사용자 경험을 설계할 때, 우리는 가능한 한 간결하고 직관적인 플로우를 만들고자 했다:

  1. 회원가입 플로우:

    • Apple 로그인으로 간편 가입
    • 이용약관 동의
    • 사용자 기본 정보 입력 (닉네임, 직군, 선호 작업 방식 등)
  2. 메인 화면 플로우:

    • 팀원 모집 / 팀 합류 카테고리 노출
    • 직군별 빠른 필터링 버튼 (개발자, 디자이너, 기획자, 기타)
    • 최근 게시글 미리보기
  3. 게시글 작성 플로우:

    • 게시글 유형 선택 (팀원 모집 / 팀 합류)
    • 필요한 정보 단계별 입력 (제목, 내용, 포지션, 기술 스택 등)
    • 미리보기 및 등록
  4. 검색 및 필터링 플로우:

    • 키워드 검색
    • 다중 태그 필터 적용
    • 결과 목록 확인

이러한 플로우를 바탕으로 wireframe과 mockup을 제작하고, 팀 내 리뷰를 통해 지속적으로 개선했다.

기술 스택 선택

- UIKit: 익숙한 환경에서 빠른 개발 진행
- SnapKit & Then: 코드 기반 UI 작성의 효율성 증대
- Firebase: 서버 개발 없이 빠른 백엔드 구축
- MVVM 패턴: 코드 가독성과 유지보수성 향상

기술 스택을 선택할 때는 몇 가지 주요 고려사항이 있었다:

  1. 개발 속도: 한정된 시간 내에 완성도 높은 앱을 만들기 위해 팀 모두가 익숙한 UIKit을 선택했다. SwiftUI도 고려했지만, 팀원들의 UIKit 경험이 더 풍부했고, 복잡한 UI를 구현하는 데 더 안정적이라고 판단했다.

  2. 코드 기반 UI: 스토리보드 대신 코드 기반 UI를 선택했다. 이는 버전 관리의 용이성과 재사용 가능한 컴포넌트 개발에 유리했다. SnapKit과 Then 라이브러리는 코드의 가독성과 작성 효율성을 크게 높여주었다.

  3. 백엔드 솔루션: 별도의 백엔드 개발 없이 Firebase를 활용하기로 결정했다. Firebase는 인증, 데이터베이스, 스토리지 등 필요한 모든 기능을 제공하며, 실시간 동기화와 확장성이 뛰어났다.

  4. 아키텍처 패턴: MVVM(Model-View-ViewModel) 패턴을 채택했다. 이는 UI 로직과 비즈니스 로직을 명확히 분리하고, 테스트 용이성을 높이는 데 도움이 되었다.

🔨 개발 과정

파이어베이스 구조 설계

가장 먼저 데이터베이스 구조를 설계했다. Firebase Firestore를 사용해 다음과 같은 컬렉션을 구성했다:

  1. users: 기본 사용자 정보 (UID, 이메일, 약관 동의 여부)
  2. infos: 상세 사용자 프로필 (닉네임, 직군, 기술 스택, 선호 작업 방식 등)
  3. posts: 게시글 정보 (팀원 모집/팀 합류, 제목, 내용, 필요 포지션 등)
  4. reports: 신고 내역 (게시글 ID, 신고 사유, 신고자 정보)

데이터베이스 설계 과정에서 가장 고민했던 부분은 '사용자 정보를 어떻게 구성할 것인가'였다. 처음에는 users 컬렉션 하나만 사용하려 했으나, 인증 정보와 사용자 프로필 정보를 분리하는 것이 보안과 유지보수 측면에서 더 효율적이라고 판단했다. 또한 infos 컬렉션에 신고한 게시글과 차단한 사용자 목록을 배열로 포함시켜, 조회 시 필터링이 용이하도록 했다.

게시글 구조도 많은 고민이 필요했다. 팀원 모집과 팀 합류라는 두 가지 유형의 게시글을 어떻게 관리할지, 서로 다른 필드들을 어떻게 처리할지 고민했다. 결국 공통 필드와 유형별 옵셔널 필드를 가진 하나의 Post 모델을 만들어 관리하기로 했다. 이는 코드의 중복을 줄이고, 쿼리의 일관성을 유지하는 데 도움이 되었다.

struct Post: Identifiable, Codable {
    // 공통 필드 (필수)
    @DocumentID var id: String? // Firestore 문서 ID (자동 생성)
    
    let userId: String   // 작성자의 uid
    let nickName: String  // 작성자 닉네임
    let postType: String // "팀원 모집" 또는 "팀 합류"
    let title: String   // 제목
    let detail: String  // 내용
    let position: [String] // 통일성을 위해 배열로 처리
    let techStack: [String] // 통일성을 위해 배열로 처리
    let ideaStatus: String  // 아이디어 상황
    let meetingStyle: String  // 선호하는 작업 방식
    let numberOfRecruits: String  // 모집 인원
    let createdAt: Date  // Firestore Timestamp와 자동 변환
    var reportCount: Int? = 0 // 신고 횟수
    
    // 팀원 모집 전용 필드 (옵셔널)
    var urgency: String? // 시급성 - "급함", "보통", "여유로움"
    var experience: String? // 경험 - "입문", "취준", "현업", "경력", "기타"
    
    // 팀 합류 전용 필드 (옵셔널)
    var available: String? // 참여가능 시기
    var currentStatus: String? // 현재상태
    
    /// 검색용 모든 태그들을 하나의 배열에 담기
    let tags: [String]
    let searchKeywords: [String]
}

또한 검색 기능을 위해 searchKeywords 필드를 추가하여, 제목의 부분 문자열을 미리 생성해 저장하는 방식을 도입했다. 이는 Firestore의 전문 검색(full-text search) 제한을 우회하는 방법이었다.

보안 규칙 설정

Firebase 보안 규칙을 설정하는 것은 데이터 보안에 중요한 부분이었다. 다음과 같은 규칙을 적용했다:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // 사용자 인증 상태 확인 함수
    function isAuthenticated() {
      return request.auth != null;
    }
    
    // 사용자 문서가 존재하는지 확인하는 함수
    function userExists() {
      return exists(/databases/$(database)/documents/users/$(request.auth.uid));
    }
    
    // users 컬렉션 규칙
    match /users/{userId} {
      // 회원가입 권한: 자신의 문서를 생성하거나 자신의 문서만 읽기 가능
      allow create: if !userExists() && request.auth.uid == userId;
      allow read, update, delete: if isAuthenticated() && request.auth.uid == userId;
    }
    
    // posts 컬렉션 규칙
    match /posts/{postId} {
      // 비회원도 게시글 읽기 가능, 로그인 유저는 CRUD 가능
      allow read: if true;  // 비회원도 게시글 열람 가능
      allow create, update, delete: if isAuthenticated();  // 로그인 사용자만 가능
    }
    
    // reports 컬렉션 규칙
    match /reports/{reportId} {
      allow read: if isAuthenticated();  // 로그인 사용자만 읽기 가능
      allow create, update, delete: if isAuthenticated();  // 로그인 사용자만 C,U,D 가능
    }
    
    // infos 컬렉션 규칙 (사용자 개인정보)
    match /infos/{infoId} {
      // 개인정보는 로그인한 본인만 수정 가능
      allow read: if true;  // 다른 사용자 정보 조회 가능
      allow create, update, delete: if isAuthenticated();  // 로그인 사용자만 생성/수정/삭제 가능
    }
  }
}

이 규칙은 비회원도 게시글을 열람할 수 있지만, 게시글 작성이나 신고 등의 기능은 로그인한 사용자만 가능하도록 제한한다. 또한 회원가입 시 중복 계정 생성을 방지하고, 사용자는 자신의 정보만 수정할 수 있도록 했다.

사용자 인증 흐름 개발

Apple 로그인을 이용한 간편 인증 시스템을 구현했다. 인증 후에는 약관 동의 과정을 거쳐 사용자 추가 정보를 입력받는 흐름을 만들었다. 이 과정에서 실제 앱에서 필요한 복잡한 인증 흐름을 경험할 수 있었다.

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
    if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential,
       let identityToken = appleIDCredential.identityToken,
       let tokenString = String(data: identityToken, encoding: .utf8),
       let rawNonce = rawNonce {
        
        let credential = OAuthProvider.credential(
            providerID: AuthProviderID.apple,
            idToken: tokenString,
            rawNonce: rawNonce
        )
        
        Auth.auth().signIn(with: credential) { [weak self] authResult, error in
            if let error = error {
                print("Firebase 인증 실패: \(error.localizedDescription)")
                return
            }
            
            guard let user = authResult?.user else { return }
            
            // Firestore에 사용자 정보 저장
            self?.createUserDocument(for: user)
            
            // UserDefaults에 UID 저장
            UserDefaults.standard.set(user.uid, forKey: "userId")
            UserDefaults.standard.synchronize()
            
            // 기존 사용자 정보 검증 후 화면 전환
            self?.checkExistingUserInfo(userID: user.uid)
        }
    }
}

인증 후에는 SceneDelegate에서 사용자 상태를 확인하여 적절한 화면으로 이동하는 로직을 구현했다:

private func checkCurrentUser() {
    if let currentUser = Auth.auth().currentUser {
        // 로그인된 유저가 있으면 Firestore의 유저 정보를 검증하고 적절한 화면으로 이동
        checkUserDocument(userID: currentUser.uid)
    } else {
        // 로그인되지 않은 경우 SignUpVC로 이동
        showSignUpVC()
    }
}

private func checkUserDocument(userID: String) {
    let db = Firestore.firestore()

    db.collection("users").document(userID).getDocument { [weak self] document, _ in
        if let document = document, document.exists {
            let termsAccepted = document.data()?["termsAccepted"] as? Bool ?? false
            
            if termsAccepted {
                // 약관에 동의한 유저라면 추가 정보 검증
                self?.checkUserInfoExists(userID: userID)
            } else {
                // 약관에 동의하지 않았다면 약관 동의 화면으로 이동
                self?.showSignUpVC()
            }
        } else {
            // 유저 문서가 존재하지 않으면 SignUpVC로 이동
            self?.showSignUpVC()
        }
    }
}

이러한 방식으로 사용자 상태에 따라 적절한 화면으로 이동하는 흐름을 구현했다. 이는 실제 앱에서 자주 사용되는 패턴으로, 사용자 경험을 해치지 않으면서도 필요한 정보를 수집할 수 있는 방법이다.

게시글 CRUD 기능 구현

게시글 CRUD 기능은 PostService 클래스를 통해 구현했다. 특히 무한 스크롤을 위한 페이징 처리와 실시간 데이터 업데이트를 위한 NotificationCenter 활용이 핵심이었다.

func getPostList(type: String?, position: String?, lastDocument: DocumentSnapshot?, completion: @escaping (Result<([Post], DocumentSnapshot?), Error>) -> Void) {
    var query: Query = db.collection("posts")
    
    // 필터링 로직
    if let type = type {
        query = query.whereField("postType", isEqualTo: type)
    }
    
    if let position = position {
        query = query.whereField("position", arrayContains: position)
    }
    
    // 페이징 처리
    query = query.order(by: "createdAt", descending: true).limit(to: 20)
    
    if let lastDocument = lastDocument {
        query = query.start(afterDocument: lastDocument)
    }
    
    // 쿼리 실행 및 결과 처리
    query.getDocuments { snapshot, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        
        guard let documents = snapshot?.documents else {
            completion(.success(([], nil)))
            return
        }
        
        let posts = documents.compactMap { try? $0.data(as: Post.self) }
        let lastDocument = documents.last
        
        // 신고한 게시글 필터링
        UserInfoService.shared.filterReportedPosts(posts: posts) { filteredPosts in
            self.getBlockedUsers { blockedUsers in
                let finalPosts = filteredPosts.filter { post in
                    // 차단된 사용자의 게시글 제외
                    !blockedUsers.contains(post.userId)
                }
                completion(.success((finalPosts, lastDocument)))
            }
        }
    }
}

페이징 처리를 위해 Firestore의 lastDocument를 활용했으며, 사용자가 신고한 게시글이나 차단한 사용자의 게시글은 필터링하여 표시하지 않도록 구현했다.

게시글 목록 화면에서는 이 서비스를 활용하여 무한 스크롤을 구현했다:

// 스크롤이 하단에 도달했을 때 추가 데이터 로드
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let offsetY = scrollView.contentOffset.y
    let contentHeight = scrollView.contentSize.height
    let frameHeight = scrollView.frame.height
    
    if offsetY > contentHeight - frameHeight - 100 {
        loadNextPage()
    }
}

private func loadNextPage() {
    guard hasMoreData, !isLoading, let lastDocument = lastDocument else { return }
    isLoading = true
    postListView.collectionView.reloadData() // 로딩 인디케이터 표시
    
    PostService.shared.getPostList(type: postType?.rawValue, position: category, lastDocument: lastDocument) { [weak self] result in
        guard let self = self else { return }
        
        switch result {
        case .success((let newPosts, let nextLastDocument)):
            self.postList += newPosts
            self.lastDocument = nextLastDocument
            self.hasMoreData = nextLastDocument != nil
            self.postListView.collectionView.reloadData()
        case .failure(let error):
            print(error)
            self.basicAlert(title: "서버 에러", message: "")
        }
        self.isLoading = false
    }
}

게시글 작성 화면 구현

게시글 작성 화면은 게시글 유형(팀원 모집/팀 합류)에 따라 다른 입력 필드를 제공하도록 구현했다. 이를 위해 BaseUploadView를 상속하는 두 개의 뷰 클래스를 만들었다:

class BaseUploadView: UIView {
    // 공통 UI 요소들
    let scrollView = UIScrollView()
    let contentView = UIView()
    let postTypeLabel = UILabel()
    lazy var submitButton = UIButton(type: .system)
    lazy var titleSection = LabelAndTextField(title: "제목", placeholder: "제목을 입력해주세요")
    let detailLabel = UILabel()
    lazy var detailTextView = UITextView()
    
    // 공통 설정 및 레이아웃
}

final class RecruitMemberUploadView: BaseUploadView {
    let postType: PostType = .recruitMember
    
    lazy var positionSection = LabelAndTagSection(postType: postType, sectionType: .position, isDuplicable: true)
    lazy var techStackTextField = LabelAndTextField(title: "필요한 기술 스택", placeholder: "예시: Swift, Figma, 등등")
    lazy var urgencySection = LabelAndTagSection(postType: postType, sectionType: .urgency)
    // 기타 팀원 모집 전용 섹션들
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }
}

특히 태그 선택 UI를 구현하기 위해 LabelAndTagSection 클래스를 만들어 재사용했다:

final class LabelAndTagSection: UIView {
    weak var delegate: LabelAndTagSectionDelegate?
    
    let titleLabel = UILabel()
    let buttonStack = UIStackView()
    var buttons: [CustomTag] = []
    let isDuplicable: Bool
    var sectionType: SectionType?
    
    var selectedTitles: [String] {
        buttons.filter { $0.isSelected }.compactMap { $0.titleLabel?.text }
    }
    
    init(postType: PostType, sectionType: SectionType, isDuplicable: Bool = false) {
        self.isDuplicable = isDuplicable
        self.sectionType = sectionType
        super.init(frame: .zero)
        setupUI(labelTitle: sectionType.title(postType: postType),
                buttonTitles: sectionType.tagTitles(postType: postType))
    }
    
    // 버튼 생성 및 레이아웃 설정
}

이 클래스를 통해 다양한 태그 선택 UI를 일관된 방식으로 구현할 수 있었다. 또한 Delegate 패턴을 활용하여 선택된 태그 정보를 상위 뷰 컨트롤러에 전달했다:

protocol LabelAndTagSectionDelegate: AnyObject {
    func selectedButton(in view: LabelAndTagSection, button: CustomTag, isSelected: Bool)
}

extension RecruitMemberUploadVC: LabelAndTagSectionDelegate {
    func selectedButton(in view: LabelAndTagSection, button: CustomTag, isSelected: Bool) {
        guard let title = button.titleLabel?.text, let section = view.sectionType else { return }
        
        switch section {
        case .position:
            selectedPositions = view.selectedTitles
        case .urgency:
            selectedUrgency = title
        case .ideaStatus:
            selectedIdeaStatus = title
        // 기타 케이스 처리
        }
    }
}

신고 및 차단 시스템, 앱스토어 리젝 경험

사용자 경험 향상을 위해 게시글 신고와 사용자 차단 기능을 구현했다. 처음에는 간단한 신고 기능만 구현했는데, 이 부분이 앱스토어 심사에서 문제가 되었다. 앱스토어 심사 팀은 "단순한 신고 기능만으로는 유해 콘텐츠 관리가 불충분하다"는 이유로 앱을 리젝했다.

신고 및 차단 시스템, 앱스토어 리젝 경험

앱스토어 리젝 메시지는 다음과 같았다:

"귀하의 앱이 콘텐츠 및 행동 지침 5.6 - 개발자 행동 규칙을 준수하지 않아 거부되었습니다. 앱은 사용자가 생성한 콘텐츠에 대해 신고 메커니즘을 제공해야 하며, 불법적이거나 공격적인 콘텐츠를 신속하게 필터링하고 제거할 수 있는 시스템이 있어야 합니다."

이를 해결하기 위해 다음과 같은 기능들을 추가 구현했다:

  1. 신고 누적 시 자동 삭제: 신고 횟수가 5회 이상이면 게시글 자동 삭제 기능 구현

  2. 관리자 알림 시스템: Slack 웹훅 연동으로 신고 발생 시 관리자에게 즉시 알림

  3. 신고 내역 관리: 사용자별 신고 내역 및 게시글 신고 상태 관리 기능 추가

  4. 차단 사용자 관리: 사용자가 차단한 계정의 게시글이 피드에 나타나지 않도록 필터링

이러한 강화된 신고 시스템 덕분에 재심사에서 무사히 앱스토어 심사를 통과할 수 있었다. 특히 앱스토어 심사 대응은 실제 서비스를 출시하는 과정에서 겪을 수 있는 중요한 경험이었고, 사용자 생성 콘텐츠(UGC)를 다루는 앱에서 콘텐츠 관리와 모니터링 시스템의 중요성을 깨닫게 해주었다.

신고 카운트 증가 로직은 트랜잭션을 활용하여 동시성 문제를 해결했다:

func incrementReportCount(postId: String, completion: @escaping (Result<Void, Error>) -> Void) {
    let postRef = db.collection("posts").document(postId)
    
    // 트랜잭션을 사용하여 안전하게 카운트 증가
    db.runTransaction({ (transaction, errorPointer) -> Any? in
        let postDocument: DocumentSnapshot
        do {
            try postDocument = transaction.getDocument(postRef)
        } catch let fetchError as NSError {
            errorPointer?.pointee = fetchError
            return nil
        }
        
        // 현재 reportCount 값 가져오기
        let currentCount = postDocument.data()?["reportCount"] as? Int ?? 0
        let newCount = currentCount + 1
        
        // reportCount 증가
        transaction.updateData(["reportCount": newCount], forDocument: postRef)
        
        // 신고 횟수가 5회 이상이면 삭제 표시
        if newCount >= 5 {
            return true // 삭제가 필요함을 표시
        }
        
        return false // 삭제가 필요하지 않음
        
    }) { (needsDelete, error) in
        if let error = error {
            completion(.failure(error))
            return
        }
        
        // 5회 이상 신고되었다면 게시글 삭제
        if needsDelete as? Bool == true {
            self.deletePost(id: postId) { result in
                switch result {
                case .success:
                    // 신고 누적으로 삭제 시 Slack 알림 발송
                    self.sendSlackMessage(message: "⚠️ 게시글 자동 삭제: 신고 누적 5회 초과 (게시글 ID: \(postId))")
                    completion(.success(()))
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        } else {
            completion(.success(()))
        }
    }
}

또한 관리자에게 신고 상황을 알리기 위해 Slack Webhook을 활용했다:

func sendSlackMessage(message: String) {
    // URLRequest 객체를 사용하여 POST 요청을 생성
    var request = URLRequest(url: webhookURL)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    // 전송할 메시지를 JSON 형식으로 변환
    let body = ["text": message]

    do {
        // 딕셔너리를 JSON 데이터로 변환
        let jsonData = try JSONSerialization.data(withJSONObject: body, options: [])
        request.httpBody = jsonData
    } catch {
        print("JSON 데이터 변환 실패: \(error.localizedDescription)")
        return
    }

    // URLSession을 사용하여 네트워크 요청을 비동기로 전송
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        // 응답 처리 로직
    }

    task.resume()
}

이러한 신고 시스템의 UI도 사용자 친화적으로 개선했다. 특히 신고 화면은 다음과 같이 구현했다:

final class ReportVC: UIViewController {
    private let titleLabel = UILabel()
    private let reasonTitleLabel = UILabel()
    private let reasonStackView = UIStackView()
    private let detailTitleLabel = UILabel()
    private let reportDetailTextView = UITextView()
    private let reportButton = UIButton()
    
    private var selectedReason: String?
    private let post: Post
    private let reporterNickname: String
    
    init(post: Post, reporterNickname: String) {
        self.post = post
        self.reporterNickname = reporterNickname
        super.init(nibName: nil, bundle: nil)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupReasonButtons()
    }
    
    private func setupReasonButtons() {
        // 신고 사유 버튼 생성 및 설정
        let reasons = ["사기/피싱", "스팸/광고", "불법 정보", "욕설/혐오 발언", "성적인 콘텐츠", "기타"]
        
        // 각 버튼은 CustomTag 클래스 사용
        reasons.forEach { reason in
            let button = CustomTag()
            button.setTitle(reason, for: .normal)
            button.addTarget(self, action: #selector(reasonButtonTapped), for: .touchUpInside)
            reasonStackView.addArrangedSubview(button)
        }
    }
    
    @objc private func reasonButtonTapped(_ sender: CustomTag) {
        // 버튼 선택 상태 토글 및 시각적 피드백
        for case let button as CustomTag in reasonStackView.arrangedSubviews {
            button.isSelected = (button == sender)
            updateButtonAppearance(button)
        }
        
        selectedReason = sender.isSelected ? sender.titleLabel?.text : nil
        
        // "기타" 선택 시 텍스트뷰 활성화
        if sender.titleLabel?.text == "기타" {
            reportDetailTextView.isEditable = true
            reportDetailTextView.becomeFirstResponder()
        }
    }
    
    @objc private func reportButtonTapped() {
        // 신고 처리 로직
        // ... (코드 생략)
    }
}

차단 시스템은 사용자 정보에 차단한 사용자 ID 목록을 배열로 저장하여 구현했다:

func blockUser(userId: String, completion: @escaping (Result<Void, Error>) -> Void) {
    guard let currentUserId = UserDefaults.standard.string(forKey: "userId") else {
        completion(.failure(NSError(domain: "", code: -2, userInfo: [NSLocalizedDescriptionKey: "UserDefaults에 저장된 userId 없음."])))
        return
    }
    
    let userRef = db.collection("infos").whereField("userId", isEqualTo: currentUserId)
    
    userRef.getDocuments { userSnapshot, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        
        // snapshot에서 첫 번째 문서 추출
        guard let userDocument = userSnapshot?.documents.first else {
            completion(.success(()))
            return
        }
        
        // blockedUsers 배열에 uid 추가
        var blockedUsers = userDocument.data()["blockedUsers"] as? [String] ?? []
        
        // 중복 차단 방지
        if !blockedUsers.contains(userId) {
            blockedUsers.append(userId)
        }
        
        // 데이터 업데이트
        self.db.collection("infos").document(userDocument.documentID).updateData([
            "blockedUsers": blockedUsers
        ]) { error in
            if let error = error {
                completion(.failure(error))
            } else {
                print("\(userId) 차단 성공")
                completion(.success(()))
            }
        }
    }
}

UI/UX 개선

사용자 경험 향상을 위해 다양한 UI 컴포넌트와 패턴을 활용했다:

  1. BaseView: 공통 UI 컴포넌트의 상속 구조 구현
  2. TagFlowLayout: 동적 태그 배치 시스템
  3. LoadingFooterView: 페이징 로딩 인디케이터
  4. CustomTag: 재사용 가능한 태그 UI

특히 TagFlowLayout은 동적으로 태그를 배치하는 데 큰 도움이 되었다:

class TagFlowLayout: UIView {
    private var tags: [UIView] = []
    private let horizontalSpacing: CGFloat = 8
    private let verticalSpacing: CGFloat = 8
    
    func addTag(_ tagView: UIView) {
        tags.append(tagView)
        addSubview(tagView)
        setNeedsLayout()
        invalidateIntrinsicContentSize()
    }
    
    func removeAllTags() {
        tags.forEach { $0.removeFromSuperview() }
        tags.removeAll()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        var currentX: CGFloat = 0
        var currentY: CGFloat = 0
        var maxHeight: CGFloat = 0
        
        for tag in tags {
            let tagSize = tag.sizeThatFits(bounds.size)
            
            if currentX + tagSize.width > bounds.width {
                currentX = 0
                currentY += maxHeight + verticalSpacing
                maxHeight = 0
            }
            
            tag.frame = CGRect(x: currentX, y: currentY, width: tagSize.width, height: tagSize.height)
            currentX += tagSize.width + horizontalSpacing
            maxHeight = max(maxHeight, tagSize.height)
        }
        
        invalidateIntrinsicContentSize()
    }
    
    override var intrinsicContentSize: CGSize {
        var maxY: CGFloat = 0
        for tag in tags {
            maxY = max(maxY, tag.frame.maxY)
        }
        return CGSize(width: bounds.width, height: maxY)
    }
}

이러한 레이아웃은 게시글 상세 화면에서 기술 스택이나 직무 태그를 표시하는 데 사용되었다.

🚧 트러블 슈팅

1. 백그라운드 동기화 문제

앱이 백그라운드 상태일 때 데이터 동기화가 실패하는 문제가 있었다. 특히 사용자가 앱을 완전히 종료하지 않고 백그라운드에 두었다가 다시 포그라운드로 가져올 때, 새로운 게시글이나 변경사항이 반영되지 않는 문제가 있었다.

이를 해결하기 위해 AppDelegate에 UIApplicationDelegate 메서드를 구현하여 앱이 백그라운드에서 포그라운드로 돌아올 때 데이터를 다시 로드하도록 했다:

func applicationWillEnterForeground(_ application: UIApplication) {
    // 앱이 포그라운드로 돌아올 때 알림 발송
    NotificationCenter.default.post(name: .appWillEnterForeground, object: nil)
}

그리고 각 뷰 컨트롤러에서 이 알림을 수신하여 데이터를 새로고침하도록 구현했다:

// 알림 등록
NotificationCenter.default.addObserver(
    self,
    selector: #selector(handleAppWillEnterForeground),
    name: .appWillEnterForeground,
    object: nil
)

// 알림 수신 처리
@objc private func handleAppWillEnterForeground() {
    refreshData() // 데이터 새로고침
}

2. 메모리 누수 문제

화면 전환 시 메모리 누수가 발생하는 문제를 발견했다. 특히 클로저 내부에서 self를 강한 참조하는 경우가 많았다. weak self 패턴과 적절한 클로저 캡처 리스트를 사용하여 해결했다.

// 메모리 누수가 발생하는 코드
PostService.shared.getPostList(type: postType?.rawValue, position: category, lastDocument: nil) { result in
    switch result {
    case .success((let newPosts, let lastDocument)):
        self.postList = newPosts  // 여기서 self를 강한 참조
        self.lastDocument = lastDocument
        self.postListView.collectionView.reloadData()
        self.refreshControl.endRefreshing()
    case .failure(let error):
        print(error)
        self.basicAlert(title: "서버 에러", message: "")
    }
    self.isLoading = false  // 여기서도 self를 강한 참조
}

// 메모리 누수 해결
PostService.shared.getPostList(type: postType?.rawValue, position: category, lastDocument: nil) { [weak self] result in
    guard let self = self else { return }
    
    switch result {
    case .success((let newPosts, let lastDocument)):
        self.postList = newPosts
        self.lastDocument = lastDocument
        self.postListView.collectionView.reloadData()
        self.refreshControl.endRefreshing()
    case .failure(let error):
        print(error)
        self.basicAlert(title: "서버 에러", message: "")
    }
    self.isLoading = false
}

또한 Instruments의 Leaks와 Allocations 도구를 활용하여 메모리 누수를 추적하고 해결했다. 특히 Firebase 리스너나 NotificationCenter 옵저버가 제대로 해제되지 않아 발생하는 메모리 누수가 많았다.

3. 데이터 일관성 문제

여러 페이지에서 동일한 데이터를 표시할 때 일관성 문제가 발생했다. 예를 들어, 게시글 상세 화면에서 게시글을 수정하거나 삭제한 후 목록 화면으로 돌아갔을 때, 목록이 업데이트되지 않는 문제가 있었다.

이를 해결하기 위해 NotificationCenter를 활용해 데이터 변경 이벤트를 전파하고, 화면을 갱신하는 방식을 도입했다:

// 게시글 수정/삭제 후 알림 발송
NotificationCenter.default.post(name: .postUpdated, object: nil)

// 알림 수신 및 처리
@objc private func handlePostUpdated() {
    refreshData() // 데이터 새로고침
}

이러한 방식으로 게시글 목록 화면, 메인 화면 등 여러 화면에서 일관된 데이터를 표시할 수 있었다.

📊 성과와 배운 점

기술적 성과

  1. Firebase 실시간 데이터베이스 활용:

    • NoSQL 데이터베이스의 구조 설계 및 쿼리 최적화 경험
    • 실시간 데이터 동기화 및 트랜잭션 처리 구현
  2. MVVM 패턴 적용:

    • 비즈니스 로직과 UI 로직의 명확한 분리
    • 코드 가독성 및 유지보수성 향상
    • 단위 테스트 용이성 증가
  3. 코드 기반 UI 구현:

    • SnapKit과 Then을 활용한 선언적 UI 작성
    • 재사용 가능한 UI 컴포넌트 설계
    • 효율적인 레이아웃 관리
  4. 백그라운드 처리:

    • 앱 생명주기 관리 및 상태 전환 처리
    • 백그라운드 작업 구현 및 최적화
  5. 앱스토어 심사 대응:

    • 심사 가이드라인 준수 방법 학습
    • 신고 및 콘텐츠 관리 시스템 강화
    • 피드백을 통한 개선 과정 경험

협업 관련 배움

  1. Git 브랜치 전략:

    • Feature 브랜치와 PR 리뷰를 통한 효율적 협업
    • Conflict 해결 프로세스 확립
    • 코드 리뷰 문화 정착
  2. 코드 리뷰:

    • 상호 리뷰를 통한 코드 품질 향상
    • 코딩 컨벤션 통일
    • 지식 공유 및 상호 학습
  3. 일정 관리:

    • MVP 개발과 우선순위 설정의 중요성
    • 주간 스프린트 계획 및 회고
    • 목표 설정 및 진행 상황 추적
  4. 분업과 통합:

    • 역할 분담과 코드 통합 과정의 체계화
    • 컴포넌트 기반 개발로 의존성 최소화
    • 인터페이스 먼저 정의 후 구현하는 방식의 효율성

개인적인 성장

이번 프로젝트를 통해 다음과 같은 개인적인 성장을 경험했다:

  1. 문제 해결 능력: 다양한 기술적 문제를 해결하면서 문제 분석 및 해결 능력이 향상되었다.
  2. 코드 품질 의식: 코드 리뷰를 통해 더 나은 코드를 작성하려는 의식이 생겼다.
  3. 사용자 중심 설계: 기능 구현 뿐만 아니라 사용자 경험을 고려한 설계의 중요성을 배웠다.
  4. 팀 커뮤니케이션: 효과적인 의사소통과 협업의 중요성을 체감했다.
  5. 앱스토어 심사 대응: 앱스토어 심사 기준에 맞춘 기능 개선 경험을 통해 실제 서비스 출시 과정을 이해할 수 있었다.

🔮 향후 계획

현재 주요 기능은 구현되었지만, 아직 개선할 점이 많다:

  • 쪽지 기능: 사용자 간 직접 소통 채널 제공
  • 태그 셀렉트 기능: 더 정밀한 필터링 제공
  • 북마크 기능: 관심 게시글 저장
  • 앱내 지원/신청 기능: 지원서 작성 및 관리
  • 댓글-답글 기능: 게시글 내 소통 활성화
  • Q&A 게시판: 일반적인 질문과 답변 공간
  • 잡담 게시판: 커뮤니티 활성화
  • 스터디/공모전 게시판: 추가 카테고리 확장

기술적으로도 다음과 같은 개선을 계획하고 있다:

  1. 캐시 최적화: 네트워크 요청 최소화 및 오프라인 지원
  2. UI 테마 시스템: 다크 모드 지원 및 일관된 디자인 시스템 구축
  3. 단위 테스트 추가: 코드 안정성 및 신뢰성 향상
  4. 딥링크 지원: 외부에서 특정 게시글로 바로 접근 가능하도록 구현
  5. 성능 최적화: 앱 시작 시간 단축 및 메모리 사용량 최적화

마치며

Ting 앱 개발 프로젝트는 기술적 도전과 팀 협업 경험, 그리고 실제 서비스 출시 과정까지 경험할 수 있는 소중한 기회였다. 특히 앱스토어 심사 과정에서의 리젝과 이를 극복하는 과정은 실제 서비스 출시에 필요한 다양한 요소들을 고려하게 만들었다.

이번 경험을 통해 단순히 기능 구현에만 집중하는 것이 아니라, 사용자 경험 설계부터 콘텐츠 관리, 서비스 안정성, 성능 최적화까지 종합적인 시각으로 앱 개발을 바라보는 안목을 기를 수 있었다. 앞으로도 이러한 경험을 바탕으로 더 나은 개발자로 성장해 나가고 싶다.

profile
이것저것 많이 해보고싶은 사람

0개의 댓글

관련 채용 정보