상태 변화를 체크할 변수들을 몇 개 선언하고, 실제로 스택에 수를 넣었다가 빼는 작업을 하면 되는 문제다. 

 

1. queue 변수에는 스택에서 출력해야 하는 변수들을 차례대로 입력받아 넣었다. 

2. stack 변수는 실제 스택 역할을 하는 변수이다. 

3. maxNum 변수는 스택에 들어갔었던 가장 큰 숫자 값을 저장하는 변수이다. 스택에 push를 할 때는 모든 원소를 오름차순으로 넣어야 하기 때문에 이 값이 필요했다.

4. ops 변수는 스택에서 어떤 연산이 수행되었는지 그 값들을 모으기 위한 리스트 변수이다. 

 

queue 변수는 데크 자료형으로 선언해서, 맨 앞에서 원소를 뺄 때도 O(1) 시간에 제거할 수 있도록 했다. 

 

만약 현재 stack에서 출력되어야 하는 원소(queue의 맨 앞에서 빼낸 원소)가 stack의 maxNum 값보다 크다면, stack에 수를 더 넣어야 한다는 뜻이므로 for문을 사용해서 stack에 원소를 1씩 증가시키며 추가해 주었다. 또한 stack에 원소를 추가할 때마다 ops 변수에도 "+" 값을 추가해 주었다. 

 

만약 그렇지 않다면, queue에서 빼낸 원소와 stack의 맨 뒤의 값이 일치하는지를 확인했다. 두 값이 일치한다면 해당 값을 stack에서 빼내서 출력해야 하므로 stack에서 pop()으로 원소를 빼내고, ops 변수에도 "-" 값을 추가해 주었다. 

 

또한 queue와 stack에는 모두 1부터 N까지의 원소가 들어갔다 나와야 하기 때문에, 정상적으로 수행되었다면 queue가 빌 때 stack도 비어야 한다. 

만일 그렇지 않다면 중간에 queue에서 빼낸 값과 stack의 맨 뒤의 값이 일치하지 않아서 stack에서 원소를 빼낼 수 없었다는 뜻이므로, while 문을 다 돌려보면 queue는 비는데 stack에는 원소가 남게 된다. 

 

따라서 이 경우에만 에러를 출력한다. 

 

import sys
from collections import deque
input = sys.stdin.readline

N = int(input())
queue = deque([])

for _ in range(N):
    queue.append(int(input()))
    
stack = []
ops = []
maxNum = 0

while queue:
    
    elem = queue.popleft()
    
    if maxNum < elem:
        for i in range(maxNum+1, elem+1):
            stack.append(i)
            ops.append("+")
        maxNum = max(max(stack), maxNum)
    
    if stack and elem == stack[-1]:
        stack.pop()
        ops.append("-")
        
if stack:
    print("NO")
else:
    for op in ops:
        print(op)

 

 

 

파이썬의 딕셔너리 자료구조형은 해시 테이블(hash table)으로 구현되어 있는데, hash table의 경우 일반적인 리스트보다 내부 원소에 더 빠르게 접근할 수 있다. 

 

따라서 never_listen 변수를 리스트로 선언하지 않고 딕셔너리로 선언해서 M번의 루프를 돌 때 해당 이름이 never_listen 내부에 있는 값인지 더 빨리 확인할 수 있도록 하였다.

 

import sys
from collections import defaultdict
input = sys.stdin.readline

N, M = map(int, input().split())
never_listen = defaultdict(lambda: False)
never_listen_and_see = []

for _ in range(N):
    name = input().strip()
    never_listen[name] = True

for _ in range(M):
    name = input().strip()
    if never_listen[name]:
        never_listen_and_see.append(name)
        
never_listen_and_see.sort()

print(len(never_listen_and_see))
for nlas in never_listen_and_see:
    print(nlas)

 

 

 

import sys
input = sys.stdin.readline

N = int(input())
S = set()

