Skip to main content

Command Palette

Search for a command to run...

Spring @Async 내부 동작 — AsyncExecutionInterceptor, TaskExecutor, 그리고 반환 타입의 비밀

Updated
11 min read

메서드 하나에 @Async만 붙이면 비동기로 실행되는데, 정작 그 뒤에서 무슨 일이 벌어지는지 설명해 보라고 하면 막막해져요. 이 글은 Spring AOP 프록시가 메서드 호출을 어떻게 가로채고, 어떤 Executor를 골라서 어떤 식으로 결과를 감싸 돌려주는지 한 단계씩 따라가는 글이에요. Spring Framework 6/7과 Spring Boot 3.x 기준으로 정리했어요.


한 줄 어노테이션의 의문

서비스 클래스에 @Async만 붙이면, 호출자는 즉시 반환되고 메서드는 다른 스레드에서 돌아가요.

@Service
public class ReportService {

    @Async
    public CompletableFuture<Report> generate(long userId) {
        Report report = heavyComputation(userId);
        return CompletableFuture.completedFuture(report);
    }
}

호출하는 쪽 코드는 이렇게 생겼어요.

CompletableFuture<Report> future = reportService.generate(42L);
future.thenAccept(this::send);

여기서 자연스럽게 떠오르는 질문이 몇 가지 있어요.

  • generate()는 분명 동기 메서드인데, 왜 즉시 반환될까요?
  • CompletableFuture.completedFuture()로 반환한 값은 이미 완료된 future일 텐데, 왜 호출 시점부터 비동기일까요?
  • 어느 스레드 풀에서 실행되는 걸까요? 직접 만든 적이 없는데요?
  • void를 반환하는 @Async 메서드에서 예외가 나면 어디로 갈까요?

이 질문들의 답은 모두 한 클래스의 한 메서드 — AsyncExecutionInterceptor.invoke() — 에 있어요. 거기로 가는 길을 따라가 봐요.


전체 그림

@Async 메서드 한 번 호출이 일어나면 다음 다섯 단계가 차례로 실행돼요.

flowchart TD
    A[Caller invokes proxy.generate] --> B[AsyncExecutionInterceptor.invoke]
    B --> C[determineAsyncExecutor]
    C --> D[Wrap real call as Callable]
    D --> E[doSubmit by return type]
    E --> F[executor.submitCompletable]
    F --> G[Caller receives CompletableFuture immediately]
    G --> H[Worker thread runs target method]
    H --> I[Result completes the future]

캐릭터는 셋이에요. AOP 프록시가 가로채고, AsyncExecutionInterceptor가 작업으로 감싸고, AsyncTaskExecutor가 다른 스레드에서 실행해요. 이 분업이 어떻게 짜여 있는지 하나씩 봐요.


1단계 — @EnableAsync가 켜는 인프라

@Async만 붙인다고 알아서 동작하지 않아요. 어딘가에서 @EnableAsync로 스위치를 켜야 해요. Spring Boot라면 spring.task.execution.* 자동 설정이 그 역할을 대신해 주지만, 본질은 같아요.

@EnableAsync 소스를 들여다보면 끝에 한 줄이 있어요.

@Import(AsyncConfigurationSelector.class)
public @interface EnableAsync {
    Class<? extends Annotation> annotation() default Annotation.class;
    boolean proxyTargetClass() default false;
    AdviceMode mode() default AdviceMode.PROXY;
    int order() default Ordered.LOWEST_PRECEDENCE;
}

AdviceMode.PROXY(기본값)일 때 AsyncConfigurationSelectorProxyAsyncConfiguration을 골라 등록해요. 그 안에 등장하는 단 하나의 빈이 모든 일의 시작이에요.

@Configuration(proxyBeanMethods = false)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration {

    @Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME)
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public AsyncAnnotationBeanPostProcessor asyncAdvisor() {
        AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
        bpp.configure(this.executor, this.exceptionHandler);
        // ...
        return bpp;
    }
}

