네로개발일기

개발자 네로의 개발 일기, 자바를 좋아합니다 !

'programming language/Java'에 해당되는 글 16건


반응형

 이전 글 

https://frogand.tistory.com/209

 

[Java] Optional를 잘 사용하는 법 1 (NPE, if문으로 null 체크를 하지 말자)

Optional을 왜 사용하냐, 그럼 언제 사용해야할까, 에 대한 질문과 답을 적으려고 한다. 요점 - NPE을 방지하기 위해 사용하는 클래스. - null이 들어올 수 있는 값을 한 번 감싸는 Wrapper 클래스. NPE (Nul

frogand.tistory.com

Stream 처럼 사용하기

Optional을 제대로 사용하려면 최대 1개의 원소를 가지고 있는 특별한 Stream이라고 생각하는 것이 좋다. Optional 클래스와 Stream 클래스는 직접적인 구현, 상속과는 관련없지만 사용방식이 유사하다. Stream 클래스의 map(), flatMap(), filter() 와 같은 메서드가 Optional도 가지고 있기 때문에 Stream처럼 사용한다고 생각하면 될 것 같다.

 

map()

public String getCityOfMemberFromOrder(Order order) {
    if (order != null) {
        Member member = order.getMember();
        if (member != null) {
            Address address = member.getAddress();
            if (address != null) {
                String city = address.getCity();
                if (city != null) {
                    return city;
                }
            }
        }
    }

    return "Seoul"; // default
}

해당 코드를 Optional을 사용하여 변경하겠다.

public String getCityOfMemberFromOrder(Order order) {
    return Optional.ofNullable(order)
            .map(Order::getMember)
            .map(Member::getAddress)
            .map(Address::getCity)
            .orElse("Seoul");
}

이전 코드의 전통적인 NPE 방어 패턴에 비해 훨씬 간결하고 명확해진 코드를 볼 수 있다. 기존에 존재하던 조건문이 사라지고 Optional의 메서드 체이닝으로 대체되었다.

- ofNullable() 정적 팩토리 메서드를 활용하여 Order 객체를 Optional로 감쌌다. Order 객체가 null인 경우를 대비해 of() 메서드 대신에 ofNullable() 메서드를 사용했다.

- map() 메서드의 연쇄 호출을 통해 Optional 객체를 3번 변환하였다. 

Optional<Order> -> Optional<Member> -> Optional<Address> -> Optional<String>

- orElse() 메서드를 호출하여 기본값으로 사용할 값을 정해주었다.

 

filter()

null 체크로 시작하는 if 조건문 패턴이 많이 있다.

if (obj != null && obj.do() ...)

예를 들어, 주어진 시간(분) 내에 생성된 주문을 한 경우에만 해당 회원 정보를 구하는 메소드를 위 패턴으로 작성해보자.

public Member getMemberIfOrderWithin(Order order, int min) {
	if (order != null && order.getDate().getTime() > System.currentTimeMillis() - min * 1000) {
		return order.getMember();
	}
}

위 코드는 if 조건문에 null 체크와 비즈니스 로직이 같이 적혀있어서 가독성이 떨어진다.  

 

반면 filter() 메서드를 사용하면 if 조건문 없이 메서드 연쇄 호출만으로 가독성있는 코드를 작성할 수 있다. 또, 메서드의 리턴타입을 Optional로 사용함으로써 호출자에게 해당 메서드가 null을 담고있는 Optional로 반환할 수도 있다는 것을 명시적으로 알려주고 있다.

public Optional<Member> getMemberIfOrderWithin(Order order, int min) {
    return Optional.ofNullable(order)
            .filter(o -> o.gerDate().getTime() > System.currentTimeMillis() - min * 1000)
            .map(Order::getMember);
}

filter() 메서드는 넘어온 함수형 인자의 리턴 값이 false인 경우, Optional을 비우기 때문에 그 이후 메서드 호출이 의미가 없어진다.

 

Java8 이전 개발 코드를 Optional로 바꾸기

Java8 이전에 개발된 코드는 Optional이 없어 null-safe하지 않을 수 있다. JAVA 표준 API조차 하위 호환성을 보장하기 위해 기존 API에 Optional을 적용할 수 없었다.

 

메서드의 반환값이 존재하지 않을 때 전통적인 처리 패턴

이전 개발된 메서드들은 반환값이 존재하지 않을 경우 크게 2가지 패턴으로 처리하였다.

 

1. null 반환

Map 인터페이스의 get() 메서드는 주어진 인덱스에 해당하는 값이 없으면 null을 반환한다.

Map<Integer, String> cities = new HashMap<>();
cities.put(1, "Seoul");
cities.put(2, "Busan");
cities.put(3, "Daejeon");

보통은 null 체크 코드가 들어간다.

String city = cities.get(4); // returns null
int length = city == null ? 0 : city.length(); // null check
System.out.println("length: " + length);

get() 메서드의 반환값을 Optional로 감싸주면 null-safe하게 처리할 수 있다.

Optional<String> maybeCity = Optional.ofNullable(cities.get(4));
int length = maybeCity.map(String::length).orElse(0);
System.out.println("length: " + length);

map() 메서드로 결과를 얻어내고 orElse() 메서드로 디폴트 값을 설정해주었다.

 

2. 예외 발생

두번째 패턴은 예외를 던져버리는 경우이다. List 인터페이스의 get() 메서드는 주어진 인덱스에 해당하는 값이 없으면 ArrayIndexOutOfBoundsException 을 던진다.

List<String> cities = Arrays.asList("Seoul", "Busan", "Daejeon");

다음과 같이 try-catch 문을 사용하여 예외 처리를 해주고 예외 처리 이후에 null 체크도 진행해야 한다.

String city = null;
try {
    city = cities.get(3); // throws exception
} catch (ArrayIndexOutOfBoundsException e) {
    // 생략
}

int length = city == null ? 0 : city.length(); // null check
System.out.println("length: " + length);

이런 경우, 예외 처리부를 감싸서 정적 유틸리티 메서드로 분리해야 한다.

Optional 클래스의 정적 팩터리 메서드를 사용해서 정상처리와 예외 처리시에 반환할 Optional 객체를 각각 지정해주자.

public static <T> Optional<T> getAsOptional(List<T> list, int index) {
    try {
        return Optional.of(list.get(index));
    } catch (ArrayIndexOutOfBoundsException e) {
        return Optional.empty();
    }
}

Optional<String> maybeCity = getAsOptional(cities, 3); // Optional
int length = maybeCity.map(String::length).orElse(0); // null check
System.out.println("length: " + length);

 

ifPresent() 메서드 사용하기

ifPresent(Consumer<? super T> consumer

이 메서드는 특정 결과를 반환하는 대신에 Optional 객체가 감싸고 있는 값이 존재하는 경우에만 실행될 로직을 함수형 인자로 넘길 수 있다. 함수형 인자로 람다식이나 메서드 레퍼런스가 넘어올 수 있다.

Optional<String> maybeCity = getAsOptional(cities, 3); 
maybeCity.ifPresent(city -> {
	System.out.println("length: " + city.length());
});

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

Optional을 사용하냐, 그럼 언제 사용해야할까, 에 대한 질문과 답을 적으려고 한다.

요점

- NPE을 방지하기 위해 사용하는 클래스.

- null이 들어올 수 있는 값을 한 번 감싸는 Wrapper 클래스.

NPE (Null Pointer Exception)

null 참조로 인해 널 포인터 예외(NPE)가 발생하는 것이 가장 문제이다. 컴파일 시점에서는 문제가 없지만 런타임때 NPE가 발생한다면 가장 골치아프다.

java.lang.NullPointerException
    at seo.dale.java.practice(OptionalTest.java:26)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:497)

NULL 처리가 취약한 코드

null 처리가 취약한 코드에서 NPE 발생 확률이 높다.

"어떤 주문을 한 회원이 어느 도시에 살고 있는지 알아내기"에 관한 메서드가 다음과 같다고 하자.

// 주문을 한 회원이 살고있는 도시를 반환한다.
public String getCityOfMemberFromOrder(Order order) {
    return order.getMember().getAddress().getCity();
}

위 메서드는 NPE 위험에 노출된 상태이다.

1) order 파라미터에 null 일 경우

