Flutter + Spring Boot로 앱 출시까지: 1인 개발 전과정 회고 (소셜로그인, JWT 등)

J_Eddy·2026년 3월 9일
post-thumbnail

2025년 한 해 동안 앱을 직접 기획하고 실제 스토어에 출시하는 도전을 해봤습니다. 현재 웹 프런트엔드와 백엔드 개발자로 일하고 있지만, 현업을 하다 보면 제가 속한 부서가 아닌 타 부서의 업무 흐름과 감수성을 이해해야 할 때가 많았습니다.

무엇보다 제로 베이스에서 기획부터 설계, 앱 개발(iOS, AOS), 백엔드 개발, QA, 서버 구성, 배포까지 전반적인 과정을 직접 경험해보는 것이 커리어에 큰 도움이 될 것이라 믿고 진행하게 되었습니다. (개발 과정에서 큰 도움을 준 Claude Code와 GPT에게 깊은 감사를 전합니다.)

주제

주제는 풋살 관련 앱을 선정했습니다. 기존에도 풋살 매칭 앱은 많았지만, 대부분 모르는 사람들과의 매칭을 위한 시스템이었을 뿐, 제가 소속된 소모임이나 동아리를 직접 관리할 수 있는 앱은 없었기 때문입니다.

현재 제가 속한 풋살 소모임의 일정 관리 방식은 다음과 같았습니다.

  • 누군가 풋살장을 대여한 후 단톡방에 "1/20일에 00축구장 18:00~20:00 5:5"라고 공지합니다.
  • 소모임 멤버들이 해당 채팅을 보고 투표로 참여/불참을 표시합니다.

🚩 문제점

  • 채팅 누락: 일정 관련 채팅이 너무 많이 생성되거나 취소 공지 등이 쌓일 경우, 채팅이 위로 밀려나 본인이 참여한 일정이 정상적으로 진행되는지 여부를 확인하기 어렵습니다.
  • 가시성 부족: 캘린더 형태로 확인이 불가능해 어떤 일정이 있는지 한눈에 파악하기 어렵습니다.
  • 팀 구성의 번거로움: 매번 현장에서 팀을 구성할 때 가위바위보를 하거나, 누군가 임의로 실력을 눈대중으로 맞춰 대강 배정하곤 했습니다.

위와 같은 문제점을 개선하고 제 커리어를 쌓기 위해 직접 앱을 만들기로 결심했습니다.

🛠️ 설계

처음 설계 단계에서 가장 막혔던 부분은 회원가입이었습니다. 회원가입 시 본인인증 과정을 거쳐야 하는데, OTP 발송 비용을 최소화하고 싶었습니다. 초기에는 이메일 인증 방식을 고민했으나, 아이디/비밀번호 찾기 기능을 제공해야 하고 이 과정에서도 추가 비용과 공수가 들 것이라 생각했습니다.

무엇보다 사용자 입장에서 이메일 회원가입은 번거로운 절차이며 이탈률이 높을 것이라 예상되어, 편의성을 고려해 소셜 로그인으로 방향을 돌렸습니다.

소셜 로그인

가장 먼저 떠오른 것은 카카오 로그인이었습니다. 예전부터 구현해보고 싶었던 기능이었기에 이번 기회에 도전했습니다. iOS와 AOS 모두에서 비교적 쉽게 구현이 가능해 즐겁게 작업했습니다.

다만, 애플의 정책상 소셜 로그인 기능이 있다면 애플 로그인을 필수로 포함해야 한다는 사실을 알게 되었습니다. 처음에는 즈레 겁을 먹기도 했지만, 막상 부딪쳐보니 큰 어려움 없이 구현할 수 있었습니다.

소셜 로그인 구현 과정에서의 트러블슈팅은 아래 포스트에 상세히 정리해두었습니다.

🖥️ 백엔드

백엔드는 Spring Boot 3.x, Java, JPA, QueryDSL을 사용하여 구현했습니다.

기술 스택 선택

Kotlin과 Spring 조합도 고민했지만, 현재 현업에서 Java를 사용하고 있어 숙련도가 높은 Java로 결정했습니다. QueryDSL은 캘린더 조회나 멤버 검색 등 동적 쿼리가 필요한 부분이 많을 것이라 예상하여 선택했는데, 실제로 매우 유용하게 활용했습니다. DB는 가벼우면서도 AWS 연동이 용이한 PostgreSQL을 선택했습니다.

프로젝트 구조

패키지는 도메인별로 관리하는 구조를 택했습니다. 계층형(Layered) 구조보다는 도메인별로 controller, service, repository, model, dto를 모아두는 방식이 관련 코드를 탐색하기에 더 효율적이었습니다.