AsyncAnnotationBeanPostProcessor가 모든 빈을 훑으면서 @Async가 붙은 클래스나 메서드를 발견하면 거기에 프록시를 씌워요. 이미 다른 advisor가 만들어 둔 프록시가 있으면 거기에 advisor를 한 줄 더 끼워 넣어요. 새로 만들어야 한다면 인터페이스 기반 JDK 동적 프록시(또는 proxyTargetClass=true일 때 CGLIB)를 만들어요.

붙는 advisor는 AsyncAnnotationAdvisor 하나예요.


2단계 — Advisor가 들고 있는 두 부품, Pointcut과 Advice

AsyncAnnotationAdvisor는 Spring AOP의 advisor 그대로, 언제 끼어들지(pointcut)끼어들어서 뭘 할지(advice) 두 가지를 들고 있어요.

public AsyncAnnotationAdvisor(
        @Nullable Supplier<? extends @Nullable Executor> executor,
        @Nullable Supplier<? extends @Nullable AsyncUncaughtExceptionHandler> exceptionHandler) {

    Set<Class<? extends Annotation>> asyncAnnotationTypes = CollectionUtils.newLinkedHashSet(2);
    asyncAnnotationTypes.add(Async.class);

    ClassLoader classLoader = AsyncAnnotationAdvisor.class.getClassLoader();
    try {
        asyncAnnotationTypes.add((Class<? extends Annotation>)
                ClassUtils.forName("jakarta.ejb.Asynchronous", classLoader));
    } catch (ClassNotFoundException ex) {
        // EJB API가 없으면 무시
    }
    try {
        asyncAnnotationTypes.add((Class<? extends Annotation>)
                ClassUtils.forName("jakarta.enterprise.concurrent.Asynchronous", classLoader));
    } catch (ClassNotFoundException ex) {
        // Jakarta Concurrent API가 없으면 무시
    }

    this.advice = buildAdvice(executor, exceptionHandler);
    this.pointcut = buildPointcut(asyncAnnotationTypes);
}

여기서 두 가지가 눈에 띄어요.

  • 기본 인식 어노테이션이 Spring의 @Async 하나가 아니라, EJB 3.1의 @jakarta.ejb.Asynchronous, Jakarta Concurrent의 @jakarta.enterprise.concurrent.Asynchronous까지 셋이에요. 클래스패스에 있을 때만 추가해요.
  • pointcut은 클래스 레벨과 메서드 레벨을 둘 다 매칭하는 합성(composite) pointcut이에요.

pointcut 빌더는 이렇게 생겼어요.

protected Pointcut buildPointcut(Set<Class<? extends Annotation>> asyncAnnotationTypes) {
    ComposablePointcut result = null;
    for (Class<? extends Annotation> asyncAnnotationType : asyncAnnotationTypes) {
        Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true);
        Pointcut mpc = new AnnotationMatchingPointcut(null, asyncAnnotationType, true);
        if (result == null) {
            result = new ComposablePointcut(cpc);
        } else {
            result.union(cpc);
        }
        result = result.union(mpc);
    }
    return (result != null ? result : Pointcut.TRUE);
}

cpc클래스에 어노테이션이 붙은 경우, mpc메서드에 붙은 경우를 매칭해요. union으로 합쳐서 둘 중 하나라도 매칭되면 advisor가 발동해요. 그래서 @Async를 클래스에 붙이면 그 안의 public 메서드 모두가 비동기가 되고, 메서드에만 붙이면 그 메서드만 비동기가 돼요.


3단계 — AsyncExecutionInterceptor.invoke()의 안쪽

pointcut에 걸리면 호출이 AsyncExecutionInterceptor로 들어와요. 이 클래스는 AOP Alliance의 MethodInterceptor라서 invoke() 메서드 하나로 전부 표현돼요.