2) order.getMember() 의 결과가 null 일 경우

3) order.getMember().getAddress() 의 결과가 null 일 경우

적절히 null 처리를 하지 않으면, NPE가 발생할 수 있다.

JAVA8 이전의 NPE 방어 패턴

NPE의 위험에 노출된 코드를 다음과 같은 코딩 스타일로 회피하였다.

public String getCityOfMemberFromOrder(Order order) {
    if (order != null) {
        Member member = order.getMember();
        if (member != null) {
            Address address = member.getAddress();
            if (address != null) {
                String city = address.getCity();
                if (city != null) {
                    return city;
                }
            }
        }
    }

    return "Seoul"; // default
}

객체 탐색의 모든 단계마다 null 체크를 하고 있다. 들여쓰기 때문에 코드를 한눈에 읽을 수 없어 핵심 비즈니스 파악이 어렵다.

public String getCityOfMemberFromOrder(Order order) {
    if (order == null) {
        return "Seoul";
    }
    Member member = order.getMember();
    if (member == null) {
        return "Seoul";
    }
    Address address = member.getAddress();
    if (address == null) {
        return "Seoul";
    }
    String city = address.getCity();
    if (city == null) {
        return "Seoul";
    }
    return city;
}

결과값을 여러 곳에서 리턴하기 때문에 유지보수의 어려움이 생길 수 있다.

2가지 방법 모두 기본적으로 객체의 필드나 메서드에 접근하기 전에 null 체크를 하면서 NPE 를 방지하고 있다.

결국 비즈니스 로직의 파악이 어려워지고 코드 가독성과 유지보수성을 희생해야하는 코드가 나오게 되었다.

Java8 이전 문제

1. 런타임에 NPE가 발생할 수 있다.

2. NPE 방어를 위해 들어간 null 체크 로직 때문에 코드 가독성과 유지 보수성이 떨어진다.

함수형 언어에서 해법 찾기

스칼라같은 소위 함수형 언어들은 전혀 다른 방법으로 이 문제를 해결하고 있다. 자바가 "존재하지 않는 값"을 표현하기 위해서 null을 사용하였다면, 함수형 언어들은 _"존재할지 안할지 모르는 값"_을 표현할 수 있는 별개의 타입이 있다. 이 타입이 존재할지 안할지 모르는 값을 제어할 수 있는 여러가지 API를 제공하기 때문에 해당 API를 통해서 간접적으로 값에 접근할 수 있다. Java8은 java.util.Optional 라는 새로운 클래스를 도입하였다.

Optional 이란?

존재할 수도 있지만 안 할 수도 있는 객체, 즉 null이 될 수도 있는 객체를 감싸고 있는 일종의 래퍼 클래스이다.

Optional을 쓰면

장점

1. 객체가 null이어도 NPE가 발생하지 않고 비즈니스 로직대로 흘러갈 수 있다.

2. 비즈니스 코드와 null 방어 코드가 뒤섞여 있지 않아 로직을 더 쉽게 파악할 수 있다.

3. 명시적으로 해당 변수가 null일 수도 있다는 가능성을 표현할 수 있어 불필요한 방어 로직을 줄일 수 있다.

단점

1. null 체크 및 Wrapping 시 오버헤드가 발생하여 성능이 저하될 수있다. (null이 절대 나오지 않는 경우 Optional을 사용하지 않는 것이 더 성능에 더 좋다.)

Optional 기본 사용법

Optional 변수 사용하기

제네릭을 제공하기 때문에 변수를 선언할 때 명기한 타입 파라미터에 따라서 감쌀 수 있는 객체의 타입이 결정된다.

변수명은 maybe나 opt같은 접두어를 부어 Optional 타입의 변수라는 것을 좀 더 명확히 나타내기도 한다.

Optional<Order> maybeOrder; // Order 타입의 객체를 감쌀 수 있는 Optional 타입의 변수

Optional 객체 생성하기

