티스토리 뷰
오늘은 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)은 데이터 무결성이 중요한 상황에서 빛을 발합니다. 이 포스팅의 예제를 통해 두 락의 동작을 이해하고 프로젝트 상황에 적합한 방법을 선택하면 될 것 같습니다. 감사합니다.
'프레임워크 > SpringBoot' 카테고리의 다른 글
[SpringBoot] Supplier 와 Consumer 의 활용 (0) | 2025.09.11 |
---|---|
[SpringBoot] Redis와 EHCache 함께 사용하는 방법 (0) | 2025.09.03 |
[JPA] 더티 체킹이란? (2) | 2025.08.26 |
[SpringBoot] MyBatis 다중 Datasource 적용하기(@Qualifier) - Mapper 경로 공유 (0) | 2025.04.09 |
[SpringBoot] HTTP 요청 다루기(@RequestBody, @RequestParam, @ModelAttribute) (0) | 2025.04.08 |
- Total
- Today
- Yesterday