본문 바로가기
Loopers

WIL - 3주차 회고

by Soono991 2025. 8. 3.

 

이번 주는 구현보다는 구현 이후의 구조적 고민에 집중했다.
조회 기능 하나, 좋아요 처리 하나에도 단순히 기능이 아닌 설계와 책임의 분리, 테스트 전략, 멱등성과 동시성 같은 본질적인 질문을 동반한다.

 

 

조회용 Service는 따로 둬야 할까?

이번 주차의 과제 중 "상품 목록"을 조회하면서 브랜드 정보, 좋아요 수, 정렬 조건이 얽히다 보니 서비스가 점점 ‘조회’스럽지 않게 변해갔다.

 

처음에는 이 데이터를 모두 엔티티의 연관관계로 해결해보려 했다.

하지만 이 방식은 불필요한 연관관계와 join이 늘어나면서 엔티티가 과도하게 오염되는 느낌이 들어 이 방식은 아니라고 생각했다.

 

그래서 찾은 방법이 최범균님이 CQRS 영상에서 언급한 CRUD를 R + CUD 의 관점으로 바라보는 것이었다.

최범균님의 영상 중 "백명석"님의 댓글 첨부

 

말 그대로 등록/수정/삭제(CUD)는 정규화된 엔티티, ORM 등을 이용하여 처리하고, 조회(Read)는 비정규화된 DTO, ViewModel 등을 이용해서 처리하는 방식인데, 이 관점에 따라 조회는 복잡한 엔티티 관계를 걷어내고 조회 전용 DTO + proejction 기반 쿼리로 분리해보기로 했다.

 

data class ProductListViewModel @QueryProjection constructor(
    val productId: Long,
    val productName: String,
    val productPrice: Long,
    val productStatus: ProductStatusType,
    val brandName: String,
    val productLikeCount: Int,
)

 

위 처럼 "상품 목록" 조회에 대한 DTO를 작성하고 projection을 사용하게 되면 엔티티의 복잡한 연관관계를 설정하지 않고도 "상품 목록" 조회 요구사항을 충족하는 데이터를 조회할 수 있게 된다.

 

val product = QProductEntity.productEntity
val brand = QBrandEntity.brandEntity
val likeCount = QProductLikeCountEntity.productLikeCountEntity

// 조건 where 절 구성
// ...

// 정렬 조건 구성
// ...

// 목록 조회
val productListViewModels = queryFactory
    .select(
        QProductListViewModel(
            product.id,
            product.name,
            product.price.value,
            product.status,
            brand.name,
            likeCount.productLikeCount,
            product.createdAt,
        ),
    )
    .from(product)
    .join(brand).on(product.brandId.eq(brand.id))
    .leftJoin(likeCount).on(product.id.eq(likeCount.productId))
    .where(predicate)
    .orderBy(*orders)
    .offset(pageable.offset)
    .limit(pageable.pageSize.toLong())
    .fetch()

 

다만 매번 요구사항에 맞는 조회용 DTO를 생성할 것인가?에 대해서는 고민해볼 필요가 있다고 생각한다.

이번 "상품 목록" 조회에서는 브랜드 정보, 상품 좋아요 수가 포함되어 있는데, 이 DTO는 재사용이 가능할까? 아니다.

특정 요구사항에 맞춘 ViewModel이기 때문에 재사용이 어렵다. 그렇기 때문에 요구사항이 추가될 때마다 그에 맞는 DTO를 생성해야 할 수도 있다.

 

예를 들면, 다른 곳에서는 "상품 목록"에 대해 “리스트에 상품 후기 개수도 보여주세요.”, “리스트에 해당 상품의 재고 수량도 함께 보고 싶어요.”, "리스트를 ‘최신 리뷰 순’으로 정렬하고 싶어요.” 등의 요구사항이 추가된다면 매번 새로운 DTO를 만들어야 한다.

 

이럴 경우에는 다음과 같이 생각해보는 것이 좋다.

* 목적에 따라 DTO를 구분하되, 네이밍과 책임이 명확해야 한다.
   - ex) ProductListBasicDto, ProductListWithLikesDto, ProductListWithReviewCountDto 등 구성
• Query 객체를 작은 단위로 나눠 재조합할 수 있도록 구성한다.

 

 

