내가 맡은 역할은 크게
1) 상품에 대한 대분류,
2) 소분류 조회,
3) 사용자가 상품을 선택했을 때의 상세 정보 확인,
4) 해당 상품에 대한 입찰을 구현해야 했다.
대분류나 소분류 상품 조회를 할 경우에는 세부 로직이 크게 달라질 일이 없었기 때문에 소분류 -> 대분류 -> 상세 조회 -> 입찰 -> 순서로
우선순위를 정하게 되었다.
사용자가 우리 서비스에 접속하게 되면 최초에 의류라는 카테고리의 상품들이 다음과 같이 보이도록 하였다.
해당 페이지가 우리 서비스의 메인 페이지이며 각 사용자가 선택하는 카테고리 별 상품을 조회할 수 있도록 하였다.
상품이 조회되는 조건은 다음과 같다.
1) 해당 모델번호를 가진 입찰 희망 구매(최고), 판매(최저)가 각 1개씩 출력
2) 이때 구매/판매는 입찰이 PROCESS인 상태여야 한다.

해당 조건이 만족하면 위와 같이 상품이 조회되는 것을 확인할 수 있다.

이때 각 상품별 브랜드, 상품 제목, 모델번호, 해당 상품이 판매 입찰이 걸려야만 상품을 구매할 수 있기 때문에
사이즈에 상관없이 판매 입찰에 등록된 가격 중 가장 낮은 가격을 보여주도록 하였다.
해당 페이지에서 구현할 기능들을 다음과 같이 정리해 보았다.
- 스크롤로 내리면 일정 영역까지 상품 이미지 딸려와야됌
- 즉시 구매가 : 해당 상품에 사이즈 상관없이 최고가로 출력
- 즉시 판매가 : 판매 입찰희망 가격 중 가장 낮은 가격을 사이즈 상관없이 출력
- 최근 거래가 계산 : (orderState : DONE) && (orderDate : (bidKind = “SELL” && bidStatud = “END”)가 되는 시점을 저장.
- 관심상품 클릭 시 마이페이지에 상품 및 사이즈 정보 전달
- 구매 / 판매 모달창 : 해당 모달창 클릭 시 각 사이즈 별 조건
- 구매 : (bidKind = “BUY” && bidStatus=”PROGRESS”), (판매 : bidStatus = “END”)
- 만약 modelNum이 동일하다면 그중 가장 저렴(비싼)한 값
- bidStartDate가 가장 오래된 상품부터 처리
- 차트 : Order테이블의 체결된 날짜를 Between을 통해 3일, 1개월, 6개월, 1년 전체 기간 동안 평균값 구해서 Chart.js사용
- 정렬 : 최근 거래가 계산을 바탕으로 구매/판매에 값 출력
- 스타일 리뷰 : 해당 상품에 대한 PhotoReview, User 테이블을 통해 스타일 리뷰 작성 (CRUD)
- 탭창 : 체결 거래, 판매, 구매입찰에 대한 탭창 필요
- 여기에 판매 입찰, 구매 입찰 : 입찰 희망가가 가장 싼 가격의 역순 및 해당 사이즈에 해당하는 가격에 수량 몇 개인지
- 버튼 : 모든 사이즈, 구매, 판매, 관심상품, 스타일 좋아요
위에서 설명한 것처럼 소분류 페이지를 구현 후, 세부 로직을 수정하여 대분류 상품 조회까지 가능하도록 한 뒤, 상세 페이지에서 구현할 기능들을 정리 후, 사용자가 상품을 선택했을 때 해당 상품의 상세 정보를 조회할 수 있도록 구현하였다.

[기본 정보 및 최근 거래가 조회]

상품 선택 시 상품의 기본 정보를 가져오도록 하였고 즉시 구매가는 메인 페이지와 동일하게 테이블 간 left join을 통해 판매 입찰이 등록된 상품중 가장 낮은 가격을 보여주도록 진행하였다.
이때 판매 입찰이 등록된 상품중 가장 낮은 가격이라면 판매 버튼에 153,300원이 보여야 되지 않냐고 생각할 수 있다.
하지만 현재가 올바르게 구현되어 있는 것이다.
왜냐하면 여러 사용자들이 구매 또는 판매를 희망할 때 본인들이 원하는 가격에 입찰을 걸게 될 것이다.
이때 일반적으로 상품이 판매로 등록되어 있는 상품이 없을 때, 해당 상품을 구매하고 싶다고 구매를 할 수 있는가??
절대 아니다. 상식적으로 판매가 등록이 되어야 구매를 진행할 수 있다.
내가 보여주고 하는 것은 해당 상품에 대한 즉시 구매/판매이다. 즉시 구매를 하고 싶다면 어떤 누군가가 상품을 판매하려고 올려놓은 것을 즉시 구매하는 것이고, 즉시 판매를 하고 싶다면 또 다른 누군가가 상품을 구매하려고 올려놓은 가격에 즉시 처분을 할 수 있기 때문에
사실상 판매 입찰이 걸린 가격이 구매에 즉시 구매가로 들어가게 되는 것이다.
이렇게 각 즉시 구매/판매에 대한 정보를 확인할 수 있도록 하였다.
다음으로는 해당 상품이 체결될 때마다 최근 거래가를 업데이트해 주는 기능을 구현하였다.
해당 상품의 사이즈에 상관없이 거래가 새롭게 체결될 때마다 체결된 금액 간의 변동률과 차액을 구했어야 했다.
우선 다음과 같이 로직 처리를 정리하였다.
- 구매 / 판매의 상태가 COMPLETE 상태
- List로 가져온 뒤 최신 등록의 역순으로 정렬 -> fetchOne 함수를 통해 가장 최근 거만 가져온다.
- 서버가 실행될 때 Product 테이블에 저장해 놓은 최근 체결 감지 시간을 가져온다.
- 서버에 저장된 시간 이후로 체결된 거래(시간)가 존재할 경우
- 해당 상품 Id를 통해 이전 체결, 변동률이 null값이라면 0으로 초기화
- 이전에 체결된 체결 금액의 상품 Id, 방금 체결된 가격과 상품 Id를 추출
- 이전 체결된 값을 방금 체결된 가격의 previousPrice에 전달
- 방금 체결된 금액을 해당 latestPrice에 Update
- latestPrice - previousPrice 연산
- DB에 각 값들을 전부 저장하게 되면 끝난 시점의 시간을 해당 상품 Id가 가진 latestDate에 저장.
- 새롭게 체결된 거래가 없을 경우 이전 체결 가격과 변동률 확인
// 최근 체결가 계산
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public RecentlyPriceDto selectRecentlyPrice(String modelNum) {
Optional<Product> oldContractValue = productRepository.findFirstByModelNumOrderByLatestDateDesc(modelNum);
lastCheckedTime = oldContractValue.map(Product::getLatestDate).orElse(LocalDateTime.now());
log.info("!!! 서버가 마지막까지 유지했던 시간 : {}", lastCheckedTime);
List<SalesBiddingDto> newAllContractSelect = productRepository.recentlyTransaction(modelNum);
log.info("최근 체결 내역 조회 : {} ", newAllContractSelect.toString());
if (newAllContractSelect.isEmpty()) {
log.info("체결된 거래가 없습니다.");
return new RecentlyPriceDto();
}
SalesBiddingDto recentlyContractValue = newAllContractSelect.get(0);
LocalDateTime recentlyContractTime = recentlyContractValue.getSalesBiddingTime();
log.info("최근 체결 내역 시간 : {}", recentlyContractTime);
RecentlyPriceDto recentlyPriceDto = RecentlyPriceDto.builder()
.latestPrice(recentlyContractValue.getSalesBiddingPrice())
.salesBiddingTime(recentlyContractTime)
.salesBiddingPrice(recentlyContractValue.getSalesBiddingPrice())
.build();
if (lastCheckedTime.isBefore(recentlyContractTime)) {
for (SalesBiddingDto product : newAllContractSelect) {
if (product.getPreviousPrice() == null || product.getPreviousPercentage() == null) {
productRepository.resetPreviousPrice(product.getProductId());
log.info("기본값 설정 완료");
}
}
List<Product> products = productRepository.findByModelNum(modelNum);
for (Product product : products) {
BigDecimal recentlyContractPrice = recentlyContractValue.getSalesBiddingPrice();
BigDecimal previousContractPrice = product.getLatestPrice();
log.info("업데이트 전 productId : {}, recentlyContractPrice : {}", product.getProductId(), recentlyContractPrice);
if (previousContractPrice != null) {
productRepository.updatePreviousPrice(product.getProductId(), previousContractPrice);
log.info("Updated previousPrice for productId: {} with price: {}", product.getProductId(), previousContractPrice);
} else {
log.warn("previousContractPrice is null, skipping update for previousPrice");
}
productRepository.updateLatestPriceAndDate(product.getProductId(), recentlyContractPrice, recentlyContractTime);
log.info("Updated latestPrice for productId: {}", product.getProductId());
BigDecimal result = recentlyContractPrice.subtract(previousContractPrice != null ? previousContractPrice : BigDecimal.ZERO);
BigDecimal changePercentageBD = BigDecimal.ZERO;
if (previousContractPrice != null && previousContractPrice.compareTo(BigDecimal.ZERO) != 0) {
changePercentageBD = recentlyContractPrice.subtract(previousContractPrice)
.divide(previousContractPrice, MathContext.DECIMAL128)
.multiply(BigDecimal.valueOf(100));
}
Long resultAsLong = result.longValueExact();
double changePercentage = changePercentageBD.doubleValue();
DecimalFormat df = new DecimalFormat("#.#");
String format = df.format(changePercentage);
double finalChangePercentage = Double.parseDouble(format);
productRepository.updateRecentlyContractPercentage(product.getProductId(), finalChangePercentage);
productRepository.updateDifferenceContract(product.getProductId(), resultAsLong);
recentlyPriceDto.setDifferenceContract(resultAsLong);
recentlyPriceDto.setChangePercentage(finalChangePercentage);
recentlyPriceDto.setPreviousPrice(previousContractPrice);
}
lastCheckedTime = recentlyContractTime;
isUpdated = true;
log.info("최근 체결 내역 업데이트 완료");
} else {
log.info("현재 등록된 거래가 최신입니다.");
return RecentlyPriceDto.builder()
.latestPrice(oldContractValue.get().getLatestPrice())
.differenceContract(oldContractValue.get().getDifferenceContract())
.previousPrice(oldContractValue.get().getPreviousPrice())
.changePercentage(oldContractValue.get().getPreviousPercentage())
.salesBiddingTime(oldContractValue.get().getLatestDate())
.salesBiddingPrice(oldContractValue.get().getLatestPrice())
.build();
}
return recentlyPriceDto;
}
이러한 과정을 거쳐 최근 체결가를 조회할 수 있도록 하였다.
[상품 체결 시세]

해당 상품의 평균 시세에 대해서는 사용자가 상품을 조회하는 시점을 LocalDateTime.now()를 통해 현재 시간 기준
- 3일 - 3시간
- 1개월 - 1일
- 6개월 - 7일
- 1년 - 1개월
- 전체 - 1개월
기준으로 각 시간 별 체결금액을 확인하여 Chart.js를 사용하여 보다 사용자가 가독성 있게 시세를 확인할 수 있도록 하였다.
이렇게 상품에 대한 조회를 개발하게 되었고, 다음으로는 개발 진행 중에 발생했던 TroubleShooting에 대해서 설명하겠습니다.
[TroubleShooting]
1. 순환 참조 오류
초기에 엔티티 설정 시 위의 ERD 다이어그램을 보면 알 수 있듯이 모든 테이블이 연결되어 있기 때문에
서로 다른 빈들이 서로 참조를 맞물리게 주입되면서 순환참조 오류가 발생
순환참조문제를 해결하기 위해 구조 자체를 바꾸는 것이 좋지만 이미 DB 설계가 다 된 상태에서 수정하게 되면, 기능 개발이 지체되기 때문에 임의로 @Lazy 어노테이션을 사용해 주었다.
2. 트랜잭션 오류
각 상품 체결 시세를 구현하게 되면서 상품 조회 시 트랜잭션이 중첩되어 오류가 발생하게 되었다.
자꾸만 트랜잭션의 부모 오류가 발생하게 되었는데, 가장 큰 원인으로는 controller에서 하나의 메서드를 호출하여 해당 메서드에서 다른 메서드들을 호출하여 하나의 DTO로 변환시켜 데이터를 가져오도록 했었는데, 해당 부분에서 트랜잭션이 여러 메서드를 호출하다 보니, 오류가 발생한 것이었다. 트랜잭션은 순차적으로 하나의 작업을 마친 뒤 다음 작업을 수행해야 하는데 하나의 메서드를 처리하다가, 또 다른 메소드를 호출하다 보니 동시성 제어에 오류가 발생하게 되었던 것이다.
이러한 문제점을 고치기 위하여 각 트랜잭션을 사용할 때 단순 조회만 하는 경우에는 속성으로

@Transactional(readOnly = true)
읽기 전용으로 설정해 주었고, 트랜잭션의 동시성 제어를 지키기 위하여 REQUIRES_NEW 속성을 부여하여, 부모 트랜잭션을 무시하고 새로운 트랜잭션을 생성하여 서로에게 영향을 주지 않도록 하였다.
이러한 과정을 정리하자면 다음과정을 통해 오류 해결을 진행하였습니다.
1) 조회기능을 하는 곳에도 @Transactional을 추가하여 메서드 전체가 트랜잭션 처리가 되도록
2) 해당 트랜잭션에 읽기 전용인지 아닌지 명시
3) 트랜잭션 내부에서 서버종료시간을 저장하는 메서드를 호출해서 DB에 커밋
4) 각 단계별 로그를 통해 오류 발생 지점 확인 -> 최근 체결 메서드에서 발생
5) Progation.REQUIRES_NEW를 통해 다른 메서드에서 호출되는 트랜잭션에 영향을 받지 않는 독립적인 트랜잭션으로 실행
- Isolation(독립성) 보장, 순차적 처리, 동기 방식을 지원함
6) JPA, QueryDSL의 트랜잭션 처리를 Entity Manager가 관리해 주는데 작업을 끝내고 영속성 콘텍스트가 종료될 때 flush로 DB에 커밋이 되면 DB에 저장 후 영속성 콘텍스트가 종료되고 Entity Manager는 이후에 영속성 컨텍스트를 초기화하도록 설정
이렇게 트랜잭션이 종료될때 해당 트랜잭션의 영속성 컨텍스트가 관리하던 모든 Entity는 준영속상태로 변경되도록 수정
3. ModelMapper 인식 오류
여러 테이블에 중첩적으로 ~price라는 column 인식 오류 발생
- ModelMapper는 자동으로 price가 붙은 column을 해당 DTO로 변환시켜 주는데 같은 이름의 DTO 객체가 존재하여 오류가 발생
해결과정
- 해당 도메인에 직접적으로 setter를 사용하는 것은 안정성 보장 및 가독성 향상을 위해 builder() 패턴 사용
- ModeMapper가 아닌 해당 DTO에 builder, build() 메서드를 통해 변환
4.QueryDsl 조건 오류
소분류, 상세 상품 조회 시 해당 입찰이나 상품이 등록되어 있는 상태어 야하는데 해당 조건이 먹히지 않는 문제가 발생
가장 최저가인 상품이 해당 조건이라면 상품 정보가 나오지 않게 됨
// 판매 상품 대분류 조회
@Override
public List<ProductRespDto> findProductsByDepartment(String mainDepartment) {
BooleanExpression eqMainDepartment = product.mainDepartment.eq(mainDepartment);
BooleanExpression productCondition = product.productStatus.eq(ProductStatus.REGISTERED);
return queryFactory.select(
Projections.constructor(ProductRespDto.class,
product.productBrand,
product.productName,
product.modelNum,
product.productImg,
product.mainDepartment,
Expressions.numberTemplate(BigDecimal.class, "coalesce({0}, {1})",
sales.salesBiddingPrice.min(),
product.originalPrice).as("buyingBiddingPrice")
)
)
.from(product)
.leftJoin(sales).on(sales.product.modelNum.eq(product.modelNum))
.where(eqMainDepartment.and(productCondition)
.and(sales.salesStatus.eq(SalesStatus.PROCESS)))
.groupBy(product.modelNum)
.fetch();
}
해당 조건들은 미등록 상품에 대한 진행과정 확인을 위한 칼럼으로 쿼리문을 사용할 때 BooleanBuilder 자체는 조건들을 각각의 Predicate 객체의 리스트로 관리하여 엔티티에 설정한 본래의 자료형으로 넘어오지만 toString()을 통해 문자열로 변환해 준다.
이러한 과정을 통해 조건을 수정하여 내가 원하는 상품이 조회되도록 하였다.
'프로젝트 후기' 카테고리의 다른 글
| 경매 프로젝트 기능 설계 (0) | 2024.08.04 |
|---|---|
| [BitCamp] 2번째 프로젝트 회고록 (1) | 2024.03.28 |
| [Bitcamp] 자바 첫번째 프로젝트 회고록 (0) | 2024.03.01 |