왜 이걸 알아야 할까?

개발 중에 이런 상황을 겪어볼 수 있다.

  • 스레드가 고갈돼서 서버가 멈춘다.
  • @Async 붙였는데 오히려 느려졌다.
  • WebClient 썼는데 로그 순서가 뒤죽박죽이다.
  • ThreadLocal 데이터가 갑자기 사라진다.

이 문제들의 공통점은 "스레드" 이다.

 

내가 만든 코드가 실제로 어떤 스레드에서 돌아가고 있는지 모른다면, 성능파악과 튜닝도 어려울것이다.

 

그래서 HTTP 요청 하나당 실제로 몇 개의 스레드가 작동하는지 환경별로 한번 알아보자.

 

환경 설명
Spring MVC 기본 동기 처리(Tomcat)
Spring MVC + @Async 일부 작업 비동기 분리
Spring MVC + WebClient 외부 API 호출 시 비동기
WebFlux + Netty 전체 논블로킹, 이벤트 기반

 

 

요청 하나 = 스레드 하나?는 옛말?

보통 아래와 같이 생각한다.

 

이 구조는 맞다 그런데 여기에 다음이 붙는 순간 스레드가 복제된다.

  • @Async 사용
  • WebClient 비동기 콜백
  • 로깅 비동기 처리
  • Netty 기반 이벤트 루프

각 케이스를 로그와 함께 한번 알아보자.

 

 

1. [Spring MVC] 동기 방식

@RestController
public class TestController {

    @GetMapping("/sync")
    public String sync() {
        log.info("스레드: {}", Thread.currentThread().getName());
        return "ok";
    }
}

 

실행 결과 

 

  • Controller → Service → Repository → 응답까지 모두 하나의 워커 스레드(exec-4) 에서 작동한다.
  • 요청 하나 = 스레드 하나 (정확)

 

 

2. [Spring MVC + @Async] - 작업을 나눴더니 스레드가 늘어났다.

@Service
public class MyService {
    @Async
    public void doAsync() {
        log.info("비동기 스레드: {}", Thread.currentThread().getName());
    }
}

@GetMapping("/async")
public String async() {
    log.info("요청 스레드: {}", Thread.currentThread().getName());
    myService.doAsync();
    return "ok";
}

 

실행 결과

 

  • 요청은 exec-1 이 처리했다.
  • 내부 로직은 별도 스레드(task-1)로 넘어갔다.
  • 요청은 하나지만 2개의 스레드가 동작했다.

 

 

3. [Spring MVC + WebClient] - 응답을 기다리는 동안 다른 스레드가 개입

@GetMapping("/webclient")
public Mono<String> callApi() {
    log.info("요청 시작: {}", Thread.currentThread().getName());

    return webClient.get()
        .uri("http://localhost:8081/slow")
        .retrieve()
        .bodyToMono(String.class)
        .doOnNext(response -> log.info("응답 받은 스레드: {}", Thread.currentThread().getName()));
}

 

실행 결과 (Spring WebClinet + 비동기)

  • 시작은 exec-2 에서 진행했다.
  • WebClient는 내부적으로 다른 스레드 풀에서 응답을 처리했다.
  • 요청 하나에 최소 2개 이상의 스레드가 동작했다.

 

 

4. [Spring WebFlux + Netty] - 더 이상 스레드 흐름은 예측 불가

@GetMapping("/webflux")
public Mono<String> reactive() {
    log.info("요청 시작: {}", Thread.currentThread().getName());

    return Mono.just("응답")
        .delayElement(Duration.ofMillis(500))
        .doOnNext(data -> log.info("응답 처리 스레드: {}", Thread.currentThread().getName()));
}

 

실행 결과

  • 요청은 reactor-http-nio-* 에서 수신했다.
  • 응답은 완전히 다른 워커 스레드에서 처리했다 (parallel-*)
  • 1 요청에 최소 2개 이상 스레드, 심지어 바뀔 수도 있다.

 

정리해보자면

