2024. 5. 24. 15:30

인터넷에 돌아다니는 코드 중에 'DbContext'를 너무 오래 유지하는 코드를 많이 봅니다.

심지어 싱글톤으로 만들어서 사용하는 경우도 종종 보이는데요....

'DbContext'는 살아있는 시간(사용량)에 비례하여 덩치가 커지는 녀석이라 가능하면 짧게 유지해야 합니다.

 

이 포스팅은 'DbContext'가 왜 이런 동작을 하는지 알아보기 위한 포스팅입니다.

 

 

* 주의 *

이 포스팅에서 기준이 되는 정보는 메모리 사용량입니다.

.NET에서는 정상적인 메모리 해제 코드를 사용했어도 여러 가지 요인에 의해 해제가 지연되는 경우가 있습니다.

그러므로 메모리 사용량은 추세로 보는 것이 좋습니다.

아니면 프로그램이 올라오고 별다른 작업이 없어 메모리에 쌓인 내용이 없는 시점에 체크하는 방법이 있습니다.

 

 

1. 'DbContext'의 특징 

'DbContext'는 원래 일회용으로 설계된 인스턴스입니다.

 

'DbContext'개체를 통해 전달한 개체를 추적하는 기능을 가지고 있습니다.

그러다 보니 오래 가지고 있을수록 추적되는 내용이 많아지기 때문에 메모리를 엄청 잡아먹게 되죠.

(저도 이걸 모르던 시절 대책 없이 폭증하는 메모리 사용량 때문에 문제를 겪은 적이 있습니다.

그 이후로는 using을 사용하여 명시적으로 제거해 주고 있습니다.)

 

 

2. 추적 

'DbContext'가 선언된 범위에서 DB를 읽거나 수정하면 해당 내용들이 추적됩니다.

이렇게 추적된 내용 중 수정된 내용은 '.SaveChanges'를 호출할 때 DB에 반영하게 됩니다.

 

DB를 직접 조회하는 건 DB의 내용이 필요할 때이지 쿼리를 작성하는 순간이 아닙니다.

IQueryable<TestTable1> iqTO1 = db1.TestTable1;
List<TestTable1> listTO1 = iqTO1.ToList();

 

1번 줄 : 전체 'TestTable1'을 조회하는 쿼리가 작성된 상태입니다.(조회X)

 

2번 줄 : 1번 줄에서 작성된 쿼리를 기반으로 DB를 조회합니다.

 

 

이때 'listTO1'의 내용은 db1에 의해 추적되게 됩니다.

메모리도 데이터를 DB로부터 읽어 왔을 때 증가한다.

 

 

이러한 특징 때문에 일반적인 메모리에 있는 리스트를 검색할 때처럼 사용하면 부하가 심할 수 있습니다.

//Queryable을 이용한 방식
using (ModelsDbContextTable db1 = new ModelsDbContextTable())
{
    IQueryable<TestTable1> iqTO1 = db1.TestTable1.Where(w => w.Int > 50000);
    List<TestTable1> listTO1 = iqTO1.ToList();
}//end using db1

//ToList를 이용한 방식
using (ModelsDbContextTable db1 = new ModelsDbContextTable())
{
    List<TestTable1> listTO1 = db1.TestTable1.ToList();
    listTO1 = listTO1.Where(w => w.Int > 50000).ToList();
}//end using db1

 

 

위 코드의 메모리 사용량 결과는 아래와 같습니다.

 

이런 동작을 하므로 쿼리를 작성할 때 DB를 조회할 타이밍을 조절하고,

조회한 결과는 가급적 적은 양이 되도록 하는 것이 중요합니다.

 

 

 

3. 추적 끊기 

'DbContext'가 끝나면(제거) 해당 DbContext가 추적 중인 내용은 추적이 끊기게 됩니다.

 

아래 코드의 'listTO1'와 'listTO2'는 'using'가 끝나면 추적이 끊어집니다.

List<TestTable1> listTO1 = new List<TestTable1>();
List<TestTable2> listTO2 = new List<TestTable2>();

using (ModelsDbContextTable db1 = new ModelsDbContextTable())
{
    listTO1 = db1.TestTable1.Where(w => w.idTestTable1 == 1).ToList();
    listTO2 = db1.TestTable2.Where(w => w.idTestTable2 == 1).ToList();

    listTO1.First().Str = "추적 끊김 테스트(1)";
    listTO2.First().Str = "추적 끊김 테스트(2)";
    db1.SaveChanges();
}//end using db1

 

 

다시 추적하려면 'Attach'를 이용해야 합니다.

using (ModelsDbContextTable db2 = new ModelsDbContextTable())
{
    listTO1.First().Str = "추적이 완전히 끊김(1)";
    listTO2.First().Str = "추적 다시 연결(2)";

    db2.TestTable2.Attach(listTO2.First());

    db2.SaveChanges();

    listTO1 = db2.TestTable1.Where(w => w.idTestTable1 == 1).ToList();
    listTO2 = db2.TestTable2.Where(w => w.idTestTable2 == 1).ToList();
}//end using db1

 

6번 줄 : 'Attach'를 이용하여 'listTO2'의 첫 번째 개체의 추적을 시작합니다.

 

 

추적을 복원해야 다시 저장이 가능합니다.

 

 

4. 생성 

가급적 'using'으로 명확하게 수명의 범위를 지정하는 게 좋습니다.

 

1) using으로 명확하게 사용 범위를 지정하기

 

2) 직접 관리하기

new 키워드를 통해 직접 생성하고, 사용이 끝나면 'Dispose'를 직접 호출하여 제거하는 방법이 있습니다.

 

3) 종속성 주입을 이용하기

만약 ASP.NET에서 종속성을 이용하여 사용한다면 컨트롤러가 생성될 때 받아도 됩니다.

(요청당 한 번씩 생성과 제거가 되기 때문)

이렇게 전달받은 개체는 지역변수에만 저장해야 컨트롤러가 제거될 때 같이 제거됩니다.

 

이 방법은 함수 인스턴스와 수명이 같아지므로 저는 잘 쓰지 않습니다.

 

 

5. 결론 

그래서

DbContext는 짧고 작게 유지해야 합니다.

 

이게 테스트하기가 아주 까다로운데....

그냥 추세만 보자면

 

1) 아래는 'DbContext'를 사용할 때마다 바로바로 제거한 경우입니다.

 

 

2) 하나의 'DbContext'를 계속 사용한 경우입니다.

 

 

속도나 메모리 정리 타이밍 같은 것들이 전자가 우수합니다.

 

 

마무리 

샘플 프로젝트 : dang-gun/EntityFrameworkSample/ContextLifeCycleTest

 

참고 :

MS Learn - DbContext 수명, 구성 및 초기화, ASP.NET MVC 5 애플리케이션의 수명 주기

tutorials point - ASP.NET MVC - Life Cycle

stack overflow - Is there a way to break DbContext to area in ASP.NET 6 framework?의 Steve Py님 답변

 

 

다른 코딩룰이나 최적화룰 같은 경우에 극적인 효과가 나오는 경우가 흔치 않은데

'DbContext'는 DB와 엮여있다 보니 극적인 효과를 주는 경우도 많습니다.

특히 성능과 상관없이 동작에는 문제가 없다 보니 개발단계에서는 몰랐다가 운영하면서 문제를 겪는 경우도 많습니다.

 

그러다 보니 원칙이 중요한데

"'DbContext'를 사용할 때는 짧고 작게"

를 항상 머리에 박아두고 사용하면 됩니다.