Rust로 산업용 IoT 게이트웨이 개발하기 - 5계층 에러 처리 아키텍처
산업용 시스템에서 에러 처리는 단순히 "실패했습니다"로 끝나면 안 됩니다. 현장 엔지니어가 문제를 빠르게 파악하고, 시스템이 자동으로 복구 가능한지 판단하며, API 클라이언트에게 적절한 HTTP 상태 코드를 반환해야 합니다.
이 글에서는 Modbus, OPC UA, BACnet 등 다양한 프로토콜을 통합하는 게이트웨이에서 사용한 5계층 에러 처리 아키텍처를 소개합니다.
문제 상황
처음에는 단순하게 시작했습니다:
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Connection failed")]
ConnectionFailed,
#[error("Read failed")]
ReadFailed,
#[error("Timeout")]
Timeout,
}
하지만 운영 환경에서 여러 문제가 발생했습니다:
"어느 장비에서 발생한 에러인가요?" - 컨텍스트 부족
"자동 재시도해도 되나요?" - 복구 가능 여부 불명확
"사용자에게 뭐라고 보여주죠?" - 기술적 메시지만 존재
"API 응답 코드는 뭘로?" - HTTP 매핑 없음
해결책: 5계층 에러 아키텍처
계층 구조
TrapError (통합)
├── ConfigError (설정)
├── DriverError (드라이버)
│ ├── ConnectionFailed
│ ├── ReadFailed
│ ├── WriteFailed
│ ├── Timeout
│ └── CircuitOpen
├── BufferError (버퍼링)
├── BusError (메시지 버스)
└── ApiError (REST API)
1. 드라이버 에러 - 풍부한 컨텍스트
#[derive(Debug, thiserror::Error)]
pub enum DriverError {
#[error("Connection failed: {message}")]
ConnectionFailed {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("Read failed for '{address}': {message}")]
ReadFailed {
address: String,
message: String
},
#[error("Operation timed out after {duration:?}")]
Timeout { duration: Duration },
#[error("Circuit breaker is open for device '{device_id}'")]
CircuitOpen { device_id: String },
#[error("Device not found: {device_id}")]
DeviceNotFound { device_id: String },
}
핵심 포인트: 각 에러 variant에 필요한 컨텍스트(주소, 장비 ID, 시간 등)를 구조체로 포함합니다.
2. 재시도 가능 여부 판단
impl DriverError {
pub fn is_retryable(&self) -> bool {
matches!(
self,
DriverError::Timeout { .. }
| DriverError::ConnectionFailed { .. }
| DriverError::CircuitOpen { .. }
)
}
}
사용 예시:
async fn read_with_retry(&self, address: &Address) -> Result<Value, DriverError> {
let mut attempts = 0;
loop {
match self.read(address).await {
Ok(value) => return Ok(value),
Err(e) if e.is_retryable() && attempts < 3 => {
attempts += 1;
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(e) => return Err(e),
}
}
}
3. HTTP 상태 코드 매핑
REST API 응답을 위한 자동 매핑:
impl DriverError {
pub fn status_code(&self) -> u16 {
match self {
DriverError::DeviceNotFound { .. }
| DriverError::AddressNotFound { .. } => 404,
DriverError::NotConnected
| DriverError::CircuitOpen { .. } => 503,
DriverError::Timeout { .. } => 504,
DriverError::InvalidValue { .. } => 400,
_ => 500,
}
}
}
Axum 핸들러에서 활용:
async fn read_tag(
State(driver): State<Arc<dyn ProtocolDriver>>,
Path(tag_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<ErrorResponse>)> {
driver.read(&tag_id.into()).await
.map(Json)
.map_err(|e| {
let status = StatusCode::from_u16(e.status_code())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
(status, Json(ErrorResponse::from(&e)))
})
}
4. 사용자 친화적 메시지 (다국어)
현장 운영자를 위한 한국어 메시지:
impl DriverError {
pub fn user_message(&self) -> String {
match self {
DriverError::ConnectionFailed { .. } =>
"장비 연결에 실패했습니다. 네트워크 상태를 확인해주세요.".to_string(),
DriverError::ReadFailed { address, .. } =>
format!("데이터 읽기 실패: {} (장비 상태를 확인해주세요)", address),
DriverError::Timeout { duration } =>
format!("응답 대기 시간 초과 ({:.1}초)", duration.as_secs_f64()),
DriverError::CircuitOpen { device_id } =>
format!("장비 보호 모드 활성화: {} (잠시 후 자동 복구됩니다)", device_id),
DriverError::DeviceNotFound { device_id } =>
format!("등록되지 않은 장비입니다: {}", device_id),
}
}
}
5. 메트릭/로깅용 에러 타입
Prometheus 메트릭이나 로그 분석을 위한 일관된 타입 문자열:
impl DriverError {
pub fn error_type(&self) -> &'static str {
match self {
DriverError::ConnectionFailed { .. } => "connection_failed",
DriverError::ReadFailed { .. } => "read_failed",
DriverError::WriteFailed { .. } => "write_failed",
DriverError::Timeout { .. } => "timeout",
DriverError::CircuitOpen { .. } => "circuit_open",
DriverError::DeviceNotFound { .. } => "device_not_found",
// ...
}
}
}
활용:
if let Err(e) = driver.read(&address).await {
metrics::counter!("driver_errors", "type" => e.error_type()).increment(1);
tracing::warn!(
error_type = e.error_type(),
retryable = e.is_retryable(),
"Read operation failed"
);
}
컨텍스트 추가 패턴
에러에 추가 정보를 더하는 빌더 스타일 메서드:
impl DriverError {
pub fn with_device(self, device_id: &str) -> Self {
match self {
DriverError::ConnectionFailed { message, source } => {
DriverError::ConnectionFailed {
message: format!("[{}] {}", device_id, message),
source,
}
}
DriverError::ReadFailed { address, message } => {
DriverError::ReadFailed {
address: format!("{}:{}", device_id, address),
message,
}
}
other => other,
}
}
pub fn with_address(self, address: &str) -> Self {
// 유사한 패턴
}
}
사용:
self.client.read(register)
.await
.map_err(|e| DriverError::from(e).with_device(&self.name))?
설정 에러: 구체적인 필드 검증
설정 파싱에서 발생하는 에러도 구체적으로:
#[derive(Debug, thiserror::Error)]
pub enum ConfigurationError {
#[error("Missing required field: {field}")]
MissingField { field: &'static str },
#[error("Invalid value for '{field}': expected {expected}, got {actual}")]
InvalidValue {
field: &'static str,
expected: String,
actual: String,
},
#[error("Invalid timeout: {duration:?} - {reason}")]
InvalidTimeout {
duration: Duration,
reason: String,
},
#[error("Invalid unit ID: {0} (must be 1-247)")]
InvalidUnitId(u8),
}
impl ConfigurationError {
pub fn missing_field(field: &'static str) -> Self {
Self::MissingField { field }
}
pub fn invalid_unit_id(id: u8) -> Self {
Self::InvalidUnitId(id)
}
}
전체 흐름 예시
실제 API 요청 처리 흐름:
// 1. API 핸들러
async fn write_tag(
State(cmd_bus): State<CommandBus>,
Path(tag_id): Path<String>,
Json(payload): Json<WriteRequest>,
) -> Result<Json<WriteResponse>, ApiError> {
// 2. 명령 생성 (감사 정보 포함)
let audit = AuditContext::with_user(&payload.user_id, client_ip);
let (cmd, response_rx) = WriteCommand::new(
tag_id.clone(),
payload.value,
audit,
Duration::from_secs(5),
);
// 3. 명령 전송
cmd_bus.send(cmd).await
.map_err(|e| ApiError::from(BusError::from(e)))?;
// 4. 응답 대기
let response = tokio::time::timeout(
Duration::from_secs(10),
response_rx
).await
.map_err(|_| ApiError::timeout("Write response timeout"))?
.map_err(|_| ApiError::internal("Response channel closed"))?;
// 5. 결과 반환
match response {
WriteResponse::Success { duration, .. } => {
Ok(Json(WriteResponse {
success: true,
duration_ms: duration.as_millis()
}))
}
WriteResponse::Error { error, .. } => {
// DriverError -> ApiError 변환 (HTTP 코드 자동 매핑)
Err(ApiError::from_driver_error(&error))
}
}
}
정리
메서드 | 용도 | 반환 타입 |
|---|---|---|
is_retryable() | 자동 재시도 여부 | bool |
status_code() | HTTP 응답 코드 | u16 |
user_message() | 사용자 표시용 | String |
error_type() | 메트릭/로깅용 | &'static str |
with_device() | 컨텍스트 추가 | Self |
이 패턴의 장점:
일관성: 모든 에러가 동일한 인터페이스 제공
정보 풍부성: 하나의 에러 타입에서 여러 정보 추출 가능
타입 안전성: 컴파일 타임에 에러 처리 누락 방지
확장성: 새 에러 타입 추가 시 trait만 구현
마치며
Rust의 thiserror와 enum을 활용하면 에러 처리가 단순한 예외 전파가 아닌, 시스템의 중요한 계약이 됩니다.
특히 산업용 시스템에서는 "왜 실패했는지", "복구 가능한지", "누구에게 어떻게 알릴지"가 모두 중요합니다. 이 패턴이 비슷한 고민을 하시는 분들께 도움이 되길 바랍니다.