많은 경우, 업무 프로그램에서 엑셀(Excel)과의 연동은 필수 불가결한 것이라 할 수 있다. 엑셀의 매력은 비정형적인 데이터를 손쉽게 다룰 수 있다는 것이다. 그래서 많은 고객들이 업무용 프로그램의 요구사항을 간단히 이렇게 명세하곤 한다.

"뭐... 엑셀 처럼 해주세요..."

대략 뷁스럽기 이를데 없는 경우다. 이야기가 좀 옆으로 샜지만 닷넷과 엑셀의 연동에서 골치거리 중 하나가 이놈의 Excel.exe 프로세스가 잘 죽지 않는다는 것이다. Excel.exe 프로세스를 확실하게 죽이는 방법에 대해 썰을 좀 풀어볼까 한다. (죽인다는 표현이 좀 쌀벌하다...)

다음은 엑셀을 액세스하는 전형적인 간단한 C# 코드이다. 뭐 설명할 필요도 없이 Sheet1의 A1 셀에 Hello Excel Interop 이란 문자열을 박아 넣는 코드로서 매우 잘 작동한다.

static void ExcelWillNotDie()
{
    Excel.ApplicationClass app = new Excel.ApplicationClass();
    Excel.Workbook workbook = app.Workbooks.Add("");
    Excel.Worksheet sheet = (Excel.Worksheet)workbook.Sheets["Sheet1"];
    Excel.Range range = (Excel.Range)sheet.Cells.get_Item("1", "A");
    range.Value2 = "Hello Excel Interop";

    // Excel 파일 저장 코드... (생략)

    app.DisplayAlerts = false; // 저장할 것인가 확인하지 않도록 설정
    app.Quit();
}

위 코드의 문제는 작업을 모두 마쳤음에도 불구하 Excel.exe 프로세스가 죽지 않는다는 것이다. 작업 관리자로 현재 수행중인 프로세스를 살펴보면 떡하니 Excel.exe가 버티고 있다. Quit() 메쏘드까지 호출해 줬는데도 말이다!

비슷한 코드를 VB 6.0이나 VBS(Visual Basic Script)로 수행해보면 메쏘드를 종료하는 시점에 Excel.exe 프로세스는 제깍 제깍 죽는다. 그런데 동등한 코드를 닷넷 환경에서 수행시키면 메쏘드 호출이 끝났어도 죽지 않는다. 원인은 COM 객체와 닷넷의 가비지 컬렉션이 서로 궁합이 잘 맞지 않는다는 것이며 Excel 프로세스가 종료하기 위해서는 생성된 모든 엑셀 COM 객체들(Application, Workbook Sheet 등등)이 해제(release)되어야 하기 때문이다.

닷넷에서 엑셀 객체들과 같은 COM 객체를 액세스 할 때는 항상 RCW(Runtime Callable Wrapper)를 통해서 액세스를 한다는 것은 알고 있을 것이다. 이 RCW는 Finalizer를 정의하고 있고 이 Finalizer가 COM 객체를 해제하도록 구성되어 있다. 따라서 엑셀 프로세스가 종료되는 시점은 다음(next) 가비지 컬렉션이 수행된 후가 될 것이다. 이것도 좀 문제가 있는 것이... 비록 가비지 컬렉션이 수행되더라도 곧바로 RCW의 Finalizer가 호출된다는 보장이 없다. Finalizer 메쏘드는 별도의 Finalizer 만을 위한 쓰레드가 호출하도록 되어 있기 때문에 언젠가는 호출되겠지만 그 언젠가가 당췌 언제인지는 모르는 것이다.

Dispose 패턴이 그러하듯이 명시적으로 엑셀 객체를 해제해 주면 되지 않을까? 그렇다. 명시적으로 엑셀 객체들을 해제 해주면 엑셀 프로세스는 곧바로 종료하게 된다. 요것이 포인트가 되겠다. COM 객체를 다룰 때는 두 가지만 알면 된다. 북치기 박치기~~ ^__^

웃자고 해본 소리고... COM 객체를 닷넷에서 다루고자 한다면 참조한 모든 COM 객체는 죄다 명시적으로 해제를 하면 된다. COM 객체를 해제하는 구체적인 방법은 System.Runtime.InteropServices 네임스페이스의 Marshal 클래스가 제공하는 ReleaseComObject 메쏘드를 호출하면 된다.

위 코드를 어떻게 수정하면 될까? 뇌리를 스치는 것은 app, workbook, sheet, range 변수에 대해 ReleaseComObject 를 호출하면 되지 않을까? 자... 그렇다면 위 메쏘드의 마지막에 다음 코드를 추가하고 Excel.exe 가 종료되나 살펴보자.

static void ExcelWillNotDie()
{
    // 기존 코드 동일 (생략)

    Marshal.ReleaseComObject(range);
    Marshal.ReleaseComObject(sheet);
    Marshal.ReleaseComObject(workbook);
    Marshal.ReleaseComObject(app);
}

테스트를 수행해 보면 예상과 달리 Excel.exe 프로세스는 종료되지 않는다. 왜일까? 필자가 붉은 색으로 강조하고 침 튀기며 외쳐댄 "참조한 COM 객체를 명시적으로 해제"를 수행 했는데 왜 Excel.exe 프로세스가 종료되지 않았을까? 둘 중 하나다. 필자가 구라를 쳤던가 아니면 코드에서 해제되지 않은 COM 객체 참조가 있던지.

당근 필자가 구라를 친 것은 아니다. 해제되지 않은 COM 객체 참조가 있기 때문이다. 메쏘드의 두 번째 라인을 잘 살펴보자. Application 객체의 Workbooks 속성의 Add 메쏘드를 호출하고 있다. 이 코드를 좀 풀어서 써 보자면 다음과 같다.

// 원래 코드
Excel.WorkSheet sheet = (Excel.WorkSheet)app.Workbooks.Add("");

// 풀어쓴 코드
Excel.WorkBooks books = app.Workbooks;
Excel.WorkSheet sheet = (Excel.WorkSheet)books.Add("");

이제 감이 오는가? Application 객체의 Workbooks 속성이 또 다른 Workbooks 라는 COM 컬렉션 객체를 반환하고 있고, 이 COM 객체에 대한 참조는 암시적으로 이루어지고 있음을 알아야 한다.

그렇다면 명시적으로 COM 객체를 해제해 주려면 엑셀에 접근하는 코드 자체를 다음과 같이 수정해주어야 한다.

static void ExcelWillDie()
{
    Excel.ApplicationClass app = new Excel.ApplicationClass();
    Excel.Workbooks workbooks = app.Workbooks;
    Excel.Workbook workbook = workbooks.Add("");
    Excel.Sheets sheets = workbook.Worksheets;
    Excel.Worksheet sheet = (Excel.Worksheet)sheets["Sheet1"];
    Excel.Range cells = (Excel.Range)sheet.Cells;
    Excel.Range range = (Excel.Range)cells.get_Item("1", "A");

    range.Value2 = "Hello Excel Interop";

    // Excel 파일 저장 코드... (생략)
    app.DisplayAlerts = false; // 저장할 것인가 확인하지 않도록 설정

    app.Quit();

    Marshal.ReleaseComObject(range);
    Marshal.ReleaseComObject(cells);
    Marshal.ReleaseComObject(sheet);
    Marshal.ReleaseComObject(sheets);
    Marshal.ReleaseComObject(workbook);
    Marshal.ReleaseComObject(workbooks);
    Marshal.ReleaseComObject(app);
}

이 코드는 확실이 메쏘드 종료와 더불어 Excel.exe 프로세스를 종료시켜 준다.

VB 6.0이나 VBS 등으로 동등한 코드를 만들면 위와 같이 명시적인 해제가 없더라도 엑셀 프로세스는 잘 죽곤 한다. 왜 일까? 그건 VB 6.0 이나 VBS의 런타임이 메쏘드의 scope를 벗어나면 메쏘드에서 사용된 로컬 변수와 암시적으로 사용된 변수(app.WorkBooks.Add 의 경우 처럼)에 대해 해제 코드를 모두 호출해 주기 때문이다.

닷넷은 왜 VB 6.0 처럼 하지 못하는 것일까? 닷넷 개발팀이 닭대가리들이라서? 닷넷 개발팀이 닭대가리라면 나는 접시물에 코박고 죽어야할 운명이다... 근본적인 원인은 닷넷의 가비지 컬렉션이 그 이유라고 보면 되겠다. 가비지 컬렉션은 많은 장점을 가지고 있지만 COM 객체 접근이나 데이터베이스 연결(connection)과 같이 unmanaged 자원에 접근하는 경우 역 기능을 가지곤 한다.

보다 많은 코드를 작성해야 하고 ReleaseComObject 도 호출하는 위와 같은 방법 말고 Excel.exe를 우아하게 종료하는 방법은 없을까? 우아하지 않은 방법은 있다. 강제로 가비지 컬렉션을 수행하고 RCW의 Finalizer 메쏘드가 호출되도록 하면 Excel.exe가 종료되긴 한다.

GC.Collect();
GC.WaitForPendingFinalizer();

위와 같은 방법은 벼룩 잡을려고 초가삼간 태우는 격이 되겠다. 그 누구도 GC.Collect 호출을 권장하지 않는다. 가비지 컬렉션은 닷넷 CLR이 판단하여 스스로 수행하도록 하는 것이 가장 좋다. 그래도 위와 같이 GC.Collect와 GC.WaitForPendingFinalizer 호출을 수행하여 Excel 연동의 결과로 수행된 Excel.exe 프로세스를 종료시키는 방법이 있다는 것만 알아 두자.

마지막으로 당부할 것은 COM 객체를 반복문 안에서 참조하는 경우를 조심해야 한다. 반복문(for, while 등) 내에서 COM 객체의 참조를 얻었다면 해제 역시 반복문 내에서 이루어져야 한다는 점이다.

for(int i=1; i < 10; i++) {
   Excel.Range r = (Excel.Range)cells.get_Item(i, 2):
   r.Value2 = i;
   Marshal.ReleaseComObject(r);
}

왜 반복문 안에서 얻은 COM 참조를 반복문 내에서 해제해야 하는 가에 대해서는 상세히 설명하지 않겠다. 잘 생각해 보기를 바란다.