발행일

북마크 버튼 하나에 바라는 상태와 공개 범위를 실었다 — 표준데이터·OID 북마크와 마이페이지 모아 보기

북마크 버튼 하나에 바라는 상태와 공개 범위를 실었다

10월 27일에 로그인한 사용자가 표준데이터와 OID를 북마크해 두고 마이페이지에서 모아 보는 기능을 넣었습니다(0717d814, 30개 파일). 버튼은 네 자리에 섭니다.

자리버튼 위치
표준데이터 검색 카드(통합 검색·카탈로그·개념체계 탭, 통계 재검색)카드 맨 끝 열
표준데이터 상세제목 바로 옆
OID 검색·공개 OID 상세의 하위 OID 목록 카드카드 오른쪽 위
같은 화면의 선택된 OID 카드카드 오른쪽 위

모아 보는 자리는 마이페이지의 북마크 관리 탭이고, 그 안을 자주 찾는 표준데이터·자주 찾는 OID 하위 탭으로 나눴습니다.

화면으로 보면 버튼 하나 다는 일인데, 정해야 할 게 넷 있었어요. 버튼이 서버에 무엇을 보낼지, 무엇까지 북마크할 수 있게 할지, 로그인 안 한 사람에게 버튼을 보여 줄지, 그리고 카드 자체가 링크인 자리에서 버튼 클릭을 어떻게 받을지입니다.

뒤집기 대신 바라는 상태를 보낸다

저장 요청은 bookmark=1|0으로 누른 뒤 바라는 상태를 보냅니다.

def post(self, request, pk):
    target = get_object_or_404(BookmarkManager.targets(self.model), pk=pk)
    bookmarked = request.POST.get("bookmark") == "1"
    BookmarkManager.save(self.model, request.user, target, bookmarked)
    return JsonResponse({"bookmarked": bookmarked})

"지금 상태를 뒤집어 줘"로 두면 같은 요청이 두 번 갈 때 상태가 반대로 남습니다. 뒤에 적을 서버 오류 시 다시 보내기나, 같은 OID가 두 카드에 떠 있을 때 아직 안 바뀐 쪽을 한 번 더 누르는 경우가 그렇습니다. 바라는 상태를 보내면 같은 요청이 몇 번 와도 결과가 같아요. 저장 쪽은 get_or_create·delete로 그 상태에 맞추기만 하고, 사용자와 대상 짝에 유일 제약을 걸어 두 번 저장돼도 한 건입니다.

이 방식은 제가 처음 정한 게 아닙니다. 8월 말 팀원이 표준데이터 상세에 "알림 받기"를 넣으면서 "토글로 두면 중복 제출·뒤로가기에 반대로 뒤집힌다"는 이유로 바라는 상태(follow=1|0)를 받게 해 뒀고, 북마크 뷰도 그 이유를 주석에 그대로 가리킵니다.

알림 받기 표에 북마크를 같이 넣지는 않았어요. 모델을 InstanceBookmark(사용자 × 표준데이터)와 OidBookmark(사용자 × OID) 둘로 따로 뒀습니다. 한 표에 섞으면 다시 찾아가려고 북마크만 한 사람에게도 변경 통보가 갑니다.

이 요청은 화면에서 fetch로 보내니까 응답 코드도 정해야 했어요. 로그인이 풀린 상태에서 Django 기본대로 로그인 화면으로 리다이렉트하면, fetch는 그 HTML을 200으로 받습니다. 그래서 handle_no_permission을 덮어 401과 {"login_required": true}를 돌려줍니다. 403은 CSRF 실패가 쓰는 코드라 섞지 않았어요.

북마크할 수 있는 범위는 공개 화면과 같다

북마크 기능이 공개 범위 밖으로 새는 길은 두 가지였습니다.

  • 주소의 pk만 바꿔서 심사 중이거나 비공개인 표준데이터를 북마크하는 것
  • 북마크한 뒤 그 표준데이터가 심사로 돌아가거나 비공개가 됐는데 마이페이지에는 남아 있는 것

둘 다 같은 함수로 막았어요.

@staticmethod
def targets(model):
    """북마크할 수 있는 대상 쿼리셋."""
    if model is InstanceBookmark:
        return MetaClassInstanceManager.get_std_data_queryset(public_only=True)
    return ObjectIdentifier.objects.filter(
        state__in=ObjectIdentifierManager.VISIBLE_STATES_WITH_RETIRED
    )

