← 개발 로그 목록

website: dev → main 승격 — 112커밋 밀린 prod를 dev/stage와 같은 스키마·기능으로 맞추기

/ 9분 분량 / 개발 로그

멤버 로그인, 프로젝트 쇼케이스, 모집 관리 등 그동안 dev에 쌓인 기능들을 한꺼번에 main(운영)으로 승격시킨 PR입니다. 별도 브랜치 분기 없이 순수 fast-forward 성격의 병합이었고, 생성 13초 만에 병합됐습니다.

요약

이 PR은 dev 브랜치를 main으로 승격시켜 prod 환경을 dev/stage와 동일한 코드·스키마 상태로 맞추는 작업입니다. 2026-07-23 05:12에 생성되어 같은 시각(13초 뒤) 바로 merged 상태가 됐습니다. 코드 변경 자체는 거의 없고(승격이므로), 그 안에 담긴 112커밋 분량의 실제 작업 — 멤버 인증 체계, 프로젝트 쇼케이스, 모집 관리 자동 메일 발송, 이메일 E2E 테스트 보강 등 — 이 이번 병합으로 한꺼번에 운영에 반영됐습니다.

배경 및 목적

Flyway 도입(#133)을 앞두고 있었는데, 그 전에 먼저 prod를 dev/stage와 같은 상태로 맞춰야 했습니다. PR 본문에 따르면 main이 dev보다 무려 112커밋 뒤처져 있었고, 실측해보니 prod DB엔 다음이 통째로 빠져 있었다고 합니다.

  • staff, projects, project_images, project_participants, project_tech_stack, recruitment_status, member_refresh_tokens 테이블 7개
  • members 테이블의 password_hash·phone·student_id·failed_login_attempts·locked_until·must_change_password 컬럼

즉 멤버 로그인 기능 자체가 prod DB엔 아예 없는 상태였던 겁니다. 다행히 members·posts 테이블이 둘 다 0건이라 데이터 손실 위험 없이 스키마만 따라잡으면 되는 상황이었습니다.

배포 시 ddl-auto: update가 이 차이를 자동으로 메워주겠지만, #133이 지적한 바로 그 문제(SQLite ALTER TABLE이 UNIQUE 컬럼 추가를 지원하지 않는 것) 때문에 members.student_id 추가가 다시 실패할 가능성이 있다고 미리 짚어뒀습니다. 실패해도 앱은 조용히 계속 뜨지만, #160에서 새로 도입한 스모크 테스트가 /api/members 등을 실제로 찔러서 문제를 감지하면 자동 롤백되도록 안전장치를 걸어뒀습니다.

구현 내용

변경 파일이 90개가 넘고 +9,500/-1,018 규모라 사실상 dev에 쌓인 여러 미션이 한 번에 들어왔다고 보는 게 맞습니다. 커밋 로그를 따라가 보면 대략 이런 흐름이었습니다.

1. 미션 하네스 정비

"기능 트리와 결합", "인당 1미션 원칙", "tidy-missions 정리 스킬" 같은 커밋들로 시작합니다. PM 문서·미션 발주 체계를 먼저 다듬고, 위키(스펙)-트리(현황)-미션(전진)이 어긋나지 않도록 자가검증을 강제하는 작업이었습니다.

2. 멤버 인증 체계 (#117)

member/auth 패키지가 새로 생겼습니다. 학번+전화번호(초기 비밀번호)로 로그인하고, 첫 로그인 때 비번을 강제로 바꾸고, 관리자가 분실 비번을 초기화하는 흐름입니다. 기존 어드민 인증(#97)의 로그인·JWT·쿠키·비밀번호 로직을 최대한 재사용했습니다.

@Transactional(noRollbackFor = {InvalidCredentialsException.class, AccountLockedException.class})
public LoginResult login(String studentId, String rawPassword) {
    Member member = memberRepository.findByStudentId(studentId)
            .orElseThrow(() -> new InvalidCredentialsException("학번 또는 비밀번호가 올바르지 않아요."));

    if (member.isLocked()) {
        throw new AccountLockedException();
    }
    ...
}

3. 프로젝트 쇼케이스 (#119)

부원이 참여한 프로젝트를 등록·전시하는 API입니다. ProjectService에는 참여자 자기제외 검증, 중복 참여자 방지, 대표 이미지는 최대 1개 등 여러 불변식 체크가 들어 있습니다.

private void requireSelfAmongParticipants(List<ProjectParticipantRequest> participants, Long creatorMemberId) {
    boolean includesSelf = participants.stream().anyMatch(p -> p.getMemberId().equals(creatorMemberId));
    if (!includesSelf) {
        throw new SelfNotIncludedException();
    }
}

4. 모집 관리 (#124)

모집 켜기/끄기 API와, 켜지는 순간 구독자 전원에게 안내 메일을 자동 발송하는 기능입니다. RecruitmentManagementService.open()에서 상태가 "닫힘→열림"으로 바뀔 때만 이벤트를 발행하는 방식으로 중복 발송을 막고 있습니다.

public synchronized RecruitmentStatusResponse open() {
    RecruitmentStatus status = findOrCreate();
    if (!status.isOpen()) {
        status.markOpened();
        statusRepository.save(status);
        eventPublisher.publishEvent(new RecruitmentOpenedEvent());
    }
    return toResponse(status);
}

5. README 개편

"구현 현황" 트리를 단순 상태 표시에서 실제 QA 검증 커버리지·pass율까지 함께 보여주는 표로 바꿨습니다. 각 기능 노드마다 "검증 (pass/전체)" 컬럼을 붙여, 문서와 실제 검증 결과가 계속 어긋나지 않도록 한 게 눈에 띕니다.

기술적 의사결정

커밋 메시지와 문서(member-auth-module.md)를 보면 리뷰 과정에서 발견하고 고친 문제들이 꽤 흥미롭습니다.

멤버 리프레시 토큰 테이블을 어드민과 공유하지 않고 분리한 이유 — Admin.id와 Member.id는 서로 다른 시퀀스라 같은 숫자 id가 우연히 겹칠 수 있고, 최악의 경우 다른 사람의 refresh 토큰이 잘못 매칭될 여지가 있었습니다. "추상화는 두 번째 중복에서"라는 팀 원칙에 따라 지금은 나란히 두고, 세 번째 유사 사례가 생기면 그때 합치기로 했습니다.

첫 로그인 강제 비밀번호 변경을 필터로 서버단에서 막은 이유 — FE 안내만으로는 우회가 가능해서, MemberPasswordGuardFilter를 만들어 mustChangePassword=true인 사용자의 요청을 차단하게 했습니다. 그런데 이게 처음엔 허용 경로 3개 밖의 모든 요청을 막아버려서, 막 로그인한 신규 멤버가 공개 피드조차 못 보는 문제가 생겼습니다. 1차로 /api/member/ 네임스페이스로 좁혔지만, #119 리뷰에서 /api/projects(멤버 네임스페이스 밖)가 가드를 그대로 우회한다는 게 드러났습니다. 결국 경로를 나열하는 대신 "쓰기 메서드(POST/PUT/PATCH/DELETE)인가"로 판단 기준을 아예 바꿔서, 앞으로 생길 어떤 멤버 전용 쓰기 API도 자동으로 커버되게 했습니다.

비밀번호 변경 시 새 토큰 쌍을 즉시 재발급하는 이유 — access 토큰은 발급 시점 클레임이 굳어 있어서, 자기가 방금 비번을 바꾼 사람이 자기가 만든 가드 필터에 다시 걸려 아무것도 못 하는 상태에 빠지는 버그를 구현 중 직접 발견해서 고쳤다고 합니다.

메일 발송을 트랜잭션 밖 이벤트로 뺀 이유 — SQLite 커넥션 풀이 1개뿐이라, 구독자가 많으면 트랜잭션을 오래 쥐고 있는 동안 다른 요청이 전부 막힙니다. 그래서 상태 저장(짧은 트랜잭션)과 실제 발송(비동기 이벤트 리스너)을 분리했습니다.

배운 점 및 개선점

이번 PR 자체는 승격 병합이라 코드 diff는 크지만 "새로 짠 로직"은 없습니다. 대신 그 안에 담긴 112커밋의 흐름을 보면, 상태공간트리 기반 QA(Actor × TokenState × MustChangePassword × Endpoint 조합을 백트래킹으로 전개해 빈틈을 찾는 방식)를 여러 차례 반복 적용해 권한상승, 계약 불일치, 비번 탈취, 공개 API 오차단 같은 문제를 리뷰 단계에서 미리 잡아낸 게 인상적입니다. 특히 가드 필터 하나가 "경로 나열"에서 "메서드 기준 판단"으로 일반화되기까지 두 번의 리뷰 라운드를 거친 과정은, 처음부터 완벽한 설계보다 실제로 뚫리는 케이스를 찾아가며 축 자체를 재정의하는 게 더 안전하다는 걸 보여줍니다.

한편 PR 설명에 적힌 대로, SQLite의 ALTER TABLE UNIQUE 컬럼 추가 미지원 문제는 이번 배포에서도 재발할 가능성이 열려 있었고, 이게 바로 다음 작업(#133, Flyway 도입)으로 이어지는 이유였습니다. 스모크 테스트로 안전장치는 마련했지만 근본 해결은 다음 PR의 몫으로 남겨둔 셈입니다.