
2025년 한 해 동안 앱을 직접 기획하고 실제 스토어에 출시하는 도전을 해봤습니다. 현재 웹 프런트엔드와 백엔드 개발자로 일하고 있지만, 현업을 하다 보면 제가 속한 부서가 아닌 타 부서의 업무 흐름과 감수성을 이해해야 할 때가 많았습니다.
무엇보다 제로 베이스에서 기획부터 설계, 앱 개발(iOS, AOS), 백엔드 개발, QA, 서버 구성, 배포까지 전반적인 과정을 직접 경험해보는 것이 커리어에 큰 도움이 될 것이라 믿고 진행하게 되었습니다. (개발 과정에서 큰 도움을 준 Claude Code와 GPT에게 깊은 감사를 전합니다.)
주제는 풋살 관련 앱을 선정했습니다. 기존에도 풋살 매칭 앱은 많았지만, 대부분 모르는 사람들과의 매칭을 위한 시스템이었을 뿐, 제가 소속된 소모임이나 동아리를 직접 관리할 수 있는 앱은 없었기 때문입니다.
현재 제가 속한 풋살 소모임의 일정 관리 방식은 다음과 같았습니다.
위와 같은 문제점을 개선하고 제 커리어를 쌓기 위해 직접 앱을 만들기로 결심했습니다.
처음 설계 단계에서 가장 막혔던 부분은 회원가입이었습니다. 회원가입 시 본인인증 과정을 거쳐야 하는데, 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가지 도메인으로 나눴습니다.
모임 내 권한을 LEADER > ADMIN > MEMBER로 나누었습니다. 처음에는 단순하게 그룹 생성자인 LEADER만 관리할 수 있게 하려 했으나, 실제 동아리 운영 환경을 고려하여 여러 명이 운영에 참여할 수 있도록 ADMIN 역할을 추가했습니다.
리더 양도 기능 구현 시, 리더가 탈퇴하려고 할 때 다른 활동(ACTIVE) 멤버가 있다면 탈퇴를 막고 리더를 먼저 양도하도록 안내했습니다. 혼자 남은 리더만 탈퇴가 가능하도록 처리했습니다.
사용자의 상태를 여러 단계로 세분화했습니다.
PENDING: 가입 신청 후 대기ACTIVE: 승인된 활성 멤버REJECT: 가입 거절됨LEFT: 자발적 탈퇴BANNED: 제명됨 (재가입 불가)제명된 멤버는 재가입이 안 되게 막았습니다. 단, 운영자가 제명 해제를 하면 다시 신청할 수 있습니다. 악성 유저가 계속해서 재가입하는 것을 방지하기 위한 조치였습니다.
소셜 로그인을 구현하면서 JWT 인증을 직접 구축했습니다. Access Token은 1시간, Refresh Token은 30일로 설정했습니다.
userId, identifier, displayName을 클레임에 담음모든 /api/** 요청은 JwtAuthInterceptor를 거치도록 했습니다. Authorization 헤더에서 Bearer 토큰을 검증하며, 토큰이 만료되면 TOKEN_EXPIRED 에러를 내려주어 앱에서 Refresh Token으로 갱신하도록 유도했습니다. 인증 제외 경로(auth/**, /health, /public/**)도 별도로 설정했습니다.
컨트롤러에서 현재 로그인한 사용자 ID를 쉽게 가져오기 위해 커스텀 어노테이션을 만들었습니다. ArgumentResolver로 구현했는데, 인터셉터에서 검증한 userId를 Request Attribute에 담아두고 리졸버에서 꺼내 주입하는 방식입니다.
@GetMapping("/my")
public ApiResponse<List<PartyGroupRes>> getMyGroupList(@CurrentUserId Long userId) {
// userId를 바로 사용 가능
}
Access Token 만료 시 Refresh Token으로 갱신하는 로직을 구현하는 과정에서 Race Condition 문제를 겪었습니다. 동시에 여러 요청이 들어올 때 Refresh Token이 중복 갱신되는 이슈였는데, A 요청이 Refresh하는 동안 B 요청도 동일한 토큰으로 갱신을 시도하면 실패하게 됩니다. 이를 해결하기 위해 Refresh Token에 고유 식별자(jti)를 추가했습니다.
이벤트 참가 여부도 상태로 관리했습니다.
APPLIED: 참가 확정WAITLISTED: 대기 (정원 초과 시)DECLINED: 불참CANCELED: 참가 취소정원이 가득 차면 자동으로 WAITLISTED 상태가 되도록 했으며, 추후 알림 기능을 추가해 자리가 났을 때 대기자에게 알림을 보낼 예정입니다.
참가자 정보를 저장할 때 스냅샷 방식을 사용했습니다. 참가 신청 시점의 displayName, profileImage, tier를 EventParticipant 테이블에 복사해둡니다. 사용자가 이후에 정보를 변경하더라도 과거의 이벤트 기록은 당시의 정보로 유지되어야 하기 때문입니다. 또한 서비스 가입자가 아닌 '용병' 정보를 저장하는 데도 이 스냅샷 필드가 유용했습니다.
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);
가장 재미있게 구현했던 기능입니다. 티어 기반의 자동 배정 시스템을 만들었습니다.
멤버마다 티어를 부여하고 가중치를 설정했습니다.
팀 배정은 Snake Draft 방식을 사용했습니다. 높은 티어부터 1팀 → 2팀 → ... → n팀 → n팀 → ... → 1팀 순으로 배정하여 각 팀의 전력이 비슷해지도록 했습니다.
예를 들어 3팀에 6명을 배정한다면:
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 파일과 환경 변수를 통해 주입하여 코드에 직접 노출되지 않도록 신경 썼습니다.ensureModerator 같은 공통 메서드로 추상화하여 중복을 줄였습니다.프런트엔드는 Flutter를 선택했습니다. 웹 개발 경험이 있어 React Native와 고민했으나, Flutter의 UI 일관성과 성능, 그리고 Dart 언어가 Java와 유사하다는 점이 백엔드 개발자인 저에게 매력적이었습니다. Hot Reload 기능 덕분에 작업 생산성이 매우 높았습니다.
Clean Architecture를 참고하여 계층을 분리했습니다.
lib/
├── core/ # 공통 (상수, 유틸, 테마)
├── data/ # 데이터 계층 (models, repositories, services)
└── presentation/ # UI 계층 (providers, screens, widgets)
Riverpod의 StateNotifier 패턴을 사용해 상태를 관리했습니다. 불변 상태 객체를 만들고 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를 사용해 일정을 시각화하고, 데이터를 범위 기반으로 미리 로드해 성능을 최적화했습니다.Dio 인터셉터를 통해 모든 요청에 JWT를 자동 첨부하고, 401(TOKEN_EXPIRED) 발생 시 자동으로 Refresh Token을 통해 갱신 후 원래 요청을 재시도하는 로직을 구축했습니다.
카카오(kakao_flutter_sdk)와 애플(sign_in_with_apple) 로그인을 연동했습니다. 토큰 정보는 보안을 위해 flutter_secure_storage에 저장하여 Android의 암호화된 SP와 iOS의 Keychain을 활용했습니다.
app_links를 사용하여 초대 링크 클릭 시 앱의 특정 화면으로 바로 이동하게 했습니다.showcaseview를 활용한 온보딩, 검색 디바운스(700ms) 및 최근 검색어 기능을 추가했습니다.mounted 체크를 통해 화면 dispose 후 발생하는 에러를 방지하는 법을 배웠습니다.1인 개발은 고독한 과정이었지만, 기획부터 배포까지 전 과정을 훑으며 개발자로서 시야가 한층 넓어졌습니다. 특히 Claude Code와 같은 AI 도구들을 적극적으로 활용하며 개발 패턴을 잡는 데 큰 도움을 받았습니다. 이번 도전이 제 커리어에 중요한 이정표가 될 것이라 확신합니다.