Skip to content

Latest commit

 

History

History
608 lines (507 loc) · 16.3 KB

File metadata and controls

608 lines (507 loc) · 16.3 KB

Nesty

P2P 기반 익명 소셜 채팅 앱. MultipeerConnectivity를 사용해 같은 공간에 있는 주변 사용자와 익명으로 소통한다.

핵심 컨셉

  • 익명성: 번호 기반 식별 (#1 ~ #100), 닉네임은 선택
  • 진화 시스템: 머무른 시간에 따라 진화 (🥚 egg → 🐣 hatch → 🐥 chick → 🕊️ bird)
  • 로컬 연결: 같은 Wi-Fi/Bluetooth 범위 내 사용자끼리만 소통
  • 세 가지 소통 방식: 홈(피어 목록), 공개 채팅, 1:1 대화

기술 스택

  • iOS 17.6+
  • SwiftUI
  • MultipeerConnectivity
  • Observation Framework (@Observable)

아키텍처: MV 패턴

ViewModel 없이 View에서 Model/Service를 직접 참조한다.

싱글톤 서비스

P2PService.shared      // P2P 연결 및 메시지 송수신
UserSession.shared     // 사용자 정보 및 설정
DirectChat.shared      // 1:1 대화 관리
PublicChat.shared      // 공개 채팅 관리

View 구조 원칙

  • View는 UI 렌더링과 사용자 인터랙션만 담당
  • 비즈니스 로직은 Model/Service에 위임
  • @State는 UI 상태에만 사용 (시트 표시, 입력 텍스트 등)

프로젝트 구조

Nesty/
├── App/
│   └── NestyApp.swift
├── Core/
│   ├── Components/
│   │   ├── CustomTabBar.swift
│   │   ├── DMRequestSheet.swift
│   │   ├── MessageInputBar.swift
│   │   └── ToastModifier.swift
│   ├── Extensions/
│   │   └── Color+Extensions.swift
│   └── Models/
│       ├── MainTab.swift
│       └── NestStage.swift
├── Features/
│   ├── Direct/
│   │   ├── Models/
│   │   │   ├── DirectMessage.swift
│   │   │   └── DMConversation.swift
│   │   ├── Services/
│   │   │   └── DirectChat.swift
│   │   └── Views/
│   │       ├── DirectChatView.swift
│   │       ├── DirectConversationRow.swift
│   │       ├── DirectEmptyStateView.swift
│   │       ├── DirectHeaderView.swift
│   │       ├── DirectMessageRow.swift
│   │       ├── DirectRoomView.swift
│   │       ├── DMRequestRow.swift
│   │       └── NicknameEditSheet.swift
│   ├── Entry/
│   │   ├── EntryView.swift
│   │   ├── MainTabView.swift
│   │   └── SplashView.swift
│   ├── Home/
│   │   ├── Models/
│   │   │   └── SelectedSender.swift
│   │   └── Views/
│   │       ├── HomeHeaderView.swift
│   │       ├── HomeView.swift
│   │       ├── PeerChip.swift
│   │       └── PeerGridView.swift
│   └── Public/
│       ├── Models/
│       │   └── Message.swift
│       ├── Services/
│       │   └── PublicChat.swift
│       └── Views/
│           ├── NewMessageButton.swift
│           ├── PublicChatView.swift
│           ├── PublicEmptyStateView.swift
│           ├── PublicHeaderView.swift
│           ├── PublicMessageList.swift
│           ├── PublicMessageRow.swift
│           └── SystemMessageRow.swift
├── Network/
│   └── P2P/
│       ├── Models/
│       │   ├── P2PConnectionState.swift
│       │   ├── P2PMessageType.swift
│       │   ├── P2PPayload.swift
│       │   └── P2PPeer.swift
│       └── Services/
│           ├── MessageRouter.swift
│           └── P2PService.swift
├── Resources/
│   └── Assets.xcassets
├── Services/
│   ├── KeyboardObserver.swift
│   └── UserSession.swift
└── Info.plist

코드 컨벤션

파일 헤더

//
//  FileName.swift
//  Nesty
//
//  Created by 심범수 on M/DD/YY.
//

들여쓰기

  • 탭 사용 (스페이스 아님)

MARK 주석

모든 섹션에 MARK 주석 필수:

// MARK: - Properties
// MARK: - State
// MARK: - Dependencies
// MARK: - Body
// MARK: - Subviews
// MARK: - Actions
// MARK: - Preview

View 파일 구조

struct SomeView: View {

	// MARK: - State

	@State private var someState: Bool = false

	// MARK: - Dependencies

	private var service = SomeService.shared

	// MARK: - Body

	var body: some View {
		// ...
	}
}

// MARK: - Subviews

private extension SomeView {

	var someSubview: some View {
		// ...
	}
}

// MARK: - Actions

private extension SomeView {

	func someAction() {
		// ...
	}
}

// MARK: - Preview

#Preview {
	SomeView()
}

