Asynchronous APIs

비동기식 API는 현대 소프트웨어 아키텍처에서 중요한 통신 패턴으로, 특히 확장성, 성능, 그리고 복원력이 중요한 시스템에서 핵심적인 역할을 한다.

비동기식 API의 기본 원리

비동기식 API의 핵심은 요청과 응답 사이의 시간적 분리이다. 이 패턴에서는 클라이언트가 요청을 보낸 후 즉각적인 응답을 기다리지 않고, 다른 작업을 계속 진행할 수 있다. 서버는 요청을 처리한 후, 다양한 메커니즘을 통해 결과를 클라이언트에게 전달한다.

동기식 vs. 비동기식 통신 모델

동기식 API에서는 클라이언트가 요청을 보내고 서버의 처리가 완료될 때까지 차단(blocking)되는 반면, 비동기식 API에서는 클라이언트가 요청 후 즉시 제어권을 돌려받아 다른 작업을 수행할 수 있다. 서버는 백그라운드에서 요청을 처리하고, 처리가 완료되면 결과를 알린다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
동기식 통신 흐름:
클라이언트 → 요청 → 서버
클라이언트 (대기…) 서버 (처리 중…)
클라이언트 ← 응답 ← 서버

비동기식 통신 흐름:
클라이언트 → 요청 → 서버
클라이언트 ← 확인 응답 ← 서버
클라이언트 (다른 작업 수행) 서버 (백그라운드 처리…)
클라이언트 ← 결과 알림 ← 서버 (처리 완료)

비동기식 API의 주요 개념

  1. 비차단(Non-blocking) 작업
    비동기식 API의 핵심은 비차단 동작이다. 클라이언트는 서버의 응답을 기다리는 동안 차단되지 않으므로, 시스템 리소스를 효율적으로 사용할 수 있다.

  2. 이벤트 기반 아키텍처
    비동기식 API는 종종 이벤트 기반 아키텍처와 연결된다. 시스템 구성 요소는 이벤트를 생성하고 소비하며, 이벤트 스트림을 통해 통신한다.

  3. 메시지 큐
    메시지 큐는 비동기 통신을 구현하는 핵심 컴포넌트이다. 생산자는 메시지를 큐에 추가하고, 소비자는 자신의 속도에 맞게 메시지를 처리한다.

  4. 콜백 및 프로미스
    프로그래밍 측면에서 비동기 작업은 종종 콜백 함수, 프로미스(Promise), 또는 더 최근에는 async/await 패턴을 통해 처리된다.

비동기식 API의 주요 이점 정리

  1. 확장성 향상: 비동기 처리를 통해 시스템 자원을 효율적으로 사용하고 더 많은 동시 요청을 처리할 수 있다.
  2. 장기 실행 작업 지원: 완료하는 데 시간이 오래 걸리는 작업을 클라이언트 타임아웃 없이 처리할 수 있다.
  3. 시스템 복원력 향상: 서비스 간 느슨한 결합을 통해 일부 시스템 장애가 전체 시스템에 미치는 영향을 최소화한다.
  4. 리소스 활용도 최적화: 클라이언트와 서버 모두에서 리소스를 효율적으로 활용할 수 있다.
  5. 사용자 경험 개선: 사용자가 긴 작업이 완료되기를 기다리지 않고도 다른 작업을 계속할 수 있다.

도전과제 및 고려사항

  1. 복잡성 증가: 비동기 API는 상태 추적, 오류 처리, 재시도 로직 등으로 인해 구현이 더 복잡할 수 있다.
  2. 작업 상태 관리: 장기 실행 작업의 상태를 추적하고 저장하기 위한 추가 인프라가 필요하다.
  3. 모니터링 및 디버깅 어려움: 비동기 작업의 추적과 문제 해결이 더 복잡해질 수 있다.
  4. 클라이언트 복잡성: 클라이언트는 비동기 응답을 처리하기 위한 추가 로직이 필요하다.
  5. 일관성 관리: 분산 시스템에서 데이터 일관성을 유지하는 것이 더 어려워질 수 있다.

비동기식 API 통합 패턴

비동기식 API를 구현하기 위한 다양한 패턴과 접근 방식이 있다. 각 패턴은 특정 사용 사례와 요구사항에 맞게 설계되었다.

폴링(Polling) 패턴

폴링은 가장 단순한 비동기 패턴으로, 클라이언트가 주기적으로 서버에 요청을 보내 처리 상태를 확인한다.

작동 방식
  1. 클라이언트가 작업 요청을 서버에 전송한다.
  2. 서버는 작업 ID와 함께 즉시 응답한다.
  3. 클라이언트는 주기적으로 작업 상태 확인 요청을 보낸다.
  4. 작업이 완료되면 서버는 결과를 포함한 응답을 반환한다.
구현 예시 (Spring Boot)
 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
// 작업 제출 엔드포인트
@PostMapping("/api/jobs")
public ResponseEntity<JobResponse> submitJob(@RequestBody JobRequest request) {
    // 작업 ID 생성 및 백그라운드 작업 시작
    String jobId = jobService.createJob(request);
    
    // 즉시 응답 반환
    return ResponseEntity
        .accepted()
        .body(new JobResponse(jobId, "PENDING", "/api/jobs/" + jobId));
}

// 작업 상태 확인 엔드포인트 (폴링용)
@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(),
        "/api/jobs/" + jobId
    );
    
    // 작업이 완료된 경우 결과 포함
    if (job.getStatus() == JobStatus.COMPLETED) {
        response.setResult(job.getResult());
    }
    
    return ResponseEntity.ok(response);
}
장단점

장점:

단점:

웹훅(Webhook) 패턴

웹훅 패턴은 “역방향 API” 또는 “콜백"이라고도 불리며, 작업이 완료되면 서버가 클라이언트에게 능동적으로 알린다.

작동 방식
  1. 클라이언트가 작업 요청과 함께 콜백 URL을 서버에 제공한다.
  2. 서버는 작업 ID와 함께 즉시 응답한다.
  3. 서버가 백그라운드에서 작업을 처리한다.
  4. 작업이 완료되면 서버는 결과를 포함한 HTTP 요청을 클라이언트의 콜백 URL로 전송한다.
구현 예시 (Spring Boot)
 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
// 작업 제출 엔드포인트 (웹훅 URL 포함)
@PostMapping("/api/async-tasks")
public ResponseEntity<TaskResponse> submitTask(
        @RequestBody TaskRequest request,
        @RequestParam String webhookUrl) {
    
    // 작업 ID 생성 및 요청 정보 저장
    String taskId = UUID.randomUUID().toString();
    asyncTaskService.saveTask(taskId, request, webhookUrl);
    
    // 비동기 작업 실행
    CompletableFuture.runAsync(() -> {
        try {
            // 작업 실행
            TaskResult result = asyncTaskService.executeTask(taskId, request);
            
            // 웹훅 호출
            webhookService.sendWebhook(webhookUrl, taskId, result);
        } catch (Exception e) {
            // 오류 처리 및 웹훅으로 오류 전송
            webhookService.sendErrorWebhook(webhookUrl, taskId, e.getMessage());
        }
    });
    
    // 즉시 응답
    return ResponseEntity
        .accepted()
        .body(new TaskResponse(taskId, "PROCESSING", "/api/async-tasks/" + taskId));
}

// 웹훅 서비스
@Service
public class WebhookService {
    
    private final RestTemplate restTemplate;
    
    public void sendWebhook(String webhookUrl, String taskId, TaskResult result) {
        WebhookPayload payload = new WebhookPayload(
            taskId,
            "COMPLETED",
            result
        );
        
        try {
            restTemplate.postForEntity(webhookUrl, payload, Void.class);
            log.info("Webhook sent for task: {}", taskId);
        } catch (Exception e) {
            log.error("Failed to send webhook for task {}: {}", taskId, e.getMessage());
            // 재시도 로직 구현 가능
        }
    }
    
    public void sendErrorWebhook(String webhookUrl, String taskId, String errorMessage) {
        WebhookPayload payload = new WebhookPayload(
            taskId,
            "FAILED",
            Map.of("error", errorMessage)
        );
        
        try {
            restTemplate.postForEntity(webhookUrl, payload, Void.class);
            log.info("Error webhook sent for task: {}", taskId);
        } catch (Exception e) {
            log.error("Failed to send error webhook for task {}: {}", taskId, e.getMessage());
        }
    }
}
장단점

장점:

단점:

롱 폴링(Long Polling) 패턴

롱 폴링은 일반 폴링과 웹훅의 중간 지점으로, 클라이언트의 요청을 즉시 응답하지 않고 유지하다가 작업이 완료되면 응답하는 방식이다.

작동 방식
  1. 클라이언트가 작업 요청을 서버에 전송한다.
  2. 서버는 작업 ID와 함께 즉시 응답한다.
  3. 클라이언트는 작업 상태 확인 요청을 보낸다.
  4. 서버는 작업이 완료될 때까지 요청을 열어둔다 (타임아웃 한도 내에서).
  5. 작업이 완료되면 서버는 결과와 함께 응답한다. 타임아웃이 발생하면 클라이언트는 새 요청을 보낸다.
구현 예시 (Spring Boot + DeferredResult)
 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
@GetMapping("/api/jobs/{jobId}/longpoll")
public DeferredResult<ResponseEntity<JobResult>> longPollJobResult(
        @PathVariable String jobId,
        @RequestParam(defaultValue = "30000") long timeout) {
    
    // DeferredResult 생성 (타임아웃 설정)
    DeferredResult<ResponseEntity<JobResult>> deferredResult = new DeferredResult<>(timeout);
    
    // 타임아웃 처리
    deferredResult.onTimeout(() -> {
        deferredResult.setResult(
            ResponseEntity.ok(new JobResult(jobId, "PROCESSING", null))
        );
    });
    
    // 현재 작업 상태 확인
    Job job = jobService.getJob(jobId);
    
    if (job == null) {
        deferredResult.setResult(ResponseEntity.notFound().build());
    } else if (job.isCompleted() || job.isFailed()) {
        // 이미 완료된 경우 즉시 결과 반환
        deferredResult.setResult(
            ResponseEntity.ok(new JobResult(jobId, job.getStatus().toString(), job.getResult()))
        );
    } else {
        // 작업이 아직 진행 중이면 완료 리스너 등록
        jobCompletionService.addCompletionListener(jobId, job -> {
            deferredResult.setResult(
                ResponseEntity.ok(new JobResult(jobId, job.getStatus().toString(), job.getResult()))
            );
        });
    }
    
    return deferredResult;
}
장단점

장점:

단점:

서버-전송 이벤트(Server-Sent Events, SSE) 패턴

SSE는 서버가 클라이언트에게 단방향으로 이벤트 스트림을 보낼 수 있게 하는 HTML5 표준이다.

작동 방식
  1. 클라이언트가 SSE 연결을 설정한다.
  2. 서버는 HTTP 연결을 유지하면서 이벤트 스트림을 클라이언트에게 전송한다.
  3. 클라이언트는 이벤트를 수신하여 처리한다.
  4. 연결이 끊어지면 클라이언트가 자동으로 재연결을 시도한다.
구현 예시 (Spring Boot + SSE)
 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
@GetMapping(value = "/api/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribeToEvents() {
    // SSE 이미터 생성 (타임아웃 설정)
    SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
    
    // 클라이언트 등록
    eventService.addClient(emitter);
    
    // 연결 종료 시 정리
    emitter.onCompletion(() -> eventService.removeClient(emitter));
    emitter.onTimeout(() -> eventService.removeClient(emitter));
    emitter.onError(e -> eventService.removeClient(emitter));
    
    // 초기 연결 확인 이벤트 전송
    try {
        emitter.send(SseEmitter.event()
            .name("CONNECTED")
            .data("Connection established")
            .id(UUID.randomUUID().toString())
            .build());
    } catch (IOException e) {
        emitter.completeWithError(e);
    }
    
    return emitter;
}

// 이벤트 발생 시 모든 클라이언트에게 전송
@Service
public class EventService {
    
    private final List<SseEmitter> clients = new CopyOnWriteArrayList<>();
    
    public void addClient(SseEmitter emitter) {
        clients.add(emitter);
    }
    
    public void removeClient(SseEmitter emitter) {
        clients.remove(emitter);
    }
    
    public void broadcastEvent(String eventName, Object data) {
        List<SseEmitter> deadEmitters = new ArrayList<>();
        
        clients.forEach(emitter -> {
            try {
                emitter.send(SseEmitter.event()
                    .name(eventName)
                    .data(data)
                    .id(UUID.randomUUID().toString())
                    .build());
            } catch (Exception e) {
                deadEmitters.add(emitter);
            }
        });
        
        // 실패한 이미터 제거
        clients.removeAll(deadEmitters);
    }
}
장단점

장점:

단점:

웹소켓(WebSocket) 패턴

웹소켓은 클라이언트와 서버 간의 양방향 실시간 통신을 위한 프로토콜이다.

