상품을 주문하는 행위는 E-Commerce 도메인에서 가장 중요한 기능 중 하나이다. 먼저 재고 관리 측면에서 예를 들면, 남은 재고는 10개였으나 12개의 주문이 발생할 수 있다. 이로 인해 사용자는 결제까지 모두 완료한 이후에 결제가 취소되는 상황을 겪거나 브랜드 측에서 추가 발주를 진행해야하는 상황이 발생할 수 있다. 전자는 사용자에게 서비스가 불쾌한 경험으로 남을 수 있고, 후자는 브랜드 측에서 서비스에 불신을 가질 수 있으며 추가 발주라는 예상치 못한 상황으로 인해 리소스가 발생할 수 있다
블랙프라이데이나 한정판 의류 같은 경우 매우 높은 트래픽이 발생 가능한 상황을 고려해야 하며, 동시성 문제로 인한 데이터 정합성 문제가 발생하면 안된다. 즉, 이를 해결하지 못한다면 서비스의 가치를 떨어뜨리고, 비즈니스적으로 문제를 발생시키며 이는 결국 매출감소로 이어질 수 있다. 반드시 해결해야 한다. 하지만 성능과 데이터 정합성은 trade-off 관계로 모두를 만족시키기는 힘들다. 따라서 여러가지 방법들에 대한 테스트를 진행해보면서 개선 과정을 거쳐가려고 한다.
Part 1. 테스트 환경과 개선 전 부하테스트 결과
👊🏻 목표 성능
Concurrent Users(동시 사용자 수) = 500명
Average Response Time(평균 응답 시간) = 200ms 이하
TPS(Think Time 100ms 기준) = 1666 TPS
💻 테스트 환경
WAS(SpringBoot) 1대 : AWS EC2 t4g.micro(2 vCPU, 1GiB Memory)
DB(MySQL) 1대 : AWS EC2 t4g.micro(2 vCPU, 1GiB Memory)
부하테스트 툴 : nGrinder
APM 툴 : Pinpoint
Metric 수집 및 시각화 툴 : Prometheus + Grafana
🕹️ 테스트 방식
vUsers : 변동
Duration : 1m
Sleep Time : 100ms
번외로, 사이드 프로젝트에서 자주 사용하는
t4g.micro는 어느정도까지 트래픽을 견딜 수 있는지 궁금했다
☠️ 부하테스트 결과
Sleep Time은 100ms로 고정시킨채 vUsers 수만 바꿔가면서 부하테스트를 진행하였다
정리하자면, vUsers가 200명, 500명 일 때는 서버가 곧바로 터지면서 측정하지 못했다. 이는 t4g.micro 성능이 낮아서 그런 것이라 판단했고, 로그를 분석한 결과 아래와 같은 에러를 확인할 수 있었다
ServletOutputStream failed to flush: java.io.IOException: Connection reset by peer
에러 분석 결과, 클라이언트가 연결을 먼저 끊고 이후에 서버가 응답을 flush 할 때 응답이 더 이상 사용 불가하다고 판단하고 ClientAbortException 터트려 발생한 에러이다. 이러한 현상의 원인을 생각해보면 서버의 성능 한계로 인한 응답 지연이 발생했고, 그로 인에 클라이언트(nGrinder)측에서 Time-out이 발생하여 연결을 먼저 끊은 것으로 보인다. 실제로 Grafana를 모니터링 한 결과, Tomcat 쓰레드 수는 max인 200에 도달했었고 CPU 사용률은 93%까지 치솟았다. 또한 주문 생성 API의 p99 latency가 30s로 측정된 점을 볼 때, 클라이언트 측에서 time-out이 발생할 수 밖에 없는 상황이었음을 알 수 있었다.
동시성 제어는 Good! 성능은 Bad..
나머지 성공한 테스트들을 종합해보면, 가장 중요한 동시성 제어 측면에서는 완벽했다. 초기 설계 단계에서 해당 문제에 중점을 두고 맞췄었고 이를 위해 비관적 락(for update)를 사용하여 동시성 제어 측면에서 우수한 결과를 만들어 낼 수 있었다. 그러나 성능 측면에서는 처참했다. 기존 성능 목표에 의하면 Think Time을 100ms로 설정했을 때 약 1666 TPS가 달성되어야 했다. 하지만 실제 테스트 결과 동시 사용자 수가 50명일 때 최고 145 TPS에 그쳤으며, 이는 목표치에 크게 못 미치는 수준이었다. 목표 성능에 도달하기 위해 단순히 Scale-out을 적용하는 방법도 있었지만, 이는 추가적인 비용이 발생한다. 따라서 먼저 다양한 성능 개선 방안을 시도해보고, 더 이상 해결할 수 없는 한계에 도달했을 때 못하는 순간이 올 때 Scale-out을 고려하기로 결정했다.
참고로 TPS 계산은 Little’s Law를 사용하였다.
Part 2. 1차 개선 (영속성 컨텍스트)
먼저, 상품을 주문하는 과정은 다음과 같은 플로우로 이루어진다
1. 재고 검증(Products)
2. 재고 차감(Products)
3. 주문 접수(Orders)
4. 주문 상품 등록(Order_items)
TPS 개선을 위해서 실제로 어떤 쿼리가 발생하고 있는지 확인해 보았다
- select ... from users where id=1
→ 주문 요청을 한 사용자 조회 - select ... from products where id=1 for update
→ 주문 대상 상품 조회 + 동시성 제어를 위해 행 잠금(for update) - select ... from orders left join order_items ... where o1_0.id=?
→ 특정 주문 + 주문 아이템 조회 - select ... from order_items where id=?
→ 특정 주문 아이템 단건 조회 - insert into orders (...) values (...)
→ 신규 주문 생성 - insert into order_items (...) values (...)
→ 주문 상세(아이템) 저장 - update products set ... where id=1
→ 상품 재고 차감 및 상품 갱신 - update orders set ... where id=?
→ 주문 정보 갱신 (modified_at 등)
위 쿼리 중에서 3,4,8번 쿼리는 의도하지 않은 쿼리였고, 원인은 아래와 같았다.
1. ID 직접 주입으로 인한 문제
'바로'에서는 ID 생성을 TSID를 통해 처리하고 있다. 자세한 이유는 아래 포스팅에서 확인할 수 있다.
https://chobo-backend.tistory.com/52
[바로] 분산 시스템에서 ID가 유일하려면?(Ft. Snowflake VS TSID 성능테스트)
패션 플랫폼 ‘바로’ 개발을 시작하면서 도메인을 구현하던 중 ID 생성 방식에 대해 고민하게 되었다. 기존에는 Auto Increment로 만들고 있었지만 이는 단일 서버의 환경에서만 안정적이지 않을까
chobo-backend.tistory.com
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (this.entityInformation.isNew(entity)) {
this.entityManager.persist(entity);
return entity;
} else {
return (S)this.entityManager.merge(entity);
}
}
JPA의 save과정에서는 해당 엔티티가 새로운 엔티티인지 판단하는 단계가 있다. Hibernate는 엔티티의 식별자(ID) 값의 존재 여부를 기준으로 엔티티 상태를 판단하는데, ID가 null이면 새로운 엔티티로, null이 아니면 기존 엔티티로 간주한다. 따라서 현재 ID를 직접 주입하는 방식을 사용하고 있어 entityManager.merge(order)가 실행되었고, 이로 인해 불필요한 select 쿼리가 추가로 발생했다.
2. 해결 방안 검토
이 문제를 해결하기 위해 ID를 @PrePersist로 주입하는 방식을 먼저 고려했다. 하지만 이 방법은 ID 값을 나중에 주입해야 하므로 val을 var로 변경해야 했다. ID는 절대 불변해야 하는 값이므로 안전성이 떨어진다고 판단하여 채택하지 않았다. 그래서 다른 방법으로 Persistable 인터페이스를 직접 구현하여 isNew()의 로직을 변경하는 방식이 가장 안전하다고 판단했다. 기존에 id를 통해서 판단하는 것이 아니라 createdAt을 기준으로 isNew()를 판단하도록 변경하면, ID를 var로 바꾸지 않아도 되어 최선의 방법이라 생각한다.
3. 최종 구현 방식
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseTimeEntity(
@CreatedDate
@Column(name = "created_at", columnDefinition = "TIMESTAMP", nullable = false, updatable = false)
var createdAt: Instant? = null,
@LastModifiedDate
@Column(name = "modified_at", columnDefinition = "TIMESTAMP")
var modifiedAt: Instant? = null,
) : Persistable<Long> {
override fun isNew(): Boolean = (this.createdAt == null)
}
최종적으로 다음과 같이 쿼리 횟수가 감소하였다
- select ... from users where id=1
→ 주문하려는 사용자 정보 조회 - select ... from products where id=1 for update
→ 주문 대상 상품 조회 + 재고/동시성 제어 위해 행 잠금(for update) - insert into orders ...
→ 주문 엔티티 저장 (주문 생성) - insert into order_items ...
→ 주문 상세(상품별 내역) 저장 - update products set ... where id=1
→ 상품 정보 수정 (재고 차감, 수정 시각 갱신 등)
4. 1차 개선 후 부하테스트 결과
서버 CPU 사용률이 80%에 도달하는 지점인 동시 사용자 100명을 기준으로 부하 테스트를 진행했다
개선 전
TPS : 121
p99 latency : 2.72s
Average response time : 1.21s
개선 후
TPS : 168
p99 latency : 1.66s
Average response time : 0.95s
5. 개선 전 대비 성능 비교 (Before vs After)
Metric | Before (개선 전) | After (개선 후) | 개선 효과 |
TPS (Transactions/sec) | 121 | 168 | 38.8% ↑ |
p99 latency | 2.72 s | 1.66 s | 38.9% ↓ |
Average response time | 1.21 s | 0.95 s | 21.5% ↓ |
- TPS : 초당 처리 가능한 트랜잭션 수가 121 → 168로 약 39% 증가
- p99 latency : 응답 지연 상위 1% 구간이 2.72s → 1.66s로 약 39% 단축
- Average response time : 1.21s → 0.95s로 약 22% 단축
6. 1차 개선 결과 정리
1차 개선을 통해 상당한 성능 향상을 달성할 수 있었다. TPS가 39% 증가하고 p99 latency가 39% 단축되면서 시스템의 처리 능력과 응답성이 크게 개선되었다. 특히 평균 응답 시간도 22% 단축되어 전체 사용자가 체감할 수 있는 성능 향상을 이뤄냈다.
개선의 핵심 사항으로는 JPA save 메커니즘에서 발생하는 불필요한 쿼리 제거에 있었다. ID를 직접 주입하는 방식으로 인해 Hibernate가 엔티티를 기존 엔티티로 판단하여 merge 과정에서 추가적인 select 쿼리가 발생했던 문제를 해결했다. Persistable 인터페이스 구현을 통해 createdAt 기준으로 isNew() 로직을 변경함으로써 8개의 쿼리를 5개로 줄일 수 있었다.
단순히 쿼리 개수 감소만으로도 이정도의 성능 향상을 얻을 수 있다는 점이 인상적이었다. 특히 높은 동시성 환경에서는 불필요한 DB 접근이 전체 시스템 성능에 미치는 영향이 생각보다 크다는 것을 확인할 수 있었다. 물론 Lock을 사용하는 경우라 더 큰 영향을 주었을 것이지만, ORM 사용 시 내부 동작 메커니즘에 대한 정확한 이해가 성능 최적화에 얼마나 중요한지 알게된 테스트였다. 하지만 여전히 목표 성능에는 미치지 못하였기에 2차 개선을 진행하게 되었다.
Part 3. 2차 개선 (원자적 재고 감소 처리)
1. 문제 상황
결국 병목은 DB
여기서 성능을 더 높일 수 있는 방법을 고민했을 때, 결국 현재 가장 큰 병목은 DB에서 발생한다고 판단했다. 내부적으로 비관적 락을 통해 상품을 조회하고 비즈니스 로직을 수행한 후 트랜잭션이 종료될 때(COMMIT 또는 ROLLBACK) 락이 해제되는 구조로 인해 Lock 보유 시간이 필요 이상으로 길어지고 있었다.
2. 개선 방법
따라서 락을 명시적(FOR UPDATE)으로 사용하지 않고 UPDATE 문을 통해 한번에 변경사항을 적용한다면 상당한 개선이 가능할 것이라 생각했다. UPDATE 방식의 장점으로는 WHERE 절에 재고 수량 확인 조건 (stock >= ?)을 포함시켜, 재고 확인과 차감을 하나의 원자적 연산으로 묶을 수 있다는 점이다. 이때 MySQL이 UPDATE 과정에서 자동으로 행 수준 X-Lock을 걸어 다른 트랜잭션의 동시 변경을 차단하므로 동시성 이슈가 발생하지 않는다. 이를 통해 UPDATE 문을 수행할 때만 Lock을 사용한다는 점에서 Lock 보유 시간이 많이 줄어들어 큰 성능 향상이 있을 것으로 예상했다.
3. 2차 개선 후 부하테스트 결과
TPS : 179.5
p99 latency : 1.63s
Average response time : 0.939s
4. 1차 개선 대비 성능 비교 (Before vs After)
Metric | Before (1차 개선) | After (2차 개선) | 개선 효과 |
TPS (Transactions/sec) | 168 | 179.5 | 6.85% ↑ |
p99 latency | 1.66 s | 1.63s | 1.81% ↓ |
Average response time | 0.95 s | 0.939s | 1.16% ↓ |
- TPS : 초당 처리 가능한 트랜잭션 수가 168 → 179.5로 약 6.85% 증가하여 처리량이 소폭 향상
- p99 latency : 응답 지연 상위 1% 구간이 1.66s → 1.63s로 약 1.81% 단축되었으나 미미한 개선 수준
- Average response time : 0.95s → 0.939s로 약 1.16% 단축되어 미세한 성능 개선
5. 2차 개선 결과 정리
분명 많은 개선이 있어야 했지만 그러지 않았다.. 이유가 뭘까?
2차 개선에서는 기대했던 것보다 상대적으로 소폭의 성능 향상을 보였다. TPS 6.85% 증가되었지만, 1차 개선에서 보여준 성능 향상에 비해서는 아쉬운 결과였다. 특히 p99 latency와 평균 응답 시간의 개선폭이 각각 1.81%, 1.16%에 그치면서 사용자 체감 성능 향상은 미미한 수준이었다.
원자적 재고 감소 처리를 통한 개선 효과는 이론적으로는 상당할 것으로 예상되었다. SELECT FOR UPDATE 방식에서 단일 UPDATE 쿼리 방식으로 변경하여 Lock 보유 시간을 단축하고, 재고 확인과 차감을 하나의 원자적 연산으로 처리할 수 있었다. 하지만 실제 성능 측정 결과는 예상보다 낮은 개선율을 보였다.
이러한 결과가 나온 이유로는 몇 가지를 추측해볼 수 있다
1. 1차 개선을 통해 이미 주요 병목 지점이 상당 부분 해결되어 추가 개선의 여지가 제한적이었을 가능성이다
2. UPDATE 쿼리 방식도 결국 행 수준 락을 사용하며, 재고처리 검증 로직을 Application 레벨에서 DB 레벨로 위임했기에 실행 시간이 늘어나면서 개선 전과 비슷했을 것이다
3. 의도치 않은 동작으로 인해 Products UPDATE 쿼리가 지연되고 있을 것이다
Part 4. 3차 개선 (FK)
1. 문제 상황
2차 개선 후에도 여전히 성능 한계가 있다고 판단하여 3차 개선을 진행하게 되었다. 현재 order_items 테이블이 orders와 products 테이블을 참조하는 FK를 가지고 있는데, FK 제약 조건을 추가했을 때 MySQL 내부적으로 데이터 무결성을 위한 추가 동작이 있지 않을까 추측했다. 실제로 Real MySQL에 의하면 InnoDB Storage Engine에서는 테이블의 변경(쓰기 잠금)이 발생한 경우에 부모 테이블이나 자식 테이블 모두 데이터가 있는지 체크하는 작업을 진행한다. 이로 인해 참조하는 테이블 레코드에 S-Lock을 걸게 된다
즉 FK를 설정한 것 만으로 개발자가 의도하지 않게 Lock이 여러 테이블로 전파되면서 Lock Contention이 심화되고, 특히 높은 동시성 환경에서는 DeadLock 발생 빈도가 증가하는 문제가 관찰되었다. 자세한 내용은 아래 포스팅에 정리해 두었다
https://chobo-backend.tistory.com/55
[바로] DeadLock 범인 찾기 (Ft. 위험한 FK?)
Part 1. DeadLock 현상 발생단일 품목 주문 API 부하테스트를 진행하면서 아래와 같이 응답이 실패함을 확인할 수 있었다 실패의 원인을 찾는데는 APM 툴로 Pinpoint를 사용하고 있었기에 크게 어렵지 않
chobo-backend.tistory.com
2. 개선 방법
따라서 3차 개선에서는 FK 제약 조건 중 Products를 제거하여 DB 레벨의 참조 무결성 검증 오버헤드를 없애고, 대신 애플리케이션 레벨에서 데이터 무결성을 관리하는 방식으로 전환하기로 결정했다. 이를 통해 트랜잭션 처리 시 불필요한 락 전파를 방지하고 전체적인 처리 성능을 향상시킬 수 있을 것으로 기대했다
3. 3차 개선 후 부하테스트 결과
TPS : 187.4
p99 latency : 1.44s
Average response time : 0.792s
4. 2차 개선 대비 결과 비교 (Before vs After)
Metric | Before (2차 개선) | After (3차 개선) | 개선 효과 |
TPS (Transactions/sec) | 179.5 | 187.4 | 4.4% ↑ |
p99 latency | 1.63s | 1.44s | 11.66% ↓ |
Average response time | 0.939s | 0.792s | 15.66% ↓ |
- TPS : 초당 처리 가능한 트랜잭션 수가 179.5 → 187.4로 약 4.4% 증가하여 처리량이 소폭 향상
- p99 latency : 응답 지연 상위 1% 구간이 1.63s → 1.44s로 약 11.66% 단축
- Average response time : 0.939s → 0.792s로 약 15.66% 단축
5. 3차 개선 결과 정리
2차 개선이 저조했던 이유 = FK를 조심하자!
3차 개선에서는 2차 개선과 달리 눈에 띄는 성능 향상을 보였다. TPS는 4.4% 증가에 그쳤지만, p99 latency 11.66% 단축과 평균 응답 시간 15.66% 단축이라는 의미 있는 개선 효과를 보였다.
이번 개선의 핵심으로는 Lock Contention 해소로 인한 대기 시간 감소에 있었다. 기존에는 order_items 삽입 시 products 테이블에 대한 참조 무결성 검증을 위해 InnoDB가 자동으로 S-Lock을 걸면서 락이 여러 테이블로 전파되는 문제가 있었다. FK 제거를 통해 이러한 불필요한 Lock 전파를 차단하고, 높은 동시성 환경에서 DeadLock을 발생시키지 않았다. Lock 전파로 인한 문제가 되는 부분은 아래 포스팅에서 자세히 정리해두었다(위 DeadLock 포스팅과 동일한 것이다ㅎㅎ)
https://chobo-backend.tistory.com/55
[바로] DeadLock 범인 찾기 (Ft. 위험한 FK?)
Part 1. DeadLock 현상 발생단일 품목 주문 API 부하테스트를 진행하면서 아래와 같이 응답이 실패함을 확인할 수 있었다 실패의 원인을 찾는데는 APM 툴로 Pinpoint를 사용하고 있었기에 크게 어렵지 않
chobo-backend.tistory.com
또한 DB 레벨의 참조 무결성 검증 오버헤드가 제거되면서 트랜잭션 처리 효율성도 향상되었다. 매번 부모-자식 테이블 간 데이터 존재 여부를 확인하는 추가적인 작업이 생략되어 전체적인 처리 시간이 단축되었고, 이는 특히 대량의 동시 요청이 발생하는 환경에서 시스템 안정성에 긍정적인 영향을 미쳤다.
하지만 이러한 성능 개선은 데이터 무결성 관리에 대한 책임을 애플리케이션으로 이전시키는 트레이드오프를 수반한다. DB 레벨의 참조 무결성 보장 기능을 포기하는 대신 애플리케이션 레벨에서 데이터 무결성을 관리해야 하므로 개발 복잡도가 증가하고, 데이터 일관성을 위한 추가적인 검증 로직 구현이 필요하다. 그럼에도 불구하고 높은 동시성이 발생가능한 주문 시스템에서는 성능과 안정성을 우선시하는 것이 합리적인 선택이라 생각한다.
Part 5. 최종 결과와 한계점
📈 개선 전 대비 결과 비교 (Before vs After)
Metric | Before (개선 전) | After (3차 개선) | 개선 효과 |
TPS (Transactions/sec) | 121 | 187.4 | 54.9% ↑ |
p99 latency | 2.72 s | 1.44s | 47.1% ↓ |
Average response time | 1.21 s | 0.792s | 34.6% ↓ |
- TPS: 초당 처리 가능한 트랜잭션 수가 121 → 187.4로 약 54.9% 증가하여 처리량 향상
- p99 latency: 응답 지연 상위 1% 구간이 2.72s → 1.44s로 약 47.1% 단축
- Average response time: 1.21s → 0.792s로 약 34.5% 단축
목표 TPS 1666에는 턱없이 부족하다... t4g.micro의 한계인가?
🤔 무엇을 더 해볼 수 있을까?
아래 3가지 정도가 떠올랐다
1. DB Connection Pool을 늘린다면 개선 될 것이다.
2. 서버나 DB를 Scale-up 한다면 개선 될 것이다.
3. Redis를 도입하면 개선 될 것이다.
비즈니스 로직이 훨씬 더 복잡해진다면?
- 현재 주문 비즈니스 로직은 아주 간단하다. 재고 검증, 재고 처리, 주문 생성.
- 그렇다면 비즈니스 로직이 지금보다 더 복잡해지는 상황에서도 현재 방식이 가능한지 체크해야한다
- 예를 들어, 쿠폰, 유저 등급에 따른 할인, 결제 등등 여러가지가 도입되었을 때이다
- UPDATE 단일 쿼리로 재고 관리가 가능할까에 대해서 고민해봐야 한다.
다음에 이어서..
여러 방법들에 대한 고민한 내용을 담아 다음 포스팅에 정리하겠다!
'프로젝트' 카테고리의 다른 글
[바로] 일괄 주문 기능 개선 Vol.1 (Ft. Eventual Consistency) (0) | 2025.08.27 |
---|---|
[바로] DeadLock 범인 찾기 (Ft. 위험한 FK?) (3) | 2025.08.25 |
[바로] 반복되는 인증,인가 처리 없애버리기(Ft. AOP & ArgumentResolver) (0) | 2025.08.08 |
[바로] 분산 시스템에서 ID가 유일하려면?(Ft. Snowflake VS TSID 성능테스트) (3) | 2025.07.28 |
[바로] 확장성과 성능을 고려한 ERD 설계하기 (3) | 2025.07.25 |