Skip to content

Conversation

@juoklee
Copy link

@juoklee juoklee commented Feb 5, 2026

📌 Summary

  • 배경: 회원 관리 기능(가입/조회/비밀번호 수정)을 TDD로 구현하면서 설계 결정과 테스트 전략에 대한 고민 필요
  • 목표:
    • claude 을 나의 스타일에 맞게 설정.
    • Layered Architecture + CQRS 스타일로 테스트 가능한 구조 설계
  • 결과: 3개 API 구현 완료, E2E + 단위 테스트 병행으로 검증, 도메인 로직은 엔티티에 위치하여 캡슐화 유지

🧭 Context & Decision

선택지와 결정

1. 왜 User가 아니라 Member인가?

문제: 엔티티 이름을 User로 할지 Member로 할지

이름 장점 단점
User 일반적, 익숙함 Spring Security User와 충돌, 너무 포괄적
Member 도메인 용어와 일치 ("회원") 덜 익숙할 수 있음

결정: Member 사용

이유:

  • 도메인 용어: "회원가입", "회원 정보" 등 비즈니스 용어와 일치
  • 충돌 방지: org.springframework.security.core.userdetails.User와 import 충돌 없음
  • 명확한 의미: User는 "사용자" (시스템 관점), Member는 "회원" (비즈니스 관점)

2. TDD 접근법 선택

문제: TDD를 어떤 방향으로 진행할 것인가?

방식 설명
Outside-In E2E → Controller → Service → Domain
Inside-Out Domain → Service → Controller → E2E

결정: Inside-Out 방식 채택

MemberTest → MemberServiceTest → MemberV1ApiE2ETest

이유: 의존성이 없는 도메인부터 견고하게 만들 수 있고, Mock/Stub 사용 최소화


3. 비밀번호 변경 로직 위치

문제: changePassword()를 Member 엔티티 vs MemberService 어디에 둘 것인가?

기준 엔티티 서비스
캡슐화 ✅ setter 노출 안 함 ❌ setter 필요
일관성 Member.create()와 동일 패턴 다른 패턴
의존성 encoder 파라미터 전달 서비스가 주입

결정: 엔티티에 위치 (Rich Domain Model)

트레이드오프: encoder를 파라미터로 넘겨야 하지만, Member.create()와 일관성 유지


4. Facade 필요성 의문

문제: Facade가 단순 위임만 하는데 필요한가?

// 현재 - 너무 단순해 보임
public MemberInfo register(...) {
    Member member = memberService.register(...);
    return MemberInfo.from(member);
}

결정: 유지

이유:

  • 현재는 단순하지만 일관된 아키텍처 유지
  • 복잡한 Use Case 생기면 여러 Service 조합 가능
  • Controller가 Domain 객체 직접 노출하는 것 방지

5. PasswordEncoder 인터페이스 분리

문제: Spring Security의 PasswordEncoder를 직접 쓰면 안 되나?

결정: Domain 레이어에 커스텀 인터페이스 정의

❌ Domain → Spring Security (외부 의존)
✅ Domain ← Infrastructure (의존성 역전)

이유: 도메인을 순수 Java로 유지 → Stub으로 쉽게 테스트 가능


6. Reader/Repository 분리 (CQRS 스타일)

문제: 일반적인 JPA 방식처럼 하나의 Repository에서 조회/저장을 모두 처리할지, 분리할지

결정: 조회(Reader)와 저장(Repository)으로 분리

이유:

  • 테스트 용이: 필요한 인터페이스만 Fake로 구현 가능
  • 의존성 명확: 생성자만 봐도 조회만 하는지, 저장도 하는지 알 수 있음
  • CQRS 확장 가능: 나중에 읽기/쓰기 DB 분리 시 구현체만 교체

트레이드오프: 현재 규모에서는 오버엔지니어링처럼 보일 수 있지만, 테스트 작성 편의성과 의존성 명확화를 위해 채택

🏗️ Design Overview

  • 영향 받는 모듈/도메인: apps/commerce-api - member 도메인
  • 신규 추가:
    • Domain: Member, MemberService, MemberReader, MemberRepository, PasswordEncoder
    • Infrastructure: MemberJpaRepository, MemberReaderImpl, MemberRepositoryImpl, PasswordEncoderImpl
    • Application: MemberFacade, MemberInfo
    • Interfaces: MemberV1Controller, MemberV1Dto
    • Support: MemberAuthFilter, SecurityConfig
    • Test: MemberTest, MemberServiceTest, MemberV1ApiE2ETest
    • API Test: http/commerce-api/member-v1.http
  • 수정:
    • apps/commerce-api/build.gradle.kts - Spring Security 의존성 추가
  • 리팩토링:
    • MemberModelMember로 rename (더 명확한 네이밍)

핵심 구조

interfaces/api/member/
├── MemberV1Controller.java     # REST API 엔드포인트
└── MemberV1Dto.java            # Request/Response DTO

application/member/
├── MemberFacade.java           # Use Case 조율
└── MemberInfo.java             # 도메인 → 애플리케이션 변환 객체

domain/member/
├── Member.java                 # 핵심 엔티티
├── MemberService.java          # 도메인 서비스
├── MemberReader.java           # 조회 인터페이스
├── MemberRepository.java       # 저장 인터페이스
└── PasswordEncoder.java        # 암호화 인터페이스

infrastructure/member/
├── MemberJpaRepository.java    # Spring Data JPA
├── MemberReaderImpl.java       # Reader 구현체
├── MemberRepositoryImpl.java   # Repository 구현체
└── PasswordEncoderImpl.java    # BCrypt 구현체

support/
├── auth/MemberAuthFilter.java  # 인증 필터
└── config/SecurityConfig.java  # Spring Security 설정

주요 컴포넌트 책임

레이어 컴포넌트 책임
Interfaces MemberV1Controller HTTP 요청/응답 처리, DTO 변환
MemberV1Dto Request/Response 정의 (Java Record)
Application MemberFacade Service 호출 + MemberInfo 변환
MemberInfo 도메인 객체 → 애플리케이션 객체 변환, 이름 마스킹
Domain Member 엔티티 + 검증 로직 + 비밀번호 변경 (Rich Domain Model)
MemberService 중복 체크 + Member 생성/저장 조율
MemberReader 조회 전용 인터페이스 (existsByLoginId, findByLoginId)
MemberRepository 저장 전용 인터페이스 (save)
PasswordEncoder 암호화 인터페이스 (encode, matches)
Infrastructure MemberJpaRepository Spring Data JPA 인터페이스
MemberReaderImpl MemberReader 구현체
MemberRepositoryImpl MemberRepository 구현체
PasswordEncoderImpl BCrypt 기반 암호화 구현체
Support MemberAuthFilter 헤더 인증 (X-Loopers-LoginId/Pw)
SecurityConfig Spring Security 설정 (CSRF 비활성화 등)

🔁 Flow Diagram

Main Flow

회원가입 흐름

sequenceDiagram
    autonumber
    participant Client
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade
    participant Service as MemberService
    participant Reader as MemberReader
    participant Member
    participant Repository as MemberRepository

    Client->>Controller: POST /api/v1/members
    Controller->>Facade: register(loginId, password, ...)
    Facade->>Service: register(loginId, password, ...)
    Service->>Reader: existsByLoginId(loginId)
    Reader-->>Service: false
    Service->>Member: Member.create(loginId, password, ..., encoder)
    Note over Member: 정적 팩토리 메서드에서<br/>모든 검증 수행
    Member-->>Service: member
    Service->>Repository: save(member)
    Repository-->>Service: member
    Service-->>Facade: member
    Facade->>Facade: MemberInfo.from(member)
    Facade-->>Controller: MemberInfo
    Controller-->>Client: 201 Created + MemberResponse
