9. 로그인한 이용자만 사진 게시물 게시하기

지난 편에서는 Django 이용자 인증 체계을 알아봤으니 이번 편에서는 인증 기능을 이용하여 로그인한 이용자만 사진 게시물을 게시하는 기능을 만들어 보겠습니다.

1. 사진 게시물에 이용자 정보 연결

(1) 기본키 (Primary Key)

로그인한 이용자만 사진 게시물을 게시한다면 각 사진 게시물엔 사진을 게시한 이용자 정보를 담아야 합니다. 누가 게시한 사진인지 알아야 하니까요. 이용자 ID(username)을 문자열로 담아도 되지만, 대개는 고유하며 변하지 않을 정보인 기본키(Primary Key)를 담습니다.

이용자가 따로 기본키 역할을 하는 모델 필드를 지정하지 않으면 Django는 관례대로 id라는 모델 필드를 알아서 만들고 이를 기본키로 사용합니다. 사진 모델인 Photo로 사진 게시물 데이터를 photo01이라는 인스턴스 객체로 할당하면 이 객체엔 id라는 멤버 변수가 속성으로 존재하고 이 속성에 정수(int) 값이 할당되어 있습니다.

>>> photo01 = Photo.objects.last()
>>> print(photo01.pk)

모델의 인스턴스 객체엔 pk 속성도 존재하는데, 이 속성은 기본키를 가리키고 있습니다. 이용자가 id 모델 필드를 기본키로 하지 않고 uuid라는 모델 필드를 만들어 이 필드를 기본키로 지정하면 pk는 이 모델 필드를 가리킵니다. 우리는 각 모델의 기본키로 설정된 모델 필드의 이름이 무엇인지 신경쓰지 않고 pk 속성을 사용하면 됩니다.

(2) 모델 관계 필드 (relationship fields)

Photo 모델로 데이터베이스에 저장할 데이터에 이용자 모델의 기본키 값을 저장하려면 다음과 같이 하면 됩니다.

class Photo(models.Model):
    user_id = models.IntegerField()

그런 뒤에 Photo 모델로 데이터를 저장하는 과정에서 이용자의 기본키 값을 user_id에 할당하면 됩니다. 각 게시물을 게시한 이용자가 누구인지 아니까 각 사진 게시물 정보를 가져오면서 이용자 정보도 함께 가져오면 좋겠군요.

from django.contrib.auth import get_user_model
User = get_user_model()

class Photo(models.Model):
    # 중략
    def get_user(self):
        return User.objects.get(pk=self.user_id)

이 코드들엔 문제가 있습니다. 모델의 기본키 모델 필드가 IntegerField가 아닌 경우에 제대로 대응하지 못하고, get_user() 메서드를 호출할 때마다 매번 이용자 모델에서 이용자 데이터를 탐색해 반환하며, 이용자 모델과 연결하는 모델마다 매번 저런 구현을 중복 적용해야 합니다. 이런 문제들에 대응하는 구현체를 만들어야 하는데, Django는 관계 모델 필드로 제공합니다. 총 세 종류입니다.

  • ForeignKey : 1 대 다(1 to n) 관계
  • OneToOneField : 1 대 1 관계
  • ManyToManyField : 다 대 다(n to n) 관계

이용자와 사진 게시물 관계로 세 관계 필드를 살펴 보겠습니다. OneToOneField는 이용자는 오직 사진 게시물을 하나만 게시하고 소유합니다. ManyToManyField는 이용자가 사진 게시물을 여러 개 올릴 수 있는데, 각 사진 게시물을 여러 이용자가 소유하는 게 가능합니다. 1번 사진을 Hannal 이용자 뿐만 아니라 Kay, Yuna 이용자가 소유하는 관계가 맺어지므로 소유한 누구나 사진 게시물을 변경하거나 지울 수 있습니다. 마지막으로 ForeignKey는 한 이용자가 여러 사진 게시물을 게시하고 소유하는 관계입니다. 우리에게 필요한 모델 관계군요. ForeignKey에 대한 건 본 편 “2. ForeignKey 모델 필드”를 참고하시고, 연결부터 해보겠습니다.

(3) Photo 모델에 이용자 기본키 정보 연결하기

from django.conf import settings

class Photo(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL)
    # 후략

ForeignKey 모델 필드(클래스)는 django.db.models 모듈에 있어서 다른 모델 필드처럼 models 객체에서 접근하여 사용하면 됩니다. 첫 번째 인자로 관계 지을 모델을 모델 객체나 경로를 문자열로 전달합니다. settingsAUTH_USER_MODEL 설정 항목은 현재 프로젝트에서 사용하는 인증용 이용자 모델이 위치한 경로를 문자열로 지정하고 있습니다. 8. 로그인, 로그아웃 하기편에서 이에 대한 내용을 참조하세요.

마이그레이션 수행

Photo 모델이 변경됐으니 데이터베이스에 반영해야 합니다. 마이그레이션을 수행합니다.

$ python manage.py makemigrations photos
You are trying to add a non-nullable field 'user' to photo without a default;
we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py
Select an option:

긴 영어 문장이 나오며 뭔가를 물어 보는데 당황하지 말고 내용을 잘 살펴 보세요.

  • 당신은 Null(None)을 허용하지 않는(non-nullable) user라는 필드를 추가하려 하는데, 값이 주어지지 않는 경우에 기본으로 저장될 기본값(default)이 없다.
  • 데이터베이스가 기존에 저장된 데이터(populate existing rows)에 Null 외 값을 필요로 해서 우린(Django) 이 작업을 바로 수행하지 못한다.
  • 그러니 뭔가 조치를 취해달라.
    1. 이 자리에서 기본값을 제공해주든
    2. 마이그레이션 수행 과정에서 빠져 나간 뒤에 models.py에 기본값 지정 인자(default)를 추가해달라.

그 자리에서 기본값을 제공해줄테니 1을 입력합니다. Django가 뭔가를 요구하네요.

Please enter the default value now, as valid Python
The datetime module is available, so you can do e.g. datetime.date.today()
>>> 

기본값으로 유효한 Python의 객체를 입력하라는 말입니다. 일단 1을 입력합니다.

이제야 마이그레이션 수행에 필요한 마이그레이션 작업 파일이 만들어 졌습니다. 두 번째 입력한 1은 이용자 모델의 기본키 값이 1을 뜻합니다. 기존에 저장한 사진 게시물의 이용자를 기본키 값이 1인 이용자로 지정한 것입니다. 만약, 기존에 소유자가 없는 사진 게시물을 위해 별도 이용자를 만들어서 연결하고자 한다면, 그 이용자를 만들어서 그 이용자의 기본키 숫자값을 지정하면 됩니다.

왜 기본키 값을 넣는지 첫 번째 이유는 앞서 설명을 하였고, 두 번째 이유는 “(2) ForeignKey 모델 필드”에서 설명하겠습니다.

이제 makemigrations 명령어로 만든 마이그레이션 작업 내용을 migrate 명령어로 데이터베이스에 반영합니다.

2. 로그인한 이용자만 사진 게시물 게시하기

Photo 모델에 이용자 모델 연결을 마쳤으니 이번엔 사진 게시물을 저장하는 과정에 로그인한 이용자 정보를 적용하겠습니다. 간단히 말해서 로그인한 이용자만 사진 게시물을 게시하는 것이지요.

(1) 현재 이용자를 사진 게시물에 적용

현재 웹 서비스에 연결된(requested) 접속 정보는 request 객체에 담겨 있습니다. request는 뷰(view) 함수가 첫 번째 인자로 전달받는 객체입니다. 개별 사진을 보는 detail 뷰 함수는 def detail(request, pk):와 같이, 사진 게시물을 새로 저장하는 create 뷰 함수는 def create(request):와 같이 첫 번째 인자로 request를 전달 받습니다.

reuqest 객체엔 user 속성이 존재하는데, 이 속성은 접속한 이용자에 정보가 담겨 있습니다. 로그인한 이용자라면 이용자 모델 클래스로 생성한 인스턴스 객체가, 로그인하지 않은 이용자라면 AnonymousUser 모델 클래스로 생성한 인스턴스 객체가 할당됩니다.

그럼 로그인한 이용자만 사진을 게시하도록 코드부터 작성해 보겠습니다.

def create(request):
    if request.method == "GET":
        form = PhotoForm()
    elif request.method == "POST":
        form = PhotoForm(request.POST, request.FILES)

        if form.is_valid():
            obj = edit_form.save(commit=False)
            obj.user = request.user
            obj.save()

            return redirect(obj)

    # 후략

한 줄은 조금 바뀌었고, 두 줄이 추가됐습니다.

if form.is_valid():
    obj = form.save(commit=False)
    obj.user = request.user
    obj.save()

두 번째 줄 obj = form.save(commit=False)에서 form 객체는 사진 게시물 생성과 관련된 폼(Form)인 PhotoForm 폼 클래스입니다. 첫 번째 줄에서 is_valid()로 웹에서 전달받은 자료를 검증한 그 객체입니다. 이 객체가 모델 폼인 경우, 그러니까 ModelForm 클래스를 상속받아 만든 폼인 경우 save() 인스턴스 메서드를 포함하고 있는데, 이 메서드는 모델의 save() 메서드와 동일한 역할을 합니다. 데이터를 모델에 연결된 데이터베이스 테이블에 저장하는 것이지요. 모델의 save() 메서드와 마찬가지로 저장한 내용이 반영된 모델의 인스턴스 객체를 반환합니다. 즉 form.save()로부터 반환받은 객체를 할당한 obj는 모델 폼의 인스턴스 객체가 아니라 PhotoForm 모델 폼 클래스에 연결되어 있는 Photo 모델로 생성한 인스턴스 객체입니다.

save() 메서드에 인자로 전달한 commit은 실제로 데이터베이스에 반영할 것인지 여부를 정합니다. True를 전달하면 바로 데이터베이스에 저장하고, False라고 하면 모델 클래스로 생성한 인스턴스 객체만 반영하고 데이터베이스에 실제로 반영하진 않습니다. 따로 반영 여부를 정해주지 않으면 기본값은 True이어서 바로 데이터베이스에 반영합니다. 데이터베이스에 저장하지 않을 거면서 뭐하러 save() 메서드를 호출했으며, save() 메서드엔 실제로 반영할 것인지 여부를 정하는 commit 인자가 필요한 이유는 무엇일까요? 답은 그 바로 다음에 나오는 두 줄에 있습니다.