네이밍 규칙

타입 접미사 예시
메인 뷰 ~View HomeView, DirectChatView
목록 행 ~Row DirectConversationRow, DMRequestRow
시트 ~Sheet DMRequestSheet, NicknameEditSheet
빈 상태 ~EmptyStateView DirectEmptyStateView
헤더 ~HeaderView HomeHeaderView
서비스 ~Service 또는 명사 P2PService, UserSession

코드 스타일

// 좋음: 컴팩트하게
if condition { return }

// 피함: 불필요한 줄바꿈
if condition {
	return
}

// 좋음: 체이닝
conversations
	.filter { $0.status == .active }
	.sorted { $0.updatedAt > $1.updatedAt }

// 좋음: guard 조기 반환
func someMethod() {
	guard let value = optionalValue else { return }
	// 로직
}

코드 품질 가이드

함수 작성 원칙

  • 10줄 이내 권장: 한 함수는 한 가지 일만 수행
  • 파라미터 3개 이하: 많으면 객체로 묶기
  • 부수효과 최소화: 외부 상태 변경은 명확하게
// 나쁨: 49줄, 여러 책임 혼재
func sendRequest(to peer: P2PPeer, message: String) -> Bool {
	let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
	if !trimmed.isEmpty && !validateMessage(trimmed) { return false }
	guard !hasActiveConversation(with: peer.number) else { return false }
	removeInactiveConversation(with: peer.number)

	var initialMessages: [DirectMessage] = []
	if !trimmed.isEmpty {
		let message = DirectMessage(
			peerNumber: peer.number,
			senderNumber: UserSession.shared.userNumber,
			text: trimmed,
			isRead: true
		)
		initialMessages.append(message)
	}

	let conversation = DMConversation(
		peerNumber: peer.number,
		peerStage: peer.stage,
		peerNickname: peer.nickname ?? "",
		messages: initialMessages,
		status: .pending
	)
	conversations.append(conversation)

	if let foundPeer = findPeer(peer.number) {
		let payload = P2PPayload(...)
		P2PService.shared.sendDirectMessage(payload, to: foundPeer)
	}
	return true
}

// 좋음: 책임 분리
func sendRequest(to peer: P2PPeer, message: String) -> Bool {
	guard canSendRequest(to: peer.number, message: message) else { return false }

	let conversation = createConversation(for: peer, message: message)
	conversations.append(conversation)
	notifyPeer(peer, about: conversation)

	return true
}

private func canSendRequest(to peerNumber: Int, message: String) -> Bool {
	let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
	if !trimmed.isEmpty && !validateMessage(trimmed) { return false }
	return !hasActiveConversation(with: peerNumber)
}

private func createConversation(for peer: P2PPeer, message: String) -> DMConversation {
	removeInactiveConversation(with: peer.number)
	let messages = createInitialMessages(for: peer, text: message)
	return DMConversation(peerNumber: peer.number, ...)
}

네이밍 원칙

타입 접두사/패턴 예시
Bool 변수 is~, has~, can~, should~ isConnected, hasUnread, canSend
함수 (조회) get~, find~, fetch~ findPeer(), fetchMessages()
함수 (변경) update~, set~, remove~ updateStage(), removeConversation()
함수 (전송) send~, broadcast~, notify~ sendMessage(), broadcastUpdate()
함수 (처리) handle~, process~ handleDMRequest(), processPayload()
// 나쁨
var flag: Bool
var check: Bool
func doSomething()
func data() -> [Message]

// 좋음
var isConnected: Bool
var hasUnreadMessages: Bool
func sendDirectMessage()
func fetchConversations() -> [DMConversation]

에러 처리

guard 조기 반환 패턴 적극 활용:

// 나쁨: 중첩 깊어짐
func sendMessage(to peerNumber: Int, text: String) -> Bool {
	let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
	if validateMessage(trimmed) {
		if let index = conversationIndex(for: peerNumber) {
			if conversations[index].status == .active {
				// 실제 로직
				return true
			}
		}
	}
	return false
}

