광클스탑/백엔드

ArrayIndexOutOfBoundsException 에러

kittae 2026. 3. 6. 19:15

Sentry를 도입 후 일상생활을 보내던 중, 하나의 에러가 Slack에 왔다.

Slack으로 온 해당 에러

 


1. ArrayIndexOutOfBoundsException이란?

ArrayIndexOutOfBoundsException은 Java에서 배열의 유효하지 않은 인덱스에 접근할 때 발생하는 런타임 예외이다. 

RuntimeException을 상속하며, 컴파일 타임에는 잡히지 않고 실행 시점에 발생한다.

 

 

배열의 인덱스 규칙

Java에서 배열은 고정 크기이며, 인덱스는 0부터 시작한다.

int[] arr = new int[5];  // 크기 5, 유효 인덱스: 0 ~ 4

arr[0] = 10;  // OK
arr[4] = 50;  // OK (마지막 인덱스)
arr[5] = 60;  // ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5

 

에러 메시지 Index N out of bounds for length M의 의미는 다음과 같다.

  • length M: 배열의 크기가 M (유효 인덱스: 0 ~ M-1)
  • Index N: 인덱스 N에 접근을 시도했지만, 유효 범위를 벗어남

 

2. 운영 환경에서 발생한 에러 분석

Sentry 에러 로그

운영 서버(production)에서 Sentry에 다음과 같은 Fatal 에러가 10시간 동안 6건 보고되었다.

ArrayIndexOutOfBoundsException: Index 8 out of bounds for length 8

