카테고리 없음

COW-CAMPUS-CONNECT : 룰렛 서비스 온보딩 가이드 (2) — 엔티티, 레포지토리, 서비스 구현 단계

Frisbeen 2025. 9. 15. 20:58

0.  대상: 룰렛 기능 도메인

DB 스키마(요약)

  • users(id, name, gender, ...)
  • prizes(경품 마스터)
  • roulette_status(유저별 남은 시도)
  • roulette_spins(스핀 히스토리)

관계 요약

  • users 1 ── 1 roulette_status (PK 공유)
  • users 1 ── N roulette_spins
  • prizes 1 ── N roulette_spins

1) 엔티티(Entity)

1-1. User (기준 엔티티)

package org.example.cowmatchingbe.domain;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;

@Entity @Table(name="users")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class User {
    @Id private Long id;

    @Column(nullable=false, length=50)
    private String name;

    @Column(length=10)
    private String gender;

    @Column(name="check_num", nullable=false)
    private Integer checkNum = 0;

    @Column(name="created_at", insertable=false, updatable=false)
    private LocalDateTime createdAt;
}
  • **기본 생성자(@NoArgsConstructor)**는 JPA가 리플렉션으로 객체를 만들 때 필요합니다.
  • @AllArgsConstructor, @Builder는 편의. (실무에선 createdAt 같은 DB 자동값을 AllArgs로 받지 않도록 주의)

1-2. Prize (경품 마스터)

💥 기존 코드에서 java.security.Timestamp를 임포트한 버그가 있었음. 올바른 타입은 LocalDateTime 또는 java.sql.Timestamp입니다. 요즘은 LocalDateTime 권장.

권장 버전:

package org.example.cowmatchingbe.domain.roulette;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;

@Entity @Table(name="prizes")
@Getter @Setter @NoArgsConstructor
public class Prize {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique=true, length=64) private String code;
    @Column(nullable=false, length=100) private String name;
    @Column(length=255) private String description;

    @Column(nullable=false) private Integer weight = 1; // 가중치
    @Column(nullable=false) private Integer stock  = 0; // 재고
    @Column(length=16) private String color;
    @Column(nullable=false) private Integer displayOrder = 0;
    @Column(nullable=false) private Boolean isActive = true;
    @Column(nullable=false) private Boolean isLoser  = false;

    @Column(name="created_at", insertable=false, updatable=false)
    private LocalDateTime createdAt;
    @Column(name="updated_at", insertable=false, updatable=false)
    private LocalDateTime updatedAt;

    // (선택) 편의 생성자 — 새 경품 등록 시 필수값만 강제하고 싶을 때
    public Prize(String code, String name, Integer weight, Integer stock, Integer displayOrder) {
        this.code = code; this.name = name; this.weight = weight; this.stock = stock; this.displayOrder = displayOrder;
    }
}
  • 왜 생성자를 굳이 추가하나?: “객체를 만들 때 필수값을 반드시 받게 강제”하기 위해서. 세터로 흩어지면 누락/실수 위험.
  • 안 써도 동작은 하지만, 도메인 규칙을 코드로 표현하려면 편의 생성자가 유용합니다.

1-3. RouletteStatus (유저별 시도 상태, 1:1, PK=FK)

여기서는 생성자를 2개 둬서 서비스에서 쓰기 편하게 만들었습니다.

package org.example.cowmatchingbe.domain.roulette;

import jakarta.persistence.*;
import lombok.*;
import org.example.cowmatchingbe.domain.User;
import java.time.LocalDateTime;

@Entity @Table(name="roulette_status")
@Getter @Setter @NoArgsConstructor
public class RouletteStatus {
    @Id
    @Column(name = "user_id")
    private Long userId;             // PK = FK(users.id)

    @MapsId
    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id")
    private User user;               // 연관관계 주인 (PK 공유)

    @Column(nullable=false) private Integer attemptsLeft = 2;
    private LocalDateTime lastSpinAt;

    @Column(insertable=false, updatable=false) private LocalDateTime createdAt;
    @Column(insertable=false, updatable=false) private LocalDateTime updatedAt;

