Synchronous vs. Asynchronous APIs#
API 설계에서 동기식(Synchronous)과 비동기식(Asynchronous) 패턴 중 어떤 것을 선택할지는 시스템 아키텍처와 사용자 경험에 중대한 영향을 미치는 결정이다. 각 패턴은 고유한 장단점을 가지고 있으며, 특정 사용 사례에 더 적합할 수 있다.
동기식 API(Synchronous API)#
동기식 API는 클라이언트가 요청을 보내고 서버의 응답을 받을 때까지 대기하는 방식으로 작동한다. 이는 요청-응답 주기가 완료될 때까지 클라이언트가 다른 작업을 수행하지 않는 “차단(blocking)” 방식을 의미한다.
동기식 API의 작동 원리#
동기식 API의 기본 흐름은 다음과 같다:
- 클라이언트가 API 요청을 전송한다.
- 클라이언트는 서버의 응답을 기다리며 차단된다.
- 서버는 요청을 처리하고 결과를 생성한다.
- 서버가 응답을 클라이언트에게 반환한다.
- 클라이언트는 응답을 받은 후에야 다음 작업을 진행한다.
이 과정은 HTTP 요청과 같은 표준 웹 통신에서 흔히 볼 수 있다. REST API는 대부분 이러한 동기식 패턴을 따른다.
동기식 API 구현 예시#
다음은 Java Spring Boot를 사용한 동기식 API 엔드포인트의 예시이다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| @RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
// 사용자 조회 (이 작업이 완료될 때까지 클라이언트는 대기)
User user = userService.findById(id);
if (user != null) {
return ResponseEntity.ok(user);
} else {
return ResponseEntity.notFound().build();
}
}
}
|
이 예시에서 클라이언트는 사용자 정보를 요청하고, 서버가 데이터베이스에서 정보를 조회하고 반환할 때까지 대기한다.
동기식 API의 장점#
- 단순성: 구현과 이해가 쉽다. 코드 흐름이 순차적이기 때문에 디버깅과 추론이 직관적이다.
- 즉각적인 응답: 요청이 성공했는지 실패했는지 즉시 알 수 있다.
- 일관성: 데이터의 최신 상태를 반환받을 수 있어 데이터 일관성이 중요한 경우에 적합하다.
- 트랜잭션 보장: 단일 요청-응답 사이클 내에서 트랜잭션 처리가 용이하다.
- 오류 처리 단순화: 오류가 발생하면 즉시 클라이언트에게 전달되어 처리할 수 있다.
동기식 API의 단점#
- 자원 차단: 클라이언트는 응답을 기다리는 동안 다른 작업을 수행할 수 없어 자원 활용이 비효율적일 수 있다.
- 확장성 제한: 대량의 동시 요청 처리 시 서버 자원이 빠르게 소진될 수 있다.
- 장기 실행 작업에 부적합: 완료하는 데 시간이 오래 걸리는 프로세스의 경우 클라이언트 타임아웃이 발생할 수 있다.
- 시스템 의존성: 서비스 간 동기식 호출은 한 서비스의 장애가 연쇄적으로 다른 서비스에 영향을 미칠 수 있다.
- 네트워크 지연 영향: 네트워크 지연이 직접적으로 사용자 경험에 영향을 미친다.
동기식 API의 적합한, 부적합한 사용 사례#
적합한 사용 사례:
- 즉각적인 응답이 필요한 상호작용 (로그인, 검색)
- 간단하고 빠른 데이터 조회 및 조작
- 트랜잭션 일관성이 중요한 작업 (결제 처리)
- 사용자 인터페이스와 직접 상호작용하는 API
부적합한 사용 사례:
- 대용량 파일 업로드 또는 다운로드
- 시간이 많이 소요되는 계산이나 데이터 처리
- 여러 외부 서비스에 의존하는 복잡한 워크플로우
- 높은 처리량이 필요한 서비스
비동기식 API(Asynchronous API)#
비동기식 API는 클라이언트가 요청을 보낸 후 서버의 응답을 기다리지 않고 다른 작업을 수행할 수 있는 방식이다. 서버는 요청을 받아 처리하고, 처리가 완료되면 다양한 방법으로 결과를 클라이언트에게 전달한다.
비동기식 API의 작동 원리#
비동기식 API의 일반적인 흐름은 다음과 같다:
- 클라이언트가 API 요청을 전송한다.
- 서버는 요청 접수를 확인하는 응답을 즉시 반환한다 (보통 요청 ID 포함).
- 클라이언트는 다른 작업을 계속 수행한다.
- 서버는 백그라운드에서 요청을 처리한다.
- 처리가 완료되면 다음 중 한 가지 방법으로 결과가 전달된다:
- 클라이언트가 결과를 폴링(polling)하여 확인
- 서버가 웹훅(webhook)을 통해 클라이언트에게 알림
- 웹소켓(WebSocket)이나 서버-전송 이벤트(SSE)를 통한 실시간 알림
비동기식 API 구현 패턴#
비동기식 API를 구현하는 주요 패턴은 다음과 같다:
폴링(Polling) 패턴#
클라이언트가 주기적으로 서버에 상태 확인 요청을 보내는 방식.
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
| // 작업 제출 엔드포인트
@PostMapping("/api/jobs")
public ResponseEntity<JobResponse> submitJob(@RequestBody JobRequest request) {
// 작업을 큐에 추가하고 작업 ID 반환
String jobId = jobService.enqueueJob(request);
return ResponseEntity
.accepted()
.body(new JobResponse(jobId, "PENDING"));
}
// 작업 상태 확인 엔드포인트 (폴링용)
@GetMapping("/api/jobs/{jobId}")
public ResponseEntity<JobResponse> getJobStatus(@PathVariable String jobId) {
Job job = jobService.getJob(jobId);
if (job == null) {
return ResponseEntity.notFound().build();
}
JobResponse response = new JobResponse(
job.getId(),
job.getStatus().toString()
);
// 작업이 완료된 경우 결과 포함
if (job.getStatus() == JobStatus.COMPLETED) {
response.setResult(job.getResult());
}
return ResponseEntity.ok(response);
}
|
콜백(Callback) / 웹훅(Webhook) 패턴#
서버가 처리를 완료한 후 클라이언트가 제공한 URL로 결과를 전송하는 방식.
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
| @PostMapping("/api/reports/generate")
public ResponseEntity<ReportResponse> generateReport(
@RequestBody ReportRequest request,
@RequestParam String callbackUrl) {
// 보고서 생성 작업 ID 생성
String reportId = UUID.randomUUID().toString();
// 비동기적으로 보고서 생성 작업 시작 (별도 스레드/작업자)
reportService.generateReportAsync(reportId, request, callbackUrl);
return ResponseEntity
.accepted()
.body(new ReportResponse(reportId, "Report generation started"));
}
// 서비스 내부에서 콜백 실행
public void notifyReportCompletion(String reportId, String callbackUrl, Report report) {
ReportResult result = new ReportResult(reportId, "COMPLETED", report.getUrl());
try {
// 콜백 URL로 결과 전송
restTemplate.postForEntity(callbackUrl, result, Void.class);
} catch (Exception e) {
log.error("Failed to send callback for report {}: {}", reportId, e.getMessage());
// 재시도 로직이나 대체 알림 메커니즘 구현 가능
}
}
|
이벤트 스트리밍 패턴#
서버-전송 이벤트(SSE)나 웹소켓(WebSocket)을 사용하여 실시간으로 처리 상태를 전달하는 방식.
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
| // SSE를 사용한 이벤트 스트리밍 예시
@GetMapping(value = "/api/jobs/{jobId}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamJobEvents(@PathVariable String jobId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
// 이미터를 작업 ID에 연결
jobEventService.addEmitter(jobId, emitter);
// 연결 종료 시 정리
emitter.onCompletion(() -> jobEventService.removeEmitter(jobId, emitter));
emitter.onTimeout(() -> jobEventService.removeEmitter(jobId, emitter));
// 초기 연결 확인 이벤트 전송
try {
emitter.send(SseEmitter.event()
.name("CONNECTED")
.data(Map.of("jobId", jobId, "message", "Connected to job events"))
);
} catch (IOException e) {
emitter.completeWithError(e);
}
return emitter;
}
// 작업 처리 과정에서 이벤트 전송
public void sendJobEvent(String jobId, String eventType, Object data) {
List<SseEmitter> emitters = jobEventService.getEmitters(jobId);
if (emitters.isEmpty()) {
return;
}
List<SseEmitter> deadEmitters = new ArrayList<>();
for (SseEmitter emitter : emitters) {
try {
emitter.send(SseEmitter.event()
.name(eventType)
.data(data)
);
} catch (Exception e) {
deadEmitters.add(emitter);
}
}
// 실패한 이미터 제거
deadEmitters.forEach(emitter -> jobEventService.removeEmitter(jobId, emitter));
}
|
메시지 큐 기반 패턴#
메시지 큐(예: RabbitMQ, Kafka)를 사용하여 비동기 작업을 처리하는 방식.
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
| @PostMapping("/api/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
// 주문 ID 생성
String orderId = UUID.randomUUID().toString();
// 초기 주문 정보 저장
Order order = orderRepository.save(new Order(orderId, request, OrderStatus.PROCESSING));
// 메시지 큐로 주문 처리 작업 전송
orderMessageProducer.sendOrderProcessingMessage(orderId, request);
// 즉시 응답 반환
return ResponseEntity
.accepted()
.body(new OrderResponse(orderId, OrderStatus.PROCESSING));
}
// 메시지 소비자 (별도 서비스/컴포넌트)
@Component
public class OrderProcessingConsumer {
@RabbitListener(queues = "order-processing-queue")
public void processOrder(OrderProcessingMessage message) {
try {
// 주문 처리 로직
Order order = orderService.processOrder(message.getOrderId(), message.getOrderRequest());
// 주문 상태 업데이트
orderRepository.updateStatus(order.getId(), OrderStatus.COMPLETED);
// 완료 메시지 발행 (알림용)
orderCompletionProducer.sendOrderCompletedMessage(order);
} catch (Exception e) {
log.error("Order processing failed for order {}: {}", message.getOrderId(), e.getMessage());
orderRepository.updateStatus(message.getOrderId(), OrderStatus.FAILED);
}
}
}
|
비동기식 API의 장점#
- 향상된 응답성: 클라이언트는 작업 완료를 기다릴 필요 없이 다른 작업을 수행할 수 있다.
- 확장성 개선: 서버는 요청을 큐에 넣고 가용 자원에 따라 처리할 수 있어 부하 관리가 용이하다.
- 장기 실행 작업 지원: 시간이 많이 걸리는 작업도 타임아웃 걱정 없이 처리할 수 있다.
- 시스템 복원력: 서비스 간 느슨한 결합으로 한 서비스의 장애가 전체 시스템에 미치는 영향이 제한적이다.
- 효율적인 자원 활용: 서버와 클라이언트 모두 대기 시간 없이 자원을 효율적으로 활용할 수 있다.
비동기식 API의 단점#
- 복잡성 증가: 구현과 테스트가 더 복잡해진다. 오류 처리, 재시도 로직, 작업 상태 추적 등이 필요하다.
- 즉각적인 응답 부재: 최종 결과를 얻기까지 지연이 발생할 수 있다.
- 상태 관리 필요: 작업 상태와 결과를 저장하고 추적하는 추가 인프라가 필요하다.
- 이벤트 순서 보장의 어려움: 메시지 전달 순서가 처리 순서를 보장하지 않을 수 있다.
- 디버깅 복잡성: 분산된 비동기 처리로 인해 문제 추적이 어려울 수 있다.
비동기식 API의 적합한, 부적합한 사용 사례#
적합한 사용 사례:
- 대용량 파일 처리 (업로드/다운로드, 변환)
- 복잡한 계산이나 분석 작업
- 이메일 전송, 알림 발송과 같은 백그라운드 작업
- 외부 서비스 통합이 필요한 복잡한 워크플로우
- 높은 처리량이 필요한 시스템
부적합한 사용 사례:
- 즉각적인 사용자 피드백이 필요한 상호작용
- 단순하고 빠른 데이터 조회
- 강력한 트랜잭션 일관성이 필요한 작업
- 실시간 응답이 중요한 사용자 인터페이스 요소
혼합 접근법: 동기식과 비동기식 API의 결합#
많은 현대적인 애플리케이션은 동기식과 비동기식 API를 모두 활용하는 혼합 접근법을 채택한다. 이는 각 패턴의 장점을 최대화하고 단점을 최소화하는 데 도움이 된다.
혼합 접근법 구현 전략#
비동기 작업 시작을 위한 동기식 API#
작업 제출은 동기식으로 처리하되, 실제 처리는 비동기식으로 진행하는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| @PostMapping("/api/documents/convert")
public ResponseEntity<ConversionResponse> convertDocument(
@RequestParam("file") MultipartFile file,
@RequestParam("targetFormat") String targetFormat) {
// 파일 저장 및 작업 메타데이터 생성 (동기식)
String documentId = documentService.saveDocument(file);
String conversionId = conversionService.createConversionJob(documentId, targetFormat);
// 비동기 변환 작업 시작
conversionService.startAsyncConversion(conversionId);
// 작업 상태 확인 URL 및 작업 ID 반환 (동기식 응답)
return ResponseEntity
.accepted()
.body(new ConversionResponse(
conversionId,
"/api/documents/conversions/" + conversionId,
"PROCESSING"
));
}
|
장기 폴링(Long Polling) 접근법#
클라이언트의 요청을 일정 시간 동안 유지하다가 결과가 준비되면 즉시 반환하거나, 타임아웃에 도달하면 현재 상태를 반환하는 방식.
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
| @GetMapping("/api/jobs/{jobId}/longpoll")
public ResponseEntity<JobResult> longPollJobResult(
@PathVariable String jobId,
@RequestParam(defaultValue = "30") int timeoutSeconds) {
// 현재 작업 상태 확인
JobStatus currentStatus = jobService.getJobStatus(jobId);
// 이미 완료된 경우 즉시 결과 반환
if (currentStatus == JobStatus.COMPLETED || currentStatus == JobStatus.FAILED) {
return ResponseEntity.ok(jobService.getJobResult(jobId));
}
// 결과를 기다리는 CountDownLatch 생성
CountDownLatch latch = new CountDownLatch(1);
JobResultListener listener = new JobResultListener(jobId, latch);
// 리스너 등록
jobService.addJobListener(jobId, listener);
try {
// 결과를 기다리거나 타임아웃까지 대기
boolean completed = latch.await(timeoutSeconds, TimeUnit.SECONDS);
// 리스너 제거
jobService.removeJobListener(jobId, listener);
if (completed) {
// 완료된 경우 결과 반환
return ResponseEntity.ok(jobService.getJobResult(jobId));
} else {
// 타임아웃된 경우 현재 상태 반환
return ResponseEntity.ok(new JobResult(jobId, jobService.getJobStatus(jobId), null));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
jobService.removeJobListener(jobId, listener);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
|
복합 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
| // 동기식 처리 (간단한 요청용)
@PostMapping("/api/payments/sync")
public ResponseEntity<PaymentResult> processPaymentSync(@RequestBody PaymentRequest request) {
// 동기적으로 결제 처리 (타임아웃 제한 있음)
PaymentResult result = paymentService.processPaymentSync(request);
return ResponseEntity.ok(result);
}
// 비동기식 처리 (복잡한 처리용)
@PostMapping("/api/payments/async")
public ResponseEntity<PaymentResponse> processPaymentAsync(
@RequestBody PaymentRequest request,
@RequestParam(required = false) String callbackUrl) {
// 결제 작업 생성
String paymentId = paymentService.initiatePayment(request);
// 비동기 처리 시작
paymentService.processPaymentAsync(paymentId, callbackUrl);
// 즉시 응답
return ResponseEntity
.accepted()
.body(new PaymentResponse(
paymentId,
"PROCESSING",
"/api/payments/" + paymentId
));
}
|
혼합 접근법의 장점#
- 유연성: 작업의 특성에 따라 가장 적합한 패턴을 선택할 수 있다.
- 점진적 도입: 기존 동기식 API를 유지하면서 필요한 부분만 비동기식으로 변환할 수 있다.
- 사용자 경험 최적화: 즉각적인 피드백과 장기 실행 작업의 효율적인 처리를 모두 제공할 수 있다.
- 리소스 최적화: 작업의 복잡성에 따라 서버 리소스를 효율적으로 활용할 수 있다.
API 패턴 선택 가이드라인#
적절한 API 패턴을 선택하기 위한 주요 고려사항:
동기식 API를 선택해야 하는 경우
- 응답 시간이 짧은 경우: 요청을 빠르게 처리할 수 있는 경우 (일반적으로 1초 미만)
- 즉각적인 응답이 필수적인 경우: 사용자가 즉시 결과를 보고 다음 작업을 결정해야 하는 경우
- 강력한 일관성이 필요한 경우: 데이터 읽기/쓰기 후 즉시 확인이 필요한 경우
- 단순한 요청-응답 흐름: 복잡한 워크플로우가 없는 간단한 CRUD 작업
비동기식 API를 선택해야 하는 경우
- 장시간 실행 작업: 처리하는 데 수 초 이상 걸리는 작업
- 대용량 데이터 처리: 대규모 파일 업로드/다운로드나 데이터 처리
- 외부 시스템 의존성: 여러 외부 서비스와의 통합이 필요한 경우
- 부하 분산이 중요한 경우: 대량의 요청을 효율적으로 처리해야 하는 경우
- 클라이언트 연결 제한: 모바일 장치와 같이 연결 유지가 비용이 많이 드는 환경
결정 프레임워크
API 패턴 선택을 위한 단계별 접근법:
- 작업 특성 분석:
- 예상 처리 시간은 얼마인가?
- 작업의 복잡성은 어느 정도인가?
- 외부 시스템 의존성이 있는가?
- 사용자 경험 요구사항 고려:
- 사용자가 즉각적인 응답을 기대하는가?
- 진행 상황 알림이 필요한가?
- 클라이언트의 특성은 어떠한가? (모바일, 웹, 서버-서버 등)
- 시스템 부하 및 확장성 요구사항 평가:
- 예상되는 동시 요청 수는 얼마인가?
- 시스템의 확장 전략은 무엇인가?
- 자원 제약 사항이 있는가?
- 오류 처리 및 복원력 요구사항 분석:
- 오류 발생 시 어떻게 처리해야 하는가?
- 재시도 메커니즘이 필요한가?
- 부분 실패는 어떻게 처리해야 하는가?
실제 구현 사례 및 모범 사례#
동기식 API 모범 사례#
- 적절한 타임아웃 설정: 클라이언트와 서버 모두에서 적절한 타임아웃을 설정하여 무한 대기를 방지한다.
- 명확한 오류 처리: 오류가 발생했을 때 클라이언트가 이해하고 대응할 수 있도록 명확한 오류 메시지와 상태 코드를 반환한다.
- 페이지네이션 적용: 대량의 데이터를 반환할 때는 페이지네이션을 사용하여 응답 시간을 최적화한다.
- 캐싱 활용: 자주 요청되는 데이터는 캐싱하여 응답 시간을 개선한다.
- 백그라운드 처리로 전환: 처리 시간이 예상보다 길어질 수 있는 작업은 비동기 처리를 고려한다.
비동기식 API 모범 사례#
- 고유 작업 ID: 각 비동기 작업에 고유 ID를 할당하고 상태 추적에 활용한다.
- 명확한 상태 모델: 작업의 가능한 상태(대기 중, 처리 중, 완료, 실패 등)를 명확히 정의한다.
- 적절한 통지 메커니즘: 사용 사례에 맞는 통지 메커니즘(폴링, 웹훅, SSE 등)을 선택한다.
- 재시도 및 실패 처리: 작업 실패 시 자동 재시도 메커니즘과 최대 재시도 횟수를 구현한다.
- 멱등성 보장: 동일 작업의 중복 제출이나 재시도 시 일관된 결과를 보장한다.
- 작업 만료 정책: 오래된 작업과 결과에 대한 명확한 만료 정책을 수립하여 리소스를 효율적으로 관리한다. 예를 들어, 완료된 작업의 결과는 24시간 후 자동 삭제하는 등의 정책을 적용할 수 있다.
- 진행률 보고: 장시간 실행되는 작업의 경우 진행 상황을 보고하는 메커니즘을 구현하여 사용자 경험을 개선한다. 이는 전체 진행률 백분율이나 단계별 상태 업데이트 형태로 제공될 수 있다.
- 작업 이력 관리: 과거 작업의 기록을 유지하여 감사(audit) 및 문제 해결에 활용한다. 이는 작업 시작 시간, 완료 시간, 상태 변화, 오류 정보 등을 포함할 수 있다.
- 작업 취소 메커니즘: 진행 중인 작업을 취소할 수 있는 API를 제공하여 불필요한 리소스 소비를 방지한다.
- 상태 기반 설계: API 응답은 항상 작업의 현재 상태를 명확하게 반영해야 한다. 이를 통해 클라이언트는 올바른 결정을 내릴 수 있다.
실제 구현 사례#
AWS S3 멀티파트 업로드 (비동기식 API 사례)#
AWS S3 멀티파트 업로드는 대용량 파일을 효율적으로 업로드하기 위한 비동기식 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
| // JavaScript를 사용한 AWS S3 멀티파트 업로드 예제
async function initiateMultipartUpload(s3Client, bucket, key) {
const command = new CreateMultipartUploadCommand({
Bucket: bucket,
Key: key,
});
// 업로드 초기화 (동기식 부분)
const { UploadId } = await s3Client.send(command);
console.log(`Multipart upload initiated with UploadId: ${UploadId}`);
return UploadId;
}
async function uploadPart(s3Client, bucket, key, uploadId, partNumber, body) {
const command = new UploadPartCommand({
Bucket: bucket,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
Body: body,
});
// 파트 업로드 (각 파트는 비동기식으로 처리)
const { ETag } = await s3Client.send(command);
console.log(`Part ${partNumber} uploaded, ETag: ${ETag}`);
return { PartNumber: partNumber, ETag };
}
async function completeMultipartUpload(s3Client, bucket, key, uploadId, parts) {
const command = new CompleteMultipartUploadCommand({
Bucket: bucket,
Key: key,
UploadId: uploadId,
MultipartUpload: { Parts: parts },
});
// 업로드 완료 (동기식 부분)
const result = await s3Client.send(command);
console.log(`Upload completed, Location: ${result.Location}`);
return result;
}
// 대용량 파일을 청크로 나누어 병렬 업로드
async function uploadLargeFile(s3Client, bucket, key, fileStream, chunkSize) {
// 1. 멀티파트 업로드 초기화
const uploadId = await initiateMultipartUpload(s3Client, bucket, key);
// 2. 파일을 청크로 분할하고 각 부분 비동기적으로 업로드
const partPromises = [];
let partNumber = 1;
// 파일 청크 처리 로직 (스트림 처리)
for await (const chunk of createChunkStream(fileStream, chunkSize)) {
partPromises.push(
uploadPart(s3Client, bucket, key, uploadId, partNumber++, chunk)
);
}
// 모든 파트 업로드 완료 대기
const uploadedParts = await Promise.all(partPromises);
// 3. 멀티파트 업로드 완료
return completeMultipartUpload(s3Client, bucket, key, uploadId, uploadedParts);
}
|
이 사례에서 주목할 점:
- 초기화와 완료는 동기식으로 처리
- 파일 청크 업로드는 비동기식으로 병렬 처리
- 고유 UploadId로 전체 프로세스 추적
- 부분 업로드에 대한 명확한 상태 추적
결제 처리 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
| @RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {
private final PaymentService paymentService;
private final PaymentRepository paymentRepository;
private final WebhookService webhookService;
// 결제 인증 및 초기화 (동기식)
@PostMapping("/authorize")
public ResponseEntity<PaymentAuthorizationResponse> authorizePayment(
@RequestBody PaymentAuthorizationRequest request) {
// 기본 유효성 검사 및 결제 인증 (빠르게 처리되어야 함)
PaymentAuthorization auth = paymentService.authorizePayment(request);
return ResponseEntity.ok(new PaymentAuthorizationResponse(
auth.getAuthorizationId(),
auth.getStatus(),
auth.getRedirectUrl() // 3D Secure 등에 필요할 경우
));
}
// 결제 처리 (비동기식)
@PostMapping("/process")
public ResponseEntity<PaymentResponse> processPayment(
@RequestBody PaymentProcessRequest request,
@RequestParam(required = false) String webhookUrl) {
// 결제 검증 (동기식)
if (!paymentService.validatePaymentRequest(request)) {
return ResponseEntity.badRequest().body(
new PaymentResponse(null, "INVALID", "Invalid payment request")
);
}
// 결제 ID 생성 및 초기 상태 저장
String paymentId = UUID.randomUUID().toString();
Payment payment = new Payment(
paymentId,
request.getAuthorizationId(),
request.getAmount(),
request.getCurrency(),
PaymentStatus.PROCESSING,
webhookUrl
);
paymentRepository.save(payment);
// 비동기 결제 처리 시작
paymentService.processPaymentAsync(paymentId);
// 즉시 응답
return ResponseEntity
.accepted()
.body(new PaymentResponse(
paymentId,
"PROCESSING",
"Payment is being processed"
));
}
// 결제 상태 조회 (동기식)
@GetMapping("/{paymentId}")
public ResponseEntity<PaymentStatusResponse> getPaymentStatus(
@PathVariable String paymentId) {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new ResourceNotFoundException("Payment not found"));
return ResponseEntity.ok(new PaymentStatusResponse(
payment.getId(),
payment.getStatus().toString(),
payment.getStatusMessage(),
payment.getCompletedAt(),
payment.getTransactionReference()
));
}
}
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final PaymentGateway paymentGateway;
private final WebhookService webhookService;
private final TaskExecutor taskExecutor;
// 비동기 결제 처리
public void processPaymentAsync(String paymentId) {
taskExecutor.execute(() -> {
try {
Payment payment = paymentRepository.findById(paymentId).orElse(null);
if (payment == null) return;
// 결제 게이트웨이 호출
PaymentResult result = paymentGateway.processPayment(
payment.getAuthorizationId(),
payment.getAmount(),
payment.getCurrency()
);
// 결과 저장
payment.setStatus(result.isSuccessful() ?
PaymentStatus.COMPLETED : PaymentStatus.FAILED);
payment.setStatusMessage(result.getMessage());
payment.setTransactionReference(result.getTransactionId());
payment.setCompletedAt(LocalDateTime.now());
paymentRepository.save(payment);
// 웹훅 알림 전송 (있는 경우)
if (payment.getWebhookUrl() != null) {
webhookService.sendPaymentWebhook(payment);
}
} catch (Exception e) {
// 오류 처리 및 저장
Payment payment = paymentRepository.findById(paymentId).orElse(null);
if (payment != null) {
payment.setStatus(PaymentStatus.ERROR);
payment.setStatusMessage("Processing error: " + e.getMessage());
paymentRepository.save(payment);
}
// 로깅
log.error("Payment processing failed: {}", e.getMessage(), e);
}
});
}
// 다른 메서드들…
}
|
이 사례에서 주목할 점:
- 결제 인증은 즉각적인 응답이 필요하므로 동기식으로 처리
- 실제 결제 처리는 시간이 걸릴 수 있으므로 비동기식으로 처리
- 결제 상태 조회는 동기식 API로 제공
- 웹훅을 통한 결제 완료 알림 제공
고급 패턴 및 기술#
반응형 프로그래밍(Reactive Programming)#
반응형 프로그래밍은 비동기 데이터 스트림을 기반으로 하는 프로그래밍 패러다임으로, 비동기 API 구현에 강력한 접근 방식을 제공한다.
주요 특징:
- 비차단(Non-blocking) I/O 사용
- 데이터 스트림 기반 처리
- 백프레셔(Backpressure) 지원으로 부하 관리
- 선언적인 데이터 흐름 처리
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
| // Spring WebFlux를 사용한 반응형 API 예시
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
private final ProductRepository productRepository;
@GetMapping
public Flux<Product> getAllProducts() {
return productRepository.findAll();
}
@GetMapping("/{id}")
public Mono<ResponseEntity<Product>> getProductById(@PathVariable String id) {
return productRepository.findById(id)
.map(product -> ResponseEntity.ok(product))
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@PostMapping
public Mono<ResponseEntity<Product>> createProduct(@RequestBody Mono<Product> productMono) {
return productMono
.flatMap(productRepository::save)
.map(savedProduct ->
ResponseEntity
.created(URI.create("/api/v1/products/" + savedProduct.getId()))
.body(savedProduct)
);
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ProductEvent> streamProductEvents() {
return Flux.interval(Duration.ofSeconds(1))
.map(sequence -> new ProductEvent(sequence, "Product Event"));
}
}
|
GraphQL을 활용한 유연한 API 설계#
GraphQL은 클라이언트가 필요한 데이터를 정확히 지정할 수 있게 해주는 쿼리 언어로, 동기식과 비동기식 처리를 모두 지원한다.
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
| // GraphQL 스키마 정의 예시
type Query {
product(id: ID!): Product
products(category: String, limit: Int): [Product]
}
type Mutation {
createProduct(input: ProductInput!): Product
updateProduct(id: ID!, input: ProductInput!): Product
deleteProduct(id: ID!): Boolean
}
type Subscription {
productUpdated: Product
newProductInCategory(category: String!): Product
}
type Product {
id: ID!
name: String!
description: String
price: Float!
category: String
inStock: Boolean
}
input ProductInput {
name: String!
description: String
price: Float!
category: String
}
|
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
| // Spring GraphQL을 사용한 리졸버 구현 예시
@Component
public class ProductResolver {
private final ProductService productService;
private final ReactiveStreamsPublisher<Product> productPublisher;
// 쿼리 리졸버 (동기식)
@QueryMapping
public Product product(@Argument String id) {
return productService.getProductById(id);
}
@QueryMapping
public List<Product> products(
@Argument String category,
@Argument Integer limit) {
return productService.findProducts(category, limit);
}
// 뮤테이션 리졸버 (비동기식으로도 구현 가능)
@MutationMapping
public Mono<Product> createProduct(@Argument ProductInput input) {
return productService.createProductAsync(input);
}
// 구독 리졸버 (비동기식)
@SubscriptionMapping
public Publisher<Product> productUpdated() {
return productPublisher;
}
@SubscriptionMapping
public Publisher<Product> newProductInCategory(@Argument String category) {
return productPublisher
.filter(product -> category.equals(product.getCategory()));
}
}
|
서버리스 아키텍처와 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
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
| // AWS Lambda와 API Gateway를 사용한 서버리스 비동기 API 예시
// Lambda 함수 (API 엔드포인트)
exports.initiateProcessing = async (event) => {
const requestBody = JSON.parse(event.body);
const jobId = uuidv4();
// DynamoDB에 작업 메타데이터 저장
await dynamoDb.put({
TableName: 'Jobs',
Item: {
jobId,
status: 'PROCESSING',
input: requestBody,
createdAt: new Date().toISOString()
}
}).promise();
// SQS에 처리 작업 메시지 전송
await sqs.sendMessage({
QueueUrl: process.env.PROCESSING_QUEUE_URL,
MessageBody: JSON.stringify({
jobId,
payload: requestBody
})
}).promise();
// 즉시 응답
return {
statusCode: 202,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jobId,
status: 'PROCESSING',
statusUrl: `/jobs/${jobId}`
})
};
};
// 작업 처리 Lambda 함수 (SQS 트리거)
exports.processJob = async (event) => {
for (const record of event.Records) {
const { jobId, payload } = JSON.parse(record.body);
try {
// 실제 처리 로직
const result = await processData(payload);
// 처리 결과 저장
await dynamoDb.update({
TableName: 'Jobs',
Key: { jobId },
UpdateExpression: 'SET #status = :status, result = :result, completedAt = :completedAt',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: {
':status': 'COMPLETED',
':result': result,
':completedAt': new Date().toISOString()
}
}).promise();
// 선택적: SNS 토픽으로 완료 알림 전송
await sns.publish({
TopicArn: process.env.JOB_COMPLETION_TOPIC,
Message: JSON.stringify({
jobId,
status: 'COMPLETED',
completedAt: new Date().toISOString()
})
}).promise();
} catch (error) {
// 오류 처리 및 저장
await dynamoDb.update({
TableName: 'Jobs',
Key: { jobId },
UpdateExpression: 'SET #status = :status, errorMessage = :error, updatedAt = :updatedAt',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: {
':status': 'FAILED',
':error': error.message,
':updatedAt': new Date().toISOString()
}
}).promise();
}
}
};
// 작업 상태 확인 Lambda 함수
exports.getJobStatus = async (event) => {
const jobId = event.pathParameters.jobId;
const result = await dynamoDb.get({
TableName: 'Jobs',
Key: { jobId }
}).promise();
if (!result.Item) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'Job not found' })
};
}
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result.Item)
};
};
|
이벤트 기반 아키텍처(Event-Driven Architecture)#
이벤트 기반 아키텍처는 비동기 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
| // Spring Cloud Stream을 사용한 이벤트 기반 마이크로서비스 예시
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
private final StreamBridge streamBridge;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
// 주문 생성
Order order = orderService.createOrder(request);
// 주문 생성 이벤트 발행
OrderCreatedEvent event = new OrderCreatedEvent(
order.getId(),
order.getCustomerId(),
order.getItems(),
order.getTotalAmount(),
LocalDateTime.now()
);
streamBridge.send("orderCreatedChannel", event);
// 응답 반환
return ResponseEntity
.created(URI.create("/api/v1/orders/" + order.getId()))
.body(new OrderResponse(order.getId(), "CREATED"));
}
}
// 이벤트 소비자 서비스
@Configuration
public class OrderEventConsumer {
private final InventoryService inventoryService;
@Bean
public Consumer<OrderCreatedEvent> processOrderCreated() {
return event -> {
log.info("Received order created event: {}", event.getOrderId());
// 재고 확인 및 할당
try {
inventoryService.allocateInventory(event.getOrderId(), event.getItems());
log.info("Inventory allocated for order: {}", event.getOrderId());
} catch (Exception e) {
log.error("Failed to allocate inventory for order {}: {}",
event.getOrderId(), e.getMessage());
// 보상 트랜잭션 또는 오류 처리 로직
}
};
}
}
|
최적화 및 모니터링#
API 패턴 선택과 더불어 성능 최적화와 모니터링은 효과적인 API 구현을 위해 중요하다.
최적화 전략#
- 커넥션 풀링: 데이터베이스 및 서비스 간 연결 풀을 효율적으로 관리한다.
- 비동기 I/O: 네트워크 및 디스크 I/O 작업에 비동기 패턴을 적용하여 자원 활용도를 높인다.
- 캐싱 전략: 적절한 캐싱을 통해 반복적인 계산이나 요청을 줄인다.
- 배치 처리: 여러 요청을 그룹화하여 처리함으로써 오버헤드를 줄인다.
- 적절한 타임아웃 설정: 리소스가 불필요하게 점유되는 것을 방지한다.
API 모니터링 및 관측성#
- 핵심 지표 추적:
- 응답 시간 (평균, 95번째 백분위수, 99번째 백분위수)
- 요청 처리량(throughput)
- 오류율
- 활성 요청 수
- 자원 활용률 (CPU, 메모리, 네트워크)
- 분산 추적: 마이크로서비스 환경에서 요청 흐름을 추적하여 병목 현상 식별
- 로깅 전략: 구조화된 로깅으로 문제 해결 및 분석 용이
- 알림 설정: 성능 문제나 오류 발생 시 신속하게 대응할 수 있도록 알림 설정
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
| // Micrometer를 사용한 API 성능 지표 측정 예시
@Component
public class ApiMetrics {
private final MeterRegistry meterRegistry;
public ApiMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping) || " +
"@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
"@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
public Object measureApiPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getSignature().getDeclaringTypeName();
String endpointTag = className + "." + methodName;
Timer.Sample sample = Timer.start(meterRegistry);
boolean success = false;
try {
Object result = joinPoint.proceed();
success = true;
return result;
} finally {
Timer timer = Timer.builder("api.request.duration")
.tag("endpoint", endpointTag)
.tag("success", String.valueOf(success))
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
sample.stop(timer);
Counter.builder("api.request.count")
.tag("endpoint", endpointTag)
.tag("success", String.valueOf(success))
.register(meterRegistry)
.increment();
}
}
}
|
Synchronous vs. Asynchronous APIs 비교#
- 동기식 API는 단순성과 즉각적인 응답이 중요한 경우에 적합하지만, 긴 처리 시간과 높은 부하에 취약할 수 있다.
- 비동기식 API는 장시간 실행 작업과 높은 처리량을 위해 설계되었지만, 구현과 테스트가 더 복잡할 수 있다.
- 혼합 접근법은 많은 실제 애플리케이션에서 가장 효과적인 솔루션을 제공하며, 각 작업의 특성에 따라 적절한 패턴을 적용할 수 있다.
- 적절한 API 패턴 선택은 작업 특성, 사용자 경험 요구사항, 시스템 부하 및 확장성 요구사항을 고려해야 한다.
- 성능 최적화와 지속적인 모니터링은 어떤 패턴을 선택하든 필수적이다.
카테고리 | 동기(Synchronous) | 비동기(Asynchronous) |
---|
기본 개념 | - 작업이 순차적으로 실행됨 | - 작업이 독립적으로 실행됨 |
| - 이전 작업이 완료될 때까지 다음 작업 대기 | - 작업의 완료를 기다리지 않고 다음 작업 진행 |
| - 실행 순서가 보장됨 | - 실행 순서가 보장되지 않음 |
처리 방식 | - 단일 스레드에서 순차적 처리 | - 멀티 스레드 또는 이벤트 루프 기반 처리 |
| - 작업 완료까지 대기 | - 작업 완료 시 콜백/Promise/async-await 등으로 처리 |
| - 직관적인 코드 흐름 | - 비선형적 코드 흐름 |
장점 | - 코드의 가독성이 좋음 | - 시스템 자원의 효율적 사용 |
| - 디버깅이 용이함 | - 더 나은 사용자 경험 제공 |
| - 에러 처리가 간단함 | - 높은 처리량(Throughput) |
단점 | - 시스템 자원 비효율적 사용 | - 코드의 복잡성 증가 |
| - 응답 시간이 길어질 수 있음 | - 디버깅이 어려움 |
| - 사용자 경험 저하 가능성 | - 에러 처리가 복잡함 |
적합한 사용 사례 | - 간단한 계산 작업 | - 네트워크 요청 |
| - 메모리 내 데이터 처리 | - 파일 입출력 |
| - 작은 크기의 데이터 처리 | - 대용량 데이터 처리 |
| - 순차적 처리가 필요한 작업 | - 독립적으로 실행 가능한 작업 |
에러 처리 | - try-catch 블록으로 직접 처리 | - Promise의 catch 또는 try-catch와 async-await 사용 |
| - 즉시 에러 감지 및 처리 | - 에러 처리가 비동기적으로 발생 |
| - 스택 트레이스 추적이 용이 | - 에러 발생 지점 추적이 복잡할 수 있음 |
성능 특성 | - CPU 집약적 작업에 유리 | - I/O 집약적 작업에 유리 |
| - 메모리 사용량이 예측 가능 | - 동시 처리로 인한 메모리 사용량 변동 |
| - 단일 작업 처리 시간이 빠름 | - 전체 처리량 최적화에 유리 |
코드 관리 | - 코드 구조가 단순함 | - 상태 관리가 필요함 |
| - 유지보수가 상대적으로 쉬움 | - 비동기 패턴에 대한 이해 필요 |
| - 테스트 작성이 용이함 | - 테스트 시나리오가 복잡할 수 있음 |
리소스 활용 | - 단일 리소스 점유 | - 리소스의 효율적 분배 |
| - 대기 시간 동안 블로킹 | - 대기 시간 동안 다른 작업 수행 |
| - 시스템 부하가 예측 가능 | - 동시성으로 인한 부하 변동 가능 |
참고 및 출처#