728x90

개요

토비의 스프링을 다시 읽으며, 개념을 자세히 정리하기보다는 면접에 나올 만한 부분을 질의응답 형식으로 정리하려고 합니다.

책을 읽는 동안 지속적으로 업데이트되는 게시글입니다.

친절하게 설명을 해주시기 위해 분량이 많아진 것이라 생각하기 때문에, 입문자도 한 번 정도는 읽어보는 것도 좋다고 생각합니다.

내용의 순서는 가나다 순이 아닌 책의 흐름에 따라 진행됩니다.

전체 글 목록은 아래 페이지에서 확인할 수 있습니다.

토비의 스프링으로 면접 준비하기

 

 

질문 및 답변

1권의 1장인 오브젝트와 의존관계 챕터에 대한 질답 목록입니다.

객체지향의 주요 개념과 디자인 패턴 그리고 IoC와 DI의 필요성과 원리 등에 대한 질답입니다.

 

관심사의 분리 원칙(Separation of Concerns, SoC)

 변화는 보통 집중된 한 가지 관심에 대해 일어나지만 그에 따른 작업은 한곳에 집중되지 않는 경우가 많다. 관심사의 분리는 관심이 같은 것끼리 하나의 객체 또는 가까운 객체로 모아, 관심이 다른 것과는 가능한 떨어져 서로 영향을 주지 않도록 분리하는 것이다. 이를 통해 변경이 일어날 때 필요한 작업을 최소화할 수 있고, 그러한 변경이 다른 곳에 문제를 일으키지 않게 할 수 있다.

 

리팩토링

 기존의 코드를 기능의 동작방식에는 변화 없이 내부 구조만 변경하여 재구성하는 작업으로, 코드의 내부 설계를 개선하여 코드를 이해하기 쉽게 만들고, 변화에 대응하기 쉽게 할 수 있다. 이를 통해 코드의 품질과 생산성을 향상하고, 유지보수가 편리해지는 견고하면서도 유연한 개발을 할 수 있다.

 

메서드 추출

 공통의 기능을 담당하는 메서드로 중복된 코드를 추출하는 기법이다.

 

상속과 확장

 상속을 통해 자식 클래스가 부모 클래스의 자원을 물려받아, 부모 클래스와 다른 부분만 추가하거나 재정의하여 기존 코드를 쉽게 확장할 수 있다. 하지만 이렇게 코드의 재사용만을 위한 기법이 아닌 더 구체적인 클래스를 구현하기 위해 사용하는 것이 주목적이다.

 

템플릿 메서드 패턴

 상속을 통해 슈퍼 클래스의 기능을 확장할 때 사용하는 가장 대표적인 디자인 패턴이다. 변하지 않는 기능을 슈퍼 클래스에 구현하고 자주 변경되고 확장되는 기능은 서브 클래스에 만드는 방식으로, 이러한 부분을 추상 메서드 혹은 오버라이드가 가능한 메서드로 정의하여 템플릿처럼 사용한다.

 

팩토리 메서드 패턴

 오브젝트를 만드는 팩토리 클래스를 사용하여 슈퍼 클래스는 구체적인 오브젝트에 대해서는 알지 못하게 하는 디자인 패턴이다. 클래스의 생성과 사용을 분리하여 결합도를 낮추고, 캡슐화를 통해 정보를 은닉 처리할 수 있다는 장점이 있다.

 

 슈퍼 클래스는 오브젝트의 생성에 대해 추상화를 하고, 이를 서브 클래스가 어떤 오브젝트를 생성할지 결정하기 때문에 슈퍼 클래스는 추상화된 오브젝트만 알고 있게 된다.

 

디자인 패턴의 개념과 장점

 소프트웨어 설계 시 특정 상황에서 자주 만나는 문제를 해결하기 위해 사용할 수 있는 재사용 가능한 설루션이다. 다양한 디자인 패턴에 대해 이해하고 있다면 문제를 마주치더라도 쉽게 효율적인 설루션을 생각할 수 있고, 자신과 상대가 모두 디자인 패턴에 대해 알고 있다면 의사소통을 더욱 간결하게 할 수 있다.

 

상속의 단점

 자바는 다중 상속을 허용하지 않기 때문에 이미 상속을 받은 클래스에 추가적인 상속이 필요한 경우에는 상속을 적용하기 힘들며, 관심사를 분리하고 확장성이 유용하게 만들어도 상속을 통해 밀접한 관계가 될 수밖에 없다는 단점이 있다.

 

인터페이스를 사용하는 이유

 인터페이스를 사용하면 객체 입장에서는 자신이 사용하는 클래스에 대해 직접적으로 알지 못하게 된다. 즉, 인터페이스는 사용하는 클래스와 실제 사용되는 클래스의 사이에서 두 클래스의 긴밀한 관계를 느슨하게 해 준다. 결국 사용하는 입장에서는 인터페이스에 정의된 기능에만 관심을 가지기 때문에 실제로 구현된 내용에는 관심을 가지지 않게 된다. 따라서 사용하고자 하는 클래스가 바뀌어도 아무런 문제가 생기지 않는다.

 

의존관계를 직접 설정하지 않는 이유

 객체 자신이 사용할 오브젝트를 직접 생성하고 선택하는 것도 하나의 관심사라고 볼 수 있다. 이러한 관심사를 분리하기 위해 객체 자신을 사용하는 클라이언트 객체에 위임하여 런타임 시에 오브젝트와 오브젝트 사이의 의존(사용) 관계를 설정하게 한다.

 

개방 폐쇄 원칙(OCP : Open-Closed Principle)

 확장에 대해서는 개방적이고 수정에 대해서는 폐쇄적이어야 한다는 원칙으로, 기존의 코드에 영향을 주지 않고 기능을 추가할 수 있도록 설계해야 하는 것을 의미한다. 예를 들면 인터페이스를 통해 기능에 대한 확장은 개방하고, 이를 이용하는 클래스는 변화에 영향을 받지 않게 폐쇄할 수 있다.

 즉, 변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어주는 것이다.

 

