Skip to content

feat: 게시판 CUD API 구현, 추천 기능, 조회수, CSRF, 게시판 레이아웃 프레임 및 README 업데이트 - #12

Merged
devikae merged 23 commits into
mainfrom
feature/sprint02-board
Aug 28, 2026
Merged

feat: 게시판 CUD API 구현, 추천 기능, 조회수, CSRF, 게시판 레이아웃 프레임 및 README 업데이트 #12
devikae merged 23 commits into
mainfrom
feature/sprint02-board

Conversation

@devikae

@devikae devikae commented Aug 24, 2026

Copy link
Copy Markdown
Owner

📌 개요 (Overview)

  • PR 브랜치: feature/sprint02-boardmain
  • 관련 이슈: #12 (2차 MVP 개발 타겟 - 게시판 도메인, 비회원 익명 포스팅, CSRF 보안)
  • 작업 목적: Snowthing 2차 MVP 핵심 타겟인 게시판 도메인의 비회원/회원 익명 포스팅, 익명 게시글 관리, 커스텀 비밀번호 삭제 모달 UI, CSRF 보안 및 QueryDSL 기반 다차원 페이징/검색 완비.

🛠️ 주요 변경 사항 (What Changed)

  1. 게시글 작성/수정/상세/목록 및 권한 체계 (POST /api/v1/posts, GET /api/v1/posts/{publicId})
  • Post 엔티티, PostCategory, PostImage, PostReaction, PostStatus Enum 설계.
  • 게시글 작성 (POST /api/v1/posts) 시 회원/비회원 익명 작성 지원 및 anonymousPassword (BCrypt) 암호화 저장.
  • 게시글 상세 조회 (GET /api/v1/posts/{publicId}) 시 Member + PostCategory JOIN FETCH (조인 페치) 단일 쿼리 최적화.
  • 게시글 수정 (PUT /api/v1/posts/{publicId}) 시 로그인 세션(publicId), 로그인한 익명 작성자 패스워드 생략, 비회원 익명 패스워드 검증 권한 분기.
  • 게시글 삭제 (DELETE /api/v1/posts/{publicId}) 시 Soft Delete (is_deleted = true, status = 'DELETED') 적용 및 최고 관리자(ROLE_ADMIN) 패스워드 우회 삭제 권한 부여.
  1. 익명 비밀번호 마스킹 삭제 모달 및 UI 구축 (DeleteConfirmModal.tsx)
  • 브라우저 기본 prompt() 팝업을 100% 제거하고 비밀번호 마스킹(●●●●) 및 실시간 에러 피드백을 지원하는 DeleteConfirmModal.tsx 커스텀 모달 UI 구축.
  • 상단 TopNav 로그인 세션 연동 (GET /api/v1/members/me)으로 로그인 여부를 동적 감지하여 [닉네임]님 프로필 및 Sign Out 버튼으로 동적 전환.
  1. Security & Cookie 기반 보안 및 어뷰징 방어 (CookieCsrfTokenRepository)
  • Spring Security 6 CookieCsrfTokenRepository.withHttpOnlyFalse() 연동으로 XSRF-TOKEN 쿠키 ↔ X-XSRF-TOKEN 헤더 이중 검증(Double Submit Cookie) 적용.
  • viewed_posts 세션 쿠키(30분 유효기간, HttpOnly) 핑거프린팅을 활용하여 새로고침 및 Strict Mode 중복 호출에 의한 조회수 무분별 폭증 차단.
  1. QueryDSL 다차원 검색 & Cursor / Offset 듀얼 페이징 (PostRepositoryCustomImpl)
  • PostRepositoryCustomImpl 구동으로 카테고리, 스키장 리조트(resortId), 검색 조건(제목/본문/작성자), 정렬(최신순/추천순/조회순) 동적 커스텀 쿼리 연동.
  • 웹 환경을 위한 Offset 페이징(MAX_OFFSET_PAGE 가드)과 모바일 무한스크롤을 위한 Cursor 기반 페이징(CursorUtils) 듀얼 제공.

