[Java] 람다/스트림에서 외부 변수 값 변경, Exception 누적 처리
오늘도 어김 없이 문제 상황에 부딪혔고 이를 해결하면서 알게 된 내용들을 정리하려고 한다. 🤣
개발은 매일 매일이 배움의 시간이라는걸 뼈저리게 느끼며 삽질 시작!
1. 문제 상황
Java로 배치 처리를 하다 보면 스트림(stream().forEach())나 람다식을 사용할 일이 참 많다.
이번 문제는 스케줄러 내부, 람다식 사용에서 발생했다.
기존에는 람다식을 통해 코어 로직을 호출하고 해당 로직에서 예외가 발생할 경우 예외와 함께 알림 처리를 진행했다.
하지만 모니터링을 통해 점진적으로 처리량을 늘려가야 할테지만, 현재 한번에 처리되는 데이터의 양은 200건으로 설정되어 있고, 그럴 일은 없겠지만 최악의 경우 200건 모두 예외가 발생하면 알림 또한 200개가 전송될 여지가 있기 때문에 예외를 누적하여 단건의 알림만 전송되도록 로직을 수정하게 됐다.
알림을 통해 모든 예외 내용을 한 눈에 파악하긴 힘들고, 어차피 로그나 DB를 확인해야 하니 예외는 최초 예외만 포함해서 알림 메시지를 구성하기로 했다.
예를 들어,
Exception firstErrorException = null;
list.forEach(item -> {
try {
process(item);
} catch (Exception e) {
firstErrorException = e; // 컴파일 에러!
}
});
위와 같이 최초 예외를 보관하기 위해 외부에 Exception 변수를 선언하고, 람다 안에서 해당 변수의 초기화를 진행했다.
뚜둔! 🫨 역시나 뭐든 쉽게 가는 법이 없지 🤣
"변수는 final이거나 effectively final이어야 한다"는 친절한(?) 에러가 발생한다.
2. 잘못된 코드와 컴파일 에러 메시지
자바 컴파일러는 Local veriable firstErrorException defined in an enclosing scope must be final or effectively final 이라는 에러 메시지를 보여준다.
즉, 람다식 안에서는 외부의 일반 변수에 값을 다시 할당할 수 없다는 것!!!!!!!
3. 람다/스트림에서 외부 변수 값 변경이 불가능한 이유
자바의 람다 캡처(closure) 원칙
자바 람다는 외부(감싸고 있는) 메서드의 지역 변수를 "캡처"해서 내부에서 쓸 수 있게 해준다.
하지만 그 변수는 "final" 혹은 "effectively final(사실상 최종값)"만 허용! 즉, 값이 변하지 않는 것만 접근 가능하다.
왜?
람다식이 실행되는 시점에는, 외부 변수의 라이프사이클(생명 주기)이 이미 끝났을 수도 있기 때문이다.
쉽게 비유하자면, 람다는 "외부 변수의 복사본"을 안고 들어가기 때문이다.
만약 값이 계속 바뀔 수 있다면,
- 동시성 문제
- 예측 불가능한 결과
- 의도치 않은 Side Effect
가 발생할 수 있다.
그래서 변경이 불가능한(final, effectively final) 변수만 허용하는거다.
"final" 혹은 "effectively final"이란?
- final: 선언 시점에 "한 번만 값이 할당"되고, 바뀌지 않는 변수
- effectively final: final 키워드는 안 붙었지만, 실제로 값이 한 번도 바뀌지 않는 변수
람다/스트림에서는 final 또는 effectively final 변수만 읽기 가능, 값 변경은 불가다.
4. 가능한 해결방법과 각각의 장단점
해당 문제를 해결하는 방법은 여러 가지가 있었다.
final 배열(혹은 컬렉션) 활용
final Exception[] firstException = {null};
list.forEach(item -> {
try {
process(item);
} catch (Exception e) {
firstException[0] = e;
}
});
- 장점: 쉽게 적용 가능하다.
- 단점: 배열 인덱스로 값을 넣고 빼는 코드가 다소 직관적이지 않다.
AtomicReference 활용
AtomicReference<Exception> firstException = new AtomicReference<>(null);
list.forEach(item -> {
try {
process(item);
} catch (Exception e) {
firstException.compareAndSet(null, e);
}
});
- 장점: 람다/스트림에서도 가독성 좋게 값을 변경 가능하고, 동시성에도 안전하다.
- 단점: AtomicReference를 처음 보는 사람에게는 생소할 수 있다.
for-each(전통적 반복문)로 대체
Exception firstException = null;
for (Item item : list) {
try {
process(item);
} catch (Exception e) {
firstException = e;
}
}
- 장점: 가장 단순하고 누구나 이해하기 쉽다.
- 단점: 람다식의 장점(함수형 스타일, 메서드 체이닝 등)을 쓸 수 없다.
5. 그래서 나의 선택은?
고민 끝에 AtomicReference를 활용하는 방식을 택했다.
코어 로직의 성공/실패 카운트도 누적해야 하고 최초 발생한 예외도 처리해야 했으며 가독성을 포기할 수 없었다. 🤣
최초 이외의 나머지 예외를 건너뛰려면 조건문도 들어가야 하니까 메서드 호출 한줄이면 될 코드를 3줄로 만들 필요가 있을까 싶었다.
Atomic은 겸사겸사 동시성에도 안전하니까 😌
enum ProcessStatus { SUCCESS, FAIL }
Map<ProcessStatus, Integer> resultCount = new EnumMap<>(ProcessStatus.class);
AtomicReference<Exception> firstException = new AtomicReference<>(null);
list.forEach(item -> {
try {
process(item);
resultCount.merge(ProcessStatus.SUCCESS, 1, Integer::sum);
} catch (Exception e) {
resultCount.merge(ProcessStatus.FAIL, 1, Integer::sum);
firstException.compareAndSet(null, e);
}
});
따라서 성공/실패 카운트는 EnumMap과 merge로 관리하고, 예외는 AtomicReference로 관리하는 패턴을 적용했다(추후 EnumMap과 HashMap의 차이에 대한 내용도 정리도 필요할 것 같... 🤔).
아참!
해결하면서 궁금했던 점!
AtomicReference<Exception> firstException = new AtomicReference<>(null);
AtomicReference<Exception>을 생성할 때 null로 초기화하는 이유가 궁금해서 찾아봤다!
초기값이 없음을 명시
AtomicReference<Exception>은 Exception 객체(혹은 아무 객체나)를 저장하는 참조형 래퍼다.
배치, 반복문, 람다 등에서 "예외가 발생하지 않았다"는 상태를 "null(아직 예외가 없다)"로 표현한 것이다.
위의 나의 선택에서 사용한 것처럼 최초 한 번만 예외를 저장하고 싶어 compareAndSet을 사용하게 된다면,
- 처음에 firstException은 null로 초기화
- 처음 예외가 발생하면 null에서 e로 바뀜
- 그 이후 발생하는 예외는 이미 값이 있으므로 저장되지 않음
compareAndSet(expectedValue, newValue)의 의미
- 첫 번째 인자(expectedValue): 현재 값이 이것과 같다면...
- 두 번째 인자(newValue): "해당 값으로 바꿔라"
의 의미로 firstException의 현재 값이 null이면 e(예외 객체)로 값을 변경하기 때문에 생성할 때 null로 초기화하는 것이다.
따라서 AtomicReference<Exception>의 초기값을 null로 두는건 "아직 예외가 발생하지 않았다"는 상태를 표현하고, 최초로 예외가 생겼을 때만 값을 저장하기 위함으로 이해하면 된다!
오늘의 삽질은 어제의 삽질보다 짧았다 😆 괜히 뿌듯하군 😌