Spring/Basic

Java Stream내에서 Exception Handling

갈색왜성 2022. 6. 27. 01:02
반응형

동료의 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-1. 논쟁거리가 된 원본 Code

결론적으로 말하면 위의 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 이고,


pic-1. JsonProcessingException 구조

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);
}
code-2. Function<T,R> interface

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);

}
code-3. try~catch로 예외 처리

여기서는 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);
}
code-4. Test Code (for Code-3)

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();

}
code-5. Exception을 RuntimeException으로 재정의해서 위임하는 예

@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());
}
code-6. Test Code (for Code-5)

참고로 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;
    }
}
code-6. Code-3을 외부함수 getMemberGroupFromMember()로 추출한 예

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-7. Code-5룰 외부함수 getMemberGroupFromMemberWithException()로 추출한 예

물론 전체 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;
}
code-8. Funtion\ Interface와 유사한 신규 Functional Interface

기존 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-9. functionWithNull() Wrapper함수

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);
}
code-10. functionWithNull() 사용 예

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);
            }
        };
    }
...
}
code-11. functionWithThrows() Wrapper함수

그리고 적용 방법은 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();
}
code-12. functionWithThrows() 사용 예

Sample Code

추가하자면

3번 방법이 제일 멋드러지게(?)보이기는 하지만 여기서 정의했던 Function<T,R> 외에 Supplier, Consumer, Predicates 처럼 자주 사용하는 Functional Interface들을 다 정의해야할 수도 있고, 예외가 발생했을 때 어떤 방식으로 처리를 할 지를 여러 방법으로 구현하다 보면 Code양도 적지도 않다. 일단 우리팀은 2번 방식으로 시작을 하되, 몇 가지 동일한 Case가 모여지면 Refactoring 단계에서 3번 방식을 도입하기로 했다.


참조