본문 바로가기
개발공부/프로젝트

최애 포토카드 컬렉션 Photo Binder 어플 개발 (Flutter)

by 바위바위게 2026. 8. 19.
반응형

📒 [Flutter] 포토카드 컬렉션을 실제 바인더처럼 관리하는 앱,
Photo Binder 개발기

"내가 어떤 포토카드를 가지고 있었지? 이 카드는 어느 앨범 특전이었지?"

포토카드 수집량이 늘어날수록 보유 현황과 구매 금액을 한눈에 파악하기가 어려워집니다. 이러한 불편함을 해결하기 위해 실물 바인더에 포토카드를 정리하듯 앱에서 컬렉션을 관리할 수 있는
Flutter 기반 모바일 앱 'Photo Binder'  개발했습니다.

📌 프로젝트 소개

📌 1. 프로젝트 소개 & 주요 기능

Pocket Binder는 포토카드 수집가를 위한 개인 컬렉션 관리 앱입니다.
단순히 이미지 갤러리처럼 사진을 나열하는 방식이 아닌, 실제 바인더에 속지를 넣고 포토카드를 꽂는 구조를 앱으로 구현했습니

📁 Pocket Binder 핵심 기능 요약
├─ 🎨 바인더 관리: 그룹/멤버별 컬렉션 생성 & 커스텀 표지 꾸미기 (4가지 테마)
├─ 🗂️ 슬롯 관리: 전체/보유/미보유 카드를 바인더 속지 형태로 한눈에 확인
├─ 📷 사진 등록: 문서 스캐너 기법을 활용한 보정 촬영 & 포카 전용 비율(68:100) 크롭
├─ 📝 상세 장부: 앨범, 버전, 특전처, 구매일, 가격, 메모 기록
├─ 🔍 편리한 UX: InteractiveViewer 전체 화면 확대 & 다중 선택 일괄 삭제
├─ 📊 통계/진행률: 바인더별 수집률(%) 및 유연한 정규식을 적용한 총 지출 금액 자동 합산
└─ 💾 아키텍처/DB: SQLite 로컬 저장 & Riverpod 클린 아키텍처 기반 상태 관리

🎨 2. 바인더 생성 및 커버 꾸미기

앱을 실행하면 컬렉션 단위로 관리할 바인더를 생성할 수 있습니다.

1) 바인더 생성 예시

  • IVE 안유진 포토카드
  • SEVENTEEN 민규 컬렉션
  • 앨범별 포토카드
  • 팬미팅 특전 카드

홈 화면에서는 생성된 바인더들이 카드 형태로 배치되며, 각 바인더별 전체 카드 수와 보유 카드 수가 실시간으로 표시됩니다.

2) 바인더 커버 커스텀
실제 바인더 표지를 꾸미는 재미를 더하기 위해 커버 커스텀 기능을 구현했습니다.

  • 설정 항목: 커버 이미지, 커버 제목, 커버 부제목, 테마
  • 제공 테마: Glitter, Concert, Pastel check, Album mood

💡 커버 이미지를 별도로 선택하지 않아도, 지정한 테마에 맞는
그라데이션 배경이 자동 적용되어 디자인 완성도를 높였습니다.

.

일반 이미지 커스텀

🗂️ 3. 포토카드 슬롯 관리 및 보유 여부 자동 판별

바인더 상세 화면에서는 카드가 그리드 형태의 슬롯으로 표시됩니다.

[앨범 전체 포토카드 10장 구성 시]
 ├─ 10개의 슬롯 생성
 ├─ 사진이 등록된 슬롯 7개  ➔ 보유 카드 (70%)
 └─ 사진이 없는 빈 슬롯 3개  ➔ 미보유 카드 (30%)

별도의 보유/미보유 체크박스를 두지 않고, 사진 등록 여부 자체를 보유 기준으로 삼도록 설계하여 UX를 단순화했습니다. 아직 구하지 못한 카드는 빈 슬롯으로 미리 만들어 두어 수집 목록(Wishlist)처럼 활용할 수 있습니다.

📷 4. 카메라 촬영 스캔 & 포카 규격 크롭

이미지 처리

카메라 촬영 및 문서 스캔

카메라 촬영은 cunning_document_scanner를 통해 네이티브 문서 스캔 기능을 사용합니다. Android에서는 ML Kit 기반 문서 스캐너가 포토카드의 모서리와 경계를 감지하고, 기울어진 상태로 촬영된 이미지의 원근과 영역을 자동으로 보정합니다.

이미지 보정 예시

ML 을 활용한 자동 모서리 보정 및 갤러리 사진 크롭

처리 단계동작

원본 촬영 포토카드와 주변 배경이 함께 촬영될 수 있음
모서리 감지 문서 스캐너가 포토카드의 네 모서리를 탐지
원근 보정 기울어진 카드 이미지를 정면 형태로 변환
결과 저장 보정된 이미지를 앱 전용 저장소에 복사