// 좋음: guard로 조기 반환
func sendMessage(to peerNumber: Int, text: String) -> Bool {
	let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)

	guard validateMessage(trimmed) else { return false }
	guard let index = conversationIndex(for: peerNumber),
		  conversations[index].status == .active
	else { return false }

	// 실제 로직
	return true
}

Result 타입 (복잡한 에러 처리 시):

enum SendError: Error {
	case invalidMessage
	case conversationNotFound
	case peerDisconnected
}

func sendMessage(to peerNumber: Int, text: String) -> Result<DirectMessage, SendError> {
	guard validateMessage(text) else { return .failure(.invalidMessage) }
	guard let index = conversationIndex(for: peerNumber) else { return .failure(.conversationNotFound) }
	guard let peer = findPeer(peerNumber) else { return .failure(.peerDisconnected) }

	let message = DirectMessage(...)
	return .success(message)
}

리팩토링 신호

신호 조치
함수가 20줄 초과 책임별로 분리
같은 코드 2번 이상 반복 헬퍼 함수로 추출
중첩 깊이 3단계 이상 guard 또는 early return 적용
주석 없이 이해 불가 함수/변수명 개선 또는 분리
파라미터 4개 이상 구조체로 묶기
// 신호: 같은 payload 생성 패턴 반복
func acceptRequest(from peerNumber: Int) {
	// ...
	let payload = P2PPayload(
		type: .dmRequestAccept,
		senderNumber: UserSession.shared.userNumber,
		senderStage: UserSession.shared.userStage,
		senderNickname: UserSession.shared.nickname,
		targetNumber: peerNumber
	)
	// ...
}

func declineRequest(from peerNumber: Int) {
	// ...
	let payload = P2PPayload(
		type: .dmRequestDecline,
		senderNumber: UserSession.shared.userNumber,  // 반복!
		senderStage: UserSession.shared.userStage,    // 반복!
		senderNickname: UserSession.shared.nickname,  // 반복!
		targetNumber: peerNumber
	)
	// ...
}

// 리팩토링: 헬퍼 함수 추출
private func createPayload(type: P2PMessageType, targetNumber: Int) -> P2PPayload {
	P2PPayload(
		type: type,
		senderNumber: UserSession.shared.userNumber,
		senderStage: UserSession.shared.userStage,
		senderNickname: UserSession.shared.nickname,
		targetNumber: targetNumber
	)
}

커밋 단위

원칙 설명
한 커밋 = 한 가지 변경 기능 추가, 버그 수정, 리팩토링 각각 별도 커밋
빌드 가능한 상태 모든 커밋은 빌드/실행 가능해야 함
리팩토링 분리 기능 변경과 코드 정리는 별도 커밋
# 나쁨: 여러 변경이 한 커밋에
git commit -m "feat: #21 DM 기능 구현 및 코드 정리"

# 좋음: 변경 단위로 분리
git commit -m "refactor: #21 payload 생성 헬퍼 함수 추출"
git commit -m "feat: #21 DM 요청 수락/거절 기능 구현"
git commit -m "fix: #21 읽음 처리 누락 버그 수정"

UI/UX 가이드라인

디자인 시스템

Color.background  // 앱 배경 (다크)
Color.main        // 메인 컬러 (액센트)
Color.flat        // 카드/입력창 배경

수치

요소
카드 cornerRadius 12
버튼 cornerRadius 10
기본 padding 20 (horizontal), 14 (vertical)
그리드 spacing 12

토스트

  • 위치: 상단 (top: 60)
  • 스타일: Capsule, 검정 배경 80%
  • 지속 시간: 2초
  • 애니메이션: top에서 slide + fade

폰트

.font(.system(size: 28, weight: .semibold))  // 헤더 타이틀
.font(.system(size: 18, weight: .semibold))  // 서브 타이틀
.font(.system(size: 16, weight: .medium))    // 본문
.font(.system(size: 14, weight: .regular))   // 보조 텍스트
.font(.system(size: 12, weight: .regular))   // 캡션

공용 컴포넌트

  • MessageInputBar: 메시지 입력창 (Public, Direct 공용)
  • DMRequestSheet: DM 요청 시트 (Home, Public 공용)
  • ToastModifier: 토스트 메시지 표시

기능 명세