작동 방식
  1. 클라이언트가 HTTP 핸드셰이크를 통해 웹소켓 연결을 설정한다.
  2. 연결이 설정되면 프로토콜이 웹소켓으로 업그레이드된다.
  3. 클라이언트와 서버는 양방향으로 메시지를 주고받을 수 있다.
  4. 연결은 명시적으로 닫힐 때까지 유지된다.
구현 예시 (Spring Boot + 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
50
51
52
53
54
55
56
57
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");  // 구독 경로
        config.setApplicationDestinationPrefixes("/app");  // 메시지 전송 경로
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/websocket")
            .setAllowedOrigins("*")
            .withSockJS();  // 폴백 옵션
    }
}

@Controller
public class TaskWebSocketController {

    @MessageMapping("/tasks/submit")
    @SendTo("/topic/task-updates")
    public TaskResponse submitTask(TaskRequest request) {
        // 작업 ID 생성
        String taskId = UUID.randomUUID().toString();
        
        // 백그라운드 작업 시작
        CompletableFuture.runAsync(() -> {
            try {
                // 작업 실행 중 상태 업데이트
                simpMessagingTemplate.convertAndSend(
                    "/topic/task-updates/" + taskId,
                    new TaskUpdate(taskId, "PROCESSING", "Task in progress…")
                );
                
                // 작업 실행
                TaskResult result = taskService.executeTask(request);
                
                // 완료 알림
                simpMessagingTemplate.convertAndSend(
                    "/topic/task-updates/" + taskId,
                    new TaskUpdate(taskId, "COMPLETED", result)
                );
            } catch (Exception e) {
                // 오류 알림
                simpMessagingTemplate.convertAndSend(
                    "/topic/task-updates/" + taskId,
                    new TaskUpdate(taskId, "FAILED", e.getMessage())
                );
            }
        });
        
        // 초기 응답
        return new TaskResponse(taskId, "SUBMITTED", "/topic/task-updates/" + taskId);
    }
}
장단점

장점:

단점:

메시지 큐 기반 패턴

메시지 큐를 사용한 비동기 통신은 확장 가능하고 복원력이 있는 시스템을 구축하는 데 중요한 패턴이다.

작동 방식
  1. 클라이언트가 API 요청을 서버에 전송한다.
  2. 서버는 작업 ID와 함께 즉시 응답한다.
  3. 서버는 작업을 메시지 큐에 게시한다.
  4. 작업자(worker)가 큐에서 메시지를 소비하여 처리한다.
  5. 작업이 완료되면 결과가 데이터베이스에 저장되거나 다른 큐에 발행된다.
  6. 클라이언트는 폴링, 웹훅 또는 다른 메커니즘을 통해 결과를 검색한다.
구현 예시 (Spring Boot + RabbitMQ)
 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
// API 컨트롤러
@RestController
@RequestMapping("/api/jobs")
public class JobController {

    private final JobService jobService;
    private final RabbitTemplate rabbitTemplate;
    
    @PostMapping
    public ResponseEntity<JobResponse> submitJob(@RequestBody JobRequest request) {
        // 작업 생성 및 초기 상태 저장
        String jobId = UUID.randomUUID().toString();
        jobService.saveJob(new Job(jobId, request, JobStatus.PENDING));
        
        // 메시지 큐로 작업 전송
        rabbitTemplate.convertAndSend(
            "job-exchange",
            "job.submit",
            new JobMessage(jobId, request)
        );
        
        // 즉시 응답
        return ResponseEntity
            .accepted()
            .body(new JobResponse(jobId, "PENDING", "/api/jobs/" + jobId));
    }
    
    @GetMapping("/{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(),
            "/api/jobs/" + jobId
        );
        
        if (job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.FAILED) {
            response.setResult(job.getResult());
        }
        
        return ResponseEntity.ok(response);
    }
}

// 메시지 소비자
@Component
public class JobProcessor {

    private final JobService jobService;
    
    @RabbitListener(queues = "job-queue")
    public void processJob(JobMessage message) {
        String jobId = message.getJobId();
        JobRequest request = message.getRequest();
        
        try {
            // 작업 상태 업데이트
            jobService.updateJobStatus(jobId, JobStatus.PROCESSING);
            
            // 작업 실행
            JobResult result = executeJob(request);
            
            // 성공 결과 저장
            jobService.completeJob(jobId, result);
        } catch (Exception e) {
            // 실패 결과 저장
            jobService.failJob(jobId, e.getMessage());
        }
    }
    
    private JobResult executeJob(JobRequest request) {
        // 실제 작업 처리 로직
        // …
        return new JobResult("Some result data");
    }
}
장단점

장점:

단점:

gRPC 스트리밍 패턴

gRPC는, Google에서 개발한 고성능 RPC(Remote Procedure Call) 프레임워크로, HTTP/2를 기반으로 하며 서버 스트리밍, 클라이언트 스트리밍, 양방향 스트리밍을 지원한다.

작동 방식
  1. gRPC 서비스 정의 파일(.proto)에서 스트리밍 메서드를 정의한다.
  2. 클라이언트가 서버에 요청을 보낸다.
  3. 서버는 스트림을 통해 여러 응답을 클라이언트에게 보낸다.
  4. 스트림이 완료되면 서버가 스트림을 닫는다.
구현 예시 (gRPC 서버 스트리밍)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// task_service.proto
syntax = "proto3";

package task;

service TaskService {
  // 서버 스트리밍 RPC
  rpc SubmitTask(TaskRequest) returns (stream TaskUpdate);
}

message TaskRequest {
  string name = 1;
  map<string, string> parameters = 2;
}

message TaskUpdate {
  string task_id = 1;
  string status = 2;
  float progress = 3;
  string message = 4;
  bytes result = 5;
}
 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
// gRPC 서비스 구현
public class TaskServiceImpl extends TaskServiceGrpc.TaskServiceImplBase {
    
    @Override
    public void submitTask(TaskRequest request, StreamObserver<TaskUpdate> responseObserver) {
        // 작업 ID 생성
        String taskId = UUID.randomUUID().toString();
        
        // 초기 업데이트 전송
        responseObserver.onNext(TaskUpdate.newBuilder()
            .setTaskId(taskId)
            .setStatus("STARTED")
            .setProgress(0.0f)
            .setMessage("Task started")
            .build());
        
        // 백그라운드에서 작업 처리
        executorService.submit(() -> {
            try {
                // 25% 진행 상황 업데이트
                responseObserver.onNext(TaskUpdate.newBuilder()
                    .setTaskId(taskId)
                    .setStatus("PROCESSING")
                    .setProgress(0.25f)
                    .setMessage("Processing started")
                    .build());
                
                Thread.sleep(1000); // 작업 시뮬레이션
                
                // 50% 진행 상황 업데이트
                responseObserver.onNext(TaskUpdate.newBuilder()
                    .setTaskId(taskId)
                    .setStatus("PROCESSING")
                    .setProgress(0.5f)
                    .setMessage("Processing halfway done")
                    .build());
                
                Thread.sleep(1000); // 작업 시뮬레이션
                
                // 75% 진행 상황 업데이트
                responseObserver.onNext(TaskUpdate.newBuilder()
                    .setTaskId(taskId)
                    .setStatus("PROCESSING")
                    .setProgress(0.75f)
                    .setMessage("Processing almost complete")
                    .build());
                
                Thread.sleep(1000); // 작업 시뮬레이션
                
                // 작업 결과 생성
                ByteString result = processTask(request);
                
                // 완료 업데이트 전송
                responseObserver.onNext(TaskUpdate.newBuilder()
                    .setTaskId(taskId)
                    .setStatus("COMPLETED")
                    .setProgress(1.0f)
                    .setMessage("Task completed successfully")
                    .setResult(result)
                    .build());
                
                // 스트림 완료
                responseObserver.onCompleted();
            } catch (Exception e) {
                // 오류 업데이트 전송
                responseObserver.onNext(TaskUpdate.newBuilder()
                    .setTaskId(taskId)
                    .setStatus("FAILED")
                    .setMessage("Error: " + e.getMessage())
                    .build());
                
                // 스트림 완료
                responseObserver.onCompleted();
            }
        });
    }
    
    private ByteString processTask(TaskRequest request) {
        // 실제 작업 처리 로직
        // …
        return ByteString.copyFromUtf8("Task result data");
    }
}
장단점

장점:

단점:

비동기식 API 설계 원칙

효과적인 비동기식 API를 설계하기 위한 핵심 원칙:

작업 식별자와 상태 추적

비동기 작업을 고유하게 식별하고 상태를 추적하는 메커니즘이 필수적이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 작업 모델 예시
public class AsyncJob {
    private final String id;
    private JobStatus status;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private LocalDateTime completedAt;
    private Object result;
    private String errorMessage;
    
    // 작업 상태 열거형
    public enum JobStatus {
        PENDING,      // 대기 중
        PROCESSING,   // 처리 중
        COMPLETED,    // 완료됨
        FAILED,       // 실패함
        CANCELLED     // 취소됨
    }
    
    // 생성자, 게터, 세터 등
}

명확한 진행 상황 보고

장기 실행 작업의 경우 클라이언트에게 진행 상황을 제공하는 것이 중요하다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 진행 상황 업데이트 엔드포인트
@GetMapping("/api/jobs/{jobId}/progress")
public ResponseEntity<JobProgress> getJobProgress(@PathVariable String jobId) {
    Job job = jobService.getJob(jobId);
    
    if (job == null) {
        return ResponseEntity.notFound().build();
    }
    
    JobProgress progress = new JobProgress(
        job.getId(),
        job.getStatus().toString(),
        job.getProgressPercentage(),
        job.getCurrentStep(),
        job.getTotalSteps(),
        job.getEstimatedTimeRemaining()
    );
    
    return ResponseEntity.ok(progress);
}

멱등성(Idempotency) 보장

비동기 작업에서는 중복 요청이나 재시도의 경우를 처리하기 위해 멱등성을 보장하는 것이 중요하다.

 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
// 멱등성 키를 사용한 요청 처리
@PostMapping("/api/payments")
public ResponseEntity<PaymentResponse> processPayment(
        @RequestBody PaymentRequest request,
        @RequestHeader(value = "Idempotency-Key", required = true) String idempotencyKey) {
    
    // 멱등성 키로 기존 요청 확인
    Payment existingPayment = paymentService.findByIdempotencyKey(idempotencyKey);
    
    if (existingPayment != null) {
        // 기존 응답 반환
        return ResponseEntity
            .status(existingPayment.getStatus() == PaymentStatus.COMPLETED ? 
                    HttpStatus.OK : HttpStatus.ACCEPTED)
            .body(mapToResponse(existingPayment));
    }
    
    // 새 요청 처리
    String paymentId = UUID.randomUUID().toString();
    
    Payment payment = new Payment(
        paymentId,
        request,
        PaymentStatus.PENDING,
        idempotencyKey
    );
    paymentService.save(payment);
    
    // 비동기 처리 시작
    paymentProcessor.processAsync(paymentId);
    
    return ResponseEntity
        .accepted()
        .body(new PaymentResponse(paymentId, "PENDING", "/api/payments/" + paymentId));
}

타임아웃 및 만료 정책

비동기 작업에는 명확한 타임아웃과 만료 정책이 필요하다.

 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
// 작업 제한 시간 설정
@PostMapping("/api/data-processing")
public ResponseEntity<JobResponse> startDataProcessing(
        @RequestBody ProcessingRequest request,
        @RequestParam(defaultValue = "3600") int timeoutSeconds) {
    
    // 작업 생성
    String jobId = UUID.randomUUID().toString();
    LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(timeoutSeconds);
    
    Job job = new Job(jobId, request, JobStatus.PENDING, expiresAt);
    jobRepository.save(job);
    
    // 비동기 처리 시작 (타임아웃 포함)
    scheduledExecutorService.schedule(() -> {
        Job currentJob = jobRepository.findById(jobId).orElse(null);
        if (currentJob != null && 
            (currentJob.getStatus() == JobStatus.PENDING || 
             currentJob.getStatus() == JobStatus.PROCESSING)) {
            
            jobRepository.updateStatus(jobId, JobStatus.FAILED, "Operation timed out");
        }
    }, timeoutSeconds, TimeUnit.SECONDS);
    
    dataProcessingService.processAsync(jobId);
    
    return ResponseEntity
        .accepted()
        .body(new JobResponse(jobId, "PENDING", expiresAt));
}

오류 처리 및 재시도 메커니즘

비동기 작업은 다양한 오류 상황을 처리할 수 있는 강력한 메커니즘이 필요하다.

 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
// 재시도 정책이 있는 메시지 소비자
@Component
public class JobConsumer {

    private final JobService jobService;
    
