본문 바로가기

카테고리 없음

Java에서 Elvis operator 흉내내기

Java에는 C언어와 마찬가지로 삼항(ternary)연산자가 있다.

자바언어명세상에는 조건 연산자(Conditional Operator)라는 이름으로 되어 있다.

아래와 같은 형태이다.

boolean b;
c = b ? c1 : c2;

불리언 타입의 조건에 따라 c1 (condition 1)값 혹은 c2가 선택되는 형태이다.

null 과 조건 연산자

토니 호어(Tony Hoare)가 널 포인터를 10억 달러짜리 실수였다고 고백했다.

(널 포인터는) 내 10억 달러짜리 실수였다. 1965년 당시, 나는 ALGOL W라는 객체 지향 언어에 쓰기 위해 포괄적인 타입 시스템을 설계하고 있었다. 내 원래 목표는 어떤 데이터를 읽든 항상 안전하도록 컴파일러가 자동으로 확인해 주는 것이었다. 그러나 나는 널 포인터를 집어넣으려는 유혹을 이길 수가 없었다. 그렇게 하는 게 훨씬 쉬웠기 때문이다. 이 결정은 셀 수도 없는 오류와 보안 버그, 시스템 다운을 낳았다. 지난 40년 동안 이러한 문제들 때문에 입은 고통과 손해는 10억 달러는 될 것이다. - 토니 호어

1960년의 모스코바 주립 대학 교환학생이었던 토이 호어(오른쪽)

자바 프로그래머는 그래서 널 참조의 사용을 방지하기 위해 레퍼런스의 값이 null인지 체크하는 구문을 넣어서 방어적으로 처리한다.

자바 8부터는 참 거짓을 판별하는 Predicate를 메서드 레퍼런스로 사용하기 편하게 하기 위해서 Object 의 컴패니언 클래스인 Objects에 isNull과 nonNull 이라는 정적 메서드를 넣어두기까지도 했다.

package java.util;

public final class Objects {

    public static boolean isNull(Object obj) {
        return obj == null;
    }
    
    public static boolean nonNull(Object obj) {
        return obj != null;
    }

따라서 아래와 같이 방어하는 코드를 작성할 수 있다.

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.time.LocalDate;

import static java.util.Objects.isNull;
import static java.util.Objects.requireNonNull;

@Nonnull
public LocalDate getOrDefault(@Nullable LocalDate date, @Nonnull LocalDate defaultDate) {
    if (isNull(date)) {
        return requireNonNull(defaultDate);
    }
    return date;
}

위의 코드는 annotation을 통해서 메서드에 넘길 값에 대해 알리고 있지만 실제 사용하는 쪽에서 defaultDate로 null을 넘기게 되면 내부의 requireNonNull 에 의해 NullPointerException가 발생한다.

MDC와 X-Request-ID

마이크로서비스를 만들다보면 시스템 간의 요청들을 연계해서 조사할 경우가 있다.

이런 경우에 UUID 같은 확률적으로 겹치지 않는 값을 서로 넘겨서 이어지게 하면 원인 파악할 때 도움이 된다.

Correlation 패턴이라고 알려져있고 위에서 사용되는 ID를 Correlation ID 라는 이름으로 부른다.

 

로거로 logback 을 사용하면 MDC(Mapped Diagnostic Context)라는 Correlatino ID를 저장하기에 적합한 컨텍스트가 있다.

One of the design goals of logback is to audit and debug complex distributed applications. Most real-world distributed systems need to deal with multiple clients simultaneously. In a typical multithreaded implementation of such a system, different threads will handle different clients. A possible but slightly discouraged approach to differentiate the logging output of one client from another consists of instantiating a new and separate logger for each client.

그래서 애플리케이션은 요청이 들어오면 X-Request-ID와 같은 헤더의 값을 MDC로 저장을 해두었다가, 다른 API로 요청할 때 이 값을 꺼내서 HTTP Client의 헤더로 사용하도록 하면 편리하다.

만약 RestTemplate을 사용한다면 아래와 같은 인터셉터를 사용해서 값을 꺼낼 수 있을 것이다.

@Configuration
public class RestTemplateConfig {

    public static final String X_REQUEST_ID = "X-Request-ID";
    
    @Bean
    public ClientHttpRequestInterceptor interceptor() {
        return (request, body, execution) -> {
            HttpHeaders headers = request.getHeaders();
            String requestId = MDC.get(X_REQUEST_ID) == null ? UUID.randomUUID().toString() : MDC.get(X_REQUEST_ID);
            headers.set(X_REQUEST_ID, requestId);
            return execution.execute(request, body);
        };

    }

 

문자열에 대한 기본값 제공 라이브러리

위에서는 LocalDate에 대한 타입에 대해 기본 값을 제공했지만 문자열에 대해서 많이 사용하는 경우가 있었다.

아파치 공통 라이브러리에는 아래와 같이 제공하는 문자와 기본문자를 가져오는 함수가 있다.

package org.apache.commons.lang3;

public class StringUtils {

    public static String defaultString(String str, String defaultStr) {
        return str == null ? defaultStr : str;
    }

위의 라이브러리를 사용하면 RestTemplate을 사용한다면 아래와 같은 인터셉터의 삼항연산자를 아래와 같이 사용할 수 있다.

String traceId = StringUtils.defaultString(MDC.get(X_REQUEST_ID), UUID.randomUUID().toString());

기본 값에 대한 지연 평가

하지만 위의 아파치 라이브러리의 코드는 단점이 있다.

자바의 특성상 MDC.get으로 반환되는 결과와 UUID.randomUUID() 및 toString()에 대한 평가(evaluation)가 메서드가 호출될 때 발생한다는 것이다. 따라서 MDC.get의 코드가 null이 아닐 경우에는 사용하지도 않을 randomUUID()의 값을 만들 뿐더러 toString()에 변환이 이루어진다.

따라서 자바 8에서 추가된 Supplier를 이용하면 아래와 같이 바꿀 수 있다.

public class OurStringUtils {