환경 요청당 스레드 수 특징
Spring MVC 1 예측 가능, 단순
MVC + @Async 2+ 스레드 분기
MVC + WebClient 2+ 응답 처리 별도
WebFlux 2 ~ n 스레드 다변화, 예측 어려움

 

스레드가 바뀌면 ThreadLocl에 저장한 값은 사라지고, 

로깅, 트랜잭션, MDC, 세션 등 "스레드 고정이 전제된 기능"이 깨질 수 있다.

'Back-End > Java' 카테고리의 다른 글

할머니도 이해할 수 있는 자바 Thread  (0) 2025.05.01
try-catch  (0) 2024.07.06
스레드  (0) 2024.07.05
프로세스  (0) 2024.07.04
Java HTTP 통신  (1) 2024.06.12

코드는 돌아가기만 하면 되는 거 아니야?? 라는 생각을 처음엔 했었다. 하지만 서비스가 커지고 사용자 수가 늘어나거나, 잘못된 로직으로 인해 장애, 운영 비용 증가, 사용자 이탈로 이어지게 된다.

 

그래서 개발자는 기능과 성능을 동시에 잡는 코드를 짤 줄 알아야 한다.

 

그럼 성능이 안나오는 코드는 어떤 특징을 가지고 있는지 알아보자

 

 

1. 데이터 구조를 무시한 코드

일을 하다보면 무의식적으로 무조건 List에 담고 for문을 돌리는 경우가 있다. 그러다 해시맵을 써야 할 곳에 배열을 쓰게 되는 경우가 발생한다.

 

이렇게 되면 검색, 삽입, 삭제할 때 시간 복잡도가 폭발하게 된다.

// 10,000건 데이터 중에서 특정 ID 찾기
for (User user : userList) {
    if (user.getId() == targetId) {
        return user;
    }
}

 

처음부터 Map<Integer, User> 로 관리했다면 O(1) 시간에 찾을 수 있다.

 

 

2. 쓸데없이 많은 IO (DB/네트워크 호출)

for문 안에서 매번 DB나 API 호출하는 경우나 같은 데이터를 반복해서 조회하는 경우 네트워크 지연, DB부하 폭발 → 시스템 전체가 느려짐으로 연결될 수 있다.

 

for (Order order : orders) {
    Customer customer = customerRepository.findById(order.getCustomerId());
    // ...
}

 

위 코드에서 orders가 1만개라면 DB를 1만 번 호출하게 된다. 

 

이럴경우 한 번에 Customer 데이터를 IN 조건으로 미리 가져오거나 조인(Join)으로 조회해 DB 접근을 최소화 한다.

 

 

 

3. 캐시를 쓰지 않는다.

자주 조회하는 데이터를 매번 새로 계산하는 경우나 매번 외부 API를 호출하는 경우 CPU, 메모리 낭비 + 응답 지연이 발생할 수 있다.

 

예를 들어

public Product getBestSeller() {
    // 매번 DB를 때려서 상위 10개를 조회
    return productRepository.findTop10BySalesOrderByDesc();
}

 

하루에도 수만 번 호출될 수 있다.

 

인기상품 같은 건 5분 간격으로 캐시에 저장하거나 Redis, Caffeine 같은 캐시를 활용한다.

 

 

 

4. 데이터 양에 대한 고려가 없을 때

어차피 데이터가 몇 건 없을거야 가정하고 코딩을했을 때 만약 한 번에 100만건 select 후 메모리에 올리게 되면 어떻게 될까?

데이터가 늘어나면 OOM(OutOfMemoryError) 혹은 서버가 터질수도 있다.

 

List<User> allUsers = userRepository.findAll(); // 수십만 건

 

초반엔 문제 없을 수 있지만, 추후 데이터가 쌓이게 되면 대규모 트래픽 시 서버가 뻗을 수 있다.

페이징 처리(LIMIT, OFFSET) 혹은 스트리밍 처리(cursor 기반 조회) 으로 개선이 필요하다.

 

 

 

5. 비효율적인 반복 로직