obj.user = request.userobj 객체의 user 속성에 현재 로그인한 request.user 속성을 할당하는 것입니다. PhotoForm 폼 클래스는 웹에서 폼 양식 자료가 담긴 request.POSTrequest.FILES만 전달 받았지, 현재 이용자 정보가 담긴 request.user를 전달받은 적이 없습니다. 그래서 save() 메서드가 반환한 obj 인스턴스 객체의 user에는 이용자 정보가 없습니다. Photo 모델에 auth.User에 있는 이용자 모델을 ForeignKey 관계로 연결했는데, 뷰 함수에서는 관련 정보를 Photo 모델의 user에 반영하지 않았습니다.

만약, edit_form.save()edit_form.save(commit=True)처럼 바로 데이터베이스에 반영하려고 하면 IntegrityError 예외 오류가 발생하며, NOT NULL constraint failed: photo_photo.user_id라 안내 받습니다.

겁먹지 말고 오류 안내말을 잘 보세요. 정확히 이해하지 않은 채 추측만 하려는 태도는 안 좋지만, 여러분은 이 강좌 나머지 내용을 그냥 건너뛰지 않을테니 예외 오류 내용을 보고 상황을 추측해 보겠습니다. NOT NULL, failed, photos_photo.user_id 이 세 가지 표현이 눈에 들어오지요? photos_photophotos 앱의 Photo 모델과 연관되어 보이고, user_id는 우리가 만든 적이 없지만 user 모델 필드와 관련되어 보입니다. Photo 모델의 user 모델 필드에 NOT NULL과 관련된 문제가 발생하여 진행하던 작업(save())이 실패했다는 뜻이군요. NOT NULLNULL이면 안 된다는 의미니까 Photo 모델의 user 모델 필드에 NULL이 들어가서 오류가 생긴 겁니다.

웹페이지의 폼 양식에서 이용자 모델의 기본키 값을 직접 전달하면 안 됩니다. 예를 들어, hannal 이용자의 기본키 값이 1023이고 이 값을 웹 폼 양식에서 user_id로 담아서 서버로 전달한다면, 이용자는 기본키 값 숫자를 고쳐서 마치 다른 이용자가 사진 게시물을 올린 것처럼 왜곡할지도 모릅니다. 이런 정보는 서버에서 알아내서 다뤄야 합니다. 그게 request.user입니다. 아하, request.userPhotoForm 폼 클래스에 전달하면 되겠구나.

form = PhotoForm(request.POST, request.FILES, request.user)

request.POST처럼.

아닙니다. 그렇게 알아서 동작(magic behaviour)해주지 않고, 그래서도 안 됩니다. 이용자 정보가 필요한 폼 클래스라면 이용자 정보를 따로 전달받도록 처리해야 합니다. Python 클래스는 실행 가능한(callable) 객체이므로 함수처럼 소괄호를 사용하여 실행하고 실행 결과로 인스턴스 객체를 반환 받는데, 인스턴스 초기화를 수행하는 메서드가 __init__()입니다. 이용자 정보인 request.user를 인자로 전달 받는 __init__() 메서드를 PhotoForm 폼 클래스에 만들면 됩니다. 이건 Class based view를 다룰 때 살펴보기로 하고, 이번 편에서는 save() 메서드에 commit 인자를 False로 전달하여 처리합니다.

Photo 모델의 user 모델 필드는 user = models.ForeignKey(settings.AUTH_USER_MODEL)로 만들었고, 이 모델 필드는 NULL을 허용하지 않습니다. 데이터베이스 테이블에도 NOT NULL로 정의되어 있습니다. 그래서 데이터베이스에 실제로 반영하지 말고 우선 모델로 생성한 인스턴스 객체를 edit_form 객체로부터 받으려고 save(commit=False) 메서드를 수행했습니다.

PhotoForm이 반환하는 Photo 모델의 인스턴스 객체를 obj에 할당받고, 이 객체의 user 속성에 이용자 정보인 request.user를 할당합니다. obj.user = request.user 코드입니다. 이제 obj.save()를 수행하여 데이터베이스에 저장합니다. obj.save(commit=True)와 동일합니다.

이제 로그인한 이용자 정보가 사진 게시물에 반영되어 저장됩니다.

(2) create 뷰 함수에 로그인한 이용자만 접근하도록 제한

로그인하지 않은 이용자가 사진 게시물을 저장하려 하면 오류가 발생할 겁니다. 로그인하지 않은 이용자는 이용자 모델로 생성한 인스턴스 객체에 기본키 값이 없을테니 IntegrityError 예외 오류가 발생할 것 같습니다. 이렇게 예상하셨다면 훌륭합니다. 한 번 시도해보세요.

실제로 발생하는 예외는 ValueError가 발생하며, 안내말은 User 모델 클래스로 만든 인스턴스여야 한다는 내용입니다. 앞서 설명드린 바와 같이 로그인하지 않은 경우 request.userAnonymousUser 모델 클래스로 생성한 인스턴스 객체가 할당되어 있습니다. django.contrib.auth.models에 있는데, 코드를 보면 아시겠지만 껍데기 역할을 할 뿐입니다.

로그인한 이용자인지 여부는 request.useris_authenticated() 메서드를 실행하면 bool 객체를 반환받아 구분합니다. True이면 로그인한 이용자, False이면 로그인하지 않은 이용자입니다. 코드도 아주 간단합니다.

from django.conf import settings

def create(request):
    if not request.user.is_authenticated():
        return redirect(settings.LOGIN_URL)
    # 후략

로그인하지 않은 이용자가 create 뷰 함수로 접근하면 settings.LOGIN_URL에 지정되어 있는 URL로 이동(redirect) 시킵니다. LOGIN_URLglobal_settings/accounts/login/으로 기본 지정되어 있습니다.

@login_required 장식자(decorator)를 사용하면 앞서 구현한 부분을 더 명확하고 간결하게 표현할 수 있습니다.

from django.contrib.auth.decorators import login_required

@login_required
def create(request):
    # 후략

더 친절하게도 로그인한 후 이동할 도착지도 next 인자로 지정됩니다. 로그인 주소를 settings.LOGIN_URL에 따로 지정해주면 자동으로 변경한 주소로 이동해 줍니다.

3. 모델 관계에 더 자세히 알아보기

우리는 이용자 모델을 Photo 모델의 user 모델 필드에 ForeignKey 관계로 연결했습니다. 그리고 user 모델 필드에 이용자 모델로 생성한 인스턴스 객체(request.user)를 할당하지 않자 photo_photo.user_id에 NULL을 저장하려 해서 저장하지 못했다는 예외 오류도 접했습니다. 이 중에서 user_id 정체를 살펴 보겠습니다.

(1) ForeignKey 모델 필드

ForeignKey, 그러니까 Many to one 관계는 “One”쪽에 “Many”쪽 데이터 여러 개가 연결되는 구조입니다.

한 이용자가 여러 게시물을 남기거나, 한 글갈래(category)에 여러 글이 속하는 관계입니다. Django 모델로는 ForeignKey 모델 필드로 모델 클래스를 지정한 것인데, 이 관계 정보를 데이터베이스엔 어떻게 저장할까요?

일단 각 모델은 데이터베이스에 Django 앱 이름과 모델 이름을 조합하여 테이블로 만듭니다. photos_photophotos 앱에 있는 Photo 모델을 뜻합니다. hello라는 앱의 Hannal 모델은 hello_hannal 테이블을, KayCha 모델은 hello_kay_cha 테이블을 만들어 연결합니다. 앞서 발생한 IntegrityError 예외 오류에서 photos_photo 정체가 무엇인지 이제 아시겠죠?

Django의 모델 필드는 데이터베이스의 컬럼(column)이 됩니다. image 모델 필드는 같은 이름을 갖는 테이블 컬럼이 됩니다. 모델 필드형(type)은 테이블 컬럼형을 결정합니다. 그런데 ForeignKey와 같은 모델 관계 필드는 컬럼 이름이 조금 다릅니다.

Photo 모델의 user 모델 필드는 컬럼 이름이 user_id입니다. user_id 정체는 user 모델 필드가 맞습니다. 그런데 user가 아니라 user_id인 이유는 무엇일까요? 질문 아니니 대답 안 하셔도 됩니다. :)

이번 9회 초반에 모델 간 연결은 기본키로 한다고 설명했습니다. Django는 Many쪽이 One쪽을 연결하는 경우, Many쪽 모델 필드 이름에 One쪽의 기본키 이름을 덧붙입니다.

기본키는 관례에 따라 id라는 모델 필드가 되며, 모델 필드는 데이터베이스 테이블 컬럼과 이름이 같으므로 테이블 컬럼도 id입니다. Photo 모델의 user 모델 필드는 이용자 모델을 Many to one으로 가리키는데, 이용자 모델의 기본키인 모델 필드도 id입니다. Photo 모델 데이터가 Many쪽이고 이용자 모델이 One쪽이므로, Photo 모델의 user 모델 필드는 이용자 모델 필드의 기본키 모델 필드인 id 이름을 덧붙여서 user_id가 됩니다.

만약 기본키 모델 필드 이름이 id가 아니라 uid라면 _uid가 덧붙게 됩니다. Django는 이 데이터베이스 컬럼 값을 참조하여 서로 분리된 모델의 데이터 연결 관계를 알아냅니다.

(2) 모델 관계를 나중에 맺기 (lazy relation)

모델 관계를 맺을 대상 모델 클래스 객체를 직접 전달해도 됩니다.

from django.contrib.auth import get_user_model
User = get_user_model()

class Photo(models.Model):
    user = models.ForeignKey(User)
    # 후략

models.ForeignKey(User)ForeignKey 클래스에 User라는 객체를 첫 번째 인자로 전달하여 호출(call)하고, 모델 필드의 인스턴스 객체를 반환받아 user에 할당하는 것입니다. Python은 소스 파일 맨 윗 줄부터 아래로 실행하므로 저 구문을 실행하여 모델 필드를 만드는 시점에 실제로 존재하는 User 객체를 사용합니다.

그렇다면 관계 맺을 모델이 있는 경로를 문자열로 담아 인자로 전달하는 경우는 언제일까요? 이용자 모델처럼 상황에 따라 연결할 모델이 바뀌는 경우가 있습니다. 지난 편에서 예를 든 것처럼 이용자 모델이 바꾸면 이 모델을 가져오는(import) 모든 코드에도 영향이 미칩니다. 그러나 settings.AUTH_USER_MODEL에 이용자 모델이 있는 위치를 지정하고, AUTH_USER_MODEL 내용을 참조하여 get_user_model() 함수로 이용자 모델을 가져오면 한 의도를 한 구현체로 정리할 수 있지요.

관계 맺을 대상 모델이 아직 만들어지기 전에 연결하려는 경우에도 문자열로 지정합니다. A 모델이(from) B 모델을(to) 관계를 맺는다면 다음과 같이 B 모델을 먼저 만들고 그 이후에 A 모델을 만들어야 합니다.

class B(models.Model):
    pass

class A(models.Model):
    b = models.ForeignKey(B)

B 모델은 C 모델을 관계 맺는다면 C 모델을 B 모델에 앞서 만들어야 합니다.

class C(models.Model):
    pass

class B(models.Model):
    c = models.ForeignKey(C)

class A(models.Model):
    b = models.ForeignKey(B)

그런데 C 모델은 A 모델에 관계를 지어야 한다고 가정하겠습니다.

class C(models.Model):
    a = models.ForeignKey(A)

class B(models.Model):
    c = models.ForeignKey(C)

class A(models.Model):
    b = models.ForeignKey(B)

문제가 생깁니다. C 모델이 만들어지는 시점에 A라는 객체가 존재하지 않기 때문이죠. A 모델을 C 모델 코드 위로 올리면 안 됩니다. A 모델이 만들어지는 시점에 B 모델이 없기 때문이지요. 이런 경우에, C 모델에서 A 모델을 문자열 인자로 전달하면 됩니다.

class C(models.Model):
    a = models.ForeignKey('A')

class B(models.Model):
    c = models.ForeignKey(C)

class A(models.Model):
    b = models.ForeignKey(B)

문자열로 관계 맺을 대상 모델을 지정하면 관계 맺을 대상 모델이 만들어졌다는 신호가 오기 전까지 관계를 맺지 않은 채 관계 맺는 연산을 지연시켜 놓습니다. 비유가 아니라 정말로 대상 모델 클래스(예 : A 모델)가 초기화 되면 모델 신호(ModelSignal)인 class_prepared를 일으키고(fire), 대상 모델을 바라보던 모델은(예 : C 모델) 이 신호을 받고선 비로소 실제 관계를 맺습니다.

이런 연산 특성을 응용하여 관계 맺을 대상 모델로 자기 자신을 지정하는 것도 가능합니다. 순환 관계(recursive relationship)이라고 하는데, 문자열 'self'을 지정하면 됩니다. 추후에 기회가 닿으면 순환 관계 모델을 만들어 보겠습니다.

정리하면, 모델 관계 필드는 관계 맺을 대상을 세 가지 형태로 지정합니다.

  • 관계 지을 모델 클래스 객체를 직접 인자로 전달
  • 관계 지을 모델 클래스 객체가 있는 경로를 문자열로 전달
    • 형식 : Django앱이름.모델이름
  • 자기 자신을 가리키는 경우 'self' 문자열 전달

강좌 9편을 마칩니다.


8. 로그인, 로그아웃 하기

이번 편에서는 Django 이용자 인증 체계을 알아보고, 이 인증 체계에서 로그인을 어떻게 처리하는지 살펴 보겠습니다.

1. Django 이용자 인증 체계

Django 이용자 인증 체계는 크게 두 가지 요소로 구분합니다.

  • 인증 (Authentication)
  • 권한 (Authorization)

인증은 “나 누구인데 확인 좀…”이라면 권한은 “나 이거 해도 돼요?”라 보면 됩니다. 누구인지 신원이 확인되지 않은 존재에게 권한을 세밀하게 부여하진 못합니다. 신원이 확인된, 즉, 인증된 이용자인지 아닌지로 구분하는 정도로 권한을 부여합니다. 그래서, 권한 체계를 비롯하여 이용자 인증 체계 자체는 인증(Authetication)을 바탕으로 합니다.

(1) Django 내장 인증 기능

Django는 이용자 인증 체계를 내장하고 있으며, 우리는 이미 이 기능을 사용해봤습니다. 4. Photo 모델로 Admin 영역에서 데이터 다루기 편에서 최고 권한 이용자로 Admin 영역에 로그인하여 사진 게시물을 입력 했었거든요.

Django에 내장된 인증 체계는 django.contrib.auth라는 경로(name space)인 Python 패키지에 모여 있으며, Django 개념으로는 Django App입니다. settings.py 파일에 있는 INSTALLED_APPS 설정 항목을 보면 'django.contrib.auth',가 있는데, 우리가 만드는 Pystagram에 사용할 Django App에 Django 인증 체계가 앱 형태로 기본 내장되어 있는 것입니다.

Django webframework으로 제품을 만든다면 Django 인증 체계를 사용하는 게 좋습니다. 오랜 기간 개발되어 보안 수준은 성숙하고 안전하며, 확장 가능하게 유연합니다. Django에서 제공하는 다른 여러 기능이 내장된 인증 기능 구조를 따르기 때문에 Django가 제공하는 기능을 유기성 있고 풍부하게 쓰기에도 Django 인증 체계를 쓰는 게 좋습니다.

(2) Django 내장 권한 기능

권한 검사 기능도 Django에 내장되어 있습니다. 뷰(View) 단위 행동(behaviour), 데이터 단위 행동에 권한을 부여하여 운용 가능하며, 권한을 그룹 단위로 묶어서(grouping) 이용자에게 지정하는 기능도 제공합니다. 자세한 내용은 권한 기능을 적용할 때 다루겠습니다.

2. 로그인 기능 구현

(1) URL 패턴 추가

Django에서 제공하는 인증 기능을 이용하여 로그인, 로그아웃 기능을 구현 하겠습니다. settings.py 파일이 있는 시작패키지에서 urls.py 파일을 열고, 다음 내용을 추가합니다.

from django.contrib.auth import views as auth_views  # 이 줄 추가.

urlpatterns = [
    # 중략
    url(
        r'^accounts/login/',
        auth_views.login,
        name='login',
        kwargs={
            'template_name': 'login.html'
        }
    ),
    url(
        r'^accounts/logout/',
        auth_views.logout,
        name='logout',
        kwargs={
            'next_page': settings.LOGIN_URL,
        }
    ),
]

r'^accounts/login/'은 로그인 하는 URL이고, 로그인 화면을 출력하거나 로그인 인증 처리를 하는 뷰 함수는 Django에 내장된 login 뷰 함수를 사용합니다. 이 함수 객체는 django.contrib.auth.views 모듈에 존재합니다. 이 URL 패턴의 이름을 name 키워드 인자를 이용하여 login이라고 지었는데, 이 인자를 사용하지 않아도 무방합니다. kwargs는 URL 패턴에 연결한 뷰 함수에 추가로 전달할 인자를 사전형(dict) 객체로 전달합니다. 키가 'template_name'이고 값이 'login.html'인 사전형 객체인데, Django에서 제공하는 login 뷰 함수에 template_name 이름으로 키워드 인자를 지정하면 로그인 화면을 출력하는 데 사용할 템플릿으로 사용합니다. 'login.html'이라는 문자열을 지정했으니 우리가 settings.py에서 TEMPLATE_DIRS에 지정한 템플릿 디렉터리 중 최상위 순위에 있는 login.html 파일을 찾아서 로그인 화면을 출력하는 데 사용합니다.

r'^accounts/logout/'은 로그아웃 하는 URL이며, 로그아웃 기능 역시 로그인 기능과 마찬가지로 Django에 내장된 뷰 함수를 사용합니다. 키워드 인자 next_page는 로그아웃 하고 난 뒤에 이동할 URL을 의미합니다. 이 항목이 없으면 로그아웃 화면이 출력됩니다. template_name: ‘logout.html’ 등으로 지정하지 않으면 Django에 내장된 로그아웃 화면이 나타납니다.

(2) 로그인 템플릿 파일

이번엔 로그인 화면에 사용할 login.html 템플릿 파일을 만듭니다. templates 디렉터리에 login.html 파일을 만들고 다음 내용을 담습니다.

{% extends "layout.html" %}

{% block content %}

{% if form.errors %}
<p>ID나 비밀번호가 일치하지 않습니다.</p>
{% endif %}

<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<input type="hidden" name="next" value="" />



<button type="submit">로그인</button>
</form>

{% endblock %}

Django에서 견본으로 제공하는 login.html 템플릿 파일에서 따와서 약간 고쳤습니다. 따로 뷰 함수에서 템플릿으로 로그인 폼을 템플릿 컨텍스트로 전달하지 않았지만, Django에서 관례로 많이 쓰는 이름인 form을 사용했습니다. form.errors엔 입력한 폼 양식에 문제가 있는 경우에 문제 내용이 담겨 있습니다. ID(username`)나 비밀번호를 입력하지 않거나 형식에 맞지 않는 등 여러 오류 종류가 있겠지만, 간결하게 ID와 비밀번호가 일치하지 않는다고만 안내합니다. 로그인에 대해서는 굳이 친절하게 뭐가 문제인지 자세히 알려줄 필요는 없습니다.

{% if next %}에서 next는 로그인을 한 후 이동할 URL을 뜻합니다. 예를 들어, 로그인을 하지 않은 채 사진에 달린 댓글을 삭제하려 하면 로그인하는 URL로 이동하고 로그인을 하고 나면 로그인하기 전에 접근하려는 URL으로 이동하는데, 이동할 URL이 GET이나 POST 방식으로 전달된 Query String 키인 next에 담깁니다. 대개는 URL이 https://pystagram.com/accounts/login/?next=/redirect_to_here/와 같이 표현됩니다.

그외엔 7. 사진 게시물 제출하여 게시하기 편 내용과 비슷합니다. formdjango.contrib.auth.forms 모듈에 있는 AuthenticationForm 폼 클래스로 생성한 인스턴스 객체입니다.

이제 http://localhost:8000/accounts/login/에 접속하면 로그인 화면이 나옵니다. 잘못된 usernamepassword을 제출하면 이에 대한 안내도 나오고요.

현재 구현한 로그인 기능으로 로그인을 하면 “Page not found” 오류를 만나게 됩니다. 이에 대해서는 곧 처리하겠습니다.

3. 로그인 과정

Django가 제공하는 로그인 뷰 함수가 어떤 과정을 거쳐 이용자 인증을 처리하는지 좀 더 살펴 보겠습니다. 이 부분을 몰라도 로그인 기능을 이용하는 데 문제 없습니다.

(1) Form 검증

웹 페이지에서 폼 양식으로 넘어오는 값은 Form을 이용해 값을 검증합니다. Django는 로그인 절차에 AuthenticationForm 폼을 사용하며, 이 폼은 django.contrib.auth.forms 모듈에 있습니다. 이쯤되면 눈치 채셨을텐데, Django는 인증 관련 모델, 폼, 뷰, 미들웨어 등을 django.contrib.auth 패키지 안에 담아 놨습니다. 인증과 관련된 소스 코드를 보려면 이 패키지를 살펴 보시면 됩니다.

AuthenticationForm 폼은 현재 이용자 정보와 HTTP 요청 정보를 담은 request 객체도 함께 인자로 전달 받는데, 세션 처리에 필요하기 때문에 그렇습니다. 폼 양식 값이 유효하면(is_valid()) 이용자가 로그인 후에 이동할 URL 문자열이 안전한 지 검사합니다. 그런 뒤 auth_login() 함수를 이용해 로그인 인증 처리를 마무리하고 나서 이용자를 다음 URL로 이동(redirect) 시킵니다.

auth_login() 함수는 이름과는 달리 실제로는 인증 과정 마무리 단계를 담당합니다. 로그인 양식을 토대로 이용자 정보를 가져와서 HTTP Request(request) 정보와 함께 사용해 서버 세션 정보를 만듭니다. 세션 정보를 만들지 않으면 로그인 정보는 유지되지 않아서 다른 페이지에 방문할 때마다 매번 로그인을 해야 합니다.

로그인 양식, 그러니까 로그인 하려고 제출한 usernamepassword에 정확히 일치하는 이용자를 찾는 과정은 AuthenticationForm 폼에서 이뤄집니다. 이 폼의 clean() 메서드에서 usernamepassword 내용을 토대로 authenticate() 함수를 이용해 인증을 시도합니다. 일치하는 이용자가 없으면 Form 오류를 일으키고, 우리는 “ID나 비밀번호가 일치하지 않습니다.”라는 안내를 화면에서 만납니다. 일치하는 이용자가 있으면 이 이용자 계정이 활성화 된 상태인지(is_active) 검사하는 걸로 폼 안에서 처리하는 인증 과정을 마칩니다.

(2) 인증 체계 기반으로 처리

authenticate() 함수는 settingsAUTHENTICATION_BACKENDS 항목에 등록된 인증 체계 기반 클래스를 하나씩 가져와서 authenticate() 메서드를 호출하여 인증을 시도합니다. 우리가 settings.py 파일에 따로 이 항목을 설정하지 않아도 문제가 없는 건, Django에 기본으로 내장된 global_settings.py에 이 항목이 설정되어 있기 때문입니다. 이 항목에 있는 내용은 'django.contrib.auth.backends.ModelBackend' 이름영역인 클래스가 튜플 객체로 담겨 있습니다.

데이터베이스에서 usernamepassword로 이용자를 찾는 과정이 비로소 이 단계에서 이뤄집니다. 이용자 모델을 가져오고, 이 모델을 이용해 username으로 먼저 이용자 데이터를 가져오고, 이 이용자 데이터에 저장된 비밀번호와 이용자가 로그인하며 제출한 password를 비교합니다. 비밀번호까지 일치하면 해당 이용자 데이터, 그러니까 이용자 모델로 생성한 인스턴스 객체를 반환하고, 그렇지 않으면 None을 반환합니다.

settingsAUTHENTICATION_BACKENDS 항목에 django.contrib.auth.backends.ModelBackend이 튜플에 담겨져 있다는 말은 다음 두 가지를 의미합니다.

  1. Django 인증 체계 기반(backend)을 꼭 사용하지 않아도 된다.
  2. 인증 체계 기반을 여러 개 이상을 사용하는 게 가능하다.

인증 체계 기반의 클래스 규칙대로 인터페이스를 만들기만 한다면 우리가 직접 만든 인증 체계를 사용하거나 Facebook, Twitter처럼 인증 API를 제공하는 서비스나 플랫폼을 기반으로 인증 체계를 운용해도 됩니다.

(3) 이용자 모델 가져오기

인증 체계 기반을 Django에서 제공하는 기본 인증 ModelBackend를 다른 것으로 갈아끼우거나 추가한다면, 이용자 정보를 데이터베이스에서 다루는 이용자 모델도 대체하여 쓸 수 있습니다. Django에 내장된 이용자 모델은 django.contrib.auth.modelsUser 모델 클래스입니다. 이 이용자 모델엔 모델 필드이 간결하게 담겨 있습니다.

  • username : 이용자 ID 역할. 다른 값과 중복되지 않는 고유한 값만 허용합니다(unique=True).
  • password : 비밀번호. PasswordField 모델 필드.
  • first_name : 성씨. CharField 모델 필드이며 생략 가능.
  • last_name : 이름. CharField 모델 필드이며 생략 가능.
  • email : 전자우편 주소. EmailField 모델 필드.
  • is_staff : 관리자 여부. BooleanField 모델 필드.
  • is_active : 활성화 된 계정인지 여부. BooleanField 모델 필드.

이외에도 is_superusergroups 같은 모델 필드 몇 가지가 더 있지만, 이 모델 필드의 값을 직접 다룰 일은 드물고, 이 강좌 내용을 이해하시면 이런 모델 필드를 직접 찾아 다루는 건 어렵지 않으므로 이 강좌에선 다루지 않겠습니다.

만약, 필명이나 사용하는 언어, 거주 지역처럼 정보를 추가로 이용자로부터 입력 받아 관리하려면 이용자 모델 클래스를 확장해야 합니다. Django에서 제공하는 이용자 모델을 변경해도 되지만, Django 소스 파일을 직접 고쳐서 쓰지 않는 게 좋습니다. Django 판을 올릴 때마다 직접 수정한 부분을 매번 챙겨야 하고, 연계되어 동작하는 다른 기능에 부작용을 일으킬 여지도 있습니다. 그래서 변경하지 않고 확장해야 합니다.

확장하는 자세한 방법은 다른 편에서 따로 다루기로 하고1, 여기에선 확장 방법 종류만 간단히 언급하겠습니다.

  1. 따로 이용자 모델을 만들고, Django의 이용자 모델에 연결(Model relationship).
  2. 이용자 모델과 모델 매니저, 이용자 폼 등을 모두 구현하여 이용자 모델 부분을 대체.

2번 방법에서 “모델 부분을 대체”한다는 표현을 눈 여겨 보세요. 일일이 Django 소스에서 이용자 모델 관련 부분, 가령, django.contrib.auth.models.Userpystagram_auth.models.User와 같이 교체하는 건 아닙니다. settingsAUTH_USER_MODEL 항목에 지정하기만 하면 됩니다.

이 항목 역시 우리가 따로 설정한 적이 없는데, Django의 global_settings에 설정되어 있으며, 기본 값으로 'auth.User' 문자열이 할당되어 있습니다. auth는 Django 앱 이름(Python 패키지)이고, User는 모델 클래스 이름입니다. Python 이름영역(name space)으로 풀어 쓰면 auth.models.User인 셈입니다.

settingsAUTH_USER_MODEL 설정 항목을 참조하여 이용자 모델을 실제로 가져오는 역할은 get_user_model() 함수가 맡으며, django.contrib.auth에 있습니다. 이 함수를 이용하면 이용자 모델이 어떤 것으로 바뀌든 코드를 일관되게 유지하게 됩니다. AUTH_USER_MODEL = 'auth.User'라고 설정하고 get_user_model() 함수를 실행하면 auth.models.User를 반환하고, AUTH_USER_MODEL = 'pystagram_auth.MyUser'로 설정하고 실행하면 pystagram_auth.models.MyUser를 이용자 모델로 반환합니다. 그러므로 get_user_model() 함수로 이용자 모델을 가져오는 게 좋습니다.

인증 체계 기반(backend)과는 달리 기본 이용자 모델은 하나만 가능합니다.

(4) 정리하면

로그인 과정을 정리하면 다음과 같습니다.

  • django.contrib.auth.views.login
    • django.contrib.auth.forms.AuthenticationForm
      • django.contrib.auth.authenticate()
        • settings.AUTHENTICATION_BACKENDS에서 인증 기반 하나씩 가져옴
        • django.contrib.auth.backends.ModelBackendauthenticate() 메서드로 인증 처리
    • django.contrib.auth.auth_login (정확히는 django.contrib.auth.loginauth_login으로 import 한 것.)으로 인증 관련 세션 처리
  • 로그인 이후 이동할 URL로 이동 처리(redirect)

4. 로그인 관련 설정 항목

settings.py에 설정하는 로그인 관련 항목이 몇 가지 있습니다.

(1) LOGIN_URL

LOGIN_URL은 로그인 URL을 뜻합니다. Django에서 제공하는 장식자(decorator) 중 login_required는 뷰 함수에 접근할 때 로그인 여부를 검사하고, 로그인하지 않으면 로그인 URL로 이용자를 이동시키는데, 이 로그인 URL을 settings.LOGIN_URL에서 가져 옵니다. global_settings에 설정된 기본값은 /accounts/login/입니다. 로그인 URL을 다른 것으로 쓴다면 이 항목에 URL을 지정하면 제3자(3rd party) 도구 등에서 참조합니다.

(2) LOGOUT_URL

LOGIN_URL과 비슷한 역할을 합니다. 기본값은 /accounts/logout/입니다. 그런데 사용할 일은 거의 없어서 사실상 죽은 설정 항목이나 마찬가지입니다. 이런 게 있다는 정도로 알아 두시면 됩니다.

(3) LOGIN_REDIRECT_URL

로그인을 하고 나서 이동할 URL을 설정합니다. 로그인 하고나서 이동할 URL이 지정된 경우 그 URL로 이동하지만, 이동할 URL이 지정되지 않았거나 지정한 URL이 보안상 문제가 있는 경우 settings.LOGIN_REDIRECT_URL를 사용합니다.

현재 구현한 기능으로는 로그인을 마치면 “Page not found” 오류를 만납니다.

웹 브라우저 주소입력란을 잘 보면 http://localhost:8000/accounts/profile/과 같이 전혀 본 적 없는 URL로 되어 있습니다. 이는 Django 기본 LOGIN_REDIRECT_URL 설정값이 /accounts/profile/이라서 그렇습니다. 아직 우리는 프로필 페이지를 만들지 않았으니 임시로 /photos/upload/로 이동하도록 설정하겠습니다. 시작패키지에서 settings.py 파일을 열고 다음 코드를 추가합니다.

LOGIN_REDIRECT_URL = '/photos/upload/'

이제 로그인을 마치면 /photos/upload/로 이동하여 사진을 올리라는 압박을 줍니다.


강좌 8편을 마칩니다.


  1. 다른 편에서 다루겠다는 내용이 늘어가니 불안해지네요. 까먹고 다루지 않을까봐요.