스캔 결과는 플러그인의 임시 경로에만 의존하지 않고, 앱의 문서 디렉터리 아래 photocard_binder 폴더로 복사해 영구 보관합니다. 파일명에는 현재 시각의 타임스탬프를 사용해 카드 이미지가 서로 덮어써지지 않도록 처리하며, 현재는 한 번의 촬영에서 한 장의 결과만 등록합니다

갤러리 이미지 크롭 (68:100)

갤러리 사진을 불러올 때는 포토카드 표준 비율인 68:100에 맞춰 크롭되도록 구현하여 바인더 그리드 내에서 사진 비율이 일정한 형태를 유지하도록 만들었습니다.

// 포토카드 표준 규격 비율 고정 (68mm x 100mm)
AspectRatio(
  ratioX: 68,
  ratioY: 100,
)

📝 5. 포토카드 상세 장부 & 통계 기능

항목 입력 예시 비고
앨범명 / 버전 LOVE DIVE / A Ver. 앨범 및 버전 구분
특전처 / 입수처 위드뮤 럭키드로우, 팬싸 특전 등 구분
구매일 / 가격 2026.08.10 / 15,000 지출 합산에 자동 반영
메모 교환으로 입수 상세 비고 기록

 

🔍 6. InteractiveViewer & 다중 삭제

  • 상세 확대 보기: InteractiveViewer를 적용해 등록된 포토카드를 핀치 투 줌으로 확대하여 사인, 홀로그램 디테일 등을 확인할 수 있습니다.
  • 선택 모드 일괄 삭제: 카드가 많을 때 한 장씩 삭제하는 번거로움을 줄이기 위해 다중 선택 모드를 지원합니다.
  • CASCADE 트랜잭션: 바인더 자체를 삭제하면 DB의 ON DELETE CASCADE 외래 키 설정으로 해당 바인더에 속한 카드 및 사진 데이터가 한꺼번에 안전하게 정리됩니다.

💾 7. 데이터 저장 구조 (SQLite)

[SQLite DB]
 ├─ binders (id, name, group_name, color, cover_image_path, cover_title, cover_subtitle, theme_id)
 └─ cards (id, binder_id, title, image_path, album, version, benefit_source, purchase_date, price, memo, created_at)

[App Documents Directory]
 └─ 실제 포토카드 이미지 파일 저장

서버 없이 사용자 기기 내부에 데이터를 안전하게 관리할 수 있도록 sqflite를 사용했습니다.

💡 이미지를 DB에 직접 저장하지 않은 이유
포토카드 이미지를 BLOB 형태로 DB에 넣으면 DB 파일 용량이 커져 조회 속도가 느려집니다. 따라서 이미지는 기기의 문서 디렉터리에 물리 파일로 저장하고, DB에는 해당 파일의 image_path 문자열만 기록했습니다.

🏗️ 8. 상태 관리 & 아키텍처 (Riverpod + Clean Architecture)

상태 관리는 flutter_riverpod을 사용했으며, Presentation / Domain / Data 계층으로 명확히 구획했습니다.

lib/
└── feature/
    ├── domain/         # 핵심 비즈니스 로직 & Entity (PhotoCard, MemberBinder)
    │   ├── entities/
    │   ├── repositories/ # 인터페이스 정의
    │   └── usecases/
    ├── data/           # 데이터 소스 및 DB 저장소 구현부
    │   └── repositories/ # SqfliteBinderRepository
    └── presentation/   # UI & Riverpod 상태 관리
        ├── provider/   # BinderNotifier, BinderState
        └── view/
UI 위젯이 DB에 직접 접근하지 않도록 분리하여, 향후 백엔드 데이터베이스로 전환 시에도 Presentation 계층의 코드 변경을 최소화할 수 있습니다.

🏗️ 10. Riverpod & Freezed 기반의 단방향 상태 관리 (State Management)

Pocket Binder 프로젝트에서는 “화면(UI)이 데이터를 직접 수정하지 않고, 상태 변경의 경로를 Notifier 한곳으로 모은 구조”를 지향했습니다. 이를 위해 Riverpod과 Freezed를 조합하여 상태 관리 시스템을 구축했습니다.

1. Centralized App State (BinderState)

앱 전반에 필요한 핵심 상태 데이터를 하나의 BinderState 모델로 정의하고 중앙에서 관리합니다.

@freezed
abstract class BinderState with _$BinderState {
  const factory BinderState({
    required List<MemberBinder> binders,
    required List<PhotoCard> cards,
    String? selectedBinderId,
    @Default(false) bool isSaving,
    @Default(0) int tab,
  }) = _BinderState;
}

상태 필드별 역할 및 기능

상태 필드 (Field) 역할 (Role)
binders 사용자가 생성한 멤버/그룹 포토카드 바인더 목록
cards 앱 내에 등록된 모든 포토카드 객체 데이터 목록
selectedBinderId 바인더 상세 화면에서 현재 선택하여 열람 중인 바인더의 ID
isSaving 카메라 스캔, 이미지 변환, DB 저장 등 비동기 작업 진행 여부 (bool)
tab 메인 화면의 하단 탭 바 선택 위치 상태

