이전 포스팅에서 코드 레벨에서의 동시성 처리에 대해서 학습했다.
각 서버에서가 아닌? 하나의 DB에 동시에 접근해서 CRUD 한다면?
앞선 동시성 이슈가 DB에서 발생하게 되고, 이는 데이터의 정합성을 보장해줄수 없게된다.

unlocked DBpackage com.example.threadsafetest;
import com.example.threadsafetest.people.People;
import com.example.threadsafetest.people.PeopleRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Transactional
public class HelloService {
final private PeopleRepository peopleRepository;
public void plusNumber() {
People people = peopleRepository.findPeopleByName("jun");
people.setCount(people.getCount() + 1);
}
public int getNumber() {
return peopleRepository.findPeopleByName("jun").getCount();
}
}
1000째 응답

DB에 접근하니 더 난리이다
접근해서 값을 가져오고 값을 쓰는데 훨신 더 많은 시간이 소요되기 때문이다 !
그때 이미 가져온 값을 동시에 쓰게되고 숫자들이 밀리게 되는 현상이 발생한다 !
unlocked DB with synchronizedpackage com.example.threadsafetest;
import com.example.threadsafetest.people.People;
import com.example.threadsafetest.people.PeopleRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Transactional
public class HelloService {
final private PeopleRepository peopleRepository;
public synchronized void plusNumber() {
People people = peopleRepository.findPeopleByName("jun");
people.setCount(people.getCount() + 1);
}
public synchronized int getNumber() {
return peopleRepository.findPeopleByName("jun").getCount();
}
}
처리를 해줬음에도 불구하고 500이 조금 넘는 값이 나온것을 볼수있다

Q. 그치만 Transactional을 통해서 트랜잭션이 보장된다면 -> 메소드가 끝나기 전에 트랜잭션도 끝나는게 아닌가?
아니였다.
synchronized plusNumber() 메소드가 실행되고 메소드가 끝날때, @Transactional이 관리된다. 즉 메소드가 끝나면 트랜잭션이 커밋된다.
synchronized -> JVM 영역
@Transactional -> JDBC(JPA) DB 영역
이처럼 메소드 레벨이 끝나고 JDBC의 영역에서 일어나는 DB의 IO는 아예 다른 영역이기 때문이다.
따라서 DB의 Lock과 트랜잭션의 관리가 필요하다 !~!~!~!
먼저 DB 동시성을 위해 간단히 지식 정리를 해보자
DB에서 동시성을 처리하려면
이 두가지가 정말 중요하다 !
트랜잭션은
ex) one 이라는 사람이 two 라는 사람에게 2000원을 입금한다고 했을때
one 계좌에서 -2000
two 계좌에서 +2000
이 과정이 두개다 성공했을때 Commit
하나라도 실패하면 Rollback을 진행한다.
그렇지 않으면 계좌 시스템에서 큰 에러가 발생한다 !
Lock은
ex) one 이라는 사람, two 라는 사람 각 각 three 에게 1000씩 입금한다고 해보자 !
one도 기존 0 + 1000 반영 → 총 1000
two도 기존 0 + 1000 반영 → 총 1000
따라서 두명이 입금했음에도
three의 계좌에는 총 1000이 있는 문제점이 생긴다.
이때 Lock을 걸어 순서를 보장해줄수 있다.
DB의 Lock을 거는것은 두가지 방법이 존재한다.
첫번째는
optimistic lock (낙관적 락)
package com.example.threadsafetest.people;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
public class People {
@Id
@GeneratedValue
@Column(name = "people_id")
private Long id;
private String name;
private Integer count;
public People(String name, Integer count) {
this.name = name;
this.count = count;
}
@Version
private Long version;
}
version을 추가하여 DB의 버전을 관리한다 !
Entity에 @Version을 추가하여 DB에 Version 컬럼을 넣어준다.

이후, JPA가 데이터를 CRUD할때 버전이 겹치면 Execption을 던져준다.
Read 로직이 많을때 적합하며, 버전 충돌시 예외 처리를 통해 시스템 안정화를 구현할 수 있다.

