안녕하세요, 이력서 기반 면접 준비를 도와드리는 QueryDaily 팀입니다.
.
.
.
"재고가 1개 남았는데, 두 명이 동시에 주문하면 어떻게 되나요?"
면접장에서 이 질문을 받으면 어떻게 대답하시겠어요? 단순히 "트랜잭션으로 처리합니다"라고 답하면 바로 꼬리 질문이 날아옵니다.
"트랜잭션만으로 동시성 문제가 해결되나요?"
"낙관적 락과 비관적 락의 차이는요?"
"분산 환경에서는 어떻게 하죠?"
동시성 제어는 백엔드 개발자라면 반드시 이해해야 할 핵심 개념입니다. 실무에서 장애의 원인이 되기도 하고, 면접에서 깊이를 평가하는 단골 주제이기도 하죠.
오늘은 동시성 문제가 왜 발생하는지, 어떻게 해결하는지, 각 방법의 트레이드오프는 무엇인지 완벽하게 정리해 드리겠습니다.

동시성 문제를 이해하려면 먼저 Race Condition(경쟁 상태)을 알아야 합니다.
// 상품 재고: 1개
// 사용자 A와 B가 동시에 주문
// 사용자 A의 트랜잭션
1. 재고 조회: 1개 (OK, 주문 가능)
2. 재고 차감: 1 - 1 = 0
3. 주문 완료
// 사용자 B의 트랜잭션 (A와 거의 동시에 실행)
1. 재고 조회: 1개 (OK, 주문 가능) // A가 아직 커밋 전!
2. 재고 차감: 1 - 1 = 0
3. 주문 완료
// 결과: 재고 1개에 주문 2개 발생 (데이터 정합성 깨짐)
이것이 바로 Lost Update(갱신 손실) 문제입니다. 두 트랜잭션이 같은 데이터를 동시에 읽고 수정하면서, 한쪽의 변경 사항이 사라져 버립니다.
많은 분들이 "트랜잭션 = 원자성 = 동시성 해결"이라고 생각합니다. 하지만 트랜잭션의 ACID 속성만으로는 동시성 문제를 완벽히 해결할 수 없습니다.
트랜잭션은 "내 작업이 모두 성공하거나 모두 실패한다"를 보장할 뿐, "다른 트랜잭션과 동시에 같은 데이터를 건드리지 않는다"를 보장하지 않습니다. 이를 위해서는 별도의 동시성 제어 메커니즘이 필요합니다.

비관적 락은 이름 그대로 "충돌이 발생할 것이다"라고 비관적으로 가정하고, 데이터를 읽는 시점에 락을 거는 방식입니다.
-- 트랜잭션 A
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- 락 획득
-- 이 시점부터 다른 트랜잭션은 이 row에 접근 불가 (대기)
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 락 해제
-- 트랜잭션 B (A가 락을 잡고 있는 동안)
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- 대기...
-- A가 COMMIT하면 그제서야 락 획득
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
}
@Transactional
public void order(Long productId) {
// 락을 걸고 조회
Product product = productRepository.findByIdWithPessimisticLock(productId)
.orElseThrow(() -> new IllegalArgumentException("상품 없음"));
if (product.getStock() < 1) {
throw new IllegalStateException("재고 부족");
}
product.decreaseStock(1);
// 트랜잭션 종료 시 락 해제
}
| 락 종류 | SQL | 설명 |
|---|---|---|
| 공유 락 (Shared Lock) | FOR SHARE | 읽기는 허용, 쓰기는 차단 |
| 배타 락 (Exclusive Lock) | FOR UPDATE | 읽기/쓰기 모두 차단 |
장점:
단점:

낙관적 락은 "충돌이 거의 없을 것이다"라고 낙관적으로 가정합니다. 락을 걸지 않고 작업을 수행한 뒤, 커밋 시점에 충돌 여부를 확인합니다.
-- 상품 테이블에 version 컬럼 추가
-- product: { id: 1, stock: 10, version: 1 }
-- 트랜잭션 A
SELECT id, stock, version FROM product WHERE id = 1;
-- 결과: stock=10, version=1
UPDATE product
SET stock = 9, version = 2
WHERE id = 1 AND version = 1; -- version 조건 포함!
-- 성공: 1 row affected, version이 2로 증가
-- 트랜잭션 B (A와 동시에 시작, A보다 조금 늦게 UPDATE)
SELECT id, stock, version FROM product WHERE id = 1;
-- 결과: stock=10, version=1 (A 커밋 전 조회)
UPDATE product
SET stock = 9, version = 2
WHERE id = 1 AND version = 1; -- version이 이미 2로 바뀜!
-- 실패: 0 row affected (충돌 감지)
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int stock;
@Version // 이 어노테이션이 핵심!
private Long version;
}
@Transactional
public void order(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("상품 없음"));
if (product.getStock() < 1) {
throw new IllegalStateException("재고 부족");
}
product.decreaseStock(1);
// 커밋 시점에 version 체크, 충돌 시 OptimisticLockingFailureException 발생
}
// 서비스 레이어에서 재시도 로직
@Retryable(value = OptimisticLockingFailureException.class, maxAttempts = 3)
@Transactional
public void orderWithRetry(Long productId) {
order(productId);
}
장점:
단점:
| 상황 | 추천 방식 | 이유 |
|---|---|---|
| 충돌이 자주 발생 | 비관적 락 | 재시도 오버헤드 방지 |
| 충돌이 거의 없음 | 낙관적 락 | 불필요한 락 대기 방지 |
| 데이터 정합성이 매우 중요 | 비관적 락 | 확실한 동시성 제어 |
| 읽기가 쓰기보다 훨씬 많음 | 낙관적 락 | 읽기 성능 확보 |
| 트랜잭션이 짧음 | 비관적 락 | 락 점유 시간 최소화 |
| 분산 환경 | 별도 고려 필요 | 아래 섹션 참조 |

서버가 여러 대인 분산 환경에서는 DB 락만으로는 부족할 수 있습니다.
Redis를 활용한 분산 락이 대표적입니다.
// Redisson 사용 예시
@Service
@RequiredArgsConstructor
public class OrderService {
private final RedissonClient redissonClient;
private final ProductRepository productRepository;
public void order(Long productId) {
RLock lock = redissonClient.getLock("product:" + productId);
try {
// 락 획득 시도 (최대 10초 대기, 3초 후 자동 해제)
boolean acquired = lock.tryLock(10, 3, TimeUnit.SECONDS);
if (!acquired) {
throw new IllegalStateException("락 획득 실패");
}
// 비즈니스 로직 수행
Product product = productRepository.findById(productId)
.orElseThrow();
product.decreaseStock(1);
productRepository.save(product);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
lock.unlock(); // 반드시 해제!
}
}
}
MySQL의 GET_LOCK을 활용하는 방법도 있습니다.
-- 락 획득 (이름 기반)
SELECT GET_LOCK('product_1', 10); -- 10초 대기
-- 비즈니스 로직 수행
UPDATE product SET stock = stock - 1 WHERE id = 1;
-- 락 해제
SELECT RELEASE_LOCK('product_1');
Q1. "낙관적 락과 비관적 락의 차이를 설명해주세요."
핵심 답변: 비관적 락은 충돌을 예상하고 데이터 접근 시점에 락을 걸어 다른 트랜잭션의 접근을 차단합니다. 반면 낙관적 락은 충돌이 드물다고 가정하고, 락 없이 작업을 수행한 뒤 커밋 시점에 version을 비교하여 충돌을 감지합니다. 비관적 락은 정합성이 확실하지만 성능 저하가 있고, 낙관적 락은 성능이 좋지만 충돌 시 재시도가 필요합니다.
Q2. "낙관적 락에서 충돌이 발생하면 어떻게 처리하나요?"
핵심 답변: JPA에서는 OptimisticLockingFailureException이 발생합니다. 이를 처리하기 위해 Spring Retry 등을 사용하여 재시도 로직을 구현하거나, 사용자에게 "다시 시도해주세요"라는 메시지를 보여주고 재요청을 유도합니다. 재시도 시에는 최신 데이터를 다시 조회해야 합니다.
Q3. "SELECT FOR UPDATE가 정확히 무엇을 하는 건가요?"
핵심 답변: SELECT FOR UPDATE는 조회하는 row에 배타적 락(Exclusive Lock)을 겁니다. 이 락이 해제될 때까지 다른 트랜잭션은 해당 row를 수정할 수 없고, FOR UPDATE로 조회하는 것도 차단됩니다. 트랜잭션이 커밋되거나 롤백되면 락이 해제됩니다.
Q4. "분산 환경에서 DB 락만으로 부족한 이유는?"
핵심 답변: DB 락은 단일 DB 인스턴스 내에서만 유효합니다. 하지만 분산 환경에서는 여러 서버가 동시에 다른 DB 커넥션으로 접근할 수 있고, 읽기 전용 복제본을 사용하는 경우 락이 적용되지 않을 수 있습니다. 따라서 Redis 분산 락처럼 모든 서버가 공유하는 별도의 락 메커니즘이 필요합니다.
Q5. "동시성 이슈를 실무에서 경험해보셨나요?"
핵심 답변 팁: 본인의 경험을 구체적으로 설명하세요. 예를 들어, "선착순 쿠폰 발급 기능에서 동시 요청 시 쿠폰이 초과 발급되는 문제가 있었습니다. 처음에는 트랜잭션만 믿었는데 해결되지 않았고, Redis 분산 락을 도입하여 해결했습니다. 이 과정에서 락 타임아웃 설정의 중요성도 배웠습니다."
오늘 배운 내용을 정리하면 이렇습니다:
동시성 제어는 단순히 암기할 개념이 아니라, 시스템의 특성과 비즈니스 요구사항을 고려하여 최적의 해결책을 선택할 수 있어야 하는 영역입니다. 면접관은 바로 이 판단력을 평가합니다.
오늘 알아본 내용처럼, 자신의 프로젝트 경험과 기술 스택에 대해 '왜?'라고 질문을 던지고 깊게 파고드는 연습은 합격의 핵심입니다.
QueryDaily는 바로 이 과정을 매일 효율적으로 도와드리는 서비스입니다.
tags: 동시성 Lock 비관적락 낙관적락 백엔드면접