메뉴 닫기

WinAPI 파일 입출력 기본 가이드, CreateFile부터 CloseHandle까지 완벽 정리

WinAPI 파일 입출력 기본 가이드, CreateFile부터 CloseHandle까지 완벽 정리

📌 C언어와 WinAPI로 텍스트·바이너리 파일을 다루는 핵심 API 사용법 공개

Windows 환경에서 파일을 다루는 일은 단순한 작업 같아 보여도, 실제 구현 과정에서는 다양한 세부 옵션과 함수 호출 규칙을 이해해야 안정적인 프로그램을 만들 수 있습니다.
특히 CreateFile, ReadFile, WriteFile, CloseHandle 네 가지 API는 WinAPI 파일 입출력의 핵심이며, 텍스트 파일뿐 아니라 바이너리 데이터 처리에도 활용됩니다.
이 글에서는 이 네 가지 함수를 기반으로 안전하고 효율적인 파일 입출력 방법을 단계별로 정리해 드립니다.
함수별 매개변수 설명부터 실무 예제 코드, 자주 발생하는 오류와 해결 방법까지 폭넓게 다루어, 초보 개발자도 쉽게 따라 할 수 있도록 구성했습니다.

또한 단순한 함수 설명에 그치지 않고, 실제 프로젝트에서 사용할 수 있는 파일 접근 모드 설정법과 에러 처리 전략까지 함께 다룹니다.
WinAPI의 파일 처리 흐름을 정확히 이해하면, 네트워크 데이터 저장, 로그 기록, 사용자 설정 파일 관리 등 다양한 상황에 활용할 수 있습니다.
이제 기본부터 실전까지 차근차근 살펴보겠습니다.



🔗 CreateFile로 파일 열기와 생성

Windows API에서 CreateFile 함수는 이름과 달리 단순히 ‘파일 생성’ 기능만 제공하는 것이 아닙니다.
이미 존재하는 파일을 열거나, 새로운 파일을 만들고, 심지어 디렉터리·디바이스 핸들까지 얻을 수 있는 범용적인 입출력 시작점입니다.
이 함수의 사용 방법을 이해하면 WinAPI 기반의 모든 파일 작업의 기초를 다질 수 있습니다.

기본적인 함수 원형은 다음과 같습니다.

CODE BLOCK
HANDLE CreateFile(
    LPCSTR lpFileName,
    DWORD dwDesiredAccess,
    DWORD dwShareMode,
    LPSECURITY_ATTRIBUTES lpSecurityAttributes,
    DWORD dwCreationDisposition,
    DWORD dwFlagsAndAttributes,
    HANDLE hTemplateFile
);

🛠️ 주요 매개변수 이해하기

  • 📄lpFileName : 열거나 생성할 파일 경로
  • 🔑dwDesiredAccess : GENERIC_READ, GENERIC_WRITE 등 접근 권한 지정
  • 🤝dwShareMode : 다른 프로세스와의 공유 모드
  • ⚙️dwCreationDisposition : 파일이 없을 때 새로 만들지 여부(CREATE_ALWAYS 등)
  • 📦dwFlagsAndAttributes : FILE_ATTRIBUTE_NORMAL 등 속성 설정

💡 사용 예시

CODE BLOCK
HANDLE hFile = CreateFile(
    "sample.txt",
    GENERIC_WRITE,
    0,
    NULL,
    CREATE_ALWAYS,
    FILE_ATTRIBUTE_NORMAL,
    NULL
);

if (hFile == INVALID_HANDLE_VALUE) {
    // 오류 처리
}

💡 TIP: 경로 지정 시 백슬래시(\) 대신 슬래시(/)를 사용해도 Windows API는 인식하지만, 네트워크 경로나 레거시 코드 호환성을 고려해 백슬래시를 권장합니다.

🛠️ ReadFile로 데이터 읽기

WinAPI의 ReadFile은 텍스트와 바이너리 모두에 동일하게 적용되는 저수준 읽기 함수입니다.
핵심은 읽은 바이트 수를 항상 확인하고, 반복 호출로 원하는 길이가 채워질 때까지 읽는 습관을 들이는 것입니다.
파일 끝에서는 성공 반환과 함께 읽은 바이트 수가 0이거나, 상황에 따라 ERROR_HANDLE_EOF를 만날 수 있습니다.
콘솔·파이프 등 커널 객체에서도 동일 패턴으로 동작하므로, 견고한 루프와 오류 처리가 중요합니다.