    @RabbitListener(queues = "job-queue")
    @Retryable(
        value = { TemporaryFailureException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public void processJob(JobMessage message) {
        String jobId = message.getJobId();
        
        try {
            // 작업 상태 업데이트
            jobService.updateStatus(jobId, JobStatus.PROCESSING);
            
            // 작업 처리
            JobResult result = executeJob(message.getRequest());
            
            // 성공 상태 업데이트
            jobService.completeJob(jobId, result);
        } catch (TemporaryFailureException e) {
            // 일시적 실패 - 재시도됨
            log.warn("Temporary failure processing job {}, will retry: {}", jobId, e.getMessage());
            throw e;  // 재시도를 위해 예외 다시 throw
        } catch (Exception e) {
            // 영구적 실패
            log.error("Permanent failure processing job {}: {}", jobId, e.getMessage());
            jobService.failJob(jobId, e.getMessage());
        }
    }
    
    @Recover
    public void recover(TemporaryFailureException e, JobMessage message) {
        // 최대 재시도 후에도 실패한 경우 처리
        String jobId = message.getJobId();
        log.error("All retries failed for job {}: {}", jobId, e.getMessage());
        jobService.failJob(jobId, "Failed after multiple retries: " + e.getMessage());
    }
}

작업 취소 기능

장기 실행 작업의 경우 클라이언트가 작업을 취소할 수 있는 메커니즘이 중요하다.

 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
// 작업 취소 엔드포인트
@DeleteMapping("/api/jobs/{jobId}")
public ResponseEntity<JobResponse> cancelJob(@PathVariable String jobId) {
    Job job = jobService.getJob(jobId);
    
    if (job == null) {
        return ResponseEntity.notFound().build();
    }
    
    // 이미 완료되거나 실패한 작업은 취소할 수 없음
    if (job.getStatus() == JobStatus.COMPLETED || 
        job.getStatus() == JobStatus.FAILED ||
        job.getStatus() == JobStatus.CANCELLED) {
        
        return ResponseEntity
            .badRequest()
            .body(new JobResponse(
                job.getId(), 
                job.getStatus().toString(),
                "Cannot cancel job with status: " + job.getStatus()
            ));
    }
    
    // 작업 취소 처리
    jobService.cancelJob(jobId);
    
    return ResponseEntity.ok(new JobResponse(jobId, "CANCELLED", null));
}

비동기식 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
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
// 작업 저장소 인터페이스
public interface JobRepository {
    String createJob(JobRequest request);
    Job findById(String jobId);
    void updateStatus(String jobId, JobStatus status);
    void updateProgress(String jobId, float progress, String message);
    void completeJob(String jobId, Object result);
    void failJob(String jobId, String errorMessage);
    void cancelJob(String jobId);
    List<Job> findExpiredJobs(LocalDateTime expirationTime);
    List<Job> findJobsByStatus(JobStatus status);
}

// Redis를 사용한 작업 저장소 구현
@Repository
public class RedisJobRepository implements JobRepository {
    
    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    
    // Hash 작업을 위한 Redis 키 접두사
    private static final String JOB_KEY_PREFIX = "job:";
    
    @Override
    public String createJob(JobRequest request) {
        String jobId = UUID.randomUUID().toString();
        Job job = new Job(
            jobId,
            request,
            JobStatus.PENDING,
            LocalDateTime.now(),
            null,
            null,
            0.0f,
            null,
            null
        );
        
        saveJob(job);
        return jobId;
    }
    
    @Override
    public Job findById(String jobId) {
        String key = JOB_KEY_PREFIX + jobId;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
        
        if (entries.isEmpty()) {
            return null;
        }
        
        try {
            // Redis 해시에서 Job 객체로 변환
            Job job = new Job();
            job.setId(jobId);
            job.setStatus(JobStatus.valueOf((String) entries.get("status")));
            job.setRequest(objectMapper.readValue(
                (String) entries.get("request"), JobRequest.class));
            
            if (entries.containsKey("progress")) {
                job.setProgress(Float.parseFloat((String) entries.get("progress")));
            }
            
            if (entries.containsKey("message")) {
                job.setMessage((String) entries.get("message"));
            }
            
            if (entries.containsKey("result")) {
                job.setResult(objectMapper.readValue(
                    (String) entries.get("result"), Object.class));
            }
            
            if (entries.containsKey("errorMessage")) {
                job.setErrorMessage((String) entries.get("errorMessage"));
            }
            
            if (entries.containsKey("createdAt")) {
                job.setCreatedAt(LocalDateTime.parse((String) entries.get("createdAt")));
            }
            
            if (entries.containsKey("updatedAt")) {
                job.setUpdatedAt(LocalDateTime.parse((String) entries.get("updatedAt")));
            }
            
            if (entries.containsKey("completedAt")) {
                job.setCompletedAt(LocalDateTime.parse((String) entries.get("completedAt")));
            }
            
            return job;
        } catch (Exception e) {
            throw new RuntimeException("Error deserializing job", e);
        }
    }
    
    // 기타 메서드 구현…
    
    private void saveJob(Job job) {
        String key = JOB_KEY_PREFIX + job.getId();
        Map<String, String> hash = new HashMap<>();
        
        try {
            hash.put("status", job.getStatus().name());
            hash.put("request", objectMapper.writeValueAsString(job.getRequest()));
            hash.put("createdAt", job.getCreatedAt().toString());
            
            if (job.getUpdatedAt() != null) {
                hash.put("updatedAt", job.getUpdatedAt().toString());
            }
            
            if (job.getProgress() > 0) {
                hash.put("progress", String.valueOf(job.getProgress()));
            }
            
            if (job.getMessage() != null) {
                hash.put("message", job.getMessage());
            }
            
            if (job.getResult() != null) {
                hash.put("result", objectMapper.writeValueAsString(job.getResult()));
            }
            
            if (job.getErrorMessage() != null) {
                hash.put("errorMessage", job.getErrorMessage());
            }
            
            if (job.getCompletedAt() != null) {
                hash.put("completedAt", job.getCompletedAt().toString());
            }
            
            redisTemplate.opsForHash().putAll(key, hash);
            
            // 만료 시간 설정 (예: 7일)
            redisTemplate.expire(key, 7, TimeUnit.DAYS);
        } catch (Exception e) {
            throw new RuntimeException("Error serializing job", e);
        }
    }
}

작업 스케줄링 및 배치 처리

정기적으로 실행되어야 하는 작업이나 배치 처리가 필요한 경우의 전략.

 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
@Configuration
@EnableScheduling
public class JobSchedulingConfig {

    private final JobRepository jobRepository;
    private final JobProcessor jobProcessor;
    
    // 만료된 작업 정리 스케줄러
    @Scheduled(fixedDelay = 60000) // 1분마다 실행
    public void cleanupExpiredJobs() {
        LocalDateTime expirationTime = LocalDateTime.now().minusDays(7);
        List<Job> expiredJobs = jobRepository.findExpiredJobs(expirationTime);
        
        for (Job job : expiredJobs) {
            if (job.getStatus() == JobStatus.PENDING || job.getStatus() == JobStatus.PROCESSING) {
                jobRepository.updateStatus(job.getId(), JobStatus.FAILED, "Job expired");
            }
            
            // 오래된 작업 데이터 정리
            jobRepository.deleteJob(job.getId());
        }
    }
    
    // 중단된 작업 복구 스케줄러
    @Scheduled(fixedDelay = 300000) // 5분마다 실행
    public void recoverStalledJobs() {
        // 오래 실행 중인 작업 찾기 (1시간 이상)
        LocalDateTime cutoffTime = LocalDateTime.now().minusHours(1);
        List<Job> stalledJobs = jobRepository.findStalledJobs(cutoffTime);
        
        for (Job job : stalledJobs) {
            log.warn("Recovering stalled job: {}", job.getId());
            
            // 작업 재시작 또는 실패 처리
            if (job.getRetryCount() < 3) {
                jobRepository.incrementRetryCount(job.getId());
                jobProcessor.processJob(job.getId());
            } else {
                jobRepository.failJob(job.getId(), "Max retries exceeded after stall");
            }
        }
    }
    
    // 배치 작업 처리 스케줄러
    @Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시에 실행
    public void runDailyBatchJobs() {
        log.info("Starting daily batch job processing");
        
        // 배치 작업 생성 및 처리
        String batchJobId = jobRepository.createJob(new BatchJobRequest("DAILY_REPORT"));
        jobProcessor.processJob(batchJobId);
    }
}

웹훅 전송 및 재시도 전략

웹훅 패턴 구현 시 전송 실패에 대한 재시도 전략이 중요하다.

 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
@Service
public class WebhookService {

    private final RestTemplate restTemplate;
    private final WebhookRepository webhookRepository;
    private final ScheduledExecutorService executorService;
    
    public void sendWebhook(String url, Object payload) {
        // 웹훅 레코드 생성
        WebhookRecord record = new WebhookRecord(
            UUID.randomUUID().toString(),
            url,
            payload,
            0,
            WebhookStatus.PENDING,
            null,
            LocalDateTime.now()
        );
        
        webhookRepository.save(record);
        
        // 웹훅 전송 시도
        tryDeliverWebhook(record, 0);
    }
    
    private void tryDeliverWebhook(WebhookRecord record, int attemptCount) {
        try {
            // 웹훅 전송
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("X-Webhook-ID", record.getId());
            headers.set("X-Webhook-Timestamp", String.valueOf(System.currentTimeMillis()));
            
            HttpEntity<Object> request = new HttpEntity<>(record.getPayload(), headers);
            ResponseEntity<String> response = restTemplate.postForEntity(
                record.getUrl(), request, String.class);
            
            // 성공 처리
            if (response.getStatusCode().is2xxSuccessful()) {
                webhookRepository.updateStatus(
                    record.getId(), 
                    WebhookStatus.DELIVERED,
                    response.getStatusCode().toString()
                );
            } else {
                handleDeliveryFailure(record, attemptCount, 
                    "Non-success status code: " + response.getStatusCode());
            }
        } catch (Exception e) {
            handleDeliveryFailure(record, attemptCount, e.getMessage());
        }
    }
    
    private void handleDeliveryFailure(WebhookRecord record, int attemptCount, String error) {
        // 최대 재시도 횟수 확인
        if (attemptCount >= 5) {
            webhookRepository.updateStatus(
                record.getId(),
                WebhookStatus.FAILED,
                "Max retries exceeded. Last error: " + error
            );
            return;
        }
        
        // 재시도 횟수 및 상태 업데이트
        webhookRepository.incrementRetryCount(record.getId());
        webhookRepository.updateStatus(
            record.getId(),
            WebhookStatus.RETRY_PENDING,
            "Will retry. Last error: " + error
        );
        
        // 지수 백오프를 사용한 재시도 스케줄링
        int delaySeconds = (int) Math.pow(2, attemptCount);
        executorService.schedule(
            () -> tryDeliverWebhook(record, attemptCount + 1),
            delaySeconds,
            TimeUnit.SECONDS
        );
    }
    
    // 실패한 웹훅 수동 재시도
    public void retryFailedWebhook(String webhookId) {
        WebhookRecord record = webhookRepository.findById(webhookId);
        
        if (record != null && 
            (record.getStatus() == WebhookStatus.FAILED || 
             record.getStatus() == WebhookStatus.RETRY_PENDING)) {
            
            // 재시도 카운트 초기화 및 재시도
            webhookRepository.resetRetryCount(webhookId);
            tryDeliverWebhook(record, 0);
        }
    }
}

상태 머신(State Machine) 기반 워크플로

복잡한 비동기 워크플로우를 관리하기 위한 상태 머신 기반 접근 방식.

  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
// 주문 처리 워크플로우 상태 정의
public enum OrderState {
    CREATED,
    PAYMENT_PENDING,
    PAYMENT_COMPLETED,
    PAYMENT_FAILED,
    INVENTORY_CHECKING,
    INVENTORY_CONFIRMED,
    INVENTORY_FAILED,
    PROCESSING,
    SHIPPED,
    DELIVERED,
    CANCELLED,
    REFUNDED
}

// 주문 이벤트 정의
public enum OrderEvent {
    CREATE,
    SUBMIT_PAYMENT,
    PAYMENT_SUCCEEDED,
    PAYMENT_FAILED,
    CHECK_INVENTORY,
    INVENTORY_AVAILABLE,
    INVENTORY_UNAVAILABLE,
    PROCESS,
    SHIP,
    DELIVER,
    CANCEL,
    REFUND
}

@Configuration
@EnableStateMachineFactory
public class OrderStateMachineConfig extends StateMachineConfigurerAdapter<OrderState, OrderEvent> {

    @Override
    public void configure(StateMachineStateConfigurer<OrderState, OrderEvent> states) throws Exception {
        states
            .withStates()
            .initial(OrderState.CREATED)
            .state(OrderState.PAYMENT_PENDING)
            .state(OrderState.PAYMENT_COMPLETED)
            .state(OrderState.PAYMENT_FAILED)
            .state(OrderState.INVENTORY_CHECKING)
            .state(OrderState.INVENTORY_CONFIRMED)
            .state(OrderState.INVENTORY_FAILED)
            .state(OrderState.PROCESSING)
            .state(OrderState.SHIPPED)
            .state(OrderState.DELIVERED)
            .state(OrderState.CANCELLED)
            .state(OrderState.REFUNDED);
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<OrderState, OrderEvent> transitions) throws Exception {
        transitions
            .withExternal()
                .source(OrderState.CREATED)
                .target(OrderState.PAYMENT_PENDING)
                .event(OrderEvent.SUBMIT_PAYMENT)
                .and()
            .withExternal()
                .source(OrderState.PAYMENT_PENDING)
                .target(OrderState.PAYMENT_COMPLETED)
                .event(OrderEvent.PAYMENT_SUCCEEDED)
                .and()
            .withExternal()
                .source(OrderState.PAYMENT_PENDING)
                .target(OrderState.PAYMENT_FAILED)
                .event(OrderEvent.PAYMENT_FAILED)
                .and()
            .withExternal()
                .source(OrderState.PAYMENT_COMPLETED)
                .target(OrderState.INVENTORY_CHECKING)
                .event(OrderEvent.CHECK_INVENTORY)
                .and()
            .withExternal()
                .source(OrderState.INVENTORY_CHECKING)
                .target(OrderState.INVENTORY_CONFIRMED)
                .event(OrderEvent.INVENTORY_AVAILABLE)
                .and()
            .withExternal()
                .source(OrderState.INVENTORY_CHECKING)
                .target(OrderState.INVENTORY_FAILED)
                .event(OrderEvent.INVENTORY_UNAVAILABLE)
                .and()
            .withExternal()
                .source(OrderState.INVENTORY_CONFIRMED)
                .target(OrderState.PROCESSING)
                .event(OrderEvent.PROCESS)
                .and()
            .withExternal()
                .source(OrderState.PROCESSING)
                .target(OrderState.SHIPPED)
                .event(OrderEvent.SHIP)
                .and()
            .withExternal()
                .source(OrderState.SHIPPED)
                .target(OrderState.DELIVERED)
                .event(OrderEvent.DELIVER)
                .and()
            .withExternal()
                .source(OrderState.CREATED)
                .target(OrderState.CANCELLED)
                .event(OrderEvent.CANCEL)
                .and()
            .withExternal()
                .source(OrderState.PAYMENT_PENDING)
                .target(OrderState.CANCELLED)
                .event(OrderEvent.CANCEL)
                .and()
            .withExternal()
                .source(OrderState.PAYMENT_COMPLETED)
                .target(OrderState.REFUNDED)
                .event(OrderEvent.REFUND);
    }
    
    @Override
    public void configure(StateMachineConfigurationConfigurer<OrderState, OrderEvent> config) throws Exception {
        config
            .withConfiguration()
            .autoStartup(true)
            .listener(new OrderStateMachineListener());
    }
    
    // 상태 변경 리스너
    public class OrderStateMachineListener extends StateMachineListenerAdapter<OrderState, OrderEvent> {
        @Override
        public void stateChanged(State<OrderState, OrderEvent> from, State<OrderState, OrderEvent> to) {
            if (from != null) {
                log.info("Order state changed from {} to {}", from.getId(), to.getId());
            }
        }
    }
}

@Service
public class OrderService {

    private final StateMachineFactory<OrderState, OrderEvent> stateMachineFactory;
    private final OrderRepository orderRepository;
    
    // 주문 생성
    public String createOrder(OrderRequest request) {
        // 주문 생성
        String orderId = UUID.randomUUID().toString();
        Order order = new Order(orderId, request, OrderState.CREATED);
        orderRepository.save(order);
        
        return orderId;
    }
    
    // 주문 상태 변경
	public boolean processOrderEvent(String orderId, OrderEvent event, Object data) {
	    // 주문 조회
	    Order order = orderRepository.findById(orderId);
	    if (order == null) {
	        return false;
	    }
	    
	    // 상태 머신 생성 및 설정
	    StateMachine<OrderState, OrderEvent> stateMachine = stateMachineFactory.getStateMachine();
	    stateMachine.getExtendedState().getVariables().put("order", order);
	    stateMachine.getExtendedState().getVariables().put("data", data);
	    
	    // 현재 상태로 복원
	    StateMachineAccessor<OrderState, OrderEvent> accessor = stateMachine.getStateMachineAccessor();
	    accessor.doWithAllRegions(stateMachineAccess -> {
	        stateMachineAccess.resetStateMachine(new DefaultStateMachineContext<>(
	            order.getState(), null, null, null));
	    });
	    
	    // 이벤트 전송
	    boolean result = stateMachine.sendEvent(event);
	    
	    // 상태 변경이 성공하면 주문 업데이트
	    if (result) {
	        order.setState(stateMachine.getState().getId());
	        orderRepository.save(order);
	    }
	    
	    return result;
	}

이벤트 소싱(Event Sourcing) 패턴

이벤트 소싱은 시스템의 상태 변화를 이벤트의 시퀀스로 저장하는 패턴으로, 비동기 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
// 이벤트 인터페이스
public interface DomainEvent {
    String getAggregateId();
    LocalDateTime getTimestamp();
    String getEventType();
}

// 주문 이벤트 예시
public class OrderCreatedEvent implements DomainEvent {
    private final String orderId;
    private final String customerId;
    private final List<OrderItem> items;
    private final BigDecimal totalAmount;
    private final LocalDateTime timestamp;
    
    @Override
    public String getAggregateId() {
        return orderId;
    }
    
    @Override
    public LocalDateTime getTimestamp() {
        return timestamp;
    }
    
    @Override
    public String getEventType() {
        return "OrderCreated";
    }
    
    // 생성자, 게터 등
}

// 이벤트 저장소
@Repository
public class EventStoreRepository {
    
    private final JdbcTemplate jdbcTemplate;
    private final ObjectMapper objectMapper;
    
    // 이벤트 저장
    public void saveEvent(DomainEvent event) {
        try {
            String eventData = objectMapper.writeValueAsString(event);
            
            jdbcTemplate.update(
                "INSERT INTO event_store (aggregate_id, event_type, data, timestamp) VALUES (?, ?, ?, ?)",
                event.getAggregateId(),
                event.getEventType(),
                eventData,
                Timestamp.valueOf(event.getTimestamp())
            );
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Error serializing event", e);
        }
    }
    
    // 집계 ID별 이벤트 조회
    public List<DomainEvent> getEventsForAggregate(String aggregateId) {
        return jdbcTemplate.query(
            "SELECT * FROM event_store WHERE aggregate_id = ? ORDER BY timestamp ASC",
            (rs, rowNum) -> {
                try {
                    String eventType = rs.getString("event_type");
                    String eventData = rs.getString("data");
                    
                    // 이벤트 타입에 따른 역직렬화
                    Class<? extends DomainEvent> eventClass = getEventClass(eventType);
                    return objectMapper.readValue(eventData, eventClass);
                } catch (Exception e) {
                    throw new RuntimeException("Error deserializing event", e);
                }
            },
            aggregateId
        );
    }
    
    private Class<? extends DomainEvent> getEventClass(String eventType) {
        // 이벤트 타입에 따른 클래스 매핑
        switch (eventType) {
            case "OrderCreated":
                return OrderCreatedEvent.class;
            case "OrderPaid":
                return OrderPaidEvent.class;
            case "OrderShipped":
                return OrderShippedEvent.class;
            // 기타 이벤트 타입
            default:
                throw new IllegalArgumentException("Unknown event type: " + eventType);
        }
    }
}

// 주문 집계(Aggregate)
public class Order {
    private String id;
    private String customerId;
    private List<OrderItem> items;
    private BigDecimal totalAmount;
    private OrderStatus status;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    // 이벤트에서 주문 재구성
    public static Order recreateFromEvents(List<DomainEvent> events) {
        Order order = new Order();
        
        for (DomainEvent event : events) {
            order.apply(event);
        }
        
        return order;
    }
    
    // 이벤트 적용
    private void apply(DomainEvent event) {
        if (event instanceof OrderCreatedEvent) {
            OrderCreatedEvent e = (OrderCreatedEvent) event;
            this.id = e.getOrderId();
            this.customerId = e.getCustomerId();
            this.items = new ArrayList<>(e.getItems());
            this.totalAmount = e.getTotalAmount();
            this.status = OrderStatus.CREATED;
            this.createdAt = e.getTimestamp();
            this.updatedAt = e.getTimestamp();
        } else if (event instanceof OrderPaidEvent) {
            OrderPaidEvent e = (OrderPaidEvent) event;
            this.status = OrderStatus.PAID;
            this.updatedAt = e.getTimestamp();
        } else if (event instanceof OrderShippedEvent) {
            OrderShippedEvent e = (OrderShippedEvent) event;
            this.status = OrderStatus.SHIPPED;
            this.updatedAt = e.getTimestamp();
        }
        // 기타 이벤트 처리
    }
    
    // 생성자, 게터, 세터 등
}

@Service
public class OrderService {

    private final EventStoreRepository eventStore;
    private final EventPublisher eventPublisher;
    
    // 주문 생성
    @Transactional
    public String createOrder(OrderRequest request) {
        // 새 주문 ID 생성
        String orderId = UUID.randomUUID().toString();
        
        // 주문 생성 이벤트 생성
        OrderCreatedEvent event = new OrderCreatedEvent(
            orderId,
            request.getCustomerId(),
            request.getItems(),
            calculateTotal(request.getItems()),
            LocalDateTime.now()
        );
        
        // 이벤트 저장
        eventStore.saveEvent(event);
        
        // 이벤트 발행
        eventPublisher.publish(event);
        
        return orderId;
    }
    
    // 주문 조회
    public Order getOrder(String orderId) {
        List<DomainEvent> events = eventStore.getEventsForAggregate(orderId);
        
        if (events.isEmpty()) {
            return null;
        }
        
        return Order.recreateFromEvents(events);
    }
    
    // 주문 결제 처리
    @Transactional
    public void payOrder(String orderId, PaymentDetails paymentDetails) {
        // 현재 주문 상태 조회
        Order order = getOrder(orderId);
        
        if (order == null) {
            throw new OrderNotFoundException(orderId);
        }
        
        if (order.getStatus() != OrderStatus.CREATED) {
            throw new InvalidOrderStateException(
                "Cannot pay order in state: " + order.getStatus());
        }
        
        // 결제 이벤트 생성
        OrderPaidEvent event = new OrderPaidEvent(
            orderId,
            paymentDetails.getTransactionId(),
            paymentDetails.getAmount(),
            paymentDetails.getPaymentMethod(),
            LocalDateTime.now()
        );
        
        // 이벤트 저장
        eventStore.saveEvent(event);
        
        // 이벤트 발행
        eventPublisher.publish(event);
    }
    
    // 기타 메서드…
}

비동기식 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
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
// 권한 확인을 위한 토큰 저장
@PostMapping("/api/long-running-tasks")
public ResponseEntity<TaskResponse> startTask(
        @RequestBody TaskRequest request,
        @RequestHeader("Authorization") String authHeader) {
    
    // 현재 사용자 인증 정보 추출
    User user = authService.getUserFromToken(authHeader);
    
    // 작업 권한 확인
    if (!permissionService.canCreateTask(user, request.getTaskType())) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
    }
    
    // 작업 생성
    String taskId = UUID.randomUUID().toString();
    
    // 작업과 함께 사용자 정보 저장
    Task task = new Task(
        taskId,
        request,
        TaskStatus.PENDING,
        user.getId()  // 작업 소유자 저장
    );
    taskRepository.save(task);
    
    // 처리를 위한 인증 정보 저장
    // 중요: 원본 토큰이 아닌 제한된 범위의 토큰 또는 작업별 토큰 사용
    String taskToken = authService.createTaskToken(user.getId(), taskId);
    taskAuthRepository.saveTaskToken(taskId, taskToken);
    
    // 비동기 작업 시작
    taskProcessor.processTaskAsync(taskId);
    
    return ResponseEntity
        .accepted()
        .body(new TaskResponse(taskId, "PENDING", "/api/long-running-tasks/" + taskId));
}

// 작업 처리 서비스에서 권한 검증
@Service
public class TaskProcessor {

    private final TaskRepository taskRepository;
    private final TaskAuthRepository taskAuthRepository;
    private final AuthService authService;
    
    public void processTaskAsync(String taskId) {
        executorService.submit(() -> {
            try {
                // 작업 정보 조회
                Task task = taskRepository.findById(taskId);
                if (task == null) return;
                
                // 작업 토큰 조회
                String taskToken = taskAuthRepository.getTaskToken(taskId);
                if (taskToken == null) {
                    taskRepository.failTask(taskId, "Missing authentication token");
                    return;
                }
                
                // 토큰 검증
                try {
                    authService.validateTaskToken(taskToken, taskId);
                } catch (Exception e) {
                    taskRepository.failTask(taskId, "Invalid authentication token");
                    return;
                }
                
                // 작업 실행 컨텍스트 설정
                SecurityContextHolder.setContext(
                    authService.createSecurityContextFromTaskToken(taskToken));
                
                // 작업 처리
                processTask(task);
                
                // 보안 컨텍스트 정리
                SecurityContextHolder.clearContext();
            } catch (Exception e) {
                log.error("Error processing task {}: {}", taskId, e.getMessage());
                taskRepository.failTask(taskId, e.getMessage());
            }
        });
    }
    
    private void processTask(Task task) {
        // 작업 처리 로직
        // …
    }
}

웹훅 보안

웹훅을 사용할 때는 발신자 인증과 메시지 무결성을 보장해야 한다.

  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
// 웹훅 서명 생성
public class WebhookSigner {
    
    private final String secretKey;
    
    public WebhookSigner(String secretKey) {
        this.secretKey = secretKey;
    }
    
    public String generateSignature(String payload) {
        try {
            Mac hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256");
            hmac.init(keySpec);
            byte[] hash = hmac.doFinal(payload.getBytes());
            return Base64.getEncoder().encodeToString(hash);
        } catch (Exception e) {
            throw new RuntimeException("Error generating webhook signature", e);
        }
    }
}

// 웹훅 발송 시 서명 추가
@Service
public class WebhookService {
    
    private final RestTemplate restTemplate;
    private final WebhookSigner webhookSigner;
    
    public void sendWebhook(String url, Object payload) {
        try {
            // 페이로드 직렬화
            String payloadJson = objectMapper.writeValueAsString(payload);
            
            // 서명 생성
            String signature = webhookSigner.generateSignature(payloadJson);
            
            // 헤더 설정
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("X-Webhook-Signature", signature);
            headers.set("X-Webhook-Timestamp", String.valueOf(System.currentTimeMillis()));
            
            // 웹훅 요청 전송
            HttpEntity<String> request = new HttpEntity<>(payloadJson, headers);
            restTemplate.postForEntity(url, request, String.class);
        } catch (Exception e) {
            log.error("Error sending webhook: {}", e.getMessage());
        }
    }
}

// 웹훅 수신 시 서명 검증 (클라이언트 측)
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
    
    private final String webhookSecret;
    private final ObjectMapper objectMapper;
    
    @PostMapping("/orders")
    public ResponseEntity<?> receiveOrderWebhook(
            @RequestBody String payload,
            @RequestHeader("X-Webhook-Signature") String signature,
            @RequestHeader("X-Webhook-Timestamp") long timestamp) {
        
        // 타임스탬프 검증 (5분 내 발송된 웹훅만 허용)
        long now = System.currentTimeMillis();
        if (now - timestamp > 300000) { // 5분 = 300,000ms
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body("Webhook timestamp too old");
        }
        
        // 서명 검증
        try {
            Mac hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec keySpec = new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256");
            hmac.init(keySpec);
            byte[] hash = hmac.doFinal(payload.getBytes());
            String expectedSignature = Base64.getEncoder().encodeToString(hash);
            
            if (!MessageDigest.isEqual(
                    expectedSignature.getBytes(), 
                    signature.getBytes())) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("Invalid webhook signature");
            }
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body("Error validating webhook signature");
        }
        
        // 페이로드 처리
        try {
            OrderWebhookPayload orderPayload = 
                objectMapper.readValue(payload, OrderWebhookPayload.class);
            
            // 주문 웹훅 처리 로직
            processOrderWebhook(orderPayload);
            
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body("Error processing webhook payload");
        }
    }
    
