SQLAlchemy Admin으로 Admin 구현하기
이번 편에서는 SQLAlchemy Admin 패키지를 사용해 Admin 영역을 구현할 거예요. Django의 매우 큰 매력 요소 중 하나가 Django Admin으로 뚝딱 Admin을 찍어내는 것이고, 다른 웹 프레임워크에서 Django Admin만큼 생산성 좋고 안정되게 동작하는 Admin 구현체를 찾기 어려운데요. SQLAlchemy Admin은 Django Admin처럼 받아 SQLAlchemy ORM 기반으로 Admin을 구현하는 도구예요. Flask Admin을 사용하셨던 분이라면 상당히 친숙하게 느끼실 거예요.
SQLAlchemy Admin 개요
(1) 소개
SQLAlchemy Admin(이하 SQLAdmin)은 SQLAlchemy 모델을 기반으로 Admin 인터페이스를 쉽고 빠르게 구현하는 도구예요. FastAPI 연동을 지원하고, SQLModel도 지원해요. FastAPI에서 Admin을 만들기에 딱 좋겠지요?
SQLAlchemy ORM 모델로 인터페이스로 선언할 수 있는 주요 요소는 다음과 같아요.
-
모델의 분류 : Django의 App에 해당하는데, 모델을 그룹 짓는 데 사용해요.
-
목록에 표시할 열(column) 지정
-
편집 대상 열 지정
-
모델 데이터의 상세 보기에서 표시할 열 지정, 표시하지 않을 열 지정
-
정렬할 열 지정
-
검색할 열 지정
-
관계 모델의 데이터 탐색 시 동작 설정
-
열(column) 단위 표시 형식자(formatter) 설정
-
열(column) 데이터의 자료형 단위로 표시 형식자(formatter) 설정
-
맞춤 템플릿 사용
(2) 설치
현재(2024년 6월 11일) 기준으로 sqladmin을 설치하나 sqladmin[full]을 설치하나 동일해요.
사용 설정
SQLAdmin Application 객체 생성에 중요한 인자는 두 개예요. 하나는 웹 애플리케이션 서버의 인스턴스 객체, 즉 FastAPI나 Starlette 인스턴스 객체고요. 두 번째는 SQLAlchemy Database engine이나 Session 제작기(session maker)예요. 두 번째 인자는 엔진이나 세션 제작기 중 하나를 전달하면 되는데, 엔진을 전달하면 SQLAlchemy의 sessionmaker로 세션 제작기를 만드는 데 사용해요. 세션 제작기를 전달하면 엔진 전달 여부와 무관하게 사용자가 지정한 세션 제작기를 사용하죠.
우리는 use_session()으로 세션 제작기와 세션 객체를 한번에 만들어 사용하기 때문에 세션 제작기를 전달하지 않고 엔진을 인자로 전달할게요.
기반 URL은 따로 지정하지 않으면 /admin이 되는데, 너무 드러나니 /-_-/admin으로 기반 URL을 변경했어요.
SQLAdmin의 Admin 클래스로 인스턴스 객체를 생성할 때 FastAPI 인스턴스 객체에 SQLAdmin URL을 등록해요. 그래서 별도로 Router 설정을 하지 않아도 되지요.
흔하진 않을만한 주의점이 하나 있어요. SQLAdmin은 자체적으로 정적 파일을 HTTP 제공(serving)해요. 정적 파일 URL에 파일 경로를 마운트(Mount)하는 거죠. 여기까지는 특이사항이 없는데, 만약 SQLAdmin을 FastAPI의 하위 애플리케이션(Sub application)으로 마운트하려 하면 마운트가 충돌나서 정상 동작하지 않아요.
이와 같은 구조는 동작하지 않아요. 저는 Admin 애플리케이션은 사용자가 접근하는 주(main) 애플리케이션과 격리하려 했는데, 정상 동작하지 않아 아쉽더라고요. 🥲
SQLAdmin 애플리케이션을 만드는 건 간단하지만, Admin 인터페이스를 확장하려면 몇 가지 설정을 더 해야 해요. 저는 이런 경우 별도 초기화 함수로 묶는 편이라 init_admin() 함수를 만들어요. 이 함수를 FastAPI 애플리케이션을 생성할 때에 호출해줄게요.
여기까지 설정하면 SQLAdmin을 사용할 수 있어요. 물론 현재는 Admin 뷰를 만들지 않아서 텅빈 화면만 나오지만요.
Alembic으로 마이그레이션 관리
우리는 그동안 테스팅 환경에서만 데이터베이스로 데이터를 관리했어요. 테스트를 수행하면 각 테스트마다 SQLite에 메모리 데이터베이스를 만들고, 그곳에 각 모델에 대응하는 데이터베이스 테이블을 새로 만들었죠. 그래서 별도로 데이터베이스 마이그레이션(Migration)을 다루지 않았는데요. Admin 인터페이스를 다루려면 실제 데이터베이스와 테이블이 영속해야 해요. 데이터베이스 마이그레이션을 관리할 때가 온 거죠.
(3) Alembic 초기화
먼저 Alembic을 설치하고요.
셸에서 Alembic을 초기화해줍니다.
init 명령어 뒤에 붙은 인자값 alembic은 Alembic 스크립트가 위치할 경로를 의미해요. 이 명령어를 실행해 초기화하면 실행한 위치(pyproject.toml 파일이 있는 곳)에 alembic.ini 파일과 alembic 디렉터리가 만들어져요.
(4) Alembic 설정
alembic.ini 파일을 열어보세요. 설정 항목이 많은데, 그 중에서 sqlalchemy.url을 찾습니다.
아마 이렇게 유효하지 않은 값이 설정되어 있을 거예요. 데이터베이스 소스 이름(DSN, Database Source Name)을 URI 형식으로 지정하면 되는데, 우린 local.db 파일로 SQLite 데이터베이스를 저장할게요.
사용하는 데이터베이스는 SQLite이고, 데이터베이스에 연결되는 드라이버는 aiosqlite를 사용한다는 의미로 Scheme을 sqlite+aiosqlite로 설정하고요. 상대경로로 현 경로에 pudding_todo.db 파일명으로 데이터베이스를 다룰 것이므로 /pudding_todo.db, 즉 :///pudding_todo.db로 파일 경로를 지정합니다.
User 모델에 대한 Admin 1차 구현
(1) UserAdmin 클래스 작성
User 모델에 대한 Admin 인터페이스를 만들어볼게요. pudding_todo/apps/account 경로에 admin.py 파일을 만들어 다음 코드를 작성하세요.
간단하죠? 이제 UserAdmin 클래스를 SQLAdmin에 등록하기만 하면 돼요.
웹 브라우저에서 Admin에 들어간 후 새로고침하면 Users가 새로이 나타나요.
New User 버튼을 눌러 사용자를 하나 만들어보세요.
편집 폼(form)에서 어떤 모델 필드를 다룰지 지정하지 않아서 모든 모델 필드가 다 나왔네요. Todo Groups와 Hashed Password는 일단 무시하고, Username과 Is Active만 설정한 뒤 저장을 누릅니다. 문제없이 저장되어 사용자 계정이 하나 생성돼요.
(2) 목록 인터페이스 손보기
Admin 인터페이스에 아무 설정도 하지 않아서 목록 화면을 보면 휑해요. 목록 화면에서 어떤 모델 필드를 열에(Column) 표시할지 지정해볼까요? 하는 김에 몇 가지 설정도 함께 할게요.
column_list는 목록 화면에서 표시할 모델 필드를 지정해요. 모델 필드명을 문자열로 지정하는 게 아니라, 모델의 필드를 직접 전달해서 직관적이죠.
column_searchable_list는 검색할 때 검색 대상이 되는 모델 필드를 지정해요. 기본적으로 검색어를 포함하는지(contains) 여부로 검색하죠.
column_sortable_list는 정렬할 수 있는 모델 필드를 지정해요. column_default_sort는 정렬을 따로 지정하지 않으면 기본으로 정렬할 모델 필드를 가리키는데, 두 번째 값(True)은 역순으로 할 것인지 여부를 지정해요. True이면 역순으로 정렬하죠. (User.id, True)는 id 모델 필드에 대해 역순으로 정렬하겠다는 뜻이예요.
마지막으로 page_size는 한 페이지에 몇 개 항목이 나오는지 지정해요.
(3) 비밀번호 해시 처리 1
우리는 계정 비밀번호를 해시 처리된 값을 데이터베이스에 저장해요. 다음은 qqqq를 해시 처리한 문자열이죠.
하지만, SQLAdmin의 폼에서는 이런 처리를 하지 않죠. 그래서 Admin에서 사용자 계정 비밀번호를 설정할 수 없어요. 이 문제를 해결하는 방법은 몇 가지가 있는데, 이번 편에서는 직관적이고 직설적인 방법을 소개할게요. 바로 모델 데이터를 추가하거나 변경할 때마다 비밀번호를 해시 처리해서 저장하는 방식이죠.
SQLAdmin은 데이터를 추가할(insert) 때 insert_model() 메서드를 호출해요. 그러므로 이 메서드를 오버라이드하면 계정 생성할 때 입력한 비밀번호를 해시 처리해서 저장할 수 있죠.
insert_model() 메서드는 request 객체와 data 객체를 인자로 받는데요. request는 Starlette의 Request 객체예요. data는 사전형 객체인데, 폼에 입력된 값들이죠. 여기에선 단순하게 사용자가 입력한 비밀번호가 있으면 해시해서 data의 hashed_password에 덮어씌웠어요.
데이터 변경은 update_model() 메서드를 호출해요. insert_model() 메서드와 다른 점은 변경 대상의 기본키값도 인자로 전달해주는 거죠.
insert_model()과 맥락 상 거의 동일한 코드지요? 차이점은 변경할 때 비밀번호를 입력하면 변경 대상의 데이터를 가져와서 해시 처리되어 저장된 값과 비교하고, 다르면 폼 데이터(payload인 data)에 새로 해시 처리한 문자열을 담는 거예요. 동일하다는 얘기는 비밀번호를 수정하지 않아서 해시 처리된 문자열 그대로 넘어온 거니까 해시 처리하면 안 되거든요.
(4) 비밀번호 해시 처리 2
계정 비밀번호 해시 처리는 더 간결하고 안전한 방법이 있어요. 바로 on_model_change() 메서드를 오버라이드하는 거죠. 이 메서드는 모델 데이터가 변경될 때 호출되는데, 추가(insert)와 변경(update)될 때 호출돼요. 다음과 같은 순서로 진행되죠.
-
on_model_change() 메서드 호출
-
실제 추가/변경 처리
-
after_model_change() 메서드 호출
코드부터 볼게요.
insert_model()과 update_model() 메서드의 코드가 합쳐진 것처럼 생겼네요. 😁 특이한 점은 부모 클래스의 on_model_change() 메서드를 호출하지 않는 건데요. 메서드 이름에서 알 수 있듯이 모델 데이터가 변경될 때 콜백처럼 호출되는 거예요. 부모 클래스의 on_model_change() 메서드는 아무 일도 하지 않죠. 대신 인자로 전달받은 data가 변경 가능한(mutable) 객체여서 이 객체의 내용을 변경하기만 하면 돼요. 그럼 이 메서드를 호출하는 쪽의 data 객체에도 변경되죠. 동일한 객체니까요.
여기까지 진행한 코드 커밋 : 9446425
TodoGroup 모델에 대한 Admin 구현
(1) TodoGroupAdmin 클래스 작성
UserAdmin 클래스와 그리 다르지 않으니 코드를 바로 볼게요.
admin_app.py에서 SQLAdmin에 Admin 페이지를 추가하는 것 잊지 마시고요.
새로 추가된 요소가 있어요. category는 Admin 페이지를 묶어주는 갈래예요. 할 일 관련 모델은 TodoGroup과 Todo가 있잖아요. 그리고 이 둘은 할 일이라는 맥락으로 보아 동일한 갈래로 분류할 수 있죠.
name은 TodoGroup 항목을 가리키는 개체(entity) 이름을 지정해요. name_plural은 복수형이고요.
이 두 속성을 설정하면 Admin 사이드바에 다음과 같이 표시돼요.
form_columns는 폼(form)에서 사용자가 편집할 수 있는 모델 필드를 지정하는 거예요. TodoGroup 모델에서 user_id는 User 모델의 기본키 값을 다루는데 이 정수값을 직접 입력하면 실수할 여지가 있잖아요. 그래서 폼에서 제외했어요. 정확히는 폼에서 편집할 필드인, name과 user만 지정한 거죠.
(2) 모델 관계 정보 값 UI 변경
그런데 Admin 페이지에서 보니 TodoGroup이 가리킬 User를 지정하는 인터페이스가 불편해보여요. 펼침메뉴에 모든 이용자가 나열되고, 그 중에 하나를 고르는 방식이죠. 불편할 뿐만 아니라 성능 문제도 일으켜요. 만약 이용자가 10만명이 있다면, TodoGroup 편집 화면을 열 때마다 이용자 10만명 데이터를 가져오니 서버에도 부하를 주고, Admin을 사용하는 사용자 웹브라우저에도 부하를 주는 거죠.
이 방식으로 하지 않고 사용자를 검색해서 고르는 방식이면 좋을텐데, SQLAdmin에서 이 기능을 제공해요.
form_ajax_refs는 AJAX 방식으로 데이터를 검색해 가져와 나열하도록 해요. 사전형(dict) 객체로 설정하는데, 키는 모델 필드명, 값은 해당 모델 필드에 대한 설정(option)을 사전형으로 정의해요. fields는 검색어에 대해 검색할 모델 필드명을 나열하고, order_by는 정렬할 모델 필드명이죠. 현재(2024년 6월 12일 기준) 설정은 이 두 가지만 있어요.
적용하니 UI가 바뀌었어요. han 이라고 입력하니 username이 hannal인 사용자가 나열돼죠. 그런데 항목으로 표시된 사용자 개체가 보기 불편하네요. 이건 User를 출력할 때 문자열화하여 출력하여 __str__() 메서드를 호출하기 때문에 그렇죠. User 모델 클래스에 __str__() 메서드를 오버라이드하여 깔끔하게 출력하겠습니다.
사용자의 계정명(username)을 출력하도록 했어요.
여기까지 진행한 코드 커밋 : 3805c1e
아참, form_ajax_refs는 모델 관계의 역방향에 대해서도 가능해요. User 모델 클래스에 todo_groups에도 지정 가능하죠.
하지만 저는 역방향에 대해서는 Admin 폼에서 편집 항목에 넣지 않는 편이에요. 모델 관계 정보를 폼에서 다루는 건 한 쪽 방향으로만 다뤄서 혼란 여지를 없애려는 의도지요.
이 부분에 대한 코드 커밋 : 3766a1e
Todo 모델에 대한 Admin 구현
(1) TodoAdmin 클래스 작성
UserAdmin, TodoGroupAdmin과 그리 다르지 않으니 TodoAdmin 코드의 기본형을 바로 볼게요.
특이사항도 없고 웹 화면도 특이사항이 없습니다.
(2) 열(Column) 레이블
열(Column) 이름이 알아보기 좋지 않네요. 우리야 직접 모델 필드명을 지었으니 알아본다쳐도, 그런 정보나 맥락이 없는 사람이 보면 duedate_at이 뭔지, completed_at이 뭔지 바로 알아보기 어려울 거예요. 다음 화면처럼 열 이름을 표시하면 좋을텐데.
SQLAdmin은 모델 필드 별로 레이블을 지정하는 기능을 제공해요. 설정 방법도 직관적이어서 코드를 보면 바로 이해하실 거예요.
TodoAdmin 클래스(Admin 페이지)도 SQLAdmin에 등록하는 것 깜박하지 마세요.
(3) 시간대 정보 적용해서 저장하기
Admin에서 할 일을 추가해보세요.
오류가 발생할 거예요. 셸에 떠있는 FastAPI 애플리케이션 서버를 보면 오류 텍스트가 출력되어 있어요. 출력된 내용이 많아 당황스러울텐데, 잘 살펴보면 문제가 발생한 요인을 발견할 거예요.
발견하셨나요?
우리는 일시정보 데이터는 Aware DateTime으로 다루고 있어요. 그래서 SQLAlchemy-Utc 패키지도 설치하고 그랬잖아요. 하지만 SQLAdmin은 Naive DateTime으로 일시 정보를 생성해요. 그래서 오류가 발생한 거죠.
모델 데이터를 추가하거나 변경할 때 어떤 메서드가 호출되는지 기억하시나요? 바로 on_model_change() 지요. 이 메서드를 오버라이드해서 일시정보 값인데 시간대가 없는 Naive DateTime이면 Aware DateTime으로 변경할건데, 편의 상 이 동작을 make_aware라고 할게요.
Todo 모델의 경우, duedate_at, completed_at, 그리고 cancelled_at 모델 필드가 사용자가 값을 편집할 수 있는 일시정보 필드지요. 폼 데이터(payload)가 담겨있는 data의 값을 모두 훑어서 make_aware해야 해요. 그래서 for key, value in data.items(): 로 쭉 훑게 했어요.
datetime 객체의 utcoffset() 메서드를 호출하면 UTC 기준으로 얼마나 시차가 있는지 timedelta 객체로 반환해요. 만약 시간대 정보가 없다면 None을 반환하고요. value.utcoffset() is not None은 시간대가 어디든 시간대 정보가 있는지 검사하는 거예요.
이제 잘 저장돼요.
(4) 값 자료형 별로 출력 형식 잡아주기
할 일을 저장하고 나면 할 일 목록에 표시되는데, 일시정보가 좀 지저분해보여요. ISO 8601 국제 표준 표기법인데, 한 눈에 들어오지도 않고 굳이 표시하지 않아도 되는 마이크로 초가 표시되어 더 산만하죠.
SQLAdmin은 두 가지 방식으로 열(Column)에 표시할 텍스트 형식을 지정할 수 있는데, column_formatters와 column_type_formatters이지요. column_formatters는 열(모델 필드) 단위로, column_type_formatters는 열의 값 자료형에 단위로 형식을 지정하는데 써요.
둘의 설정 방법이 거의 동일한데, column_type_formatters부터 사용해볼게요.
사전형으로 지정하는데, 키는 자료형(type)을, 값은 호출가능한(callable) 객체를 짝지어 형식화 해요. SQLAdmin은 이 호출가능한 객체에 셀에 매핑된 값을 인자로 전달하며 호출하죠. 예를 들어, duedate_at 열(모델 필드)를 출력할 때엔 duedate_at 값을 전달하고, 위 코드에선 datetime 객체의 strftime() 메서드를 이용해 형식 문자열을 생성해요. 이는 __str__() 메서드를 호출하는 것과는 달라요.
SQLAdmin에는 기본적으로 bool자료형과 None의 자료형에 대해서 형식자를 포함되어 있어요. bool 자료형이면 아이콘으로 표시하는데, UserAdmin 인터페이스에서 is_active가 그 예죠. None의 자료형에 대해서는, 간단히 말해서 값이 None이면 아무 문자열도 출력하지 않게 처리해요.
(5) 열(Column) 별로 출력 형식 잡아주기
column_formatters도 비슷한 방식으로 지정하는데, 자료형(type) 대신 모델필드를 키로 사용하는 점이 다르고, 값에 사용할 호출가능한 객체의 인자가 두 개라는 점도 달라요.
현재 TodoAdmin의 group이 지저분하게 보이는데요.
Todo.group 을 할 일 그룹명(TodoGroup.name)으로 출력되도록 해볼게요. 간단해요.
호출가능한 객체가 인자 두 개를 받는데, 첫 번째는 모델 데이터 값, 즉 Todo 인스턴스 객체이고, 두 번째 인자는 모델 필드명(Attribute)이 문자열로 전달돼요. Todo.group이라면 group 문자열이 전달되는거죠. 굳이 필요하지 않아서 익명으로(_) 받았어요.
한결 보기 좋네요!
여기까지 진행한 코드 커밋 : 2897cae
(6) 모델 관계의 모델 필드를 열(Column)에 추가
데이터베이스를 정규화해서 Todo는 User를 직접 가리키진 않죠. 그런데 Admin에서는 각 할 일이 누구의 할 일인지 보여주는 게 더 편할 거예요.
방법은 간단한데요. 문자열로 Python 이름공간 경로를 명기하는 거예요.
Todo 인스턴스 객체의 group 속성(모델필드)은 TodoGroup의 인스턴스 객체이고, 이 인스턴스 객체의 user 속성(모델필드)은 User의 인스턴스 객체잖아요. 이를 Python에서는 todo.group.user.username으로 표현해 username에 접근하죠. TodoAdmin은 Todo 모델에 대한 인터페이스이니 Todo의 모델 필드인 group부터 기술하면 돼요.
column_list에 "group.user.username"로 열을 추가했으니, 이 열의 이름을 알아보기 좋게 하려고 column_labels에도 관련 코드를 넣었어요.
여기까지 진행한 코드 커밋 : a2e96cf