728x90

Service Layer

이직하고 프로젝트에 대해 구조 파악을 하면서 리팩토링을 진행중에 좀 생각하게 된게 꽤많았다.
사실 서비스 레이어라고 해서 비즈니스 로직을 다 넣는건가?
또는 비즈니스 로직이 다 들어가 있는 것이다. 라는 얘기들이 많았다.
사실 나도 그렇게 생각했었던 사람중 1명이었다.
이게 근데 잘못된 생각이었다.

결국엔 사실 소스 코드를 다 분리하고 봐도 하나로 합쳐져서 동작하게 되는건 사실 맞다고 본다.

그러니까 다시 말하면 클래스 하나의 메소드에서 엄청나게 많은 줄을 가지고 개발을 할 수도 있다는 것이다.
근데 이거는 객체 지향 설계 관점에선 너무 안좋은 것이고
각자의 책임이 있을 것인데 그걸 분리한게 객체 지향인거다.

Service Layer에 대한 오해

일단 이 서비스 레이어에서는 비즈니스 로직이 넘쳐나게 될 것이 아니라,

적어도 뭔가의 조건을 통해 돌려주고 수정하고 하는 로직들은 도메인 객체가 해야될 일이라는 것이다.

보통의 서비스 레이어 특징

  • @Service에 사용되는 서비스
  • 일반적으로는 @Controller, @Repository에 사용된다.
  • @Transactional 이 사용되는 영역

도메인 특징

  • 도메인이라고 불리는 대상이 뭘 하는 객체인지 모든 사람들이 알 수 있고 공유하게 만든 모델
    • 주문, 상품 등등.. 이 도메인이 될 수 있다.
  • JPA를 사용한다면 @Entity 모델이 될 수도 있다.
    • 그렇지만, DB의 테이블과 동일해야 한다? 에서는 NO라고 할 수 있다.

모아서 보니까 결국엔 비즈니스 로직은 도메인이 가져야 한다.

그렇다면 서비스 레이어는???

트랜잭션 관리, 도메인의 순서 대로 객체에게 할당을 하여 식만 조합해주는 느낌으로 가야한다.
그래서 나는 예시 소스로 간단하게 만들어보자면

public class ExampleService {
    private final ExampleRepository1 exampleRepository1;
    private final ExampleRepository2 exampleRepository2;
    private final ExampleRepository3 exampleRepository3;

    public ExampleService(ExampleRepository1 exampleRepository1,
     ExampleRepository2 exampleRepository2, ExampleRepository3 exampleRepository3) {
        this.exampleRepository1 = exampleRepository1;
        this.exampleRepository2 = exampleRepository2;
        this.exampleRepository3 = exampleRepository3;
    }
}

만약 이런 클래스가 있어서 각 repository별로 사용하는 트랜잭션이 다르다면 트랜잭션 처리가 애매했다.
위 구조를 아래와 같이 변경했다.

public class ExampleFacade {
    private final ExampleService1 exampleService1;
    private final ExampleService2 exampleService2;
    private final ExampleService3 exampleService3;

    public ExampleService(ExampleService1 exampleService1,
     ExampleService2 exampleService2, ExampleService3 exampleService3) {
        this.exampleService1 = exampleService1;
        this.exampleService2 = exampleService2;
        this.exampleService3 = exampleService3;
    }

}

@Transactional
public class ExampleService1 {
    private final ExampleRepository1 exampleRepository1;

    public ExampleService1(ExampleRepository1 exampleRepository1) {
        this.exampleRepository1 = exampleRepository1;
    }
}

@Transactional
public class ExampleService2 {
    private final ExampleRepository2 exampleRepository2;

    public ExampleService1(ExampleRepository2 exampleRepository2) {
        this.exampleRepository2 = exampleRepository2;
    }
}

@Transactional
public class ExampleService3 {
    private final ExampleRepository3 exampleRepository3;

    public ExampleService1(ExampleRepository3 exampleRepository3) {
        this.exampleRepository3 = exampleRepository3;
    }
}

이렇게 분리를 해서 트랜잭션 관리를 해줬었다.
이게 이제 이전에 내가 퍼사드 패턴 적용기 라고 해서 포스팅을 했었던 구조인데
거기에 +@ 로 덧붙인다면 각 도메인에 대한 로직 수행을 서비스 -> 도메인 이 과정을
넣어주어야 완성인 것 같다.
객체 설계에 대해 점차 이해가 쏙쏙 되는 중이다.

