지난 글에서 소셜 로그인으로 사용자 인증까지 마쳤다. 하지만 우리 서비스(Tennis GG)에 필요한 건 ‘이메일’ 하나가 아니다. 테니스 실력(NTRP), 구력, 닉네임… 받아야 할 게 몇가지가 있다. 로그인 직후, 사용자를 붙잡아두고 자연스럽게 정보를 입력받는 과정(Onboarding Flow)을 어떻게 구현했는지 정리해본다.
먼저, 완성된 화면부터 보여드리겠습니다.





왜 이 글을 쓰게 되었는지
처음엔 그냥 긴 Form 하나 던져주고 “입력하세요” 할까 했다. 근데 나부터가 모바일에서 스크롤이 너무 많으면 “나중에 할게요” 하고 나가버린다. 그래서 PageView를 써서 하나씩(무리하지 않게) 물어보는 라우팅 구조를 짰다.
1. 컨트롤러 설계: GetX 하나로 끝내기
5단계나 되는 입력 과정을 각각 다른 페이지로 만들면 데이터 관리가 골치 아프다. PageView 하나에 SetupController 하나로 퉁쳤다.
class SocialProfileSetupController extends GetxController {
final pageController = PageController(); // 화면 전환용
final currentStep = 0.obs; // 현재 내가 몇 번째 질문 중인가 (0~4)
// 다음 버튼 누를 때 호출
void nextStep() {
// 0. 유효성 검사 (여기서 컷 당하면 못 넘어감)
// ...
// 1. 다음 페이지로 스르륵 이동
if (currentStep.value < 4) {
currentStep.value++;
pageController.animateToPage(
currentStep.value,
duration: const Duration(milliseconds: 300), // 스무스하게
curve: Curves.easeInOut,
);
} else {
// 2. 마지막이면 서버 전송
completeSetup();
}
}
}
2. 단계별 구현 디테일 (Step by Step)
Step 0: 닉네임 (제일 중요)
닉네임은 서비스의 얼굴이다. 대충 입력하게 둘 수 없다. 한글, 영문, 숫자, 특수문자(-, _, .)만 허용하고 2~20자로 제한했다. (추후 추천 닉네임 설정 도입하려함)
String? validateNickname(String? value) {
if (value == null || value.isEmpty) return '닉네임을 입력해주세요.';
if (value.trim().length < 2 || value.trim().length > 20) {
return '길이가 너무 짧거나 깁니다 (2~20자).';
}
// 정규식: 한글/영문/숫자/특수문자만 허용
if (!RegExp(r'^[가-힣a-zA-Z0-9._-]+$').hasMatch(value)) {
return '특수문자는 -, _, . 만 쓸 수 있어요.';
}
return null;
}
Step 1: 프로필 사진 (일단 보류)
사진까지 찍게 하면 귀찮아서 나갈까 봐, 일단 TODO로 남겨뒀다. “나중에 구현 예정” 로그만 찍고 쿨하게 패스.
Step 2: 테니스 시작 날짜 (DatePicker)
나이는 테니스 매칭에서 중요한 요소다. showDatePicker로 깔끔하게 받는다. 미래에서 온 사람(Next Generation)은 가입 불가하도록 lastDate는 DateTime.now()로 막았다.
final date = await showDatePicker(
context: context,
initialDate: controller.birthDate.value ?? DateTime(2000, 1, 1),
firstDate: DateTime(1900), // 고조할아버지까지만
lastDate: DateTime.now(),
locale: const Locale('ko', 'KR'),
);
Step 3: 플레이어 정보 (구력 & NTRP)
테니스 커뮤니티의 핵심! 숫자를 키보드로 치는 것보다 **슬라이더(Slider)**로 드르륵 긁는 손맛을 줬다. GetX의 Obx로 감싸서 슬라이더 움직일 때마다 숫자가 실시간으로 바뀌게 했다.
// 구력 슬라이더 (0 ~ 20년+)
Slider(
value: controller.experience.value,
onChanged: (val) => controller.experience.value = val,
// ...
),
// NTRP (실력) 슬라이더 (1.0 ~ 4.5)
Slider(
value: controller.ntrp.value,
divisions: 12, // 0.5 단위
onChanged: (val) => controller.ntrp.value = val,
),
Step 4: 약관 동의 (마지막 관문)
필수 약관(terms, privacy)을 다 체크해야만 ‘완료’ 버튼이 활성화(isEnabled)되도록 했다. 마케팅 동의는 선택이니까 쿨하게 둬도 된다.
3. 대장정의 마무리: 서버 전송 (Complete)
여기까지 온 끈기 있는 사용자들의 데이터를 서버로 보낸다.
Future<void> completeSetup() async {
isLoading.value = true; // 로딩 돌려주고
try {
final params = {
'nickname': nicknameController.text.trim(),
'marketingAgreed': marketingAgreed.value,
'gender': selectedGender.value,
'experience': experience.value.toInt(),
'ntrp': ntrp.value,
'birthDate': birthDate.value!.toIso8601String().split('T')[0], // YYYY-MM-DD
// 알림은 일단 다 켜드림 (ON)
'chatNotification': 1,
// ...
};
// 백엔드 API 호출
final success = await HomeApi.to.reqUpdateProfile(params);
if (success) {
// 중요: FCM 토큰 등록 (이제 알림 받을 자격이 생김)
await GlobalService.to.registerFcmToken();
// 메인으로 납치 (뒤로가기 불가)
Get.offAllNamed(Routes.MAIN_TAB);
}
} finally {
isLoading.value = false;
}
}
(Bonus) 부록: 지금은 삭제된 이메일 인증 로직 구현
지금은 소셜 로그인으로 통일하면서 사라졌지만, 초기(v1)에 공들여 짰던 이메일 인증(Email Verification) 로직을 공유한다. 혹시 이메일 가입이 필요한 분들은 참고하시길.
1. 인증 코드 발송 및 타이머 시작
사용자가 ‘인증발송’을 누르면 백엔드로 요청을 보내고, 성공 시 3분 타이머를 돌린다.
Future<void> sendVerificationCode() async {
// 1. 이메일 형식 검사
if (validateEmail(emailController.text) != null) return;
isSendingCode.value = true;
try {
// 2. 백엔드 API 호출
final success = await HomeApi.to.reqSendEmailVerificationCode(
emailController.text.trim()
);
if (success) {
isCodeSent.value = true; // 입력창 활성화
_startTimer(); // 타이머 시작!
}
} finally {
isSendingCode.value = false;
}
}
2. 타이머 관리 (Timer)
3분(180초)을 카운트다운하고, 30초가 남았을 때부터 ‘재발송’ 버튼을 풀어주는 디테일.
void _startTimer() {
_timer?.cancel();
remainingSeconds.value = 180; // 3분
canResend.value = false; // 일단 재발송 잠금
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (remainingSeconds.value > 0) {
remainingSeconds.value--;
// 30초 이하로 남았고 아직 인증 안 했으면 재발송 허용
if (remainingSeconds.value <= 30 && !isEmailVerified.value) {
canResend.value = true;
}
} else {
timer.cancel(); // 시간 종료
canResend.value = true; // 만료됐으니 재발송 가능
}
});
}
3. 인증 코드 검증 (Verification)
사용자가 문자를 보고 코드를 입력하면 최종 확인을 한다. 여기서 isEmailVerified가 true가 되어야만 다음 단계로 넘어갈 수 있다.
Future<void> verifyCode() async {
if (verificationCodeController.text.length != 6) return;
isVerifyingCode.value = true;
try {
// 이메일 + 코드 묶어서 검증 요청
final success = await HomeApi.to.reqVerifyEmailCode(
emailController.text.trim(),
verificationCodeController.text.trim(),
);
if (success) {
isEmailVerified.value = true; // 인증 성공!
isCodeSent.value = false; // 입력창 정리
_stopTimer(); // 타이머 해제
}
} finally {
isVerifyingCode.value = false;
}
}
정리
소셜 로그인만 붙이면 끝날 줄 알았는데, 사실 진짜 시작은 프로필 설정부터였다. 사용자가 귀찮아하지 않게 적절히 단계를 쪼개고(PageView), 입력하기 쉬운 UI(Slider, DatePicker)를 제공하는 게 백엔드 코드 짜는 것보다 더 고민이 많이 되는 부분이었다.