티스토리 뷰

오늘은 SpringBoot 와 JPA를 사용해 데이터 동시성 문제를 해결하는 두 가지 방법, 낙관락비관락에 대해 알아보겠습니다. 동시성 제어는 여러 사용자가 동일한 데이터를 동시에 수정할 때 충돌을 방지하는 데 사용됩니다. 이번 포스팅에선 낙관락과 비관락의 의미와 특징을 간단한 예제를 통해 알아보겠습니다.


1. 낙관락(Optimistic Locking)

의미

낙관락은 데이터 충돌이 드물다고 가정하고, 데이터 수정 시 버전 번호를 확인해 충돌을 감지하는 방식입니다. JPA에서는 @Version 어노테이션으로 구현됩니다.

특징

  • 엔티티에 @Version 필드를 추가해 버전 관리
  • 수정 시 DB의 버전과 엔티티의 버전이 일치하는지 확인
  • 충돌 발생 시 OptimisticLockException 이 발생해 데이터 무결성 보호
  • 장점: 락을 걸지 않아 성능이 좋고, 데드락 위험이 없음
  • 단점: 충돌 빈도가 높으면 예외 처리와 재시도가 필요

사용처

  • 동시 수정이 드문 경우(예: 사용자 프로필 수정, 게시글 편집)

주의사항

  • OptimisticLockException 처리 로직(재시도 또는 사용자 알림) 필요
  • 버전 필드는 JPA가 자동 관리하므로 수정 금지
  • DB 테이블에 version 컬럼이 존재해야 함

2. 비관락(Pessimistic Locking)

의미

비관락은 데이터 충돌이 자주 발생한다고 가정하고, 데이터에 락을 걸어 다른 트랜잭션이 접근하지 못하게 합니다. JPA에서는 LockModeType.PESSIMISTIC_WRITE 등을 사용합니다.

특징

  • 데이터 조회 시 DB에 락을 걸어 다른 트랜잭션의 수정 차단
  • 락은 트랜잭션 종료 시 해제
  • 장점: 충돌을 원천적으로 방지해 데이터 무결성 보장
  • 단점: 락으로 인해 성능 저하, 데드락 가능성 존재

사용처

  • 충돌 가능성이 높은 경우(예: 재고 관리, 예약 시스템)

주의사항

  • 락 유지 시간 최소화(트랜잭션 짧게 유지)
  • 데드락 방지를 위해 락 순서 일관성 유지
  • DB가 락을 지원해야 함

3. 예제

간단한 Employee 엔티티를 사용해 낙관락과 비관락을 구현합니다. 직원의 급여를 수정하는 시나리오로, 동시성 충돌을 테스트합니다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
description = 'OptimisticLock'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot Starters
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // PostgreSQL
    implementation 'org.postgresql:postgresql'

    // Lombok
    annotationProcessor 'org.projectlombok:lombok'
    compileOnly 'org.projectlombok:lombok'

    // Testing
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

application.yml

server:
  port: 1220

spring:
  datasource:
    url: jdbc:postgresql://127.0.0.1:5432/mydb
    username: myuser
    password: 1234
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: true

Employee.java

package com.example.optimisticlock.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.*;

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Table(name = "employee")
public class Employee {

  @Id
  private Long id;
  private String name;
  private int salary;

  @Version
  private Long version; // 낙관락을 위한 버전 필드(테이블 컬럼 추가 필수)
}

EmployeeRepository.java

package com.example.optimisticlock.repository;

import com.example.optimisticlock.entity.Employee;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {

  // LockModeType
  // 1. NONE : 기본값, 락을 사용하지 않음
  // 2. OPTIMISTIC : 낙관락, 읽기 작업에 사용, 버전 필드 체크
  // 3. OPTIMISTIC_FORCE_INCREMENT : 낙관락, 읽기 작업에 사용, 버전 필드 체크 및 버전 증가
  // 4. PESSIMISTIC_READ : 비관락, 읽기 작업에 사용, 다른 트랜잭션이 쓰기 작업을 못하게 막음
  // 5. PESSIMISTIC_WRITE : 비관락, 쓰기 작업에 사용, 다른 트랜잭션이 읽기/쓰기 작업을 못하게 막음
  // 6. PESSIMISTIC_FORCE_INCREMENT : 비관락, 쓰기 작업에 사용, 다른 트랜잭션이 읽기/쓰기 작업을 못하게 막고 버전 증가
  // 7. READ : 공유락, 읽기 작업에 사용, 다른 트랜잭션이 쓰기 작업을 못하게 막음
  // 8. WRITE : 배타락, 쓰기 작업에 사용, 다른 트랜잭션이 읽기/쓰기 작업을 못하게 막음

  // 비관락용 메서드
  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query("SELECT e FROM Employee e WHERE e.id = :id")
  Optional<Employee> findByIdWithPessimisticLock(Long id);

  // 낙관락용은 기본 findById() 메서드 사용
}

EmployeeService.java

package com.example.optimisticlock.service;