좋아요 기능은 정말 멱등하게 만들었을까?

좋아요 기능은 등록/취소를 각각 API로 나누어 멱등성을 보장하려고 했다.

@Transactional
fun like(command: ProductLikeCommand.Like) {
    if (productLikeRepository.existsByUserIdAndProductId(command.userId, command.productId)) return

    productLikeRepository.create(command.toEntity()).let { created ->
        productLikeCountRepository.findByProductId(created.productId)?.apply {
            increaseProductLikeCount()
        } ?: productLikeCountRepository.save(
            ProductLikeCountEntity(created.productId, 1),
        )
    }
}

@Transactional
fun unlike(command: ProductLikeCommand.Unlike) {
    if (!productLikeRepository.existsByUserIdAndProductId(command.userId, command.productId)) return

    productLikeRepository.deleteByUserIdAndProductId(command.userId, command.productId).also {
        productLikeCountRepository.findByProductId(command.productId)?.decreaseProductLikeCount()
    }
}

 

좋아요 등록의 경우 먼저 좋아요 정보가 있는지 확인한 후 있으면 그대로 반환, 없을 경우에는 등록 처리를 한다.

좋아요 취소의 경우도 먼저 좋아요 정보가 있는지 확인한 후 없으면 그대로 반환, 있을 경우에는 취소 처리를 한다.

 

이렇게 했을 때는 특정 사용자가 하나의 상품에 대해 여러 번 좋아요 등록 요청을 보내도 최종적으로는 1번만 등록 처리가 될 것이고,

반대로 하나의 상품에 대해 여러 번 좋아요 취소 요청을 보내도 최종적으로는 1번만 취소 처리가 될 것이다.

 

등록 요청이 여러 번 들어와도 DB에 한 번만 저장된다.

취소 요청도 마찬가지로 한 번만 삭제된다.

API 관점에서는 멱등성이 잘 보장되어 있다.

 

하지만 동시성은 또 다른 문제였다.

 

멱등성은 단일 요청 기준의 안전성
동시성은 다중 요청 기준의 정합성

 

 

특정 사용자가 하나의 상품에 대해 "동시에" 여러 번 좋아요 등록 요청을 보낸다면?

특정 사용자가 하나의 상품에 대해 "동시에" 여러 번 좋아요 취소 요청을 보낸다면?

물론 이번 주차는 "멱등성"까지만 고려 사항이었기 때문에 지금은 불필요한 고민일 수 있겠지만 나중을 위해 "동시성"에 대해서도 한 번 고민해보았다.

 

예: 동시에 여러 번 좋아요 등록 요청
	- 유니크 키(userId, productId) 제약으로 한 번만 성공 → OK

예: 동시에 여러 번 좋아요 취소 요청
	- 삭제할 데이터가 이미 없어도 delete 쿼리는 성공
	- deleted = 0이어도, 현재 로직은 좋아요 수 감소 실행됨

 

@Transactional
fun unlike(command: ProductLikeCommand.Unlike) {
    if (!productLikeRepository.existsByUserIdAndProductId(command.userId, command.productId)) return

	// 데이터가 없어도 삭제 쿼리는 성공
    productLikeRepository.deleteByUserIdAndProductId(command.userId, command.productId).also {
    	// 데이터가 삭제되지 않았음에도 해당 로직이 실행되어 좋아요 수를 감소(-1)하게 됨.
        productLikeCountRepository.findByProductId(command.productId)?.decreaseProductLikeCount()
    }
}

 

 

 

동시성은 다음 주차에 해결해볼 예정이지만, 지금 간단하게 생각해보면 좋아요 취소(delete의 결과가 1건, 즉 실제 데이터가 삭제되었을 경우에만 좋아요 수를 감소하는 방법으로 해결해볼 수 있을 것 같다.

이 부분은 다음 주차에 해결해보고 따로 후기를 남기도록 하겠다.

 

 

'Loopers' 카테고리의 다른 글

WIL - 6주차 회고  (1) 2025.08.24
WIL - 5주차 회고  (2) 2025.08.17
WIL - 4주차 회고  (5) 2025.08.10
WIL - 2주차 회고  (2) 2025.07.25
WIL - 1주차 회고  (1) 2025.07.18

댓글