    private void processOrderWebhook(OrderWebhookPayload payload) {
        // 웹훅 처리 로직
        // …
    }
}

데이터 보안

비동기 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
// 민감한 데이터의 암호화 저장
@Service
public class SecureJobService {

    private final JobRepository jobRepository;
    private final EncryptionService encryptionService;
    
    public String createJob(JobRequest request) {
        String jobId = UUID.randomUUID().toString();
        
        // 민감한 데이터 암호화
        if (request.hasSensitiveData()) {
            request = encryptSensitiveData(request);
        }
        
        Job job = new Job(jobId, request, JobStatus.PENDING);
        jobRepository.save(job);
        
        return jobId;
    }
    
    private JobRequest encryptSensitiveData(JobRequest request) {
        // 요청 복제
        JobRequest encryptedRequest = request.clone();
        
        // 민감한 필드 암호화
        if (request.getPaymentInfo() != null) {
            PaymentInfo encryptedPaymentInfo = new PaymentInfo(
                encryptionService.encrypt(request.getPaymentInfo().getCardNumber()),
                request.getPaymentInfo().getExpiryMonth(),
                request.getPaymentInfo().getExpiryYear(),
                encryptionService.encrypt(request.getPaymentInfo().getCvv())
            );
            encryptedRequest.setPaymentInfo(encryptedPaymentInfo);
        }
        
        if (request.getPersonalInfo() != null) {
            PersonalInfo encryptedPersonalInfo = new PersonalInfo(
                request.getPersonalInfo().getName(),
                encryptionService.encrypt(request.getPersonalInfo().getSsn()),
                encryptionService.encrypt(request.getPersonalInfo().getDateOfBirth())
            );
            encryptedRequest.setPersonalInfo(encryptedPersonalInfo);
        }
        
        // 민감 데이터 포함 표시
        encryptedRequest.setContainsSensitiveData(true);
        
        return encryptedRequest;
    }
    
