팀 프로젝트에서 배운 것들 - 트랜잭션, 페이징, 그리고 코드 컨벤션
Spring Boot 6인 팀 프로젝트(CommercePJT, 이커머스 어드민 백엔드)에서 주문·리뷰 도메인을 맡았습니다.
기능 구현보다 "여섯 명의 코드가 하나로 합쳐지는 과정"에서 배운 게 훨씬 많았던 프로젝트라,
그 기록을 남겨봅니다~!
1. @Transactional — 데이터 정합성은 코드가 아니라 트랜잭션이 지킨다
주문 생성 로직의 순서 :
- 고객·상품·관리자 조회 및 검증
- 판매 가능 여부 확인 (단종/품절이면 거부)
- 재고 차감
- 주문 저장
여기서 3번과 4번 사이에 예외가 터지면 어떻게 될까?
재고는 줄었는데 주문은 없는 상태, 즉 데이터 불일치가 남게됩니다.
@Transactional
public CreateOrderResponse create(CreateOrderRequest request, Long adminId) {
// ... 조회 및 검증
product.decreaseStock(request.getQuantity()); // 재고 차감
Order order = Order.create(customer, product, request.getQuantity(), admin);
orderRepository.save(order); // 여기서 실패하면?
return new CreateOrderResponse(order);
}
@Transactional을 붙이면 이 메서드 전체가 하나의 트랜잭션으로 묶이는데,
중간에 예외가 발생하면 차감된 재고까지 함께 롤백됩니다.
"함께 성공하거나 함께 실패한다"를 프레임워크가 보장해주는 것.
더티 체킹(Dirty Checking)
처음엔 재고를 바꾼 뒤에 productRepository.save(product)를 꼭 호출해야 하는 줄 알았습니다.
근데 안 써도 UPDATE 쿼리가 나가더라구요
JPA는 트랜잭션 안에서 조회한 엔티티를 영속성 컨텍스트가 관리하고,
트랜잭션이 끝날 때 처음 상태와 비교해서 바뀐 부분을 자동으로 UPDATE 합니다.
주문 취소할 때 재고 복구되는 것도 똑같은 원리예요.
@Transactional
public void cancel(Long orderId, String cancelReason) {
Order order = orderRepository.findById(orderId).orElseThrow(
() -> new NotFoundException("주문을 찾을 수 없습니다."));
order.cancel(cancelReason);
order.getProduct().increaseStock(order.getQuantity()); // save() 없이도 반영됨!
}
조회만 하는 메서드에는 @Transactional(readOnly = true)를 붙였습니다.
스냅샷 비교를 생략하기 때문에 약간의 성능 이점이 있습니다.
2. 상태 전환 규칙은 엔티티 안에 두기
주문 상태는 준비중 → 배송중 → 배송완료 순서만 허용됩니다.
이 검증을 Service에 두면, 나중에 다른 경로로 상태를 바꾸는 코드가 생겼을 때 규칙이 뚫려버려요.
그래서 엔티티 메서드 안에 규칙을 넣었습니다.
public void updateStatus(OrderStatus newStatus) {
if (this.status == OrderStatus.CANCELED) {
throw new IllegalArgumentException("취소된 주문은 상태를 변경할 수 없습니다.");
}
// 현재 상태에서 허용되는 다음 상태인지 검증
...
this.status = newStatus;
}
엔티티가 자기 상태를 스스로 지키게 하니까,
어디서 호출하든 잘못된 전환이 불가능해졌습니다.
"데이터를 가진 객체가 그 데이터의 규칙도 갖는다"는 말을 코드로 체감한 부분!
3. Pageable — 프레임워크가 이미 해주는 일을 직접 하지 말자
처음엔 페이징을 이렇게 구현했었습니다.
public CustomerListResponse findAll(String keyword, CustomerStatus status,
int page, int size, String sortBy, String sortDir) {
if (page < 1) throw new IllegalArgumentException("페이지 번호는 1 이상이어야 합니다.");
if (size < 1) throw new IllegalArgumentException("페이지당 개수는 1 이상이어야 합니다.");
// 정렬 필드 검증, 정렬 방향 분기...
Pageable pageable = PageRequest.of(page - 1, size, sort);
...
}
파라미터를 낱개로 받아서 검증하고 PageRequest로 조립하는 코드가 도메인마다 중복되고 있었어요.
이걸 Pageable 직접 수신 방식으로 바꾸니까 검증·조립 코드가 통째로 사라졌습니다.
@GetMapping
public ResponseEntity<OrderListResponse> findAll(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) OrderStatus status,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
return ResponseEntity.ok(orderService.findAll(keyword, status, pageable));
}
여기서 만난 함정 : 0-기반 vs 1-기반
Spring Data JPA는 페이지를 0부터 셉니다.
기존 코드의 page - 1이 그 차이를 메꿔주고 있었는데,
Pageable로 바꾸면서 그 코드가 사라지니 요구사항(1부터 시작, 기본 10개)과 어긋나기 시작했어요.
파라미터 없이 요청하면 Spring 기본값인 size=20이 그대로 나왔습니다.
팀에서는 전역 설정으로 해결했습니다.
spring.data.web.pageable.one-indexed-parameters=true
spring.data.web.pageable.default-page-size=10
주의할 점은 이 설정이 "요청 파라미터 해석"에만 적용된다는 것.
내부 페이지 번호는 여전히 0부터라서, 응답 DTO의 page.getNumber() + 1은 그대로 두어야 합니다.
설정 넣었다고 관련 코드를 다 지우면 응답의 현재 페이지 번호가 틀어져요!
구현 방식을 바꿀 때는 기존 코드가 암묵적으로 하던 일이 뭔지 목록화하고,
새 방식에서 누가 그 역할을 대신하는지 확인해야 한다는 걸 배웠습니다.
4. DTO 변환 방식 통일 — from() vs 생성자
팀마다 스타일이 달라서 정적 팩토리 메서드(from())랑 생성자 방식이 섞여 있었습니다.
최종적으로는 엔티티를 받는 생성자로 통일했어요.
@Getter
public class OrderResponse {
private final Long orderId;
private final String orderNumber;
private final String customerName;
private final String status;
public OrderResponse(Order order) {
this.orderId = order.getOrderId();
this.orderNumber = order.getOrderNumber();
this.customerName = order.getCustomer().getName();
this.status = order.getStatus().name();
}
}
이렇게 하면 스트림에서도 깔끔해집니다.
List<OrderResponse> orders = orderPage.getContent().stream()
.map(OrderResponse::new)
.toList();
포인트는 두 가지 :
- 필드를 final로 → 생성자에서 한 번 채우면 변하지 않는 불변 DTO가 됩니다.
- 기존 생성자와 @AllArgsConstructor는 삭제 → 남겨두면 옛날 방식이 계속 쓰이거든요. 지워야 컴파일 단계에서 강제됩니다.
전환하는 중에 "7개의 인수가 필요하지만 1개가 발견되었습니다" 에러를 만났는데, Service 호출부만 바꾸고 DTO를 안 바꿔서 생긴 일이었습니다. DTO와 호출부는 항상 세트로 움직인다는 것..!
5. 검증은 어디에 두어야 할까
팀에서 정한 기준은 이렇습니다.
검증 종류 위치 예시
| 형식 검증 | DTO + @Valid | @NotBlank, @Email, @Size |
| 비즈니스 검증 | Service | 승인 대기 상태인지, 재고가 충분한지 |
그리고 팀원이 겪은 사례 중에 인상 깊었던 게 하나 있었어요.
DTO에 검증 애노테이션을 다 붙였는데도 잘못된 이메일이 그대로 저장되는 버그였습니다.
원인은 @Valid의 위치였어요.
// 잘못된 코드 — @Valid가 PathVariable에 붙음
public ResponseEntity<CustomerResponse> update(
@Valid @PathVariable Long customerId,
@RequestBody UpdateCustomerRequest request) { ... }
// 올바른 코드
public ResponseEntity<CustomerResponse> update(
@PathVariable Long customerId,
@Valid @RequestBody UpdateCustomerRequest request) { ... }
@Valid는 바로 뒤 파라미터를 검증하라는 지시입니다.
DTO에 아무리 규칙을 붙여놔도 @RequestBody 앞에 없으면 그냥 무시돼요.
에러도 로그도 없이 조용히 동작하지 않는 버그라서,
잘못된 입력을 일부러 넣어보지 않으면 발견할 수 없는 종류였습니다.
성공 케이스만 테스트하면 "실패해야 하는데 성공하는" 버그는 절대 못 잡습니다!
6. BaseEntity 정리 — @Temporal은 왜 불필요한가
공통 엔티티가 이렇게 되어 있었습니다.
@CreatedDate
@Temporal(TemporalType.TIMESTAMP)
private LocalDateTime createdAt;
@Temporal은 원래 Date, Calendar처럼 날짜만인지 시간까지 포함인지 모호한 타입에 "이건 TIMESTAMP로 저장해"라고 알려주는 애노테이션입니다.
그런데 LocalDateTime은 타입 이름 자체가 날짜+시간이라 JPA가 알아서 매핑해요. 즉 붙일 이유가 없었던 것.
대신 @Column을 붙이는 게 실익이 있었습니다.
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime modifiedAt;
}
updatable = false를 붙이면 JPA가 UPDATE 문에서 이 컬럼을 아예 제외합니다.
생성일이 수정되는 버그가 원천 차단되는 거예요. 물론 수정일(modifiedAt)에는 붙이면 안 됩니다~!
7. 리뷰 통계 — 집계 쿼리에서 빠지는 값 채우기
상품별 별점 분포를 구하는데, GROUP BY rating은 존재하는 별점만 반환합니다.
3점짜리 리뷰가 없으면 응답에서 3점 항목 자체가 사라져서, 프론트에서 분포 그래프를 그릴 수가 없어요.
그래서 5~1점을 미리 0으로 채워두고 쿼리 결과로 덮어썼습니다.
Map<Integer, Long> ratingCounts = new LinkedHashMap<>();
for (int i = 5; i >= 1; i--) {
ratingCounts.put(i, 0L); // 없는 별점도 0으로 표시
}
for (Object[] row : reviewRepository.countByRating(productId)) {
ratingCounts.put((Integer) row[0], (Long) row[1]);
}
LinkedHashMap을 쓴 이유는 5→1 순서를 보장하기 위해서입니다.
그리고 평균 평점은 AVG()가 리뷰 0개일 때 0이 아니라 null을 반환하기 때문에 래퍼 타입으로 받아서 처리해야 해요.
Double average = reviewRepository.findAverageRating(productId);
double averageRating = (average == null) ? 0.0 : Math.round(average * 10) / 10.0;
집계는 DB가, 표현 형태(정렬·반올림·빈 값 채우기)는 자바가 담당한다는 역할 분담을 배웠습니다.
8. 200 OK가 요구사항 충족을 의미하진 않는다
이것도 팀원이 겪은 사례인데 진짜 공감했던 부분이에요.
고객 리스트 조회에 ?keyword=김&status=ACTIVE를 보내도 항상 전체가 조회됐다고 합니다.
근데 응답은 200 OK, 에러 로그도 없고 페이징도 잘 동작해서 겉보기엔 완성된 기능처럼 보였어요.
원인은 두 군데가 끊겨 있어서였습니다.
- Service가 조건 없이 findAll(pageable)만 호출
- Repository가 JpaSpecificationExecutor를 상속하지 않아서 조건 객체를 받는 메서드 자체가 없음
검색 조건을 정의한 Specification 클래스는 있었지만, 그걸 실행할 통로가 없었던 것이죠.
public interface CustomerRepository
extends JpaRepository<Customer, Long>, JpaSpecificationExecutor<Customer> { }
Specification<Customer> spec = Specification
.where(CustomerSpecification.keyword(keyword))
.and(CustomerSpecification.status(status));
Page<Customer> page = customerRepository.findAll(spec, pageable);
각 조건은 파라미터가 null이면 null을 반환하도록 만들어서,
WHERE 절에서 자동으로 빠지게 했습니다.
검색어만, 상태만, 둘 다, 조건 없이 — 모든 조합이 하나의 코드로 처리돼요!
요청 파라미터는 Controller → Service → Repository까지 끝까지 흘러야 의미가 있고, 중간에 한 곳이라도 끊기면 앞 단계의 코드가 전부 무용지물이 된다는 걸 배웠습니다.
9. 그 외에 배운 것들
Entity를 그대로 응답하면 안 되는 이유
관리자 Entity에는 비밀번호가 들어 있습니다.
암호화되어 있어도 외부로 나가면 안 되는 값이죠.
Entity는 DB 매핑·영속성 관리용, 외부 응답은 DTO로 제어한다는 역할 분리가 필요합니다.
수정 기능의 중복 검사는 "자기 자신 제외"
existsByEmail만 쓰면 본인 이메일을 그대로 유지하는 것도 중복으로 판정돼버립니다.
existsByEmailAndIdNot(email, id)처럼 수정 대상을 빼고 검사해야 해요.
DB 제약에만 의존하면 사용자는 500을 받는다.
주문 이력이 있는 고객을 삭제하려고 하면 FK 제약 위반으로 500이 떨어졌습니다.
서비스 계층에서 먼저 existsByCustomerCustomerId로 확인해서 409와 함께
"주문 이력이 있는 고객은 삭제할 수 없습니다"라는 메시지를 주도록 바꾸니까,
같은 상황이 이해 가능한 응답으로 바뀌었어요.
긴 에러 로그는 맨 아래부터 읽기
data.sql과 엔티티가 어긋날 때마다 서버 기동이 실패했는데,
수십 줄짜리 스택트레이스에서 진짜 원인은 항상 최하단 Caused by에 있었습니다.
라이브러리는 추가만으로도 동작을 바꾼다.
Spring Security 의존성을 추가하자마자 회원가입·로그인은 물론 H2 콘솔까지 전부 403이 됐습니다.
세션을 직접 구현하는 구조였기 때문에, 의존성을 걷어내고 Interceptor 방식으로 정리했어요.
마무리
혼자 하는 과제였다면 "동작하니까 됐다"에서 멈췄을 것 같습니다.
여섯 명이 각자 만든 코드를 하나로 합치면서,
동작하는 코드와 함께 유지보수할 수 있는 코드는 다르다는 걸 계속 마주쳤어요.
가장 크게 남는 교훈 두 가지 :
- 컨벤션은 개발 초기에 예시 코드로 확정해야 한다.
후반에 통일하면 그만큼의 리팩토링 비용을 그대로 지불하게 됩니다. - 실패해야 하는 케이스를 테스트해야 한다.
잘 되는 것만 확인하면, 조용히 틀린 채로 200 OK를 반환하는 기능을 끝까지 못 찾아요.
다음 프로젝트에서는 JUnit 테스트 코드 작성이랑 동시성 처리(재고 차감 경합)를 꼭 해보고 싶었습니다 :)
'개인과제' 카테고리의 다른 글
| 개인과제 : AWS 배포 과제 (VPC / EC2 / RDS / Parameter Store / S3) (0) | 2026.08.10 |
|---|---|
| 개인과제 : Spring Advanced 과제 (에러 분석 / 코드 개선 / N+1 / 테스트 코드) (0) | 2026.07.27 |
| 개인과제 : CH 3 입문 Spring Boot 일정 관리 API 서버 구축 및 핵심 로직 분석 (0) | 2026.07.01 |
| 개인과제 : [git & IDEA] CH 2 계산기 과제 PART1. (0) | 2026.06.12 |
| 개인과제 : [git & github] 온보딩 주차 - 협업을 위한 git 활용 PART.2 (0) | 2026.06.12 |