You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
AuthController, UserController가 user-service 안에 있고, api/ServletConfig가 basePackages = "com.ntropy"를 스캔하므로 실제로 매핑된다. /api/auth/**, /api/users/**가 BFF를 거치지 않아 "controller는 bff-service만 노출한다"는 합의가 깨진 상태다.
2. 응답 규격이 두 갈래다
bff 컨트롤러는 ApiResponse + ErrorCode + ServiceException 형식을 쓰는데, user-service 컨트롤러는 도메인 엔티티 User를 그대로 반환하고 AccessDeniedException을 던진다. FE가 응답 포맷 두 종류를 분기 처리해야 한다.
3. 모듈 간 계약이 common에 없다
UserQueryClient / UserSummary가 com.ntropy.user.client.common, com.ntropy.user.dto.common에 있다. 다른 모듈이 사용하려면 project(':services:user-service')를 직접 의존해야 해서 모듈 경계가 무너진다. (나머지 18개 client는 전부 common/client에 있음)
4. UserBirthDateQueryClient 구현체가 없다
common/client/UserBirthDateQueryClient를 구현하는 클래스가 프로젝트 전체에 없다. account-service가 ObjectProvider로 방어하고 있어 기동은 되지만, 생년월일이 필요한 은행(CODEF 연동)은 런타임에 INVALID_REQUEST로 실패한다.
5. Security 필터 체인이 서블릿에 등록되어 있지 않다
SecurityConfig가 SecurityFilterChain 빈을 만들지만 springSecurityFilterChain을 필터로 등록하는 지점이 없다.
AbstractSecurityWebApplicationInitializer / DelegatingFilterProxy 검색 결과 0건
스프링 레거시는 Boot와 달리 Filter 타입 빈을 자동 등록하지 않으므로, JwtAuthenticationFilter는 빈으로 생성만 되고 요청 경로에는 들어가지 않는다. SecurityContextHolder가 항상 비어 있어 bff의 AuthenticatedUserIdResolver가 전부 UNAUTHORIZED로 떨어진다.
6. Security 설정이 도메인 모듈에 있다
SecurityConfig의 .anyRequest().authenticated()는 work / account / defense 등 전 모듈의 요청에 적용된다. 한 도메인 모듈이 다른 모든 모듈의 접근 정책을 결정하고 있다. 경로별 인증 규칙은 CORS·DataSource와 같은 조립 단위 설정이므로 api에 있어야 한다.
7. 컨텍스트 이중 등록
UserServiceConfig에 @ComponentScan("com.ntropy.user")가 있다. RootConfig가 이미 com.ntropy를 스캔하므로 중복이고, RootConfig는 @Controller를 excludeFilter로 빼는데 이 중첩 스캔에는 필터가 없어 컨트롤러 빈이 루트 컨텍스트에도 등록된다. 다른 모듈 config에는 @ComponentScan이 없다.
8. 패키지 구조가 다른 모듈과 다르다
work-service / account-service는 아래 구조를 공유하는데, user-service만 어긋난다.
com.ntropy.{도메인}
├── client/ Local*Client + 외부 API는 하위 패키지 (kma/, codef/)
├── config/ 모듈 설정 + *Properties
├── domain/entity/ DB 매핑 객체
├── exception/ {Domain}ErrorCode
├── mapper/ MyBatis Mapper 인터페이스
├── service/
└── package-info.java (모든 패키지)
항목
컨벤션
user-service 현재
엔티티 위치
domain/entity/
model/
모듈 에러코드
exception/{Domain}ErrorCode
없음
외부 API 클라이언트
client/{provider}/ 하위 패키지
client/ 직속
package-info.java
모든 패키지에 존재
전무
루트 패키지
com.ntropy.{도메인} 단일
com.ntropy.user + com.ntropy.auth 2개
특히 com.ntropy.auth에는 3개 파일만 있고, 정작 인증 코드 대부분은 user 밑에 있어 경계 역할을 못 한다.
부수 문제로, com.ntropy.auth는 com.ntropy.user의 하위가 아니라 별도 루트 패키지라 UserServiceConfig의 @ComponentScan("com.ntropy.user")에 JwtProvider가 잡히지 않는다. 이를 덮기 위해 SecurityConfig에 @Import({JwtAuthenticationFilter.class, JwtProvider.class})가 붙어 있는데, JwtAuthenticationFilter는 이미 스캔 대상이라 스캔 이름(jwtAuthenticationFilter)과 @Import FQCN 이름으로 빈이 2개 등록된다. Security 배선을 고치는 순간 어느 인스턴스가 체인에 들어갔는지 불명확해진다.
9. UserErrorCode가 없어 예외 규격을 맞출 수 없다
work·account는 exception/{Domain}ErrorCode + ServiceException(XxxErrorCode.YYY) 형태로 던진다. user-service는 AccessDeniedException, IllegalArgumentException 등을 그대로 쓰고 있어 bff의 GlobalExceptionHandler가 일관되게 처리하지 못한다. B(응답 규격 통일)의 선행 조건이다.
10. 미사용 파일이 남아 있다
auth/dto/OAuthLoginRequest.java — 참조 0건. AuthController가 @RequestParam("code")로 직접 받는다
user/dto/UserResponseDto.java — 참조 0건. UserController가 엔티티 User를 그대로 반환한다
user/controller/GlobalExceptionHandler.java — 파일 전체가 주석 처리. bff에 동일 클래스 존재
11. 빌드 스크립트가 루트 설정을 상속하지 않는다
다른 모듈은 dependencies { } 블록만 두는데, user-service는 plugins/repositories/group/version/sourceCompatibility/ext/test를 전부 재선언한다. 특히 springVersion = '5.3.34'로 루트(5.3.39)와 다르다. 또한 testImplementation project(':api')는 api → user-service 의존의 역방향이라 모듈 경계 강제가 무력화된다.
security/는 다른 도메인 모듈에는 없는 패키지다. 토큰 발급·검증을 도메인 로직과 섞지 않고 이름으로 드러내기 위해 user-service에만 신설한다. JwtAuthenticationFilter / SecurityConfig는 여기가 아니라 api 모듈로 간다(C 항목) — 이 패키지에는 토큰 발급·검증 로직만 둔다.
F-4. 미사용 파일 삭제
auth/dto/OAuthLoginRequest.java
user/controller/GlobalExceptionHandler.java
user/dto/UserResponseDto.java (bff의 UserResponse로 대체됨을 확인 후)
테이블명 USERS vs ERD상 USER 확정 후 스키마·Mapper·db/init-all.sql 정렬
F-6. 검증
verify: com.ntropy.auth 전체 검색 결과 0건
verify: ./gradlew clean build 성공
verify: WAR 기동 후 /api/users/{userId} 정상 응답
⚠️ 병행 필요 — 다른 모듈에 영향 있음
C가 적용되면 지금까지 인증 없이 열려 있던 API가 전부 401이 된다. bff 컨트롤러 상당수가 아직 @RequestParam Long userId // TODO: AUTH 연동 후 인증 사용자 ID 사용 형태다 (SubscriptionController 등).
이 리팩토링 PR 머지 시점에 FE(김동현)가 모든 요청에 Authorization: Bearer 헤더를 붙이도록 맞춰야 한다
각 도메인 담당자가 @RequestParam userId → AuthenticatedUserIdResolver 기반으로 전환해야 한다 (별도 이슈로 분리 권장)
---
**1. `POST /api/subscriptions/webhook`이 `permitAll` 필수입니다.** PortOne이 외부에서 호출하는 엔드포인트라 JWT가 없습니다. 지금은 필터가 안 돌아서 문제가 없지만, C 적용하는 순간 결제 웹훅이 전부 401로 막힙니다. `PortOneWebhookVerifier`로 자체 서명 검증을 하고 있으니 Security에서 열어주는 게 맞습니다. C-4에 넣어뒀습니다.
**2. CORS preflight가 401로 막힐 수 있습니다.** 현재 CORS는 `ServletConfig.addCorsMappings`, 즉 **DispatcherServlet 안쪽**에서 처리됩니다. Security 필터는 그보다 앞단이라, 인증 헤더가 없는 `OPTIONS` preflight가 필터에서 먼저 401로 잘립니다. `http.cors()`를 켜거나 `.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()`을 넣어야 합니다. 이거 놓치면 "포스트맨은 되는데 브라우저는 안 됨" 증상으로 나타나서 디버깅에 시간 많이 씁니다.
🛠️ 계획된 리팩토링할 기능
services/user-service전체 — 모듈러 모놀리식 컨벤션(BFF 단독 controller / common 인터페이스 경유 / 루트 build.gradle 상속) 정합화user-service가 나머지 8개 모듈과 다른 구조를 갖고 있어, 기존 컨벤션에 맞춰 정리한다.
대상 범위
com.ntropy.user.controller→com.ntropy.bff.controller.usercom.ntropy.user.client.common,com.ntropy.user.dto.common→common모듈com.ntropy.user.security,com.ntropy.auth→api모듈 /com.ntropy.user하위로 재배치services/user-service/build.gradle,api/build.gradleapi/config/WebConfig,api/config/SecurityConfig(신규)🛠 사유
1. controller가 bff-service를 우회한다
AuthController,UserController가 user-service 안에 있고,api/ServletConfig가basePackages = "com.ntropy"를 스캔하므로 실제로 매핑된다./api/auth/**,/api/users/**가 BFF를 거치지 않아 "controller는 bff-service만 노출한다"는 합의가 깨진 상태다.2. 응답 규격이 두 갈래다
bff 컨트롤러는
ApiResponse+ErrorCode+ServiceException형식을 쓰는데, user-service 컨트롤러는 도메인 엔티티User를 그대로 반환하고AccessDeniedException을 던진다. FE가 응답 포맷 두 종류를 분기 처리해야 한다.3. 모듈 간 계약이 common에 없다
UserQueryClient/UserSummary가com.ntropy.user.client.common,com.ntropy.user.dto.common에 있다. 다른 모듈이 사용하려면project(':services:user-service')를 직접 의존해야 해서 모듈 경계가 무너진다. (나머지 18개 client는 전부common/client에 있음)4.
UserBirthDateQueryClient구현체가 없다common/client/UserBirthDateQueryClient를 구현하는 클래스가 프로젝트 전체에 없다. account-service가ObjectProvider로 방어하고 있어 기동은 되지만, 생년월일이 필요한 은행(CODEF 연동)은 런타임에INVALID_REQUEST로 실패한다.5. Security 필터 체인이 서블릿에 등록되어 있지 않다
SecurityConfig가SecurityFilterChain빈을 만들지만springSecurityFilterChain을 필터로 등록하는 지점이 없다.web.xml없음WebConfig.getServletFilters()는CharacterEncodingFilter만 반환AbstractSecurityWebApplicationInitializer/DelegatingFilterProxy검색 결과 0건스프링 레거시는 Boot와 달리
Filter타입 빈을 자동 등록하지 않으므로,JwtAuthenticationFilter는 빈으로 생성만 되고 요청 경로에는 들어가지 않는다.SecurityContextHolder가 항상 비어 있어 bff의AuthenticatedUserIdResolver가 전부UNAUTHORIZED로 떨어진다.6. Security 설정이 도메인 모듈에 있다
SecurityConfig의.anyRequest().authenticated()는 work / account / defense 등 전 모듈의 요청에 적용된다. 한 도메인 모듈이 다른 모든 모듈의 접근 정책을 결정하고 있다. 경로별 인증 규칙은 CORS·DataSource와 같은 조립 단위 설정이므로api에 있어야 한다.7. 컨텍스트 이중 등록
UserServiceConfig에@ComponentScan("com.ntropy.user")가 있다.RootConfig가 이미com.ntropy를 스캔하므로 중복이고, RootConfig는@Controller를 excludeFilter로 빼는데 이 중첩 스캔에는 필터가 없어 컨트롤러 빈이 루트 컨텍스트에도 등록된다. 다른 모듈 config에는@ComponentScan이 없다.8. 패키지 구조가 다른 모듈과 다르다
work-service / account-service는 아래 구조를 공유하는데, user-service만 어긋난다.
domain/entity/model/exception/{Domain}ErrorCodeclient/{provider}/하위 패키지client/직속package-info.javacom.ntropy.{도메인}단일com.ntropy.user+com.ntropy.auth2개특히
com.ntropy.auth에는 3개 파일만 있고, 정작 인증 코드 대부분은user밑에 있어 경계 역할을 못 한다.com.ntropy.authdto/OAuthLoginRequest(미사용),dto/OAuthLoginResponse,security/JwtProvidercom.ntropy.usercontroller/AuthController,security/SecurityConfig,security/JwtAuthenticationFilter,client/KakaoOAuthClient,client/GoogleOAuthClient,dto/TokenRefreshRequestDto,dto/TokenRefreshResponseDto부수 문제로,
com.ntropy.auth는com.ntropy.user의 하위가 아니라 별도 루트 패키지라UserServiceConfig의@ComponentScan("com.ntropy.user")에JwtProvider가 잡히지 않는다. 이를 덮기 위해SecurityConfig에@Import({JwtAuthenticationFilter.class, JwtProvider.class})가 붙어 있는데,JwtAuthenticationFilter는 이미 스캔 대상이라 스캔 이름(jwtAuthenticationFilter)과@ImportFQCN 이름으로 빈이 2개 등록된다. Security 배선을 고치는 순간 어느 인스턴스가 체인에 들어갔는지 불명확해진다.9.
UserErrorCode가 없어 예외 규격을 맞출 수 없다work·account는
exception/{Domain}ErrorCode+ServiceException(XxxErrorCode.YYY)형태로 던진다. user-service는AccessDeniedException,IllegalArgumentException등을 그대로 쓰고 있어 bff의GlobalExceptionHandler가 일관되게 처리하지 못한다. B(응답 규격 통일)의 선행 조건이다.10. 미사용 파일이 남아 있다
auth/dto/OAuthLoginRequest.java— 참조 0건.AuthController가@RequestParam("code")로 직접 받는다user/dto/UserResponseDto.java— 참조 0건.UserController가 엔티티User를 그대로 반환한다user/controller/GlobalExceptionHandler.java— 파일 전체가 주석 처리. bff에 동일 클래스 존재11. 빌드 스크립트가 루트 설정을 상속하지 않는다
다른 모듈은
dependencies { }블록만 두는데, user-service는plugins/repositories/group/version/sourceCompatibility/ext/test를 전부 재선언한다. 특히springVersion = '5.3.34'로 루트(5.3.39)와 다르다. 또한testImplementation project(':api')는 api → user-service 의존의 역방향이라 모듈 경계 강제가 무력화된다.📝 check-lists
A. 모듈 계약을 common으로 이동
com.ntropy.user.client.common.UserQueryClient→com.ntropy.common.client.UserQueryClientcom.ntropy.user.dto.common.UserSummary→com.ntropy.common.dto.user.UserSummaryLocalUserQueryClient를 이동된 인터페이스 기준으로 수정LocalUserBirthDateQueryClient신규 구현 (common.client.UserBirthDateQueryClient)INVALID_REQUEST미발생B. controller를 bff-service로 회수
com.ntropy.bff.controller.user패키지 생성,AuthController/UserController이동bff/dto/user/request,bff/dto/user/response로 신규 작성OAuthLoginResponse,TokenRefreshRequestDto,TokenRefreshResponseDto이동UserResponseDto→bff/dto/user/response/UserResponse로 되살려 사용 (엔티티User직접 반환 제거)ApiResponse엔벨로프로 통일, 예외를ServiceException+ErrorCode로 전환com.ntropy.user.exception.UserErrorCode신규 작성 (ServiceErrorCode구현, account의AccountErrorCode참고)USER_NOT_FOUND,UNAUTHORIZED,FORBIDDEN,INVALID_TOKEN,TOKEN_EXPIRED,OAUTH_PROVIDER_ERROR등UserService의AccessDeniedException/IllegalArgumentException→ServiceException(UserErrorCode.XXX)로 전환UserCommandClient/UserQueryClient인터페이스만 노출controller패키지 삭제GET /api/auth/oauth/{provider},GET /api/auth/me,POST /api/auth/logout,POST /api/auth/refreshGET|PUT|DELETE /api/users/{userId}C. Security를 api 모듈로 이동 및 배선 (C-1안: Spring Security 유지)
C-1. 토큰 검증 계약을 common으로
common/client/TokenVerifier정의 —VerifiedToken verify(String token), 무효 시nullcommon/dto/user/VerifiedToken(Long userId, String role)record 정의LocalTokenVerifier구현 (JwtProvider위임)JwtProvider는 user-service 잔류 (@Value("${jwt.secret}")가user-local.properties에 묶여 있음)C-2. 필터·설정을 api로 이동
JwtAuthenticationFilter→api/security/JwtAuthenticationFilter, 의존을JwtProvider→TokenVerifier로 교체SecurityConfig→api/config/SecurityConfig, user-service의 원본 삭제@Import({JwtAuthenticationFilter.class, JwtProvider.class})제거 (빈 이중 등록 해소)@EnableGlobalMethodSecurity(prePostEnabled = true)유지 여부 결정 — 현재@PreAuthorize사용처 0건C-3. 필터 체인 등록
WebConfig.getServletFilters()에new DelegatingFilterProxy("springSecurityFilterChain")추가CharacterEncodingFilter→DelegatingFilterProxy순서 확인api/build.gradle에spring-security-web,spring-security-config추가 (5.8.11)C-4. 경로 규칙을 전 모듈 기준으로 재작성
permitAll대상 인벤토리 작성 후antMatchers에 반영/api/auth/**(로그인·토큰 재발급)/api/subscriptions/webhook— PortOne이 외부에서 호출. 누락 시 결제 웹훅 전체 실패 (자체 서명 검증 있음)/api/subscriptions/plans— 비로그인 플랜 조회 허용 여부 확인 필요/swagger-ui.html,/webjars/**,/v2/api-docs.anyRequest().authenticated()ServletConfig.addCorsMappings와 별개로 Security 체인에도 CORS 허용 필요 (http.cors()) — preflightOPTIONS가 401로 막히지 않는지 확인C-5. 검증
JwtAuthenticationFilter.doFilterInternal에 로그를 찍어 실제 진입 확인GET /api/jobs→ 401, 유효 토큰 → 200POST /api/subscriptions/webhook→ 200 (permitAll 확인)http://localhost:5173)에서 preflightOPTIONS통과AuthenticatedUserIdResolver가 실제 userId 반환D. 스프링 컨텍스트 정리
UserServiceConfig의@ComponentScan("com.ntropy.user")제거@PropertySource("classpath:user-local.properties")는 유지 (다른 모듈과 동일 패턴)RestTemplate/ObjectMapper빈이 다른 모듈과 중복 등록되지 않는지 확인NoUniqueBeanDefinitionException없이 정상 부팅E. 빌드 스크립트 정합화
services/user-service/build.gradle을dependencies { }블록만 남기고 루트 설정 상속ext.springVersion제거 → 루트의5.3.39사용testImplementation project(':api')제거5.8.11로 통일 (현재 bff5.8.11/ user5.8.12)./gradlew clean build성공F. 패키지 구조 및 미사용 파일 정리
F-1. 루트 패키지 통합
com.ntropy.auth패키지 제거auth/security/JwtProvider→com.ntropy.user.security.JwtProviderauth/dto/OAuthLoginResponse→ bff로 이동 (B 항목과 연계)F-2. 컨벤션 정렬
user/model/User→user/domain/entity/Useruser/client/KakaoOAuthClient,GoogleOAuthClient→user/client/oauth/하위로 이동user/exception/UserErrorCode신규 (B 항목과 동일 작업)package-info.java추가 (work-service 형식 준수)client,client/oauth,config,domain/entity,exception,mapper,security,service, 루트F-3. 최종 구조
F-4. 미사용 파일 삭제
auth/dto/OAuthLoginRequest.javauser/controller/GlobalExceptionHandler.javauser/dto/UserResponseDto.java(bff의UserResponse로 대체됨을 확인 후)F-5. 리소스 위치
src/main/resources/user-service-schema.sql→src/main/resources/db/user-service-schema.sqlUSERSvs ERD상USER확정 후 스키마·Mapper·db/init-all.sql정렬F-6. 검증
com.ntropy.auth전체 검색 결과 0건./gradlew clean build성공/api/users/{userId}정상 응답C가 적용되면 지금까지 인증 없이 열려 있던 API가 전부 401이 된다. bff 컨트롤러 상당수가 아직
@RequestParam Long userId // TODO: AUTH 연동 후 인증 사용자 ID 사용형태다 (SubscriptionController등).Authorization: Bearer헤더를 붙이도록 맞춰야 한다@RequestParam userId→AuthenticatedUserIdResolver기반으로 전환해야 한다 (별도 이슈로 분리 권장)