“저장했는데 내 수정이 사라졌어요”
어느 날 담당자에게서 연락이 왔다.
“프로젝트 정보를 수정하고 저장했는데, 새로고침하니까 원래대로 돌아가 있어요.”
처음에는 버그인 줄 알았다. 코드를 봤지만 로직은 정상이었다. 저장도 되고, DB에도 반영된다. 그런데 로그를 자세히 들여다보니 원인이 보였다.
[14:32:01] 사용자 A: 프로젝트 "농장관리" 조회 (version=1) [14:32:03] 사용자 B: 프로젝트 "농장관리" 조회 (version=1) [14:32:10] 사용자 A: 이름을 "스마트농장"으로 수정 → 저장 성공 [14:32:15] 사용자 B: 상태를 "완료"로 수정 → 저장 성공
A가 이름을 바꿨고, 5초 뒤에 B가 상태를 바꿨다. 둘 다 “저장 성공”이었다. 그런데 B가 저장할 때 A의 수정 사항을 덮어써 버린 것이다. B는 version=1 기준의 데이터를 통째로 저장했으니, A가 바꾼 이름은 사라졌다.
이것이 ‘Lost Update(갱신 손실)’ 문제다. 두 사용자가 동시에 같은 데이터를 수정할 때, 나중에 저장한 쪽이 먼저 저장한 쪽의 변경을 덮어쓰는 현상이다.
‘@Transactional을 붙이면 해결되는 거 아니야?’
그렇지 않다. 트랜잭션은 ‘하나의 작업 단위’를 보장할 뿐, ‘두 사용자의 동시 수정’까지 자동으로 해결하지 않는다. 이 글에서는 트랜잭션의 기본을 짚은 뒤, 동시성 문제를 어떻게 제어하는지 실무 관점에서 정리한다.