    // 작업 결과 조회 시 복호화
    public Job getJob(String jobId, boolean decryptSensitiveData) {
        Job job = jobRepository.findById(jobId);
        
        if (job == null) {
            return null;
        }
        
        // 민감한 데이터가 있고 복호화가 요청된 경우
        if (decryptSensitiveData && job.getRequest().isContainsSensitiveData()) {
            job = decryptSensitiveData(job);
        }
        
        return job;
    }
    
    private Job decryptSensitiveData(Job job) {
        // 작업 복제
        Job decryptedJob = job.clone();
        JobRequest request = job.getRequest();
        
        // 민감한 필드 복호화
        if (request.getPaymentInfo() != null) {
            PaymentInfo decryptedPaymentInfo = new PaymentInfo(
                encryptionService.decrypt(request.getPaymentInfo().getCardNumber()),
                request.getPaymentInfo().getExpiryMonth(),
                request.getPaymentInfo().getExpiryYear(),
                encryptionService.decrypt(request.getPaymentInfo().getCvv())
            );
            decryptedJob.getRequest().setPaymentInfo(decryptedPaymentInfo);
        }
        
        if (request.getPersonalInfo() != null) {
            PersonalInfo decryptedPersonalInfo = new PersonalInfo(
                request.getPersonalInfo().getName(),
                encryptionService.decrypt(request.getPersonalInfo().getSsn()),
                encryptionService.decrypt(request.getPersonalInfo().getDateOfBirth())
            );
            decryptedJob.getRequest().setPersonalInfo(decryptedPersonalInfo);
        }
        
        return decryptedJob;
    }
}

비동기식 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
// 작업 모니터링 서비스
@Service
public class JobMonitoringService {

    private final JobRepository jobRepository;
    private final MeterRegistry meterRegistry;
    
    // 작업 통계 수집 (스케줄링된 메서드)
    @Scheduled(fixedRate = 60000) // 1분마다 실행
    public void collectJobMetrics() {
        // 상태별 작업 수 통계
        Map<JobStatus, Long> countByStatus = jobRepository.countByStatus();
        
        for (Map.Entry<JobStatus, Long> entry : countByStatus.entrySet()) {
            meterRegistry.gauge(
                "jobs.count",
                Tags.of("status", entry.getKey().name()),
                entry.getValue()
            );
        }
        
        // 처리 시간 통계
        Map<String, Double> avgProcessingTime = jobRepository.getAverageProcessingTimeByType();
        
        for (Map.Entry<String, Double> entry : avgProcessingTime.entrySet()) {
            meterRegistry.gauge(
                "jobs.processing_time",
                Tags.of("job_type", entry.getKey()),
                entry.getValue()
            );
        }
        
        // 실패율 통계
        Map<String, Double> failureRates = jobRepository.getFailureRateByType();
        
        for (Map.Entry<String, Double> entry : failureRates.entrySet()) {
            meterRegistry.gauge(
                "jobs.failure_rate",
                Tags.of("job_type", entry.getKey()),
                entry.getValue()
            );
        }
    }
    
    // 오래 실행 중인 작업 탐지
    @Scheduled(fixedRate = 300000) // 5분마다 실행
    public void detectLongRunningJobs() {
        List<Job> longRunningJobs = jobRepository.findLongRunningJobs(Duration.ofMinutes(30));
        
        // 메트릭 기록
        meterRegistry.gauge("jobs.long_running.count", longRunningJobs.size());
        
        // 알림 발송
        if (!longRunningJobs.isEmpty()) {
            StringBuilder message = new StringBuilder("Long running jobs detected:\n");
            
            for (Job job : longRunningJobs) {
                message.append(String.format(
                    "Job %s (type: %s) running for %s minutes\n",
                    job.getId(),
                    job.getRequest().getType(),
                    Duration.between(job.getStartedAt(), LocalDateTime.now()).toMinutes()
                ));
            }
            
            alertService.sendAlert("LONG_RUNNING_JOBS", message.toString());
        }
    }
    
    // 작업 처리 시간 히스토그램
    public void recordJobProcessingTime(String jobType, Duration duration) {
        meterRegistry.timer("jobs.processing_duration", "job_type", jobType)
            .record(duration);
    }
    
    // 작업 결과 카운터
    public void recordJobResult(String jobType, JobStatus status) {
        meterRegistry.counter("jobs.completed", 
                "job_type", jobType, 
                "status", status.name())
            .increment();
    }
}

분산 추적

마이크로서비스 환경에서는 분산 추적을 통해 비동기 작업의 흐름을 추적할 수 있다.

 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
// 분산 추적 서비스
@Service
public class TraceableJobService {

    private final JobRepository jobRepository;
    private final Tracer tracer;
    
    public String createJob(JobRequest request) {
        // 현재 활성 스팬 가져오기
        Span parentSpan = tracer.currentSpan();
        
        // 작업 생성 스팬 시작
        Span span = tracer.nextSpan()
            .name("job.create")
            .tag("job.type", request.getType())
            .start();
        
        try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) {
            // 작업 생성
            String jobId = UUID.randomUUID().toString();
            
            // 추적 컨텍스트 저장
            TraceContext traceContext = span.context();
            String traceId = traceContext.traceIdString();
            String spanId = traceContext.spanIdString();
            
            // 작업 저장 (추적 정보 포함)
            Job job = new Job(
                jobId,
                request,
                JobStatus.PENDING,
                traceId,
                spanId
            );
            jobRepository.save(job);
            
            // 로그
            span.annotate("Job created: " + jobId);
            
            return jobId;
        } finally {
            span.finish();
        }
    }
    
    public void processJob(String jobId) {
        // 작업 조회
        Job job = jobRepository.findById(jobId);
        if (job == null) return;
        
        // 저장된 추적 컨텍스트에서 스팬 생성
        TraceContext parentContext = TraceContext.newBuilder()
            .traceId(job.getTraceId())
            .spanId(job.getSpanId())
            .build();
            
        Span parentSpan = tracer.toSpan(parentContext);
        
        // 작업 처리 스팬 시작
        Span span = tracer.newChild(parentSpan.context())
            .name("job.process")
            .tag("job.id", jobId)
            .tag("job.type", job.getRequest().getType())
            .start();
        
        try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) {
            // 작업 상태 업데이트
            jobRepository.updateStatus(jobId, JobStatus.PROCESSING);
            span.annotate("Job processing started");
            
            // 작업 처리 로직
            JobResult result = processJobRequest(job.getRequest());
            
            // 완료 상태 업데이트
            jobRepository.complete(jobId, result);
            span.annotate("Job completed successfully");
        } catch (Exception e) {
            span.tag("error", "true");
            span.tag("error.message", e.getMessage());
            
            // 실패 상태 업데이트
            jobRepository.fail(jobId, e.getMessage());
            span.annotate("Job failed: " + e.getMessage());
        } finally {
            span.finish();
        }
    }
    
    private JobResult processJobRequest(JobRequest request) {
        // 작업 처리 로직
        // …
        return new JobResult(/* 결과 데이터 */);
    }
}

로깅 전략

효과적인 문제 해결을 위해 구조화된 로깅이 중요하다.

  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
// 구조화된 로깅 예시
@Service
public class AsyncJobProcessor {

    private final Logger log = LoggerFactory.getLogger(AsyncJobProcessor.class);
    private final JobRepository jobRepository;
    
    public void processJob(String jobId) {
        // MDC에 작업 컨텍스트 추가
        MDC.put("jobId", jobId);
        
        try {
            // 작업 조회
            Job job = jobRepository.findById(jobId);
            if (job == null) {
                log.error("Job not found");
                return;
            }
            
            // 추가 컨텍스트 정보
            MDC.put("jobType", job.getRequest().getType());
            MDC.put("userId", job.getUserId());
            
            log.info("Starting job processing");
            
            // 작업 상태 업데이트
            jobRepository.updateStatus(jobId, JobStatus.PROCESSING);
            
            // 진행 상황 업데이트를 위한 리스너
            ProgressListener progressListener = (progress, message) -> {
                log.info("Job progress: {}% - {}", progress * 100, message);
                jobRepository.updateProgress(jobId, progress, message);
            };
            
            try {
                // 작업 처리
                log.debug("Executing job with parameters: {}", job.getRequest());
                JobResult result = executeJob(job.getRequest(), progressListener);
                
                // 성공 처리
                log.info("Job completed successfully");
                jobRepository.complete(jobId, result);
            } catch (Exception e) {
                log.error("Job execution failed", e);
                jobRepository.fail(jobId, e.getMessage());
            }
        } finally {
            // MDC 정리
            MDC.clear();
        }
    }
    
