테니스 매칭 시스템 구현기 (1) – 모바일 프론트엔드

지난 글에서 사용자 프로필을 입력받아 유저를 준비시켰다. 이제 이 사람들이 실제로 만나서 테니스를 칠 수 있게 ‘매칭(Match)’ 시스템을 만들어야 한다.

Tennis GG 서비스의 핵심 기능이자, 가장 복잡한 비즈니스 로직이 들어가는 부분이다. 내용이 길어서 

(1) 모바일 프론트엔드와 (2) 백엔드 편으로 나누어 정리한다.

오늘은 Flutter로 구현한 화면과 상태 관리 이야기다.

주요 포인트

매칭 시스템은 단순히 “글 쓰고 읽기”가 아니다.

  • 리스트: 내 입맛에 맞는 게임만 골라봐야 하고 (Filter)
  • 생성: 날짜, 시간, 코트, 실력 제한 등 입력할게 많고 (Form)
  • 상세: 내가 만든 게임인지(Host), 남이 만든 게임인지(Guest)에 따라 보이는 게 다르다.

이 복잡한 상태들을 GetX로 어떻게 깔끔하게 관리했는지 기록해둔다.

1. 매칭 리스트: 보고 싶은 것만 보자

매칭 리스트는 사용자가 앱을 켜자마자 가장 먼저 보는 화면이다. 무작정 다 보여주면 안 된다. “이번 주말, 서울, 복식 게임”을 찾는 사람에게 부산 단식 게임을 보여줄 필요는 없으니까.

주요 고민: 필터링과 무한 스크롤

서버에 보낼 파라미터가 꽤 많다. pagesize는 기본이고 status(모집중), gameTypes(양도/매칭), dateregion 등등. 이걸 컨트롤러에서 obs 변수들로 관리해서, 값이 바뀌면 자동으로 리스트를 새로 긁어오게 했다.

구현 코드 (Controller)

class MatchListController extends GetxController {
  // 필터 상태 관리 (GetX의 장점: 변수만 바꿔도 반응함)
  final filterType = 'recruiting'.obs; // recruiting: 매칭, transfer: 양도
  final isIncludeClosed = false.obs;   // 마감된 게임도 볼래?
  
  // 데이터 불러오기
  Future<void> loadMatches({bool isRefresh = false}) async {
    try {
      // 쿼리 파라미터 조립
      Map<String, dynamic> queryParams = {
        'page': _currentPage, 
        'size': _pageSize,
        'sort': 'latest', // 최신순
      };

      // 필터 적용
      if (!isIncludeClosed.value) queryParams['status'] = '모집중';
      if (filterType.value == 'transfer') {
        queryParams['gameTypes[]'] = ['양도']; 
      }
      
      // API 호출
      final result = await HomeApi.to.reqMatchList(queryParams: queryParams);
      // ... 리스트 업데이트 로직
    } catch (e) {
      Get.log('매칭 로드 실패: $e');
    }
  }
}

UI에서는 RefreshIndicator로 당겨서 새로고침을 구현하고, ListView.builder로 리스트를 그린다. 중요한 건 상태 변화 감지인데, Obx로 리스트 부분을 감싸주면 컨트롤러의 데이터가 변할 때 알아서 다시 그린다. setState 없이 깔끔하다.

2. 매칭 생성: 입력번잡스러우면 안 된다

매칭을 만드는 과정은 생각보다 입력할 게 많다. 어디서(코트), 언제(시간), 누구랑(실력, 성별), 어떻게(단/복식) 할지를 다 정해야 한다. 입력이 귀찮으면 유저는 매칭을 안 만든다. 최대한 편하게 만들어야 했다.

UX 포인트

  1. 탭으로 모드 전환: 일반 매칭과 양도(코트만 넘김)는 성격이 다르다. 상단 탭으로 뷰를 분리했다.
  2. 바텀시트 활용: 날짜나 코트 선택은 새 페이지로 넘기지 않고 BottomSheet를 띄워 흐름을 유지했다.
  3. 유효성 검사: “종료 시간이 시작 시간보다 빠르면 안 됨”, “NTRP 최소값이 최대값보다 크면 안 됨” 같은 로직은 필수다.

구현 코드 (Controller & Validation)

Future<void> _createMatchRequest() async {
  // 1. 유효성 검사 (Validation)
  if (!validate()) return; 

  try {
    // 2. 서버로 보낼 데이터 정리
    final params = {
      'title': finalTitle,
      'type': selectedMatchType.value, // 단식 or 복식
      'location': court.name,
      'matchDateTime': matchDateTime.toIso8601String(),
      
      // NTRP 범위 (실력 제한)
      'ntrpMin': selectedNtrpRange.value.start,
      'ntrpMax': selectedNtrpRange.value.end,
      // ...
    };

    // 3. 생성 요청
    final result = await HomeApi.to.reqCreateMatch(params);
    
    // 4. 성공 시 상세 페이지로 이동 (뒤로가기 시 목록으로)
    if (result != null) {
      Get.offNamed('/match_detail', arguments: {'matchId': result['id']});
    }
  } catch (e) {
    Get.log('생성 중 에러 발생: $e');
  }
}

3. 매칭 상세: 호스트와 게스트의 권한설정

상세 페이지는 하나의 화면이지만, 누가 보느냐에 따라 기능이 완전히 달라진다.

  • 호스트(내가 만든 글): 참가 신청 관리, 수정, 삭제, 마감 처리
  • 게스트(참가자): 신청하기, 신청 취소, 호스트 정보 보기

이 분기 처리가 꼬이면 “내가 신청하고 내가 수락하는” 개그 상황이 연출된다.

권한 체크 로직

// Controller 내부
bool get isHost {
  final currentUserId = GlobalService.to.userInfo['id'];
  // 글 작성자 ID와 내 ID 비교
  return matchDetail.value?['creatorId'].toString() == currentUserId.toString();
}

UI 분기 처리

버튼 하나를 그릴 때도 if 문이 들어간다.

// View 내부
Widget _buildActionButton() {
  // 1. 내가 호스트라면? -> 신청자 관리
  if (controller.isHost) {
    return ElevatedButton(
      onPressed: () => _showApplicantBottomSheet(),
      child: Text('신청 관리 (${controller.applicantCount})'),
    );
  }
  
  // 2. 이미 참가 확정된 게스트라면?
  if (controller.isAccepted) {
    return ElevatedButton(
      style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
      child: Text('참가 확정됨'), // 클릭하면 채팅방 이동?
    );
  }
  
  // 3. 일반 게스트라면? -> 신청하기
  return ElevatedButton(
    onPressed: () => _showJoinDialog(context),
    child: Text('참가 신청'),
  );
}

정리

UI만 보면 그냥 리스트 있고 버튼 있는 평범한 앱 같지만, 그 뒤에는 필터링유효성 검사권한 분기 같은 로직들이 복잡하게 얽혀있다. Flutter와 GetX를 사용해서 이런 복잡성을 Controller로 몰아넣고, UI(View)는 최대한 로직을 모르게(멍청하게) 유지하려고 노력했다.

이제 모바일 화면은 준비됐다. 하지만 서버가 이 요청들을 제대로 처리해주지 않으면 껍데기일 뿐이다. 다음 글에서는 이 데이터들을 받아 처리하는 백엔드(Spring Boot)의 트랜잭션과 동시성 처리 이야기를 해보겠다. (중복 신청 막기 등등…)

댓글 남기기