@Transactional은 어디까지 보장하는가
동시성 문제를 다루기 전에, 트랜잭션의 기본부터 짧게 짚자.
트랜잭션(Transaction)이란, 여러 DB 작업을 하나의 단위로 묶는 것이다. “전부 성공하거나, 전부 실패하거나.” 중간에 하나가 실패하면 전부 원래대로 되돌린다(롤백).
@Transactional
public void transferMoney(Long fromId, Long toId, int amount) {
Account from = accountRepository.findById(fromId).orElseThrow();
Account to = accountRepository.findById(toId).orElseThrow();
from.withdraw(amount); // 출금
to.deposit(amount); // 입금
// 둘 다 성공해야 커밋. 하나라도 실패하면 둘 다 롤백.
}
Spring에서는 @Transactional 어노테이션 하나로 트랜잭션을 관리한다. 이 메서드 안의 모든 DB 작업이 성공하면 커밋, 하나라도 예외가 나면 롤백된다.
ACID라는 트랜잭션의 4가지 속성을 들어본 적이 있을 것이다. 간단히 정리하면 이렇다.
| 속성 | 의미 | 한 줄 설명 |
|---|---|---|
| Atomicity (원자성) | 전부 아니면 전무 | 출금만 되고 입금이 안 되는 건 없다 |
| Consistency (일관성) | 규칙을 위반하지 않음 | 잔액이 음수가 되면 안 된다 |
| Isolation (격리성) | 동시 실행이 서로 간섭하지 않음 | 두 트랜잭션이 서로의 중간 상태를 보지 않는다 |
| Durability (지속성) | 커밋되면 영구 보존 | 서버가 꺼져도 데이터는 살아 있다 |
여기서 핵심은 세 번째, ‘격리성(Isolation)’이다. 이론적으로는 트랜잭션끼리 서로 완벽히 격리되어야 하지만, 현실에서는 성능을 위해 격리 수준을 조절한다. 그리고 이 격리 수준만으로 해결되지 않는 문제가 있다. 그것이 바로 ‘동시성 제어’다.
@Transactional에서 자주 하는 실수
동시성으로 넘어가기 전에, @Transactional 자체에서 발생하는 흔한 실수를 먼저 짚자. 이것만 알아도 실무에서 시간을 많이 아낀다.
실수 1: 같은 클래스 안에서 호출하면 트랜잭션이 안 걸린다
@Service
public class ProjectService {
public void outerMethod() {
innerMethod(); // @Transactional이 안 걸림!
}
@Transactional
public void innerMethod() {
// DB 작업...
}
}
Spring의 @Transactional은 프록시 기반이다. 외부에서 호출해야 프록시를 거쳐서 트랜잭션이 적용된다. 같은 클래스 안에서 this.innerMethod()로 호출하면 프록시를 우회하므로 트랜잭션이 걸리지 않는다.
해결법: 트랜잭션이 필요한 메서드는 별도의 서비스 클래스로 분리하거나, outerMethod에 @Transactional을 붙인다.
실수 2: 예외 종류에 따라 롤백이 안 된다
@Transactional
public void createProject(ProjectDto dto) {
projectRepository.save(dto.toEntity());
if (someCondition) {
throw new IOException("파일 오류"); // 체크 예외 → 롤백 안 됨!
}
}
Spring의 @Transactional은 기본적으로 RuntimeException(언체크 예외)에서만 롤백한다. IOException 같은 체크 예외는 롤백하지 않는다. 체크 예외에서도 롤백하려면 명시해야 한다.
@Transactional(rollbackFor = Exception.class)
실수 3: 읽기 전용 트랜잭션
조회만 하는 메서드에는 readOnly를 붙이면 성능이 좋아진다. Hibernate가 변경 감지(dirty checking)를 생략하고, DB도 읽기 최적화를 적용할 수 있다.
@Transactional(readOnly = true)
public List<Project> getProjects() {
return projectRepository.findAll();
}
동시성 문제: 트랜잭션만으로는 부족한 이유
서론에서 봤던 Lost Update를 다시 떠올려 보자.
시간순서: 1. 트랜잭션 A: 프로젝트 조회 (이름="농장관리", 상태="진행중") 2. 트랜잭션 B: 프로젝트 조회 (이름="농장관리", 상태="진행중") 3. 트랜잭션 A: 이름을 "스마트농장"으로 수정 → 커밋 4. 트랜잭션 B: 상태를 "완료"로 수정 → 커밋 → A의 이름 변경이 사라짐!
두 트랜잭션 모두 정상적으로 커밋되었다. ACID의 원자성도 깨지지 않았다. 하지만 결과적으로 A의 변경은 사라졌다. 왜?
PostgreSQL의 기본 격리 수준은 ‘READ COMMITTED’다. 이 수준에서는 “다른 트랜잭션이 커밋한 데이터만 볼 수 있다”는 것만 보장한다. 두 트랜잭션이 같은 데이터를 동시에 수정하는 것 자체를 막지는 않는다.
격리 수준을 올리면(REPEATABLE READ, SERIALIZABLE) 일부 문제를 방지할 수 있지만, 성능 비용이 크고 데드락 위험이 높아진다. 그래서 실무에서는 격리 수준을 올리기보다, ‘락(Lock)’을 사용해서 동시성을 제어한다.
낙관적 락 vs 비관적 락
동시성 문제의 핵심 해결책이다. 이 둘의 차이를 이해하면, 실무에서 어떤 상황에 어떤 락을 써야 하는지 판단할 수 있다.
낙관적 락(Optimistic Lock): “충돌은 드물 것이다. 발생하면 그때 처리하자”
충돌이 자주 일어나지 않는다고 가정하고, 저장하는 시점에 충돌을 감지한다. JPA에서는 @Version 어노테이션으로 구현한다.
@Entity
public class Project {
@Id @GeneratedValue
private Long id;
private String name;
private String status;
@Version // 낙관적 락의 핵심
private Long version;
}
@Version을 붙이면, JPA가 UPDATE 시 자동으로 버전을 체크한다.
-- JPA가 생성하는 SQL UPDATE project SET name = '스마트농장', version = 2 WHERE id = 1 AND version = 1
만약 다른 트랜잭션이 이미 version을 2로 올렸다면, WHERE 조건이 맞지 않아 업데이트 행 수가 0이 된다. JPA는 이를 감지하고 ‘OptimisticLockException’을 던진다.
시간순서 (낙관적 락 적용 후): 1. 트랜잭션 A: 조회 (version=1) 2. 트랜잭션 B: 조회 (version=1) 3. 트랜잭션 A: 수정 → UPDATE ... WHERE version=1 → 성공 (version=2) 4. 트랜잭션 B: 수정 → UPDATE ... WHERE version=1 → 실패! (version 불일치) → OptimisticLockException 발생 → "다른 사용자가 수정했습니다. 다시 시도해 주세요."
// 예외 처리 예시
@PutMapping("/api/projects/{id}")
public ResponseEntity<?> updateProject(@PathVariable Long id,
@RequestBody ProjectUpdateDto dto) {
try {
projectService.update(id, dto);
return ResponseEntity.ok().build();
} catch (OptimisticLockException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body("다른 사용자가 이미 수정했습니다. 새로고침 후 다시 시도해 주세요.");
}
}
비관적 락(Pessimistic Lock): “충돌이 일어날 것이다. 미리 잠그자”
충돌이 자주 일어날 것으로 예상되면, 조회 시점부터 해당 행을 잠근다. 다른 트랜잭션은 락이 풀릴 때까지 대기한다.
public interface ProjectRepository extends JpaRepository<Project, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Project p WHERE p.id = :id")
Optional<Project> findByIdForUpdate(@Param("id") Long id);
}
-- 실행되는 SQL (PostgreSQL) SELECT * FROM project WHERE id = 1 FOR UPDATE
FOR UPDATE가 붙으면, 이 트랜잭션이 커밋하거나 롤백할 때까지 다른 트랜잭션은 해당 행을 수정할 수 없다. 대기하다가 락이 풀리면 그때 진행한다.
시간순서 (비관적 락 적용): 1. 트랜잭션 A: SELECT ... FOR UPDATE → 행 잠김 2. 트랜잭션 B: SELECT ... FOR UPDATE → 대기 (A가 끝날 때까지) 3. 트랜잭션 A: 수정 → 커밋 → 락 해제 4. 트랜잭션 B: 이제 진행 → 최신 데이터 기준으로 수정 → 커밋
어떤 락을 써야 하는가
| 상황 | 추천 | 이유 |
|---|---|---|
| 충돌이 드문 경우 (일반 CRUD) | 낙관적 락 | 성능 좋음, 대기 없음 |
| 충돌이 잦은 경우 (재고, 포인트) | 비관적 락 | 확실한 동시성 보장 |
| 읽기가 많고 수정이 드문 경우 | 낙관적 락 | 대부분 충돌 없이 통과 |
| 금융/결제 같은 정확성 필수 | 비관적 락 | 데이터 정합성 최우선 |