    // 풀 생성자 — 엔티티 생성 시점에 모든 필수값을 받는 버전
    public RouletteStatus(Long userId, Integer attemptsLeft, LocalDateTime lastSpinAt) {
        this.userId = userId;
        this.attemptsLeft = attemptsLeft;
        this.lastSpinAt = lastSpinAt;
    }

    // 편의 생성자 — 서비스에서 자주 쓰는 단순 케이스
    public RouletteStatus(Long userId, Integer attemptsLeft) {
        this.userId = userId;
        this.attemptsLeft = attemptsLeft;
        this.lastSpinAt = null;
    }
}
  • @OneToOne + @MapsId: roulette_status.user_id가 PK이자 FK인 PK 공유 1:1을 JPA로 자연스럽게 표현.
  • 생성자를 2개 둔 이유: 서비스 코드에서 필수값만 빠르게 채워 만들고 싶어서.

❗연관 매핑을 안 쓰고 Long userId만 둬도 기능은 동작합니다(DDL의 FK가 무결성 보장). 다만 객체 탐색/가독성/JPQL join 편의가 필요하면 연관 매핑을 사용하세요.


1-4. RouletteSpin (스핀 히스토리, N:1 to User/Prize)

두 가지 설계를 제공합니다. 상황에 따라 선택하세요.

A안) 순수 ID 필드만 (단방향, 가장 단순)

@Entity @Table(name="roulette_spins")
@Getter @Setter @NoArgsConstructor
public class RouletteSpin {
    @Id @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Long id;

    @Column(nullable=false) private Long userId;    // FK(users.id)
    private Long prizeId;                           // 꽝이면 null

    @Column(nullable=false) private Boolean won = false;  // true=당첨
    private String ip; private String ua;

    @Column(nullable=false, updatable=false)
    private LocalDateTime createdAt = LocalDateTime.now();

    public RouletteSpin(Long userId, Long prizeId, Boolean won) {
        this.userId = userId; this.prizeId = prizeId; this.won = won;
        this.createdAt = LocalDateTime.now();
    }
}
  • 장점: 단순/예측가능, N+1 문제 없음. 단, User/Prize 정보는 따로 조회해야 함.

B안) 이중 매핑 (ID + 연관 둘 다, 실무에서 자주 씀)

@Entity @Table(name="roulette_spins")
@Getter @Setter @NoArgsConstructor
public class RouletteSpin {
    @Id @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Long id;

    @Column(name="user_id", nullable=false)
    private Long userId;

    @ManyToOne(fetch=FetchType.LAZY, optional=false)
    @JoinColumn(name="user_id", insertable=false, updatable=false)
    private User user; // 읽기 전용 뷰

    @Column(name="prize_id")
    private Long prizeId;

    @ManyToOne(fetch=FetchType.LAZY)
    @JoinColumn(name="prize_id", insertable=false, updatable=false)
    private Prize prize; // 읽기 전용 뷰

    @Column(nullable=false) private Boolean won = false;
    private String ip; private String ua;

    @Column(nullable=false, updatable=false)
    private LocalDateTime createdAt = LocalDateTime.now();

    public RouletteSpin(Long userId, Long prizeId, Boolean won) {
        this.userId = userId; this.prizeId = prizeId; this.won = won;
        this.createdAt = LocalDateTime.now();
    }
}
  • 장점: 조인 없이도 ID만 빠르게 접근, 필요하면 객체 탐색도 가능.
  • 주의: 연관 필드는 insertable=false, updatable=false 쓰기 주체는 ID 필드로만.

C안) 순수 연관만(아이디 필드 없음)은 가능하지만, 쿼리 DTO 작성/튜닝 난이도가 올라가서 초보에겐 비추.


2) 레포지토리(Repository)

PrizeRepository

public interface PrizeRepository extends JpaRepository<Prize, Long> {
    @Query("""
      select p from Prize p
      where p.isActive = true and (p.isLoser = true or p.stock > 0)
      order by p.displayOrder asc, p.id asc
    """)
    List<Prize> findActiveCandidates();

