website: 학번 로그인 기반 멤버 계정 체계 구축
멋쟁이사자처럼 경희대 홈페이지에 학번+전화번호 기반 멤버 로그인 체계와 프로젝트 쇼케이스 기능을 병합했습니다. 상태공간트리 QA 기법으로 테스트 커버리지를 설계하면서 기존 비밀번호 가드 필터의 설계 결함을 발견하고 일반화하는 과정을 정리합니다.
요약
2026년 7월 20일, [BE] 계정·인증 — 학번 로그인 기반 멤버 계정 체계 (#117) PR을 dev 브랜치에 머지했습니다. 커밋 메시지 그대로, 이번 작업의 핵심은 "학번 로그인 기반 멤버 계정 체계"를 만드는 것이었는데, 실제로는 여기에 프로젝트 쇼케이스 모듈(#119)까지 함께 딸려 들어왔습니다. 총 63개 파일이 바뀌었고 3265줄이 추가, 29줄이 삭제됐습니다. 순수 추가에 가까운 규모인 걸 보면 알 수 있듯, 완전히 새로운 도메인을 하나 얹은 작업입니다.
배경 및 목적
기존에는 관리자(Admin) 인증 체계만 있었던 것으로 보입니다. 이번 작업은 여기에 "멤버(동아리원)"라는 별도의 사용자 계층을 추가하는 것이 목적이었습니다. 멤버는 학번으로 로그인하고, 초기 비밀번호는 전화번호로 설정되며, 첫 로그인 시 비밀번호를 강제로 바꾸도록 하는 흐름입니다.
여기에 더해 멤버가 직접 자기 프로젝트를 등록·수정·삭제할 수 있는 "프로젝트 쇼케이스" 기능도 같은 PR에 들어왔습니다. 이 두 기능이 얽히면서 "멤버 전용 API에 대한 접근 제어를 어떻게 일관되게 걸 것인가"라는 문제가 자연스럽게 불거졌고, 이게 이번 작업에서 가장 흥미로운 대목이었습니다.
구현 내용
변경된 파일
주요 변경 영역은 다음 네 갈래로 나눌 수 있습니다.
- 인증 인프라:
JwtAuthenticationFilter,JwtProvider,AdminPrincipal,SecurityConfig - 멤버 인증 모듈:
MemberAuthController,MemberAuthService,MemberCookieFactory,MemberPasswordGuardFilter,MemberRefreshToken(Repository 포함), 각종 DTO - 프로젝트 쇼케이스 모듈:
Project,ProjectController,ProjectService,ProjectImage,ProjectParticipant및 관련 Repository/DTO/예외 클래스 일체 - 테스트 및 QA 문서:
ProjectControllerTest(565줄),MemberAuthControllerTest(369줄),STATE_SPACE_QA.md(248줄)
추가 라인 3265줄 중 상당 부분이 테스트 코드와 QA 문서라는 점이 눈에 띕니다. 실제 프로덕션 코드보다 그걸 검증하는 코드/문서의 비중이 크게 잡힌 커밋입니다.
MemberAuthService — 관리자 인증 로직의 반복
MemberAuthService는 코드 주석에 명시된 대로 admin.auth.AdminAuthService와 거의 같은 로그인/JWT/쿠키 로직을 멤버용으로 그대로 반복해서 구현했습니다.
// admin.auth.AdminAuthService와 같은 로그인/JWT/쿠키 로직을 멤버(학번 로그인)용으로 그대로 반복한다.
// #117 계획대로 admins/refresh_tokens 테이블은 건드리지 않고 나란히 둔다.
@Service
@RequiredArgsConstructor
public class MemberAuthService {
로그인 실패 시 잠금 처리, refresh token 해시 저장, 비밀번호 변경 시 기존 세션 전체 무효화 등 관리자 인증과 동일한 패턴을 따릅니다. 특히 인상적이었던 부분은 비밀번호 변경 로직입니다.
@Transactional
public LoginResult changePassword(Long memberId, String currentRawPassword, String newRawPassword) {
Member member = memberRepository.findById(memberId)
.orElseThrow(MemberNotFoundException::new);
if (!passwordEncoder.matches(currentRawPassword, member.getPasswordHash())) {
throw new InvalidCredentialsException("현재 비밀번호가 올바르지 않아요.");
}
AdminPasswordPolicy.validate(newRawPassword);
member.changePassword(passwordEncoder.encode(newRawPassword));
revokeAllTokensFor(member.getId());
return issueTokenPair(member);
}
JWT는 발급 시점 클레임을 그대로 담고 있어서, DB의 mustChangePassword 값을 바꿔도 이미 발급된 토큰에는 반영이 안 됩니다. 그래서 비밀번호를 바꾸면 기존 세션(다른 기기 포함)을 전부 끊고, 방금 요청을 보낸 세션에는 바로 새 토큰 쌍을 발급해 다시 로그인할 필요가 없게 만들었습니다. 첫 로그인 강제 변경 흐름에서도 사용자가 새 비밀번호만 입력하면 되도록(현재 비밀번호는 로그인 시 입력한 전화번호를 그대로 흘려보내는 방식으로) UX를 신경 쓴 흔적입니다.
ProjectService — 쇼케이스 도메인의 불변식들
ProjectService는 프로젝트 생성/수정/삭제 시 여러 불변식을 검증합니다. 눈에 띄는 것 몇 가지만 짚어보면:
private void requireParticipant(Long projectId, Long memberId) {
if (!projectParticipantRepository.existsByProjectIdAndMemberId(projectId, memberId)) {
throw new NotProjectParticipantException();
}
}
프로젝트를 만든 사람이라는 개념 자체가 엔티티에 없고, "참여자 집합에 속해 있는가"만으로 수정/삭제 권한을 판단합니다. 이 설계 때문에 뒤에서 다룰 테스트 설계에서도 재미있는 문제가 하나 생겼습니다.
// 같은 memberId를 참여자 목록에 두 번 넣으면 ProjectParticipant 행이 중복 저장돼(유니크
// 제약 없음) 상세 응답에 같은 사람이 두 번 나온다 — 상태공간트리 QA에서 발견.
private void requireNoDuplicateParticipants(List<ProjectParticipantRequest> participants) {
long distinctCount = participants.stream().map(ProjectParticipantRequest::getMemberId)
.collect(Collectors.toSet()).size();
if (distinctCount != participants.size()) {
throw new DuplicateParticipantException();
}
}
주석에서 밝히듯, 이 검증 로직 자체가 QA 과정에서 발견된 빈틈을 메우기 위해 추가된 것입니다.
기술적 의사결정
MemberPasswordGuardFilter — "경로 기반"에서 "메서드 기반"으로
이번 작업에서 가장 의미 있는 기술적 의사결정은 MemberPasswordGuardFilter의 판단 기준을 바꾼 것입니다.
원래 이 필터는 "요청 경로가 /api/member/로 시작하는가"를 보고, mustChangePassword=true인 멤버의 접근을 막았습니다. 그런데 프로젝트 쇼케이스 API(/api/projects)는 멤버 전용 쓰기 API이면서도 /api/member/ 네임스페이스 바깥에 있었습니다. 그 결과 비밀번호를 아직 안 바꾼 멤버도 /api/projects에 자유롭게 쓰기 요청을 보낼 수 있는 구멍이 있었습니다.
STATE_SPACE_QA.md에 이 판단 과정이 잘 기록돼 있습니다.
/api/projects(멤버 전용 쓰기)가/api/member/밖에 있다는 사실 자체가 #117의MemberPasswordGuardFilter설계를 재검토하게 만든 계기였다 — 그 결과가 "쓰기 메서드 기반"으로의 일반화다.
즉 판단 기준을 "경로가 /api/member/인가"에서 "HTTP 메서드가 쓰기 메서드(POST/PUT/PATCH/DELETE)인가"로 바꿨습니다. 이렇게 하면 앞으로 멤버 전용 쓰기 API가 어느 경로에 생기든, 경로를 일일이 화이트/블랙리스트에 등록하지 않고도 가드가 자동으로 적용됩니다.
대안 비교: 경로 기반을 유지하면서 /api/projects를 예외 목록에 추가하는 방법도 있었을 겁니다. 하지만 이건 새 멤버 전용 API가 생길 때마다 목록을 계속 갱신해야 하고, 깜빡하면 똑같은 구멍이 또 생기는 구조입니다. 메서드 기반으로 일반화하면 "쓰기 요청인가"라는 더 근본적인 성질에 기대게 되어 미래의 실수를 구조적으로 막을 수 있습니다. 다만 GET인데 상태를 바꾸는 예외적인 API가 생기면 놓칠 수 있다는 점은 QA 문서에도 명시적으로 남겨뒀습니다.
상태공간트리(State Space Tree) 기반 QA 설계
이번 커밋에서 개인적으로 가장 눈여겨본 부분은 STATE_SPACE_QA.md입니다. 단순히 테스트 케이스를 나열하는 게 아니라, "누가·어떤 토큰 상태로·어떤 계정 상태로·어디를 치는가"를 축으로 놓고 트리를 구성한 뒤 DFS/백트래킹으로 리프까지 내려가며 테스트를 뽑는 방식을 문서화했습니다.
카티션 곱(모든 조합을 다 곱하는 방식)을 쓰지 않은 이유도 명확하게 남겼습니다.
변수를 그냥 다 곱하면 말이 안 되는 조합까지 기계적으로 생긴다 — 예를 들어 "익명 방문자인데 토큰이 유효함"이나 "SUPER_ADMIN인데 비밀번호 강제변경 대기중"은 세상에 존재할 수 없는 상태다.
트리는 부모 노드의 선택에 따라 자식 축의 도메인 자체가 좁아지거나 사라지므로, 무의미한 조합이 애초에 만들어지지 않습니다. 처음에는 로그인/로그아웃/리프레시까지 하나의 Actor 축 아래 욱여넣으려 했는데, 코드를 다시 보니 이 엔드포인트들은 access_token이 아니라 refresh_token 쿠키만 보고 판단한다는 걸 알게 되어 트리를 A(리소스 접근)와 B(인증 모듈 자기 자신)로 쪼갰다는 설계 정정 과정도 문서에 그대로 남아 있습니다.
이 방식으로 실제 코드에 있던 버그 4건을 리뷰 단계에서 잡아냈는데, 그중 하나가 앞서 언급한 MemberPasswordGuardFilter의 경로 기반 판단 문제였고, 다른 하나는 update()에서 참여자 목록 교체 시 본인을 빼도 막지 않던 문제였습니다.
배운 점 및 개선점
같은 종류의 인증 로직(Admin과 Member)을 반복 구현하면서 코드 중복은 늘었지만, admins/refresh_tokens 테이블을 건드리지 않고 나란히 두는 선택은 이번 스코프에서는 안전한 트레이드오프였다고 봅니다. 다만 세 번째 사용자 계층이 생긴다면 공통 로직을 추상화하는 리팩토링이 필요해질 것 같습니다.
QA 문서에서도 스스로 짚었듯, "한 기능만 보고 설계한 규칙이 다음 기능에서 뚫리는" 패턴이 트리 A→B→C에 걸쳐 반복됐습니다. 멤버 인증 필터를 설계할 때는 프로젝트 쇼케이스라는 기능이 존재하지 않았기 때문에 경로 기반 판단이 합리적으로 보였을 것입니다. 새 기능이 기존 보안 규칙의 전제를 깨뜨릴 수 있다는 걸 염두에 두고, 앞으로 새 멤버 전용 API가 추가될 때마다 이 가드가 여전히 유효한지 먼저 확인하는 습관을 QA 문서에 명시적으로 남겨둔 게 다음 작업자(혹은 미래의 나)에게 도움이 될 거라 생각합니다.
참고 자료
backend/docs/member-auth-module.mdbackend/docs/project-showcase-module.mdbackend/src/test/STATE_SPACE_QA.md