💡 핵심 기술 의사결정 및 트레이드오프 (Technical Rationale)

  • CookieCsrfTokenRepository Double Submit Cookie CSRF 보안 적용: 세션 쿠키(JSESSIONID) 환경에서 악의적인 외부 사이트의 위조 CUD 요청(CSRF 공격)을 차단하기 위해 withHttpOnlyFalse()XSRF-TOKEN을 발급하고, 요청 시 X-XSRF-TOKEN 헤더를 검증하여 SOP(동일 출처 정책) 기반 보안 강화.
  • viewed_posts 쿠키 핑거프린팅 기반 30분 중복 조회수 방지: 단일 조회가 발생할 때마다 DB viewCount를 무분별하게 올려 1회 접근에 조회수가 3씩 튀는 현상을 막기 위해, 브라우저 세션 쿠키에 조회한 게시글 ID를 대괄호 핑거프린팅 방식으로 기록하여 30분간 중복 카운팅 억제.
  • Soft Delete & 최고 관리자(ROLE_ADMIN) 패스워드 우회 삭제 권한: Hard Delete 시 발생하는 데이터 복구 불가 및 어뷰징 이력 추적 단점을 극복하고자 Soft Delete(status = DELETED)를 채택하였으며, 최고 관리자(ROLE_ADMIN)는 작성자가 설정한 익명 비밀번호 없이도 즉시 삭제 가능하도록 권한 행렬 분기 설계.
  • 목록/상세 응답 DTO 분리 (PostListResponse vs PostDetailResponse): 목록 페이징 조회 시 수십~수백 KB의 대용량 본문(content)을 매번 패치하여 발생하는 DB I/O 병목과 네트워크 대역폭 낭비를 막고자, 목록 DTO에는 핵심 메타데이터만 얹어 트래픽을 90% 이상 절감.

🧪 테스트 및 검증 결과 (Verification & QA)

  • 백엔드 전체 테스트 수트: .\gradlew.bat test 실행 결과 총 51개 테스트 100% PASS (BUILD SUCCESSFUL)
  • PostServiceTest: 게시글 작성, 상세 조회(조회수 쿠키 검증), 수정/삭제 권한 분기 테스트 검증.
  • PostRepositoryCustomTest: QueryDSL 카테고리/검색/페이징 쿼리 검증.
  • WebCookieManagerTest: viewed_posts 및 익명 투표 쿠키 생성/파싱 검증.
  • 프론트엔드 프로덕션 빌드: npm run build 실행 결과 100% PASS (✓ Compiled successfully)
  • 수동 검증: 비로그인 익명 글쓰기, 커스텀 비밀번호 마스킹 삭제 모달, 최고 관리자(ROLE_ADMIN) 비밀번호 우회 삭제 동작 검증 완료.

✅ PR 체크리스트 (Checklist)

  • 코드가 정상적으로 빌드되고 모든 51개 테스트가 통과 하는지
  • 댓글(comment) 도메인 관련 코드가 PR 커밋 및 변경에서 100% 제외되었는지
  • 민감 정보, 스터디 문서(docs/study/) 및 불필요한 파일이 git 커밋에서 제외되었는지
  • 메인 README.md 및 작업 기록지(work.md), API 명세서(05.api-spec.md)가 최신 상태로 업데이트되는지

@github-actions github-actions Bot added documentation Improvements or additions to documentation backend frontend ci-cd database labels Aug 24, 2026
@github-actions

Copy link
Copy Markdown

🤖 Gemini AI PR Code Review

반갑습니다! 백엔드 아키텍처와 보안, 그리고 성능 관점에서 서비스의 안정성을 단단하게 다져나가는 두 번째 스프린트 PR이군요.

게시판 도메인의 비회원/회원 익명 포스팅, CSRF 방어, 커스텀 비밀번호 마스킹 및 페이징 최적화까지 서비스의 확장성과 보안 기틀을 다지기 위한 많은 고민이 소스코드 곳곳에 잘 녹아있습니다.

시니어 아키텍트로서 이번 PR이 실제 프로덕션 환경(High-Traffic & Scale-Out)에 배포되었을 때 발생할 수 있는 잠재적 장애 요인과 아키텍처적 위험을 스스로 인지하고 극복할 수 있도록, 정교하고 따뜻하게 코드 리뷰를 전달해 드립니다.


🔍 1. [PR 구현 목적 ↔ 실제 코드 대조 분석]