2. Freezed를 통한 불변성(Immutability)과 안전한 상태 변경

Freezed 패키지를 채택한 가장 중요한 이유는 상태를 불변(Immutable) 객체로 다루기 위해서입니다.

예를 들어 카드 목록에 새 포토카드를 추가할 때, 기존 메모리상의 리스트(state.cards)를 직접 변경(push/add)하지 않고 copyWith를 사용하여 새로운 불변 객체를 반환합니다.

// ❌ 기존 상태 객체를 직접 수정하는 위험한 방식 (Side-effect 발생 가능)
state.cards.add(newCard); 

// ✅ Freezed copyWith을 통한 불변 상태 업데이트 방식
state = state.copyWith(
  cards: [...state.cards, newCard],
);

💡 불변 상태 변경의 장점

이전 상태 객체가 예기치 않게 변경되는 부작용(Side Effect)을 차단하며, Riverpod은 새 객체 참조가 전달되었을 때 해당 상태를 감시(ref.watch)하는 **관련 UI 위젯만 효율적으로 재빌드(Rebuild)**합니다.

3. 상태 변경 단일화 (BinderNotifier) & 단방향 데이터 흐름

상태를 변경하는 책임은 100% BinderNotifier에만 부여했습니다.

final binderProvider = NotifierProvider<BinderNotifier, BinderState>(
  BinderNotifier.new,
);

단방향 데이터 흐름 (Unidirectional Data Flow)

UI 화면은 데이터 변경 로직을 몰라도 되며, 렌더링 시에는 ref.watch(), 이벤트를 보낼 때에는 ref.read()를 사용해 Notifier에 동작을 요청하기만 합니다.

데이터 처리 흐름

사용자 입력 -> Card 생성 UseCase - > Repository -> SQLite 저장 -> RiverPod State 업데이트 -> UI 갱신

핵심 코드 구현 예시

① 바인더 생성 함수 (addBinder)

void addBinder({
  required String name,
  required String group,
}) {
  // UseCase를 활용해 도메인 객체 생성
  final binder = _createMemberBinder(
    name: name,
    group: group,
    colorValue: _colors[state.binders.length % _colors.length],
  );

  // 불변 상태 업데이트
  state = state.copyWith(
    binders: [...state.binders, binder],
  );

  // 로컬 저장소 저장 호출
  _save();
}

② 포토카드 정보 수정 함수 (updateCardRecord)

void updateCardRecord({
  required String cardId,
  required String album,
  required String version,
  required String benefitSource,
  required DateTime acquiredAt,
  required String price,
  required String memo,
}) {
  // UpdatePhotoCardRecord UseCase 실행 결과를 새 상태로 반영
  state = state.copyWith(
    cards: _updatePhotoCardRecord(
      cards: state.cards,
      cardId: cardId,
      album: album,
      version: version,
      benefitSource: benefitSource,
      acquiredAt: acquiredAt,
      price: price,
      memo: memo,
    ),
  );

  _save();
}

🌟 이 상태 관리 아키텍처가 주는 핵심 이점 3가지

  1. 역할의 명확한 분리 (Separation of Concerns): UI 위젯은 다이얼로그나 그리드 표시 같은 ‘화면 표현’ 역할에만 집중할 수 있습니다.
  2. 상태 변경 추적 용이성: 모든 데이터 변경 흐름이 BinderNotifier 한곳으로 모이기 때문에 디버깅과 로깅이 쉬워집니다.
  3. 높은 유닛 테스트 가능성 (Testability): 데이터 비즈니스 검증 규칙은 UseCase 계층에 분리되어 있어, UI나 DB 없이도 도메인 로직을 독립적으로 단위 테스트할 수 있습니다.

🛠️ 9. 프로젝트 사용 기술 스택

구분 기술 스택
Framework / Language Flutter / Dart
State Management Riverpod
Model / Immutable Data Freezed
Database SQLite (sqflite)
Image & Camera image_picker, image_cropper, cunning_document_scanner
File Storage path_provider

- 현재 내부테스터 모집중 ( 8월 28 일 크몽을 통한 테스터 시작 ~    ) 

🚀 11. 향후 개선 방향 (Roadmap)

  • [ ] ☁️ 클라우드 동기화: Supabase / Firebase 연동을 통한 멀티 디바이스 지원
  • [ ] 🤖 On-Device AI 자동 인식: 카메라 촬영 시 포토카드의 멤버, 그룹, 앨범 정보를 자동 추출
  • [ ] 💾 데이터 백업 및 복원: ZIP/JSON 형태의 데이터 Export & Import 기능 추가
  • [ ] 🔄 교환/중복 카드 관리 기능: 중복 카드를 별도 관리하고 교환 리스트 구성
  • [ ] 📈 가격 통계 시각화: 멤버별/월별/앨범별 지출 비율 차트 및 평균 구매가 제공