[회고록]비동기 프로그래밍에서 종종 발생하는 실수 / DBContext Scope 이슈

2024. 3. 31. 18:33기타 (회고 및 정보글 등)

//코드 예시 *실제 사용한 코드가 아님*

public  Task<PaginatedList<PostResponse>> Get(int page, int size)
    {
        using (var l = lifetimeScope.BeginLifetimeScope())
        {
            var dbContext = l.Resolve<DbContext>();
            var source = dbContext.Posts
                .OrderByDescending(h => h.CreateTime)
                .Include(p => p.User)
                .Select(g => PostResponse.Of(g));
    
            return  PaginatedList<PostResponse>.ToPagedList(source, page, size);
        }
    }

 

대부분의 Get메서드는 페이지네이션이 포함되어 있었다.

 

따라서 페이지네이션을 쉽게 적용하기 위한 공통 함수와 DTO를 만들어두었고, 평소처럼 이를 사용하며 개발을 진행중이었다.

그런데 평소에 잘만 되던 패턴에서 다음과 같은 에러를 볼 수 있었다.

System.InvalidOperationException: Can't call this method when MySqlDataReader is closed.
at MySqlConnector.MySqlDataReader.VerifyNotDisposed() in //src/MySqlConnector/MySqlDataReader.cs:line 675
at MySqlConnector.MySqlDataReader.ReadAsync(CancellationToken cancellationToken) in //src/MySqlConnector/MySqlDataReader.cs:line 35
at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable1.AsyncEnumerator.MoveNextAsync()    at Microsoft.EntityFrameworkCore.Query.ShapedQueryCompilingExpressionVisitor.SingleAsync[TSource](IAsyncEnumerable1 asyncEnumerable, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Query.ShapedQueryCompilingExpressionVisitor.SingleAsync[TSource](IAsyncEnumerable1 asyncEnumerable, CancellationToken cancellationToken)    at AdminApiServer.Common.PaginatedList1.ToPagedList(IQueryable`1 source, Int32 page, Int32 size) in /Users/jooooonj/Desktop/Projects/monster-quest-server/Applications/AdminServer/AdminApiServer/Common/Pagination/PaginatedList.cs:line 44

 

관련해서 검색해보니 다음과 같은 답변을 들을 수 있었다.

Error creating query string: 
Cannot access a disposed context instance. 
A common cause of this error is disposing a context instance 
that was resolved from dependency injection 
and then later trying to use the same context instance elsewhere 
in your application. This may occur if you are calling 'Dispose'
 on the context instance, or wrapping it in a using statement.
 If you are using dependency injection, 
you should let the dependency injection container take care of disposing context instances.
Object name: 'GameDbContext'..

번역
데이터베이스 컨텍스트 인스턴스를 여러 곳에서 사용하고 있을 때 발생할 수 있는 일반적인 문제입니다. 
주로 의존성 주입(Dependency Injection)을 통해 컨텍스트 인스턴스를 가져온 후
여러 곳에서 사용하고 있다가 
해당 인스턴스를 잘못해서 두 번 이상 해제하거나 사용하는 경우에 발생할 수 있습니다.

 

이게무슨..

 

DbContext Scope가 겹치거나 끊기거나 하는 문제가 발생한 것을 알 수 있었다.

 

처음에는 DB관련 에러라고 하니, 쿼리가 제대로 생성되는지를 확인해보았다.

 

SELECT 
FROM `post` AS `g`
INNER JOIN `users` AS `u` ON `g`.`user` = `u`.`Id`
INNER JOIN `users` AS `u0` ON `g`.`user` = `u0`.`Id`
WHERE `g`.`gId` = @gId
ORDER BY `g`.`createTime` DESC

 

당연히 아무런 이상이 없었다.

그래서 내가 작성한 코드부터 이상이 없는지 제대로 확인하고자 코드를 다시 살펴보았는데, 이게웬걸 비동기 프로그래밍을 다루면서 await를 누락시켰음을 발견하였다...

 

ToPagedList 는 내부적으로 페이징 처리를 하며 DTO에 데이터를 바인딩 할때 비동기로 작업을 수행한다. 왜냐하면 해당 함수 내에서 실제 데이터베이스에서 필요한 데이터를 인메모리에 가져오기 때문에(IO작업을 하기 때문에 ) 성능을 위해서 비동기로 처리가 된다.

 

따라서 비동기 작업이 끝날 때까지 기다리고 나서 데이터가 바인딩된 DTO를 반환해야 하는데, await 가 빠져 있기 때문에 바로 값이 제대로 들어오지도 않은 PaginatedList 를 리턴해버린다.

즉, 바인딩하는 DbContext의 Scope는 아직 일중인데 갑자기 비어있는 DTO를 return 하며 using키워드로 인해 해당 함수 종료와 함께 connetion을 끊어 버리는 상황이 발생한 것이다.

즉, 정리하자면 SCOPE 의 불일치 때문에 발생한 이슈다.

 

비동기 관련 코드를 작성하다보면 종종 생길 수 있는 실수인데, 치명적일 수 있기 때문에 항상 주의해야 한다.