2 분 소요

OOM(Out Of Memory)는 서버에서 사용할 수 있는 heap 사이즈보다 더 많은 공간을 사용하려할 때 발생하는 에러다.
동작중인 스레드들이 heap을 너무 많이 점유했을 경우 발생할 수 있다.

일반적인 웹 애플리케이션 서버를 운영한다면 들어온 요청을 늦어도 수초 내로 응답으로 내보내기도 하고, 서버 내에서 실제로 활성화된 스레드 수는 제한돼있다. 때문에 코드상에서 너무 많은 객체를 들고있는게 아니라면 각 스레드는 적은 양의 메모리를 사용하므로 메모리는 수시로 정리될 것이다. 항상 그런 환경이 맞다면, OOM이 발생하기 어려운 환경이라는 생각이 든다.

하지만 코드를 작성할 때는 너무 많은 객체를 생성하고 들고있는지에 대해 경고같은게 발생하지 않으므로 자각하기 어려울 수 있다.
(수량이 많은 것도 중요하지만, 객체가 가지는 값의 크기가 지나치게 큰지 확인하는 것도 필요하다)

아래는 단적인 예를 표현한 것이다.

val list = mutableListOf<ByteArray>()
repeat(100_000) {
    list.add(ByteArray(10 * 1024 * 1024)) // 10MB 객체 추가
}
  • 객체 하나가 10MB인 것도 문제지만, 객체를 10만번 list에 추가하고 있다.


아래는 copy()를 사용해서 새 객체를 만들고 있다. 이때 map을 사용해서 기존 list를 새 list로 만들어야 한다고 해보자.
딱히 큰 문제가 없는 코드로 보인다.

list.map { it.copy(field = newField) }


조건을 추가해보자. list는 담고있는 객체가 꽤 많아서 같은게 2개있으면 OOM이 날 수 있을 정도로 간당간당한 상태다.
(트래픽이 피크일 때도 터지지 않도록 적당히 메모리를 확보하는게 맞으므로 이미 잘못된 상황이지만 그렇다고 해보자!!)
그러면 위 코드는 list에 있던 객체들을 돌면서 copy()를 호출하면서 새 객체가 1개씩 순간적으로 더 생길 것이고, map은 list 내의 모든 객체에 대한 처리가 끝나야만 응답으로 새 list를 반환할 수 있다.

//_Collections.kt
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    // destination에 응답으로 반환할 리스트를 생성
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
    for (item in this)
        destination.add(transform(item)) // 새 객체를 저장하고
    return destination // 모두 저장했으면 반환
}
  • destination이 너무 많은 객체를 들고있어야 한다.

그러면 분명 OOM이 발생할 것이다. 만약 새 list 생성 후에는 기존 list를 참조하는 로직이 없어 GC에 의해 처리될 수 있다고해도 새 list를 응답으로 반환할 때까지는 메모리를 점유하고 있어야 한다. GC가 바로 정리해준다는 보장도 없다.

copy()를 사용한게 잘못이었을까? map을 사용한게 잘못이었을까?

아니다. 당장 heap이 터질 정도로 큰 list를 들고있는게 잘못일 가능성이 크다. (물론 정황상 어쩔 수 없이 들고있어야 할 수도 있다)
너무 많은 값을 갖고있지 못하도록 request에서 limit으로 제한하거나, list를 쪼개서 chunk 단위로 작업하는 다양한 방법이 있을 것 같다.

DB에서 데이터를 가져오는 상황

DB에 조회 쿼리를 날려서 애플리케이션 서버에 fetch 해야하는 상황이라고 해보자.
users 테이블에 100만 row가 있는 상태에서 findAll()을 호출한다면, 높은 확률로 OOM이 발생할 수 있다.

val users = userRepository.findAll() // 100만 user
  • findAll()뿐만 아니라 여러 row를 조회하는 쿼리라면 해당된다.

쿼리에 limit을 걸어서 적당히 가져온다고 해보자. 그러면 fetch할 때는 적당량의 객체만 넘어오므로 문제가 되지 않는다.
하지만 모종의 이유로 중복된 데이터를 허용하지 않아서 set에 객체들을 보관한 뒤 insert하는 로직이 있다고 해보자.

val setForInsert = mutableSetOf<User>()
        
do {
    val users1 = userRepository.getUsers1(/* 조건1 */)
    val users2 = userRepository.getUsers2(/* 조건2 */)   
    setForInsert.add(users1)
    setForInsert.add(users2)
} while (users1.isNotEmpty() || users2.isNotEmpty())

userRepository.saveAll(setForInsert)
  • users1, users2를 가져오는데 문제가 없었다고 해도, setForInsert에 추가하니까 결국 메모리가 터질 수 있다.
  • 위에서 살펴본 map과 유사한 상황이다.

이때는 users1, users2 조회 쿼리를 하나로 합친다면 set에 관리할 필요가 없으므로 간단히 해결할 수 있다.

문제로 지적한 것들이 어떤 상황에서는 문제가 되지 않을 수도 있고, 다른 원인으로 인해 발생했을 가능성도 다분하다.
상황에 따라 적절한 코드를 작성하는게 중요한 것 같다. 특히 OOM은 발생하기 전까지는 문제있는 코드라고 생각하기 어려울 때가 많아서 주의가 필요하다.

카테고리:

업데이트:

댓글남기기