-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-1] 회원가입, 내 정보 조회, 비밀번호 변경 테스트코드 작성 및 기능 구현 #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: Namjin-kimm
Are you sure you want to change the base?
Changes from all commits
c0dcfd8
78cf859
357da15
5ac9342
e488bd0
6e9f89f
f42cce5
c2620b8
d3cdf71
fea8a99
a61624a
57814f3
33a5b13
9587b68
5d5066c
7e69f0c
c4d7354
07b832b
eac2aad
c5854c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| # CLAUDE.md | ||
|
|
||
| ## 프로젝트 개요 | ||
|
|
||
| Spring Boot 기반 멀티 모듈 Java 프로젝트 (`loopers-java-spring-template`). | ||
| REST API, Batch, Kafka Streaming 3개의 실행 가능한 앱과 재사용 가능한 인프라/지원 모듈로 구성된다. | ||
|
|
||
| ## 기술 스택 및 버전 | ||
|
|
||
| | 구성 요소 | 버전 | | ||
| |-----------|------| | ||
| | Java | 21 (Toolchain) | | ||
| | Spring Boot | 3.4.4 | | ||
| | Spring Cloud Dependencies | 2024.0.1 | | ||
| | Spring Dependency Management | 1.1.7 | | ||
| | Kotlin | 2.0.20 | | ||
| | springdoc-openapi | 2.7.0 | | ||
| | QueryDSL | Jakarta | | ||
| | MySQL Connector/J | Spring Boot 관리 | | ||
| | Mockito | 5.14.0 | | ||
| | spring-mockk | 4.0.2 | | ||
| | instancio-junit | 5.0.2 | | ||
|
|
||
| ## 모듈 구조 | ||
|
|
||
| ``` | ||
| root | ||
| ├── apps/ # 실행 가능한 Spring Boot 애플리케이션 | ||
| │ ├── commerce-api/ # REST API (포트 8080, 관리 포트 8081) | ||
| │ ├── commerce-batch/ # Spring Batch 처리 | ||
| │ └── commerce-streamer/ # Kafka 스트리밍 처리 | ||
| ├── modules/ # 재사용 가능한 인프라 모듈 | ||
| │ ├── jpa/ # JPA/Hibernate + HikariCP 설정 | ||
| │ ├── redis/ # Redis Master-Replica 설정 (Lettuce) | ||
| │ └── kafka/ # Kafka Producer/Consumer 설정 | ||
| └── supports/ # 부가 기능 모듈 | ||
| ├── jackson/ # JSON 직렬화 설정 | ||
| ├── logging/ # Logback + Slack 알림 | ||
| └── monitoring/ # Prometheus + Micrometer 메트릭 | ||
| ``` | ||
|
|
||
| - `apps/` 모듈만 `bootJar` 활성화, 나머지는 `jar` 태스크만 활성화 | ||
|
|
||
| ## 아키텍처 패턴 | ||
|
|
||
| Layered Hexagonal Architecture (Ports & Adapters): | ||
|
|
||
| ``` | ||
| interfaces/ → Controller, API Spec, DTO (HTTP 계층) | ||
| application/ → Facade (유스케이스 조율) | ||
| domain/ → Model, Service, Repository 인터페이스 (비즈니스 로직) | ||
| infrastructure/→ Repository 구현체, JPA Repository (기술 어댑터) | ||
| support/ → 공통 에러, 유틸리티 | ||
| ``` | ||
|
|
||
| 의존성 방향: `interfaces → application → domain ← infrastructure` | ||
|
|
||
| ## 빌드 및 실행 | ||
|
|
||
| ```bash | ||
| # 빌드 | ||
| ./gradlew build | ||
|
|
||
| # commerce-api 실행 | ||
| ./gradlew :apps:commerce-api:bootRun | ||
|
|
||
| # commerce-batch 실행 (job 이름 지정 필수) | ||
| ./gradlew :apps:commerce-batch:bootRun --args='--spring.batch.job.name=demoJob' | ||
|
|
||
| # 테스트 | ||
| ./gradlew test | ||
|
|
||
| # 인프라 (MySQL, Redis, Kafka) | ||
| docker compose -f infra-compose.yml up -d | ||
|
|
||
| # 모니터링 (Prometheus, Grafana) | ||
| docker compose -f monitoring-compose.yml up -d | ||
| ``` | ||
|
|
||
| ## 주요 컨벤션 | ||
|
|
||
| ### 엔티티 | ||
| - `BaseEntity`를 상속하여 `id`, `created_at`, `updated_at`, `deleted_at` 자동 관리 | ||
| - Soft delete 패턴 (`delete()`, `restore()` 메서드) | ||
| - Protected 기본 생성자, `@PrePersist`/`@PreUpdate` 훅 사용 | ||
| - 타임존: UTC 저장, Asia/Seoul 표시 | ||
|
|
||
| ### API 응답 | ||
| ```json | ||
| { | ||
| "meta": { "result": "SUCCESS|FAIL", "errorCode": "...", "message": "..." }, | ||
| "data": { } | ||
| } | ||
| ``` | ||
|
|
||
| ### 에러 처리 | ||
| - `CoreException(ErrorType, message)` → `ApiControllerAdvice`에서 일괄 처리 | ||
| - ErrorType: `BAD_REQUEST(400)`, `NOT_FOUND(404)`, `CONFLICT(409)`, `INTERNAL_ERROR(500)` | ||
|
|
||
| ### DTO 매핑 흐름 | ||
| ``` | ||
| Entity → ExampleInfo (application DTO) → ExampleV1Dto (API DTO) → ApiResponse | ||
| ``` | ||
| - Java Record로 불변 DTO 정의, `from()` 팩토리 메서드 사용 | ||
|
|
||
| ### 네이밍 | ||
| - 패키지: `com.loopers.*` | ||
| - API 버전: 클래스명에 `V1`, `V2` 접미사 (예: `ExampleV1Controller`) | ||
| - DTO: `Dto` 또는 `Info` 접미사 | ||
| - Repository: `Repository` (인터페이스), `JpaRepository` (JPA) | ||
|
|
||
| ### 테스트 | ||
| - JUnit 5, `@Nested` + `@DisplayName`(한국어) 사용 | ||
| - 통합 테스트: `@SpringBootTest(webEnvironment = RANDOM_PORT)` + `TestRestTemplate` | ||
| - Testcontainers: MySQL, Redis, Kafka | ||
| - `DatabaseCleanUp` 픽스처로 테스트 간 데이터 정리 (`@AfterEach`) | ||
| - JaCoCo 코드 커버리지 (XML 리포트) | ||
| - 프로파일: `test`, 순차 실행 (`maxParallelForks = 1`) | ||
| - 테스트 데이터 중 여러 테스트에서 반복 사용되는 값은 클래스 레벨 상수(`private static final`)로 선언한다 | ||
|
|
||
| ### 코드 스타일 | ||
| - Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Slf4j` | ||
| - Jackson: `NON_NULL` 직렬화, 빈 문자열 → null 역직렬화 | ||
| - 비즈니스 컨텍스트 주석은 한국어 사용 | ||
|
|
||
| ## 환경 프로파일 | ||
|
|
||
| `local`, `test`, `dev`, `qa`, `prd` | ||
|
|
||
| | 설정 | local/test | dev/qa/prd | | ||
| |------|-----------|------------| | ||
| | DDL auto | create | none | | ||
| | SQL 로깅 | 활성화 | 비활성화 | | ||
| | Swagger | 활성화 | prd만 비활성화 | | ||
| | Batch 스키마 | always | never | | ||
|
|
||
| ## 환경 변수 | ||
|
|
||
| | 변수 | 용도 | | ||
| |------|------| | ||
| | `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PWD` | MySQL 접속 | | ||
| | `REDIS_MASTER_HOST`, `REDIS_MASTER_PORT` | Redis Master | | ||
| | `REDIS_REPLICA_1_HOST`, `REDIS_REPLICA_1_PORT` | Redis Replica | | ||
| | `BOOTSTRAP_SERVERS` | Kafka 브로커 | |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| public record ChangePasswordCommand( | ||
| String loginId, | ||
| String currentPassword, | ||
| String newPassword | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| public record SignupCommand( | ||
| String loginId, | ||
| String password, | ||
| String name, | ||
| String birthday, | ||
| String email | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
| import com.loopers.domain.user.UserService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class UserFacade { | ||
| private final UserService userService; | ||
|
|
||
| public UserInfo signUp(SignupCommand command) { | ||
| UserModel user = userService.signup( | ||
| command.loginId(), command.password(), command.name(), command.birthday(), command.email() | ||
| ); | ||
| return UserInfo.from(user); | ||
| } | ||
|
|
||
| public UserInfo getMyInfo(String loginId) { | ||
| UserModel user = userService.findByLoginId(loginId); | ||
| return UserInfo.from(user); | ||
| } | ||
|
|
||
| public void changePassword(ChangePasswordCommand command) { | ||
| userService.changePassword(command.loginId(), command.currentPassword(), command.newPassword()); | ||
| } | ||
| } | ||
|
Comment on lines
+8
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "UserService.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 159 🏁 Script executed: rg -n "@Transactional|@Service|public.*signup|public.*changePassword|public.*findByLoginId" --type=java -A 2 -B 1 -g "**/UserService.java"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1622 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3451 트랜잭션 경계가 없어 데이터 정합성 위험이 존재한다.
예를 들어 개선안:
🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.loopers.application.user; | ||
|
|
||
| import com.loopers.domain.user.UserModel; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.Objects; | ||
|
|
||
| public record UserInfo( | ||
| Long id, | ||
| String loginId, | ||
| String name, | ||
| LocalDate birthday, | ||
| String email | ||
| ) { | ||
| public static UserInfo from(UserModel userModel) { | ||
| Objects.requireNonNull(userModel, "userModel은 null일 수 없습니다."); | ||
| return new UserInfo( | ||
| userModel.getId(), | ||
| userModel.getLoginId(), | ||
| userModel.getName(), | ||
| userModel.getBirthday(), | ||
| userModel.getEmail() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package com.loopers.config; | ||
|
|
||
| import com.loopers.domain.user.UserService; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.servlet.HandlerInterceptor; | ||
|
|
||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class AuthInterceptor implements HandlerInterceptor { | ||
| private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class); | ||
|
|
||
| private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; | ||
| private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; | ||
|
Comment on lines
+20
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 보안 및 운영 관점에서 인증 방식 검토가 필요하다. 매 요청마다 헤더에 비밀번호를 평문으로 전송하는 방식은 다음 문제가 있다:
권장 수정안: JWT 토큰 또는 세션 기반 인증으로 전환하여, 로그인 시 한 번만 인증하고 이후 토큰/세션으로 검증하는 방식을 고려해야 한다. 학습/프로토타입 목적이라면 현재 방식을 유지하되, 프로덕션 배포 전 반드시 개선이 필요하다. 🤖 Prompt for AI Agents |
||
| public static final String ATTR_LOGIN_ID = "loginId"; | ||
|
|
||
| private final UserService userService; | ||
|
|
||
| @Override | ||
| public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { | ||
| String loginId = request.getHeader(HEADER_LOGIN_ID); | ||
| String loginPw = request.getHeader(HEADER_LOGIN_PW); | ||
|
|
||
| if (loginId == null || loginId.isBlank() || loginPw == null || loginPw.isBlank()) { | ||
| log.warn("인증 헤더 누락 - URI: {}, RemoteAddr: {}", request.getRequestURI(), request.getRemoteAddr()); | ||
| throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); | ||
| } | ||
|
|
||
| try{ | ||
| userService.authenticate(loginId, loginPw); | ||
| }catch(CoreException e){ | ||
| log.warn("인증 실패 - loginId: {}, URI: {}", loginId, request.getRequestURI()); | ||
|
Comment on lines
+38
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 로그에
🤖 Prompt for AI Agents |
||
| throw e; | ||
| } | ||
|
Comment on lines
+36
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "ApiControllerAdvice.java" -o -name "*ControllerAdvice*.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 170 🏁 Script executed: find . -name "ErrorType.java" -o -name "*ErrorType*.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 159 🏁 Script executed: # Also search for AuthInterceptor to confirm its exact location and structure
find . -name "AuthInterceptor.java" -exec head -50 {} +Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1821 🏁 Script executed: cat ./apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5938 🏁 Script executed: cat ./apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 917 🏁 Script executed: # Verify Spring MVC interceptor exception handling in Spring Boot context
# Check if there are any other exception handlers that might affect interceptor exceptions
rg "ExceptionHandler|@ControllerAdvice|@RestControllerAdvice" --type java -A 2 | head -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3854 🏁 Script executed: # Check if there are any tests or documentation about interceptor exception handling
fd "test" --type f -name "*Test.java" -o -name "*Tests.java" | xargs grep -l "AuthInterceptor\|preHandle" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 315 인증 헤더 누락 시 원격 주소 대신 라인 39에서 로그를 🤖 Prompt for AI Agents |
||
| request.setAttribute(ATTR_LOGIN_ID, loginId); | ||
|
|
||
| return true; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.loopers.config; | ||
|
|
||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||
|
|
||
| @Configuration | ||
| public class SecurityConfig { | ||
|
|
||
| @Bean | ||
| public PasswordEncoder passwordEncoder() { | ||
| return new BCryptPasswordEncoder(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.loopers.config; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.web.servlet.config.annotation.InterceptorRegistry; | ||
| import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Configuration | ||
| public class WebMvcConfig implements WebMvcConfigurer { | ||
|
|
||
| private final AuthInterceptor authInterceptor; | ||
|
|
||
| @Override | ||
| public void addInterceptors(InterceptorRegistry registry) { | ||
| registry.addInterceptor(authInterceptor) | ||
| .addPathPatterns("/api/**") | ||
| .excludePathPatterns("/api/v1/users/signup"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.loopers.domain.user; | ||
|
|
||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
|
|
||
| public class Password { | ||
|
|
||
| private static final int MIN_LENGTH = 8; | ||
| private static final int MAX_LENGTH = 16; | ||
| private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\",./<>?]*$"; | ||
|
|
||
| public static void validate(String value, String birthday) { | ||
| if (value == null || value.isBlank()) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); | ||
| } | ||
|
|
||
| if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8자리 이상, 16자리 이하이어야 합니다."); | ||
| } | ||
|
|
||
| if (!value.matches(PASSWORD_PATTERN)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호 형식이 올바르지 않습니다."); | ||
| } | ||
|
|
||
| String birthdayWithoutDash = birthday.replace("-", ""); | ||
| if (value.contains(birthday) || value.contains(birthdayWithoutDash)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); | ||
|
Comment on lines
+12
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. birthday가 null이면 NPE로 500이 발생할 수 있다 🔧 예시 수정안 public static void validate(String value, String birthday) {
if (value == null || value.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다.");
}
+ if (birthday == null || birthday.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다.");
+ }
if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8자리 이상, 16자리 이하이어야 합니다.");
}
if (!value.matches(PASSWORD_PATTERN)) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호 형식이 올바르지 않습니다.");
}
String birthdayWithoutDash = birthday.replace("-", "");
if (value.contains(birthday) || value.contains(birthdayWithoutDash)) {
throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다.");
}
}🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 429
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1290
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 104
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 233
🏁 Script executed:
# gradle.properties 파일 내용 확인 cat -n gradle.propertiesRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 747
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4157
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 247
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3251
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 232
spring-security-crypto 의존성의 버전을 gradle.properties에서 명시적으로 관리해야 한다.
현재 버전이 고정되지 않아 빌드 시점에 따라 해상되는 버전이 달라질 수 있고, 운영 환경에서 보안 취약점 발생 시 대응이 지연된다. 프로젝트의 다른 외부 라이브러리(springdoc-openapi 등)는 gradle.properties에서 버전을 관리하고 있으므로 spring-security-crypto도 동일한 패턴으로 통일해야 한다.
gradle.properties에
springSecurityCryptoVersion=항목을 추가하고, build.gradle.kts에서${project.properties["springSecurityCryptoVersion"]}로 참조하도록 수정해야 한다. 추가로 CI 파이프라인에 의존성 취약점 스캔 단계를 추가하여 정기적으로 의존성 버전의 보안 상태를 점검하는 편이 낫다.수정 예시
gradle.properties에 추가:
build.gradle.kts 수정:
🤖 Prompt for AI Agents