지속해서 발생하는 버전 에러로 요청이 이뤄지지 않는다
StaleObjectStateException
트랜잭션 커밋 단계에서 버전이 다를때 발생하는 에러이다.

에러 처리를 통해 해당 트랜잭션에 다시 접근해보겠다.
@Transactional
public void plusNumber() {
boolean updated = false;
while (!updated) {
try {
People people = peopleRepository.findPeopleByName("jun");
people.setCount(people.getCount() + 1);
peopleRepository.save(people);
updated = true;
} catch (OptimisticLockingFailureException e) {
// 충돌이 발생하면 루프를 돌면서 재시도
System.out.println("Optimistic lock exception, retrying...");
}
}
}
동시성 에러 발생시 DB 접근을 지속적으로 재시도해도 문제는 해결되지 않는다 !
이번에는 두번째 방법인 비관적 락을 시도해보자
비관적 락

비관적락은 다음과 같이
@Lock(LockModeType.PESSIMSTIC_READ) 어노테이션을 통해 락 설정이 가능하다.
참고로 Mode에는
PESSIMISTIC_READ
다른 트랜잭션에게 읽기만을 허용
PESSIMISTIC_WRITE
다른 트랜잭션에서 쓰지도 읽지도 X
PESSIMISTIC_FORCE_INCREMENT
잠금을 걺과 동시에 버전을 증가
내 로직에서는 데이터를 write 할때도 read 하면 안되기에 (가장 최근의 commit된 데이터를 조회 해와야 한다) PESSIMISTIC_WRITE을 선택했다.
package com.example.threadsafetest;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Scope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController()
@RequestMapping("hello")
@RequiredArgsConstructor
public class HelloController {
final private HelloService helloService;
@GetMapping()
public Integer hello() {
for (int i = 0; i < 5; i++) {
helloService.plusNumber();
}
return helloService.getNumber();
}
}
package com.example.threadsafetest;
import com.example.threadsafetest.people.People;
import com.example.threadsafetest.people.PeopleRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class HelloService {
final private PeopleRepository peopleRepository;
@Transactional
public void plusNumber() {
People people = peopleRepository.findPeopleByName("jun");
people.setCount(people.getCount() + 1);
peopleRepository.save(people);
}
@Transactional
public int getNumber() {
return peopleRepository.findPeopleByName("jun").getCount();
}
}

정상적으로 모든 요청이 처리된것을 확인했다.
"jun"이라는 이름을 가진 컬럼에 Lock을 걸었기 때문에 트랜잭션이 commit or rollback 되기 전에는 해당 컬럼에 접근이 불가능하다.
따라서 비관적락을 사용하면 데이터의 정합성은 보존된다.
이전 포스팅에서 진행했던
시스템 단계의 ConcurrentHahMap의 시간과 DB단계에서 처리한 비관적락의 시간을 비교해보겠다.
시스템 단계의 ConcurrentHahMap


DB 단계의 Lock


총 시간만 확인하더라고 약 500정도 시간 차이가 발생한다.
그만큼 DB 접근 시간 + 비관적락 시간은 훨신 더 긴 시간이 소요된다.
결론
트랜잭션이 보장되고 데드락과 성능 저하 부분을 고려해야 하지만 !
가장 중요한 데이터의 정합성은 유지가 된다.
항상 trade off를 고민하며 학습해야 한다.
추가적으로 왜 코드 로직을 최적화 해야하는지 깨달았다
+1을 5번 하는거 보단 +5 하는것이 동시성 관점에서 훨신 안정적이고 빠르다.
또한 하나의 트랙잭션 내에서 처리 하는것이 좋다.
예를들어 조회 로직이 이미 있다고 해도, 기존 로직에서 return 값을 바로 넘겨주며 하나의 트랜잭션 단위에서 처리한다면 더 안정적이다.
당연한 이야기지만 직접 로직을 작성하고, Test에 실패하며 실습으로 다뤘기에 몸소 체감이 가능했다.
동시성 로직을 구현할 기회가 생긴다면 꼭 도전해보고싶다.
참조 -
JPA-잠금의-종류