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
728x90

웹 캐시

웹 캐시 또는 HTTP 캐시 라고 하는데,

이 캐시는 서버와 불필요한 네트워크 통신을 줄이기 위해서 임시 저장한

정보들을 바로 뿌려주는 기술이다.

웹 캐시가 자신의 저장소 내에 요청된 리소스를 가지고 있다면, 그 요청을 가로채

원래라면 서버에서 리소스를 가져오겠지만, 리소스의 복사본(프록시) 를 통해 데이터를 가져오게 된다.

성능이 향상되는건 말할 것도 없지만, 이 리소스가 영원히 변하지 않는 것은 아니기 때문에

가지고 있던 값이 변하기 전까지만 캐시로 유지하고 더 이상은 캐싱을 하지 않아야 한다.

사설 브라우저 캐시

이 캐시는 한 사용자 전용의 캐시이다.

예로, 크롬 브라우저를 켰을때 브라우저 자체는 이 사용자만의 캐시를 고유하게 갖고 있다.

이게 사용자마다의 개개인의 캐시로 기록이 되어있기 때문에 사설 브라우저 캐시라고 하는 것이다.

공유 프록시 캐시

내가 이해한 바로는 어떤 API를 호출하는 작업이 있을 때

자기 자신뿐 아니라, 같은 API를 누구든 호출할 때 재사용되기 때문에 그 재사용되는 응답을 저장하는 캐시이다.

유효성

사실상 캐시에 저장된다고 한다면 그 리소스가 변경, 만료기간이 없다고 할 때,

영원히 캐시로만 서비스가 될 수도 있다.

그렇지만 캐시의 공간은 유효하기 때문에 주기적으로 제거가 된다.

동작과정

스크린샷 2021-12-10 오전 10 17 43

Cache-Control 헤더의 max-age값은 만료 시간을 의미한다.

1번 그림

1번그림은 캐시가 없을 때, /doc의 url로 GET 요청을 하면

캐시가 없는 상태이므로, 서버에서 리소스를 조회한다.

그 이후에 저장된 캐시가 없었기 때문에, 이 리소스를 바탕으로 캐시를 하나 생성하고

클라이언트에 Response값을 내려준다.

2번 그림

1번 그림에서 10초가 지난뒤에 다시 /docGET요청을 보내본다.

근데 설정한 만료시간은 100초 이므로 10초는 만료시간 내에 존재한다.

그래서 서버로 리소스 찾는 명령을 보내지않고 캐시에 저장된 데이터를 클라이언트에게 돌려준다.

3번 그림

설정한 만료 시간 (100초) 를 지난 후에 같은 방식으로 요청을 보낸다.

이번에는 만료 시간이 지났기 때문에 캐시는 한번 검증을 해야한다.

변경된 값이 있는지를 체크해야 한다

그다음에 변경 사항이 없다면 304 상태 코드로 수정 사항이 없다는 것을 알리면서

시간을 갱신한 후 클라이언트로 돌려주는 과정이 되겠다.

캐시 검증

위 3번 그림에서 봤듯, 캐시의 만료시간이 지나면 문서를 다시 서버에 요청하여 가져와야 하는데

두가지 옵션이 있다.

ETags

ETags는 캐시 데이터와 함께 헤더에 고유 식별자를 붙여서 내려보내 주게 된다.

HTTP/1.1 200 OK
Cache-Control: max-age:100
Age: 0
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

이런식으로 데이터가 내려가게 된다.

검증할 때에는 ETag 헤더값으로 데이터를 식별하고, 만료되어 새로운 캐시를 저장할 때에는

마찬가지로 ETag의 값도 새로 갱신하고 내려주게 된다.

아래는 깃허브에서 스프링 캐시에 대한 학습을 진행해보았다.

깃허브 바로가기

728x90

'CS' 카테고리의 다른 글

ALU 구현  (0) 2022.08.09
불 연산 회고  (0) 2022.08.09
불 논리 정리 2  (0) 2022.08.09
불 논리 회고  (0) 2022.08.07
728x90

Spring Security 예제

우선 코드는 깃허브에 있다.

일단 문서로 정리한 것을 토대로 로그인과 회원가입 어떤 순서로 동작하는지 알아보려고 했다.

로그인

일단 시큐리티에 대한 설정은 SecurityConfig 라는 클래스 설정파일을 만들어서 진행했다.

WebSecurityConfigurerAdapter

WebSecurityConfigurerAdapterWebSecurityConfigurer 라는 인터페이스를

조금 더 쉽게 생성하기 위해 존재하는 클래스이다. 이 클래스의 구현을

그러니까 기본으로 적용되어 있는것 외에 재정의 하여 사용할 수 있다.

아래는 해당 추상클래스에 대한 설명을 가져와봤다.

스크린샷 2021-12-05 오후 8 51 54

우리는 이 추상 클래스에서 구현되어있는 configure(HttpSecurity http) 메소드를 재정의 하여 설정을 진행한다.

스크린샷 2021-12-05 오후 8 55 19

SecurityConfig

package io.github.lsj8367.security.configuration;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/", "/h2-console/**"); // "/" 와 "/h2-console"은 접근 가능
        //여기서 무시하는 과정이 아래의 configure()보다 우선순위가 높다.
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .anyRequest().authenticated() //모든 요청은 인증이 필요함 위의 ignoring()에 포함된 애들은 제외시킨다. 추가적으로 permitAll()로 해제해준 url은 접근 가능
            .and()
            .formLogin() //formLogin 설정 default /login
            .and()
            .logout().permitAll(); //logout에 대한 모든권한 적용 default /logout
    }

}

여기 설정에서 formLogin()은 uri를 잡아주지 않았기에 기본값인 /login으로 로그인을 처리한다.

UsernamePasswordAuthenticationFilter

이때 동작하는 필터가 UsernamePasswordAuthenticationFilter인데 이 클래스는

AbstractAuthenticationProcessingFilter를 상속받아 처리해준다.

UsernamePasswordAuthenticationFilter 클래스는 기본값인 /login에 대해 응답한다고

설명에 명시되어 있다. 여기서 사용되는 username, passwordconfigure()에서

usernameParameter()passwordParameter()로 변경해서 매칭해줄 수 있다.

인증에 대한 검증을 수행하게 되는데

스크린샷 2021-12-05 오후 9 07 52

HTTP 메소드가 POST인지를 먼저 판단한다.

그 후에 obtainUsername, obtainPassword로 파라미터 설정한것을 가져온다.

나는 기본값으로 두었기에 form안에 input id가 username, password 이다.

그다음에 null인지, 빈문자열인지 검증을 한 뒤에

UsernamePasswordAuthenticationToken을 발급해준다.

이렇게 로그인 인증이 일단락 되었다.

728x90

'Spring > Security' 카테고리의 다른 글

Authentication 인증  (0) 2022.08.09
개요  (0) 2022.08.09
728x90

스프링 시큐리티 사용자 인증

스프링 시큐리티는 인증에 대한 정보들을 제공한다.

Servlet Authentication Architecture

인증에 대한 아키텍처는 아래와 같다.

  • SpringSecurityContextHolder
    • 인증된 사용자의 세부 정보를 저장하는 곳
  • SecurityContext
    • SpringSecurityContextHolder에서 가져온 현재 인증된 사용자 인증을 가지고 있다.
  • Authentication
    • 사용자가 인증을 위해 제공한 자격증명이나 현재 사용자를 제공하기 위한 AuthenticationManager의 입력이 될 수 있음
  • GrantedAuthority
    • 인증에 대한 권한 정보
  • AuthenticationManager
    • 스프링 시큐리티 필터가 인증을 수행하는 방법을 정의
  • ProviderManager
    • AuthenticationManager의 기본 구현체
  • AuthenticationProvider
    • ProviderManager에서 특정 유형의 인증을 진행하는 Provider
  • Request Credentials with AuthenticationEntryPoint
  • AbstractAuthenticationProcessingFilter
    • 인증에 사용되는 기본 필터

SecurityContextHolder

구조

일반적인 구조는 위와같이 생겼다.

설명

스프링 시큐리티가 인증된 사용자의 정보를 저장하는 곳이다.

값이 포함되어 있으면 현재 인증된 사용자로 사용된다.

Authentication

사용자가 인증을 위해 제공한 자격 증명 AuthenticationManager 입력값.

이 상태에서 isAuthenticated()false를 반환

현재 인증된 사용자를 반환 현재 인증은 SecurityContext에서 얻을 수 있다.

Authentication이 포함하고 있는 값들

  • principal
    • 사용자를 식별한다.
    • username, password로 인증할 때 UserDetails가 값들을 포함한다.
  • credentials
    • 비밀번호
    • 이 비밀번호는 유출되지 않게 사용자를 인증 후 지워진다.
  • authorities
    • GrantAuthority 권한을 포함한다.

GrantAuthority

사용자에게 부여된 상위 수준의 권한

이 객체는 Authentication.getAuthorities() 에서 얻을 수 있다.

이 권한은 인증받으려는 주체에게 부여된 권한이다.

이 권한은 흔히 말하는 사용자, 관리자 등등 해당 권한을 나타낸다.

이 역할로 특정 URI에 대한 권한이나 접근 권한을 제어할 수 있게 된다.

username, password 기반 인증을 사용할 시에 권한은 UserDetailService가 가지고 있다.

UserDetailService

이 클래스는 AuthenticationProvider에서 username, password로 인증하기 위해

해당 사용자 이름, 비밀번호, 기타 속성들을 검색하는데에 사용된다.

UserDetailService를 커스텀 Bean으로 정의할 수 있다.

AuthenticationManager

이 클래스는 스프링 시큐리티 필터가 인증을 수행하는 방법을 명시한 API이다.

반환되는 인증은 AuthenticationManager를 호출한 컨트롤러에 의해

SpringSecurityHolder에 설정된다.

스프링 시큐리티의 필터와 통합하지 않는 경우에는 SecurityContextHolder를 직접

설정 할 수 있고, AuthenticationManager를 사용할 필요가 없어진다.

AuthenticationManager의 구현은 어떤것이든 가능하지만, 기본 구현은 ProviderManager

ProviderManager

위에서 말했듯, 이 클래스는 AuthenticationManager의 구현체이다.

ProviderManagerAuthenticationProviders의 리스트에 위임한다.

AuthenticationProvider는 성공, 실패를 나타내거라 결정할 수 없고

다운스트림 AuthenticationProvider가 결정하도록 허용할 수 없다.

AuthenticationProvider중 어느 것도 인증을 할 수 없는 경우에는

인증 예외인 ProviderNotFoundException이 발생하여 인증이 실패하게 된다.

구조

설명했듯 각 provider들이 특정 유형의 인증을 수행한다.

ProviderManagerAuthenticationProvider가 인증을 수행할 수 없는 경우

상위의 AuthenticationProvider를 설정하여 구성이 가능하다.

상위 AuthenticationProviderProviderManager의 인스턴스이다.

스크린샷 2021-12-03 오후 10 44 17

AuthenticationProvider

ProviderManager에 여러 AuthenticationProvider를 주입할 수 있다.

AuthenticationProvider들은 저마다의 특정 유형 인증을 수행하게 되어있다.

예를 들어, DaoAuthenticationProvider는 이름/암호 기반의 인증을 사용하고,

JwtAuthenticationProvider는 Jwt 토큰 인증을 사용한다.

AbstractAuthenticationProcessingFilter

이 클래스는 사용자의 자격 증명을 인증하기 위한 기본 필터이다.

이 인증이 되기전에 AuthenticationEntryPoint를 사용하여 HTTP 요청을 한다.

Authentication으로 인증에 대한 정보들을 쭉 가져온다.

AuthenticationManager가 그 인증 정보들을 받아 여러 검증들을 수행한다.

성공하면 Success 기본 호출은 AuthenticationSuccessHandler, 실패하면 Failure로 가서 AuthenticationFailureHandler호출됨

728x90

'Spring > Security' 카테고리의 다른 글

로그인 동작 순서  (0) 2022.08.09
개요  (0) 2022.08.09
728x90

📌 Spring Security

Spring SecurityServlet Filters를 기반으로 동작한다.

Spring Security는 인증, 권한 부여 및 보호를 제공하는 프레임워크이다.

스크린샷 2021-12-03 오후 10 38 42

전체적인 구조는 위와 같다.

Gradle 설정

plugins {
      id 'io.spring.dependency-management' version "1.0.10.RELEASE"
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
}

📌 Filter

그래서 필터의 역할을 먼저 아는것이 중요한데,

Filter 영어 단어만 봐도 뭔가를 필터로 걸러주는 느낌이 난다.

업무중에 통신 하나하나의 로그를 다 찍는 과정을 Filter를 통해서 구현했던 경험이 있다.

이 필터를 줄줄이 연결하면 FilterChain이 되는 것이다.

다음은 스프링 공식문서에서 FilterChain의 구조를 가져왔다.

구조

스크린샷 2021-12-01 오후 9 19 39

클라이언트는 서버에 요청을 보내고 컨테이너는 요청 URI를 기반으로 HttpServletRequest를 처리하는 필터와

필터체인을 생성한다. Spring MVC에서의 ServletDispatcherServlet이 된다.

Filter 인터페이스를 구현하면 doFilter(ServletRequest request, ServletResponse response, FilterChain chain)

메소드를 구현해주어야 한다.

chain.doFilter()를 호출하면 다음 필터를 차례대로 수행하는 것이다.

이렇게 필터들이 쭉 연결되어 기능을 수행한다고 생각하면 된다.

📌 DelegatingFilterProxy

구조

스크린샷 2021-12-01 오후 9 30 41

설명

SpringServlet 컨테이너의 라이프사이클과

SpringApplicationContext 사이의 연결을 허용하는

DelegatingFilterProxy라는 필터를 제공한다.

이 필터는 Servlet Filter로 애플리케이션 컨텍스트에서 요청한 것을 스프링 컨테이너에 생성된

Bean Filter를 찾고 그 필터를 호출한다.

DelegatingFilterProxy는 컨테이너가 시작되기 전에 필터를 등록해야 하기 때문에 중요하다.

📌 FilterChainProxy

Spring Security의 서블릿 지원은 FilterChainProxy에 포함되어 있다.

FilterChainProxySecurityFilterChain을 통해 여러 필터 인스턴스에 위임할 수 있도록

시큐리티에서 제공하는 특수 필터이다.

FilterChainProxyBean이라서 DelegatingFilterProxy안에 포함된다.

구조

스크린샷 2021-12-01 오후 9 45 51

이제보니 구조가 좀 잡혀가는것 같다.

클린코드와 자바의 정석을 읽었더니 시큐리티 아키텍처가 이해되는건 무엇일까.... 🤔🤔🤔

📌 SecurityFilterChain

SecurityFilterChain은 클라이언트에서 필터를 돌다가 DelegatingFilterProxy에 들어온 요청을

받아서 이 요청에 대해 처리해야 하는 Security Filter를 찾아 수행한다.

구조

스크린샷 2021-12-01 오후 9 57 39

📌 Security Filter

핵심인 시큐리티 필터이다. 이 필터는 SecurityFilterChain API를 사용해

FilterChainProxy에 삽입된다. 이 필터들은 순서가 중요하다.

필터의 순서를 알 필요는 없다.

근데 알아두면 유용하게 사용할 수는 있다.

필터에는 다음과 같은 필터들이 존재하고 위에서 아래로 순서가 매겨져 있다.

  • ChannelProcessingFilter
  • WebAsyncManagerIntegrationFilter
  • SecurityContextPersistenceFilter
  • HeaderWriterFilter
  • CorsFilter
  • CsrfFilter
  • LogoutFilter
  • OAuth2AuthorizationRequestRedirectFilter
  • Saml2WebSsoAuthenticationRequestFilter
  • X509AuthenticationFilter
  • AbstractPreAuthenticatedProcessingFilter
  • CasAuthenticationFilter
  • OAuth2LoginAuthenticationFilter
  • Saml2WebSsoAuthenticationFilter
  • UsernamePasswordAuthenticationFilter
  • OpenIDAuthenticationFilter
  • DefaultLoginPageGeneratingFilter
  • DefaultLogoutPageGeneratingFilter
  • ConcurrentSessionFilter
  • DigestAuthenticationFilter
  • BearerTokenAuthenticationFilter
  • BasicAuthenticationFilter
  • RequestCacheAwareFilter
  • SecurityContextHolderAwareRequestFilter
  • JaasApiIntegrationFilter
  • RememberMeAuthenticationFilter
  • AnonymousAuthenticationFilter
  • OAuth2AuthorizationCodeGrantFilter
  • SessionManagementFilter
  • ExceptionTranslationFilter
  • FilterSecurityInterceptor
  • SwitchUserFilter

블럭이 쳐져있는 필터들을 유의해서 더욱 살펴봐야 하겠다.

📌 Handling Security Exceptions

위에서 ExceptionTranslationFilter를 사용하면 AccessDeniedException이나,

AuthenticationException을 HTTP Response로 변환할 수 있다.

스크린샷 2021-12-01 오후 11 16 45
  1. 일단 들어온 요청에서 FilterChain.doFilter()로 나머지 애플리케이션을 호출한다.
  2. 사용자가 인증이 되지 않았거나, AuthenticationException이 발생한 경우에 인증을 진행한다.

    이 경우 사용자가 성공적으로 인증되면 RequestCache에 사용자를 저장하고 이 캐시를 사용하여 원래 요청을 쭉 진행한다.
  3. 여기서 AccessDeniedException이 발생하게 되면 액세스는 거부되고, AccessDeniedHandler는 이 예외를 처리하기 위해 호출된다.

이 설정이 AccessDeniedExceptionAuthenticationException에 대한 예외처리가 없다면

ExceptionTranslationFilter는 아무것도 수행하지 않는다.

정리

여기까지가 일단 스프링 시큐리티의 기본적인 흐름이다.

다음 포스팅에서는 인증(Authentication) 에 대해서만 쭉 다뤄보는것으로 하겠다.

어렴풋이 알고 있는 기본값들에 대해서만 사용할게 아니라 입맛에 따라 커스텀하며 필터를 작업해주는 것이

개발자의 역량이라고 생각한다. 이게 뒷받침되려면 당연히 이런 공식문서를 읽어보는게 답이다.

전체적인 아키텍처의 흐름을 알아야 잘 설계할 수 있다.

아무튼 이 아키텍처를 공부함으로써 어제의 나보다는 오늘이 더 성장했다.

업무나 프로젝트에 들어간 시큐리티에 대한 코드를 보면 지금보다 더 이해할거라고 자신한다 🔥🔥🔥🔥

728x90

'Spring > Security' 카테고리의 다른 글

로그인 동작 순서  (0) 2022.08.09
Authentication 인증  (0) 2022.08.09

+ Recent posts