JPA 사용시 동시성 이슈

wwlee94·2022년 10월 30일
2

트러블 슈팅

목록 보기
2/3
post-thumbnail

동시성 이슈 내용 공유

Kafka & JPA 사용시 발생하는 동시성 이슈에 관하여 내용을 공유함.

이슈에 대해 결론을 먼저 이야기 하자면, 동일한 테이블에 대해 쓰기 작업하는 A, B컨슈머가 있었는데 여러 서비스에서 거의 동시에 요청이 오면서 서로의 데이터를 덮어 씌워버리는 현상이 발생함

각 서비스 이름과 담당 역할은 다음과 같다.
ACC : AI 역량검사 응시 플랫폼
JOBDA : 구직자 채용 플랫폼 (현재 속해 있는 팀)
SOLVER : AI 역량검사 분석 서비스

각 서비스팀 담당자에게 여쭤보니 처리 과정은 다음과 같았다.

ACC 팀 처리 과정

역량검사 AI 면접 응시 완료 이후 ACC팀의 처리 과정

  1. ACC DB에 응시 상태값 업데이트
  2. JOBDA로 응시자 상태값 카프카 발송 (A 컨슈머)
  3. SOLVER로 응시자 분석 요청 API 호출

SOLVER 팀 처리 과정

SOLVER 쪽 분석 요청 API 처리 과정

  1. SOLVER DB에 분석 상태 업데이트
  2. JOBDA로 분석 상태값 카프카 발송 (B 컨슈머)

원인 파악

이야기만 들었을 때는 DB 저장 이후 분석 요청을 보내기 때문에 시간차가 있을 것이라고 생각하여 A 컨슈머 로직이 먼저 수행되고 B 컨슈머 로직이 수행될 줄 알았지만 컨슈밍 성공 했을때 로깅 데이터를 확인해보니 초 단위로 동일하게 컨슈밍 되었다.

[로깅 데이터]
sn, topic, message, created_date_time
2906, 'acc-cms.v1.xxx', '{"code":"b0cf526bd9d34xxx","examineeStatus":"COMPLETED","applyEndDateTime":"2022-06-07T22:55:27.858715"}', '2022-06-07 22:55:27'
2907, 'solver.v1.xxx', '{"code":"b0cf526bd9d34xxx","analysisStatus":"PROGRESS"}', '2022-06-07 22:55:27'

현재 운영하고 있는 JOBDA의 컨슈머들에서 데이터를 업데이트 하는 방식은 JPA에서 제공하는 Dirty Checking 을 사용하고 있었다.

Dirty Checking 이란?
Transaction 안에서 엔티티의 변경이 일어날 때, 엔티티의 변경 내용을 트랜잭션이 끝나는 시점에 자동으로 데이터베이스에 반영해주는 JPA의 기능이다.

각각의 컨슈머가 업데이트 하는 필드는 서로 다르다.

  • A 컨슈머
    • examineeStatus : 응시 상태 업데이트
    • applyEndDateTime : 응시 완료 일시 업데이트
  • B 컨슈머
    • analysisStatus : 분석 완료 상태 업데이트

따라서, 서로 전혀 영향이 없을 것 같다고 생각했지만, 실제 JPA에서 동작하는 Update 쿼리를 보면 다음과 같이 모든 필드에 대해서 Update 쿼리를 수행한다.

[JPA 업데이트 시 모든 필드에 대해 업데이트하는 쿼리]
update
        acc.apply 
    set
        analysis_type=?,
        code=?,
        end_date_time=?,
        progress_type=?,
        result_type=?,
        result_unreliable_factor=?,
        version=?,
        user_id=?,
        ...
    where
        sn=?

모든 필드에 대한 업데이트를 동시에 수행 하다보니 서로 같은 데이터를 조회 했지만 다르게 데이터를 쓰려고 하여 먼저 변경된 데이터도 사라지는 경우도 생기고, DeadLock 관련 이슈가 발생했었다.

해결 방법

모든 필드에 대해 업데이트 처리를 하는 것이 아니라 필요한 컬럼만 업데이트 처리를 하게 하면 덮어 씌워지는 현상을 막을 수 있다.

특정 필드만 업데이트 할 수 있게 적용하는 방법은 2가지가 있다.

  1. @DynamicUpdate 사용
  2. @Query 로 직접 Update 쿼리 작성

각각의 방법에는 trade-off가 있기 때문에 적절한 방법을 찾아서 적용시키는 것이 좋아보인다.

@DynamicUpdate 사용

해당 기능은 JPA 엔티티 클래스에 명시하면 된다.

장점

업데이트 할 쿼리를 개발자가 만들지 않아도 JPA가 자동으로 특정 컬럼만 업데이트 해줘서 개발이 편하다.

JPA 변경 감지 기능을 계속 사용 가능하다.

어노테이션 추가만 하면 되서 사용이 매우 간단하다.

단점

JPA의 구현체인 hibernate는 애플리케이션이 처음 로드될 때 entity 들을 모두 스캔하여 업데이트할 쿼리를 캐시 해놓고 사용한다. (PreparedStatement)

즉, DynamicUpdate 를 사용하면 캐시 된 쿼리를 사용하지 못하고 새로 동적 쿼리를 생성하고 어떤 컬럼에 대한 변경이 있는지 체크해야해 이 과정에서 오버헤드가 발생해 오히려 성능 저하가 발생할 수도 있다.

어플리케이션단 성능 저하 관련 참고

반대로,
불필요하게 업데이트하는 컬럼이 사라져서 DB 부하가 줄어들고 각 컬럼에 대한 참조 무결성 및 제약 조건을 체크하기 위한 작업의 오버헤드가 줄어든다라는 의견도 있어서
오히려 DB단에 대해서는 성능이 좋아졌다는 글도 있었다.

DB단 성능 향상 관련 참고

@Query 로 직접 Update 쿼리 작성

장점

@Query 또한 이름 없는 정적 NamedQuery 라고 볼 수 있다.

@NamedQuery처럼 어플리케이션 로딩 시점에 JPQL 문법을 체크하고 미리 파싱해둔다. (PreparedStatement)

@Query에서도 JPQL 문법 잘못 사용 후 어플리케이션 로딩한 경우 아래처럼 에러가 발생

따라서, @Query를 사용하면 기존처럼 캐싱된 쿼리를 사용할 수 있다.

단점

특정 필드에 대한 업데이트가 필요한 곳에 매번 Update 쿼리를 개발자가 아래처럼 직접 작성하여 처리하여야한다.

@Modifying
@Query("update Apply a set a.progressType = :progressType where a.code = :code")
void updateProgressType(String code, ProgressType progressType);

JPA의 변경 감지 기능을 이용할 수 없어 불필요한 요청이 나갈 수 있다.

  • JPA는 변경이 일어나지 않으면 변경 쿼리를 날리지 않는다.

번외

@Modifying 어노테이션에 대하여

profile
개발 블로그 📝

0개의 댓글