Loading

비밀번호 변경 흐름

sequenceDiagram
    autonumber
    participant Client
    participant Filter as MemberAuthFilter
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade
    participant Service as MemberService
    participant Member
    participant Repository as MemberRepository

    Client->>Filter: PATCH /api/v1/members/me/password<br/>+ X-Loopers-LoginId/Pw 헤더
    Filter->>Filter: 인증 검증
    Filter->>Controller: request.setAttribute("authenticatedMember", member)
    Controller->>Facade: changePassword(member, currentPw, newPw)
    Facade->>Service: changePassword(member, currentPw, newPw)
    Service->>Member: member.changePassword(currentPw, newPw, encoder)
    Note over Member: 1. 현재 비밀번호 일치 확인<br/>2. 새 비밀번호 ≠ 현재<br/>3. 새 비밀번호 규칙 검증
    Member-->>Service: void (상태 변경됨)
    Service->>Repository: save(member)
    Repository-->>Service: member
    Service-->>Facade: void
    Facade-->>Controller: void
    Controller-->>Client: 200 OK
Loading

내 정보 조회 흐름

sequenceDiagram
    autonumber
    participant Client
    participant Filter as MemberAuthFilter
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade

    Client->>Filter: GET /api/v1/members/me<br/>+ X-Loopers-LoginId/Pw 헤더
    Filter->>Filter: 인증 검증 (Reader + PasswordEncoder)
    Filter->>Controller: request.setAttribute("authenticatedMember", member)
    Controller->>Facade: getMe(authenticatedMember)
    Facade->>Facade: MemberInfo.fromWithMaskedName(member)
    Note over Facade: 이름 마스킹 처리<br/>"홍길동" → "홍길*"
    Facade-->>Controller: MemberInfo (마스킹된 이름)
    Controller-->>Client: 200 OK + MemberResponse
Loading

변경 목적

Spring Security 기반 회원 관리 시스템 구축: 회원가입, 내 정보 조회, 비밀번호 수정 3가지 기능 구현

핵심 변경점

도메인: Member 엔티티에 포괄적 검증 로직 적용 (로그인ID 영숫자만, 비밀번호 8~16자 및 생년월일 미포함, 이름 한글/영문만, 이메일 형식) 및 연속된 공백 정규화 replaceAll("\\s+", " "). 보안: BCryptPasswordEncoder 기반 비밀번호 암호화, 커스텀 X-Loopers-LoginId/X-Loopers-LoginPw 헤더 인증 필터 추가. 아키텍처: 도메인 PasswordEncoder 인터페이스로 Spring Security 의존성 역전, DDD 패턴(MemberReader/Repository 인터페이스), MemberFacade 계층 도입.

리스크/주의사항

인증 방식: 커스텀 헤더 기반 인증은 표준 방식(Basic/Bearer/JWT)이 아니므로, 향후 표준화 계획 여부 확인 필요. 보안 설정: SecurityConfig에서 모든 HTTP 요청 허용(anyRequest().permitAll()) 및 CSRF 비활성화로 개발 초기 단계로 보이며, 운영 환경 전 세분화 필요. 생년월일 검증: 비밀번호에 생년월일 포함 여부를 birthDate.toString().replace("-", "")로 체크하므로, 향후 형식 변경 시 영향 범위 검토 필요.

테스트/검증

유닛 테스트: Member/MemberService 테스트에서 검증 규칙 및 changePassword 로직 검증 (FakeMemberReader/Repository 사용, 122개 라인). E2E 테스트: MemberV1ApiE2ETest로 3개 API(회원가입, 내 정보 조회, 비밀번호 수정)의 9개 시나리오 검증, DatabaseCleanUp으로 테스트 간 데이터 격리. 문서: http/commerce-api/member-v1.http에서 정상/오류 요청 예시 제공.

@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review
📝 Walkthrough

Walkthrough

도메인, 인프라, 애플리케이션, 인터페이스 계층에 걸쳐 멤버 관리 기능을 전체 구현했다. 회원가입, 비밀번호 변경, 인증 기능을 포함하며, 커스텀 필터 기반 인증과 Spring Security 설정이 적용되었다.

Changes

Cohort / File(s) Summary
빌드 설정
apps/commerce-api/build.gradle.kts
Spring Boot Security 스타터 의존성 추가
도메인 엔티티 및 인터페이스
apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java, MemberRepository.java, MemberReader.java, PasswordEncoder.java
Member JPA 엔티티 및 검증 로직, 리포지토리/리더 인터페이스, 비밀번호 인코딩 계약 정의
도메인 서비스
apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java
회원가입 및 비밀번호 변경 비즈니스 로직 구현
인프라 계층
apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java, MemberReaderImpl.java, MemberRepositoryImpl.java, PasswordEncoderImpl.java
Spring Data JPA 리포지토리 및 도메인 인터페이스 구현체
애플리케이션 계층
apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java, MemberInfo.java
멤버 파일더 및 DTO로 회원 조회, 가입, 비밀번호 변경 노출
API 계층
apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java, MemberV1Dto.java
REST 엔드포인트 정의 (가입, 내정보 조회, 비밀번호 변경)
보안 설정
apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java, apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java
커스텀 인증 필터 및 Spring Security 필터 체인 설정
테스트 - 도메인
apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java, MemberServiceTest.java
엔티티 검증, 서비스 비즈니스 로직 단위 테스트
테스트 - E2E
apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java
API 엔드포인트 통합 테스트
HTTP 클라이언트
http/commerce-api/member-v1.http
API 사용 예시 및 테스트 시나리오

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade
    participant Service as MemberService
    participant Member as Member
    participant Encoder as PasswordEncoder
    participant Repo as MemberRepository
    participant DB as Database
    
    Client->>Controller: POST /api/v1/members<br/>(loginId, password, ...)
    Controller->>Facade: register(loginId, rawPassword, name, ...)
    Facade->>Service: register(...)
    Service->>Service: checkExisting(loginId)
    Service->>Member: create(..., encoder)
    Member->>Encoder: encode(rawPassword)
    Encoder-->>Member: encodedPassword
    Member->>Member: validate(loginId, name, email)
    Member-->>Service: member
    Service->>Repo: save(member)
    Repo->>DB: INSERT
    DB-->>Repo: memberWithId
    Repo-->>Service: memberWithId
    Service-->>Facade: member
    Facade->>Facade: toMemberInfo(member)
    Facade-->>Controller: memberInfo
    Controller-->>Client: 201 Created<br/>MemberResponse
Loading
sequenceDiagram
    participant Client
    participant Filter as MemberAuthFilter
    participant Reader as MemberReader
    participant DB as Database
    participant Encoder as PasswordEncoder
    participant Controller as MemberV1Controller
    
    Client->>Filter: GET /api/v1/members/me<br/>X-Loopers-LoginId<br/>X-Loopers-LoginPw
    Filter->>Filter: extractHeaders(request)
    Filter->>Reader: findByLoginId(loginId)
    Reader->>DB: SELECT * FROM member
    DB-->>Reader: member
    Reader-->>Filter: optional(member)
    Filter->>Encoder: matches(rawPassword, encoded)
    Encoder-->>Filter: boolean
    Filter->>Filter: request.setAttribute<br/>("authenticatedMember", member)
    Filter-->>Controller: proceed with member
    Controller->>Controller: getMe(authenticatedMember)
    Controller-->>Client: 200 OK<br/>MemberResponse