@Override
public @Nullable Object invoke(final MethodInvocation invocation) throws Throwable {
    Class<?> targetClass = (invocation.getThis() != null
            ? AopUtils.getTargetClass(invocation.getThis()) : null);
    final Method userMethod = BridgeMethodResolver.getMostSpecificMethod(
            invocation.getMethod(), targetClass);
    AsyncTaskExecutor executor = determineAsyncExecutor(userMethod);
    if (executor == null) {
        throw new IllegalStateException(
                "No executor specified and no default executor set on AsyncExecutionInterceptor either");
    }
    Callable<Object> task = () -> {
        try {
            Object result = invocation.proceed();
            if (result instanceof Future<?> future) {
                return future.get();
            }
        } catch (ExecutionException ex) {
            Throwable cause = ex.getCause();
            handleError(cause == null ? ex : cause, userMethod, invocation.getArguments());
        } catch (Throwable ex) {
            handleError(ex, userMethod, invocation.getArguments());
        }
        return null;
    };
    return doSubmit(task, executor, userMethod.getReturnType());
}

흐름이 거의 명료해요. 정리해 봐요.

  1. 메서드 해석: 타깃 클래스에서 가장 구체적인 메서드를 찾아내요. 제네릭 브릿지 메서드를 거르는 역할이에요.
  2. Executor 선택: determineAsyncExecutor()로 이 메서드에 쓸 AsyncTaskExecutor를 결정해요. (자세한 건 4단계)
  3. 작업 객체 생성: 진짜 호출(invocation.proceed())을 Callable로 감싸요. 이 Callable이 워커 스레드에서 돌아갈 본체예요.
  4. 제출: doSubmit()이 반환 타입에 맞춰 Executor에 작업을 던져요. (자세한 건 5단계)

여기서 핵심 포인트가 두 가지 있어요.

invocation.proceed()는 호출 시점이 아니라 워커 스레드에서 실행돼요

generate() 안에서 CompletableFuture.completedFuture(...)로 즉시 완료된 future를 만들어도, invocation.proceed() 자체가 워커 스레드 안의 람다에서 호출돼요. 그래서 메서드 본문 전체가 다른 스레드에서 실행되고, 호출자는 즉시 빈 future(또는 곧 채워질 future)를 받아요.

메서드가 또 Future를 반환하면 결과를 풀어서 다시 감싸요

람다 안에 if (result instanceof Future<?> future) { return future.get(); } 가 있어요. 사용자의 generate()가 이미 CompletableFuture<Report>를 반환하지만, 그 안에서 future.get()을 호출해서 실제 값을 꺼내요. 그러고 나서 바깥 단계인 doSubmit()이 이 값을 새 CompletableFuture에 다시 넣어요. 결과적으로 호출자에게 돌아가는 future와 메서드가 반환한 future는 서로 다른 객체예요.


4단계 — Executor 선택 규칙

protected @Nullable AsyncTaskExecutor determineAsyncExecutor(Method method) {
    AsyncTaskExecutor executor = this.executors.get(method);
    if (executor == null) {
        Executor targetExecutor;
        String qualifier = getExecutorQualifier(method);
        if (this.embeddedValueResolver != null && StringUtils.hasLength(qualifier)) {
            qualifier = this.embeddedValueResolver.resolveStringValue(qualifier);
        }
        if (StringUtils.hasLength(qualifier)) {
            targetExecutor = findQualifiedExecutor(this.beanFactory, qualifier);
        } else {
            targetExecutor = this.defaultExecutor.get();
        }
        if (targetExecutor == null) {
            return null;
        }
        executor = (targetExecutor instanceof AsyncTaskExecutor asyncTaskExecutor
                ? asyncTaskExecutor : new TaskExecutorAdapter(targetExecutor));
        this.executors.put(method, executor);
    }
    return executor;
}

메서드별로 결과가 캐시돼요(ConcurrentHashMap<Method, AsyncTaskExecutor>). 두 번째 호출부터는 lookup 한 번이면 끝이에요.

선택 순서는 단순해요.

  1. @Async("myExecutor")처럼 qualifier가 있으면 그 이름의 Executor 빈을 찾아요. SpEL이나 ${} 플레이스홀더도 지원해요.
  2. qualifier가 없으면 defaultExecutor로 떨어져요.

defaultExecutor는 또 어디서 올까요? AsyncExecutionAspectSupport.getDefaultExecutor()에 단계별로 정의돼 있어요.

protected @Nullable Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) {
    if (beanFactory != null) {
        try {
            return beanFactory.getBean(TaskExecutor.class);
        } catch (NoUniqueBeanDefinitionException ex) {
            // TaskExecutor 빈이 여러 개면 'taskExecutor' 이름으로 다시 시도
            try {
                return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class);
            } catch (NoSuchBeanDefinitionException ex2) {
                // 'taskExecutor'라는 이름의 빈도 없으면 INFO 로그
            }
        } catch (NoSuchBeanDefinitionException ex) {
            // TaskExecutor 타입이 아예 없으면 'taskExecutor' 이름의 Executor를 찾음
            try {
                return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class);
            } catch (NoSuchBeanDefinitionException ex2) {
                // 둘 다 없으면 null 반환
            }
        }
    }
    return null;
}

여기서 DEFAULT_TASK_EXECUTOR_BEAN_NAME = "taskExecutor"예요. 정리하면 우선순위가 이래요.

  1. 컨텍스트에 유일한 TaskExecutor 빈이 있으면 그것.
  2. 여럿이면 그중 "taskExecutor"라는 이름을 가진 것.
  3. 없으면 "taskExecutor"라는 이름의 Executor 빈.
  4. 그것도 없으면 null. 이 경우 AsyncExecutionInterceptor가 한 번 더 fallback해서 새 SimpleAsyncTaskExecutor를 만들어요(서브클래스인 AsyncExecutionInterceptor.getDefaultExecutor()의 추가 동작).

Spring Boot 3.x는 자동 설정으로 applicationTaskExecutor라는 이름의 ThreadPoolTaskExecutor를 등록하고, 동시에 "taskExecutor"라는 별칭(alias)을 부여해요. 그래서 별도 설정 없이도 @Async가 풀(pool) 기반으로 돌아가는 거예요. spring.task.execution.* 속성으로 코어 풀 사이즈·큐 용량·스레드 이름 등을 조절할 수 있어요.


5단계 — 반환 타입에 따른 doSubmit()

흐름의 마지막 조각이에요.

protected @Nullable Object doSubmit(Callable<Object> task, AsyncTaskExecutor executor, Class<?> returnType) {
    if (CompletableFuture.class.isAssignableFrom(returnType)) {
        return executor.submitCompletable(task);
    } else if (Future.class.isAssignableFrom(returnType)) {
        return executor.submit(task);
    } else if (void.class == returnType || "kotlin.Unit".equals(returnType.getName())) {
        executor.submit(task);
        return null;
    } else {
        throw new IllegalArgumentException(
                "Invalid return type for async method (only Future and void supported): " + returnType);
    }
}

분기는 네 가지뿐이에요.

반환 타입 실제 호출 호출자에게 돌아가는 객체
CompletableFuture<T> executor.submitCompletable(task) CompletableFuture
Future<T> (구버전 호환) executor.submit(task) Future
void 또는 Kotlin Unit executor.submit(task) (반환 버림) null
그 외 즉시 IllegalArgumentException 호출 자체가 실패

세 가지 시사점이 있어요.

사용자가 반환한 future는 버려져요