진입 (EntryView)

  • 랜덤 번호 할당 (#1 ~ #100)
  • 입장 전에도 주변 유저 수 표시
  • "입장하기" 버튼으로 세션 시작

홈 (HomeView)

  • 3열 그리드로 주변 피어 표시
  • 본인은 상단 좌측에 항상 표시
  • 피어 탭 → DM 요청 시트
  • 활성 대화 중이면 "이미 대화 중인 유저입니다" 토스트

공개 채팅 (PublicChatView)

  • 역순 스크롤 (최신 메시지가 하단)
  • 발신자 칩 탭 → DM 요청 시트
  • 알림 토글 버튼 (bell / bell.slash)
  • 새 메시지 버튼 (스크롤 위치 추적)

1:1 대화 (DirectChatView)

  • 세 섹션: 메시지 요청, 대화, 보낸 요청
  • 카드형 UI (cornerRadius, shadow)
  • Swipe actions: 나가기
  • 대화방 진입 시 읽음 처리

1:1 채팅방 (DirectRoomView)

  • 상대방 진화 단계 실시간 반영
  • 닉네임 편집 시트
  • 나가기 확인 Alert
  • 키보드 연동 레이아웃

메시지 요청 흐름

  1. 요청 전송 (첫 메시지는 선택)
  2. 상대방 수락/거절 (햅틱 피드백)
  3. 수락 → active 상태로 전환
  4. 거절/나간 대화 → 재요청 가능

진화 시스템

단계 이모지 시간
egg 🥚 0초
hatch 🐣 60초
chick 🐥 180초
bird 🕊️ 540초

P2P 통신

Info.plist 필수 설정

<key>NSBonjourServices</key>
<array>
	<string>_nesty-chat._tcp</string>
	<string>_nesty-chat._udp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>주변 사용자와 연결하기 위해 로컬 네트워크 접근이 필요합니다.</string>

메시지 타입 (P2PPayload.MessageType)

  • publicMessage: 공개 채팅 메시지
  • dmRequest: DM 요청
  • dmRequestAccept: DM 요청 수락
  • dmRequestDecline: DM 요청 거절
  • directMessage: 1:1 메시지
  • dmReadReceipt: 읽음 확인
  • dmLeave: 대화방 나가기
  • peerInfo: 피어 정보 교환
  • stageUpdate: 진화 단계 업데이트
  • nicknameUpdate: 닉네임 업데이트

Git 컨벤션

커밋 메시지 구조

type: #<issue_number> <subject>

<body (optional)>

커밋 타입

Type Description
add 리소스 추가 (라이브러리, 컬러, 폰트, 이미지)
feat 새로운 기능 구현
fix 버그 수정
docs 문서 업데이트
style 코드 포맷팅 (로직 변경 없음)
design UI/UX 디자인 변경
refactor 코드 리팩토링 (기능 변경 없음)
test 테스트 추가 또는 업데이트
chore 빌드 프로세스, 패키지 설정, 환경 구성
rename 파일 또는 폴더 이름 변경
remove 파일 또는 폴더 삭제

Subject 규칙

  • 최대 50자
  • 한글로 작성
  • 이슈 번호 포함 (#)
  • 마침표 없음
  • 명확하고 간결하게

Body 규칙 (선택)

  • 한글로 작성
  • 무엇을, 어떻게, 왜 설명
  • Subject와 Body 사이 빈 줄

예시

# Body 없이
feat: #24 LoginView UI 구현

# Body 포함
feat: #24 로그인 UI 구현

로그인 화면의 초기 UI를 구성하면서 도메인과 프레젠테이션 계층을
명확히 분리한 클린 아키텍처 기반 구조를 도입했습니다.

PR 제목

feat: #이슈번호 기능명

이슈 연결

## 관련 이슈
- Resolved: #이슈번호

주의사항

하지 말 것

  • ViewModel 레이어 추가
  • 과도한 추상화
  • 불필요한 @State 남용
  • 스페이스 들여쓰기 (탭 사용)

선호하는 방식

  • 컴팩트한 코드
  • 한글 주석 및 커밋 메시지
  • 근본 원인 파악 후 수정
  • extension으로 코드 분리
  • guard 조기 반환 패턴

미구현 기능

  • SettingsView (닉네임 변경, 알림 설정)
  • 메시지 요청 차단 기능
  • HomeView 오래 머무른 순 정렬
  • 닉네임 변경 30일 제한