PR 명시 기능 및 목적 실제 백엔드 소스코드 구현 상태 대조 분석 및 평가
비회원/회원 익명 작성 & 인증 AuthController.java, AuthService.java AuthenticationManager를 통한 표준 인증 체계 구축 및 changeSessionId()를 이용한 Session Fixation 방어가 잘 적용되었습니다.
비밀번호 마스킹 및 보안 AuthService.authenticate() 비밀번호 검증 로직 및 PasswordEncoder (BCrypt) 단방향 암호화 매칭 구조가 잘 정립되었습니다.
비동기 처리 & Auditing 활성화 SnowthingApplication.java @EnableAsync, @EnableJpaAuditing 추가로 비동기 이벤트 처리 및 엔티티 생성이력 자동화 기반을 마련했습니다.
프로필 응답의 불변성 보장 MemberLoginResponse.java List.copyOf()를 활용한 방어적 복사(Defensive Copy)로 외부 수정 가능성을 완전히 차단했습니다.

🏛️ 2. [잠재적 위협 & 아키텍처 딥다이브 (Security & System Risks)]

🚨 위협 1: @EnableAsync 기본 설정에 따른 Thread Pool Exhaustion 및 OOM 위험

  • 물리적 원리 & 위협:
    • @EnableAsync 선언 시 Spring은 기본적으로 SimpleAsyncTaskExecutor를 사용합니다. 이 Executor는 스레드를 재사용(Pooling)하지 않고 비동기 요청이 올 때마다 새로운 스레드를 무제한 생성합니다.
    • 게시판 조회수 업데이트, 비동기 알림, 댓글 알림 등의 요청이 급증하면 순간적으로 스레드가 수천 개 이상 생성되어 CPU Context Switching 비용 폭증 및 java.lang.OutOfMemoryError: unable to create new native thread 장애로 시스템 전체가 다운됩니다.
  • 대안 기술 비교 및 선택:
    1. Custom ThreadPoolTaskExecutor 설정 (권장 - 단기/중기)
      • 장점: Core Pool, Max Pool, Queue Capacity, RejectedExecutionHandler를 명시하여 JVM 자원 사용량을 예측 가능하게 제한.
      • 단점: Thread Pool 크기와 Queue 크기 간의 튜닝(Sizing)이 필요함.
    2. Java 21+ Virtual Threads (Executors.newVirtualThreadPerTaskExecutor())
      • 장점: OS 스레드를 직접 생성하지 않고 Carrier Thread 상에서 Lightweight하게 동작하여 스레드 생성 비용과 메모리 점유가 극도로 적음.
      • 단점: Java 21 이상 환경 필요, Pinning Issue(synchronized 블록 내 I/O) 주의 필요.
    3. External Message Broker (RabbitMQ / Kafka)
      • 장점: 서버 장애 시에도 메시지가 보존되며, 서비스 간 완벽한 Decoupling 달성.
      • 단점: 인프라 구축 및 유지보수 공수 증가.

🚨 위협 2: Servlet In-Memory Session의 30일 장기 만료(30일) 설정에 따른 Heap OOM 및 Scale-Out 불능

  • 물리적 원리 & 위협:
    • AuthController에서 loginRequest.isRememberMe()가 참일 때 session.setMaxInactiveInterval(30 * 24 * 60 * 60)으로 30일 세션을 설정했습니다.
    • 별도의 외부 세션 저장소(Spring Session Redis 등)가 없는 상태에서 내장 톰캣(Tomcat)의 In-Memory Session을 30일 동안 유지하면, 사용자가 쌓일수록 JVM Heap Memory에 세션 객체가 누적되어 Memory Leak 및 GC Pause 타임 증가를 유발합니다.
    • 또한, 서버를 2대 이상으로 확장(Scale-Out)할 때 Sticky Session 또는 Session Clustering이 없다면 로그인 세션 불일치 장애가 발생합니다.
  • 대안 기술 비교 및 선택:
    1. Spring Session Data Redis 도입 (권장)
      • 장점: WAS 메모리를 사용하지 않고 외부 In-Memory DB에 저장하여 서버 Scale-Out이 자유롭고 WAS 재시작 시에도 로그인 유지.
      • 단점: Redis 인프라 필요 및 Network I/O 비용 발생.
    2. JWT 기반 Stateless Authentication + Refresh Token (HttpOnly Cookie)
      • 장점: 서버 세션 상태를 전혀 저장하지 않으므로 완전히 Stateless한 아키텍처 달성.
      • 단점: Token 탈취 시 즉각적인 강제 로그아웃(Revocation) 구현이 까다로움.

