Brolog
Spring

[Java] Record와 @Version으로 이해하는 불변 데이터와 동시성 제어

Java의 record 객체가 등장한 이유와 불변 데이터 객체의 특징, 장단점, DTO 활용법부터 JPA의 @Version을 이용한 낙관적 락과 동시성 제어 원리까지 정리합니다.

📖 Java Record와 @Version으로 이해하는 불변 데이터와 동시성 제어

Java로 백엔드 애플리케이션을 개발하다 보면 반복적으로 등장하는 두 가지 문제가 있다.

하나는 데이터를 전달하기 위한 객체를 만들면서 생기는 불필요한 코드의 반복이고,

다른 하나는 여러 사용자가 동시에 같은 데이터를 수정하면서 발생하는 동시성 문제다.

예를 들어 API 응답을 전달하기 위해 다음과 같은 클래스를 만든다고 생각해보자.

public class UserResponse {

    private final Long id;
    private final String name;
    private final String email;

    public UserResponse(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

데이터 자체는 단순하다.

하지만 필드 선언부터 생성자, getter까지 반복해서 작성해야 한다.

Java의 record는 이런 데이터 중심 객체를 간결하게 표현하기 위해 등장한 문법이다.

반면 데이터베이스에서 여러 사용자가 동시에 같은 데이터를 수정하는 문제는 @Version과 같은 낙관적 락(Optimistic Lock) 개념으로 해결할 수 있다.

이번 글에서는 두 개념을 단순히 문법 수준에서 끝내지 않고,

  • Java Record란 무엇인지
  • 왜 사용하는지
  • 일반 클래스와 어떤 차이가 있는지
  • 불변 객체와 어떤 관계가 있는지
  • DTO로 어떻게 활용하는지
  • Record의 장단점은 무엇인지
  • JPA의 @Version은 어떤 역할을 하는지
  • 낙관적 락은 어떻게 동작하는지
  • 동시에 수정했을 때 어떤 일이 발생하는지
  • 실제 서비스에서는 어떻게 적용해야 하는지

까지 하나의 흐름으로 정리해본다.


🔎 1. Java Record란 무엇인가?

record는 Java 16부터 정식으로 사용할 수 있게 된 데이터 전달 객체를 간결하게 표현하기 위한 클래스 선언 방식이다.

간단한 예를 보면 다음과 같다.

public record UserResponse(
    Long id,
    String name,
    String email
) {
}

이렇게 선언하면 Java가 데이터 객체에 필요한 기본적인 구조를 제공한다.

일반 클래스에서는 다음과 같이 작성해야 했던 코드가:

public class UserResponse {

    private final Long id;
    private final String name;
    private final String email;

    public UserResponse(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

Record에서는:

public record UserResponse(
    Long id,
    String name,
    String email
) {
}

로 줄어든다.

즉 Record의 핵심 목적은 단순하다.

데이터를 담는 객체를 더 간결하고 명확하게 표현하는 것


🧩 2. Record가 등장한 이유

기존 Java에서는 데이터를 전달하기 위한 객체를 만들 때 반복되는 코드가 많았다.

대표적으로:

private final String name;

필드를 선언하고,

public UserResponse(String name) {
    this.name = name;
}

생성자를 만들고,

public String getName() {
    return name;
}

getter까지 만들어야 했다.

필드가 10개라면 getter도 계속 늘어난다.

이런 객체는 대부분 특별한 비즈니스 로직을 가지고 있지 않고 단순히 데이터를 전달하는 역할만 한다.

예를 들어 API Response라면:

UserResponse

id
name
email
createdAt

정도의 데이터를 전달하는 것이 목적이다.

Record는 이런 **데이터 중심 객체(Data Carrier)**를 표현하기 위한 문법이다.


🛠️ 3. Record 기본 사용법

가장 기본적인 형태는 다음과 같다.

public record User(
    Long id,
    String name,
    String email
) {
}

객체를 생성할 때는 일반 클래스와 비슷하다.

User user = new User(
    1L,
    "콩이",
    "bean@example.com"
);

값을 가져올 때는 getter 대신 컴포넌트 이름과 동일한 accessor method를 사용한다.

user.id();
user.name();
user.email();

즉 일반 클래스의:

user.getName();

대신:

user.name();

을 사용한다.


🔎 4. Record의 컴포넌트란?

다음 코드를 보자.

public record User(
    Long id,
    String name
) {
}

여기서:

Long id
String name

을 Record의 component라고 한다.

이 컴포넌트를 기준으로 Java가 여러 기능을 자동으로 제공한다.

대표적으로:

  • private final 필드
  • canonical constructor
  • accessor method
  • equals()
  • hashCode()
  • toString()

등이 만들어진다.

따라서 Record는 단순히 코드를 줄여주는 문법이 아니라 데이터 객체의 의도를 명확하게 표현하는 타입이라고 보는 것이 좋다.


🧱 5. Record는 기본적으로 불변 객체다

Record에서 중요한 특징 중 하나가 **불변성(Immutable)**이다.

다음과 같이 선언했다고 생각해보자.

public record User(
    Long id,
    String name
) {
}

Record의 컴포넌트는 기본적으로 final 필드로 관리된다.

따라서 다음과 같이 값을 변경할 수 없다.

User user = new User(1L, "콩이");

user.name = "두부";

위 코드는 컴파일되지 않는다.

Record는 객체가 생성된 이후 자신의 컴포넌트를 직접 변경할 수 없도록 설계되어 있다.


🔐 6. 불변 객체란 무엇인가?

불변 객체(Immutable Object)는 객체가 생성된 이후 내부 상태를 변경할 수 없는 객체를 의미한다.

예를 들어:

String name = "BroBean";

이라는 문자열이 있다고 하자.

다음 코드:

name = "Java";

는 기존 String 객체의 값을 변경하는 것이 아니다.

새로운 문자열을 가리키도록 참조가 변경되는 것이다.

이처럼 불변 객체는 객체 자체의 상태가 생성 이후 변경되지 않는다.

Record 역시 이러한 데이터 객체 설계를 쉽게 만들 수 있다.


🌱 7. 불변 객체를 사용하는 이유

불변 객체를 사용하는 가장 큰 이유 중 하나는 상태 변경으로 인한 예측하지 못한 사이드 이펙트를 줄이는 것이다.

예를 들어 여러 메서드가 동일한 객체를 참조하고 있다고 하자.

User user = ...;

serviceA.process(user);
serviceB.process(user);
serviceC.process(user);

만약 serviceA가 객체 내부 상태를 변경한다면 serviceB와 serviceC의 결과도 달라질 수 있다.

반면 불변 객체라면:

생성

↓

값 고정

↓

여러 곳에서 공유

↓

상태 변경 없음

이라는 구조가 된다.

따라서 객체의 상태를 추적하기 쉬워진다.


🧠 8. Record와 equals(), hashCode()

Record는 데이터 객체로 사용되는 경우가 많기 때문에 값 기반 비교가 중요하다.

예:

User user1 = new User(1L, "콩이");
User user2 = new User(1L, "콩이");

두 객체는 서로 다른 객체이지만 데이터가 동일하다.

Record는 컴포넌트를 기준으로 equals()와 hashCode()를 생성한다.

따라서:

user1.equals(user2);

는 true가 된다.

일반 클래스에서는 직접 구현하거나 Lombok 등을 사용해야 하는 경우가 많지만 Record에서는 기본적으로 제공된다.


📝 9. Record의 toString()

Record는 toString() 역시 자동으로 생성한다.

User user = new User(1L, "콩이");

System.out.println(user);

결과는 대략 다음과 같은 형태가 된다.

User[id=1, name=콩이]

따라서 디버깅할 때도 객체 내부 값을 쉽게 확인할 수 있다.


🛠️ 10. Record에서도 메서드를 만들 수 있을까?

Record가 단순한 데이터만 담을 수 있는 것은 아니다.

필요하다면 메서드를 추가할 수 있다.

public record User(
    Long id,
    String name
) {

    public String displayName() {
        return "농부 " + name;
    }
}

사용:

User user = new User(1L, "콩이");

System.out.println(user.displayName());

즉 Record는 단순한 구조체가 아니라 일반적인 Java 타입이다.

다만 Record의 목적을 생각하면 데이터의 상태를 복잡하게 관리하는 객체보다는 값을 표현하고 전달하는 객체에 사용하는 것이 적합하다.


🔎 11. Record에서 생성자를 커스터마이징할 수 있을까?

가능하다.

예를 들어 입력값을 검증하고 싶다면 compact constructor를 사용할 수 있다.

public record User(
    Long id,
    String name
) {

    public User {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("이름은 필수입니다.");
        }
    }
}

여기서는 Record의 기본 생성자 형태를 유지하면서 검증 로직만 추가했다.

객체 생성 시:

new User(1L, "");

을 실행하면 예외가 발생한다.


🧩 12. Record와 일반 클래스의 차이

둘의 차이를 정리하면 다음과 같다.

항목일반 ClassRecord
필드직접 선언Component로 선언
생성자직접 작성 가능Canonical Constructor 자동 생성
getter직접 작성name() 형태 자동 생성
equals직접 구현 가능자동 생성
hashCode직접 구현 가능자동 생성
toString직접 구현 가능자동 생성
불변성직접 설계Component가 final
상속가능다른 클래스를 상속할 수 없음
목적다양한 객체 모델링데이터 중심 객체

따라서 Record는 일반 클래스를 완전히 대체하는 것이 아니다.

데이터 전달이나 값 표현에 특화된 별도의 도구라고 이해하는 것이 좋다.


🚫 13. Record는 상속할 수 없다

Record는 다른 클래스를 상속할 수 없다.

public record User(...) extends BaseUser {
}

이런 형태는 사용할 수 없다.

Record 자체가 이미 java.lang.Record를 상속하고 있기 때문이다.

다만 interface는 구현할 수 있다.

public record User(
    Long id,
    String name
) implements UserInfo {
}

따라서 공통 동작을 interface로 추상화하는 방식은 사용할 수 있다.


⚠️ 14. Record의 단점

Record가 항상 좋은 것은 아니다.

대표적인 단점은 다음과 같다.

1. 상속 제한

클래스를 상속할 수 없다.

2. 상태 변경이 어렵다

불변 객체를 만드는 것이 목적이기 때문에 변경 가능한 객체가 필요한 경우 적합하지 않다.

3. JPA Entity로 사용하기 어렵다

JPA Entity는 일반적으로 다음과 같은 특성을 필요로 한다.

  • 기본 생성자
  • 프록시 생성
  • 변경 감지
  • 엔티티 상태 관리

Record의 설계 목적과 JPA Entity의 요구사항은 서로 다르다.

따라서 일반적으로:

Entity = class

DTO = record

구조를 많이 사용한다.


📦 15. Record를 DTO로 사용하는 이유

Record가 가장 자주 사용되는 영역 중 하나가 **DTO(Data Transfer Object)**다.

예를 들어 API 요청:

public record CreateUserRequest(
    String name,
    String email
) {
}

API 응답:

public record UserResponse(
    Long id,
    String name,
    String email
) {
}

처럼 사용할 수 있다.

DTO는 기본적으로 데이터를 전달하는 역할을 하기 때문에 Record의 목적과 잘 맞는다.


🌐 16. Spring Boot에서 Request DTO로 사용하기

Spring Boot에서는 다음과 같이 사용할 수 있다.

public record CreateUserRequest(
    String name,
    String email
) {
}

Controller:

@PostMapping("/users")
public ResponseEntity<Void> create(
    @RequestBody CreateUserRequest request
) {

    userService.create(
        request.name(),
        request.email()
    );

    return ResponseEntity.ok().build();
}

Lombok 없이도 간결한 요청 객체를 만들 수 있다.


📤 17. Response DTO로 사용하기

응답 객체 역시 Record와 잘 맞는다.

public record UserResponse(
    Long id,
    String name,
    String email
) {
}

Controller:

@GetMapping("/users/{id}")
public UserResponse getUser(
    @PathVariable Long id
) {

    User user = userService.findById(id);

    return new UserResponse(
        user.getId(),
        user.getName(),
        user.getEmail()
    );
}

응답 객체는 외부에 데이터를 전달하는 것이 목적이므로 굳이 변경 가능한 상태를 가질 필요가 없다.


🧠 18. Record를 Entity로 사용하면 안 될까?

무조건 "절대 안 된다"라고 생각하기보다는 역할이 다르다고 이해하는 것이 좋다.

JPA Entity는 데이터베이스와 연결되어 있고 생명주기와 상태 변경을 관리해야 한다.

예:

@Entity
public class User {

    @Id
    private Long id;

    private String name;

    public void changeName(String name) {
        this.name = name;
    }
}

반면 Record는:

public record UserResponse(
    Long id,
    String name
) {
}

처럼 현재 데이터를 표현하고 전달하는 역할에 적합하다.

따라서 일반적인 구조는:

Database
   ↓
Entity
   ↓
Service
   ↓
DTO / Record
   ↓
Controller
   ↓
Client

와 같은 형태가 된다.


🌱 19. Record와 Value Object

Record는 DTO뿐만 아니라 Value Object를 표현하는 데에도 활용할 수 있다.

예를 들어 이메일 주소를 하나의 값 객체로 만들 수 있다.

public record Email(
    String value
) {

    public Email {
        if (value == null || !value.contains("@")) {
            throw new IllegalArgumentException("올바른 이메일이 아닙니다.");
        }
    }
}

이렇게 하면 단순한 String보다 의미가 명확해진다.

Email email = new Email("bean@example.com");

이 객체는 단순 문자열이 아니라:

"유효한 이메일 주소라는 의미를 가진 값"

을 표현한다.


🔎 20. 이제 동시성 문제를 생각해보자

Record가 데이터 객체의 구조와 불변성에 관한 이야기라면,

이번에는 여러 사용자가 동시에 데이터를 수정하는 문제를 생각해보자.

예를 들어 농장에 다음과 같은 자산이 있다고 하자.

현재 자산 = 100,000원

사용자 A가 조회한다.

A → 100,000원

동시에 사용자 B도 조회한다.

B → 100,000원

A가 20,000원을 사용한다.

100,000 → 80,000

B가 30,000원을 사용한다.

100,000 → 70,000

만약 두 요청이 단순히 자신의 조회 결과를 기준으로 저장한다면 최종 결과는:

70,000원

이 될 수 있다.

하지만 실제로 기대한 값은:

100,000 - 20,000 - 30,000

= 50,000원

이다.

이런 문제가 바로 **동시성 문제(Concurrency Problem)**다.


⚠️ 21. Lost Update란?

위와 같은 문제를 Lost Update라고 한다.

두 사용자가 같은 데이터를 조회한 후 각각 수정하고 저장하면서,

먼저 저장된 변경사항이 나중 저장된 값에 의해 덮어써지는 문제다.

흐름을 보면:

현재 값: 100

A 조회 → 100
B 조회 → 100

A 수정 → 80
B 수정 → 70

A 저장 → 80
B 저장 → 70

최종 값:

70

A의 변경사항이 사라졌다.

이것이 Lost Update다.


🔐 22. 동시성 제어란?

동시성 제어는 여러 요청이 동시에 같은 데이터를 수정할 때 데이터의 일관성을 유지하기 위한 방법이다.

대표적인 방식은 크게 두 가지다.

비관적 락
Pessimistic Lock

낙관적 락
Optimistic Lock

두 방식은 접근 방법 자체가 다르다.


🔒 23. 비관적 락이란?

비관적 락은:

"동시에 수정할 가능성이 높으니 일단 잠그고 보자."

라는 방식이다.

데이터를 조회할 때부터 다른 트랜잭션이 수정하지 못하도록 잠근다.

대표적인 SQL 개념은:

SELECT *
FROM user
WHERE id = ?
FOR UPDATE;

이다.

이 경우 해당 행을 다른 트랜잭션이 동시에 수정하기 어렵게 만든다.

장점은 충돌 자체를 줄일 수 있다는 것이다.

하지만 단점도 있다.

  • 락을 오래 유지하면 대기 발생
  • 트래픽 증가 시 병목 가능
  • 데드락 가능성
  • DB 리소스 사용 증가

따라서 항상 비관적 락이 정답은 아니다.


🌱 24. 낙관적 락이란?

낙관적 락은 반대로 생각한다.

"대부분 충돌하지 않을 것이다. 일단 작업하고 충돌이 발생했을 때 감지하자."

즉 데이터를 조회할 때는 락을 걸지 않는다.

대신 데이터가 수정되는 순간:

내가 조회했던 데이터가
아직 그대로인가?

를 확인한다.

이때 사용하는 대표적인 방법이 @Version이다.


🏷️ 25. @Version이란?

JPA에서는 @Version을 사용해 엔티티의 버전을 관리할 수 있다.

예:

@Entity
public class Farm {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @Version
    private Long version;
}

여기서:

@Version
private Long version;

이 필드가 해당 엔티티의 버전 번호 역할을 한다.

데이터가 처음 생성되었다고 하면:

id = 1
name = "알콩달콩 농장"
version = 0

일 수 있다.

수정이 성공하면:

version = 1

로 증가한다.

다시 수정하면:

version = 2

가 된다.


🔎 26. @Version은 어떻게 동작할까?

핵심은 UPDATE SQL에 있다.

처음 조회했을 때:

id = 1
version = 3

이었다고 하자.

수정할 때 JPA는 단순히:

UPDATE farm
SET name = ?
WHERE id = ?;

만 실행하는 것이 아니다.

대략 다음과 같은 조건이 포함된다.

UPDATE farm
SET
    name = ?,
    version = version + 1
WHERE
    id = ?
    AND version = ?;

여기서 마지막 version = ?가 핵심이다.

내가 조회했을 당시 버전이:

3

이었다면:

WHERE id = 1
AND version = 3

이라는 조건으로 UPDATE를 실행한다.


🧠 27. 왜 version 조건이 필요한가?

두 사용자가 동시에 조회했다고 하자.

현재 데이터:

version = 3

A:

id = 1
version = 3

B:

id = 1
version = 3

둘 다 같은 버전을 가지고 있다.

먼저 A가 수정한다.

UPDATE farm
SET
    name = 'A',
    version = 4
WHERE
    id = 1
    AND version = 3;

정상적으로 수정된다.

현재 DB:

version = 4

이제 B가 수정하려고 한다.

B가 가지고 있는 버전은 여전히:

version = 3

이다.

따라서:

UPDATE farm
SET
    name = 'B',
    version = 4
WHERE
    id = 1
    AND version = 3;

를 실행한다.

하지만 현재 DB의 version은 이미:

4

이다.

따라서 조건에 맞는 행이 없다.

UPDATE 대상 행 = 0개

가 된다.

이것을 통해 JPA는:

"내가 조회했던 데이터가 다른 사람에 의해 이미 변경되었다."

는 사실을 감지할 수 있다.


🚨 28. OptimisticLockException

이렇게 버전 충돌이 발생하면 JPA는 일반적으로:

OptimisticLockException

을 발생시킨다.

즉:

A 조회
B 조회

A 수정 성공
version 3 → 4

B 수정 시도

version 3 조건 불일치

↓

OptimisticLockException

이라는 흐름이 된다.

이것이 @Version을 이용한 낙관적 락의 핵심 원리다.


🔄 29. @Version의 전체 흐름

전체 과정을 정리하면 다음과 같다.

1. 데이터 조회

        ↓

2. 현재 version 확인

        ↓

3. 애플리케이션에서 데이터 수정

        ↓

4. UPDATE 실행

        ↓

5. WHERE id = ? AND version = ?

        ↓

6. 수정 성공

        ↓

7. version 증가

만약 수정 대상이 없다면:

version 충돌

↓

OptimisticLockException

↓

트랜잭션 실패

가 된다.


🧩 30. @Version은 락을 거는 것이 아니다

여기서 자주 하는 오해가 있다.

@Version
private Long version;

을 사용하면 DB Row에 물리적인 Lock이 걸린다고 생각하는 경우가 있다.

하지만 @Version의 핵심은 락을 미리 걸어두는 것이 아니라 변경 충돌을 감지하는 것이다.

즉:

비관적 락

"먼저 잠근다."

낙관적 락

"일단 처리하고 충돌을 확인한다."

라고 이해하면 된다.


⚙️ 31. @Version 필드 타입

JPA에서 @Version은 여러 타입으로 사용할 수 있다.

대표적으로:

@Version
private Long version;

또는:

@Version
private Integer version;

등을 사용할 수 있다.

일반적인 서비스에서는 Long을 사용하는 경우가 많다.

중요한 것은 개발자가 직접 version을 증가시키는 것이 아니라 JPA가 관리하도록 맡기는 것이다.

따라서 일반적으로:

entity.setVersion(entity.getVersion() + 1);

처럼 직접 수정하지 않는다.


🚫 32. Version 값을 직접 수정하면 안 되는 이유

@Version은 JPA가 엔티티 변경 여부를 추적하는 핵심 값이다.

따라서 다음처럼 직접 변경하는 코드는 피해야 한다.

farm.setVersion(farm.getVersion() + 1);

Version은:

조회

↓

수정 감지

↓

UPDATE

↓

Version 증가

과정을 JPA가 관리하도록 두는 것이 기본적인 사용법이다.


🔎 33. @Version과 Dirty Checking

@Version을 이해하려면 JPA의 Dirty Checking도 함께 알아야 한다.

JPA는 트랜잭션 안에서 관리 중인 엔티티의 상태가 변경되었는지 확인한다.

예:

@Transactional
public void changeFarmName(
    Long farmId,
    String name
) {

    Farm farm = farmRepository.findById(farmId)
        .orElseThrow();

    farm.changeName(name);
}

여기서는 명시적인:

farmRepository.save(farm);

가 없어도 변경사항이 반영될 수 있다.

트랜잭션이 종료될 때 JPA가 변경된 상태를 감지하고 UPDATE를 실행하기 때문이다.

이때 @Version이 있다면 UPDATE에 version 조건이 포함된다.


🧠 34. @Version과 트랜잭션은 함께 이해해야 한다

@Version만 선언했다고 모든 동시성 문제가 자동으로 해결되는 것은 아니다.

보통 다음과 같은 구조에서 사용한다.

@Transactional
public void updateFarm(
    Long farmId,
    String name
) {

    Farm farm = farmRepository.findById(farmId)
        .orElseThrow();

    farm.changeName(name);
}

여기서:

@Transactional

+

JPA Entity

+

@Version

+

Dirty Checking

이 서로 연결된다.

따라서 @Version을 제대로 이해하려면 JPA 영속성 컨텍스트와 트랜잭션에 대한 기본적인 이해도 필요하다.


🔥 35. 실무에서는 충돌을 어떻게 처리할까?

낙관적 락의 중요한 특징은:

충돌을 막는 것이 아니라 충돌을 발견한다.

는 것이다.

따라서 충돌이 발생하면 애플리케이션에서 적절한 처리를 해야 한다.

예를 들어 사용자에게:

다른 사용자가 먼저 정보를 수정했습니다.

최신 정보를 다시 확인한 후
다시 시도해주세요.

라고 안내할 수 있다.

Spring에서는 예외를 적절한 응답으로 변환할 수 있다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OptimisticLockException.class)
    public ResponseEntity<String> handleOptimisticLock(
        OptimisticLockException e
    ) {

        return ResponseEntity
            .status(HttpStatus.CONFLICT)
            .body("다른 사용자가 먼저 데이터를 수정했습니다.");
    }
}

HTTP 상태 코드로는 일반적으로:

409 Conflict

를 사용하는 것을 고려할 수 있다.


🔄 36. 충돌이 발생하면 무조건 실패시켜야 할까?

항상 그런 것은 아니다.

데이터의 성격에 따라 처리 전략을 선택할 수 있다.

예를 들어 단순한 프로필 수정이라면:

충돌 발생

↓

최신 데이터 다시 조회

↓

사용자에게 다시 입력 요청

하는 방식이 적합할 수 있다.

반면 자동으로 합칠 수 있는 데이터라면:

A의 변경

+

B의 변경

↓

Merge

↓

최종 데이터

방식을 사용할 수도 있다.

하지만 모든 데이터를 자동으로 Merge하는 것은 위험하다.

특히 금액, 재고, 수량처럼 비즈니스 규칙이 중요한 데이터는 충돌 발생 시 명확하게 처리하는 것이 좋다.


💰 37. 금액 데이터와 동시성 제어

가계부나 금융 서비스에서는 동시성 문제가 특히 중요하다.

예를 들어:

현재 잔액 = 100,000원

A가:

20,000원 사용

B가:

30,000원 사용

하는 상황에서 단순 조회 후 저장을 사용하면 Lost Update가 발생할 수 있다.

따라서 단순히:

balance -= amount;

만 생각할 것이 아니라,

동시에 변경되는가?

↓

변경 충돌을 어떻게 감지할 것인가?

↓

충돌하면 어떻게 처리할 것인가?

까지 함께 설계해야 한다.

특히 금융 데이터처럼 절대 유실되면 안 되는 데이터라면 낙관적 락뿐만 아니라 원자적 UPDATE, 비관적 락, 데이터베이스 제약조건 등 여러 방법을 함께 검토해야 한다.


🧩 38. Record와 @Version을 함께 정리하면

Record와 @Version은 서로 전혀 다른 기능처럼 보이지만 백엔드 애플리케이션을 설계할 때 함께 이해하면 좋은 개념이다.

Record는:

데이터를 어떻게 표현하고 전달할 것인가?

에 대한 문제를 해결한다.

public record FarmResponse(
    Long id,
    String name,
    Long level
) {
}

반면 @Version은:

여러 요청이 동시에 데이터를 변경할 때
어떻게 충돌을 감지할 것인가?

에 대한 문제를 해결한다.

@Entity
public class Farm {

