Retry Pattern을 사용하는 이유 (+IO)

안녕하세요. HttpClient 통신 코드 개발 중 문득 궁금한 점이 생겨 글을 남깁니다.

네트워크 프로그래밍에서 서버나 클라이언트의 여러 가지 이유로 통신이 실패할 수 있기에 재시도 패턴을 구현하는 경우가 많은 것을 알고 있습니다.
그래서 재시도 패턴을 공부하다 보니 흥미롭게도 파일 I/O 작업에서도 해당 패턴을 사용하는 경우가 있더라구요. 여기서 다른 분들께서는 파일 I/O 작업 로직 작성시 해당 패턴을 구현하는지와 만일 구현하신다면 어떤 이유인지 여쭤봅니다.
정리: (통신을 제외하고) 재시도 패턴을 구현해야 하는 로직이 있는지, 있다면 그 이유가 무엇인지입니다.

  • (파일 I/O 작업 재시도 패턴 관련 추측)
    • 싱글스레드 환경에서 Dispose()를 적절히 구현하더라도 핸들 문제가 발생할 수 있다. → 때문에 다른 프로세스에서 접근하고 있는 파일에 또 다른 요청이 들어온다. → IO Exception 발생
    • 멀티스레드 환경에서 발생. (이 경우에는 재시도 패턴이 효과가 있는지 의문입니다.)
좋아요 2

자원과 자원의 권한, 자원의 점유 및 해제 관점에서 재시도 패턴을 바라보면 어떨까 합니다.

만약 자원에 대한 완전한 권한이 있다면 재시도 패턴이 필요가 없습니다.

한 가지 예로 내 프로그램이 점유한 메모리인데요, 메모리에 값을 읽고 쓸 때 재시도 패턴은 필요가 없습니다. 그 메모리에 대한 완전한 권한이 있기 때문입니다.

그런데 가령, 시리얼통신을 통한 장비 제어의 경우 재시도 패턴이 필요한데 시리얼통신의 특성상 무결성이 보장되지 않기 때문인데요, 물리적인 전선의 길이라던가 다양한 환경에서 노이즈를 발생할 수 있는 요인 때문에 데이터가 왜곡 될 수 있습니다. 즉, 외부 요인 때문에 데이터가 보장되지 않으므로 재시도 패턴이 필요합니다.

자원의 정확한 상태 정보를 얻지 못할 경우도 같은 관점으로 재시도 패턴이 필요할 수 있습니다. 가령, 특정 파일을 열어야 하는데 열기 전까지는 그 파일을 점유하고 있는 프로세스가 있는지를 알 수 없는 경우, 특정 타임아웃 동안 특정 시간 간격으로 그 파일을 열도록 재시도 해야 할 수도 있습니다.

반면에 스레드와는 관련이 없습니다. 스레드는 내 프로그램 안에서의 제어 가능한 자원이므로 재시도 패턴이 아니라 자원 경합에 대한 처리를 해야 하는 부분입니다.

좋아요 7

I/O 가 너무 포괄적인 개념이라…
구체적으로 어떤걸 말씀하시는지 이해가 안되네요…

좋아요 2

네트워크 I/O인지, 파일 I/O인지 질문하신 취지가 모호하게 해석되는데요.
파일 I/O를 말씀하시는거라면, 흔하지는 않는데 안티바이러스나 다른 프로세스의 간섭으로 인해 일시적으로 파일 핸들을 잡고 있어서 삭제나 옮기기와 같은 명령이 적용되지 않는 경우가 발생될 수 있습니다.
이런 케이스라면 일정 간격을 두고 재시도를 통해 해소될 수 있겠네요.
다만, 무한 반복되지 않도록 잘 처리해야 되겠죠~

좋아요 2

죄송합니다. 너무 포괄적이였군요. 파일 삭제, 압축, 이동과 관련된 I/O 작업입니다.

좋아요 1

답변 감사합니다. 핸들 문제에서, 특정 파일에 대해 순차적으로 파일 i/o 작업을 진행했을 때 (dispose()가 적절히 호출되었음에도) 핸들이 제때 해제되지 않는 경우가 발생할 수 있을까요?
예를 들어, “파일 다운로드 → 파일 압축 → 원 파일 삭제” 같은 상황에서요.