객체지향 설계 원칙 (SOLID)

 SRP(Single Responsibility Principle:단일 책임 원칙), OCP(Open-Closed Principle:개방 폐쇄 원칙), LSP(Liskov substitution principle:리스코프 치환 원칙), ISP((Interface segregation principle:인터페이스 분리 원칙), DIP(Dependency inversion principle:의존관계 역전 원칙) 총 5가지의 객체지향 설계 원칙을 의미한다. 특정한 문제에 대한 솔루션인 디자인 패턴과 다르게 더 일반적인 상황에서 적용 가능한 객체지향 설계의 기준이라고 볼 수 있다.

 

높은 응집도와 낮은 결합도

 응집도는 하나의 모듈 혹은 클래스에 있는 기능들의 연관도로, 응집도가 높다면 하나의 관심사에만 집중되어 있기 때문에 변경이 필요할 때 특정 모듈만 수정하면 해결할 수 있다. 결합도는 관심이 다른 모듈 혹은 클래스와의 의존성의 정도로, 결합도가 낮다면 느슨하게 연결되어 있어 독립적이기 때문에 변경에 대한 여파가 전파되지 않는다.

 

전략 패턴

 기능에서 변경이 필요한 알고리즘을 인터페이스로 분리시켜 이를 구현한 구체적인 알고리즘 클래스를 런타임시에 필요에 따라 바꿔서 적용할 수 있는 행위 디자인 패턴으로, 알고리즘의 변형이 빈번한 경우에 유용하다. 추상화된 전략 인터페이스와 이를 구현한 클래스, 전략을 등록하고 실행하는 컨텍스트와 전략을 설정하고 결과를 얻는 클라이언트가 있다.

 

스프링이란

 객체지향적 설계와 디자인 패턴의 장점을 개발자들이 자연스럽게 활용할 수 있게 해주는 프레임워크다. IoC를 극한까지 적용하고 있는 IoC 프레임워크이기 때문에 컴포넌트의 생성과 관계설정, 사용, 생명주기 관리 등을 위해 애플리케이션 전반에 걸쳐 IoC를 적용하기 편리하게 도와준다.

 오브젝트가 어떻게 설계되고, 만들어지고, 관계를 맺고 사용되는지에 관심을 맞춰 개발된 프레임워크다. 개발자가 이러한 과정들을 설계하고 작업할 때 반복적으로나 강제적으로 해야하는 번거로운 작업들을 편하게 할 수 있도록 지원해준다.

 

오브젝트 팩토리

 객체의 생성 방법을 결정하고 만들어진 오브젝트를 돌려주는 오브젝트로 디자인 패턴에서 사용되는 팩토리와는 다르다. 단순하게 오브젝트를 생성하는 쪽과 생성된 오브젝트를 사용하는 쪽의 역할과 책임을 분리하려는 목적으로 사용한다.

 

IoC(Inversion of Control)

 오브젝트 스스로 자신이 사용할 클래스를 결정하고, 생성하는 것처럼 사용하는 쪽에서 제어하는 것이 아닌, 반대로 모든 제어 권한을 자신이 아닌 다른 오브젝트에게 위임하는 것이다. 이렇게 되면 오브젝트는 자신이 사용할 오브젝트를 스스로 결정하지 않고, 생성하지도 않으며, 자신도 어떻게 만들어지고 사용되는지 알 수 없다. 그래서 IoC를 적용하다 보면 자연스럽게 관심이 분리되고 책임이 나눠져 설계가 깔끔해지고, 유연성과 확장성이 향상된다.

 

 추가로 프레임워크도 제어의 역전이 적용된 기술이라고 볼 수 있다. 개발자가 프레임워크를 사용하여 개발하는 것이 아닌 프레임워크에 맞춰 개발하기 때문이다.

 

스프링에서의 빈이란

 스프링 컨테이너가 생성과 관계설정, 사용 등을 제어해 주는 IoC가 적용된 오브젝트 단위의 컴포넌트이다. 스프링에서 사용된다고 모두 빈인 것이 아닌, 스프링 컨테이너에 의해 제어되는 것만을 빈이라고 한다.

 

빈 팩토리와 애플리케이션 컨텍스트의 차이점

 빈 팩토리는 빈을 생성하고 관계를 설정하는 IoC의 기본적인 부분을 제어한다면 애플리케이션 컨텍스트는 애플리케이션 전반의 모든 구성요소의 제어 작업을 담당한다. 애플리케이션 컨텍스트는 BeanFactory 인터페이스를 상속했기 때문에 빈 팩토리라고 볼 수도 있다.

 

@Configuration

 빈들을 애플리케이션 컨텍스트에 등록하기 위해 사용하는 애너테이션으로 클래스에 해당 어노테이션을 설정하면 해당 클래스의 본문을 파싱해 @Bean 애너테이션이 설정된 메서드의 이름으로 빈을 등록한다. 이때 등록된 빈은 스프링 컨테이너에 의해 관리되고 싱글톤임이 보장된다.

 

애플리케이션 컨텍스트의 개념과 동작방식 및 장점

 애플리케이션 컨텍스트는 애플리케이션에서 IoC를 적용해 관리할 모든  오브젝트에 대한 생성과 관계설정 등을 담당한다. 애플리케이션 컨텍스트는 클라이언트가 빈을 요청하면 빈 목록에 해당 빈이 있는지 확인하여 빈을 생성하게 한 후에 클라이언트에게 전달한다. 이렇게 클라이언트는 구체적인 오브젝트의 생성 과정을 알 필요가 없기 때문에 일관된 방식으로 원하는 오브젝트를 가져올 수 있게 된다. 추가로 생성과 관계설정만이 아닌 오브젝트의 생성 방식, 시점, 전략, 후처리 등 다양한 기능을 제공하기도 한다.

 

동일성과 동등성

 동일한 오브젝트라면 서로 같은 객체의 주소가 동일한 것이고, 동등한 오브젝트라면 서로 다른 객체지만 객체의 동등성 기준에 따라 동등한 객체인 것을 말한다.

 

스프링에서 싱글톤을 사용하는 이유

 클라이언트의 요청마다 필요한 오브젝트를 새로 만든다면 서버에 부하가 생길 것이고 감당하기 힘들어 진다. 이러한 문제를 해결하기 위해 애플리케이션 안에 제한된 수의 오브젝트를 만들어서 사용하기 위한 것이 싱글톤 패턴의 원리이고, 따라서 스프링은 싱글톤을 사용한다.

 

싱글톤 패턴과 구현

 클래스를 애플리케이션 내에서 제한된 인스턴스 개수나 보통 하나만 존재하도록 강제하는 디자인 패턴이다. 이렇게 생성된 인스턴스는 애플리케이션 내에서 전역적으로 접근이 가능하고, 주로 애플리케이션의 여러 곳에서 공유하는 경우에 사용된다.

 클래스 내에서 오브젝트를 생성하지 못하게 생성자를 private으로 제한하고, 싱글톤 오브젝트를 저장할 스태틱 필드를 정의하여 팩토리 메서드를 사용해 한 번만 오브젝트를 생성하게 하여 필드에 저장한다. 그 후에는 이미 만들어져 스태틱 필드에 저장된 오브젝트를 넘겨줘 공유한다.

 

싱글톤 패턴의 한계

 private 생성자 하나만 가지고 있기 때문에 다른 생성자가 없어 상속을 할 수 없고 다형성도 사용할 수 없다. 테스트에서도 만들어지는 방식이 제한되기 때문에 목 오브젝트 등으로 대체하기 힘들고, 자원을 공유하여 격리된 환경에서의 테스트가 힘들다. 또한, 클래스 로더가 두 개 이상일 경우 지정하지 않으면 싱글톤이 하나만 만들어지는 것이 보장되지 않고, 전역 상태로 사용되기 때문에 어떤 객체에서든 접근할 수 있다는 위험이 있다.

 

싱글톤 레지스트리

 스프링에서의 싱글톤은 디자인 패턴의 싱글톤 패턴과 비슷하지만 구현 방법은 많이 다르다. 기존 싱글톤 패턴의 한계를 극복하기 위해 스프링은 직접 싱글톤 형태의 오브젝트를 만들고 관리하는 싱글톤 레지스트리 기능을 제공한다. 이는 오브젝트 생성에 대한 모든 권한이 애플리케이션 컨텍스트가 가지고 있기 때문에 싱글톤을 생성하고, 관리하고, 공급할 수 있기 때문에 가능한데, 이로 인해 스프링에서는 평범한 자바 클래스도 싱글톤으로 활용할 수 있다.

 

싱글톤을 사용할 때 주의해야 할 점

 멀티 스레드 환경에서 하나의 싱글톤 오브젝트를 여러 스레드가 동시에 사용할 수 있기 때문에 내부에 상태를 저장하기 위해 읽기 전용이 아닌 인스턴스 변수를 수정하여 상태를 공유하는 것은 위험하다. 따라서 파라미터나 지역 변수, 리턴 값 등을 이용해 각 스레드마다 독립적인 공간을 활용해야 한다.

 

DI(Dependency Injection, 의존관계 주입)

 오브젝트 레퍼런스를 외부로부터 주입 받아 이를 통해 다른 오브젝트와 런타임 시에 의존관계가 만들어지는 것이다.

 

의존관계

 A가 B를 의존하고 있다는 것은 A의 의존대상인 B가 변하면 A에도 영향을 미친다는 것이고 반대로 B는 A에 의존하지 않고 있기 때문에 A의 변화는 B에 영향을 끼치지 않는다.

 

런타임 의존관계

 설계와 코드에 드러나지 않는 실제 사용대상인 구체적인 의존 오브젝트와의 관계가 런타임 시에 설정되어 드러나는 것을 말한다. 그리고 이러한 의존관계를 런타임 시에 설정해 주는 제3의 존재가 애플리케이션 컨텍스트다.

 

DL(Dependency Lookup, 의존관계 검색)

 스프링은 의존관계를 맺는 방법이 외부로부터의 주입받는 의존관계 주입 말고도 스스로 의존관계 검색을 통해 자신이 필요로 하는 의존 오브젝트를 능동적으로 찾는 방법도 존재한다. 이 방법은 의존관계를 맺을 오브젝트를 결정하거나 생성하는 것은 스프링 컨테이너에게 권한을 넘기지만, 가져올 때는 컨테이너에게 요청하는 방법을 사용한다.

 

DI와 DL의 차이

 DI는 오브젝트와 오브젝트 사이의 의존관계를 설정하기 위해서는 컨테이너가 생성과 초기화 권한을 갖고 있어야 주입해 줄 수 있기 때문에 두 오브젝트 모두 빈으로 등록이 되어 있어야 한다. 하지만 DL은 검색을 하려는 대상 오브젝트만 빈이면 검색하는 오브젝트 자신이 빈일 필요가 없다.

 

외부로부터 레퍼런스를 주입받으면 모두 DI일까?

 레퍼런스를 주입받는다고 모두 DI는 아니다. DI는 런타임 시에 의존관계가 결정되어 주입되어야 하는데 이미 파라미터의 타입으로 구체적인 클래스 타입이 명시되어 있다면 DI를 받는다고 할 수 없다.

 

DI의 장점

 객체지향 설계와 프로그래밍 원칙을 따른 기술이기 때문에 구체적인 의존관계가 드러나지 않고, 인터페이스를 통해 결합도를 낮추고, 의존관계의 대상이 변경에 영향을 받지 않으며, 확장에는 자유로워 진다.

 이로 인해 기능 구현의 교환이 자유롭고(예:기존에 사용하던 DB에서 다른 DB로의 교환), 기존코드의 변경 없이 새로운 런타임 의존관계를 설정하여 부가기능도 자유롭게 추가할 수 있다.

 

생성자를 이용한 의존관계 주입

 생성자에 파라미터를 만들어 이를 통해 스프링 컨테이너가 의존할 오브젝트 레퍼런스를 넘겨주는 방식이다. 한 번에 필요한 모든 파라미터를 다 받아야 한다.

 

메서드를 이용한 의존관계 주입

 수정자 메서드와 초기화 메서드를 이용해서도 의존관계를 주입할 수 있다. 생성자를 이용한 의존관계 주입과는 다르게 한 번에 모든 파라미터를 다 받을 필요 없이 필요에 따라 받아서 주입할 수 있다. 수정자 메서드 방식은 한 번에 하나의 파라미터만 받아야 한다는 단점이 있고, 초기화 메서드 방식은 파라미터 수의 제한이 없다. 주로 자바빈 규약을 따르는 수정자 메서드 방식을 사용한다. 

728x90

Mapped Diagnostic Context

멀티 쓰레드 환경에서 로그가 뒤섞이는 문제를 해결하기 위해 사용할 수 있는 객체로

현재 실행 중인 쓰레드에 메타 정보를 넣고 관리하는 공간이다.

 

  • 내부적으로 쓰레드 로컬을 사용한다.
  • Map 형태로 구성되어 (키, 값) 형태로 값을 저장하고 사용할 수 있다.
  • 다양한 데이터를 저장해 구체적이고 유용한 로그 메시지를 제공할 수 있다.

 

주로 각각의 요청에 대한 로깅을 구분하기 위해 고유한 요청 ID를 설정하거나

사용자 정보, 세션, 트랜잭션, 로깅 수준 등을 설정한다.

 

 

MDC 사용

implementation 'org.springframework.boot:spring-boot-starter-logging'

 

위의 디펜던시를 추가하면 org.slf4j 패키지에 있는 MDC를 사용할 수 있다.

 

위에서 언급한 것처럼 Map 형태로 이루어져 있기 때문에 put, get, remove, clear 처럼

자바의 유틸 클래스에 있는 Map의 메서드와 다를게 없어서 사용에 어려움은 없다.

 

이제 MDC를 어디에서 설정해주는게 효율적인지를 생각해 봐야 하는데

요청에 대한 모든 로그를 남기려면 가장 처음에 접근하는 필터단에서 처리해주는게 효율적이다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MDCLoggingFilter implements Filter {

	@Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        final UUID uuid = UUID.randomUUID();
        MDC.put("request_id", uuid.toString());
        chain.doFilter(request, response);
        MDC.clear();
    }
}

 

위와 같이 @Order 어노테이션을 사용해 filter 중에서도 가장 빠른 순서로 만들어준다.

 