for _ in range(N):
    command = input().strip()
    
    if command == "all":
        S = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
    elif command == "empty":
        S = set()
    else:
        cmd, elem = command.split()
        elem = int(elem)
        
        if cmd == "add":
            S.add(elem)
        elif cmd == "remove":
            if elem in S:
                S.remove(elem)
        elif cmd == "check":
            if elem in S:
                print(1)
            else:
                print(0)
        else:
            if elem in S:
                S.remove(elem)
            else:
                S.add(elem)

 

 

 

배추밭과 심어진 배추 위치 정보를 배열에 저장한 뒤, 배열을 BFS로 탐색하면 된다.

 

BFS나 DFS로 탐색하면 한 위치에서 출발해서 갈 수 있는 모든 점을 탐색할 수 있기 때문에 인접한 배추를 모두 한 번에 구할 수 있다. 

 

다른 문제에서는 이미 탐색한 지점을 구분하기 위해 visited라는 배열을 선언하기도 하지만, 여기서는 이를 구분하기 위해 이미 탐색한 배열 값을 1에서 0으로 바꿔 주었다. 

 

import sys
from collections import deque
input = sys.stdin.readline

dx = [0, 0, -1, 1]
dy = [-1, 1, 0, 0]

def bfs(start_x, start_y):
    queue = deque([[start_x, start_y]])
    arr[start_x][start_y] = 0
    while queue:
        x, y = queue.popleft()
        for k in range(4):
            nx = x + dx[k]
            ny = y + dy[k]
            if nx < 0 or nx >= M or ny < 0 or ny >= N:
                continue
            if arr[nx][ny] == 1:
                queue.append([nx, ny])
                arr[nx][ny] = 0


T = int(input())
for _ in range(T):
    M, N, K = map(int, input().split())
    arr = [[0] * N for _ in range(M)]
    for _ in range(K):
        a, b = map(int, input().split())
        arr[a][b] = 1
    
    cnt = 0
    for i in range(M):
        for j in range(N):
            if arr[i][j] == 1:
                bfs(i, j)
                cnt += 1
    print(cnt)

 

 

DP(dynamic programming)을 이용해서 풀 수 있는 문제였다. 

 

예를 들어 피보나치 함수는 f(i) = f(i-1) + f(i-2) 라는 점화식이 적용되는데, 이는 피보나치 수의 값 뿐만 아니라 문제에서 나오는 0과 1의 출력 횟수에도 동일하게 적용된다. 

 

import sys
input = sys.stdin.readline

dp = [[0, 0] for _ in range(41)]
dp[0][0], dp[0][1] = 1, 0
dp[1][0], dp[1][1] = 0, 1

for i in range(2, 41):
    dp[i][0] = dp[i-1][0] + dp[i-2][0]
    dp[i][1] = dp[i-1][1] + dp[i-2][1]
    
T = int(input())
for _ in range(T):
    N = int(input())
    print(str(dp[N][0]) + " " + str(dp[N][1]))

 

 

배열을 선언하고 명령어에 따라 뒤집거나 앞의 원소를 빼면 값은 맞게 나오지만 시간 초과가 걸리는 문제였다. 

 

1. 전처리

- 배열을 두 번 뒤집는 것은 아무 일도 하지 않는 것과 같기 때문에 명령어에 "RR"이 들어간 경우 이를 제거해주었다. 

 

- 배열이 빈 배열인데 명령어에 "D"가 들어가면 배열을 순회하기 전에 오류로 판단하였다. 

 

2. 배열 순회

- "D"가 나올 때마다 뒤집으면 deque를 사용해도 시간초과가 나는 것 같았다. 따라서 reverse라는 변수를 추가하고, 현재 D가 몇 번 나왔는지를 기록하는 데 사용했다. 

(D가 홀수 번 나왔으면 reverse=True로 배열이 뒤집혀야 하는 상태이고, 짝수 번 나왔으면 reverse=False로 배열이 원래 상태이다.)

 

- "R"이 나올 때는 reverse의 변수 값만 바꿔주었다. "D"가 나오면 reverse의 값에 따라 맨 앞의 원소를 뺄지(배열이 그대로 있는 상태), 맨 뒤의 원소를 뺄지(배열이 뒤집힌 상태)를 결정하였다.

