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;
}
- 장점: 명시적 잠금으로 레이스 컨디션 완화. 단점: 대기/데드락 리스크.