get_std_data_queryset(public_only=True)는 홈 건수가 쓰고, 표준데이터 검색 목록과 같은 범위로 맞춰 둔 공개 모집단이고, OID 쪽은 공개 트리가 싣는 상태(등록·폐기)입니다. 저장할 때는 이 범위 밖이면 404이고, 마이페이지 목록도 매번 이 범위로 다시 거릅니다. Open API가 공개 화면과 같은 모집단 함수를 쓰게 한 것(목업으로 먼저 짠 Open API의 교체 장치는 한 번도 쓰이지 않았다)과 같은 이유예요. 공개 기준이 바뀌면 화면·API·북마크가 같이 바뀝니다.

OID 카드의 제목은 연결된 표준데이터의 제목인데, 여기도 공개 범위 안의 표준데이터 제목만 싣습니다. OID는 공개인데 연결된 표준데이터가 비공개면 제목 칸이 비어요. 테스트에 "비공개 표준데이터 제목은 마이페이지에 나오지 않는다"를 따로 넣었습니다.

쪽 나눔에서는 거르는 순서를 정했습니다. 마이페이지는 하위 탭마다 쪽을 나누는데, 쪽을 먼저 나누고 나중에 공개 범위로 거르면 한 쪽이 정한 건수보다 적게 나오고 쪽 수도 실제와 어긋나요. 그래서 공개 범위로 먼저 거른 쿼리셋을 Paginator에 넘깁니다. 쪽 크기는 원래 화면을 따랐어요. 표준데이터는 검색 목록과 같은 공용 정책값이고, OID는 하위 OID 목록과 같은 12건입니다.

비로그인에게도 버튼은 보인다

버튼은 로그인하지 않은 사람에게도 보여 줬습니다. 기능이 있다는 걸 알아야 로그인할 이유가 생기니까요. 누르면 "로그인 후 북마크할 수 있습니다. 로그인 화면으로 이동할까요?"를 묻고, 확인하면 ?next=<보던 주소>를 붙여 로그인 화면으로 보냅니다. 로그인하면 보던 화면으로 돌아와요.

버튼을 모두에게 보여 주면 저장 요청에 필요한 CSRF 토큰도 모두에게 찍고 싶어지는데, 그러면 비로그인 방문자 전원에게 CSRF 쿠키가 붙습니다. 그래서 스크립트는 모든 화면에 싣되 토큰은 로그인 사용자에게만 넘겼어요.

<script
  src="{{ 'js/bookmarkToggle.js' | static | add_hash }}"
  data-login-url="{{ 'common:user_login' | url }}"
  {%- if user.is_authenticated %} data-csrf-token="{{ csrf_token }}"{% endif %}
  type="text/javascript"
  defer
></script>

JS는 토큰이 없으면 요청을 보내지 않고 바로 로그인 안내를 띄웁니다. 화면을 열어 둔 사이 세션이 끝난 경우는 서버가 401을 주니 같은 안내로 갑니다.

카드가 링크인 자리에서 버튼 클릭 받기

표준데이터 검색 카드는 <li onclick>으로 상세로 이동합니다. 버튼 클릭을 평소처럼 거품 단계에서 받으면 카드 이동이 먼저 일어나요. 게다가 이 목록은 검색 조건을 바꿀 때마다 조각째 갈아끼워지기 때문에(검색 조건을 바꿀 때 새로고침 대신 조각 9개를 갈아끼웠다) 버튼마다 핸들러를 걸어 둘 수도 없습니다.

그래서 document에 캡처 단계 핸들러를 하나 걸었어요. 캡처 단계는 카드의 onclick보다 먼저 돌기 때문에, 버튼이면 preventDefault()·stopPropagation()으로 카드 이동을 끊고 저장만 합니다.

OID 카드는 사정이 또 달랐어요. 하위 OID 목록 카드는 쪽을 넘기거나 거를 때마다 JS가 데이터에서 다시 그립니다. 버튼의 aria-pressed만 바꿔 두면 다음 쪽에 갔다가 돌아올 때 옛 상태로 그려져요. 그래서 oidClassTree.js가 눌린 OID를 Set으로 들고, 저장이 끝나면 버튼이 bookmark:change 이벤트를 올려 그 Set과 화면을 맞춥니다. 같은 OID가 하위 목록 카드와 선택된 OID 카드에 함께 떠 있으면 한쪽을 누를 때 다른 쪽도 이 이벤트로 같이 바뀌어요.