배열은 리스트가 아니라 데크(deque)를 사용했기 때문에 원소를 빼는 작업은 O(1)이 걸렸다. 

 

- 마지막에는 배열을 그대로 출력해야 했기 때문에 reverse=True일 때만 마지막으로 한 번 뒤집어 주었다. 

 

import sys
from collections import deque
input = sys.stdin.readline

T = int(input())
for _ in range(T):
    is_error = False
    arr = deque([])
    commands = input().strip().replace("RR", "")
    length = int(input())
    string = input().strip()
    reverse = False
    if not string == "[]":
        arr = deque(string.replace("[", "").replace("]", "").split(","))
    if len(arr) == 0 and "D" in commands:
        is_error = True
    if not is_error:
        for command in commands:
            if command == "R":
                reverse = not reverse
            else:
                if len(arr) == 0:
                    is_error = True
                    break
                if not reverse:
                    arr.popleft()
                else:
                    arr.pop()
                     
    if is_error:
        print("error")
    else:
        if reverse:
            arr.reverse()
        print("[", end="")
        print(",".join(arr), end="")
        print("]")

 

Models


장고의 models.Model 클래스를 활용하면 직접 SQL을 사용하지 않고도 파이썬에서 객체 타입을 선언하고 DB와 연결시킬 수 있다. models.Model을 상속한 모델 클래스를 만들면 장고가 제공하는 ORM(object-relational mapping)을 이용할 수 있다.
models.Model을 상속한 클래스 내부에는 필드를 정의할 수 있고, 이 필드들도 models 라이브러리의 세부 속성으로 선언할 수 있다.
DB와 연결되었을 때 하나의 클래스는 하나의 테이블이 되고, 클래스 내부의 필드들은 각 테이블 내부의 컬럼들이 된다.

다음은 장고 모델 클래스의 예시이다.

from django.db import models

class User(models.Model):
    id = models.AutoField()
    name = models.CharField()
    age = models.IntegerField()


User 클래스는 장고와 연결된 DB에서 user 테이블이 되고, id, name, age 속성들은 user 테이블 내부의 id, name, age라는 이름의 컬럼들이 된다. 참고로 실제 테이블 이름은 user이 아닐 수 있다.
장고에서는 테이블 이름을 지을 때 기본으로 사용되는 규칙이 있어서 별도의 이름을 지정하지 않으면 그 규칙대로 이름이 지정되고, 원하는 이름이 있으면 커스텀 속성으로 지정할 수 있다.

보통은 'app이름_model이름' 으로 테이블 이름이 지정된다. 만약 User 클래스가 account라는 app 내부에서 정의되었다면, 기본적으로 해당 테이블은 account_user이 된다. 직접 테이블 이름을 지정할 수도 있다. 각 모델 클래스의 내부에는 Meta 클래스를 선언하여 테이블 자체와 관련된 정보를 설정할 수 있다.
db_table 속성에다가 지정하고 싶은 테이블 이름을 입력하면 된다. 아래와 같은 경우 DB에 저장되는 테이블 이름도 User가 된다.

from django.db import models

class User(models.Model):
    id = models.AutoField()
    name = models.CharField()
    age = models.IntegerField()
    
    class Meta:
        db_table = "User"


장고에서 모델 클래스(models.Model을 상속한 클래스)를 정의하면 기본적으로는 DB에 테이블이 생기고, 변경되는 점들이 있으면 마이그레이션(migration)을 통해 연결되는 것이 대부분이다. 그러나 Meta 클래스에서 다른 옵션을 선택하면 이 규칙들을 바꿀 수 있다.


1) abstract = True

장고에는 추상 모델(abstract model)이 있다. 이렇게 선언하면 장고 내부에서는 해당 클래스를 모델로 인식하지만, 연동된 데이터베이스에 테이블이 생기지는 않는다.
보통은 추상 모델 클래스를 만들어 놓고, 다른 모델에서 해당 클래스를 상속하는 방식으로 사용한다. 한 모델 클래스를 상속받는 다른 모델 클래스는 그 모델 클래스에 정의된 필드를 사용할 수 있다.