💻 3. [소스코드 품질 & 엣지 케이스 리뷰 (Code Quality & Edge Cases)]

AuthControllerAuthService 간의 이원화된 인증 로직 (Architecture Smell)

  • AuthController에서는 AuthenticationManager.authenticate()를 사용하고 있지만, AuthService 내부에도 authenticate() 메서드가 별도로 존재합니다.
  • 문제점: 실제 컨트롤러 흐름에서는 AuthService.authenticate()가 호출되지 않는 데드 코드(Dead Code) 상태이며, 향후 다른 개발자가 서비스 레이어의 인증을 직접 호출할 때 SecurityContext 갱신 누락 등의 사이드 이펙트가 발생할 수 있습니다.

AuthService.getMemberProfileByEmail()의 DB N+1 및 불필요한 쿼리 중복

  • memberRepository.findByEmail() 조회 후, 연관된 스키장과 라이딩 스타일을 가져오기 위해 memberResortRepositorymemberRidingStyleRepository를 각각 조회하고 있습니다.
  • 엣지 케이스: 회원이 등록된 스키장이나 라이딩 스타일이 없을 경우에도 매번 추가 쿼리가 수행됩니다. Member 조회 시점에 QueryDSL이나 Fetch Join을 통해 프로필 정보에 필요한 조인 데이터를 한 번의 쿼리로 가져오는 것이 DB I/O 감소에 훨씬 유리합니다.

MemberLoginRequest DTO의 Type Mismatch 및 Null Safety

private boolean rememberMe = false; // Primitive type

@Builder
public MemberLoginRequest(String email, String password, Boolean rememberMe) { // Wrapper type
    this.email = email;
    this.password = password;
    this.rememberMe = rememberMe != null ? rememberMe : false;
}
  • 필드는 boolean인데 생성자 매개변수는 Boolean으로 받고 있습니다. Jackson Deserialization 과정에서 JSON 필드가 누락되면 기본적으로 false가 할당되므로, 매개변수도 Primitive boolean으로 통일하여 코드의 직관성을 높이는 것이 좋습니다.

🛠️ 4. [개선된 코드 예시 (Before vs After)]

1) Async ThreadPool 설정 (비동기 스레드 풀 안정화)

// [AFTER]: global/config/AsyncConfig.java (신규 생성)
package com.ikae.snowthing.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("SnowAsync-");
        // Queue가 가득 찼을 때 요청을 버리지 않고 호출한 스레드(Request Thread)가 직접 처리하도록 설정
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

2) AuthController & AuthService 구조 정제 (Clean Architecture)

// [BEFORE]: AuthController.java 내의 과도한 저수준 세션 핸들링 및 AuthService 데드 코드 존재
// [AFTER]: AuthController.java
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

    private static final int REMEMBER_ME_TIMEOUT_SECONDS = 30 * 24 * 60 * 60; // 30일
    private static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 60 * 60;        // 1시간

    private final AuthenticationManager authenticationManager;
    private final SecurityContextRepository securityContextRepository;
    private final AuthService authService;

    @PostMapping("/login")
    public ResponseEntity<MemberLoginResponse> login(
            @Valid @RequestBody MemberLoginRequest loginRequest,
            HttpServletRequest httpRequest,
            HttpServletResponse httpResponse
    ) {
        // 1. Spring Security 표준 인증 위임
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword())
        );

        // 2. SecurityContext 설정
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        SecurityContextHolder.setContext(context);

        // 3. Session Fixation 방어 및 SecurityContext 저장
        HttpSession session = httpRequest.getSession(true);
        httpRequest.changeSessionId();
        securityContextRepository.saveContext(context, httpRequest, httpResponse);

        // 4. 타임아웃 계산 및 세션 적용
        int timeoutSeconds = loginRequest.isRememberMe() ? REMEMBER_ME_TIMEOUT_SECONDS : DEFAULT_SESSION_TIMEOUT_SECONDS;
        session.setMaxInactiveInterval(timeoutSeconds);

        // 5. 프로필 정보 반환
        MemberLoginResponse response = authService.getMemberProfileByEmail(loginRequest.getEmail());
        return ResponseEntity.ok(response);
    }
}
// [AFTER]: MemberLoginRequest.java (타입 일관성 확보)
package com.ikae.snowthing.domain.auth.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class MemberLoginRequest {

    @NotBlank(message = "이메일은 필수 입력값입니다.")
    private String email;

    @NotBlank(message = "비밀번호는 필수 입력값입니다.")
    private String password;

    private boolean rememberMe = false;

    @Builder
    public MemberLoginRequest(String email, String password, boolean rememberMe) {
        this.email = email;
        this.password = password;
        this.rememberMe = rememberMe;
    }
}