랜덤으로 고유한 UUID를 만들어 MDC에 저장해주면 요청별로 고유 ID를 기록할 수 있게 되고

쓰레드 로컬 변수는 초기화하지 않으면 쓰레드풀에서 다시 꺼낼 때

이전 데이터가 남아있게 되어 doFilter가 끝난 후에는 MDC를 clear 해줘야 한다.

 

 

Logback 적용

<configuration>
    <appender name="CONSOLE_APPENDER" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <charset>utf8</charset>
            <pattern>%date{HH:mm:ss.SSS, Asia/Seoul} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="CONSOLE_APPENDER" />
    </root>
</configuration>

 

위와 같이 설정하면 콘솔창에 로그가 출력되는 것을 확인할 수 있다.

 

AOP를 적용하여 번거로운 로깅 처리를 간단하게 할 수도 있고

Spring Cloud Sleuth와 Zipkin을 사용해 분산된 마이크로서비스 간의 로깅도 가능하다 하니

이 부분들도 학습하면서 언젠가 정리해보도록 하겠다.

728x90

정의

N + 1 보다는 1 + N이라고 볼 수 있는데

한 개의 쿼리에 예상과는 다른 N개의 추가 쿼리가 발생하는 문제다.

 

원인

즉시로딩, 지연로딩, 연관 관계와 상관 없이 언제든 N + 1 문제가 발생할 수 있다

 

즉시로딩의 경우

N:M 관계에서 N을 조회하는 쿼리를 날렸을 때

M을 즉시로딩으로 가져오게 되어있다면

N을 조회할 때 M을 한 번의 쿼리만 날려서 조회할거라 예상할 수 있지만

JPQL은 N을 먼저 조회하는 쿼리를 날린 후에

M을 즉시로딩으로 가져오게 설정된걸 확인하고

M을 가져오는 추가 쿼리를 날리게 된다.

 

일대일 관계라면 쿼리가 한 번이 아닌 2번 발생할 것이고

일대다 관계라면 1 + M개만큼의 쿼리가 발생한다.

 

지연로딩의 경우

N:M 관계에서 N을 조회한 후에

지연로딩으로 가져온 M의 객체들을 사용하려는 경우에는

실제 객체가 아닌 프록시 객체가 존재하는 상태이기 때문에

실제 객체를 조회하기 위해 쿼리문이 M번 더 발생하게 된다.

 

 

해결 방법

확실한 정답은 없지만 가장 간단한 해결 방법은 패치 조인을 사용하는 것이다.

 

기본 패치 타입이 즉시로딩인 경우에는 모두 패치 타입을 지연로딩으로 바꾼 후에

연관된 엔티티를 사용해야하는 경우에는 애초에 패치 조인으로 한 번에 가져오는 것이다.

 

물론 이 방법도 문제점이 존재하는데

대표적인 경우가 페이징 처리시 발생하는 문제다.

 

예를 들어 1페이지 당 10개씩 끊어서 페이징을 처리한다 했을 때

100만개의 데이터가 있다면 이 데이터를 모두 메모리에 가져온 후에

페이징 처리를 하게 되기 때문에 데이터의 양이 적다면 메모리 초과가 발생하진 않겠지만

데이터가 많다면 무조건 메모리 초과가 발생할 것이다.

 

일대일, 다대일 관계라면 몇 개의 데이터를 조회할지 측정이 가능하지만

일대다, 다대다 관계라면 정확한 측정이 어렵다.

 

이런 문제를 해결하기 위해서는 배치 사이즈를 조절하는 방법이 있는데

배치는 지연 로딩을 생각하면 이해하기 쉽다.

 

배치 사이즈가 적용되면 같은 쿼리

 

예를 들어, 배치 사이즈가 100이라면 100만 개의 데이터 중에서 100개의 베치를 가져온 후에

이후의 배치가 필요한 경우에는 IN 쿼리를 한 번 더 날리게 된다.

 

사실 함정이 한 가지 있는데 하이버네이트가 최적화를 해두었기 때문에

배치 사이즈를 100으로 설정했다고 100개씩 끊어서 쿼리를 날리진 않는다.

100
100/2 = 50
50/2 = 25
25/2 = 12
1 ~ 10

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 25, 50, 100]

 

배치 사이즈가 100인 경우에는 위와 같이 미리 여러 개의 prepared statement를 만들어 두는데

기본값인 legacy 전략은 데이터보다 적은 수를, padded 전략은 많은 수를 쿼리로 가져온다.

 

예를 들어, 조회하려는 데이터가 총 32개라면 in(?*25)와 in(?*7) 두 개의 쿼리가 날라가고

padded 전략이라면 in(?*50) 한 개의 쿼리가 날라간다.

 

이렇게 최적화를 해주는 이유는 배치 사이즈가 100이라고 in절을 in(?*1) 부터 in(?*100)까지

다 만들어두는 것은 성능적으로 비효율적이기 때문이다.  

 

'Back-End > Spring' 카테고리의 다른 글

스프링 면접 질문 정리 : 오브젝트와 의존관계  (0) 2024.04.03
[로깅] MDC를 이용한 식별된 로깅 처리  (1) 2024.03.05
슬라이스 테스트  (0) 2023.07.05
예외  (0) 2023.06.15
템플릿 정리  (0) 2023.06.14
728x90
 

GitHub - da9dac/blank

Contribute to da9dac/blank development by creating an account on GitHub.

github.com

전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.

 

 

환경 설정

가장 많이 사용되는 OAuth2.0 서비스 제공업체인

구글, 카카오, 네이버를 이용한 인증을 구현하기 전에

yml / properties 파일에 설정을 추가해주겠다.

 

각 API 사용을 위한 사전 설정은 구글링 혹은 아래의 사이트를 참고하길 바란다.

OAuth 2.0을 사용하여 Google API에 액세스하기  |  Authorization  |  Google for Developers

 

OAuth 2.0을 사용하여 Google API에 액세스하기  |  Authorization  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English 의견 보내기 OAuth 2.0을 사용하여 Google API에 액세스하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하

developers.google.com

REST API | Kakao Developers 문서

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

네이버 로그인 API 명세 - LOGIN (naver.com)

 

네이버 로그인 API 명세 - LOGIN

네이버 로그인 API 명세 네이버 로그인 API는 네이버 로그인 인증 요청 API, 접근 토큰 발급/갱신/삭제 요청API로 구성되어 있습니다. 네이버 로그인 인증 요청 API는 여러분의 웹 또는 앱에 네이버

developers.naver.com

 

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: your-client-id
            client-secret: your-client-secret
            scope: email
            redirect-uri: http://localhost:8080/auth/oauth/code
          kakao:
            client-id: your-client-id
            client-secret: your-client-secret
            authorization-grant-type: authorization_code
            scope: account_email
            client-name: kakao
            client-authentication-method: POST
            redirect-uri: http://localhost:8080/auth/oauth/code
          naver:
            client-id: your-client-id
            client-secret: your-client-secret
            authorization-grant-type: authorization_code
            client-name: Naver
            redirect-uri: http://localhost:8080/auth/oauth/code
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response

 

사전 설정을 마친 후 각 API의

클라이언트 ID와 시크릿키를 다음과 같이 설정 파일에 추가해준다.

 

scope는 자신이 가져오고자 하는 정보들을 적어주면 되는데

간단하게 이메일 정도만 가져오도록 했다.

 

구글, 페이스북, 깃허브 같은 기업은 이미 등록이 되어있지만

국내 기업인 카카오와 네이버는 별도로 provider를 설정해줘야 한다.

 

리다이렉트 uri의 경우에는 본인이 설정해둔 곳으로 바꿔주면 되는데

로컬에서만 테스트 할거라 그냥 대충 적어놨다.

 

@Bean
public DefaultOAuth2UserService defaultOAuth2UserService() {
    return new DefaultOAuth2UserService();
}

@Bean
public RestTemplate restTemplate() {
    return new RestTemplate();
}

 

위와 같이 설정을 마친 후에 Config 클래스에

OAuth를 사용하기 위한 빈 등록들을 해준다.

 

OAuth 요청 처리 흐름

원래는 클라이언트가 로그인 요청을 보내면 서버에서 리다이렉트 URL을 넘겨준 후에

클라이언트가 다시 이동하여 로그인을 수행한 후에 인가 코드를 넘겨주지만

미리 리다이렉트 URL을 전달하여 통신 횟수를 줄이겠다.

 

  1. 클라이언트(프론트) 측에 미리 OAuth 리다이렉트 URL을 가르쳐준다.
  2. 클라이언트 측이 로그인 후 인가 코드를 얻어 서버에 전달한다.
  3. 서버는 인가 코드로 해당 서비스 제공자의 액세스 토큰을 얻는다.
  4. 액세스 토큰으로 사용자 정보를 획득한다.
  5. 사용자 정보로 로그인 혹은 회원가입을 진행한다.
  6. 실제 본인 서버의 액세스 및 리프래시 토큰을 발급한다.

 

OAuth 서비스 구현 사전 작업

@Getter
@Builder
public class MemberProfile {

    private String email;
}

 

scope에서 설정한 이메일을 담을 클래스를 하나 만든다.

(닉네임이나 다른 정보들도 더 가져오는 경우에는 필드를 추가하면 된다)

 

public enum OAuthProvider {

	private final String registrationId;
	private final Function<Map<String, Object>, MemberProfile> of;

	OAuthProvider(String registrationId, Function<Map<String, Object>, MemberProfile> of) {
		this.registrationId = registrationId;
		this.of = of;
	}
}

 

그 후 위와 같은 enum 클래스를 하나 생성하여

인증 서비스 제공자와 사용자의 정보를 가져오는 함수를 선언해준다.

 

GOOGLE("google", (attributes) ->
    MemberProfile.builder()
        .email((String) attributes.get("email"))
        .build()),

KAKAO("kakao", (attributes) -> {
    Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");

    return MemberProfile.builder()
        .email((String) kakaoAccount.get("email"))
        .build();
}),

NAVER("naver", (attributes) -> {
    Map<String, Object> response = (Map<String, Object>) attributes.get("response");

    return MemberProfile.builder()
        .email((String) response.get("email"))
        .build();
});

 