2) managed = False

장고에서 모델 클래스에 변경사항이 있으면 마이그레이션 커맨드(makemigrations, migrate 등)를 통해 반영할 수 있는 이유는 테이블에서는 이 옵션이 기본으로 managed=True로 되어 있기 때문이다. 그러나 이 옵션을 False로 저장하면 해당 모델을 추가하거나 내부 필드를 변경해도 장고와 연결된 DB에 변경사항이 반영되지 않는다. 변경사항을 반영하려면 직접 해당 DB에 SQL문을 입력해야 한다.
이 옵션은 주로 해당 모델이 장고 프로젝트 내에서만 사용되는 모델이 아닐 때 등에 사용된다.


3) proxy = True

이 속성을 지정한 모델은 기존에 있던 다른 모델의 프록시 모델이 된다. 즉 다른 모델과 똑같이 작용한다고 보면 된다.

from django.db import models

class User(models.Model):
    # contents
    
class Person(User):
    # contents
    
    class Meta:
        proxy = True


모델 Person이 모델 User의 프록시 모델이라고 해 보자. User.objects.all()과 Person.objects.all()은 모두 같은 결과를 리턴한다.
프록시 모델을 선언하는 이유 중 하나는 DB와 장고 모델과의 연결은 그대로 두고, User 모델이 장고 내부에서 작동하는 방식이나 메소드를 바꾸고 싶을 때이다. 기본 모델인 User와 달리 Person에서는 정렬(ordering) 방식을 다르게 한다던가, 내부 메소드를 추가로 정의하고 싶을 때 프록시 모델을 사용한다.

 

Managers

 

쿼리 등으로 장고의 모델과 데이터베이스 사이에 통신이 일어날 때, 모델과 데이터베이스가 직접적으로 통신하지 않는다. 그 사이에는 Manager 클래스가 있다.
이 manager 클래스는 한 모델마다 최소 1개 이상이 있다. 즉 1:M의 관계다. 기본적으로는 1개이지만 원한다면 하나의 모델에 여러 개의 매니저 클래스를 둘 수 있다.
우리가 알고 있는 기본 매니저 클래스가 바로 objects이다. 항상 쿼리를 날릴 때 Model.objects.all() 이라고 썼었는데, 그 objects가 바로 매니저 클래스였던 것이다.

매니저 클래스는 쿼리셋을 만드는 방식이나 쿼리셋의 결과를 다르게 하고 싶을 때에도 사용한다.
예를 들면 Person 모델에는 employee_type이 crew, chef, administrator인 세 종류의 직원이 있다고 해 보자. 만약 employee_type이 crew인 직원들 안에서만 쿼리를 수행하고 싶다면 매번 filter() 메소드를 거는 방법도 있지만, 아예 crew_members 라는 매니저 클래스를 생성하고 필터링된 쿼리를 적용한다면 더 편하고 직관적으로 쿼리를 작성할 수도 있다.

# models.py
from django.db import models

class Person(models.Model):
    # contents
    objects = models.Manager()
    crew_objects = PersonCrewManager()
    administrator_objects = PersonAdministratorManager()
    chef_objects = PersonChefManager()
    
class PersonCrewManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(employee_type="crew")
    
class PersonAdministratorManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(employee_type="administrator")
        
class PersonChefManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(employee_type="chef")
# example.py
from models import Person

Person.objects.all()
Person.crew_objects.all()
Person.administrator_objects.all()
Person.chef_objects.all()

 

 

QuerySet

 

장고 모델에서 DB에 연결하여 정보를 가져오기 위해서 사용하는 것이 쿼리셋이다. 장고 문법으로 쿼리를 작성하면 해당 쿼리셋은 DB의 SQL로 변환되어 DB에 전달되고, DB에서 나온 데이터는 다시 장고에서 쿼리셋으로 변환된다. 

 

