UMC 8기 프로젝트 회고

tls·2025년 10월 5일

Log

목록 보기
1/3
post-thumbnail

2025.08.26

개요

저번 주 금요일 부로 동아리 UMC 8기를 진행하며 했던 프로젝트가 끝났다! 열정적이고 책임감 있는 팀원들과 함께할 수 있어 매우 행운이었던 프로젝트라고 생각한다 ᵔ ̮ ᵔ !! 성공적으로 프로젝트를 마무리했으니! 회고를 통해 프로젝트를 진행하며 배운 점들과 아쉬웠던 점을 정리해보고자 한다.

7602

우선 내가 진행했던 프로젝트는 자영업자모으자라는 프로젝트인데, 자영업자를 위해 공동 구매가 가능하도록 하는 이커머스 서비스이다. PM분이 학교 주위 식당을 대상으로 설문조사를 진행했을 때 친환경 일회용품의 가격이 비싸 공동구매할 수 있는 플랫폼이 있다면 좋겠다는 의견에서 착안해 시작하게 된 프로젝트다. 해당 프로젝트의 주요 기능은 상품을 개별 구매 혹은 공동 구매할 수 있다는 점과 자영업자의 업종에 맞추어 업종별 맞춤 카테고리도 제공한다는 점이다.

프로젝트는 6월 23일~8월 22일동안 진행했다. 정확히 개발에 착수한 건 6월 30일쯤으로, 8주일 간 진행한 프로젝트라고 할 수 있겠다. 팀은 기획 한 명(PM), 디자인 한 명, 웹 프론트엔드 네 명과 백엔드 다섯 명이서 진행했다. 전체적인 아키텍처는 다음과 같다.

7601

백엔드에서는 Java, Spring Boot, MySQL 그리고 Swagger, Redis, Toss Payments, SSE를 사용했다.

백엔드 팀장으로서...

내가 백엔드 팀장을 맡아 진행했기에 팀장으로서 어떻게 진행했는지도 짚어보고자 한다. 내가 리더형은 아니라고 생각해 팀장을 지원 받을 때 따로 나서지 않았으나 기획 분에게서 아래처럼 카톡이 왔다.

7604

고민 끝에 열심히 해보고자 백엔드 팀장을 맡았다.

팀장을 맡아서 프로젝트를 진행할 때 늘 고민하게 되는 부분은 역할 분배와 ERD 설계인 것 같다. 컨벤션이나 깃허브, 커밋 전략은 정하기 어렵지 않은데... 역할 분배와 ERD 설계는 어떻게 나눌지가 애매한 것 같아서 늘 고민이 된다. 이번에는 다행히 기획 분이 작성해두신 기능명세서를 확인했을 때 화면 종류가 크게 5가지 정도로 분류가 되어있길래 그걸 기준으로 나누기로 했다. 맡고 싶은 부분이 있으면 따로 말하고 겹치면 뽑기, 의견이 없거나 남은 작업에 대해서는 사다리 타기로 진행했다.

데이터베이스 스키마는 각자 맡은 부분에 필요한 데이터를 API 명세서와 함께 작성해오기로 했다.
그리고 ERD 설계는 ERD Cloud를 사용했다. 근데... ERD Cloud로는 보기가 좀 불편하다고 생각해서 우선 노션에 테이블을 여러 개 만들어서 1차로 작성하고 백엔드 회의 때 다 같이 한 번 읽어보며 수정사항을 논의했다. 이후 회의가 끝나고 ERD Cloud로 따로 옮겼다. (지금 생각해보면 Datagrip에서 ER 다이어그램을 뽑아도 됐을 일이다...)

76067607

이 방법이 효율적인지는 잘 모르겠어서 고민이다...

이후 자바 버전을 설정해서 기초 세팅을 하고 브랜치 전략, PR/이슈 템플릿, 커밋 메시지, 협업 규칙, 패키지 구조, 커밋 컨벤션 등을 간단히 논의했다. 자세한 내용은 백엔드 팀 규칙에서 볼 수 있다.

이 중에서 기억에 남는 거라면 DTO를 작성할 때 record를 사용하기로 정한 것이다. record는 DTO를 작성하는 데 사용하기 적합한 Java14부터 생긴 새로운 종류의 클래스이다. 생성자나 getter 등을 따로 작성하지 않아도 자동으로 생성해준다고 하여 사용하기로 결정했다. 사용해보며 느낀 점은 확실히 class로 작성할 때보다 보일러 플레이트가 빠져서 코드가 간결해지고 final 필드기에 불변이라 안전하다는 것이다.

백엔드로서...