Stack Trace:
  java.util.LinkedHashMap.keysToArray()
  hello.gcstopapi.infra.state.CaffeineCartStateServiceImpl.getCartCourses() (line 82)
  hello.gcstopapi.domain.cart.service.CartService.getCart() (line 70)
  hello.gcstopapi.domain.cart.controller.CartController.getMyCart() (line 61)
  • 엔드포인트: GET /api/v1/carts?roundId=35973
  • 사용자: anonymousUser
  • 환경: production (Eclipse Adoptium JDK 21

코드 분석

에러가 발생한 흐름을 코드로 따라가 보자.

CartController - API 진입점

@GetMapping
public ResponseEntity<CartResponse> getMyCart(...) {
    // ...
    CartResponse response = cartService.getCart(player.getId(), cartType, roundId);  // line 61
    return ResponseEntity.ok(response);
}

 

CartService - 비즈니스 로직

public CartResponse getCart(Long playerId, CartType cartType, Long roundId) {
    Long roundIdType = getEffectiveRoundId(cartType, roundId);
    List<Long> courseIds = cartStateService.getCartCourses(roundIdType, playerId, cartType);  // line 70
    // ...
}

 

CaffeineCartStateServiceImpl - 에러 발생 지점

@Override
public List<Long> getCartCourses(Long roundId, Long playerId, CartType cartType) {
    String playerCartKey = buildPlayerCartKey(roundId, playerId, cartType);
    LinkedHashSet<Long> cart = playerCarts.getIfPresent(playerCartKey);

    if (cart == null || cart.isEmpty()) {
        return List.of();
    }
    return new ArrayList<>(cart);  // line 82 - 여기서 예외 발생!
}

 

new ArrayList<>(cart)에서 예외가 발생하는가?

new ArrayList<>(Collection)의 내부 구현을 살펴보자.

// java.util.ArrayList 생성자 (JDK 소스)
public ArrayList(Collection<? extends E> c) {
    Object[] a = c.toArray();           // ← 여기서 예외 발생
    // ...
}


LinkedHashSet.toArray()
는 다시 내부적으로 2단계로 동작한다.

// 1단계: size()로 결과 배열 크기 결정
int size = this.size();            // → 8
Object[] result = new Object[size]; // 크기 8 배열 할당
​
// 2단계: 내부 순회하며 배열에 복사
int idx = 0;
for (each element in LinkedHashSet) {
    result[idx++] = element;        // idx가 8에 도달하면 → Index 8 out of bounds for length 8
}


1단계와 2단계 사이에 동기화가 없으므로
, 다른 스레드가 LinkedHashSet에 요소를 추가하면 실제 요소 수가 배열 크기를 초과한다.

 

장바구니 제한이 6개인데 왜 8개?

나는 프로젝트에서 장바구니 제한을 MAX_COURSES = 6으로 설정 해놨다.

// GameConstants.java
public static final int MAX_COURSES = 6;

 

그런데 확인해보니, 제한 체크 로직에도 동기화 문제가 있었다!

// CartService.java - addCourseToCart()
List<Long> cartIds = cartStateService.getCartCourses(roundId, playerId, cartType);  // ① 현재 크기 조회
if (cartIds.size() >= MAX_CART_SIZE) {   // ② 6개 미만이면 통과
    throw new BusinessException(ErrorCode.CART_FULL);
}
cartStateService.addCourseToCart(roundId, playerId, courseId, cartType);  // ③ 실제 추가

 

1~3 사이에 동기화가 없으므로, 여러 요청이 동시에 2를 통과하면 6개 제한을 초과할 수 있다.
그러므로 장바구니에 8개까지 들어간 이유가 성립된다.

 

즉, 문제는 같은 사용자가 보낸 여러 요청이 Tomcat의 서로 다른 스레드에서 동시에 처리되면서, 같은 LinkedHashSet 인스턴스에 동기화 없이 접근하는 것이 원인이다.


3. 해결 방법

LinkedHashSet → CopyOnWriteArraySet 교체

CopyOnWriteArraySet은 Java의 java.util.concurrent 패키지에 있는 Thread-Safe한 Set 구현체다.

쓰기(add, remove) 시 내부 배열 전체를 복사하고, 읽기 시에는 복사 없이 현재 배열의 스냅샷을 그대로 반환한다.

 

CopyOnWriteArraySet 동작 원리

읽기(toArray, iteration)

→ 현재 내부 배열을 그대로 참조 (복사 없음, lock 없음)

→ 읽는 도중 다른 스레드가 add()를 해도, 읽는 쪽은 이전 배열을 보고 있으므로 안전

 

쓰기(add, remove)

→ 내부 배열을 통째로 복사한 뒤, 새 배열에 변경을 적용

→ 기존 배열을 읽고 있던 스레드에는 영향 없음

 

이 특성 덕분에 size()toArray() 사이에 다른 스레드가 add()를 해도, 읽기 스레드는 변경 전 배열의 스냅샷을 보기 때문에 ArrayIndexOutOfBoundsException이 발생하지 않는다!

 

변경 전

// Caffeine Cache: LinkedHashSet 사용!!!
  private final Cache<String, LinkedHashSet<Long>> playerCarts = Caffeine.newBuilder()
          .expireAfterWrite(5, TimeUnit.MINUTES)
          .maximumSize(10000)
          .build();

@Override
  public void addCourseToCart(Long roundId, Long playerId, Long courseId, CartType cartType) {
      String playerCartKey = buildPlayerCartKey(roundId, playerId, cartType);
      // ...
      LinkedHashSet<Long> cart = playerCarts.get(playerCartKey, k -> new LinkedHashSet<>());
      if (cart.add(courseId)) {
          // ...
      }
  }

 

변경 후

// Caffeine Cache: CopyOnWriteArraySet으로 교체
  private final Cache<String, Set<Long>> playerCarts = Caffeine.newBuilder()
          .expireAfterWrite(5, TimeUnit.MINUTES)
          .maximumSize(10000)
          .build();

@Override
  public void addCourseToCart(Long roundId, Long playerId, Long courseId, CartType cartType) {
      String playerCartKey = buildPlayerCartKey(roundId, playerId, cartType);
      // ...
      Set<Long> cart = playerCarts.get(playerCartKey, k -> new CopyOnWriteArraySet<>());
      if (cart.add(courseId)) {  // Thread-Safe: 내부적으로 배열 복사 후 추가
          // ...
      }
  }

 

 1. 캐시 타입: Cache<String, LinkedHashSet<Long>>Cache<String, Set<Long>>
 2. 생성부: new LinkedHashSet<>()new CopyOnWriteArraySet<>()
 3. 참조 타입: LinkedHashSet<Long>Set<Long>


4. 결과는??

하루정도 모니터링 결과, 해당 에러는 없어졌다!

Sentry 도입 이후, 무지했던 에러에 대해 분석하고, 해결하는 습관이 생긴 것 같아 뿌듯하다!

읽어주셔서 감사합니다. 오늘도 좋은 하루 되시길 바랍니다 :)

'광클스탑 > 백엔드' 카테고리의 다른 글

RedisConnectionException 에러  (0) 2026.05.19