카테고리 없음

[유레카 / 백엔드] TIL - 26 (Transaction & Concurrency)

coding-quokka101 2026. 3. 18. 10:16

📌 들어가며

오늘의 학습 목표: 트랜잭션의 개념을 제대로 이해하고, 동시성 문제를 해결하는 방법 익히기

안녕하세요! 오늘은 트랜잭션과 동시성 제어를 공부했습니다.

솔직히 이 주제를 공부하게 된 계기가 있어요. 팀 프로젝트에서 "선착순 쿠폰 발급" 기능을 만들었는데...

상황:
쿠폰 100개 한정, 선착순 발급!

테스트:
1명씩 요청 → 정상 작동 ✅
10명 동시 요청 → 정상 작동 ✅
100명 동시 요청 → 쿠폰 127개 발급됨 🤯

나: "어... 왜 100개 넘게 발급됐지?"

알고 보니 동시성 문제였어요. 여러 요청이 동시에 "쿠폰 남았나?" 확인하고, 동시에 "발급!"을 해버린 거죠.

그리고 또 하나, 면접에서 이런 질문을 받았어요.

"주문할 때 재고 차감은 어떻게 처리하셨어요? 100명이 동시에 주문하면요?"

그때 제대로 대답을 못 했거든요. 😅 그래서 이번 기회에 확실하게 정리해봤습니다!


🎯 Today I Learned

✅ 트랜잭션이란 무엇인가
✅ ACID 속성 이해
✅ @Transactional 제대로 사용하기
✅ 동시성 문제의 종류 (Lost Update, Dirty Read 등)
✅ 락(Lock)의 종류와 선택 기준
✅ 낙관적 락 vs 비관적 락
✅ 데드락과 해결 방법
✅ 실무에서 자주 겪는 상황들

🤔 트랜잭션, 왜 필요할까?

은행 송금 예시로 이해하기

트랜잭션을 처음 배울 때 가장 많이 드는 예시, 은행 송금이에요.

A가 B에게 10만원 송금:

1단계: A 계좌에서 10만원 차감
2단계: B 계좌에 10만원 추가

만약 1단계 후에 서버가 죽으면?
→ A는 10만원 빠졌는데 B는 못 받음!
→ 10만원 증발 💸

트랜잭션이 해결:

트랜잭션 시작
    1단계: A 계좌에서 10만원 차감
    2단계: B 계좌에 10만원 추가
트랜잭션 끝

- 둘 다 성공하면 → COMMIT (확정)
- 하나라도 실패하면 → ROLLBACK (원상복구)

→ "전부 성공" 또는 "전부 실패", 중간 상태 없음!

내가 이해한 트랜잭션 정의

트랜잭션 = "하나의 작업 단위"

여러 개의 DB 작업을 묶어서,
전부 성공하거나 전부 실패하게 만드는 것

예:
- 주문 생성 + 재고 차감 + 포인트 사용 → 하나의 트랜잭션
- 하나라도 실패하면 전부 롤백!

📚 ACID 속성

트랜잭션이 지켜야 할 4가지 속성이에요. 처음엔 외우기만 했는데, 이해하고 나니까 기억에 남더라고요.

A - Atomicity (원자성)

"전부 성공 or 전부 실패"

원자(Atom) = 더 이상 쪼갤 수 없는 단위

A 차감 + B 추가 = 쪼갤 수 없는 하나의 작업
중간에 실패하면? 전부 롤백!

C - Consistency (일관성)

"트랜잭션 전후로 데이터 규칙이 유지"

규칙: 모든 계좌 잔액의 합 = 100만원

트랜잭션 전: A(60만) + B(40만) = 100만원
트랜잭션 후: A(50만) + B(50만) = 100만원 ✅

A(50만) + B(40만) = 90만원? → 규칙 위반! ❌

I - Isolation (격리성)

"동시 실행해도 서로 영향 없음"

A가 송금 중일 때,
다른 트랜잭션이 A 잔액을 읽으면?
→ 완료 전 중간 상태가 보이면 안 됨!
→ 마치 혼자 실행하는 것처럼

D - Durability (지속성)

"커밋되면 영구 저장"

트랜잭션 완료(COMMIT) 후 서버가 죽어도,
재시작하면 데이터는 그대로 있어야 함!

🔧 @Transactional 제대로 사용하기

기본 사용법

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    @Transactional
    public Order createOrder(OrderRequest request) {
        // 1. 상품 조회
        Product product = productRepository.findById(request.getProductId())
                .orElseThrow(() -> new ProductNotFoundException());

        // 2. 재고 차감
        product.decreaseStock(request.getQuantity());

        // 3. 주문 생성
        Order order = Order.create(request, product);
        
        return orderRepository.save(order);
        
        // 메서드 정상 종료 → 자동 COMMIT
        // 예외 발생 → 자동 ROLLBACK
    }
}

내가 겪은 실수들

실수 1: private 메서드에 @Transactional

@Service
public class OrderService {

    public void process() {
        createOrder();  // 트랜잭션 적용 안 됨!
    }

    @Transactional
    private void createOrder() {  // ❌ private이라 프록시가 못 잡음
        // ...
    }
}
왜 안 될까?

Spring의 @Transactional은 프록시(Proxy) 방식으로 동작
→ 프록시는 public 메서드만 가로챌 수 있음
→ private은 프록시가 못 건드림!

해결: public으로 변경하거나, 구조 재설계

실수 2: 같은 클래스 내부 호출

@Service
public class OrderService {

    public void processOrder() {
        // 같은 클래스의 다른 메서드 호출
        this.createOrder();  // ❌ 트랜잭션 적용 안 됨!
    }

    @Transactional
    public void createOrder() {
        // ...
    }
}
왜 안 될까?

외부에서 호출: [클라이언트] → [프록시] → [실제 객체]
                              ↑ 여기서 트랜잭션 시작!

내부에서 호출: [실제 객체의 메서드] → [실제 객체의 다른 메서드]
              ↑ 프록시를 안 거침!

해결 방법:
1. 별도 클래스로 분리
2. 자기 자신을 주입받아서 호출 (비추천)

실수 3: Checked Exception은 롤백 안 됨

@Transactional
public void createOrder() throws IOException {
    orderRepository.save(order);
    
    throw new IOException("파일 에러");  // ❌ 롤백 안 됨!
}
Spring 기본 설정:
- RuntimeException (Unchecked) → 롤백 O
- Exception (Checked) → 롤백 X

해결:
@Transactional(rollbackFor = Exception.class)
public void createOrder() throws IOException {
    // 이제 모든 예외에 롤백됨
}

@Transactional 옵션 정리

@Transactional(
    readOnly = true,           // 읽기 전용 (성능 최적화)
    timeout = 10,              // 10초 내에 완료 안 되면 롤백
    rollbackFor = Exception.class,  // 이 예외 발생 시 롤백
    propagation = Propagation.REQUIRED  // 전파 옵션
)
📌 readOnly = true 쓰면 좋은 경우:

- 조회만 하는 메서드
- JPA가 변경 감지(Dirty Checking) 안 함 → 성능 ↑
- DB가 읽기 전용으로 최적화 가능

@Transactional(readOnly = true)
public List<Order> getOrders() {
    return orderRepository.findAll();
}

😱 동시성 문제의 종류

왜 동시성 문제가 생길까?

선착순 쿠폰 100개 발급 시나리오:

시간 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→

사용자 A: ──[쿠폰 개수 조회: 99개]──[발급!]──
사용자 B: ────[쿠폰 개수 조회: 99개]──[발급!]──

둘 다 "99개니까 발급 가능!" 이라고 판단
둘 다 발급 → 101개 발급됨!

이게 "Race Condition (경쟁 상태)"

Lost Update (갱신 손실)

재고 10개인 상품, A와 B가 동시에 1개씩 주문:

예상: 10 - 1 - 1 = 8개
실제: ???

시간 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→

A: ──[재고 읽음: 10]─────────[10-1=9 저장]──
B: ────[재고 읽음: 10]─────────[10-1=9 저장]──

결과: 재고 9개 (하나 손실!)

Dirty Read (더티 리드)

A: 재고 10 → 5로 변경 (아직 커밋 안 함)
B: 재고 읽음 → 5 (커밋 안 된 값을 읽음!)
A: 롤백! → 재고 다시 10

B는 "5"를 믿고 로직 수행했는데,
실제로는 "10"이었음 → 잘못된 데이터 기반 처리!

Non-Repeatable Read (반복 불가능 읽기)

B: 재고 읽음 → 10
A: 재고 10 → 5로 변경 후 커밋!
B: 재고 다시 읽음 → 5

같은 트랜잭션에서 같은 데이터를 두 번 읽었는데
값이 다름! → 뭘 믿어야 해?

🔒 락(Lock)으로 해결하기

락이란?

락 = "내가 쓰는 동안 다른 사람 건드리지 마!"

화장실 비유:
- 들어가면 문 잠금 (Lock)
- 볼일 보는 중 (작업 수행)
- 나오면서 문 열기 (Unlock)
- 다음 사람 입장

데이터도 마찬가지!

비관적 락 (Pessimistic Lock)

"충돌이 날 거야!" 라고 비관적으로 생각
→ 미리 락을 걸고 시작

특징:
- 데이터 읽을 때부터 락
- 다른 트랜잭션은 대기
- 충돌 가능성 높을 때 사용
public interface ProductRepository extends JpaRepository<Product, Long> {

    // 비관적 락 - 쓰기 락 (다른 트랜잭션 읽기/쓰기 모두 대기)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithLock(@Param("id") Long id);
}
@Service
public class OrderService {

    @Transactional
    public void order(Long productId, int quantity) {
        // 락 걸고 조회 → 다른 트랜잭션은 여기서 대기!
        Product product = productRepository.findByIdWithLock(productId)
                .orElseThrow();

        product.decreaseStock(quantity);  // 재고 차감
        
        // 트랜잭션 끝나면 락 해제 → 대기 중인 트랜잭션 실행
    }
}
실행 흐름:

A: ──[락 획득]──[재고 10→9]──[커밋, 락 해제]──
B: ──[락 대기...]──────────────[락 획득]──[재고 9→8]──[커밋]

결과: 10 → 9 → 8 ✅ 정상!

낙관적 락 (Optimistic Lock)

"충돌 안 날 거야!" 라고 낙관적으로 생각
→ 락 안 걸고, 커밋할 때 충돌 체크

특징:
- 버전(version) 컬럼 사용
- 커밋 시점에 버전 비교
- 충돌하면 예외 발생 → 재시도
- 충돌 가능성 낮을 때 사용
@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private int stock;

    @Version  // 낙관적 락용 버전 컬럼
    private Long version;
}
@Service
public class OrderService {

    @Transactional
    public void order(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
                .orElseThrow();

        product.decreaseStock(quantity);
        
        // 커밋 시점에 version 비교
        // 다른 트랜잭션이 먼저 수정했으면 → OptimisticLockException!
    }
}
실행 흐름:

A: ──[조회: version=1]──[재고 10→9, version=2로 업데이트]──
B: ──[조회: version=1]──[커밋 시도: version=1인데 DB는 2?]──[충돌! 예외!]

B는 재시도하거나 에러 처리

낙관적 vs 비관적, 언제 뭘 써?

📌 비관적 락을 써야 할 때:

✅ 충돌이 자주 발생할 때
   예: 인기 상품 재고, 선착순 이벤트
   
✅ 충돌 시 재시도 비용이 클 때
   예: 복잡한 계산 후 저장

✅ 데이터 정합성이 매우 중요할 때
   예: 금융 거래, 결제


📌 낙관적 락을 써야 할 때:

✅ 충돌이 거의 없을 때
   예: 개인 설정 변경, 프로필 수정
   
✅ 읽기가 많고 쓰기가 적을 때

✅ 동시 접근이 적을 때
실무 팁:

"100명이 동시에 주문" 같은 상황?
→ 비관적 락 (충돌 확실함)

"가끔 관리자가 상품 정보 수정" 같은 상황?
→ 낙관적 락 (충돌 거의 없음)

💀 데드락 (Deadlock)

데드락이란?

데드락 = 서로가 서로를 기다리며 영원히 멈춤

교착 상태 비유:
좁은 골목에서 두 차가 마주침
A: "니가 빠져"
B: "니가 빠져"
→ 둘 다 못 움직임!

 

트랜잭션 A: 상품 락 획득 → 재고 락 획득하려고 대기...
트랜잭션 B: 재고 락 획득 → 상품 락 획득하려고 대기...

A: "재고 락 줘!" (B가 가지고 있음)
B: "상품 락 줘!" (A가 가지고 있음)

→ 서로 영원히 대기... 💀

데드락 예방법

📌 방법 1: 락 획득 순서 통일

// ❌ 데드락 위험
트랜잭션 A: 상품 → 재고 순으로 락
트랜잭션 B: 재고 → 상품 순으로 락

// ✅ 안전
트랜잭션 A: 상품 → 재고 순으로 락
트랜잭션 B: 상품 → 재고 순으로 락 (순서 통일!)


📌 방법 2: 락 타임아웃 설정

@QueryHints({
    @QueryHint(name = "javax.persistence.lock.timeout", value = "3000")
})
→ 3초 대기 후 포기 (무한 대기 방지)


📌 방법 3: 한 번에 필요한 락 모두 획득

트랜잭션 시작 시 필요한 모든 락을 한꺼번에
→ 일부만 획득하고 대기하는 상황 방지

🎯 실무 상황별 해결책

상황 1: 선착순 쿠폰 100개

문제: 100개 한정인데 동시 요청으로 초과 발급

해결책들:

1️⃣ 비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
Coupon findFirstByIssuedFalse();

→ 순서대로 처리, 확실하지만 느림


2️⃣ Redis로 분산 락
redissonClient.getLock("coupon-lock")

→ 여러 서버에서도 동작, 성능 좋음


3️⃣ DB unique 제약 + 예외 처리
(user_id, coupon_event_id) unique

