T_era

동기/비동기와 블로킹/논블로킹 완전 공략 본문

이론/백엔드 개념정리

동기/비동기와 블로킹/논블로킹 완전 공략

블스뜸 2025. 6. 27. 16:28

📚 목차

  1. 개념 이해
  2. 4가지 조합 패턴
  3. 실제 사용 예제
  4. 문제점과 해결방안
  5. Spring Boot에서의 활용

개념 이해

🤔 동기(Synchronous) vs 비동기(Asynchronous)

핵심 내용: "작업의 순서와 결과 처리는 어떻게 될까?"

동기 (Synchronous)

  • 정의: 각 작업의 시작과 끝이 일치하는 것
  • 특징: 함수를 호출하고 그 결과를 받은 후 다음 작업을 시작
  • 흐름: A → B → C (순차적 실행)
// 동기 방식 예제
public void synchronousExample() {
    String result1 = doTask1();  // Task1 완료까지 대기
    String result2 = doTask2();  // Task2 완료까지 대기  
    String result3 = doTask3();  // Task3 완료까지 대기

    System.out.println("모든 작업 완료: " + result1 + ", " + result2 + ", " + result3);
}

비동기 (Asynchronous)

  • 정의: 각 작업의 시작과 끝이 일치하지 않는 것
  • 특징: 함수를 호출하고 결과와 상관없이 남은 작업을 수행
  • 흐름: A, B, C 동시 시작 → 각각 완료 시점이 다름
// 비동기 방식 예제
public void asynchronousExample() {
    CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> doTask1());
    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> doTask2());
    CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> doTask3());

    // 모든 작업이 동시에 실행되고, 각각 완료되는 시점이 다름
    System.out.println("작업들이 백그라운드에서 실행 중...");
}

🤔 블로킹(Blocking) vs 논블로킹(Non-blocking)

핵심 내용: "작업이 진행되는 동안 나는 어떤 상태일까?"

블로킹 (Blocking)

  • 정의: 어떤 작업을 호출한 후 그 작업이 완료되기를 기다림
  • 특징: 제어권이 호출된 함수에게 넘어감
  • 상태: 대기 상태 (CPU 자원 낭비 가능)
// 블로킹 예제
public void blockingExample() {
    System.out.println("파일 읽기 시작...");
    String content = Files.readString(Path.of("large-file.txt")); // 여기서 멈춤
    System.out.println("파일 읽기 완료: " + content.length() + "자");
    // 파일 읽기가 완료될 때까지 다음 줄 실행 안됨
}

논블로킹 (Non-blocking)

  • 정의: 어떤 작업을 호출한 후 그 작업이 완료되는 것을 기다리지 않고 내 작업을 진행
  • 특징: 제어권이 호출자에게 유지됨
  • 상태: 다른 작업 수행 가능
// 논블로킹 예제
public void nonBlockingExample() {
    System.out.println("파일 읽기 시작...");
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        try {
            return Files.readString(Path.of("large-file.txt"));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    });

    System.out.println("파일 읽기가 백그라운드에서 실행 중...");
    System.out.println("다른 작업들을 계속 수행할 수 있습니다!");

    // 나중에 결과가 필요할 때
    String content = future.join();
    System.out.println("파일 읽기 완료: " + content.length() + "자");
}

4가지 조합 패턴

1️⃣ 동기 + 블로킹 (Synchronous + Blocking)

가장 익숙한 형태의 프로그래밍

public class SyncBlockingExample {

    public void syncBlocking() {
        System.out.println("=== 동기-블로킹 예제 ===");

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) {
            System.out.print("입력하세요: ");
            // 사용자가 입력할 때까지 대기(블로킹)
            String input = reader.readLine(); 

            // 데이터를 받으면 그 다음 작업 실행(동기)
            System.out.println("입력된 값: " + input);
            processInput(input);

        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void processInput(String input) {
        System.out.println("입력 처리 중: " + input.toUpperCase());
    }
}

🤔 질문: 이 방식의 장단점은 무엇일까?

  • 장점: 코드가 직관적이고 이해하기 쉬움
  • 단점: I/O 작업 중 CPU가 놀고 있음 (자원 낭비)

2️⃣ 동기 + 논블로킹 (Synchronous + Non-blocking)

폴링(Polling) 방식

public class SyncNonBlockingExample {

    public void syncNonBlocking() {
        System.out.println("=== 동기-논블로킹 예제 ===");

        try (FileChannel channel = FileChannel.open(Path.of("large-file.txt"), StandardOpenOption.READ)) {
            ByteBuffer buffer = ByteBuffer.allocate(1024);

            while (true) {
                int bytesRead = channel.read(buffer); // 논블로킹: 결과 즉시 반환

                if (bytesRead > 0) {
                    // 데이터를 읽었음
                    System.out.println("읽은 바이트: " + bytesRead);
                    buffer.flip();

                    // 데이터 처리
                    processData(buffer);
                    buffer.clear();

                } else if (bytesRead == 0) {
                    System.out.println("데이터 준비 중...");

                    // 다른 작업 수행 (동기)
                    doOtherWork();

                    // 잠시 대기 후 다시 시도
                    Thread.sleep(100);

                } else {
                    // EOF (-1) - 파일 끝
                    System.out.println("파일 읽기 완료");
                    break;
                }
            }
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }

    private void processData(ByteBuffer buffer) {
        // 데이터 처리 로직
        System.out.println("데이터 처리 중...");
    }

    private void doOtherWork() {
        System.out.println("다른 작업 수행 중...");
    }
}

🤔 질문: 폴링 방식의 문제점은 무엇일까?

  • 문제점: CPU 자원을 계속 사용하여 폴링 (비효율적)
  • 해결책: 적절한 대기 시간 설정 필요

3️⃣ 비동기 + 블로킹 (Asynchronous + Blocking)

병렬 작업 후 대기

public class AsyncBlockingExample {

    public void asyncBlocking() {
        System.out.println("=== 비동기-블로킹 예제 ===");

        // 여러 일을 시작 (비동기)
        CompletableFuture<String> job1 = startJob("피자 주문", 3000);
        CompletableFuture<String> job2 = startJob("치킨 주문", 2000);
        CompletableFuture<String> job3 = startJob("음료 주문", 1000);

        System.out.println("모든 주문이 진행 중입니다...");

        try {
            // 모든 일이 끝날 때까지 대기 (블로킹)
            CompletableFuture.allOf(job1, job2, job3).get(); 

            System.out.println("모든 음식이 도착했습니다!");
            System.out.println("결과: " + job1.get() + ", " + job2.get() + ", " + job3.get());

        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }

    private CompletableFuture<String> startJob(String jobName, int delay) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                System.out.println(jobName + " 시작...");
                Thread.sleep(delay);
                System.out.println(jobName + " 완료!");
                return jobName + " 완료";
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
    }
}

🤔 질문: 이 방식은 언제 유용할까?

  • 유용한 경우: 여러 독립적인 작업을 병렬로 실행하고 모든 결과가 필요한 경우
  • 주의사항: 하나라도 실패하면 전체가 블로킹될 수 있음

4️⃣ 비동기 + 논블로킹 (Asynchronous + Non-blocking)

가장 효율적인 방식

public class AsyncNonBlockingExample {

    public void asyncNonBlocking() {
        System.out.println("=== 비동기-논블로킹 예제 ===");

        // 여러 일을 시작 (비동기)
        startJob("피자 주문", 3000).thenAccept(result -> handleResult(result));
        startJob("치킨 주문", 2000).thenAccept(result -> handleResult(result));
        startJob("음료 주문", 1000).thenAccept(result -> handleResult(result)); 

        System.out.println("모든 주문 완료! 다른 일을 계속 합니다...");

        // 다른 작업 계속 수행 (논블로킹)
        for (int i = 1; i <= 10; i++) {
            System.out.println("다른 작업 " + i + "번 수행 중...");
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }

        System.out.println("프로그램 종료");
    }

    private CompletableFuture<String> startJob(String jobName, int delay) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                System.out.println(jobName + " 시작...");
                Thread.sleep(delay);
                System.out.println(jobName + " 완료!");
                return jobName + " 완료";
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
    }

    // 작업이 끝나는 대로 즉시 호출됨 (논블로킹)
    private void handleResult(String result) {
        System.out.println("🎉 [즉시처리] " + result + " - 바로 고객에게 전달!");
    }
}

🤔 질문: 이 방식이 가장 효율적인 이유는?

  • 이유: CPU 자원을 최대한 활용하면서도 응답성을 유지
  • 장점: 높은 처리량과 낮은 지연시간

실제 사용 예제

🌐 Spring Boot에서의 비동기 처리

@RestController
@RequestMapping("/api/async")
public class AsyncController {

    @Autowired
    private AsyncService asyncService;

    // 비동기 + 블로킹 예제
    @GetMapping("/users")
    public CompletableFuture<List<User>> getUsersAsync() {
        return asyncService.getUsersAsync();
    }

    // 동기 + 블로킹 예제 (기존 방식)
    @GetMapping("/users-sync")
    public List<User> getUsersSync() {
        return asyncService.getUsersSync();
    }