장바구니, 결제, 쿠폰, 배송지 관련 기능을 개발했고 기능별로 개발하며 배운 점을 간단하게 정리했다.

장바구니 기능 개발

  1. N+1 문제 해결
public Cart findByMemberId(Long memberId) {
    QCart cart = QCart.cart;
    QCartProduct cartProduct = QCartProduct.cartProduct;
    QProduct product = QProduct.product;
    QProductOption productOption = QProductOption.productOption;

    return queryFactory
        .selectFrom(cart)
        .leftJoin(cart.cartProducts, cartProduct).fetchJoin()
        .leftJoin(cartProduct.product, product).fetchJoin()
        .leftJoin(cartProduct.productOption, productOption).fetchJoin()
        .where(cart.member.id.eq(memberId))
        .fetchOne();
}

장바구니와 장바구니 내 상품 목록을 가져올 때 FETCH JOIN을 사용하여 연관된 데이터를 함께 가져오도록 작성해 N+1 문제를 해결했다. 제품 없이 비어있는 카트더라도 결과를 반환할 수 있도록 LEFT JOIN을 사용했다.

  1. Propagation, 영속성 컨텍스트에 대한 이해

이 문제가 생긴 이유는... 회원에게 장바구니가 없을 때를 고려해서이다.
Serivce에 CQRS 패턴을 적용했기에 Service 상단에 조회만 진행하는 Service에는 @Transactional(readOnly=true) 어노테이션을 붙여둔 상황이었다. 근데 위 상황을 고려하게 되면 장바구니를 조회하는 로직에서도 데이터를 생성할 일이 생긴 것이다! 그리고 만약 유저가 (장바구니를 조회하지 않고) 구매가 급해서 상품부터 구경하고 장바구니에 담는다면? 장바구니에 상품을 담는 API를 실행할 때도 장바구니가 없다면 생성되어야 하는 상황이었다.

commandService가 queryService를 참고하게 한다면 순환 참조 위험이 있다고 생각했다. 그래서 cartComponent로 따로 빼어 작성하고 두 service에서 사용하도록 했다.

하지만 이렇게 해도 장바구니 조회 시 could not execute statement [Connection is read-only. Queries leading to data modification are not allowed] [/* insert for com.jajaja.domain.cart.entity.Cart */insert into cart (coupon_id,created_at,member_id,updated_at) values (?,?,?,?)] at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:348) 이런 에러가 발생했다. 볼드체로 처리한 부분만 읽어봐도 알겠지만, read-only 커넥션임에도 불구하고 데이터를 수정(추가)하려 해서 발생한 에러이다.

그래서 @Transaction(propagation = Propagation.REQUIRES_NEW)을 통해서 트랜잭션과 영속성 컨텍스트를 분리했다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public Cart findCart(Long memberId) {
	return cartRepository.findByMemberId(memberId)
		.orElseGet(() -> createCart(memberId));
}

/**
 * 신규 사용자를 위해 장바구니를 생성합니다.
 * @param memberId 사용자 ID
 * @return Cart
 */
private Cart createCart(Long memberId) {
	log.info("[CartComponent] 사용자 {}의 장바구니가 없어 새로 생성합니다.", memberId);
	User user = userRepository.findById(memberId)
		.orElseThrow(() -> new CartHandler(ErrorStatus.USER_NOT_FOUND));
	Cart newCart = Cart.builder().member(user).build();
	return cartRepository.save(newCart);
}

이렇게 적용하면 잘 될 줄 알았는데... 장바구니 조회 시에는 잘 적용이 되지만 장바구니에 상품을 추가할 때는 장바구니만 생성되고 장바구니 내 상품은 데이터베이스에 저장이 되지 않았다. 왜?

기존 장바구니에 상품 추가 로직 (가독성을 위해 상품 수정 로직은 삭제하였습니다.)

public void addOrUpdateCartProduct(Long memberId, CartProductAddRequestDto request) {
	Cart cart = cartComponent.findCart(memberId);
	Product product = productRepository.findById(request.productId())
		.orElseThrow(() -> new CartHandler(ErrorStatus.PRODUCT_NOT_FOUND));

	CartProduct newCartProduct
		= CartProduct.create(cart, product, option, request.quantity());
	
	cart.addCartProduct(newCartProduct);
}

cart.addCartProduct(newCartProduct);를 하면 알아서 추가되어야 한다고 생각했었는데...

보통은 save()와 같은 명시적 메서드 없이도 업데이트가 잘 된다. JPA가 영속 상태의 엔티티의 변경을 자동으로 추적하기 때문이다. 하지만 cartProduct는 새로운 객체이기 때문에 영속성 컨텍스트의 관리 대상이 아니어서 명시적인 저장을 진행해줘야 한다.
그 뿐만 아니라, 장바구니는 Propagation.REQUIRES_NEW를 통해 새로운 트랜잭션에서 관리되던 대상이다. findCart() 메서드가 완료되면 즉시 커밋된다. 트랜잭션이 커밋됨과 동시에 종료되고 영속성 컨텍스트도 사라지면서 장바구니(Cart)도 준영속 상태로 반환된다.
즉, 장바구니와 장바구니 내 상품(Cart와 CartProduct) 모두 영속성 컨텍스트에서 관리하지 않아 변경사항을 자동으로 감지하지 않는 객체에 해당해 명시적 저장이 필요해진 것이다.

마지막 단에 save() 메서드를 추가해 명시적으로 저장해주고 영속성 컨텍스트에 추가해주면 이 모든 게 해결된다.

public void addOrUpdateCartProduct(Long memberId, CartProductAddRequestDto request) {
	Cart cart = cartComponent.findCart(memberId);
	Product product = productRepository.findById(request.productId())
		.orElseThrow(() -> new CartHandler(ErrorStatus.PRODUCT_NOT_FOUND));

	CartProduct newCartProduct
		= CartProduct.create(cart, product, option, request.quantity());
	
	cart.addCartProduct(newCartProduct);
	cartProductRepository.save(newCartProduct);
}

Propagation.REQUIRES_NEW 속성을 적용하면 우려되는 문제가 두 가지 정도 있었다. 부모 트랜잭션에서 에러가 발생해서 롤백될 때나 락 경합 문제이다. 하지만 두 문제 다 장바구니 로직과는 관련 없는 문제다.

첫 번째 문제의 경우, findCart()가 실행되기 전 커밋되지 않은 변경사항이 존재해야 되는데, 장바구니 조회/장바구니 내 상품 추가에는 그런 로직이 존재하지 않는다. 두 번째 문제는 보통 여러 트랜잭션이 동일한 데이터에 접근하려 할 때 발생한다. 하지만 장바구니 데이터는 유저당 하나씩만 있는 데이터로, 특정 회원에게 종속된 고유한 데이터이다. 그러므로 여러 트랜잭션이 동시에 접근할 일이 없어 락 경합 문제가 발생하지 않으리라 생각했다.

그리고 ...
이렇게 구현한 뒤 곰곰히 생각해보니 회원이 회원가입을 진행할 때 장바구니를 생성하면 해결되는 문제였다. 그래서 위 코드를 전부 제거하고 회원가입 시 장바구니도 생성하도록 로직을 변경했다.

7609

하지만 덕분에!! Propagation 속성과 영속성 컨텍스트에 대해서 깊이 이해할 수 있었다.

결제 기능 개발

Toss Payments API를 사용하여 결제 기능을 개발했다. 블로그 글을 따로 작성해둔 게 있다. 이 글을 참고하면 된다!

쿠폰, 배송지 등 개발

쿠폰 개발이 별 거 아니라고 생각했는데 정말 고려해야 할 게 많았다... 특히 쿠폰을 적용할 수 있는 조건에서 고려해야 될 게 많았다. 특정 브랜드에만 적용되는 쿠폰, 특정 상품 종류에만 적용되는 쿠폰, 최소 주문 금액 이상일 경우에만 적용되는 쿠폰, 그리고 정률/정액 할인까지 고려해야 했다.

그래서...
discountType으로 정률/정액 할인을 구분하고 discountValue로 얼마나 할인되는지를, conditionType에 따라 전체/특정 브랜드/특정 상품 종류에 해당함을 명시하고 conditionValues에 어떤 브랜드나 어떤 상품 종류에 할인이 적용되는지를 기입하는 식으로 데이터베이스를 설계했었다.

한 메서드면 되겠지? 라고 생각했다.
그리고 이런 재앙 같은 코드가 탄생했다.

public PriceInfoDto calculateDiscount(Cart cart, Coupon coupon) {
    if (coupon.getExpiresAt() != null && coupon.getExpiresAt().isBefore(LocalDateTime.now())) {
        throw new CouponHandler(ErrorStatus.COUPON_EXPIRED);
    }

    if (coupon.getMinOrderAmount() != null &&
           cart.calculateTotalAmount() <= coupon.getMinOrderAmount()) {
        throw new CouponHandler(ErrorStatus.COUPON_MIN_ORDER_AMOUNT_NOT_MET);
    }

    ConditionType conditionType = coupon.getConditionType();
    String conditionValues = coupon.getConditionValues();
    switch (conditionType) {
        case ALL:
            break;
        case BRAND:
            // ... 브랜드 조건 검증 로직 중략
            break;
        case CATEGORY:
            // ... 카테고리 조건 검증 로직 중략
            break;
        default:
            throw new CouponHandler(ErrorStatus.INVALID_COUPON_TYPE);
    }

    int targetAmount;
    switch (coupon.getConditionType()) {
        case ALL:
            targetAmount = cart.calculateTotalAmount();
            break;
        case BRAND:
            // ... 브랜드 대상 금액 계산 로직 중략
            break;
        case CATEGORY:
            // ... 카테고리 대상 금액 계산 로직 중략
            break;
        default:
            targetAmount = 0;
            break;
    }

    DiscountType discountType = coupon.getDiscountType();
    Integer discountValue = coupon.getDiscountValue();
    int discountAmount;
    switch (discountType) {
        case PERCENTAGE:
            discountAmount = Math.round(targetAmount * discountValue / 100.0f);
            break;
        case FIXED_AMOUNT:
            discountAmount = discountValue;
            break;
        default:
            log.warn("[CouponCommonService] 알 수 없는 할인 타입: {}", discountType);
            return PriceInfoDto.noDiscount(cart.calculateTotalAmount());
    }

    return PriceInfoDto.withDiscount(cart.calculateTotalAmount(), discountAmount);
}

중략을 통해 생략했지만 코드 중복도 너무너무 많고 긴 코드였다...
그래서 메서드 분리를 통해 중복된 코드를 제거하고 깔끔하게 수정했다. 그래도 코드가 정말 길지만... 붙여넣어두면 글이 너무 길어져서.. 이 링크에서 읽을 수 있다.

그리고 이렇게 작성하며 가능한 모든 상황은 다 고려했다고 생각했는데, 특정 월(8월, 9월 등)에만 사용 가능한 쿠폰이나 첫 구매 시 사용 가능한 쿠폰 등등등... 다양한 상황이 많았다.

코드의 양이 방대하고 길어서... 코드 리뷰어가 이해하기 쉽게 주석, 커밋 메시지와 PR 내용까지 고민하는 과정도 거칠 수 있어 협업까지 고려해서 개발해볼 수 있었다.

아쉬운 점

최적화

개인적으로 최적화를 진행해보고 또 결과를 남겨보고 싶었는데 프로젝트 기간 중에 진행하지 못 한 게 많이 아쉽다.. 열심히 하고자 노력한 프로젝트라 더 그런 것 같다.
그래도 프로젝트는 끝났어도 따로 리팩토링을 진행해도 되니까! 쿼리문 수정, 캐싱 등 다양한 방법으로 리팩토링하고 또 기록해보고자 한다.

의문?

결제 기능 자체는 잘 구현해낼 수 있었지만... Toss Payments API를 사용하면 결제 승인까지 20분 정도의 결제 유예 기간이 있다. 이 유예 기간이 지나면 자동으로 결제가 진행되지 않은 Order 데이터들은 전부 결제 실패 처리 해야 된다고 생각해서 일단은 Scheduler를 사용해 매분마다.................. 확인해서 Order 데이터를 전부 실패 처리하게끔 했다.

만약 사용자가 많아 Order 데이터가 많이 쌓이면 데이터 처리에 오래 걸리고 데이터베이스 락 문제도 발생할 수 있을 것 같은데, 어떻게 처리하면 좋을지 고민이다...
아니면 어차피 20분 정도의 유예 기간 후 결제 승인 API를 전송하면 Toss에서 자동으로 결제 실패 메시지를 보내주므로 굳이 서버 단에서 데이터 실패 처리를 매분마다 진행할 필요가 없을까? 그냥 새벽에 데이터를 한꺼번에 배치 처리하는 게 맞을까? 그리고 모든 결제 실패 데이터를.... 저장할까? 사용자 데이터라고 생각하면 필수긴 하지만...... 만약 10분 내에 동일한 상품들을 결제 실패를 세 번 정도 한 뒤 결제를 성공하면 마지막 데이터만 있어도 되지 않을까? 진짜로 서비스하는 이커머스 팀에서는 어떻게 처리할지 궁금하다!!

마치며...

이번 프로젝트에서는 왜???를 좀 더 따져가며 개발했다. 덕분에 이론으로만 이해해왔던 것들(특히 JPA 영속..) 뼛속에 새기며 이해할 수 있었다. 그리고 공부할 게 더 많아졌다... ㅎㅎ 열심히 해서 다음 프로젝트엔 더 발전하고 싶다! 화이팅 ⌒𖧉⌒

0개의 댓글