💡 멘토의 총평 및 성장 가이드

이번 PR은 스프링 시큐리티의 세션 고정 공격 방어(changeSessionId()), 방어적 복사를 통한 DTO 불변성 유지, CSRF 이중 검증 등 기본기가 아주 잘 다져진 훌륭한 코드입니다!

다음 단계로 성장하기 위해 이번에 리뷰해 드린 **1) 비동기 스레드 풀 관리(OOM 방지)**와 2) 대규모 유저 접속 시의 분산 세션(Redis) 고려라는 아키텍처 관점을 팀 내에 공유하고 논의해 보세요. 한 단계 더 깊이 있는 백엔드 아키텍트로 거듭나실 것입니다.

정말 고생 많으셨습니다! 추가로 궁금한 점이 있다면 언제든 편하게 질문해 주세요.

@yyy9942 yyy9942 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

고생하셨어요 코멘트 단 내용 확인해서 답변 달아주세요.

아래 내용은 codex가 잡아준 내용이니, 이 내용도 참조해보세요.

  1. H2/MySQL 설정 불일치
    application.yml:16
    [Blocker] Docker MySQL이 실제 검증에 사용되지 않습니다.
    Docker Compose에서는 MySQL을 실행하지만 애플리케이션 기본 datasource는 H2를 바라보고 있습니다. 따라서 Compose를 실행해도 백엔드는 MySQL이 아닌 H2에 연결되고, MySQL DDL·제약조건 오류를 발견할 수 없습니다.
    H2와 MySQL 설정을 프로필로 분리하고, Docker 프로필에서는 MySQL datasource를 사용하도록 구성해주세요. 수정 후 실제 연결 JDBC URL과 MySQL 환경에서 게시글 작성·삭제 및 익명 추천을 검증한 결과도 남겨주세요.

  2. 게시글 deleted_at 누락
    ddl.sql:114
    [Blocker] 엔티티의 Soft Delete SQL과 DDL이 일치하지 않습니다.
    Post의 @SQLDelete는 삭제 시 deleted_at = NOW()까지 실행하지만 현재 post 테이블에는 deleted_at 컬럼이 없습니다. 이 DDL로 생성한 MySQL에서 삭제 API를 호출하면 존재하지 않는 컬럼 오류가 발생합니다.
    게시글과 댓글 엔티티의 Soft Delete SQL을 DDL과 전부 대조해주세요. 수정 후 실제 MySQL에서 삭제 API를 호출하여 is_deleted, status, deleted_at이 기대한 값으로 변경되는지 확인해주세요.

  3. 익명 추천 스키마 불일치
    ddl.sql:136
    [Blocker] 현재 DDL에서는 비로그인 익명 추천을 저장할 수 없습니다.
    엔티티는 익명 추천일 때 member_id=null을 허용하고 anonymous_voter_id를 사용하지만, DDL은 member_id NOT NULL이며 anonymous_voter_id, writer_ip 컬럼도 없습니다.
    엔티티와 DDL의 컬럼, null 허용 여부, 회원 추천 UNIQUE 제약, 익명 추천 UNIQUE 제약을 일치시켜주세요. H2 테스트뿐 아니라 실제 MySQL에서 회원 추천과 익명 추천의 생성·취소·중복 요청을 검증해주세요.

  4. 댓글 작성 API 주소 오류
    page.tsx:210
    [Blocker] 백엔드 API 계약과 주소가 달라 댓글 작성이 404로 실패합니다.
    백엔드는 /api/v1/posts/{publicId}/comments를 제공하지만 여기서는 /api/posts/{publicId}/comments를 호출하고 있습니다. 백엔드 테스트와 프론트 빌드가 각각 성공해도 실제 연동에서는 실패합니다.
    이 라인만 수정하기보다 API base URL과 /api/v1을 공통 모듈로 관리해주세요. 수정 후 일반 댓글과 대댓글을 브라우저에서 각각 작성하고 실제 요청 URL과 응답 상태를 확인해주세요.

  5. 댓글 삭제 주소·비밀번호 노출
    page.tsx:253
    [Blocker] 삭제 주소와 익명 비밀번호 전달 방식 모두 수정이 필요합니다.
    백엔드는 /api/v1/comments/{id}를 제공하지만 여기서는 /api/comments/{id}를 호출해 404가 발생합니다. 또한 익명 비밀번호를 URL 쿼리 문자열에 추가하면 Nginx, 서버, APM, 프록시 로그 등에 평문으로 남을 수 있습니다.
    API 주소를 통일하고 비밀번호는 request body DTO로 전달해주세요. 정상 비밀번호는 성공, 잘못된 비밀번호는 403, 요청 URL에는 비밀번호가 포함되지 않는 것을 검증해주세요.

  6. 게시글 삭제 비밀번호 RequestParam
    PostController.java:93
    [Blocker] 민감정보를 URL로 받는 API 계약을 변경해주세요.
    anonymousPassword를 @RequestParam으로 받으면 모든 클라이언트가 비밀번호를 URL에 담아야 합니다. 프론트만 수정해서는 해결되지 않으므로 백엔드 계약 자체를 request body 방식으로 변경해야 합니다.
    삭제 요청 DTO를 만들고 로그인 작성자·관리자·비회원 익명 작성자의 권한 분기를 각각 테스트해주세요. 특히 관계없는 로그인 사용자가 익명 비밀번호를 알 경우 삭제 가능한지도 정책을 명확히 검증해주세요.

  7. 댓글 삭제 비밀번호 RequestParam
    CommentController.java:48
    [Blocker] 댓글 삭제도 게시글과 동일한 보안 계약을 사용해야 합니다.
    CommentDeleteRequest DTO가 존재하지만 실제 컨트롤러에서는 사용하지 않고 비밀번호를 @RequestParam으로 받고 있습니다.
    사용되지 않는 DTO를 실제 API에 적용하고 게시글·댓글 삭제의 비밀번호 전달 규칙을 통일해주세요. 정상 비밀번호, 잘못된 비밀번호, 로그인 작성자, 관리자 케이스를 컨트롤러 테스트에 추가해주세요.

  8. 숨김·차단 게시글 목록 노출
    PostRepositoryCustomImpl.java:42
    [Blocker] isDeleted=false만으로는 공개 게시글을 판별할 수 없습니다.
    현재 조건에서는 HIDDEN, BLOCKED, DRAFT 게시글도 삭제 상태만 아니라면 일반 사용자 목록에 노출됩니다. 상세 접근만 차단해도 목록에서 제목·작성자·카운트가 노출될 수 있습니다.
    일반 사용자 목록에는 status=NORMAL 조건을 적용해주세요. 댓글 조회·작성과 추천 API도 동일한 상태 정책을 사용하는지 확인하고, 상태별 접근 테스트를 추가해주세요.

  9. QueryDSL 성능 설명 불일치
    MemberRepositoryCustomImpl.java:25
    [Major] “단일 조인 DTO 프로젝션”이라는 설명과 구현이 다릅니다.
    실제 구현은 회원 엔티티, 리조트 이름, 라이딩 스타일 이름을 각각 조회하므로 총 3개 쿼리입니다. 현재 구조는 한 회원당 고정된 3개 쿼리라 전형적인 N+1은 아니지만, 단일 쿼리 최적화라고 설명할 수도 없습니다.
    3개 쿼리를 의도적으로 선택했다면 선택 이유와 트레이드오프를 문서에 적어주세요. 단일 쿼리가 목표라면 실제 projection과 중복 row 조합 방식을 다시 설계하고 쿼리 횟수도 테스트해주세요.

  10. 가상 스레드 중복 설정
    AsyncConfig.java:16
    [Major] 가상 스레드 설정의 적용 범위와 중복 여부를 설명해주세요.
    spring.threads.virtual.enabled=true를 설정하면서 별도의 taskExecutor도 등록했습니다. Spring Boot 자동 실행기와 직접 등록한 실행기 중 어떤 것이 Servlet 요청과 @Async 작업에 각각 사용되는지 확인이 필요합니다.
    또한 가상 스레드는 플랫폼 스레드 비용을 줄이지만 DB 커넥션, 메모리, 외부 API 제한까지 해결하지는 않습니다. 따라서 “Native OOM 원천 차단” 표현은 수정해주세요. 현재 @Async 대상과 가상 스레드를 선택한 근거, DB 커넥션 풀이 고갈될 때의 제한 전략도 설명해주세요.