사실 이런 질문에 대한 답은 직접 해보시는게 답이죠 ^^;
다른 외부 간섭이 없는 한 dispose 한 파일의 자원이 해제되지 않는 경우는 경험해보지 못했습니다.

좋아요 1

질문하신대로 통신을 제외한 파일i/o에서는 오히려 재시도를 하는 경우는 못 본 것 같네요…
어떤 경우에 사용하는지 저도 궁금합니다.:thinking:

Dispose가 제대로 된 상태가 아닐거 같아요…
(혹은 다른 문제가 있거나…)

Dispose호출은 GC한테

이 객체 이제 버릴거니까 너 시간나면 갖고가서 소각시켜~

이렇게 통보만 해주는 것이지, 실제로 언제 갖고가서 소각시킬지는 아무도 모릅니다


:star: Post Script.
위 내용은 잘못된 정보를 포함합니다!!
정확한 정보는 아래 댓글을 참고하세요~!!
혼란을 드려 죄송합니다^^;

좋아요 1

닷넷의 메모리 소각을 말씀하시는거라면 이견은 없는데요~

FileStream의 Dispose는 FileStream 클래스 내부의 파일 핸들(SafeHandle)을 Dispose 해 주는 코드가 포함되어 있어서 정상적이라면 실제 파일 핸들을 명시적으로 닫네요.
@명아리 님이 혹시 헷갈리실까바 공유 드려요.

아래 그림상의 _handle이 파일 핸들입니다.


FileStream은 파일 핸들을 멤버변수로 가지고 있네요

참고

https://referencesource.microsoft.com/#mscorlib/system/io/filestream.cs,7815998d07a67991

좋아요 4

좋은 정보 감사합니다 :grinning:

좋아요 2

@dimohy @CODE_REAPER @nyjin 답변 정말 감사합니다.
그동안 테스트도 해보고, 이리 저리 찾아봤는데 정상적으로 dipose()가 호출했다면 핸들도 정상적으로 해제가 되는 것 같습니다.


그러나 분명 리소스(핸들) 해제를 정상적으로 진행했는데도 불구하고, 특정 파일 작업에서 io exception이 잦은 빈도로 발생했습니다(발생 안 할 때도 있습니다.)… 그래서 좀 더 찾아보니 저와 비슷한 상황을 겪는 사례가 있는 것 같아 공유합니다.

  • c# - FileStream.Close() is not closing the file handle instantly - Stack Overflow
    • 질문을 요약하면 "filestream을 통해 작업을 수행하고 Close() 호출하였으나, 이어서 동일 파일에 작업할 때 “해당 파일은 다른 프로세스에서 이미 사용중"과 같은 예외가 발생했다.” 입니다. (답변 작성자의 의도를 잘못 전달할 것 같아서 요약하지 못했습니다. 링크를 통해 확인하시면 좋을 것 같습니다.)

혹시 해당 이슈에 도움이될까 “이펙티브 C#(3판) - 아이템 46” 의 일부 내용을 발췌해봅니다. - (문제 시 삭제하겠습니다.)

Dispose()가 객체를 메모리에서 제거해주지는 못한다. 다만 관리되지 않는 리소스를 해제할 수 있도록 기회를 주는 메서드일 뿐이다. 따라서 Dispose() 메서드를 호출한 이후에도 해당 객체가 메모리상에 남아 있을 수 있는데 이러한 객체를 다시 사용하게 되면 문제가 발생할 수 있다. 앞의 예제를 다시 살펴보자. SqlConnection의 Dispose() 메서드는 데이터베이스로의 연결을 닫는다. 하지만 연결이 닫힌 이후에도 SqlConnection 객체는 여전히 메모리상에 남아 있는데 이 객체는 사실상 데이터베이스로의 연결을 소실한 상태다. 메모리에 남아 있지만 쓸모가 없다는 뜻이다. 만약 해당 객체를 다른 부분에서 재사용할 가능성이 조금이라도 있다면 절대 Dispose()를 호출해서는 안 된다.
<Bill Wagner의 이펙티브 C# (3판)>

좋아요 3

제 생각으로는 특별한 상황, 예를 들어 백신 프로그램 등 파일을 모니터링 하는 프로그램에 의해 그런 문제가 생긴게 아닌가 합니다.