728x90

'Spring' 카테고리의 다른 글

@ExceptionHandler  (0) 2022.08.10
Spring Rest Docs 테스트로 문서화를 해보자!  (0) 2022.08.10
Validaion  (0) 2022.08.09
Filter, Interceptor 정리  (0) 2022.08.07
728x90

📌 Service 레이어 테스트

저번 퍼사드 패턴 적용기 에서
언급했던 테스트에 대해서 써보려고 한다.
바로 정리해보도록 하겠다.
이전의 Mockito 에 대해서 정리한 글이 있다.

📌 테스트 과정

일단 퍼사드 패턴을 적용함으로써
여러개의 서비스를 하나의 퍼사드가 관리해주는 형태로 작성을 했었다.

그러면서 하나의 Service <- 하나의 Repository가 되는 구조를 가지게 되었다.

여기서 @Mock이 등장하게 되었는데,

기존의 @Autowired 방식으로 사용했다면 꿈도 못꾸는 가짜 객체 주입이다.

Service service = new Service(); 해서 @Autowired된 객체를 주입할 수 있을까?

답은 아니라고 생각한다. 의존을 자동으로 주입해주기 때문에 뭘 할당할 수 없는 구조였다.
하지만 생성자 주입을 해준다면??? 얘기는 다르다.

Service service = new Service(repotisory); 가되기 때문에 @Mock으로 가짜 객체를 서비스에

주입해서 POJO 단위 테스트를 진행할 수 있게 된다.

@Service
@Transactional
public class Service {
    private final Repository repository;

    public Service(final Repository repository) {
        this.repository = repository;
    }

    public Foo findByName(final String name) {
        repository.findByName(name).orElseThrow();
    }
}
public interface Repository extends JpaRepository<Foo, Long> {
    Optional<Foo> findByName(final String name);
}
@Getter
public class Foo {
    private String name;
    private int age;

    public Foo (final String name, final int age) {
        this.name = name;
        this.age = age;
    }
}
@ExtendWith(MockitoExtension.class) //Mockito 의존성을 가져온다.
public class ServiceTest {

    @Mock
    private Repository repository;

    @InjectMocks //주입받을 클래스
    private Service service; //(Service service = new Service(repository);) 와 같은구조가 된다.