Comment thread backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java Outdated
Comment thread backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentResponse.java Outdated
Comment thread backend/src/main/java/com/ikae/snowthing/domain/member/service/MemberService.java Outdated
Comment thread backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java Outdated
Comment thread backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java Outdated
@yyy9942

yyy9942 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

conflicts 나는것도 해결하기! rebase나 merge를 진행해주세요.

Repository owner deleted a comment from github-actions Bot Aug 27, 2026
Repository owner deleted a comment from github-actions Bot Aug 27, 2026
Repository owner deleted a comment from github-actions Bot Aug 27, 2026
Repository owner deleted a comment from github-actions Bot Aug 27, 2026
Repository owner deleted a comment from github-actions Bot Aug 27, 2026
@devikae

devikae commented Aug 27, 2026

Copy link
Copy Markdown
Owner Author
  1. H2/MySQL 설정 불일치 -> 설정 맞춤 및, 작성 테스트
image
  • 수정테스트
image
  1. 삭제 테스트 및 deleted_at 누락 확인, 삭제 시 is_deleted=true, status='DELETED', deleted_at 정상 기록
image
  1. member_id NULL 허용, 회원 복합 유니크(uk_post_member_type)와 익명 복합 유니크(uk_post_anon_voter_type) 제약조건을 엔티티와 동일하게 맞췄습니다.