    @Id
    private Long id;

    private String name;

    @Version
    private Long version;
}

전체 구조를 보면 다음과 같이 이해할 수 있다.

                    Database
                       │
                       ▼
                 JPA Entity
                       │
                @Version 관리
                       │
                       ▼
                    Service
                       │
              비즈니스 로직 수행
                       │
                       ▼
                 Record DTO
                       │
                       ▼
                   Controller
                       │
                       ▼
                    Client

즉,

Entity

→ 데이터베이스와 상태를 관리

Record

→ 데이터를 안전하고 간결하게 전달

@Version

→ 동시에 수정되는 데이터의 충돌을 감지

라는 역할 차이가 있다.


🎯 마무리

Java의 record는 단순히 "코드를 줄여주는 문법"으로만 이해하면 아쉽다.

Record는 데이터를 표현하는 객체의 의도를 명확하게 만들고, 불변 데이터 구조를 간결하게 작성할 수 있도록 해주는 Java의 기능이다.

특히 다음과 같은 객체에서 활용하기 좋다.

Request DTO

Response DTO

Value Object

API 데이터 전달 객체

읽기 전용 데이터 객체

반면 JPA의 @Version은 여러 사용자가 같은 데이터를 동시에 수정하는 상황에서 변경 충돌을 감지하기 위한 기능이다.

핵심 동작은 다음과 같다.

조회 당시 version = 3

