끝을 보는 용기

Day 119 - 게임 아이템 거래소 프로젝트 35%, 낙낙(Knock Knock) 대신 락락(Lock Lock), 제대로 골머리 앓는 중

writingforever162 2025. 2. 2. 23:51

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) 좀 공부해야겠다.