예를 들어서 다음과 같은 쿼리를 작성하면, DB에는 이에 해당하는 SQL문이 입력된다. 

Person.objects.get(id=1)
SELECT * FROM myapp_person WHERE id = 1;

 

사실 쿼리셋은 코드가 실행되자마자 SQL에서 데이터를 가져오지는 않는다. 즉 쿼리셋은 작성되는 시점과 데이터를 가져오는 시점(evaluate 되는 시점)이 다르다. 그래서 'querysets are lazy'라는 말이 있다. 

변수에 쿼리셋을 할당할 때는 DB를 거치지 않고, 결과물을 콘솔 등으로 print()해서 보여줄 때 DB를 거쳐서 결과를 가져오게 된다. 

 

또한 쿼리는 캐싱을 사용한다. 한 번 evaluate 된 결과를 다음에 사용할 때는 또 데이터베이스를 거치지 않고(no hit), 캐싱해둔 데이터를 사용한다는 것이다. 그런데 그 결과값을 변수에 할당해 두지 않으면 같은 쿼리 결과를 사용할 때도 데이터베이스를 계속 hit 하게 된다. 

 

또한 한 모델 테이블과 연결된 여러 모델의 데이터도 select_related()prefetch_related()를 통해서 한 번에 불러올 수 있다. 이는 특히 for loop 등으로 반복적인 작업을 계속할 때 효율적이다. 

해당 모델이 참조하는 모델 데이터를 불러올 때는 select_related(), 해당 모델을 참조하는 모델 데이터를 불러올 때는 prefetch_related()를 불러온다. many-to-many 관계의 모델 데이터도 prefetch_related()로 불러올 수 있다. 

 

캐싱이 사용되는 경우 -> 효율적

people = Person.objects.select_related("company")		# Person, Company 데이터를 모두 가져옴
for person in people:
    person.company.is_valid = True		# 여기서는 database hit 발생 X

캐싱이 사용되지 않는 경우 -> 비효율적

people = Person.objects.all()		# 여기서 Person 모델 데이터만 가져옴
for person in people:
    person.company.is_valid = True		# 한 번 실행될 때마다 database hit

 

 

복잡한 쿼리셋

 

쿼리셋에서 filter(), exclude() 등으로 여러 개의 조건을 걸 수 있는데, 간혹 복잡하거나 구체적인 조건도 설정할 수 있다. 

 

1) field lookups

말 그대로 필드의 값과 동일한 값만 찾는 게 아니라, 숫자일 경우 gt, gte, lt, lte 등으로 작거나 큰 값을 조회할 수 있다. 문자열인 경우는 contains, iexact 등으로 특정 문자열을 포함하거나 대소문자를 구분하지 않은 결과들도 조회할 수 있다. 이런 field lookup등의 경우 필드 이름 뒤에 '__'을 붙인다. 

더 복잡한 field lookup도 있다. '__'을 붙이면 해당 모델과 관계 있는 다른 모델에서의 검색도 가능하다. 이 FK 검색은 연쇄적으로 계속 일어날 수 있는데, 실제로 이렇게 쿼리를 작성할 경우 SQL문에서는 JOIN 문을 사용해서 결과를 가져온다고 한다. 

# Person의 employee_type 필드값에 'c'를 포함한 모든 레코드 조회
Person.objects.filter(employee_type__contains="c")

# Person의 foreignkey인 Company 모델의 name 필드값에 'a'를 포함한 모든 레코드 조회
Person.objects.filter(company__name__contains="a")

 

2) Q()

Q() 안에는 filter(), exclude()에 사용하는 것처럼 조건문이 온다. 다만 Q()를 여러 개 사용해서 AND, OR, XOR 등 다양한 조건이 교차하는 필터링을 할 수 있다. 기존의 chaining 방법으로는 모든 조건들을 전부 만족하는 쿼리만 불러올 수 있지만 Q()를 사용하면 조건들을 다양하게 선언할 수 있다. 

# Person 중 name에 'peter'이 포함되고 27세 이상인 레코드 조회
Person.objects.filter(Q(name__contains="peter") | Q(age__gte=27))

 

3) F()

F()를 사용하면 기존의 다른 쿼리와는 달리 여러 필드들의 값을 비교할 수 있다. 기존에는 한 필드의 값에만 조건을 걸 수 있었다. 하지만 F()를 사용하면, 예를 들면 두 필드값을 비교하는 등의 필터링도 가능하다. 또한 필드값을 가져와서 연산 후에 조건을 거는 것도 가능하다. 

# House 레코드 중 number_of_people 필드와 number_of_cars 필드값이 같은 레코드만 조회
House.objects.filter(F("number_of_people") == F("number_of_cars"))

# House 레코드 중 price가 annual_people_income 필드값의 2배 이상인 레코드만 조회
House.objects.filter(price__gt=2*F("annual_people_income"))

 

4) Aggregation

또한 aggregate()를 사용하면 레코드의 총 개수, 특정 필드의 총합 등 특정 값을 추가해서 계산할 수 있다. Count, Avg, Max, Sum 등 다양한 기본 함수들을 사용할 수 있다. 

# 전체 Person 레코드의 개수 조회
Person.objects.aggregate(Count("id"))

 

5) Annotate

annotate()를 사용하면 쿼리셋에 포함되는 각 인스턴스 별로 aggregate()와 같은 쿼리를 생성할 수 있다. 

# 피자에 들어가는 토핑의 개수를 같이 조회
pizzas = Pizza.objects.annotate(number_of_toppings=Count("toppings")

# 첫 번째 피자에 들어간 토핑의 개수
pizzas[0].number_of_toppings

 

'server-side > Django' 카테고리의 다른 글

django routers  (1) 2023.12.21
django apps  (0) 2023.12.20
python - poetry 사용하기  (0) 2023.07.12
signals  (0) 2023.06.21
Model.select_related() vs Model.prefetch_related()  (0) 2022.07.11

이 포스트는 인프런의 스프링부트 시큐리티 강의를 참고하여 작성되었습니다. 

 

 

프로젝트 구조

구글 로그인은 소셜로그인의 일종이기 때문에 스프링 시큐리티를 사용할 때 OAuth와 함께 사용해야 한다. 홈페이지에서는 일반 로그인과 구글로그인을 같이 구현하는 것이 목표이므로, 일반 로그인의 코드를 따로 건드리지 않고 구글 로그인 코드를 작성해 주어야 하겠다. 

 

일반 로그인 관련 코드는 auth라는 패키지에, 소셜 로그인 관련 코드는 oauth 패키지에 따로 묶어두자. 

 

스프링 시큐리티를 사용할 때 가장 기본이 되는 클래스는 SecurityConfig 클래스다. 이 클래스 내부에는 SecurityFilterChain 인스턴스를 리턴하는 filterChain() 메소드가 재정의된다. filterChain() 메소드는 스프링 시큐리티가 개입할 때 가장 먼저 호출되는 메소드이다.

 

이 클래스에 @EnableWebSecurity 어노테이션을 붙여서 내부에 정의된 filterChain() 메소드를 스프링 필터체인(filterChain)에 등록한다. 

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {

    @Autowired
    public PrincipalOauth2UserService principalOauth2UserService;

    @Bean
    public BCryptPasswordEncoder encoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.csrf(csrf -> csrf.disable());
        http
                .cors(cors -> cors.disable())
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests((authz) -> authz
                        .requestMatchers("/login", "/loginForm", "/loginProc", "/join", "/joinProc", "/user").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin((formLogin) ->
                        formLogin
                                .loginPage("/loginForm")
                                .loginProcessingUrl("/loginProc")
                                .defaultSuccessUrl("/"))
                .oauth2Login((oauth2Login) ->
                        oauth2Login
                                .loginPage("/loginForm")
                                .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(principalOauth2UserService))
                )
                .httpBasic(withDefaults());
        return http.build();
    }

}

 

