← 개발 로그 목록

website: 부원 계정 관리 어드민 화면을 만들다가 학번 unique 설계까지 다시 손보게 된 이야기

/ 7분 분량 / 개발 로그

부원 계정을 관리자가 직접 등록·수정하고, 필요하면 비밀번호를 초기화하거나 오프보딩까지 할 수 있는 어드민 화면을 만드는 작업이었다(#145). 진행하는 과정에서 권한 설정이 문서와 어긋나 있는 걸 발견해 고쳤고, 리뷰 중에 나온 질문 하나 때문에 학번 unique 제약 자체를 다시 설계하게 됐다.

어드민 화면 만들기

요구사항은 단순했다. 학번을 로그인 아이디로, 전화번호를 초기 비밀번호로 써서 부원을 등록하고, 이모지는 서버가 자동으로 배정한다. 목록에서 이름·역할을 수정할 수 있고, 비밀번호 초기화 버튼을 누르면 전화번호로 되돌아간다. 오프보딩은 로그인만 막을 뿐 그 사람이 남긴 글이나 기록은 그대로 남겨야 한다는 게 전제였다.

프론트는 MemberManagement.tsx에 등록 폼, 인라인 수정, 비번초기화/오프보딩 버튼을 한 화면에 몰아넣었다. 세션 갱신 후 목록을 불러오고, 401이나 리프레시 토큰 무효 응답이 오면 로그인 페이지로 돌려보내는 처리를 useEffect에 넣었다. 백엔드는 Member, MemberService, MemberAuthService에 오프보딩 로직을 추가하고, 응답 DTO는 학번·오프보딩 여부까지 담을 수 있게 MemberAdminResponse로 분리했다.

권한 스펙이 문서와 어긋나 있었다

QA를 하면서 위키의 "정보구조와 권한" 문서와 실제 구현을 나란히 놓고 봤다. 문서에는 멤버 등록·수정·비번초기화·오프보딩이 ADMIN 이상이면 누구나 가능하고, 관리자 임명·회수·승계만 SUPER_ADMIN 전용이라고 돼 있었다. 그런데 실제 MemberController의 @PreAuthorize는 등록·수정에 hasRole('SUPER_ADMIN')을 걸어놨다. 문서 기준으로 보면 명백히 좁게 잠겨 있는 상태였다. hasAnyRole('ADMIN','SUPER_ADMIN')로 고치고, ADMIN 권한으로 등록·수정이 되는지, SUPER_ADMIN 동작은 그대로인지, MEMBER나 비로그인은 여전히 막히는지를 회귀로 다시 확인했다. 프론트에서도 등록·수정 버튼에 SUPER_ADMIN 전용 노출 조건이 남아 있어서 같이 걷어냈다 — 이 화면 자체가 이미 ADMIN 이상만 들어올 수 있으니 화면 안에서 또 나눌 이유가 없었다.

QA 실행 방식도 잠깐 고민했다. 이전 미션들(#117, #119)은 별도 프로세스로 서버를 띄워 소켓 HTTP로 찔렀는데, 이번엔 초대 메일 발송 같은 외부 경계가 없고 그 방식의 이점이 크지 않았다. 반대로 admin.seed.super-admins는 메일 발송에 의존해 소켓 기동을 하려면 Mailpit 같은 메일 서버 인프라를 새로 갖춰야 했다. 그 비용이 검증 신뢰도 증가분보다 크다고 판단해서, DispatcherServlet·SecurityFilterChain·실제 SQLite까지 그대로 통과하되 소켓 레이어만 생략하는 MockMvc 기반으로 QA를 진행했다.

재입부 시나리오에서 나온 문제

리뷰 중에 "14기로 활동하다 오프보딩된 사람이 15기로 다시 들어오면 어떻게 되나요?"라는 질문이 나왔다. 실제로 해보니 등록이 409로 막혔다. 오프보딩은 로그인만 막는 소프트 딜리트라 row가 DB에 그대로 남는데, 학번에 전역 unique가 걸려 있어서 그 학번은 사실상 영구히 재사용 불가능한 상태였다. 기존 QA 표에 있던 "학번 중복 등록 시 409" 시나리오는 맞는 동작이었지만, "오프보딩된 뒤에도 그런가"는 아무도 확인한 적이 없던 갭이었다.

고친 방향은 unique를 (학번, 기수) 복합키로 바꾸는 것이었다. 대신 "한 사람이 동시에 두 기수로 활동 중일 순 없다"는 불변식은 별도로 체크하게 했다 — 기수가 달라도 오프보딩되지 않은(활동 중인) 레코드가 이미 있으면 막는 식이다.

문제는 이 레포가 Flyway(#133)로 스키마를 관리한다는 점이었다. 엔티티 애노테이션만 고치면 로컬 테스트는 통과해도 실제 stage/prod DB엔 전혀 반영되지 않는다. 그래서 실제 파일 SQLite로 재현해 원본 SQLITE_CONSTRAINT_UNIQUE 예외까지 직접 확인한 다음, V2__member_offboarding_and_cohort_unique.sql 마이그레이션을 작성했다. SQLite는 기존 컬럼의 unique 제약을 ALTER로 바꿀 수 없어서 테이블을 재생성하는 패턴을 썼다.

여기서 한 번 더 확인해야 했던 건 "V1까지만 적용된, 실데이터가 있는 DB"에 V2가 안전하게 얹히는가였다. 원래는 별도 git worktree로 dev를 체크아웃해서 V1만 있는 DB를 만들고 그 위에 V2를 수동으로 얹어 확인하는 식으로 했는데, 이 절차 자체를 MigrationUpgradeHarness라는 재사용 가능한 테스트 도구로 옮겼다. @DynamicPropertySource에서 컨텍스트가 뜨기 전에 V1까지 마이그레이션 → raw SQL로 기존 행 심기 → V2까지 마이그레이션을 순서대로 실행하고, 그 위에서 Spring 컨텍스트를 띄워 실제 Repository·Service로 기존 행이 보존되는지, 재입부가 되는지를 확인하게 만들었다. 다음에 비슷한 "테이블 재생성형" 마이그레이션이 필요할 때 그대로 다시 쓸 수 있다.

부수적으로 걸린 지뢰들

unique 제약을 바꾸면서 학번 하나가 이제 여러 row(재입부 이력)를 가질 수 있게 됐는데, MemberRepository.findByStudentId가 단일 결과를 기대하는 Optional<Member> 반환이었다. 재입부가 실제로 일어난 학번을 조회하면 IncorrectResultSizeDataAccessException으로 터질 뻔한 지점이었다. findAllByStudentId(List 반환)로 바꾸고 호출부를 다 맞췄다. 로그인 조회(findByStudentIdAndOffboardedAtIsNull)는 원래도 활동 중인 레코드 하나로 좁혀서 조회했기 때문에 이 문제가 없었다.

MemberAuthService에 DB 없이 도는 순수 단위 테스트가 하나도 없다는 것도 이번에 확인했다. 추가하면서 Member.create()가 id를 채우지 않는다는 걸 놓쳤는데, 이 상태로는 "오프보딩하면 활성 리프레시 토큰을 전부 폐기한다"는 테스트가 실제로는 revokeAllTokensFor(null)을 호출하면서도 조용히 통과할 뻔했다. 테스트에서 ReflectionTestUtils.setField로 id를 직접 채워준 다음에야 이 검증이 의미 있게 작동한다는 걸 확인했다.

남은 생각

이번 건은 코드를 새로 짜는 것보다 리뷰에서 던진 두 개의 질문 — "문서와 다른 거 아닌가", "오프보딩 후 재입부는 되나" — 이 실제로 설계 결함을 드러낸 경우였다. 특히 재입부 시나리오는 처음 기능을 설계할 때는 잘 떠오르지 않는 케이스였는데, unique 제약을 잡을 때는 앞으로 "삭제/비활성화 이후 같은 값을 다시 쓸 수 있어야 하는가"를 먼저 물어보는 게 나을 것 같다.