1. 프로젝트 진행 상황 및 계획
🥇 경매 단건 조회하는 R 구현하기 (완료)
🥈 경매장 R에 예외 처리하기 (완료)
🥉 종료된 경매에 입찰할 수 없도록 코드 리팩토링(refactoring)하기 (진행 중, 25.02.03 완료 목표)
4️⃣ 동시성 제어 관련 공부하기 (진행 중, 25.02.03 완료 목표)
2. 여태까지 처리한 예외만 5가지인데, 내일부터는 동시성까지 제어해야 한다.
package no.gunbang.market.domain.auction.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.Objects;
import lombok.Getter;
import lombok.NoArgsConstructor;
import no.gunbang.market.common.BaseEntity;
import no.gunbang.market.common.Item;
import no.gunbang.market.common.Status;
import no.gunbang.market.common.exception.CustomException;
import no.gunbang.market.common.exception.ErrorCode;
import no.gunbang.market.domain.user.entity.User;
import org.hibernate.annotations.Comment;
@Entity
@Table(name = "auction")
@Getter
@NoArgsConstructor
public class Auction extends BaseEntity {
@Comment("경매장 식별자")
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(columnDefinition = "BIGINT")
private Long id;
@Comment("경매 시작가")
private long startingPrice;
@Comment("경매 마감 기한")
private LocalDateTime dueDate;
@Enumerated(EnumType.STRING)
@Comment("경매 진행 상태")
private Status status;
@Comment("경매 참여자 수")
private int bidderCount = 0;
@Comment("사용자 외래키")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Comment("아이템 외래키")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
private static final int minAuctionDays = 3;
private static final int maxAuctionDays = 7;
public static Auction of(
User user,
Item item,
long startingPrice,
int auctionDays
) {
validateAuctionDays(auctionDays);
Auction auction = new Auction();
auction.user = user;
auction.item = item;
auction.startingPrice = startingPrice;
auction.status = Status.ON_SALE;
auction.dueDate = auction.toDueDate(auctionDays);
return auction;
}
// 로그인한 사용자와 경매 등록한 사용자가 동일한지 검증
public void validateUser(Long userId) {
if (!Objects.equals(this.user.getId(), userId)) {
throw new CustomException(ErrorCode.NO_AUTHORITY);
}
}
// 경매 취소
public void delete() {
this.status = Status.CANCELLED;
}
// 입찰 횟수가 늘어날 때마다 입찰자 수 계산
public void incrementBidderCount() {
this.bidderCount++;
}
// 경매 마감 기한을 계산하여 LocalDateTime으로 반환
private LocalDateTime toDueDate(int auctionDays) {
return LocalDateTime.now().plusDays(auctionDays);
}
// 경매 진행 기간이 최소 및 최대 제한을 만족하는지 검증
private static void validateAuctionDays(int auctionDays) {
if (auctionDays < minAuctionDays || auctionDays > maxAuctionDays) {
throw new CustomException(ErrorCode.AUCTION_DAYS_OUT_OF_RANGE);
}
}
}
package no.gunbang.market.domain.auction.service;
import java.time.LocalDateTime;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import no.gunbang.market.common.Item;
import no.gunbang.market.common.ItemRepository;
import no.gunbang.market.common.Status;
import no.gunbang.market.common.exception.CustomException;
import no.gunbang.market.common.exception.ErrorCode;
import no.gunbang.market.domain.auction.dto.request.AuctionRegistrationRequestDto;
import no.gunbang.market.domain.auction.dto.request.BidAuctionRequestDto;
import no.gunbang.market.domain.auction.dto.response.AuctionListResponseDto;
import no.gunbang.market.domain.auction.dto.response.AuctionRegistrationResponseDto;
import no.gunbang.market.domain.auction.dto.response.AuctionResponseDto;
import no.gunbang.market.domain.auction.dto.response.BidAuctionResponseDto;
import no.gunbang.market.domain.auction.entity.Auction;
import no.gunbang.market.domain.auction.entity.Bid;
import no.gunbang.market.domain.auction.repository.AuctionRepository;
import no.gunbang.market.domain.auction.repository.BidRepository;
import no.gunbang.market.domain.user.entity.User;
import no.gunbang.market.domain.user.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class AuctionService {
private static final LocalDateTime START_DATE = LocalDateTime.now().minusDays(30);
private final AuctionRepository auctionRepository;
private final ItemRepository itemRepository;
private final UserRepository userRepository;
private final BidRepository bidRepository;
public Page<AuctionListResponseDto> getPopulars(Pageable pageable) {
return auctionRepository.findPopularAuctionItems(
START_DATE,
pageable
);
}
public Page<AuctionListResponseDto> getAllAuctions(
Pageable pageable,
String searchKeyword,
String sortBy,
String sortDirection
) {
return auctionRepository.findAllAuctionItems(
START_DATE,
searchKeyword,
sortBy,
sortDirection,
pageable
);
}
public AuctionResponseDto getAuctionById(Long auctionId) {
Auction foundAuction = findAuctionById(auctionId);
Optional<Bid> currentBid = bidRepository.findByAuction(foundAuction);
long currentMaxPrice = currentBid.map(Bid::getBidPrice)
.orElse(foundAuction.getStartingPrice());
return AuctionResponseDto.toDto(
foundAuction,
currentMaxPrice
);
}
@Transactional
public AuctionRegistrationResponseDto registerAuction(
Long userId,
AuctionRegistrationRequestDto requestDto
) {
User foundUser = findUserById(userId);
Long itemId = requestDto.getItemId();
Item foundItem = findItemByItem(itemId);
Auction auctionToRegister = Auction.of(
foundUser,
foundItem,
requestDto.getStartingPrice(),
requestDto.getAuctionDays()
);
Auction registeredAuction = auctionRepository.save(auctionToRegister);
return AuctionRegistrationResponseDto.toDto(registeredAuction);
}
@Transactional
public BidAuctionResponseDto bidAuction(
Long userId,
BidAuctionRequestDto requestDto
) {
User foundUser = findUserById(userId);
Long auctionId = requestDto.getAuctionId();
Auction foundAuction = auctionRepository.findByIdAndStatus(
auctionId,
Status.ON_SALE
).orElseThrow(
() -> new CustomException(ErrorCode.AUCTION_NOT_ACTIVE)
);
long bidPrice = requestDto.getBidPrice();
Bid foundBid = bidRepository.findByAuction(foundAuction)
.map(existingBid -> {
existingBid.updateBid(
bidPrice,
foundUser
);
return existingBid;
}
).orElseGet(
() -> bidRepository.save(
Bid.of(
foundUser,
foundAuction,
bidPrice
)
)
);
foundAuction.incrementBidderCount();
auctionRepository.save(foundAuction);
return BidAuctionResponseDto.toDto(foundBid);
}
@Transactional
public void deleteAuction(Long userId, Long auctionId) {
boolean hasBid = bidRepository.existsByAuctionId(auctionId);
if (hasBid) {
throw new CustomException(ErrorCode.CANNOT_CANCEL_AUCTION);
}
Auction foundAuction = findAuctionById(auctionId);
foundAuction.validateUser(userId);
foundAuction.delete();
}
private User findUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(
() -> new CustomException(ErrorCode.USER_NOT_FOUND)
);
}
private Item findItemByItem(Long itemId) {
return itemRepository.findById(itemId)
.orElseThrow(
() -> new CustomException(ErrorCode.ITEM_NOT_FOUND)
);
}
private Auction findAuctionById(Long auctionId) {
return auctionRepository.findById(auctionId)
.orElseThrow(
() -> new CustomException(ErrorCode.AUCTION_NOT_FOUND)
);
}
}
(1) AUCTION_NOT_ACTIVE: 취소되거나 종료된 경매에 입찰하려고 할 때
(2) AUCTION_NOT_FOUND: 입찰하려는 경매가 존재하지 않을 때
(3) CANNOT_CANCEL_AUCTION: 입찰 중인 경매를 취소하려고 할 때
(4) NO_AUTHORITY: 경매를 등록한 사용자가 아닌 다른 사용자가 경매를 취소하려고 할 때
(5) AUCTION_DAYS_OUT_OF_RANGE: 입력된 경매 희망 기간이 3일 미만 또는 7일 초과일 때
아이템이 없을 때나 사용자가 없을 때를 제외하고도 처리한 예외만 5개인데, 종료된 경매에 입찰하려고 할 때 예외 처리도 해야 하고, 앞으로 남은 기간 동안 프로젝트를 진행하면서 얼마나 많은 예외가 발생할지는 아무도 몰랐다. 그뿐이랴.
내일부터는 동시성도 제어해야 마감일을 맞출 수 있었다.
틈틈이 5분 기록 보드에 트러블슈팅(troubleshooting)과 아이디어를 기록해 두며 코드 작성 속도를 높여야겠다. 팀원 모두의 격려와 별개로, 누구보다 내 실력이 어디까지인지 알고 한계를 누구보다 체감한 만큼, 어느 때보다 팀에서 더 노력해야 한다는 위기감을 느꼈다. 자기 전까지 문 대신 내 머리를 '락락(Lock Lock)' 두들기는 락(lock) 좀 공부해야겠다.
'끝을 보는 용기' 카테고리의 다른 글
Day 121 - 게임 아이템 거래소 프로젝트 55%, 한 사용자가 연속으로 입찰하지 못하도록 막아야 할까? (0) | 2025.02.04 |
---|---|
Day 120 - 게임 아이템 거래소 프로젝트 45%, 자물쇠를 걸기도 전에 데드락(Deadlock)이 발생했을 때 심정이란 (0) | 2025.02.03 |
Day 118 - 게임 아이템 거래소 프로젝트 25%, 코드 한 줄 쓸 때마다 '지금이 최선일까?' 고민하다 (0) | 2025.02.01 |
Day 117 - 게임 아이템 거래소 프로젝트 10%, 기록 팀장은 감기에 걸려도 기록을 포기하지 못한다. (0) | 2025.01.31 |
Day 116 - 휴식 후 JVM을 간단하게 복습하다. (0) | 2025.01.30 |