Entity Framework 동시성 충돌의 해결에 대하여 -SaveChanges와 EF의 매커니즘 (3) -어려움
- -
앞선 이야기: https://dogfootsleep.tistory.com/40
Entity Framework 동시성 충돌의 해결에 대하여
드디어 동시성 해결에 대한 내용이다!이 이야기를 하기 위해서 앞서 많은 내용들을 이야기 하였다.https://dogfootsleep.tistory.com/38 트랜잭션 격리 수준 (Isolation level)https://dog-foot-sleep.tistory.com/34 트랜
dogfootsleep.tistory.com
EF에서 dbContext를 사용할 때 DI(Dependency Injection)을 사용하면 기본적인 Life cycle(생명 주기)은 Scoped로 되어 있다. Life Cycle에 대해서는 추후에 다시 이야기 하도록 하겠다.
DI 컨테이너의 범위(예: HTTP 요청) 동안 DbContext 인스턴스는 동일하게 유지된다.
ASP.NET Core에서는 HTTP 요청 범위 내에서 DbContext를 관리하는 것이 일반적이고 요청 시작 시 인스턴스가 생성되고, 요청 종료 시 인스턴스가 파괴된다.(Dispose)
문제가 되는 예시를 코드로 만들어보겠다.
//EF로 DB에 있는 데이터들을 조회하여 리스트로 만듬
var userMileageList = _dbContext.UserMileage.Where(x=> x== 삭제된 마일리지).ToList();
//마일리지 리스트 하나하나 반복
foreach(UserMileage userMileage in userMileageList)
{
//삭제된 마일리지를 0으로 변경
userMileage.Mileage= 0;
//삭제된 마일리지를 로그에 추가
_dbContext.MileageLog.Add(
new MileageLog
{
UserId= userMileage.Id
}
);
//DB에 반영
_context.SaveChanges();
}
해당 코드는 삭제된 마일리지를 모두 가져와서 마일리지를 0으로 만드는 로직이다.
해당 코드에서의 문제점은 이러하다.
동시성 문제를 해결하여 userMileageList에서 조회하고 나서
userMileage.Mileage= 0;가 DB에 반영되는 사이에 마일리지가 다른 트랜잭션에 의해 변경되더라도
앞서 설명했던거와 같이 DbUpdateConcurrencyException가 일어나며 변경이 저장되지 않는다.
그러나 예외가 발생하기 때문에 foreach 문 전체가 break 되며 하나의 충돌 때문에 List 전체의 Update와 Insert가
일어나지 않는 문제가 생긴다.
그럼 코드를 변경한다면?
//EF로 DB에 있는 데이터들을 조회하여 리스트로 만듬
var userMileageList = _dbContext.UserMileage.Where(x=> x== 삭제된 마일리지).ToList();
//마일리지 리스트 하나하나 반복
foreach(UserMileage userMileage in userMileageList)
{
//try catch 로직 추가
try
{
//삭제된 마일리지를 0으로 변경
userMileage.Mileage= 0;
//삭제된 마일리지를 로그에 추가
_dbContext.MileageLog.Add(
new MileageLog
{
UserId= userMileage.Id
}
);
//DB에 반영
_context.SaveChanges();
}
catch(Exception ex)
{
//로그 남기기
}
}
위와 같이 try catch를 추가하여 DbUpdateConcurrencyException 가 생기더라도 catch 후 넘어갈 수 있도록
만들 수 있다.
그럼 문제가 해결이 되지 않았을까?
그렇지 않다.
EF의 특성 때문에 추가적인 문제가 복잡하게 발생되기 때문이다.
위의 코드가 100건이 돌고 있다고 가정하자.만약 1번에서 userMileage.Mileage= 0; 의 동시성 충돌이 났다고 상상하여 보자그럼 DbUpdateConcurrencyException가 발생하고 catch로 이동 되어 로그를 남길 것이다.그럼 다음 반복문이 실행된다.
그럼 첫번째 //삭제된 마일리지를 로그에 추가
_dbContext.MileageLog.Add(
new MileageLog
{
UserId= userMileage.Id
}
);해당 로직은 반영이 되었을까 안 되었을까?우리가 상식적으로 생각해보면 해당 로직 위에서 동시성 충돌이 났기 때문에그 뒤의 로직은 반영이 안되고 넘어가는 것이 맞다.
그러나 그렇지 않다 반영이 된다!
그럼 두번째위의 로직이 반영이 된거야 눈감아 줄 수 있다고 치자.그럼 다음 반복문은 잘 돌 것인가?
잘돈다. 근데 예외가 난다
모든 정상적인 케이스 모두 예외가 난다.
예외가 나는 이유는 SaveChanges 때문이다.
정확하게는 EF의 매커니즘 때문이다.
_dbContext는 DI를 할 때 기본적으로 Scoped로 Life Cycle이 정해진다. 그렇기에 해당 메인 스레드가 끝나기 전까지는 DI 된 dbContext 객체는 하나의 인스턴스를 가지고 유지된다.
dbContext의 ChangeTracker 메서드를 확인해보면 dbContext가 DB와 연결되면 CRUD하는 데이터의 정보를 Entry로 리스트업 하여 가지고 있는다.
각 entry들은 상태 값을 가지며, 조회를 하였을 때는 unchanged를 추가는 Added, 삭제는 Deleted, 변경은 Modified 그리고 추적 안함은 Detached이다.
문제는 모든 변경 사항이나 조회가 SaveChanges 를 만났을 때 Entries를 모두 확인 하여 DB에 반영을 하는 매커니즘이다.
SaveChanges 를 만나면 동시성 충돌로 인해 DbUpdateConcurrencyException 예외가 나며 DB 반영이 안되지만
_dbContext는 Scoped로 만들어진 객체이기 때문에 다음 반복문에서도 같은 _dbContext 객체의 인스턴스를 유지하며 그 Entries를 가지고 있다.
결국 다음번 로직이 돈 후 SaveChanges 만나면 그 예외가 났을 때의 상태값들을 DB에 반영하려고 하는 것이다.
따라서 예외를 일으킨 데이터는 조회 때의 timestamp와 업데이트의 timestamp가 계속 다르기에 계속 예외를 발생시키고
예외 이후의 변경사항들은 정보들이 반영이 되는 문제가 일어나는 것이다.
그렇기 때문에 이 문제를 해결 하기 위해서는
//EF로 DB에 있는 데이터들을 조회하여 리스트로 만듬
var userMileageList = _dbContext.UserMileage.Where(x=> x== 삭제된 마일리지).ToList();
//마일리지 리스트 하나하나 반복
foreach(UserMileage userMileage in userMileageList)
{
//try catch 로직 추가
try
{
//삭제된 마일리지를 0으로 변경
userMileage.Mileage= 0;
//삭제된 마일리지를 로그에 추가
_dbContext.MileageLog.Add(
new MileageLog
{
UserId= userMileage.Id
}
);
//DB에 반영
_context.SaveChanges();
}
catch(Exception ex)
{
//로그 남기기
//예외 발생 시 Entry 중 저장 반영되야되는 것들을 제거하는 메서드
SaveChangesClear();
}
}
public void SaveChangesClear()
{
//현재 dbContext의 변경 엔트리 중 Unchanged를 제외한 모두를 가져온다
foreach (var entry in base.ChangeTracker.Entries().Where(e => e.State != EntityState.Unchanged))
{
//추적 안함으로 변경
entry.State = EntityState.Detached;
}
}
SaveChangesClear() 와 같은 메서드를 따로 만들어 예외가 발생 시 변경에 실패한 엔트리들을 추적 하지 않도록
상태변경을 하는 로직을 추가해줘야 한다.
물론 또 다른 문제로는 조회를 5000건 하고 1건 변경하고 3건 추가하는 로직을 조회한 모든 건수 만큼 반복하는 로직을
만든다면 _dbContext의 Entries는 5000*3만큼 Entry들이 늘어난다. 그리고 SaveChangesClear()는 모든 엔트리를 확인하여 상태변경을 해줄 것이다.
C#에서 주로 사용하는 ORM 중에 Dapper와 EF 중에 EF가 성능적으로 2~3배정도 차이가 난나고 하는데
마이크로 ORM으로 설계되어 최소한의 오버헤드로 돌아가는 Dapper에 비해 다양한 기능을 지원하는 EF가 위와 같은 다양한 기능 때문에 무겁다고 생각한다.
아래는 추가적인 Dapper와 EF의 성능 비교이다.
같은 로직을 Dapper와 EF로 돌린 것이고
로직은 아래와 같다.
티켓 배치 로직
-> //티켓 모두 조회
반복문
{
-> //티켓 차감 업데이트
-> //티켓 차람 로그 추가
SaveChanges ()
}
-> //티켓 얼럿 추가
- EF를 사용할 경우 5000건은 5m 13s 걸린다
- 1000건 당 약 1분 정도 걸린다.
- Dapper를 사용할 경우 총 2m 6s가 걸린다.
- 1000건 당 25초 정도 걸린다.
거의 2배~3배정도 차이가 난다.
EF가 조회한 내용을 모두 들고 있기에 느리다고 판단하여
그래서 EF를 티켓 모두 조회 하는 것이 아니라 1건씩 조회하여 호출하는 걸로 바꾸어 보았다.
결과는 5m 15s 큰 차이가 나지 않았다.
혹시나 하여 changeTracker를 확인하니 변경사항과 조회 사항이 매번 반복할 때마다 열거형으로 추가 되고 있었다.
그래서 SaveChanges()를 하고나면 changeTracker에 Clear를 하여 업데이트 된 내용을 지웠더니
결과는 3m 58s
무려 20%의 성능향상이 생겼다.
dbContext를 transient 로 만드는 것 동일한 효과를 내었을 것이라 생각한다.
각각의 상황에 따라 장단점이 달라지겠지만
EF는 배치작업과 같이 여러번 반복하는 것이 아닌 최대한 짧은 트랜잭션으로 구현하여 호출하는 것이 효율적일 것이라고 생각되었다.
소중한 공감 감사합니다