src/main/java/com/myapp/squad/
├── common/          # 공통 (인증, 예외, 상수)
├── config/          # 설정
├── user/            # 사용자 도메인
├── group/           # 모임 도메인
└── event/           # 이벤트 도메인

주요 도메인 설계

크게 3가지 도메인으로 나눴습니다.

  • user: 사용자 인증/인가, 프로필 관리
  • group: 모임 생성/관리
  • event: 풋살 일정 생성, 참가 신청, 팀 배정

권한 체계

모임 내 권한을 LEADER > ADMIN > MEMBER로 나누었습니다. 처음에는 단순하게 그룹 생성자인 LEADER만 관리할 수 있게 하려 했으나, 실제 동아리 운영 환경을 고려하여 여러 명이 운영에 참여할 수 있도록 ADMIN 역할을 추가했습니다.

  • LEADER: 모임 삭제, 리더 양도, 멤버 권한 변경, 멤버 티어 관리
  • ADMIN: 멤버 승인/거절/제명, 이벤트 생성, 멤버 티어 관리
  • MEMBER: 이벤트 생성, 참가, 본인 정보 수정

리더 양도 기능 구현 시, 리더가 탈퇴하려고 할 때 다른 활동(ACTIVE) 멤버가 있다면 탈퇴를 막고 리더를 먼저 양도하도록 안내했습니다. 혼자 남은 리더만 탈퇴가 가능하도록 처리했습니다.

멤버십 상태 관리

사용자의 상태를 여러 단계로 세분화했습니다.

  • PENDING: 가입 신청 후 대기
  • ACTIVE: 승인된 활성 멤버
  • REJECT: 가입 거절됨
  • LEFT: 자발적 탈퇴
  • BANNED: 제명됨 (재가입 불가)

제명된 멤버는 재가입이 안 되게 막았습니다. 단, 운영자가 제명 해제를 하면 다시 신청할 수 있습니다. 악성 유저가 계속해서 재가입하는 것을 방지하기 위한 조치였습니다.

인증 구현

소셜 로그인을 구현하면서 JWT 인증을 직접 구축했습니다. Access Token은 1시간, Refresh Token은 30일로 설정했습니다.

JWT 구조

  • Access Token: userId, identifier, displayName을 클레임에 담음
  • Refresh Token: DB에 저장하고 만료 시간을 관리
  • 서명: SHA256 사용

인터셉터 기반 인증

