헬스장 & 트레이너, 이용권 관리 - fitpassTeam/fitpass GitHub Wiki

헬스장 & 트레이너, 이용권 관리

전체 개요

이 시스템은 헬스장(Gym), 트레이너(Trainer), 이용권(Membership) 세 도메인이 긴밀하게 연결된 복합 비즈니스 로직을 구현합니다. 헬스장 사업자가 자신의 헬스장과 트레이너, 이용권을 관리하고, 일반 사용자가 이용권을 구매하여 헬스장을 이용하는 완전한 헬스장 운영 시스템입니다.

도메인 관계도

User (OWNER) ──┐
               │ 1:N
               ▼
              Gym ────────┐
               │          │ 1:N
               │          ▼
               │        Trainer
               │ 1:N      │
               ▼          │
           Membership     │
               │          │
               │ N:M      │ N:M (포인트 직접 결제)
               ▼          ▼
User ──→ MembershipPurchase    User ──→ Reservation
  │                                         │
  │ (포인트 결제)                           │ (포인트 직접 결제)
  ▼                                         ▼
Point ←─────────────────────────────────── Point

실제 구현된 사용자 플로우

USER 플로우

USER → 헬스장 & 트레이너 탐색 → 트레이너 선택 
  ↓
예약 가능 시간 조회 → 예약 생성 (포인트 차감) → 예약 대기 (PENDING)
  ↓
OWNER 승인/거절 대기 → 예약 확정/거절 → 예약 이용 → (선택) 리뷰 작성
  ↓
(별도) 이용권 선택 → 포인트 결제 → 예약 활성화일 설정 → 자동 활성화 → 헬스장 이용

OWNER 플로우

OWNER → 헬스장 등록 → 관리자 승인 대기 → 트레이너, 이용권 등록 
  ↓
예약 관리 (승인/거절) → 게시물 관리

Point 플로우

Point → 포인트 충전 → 이용권/PT 결제 시 포인트 사용
  ↓
예약 취소/거절 시 포인트 환불 → 90% 포인트 환불 → 포인트 이력 자동 및 추적

1. 헬스장(Gym) 도메인

엔티티 구조

@Entity
@Table(name = "gyms")
@SQLRestriction("deleted_at IS NULL")  // 소프트 삭제
@SQLDelete(sql = "UPDATE gyms SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
public class Gym extends BaseEntity {
    private Long id;                    // 헬스장 ID
    private String name;                // 헬스장 이름
    private String number;              // 연락처 (unique)
    private String content;             // 상세 설명
    private String address;             // 주소
    private LocalTime openTime;         // 운영 시작 시간
    private LocalTime closeTime;        // 운영 종료 시간
    private GymStatus gymStatus;        // 승인 상태
    private User user;                  // 헬스장 소유자 (OWNER)
    private List<Trainer> trainers;     // 소속 트레이너들
    private List<Image> images;         // 헬스장 이미지들
}

헬스장 상태 관리

상태 설명 권한 검증
PENDING 대기중 관리자 승인 필요
APPROVED 승인됨 운영 가능 상태
REJECTED 거절됨 재신청 필요
OPEN 영업중 실시간 운영 상태
CLOSE 영업종료 실시간 운영 상태

헬스장 소유권 검증 로직

// 모든 헬스장 관련 작업에서 반복되는 핵심 검증
private void validateOwnership(Long userId, Gym gym) {
    // 1. 사용자 조회
    User user = userRepository.findByIdOrElseThrow(userId);
    
    // 2. OWNER 권한 확인
    if (user.getUserRole() != UserRole.OWNER) {
        throw new BaseException(ExceptionCode.NOT_GYM_OWNER);
    }
    
    // 3. 헬스장 소유자 확인
    if (!Objects.equals(gym.getOwner().getId(), userId)) {
        throw new BaseException(ExceptionCode.NOT_GYM_OWNER);
    }
}

1. 헬스장 등록 (신청)

POST /gyms

처리 과정:

  1. 권한 검증: UserRole.OWNER 확인
  2. 데이터 검증: 필수 정보 및 이미지 검증
  3. 엔티티 생성: 기본 상태 PENDING으로 설정
  4. 이미지 처리: S3 업로드 및 연관관계 설정
  5. DB 저장: 헬스장 정보 영구 저장

2. 관리자 승인 프로세스

POST /admin/gyms/{gymId}/approve  // 승인
POST /admin/gyms/{gymId}/reject   // 거절
GET /admin/gyms/pending           // 승인 대기 목록

승인 규칙:

  • PENDING 상태만 승인/거절 가능
  • 승인 시 → APPROVED 상태 전환
  • 거절 시 → REJECTED 상태 전환

3. 헬스장 운영 관리

GET /gyms              // 전체 헬스장 목록 (페이징)
GET /gyms/{gymId}      // 헬스장 상세 정보
PATCH /gyms/{gymId}    // 헬스장 정보 수정
DELETE /gyms/{gymId}   // 헬스장 삭제 (소프트 삭제)

이미지 관리 시스템

  • 다중 이미지 지원: 헬스장당 여러 이미지 등록
  • S3 연동: AWS S3를 통한 이미지 저장
  • CASCADE 삭제: 헬스장 삭제 시 이미지도 함께 삭제
  • 이미지 업데이트: 기존 이미지 삭제 후 새 이미지 업로드

2. 트레이너(Trainer) 도메인

엔티티 구조

@Entity
@Table(name = "trainers")
public class Trainer extends BaseEntity {
    private Long id;                    // 트레이너 ID
    private String name;                // 트레이너 이름
    private int price;                  // 시간당 수업료
    private String content;             // 트레이너 소개
    private String experience;          // 경력 사항
    private TrainerStatus status;       // 트레이너 상태
    private Gym gym;                    // 소속 헬스장
    private List<Image> images;         // 트레이너 프로필 이미지
    private List<User> members;         // 담당 회원들
}

트레이너 상태 관리

상태 설명 가용성
ACTIVE 활성화 예약 가능
HOLIDAY 휴가 중 예약 불가
DELETED 삭제됨 서비스 이용 불가

헬스장-트레이너 연관관계 검증

public void validateTrainerBelongsToGym(Trainer trainer, Gym gym) {
    if (!trainer.getGym().getId().equals(gym.getId())) {
        throw new BaseException(INVALID_GYM_TRAINER_RELATION);
    }
}

1. 트레이너 등록

POST /gyms/{gymId}/trainers

처리 과정:

  1. 헬스장 소유권 검증: 3단계 권한 확인
  2. 트레이너 정보 검증: 이름, 가격, 경력 등
  3. 이미지 처리: 프로필 이미지 S3 업로드
  4. 헬스장 배정: trainer.assignToGym(gym)
  5. 상태 초기화: TrainerStatus.ACTIVE로 설정

2. 트레이너 관리

GET /gyms/{gymId}/trainers           // 헬스장 트레이너 목록
GET /gyms/{gymId}/trainers/{id}      // 트레이너 상세 정보
PATCH /gyms/{gymId}/trainers/{id}    // 트레이너 정보 수정
DELETE /gyms/{gymId}/trainers/{id}   // 트레이너 삭제

3. 복합 권한 검증 로직

// 트레이너 관련 모든 작업에서 수행되는 검증
private void validateTrainerOperation(Long userId, Long gymId, Long trainerId) {
    // 1. 사용자 OWNER 권한 확인
    User user = userRepository.findByIdOrElseThrow(userId);
    if (user.getUserRole() != UserRole.OWNER) {
        throw new BaseException(ExceptionCode.NOT_GYM_OWNER);
    }
    
    // 2. 헬스장 소유권 확인
    Gym gym = gymRepository.findByIdOrElseThrow(gymId);
    if (!Objects.equals(gym.getOwner().getId(), userId)) {
        throw new BaseException(ExceptionCode.NOT_GYM_OWNER);
    }
    
    // 3. 트레이너-헬스장 연관관계 확인
    Trainer trainer = trainerRepository.findByIdOrElseThrow(trainerId);
    if (!trainer.getGym().getId().equals(gymId)) {
        throw new BaseException(ExceptionCode.NOT_GYM_OWNER);
    }
    
    // 4. 트레이너 소속 확인
    trainer.validateTrainerBelongsToGym(trainer, gym);
}

3. 이용권(Membership) 도메인

이용권 엔티티 구조

@Entity
@Table(name = "memberships")
public class Membership extends BaseEntity {
    private Long id;                    // 이용권 ID
    private String name;                // 이용권 이름
    private int price;                  // 이용권 가격
    private String content;             // 이용권 설명
    private int durationInDays;         // 이용 기간 (일)
    private Gym gym;                    // 발행 헬스장
}

이용권 구매 엔티티 구조

@Entity
@Table(name = "membership_purchase")
public class MembershipPurchase extends BaseEntity {
    private Long id;                      // 구매 ID
    private Membership membership;        // 구매한 이용권
    private User user;                    // 구매자
    private Gym gym;                      // 이용 헬스장
    private LocalDateTime purchaseDate;   // 구매일시
    private LocalDateTime startDate;      // 이용 시작일 (활성화 시 설정)
    private LocalDateTime endDate;        // 이용 종료일 (활성화 시 설정)
    private LocalDateTime scheduledStartDate; // 예약 활성화일
}

이용권 상태별 동작

1. 구매 상태 (미활성화)

public boolean isNotStarted() {
    return startDate == null;
}
  • 특징: 구매했지만 아직 사용 시작 안함
  • 활성화: 예약된 날짜에 자동 활성화 또는 수동 활성화

2. 활성화 상태

public boolean isActive() {
    return startDate != null && endDate != null
        && LocalDateTime.now().isAfter(startDate)
        && LocalDateTime.now().isBefore(endDate);
}
  • 특징: 현재 사용 가능한 이용권
  • 예약 가능: 이 상태에서만 트레이너 예약 가능

3. 만료 상태

  • 특징: endDate를 지난 이용권
  • 제한: 새로운 예약 불가

이용권 구매 플로우

1. 이용권 구매 프로세스

POST /gyms/{gymId}/memberships/{membershipId}/purchase

처리 과정:

  1. 이용권 검증: 헬스장의 이용권인지 확인
  2. 활성화 날짜 검증: 구매일 기준 7일 이내만 허용
  3. 포인트 차감: pointService.usePoint(userId, price, description)
  4. 구매 기록 생성: 예약 활성화 날짜와 함께 저장
  5. 구매 완료: MembershipPurchase 엔티티 생성

2. 활성화 날짜 제한 규칙

// 구매일 기준 제한 사항
LocalDate today = LocalDate.now();
if (activationDate.isBefore(today)) {
    throw new BaseException(ExceptionCode.INVALID_ACTIVATION_DATE_PAST);
}
if (activationDate.isAfter(today.plusDays(7))) {
    throw new BaseException(ExceptionCode.INVALID_ACTIVATION_DATE_TOO_FAR);
}

자동화 스케줄러 시스템

1. 이용권 자동 활성화 (매일 오전 6시)

@Scheduled(cron = "0 0 6 * * *")
@Transactional
public void activateScheduledMemberships() {
    // 1. 오늘 활성화 예정인 이용권 조회
    List<MembershipPurchase> scheduled = membershipPurchaseRepository
        .findScheduledForActivation(startOfDay, endOfDay);
    
    // 2. 자동 활성화 실행
    scheduled.forEach(purchase -> {
        purchase.activate(LocalDateTime.now());
        
        // 3. 활성화 알림 발송
        notifyService.send(purchase.getUser(), 
            NotificationType.MEMBERSHIP, 
            "이용권이 활성화되었습니다!", 
            "/memberships/purchases/active");
    });
}

2. 만료 예정 알림 시스템 (매일 오전 9시)

3일 전 알림

@Scheduled(cron = "0 0 9 * * *")
public void sendThreeDaysBeforeExpirationNotice() {
    LocalDateTime threeDaysLater = LocalDateTime.now().plusDays(3);
    // 3일 후 만료되는 이용권 조회 및 알림 발송
}

1일 전 알림

@Scheduled(cron = "0 0 9 * * *")
public void sendOneDayBeforeExpirationNotice() {
    LocalDateTime tomorrow = LocalDateTime.now().plusDays(1);
    // 내일 만료되는 이용권 조회 및 알림 발송
}

당일 만료 알림

@Scheduled(cron = "0 0 9 * * *")
public void sendTodayExpirationNotice() {
    LocalDateTime today = LocalDateTime.now();
    // 오늘 만료되는 이용권 조회 및 알림 발송
}

복합 비즈니스 로직

1. 권한 계층 구조

ADMIN
  └── 헬스장 승인/거절
  
OWNER
  └── 헬스장 소유자
      ├── 헬스장 관리 (CRUD)
      ├── 트레이너 관리 (CRUD)
      └── 이용권 관리 (CRUD)
      
USER
  └── 일반 사용자
      ├── 이용권 구매
      ├── 트레이너 예약
      └── 헬스장 이용

2. 데이터 일관성 보장

// 트레이너 삭제 시 연관 데이터 정리
@Transactional
public void deleteTrainer(Long trainerId) {
    Trainer trainer = trainerRepository.findByIdOrElseThrow(trainerId);
    
    // 1. 진행 중인 예약 확인
    if (hasActiveReservations(trainer)) {
        throw new BaseException(ExceptionCode.TRAINER_HAS_ACTIVE_RESERVATIONS);
    }
    
    // 2. S3 이미지 삭제
    trainer.getImages().forEach(image -> 
        s3Service.deleteFileFromS3(image.getUrl()));
    
    // 3. 트레이너 삭제
    trainerRepository.delete(trainer);
}

3. 소프트 삭제 구현

@SQLRestriction("deleted_at IS NULL")
@SQLDelete(sql = "UPDATE gyms SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
  • 헬스장 삭제: 소프트 삭제로 데이터 보존
  • 연관 데이터: 트레이너, 이용권 등도 함께 비활성화
  • 복구 가능: 관리자가 필요시 복구 가능

API 명세 요약

헬스장 관리 API

Method Endpoint 설명 권한
POST /gyms 헬스장 등록 OWNER
GET /gyms 전체 헬스장 목록 조회 PUBLIC
GET /gyms/{id} 헬스장 상세 조회 PUBLIC
PATCH /gyms/{id} 헬스장 정보 수정 OWNER
DELETE /gyms/{id} 헬스장 삭제 OWNER
POST /admin/gyms/{id}/approve 헬스장 승인 ADMIN
POST /admin/gyms/{id}/reject 헬스장 거절 ADMIN

트레이너 관리 API

Method Endpoint 설명 권한
POST /gyms/{gymId}/trainers 트레이너 등록 OWNER
GET /gyms/{gymId}/trainers 트레이너 목록 조회 USER+
GET /gyms/{gymId}/trainers/{id} 트레이너 상세 조회 USER+
PATCH /gyms/{gymId}/trainers/{id} 트레이너 정보 수정 OWNER
DELETE /gyms/{gymId}/trainers/{id} 트레이너 삭제 OWNER

이용권 관리 API

Method Endpoint 설명 권한
POST /gyms/{gymId}/memberships 이용권 생성 OWNER
GET /gyms/{gymId}/memberships 이용권 목록 조회 USER+
POST /gyms/{gymId}/memberships/{id}/purchase 이용권 구매 USER
GET /memberships/purchases/me 내 구매 이력 조회 USER
GET /memberships/purchases/active 현재 활성 이용권 조회 USER
GET /memberships/purchases/not-started 미활성화 이용권 조회 USER

보안 및 검증 체계

1. 다단계 권한 검증

// 트레이너 수정 시 수행되는 검증
private void validateTrainerUpdate(Long userId, Long gymId, Long trainerId) {
    // Level 1: 사용자 존재 및 권한 확인
    User user = userRepository.findByIdOrElseThrow(userId);
    if (user.getUserRole() != UserRole.OWNER) {
        throw new BaseException(ExceptionCode.NOT_GYM_OWNER);
    }
    
    // Level 2: 헬스장 소유권 확인
    Gym gym = gymRepository.findByIdOrElseThrow(gymId);
    if (!Objects.equals(gym.getOwner().getId(), userId)) {
        throw new BaseException(ExceptionCode.NOT_GYM_OWNER);
    }
    
    // Level 3: 트레이너 소속 확인
    Trainer trainer = trainerRepository.findByIdOrElseThrow(trainerId);
    if (!trainer.getGym().getId().equals(gymId)) {
        throw new BaseException(ExceptionCode.NOT_GYM_OWNER);
    }
    
    // Level 4: 연관관계 검증
    trainer.validateTrainerBelongsToGym(trainer, gym);
}

2. 도메인별 무결성 검증

  • 헬스장: 연락처 중복 불가, 소유자 확인
  • 트레이너: 헬스장 소속 확인, 가격 양수 검증
  • 이용권: 헬스장 일치 확인, 활성화 날짜 제한

확장 가능성

성능 최적화 방안

  • 캐싱: 헬스장 목록, 트레이너 정보 캐싱
  • 인덱싱: 복합 쿼리 최적화를 위한 인덱스 설계
  • 배치 처리: 대량 알림 발송 최적화

핵심 특징 요약

복합 도메인 관리

  • 헬스장-트레이너-이용권 간 긴밀한 연관관계
  • 계층적 권한 구조 (ADMIN > OWNER > USER)
  • 도메인별 독립적이면서도 연결된 비즈니스 로직

자동화 시스템

  • 이용권 자동 활성화 스케줄러
  • 만료 예정 알림 시스템 (3일/1일/당일 전)
  • 실시간 알림 발송 통합

데이터 무결성

  • 다단계 권한 검증 시스템
  • 연관관계 일관성 보장
  • 소프트 삭제를 통한 데이터 보존

사용자 경험

  • 직관적인 이용권 구매 플로우
  • 예약 활성화 날짜 선택 기능
  • 실시간 상태 알림 시스템
⚠️ **GitHub.com Fallback** ⚠️