카테고리 없음

[RestSharp 실전 시리즈] 3편. 에러 처리와 그리드 CRUD 저장 패턴

Lior 2026. 6. 7. 19:30
RESTSHARP PRACTICAL SERIES · 3편

[RestSharp 실전 시리즈] 3편. 에러 처리와 그리드 CRUD 저장 패턴

실무형 RestSharp API 통신 패턴 정리

.NET Framework 4.7.2 · RestSharp 110.x
핵심 요약 RestSharp 기반 CRUD API 통신, JWT 인증, 에러 처리, DataTable 저장 패턴을 실무 중심으로 정리합니다.

시리즈 전체 목차
1편. RestSharp 기본 설정 · GET 요청 · 서버 응답 구조
2편. POST · PUT · DELETE와 JWT 인증
3편. 에러 처리와 그리드 CRUD 저장 패턴
← 현재 글


1. 소개

1편과 2편에서 API 호출 기본기를 다뤘는데, 응답 처리를 response.IsSuccessfuljObject["scs"] 체크 정도만 하고 넘어갔어요. 실무에서는 그걸로 충분하지 않아요.

타임아웃이 났을 때, 인증 토큰이 만료됐을 때, 서버가 500을 던졌을 때 각각 다르게 처리해야 하거든요. 에러 처리를 제대로 안 해두면 사용자 입장에서는 아무 반응 없이 그냥 멈춘 것처럼 보여요.

이번 편에서는 두 가지를 다뤄요.

  1. 2단계 에러 처리 — HTTP 레벨 실패와 비즈니스 레벨 실패를 나눠서 처리하는 패턴
  2. 그리드 CRUD 저장DataTableRowState를 활용해서 변경된 행만 골라 insert/update/delete로 분류한 다음 한 번에 서버로 보내는 패턴

이 두 패턴을 조합하면 실무 화면 대부분은 커버할 수 있어요.


2. 기본 사용 방법

에러가 나는 지점 두 곳

API 호출에서 실패는 두 단계로 발생해요.

[1단계] HTTP 레벨 실패
  → 타임아웃, 네트워크 단절
  → 서버가 4xx / 5xx 반환

[2단계] 비즈니스 레벨 실패
  → HTTP 200 OK이지만 처리 실패
  → scs: false, code: "ERR_DUPLICATE", msg: "이미 등록된 항목입니다."

response.IsSuccessful로 1단계를 걸러내고, (bool)jObject["scs"]로 2단계를 걸러내는 게 기본 구조예요.

에러 응답 DTO

HTTP 에러(4xx/5xx)가 났을 때 서버가 내려보내는 에러 구조를 파싱하기 위한 DTO예요.

public class ApiErrorDto
{
    public string scs  { get; set; }
    public string code { get; set; }
    public string msg  { get; set; }
    public string args { get; set; }
}

3. 실무 적용 방법

2단계 에러 처리 전체 패턴

private void SaveProduct(ProductDto dto)
{
    RestClient client = new RestClient("https://api.example.com");
    RestRequest req   = new RestRequest("/products", Method.Post);
    req.AddStringBody(JsonConvert.SerializeObject(dto), ContentType.Json);
    req.AddHeader("Authorization", "Bearer " + AppSession.Token);

    RestResponse response = client.Execute(req);

    // ── 1단계: HTTP 레벨 체크 ──────────────────────────────
    if (!response.IsSuccessful)
    {
        // 타임아웃
        if (response.ResponseStatus == ResponseStatus.TimedOut)
        {
            MessageBox.Show("요청 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.");
            return;
        }

        // 서버가 4xx/5xx 에러 응답을 내려준 경우
        if (!string.IsNullOrEmpty(response.Content))
        {
            ApiErrorDto errorDto = JsonConvert.DeserializeObject<ApiErrorDto>(response.Content);
            MessageBox.Show(ApiMessage.GetMessage(errorDto.code));
        }
        else
        {
            MessageBox.Show("서버에 연결할 수 없습니다.");
        }
        return;
    }

    // ── 2단계: 비즈니스 레벨 체크 ────────────────────────────
    JObject jObject = JObject.Parse(response.Content);

    if (!(bool)jObject["scs"])
    {
        string code = jObject["code"].ToString();

        // 인증 만료 → 로그인 화면으로
        if (code == "ERR_AUTH")
        {
            MessageBox.Show("인증이 만료되었습니다. 다시 로그인해 주세요.");
            ShowLoginForm();
            return;
        }

        MessageBox.Show(jObject["msg"].ToString());
        return;
    }

    // ── 성공 처리 ────────────────────────────────────────────
    MessageBox.Show("저장되었습니다.");
    SearchProducts();
}

