개요
이번 글은 동시성 문제 해결 전략에 대한 이야기다.
사이드 프로젝트에서 약관 도메인의 생성 및 수정 API를 구현하면서,
동시성 이슈가 발생할 수 있는 지점이 존재했다.
Spring Data JPA는 이를 해결하기 위한 강력한 기능을 제공한다.
어쩌면 이 글은 그냥 `@Version` 하나로 끝낼 수도 있었을 것이다.
하지만 나는 단순히 동시성 문제를 "해결"하는 데서 멈추고 싶지 않았다.
> "이 도메인에서는 어떤 방식이 가장 합리적일까?"
이 질문을 던지며,
도메인의 특성과 시스템의 본질을 기준으로 기술 선택을 내리고자 했다.
이번 글에서는
"기술보다 중요한 것이 무엇인가"에 대한 고민을 정리해보려 한다.
문제 인식
먼저 약관 도메인 특성에 대해 잠시 설명하고자 한다.
(약관 도메인 분석에 대해서는 "기능 분석: 약관 도메인" 글에 자세히 정리해 두었다)
약관이라는 도메인은 회원 가입 절차에 필수적인 요소로,
아래와 같이 "이용약관, "마케팅 알림 수신 동의" 등 여러 종류가 존재한다.
그리고 아래와 같이 각 약관마다 "시행일"이라는 약관 고유의 이력들이 존재한다
필자는 이렇게 같은 종류의 약관에 따라 이력을 관리하는 것을,
"약관 버전"을 관리하는 것으로 설계를 했다.
그렇기 때문에 같은 약관 종류 기준으로 고유의 버전 정보를 갖고 있어야 하며,
약관 버전은 이전 버전에서 1씩 증가하도록 관리한다.
그런데 새로운 종류의 약관을 등록할 때,
"두 명 이상의 관리자가 약관 정보를 동시에 등록한다면?"
혹은 기존의 약관 정보를 수정할 때,
"두 명 이상의 관리자가 약관 정보를 동시에 수정한다면?"
Spring은 기본적으로 성능을 위해 멀티스레드로 작동하기 때문에,
여러 스레드에서 동시에 같은 약관 정보를 생성하거나, 수정한다면,
잘못된 약관 버전으로 등록되는 사고가 발생될 수 있다.
따라서 약관 도메인은 단순한 텍스트 저장이 아니라,
시간에 따른 정확한 이력 관리와 버전 정합성이 핵심인 도메인이었다.
필자는 과거 Java의 다양한 Lock 전략에 대해 깊이 있게 학습하면서,
"기술적 성능보다, 상황에 맞는 선택이 중요하다"는 걸 느꼈다.
이 경험은 이번 약관 도메인에서도 다시 떠올랐다.
(Lock 관련된 학습 내용은 "JVM에서의 Lock" 글에서 확인할 수 있다)
(Lock 성능 고려는 "Lock 성능테스트" 글에서 CSW 수치까지 검증한 내역을 확인해 볼 수 있다)
또한, 단순히 “Lock을 쓴다”는 선택만으로는 부족했다.
이 도메인이 가진 본질과, 락 전략 사이의 균형을 고민해야 했다.
`@Version` 활용 (낙관적 락)
JPA에 익숙하지 않았던 지라,
`@Version` 어노테이션을 이번 사이드 프로젝트를 수행하며 알게 되었다.
사용 방법은 매우 간단했다.
이번에 처음 학습하게 된 내용이니, `@Version`에 대해 간단히 정리하고자 한다.
`@Version` 이란?
JPA에서 낙관적 락(Optimistic Lock)을 지원하기 위한 어노테이션이다.
특정 필드에 @Version을 지정하면, JPA는 해당 필드를 이용해 버전 충돌 여부를 자동으로 감지한다.
어떻게 동작하는가?
예를 들어 아래와 같이 엔티티를 설계했다고 가정하자.
@Entity
public Terms{ //약관
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version
private Integer rowVersion; // 버전 역할
@Getter
@Column(nullable = false)
private String code; // 약관 고유 코드
@Getter
@Column(nullable = false)
private String content; // 약관 내용
}
기존에 저장되어 있는 약관 엔티티가
`{ id: 1, rowVersion: 0, code: "이용 약관", content: "낙관적"}`이었고,
동일한 id를 가진 해당 엔티티의 정보를 다음과 같이 저장한다면
`{id: 1, rowVersion: 0, code: "이용 약관", content: "락"}`
JpaRepository save() 수행 시 아래와 같은 쿼리가 발생한다.
UPDATE terms
SET row_version = 1, code = "이용 약관" ,content = "락"
WHERE id = 1 AND row_version = 0;
위와 같이 기존 rowVersion +1 증가시키며,
WHERE 조건에 id와 함께, 기존 버전의 값을 조회한다.
그런데 해당 쿼리가 수행될 때,
"어떠한 row도 변화가 없다면?"
즉, 이미 버전이 업데이트되어,
id = 1, row_version = 0 조건에 해당하는 값이 존재하지 않는 경우,
JPA는 OptimisticLockingFailureException 예외를 발생시킨다.
즉, 미리 Lock을 걸어 쿼리가 순차적으로 실행되게끔 하는 게 아닌,
쿼리가 수행될 때, 의도한 버전 변경이 실패했는지 확인하는 것이다.
직접 재시도 로직 구현
Compare and Swap 방식과 논리가 유사하지만,
재시도하는 로직은 별도로 구현해야 한다
필자는 실제로 아래와 같은 OptimisticLockingFailureException 재시도 유틸을 구현했었다
@Log4j2
public class OptimisticLockingFailureRetryUtils {
public static <T> T executeWithRetry(int maxRetryCount, RetryableOperation<T> operation) {
return executeWithRetry(maxRetryCount, operation, 0);
}
private static <T> T executeWithRetry(int maxRetryCount, RetryableOperation<T> operation, int retryCount) {
try {
return operation.execute();
} catch (OptimisticLockingFailureException e) {
retryCount++;
log.warn("낙관적 락 충돌 발생 - 재시도 {}회", retryCount);
if (retryCount >= maxRetryCount) {
log.error("최대 재시도 초과 - 작업 실패", e);
throw ErrorCode.INTERNAL_SERVER_ERROR.exception("서버 내부 오류로 인한 작업 실패, 재시도 요청 필요");
}
return executeWithRetry(maxRetryCount, operation, retryCount);
}
}
@FunctionalInterface
public interface RetryableOperation<T> {
T execute() throws OptimisticLockingFailureException;
}
}
아래와 같이 람다식을 활용해 간단히 활용할 수 있다
public Terms createTerms(Terms terms) {
// 코드 생략..
return OptimisticLockingFailureRetryUtils.executeWithRetry(3, () -> this.repository.save(terms));
}
왜 @Version이 적절하지 않았는가?
하지만 이렇게 `@Version` 을 활용하기에는
기존 약관의 이력 그대로 남아있지 못하게 되는 문제가 발생했다.
@Version 기반의 업데이트는 기존 데이터를 직접 덮어쓴다.
약관 도메인의 경우, 모든 버전을 이력으로 남겨야 하는 구조이기 때문에
낙관적 락은 적용 자체가 비즈니스 요구사항을 훼손하는 방식이 된다.
기술적으로 가장 간단한 해결책이었지만,
약관이라는 도메인은 과거의 변경 이력을 보존해야 했기에 선택할 수 없었다.
`@Lock(PESSIMISTIC_WRITE)` 활용 (비관적 락)
`@Version` 활용으로 비즈니스 요구사항을 지킬 수 없다는 한계점을 인지한 이후,
그다음으로 시도해 본 것이 바로 '비관적 락'이다.
마찬가지로 JPA에 익숙하지 않았던 지라,
`@Lock(PESSIMISTIC_WRITE)`의 내부 작동 방식도 이번에 간단히 정리해보고자 한다.
`@Lock(PESSIMISTIC_WRITE)` 이란?
JPA에서 지원하는 비관적 락 방식으로 어플리케이션 내부의 Lock이 아닌,
DB 내부에서 테이블 Row에 Lock을 거는 방식이다
어떻게 동작하는가?
예를 들어 아래와 같이 saveVersionTest() 메서드에 트랜잭션괴 Lock 어노테이션 설정을 했다고 가정하자
// 어노테이션 생략..
public class VersionTestService {
private final VersionTestRepositroy repository;
@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
public VersionTest saveVerstionTest(VersionTest test) {
int maxVersion = this.repositroy.findMaxVersion(); // 가장 최신 버전 값 확인
test.newVersion(++maxVersion); // 새로운 버전 부여
return this.repositroy.save(test); // 새로운 데이터 저장
}
}
saveVerstionTest() 메서드가 스레드A, 스레드B 2개의 스레드에서 동시에 실행된다면,
먼저 스레드A와 스레드B 둘 다 @Transactional AOP에 의해 DB 트랜잭션이 시작된다.
그리고 메서드 내부 첫 줄 `repository.findMaxVersion()`에 의해 select ~ for update 쿼리가 수행된다.
`repository.findMaxVersion()` 메서드를 수행하는 순간조차,
스프링 어플리케이션 내부에서는 스레드A, 스레드B 둘 다 Lock이 걸리지 않고 수행된다.
그러나 DB 입장에서는 쿼리 요청 중
스레드A의 요청이 먼저 왔는지, 스레드B의 요청이 먼저 도착했는지에 따라,
두 스레드 중 하나의 스레드에만 즉시 쿼리 응답을 할 것이며,
두 스레드 중 하나는 `repository.findMaxVersion()` 수행 시점에서 DB 쿼리 응답을 대기해야 한다.
만약 스레드A가 먼저 DB 특정 row에 Lock을 획득했다면,
스레드B는 스레드A의 트랜잭션이 닫힐 때까지 기다려야 하는 것이다.
즉, 이 또한 어플리케이션 내부의 Lock이 아닌, DB의 Lock이라고 볼 수 있으며,
같은 row에 대해서만 Lock이 걸리기 때문에,
동일한 데이터가 아니라면 크게 대기할 일은 발생하지 않을 것이다.
DB의 Lock이 정말 최선일까?
필자는 JPA의 비관적 Lock 작동 방식에 강한 거부감을 느꼈다
그 이유는 바로 DB에서 Lock을 관리해야 하기 때문이다.
지금까지 필자의 업무 경험상,
API의 성능 저하 원인 중 9할이 DB의 부하였고,
DB 부하를 막기 위해 다양한 고민을 했던 과거를 떠올려 봤을 때,
API 요청마다 DB가 row에 Lock이 걸리게 하는 것에도,
DB 성능이 저하될 수밖에 없다고 느꼈기 때문이다.
결국 비관적 락은 정합성은 확보되지만,
"특정 API의 모든 요청이 DB Lock을 건다"는 점에서
비용이 크고, 과한 설계라 판단했다.
그래서 무엇을 선택했는가?
`@Version`은 간결하고 강력했다.
`@Lock(PESSIMISTIC_WRITE)`는 정합성을 보장했다.
하지만 필자는 둘 다 선택하지 않았다.
그 이유는 단순하다.
"내가 만들고 있는 도메인이, 그런 방식의 락을 필요로 하지 않았기 때문이다."
두 가지 기술 모두 동시성 문제를 해결할 수는 있었다.
하지만 약관 도메인은 "이력 보존", "중복 방지"가 핵심이지,
동일 row를 덮어쓰는 문제가 아니었다.
DB 제약조건 활용
결국 필자는 데이터베이스에 제약조건을 추가하는 방식을 선택했다.
그 이유는 다음과 같다
같은 약관 code(종류)에 따라 버전을 유일해야 한다.
예를 들어, DB에 "이용 약관"의 버전이 11인 값이 동시에 2개가 존재해선 안된다
따라서 아래와 같이 약관 code와 version에 유니크 제약조건을 추가했다
@Entity
@Table(
name = "terms",
uniqueConstraints = {
@UniqueConstraint(name = "uk_terms_code_version", columnNames = {"code", "version"})
}
)
public class Terms { // 생략
}
version 관리는 비관적 락에서 구현한 바와 동일하게 service 로직에서 제어한다
그리고 만약 동시성이 충돌하더라도, DB 제약조건 위반으로 예외가 발생한다
그 예외를 잡고 응답하면 충분히 안전한 흐름이 보장된다
아래는 간단한 예시 코드다
public class TermsService {
private final TermsRepository repository;
public Terms saveTerms(Terms terms) {
// 코드 생략..
int maxVersion = this.repository.findMaxVersionByCode(terms.code()).orElse(0);
terms.setNewVersion(++maxVersion);
try {
return this.repository.save(terms);
} catch (DataIntegrityViolationException e) {
throw new Exception("데이터 무결성 위반으로 인한 작업 실패, 데이터 확인 요청 필요");
}
}
}
도메인 관점에서 바라보자
누군가는 이러한 선택이 동시성 문제 해결이 아니라고 판단할 수도 있다.
그러나 필자는 해당 도메인 관점에서
가장 합리적인 동시성 문제 해결이라고 판단했다.
그 이유는 다음과 같다
- 약관 도메인 특성상 새로운 약관이 생기거나, 기존 약관이 수정되는 일이 매우 적다
- 약관을 생성하고 수정하는 역할은 전적으로 내부 관리자가 맡는다
- 보통 약관을 동시에 생성하거나, 수정하는 것은 도메인 특성상 올바른 경우에 해당하지 않는다
한번 생각해 보자.
보통 약관이라는 정보는 시시각각 바뀌어서는 안 되는 정보다.
그런데
"누군가 동시에 같은 약관 정보를 생성하거나, 수정했다?"
그렇다면 이를 동시성 제어하며 둘 다 저장되도록 허용해 줄게 아니라,
내부 관리자에게 현재 문제가 발생됐으니 확인해 보라고 경고를 하는 게 낫다
특히 약관이라는 도메인은 법률적으로 엮여 있기 때문에
잘못된 정보를 저장하게 되는 것은 매우 크리티컬 한 문제가 발생되게 된다.
그렇기 때문에 이 도메인에 대해 동시성 문제가 발생하더라도,
Lock을 활용해 해결할 문제라고 판단하지 않았다
오히려 올바른 실패로 처리함으로써,
비즈니스적으로 안전한 동시성 문제 해결책을 택했다.
결론
사이드 프로젝트 수행 과정 중,
약관이라는 도메인에 동시성 이슈가 발생할 수 있는 지점이 존재했다.
JPA에 익숙하지 않았기에
먼저 JPA가 제공하는 동시성 제어 방식들을 학습하고, 직접 적용해보았다.
- 낙관적 락은 매우 간결했지만,
기존 데이터의 이력을 보존할 수 없었다. - 비관적 락은 정합성을 강하게 보장했지만,
DB 성능 저하라는 위험이 뒤따랐다.
결국, 두 방식 모두 기술적으로는 가능했지만,
도메인 요구사항과는 맞지 않았다.
오히려 락을 활용한 설계는
약관이라는 도메인이 가진 법적 책임성과 안정성을 해칠 수 있었다.
그래서 필자는 기술적인 락이 아닌,
DB 제약조건을 통해 중복을 방지하고,
문제가 생기면 올바르게 실패하는 구조를 선택했다.
결론적으로, 이 방식은
가장 단순하지만, 도메인에 가장 잘 맞는 동시성 해결책이었다.
생각 정리
필자는 신입 시절,
오래된 기술의 레거시 프로젝트를 리빌딩하는 역할을 맡은 적이 있다.
그때는 트렌디한 기술을 배우고 싶다는 의욕에 앞서,
도메인에 대한 이해보다는
새로운 기술을 먼저 적용하는 데에만 집중했던 기억이 있다.
즉, 도메인에 맞는 기술을 선택한 것이 아니라,
기술을 먼저 선택하고,
그 기술에 도메인을 끼워 맞추려 했던 것이다.
이제는 같은 실수를 반복하고 싶지 않다.
기술은 언제든 바뀔 수 있다.
그러나 도메인은 변하지 않는 본질이다.
무엇을 사용할 것인가보다,
무엇을 해결해야 하는지가 먼저다.
이제는 언제나 먼저 도메인을 이해하고,
그에 맞는 기술을 선택하는 개발자로 성장하고 싶다.
'개발 일기' 카테고리의 다른 글
[사이드 프로젝트] QueryDSL과 타입 안정성을 고려한 커서 설계기 (1) | 2025.04.11 |
---|---|
[사이드 프로젝트] 정렬 하나에도 설계가 들어간다 (0) | 2025.04.04 |
[사이드 프로젝트] 일관된 응답 구조 설계 (0) | 2025.04.02 |
[고찰하기] 필요한 기능만 의존하게 하자 (0) | 2025.04.01 |
[고찰하기] 엔티티가 아니라 "도메인"을 설계하기 (0) | 2025.03.31 |