    @Modifying
    @Query("update Prize p set p.stock = p.stock - 1 where p.id = :id and p.stock > 0")
    int decrementStock(@Param("id") Long id); // 원자적 차감
}
  • @Query는 조회(select). @Modifying @Query는 update/delete.
  • :id는 메서드 파라미터 @Param("id") Long id 바운딩됩니다.

RouletteStatusRepository (비관적 락 or 조건 UPDATE)

public interface RouletteStatusRepository extends JpaRepository<RouletteStatus, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
    @Query("select r from RouletteStatus r where r.userId = :userId")
    Optional<RouletteStatus> findForUpdate(@Param("userId") Long userId);

    @Modifying
    @Query("""
      update RouletteStatus r
         set r.attemptsLeft = r.attemptsLeft - 1,
             r.lastSpinAt = CURRENT_TIMESTAMP
       where r.userId = :userId and r.attemptsLeft > 0
    """)
    int decrementAttempt(@Param("userId") Long userId);
}
  • 비관적 락을 쓰려면 서비스에서 **트랜잭션 안에서 findForUpdate**를 호출해야 실제로 잠김.
  • 더 단순한 패턴은 조건 UPDATE(권장): attemptsLeft > 0 인 경우에만 -1, 실패 시 0 반환.

RouletteSpinRepository

public interface RouletteSpinRepository extends JpaRepository<RouletteSpin, Long> { }

✅ 스프링 데이터 JPA는 인터페이스만 선언해도 런타임에 구현체(프록시)를 자동 생성해 Bean으로 등록합니다. 우리가 직접 new 하지 않습니다.


3) 서비스(Service) — 비즈니스 로직

기본 버전 (현재 구현)

@Service
@RequiredArgsConstructor
public class RouletteService {
    private final PrizeRepository prizeRepository;
    private final RouletteStatusRepository statusRepository;
    private final RouletteSpinRepository spinRepository;

    @Transactional(readOnly = true)
    public List<Prize> getActivePrizes() {
        return prizeRepository.findActiveCandidates();
    }

    @Transactional(readOnly = true)
    public int getAttemptsLeft(Long userId) {
        return statusRepository.findById(userId)
                .map(RouletteStatus::getAttemptsLeft)
                .orElse(2);
    }

    @Transactional
    public boolean spin(Long userId, Long prizeId) {
        // (A) 시도 차감 — 조건 UPDATE 권장
        statusRepository.findById(userId).orElseGet(() -> {
            statusRepository.save(new RouletteStatus(userId, 2));
            return null;
        });
        if (statusRepository.decrementAttempt(userId) == 0) {
            throw new IllegalStateException("더 이상 시도할 수 없습니다.");
        }

        // (B) 재고 차감 — 원자적
        if (prizeRepository.decrementStock(prizeId) == 0) {
            throw new IllegalStateException("재고가 없습니다.");
        }

        // (C) 스핀 로그
        spinRepository.save(new RouletteSpin(userId, prizeId, true));
        return true;
    }
}
  • @Transactional로 3단계를 한번에 커밋/롤백.
  • 동시성: 조건 UPDATE 패턴으로 안전하게 처리.

비관적 락 버전(참고)

@Transactional
public boolean spinWithLock(Long userId, Long prizeId) {
    RouletteStatus status = statusRepository.findForUpdate(userId)
        .orElseGet(() -> { // 없으면 만들고 같은 트랜잭션에서 다시 잠금 읽기
            statusRepository.save(new RouletteStatus(userId, 2));
            return statusRepository.findForUpdate(userId).orElseThrow();
        });

    if (status.getAttemptsLeft() <= 0) throw new IllegalStateException("시도 불가");
    status.setAttemptsLeft(status.getAttemptsLeft() - 1);

    if (prizeRepository.decrementStock(prizeId) == 0) throw new IllegalStateException("품절");

    spinRepository.save(new RouletteSpin(userId, prizeId, true));
    return true;
}
  • 장점: 명시적 잠금으로 레이스 컨디션 완화. 단점: 대기/데드락 리스크.