website: 부원 계정 관리 어드민 화면 + 오프보딩 (#145)
학번 로그인(#117·#118)은 이미 머지돼 있었는데, 정작 로그인할 계정을 발급할 관리자 화면이 없어서 아무도 로그인을 못 하는 상태였다. `/admin/members`에 부원 계정 관리 화면을 붙이는 게 이 PR의 목적이었고, 2026-07-26에 dev로 머지됐다.
등록·수정·비밀번호 초기화는 이미 있던 API에 화면만 붙이면 되는 작업이었다. 반면 오프보딩(로그인만 막고 글·기록은 그대로 남기는 소프트 딜리트)은 백엔드부터 새로 만들어야 했다. Member에 offboardedAt을 nullable로 추가하고, 로그인 시 오프보딩된 계정은 기존 "학번/비번 오류"와 똑같은 메시지로 막았다. 계정이 존재하는지 여부 자체를 노출하지 않으려는 판단이었다.
초기 구현을 끝내고 나서 위키의 "정보구조와 권한" 문서와 코드를 다시 대조해봤다. 등록·수정이 SUPER_ADMIN 전용으로 잠겨 있었는데, 위키에는 멤버 등록·정보 수정·비밀번호 초기화·오프보딩이 전부 ADMIN 이상 공용 권한이고, SUPER_ADMIN 전용은 관리자 임명·회수·승계뿐이라고 명시돼 있었다. @PreAuthorize를 hasRole('SUPER_ADMIN')에서 hasAnyRole('ADMIN','SUPER_ADMIN')로 고쳤고, 프론트에서도 등록·수정 버튼의 SUPER_ADMIN 전용 노출 조건을 뺐다. /admin/members 화면 자체가 이미 ADMIN 이상만 진입 가능하니 화면 안에서 또 역할을 나눌 이유가 없었다. 예전 설계결정 문서(member-auth-module.md)에 SUPER_ADMIN 전용이라고 적어둔 부분은 지우지 않고 정정 각주만 붙였다 — 당시엔 그렇게 판단했다는 기록 자체는 남겨두는 게 맞다고 봤다.
그 다음엔 테스트 커버리지를 다시 봤다. MemberService·MemberAuthService 둘 다 @SpringBootTest로 실제 DB 위에서만 검증되고 있었고, 오프보딩·로그인차단·비번초기화처럼 보안 경계에 걸리는 로직인데도 DB 없이 빠르게 도는 순수 단위 테스트가 하나도 없었다. MemberAuthServiceTest를 추가하다가 Member.create()가 id를 채우지 않는다는 걸 놓쳐서, 오프보딩 시 토큰 폐기 테스트가 실제로는 아무것도 검증하지 않은 채로 통과할 뻔했다. revokeAllTokensFor(null)이 조용히 호출되고 아무 일도 안 일어나도 테스트는 초록불이었던 셈이다. 테스트 안에서 엔티티 id를 직접 세팅해주는 걸로 고쳤는데, 단위 테스트 계층이 아예 없었으면 이런 종류의 구멍은 계속 안 보였을 거란 생각이 들었다.
PR을 올리고 리뷰를 받으면서(김우진·신선우) 재입부 시나리오가 나왔다. "14기로 활동하다 오프보딩된 사람이 15기로 다시 들어오면 어떻게 되나?" 확인해보니 학번에 전역 unique가 걸려 있어서 오프보딩된 학번은 영구적으로 재사용이 불가능했다. 오프보딩이 소프트 딜리트라 row가 DB에 그대로 남는데, 그 학번으로 다시 등록을 시도하면 그냥 409였다. 학번 unique를 (학번, 기수) 복합키로 바꾸고, "동시에 두 기수로 활동할 수 없다"는 불변식은 별도로(오프보딩 안 된 활동 중 레코드 존재 여부) 체크하는 방식으로 정정했다.
여기서 애플리케이션 코드만 고치면 안 되는 문제가 있었다. 이 레포는 이 PR 작업 중간에 별도로 Flyway가 도입돼서(#133, stage 사고를 계기로) 엔티티 애노테이션만 고치면 실제 stage/prod DB엔 전혀 반영이 안 되는 구조였다. 이 브랜치는 Flyway 도입 이전에 갈라진 오래된 브랜치라, dev를 통째로 머지하면 무관한 커밋 60개가 딸려 들어와서 그 중 필요한 5개만 골라 cherry-pick했다. 실제 파일 SQLite로 재현해서 학번 단일 unique가 그대로 남아 있으면 재입부 INSERT가 SQLITE_CONSTRAINT_UNIQUE로 죽는 걸 직접 확인한 다음, V2__member_offboarding_and_cohort_unique.sql 마이그레이션을 추가했다. SQLite는 기존 컬럼의 unique 제약을 ALTER로 못 바꿔서 테이블 재생성 패턴을 썼다. 그리고 "이미 앞 버전까지 적용된 실데이터 DB에 새 마이그레이션이 얹히는" 실제 배포 경로를 재현하는 MigrationUpgradeHarness를 만들어서 기존 행이 보존되고 재입부가 되는지 커밋 가능한 테스트로 고정했다.
이 작업을 하다가 사용자 요청으로 자체 리뷰를 한 번 더 돌렸는데, MemberRepository.findByStudentId가 Optional<Member>로 단일 결과를 기대하고 있다는 게 눈에 띄었다. 재입부가 가능해진 이상 학번 하나가 row 두 개(오프보딩된 과거 기수 + 활동 중인 새 기수)를 가질 수 있는데, 그 상태에서 findByStudentId를 호출하면 IncorrectResultSizeDataAccessException으로 터질 지뢰였다. findAllByStudentId(List 반환)로 바꾸고 호출부를 다 맞췄다. 로그인 조회는 원래도 findByStudentIdAndOffboardedAtIsNull로 활동 중인 것 하나로 좁혀서 조회하고 있어서 이 문제가 없었다.
마지막으로 리뷰에서(박일하) 등록은 역할 0개를 막는데 수정은 안 막는다는 지적이 나왔다. 등록 폼엔 역할 미선택 시 제출 버튼을 비활성화하는 가드가 있었는데 수정 폼엔 없었던 거다. 프론트 저장 버튼에 create와 대칭으로 가드를 추가하고, 백엔드 MemberUpdateRequest.roles에도 @Size(min=1) 검증을 걸어 이중으로 막았다.
돌아보면 이 PR은 "화면 하나 붙이는 작업"으로 시작했다가, 위키와 코드 사이의 권한 불일치, 테스트 계층의 빈틈, 소프트 딜리트와 unique 제약의 충돌, 그리고 마이그레이션 인프라 도입 시점과 겹치는 문제까지 순서대로 걸려 나온 케이스였다. 특히 "삭제 없이 로그인만 막는다"는 요구사항 하나가 unique 제약 설계까지 영향을 준다는 걸 재입부 시나리오가 나오고 나서야 알아챈 게 인상적이었다 — 소프트 딜리트를 쓸 땐 처음부터 "삭제된 값도 재사용 가능해야 하는가"를 unique 제약 설계 단계에서 같이 물었어야 했다.