Loading
sequenceDiagram
    participant Client
    participant Filter as MemberAuthFilter
    participant Controller as MemberV1Controller
    participant Facade as MemberFacade
    participant Service as MemberService
    participant Member as Member
    participant Encoder as PasswordEncoder
    participant Repo as MemberRepository
    
    Client->>Filter: PATCH /api/v1/members/me/password<br/>Auth headers
    Filter->>Filter: authenticate & setMember
    Filter-->>Controller: request with member
    Controller->>Facade: changePassword(member,<br/>currentPassword, newPassword)
    Facade->>Service: changePassword(member, ...)
    Service->>Member: changePassword(currentPw, newPw, encoder)
    Member->>Encoder: matches(currentPw, encodedPw)
    Encoder-->>Member: matches result
    Member->>Member: validate newPassword<br/>!= currentPassword
    Member->>Member: validate newPassword rules
    Member->>Encoder: encode(newPassword)
    Encoder-->>Member: newEncodedPassword
    Member->>Member: update password field
    Member-->>Service: success
    Service->>Repo: save(member)
    Repo-->>Service: updated member
    Service-->>Facade: void
    Facade-->>Controller: void
    Controller-->>Client: 200 OK
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

비밀번호 검증 규칙, 회원정보 정규화, 커스텀 인증 필터 로직, 도메인 엔티티 설계가 복잡하고, 1,000줄 이상의 새 코드가 여러 계층에 분산되어 있다. 보안과 데이터 무결성 관점에서 꼼꼼한 검토가 필요하다.


운영 관점 검토 의견

인증 필터 오류 응답 표준화 필요

MemberAuthFilter에서 인증 실패 시 401 응답만 반환하는데, 실패 사유(누락된 헤더, 사용자 미존재, 비밀번호 오류)를 구분하지 않는다. 운영 관점에서 디버깅 및 모니터링이 어려우므로, ErrorType이나 오류 코드를 서로 다르게 반환하여 실패 원인을 식별할 수 있게 수정하는 것을 권장한다. 추가 테스트로 각 실패 경로에 대한 별도의 E2E 테스트 케이스를 확대해야 한다.

비밀번호 인코딩 검증 로직의 일관성

MemberAuthFilter와 Member의 changePassword 메서드 모두에서 PasswordEncoder.matches()를 호출하는데, 이 로직이 중복되어 있다. 향후 검증 규칙이 변경될 때 일관성 유지가 어려우므로, 별도의 도메인 서비스 메서드(예: MemberService.authenticate)로 추상화하는 것을 권장한다.

테스트 커버리지 - 경계 케이스 및 성능

MemberTest와 MemberServiceTest는 정상/예외 경로를 충분히 커버하고 있으나, 다음 추가 테스트를 고려하면 좋다:

  • 매우 긴 비밀번호(16자 이상), 특수문자 경계값 테스트
  • 동시에 동일 loginId로 가입 시 race condition 테스트 (MemberRepository 또는 DB 레벨 unique constraint 확인)
  • 대량 회원 조회 시 성능 테스트 (MemberJpaRepository의 조회 쿼리 최적화 필요 여부 검토)
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 회원 관리 기능(가입, 조회, 비밀번호 수정)의 구현을 명확하게 요약하고 있으며, 변경 사항의 주요 포인트를 정확하게 전달한다.
Description check ✅ Passed PR 설명이 템플릿의 모든 필수 섹션을 포함하고 있으며, 설계 결정사항이 명확하고 다이어그램으로 뒷받침되고 있다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@juoklee
Copy link
Author

juoklee commented Feb 5, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java`:
- Around line 32-81: The constructor factory Member.create and its validators
(validatePassword, validateLoginId, normalizeName, validateName, validateEmail)
lack null/blank checks causing NPEs; add guard clauses at the start of create to
validate non-null and non-blank loginId, rawPassword, name, email and non-null
birthDate and throw CoreException(ErrorType.BAD_REQUEST, "...") with appropriate
messages, and update validatePassword to explicitly check birthDate != null
before using it; ensure name is trimmed/blank-checked before normalizeName and
keep Member immutable (defensive copy if needed) and add tests asserting
400/CoreException for null or blank required fields.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`:
- Around line 18-25: Add a database-unique constraint on the Member entity's
loginId by annotating the loginId field (Member.loginId) with `@Column`(unique =
true); update MemberService.register to wrap the memberRepository.save(member)
call in a try/catch that catches DataIntegrityViolationException and throws new
CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 로그인ID입니다."); and add an
integration/concurrency test that fires concurrent register requests using the
same loginId and asserts that exactly one request succeeds while the others
receive CoreException/ErrorType.BAD_REQUEST responses.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java`:
- Around line 8-10: The Member repository queries (existsByLoginId and
findByLoginId in MemberJpaRepository) will cause full table scans unless the
Member.loginId column has a uniqueness constraint and/or an index; update the
Member entity by adding either `@Column`(unique = true) on the loginId field or
add a class-level `@Table`(indexes = {`@Index`(columnList = "login_id")}) so the
database enforces uniqueness and creates an index to speed up existsByLoginId
and findByLoginId lookups.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java`:
- Around line 40-57: The controller assumes
request.getAttribute("authenticatedMember") is a Member and casts it directly in
getMe and changePassword, which can throw a ClassCastException/produce a 500
when the attribute is missing or invalid; update ErrorType to add an
UNAUTHORIZED entry (HttpStatus.UNAUTHORIZED) and then in MemberV1Controller
check the attribute returned by request.getAttribute("authenticatedMember")
before casting — if it's null or not an instance of Member throw a CoreException
with ErrorType.UNAUTHORIZED so the API returns a 401 standard error response;
apply this check in both getMe and changePassword (and any other handlers using
request.getAttribute("authenticatedMember")) and add tests that call /me and
/me/password without auth to assert 401 and the standard error body.

In
`@apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java`:
- Around line 64-75: The requiresAuthentication method currently uses
request.getRequestURI(), which fails when a context path is present; change it
to use request.getServletPath() for path comparisons, keep the POST exact-match
exception for "/api/v1/members" (only skip auth when method=="POST" &&
servletPath.equals("/api/v1/members")), and retain the authentication
requirement for any servletPath startingWith "/api/v1/members/"; update tests to
assert POST /api/v1/members is allowed and GET /api/v1/members is authenticated
in a context-path-enabled environment.
🧹 Nitpick comments (3)
apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java (2)

34-79: 도메인 유효성 검증 실패 케이스 테스트가 누락되어 있다.

현재 테스트는 중복 loginId와 정상 가입만 검증한다. 도메인 계층에서 수행되는 비밀번호 규칙 위반, 이메일 형식 오류, 이름 유효성 검증 실패 케이스를 단위 테스트에 추가하면, E2E 테스트보다 빠른 피드백을 얻을 수 있다. 장애 발생 시 원인 파악이 용이해진다.


94-98: FakeMemberReader.findByLoginId가 항상 Optional.empty()를 반환한다.

비밀번호 변경 기능 테스트 시 해당 Fake 구현체를 재사용하려면, 저장된 Member를 조회할 수 있도록 확장이 필요하다. 현재 구조에서는 비밀번호 변경 단위 테스트를 작성하기 어렵다.

♻️ 개선안
 static class FakeMemberReader implements MemberReader {
-    private final Map<String, Boolean> existingLoginIds = new HashMap<>();
+    private final Map<String, Member> members = new HashMap<>();

     void addExistingLoginId(String loginId) {
-        existingLoginIds.put(loginId, true);
+        members.put(loginId, null); // existence only
+    }
+
+    void addMember(Member member) {
+        members.put(member.getLoginId(), member);
     }

     `@Override`
     public boolean existsByLoginId(String loginId) {
-        return existingLoginIds.containsKey(loginId);
+        return members.containsKey(loginId);
     }

     `@Override`
     public Optional<Member> findByLoginId(String loginId) {
-        return Optional.empty();
+        return Optional.ofNullable(members.get(loginId));
     }
 }
apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java (1)

19-25: 무상태 인증인데 기본 세션이 생성될 수 있어 운영 비용이 늘 수 있다

현재 설정은 HttpSession 기반 SecurityContext 저장소가 활성화된 상태라 익명 인증도 세션에 저장되어 JSESSIONID가 발급될 가능성이 있고, 이는 메모리 사용 및 세션 복제 비용 증가로 이어질 수 있다.
수정안으로 무상태 세션 정책과 기본 로그인 기능 비활성화를 추가하는 것이 안전하다.
추가 테스트로 /api/v1/members/me 호출 후 Set-Cookie(JSESSIONID) 헤더 부재와 기존 인증 플로우 정상 동작을 E2E로 검증하는 것을 권장한다.

🔧 수정안 예시
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
 import org.springframework.security.web.SecurityFilterChain;
 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

 `@RequiredArgsConstructor`
 `@Configuration`
 `@EnableWebSecurity`
 public class SecurityConfig {
@@
     `@Bean`
     public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
         return http
             .csrf(csrf -> csrf.disable())
+            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+            .formLogin(form -> form.disable())
+            .httpBasic(basic -> basic.disable())
             .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
             .addFilterBefore(memberAuthFilter, UsernamePasswordAuthenticationFilter.class)
             .build();
     }
 }

Comment on lines 32 to 81
public static Member create(String loginId, String rawPassword,
String name, LocalDate birthDate,
String email, PasswordEncoder encoder) {
validateLoginId(loginId);
validatePassword(rawPassword, birthDate);
String normalizedName = normalizeName(name);
validateName(normalizedName);
validateEmail(email);

String encodedPassword = encoder.encode(rawPassword);
return new Member(loginId, encodedPassword, normalizedName, birthDate, email);
}

private static void validatePassword(String password, LocalDate birthDate) {
if (password.length() < 8 || password.length() > 16) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다.");
}
if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$")) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 허용됩니다.");
}
String birthDateStr = birthDate.toString().replace("-", ""); // 19900115
if (password.contains(birthDateStr)) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다.");
}
}

private static void validateLoginId(String loginId) {
if (!loginId.matches("^[a-zA-Z0-9]+$")) {
throw new CoreException(ErrorType.BAD_REQUEST, "로그인ID는 영문과 숫자만 허용됩니다.");
}
}

private static String normalizeName(String name) {
return name.trim().replaceAll("\\s+", " ");
}

private static void validateName(String name) {
boolean isKorean = name.matches("^[가-힣]+$");
boolean isEnglish = name.matches("^[a-zA-Z]+( [a-zA-Z]+)*$");

if (!isKorean && !isEnglish) {
throw new CoreException(ErrorType.BAD_REQUEST, "이름은 한글만 또는 영문만 허용됩니다.");
}
}