이중 for문, 삼중 for문을 남발하는 경우 or 같은 계산을 여러 번 반복하는 경우 시간복잡도가 기하급수적으로 증가할 수 있다.

for (Item itemA : itemList) {
    for (Item itemB : itemList) {
        if (itemA.equals(itemB)) continue;
        // 비교 처리
    }
}

 

O(n^2) 알고리즘이다. 사전에 정렬하거나 해시셋(HashSet)을 활용, 필요없는 반복을 최소화해서 개선을 할 수 있다.

 

 

 

정리하자면 

항목 핵심 요약
데이터 구조 상황에 맞는 자료구조를 써야 한다
IO 최소화 DB, API 호출을 줄여야 한다
캐시 적극 사용 비싼 연산은 저장하고 재사용한다
데이터 양 고려 언제든 데이터가 늘어날 수 있다
반복 최소화 이중/삼중 for문은 반드시 경계한다

쿼리 계획이란?

쿼리 계획(Execution Plan)은 오라클 데이터베이스가 SQL 문장을 실행할 때 "어떤 방법으로 데이터를 읽고 처리할지" 미리 계산해서 보여주는 설명서이다.

 

즉,

  • 테이블을 풀스캔할지?
  • 인덱스를 탈지?
  • 조인을 어떤 순서로 할지? 

등을 알려주는 실행 청사진이다.

 

이걸 보면 쿼리 성능 문제를 미리 예측하거나 최적화할 수 있다.

 

 

SQL Developer에서 Explain Plan 확인하는 기본 방법

  1. SQL Developer 실행
  2. 오라클 DB에 로그인
  3. 쿼리 작성  ex) SELECT * FROM employees WHERE department_id = 10;
  4. 쿼리 블록을 드래그하거나 선택한 후, 상단 메뉴에서 Explain Plan(F10) 버튼 클릭 또는 오른쪽 클릭 → Explain Plan 선택

 

Explain Plan 결과 화면 읽는 방법

컬럼 설명
Operation 어떤 작업을 했는지(Full Table Scan, Index Scan 등)
Object Name 대상 테이블이나 인덱스 이름
Rows (Cardinality) 예측 결과로 읽게 될 데이터 건수
Cost 이 작업에 필요한 예상 리소스 소비량(CPU, IO 등)
Bytes 읽어야 할 데이터 크기

 

Cardinality 가 높다 = 많은 데이터가 나온다. / Cardinality가 낮다 = 적은 데이터가 나온다.

 

자주 나오는 Operation 종류 및 의미

Operation 의미
TABLE ACCESS FULL 테이블 전체를 읽는다( 성능 위험 신호 But 더 좋을때도 있음!)
INDEX RANGE SCAN 인덱스를 범위 검색(좋음)
INDEX UNIQUE SCAN 인덱스를 정확히 하나만 검색(매우 좋음)
NESTED LOOPS 두 테이블을 반복적으로 조인(작은 데이터에 적합)
HASH JOIN 대량 데이터 조인 (메모리 사용 많음)
SORT AGGREGATE 집계 연산(SUM, COUNT 등)

 

 

 

Explain Plan 볼 때 주의할 점!

1. Cost가 낮다고 무조건 좋은 게 아니다.

Cost는 참고용이고 "IO 비용"을 줄이는게 최종 목표이다.

 

2. 풀스캔(TABLE ACCESS FULL)이 무조건 나쁜 건 아니다.

아주 작은 테이블이라면 풀스캔이 더 빠를 수도 있다. 하지만 큰 테이블이라면 반드시 인덱스 활용을 고려해봐야한다.

 

3. 실행계획이 변할 수 있다.

바인드 변수, 통계 정보, 힌트 등에 따라 변동이 가능하다. ALWAYS 실제 데이터와 상황을 고려해야 한다.

 

4. AUTOTRACE나 DBMS_XPLAN.DISPLAY를 써보자

Explain Plan은 예측, Autotrace는 진짜 실행 기반이므로 함께 보는 게 좋다.

+ Recent posts