    @Test
    void test() {
        //given
        Foo foo = new Foo("테스트", 15);

        //when
        // 여기서 mockito로 가짜객체가 어떤 조건을 실행했을 때 어떤 객체를 돌려주게끔 설정해준다.
        BDDMockito.given(repository.findByName(anyString()).willReturn(Optional.ofNullable(foo));

        //then
        Foo foo = service.findByName("테스트");
        verify(service).findByName("테스트"); //service 객체의 findByName메소드에 name값이 '테스트' 인 것을 호출했는지 검증
        Assertions.assertThat(foo.getName()).isEqualTo("테스트");
        Assertions.assertThat(foo.getAge()).isEqualTo(15);
    }
}

이런식으로 POJO 테스트를 진행할 수 있다.
이 구조로 하여금 더더욱 DI가 중요하다는 것을 깨닫고
기초가 중요하다는 것을 다시금 인지하게 되었다.

@Spy, @Mock의 차이

간략하게 한줄로 설명하자면 @Spy는 실제 인스턴스를 사용,

@Mock은 가짜 인스턴스를 사용한다.

그래서 @SpyMockito.when() 이나 BDDMockito.given() 메서드 등으로 메서드의 행위를 지정해 주지 않으면

@Spy 객체를 만들 때 사용한 실제 인스턴스의 메서드를 호출한다.

그래서 @Spy는 실제 인스턴스를 필요로 하기 때문에 인스턴스를 할당 해주어야 사용이 가능하다.

이걸로 미루어 보아 두개를 상황에 따라 적절하게 엮어서 사용해줘야 한다는 것을 느낀다. 물론 적용 해볼 경험이 필요하다!!!!

728x90
728x90

👏 퍼사드 패턴 적용기

퍼사드 패턴에 대한 설명은 깃허브 에 있다.
리팩토링 진행도중에 좀 좋은 구조를 구성해서 적어본다.

🔥 개요

우선 Service 레이어에서 Repository를 너무 많이 의존하는것이 상당히 컸다.

여러군데에서 가져오는 것 사실 이것은 개발하면서 어쩔 수 없는 노릇일 수 있다.

그러나 의존을 너무 많이 가져간다는 것은 유지보수 측면에서도 힘들것이고,
응집도도 낮은 그런 클래스가 되어버린다.
그래서 일단 생각해 낸것은 우리는 객체지향을 사용하고 있다.
어떤 한 클래스는 조합하는것만을 목표로 하는 클래스가 있다면 어떨까🤔 하고 생각했다.
이 클래스를 생각한것은 기존 프로젝트에 테스트 코드를 추가하려고 하다보니까 고민하게 되었다. 🤔
기존 클래스에서 테스트를 하려면 너무 필요없는 의존성까지 추가해줘야해서 자칫하면 혼동을 불러올 수가 있었다.
그래서 생각한 것이 퍼사드 패턴...

이 퍼사드 클래스가 여러개의 Service 레이어를 두면 그것들을 한데 모아서

처리를 해주면 의존은 낮아지면서 응집도는 올릴 수 있을거라고 생각했다.

왜?

각각 역할에 대한 Repository 와 필요한 의존성만 가지고 하나의 Service로 나눈것을 퍼사드쪽에서

조립해서 써주면 지금과 같은 로직이 되면서 동시에 테스트도 잘 가져갈 수 있을거라 생각했다.

🔥 클래스 다이어그램

스크린샷 2021-12-31 오전 9 43 48

예를 들어 이런 클래스가 있다고 가정하자.

Repository 객체를 두개를 사용하고 있다.

이게 2개일 수 있고 그 이상이 될 수도 있다.
이 구조를 개선하려고 했던 다음 클래스 다이어그램을 보도록 하자.

스크린샷 2021-12-31 오전 9 47 21

이런식으로 하위 객체를 의존성을 1개씩만 갖게하고 퍼사드에서는 조합만 해주면 되는게 된다.
이렇게 되면 각 기능에 대한 단위 테스트를 깔끔하게 진행할 수 있고,
문제가 발생하는 로직에 한해서만 유지보수를 가능할 수 있게 되었다.

결국엔 Spring MVC 패턴이 그냥 이 퍼사드 패턴이 적용되어 있다 라고 생각하면 되지만,

아래의 의존성을 덕지덕지 붙여가면서 하나의 서비스를 뚱뚱하게 유지하기는 싫었다.
예시의 코드로 보자면
멤버와 아이템두개를 합쳐서 보여주는게 있다고 하자.

Member.java

public class Member {
    private Long id;
    private String name;
    private String email;
}

Item.java

public class Item {
    private Long id;
    private String name;
    private int price;
    //getter, setter 생략
}

Repository

public class MemberRepository extends JpaRepository<Member, Long> {
}

public class ItemRepository extends JpaRepository<Item, Long> {
}

레거시

@Service
public class FooService {
    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private ItemRepository itemRepository;

    public void getMemberAndItem(int id) {
        Member member = memberRepository.findById(id).orElseThrow();
        Item item = itemRepository.findById(id).orElseThrow();
        //두 값의 비즈니스 로직을 수행

        return //두개를 합쳐 반환한다.
    }

}

간결하게 두줄만 작성했지만 벌써 두개 조회를 동시에 하고 안에서 로직을 구현하고 있으니 스파게티 코드가 생성될 것이 예상된다.
이 구조를 아래와 같이 변경한다.

개선안

기본적으로 생성자 주입으로 변경을 해준다.

public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(fianl MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Member findById(final int id) {
        Member member = memberRepository.findById(id).orElseThrow();
        // member에 대한 로직 수행...
        return member;
    }
}

public class ItemService {
    private final ItemRepository itemRepository;

    public ItemService(final ItemRepository itemRepository) {
        this.itemRepository = itemRepository;
    }

    public Item findById(final int id) {
        Item item = itemRepository.findById(id).orElseThrow();
        // item에 대한 로직 수행...
        return item;
    }
}

일단 서비스를 위와같이 두개로 분리한다. 그러면 각 Repository에 대해 해야할 일만 명확하게 구분이 되어진다.

이것을 사용할 퍼사드로 넣어주면 되는 것이다 👍

public class TogetherFacade {
    private final MemberService memberService;
    private final ItemService itemService;

    public TogetherFacade(final MemberService memberService, final ItemService itemService) {
        this.memberService = memberService;
        this.itemService = itemService;
    }

