헬스장 & 트레이너, 이용권 관리 - 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 → 헬스장 & 트레이너 탐색 → 트레이너 선택
↓
예약 가능 시간 조회 → 예약 생성 (포인트 차감) → 예약 대기 (PENDING)
↓
OWNER 승인/거절 대기 → 예약 확정/거절 → 예약 이용 → (선택) 리뷰 작성
↓
(별도) 이용권 선택 → 포인트 결제 → 예약 활성화일 설정 → 자동 활성화 → 헬스장 이용
OWNER → 헬스장 등록 → 관리자 승인 대기 → 트레이너, 이용권 등록
↓
예약 관리 (승인/거절) → 게시물 관리
Point → 포인트 충전 → 이용권/PT 결제 시 포인트 사용
↓
예약 취소/거절 시 포인트 환불 → 90% 포인트 환불 → 포인트 이력 자동 및 추적
@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);
}
}
POST /gyms
처리 과정:
- 권한 검증: UserRole.OWNER 확인
- 데이터 검증: 필수 정보 및 이미지 검증
- 엔티티 생성: 기본 상태 PENDING으로 설정
- 이미지 처리: S3 업로드 및 연관관계 설정
- DB 저장: 헬스장 정보 영구 저장
POST /admin/gyms/{gymId}/approve // 승인
POST /admin/gyms/{gymId}/reject // 거절
GET /admin/gyms/pending // 승인 대기 목록
승인 규칙:
- PENDING 상태만 승인/거절 가능
- 승인 시 → APPROVED 상태 전환
- 거절 시 → REJECTED 상태 전환
GET /gyms // 전체 헬스장 목록 (페이징)
GET /gyms/{gymId} // 헬스장 상세 정보
PATCH /gyms/{gymId} // 헬스장 정보 수정
DELETE /gyms/{gymId} // 헬스장 삭제 (소프트 삭제)
- 다중 이미지 지원: 헬스장당 여러 이미지 등록
- S3 연동: AWS S3를 통한 이미지 저장
- CASCADE 삭제: 헬스장 삭제 시 이미지도 함께 삭제
- 이미지 업데이트: 기존 이미지 삭제 후 새 이미지 업로드
@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);
}
}
POST /gyms/{gymId}/trainers
처리 과정:
- 헬스장 소유권 검증: 3단계 권한 확인
- 트레이너 정보 검증: 이름, 가격, 경력 등
- 이미지 처리: 프로필 이미지 S3 업로드
-
헬스장 배정:
trainer.assignToGym(gym)
- 상태 초기화: TrainerStatus.ACTIVE로 설정
GET /gyms/{gymId}/trainers // 헬스장 트레이너 목록
GET /gyms/{gymId}/trainers/{id} // 트레이너 상세 정보
PATCH /gyms/{gymId}/trainers/{id} // 트레이너 정보 수정
DELETE /gyms/{gymId}/trainers/{id} // 트레이너 삭제
// 트레이너 관련 모든 작업에서 수행되는 검증
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);
}
@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; // 예약 활성화일
}
public boolean isNotStarted() {
return startDate == null;
}
- 특징: 구매했지만 아직 사용 시작 안함
- 활성화: 예약된 날짜에 자동 활성화 또는 수동 활성화
public boolean isActive() {
return startDate != null && endDate != null
&& LocalDateTime.now().isAfter(startDate)
&& LocalDateTime.now().isBefore(endDate);
}
- 특징: 현재 사용 가능한 이용권
- 예약 가능: 이 상태에서만 트레이너 예약 가능
- 특징: endDate를 지난 이용권
- 제한: 새로운 예약 불가
POST /gyms/{gymId}/memberships/{membershipId}/purchase
처리 과정:
- 이용권 검증: 헬스장의 이용권인지 확인
- 활성화 날짜 검증: 구매일 기준 7일 이내만 허용
-
포인트 차감:
pointService.usePoint(userId, price, description)
- 구매 기록 생성: 예약 활성화 날짜와 함께 저장
- 구매 완료: MembershipPurchase 엔티티 생성
// 구매일 기준 제한 사항
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);
}
@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");
});
}
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();
// 오늘 만료되는 이용권 조회 및 알림 발송
}
ADMIN
└── 헬스장 승인/거절
OWNER
└── 헬스장 소유자
├── 헬스장 관리 (CRUD)
├── 트레이너 관리 (CRUD)
└── 이용권 관리 (CRUD)
USER
└── 일반 사용자
├── 이용권 구매
├── 트레이너 예약
└── 헬스장 이용
// 트레이너 삭제 시 연관 데이터 정리
@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);
}
@SQLRestriction("deleted_at IS NULL")
@SQLDelete(sql = "UPDATE gyms SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
- 헬스장 삭제: 소프트 삭제로 데이터 보존
- 연관 데이터: 트레이너, 이용권 등도 함께 비활성화
- 복구 가능: 관리자가 필요시 복구 가능
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 |
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 |
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 |
// 트레이너 수정 시 수행되는 검증
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);
}
- 헬스장: 연락처 중복 불가, 소유자 확인
- 트레이너: 헬스장 소속 확인, 가격 양수 검증
- 이용권: 헬스장 일치 확인, 활성화 날짜 제한
- 캐싱: 헬스장 목록, 트레이너 정보 캐싱
- 인덱싱: 복합 쿼리 최적화를 위한 인덱스 설계
- 배치 처리: 대량 알림 발송 최적화
- 헬스장-트레이너-이용권 간 긴밀한 연관관계
- 계층적 권한 구조 (ADMIN > OWNER > USER)
- 도메인별 독립적이면서도 연결된 비즈니스 로직
- 이용권 자동 활성화 스케줄러
- 만료 예정 알림 시스템 (3일/1일/당일 전)
- 실시간 알림 발송 통합
- 다단계 권한 검증 시스템
- 연관관계 일관성 보장
- 소프트 삭제를 통한 데이터 보존
- 직관적인 이용권 구매 플로우
- 예약 활성화 날짜 선택 기능
- 실시간 상태 알림 시스템