에러 코드 → 메시지 변환 유틸리티

서버가 내려보낸 에러 코드를 그대로 사용자에게 보여주면 안 돼요. 친절한 메시지로 변환하는 유틸리티를 만들어 두세요.

public static class ApiMessage
{
    private static readonly Dictionary<string, string> _messages =
        new Dictionary<string, string>
    {
        { "ERR_AUTH",       "인증이 만료되었습니다. 다시 로그인해 주세요." },
        { "ERR_FORBIDDEN",  "접근 권한이 없습니다." },
        { "ERR_DUPLICATE",  "이미 등록된 항목입니다." },
        { "ERR_NOT_FOUND",  "해당 데이터를 찾을 수 없습니다." },
        { "ERR_VALIDATION", "입력값을 확인해 주세요." },
    };

    public static string GetMessage(string code)
    {
        return _messages.TryGetValue(code, out string msg)
            ? msg
            : "처리 중 오류가 발생했습니다. (" + code + ")";
    }
}

그리드 CRUD 저장 — DataRowState 활용

1편에서 AcceptChanges()를 꼭 호출해야 한다고 했는데, 이 이유가 여기서 나와요.

DataTable은 각 행의 상태를 자동으로 추적해요.

RowState 의미 언제
Added 신규 추가 조회 직후(AcceptChanges 전), 사용자가 행 추가
Unchanged 변경 없음 AcceptChanges() 호출 후
Modified 수정됨 사용자가 기존 행 편집
Deleted 삭제됨 사용자가 행 삭제

이 상태를 이용해서 변경된 행만 골라내는 유틸리티예요.

public static class GridUtils
{
    public static Dictionary<string, List<DataRow>> GetChangedRows(DataTable dataTable)
    {
        var insertList = new List<DataRow>();
        var updateList = new List<DataRow>();
        var deleteList = new List<DataRow>();

        // GetChanges()는 변경된 행만 담긴 새 DataTable을 반환해요
        DataTable changes = dataTable.GetChanges();

        if (changes == null) // 변경 없으면 null 반환
            goto done;

        foreach (DataRow row in changes.Rows)
        {
            if (row.RowState == DataRowState.Added)
            {
                insertList.Add(row);
            }
            else if (row.RowState == DataRowState.Modified)
            {
                updateList.Add(row);
            }
            else if (row.RowState == DataRowState.Deleted)
            {
                // Deleted 상태의 행은 현재 값에 접근하면 예외가 나요
                // Original 버전으로 읽어서 새 행에 복사해야 해요
                DataRow deletedRow = changes.NewRow();
                for (int i = 0; i < changes.Columns.Count; i++)
                    deletedRow[i] = row[i, DataRowVersion.Original];
                deleteList.Add(deletedRow);
            }
        }

        done:
        return new Dictionary<string, List<DataRow>>
        {
            { "insert", insertList },
            { "update", updateList },
            { "delete", deleteList }
        };
    }
}

DataRowState.Deleted 상태의 행에서 row["컬럼명"]으로 직접 값을 읽으면 DeletedRowInaccessibleException이 나요. 반드시 row[컬럼인덱스, DataRowVersion.Original]로 읽어야 해요.

DataRow → DTO 변환 (리플렉션)

변경된 행 목록을 서버로 보내려면 DTO 리스트로 변환해야 해요. 리플렉션을 활용하면 컬럼명과 프로퍼티명이 일치하는 경우 자동으로 매핑할 수 있어요.