실무 조언
1. 데드락에 주의하라
비관적 락을 쓸 때 가장 무서운 것이 데드락(Deadlock)이다. 두 트랜잭션이 서로 상대방의 락을 기다리면서 영원히 멈추는 상태다.
트랜잭션 A: 프로젝트 1 잠금 → 프로젝트 2 잠금 대기 트랜잭션 B: 프로젝트 2 잠금 → 프로젝트 1 잠금 대기 → 서로 영원히 대기 (데드락!)
PostgreSQL은 데드락을 자동 감지하고 한쪽을 강제 롤백한다. 하지만 예방이 더 좋다.
- 락을 거는 순서를 일관되게 유지한다 (항상 ID 오름차순으로 잠금)
- 트랜잭션을 가능한 짧게 유지한다
- 비관적 락에 타임아웃을 설정한다
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) // 3초 대기 후 포기
@Query("SELECT p FROM Project p WHERE p.id = :id")
Optional<Project> findByIdForUpdate(@Param("id") Long id);
2. 낙관적 락의 재시도 전략
낙관적 락에서 OptimisticLockException이 발생하면, 단순히 에러를 보여주는 것보다 자동 재시도가 더 나을 때도 있다.
@Retryable(value = OptimisticLockException.class, maxAttempts = 3)
@Transactional
public void updateWithRetry(Long id, ProjectUpdateDto dto) {
Project project = projectRepository.findById(id).orElseThrow();
project.update(dto);
}
다만 모든 상황에서 자동 재시도가 적합한 건 아니다. 사용자가 직접 편집한 내용이 포함된 경우, 자동 재시도보다 “충돌 알림 + 새로고침 유도”가 더 안전하다.
3. 실무 체크리스트
| 점검 항목 | 확인 |
|---|---|
| @Transactional이 프록시를 통해 호출되는가? | self-invocation 주의 |
| 체크 예외에서 롤백이 필요한가? | rollbackFor 설정 확인 |
| 읽기 전용 메서드에 readOnly=true를 붙였는가? | 성능 최적화 |
| 동시 수정이 가능한 엔티티에 @Version이 있는가? | Lost Update 방지 |
| 비관적 락 사용 시 락 순서가 일관되는가? | 데드락 방지 |
| 비관적 락에 타임아웃이 설정되어 있는가? | 무한 대기 방지 |
마치며: “트랜잭션”만으로는 부족하다
정리하면 이렇다.
- @Transactional은 ‘하나의 작업 단위’를 보장하지, ‘동시 수정’을 막지 않는다.
- 동시 수정 문제(Lost Update)는 락으로 해결한다.
- 충돌이 드물면 낙관적 락(@Version), 충돌이 잦으면 비관적 락(FOR UPDATE).
- 비관적 락을 쓸 때는 데드락과 타임아웃을 반드시 고려한다.
다음 글에서는 API 문서화 이야기를 다뤄보려 한다. 팀원이 “이 API 파라미터가 뭐예요?” 하고 물어볼 때마다 코드를 열어서 설명하는 건 한계가 있다. Swagger에서 SpringDoc으로 넘어간 이유와, API 문서를 자동으로 생성하는 방법을 알아보겠다.