Optional 클래스는 간편하게 객체 생성을 할 수 있도록 3가지 정적 팩토리 메서드를 제공한다. (empty, of, ofNullable)

- Optional.empty()

null을 담고 있는(비어있는) Optional 객체를 가져온다. 이 비어있는 객체는 Optional 내부적으로 미리 생성해놓은 싱글턴 인스턴스이다.

Optional<Member> maybeMember = Optional.empty();

- Optional.of(value)

null이 아닌 객체를 담고있는 Optional 객체를 생성한다. null이 들어올 경우 NPE를 던지기 때문에 주의해야 한다.

Optional<Member> maybeMember = Optional.of(aMember);

- Optional.ofNullable(value)

null이 아닌지 확신할 수 없는 객체를 담고 있는 Optional 객체를 생성한다. null이 넘어올 경우 NPE를 던지지 않고 Optional.empty()와 동일하게 비어있는 Optional 객체를 가져온다. 해당 객체가 null인지 아닌지 자신없는 상황에서 이 메서드를 사용해야 한다.

Optional<Member> maybeMember = Optional.ofNullable(aMember);
Optional<Member> maybeNotMember = Optional.ofNullable(null);

Optional이 담고있는 객체 접근하기

Optional 클래스는 담고있는 객체를 꺼내기 위해 다양한 인스턴스 메서드를 제공한다. 아래 메서드는 모두 Optional이 담고있는 객체가 존재할 경우 동일하게 해당 값을 반환한다. 반면에 비어있는 경우 (즉, null일 경우) 메서드마다 다르게 동작한다.

- get()

비어있는 Optional 객체에 대해서, NoSuchElementException을 던진다.

- orElse(T other)

비어있는 Optional 객체에 대해서, 넘어온 인자를 반환한다.

- orElseGet(Supplier<? extends T> other)

비어있는 Optional 객체에 대해서, 넘어온 함수형 인자를 통해 생성된 객체를 반환한다.

- orElseThrow(Supplier<? extends X> exceptionSupplier)

비어있는 Optional 객체에 대해서, 넘어온 함수형 인자를 통해 생성된 예외를 던진다.

 

Optional을 잘못 사용할 경우

get() 메서드는 비어있는 Optional 객체를 대상으로 호출할 경우, 예외를 발생시키기 때문에 객체 존재여부를 boolean으로 반환하는 isPresent()라는 메서드를 통해 null 체크가 필요하다.

// Optional을 사용한 경우
String text = getText();
Optional<String> maybeText = Optional.ofNullable(text);
int length;
if (maybeText.isPresent()) {
    length = maybeText.get().length();
} else {
    length = 0;
}

// Optional을 사용하지 않은 경우
String text = getText();
int length;
if (text == null) {
    length = text.length();
} else {
    length = 0;
}

이렇게 사용할 경우 Optional을 사용해서 좋아진 부분이 없다.

Optional 적용 후에는 null 체크를 할 필요가 없다. (null 체크를 하지 않고 Optional에 위임하기 위해 Optional을 사용한다.)

 

Optional을 정확히 이해했다면 다음과 같이 한줄로 코드를 작성할 수 있어야 한다. 

int length = Optional.ofNullable(getText()).map(String::length).orElse(0);

 

 출처 