모든 /api/** 요청은 JwtAuthInterceptor를 거치도록 했습니다. Authorization 헤더에서 Bearer 토큰을 검증하며, 토큰이 만료되면 TOKEN_EXPIRED 에러를 내려주어 앱에서 Refresh Token으로 갱신하도록 유도했습니다. 인증 제외 경로(auth/**, /health, /public/**)도 별도로 설정했습니다.

@CurrentUserId 커스텀 어노테이션

컨트롤러에서 현재 로그인한 사용자 ID를 쉽게 가져오기 위해 커스텀 어노테이션을 만들었습니다. ArgumentResolver로 구현했는데, 인터셉터에서 검증한 userId를 Request Attribute에 담아두고 리졸버에서 꺼내 주입하는 방식입니다.

@GetMapping("/my")
public ApiResponse<List<PartyGroupRes>> getMyGroupList(@CurrentUserId Long userId) {
    // userId를 바로 사용 가능
}

Race Condition 문제

Access Token 만료 시 Refresh Token으로 갱신하는 로직을 구현하는 과정에서 Race Condition 문제를 겪었습니다. 동시에 여러 요청이 들어올 때 Refresh Token이 중복 갱신되는 이슈였는데, A 요청이 Refresh하는 동안 B 요청도 동일한 토큰으로 갱신을 시도하면 실패하게 됩니다. 이를 해결하기 위해 Refresh Token에 고유 식별자(jti)를 추가했습니다.

이벤트(일정) 관리

참가 상태 관리

이벤트 참가 여부도 상태로 관리했습니다.

  • APPLIED: 참가 확정
  • WAITLISTED: 대기 (정원 초과 시)
  • DECLINED: 불참
  • CANCELED: 참가 취소

정원이 가득 차면 자동으로 WAITLISTED 상태가 되도록 했으며, 추후 알림 기능을 추가해 자리가 났을 때 대기자에게 알림을 보낼 예정입니다.

스냅샷 패턴

참가자 정보를 저장할 때 스냅샷 방식을 사용했습니다. 참가 신청 시점의 displayName, profileImage, tierEventParticipant 테이블에 복사해둡니다. 사용자가 이후에 정보를 변경하더라도 과거의 이벤트 기록은 당시의 정보로 유지되어야 하기 때문입니다. 또한 서비스 가입자가 아닌 '용병' 정보를 저장하는 데도 이 스냅샷 필드가 유용했습니다.

EventParticipantEntity ep = EventParticipantEntity.builder()
        .event(event)
        .appUser(user)
        .displayName(user.getDisplayName())    // 스냅샷
        .profileImage(user.getProfileImage())  // 스냅샷
        .tier(groupMember.getTier())           // 스냅샷
        .build();

동시성 제어

정원이 1자리 남았을 때 동시에 여러 명이 신청하는 경우를 방지하기 위해, 이벤트 조회 시 비관적 락(Pessimistic Lock)을 사용했습니다.

@Query("SELECT e FROM EventEntity e WHERE e.id = :eventId")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<EventEntity> findByIdWithLock(@Param("eventId") Long eventId);

팀 밸런싱 알고리즘

가장 재미있게 구현했던 기능입니다. 티어 기반의 자동 배정 시스템을 만들었습니다.

티어 시스템

멤버마다 티어를 부여하고 가중치를 설정했습니다.

  • PRO(1000점), SEMI_PRO(600점), AMATEUR(350점), JUNIOR(200점), BEGINNER(100점), ROOKIE(50점)
    가중치는 주관적인 판단으로 정했으며, 실제 운영하며 조정해나갈 계획입니다.

Snake Draft 알고리즘

팀 배정은 Snake Draft 방식을 사용했습니다. 높은 티어부터 1팀 → 2팀 → ... → n팀 → n팀 → ... → 1팀 순으로 배정하여 각 팀의 전력이 비슷해지도록 했습니다.

예를 들어 3팀에 6명을 배정한다면:

  1. PRO(1000) → 1팀
  2. SEMI_PRO(600) → 2팀
  3. AMATEUR(350) → 3팀
  4. AMATEUR(350) → 3팀 (방향 전환)
  5. JUNIOR(200) → 2팀
  6. BEGINNER(100) → 1팀
    결과적으로 팀 간 균형이 어느 정도 맞게 됩니다.
int teamIndex = 0;
boolean forward = true;

for (ParticipantWithTier participant : participants) {
    teams.get(teamIndex).addMember(participant);

    if (forward) {
        teamIndex++;
        if (teamIndex >= numberOfTeams) {
            teamIndex = numberOfTeams - 1;
            forward = false;
        }
    } else {
        teamIndex--;
        if (teamIndex < 0) {
            teamIndex = 0;
            forward = true;
        }
    }
}

팀 배정 저장

밸런싱 결과는 미리보기로 보여주고, 운영자가 확인 후 저장하면 team_number 필드에 반영됩니다. 조회와 저장 API를 분리하여 운영자가 결과를 수정할 여지를 주었습니다.

용병 시스템

모임 인원이 부족할 때 지인을 데려오는 경우를 고려해 용병 시스템을 추가했습니다. 용병은 app_user_id가 NULL이며, 이름과 티어만 입력받습니다. 동일 이벤트 내 닉네임 중복 체크도 적용했습니다.

if (eventParticipantRepository.existsByEventIdAndDisplayNameAndParticipantType(
        eventId, req.guestNickname(), ParticipantType.GUEST)) {
    throw new BizException(BizExceptionCode.GUEST_NICKNAME_DUPLICATE);
}

용병 삭제는 운영자가 가능하지만, 회원은 본인이 직접 참가 취소를 해야 하도록 구분했습니다.

예외 처리

BizExceptionCode enum을 통해 약 67개의 예외 코드를 체계적으로 정의했습니다. GlobalExceptionHandler에서 이를 잡아 일관된 응답 포맷으로 내려주며, 프런트엔드에서는 구체적인 코드를 보고 사용자에게 정확한 안내를 제공할 수 있습니다.

배포

Docker로 컨테이너화하고 Docker Compose로 환경을 분리했습니다.

  • 멀티 스테이지 빌드: 빌드용 이미지와 런타임 이미지를 분리하여 최종 이미지 크기를 최적화했습니다.
  • 환경 분리: 민감 정보는 .env 파일과 환경 변수를 통해 주입하여 코드에 직접 노출되지 않도록 신경 썼습니다.

어려웠던 점 & 배운 점

  • QueryDSL 설정: AnnotationProcessor 설정과 Q클래스 생성 경로를 잡는 데 시간이 꽤 걸렸습니다.
  • JPA N+1 문제: 특히 목록 조회 시 발생하는 문제를 fetch join으로 해결하며 성능 최적화를 경험했습니다.
  • 권한 체크: 복잡한 비즈니스 로직을 ensureModerator 같은 공통 메서드로 추상화하여 중복을 줄였습니다.
  • 데이터 정점: 탈퇴 시 관련 기록 정리 등 비즈니스 로직으로 처리해야 할 부분들을 꼼꼼히 챙겼습니다.
  • 설계의 중요성: 초반에 enum과 도메인 구조를 잘 정의해두는 것이 유지보수에 얼마나 큰 영향을 주는지 실감했습니다.

📱 프런트엔드 (App)

프런트엔드는 Flutter를 선택했습니다. 웹 개발 경험이 있어 React Native와 고민했으나, Flutter의 UI 일관성과 성능, 그리고 Dart 언어가 Java와 유사하다는 점이 백엔드 개발자인 저에게 매력적이었습니다. Hot Reload 기능 덕분에 작업 생산성이 매우 높았습니다.

프로젝트 구조

Clean Architecture를 참고하여 계층을 분리했습니다.

lib/
├── core/           # 공통 (상수, 유틸, 테마)
├── data/           # 데이터 계층 (models, repositories, services)
└── presentation/   # UI 계층 (providers, screens, widgets)

상태 관리 (Riverpod)

RiverpodStateNotifier 패턴을 사용해 상태를 관리했습니다. 불변 상태 객체를 만들고 copyWith로 업데이트하는 방식은 코드를 매우 깔끔하게 만들어주었습니다.

class AuthNotifier extends StateNotifier<AuthState> {
  Future<void> loginWithKakao() async {
    state = state.copyWith(isLoading: true);
    try {
      final result = await _repository.loginWithKakao(...);
      state = state.copyWith(isLoading: false, appUser: result.user);
    } catch (e) {
      state = state.copyWith(isLoading: false, error: e.toString());
    }
  }
}

주요 화면 구성

  • 홈 탭: table_calendar를 사용해 일정을 시각화하고, 데이터를 범위 기반으로 미리 로드해 성능을 최적화했습니다.
  • 상세 화면: 참가자 목록 확인 및 상태 변경, 운영자 전용 기능 제공.
  • 팀 밸런싱: 백엔드 결과를 확인하고 저장하는 운영자 전용 UI.
  • 멤버 관리: 승인/제명/티어 변경 기능을 탭별로 구분하여 제공.

API 통신 (Dio)

Dio 인터셉터를 통해 모든 요청에 JWT를 자동 첨부하고, 401(TOKEN_EXPIRED) 발생 시 자동으로 Refresh Token을 통해 갱신 후 원래 요청을 재시도하는 로직을 구축했습니다.

인증 및 보안

카카오(kakao_flutter_sdk)와 애플(sign_in_with_apple) 로그인을 연동했습니다. 토큰 정보는 보안을 위해 flutter_secure_storage에 저장하여 Android의 암호화된 SP와 iOS의 Keychain을 활용했습니다.

캐싱 및 기타 기능

  • 날짜 범위 기반 캐싱: 이미 로드된 범위를 기억해 부족한 데이터만 요청합니다.
  • Hive 오프라인 캐싱: 프로필 등 기본 정보는 네트워크 없이도 확인할 수 있도록 캐싱했습니다.
  • 딥링크: app_links를 사용하여 초대 링크 클릭 시 앱의 특정 화면으로 바로 이동하게 했습니다.
  • 기타: 다크 모드 지원, showcaseview를 활용한 온보딩, 검색 디바운스(700ms) 및 최근 검색어 기능을 추가했습니다.

어려웠던 점 & 배운 점

  • Dart 문법: Null Safety 개념을 이해하는 데 시간이 걸렸지만, 안정적인 코드 작성에 큰 도움이 되었습니다.
  • 비동기 처리: mounted 체크를 통해 화면 dispose 후 발생하는 에러를 방지하는 법을 배웠습니다.
  • iOS 빌드: Xcode 설정 및 애플 개발자 계정 관리 등 인프라 측면의 복잡함을 경험했습니다.
  • 선언형 UI: 위젯 트리 방식에 익숙해지니 상태 변화에 따른 UI 표현이 매우 직관적이고 편했습니다.

🏁 마치며

1인 개발은 고독한 과정이었지만, 기획부터 배포까지 전 과정을 훑으며 개발자로서 시야가 한층 넓어졌습니다. 특히 Claude Code와 같은 AI 도구들을 적극적으로 활용하며 개발 패턴을 잡는 데 큰 도움을 받았습니다. 이번 도전이 제 커리어에 중요한 이정표가 될 것이라 확신합니다.

profile
논리적으로 사고하고 해결하는 것을 좋아하는 개발자입니다.

0개의 댓글