HttpSecurity.cors(), HttpSecurity.csrf()의 내부에서는 CORS, CSRF 처리를 어떻게 할지를 정의할 수 있다. 

 

HttpSecurity.authorizeHttpRequests() 내부에서는 프로젝트의 각 엔드포인트에 대해 접근권한을 허용할지 말지를 결정할 수 있다. requestMatchers() 안에 url 패턴을 선언해서 해당 패턴들을 가진 엔드포인트들을 한 번에 처리할 수도 있다. 

 

뒤에 authenticated()를 붙이면 해당 엔드포인트들에 대해서는 인증을 실행하고, permitAll()을 붙이면 해당 엔드포인트에 대한 요청들은 별도의 인증을 안 하고 통과시킨다. 

 

HttpSecurity.formLogin() 내부에서는 시큐리티 기본 로그인을 어떻게 할지를 설정한다. 기본 로그인이란 아이디, 비밀번호를 입력해서 사용자를 인증하는 로그인이다. 메소드 내부에서는 .loginPage()로 어떤 엔드포인트를 기본 로그인 페이지로 설정할지를 정할 수 있다. 이 경우 인증되지 않은 사용자가 인증이 필요한 페이지를 요청할 경우 기본 로그인 페이지로 리다이렉트 된다. 

 

.loginProcessingUrl()은 로그인 과정을 처리하는 엔드포인트다. 보통 로그인 폼에서 '로그인'을 눌렀을 때 로그인 과정을 처리하는 엔드포인트로 이동하고, 해당 엔드포인트에서 로그인 인증이 진행된다. 

 

.defaultSuccessUrl()은 로그인이 성공했을 때 리다이렉트 될 엔드포인트이다. 

 

HttpSecurity.oauth2Login() 내부에서는 OAuth 로그인을 어떻게 설정할지를 작성한다. .loginPage()에서 설정한 엔드포인트로부터 들어오는 요청들에 대해서 oauth provider(구글, 네이버 등등)가 인증을 진행한다. 로그인 페이지 말고도 여러 엔드포인트를 설정할 수 있다. 

 

그 전에, OAuth에서는 3가지의 주체가 있다: Client, Resource Owner, Resource Server

client는 소셜로그인을 이용하려는 웹사이트의 서버, resource owner는 사용자, resource server는 구글 등 소셜로그인 서비스를 제공하는 서버이다. 

 

 

스프링 시큐리티 OAuth 2.0에서 지정한 엔드포인트(protocol endpoint)

1. Authorization Endpoint

client가 resource owner로부터 권한(authorization)을 획득하는 부분이다. 

즉 사용자(resource owner)의 로그인이 진행되는 부분이다. 사용자가 로그인을 진행하면 그 과정에서 앱 서비스(client)는 권한 코드(authorization code)를 얻을 수 있다. 사용자의 로그인이 성공적이었다면 1번에서 권한 코드를 얻을 수 있다. 

 

2. Token Endpoint

1번에서 얻은 권한 코드를 액세스 토큰으로 교환하는 지점이다. 

 

3. Redirection Endpoint

인증 서버(resource server)가 앱 서비스(클라이언트)에게 권한 정보(authorization)를 리턴하는 지점이다. 

 

4. UserInfo Endpoint

클라이언트가 인증된 사용자의 정보(claims)를 받기 위해서는 이 엔드포인트로 요청하면 된다. 

 

코드에서는 .userInfoEndpoint()로 사용자의 인증 정보를 받아서 어떻게 처리할지를 설정했다. 

.userInfoEndpoint().userService() 에서는 사용자의 인증 정보를 엔드포인트로 받아서 어떤 UserDetailsService 클래스에 넘겨줄지를 설정한다. 여기서는 앞에서 미리 작성한 principalOauth2UserService 클래스에게 유저 정보를 넘겨주기로 설정했다. 

 

 

OAuth 서비스 클래스 작성 - 기존 로그인 로직과 연동

principalOauth2UserService 클래스는 DefaultOAuth2UserService 클래스를 상속하고, DefaultOAuth2UserService 클래스는 OAuth2Service 인터페이스를 구현한다. 

OAuth2Service 인터페이스는 UserInfo Endpoint에서 유저 정보를 얻은 뒤 그에 맞는 OAuth2User 인터페이스 타입의 인스턴스를 리턴한다. 즉 우리 클래스는 이 인터페이스를 구현받은 클래스를 상속받고 있으므로 OAuth2Service 인터페이스의 역할 + a를 한다고 볼 수 있다. 

 

해당 클래스는 앞서 구현 및 상속받은 인터페이스, 클래스가 있기 때문에 loadUser() 라는 메소드를 재정의해야 한다. loadUser() 메소드는 UserInfo Endpoint에서 유저 정보를 얻은 뒤 그에 맞는 OAuth2User 인터페이스 타입의 인스턴스를 리턴한다. 인터페이스 타입을 리턴할 수는 없으므로, OAuth2User 인터페이스를 구현한 클래스를 하나 만든 뒤 그 클래스 타입의 인스턴스를 리턴시켜야 하겠다. 

 

(예시 코드)

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oauth2User = super.loadUser(userRequest);
        // code
        return new PrincipalDetails(userEntity, oauth2User.getAttributes());
    }
}

 

여기서 PrincipalDetails라는 클래스는 강의에서 정의한 클래스로, 기존 로그인에서 필요했던 UserDetails 인터페이스와, loadUser()의 리턴 타입인 OAuth2User 인터페이스를 구현한 클래스이다. 

 

앞서 기본 로그인을 진행하기 위해서는 PrincipalOauth2UserService와 같은 역할을 하는 서비스 클래스로 PrincipalDetailsService 클래스를 정의했었고, 해당 클래스는 UserDetailsService 라는 인터페이스를 구현하고 있었다. UserDetailsService 인터페이스에서는 loadUserByUsername() 메소드를 오버라이딩 해야 했는데, 이 메소드의 리턴 타입이 UserDetails 이었다.

 

즉 PrincipalDetailsService와 PrincipalOauth2UserService의 관계는 매우 비슷하다. 각각 UserDetailsService와 OAuth2Service 인터페이스를 구현하고, 그 인터페이스에 선언된 메소드에서는 각각 UserDetails와 OAuth2User 인터페이스 타입을 리턴한다. 

 

그러므로 PrincipalDetails 클래스에서 두 인터페이스(UserDetails, OAuth2User)를 모두 구현해버리면 하나의 클래스가 일반 로그인 서비스인 PrincipalDetailsService와 소셜로그인 서비스인 PrincipalOAuth2UserService 모두에서 쓰일 수 있게 된다. 

 

대신 일반 로그인과 소셜로그인의 처리 절차가 다를 수 있으므로, UserDetailsService의 loadUserByUsername() 메소드와 OAuth2UserService의 loadUser() 메소드를 각각 다르게 오버라이딩 해 주면 같은 엔드포인트와 같은 클래스 타입을 사용해서 두 가지의 로그인을 한번에 진행할 수 있다. 

 

구조 설명

 

해당 그림은 스프링 시큐리티 구조를 간단히 나타낸 것이다. 

 

앞서 언급한 PrincipalOauth2UserService와 PrincipalDetailsService는 그림에서 'UserDetailsService' 부분에 해당한다. 이 클래스들은 AuthenticationProvider에서 authentication 객체를 건네받은 뒤, 해당 객체를 통해 UserDetails 인터페이스에 접근해서 유저 정보를 가져온 뒤 다시 AuthenticationProvider에게 그 정보를 리턴하는 역할을 한다. 

 

또한 앞서 나왔던 PrincipalDetails는 UserDetails 부분에 해당한다. 보통 모델에서 사용하는 유저 클래스가 이 인터페이스를 구현한다. 이 인터페이스에는 스프링 시큐리티에서 해당 유저 모델을 사용하기 위해서 필요한 여러 필드와 메소드(개별 유저의 권한 리턴하기, 유저가 유효한지 확인하는 메소드 등)가 선언되어 있다. 

 

+ Recent posts