www.daleseo.com](https://www.daleseo.com/java8-optional-after/)

https://www.daleseo.com/java8-optional-after/

 

자바8 Optional 2부: null을 대하는 새로운 방법

Engineering Blog by Dale Seo

www.daleseo.com

 

https://maivve.tistory.com/332

 

[Java] JAVA8 Optional Class에 대한 정의 및 사용방법 - Tutorial(Sample)

https://github.com/201402407/JAVA_LOGIC_TUTORIAL GitHub - 201402407/JAVA_LOGIC_TUTORIAL: 자바의 기본기를 다지는 코딩 가이드 및 자바 튜토리얼 자바의 기본기를 다지는 코딩 가이드 및 자바 튜토리얼. Contribute to 2014

maivve.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

Java에서 Enum을 활용적으로 !

 

Enum을 통해 얻는 기본적인 장점들은 아래와 같다.

1. 문자열과 비교해, IDE의 적극적인 지원을 받을 수 있다.

- 자동완성, 오타검증, 텍스트 리팩토링

2. 허용 가능한 값들을 제한할 수 있다.

3. 리팩토링 시, 변경 범위가 최소화된다.

- 내용의 추가가 필요하더라도, Enum 코드 외에 수정할 필요가 없다.

 

이 장점들은 모든 언어들의 Enum에서 얻을 수 있는 공통적인 장점이다.

 

하지만, Java의 Enum은 이보다 더 많은 장점을 가지고 있다.

Java의 Enum은 완전한 기능을 가진 클래스이기 때문이다. (C/C++의 경우 Enum이 결국 int 값이다.)

 

1. 데이터들간의 연관관계 표현

origin 테이블에 있는 내용을 2개의 테이블 (테이블명: table1, table2)에 등록하는 기능이 있다고 하자.

origin 테이블의 값은 "Y", "N"으로 저장되는데 table1, table2는 각각 "1", "0" / true, false 형태로 저장된다고 한다.

그럼 이를 분류하는 메서드를 다음과 같이 만들 것이다.

public class LegacyCase {
    
    public String toTable1Value(String originValue) {
        if ("Y".equals(originValue)) {
            return "1";
        } else {
            return "0";
        }
    }
    
    public boolean toTable2Value(String originValue) {
        if ("Y".equals(originValue)) {
            return true;
        } else {
            return false;
        }
    }
}

기능상의 문제는 없지만, 문제가 있다.

1. "Y", "1", true가 모두 같은 의미라는 것을 알 수 없다.

"Y"란 값이 "1"이 될 수도 있고 true가 될 수도 있다는 것을 확인하려면 항상 위에서 선언된 클래스와 메서드를 찾아야 한다.

2. 불필요한 코드량이 많다.

- "Y", "N"이외에 "R", "S" 등의 추가 값이 필요한 경우, if문을 포함하여 메서드 단위로 코드가 증가하게 된다.

- 동일한 타입의 값이 추가되는 것에 비해 너무 많은 반복성 코드가 발생하게 된다.

 

그래서 이 부분을 Enum으로 추출해보자.

public enum TableStatus {
    
    Y("1", true);
    N("0", false);
    
    private String table1Value;
    private boolean table2Value;
    
    TableStatus(String table1Value, boolean table2Value) {
        this.table1Value = table1Value;
        this.table2Value = table2Value;
    }
    
    public String getTable1Value() {
        return table1Value;
    }
    
    public boolean getTable2Value() {
        return table2Value;
    }
}

"Y", "1", true 가 한 묶음으로, "N", "0", false가 한 묶음이 된 것을 코드로 확인할 수 있다.

또한 추가 타입이 필요한 경우에도, Enum 상수와 get 메서만 추가하면 된다.

(lombok의 @Getter 를 사용하여 Enum의 get 메서드를 대신할 수 있다.)

 

@Test
public void test() throws Exception {
    
    // given
    TableStatus origin = selectFromOriginTable();
    
    String table1Value = origin.getTable1Value();
    String table2Value = origin.getTable2Value();
    
    assertThat(origin, is(TableStatus.Y));
    assertThat(table1Value, is("1"));
    assertThat(table2Value, is(true));
}

 

2. 상태와 행위를 한 곳에서 관리

서로 다른 계산식을 적용해야할 때가 있다.

DB에 저장된 code의 값이 "CALC_A"일 경우엔 값 그대로, "CALC_B"일 경우에 10을 곱해서, "CALC_C"인 경우에는 3을 곱해서 전달해야 한다.

가장 쉬운 해결방법은 아래와 같이 static 메서드를 작성해서 필요한 곳에 호출하는 방식이다.

public class LegacyCalculator {
    
    public static long calculate(String code, long originValue) {
        if ("CALC_A".equals(code)) {
            return originValue;
        } else if ("CALC_B".equals(code)) {
            return originValue * 10;
        } else if ("CALC_B".equals(code)) {
            return originValue * 3;
        } else {
            return 0;
        }
    }
}

이렇게 메서드를 분리하고 사용하다 보면, 코드는 코드대로 조회하고, 계산은 별도의 클래스와 메서드에서 진행하게 된다.

@Test
public void 코드에_따른_서로다른_계산_legacy() throws Exception {
    String code = selectCode();
    long originValue = 10000L;
    long result = LegacyCalculator.calculator(code, originValue);
    
    assertThat(result, is(10000L));
}

이 상황에 문제가 있다.

LegacyCalculator의 메서드와 code가 서로 관계가 있음을 표현할 수 없기 때문이다.

이런 경우,

1. 똑같은 기능을 하는 메서드를 중복 생성할 수 있다.

- 히스토리 관리가 되지 않은 상태에서 계산 메서드를 중복 생성할 수 있다.

- 관리 포인트가 증가할 확률이 매우 높다.

2. 계산 메서드를 누락할 수 있다.

- 결국 문자열과 메서드로 분리되어 있어, 이 계산 메서드를 써야함을 알 수 없어 새로운 기능 생성 시 계산 메서드 호출이 누락될 수 있다.

 

"DB에서 뽑은 특정 값은 지정된 메서드와 관계가 있다."

역할과 책임이라는 관점에서 봤을 때, 위 메시지는 Code에 책임이 있다.

public class CalculatorType {

    CALC_A(value -> value),
    CALC_B(value -> value * 10),
    CALC_C(value -> value * 3),
    CALC_ETC(value -> 0L);
    
    private Function<Long, Long> expression;
    
    CalculatorType(Function<Long, Long> expression) {
        this.expression = expression;
    }
    
    public long calculate(long value) {
        return expression.apply(value);
    }
}

Code가 본인 만의 계산식을 가지도록 지정하였다. (Java8이 업데이트되면서, 인자값으로 함수를 사용할 수 있게 되었다.)

 

Entity 클래스에서 선언할 경우에 String이 아니라 enum을 선언하면 된다.

@Column
@Enumerated(EnumType.STRING)
private CalculatorType calculatorType;

실제로 사용하는 곳에서도 직접 Code에게 계산을 요청하자.

@Test
public void 코드에_따라_서로다른_계산_enum() throws Exception {
    CalculatorType code = selectCode();
    long originValue = 10000L;
    long result = code.calculate(originValue);
    
    assertThat(result, is(10000L));
}

3. 데이터 그룹관리

결제라는 데이터는 '결제 종류'와 '결제 수단'이라는 2가지 형태로 표현된다.

예를 들어 신용카드 결제는 '신용카드'라는 결제 수단과 '카드'라는 결제 종류가 있다.

 

결제된 건이 어떤 결제 수단으로 진행되며, 결제 방식이 어떤 결제 종류에 속하는지 확인해야한다고 하자.

이를 해결하는 가장 쉬운 방법은 if문이다.

public class LegacyPayGroup {
    
    public static String getPayGroup(String payCode) {
        
        if ("ACCOUNT_TRANSFER".equals(payCode) || "PERMITTANCE".equals(payCode) || "ON_SITE_PAYMENT".equals(payCode) || "TOSS".equals(payCode)) {
            return "CASH";
        } else if ("PAYCO".equals(payCode) || "CARD".equals(payCode) || "KAKAO_PAY".equals(payCode) || "BAEMIN_PAY".equals(payCode)) {
            return "CARD";
        } else if ("POINT".equals(payCode) || "COUPON".equals(payCode)) {
            return "ETC";
        } else {
            return "EMPTY";
        }
    }
}

여기서도 여러 문제가 있다.

1. 둘의 관계를 파악하기 힘들다.

- 위 메서드는 포함관계를 나타내는 것일까. 아니면 단순한 대체값을 리턴한 것일까?

- 현재는 결제 종류가 결제 수단을 포함하고 있는 관계인데, 메서드만으로 표현이 불가능하다.

2. 입력값과 결과값이 예측 불가능하다.

- 결제 수단의 범위를 지정할 수 있어서 문자열이면 전부 파라미터로 전달될 수 있다.

- 마찬가지로 결과를 받는 쪽에서도 문자열을 받기 때문에 결제 종류로 지정된 값만 받을 수 있도록 검증코드가 필요하게 된다.

3. 그룹별 기능을 추가하기가 어렵다.

- 또 다시 결제 종류에 따른 if문으로 메서드를 만들어야 하나?

 

각각의 메서드는 원하는 때에 사용하기 위해 독립적으로 구성할 수 밖에 없는데

그럴 때마다 결제 종류를 분기하는 코드가 필수적으로 필요하다. 

 

각 타입은 본인이 수행해야할 기능과 책임과 가질 수 있게 하기 위해 enum 타입으로 변경하자.

@Getter
public enum PayGroup {

    CASH("현금", Arrays.asList("ACCOUNT_TRANSFER", "REMITTANCE", "ON_SITE_PAYMENT", "TOSS")),
    CARD("카드", Arrays.asList("PAYCO", "CARD", "KAKAO_PAY", "BAEMIN_PAY")),
    ETC("기타", Arrays.asList("POINT", "COUPON")),
    EMPTY("없음", Collections.EMPTY_LIST);
    
    private String title;
    private List<String> payList;
    
    PayGroup(String title, List<String> payList) {
        this.title = title;
        this.payList = payList;
    }
    
    public static PayGroup findByPayCode(String code) {
        return Arrays.stream(PayGroup.values())
                .filter(payGroup -> payGroup.hasPayCode(code))
                .findAny()
                .orElse(EMPTY);
    }
    
    public boolean hasPayCode(String code) {
        return payList.stream()
                .anyMatch(pay -> pay.equals(code));
    }
}

Java의 Enum은 결국 클래스이기 때문에 Enum 상수에 결제 종류 문자열 리스트를 가지도록 했다.

@Test
public void PayGroup에게_직접_결제종류_물어보기_문자열() throws Exception {
    String payCode = selectPayCode();
    PayGroup payGroup = PayGroup.findByPayCode(payCode);
    
    assertThat(payGroup.name(), is("BAEMIN_PAY"));
    assertThat(payGroup.getTitle(), is("배민페이"));
}

하지만, 해결되지 않은 것이 있다.

결제수단은 문자열로 되어있다.

DB 테이블의 결제수단 컬럼에 잘못된 값을 등록하거나, 파라미터로 전달된 값이 잘못되었을 경우가 있을 때 관리가 되지않을 위험이 있다.

public enum PayType {

    ACCOUNT_TRANSFER("계좌이체"),
    REMITTANCE("무통장입금"),
    ON_SITE_PAYMENT("현장결제"),
    TOSS("토스"),
    PAYCO("페이코"),
    CARD("신용카드"),
    KAKAO_PAY("카카오페이"),
    BAEMIN_PAY("배민페이"),
    POINT("포인트"),
    COUPON("쿠폰"),
    EMPTY("없음");
    
    private String title;
    
    PayType(String title) {
        this.title = title;
    }
    
    public String getTitle() {
        return title;
    }
}

이렇게 Enum으로 결제종류를 만들고 PayGroup에서 사용해보자.

@Getter
public enum PayGroupAdvanced {

    CASH("현금", Arrays.asList(PayType.ACCOUNT_TRANSFER, PayType.REMITTANCE, PayType.ON_SITE_PAYMENT, PayType.TOSS)),
    CARD("카드", Arrays.asList(PayType.PAYCO, PayType.CARD, PayType.KAKAO_PAY, PayType.BAEMIN_PAY)),
    ETC("기타", Arrays.asList(PayType.POINT, PayType.COUPON)),
    EMPTY("없음", Collections.EMPTY_LIST);
    
    private String title;
    private List<String> payList;
    
    PayGroup(String title, List<String> payList) {
        this.title = title;
        this.payList = payList;
    }
    
    public static PayGroup findByPayType(PayType payType) {
        return Arrays.stream(PayGroupAdvanced.values())
                .filter(payGroup -> payGroup.hasPayCode(payType))
                .findAny()
                .orElse(EMPTY);
    }
    
    public boolean hasPayCode(PayType payType) {
        return payList.stream()
                .anyMatch(pay -> pay == payType));
    }
}
@Test
public void PayGroup에게_직접_결제종류_물어보기_enum() throws Exception {
    PayType payType = selectPayType();
    PayGroupAdvanced payGroup = PayGroupAdvanced.findByPayType(payType);
    
    assertThat(payGroup.name(), is("BAEMIN_PAY"));
    assertThat(payGroup.getTitle(), is("배민페이"));
}

PayType으로 데이터를 받아 타입 안전성까지 확보하여 관련 처리를 진행할 수 있다.

4. 관리 주체를 DB에서 객체로

DB에서 코드를 관리할 경우 문제가 발생할 수 있다.

1. 코드명만 봐서는 무엇을 나타내는지 알 수 없다.

- 문서화가 되어있다 하더라도, 문서 업데이트가 잘 되어있는지 확신할 수 없어 DB를 다시 찾아봐야 하는 번거로움이 있다.

2. 항상 코드 테이블 조회 쿼리가 실행되어야 했다.

- 화면에 표시하기 위해 코드 테이블을 조회해야 한다.

 

Enum을 바로 JSON으로 리턴하게 되면, 상수 name만 출력된다.

Enum의 name과 title 모두 필요한 경우가 많다.

클래스의 생성자로 일관된 타입을 받기 위해 인터페이스를 하나 생성하자.

public interface EnumMapperType {
    String getCode();
    String getTitle();
}
@Getter
@ToString
public class EnumMapperValue {
    
    private String code;
    private String title;
    
    public EnumMapperValue(EnumMapperTupe enumMapperType) {
        code = enumMapperType.getCode();
        title = enumMapperType.getTitle();
    }  
}

Enum은 미리 선언한 인터페이스를 구현하면 된다.

public enum FeeType implements EnumMapperType {
    
    PERCENT("정율"),
    MONEY("정액");
    
    private String title;
    
    FeeType(String title) {
        this.title = title;
    }
    
    @Override
    public String getCode() {
        return name();
    }
    