    private JobResult executeJob(JobRequest request, ProgressListener progressListener) {
        // 작업 처리 로직
        // …
        
        // 진행 상황 업데이트
        progressListener.onProgress(0.25f, "Initial processing completed");
        
        // 더 많은 처리
        // …
        
        progressListener.onProgress(0.5f, "Halfway done");
        
        // 더 많은 처리
        // …
        
        progressListener.onProgress(0.75f, "Almost there");
        
        // 최종 처리
        // …
        
        progressListener.onProgress(1.0f, "Processing complete");
        
        return new JobResult(/* 결과 데이터 */);
    }
   

// 진행 상황 리스너 인터페이스
public interface ProgressListener {
    void onProgress(float progress, String message);
}

// 로그 이벤트 구조체
public class JobLogEvent {
    private final String jobId;
    private final String eventType;
    private final String message;
    private final Map<String, Object> details;
    private final LocalDateTime timestamp;
    
    // 생성자, 게터 등
    
    public static JobLogEvent info(String jobId, String message) {
        return new JobLogEvent(jobId, "INFO", message, Collections.emptyMap(), LocalDateTime.now());
    }
    
    public static JobLogEvent warning(String jobId, String message) {
        return new JobLogEvent(jobId, "WARNING", message, Collections.emptyMap(), LocalDateTime.now());
    }
    
    public static JobLogEvent error(String jobId, String message, Throwable error) {
        Map<String, Object> details = new HashMap<>();
        details.put("errorType", error.getClass().getName());
        details.put("errorMessage", error.getMessage());
        details.put("stackTrace", getStackTraceAsString(error));
        
        return new JobLogEvent(jobId, "ERROR", message, details, LocalDateTime.now());
    }
    
    public static JobLogEvent progress(String jobId, float progress, String message) {
        Map<String, Object> details = new HashMap<>();
        details.put("progress", progress);
        
        return new JobLogEvent(jobId, "PROGRESS", message, details, LocalDateTime.now());
    }
    
    private static String getStackTraceAsString(Throwable error) {
        StringWriter sw = new StringWriter();
        error.printStackTrace(new PrintWriter(sw));
        return sw.toString();
    }
}

// 작업 로그 저장소
@Repository
public class JobLogRepository {
    
    private final MongoTemplate mongoTemplate;
    
    public void saveLogEvent(JobLogEvent event) {
        mongoTemplate.save(event, "job_logs");
    }
    
    public List<JobLogEvent> getJobLogs(String jobId) {
        Query query = Query.query(Criteria.where("jobId").is(jobId))
            .with(Sort.by(Sort.Direction.ASC, "timestamp"));
            
        return mongoTemplate.find(query, JobLogEvent.class, "job_logs");
    }
}

비동기식 API의 적용 사례

비동기식 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
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
@RestController
@RequestMapping("/api/files")
public class FileProcessingController {

    private final FileProcessingService fileService;
    private final FileRepository fileRepository;
    
    @PostMapping("/convert")
    public ResponseEntity<FileConversionResponse> convertFile(
            @RequestParam("file") MultipartFile file,
            @RequestParam("targetFormat") String targetFormat) {
        
        try {
            // 입력 파일 저장
            String sourceFileName = file.getOriginalFilename();
            String sourceContentType = file.getContentType();
            String sourceFilePath = fileService.saveUploadedFile(file);
            
            // 변환 작업 생성
            String conversionId = UUID.randomUUID().toString();
            
            FileConversion conversion = new FileConversion(
                conversionId,
                sourceFileName,
                sourceContentType,
                sourceFilePath,
                targetFormat,
                FileConversionStatus.PENDING,
                LocalDateTime.now()
            );
            
            fileRepository.saveConversion(conversion);
            
            // 비동기 변환 작업 시작
            fileService.convertFileAsync(conversionId);
            
            // 즉시 응답
            return ResponseEntity
                .accepted()
                .body(new FileConversionResponse(
                    conversionId,
                    "PENDING",
                    "/api/files/conversions/" + conversionId
                ));
        } catch (IOException e) {
            return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new FileConversionResponse(null, "ERROR", e.getMessage()));
        }
    }
    
    @GetMapping("/conversions/{conversionId}")
    public ResponseEntity<FileConversionStatusResponse> getConversionStatus(
            @PathVariable String conversionId) {
        
        FileConversion conversion = fileRepository.findConversionById(conversionId);
        
        if (conversion == null) {
            return ResponseEntity.notFound().build();
        }
        
        FileConversionStatusResponse response = new FileConversionStatusResponse(
            conversion.getId(),
            conversion.getStatus().toString(),
            conversion.getProgress(),
            conversion.getMessage()
        );
        
        // 변환이 완료된 경우 다운로드 URL 추가
        if (conversion.getStatus() == FileConversionStatus.COMPLETED) {
            response.setDownloadUrl("/api/files/download/" + conversion.getOutputFileId());
        }
        
        return ResponseEntity.ok(response);
    }
    
    @GetMapping("/download/{fileId}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String fileId) {
        StoredFile file = fileRepository.findFileById(fileId);
        
        if (file == null) {
            return ResponseEntity.notFound().build();
        }
        
        try {
            Resource resource = fileService.loadFileAsResource(file.getFilePath());
            
            return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(file.getContentType()))
                .header(HttpHeaders.CONTENT_DISPOSITION, 
                    "attachment; filename=\"" + file.getFileName() + "\"")
                .body(resource);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
}

@Service
public class FileProcessingService {
    
    private final FileRepository fileRepository;
    private final ProgressTracker progressTracker;
    
    public void convertFileAsync(String conversionId) {
        executorService.submit(() -> {
            // 변환 작업 조회
            FileConversion conversion = fileRepository.findConversionById(conversionId);
            if (conversion == null) return;
            
            try {
                // 상태 업데이트
                fileRepository.updateConversionStatus(
                    conversionId, FileConversionStatus.PROCESSING);
                
                // 진행 상황 추적을 위한 리스너 생성
                ProgressListener progressListener = (progress, message) -> {
                    progressTracker.updateProgress(conversionId, progress, message);
                };
                
                // 파일 변환 실행
                String outputFilePath = convertFile(
                    conversion.getSourceFilePath(),
                    conversion.getTargetFormat(),
                    progressListener
                );
                
                // 결과 파일 저장
                String outputFileName = generateOutputFileName(
                    conversion.getSourceFileName(), conversion.getTargetFormat());
                    
                String outputContentType = determineContentType(conversion.getTargetFormat());
                
                String outputFileId = UUID.randomUUID().toString();
                
                StoredFile outputFile = new StoredFile(
                    outputFileId,
                    outputFileName,
                    outputContentType,
                    outputFilePath,
                    LocalDateTime.now()
                );
                
                fileRepository.saveFile(outputFile);
                
                // 변환 완료 상태 업데이트
                fileRepository.completeConversion(conversionId, outputFileId);
            } catch (Exception e) {
                // 변환 실패 처리
                fileRepository.failConversion(conversionId, e.getMessage());
            }
        });
    }
    
    private String convertFile(String sourceFilePath, String targetFormat, 
            ProgressListener progressListener) {
        // 실제 파일 변환 로직
        // (예: FFmpeg, ImageMagick, Apache POI 등을 사용한 변환)
        
        // 파일 형식에 따른 적절한 변환기 선택
        FileConverter converter = getConverterForFormat(targetFormat);
        
        // 변환 실행
        return converter.convert(sourceFilePath, targetFormat, progressListener);
    }
    
    // 기타 헬퍼 메서드…
}

데이터 분석 및 보고서 생성

대량의 데이터를 처리하고 보고서를 생성하는 비동기 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
@RestController
@RequestMapping("/api/reports")
public class ReportGenerationController {

    private final ReportService reportService;
    
    @PostMapping("/generate")
    public ResponseEntity<ReportGenerationResponse> generateReport(
            @RequestBody ReportRequest request,
            @RequestParam(required = false) String webhookUrl) {
        
        // 보고서 작업 생성
        String reportId = reportService.initiateReportGeneration(request, webhookUrl);
        
        // 즉시 응답 반환
        return ResponseEntity
            .accepted()
            .body(new ReportGenerationResponse(
                reportId,
                "PENDING",
                "/api/reports/" + reportId
            ));
    }
    
    @GetMapping("/{reportId}")
    public ResponseEntity<ReportStatusResponse> getReportStatus(
            @PathVariable String reportId) {
        
        Report report = reportService.getReport(reportId);
        
        if (report == null) {
            return ResponseEntity.notFound().build();
        }
        
        ReportStatusResponse response = new ReportStatusResponse(
            report.getId(),
            report.getStatus().toString(),
            report.getProgress(),
            report.getMessage()
        );
        
        // 보고서가 완료된 경우 다운로드 URL 추가
        if (report.getStatus() == ReportStatus.COMPLETED) {
            response.setDownloadUrl("/api/reports/" + reportId + "/download");
        }
        
        return ResponseEntity.ok(response);
    }
    
    @GetMapping("/{reportId}/download")
    public ResponseEntity<Resource> downloadReport(@PathVariable String reportId) {
        Report report = reportService.getReport(reportId);
        
        if (report == null || report.getStatus() != ReportStatus.COMPLETED) {
            return ResponseEntity.notFound().build();
        }
        
        try {
            Resource resource = reportService.getReportFile(reportId);
            
            return ResponseEntity.ok()
                .contentType(MediaType.APPLICATION_PDF)
                .header(HttpHeaders.CONTENT_DISPOSITION, 
                    "attachment; filename=\"report_" + reportId + ".pdf\"")
                .body(resource);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
}

@Service
public class ReportService {
    
    private final ReportRepository reportRepository;
    private final DataSourceService dataSourceService;
    private final WebhookService webhookService;
    
    public String initiateReportGeneration(ReportRequest request, String webhookUrl) {
        // 보고서 ID 생성
        String reportId = UUID.randomUUID().toString();
        
        // 보고서 레코드 생성
        Report report = new Report(
            reportId,
            request,
            ReportStatus.PENDING,
            0.0f,
            "Initializing report generation",
            webhookUrl,
            LocalDateTime.now()
        );
        
        reportRepository.save(report);
        
        // 비동기 보고서 생성 시작
        generateReportAsync(reportId);
        
        return reportId;
    }
    
    private void generateReportAsync(String reportId) {
        executorService.submit(() -> {
            Report report = reportRepository.findById(reportId);
            if (report == null) return;
            
            try {
                // 상태 업데이트
                reportRepository.updateStatus(
                    reportId, ReportStatus.PROCESSING, 0.1f, "Collecting data");
                
                // 데이터 수집
                ReportData data = collectReportData(report.getRequest());
                
                // 상태 업데이트
                reportRepository.updateProgress(
                    reportId, 0.4f, "Analyzing data");
                
                // 데이터 분석
                AnalysisResult analysis = analyzeData(data);
                
                // 상태 업데이트
                reportRepository.updateProgress(
                    reportId, 0.7f, "Generating report document");
                
                // 보고서 생성
                String reportFilePath = generateReportDocument(analysis);
                
                // 보고서 완료
                reportRepository.completeReport(reportId, reportFilePath);
                
                // 웹훅 URL이 제공된 경우 알림 전송
                if (report.getWebhookUrl() != null) {
                    webhookService.sendReportCompletionWebhook(
                        report.getWebhookUrl(), reportId);
                }
            } catch (Exception e) {
                // 보고서 생성 실패 처리
                reportRepository.failReport(reportId, e.getMessage());
                
                // 실패 웹훅 알림
                if (report.getWebhookUrl() != null) {
                    webhookService.sendReportFailureWebhook(
                        report.getWebhookUrl(), reportId, e.getMessage());
                }
            }
        });
    }
    
    private ReportData collectReportData(ReportRequest request) {
        // 보고서 유형에 따른 데이터 수집
        switch (request.getType()) {
            case "SALES":
                return dataSourceService.collectSalesData(
                    request.getStartDate(), 
                    request.getEndDate(),
                    request.getFilters()
                );
            case "INVENTORY":
                return dataSourceService.collectInventoryData(
                    request.getCategories(),
                    request.getLocations()
                );
            case "CUSTOMER":
                return dataSourceService.collectCustomerData(
                    request.getSegments(),
                    request.getTimeframe()
                );
            default:
                throw new IllegalArgumentException("Unsupported report type: " + request.getType());
        }
    }
    
    private AnalysisResult analyzeData(ReportData data) {
        // 데이터 분석 로직
        // …
        return new AnalysisResult(/* 분석 결과 */);
    }
    
    private String generateReportDocument(AnalysisResult analysis) {
        // 보고서 문서 생성 로직 (PDF, Excel 등)
        // …
        return "/path/to/report/file.pdf";
    }
    
    // 기타 메서드…
}

결제 처리

결제 처리와 같이 외부 서비스 호출이 필요한 작업에 대한 비동기 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
@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentService paymentService;
    
    @PostMapping
    public ResponseEntity<PaymentResponse> processPayment(
            @RequestBody PaymentRequest request,
            @RequestHeader(value = "Idempotency-Key", required = true) String idempotencyKey) {
        
        // 멱등성 키로 기존 요청 확인
        Payment existingPayment = paymentService.findByIdempotencyKey(idempotencyKey);
        
        if (existingPayment != null) {
            // 기존 응답 반환
            return ResponseEntity
                .status(existingPayment.getStatus() == PaymentStatus.COMPLETED ? 
                        HttpStatus.OK : HttpStatus.ACCEPTED)
                .body(mapToResponse(existingPayment));
        }
        
        // 결제 생성
        String paymentId = paymentService.initiatePayment(request, idempotencyKey);
        
        // 즉시 응답
        return ResponseEntity
            .accepted()
            .body(new PaymentResponse(
                paymentId,
                "PROCESSING",
                "/api/payments/" + paymentId
            ));
    }
    
    @GetMapping("/{paymentId}")
    public ResponseEntity<PaymentResponse> getPaymentStatus(@PathVariable String paymentId) {
        Payment payment = paymentService.getPayment(paymentId);
        
        if (payment == null) {
            return ResponseEntity.notFound().build();
        }
        
        return ResponseEntity.ok(mapToResponse(payment));
    }
    
    private PaymentResponse mapToResponse(Payment payment) {
        PaymentResponse response = new PaymentResponse(
            payment.getId(),
            payment.getStatus().toString(),
            "/api/payments/" + payment.getId()
        );
        
        if (payment.getStatus() == PaymentStatus.COMPLETED) {
            response.setTransactionId(payment.getTransactionId());
            response.setCompletedAt(payment.getCompletedAt());
        } else if (payment.getStatus() == PaymentStatus.FAILED) {
            response.setError(payment.getErrorMessage());
        }
        
        return response;
    }
}

@Service
public class PaymentService {
    