회원 삭제 점검에 넣었다

사용자를 참조하는 새 모델을 만들면 회원 삭제 점검에도 알려야 합니다. 팀원이 만든 삭제 점검은 사용자를 참조하는 모델마다 "계정과 함께 지울지, 보존할지"가 정해져 있어야 통과시키고, 정해지지 않은 참조가 있으면 보존 처리가 정의되지 않은 … 참조 N건을 먼저 정리해 주세요로 삭제를 막습니다. 북마크 두 모델의 사용자 연결을 계정과 함께 지워지는 부속 연결 목록에 넣었고, "북마크가 있어도 회원 삭제가 막히지 않는다"를 테스트로 남겼어요.

검증

std_data/tests/test_bookmark.py에 8건을 넣었습니다.

  • 비로그인 저장은 401이고 아무것도 저장되지 않는다
  • 비로그인에게도 눌리지 않은 버튼이 보인다
  • 공개 범위 밖 대상은 404다
  • 바라는 상태대로 저장·해제된다
  • 목록·상세·OID 화면이 눌린 상태로 그린다
  • 마이페이지는 아직 공개인 것만 보여 주고, 비공개 표준데이터 제목은 싣지 않는다
  • 하위 탭마다 따로 쪽을 나눈다(page는 열린 하위 탭에만 걸린다)
  • 북마크가 회원 삭제를 막지 않는다

브라우저에서는 dev에서 저장·해제, OID 쪽을 넘겼다 돌아와도 눌린 상태가 유지되는지, 버튼을 눌러도 카드 이동이 일어나지 않는지, 마이페이지 자주 찾는 OID에서 13건이 1쪽 12건·2쪽 1건으로 나뉘는지 확인했습니다.

한계

  • dev에서 난 500에 다시 보내기를 붙였습니다. OID 카드를 고르자마자 북마크를 누르면 상세 조회 요청 네다섯 개와 겹쳐, dev의 SQLite가 database is locked로 500을 냈어요. 바라는 상태를 보내는 요청이라 다시 보내도 결과가 같으니, 서버 오류면 0.4초 쉬고 한 번만 다시 보내게 했습니다. 회원 삭제 가드가 사용자를 참조하는 모델의 저장을 감싸 트랜잭션을 열고, 저장 전에 잠금 행과 계정 연계 상태를 먼저 조회합니다. Django 4.2의 SQLite 트랜잭션은 지연 시작이라 이 조회에서 읽기 잠금을 잡고 저장에서 쓰기 잠금으로 올라가야 하는데, 방문 기록 저장도 같은 가드를 지나니 두 요청이 읽기 잠금을 쥔 채 서로 승격을 기다리면 한쪽이 database is locked를 받습니다. 코드 경로는 이렇게 따라갔지만 재현 실험까지는 하지 않았어요. 운영 DB는 PostgreSQL이라 이 오류는 나지 않고(같은 잠금 행에서 기다릴 뿐입니다), 다른 쓰기 화면도 dev에서는 같은 500을 낼 수 있는데 다시 보내기는 북마크에만 넣었어요.
  • 버튼 모양이 두 곳에 있습니다. 템플릿이 그리는 카드는 Jinja 매크로(bookmark_button)가, JS가 그리는 OID 카드는 buildBookmarkButton이 같은 모양을 따로 만듭니다. 양쪽 주석에 "한쪽을 바꾸면 두 곳을 같이 볼 것"이라고 적어 뒀지만 규칙으로 막은 것뿐이에요.
  • 주석 하나가 동작과 어긋납니다. 매크로 주석은 스크립트를 "로그인 사용자에게만 싣는다"고 적었는데, 실제로는 모든 화면에 싣고 토큰만 로그인 사용자에게 넘깁니다. 같은 커밋 안에서 방식을 바꾸고 주석을 못 따라간 거예요.
  • 아직 작업 브랜치에만 있습니다. 이 글을 쓰는 시점에 북마크 커밋은 본 작업 브랜치에 합쳐지지 않았습니다. (이 글을 정리하며 덧붙임) 다음 날 다른 브랜치에도 같은 번호의 마이그레이션이 생겨서, 합칠 때 번호를 다시 맞춰야 합니다.