[feat] 소셜 로그인 및 회원 인증·프로필 구현 - #6
Conversation
Member에서 인증 책임을 제거해 순수 도메인으로 두고, 소셜 provider를 member.domain enum에서 auth.oauth로 옮긴다. 변경 가능한 인프라 요소 (provider 목록, 토큰 발급 방식)를 domain 밖으로 분리해 provider 추가 시 domain 코드가 바뀌지 않도록 한다. - OAuthProvider enum을 auth.oauth로 이동, 이름 기반 파싱 - OAuthClient/Resolver가 문자열 대신 enum을 사용 - JwtProvider를 JwtIssuer 인터페이스와 JjwtIssuer 구현으로 분리 - 허용 audience 검증을 AllowedAudiences 일급 컬렉션으로 추출
소셜 id_token 검증 결과로 회원을 조회·가입하고 서비스 JWT(access·refresh)를
발급한다. refresh는 회원별로 저장·회전하며 재발급 시 검증한다.
- POST /api/auth/login/{provider}: 소셜 로그인
- POST /api/auth/reissue: refresh 토큰으로 재발급
- SocialAccount로 소셜 계정과 회원을 분리해 연결
- RefreshToken 엔티티가 회전·일치 검증 규칙 보유
무상태(STATELESS) 보안 필터체인을 구성하고 Bearer 토큰을 검증하는 JWT 인증 필터를 추가한다. 인증 실패 시 공통 응답 포맷으로 401을 반환한다. - /api/auth, /swagger, /v3/api-docs 외 경로는 인증 필요 - JwtAuthenticationFilter가 토큰을 파싱해 AuthPrincipal 주입 - 미인증 요청은 JwtAuthenticationEntryPoint가 401 JSON 응답 - 보호 엔드포인트 GET /api/members/me 추가
OpenAPI 문서에 JWT Bearer 보안 스키마를 등록해 Swagger UI에서 토큰을 넣고 보호 API를 호출할 수 있게 한다. - /swagger-ui.html, /v3/api-docs 노출 - bearerAuth 보안 스키마 등록
DB 스키마를 Flyway 마이그레이션으로 관리하고, ddl-auto를 validate로 바꿔
엔티티와 스키마 정합성을 검증한다. 설정을 local·dev·prod 프로필로 분리한다.
- spring-boot-flyway 모듈 추가(Boot 4 자동설정 제공), flyway-mysql
- V1__init.sql: members·social_accounts·refresh_tokens 초기 스키마
- application.yml 공통 + application-{local,dev,prod}.yml 분리
- 테스트는 Testcontainers에서 Flyway 실행 후 스키마 검증
아직 배포 서버가 없어 dev·prod 프로필은 잠자는 스캐폴딩일 뿐이라 제거한다. 로컬 실행(local)과 테스트는 환경변수 없이 동작하며, 실제 배포 셋업 시 인프라 값에 맞춰 프로필을 다시 추가한다.
한 파일에 여러 DTO를 모으지 않고 클래스마다 개별 파일로 분리한다. - AuthDtos를 LoginRequest·ReissueRequest·TokenResponse로 분리 - 응답 래퍼 ErrorResponse를 ApiResponse에서 분리
issueTokens의 if-else 분기를 persistRefreshToken 함수로 추출하고 guard clause로 평탄화한다.
부트스트랩 진입점을 커버리지에서 제외하고, 미커버 분기·게터를 검증하는 테스트를 추가한다. OAuth 클라이언트는 검증기를 주입받도록 바꿔 위임 동작을 단위 테스트로 검증한다. - jacoco에서 GamssApplication 제외 - Google·AppleOAuthClient에 OidcTokenVerifier 주입 생성자 추가 - OidcTokenVerifier의 subject 존재 검증을 guard로 일원화 - 엔티티 게터·빈 토큰·비Bearer 헤더·OAuthProperties 기본값 테스트 추가
닉네임을 값 객체(Nickname)로 분리해 공백 불가·최대 20자 불변식을 스스로 보장하게 하고, 회원이 닉네임을 수정할 수 있게 한다. - Nickname 값 객체(@embeddable, 검증 포함) 추가 - Member에 nickname 필드와 updateNickname 추가 - PATCH /api/members/me/nickname 엔드포인트 - V1 스키마에 nickname 컬럼 추가(배포 전이라 마이그레이션 통합)
JPA Auditing으로 updatedAt을 자동 관리하고, 회원 탈퇴를 소프트 삭제로 구현한다. 탈퇴 회원은 로그인·재발급이 차단된다. - JPA Auditing 적용(createdAt·updatedAt @CreatedDate/@LastModifiedDate) - MemberStatus(ACTIVE/WITHDRAWN)와 deletedAt 추가, Member.withdraw() - DELETE /api/members/me 탈퇴 엔드포인트(소프트 삭제) - 탈퇴 회원 로그인·재발급 시 WITHDRAWN_MEMBER, 중복 탈퇴 시 ALREADY_WITHDRAWN - V1 스키마에 status·updated_at·deleted_at 컬럼 추가
GET /me 응답에 가입일과 상태를 추가하고, 닉네임 값 객체에 공백 정리· 최소 길이·금칙어 규칙을 더한다. - MemberResponse에 status·createdAt 추가 - Nickname: 앞뒤 공백 trim, 최소 2자, 금칙어 필터(INVALID_NICKNAME) - 닉네임 길이·금칙어 규칙을 Nickname 값 객체로 일원화(DTO는 구조 검증만)
init 블록이 커지지 않도록 검증을 validate로 위임하고 길이·금칙어 검사를 private 함수로 추출한다.
관용 컨벤션(공개 API → private → Any 오버라이드 → companion)에 맞춰 private 헬퍼 위치를 정리하고, 긴 함수를 분리한다. - OidcTokenVerifier.verify의 파싱 로직을 parseClaims로 분리 - 테스트의 private 헬퍼를 클래스 하단으로 이동
spring-boot-docker-compose(developmentOnly)를 추가해 앱 실행 시 MySQL 컨테이너를 자동으로 띄우고 종료 시 함께 정리한다. 배포 산출물에는 포함되지 않는다.
각 API에 한글 태그·요약·설명과 요청/응답 필드 설명·예시를 추가해 Swagger UI 가독성을 높인다. - 컨트롤러에 @tag(인증·회원)와 @operation 요약·설명 추가 - provider 파라미터, 요청/응답 DTO에 @Schema 설명·예시 추가 - OpenAPI 문서 설명 문구 조정
kite707
left a comment
There was a problem hiding this comment.
현재 만드는 JWT 페이로드는 validity만 다를 뿐 완전히 동일합니다 (subject, issuedAt, expiration뿐, 토큰 종류를 나타내는 클레임이 없음). JwtAuthenticationFilter도 들어온 Bearer 토큰이 access인지 refresh인지 구분 없이 parseMemberId로 파싱만 되면 인증 처리하기 때문에 refreshToken으로도 인증이 필요한 api가 성공하고 있어요.
JwtIssuer 인터페이스에 토큰 종류를 구분하는 클레임("type": "access" / "type": "refresh")을 추가하고, 파싱 함수도 발급 함수와 대칭되게 분리하는건 어떨까요?
access·refresh 를 같은 build() 로 만들면서 유효기간만 다르게 줬기 때문에 발급된 두 토큰이 만료 시각 외에는 구별되지 않았다. JwtAuthenticationFilter 는 parseMemberId 가 성공하면 인증을 채우므로, refresh 토큰을 Bearer 로 보내도 보호된 API 가 호출됐다. 탈퇴 처리에도 영향이 있었다. login·reissue 에는 탈퇴 검사가 있지만 필터는 DB 를 보지 않아, 탈퇴 회원이 refresh 토큰을 Bearer 로 쓰면 그대로 통과했다. 의도한 잔존 기간은 access 만료까지의 1시간이었으나 실제로는 refresh 만료인 14일이었다. 토큰에 종류 클레임을 싣고, parseMemberId 를 parseAccessToken·parseRefreshToken 으로 나눠 발급과 대칭이 되게 한다. 파싱이 하나면 호출부가 종류를 확인할 방법 자체가 없어 같은 실수가 반복된다. reissue 에 access 토큰을 쓰는 반대 방향 구멍도 함께 막힌다. 기존 JjwtIssuerTest 의 '리프레시 토큰도 memberId를 파싱한다' 는 이 버그를 정상 동작으로 못박고 있어 교체한다. 커버리지가 98%였음에도 발견되지 않은 이유다.
d1cebd4 to
4431054
Compare
theminjunchoi
left a comment
There was a problem hiding this comment.
제안해준 두가지 그대로 반영했습니다!
이전에 발급된 Type이 없는 토큰의 경우까지도 잘 처리해주신 것 같습니다! 테스트코드도 여러가지 경우를 꼼꼼히 짜주셨네요👍 |
처음 로그인하는 소셜 계정으로 요청이 동시에 오면 두 요청 모두 계정이 없다고 보고 회원을 생성해, 뒤늦은 하나가 (provider, providerId) 유니크 제약에 걸려 500을 반환했다. 사용자가 로그인 버튼을 연속으로 눌러 요청이 겹치면 재현된다. login 트랜잭션 안에서는 회복할 수 없다. 제약 위반으로 트랜잭션이 rollback-only 가 되어 REQUIRES_NEW로 재조회를 분리해도 바깥이 커밋에 실패한다. 그래서 트랜잭션 경계 바깥의 AuthFacade 에서 한 번만 재시도한다. 재시도 때는 앞선 요청이 커밋한 소셜 계정이 조회돼 기존 회원으로 로그인된다. login 을 별도 빈(AuthService)에 두는 것은 자기 호출이 트랜잭션 프록시를 거치지 않아 새 트랜잭션으로 시작되지 않기 때문이다. 소셜 계정과 리프레시 토큰을 한 트랜잭션으로 커밋하므로, 재시도 시 리프레시 토큰의 유니크 충돌 (uk_refresh_member)도 함께 해소된다 — 이미 커밋된 토큰을 회전하게 된다. 8개 스레드가 같은 계정으로 동시 로그인하는 통합 테스트로 검증했다. 재시도를 제거하면 이 테스트가 실패하는 것을 확인했다.
동시 로그인 재시도를 AuthFacade 에서 인라인으로 처리하던 것을, 책임별로 나눈다. 컨트롤러는 AuthService 하나만 호출한다. - AuthService: 흐름 조율만. 재시도는 ConflictRetry 에, 트랜잭션 작업은 LoginService 에 위임한다. 영속성 예외 타입에 더는 의존하지 않는다. - LoginService: 로그인·재발급의 트랜잭션 작업 단위(소셜 검증·회원 확보·토큰 발급을 한 트랜잭션으로). 기존 AuthService 로직을 그대로 옮겼다. - ConflictRetry: 재시도 정책만 담당. 무엇을 재시도할지는 호출부가 넘긴다. - SocialAccountService: 유니크 제약 위반(DataIntegrityViolationException)을 ConcurrentRegistrationException 으로 번역해, 재시도 판단이 영속성 기술에 의존하지 않게 한다. 소셜 계정과 리프레시 토큰을 한 트랜잭션으로 커밋하므로 충돌 지점이 소셜 계정 하나뿐이라 1회 재시도로 충분하다. 8스레드 동시 로그인 통합 테스트로 검증했고, 재시도를 제거하면 실패하는 것도 확인했다.
AllowedAudiences 가 허용 목록이 비면 모든 audience 를 통과시켜(fail-open), client-ids 설정을 빠뜨린 채 배포하면 aud 검증이 조용히 꺼진 상태로 운영될 수 있었다. 어떤 소셜 앱의 토큰이든 로그인이 되는 셈이다. - AllowedAudiences: 목록이 비면 전부 거부한다(fail-closed). 설정 누락이 보안을 끄는 게 아니라 로그인을 막아 드러나게 한다. - OAuthClientIdsValidator: dev/prod 기동 시 client-ids 가 비면 부팅을 실패시킨다. 런타임에 "모든 로그인 실패"로 발견하는 대신 배포 시점에 "서버가 안 뜸"으로 더 빨리 잡는다. local/test 는 설정 없이 뜰 수 있게 둔다. fail-open 동작을 정답으로 고정하던 테스트는 fail-closed 검증으로 교체했다.
|
전체적으로 정말 꼼꼼하게 잘 만드신 것 같습니다. Flyway + docker-compose + Testcontainers를 조합해서 로컬·테스트·배포 환경이 전부 실제 MySQL을 쓰게 초기 세팅을 친절하게 잘 해주신 것 같습니다. 테스트코드도 꼼꼼하게 짜주셨고, PR 설명에 작업한 내용을 상세히 적어주셔서 리뷰하기 좋았습니다. 작업량이 많은데 작업하느라 고생 많으셨습니다!!👍 저도 얼른 작업해서 올릴게요! |
RefreshToken.token 에 서명된 JWT 원본을 그대로 저장하고 있어, DB가 유출되면 그 값을 그대로 재발급 요청에 써서 새 토큰을 받을 수 있었다. 유출된 값 자체가 유효하게 서명된 토큰이라 재사용을 막지 못한다. 비밀번호처럼 해시해서 저장한다. 원본은 클라이언트에만 주고 DB에는 해시만 남긴다. 재발급 시 제시된 토큰을 같은 방식으로 해시해 비교한다. - TokenHasher 인터페이스 + Sha256TokenHasher: 해싱 방식을 JwtIssuer 처럼 추상화한다. 리프레시 토큰은 서명된 JWT라 이미 고엔트로피이므로 비밀번호용 느린 해시가 아니라 SHA-256 으로 충분하다(bcrypt 는 72바이트 입력 제한도 있다). - 해싱은 저장 시점(persistRefreshToken)에 서비스가 수행한다. RefreshToken 도메인은 불투명한 문자열을 저장·비교만 하고 해시를 알지 못한다.
kite707
left a comment
There was a problem hiding this comment.
RefreshToken 해싱 커밋 확인했습니다. TokenHasher를 JwtIssuer처럼 분리해두신 것도 좋았고, 커밋 메시지에 남겨주신 이유들 덕분에 저도 많이 배웠습니다. bcrypt가 72바이트 넘는 입력은 무시한다는 것도 몰랐고, MessageDigest가 Thread Safe 않아서 호출마다 새로 생성해야 한다는 것도 이번에 처음 알았네요. 덕분에 저도 공부가 많이 됐습니다.
고생하셨습니다!
🔗 연관 이슈
📌 개요
구글·애플 소셜 로그인과 JWT 인증을 구현하고, 회원(프로필·탈퇴) 기반을 함께 마련했습니다. 앱이 소셜 SDK로 받은 id_token을 서버가 검증해 회원을 조회·가입시키고 서비스 토큰(access·refresh)을 발급합니다.
🔧 주요 변경사항
인증
POST /api/auth/login/{provider}(google·apple), 토큰 재발급POST /api/auth/reissue회원
GET /api/members/mePATCH /api/members/me/nickname(닉네임은 검증 포함 값 객체)DELETE /api/members/me(소프트 삭제 — status·deletedAt 마킹). 탈퇴 회원은 로그인·재발급 차단인프라·구조
swagger 모습
✅ 리뷰 반영
리뷰에서 짚어주신 보안·동시성 이슈를 반영했습니다. 각 항목은 재현 테스트로 확인했고, 재현 코드에서 수정을 되돌리면 테스트가 실패하는 것도 검증했습니다.
parseAccessToken/parseRefreshToken으로 분리했습니다. 탈퇴 후 잔존 기간이 의도한 1시간에서 refresh 만료인 14일로 벌어지던 것도 함께 해소됩니다.TokenHasher(SHA-256) 추상화를 도입해 저장 시점에 해시합니다. 원본은 클라이언트에만 주고 DB에는 해시만 남깁니다.🌐 API · DB 영향
/api/auth/login/{provider},/api/auth/reissue,/api/members/me(GET·PATCH·DELETE) 신규V1__init.sql—members(email·nickname·status·created_at·updated_at·deleted_at),social_accounts,refresh_tokens💬 리뷰 포인트
Member.email·nickname은 소셜 로그인 제약(Apple 이메일 미제공, 닉네임 후설정)상 의도적으로 nullable입니다.