    private final PaymentRepository paymentRepository;
    private final PaymentGateway paymentGateway;
    private final NotificationService notificationService;
    
    public String initiatePayment(PaymentRequest request, String idempotencyKey) {
        // 결제 ID 생성
        String paymentId = UUID.randomUUID().toString();
        
        // 결제 레코드 생성
        Payment payment = new Payment(
            paymentId,
            request,
            PaymentStatus.PROCESSING,
            idempotencyKey,
            LocalDateTime.now()
        );
        
        paymentRepository.save(payment);
        
        // 비동기 결제 처리 시작
        processPaymentAsync(paymentId);
        
        return paymentId;
    }
    
    private void processPaymentAsync(String paymentId) {
        executorService.submit(() -> {
            Payment payment = paymentRepository.findById(paymentId);
            if (payment == null) return;
            
            try {
                // 결제 처리
                PaymentResult result = paymentGateway.processPayment(
                    payment.getRequest().getAmount(),
                    payment.getRequest().getCurrency(),
                    payment.getRequest().getPaymentMethod(),
                    payment.getRequest().getCustomerInfo()
                );
                
                if (result.isSuccessful()) {
                    // 결제 성공 처리
                    paymentRepository.completePayment(
                        paymentId, 
                        result.getTransactionId(),
                        result.getAuthorizationCode()
                    );
                    
                    // 알림 전송
                    notificationService.sendPaymentSuccessNotification(
                        payment.getRequest().getCustomerId(),
                        payment.getRequest().getAmount(),
                        payment.getRequest().getCurrency()
                    );
                } else {
                    // 결제 실패 처리
                    paymentRepository.failPayment(
                        paymentId, 
                        result.getErrorCode(),
                        result.getErrorMessage()
                    );
                    
                    // 실패 알림
                    notificationService.sendPaymentFailureNotification(
                        payment.getRequest().getCustomerId(),
                        payment.getRequest().getAmount(),
                        result.getErrorMessage()
                    );
                }
            } catch (Exception e) {
                // 처리 오류 처리
                paymentRepository.failPayment(
                    paymentId, 
                    "PROCESSING_ERROR",
                    e.getMessage()
                );
                
                // 오류 알림
                notificationService.sendPaymentErrorNotification(
                    payment.getRequest().getCustomerId(),
                    e.getMessage()
                );
            }
        });
    }
    
    public Payment findByIdempotencyKey(String idempotencyKey) {
        return paymentRepository.findByIdempotencyKey(idempotencyKey);
    }
    
    public Payment getPayment(String paymentId) {
        return paymentRepository.findById(paymentId);
    }
}

비동기식 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
 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
# OpenAPI 명세 예시
openapi: 3.0.0
info:
  title: 비동기 파일 변환 API
  version: 1.0.0
  description: |
    대용량 파일을 비동기적으로 변환하기 위한 API입니다.
    
    ## 비동기 처리 흐름
    1. 파일을 업로드하고 변환 요청을 제출합니다.
    2. API는 즉시 작업 ID와 함께 202 Accepted 응답을 반환합니다.
    3. 클라이언트는 제공된 상태 URL을 폴링하여 변환 상태를 확인합니다.
    4. 변환이 완료되면 상태 엔드포인트가 다운로드 URL을 제공합니다.
    
    ## 웹훅 지원
    웹훅 URL을 제공하면 작업이 완료될 때 알림을 받을 수 있습니다.
    
    ## 오류 처리
    작업이 실패하면 상태 엔드포인트에서 오류 정보를 제공합니다.
    
    ## 타임아웃 및 만료
    작업은 최대 24시간 동안 유효하며, 그 이후에는 자동으로 정리됩니다.

paths:
  /api/files/convert:
    post:
      summary: 파일 변환 요청 제출
      description: 파일 업로드 및 변환 작업 시작
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: 변환할 파일
                targetFormat:
                  type: string
                  description: 대상 파일 형식 (pdf, docx, jpg 등)
                webhookUrl:
                  type: string
                  format: uri
                  description: 작업 완료 시 알림을 받을 웹훅 URL (선택 사항)
              required:
                - file
                - targetFormat
      responses:
        '202':
          description: 변환 작업이 성공적으로 시작됨
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileConversionResponse'
        '400':
          description: 잘못된 요청
  
  /api/files/conversions/{conversionId}:
    get:
      summary: 변환 작업 상태 조회
      parameters:
        - name: conversionId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: 현재 변환 상태
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileConversionStatusResponse'
              examples:
                pending:
                  value:
                    id: "123e4567-e89b-12d3-a456-426614174000"
                    status: "PENDING"
                    progress: 0.0
                    message: "Waiting to start"
                processing:
                  value:
                    id: "123e4567-e89b-12d3-a456-426614174000"
                    status: "PROCESSING"
                    progress: 0.65
                    message: "Converting page 13 of 20"
                completed:
                  value:
                    id: "123e4567-e89b-12d3-a456-426614174000"
                    status: "COMPLETED"
                    progress: 1.0
                    message: "Conversion complete"
                    downloadUrl: "/api/files/download/abc123"
                failed:
                  value:
                    id: "123e4567-e89b-12d3-a456-426614174000"
                    status: "FAILED"
                    progress: 0.5
                    message: "Conversion failed: Unsupported file format"
                    error: "UNSUPPORTED_FORMAT"
        '404':
          description: 변환 작업을 찾을 수 없음

components:
  schemas:
    FileConversionResponse:
      type: object
      properties:
        id:
          type: string
          description: 변환 작업 ID
        status:
          type: string
          enum: [PENDING, PROCESSING, COMPLETED, FAILED]
          description: 작업 상태
        statusUrl:
          type: string
          description: 상태 확인을 위한 URL
      required:
        - id
        - status
        - statusUrl
    
    FileConversionStatusResponse:
      type: object
      properties:
        id:
          type: string
          description: 변환 작업 ID
        status:
          type: string
          enum: [PENDING, PROCESSING, COMPLETED, FAILED]
          description: 작업 상태
        progress:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: 진행률 (0.0 ~ 1.0)
        message:
          type: string
          description: 상태 메시지
        downloadUrl:
          type: string
          description: 완료된 경우 다운로드 URL
        error:
          type: string
          description: 실패한 경우 오류 코드
      required:
        - id
        - status

견고한 오류 처리

비동기 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
// 오류 처리 패턴 예시
@ControllerAdvice
public class AsyncApiExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            "RESOURCE_NOT_FOUND",
            ex.getMessage(),
            HttpStatus.NOT_FOUND.value()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    @ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<ErrorResponse> handleInvalidRequest(InvalidRequestException ex) {
        ErrorResponse error = new ErrorResponse(
            "INVALID_REQUEST",
            ex.getMessage(),
            HttpStatus.BAD_REQUEST.value()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
    
    @ExceptionHandler(ConflictException.class)
    public ResponseEntity<ErrorResponse> handleConflict(ConflictException ex) {
        ErrorResponse error = new ErrorResponse(
            "CONFLICT",
            ex.getMessage(),
            HttpStatus.CONFLICT.value()
        );
        
        return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
    }
    
    @ExceptionHandler(AsyncJobProcessingException.class)
    public ResponseEntity<ErrorResponse> handleAsyncJobProcessing(AsyncJobProcessingException ex) {
        ErrorResponse error = new ErrorResponse(
            ex.getErrorCode(),
            ex.getMessage(),
            HttpStatus.INTERNAL_SERVER_ERROR.value()
        );
        
        error.addDetail("jobId", ex.getJobId());
        error.addDetail("jobType", ex.getJobType());
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        ErrorResponse error = new ErrorResponse(
            "INTERNAL_SERVER_ERROR",
            "An unexpected error occurred",
            HttpStatus.INTERNAL_SERVER_ERROR.value()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

// 비동기 작업 관련 예외
public class AsyncJobProcessingException extends RuntimeException {
    private final String jobId;
    private final String jobType;
    private final String errorCode;
    
    public AsyncJobProcessingException(String message, String jobId, String jobType, String errorCode) {
        super(message);
        this.jobId = jobId;
        this.jobType = jobType;
        this.errorCode = errorCode;
    }
    
    // 게터 메서드…
}

// 오류 응답 모델
public class ErrorResponse {
    private final String code;
    private final String message;
    private final int status;
    private final Map<String, Object> details;
    private final String timestamp;
    
    public ErrorResponse(String code, String message, int status) {
        this.code = code;
        this.message = message;
        this.status = status;
        this.details = new HashMap<>();
        this.timestamp = LocalDateTime.now().toString();
    }
    
    public void addDetail(String key, Object value) {
        details.put(key, value);
    }
    
    // 게터 메서드…
}

효율적인 리소스 관리

비동기 작업은 시스템 리소스를 효율적으로 관리해야 한다.

  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
// 효율적인 스레드 풀 구성 예시
@Configuration
public class AsyncTaskExecutorConfig { 
   
    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        
        // 코어 스레드 수 (항상 유지되는 스레드 수)
        executor.setCorePoolSize(10);
        
        // 최대 스레드 수 (부하가 높을 때)
        executor.setMaxPoolSize(50);
        
        // 작업 큐 크기 (대기 작업)
        executor.setQueueCapacity(100);
        
        // 유휴 스레드 제거 시간(초)
        executor.setKeepAliveSeconds(60);
        
        // 스레드 이름 접두사
        executor.setThreadNamePrefix("async-task-");
        
        // 큐가 가득 찼을 때 정책: CallerRunsPolicy는 호출 스레드에서 작업 실행
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        
        // 작업 완료 대기 없이 즉시 종료 방지
        executor.setWaitForTasksToCompleteOnShutdown(true);
        
        // 종료 시 최대 대기 시간(초)
        executor.setAwaitTerminationSeconds(60);
        
        executor.initialize();
        return executor;
    }
    
    // 작업 유형별 별도 스레드 풀
    @Bean(name = "fileProcessingExecutor")
    public Executor fileProcessingExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("file-proc-");
        executor.initialize();
        return executor;
    }
    
    @Bean(name = "reportGenerationExecutor")
    public Executor reportGenerationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.setThreadNamePrefix("report-gen-");
        executor.initialize();
        return executor;
    }
}

// 스레드 풀 사용 예시
@Service
public class ResourceEfficientJobService {

    @Autowired
    @Qualifier("taskExecutor")
    private Executor taskExecutor;
    
    @Autowired
    @Qualifier("fileProcessingExecutor")
    private Executor fileProcessingExecutor;
    
    @Autowired
    @Qualifier("reportGenerationExecutor")
    private Executor reportGenerationExecutor;
    
    public void submitJob(Job job) {
        // 작업 유형에 따라 적절한 스레드 풀 선택
        Executor selectedExecutor;
        
        switch (job.getType()) {
            case "FILE_PROCESSING":
                selectedExecutor = fileProcessingExecutor;
                break;
            case "REPORT_GENERATION":
                selectedExecutor = reportGenerationExecutor;
                break;
            default:
                selectedExecutor = taskExecutor;
        }
        
        // 선택한 스레드 풀에 작업 제출
        selectedExecutor.execute(() -> {
            try {
                processJob(job);
            } catch (Exception e) {
                handleJobError(job, e);
            }
        });
    }
    
    // 자원 제한 관리
    public void processJob(Job job) {
        // 작업 유형에 따른 자원 제한 설정
        int memoryLimitMB = getMemoryLimitForJobType(job.getType());
        int timeoutSeconds = getTimeoutForJobType(job.getType());
        
        // 메모리 사용량 모니터링
        MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
        MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
        
        // 시간 제한 설정
        long startTime = System.currentTimeMillis();
        long endTime = startTime + (timeoutSeconds * 1000);
        
        try {
            // 작업 처리 진행
            while (!job.isCompleted()) {
                // 메모리 사용량 확인
                if (heapUsage.getUsed() / (1024 * 1024) > memoryLimitMB) {
                    throw new ResourceLimitExceededException(
                        "Memory limit exceeded for job: " + job.getId());
                }
                
                // 시간 제한 확인
                if (System.currentTimeMillis() > endTime) {
                    throw new ResourceLimitExceededException(
                        "Time limit exceeded for job: " + job.getId());
                }
                
                // 작업 처리 단계 실행
                executeNextStep(job);
                
                // 메모리 사용량 업데이트
                heapUsage = memoryBean.getHeapMemoryUsage();
            }
        } finally {
            // 자원 정리
            cleanupResources(job);
        }
    }
    
    // 자원 정리 메서드
    private void cleanupResources(Job job) {
        // 임시 파일 삭제
        if (job.hasTempFiles()) {
            for (String filePath : job.getTempFilePaths()) {
                try {
                    Files.deleteIfExists(Paths.get(filePath));
                } catch (IOException e) {
                    log.warn("Failed to delete temp file: {}", filePath, e);
                }
            }
        }
        
        // 기타 자원 정리
        // …
    }
}

클라이언트 SDK 및 도우미 라이브러리

비동기 API의 사용성을 향상시키기 위한 클라이언트 SDK 예시.

  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
// 자바 클라이언트 SDK 예시
public class AsyncApiClient {
    
    private final String baseUrl;
    private final HttpClient httpClient;
    private final ObjectMapper objectMapper;
    private final ScheduledExecutorService scheduler;
    
    public AsyncApiClient(String baseUrl) {
        this.baseUrl = baseUrl;
        this.httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
        this.objectMapper = new ObjectMapper()
            .registerModule(new JavaTimeModule());
        this.scheduler = Executors.newScheduledThreadPool(1);
    }
    
    // 파일 변환 작업 시작
    public CompletableFuture<FileConversionResult> convertFile(File file, String targetFormat) {
        // 멀티파트 요청 작성
        HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + "/api/files/convert"))
            .header("Accept", "application/json");
            
        // 비동기 요청 제출
        return submitMultipartRequest(requestBuilder, file, targetFormat)
            .thenCompose(response -> {
                if (response.statusCode() == 202) {
                    // 작업 ID 파싱
                    FileConversionResponse conversionResponse = parseResponse(
                        response.body(), FileConversionResponse.class);
                    
                    // 작업 완료 대기
                    return waitForCompletion(conversionResponse.getId(), conversionResponse.getStatusUrl());
                } else {
                    throw new AsyncApiException("Failed to start conversion: " + response.statusCode());
                }
            });
    }
    