    public void 전부를_만들어_돌려준다(final int id) {
        //이미 비즈니스 로직은 각 서비스에서 실행되었다.
        Member member = memberService.findById(id);
        Item item = itemService.findById(id);

        // 받은 그대로 조합식만 구성해주면 되는 것
        //return....
    }
}

이렇게 되었을 때, 각 서비스 레이어부터 단위테스트를 잘 구성해서 퍼사드쪽으로 넘어올 수가 있다.

테스트만을 위한 코드작성? 이라고는 볼 수 없을 것 같다.

결국 모든 프로그래밍의 구조를 따진다면 조합해서 놓고보면 쭉 이어진 코드는 맞다.
하지만 객체지향의 의미는 해당 객체에게 메세지를 보내 기능을 수행하도록 구성하는게 바람직하다.
그렇기에 이렇게 분리해주면 오류가 어디서 나는지 명확하게 짚을 수 있다.
그러면서 단일 책임 원칙을 준수하게 된 아래의 레이어들이다. 🤩

여기서 더 어떻게 고쳐야하나? 라는건 나의 남은 숙제다 ㅋㅋㅋㅋㅋ

🔥 후기

일단 로직이 더러운것은 놔두고 각자의 역할만 따로따로 분리해서 만들고 보니
로직이 더러운게 아니었다. 그냥 모든 코드가 합쳐져서 한 메소드가
많은 일을 수행하고 있던거지 로직 자체가 이상하게 짜여져있는 것은 아니었다. (물론 이 클래스만 그랬을거다.)

Mocking을 하면서 Mockito로 각 서비스 레이어를 테스트하였다.

@Autowired로 되어있던 것을 생성자 주입으로 바꾸면서 이 테스트도 진행할 수 있게 되었던건데,

이것은 다음 포스트에서 자세하게 다뤄보도록 하겠다.
아무튼 구조를 개선하여 조금 더 깔끔하게 코드를 관리할 수 있게 되었다. 🤣

728x90
728x90

기본 알고리즘부터 풀어보는데에 있어서

이 소수를 구하는것에 상당히 애를 먹는다.

물론 소수 뿐만이 아니라 다른 유형의 문제들도

결국 머릿속에선 정리가 매우 잘되지만, 이걸 코드로 구현하는게 쉽지가 않다.

아무튼 각설하고, 소수를 판별하는 알고리즘에 대해 알아보도록 하자.

소수

소수는, 1보다는 큰 자연수들 중 1과 자기 자신만을 약수로 가지고 있는 수를 의미한다.

위키피디아의 이미지를 가져와보았다.

에라토스테네스의-체

순서는 2부터 소수를 구하고자 하는 목표값 까지의 모든 수를 나열한다.

그다음 2는 소수기에 Prime Numbers에 2를 넣어준다.

그 다음 자기 자신을 제외한 2의 배수를 다 지운다.

지우는 이유?

자기 인수의 배수를 다 지우는 이유는 그 수들은 이미 배수라서 약수에 인수가 포함되기 때문이다.

이 구간을 계속 반복해주면 목표 수 사이까지의 소수가 구해지게 된다.

말로는 설명하기 어려운것 같다.

코드로 자세히 봐보도록 하자.

자바 코드로 구현하기

public class EratosTenes {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

        //여기서 N의 값을 9라고 해보자.
        int N = Integer.parseInt(br.readLine());

        boolean[] array = new boolean[N + 1]; // 배열은 0부터 시작하기 때문에 자릿수를 맞춰주는 개념으로 1을 더해주었다.

        //0과 1은 소수가 아니다.
        array[0] = true;
        array[1] = true;

        //2부터 시작해서 배열 총길이의 제곱근 값까지 반복한다.
        for(int i = 2; i <= Math.sqrt(array.length); i++) {
            if (array[i]) {
                continue;
            }

            // 소수가 아닌수를 true
            for(int j = i * i; j < array.length; j += i) {
                array[j] = true;
            }
        }

    }
}

이렇게하여 boolean[] array의 배열값에서 true이면 전부 소수가 아닌수가 된다.

여기서 true로 바꾸어주는 로직이 i * i로 시작하는 이유는

i의 처음 인수가 2이다. 근데 소수에서는 0과 1을 제외하면 2, 3까지는 무조건 소수임이 보장되어 있다.

그래서 i * i로 4부터 시작하여 자기 자신만큼을 더해주는게 배수의 원리이니까 전부 true로 바꿔주는게 된다.

이렇게 하여 소수를 구할 수 있게 된다.

728x90

'CS > 알고리즘' 카테고리의 다른 글

Select Sort 선택정렬  (0) 2022.08.10
728x90

일급 컬렉션

일단 나는 넥스트스텝의 TDD, Clean Code with Java 12기를 하면서

여기서도 일급 컬렉션을 사용했었다.

참 웃겼던건 이것을 조금 응용을 했었어야 했는데 개념 자체도 자세하게 정리가 덜 된것 같았다.

도메인에서부터 차근차근 만들어가는 것에서는 어느정도 생각이 잘 들었지만,

기존 레거시 코드에 이런게 적용되어 있지 않고 뚱뚱하게 로직이 작성되어 있으면

그냥 넘어갔던게 흔했다.

객체지향 생활체조 원칙

소트웍스 앤솔러지에서 발췌된 객체지향 생활체조 원칙이다.

  • 규칙 1: 한 메서드에 오직 한 단계의 들여쓰기만 한다.
  • 규칙 2: else 예약어를 쓰지 않는다.
  • 규칙 3: 모든 원시값과 문자열을 포장한다.
  • 규칙 4: 한 줄에 점을 하나만 찍는다.
  • 규칙 5: 줄여쓰지 않는다(축약 금지).
  • 규칙 6: 모든 엔티티를 작게 유지한다.
  • 규칙 7: 2개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
  • 규칙 8: 일급 컬렉션을 쓴다.
  • 규칙 9: 게터/세터/프로퍼티를 쓰지 않는다.

진하게 설정해 놓은 것들은 웬만하면 거의 적용하고 있는 규칙들이다.

단, 9번 규칙에서 게터는 어쩔수 없이 사용하게 되는것 같다.

여기서 이 8번 규칙을 적용하는게 위에서 말했던 이유이다.

일급 컬렉션을 쓴다.

이 규칙 적용하는 법은 간단하다.

컬렉션을 포함한 클래스는 반드시 다른 멤버 변수는 존재하지 않아야 한다.

그리고 그 컬렉션만 들어있기 때문에 해당하는 컬렉션만의 로직을 구현할 수 있는 분기점이 생성된 것이다.

예를 한가지 들어보겠다.

public class Car {
    private final String name;
    private final int yearModel;

    public Car(final String name, final int yearModel) {
        this.name = name;
        this.yearModel = yearModel;
    }

    public String getName() {
        return name;
    }
}

이런 자동차 객체를 정의 했을 때, 비즈니스 로직에서 이런 행위들을 했다고 쳐보자.

public class CarService {
    public List<Car> findCarName(final String carName) {
        //다른 비즈니스 로직 수행....
        //DB에서 이러한 데이터를 가져왔다고 가정
        List<Car> carList = Arrays.asList(
            new Car("아반떼", 2021),
            new Car("k3", 2018),
            new Car("그랜저", 2019),
            new Car("모하비", 2020),
            new Car("아반떼", 2017),
            new Car("아반떼", 2016),
            new Car("아반떼", 2015);
        );

        //다른 비즈니스 로직 수행....

        return carList.stream()
            .filter(car -> car.getName().equals("아반떼"))
            .map(car -> car.getName())
            .collect(Collectors.toList());
    }
}

이런식으로 하나의 로직이 어떤 곳에서 데이터를 불러오고

차들의 이름이 매개변수로 받는 carName인 애들만 추려서 리스트로 만드는

그런 로직이다.

간단해서 나눌 필요가 없다고 생각이 들 수도 있지만, 메소드를 하나의 일만 하게끔 분리했을 때 이런 모양이 나왔다면,

그리고 이 로직들이 안에 갇혀있으면 점점 뚱뚱해지면서 테스트하기가 힘들어지는 로직이 생기게 될 것이다.

그래서 아래와 같이 개선한다.

일급 컬렉션

public class Cars {
    private final List<Car> cars;

    public Cars(List<Car> cars) {
        this.cars = cars;
    }

    public List<Car> findAllByCarName(final String carName) {
        return cars.stream()
            .filter(car -> car.getName().equals("아반떼"))
            .map(car -> car.getName())
            .collect(Collectors.toList())
    }
}

Car객체의 리스트만을 멤버 변수로 갖고있는 일급 컬렉션인 Cars를 구현했고,

여기서 해당하는 비즈니스 로직을 정의해 주었다.

public class CarService {
    public List<Car> findCarName(final String carName) {
        //DB에서 이러한 데이터를 가져왔다고 가정
        Cars cars = new Cars(Arrays.asList(
            new Car("아반떼", 2021),
            new Car("k3", 2018),
            new Car("그랜저", 2019),
            new Car("모하비", 2020),
            new Car("아반떼", 2017),
            new Car("아반떼", 2016),
            new Car("아반떼", 2015);
        ));

        return cars.findAllByCarName(carName);
    }
}

해당하는 조건으로만 생성된 일급 컬렉션이 생겨서 비즈니스 로직에 의존하지 않고

순수 Car 리스트에 대한 테스트만 진행을 해볼 수 있게 되었다.

그리고 불변으로 만들어 주어야 무분별한 수정, 삽입이 안되게끔 만들어 줄 수 있다.

그러기 위해선 일급 컬렉션 객체에서 Getter, Setter같은 메소드들을 제외시키고,

오로지 생성자를 통한 생성, 별도로 구현한 로직외 값을 수정 불가 하게끔 정의해주고 사용한다.

이렇게 되면 일급 컬렉션 + 불변객체 가 성립이된다.

더 나아가서 지금은 차 브랜드를 현대, 기아 두개를 같이 넣었지만

KiaCars, HyundaiCars처럼 일급 컬렉션을 따로 만들어서 관리할 수도 있다.

이렇게 따로 만든채로 다른 비즈니스 로직을 수행하게끔 해줄 수도 있다.

하나 더 나아간다면 또 공통적으로 ~를 한다. 라는 기능이 존재할 경우 인터페이스를 놓고 구현해줌으로써

또 여러 과정을 거치는 방법도 있을 것이다.

마무리

이런 일급 컬렉션을 사용하는 것에 재미가 들렸고 완벽하게라고는 못하겠지만,

점차 쓰는 빈도가 늘어가면서 객체지향, 그리고 리팩토링에 불이 붙을것 같다.

728x90

'Java' 카테고리의 다른 글

Checked Exception, Unchecked Exception  (0) 2022.09.07
변성  (0) 2022.08.11
변수  (0) 2022.08.07
상태 패턴 적용  (0) 2022.08.07
728x90

모든 코드는 깃허브 에 있다.

Validation

Validation의 사전적 정의는 확인 이다.

프로그래밍을 하면서 Validation을 한다고 하면 유효성 검증,

즉, 수없이 싸우는 null과 빈 값에 대해 검증을 한다.

뭐 때에 따라서는 조건에 맞는 값이 들어와야 한다는 것도 포함이다.

유효성 검증

애초에 검증을 한다는 것을 단순하게 생각하면

스프링에서 어떤 요청이 하나 들어왔다고 치자.

@RestController
public class Hello {

    @GetMapping("/hello")
    public String hello(@RequestParam String name) {
        return "hello " + name;
    }
}

/hello?name=이름이라는 주소로 호출을 한다면 파라미터에 name값이 들어갈 것이다.

우리는 저 부분에서 name이 null이거나 빈문자열인 경우를 검증해줄 수 있다.

그래서

@GetMapping("/hello")
public String hello(@RequestParam String name) {
    if (Objects.isNull(name) || name.equals("") {
        throw new RuntimeException("이름은 빈 문자열이거나 Null일 수 없습니다.");
    }
    return "hello " + name;
}

이런식으로 RuntimeException 을 던져줄 수도 있다.

이런 하나하나를 다 해주게 된다면 코드 로직이 좀 더 뚱뚱해지고 가독성을 헤칠 수 있고

빈 문자열이나 null을 검증한다. 라는 공통적 의미가 겹치는 로직이 많이 발생하게 될 것이다.

그래서 이 동작을 해줄 수 있는 편리한 라이브러리 javax.validation 을 사용하는 것이다.

의존성

검증은 javax.validation:validation-api 이 의존성을 추가하고 (버전은 명시하지 않음)

검증 API 참조는 org.hibernate.validator:hibernate-validator를 이용했다.

그러나 내가 해본 것은 Spring Boot에서 해보았기 때문에

그레이들에는 아래와 같이 설정해주었다.

dependencies {
    implementation (
            'org.springframework.boot:spring-boot-starter-web',
            'org.springframework.boot:spring-boot-starter-validation',
            'org.projectlombok:lombok'
    )
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation (
            'org.springframework.boot:spring-boot-starter-test',
            'org.assertj:assertj-core:3.21.0'
    )
}

어노테이션 종류

일단 검증을 할 수 있게 해주는 어노테이션을 알아보자

스크린샷 2021-12-15 오후 10 23 46

 어노테이션

예제 

 설명

 @DecimalMax

 @DecimalMax(value = "5.5")

- 소수 최대값 지정

- 같은값 허용

- null 허용

 @DecimalMin

 @DecimalMin(value = "5.5")

- 소수 최소값 지정

- 같은값 허용

- null 허용

 @Max

 @Max(value = 10)

- 정수 최대값 지정

- 같은값 허용

- null 허용

 @Min

 @Min(value = 10)

- 정수 최소값 지정 

- 같은값 허용

- null 허용

 @Digits

 @Digits(integer = 3, fraction = 2)

- interger 정수 허용 자리수 ex) 3이니까 100자리 까지허용

