website: dev → main, 어드민 인증·멤버 API·피드·프론트 헬스체크 배포
멋사 경희대 사이트 프로젝트에서 그동안 dev 브랜치에 쌓인 어드민 인증, 멤버 API, 피드 백엔드, OCI Storage 연동, CD 안정화 작업들을 한 번에 main으로 배포한 PR입니다.
요약
PR #110은 2026-07-13 오전 5시 35분에 생성돼 11분 뒤인 5시 46분에 곧바로 병합됐습니다. dev 브랜치에서 오랫동안 쌓여온 작업들 — 어드민 인증 파운데이션(JWT), 멤버 API(목록/생성/수정), 피드(post/comment/magic-link) 백엔드, OCI Storage 연동, CD 안정화, 프론트엔드 헬스체크 API — 를 한 번에 main에 반영하는 릴리스성 PR입니다. 변경 규모만 봐도 15,419줄 추가에 136줄 삭제로, 사실상 여러 개의 작은 PR들이 dev에서 검증을 마친 뒤 이 한 번의 머지로 묶여 들어간 형태입니다. PR 본문에도 "stage에서 이미 검증된 커밋들"이라고 명시돼 있고, 남은 체크리스트는 "main 머지 후 CD 헬스체크 통과 확인" 하나뿐이었습니다.
배경 및 목적
이 PR이 다루는 범위가 넓다 보니 배경도 여러 갈래입니다.
먼저 CD 안정화. 커밋 히스토리를 보면 이 프로젝트가 OCI Ampere A1(arm64) 서버에 배포하는 과정에서 겪은 문제들이 순차적으로 등장합니다. GitHub Actions 기본 빌드가 amd64였는데 서버는 arm64라 플랫폼이 안 맞았던 문제, actuator 헬스체크가 SecurityConfig 추가 시 조용히 깨지던 문제, PostgreSQL에서 SQLite로 옮기면서 겪은 다이얼렉트·볼륨 마운트 이슈 등이 하나씩 fix 커밋으로 남아 있습니다. PR 설명에 적힌 "git pull 무음 실패 수정, 드리프트 알람, 헬스체크 로그 가시화, 배포 마커 gitignore"도 같은 맥락의 CD 안정화 작업입니다.
프론트 헬스체크는 조금 다른 이유였습니다. /api/health 엔드포인트를 새로 만든 건 UptimeRobot이 정적 캐시 때문에 실제로는 살아있는 서비스를 죽은 것처럼 오탐하던 문제를 해결하기 위해서였습니다.
어드민 인증·이메일 모듈은 문서(backend/docs/email-module.md)에 이슈 번호와 함께 맥락이 잘 남아 있습니다. #75(OCI Email Delivery 발송 기반) 위에 #74(어드민 초대·비밀번호 재설정)가 이메일 발송을 필요로 했고, #85 리뷰에서 트랜잭션 경계 문제가 지적되면서 상당한 시행착오가 있었습니다. 이 부분은 아래 기술적 의사결정에서 자세히 다룹니다.
구현 내용
어드민 인증 (JWT)
AdminAuthService가 로그인·로그아웃·리프레시·토큰 전체 무효화를 담당합니다. 눈에 띄는 부분은 로그인 실패 시 계정 잠금 로직입니다.
@Transactional(noRollbackFor = {InvalidCredentialsException.class, AccountLockedException.class})
public LoginResult login(String email, String rawPassword) {
Admin admin = adminRepository.findByEmail(email)
.orElseThrow(InvalidCredentialsException::new);
if (admin.isLocked()) {
throw new AccountLockedException();
}
if (!passwordEncoder.matches(rawPassword, admin.getPasswordHash())) {
admin.recordFailedLogin(maxAttempts, Duration.ofMinutes(lockoutDurationMinutes));
if (admin.isLocked()) {
throw new AccountLockedException();
}
throw new InvalidCredentialsException();
}
...
}
noRollbackFor를 명시적으로 걸어둔 이유가 코드 주석에 남아 있는데, 실패 로그인 시 늘어난 시도 횟수·잠금 상태가 예외로 인한 전체 롤백에 딸려서 사라지면 무차별 대입 방어 자체가 무력화되기 때문입니다. Refresh 토큰은 로테이션하지 않고 만료까지 재사용하는 방식을 택했고, 저장할 땐 원문이 아니라 SHA-256 해시로 저장합니다.
통합 예외 처리
GlobalExceptionHandler가 매직링크 토큰, 이미지 업로드, 어드민 인증 관련 예외들을 한곳에서 처리합니다. 기존 응답 형식은 success/message 두 키였는데, 어드민 인증 모듈만 프론트가 분기할 수 있도록 code 필드를 추가했습니다.
private Map<String, Object> errorBody(String message, String code) {
return Map.of("success", false, "message", message, "code", code);
}
INVALID_CREDENTIALS, ACCOUNT_LOCKED, WEAK_PASSWORD, LAST_SUPER_ADMIN 등 케이스별로 HTTP 상태 코드와 code를 짝지어 예외 핸들러를 나눴습니다.
이메일 모듈 (초대·비밀번호 재설정)
EmailService는 Thymeleaf로 HTML 메일을 렌더링해서 발송하고, 성공/실패 여부를 email_log에 남깁니다. stage 환경에서는 발신 주소·DKIM을 prod와 공유하기 때문에 제목에 [stage] 접두어를 붙여 실제 수신자가 테스트 메일과 실제 메일을 헷갈리지 않게 했습니다. 발송 전 InternetAddress.validate()로 수신 주소 형식을 검사하는 로직도 테스트를 작성하다가 발견한 실제 결함을 메꾼 것이었습니다.
피드 백엔드
FeedImageController/FeedImageService로 이미지 업로드를, MagicLinkTokenController로 매직링크 토큰 발급을 처리합니다. OCI Storage 연동(OciStorageConfig)도 이 PR에 포함되어 있습니다.
README 전면 개편
가장 눈에 띄는 비-백엔드 변경은 README입니다. 기존의 단순한 구조 설명에서 배지·표·팀 소개가 들어간 형태로 다시 썼습니다. "일하는 방식 — AI-native" 섹션에서는 PM이 미션을 발행하고 팀원이 R→P→I→Q(리서치→설계→구현→QA) 순서로 진행하는 흐름을, PM은 설계 승인·최종 검수 두 게이트에서만 개입한다는 걸 명시했습니다.
CD·인프라 안정화 (커밋 히스토리 기준)
이 PR에 포함된 dev 브랜치의 히스토리를 따라가 보면 상당히 긴 시행착오가 보입니다:
- ARM64 빌드 미스매치 → QEMU + Buildx 도입
- actuator 헬스체크가 SecurityConfig 추가로 조용히 깨지는 문제 →
EndpointRequest.to(HealthEndpoint)permitAll 명시 - PostgreSQL → SQLite 전환,
Hikari pool-size=1(SQLite single-writer 제약) - 파일 단위 볼륨 마운트 시 CANTOPEN 에러 → 디렉터리 단위 마운트로 변경
spring.jpa.database-platform이 Hibernate 6.6.x에서 안 먹혀서hibernate.dialect직접 지정- 단일
BACKEND_TAG가 stage 배포 시 prod 컨테이너까지 재생성시키는 버그 →STAGE_TAG/PROD_TAG분리 - 재부팅 후 컨테이너 자동 복구를 위해 restart 정책
on-failure:5→unless-stopped reset-stage-db.yml워크플로 추가로 인프라 담당자 없이도 stage DB 초기화 가능하게- CI에서 "임시 생략"으로 echo만 하던 빌드/테스트 스텝을 실제
./gradlew compileJava test로 전환
기술적 의사결정: 이메일 로그가 트랜잭션 롤백에 휩쓸리는 문제
이 PR에서 가장 흥미로운 기술적 씨름은 email_log 저장 위치를 둘러싼 문제였습니다. EmailService.send()는 자체 트랜잭션을 열지 않기 때문에, 호출자가 @Transactional 메서드 안에서 이 메서드를 부르면 SMTP 실패로 인한 EmailSendException이 바깥 트랜잭션까지 롤백시켜버리고, 방금 남긴 실패 로그 자체도 함께 사라지는 문제가 #85 리뷰에서 지적됐습니다.
1차 시도로 email_log 저장을 별도 빈으로 분리하고 @Transactional(propagation = REQUIRES_NEW)를 붙였습니다. 그런데 CI에서 실패했습니다. 원인은 application.yml에 SQLite의 single-writer 제약 때문에 hikari.maximum-pool-size: 1로 고정해둔 설정이었습니다. REQUIRES_NEW는 같은 스레드에서 새 커넥션을 요청하는데, 풀에 커넥션이 하나뿐이면 바깥 트랜잭션이 그걸 쥔 채 기다리고 새 트랜잭션은 반납을 기다리는 순환 대기가 생겨 타임아웃이 났습니다.
2차 시도로 활성 트랜잭션 안에서 호출되면 즉시 IllegalStateException을 던지는 가드를 넣었는데, 이건 "로그가 조용히 사라지는 버그"를 "발송 자체가 안 되는 것"으로 바꾼 것일 뿐 원래 요구사항인 "발송 결과는 무조건 남아야 한다"를 회피한 것이었습니다.
최종적으로는 이벤트 발행 + @Async 리스너 조합으로 순환 대기 자체를 끊었습니다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION, fallbackExecution = true)
void onEmailLogEvent(EmailLogEvent event) {
emailLogRepository.save(event.toEmailLog());
}
@Async를 얹으면 콜백이 별도 스레드에서 실행되므로, 원래 스레드는 콜백을 기다리지 않고 바로 커넥션을 반납합니다. 그러면 새 스레드가 커넥션을 요청할 시점엔 이미 반납돼 있어 데드락이 안 생깁니다. EmailService는 활성 트랜잭션이 없으면(기존 모든 호출 경로) 예전처럼 즉시 저장하고, 트랜잭션이 있을 때만 이 비동기 경로를 타도록 분기했습니다. 트레이드오프는 트랜잭션 안에서 호출되는 경로에 한해 로그 저장이 최종적 일관성(eventually consistent)이 된다는 점 — send()가 반환된 직후엔 아직 email_log에 안 보일 수 있어서, 재현 테스트도 즉시 assert하지 않고 짧게 폴링하는 방식으로 작성했습니다.
배운 점 및 개선점
이 PR 자체는 여러 기능 PR들을 모아 배포한 릴리스 성격이라, 개별 기능보다 "합쳐서 한 번에 배포"하는 흐름에서 배운 게 많았습니다. dev-main 동기화 충돌을 여러 차례 겪은 커밋 히스토리(chore: dev-main 충돌 해소가 두 번 등장)를 보면, 브랜치를 오래 쌓아뒀다가 한 번에 합치는 방식의 비용이 만만치 않다는 걸 보여줍니다.
기술적으로는 SQLite의 single-writer 제약이 겉보기에 멀쩡해 보이는 코드(REQUIRES_NEW)를 CI에서만 실패하게 만드는 경험이 인상적이었습니다. 로컬에서 통과하는 코드도 실제 인프라 제약(커넥션 풀 크기 1) 위에서는 다르게 동작할 수 있다는 걸 실측으로 확인한 셈입니다. 또 "가드로 예외를 막는 것"과 "원래 요구사항을 달성하는 것"은 다르다는 점도 리뷰를 통해 짚였습니다 — 증상만 없애는 수정과 진짜 문제를 푸는 수정을 구분하는 게 중요했습니다.
남은 과제로는 email-module.md에 정리된 대로 초대·재설정 HTTP 엔드포인트의 end-to-end 테스트, 발송 실패 시 재시도(멱등성 보장이 선행돼야 함), 발송을 큐로 빼는 것 등이 있습니다. CD 쪽에서도 배포 마커 gitignore, 드리프트 알람 같은 항목들이 이번에 정리됐지만 계속 다듬어야 할 여지가 있어 보입니다.