        ↓

다른 사용자가 수정

        ↓

DB version = 4

        ↓

내가 version = 3 조건으로 UPDATE

        ↓

수정 대상 없음

        ↓

OptimisticLockException

결국 두 개념을 한 문장으로 정리하면 다음과 같다.

Record는 데이터를 어떻게 안전하고 명확하게 표현할 것인가에 대한 도구이고, @Version은 여러 요청이 같은 데이터를 수정할 때 변경 충돌을 어떻게 감지할 것인가에 대한 도구다.

그리고 이 둘을 제대로 이해하기 위해서는 함께 알아두면 좋은 개념들이 있다.

Record
 ↓
불변 객체
 ↓
Value Object
 ↓
DTO
 ↓
JPA Entity
 ↓
영속성 컨텍스트
 ↓
Dirty Checking
 ↓
Transaction
 ↓
동시성 문제
 ↓
Lost Update
 ↓
비관적 락
 ↓
낙관적 락
 ↓
@Version
 ↓
OptimisticLockException

특히 실무에서는 단순히 @Version을 붙이는 것에서 끝내지 않고,

충돌을 어떻게 감지할 것인가?

↓

충돌이 발생하면 어떤 예외를 반환할 것인가?

↓

사용자에게 재시도를 요구할 것인가?

↓

자동으로 재시도할 수 있는 데이터인가?

↓

비관적 락이 더 적합한가?

↓

DB의 원자적 연산으로 해결할 수 있는가?

까지 함께 고민해야 한다.

결국 동시성 제어는 특정 어노테이션 하나를 사용하는 문제가 아니라 데이터의 특성과 비즈니스 규칙에 맞는 일관성 전략을 선택하는 문제다.

Record 역시 마찬가지다.

모든 클래스를 Record로 바꾸는 것이 목적이 아니라,

상태를 변경해야 하는 Entity
        VS
값을 표현하고 전달하는 DTO

를 구분하고 각각의 역할에 맞는 객체를 사용하는 것이 중요하다.

Java 백엔드에서 객체 설계를 할 때는 결국:

"이 객체는 무엇을 표현하는가?"

"이 객체의 상태는 변경되어야 하는가?"

"이 데이터는 어디까지 전달되는가?"

"동시에 여러 사용자가 변경할 수 있는가?"

"변경 충돌이 발생하면 어떻게 처리해야 하는가?"

라는 질문을 먼저 생각하는 것이 중요하다.

record와 @Version은 서로 다른 문제를 해결하지만, 이러한 데이터의 역할과 상태, 그리고 동시성에 대한 사고방식을 익히는 데 좋은 출발점이 된다.

Copyright © 2026 Brolog. All rights reserved.