- fraction은 소수점 허용 자리수 ex)2니까 소수점 둘째짜리 까지 허용

- null 허용

 @Size

 @Size(min = 2, max = 4)

- 길이를 검증
- null 허용

- ex) "a" 비허용, "abcd"허용

 @NotNull

 @NotNull

 설명생략

 @Pattern

 @Pattern(regexp = "")

- 정규식에 해당하는 것만 통과

 @NotEmpty

 @NotEmpty

- null 비허용

- 길이가 0 비허용 ex) ""

 @Positive

 @Positive

- 양수만 허용

- 0 비허용 
- null 비허용

 @PositiveOrZero  @PositiveOrZero

- 양수 허용

- 0 허용
- null 비허용

 @Negative @Negative

- 음수만 허용

- 0 비허용 
- null 비허용

 @NegativeOrZero  @NegativeOrZero

- 음수만 허용

- 0 허용 
- null 비허용

 @Email  @Email(regexp = "정규식")

- 정규식따로 작성 권장 , "abc@def" 같은 케이스도 통과해버림

 @Future @Future

- 미래날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러발생

- LocalDate권장 

 @FutureOrPresent @FutureOrPresent

- 미래, 현재날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장

 @Past  @Past

 - 과거날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장 

 @PastOrPresent

 @FutureOrPresent

 - 과거, 현재 날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장 

이런 어노테이션들이 있다.

기본 Hibernate Test

유저 라는 클래스를 구현해놓고 null과 길이에 대한 검증만 진행하는 어노테이션을 구현해놓았다.

@Getter
@NoArgsConstructor
public class User {
    @NotNull(message = "email is not null")
    private String email;

    @NotNull(message = "name is not null")
    @Size(min = 2, max = 4, message = "name must be between 2 and 4")
    private String name;

    @Min(value = 1, message = "age is more than 0")
    private int age;

    public User(final String email, final String name, final int age) {
        this.email = email;
        this.name = name;
        this.age = age;
    }

}

UserTest.java

import static org.assertj.core.api.Assertions.assertThat;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class UserTest {

    private Validator validator;

    @BeforeEach
    void setUp() {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();
    }

    @Test
    void 이메일이_null일_때() {
        final User user = new User(null, "홍길동", 12);
        final Set<ConstraintViolation<User>> validate = validator.validate(user);

        assertThat(validate.stream().findFirst()
            .get().getMessage()).isEqualTo("email is not null");
    }

    @Test
    void 이름_null() {
        final User user = new User("test@email.com", null, 12);
        final Set<ConstraintViolation<User>> validate = validator.validate(user);

        assertThat(validate.stream().findFirst()
            .get().getMessage()).isEqualTo("name is not null");
    }

    @Test
    void 이름_2글자에서_4글자_사이가_아닐_때() {
        final User min = new User("test@email.com", "홍", 22);
        final User max = new User("test@email.com", "홍홍홍홍홍", 22);
        final Set<ConstraintViolation<User>> minValidate = validator.validate(min);
        final Set<ConstraintViolation<User>> maxValidate = validator.validate(max);

        assertThat(minValidate.stream().findFirst().get().getMessage()).isEqualTo("name must be between 2 and 4");

        assertThat(maxValidate.stream().findFirst()
            .get().getMessage()).isEqualTo("name must be between 2 and 4");
    }

    @Test
    void 나이_1보다_작을_때() {
        final User user = new User("test@abc.com", "lsj", 0);
        final Set<ConstraintViolation<User>> validUser = validator.validate(user);

        assertThat(validUser.stream().findFirst().get().getMessage()).isEqualTo("age is more than 0");
    }
}

이 테스트를 진행하면 hibernate가 돌아가면서 검증을 수행해주게 된다.
이 부분에서는 어떠한 예외가 던져져서 Exception 객체로 나오지는 않는다.

RestController Test

아래는 RestController로 테스트 했을 때 나오는 경우이다.

UserController.java

@Valid 어노테이션을 사용하여 검증을 시켜주었다.

MethodArgumentNotValidException 클래스는 @Valid 어노테이션이 달린 인자의

검증이 실패하면 던지게 된다.

스크린샷 2021-12-15 오후 11 00 55
@RestController
public class UserController {

    @PostMapping("/user")
    public ResponseEntity<User> getUser(@Valid @RequestBody User user) {
        return ResponseEntity.ok(user);
    }
}

일단 다른 값들의 검증은 위에서 살펴보았으니 단순하게 에러를 던지는 과정만 테스트에서 보도록 하겠다.

UserControllerTest.java

@WebMvcTest(UserController.class)
@AutoConfigureMockMvc
class UserControllerTest {

    ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private MockMvc mockMvc;

    @Test
    void 유저_검증_이메일_null() throws Exception {
        final User user = new User(null, "테스트", 34);
        mockMvc.perform(post("/user")
                .contentType(APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(user)))
            .andExpect(status().isBadRequest())
            .andDo(print());
    }

}

스크린샷 2021-12-15 오후 11 05 38

보면 email값이 null로 요청이 들어왔고 그래서 예외에는 MethodArgumentNotValidException이 던져졌다.

이럴때 이제 @ExceptionHandler를 통해 커스텀으로 예외를 구현해주면 되겠다.

커스텀으로 구현해주는 이유는 통째로 값을 내려줄 수는 있지만,

불필요한 정보와 시스템 내부 정보가 포함되어 있기 때문에 별도로 정의하여 내려주는 것이 좋다.

12-29 추가

커스텀으로 validator를 구현하여 검증을 진행해 줄 수 있다.

리스트의 경우에는 @Valid가 붙어있음에도 불구하고 유효성 검사를 못하는 경우가 발생한다.

이렇게 해서 안에서 예외를 처리해주는 방법도 있다.

@Component
public class CustomValidator implements Validator {

    private SpringValidatorAdapter validatorAdapter;

    public CustomValidator() {
        this.validatorAdapter = new SpringValidatorAdapter(Validation.buildDefaultValidatorFactory()
            .getValidator());
    }

    @Override
    public boolean supports(final Class<?> clazz) {
        return Collection.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(final Object target, final Errors errors) {
        if (target instanceof Collection) {
            Collection collection = (Collection) target;

            for (Object object : collection) {
                validatorAdapter.validate(object, errors);
            }
        } else {
            validatorAdapter.validate(target, errors);
        }
    }
}

방법2

내가 사용한 방법인데

Controller로 넘어오는 RequestBody 객체에 리스트가 있는 경우에는 그 객체안에 List에 @Valid를 붙여준다.

@Valid
List<FormClass> formClassList;

이런식으로 붙여주고 객체 안에서 여러 변수에 유효성 체크하는 어노테이션을 넣어두면 잘 작동하게 되는데,

Controller에서는 이것을 처리해줄 것이 필요하다.

그게 바로 BindingResult 객체이다.

@PutMapping("/")
public ResponseEntity<String> update(
    @Valid @RequestBody List<FormClass> formClassList, BindingResult bindingResult) throws BindException {
    if (bindingResult.hasErrors()) {
        throw new BindException(bindingResult);
    }
}

이렇게 되게 되면 유효성 검사하고 발생된 에러들이 BindingResult 객체에 담겨있는것을 디버그모드로 확인할 수 있다.

그래서 그 객체가 에러를 갖고 있다면 BindException을 던져주고

나는 ExceptionHandler에서 처리를 진행해주었다.

참고

Baeldung - SpringBoot Bean Validation
Baeldung - javax Validation

728x90

'Spring' 카테고리의 다른 글

Spring Rest Docs 테스트로 문서화를 해보자!  (0) 2022.08.10
Service Layer에 대한 생각  (0) 2022.08.10
Filter, Interceptor 정리  (0) 2022.08.07
Jasypt  (0) 2022.08.07

+ Recent posts