image

4.경로가 /api/posts/...로 잘못 호출되던 부분을 /api/v1/posts/{publicId}/comments로 수정했습니다. 동시에 프론트엔드 곳곳에 하드코딩되어 있던 API 주소들을 모아서 관리할 수 있도록 frontend/app/lib/api.ts 공통 모듈을 만들고, 전체 페이지가 API_ENDPOINTS를 참조하도록 했습니다. 빌드와 API 연동도 정상 통과합니다.
image

  1. 댓글 삭제 시 쿼리 파라미터로 가는게 아닌, Request Body로 비밀번호를 전송하도록 변경했습니다.
image
  1. 게시글 삭제 시 쿼리 파마리터로 가는게 아닌, Request Body로 비밀번호를 전송하도록 변경했습니다.
image
  1. 댓글 삭제 비밀번호 쿼리 파마리터로 가는게 아닌, Request Body로 비밀번호를 전송하도록 변경했습니다.
image
  1. isDeleted = false만 체크하면 HIDDEN이나 DRAFT 같은 비공개 글이 목록에 노출될 수 있었네요.
    QueryDSL 목록 쿼리(Offset 카운트/PK 선별, Cursor Keyset)와 JPQL 쿼리 전반에 post.status.eq(PostStatus.NORMAL) 조건을 추가하여 비공개 글이 일반 목록에 노출되지 않도록 처리했습니다.
    댓글 작성 및 조회 (CommentService): status != NORMAL인 경우 404 POST_NOT_FOUND로 일관되게 차단.
    게시글 추천 및 상세 조회 (PostService): 일반 사용자의 status != NORMAL 접근 시 404 POST_NOT_FOUND 차단 (관리자 예외 허용).
    상태별(NORMAL, HIDDEN, BLOCKED, DRAFT, DELETED) 목록 제외 및 상세/댓글/추천 접근 차단 정책을 검증하는 전용 테스트(PostStatusPolicyTest)도 추가해 두었습니다

  2. 단일 조인 프로젝션이 아닌 다중 1:N Cartesian Product 방지를 위한 3-Step O(1) 분리 조회로 수정했습니다.
    [3-Step 쿼리 선택 이유 및 N+1 배제 ]:
    Cartesian Product 방지: Member는 member_resorts(1:N)와 member_riding_styles(1:M)라는 2개의 독립된 일대다 컬렉션을 가집니다. 단일 쿼리로 조인 시 발생하는 카테시안 곱 중복 데이터와 메모리 오버헤드를 물리적으로 차단하기 위해 3단계로 분리했습니다.
    고정 O(1) 쿼리 (N+1 없음): 데이터 개수(리조트 N개, 스타일 M개)가 늘어나더라도 쿼리가 데이터 수에 비례하여 늘어나는 N+1이 아니라, 회원 기본 정보(1회) + 리조트 목록(1회) + 스타일 목록(1회) = 항상 고정된 3회로 수행됩니다.
    인덱스 최적화: 3개 쿼리 모두 PK/Email 인덱스 및 FK 인덱스 조건을 타기때문에, N×M 중복 데이터 대신 최소 데이터(1+N+M row)만 전송되어 안전하고 효율적입니다.
    실제 실행되는 3개 쿼리 로그와 다중 리조트/스타일 보유 시의 무결성 검증은 MemberRepositoryCustomTest에 명시해두었습니다.

  3. 적용 범위 및 중복 해소 (AsyncConfigurer 적용):

Servlet HTTP 요청: spring.threads.virtual.enabled=true에 의해 Tomcat 서블릿 컨테이너가 모든 인바운드 HTTP 요청을 가상 스레드(virtual-0, virtual-1...)로 처리합니다.
@async 백그라운드 작업: 별도의 잉여 @bean 등록 대신 Spring 표준 인터페이스인 AsyncConfigurer를 구현하여, @async 작업 전용 가상 스레드 실행기(async-vt- prefix)와 미처리 예외 핸들러(AsyncUncaughtExceptionHandler)를 일원화하여 등록했습니다. 중복 빈 충돌 없이 동작합니다.

물리적 한계 및 과장 표현 정정:
"Native OOM 원천 차단"이라는 과장된 표현을 바로잡았습니다. 가상 스레드는 OS 플랫폼 스레드의 스택 메모리(기본 1MB) 생성 비용을 경량화(KB 단위)하여 수만 개의 동시 I/O 대기를 가능하게 할 뿐, JVM 힙 메모리나 DB 커넥션 풀(HikariCP)의 물리적 한계를 해결하지는 못합니다.

@async 대상 및 가상 스레드 선택 근거:
현재 이벤트 발행(PostReactionEvent), 비동기 알림 및 통계 집계 등 I/O 대기가 발생하는 백그라운드 작업에 적용되어 있습니다. I/O 블로킹 중에도 캐리어 스레드를 점유하지 않고 언마운트(Unmount)되므로 적은 리소스로 높은 처리량(Throughput)을 낼 수 있습니다.

DB 커넥션 풀 고갈 방지 전략:
가상 스레드가 무제한으로 DB 작업을 호출하여 커넥션 풀(HikariCP)이 고갈되는 문제를 방지하기 위해:
OSIV=false 적용: 뷰 렌더링 시점의 커넥션 점유를 차단하고 실제 트랜잭션 수행 시에만 커넥션을 점유하도록 최소화.
DB 작업과 순수 I/O 작업 격리: 외부 웹훅/알림/파일 I/O 등 DB 커넥션이 필요 없는 작업과 DB 트랜잭션 작업을 물리적으로 분리.
세마포어(Semaphore) 기반 동시 진입 제어: 대량의 트래픽 발생 시 커넥션 풀 크기(maximum-pool-size)에 맞춰 동시 DB 진입 수를 제한하는 방어선을 유지합니다.

@devikae
devikae requested a review from yyy9942 August 27, 2026 12:53

@yyy9942 yyy9942 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

고생하셨어요 정리 조금만 더 하고 머지해주세요.

@devikae
devikae merged commit a8f3d69 into main Aug 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend ci-cd database documentation Improvements or additions to documentation frontend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants