본문 바로가기
Framework/Spring Boot

[Spring Boot] 낙관적 락Optimistic vs 비관적 락Pessimistic JPA 동시성 제어

Redis는 구축하고 유지보수하기 까다로운 외부 인프라입니다.

만약 "우리 회사는 그 정도로 트래픽이 몰리지 않는데 굳이 Redis까지 깔아야 하나요? 그냥 JPA랑 MySQL 기능만으로 동시성을 막을 수는 없나요?"라고 묻는다면 정답은 "가능하다!"입니다.
실무에서는 동시성 문제가 터질 확률(충돌 빈도)에 따라, 애플리케이션 레벨에서 가볍게 막아내는 낙관적 락(Optimistic Lock)과 DB 레벨에서 아예 진짜 자물쇠를 걸어버리는 비관적 락(Pessimistic Lock)을 골라서 사용합니다.

 

이번 글에서는 "JPA 동시성 제어의 두 가지 철학!" 낙관적 락과 비관적 락의 작동 원리와 실무 적용 시나리오에 대하여 다뤄보겠습니다.

1. 낙관적 락 (Optimistic Lock): "설마 충돌 나겠어?"

낙관적 락은 이름 그대로 세상(트래픽)을 긍정적으로 바라봅니다. "동시에 들어올 확률은 거의 없으니 평소엔 자물쇠 없이 빠르게 넘어가고, 만약 진짜 우연히 동시에 들어와서 충돌이 나면 그때 에러를 뱉어버리자!"라는 마인드입니다. 이는 Version(버전)이라는 정수 필드를 통해 마법처럼 동작합니다.

// Entity 클래스
@Entity
public class Item {
    @Id @GeneratedValue
    private Long id;
    
    private int stock;

    @Version //  이것 하나만 붙이면 스프링 데이터 JPA가 알아서 낙관적 락을 켭니다!
    private Long version;
}

작동 원리: 철수와 영희가 동시에 재고 1개(version=1)짜리 상품을 조회했습니다. 철수가 먼저 0개로 수정(UPDATE)하면서 version을 2로 올립니다. 간발의 차이로 영희가 0개로 수정하려 UPDATE Item SET stock=0, version=2 WHERE id=1 AND version=1 쿼리를 날립니다. 하지만 DB에는 이미 철수가 버전을 2로 올려두었기 때문에, WHERE 조건에 맞는 version=1인 데터가 없어서 업데이트가 0건 처리됩니다! 이때 JPA는 OptimisticLockException 예외를 빵 터뜨리며 영희의 결제를 튕겨냅니다.

 

2. 비관적 락 (Pessimistic Lock): "아무도 믿을 수 없어!"

반대로 비관적 락은 세상을 불신합니다. "무조건 동시에 들어와서 꼬일 테니, 내가 조회(SELECT)하는 순간 무조건 다른 놈들은 건드리지도 못하게 DB Row에 진짜 자물쇠를 걸어버릴 거야!"라는 마인드입니다.

// Repository 인터페이스
public interface ItemRepository extends JpaRepository<Item, Long> {
    
    //  PESSIMISTIC_WRITE 모드를 주면, 실제 쿼리가 "SELECT ... FOR UPDATE" 로 날아갑니다.
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT i FROM Item i WHERE i.id = :id")
    Optional<Item> findByIdWithPessimisticLock(Long id);
}

작동 원리: 철수가 먼저 findByIdWithPessimisticLock()을 호출하면, DB(MySQL)는 해당 데이터 줄(Row)에 베타 락(X-Lock)을 겁니다. 0.001초 뒤 영희가 똑같은 메서드를 호출하면, 영희의 쓰레드는 DB 락이 풀릴 때까지 DB 단에서 무한정 대기(Blocking)하게 됩니다. 철수가 재고 차감을 끝내고 트랜잭션(Commit)을 닫아야 비로소 영희가 깨어나서 남은 재고(0개)를 보고 얌전히 "품절" 에러를 내뱉습니다.

 

3. 실무에서는 무엇을 써야 할까?

  • 게시글 조회수 증가, 내 정보 수정: 충돌 빈도가 매우 낮습니다. 이런 곳에 비관적 락을 걸면 성능만 뚝 떨어집니다. 낙관적 락(@Version)을 걸고, 충돌 시 자연스럽게 "다시 시도해 주세요" 알람을 띄우면 됩니다.
  • 선착순 티켓팅, 쿠폰 발급, 금융 송금: 무조건 충돌이 발생합니다. 낙관적 락을 쓰면 1만 명 중 1명만 성공하고 9,999명에게 Exception이 터져버립니다. 비관적 락(PESSIMISTIC_WRITE)을 사용하여 무식하더라도 확실하게 줄을 세우거나, 성능이 중요하다면 에서 배운 Redis 분산 락을 도입해야 합니다.

 

4. 마무리 및 주요 발전 방향

지금까지 트래픽 양상과 충돌 빈도에 따라 적절한 무기를 꺼내 들어 완벽하게 데이터 정합성을 지켜내는 시니어급 JPA 동시성 제어, 낙관적 락과 비관적 락의 실무 비교 가이드에 대해 상세히 다루어 보았습니다. 이제 여러분은 무작정 Redis부터 깔지 않고 "이게 정말 분산 락이 필요한 규모인가?"를 아키텍트의 시선으로 판단할 수 있습니다.

백엔드 서버의 안정성은 완성되었습니다. 이제 프론트엔드 개발자(React/iOS)와의 협업 이야기로 넘어가 보겠습니다. 프론트엔드가 백엔드 API를 호출했을 때, "상태: 결제완료"라는 JSON 데이터를 받았습니다. 그런데 프론트엔드가 "결제가 완료됐으면 그다음에 '배송조회' API를 호출해야 하나? 아니면 '환불' API를 호출해야 하나?"라며 API 문서를 뒤적거립니다. "만약 백엔드가 응답 JSON 안에 다음에 클라이언트가 할 수 있는 행동(Link)을 몽땅 다 적어서 던져주면 어떨까?" 본 설명에서는 "REST API 성숙도 모델의 최고봉(Level 3)!" 스스로 다음 행동을 지시하는 마법의 아키텍처, Spring HATEOAS 완벽 가이드에 대해 아주 심도 있게 살펴보겠습니다.


웹 개발을 하다 보면 누구나 "우리는 RESTful API를 사용합니다"라고 말합니다. HTTP 메서드(GET, POST, PUT, DELETE)를 쓰고 /users/1처럼 예쁜 URL을 쓰면 REST API라고 믿기 때문입니다. 하지만 REST의 창시자인 '로이 필딩(Roy Fielding)' 아저씨는 분노하며 "네놈들이 만든 건 진짜 REST가 아니야!"라고 일갈합니다. 로이 필딩이 말하는 완벽한 REST(Richardson Maturity Model Level 3)의 필수 조건 중 하나는 바로 HATEOAS (Hypermedia As The Engine Of Application State)입니다. 이름이 끔찍하게 길지만 핵심은 단순합니다. "서버가 클라이언트(앱/웹)에게 응답(JSON)을 내려줄 때, 그 데이터를 가지고 '다음에 할 수 있는 행동(상태 전이)'들의 URL 링크까지 통째로 응답에 포함해서 줘라!"라는 것입니다.

본 글에서는 "클라이언트를 API 문서 지옥에서 해방시키는 마법!" Spring HATEOAS를 활용한 궁극의 RESTful API 아키텍처에 대하여 다뤄보겠습니다.

1. HATEOAS의 필요성: 클라이언트 하드코딩 방지

여러분(백엔드)이 "주문 상세 조회" API를 만들었습니다.

일반적인(Level 2) 가짜 REST 응답:
{ "orderId": 100, "status": "SHIPPED" }
이걸 받은 프론트엔드 개발자는 코드를 짭니다. "음, status가 SHIPPED(배송중)일 때는 환불이 불가능하니까 화면에서 '결제취소' 버튼을 숨겨야지!" (클라이언트에 비즈니스 로직이 하드코딩됨)

진정한(Level 3) HATEOAS 응답:
{ "orderId": 100, "status": "SHIPPED", "_links": { "self": { "href": "/orders/100" }, "tracking": { "href": "/orders/100/delivery" } } }
백엔드가 응답을 줄 때 결제 취소(cancel) 링크를 아예 빼버리고 배송 조회(tracking) 링크만 줍니다. 프론트엔드는 "status가 뭔지는 내 알 바 아니고, JSON 안에 'cancel' 링크가 없네? 그럼 결제취소 버튼 숨길게!"라고 반응합니다. 비즈니스 로직이 오직 서버에만 존재하게 되며, URL 주소가 바뀌더라도 클라이언트 코드를 수정할 필요가 없는 완벽한 결합도 분리(Decoupling)가 이루어집니다!


2. Spring HATEOAS 실전 구현

스프링 부트는 이 복잡해 보이는 링크 삽입 작업을 EntityModel과 WebMvcLinkBuilder라는 천재적인 도구로 쉽게 만들어 줍니다.

// 의존성 추가: implementation 'org.springframework.boot:spring-boot-starter-hateoas'

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public EntityModel<OrderDto> getOrder(@PathVariable Long id) {
        OrderDto order = orderService.findById(id);

        // 1. 순수한 데이터 객체를 HATEOAS용 모델로 감쌉니다.
        EntityModel<OrderDto> entityModel = EntityModel.of(order);

        // 2. 자기 자신(Self)을 조회하는 링크 추가
        // 하드코딩("/api/orders/1")이 아니라, 메서드 맵핑 정보를 역추적하여 링크를 자동 생성합니다!
        WebMvcLinkBuilder selfLink = linkTo(methodOn(OrderController.class).getOrder(id));
        entityModel.add(selfLink.withSelfRel());

        // 3. 비즈니스 상태에 따른 동적 링크 추가
        if (order.getStatus().equals("PAYMENT_COMPLETED")) {
            // 결제 완료 상태면 "주문 취소" 링크를 꽂아준다!
            WebMvcLinkBuilder cancelLink = linkTo(methodOn(OrderController.class).cancelOrder(id));
            entityModel.add(cancelLink.withRel("cancel-order"));
        }

        return entityModel;
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> cancelOrder(@PathVariable Long id) { /* 취소 로직 */ return ResponseEntity.ok().build(); }
}

3. HATEOAS의 치명적 단점: 오버엔지니어링의 경계

로이 필딩 아저씨는 기뻐하겠지만, 실무(스타트업~중견)에서 HATEOAS를 도입하는 기업은 생각보다 극소수입니다. "오버엔지니어링(Over-engineering)"이기 때문입니다. 모든 엔티티와 리스트 응답마다 링크를 만들어주는 코드를 덕지덕지 발라야 하고, JSON 응답 크기도 무거워집니다. 요즘은 Swagger(Springdoc)나 포스트맨 같은 API 문서화 툴이 너무 잘 되어 있어서 프론트엔드가 그냥 문서 보고 URL을 하드코딩하는 것이 개발 속도 측면에서 훨씬 빠르기 때문입니다.


4. 마무리 및 주요 발전 방향

지금까지 클라이언트와 서버의 강한 결합(URL 하드코딩)을 끊어내고, 서버가 스스로 다음 상태를 지시하는 진정한 REST API 성숙도 최고 레벨(Level 3)의 경지, Spring HATEOAS의 철학과 실무 구현법에 대해 상세히 다루어 보았습니다. 비록 실무 채택률은 낮더라도, 이를 이해하고 있는 개발자는 아키텍처 토론에서 압도적인 통찰력을 뽐낼 수 있습니다.

REST API에 대한 고찰을 마쳤으니, 이번엔 REST API 자체의 치명적인 한계를 극복하는 방법을 살펴보겠습니다. 모바일 앱 팀에서 화를 냅니다. "야! 나는 유저의 '이름' 딱 하나만 화면에 그리면 되는데, 네가 만든 GET /users/1 API는 주소, 생일, 구매 내역까지 쓸데없는 1MB짜리 JSON을 통째로 다 내려주잖아! 우리 앱 데이터 요금 많이 나오면 네가 낼래?" 이 끔찍한 오버패칭(Over-fetching) 문제를 극복하기 위해, 프론트엔드가 "나 유저 이름만 필요해!"라고 쿼리(요청)하면 정확히 이름만 발라내서 던져주는 혁명적인 통신 기술이 등장했습니다.

 

다음 글에서는 "REST API 시대의 종말을 선언하다!" 페이스북(Meta)이 창조한 유연함의 극치, Spring Boot와 GraphQL(그래프QL) 연동 완벽 가이드에 대해 작성하겠습니다.