[RestSharp 실전 시리즈] 3편. 에러 처리와 그리드 CRUD 저장 패턴
실무형 RestSharp API 통신 패턴 정리
시리즈 전체 목차
1편. RestSharp 기본 설정 · GET 요청 · 서버 응답 구조
2편. POST · PUT · DELETE와 JWT 인증
3편. 에러 처리와 그리드 CRUD 저장 패턴 ← 현재 글
1. 소개
1편과 2편에서 API 호출 기본기를 다뤘는데, 응답 처리를 response.IsSuccessful과 jObject["scs"] 체크 정도만 하고 넘어갔어요. 실무에서는 그걸로 충분하지 않아요.
타임아웃이 났을 때, 인증 토큰이 만료됐을 때, 서버가 500을 던졌을 때 각각 다르게 처리해야 하거든요. 에러 처리를 제대로 안 해두면 사용자 입장에서는 아무 반응 없이 그냥 멈춘 것처럼 보여요.
이번 편에서는 두 가지를 다뤄요.
- 2단계 에러 처리 — HTTP 레벨 실패와 비즈니스 레벨 실패를 나눠서 처리하는 패턴
- 그리드 CRUD 저장 —
DataTable의RowState를 활용해서 변경된 행만 골라 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 저장 패턴 ← 현재 글