    // 작업 완료 대기 (폴링)
    private CompletableFuture<FileConversionResult> waitForCompletion(String jobId, String statusUrl) {
        CompletableFuture<FileConversionResult> resultFuture = new CompletableFuture<>();
        
        // 폴링 작업 스케줄링
        AtomicInteger attempts = new AtomicInteger(0);
        AtomicReference<ScheduledFuture<?>> futureRef = new AtomicReference<>();
        
        ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(() -> {
            try {
                // 상태 확인 요청
                HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(baseUrl + statusUrl))
                    .GET()
                    .header("Accept", "application/json")
                    .build();
                
                HttpResponse<String> response = httpClient.send(
                    request, HttpResponse.BodyHandlers.ofString());
                
                if (response.statusCode() == 200) {
                    FileConversionStatusResponse status = parseResponse(
                        response.body(), FileConversionStatusResponse.class);
                    
                    // 상태에 따른 처리
                    switch (status.getStatus()) {
                        case "COMPLETED":
                            // 작업 완료 - 결과 반환
                            FileConversionResult result = new FileConversionResult(
                                jobId,
                                status.getStatus(),
                                baseUrl + status.getDownloadUrl()
                            );
                            
                            resultFuture.complete(result);
                            cancelScheduledTask(futureRef.get());
                            break;
                            
                        case "FAILED":
                            // 작업 실패 - 예외 발생
                            resultFuture.completeExceptionally(
                                new AsyncJobFailedException(status.getMessage(), status.getError()));
                            cancelScheduledTask(futureRef.get());
                            break;
                            
                        case "PROCESSING":
                        case "PENDING":
                            // 진행 중 - 계속 대기
                            if (attempts.incrementAndGet() > maxAttempts) {
                                resultFuture.completeExceptionally(
                                    new AsyncJobTimeoutException("Job timed out after " + maxAttempts + " attempts"));
                                cancelScheduledTask(futureRef.get());
                            }
                            break;
                    }
                } else if (response.statusCode() == 404) {
                    resultFuture.completeExceptionally(
                        new AsyncJobNotFoundException("Job not found: " + jobId));
                    cancelScheduledTask(futureRef.get());
                } else {
                    if (attempts.incrementAndGet() > maxAttempts) {
                        resultFuture.completeExceptionally(
                            new AsyncApiException("Failed to get job status: " + response.statusCode()));
                        cancelScheduledTask(futureRef.get());
                    }
                }
            } catch (Exception e) {
                if (attempts.incrementAndGet() > maxRetries) {
                    resultFuture.completeExceptionally(e);
                    cancelScheduledTask(futureRef.get());
                }
            }
        }, 0, pollingIntervalMillis, TimeUnit.MILLISECONDS);
        
        futureRef.set(future);
        
        return resultFuture;
    }
    
    // 웹훅 기반 작업 처리
    public CompletableFuture<FileConversionResult> convertFileWithWebhook(
            File file, String targetFormat, WebhookHandler<FileConversionResult> webhookHandler) {
        
        CompletableFuture<FileConversionResult> resultFuture = new CompletableFuture<>();
        
        // 웹훅 핸들러 등록
        String webhookId = webhookHandler.register((json) -> {
            try {
                WebhookPayload payload = objectMapper.readValue(json, WebhookPayload.class);
                
                if ("COMPLETED".equals(payload.getStatus())) {
                    FileConversionResult result = new FileConversionResult(
                        payload.getJobId(),
                        payload.getStatus(),
                        baseUrl + payload.getDownloadUrl()
                    );
                    resultFuture.complete(result);
                } else if ("FAILED".equals(payload.getStatus())) {
                    resultFuture.completeExceptionally(
                        new AsyncJobFailedException(payload.getMessage(), payload.getError()));
                }
                
                return true;
            } catch (Exception e) {
                return false;
            }
        });
        
        // 요청 준비
        HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + "/api/files/convert?webhookUrl=" + 
                URLEncoder.encode(webhookHandler.getCallbackUrl() + "?id=" + webhookId, 
                    StandardCharsets.UTF_8)))
            .header("Accept", "application/json");
            
        // 비동기 요청 제출
        submitMultipartRequest(requestBuilder, file, targetFormat)
            .thenAccept(response -> {
                if (response.statusCode() != 202) {
                    resultFuture.completeExceptionally(
                        new AsyncApiException("Failed to start conversion: " + response.statusCode()));
                    webhookHandler.unregister(webhookId);
                }
            })
            .exceptionally(e -> {
                resultFuture.completeExceptionally(e);
                webhookHandler.unregister(webhookId);
                return null;
            });
            
        // 타임아웃 설정
        scheduler.schedule(() -> {
            if (!resultFuture.isDone()) {
                resultFuture.completeExceptionally(
                    new AsyncJobTimeoutException("Webhook callback timed out"));
                webhookHandler.unregister(webhookId);
            }
        }, webhookTimeoutSeconds, TimeUnit.SECONDS);
        
        return resultFuture;
    }
    
    // 헬퍼 메서드…
}

버전 관리 및 호환성

비동기 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
// API 버전 관리 예시
@RestController
@RequestMapping("/api/v1/jobs")
public class JobControllerV1 {
    
    private final JobService jobService;
    
    @PostMapping
    public ResponseEntity<JobResponseV1> createJob(@RequestBody JobRequestV1 request) {
        // 버전 1 요청 처리
        String jobId = jobService.createJob(request.toInternalModel());
        
        // 버전 1 응답 생성
        return ResponseEntity
            .accepted()
            .body(new JobResponseV1(jobId, "PENDING", "/api/v1/jobs/" + jobId));
    }
    
    @GetMapping("/{jobId}")
    public ResponseEntity<JobResponseV1> getJobStatus(@PathVariable String jobId) {
        Job job = jobService.getJob(jobId);
        
        if (job == null) {
            return ResponseEntity.notFound().build();
        }
        
        // 버전 1 응답 매핑
        return ResponseEntity.ok(JobResponseV1.fromInternalModel(job));
    }
}

@RestController
@RequestMapping("/api/v2/jobs")
public class JobControllerV2 {
    
    private final JobService jobService;
    
    @PostMapping
    public ResponseEntity<JobResponseV2> createJob(
            @RequestBody JobRequestV2 request,
            @RequestParam(required = false) String webhookUrl) {
        
        // 버전 2 요청 처리 (웹훅 지원 추가)
        String jobId = jobService.createJobWithWebhook(
            request.toInternalModel(), webhookUrl);
        
        // 버전 2 응답 생성 (추가 정보 포함)
        JobResponseV2 response = new JobResponseV2(
            jobId, 
            "PENDING", 
            "/api/v2/jobs/" + jobId,
            LocalDateTime.now(),
            webhookUrl != null
        );
        
        return ResponseEntity.accepted().body(response);
    }
    
    @GetMapping("/{jobId}")
    public ResponseEntity<JobResponseV2> getJobStatus(@PathVariable String jobId) {
        Job job = jobService.getJob(jobId);
        
        if (job == null) {
            return ResponseEntity.notFound().build();
        }
        
        // 버전 2 응답 매핑 (추가 정보 포함)
        return ResponseEntity.ok(JobResponseV2.fromInternalModel(job));
    }
    
    // 버전 2에서 추가된 엔드포인트
    @DeleteMapping("/{jobId}")
    public ResponseEntity<Void> cancelJob(@PathVariable String jobId) {
        boolean cancelled = jobService.cancelJob(jobId);
        
        if (!cancelled) {
            return ResponseEntity.notFound().build();
        }
        
        return ResponseEntity.noContent().build();
    }
}

// API 모델 버전 관리
public class JobRequestV1 {
    private String type;
    private Map<String, Object> parameters;
    
    // 내부 모델로 변환
    public JobRequest toInternalModel() {
        return new JobRequest(type, parameters);
    }
    
    // 생성자, 게터, 세터 등
}

public class JobRequestV2 {
    private String type;
    private Map<String, Object> parameters;
    private Integer priority;
    private String callbackId;
    
    // 내부 모델로 변환
    public JobRequest toInternalModel() {
        JobRequest internalRequest = new JobRequest(type, parameters);
        internalRequest.setPriority(priority);
        internalRequest.setCallbackId(callbackId);
        return internalRequest;
    }
    
    // 생성자, 게터, 세터 등
}

public class JobResponseV1 {
    private String id;
    private String status;
    private String statusUrl;
    
    // 내부 모델에서 변환
    public static JobResponseV1 fromInternalModel(Job job) {
        return new JobResponseV1(
            job.getId(),
            job.getStatus().toString(),
            "/api/v1/jobs/" + job.getId()
        );
    }
    
    // 생성자, 게터, 세터 등
}

public class JobResponseV2 {
    private String id;
    private String status;
    private String statusUrl;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private boolean webhookEnabled;
    private Float progress;
    
    // 내부 모델에서 변환
    public static JobResponseV2 fromInternalModel(Job job) {
        JobResponseV2 response = new JobResponseV2();
        response.setId(job.getId());
        response.setStatus(job.getStatus().toString());
        response.setStatusUrl("/api/v2/jobs/" + job.getId());
        response.setCreatedAt(job.getCreatedAt());
        response.setUpdatedAt(job.getUpdatedAt());
        response.setWebhookEnabled(job.getWebhookUrl() != null);
        response.setProgress(job.getProgress());
        return response;
    }
    
    // 생성자, 게터, 세터 등
}

용어 정리

용어설명

참고 및 출처