📚 함수 원형과 기본 사용

CODE BLOCK
BOOL ReadFile(
    HANDLE       hFile,
    LPVOID       lpBuffer,
    DWORD        nNumberOfBytesToRead,
    LPDWORD      lpNumberOfBytesRead,
    LPOVERLAPPED lpOverlapped
);

일반적인 동기 모드에서는 lpOverlappedNULL로 두고 호출하며, 반환값과 lpNumberOfBytesRead를 함께 확인합니다.
텍스트 파일이라도 멀티바이트/유니코드 변환은 개발자가 직접 처리해야 하므로, WideCharToMultiByteMultiByteToWideChar로 인코딩을 명확히 관리하세요.

🧩 안전한 읽기 루프 예제 (동기)

CODE BLOCK
char  buf[4096];
DWORD total = 0;

for (;;) {
    DWORD got = 0;
    BOOL ok = ReadFile(hFile, buf, sizeof(buf), &got, NULL);
    if (!ok) {
        DWORD err = GetLastError();
        // 필요한 경우 ERROR_HANDLE_EOF 등 처리
        break;
    }
    if (got == 0) { // EOF
        break;
    }
    // got 바이트 사용 (텍스트면 인코딩 고려)
    total += got;
}

💡 TIP: CRLF(\r\n) 줄바꿈은 바이너리 관점에서 2바이트입니다.
텍스트 처리 시 줄 경계 파싱을 직접 구현하거나, C 런타임을 활용하려면 _open/_read 같은 고수준 API를 혼용하지 않도록 계층을 분리하세요.

⚡ 오버랩 I/O 한눈에 보기

비차단 입출력이 필요하면 CreateFile 호출 시 FILE_FLAG_OVERLAPPED를 설정하고, OVERLAPPED 구조체를 넘겨 호출합니다.
이때 함수는 FALSE를 반환하고 GetLastError()==ERROR_IO_PENDING이면 정상이며, GetOverlappedResult나 이벤트 객체로 완료를 추적합니다.
파일 위치는 OVERLAPPED.Offset/OffsetHigh로 지정합니다.

CODE BLOCK
OVERLAPPED ol = {0};
ol.Offset = 0; ol.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

BOOL ok = ReadFile(hFile, buf, sizeof(buf), NULL, &ol);
if (!ok && GetLastError() == ERROR_IO_PENDING) {
    // 대기 또는 폴링
    WaitForSingleObject(ol.hEvent, INFINITE);
    DWORD got = 0;
    GetOverlappedResult(hFile, &ol, &got, FALSE);
}
CloseHandle(ol.hEvent);

⚠️ 주의: 파일 핸들이 FILE_FLAG_OVERLAPPED로 열렸다면, 동기 방식처럼 lpOverlapped=NULL로 호출하면 교착·예상치 못한 블로킹이 발생할 수 있습니다.
모드를 섞지 말고, 완료 확인을 항상 일관된 방법으로 처리하세요.



⚙️ WriteFile로 데이터 쓰기

WinAPI의 WriteFile은 텍스트든 바이너리든 동일한 바이트 스트림을 대상으로 동작합니다.
핵심은 실제로 기록된 바이트 수를 항상 확인하고, 필요 시 반복 호출로 전체 데이터를 끝까지 밀어넣는 것입니다.
디스크 캐시와 파일 시스템 드라이버의 특성 때문에 한 번의 호출이 요청한 길이를 모두 처리하지 못할 수 있으므로, 견고한 루프 패턴을 익히면 신뢰도가 크게 올라갑니다.
또한 파일을 ‘어디에’ 쓰는지도 중요합니다.
동기 모드에서는 파일 포인터가 자동으로 이동하지만, 오버랩 I/O에서는 OVERLAPPED.Offset/OffsetHigh로 위치를 명시해야 예기치 않은 덮어쓰기를 방지할 수 있습니다.

✍️ 함수 원형과 기본 사용

