테니스 매칭 시스템 구현기 (2) – 백엔드 (Spring Boot)

이전 글에서 Flutter로 매칭 화면을 그리는 법을 다뤘다. 이번에는 그 화면 뒤에서 실제로 데이터를 굴리는 **백엔드(Spring Boot)**구현 과정을 정리해본다.

모바일 개발자 입장에서 백엔드를 하다 보면 가장 신경 쓰이는 게 “이 데이터를 믿을 수 있는가?”다. 앱은 끄면 날아가지만, DB에 박힌 데이터는 영원하니까.

1. 매칭 리스트

“서울 강남구에서”, “이번 주말 오후에”, “복식 게임을”, “NTRP 3.0 이상만” 찾고 싶다. 경우의 수가 너무 많다. 이걸 if-else로 쿼리 문자열을 짜다가는 헬게이트가 열린다. JPA의 Specification(혹은 QueryDSL)을 써서 동적 쿼리를 처리했다.

흐름

Controller는 단순히 요청을 토스하고, Service가 파라미터를 “DB가 알아듣는 말”로 번역한다.

구현 코드

Controller (MatchController.java) 그냥 파라미터 다 받아서 넘긴다. 깔끔하다.

@GetMapping
public ApiResponse<PageResponse<MatchResponse>> searchMatches(
        @RequestParam(required = false) List<String> gameTypes,
        @RequestParam(required = false) String date,
        @RequestParam(required = false) MatchStatus status,
        // ... 지역, 시간, 레벨 등등
        Pageable pageable) {
    
    Page<MatchResponse> matches = matchService.searchMatches(gameTypes, date, status, ..., pageable);
    return ApiResponse.success("조회 성공", PageResponse.from(matches));
}

Service (MatchService.java) 여기서 문자열로 들어온 날짜를 LocalDateTime으로 바꾸고, Enum 타입으로 변환하는 전처리를 한다.

public Page<MatchResponse> searchMatches(...) {
    // 1. 문자열 날짜 -> LocalDateTime 변환 (00:00 ~ 23:59)
    LocalDateTime startOfDay = null;
    LocalDateTime endOfDay = null;
    
    if (date != null && !date.isEmpty()) {
        LocalDate targetDate = LocalDate.parse(date);
        startOfDay = targetDate.atStartOfDay();
        endOfDay = targetDate.atTime(LocalTime.MAX);
    }

    // 2. 동적 쿼리 생성 (Specification)
    Specification<Match> spec = MatchSpecification.filterMatches(status, startOfDay, ...);
    
    // 3. 조회 및 DTO 변환
    return matchRepository.findAll(spec, pageable).map(MatchResponse::from);
}

2. 매칭 생성: 기본값은 누가 채우나?

매칭을 생성할 때 클라이언트가 보내주는 건 ‘제목’, ‘일시’, ‘장소’ 같은 핵심 정보다. 하지만 DB에는 status(모집중), currentPlayers(1명), id(UUID) 같은 관리용 데이터도 필요하다.

이걸 Service에서 set...으로 하나하나 넣는 건 까먹기 딱 좋다. 엔티티가 스스로 챙기도록 @PrePersist를 활용했다.

구현 코드

Entity (Match.java)

@Entity
public class Match {
    // ... 필드들

    @PrePersist
    public void prePersist() {
        // ID가 없으면 UUID 생성
        if (this.id == null) {
            this.id = java.util.UUID.randomUUID().toString();
        }
        // 상태 기본값: 모집중
        if (this.status == null) {
            this.status = MatchStatus.모집중;
        }
        // 현재 인원: 호스트 1명 포함
        if (this.currentPlayers == null) {
            this.currentPlayers = 1; 
        }
    }
}

Service (MatchService.java) 덕분에 서비스 코드는 비즈니스 로직(누가 무엇을 만들었나)에만 집중할 수 있다.

@Transactional
public MatchResponse createMatch(String userId, MatchRequest request) {
    // 유저 찾기 (없으면 예외 컷)
    User creator = userRepository.findById(userId)
            .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

    // 빌더로 생성 (기본값들은 PrePersist가 처리함)
    Match match = Match.builder()
            .title(request.getTitle())
            .type(request.getType())
            .location(request.getLocation())
            .matchDateTime(request.getMatchDateTime())
            .creator(creator)
            .build();
            
    matchRepository.save(match);
    return MatchResponse.from(match);
}

3. 매칭 상세: 정보 모으기 (Aggregation)

상세 화면은 단순히 Match 테이블 하나만 읽어서 되는 게 아니다. 사용자가 이 화면을 볼 때 궁금한 건 세 가지다.

  1. 이 게임 정보 (Match)
  2. 내가 여기에 참가 신청을 했나? (Participant Status)
  3. 채팅방은 어디인가? (ChatRoom)

이걸 클라이언트가 API 3번 찔러서 조합하게 하면 모바일 개발자한테 욕먹는다. (내가 그래봤으니까) 백엔드에서 한 번에 말아 주는 게 예의다.

구현 코드 (MatchService.java)

public MatchResponse getMatch(String matchId, String userId) {
    // 1. 매칭 정보 조회
    Match match = matchRepository.findById(matchId)
            .orElseThrow(() -> new BusinessException(ErrorCode.MATCH_NOT_FOUND));

    // 2. '나'의 참가 상태 확인 (GUEST인 경우)
    ParticipantStatus myStatus = null;
    if (userId != null) {
        myStatus = participantRepository.findByMatchIdAndUserId(matchId, userId)
                .map(MatchParticipant::getStatus)
                .orElse(null); // 신청 안 했으면 null
    }

    // 3. 채팅방 및 최근 대화 가져오기 (이미 참가자라면)
    List<ChatMessageResponse> chatMessages = null;
    Optional<ChatRoom> chatRoomOpt = chatRoomRepository.findByMatchId(matchId);
    
    if (chatRoomOpt.isPresent()) {
        // ... 채팅방 ID 및 메시지 로드 로직
    }

    // 4. 수락된 참가자(플레이어) 목록
    List<ParticipantResponse> participants = 
            participantRepository.findByMatchIdAndStatus(matchId, ParticipantStatus.ACCEPTED)
                .stream()
                .map(ParticipantResponse::from)
                .collect(Collectors.toList());

    // 5. 종합 선물세트 리턴
    return MatchResponse.from(match, myStatus, chatMessages, participants);
}

정리

백엔드 작업은 눈에 보이는 화려함은 없지만, 데이터의 정합성과 클라이언트의 편의성을 챙기는 맛이 있다. 특히 상세 조회(getMatch)에서 클라이언트가 처리하기 귀찮은 조립 로직을 서버가 다 처리해서 넘겨줄 때, 모바일 개발자로서의 자아가 백엔드 개발자로서의 자아를 칭찬하게 된다.

다음엔 이 시스템의 꽃인 실시간 채팅(WebSocket)과 알림(FCM) 처리를 정리해봐야겠다.

댓글 남기기