    // 여러 작업을 병렬로 처리
    @GetMapping("/dashboard")
    public CompletableFuture<DashboardData> getDashboardData() {
        CompletableFuture<List<User>> usersFuture = asyncService.getUsersAsync();
        CompletableFuture<List<Order>> ordersFuture = asyncService.getOrdersAsync();
        CompletableFuture<Statistics> statsFuture = asyncService.getStatisticsAsync();

        return CompletableFuture.allOf(usersFuture, ordersFuture, statsFuture)
            .thenApply(v -> new DashboardData(
                usersFuture.join(),
                ordersFuture.join(),
                statsFuture.join()
            ));
    }
}

🔄 WebFlux와 리액티브 프로그래밍

@RestController
@RequestMapping("/api/reactive")
public class ReactiveController {

    @Autowired
    private ReactiveService reactiveService;

    // 리액티브 스트림 (논블로킹)
    @GetMapping("/users")
    public Flux<User> getUsersReactive() {
        return reactiveService.getUsersFlux();
    }

    // 백프레셔 지원
    @GetMapping("/users/stream")
    public Flux<User> streamUsers() {
        return reactiveService.getUsersStream()
            .onBackpressureBuffer(1000) // 백프레셔 처리
            .delayElements(Duration.ofMillis(100)); // 속도 제어
    }
}

문제점과 해결방안

🚨 주요 문제점들

1. 콜백 지옥 (Callback Hell)

// 문제가 있는 코드
public void callbackHell() {
    asyncTask1().thenAccept(result1 -> {
        asyncTask2(result1).thenAccept(result2 -> {
            asyncTask3(result2).thenAccept(result3 -> {
                asyncTask4(result3).thenAccept(result4 -> {
                    System.out.println("최종 결과: " + result4);
                });
            });
        });
    });
}

해결방안: CompletableFuture 체이닝

public void cleanAsyncChain() {
    asyncTask1()
        .thenCompose(this::asyncTask2)
        .thenCompose(this::asyncTask3)
        .thenCompose(this::asyncTask4)
        .thenAccept(result -> System.out.println("최종 결과: " + result))
        .exceptionally(throwable -> {
            System.err.println("에러 발생: " + throwable.getMessage());
            return null;
        });
}

2. 스레드 풀 관리

@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("Async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new SimpleAsyncUncaughtExceptionHandler();
    }
}

3. 타임아웃 처리

public CompletableFuture<String> withTimeout() {
    return CompletableFuture.supplyAsync(() -> {
        // 긴 작업
        try {
            Thread.sleep(5000);
            return "작업 완료";
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).orTimeout(3, TimeUnit.SECONDS) // 3초 타임아웃
      .exceptionally(throwable -> {
          if (throwable instanceof TimeoutException) {
              return "타임아웃 발생";
          }
          return "에러 발생: " + throwable.getMessage();
      });
}

🎯 성능 최적화 팁

  1. 적절한 스레드 풀 크기 설정
    • CPU 바운드: CPU 코어 수 + 1
    • I/O 바운드: CPU 코어 수 * 2
  2. 백프레셔 처리
    • 무한 스트림 방지
    • 메모리 사용량 제어
  3. 에러 처리
    • Circuit Breaker 패턴 적용
    • 재시도 로직 구현

Spring Boot에서의 활용

📦 의존성 추가

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'io.projectreactor:reactor-core'
}

⚙️ 설정

@Configuration
@EnableAsync
public class AsyncConfiguration {

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("AsyncTask-");
        executor.initialize();
        return executor;
    }
}

🔧 실제 서비스 구현

@Service
public class UserService {

    @Async
    public CompletableFuture<List<User>> getUsersAsync() {
        // 비동기로 사용자 목록 조회
        return CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(1000); // DB 조회 시뮬레이션
                return Arrays.asList(
                    new User(1L, "김철수"),
                    new User(2L, "이영희")
                );
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
    }

    public Flux<User> getUsersReactive() {
        return Flux.fromIterable(Arrays.asList(
            new User(1L, "김철수"),
            new User(2L, "이영희")
        )).delayElements(Duration.ofMillis(100));
    }
}

📝 정리

🎯 언제 어떤 방식을 사용할까?

상황 권장 방식 이유
간단한 순차 작업 동기 + 블로킹 코드가 직관적이고 이해하기 쉬움
I/O 작업이 많은 경우 비동기 + 논블로킹 CPU 자원을 효율적으로 활용
여러 독립 작업 비동기 + 블로킹 병렬 처리로 성능 향상
실시간 스트리밍 리액티브 스트림 백프레셔와 메모리 효율성

🤔 마지막 질문들

  1. Q: 항상 비동기 + 논블로킹이 최선인가?
    • A: 아니요. 상황에 따라 적절한 방식을 선택해야 한다.
  2. Q: 동시성 문제는 어떻게 해결하나요?
    • A: synchronized, volatile, Atomic 클래스, Lock 등을 사용한다.
  3. Q: 디버깅이 어려운데 어떻게 하나요?
    • A: 로깅, 메트릭, 분산 추적 도구를 활용한다.