3단계에서 본 람다는 사용자의 future를 future.get()으로 풀어서 값만 꺼냈어요. 그 값을 다시 submitCompletable()이 만든 새 future에 넣어서 호출자에게 돌려줘요. 즉 호출자가 받는 CompletableFuture는 사용자가 반환한 그것이 아니에요. 결과 값은 같지만 객체는 달라요. 이걸 모르면 future를 두 번 합성하면서 혼란을 겪기 쉬워요.

ListenableFuture는 Spring 6.0부터 deprecated

이전엔 org.springframework.util.concurrent.ListenableFuture 가 별도 갈래로 있었어요. Spring 6.0부터 deprecated 됐고, 표준 CompletableFuture로 일원화돼요. 신규 코드는 CompletableFuture만 쓰는 게 안전해요.

반환 타입이 잘못되면 호출 시점에 예외

리스트나 String을 반환하는 메서드에 무심코 @Async를 붙이면, 사용자 코드에서 메서드를 호출하는 그 순간 IllegalArgumentException이 던져져요. 컴파일에서 잡히지 않으니 통합 테스트로 검증하는 게 안전해요.


void 메서드의 예외는 어디로 가나요

Future를 반환하는 메서드는 호출자가 future.get()을 부르는 시점에 예외가 다시 던져져요. 그런데 void라면 호출자가 결과를 받지 않는데, 워커 스레드에서 던진 예외는 어디로 갈까요?

AsyncExecutionAspectSupport.handleError()가 그 분기를 책임져요.

protected void handleError(Throwable ex, Method method, @Nullable Object... params) throws Exception {
    if (Future.class.isAssignableFrom(method.getReturnType())) {
        ReflectionUtils.rethrowException(ex);
    } else {
        try {
            this.exceptionHandler.obtain().handleUncaughtException(ex, method, params);
        } catch (Throwable ex2) {
            logger.warn("Exception handler for async method '" + method.toGenericString() +
                    "' threw unexpected exception itself", ex2);
        }
    }
}

Future면 다시 던져서 future를 실패 상태로 만들고, 그게 아니면 AsyncUncaughtExceptionHandler.handleUncaughtException()을 호출해요. 기본 구현은 SimpleAsyncUncaughtExceptionHandler로, 스택 트레이스만 ERROR 로그에 남기고 끝나요.

조용히 사라지지 않게 하려면 AsyncConfigurer를 구현해서 핸들러를 갈아끼우면 돼요.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(32);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("report-async-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            // 로그 + 알림 시스템 전송 등
            log.error("Async method {} failed with args {}", method, params, ex);
        };
    }
}

같은 클래스의 메서드를 부르면 왜 비동기가 안 될까요

가장 자주 마주치는 함정이에요.

@Service
public class ReportService {

    public void run(long userId) {
        this.generate(userId); // 비동기로 안 돌아요
    }

    @Async
    public CompletableFuture<Report> generate(long userId) { ... }
}

run()이 호출하는 this.generate()프록시를 거치지 않아요. this는 실제 타깃 객체를 가리키지, 그 위를 감싸고 있는 AOP 프록시가 아니에요. AOP advice는 프록시 메서드를 거쳐야만 끼어드는데, this.generate()는 그 길을 우회해요. 결과적으로 AsyncExecutionInterceptor가 한 번도 호출되지 않고, 그냥 호출 스레드에서 동기 실행돼요.

AopContext.currentProxy()로 프록시를 끄집어내는 방법이 있긴 해요. 다만 두 가지 제약이 있어요.

  • @EnableAspectJAutoProxy(exposeProxy = true)로 프록시를 ThreadLocal에 노출해야 해요. 성능 비용이 있어서 기본은 false예요.
  • @Async 메서드가 다른 스레드에서 다시 자기 자신을 부른다면, ThreadLocal에 들어 있던 프록시 참조는 이미 사라졌어요. 워커 스레드는 그 ThreadLocal을 못 봐요.

가장 깨끗한 해결은 메서드를 다른 빈으로 분리하는 거예요. 그러면 호출이 자연스럽게 프록시를 통과해요.


