Spring Security는 내부적으로 여러 클래스와 인터페이스들을 거침으로써 웹에서 인증(Authentication)과 인가(Authorization) 작업을 쉽게 구현할 수 있도록 한다.
✅Spring Security 구조
내부 구조는 크게 보면 다음과 같다.
1. AuthenticationFilter
2. AuthenticationManager
3. AuthenticationProvider
4. UserDetailsService
5. UserDetails
6. SecurityContextHolder
(1) AuthenticationFilter
Spring Security의 구조를 크게 살펴보면, 들어온 요청은 제일 먼저 AuthenticationFilter를 거친다. AuthenticationFilter에서는 앞으로의 인증 과정에서 사용하기 위해 UsernamePasswordAuthenticationToken(일종의 토큰으로 보면 된다)을 만들고, 이를 다음 단계인 AuthenticationManager 인터페이스에 전달한다.
나중에는 AuthenticationManager로부터 인증이 완료된 User 객체를 받아서 SecurityContextHolder에 전달하는 역할까지 맡는다.
(SecurityContextHolder는 스프링 시큐리티의 In-Memory DB라고 보면 된다.)
(2) AuthenticationManager
AuthenticationManager는 사실 인터페이스이고, 이를 구현한 클래스로는 ProviderManager가 있다. 이 인터페이스/구현 클래스에서는 AuthenticationFilter에서 넘어온 토큰이 유효한지 확인한 뒤(validate token credentials) 다음 단계인 AuthenticationProvider로 넘겨준다.
(3) AuthenticationProvider
인증 작업이 이뤄지는 단계다. 토큰의 정보가 인증 서버에 있는 credentials 정보와 일치하는지를 검사한다. 일치하지 않으면 AuthenticationException을 발생시키고, 일치한다면 Authentication 토큰 객체에 추가 정보를 담는다.
인프런 강의 2강에서 SecurityConfig 클래스를 등록할 때 WebSecurityConfigurerAdapter 클래스를 상속받아 진행하는 부분이 있었는데, 현재는 WebSecurityConfigurerAdapter 클래스는 deprecated 된 상태라 사용할 수 없었다. 따라서 다른 유튜브 강의를 참고해서 진행하였다.
✅Basic Authentication
프로젝트를 시작할 때 spring security를dependency로 더하지 않았었다. MavenRepository에서 코드를 찾아 build.gradle 파일의 dependencies 변수에 추가해 준다.
해당 순서를 거쳐서 controller 클래스가 호출되고, 중간에 인증이 실패하면 아까처럼 401 Unauthorized 응답을 리턴하게 된다.
(1) JWTAuthFilter
가장 먼저 요청을 받는 클래스로, 요청의 헤더에 'JWT'가 포함되어있는지를 확인한다. 만약 JWT가 없다면 JWT 토큰 관련 정보가 없는 것이므로 401 응답을 리턴한다.
만약 JWT 헤더가 있다면 그 다음 단계인 UserDetailsService에다가 JWT 헤더의 토큰에서 추출한 유저 관련 정보를 가지고 해당 정보를 요청한다. (ex. id=1인 유저의 정보 요청)
UserDetailsService로부터 정보를 받으면 받은 유저 정보와 JWT 토큰을 디코딩해서 얻은 정보를 비교한다. 둘이 다르면 401 에러를 리턴하고, 같으면 SecurityContextHolder를 호출한다.
(2) UserDetailsService
JWTAuthFilter의 요청을 받아서 그 다음 클래스인 DB(InMemoryDB일 수도, 유저 정보가 저장된 일반 DB일 수도 있고 구현은 다양하다)에다가 유저 정보를 요청하고, DB가 유저 정보를 리턴하면 그걸 JWTAuthFilter에게 전달한다.
(3) DB
UserDetailsService의 요청을 받아서 DB에서 유저 정보를 조회한 뒤 UserDetailsService에게 리턴한다.
(4) SecurityContextHolder
이 안에 SecurityFilterChain 인터페이스도 포함되어 있다. JWTAuthFilter에 의해 호출되면 이 클래스에서는 유저의 상태를 authenticated로 바꾸고 요청을 DispatcherServlet에게 넘긴다.
그러면 DispatcherServlet에서 유저가 원래 요청했던 URL에 해당하는 컨트롤러의 메소드를 호출하고 해당 컨트롤러에서 응답을 리턴하게 된다.
✅JWT 인증 구현 - JwtAuthFilter
본격적인 JWT 인증을 구현하기 전, 하나의 dependency가 더 필요하다.
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
이후 config 디렉토리에 JwtAuthFilter 클래스를 추가하고, 해당 클래스에 @Component 어노테이션을 붙인다.
또한 해당 클래스가 OncePerRequestFilter 클래스를 상속하도록 한다.
OncePerRequestFilter는 Filter의 일종으로, 한 번의 요청당 한 번만 호출되는 filter 클래스라고 보면 된다. 그리고 해당 클래스를 상속하려면 반드시 doFilterInternal() 메소드를 구현해야 한다. 한 번의 요청이 호출될 때 해당 filter 클래스가 어떤 작업을 할지를 이 메소드에서 정의한다고 보면 된다.
참고로 해당 영상에서는 Lombok를 dependency에 추가해서 모든 생성자 관련 코드를 @RequiredArgsConstructor으로 대체하는데, Lombok을 추가하지 않았다면 다음 작업으로 대체하면 된다.
(예시) Lombok을 dependencies에 추가했을 경우
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
}
(예시) Lombok을 dependencies에 추가하지 않았을 경우
@Configuration
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
@Autowired
public SecurityConfig(JwtAuthFilter jwtAuthFilter){
this.jwtAuthFilter = jwtAuthFilter;
}
}
✅JWT 인증 구현 - UserDetailsService
UserDetailsService를 구현하는 방법은 두 가지가 있다.
UserDetailsService 인터페이스를 구현한 개별 클래스를 만들 수도 있고, SecurityConfig 클래스에 @Bean을 붙여 메소드로 구현할 수도 있다.
강의에서는 비교적 간단한 메소드 구현 방식을 사용하였으나, 여기서는 circular import 에러 문제로 UserDetailsService 인터페이스를 클래스로 구현하여 사용하였다.
UserDetailsServiceImpl.java
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Autowired
public UserDetailsServiceImpl(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository
.findByName(username)
.stream()
.findAny()
.orElseThrow(() -> new UsernameNotFoundException("해당 유저가 없습니다."));
}
}
디자인 패턴 중 controller-service-repository 패턴을 사용해서 클래스를 만들고, 저번에 연결했던 mysql에 API를 사용해서 데이터가 저장되도록 해 보았다.
컨트롤러의 경우, @RestController 어노테이션(Annotation)을 사용해서 해당 클래스를 REST 컨트롤러로 등록해 준다.
@RestController는 REST API 기능을 수행할 때 붙이고, @Controller는 REST API가 아닌, 일반 뷰를 렌더링하는 용도의 클래스에 등록한다.
또한 디자인패턴 클래스들 중 컨트롤러가 가장 먼저 요청(request)을 받게 되는데, 해당 요청을 다음 클래스인 서비스(service)로 넘겨주기 위해서, private final 타입으로 service 멤버 변수를 정의한다.
private final로 선언하는 이유는 한번 서비스 클래스와 컨트롤러 클래스가 맵핑된 뒤 그 맵핑이 바뀌지 않게 하기 위해서이다.
또한 @Autowired를 생성자에 붙여서 컨트롤러가 생성될 때 매개변수로 서비스를 받도록 한다. 서비스와 컨트롤러가 같이 생성되도록 하기 위해서이다.
그런데 스프링에서는 객체의 생성을 스프링 컨테이너에서 관리하므로, 서비스와 컨트롤러를 할당할 때 new 생성자를 쓰면 안 된다. 그냥 매개변수로 받은 서비스를 멤버 변수에 연결하면 된다.
컨트롤러 클래스 내부에는 각 메소드를 정의할 수 있는데, 메소드 하나당 하나의 API가 된다.
각 메소드에는 @GetMapping, @PostMapping 등의 어노테이션을 붙여서 해당 API가 어떤 HTTP 방식으로 작동할지를 명시한다. 또한 @GetMapping의 경우 @RequestParam 어노테이션을 붙여서 쿼리 변수를 받을 수도 있다.
MemberController.java
@RestController
public class MemberController {
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService){
this.memberService = memberService;
}
@GetMapping("/member")
public Member member(@RequestParam(value = "name", defaultValue = "user") String name){
// API logic
}
}
서비스 클래스도 마찬가지이다. 컨트롤러 클래스가 로직 가장 위단에서 요청을 처리하는 반면(통신 관련 로직 등), 서비스 클래스에는 소위 말하는 비즈니스 로직이 포함된다.
@Service 어노테이션으로 해당 클래스를 서비스 클래스로 등록한다. 마찬가지로 private final 멤버 변수로 리포지토리(repository)를 등록한 뒤, 생성자에 @Autowired를 붙여서 서비스 클래스가 생성될 때 리포지토리 클래스가 같이 생성 및 매칭되도록 한다.
MemberService.java
@Service
public class MemberService {
private final MemberRepository memberRepository;
@Autowired
public MemberService(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
public Member join(Member member){
// business logic
}
}
리포지토리 클래스의 경우 로직이 상대적으로 간단하다. 정확하게는 리포지토리 클래스가 아니라 리포지토리 인터페이스가 된다.
리포지토리 인터페이스를 만들고, 해당 인터페이스가 <도메인 클래스 타입, 도메인 클래스 id 타입>의 JpaRepository를 상속받도록 한다.
해당 JpaRepository 안에는 DB에 접근할 때 사람들이 많이 사용하는 메소드들(findById 등)이 들어 있기 때문에, 이 인터페이스를 상속하면 해당 메소드를 별도로 정의하지 않고 사용할 수 있다.
다만 JpaRepository에서는 ID같은 PK 필드에 대해서만 findById 메소드를 제공하므로, 다른 컬럼을 기준으로 찾고 싶다면 해당 인터페이스에 추가로 메소드를 정의해주면 된다.
이전에 들었던 스프링 강의에서는 Spring Data JPA를 사용하기 위해서 H2 데이터베이스를 사용했는데, 이게 용량이 작을뿐더러 실제 생활에서 많이 사용되는 데이터베이스는 아니라고 하셨다.
그래서 Spring Data JPA를 Mysql과 연동하여 사용해 보려고 한다.
우선 스프링에서 제공하는 '스프링과 mysql 연동하기' 가이드 코드를 참고했다.
mysql 프로그램은 이전에 xampp를 통해 apache 서버와 함께 다운받아 뒀었어서 추가로 설치할 것은 없었다.
✅Driver 클래스와 Driver 연동 에러
그런데 application.properties 파일 코드에서 에러가 났다.
해당 코드에서 에러가 발생한 것이었다. (지금은 에러를 해결한 상태라 파란색이지만, 원래는 붉은 글씨로 ClassException 에러가 났었다.) 찾아보니 application.properties에 명시한 driver 클래스가 실제 driver랑 연동이 되지 않아 class를 불러올 수 없던 것이었다.
그런데 나는 이미 mysql과 java의 driver인 connector 파일을 maven에서 import해서 gradle build 파일에 명시해 둔 상태였는데도 에러가 났다.
select version(); 이라는 SQL문으로 mysql 버전을 확인해 보니, 내가 쓰는 mysql의 버전은 10.0 이상이었는데 maven에서 import한 코드는 mysql 8.0 이상의 버전에 대해서는 적용할 수 없었다. (java:5.1.6 버전의 connector는 mysql 8.0 이상을 지원하지 않는 것으로 보이는데, mysql 버전이 10.4.28이었다.)
그러므로 따로 mysql 홈페이지에 가서 mysql connector 8.0을 새로 다운로드 받았다. (connector 버전은 8.0이지만 mysql의 가장 최신 버전까지 java와 연동할 수 있다.)
다운받을 connector를 선택할 때는 드롭다운 메뉴에서 platform independent를 선택해 주고, tar과 zip 중에서는 아무거나 선택하면 된다. 우리가 필요한 것은 해당 파일에 있는 .jar 파일이다. 다운받은 파일에서 jar 파일만 따로 뺀 뒤, jar 파일의 디렉토리를 복사해 두자.
✅Intellij에서 jar 파일 새로 적용하기
그리고 Intellij를 열어서 File -> Project Structure로 들어간 뒤 왼쪽 탭에서 Module을 클릭하고, 가운데 탭 중에서 Dependencies를 클릭한다. 그리고 작은 + 버튼을 누르고 '1 JARS or directories'를 누른 뒤 jar 파일을 찾아서 등록하면 된다.
그리고 다시 application.properties 파일을 확인하면 클래스를 찾지 못해서 붉게 나타나던 코드가 파란색으로 정상적으로 보이게 된다.
✅Mysql Dialect 설정 에러
그런데 여기서 또 다른 에러가 발생했다.
JDBC 메타데이터 없이는 Dialect을 결정할 수 없다-는 에러였고, 해결하려면 javax.persistence.jdbc.url / hibernate.connection.url / hibernate.dialect 값들 중 하나를 설정해야 하는 것으로 보였다.
위 세 값 중 javax.persistence.jdbc.url과 hibernate.dialect 값을 설정해서 오류를 해결할 수 있었다.
javax.persistence.jdbc.url 값은 jdbc:mysql://localhost:포트번호를 적어주면 된다.
hibernate.dialect 값은 org.hibernate.dialect.MySQLDialect로 입력하면 된다. 다른 DB를 사용한다면 다른 DB 버전으로 입력해주면 되겠다.
위와 같이 두 에러를 해결했더니 잘 실행되는 모습을 볼 수 있다.
✅Dialect란?
SQL 언어를 사용하는 많은 데이터베이스가 있다. 각 DB가 언어를 사용할 때는 SQL 언어를 그대로 사용하는 게 아니라 SQL에는 없는 부분을 따로 정의하기도 하고, SQL의 특정 부분은 구현하지 않거나, 조금씩 다른 형식의 문법을 사용한다. 이처럼 각 DB마다 사용하는 문법을 SQL Dialect라고 한다. SQL을 표준어, 각 DB에서 사용하는 방식을 방언에 비유한 것 같다.
Signal은 전체 프레임워크 내에서 어떤 이벤트가 발생했을 때, 그 이벤트의 발생을 알려주는 notification의 역할을 한다.
모든 signal은 django.dispatch.Signal 클래스의 인스턴스들이며, 사용자는 signal을 받을 수도 있고, 직접 signal을 만들 수도 있고, 많이 사용되는 signal 객체들을 받을 수도 있다. (주로 django.core.signals, django.db.models.signals 등 ~signals. 파일에 명시되어 있다.)
signal을 받거나 준다는 것은 무엇일까? signal을 받는다는 것은 어떤 이벤트가 발생했을 때 알림(notification)을 받는다는 것이고, signal을 준다는 것은 어떤 이벤트가 발생했을 때 알림을 보낸다는 것이다. signal을 받을 때는 해당 알림을 받아서 어떤 일을 할지를 receiver function으로 정의한다.
반면 signal을 줄 때는 위에서 언급한 것처럼 django.dispatch.Signal 클래스의 인스턴스를 만든 뒤 Signal의 send() 함수를 이용해서 signal을 보낸다.
✅Example
예시를 통해 살펴보자. 한 피자집에서 주문한 피자가 만들어졌을 때 소비자한테 알림을 보내려고 한다. 이때 피자집에서는 Signal을 만들고, 손님은 해당 signal을 받게 된다.
우선 피자집의 입장에서 보면, 피자가 만들어졌을 때 signal 인스턴스를 생성한 뒤 send 함수를 이용해서 보내면 된다. 이때 누구에게 보내는지는 중요하지 않다. Signal을 보내는 쪽은 발신자를 특정하지 않고 보내고, 받는 쪽에서 자신이 원하는 signal만 골라서 받게 된다.
이때, 위에서는 간단한 예시로 PizzaStore 클래스만 정의하였지만 실제로는 여러 가게들이 메뉴가 완성되었을 때 signal을 보낼 것이다. 손님이 원하는 건 자신이 주문한 피자 가게에서 보낸 signal이므로 해당 피자 가게가 보낸 signal만 받도록 설정할 수 있다. 즉 특정 발신자(sender)가 보낸 signal만 받도록 설정할 수 있다.
아래 코드에서는 PizzaStore에서 보낸 pizza_done signal에 대해서만 notify_pizza 함수를 signal과 연결시킨다.
# customer.py
from pizzastore import PizzaStore, pizza_done
from django.dispatch import receiver
@receiver(pizza_done, sender=PizzaStore)
def notify_pizza(sender, **kwargs):
print("Pizza is done!")
# code
위에서 언급한 @(decorator)를 사용하는 방법 말고도 signal과 receiver 함수를 연결할 수 있다.
# customer.py
from pizzastore import PizzaStore, pizza_done
from django.dispatch import receiver
def notify_pizza(sender, **kwargs):
print("Pizza is done!")
# code
pizza_done.connect(notify_pizza)
즉 signal과 receiver 함수를 연결하는 방법은 두 가지가 있다.
1. @(decorator)를 사용한 연결
2. connect 함수를 사용한 연결
sender 옵션은 필수 옵션은 아니다. sender 옵션을 지정하지 않을 경우, notify_pizza는 PizzaStore가 아닌 다른 클래스에서 생성한(sender가 다른 클래스로 되어 있는) pizza_done signal이 발생할 때에도 실행될 것이다.
✅Signal.send(). vs Signal.send_robust()
signal을 보낼 때는 .send() 함수 외에도 .send_robust() 함수를 사용할 수 있다. 두 함수 모두 (해당 signal을 받는 receiver 함수, 해당 함수가 리턴하는 값)의 튜플 리스트를 리턴한다.
다만 .send() 함수는 개별 receiver 함수로 signal을 보낼 때 에러가 발생할 경우 그 에러를 처리하지 않는다. 그래서 일부 receiver 함수에서 오류가 발생할 경우, 그 함수는 signal을 받지 못할 수 있다.
반면 .send_robust() 함수는 signal을 보내는 도중 발생한 모든 에러를 처리한다(catch로 처리하는 것으로 보인다). 그래서 일부 receiver 함수에 signal을 보내다가 에러가 발생한 경우, .send_robust() 함수의 값으로 리턴되는 tuple 중 일부는 (에러가 발생한 함수, 발생한 에러의 종류)로 값이 리턴된다. 어떻게 해서든지 signal을 보낸다는 의미에서 send_robust라고 이름 붙여진 것 같다.
✅Receiver 함수의 실행 횟수 제한
receiver 함수는 기본적으로 signal을 받을 때마다 실행되는데, 이 방식이 문제가 될 수도 있다.
예를 들어 어떤 모델 인스턴스가 저장될 때마다 사용자에게 이메일이 가는 것보다, 한 번만 가는 것이 더 바람직하다.
이때는 receiver 함수에 dispatch_uid라는 새로운 옵션을 선언하고 그 값으로는 해싱이 가능한 문자열이면 어떤 것이든 선언할 수 있다.
php를 실행하기 위해서는 XAMPP라는 외부 프로그램이 필요하다. XMAPP는 Apache, Mysql 등의 프로그램을 모아서 설치해주고 apache와 mysql의 실행을 관리해 준다. xmapp에서 설치해주는 apache 서버를 사용해서 php를 로컬에서 실행할 수 있다.
xampp 설치는 해당 링크에서 할 수 있다. installer 파일이 실행되면 설치 창이 뜰 텐데, 추천하는 선택지(recommend)를 선택해서 클릭해서 설치해주면 된다.
사실 php파일의 실행에서 mysql은 필요하지 않다. 다만 대부분의 백엔드에서는 데이터베이스를 사용하는 것이 일반적이므로 필요할 경우 xampp를 사용하면 mysql과 php를 연결한 뒤, apache와 mysql을 같이 실행할 수 있다는 장점이 있다.
php는 xampp가 설치된 디렉토리를 /xampp라고 하면, /xampp/htdocs 폴더 내부를 기본 실행 디렉토리로 한다. (변경하는 방법이 분명 있겠지만 여기서는 그 방법을 모르므로 이 디렉토리로 파일들을 옮겨서 실행했다.)
영상에서는 여기에 폴더를 하나 만들고, 그 디렉토리 내부에 여러 개의 php 프로젝트를 두던데 이렇게 진행하면 관리하기에 편리할 것 같았다.
apache와 필요에 따라서 mysql까지 구동하고 나면(start 버튼을 눌러서 간단히 구동할 수 있다), localhost/dashboard url을 통해 기본 페이지를 로딩할 수 있다. 이 경로가 /xampp/htdocs 경로에 해당한다. 앞서 폴더를 하나 만들고 그 안에 여러 프로젝트 디렉토리를 만들었으므로, /localhost/dashboard/folder_name/project_name/... 이런 식으로 url을 입력하면 원하는 php 파일을 브라우저에 띄울 수 있다.