소셜 프로필 설정 (Flutter + GetX, Bonus+email 인증)

지난 글에서 소셜 로그인으로 사용자 인증까지 마쳤다. 하지만 우리 서비스(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: 약관 동의 (마지막 관문)

필수 약관(termsprivacy)을 다 체크해야만 ‘완료’ 버튼이 활성화(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)를 제공하는 게 백엔드 코드 짜는 것보다 더 고민이 많이 되는 부분이었다.

댓글 남기기