diff --git a/.github/workflows/pipeline-monorepo.yaml b/.github/workflows/pipeline-monorepo.yaml index 3435442..f80ff26 100644 --- a/.github/workflows/pipeline-monorepo.yaml +++ b/.github/workflows/pipeline-monorepo.yaml @@ -4,6 +4,7 @@ on: push: branches: - dev + - perf/after-optimization paths-ignore: - 'tests/**' # k6 스크립트 - 'docs/**' # 문서 폴더 diff --git a/apigateway-service/pom.xml b/apigateway-service/pom.xml index 2ece791..e34cd50 100644 --- a/apigateway-service/pom.xml +++ b/apigateway-service/pom.xml @@ -76,6 +76,15 @@ 2.5.0 + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + com.example common-module diff --git a/apigateway-service/src/main/resources/application.yml b/apigateway-service/src/main/resources/application.yml index 4ea154c..d72ceb9 100644 --- a/apigateway-service/src/main/resources/application.yml +++ b/apigateway-service/src/main/resources/application.yml @@ -74,6 +74,9 @@ spring: uri: http://community-service:80 predicates: - Path=/community-service/** + httpclient: + connect-timeout: 10000 + response-timeout: 30s app: gateway: @@ -146,4 +149,19 @@ springdoc: # 게이트웨이의 공인 주소(ngrok)를 명시하여 Mixed Content 문제 해결 servers: - url: https://impressionless-connaturally-jonie.ngrok-free.dev - description: NGrok Development Server \ No newline at end of file + description: NGrok Development Server + +# --- Monitoring Configuration (Added) --- +management: + endpoints: + web: + exposure: + include: prometheus, health, info # 프로메테우스 지표 노출 허용 + endpoint: + health: + show-details: always + prometheus: + enabled: true + metrics: + tags: + application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가 \ No newline at end of file diff --git a/auth-service/pom.xml b/auth-service/pom.xml index 1ef14c0..8370665 100644 --- a/auth-service/pom.xml +++ b/auth-service/pom.xml @@ -98,10 +98,6 @@ runtime - - org.springframework.cloud - spring-cloud-starter-openfeign - org.springframework.cloud spring-cloud-starter-kubernetes-client-loadbalancer diff --git a/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java b/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java index 8e130d1..75974e7 100644 --- a/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java +++ b/auth-service/src/main/java/com/example/authservice/AuthServiceApplication.java @@ -2,7 +2,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableAsync; @@ -11,7 +10,6 @@ @SpringBootApplication(scanBasePackages = "com.example") @EnableJpaAuditing @EnableAsync -@EnableFeignClients public class AuthServiceApplication { public static void main(String[] args) { diff --git a/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java b/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java index 8848e9f..6571957 100644 --- a/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java +++ b/auth-service/src/main/java/com/example/authservice/config/SecurityConfig.java @@ -40,7 +40,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/actuator/**").permitAll() // 모든 /auth/** 경로는 인증 없이 허용 .requestMatchers("/auth/**").permitAll() .requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll() diff --git a/auth-service/src/main/resources/application.yml b/auth-service/src/main/resources/application.yml index 4d3dba7..d265c5d 100644 --- a/auth-service/src/main/resources/application.yml +++ b/auth-service/src/main/resources/application.yml @@ -106,10 +106,23 @@ jwt: # Feign (User Service ???) app: - feign: - user-service-url: http://user-service:80 # k8s ?? ??? ?? (?? 80) frontend: redirect-url: ${APP_FRONTEND_REDIRECT_URL} # ex) http://127.0.0.1:5173/auth/callback springdoc: - override-with-generic-response: false \ No newline at end of file + override-with-generic-response: false + +# --- Monitoring Configuration (Added) --- +management: + endpoints: + web: + exposure: + include: prometheus, health, info # 프로메테우스 지표 노출 허용 + endpoint: + health: + show-details: always + prometheus: + enabled: true + metrics: + tags: + application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가 \ No newline at end of file diff --git a/chat-service/pom.xml b/chat-service/pom.xml index 8a13326..dcd7255 100644 --- a/chat-service/pom.xml +++ b/chat-service/pom.xml @@ -124,6 +124,16 @@ spring-boot-starter-test test + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + diff --git a/chat-service/src/main/resources/application.yml b/chat-service/src/main/resources/application.yml index c0b29c8..e29ebb8 100644 --- a/chat-service/src/main/resources/application.yml +++ b/chat-service/src/main/resources/application.yml @@ -37,4 +37,19 @@ spring: logging: level: org.springframework.web.socket: DEBUG - com.example.chatservice: DEBUG \ No newline at end of file + com.example.chatservice: DEBUG + +# --- Monitoring Configuration (Added) --- +management: + endpoints: + web: + exposure: + include: prometheus, health, info # 프로메테우스 지표 노출 허용 + endpoint: + health: + show-details: always + prometheus: + enabled: true + metrics: + tags: + application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가 \ No newline at end of file diff --git a/common-module/src/main/java/com/example/commonmodule/dto/event/UserProfileCreationFailureEvent.java b/common-module/src/main/java/com/example/commonmodule/dto/event/UserProfileCreationFailureEvent.java index 15c5b59..aea9382 100644 --- a/common-module/src/main/java/com/example/commonmodule/dto/event/UserProfileCreationFailureEvent.java +++ b/common-module/src/main/java/com/example/commonmodule/dto/event/UserProfileCreationFailureEvent.java @@ -9,5 +9,5 @@ @AllArgsConstructor public class UserProfileCreationFailureEvent { private Long userId; - private String reason; // 실패 사유 + private String reason; } \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/controller/PostController.java b/community-service/src/main/java/com/example/communityservice/controller/PostController.java index 04c640a..5866e19 100644 --- a/community-service/src/main/java/com/example/communityservice/controller/PostController.java +++ b/community-service/src/main/java/com/example/communityservice/controller/PostController.java @@ -6,8 +6,7 @@ import com.example.communityservice.dto.request.PostCreateRequest; import com.example.communityservice.dto.request.PostUpdateRequest; import com.example.communityservice.dto.request.RecruitmentStatusRequest; -import com.example.communityservice.dto.response.PostDetailResponse; -import com.example.communityservice.dto.response.PostResponse; +import com.example.communityservice.dto.response.*; import com.example.communityservice.entity.enumerate.PostCategory; import com.example.communityservice.service.PostService; import io.swagger.v3.oas.annotations.Operation; @@ -79,12 +78,13 @@ public ResponseEntity deletePost(@AuthenticationPrincipal AuthUser authUse @Operation(summary = "게시글 목록 조회 (검색/필터)", description = "카테고리, 키워드, 해결 여부 등을 조건으로 게시글 목록을 조회합니다. (인증 불필요)") @GetMapping("/posts") - public ResponseEntity> getPosts( + public ResponseEntity> getPosts( @Parameter(description = "카테고리 (QNA, INFO, RECRUIT)") @RequestParam(required = false) PostCategory category, @Parameter(description = "검색 키워드 (제목 + 내용)") @RequestParam(required = false) String keyword, @Parameter(description = "해결 여부 (QnA 전용)") @RequestParam(required = false) Boolean isSolved, @Parameter(description = "페이징 설정 (기본: 최신순 10개)") @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - return ResponseEntity.ok(postService.getPosts(category, keyword, isSolved, pageable)); + Page page = postService.getPosts(category, keyword, isSolved, pageable); + return ResponseEntity.ok(new CustomPageResponse<>(page)); } @Operation(summary = "게시글 상세 조회", description = "게시글의 상세 내용과 계층형 댓글 목록을 조회합니다. 로그인 시 북마크 여부가 포함됩니다.") @@ -202,27 +202,26 @@ public ResponseEntity updateRecruitmentStatus(@AuthenticationPrincipal Aut return ResponseEntity.ok().build(); } - @Operation(summary = "내가 쓴 게시글 조회", description = "로그인한 사용자가 작성한 게시글 목록을 조회합니다.") + @Operation(summary = "내가 쓴 게시글 조회", description = "로그인한 사용자가 작성한 게시글 목록을 조회합니다. (카테고리 필터링 가능)") @SecurityRequirement(name = "BearerAuthentication") @GetMapping("/posts/me") - public ResponseEntity> getMyPosts(@AuthenticationPrincipal AuthUser authUser, - @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - return ResponseEntity.ok(postService.getMyPosts(authUser.getUserId(), pageable)); + public ResponseEntity> getMyPosts( + @AuthenticationPrincipal AuthUser authUser, + @Parameter(description = "카테고리 (QNA, INFO, RECRUIT), 미입력 시 전체") @RequestParam(required = false) PostCategory category, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page page = postService.getMyPosts(authUser.getUserId(), category, pageable); + return ResponseEntity.ok(new CustomPageResponse<>(page)); } - @Operation(summary = "내가 북마크한 글 조회", description = "로그인한 사용자가 북마크한 게시글 목록을 조회합니다.") + @Operation(summary = "내가 북마크한 글 조회", description = "로그인한 사용자가 북마크한 게시글 목록을 조회합니다. (카테고리 필터링 가능)") @SecurityRequirement(name = "BearerAuthentication") @GetMapping("/posts/me/bookmarks") - public ResponseEntity> getMyBookmarkedPosts(@AuthenticationPrincipal AuthUser authUser, - @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - return ResponseEntity.ok(postService.getMyBookmarkedPosts(authUser.getUserId(), pageable)); + public ResponseEntity> getMyBookmarkedPosts( + @AuthenticationPrincipal AuthUser authUser, + @Parameter(description = "카테고리 (QNA, INFO, RECRUIT), 미입력 시 전체") @RequestParam(required = false) PostCategory category, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page page = postService.getMyBookmarkedPosts(authUser.getUserId(), category, pageable); + return ResponseEntity.ok(new CustomPageResponse<>(page)); } - @Operation(summary = "내가 댓글 단 글 조회", description = "로그인한 사용자가 댓글을 작성한 게시글 목록을 조회합니다.") - @SecurityRequirement(name = "BearerAuthentication") - @GetMapping("/posts/me/commented") - public ResponseEntity> getMyCommentedPosts(@AuthenticationPrincipal AuthUser authUser, - @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - return ResponseEntity.ok(postService.getMyCommentedPosts(authUser.getUserId(), pageable)); - } } \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/controller/TestSetupController.java b/community-service/src/main/java/com/example/communityservice/controller/TestSetupController.java index db60af4..836efe2 100644 --- a/community-service/src/main/java/com/example/communityservice/controller/TestSetupController.java +++ b/community-service/src/main/java/com/example/communityservice/controller/TestSetupController.java @@ -40,12 +40,18 @@ public ResponseEntity setupPosts(@RequestBody Map request) // 2. 데이터 생성 List posts = new ArrayList<>(); + + PostCategory[] categories = PostCategory.values(); + for (int i = 0; i < count; i++) { + + PostCategory category = categories[i % categories.length]; + posts.add(PostEntity.builder() .userId(userId) .title("성능 테스트용 게시글 " + i) .content("이 게시글은 성능 테스트를 위해 생성된 더미 데이터입니다. ".repeat(5)) // 내용 불리기 - .category(PostCategory.INFO) + .category(category) .build()); } diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/CommentResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/CommentResponse.java index e0e67ff..5f6dc3b 100644 --- a/community-service/src/main/java/com/example/communityservice/dto/response/CommentResponse.java +++ b/community-service/src/main/java/com/example/communityservice/dto/response/CommentResponse.java @@ -1,38 +1,48 @@ package com.example.communityservice.dto.response; -import com.example.communityservice.entity.PostCommentEntity; -import lombok.Builder; -import lombok.Data; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -@Data +@Getter +@Setter @Builder +@NoArgsConstructor +@AllArgsConstructor public class CommentResponse { private Long id; - private Long userId; - - // 작성자 정보 (로컬 캐시에서 매핑) + private Long postId; + private Long userId; // 작성자 ID private String writerName; private String writerEmail; - private String content; - private boolean isAccepted; // QnA 채택 여부 + private boolean isAccepted; // 채택 여부 + + @JsonIgnore // 계층 구조 생성용 (클라이언트 응답에는 제외 가능) + private Long parentId; + private LocalDateTime createdAt; + private LocalDateTime lastModifiedAt; - // 대댓글 리스트 (계층형 구조) - private List children; + @Builder.Default + private List children = new ArrayList<>(); - public static CommentResponse from(PostCommentEntity entity) { - return CommentResponse.builder() - .id(entity.getId()) - .userId(entity.getUserId()) - .content(entity.getContent()) - .isAccepted(entity.isAccepted()) - .createdAt(entity.getCreatedAt()) - .children(new ArrayList<>()) // 초기화 - .build(); + // QueryDSL Projections.constructor 사용을 위한 생성자 (children 제외) + public CommentResponse(Long id, Long postId, Long userId, String writerName, String writerEmail, + String content, boolean isAccepted, Long parentId, + LocalDateTime createdAt, LocalDateTime lastModifiedAt) { + this.id = id; + this.postId = postId; + this.userId = userId; + this.writerName = writerName; + this.writerEmail = writerEmail; + this.content = content; + this.isAccepted = isAccepted; + this.parentId = parentId; + this.createdAt = createdAt; + this.lastModifiedAt = lastModifiedAt; } } \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/CustomPageResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/CustomPageResponse.java new file mode 100644 index 0000000..8a4335f --- /dev/null +++ b/community-service/src/main/java/com/example/communityservice/dto/response/CustomPageResponse.java @@ -0,0 +1,27 @@ +package com.example.communityservice.dto.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class CustomPageResponse { + private List content; + private int pageNumber; + private int pageSize; + private long totalElements; + private int totalPages; + private boolean last; + + public CustomPageResponse(Page page) { + this.content = page.getContent(); + this.pageNumber = page.getNumber(); + this.pageSize = page.getSize(); + this.totalElements = page.getTotalElements(); + this.totalPages = page.getTotalPages(); + this.last = page.isLast(); + } +} \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/MyBookmarkPostResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/MyBookmarkPostResponse.java new file mode 100644 index 0000000..920a609 --- /dev/null +++ b/community-service/src/main/java/com/example/communityservice/dto/response/MyBookmarkPostResponse.java @@ -0,0 +1,42 @@ +package com.example.communityservice.dto.response; + +import com.example.communityservice.entity.PostEntity; +import com.example.communityservice.entity.enumerate.PostCategory; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +public class MyBookmarkPostResponse { + private Long id; + private String title; + private String content; // 요약본 + private PostCategory category; + private LocalDateTime createdAt; + private String writerName; // 원 글 작성자 이름 + + // QueryDSL Projections.constructor 사용을 위한 생성자 + public MyBookmarkPostResponse(Long id, String title, String content, PostCategory category, LocalDateTime createdAt, String writerName) { + this.id = id; + this.title = title; + this.content = content; + this.category = category; + this.createdAt = createdAt; + this.writerName = writerName; + } + + public static MyBookmarkPostResponse from(PostEntity entity) { + return MyBookmarkPostResponse.builder() + .id(entity.getId()) + .title(entity.getTitle()) + .content(entity.getContent()) + .category(entity.getCategory()) + .createdAt(entity.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/MyPostResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/MyPostResponse.java new file mode 100644 index 0000000..0e596f3 --- /dev/null +++ b/community-service/src/main/java/com/example/communityservice/dto/response/MyPostResponse.java @@ -0,0 +1,31 @@ +package com.example.communityservice.dto.response; + +import com.example.communityservice.entity.PostEntity; +import com.example.communityservice.entity.enumerate.PostCategory; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class MyPostResponse { // 내가 쓴 글 반환 목적 DTO + private Long id; + private String title; + private String content; + private PostCategory category; + private LocalDateTime createdAt; + private LocalDateTime lastModifiedAt; + + // Entity -> DTO 변환 메서드 + public static MyPostResponse from(PostEntity entity) { + return MyPostResponse.builder() + .id(entity.getId()) + .title(entity.getTitle()) + .content(entity.getContent()) + .category(entity.getCategory()) + .createdAt(entity.getCreatedAt()) + .lastModifiedAt(entity.getLastModifiedAt()) + .build(); + } +} diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/PostDetailResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/PostDetailResponse.java index 0e471c5..01c03ce 100644 --- a/community-service/src/main/java/com/example/communityservice/dto/response/PostDetailResponse.java +++ b/community-service/src/main/java/com/example/communityservice/dto/response/PostDetailResponse.java @@ -1,56 +1,55 @@ package com.example.communityservice.dto.response; -import com.example.communityservice.entity.PostEntity; import com.example.communityservice.entity.enumerate.PostCategory; -import com.example.communityservice.entity.enumerate.RecruitmentStatus; -import lombok.Builder; -import lombok.Data; +import lombok.*; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; -@Data +@Getter +@Setter @Builder +@NoArgsConstructor +@AllArgsConstructor public class PostDetailResponse { private Long id; private Long userId; - - // 작성자 정보 private String writerName; private String writerEmail; - private PostCategory category; private String title; private String content; private Long viewCount; private Long bookmarkCount; - - // 상태 값 - private Boolean isSolved; - private RecruitmentStatus recruitmentStatus; - - // 현재 사용자의 북마크 여부 + private Long commentCount; + private boolean isSolved; private boolean isBookmarked; - - // 댓글 목록 (계층형) - private List comments; - private LocalDateTime createdAt; private LocalDateTime lastModifiedAt; - public static PostDetailResponse from(PostEntity entity) { - return PostDetailResponse.builder() - .id(entity.getId()) - .userId(entity.getUserId()) - .category(entity.getCategory()) - .title(entity.getTitle()) - .content(entity.getContent()) - .viewCount(entity.getViewCount()) - .bookmarkCount(entity.getBookmarkCount()) - .isSolved(entity.isSolved()) - .recruitmentStatus(entity.getRecruitmentStatus()) - .createdAt(entity.getCreatedAt()) - .lastModifiedAt(entity.getLastModifiedAt()) - .build(); + @Builder.Default + private List comments = new ArrayList<>(); + + // QueryDSL용 생성자 (comments 제외) + public PostDetailResponse(Long id, Long userId, String writerName, String writerEmail, + PostCategory category, String title, String content, + Long viewCount, Long bookmarkCount, Long commentCount, + boolean isSolved, boolean isBookmarked, + LocalDateTime createdAt, LocalDateTime lastModifiedAt) { + this.id = id; + this.userId = userId; + this.writerName = writerName; + this.writerEmail = writerEmail; + this.category = category; + this.title = title; + this.content = content; + this.viewCount = viewCount; + this.bookmarkCount = bookmarkCount; + this.commentCount = commentCount; + this.isSolved = isSolved; + this.isBookmarked = isBookmarked; + this.createdAt = createdAt; + this.lastModifiedAt = lastModifiedAt; } } \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/dto/response/PostResponse.java b/community-service/src/main/java/com/example/communityservice/dto/response/PostResponse.java index 2d4858e..7f1c778 100644 --- a/community-service/src/main/java/com/example/communityservice/dto/response/PostResponse.java +++ b/community-service/src/main/java/com/example/communityservice/dto/response/PostResponse.java @@ -3,20 +3,21 @@ import com.example.communityservice.entity.PostEntity; import com.example.communityservice.entity.enumerate.PostCategory; import com.example.communityservice.entity.enumerate.RecruitmentStatus; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Collectors; @Data @Builder +@NoArgsConstructor public class PostResponse { private Long id; private Long userId; - // 작성자 정보 (Service에서 채움) + // 작성자 정보 private String writerName; private String writerEmail; @@ -25,11 +26,34 @@ public class PostResponse { private String content; private Long viewCount; private Long bookmarkCount; + private Long commentCount; private Boolean isSolved; private RecruitmentStatus recruitmentStatus; private LocalDateTime createdAt; private LocalDateTime lastModifiedAt; + // QueryDSL Projections.constructor 사용을 위한 생성자 + public PostResponse(Long id, Long userId, String writerName, String writerEmail, + PostCategory category, String title, String content, + Long viewCount, Long bookmarkCount, Long commentCount, // [추가] + Boolean isSolved, RecruitmentStatus recruitmentStatus, + LocalDateTime createdAt, LocalDateTime lastModifiedAt) { + this.id = id; + this.userId = userId; + this.writerName = writerName; + this.writerEmail = writerEmail; + this.category = category; + this.title = title; + this.content = content; + this.viewCount = viewCount; + this.bookmarkCount = bookmarkCount; + this.commentCount = commentCount; + this.isSolved = isSolved; + this.recruitmentStatus = recruitmentStatus; + this.createdAt = createdAt; + this.lastModifiedAt = lastModifiedAt; + } + public static PostResponse from(PostEntity entity) { return PostResponse.builder() .id(entity.getId()) @@ -39,6 +63,7 @@ public static PostResponse from(PostEntity entity) { .content(entity.getContent()) .viewCount(entity.getViewCount()) .bookmarkCount(entity.getBookmarkCount()) + .commentCount(entity.getCommentCount()) .isSolved(entity.getCategory() == PostCategory.QNA ? entity.isSolved() : null) .recruitmentStatus(entity.getCategory() == PostCategory.RECRUIT ? entity.getRecruitmentStatus() : null) .createdAt(entity.getCreatedAt()) diff --git a/community-service/src/main/java/com/example/communityservice/entity/PostBookmarkEntity.java b/community-service/src/main/java/com/example/communityservice/entity/PostBookmarkEntity.java index 35fddd3..88ab792 100644 --- a/community-service/src/main/java/com/example/communityservice/entity/PostBookmarkEntity.java +++ b/community-service/src/main/java/com/example/communityservice/entity/PostBookmarkEntity.java @@ -7,9 +7,13 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "post_bookmark", uniqueConstraints = { - @UniqueConstraint(name = "uk_post_user_bookmark", columnNames = {"post_id", "user_id"}) -}) +@Table(name = "post_bookmark", + uniqueConstraints = { + @UniqueConstraint(name = "uk_post_user_bookmark", columnNames = {"post_id", "user_id"}) + }, indexes = { + @Index(name = "idx_bookmark_user_date", columnList = "user_id, created_at DESC") // 내 북마크 목록 조회용 (userId로 조회 + 최신순 정렬) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PostBookmarkEntity extends BaseEntity { diff --git a/community-service/src/main/java/com/example/communityservice/entity/PostCommentEntity.java b/community-service/src/main/java/com/example/communityservice/entity/PostCommentEntity.java index 38daa80..d902b4c 100644 --- a/community-service/src/main/java/com/example/communityservice/entity/PostCommentEntity.java +++ b/community-service/src/main/java/com/example/communityservice/entity/PostCommentEntity.java @@ -12,7 +12,9 @@ import java.util.List; @Entity -@Table(name = "community_comment") +@Table(name = "community_comment", indexes = { + @Index(name = "idx_comment_post", columnList = "post_id, created_at ASC") // 게시글별 댓글 목록 조회 +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PostCommentEntity extends BaseEntity { diff --git a/community-service/src/main/java/com/example/communityservice/entity/PostEntity.java b/community-service/src/main/java/com/example/communityservice/entity/PostEntity.java index f75c198..6eddc32 100644 --- a/community-service/src/main/java/com/example/communityservice/entity/PostEntity.java +++ b/community-service/src/main/java/com/example/communityservice/entity/PostEntity.java @@ -14,7 +14,11 @@ import java.util.List; @Entity -@Table(name = "community_post") +@Table(name = "community_post", indexes = { + @Index(name = "idx_post_category_view", columnList = "category, view_count DESC"), // 1. 카테고리별 조회수 정렬 (인기글) + @Index(name = "idx_post_category_date", columnList = "category, created_at DESC"), // 2. 카테고리별 최신순 정렬 (목록) + @Index(name = "idx_post_user_id", columnList = "user_id") // 3. 내가 쓴 글 조회 +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PostEntity extends BaseEntity { @@ -32,8 +36,7 @@ public class PostEntity extends BaseEntity { @Column(nullable = false) private String title; - @Lob - @Column(nullable = false, columnDefinition = "LONGTEXT") + @Column(nullable = false, columnDefinition = "MEDIUMTEXT") private String content; @Column(nullable = false) @@ -44,6 +47,10 @@ public class PostEntity extends BaseEntity { @ColumnDefault("0") private Long bookmarkCount = 0L; + @Column(nullable = false) + @ColumnDefault("0") + private Long commentCount = 0L; + @Column(nullable = false) @ColumnDefault("false") private boolean isSolved = false; // QNA 전용 @@ -71,20 +78,32 @@ public void update(String title, String content) { this.content = content; } - public void increaseViewCount() { - this.viewCount++; + // === 통계 데이터 증감 메서드 === + + // 조회수 증가 (스케줄러가 사용) + public void increaseViewCount(Long count) { + this.viewCount += count; } - // 북마크 증가 + // 댓글 수 관리 + public void increaseCommentCount() { + this.commentCount++; + } + + public void decreaseCommentCount() { + this.commentCount = Math.max(0, this.commentCount - 1); + } + + // 북마크 수 관리 public void increaseBookmarkCount() { this.bookmarkCount++; } - // 북마크 감소 public void decreaseBookmarkCount() { this.bookmarkCount = Math.max(0, this.bookmarkCount - 1); } + // 상태 변경 메서드 public void markAsSolved() { if (this.category == PostCategory.QNA) { this.isSolved = true; @@ -96,5 +115,4 @@ public void updateRecruitmentStatus(RecruitmentStatus status) { this.recruitmentStatus = status; } } - } \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/repository/PostRepository.java b/community-service/src/main/java/com/example/communityservice/repository/PostRepository.java index e6738d8..d55ca3f 100644 --- a/community-service/src/main/java/com/example/communityservice/repository/PostRepository.java +++ b/community-service/src/main/java/com/example/communityservice/repository/PostRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -11,11 +12,26 @@ public interface PostRepository extends JpaRepository, PostRep // 내가 쓴 게시글 조회 Page findAllByUserId(Long userId, Pageable pageable); - // 내가 북마크한 게시글 조회 (북마크 엔티티와 조인) + // 내가 북마크한 게시글 조회 @Query("SELECT b.post FROM PostBookmarkEntity b WHERE b.userId = :userId ORDER BY b.createdAt DESC") Page findBookmarkedPosts(@Param("userId") Long userId, Pageable pageable); - // 내가 댓글 단 게시글 조회 (중복 제거) + // 내가 댓글 단 게시글 조회 @Query("SELECT DISTINCT c.post FROM PostCommentEntity c WHERE c.userId = :userId ORDER BY c.post.createdAt DESC") Page findCommentedPosts(@Param("userId") Long userId, Pageable pageable); + + // 조회수 Bulk Update (스케줄러용) + @Modifying(clearAutomatically = true) + @Query("UPDATE PostEntity p SET p.viewCount = p.viewCount + :count WHERE p.id = :id") + void incrementViewCount(@Param("id") Long id, @Param("count") Long count); + + // 북마크 Bulk Update (스케줄러용) + @Modifying(clearAutomatically = true) + @Query("UPDATE PostEntity p SET p.bookmarkCount = p.bookmarkCount + :delta WHERE p.id = :id") + void updateBookmarkCount(@Param("id") Long id, @Param("delta") Long delta); + + // 댓글 수 Bulk Update (스케줄러용) + @Modifying(clearAutomatically = true) + @Query("UPDATE PostEntity p SET p.commentCount = p.commentCount + :delta WHERE p.id = :id") + void updateCommentCount(@Param("id") Long id, @Param("delta") Long delta); } \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryCustom.java b/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryCustom.java index 329d279..43c5582 100644 --- a/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryCustom.java +++ b/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryCustom.java @@ -1,10 +1,24 @@ package com.example.communityservice.repository; -import com.example.communityservice.dto.response.PostResponse; +import com.example.communityservice.dto.response.*; import com.example.communityservice.entity.enumerate.PostCategory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; +import java.util.Optional; + public interface PostRepositoryCustom { + // 기존 메서드 Page searchPosts(PostCategory category, String keyword, Boolean isSolved, Pageable pageable); -} + + // 내가 쓴 글 조회 (경량 DTO) + Page findMyPosts(Long userId, PostCategory category, Pageable pageable); + + // 내가 북마크한 글 조회 (경량 DTO + 카테고리 분류) + Page findMyBookmarkedPosts(Long userId, PostCategory category, Pageable pageable); + + Optional findPostDetailById(Long postId, Long loginUserId); + + List findCommentsByPostId(Long postId); +} \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryImpl.java b/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryImpl.java index 2ed663b..a424c1d 100644 --- a/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryImpl.java +++ b/community-service/src/main/java/com/example/communityservice/repository/PostRepositoryImpl.java @@ -1,10 +1,13 @@ package com.example.communityservice.repository; -import com.example.communityservice.dto.response.PostResponse; -import com.example.communityservice.entity.PostEntity; -import com.example.communityservice.entity.QPostEntity; +import com.example.communityservice.dto.response.*; +import com.example.communityservice.entity.*; import com.example.communityservice.entity.enumerate.PostCategory; +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -13,6 +16,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Repository @@ -21,13 +25,33 @@ public class PostRepositoryImpl implements PostRepositoryCustom { private final JPAQueryFactory queryFactory; private final QPostEntity post = QPostEntity.postEntity; + private final QPostBookmarkEntity postBookmark = QPostBookmarkEntity.postBookmarkEntity; + private final QPostUserProfileEntity userProfile = QPostUserProfileEntity.postUserProfileEntity; + private final QPostCommentEntity postComment = QPostCommentEntity.postCommentEntity; + + // 1. 게시글 목록 조회 (작성자 정보 Join 추가) @Override public Page searchPosts(PostCategory category, String keyword, Boolean isSolved, Pageable pageable) { - - // 1. 조건에 맞는 게시글 조회 (작성자 정보 조인 제외) - List posts = queryFactory - .selectFrom(post) + List content = queryFactory + .select(Projections.constructor(PostResponse.class, + post.id, + post.userId, + userProfile.name, + userProfile.email, + post.category, + post.title, + post.content, + post.viewCount, + post.bookmarkCount, + post.commentCount, + post.isSolved, + post.recruitmentStatus, + post.createdAt, + post.lastModifiedAt + )) + .from(post) + .leftJoin(userProfile).on(post.userId.eq(userProfile.userId)) .where( categoryEq(category), keywordContains(keyword), @@ -38,7 +62,6 @@ public Page searchPosts(PostCategory category, String keyword, Boo .orderBy(post.createdAt.desc()) .fetch(); - // 2. 전체 카운트 조회 (페이징 처리를 위해 필요) Long total = queryFactory .select(post.count()) .from(post) @@ -51,15 +74,127 @@ public Page searchPosts(PostCategory category, String keyword, Boo if (total == null) total = 0L; - // 3. Entity -> DTO 변환 - // 작성자 정보(WriterName, WriterEmail)는 Service 계층의 mapWriterInfo()에서 채워지므로 여기서는 변환만 수행 - List responses = posts.stream() - .map(PostResponse::from) + return new PageImpl<>(content, pageable, total); + } + + // 게시글 상세 조회 (작성자 Join + 북마크 여부 확인) + @Override + public Optional findPostDetailById(Long postId, Long loginUserId) { + return Optional.ofNullable(queryFactory + .select(Projections.constructor(PostDetailResponse.class, + post.id, + post.userId, + userProfile.name, + userProfile.email, + post.category, + post.title, + post.content, + post.viewCount, + post.bookmarkCount, + post.commentCount, + post.isSolved, + ExpressionUtils.as( + new com.querydsl.core.types.dsl.CaseBuilder() + .when(isBookmarkedCondition(postId, loginUserId)) + .then(true) + .otherwise(false), + "isBookmarked" + ), + post.createdAt, + post.lastModifiedAt + )) + .from(post) + .leftJoin(userProfile).on(post.userId.eq(userProfile.userId)) + .where(post.id.eq(postId)) + .fetchOne()); + } + + // 댓글 목록 조회 (작성자 Join) + @Override + public List findCommentsByPostId(Long postId) { + return queryFactory + .select(Projections.constructor(CommentResponse.class, + postComment.id, + postComment.post.id, // postId + postComment.userId, + userProfile.name, // writerName + userProfile.email, // writerEmail + postComment.content, + postComment.isAccepted, + postComment.parent.id, // parentId + postComment.createdAt, + postComment.lastModifiedAt + )) + .from(postComment) + .leftJoin(userProfile).on(postComment.userId.eq(userProfile.userId)) + .where(postComment.post.id.eq(postId)) + .orderBy(postComment.createdAt.asc()) // 작성순 정렬 + .fetch(); + } + + @Override + public Page findMyPosts(Long userId, PostCategory category, Pageable pageable) { + List posts = queryFactory + .selectFrom(post) + .where( + post.userId.eq(userId), + categoryEq(category) + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(post.createdAt.desc()) + .fetch(); + + Long total = queryFactory + .select(post.count()) + .from(post) + .where(post.userId.eq(userId), categoryEq(category)) + .fetchOne(); + + if (total == null) total = 0L; + + List responses = posts.stream() + .map(MyPostResponse::from) .collect(Collectors.toList()); return new PageImpl<>(responses, pageable, total); } + @Override + public Page findMyBookmarkedPosts(Long userId, PostCategory category, Pageable pageable) { + List content = queryFactory + .select(Projections.constructor(MyBookmarkPostResponse.class, + post.id, + post.title, + post.content, + post.category, + post.createdAt, + userProfile.name // writerName + )) + .from(post) + .join(postBookmark).on(post.id.eq(postBookmark.post.id)) + .leftJoin(userProfile).on(post.userId.eq(userProfile.userId)) // 작성자 정보 조인 (Post.userId = UserProfile.userId) + .where( + postBookmark.userId.eq(userId), + categoryEq(category) + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(postBookmark.createdAt.desc()) + .fetch(); + + Long total = queryFactory + .select(post.count()) + .from(post) + .join(postBookmark).on(post.id.eq(postBookmark.post.id)) + .where(postBookmark.userId.eq(userId), categoryEq(category)) + .fetchOne(); + + if (total == null) total = 0L; + + return new PageImpl<>(content, pageable, total); + } + private BooleanExpression categoryEq(PostCategory category) { return category != null ? post.category.eq(category) : null; } @@ -73,4 +208,16 @@ private BooleanExpression keywordContains(String keyword) { private BooleanExpression isSolvedEq(Boolean isSolved) { return isSolved != null ? post.isSolved.eq(isSolved) : null; } + + private BooleanExpression isBookmarkedCondition(Long postId, Long loginUserId) { + if (loginUserId == null) { + return Expressions.asBoolean(false).isTrue(); // 로그인 안 했으면 false + } + // SubQuery: select 1 from bookmark where post_id = ? and user_id = ? + return JPAExpressions.selectOne() + .from(postBookmark) + .where(postBookmark.post.id.eq(postId) + .and(postBookmark.userId.eq(loginUserId))) + .exists(); + } } \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/scheduler/PostBatchScheduler.java b/community-service/src/main/java/com/example/communityservice/scheduler/PostBatchScheduler.java new file mode 100644 index 0000000..6582a66 --- /dev/null +++ b/community-service/src/main/java/com/example/communityservice/scheduler/PostBatchScheduler.java @@ -0,0 +1,87 @@ +package com.example.communityservice.scheduler; + +import com.example.communityservice.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PostBatchScheduler { + + private final PostRepository postRepository; + private final StringRedisTemplate redisTemplate; + + // Keys + private static final String VIEW_BATCH_KEY = "post:views"; + private static final String VIEW_SYNC_KEY = "post:views:sync"; + + private static final String BOOKMARK_BATCH_KEY = "post:bookmarks:delta"; + private static final String BOOKMARK_SYNC_KEY = "post:bookmarks:sync"; + + private static final String COMMENT_BATCH_KEY = "post:comments:delta"; + private static final String COMMENT_SYNC_KEY = "post:comments:sync"; + + @Scheduled(fixedRate = 600000, initialDelay = 30000) // 10분마다 실행 (Portfolio와 겹치지 않게 30초 딜레이) + public void syncCounts() { + log.info("[Scheduler] 커뮤니티 통계 데이터 DB 동기화 시작"); + + syncViewCounts(); + syncBookmarkCounts(); + syncCommentCounts(); + + log.info("[Scheduler] 동기화 작업 완료"); + } + + // 1. 조회수 동기화 + private void syncViewCounts() { + processBatch(VIEW_BATCH_KEY, VIEW_SYNC_KEY, (postId, count) -> postRepository.incrementViewCount(postId, count)); + } + + // 2. 북마크 수 동기화 + private void syncBookmarkCounts() { + processBatch(BOOKMARK_BATCH_KEY, BOOKMARK_SYNC_KEY, (postId, delta) -> { + if (delta != 0) postRepository.updateBookmarkCount(postId, delta); + }); + } + + // 3. 댓글 수 동기화 + private void syncCommentCounts() { + processBatch(COMMENT_BATCH_KEY, COMMENT_SYNC_KEY, (postId, delta) -> { + if (delta != 0) postRepository.updateCommentCount(postId, delta); + }); + } + + // 공통 배치 처리 로직 + private void processBatch(String batchKey, String syncKey, BatchUpdateAction action) { + if (Boolean.TRUE.equals(redisTemplate.hasKey(batchKey))) { + redisTemplate.rename(batchKey, syncKey); + Map entries = redisTemplate.opsForHash().entries(syncKey); + + if (!entries.isEmpty()) { + for (Map.Entry entry : entries.entrySet()) { + try { + Long postId = Long.parseLong((String) entry.getKey()); + Long value = Long.parseLong((String) entry.getValue()); + action.update(postId, value); // DB 업데이트 수행 + } catch (Exception e) { + log.error("배치 처리 실패 - Key: {}, PostId: {}", batchKey, entry.getKey(), e); + } + } + } + redisTemplate.delete(syncKey); + } + } + + // 함수형 인터페이스 (내부에서만 사용) + @FunctionalInterface + interface BatchUpdateAction { + void update(Long postId, Long value); + } +} \ No newline at end of file diff --git a/community-service/src/main/java/com/example/communityservice/service/PostService.java b/community-service/src/main/java/com/example/communityservice/service/PostService.java index 94c06f5..5544a86 100644 --- a/community-service/src/main/java/com/example/communityservice/service/PostService.java +++ b/community-service/src/main/java/com/example/communityservice/service/PostService.java @@ -1,31 +1,30 @@ package com.example.communityservice.service; import com.example.commonmodule.exception.BusinessException; -import com.example.commonmodule.exception.CommonErrorCode; import com.example.communityservice.client.ChatServiceClient; import com.example.communityservice.dto.request.CommentRequest; import com.example.communityservice.dto.request.PostCreateRequest; import com.example.communityservice.dto.request.PostUpdateRequest; -import com.example.communityservice.dto.response.CommentResponse; -import com.example.communityservice.dto.response.PostDetailResponse; -import com.example.communityservice.dto.response.PostResponse; +import com.example.communityservice.dto.response.*; import com.example.communityservice.entity.*; import com.example.communityservice.entity.enumerate.PostCategory; import com.example.communityservice.entity.enumerate.RecruitmentStatus; import com.example.communityservice.repository.PostBookmarkRepository; import com.example.communityservice.repository.PostCommentRepository; import com.example.communityservice.repository.PostRepository; -import com.example.communityservice.repository.PostUserProfileRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.util.*; -import java.util.stream.Collectors; +import java.util.concurrent.TimeUnit; import static com.example.communityservice.exception.ErrorCode.*; @@ -38,9 +37,22 @@ public class PostService { private final PostRepository postRepository; private final PostCommentRepository commentRepository; private final PostBookmarkRepository postBookmarkRepository; - private final PostUserProfileRepository userProfileRepository; private final ChatServiceClient chatServiceClient; + // Redis & Utils + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + // Redis Keys + private static final String POST_INFO_KEY_PREFIX = "post:info:"; + private static final String POST_STATS_KEY_PREFIX = "post:stats:"; + + // Batch Keys (views는 계속 쌓이는 accumulate 값이고 북마크/댓글은 증감이 빈번한 값이라서 아래처럼 변화량를 명확히 하기 위해서 delta라고 이름 지었음) + private static final String VIEW_BATCH_KEY = "post:views"; + private static final String BOOKMARK_BATCH_KEY = "post:bookmarks:delta"; + private static final String COMMENT_BATCH_KEY = "post:comments:delta"; + + // 1. 게시글 생성 @Transactional public Long createPost(Long userId, PostCreateRequest request) { PostEntity post = PostEntity.builder() @@ -49,10 +61,10 @@ public Long createPost(Long userId, PostCreateRequest request) { .title(request.getTitle()) .content(request.getContent()) .build(); - return postRepository.save(post).getId(); } + // 2. 게시글 수정 @Transactional public void updatePost(Long userId, Long postId, PostUpdateRequest request) { PostEntity post = postRepository.findById(postId) @@ -63,8 +75,10 @@ public void updatePost(Long userId, Long postId, PostUpdateRequest request) { } post.update(request.getTitle(), request.getContent()); + redisTemplate.delete(POST_INFO_KEY_PREFIX + postId); } + // 3. 게시글 삭제 @Transactional public void deletePost(Long userId, Long postId) { PostEntity post = postRepository.findById(postId) @@ -75,58 +89,47 @@ public void deletePost(Long userId, Long postId) { } postRepository.delete(post); + redisTemplate.delete(POST_INFO_KEY_PREFIX + postId); + redisTemplate.delete(POST_STATS_KEY_PREFIX + postId); } + // 4. 게시글 목록 조회 public Page getPosts(PostCategory category, String keyword, Boolean isSolved, Pageable pageable) { - Page postResponses = postRepository.searchPosts(category, keyword, isSolved, pageable); - mapWriterInfo(postResponses.getContent()); - return postResponses; + return postRepository.searchPosts(category, keyword, isSolved, pageable); } - // 상세 조회 로직 구현 (댓글 계층 구조, 사용자 정보 매핑, 북마크 여부) + // 5. 게시글 상세 조회 @Transactional public PostDetailResponse getPostDetail(Long postId, Long currentUserId) { - PostEntity post = postRepository.findById(postId) - .orElseThrow(() -> new BusinessException(POST_NOT_FOUND)); - post.increaseViewCount(); + // A. 조회수 증가 (Redis) + redisTemplate.opsForHash().increment(POST_STATS_KEY_PREFIX + postId, "viewCount", 1L); + redisTemplate.opsForHash().increment(VIEW_BATCH_KEY, String.valueOf(postId), 1L); + + // B. 게시글 본문 조회 + PostDetailResponse response = getCachedPostBaseInfo(postId); + + // C. 댓글 목록 조회 + List comments = postRepository.findCommentsByPostId(postId); + response.setComments(convertToCommentHierarchy(comments)); - // 1. 기본 Post 정보 변환 - PostDetailResponse response = PostDetailResponse.from(post); + // D. 통계 데이터 병합 (댓글 수, 북마크 수 등) + mergeDynamicStats(postId, response); - // 2. 북마크 여부 확인 + // E. 개인화 정보 확인 if (currentUserId != null) { - boolean isBookmarked = postBookmarkRepository.existsByPostAndUserId(post, currentUserId); + PostEntity proxyPost = postRepository.getReferenceById(postId); + boolean isBookmarked = postBookmarkRepository.existsByPostAndUserId(proxyPost, currentUserId); response.setBookmarked(isBookmarked); } - // 3. 댓글 조회 및 계층 구조 조립 - List comments = commentRepository.findAllByPostIdOrderByCreatedAtAsc(postId); - List commentResponses = convertToCommentHierarchy(comments); - response.setComments(commentResponses); - - // 4. 게시글 작성자 및 댓글 작성자 정보 일괄 매핑 - Set userIds = new HashSet<>(); - userIds.add(post.getUserId()); // 게시글 작성자 - comments.forEach(c -> userIds.add(c.getUserId())); // 댓글 작성자들 - - Map userMap = userProfileRepository.findAllById(userIds).stream() - .collect(Collectors.toMap(PostUserProfileEntity::getUserId, u -> u)); - - // 게시글 작성자 정보 세팅 - setWriterInfo(response, userMap.get(post.getUserId())); - - // 댓글 작성자 정보 세팅 (재귀적으로 처리하지 않고, Flat한 리스트에서 처리 후 조립했으므로 참조를 통해 반영됨) - // 위 convertToCommentHierarchy 내부에서 DTO를 만들었으므로, 여기서 순회하며 값을 채워줍니다. - updateCommentWriterInfo(response.getComments(), userMap); - return response; } + // 6. 댓글 작성 @Transactional public void createComment(Long userId, Long postId, CommentRequest request) { - PostEntity post = postRepository.findById(postId) - .orElseThrow(() -> new BusinessException(POST_NOT_FOUND)); + PostEntity post = postRepository.getReferenceById(postId); // Proxy PostCommentEntity parent = null; if (request.getParentId() != null) { @@ -142,208 +145,172 @@ public void createComment(Long userId, Long postId, CommentRequest request) { .build(); commentRepository.save(comment); + + // DB Update 제거 -> Redis 반영 + redisTemplate.opsForHash().increment(POST_STATS_KEY_PREFIX + postId, "commentCount", 1L); + redisTemplate.opsForHash().increment(COMMENT_BATCH_KEY, String.valueOf(postId), 1L); } + // 7. 댓글 수정 @Transactional public void updateComment(Long userId, Long postId, Long commentId, CommentRequest request) { - // postId는 URL 유효성 검증용으로 받을 수 있으나, 여기서는 commentId로 조회 후 권한만 확인 PostCommentEntity comment = commentRepository.findById(commentId) .orElseThrow(() -> new BusinessException(COMMENT_NOT_FOUND)); - // URL의 postId와 댓글의 실제 postId가 일치하는지 검증 - if (!comment.getPost().getId().equals(postId)) { - throw new BusinessException(INVALID_INPUT_VALUE); - } - - if (!comment.getUserId().equals(userId)) { - throw new BusinessException(NOT_COMMENT_OWNER); - } + if (!comment.getPost().getId().equals(postId)) throw new BusinessException(INVALID_INPUT_VALUE); + if (!comment.getUserId().equals(userId)) throw new BusinessException(NOT_COMMENT_OWNER); comment.updateContent(request.getContent()); } + // 8. 댓글 삭제 @Transactional public void deleteComment(Long userId, Long postId, Long commentId) { PostCommentEntity comment = commentRepository.findById(commentId) .orElseThrow(() -> new BusinessException(COMMENT_NOT_FOUND)); + if (!comment.getPost().getId().equals(postId)) throw new BusinessException(INVALID_INPUT_VALUE); + if (!comment.getUserId().equals(userId)) throw new BusinessException(NOT_COMMENT_OWNER); - // URL의 postId와 댓글의 실제 postId가 일치하는지 검증 - if (!comment.getPost().getId().equals(postId)) { - throw new BusinessException(INVALID_INPUT_VALUE); - } - - if (!comment.getUserId().equals(userId)) { - throw new BusinessException(NOT_COMMENT_OWNER); - } - - // 자식 댓글이 있는 경우 Cascade 설정에 따라 함께 삭제됨 commentRepository.delete(comment); - } - - // 댓글 계층 구조 변환 (Parent-Child) - private List convertToCommentHierarchy(List entities) { - Map map = new HashMap<>(); - List roots = new ArrayList<>(); - - // 1. DTO 변환 및 Map 저장 - for (PostCommentEntity entity : entities) { - CommentResponse dto = CommentResponse.from(entity); - map.put(entity.getId(), dto); - } - // 2. 계층 구조 조립 - for (PostCommentEntity entity : entities) { - CommentResponse currentDto = map.get(entity.getId()); - if (entity.getParent() != null) { - CommentResponse parentDto = map.get(entity.getParent().getId()); - if (parentDto != null) { // 부모가 존재하면 자식 리스트에 추가 - parentDto.getChildren().add(currentDto); - } - } else { - // 부모가 없으면 최상위 댓글 - roots.add(currentDto); - } - } - return roots; - } - - // 댓글 리스트(대댓글 포함)에 작성자 정보 매핑 - private void updateCommentWriterInfo(List comments, Map userMap) { - for (CommentResponse comment : comments) { - PostUserProfileEntity user = userMap.get(comment.getUserId()); - if (user != null) { - comment.setWriterName(user.getName()); - comment.setWriterEmail(user.getEmail()); - } else { - comment.setWriterName("알 수 없음"); - } - - // 대댓글에 대해서도 재귀 호출 - if (comment.getChildren() != null && !comment.getChildren().isEmpty()) { - updateCommentWriterInfo(comment.getChildren(), userMap); - } - } - } - - // 게시글 작성자 정보 세팅 헬퍼 - private void setWriterInfo(PostDetailResponse response, PostUserProfileEntity user) { - if (user != null) { - response.setWriterName(user.getName()); - response.setWriterEmail(user.getEmail()); - } else { - response.setWriterName("알 수 없음"); - } - } - - // 목록 조회용 작성자 정보 매핑 - private void mapWriterInfo(List posts) { - Set userIds = posts.stream().map(PostResponse::getUserId).collect(Collectors.toSet()); - Map userMap = userProfileRepository.findAllById(userIds).stream() - .collect(Collectors.toMap(PostUserProfileEntity::getUserId, u -> u)); - - posts.forEach(post -> { - PostUserProfileEntity user = userMap.get(post.getUserId()); - if (user != null) { - post.setWriterName(user.getName()); - post.setWriterEmail(user.getEmail()); - } else { - post.setWriterName("알 수 없음"); - } - }); + // DB Update 제거 -> Redis 반영 + redisTemplate.opsForHash().increment(POST_STATS_KEY_PREFIX + postId, "commentCount", -1L); + redisTemplate.opsForHash().increment(COMMENT_BATCH_KEY, String.valueOf(postId), -1L); } + // 9. 답변 채택 @Transactional public void adoptAnswer(Long userId, Long postId, Long commentId) { - PostEntity post = postRepository.findById(postId) - .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); - - if (!post.getUserId().equals(userId)) { - throw new BusinessException(NOT_POST_OWNER); - } - if (post.getCategory() != PostCategory.QNA) { - throw new BusinessException(NOT_QNA_CATEGORY); - } - - PostCommentEntity comment = commentRepository.findById(commentId) - .orElseThrow(() -> new BusinessException(COMMENT_NOT_FOUND)); + PostEntity post = postRepository.findById(postId).orElseThrow(() -> new BusinessException(POST_NOT_FOUND)); + if (!post.getUserId().equals(userId)) throw new BusinessException(NOT_POST_OWNER); + if (post.getCategory() != PostCategory.QNA) throw new BusinessException(NOT_QNA_CATEGORY); + PostCommentEntity comment = commentRepository.findById(commentId).orElseThrow(() -> new BusinessException(COMMENT_NOT_FOUND)); comment.accept(); post.markAsSolved(); + redisTemplate.delete(POST_INFO_KEY_PREFIX + postId); } - public void applyTeam(Long applicantId, Long postId) { - PostEntity post = postRepository.findById(postId) - .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); + // 10. 북마크 토글 + @Transactional + public void toggleBookmark(Long userId, Long postId) { + PostEntity post = postRepository.getReferenceById(postId); + String statsKey = POST_STATS_KEY_PREFIX + postId; - if (post.getCategory() != PostCategory.RECRUIT) { - throw new BusinessException(NOT_RECRUIT_CATEGORY); - } - if (post.getRecruitmentStatus() == RecruitmentStatus.CLOSED) { - throw new BusinessException(RECRUITMENT_CLOSED); - } + postBookmarkRepository.findByPostAndUserId(post, userId).ifPresentOrElse( + bookmark -> { + // 취소 + postBookmarkRepository.delete(bookmark); + redisTemplate.opsForHash().increment(statsKey, "bookmarkCount", -1L); + redisTemplate.opsForHash().increment(BOOKMARK_BATCH_KEY, String.valueOf(postId), -1L); + }, + () -> { + // 등록 + postBookmarkRepository.save(new PostBookmarkEntity(post, userId)); + redisTemplate.opsForHash().increment(statsKey, "bookmarkCount", 1L); + redisTemplate.opsForHash().increment(BOOKMARK_BATCH_KEY, String.valueOf(postId), 1L); + } + ); + } + + // 11. 팀원 모집 지원 + public void applyTeam(Long applicantId, Long postId) { + PostEntity post = postRepository.findById(postId).orElseThrow(() -> new BusinessException(POST_NOT_FOUND)); + if (post.getCategory() != PostCategory.RECRUIT) throw new BusinessException(NOT_RECRUIT_CATEGORY); + if (post.getRecruitmentStatus() == RecruitmentStatus.CLOSED) throw new BusinessException(RECRUITMENT_CLOSED); String message = String.format("안녕하세요, '%s' 모집 글을 보고 지원합니다.", post.getTitle()); try { chatServiceClient.sendMessageInternal(applicantId, post.getUserId(), message); } catch (Exception e) { - log.error("메시지 전송 실패: {}", e.getMessage()); + log.error("메시지 전송 실패", e); throw new BusinessException(MESSAGE_SEND_FAILED); } } + // 12. 모집 상태 변경 @Transactional public void updateRecruitmentStatus(Long userId, Long postId, RecruitmentStatus status) { - PostEntity post = postRepository.findById(postId) - .orElseThrow(() -> new BusinessException(POST_NOT_FOUND)); - - if (!post.getUserId().equals(userId)) { - throw new BusinessException(NOT_POST_OWNER); - } - if (post.getCategory() != PostCategory.RECRUIT) { - throw new BusinessException(NOT_RECRUIT_CATEGORY); - } - + PostEntity post = postRepository.findById(postId).orElseThrow(() -> new BusinessException(POST_NOT_FOUND)); + if (!post.getUserId().equals(userId)) throw new BusinessException(NOT_POST_OWNER); + if (post.getCategory() != PostCategory.RECRUIT) throw new BusinessException(NOT_RECRUIT_CATEGORY); post.updateRecruitmentStatus(status); + redisTemplate.delete(POST_INFO_KEY_PREFIX + postId); } - @Transactional - public void toggleBookmark(Long userId, Long postId) { - PostEntity post = postRepository.findById(postId) - .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); + // 13. 마이페이지 관련 조회 + public Page getMyPosts(Long userId, PostCategory category, Pageable pageable) { + return postRepository.findMyPosts(userId, category, pageable); + } - postBookmarkRepository.findByPostAndUserId(post, userId).ifPresentOrElse( - bookmark -> { - postBookmarkRepository.delete(bookmark); - post.decreaseBookmarkCount(); - }, - () -> { - postBookmarkRepository.save(new PostBookmarkEntity(post, userId)); - post.increaseBookmarkCount(); - } - ); + public Page getMyBookmarkedPosts(Long userId, PostCategory category, Pageable pageable) { + return postRepository.findMyBookmarkedPosts(userId, category, pageable); } + // ========================================== + // Private Helpers + // ========================================== + private PostDetailResponse getCachedPostBaseInfo(Long postId) { + String cacheKey = POST_INFO_KEY_PREFIX + postId; + String cachedJson = redisTemplate.opsForValue().get(cacheKey); - public Page getMyPosts(Long userId, Pageable pageable) { - Page posts = postRepository.findAllByUserId(userId, pageable); - List responses = posts.stream().map(PostResponse::from).collect(Collectors.toList()); - mapWriterInfo(responses); // 작성자 정보 매핑 (본인이지만 통일성을 위해 호출) - return new PageImpl<>(responses, pageable, posts.getTotalElements()); - } + if (StringUtils.hasText(cachedJson)) { + try { + return objectMapper.readValue(cachedJson, PostDetailResponse.class); + } catch (JsonProcessingException e) { + log.error("JSON Parsing Error", e); + } + } - public Page getMyBookmarkedPosts(Long userId, Pageable pageable) { - Page posts = postRepository.findBookmarkedPosts(userId, pageable); - List responses = posts.stream().map(PostResponse::from).collect(Collectors.toList()); - mapWriterInfo(responses); - return new PageImpl<>(responses, pageable, posts.getTotalElements()); + PostDetailResponse response = postRepository.findPostDetailById(postId, null) + .orElseThrow(() -> new BusinessException(POST_NOT_FOUND)); + response.setComments(new ArrayList<>()); + + try { + redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(response), 1, TimeUnit.HOURS); + } catch (JsonProcessingException e) { + log.error("Redis Set Error", e); + } + return response; } - public Page getMyCommentedPosts(Long userId, Pageable pageable) { - Page posts = postRepository.findCommentedPosts(userId, pageable); - List responses = posts.stream().map(PostResponse::from).collect(Collectors.toList()); - mapWriterInfo(responses); - return new PageImpl<>(responses, pageable, posts.getTotalElements()); + private void mergeDynamicStats(Long postId, PostDetailResponse response) { + String statsKey = POST_STATS_KEY_PREFIX + postId; + List stats = redisTemplate.opsForHash().multiGet(statsKey, Arrays.asList("viewCount", "bookmarkCount", "commentCount")); + + Object viewCount = stats.get(0); + Object bookmarkCount = stats.get(1); + Object commentCount = stats.get(2); + + // Redis에 값이 하나라도 없으면(Cache Miss), DTO(DB) 값으로 초기화 + if (viewCount == null || bookmarkCount == null || commentCount == null) { + redisTemplate.opsForHash().putIfAbsent(statsKey, "viewCount", String.valueOf(response.getViewCount())); + redisTemplate.opsForHash().putIfAbsent(statsKey, "bookmarkCount", String.valueOf(response.getBookmarkCount())); + + // DTO에 commentCount가 있으므로 안전하게 초기화 가능 + String dbCommentCount = response.getCommentCount() != null ? String.valueOf(response.getCommentCount()) : "0"; + redisTemplate.opsForHash().putIfAbsent(statsKey, "commentCount", dbCommentCount); + } else { + // Redis Hit: Redis 값으로 DTO 덮어쓰기 + response.setViewCount(Long.parseLong(viewCount.toString())); + response.setBookmarkCount(Long.parseLong(bookmarkCount.toString())); + response.setCommentCount(Long.parseLong(commentCount.toString())); + } } + private List convertToCommentHierarchy(List comments) { + Map map = new HashMap<>(); + List roots = new ArrayList<>(); + for (CommentResponse dto : comments) map.put(dto.getId(), dto); + for (CommentResponse dto : comments) { + if (dto.getParentId() != null) { + CommentResponse parent = map.get(dto.getParentId()); + if (parent != null) parent.getChildren().add(dto); + } else { + roots.add(dto); + } + } + return roots; + } } \ No newline at end of file diff --git a/community-service/src/main/resources/application.yml b/community-service/src/main/resources/application.yml index 153d195..87fef21 100644 --- a/community-service/src/main/resources/application.yml +++ b/community-service/src/main/resources/application.yml @@ -49,4 +49,19 @@ app: # --- Swagger (OpenAPI) --- springdoc: - override-with-generic-response: false \ No newline at end of file + override-with-generic-response: false + +# --- Monitoring Configuration (Added) --- +management: + endpoints: + web: + exposure: + include: prometheus, health, info # 프로메테우스 지표 노출 허용 + endpoint: + health: + show-details: always + prometheus: + enabled: true + metrics: + tags: + application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가 \ No newline at end of file diff --git a/docs/developer guide/APIGATEWAY_SERVICE.md b/docs/developer guide/APIGATEWAY_SERVICE.md deleted file mode 100644 index 5c119d4..0000000 --- a/docs/developer guide/APIGATEWAY_SERVICE.md +++ /dev/null @@ -1,147 +0,0 @@ -## 1. 개요 - - `apigateway-service`는 LinkFolio 백엔드 마이크로서비스 아키텍처(MSA)의 단일 진입점(Single Point of Entry) 역할을 수행하는 API 게이트웨이 서비스 - - 모든 외부 클라이언트(웹, 앱)의 요청은 이 게이트웨이를 통과 - - 요청을 인증하고 내부의 적절한 마이크로서비스(예: `user-service`, `auth-service`, `portfolio-service`, `chat-service`)로 라우팅하는 책임 - - 주요 기술 스택으로는 Spring Cloud Gateway를 사용하여 비동기/논블로킹(Non-Blocking) 방식의 반응형(Reactive) 시스템으로 구축되었다. - ---- - -## 2. 핵심 설정 (application.yml) - -### 2.1. forward-headers-strategy: NATIVE - -`application.yml`에 설정된 `server.forward-headers-strategy: NATIVE`는 게이트웨이가 자신을 호출한 **앞단의 리버스 프록시(예: Kubernetes Ingress, Nginx, 로드 밸런서)를 신뢰**하도록 지시하는 핵심 설정이다. - -- **필요성**: 클라이언트 요청(예: `https://linkfolio.com`)은 게이트웨이로 직접 도달하는 것이 아니라, 로드 밸런서를 먼저 거친다. 로드 밸런서는 HTTPS 요청을 받아 SSL/TLS 처리를 종료(SSL Termination)한 후, 게이트웨이로는 암호화되지 않은 내부 HTTP 요청(예: `http://apigateway-service:8000`)을 보낸다. -- **기본 문제**: 이 설정을 사용하지 않으면(`NONE`이 기본값), 게이트웨이는 자신을 호출한 로드 밸런서의 **내부 IP**와 **HTTP 프로토콜**만을 인식하게 된다. 원래 클라이언트의 IP 주소와 HTTPS 프로토콜 정보는 유실된다. - -- **동작**: `NATIVE` 전략은 로드 밸런서가 요청 헤더에 추가해주는 표준 `X-Forwarded-*` 헤더들을 신뢰하고 파싱하라고 Spring에 지시한다. - * `X-Forwarded-For`: 실제 클라이언트의 IP 주소 - * `X-Forwarded-Proto`: 원래 요청의 프로토콜 (http 또는 https) - * `X-Forwarded-Host`: 원래 요청의 호스트명 (예: `linkfolio.com`) - -- **미사용 시 문제점**: - 1. **부정확한 로그**: 모든 로그에 클라이언트 IP가 로드 밸런서의 내부 IP로 기록되어, 장애 추적이나 어뷰징 대응이 불가능해진다. - 2. **리디렉션 및 Swagger UI 오류**: 게이트웨이가 자신에게 온 요청이 `http://`라고 착각한다. 사용자가 `https://.../swagger-ui.html`로 접속해도, Swagger UI가 API 명세를 로드하기 위해 요청하는 URL(`api-docs`)을 `http://.../api-docs`로 잘못 생성하여 반환한다. 브라우저는 보안 페이지(`https://`)에서 비보안 리소스(`http://`)를 로드하려는 시도를 **'혼합 콘텐츠(Mixed Content)'** 오류로 간주하고 차단하여 UI가 깨지게 된다. - 3. **IP 기반 보안 기능 오작동**: IP 기반 차단 또는 요청 제한(Rate Limiting) 기능이 모두 로드 밸런서의 IP를 기준으로 동작하여 보안 정책이 무력화된다. - -### 2.2. CORS (Cross-Origin Resource Sharing) -`globalcors` 설정을 통해 모든 경로(`[/**]`)에 대해 특정 프론트엔드 오리진(예: `localhost:3000`, Vercel 배포 주소)에서의 요청을 허용하도록 설정하였다. -`allow-credentials: true` 설정을 통해 인증과 관련된 쿠키(예: Refresh Token)도 주고받을 수 있도록 허용한다. - -### 2.3. 라우팅 (Routes) -각 마이크로서비스로 요청을 중계하는 규칙이다. - - - `user-service-route`: `/user-service/**` 경로의 요청을 `http://user-service:80` (쿠버네티스 내부 서비스 DNS)로 전달한다. - - `auth-service-route`: `/auth-service/**` 경로의 요청을 `http://auth-service:80`으로 전달한다. - - `portfolio-service-route`: `/portfolio-service/**` 경로의 요청을 `http://portfolio-service:80`으로 전달한다. - - `chat-service-route`: `/chat-service/**` 경로의 요청을 `http://chat-service:80`으로 전달한다. - - `chat-service-ws-route`: `/ws-chat/**` 경로의 WebSocket 요청을 `http://chat-service:80`으로 전달한다. - -### 2.4. 인증 제외 경로 (Excluded URLs) - -`app.gateway.excluded-urls` 목록에 포함된 경로는 `AuthorizationHeaderFilter`의 인증 검사를 통과(bypass)한다. - -주로 회원가입, 로그인, 토큰 재발급, 아이디/비밀번호 찾기 등 인증 이전에 수행되어야 하는 API들과, 인증이 필요 없는 포트폴리오 목록/상세 조회 API(`portfolio-service/portfolios/**`)가 여기에 해당한다. - ---- - -## 3. 인증 필터 (AuthorizationHeaderFilter.java) -이 필터는 게이트웨이의 핵심 보안 로직을 담당한다. - -### 3.1. 필요성 -MSA 구조에서 각 서비스(`user-service`, `portfolio-service` 등)가 개별적으로 JWT 토큰을 검증하는 것은 비효율적이며 보일러플레이트 코드를 증가시킨다. 게이트웨이가 앞단(Proxy)에서 중앙 인증 지점 역할을 맡아, 유효한 JWT 토큰을 가진 요청만 내부 서비스로 전달하도록 한다. 내부 서비스들은 게이트웨이를 통과한 요청을 '신뢰'할 수 있게 된다. - -### 3.2. 동작 원리 및 흐름 -이 필터는 `GlobalFilter`로 구현되었으며, `Ordered.HIGHEST_PRECEDENCE` (최고 우선순위)를 가져 다른 어떤 필터보다 먼저 실행된다. -(보통 MSA에서는 JWT 토큰을 `HTTP 헤더 방식`으로 처리하거나 `별도의 간소화한 토큰`으로 처리하는데, 본 프로젝트에서는 `HTTP 헤더 방식`을 선택하였다.) - - 1. **요청 경로 확인**: 현재 요청의 경로(path)를 가져온다. - - 2. **화이트리스트 검사**: isPatchExcluded 메서드를 호출하여, excluded-urls에 등록된 경로인지 확인한다. 만약 등록된 경로라면, 인증 절차 없이 즉시 다음 필터 체인(내부 서비스)으로 요청을 전달한다. - - 3. **헤더 존재 여부 검사**: 화이트리스트에 없는 경로일 경우, Authorization 헤더가 있는지 확인한다. 헤더가 없으면 MISSING_AUTH_HEADER 예외를 발생시킨다. - - 4. **JWT 추출**: getJwtFromHeader 메서드를 통해 "Bearer " 접두사를 제거하고 순수 JWT 토큰을 추출한다. 형식이 잘못되면 INVALID_AUTH_FORMAT 예외를 발생시킨다. - - 5. **JWT 검증 및 파싱**: getClaims 메서드에서 jwtParser (JWT Secret Key로 초기화됨)를 사용하여 토큰의 서명, 만료 시간 등을 검증하고 Payload(Claims)를 추출한다. 검증에 실패하면 INVALID_JWT_TOKEN 예외를 발생시킨다. - - 6. **Claims 정보 추출**: Claims에서 subject(userId), email, role 정보를 추출한다. - - 7. **내부 헤더 주입**: buildInternalRequest 메서드를 호출하여 새로운 요청(Request)을 생성한다. 이 과정은 스푸핑 공격 방지를 위해 매우 중요하다. (3.3절 참고) - - - 8. **요청 전달**: 새로 생성된 요청을 다음 필터 체인으로 전달한다. - -### 3.3. 스푸핑(Spoofing) 공격 방지 -- 문제 상황: `user-service`나 `portfolio-service` 같은 내부 서비스들은 게이트웨이가 주입한 내부 헤더(예: `X-User-Id`)를 신뢰하고 사용자 인증을 처리한다. (이는 `common-module`의 `InternalHeaderAuthenticationFilter`를 통해 이루어진다). 만약 악의적인 사용자가 게이트웨이로 요청을 보낼 때, `Authorization` 헤더와 함께 위조된 `X-User-Id: 123` (다른 사용자 ID) 헤더를 같이 보낸다면, 게이트웨이가 이 헤더를 그대로 통과시킬 경우 내부 서비스는 위조된 사용자 ID를 신뢰하여 인가 오류가 발생할 수 있다. - - -- 해결 방안: `buildInternalRequest` 메서드는 이 문제를 원천 차단한다. - 1. request.mutate().headers(...)를 통해 요청 헤더를 수정한다. - 2. 외부에서 주입되었을 가능성이 있는 X-User-Id, X-User-Email, X-User-Role 헤더를 명시적으로 모두 제거(remove)한다. - 3. 또한, 더 이상 필요 없는 Authorization 헤더도 제거한다. - 4. 오직 JWT 토큰에서 직접 파싱한 신뢰할 수 있는 userId, email, role 값만을 사용하여 X-User-* 헤더를 새롭게 추가(add)한다. - -| 이러한 '제거 후 재주입(Remove-and-Re-add)' 전략을 통해, 내부 마이크로서비스는 오직 게이트웨이가 검증하고 주입한 사용자 정보만을 신뢰할 수 있게 된다. - -Q. 로직을 봤을 때, JWT 기반으로 추출한 Claims로 HTTP 헤더에 add를 하고 있는데, 굳이 remove가 없어도 되지 않나? - -A. HTTP 헤더는 `다중 값(multi-valued)`을 가질 수 있어서 add를 했을 때 값을 덮어쓰기(overwrite) 하는게 아니라 새로운 값을 추가하는 구조가 된다. 따라서 반드시 remove가 선행되어야 한다. - ---- - -## 4. 예외 처리 -`AuthorizationHeaderFilter`에서 인증 관련 예외 발생 시, `onError` 메서드가 호출된다. 이 메서드는 `ErrorCode` Enum에 정의된 상태 코드와 메시지를 기반으로 표준화된 JSON 에러 응답을 생성하여 클라이언트에게 반환한다. - -게이트웨이 고유의 ErrorCode는 다음과 같다: - -- MISSING_AUTH_HEADER (A001): Authorization 헤더가 누락됨. -- INVALID_AUTH_FORMAT (A002): "Bearer " 접두사가 없거나 형식이 잘못됨. -- INVALID_JWT_TOKEN (A003): JWT 서명 오류, 만료, 형식 오류 등. -- INVALID_JWT_PAYLOAD (A004): JWT 내부에 필수 클레임(userId, email 등)이 누락됨. -- INTERNAL_FILTER_ERROR (A500): 필터 동작 중 예기치 못한 서버 오류. - ---- - -## 5. 의존성 관리 (pom.xml) -apigateway-service의 pom.xml은 MSA 게이트웨이 역할에 맞게 신중하게 관리된다. - -- `spring-cloud-starter-gateway-server-webflux`: Spring Cloud Gateway의 핵심 의존성. (WebFlux 포함) - -- `jjwt-*`: `AuthorizationHeaderFilter`에서 JWT를 파싱하고 검증하기 위해 사용된다. - -- `springdoc-openapi-starter-webflux-ui`: WebFlux 환경에서 Swagger UI를 통합하고, `application.yml`에 정의된 내부 서비스들의 API 문서를 취합하여 보여주는 역할을 한다. - -- `common-module`: - - 게이트웨이는 `common-module`을 의존한다. 이는 ErrorCode 인터페이스, 공통 예외 응답 DTO인 `ErrorResponse` 등을 공유하기 위함이다. - - 의존성 제외(Exclusion): common-module은 다른 서비스들을 위해 `spring-boot-starter-data-jpa`, `spring-boot-starter-web` (MVC), `spring-boot-starter-security` (MVC용) 의존성을 포함하고 있다. **게이트웨이는 WebFlux 기반이며 데이터베이스가 필요 없으므로, common-module을 가져올 때 이 의존성들을 태그를 사용하여 모두 제외**한다. - - 이러한 제외 설정은 `ApigatewayServiceApplication.java의 @SpringBootApplication(exclude = ...)` 설정을 통해서도 재확인되어, JPA 관련 자동 설정이 로드되지 않도록 보장한다. - ---- - -#### -```mermaid -sequenceDiagram - participant Client as 👤 클라이언트 - participant APIGateway as 🚪 API 게이트웨이 - participant UserService as 👥 user-service - - Client->>+APIGateway: GET /user-service/users/me
(Header: Authorization: Bearer ) - - Note over APIGateway: 1. [GlobalFilter] AuthorizationHeaderFilter 실행 - APIGateway->>APIGateway: 2. JWT 검증 및 Claims 추출
(userId, email, role) - - Note over APIGateway: 3. [스푸핑 방지] 기존 X-User-* 헤더 (있다면) 제거 - APIGateway->>APIGateway: 4. 신뢰할 수 있는 X-User-* 헤더 새로 주입 - - APIGateway->>+UserService: GET /users/me
(Header: X-User-Id, X-User-Email, X-User-Role) - - Note over UserService: 5. [Filter] InternalHeaderAuthenticationFilter 실행 - UserService->>UserService: 6. X-User-* 헤더를 신뢰하여 AuthUser 객체 생성 - UserService->>UserService: 7. SecurityContextHolder에 인증 객체 등록 - UserService->>UserService: 8. Controller 로직 실행 (@AuthenticationPrincipal) - - UserService-->>-APIGateway: 200 OK (UserResponse) - APIGateway-->>-Client: 200 OK (UserResponse) -``` \ No newline at end of file diff --git a/docs/developer guide/AUTH_SERVICE.md b/docs/developer guide/AUTH_SERVICE.md deleted file mode 100644 index e3f058d..0000000 --- a/docs/developer guide/AUTH_SERVICE.md +++ /dev/null @@ -1,221 +0,0 @@ -# AUTH_SERVICE.md - -## 1. 개요 - -`auth-service`는 LinkFolio MSA의 **인증/인가**를 총괄하는 핵심 마이크로서비스이다. - -주요 책임은 다음과 같다: -1. **인증 처리**: 자체(Local) 로그인 및 소셜(OAuth2) 로그인을 처리한다. -2. **토큰 발급**: 인증 성공 시, `apigateway-service`에서 검증할 JWT(Access Token, Refresh Token)를 생성하여 발급한다. -3. **SAGA 트랜잭션 주관**: 회원가입 시, 분산 트랜잭션(SAGA)의 **시작점(Coordinator)** 역할을 수행한다. `AuthUserEntity`를 `PENDING` 상태로 먼저 생성하고 Kafka 이벤트를 발행하여 `user-service`의 프로필 생성을 요청한다. - ---- - -## 2. 핵심 기능 - -* Spring Security를 이용한 자체 로그인(ID/PW) 인증 -* OAuth2 (Google, Naver, Kakao) 소셜 로그인 인증 -* Refresh Token Rotation (RTR) 전략을 사용한 JWT 발급 및 재발급 (`/auth/refresh`) -* Kafka SAGA 패턴을 이용한 분산 회원가입 트랜잭션 (Auth/User) -* Redis를 이용한 이메일 인증 코드 관리 (회원가입/비밀번호 재설정) -* Redis를 이용한 OAuth2 `state` 관리 (Stateless 인증) -* 계정 관리 (아이디 찾기, 비밀번호 변경/재설정) - ---- - -## 3. 인증 흐름 (Authentication Flow) - -`SecurityConfig`를 중심으로 자체 로그인과 소셜 로그인이 분리되어 처리된다. - -### 3.1. 자체 로그인 (Local Login) - -1. 클라이언트가 `/auth/login` 엔드포인트로 ID/PW를 POST 요청한다. -2. `CustomAuthenticationFilter`가 이 요청을 가로채 `UsernamePasswordAuthenticationToken`을 생성하여 `AuthenticationManager`에 인증을 위임한다. -3. `AuthenticationManager`는 `CustomUserDetailsService`를 호출하여 사용자를 조회한다. -4. `CustomUserDetailsService`는 `AuthUserRepository`에서 `username` 기준으로 사용자를 조회한다. -5. **[SAGA 연동]** 이때, `AuthUserEntity`의 `status`가 `COMPLETED`가 아닌 경우(예: `PENDING` 또는 `CANCELLED`) 로그인을 차단하여 회원가입 SAGA가 완료된 사용자만 인증을 허용한다. -6. 인증 성공 시, `LocalLoginSuccessHandler`가 호출된다. -7. `LocalLoginSuccessHandler`는 `JwtTokenProvider`를 통해 Access/Refresh Token을 발급받는다. -8. Access Token은 JSON 응답 본문에, Refresh Token은 HttpOnly 쿠키(`refresh_token`)에 담아 클라이언트에 반환한다. -9. Refresh Token은 `RefreshTokenService`를 통해 Redis에 `RT:` 키로 저장된다. - -### 3.2. 소셜 로그인 (OAuth2) - -1. `SecurityConfig`에 정의된 대로 `CustomOAuth2UserService`가 인증을 처리한다. -2. **[Stateless]** `RedisBasedAuthorizationRequestRepository`를 사용하여 OAuth2 인증 요청의 `state` 값을 세션 대신 Redis에 저장함으로써, 다중화(Replica) 환경에서도 일관성을 유지한다. -3. `CustomOAuth2UserService`는 `전략 패턴(Strategy Pattern)`을 사용한다. `Map` 빈을 주입받아, `registrationId` (google, naver, kakao)에 맞는 파서(Parser)를 동적으로 선택하여 공급자별 응답을 `OAuthAttributes` DTO로 표준화한다. -4. `saveOrUpdate` 로직을 통해 기가입자인지 확인하고, 신규 가입자일 경우 `자체 로그인과 동일한 SAGA 트랜잭션(Kafka 이벤트 발행)`을 시작한다. -5. 인증 성공 시, `OAuth2LoginSuccessHandler`가 호출된다. -6. 이 핸들러는 토큰 발급 및 Redis 저장, 쿠키 설정을 수행한 후, Access Token을 쿼리 파라미터(`?token=...`)로 붙여 프론트엔드 URL로 리디렉션시킨다. - -### 3.3. JWT 발급 및 재발급 (RTR) - -* **`JwtTokenProvider`**: - * **Access Token**: `userId(sub)`, `email`, `role`을 포함하며 만료 시간이 짧다. - * **Refresh Token**: `userId(sub)`만 포함하며 만료 시간이 길다. -* **`RefreshTokenService`** (RTR 구현): - * `/auth/refresh` 요청 시, 쿠키의 Refresh Token과 Redis에 저장된 `RT:`의 토큰 값을 비교한다. - * 두 토큰이 일치하지 않으면, 토큰 탈취 시도로 간주하여 Redis의 토큰을 삭제하고 `REFRESH_TOKEN_MISMATCH` 오류를 반환한다. - * 두 토큰이 일치하면, **새로운 Access Token**과 **새로운 Refresh Token**을 모두 재발급한다. - * 새 Refresh Token을 Redis에 덮어쓰고, 새 쿠키를 클라이언트에 전송하여 토큰을 '회전(Rotate)'시킨다. - -### 3.4. Spring Security 설정 (`SecurityConfig.java`) - -`SecurityConfig`는 `auth-service`의 모든 인증/인가 흐름을 정의하는 중추적인 파일이다. - -1. **세션 관리**: `sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))` 설정을 통해 이 서비스가 HTTP 세션을 사용하지 않는 **Stateless** 서버임을 명시한다. 이는 JWT 기반 인증의 필수 요소이다. -2. **경로 권한 (인가)**: `authorizeHttpRequests`를 통해 각 API 경로의 접근 권한을 설정한다. - * `/auth/**`, `/oauth2/**` 등 인증 자체를 처리하는 모든 경로는 `permitAll()`로 허용된다. - * 그 외의 모든 요청(`anyRequest()`)은 `authenticated()`로 설정되지만, `auth-service`는 게이트웨이의 내부 헤더 인증(`InternalHeaderAuthenticationFilter`)을 사용하지 않으므로, 사실상 모든 비인증 경로는 `permitAll()`로 열려있는 것과 유사하게 동작한다. (인증이 필요한 API는 `AuthController`의 `/password`, `/logout` 정도이다.) -3. **자체 로그인 필터 체인**: `http.addFilter(authenticationFilter)`를 통해 `CustomAuthenticationFilter`를 등록한다. 이 필터는 `/auth/login` 경로의 요청을 전담하여 `CustomUserDetailsService` 및 `LocalLoginSuccessHandler`로 연결한다. -4. **소셜 로그인(OAuth2) 필터 체인**: `http.oauth2Login()` 블록을 통해 소셜 로그인 흐름을 커스터마이징한다. - * `authorizationRequestRepository(redisBasedAuthorizationRequestRepository)`: `state` 값을 세션 대신 Redis에 저장하도록 설정한다. - * `userService(customOAuth2UserService)`: 공급자로부터 사용자 정보를 받아온 후, `CustomOAuth2UserService`를 실행하여 SAGA 트랜잭션을 포함한 회원가입/로그인 로직을 수행하도록 한다. - * `successHandler(oAuth2LoginSuccessHandler)`: 인증 성공 후, `OAuth2LoginSuccessHandler`를 실행하여 JWT를 발급하고 프론트엔드로 리디렉션한다. - - ---- - -## 4. SAGA (회원가입) 트랜잭션 - -`auth-service`와 `user-service`는 Kafka를 통해 회원가입 트랜잭션을 처리한다. `auth-service`는 이 SAGA의 주관사(Coordinator) 역할을 한다. - -### 4.1. SAGA 시작 (AuthService.signUp) - -`AuthService.signUp` 메서드는 `@Transactional`로 선언되어 있으며 **Outbox Pattern**을 사용한다. - -1. `EmailVerificationService`를 통해 이메일이 인증 완료(`VE:`) 상태인지 Redis에서 확인한다. -2. `AuthUserEntity`를 `AuthStatus.PENDING` 상태로 생성하여 Auth DB에 저장한다. -3. `UserRegistrationRequestedEvent` 이벤트를 JSON으로 변환하여 `OutboxEntity`에 저장한다. -4. 트랜잭션이 커밋되면, **Debezium(CDC)**이 `outbox` 테이블의 변경을 감지하여 Kafka로 이벤트를 자동 발행한다. -5. 이를 통해 DB 저장과 메시지 발행의 원자성(Atomicity)을 보장한다. - -### 4.2. SAGA 응답 처리 (AuthEventHandler) - -`AuthEventHandler`는 `user-service`의 처리 결과를 Kafka로부터 수신한다. 이때 Java 객체가 아닌 **CDC 이벤트(Avro GenericRecord)**를 직접 수신한다. - -* **`handleUserProfileEvent` (성공 및 동기화)**: - * 토픽: `user_db_server.user_db.user_profile` (user-service DB 변경 로그) - * `user-service`가 프로필을 `COMPLETED` 상태로 저장하면 이 이벤트를 수신한다. - * `userId`로 `PENDING` 상태의 `AuthUserEntity`를 찾아 상태를 `COMPLETED`로 변경하여 회원가입을 완료한다. - * 또한, 사용자 이름(`name`) 등이 변경된 경우 `AuthUserEntity` 정보를 동기화한다. -* **(실패 시)**: `UserProfileCreationFailureEvent` 수신 시 보상 트랜잭션을 수행하여 상태를 `CANCELLED`로 변경한다. (별도 토픽 사용) - ---- - -## 5. 주요 기능 상세 (Redis 활용) - -`auth-service`는 `RedisConfig`를 통해 Redis를 광범위하게 사용한다. - -* **RefreshTokenService**: `RT:` 키로 Refresh Token을 저장 (RTR에 사용). -* **RedisBasedAuthorizationRequestRepository**: `OAUTH2_REQ:` 키로 OAuth2 인증 요청 상태 저장. -* **EmailVerificationService**: - * `VC:`: 회원가입 인증 코드 (3분 TTL) - * `VE:`: 회원가입 인증 완료 상태 (3분 TTL) - * `PW_RESET:`: 비밀번호 재설정 인증 코드 (3분 TTL) - * `PW_VERIFIED:`: 비밀번호 재설정 인증 완료 상태 (6분 TTL) - -### 5.1. Redis 설정 (`RedisConfig.java`) - -`auth-service`의 `RedisConfig`는 다른 서비스(예: `user-service`)와 다른 직렬화 방식을 사용한다. - -* **Key Serializer**: `StringRedisSerializer`를 사용하여 Redis의 키가 `RT:1`, `VC:test@...`처럼 인간이 읽을 수 있는 문자열로 저장되도록 한다. -* **Value Serializer**: `JdkSerializationRedisSerializer`를 사용한다. - -**JSON 직렬화를 사용하지 못하는 이유:** -`RefreshTokenService`나 `EmailVerificationService`는 값으로 단순 `String`을 저장하므로 어떤 직렬화 방식을 사용해도 무방하다. - -하지만 `RedisBasedAuthorizationRequestRepository`는 OAuth2 인증 요청 정보를 담고 있는 `OAuth2AuthorizationRequest` 객체 자체를 Redis에 저장해야 한다. 이 객체는 Spring Security가 제공하는 복잡한 객체이며, 단순 POJO가 아니기 때문에 **JSON 직렬화(예: `GenericJackson2JsonRedisSerializer`)가 불가능하다.** - -`OAuth2AuthorizationRequest` 객체는 `java.io.Serializable` 인터페이스를 구현하고 있으므로, **Java의 기본 직렬화 방식**을 사용해야만 한다. `JdkSerializationRedisSerializer`가 바로 이 역할을 수행하며, `auth-service`에서 이 방식을 채택한 것은 OAuth2의 `state` 객체를 저장하기 위한 필수적인 선택이다. - ---- - -## 6. 의존성 관리 (pom.xml) - -`auth-service`는 인증/인가 및 SAGA 주관에 필요한 다양한 의존성을 포함한다. - -* `spring-boot-starter-security`: Spring Security 핵심. -* `spring-boot-starter-oauth2-client`: 소셜 로그인 기능. -* `jjwt-api`, `jjwt-impl`, `jjwt-jackson`: JWT 생성, 파싱, 검증. -* `spring-boot-starter-data-redis`: Refresh Token, OAuth2 State, 인증 코드 저장. -* `spring-boot-starter-mail`: 이메일 인증 코드 발송. -* `spring-kafka`: SAGA 트랜잭션 이벤트 발행 및 수신. -* `common-module`: 공통 DTO, 예외, Enum 공유. - ---- - -## 7. 주요 설정 (application.yml) - -`application.yml` 파일은 `auth-service`의 모든 외부 의존성 및 동작 환경을 정의한다. - -* **`server.port: 8081`**: `auth-service`의 실행 포트를 8081로 지정한다. -* **`spring.datasource` / `spring.jpa`**: `AuthUserEntity`를 저장할 MySQL DB 연결 정보를 환경변수(DB_HOST 등)로부터 주입받는다. -* **`spring.data.redis`**: Refresh Token, 인증 코드, OAuth2 State 저장을 위한 Redis(`REDIS_HOST`) 연결 정보를 정의한다. -* **`spring.kafka`**: - * **`producer`**: SAGA 시작(`UserRegistrationRequestedEvent`)을 위해 Kafka로 이벤트를 발행(serialize)하는 설정을 정의한다. - * **`consumer`**: SAGA 응답(`UserProfileCreationSuccessEvent` 등)을 수신(deserialize)하기 위한 설정을 정의한다. `spring.json.trusted.packages`와 `type.mapping`은 `common-module`의 DTO를 올바르게 역직렬화하기 위해 필수적이다. -* **`spring.mail`**: `EmailService`가 인증 코드를 발송하기 위해 사용할 GMail SMTP 서버(`smtp.gmail.com`) 및 계정 정보를 설정한다. -* **`spring.security.oauth2`**: Google, Naver, Kakao 각 소셜 공급자로부터 발급받은 `client-id`, `client-secret` 및 `scope`를 정의한다. 이 설정은 `CustomOAuth2UserService`의 기반 데이터가 된다. -* **`jwt`**: - * `secret`: `JwtTokenProvider`가 토큰 서명에 사용할 비밀 키를 환경변수(`JWT_SECRET`)로부터 주입받는다. - * `access_expiration_time` / `refresh_expiration_time`: 각각 Access Token과 Refresh Token의 만료 시간을 설정한다. -* **`app.frontend.redirect-url`**: 소셜 로그인 성공 시, `OAuth2LoginSuccessHandler`가 사용자를 리디렉션시킬 프론트엔드 콜백 URL을 정의한다. - ---- - -A. 자체 로그인(Local Login) 및 토큰 발급 -#### -```mermaid -sequenceDiagram - participant Client as 👤 클라이언트 - participant AuthService as 🔐 auth-service - participant AuthDB as 🗄️ Auth DB - participant Redis as ⚡ Redis (Cache) - - Client->>+AuthService: POST /auth/login (username, password) - - Note over AuthService: 1. [Filter] CustomAuthenticationFilter - AuthService->>+AuthDB: 2. [Service] CustomUserDetailsService:
사용자 조회 (Status='COMPLETED' 확인) - AuthDB-->>-AuthService: AuthUserEntity - - Note over AuthService: 3. [Handler] LocalLoginSuccessHandler 실행 - AuthService->>AuthService: 4. JwtTokenProvider:
Access/Refresh Token 생성 - AuthService->>+Redis: 5. RefreshTokenService:
Refresh Token 저장 (Key: RT:) - Redis-->>-AuthService: OK - - AuthService-->>-Client: 200 OK (Body: AccessToken, Cookie: RefreshToken) -``` - -B. 회원가입 SAGA (SAGA Coordinator) -#### -```mermaid -sequenceDiagram - participant Client as 👤 클라이언트 - participant AuthService as 🔐 auth-service - participant AuthDB as 🗄️ Auth DB - participant Kafka as 📨 Kafka - participant UserService as 👥 user-service - - Client->>+AuthService: POST /auth/signup (회원가입 요청) - Note over AuthService: 1. 이메일 인증('VE:') 등 검증 - - par [AuthService 로컬 트랜잭션] - AuthService->>+AuthDB: 2. [TX-Auth] AuthUser (PENDING) 저장 - AuthDB-->>-AuthService: OK - and - AuthService->>+Kafka: 3. [SAGA] UserRegistrationRequestedEvent 발행 - Kafka-->>-AuthService: OK - end - - AuthService-->>-Client: 201 Created (1차 응답) - - Kafka-->>+UserService: 4. [SAGA] Event 수신 (UserEventHandler) - UserService-->>-Kafka: (처리 중...) - - Kafka-->>+AuthService: 5. [SAGA-Success] Event 수신 (AuthEventHandler) - Note over AuthService: (user-service가 성공 이벤트를 발행) - AuthService->>+AuthDB: 6. [TX-Auth] AuthUser (PENDING -> COMPLETED) 상태 변경 - AuthDB-->>-AuthService: OK - AuthService-->>-Kafka: (ACK) -``` \ No newline at end of file diff --git a/docs/developer guide/CHAT_SERVICE.md b/docs/developer guide/CHAT_SERVICE.md deleted file mode 100644 index 894a2cb..0000000 --- a/docs/developer guide/CHAT_SERVICE.md +++ /dev/null @@ -1,182 +0,0 @@ -# CHAT_SERVICE.md - -## 1. 개요 - -`chat-service`는 LinkFolio MSA에서 **실시간 1:1 채팅** 및 **메시지 관리**를 전담하는 마이크로서비스이다. - -대용량 메시지 처리를 위해 **MongoDB**를 메인 저장소로 사용하며, 다중 서버 환경에서의 실시간성을 보장하기 위해 **WebSocket (STOMP)**과 **Redis Pub/Sub** 아키텍처를 결합하였다. - -또한, 타 서비스(`user-service`)와의 결합도를 낮추고 조회 성능을 극대화하기 위해 **Kafka CDC(Change Data Capture)**를 통해 사용자 프로필 정보를 로컬 MongoDB에 동기화(Caching)하여 사용한다. - ---- - -## 2. 핵심 기술 및 특징 - -* **WebSocket + STOMP**: 양방향 실시간 통신을 위해 표준 WebSocket 위에 메시징 규약인 STOMP를 얹어 사용한다. -* **Redis Pub/Sub**: Scale-out 된 여러 채팅 서버 인스턴스 간에 메시지를 실시간으로 전파(Broadcast)한다. -* **MongoDB**: 스키마 유연성과 대량의 쓰기/읽기 성능을 위해 NoSQL을 사용한다. -* **Kafka CDC (Data Sync)**: `user-service`의 프로필 변경 사항을 실시간으로 수신하여 `chat-service` 내부의 `chat_user_profile` 컬렉션에 동기화한다. (Feign Client 제거) -* **Gateway Header Auth**: WebSocket Handshake 단계에서 Gateway가 검증한 헤더(`X-User-Id`)를 가로채 인증을 처리한다. - ---- - -## 3. 상세 아키텍처 및 데이터 흐름 - -이 섹션은 프론트엔드 개발자가 채팅 기능을 구현하기 위해 반드시 이해해야 할 흐름을 상세히 기술한다. - -### 3.1. 사전 지식 (Prerequisites) - -1. **WebSocket Handshake**: WebSocket 연결은 최초에 **HTTP 프로토콜**로 시작된다(`Upgrade` 헤더 사용). 따라서 최초 연결 시에는 HTTP 헤더를 사용할 수 있다. -2. **STOMP Protocol**: WebSocket이 연결된 후, 그 위에서 동작하는 텍스트 기반 메시징 프로토콜이다. -3. **Gateway의 역할**: 클라이언트가 직접 마이크로서비스에 붙는 것이 아니라, API Gateway를 거친다. Gateway는 JWT를 검증하고 `X-User-Id` 헤더를 붙여서 내부 서비스로 넘겨준다. - -### 3.2. 연결 및 인증 흐름 (Connection Flow) - -가장 중요한 부분은 "JWT 토큰이 있는 상태에서 어떻게 WebSocket 인증을 통과하는가?"이다. - -1. **클라이언트 연결 요청**: - * 프론트엔드는 `/ws-chat` 엔드포인트로 연결을 시도한다. - * 이때는 **HTTP 요청**이므로, Gateway가 `Authorization` 헤더를 검증하고 `X-User-Id` 헤더를 주입하여 `chat-service`로 전달한다. -2. **Handshake Interceptor (`HttpHandshakeInterceptor`)**: - * `chat-service`는 WebSocket 연결이 맺어지기 직전(Handshake 단계)에 요청을 가로챈다. - * HTTP 헤더에 있는 `X-User-Id`를 꺼내서, **WebSocket 세션 속성(Attributes)**에 저장한다. - * 이 단계가 성공해야 물리적인 연결이 수립된다. -3. **STOMP Connect (`StompHandler`)**: - * 연결 수립 후, 클라이언트는 STOMP `CONNECT` 프레임을 보낸다. - * `StompHandler`는 세션 속성에 저장해둔 `X-User-Id`를 꺼내와서, Spring Security의 `Principal`(인증 객체)로 등록한다. - * 이후의 모든 메시징 작업(Send)에서는 이 `Principal`을 통해 보낸 사람을 식별한다. - -### 3.3. 메시지 전송 및 수신 흐름 (Pub/Sub Flow) - -사용자 A(Server 1 접속)가 사용자 B(Server 2 접속)에게 메시지를 보내는 상황이다. - -1. **SEND (Client -> Server 1)**: - * 사용자 A가 `/app/chat/send` 주소로 JSON 메시지를 전송한다. -2. **Persistence (Server 1)**: - * `ChatSocketController`가 메시지를 받는다. - * `ChatService`가 MongoDB에 메시지를 **저장**한다. -3. **Publish (Server 1 -> Redis)**: - * 저장이 완료되면 `RedisPublisher`가 `chatroom`이라는 Redis Topic에 메시지를 발행(Publish)한다. - * 이때 메시지는 직렬화된 JSON 형태이다. -4. **Subscribe (Redis -> Server 1, Server 2)**: - * `chatroom` 토픽을 구독하고 있던 모든 채팅 서버(Server 1, Server 2)가 메시지를 수신한다. -5. **Broadcast (Server 2 -> Client B)**: - * `RedisSubscriber`는 수신한 메시지의 `roomId`를 확인한다. - * 자신의 서버에 해당 `roomId`를 구독(`SUBSCRIBE /topic/chat/{roomId}`)하고 있는 클라이언트가 있는지 찾는다. - * 사용자 B가 Server 2에 붙어있으므로, Server 2는 사용자 B에게 WebSocket으로 메시지를 쏘아준다. - -### 3.4. WebSocket + STOMP 설정 및 상세 동작 원리 - -WebSocketConfig 설정에 따른 전체적인 메시지 처리 흐름은 다음과 같다. - -**[전체적인 큰 흐름]** -WebSocket + STOMP 구조는 크게 다음 3단계로 동작한다. - -1. **HTTP → WebSocket 업그레이드 단계 (Handshake)** - * 브라우저가 일반 HTTP 요청을 보내고 서버가 이를 WebSocket 프로토콜로 업그레이드한다. - * 이 단계에서 인증(JWT 검증)이나 초기 세션 값 저장이 이루어진다. -2. **클라이언트 → 서버로 메시지 전송 ("/app" 경로 사용)** - * 프론트엔드에서 `/app`으로 시작하는 주소로 메시지를 보내면, 서버의 `@MessageMapping` 메서드가 이 메시지를 처리한다. -3. **서버 → 클라이언트에게 메시지 브로드캐스트 ("/topic" 경로 사용)** - * 서버(또는 Redis Subscriber)가 `/topic`으로 시작하는 경로로 메시지를 발행하면, 해당 토픽을 구독중인 클라이언트들은 실시간으로 메시지를 전달받는다. - -#### 1) registerStompEndpoints (WebSocket 초기 연결) -* `registry.addEndpoint("/ws-chat")`: 브라우저가 WebSocket을 처음 연결할 때 호출하는 URL이다. 즉, "웹소켓 연결 요청은 **/ws-chat** 주소로 하십시오"라는 의미이다. -* `setAllowedOriginPatterns("*")`: 특정 도메인만 허용하는 CORS와 비슷한 개념이며, 어떤 프론트엔드(origin)에서도 접속 가능하도록 허용한다. -* `addInterceptors(httpHandshakeInterceptor)`: Handshake Interceptor는 WebSocket 연결이 성립되기 이전, 즉 **HTTP 요청 단계**에서 실행된다. - * **동작 흐름**: - 1. 브라우저가 `/ws-chat` 주소로 일반 HTTP 요청을 보낸다. - 2. 서버는 이 요청을 WebSocket 프로토콜로 업그레이드한다. - 3. 이 과정에서 HTTP Header 또는 URL Query에서 토큰을 추출하여 JWT 검증 후 유저 ID를 WebSocket 세션에 저장한다. - * 즉, "WebSocket 연결이 되기 전에 인증을 통과한 사용자만 입장시키는 문지기 역할"을 한다. - -#### 2) configureMessageBroker (STOMP 주소 체계) -STOMP는 '주소 기반 라우팅'을 하기 때문에, 메시지를 보낼 때 사용하는 주소와 받을 때 사용하는 주소가 다르다. - -* **setApplicationDestinationPrefixes("/app")** - * **클라이언트 → 서버**로 메시지를 보낼 때 사용하는 prefix이다. (예: `client.send("/app/chat/send", payload)`) - * 서버에서는 `@MessageMapping("/chat/send")` 로 이 메시지를 받는다. - * HTTP의 `@PostMapping` 같은 개념의 "WebSocket용 Controller 경로"라고 보면 된다. -* **enableSimpleBroker("/topic", "/queue")** - * **서버 → 클라이언트**로 메시지를 전달할 때 사용하는 prefix이다. - * `/topic`: 여러 사용자에게 메시지를 뿌릴 때 (Pub/Sub 방식) - * `/queue`: 1:1 개인 메시지 전용 - * 예: 서버에서 `/topic/chatroom/123` 로 메시지를 발행하면, 해당 주소를 구독한 클라이언트들이 모두 메시지를 받는다. - -#### 3) configureClientInboundChannel (메시지 수신 Interceptor) -이 부분은 Handshake 인터셉터와 타이밍이 다르다. WebSocket이 이미 연결된 후 **클라이언트의 STOMP Frame이 서버로 들어올 때마다** 작동한다. - -* **검사 대상**: CONNECT, SEND, SUBSCRIBE, DISCONNECT 등 모든 프레임. -* **필요성**: Handshake에서 인증을 통과했더라도, 이후 권한 없는 채팅방에 `SUBSCRIBE` 요청을 보내거나 `SEND` 요청을 보내는 등 비정상적인 행동을 지속적으로 감시해야 한다. -* 즉, "WebSocket 연결 후에도 지속적으로 보안을 검사하는 필터" 역할을 수행한다. - ---- - -## 4. 데이터 모델 (MongoDB) - -### 4.1. `ChatRoomEntity` (`chat_room`) -채팅방의 메타데이터를 저장한다. - -* **Index**: `{'user1Id': 1, 'user2Id': 1}` (Unique Compound Index) - 항상 `user1Id < user2Id`로 정렬하여 저장, 중복 방 생성 방지. -* **Fields**: - * `lastMessage`: 목록에 보여줄 미리보기 메시지. - * `lastReadAt`: `Map` - 사용자별 마지막 읽은 시간 (안 읽은 메시지 계산용). - -### 4.2. `ChatMessageEntity` (`chat_message`) -실제 대화 내용을 저장한다. - -* **Index**: `roomId` (메시지 이력 조회용) -* **Fields**: `senderId`, `content`, `createdAt`, `readCount` 등. - -### 4.3. `ChatUserProfileEntity` (`chat_user_profile`) [NEW] -타 서비스(`user-service`)의 사용자 정보를 로컬에 캐싱한 데이터이다. - -* **Purpose**: 채팅방 목록 조회 시 상대방의 이름/사진을 보여줘야 하는데, 매번 `user-service`를 호출(Feign)하면 성능 저하가 발생하므로 로컬에 복제본을 둔다. -* **Sync**: `user-service` DB가 변경되면 Kafka CDC를 통해 이 컬렉션이 실시간 업데이트된다. - ---- - -## 5. 주요 기능 구현 상세 - -### 5.1. 읽지 않은 메시지 수 계산 (Unread Count) -* **Logic**: `ChatMessageRepository.countUnreadMessages` -* `roomId`가 일치하고, -* `senderId`가 내가 아니며 (내가 보낸 건 제외), -* `createdAt`이 `ChatRoomEntity`에 저장된 내 `lastReadAt`보다 큰 메시지의 개수를 센다. - -### 5.2. 채팅방 목록 조회 (`GET /chat/rooms`) -이전 버전과 달리 Feign Client를 사용하지 않는다. - -1. MongoDB (`chat_room`)에서 내가 속한 방 목록을 가져온다 (`Slice` 페이징). -2. 방 목록에서 상대방 ID들을 추출한다. -3. MongoDB (`chat_user_profile`)에서 상대방 프로필 정보를 `In-Query`로 한 번에 조회한다. (성능 최적화) -4. 각 방의 `unreadCount`를 계산하여 DTO로 조합 후 반환한다. - ---- - -## 6. 시퀀스 다이어그램 - -#### A. 인증 및 연결 (Handshake & Connect) - -```mermaid -sequenceDiagram - participant Client as 👤 클라이언트 - participant Gateway as 🚪 API Gateway - participant Interceptor as 🛑 HttpHandshakeInterceptor - participant StompHandler as 👮 StompHandler - participant Session as 💾 WebSocket Session - - Client->>Gateway: CONNECT /ws-chat (HTTP Upgrade) - Note right of Client: Header: Authorization (JWT) - - Gateway->>Gateway: JWT 검증 & X-User-Id 주입 - Gateway->>Interceptor: Request (Header: X-User-Id) - - Interceptor->>Session: Attributes.put("X-User-Id", value) - Interceptor-->>Client: 101 Switching Protocols (WS 연결 성공) - - Client->>StompHandler: STOMP CONNECT Frame - StompHandler->>Session: Attributes.get("X-User-Id") - StompHandler->>StompHandler: Set Principal (UserAuth) - StompHandler-->>Client: STOMP CONNECTED Frame -``` \ No newline at end of file diff --git a/docs/developer guide/COMMON_MODULE.md b/docs/developer guide/COMMON_MODULE.md deleted file mode 100644 index b21757a..0000000 --- a/docs/developer guide/COMMON_MODULE.md +++ /dev/null @@ -1,83 +0,0 @@ -## 1. 개요 - -`common-module`은 LinkFolio MSA 프로젝트의 **공통 라이브러리 모듈**이다. - -모든 마이크로서비스(예: `user-service`, `auth-service`, `portfolio-service`)가 공유하는 DTO, Enum, 공통 예외 처리 로직, 엔티티 기반 클래스 등을 중앙에서 관리하여 코드 중복을 제거하고 일관성을 유지하는 것을 목적으로 한다. - -이 모듈은 실행 가능한 Spring Boot 애플리케이션이 아닌, 다른 서비스들에 의해 의존성으로 포함되는 **JAR 라이브러리**로 패키징된다. - ---- - -## 2. 핵심 구성 요소 - -### 2.1. 공통 예외 처리 (`exception`) - -모든 서비스에서 일관된 예외 응답 형식을 보장하기 위한 핵심 로직을 제공한다. - -* **`GlobalExceptionHandler`**: `@RestControllerAdvice`를 통해 모든 컨트롤러에서 발생하는 예외를 가로챈다. -* **`BusinessException`**: 서비스 전반에서 사용되는 공통 런타임 예외. 각 서비스의 `ErrorCode`를 담아 `GlobalExceptionHandler`로 전달한다. -* **`ErrorResponse`**: `timestamp`, `status`, `code`, `message` 필드를 갖는 표준화된 JSON 오류 응답 DTO이다. -* **`ErrorCode` (Interface)**: 각 서비스의 `ErrorCode` Enum(예: `auth-service`의 `ErrorCode`)이 반드시 구현해야 하는 공통 인터페이스이다. - -### 2.2. 내부 인증 필터 (`filter`) - -* **`InternalHeaderAuthenticationFilter`**: `apigateway-service`를 통과한 요청을 처리하는 내부 서비스(예: `user-service`, `portfolio-service`)의 `SecurityConfig`에 등록된다. -* **동작**: 게이트웨이가 주입한 `X-User-Id`, `X-User-Email`, `X-User-Role` 헤더를 읽어 신뢰할 수 있는 `AuthUser` 객체를 생성한다. -* **역할**: 생성된 인증 객체를 `SecurityContextHolder`에 등록하여, 내부 서비스들이 JWT 검증 없이도 `@AuthenticationPrincipal` 어노테이션 등으로 사용자를 즉시 식별할 수 있게 한다. - -### 2.3. 공통 엔티티 및 Enum (`entity`) - -* **`BaseEntity`**: 모든 JPA 엔티티가 상속받는 `@MappedSuperclass`이다. `@CreatedDate` (`createdAt`)와 `@LastModifiedDate` (`lastModifiedAt`) 필드를 제공하여 생성/수정 시간을 자동으로 관리한다. -* **`enumerate`**: - * `Role.java` (USER, ADMIN) - * `Gender.java` (MALE, FEMALE) - * `UserProvider.java` (LOCAL, GOOGLE, NAVER, KAKAO) - -### 2.4. 이벤트 DTO (`dto.event`) - -Kafka를 통해 서비스 간 비동기 통신(SAGA 패턴, 데이터 동기화)에 사용되는 공통 이벤트 DTO를 정의한다. - -* **`UserRegistrationRequestedEvent`**: 회원가입 SAGA 시작 이벤트 (Auth -> User). -* **`UserProfileCreationSuccessEvent`**: SAGA 성공 이벤트 (User -> Auth). -* **`UserProfileCreationFailureEvent`**: SAGA 보상 트랜잭션 이벤트 (User -> Auth). -* **`UserProfilePublishedEvent`**: 프로필 생성/변경 전파(Fan-out) 이벤트 (User -> Auth, Portfolio 등). - ---- - -## 3. 의존성 관리 (`pom.xml`) - -`common-module`의 `pom.xml`은 대부분의 서비스가 공통으로 필요로 하는 핵심 의존성들을 포함한다. - -* `spring-boot-starter-data-jpa` -* `spring-boot-starter-web` (Spring MVC) -* `spring-boot-starter-security` - -> ** 의존성 제외(Exclusion) 전략** -> -> `common-module`은 Spring MVC(`web`)와 JPA(`data-jpa`) 기반으로 작성되었다. -> -> 하지만 `apigateway-service`와 같이 `WebFlux(Reactive)`를 사용하거나 `DB가 필요 없는` 서비스는, `common-module`을 의존할 때 반드시 이 의존성들을 `` 태그로 제외해야 한다. -> -> *`apigateway-service/pom.xml` 예시:* -> ```xml -> -> com.example -> common-module -> ${project.version} -> -> -> org.springframework.boot -> spring-boot-starter-web -> -> -> org.springframework.boot -> spring-boot-starter-data-jpa -> -> -> org.springframework.boot -> spring-boot-starter-security -> -> -> -> ``` -> \ No newline at end of file diff --git a/docs/developer guide/PORTFOLIO_SERVICE.md b/docs/developer guide/PORTFOLIO_SERVICE.md deleted file mode 100644 index 57a3a99..0000000 --- a/docs/developer guide/PORTFOLIO_SERVICE.md +++ /dev/null @@ -1,169 +0,0 @@ -# PORTFOLIO_SERVICE.md - -## 1. 개요 - -`portfolio-service`는 LinkFolio MSA에서 **사용자의 포트폴리오** 및 관련 데이터(관심(Like) 등)를 전문적으로 관리하는 마이크로서비스이다. - -이 서비스의 가장 큰 아키텍처적 특징은 데이터 `비정규화(Denormalization)`이다. `user-service`의 사용자 정보(이름, 이메일 등)를 Feign Client를 통해 실시간으로 호출하여 조인하는 대신, **Kafka(CDC)**를 통해 `비동기`적으로 데이터를 수신하여 `PortfolioEntity` 내부에 캐시(저장)한다. - -이러한 설계는 포트폴리오 목록 조회와 같은 대량 읽기(Read) 작업에서 `user-service`에 대한 동기식 의존성을 제거하여, 시스템 전체의 성능과 장애 격리 수준을 크게 향상시킨다. - ---- - -## 2. 핵심 기능 - -* **포트폴리오 CRUD**: 사용자는 자신의 포트폴리오를 생성, 조회, 수정할 수 있다 (`getMyPortfolio`, `createOrUpdateMyPortfolio`). -* **포트폴리오 '관심' 기능**: 다른 사용자의 포트폴리오에 '관심'을 추가하거나 취소할 수 있다 (`addLike`, `removeLike`). -* **데이터 동기화 (Kafka Consumer)**: `user-service`의 DB 변경 사항(CDC)을 수신하여 포트폴리오에 캐시된 사용자 정보를 생성하거나 갱신한다. -* **동적 검색 (QueryDSL)**: 직군(position) 필터링 및 `likeCount`, `createdAt` 등 다양한 조건으로 포트폴리오 목록을 정렬 및 검색(Slice)한다. - ---- - -## 3. 데이터 모델 및 비정규화 - -### 3.1. `PortfolioEntity.java` - -`portfolio-service`의 핵심 엔티티는 `PortfolioEntity`이다. - -* **소유자**: `userId` 필드는 `auth-service` 및 `user-service`와 공유하는 사용자의 고유 ID(PK)이며, 이 포트폴리오의 소유자를 나타낸다. `Unique` 제약조건이 걸려있어 사용자 한 명당 하나의 포트폴리오만 생성할 수 있다. -* **비정규화된 사용자 정보**: `name`, `email`, `birthdate`, `gender` 필드는 `user-service`가 원본(Source of Truth)을 가진 데이터이다. `PortfolioService`는 이 데이터를 **Kafka를 통해 수신하여 복제 및 캐시**한다. -* **사용자 입력 정보**: `photoUrl`, `oneLiner`, `content`, `position`, `hashtags` 등은 사용자가 `portfolio-service`를 통해 직접 입력하고 수정하는 데이터이다. -* **상태 관리**: `isPublished` (발행 여부), `viewCount` (조회수), `likeCount` (관심수) 필드를 통해 포트폴리오 상태를 관리한다. - -### 3.2. `PortfolioLikeEntity.java` - -'관심' 관계를 저장하는 엔티티이다. - -* `likerId` (관심을 누른 사용자 ID)와 `portfolio` (관심 대상 포트폴리오) 두 컬럼에 `uk_user_portfolio`라는 **복합 유니크 제약조건(Unique Constraint)**이 설정되어 있다. -* 이는 한 명의 사용자가 동일한 포트폴리오에 여러 번 '관심'을 누르는 것을 DB 레벨에서 원천 차단한다. - ---- - -## 4. 데이터 동기화 흐름 (Kafka Consumer) - -이 서비스는 SAGA의 최종 소비자(Consumer) 역할을 한다. - -* **`PortfolioEventHandler.java`**: Debezium(CDC)이 발행하는 `user_db_server.user_db.user_profile` 토픽을 `@KafkaListener`로 구독한다. -* **메시지 처리 방식**: Java DTO가 아닌 **Avro (`GenericRecord`)** 형식을 사용하여 스키마 의존성을 낮추고 데이터를 유연하게 파싱한다. -* **흐름 1: 신규 회원가입 (SAGA 완료 시)** - 1. `user-service`가 프로필을 `COMPLETED` 상태로 DB에 저장하면, CDC가 이벤트를 발행한다. - 2. `PortfolioEventHandler`가 이벤트를 수신하고 `GenericRecord`에서 `userId`, `name` 등을 추출한다. - 3. `portfolioRepository.findByUserId`로 조회 시 엔티티가 존재하지 않으므로, `else` 분기를 탄다. - 4. 이벤트의 정보를 기반으로 **`isPublished(false)`** 상태의 초기 `PortfolioEntity` 레코드를 생성한다. -* **흐름 2: 기존 사용자 프로필 수정 시** - 1. `user-service`에서 사용자 정보가 수정되어 DB에 반영되면, CDC가 이벤트를 발행한다. - 2. `PortfolioEventHandler`가 이벤트를 수신한다. - 3. `findByUserId`로 `PortfolioEntity`를 찾은 후, `portfolio.updateCache(...)` 메서드를 호출하여 `name`, `email` 등 캐시된 필드를 덮어쓴다(동기화한다). - ---- - -## 5. 비즈니스 로직 및 QueryDSL - -### 5.1. `PortfolioService.java` - -* **`createOrUpdateMyPortfolio`**: - * Kafka가 생성해둔 `PortfolioEntity`를 `authUserId`로 조회한다. - * `PortfolioRequest` DTO의 값으로 `portfolio.updateUserInput(...)`을 호출하여 사용자 입력 필드를 갱신한다. - * 이 과정에서 `isPublished` 상태가 `true`로 변경된다. -* **`getPortfolioDetails`**: - * 비정규화된 `PortfolioEntity`만 조회하므로 Feign Client 호출이 발생하지 않는다. - * `portfolio.increaseViewCount()`를 호출하여 조회수를 1 증가시킨다(Dirty Checking). - * 만약 사용자가 인증된 상태(`authUser != null`)라면, `portfolioLikeRepository.existsByLikerIdAndPortfolio`를 호출하여 `isLiked` 상태를 `true/false`로 설정한 후 DTO로 반환한다. - -### 5.2. `PortfolioLikeService.java` - -'관심' 기능은 `PortfolioLikeEntity` (관계)와 `PortfolioEntity` (카운트 캐시)를 **둘 다 갱신**하는 트랜잭션으로 동작한다. - -* **`addLike`**: - 1. `PortfolioLikeEntity`를 생성하고 `save`한다. - 2. `portfolio.addLike(portfolioLike)`를 호출하여 `PortfolioEntity`의 `likeCount`를 1 증가시킨다. -* **`removeLike`**: - 1. `PortfolioLikeEntity`를 조회하여 `delete`한다. - 2. `portfolio.removeLike(portfolioLike)`를 호출하여 `likeCount`를 1 감소시킨다. - -### 5.3. QueryDSL (`*RepositoryImpl.java`) - -`config/QueryDslConfig`를 통해 `JPAQueryFactory`를 빈으로 등록한다. - -* **`PortfolioRepositoryImpl.java`**: 메인 페이지의 포트폴리오 목록(`searchPortfolioList`)을 조회한다. -* **`PortfolioLikeRepositoryImpl.java`**: '내 관심 목록'(`searchMyLikedPortfolios`)을 조회한다. -* 두 구현체 모두 `positionEq`와 같은 동적 `where`절과, `Pageable`의 `Sort` 객체를 파싱하여 `likeCount`, `createdAt` 등으로 동적 정렬을 수행하는 `applySorting` 로직을 포함하고 있다. - ---- - -## 6. 보안 및 인증 (`SecurityConfig.java`) - -`user-service`와 마찬가지로 `InternalHeaderAuthenticationFilter`를 사용하여 게이트웨이 인증을 신뢰한다. - -**핵심 차이점**: -* `GET /portfolios` (목록 조회) -* `GET /portfolios/{portfolioId:\d+}` (상세 조회) - -위 두 경로는 `permitAll()`로 설정되어 **인증되지 않은 사용자도 포트폴리오를 조회**할 수 있도록 허용한다. - -`PortfolioController`는 `@AuthenticationPrincipal AuthUser authUser` 파라미터를 받으며, `permitAll()` 경로에서는 이 값이 `null`로 전달된다. `PortfolioService`는 `authUser`가 `null`인지 여부를 확인하여 '관심' 여부(`isLiked`)를 처리한다. - ---- - -## 7. 주요 설정 (application.yml) - -`application.yml`은 `portfolio-service`의 데이터베이스 및 Kafka 소비자 설정을 정의한다. - -* **`server.port: 8082`**: `portfolio-service`의 실행 포트를 8082로 지정한다. -* **`spring.datasource` / `spring.jpa`**: `PortfolioEntity` 등을 저장할 MySQL DB 연결 정보를 정의한다. -* **`spring.kafka.consumer`**: - * `group-id: "portfolio-consumer-group"`: `portfolio-service`의 소비자 그룹을 식별한다. - * `key/value-deserializer`: `KafkaAvroDeserializer`를 사용한다. - * `schema.registry.url`: Avro 스키마를 조회할 레지스트리 주소를 설정한다. - * `specific.avro.reader: false`: 특정 클래스로 매핑하지 않고 `GenericRecord`로 유연하게 읽어들인다. -* **`app.feign.user-service-url`**: `pom.xml`과 `PortfolioServiceApplication`에 Feign Client가 활성화되어 있고, `application.yml`에도 `user-service` URL이 정의되어 있다. 하지만 현재 비즈니스 로직(Kafka 비동기 동기화)으로 인해 **실제 Feign Client 인터페이스가 정의되거나 사용되지는 않고 있다.** 이는 향후 동기식 호출이 필요할 경우를 대비한 설정으로 볼 수 있다. - ---- -A. 데이터 동기화 (Fan-out Consumer: CDC) -#### -```mermaid -sequenceDiagram - participant UserService as 👥 user-service - participant UserDB as 🗄️ User DB - participant Kafka as 📨 Kafka (Connect) - participant PortfolioService as 📑 portfolio-service - participant PortfolioDB as 🗄️ Portfolio DB - - Note over UserService: (사용자가 /users/me 에서 이름 변경) - UserService->>+UserDB: 1. [TX] 사용자 정보 UPDATE - UserDB-->>-UserService: OK - - Note over Kafka: 2. Debezium이 UserDB 변경 감지 및 발행 - Kafka-->>+PortfolioService: 3. [CDC] Event 수신 (PortfolioEventHandler) - - PortfolioService->>PortfolioService: 4. Avro Parsing (GenericRecord) - - PortfolioService->>+PortfolioDB: 5. PortfolioEntity 조회 (BY userId) - PortfolioDB-->>-PortfolioService: PortfolioEntity - - PortfolioService->>+PortfolioDB: 6. PortfolioEntity UPDATE
(캐시된 name, email 등 동기화) - PortfolioDB-->>-PortfolioService: OK -``` -B. 포트폴리오 '관심' 추가 (Like) -#### -```mermaid -sequenceDiagram -participant Client as 👤 클라이언트 -participant PortfolioService as 📑 portfolio-service -participant PortfolioDB as 🗄️ Portfolio DB - - Client->>+PortfolioService: POST /portfolios/{id}/like - - Note over PortfolioService: 1. [TX] PortfolioLikeService.addLike() 실행 - - PortfolioService->>+PortfolioDB: 2. (복합키) uk_user_portfolio 중복 검사 - PortfolioDB-->>-PortfolioService: (중복 없음) - - PortfolioService->>+PortfolioDB: 3. PortfolioLikeEntity INSERT (관계 저장) - PortfolioDB-->>-PortfolioService: OK - - PortfolioService->>+PortfolioDB: 4. PortfolioEntity UPDATE (likeCount = likeCount + 1) - PortfolioDB-->>-PortfolioService: OK - - PortfolioService-->>-Client: 201 Created (TX Commit) -``` \ No newline at end of file diff --git a/docs/developer guide/USER_SERVICE.md b/docs/developer guide/USER_SERVICE.md deleted file mode 100644 index 16d2328..0000000 --- a/docs/developer guide/USER_SERVICE.md +++ /dev/null @@ -1,125 +0,0 @@ -# USER_SERVICE.md - -## 1. 개요 - -`user-service`는 LinkFolio MSA에서 **사용자 프로필 정보**를 전담하여 관리하는 마이크로서비스이다. - -`auth-service`가 '인증' 자체(예: 비밀번호, 소셜 계정)를 담당한다면, `user-service`는 '인증된 사용자'의 상세 정보(예: 실명, 생년월일, 성별 등)를 저장하고 관리한다. - -이 서비스의 가장 중요한 역할은 `auth-service`로부터 시작된 회원가입 SAGA 트랜잭션의 참여자(Participant)로서, 프로필 생성을 완료하고 그 결과를 다시 `auth-service` 및 다른 서비스들에게 전파(Fan-out)하는 것이다. - ---- - -## 2. 핵심 기능 - -* **SAGA 트랜잭션 참여**: `auth-service`의 회원가입 요청(`UserRegistrationRequestedEvent`)을 Kafka로 수신하여 사용자 프로필을 생성한다. -* **SAGA 응답 및 전파**: - 1. **(SAGA 응답)** 프로필 생성 성공/실패 여부를 Kafka(`UserProfileCreationSuccessEvent` / `FailureEvent`)를 통해 `auth-service`로 다시 알린다. - 2. **(데이터 전파)** 프로필이 생성되거나 수정되면, `UserProfilePublishedEvent`를 발행(Fan-out)하여 `portfolio-service`나 `auth-service` 등 다른 서비스들이 데이터를 동기화할 수 있도록 한다. -* **프로필 관리 API**: `apigateway-service`를 통해 인증된 사용자의 프로필 조회(`GET /users/me`) 및 수정(`PUT /users/me`) API를 제공한다. - ---- - -## 3. SAGA 및 데이터 전파 흐름 (Kafka) - -`user-service`의 핵심 로직은 `UserEventHandler`에 집중되어 있다. - -### 3.1. SAGA 참여자 (Consumer) - -1. `auth-service`가 `UserRegistrationRequestedEvent`를 발행하면, `UserEventHandler`의 `handleUserRegistrationRequested` 메서드가 이를 수신(@KafkaListener)한다. -2. 이 메서드는 `UserService.createUserProfile`를 호출한다. -3. `createUserProfile`는 `AuthUserEntity`와 동일한 `userId`를 PK로 갖는 `UserProfileEntity`를 생성한다. - * `UserProfileEntity`는 `fromEvent` 정적 메서드를 통해 `UserProfileStatus.PENDING` 상태로 생성된다. - * 즉시 `updateStatus(UserProfileStatus.COMPLETED)`로 상태가 변경된 후 DB에 저장된다. -4. **(멱등성)** 만약 동일한 `userId`로 이벤트가 중복 수신되더라도, `userRepository.existsById` 검사를 통해 이미 생성된 프로필을 반환하여 멱등성을 보장한다. - -### 3.2. SAGA 응답 및 전파 (CDC 기반) - -`UserEventHandler`는 프로필 생성 성공/실패 여부에 따라 다르게 동작한다. - -* **성공 시 (UserService)**: - * Java 코드 레벨에서 Kafka 이벤트를 발행하지 않는다. - * `userRepository.save()`를 통해 DB에 데이터가 저장되면, **Debezium(CDC)**이 Transaction Log를 감지하여 `user_db.user_profile` 토픽으로 이벤트를 자동 발행한다. - * `auth-service`와 `portfolio-service`는 이 CDC 이벤트를 구독하여 상태를 동기화한다. -* **실패 시 (UserEventHandler catch 블록)**: - * 예외 발생 시 `KafkaTemplate`을 사용하여 명시적으로 `UserProfileCreationFailureEvent`를 발행한다. - * 이를 수신한 `auth-service`는 계정 상태를 `CANCELLED`로 변경한다. - -### 3.3. 프로필 수정 시 데이터 전파 - -사용자가 `PUT /users/me` API를 통해 프로필(이름, 생년월일 등)을 수정하면, `UserService.updateUserProfile` 메서드가 호출된다. - -이 메서드 또한 **DB만 업데이트**한다. 트랜잭션이 커밋되면 CDC가 자동으로 변경된 이름 등의 정보를 Kafka로 발행(Fan-out)하며, 이를 구독하고 있는 `portfolio-service`와 `auth-service`가 각자의 캐시 데이터를 일관성 있게 갱신한다. 이를 통해 'Dual Write' 문제(DB는 갱신되었으나 메시지 발행 실패)를 원천 차단한다. - ---- - -## 4. 보안 및 인증 (`SecurityConfig.java`) - -`user-service`는 `auth-service`와 달리 인증을 직접 수행하지 않고, `apigateway-service`의 인증 결과를 신뢰한다. - -* `SecurityConfig`는 `common-module`에 정의된 `InternalHeaderAuthenticationFilter`를 `AuthorizationFilter` 앞에 등록한다. -* 이 필터는 게이트웨이가 주입한 `X-User-Id`, `X-User-Email`, `X-User-Role` 헤더를 읽어 `SecurityContextHolder`에 `AuthUser` 객체를 등록한다. -* 이를 통해 `UserController`는 `@AuthenticationPrincipal AuthUser authUser` 어노테이션을 사용하여 JWT 파싱 없이도 즉시 사용자 ID(`authUser.getUserId()`)를 획득할 수 있다. - ---- - -## 5. 주요 설정 (`application.yml`) - -`application.yml`은 `user-service`의 SAGA 참여자 및 데이터 전파자로서의 역할을 정의한다. - -* **`server.port: 8080`**: `user-service`의 실행 포트를 8080으로 지정한다. -* **`spring.datasource` / `spring.jpa`**: `UserProfileEntity`를 저장할 MySQL DB 연결 정보를 정의한다. -* **`spring.data.redis`**: `user-service`도 Redis 설정을 포함한다. `RedisConfig`를 보면 `auth-service`와 달리, 값(value)의 직렬화 방식으로 `GenericJackson2JsonRedisSerializer` (JSON)를 사용한다. 이는 `OAuth2AuthorizationRequest` 객체처럼 Java 직렬화가 필수적인 복잡한 객체를 저장할 필요가 없기 때문이다. -* **`spring.kafka`**: - * **`consumer`**: SAGA 시작(`UserRegistrationRequestedEvent`) 이벤트를 수신(deserialize)하기 위한 설정을 정의한다. - * **`producer`**: SAGA 응답(`Success/FailureEvent`) 및 데이터 전파(`PublishedEvent`)를 위해 이벤트를 발행(serialize)하는 설정을 정의한다. - * `properties.spring.json.type.mapping`: `common-module`의 이벤트 DTO를 Kafka 메시지와 매핑하여 올바르게 역직렬화하기 위한 필수 설정이다. - ---- - -## 6. 의존성 관리 (`pom.xml`) - -`user-service`의 `pom.xml`은 SAGA 참여 및 데이터 관리에 필요한 의존성들을 포함한다. - -* `spring-boot-starter-data-jpa`: `UserProfileEntity` 관리를 위한 JPA 의존성. -* `spring-kafka`: SAGA 이벤트 수신 및 발행을 위한 Kafka 의존성. -* `spring-boot-starter-security`: `SecurityConfig` 및 `InternalHeaderAuthenticationFilter` 적용을 위한 의존성. -* `spring-boot-starter-data-redis`: Redis 연결 및 캐싱(현재 명시적 사용은 적으나 향후 확장용) 의존성. -* `common-module`: `BaseEntity`, `InternalHeaderAuthenticationFilter`, SAGA 이벤트 DTO(`UserRegistrationRequestedEvent` 등)를 공유하기 위한 핵심 의존성. -* `org.mapstruct:mapstruct`: `UserMapper`에서 `UserProfileEntity`를 `UserResponse` DTO 등으로 변환하기 위해 사용된다. - ---- - -#### - -```mermaid -sequenceDiagram - participant AuthService as 🔐 auth-service - participant Kafka as 📨 Kafka - participant UserService as 👥 user-service - participant UserDB as 🗄️ User DB - participant PortfolioService as 📑 portfolio-service - - Note over Kafka: (AuthService가 SAGA 시작 이벤트 발행) - Kafka-->>+UserService: 1. [SAGA] UserRegistrationRequestedEvent 수신
(UserEventHandler) - - UserService->>UserService: 2. createUserProfile() 실행 - - par [UserService 로컬 트랜잭션] - UserService->>+UserDB: 3. [TX-User] UserProfile (COMPLETED) 저장 - UserDB-->>-UserService: OK - and - UserService->>+Kafka: 4. [SAGA-Success] UserProfileCreationSuccessEvent 발행
(-> AuthService) - and - UserService->>+Kafka: 5. [Fan-out] UserProfilePublishedEvent 발행
(-> AuthService, PortfolioService) - end - - Kafka-->>-UserService: (ACK) - - Kafka-->>+AuthService: 6. [SAGA-Success] Event 수신
(AuthUser 상태 COMPLETED로 변경) - Kafka-->>-AuthService: (ACK) - - Kafka-->>+PortfolioService: 7. [Fan-out] Event 수신 (PortfolioEventHandler) - PortfolioService-->>PortfolioService: 8. PortfolioEntity 초기 레코드 생성 (데이터 동기화) - PortfolioService-->>-Kafka: (ACK) -``` \ No newline at end of file diff --git a/portfolio-service/pom.xml b/portfolio-service/pom.xml index 8712c5d..31c0f7a 100644 --- a/portfolio-service/pom.xml +++ b/portfolio-service/pom.xml @@ -148,12 +148,6 @@ test - - - org.springframework.cloud - spring-cloud-starter-openfeign - - com.example diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/PortfolioServiceApplication.java b/portfolio-service/src/main/java/com/example/portfolioservice/PortfolioServiceApplication.java index 80fde6e..976b30c 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/PortfolioServiceApplication.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/PortfolioServiceApplication.java @@ -2,13 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication(scanBasePackages = "com.example") @EnableJpaAuditing -@EnableFeignClients public class PortfolioServiceApplication { public static void main(String[] args) { diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/controller/PortfolioController.java b/portfolio-service/src/main/java/com/example/portfolioservice/controller/PortfolioController.java index 12a1da4..be1f262 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/controller/PortfolioController.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/controller/PortfolioController.java @@ -63,7 +63,7 @@ public ResponseEntity getPortfolioDetailsApi(@PathVari return ResponseEntity.ok(response); } - @Operation(summary = "포트폴리오 관심 추가", description = "특정 포트폴리오에 관심을 추가합니다.") + @Operation(summary = "포트폴리오 북마크 추가", description = "특정 포트폴리오를 북마크로 추가합니다.") @SecurityRequirement(name = "BearerAuthentication") @PostMapping("/portfolios/{portfolioId}/like") public ResponseEntity addLikeApi(@AuthenticationPrincipal AuthUser authUser, @@ -72,7 +72,7 @@ public ResponseEntity addLikeApi(@AuthenticationPrincipal AuthUser authUse return ResponseEntity.status(HttpStatus.CREATED).build(); } - @Operation(summary = "포트폴리오 관심 취소", description = "특정 포트폴리오에 대한 관심을 취소합니다.") + @Operation(summary = "포트폴리오 북마크 취소", description = "특정 포트폴리오에 대한 북마크를 취소합니다.") @SecurityRequirement(name = "BearerAuthentication") @DeleteMapping("/portfolios/{portfolioId}/like") public ResponseEntity removeLikeApi(@AuthenticationPrincipal AuthUser authUser, diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/controller/TestSetupController.java b/portfolio-service/src/main/java/com/example/portfolioservice/controller/TestSetupController.java index 47f360e..b901996 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/controller/TestSetupController.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/controller/TestSetupController.java @@ -24,6 +24,10 @@ public class TestSetupController { private final PortfolioRepository portfolioRepository; + private static final String[] POSITIONS = { + "BACKEND", "FRONTEND", "FULLSTACK", "DEVOPS", "AI_ML", "MOBILE", "DESIGN", "PM" + }; + @PostMapping("/setup") @Transactional public ResponseEntity setupPortfolio(@RequestBody Map request) { @@ -41,13 +45,15 @@ public ResponseEntity setupPortfolio(@RequestBody Map requ .isPublished(true) // 즉시 공개 상태로 생성 .build()); + String randomPosition = POSITIONS[(int) (userId % POSITIONS.length)]; + // 2. 테스트용 데이터 강제 주입 (Update) portfolio.updateUserInput( - "https://via.placeholder.com/150", // photoUrl - "안녕하세요, 성능 테스트용 포트폴리오입니다.", // oneLiner - "이것은 k6 테스트를 위해 자동 생성된 포트폴리오 내용입니다.", // content - "BACKEND", // position - null // hashtags + "https://via.placeholder.com/150", + "안녕하세요, 성능 테스트용 포트폴리오입니다.", + "이것은 k6 테스트를 위해 자동 생성된 포트폴리오 내용입니다.", + randomPosition, + null ); portfolioRepository.save(portfolio); diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/dto/response/PortfolioDetailsResponse.java b/portfolio-service/src/main/java/com/example/portfolioservice/dto/response/PortfolioDetailsResponse.java index 5dd765e..4706ca3 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/dto/response/PortfolioDetailsResponse.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/dto/response/PortfolioDetailsResponse.java @@ -1,14 +1,15 @@ package com.example.portfolioservice.dto.response; import com.example.commonmodule.entity.enumerate.Gender; -import lombok.Builder; -import lombok.Data; +import lombok.*; import java.time.LocalDateTime; import java.util.List; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class PortfolioDetailsResponse { // 고정 정보 (캐시) private Long userId; diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/entity/PortfolioEntity.java b/portfolio-service/src/main/java/com/example/portfolioservice/entity/PortfolioEntity.java index 4594e9b..e302f76 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/entity/PortfolioEntity.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/entity/PortfolioEntity.java @@ -9,11 +9,20 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @Entity -@Table(name = "`portfolio`") +@Table(name = "`portfolio`", indexes = { + // 인기순 정렬 (필터링 X) + @Index(name = "idx_portfolio_popularity", columnList = "is_published, popularity_score DESC"), + // 최신순(수정순) 정렬 (필터링 X) + @Index(name = "idx_portfolio_publish_date", columnList = "is_published, last_modified_at DESC"), + // 직군별 필터링 + 최신 수정순 정렬 (커버링 인덱스 효과) + @Index(name = "idx_portfolio_position_publish_date", columnList = "is_published, position, last_modified_at DESC") +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PortfolioEntity extends BaseEntity { @@ -68,9 +77,11 @@ public class PortfolioEntity extends BaseEntity { @Column(name = "like_count", nullable = false) @ColumnDefault("0") - private Long likeCount = 0L; - + private Long likeCount = 0L; // 북마크 수 + @Column(name = "popularity_score") + @ColumnDefault("0") + private Long popularityScore = 0L; // == 생성자 == // @Builder @@ -99,8 +110,10 @@ public void updateCache(String name, String email, String birthdate, Gender gend } // --- 내부 헬퍼 메서드 --- // - public void increaseViewCount() { - this.viewCount++; + + // 스케줄러가 조회수 반영할 때 사용 + public void increaseViewCount(Long count) { + this.viewCount += count; } public void increaseLikeCount() { @@ -111,7 +124,6 @@ public void decreaseLikeCount() { this.likeCount = Math.max(0, this.likeCount - 1); } - // 사용자가 입력한 포트폴리오 정보 갱신 public void updateUserInput(String photoUrl, String oneLiner, String content, String position, List hashtags) { this.photoUrl = photoUrl; @@ -130,7 +142,19 @@ public void updateUserInput(String photoUrl, String oneLiner, String content, St if (!this.isPublished) { this.isPublished = true; } + calculateAndSetPopularityScore(); } + // 점수 계산 로직 + // ㄴ Hacker News 알고리즘의 변형(시간이 지날수록 분모가 커져서 점수가 낮아지도록) -> Score = (Points) / (Time + 2)^1.5 + public void calculateAndSetPopularityScore() { + long points = (this.viewCount * 1) + (this.likeCount * 50); + + LocalDateTime timeBase = (this.getLastModifiedAt() != null) ? this.getLastModifiedAt() : LocalDateTime.now(); + long hoursDiff = Math.max(0, ChronoUnit.HOURS.between(timeBase, LocalDateTime.now())); + double timeFactor = Math.pow(hoursDiff + 2, 1.5); + + this.popularityScore = (long) ((points * 1000) / timeFactor); + } } \ No newline at end of file diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/repository/PortfolioRepository.java b/portfolio-service/src/main/java/com/example/portfolioservice/repository/PortfolioRepository.java index cbc108e..8b63f04 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/repository/PortfolioRepository.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/repository/PortfolioRepository.java @@ -4,10 +4,30 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; // [추가] public interface PortfolioRepository extends JpaRepository, PortfolioRepositoryCustom { // userId(소유자ID)로 포트폴리오를 조회하는 메서드 Optional findByUserId(Long userId); + + // 조회수 Bulk Update + @Modifying(clearAutomatically = true) + @Query("UPDATE PortfolioEntity p SET p.viewCount = p.viewCount + :count WHERE p.portfolioId = :id") + void incrementViewCount(@Param("id") Long id, @Param("count") Long count); + + // 좋아요 수 Bulk Update + @Modifying(clearAutomatically = true) + @Query("UPDATE PortfolioEntity p SET p.likeCount = p.likeCount + :delta WHERE p.portfolioId = :id") + void updateLikeCount(@Param("id") Long id, @Param("delta") Long delta); + + // 인기 점수 Bulk Update + @Modifying(clearAutomatically = true) + @Query(value = "UPDATE portfolio p SET p.popularity_score = " + + "CAST((p.view_count + p.like_count * 50) * 1000 / POW(TIMESTAMPDIFF(HOUR, IFNULL(p.last_modified_at, NOW()), NOW()) + 2, 1.5) AS UNSIGNED)", + nativeQuery = true) + void updateAllPopularityScores(); } \ No newline at end of file diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/repository/PortfolioRepositoryImpl.java b/portfolio-service/src/main/java/com/example/portfolioservice/repository/PortfolioRepositoryImpl.java index ffdde00..0e3a78e 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/repository/PortfolioRepositoryImpl.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/repository/PortfolioRepositoryImpl.java @@ -7,7 +7,6 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.PathBuilder; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -82,14 +81,11 @@ private BooleanExpression positionEq(String position) { */ private void applySorting(JPAQuery query, Sort sort) { if (sort.isUnsorted()) { - // 정렬 조건이 없으면 기본값 (최신순) - query.orderBy(portfolio.createdAt.desc()); + // 정렬 조건이 없으면 기본값 (최신 수정순) + query.orderBy(portfolio.lastModifiedAt.desc()); return; } - // PathBuilder를 사용하여 문자열 기반의 정렬 속성을 Q-Type 경로로 변환 - PathBuilder entityPath = new PathBuilder<>(PortfolioEntity.class, "portfolioEntity"); - for (Sort.Order order : sort) { Order direction = order.isAscending() ? Order.ASC : Order.DESC; String property = order.getProperty(); @@ -98,23 +94,16 @@ private void applySorting(JPAQuery query, Sort sort) { // 정렬 가능한 속성을 화이트리스트 방식으로 제한 switch (property) { - case "createdAt": - orderSpecifier = new OrderSpecifier<>(direction, portfolio.createdAt); - break; - case "likeCount": - orderSpecifier = new OrderSpecifier<>(direction, portfolio.likeCount); - break; - case "viewCount": - orderSpecifier = new OrderSpecifier<>(direction, portfolio.viewCount); + case "popularityScore": // 인기순 + orderSpecifier = new OrderSpecifier<>(direction, portfolio.popularityScore); break; - // '수정일순'을 이전에 추가했다면 여기 포함 - case "lastModifiedAt": + case "lastModifiedAt": // 최신순 (수정일 기준) orderSpecifier = new OrderSpecifier<>(direction, portfolio.lastModifiedAt); break; default: - // 허용되지 않은 정렬 속성이면 경고 로그만 남기고 무시 (기본값 최신순 적용) - log.warn("Warning: Invalid sort property provided: {}. Defaulting to createdAt.", property); - orderSpecifier = new OrderSpecifier<>(Order.DESC, portfolio.createdAt); + // 허용되지 않은 정렬 속성이면 경고 로그만 남기고 무시 (기본값 최신 수정순 적용) + log.warn("Warning: Invalid sort property provided: {}. Defaulting to lastModifiedAt.", property); + orderSpecifier = new OrderSpecifier<>(Order.DESC, portfolio.lastModifiedAt); } query.orderBy(orderSpecifier); } diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/scheduler/PortfolioBatchScheduler.java b/portfolio-service/src/main/java/com/example/portfolioservice/scheduler/PortfolioBatchScheduler.java new file mode 100644 index 0000000..94e0447 --- /dev/null +++ b/portfolio-service/src/main/java/com/example/portfolioservice/scheduler/PortfolioBatchScheduler.java @@ -0,0 +1,111 @@ +package com.example.portfolioservice.scheduler; + +import com.example.portfolioservice.repository.PortfolioRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PortfolioBatchScheduler { + + private final PortfolioRepository portfolioRepository; + private final StringRedisTemplate redisTemplate; + + // View Count Keys + private static final String VIEW_BACKUP_KEY = "portfolio:views"; + private static final String VIEW_SYNC_KEY = "portfolio:views:sync"; + + // Like Count Keys + private static final String LIKE_BATCH_KEY = "portfolio:likes:delta"; + private static final String LIKE_SYNC_KEY = "portfolio:likes:sync"; + + @Scheduled(fixedRate = 600000) // 10분마다 수행 + public void syncCountsAndCalculateScore() { + log.info("Batch Scheduler Started"); + + // 1. 조회수 동기화 + syncViewCounts(); + + // 2. 좋아요 수 동기화 + syncLikeCounts(); + + // 3. 인기 점수 재계산 + updatePopularityScoresInDb(); + + log.info("Batch Scheduler Finished"); + } + + // 조회수 동기화 로직 + private void syncViewCounts() { + if (Boolean.TRUE.equals(redisTemplate.hasKey(VIEW_BACKUP_KEY))) { + redisTemplate.rename(VIEW_BACKUP_KEY, VIEW_SYNC_KEY); + Map viewCounts = redisTemplate.opsForHash().entries(VIEW_SYNC_KEY); + + if (!viewCounts.isEmpty()) { + for (Map.Entry entry : viewCounts.entrySet()) { + try { + Long portfolioId = Long.parseLong((String) entry.getKey()); + Long count = Long.parseLong((String) entry.getValue()); + updateViewCountInDb(portfolioId, count); + } catch (Exception e) { + log.error("조회수 배치 처리 실패 ID: {}", entry.getKey()); + } + } + } + redisTemplate.delete(VIEW_SYNC_KEY); + log.info("Synced {} portfolios view counts.", viewCounts.size()); + } + } + + // 좋아요 수 동기화 로직 + private void syncLikeCounts() { + if (Boolean.TRUE.equals(redisTemplate.hasKey(LIKE_BATCH_KEY))) { + // Atomic Rename: 들어오는 요청 유실 방지 + redisTemplate.rename(LIKE_BATCH_KEY, LIKE_SYNC_KEY); + + // 데이터 읽기 + Map likeDeltas = redisTemplate.opsForHash().entries(LIKE_SYNC_KEY); + + if (!likeDeltas.isEmpty()) { + for (Map.Entry entry : likeDeltas.entrySet()) { + try { + Long portfolioId = Long.parseLong((String) entry.getKey()); + Long delta = Long.parseLong((String) entry.getValue()); + + // 변화량이 0이 아닐 때만 DB 업데이트 (부하 절약) + if (delta != 0) { + updateLikeCountInDb(portfolioId, delta); + } + } catch (Exception e) { + log.error("좋아요 배치 처리 실패 ID: {}", entry.getKey(), e); + } + } + } + // 처리 완료된 임시 키 삭제 + redisTemplate.delete(LIKE_SYNC_KEY); + log.info("Synced {} portfolios like counts.", likeDeltas.size()); + } + } + + @Transactional + public void updateViewCountInDb(Long portfolioId, Long count) { + portfolioRepository.incrementViewCount(portfolioId, count); + } + + @Transactional + public void updateLikeCountInDb(Long portfolioId, Long delta) { + portfolioRepository.updateLikeCount(portfolioId, delta); + } + + @Transactional + public void updatePopularityScoresInDb() { + portfolioRepository.updateAllPopularityScores(); + } +} \ No newline at end of file diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/service/PortfolioLikeService.java b/portfolio-service/src/main/java/com/example/portfolioservice/service/PortfolioLikeService.java index cc144a9..1d5b971 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/service/PortfolioLikeService.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/service/PortfolioLikeService.java @@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,59 +24,54 @@ public class PortfolioLikeService { private final PortfolioRepository portfolioRepository; private final PortfolioLikeRepository portfolioLikeRepository; - private final PortfolioMapper portfolioMapper; + private final StringRedisTemplate redisTemplate; + + // 실시간 조회용 (화면에 보여지는 값) + private static final String STATS_KEY_PREFIX = "portfolio:stats:"; + // 배치 동기화용 (DB에 반영할 증감분) + private static final String LIKE_BATCH_KEY = "portfolio:likes:delta"; /** - * 포트폴리오 관심 추가 + * 포트폴리오 북마크 추가 */ public void addLike(Long authUserId, Long portfolioId) { - PortfolioEntity portfolio = portfolioRepository.findById(portfolioId) - .orElseThrow(() -> new BusinessException(ErrorCode.PORTFOLIO_NOT_FOUND)); - - // 발행되지 않은 포트폴리오는 숨김 처리 - if (!portfolio.isPublished()) { - throw new BusinessException(ErrorCode.PORTFOLIO_NOT_FOUND); - } + // 1. 검증 (Proxy 조회로 쿼리 절약) + PortfolioEntity portfolio = portfolioRepository.getReferenceById(portfolioId); - // 중복 좋아요 방지 + // 중복 체크 (DB 조회는 필요하지만, Parent Row Lock은 걸리지 않음) if (portfolioLikeRepository.existsByLikerIdAndPortfolio(authUserId, portfolio)) { - log.warn("이미 관심 추가된 포트폴리오입니다. UserId: {}, PortfolioId: {}", authUserId, portfolioId); return; } - // 1. PortfolioLike 엔티티 생성 및 저장 - PortfolioLikeEntity portfolioLike = PortfolioLikeEntity.of(authUserId, portfolio); - portfolioLikeRepository.save(portfolioLike); + // 2. DB 반영 (자식 테이블 Insert만 수행 -> Parent Lock 없음) + portfolioLikeRepository.save(PortfolioLikeEntity.of(authUserId, portfolio)); - // 2. Portfolio 엔티티의 likeCount만 증가 - portfolio.increaseLikeCount(); - - log.info("관심 추가 완료. UserId: {}, PortfolioId: {}", authUserId, portfolioId); + // 3. Redis 반영 + // A. 실시간 조회용 값 증가 (+1) + redisTemplate.opsForHash().increment(STATS_KEY_PREFIX + portfolioId, "likeCount", 1L); + // B. 배치 동기화용 Delta 값 증가 (+1) + redisTemplate.opsForHash().increment(LIKE_BATCH_KEY, String.valueOf(portfolioId), 1L); } /** - * 포트폴리오 관심 취소 + * 포트폴리오 북마크 취소 (DB + Redis 동시 업데이트) */ public void removeLike(Long authUserId, Long portfolioId) { - PortfolioEntity portfolio = portfolioRepository.findById(portfolioId) - .orElseThrow(() -> new BusinessException(ErrorCode.PORTFOLIO_NOT_FOUND)); + PortfolioEntity portfolio = portfolioRepository.getReferenceById(portfolioId); - // 삭제할 PortfolioLike 엔티티 조회 PortfolioLikeEntity portfolioLike = portfolioLikeRepository.findByLikerIdAndPortfolio(authUserId, portfolio) .orElse(null); - if (portfolioLike == null) { - log.warn("관심 추가되지 않은 포트폴리오입니다. UserId: {}, PortfolioId: {}", authUserId, portfolioId); - return; // 멱등성 - } + if (portfolioLike == null) return; - // 1. PortfolioLike 엔티티 제거 + // 2. DB 반영 (자식 테이블 Delete만 수행) portfolioLikeRepository.delete(portfolioLike); - // 2. portfolio 엔터티의 likeCount 감소 - portfolio.decreaseLikeCount(); - - log.info("관심 취소 완료. UserId: {}, PortfolioId: {}", authUserId, portfolioId); + // 3. Redis 반영 + // A. 실시간 조회용 값 감소 (-1) + redisTemplate.opsForHash().increment(STATS_KEY_PREFIX + portfolioId, "likeCount", -1L); + // B. 배치 동기화용 Delta 값 감소 (-1) + redisTemplate.opsForHash().increment(LIKE_BATCH_KEY, String.valueOf(portfolioId), -1L); } /** diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/service/PortfolioService.java b/portfolio-service/src/main/java/com/example/portfolioservice/service/PortfolioService.java index a8cb0d6..9aee99c 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/service/PortfolioService.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/service/PortfolioService.java @@ -10,14 +10,21 @@ import com.example.portfolioservice.repository.PortfolioLikeRepository; import com.example.portfolioservice.repository.PortfolioRepository; import com.example.portfolioservice.util.PortfolioMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import java.util.Arrays; import java.util.List; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @@ -28,6 +35,14 @@ public class PortfolioService { private final PortfolioRepository portfolioRepository; private final PortfolioMapper portfolioMapper; private final PortfolioLikeRepository portfolioLikeRepository; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + // Redis 캐싱 - 포트폴리오 상세 조회 Key 규칙 + // ㄴ 1) "portfolio:details:{id}" (정적) + // ㄴ 2) "portfolio:stats:{id}" (동적) + private static final String STATIC_KEY_PREFIX = "portfolio:details:"; + private static final String STATS_KEY_PREFIX = "portfolio:stats:"; /** * 내 포트폴리오 조회 (마이페이지) @@ -41,7 +56,7 @@ public PortfolioDetailsResponse getMyPortfolio(Long authUserId) { }); // 2. DTO로 변환하여 반환 - return portfolioMapper.toPortfolioResponse(portfolio); + return portfolioMapper.toPortfolioResponse(portfolio, false); } /** @@ -49,27 +64,17 @@ public PortfolioDetailsResponse getMyPortfolio(Long authUserId) { */ @Transactional public PortfolioDetailsResponse createOrUpdateMyPortfolio(Long authUserId, PortfolioRequest request) { - PortfolioEntity portfolio = portfolioRepository.findByUserId(authUserId) - .orElseThrow(() -> { - log.warn("PortfolioEntity가 존재하지 않음 (createOrUpdate). Kafka 이벤트 처리 지연 또는 실패. UserId: {}", authUserId); - return new BusinessException(ErrorCode.PORTFOLIO_NOT_FOUND); - }); + .orElseThrow(() -> new BusinessException(ErrorCode.PORTFOLIO_NOT_FOUND)); - // 2. 사용자 입력 정보(Request)로 엔티티 갱신 - // updateUserInput 내부에서 isPublished가 'true'로 변경됨 - portfolio.updateUserInput( - request.getPhotoUrl(), - request.getOneLiner(), - request.getContent(), - request.getPosition(), - request.getHashtags() - ); - - // 3. DB 저장 (Update) (2차 저장) + portfolio.updateUserInput(request.getPhotoUrl(), request.getOneLiner(), request.getContent(), request.getPosition(), request.getHashtags()); PortfolioEntity updatedPortfolio = portfolioRepository.save(portfolio); - return portfolioMapper.toPortfolioResponse(updatedPortfolio); + // 내용이 수정되었으므로 정적 캐시 삭제 (Eviction) + // ㄴ 다음 조회 시 DB에서 새 내용을 가져와 캐싱함. (조회수/좋아요는 statsKey에 있으므로 유지됨) + redisTemplate.delete(STATIC_KEY_PREFIX + updatedPortfolio.getPortfolioId()); + + return portfolioMapper.toPortfolioResponse(updatedPortfolio, false); } /** @@ -80,46 +85,98 @@ public Slice getPortfolioList(Pageable pageable, String p } /** - * 포트폴리오 상세 조회 (상세보기 - 인증 불필요) + * 포트폴리오 상세 조회 (Split Strategy: 정적+동적 병합) */ @Transactional public PortfolioDetailsResponse getPortfolioDetails(Long portfolioId, AuthUser authUser) { - // Feign 호출 없이, 캐시된 DB 데이터만으로 응답 + + String statsKey = STATS_KEY_PREFIX + portfolioId; + + // 1. 조회수(동적 데이터) 증가 (DB 부하 X) + // ㄴ A. [Display용] 사용자에게 보여줄 실시간 값 (통계용 Hash: portfolio:stats:{id}) + redisTemplate.opsForHash().increment(statsKey, "viewCount", 1L); + // ㄴ B. [Batch용] DB에 나중에 반영할 증가분 (배치용 Hash: portfolio:views) + redisTemplate.opsForHash().increment("portfolio:views", String.valueOf(portfolioId), 1L); + + // 2. 정적 데이터 조회 (제목, 내용 등) + PortfolioDetailsResponse response = getStaticPortfolioData(portfolioId); + + // 3. 동적 데이터 조회 및 병합 (조회수, 북마크 수) + mergeDynamicStats(portfolioId, response); + + // 4. 개인 데이터(좋아요) 여부 (이건 캐싱 불가능해서 DB 조회) + boolean isLiked = false; + if (authUser != null) { + // 성능 최적화: ID만으로 조회 (Entity 조회 X) + PortfolioEntity proxy = portfolioRepository.getReferenceById(portfolioId); + isLiked = portfolioLikeRepository.existsByLikerIdAndPortfolio(authUser.getUserId(), proxy); + } + + response.setLiked(isLiked); + + return response; + } + + //============================// + //== Internal Helper Method ==// + //============================// + + // 정적 데이터(title, content, ...)를 Redis에서 조회 + private PortfolioDetailsResponse getStaticPortfolioData(Long portfolioId) { + String cacheKey = STATIC_KEY_PREFIX + portfolioId; + String cachedJson = redisTemplate.opsForValue().get(cacheKey); + + // Cache Hit -> Redis 조회 + if (StringUtils.hasText(cachedJson)) { + try { + return objectMapper.readValue(cachedJson, PortfolioDetailsResponse.class); + } catch (JsonProcessingException e) { + log.error("JSON 파싱 에러. DB에서 다시 조회합니다.", e); + } + } + + // Cache Miss -> DB 조회 PortfolioEntity portfolio = portfolioRepository.findById(portfolioId) .orElseThrow(() -> new BusinessException(ErrorCode.PORTFOLIO_NOT_FOUND)); - // 발행(isPublished=true)되지 않은 포트폴리오는 찾을 수 없는 것으로 처리 if (!portfolio.isPublished()) { - log.warn("아직 발행되지 않은 포트폴리오 접근 시도. UserId: {}", portfolioId); throw new BusinessException(ErrorCode.PORTFOLIO_NOT_FOUND); } - portfolio.increaseViewCount(); + PortfolioDetailsResponse response = portfolioMapper.toPortfolioResponse(portfolio, false); - boolean isLiked = false; - if (authUser != null) { - // 로그인한 상태라면, Like 테이블을 조회하여 관심 여부 확인 - isLiked = portfolioLikeRepository.existsByLikerIdAndPortfolio(authUser.getUserId(), portfolio); + // Redis 저장 (TTL 1시간) + try { + redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(response), 1, TimeUnit.HOURS); + } catch (JsonProcessingException e) { + log.error("Redis 저장 실패", e); } - List hashtags = portfolioMapper.stringToHashtagList(portfolio.getHashtags()); - return PortfolioDetailsResponse.builder() - .userId(portfolio.getUserId()) - .name(portfolio.getName()) - .email(portfolio.getEmail()) - .birthdate(portfolio.getBirthdate()) - .gender(portfolio.getGender()) - .photoUrl(portfolio.getPhotoUrl()) - .oneLiner(portfolio.getOneLiner()) - .content(portfolio.getContent()) - .position(portfolio.getPosition()) - .hashtags(hashtags) - .isPublished(portfolio.isPublished()) - .isLiked(isLiked) - .viewCount(portfolio.getViewCount()) - .likeCount(portfolio.getLikeCount()) - .createdAt(portfolio.getCreatedAt()) - .lastModifiedAt(portfolio.getLastModifiedAt()) - .build(); + return response; + } + + private void mergeDynamicStats(Long portfolioId, PortfolioDetailsResponse response) { + String statsKey = STATS_KEY_PREFIX + portfolioId; + List stats = redisTemplate.opsForHash().multiGet(statsKey, Arrays.asList("viewCount", "likeCount")); + + Object viewCountObj = stats.get(0); + Object likeCountObj = stats.get(1); + + // Redis에 통계 데이터가 없는 경우 (Cache Miss or First Access) + // ㄴ DB에 있는 최신 값을 가져와서 Redis를 초기화해줘야 함 (동기화) + if (viewCountObj == null || likeCountObj == null) { + // 정적 데이터 조회 시 사용했던 Entity 정보(response)에 있는 값을 기준으로 초기화 + // ㄴ Redis가 비어있다면 DB 기준으로 Redis에 재설정 + redisTemplate.opsForHash().putIfAbsent(statsKey, "viewCount", String.valueOf(response.getViewCount())); + redisTemplate.opsForHash().putIfAbsent(statsKey, "likeCount", String.valueOf(response.getLikeCount())); + // 다시 읽거나, 현재 값 사용 + if (viewCountObj != null) response.setViewCount(Long.parseLong(viewCountObj.toString())); + if (likeCountObj != null) response.setLikeCount(Long.parseLong(likeCountObj.toString())); + } else { + // Redis 값 적용 + response.setViewCount(Long.parseLong(viewCountObj.toString())); + response.setLikeCount(Long.parseLong(likeCountObj.toString())); + } } + } \ No newline at end of file diff --git a/portfolio-service/src/main/java/com/example/portfolioservice/util/PortfolioMapper.java b/portfolio-service/src/main/java/com/example/portfolioservice/util/PortfolioMapper.java index 55d079c..dedf0a3 100644 --- a/portfolio-service/src/main/java/com/example/portfolioservice/util/PortfolioMapper.java +++ b/portfolio-service/src/main/java/com/example/portfolioservice/util/PortfolioMapper.java @@ -3,6 +3,7 @@ import com.example.portfolioservice.dto.response.PortfolioCardResponse; import com.example.portfolioservice.dto.response.PortfolioDetailsResponse; import com.example.portfolioservice.entity.PortfolioEntity; +import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; @@ -14,15 +15,15 @@ @Mapper(componentModel = "spring") public interface PortfolioMapper { - // Entity -> 상세 응답 DTO - @Mapping(source = "hashtags", target = "hashtags", qualifiedByName = "stringToHashtagList") - PortfolioDetailsResponse toPortfolioResponse(PortfolioEntity entity); + /** + * Entity -> 상세 응답 DTO 변환 + */ + @Mapping(source = "entity.hashtags", target = "hashtags", qualifiedByName = "stringToHashtagList") + @Mapping(target = "isLiked", source = "isLiked") // 파라미터로 받은 isLiked 매핑 + @Mapping(target = "isPublished", source = "entity.published") // Lombok getter(isPublished) -> property(published) + PortfolioDetailsResponse toPortfolioResponse(PortfolioEntity entity, boolean isLiked); - // Entity -> 카드 응답 DTO - @Mapping(source = "hashtags", target = "hashtags", qualifiedByName = "stringToHashtagList") - PortfolioCardResponse toPortfolioCardResponse(PortfolioEntity entity); - - // 쉼표로 구분된 문자열을 List으로 변환하는 헬퍼 메서드 (재사용) + // 쉼표로 구분된 문자열을 List으로 변환하는 헬퍼 메서드 @Named("stringToHashtagList") default List stringToHashtagList(String hashtags) { if (hashtags == null || hashtags.isEmpty()) { diff --git a/portfolio-service/src/main/resources/application.yml b/portfolio-service/src/main/resources/application.yml index 36f5455..d7001a5 100644 --- a/portfolio-service/src/main/resources/application.yml +++ b/portfolio-service/src/main/resources/application.yml @@ -41,11 +41,21 @@ spring: schema.registry.url: http://10.0.2.9:8081 specific.avro.reader: false -# --- Feign Client Configuration --- -app: - feign: - user-service-url: http://user-service:80 # k8s ?? ??? ?? - # --- Swagger (OpenAPI) --- springdoc: - override-with-generic-response: false \ No newline at end of file + override-with-generic-response: false + +# --- Monitoring Configuration (Added) --- +management: + endpoints: + web: + exposure: + include: prometheus, health, info # 프로메테우스 지표 노출 허용 + endpoint: + health: + show-details: always + prometheus: + enabled: true + metrics: + tags: + application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가 \ No newline at end of file diff --git a/support-service/pom.xml b/support-service/pom.xml index 47d67bd..137509d 100644 --- a/support-service/pom.xml +++ b/support-service/pom.xml @@ -70,6 +70,16 @@ org.springframework.boot spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + diff --git a/support-service/src/main/java/com/example/supportservice/config/RedisConfig.java b/support-service/src/main/java/com/example/supportservice/config/RedisConfig.java index 5ee10ae..476bb66 100644 --- a/support-service/src/main/java/com/example/supportservice/config/RedisConfig.java +++ b/support-service/src/main/java/com/example/supportservice/config/RedisConfig.java @@ -4,16 +4,22 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; +import java.time.Duration; + @Configuration @EnableCaching public class RedisConfig { @@ -29,6 +35,14 @@ public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port)); } + // ObjectMapper 설정을 재사용하기 위한 메서드 + private ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return objectMapper; + } + @Bean public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); @@ -50,4 +64,17 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec template.afterPropertiesSet(); return template; } + + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper()))) + .entryTtl(Duration.ofHours(1)); // 기본 캐시 유효 시간: 1시간 + + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(connectionFactory) + .cacheDefaults(redisCacheConfiguration) + .build(); + } } \ No newline at end of file diff --git a/support-service/src/main/java/com/example/supportservice/controller/NoticeController.java b/support-service/src/main/java/com/example/supportservice/controller/NoticeController.java index 41612cb..bc61bd6 100644 --- a/support-service/src/main/java/com/example/supportservice/controller/NoticeController.java +++ b/support-service/src/main/java/com/example/supportservice/controller/NoticeController.java @@ -2,6 +2,7 @@ import com.example.commonmodule.exception.ErrorResponse; import com.example.supportservice.dto.request.NoticeRequest; +import com.example.supportservice.dto.response.NoticeListResponse; import com.example.supportservice.dto.response.NoticeResponse; import com.example.supportservice.service.NoticeService; import io.swagger.v3.oas.annotations.Operation; @@ -37,7 +38,7 @@ public class NoticeController { @ApiResponse(responseCode = "500", description = "서버 내부 오류", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @GetMapping("/notices") - public ResponseEntity> getNotices( + public ResponseEntity> getNotices( @PageableDefault(size = 10, sort = {"isImportant", "createdAt"}, direction = Sort.Direction.DESC) Pageable pageable) { return ResponseEntity.ok(noticeService.getNotices(pageable)); } diff --git a/support-service/src/main/java/com/example/supportservice/dto/response/CustomPageResponse.java b/support-service/src/main/java/com/example/supportservice/dto/response/CustomPageResponse.java new file mode 100644 index 0000000..149440a --- /dev/null +++ b/support-service/src/main/java/com/example/supportservice/dto/response/CustomPageResponse.java @@ -0,0 +1,33 @@ +package com.example.supportservice.dto.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class CustomPageResponse { + private List content; + private int pageNumber; + private int pageSize; + private long totalElements; + + // Page 객체를 받아서 이 객체(CustomPageResponse)로 변환하는 생성자 + public CustomPageResponse(Page page) { + this.content = page.getContent(); + this.pageNumber = page.getNumber(); + this.pageSize = page.getSize(); + this.totalElements = page.getTotalElements(); + } + + // 다시 Page 인터페이스 객체로 복구하는 메서드 + public Page toPage() { + return new PageImpl<>(content, PageRequest.of(pageNumber, pageSize), totalElements); + } +} \ No newline at end of file diff --git a/support-service/src/main/java/com/example/supportservice/dto/response/NoticeListResponse.java b/support-service/src/main/java/com/example/supportservice/dto/response/NoticeListResponse.java new file mode 100644 index 0000000..c7d333d --- /dev/null +++ b/support-service/src/main/java/com/example/supportservice/dto/response/NoticeListResponse.java @@ -0,0 +1,15 @@ +package com.example.supportservice.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder +public class NoticeListResponse { + private Long id; + private String title; + private boolean isImportant; + private LocalDate createdAt; +} \ No newline at end of file diff --git a/support-service/src/main/java/com/example/supportservice/entity/NoticeEntity.java b/support-service/src/main/java/com/example/supportservice/entity/NoticeEntity.java index 3b2b53b..cb89c3d 100644 --- a/support-service/src/main/java/com/example/supportservice/entity/NoticeEntity.java +++ b/support-service/src/main/java/com/example/supportservice/entity/NoticeEntity.java @@ -5,7 +5,9 @@ import lombok.*; @Entity -@Table(name = "notice") +@Table(name = "notice", indexes = { + @Index(name = "idx_notice_important_date", columnList = "is_important DESC, created_at DESC") +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class NoticeEntity extends BaseEntity { diff --git a/support-service/src/main/java/com/example/supportservice/service/FaqService.java b/support-service/src/main/java/com/example/supportservice/service/FaqService.java index 51e8e48..36e03e3 100644 --- a/support-service/src/main/java/com/example/supportservice/service/FaqService.java +++ b/support-service/src/main/java/com/example/supportservice/service/FaqService.java @@ -8,6 +8,8 @@ import com.example.supportservice.exception.ErrorCode; import com.example.supportservice.repository.FaqRepository; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,13 +23,15 @@ public class FaqService { private final FaqRepository faqRepository; - // 자주 묻는 질문 전체 목록 조회 + // 자주 묻는 질문 조회 (title과 contents를 모두 FaqResponse에 담아서 반환 = 목록 조회, 상세 조회 구분 없음) + @Cacheable(value = "faqs", key = "'all'") public List getAllFaqs() { return faqRepository.findAll().stream() .map(this::toResponse) .collect(Collectors.toList()); } + @Cacheable(value = "faqs", key = "#category") public List getFaqsByCategory(FaqCategory category) { return faqRepository.findAllByCategory(category).stream() .map(this::toResponse) @@ -36,6 +40,7 @@ public List getFaqsByCategory(FaqCategory category) { // 자주 묻는 질문 생성 @Transactional + @CacheEvict(value = "faqs", allEntries = true) public void createFaq(FaqRequest request) { FaqEntity faq = FaqEntity.builder() .category(request.getCategory()) @@ -47,6 +52,7 @@ public void createFaq(FaqRequest request) { // 자주 묻는 질문 수정 @Transactional + @CacheEvict(value = "faqs", allEntries = true) public void updateFaq(Long id, FaqRequest request) { FaqEntity faq = faqRepository.findById(id) .orElseThrow(() -> new BusinessException(ErrorCode.FAQ_NOT_FOUND)); @@ -55,6 +61,7 @@ public void updateFaq(Long id, FaqRequest request) { // 자주 묻는 질문 삭제 @Transactional + @CacheEvict(value = "faqs", allEntries = true) public void deleteFaq(Long id) { faqRepository.deleteById(id); } diff --git a/support-service/src/main/java/com/example/supportservice/service/NoticeService.java b/support-service/src/main/java/com/example/supportservice/service/NoticeService.java index 376a64c..d597598 100644 --- a/support-service/src/main/java/com/example/supportservice/service/NoticeService.java +++ b/support-service/src/main/java/com/example/supportservice/service/NoticeService.java @@ -2,30 +2,80 @@ import com.example.commonmodule.exception.BusinessException; import com.example.supportservice.dto.request.NoticeRequest; +import com.example.supportservice.dto.response.CustomPageResponse; +import com.example.supportservice.dto.response.NoticeListResponse; import com.example.supportservice.dto.response.NoticeResponse; import com.example.supportservice.entity.NoticeEntity; import com.example.supportservice.exception.ErrorCode; import com.example.supportservice.repository.NoticeRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.concurrent.TimeUnit; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) +@Slf4j public class NoticeService { private final NoticeRepository noticeRepository; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; // [USER/ADMIN] 공지사항 목록 조회 - public Page getNotices(Pageable pageable) { - return noticeRepository.findAll(pageable) - .map(this::toResponse); + public Page getNotices(Pageable pageable) { + String cacheKey = "noticeList::" + pageable.getPageNumber() + "-" + pageable.getPageSize(); + + // 1. 캐시 조회 + String cachedJson = (String) redisTemplate.opsForValue().get(cacheKey); + + if (cachedJson != null) { + try { + // JSON -> CustomPageResponse (Wrapper)로 역직렬화 + CustomPageResponse customPage = objectMapper.readValue( + cachedJson, + new TypeReference>() {} + ); + // Wrapper -> PageImpl (원본)로 복구하여 반환 + return customPage.toPage(); + } catch (JsonProcessingException e) { + log.error("Cache Deserialization Failed", e); + // 에러 나면 DB 조회하도록 흐름을 이어감 + } + } + + // 2. DB 조회 (캐시 없거나 에러 시) + Page page = noticeRepository.findAll(pageable).map(this::toListResponse); + + // 3. 캐시 저장 + try { + // PageImpl -> CustomPageResponse (Wrapper)로 변환 -> JSON 직렬화 + CustomPageResponse wrapper = new CustomPageResponse<>(page); + String jsonToCache = objectMapper.writeValueAsString(wrapper); + + redisTemplate.opsForValue().set(cacheKey, jsonToCache, 1, TimeUnit.HOURS); + } catch (JsonProcessingException e) { + log.error("Cache Serialization Failed", e); + } + + return page; } // [USER/ADMIN] 공지사항 상세 조회 + @Cacheable(value = "noticeDetail", key = "#id") public NoticeResponse getNotice(Long id) { NoticeEntity notice = noticeRepository.findById(id) .orElseThrow(() -> new BusinessException(ErrorCode.NOTICE_NOT_FOUND)); @@ -34,6 +84,7 @@ public NoticeResponse getNotice(Long id) { // [ADMIN] 공지사항 등록 @Transactional + @CacheEvict(value = "noticeList", allEntries = true) // 목록 캐시만 초기화 public void createNotice(NoticeRequest request) { NoticeEntity notice = NoticeEntity.builder() .title(request.getTitle()) @@ -45,6 +96,10 @@ public void createNotice(NoticeRequest request) { // [ADMIN] 공지사항 수정 @Transactional + @Caching(evict = { + @CacheEvict(value = "noticeDetail", key = "#id"), // 해당 상세 캐시 삭제 + @CacheEvict(value = "noticeList", allEntries = true) // 목록 캐시 전체 초기화 (제목 등이 바뀔 수 있으므로) + }) public void updateNotice(Long id, NoticeRequest request) { NoticeEntity notice = noticeRepository.findById(id) .orElseThrow(() -> new BusinessException(ErrorCode.NOTICE_NOT_FOUND)); @@ -54,11 +109,16 @@ public void updateNotice(Long id, NoticeRequest request) { // [ADMIN] 공지사항 삭제 @Transactional + @Caching(evict = { + @CacheEvict(value = "noticeDetail", key = "#id"), + @CacheEvict(value = "noticeList", allEntries = true) + }) public void deleteNotice(Long id) { noticeRepository.deleteById(id); } - // Mapper Method + + // 상세 조회용 매퍼 (Content 포함) private NoticeResponse toResponse(NoticeEntity entity) { return NoticeResponse.builder() .id(entity.getId()) @@ -68,4 +128,14 @@ private NoticeResponse toResponse(NoticeEntity entity) { .createdAt(entity.getCreatedAt().toLocalDate()) .build(); } + + // 목록 조회용 매퍼 (Content 제외) + private NoticeListResponse toListResponse(NoticeEntity entity) { + return NoticeListResponse.builder() + .id(entity.getId()) + .title(entity.getTitle()) + .isImportant(entity.isImportant()) + .createdAt(entity.getCreatedAt().toLocalDate()) + .build(); + } } \ No newline at end of file diff --git a/support-service/src/main/resources/application.yml b/support-service/src/main/resources/application.yml index a83c6a9..0bec7f9 100644 --- a/support-service/src/main/resources/application.yml +++ b/support-service/src/main/resources/application.yml @@ -31,4 +31,19 @@ spring: # Swagger ?? springdoc: - override-with-generic-response: false \ No newline at end of file + override-with-generic-response: false + +# --- Monitoring Configuration (Added) --- +management: + endpoints: + web: + exposure: + include: prometheus, health, info # 프로메테우스 지표 노출 허용 + endpoint: + health: + show-details: always + prometheus: + enabled: true + metrics: + tags: + application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가 \ No newline at end of file diff --git a/tests/results/write/3_community_write/before.html b/tests/k6/results/before/isolated/auth_stress.html similarity index 91% rename from tests/results/write/3_community_write/before.html rename to tests/k6/results/before/isolated/auth_stress.html index 0371e10..1ed88c0 100644 --- a/tests/results/write/3_community_write/before.html +++ b/tests/k6/results/before/isolated/auth_stress.html @@ -10,7 +10,7 @@ - Test Report: 2025-11-27 04:06 + Test Report: 2025-12-02 08:22 - - - - -
-
-

- - Test Report: 2025-11-27 03:45 -

-
- -
- -
-
- -

Total Requests

-
- 660 - -
-
- - -
- -

Failed Requests

-
20
-
- - -
- -

Breached Thresholds

-
0
-
- -
- -

Failed Checks

-
20
-
-
- - -
- - -
- - -

Trends & Times

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AvgMinMedMaxP(90)P(95)
http_req_blocked0.030.000.002.800.000.00
http_req_connecting0.020.000.001.720.000.00
http_req_duration455.82105.00401.891807.41706.59848.65
http_req_receiving27.610.0013.39362.0774.78105.60
http_req_sending0.010.000.001.530.000.00
http_req_tls_handshaking0.000.000.000.000.000.00
http_req_waiting428.20102.17375.501718.76677.78824.78
iteration_duration456.02105.00401.901810.22706.59848.65
- - - -

Rates

- - - - - - - - - - - - - - - - - - - - - - - -
Rate %Pass CountFail Count
http_req_failed3.00%20.00640.00
- - - - - - -
- - - - -
-
- -
-

Checks

- -
- Passed - 640 -
-
- Failed - 20 -
-
- - - -
-

Iterations

- -
- Total - 660 -
-
- Rate - 21.87/s -
-
- - -
-

Virtual Users

- -
- Min - 10 -
-
- Max - 10 -
-
- -
-

Requests

- -
- Total - - 660 - - -
-
- Rate - - 21.87/s - - -
-
- -
-

Data Received

- -
- Total - 0.60 MB -
-
- Rate - 0.02 mB/s -
-
- -
-

Data Sent

- -
- Total - 0.08 MB -
-
- Rate - 0.00 mB/s -
-
-
-
- - - - -
- - - - -

Other Checks

- - - - - - - - - - - - - - - - - - - -
Check NamePassesFailures% Pass
Status is 2006402096.97
-
- -
-
- -
K6 Reporter v3.0.3 - Ben Coleman 2025 · GitHub
-
- - diff --git a/tests/results/read/2_portfolio_detail/before.json b/tests/results/read/2_portfolio_detail/before.json deleted file mode 100644 index bff4d21..0000000 --- a/tests/results/read/2_portfolio_detail/before.json +++ /dev/null @@ -1 +0,0 @@ -{"state":{"testRunDurationMs":30176.211,"isStdOutTTY":true,"isStdErrTTY":true},"metrics":{"vus_max":{"type":"gauge","contains":"default","values":{"value":10,"min":10,"max":10}},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"max":1807.4128,"p(90)":709.5899499999999,"p(95)":852.6997249999993,"avg":458.0497335937504,"min":114.9948,"med":401.88554999999997}},"http_req_blocked":{"contains":"time","values":{"med":0,"max":2.8043,"p(90)":0,"p(95)":0,"avg":0.03176530303030303,"min":0},"type":"trend"},"http_req_sending":{"type":"trend","contains":"time","values":{"avg":0.010538030303030303,"min":0,"med":0,"max":1.5332,"p(90)":0,"p(95)":0}},"http_req_failed":{"type":"rate","contains":"default","values":{"rate":0.030303030303030304,"passes":20,"fails":640}},"iteration_duration":{"type":"trend","contains":"time","values":{"p(90)":706.5860500000001,"p(95)":848.6470249999999,"avg":456.02274530303043,"min":105.0018,"med":401.9003,"max":1810.2171}},"http_req_waiting":{"type":"trend","contains":"time","values":{"p(95)":824.7772699999995,"avg":428.19777075757554,"min":102.1737,"med":375.50115,"max":1718.7622,"p(90)":677.77682}},"http_req_receiving":{"type":"trend","contains":"time","values":{"p(90)":74.78452000000001,"p(95)":105.60457999999997,"avg":27.61423984848486,"min":0,"med":13.389949999999999,"max":362.0732}},"http_req_connecting":{"values":{"med":0,"max":1.7159,"p(90)":0,"p(95)":0,"avg":0.023888333333333334,"min":0},"type":"trend","contains":"time"},"data_sent":{"contains":"data","values":{"count":75833,"rate":2513.0060231882658},"type":"counter"},"data_received":{"type":"counter","contains":"data","values":{"count":600297,"rate":19893.054167734976}},"iterations":{"type":"counter","contains":"default","values":{"count":660,"rate":21.87153317558656}},"vus":{"contains":"default","values":{"value":10,"min":10,"max":10},"type":"gauge"},"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"avg":0,"min":0,"med":0,"max":0,"p(90)":0,"p(95)":0}},"checks":{"type":"rate","contains":"default","values":{"rate":0.9696969696969697,"passes":640,"fails":20}},"http_reqs":{"values":{"count":660,"rate":21.87153317558656},"type":"counter","contains":"default"},"http_req_duration":{"type":"trend","contains":"time","values":{"min":105.0018,"med":401.88554999999997,"max":1807.4128,"p(90)":706.5860500000001,"p(95)":848.6470249999999,"avg":455.82254863636405}}},"root_group":{"groups":[],"checks":[{"passes":640,"fails":20,"name":"Status is 200","path":"::Status is 200","id":"887b5211c579c0c925c267b35b5af7da"}],"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e"},"options":{"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"],"summaryTimeUnit":"","noColor":false}} \ No newline at end of file diff --git a/tests/results/read/3_community_list/before.json b/tests/results/read/3_community_list/before.json deleted file mode 100644 index c80399c..0000000 --- a/tests/results/read/3_community_list/before.json +++ /dev/null @@ -1 +0,0 @@ -{"root_group":{"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"name":"Status is 200","path":"::Status is 200","id":"887b5211c579c0c925c267b35b5af7da","passes":188,"fails":2}]},"options":{"summaryTimeUnit":"","noColor":false,"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"]},"state":{"isStdErrTTY":true,"testRunDurationMs":31012.8813,"isStdOutTTY":true},"metrics":{"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"avg":0,"min":0,"med":0,"max":0,"p(90)":0,"p(95)":0}},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"max":2974.0152,"p(90)":2266.5663400000003,"p(95)":2413.98381,"avg":1634.7024015957447,"min":734.9947,"med":1564.1769}},"http_req_blocked":{"type":"trend","contains":"time","values":{"med":0,"max":3.2516,"p(90)":0,"p(95)":0,"avg":0.08882210526315788,"min":0}},"iterations":{"type":"counter","contains":"default","values":{"count":190,"rate":6.1264865448022725}},"vus":{"type":"gauge","contains":"default","values":{"value":2,"min":2,"max":10}},"iteration_duration":{"type":"trend","contains":"time","values":{"p(95)":2411.8684699999994,"avg":1617.7659373684214,"min":6.423,"med":1560.27425,"max":2974.0152,"p(90)":2260.95038}},"http_req_waiting":{"type":"trend","contains":"time","values":{"med":1516.89475,"max":2644.6755,"p(90)":2169.12551,"p(95)":2339.2308449999996,"avg":1550.449202631578,"min":0}},"http_req_failed":{"contains":"default","values":{"rate":0.010526315789473684,"passes":2,"fails":188},"type":"rate"},"http_req_duration":{"type":"trend","contains":"time","values":{"med":1559.71625,"max":2974.0152,"p(90)":2260.95038,"p(95)":2411.8684699999994,"avg":1617.4950078947368,"min":0}},"vus_max":{"type":"gauge","contains":"default","values":{"min":10,"max":10,"value":10}},"checks":{"type":"rate","contains":"default","values":{"fails":2,"rate":0.9894736842105263,"passes":188}},"data_received":{"type":"counter","contains":"data","values":{"count":1518773,"rate":48972.32815320516}},"http_req_receiving":{"type":"trend","contains":"time","values":{"p(90)":187.84880999999996,"p(95)":247.92342,"avg":67.02093473684212,"min":0,"med":37.90605,"max":456.8492}},"http_reqs":{"type":"counter","contains":"default","values":{"count":190,"rate":6.1264865448022725}},"http_req_connecting":{"contains":"time","values":{"max":2.7266,"p(90)":0,"p(95)":0,"avg":0.05741842105263158,"min":0,"med":0},"type":"trend"},"http_req_sending":{"type":"trend","contains":"time","values":{"p(90)":0,"p(95)":0,"avg":0.02487052631578947,"min":0,"med":0,"max":1.0484}},"data_sent":{"type":"counter","contains":"data","values":{"count":26320,"rate":848.679609785241}}}} \ No newline at end of file diff --git a/tests/results/read/4_community_detail/before.html b/tests/results/read/4_community_detail/before.html deleted file mode 100644 index a242806..0000000 --- a/tests/results/read/4_community_detail/before.html +++ /dev/null @@ -1,855 +0,0 @@ - - - - - - - - - - - - - Test Report: 2025-11-27 03:51 - - - - - -
-
-

- - Test Report: 2025-11-27 03:51 -

-
- -
- -
-
- -

Total Requests

-
- 328 - -
-
- - -
- -

Failed Requests

-
2
-
- - -
- -

Breached Thresholds

-
0
-
- -
- -

Failed Checks

-
4
-
-
- - -
- - -
- - -

Trends & Times

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AvgMinMedMaxP(90)P(95)
http_req_blocked0.080.000.006.730.000.00
http_req_connecting0.070.000.006.130.000.00
http_req_duration921.360.00831.662940.821519.851811.38
http_req_receiving44.880.0021.261244.90110.90158.39
http_req_sending0.010.000.001.560.000.00
http_req_tls_handshaking0.000.000.000.000.000.00
http_req_waiting876.470.00794.642226.581451.341695.30
iteration_duration921.649.35832.012942.201519.851811.38
- - - -

Rates

- - - - - - - - - - - - - - - - - - - - - - - -
Rate %Pass CountFail Count
http_req_failed1.00%2.00326.00
- - - - - - -
- - - - -
-
- -
-

Checks

- -
- Passed - 652 -
-
- Failed - 4 -
-
- - - -
-

Iterations

- -
- Total - 328 -
-
- Rate - 10.77/s -
-
- - -
-

Virtual Users

- -
- Min - 10 -
-
- Max - 10 -
-
- -
-

Requests

- -
- Total - - 328 - - -
-
- Rate - - 10.77/s - - -
-
- -
-

Data Received

- -
- Total - 0.38 MB -
-
- Rate - 0.01 mB/s -
-
- -
-

Data Sent

- -
- Total - 0.04 MB -
-
- Rate - 0.00 mB/s -
-
-
-
- - - - -
- - - - -

Other Checks

- - - - - - - - - - - - - - - - - - - - - - - - - - -
Check NamePassesFailures% Pass
Status is 200326299.39
Content Check326299.39
-
- -
-
- -
K6 Reporter v3.0.3 - Ben Coleman 2025 · GitHub
-
- - diff --git a/tests/results/read/4_community_detail/before.json b/tests/results/read/4_community_detail/before.json deleted file mode 100644 index 49ac3c2..0000000 --- a/tests/results/read/4_community_detail/before.json +++ /dev/null @@ -1 +0,0 @@ -{"root_group":{"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"name":"Status is 200","path":"::Status is 200","id":"887b5211c579c0c925c267b35b5af7da","passes":326,"fails":2},{"name":"Content Check","path":"::Content Check","id":"6900a57c50c532e29f1e14983cb06345","passes":326,"fails":2}]},"options":{"noColor":false,"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"],"summaryTimeUnit":""},"state":{"isStdOutTTY":true,"isStdErrTTY":true,"testRunDurationMs":30446.4428},"metrics":{"vus_max":{"values":{"value":10,"min":10,"max":10},"type":"gauge","contains":"default"},"http_reqs":{"type":"counter","contains":"default","values":{"count":328,"rate":10.773015493291059}},"http_req_duration":{"type":"trend","contains":"time","values":{"avg":921.3644298780488,"min":0,"med":831.659,"max":2940.8198,"p(90)":1519.8549699999999,"p(95)":1811.3847799999987}},"iteration_duration":{"type":"trend","contains":"time","values":{"avg":921.6443689024394,"min":9.346,"med":832.0092999999999,"max":2942.2048,"p(90)":1519.8549699999999,"p(95)":1811.3847799999987}},"http_req_waiting":{"type":"trend","contains":"time","values":{"p(90)":1451.3354300000003,"p(95)":1695.304635,"avg":876.4694710365852,"min":0,"med":794.6367,"max":2226.584}},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"p(90)":1520.14755,"p(95)":1816.5093,"avg":927.016972392638,"min":168.4215,"med":835.0980500000001,"max":2940.8198}},"data_received":{"type":"counter","contains":"data","values":{"count":377756,"rate":12407.229392328223}},"checks":{"type":"rate","contains":"default","values":{"rate":0.9939024390243902,"passes":652,"fails":4}},"vus":{"values":{"value":10,"min":10,"max":10},"type":"gauge","contains":"default"},"http_req_receiving":{"type":"trend","contains":"time","values":{"max":1244.8992,"p(90)":110.90284000000001,"p(95)":158.38505499999997,"avg":44.8811329268293,"min":0,"med":21.2584}},"http_req_connecting":{"type":"trend","contains":"time","values":{"p(90)":0,"p(95)":0,"avg":0.06831676829268292,"min":0,"med":0,"max":6.13}},"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"min":0,"med":0,"max":0,"p(90)":0,"p(95)":0,"avg":0}},"http_req_blocked":{"type":"trend","contains":"time","values":{"p(90)":0,"p(95)":0,"avg":0.08275640243902438,"min":0,"med":0,"max":6.7259}},"data_sent":{"type":"counter","contains":"data","values":{"rate":1166.0147043516033,"count":35501}},"http_req_sending":{"contains":"time","values":{"p(90)":0,"p(95)":0,"avg":0.013825914634146343,"min":0,"med":0,"max":1.5621},"type":"trend"},"http_req_failed":{"type":"rate","contains":"default","values":{"rate":0.006097560975609756,"passes":2,"fails":326}},"iterations":{"type":"counter","contains":"default","values":{"rate":10.773015493291059,"count":328}}}} \ No newline at end of file diff --git a/tests/results/read/5_user_read/before.json b/tests/results/read/5_user_read/before.json deleted file mode 100644 index e3259da..0000000 --- a/tests/results/read/5_user_read/before.json +++ /dev/null @@ -1 +0,0 @@ -{"root_group":{"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"id":"887b5211c579c0c925c267b35b5af7da","passes":665,"fails":1,"name":"Status is 200","path":"::Status is 200"},{"name":"Has Email","path":"::Has Email","id":"da3e42f3dc6008a0b16f71b5055673aa","passes":665,"fails":1}]},"options":{"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"],"summaryTimeUnit":"","noColor":false},"state":{"isStdOutTTY":true,"isStdErrTTY":true,"testRunDurationMs":34383.3281},"metrics":{"http_req_receiving":{"type":"trend","contains":"time","values":{"p(90)":119.60125999999994,"p(95)":164.28053,"avg":46.39241199400296,"min":0,"med":24.6031,"max":918.1029}},"http_req_waiting":{"type":"trend","contains":"time","values":{"avg":410.57814617691133,"min":0,"med":277.0942,"max":7045.2023,"p(90)":617.71258,"p(95)":888.6816699999997}},"vus_max":{"contains":"default","values":{"value":10,"min":10,"max":10},"type":"gauge"},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"avg":457.6757150150147,"min":72.3405,"med":326.4121,"max":7079.1937,"p(90)":716.5243499999999,"p(95)":965.6986750000001}},"data_sent":{"type":"counter","contains":"data","values":{"count":238279,"rate":6930.073764441669}},"http_reqs":{"type":"counter","contains":"default","values":{"count":667,"rate":19.39893654448186}},"http_req_sending":{"contains":"time","values":{"max":1.537,"p(90)":0,"p(95)":0,"avg":0.018986356821589206,"min":0,"med":0},"type":"trend"},"http_req_blocked":{"contains":"time","values":{"p(90)":0,"p(95)":0,"avg":0.013735382308845577,"min":0,"med":0,"max":1.5559},"type":"trend"},"http_req_duration":{"type":"trend","contains":"time","values":{"med":325.5767,"max":7079.1937,"p(90)":716.2955,"p(95)":965.5377099999998,"avg":456.9895445277358,"min":0}},"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"avg":0,"min":0,"med":0,"max":0,"p(90)":0,"p(95)":0}},"data_received":{"type":"counter","contains":"data","values":{"count":346113,"rate":10066.303034812969}},"http_req_connecting":{"contains":"time","values":{"max":0.9889,"p(90)":0,"p(95)":0,"avg":0.006814692653673163,"min":0,"med":0},"type":"trend"},"iterations":{"contains":"default","values":{"count":666,"rate":19.36985268159658},"type":"counter"},"checks":{"type":"rate","contains":"default","values":{"rate":0.9984984984984985,"passes":1330,"fails":2}},"vus":{"values":{"max":10,"value":10,"min":0},"type":"gauge","contains":"default"},"iteration_duration":{"type":"trend","contains":"time","values":{"avg":451.5684135135134,"min":6.6247,"med":324.16089999999997,"max":7079.7444,"p(90)":710.0818999999999,"p(95)":961.486975}},"http_req_failed":{"type":"rate","contains":"default","values":{"fails":666,"rate":0.0014992503748125937,"passes":1}}},"setup_data":{"headers":{"Content-Type":"application/json","Authorization":"Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1IiwiZW1haWwiOiJ1c2VyXzFAdGVzdC5jb20iLCJyb2xlIjoiVVNFUiIsImV4cCI6MTc2NDMwMTkzOX0.Q9RAZeabW1iQrA4Ts8QxMS3OqPOOcYFKUeYB0-3uQ85aICgFLtcl9UE0h8iI0qF7_GmC2ks9_kK5LF7EulEMyA"}}} \ No newline at end of file diff --git a/tests/results/read/6_support_list/before.html b/tests/results/read/6_support_list/before.html deleted file mode 100644 index 3a2b899..0000000 --- a/tests/results/read/6_support_list/before.html +++ /dev/null @@ -1,848 +0,0 @@ - - - - - - - - - - - - - Test Report: 2025-11-27 03:56 - - - - - -
-
-

- - Test Report: 2025-11-27 03:56 -

-
- -
- -
-
- -

Total Requests

-
- 316 - -
-
- - -
- -

Failed Requests

-
0
-
- - -
- -

Breached Thresholds

-
0
-
- -
- -

Failed Checks

-
0
-
-
- - -
- - -
- - -

Trends & Times

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AvgMinMedMaxP(90)P(95)
http_req_blocked0.140.000.0011.310.000.00
http_req_connecting0.090.000.009.660.000.00
http_req_duration958.50194.16863.834147.771484.551851.10
http_req_receiving31.880.0010.41833.6865.44102.79
http_req_sending0.020.000.002.490.000.00
http_req_tls_handshaking0.000.000.000.000.000.00
http_req_waiting926.60173.32836.374130.101419.431832.48
iteration_duration958.76194.16863.904147.771484.551851.10
- - - -

Rates

- - - - - - - - - - - - - - - - - - - - - - - -
Rate %Pass CountFail Count
http_req_failed0.00%0.00316.00
- - - - - - -
- - - - -
-
- -
-

Checks

- -
- Passed - 316 -
-
- Failed - 0 -
-
- - - -
-

Iterations

- -
- Total - 316 -
-
- Rate - 10.40/s -
-
- - -
-

Virtual Users

- -
- Min - 10 -
-
- Max - 10 -
-
- -
-

Requests

- -
- Total - - 316 - - -
-
- Rate - - 10.40/s - - -
-
- -
-

Data Received

- -
- Total - 0.78 MB -
-
- Rate - 0.03 mB/s -
-
- -
-

Data Sent

- -
- Total - 0.03 MB -
-
- Rate - 0.00 mB/s -
-
-
-
- - - - -
- - - - -

Other Checks

- - - - - - - - - - - - - - - - - - - -
Check NamePassesFailures% Pass
Status is 2003160100.00
-
- -
-
- -
K6 Reporter v3.0.3 - Ben Coleman 2025 · GitHub
-
- - diff --git a/tests/results/read/6_support_list/before.json b/tests/results/read/6_support_list/before.json deleted file mode 100644 index 2f8c340..0000000 --- a/tests/results/read/6_support_list/before.json +++ /dev/null @@ -1 +0,0 @@ -{"root_group":{"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"name":"Status is 200","path":"::Status is 200","id":"887b5211c579c0c925c267b35b5af7da","passes":316,"fails":0}]},"options":{"summaryTimeUnit":"","noColor":false,"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"]},"state":{"isStdOutTTY":true,"isStdErrTTY":true,"testRunDurationMs":30390.4519},"metrics":{"vus_max":{"type":"gauge","contains":"default","values":{"value":10,"min":10,"max":10}},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"avg":958.5010363924049,"min":194.1594,"med":863.8252,"max":4147.7687,"p(90)":1484.5453499999999,"p(95)":1851.0997499999999}},"http_reqs":{"type":"counter","contains":"default","values":{"rate":10.39800267004256,"count":316}},"http_req_duration":{"type":"trend","contains":"time","values":{"p(95)":1851.0997499999999,"avg":958.5010363924049,"min":194.1594,"med":863.8252,"max":4147.7687,"p(90)":1484.5453499999999}},"iterations":{"type":"counter","contains":"default","values":{"count":316,"rate":10.39800267004256}},"data_sent":{"values":{"count":33180,"rate":1091.790280354469},"type":"counter","contains":"data"},"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"p(95)":0,"avg":0,"min":0,"med":0,"max":0,"p(90)":0}},"http_req_connecting":{"type":"trend","contains":"time","values":{"avg":0.08537879746835442,"min":0,"med":0,"max":9.661,"p(90)":0,"p(95)":0}},"data_received":{"contains":"data","values":{"count":775913,"rate":25531.47292949599},"type":"counter"},"iteration_duration":{"type":"trend","contains":"time","values":{"max":4147.7687,"p(90)":1484.5453499999999,"p(95)":1851.0997499999999,"avg":958.7572844936706,"min":194.1594,"med":863.9021}},"http_req_failed":{"type":"rate","contains":"default","values":{"rate":0,"passes":0,"fails":316}},"http_req_sending":{"values":{"max":2.4933,"p(90)":0,"p(95)":0,"avg":0.02021645569620253,"min":0,"med":0},"type":"trend","contains":"time"},"http_req_receiving":{"type":"trend","contains":"time","values":{"avg":31.881006329113927,"min":0,"med":10.4091,"max":833.6828,"p(90)":65.44325,"p(95)":102.787725}},"vus":{"type":"gauge","contains":"default","values":{"value":10,"min":10,"max":10}},"checks":{"contains":"default","values":{"rate":1,"passes":316,"fails":0},"type":"rate"},"http_req_waiting":{"type":"trend","contains":"time","values":{"med":836.3714500000001,"max":4130.0992,"p(90)":1419.42855,"p(95)":1832.47705,"avg":926.5998136075958,"min":173.3168}},"http_req_blocked":{"type":"trend","contains":"time","values":{"p(90)":0,"p(95)":0,"avg":0.14278987341772154,"min":0,"med":0,"max":11.3107}}}} \ No newline at end of file diff --git a/tests/results/write/1_auth_login/before.html b/tests/results/write/1_auth_login/before.html deleted file mode 100644 index 53fc9a0..0000000 --- a/tests/results/write/1_auth_login/before.html +++ /dev/null @@ -1,855 +0,0 @@ - - - - - - - - - - - - - Test Report: 2025-11-27 04:03 - - - - - -
-
-

- - Test Report: 2025-11-27 04:03 -

-
- -
- -
-
- -

Total Requests

-
- 193 - -
-
- - -
- -

Failed Requests

-
0
-
- - -
- -

Breached Thresholds

-
0
-
- -
- -

Failed Checks

-
0
-
-
- - -
- - -
- - -

Trends & Times

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AvgMinMedMaxP(90)P(95)
http_req_blocked0.060.000.001.680.000.00
http_req_connecting0.030.000.001.100.000.00
http_req_duration1583.45249.401152.207820.662787.273018.50
http_req_receiving3.020.000.7147.368.5415.43
http_req_sending0.010.000.001.010.000.00
http_req_tls_handshaking0.000.000.000.000.000.00
http_req_waiting1580.42249.401149.187819.692786.723017.93
iteration_duration1583.93249.401152.207822.342787.273018.50
- - - -

Rates

- - - - - - - - - - - - - - - - - - - - - - - -
Rate %Pass CountFail Count
http_req_failed0.00%0.00193.00
- - - - - - -
- - - - -
-
- -
-

Checks

- -
- Passed - 386 -
-
- Failed - 0 -
-
- - - -
-

Iterations

- -
- Total - 193 -
-
- Rate - 6.26/s -
-
- - -
-

Virtual Users

- -
- Min - 10 -
-
- Max - 10 -
-
- -
-

Requests

- -
- Total - - 193 - - -
-
- Rate - - 6.26/s - - -
-
- -
-

Data Received

- -
- Total - 0.17 MB -
-
- Rate - 0.01 mB/s -
-
- -
-

Data Sent

- -
- Total - 0.04 MB -
-
- Rate - 0.00 mB/s -
-
-
-
- - - - -
- - - - -

Other Checks

- - - - - - - - - - - - - - - - - - - - - - - - - - -
Check NamePassesFailures% Pass
Login Success1930100.00
Token Exists1930100.00
-
- -
-
- -
K6 Reporter v3.0.3 - Ben Coleman 2025 · GitHub
-
- - diff --git a/tests/results/write/1_auth_login/before.json b/tests/results/write/1_auth_login/before.json deleted file mode 100644 index e19ff38..0000000 --- a/tests/results/write/1_auth_login/before.json +++ /dev/null @@ -1 +0,0 @@ -{"metrics":{"http_req_sending":{"type":"trend","contains":"time","values":{"med":0,"max":1.0092,"p(90)":0,"p(95)":0,"avg":0.011835751295336788,"min":0}},"http_reqs":{"type":"counter","contains":"default","values":{"rate":6.257869899617574,"count":193}},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"p(90)":2787.267580000001,"p(95)":3018.50094,"avg":1583.4502383419692,"min":249.4003,"med":1152.2029,"max":7820.6644}},"http_req_failed":{"type":"rate","contains":"default","values":{"passes":0,"fails":193,"rate":0}},"data_sent":{"type":"counter","contains":"data","values":{"count":40510,"rate":1313.504194992269}},"data_received":{"type":"counter","contains":"data","values":{"count":173043,"rate":5610.78021264002}},"http_req_waiting":{"values":{"min":249.4003,"med":1149.183,"max":7819.6891,"p(90)":2786.7228800000007,"p(95)":3017.9296799999997,"avg":1580.4198227979282},"type":"trend","contains":"time"},"http_req_duration":{"type":"trend","contains":"time","values":{"p(90)":2787.267580000001,"p(95)":3018.50094,"avg":1583.4502383419692,"min":249.4003,"med":1152.2029,"max":7820.6644}},"http_req_blocked":{"type":"trend","contains":"time","values":{"min":0,"med":0,"max":1.6801,"p(90)":0,"p(95)":0,"avg":0.06067305699481865}},"http_req_receiving":{"type":"trend","contains":"time","values":{"p(95)":15.431719999999896,"avg":3.018579792746115,"min":0,"med":0.7127,"max":47.3604,"p(90)":8.53886}},"iterations":{"type":"counter","contains":"default","values":{"count":193,"rate":6.257869899617574}},"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"avg":0,"min":0,"med":0,"max":0,"p(90)":0,"p(95)":0}},"checks":{"type":"rate","contains":"default","values":{"rate":1,"passes":386,"fails":0}},"vus":{"type":"gauge","contains":"default","values":{"value":10,"min":10,"max":10}},"vus_max":{"values":{"value":10,"min":10,"max":10},"type":"gauge","contains":"default"},"http_req_connecting":{"type":"trend","contains":"time","values":{"med":0,"max":1.0997,"p(90)":0,"p(95)":0,"avg":0.02577409326424871,"min":0}},"iteration_duration":{"values":{"avg":1583.9289616580302,"min":249.4003,"med":1152.2029,"max":7822.3445,"p(90)":2787.267580000001,"p(95)":3018.50094},"type":"trend","contains":"time"}},"root_group":{"path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"name":"Login Success","path":"::Login Success","id":"bd9be8e236ec9bcbbe033a432ee4230e","passes":193,"fails":0},{"id":"f02a9aac5a31a6de1e21cdbe2a0659e0","passes":193,"fails":0,"name":"Token Exists","path":"::Token Exists"}],"name":""},"options":{"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"],"summaryTimeUnit":"","noColor":false},"state":{"isStdErrTTY":true,"testRunDurationMs":30841.1653,"isStdOutTTY":true}} \ No newline at end of file diff --git a/tests/results/write/2_portfolio_like/before.json b/tests/results/write/2_portfolio_like/before.json deleted file mode 100644 index d9ecc9d..0000000 --- a/tests/results/write/2_portfolio_like/before.json +++ /dev/null @@ -1 +0,0 @@ -{"root_group":{"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"id":"de29ffed9ef6f67de74c67ff3b387220","passes":231,"fails":13,"name":"Like success (201) or Conflict (409)","path":"::Like success (201) or Conflict (409)"},{"id":"e873ca814e9e231e059d4c393f1351ac","passes":231,"fails":0,"name":"Unlike success (204)","path":"::Unlike success (204)"}]},"options":{"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"],"summaryTimeUnit":"","noColor":false},"state":{"testRunDurationMs":31640.4313,"isStdOutTTY":true,"isStdErrTTY":true},"metrics":{"vus":{"type":"gauge","contains":"default","values":{"value":9,"min":9,"max":10}},"http_reqs":{"values":{"count":476,"rate":15.044042715056163},"type":"counter","contains":"default"},"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"avg":0,"min":0,"med":0,"max":0,"p(90)":0,"p(95)":0}},"http_req_failed":{"type":"rate","contains":"default","values":{"rate":0.0273109243697479,"passes":13,"fails":463}},"iterations":{"type":"counter","contains":"default","values":{"count":244,"rate":7.711652148053999}},"http_req_sending":{"type":"trend","contains":"time","values":{"min":0,"med":0,"max":1.5089,"p(90)":0,"p(95)":0,"avg":0.013590126050420167}},"http_req_duration":{"type":"trend","contains":"time","values":{"med":625.0423499999999,"max":1672.9665,"p(90)":1033.1483,"p(95)":1205.4931499999998,"avg":653.7776363445372,"min":0}},"http_req_connecting":{"type":"trend","contains":"time","values":{"med":0,"max":0.8153,"p(90)":0,"p(95)":0,"avg":0.01421827731092437,"min":0}},"data_received":{"type":"counter","contains":"data","values":{"count":170635,"rate":5392.941656898337}},"http_req_blocked":{"type":"trend","contains":"time","values":{"p(95)":0,"avg":0.022765336134453782,"min":0,"med":0,"max":1.8027,"p(90)":0}},"http_req_waiting":{"type":"trend","contains":"time","values":{"p(90)":1032.67245,"p(95)":1205.133375,"avg":652.8225401260503,"min":0,"med":624.2879,"max":1672.9665}},"http_req_receiving":{"values":{"avg":0.9415060924369745,"min":0,"med":0.31045,"max":64.5738,"p(90)":0.9352,"p(95)":1.00895},"type":"trend","contains":"time"},"checks":{"contains":"default","values":{"rate":0.9726315789473684,"passes":462,"fails":13},"type":"rate"},"data_sent":{"type":"counter","contains":"data","values":{"count":182695,"rate":5774.099545855433}},"iteration_duration":{"type":"trend","contains":"time","values":{"med":1320.19005,"max":2580.9861,"p(90)":1777.29909,"p(95)":1916.69314,"avg":1274.5657930327864,"min":3.9297}},"vus_max":{"contains":"default","values":{"value":10,"min":10,"max":10},"type":"gauge"},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"min":185.3548,"med":628.2857,"max":1672.9665,"p(90)":1047.6454800000001,"p(95)":1209.9936199999997,"avg":662.8456669546432}}},"setup_data":{"headers":{"Authorization":"Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1IiwiZW1haWwiOiJ1c2VyXzFAdGVzdC5jb20iLCJyb2xlIjoiVVNFUiIsImV4cCI6MTc2NDMwMjk5MX0.Qase76QekVxfadiZpU-KR4pxs1H4r1RoNUMQ1cpJdHQQGtuyuQslelcz0GBy_xnVpTKLAO9mL40-qqjKSskyZg","Content-Type":"application/json"}}} \ No newline at end of file diff --git a/tests/results/write/3_community_write/before.json b/tests/results/write/3_community_write/before.json deleted file mode 100644 index ff12fdb..0000000 --- a/tests/results/write/3_community_write/before.json +++ /dev/null @@ -1 +0,0 @@ -{"root_group":{"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"passes":240,"fails":0,"name":"Comment Created","path":"::Comment Created","id":"725412d814d8ceda1957427259ad2be8"}]},"options":{"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"],"summaryTimeUnit":"","noColor":false},"state":{"isStdOutTTY":true,"isStdErrTTY":true,"testRunDurationMs":20707.4603},"metrics":{"http_req_failed":{"type":"rate","contains":"default","values":{"rate":0,"passes":0,"fails":241}},"http_req_connecting":{"type":"trend","contains":"time","values":{"min":0,"med":0,"max":4.1564,"p(90)":0,"p(95)":0,"avg":0.06590165975103734}},"iterations":{"type":"counter","contains":"default","values":{"count":240,"rate":11.59002584203916}},"data_sent":{"type":"counter","contains":"data","values":{"count":103169,"rate":4982.214067072242}},"http_req_sending":{"type":"trend","contains":"time","values":{"p(95)":0,"avg":0.04567427385892115,"min":0,"med":0,"max":1.6921,"p(90)":0}},"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"p(90)":0,"p(95)":0,"avg":0,"min":0,"med":0,"max":0}},"vus":{"type":"gauge","contains":"default","values":{"value":10,"min":10,"max":10}},"iteration_duration":{"values":{"avg":842.3199275000005,"min":167.8171,"med":692.96055,"max":3260.5702,"p(90)":1341.9332299999996,"p(95)":1854.5681649999976},"type":"trend","contains":"time"},"http_req_receiving":{"type":"trend","contains":"time","values":{"avg":0.3762639004149377,"min":0,"med":0.3386,"max":1.5633,"p(90)":0.9441,"p(95)":0.9911}},"checks":{"type":"rate","contains":"default","values":{"rate":1,"passes":240,"fails":0}},"http_req_blocked":{"contains":"time","values":{"min":0,"med":0,"max":4.6638,"p(90)":0,"p(95)":0,"avg":0.08120954356846471},"type":"trend"},"vus_max":{"values":{"value":10,"min":10,"max":10},"type":"gauge","contains":"default"},"http_req_waiting":{"type":"trend","contains":"time","values":{"p(90)":1335.9082,"p(95)":1846.3113,"avg":839.286505394191,"min":167.3045,"med":691.0865,"max":3258.7946}},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"p(95)":1847.2921,"avg":839.7084435684648,"min":167.3045,"med":691.9331,"max":3258.7946,"p(90)":1337.4162}},"data_received":{"type":"counter","contains":"data","values":{"count":86813,"rate":4192.353805937273}},"http_reqs":{"type":"counter","contains":"default","values":{"count":241,"rate":11.63831761638099}},"http_req_duration":{"type":"trend","contains":"time","values":{"min":167.3045,"med":691.9331,"max":3258.7946,"p(90)":1337.4162,"p(95)":1847.2921,"avg":839.7084435684648}}},"setup_data":{"headers":{"Authorization":"Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1IiwiZW1haWwiOiJ1c2VyXzFAdGVzdC5jb20iLCJyb2xlIjoiVVNFUiIsImV4cCI6MTc2NDMwMjc1OH0.FKW9xfOdzKSJYXDGqhw1fSQVTyX8OR8Jhz3-s_hz0tvRnqKEuLw_uetfhPbSdTKyCcHjGrQp6by728Jg0EfaYg","Content-Type":"application/json"}}} \ No newline at end of file diff --git a/tests/results/write/4_community_comment/before.html b/tests/results/write/4_community_comment/before.html deleted file mode 100644 index b779d66..0000000 --- a/tests/results/write/4_community_comment/before.html +++ /dev/null @@ -1,848 +0,0 @@ - - - - - - - - - - - - - Test Report: 2025-11-27 04:16 - - - - - -
-
-

- - Test Report: 2025-11-27 04:16 -

-
- -
- -
-
- -

Total Requests

-
- 333 - -
-
- - -
- -

Failed Requests

-
0
-
- - -
- -

Breached Thresholds

-
0
-
- -
- -

Failed Checks

-
0
-
-
- - -
- - -
- - -

Trends & Times

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AvgMinMedMaxP(90)P(95)
http_req_blocked0.040.000.003.430.000.00
http_req_connecting0.030.000.002.740.000.00
http_req_duration612.03116.49562.071553.24951.471090.69
http_req_receiving0.300.000.001.630.830.95
http_req_sending0.020.000.001.510.000.00
http_req_tls_handshaking0.000.000.000.000.000.00
http_req_waiting611.72116.49562.071552.68950.981090.07
iteration_duration613.05116.49563.701553.24952.071090.72
- - - -

Rates

- - - - - - - - - - - - - - - - - - - - - - - -
Rate %Pass CountFail Count
http_req_failed0.00%0.00333.00
- - - - - - -
- - - - -
-
- -
-

Checks

- -
- Passed - 332 -
-
- Failed - 0 -
-
- - - -
-

Iterations

- -
- Total - 332 -
-
- Rate - 15.91/s -
-
- - -
-

Virtual Users

- -
- Min - 10 -
-
- Max - 10 -
-
- -
-

Requests

- -
- Total - - 333 - - -
-
- Rate - - 15.96/s - - -
-
- -
-

Data Received

- -
- Total - 0.12 MB -
-
- Rate - 0.01 mB/s -
-
- -
-

Data Sent

- -
- Total - 0.14 MB -
-
- Rate - 0.01 mB/s -
-
-
-
- - - - -
- - - - -

Other Checks

- - - - - - - - - - - - - - - - - - - -
Check NamePassesFailures% Pass
Comment Created3320100.00
-
- -
-
- -
K6 Reporter v3.0.3 - Ben Coleman 2025 · GitHub
-
- - diff --git a/tests/results/write/4_community_comment/before.json b/tests/results/write/4_community_comment/before.json deleted file mode 100644 index 4fffe53..0000000 --- a/tests/results/write/4_community_comment/before.json +++ /dev/null @@ -1 +0,0 @@ -{"root_group":{"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"name":"Comment Created","path":"::Comment Created","id":"725412d814d8ceda1957427259ad2be8","passes":332,"fails":0}]},"options":{"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"],"summaryTimeUnit":"","noColor":false},"state":{"isStdOutTTY":true,"isStdErrTTY":true,"testRunDurationMs":20870.7456},"metrics":{"http_req_sending":{"type":"trend","contains":"time","values":{"p(95)":0,"avg":0.015749549549549546,"min":0,"med":0,"max":1.5077,"p(90)":0}},"http_req_receiving":{"type":"trend","contains":"time","values":{"p(95)":0.9531999999999998,"avg":0.2999060060060061,"min":0,"med":0,"max":1.6316,"p(90)":0.8303800000000002}},"checks":{"type":"rate","contains":"default","values":{"rate":1,"passes":332,"fails":0}},"http_req_tls_handshaking":{"type":"trend","contains":"time","values":{"p(95)":0,"avg":0,"min":0,"med":0,"max":0,"p(90)":0}},"http_req_failed":{"contains":"default","values":{"rate":0,"passes":0,"fails":333},"type":"rate"},"http_req_duration":{"type":"trend","contains":"time","values":{"avg":612.0308471471474,"min":116.4931,"med":562.0689,"max":1553.2447,"p(90)":951.4713,"p(95)":1090.6928}},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"avg":612.0308471471474,"min":116.4931,"med":562.0689,"max":1553.2447,"p(90)":951.4713,"p(95)":1090.6928}},"data_received":{"type":"counter","contains":"data","values":{"count":119749,"rate":5737.64839527343}},"http_req_blocked":{"contains":"time","values":{"p(90)":0,"p(95)":0,"avg":0.03768798798798799,"min":0,"med":0,"max":3.4347},"type":"trend"},"http_req_waiting":{"type":"trend","contains":"time","values":{"avg":611.7151915915915,"min":116.4931,"med":562.0652,"max":1552.6839,"p(90)":950.9813200000001,"p(95)":1090.06966}},"iteration_duration":{"contains":"time","values":{"avg":613.0495780120483,"min":116.4931,"med":563.70055,"max":1553.2447,"p(90)":952.0669500000002,"p(95)":1090.7162500000002},"type":"trend"},"vus":{"type":"gauge","contains":"default","values":{"value":10,"min":10,"max":10}},"http_reqs":{"values":{"count":333,"rate":15.955347565541693},"type":"counter","contains":"default"},"data_sent":{"contains":"data","values":{"count":141309,"rate":6770.673300718112},"type":"counter"},"iterations":{"contains":"default","values":{"count":332,"rate":15.907433608888415},"type":"counter"},"http_req_connecting":{"type":"trend","contains":"time","values":{"min":0,"med":0,"max":2.7434,"p(90)":0,"p(95)":0,"avg":0.025303903903903902}},"vus_max":{"type":"gauge","contains":"default","values":{"max":10,"value":10,"min":10}}},"setup_data":{"headers":{"Authorization":"Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1IiwiZW1haWwiOiJ1c2VyXzFAdGVzdC5jb20iLCJyb2xlIjoiVVNFUiIsImV4cCI6MTc2NDMwMzM5OX0.UCP_LNXh4EdpTM1WXHWJnxCbiy8J_q_MazB5a8KmT-j80xb8YXiEkl5tmv36G-NEkNDA7Bg7ZPltKPowVarOdQ","Content-Type":"application/json"}}} \ No newline at end of file diff --git a/tests/results/write/5_user_update/before.html b/tests/results/write/5_user_update/before.html deleted file mode 100644 index 1267bc4..0000000 --- a/tests/results/write/5_user_update/before.html +++ /dev/null @@ -1,848 +0,0 @@ - - - - - - - - - - - - - Test Report: 2025-11-27 04:18 - - - - - -
-
-

- - Test Report: 2025-11-27 04:18 -

-
- -
- -
-
- -

Total Requests

-
- 549 - -
-
- - -
- -

Failed Requests

-
0
-
- - -
- -

Breached Thresholds

-
0
-
- -
- -

Failed Checks

-
0
-
-
- - -
- - -
- - -

Trends & Times

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AvgMinMedMaxP(90)P(95)
http_req_blocked0.020.000.002.680.000.00
http_req_connecting0.010.000.001.490.000.00
http_req_duration548.3421.52258.252637.231572.941875.07
http_req_receiving57.870.0014.51766.34176.39261.49
http_req_sending0.010.000.001.530.000.00
http_req_tls_handshaking0.000.000.000.000.000.00
http_req_waiting490.4617.27236.922370.491359.721687.04
iteration_duration549.0421.92257.482637.231573.161875.52
- - - -

Rates

- - - - - - - - - - - - - - - - - - - - - - - -
Rate %Pass CountFail Count
http_req_failed0.00%0.00549.00
- - - - - - -
- - - - -
-
- -
-

Checks

- -
- Passed - 548 -
-
- Failed - 0 -
-
- - - -
-

Iterations

- -
- Total - 548 -
-
- Rate - 17.97/s -
-
- - -
-

Virtual Users

- -
- Min - 10 -
-
- Max - 10 -
-
- -
-

Requests

- -
- Total - - 549 - - -
-
- Rate - - 18.00/s - - -
-
- -
-

Data Received

- -
- Total - 0.24 MB -
-
- Rate - 0.01 mB/s -
-
- -
-

Data Sent

- -
- Total - 0.25 MB -
-
- Rate - 0.01 mB/s -
-
-
-
- - - - -
- - - - -

Other Checks

- - - - - - - - - - - - - - - - - - - -
Check NamePassesFailures% Pass
Update Success5480100.00
-
- -
-
- -
K6 Reporter v3.0.3 - Ben Coleman 2025 · GitHub
-
- - diff --git a/tests/results/write/5_user_update/before.json b/tests/results/write/5_user_update/before.json deleted file mode 100644 index 55e1cd5..0000000 --- a/tests/results/write/5_user_update/before.json +++ /dev/null @@ -1 +0,0 @@ -{"root_group":{"name":"","path":"","id":"d41d8cd98f00b204e9800998ecf8427e","groups":[],"checks":[{"fails":0,"name":"Update Success","path":"::Update Success","id":"a361c0ffcecb786c7693f187621eca11","passes":548}]},"options":{"summaryTrendStats":["avg","min","med","max","p(90)","p(95)"],"summaryTimeUnit":"","noColor":false},"state":{"isStdOutTTY":true,"isStdErrTTY":true,"testRunDurationMs":30491.6677},"metrics":{"vus_max":{"type":"gauge","contains":"default","values":{"value":10,"min":10,"max":10}},"http_req_duration{expected_response:true}":{"type":"trend","contains":"time","values":{"avg":548.3409579234965,"min":21.5194,"med":258.2512,"max":2637.2323,"p(90)":1572.9442399999998,"p(95)":1875.0669}},"iterations":{"type":"counter","contains":"default","values":{"count":548,"rate":17.972122921961397}},"http_req_receiving":{"type":"trend","contains":"time","values":{"avg":57.86995446265939,"min":0,"med":14.5142,"max":766.3409,"p(90)":176.38903999999994,"p(95)":261.48648000000003}},"http_req_blocked":{"type":"trend","contains":"time","values":{"p(95)":0,"avg":0.022662295081967217,"min":0,"med":0,"max":2.6795,"p(90)":0}},"data_sent":{"values":{"count":245165,"rate":8040.393277669099},"type":"counter","contains":"data"},"data_received":{"type":"counter","contains":"data","values":{"count":244275,"rate":8011.204975843286}},"vus":{"contains":"default","values":{"value":10,"min":10,"max":10},"type":"gauge"},"iteration_duration":{"type":"trend","contains":"time","values":{"avg":549.0436925182479,"min":21.9159,"med":257.47839999999997,"max":2637.2323,"p(90)":1573.16306,"p(95)":1875.51786}},"http_req_connecting":{"values":{"min":0,"med":0,"max":1.4913,"p(90)":0,"p(95)":0,"avg":0.01075264116575592},"type":"trend","contains":"time"},"http_req_failed":{"type":"rate","contains":"default","values":{"rate":0,"passes":0,"fails":549}},"checks":{"type":"rate","contains":"default","values":{"rate":1,"passes":548,"fails":0}},"http_req_sending":{"type":"trend","contains":"time","values":{"min":0,"med":0,"max":1.527,"p(90)":0,"p(95)":0,"avg":0.014445901639344264}},"http_reqs":{"type":"counter","contains":"default","values":{"rate":18.004918766709505,"count":549}},"http_req_duration":{"values":{"avg":548.3409579234965,"min":21.5194,"med":258.2512,"max":2637.2323,"p(90)":1572.9442399999998,"p(95)":1875.0669},"type":"trend","contains":"time"},"http_req_tls_handshaking":{"contains":"time","values":{"med":0,"max":0,"p(90)":0,"p(95)":0,"avg":0,"min":0},"type":"trend"},"http_req_waiting":{"contains":"time","values":{"p(90)":1359.7155199999997,"p(95)":1687.04004,"avg":490.45655755919796,"min":17.274,"med":236.9163,"max":2370.4932},"type":"trend"}},"setup_data":{"headers":{"Authorization":"Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1IiwiZW1haWwiOiJ1c2VyXzFAdGVzdC5jb20iLCJyb2xlIjoiVVNFUiIsImV4cCI6MTc2NDMwMzQ1NX0.K3CnBLcnUHriovs_A55M2XtQ-S0UIm-c19OmNJnBpqbFJIwJ1mzmA91Tg-pUwOLIffSlsQDKAJ1jY6JZS97OKw","Content-Type":"application/json"}}} \ No newline at end of file diff --git a/user-service/pom.xml b/user-service/pom.xml index 1beb788..90f7812 100644 --- a/user-service/pom.xml +++ b/user-service/pom.xml @@ -83,10 +83,6 @@ spring-boot-starter-security - - org.springframework.cloud - spring-cloud-starter-openfeign - org.springframework.cloud spring-cloud-starter-kubernetes-client-loadbalancer @@ -96,11 +92,6 @@ spring-cloud-starter-circuitbreaker-resilience4j - - - - - jakarta.annotation jakarta.annotation-api diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml index 6723260..9bbf23c 100644 --- a/user-service/src/main/resources/application.yml +++ b/user-service/src/main/resources/application.yml @@ -57,4 +57,19 @@ app: springdoc: - override-with-generic-response: false \ No newline at end of file + override-with-generic-response: false + +# --- Monitoring Configuration (Added) --- +management: + endpoints: + web: + exposure: + include: prometheus, health, info # 프로메테우스 지표 노출 허용 + endpoint: + health: + show-details: always + prometheus: + enabled: true + metrics: + tags: + application: ${spring.application.name} # Grafana에서 서비스별로 구분하기 위해 태그 추가 \ No newline at end of file