import com.example.optimisticlock.entity.Employee;
import com.example.optimisticlock.repository.EmployeeRepository;
import jakarta.persistence.OptimisticLockException;
import jakarta.persistence.PessimisticLockException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class EmployeeService {
  private final EmployeeRepository employeeRepository;

  @Transactional
  public String saveEmployee(Long id, String name, int salary) {
    Employee employee = Employee.builder()
      .id(id)
      .name(name)
      .salary(salary)
      .build();

    return employeeRepository.save(employee).getName();
  }

  // 낙관락: 기본 findById 사용 (락 없음)
  @Transactional
  public Employee updateSalaryOptimistic(Long id, int newSalary) throws OptimisticLockException {
    Employee employee = employeeRepository.findById(id)
      .orElseThrow(() -> new IllegalArgumentException("Employee not found"));
    employee.setSalary(newSalary);
    return employeeRepository.save(employee);
  }

  // 비관락: findByIdWithPessimisticLock 사용 (락 적용)
  @Transactional
  public Employee updateSalaryPessimistic(Long id, int newSalary) throws PessimisticLockException {
    Employee employee = employeeRepository.findByIdWithPessimisticLock(id)
      .orElseThrow(() -> new IllegalArgumentException("Employee not found"));
    employee.setSalary(newSalary);
    return employeeRepository.save(employee);
  }
}

EmployeeController.java

package com.example.optimisticlock.controller;

import com.example.optimisticlock.entity.Employee;
import com.example.optimisticlock.service.EmployeeService;
import jakarta.persistence.OptimisticLockException;
import jakarta.persistence.PessimisticLockException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/employee")
public class EmployeeController {
  private final EmployeeService employeeService;

  // 초기 데이터 생성
  @PostMapping
  public String createEmployee(@RequestBody Employee employee) {
    return employeeService.saveEmployee(employee.getId(), employee.getName(), employee.getSalary());
  }

  // 낙관락: 급여 수정
  @PutMapping("/{id}/salary/optimistic")
  public ResponseEntity<Employee> updateSalaryOptimistic(@PathVariable Long id, @RequestParam int salary) {
    try {
      Employee updated = employeeService.updateSalaryOptimistic(id, salary);
      return ResponseEntity.ok(updated);
    } catch (OptimisticLockException e) {
      return ResponseEntity.status(409).body(null); // 충돌 시 409 Conflict
    }
  }

  // 비관락: 급여 수정
  @PutMapping("/{id}/salary/pessimistic")
  public ResponseEntity<Employee> updateSalaryPessimistic(@PathVariable Long id, @RequestParam int salary) {
    try {
      Employee updated = employeeService.updateSalaryPessimistic(id, salary);
      return ResponseEntity.ok(updated);
    } catch (PessimisticLockException e) {
      return ResponseEntity.status(409).body(null); // 락 충돌 시 409 Conflict
    }
  }
}
  • 핵심구성
    • 엔티티: Employee에 @Version 필드 추가
    • 서비스: updateSalary 메서드에서 급여 수정 시 낙관락/비관락 적용
    • 컨트롤러: 급여 수정 요청 처리, 충돌 시 오류 발생
  • 동작
    • 초기 데이터 생성: POST /api/employee 로 직원 저장
    • 급여 수정
      • 낙관락: PUT /api/employee/1/salary/optimistic?salary=2000
      • 비관락: PUT /api/employee/1/salary/pessimistic?salary=3000

4. 테스트 방법

Postman 과 JMeter 를 이용한 테스트 방법에 대해 간략히 알아보겠습니다.

4.1. Postman

  • Http Request 를 만들고 Collection 설정 > Run 을 클릭합니다.

  • 항목을 선택한 후, 반복할 횟수(Iterations) 를 작성합니다.
  • 그 후, "Run" 버튼을 클릭하여 메서드를 호출합니다.

  • 필자의 경우, Delay 를 없애고 여러 번 반복하여도 Postman으로 Exception 을 발생시키진 못했습니다.

 

4.2. Apache JMeter

Apache JMeter 는 오픈소스 성능/부하 테스트 도구로, 여러 프로토콜을 시뮬레이션해서 동시 사용자, 처리량, 지연시간을 측정하고 병목을 찾을 수 있습니다. 아래 경로에서 zip 파일로 다운로드할 수 있습니다. (Java 8+ 필수)

https://jmeter.apache.org/download_jmeter.cgi#binaries

  • cmd 창을 열고 설치한 경로로 이동 후 , jmeter 명령어를 입력하면 실행됩니다.

  • Test Plan 우클릭 > Add > Threads (Users) > Thread Group 을 클릭하여 그룹을 만듭니다.

  • Thread Group 우클릭 > Add > Sampler > Http Request 를 클릭하여 Request 를 만듭니다.

  • Name 과 Http Request 부분을 작성합니다.

  • 응답 결과를 보기 위해 리스너를 추가합니다. Request 우클릭 > Add > Listener > View Results Tree
  • 다양한 리스너들이 있으니 상황에 맞게 추가하면 됩니다.

  • 같은 메서드를 내용만 다르게 하여 복사하고 Thread Group 의 users 와 Loop Count 속성을 작성합니다.

  • Thread Group 우클릭 > Start 를 누르면 실행됩니다.

  • View Results Tree 를 클릭하여 오류를 확인합니다.
Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)

5. 마무리

JPA의 낙관락과 비관락은 동시성 제어의 강력한 도구입니다. 낙관락(@Version)은 성능을 중시하는 환경에서, 비관락(@Lock)은 데이터 무결성이 중요한 상황에서 빛을 발합니다. 이 포스팅의 예제를 통해 두 락의 동작을 이해하고 프로젝트 상황에 적합한 방법을 선택하면 될 것 같습니다. 감사합니다.

 

최근에 올라온 글
Total
Today
Yesterday