    public static String getOrDefault(@Nullable String candidateValue, @Nonnull Supplier<String> defaultValueSupplier) {
        if (candidateValue == null) {
            return defaultValueSupplier.get();
        }
        return candidateValue;
    }

그러면 아래와 같이 비슷하게 코드를 바꿀 수 있다.

String traceId = OurStringUtils.getOrDefault(MDC.get(X_REQUEST_ID), () -> UUID.randomUUID().toString());

뒷 부분이 람다식으로 살짝 바뀌었지만 다르게 동작한다. UUID.randomUUID().toString() 에 대한 평가는 MDC.get()의 결과가 null일 경우에만 이루어진다.

코드리뷰 코멘트

삼항연산자로 되어 있는 코드를 바로 위의 getOrDefault로 바꾸는 것에 대해 아래와 같은 코멘트가 있었다.

이런부분도 OurStringUtils를 쓸 필요가 있을까요?
오히려 삼항연산자가 가독성이 나을 것 같은데 어떠신가요?

가독성이란 경험이나 습관에 따라 달라질 수 있는 것이라 개인별로 차이가 있다고 생각한다.

일단 유틸리티를 사용하면서 문장이 길어질 수는 있다. 그래서 가독성이 떨어진다면 static import를 통해 아래와 같이 줄일 수도 있을 것이다.

import static com.tistory.namocom.util.OurStringUtils.*;

String traceId = getOrDefault(MDC.get(X_REQUEST_ID), () -> UUID.randomUUID().toString());

하지만 내가 삼항연산자 대신 유틸리티를 사용한 이유는 다른 이유 때문이었다.

일단 DRY 원칙 위반이다.

String requestId = MDC.get(X_REQUEST_ID) == null
	? UUID.randomUUID().toString() : MDC.get(X_REQUEST_ID);

?의 조건으로 MDC.get이 나오는데 거기에 대한 조건 값으로 MDC.get이 나오는 중복이 들어있다.

만약 자바에 groovy나 Kotlin, Gosu, Swift (?? 이긴 하다), TypeScript 등에서 지원하는 Elvis operator가 있었으면 그것을 사용했을 것이다.

엘비스 연산자란 ?: 처럼 엘비스 프레슬리의 머리스타일처럼 생겼다고 해서 붙여진 연산자이다.

정말 그러한가?:

만약에 이게 되었으면 아래와 간단하게 해결된다.

String requestId = MDC.get(X_REQUEST_ID) ?: UUID.randomUUID().toString();

Ternary vs Elvis ?

그렇다면 삼항연산자와 엘비스 연산자는 완전히 동등할까?

여기에 대해서는 위키백과에 대해 동등성에 대해 설명이 적혀있다.

f() 함수에 대한 조건이 두번 들어가 있고 부수효과가 발생하는 경우에 f()가 두 번 호출 되는 경우는 삼항연산자와 Elvis 연산자는 다를 수 있다는 것이다.

또한 유사한 Null coalescing operator에도 비슷한 내용이 적혀있다.

 

사고 실험으로 해보면 쉽게 이해가 된다.

삼항연산자에서 MDC.get(X_REQUEST_ID)의 결과가 처음에는 null 이 아니였는데, 두 번째 호출에서 MDC.get(X_REQUEST_ID) null이 발생할 수 있는 상황이라면 requestId는 null로 값이 설정될 수도 있는 것이다.

하지만 엘비스 연산자의 경우 처음 MDC.get(X_REQUEST_ID) 의 값이 null 이 아니라면 그 값이 그대로 requestId로 들어가므로 null이 아님이 보장이 된다.

 

MDC의 경우 아래와 같이 구현이 되어 있다.

package org.slf4j;

public class MDC {

    static MDCAdapter mdcAdapter;
    
    public static String get(String key) throws IllegalArgumentException {
        if (key == null) {
            throw new IllegalArgumentException("key parameter cannot be null");
        }

        if (mdcAdapter == null) {
            throw new IllegalStateException("MDCAdapter cannot be null. See also " + NULL_MDCA_URL);
        }
        return mdcAdapter.get(key);
    }
    
    public static void remove(String key) throws IllegalArgumentException {
        if (key == null) {
            throw new IllegalArgumentException("key parameter cannot be null");
        }

        if (mdcAdapter == null) {
            throw new IllegalStateException("MDCAdapter cannot be null. See also " + NULL_MDCA_URL);
        }
        mdcAdapter.remove(key);
    }
    
    public static void clear() {
        if (mdcAdapter == null) {
            throw new IllegalStateException("MDCAdapter cannot be null. See also " + NULL_MDCA_URL);
        }
        mdcAdapter.clear();
    }

정적 메서드를 통해서 값을 조회, 제거가 가능하다.

만약 다른 스레드에서 조회하는 값을 조작을 한다면 첫 번째 get과 두 번째 get 사이에 값이 바뀌지 않으리라는 보장을 할 수 없을 것이다.

임시 변수 도입하기

아래와 같이 임시 변수를 통해 비교할 값을 옮겨둘 수도 있을 것이다.

final String xRequestId = MDC.get(X_REQUEST_ID);
String requestId = xRequestId == null ? UUID.randomUUID().toString() : xRequestId;

하지만 가독성적인 측면이나 DRY 원칙적인 측면에서나 좀 불편해보인다.

Java 대신 Kotlin을 써야할 이유 하나 더 추가가 된 듯.