파일 객체를 Dispose 하면 파일 핸들이 즉시 닫히는게 맞습니다.

좋아요 2

관련해서 아래의 코드로 테스트를 해보았습니다.

var testCount = 100000;

var tempFilename = Path.GetTempFileName();
for (var i = 0; i < testCount; i++)
{
    var s = File.OpenWrite(tempFilename);

    s.WriteByte(1);
    s.WriteByte(2);
    s.WriteByte(3);

    s.Close();
    // s.Dispose(); // 동일
}

10만번 동일한 파일 이름으로 쓰고 닫고 다시 열고 하는 테스트 입니다. 정상적으로 동작합니다.

.NET 버젼 및 파일 I/O가 아닌 네트워크 폴더 등 다른 환경을 고려해야곘지만 파일 I/O의 경우 제 개발 경험으로 잘 Dispose 해줬다면 그런 일은 없었던 것 같습니다.

@명아리 님이 겪는 문제는 크게 두가지로 요약할 수 있을 것 같습니다.

  1. 백신 프로그램 등 파일을 모니터링 하는 프로그램에 의한 IOException 발생
  2. 코드상에서 해당 파일을 Dispose() 하지 않고 파일을 오픈한 경우
좋아요 5

저도 가만히 생각해보니, 그냥 using문을 써서 자동으로 dispose가 호출되도록 파일 처리를 했을 때 문제가 되는 경우는 겪어보질 못했는데,
@dimohy 님의 말씀처럼 외부(보안프로그램이나 백신)의 영향이 있지는 않는가… 생각이 듭니다.

좋아요 3

GC의 메모리 처리 방식과 현재 발생되는 이슈가 뒤섞여 원인을 찾기 어려워지는 것 같아 댓글 달아요~
(오래된 기억이라 틀린 내용이 있으시면 말씀해주세요~)
닷넷의 메모리는 Managed, UnManaged로 나뉩니다.

  • Managed는 개발 할때 흔히 선언하는 클래스나, 멤버 변수들을 포함합니다. 심지어 이전에 공유 드렸던 FileStream의 멤버 변수인 SafeFileHandle도 Managed 입니다.
  • UnManaged는 네트워크 소켓 핸들, 파일 핸들, COM 객체, 뮤텍스 등 닷넷 런타임 밖에 있는 자원들이 UnManaged 메모리 영역입니다.
    앞서 말씀드린 Managed 영역의 SafeFileHandle에 연결된 실제 파일 핸들을 예로 들 수 있습니다.

닷넷 런타임은 적절한 시기에 GC(가비지 컬렉터)를 실행하고, GC는 Managed 영역의 인스턴스들을 대상으로 Object Graph를 그려 사용하고 있는 인스턴스와 사용하고 있지 않은 인스턴스를 걸러내고, 사용하지 않는 인스턴스의 메모리를 해제한 후 최적화합니다.(해제된 메모리로 조각난 메모리 영역을 재정렬 합니다.)
그래서 FileStream의 Dispose 함수에서 _canRead, _canWrite, _canSeek과 같은 Managed 영역 변수들은 초기화할 뿐 별다른 조치를 하지 않는 것은 GC가 나중에 처리해주기 때문으로 이해해주시면 될 것 같습니다.

공유해주신 이팩티브 c#도 앞서 말씀드린 맥락으로 이해해보자면, SqlConnection 인스턴스의 멤버변수들은 Managed 메모리 영역이므로 GC에 의해 메모리가 해제 됩니다.
단, 데이터베이스 연결 핸들은 UnManaged 영역이므로 Dispose 함수 내부에서 처리합니다.

만약 Dispose 함수를 부르지 않고 지나쳤는데 사용하지 않는 인스턴스면 어떻게 될까요?
c#은 소멸자를 통해 UnManaged 영역의 메모리 해제를 할수 있도록 지원합니다.

이슈가 어떤 이유로 발생되었는지 정확히 알수는 없지만, 이전에 멀티 스레드 환경에서 압축, 파일 이동과 같은 처리를 하신다고 했었는데 단일 스레드에서 이슈가 없는지 그리고 각 Task(압축, 이동)를 나누어서 할 때 이슈가 없는 지 테스트 해 보시는게 어떨까 싶네요.

좋아요 4