발생 배경: JPA의 기본 연관 로딩 전략은 지연 로딩(Lazy Loading)입니다. 즉, 엔티티를 조회할 때 연관된 객체를 처음부터 가져오지 않고, 해당 객체를 실제로 사용할 때 추가 쿼리를 실행합니다. 이로 인해 N+1 문제가 발생하기 쉽습니다. 예를 들어 게시글 목록 N개를 조회하고, 각 게시글의 댓글을 출력하는 상황을 생각해봅시다. 첫 번째 쿼리로 게시글 목록을 가져온 후(1), 각 게시글마다 댓글을 조회하는 쿼리가 추가로 N번 실행된다면 총 N+1번의 쿼리가 발생하게 됩니다. 이렇게 예상보다 많은 추가 쿼리가 실행되면 성능 문제가 생길 뿐 아니라, 조회 시점이 달라지는 경우 데이터 일관성에도 영향을 줄 수 있습니다. 예를 들어 하나의 트랜잭션 안에서 N+1 쿼리가 발생하면, 첫 번째 조회 이후 마지막 조회 사이에 다른 트랜잭션이 데이터를 변경했을 경우 각 조회 결과가 동일한 시점의 데이터가 아닐 수 있습니다. 이는 곧 동일 작업에 대한 데이터 정합성 불일치로 이어질 수 있습니다.
증상 및 사례: N+1 문제의 가장 눈에 띄는 증상은 쿼리 남발입니다. 애플리케이션 로그나 DB 모니터링을 보면 동일하거나 유사한 쿼리가 대량으로 실행됩니다. 예를 들어, 개발자는 게시글 10개와 각 게시글의 댓글을 출력하는 단순 기능을 작성했는데, 쿼리가 11번 이상 실행되고 응답 시간이 급격히 느려지는 것을 발견할 수 있습니다. 또한 OSIV(Open Session In View) 같은 환경에서 지연 로딩을 남발하면, 트랜잭션 범위를 벗어난 시점에 엔티티를 초기화하면서 LazyInitializationException이 발생하거나, 각각의 지연 로딩이 별도 트랜잭션으로 처리되어 데이터가 일관되지 않게 보이는 문제도 생길 수 있습니다.
정합성이 깨지는 방식: 지연 로딩 자체는 데이터의 무결성을 직접 훼손하지는 않지만, 잘못된 사용으로 인해 논리적 불일치가 발생할 수 있습니다. 예를 들어, 지연 로딩으로 불러온 컬렉션의 크기가 예상과 다르다거나(다른 조건이 적용된 별도 쿼리가 실행되는 경우), 여러 번의 쿼리 사이에 데이터가 수정되어 앞에서 조회한 데이터와 뒤에서 조회한 데이터가 달라지는 현상이 발생할 수 있습니다. N+1 문제는 주로 성능 이슈로 다뤄지지만, 그 결과로 개발자가 의도치 않게 일부 객체를 누락하거나 잘못된 데이터를 참고하는 버그로 이어질 수 있습니다. 특히 연관 데이터를 제대로 가져오지 않아 화면이나 로직에서 일부분만 표시되는 불완전한 데이터는 일종의 정합성 문제라 볼 수 있습니다.
해결 및 예방: 핵심 해결책은 한 번의 쿼리로 필요한 연관 데이터를 모두 가져오는 것입니다. JPA에서는 fetch join을 사용한 JPQL 또는 @EntityGraph 애너테이션을 활용하고, QueryDSL을 사용한다면 join().fetchJoin() 메서드를 이용해 연관 객체를 함께 조회할 수 있습니다. 이렇게 하면 추가적인 N개의 쿼리 발생을 막아 처음 조회 시점의 일관된 데이터 셋을 가져오게 됩니다. 아래는 N+1 문제가 있는 코드와 해결된 코드의 예시입니다.
// 문제되는 코드: 게시글과 댓글을 따로 조회 (지연 로딩으로 인한 N+1 발생)
List<Post> posts = postRepository.findAll(); // 게시글 목록 조회 (1개의 쿼리)
for (Post post : posts) {
System.out.println(post.getComments().size()); // 각 게시글의 댓글 수 조회 (게시글마다 쿼리 발생)
}
// 개선된 코드: fetch join으로 게시글과 댓글을 한 번에 조회 (1개의 쿼리로 처리)
@Query("SELECT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments(); // Spring Data JPA JPQL 사용 예시
List<Post> postsWithComments = postRepository.findAllWithComments();
for (Post post : postsWithComments) {
System.out.println(post.getComments().size()); // 댓글 정보가 이미 로딩되어 추가 쿼리 없음
}
또는 QueryDSL을 사용하는 경우:
// QueryDSL 사용 예시: 게시글과 댓글을 fetch join으로 조회
List<Post> posts = queryFactory.selectFrom(QPost.post)
.leftJoin(QPost.post.comments, QComment.comment).fetchJoin()
.fetch();
위와 같이 fetch join을 적용하면 연관된 엔티티를 한 번에 가져와 영속성 컨텍스트에 적재하므로 N+1 문제를 방지할 수 있습니다. 단, fetch join 사용 시에는 한계와 주의사항도 있습니다. 예를 들어 두 개 이상의 컬렉션을 동시에 fetch join할 수 없고, 1:N 관계를 fetch join할 때는 페이징 API와 함께 사용할 경우 데이터가 뒤틀릴 수 있습니다. 왜냐하면 1:N fetch 조인 시 JPA 구현체는 데이터 중복을 제거하거나 전체를 메모리에 올려 페이징하는데, 이 과정에서 데이터가 부정확하게 뻥튀기되거나 일부 누락될 수 있기 때문입니다. 따라서 한 쿼리에 한 개의 컬렉션만 fetch join하고, 복잡한 경우에는 별도 쿼리로 나누거나 BatchSize 설정 등의 대안을 고려해야 합니다.
요약하면, 지연 로딩으로 인한 N+1 문제는 성능과 데이터 일관성 모두에 악영향을 줄 수 있으므로, fetch join/EntityGraph 등의 JPA 기능을 적극 활용하여 한 번의 쿼리로 필요한 데이터를 가져오도록 설계해야 합니다. 이를 통해 항상 동일 트랜잭션 내에서 일관된 데이터를 다루게 되어 정합성 이슈를 예방할 수 있습니다.
발생 배경: Spring 프레임워크의 @Transactional은 선언적 트랜잭션을 매우 편리하게 지원하지만, 전파(Propagation)와 롤백 규칙에 대한 이해 부족으로 인해 의도치 않은 동작이 발생할 수 있습니다. 전형적인 실수 중 하나는 자기 자신의 내부 메서드 호출에 @Transactional을 붙이는 경우입니다. Spring AOP 프록시가 적용되지 않아 트랜잭션이 시작되지 않기 때문에 개발자는 트랜잭션이 걸렸다고 생각하지만 실제로는 각 SQL이 Auto-commit으로 실행되어 중간에 오류가 나도 롤백되지 않는 문제가 생길 수 있습니다. 또한, 트랜잭션 전파 수준을 잘못 이해하면 부분 커밋 또는 전체 롤백이 누락되는 상황이 발생합니다. 특히 Propagation.REQUIRES_NEW를 남용하거나, 예외 처리를 부적절하게 하면 문제가 두드러집니다.
증상 및 사례: 여러 서비스 계층 호출이 연쇄되는 시나리오를 가정해봅시다. 예를 들어 ServiceA가 @Transactional로 둘러싸여 있고 내부에서 ServiceB의 메서드를 호출한다고 하겠습니다. 기본 전파 설정(PROAGATION_REQUIRED)에서는 둘 다 하나의 물리 트랜잭션으로 묶입니다. 만약 ServiceB 내부에서 Runtime 예외가 발생하면 Spring은 해당 예외가 트랜잭션 경계를 넘어가는 순간 전체 트랜잭션을 rollback-only로 표시합니다. 그런데 개발자가 ServiceA에서 이 예외를 try-catch로 잡아 로그만 남기고 정상 흐름으로 처리해버리면, 겉보기에는 문제가 없어 보여도 트랜잭션은 이미 롤백 예약된 상태입니다. 결국 ServiceA의 메서드가 끝날 때 UnexpectedRollbackException이 발생하여 아무 데이터도 커밋되지 않거나, 경우에 따라서는 개발자가 모르는 사이에 데이터가 전혀 반영되지 않는 현상을 겪게 됩니다. 이처럼 예외를 잡았는데도 데이터베이스에 반영이 안 되는 상황은 전파와 롤백 규칙을 오해할 때 빈번히 나타나며, 실무에서 디버깅이 어려운 이슈 중 하나입니다.
또 다른 사례는 Propagation.REQUIRES_NEW의 오용입니다. 예를 들어 메일 발송 이력 저장 같은 보조 로직을 REQUIRES_NEW로 분리하여 본 트랜잭션과 독립적으로 커밋하게 할 수 있습니다. 하지만 이 기법을 남용하면 부분 커밋이 발생합니다. 예컨대, 하나의 비즈니스 작업에서 메인 데이터는 롤백되었지만 REQUIRES_NEW로 수행한 로그는 이미 커밋되는 식으로 데이터의 일관성이 깨질 수 있습니다. 이는 다중 데이터소스를 사용할 때도 유사하게 발생하는데, 기본 트랜잭션 매니저 하나만 작동하여 다른 데이터베이스 연산은 트랜잭션이 적용되지 않으면 한쪽만 커밋되는 문제가 됩니다.
정합성이 깨지는 방식: 잘못된 트랜잭션 전파나 @Transactional 사용 미숙으로 인해 원자성(Atomicity)이 보장되지 않으면 데이터 정합성이 훼손됩니다. 부분 커밋/부분 롤백이 대표적입니다. 예를 들어, 주문 생성 서비스에서 주문 데이터와 재고 감소를 각각 다른 트랜잭션으로 처리하면, 재고 감소는 커밋됐지만 주문 생성은 롤백되는 모순이 발생할 수 있습니다. 이는 시스템 입장에서 재고는 줄었는데 주문 데이터는 없는 불일치 상태를 만들죠. 또한 예상과 달리 트랜잭션이 커밋되지 않는 경우(예외를 잘못 처리한 경우 등) 데이터베이스에는 반영이 안 되었는데 애플리케이션 로직은 성공으로 판단하여 이후 처리에 문제를 일으킬 수 있습니다. 요컨대, 트랜잭션 경계 설정 오류는 데이터의 원자적 일괄처리 실패로 이어져 정합성을 해칩니다.
해결 및 예방: 우선 @Transactional의 동작 원리와 전파 옵션을 정확히 이해해야 합니다. 동일 클래스 내에서 호출되는 메서드에는 트랜잭션이 적용되지 않으므로, 필요하다면 self-injection 방식이나 TransactionTemplate을 사용하는 등 보완책을 써야 합니다. 또한 체크 예외 vs 언체크 예외에 따른 기본 롤백 전략을 숙지하고, 필요한 경우 rollbackFor를 지정하여 체크 예외도 롤백되도록 설정해야 합니다. 가장 중요한 것은 예외 처리를 할 때 트랜잭션 상태를 고려하는 것입니다. 런타임 예외를 catch 하여 삼켜버리면 앞서 설명한 것처럼 트랜잭션은 롤백-only로 남아 Commit이 일어나지 않으므로, 이런 경우에는 차라리 예외를 다시 던지거나, 해당 예외에 대해 noRollbackFor 옵션을 주어 부분 커밋을 허용하는지를 결정해야 합니다. 예를 들어, 아래와 같은 구조를 보겠습니다.
@Service
public class OrderService {
@Autowired PaymentService paymentService;
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
try {
paymentService.pay(order); // 결제 시도
} catch (PaymentFailedException e) {
// 결제 실패 처리 (예: 재시도 또는 상태 기록)
// 예외를 삼키면 Order는 계속 커밋될까?
}
// 기타 후속 처리...
}
}
@Service
public class PaymentService {
@Transactional
public void pay(Order order) {
paymentGateway.requestPayment(order);
// PaymentFailedException 발생 가능
}
}
위 코드에서 PaymentService.pay에서 런타임 예외(PaymentFailedException이 RuntimeException 상속 가정)가 발생하면 OrderService.placeOrder의 트랜잭션은 rollback-only로 표시됩니다. 설령 catch 해서 placeOrder 메서드를 정상 종료해도 트랜잭션은 커밋되지 않고 Rollback되며, Spring은 UnexpectedRollbackException을 발생시켜 호출 측에 알려줍니다. 따라서 이 상황에서는 주문과 결제를 하나의 트랜잭션으로 묶는 게 타당한지, 아니면 결제 실패 시 주문을 취소하는 로직을 추가해야 할지 요구사항을 따져보고 구현해야 합니다. 만약 결제 실패시에도 주문은 남겨야 한다면, PaymentService.pay를 REQUIRES_NEW 트랜잭션으로 분리하고 예외를 catch하여 처리를 마친 뒤, placeOrder에서는 정상 흐름으로 간주하게 할 수 있습니다. 이렇게 하면 주문 저장 트랜잭션은 롤백되지 않고 커밋되며, 결제 트랜잭션만 별도로 롤백되어 로그를 남기거나 사용자에게 재시도를 요청하는 식으로 처리할 수 있습니다. 반대로 부분 커밋이 원치 않을 경우(all-or-nothing이 필요할 경우)에는, PaymentFailedException을 catch 하지 않고 그대로 던지도록 하여 전체 트랜잭션을 롤백시키거나, catch 하더라도 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
를 호출하여 수동으로 롤백시키는 방법도 있습니다.
추가로, 다중 데이터베이스(DataSource) 사용 시에는 JPA 기본 트랜잭션 매니저가 하나의 DB에만 연결되므로, 두 곳에 저장을 해야 한다면 JTA나 ChainedTransactionManager 설정을 고려해야 합니다. 그렇지 않으면 앞서 언급한 대로 한쪽 DB는 커밋되고 다른 한쪽은 롤백되는 심각한 정합성 문제에 직면할 수 있습니다.
정리하면, 트랜잭션을 설계할 때는 전파 모드(propagation), 격리 수준(isolation), 롤백 대상 예외 등을 면밀히 고민해야 합니다. 기본 전략을 바꾸지 않더라도, 서비스 계층의 트랜잭션 경계를 명확히(가능하면 비즈니스 단위당 한 곳) 하고, 복잡한 내부 호출 구조가 있다면 트랜잭션 전파와 예외 처리 규칙을 팀원 전체가 공유하는 것이 좋습니다. 또한 테스트를 통해 예외 시나리오에서 커밋/롤백 동작을 검증함으로써, 실무에서 데이터가 예상과 다르게 처리되어 정합성이 깨지는 일을 방지해야 합니다.
발생 배경: 멀티스레드 환경 또는 다중 인스턴스(멀티서버) 환경에서 동일한 데이터에 동시에 접근하여 수정하는 상황에서 흔히 동시성 문제(Concurrency Issue)가 발생합니다. 이러한 상황을 흔히 Race Condition이라고도 부릅니다. 예를 들어 재고 감소 로직을 생각해보겠습니다. 재고 테이블에서 상품의 수량을 줄이는 decrease() 메서드를 여러 스레드가 동시에 호출하면, 서로 간의 연산이 간섭하여 결과적으로 일부 감소가 반영되지 않거나, 심지어 재고 수량이 음수가 되는 문제가 생길 수 있습니다. 한 세션(트랜잭션)이 데이터를 수정하는 중에, 다른 세션이 수정 전의 데이터를 읽어서 로직을 처리하면 데이터 정합성이 깨질 수 있다고 정의됩니다. JPA에서는 기본적으로 “마지막 커밋 우승” 원칙에 따라 별도의 조치가 없으면 충돌을 감지하지 못하고, 먼저 커밋된 내용 위에 나중 커밋이 덮어씌워질 수 있습니다.
증상 및 사례: 동시성 문제의 대표적인 증상은 계산 결과의 불일치입니다. 예를 들어 재고 100개에서 100명의 사용자가 동시에 1씩 감소시키는 요청을 보내면, 이론적으로 재고가 0이 되어야 합니다. 그러나 동시성 문제가 있다면 결과 값이 0이 아니라 5나 7처럼 남아있거나(몇 번의 감소가 반영되지 않음), 경우에 따라 재고가 -1 이하(음수)로 내려가는 비정상적인 결과가 나타날 수도 있습니다. 실제 사례로, 어떤 서비스에서 동시에 10개의 좋아요를 추가하는 기능을 테스트했는데, 기대치는 10이지만 간헐적으로 8이나 9로 저장되는 것을 발견했다면 이는 동시성 이슈로 인한 업데이트 손실(Lost Update)일 가능성이 높습니다. 또한 데이터베이스의 일관성 제약 조건을 위반하는 에러가 날 수도 있습니다. 예를 들어 재고 수량에 대한 CHECK 제약(음수 불가)이 있으면, 동시 업데이트로 수량이 음수가 되어버려 커밋 시 DB 에러로 나타나기도 합니다.
정합성이 깨지는 방식: 동시성 문제는 여러 스레드나 프로세스가 순서에 대한 가정이 깨질 때 발생합니다. 두 트랜잭션 A와 B가 있다고 할 때,
잃어버린 업데이트(Lost Update): A가 읽은 값을 기반으로 업데이트를 수행하고 커밋했지만, B도 같은 원본 값을 읽어 업데이트한 후 나중에 커밋함으로써 A의 업데이트를 덮어씁니다. A의 작업이 사라져버려 데이터가 일부 손실됩니다.
더티 읽기/반복 가능하지 않은 읽기/팬텀 읽기 등의 문제가 나타나기도 하지만, JPA 기본 설정에서는 보통 READ_COMMITTED 이상을 쓰므로 직접적인 더티 읽기는 없더라도 Repeatable Read가 보장되지 않아 발생하는 문제(예: 재고를 두 번 조회할 때 그 사이 다른 커밋이 일어나 값이 달라짐)도 있습니다.
음수 재고, 중복 계산 등은 결국 원자성이 보장되지 않아 논리적으로 모순된 상태를 만들어냅니다. 이처럼 동시성 이슈는 데이터베이스는 오류를 인지 못한 채, 비즈니스적으로 잘못된 상태를 만들어내기 때문에 나중에 큰 문제가 됩니다.
해결 및 예방: 동시성 문제를 해결하려면 한 시점에는 하나의 쓰레드(또는 트랜잭션)만 해당 데이터에 접근하도록 제어해야 합니다. 일반적으로 아래와 같은 전략들이 있습니다.
낙관적 락 (Optimistic Lock): 충돌이 거의 없을 것으로 가정하고, 각 트랜잭션이 작업을 수행한 뒤 커밋 시점에 충돌 여부를 검증하는 방법입니다. JPA에서는 엔티티에 @Version 필드를 추가함으로써 자동으로 낙관적 락을 사용할 수 있습니다. 예를 들어 @Version private int version; 필드를 추가하면, 엔티티를 수정하여 커밋할 때 WHERE 절에 이전 버전 번호를 포함한 업데이트를 수행합니다. 만약 다른 트랜잭션이 먼저 해당 엔티티를 변경하여 버전이 달라졌다면 업데이트 결과가 0행에 적용되고 JPA는 OptimisticLockException을 발생시킵니다. 이 예외를 통해 충돌을 감지할 수 있고, 애플리케이션은 재시도 로직을 구현하거나 사용자에게 오류를 알릴 수 있습니다. 낙관적 락은 동시성 충돌이 빈번하지 않은 시스템에서 성능 이점을 유지하면서 정합성을 확보하는데 유용합니다.
비관적 락 (Pessimistic Lock): 충돌이 일어날 것이라고 가정하고 데이터를 선점 락으로 보호하는 방법입니다. JPA에서는 @Lock(PESSIMISTIC_WRITE)를 리포지토리 메서드에 붙이거나, JPQL에 FOR UPDATE 키워드를 사용해 쿼리를 실행함으로써 DB 차원에서 해당 로우를 잠그게 할 수 있습니다. 예를 들어 Spring Data JPA에서는 Stock findByProductId(Long productId); 메서드에 @Lock(LockModeType.PESSIMISTIC_WRITE)를 붙여 호출하면, SELECT 쿼리에 for update가 포함되어 다른 트랜잭션이 해당 행을 건드릴 수 없도록 잠금을 겁니다. 이 방식은 동시에 접근하는 트랜잭션이 많아도 순차적으로 처리되도록 보장하여 정합성을 지키지만, 항상 락을 거므로 응답 지연과 데드락의 위험, 전반적인 처리량 감소 같은 단점이 있습니다.
원자적 업데이트 쿼리 활용: 애플리케이션 레벨에서 JPA 영속성 컨텍스트로 처리하지 않고, DB에 직접 조건부 업데이트 쿼리를 보내는 방법입니다. 예를 들어 재고 감소 시 UPDATE stock SET quantity = quantity - 1 WHERE id = ? AND quantity >= 1 와 같이 한 쿼리로 감소와 조건 체크를 모두 처리하면, 여러 트랜잭션이 동시 실행되어도 DB가 내부적으로 락을 걸고 업데이트를 수행합니다. 업데이트 결과로 영향을 받은 행 수를 확인하여 0이면 재고 부족으로 간주하는 식으로 로직을 구성할 수 있습니다. 이러한 방법은 DB의 기능에 의존하지만, 어플리케이션에서 락을 거는 것보다 간결할 수 있습니다. Spring Data JPA에서는 @Modifying 어노테이션으로 업데이트 쿼리를 실행할 수 있으며, 이 때 clearAutomatically=true를 설정해서 영속성 컨텍스트의 캐시를 갱신 또는 초기화해주는 것이 중요합니다 (bulk 업데이트는 1차 캐시를 무시하기 때문에, 업데이트 후 em.clear()나 해당 옵션으로 메모리 상의 정합성도 맞춰줘야 합니다).
분산 락/별도 락 시스템: 하나의 서버 인스턴스 내에서는 synchronized 블록이나 java.util.concurrent.locks.Lock을 사용할 수 있지만, 서버 여러 대라면 효과가 없습니다. 이때는 Redis와 같은 외부 시스템으로 락을 구현하기도 합니다. 예를 들어 Redisson 라이브러리를 사용하면 Redis의 pub-sub 기반 분산락을 쉽게 사용할 수 있으며, 한 쓰레드가 락 해제 시점에 대기 중인 다른 쓰레드에게 신호를 주어 락을 획득하게 하는 방식으로 동작합니다. 분산 락은 잘만 사용하면 효과적이지만, 구현의 복잡성과 잠재적 단일 장애점(Redis 자체 장애) 등을 고려해야 합니다.
위 해결책들을 적용하여 멀티쓰레드 환경에서 데이터의 정합성을 보장할 수 있습니다. 예를 들어, 재고 감소 예제를 낙관적 락으로 해결한다면 다음과 같습니다:
@Entity
public class Stock {
@Id @GeneratedValue
private Long id;
private Long productId;
private Long quantity;
@Version
private int version; // 낙관적 락 버전 필드
public void decrease(Long amount) {
if (this.quantity - amount < 0) {
throw new IllegalStateException("재고 부족");
}
this.quantity -= amount;
}
}
// 서비스에서 낙관적 락 예외 처리
@Transactional
public void decreaseStock(Long productId, Long amount) {
try {
Stock stock = stockRepository.findByProductId(productId)
.orElseThrow();
stock.decrease(amount);
// JPA @Version 필드로 인해 flush 시 충돌 시 OptimisticLockException 발생
} catch (OptimisticLockException oe) {
// 재시도 또는 오류 처리 로직
throw oe;
}
}
혹은 비관적 락 적용 예시:
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.productId = :productId")
Optional<Stock> findByProductIdForUpdate(@Param("productId") Long productId);
}
// 서비스에서 비관적 락 사용
@Transactional
public void decreaseStock(Long productId, Long amount) {
Stock stock = stockRepository.findByProductIdForUpdate(productId)
.orElseThrow();
stock.decrease(amount);
// 트랜잭션 끝날 때까지 해당 row는 락이 유지됨
}
이렇게 하면 동시에 100개의 쓰레드가 와도 한 번에 하나씩 처리되어 재고 감소 로직의 정합성을 지킬 수 있습니다. 다만 비관적 락은 트래픽이 매우 많을 경우 병목이 될 수 있으므로, 낙관적 락으로 처리 가능한 시나리오인지 우선 고려하고, 실제 충돌 빈도가 낮다면 낙관적 락을 사용하는 편이 성능에 유리합니다.
마지막으로, 격리 수준(Isolation Level)도 동시성 정합성에 영향을 줍니다. 기본적으로 MySQL InnoDB는 REPEATABLE READ를 기본 격리수준으로 해 동일 트랜잭션 내에서 반복 조회 시 일관된 스냅샷을 제공합니다. 반면 PostgreSQL은 기본이 READ COMMITTED이어서 한 트랜잭션 내라도 쿼리마다 다른 커밋 내용을 볼 수 있습니다. 만약 동일 트랜잭션 내에서 두 번 조회해야 하는 상황(예: 보고서 생성 중 통계 쿼리를 두 번 실행)이 있는데 DB에 따라 값이 달라진다면, MySQL에서는 아무 문제 없던 로직이 PostgreSQL에서는 한 트랜잭션 사이에 팬텀 리드로 결과가 달라지는 문제가 생길 수 있습니다. 이런 경우 Spring @Transactional 애너테이션의 isolation = Isolation.REPEATABLE_READ 속성을 PostgreSQL에서 지정하여 격리 수준을 높여주면 동일한 쿼리에 대해 일관된 결과를 얻을 수 있습니다.
요약하면, 동시성으로 인한 정합성 깨짐은 엔터프라이즈 애플리케이션에서 매우 치명적인 이슈이므로, 락이나 버전 관리 등을 통해 반드시 예방해야 합니다. 각 접근 방식의 트레이드오프(성능 vs 정합성 보장 수준)를 이해하고 시나리오에 맞는 방법을 적용하는 것이 중요합니다.
발생 배경: 데이터베이스는 데이터 정합성을 유지하기 위해 NOT NULL, UNIQUE, FOREIGN KEY, CHECK 등의 무결성 제약을 제공합니다. 한편 애플리케이션에서도 JPA 엔티티의 애너테이션(@Column(nullable = false), @UniqueConstraint 등)이나 코드 상의 검증으로 비슷한 제약을 검증합니다. 문제가 되는 경우는 DB 제약과 애플리케이션 로직의 불일치 또는 제약 부재입니다. 실무에서는 개발 편의 또는 성능상의 이유로 DB 제약을 일부 생략하기도 하고, 오로지 애플리케이션 레벨 검증만으로 데이터 정합성을 유지하려는 경우가 있습니다. 그러나 이런 접근은 동시성, 버그, 이기종 시스템 연동 등의 상황에서 쉽게 한계에 부딪힙니다.
증상 및 사례:
NULL 불일치: 엔티티 필드에는 @NotNull이나 nullable = false로 명시했지만, DB 컬럼에는 NOT NULL 제약이 없는 경우입니다. 이때 애플리케이션은 해당 필드가 항상 값이 있다고 가정하지만, 만약 어떤 버그나 예상치 못한 입력으로 null이 들어가 DB에 저장되면, 이후 이 레코드를 조회하여 처리하는 로직에서 NullPointerException이나 누락된 데이터로 인한 오류가 발생할 수 있습니다. 반대로, DB는 NOT NULL인데 애플리케이션 쪽 로직이 그 제약을 인지 못해서 null을 넣으려 한다면, DB에서 ConstraintViolationException (예: JDBC의 SQLException)이 터지고 트랜잭션이 롤백됩니다. 만약 이 예외를 적절히 처리하지 않으면 사용자 입장에서는 이유를 모른 채 요청이 실패하는 상황이 벌어집니다.
유니크 제약 부재: "사용자 이메일은 유니크해야 한다"는 규칙을 가정해보면, DB에 Unique 인덱스를 걸지 않고 애플리케이션에서만 userRepository.findByEmail()로 존재 여부를 확인 후 저장한다고 해봅시다. 이 방식은 단일 쓰레드 환경에서는 동작하지만, 동시 요청 두 개가 거의 동시에 같은 이메일로 회원가입을 시도하면 둘 다 find 쿼리에서는 없다고 판단하고 insert를 수행해 중복된 데이터가 DB에 들어갈 수 있습니다. 결과적으로 하나의 이메일에 두 계정이 생성되는 데이터 정합성 위반이 발생하는 것이죠. 이런 문제는 분산 락이나 DB 제약 없이는 완벽히 막기 어렵습니다. 반면 Unique 제약을 DB에 두었다면, 둘 중 하나의 트랜잭션은 커밋 시 제약 위반으로 실패하여 애플리케이션 쪽에 예외가 던져집니다. 개발자는 이 예외를 캐치하여 "이미 존재하는 이메일입니다"와 같이 처리하면 됩니다. DB 제약이 없다면 애플리케이션은 이러한 race condition을 스스로 해결하기 매우 까다롭습니다.
외래 키(FK) 제약 부재: 외래 키가 없는 경우, 부모-자식 엔티티 간 참조 무결성이 강제되지 않습니다. 예를 들어 주문과 주문상세 테이블에서, 외래 키 제약이 없다면 애플리케이션의 실수나 다른 배치 작업 등으로 부모 없는 자식(고아 레코드)이 생길 수 있습니다. 반대로, 자식 레코드가 있는데도 부모 레코드를 삭제해버려도 DB는 막지 않으니 연관관계가 깨진 데이터가 남습니다. 이런 상태를 애플리케이션이 인지 못한 채 진행하면 조회 시 NPE가 발생하거나, 합산/집계 시 잘못된 값이 나오는 등 문제가 됩니다. 외래 키 제약을 두면 이러한 상황에서 DB에서 무결성 오류가 발생하여 트랜잭션을 롤백시켜주므로, 적어도 잘못된 데이터가 저장되는 것은 사전에 차단됩니다.
정합성이 깨지는 방식: 위 사례들의 공통점은 애플리케이션이 예상하지 못한 이상치 데이터가 DB에 존재하게 되어 논리적 모순을 일으킨다는 것입니다. Null이어서는 안 될 곳에 Null이 들어가거나, 유일해야 할 값이 중복되거나, 참조가 끊어진 데이터가 존재하면, 비즈니스 로직은 더 이상 모든 경우를 커버하지 못하고 오동작하거나 잘못된 결과를 산출하게 됩니다. 예컨대, 중복 이메일 계정이 두 개 있다면 "이메일로 사용자 찾기" 로직이 두 엔티티를 반환하여 혼란이 생길 수 있고, 잘못하면 인증이나 권한 체계에 보안 이슈까지 생길 수 있습니다. 또한 정합성 문제가 누적되면 데이터 신뢰도가 떨어져, 나중에 올바른 통계나 계산도 할 수 없게 될 수 있습니다.
해결 및 예방: 가능하면 데이터베이스에 엄격한 제약을 부여하는 것이 1차 방어선입니다. 중요한 무결성 조건(유일성, NOT NULL, FK 등)은 반드시 DB에서도 같은 제약을 설정하고, 애플리케이션은 가급적 오류를 미리 방지하도록 추가 검증을 합니다. DB 제약 설정으로 얻는 장점은, 애플리케이션 버그나 예상치 못한 경로를 통해 잘못된 데이터가 들어오더라도 DB가 마지막 수문장 역할을 해준다는 것입니다. 물론 사용자 경험을 위해 사전에 체크해서 예외를 던지지 않도록 하는 것이 좋지만, 이중으로 걸어두는 것이 안전합니다. 그리고 DB 제약으로 인해 예외가 발생할 수 있다는 것을 개발자는 인지하고, 해당 예외 (DataIntegrityViolationException 등으로 스프링에서 wrapping됨)를 잡아 적절한 오류 메시지로 변환해야 합니다.
예를 들어 JPA 엔티티를 정의할 때부터 DB 제약과 일치하도록 합니다:
@Entity
@Table(name = "users", uniqueConstraints = {@UniqueConstraint(name="UK_user_email", columnNames="email")})
public class User {
@Id @GeneratedValue
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
// ...
}
위와 같이 하면 DDL 생성 시 유니크 인덱스와 NOT NULL이 반영되고, 실수로 애플리케이션 로직에서 중복 이메일 저장을 시도하면 DB에서 에러가 발생합니다. 이 예외를 try-catch로 잡아서 "이미 존재하는 이메일"이라고 안내하거나, 스프링의 Validator 등을 통해 사전에 검증하여 사용자에게 피드백을 줄 수 있습니다.
또 다른 예로 외래 키의 경우:
ALTER TABLE order_item
ADD CONSTRAINT FK_order_item_order_id
FOREIGN KEY (order_id) REFERENCES orders(id);
이렇게 해두면 잘못된 참조 데이터 저장 시도 시 DB가 에러를 주므로, 개발 단계에서 버그를 빨리 발견할 수 있습니다.
주의할 점은, MySQL과 PostgreSQL 등 DBMS마다 제약 동작이 약간 다를 수 있다는 것입니다. 예를 들어 MySQL은 기본적으로 InnoDB 엔진을 쓰면 외래키를 지원하지만, MyISAM엔 외래키가 무시되는 차이가 있었습니다. 또한 문자열 컬레이션/대소문자 이슈도 있는데, MySQL은 기본이 대소문자 구분 안 하는(unique 제약도 마찬가지) utf8_general_ci 등을 많이 쓰기 때문에 User와 user가 동일로 간주되어 유니크 제약에 막히지만, PostgreSQL은 대소문자를 구분하므로 같은 값을 넣어도 통과될 수 있습니다. 이런 경우 애플리케이션이 MySQL에 맞춰 "대소문자 구분 안 함" 전제로 짰다면 PostgreSQL에서는 중복 데이터가 생길 수 있습니다. 해결책은 PostgreSQL에서도 CITEXT 확장이나 LOWER() 함수 인덱스 등을 이용해 대소문자 무시 동작을 명시적으로 구현하거나, 애플리케이션 레벨에서 일관되게 대소문자를 처리하는 것입니다.
결론적으로, 애플리케이션과 데이터베이스 간 제약 조건의 싱크를 맞추는 것이 중요합니다. 실무에서는 초기 개발 편의를 위해 DB 제약을 느슨하게 했다가, 나중에 데이터 품질 이슈가 생겨 큰 공수를 들여 정제하는 일이 생기곤 합니다. 처음부터 중요한 비즈니스 제약은 DB와 애플리케이션 양쪽에 확실히 걸어두고, 어느 한 쪽에서 예외가 나더라도 전체적으로 데이터의 무결성은 지켜지도록 방어적으로 설계하는 것이 좋습니다. 특히 Unique 제약의 경우 애플리케이션 단 검증만으로는 동시성 상황을 완벽히 처리하기 어려우므로, 반드시 DB 제약으로 보강하고 발생할 수 있는 예외를 처리하는 패턴을 갖추는 것을 권장합니다.
발생 배경: MySQL과 PostgreSQL은 모두 관계형 DB이지만, 트랜잭션 동작과 일부 기능에서 차이가 있습니다. 다중 DB 환경이라 함은 두 가지를 의미할 수 있습니다. (1) 하나의 애플리케이션이 두 가지 데이터베이스를 동시에 사용하는 경우, (2) 또는 개발/운영 환경 등에서 데이터베이스 종류가 달라 생기는 차이입니다. 첫 번째 경우에는 앞서 언급한 복수 트랜잭션 매니저 이슈(분산 트랜잭션)가 나타나고, 두 번째 경우에는 각 DB의 동작 차이로 인한 정합성 문제가 주로 발생합니다.
증상 및 사례 (1): 한 서비스에서 MySQL, PostgreSQL 둘 다 사용하는 경우: Spring Boot 애플리케이션에서 다중 DataSource를 설정해 MySQL과 PostgreSQL에 모두 연결하여 작업한다고 가정해봅시다. 일반적인 설정에서는 @Primary로 지정된 하나의 PlatformTransactionManager만 @Transactional에 적용되므로, 트랜잭션이 하나의 DB에만 걸리고 다른 DB 작업은 별개로 처리될 수 있습니다. 예를 들어 MySQL에 주문을 저장하고 PostgreSQL에 결제 정보를 저장하는 로직을 한 메서드에서 수행하면, 기본 TM인 MySQL 쪽은 트랜잭션이 관리되지만 PostgreSQL 쪽 저장은 즉시 커밋되거나 아예 트랜잭션 없이 진행될 수 있습니다. 이 때 PostgreSQL 쪽에서 오류가 발생하면 MySQL 쪽 트랜잭션은 롤백되겠지만, 이미 PostgreSQL에 커밋된 데이터는 되돌릴 방법이 없습니다. 결과적으로 두 DB 간 데이터가 불일치하게 됩니다. 반대로 MySQL 커밋 후 PostgreSQL에서 오류나면 MySQL 쪽은 rollback이 안 되니(이미 커밋됨) 이 역시 전체 시스템 상태가 모순됩니다.
증상 및 사례 (2): DB 종류 차이로 인한 정합성 문제: MySQL과 PostgreSQL은 SQL 표준 준수도, 내부 구현 등이 달라 쿼리 동작이나 제약에 차이가 있습니다. 한 가지 예는 앞서 언급한 트랜잭션 격리 수준 기본값입니다. MySQL(InnoDB)은 기본 REPEATABLE READ이고, PostgreSQL은 READ COMMITTED입니다. 어떤 서비스가 MySQL 환경에서 개발되었는데, PostgreSQL로 교체되거나 둘 다 지원해야 하는 상황이라면, 동일 트랜잭션 내 연속 조회 시 MySQL에서는 일관된 결과가 나오지만 PostgreSQL에서는 중간에 다른 커밋이 보이는 케이스가 생길 수 있습니다. 예를 들어, 재무 보고서를 생성하는 로직이 트랜잭션 안에서 여러 쿼리를 통해 통계를 누적한다고 할 때, MySQL에서는 처음 트랜잭션 시작 시점의 스냅샷으로 고정되어 안전하지만(PostgreSQL에서 SERIALIZABLE 수준에 가까움), PostgreSQL READ COMMITTED에서는 각 쿼리마다 최신 커밋을 보므로 집계 중간에 데이터가 추가되어 합계가 달라지는 현상이 있을 수 있습니다. 또 다른 차이는 문자 인코딩과 정렬입니다. MySQL은 기본적으로 utf8mb4_general_ci 같은 대소문자 구분 없는(collation) 정렬을 쓰는 반면, PostgreSQL은 대소문자 구분합니다. 그러다보니, 예를 들어 고객명에 대한 정렬이나 비교 로직이 MySQL에서는 의도대로 됐지만 PostgreSQL에서는 대문자가 먼저 온다거나, Unique 제약이 깨진다거나(예: 'ABC'와 'abc'를 MySQL은 동일시하여 막지만 PostgreSQL은 별도로 막지 않으면 허용) 하는 일이 생깁니다. 또한 MySQL의 TEXT/VARCHAR 컬럼과 PostgreSQL의 TEXT 컬럼은 처리 가능한 문자열 길이나 인덱싱 가능 크기 등이 달라서, 극단적으로는 MySQL에서 되던 INSERT가 PostgreSQL에서는 TEXT too long 에러가 날 수도 있습니다. 날짜와 시간 처리도 미묘하게 다른데, MySQL은 '0000-00-00' 같은 잘못된 날짜를 허용(모드에 따라)하기도 하지만 PostgreSQL은 아예 허용하지 않습니다. 이런 경우 MySQL에서는 넘어가던 잘못된 데이터가 PostgreSQL에서 에러로 드러나며 트랜잭션이 롤백되는 식으로 동일 로직에 대한 결과가 달라질 수 있습니다.
정합성이 깨지는 방식: (1)의 경우는 분산 트랜잭션 미구현으로 인한 부분 커밋으로 정합성 깨짐입니다. 서로 다른 DB에 걸친 작업이 원자적으로 처리되지 않아 데이터불일치가 발생하는 것이지요. (2)의 경우는 DB의 동작 차이 때문에 한쪽에서는 정합하던 연산이 다른 쪽에서는 정합하지 않게 되는 문제입니다. 예컨대 MySQL에서는 동시에 실행된 두 트랜잭션이 특정 조건에서 Phantom Read가 발생하지 않는데, PostgreSQL에서는 발생해서 엔티티 개수가 틀려진다거나 하는 일이 생길 수 있습니다. 혹은 MySQL에서는 허용된 값이 PostgreSQL에선 제약에 막혀서 한쪽 DB에는 기록됐지만 다른 쪽에는 누락되어 데이터 셋의 차이가 생길 수 있습니다.
해결 및 예방:
다중 DB 분산 트랜잭션: 만약 하나의 비즈니스 로직에서 MySQL과 PostgreSQL 모두에 업데이트해야 한다면, Spring에서 지원하는 ChainedTransactionManager나 JTA (Atomikos, Bitronix 등의 2PC 구현체)로 분산 트랜잭션을 구성해야 합니다. ChainedTransactionManager는 순차 커밋/역순 롤백을 통해 비교적 단순히 처리하지만 100% 원자적이진 않고, JTA는 철저히 2-phase commit으로 ACID를 보장하지만 설정이 복잡합니다. 규모가 큰 시스템에선 차라리 마이크로서비스 분리나 최종 일관성(사후 보정) 전략을 고려하기도 합니다. 중요한 것은, 아무 조치 없이 두 DB를 다루면 절대 안 된다는 점입니다. 트랜잭션 매니저가 하나만 사용하는 것을 인지하고 필요한 조치를 취해야 합니다. 만약 기술적 한계나 복잡성으로 분산 트랜잭션을 쓰기 어렵다면, 한쪽을 완전히 커밋한 후 다른쪽을 처리하고 실패 시 보상 작업을 수행하는 등의 설계(또는 SAGA 패턴)를 도입해야 합니다. 어떤 방법을 쓰더라도, 두 데이터베이스 간 데이터가 어긋나지 않도록 하는 추가 작업이 필수입니다.
DB별 동작 차이 다루기: 애플리케이션을 두 DB에서 모두 동작시키려면, 두 환경에서 모두 테스트를 해보고 미묘한 차이를 잡아내야 합니다. 격리 수준은 @Transactional 옵션이나 DB 세팅을 조정하여 일관된 수준으로 맞춰주는 것이 안전합니다(예: PostgreSQL에도 REPEATABLE READ 사용 고려). 또한 SQL 표준에 어긋나는 MySQL 전용 쿼리나 관용구(INSERT ... ON DUPLICATE KEY UPDATE 같은)는 PostgreSQL에서는 통하지 않으므로, JPA/QueryDSL 같은 추상화를 통해 DB에 맞게 SQL이 생성되도록 하는 것이 좋습니다. 문법 차이 외에도 함수나 자료형의 차이도 검토합니다. 예를 들어 MySQL의 JSON 타입 vs PostgreSQL의 JSONB 타입은 사용하는 함수가 다르니, JPA @Type 처리 등을 DB별로 분기해야 할 수 있습니다. 이러한 차이가 엮여서 데이터 정합성 문제로 이어지는 경우도 있습니다(예: JSON 필드에 일부 DB에서는 허용된 구조가 다른 DB에서는 파싱 오류 나는 경우 등).
마이그레이션 시 데이터 정제: 만약 MySQL -> PostgreSQL 식으로 이행을 한다면, 이전 DB에서 허용되던 어긋난 데이터(예: 잘못된 날짜, 중복 데이터 등)를 미리 찾아내 정리하고 옮겨야 정합성을 유지할 수 있습니다. 이 부분을 간과하면 PostgreSQL에서는 migration script 자체가 에러나서 일부 데이터는 건너뛰게 되거나, 가져왔다 해도 애플리케이션에서 문제를 일으킬 수 있습니다.
환경 설정 통일: MySQL의 경우 sql_mode 설정에 따라 동작이 달라집니다. STRICT 모드를 켜면 유효성 엄격히 체크하나, 꺼져 있으면 경고만 주고 잘못된 데이터를 받아들이기도 합니다. 가능하면 양쪽 DB 모두 엄격한 모드로 설정하여 오류를 사전에 드러내고, 애플리케이션이 이를 처리하게 하는 것이 정합성에 유리합니다. 예를 들어 MySQL의 STRICT_ALL_TABLES, NO_ZERO_DATE 등의 모드를 활성화해 PostgreSQL과 유사한 수준의 엄격함을 유지하면, 한쪽에서는 조용히 넘어가고 다른쪽에서 오류나는 상황을 줄일 수 있습니다.
정리하면, 이기종 DB를 동시에 다룰 때는 두 가지 축에서 정합성을 고민해야 합니다: 트랜잭션 일관성(분산 트랜잭션 여부)과 기능 동작의 일관성(DB별 차이 조정). 이를 위해 Spring의 트랜잭션 매니저 설정을 확인하고, 애플리케이션 로직을 DB 중립적으로 구현하며, 필요시 DB 벤더별 프로필이나 DAO를 분리하여 관리하는 전략도 고려해야 합니다. 충분한 사전 호환성 테스트와 설정 조정만이 다중 DB 환경에서 데이터 정합성을 지키는 길입니다.
발생 배경: QueryDSL은 타입 안정성과 동적 쿼리 작성에 매우 유용하지만, JPA 표준 JPQL을 사용하는 만큼 개발자의 선택에 따라 쿼리의 완전성이 좌우됩니다. JPQL을 직접 작성할 때 겪는 어려움(예: 복잡한 fetch join)을 QueryDSL이 단순화해주지만, 개발자가 어떤 조인을 어떻게 했는지에 따라 예상치 못한 정합성 문제가 발생하기도 합니다. 특히 fetch join을 빼먹거나, select 절을 부적절하게 구성하면 엔티티 중복, 누락, LazyInitializationException 등의 문제가 생길 수 있습니다.
증상 및 사례:
fetch join 누락으로 인한 N+1 재발: QueryDSL로 조인을 걸었다고 해서 자동으로 연관 엔티티가 초기화되는 것은 아닙니다. query.selectFrom(team).leftJoin(team.members, member).fetch()처럼 fetchJoin 없이 조인만 하면 이는 단순히 SQL의 JOIN으로 결과를 가져오지만, JPA 영속성 컨텍스트에는 기본 fetch 전략(지연 로딩)대로 team.members가 프록시 상태로 남습니다. 따라서 결과를 처리하는 과정에서 team.getMembers()를 호출하면 다시 N+1 쿼리가 발생하게 됩니다. 개발자는 QueryDSL로 조인했으니 해결됐다고 착각할 수 있지만, .fetchJoin()을 누락하면 여전히 N+1 문제가 남아있는 것입니다. 이로 인해 앞서 1번에서 언급한 것과 동일한 정합성(및 성능) 문제가 재발할 수 있습니다.
조인으로 인한 엔티티 중복: QueryDSL을 이용해 다대일 관계 등을 JOIN으로 가져올 때, select 절에 기본 엔티티를 사용하면(예: selectFrom(team).leftJoin(team.members, member)) Hibernate는 루트 엔티티인 Team을 중복 포함한 결과를 여러 개 리턴할 수 있습니다. 예를 들어 Team 1에 Member 3명이 있으면, JOIN 결과는 3행으로 나오고 JPA는 Team 엔티티 3개(동일 식별자)로 인식하여 List에 넣을 수 있습니다. 일반적인 JPA find에서는 중복을 걸러주지만, QueryDSL의 programmatic query에서는 .distinct()를 명시하지 않으면 프로그래머가 이 중복을 직접 다뤄야 합니다. 만약 중복을 제거하지 않고 그대로 처리하면, Team 리스트 사이즈가 기대보다 커지거나, Team 1이 여러 번 나타나기도 합니다. 개발자가 이를 모르고 팀 개수를 센다거나 할 경우 잘못된 계산을 할 위험이 있습니다. 또한 중복 엔티티가 존재하면, JPA는 기본적으로 같은 식별자의 엔티티는 영속성 컨텍스트에서 동일 객체로 취급하지만, 컬렉션에 중복으로 들어간 경우 혼란을 초래할 수 있습니다. (참고로 Hibernate는 JPQL fetch join의 경우 중복 제거를 알아서 하기도 하지만, 순수 QueryDSL에는 그런 편의가 덜합니다.)
잘못된 select로 인한 불완전한 데이터: QueryDSL은 엔티티가 아닌 특정 필드나 DTO를 select 할 수도 있습니다. 그런데 간혹 개발자가 엔티티의 일부 필드만 조회하도록 QueryDSL을 구성한 뒤, 이를 영속성 컨텍스트에 관리되는 엔티티인 양 잘못 사용하는 경우가 있습니다. 예를 들어 QueryDSL로 QUser.user.name만 select해서 List을 받았는데, 이를 엔티티와 결합해 처리하려다보니 정합성 오류가 나는 식입니다. 또는 복잡한 동적 조건 조합 중에 일부 조건을 빼먹어서, 의도와 다르게 더 많은(혹은 적은) 데이터가 조회되어 로직상 문제가 될 수도 있습니다. 이런 건 순전히 쿼리 버그지만, QueryDSL은 컴파일 타임에 오류를 못 잡아주므로 런타임에 잘못된 결과로 드러나 정합성 이슈로 나타납니다.
복잡한 fetch join 제약: JPA의 제약으로 fetch join은 둘 이상의 컬렉션에 적용할 수 없습니다. 그러나 복잡한 요구사항 때문에 QueryDSL로 여러 조인을 한 번에 하려다 보면, 실수로 두 개 이상의 컬렉션을 fetchJoin하는 쿼리를 만들어 런타임 예외를 만나기도 합니다. 또는 fetch join과 paging (.offset, .limit)을 함께 쓰면 Hibernate가 경고를 내거나 페이징을 제대로 적용하지 못합니다. 이 경우 결과 데이터가 잘못 잘려나가거나, 아예 예외로 작동이 안 되어 데이터를 화면에 표시하지 못하는 상황이 벌어질 수 있습니다.
정합성이 깨지는 방식: 요컨대, QueryDSL로 작성한 쿼리가 개발자의 의도와 다른 SQL을 생성하거나, JPA의 규약을 지키지 못했을 때 데이터 정합성에 문제가 발생합니다. 잘못된 조인이나 누락된 fetch join 등으로 필요한 데이터가 일부 누락되거나 중복으로 잘못 전달되면, 최종 비즈니스 로직 처리 결과가 틀어지게 됩니다. 예를 들어 N+1 문제가 남아서 일부 객체가 업데이트되지 않은 상태로 화면에 보여지거나, 중복 엔티티로 인해 합계를 두 번 계산하는 등의 오류가 발생할 수 있습니다. 또한 LazyInitializationException은 말할 것도 없이 데이터를 아예 가져오지 못하는 불일치 상태를 초래합니다.
해결 및 예방: QueryDSL을 사용할 때도 JPA의 기본 원리를 항상 염두에 둬야 합니다. 구체적인 가이드는 다음과 같습니다.
List<Team> teams = queryFactory.selectFrom(QTeam.team)
.leftJoin(QTeam.team.members, QMember.member).fetchJoin()
.distinct()
.fetch();
이렇게 .distinct()를 추가하면 팀이 중복으로 List에 담기는 것을 방지할 수 있습니다. 혹은 아예 처음부터 DTO로 필요한 데이터만 조회하면 중복 문제가 피하기 쉬울 때도 있습니다. (예: 팀명과 멤버수 같이 집계해서 DTO로 받기 등)
마지막으로, QueryDSL은 강력하지만 개발자 편의에 따라 중요한 쿼리 부분을 자동으로 처리해주지 않는다는 점을 기억해야 합니다. JPA 자체의 모범 사용 사례를 따르는 한 QueryDSL도 큰 문제가 없지만, 방심하면 미묘한 정합성 이슈가 발생할 수 있습니다. 따라서 쿼리 작성 시 하나하나 의미를 따져보고, 특히 조인 시 의도한 데이터만 가져오고 중복이 없도록 신경 써야 합니다. 실무에서 QueryDSL로 복잡한 쿼리를 만들 때는 동료 리뷰 등을 통해 SQL 결과를 함께 검증하는 것도 정합성 오류를 줄이는 좋은 방법입니다.
이상으로 Spring Data JPA 환경에서 자주 만나는 데이터 정합성 이슈들을 살펴보았습니다. 지연 로딩으로 인한 N+1부터 트랜잭션 관리 실수, 동시성 문제, 데이터베이스 제약, 이기종 DB 차이, 그리고 QueryDSL 사용 시 주의사항까지 실제 사례 위주로 다뤄봤습니다.
정합성(Integrity)은 시스템의 신뢰성과 직결됩니다. 눈에 띄는 장애는 없더라도, 데이터가 조금씩 어긋난 채 쌓이면 나중에는 비즈니스에 큰 손실을 입히거나, 사용자의 신뢰를 잃는 결과를 초래합니다. 실무 개발자는 다음과 같은 교훈을 얻을 수 있을 것입니다:
현업에서 흔히 접하는 이슈들인 만큼, 위 내용들을 숙지하고 예방하면 우리 서비스의 데이터 신뢰성을 크게 높일 수 있습니다. 작은 설정 하나, 사소한 코드 한 줄 차이로도 데이터 정합성에는 큰 영향이 갈 수 있으므로, 늘 꼼꼼히 검토하고 테스트하는 습관이 필요합니다. 데이터는 곧 자산이기에, 개발 단계부터 정합성에 대한 고민을 놓치지 말아야 할 것입니다.
[JPA/QueryDSL] N+1 문제 해결하는 방법(EntityGraph, fetchJoin) — 혁키의 개발일지
https://9hyuk9.tistory.com/91
[JPA] 페치 조인(fetch join) - 한계 | 공부하고 정리하는 공간
https://ym1085.github.io/jpa/JPA-%ED%8E%98%EC%B9%98%EC%A1%B0%EC%9D%B8-%EB%A7%88%EB%AC%B4%EB%A6%AC/
Spring @Transactional mistakes everyone did | by Alexander Kozhenkov | Javarevisited | Medium
https://medium.com/javarevisited/spring-transactional-mistakes-everyone-did-31418e5a6d6b
UnexpectedRollbackException and Spring Transaction Management - Ben Northrop
https://www.bennorthrop.com/Essays/2019/unexpectedrollbackexception-and-spring-transaction-management.php
Explicit @Transactional Rollbacks in Spring Boot | by harsh shah | Medium
https://medium.com/@shahharsh172/explicit-transactional-rollbacks-in-spring-boot-3c4a46626f2d
Spring 동시성 문제(데이터 정합성) 뿌셔보기~
https://velog.io/@fill0006/Spring-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%BF%8C%EC%85%94%EB%B3%B4%EA%B8%B0
[JPA] 동시성과 데이터 정합성(feat. 더티체킹)
https://velog.io/@shwncho/JPA-%EB%8F%99%EC%8B%9C%EC%84%B1%EA%B3%BC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%A0%95%ED%95%A9%EC%84%B1feat.-%EB%8D%94%ED%8B%B0%EC%B2%B4%ED%82%B9
Should I check for DB constraints in code or should I catch ... (StackOverflow)
https://stackoverflow.com/questions/405359/should-i-check-for-db-constraints-in-code-or-should-i-catch-exceptions-thrown-by