Error Handling and Retries#
현대 소프트웨어 아키텍처에서 API는 중추적인 역할을 담당하며, 다양한 시스템 간의 원활한 통신을 가능하게 한다. 그러나 네트워크 불안정성, 서버 과부하, 일시적인 서비스 중단 등 다양한 이유로 API 호출은 항상 성공적으로 완료되지 않을 수 있다. 따라서 효과적인 오류 처리와 재시도 메커니즘은 안정적인 API 설계의 핵심 요소이다.
API 오류 처리의 중요성#
오류 처리가 중요한 이유#
효과적인 오류 처리는 다음과 같은 여러 이유로 중요하다:
- 사용자 경험 향상: 명확한 오류 메시지는 사용자가 문제를 이해하고 해결할 수 있게 도와준다.
- 디버깅 용이성: 상세한 오류 정보는 개발자가 문제를 신속하게 식별하고 해결하는 데 도움이 된다.
- 시스템 안정성: 적절한 오류 처리는 예기치 않은 상황에서도 애플리케이션이 계속 작동할 수 있게 한다.
- 보안 강화: 오류 처리는 민감한 정보 노출을 방지하고 잠재적인 공격 벡터를 감소시킨다.
- API 사용성: 일관되고 예측 가능한 오류 응답은 API의 사용성을 크게 향상시킨다.
부적절한 오류 처리의 결과#
오류 처리가 제대로 구현되지 않으면 다음과 같은 문제가 발생할 수 있다:
- 애플리케이션 장애: 처리되지 않은 예외로 인한 전체 시스템 중단
- 사용자 혼란: 불명확한 오류 메시지로 인한 사용자 좌절감 증가
- 리소스 낭비: 실패한 요청이 적절히 처리되지 않아 시스템 리소스 소모
- 데이터 불일치: 부분적으로 완료된 작업으로 인한 데이터 무결성 문제
- 보안 취약점: 과도한 오류 정보 노출로 인한 잠재적 보안 위험
API 오류 유형 및 분류#
API 오류를 효과적으로 처리하기 위해서는 먼저 다양한 유형의 오류를 이해해야 한다.
오류의 기본 분류#
API 오류는 크게 다음 네 가지 범주로 분류할 수 있다:
- 클라이언트 오류 (4xx): 잘못된 요청, 인증 실패, 권한 부족 등 클라이언트 측 문제로 인한 오류
- 서버 오류 (5xx): 서버 내부 오류, 서비스 불가, 게이트웨이 오류 등 서버 측 문제로 인한 오류
- 네트워크 오류: 연결 시간 초과, 네트워크 단절, DNS 해석 실패 등
- 비즈니스 로직 오류: 기술적으로는 성공적이지만 비즈니스 규칙 위반으로 인한 오류
일시적 vs. 영구적 오류#
오류를 처리하고 재시도 전략을 개발할 때 오류의 지속성 특성을 이해하는 것이 중요하다:
- 일시적 오류 (Transient Failures):
- 잠시 후 자동으로 해결될 수 있는 오류
- 예: 네트워크 지연, 서버 과부하, 일시적인 서비스 중단
- 재시도가 효과적인 경우
- 영구적 오류 (Permanent Failures):
- 조치 없이는 해결되지 않는 오류
- 예: 인증 실패, 권한 부족, 리소스 부재, 유효하지 않은 입력 데이터
- 재시도가 효과가 없거나 부적절한 경우
일반적인 HTTP 상태 코드#
REST API에서는 HTTP 상태 코드를 사용하여 오류 상태를 전달한다.
가장 일반적인 오류 관련 상태 코드는 다음과 같다:
클라이언트 오류 (4xx)
- 400 Bad Request: 유효하지 않은 요청 형식이나 매개변수
- 401 Unauthorized: 인증 필요
- 403 Forbidden: 인증되었으나 권한 부족
- 404 Not Found: 요청된 리소스를 찾을 수 없음
- 409 Conflict: 현재 상태와 충돌하는 요청
- 422 Unprocessable Entity: 유효성 검사 실패
- 429 Too Many Requests: 속도 제한 초과
서버 오류 (5xx)
- 500 Internal Server Error: 일반적인 서버 오류
- 502 Bad Gateway: 게이트웨이 또는 프록시 오류
- 503 Service Unavailable: 일시적으로 서비스 사용 불가
- 504 Gateway Timeout: 게이트웨이 시간 초과
효과적인 API 오류 응답 설계#
오류 응답 구조#
일관성 있고 유용한 API 오류 응답을 설계하기 위한 모범 사례는 다음과 같다:
표준 오류 응답 형식#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| {
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "The requested resource was not found.",
"details": "User with ID 12345 does not exist.",
"timestamp": "2023-06-15T08:42:15Z",
"requestId": "req-123456",
"path": "/api/users/12345",
"status": 404,
"fields": [
{
"name": "userId",
"reason": "not_found"
}
]
}
}
|
주요 구성 요소#
- 코드 (code): 기계가 읽을 수 있는 고유한 오류 식별자
- 메시지 (message): 사용자 친화적인 오류 설명
- 세부 정보 (details): 오류에 대한 추가 컨텍스트 (선택 사항)
- 타임스탬프 (timestamp): 오류 발생 시간
- 요청 ID (requestId): 요청 추적을 위한 고유 식별자
- 경로 (path): 오류가 발생한 API 엔드포인트
- 상태 (status): HTTP 상태 코드
- 필드 (fields): 특정 필드와 관련된 오류 (유효성 검사 오류의 경우)
오류 메시지 작성 지침#
효과적인 오류 메시지는 다음 특성을 갖추어야 한다:
- 명확성: 무슨 일이 일어났는지 분명하게 설명한다.
- 간결성: 짧고 간결하게 정보를 전달한다.
- 행동 지향성: 가능한 경우 문제 해결 방법을 제시한다.
- 기술 용어 지양: 최종 사용자가 이해할 수 있는 언어를 사용한다.
- 일관성: 전체 API에서 일관된 형식과 용어를 사용한다.
좋은 오류 메시지 예시#
- “유효하지 않은 이메일 형식입니다. 올바른 이메일 주소를 입력해 주세요.”
- “결제 금액은 양수여야 합니다.”
- “요청된 사용자를 찾을 수 없습니다. 사용자 ID를 확인하세요.”
나쁜 오류 메시지 예시#
- “오류 발생!”
- “데이터베이스 쿼리 실패: SQL 오류 #5123”
- “NullPointerException이 발생했습니다.”
다양한 환경에 맞는 오류 정보 수준#
오류 응답에 포함되는 정보의 양은 환경에 따라 조정되어야 한다:
- 개발 환경:
- 상세한 디버깅 정보 포함
- 스택 추적, 예외 유형, 내부 오류 코드 등 포함 가능
- 세부적인 문맥 정보 제공
- 프로덕션 환경:
- 민감한 내부 정보 제외
- 보안 위험을 야기할 수 있는 세부 정보 생략
- 사용자 친화적인 메시지에 중점
- 문제 추적을 위한 요청 ID 항상 포함
서버 측 오류 처리 전략#
API 엔드포인트에서의 오류 처리#
서버 측 API 구현에서 효과적인 오류 처리를 위한 접근 방식.
유효성 검사#
입력 데이터 유효성 검사는 오류 처리의 첫 번째 방어선:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // Java Spring Boot 예시
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest userRequest) {
// @Valid 어노테이션이 유효성 검사를 처리합니다.
// 유효하지 않은 경우 MethodArgumentNotValidException이 발생합니다.
User user = userService.createUser(userRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
// 전역 예외 처리기
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(
MethodArgumentNotValidException ex) {
List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
List<Map<String, String>> fields = fieldErrors.stream()
.map(err -> Map.of(
"name", err.getField(),
"reason", err.getDefaultMessage()
))
.collect(Collectors.toList());
ErrorResponse errorResponse = new ErrorResponse(
"VALIDATION_ERROR",
"입력 데이터 유효성 검사에 실패했습니다.",
"하나 이상의 필드가 유효하지 않습니다.",
ZonedDateTime.now(),
UUID.randomUUID().toString(),
ex.getRequest().getRequestURI(),
HttpStatus.BAD_REQUEST.value(),
fields
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
}
|
예외 처리 및 매핑#
비즈니스 로직에서 발생하는 예외를 적절한 HTTP 응답으로 매핑하는 전략:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| // 사용자 정의 예외 클래스
public class ResourceNotFoundException extends RuntimeException {
private final String resourceType;
private final String resourceId;
public ResourceNotFoundException(String resourceType, String resourceId) {
super(String.format("%s with ID %s not found", resourceType, resourceId));
this.resourceType = resourceType;
this.resourceId = resourceId;
}
// getters…
}
// 서비스 레이어
@Service
public class UserService {
public User getUserById(String userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User", userId));
}
}
// 전역 예외 처리기에 추가
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
ResourceNotFoundException ex, HttpServletRequest request) {
ErrorResponse errorResponse = new ErrorResponse(
"RESOURCE_NOT_FOUND",
ex.getMessage(),
null,
ZonedDateTime.now(),
UUID.randomUUID().toString(),
request.getRequestURI(),
HttpStatus.NOT_FOUND.value(),
null
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
|
트랜잭션 관리#
API 작업 중 오류가 발생했을 때 데이터 일관성을 유지하기 위한 트랜잭션 관리:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| @Service
public class OrderService {
@Transactional
public Order createOrder(OrderRequest orderRequest) {
// 전체 작업이 트랜잭션으로 묶여 있어 일부에서 실패하면 모두 롤백됩니다.
Customer customer = customerRepository.findById(orderRequest.getCustomerId())
.orElseThrow(() -> new ResourceNotFoundException("Customer", orderRequest.getCustomerId()));
// 재고 확인
for (OrderItemRequest item : orderRequest.getItems()) {
Product product = productRepository.findById(item.getProductId())
.orElseThrow(() -> new ResourceNotFoundException("Product", item.getProductId()));
if (product.getStock() < item.getQuantity()) {
throw new InsufficientStockException(product.getId(), product.getStock(), item.getQuantity());
}
}
// 주문 생성 및 재고 업데이트
Order order = new Order();
// …주문 처리 로직…
return orderRepository.save(order);
}
}
|
로깅 전략#
효과적인 오류 처리를 위해서는 적절한 로깅이 필수적:
로그 레벨 선택#
상황에 맞는 로그 레벨 사용:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| @Service
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
public PaymentResult processPayment(PaymentRequest request) {
try {
// 결제 처리 로직
log.info("Payment processed successfully for order: {}", request.getOrderId());
return result;
} catch (PaymentProviderTemporaryException ex) {
// 일시적인 오류 - 재시도 가능
log.warn("Temporary payment provider error: {}. Will retry.", ex.getMessage());
throw ex;
} catch (PaymentDeclinedException ex) {
// 정상적인 비즈니스 거부 - 심각한 오류 아님
log.info("Payment declined: {}", ex.getMessage());
throw ex;
} catch (Exception ex) {
// 예상치 못한 오류 - 심각한 문제
log.error("Critical payment processing error", ex);
throw new PaymentFailedException("Payment processing failed unexpectedly", ex);
}
}
}
|
구조화된 로깅 및 컨텍스트 포함#
문제 진단에 필요한 충분한 컨텍스트 정보를 로그에 포함:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // MDC(Mapped Diagnostic Context)를 사용한 로깅
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 요청 ID 생성 및 MDC에 추가
String requestId = UUID.randomUUID().toString();
MDC.put("requestId", requestId);
// 요청 정보 로깅
MDC.put("clientIp", request.getRemoteAddr());
MDC.put("userAgent", request.getHeader("User-Agent"));
MDC.put("path", request.getRequestURI());
try {
// 응답 헤더에 요청 ID 추가
response.setHeader("X-Request-ID", requestId);
filterChain.doFilter(request, response);
} finally {
// MDC 정리
MDC.clear();
}
}
}
|
중앙 집중식 로그 관리#
분산 시스템에서의 로그 관리 및 상관관계 분석:
- 로그 집계: ELK 스택(Elasticsearch, Logstash, Kibana), Graylog, Splunk 등을 사용하여 모든 서비스의 로그를 중앙에서 수집 및 분석
- 요청 추적: 마이크로서비스 간 요청 추적을 위해 Spring Cloud Sleuth, Zipkin, Jaeger 등의 분산 추적 시스템 활용
- 알림 설정: 중요 오류에 대한 실시간 알림 구성
클라이언트 측 오류 처리 전략#
API를 소비하는 클라이언트 측에서의 오류 처리 전략.
오류 감지 및 처리#
일반적인 오류 처리 패턴#
JavaScript에서의 일반적인 오류 처리 패턴:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| // Fetch API 사용시 오류 처리
async function fetchUser(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
// HTTP 오류 응답 확인
if (!response.ok) {
const errorData = await response.json();
throw new APIError(
response.status,
errorData.error.code,
errorData.error.message,
errorData.error.requestId
);
}
return await response.json();
} catch (error) {
if (error instanceof APIError) {
// API 오류 처리
console.error(`API Error ${error.status}: ${error.code} - ${error.message}`);
// 사용자에게 적절한 메시지 표시
displayErrorMessage(error.userFriendlyMessage);
} else {
// 네트워크 오류 등 다른 예외 처리
console.error('Network or unknown error:', error);
displayErrorMessage('네트워크 연결을 확인해 주세요.');
}
throw error; // 필요한 경우 상위 레벨로 오류 전파
}
}
// 사용자 정의 API 오류 클래스
class APIError extends Error {
constructor(status, code, message, requestId) {
super(message);
this.name = 'APIError';
this.status = status;
this.code = code;
this.requestId = requestId;
this.userFriendlyMessage = this.getUserFriendlyMessage();
}
getUserFriendlyMessage() {
// 오류 코드에 따른 사용자 친화적 메시지 반환
switch (this.code) {
case 'RESOURCE_NOT_FOUND':
return '요청하신 정보를 찾을 수 없습니다.';
case 'VALIDATION_ERROR':
return '입력한 정보가 유효하지 않습니다.';
case 'UNAUTHORIZED':
return '이 작업을 수행하기 위한 권한이 없습니다.';
default:
return '요청을 처리하는 중 오류가 발생했습니다. 나중에 다시 시도해 주세요.';
}
}
}
|
상태 코드 및 응답 구조 분석#
다양한 HTTP 상태 코드 및 API 응답 구조를 분석하여 적절한 처리 방법 결정:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
| async function handleAPIResponse(response) {
// 응답 본문 파싱
let data;
let errorData;
try {
// 응답이 JSON인지 확인
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
} catch (error) {
// 응답 본문 파싱 오류
errorData = { message: 'Invalid response format' };
}
// 성공적인 응답
if (response.ok) {
return data;
}
// 오류 응답 처리
switch (response.status) {
case 400: // Bad Request
return handleValidationErrors(data);
case 401: // Unauthorized
// 인증 토큰 갱신 또는 로그인 페이지로 리디렉션
authService.refreshToken().catch(() => {
window.location.href = '/login';
});
throw new AuthError('Authentication required');
case 403: // Forbidden
throw new AccessDeniedError('You do not have permission to access this resource');
case 404: // Not Found
throw new NotFoundError(`The requested resource was not found`);
case 422: // Unprocessable Entity
return handleValidationErrors(data);
case 429: // Too Many Requests
// 재시도 지연 설정
const retryAfter = response.headers.get('Retry-After');
throw new RateLimitError('Rate limit exceeded', retryAfter);
case 500: // Internal Server Error
case 502: // Bad Gateway
case 503: // Service Unavailable
case 504: // Gateway Timeout
throw new ServerError('A server error occurred. Please try again later.');
default:
throw new APIError(`Unknown error: ${response.status}`, data);
}
}
// 유효성 검사 오류 처리
function handleValidationErrors(data) {
if (data.error && data.error.fields) {
// 필드별 오류 메시지 추출
const fieldErrors = data.error.fields.reduce((acc, field) => {
acc[field.name] = field.reason;
return acc;
}, {});
throw new ValidationError(data.error.message, fieldErrors);
}
throw new BadRequestError(data.error?.message || 'Invalid request');
}
|
사용자 경험 최적화#
오류 발생 시 최종 사용자 경험을 최적화하는 방법:
오류 UI 패턴#
사용자에게 오류를 표시하는 다양한 UI 패턴:
- 인라인 오류 메시지: 특정 입력 필드 옆에 오류 메시지 표시
- 토스트 알림: 일시적인 오류 메시지를 화면 상단/하단에 표시
- 모달 다이얼로그: 중요한 오류에 대해 사용자의 주의를 끌기 위한 모달 창
- 오류 페이지: 심각한 오류의 경우 전용 오류 페이지로 이동
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
| // React에서의 오류 UI 예시
function UserForm() {
const [user, setUser] = useState({ name: '', email: '' });
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
setErrors({});
setServerError(null);
setIsSubmitting(true);
try {
// 로컬 유효성 검사
const validationErrors = validateUser(user);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// API 호출
const response = await createUser(user);
// 성공 처리
toast.success('User created successfully!');
// 리디렉션 또는 다음 단계 처리
} catch (error) {
if (error instanceof ValidationError) {
// 필드별 오류 표시
setErrors(error.fieldErrors);
} else if (error instanceof ServerError) {
// 서버 오류 표시
setServerError('The server is currently unavailable. Please try again later.');
} else {
// 일반 오류 표시
setServerError(error.userFriendlyMessage || 'An unexpected error occurred.');
}
// 오류 로깅
logErrorToMonitoringService(error);
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
{serverError && (
<div className="error-banner">
<ErrorIcon /> {serverError}
</div>
)}
<div className="form-field">
<label htmlFor="name">Name</label>
<input
id="name"
value={user.name}
onChange={(e) => setUser({…user, name: e.target.value})}
className={errors.name ? 'input-error' : ''}
/>
{errors.name && <div className="field-error">{errors.name}</div>}
</div>
<div className="form-field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={user.email}
onChange={(e) => setUser({…user, email: e.target.value})}
className={errors.email ? 'input-error' : ''}
/>
{errors.email && <div className="field-error">{errors.email}</div>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating…' : 'Create User'}
</button>
</form>
);
}
|
오류 복구 옵션#
사용자가 오류를 복구할 수 있는 방법 제공:
- 가이드 제공: 오류 해결을 위한 단계별 지침 제공
- 자동 수정 제안: 가능한 경우 오류 수정 방법 제안
- 대체 워크플로우: 현재 경로가 실패한 경우 대체 옵션 제공
- 수동 재시도: 사용자가 작업을 다시 시도할 수 있는 옵션 제공
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| // 오류 복구 옵션을 제공하는 컴포넌트 예시
function PaymentErrorHandler({ error, onRetry, onAlternativeMethod }) {
// 오류 유형에 따른 대응
switch (error.code) {
case 'PAYMENT_DECLINED':
return (
<ErrorCard>
<h3>결제가 거부되었습니다</h3>
<p>카드 발급사에서 이 거래를 거부했습니다. 다음 중 하나를 시도해 보세요:</p>
<ul>
<li>카드 정보가 올바른지 확인하세요</li>
<li>충분한 잔액이 있는지 확인하세요</li>
<li>다른 카드로 시도하세요</li>
</ul>
<ButtonGroup>
<Button primary onClick={onRetry}>다시 시도</Button>
<Button secondary onClick={onAlternativeMethod}>다른 결제 방법</Button>
</ButtonGroup>
</ErrorCard>
);
case 'NETWORK_ERROR':
return (
<ErrorCard>
<h3>연결 오류</h3>
<p>인터넷 연결에 문제가 있습니다. 네트워크 연결을 확인한 후 다시 시도하세요.</p>
<ConnectionStatus />
<Button primary onClick={onRetry}>다시 시도</Button>
</ErrorCard>
);
case 'SERVICE_UNAVAILABLE':
return (
<ErrorCard>
<h3>서비스 일시 중단</h3>
<p>결제 서비스가 현재 일시적으로 사용 불가능합니다. 불편을 드려 죄송합니다.</p>
<p>현재 시스템 상태: <ServiceStatusIndicator /></p>
<p>예상 복구 시간: {formatTime(error.estimatedResolutionTime)}</p>
<ButtonGroup>
<Button primary onClick={onRetry}>다시 시도</Button>
<Button secondary onClick={() => saveOrderForLater()}>나중에 완료</Button>
</ButtonGroup>
</ErrorCard>
);
default:
return (
<ErrorCard>
<h3>결제 처리 중 오류가 발생했습니다</h3>
<p>{error.userFriendlyMessage}</p>
<Button primary onClick={onRetry}>다시 시도</Button>
</ErrorCard>
);
}
}
|
API 재시도 전략#
네트워크 불안정성이나 일시적인 서비스 중단으로 인한 일시적 오류는 재시도를 통해 해결할 수 있다. 효과적인 재시도 전략은 애플리케이션의 복원력을 크게 향상시킨다.
재시도 가능한 오류 식별#
모든 오류가 재시도에 적합한 것은 아니다.
다음 기준을 사용하여 재시도 가능한 오류를 식별할 수 있다:
일반적으로 재시도 가능한 오류#
- 네트워크 시간 초과 (Network Timeouts): 일시적인 네트워크 지연으로 인한 시간 초과
- 서버 과부하 (503 Service Unavailable): 서버가 일시적으로 요청을 처리할 수 없음
- 연결 오류 (Connection Errors): 네트워크 연결 문제
- 5xx 서버 오류: 대부분의 서버 측 오류(500, 502, 504 등)
- 일시적인 데이터베이스 오류: 경쟁 조건, 교착 상태, 연결 풀 고갈 등
- 속도 제한 (429 Too Many Requests): 요청 속도 제한 초과(적절한 백오프와 함께)
재시도하지 말아야 할 오류#
- 클라이언트 오류 (4xx): 대부분의 400, 401, 403, 404 등은 재시도해도 성공할 가능성이 낮음
- 유효성 검사 실패: 입력 데이터 문제로 인한 오류
- 인증/권한 오류: 인증 정보가 잘못되었거나 권한이 부족한 경우
- 비즈니스 로직 오류: 비즈니스 규칙 위반으로 인한 오류
- 영구적인 리소스 상태 문제: 리소스가 이미 존재하거나 찾을 수 없는 경우
7.1.3 오류 분류 코드 예시#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| // 오류의 재시도 가능성 평가
function isRetryable(error) {
// 네트워크 오류는 재시도 가능
if (error.name === 'NetworkError' || error.message.includes('network')) {
return true;
}
// API 오류인 경우 상태 코드 확인
if (error instanceof APIError) {
// 서버 오류(5xx)는 일반적으로 재시도 가능
if (error.status >= 500 && error.status < 600) {
return true;
}
// 429(Too Many Requests)는 재시도 가능하지만 Retry-After 헤더 준수 필요
if (error.status === 429) {
return true;
}
// 특정 비즈니스 오류 코드가 재시도 가능한지 확인
if (RETRYABLE_ERROR_CODES.includes(error.code)) {
return true;
}
}
// 특정 데이터베이스 오류 식별
if (error.code === 'DEADLOCK' || error.code === 'CONNECTION_TIMEOUT') {
return true;
}
// 기본적으로 재시도하지 않음
return false;
}
// 재시도 가능한 비즈니스 오류 코드 목록
const RETRYABLE_ERROR_CODES = [
'TEMPORARY_OUTAGE',
'DATABASE_TIMEOUT',
'RESOURCE_EXHAUSTED',
'SERVICE_UNAVAILABLE',
'GATEWAY_TIMEOUT'
];
|
백오프 전략#
단순히 요청을 즉시 재시도하는 것은 서버에 부담을 줄 수 있다. 효과적인 백오프 전략을 사용하여 재시도 간격을 조정해야 한다.
주요 백오프 전략#
- 고정 지연 (Fixed Delay):
- 일정한 시간 간격으로 재시도
- 간단하지만 대규모 클라이언트의 경우 ‘서지(surge)’ 문제 발생 가능
- 지수 백오프 (Exponential Backoff):
- 재시도할 때마다 대기 시간을 지수적으로 증가
- 가장 널리 사용되는 전략으로 서버 부하를 효과적으로 분산
- 지수 백오프 + 지터 (Exponential Backoff with Jitter):
- 지수 백오프에 무작위성 추가
- 다수의 클라이언트가 동시에 재시도하는 것을 방지해 서버 부하 피크 감소
- 데코레이터 (Decorator) 헤더 기반 재시도:
- 서버가
Retry-After
헤더를 통해 권장 재시도 시간 제공 - 서버 상태에 기반한 지능적인 재시도 가능
지수 백오프 구현 예시#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
| /**
* 지수 백오프 및 지터를 사용한 재시도 함수
* @param {Function} operation - 실행할 작업 함수
* @param {Object} options - 재시도 옵션
* @returns {Promise} 작업 결과
*/
async function retryWithBackoff(operation, options = {}) {
const {
maxRetries = 5,
baseDelay = 300,
maxDelay = 30000,
factor = 2,
jitter = true,
retryableErrors = isRetryable
} = options;
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error) {
attempt++;
// 최대 재시도 횟수 초과 또는 재시도할 수 없는 오류인 경우
if (attempt >= maxRetries || !retryableErrors(error)) {
throw error;
}
// Retry-After 헤더가 있는 경우 해당 값 사용
if (error.retryAfter) {
const retryAfterMs = parseRetryAfter(error.retryAfter);
await sleep(retryAfterMs);
continue;
}
// 지수 백오프 계산
let delay = Math.min(maxDelay, baseDelay * Math.pow(factor, attempt - 1));
// 지터 추가 (선택 사항)
if (jitter) {
// 계산된 지연의 0%~100% 사이의 무작위 값 사이에서 지연 시간 조정
delay = Math.random() * delay;
}
console.log(`Retrying after ${delay}ms (attempt ${attempt} of ${maxRetries})`);
await sleep(delay);
}
}
}
// 헬퍼 함수
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function parseRetryAfter(retryAfter) {
// 숫자만 있는 경우 초 단위로 해석
if (/^\d+$/.test(retryAfter)) {
return parseInt(retryAfter, 10) * 1000;
}
// HTTP 날짜 형식인 경우 밀리초로 변환
const date = new Date(retryAfter);
return date.getTime() - Date.now();
}
// 사용 예시
async function fetchUserWithRetry(userId) {
return retryWithBackoff(
() => fetch(`/api/users/${userId}`).then(res => {
if (!res.ok) {
const error = new Error('API request failed');
error.status = res.status;
error.retryAfter = res.headers.get('Retry-After');
throw error;
}
return res.json();
}),
{
maxRetries: 3,
baseDelay: 500,
retryableErrors: (error) => error.status >= 500 || error.status === 429
}
);
}
|
재시도 제한 및 서킷 브레이커#
무한정 재시도하는 것은 리소스 낭비와 시스템 과부하를 초래할 수 있다. 적절한 제한을 설정하고 서킷 브레이커 패턴을 구현하는 것이 중요하다.
재시도 제한#
- 최대 재시도 횟수: 특정 횟수 이후에는 재시도 중단
- 최대 시간 제한: 지정된 시간 이후에는 재시도 중단
- 재시도 예산: 일정 시간 동안 허용되는 최대 재시도 횟수 설정
서킷 브레이커 패턴#
서킷 브레이커는 반복적인 실패가 감지될 때 추가 호출을 일시적으로 차단하는 패턴이다. 이는 실패한 서비스가 복구될 시간을 제공하고, 시스템의 나머지 부분을 보호한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
| /**
* 간단한 서킷 브레이커 구현
*/
class CircuitBreaker {
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 30000;
this.monitorInterval = options.monitorInterval || 5000;
this.state = 'CLOSED';
this.failureCount = 0;
this.lastFailureTime = null;
this.monitors = [];
// 모니터링 간격 설정
setInterval(() => this.updateState(), this.monitorInterval);
}
// 상태 업데이트
updateState() {
if (this.state === 'OPEN') {
const now = Date.now();
if (now - this.lastFailureTime >= this.resetTimeout) {
// 재설정 시간이 지나면 반열림 상태로 전환
this.state = 'HALF_OPEN';
this.notifyMonitors();
}
}
}
// 작업 실행
async execute(operation) {
if (this.state === 'OPEN') {
throw new Error('Circuit is OPEN - operation rejected');
}
try {
const result = await operation();
// 성공 시 회로 닫기
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
this.failureCount = 0;
this.notifyMonitors();
}
return result;
} catch (error) {
// 실패 처리
this.recordFailure();
throw error;
}
}
// 실패 기록
recordFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.state === 'CLOSED' && this.failureCount >= this.failureThreshold) {
// 실패 임계값 초과 시 회로 열기
this.state = 'OPEN';
this.notifyMonitors();
} else if (this.state === 'HALF_OPEN') {
// 반열림 상태에서 실패하면 다시 열림 상태로
this.state = 'OPEN';
this.notifyMonitors();
}
}
// 상태 변경 시 이벤트 발생
notifyMonitors() {
this.monitors.forEach(callback => {
try {
callback(this.state);
} catch (error) {
console.error('Circuit breaker monitor error:', error);
}
});
}
// 상태 변경 이벤트 구독
onStateChange(callback) {
this.monitors.push(callback);
return () => {
this.monitors = this.monitors.filter(cb => cb !== callback);
};
}
}
// 서킷 브레이커 사용 예시
const paymentServiceBreaker = new CircuitBreaker({
failureThreshold: 3,
resetTimeout: 10000
});
// 로깅 및 메트릭 수집
paymentServiceBreaker.onStateChange(state => {
console.log(`Payment service circuit breaker state changed to: ${state}`);
metrics.recordCircuitBreakerState('payment-service', state);
});
// API 호출 시 서킷 브레이커 적용
async function processPayment(paymentData) {
try {
return await paymentServiceBreaker.execute(() => {
return paymentApiClient.processPayment(paymentData);
});
} catch (error) {
if (error.message.includes('Circuit is OPEN')) {
// 대체 결제 처리 또는 사용자에게 알림
return fallbackPaymentProcessing(paymentData);
}
throw error;
}
}
|
재시도와 멱등성#
재시도 전략을 안전하게 구현하기 위해서는 API 작업의 멱등성(idempotency)을 고려해야 한다.
멱등성이란?#
멱등성은 동일한 요청을 여러 번 실행해도 시스템 상태가 한 번 실행한 것과 동일한 상태가 되는 속성을 의미한다. 멱등한 작업은 재시도해도 안전하다.
HTTP 메서드별 멱등성#
- 멱등한 메서드: GET, PUT, DELETE, HEAD, OPTIONS
- 멱등하지 않은 메서드: POST, PATCH
멱등성 구현 전략#
멱등성 키 사용:
- 클라이언트가 각 요청에 고유 식별자 제공
- 서버는 이미 처리된 요청을 감지하고 중복 처리 방지
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
| // 클라이언트 측 구현
async function createOrder(orderData) {
// 클라이언트 생성 고유 ID
const idempotencyKey = generateUUID();
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(orderData)
});
// 응답 처리…
}
// 서버 측 구현 (Express.js)
// 멱등성 키 처리 미들웨어
function idempotencyMiddleware(req, res, next) {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey && req.method === 'POST') {
return res.status(400).json({
error: {
code: 'MISSING_IDEMPOTENCY_KEY',
message: 'Idempotency-Key header is required for POST requests'
}
});
}
if (idempotencyKey) {
// 캐시에서 이전 응답 확인
redisClient.get(`idempotency:${idempotencyKey}`, (err, cachedResponse) => {
if (err) {
return next(err);
}
if (cachedResponse) {
// 이미 처리된 요청이므로 캐시된 응답 반환
const parsedResponse = JSON.parse(cachedResponse);
return res.status(parsedResponse.status)
.set(parsedResponse.headers)
.send(parsedResponse.body);
}
// 기존 응답 객체 메서드 저장
const originalSend = res.send;
const originalJson = res.json;
const originalStatus = res.status;
// 응답 데이터 캡처
let responseBody;
let responseStatus = 200;
const responseHeaders = {};
// 응답 메서드 재정의
res.send = function(body) {
responseBody = body;
originalSend.apply(res, arguments);
return res;
};
res.json = function(body) {
responseBody = body;
originalJson.apply(res, arguments);
return res;
};
res.status = function(code) {
responseStatus = code;
originalStatus.apply(res, arguments);
return res;
};
res.on('finish', () => {
// 성공적인 응답만 캐시
if (responseStatus >= 200 && responseStatus < 500) {
const cacheEntry = {
status: responseStatus,
headers: responseHeaders,
body: responseBody
};
// 응답 캐싱 (24시간 TTL)
redisClient.set(
`idempotency:${idempotencyKey}`,
JSON.stringify(cacheEntry),
'EX',
86400
);
}
});
next();
});
} else {
next();
}
}
|
조건부 요청:
- HTTP 조건부 요청 헤더(If-Match, If-None-Match 등) 사용
- 리소스 상태를 기반으로 작업 수행 여부 결정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // 조건부 업데이트 예시
async function updateUserIfNotChanged(userId, userData, etag) {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': etag // 리소스가 변경되지 않은 경우에만 업데이트
},
body: JSON.stringify(userData)
});
if (response.status === 412) { // Precondition Failed
// 리소스가 변경되었으므로 최신 데이터 가져오기
const currentData = await fetch(`/api/users/${userId}`).then(r => r.json());
// 충돌 해결 로직…
return { conflict: true, currentData };
}
return response.json();
}
|
거래 ID 사용:
- 전체 거래(여러 API 호출로 구성될 수 있음)에 대한 고유 ID 사용
- 데이터베이스에 거래 상태 저장하여 중복 처리 방지
프로덕션 환경에서의 오류 모니터링 및 분석#
효과적인 오류 처리를 위해서는 프로덕션 환경에서 발생하는 오류를 지속적으로 모니터링하고 분석하는 것이 중요하다.
모니터링 및 알림 시스템#
주요 모니터링 대상#
- 오류 발생률: 전체 요청 대비 오류 비율
- 오류 유형 분포: 다양한 오류 유형의 발생 빈도
- 서비스 수준 목표(SLO): 가용성 및 성능 지표
- 재시도 패턴: 재시도 빈도 및 성공률
- 서킷 브레이커 상태: 열림/닫힘 상태 및 전환 빈도
모니터링 도구 및 통합#
- 애플리케이션 성능 모니터링(APM): New Relic, Datadog, Dynatrace 등
- 로그 집계 및 분석: ELK 스택(Elasticsearch, Logstash, Kibana), Graylog
- 메트릭 시각화: Grafana, Prometheus
- 오류 추적 서비스: Sentry, Rollbar, Bugsnag
- 분산 추적: Jaeger, Zipkin
오류 패턴 분석#
수집된 오류 데이터를 분석하여 패턴을 식별하고 근본 원인을 파악한다:
오류 분석 방법#
- 오류 빈도 및 영향 평가: 가장 자주 발생하는 오류와 그 영향 분석
- 시간적 패턴 분석: 특정 시간이나 이벤트와 관련된 오류 패턴 식별
- 사용자 영향 분석: 오류가 사용자 경험에 미치는 영향 평가
- 근본 원인 분석: 오류의 근본 원인 및 해결 방안 식별
오류 분류 및 우선순위 지정#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
| // 오류 분류 및 심각도 평가 함수
function categorizeError(error) {
// 기본 정보 수집
const errorData = {
type: error.name || 'Unknown',
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
component: error.component || getCurrentComponent(),
requestId: error.requestId || getCurrentRequestId(),
userId: getCurrentUserId(),
url: error.url || window.location.href
};
// 심각도 결정
let severity = 'info';
if (error instanceof NetworkError || error.status >= 500) {
severity = 'error';
} else if (error instanceof AuthError) {
severity = 'warning';
} else if (error instanceof ValidationError) {
severity = 'info';
}
// 재시도 가능성 평가
const isRetryable = evaluateRetryability(error);
// 사용자 영향 평가
const userImpact = evaluateUserImpact(error);
// 비즈니스 영향 평가
const businessImpact = evaluateBusinessImpact(error);
// 우선순위 계산
const priority = calculatePriority(severity, userImpact, businessImpact);
return {
…errorData,
severity,
isRetryable,
userImpact,
businessImpact,
priority
};
}
// 오류 보고 및 처리
function reportError(error) {
const categorizedError = categorizeError(error);
// 심각도에 따른 처리
switch (categorizedError.severity) {
case 'error':
// 중요 오류는 즉시 알림
notifyOnCall(categorizedError);
break;
case 'warning':
// 경고는 집계 후 알림
addToErrorAggregation(categorizedError);
break;
// 기타 심각도…
}
// 모든 오류는 중앙 에러 추적 시스템에 보고
errorTrackingService.captureException(error, {
extra: categorizedError
});
// 내부 분석을 위한 로깅
logger.log(categorizedError.severity, JSON.stringify(categorizedError));
}
|
사례 연구 및 실제 예시#
다양한 산업 및 애플리케이션 유형에서의 API 오류 처리 및 재시도 전략의 실제 사례
전자상거래 플랫폼#
시나리오#
대규모 전자상거래 플랫폼에서 결제 처리 API의 오류 처리 및 재시도 전략
주요 도전 과제#
- 고객 경험에 직접적인 영향을 미치는 중요한 거래
- 외부 결제 게이트웨이에 대한 의존성
- 부분적으로 완료된 거래의 일관성 유지
- 높은 거래량과 트래픽 스파이크
구현된 솔루션#
- 멱등성 처리:
- 모든 결제 요청에 클라이언트 생성 멱등성 키 사용
- 동일한 거래 ID에 대한 중복 처리 방지
- 계층적 재시도 전략:
- 클라이언트 측: 사용자에게 영향을 미치지 않는 배경 재시도
- 서버 측: 결제 게이트웨이 API 호출에 대한 지능적 재시도
- 비동기 작업 큐: 실패한 작업을 나중에 재시도할 수 있도록 큐에 저장
- 복원력 패턴:
- 서킷 브레이커: 외부 결제 프로세서 문제 감지 및 대체 프로세서로 전환
- 대체 경로: 주요 결제 방법이 실패할 경우 대체 결제 방법 제안
- 사용자 경험 최적화:
- 배경 재시도: 사용자에게 보이지 않게 재시도 진행
- 명확한 오류 메시지: 사용자가 이해하기 쉬운 오류 설명과 해결 방법 제공
- 진행 상태 추적: 주문 처리 단계를 명확히 표시하여 사용자가 프로세스 상태를 이해할 수 있도록 함
- 모니터링 및 분석:
- 실시간 대시보드: 결제 오류율 및 유형 모니터링
- 자동 알림: 특정 임계값 초과 시 운영팀에 알림
- 정기적인 리뷰: 결제 오류 패턴 분석 및 개선점 도출
- 결제 성공률 99.5%로 향상
- 결제 관련 고객 문의 40% 감소
- 결제 처리 시스템의 복원력 개선으로 서비스 중단 감소
모바일 애플리케이션#
시나리오#
불안정한 네트워크 환경에서 작동해야 하는 모바일 애플리케이션의 API 클라이언트
주요 도전 과제#
- 간헐적인 네트워크 연결
- 제한된 배터리 및 데이터 사용량
- 다양한 네트워크 상태(Wi-Fi, 셀룰러, 오프라인)
- 백그라운드 동기화 요구사항
구현된 솔루션#
- 오프라인 우선 아키텍처:
- 로컬 데이터 저장소: 모든 사용자 작업을 먼저 로컬에 저장
- 작업 큐: 오프라인 상태에서 수행된 작업을 서버 동기화를 위한 큐에 저장
- 네트워크 인식 재시도:
- 네트워크 상태 모니터링: 연결 유형 및 품질에 따른 재시도 전략 조정
- 배터리 인식: 배터리 잔량이 적을 때 재시도 빈도 감소
- 동기화 전략:
- 우선순위 기반 동기화: 중요한 작업 먼저 동기화
- 일괄 처리: 여러 작업을 하나의 네트워크 요청으로 묶어 효율성 향상
- 충돌 해결:
- 자동 해결: 가능한 경우 충돌 자동 해결 알고리즘 적용
- 사용자 안내: 복잡한 충돌의 경우 사용자에게 해결 옵션 제공
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
| // 모바일 애플리케이션에서의 오프라인 우선 API 클라이언트 구현
class OfflineFirstApiClient {
constructor(options = {}) {
this.apiBaseUrl = options.apiBaseUrl || 'https://api.example.com';
this.syncManager = new SyncManager();
this.networkMonitor = new NetworkMonitor();
this.localStore = new LocalStore();
// 네트워크 상태 변경 시 동기화 시도
this.networkMonitor.onConnectivityChange((isConnected) => {
if (isConnected) {
this.syncManager.attemptSync();
}
});
}
// 데이터 생성 요청
async create(endpoint, data) {
// 고유 ID 생성
const localId = generateUUID();
const timestamp = Date.now();
// 로컬 저장소에 저장
await this.localStore.saveItem(endpoint, {
id: localId,
data,
status: 'pending',
operation: 'create',
timestamp
});
// 네트워크 연결 확인
if (this.networkMonitor.isConnected()) {
try {
// 서버에 즉시 동기화 시도
const result = await this.syncManager.syncItem(endpoint, localId);
return result;
} catch (error) {
// 오류 발생 시 작업은 여전히 큐에 남아 있음
console.log(`Synchronization failed, will retry later: ${error.message}`);
return { id: localId, status: 'pending', data };
}
} else {
// 오프라인 상태 - 나중에 동기화
console.log('Offline - item queued for later synchronization');
return { id: localId, status: 'pending', data };
}
}
// 데이터 업데이트 요청
async update(endpoint, id, data) {
// 로컬 데이터 업데이트
await this.localStore.updateItem(endpoint, id, {
data,
status: 'pending',
operation: 'update',
timestamp: Date.now()
});
// 즉시 동기화 시도
if (this.networkMonitor.isConnected()) {
return this.syncManager.syncItem(endpoint, id);
}
return { id, status: 'pending', data };
}
// 데이터 조회 요청
async get(endpoint, id) {
// 먼저 로컬 데이터 확인
const localData = await this.localStore.getItem(endpoint, id);
// 네트워크 연결 확인
if (this.networkMonitor.isConnected()) {
try {
// 서버에서 최신 데이터 가져오기
const response = await fetch(`${this.apiBaseUrl}/${endpoint}/${id}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const serverData = await response.json();
// 로컬 데이터 업데이트
await this.localStore.saveItem(endpoint, {
id,
data: serverData,
status: 'synced',
timestamp: Date.now()
});
return serverData;
} catch (error) {
console.log(`Error fetching data: ${error.message}`);
// 오류 발생 시 로컬 데이터 반환
return localData ? localData.data : null;
}
} else {
// 오프라인 상태 - 로컬 데이터만 반환
return localData ? localData.data : null;
}
}
}
// 동기화 관리자
class SyncManager {
constructor() {
this.syncQueue = [];
this.isSyncing = false;
this.retryDelay = 1000;
this.maxRetries = 5;
// 주기적인 동기화 시도 (5분마다)
setInterval(() => this.attemptSync(), 5 * 60 * 1000);
}
// 모든 대기 중인 항목 동기화 시도
async attemptSync() {
if (this.isSyncing) return;
this.isSyncing = true;
try {
// 동기화 큐 처리
const pendingItems = await localStore.getPendingItems();
// 배터리 및 네트워크 상태에 따라 동기화 전략 조정
const batchSize = this.determineBatchSize();
const prioritizedItems = this.prioritizeItems(pendingItems);
for (let i = 0; i < prioritizedItems.length; i += batchSize) {
const batch = prioritizedItems.slice(i, i + batchSize);
await this.processBatch(batch);
}
} catch (error) {
console.error('Sync error:', error);
} finally {
this.isSyncing = false;
}
}
// 네트워크 및 배터리 상태에 따른 배치 크기 결정
determineBatchSize() {
const networkType = networkMonitor.getConnectionType();
const batteryLevel = batteryMonitor.getBatteryLevel();
// 배터리 부족 또는 셀룰러 네트워크에서는 작은 배치 사용
if (batteryLevel < 0.2 || networkType === 'cellular') {
return 5;
}
// Wi-Fi 및 충분한 배터리에서는 큰 배치 사용
return 20;
}
// 항목 우선순위 지정
prioritizeItems(items) {
return items.sort((a, b) => {
// 1. 작업 유형 (생성 > 업데이트 > 삭제)
if (a.operation !== b.operation) {
const operationPriority = { 'create': 3, 'update': 2, 'delete': 1 };
return operationPriority[b.operation] - operationPriority[a.operation];
}
// 2. 엔드포인트 우선순위 (중요한 데이터 먼저)
const endpointPriority = {
'payments': 5,
'orders': 4,
'profiles': 3,
'preferences': 2,
'logs': 1
};
const aPriority = endpointPriority[a.endpoint] || 0;
const bPriority = endpointPriority[b.endpoint] || 0;
if (aPriority !== bPriority) {
return bPriority - aPriority;
}
// 3. 타임스탬프 (오래된 항목 먼저)
return a.timestamp - b.timestamp;
});
}
}
|
- 약한 네트워크 환경에서도 97% 동기화 성공률 달성
- 오프라인 상태에서도 앱 사용성 유지
- 네트워크 데이터 사용량 30% 감소
- 배터리 사용량 최적화
9.3 금융 서비스 API#
시나리오#
높은 신뢰성과 정확성이 요구되는 금융 거래 API
주요 도전 과제#
- 0% 오류 허용 정책
- 엄격한 규정 준수 요구사항
- 고의적 API 남용 및 공격 대응
- 감사 및 추적 필요성
구현된 솔루션#
- 심층 방어(Defense in Depth):
- 다중 검증 계층: 클라이언트 측, API 게이트웨이, 서비스 계층, 데이터베이스 계층의 검증
- 이중 제출 방지: 멱등성 키와 거래 ID를 통한 중복 거래 방지
- 정교한 오류 처리:
- 분류된 오류 코드: 상세한 오류 분류 체계
- 보안 강화 오류 메시지: 민감한 정보를 노출하지 않는 오류 응답
- 재시도 관리:
- 조건부 재시도: 시스템 상태 및 거래 유형에 따른 재시도 결정
- 재시도 알림: 재시도 시 관리자에게 자동 알림
- 감사 및 규정 준수:
- 전체 요청 및 응답 로깅: 감사 목적으로 모든 API 상호작용 기록
- 결과 증명: 각 거래에 대한 암호학적 증명 생성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
| // 금융 거래 API 오류 처리 예시
@RestController
@RequestMapping("/api/v1/transactions")
public class TransactionController {
private final TransactionService transactionService;
private final AuditService auditService;
private final IdempotencyService idempotencyService;
// 거래 생성 API
@PostMapping
public ResponseEntity<TransactionResponse> createTransaction(
@RequestHeader("X-Idempotency-Key") String idempotencyKey,
@Valid @RequestBody TransactionRequest request,
Authentication authentication) {
// 사용자 인증 및 권한 확인
User user = (User) authentication.getPrincipal();
if (!transactionService.canInitiateTransaction(user, request.getAccountId())) {
// 권한 오류 감사 로깅
auditService.logAccessDenied(user.getId(), "transaction_initiate", request.getAccountId());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(TransactionResponse.error("ACCESS_DENIED", "You don't have permission to initiate transactions for this account"));
}
try {
// 멱등성 확인
Optional<TransactionResponse> cachedResponse =
idempotencyService.getCachedResponse(idempotencyKey, user.getId());
if (cachedResponse.isPresent()) {
// 중복 요청 - 이전 응답 반환
return ResponseEntity.ok(cachedResponse.get());
}
// 거래 처리 (여러 검증 단계 포함)
TransactionResult result = transactionService.processTransaction(request, user);
// 성공한 거래 응답 캐싱
TransactionResponse response = TransactionResponse.success(result);
idempotencyService.cacheResponse(idempotencyKey, user.getId(), response);
// 감사 로깅
auditService.logSuccessfulTransaction(
result.getTransactionId(),
user.getId(),
request.getAccountId(),
request.getAmount(),
result.getTimestamp());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
} catch (InsufficientFundsException e) {
// 비즈니스 규칙 위반 - 자금 부족
auditService.logTransactionFailed(user.getId(), request.getAccountId(),
"INSUFFICIENT_FUNDS", request.getAmount());
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
.body(TransactionResponse.error("INSUFFICIENT_FUNDS",
"Insufficient funds in the account"));
} catch (AccountLockedException e) {
// 계정 잠김 - 재시도 정보 포함
return ResponseEntity.status(HttpStatus.LOCKED)
.header("Retry-After", String.valueOf(e.getLockDurationSeconds()))
.body(TransactionResponse.error("ACCOUNT_LOCKED",
"Account is temporarily locked. Please try again later."));
} catch (TransactionLimitExceededException e) {
// 거래 한도 초과
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(TransactionResponse.error("LIMIT_EXCEEDED",
"Transaction exceeds your daily/monthly limit"));
} catch (DuplicateTransactionException e) {
// 중복 거래 (멱등성이 아닌 비즈니스 기준)
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(TransactionResponse.error("DUPLICATE_TRANSACTION",
"A similar transaction was recently processed"));
} catch (Exception e) {
// 예상치 못한 오류
String errorId = UUID.randomUUID().toString();
auditService.logSystemError(errorId, user.getId(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(TransactionResponse.error("SYSTEM_ERROR",
"An unexpected error occurred. Reference: " + errorId));
}
}
}
// 트랜잭션 서비스의 재시도 로직
@Service
@Transactional
public class TransactionServiceImpl implements TransactionService {
private final TransactionRepository transactionRepository;
private final AccountService accountService;
private final NotificationService notificationService;
@Override
public TransactionResult processTransaction(TransactionRequest request, User user) {
// 트랜잭션 생성 및 초기 상태 저장
Transaction transaction = new Transaction();
transaction.setStatus(TransactionStatus.PENDING);
transaction.setTransactionId(generateTransactionId());
transaction.setAmount(request.getAmount());
transaction.setSourceAccountId(request.getSourceAccountId());
transaction.setDestinationAccountId(request.getDestinationAccountId());
transaction.setUserId(user.getId());
transaction.setCreatedAt(LocalDateTime.now());
transactionRepository.save(transaction);
try {
// 외부 결제 게이트웨이 호출
PaymentResult paymentResult = processPayment(transaction);
// 결과 업데이트
transaction.setStatus(TransactionStatus.COMPLETED);
transaction.setCompletedAt(LocalDateTime.now());
transaction.setExternalReference(paymentResult.getReferenceId());
// 알림 전송
notificationService.sendTransactionConfirmation(transaction);
return new TransactionResult(
transaction.getTransactionId(),
TransactionStatus.COMPLETED,
transaction.getCompletedAt()
);
} catch (PaymentGatewayTimeoutException e) {
// 시간 초과 - 재시도 가능
return handlePaymentTimeout(transaction, e);
} catch (PaymentGatewayErrorException e) {
// 게이트웨이 오류 - 거래 실패로 표시
transaction.setStatus(TransactionStatus.FAILED);
transaction.setErrorCode(e.getErrorCode());
transaction.setErrorMessage(e.getMessage());
transactionRepository.save(transaction);
throw new TransactionFailedException("Payment gateway error: " + e.getMessage());
}
}
private TransactionResult handlePaymentTimeout(Transaction transaction, PaymentGatewayTimeoutException e) {
// 게이트웨이 시간 초과 - 거래 상태 확인 필요
transaction.setStatus(TransactionStatus.PENDING_VERIFICATION);
transactionRepository.save(transaction);
// 비동기 검증 작업 예약
verificationTaskScheduler.scheduleVerification(transaction.getTransactionId());
// 관리자에게 알림
notificationService.alertOperator(
"PAYMENT_TIMEOUT",
"Transaction " + transaction.getTransactionId() + " requires verification",
transaction
);
// 클라이언트에게 확인 대기 중 응답
return new TransactionResult(
transaction.getTransactionId(),
TransactionStatus.PENDING_VERIFICATION,
null
);
}
// 거래 상태 확인 (비동기 검증용)
@Transactional
public TransactionResult verifyTransactionStatus(String transactionId) {
Transaction transaction = transactionRepository.findByTransactionId(transactionId)
.orElseThrow(() -> new TransactionNotFoundException(transactionId));
if (transaction.getStatus() != TransactionStatus.PENDING_VERIFICATION) {
return new TransactionResult(
transaction.getTransactionId(),
transaction.getStatus(),
transaction.getCompletedAt()
);
}
try {
// 결제 게이트웨이에 상태 확인
PaymentStatusResult statusResult = paymentGateway.checkStatus(transaction.getExternalReference());
switch (statusResult.getStatus()) {
case COMPLETED:
transaction.setStatus(TransactionStatus.COMPLETED);
transaction.setCompletedAt(LocalDateTime.now());
break;
case FAILED:
transaction.setStatus(TransactionStatus.FAILED);
transaction.setErrorCode(statusResult.getErrorCode());
transaction.setErrorMessage(statusResult.getErrorMessage());
break;
case PENDING:
// 여전히 대기 중 - 나중에 다시 확인
return new TransactionResult(
transaction.getTransactionId(),
TransactionStatus.PENDING_VERIFICATION,
null
);
}
transactionRepository.save(transaction);
// 결과에 따른 알림
if (transaction.getStatus() == TransactionStatus.COMPLETED) {
notificationService.sendTransactionConfirmation(transaction);
} else if (transaction.getStatus() == TransactionStatus.FAILED) {
notificationService.sendTransactionFailure(transaction);
}
return new TransactionResult(
transaction.getTransactionId(),
transaction.getStatus(),
transaction.getCompletedAt()
);
} catch (Exception e) {
// 확인 중 오류 - 수동 검토 필요
transaction.setStatus(TransactionStatus.MANUAL_REVIEW_REQUIRED);
transaction.setNotes("Verification failed: " + e.getMessage());
transactionRepository.save(transaction);
// 수동 검토 알림
notificationService.alertOperator(
"MANUAL_REVIEW_REQUIRED",
"Transaction " + transactionId + " requires manual review",
transaction
);
return new TransactionResult(
transaction.getTransactionId(),
TransactionStatus.MANUAL_REVIEW_REQUIRED,
null
);
}
}
}
|
- 99.999% 트랜잭션 성공률 달성
- 엄격한 규제 요구사항 충족
- 거래 분쟁 50% 감소
- 신속한 문제 해결을 위한 강력한 감사 추적 구현
용어 정리#
용어 | 설명 |
---|
백오프(Backoff) | 네트워크 통신 또는 시스템에서 충돌이나 오류가 발생했을 때, 재시도 간격을 조정하여 문제를 해결하거나 시스템 자원을 효율적으로 사용하는 데 활용되는 기법
충돌 감지 및 대기: - 두 개 이상의 노드가 동시에 데이터를 전송하려고 할 때 충돌이 발생한다. 백오프는 이러한 충돌을 감지한 후 일정 시간 대기하여 재전송을 시도하는 방식이다. - 대기 시간은 랜덤하게 설정되며, 이를 통해 재충돌 확률을 줄인다. 재시도 간격 조정: - 백오프는 재시도 간격을 점진적으로 늘려가며, 네트워크 혼잡이나 서버 과부하를 완화한다. - 이 과정에서 일정한 패턴(예: 지수적 증가)을 따르거나 랜덤 값을 추가하여 효율성을 높인다. |
서지(Surge) 문제 | 시스템이나 네트워크에서 짧은 시간 동안 갑작스럽게 요청이나 트래픽이 급증하는 현상을 의미한다. 이러한 문제는 클라이언트-서버 환경, 분산 시스템, 네트워크 인프라 등 다양한 기술적 환경에서 발생할 수 있으며, 시스템 성능 저하, 과부하, 또는 장애를 초래할 수 있다. - 서지(Surge) 문제의 주요 원인 1. 사용자 요청의 급증: - 특정 시간대에 대규모 사용자들이 동시에 서비스를 이용하려는 경우(예: 쇼핑몰의 할인 행사, 티켓 예매 시작). 2. 시스템 장애로 인한 재시도 요청: - 클라이언트가 실패한 요청을 반복적으로 재시도하면서 트래픽이 폭발적으로 증가. 3. 외부 이벤트: - 자연재해, 데이터 유출 등 예기치 못한 사건으로 인해 고객 지원 요청이 급증. 4. 봇 트래픽 및 DDoS 공격: - 악의적인 봇이나 분산 서비스 거부 공격으로 인해 비정상적인 트래픽이 발생. - 서지 문제로 인한 영향 1. 네트워크 혼잡: - 트래픽이 폭증하면 네트워크 대역폭이 포화 상태에 도달하여 정상적인 데이터 전송이 지연된다. 2. 서버 과부하: - 서버가 처리할 수 있는 최대 연결 수를 초과하면 응답 시간이 길어지거나 요청이 거부된다. 3. 서비스 중단: - 과도한 부하로 인해 시스템이 다운되거나 사용자가 오류 페이지를 보게 되는 상황 발생. 4. 데이터 손실 및 성능 저하: - 처리되지 않은 요청이 큐에 쌓이며 타임아웃 발생 또는 데이터 손실 가능성 증가. 5. 비용 증가: - 클라우드 환경에서는 급격한 트래픽 처리를 위해 추가적인 자원 할당(오토스케일링)이 필요하여 비용 부담 증가.
|
Jitter | 재시도 간격에 랜덤성을 추가하는 기법으로, 네트워크나 시스템의 안정성을 높이고 성능 저하를 방지하기 위해 사용된다. - Jitter의 필요성 1. 동기화된 재시도의 문제(Thundering Herd Problem): - 여러 클라이언트가 동일한 실패 조건을 감지하고, 고정된 간격으로 재시도를 시도하면 서버에 과도한 부하가 발생할 수 있다. - 예를 들어, 100개의 클라이언트가 5초 후 동시에 재시도하면 서버는 다시 과부하 상태에 빠질 가능성이 높다. 2. 재충돌 방지: - 백오프 간격이 고정되거나 동일한 알고리즘을 따르는 경우, 클라이언트들이 동일한 시간에 다시 요청을 보내 충돌이 반복될 수 있다. 3. 트래픽 스파이크 완화: - Jitter는 요청 시간을 분산시켜 트래픽이 특정 시간대에 몰리는 것을 방지한다. |
참고 및 출처#