    @Override
    public String getTitle() {
        return title;
    }
}

 

이제 Enum을 Value 클래스로 변경한 후, 전달하자.

@GetMapping("/no-bean-categories")
public List<EnumMapperValue> getNoBeanCategory() {
    return Arrays.stream(FeeType.values())
            .map(EnumMapperValue::new)
            .collect(Collectors.toList());
}

/*
[
  {
    "code": "PERCENT",
    "title": "정율"
  },
  {
    "code": "MONEY",
    "title": "정액"
  }
]

*/

 

 

 

 출처 

https://techblog.woowahan.com/2527/

 

Java Enum 활용기 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요? 우아한 형제들에서 결제/정산 시스템을 개발하고 있는 이동욱입니다. 이번 사내 블로그 포스팅 주제로 저는 Java Enum 활용 경험을 선택하였습니다. 이전에 개인 블로그에 E

techblog.woowahan.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다. 
도메인에서 사용하는 용어를 코드에 반영하지 않으면
그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다.
도메인 모델의 Entity나 value에 공개 set 메서드만 넣지 않아도 일관성이 깨질 가능성이 줄어든다.
공개 set 메서드를 사용하지 않으면 의미가 드러나는 메서드를 사용해서 구현할 가능성이 높아진다.
예를 들어, set 형식의 이름을 갖는 공개 메서드를 사용하지 않으면
자연스럽게 cancel이나 changePassword 처럼 의미가 더 잘 드러나는 이름을 사용하는 빈도가 높아진다.
- 도메인 주도 개발 시작하기, 최범균-

 

'setter를 지양하라'라는 말을 구체적으로 서술하면

외부에서 필드의 값을 변경하기 위해 접근할 때,

단순하게 setXXX라는 이름의 메서드 하나만 있는 것이 아니라,

필드의 값을 변경하려는 목적을 파악하여

그 목적을 잘 표현하는 메서드를 제공하라.

 

 

예제

'회원' 객체는 회원 이름과 회원 상태 속성을 가지고 있다.

setState 메서드는 회원의 상태를 변경하는 '공개 set 메서드'이다.

public class 회원 {
    
    private String name;
    private String state;
    
    public String getName() {
        return name;
    }
    
    public String getState() {
        return state;
    }
    
    public void setState(String state) {
        this.state = state;
    }
}

회원관리 서비스는 회원의 상태를 조정하여 회원을 차단하거나, vip로 승격한다.

public class 회원관리 {
    
    public void blockMember(회원 회원) {
    
        if (회원.getName().equals("jyjeon")) { // 공통 도메인 규칙
            throw new IllegalArgumentException("닉네임이 jyjeon인 사람의 정보는 변경할 수 없습니다.");
        }
        if (회원.getState().equals("vip")) { // 회원 차단 도메인 규칙
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
        
        회원.setState("blocked");
    }
    
    public void upgradeMemberToVip(회원 회원) {
        if (회원.getName().equals("jyjeon")) { // 공통 도메인 규칙
            throw new IllegalArgumentException("닉네임이 jyjeon인 사람의 정보는 변경할 수 없습니다.");
        }
        if (회원.getState().equals("blocked")) { // vip 상향 도메인 규칙
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
        
        회원.setState("vip");
    }
}

회원의 상태(state 필드 값)를 변경하기 위해서는 회원 도메인의 규칙을 지켜야 합니다.

- 특정 닉네임을 가진 회원의 상태는 변경할 수 없다.

- vip는 차단할 수 없다.

- 차단된 회원은 vip가 될 수 없다.

 

공개 set 메서드의 문제점 1) 도메인 로직의 분산

공개 set 메서드는 도메인의 의미나 의도를 표현하지 못하고
도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산시킨다.
도메인 로직이 한 곳에 응집되지 않으므로 
코드를 유지보수할 때에도 분석하고 수정하는 데 더 많은 시간이 필요하다.
- 도메인 주도 개발 시작하기, 최범균 -

"도메인 로직이 응용 영역이나 표현 영역으로 분산된다."의 의미를 알아보자.

 

도메인 모델 패턴에서, 사용자의 요청을 처리하고 사용자에게 정보를 보여주는 표현 영역이라 한다.

MVC 패턴은 http 요청을 받고 응답하는 컨트롤러 클래스들은 표현 영역이라 할 수 있다.

사용자가 요청한 기능을 실행하는 영역을 응용 영역이라 한다. MVC 패턴은 비즈니스 로직을 수행하는 서비스 클래스가 응용 영역이다.

 

특정 도메인을 개념적으로 표현하고, 도메인의 핵심 규칙이 있는 영역을 도메인 영역이라고 한다. 도메인 영역은 도메인 객체 하나를 가리킬 수도 있지만, 연관이 있는 상위 도메인과 하위 도메인들을 한 다발로 묶어 Aggregate(애그리거트)라는 군집을 가리킬 수도 있다.

 

예제에서 '회원의 상태를 변경하기 위해서 회원이 특정한 이름을 가지고 있는지 확인해야 한다.'는 규칙이 있다. 도메인의 속성을 변경하기 위한 핵심 규칙이기 때문에 이 규칙은 도메인 영역에서 구현되어야 한다.

 

하지만, 예제에서는 도메인 규칙을 표현하는 코드가 비즈니스 로직을 수행하는 '회원관리' 클래스에 작성되어 있다. 다시말해, 도메인 로직이 응용 영역으로 분산되어 있다.

 

1. 서비스가 커지면서 회원의 상태를 변경하는 곳이 29억 군데라면? 29억 군데에 규칙을 표현해야 한다.

2. 회원의 상태를 변경하기 위한 규칙이 추가되거나 삭제된다면? 29억 군데의 비즈니스 로직을 전부 수정해야 한다.

 

공개 set 메서드를 사용할 때, 도메인 로직이 한 곳에 응집되지 않으므로 코드를 유지 보수할 때에도 분석하고 수정하는 데 시간이 필요하다. 

 

 

단순히 값을 변경하는 공개 set 메서드가 일으킬 문제에 대해 알아보았다.

그럼 값을 변경하는 것에 더해서 공통 규칙을 set 메서드에 넣어주면 해결이 될까?

// 회원.java
public void setState(String state) {
    if (this.name.equals("jyjeon")) {
        throw new IllegalArgumentException("닉네임이 jyjeon인 회원의 정보는 변경할 수 없습니다.");
    }
    
    this.state = state;
}
public class 회원관리 {
    
    public void blockMember(회원 회원) {
        if (회원.getState().equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
        
        회원.setState("blocked"); // 공통 규칙은 set 메서드에 있다.
    }

    public void upgradeMembetToVip(회원 회원) {
        if (회원.getState().equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
        
        회원.setState("vip"); // 공통 규칙은 set 메서드에 있다.
    }
}

 

하지만, 'vip는 차단할 수 없다.' '차단된 회원은 vip가 될 수 없다.' 라는 규칙도 도메인 규칙이고, 도메인 영역에 있어야 한다. 지금은, 그 책임을 응용 영역에서 해결하고 있다.

 

그렇다면, 특정 조건에 따른 도메인 규칙도 set 메서드를 넣으면 해결이 될까?

public void setState(String state) {
    if (this.name.equals("jyjeon")) {
        throw new IllegalArgumentException("닉네임이 jyjeon인 회원의 정보는 변경할 수 없습니다.");
    }

    if (state.equals("blocked")) {
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }
    } // 회원을 차단할 때의 도메인 규칙?

    if (state.equals("vip")) {
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }
    } // vip로 상향할 때의 도메인 규칙?

    this.state = state;
}
public class 회원관리 {
    public void blockMember(회원 회원) {
        회원.setState("blocked");
    }

    public void upgradeMemberToVip(회원 회원) {
        회원.setState("vip");
    }
}

이런 코드로 해결하는 것은 좋지 않다. 하나의 공간에 너무 많은 책임이 있다.

 

문제점 1 (도메인 로직의 분산)의 해결책

필드의 값을 변경하려는 목적을 제대로 파악하여
그 목적을 잘 표현하는 메서드를 제공해라.
public class 회원 {
    private String name;
    private String state;

    public String getName() {
        return name;
    }
    
    public String getState() {
        return state;
    }

    public void blockMember() {
        verifyName();
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }

        this.state = "blocked";
    }

    public void upgradeMemberToVip() {
        verifyName();
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }

        this.state = "vip";
    }

    private void verifyName() { // 공통 도메인 규칙
        if (this.name.equals("jyjeon")) {
            throw new IllegalArgumentException("닉네임이 jyjeon인 회원의 정보는 변경할 수 없습니다.");
        }
    }
}
public class 회원관리 {
    public void blockMember(회원 회원) {
        회원.blockMember(); // 도메인 관련 로직을 도메인 영역에 위임
    }
    
    public void upgradeMemberToVip(회원 회원) {
        회원.upgrageMemberToVip(); // 도메인 관련 로직을 도메인 영역에 위임
    }
}

이 코드가 기존의 문제점을 어떻게 해결해줄까?

1. 공통 도메인 규칙이 응용 영역으로 분산된다.

회원 도메인의 상태를 변경할 때 공통으로 필요한 도메인 규칙을 도메인 영역 안에서 구현하고 지키게함으로써, 도메인 영역 외의 영역들에서는 더이상 공통 도메인 규칙에 대해 신경 쓸 필요가 없다.

헌데, 도메인 영역 안의 메서드라 해도, 이런 식의 코드는 공통 도메인 규칙을 검사하는 것을 깜빡할 수도 있다.

public class 회원 {
    private String name;
    private String state;

    public String getName() {
        return name;
    }

    public void blockMember() {
        if (this.state.equals("vip")) {
            throw new IllegalArgumentException("vip는 차단할 수 없습니다.");
        }

        setState("blocked");
    }

    public void upgradeMemberToVip() {
        if (this.state.equals("blocked")) {
            throw new IllegalArgumentException("차단된 사용자는 vip가 될 수 없습니다.");
        }

        setState("vip");
    }

    private void setState(String state) {
        verifyName(); // 공통 도메인 규칙과 필드에 값을 할당하는 로직을 묶어서 메서드로 뺀다.
        this.state = state;
    } 

    private void verifyName() {
        if (this.name.equals("jyjeon")) {
            throw new IllegalArgumentException("닉네임이 jyjeon인 회원의 정보는 변경할 수 없습니다.");
        }
    }

    public String getState() {
        return state;
    }
}

공통 도메인 규칙과 필드에 값을 할당하는 코드를 setState()라는 메서드로 묶어 추출했다. 필드에 값을 할당하고자 한다면 직접 값을 할당하지 말고 내부적으로 다시 setState()를 호출하도록 만들면, 값 할당과 함께 공통 도메인 규칙을 검사하는 로직이 실행된다.

 

2. 상황별 도메인 규칙이 응용 영역으로 분산된다.

각 상황별로 각각의 공개 메서드를 만들어서 그 안에 도메인 규칙을 구현하고, 그 각각의 메서드를 외부에 제공한다.

응용 영역에서 도메인 영역과 소통할 때, 도메인 모델과 관련된 모든 규칙들은 도메인 모델 안에서 알아서 구현하도록 했기 때문에, 응용 영역은 어떠한 도메인 규칙에 대해서도 신경쓸 필요가 없다. (캡슐화)

 

도메인과 관련된 어떤 처리가 필요할 때는 도메인 영역에서 제공하는 공개 메서드들 중에 목적에 맞는 메서드를 호출하고, 응용영역은 비즈니스 로직에만 집중할 수 있습니다.

 

비즈니스 로직에서 도메인 규칙을 가지고 있음 -> 응용 영역으로 도메인 영역의 책임이 분산된다. -> 응용 영역에 너무 많은 책임을 가지게 된다.

공개 set 메서드의 문제점 2) 잘못 정의한 메시지

Never Trust User (사용자를 믿지 마라)

'객체 지향의 사실과 오해 (조영호)'라는 책에서 객체와 객체 간에 주고 받는 메시지의 역할을 강조한다.

메시지를 수신받은 객체는 우선 자신이 해당하는 메시지를 처리할 수 있는지 확인한다.
메시지를 처리할 수 있다는 이야기는 객체가 해당 메시지에 해당하는 행동을 수행해야 할 책임이 있다는 것을 의미한다.
따라서 근본적으로 메시지의 개념은 책임의 개념과 연결된다.
송신자는 메시지 전송을 통해서만 다른 객체의 책임을 요청할 수 있고, 수신자는 오직 메시지 수신을 통해서만 자신의 책임을 수행할 수 있다.
따라서 객체가 수신할 수 있는 메시지의 모양이 객체가 수행할 책임의 모양을 결정한다.
- 객체지향의 사실과 오해, 조영호 -

객체가 객체에게 어떤 행동을 지시하는 유일한 방법은 메시지를 주고받는 것이다.

set메서드를 가지고 있는데 메시지 상으로 'XXX를 바꾼다'라는 메시지를 받아 수행할 수 있다.

하지만, 실제로는 그 메서드를 실행했을 때 문제가 생길 수 있음에도 실행 권한을 주어 버렸으므로 문제가 발생하는 것이다.

 

문제점 2 (잘못 정의한 메시지) 해결책

도메인 모델의 entity나 value에 공개 set 메서드만 넣지 않아도 일관성이 깨질 가능성이 줄어든다.
공개 set 메서드를 사용하지 않으면 의미가 드러나는 메서드를 사용해서 구현할 가능성이 높아진다.
예를 들어, set 형식의 이름을 갖는 공개 메서드를 사용하지 않으면
자연스럽게 cancel이나 changePassword처럼 의미가 더 잘 드러나는 이름을 사용할 빈도가 높아진다.
public class Post {

    private Long id;
    private String title;
    private String content;
    private Member writer;
}

1. id는 유일한 식별자이다. 통상적으로 식별자 값은 데이터의 무결성과 연관관계 등 고려해야 할 제약조건이 많아 수정하지 않는 것을 권장한다.

2. title을 바꾸기 위해서는 새 title이 '영어 대/소문자, 숫자, 특수문자를 포함하여 10자 이상'을 만족하는지 검사한다.

검사를 마친 후, title를 바꾸도록 메서드를 구현하고 setTitle() 보단 changeTitle()로 실행의도를 분명히 들어낸다.

3. writer의 값을 바꾸는 것은 금지되어 있다. 관련 메서드는 제공하지 않는다.

 

public class Post {
    
    private Long id;
    private String title;
    private String content;
    private Member writer;
    
    public changeTitle(String newTitle) {
        checkTtile(newTitle);
        this.title = newTitle;
    }
    
    private void checkTitle(String title) {
        Pattern titlePattern = Pattern.compile("^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@$!%*#?&])[A-Za-z[0-9]$@$!%*#?&]{10,}$");
        Matcher matcher = titlePattern.matcher(title);

        if (!matcher.find()) {
            throw new IllegalArgumentException("title의 작성 양식에 맞지 않습니다.");
        }
    }
}

 

 출처  setter 쓰지 말라고만 하고 가버리면 어떡해요 - 여우

https://velog.io/@backfox/setter-%EC%93%B0%EC%A7%80-%EB%A7%90%EB%9D%BC%EA%B3%A0%EB%A7%8C-%ED%95%98%EA%B3%A0-%EA%B0%80%EB%B2%84%EB%A6%AC%EB%A9%B4-%EC%96%B4%EB%96%A1%ED%95%B4%EC%9A%94

 

setter 쓰지 말라고만 하고 가버리면 어떡해요

부트캠프 과정을 잘 견뎌내고 팀프로젝트에서 백엔드를 맡은 엄준식(27)씨.백엔드 커리큘럼을 유난히 즐거워했던 준식씨였기에 자신이 맡은 파트가 꽤 마음에 드는 모양이다.준식씨: 됐다! 게시

velog.io

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

https://frogand.tistory.com/190

 

지난 포스팅에 @CurrentUser란 커스텀 어노테이션을 만들면서 @Target, @Retention을 사용했습니다.

 

meta-annotation

메타 어노테이션은 다른 어노테이션에서도 사용되는 어노테이션을 말하며, 커스텀 어노테이션을 생성할 때 주로 생성된다.

 

@Target

@Target은 자바 컴파일러가 어노테이션이 어디에 적용될지 결정하기 위해 사용한다.

예를들어 Spring의 @Service 어노테이션의 ElementType.Type은 해당 어노테이션은 타입 선언 시 사용한다는 의미다.

 

ElementType.PACKAGE : 패키지 선언

ElementType.TYPE : 타입 선언

ElementType.ANNOTATION_TYPE : 어노테이션 타입 선언

ElementType.CONSTRUCTOR : 생성자 선언

ElementType.FIELD : 멤버 변수 선언

ElementType.LOCAL_VARIABLE : 지역 변수 선언

ElementType.METHOD : 메서드 선언

ElementType.PARAMETER : 전달 인자 선언

ElementType.TYPE_PARAMETER : 전달인자 타입 선언

ElementType.TYPE_USE : 타입 선언

 

@Retention

@Retention은 어노테이션이 실제로 적용되고 유지되는 범위를 의미한다.

 

RetentionPolicy.RUNTIME : 컴파일 이후에도 JVM에 의해 계속 참조가 가능하다. 주로 리플렉션이나 로깅에 많이 사용한다.

RetentionPolicy.CLASS : 컴파일러가 클래스를 참조할 때까지 유효하다.

RetentionPolicy.SOURCE : 컴파일 전까지만 유효한다. 즉, 컴파일 이후에는 사라지게 된다.

 

 

 출처 

https://sanghye.tistory.com/39

 

[Spring] Meta Annotation 이란?(@Target, @Retention)

Spring 에서는 Anntotation 사용에 대한 기능을 상당히 많이 제공하고 있습니다. 주로 사용하는 @Controller, @Service, @Repostiroy 등 많은 Annotation 이 존재합니다. 해당 Annotion 은 각 기능에 필요한 만큼..

sanghye.tistory.com

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !