Java Stream내에서 Exception Handling
동료의 Code를 Review하는 시간에 갑론을박 했던 내용이 있어 관련 내용을 찾아보고 정리해 보았다.
이슈가 됐던 거는 아래 Code-1과 유사한 코드였는데 그 내용은 stream()으로 진행하는 Lambda Function안에 try~catch 구문을 사용하는 것이 맞느냐? 좋은 코드냐? 는 논쟁이었다.
...
Member member = memberRepository.findAllByGroup(groupName).stream().
.findfirst()
.map(v -> {
try {
return mapper.readValue(v.getGroupInfo(), MemberGroup.class);
} catch (JsonProcessingException e) {
log.error("JsonProcessingException", e);
throw e;
}
}).get();
...
결론적으로 말하면 위의 Code는 Compile 단계에서 다음과 같은 Error가 발생한다.
error: unreported exception JsonProcessingException; must be caught or declared to be thrown
Error 메시지를 보면 Exception을 별도로 처리해주거나 Throw를 명시해주라는 말인데 함수명에 throw를 선언해도 위의 문제는 해결되지 않는다. 원인은 pic-1과 같이 JsonProcessingException이 Check Exception에서 상속받아서 선언된 Exception 이고,
map 안에 사용하는 Lambda식이 functional interface의 하나인 Function<T, R>의 구현체인데, 이 Interface에서는 code-2와 같이 apply() 함수에 throws 정의가 없기 때문에, Checked Exception에 대한 처리가 필요한 경우 위와 같이 Compile 오류가 발생한다.
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
Solution 1-1. try~catch 문으로 내부적으로 Exception 처리
제일 쉬운 방법은 lambda식 내부에 try~catch 문을 사용해서 Excpetion을 처리하는 방법이다. code-3은 익명 함수에서 try~catch를 통해 Exception 발생 시 null을 return하고 stream()에서 null을 별도로 처리하는 예이다.
public MemberGroup getMemberGroupInfo01(String groupName) {
...
return memberRepository.findAllByGroup(groupName).stream()
.findFirst()
.map(v -> {
try {
return mapper.readValue(v.getGroupInfo(), MemberGroup.class);
} catch (JsonProcessingException e) {
log.error(">>> JsonProcessingException : {}", e.getMessage());
return null;
}
})
.orElse(null);
}
여기서는 orElse()를 통해 null을 return하는 형식으로 처리했지만, orElseThrow로 호출한 곳으로 Exception을 위임 할 수도 있다. 아래 code-4는 code-3의 Test Code이다.
...
@Mock
private MemberRepository mockMemberRepository;
@InjectMocks
private MemberService memberService;
@BeforeEach
private void init(){
memberList = Arrays.asList(...
new Member(2,"Andres",17,"Shark","{\"info\":\"invalid\"}"),
...
}
@Test
void getMemberGroupInfo01_WhenOccurException_ThenNullReturn() {
// Given
when(mockMemberRepository
.findAllByGroup("Shark"))
.thenReturn(memberList.stream()
.filter(member->member.getGroup().equalsIgnoreCase("Shark"))
.collect(Collectors.toList())
);
// When
MemberGroup invalidMemberGroup = memberService.getMemberGroupInfo01("Shark");
// Then
assertEquals(null, invalidMemberGroup);
}
Solution 1-2. try~catch 로 Exception 위임
try~catch 구문에서 catch한 Exception을 호출한 곳으로 처리를 위임하려면, 발생한 Exception을 Unchecked Exception를 재정의해서 Throw해야 한다. code-5에서는 Catch한 JsonProcessingException을 RuntimeException으로 재정의해서 throw하는 예이다.
public MemberGroup getMemberGroupInfo02(String groupName) {
ObjectMapper mapper = new ObjectMapper();
return memberRepository.findAllByGroup(groupName).stream()
.findFirst()
.map(v -> {
try {
return mapper.readValue(v.getGroupInfo(), MemberGroup.class);
} catch (JsonProcessingException e) {
throw new RuntimeException("Fail to parse Json String : '" + v.getGroupInfo() + "'");
}
})
.get();
}
@Test
void getMemberGroupInfo02_WhenOccurException_ThenThrowException() {
// Given
when(mockMemberRepository
.findAllByGroup("Shark"))
.thenReturn(memberList.stream()
.filter(member->member.getGroup().equalsIgnoreCase("Shark"))
.collect(Collectors.toList())
);
// When
// Then
RuntimeException e = assertThrows(RuntimeException.class, ()->{
memberService.getMemberGroupInfo02("Shark");
});
System.out.printf(">>> Exception Message : %s\n", e.getMessage());
}
참고로 Test 성공 후 RuntimeException이 Catch 되기 때문에 Test 후에 출력 Message도 아래와 같이 정상적으로 확인된다.
>>> Exception Message : Fail to parse Json String : '{"info":"invalid"}'
Solution 2. try~catch를 위한 외부 함수 정의
위의 방법들을 통해 Lambda 구문 내부에서 발생하는 Exception 처리는 할 수 있지만, Lambda 구문의 깔끔함과 특유의 가독성을 선호하는 사람들에게 lambda구문 안에 try~catch문이 길게 나열되는 것을 좋아하지 않고, 대부분 지양하고 있다. 그래서 1-1과 1-2 에서의 try~catch구문을 Code-6,7과 같이 외부 함수 getMemberGroupFromMember(), getMemberGroupFromMemberWithException()로 추출해서 Lambda 구문을 깔끔하게 만들수 있다.
public MemberGroup getMemberGroupInfo03(String groupName) {
return memberRepository.findAllByGroup(groupName).stream()
.findFirst()
.map(this::getMemberGroupFromMember)
.orElse(null);
}
private MemberGroup getMemberGroupFromMember(Member member) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readValue(member.getGroupInfo(), MemberGroup.class);
} catch (JsonProcessingException e) {
log.error(">>> JsonProcessingException : {}", e.getMessage());
return null;
}
}
public MemberGroup getMemberGroupInfo04(String groupName) {
return memberRepository.findAllByGroup(groupName).stream()
.findFirst()
.map(this::getMemberGroupFromMember) .get();
}
private MemberGroup getMemberGroupFromMemberWithException(Member member) {
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readValue(member.getGroupInfo(), MemberGroup.class);
} catch (JsonProcessingException e) {
throw new RuntimeException("Fail to parse Json String : '" + member.getGroupInfo() + "'");
}
}
물론 전체 Code Line이 줄지 않은 상태에서 가독성만 좋아진 것이고, 위와 같은 경우가 생길 때마다 getMemberGroupFromMember()와 getMemberGroupFromMemberWithException() 함수와 같이 try~catch구문을 추출해서 함수를 새로 생성해야 하는 문제가 있다.
Solution 3. Functional Interface의 재정의
Solution 2와 같이 상황마다 별도의 함수를 추출하는 것은 다소 번거로울 수 있다. 또, 너무 많은 수의 함수를 추출하면 유지 관리에 어려움이 있을 가능성도 있다. 이런 경우에는 지금부터 소개하는 방법이 좀 더 유용하다. 위의 예에 있는 stream<R>.map() 은 Functional Interface들 중 Funtion<T, R> Interface를 익명 함수로 구현한 것인데, Solution 3에서는 이 Funtion<T,R> Interface와 유사한 형태로 새로운 Functional Interface를 범용적으로 재정의 해서 사용하는 방법이다. 여기에서는 Code-8과 같이 정의하였다.
@FunctionalInterface
public interface FunctionWithThrows<T, R> {
R apply(T t) throws Exception;
}
기존 Function<T,R> 과 유사하지만 apply() 함수가 Exception을 Throws 할 수 있게 해서, 새로운 Functional Interface를 FunctionWithThrows<T,R>이라는 이름으로 정의하고, 이후에는 FunctionWithThrows<T, R> interface를 인자로 받아서 Function<T,R> Interface를 Return 하는 Wrapper 함수를 정의하면 된다.
3.1 Exception 발생 시 null Return.
처음에는 1-1과 같이 Exception이 발생하면 null을 Return하는 범용 Wrapper함수 functionWithNull()을 Code-9와 같이 정의하였다. FunctionWithThrows<T,R>을 인자로 받아 Checked Exception을 발생하는 경우 null을 Return 하는 Functional Interface를 구현체를 Return 한다.
public class CustomFunctionalInterfaces {
...
public static <T,R> Function<T,R> functionWithNull(FunctionWithThrows<T, R> functionWithThrows) {
return item->{
try{
return functionWithThrows.apply(item);
}catch(Exception e) {
return null;
}
};
}
...
}
code-10은 functionWithNull()의 사용 예이다. 기존에 그냥 작성했던 Lambda 구문을 위에서 정의한 functionWithNull() 함수로 Wrap해서 사용하면 된다. 단, 인자로 넘기는 Lamdba 구문 특성상 외부에서 정의된 ObjectMapper 객체는 사용할 수 없고, Lambda 구문 내에서 바로 생성해서 사용해야 하는 제약이 있다.
public MemberGroup getMemberGroupInfo05(String groupName) {
return memberRepository.findAllByGroup(groupName).stream()
.findFirst()
.map(CustomFunctionalInterfaces.functionWithNull(
v->(new ObjectMapper()).readValue(v.getGroupInfo(), MemberGroup.class))
)
.orElse(null);
}
3.2 Exception을 위임하는 예
1-2와 같이 RuntimeException을 Throw해서 처리하는 방법도 Wrapper 함수를 정의해서 사용할 수 있다.
public class CustomFunctionalInterfaces {
...
public static <T,R> Function<T,R> functionWithThrows(FunctionWithThrows<T, R> functionWithThrows) {
return item->{
try{
return functionWithThrows.apply(item);
}catch(Exception e) {
throw new RuntimeException(e);
}
};
}
...
}
그리고 적용 방법은 3-1과 유사하게 functionWithThrows()을 Wrapper해서 사용하면 된다.
public MemberGroup getMemberGroupInfo06(String groupName) {
return memberRepository.findAllByGroup(groupName).stream()
.findFirst()
.map(CustomFunctionalInterfaces.functionWithThrows(
v->(new ObjectMapper()).readValue(v.getGroupInfo(), MemberGroup.class))
)
.get();
}
Sample Code
- https://github.com/first-browndwarf/spring-boot-example.git 내 trycatchinstream project
추가하자면
3번 방법이 제일 멋드러지게(?)보이기는 하지만 여기서 정의했던 Function<T,R> 외에 Supplier
참조