CODE BLOCK
BOOL WriteFile(
    HANDLE       hFile,
    LPCVOID      lpBuffer,
    DWORD        nNumberOfBytesToWrite,
    LPDWORD      lpNumberOfBytesWritten,
    LPOVERLAPPED lpOverlapped
);

동기 모드에서는 lpOverlappedNULL로 두고 호출합니다.
반환값이 TRUE라면 실제로 기록된 길이는 lpNumberOfBytesWritten에서 확인합니다.
오류 시 GetLastError()로 원인을 파악하고, 디스크 공간 부족, 접근 권한, 공유 모드 충돌 등의 케이스를 구분해 대처합니다.

🔁 안전한 쓰기 루프 예제 (동기)

CODE BLOCK
const BYTE* p   = (const BYTE*)buffer;   // 쓰고자 하는 메모리
DWORD       len = (DWORD)bufferSize;     // 총 길이
DWORD       done = 0;

while (done < len) {
    DWORD wrote = 0;
    BOOL ok = WriteFile(hFile, p + done, len - done, &wrote, NULL);
    if (!ok) {
        DWORD err = GetLastError();
        // 오류 처리 (공간 부족, 권한, 공유 충돌 등)
        break;
    }
    if (wrote == 0) { // 비정상 상황: 더 이상 진행 불가
        // 적절한 복구/로그 처리
        break;
    }
    done += wrote;
}

💡 TIP: 파일을 덧붙이기(Append) 용도로 열려면 dwDesiredAccessFILE_APPEND_DATA를 주거나, 쓰기 전 SetFilePointerEx로 끝으로 이동하세요.
동시에 여러 스레드가 같은 핸들에 쓰는 경우 레이스 컨디션을 막기 위해 뮤텍스/크리티컬 섹션으로 순서를 보장하는 편이 안전합니다.

⚡ 오버랩 I/O로 비차단 쓰기

파일을 CreateFile에서 FILE_FLAG_OVERLAPPED로 열었으면, WriteFile 호출 때 OVERLAPPED를 제공해야 합니다.
호출은 FALSE를 반환하고 GetLastError()==ERROR_IO_PENDING이면 정상입니다.
완료는 이벤트 핸들이나 GetOverlappedResult로 확인하며, 위치 지정은 Offset/OffsetHigh를 사용합니다.

CODE BLOCK
OVERLAPPED ol = {0};
ol.Offset = 0;                     // 원하는 파일 위치
ol.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

BOOL ok = WriteFile(hFile, p, len, NULL, &ol);
if (!ok && GetLastError() == ERROR_IO_PENDING) {
    WaitForSingleObject(ol.hEvent, INFINITE);
    DWORD wrote = 0;
    GetOverlappedResult(hFile, &ol, &wrote, FALSE);
}
CloseHandle(ol.hEvent);

  • 🧾lpNumberOfBytesWritten을 반드시 확인해 부분 기록을 처리하세요.
  • 🧭오버랩 모드에서는 Offset을 명시하고, 동기/비동기 방식을 섞지 마세요.
  • 💽디스크에 즉시 반영하려면 FlushFileBuffers를 고려하세요.
  • 🧱기존 파일을 덮어쓸 때는 백업 파일을 먼저 만들고 쓰는 안전한 저장(atomic-like) 패턴을 사용하면 좋습니다.

⚠️ 주의: 텍스트 파일을 UTF-8로 저장할 때 BOM을 함께 쓰고 싶다면 버퍼 선두에 0xEF, 0xBB, 0xBF를 먼저 기록하세요.
개행은 Windows 표준인 \r\n을 사용하지 않으면 일부 도구에서 줄바꿈이 깨질 수 있습니다.

상황 권장 접근/플래그
덮어쓰기 새 저장 GENERIC_WRITE + CREATE_ALWAYS
기존 파일에 이어쓰기 FILE_APPEND_DATA 또는 SetFilePointerEx(끝)
대용량 비차단 기록 FILE_FLAG_OVERLAPPED + 이벤트/IOCP

🔌 CloseHandle로 파일 닫기

파일 작업의 마지막 단계는 핸들을 정리하는 일입니다.
CloseHandle은 커널 오브젝트의 참조 카운트를 줄이고, 더 이상 참조가 없으면 시스템 리소스를 해제합니다.
파일 핸들을 닫으면 캐시에 남아 있던 기록이 파일 시스템으로 커밋되고, 공유 락이 풀리며, 다른 프로세스가 접근할 수 있게 됩니다.
작은 도구라면 종료 시 운영체제가 정리해 주기도 하지만, 서비스·장시간 실행 애플리케이션에서는 누적 누수로 이어지므로 명시적으로 닫는 습관이 필수입니다.

📚 함수 원형과 반환 값

CODE BLOCK
BOOL CloseHandle(
    HANDLE hObject
);

성공 시 TRUE, 실패 시 FALSE를 반환하며, 오류 코드는 GetLastError()로 확인합니다.
파일 오브젝트 외에도 이벤트, 뮤텍스, 파이프 등 다양한 커널 오브젝트에 사용되지만, 오브젝트 종류가 다르면 정리 방식도 달라질 수 있으므로 문서를 확인하세요.

✅ 안전한 정리 패턴

CODE BLOCK
HANDLE hFile = CreateFile(...);
if (hFile == INVALID_HANDLE_VALUE) {
    // 오류 처리
    return;
}

// 파일 작업...
// 필요 시 FlushFileBuffers(hFile);

if (hFile && hFile != INVALID_HANDLE_VALUE) {
    BOOL ok = CloseHandle(hFile);
    if (!ok) {
        DWORD err = GetLastError();
        // 로깅/복구
    }
}

💡 TIP: 쓰기 직후 즉시 디스크 반영이 필요하면 FlushFileBuffers로 강제 플러시한 다음 CloseHandle을 호출하세요.
전원 장애·비정상 종료에 대비해 저널링 파일 시스템이라도 응용 프로그램 수준의 플러시를 해두면 안전성이 올라갑니다.

⚠️ 흔한 실수와 예방책

  • 🔁같은 핸들을 중복으로 닫지 마세요.
    한 번 닫힌 핸들을 다시 닫으면 ERROR_INVALID_HANDLE이 발생할 수 있습니다.
  • 오버랩 I/O가 진행 중이라면 CancelIoEx로 취소하고, GetOverlappedResult 또는 이벤트 대기로 완료를 확인한 뒤 닫습니다.
  • 🧰GetCurrentProcess/GetCurrentThread의 의사 핸들은 별도 해제가 필요 없습니다.
    실제 복제 핸들은 DuplicateHandle로 만든 경우에만 닫으세요.
  • 🧹여러 코드 경로(중간 return, 오류 처리)가 있으면 goto cleanup 또는 __try/__finally로 정리 구역을 통일해 누수를 방지하세요.

⚠️ 주의: 핸들을 닫으면 자동으로 모든 I/O가 ‘취소’되는 것은 아닙니다.
진행 중인 작업은 완료/실패로 귀결될 수 있으니, 쓰레드 종료 직전에는 반드시 취소 또는 완료 대기를 명시적으로 수행하세요.

💎 핵심 포인트:
핸들은 열리는 모든 경로에서 닫혀야 합니다.
함수의 정상/오류 반환을 아우르는 단일 정리 루틴을 만들고, 오버랩 I/O 여부와 무관하게 FlushFileBuffersCloseHandle 호출 순서를 명확히 해 두면 예측 가능한 동작을 얻을 수 있습니다.



💡 파일 입출력 시 주의사항과 팁

WinAPI로 파일을 다룰 때는 단순히 열고 읽고 쓰는 것을 넘어서, 경로 길이, 잠금 공유, 버퍼링 정책, 인코딩, 장애 복구 전략 등 주변 요소를 함께 설계해야 예측 가능한 동작을 얻을 수 있습니다.
현장에서 자주 마주치는 문제를 기준으로 체크리스트와 예제로 정리했습니다.
텍스트·바이너리 모두에 적용되며, 동기·오버랩 I/O 어디서든 도움이 됩니다.

  • 🔗긴 경로는 접두사 \\?\를 써서 MAX_PATH 한계를 우회하세요.
  • 🤝동시 접근이 필요하면 dwShareModeFILE_SHARE_READ/WRITE를 적절히 부여하세요.
  • 🧾텍스트는 인코딩을 명확히: UTF-8 BOM, UTF-16LE 등 정책을 정하고 읽기/쓰기 모두 동일하게 유지하세요.
  • 💽전원 장애 대비가 필요하면 FlushFileBuffers 또는 MOVEFILE_WRITE_THROUGH와 같은 옵션을 고려하세요.
  • 🧱덮어쓰기 저장은 임시 파일 → 원본 교체원자적 저장 패턴을 쓰면 안전합니다.
  • 🧪I/O 실패 시 GetLastError 코드를 로깅하고, 재시도·롤백 루틴을 준비하세요.

🧩 원자적(안전) 저장 패턴 예제

CODE BLOCK
// temp에 완성본을 쓴 뒤 MoveFileEx로 교체
HANDLE h = CreateFile(L"data.tmp",
    GENERIC_WRITE, 0, NULL, CREATE_ALWAYS,
    FILE_ATTRIBUTE_NORMAL, NULL);

if (h == INVALID_HANDLE_VALUE) { /* 오류 처리 */ }

// ... 모든 데이터 기록 ...
FlushFileBuffers(h);
CloseHandle(h);

// 교체: 쓰루 옵션으로 디스크 반영 보장 강화
BOOL ok = MoveFileExW(L"data.tmp", L"data.txt",
    MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH);

if (!ok) { /* 롤백/오류 처리 */ }

💎 핵심 포인트:
쓰기 도중 예외가 나도 원본을 보존하려면 항상 임시 파일에 전부 기록한 뒤 교체하세요.
교체 직전 FlushFileBuffers를 호출하고, 교체는 MoveFileEx의 적절한 플래그로 원자성을 최대화합니다.

📏 파일 포인터와 크기 관리

랜덤 액세스 시에는 SetFilePointerEx로 위치를 이동하고, 파일을 잘라내거나 늘릴 때는 SetEndOfFile을 사용합니다.
읽기/쓰기 혼용 시 파일 포인터가 어디에 위치하는지 항상 의식하고, 멀티스레드 환경에서는 스레드별 핸들 또는 뮤텍스로 순서를 제어하세요.

목표 권장 API/플래그
긴 경로 지원 경로 접두사 \\?\
전원 장애 대비 FlushFileBuffers, MOVEFILE_WRITE_THROUGH
임시 파일(빠른 캐시) FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE
메모리 맵 입출력 CreateFileMapping/MapViewOfFile

⚠️ 주의: FILE_FLAG_NO_BUFFERING을 사용할 때는 섹터 크기 정렬 제약(버퍼 주소, 길이, 오프셋)을 지켜야 합니다.
조건을 어기면 호출이 실패합니다.
특수한 성능 요구가 아니라면 일반적으로 권장하지 않습니다.

🧰 오류 메시지 로깅 템플릿

CODE BLOCK
DWORD err = GetLastError();
LPWSTR msg = NULL;
FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER |
               FORMAT_MESSAGE_FROM_SYSTEM |
               FORMAT_MESSAGE_IGNORE_INSERTS,
               NULL, err, 0, (LPWSTR)&msg, 0, NULL);
// msg 로깅...
if (msg) LocalFree(msg);

💬 문제 상황을 재현할 수 있는 입력, 경로, 접근 권한, 공유 모드, 플래그를 로그에 함께 남기면 다음 디버깅 시간이 크게 줄어듭니다.

자주 묻는 질문 (FAQ)

CreateFile에서 OPEN_EXISTING, CREATE_ALWAYS, OPEN_ALWAYS, CREATE_NEW, TRUNCATE_EXISTING 차이가 뭔가요?
존재 여부와 덮어쓰기 동작이 다릅니다.
OPEN_EXISTING은 존재할 때만 엽니다.
CREATE_ALWAYS는 항상 새로 만들고 기존이 있으면 잘라냅니다.
OPEN_ALWAYS는 있으면 열고 없으면 만듭니다.
CREATE_NEW는 파일이 없을 때만 새로 만듭니다.
TRUNCATE_EXISTING은 존재해야 하며 길이를 0으로 자릅니다.
ReadFile이 TRUE를 반환했는데 요청한 바이트보다 적게 읽혔습니다. 정상인가요?
정상일 수 있습니다.
파일 끝에 도달했거나, 장치·파이프 특성상 부분 읽기가 발생할 수 있습니다.
항상 lpNumberOfBytesRead를 확인하고 루프로 원하는 양이 채워질 때까지 반복하세요.
한글 경로와 UTF-8 파일명을 안전하게 처리하려면 어떻게 해야 하나요?
WinAPI는 유니코드 UTF-16을 기본으로 합니다.
CreateFileW 같은 W 접미사 함수를 사용하고, 문자열은 wide 문자형으로 관리하세요.
멀티바이트 입력은 MultiByteToWideChar로 변환하고, 긴 경로는 \\?\ 접두사를 사용하면 안정적입니다.
공유 위반(FILE_SHARE) 오류를 줄이려면 어떤 설정이 좋나요?
다른 프로세스와 함께 읽을 가능성이 있으면 FILE_SHARE_READ를, 로그처럼 동시에 쓰기와 읽기가 필요하면 FILE_SHARE_READ | FILE_SHARE_WRITE를 고려하세요.
민감한 쓰기에는 공유를 최소화하고 명시적 동기화(뮤텍스, 파일 잠금)를 함께 사용하세요.
여러 프로세스가 같은 파일에 안전하게 이어쓰기(Append)하려면?
CreateFile에서 FILE_APPEND_DATA 권한으로 열고, 가능하면 각 프로세스가 자체 핸들을 사용하세요.
한 번의 WriteFile 호출로 의미 있는 레코드 단위를 기록하고, 필요하면 OS 수준 잠금이나 이름 있는 뮤텍스를 사용해 순서를 보장합니다.
오버랩 I/O가 무엇이며 ERROR_IO_PENDING은 왜 정상인가요?
오버랩 I/O는 비차단 방식입니다.
FILE_FLAG_OVERLAPPED로 연 뒤 ReadFile/WriteFile에 OVERLAPPED를 주면 즉시 반환합니다.
ERROR_IO_PENDING은 작업이 백그라운드로 제출되었음을 의미하며, 이벤트나 GetOverlappedResult로 완료를 확인합니다.
FlushFileBuffers는 언제 필요한가요? 성능에는 어떤 영향이 있나요?
장애 시 데이터 유실을 줄여야 할 때, 중요 트랜잭션 직후, 파일 교체 직전에 사용합니다.
디스크까지 강제 반영하므로 지연이 커질 수 있습니다.
배치로 묶어 호출 횟수를 줄이거나, 임시 파일 → 교체 패턴과 함께 사용하세요.
메모리 맵 파일(CreateFileMapping)과 ReadFile/WriteFile은 언제 무엇을 쓰면 좋나요?
랜덤 액세스가 잦고 큰 파일을 조각조각 다룰 때는 메모리 맵이 편리합니다.
간단한 순차 처리나 스트리밍은 ReadFile/WriteFile이 구현이 단순하고 예측 가능합니다.
접근 패턴과 동기화 요구에 맞춰 선택하세요.

🧾 WinAPI 파일 입출력 핵심 요점 정리

이 글에서는 Windows 환경에서 텍스트와 바이너리 파일을 다루는 필수 API 흐름을 단계별로 살폈습니다.
핵심은 CreateFile로 올바른 접근 권한과 공유 모드, 생성 정책을 선택해 핸들을 얻고, ReadFile/WriteFile에서 실제 처리된 바이트 수를 확인하며 견고한 루프를 구성하는 일입니다.
필요 시 FILE_FLAG_OVERLAPPED로 비차단 I/O를 구현하고, 위치 지정은 OVERLAPPED.Offset 또는 SetFilePointerEx로 명확히 관리합니다.
기록 보존이 중요하면 FlushFileBuffers와 임시 파일 → 교체 패턴을 활용해 안정성을 높입니다.
모든 경로에서 CloseHandle로 리소스를 정리하는 습관이 누수와 잠금 문제를 예방합니다.
인코딩, 경로 길이, 공유 충돌 같은 주변 요소까지 함께 설계하면 실무에서도 예측 가능한 파일 처리 품질을 확보할 수 있습니다.


🏷️ 관련 태그 : WinAPI, CreateFile, ReadFile, WriteFile, CloseHandle, Windows 파일 입출력, Overlapped I/O, UTF-8 인코딩, FlushFileBuffers, SetFilePointerEx