Virtual Thread 시대의 @Async

Spring Boot 3.2(Spring Framework 6.1)부터 가상 스레드를 한 줄로 켤 수 있어요.

spring:
  threads:
    virtual:
      enabled: true

이게 켜지면 자동 설정이 applicationTaskExecutor로 가상 스레드를 사용하는 SimpleAsyncTaskExecutor(setVirtualThreads(true) 적용)를 만들어요. @Async 메서드는 풀에서 미리 뜬 플랫폼 스레드 대신, 호출마다 새로 만들어지는 가상 스레드 위에서 실행돼요.

이때 동작은 이렇게 바뀌어요.

  • 풀 사이즈·큐 용량 같은 개념이 없어져요. 가상 스레드는 매번 새로 만들어요.
  • I/O 블로킹에서 캐리어 플랫폼 스레드를 점유하지 않아요. 그래서 동시 처리 가능한 비동기 작업 수가 사실상 OS·메모리 한계까지 늘어나요.
  • 다만 synchronized 블록에서는 여전히 캐리어 스레드를 핀(pin)할 수 있어요. JEP 491에서 이 제약이 완화되고 있어요.

AsyncExecutionInterceptor의 코드 경로는 똑같아요. 바뀌는 건 executor 자리에 어떤 AsyncTaskExecutor가 꽂히느냐 뿐이에요. 인터셉터 입장에서 executor.submit(task)는 그대로예요.


정리

@Async 한 줄 뒤의 흐름을 다시 한눈에 압축해 봐요.

flowchart LR
    A["@EnableAsync"] --> B[AsyncAnnotationBeanPostProcessor]
    B --> C[Wrap bean with proxy]
    C --> D[AsyncAnnotationAdvisor]
    D --> E[AsyncExecutionInterceptor.invoke]
    E --> F[determineAsyncExecutor]
    F --> G[doSubmit by return type]
    G --> H[AsyncTaskExecutor.submitCompletable]

핵심을 다시 정리하면 이래요.

  • @EnableAsyncAsyncAnnotationBeanPostProcessor를 등록해서, @Async 빈에 AsyncAnnotationAdvisor를 끼운 프록시를 씌워요.
  • pointcut은 클래스·메서드 어노테이션을 합쳐서 매칭해요. Spring @Async 외에 jakarta.ejb.Asynchronous, jakarta.enterprise.concurrent.Asynchronous도 자동 인식해요.
  • AsyncExecutionInterceptor.invoke()가 진짜 호출을 Callable로 감싸서 AsyncTaskExecutor에 던져요.
  • Executor는 메서드의 qualifier > 유일한 TaskExecutor 빈 > "taskExecutor" 이름 빈 > SimpleAsyncTaskExecutor fallback 순서로 결정돼요. 결과는 메서드별로 캐시돼요.
  • 반환 타입은 CompletableFuture / Future / void(또는 Kotlin Unit)만 허용돼요. 그 외엔 호출 시점에 IllegalArgumentException이에요.
  • void로 던진 예외는 AsyncUncaughtExceptionHandler로 흘러가요. 기본 핸들러는 로그만 남겨요.
  • 같은 클래스 안의 자기 호출은 프록시를 우회하니까, 비동기로 돌리려면 메서드를 다른 빈으로 분리하는 게 가장 깨끗해요.
  • Spring Boot 3.2 + Java 21에서 spring.threads.virtual.enabled=true만 켜면 가상 스레드 기반 Executor로 갈아끼워져요. 인터셉터 코드는 그대로예요.

@Async는 외부에서 보면 어노테이션 한 줄짜리 마법처럼 보이지만, 뜯어보면 Spring AOP의 일반적인 advisor + 평범한 Callable 제출 + Executor 추상화의 합성품이에요. 한 번 흐름을 따라가 보면, 자기 호출 함정이나 반환 타입 제약 같은 동작 원리가 자연스럽게 이해돼요.


참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