위와 같이 3개의 enum 객체를 만들어준다.

 

각 서비스마다 정보를 가져오는 방법이 다른데

구글, 페이스북, 깃허브 같은 경우는 속성명만으로 간편하게 가져올 수 있고,

카카오, 네이버의 경우에는 각각 kakao_account, response로

Map 형태의 속성 컬렉션을 가져온 후에

키를 사용해 속성 값을 가져올 수 있다.

 

public String getRegistrationId() {
    return this.registrationId;
}

 

위와 같은 메서드도 나중에 사용하기 위해 하나 만들어 둔다.

 

서비스 로직 구현

이제 실제 인증 로직을 처리할 서비스 로직을 구현하겠다.

 

구현할 로직들은 간단하게 아래와 같다.

  • Authoriztion Code를 통해 OAuth 서비스에 액세스 한다.
  • 사용자 정보를 획득한다.
  • 회원가입 혹은 로그인을 진행한다.
  • 액세스 토큰 및 리프래시 토큰을 발급한다.

 

@Service
@Transactional(readOnly = true)
public class OAuthService {
	private final InMemoryClientRegistrationRepository inMemoryRepository;
	private final MemberRepository memberRepository;
	private final JwtProvider jwtProvider;
	private final DefaultOAuth2UserService defaultOAuth2UserService;
	private final RestTemplate restTemplate;

	public OAuthService(InMemoryClientRegistrationRepository inMemoryRepository, MemberRepository memberRepository,
		JwtProvider jwtProvider, DefaultOAuth2UserService defaultOAuth2UserService, RestTemplate restTemplate) {
		this.inMemoryRepository = inMemoryRepository;
		this.memberRepository = memberRepository;
		this.jwtProvider = jwtProvider;
		this.defaultOAuth2UserService = defaultOAuth2UserService;
		this.restTemplate = restTemplate;
	}
}

 

위와 같은 서비스 클래스를 하나 만들어 주고 의존성을 주입 받는다.

 

각 클래스의 역할은 아래와 같다.

  • InMemoryClientRegistrationRepository : YML 파일에 설정했던 OAuth Provider 정보가 들어있는 저장소
  • MemberRepository : 기존 회원이면 로그인 정보를 확인하고 아닐 시 신규 회원 정보를 저장
  • JwtProvider : 로그인 성공 시 토큰을 발급해주는 역할
  • DefaultOAuth2UserService : OAuth 사용자 정보를 얻어오는 역할로 UserDetailsService와 같다 볼 수 있다.
  • RestTemplate : 인가 코드를 사용해 요청을 보내 액세스 토큰을 발급 받기 위해 사용

 

@Transactional
public OAuthResult login(OAuthProvider provider, String code) {

    String registrationId = provider.getDescription(); // 1

    ClientRegistration clientRegistration = inMemoryRepository.findByRegistrationId(registrationId); // 2

    String token = getToken(code, clientRegistration); // 3

    OAuth2User oAuth2User = getOAuth2User(token, clientRegistration); // 4

    Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes()); // 5

    MemberProfile memberProfile = OAuthProvider.extract(registrationId, attributes); // 6

    Member member = getOrSaveMember(memberProfile); // 7

    return createToken(member); // 8
}

 

전체적인 로직의 흐름은 위의 코드와 같은데 간단하게 살펴보겠다.

 

0. 컨트롤러에서 클라이언트로부터 받아온 Provider 정보와 인가 코드를 받아온다

1. Provider 정보에서 어떤 OAuth 서비스를 사용해 로그인할지 정보를 가져온다.

2. InMemoryClientRegistrationRepository에서 Provider의 상세 정보를 가져온다.

3. 구글, 네이버, 카카오 등의 서비스의 액세스 토큰을 얻어 온다.

4. 얻어온 토큰으로 사용자 정보를 얻어 온다. (UserDetails와 같다고 볼 수 있다)

5. 필요한 사용자의 정보만 Map 형태로 가져온다.

6. 사용자 정보에서 이메일을 가져와 MemberProfile 객체를 만든다.

7. 이미 존재하는 이메일이면 기존 회원 정보를, 아니라면 새로운 회원을 만든다.

8. 실제 자신의 백엔드 서버에서 사용될 액세스 및 리프래시 토큰을 생성해 리턴한다.

 

@Getter
@Builder
public class OAuthResult {
	
	private String accessToken;
	private String refreshToken;
}

 

우선 로그인 요청의 리턴값인 액세스 토큰과 리프래시 토큰을

저장할 클래스를 하나 생성해준다.

 

private String getToken(String code, ClientRegistration clientRegistration) {

    // 액세스 토큰 획들을 위해 요청을 보낼 URI
    String uri = clientRegistration.getProviderDetails().getTokenUri();

    // 요청 헤더 설정
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(APPLICATION_FORM_URLENCODED);
    headers.setAcceptCharset(List.of(UTF_8));

    // 요청값 설정
    HttpEntity<MultiValueMap<String, String>> entity =
        new HttpEntity<>(tokenRequest(code, clientRegistration), headers);

    // 요청 전송
    try {
        ResponseEntity<Map<String, String>> responseEntity = restTemplate.exchange(
            uri,
            HttpMethod.POST,
            entity,
            new ParameterizedTypeReference<>() {}
        );

        // 응답 바디에서 액세스 토큰 획득
        return responseEntity.getBody().get("access_token");

    } catch (HttpClientErrorException.BadRequest e) {
        throw new RuntimeException();
    }
}

 

인가 코드로 액세스 토큰을 얻어 오는 메서드로

실제 백엔드 서버의 액세스 토큰이 아닌 OAuth 서비스를 이용하기 위한

액세스 토큰에 해당한다.

 

private MultiValueMap<String, String> tokenRequest(String code, ClientRegistration provider) {
    MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();

    formData.add("code", code);
    formData.add("grant_type", "authorization_code");
    formData.add("redirect_uri", provider.getRedirectUri());
    formData.add("client_secret", provider.getClientSecret());
    formData.add("client_id",provider.getClientId());
    return formData;
}

 

인가 코드와 서비스 제공자의 정보를 토대로 요청에 사용될

데이터들을 설정해 준 후에

RestTemplate을 사용해 요청을 보내 액세스 토큰을 얻어 온다.

 

private OAuth2User getOAuth2User(String token, ClientRegistration clientRegistration) {

    OAuth2AccessTokenResponse tokenResponse = OAuth2AccessTokenResponse.withToken(token)
        .tokenType(OAuth2AccessToken.TokenType.BEARER)
        .expiresIn(3600L)
        .build();

    OAuth2UserRequest userRequest = new OAuth2UserRequest(clientRegistration, tokenResponse.getAccessToken());

    return defaultOAuth2UserService.loadUser(userRequest);
}

 

얻어온 액세스 토큰으로 OAuth2User 객체를 얻어오기 위한

응답 객체를 만든 후에 요청을 보내 객체를 얻어온다.

 

loadUser 메서드나 OAuth2User 객체명을 보면 알 수 있듯이

UserDetails를 UserDetailsService의 loadUserByUsername 메서드로

가져오는 것과 비슷하다.

 

private Member getOrSaveMember(MemberProfile memberProfile) {
    Member member = getMember(memberProfile);
    if (member == null) {
        member = saveMember(memberProfile);
    }
    return member;
}

 

OAuth2User 객체에서 얻어온 이메일 값으로

이미 해당 이메일로 가입된 회원이 있으면

굳이 회원가입을 할 필요가 없으니 그대로 기존 회원 정보를 리턴하고

가입된 회원이 아니라면 새로 가입 시킨 후 리턴한다.

 

private Member getMember(MemberProfile memberProfile) {
    return memberRepository.findByEmail(memberProfile.getEmail())
        .orElse(null);
}

private Member saveMember(MemberProfile memberProfile) {
    Member member = Member.createMember(
        memberProfile.getEmail(),
        memberProfile.getEmail().split("@")[0],
        generateRandomNickname());

    return memberRepository.save(member);
}

 

회원 조회 및 회원 가입 메서드는 대충 위와 같이 짤 수 있다.

(랜덤 닉네임을 부여하였는데 이 부분들은 각자 편한대로 바꿔도 상관 없다)

 

private OAuthResult createToken(Member member) {
    CustomUserDetails userDetails = new CustomUserDetails(member);

    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

    String accessToken = jwtProvider.createAccessToken(authentication, AuthConstant.ACCESS_TOKEN_EXPIRE_TIME);
    String refreshToken = jwtProvider.createRefreshToken(authentication, AuthConstant.ACCESS_TOKEN_EXPIRE_TIME);

    return OAuthResult.builder()
        .accessToken(accessToken)
        .refreshToken(refreshToken)
        .build();
}

 

기존에 JWT 인증 필터에서 토큰을 생성하던 방식처럼 그대로

인증 객체를 하나 만든 후에 이를 토대로 토큰을 생성해준다.

 

OAuth 컨트롤러 구현

@RestController
@RequestMapping("/auth")
public class AuthController {

	private MemberRepository memberRepository;
	private PasswordEncoder passwordEncoder;
	private OAuthService oAuthService;

	public AuthController(MemberRepository memberRepository, PasswordEncoder passwordEncoder,
		OAuthService oAuthService) {
		this.memberRepository = memberRepository;
		this.passwordEncoder = passwordEncoder;
		this.oAuthService = oAuthService;
	}
}

 

기존 컨트롤러에 OAuthService 클래스 의존성을 추가해준다.

 

@Getter
public class OAuthRequest {
	private OAuthProvider provider;
	private String code;
}

 

Provider와 인가 코드를 받아올 DTO 클래스도 위와 같이 생성해준다.

 

@PostMapping("/oauth")
public ResponseEntity<Void> oauth(@RequestBody @Valid OAuthRequest request) {
    OAuthResult token = oAuthService.login(request.getProvider(), request.getCode());

    HttpHeaders headers = new HttpHeaders();
    headers.add(AUTHORIZATION, BEARER + token.getAccessToken());
    headers.add(REFRESH, BEARER + token.getRefreshToken());

    return ResponseEntity.ok().headers(headers).build();
}

 

구현해두었던 login 메서드로 Provider와 인가 코드를 넘겨준 후에

액세스 토큰과 리프래시 토큰을 받아온다.

 

이를 응답의 헤더에 추가하여 클라이언트에 전달해주면 끝이다.

(응답의 바디로 주든 헤더로 주든 편한 방식대로 해도 괜찮다)

 

인가 코드 얻기 및 테스트

구글 

https://accounts.google.com/o/oauth2/v2/auth?client_id={클라이언트ID}&redirect_uri={리다이렉트URI}&response_type=code&scope={가져올 스코프}

 

카카오

https://kauth.kakao.com/oauth/authorize?client_id={클라이언트ID}&scope={가져올 스코프}&response_type=code&redirect_uri={리다이렉트 URI}

 

네이버

https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id={클라이언트ID}&redirect_uri={리다이렉트 URI}&state=true

 

위와 같은 로그인 주소를 클라이언트 측에 미리 전달해주어

인가 코드와 Provider만 전달 받으면 된다.

 

각 URI 형식에 맞게 값을 넣은 후에 로그인을 하여 인가 코드를 얻어보겠다.

 

@GetMapping("/oauth/code")
public String code() {
    return "get code";
}

 

카카오 같은 경우는 존재하지 않는 페이지를 리다이렉트 URI로 지정하면

응답으로 전달한 코드를 만료시키기 때문에 아무 API나 임시로 만들어 두는 것이 좋다.

 

 

구글의 경우에는 이렇게 리다이렉트된 URI를 확인해보면 주소창에 인가 코드가 적힌 것을 확인할 수 있다.

 

 

네이버와 카카오도 마찬가지로 이렇게 인가 코드를 확인할 수 있고

이 코드를 oauth 로그인 api로 요청을 보내면

 

 

이렇게 로그인에 성공한 후에 액세스 및 리프래시 토큰을 발급해주는 것을 확인할 수 있다.

 

마치며

JWT 및 OAuth 인증 방식의 경우에는

항상 돌아가는 방식이 비슷하기 때문에

어떤 순서로 인증이 진행되는지에 대해서만

확실하게 알아두면 언제든 구현할 수 있다.

 

로직이 바뀔 일이 거의 없기 때문에

암기 과목에 가까운 느낌이 들기도...

 

여담으로 깃허브 인증의 경우에는 사용자 정보를 가져오는

방식이 조금 다른 것으로 알고 있다.

 

이메일 속성만 가져오는 과정이 달랐는지

다른 속성들도 모두 달랐는지 햇갈리는데

만약 깃허브 인증을 사용한다면 참고하길 바란다.

728x90
 

GitHub - da9dac/blank

Contribute to da9dac/blank development by creating an account on GitHub.

github.com

전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.

 

 

이번 게시글에서는

액세스 토큰과 리프래시 토큰 발급,

리프래시 토큰을 이용한 액세스 토큰 재발급,

액세스 토큰 검증

이렇게 3개의 필터를 구현하겠다.

 

로그인 처리 및 토큰 발급 필터

사용자의 로그인 요청을 받아

서버에 저장된 인증 정보와 일치하는지 판단하여

사용자의 로그인을 처리하고

성공 시 액세스 토큰과 리프래시 토큰을 발급하는

필터를 구현하겠다.

 

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

	private final JwtProvider jwtProvider;
	private final AuthenticationManager authenticationManager;

	public JwtAuthenticationFilter(JwtProvider jwtProvider, AuthenticationManager authenticationManager1) {
		this.jwtProvider = jwtProvider;
		this.authenticationManager = authenticationManager1;
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) 
		throws AuthenticationException {

		return null;
	}

	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, 
		FilterChain chain, Authentication authResult) throws IOException, ServletException {

		
	}
}

 

우선 UsernamePasswordAuthenticationFilter를 상속받는 필터 클래스를 만들어준다.

 

토큰을 발급받기 위해 JwtProvider와

인증을 수행하기 위한

AuthenticationManager 객체를 DI 받는다.

 

attemptAuthentication와 successfulAuthentication 메서드를

오버라이딩 하여 인증을 처리하고 토큰을 발급하는

로직을 추가해주면 된다.

 

@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

    if (!request.getMethod().equals("POST")) {
        // POST 메서드로만 로그인 요청을 받고 싶은 경우
        // 다른 메서드로 들어온 요청일 시 예외를 발생
    }

    try {
        JwtLogin loginDto = getLoginDtoFrom(request);

        UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());

        return authenticationManager.authenticate(authenticationToken);

    }  catch (Exception exception) {
        return null;
    }
}

 

메서드 명에서 알 수 있듯이

인증을 시도하는 기능을 수행하는 메서드다.

 

로그인 요청을 POST 메서드만 허용하고 싶다면

위와 같이 설정하면 되고

모두 허용할거라면 넘어가도 상관없다.

 

로그인 요청 객체의 정보로

미인증 상태의 UsernamePasswordAuthenticationToken을 생성하고

이를 AuthenticationManager에 전달한다.

 

인증에 성공하면 인증 상태의 토큰을 리턴하고

인증에 실패하면 예외가 발생할 것이다.

(발생한 예외는 try - catch 문으로 처리해 준다)

 

지금은 코드를 설명하기 위해

예외 처리를 대충 넘어가지만

어떤 이유로 인증에 실패했는지

자세하게 처리하는 것이 좋다.

 

private JwtLogin getLoginDtoFrom(HttpServletRequest request) throws IOException {
    return new ObjectMapper().readValue(request.getInputStream(), JwtLogin.class);
}

 

요청 객체에서 로그인 요청 데이터를 얻어올 수 있다.

 

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
    FilterChain chain, Authentication authResult) throws IOException {

    String accessToken = jwtProvider.createAccessToken(authResult, ACCESS_TOKEN_EXPIRE_TIME);
    String refreshToken = jwtProvider.createRefreshToken(authResult, REFRESH_TOKEN_EXPIRE_TIME);

    response.setHeader(AUTHORIZATION, BEARER + accessToken);
    response.setHeader(REFRESH, BEARER + refreshToken);

    response.setStatus(HttpServletResponse.SC_OK);
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setCharacterEncoding("UTF-8");
    response.getWriter().write("로그인 성공");
}

 

마지막으로 인증에 성공한 경우 자동으로 실행될 메서드를 구현한다.

 

인증에 성공했으니 응답 헤더에 포함할

액세스 토큰과 리프래시 토큰을 생성하고

이를 응답의 헤더에 지정해 준다.

 

토큰의 형태는 "Bearer abcde12345"처럼 생겼기 때문에

꼭 Bearer을 붙여줘야 한다.

 

헷갈릴 위험이 있기 때문에 이런 값들은

상수에 저장해 두는 것이 좋다.

 

이제 로그인에 성공하면 이 메서드에서 설정한

응답들이 헤더와 바디에 표시될 것이다.

 

회원가입 구현 및 로그인 테스트

로그인 요청을 보내기 위해 간단하게 회원가입 기능을 구현하겠다.

 

@RestController
@RequestMapping("/auth")
public class AuthController {

	private MemberRepository memberRepository;
	private PasswordEncoder passwordEncoder;

	public AuthController(MemberRepository memberRepository, PasswordEncoder passwordEncoder) {
		this.memberRepository = memberRepository;
		this.passwordEncoder = passwordEncoder;
	}

	@PostMapping("/signup")
	public ResponseEntity<Void> signup() {
		memberRepository.save(
			Member.builder()
				.email("test@email.com")
				.password(passwordEncoder.encode("qwer1234!"))
				.authority(Authority.ROLE_USER)
				.build()
		);

		return new ResponseEntity<>(HttpStatus.CREATED);
	}
}

 

원래는 서비스 로직이라 컨트롤러단에 두면 안 되지만

빠르게 로그인 테스트만 하기 위해

컨트롤러에서 처리하게 했다.

 

 

요청을 보낸 후에 로그인을 해보면

위와 같이 액세스 토큰과 리프래시 토큰이

헤더에 담겨 있는 걸 확인할 수 있다.

 

액세스 토큰 재발급 구현

액세스 토큰이 만료된 경우

리프래시 토큰으로 재발급받는 방법은

간단하게 두 가지 방법이 있다.

 

첫 번째는 클라이언트에게 매 요청마다

액세스 토큰과 리프래시 토큰을 모두 받아

액세스 토큰이 만료된 경우

자동으로 리프래시 토큰을 발급해 주는 방법이다.

 

두 번째는 요청에 필요한 액세스 토큰만 받고

만료된 액세스 토큰인 경우

클라이언트에게 리프래시 토큰을 요청한다.

 

이번에는 두 번째  방법을 사용해 보겠다.

 

public class JwtRefreshFilter extends OncePerRequestFilter {

	private JwtProvider jwtProvider;

	public JwtRefreshFilter(JwtProvider jwtProvider) {
		this.jwtProvider = jwtProvider;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

	}
}

 

위와 같이

OncePerRequestFilter를 상속받는 필터를 만들어준다.

 

doFilterInternal 메서드에

리프래시 토큰으로 액세스 토큰을 발급해주는

로직을 구현해 주면 된다.

 

private String getRefreshToken(HttpServletRequest request) {

    String refreshToken = request.getHeader(REFRESH);

    if (refreshToken == null) {
        throw new JwtNotFoundException();
    }

    return refreshToken.replace(BEARER, "");
}

 

우선 사용자가 보낸 요청 헤더에서

리프래시 토큰을 얻어 온다.

 

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
    FilterChain filterChain) throws ServletException, IOException {

    if(!request.getMethod().equals("POST")){
        // 특정 메서드로만 요청 받고 싶은 경우
    }
    else{
        try {
            String refreshToken = getRefreshToken(request);

            jwtProvider.validateToken(refreshToken);

            String refilledAccessToken =
                jwtProvider.refillAccessToken(refreshToken, ACCESS_TOKEN_EXPIRE_TIME);

            response.setHeader(AUTHORIZATION, BEARER + refilledAccessToken);
        } catch (Exception exception) {
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "알 수 없는 오류입니다. 다시 시도해주세요.");
        }
    }
}

 

그 후에 이전에 구현했던 인증 필터와 비슷한 구조로

실질적인 필터의 로직을 구현해 준다.

 

헤더에서 가져온 리프래시 토큰이

올바른 토큰인지 검증한 후에

올바르다면 액세스 토큰을 새로 발급받아

응답의 헤더에 지정해 준다.

 

이 과정에서 발생하는 예외는

계속 언급하지만 좀 더 세세하게 처리해 주는 것이 좋다.

 

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {

    return !request.getRequestURI().equals(REFRESH_URL);
}

 

추가로 shouldNotFilter 메서드도 오버라이딩 하여

특정 주소의 요청만 허용하게 해 주겠다.

(리프래시 요청을 받고 싶은 URL을 지정해 주면 된다)

 

액세스 토큰 재발급 테스트

이제 리프래시 토큰을 보내

액세스 토큰이 재발급되는지 확인해 보겠다.

 

 

액세스 토큰만 재발급하는 작업에 성공했다.

 

액세스 토큰 검증 구현

이제 토큰을 발급하는 필터들의 구현은 모두 끝났으니

인증 및 인가가 필요한 요청에 대해

요청에 포함된 액세스 토큰을 검증하고

사용자의 권한이 충분한지

검증하는 필터를 구현하겠다.

 

public class JwtVerificationFilter extends OncePerRequestFilter {
	
	private final JwtProvider jwtProvider;
	private final CustomUserDetailsService customUserDetailsService;

	public JwtVerificationFilter(JwtProvider jwtProvider, CustomUserDetailsService customUserDetailsService) {
		this.jwtProvider = jwtProvider;
		this.customUserDetailsService = customUserDetailsService;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {
		
	}

	@Override
	protected boolean shouldNotFilter(HttpServletRequest request) {
		
	}
}

 

검증 필터도 마찬가지로

OncePerRequestFilter를 상속받아 구현하겠다.

 

해당 필터는 토큰을 생성하는 것이 아닌

검증만 수행하면 된다.

 

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    try{
        Claims claims = verifyClaims(request);
        setAuthenticationToContext(claims);
    } catch(Exception e){
        request.setAttribute(EXCEPTION, e);
    }

    filterChain.doFilter(request, response);
}

 

해당 필터는 위와 같이 토큰의 Claim만 검증하면

올바른 토큰인걸 확인할 수 있으니

검증이 통과하면 시큐리티 콘텍스트에

인증 정보를 등록해 주면 된다.

 

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {

    String accessToken = getAccessToken(request);

    return accessToken == null || !accessToken.startsWith(BEARER);
}

 

요청 헤더에 액세스 토큰이 포함되지 않은 경우는

해당 메서드에서 처리해 준다.

 

private Claims verifyClaims(HttpServletRequest request) {

    String accessToken = getAccessToken(request).replace(BEARER, "");

    return jwtProvider.getClaims(accessToken);
}

 

Claim 검증은 위와 같이

기존에 JwtProvider 클래스에 구현해 둔

토큰 검증 메서드를 사용한다.

 

private String getAccessToken(HttpServletRequest request) {

    return request.getHeader(AUTHORIZATION);
}

 

액세스 토큰은 위와 같이

요청의 헤더에서 간단하게 가져올 수 있다.

 

private void setAuthenticationToContext(Claims claims) {

    CustomUserDetails principal =
        (CustomUserDetails) customUserDetailsService.loadUserByUsername(claims.getSubject());

    Collection<? extends GrantedAuthority> authorities = principal.getAuthorities();

    Authentication authentication =
        new UsernamePasswordAuthenticationToken(principal, null, authorities);

    SecurityContextHolder.getContext().setAuthentication(authentication);
}

 

콘텍스트에 인증 정보를 저장하는 메서드로

토큰의 Claim에서 사용자의 이메일을 가져와

CustomUserDetails를 얻어온 후에

이를 이용해 해당 사용자의 권한을 포함한

인증 객체를 만들고 컨텍스트에 저장한다.

 

검증 필터 테스트

이제 로그인한 사용자만 요청할 수 있는

API를 새로 만든 후에

액세스 토큰을 제대로 검증하는지

요청을 보내보겠다.

 

@GetMapping("/test")
public String test() {
    return "테스트 성공";
}

 

우선 위와 같이 간단한 API를 만들어준다.

 

static Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> getAuthorizeHttpRequestsConfigurer() {

    return auth -> auth
        .requestMatchers(
            new AntPathRequestMatcher("/auth/test")
        ).hasRole("USER")
        .anyRequest().permitAll();
}

 

해당 API를 로그인한 사용자만

요청을 보낼 수 있게 설정해 준다.

 

 

우선 헤더에 액세스 토큰을 포함하지 않으니

당연히 권한이 없다고 요청에 실패한다.

 

 

이제 위와 같이 액세스 토큰을 포함하여

요청을 보내면 성공하는 걸 볼 수 있다.

 

마치며

 

기존 프로젝트에서는

스프링 부트 2.7.1 / 스프링 시큐리티 5.7.1

버전을 사용했다.

 

이번에는 각각 3.1.3 / 6.1.3 버전을 사용했는데

설정 부분에서 달라진 점들이 있고

Deprecate 된 메서드들을 사용하지 않고 하려니

예상치 못한 오류들이 발생하여 애를 먹었다.

 

Deprecate 된 and 메서드를 사용을 자제하고

apply 메서드를 사용해서

커스텀한 설정을 적용하려니

필터 체인 빌드를 할 수가 없어

apply를 따로 빼서 적용시켰다.

 

글을 정리하는 요령이 없어서

다른 블로그들의 정리글에 비해

양이 많은거 같긴하다...

 

어쨌든 JWT 구현 정리를 모두 마쳤으니

OAuth 인증 방식에 대해서도 준비해 보겠다.

728x90
 

GitHub - da9dac/blank

Contribute to da9dac/blank development by creating an account on GitHub.

github.com

전체 코드는 해당 깃허브 레포지토리에서 볼 수 있습니다.

 

 

 

이번에 프로젝트를 마무리하면서 사용했던 기술들을 정리해 보는

시간이 필요할 것 같아 스프링 시큐리티부터 정리해 보겠다.

 

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
testImplementation 'org.springframework.security:spring-security-test'

 

우선 gradle 파일에 시큐리티와 JWT 관련 의존성을 추가해 준다.

(OAuth는 어차피 나중에 사용할 것이기 때문에 미리 넣어두었다)

 

엔티티 매핑

데이터베이스 설정은 H2나 MySQL 혹은 다른 사용 중이던

데이터베이스에 맞게 각자 설정해 준다.

@Builder
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Member extends BaseEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long memberId;

	private String nickname;

	private String email;

	private String password;

	private Authority authority;
}

 

회원 엔티티가 있어야

회원가입을 하고 로그인 정보를 검증할 테니

회원 엔티티부터 만들어준다.

 

@AllArgsConstructor
public enum Authority implements BaseEnum {
	ROLE_ADMIN("admin"),
	ROLE_USER("user");

	private final String description;

	@Override
	public String getName() {
		return name();
	}

	@Override
	public String getDescription() {
		return this.description;
	}

}

 

권한을 지정할 때 사용할 enum 클래스도 하나 추가해 준다.

이때 필드값에 어떤 것이 들어가던 상관없지만

ROLE_권한명 양식은 지켜서 만들어야 한다.

 

스프링 시큐리티는 ROLE_ 접두사가 붙은 권한을

기본적으로 인식하기 때문에 양식을 지켜야 한다.

(일관성만 유지한다면 붙이지 않고도 사용은 가능하다)

 

로그인 정보 조회 클래스

데이터베이스에서 로그인 정보를 조회해

요청값과 일치하는지 비교할 때 사용할

UserDetails와 UserDetailsService를 구현할 것이다.

 

@Getter
public class CustomUserDetails implements UserDetails {
	private Member member;

	public CustomUserDetails(Member member) {
		this.member = member;
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return Collections.singleton(
			new SimpleGrantedAuthority(member.getAuthority().toString())
		);
	}

	@Override
	public String getUsername() {
		return member.getEmail();
	}

	@Override
	public String getPassword() {
		return member.getPassword();
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	} // 계정 만료 여부 (계정의 유효기간이 지난 경우)

	@Override
	public boolean isAccountNonLocked() {
		return true;
	} // 계정 잠금 여부 (여러 번의 로그인 실패 시)

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	} // 계정의 비밀번호가 만료되었는지 (비밀번호 변경 주기가 지난 경우)

	@Override
	public boolean isEnabled() {
		return  true;
	} // 계정이 일시적으로 잠긴 상태인지 여부 (관리자에 의해 일시적으로 정지된 계정 같은 경우)
}

 

필드에 멤버 엔티티를 하나 선언한 후에

생성자로 받아와 알맞게 메서드들을 구현해주면 된다.

 

boolean 값들의 경우에는 일단 기본적으로 true로 설정해주었는데

만약 엔티티에 위의 값들을 저장할 필드를 추가한다면

member.get필드명으로 값을 받아오면 된다.

 

@Service
public class CustomUserDetailsService implements UserDetailsService {

	private MemberRepository memberRepository;

	public CustomUserDetailsService(MemberRepository memberRepository) {
		this.memberRepository = memberRepository;
	}

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

		Member member = memberRepository.findByEmail(username).orElseThrow();

		return new CustomUserDetails(member);
	}
}

 

레포지토리에서 파라미터의 username과 일치하는

회원을 찾아 위에서 만든 CustomUserDetails 객체를 생성해준다.

 

이메일과 일치하는 회원이 없는 경우에는

이메일 즉, 로그인 아이디(username)가 잘못된 경우니

아이디가 틀렸다는 등의 예외를 발생시켜도 된다.

 

이렇게 클래스들을 만들어두면

로그인 요청이 들어왔을 때 AuthenticationManager가

위의 클래스들을 사용해 로그인 정보와 실제 사용자 정보가

일치하는지 비교하여 로그인 성공 여부를 판단한다.

 

스프링 시큐리티 설정

스프링 시큐리티의 기본 설정값은

세션을 사용한 로그인 방식을 사용한다.

 

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {

		httpSecurity.apply(new CustomFilterConfigurer()); // 필터 설정

		return httpSecurity
			.httpBasic(HttpBasicConfigurer::disable) // 1
			.headers(getHeadersConfigurer()) // 2
			.csrf(CsrfConfigurer::disable) // 3
			.cors(getCorsConfigurer()) // 4
			.sessionManagement(getSessionManagementConfigurer()) // 5
			.exceptionHandling(getExceptionHandlingConfigurer()) // 6
			.authorizeHttpRequests(getAuthorizeHttpRequestsConfigurer()) // 7
			.build();
	}
}

 

우선 SecurityConfig 클래스를 생성한 후에

위와 같이 작성합니다.

 

설정에 사용된 메서드들은 잠시 뒤에 알아보고

어떤 설정들을 적용했는지 알아보겠습니다.

 

1. httpBasic 옵션은 기본적인 인증 방식으로

보안상 좋지 않기 때문에 비활성화합니다.

 

2. H2 DB를 사용하기 위해 해둔 설정이라

필수는 아니기에 생략 가능합니다.

 

3. csrf 옵션은 원래는 기본적으로 CSRF 토큰을 받은

요청만 수행하게 되어있지만 로컬에서 테스트하기 편하게

비활성화해두었습니다.

 

4. cors 옵션은 로컬에서만 테스트할 거라면

굳이 설정할 필요는 없지만 일단은

모든 URL에 대해 요청을 열어놨습니다.

 

5. 토큰 로그인 방식을 사용할 것이기 때문에

세션의 상태를 유지하지 않게 설정했습니다.

 

6. 인증 예외에 대한 처리를 설정합니다.

 

7. API 요청에 필요한 인증 및 인가의 수준을 설정합니다.

 

public class Customizers {
	// 설정을 반환할 메서드들
}

 

필터체인에 람다식으로 설정을 적용하거나

SecurityConfig 클래스 내에 메서드를 만들어도 상관없지만

가독성과 클래스의 역할을 구분하기 위해

따로 메서드와 클래스로 분리하였다

 

접근 제어자의 경우에는 Config 클래스와 같은 패키지라 default로 했지만

만약 경로가 다르다면 적절하게 바꿔줘야 한다.

 

static Customizer<CorsConfigurer<HttpSecurity>> getCorsConfigurer() {

		return cors -> {
			CorsConfiguration configuration = new CorsConfiguration();
			configuration.setAllowedOrigins(List.of("http://localhost:3000"));
			configuration.addAllowedMethod("*");
			configuration.addAllowedHeader("*");
			configuration.setAllowCredentials(true);
			configuration.setExposedHeaders(List.of(AUTHORIZATION, REFRESH, LOCATION));

			UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
			source.registerCorsConfiguration("/**", configuration);

			cors.configurationSource(source);
		};
}

 

CORS 설정으로 모든 HTTP 메서드와 헤더에 대해 요청을 허용하고

일단 허용 주소는 프런트 측이 로컬에서 자주 사용하는 3000번 포트로 열어놨다.

(그냥 "*"을 적어놔도 상관없다)

 

자격 증명 요청 여부도 true로 설정하고

헤더에 액세스 토큰, 리프래시 토큰, 리디렉트 등을 노출하도록 했다.

 

마지막으로 이 설정들을 어떤 url들에 적용할지 정하는데

모든 url에 적용되게 해 놨다.

 

나중에 프런트와 백엔드가 통합된 환경에서만 필요한 설정이라

로컬에서 혼자 연습만 할 거라면

굳이 cors 설정은 해줄 필요 없다.

 

static Customizer<SessionManagementConfigurer<HttpSecurity>> getSessionManagementConfigurer() {

		return sessionManagementConfigurer -> new SessionManagementConfigurer<>()
			.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}

 

세션 로그인을 사용하지 않을 것이기 때문에

세션 정책을 상태를 유지하지 않게 바꿔준다.

 

static Customizer<ExceptionHandlingConfigurer<HttpSecurity>> getExceptionHandlingConfigurer() {

		return exceptionHandlingConfigurer -> new ExceptionHandlingConfigurer<>()
			.authenticationEntryPoint(getCustomAuthenticationEntryPoint())
			.accessDeniedHandler(getCustomAccessDeniedHandler());
}

 

인증 및 인가 실패 시 어떻게 할지 설정해 주는 부분으로

인증에 실패하거나 인가에 실패할 시

어떤 동작들을 실행할지 지정해 준다.

 

static AuthenticationEntryPoint getCustomAuthenticationEntryPoint() {
		return new CustomAuthenticationEntryPoint();
}

static AccessDeniedHandler getCustomAccessDeniedHandler() {
		return new CustomAccessDeniedHandler();
}

 

사용된 메서드들은 그냥 직접 만든 클래스들을 리턴하는 메서드다.

 

// AuthenticationEntryPoint
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException authException) throws IOException, ServletException {

	}
}

// AccessDeniedHandler
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response,
		AccessDeniedException accessDeniedException) throws IOException, ServletException {

	}
}

 

각각의 클래스들은 위와 같이 생겼고

오버라이딩한 메서드 안에 자신이 실행시키고 싶은

로직을 추가하면 된다.

(필수는 아니지만 해두면 좋다)

 

static Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> getAuthorizeHttpRequestsConfigurer() {

    return auth -> auth
        .requestMatchers(
            new AntPathRequestMatcher("/"),
            new AntPathRequestMatcher("/h2-console/**"),
            new AntPathRequestMatcher("/profile")
        ).permitAll()
        .anyRequest().permitAll();
}

 

어떤 요청들에 어떤 권한이 필요하고

권한이 필요 없는지 등을

지정해 줄 수 있는 설정이다.

 

자신이 인증 및 인가가 필요한 API 설정에 맞게

커스텀하여 사용하면 되고

스프링 부트 2.7.1, 시큐리티 5 버전까지는 문제가 없었지만

위와 같이 new AntPathRequestMatcher() 형식을

명시적으로 지정해주지 않으면 h2 콘솔 사용 시

예외가 발생한다.

 

지금처럼 API가 별로 없는 경우에는

따로 분리할 필요 없이

필터체인 메서드 안에서 람다식으로 해도 괜찮지만

API가 많아져 설정이 복잡해진다면

위와 같이 메서드로 분리하는 것이 깔끔하다.

 

커스텀 필터 설정

이제 마지막 설정으로는

로그인 정보를 확인하고 액세스 토큰을 발급하는 필터와

액세스 토큰 만료 시 리프래시 토큰으로 재발급하는 필터,

액세스 토큰이 유효한지 확인하는 필터를 추가하는 작업을 할 것이다.

 

private class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
    @Override
    public void configure(HttpSecurity builder) throws Exception {
        AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtProvider, authenticationManager);
        jwtAuthenticationFilter.setFilterProcessesUrl(LOGIN_URL);
        // jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
        // jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

        JwtRefreshFilter jwtRefreshFilter = new JwtRefreshFilter(jwtProvider);
        JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtProvider);

        builder
            .addFilter(jwtAuthenticationFilter)
            .addFilterAfter(jwtRefreshFilter, JwtAuthenticationFilter.class)
            .addFilterAfter(jwtVerificationFilter, JwtRefreshFilter.class);
    }
}

 

아직 필터 클래스를 구현하진 않았지만

위와 같은 필터들을 구현할 것이기에

컴파일 에러가 뜨더라도 적어만 둔다.

 

성공 및 실패 핸들러는 빼도 상관 없다.

 

필터의 순서는 필터 체인 설정에 보이는 것처럼

인증 필터, 리프래시 필터, 검증 필터 순으로 넣어준다.

 

토큰 생성 및 검증 클래스

이제 구현해야 하는 클래스의 기능은 크게 세 가지다.

 

1. 사용자의 로그인 정보를 토대로

액세스 토큰과 리프래시 토큰을 발급하기

 

2. 리프래시 토큰으로 액세스 토큰을 재발급하기

 

3. 토큰 검증하기

 

@Component
public class JwtProvider {
	
}

 

일단 위와 같은 클래스를 새로 만든 후에

위에서 정리한 기능들을 구현한다.

 

@Value("${jwt.key}")
private String key;

private Key secretKey;

@PostConstruct
protected void init() {
    secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key));
}

 

key는 JWT 토큰 서명에 사용될 암호화 하기 전의 키로

yml이나 properties 파일에 값을 저장한다.

(학습용이라면 변수에 직접 명시해 줘도 상관없다)

 

다음 필드인 secretKey는 암호화된 키가 저장될 변수로

init 메서드를 사용해 초기화를 해준다.

 

시크릿키는 실제 토큰 생성에 사용되는 값으로

BASE64 형식으로 디코딩된 키에

HMAC SHA 알고리즘을 적용한 값을 할당한다.

 

즉, key를 알아보기 어렵게 암호화한

secretKey로 토큰을 생성한다.

 

public String createAccessToken(Authentication authentication, Long tokenExpireTime) {

    return Jwts.builder()
        .setSubject(authentication.getName())
        .claim(CLAIM_ID, getId(authentication))
        .claim(CLAIM_AUTHORITY, getAuthorities(authentication))
        .setExpiration(getExpiration(tokenExpireTime))
        .signWith(secretKey, SignatureAlgorithm.HS512)
        .compact();
}

 

다음은 액세스 토큰을 생성하는 메서드를 구현한다.

 

빌더 패턴을 사용하여

JWT 토큰의 구성을 설정해주는데

Authentication 객체의 getName 메서드는

사용자의 이메일을 가져오고

객체 안의 Principal에는 UserDetails가 들어있다.

 

claim에는 자신이 토큰에 포함하고 싶은

사용자의 정보를 추가해주면 되고

토큰의 만료시간과

토큰의 서명에 사용할 키를 지정해주면 된다.

 

보통 프런트가 필요한 데이터들을 claim에 넣어준다.

 

private Long getId(Authentication authentication) {

    if(authentication.getPrincipal() instanceof CustomUserDetails customUserDetails){
        return customUserDetails.getMember().getMemberId();
    }

    return null;
}

 

다음은 Claim에 넣어줄 회원의 ID를 가져오는 메서드로

여기서 ID는 이메일이 아니라 회원 테이블의 기본키다.

 

private String getAuthorities(Authentication authentication) {

    return authentication.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.joining(","));
}

 

비슷하게 해당 회원이 가진 권한도 Claim에 넣어준다.

 

private Date getExpiration(Long tokenExpireTime) {
    return new Date(new Date().getTime() + tokenExpireTime);
}

 

토큰의 만료 시간도 현재 시간을 기준으로 설정해준다.

 

public String createRefreshToken(Authentication authentication, long tokenExpireTime){

    return Jwts.builder()
        .setSubject(authentication.getName())
        .claim(CLAIM_ID, getId(authentication))
        .setExpiration(getExpiration(tokenExpireTime))
        .signWith(secretKey, SignatureAlgorithm.HS512)
        .compact();
}

 

다음은 리프래시 토큰을 생성하는 메서드로

액세스 토큰과 비슷하지만

액세스 토큰 재발급에만 쓰일 토큰이기 때문에

굳이 권한과 같은 정보를 가지고 있을 필요가 없다.

 

그래서 만료시간 정도만 액세스 토큰보다

길게 설정하여 만들어주면 된다.

 

public String refillAccessToken(String refreshToken, long tokenExpireTime) {
    Claims claims = getClaims(refreshToken);
    String username = claims.getSubject();

    UserDetails userDetails = userDetailsService.loadUserByUsername(username);

    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
        userDetails, null, userDetails.getAuthorities());

    return createAccessToken(authentication, tokenExpireTime);
}

 

리프래시 토큰으로 액세스 토큰을

재발급 할 때 사용할 메서드다.

 

액세스 토큰을 만들 때 사용할

Authentication 객체를 만들기 위해

사용자의 이메일로 UserDetails를 가져온 후에

Authentication 객체를 만들고

이를 사용해 액세스 토큰을 재발급 받는다.

 

public Claims getClaims(String token) {
    try {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    } catch (ExpiredJwtException e) {
        throw new JwtExpiredException();
    } catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
        throw new JwtNotValidException();
    }
}

 

위에서 사용한

토큰에서 Claims를 가져오는 메서드로

토큰을 비밀키로 복호화하는 과정에서

만료되거나 잘못된 토큰인 경우

예외가 발생한다.

 

위와 같이 간단하게 예외 클래스들을 만들어서

처리해줘도 괜찮고

귀찮다면 대충 런타임 예외를 던져버려도 상관없다.

(하지만 되도록이면 예외는 처리해주도록 하자)

 

public void validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
    } catch (ExpiredJwtException e) {
        throw new JwtExpiredException();
    } catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
        throw new JwtNotValidException();
    }
}

 

나중에 만들 토큰 검증 필터에서 사용할

토큰이 정상적인지 검증할 메서드로

Claims를 가져오는 메서드와

반환값의 차이만 있다.

 

시큐리티 설정과 토큰 생성 및 검증에 대한

구현을 모두 끝냈고
다음 게시글에서 필터를 구현해보겠다.

728x90

Storage Mechanisms

Username과 Password를 읽기 위해 제공되는 저장 기술들(UserDetailsService의 구현체)은 아래와 같다.

  • In-Memory Authentication
  • JDBC Authentication (JDBC를 사용하는 경우)
  • UserDetailsService (커스텀 데이터 베이스를 사용하는 경우)
  • LDAP Authentication (LDAP 저장소를 사용하는 경우)

 

UserDetailsService

저장 기술들의 종류로 해당 인터페이스를 구현한 클래스들을 사용하여

UserDetails의 관리를 제공하고 인증을 할 수 있게 한다.

(기본적으로 인메모리 방식과 JDBC 방식을 지원한다.)

 

이를 사용한 인증 방식은 Username과 Password를 사용하는 인증 방식 사용 시

스프링 시큐리티에서 기본적으로 사용하게 설정된다.

@Bean
CustomUserDetailsService customUserDetailsService() {
	return new CustomUserDetailsService();
}

위와 같이 UserDetailsService를 직접 커스텀하여 빈으로 등록하여 사용할 수도 있다.

UserDetails

UserDetailsManager를 통해 얻을 수 있는 객체로

DaoAuthenticationProvider가 UserDetails가 유효한지 확인한 후에

UserDetailsManager로 부터 인증된 UserDetails를 반환한다.

 

즉, user라는 Username을 가진 UserDetails가 있는지 확인한 후에

password까지 일치한다면 인증이 추가된 UserDetails를 반환한다.

 

PasswordEncoder

인코더라는 단어에서 알 수 있듯이 패스워드를 암호화하여 저장할 수 있는 기능을 지원한다.

PasswordEncoder도 마찬가지로 커스텀 구현하여 빈으로 등록하여 사용할 수 있다.

 

DaoAuthenticationProvider

위에서 알아본대로 UserDetailsService와 PasswordEncoder를 사용하여

Username 및 Password 기반의 인증을 수행하는 AuthenticationProvider의 구현 클래스다.

 

 

인증 과정을 순서대로 살펴보자면 아래와 같다.

  1. 아직 인증되지 않은 UsernamePasswordAuthenticationToken(토큰이라고 부르겠음)을 DaoAuthenticationProvider로 전달한다.
  2. 해당 토큰의 정보로 UserDetailsService에서 UserDetails를 조회한다.
  3. 그 후 PasswordEncoder를 사용하여 UserDetails의 패스워드와 토큰의 패스워드를 검증한다.
  4. 인증에 성공하면 인증이 추가된 토큰을 반환한다.
  5. 이는 최종적으로 필터에 의해 SecurityContextHolder에 저장된다.

In-Memory Authentication

InMemoryUserDetailsManager를 사용하는 인증 방식이다.

@Bean
public UserDetailsService users() {
	//User의 UserDetails 생성
	UserDetails user = User.builder()
		.username("user")
		.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
		.roles("USER")
		.build();
	//Admin의 UserDetails 생성
	UserDetails admin = User.builder()
		.username("admin")
		.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
		.roles("USER", "ADMIN")
		.build();
	return new InMemoryUserDetailsManager(user, admin);
}

위의 코드처럼 username, password, role을 지정하여 UserDetails를 생성한 후에

이를 사용해 InMemoryUserDetailsManager 객체를 생성한다.

 

어디서 많이 본거 같은 익숙한 느낌이 들 수도 있는데

이는 스프링 시큐리티의 기본 로그인 페이지에 접속할 때 사용하던

접속 정보를 생성해주던 코드이다.

 

즉, 서버 실행 시 콘솔창에 출력된 랜덤 비밀번호와 user라고 입력해서 로그인을 하면

해당 정보를 가지고 위의 InMemoryUserDetailsManager를 사용해

해당 정보와 일치하는 UserDetails가 있는지 확인하여 로그인이 되던 것이다.

 

하지만 인메모리 저장소의 휘발성이라는 특성 때문에

개발 및 테스트 단계에서만 사용하는 것을 권장하고 실제 배포 단계에서는 사용하면 안된다.

728x90

Username/Password

가장 일반적으로 사용되는 인증 방식으로

아이디와 패스워드를 사용해 로그인하는 방식이라고 볼 수 있다.

 

스프링 시큐리티는 HttpServletRequest에서

Username과 Password를 읽기 위해 아래와 같은 방식을 지원한다.

  • Form
  • Basic
  • Digest

 

Form

우선 로그인하지 않은 사용자가 인증이 필요한 리소스에 요청한 경우

로그인 페이지로 리다이렉트 되는 과정을 먼저 살펴보겠다.

 

 

 

우선 인증되지 않은 사용자가 인증이 필요한 리소스에 요청을 보내면

AuthorizationFilter가 AccessDeniedException 예외를 던진다.

 

인증되지 않은 사용자이기 때문에 ExceptionTranslationFilter가 인증을 시작하고

AuthenticationEntryPoint를 이용하여 로그인 페이지로 리다이렉트 시킨다.

 

리다이렉트 된 사용자는 로그인 페이지를 요청하고

로그인 컨트롤러가 로그인 페이지를 응답한다.

 

 

사용자가 로그인 페이지에서 Username과 Password를 보내면

UsernamePasswordAuthenticationFilter가 이를 통해 인증을 시작한다.

 

이후의 과정은 이전에 살펴본 내용과 유사하다.

public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		.formLogin(withDefaults());
	// ...
}

스프링 시큐리티는 기본적으로 이러한 Form 로그인 방식이 활성화되어 있지만

위의 코드와 같이 서블릿 기반 구성이 제공된다면

Form 로그인 방식을 명시적으로 반드시 지정해야 한다.

public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		.formLogin(form -> form
			.loginPage("/login") //로그인 페이지 지정
			.permitAll() //로그인 페이지의 접근권한 지정
		);
}

위와 같이 직접 커스텀한 로그인 페이지를 지정해 줄 수도 있다.

 

로그인 페이지에서 Username과 Password를 보낼 때는 Post 메서드를 이용해서 보내야 한다.

(이때 요청을 보낼 때 파라미터의 이름을 username과 password로 보내야 한다.)

@Controller
class LoginController {
	@GetMapping("/login")
	String login() {
		return "login";
	}
}

커스텀 로그인 페이지를 설정했다면 스프링 MVC 컨트롤러에서는

Get 컨트롤러에 자신이 지정한 페이지(/login)를 매핑해줘야 한다.

 

Basic

 

Form 방식에서는 인증되지 않은 사용자의 요청에 대해 로그인 페이지로 리다이렉트 했다면

Basic 방식에서는 인증되지 않은 사용자에게 로그인을 다시 요청한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		// ...
		.httpBasic(withDefaults());
	return http.build();
}

이 방식도 마찬가지로 스프링 시큐리티에서 기본적으로 활성화되어 있지만

서블릿 기반 설정을 적용한다면 위와 같이 명시적으로 적어줘야 한다.

 

Digest

크레덴셜이 일반 텍스트 형태로 전송되지 않게 Basic 방식의 문제점을 해결하려는 방법이지만

암호의 저장 방식이 보안상 좋지 않기 때문에 사용하는 것을 권장하지 않는다.

+ Recent posts