public static class ConvUtils
{
    public static T GetItem<T>(DataRow row) where T : new()
    {
        T obj = Activator.CreateInstance<T>();
        Type type = typeof(T);

        foreach (DataColumn col in row.Table.Columns)
        {
            foreach (var prop in type.GetProperties())
            {
                if (prop.Name != col.ColumnName) continue;
                if (row[col.ColumnName] is DBNull)  { prop.SetValue(obj, null); continue; }

                string typeName = prop.PropertyType.FullName;

                if      (typeName.Contains("String"))  prop.SetValue(obj, Convert.ToString(row[col.ColumnName]));
                else if (typeName.Contains("Int64"))   prop.SetValue(obj, Convert.ToInt64(row[col.ColumnName]));
                else if (typeName.Contains("Int32"))   prop.SetValue(obj, Convert.ToInt32(row[col.ColumnName]));
                else if (typeName.Contains("Double"))  prop.SetValue(obj, Convert.ToDouble(row[col.ColumnName]));
                else if (typeName.Contains("Decimal")) prop.SetValue(obj, Convert.ToDecimal(row[col.ColumnName]));
                else if (typeName.Contains("Boolean")) prop.SetValue(obj, Convert.ToBoolean(row[col.ColumnName]));
                else if (typeName.Contains("DateTime"))prop.SetValue(obj, Convert.ToDateTime(row[col.ColumnName]));
            }
        }
        return obj;
    }
}

그리드 저장 전체 흐름

위 유틸리티들을 조합한 실제 저장 버튼 코드예요.

private void btnSave_Click(object sender, EventArgs e)
{
    DataTable dt = (DataTable)gridProduct.DataSource;

    // 변경된 행 분류
    var changed = GridUtils.GetChangedRows(dt);

    if (changed["insert"].Count == 0
     && changed["update"].Count == 0
     && changed["delete"].Count == 0)
    {
        MessageBox.Show("변경된 내용이 없습니다.");
        return;
    }

    // DataRow → DTO 변환
    var insertDtos = changed["insert"].Select(r => ConvUtils.GetItem<ProductDto>(r)).ToList();
    var updateDtos = changed["update"].Select(r => ConvUtils.GetItem<ProductDto>(r)).ToList();
    var deleteDtos = changed["delete"].Select(r => ConvUtils.GetItem<ProductDto>(r)).ToList();

    // 요청 조립
    JObject reqParam = new JObject();
    reqParam.Add("insertList", JsonConvert.SerializeObject(insertDtos));
    reqParam.Add("updateList", JsonConvert.SerializeObject(updateDtos));
    reqParam.Add("deleteList", JsonConvert.SerializeObject(deleteDtos));

    RestClient client = new RestClient("https://api.example.com");
    RestRequest req   = new RestRequest("/products/batch", Method.Post);
    req.AddBody(reqParam.ToString(), ContentType.Json);
    req.AddHeader("Authorization", "Bearer " + AppSession.Token);

    RestResponse response = client.Execute(req);

    if (!response.IsSuccessful)
    {
        MessageBox.Show("저장 중 오류가 발생했습니다.");
        return;
    }

    JObject jObject = JObject.Parse(response.Content);
    if ((bool)jObject["scs"])
    {
        MessageBox.Show("저장되었습니다.");
        SearchProducts(); // 새로 조회하면 AcceptChanges 포함
    }
    else
    {
        MessageBox.Show(jObject["msg"].ToString());
    }
}

4. 주의사항

주의 1 — Deleted 행 읽기는 반드시 Original 버전으로

이걸 모르면 처음에 진짜 당황해요. DataRowState.Deleted 상태의 행은 이미 "삭제 예약"된 상태라 현재 값에 접근하면 예외가 터져요.

// 잘못된 방법 — DeletedRowInaccessibleException 발생
string name = row["name"].ToString(); // 예외!

// 올바른 방법 — Original 버전으로 읽기
string name = row["name", DataRowVersion.Original].ToString();

유틸리티 메서드에서 처리해 주면 호출하는 쪽에서는 신경 쓰지 않아도 돼요.

주의 2 — 저장 후 AcceptChanges 타이밍

저장 성공 후 dt.AcceptChanges()를 직접 호출하는 대신, 서버에서 최신 데이터를 다시 조회하는 방식이 더 안전해요. 서버에서 자동으로 채워주는 필드(생성일시, 생성자 등)가 있을 수 있거든요.

// 권장 — 저장 후 새로 조회
if ((bool)jObject["scs"])
{
    MessageBox.Show("저장되었습니다.");
    SearchProducts(); // 조회 메서드 안에서 AcceptChanges() 포함
}

// 비권장 — 직접 AcceptChanges (서버 반영 값이 누락될 수 있음)
// dt.AcceptChanges();

주의 3 — GetChanges()가 null을 반환해요

DataTable.GetChanges()는 변경된 행이 없으면 null을 반환해요. null 체크를 빠뜨리면 NullReferenceException이 나요.

DataTable changes = dataTable.GetChanges();

if (changes == null) return; // 변경 없음 — 반드시 체크

foreach (DataRow row in changes.Rows)
{
    // ...
}

5. 성능 / 구조 팁

API 호출 공통 메서드로 추출하기

모든 API 호출에서 에러 처리 코드가 반복된다면, 공통 메서드로 추출하면 유지보수가 훨씬 편해져요.

public static class ApiClient
{
    private static readonly string BaseUrl = ConfigManager.GetServerUrl();

    public static JObject Execute(string endpoint, Method method, object body = null)
    {
        RestClient client = new RestClient(BaseUrl);
        RestRequest req   = new RestRequest(endpoint, method);

        if (body != null)
            req.AddStringBody(JsonConvert.SerializeObject(body), ContentType.Json);

        req.AddHeader("Authorization", "Bearer " + AppSession.Token);

        RestResponse response = client.Execute(req);

        // 1단계 체크
        if (!response.IsSuccessful)
        {
            if (response.ResponseStatus == ResponseStatus.TimedOut)
                throw new TimeoutException("요청 시간이 초과되었습니다.");

            if (!string.IsNullOrEmpty(response.Content))
            {
                ApiErrorDto err = JsonConvert.DeserializeObject<ApiErrorDto>(response.Content);
                throw new ApiException(err.code, ApiMessage.GetMessage(err.code));
            }

            throw new ApiException("ERR_NETWORK", "서버에 연결할 수 없습니다.");
        }

        JObject jObject = JObject.Parse(response.Content);

        // 2단계 체크
        if (!(bool)jObject["scs"])
            throw new ApiException(
                jObject["code"].ToString(),
                jObject["msg"].ToString()
            );

        return jObject;
    }
}
// 호출하는 쪽은 이렇게 깔끔해져요
try
{
    JObject result = ApiClient.Execute("/products", Method.Get);

    DataTable dt = (DataTable)JsonConvert.DeserializeObject(
        result["list"].ToString(), typeof(DataTable));
    dt.AcceptChanges();
    gridProduct.DataSource = dt;
}
catch (ApiException ex) when (ex.Code == "ERR_AUTH")
{
    MessageBox.Show(ex.Message);
    ShowLoginForm();
}
catch (ApiException ex)
{
    MessageBox.Show(ex.Message);
}
catch (TimeoutException ex)
{
    MessageBox.Show(ex.Message);
}

커스텀 예외 클래스

public class ApiException : Exception
{
    public string Code { get; }

    public ApiException(string code, string message) : base(message)
    {
        Code = code;
    }
}

6. 정리

에러 처리 - API 실패는 HTTP 레벨(타임아웃/4xx/5xx)과 비즈니스 레벨(scs: false) 두 단계로 나뉘어요 - response.IsSuccessful로 1단계, jObject["scs"]로 2단계를 순서대로 확인해요 - 에러 코드는 사용자 친화적 메시지로 변환해서 보여줘요 - ERR_AUTH 같은 인증 에러는 별도 분기로 처리해서 로그인 화면으로 보내야 해요

그리드 CRUD 저장 - 조회 후 AcceptChanges()를 호출해야 이후 변경 추적이 올바르게 동작해요 - DataTable.GetChanges()로 변경된 행만 추출하고 RowState로 insert/update/delete 분류해요 - Deleted 상태의 행은 DataRowVersion.Original로 값을 읽어야 예외가 안 나요 - 저장 후에는 AcceptChanges() 직접 호출보다 다시 조회하는 방식이 더 안전해요

구조 팁 - 반복되는 에러 처리는 공통 메서드로 추출하고, 커스텀 예외로 의미를 명확히 해요 - 에러 코드 → 메시지 변환은 딕셔너리 기반 유틸리티로 한 곳에서 관리해요


이 시리즈에서 다룬 패턴들은 .NET WinForms 기반 업무용 앱에서 실제로 사용 중인 방식이에요. GET 조회 → 그리드 바인딩 → 사용자 편집 → 변경 행 저장이라는 흐름이 반복되는 화면이라면 그대로 적용할 수 있어요.


시리즈 전체 목차
1편. RestSharp 기본 설정 · GET 요청 · 서버 응답 구조
2편. POST · PUT · DELETE와 JWT 인증
3편. 에러 처리와 그리드 CRUD 저장 패턴 ← 현재 글

다음 시리즈 RestSharp 실전 시리즈 다음 글도 함께 확인해보세요.