→ 중복 발급 자체를 DB가 막음

상황 2: 재고 차감

문제: 재고 10개인데 동시 주문으로 마이너스

해결책:

1️⃣ 비관적 락 + 재고 체크
@Lock(LockModeType.PESSIMISTIC_WRITE)
Product findById(Long id);

if (product.getStock() < quantity) {
    throw new OutOfStockException();
}
product.decreaseStock(quantity);


2️⃣ UPDATE 쿼리로 원자적 처리
UPDATE product 
SET stock = stock - :quantity 
WHERE id = :id AND stock >= :quantity

→ 조회 없이 바로 UPDATE, 락 시간 최소화

상황 3: 좋아요 카운트

문제: 동시에 좋아요 누르면 카운트 손실

해결책:

1️⃣ 원자적 UPDATE
UPDATE post SET like_count = like_count + 1 WHERE id = :id

→ 읽고 쓰는 게 아니라 바로 증가


2️⃣ 별도 테이블 + 집계
likes 테이블에 행 추가 (INSERT)
COUNT는 따로 조회하거나 배치로 집계

→ 락 경합 최소화

💡 오늘 배운 핵심 정리

1️⃣ 트랜잭션 = 전부 성공 or 전부 실패
   - ACID 속성 기억하기
   - @Transactional로 간단히 적용

2️⃣ @Transactional 주의점
   - private 메서드 ❌
   - 같은 클래스 내부 호출 ❌
   - Checked Exception은 rollbackFor 필요

3️⃣ 동시성 문제 = 여러 요청이 동시에 같은 데이터 접근
   - Lost Update, Dirty Read 등
   - 락(Lock)으로 해결

4️⃣ 비관적 락 vs 낙관적 락
   - 충돌 많으면 → 비관적 (미리 락)
   - 충돌 적으면 → 낙관적 (나중에 체크)

5️⃣ 데드락 조심!
   - 락 순서 통일
   - 타임아웃 설정

😊 좋았던 점 & 😅 아쉬웠던 점

좋았던 점

👍 면접 질문 대비 완료

"100명이 동시에 주문하면?" → 이제 자신있게 대답 가능!

👍 실제 버그 원인 이해

선착순 쿠폰 초과 발급 버그가 왜 생겼는지 이해했고, 해결책도 알게 됨

👍 @Transactional 제대로 이해

그냥 붙이면 되는 줄 알았는데, 주의할 점이 많다는 걸 알게 됨

아쉬웠던 점

😢 분산 환경은 더 복잡

서버가 여러 대면 DB 락만으로 안 되고, Redis 분산 락 같은 게 필요. 다음에 더 공부해야겠어요

😢 실제 성능 테스트 못 해봄

이론으로는 알겠는데, 실제로 100명 동시 요청 테스트를 해보고 싶어요


🚀 다음 학습 목표

📌 단기
   ✓ 프로젝트에 동시성 처리 적용
   ✓ 재고 관리 로직 개선

📌 중기
   ✓ Redis 분산 락 학습
   ✓ JMeter로 동시성 테스트

📌 장기
   ✓ 대용량 트래픽 처리 패턴
   ✓ 이벤트 소싱, CQRS

🎬 마치며

트랜잭션과 동시성을 공부하면서 느낀 건, **"혼자 테스트하면 안 보이는 버그가 있다"**는 거예요.

혼자 테스트: "잘 되네~" ✅
100명 동시 테스트: "어... 왜 이래?" 🔥

특히 선착순 이벤트, 재고 관리, 좋아요 같은 기능은 동시성을 꼭 고려해야 해요.

면접에서 "동시성 문제 어떻게 해결하셨어요?"라는 질문에 이제 자신있게 대답할 수 있을 것 같습니다!

  • 비관적 락으로 순서 보장했어요
  • 낙관적 락으로 충돌 감지했어요
  • 원자적 UPDATE 쿼리 사용했어요

이런 경험이 있다는 게 큰 차이를 만드는 것 같아요! 💪


📚 참고 자료


Tag: #트랜잭션 #동시성 #Lock #SpringBoot #ACID #데드락 #TIL #멀티캠퍼스 #유레카3기백엔드 #백엔드개발 #부트캠프후기


📸 이미지 위치 (7개)

  1. 맨 위: Transaction & Concurrency 배너
  2. ACID 속성 다이어그램
  3. 동시성 문제 발생 시나리오 (Race Condition)
  4. 비관적 락 동작 흐름
  5. 낙관적 락 동작 흐름
  6. 데드락 발생 시나리오
  7. 트랜잭션 & 동시성 요약 인포그래픽

이해 과정과 실무 상황 위주로 작성했어요! 수정할 부분 있으면 말씀해주세요 😊