private static void validateEmail(String email) {
if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) {
throw new CoreException(ErrorType.BAD_REQUEST, "올바른 이메일 형식이 아닙니다.");
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

필수 입력 null 처리 누락으로 NPE 위험
운영 관점에서 null 입력이 들어오면 NPE로 500이 발생해 표준 오류 포맷과 모니터링이 깨질 수 있다.
수정안: create/검증 단계에서 loginId/password/name/birthDate/email null을 먼저 검사해 CoreException(BAD_REQUEST)으로 처리하고, validatePassword에서 birthDate null도 차단한다.
추가 테스트: 필수 값이 null 또는 공백인 요청에 대해 400과 메시지가 반환되는지 테스트한다.

🔧 수정 예시
     public static Member create(String loginId, String rawPassword,
                                 String name, LocalDate birthDate,
                                 String email, PasswordEncoder encoder) {
+        if (loginId == null || rawPassword == null || name == null || birthDate == null || email == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "필수 입력값이 누락되었습니다.");
+        }
         validateLoginId(loginId);
         validatePassword(rawPassword, birthDate);
         String normalizedName = normalizeName(name);
         validateName(normalizedName);
         validateEmail(email);
@@
     private static void validatePassword(String password, LocalDate birthDate) {
+        if (password == null || birthDate == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호 또는 생년월일이 누락되었습니다.");
+        }
         if (password.length() < 8 || password.length() > 16) {
             throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다.");
         }

As per coding guidelines: null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java` around
lines 32 - 81, The constructor factory Member.create and its validators
(validatePassword, validateLoginId, normalizeName, validateName, validateEmail)
lack null/blank checks causing NPEs; add guard clauses at the start of create to
validate non-null and non-blank loginId, rawPassword, name, email and non-null
birthDate and throw CoreException(ErrorType.BAD_REQUEST, "...") with appropriate
messages, and update validatePassword to explicitly check birthDate != null
before using it; ensure name is trimmed/blank-checked before normalizeName and
keep Member immutable (defensive copy if needed) and add tests asserting
400/CoreException for null or blank required fields.

Comment on lines +18 to +25
public Member register(String loginId, String rawPassword, String name,
LocalDate birthDate, String email) {
if (memberReader.existsByLoginId(loginId)) {
throw new CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 로그인ID입니다.");
}

Member member = Member.create(loginId, rawPassword, name, birthDate, email, passwordEncoder);
return memberRepository.save(member);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "MemberService.java" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 163


🏁 Script executed:

find . -type f -name "Member.java" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 156


🏁 Script executed:

fd -t f "MemberService" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 242


🏁 Script executed:

wc -l ./apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java
wc -l ./apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 242


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1427


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4823


🏁 Script executed:

find . -type f -name "MemberRepository.java" -o -name "MemberReader.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 244


🏁 Script executed:

grep -r "DataIntegrityViolationException" ./apps/commerce-api/src --include="*.java" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4987


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 225


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 335


🏁 Script executed:

grep -r "unique\|@Column\|@Index" ./apps/commerce-api/src/main/java/com/loopers/domain/member --include="*.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find ./apps/commerce-api -type f -name "*.java" | xargs grep -l "implements MemberRepository\|implements MemberReader" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 351


🏁 Script executed:

find ./apps/commerce-api -type f -name "*MemberRepository*.java" -o -name "*MemberReader*.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 428


🏁 Script executed:

grep -r "class.*MemberRepository\|class.*MemberReader" ./apps/commerce-api/src --include="*.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 682


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 699


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 929


🏁 Script executed:

grep -n "Column\|Index\|unique\|constraint" ./apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find ./apps/commerce-api -type f -name "MemberJpaRepository.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 177


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 511


🏁 Script executed:

find ./apps/commerce-api -type f \( -name "*.sql" -o -name "*Migration*.java" -o -name "schema*" \) | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find ./apps -type f -name "*.xml" -o -name "*.sql" | xargs grep -l "member" 2>/dev/null | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

grep -r "DataIntegrityViolationException\|ConstraintViolationException" ./apps/commerce-api/src/main/java --include="*.java" -A 3 | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

grep -r "@Transactional\|@Column" ./apps/commerce-api/src/main/java/com/loopers/domain/member --include="*.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find ./apps/commerce-api -type f -name "application*.yml" -o -name "application*.yaml" | xargs cat 2>/dev/null | grep -A 20 "jpa\|hibernate" | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 356


동시 가입 요청에서 로그인ID 중복 경쟁 상태 처리 필수

현재 코드는 existsByLoginId 검사 후 저장하는 사이에 다른 요청이 동일 loginId를 저장할 수 있어, 데이터베이스 중복 제약 위반 시 DataIntegrityViolationException이 발생하면 500 에러로 노출된다. 운영 안정성을 위해 다음을 필수 처리해야 한다:

  1. Member 엔티티의 loginId 필드에 @Column(unique=true) 추가로 데이터베이스 수준 중복 방지
  2. MemberService.register 메서드에서 DataIntegrityViolationException을 포착하여 CoreException(ErrorType.BAD_REQUEST)로 변환해 일관된 400 응답 반환
  3. 동시 등록 시나리오 테스트 추가 (동일 loginId로 다중 요청 시 하나만 성공, 나머지는 400 반환 확인)
🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java`
around lines 18 - 25, Add a database-unique constraint on the Member entity's
loginId by annotating the loginId field (Member.loginId) with `@Column`(unique =
true); update MemberService.register to wrap the memberRepository.save(member)
call in a try/catch that catches DataIntegrityViolationException and throws new
CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 로그인ID입니다."); and add an
integration/concurrency test that fires concurrent register requests using the
same loginId and asserts that exactly one request succeeds while the others
receive CoreException/ErrorType.BAD_REQUEST responses.

Comment on lines +8 to +10
public interface MemberJpaRepository extends JpaRepository<Member, Long> {
boolean existsByLoginId(String loginId);
Optional<Member> findByLoginId(String loginId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -type f -name "MemberJpaRepository.java" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 255


🏁 Script executed:

fd -type f -name "Member.java" | grep -E "(entity|domain|model)" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 255


🏁 Script executed:

# Member 엔티티 파일 찾기
fd -type f -name "*Member*.java" | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 255


🏁 Script executed:

git ls-files | grep -E "MemberJpaRepository|Member\.java" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 245


🏁 Script executed:

# 저장소 구조 확인
find . -type f -name "Member*.java" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1338


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4823


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 511


🏁 Script executed:

find . -type f -name "BaseEntity.java" | head -5 | xargs cat -n

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2291


🏁 Script executed:

find . -type f \( -name "V*.sql" -o -name "*migration*" \) | grep -i member | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find . -type d \( -name "db" -o -name "migration" -o -name "flyway" \) 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find . -type f -name "*.sql" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find . -path "*/resources/db*" -o -path "*/resources/migration*" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Member entity에서 `@Index`, `@Column`(unique) 검색
rg -n "@Index|@Column.*unique" apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java | head -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 699


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java | head -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 929


🏁 Script executed:

find . -type f -name "schema*.sql" -o -name "application*.yml" -o -name "application*.yaml" 2>/dev/null | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 256


🏁 Script executed:

cat apps/commerce-api/src/main/resources/application.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1044


🏁 Script executed:

cat apps/commerce-api/src/main/resources/jpa.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 161


🏁 Script executed:

find . -name "jpa.yml" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 125


🏁 Script executed:

ls -la apps/commerce-api/src/main/resources/

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 260


🏁 Script executed:

cat ./modules/jpa/src/main/resources/jpa.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2231


🏁 Script executed:

find . -type f \( -name "*.sql" -o -name "*.ddl" \) 2>/dev/null | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Member entity 전체 어노테이션 다시 확인
rg -A 20 "^@Entity" apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 611


loginId 컬럼에 인덱스와 유니크 제약조건을 추가해야 한다.

existsByLoginId와 findByLoginId는 로그인 및 회원가입 시 loginId 기반 조회를 수행하며, 현재 loginId 필드에 @Column(unique=true) 또는 @Index 어노테이션이 없다. 회원 수 증가에 따라 이들 메서드는 Full Table Scan을 유발하여 응답 지연과 데이터베이스 부하를 야기한다.

Member 엔티티의 loginId 필드에 @Column(unique = true) 어노테이션을 추가하거나, @Table(indexes = {@Index(columnList = "login_id")}) 를 통해 인덱스를 정의해야 한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java`
around lines 8 - 10, The Member repository queries (existsByLoginId and
findByLoginId in MemberJpaRepository) will cause full table scans unless the
Member.loginId column has a uniqueness constraint and/or an index; update the
Member entity by adding either `@Column`(unique = true) on the loginId field or
add a class-level `@Table`(indexes = {`@Index`(columnList = "login_id")}) so the
database enforces uniqueness and creates an index to speed up existsByLoginId
and findByLoginId lookups.

Comment on lines 40 to 57
@GetMapping("/me")
public ApiResponse<MemberV1Dto.MemberResponse> getMe(HttpServletRequest request) {
Member authenticatedMember = (Member) request.getAttribute("authenticatedMember");
MemberInfo info = memberFacade.getMe(authenticatedMember);
return ApiResponse.success(MemberV1Dto.MemberResponse.from(info));
}

@PatchMapping("/me/password")
public ApiResponse<Void> changePassword(
HttpServletRequest request,
@RequestBody MemberV1Dto.ChangePasswordRequest passwordRequest
) {
Member authenticatedMember = (Member) request.getAttribute("authenticatedMember");
memberFacade.changePassword(
authenticatedMember,
passwordRequest.currentPassword(),
passwordRequest.newPassword()
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "MemberV1Controller.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 176


🏁 Script executed:

find . -name "CoreException.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 163


🏁 Script executed:

find . -name "ErrorType.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 159


🏁 Script executed:

find . -name "ApiControllerAdvice.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 170


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java | head -70

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2788


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 741


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 949


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6820


🏁 Script executed:

find . -name "*Filter*.java" -type f | grep -E "(auth|Auth|Interceptor)" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 165


🏁 Script executed:

rg -l "authenticatedMember" --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 334


🏁 Script executed:

rg "401\|UNAUTHORIZED\|Unauthorized" --type java -A 2 | head -40

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3203


authenticatedMember null 캐스팅 시 500 에러 발생 가능
필터 장애 또는 요청 우회 시 인증이 실패해도 Controller에 null 또는 다른 타입의 객체가 전달될 수 있다. 이 경우 캐스팅 실패로 500 에러가 발생해 운영 관점에서 장애 분석이 어려워진다.

수정안: request.getAttribute 값을 검사하고 Member가 아니면 CoreException으로 변환해 표준 에러 응답을 반환한다. 단, 현재 ErrorType에 401(UNAUTHORIZED) 상태가 정의되어 있지 않으므로 먼저 ErrorType에 UNAUTHORIZED를 추가해야 한다.

추가 테스트: 인증 헤더 없이 /me 및 /me/password 호출 시 401과 표준 에러 바디가 반환되는지 검증한다.

🔧 수정 예시

ErrorType.java에 UNAUTHORIZED 추가:

UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증 정보가 없습니다."),

MemberV1Controller.java 수정:

+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
@@
     `@GetMapping`("/me")
     public ApiResponse<MemberV1Dto.MemberResponse> getMe(HttpServletRequest request) {
-        Member authenticatedMember = (Member) request.getAttribute("authenticatedMember");
+        Member authenticatedMember = getAuthenticatedMember(request);
         MemberInfo info = memberFacade.getMe(authenticatedMember);
         return ApiResponse.success(MemberV1Dto.MemberResponse.from(info));
     }

     `@PatchMapping`("/me/password")
     public ApiResponse<Void> changePassword(
         HttpServletRequest request,
         `@RequestBody` MemberV1Dto.ChangePasswordRequest passwordRequest
     ) {
-        Member authenticatedMember = (Member) request.getAttribute("authenticatedMember");
+        Member authenticatedMember = getAuthenticatedMember(request);
         memberFacade.changePassword(
             authenticatedMember,
             passwordRequest.currentPassword(),
             passwordRequest.newPassword()
         );
         return ApiResponse.success(null);
     }
+
+    private Member getAuthenticatedMember(HttpServletRequest request) {
+        Object attr = request.getAttribute("authenticatedMember");
+        if (!(attr instanceof Member)) {
+            throw new CoreException(ErrorType.UNAUTHORIZED);
+        }
+        return (Member) attr;
+    }
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java`
around lines 40 - 57, The controller assumes
request.getAttribute("authenticatedMember") is a Member and casts it directly in
getMe and changePassword, which can throw a ClassCastException/produce a 500
when the attribute is missing or invalid; update ErrorType to add an
UNAUTHORIZED entry (HttpStatus.UNAUTHORIZED) and then in MemberV1Controller
check the attribute returned by request.getAttribute("authenticatedMember")
before casting — if it's null or not an instance of Member throw a CoreException
with ErrorType.UNAUTHORIZED so the API returns a 401 standard error response;
apply this check in both getMe and changePassword (and any other handlers using
request.getAttribute("authenticatedMember")) and add tests that call /me and
/me/password without auth to assert 401 and the standard error body.

Comment on lines +64 to +75
private boolean requiresAuthentication(HttpServletRequest request) {
String path = request.getRequestURI();
String method = request.getMethod();

// POST /api/v1/members (회원가입)는 인증 불필요
if ("POST".equals(method) && "/api/v1/members".equals(path)) {
return false;
}

// /api/v1/members/** 경로는 인증 필요
return path.startsWith("/api/v1/members/");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "MemberAuthFilter.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 165


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3203


🌐 Web query:

HttpServletRequest getRequestURI vs getServletPath difference context path

💡 Result:

In the Servlet API, these three values are different slices of the incoming request path:

  • request.getRequestURI(): the full request path within the server, from the start of the path up to (but not including) the query string. (So it includes the context path + servlet mapping + any extra path info.) [1]

  • request.getContextPath(): the webapp’s context root (the portion of the URI that identifies which app is being addressed). It starts with /, does not end with /, and is "" for the root (default) context. [2]

  • request.getServletPath(): the portion of the URL that matched the servlet mapping (the “servlet’s own path”), excluding any extra path info and query string. If the servlet was matched by the "/*" mapping, this can be "". [1]

A common way to relate them is:

getRequestURI() = getContextPath() + getServletPath() + getPathInfo() (where getPathInfo() may be null)

Example

Request URL: http://host:8080/shop/api/orders/123?x=1

  • getContextPath()/shop
  • getServletPath()/api (depending on your servlet mapping)
  • getPathInfo()/orders/123 (if the mapping is /api/*)
  • getRequestURI()/shop/api/orders/123 [1][2]

Sources: Jakarta Servlet HttpServletRequest Javadoc for getRequestURI(), getServletPath(), getContextPath() [1][2].

[1] (jakarta.ee)
[2] (jakarta.ee)

Citations:


컨텍스트 경로 환경에서 회원가입 엔드포인트 인증 요구 문제

getRequestURI() 사용 시 컨텍스트 경로가 포함되므로, 컨텍스트 경로가 있는 배포 환경에서 POST /api/v1/members(회원가입) 요청이 경로 비교 실패로 인해 인증을 요구하게 되어 의도한 공개 엔드포인트가 차단된다.

수정안: request.getServletPath() 기준으로 처리하고, "/api/v1/members" 정확 일치 조건에서 POST만 예외 처리한다.

추가 테스트: 컨텍스트 경로가 있는 환경에서 POST /api/v1/members가 401로 차단되지 않는지, GET /api/v1/members는 401로 차단되는지 검증한다.

🔧 수정 예시
    private boolean requiresAuthentication(HttpServletRequest request) {
-        String path = request.getRequestURI();
+        String path = request.getServletPath();
        String method = request.getMethod();

-        // POST /api/v1/members (회원가입)는 인증 불필요
-        if ("POST".equals(method) && "/api/v1/members".equals(path)) {
-            return false;
-        }
-
-        // /api/v1/members/** 경로는 인증 필요
-        return path.startsWith("/api/v1/members/");
+        if ("/api/v1/members".equals(path)) {
+            return !"POST".equals(method);
+        }
+        return path.startsWith("/api/v1/members/");
    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private boolean requiresAuthentication(HttpServletRequest request) {
String path = request.getRequestURI();
String method = request.getMethod();
// POST /api/v1/members (회원가입)는 인증 불필요
if ("POST".equals(method) && "/api/v1/members".equals(path)) {
return false;
}
// /api/v1/members/** 경로는 인증 필요
return path.startsWith("/api/v1/members/");
}
private boolean requiresAuthentication(HttpServletRequest request) {
String path = request.getServletPath();
String method = request.getMethod();
if ("/api/v1/members".equals(path)) {
return !"POST".equals(method);
}
return path.startsWith("/api/v1/members/");
}
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java`
around lines 64 - 75, The requiresAuthentication method currently uses
request.getRequestURI(), which fails when a context path is present; change it
to use request.getServletPath() for path comparisons, keep the POST exact-match
exception for "/api/v1/members" (only skip auth when method=="POST" &&
servletPath.equals("/api/v1/members")), and retain the authentication
requirement for any servletPath startingWith "/api/v1/members/"; update tests to
assert POST /api/v1/members is allowed and GET /api/v1/members is authenticated
in a context-path-enabled environment.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/commerce-api/build.gradle.kts`:
- Around line 12-14: Spring Security 6.4.4 (managed by the Spring Boot BOM) is
vulnerable to CVE-2025-22234; override the managed version by adding a
dependencyManagement entry that forces
org.springframework.security:spring-security-core to 6.4.5 or later (use
dependencyManagement and specify the
org.springframework.security:spring-security-core:6.4.5 coordinate), then update
CI to run a dependency-vulnerability scan task and add smoke tests for signup,
login, and password-change authorization flows to validate the fix.

In `@apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java`:
- Around line 32-43: Validate null inputs at the start of Member.create and
Member.changePassword: check that encoder is not null in create, and that
encoder, currentPassword, and newRawPassword are not null in changePassword; if
any are null, throw a CoreException with BAD_REQUEST to ensure unified error
handling via ApiControllerAdvice. Update Member.create (method name: create) and
Member.changePassword (method name: changePassword) to perform these null-guards
before any processing (e.g., before calling encoder.encode or password checks).
Add unit tests asserting that calling Member.create with a null encoder and
Member.changePassword with null encoder/currentPassword/newRawPassword each
result in a CoreException with BAD_REQUEST. Ensure messages in exceptions
provide minimal context for debugging while keeping the error type consistent.

In
`@apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java`:
- Around line 36-57: MemberAuthFilter's authentication failures currently send
only a 401 status without the standard ApiResponse JSON; update the filter
(doFilterInternal in MemberAuthFilter) to serialize the standard ApiResponse
with ErrorType.UNAUTHORIZED into the response body instead of returning empty
status: ensure ErrorType includes an UNAUTHORIZED(401) entry, set
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED), set
response.setContentType("application/json;charset=UTF-8"), write the
JSON-serialized ApiResponse (containing error code/message) to
response.getWriter() and return without proceeding the filter chain on
header-missing, user-not-found (memberReader.findByLoginId), and
password-mismatch (passwordEncoder.matches) branches; add tests verifying
header-missing, non-existent user, and wrong-password scenarios return 401 plus
the standard ApiResponse JSON.

In
`@apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java`:
- Around line 19-25: Security is too lax: in securityFilterChain(HttpSecurity)
you must stop accepting plaintext passwords via headers and tighten
CSRF/authorization; replace the current permissive config
(authorizeHttpRequests(...).anyRequest().permitAll()) and the memberAuthFilter
approach with a token-based authentication flow (e.g., JWT/Bearer via a
JwtAuthenticationFilter) or at minimum HTTP Basic, enable CSRF unless you
explicitly choose stateless JWT (document the reason if you call
csrf().disable()), and register an
AuthenticationEntryPoint/AuthenticationFailureHandler to log authentication
failures (log non-sensitive context such as loginId and failure reason but never
the password). Locate SecurityFilterChain securityFilterChain, memberAuthFilter,
addFilterBefore(..., UsernamePasswordAuthenticationFilter.class) and update them
accordingly to implement token parsing/validation, proper authorization rules,
CSRF handling, and secure failure logging.
🧹 Nitpick comments (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/member/PasswordEncoderImpl.java (1)

7-19: BCrypt 강도 하드코딩은 성능 튜닝을 제한한다.

BCryptPasswordEncoder의 기본 strength는 10이며, 각 +1마다 해시/검증 시간이 약 2배 증가한다. 기본값 고정 시 트래픽 증가에 따른 로그인 지연이 발생하면, 하드웨어 성능에 맞춰 강도를 조정할 수 없어 운영 유연성이 떨어진다. Spring Security 권장사항에 따르면 본 운영 환경에서 검증이 약 1초 이내로 완료되는 최대 강도를 선택하고, 이후 하드웨어 개선 시 점진적으로 증가시켜야 한다.

strength를 설정값으로 주입해 환경별 조정을 가능하게 하고, PasswordEncoderImpl은 주입받은 BCryptPasswordEncoder 인스턴스를 사용하도록 변경한다.

수정 예시
 import com.loopers.domain.member.PasswordEncoder;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 import org.springframework.stereotype.Component;

 `@Component`
 public class PasswordEncoderImpl implements PasswordEncoder {
-    private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
+    private final BCryptPasswordEncoder bCryptPasswordEncoder;
+
+    public PasswordEncoderImpl(`@Value`("${security.bcrypt.strength:10}") int strength) {
+        this.bCryptPasswordEncoder = new BCryptPasswordEncoder(strength);
+    }

application.yml(또는 환경별 프로파일)에 설정값을 추가하고, application-test.yml에는 더 낮은 strength(예: 4)를 지정한 뒤 인코딩/매칭이 정상 동작하는지 스프링 컨텍스트 통합 테스트를 작성한다. 또한 본 운영 환경에서 목표 검증 시간에 맞는 strength 값을 벤치마킹으로 결정해야 한다.

Comment on lines +12 to +14

// security
implementation("org.springframework.boot:spring-boot-starter-security")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n apps/commerce-api/build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1271


🏁 Script executed:

# Find root build.gradle.kts or gradle configuration
fd -t f "build.gradle.kts" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 242


🏁 Script executed:

cat -n build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4538


🏁 Script executed:

cat -n gradle.properties

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 747


🌐 Web query:

Spring Boot 3.4.4 spring-boot-starter-security version CVE

💡 Result:

Spring Boot 3.4.4’s spring-boot-starter-security pulls in Spring Security 6.4.4 (via the Spring Boot BOM). [1]

CVE context for Spring Security 6.4.4

So, if you’re on Boot 3.4.4, you’re on Spring Security 6.4.4 (includes fixes for CVE-2025-22223/22228, but is impacted by CVE-2025-22234). [1][2][3]

What to do

  • Upgrade Spring Boot to a release that manages Spring Security ≥ 6.4.5, or override the managed version to 6.4.5+. [3]

References:
[1] (github.com)
[2] (spring.io)
[3] (spring.io)

Citations:


🏁 Script executed:

# Search for dependency scanning or security tasks in gradle files
rg -i "(dependenc.*scan|security.*scan|owasp|snyk|checkmarx)" --type gradle

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 233


🏁 Script executed:

# Search for CI configuration and security scanning
find . -type f \( -name "*.yml" -o -name "*.yaml" -o -name ".gradle" \) | grep -E "(github|gitlab|workflow|ci)" | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 110


🏁 Script executed:

# Search for GitHub workflows
find .github/workflows -type f 2>/dev/null | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check if there are any security scanning plugins in any gradle file
rg "dependency.*check|owasp|snyk" --type-list

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 474


🏁 Script executed:

# Check build gradle files for any security-related configuration
find . -name "build.gradle.kts" -type f | xargs grep -l "security\|cve\|vulnerability" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 121


Spring Security 6.4.4의 CVE-2025-22234 취약점 해결이 필수다.

Spring Boot 3.4.4 BOM에 의해 Spring Security 6.4.4가 관리되고 있으나, 해당 버전은 CVE-2025-22234에 영향을 받는다. 이 취약점은 인증/인가 처리 과정에서 발생하는 보안 결함이며, 상용 환경에서 미해결 시 인증 우회 등의 위험에 노출된다.

수정안으로 dependencyManagement 블록에서 Spring Security를 6.4.5 이상으로 명시적으로 오버라이드한다.

dependencyManagement {
    dependencies {
        dependency("org.springframework.security:spring-security-core:6.4.5")
    }
}

이후 의존성 취약점 스캔 태스크를 CI에 포함하고, 회원가입/로그인/비밀번호 변경 API의 인증/인가 관련 스모크 테스트를 추가한다.

🤖 Prompt for AI Agents
In `@apps/commerce-api/build.gradle.kts` around lines 12 - 14, Spring Security
6.4.4 (managed by the Spring Boot BOM) is vulnerable to CVE-2025-22234; override
the managed version by adding a dependencyManagement entry that forces
org.springframework.security:spring-security-core to 6.4.5 or later (use
dependencyManagement and specify the
org.springframework.security:spring-security-core:6.4.5 coordinate), then update
CI to run a dependency-vulnerability scan task and add smoke tests for signup,
login, and password-change authorization flows to validate the fix.

Comment on lines 32 to 43
public static Member create(String loginId, String rawPassword,
String name, LocalDate birthDate,
String email, PasswordEncoder encoder) {
validateLoginId(loginId);
validatePassword(rawPassword, birthDate);
String normalizedName = normalizeName(name);
validateName(normalizedName);
validateEmail(email);

String encodedPassword = encoder.encode(rawPassword);
return new Member(loginId, encodedPassword, normalizedName, birthDate, email);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

비밀번호 변경/생성 입력값 null 가드가 필요하다.
encoder 또는 currentPassword/newRawPassword가 null이면 NPE로 500이 발생해 운영에서 표준 오류 포맷이 깨지고 원인 분석이 어려워진다.
수정안으로 create와 changePassword 시작부에서 null을 검사해 CoreException(BAD_REQUEST)로 통일한다.

수정 예시
     public static Member create(String loginId, String rawPassword,
                                 String name, LocalDate birthDate,
                                 String email, PasswordEncoder encoder) {
+        if (encoder == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호 인코더가 필요합니다.");
+        }
         validateLoginId(loginId);
         validatePassword(rawPassword, birthDate);
         String normalizedName = normalizeName(name);
         validateName(normalizedName);
         validateEmail(email);

         String encodedPassword = encoder.encode(rawPassword);
         return new Member(loginId, encodedPassword, normalizedName, birthDate, email);
     }

     public void changePassword(String currentPassword, String newRawPassword,
                                PasswordEncoder encoder) {
+        if (currentPassword == null || newRawPassword == null || encoder == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호 입력값이 누락되었습니다.");
+        }
         // 현재 비밀번호 확인
         if (!encoder.matches(currentPassword, this.password)) {
             throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다.");
         }

추가 테스트로 currentPassword/newRawPassword/encoder가 null일 때 BAD_REQUEST가 발생하는지 단위 테스트를 추가한다.
Based on learnings 'In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format. Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns. This guidance applies to all Java files under the commerce-api module (source code under apps/commerce-api/src/main/java/).'를 근거로 했다.
As per coding guidelines 'null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다'를 근거로 했다.

Also applies to: 83-97

🤖 Prompt for AI Agents
In `@apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java` around
lines 32 - 43, Validate null inputs at the start of Member.create and
Member.changePassword: check that encoder is not null in create, and that
encoder, currentPassword, and newRawPassword are not null in changePassword; if
any are null, throw a CoreException with BAD_REQUEST to ensure unified error
handling via ApiControllerAdvice. Update Member.create (method name: create) and
Member.changePassword (method name: changePassword) to perform these null-guards
before any processing (e.g., before calling encoder.encode or password checks).
Add unit tests asserting that calling Member.create with a null encoder and
Member.changePassword with null encoder/currentPassword/newRawPassword each
result in a CoreException with BAD_REQUEST. Ensure messages in exceptions
provide minimal context for debugging while keeping the error type consistent.

Comment on lines 36 to 57
String loginId = request.getHeader(HEADER_LOGIN_ID);
String loginPw = request.getHeader(HEADER_LOGIN_PW);

// 헤더가 없으면 401
if (loginId == null || loginPw == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}

// 회원 조회
Optional<Member> memberOpt = memberReader.findByLoginId(loginId);
if (memberOpt.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}

// 비밀번호 검증
Member member = memberOpt.get();
if (!passwordEncoder.matches(loginPw, member.getPassword())) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find apps/commerce-api -type f -name "*.java" | grep -E "(MemberAuthFilter|ApiControllerAdvice|CoreException)" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 405


🏁 Script executed:

find . -type f -name "MemberAuthFilter.java" -o -name "ApiControllerAdvice.java" -o -name "CoreException.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 330


🏁 Script executed:

cat apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2671


🏁 Script executed:

cat apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 608


🏁 Script executed:

cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5938


🏁 Script executed:

find apps/commerce-api -type f -name "*.java" | xargs grep -l "HandlerExceptionResolver" | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

find apps/commerce-api -type f -name "ErrorType.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 157


🏁 Script executed:

cat apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 816


🏁 Script executed:

find apps/commerce-api -type f -name "ApiResponse.java"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 160


🏁 Script executed:

find apps/commerce-api -type f -name "*Filter.java" | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 163


🏁 Script executed:

cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1044


필터 레이어의 인증 실패 응답이 API 표준 포맷과 불일치한다

현재 상태 코드만 반환하고 응답 본문이 없어서 클라이언트가 표준 ApiResponse 포맷을 기대할 수 없으며, 에러 모니터링과 로깅이 일관되지 않는다.

수정안: OncePerRequestFilter는 Servlet 레이어에서 실행되므로 ApiControllerAdvice가 예외를 처리하지 못한다. 다음 중 하나의 방식으로 통일해야 한다.

  • (권장) 필터에서 직접 ApiResponse를 JSON으로 직렬화하여 응답 바디에 작성한다. 이 경우 ErrorType에 UNAUTHORIZED(401) 에러 타입을 추가하고, 인증 실패 시 표준 ApiResponse 형식으로 반환한다.
  • (대안) 인증 로직을 HandlerInterceptor로 이동하여 ApiControllerAdvice 흐름에 포함시킨다.

추가 테스트: 헤더 누락, 사용자 미존재, 비밀번호 불일치 각 시나리오에서 응답이 표준 ApiResponse 포맷과 401 상태 코드를 가지는지 검증해야 한다.

🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java`
around lines 36 - 57, MemberAuthFilter's authentication failures currently send
only a 401 status without the standard ApiResponse JSON; update the filter
(doFilterInternal in MemberAuthFilter) to serialize the standard ApiResponse
with ErrorType.UNAUTHORIZED into the response body instead of returning empty
status: ensure ErrorType includes an UNAUTHORIZED(401) entry, set
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED), set
response.setContentType("application/json;charset=UTF-8"), write the
JSON-serialized ApiResponse (containing error code/message) to
response.getWriter() and return without proceeding the filter chain on
header-missing, user-not-found (memberReader.findByLoginId), and
password-mismatch (passwordEncoder.matches) branches; add tests verifying
header-missing, non-existent user, and wrong-password scenarios return 401 plus
the standard ApiResponse JSON.

Comment on lines +19 to +25
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.addFilterBefore(memberAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd "SecurityConfig.java" --type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 163


🏁 Script executed:

wc -l apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 166


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1339


🏁 Script executed:

fd "MemberAuthFilter.java" --type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 163


🏁 Script executed:

wc -l apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 166


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3203


비밀번호를 HTTP 헤더로 직접 전달하는 것은 보안상 매우 위험하다.

X-Loopers-LoginPw 헤더로 비밀번호를 평문 형태로 전달하면 HTTP 헤더 로그, 프록시 로그, 네트워크 추적 등에서 민감정보가 노출될 수 있다. 또한 CSRF 보호를 비활성화한 상태에서 인가 검증(anyRequest().permitAll())이 없으므로 보안 방어층이 미흡하다.

개선안:

  • Bearer 토큰 기반 인증(JWT) 또는 HTTP Basic 인증(Base64 인코딩)으로 변경
  • CSRF 보호를 활성화하거나 stateless 설정(JWT) 선택 시 disable 이유 문서화
  • 인증 실패 로그를 추가해 보안 모니터링 가능하도록 구성 (시도한 loginId, 실패 사유 등)
  • 로그에는 민감정보(비밀번호) 기록 금지
🤖 Prompt for AI Agents
In
`@apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java`
around lines 19 - 25, Security is too lax: in securityFilterChain(HttpSecurity)
you must stop accepting plaintext passwords via headers and tighten
CSRF/authorization; replace the current permissive config
(authorizeHttpRequests(...).anyRequest().permitAll()) and the memberAuthFilter
approach with a token-based authentication flow (e.g., JWT/Bearer via a
JwtAuthenticationFilter) or at minimum HTTP Basic, enable CSRF unless you
explicitly choose stateless JWT (document the reason if you call
csrf().disable()), and register an
AuthenticationEntryPoint/AuthenticationFailureHandler to log authentication
failures (log non-sensitive context such as loginId and failure reason but never
the password). Locate SecurityFilterChain securityFilterChain, memberAuthFilter,
addFilterBefore(..., UsernamePasswordAuthenticationFilter.class) and update them
accordingly to implement token parsing/validation, proper authorization rules,
CSRF handling, and secure failure logging.

- MemberV1Controller: getAuthenticatedMember() 헬퍼 메서드 추가로 null 체크
- MemberInfo: maskName()에서 null 입력 시 빈 문자열 반환
- MemberAuthFilter: Optional.get() 대신 filter().orElse(null) 패턴 적용
- Member: create(), changePassword()에 null/blank guard clause 추가
- MemberTest: 필수값 검증 테스트 8개 추가
- ExampleV1Dto: 중첩 구조를 flat 구조로 변경
- MemberV1ApiE2ETest: 중첩 DTO 접근자 .member() 추가
- DB 인덱스 생성으로 existsByLoginId/findByLoginId 쿼리 성능 개선
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants