From 77af694f587a61701e40e6873e1e12085225344a Mon Sep 17 00:00:00 2001 From: ikae Date: Fri, 21 Aug 2026 23:41:40 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat(post):=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20CRUD,=20=EC=B6=94=EC=B2=9C/=EB=B9=84=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +- .../ikae/snowthing/SnowthingApplication.java | 3 + .../auth/controller/AuthController.java | 51 +- .../domain/auth/dto/MemberLoginResponse.java | 6 +- .../domain/auth/service/AuthService.java | 66 +- .../controller/MasterDataController.java | 18 +- .../member/controller/MemberController.java | 24 +- .../domain/member/dto/ResortResponse.java | 13 + .../member/dto/RidingStyleResponse.java | 13 + .../post/controller/PostController.java | 100 ++ .../domain/post/dto/PostCreateRequest.java | 32 + .../domain/post/dto/PostDetailResponse.java | 85 ++ .../domain/post/dto/PostListResponse.java | 55 ++ .../domain/post/dto/PostReactionRequest.java | 9 + .../domain/post/dto/PostResponse.java | 39 + .../domain/post/dto/PostUpdateRequest.java | 31 + .../snowthing/domain/post/entity/Post.java | 136 +++ .../domain/post/entity/PostCategory.java | 31 + .../domain/post/entity/PostImage.java | 41 + .../domain/post/entity/PostReaction.java | 45 + .../domain/post/entity/PostStatus.java | 8 + .../domain/post/entity/ReactionType.java | 6 + .../domain/post/event/PostReactionEvent.java | 8 + .../post/event/PostReactionEventListener.java | 37 + .../repository/PostCategoryRepository.java | 11 + .../post/repository/PostImageRepository.java | 11 + .../repository/PostReactionRepository.java | 13 + .../post/repository/PostRepository.java | 26 + .../domain/post/service/PostService.java | 219 +++++ .../global/config/DataInitializer.java | 14 + .../global/config/SecurityConfig.java | 16 +- .../snowthing/global/error/ErrorCode.java | 30 + .../snowthing/global/error/ErrorResponse.java | 15 + .../global/exception/CustomAuthException.java | 20 + .../exception/GlobalExceptionHandler.java | 36 +- .../global/security/CustomUserDetails.java | 61 ++ .../security/CustomUserDetailsService.java | 25 + .../post/controller/PostControllerTest.java | 157 +++ .../domain/post/service/PostServiceTest.java | 286 ++++++ docs/conception/sprint01/03.domain-model.md | 4 +- docs/conception/sprint01/04.erd.md | 16 +- docs/project/project.md | 200 ++-- docs/project/work.md | 69 ++ docs/study/sprint01/1.md | 279 ++++++ docs/study/sprint01/2.md | 213 +++++ docs/study/sprint01/3.md | 264 ++++++ docs/study/sprint01/4.md | 185 ++++ .../studySnowthingCompleteInterview260819.md | 574 +++++++++++ ...dySprint01BoardIssuesAndSolutions260821.md | 124 +++ .../studySprint01CompleteCodeMaster260821.md | 897 ++++++++++++++++++ .../sprint01/studySqlLogNPlusOne260819.md | 138 +++ .../studyCommunityPostCommentMaster260821.md | 709 ++++++++++++++ ...dySprint01BoardIssuesAndSolutions260821.md | 124 +++ ...dySprint02BoardIssuesAndSolutions260821.md | 593 ++++++++++++ .../studySprint02PostDomainIssues260821.md | 191 ++++ ...dySprint02BoardIssuesAndSolutions260821.md | 593 ++++++++++++ .../studySprint02PostDomainIssues260821.md | 191 ++++ docs/studyApiDesign260808.md | 80 ++ docs/studyArchConcepts260806.md | 313 ++++++ docs/studyArchPrinciples260810.md | 72 ++ docs/studyCommunityPostCommentMaster260821.md | 710 ++++++++++++++ docs/studyDomainErd260807.md | 107 +++ docs/studyPkStrategy260807.md | 133 +++ docs/studySessionAuth260806.md | 67 ++ docs/studySessionFlow260810.md | 83 ++ docs/studySystemArch260810.md | 87 ++ ...sprint01_session_concurrency_jpa_260817.md | 539 +++++++++++ frontend/app/page.tsx | 8 +- frontend/app/posts/[publicId]/page.tsx | 516 ++++++++++ frontend/app/posts/create/page.tsx | 209 ++++ frontend/app/posts/page.tsx | 203 ++++ 71 files changed, 10109 insertions(+), 185 deletions(-) create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/member/dto/ResortResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/member/dto/RidingStyleResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostCreateRequest.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostListResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostReactionRequest.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostUpdateRequest.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/entity/Post.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostCategory.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostImage.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostReaction.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostStatus.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/entity/ReactionType.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEvent.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostCategoryRepository.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostImageRepository.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostReactionRepository.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepository.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java create mode 100644 backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java create mode 100644 backend/src/main/java/com/ikae/snowthing/global/error/ErrorResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/global/exception/CustomAuthException.java create mode 100644 backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetails.java create mode 100644 backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetailsService.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/post/controller/PostControllerTest.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java create mode 100644 docs/study/sprint01/1.md create mode 100644 docs/study/sprint01/2.md create mode 100644 docs/study/sprint01/3.md create mode 100644 docs/study/sprint01/4.md create mode 100644 docs/study/sprint01/studySnowthingCompleteInterview260819.md create mode 100644 docs/study/sprint01/studySprint01BoardIssuesAndSolutions260821.md create mode 100644 docs/study/sprint01/studySprint01CompleteCodeMaster260821.md create mode 100644 docs/study/sprint01/studySqlLogNPlusOne260819.md create mode 100644 docs/study/sprint02/studyCommunityPostCommentMaster260821.md create mode 100644 docs/study/sprint02/studySprint01BoardIssuesAndSolutions260821.md create mode 100644 docs/study/sprint02/studySprint02BoardIssuesAndSolutions260821.md create mode 100644 docs/study/sprint02/studySprint02PostDomainIssues260821.md create mode 100644 docs/study/studySprint02BoardIssuesAndSolutions260821.md create mode 100644 docs/study/studySprint02PostDomainIssues260821.md create mode 100644 docs/studyApiDesign260808.md create mode 100644 docs/studyArchConcepts260806.md create mode 100644 docs/studyArchPrinciples260810.md create mode 100644 docs/studyCommunityPostCommentMaster260821.md create mode 100644 docs/studyDomainErd260807.md create mode 100644 docs/studyPkStrategy260807.md create mode 100644 docs/studySessionAuth260806.md create mode 100644 docs/studySessionFlow260810.md create mode 100644 docs/studySystemArch260810.md create mode 100644 docs/study_sprint01_session_concurrency_jpa_260817.md create mode 100644 frontend/app/posts/[publicId]/page.tsx create mode 100644 frontend/app/posts/create/page.tsx create mode 100644 frontend/app/posts/page.tsx diff --git a/.gitignore b/.gitignore index 936bc2b..49430c1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,9 @@ application-credentials*.yml *.key *.p12 -# Local Project & Study Notes (스터디 문서 전면 제외 & project.md, work.md만 허용) -docs/study/ -docs/study*.md +# Local Project & Study Notes +!docs/study/ +!docs/study/** docs/project/* !docs/project/project.md !docs/project/work.md diff --git a/backend/src/main/java/com/ikae/snowthing/SnowthingApplication.java b/backend/src/main/java/com/ikae/snowthing/SnowthingApplication.java index bec0ea7..01e1a22 100644 --- a/backend/src/main/java/com/ikae/snowthing/SnowthingApplication.java +++ b/backend/src/main/java/com/ikae/snowthing/SnowthingApplication.java @@ -4,6 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; + +@EnableAsync @EnableJpaAuditing @SpringBootApplication public class SnowthingApplication { diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/controller/AuthController.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/controller/AuthController.java index ff783c4..2576865 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/controller/AuthController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/controller/AuthController.java @@ -4,24 +4,69 @@ import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; import com.ikae.snowthing.domain.auth.service.AuthService; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/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 login( @Valid @RequestBody MemberLoginRequest loginRequest, - HttpServletRequest request + HttpServletRequest httpRequest, + HttpServletResponse httpResponse ) { - MemberLoginResponse response = authService.login(loginRequest, request); + // 1. Spring Security 표준 AuthenticationManager 위임 인증 처리 + Authentication authenticationToken = new UsernamePasswordAuthenticationToken( + loginRequest.getEmail(), + loginRequest.getPassword() + ); + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + // 2. SecurityContext 생성 및 ContextHolder 설정 + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + + // 3. 세션 고정 공격 방어(Session Fixation Protection) 및 시큐리티 표준 SecurityContextRepository 저장 + HttpSession session = httpRequest.getSession(true); + httpRequest.changeSessionId(); + securityContextRepository.saveContext(securityContext, httpRequest, httpResponse); + + // 4. Remember-Me 세션 타임아웃 계산 및 적용 (별도 메서드 분리 및 상수 적용) + int timeoutSeconds = calculateSessionTimeoutSeconds(loginRequest.isRememberMe()); + session.setMaxInactiveInterval(timeoutSeconds); + + MemberLoginResponse response = authService.getMyProfile(loginRequest.getEmail()); return ResponseEntity.ok(response); } + + /** + * Remember-Me 체크 여부에 따른 세션 타임아웃 계산 (초 단위) + */ + private int calculateSessionTimeoutSeconds(boolean rememberMe) { + return rememberMe ? REMEMBER_ME_TIMEOUT_SECONDS : DEFAULT_SESSION_TIMEOUT_SECONDS; + } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginResponse.java index cdd99f4..843b7fd 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginResponse.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/dto/MemberLoginResponse.java @@ -5,7 +5,6 @@ import lombok.Builder; import lombok.Getter; -import java.util.ArrayList; import java.util.List; @Getter @@ -24,8 +23,9 @@ public MemberLoginResponse(String publicId, String email, String nickname, Role this.email = email; this.nickname = nickname; this.role = role; - this.resortNames = resortNames != null ? resortNames : new ArrayList<>(); - this.ridingStyleNames = ridingStyleNames != null ? ridingStyleNames : new ArrayList<>(); + // 방어적 복사 (Defensive Copy) 적용으로 100% 불변 리스트 보장 및 외부 변형 차단 + this.resortNames = resortNames != null ? List.copyOf(resortNames) : List.of(); + this.ridingStyleNames = ridingStyleNames != null ? List.copyOf(ridingStyleNames) : List.of(); } public static MemberLoginResponse from(Member member, List resortNames, List ridingStyleNames) { diff --git a/backend/src/main/java/com/ikae/snowthing/domain/auth/service/AuthService.java b/backend/src/main/java/com/ikae/snowthing/domain/auth/service/AuthService.java index 7be5460..1b271d2 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/auth/service/AuthService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/auth/service/AuthService.java @@ -1,24 +1,17 @@ package com.ikae.snowthing.domain.auth.service; -import com.ikae.snowthing.domain.auth.dto.MemberLoginRequest; import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; import com.ikae.snowthing.domain.member.entity.Member; import com.ikae.snowthing.domain.member.repository.MemberRepository; import com.ikae.snowthing.domain.member.repository.MemberResortRepository; import com.ikae.snowthing.domain.member.repository.MemberRidingStyleRepository; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; import java.util.List; @Service @@ -31,55 +24,26 @@ public class AuthService { private final MemberRidingStyleRepository memberRidingStyleRepository; private final PasswordEncoder passwordEncoder; - @Transactional - public MemberLoginResponse login(MemberLoginRequest request, HttpServletRequest httpRequest) { - Member member = memberRepository.findByEmail(request.getEmail()) - .orElseThrow(() -> new IllegalArgumentException("INVALID_CREDENTIALS")); + /** + * 회원 자격 증명 (이메일 및 비밀번호 검증) + */ + public Member authenticate(String email, String rawPassword) { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new CustomAuthException(ErrorCode.INVALID_CREDENTIALS)); - if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { - throw new IllegalArgumentException("INVALID_CREDENTIALS"); + if (!passwordEncoder.matches(rawPassword, member.getPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); } - Authentication authentication = new UsernamePasswordAuthenticationToken( - member.getEmail(), - null, - Collections.singletonList(new SimpleGrantedAuthority(member.getRole().getKey())) - ); - - SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); - securityContext.setAuthentication(authentication); - SecurityContextHolder.setContext(securityContext); - - HttpSession session = httpRequest.getSession(true); - httpRequest.changeSessionId(); - - int sessionTimeoutSeconds = request.isRememberMe() ? 30 * 24 * 60 * 60 : 60 * 60; - session.setMaxInactiveInterval(sessionTimeoutSeconds); - session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext); - - List resortNames = memberResortRepository.findAllByMemberIdWithResort(member.getId()).stream() - .map(mr -> mr.getResort().getName()) - .toList(); - - List ridingStyleNames = memberRidingStyleRepository.findAllByMemberIdWithRidingStyle(member.getId()).stream() - .map(mrs -> mrs.getRidingStyle().getStyleName()) - .toList(); - - return MemberLoginResponse.from(member, resortNames, ridingStyleNames); - } - - public void logout(HttpServletRequest httpRequest) { - SecurityContextHolder.clearContext(); - - HttpSession session = httpRequest.getSession(false); - if (session != null) { - session.invalidate(); - } + return member; } + /** + * 내 프로필 정보 및 선호 스키장/라이딩 성향 Fetch Join 조회 (방어적 복사 적용) + */ public MemberLoginResponse getMyProfile(String email) { Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("MEMBER_NOT_FOUND")); + .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); List resortNames = memberResortRepository.findAllByMemberIdWithResort(member.getId()).stream() .map(mr -> mr.getResort().getName()) diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MasterDataController.java b/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MasterDataController.java index b9d966f..d774449 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MasterDataController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MasterDataController.java @@ -1,7 +1,7 @@ package com.ikae.snowthing.domain.member.controller; -import com.ikae.snowthing.domain.member.entity.Resort; -import com.ikae.snowthing.domain.member.entity.RidingStyle; +import com.ikae.snowthing.domain.member.dto.ResortResponse; +import com.ikae.snowthing.domain.member.dto.RidingStyleResponse; import com.ikae.snowthing.domain.member.repository.ResortRepository; import com.ikae.snowthing.domain.member.repository.RidingStyleRepository; import lombok.RequiredArgsConstructor; @@ -21,12 +21,18 @@ public class MasterDataController { private final RidingStyleRepository ridingStyleRepository; @GetMapping("/resorts") - public ResponseEntity> getResorts() { - return ResponseEntity.ok(resortRepository.findAll()); + public ResponseEntity> getResorts() { + List response = resortRepository.findAll().stream() + .map(ResortResponse::from) + .toList(); + return ResponseEntity.ok(response); } @GetMapping("/riding-styles") - public ResponseEntity> getRidingStyles() { - return ResponseEntity.ok(ridingStyleRepository.findAll()); + public ResponseEntity> getRidingStyles() { + List response = ridingStyleRepository.findAll().stream() + .map(RidingStyleResponse::from) + .toList(); + return ResponseEntity.ok(response); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MemberController.java b/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MemberController.java index c920d95..24ff0e8 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/controller/MemberController.java @@ -6,12 +6,12 @@ import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; import com.ikae.snowthing.domain.member.service.MemberService; +import com.ikae.snowthing.global.security.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -29,23 +29,17 @@ public ResponseEntity signUp(@Valid @RequestBody MemberSig } @GetMapping("/me") - public ResponseEntity getMyProfile() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - String email = (String) authentication.getPrincipal(); - MemberLoginResponse profile = authService.getMyProfile(email); + public ResponseEntity getMyProfile(@AuthenticationPrincipal CustomUserDetails userDetails) { + MemberLoginResponse profile = authService.getMyProfile(userDetails.getUsername()); return ResponseEntity.ok(profile); } @PutMapping("/me") - public ResponseEntity updateMyProfile(@Valid @RequestBody MemberProfileUpdateRequest request) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - String email = (String) authentication.getPrincipal(); + public ResponseEntity updateMyProfile( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody MemberProfileUpdateRequest request + ) { + String email = userDetails.getUsername(); memberService.updateMyProfile(email, request); MemberLoginResponse updatedProfile = authService.getMyProfile(email); return ResponseEntity.ok(updatedProfile); diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/ResortResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/ResortResponse.java new file mode 100644 index 0000000..f327c3a --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/ResortResponse.java @@ -0,0 +1,13 @@ +package com.ikae.snowthing.domain.member.dto; + +import com.ikae.snowthing.domain.member.entity.Resort; + +public record ResortResponse( + Long id, + String name, + String regionName +) { + public static ResortResponse from(Resort resort) { + return new ResortResponse(resort.getId(), resort.getName(), resort.getRegionName()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/RidingStyleResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/RidingStyleResponse.java new file mode 100644 index 0000000..2362e95 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/RidingStyleResponse.java @@ -0,0 +1,13 @@ +package com.ikae.snowthing.domain.member.dto; + +import com.ikae.snowthing.domain.member.entity.RidingStyle; + +public record RidingStyleResponse( + Long id, + String styleName, + String description +) { + public static RidingStyleResponse from(RidingStyle ridingStyle) { + return new RidingStyleResponse(ridingStyle.getId(), ridingStyle.getStyleName(), ridingStyle.getDescription()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java b/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java new file mode 100644 index 0000000..d5cb36f --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/controller/PostController.java @@ -0,0 +1,100 @@ +package com.ikae.snowthing.domain.post.controller; + +import com.ikae.snowthing.domain.post.dto.*; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.security.CustomUserDetails; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + @PostMapping + public ResponseEntity createPost( + @Valid @RequestBody PostCreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletRequest httpRequest + ) { + String clientIp = getClientIp(httpRequest); + PostResponse response = postService.createPost(request, userDetails, clientIp); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{publicId}") + public ResponseEntity getPostDetail( + @PathVariable String publicId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + PostDetailResponse response = postService.getPostDetail(publicId, userDetails); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> getPostList( + @RequestParam(required = false) String categoryCode, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Page response = postService.getPostList(categoryCode, page, size); + return ResponseEntity.ok(response); + } + + @PutMapping("/{publicId}") + public ResponseEntity updatePost( + @PathVariable String publicId, + @Valid @RequestBody PostUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + PostResponse response = postService.updatePost(publicId, request, userDetails); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{publicId}") + public ResponseEntity> deletePost( + @PathVariable String publicId, + @RequestParam(required = false) String anonymousPassword, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + postService.deletePost(publicId, anonymousPassword, userDetails); + return ResponseEntity.ok(Map.of( + "message", "게시글이 정상적으로 삭제(Soft Delete) 처리되었습니다.", + "publicId", publicId + )); + } + + @PostMapping("/{publicId}/reactions") + public ResponseEntity> reactToPost( + @PathVariable String publicId, + @Valid @RequestBody PostReactionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + postService.reactToPost(publicId, request.type(), userDetails); + return ResponseEntity.ok(Map.of( + "message", "투표가 성공적으로 수신되었습니다.", + "type", request.type().name() + )); + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip != null ? ip : "127.0.0.1"; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostCreateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..dc8e98f --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostCreateRequest.java @@ -0,0 +1,32 @@ +package com.ikae.snowthing.domain.post.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +import java.util.List; + +@Builder +public record PostCreateRequest( + @NotBlank(message = "카테고리 코드는 필수 입력값입니다.") + String categoryCode, + + @NotBlank(message = "제목은 필수 입력값입니다.") + @Size(max = 200, message = "제목은 최대 200자까지 입력 가능합니다.") + String title, + + @NotBlank(message = "본문은 필수 입력값입니다.") + String content, + + boolean isAnonymous, + String anonymousPassword, + List imageUrls +) { + public PostCreateRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } else { + imageUrls = List.copyOf(imageUrls); + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java new file mode 100644 index 0000000..e567209 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostDetailResponse.java @@ -0,0 +1,85 @@ +package com.ikae.snowthing.domain.post.dto; + +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostImage; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +public record PostDetailResponse( + String publicId, + String categoryName, + String categoryCode, + String title, + String content, + PostStatus status, + int viewCount, + int commentCount, + int likeCount, + int dislikeCount, + WriterInfo writer, + List images, + LocalDateTime createdAt +) { + @Builder + public record WriterInfo( + String publicId, + String nickname, + String profileImageUrl + ) {} + + public static PostDetailResponse from(Post post) { + WriterInfo writerInfo; + if (post.isAnonymous()) { + writerInfo = WriterInfo.builder() + .publicId(null) + .nickname("익명 (" + maskIp(post.getWriterIp()) + ")") + .profileImageUrl(null) + .build(); + } else if (post.getMember() != null) { + writerInfo = WriterInfo.builder() + .publicId(post.getMember().getPublicId()) + .nickname(post.getMember().getNickname()) + .profileImageUrl(post.getMember().getProfileImageUrl()) + .build(); + } else { + writerInfo = WriterInfo.builder() + .publicId(null) + .nickname("알 수 없음") + .profileImageUrl(null) + .build(); + } + + List imageUrls = post.getImages().stream() + .map(PostImage::getImageUrl) + .toList(); + + return PostDetailResponse.builder() + .publicId(post.getPublicId()) + .categoryName(post.getCategory().getName()) + .categoryCode(post.getCategory().getCode()) + .title(post.getTitle()) + .content(post.getContent()) + .status(post.getStatus()) + .viewCount(post.getViewCount()) + .commentCount(post.getCommentCount()) + .likeCount(post.getLikeCount()) + .dislikeCount(post.getDislikeCount()) + .writer(writerInfo) + .images(imageUrls) + .createdAt(post.getCreatedAt()) + .build(); + } + + private static String maskIp(String ip) { + if (ip == null || ip.isBlank()) return "***.***.***.***"; + String[] parts = ip.split("\\."); + if (parts.length == 4) { + return parts[0] + "." + parts[1] + ".***.***"; + } + return ip; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostListResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostListResponse.java new file mode 100644 index 0000000..c3926b5 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostListResponse.java @@ -0,0 +1,55 @@ +package com.ikae.snowthing.domain.post.dto; + +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record PostListResponse( + String publicId, + String categoryName, + String categoryCode, + String title, + String writerNickname, + String thumbnailImageUrl, + int viewCount, + int commentCount, + int likeCount, + int dislikeCount, + PostStatus status, + LocalDateTime createdAt +) { + public static PostListResponse from(Post post) { + String writerNickname = post.isAnonymous() + ? "익명 (" + maskIp(post.getWriterIp()) + ")" + : (post.getMember() != null ? post.getMember().getNickname() : "알 수 없음"); + + String thumbnailUrl = post.getImages().isEmpty() ? null : post.getImages().get(0).getImageUrl(); + + return PostListResponse.builder() + .publicId(post.getPublicId()) + .categoryName(post.getCategory().getName()) + .categoryCode(post.getCategory().getCode()) + .title(post.getTitle()) + .writerNickname(writerNickname) + .thumbnailImageUrl(thumbnailUrl) + .viewCount(post.getViewCount()) + .commentCount(post.getCommentCount()) + .likeCount(post.getLikeCount()) + .dislikeCount(post.getDislikeCount()) + .status(post.getStatus()) + .createdAt(post.getCreatedAt()) + .build(); + } + + private static String maskIp(String ip) { + if (ip == null || ip.isBlank()) return "***.***.***.***"; + String[] parts = ip.split("\\."); + if (parts.length == 4) { + return parts[0] + "." + parts[1] + ".***.***"; + } + return ip; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostReactionRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostReactionRequest.java new file mode 100644 index 0000000..b646d7f --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostReactionRequest.java @@ -0,0 +1,9 @@ +package com.ikae.snowthing.domain.post.dto; + +import com.ikae.snowthing.domain.post.entity.ReactionType; +import jakarta.validation.constraints.NotNull; + +public record PostReactionRequest( + @NotNull(message = "투표 유형(LIKE/DISLIKE)은 필수 입력값입니다.") + ReactionType type +) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostResponse.java new file mode 100644 index 0000000..32c0a34 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostResponse.java @@ -0,0 +1,39 @@ +package com.ikae.snowthing.domain.post.dto; + +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostStatus; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record PostResponse( + String publicId, + String title, + String writerName, + PostStatus status, + LocalDateTime createdAt +) { + public static PostResponse from(Post post) { + String writerName = post.isAnonymous() + ? "익명 (" + maskIp(post.getWriterIp()) + ")" + : (post.getMember() != null ? post.getMember().getNickname() : "알 수 없음"); + + return PostResponse.builder() + .publicId(post.getPublicId()) + .title(post.getTitle()) + .writerName(writerName) + .status(post.getStatus()) + .createdAt(post.getCreatedAt()) + .build(); + } + + private static String maskIp(String ip) { + if (ip == null || ip.isBlank()) return "***.***.***.***"; + String[] parts = ip.split("\\."); + if (parts.length == 4) { + return parts[0] + "." + parts[1] + ".***.***"; + } + return ip; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostUpdateRequest.java new file mode 100644 index 0000000..ad6aac0 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/dto/PostUpdateRequest.java @@ -0,0 +1,31 @@ +package com.ikae.snowthing.domain.post.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Builder; + +import java.util.List; + +@Builder +public record PostUpdateRequest( + @NotBlank(message = "카테고리 코드는 필수 입력값입니다.") + String categoryCode, + + @NotBlank(message = "제목은 필수 입력값입니다.") + @Size(max = 200, message = "제목은 최대 200자까지 입력 가능합니다.") + String title, + + @NotBlank(message = "본문은 필수 입력값입니다.") + String content, + + String anonymousPassword, + List imageUrls +) { + public PostUpdateRequest { + if (imageUrls == null) { + imageUrls = List.of(); + } else { + imageUrls = List.copyOf(imageUrls); + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/Post.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/Post.java new file mode 100644 index 0000000..02a10e6 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/Post.java @@ -0,0 +1,136 @@ +package com.ikae.snowthing.domain.post.entity; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "post") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE post SET is_deleted = true, status = 'DELETED', deleted_at = NOW() WHERE post_id = ?") +@SQLRestriction("is_deleted = false") +public class Post extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "post_id") + private Long id; + + @Column(name = "public_id", nullable = false, unique = true, length = 36) + private String publicId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + private PostCategory category; + + @Column(nullable = false, length = 200) + private String title; + + @Lob + @Column(nullable = false) + private String content; + + @Column(name = "writer_ip", nullable = false, length = 45) + private String writerIp; + + @Column(name = "is_anonymous", nullable = false) + private boolean isAnonymous; + + @Column(name = "anonymous_password") + private String anonymousPassword; + + @Column(name = "view_count", nullable = false) + private int viewCount = 0; + + @Column(name = "comment_count", nullable = false) + private int commentCount = 0; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + @Column(name = "dislike_count", nullable = false) + private int dislikeCount = 0; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PostStatus status = PostStatus.NORMAL; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List images = new ArrayList<>(); + + @Builder + public Post(Member member, PostCategory category, String title, String content, + String writerIp, boolean isAnonymous, String anonymousPassword) { + this.publicId = UUID.randomUUID().toString(); + this.member = member; + this.category = category; + this.title = title; + this.content = content; + this.writerIp = writerIp; + this.isAnonymous = isAnonymous; + this.anonymousPassword = anonymousPassword; + this.status = PostStatus.NORMAL; + this.isDeleted = false; + } + + public void update(String title, String content, PostCategory category) { + this.title = title; + this.content = content; + this.category = category; + } + + public void softDelete() { + this.isDeleted = true; + this.status = PostStatus.DELETED; + this.deletedAt = LocalDateTime.now(); + } + + public void increaseViewCount() { + this.viewCount++; + } + + public void increaseCommentCount() { + this.commentCount++; + } + + public void decreaseCommentCount() { + if (this.commentCount > 0) { + this.commentCount--; + } + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void increaseDislikeCount() { + this.dislikeCount++; + } + + public void addImage(PostImage image) { + this.images.add(image); + image.setPost(this); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostCategory.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostCategory.java new file mode 100644 index 0000000..d58acf8 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostCategory.java @@ -0,0 +1,31 @@ +package com.ikae.snowthing.domain.post.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_category") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "category_id") + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, unique = true, length = 50) + private String code; + + @Builder + public PostCategory(String name, String code) { + this.name = name; + this.code = code; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostImage.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostImage.java new file mode 100644 index 0000000..d7a6388 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostImage.java @@ -0,0 +1,41 @@ +package com.ikae.snowthing.domain.post.entity; + +import com.ikae.snowthing.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "post_image") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + @Column(name = "sort_order", nullable = false) + private int sortOrder = 1; + + @Builder + public PostImage(Post post, String imageUrl, int sortOrder) { + this.post = post; + this.imageUrl = imageUrl; + this.sortOrder = sortOrder; + } + + public void setPost(Post post) { + this.post = post; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostReaction.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostReaction.java new file mode 100644 index 0000000..bcfe9eb --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostReaction.java @@ -0,0 +1,45 @@ +package com.ikae.snowthing.domain.post.entity; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "post_reaction", + uniqueConstraints = { + @UniqueConstraint(name = "uk_post_member_type", columnNames = {"post_id", "member_id", "type"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostReaction extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reaction_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private ReactionType type; + + @Builder + public PostReaction(Post post, Member member, ReactionType type) { + this.post = post; + this.member = member; + this.type = type; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostStatus.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostStatus.java new file mode 100644 index 0000000..d636ab0 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/PostStatus.java @@ -0,0 +1,8 @@ +package com.ikae.snowthing.domain.post.entity; + +public enum PostStatus { + NORMAL, + HIDDEN, + DELETED, + BLOCKED +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/entity/ReactionType.java b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/ReactionType.java new file mode 100644 index 0000000..b261d75 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/entity/ReactionType.java @@ -0,0 +1,6 @@ +package com.ikae.snowthing.domain.post.entity; + +public enum ReactionType { + LIKE, + DISLIKE +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEvent.java b/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEvent.java new file mode 100644 index 0000000..c79993c --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEvent.java @@ -0,0 +1,8 @@ +package com.ikae.snowthing.domain.post.event; + +import com.ikae.snowthing.domain.post.entity.ReactionType; + +public record PostReactionEvent( + Long postId, + ReactionType type +) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java b/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java new file mode 100644 index 0000000..d84d793 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java @@ -0,0 +1,37 @@ +package com.ikae.snowthing.domain.post.event; + +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.ReactionType; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PostReactionEventListener { + + private final PostRepository postRepository; + + @Async + @EventListener + @Transactional + public void handlePostReactionEvent(PostReactionEvent event) { + log.info("비동기 추천/비추천 수 카운트 갱신 처리 - postId: {}, type: {}", event.postId(), event.type()); + Post post = postRepository.findById(event.postId()).orElse(null); + if (post == null) { + log.warn("해당 게시글을 찾을 수 없습니다. postId: {}", event.postId()); + return; + } + + if (event.type() == ReactionType.LIKE) { + post.increaseLikeCount(); + } else if (event.type() == ReactionType.DISLIKE) { + post.increaseDislikeCount(); + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostCategoryRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostCategoryRepository.java new file mode 100644 index 0000000..2bd200e --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostCategoryRepository.java @@ -0,0 +1,11 @@ +package com.ikae.snowthing.domain.post.repository; + +import com.ikae.snowthing.domain.post.entity.PostCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PostCategoryRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostImageRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostImageRepository.java new file mode 100644 index 0000000..9cf8f3c --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostImageRepository.java @@ -0,0 +1,11 @@ +package com.ikae.snowthing.domain.post.repository; + +import com.ikae.snowthing.domain.post.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostImageRepository extends JpaRepository { + + List findByPostIdOrderBySortOrderAsc(Long postId); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostReactionRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostReactionRepository.java new file mode 100644 index 0000000..ad22dad --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostReactionRepository.java @@ -0,0 +1,13 @@ +package com.ikae.snowthing.domain.post.repository; + +import com.ikae.snowthing.domain.post.entity.PostReaction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PostReactionRepository extends JpaRepository { + + boolean existsByPostIdAndMemberIdAndType(Long postId, Long memberId, com.ikae.snowthing.domain.post.entity.ReactionType type); + + Optional findByPostIdAndMemberIdAndType(Long postId, Long memberId, com.ikae.snowthing.domain.post.entity.ReactionType type); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepository.java new file mode 100644 index 0000000..a63d4b2 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/repository/PostRepository.java @@ -0,0 +1,26 @@ +package com.ikae.snowthing.domain.post.repository; + +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.entity.PostStatus; +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.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface PostRepository extends JpaRepository { + + Optional findByPublicId(String publicId); + + @Query("SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.publicId = :publicId") + Optional findWithMemberAndCategoryByPublicId(@Param("publicId") String publicId); + + @Query("SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category WHERE p.category.code = :categoryCode") + Page findByCategoryCodeWithMemberAndCategory(@Param("categoryCode") String categoryCode, Pageable pageable); + + @Query(value = "SELECT p FROM Post p LEFT JOIN FETCH p.member JOIN FETCH p.category", + countQuery = "SELECT COUNT(p) FROM Post p") + Page findAllWithMemberAndCategory(Pageable pageable); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java b/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java new file mode 100644 index 0000000..ad51154 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java @@ -0,0 +1,219 @@ +package com.ikae.snowthing.domain.post.service; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.*; +import com.ikae.snowthing.domain.post.entity.*; +import com.ikae.snowthing.domain.post.event.PostReactionEvent; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostImageRepository; +import com.ikae.snowthing.domain.post.repository.PostReactionRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostService { + + private final PostRepository postRepository; + private final PostCategoryRepository categoryRepository; + private final PostImageRepository imageRepository; + private final PostReactionRepository reactionRepository; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public PostResponse createPost(PostCreateRequest request, CustomUserDetails userDetails, String clientIp) { + PostCategory category = categoryRepository.findByCode(request.categoryCode()) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_CATEGORY_NOT_FOUND)); + + Member member = null; + String encodedPassword = null; + + boolean isAnon = request.isAnonymous() || "ANONYMOUS".equalsIgnoreCase(category.getCode()); + + if (isAnon) { + if (request.anonymousPassword() == null || request.anonymousPassword().isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + encodedPassword = passwordEncoder.encode(request.anonymousPassword()); + } else { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); + } + member = memberRepository.findByPublicId(userDetails.getPublicId()) + .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); + } + + Post post = Post.builder() + .member(member) + .category(category) + .title(request.title()) + .content(request.content()) + .writerIp(clientIp != null ? clientIp : "127.0.0.1") + .isAnonymous(isAnon) + .anonymousPassword(encodedPassword) + .build(); + + Post savedPost = postRepository.save(post); + + if (request.imageUrls() != null && !request.imageUrls().isEmpty()) { + int sortOrder = 1; + for (String imageUrl : request.imageUrls()) { + PostImage image = PostImage.builder() + .post(savedPost) + .imageUrl(imageUrl) + .sortOrder(sortOrder++) + .build(); + imageRepository.save(image); + savedPost.addImage(image); + } + } + + return PostResponse.from(savedPost); + } + + @Transactional + public PostDetailResponse getPostDetail(String publicId, CustomUserDetails userDetails) { + Post post = postRepository.findWithMemberAndCategoryByPublicId(publicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted()) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + if (post.getStatus() != PostStatus.NORMAL) { + boolean isAdmin = userDetails != null && userDetails.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + if (!isAdmin) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + } + + post.increaseViewCount(); + return PostDetailResponse.from(post); + } + + public Page getPostList(String categoryCode, int page, int size) { + if (size < 1 || size > 100) { + throw new CustomAuthException(ErrorCode.INVALID_PAGE_SIZE); + } + + Pageable pageable = PageRequest.of( + page, + size, + Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id")) + ); + + Page posts; + if (categoryCode != null && !categoryCode.isBlank()) { + posts = postRepository.findByCategoryCodeWithMemberAndCategory(categoryCode, pageable); + } else { + posts = postRepository.findAllWithMemberAndCategory(pageable); + } + + return posts.map(PostListResponse::from); + } + + @Transactional + public PostResponse updatePost(String publicId, PostUpdateRequest request, CustomUserDetails userDetails) { + Post post = postRepository.findByPublicId(publicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted()) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + validateUpdateOrDeletePermission(post, request.anonymousPassword(), userDetails); + + PostCategory category = categoryRepository.findByCode(request.categoryCode()) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_CATEGORY_NOT_FOUND)); + + post.update(request.title(), request.content(), category); + return PostResponse.from(post); + } + + @Transactional + public void deletePost(String publicId, String anonymousPassword, CustomUserDetails userDetails) { + Post post = postRepository.findByPublicId(publicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted()) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + validateUpdateOrDeletePermission(post, anonymousPassword, userDetails); + + post.softDelete(); + } + + @Transactional + public void reactToPost(String publicId, ReactionType type, CustomUserDetails userDetails) { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + Post post = postRepository.findByPublicId(publicId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted()) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + Member member = memberRepository.findByPublicId(userDetails.getPublicId()) + .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); + + PostReaction reaction = PostReaction.builder() + .post(post) + .member(member) + .type(type) + .build(); + + try { + reactionRepository.save(reaction); + } catch (DataIntegrityViolationException e) { + log.warn("중복 추천/비추천 투표 감지 - memberId: {}, postId: {}", member.getId(), post.getId()); + throw new CustomAuthException(ErrorCode.ALREADY_REACTED); + } + + eventPublisher.publishEvent(new PostReactionEvent(post.getId(), type)); + } + + private void validateUpdateOrDeletePermission(Post post, String anonymousPassword, CustomUserDetails userDetails) { + if (post.isAnonymous()) { + if (anonymousPassword == null || !passwordEncoder.matches(anonymousPassword, post.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + } + } else { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + boolean isAdmin = userDetails.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + + boolean isWriter = post.getMember() != null && post.getMember().getPublicId().equals(userDetails.getPublicId()); + + if (!isAdmin && !isWriter) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + } + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java index 5a1114b..04184e8 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java @@ -10,12 +10,16 @@ import java.util.List; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; + @Component @RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { private final ResortRepository resortRepository; private final RidingStyleRepository ridingStyleRepository; + private final PostCategoryRepository categoryRepository; @Override public void run(String... args) { @@ -40,5 +44,15 @@ public void run(String... args) { RidingStyle.builder().styleName("관광 / 크루징").description("풍경 감상 및 여유로운 라이딩").build() )); } + + if (categoryRepository.count() == 0) { + categoryRepository.saveAll(List.of( + PostCategory.builder().name("자유게시판").code("FREE").build(), + PostCategory.builder().name("익명게시판").code("ANONYMOUS").build(), + PostCategory.builder().name("질문게시판").code("QNA").build(), + PostCategory.builder().name("장비VS").code("GEAR_VS").build(), + PostCategory.builder().name("맛집게시판").code("FOOD").build() + )); + } } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java index 127e928..609c202 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java @@ -3,6 +3,8 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -12,6 +14,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -27,6 +30,11 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); @@ -60,7 +68,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionFixation(sessionFixation -> sessionFixation.changeSessionId()) ) .logout(logout -> logout - .logoutUrl("/api/auth/logout") + .logoutRequestMatcher(new AntPathRequestMatcher("/api/auth/logout", "POST")) .invalidateHttpSession(true) .clearAuthentication(true) .deleteCookies("JSESSIONID") @@ -74,18 +82,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); + response.getWriter().write("{\"error\":\"UNAUTHORIZED\",\"code\":\"AUTH_001\",\"message\":\"로그인이 필요합니다.\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write("{\"error\":\"FORBIDDEN\"}"); + response.getWriter().write("{\"error\":\"FORBIDDEN\",\"code\":\"AUTH_002\",\"message\":\"접근 권한이 없습니다.\"}"); }) ) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/members", "/api/auth/login", "/api/resorts", "/api/riding-styles").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") - .requestMatchers("/api/members/me", "/api/auth/logout").authenticated() + .requestMatchers("/api/members/me").authenticated() .anyRequest().authenticated() ); diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java new file mode 100644 index 0000000..b6ffbdb --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -0,0 +1,30 @@ +package com.ikae.snowthing.global.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 일치하지 않습니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH_002", "해당 작업을 수행할 권한이 없습니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_001", "존재하지 않는 회원입니다."), + DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "MEMBER_002", "이미 사용 중인 이메일입니다."), + DUPLICATE_NICKNAME(HttpStatus.BAD_REQUEST, "MEMBER_003", "이미 사용 중인 닉네임입니다."), + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_001", "존재하지 않거나 삭제된 게시글입니다."), + POST_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_002", "존재하지 않는 게시판 카테고리입니다."), + ALREADY_REACTED(HttpStatus.CONFLICT, "POST_003", "이미 추천 또는 비추천 투표를 완료한 게시글입니다."), + INVALID_ANON_PASSWORD(HttpStatus.FORBIDDEN, "POST_004", "비회원 익명 비밀번호가 일치하지 않습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않거나 이미 삭제된 댓글입니다."), + PARENT_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_002", "존재하지 않는 부모 댓글입니다."), + INVALID_COMMENT_PARENT(HttpStatus.BAD_REQUEST, "COMMENT_003", "동일한 게시글의 댓글에만 대댓글을 달 수 있습니다."), + INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다."), + INVALID_PAGE_SIZE(HttpStatus.BAD_REQUEST, "COMMON_002", "페이지 크기는 1 이상 100 이하이어야 합니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorResponse.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorResponse.java new file mode 100644 index 0000000..0e923c0 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorResponse.java @@ -0,0 +1,15 @@ +package com.ikae.snowthing.global.error; + +public record ErrorResponse( + String code, + String error, + String message +) { + public static ErrorResponse from(ErrorCode errorCode) { + return new ErrorResponse(errorCode.getCode(), errorCode.name(), errorCode.getMessage()); + } + + public static ErrorResponse of(ErrorCode errorCode, String customMessage) { + return new ErrorResponse(errorCode.getCode(), errorCode.name(), customMessage); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/CustomAuthException.java b/backend/src/main/java/com/ikae/snowthing/global/exception/CustomAuthException.java new file mode 100644 index 0000000..ddce5db --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/CustomAuthException.java @@ -0,0 +1,20 @@ +package com.ikae.snowthing.global.exception; + +import com.ikae.snowthing.global.error.ErrorCode; +import lombok.Getter; + +@Getter +public class CustomAuthException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomAuthException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomAuthException(String message) { + super(message); + this.errorCode = ErrorCode.INVALID_CREDENTIALS; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java index 243f8b8..0cd44a0 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/ikae/snowthing/global/exception/GlobalExceptionHandler.java @@ -1,27 +1,49 @@ package com.ikae.snowthing.global.exception; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.error.ErrorResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.Map; - @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(CustomAuthException.class) + public ResponseEntity handleCustomAuthException(CustomAuthException e) { + ErrorCode errorCode = e.getErrorCode() != null ? e.getErrorCode() : ErrorCode.INVALID_CREDENTIALS; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.from(errorCode)); + } + + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) + public ResponseEntity handleAuthenticationException(Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.from(ErrorCode.INVALID_CREDENTIALS)); + } + @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { if ("INVALID_CREDENTIALS".equals(e.getMessage())) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", e.getMessage())); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ErrorResponse.from(ErrorCode.INVALID_CREDENTIALS)); + } + if ("DUPLICATE_EMAIL".equals(e.getMessage())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.from(ErrorCode.DUPLICATE_EMAIL)); + } + if ("DUPLICATE_NICKNAME".equals(e.getMessage())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.from(ErrorCode.DUPLICATE_NICKNAME)); } - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", e.getMessage())); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, e.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { String defaultMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", defaultMessage != null ? defaultMessage : "INVALID_INPUT")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, defaultMessage != null ? defaultMessage : ErrorCode.INVALID_INPUT.getMessage())); } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetails.java b/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetails.java new file mode 100644 index 0000000..6a2bd0a --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetails.java @@ -0,0 +1,61 @@ +package com.ikae.snowthing.global.security; + +import com.ikae.snowthing.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(member.getRole().getKey())); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } + + public String getPublicId() { + return member.getPublicId(); + } + + public String getNickname() { + return member.getNickname(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return member.getStatus() != com.ikae.snowthing.domain.member.entity.MemberStatus.SUSPENDED; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return member.getStatus() == com.ikae.snowthing.domain.member.entity.MemberStatus.ACTIVE; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetailsService.java b/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetailsService.java new file mode 100644 index 0000000..9352b7d --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/security/CustomUserDetailsService.java @@ -0,0 +1,25 @@ +package com.ikae.snowthing.global.security; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException(ErrorCode.MEMBER_NOT_FOUND.getMessage())); + return new CustomUserDetails(member); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/post/controller/PostControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/post/controller/PostControllerTest.java new file mode 100644 index 0000000..b93ff76 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/post/controller/PostControllerTest.java @@ -0,0 +1,157 @@ +package com.ikae.snowthing.domain.post.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.security.CustomUserDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class PostControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PostCategoryRepository categoryRepository; + + @Autowired + private PostService postService; + + @Autowired + private PasswordEncoder passwordEncoder; + + private Member member; + private CustomUserDetails userDetails; + + @BeforeEach + void setUp() { + categoryRepository.findByCode("FREE") + .orElseGet(() -> categoryRepository.save(PostCategory.builder().name("자유게시판").code("FREE").build())); + + member = memberRepository.save(Member.builder() + .email("testuser@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("컨트롤러보더") + .role(Role.ROLE_USER) + .build()); + + userDetails = new CustomUserDetails(member); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()) + ); + } + + @Test + @DisplayName("POST /api/posts - 정상 게시글 작성 201 Created") + void createPost_success() throws Exception { + PostCreateRequest request = PostCreateRequest.builder() + .categoryCode("FREE") + .title("컨트롤러 통합 테스트 제목") + .content("컨트롤러 통합 테스트 본문") + .isAnonymous(false) + .build(); + + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.publicId").exists()) + .andExpect(jsonPath("$.title").value("컨트롤러 통합 테스트 제목")) + .andExpect(jsonPath("$.writerName").value("컨트롤러보더")); + } + + @Test + @DisplayName("POST /api/posts - 빈 제목 입력 시 400 Bad Request") + void createPost_emptyTitle_badRequest() throws Exception { + PostCreateRequest request = PostCreateRequest.builder() + .categoryCode("FREE") + .title("") + .content("본문 내용") + .isAnonymous(false) + .build(); + + mockMvc.perform(post("/api/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("GET /api/posts/{publicId} - 정상 상세 조회 200 OK") + void getPostDetail_success() throws Exception { + PostResponse post = postService.createPost(PostCreateRequest.builder() + .categoryCode("FREE") + .title("상세조회 테스트") + .content("상세본문") + .isAnonymous(false) + .build(), userDetails, "127.0.0.1"); + + mockMvc.perform(get("/api/posts/" + post.publicId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("상세조회 테스트")) + .andExpect(jsonPath("$.viewCount").value(1)); + } + + @Test + @DisplayName("GET /api/posts - 목록 페이징 조회 200 OK") + void getPostList_success() throws Exception { + postService.createPost(PostCreateRequest.builder() + .categoryCode("FREE") + .title("목록 테스트 1") + .content("본문 1") + .isAnonymous(false) + .build(), userDetails, "127.0.0.1"); + + mockMvc.perform(get("/api/posts") + .param("categoryCode", "FREE") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].title").value("목록 테스트 1")); + } + + @Test + @DisplayName("DELETE /api/posts/{publicId} - 정상 삭제 200 OK") + void deletePost_success() throws Exception { + PostResponse post = postService.createPost(PostCreateRequest.builder() + .categoryCode("FREE") + .title("삭제 테스트") + .content("본문 내용") + .isAnonymous(false) + .build(), userDetails, "127.0.0.1"); + + mockMvc.perform(delete("/api/posts/" + post.publicId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java new file mode 100644 index 0000000..790bed8 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java @@ -0,0 +1,286 @@ +package com.ikae.snowthing.domain.post.service; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.*; +import com.ikae.snowthing.domain.post.entity.*; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.repository.PostReactionRepository; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +class PostServiceTest { + + @Autowired + private PostService postService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostCategoryRepository categoryRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PostReactionRepository reactionRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private Member member1; + private Member member2; + private CustomUserDetails userDetails1; + private CustomUserDetails userDetails2; + private PostCategory freeCategory; + + @BeforeEach + void setUp() { + member1 = memberRepository.save(Member.builder() + .email("user1@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("보더1호") + .role(Role.ROLE_USER) + .build()); + + member2 = memberRepository.save(Member.builder() + .email("user2@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("보더2호") + .role(Role.ROLE_USER) + .build()); + + userDetails1 = new CustomUserDetails(member1); + userDetails2 = new CustomUserDetails(member2); + + freeCategory = categoryRepository.findByCode("FREE") + .orElseGet(() -> categoryRepository.save(PostCategory.builder().name("자유게시판").code("FREE").build())); + } + + @Nested + @DisplayName("게시글 작성 테스트") + class CreatePostTest { + + @Test + @DisplayName("로그인 회원이 정상적으로 게시글을 작성한다.") + void createPost_success_member() { + PostCreateRequest request = PostCreateRequest.builder() + .categoryCode("FREE") + .title("오늘 설질 어떤가요?") + .content("휘팍 설질 최고입니다.") + .isAnonymous(false) + .imageUrls(List.of("https://cdn.example.com/1.jpg")) + .build(); + + PostResponse response = postService.createPost(request, userDetails1, "127.0.0.1"); + + assertThat(response.publicId()).isNotNull(); + assertThat(response.title()).isEqualTo("오늘 설질 어떤가요?"); + assertThat(response.writerName()).isEqualTo("보더1호"); + assertThat(response.status()).isEqualTo(PostStatus.NORMAL); + } + + @Test + @DisplayName("비회원이 익명 게시글을 정상적으로 작성한다.") + void createPost_success_anonymous() { + PostCreateRequest request = PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명 질문입니다.") + .content("입문용 데크 추천해 주세요.") + .isAnonymous(true) + .anonymousPassword("Anon1234!") + .build(); + + PostResponse response = postService.createPost(request, null, "192.168.1.100"); + + assertThat(response.publicId()).isNotNull(); + assertThat(response.writerName()).contains("익명"); + } + + @Test + @DisplayName("비인증 유저가 회원 게시글(isAnonymous=false) 작성을 시도하면 예외가 터진다.") + void createPost_unauthorized() { + PostCreateRequest request = PostCreateRequest.builder() + .categoryCode("FREE") + .title("비인증 게시글") + .content("본문 내용") + .isAnonymous(false) + .build(); + + assertThatThrownBy(() -> postService.createPost(request, null, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_CREDENTIALS); + } + } + + @Nested + @DisplayName("게시글 상세 및 목록 조회 테스트") + class GetPostTest { + + @Test + @DisplayName("정상 게시글 상세 조회 시 조회수가 1 증가한다.") + void getPostDetail_success() { + PostCreateRequest request = PostCreateRequest.builder() + .categoryCode("FREE") + .title("조회수 테스트") + .content("상세 내용") + .isAnonymous(false) + .build(); + + PostResponse created = postService.createPost(request, userDetails1, "127.0.0.1"); + + PostDetailResponse detail = postService.getPostDetail(created.publicId(), userDetails1); + + assertThat(detail.title()).isEqualTo("조회수 테스트"); + assertThat(detail.viewCount()).isEqualTo(1); + } + + @Test + @DisplayName("존재하지 않는 publicId 조회 시 404 예외가 터진다.") + void getPostDetail_notFound() { + assertThatThrownBy(() -> postService.getPostDetail("non-existent-uuid", userDetails1)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("목록 조회 시 본문(content)이 제외되고 최신순/id역순으로 페이징 조회된다.") + void getPostList_success() { + for (int i = 1; i <= 5; i++) { + postService.createPost(PostCreateRequest.builder() + .categoryCode("FREE") + .title("테스트 글 " + i) + .content("본문 내용 " + i) + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + } + + Page page = postService.getPostList("FREE", 0, 10); + + assertThat(page.getTotalElements()).isEqualTo(5); + assertThat(page.getContent().get(0).title()).isEqualTo("테스트 글 5"); + } + + @Test + @DisplayName("잘못된 페이지 크기(size=0 이하) 입력 시 예외가 터진다.") + void getPostList_invalidPageSize() { + assertThatThrownBy(() -> postService.getPostList("FREE", 0, 0)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_PAGE_SIZE); + } + } + + @Nested + @DisplayName("게시글 수정 및 삭제 권한 테스트") + class UpdateAndDeletePostTest { + + private PostResponse createdPost; + + @BeforeEach + void setUpPost() { + createdPost = postService.createPost(PostCreateRequest.builder() + .categoryCode("FREE") + .title("수정전 제목") + .content("수정전 본문") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + } + + @Test + @DisplayName("작성자 본인은 게시글을 정상 수정한다.") + void updatePost_success_writer() { + PostUpdateRequest updateReq = PostUpdateRequest.builder() + .categoryCode("FREE") + .title("수정후 제목") + .content("수정후 본문") + .build(); + + PostResponse updated = postService.updatePost(createdPost.publicId(), updateReq, userDetails1); + + assertThat(updated.title()).isEqualTo("수정후 제목"); + } + + @Test + @DisplayName("타 회원이 수정 시도 시 403 Forbidden 예외가 터진다.") + void updatePost_forbidden_otherUser() { + PostUpdateRequest updateReq = PostUpdateRequest.builder() + .categoryCode("FREE") + .title("타인 해킹 시도") + .content("해킹 본문") + .build(); + + assertThatThrownBy(() -> postService.updatePost(createdPost.publicId(), updateReq, userDetails2)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("작성자 본인은 Soft Delete로 게시글을 정상 삭제한다.") + void deletePost_success_softDelete() { + postService.deletePost(createdPost.publicId(), null, userDetails1); + + assertThatThrownBy(() -> postService.getPostDetail(createdPost.publicId(), userDetails1)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + + @Test + @DisplayName("이미 삭제된 게시글에 대해 삭제를 재시도하면 404 예외가 터진다.") + void deletePost_alreadyDeleted() { + postService.deletePost(createdPost.publicId(), null, userDetails1); + + assertThatThrownBy(() -> postService.deletePost(createdPost.publicId(), null, userDetails1)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.POST_NOT_FOUND); + } + } + + @Nested + @DisplayName("추천/비추천 중복 제약 조건 테스트") + class ReactionTest { + + @Test + @DisplayName("동일 회원 중복 추천 시 DB UNIQUE 제약 조건에 걸려 ALREADY_REACTED 예외가 터진다.") + void reactToPost_duplicate_throwsAlreadyReacted() { + PostResponse post = postService.createPost(PostCreateRequest.builder() + .categoryCode("FREE") + .title("추천 테스트 글") + .content("내용") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + postService.reactToPost(post.publicId(), ReactionType.LIKE, userDetails1); + + assertThatThrownBy(() -> postService.reactToPost(post.publicId(), ReactionType.LIKE, userDetails1)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ALREADY_REACTED); + } + } +} diff --git a/docs/conception/sprint01/03.domain-model.md b/docs/conception/sprint01/03.domain-model.md index 5b4717f..dea3a79 100644 --- a/docs/conception/sprint01/03.domain-model.md +++ b/docs/conception/sprint01/03.domain-model.md @@ -36,10 +36,10 @@ Snowthing 플랫폼의 비즈니스 영역을 4개의 **Bounded Context (도메 * **도메인 목적**: 윈터스포츠 유저 간의 자유로운 정보 교류, 질의응답, 노하우 공유 및 추천/댓글 소통. * **핵심 도메인 객체 (Entities & Value Objects)**: * **`PostCategory` (카테고리)**: 자유게시판(`FREE`), 익명게시판(`ANONYMOUS`), 질문(`QNA`), 장비VS(`GEAR_VS`), 맛집(`FOOD`) 분류. - * **`Post` (게시글)**: 제목, 본문, 작성자 IP (`writer_ip`), 비회원 비밀번호 (`anonymous_password` BCrypt), 조회수, 카운트 역정규화(댓글수/추천수/비추천수), **게시글 상태 (`status`: `NORMAL`, `HIDDEN`, `DELETED`, `BLOCKED`)**, Soft Delete 여부. + * **`Post` (게시글)**: 제목, 본문, 작성자 IP (`writer_ip`), 비회원 비밀번호 (`anonymous_password` BCrypt), 조회수, 카운트 역정규화(댓글수/추천수/비추천수), **게시글 상태 (`status`: `NORMAL`, `HIDDEN`, `DELETED`, `BLOCKED`)**, Soft Delete 여부 (`is_deleted`), **삭제 시각 (`deleted_at` NULLABLE)**. * **`PostImage` (첨부 이미지)**: 게시글 1:N 첨부 이미지 파일. * **`PostReaction` (게시글 반응)**: 추천(`LIKE`) 및 비추천(`DISLIKE`) (1인 1회 중복 투표 제한, `@Async` 비동기 처리). - * **`Comment` (댓글/대댓글)**: 원댓글 및 계층형 대댓글 (`parentId`), 작성자 IP, 비회원 비밀번호, Soft Delete 여부. + * **`Comment` (댓글/대댓글)**: 원댓글 및 계층형 대댓글 (`parentId`), 작성자 IP, 비회원 비밀번호, Soft Delete 여부 (`is_deleted`), **삭제 시각 (`deleted_at` NULLABLE)**. --- diff --git a/docs/conception/sprint01/04.erd.md b/docs/conception/sprint01/04.erd.md index 75ac0ae..bb58e81 100644 --- a/docs/conception/sprint01/04.erd.md +++ b/docs/conception/sprint01/04.erd.md @@ -27,6 +27,7 @@ erDiagram MEMBER ||--o{ POST_REACTION : "투표함 (1:N)" POST ||--o{ COMMENT : "달림 (1:N)" + MEMBER ||--o{ MEMBER_PROFILE_IMAGE : "등록함 (1:N 대표1개+서브4개)" MEMBER ||--o{ COMMENT : "작성함 (1:N)" COMMENT ||--o{ COMMENT : "대댓글 (Self Reference)" @@ -36,7 +37,7 @@ erDiagram VARCHAR(100) email UK "로그인 이메일" VARCHAR(255) password "NULLABLE (OAuth2 대비)" VARCHAR(50) nickname UK "활동 닉네임" - VARCHAR(500) profile_image_url + VARCHAR(500) profile_image_url "대표 프로필 이미지 URL (역정규화)" VARCHAR(255) bio VARCHAR(100) departure_region BIGINT crew_id FK "소속 크루 ID" @@ -47,6 +48,15 @@ erDiagram DATETIME updated_at } + MEMBER_PROFILE_IMAGE { + BIGINT image_id PK "DB 내부 PK" + BIGINT member_id FK "회원 ID" + VARCHAR(500) image_url "프로필 이미지 URL" + BOOLEAN is_main "대표 프로필 여부" + INT sort_order "슬라이드 렌더링 순서 (0~4)" + DATETIME created_at + } + CREW { BIGINT crew_id PK VARCHAR(36) public_id UK @@ -101,6 +111,7 @@ erDiagram INT dislike_count "비추천 수" VARCHAR(20) status "게시글 상태 (NORMAL, HIDDEN, DELETED, BLOCKED)" BOOLEAN is_deleted "Soft Delete 여부" + DATETIME deleted_at "Soft Delete 시각 (NULLABLE)" DATETIME created_at "작성 일시" DATETIME updated_at } @@ -131,6 +142,7 @@ erDiagram BOOLEAN is_anonymous VARCHAR(255) anonymous_password BOOLEAN is_deleted "Soft Delete 적용" + DATETIME deleted_at "Soft Delete 시각 (NULLABLE)" DATETIME created_at DATETIME updated_at } @@ -228,6 +240,7 @@ erDiagram | `dislike_count` | INT | NOT NULL, DEFAULT 0 | **비추천 수 역정규화** | | `status` | VARCHAR(20) | NOT NULL, DEFAULT 'NORMAL' | 게시글 상태 (`NORMAL`, `HIDDEN`, `DELETED`, `BLOCKED`) | | `is_deleted` | BOOLEAN | NOT NULL, DEFAULT FALSE | **Soft Delete** 여부 | +| `deleted_at` | DATETIME | NULL, DEFAULT NULL | **Soft Delete** 처리 시각 | | `created_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP, INDEX | 작성 일시 | | `updated_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 수정 일시 | @@ -262,6 +275,7 @@ erDiagram | `is_anonymous` | BOOLEAN | NOT NULL, DEFAULT FALSE | 익명 작성 여부 | | `anonymous_password` | VARCHAR(255) | NULLABLE | 비회원 익명 비밀번호 (BCrypt) | | `is_deleted` | BOOLEAN | NOT NULL, DEFAULT FALSE | **Soft Delete** 적용 | +| `deleted_at` | DATETIME | NULL, DEFAULT NULL | **Soft Delete** 처리 시각 | | `created_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 작성 일시 | | `updated_at` | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 수정 일시 | diff --git a/docs/project/project.md b/docs/project/project.md index c03fd54..a0fec66 100644 --- a/docs/project/project.md +++ b/docs/project/project.md @@ -1,89 +1,111 @@ -## AI 게시글 요약 - -- 게시글 본문만 요약 -- 자유게시판/익명게시판 시간대별 흐름 요약 -- 글이 너무 적으면 “요약할 내용 부족으로 따로 요약 안함” - -## 자유게시판 / 댓글 / 대댓글 - -- 댓글은 무한 대댓글? -- 댓글 조회는 parent_id 구조로 가져오거나, 깊이 제한 후 정렬해서 조립 -- 댓글 삭제를 물리 삭제할지, 소프트 삭제할지 - - 상위 댓글 삭제 시 하위 대댓글은 남기고 “삭제된 댓글입니다” 처리 -- 댓글/대댓글 알림 범위 필요 - - 예: 내 글에 댓글, 내 댓글에 대댓글, 내가 멘션당함 -- 댓글 이미지 허용 여부 결 - - 이미지 허용 시 용량 제한, 개수는 하나 -- 조회수 증가 조건 필요 -- 예: 같은 유저/IP는 일정 시간 내 1회만 증가 -- 본인 조회는 카운트할지 제외할지 결정 필요 - -## 카풀 비용 계산기 - -- 출발지, 목적지, 경유지 입력 필요 -- 톨비 API나 지도 API 연동 가능 여부 확인 -- 인원 수로 1인 비용 자동 계산 -- 편도 -- 실제 정산은 서비스가 하지 않고 계산만 제공하는 방식 추천 -- 카풀 모집글과 연결 - -## 카테고리별 장비 VS - -- 카테고리 필요 - - 예: 데크,부츠, 바인딩, 고글 -- VS 후보를 운영자가 만들지, 유저 투표로 만들지 -- 인기 장비끼리 자동 매칭 가능 - 같은 카테고리 내에서 -- 투표 댓글 전부 익명 -- AI가 일부러 논쟁 질문을 던지는 방식 - - 예: 입문자한테 이 데크는 오버스펙 아닌가요? -- 승패 결과를 누적해서 장비 랭킹으로 확장 가능 - -## 리조트 맛집 - -- 리조트별 맛집 등록 -- 메뉴 추천, 가격대, 영업시간 -- 시즌 중 휴무/브레이크타임 -- 맛집 댓글/평점/사진 기능 - -## 리프트 줄 제보 - -- 스키장 웹캠/API에서 이미지 한 프레임 수집 -- AI가 리프트별 혼잡도 판단 -- 상태값은 적음 / 보통 / 혼잡 / 판단불가 -- 날씨, 야간, 안개, 카메라 가림은 판단불가 -- 원본 이미지는 저장하지 않고 분석 결과만 저장 -- 리조트별/리프트별 최근 갱신 시간 표시 필요 -- 너무 자주 호출하면 비용 문제 생기므로 주기 조절 필요 - -## 강습매칭 - -- 결제는 서비스 밖에서 알아서 -- 서비스는 강사 소개와 대화 연결만 제공 -- 강사 프로필 필요 - - 예: 경력, 가능 리조트, 가능 시간, 강습 레벨, 스타일, 후기 -- 사용자 요청 조건 필요 - - 예: 초보/중급/상급, 카빙, 트릭, 여성 강사 선호, 촬영 포함 -- 매칭 후 쪽지나 채팅으로 연결 -- 강습 후 후기 작성 가능 -- 허위 강사/사기 방지를 위한 신고 기능 필요 - -## 보더명함 - -- 베이스 리조트, 장비, 주 활동 요일/시간 표시 -- 라이딩 스타일 표시 - - 예: 카빙, 트릭, 파크, 관광, 촬영 -- 쪽지 -- 상대 보더명함 저장 기능 -- 저장한 명함 목록에서 다시 쪽지 가능 -- 공개 범위 설정 필요 -- QR 코드나 공유 이미지로 확장 가능 - -## 같이타요 매칭 - -- 사용자가 매칭 ON/OFF 설정 -- 같은 리조트, 날짜, 시간대, 실력, 스타일, 성향 등을 기준 매칭 -- 예: 빡연습, 설렁설렁, 촬영 위주, 초보 환영 -- 매칭되면 서로 보더명함 확인 -- 바로 채팅보다 매칭 요청 → 수락 → 채팅 -- 원치 않는 매칭 방지를 위해 차단/신고 필요 -- 성별/나이 공개 여부는 민감하므로 선택 공개 추천 \ No newline at end of file +## AI 게시글 요약 + +- 게시글 본문만 요약 +- 자유게시판/익명게시판 시간대별 흐름 요약 +- 글이 너무 적으면 “요약할 내용 부족으로 따로 요약 안함” + +## 자유게시판 / 댓글 / 대댓글 + +- 댓글은 무한 대댓글? +- 댓글 조회는 parent_id 구조로 가져오거나, 깊이 제한 후 정렬해서 조립 +- 댓글 삭제를 물리 삭제할지, 소프트 삭제할지 + - 상위 댓글 삭제 시 하위 대댓글은 남기고 “삭제된 댓글입니다” 처리 +- 댓글/대댓글 알림 범위 필요 + - 예: 내 글에 댓글, 내 댓글에 대댓글, 내가 멘션당함 +- 댓글 이미지 허용 여부 결 + - 이미지 허용 시 용량 제한, 개수는 하나 +- 조회수 증가 조건 필요 +- 예: 같은 유저/IP는 일정 시간 내 1회만 증가 +- 본인 조회는 카운트할지 제외할지 결정 필요 + +## 카풀 비용 계산기 + +- 출발지, 목적지, 경유지 입력 필요 +- 톨비 API나 지도 API 연동 가능 여부 확인 +- 인원 수로 1인 비용 자동 계산 +- 편도 +- 실제 정산은 서비스가 하지 않고 계산만 제공하는 방식 추천 +- 카풀 모집글과 연결 + +## 카테고리별 장비 VS + +- 카테고리 필요 + - 예: 데크,부츠, 바인딩, 고글 +- VS 후보를 운영자가 만들지, 유저 투표로 만들지 +- 인기 장비끼리 자동 매칭 가능 - 같은 카테고리 내에서 +- 투표 댓글 전부 익명 +- AI가 일부러 논쟁 질문을 던지는 방식 + - 예: 입문자한테 이 데크는 오버스펙 아닌가요? +- 승패 결과를 누적해서 장비 랭킹으로 확장 가능 + +## 리조트 맛집 + +- 리조트별 맛집 등록 +- 메뉴 추천, 가격대, 영업시간 +- 시즌 중 휴무/브레이크타임 +- 맛집 댓글/평점/사진 기능 + +## 리프트 줄 제보 + +- 스키장 웹캠/API에서 이미지 한 프레임 수집 +- AI가 리프트별 혼잡도 판단 +- 상태값은 적음 / 보통 / 혼잡 / 판단불가 +- 날씨, 야간, 안개, 카메라 가림은 판단불가 +- 원본 이미지는 저장하지 않고 분석 결과만 저장 +- 리조트별/리프트별 최근 갱신 시간 표시 필요 +- 너무 자주 호출하면 비용 문제 생기므로 주기 조절 필요 + +## 강습매칭 + +- 결제는 서비스 밖에서 알아서 +- 서비스는 강사 소개와 대화 연결만 제공 +- 강사 프로필 필요 + - 예: 경력, 가능 리조트, 가능 시간, 강습 레벨, 스타일, 후기 +- 사용자 요청 조건 필요 + - 예: 초보/중급/상급, 카빙, 트릭, 여성 강사 선호, 촬영 포함 +- 매칭 후 쪽지나 채팅으로 연결 +- 강습 후 후기 작성 가능 +- 허위 강사/사기 방지를 위한 신고 기능 필요 + +## 보더명함 + +- 베이스 리조트, 장비, 주 활동 요일/시간 표시 +- 라이딩 스타일 표시 + - 예: 카빙, 트릭, 파크, 관광, 촬영 +- 쪽지 +- 상대 보더명함 저장 기능 +- 저장한 명함 목록에서 다시 쪽지 가능 +- 공개 범위 설정 필요 +- QR 코드나 공유 이미지로 확장 가능 + +## 같이타요 매칭 + +- 사용자가 매칭 ON/OFF 설정 +- 같은 리조트, 날짜, 시간대, 실력, 스타일, 성향 등을 기준 매칭 +- 예: 빡연습, 설렁설렁, 촬영 위주, 초보 환영 +- 매칭되면 서로 보더명함 확인 +- 바로 채팅보다 매칭 요청 → 수락 → 채팅 +- 원치 않는 매칭 방지를 위해 차단/신고 필요 +- 성별/나이 공개 여부는 민감하므로 선택 공개 추천 + +## 회원 다중 프로필 사진 (대표 1개 + 서브 4개) 및 갤러리 슬라이더 + +### 1. 구상 개요 & 목적 +- 프로필 설정 시 회원은 **대표 프로필 사진 1장 (`is_main = true`) + 추가 갤러리 사진 최대 4장 (총 5장)**을 등록 가능. +- 스노보더 특성상 라이딩 샷, 칼날 카빙 샷, 기물/트릭 샷, 장비 샷, 셀카 등 다양한 사진으로 유저를 개성 있게 표현. + +### 2. 노출 및 UI/UX 정책 +- **댓글 / 게시글 목록 노출**: + - 목록 조회 시 조인 쿼리 횟수와 데이터 전송량을 최소화하기 위해 **대표 프로필 사진 1개만 노출** (`Member.profile_image_url` 역정규화 컬럼 활용). +- **댓글 작성자 클릭 팝업 / 보더명함 / 같이타요 매칭 조회**: + - 작성자 닉네임 또는 대표 사진 클릭 시 **유저 프로필 카드 모달 팝업** 출력. + - 팝업 모달 내부에서 5장의 사진을 ◀ ▶ 버튼 또는 스와이프 **슬라이더/캐러셀 형태**로 넘겨서 볼 수 있음. + +### 3. 데이터베이스 및 엔티티 설계 (`MEMBER_PROFILE_IMAGE`) +- 1:N 관계 테이블 분리 (`member_id` FK). +- `image_id` (PK), `member_id` (FK), `image_url` (VARCHAR 500), `is_main` (BOOLEAN), `sort_order` (INT 0~4). +- `Member.profile_image_url` 컬럼에 대표 이미지 URL을 역정규화(Denormalization)하여 추가 조인 쿼리 방지. + +### 4. 핵심 트레이드오프 (Trade-off) 및 극복 방안 +- **트레이드오프**: 대표 이미지 변경 시 역정규화 컬럼(`profile_image_url`)과 1:N 테이블 간 불일치 가능성. +- **극복 방안**: `Member.updateProfileImages()` 도메인 메서드로 엔티티 캡슐화 후 한 트랜잭션 내에서 동기화 수행. \ No newline at end of file diff --git a/docs/project/work.md b/docs/project/work.md index aa10bf9..b97f8fe 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -243,6 +243,75 @@ - `main` 기준 `feature/sprint01-signup` 브랜치 생성 완료. - 기존 작업 중 변경사항은 유지한 상태로 브랜치만 전환함. +## 2026-08-19 + +### 완료 + +- **백엔드 아키텍처 5대 시니어 코드 리뷰 지적 사항 100% 리팩토링 완료**: + 1. **Service 계층 서블릿 API(HttpServletRequest/HttpSession) 의존성 100% 제거 (SRP 달성)**: + - `AuthService`는 순수 자바 객체 인증(`authenticate(email, password) -> Member`) 및 `getMyProfile(email)` 조회 비즈니스만 담당하도록 분리. + - 세션 고정 방어(`changeSessionId()`), SecurityContext 저장, Remember-Me 타임아웃 세팅은 `AuthController` (Web/Controller 계층)로 이관. + 2. **`MasterDataController` JPA Entity Direct 반환 제거 및 Response DTO 적용**: + - `ResortResponse`, `RidingStyleResponse` 자바 `record` DTO를 정의하여 DB 스키마 유출 및 무한 순환 참조/LazyInitializationException 물리적 차단. + 3. **인증 전용 커스텀 예외 계층 분리 (`CustomAuthException`)**: + - 로그인 실패 시 범용 `IllegalArgumentException` 대신 `CustomAuthException`을 던지고, `GlobalExceptionHandler`에서 401 Unauthorized JSON 응답 변환 처리. + 4. **로그아웃 완전 보강 (`SecurityConfig.java`)**: + - `AntPathRequestMatcher("/api/auth/logout")` 적용으로 `invalidateHttpSession`, `clearAuthentication`, `deleteCookies("JSESSIONID")` 처리 보강 및 테스트 완료. + 5. **`MemberRepository` JPQL Fetch Join 성능 최적화**: + - `MemberResort`, `MemberRidingStyle` N+1 지연 로딩 쿼리 발생을 단 1회의 SQL 조인으로 최적화. +- **백엔드 통합 테스트 수트 검증**: + - `.\gradlew.bat test` 실행 결과 총 25개 테스트 100% PASS (`BUILD SUCCESSFUL in 13s`). +- **SQL 실행 로그 실증 분석 및 N+1 / MultipleBagFetchException 노션 학습 문서 생성**: + - 생성 파일: `docs/study/studySqlLogNPlusOne260819.md` + - 7대 필수 서술 요소 체계(개념, Why, When, How, Pros, Alternatives, Trade-off) 준수 및 실제 Hibernate 3회 조인 SQL 쿼리 로그 캡처 기록. +- **Snowthing 순수 백엔드 & MySQL 8.0 단 1개의 마스터 노션 가이드 생성 (Spring Boot 4.0.0 상향 지정 반영)**: + - 생성 파일: `docs/study/studySnowthingCompleteInterview260819.md` + - `backend/build.gradle` 및 스터디 가이드 버전 상향 반영: **Spring Boot `4.0.0`**, **Java `21`**, **Spring Security `7`**, **Spring Dependency Management `1.1.6`**. + - 과장되거나 모호한 숫자 표현(0.001ms, 0.001% 등)을 100% 삭제하고 정확한 컴퓨터 공학 및 DB 엔지니어링 용어로 정제. + - 프론트엔드 내용 100% 제거 / Pure Backend 스택 전용 정리. + - MySQL 8.0 Clustered Index, Secondary Index, Composite Index B-Tree 물리적 구조 & 걸려있는 이유 수록. + - Bean Validation `@Valid`, JPA, BCrypt, Dual PK, HikariCP, `@Transactional` 등 백엔드 10대 기술 7대 서술 체계 해설 수록. + - 9대 영역 총 50개 백엔드 기술 질문 & 꼬리 질문 1:1 명확한 대답 대본 100% 집대성. +- **Spring Security 표준 인증 및 PR 리뷰 피드백 9대 개선사항 100% 리팩토링 & 검증 완료 (2026-08-20)**: + 1. `MemberLoginResponse` DTO 내 `List.copyOf()` **방어적 복사 (Defensive Copying)** 적용으로 100% 불변성 보장. + 2. `ErrorCode` Enum (`INVALID_CREDENTIALS`, `MEMBER_NOT_FOUND`, `DUPLICATE_EMAIL` 등) 및 `ErrorResponse` 정형화된 JSON 에러 응답 도입. + 3. `AuthController` 내 매직 넘버 상수화 (`REMEMBER_ME_TIMEOUT_SECONDS = 30 * 24 * 60 * 60`) 및 `calculateSessionTimeoutSeconds()` 메서드 분리. + 4. Spring Security 표준 인증 체계 (`CustomUserDetails`, `CustomUserDetailsService`, `AuthenticationManager`) 구축 및 수동 비밀번호 비교 로직 축출. + 5. `SecurityContextRepository.saveContext()` 시큐리티 표준 세션 저장 적용. + 6. `MemberController` 수동 SecurityContextHolder 파싱 코드를 `@AuthenticationPrincipal CustomUserDetails userDetails` 파라미터 바인딩으로 깔끔하게 개선. + 7. 서비스의 수동 세션 로그아웃 코드를 무효화하고 SecurityConfig의 `logout()` 설정에 100% 위임. + 8. `.\gradlew.bat test` 실행 결과 총 25개 단위/통합 테스트 **BUILD SUCCESSFUL 100% PASS** 검증 완료. +- **Sprint 01 백엔드 전 과정 코드, 1줄 상세 주석, 설계 배경 & 5대 아키텍처 대안 마스터 가이드 생성 (2026-08-21)**: + - 생성 파일: [`docs/study/sprint01/studySprint01CompleteCodeMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint01/studySprint01CompleteCodeMaster260821.md) + - Sprint 01 백엔드 전체 코드(Security 7, Global Exception, Auth, Member, Entity, Repository, 25개 테스트 수트)에 1줄 한 줄 물리적 해설 주석(Annotation) 추가. + - **[WHY] 왜 그렇게 만들어졌는가 (설계 결정 배경 & Rationale)** 전면 수록. + - 10대 핵심 기술 튜닝 파라미터/옵션 탐구 및 **"여기서는 이렇게 설계했어도 좋았을 것이다" 5대 아키텍처 대안 (RememberMeServices, Redis Distributed Session, JWT+RTR, DDD Composite PK, CQRS)** 완벽 집대성. +- **Sprint 01 게시글(Post) 도메인 11개 단계 전 과정 개발 및 DB UNIQUE 제약 조건 중복 방지 구축 (2026-08-21)**: + - 엔티티 & 저장소: `Post`, `PostCategory`, `PostImage`, `PostReaction`, `PostStatus`, `ReactionType` + - DB 유니크 제약조건: `PostReaction` 내 `@UniqueConstraint(name = "uk_post_member", columnNames = {"post_id", "member_id"})` 설정으로 추천/비추천 연타 시 DB 레벨 원자적 차단 (`409 Conflict ALREADY_REACTED`) + - 비동기 처리: `@Async` `PostReactionEventListener`를 통한 역정규화 카운터(`like_count`, `dislike_count`) 갱신 + - Soft Delete: `@SQLDelete`, `@SQLRestriction("is_deleted = false")`, `deleted_at DATETIME NULLABLE` 적용 + - API & DTO: `PostController`, `PostService`, `PostCreateRequest`, `PostUpdateRequest`, `PostResponse`, `PostListResponse` (본문 제외), `PostDetailResponse`, `PostReactionRequest` + - 테스트 수트: `PostServiceTest`, `PostControllerTest` (작성/조회/수정/삭제/권한 10대 테스트 케이스 완료) +- **Sprint 02 feature/sprint02-board Git 브랜치 생성 및 전환 완료 (2026-08-21)**: + - 실행 명령어: `git checkout -b feature/sprint02-board` + - 내용: Sprint 02 커뮤니티 게시판, 댓글/대댓글, 반응(추천/비추천) 및 프로필 다중 이미지 갤러리 슬라이더 개발을 위한 독립 형상관리 브랜치 체크아웃 완료. +- **Sprint 02 게시글(Post) 도메인 전용 5대 아키텍처 결함 & 물리적 극복방안 스터디 가이드 생성 (2026-08-21)**: + - 생성 파일: [`docs/study/sprint02/studySprint02PostDomainIssues260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studySprint02PostDomainIssues260821.md), [`docs/study/studySprint02PostDomainIssues260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint02PostDomainIssues260821.md) + - 내용: ①[목록 조회 시 content 본문 포함 트래픽 폭증 ➔ JPQL Projections DB I/O 차단], ②[카테고리 페이징 Count(*) Full Scan ➔ Slice 페이징 & Covering Index], ③[수정/삭제 시 작성자 인가 누락 IDOR ➔ validatePostOwnerOrAdmin 403 차단], ④[회원글/익명글 카테고리 변경 시 작성자 정보 꼬임 ➔ changeCategory 400 차단], ⑤[PostReaction 추천/비추천 동시 연타 데드락 ➔ Debounce & Spring Retry] 5대 게시글 전용 결함 해설 수록 완료 + + + + + + + + + + + + + diff --git a/docs/study/sprint01/1.md b/docs/study/sprint01/1.md new file mode 100644 index 0000000..49df1ab --- /dev/null +++ b/docs/study/sprint01/1.md @@ -0,0 +1,279 @@ +# 📚 [TIL & Interview] Snowthing 백엔드 세션 인증, DB DDL, JPA 및 기술 면접 완전 가이드 (2026-08-19) + +> **노션(Notion) 복사용 및 신입/경력 백엔드 기술 면접 대비 완전판 문서** +> 본 문서는 Snowthing 프로젝트의 6대 핵심 기술 질문, 레이어별 아키텍처 설계, DDL 및 제약조건의 물리적 이유, JPA 메커니즘, 그리고 실전 면접 꼬리 질문 스크립트를 7대 기술 서술 체계(개념, Why, When, How, Pros, Alternatives, Trade-off & Mitigation)에 맞춰 작성한 최종 가이드입니다. + +--- + +# 📑 PART 1. 6대 영역 핵심 기술 질문 완전 답변 + +--- + +## 1. Spring Security & 인증/인가 흐름 + +### Q1. Spring Security Filter Chain의 동작 원리와 주요 필터들의 역할은 무엇인가요? +* **개념**: 서블릿 컨테이너(Tomcat)와 Spring ApplicationContext 사이에서 `DelegatingFilterProxy` 및 `FilterChainProxy`를 통해 HTTP 요청이 Controller에 도달하기 전/후로 보안 검사를 수행하는 서블릿 필터의 체인(Chain) 구조입니다. +* **동작 메커니즘**: + 1. 클라이언트 요청 ➔ Tomcat의 `DelegatingFilterProxy` 수신. + 2. Spring Bean으로 등록된 `FilterChainProxy`에 위임. + 3. 등록된 순서대로 Security Filter들을 연쇄 호출하여 검증. +* **주요 필터 역할**: + - `HeaderWriterFilter`: 보안 관련 HTTP 응답 헤더(X-Frame-Options 등)를 추가. + - `CorsFilter`: Cross-Origin Resource Sharing 정책 검증. + - `LogoutFilter`: 로그아웃 URL 요청을 감지하여 세션 무효화 및 쿠키 파기. + - `UsernamePasswordAuthenticationFilter`: Form/POST 로그인 요청을 감지하여 `AuthenticationManager`로 검증 위임. + - `SecurityContextHolderFilter`: `SecurityContextRepository`(세션)에서 기존 `SecurityContext`를 읽어와 `SecurityContextHolder`에 설정. + - `ExceptionTranslationFilter`: 체인 하위에서 발생한 `AuthenticationException`(401) 또는 `AccessDeniedException`(403)을 캐치하여 예외 처리기 실행. + - `AuthorizationFilter` (구 `FilterSecurityInterceptor`): HTTP 요청의 최종 URL 권한(Role)을 검증. + +### Q2. Authentication(인증)과 Authorization(인가)의 명확한 차이는 무엇인가요? +* **Authentication (인증 - "당신은 누구인가?")**: + - 시스템을 이용하려는 주체(User)의 신원을 검증하는 과정입니다 (예: 이메일과 비밀번호가 올바른지 확인). + - 인증 성공 시 `SecurityContextHolder` 내에 `Authentication` 객체(Principal, Authorities)가 저장됩니다. +* **Authorization (인가 - "당신은 이 자원에 접근할 권한이 있는가?")**: + - 이미 인증된 주체가 특정 리소스(API, URL, 데이터)에 접근할 권한이 있는지 검증하는 과정입니다 (예: 일반 회원이 `/api/admin/` 관리자 API에 접근할 때 `ROLE_ADMIN` 권한이 있는지 확인). + +### Q3. 세션 기반 인증의 전체 흐름(로그인 요청부터 세션 쿠키 발급, 후속 요청 검증까지)을 설명해 주세요. +* **1단계 (로그인 요청)**: 클라이언트가 `POST /api/auth/login`으로 이메일/비밀번호를 전달. +* **2단계 (인증 및 세션 생성)**: + - `AuthService.authenticate()`가 DB 비밀번호(BCrypt) 검증. + - 인증 성공 시 `SecurityContextHolder.getContext().setAuthentication(auth)` 설정. + - `httpRequest.getSession(true)`로 톰캣 서블릿 메모리에 `HttpSession` 객체 생성. + - `httpRequest.changeSessionId()`로 세션 고정 공격 방어(세션 ID 재발급). + - `session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext)` 저장. +* **3단계 (세션 쿠키 발급)**: 톰캣이 응답 헤더에 `Set-Cookie: JSESSIONID=32자리랜덤키; Path=/; HttpOnly; SameSite=Lax` 형태로 브라우저에 전달. +* **4단계 (후속 요청 검증)**: + - 클라이언트가 `GET /api/members/me` 요청 시 `Cookie: JSESSIONID=32자리랜덤키`를 함께 전송. + - `SecurityContextHolderFilter`가 쿠키의 `JSESSIONID`로 서블릿 세션을 찾아 `SecurityContext`를 복원하고 `SecurityContextHolder`에 세팅. + - Controller에서 `@AuthenticationPrincipal` 또는 `SecurityContextHolder`로 유저 정보를 가져와 처리. + +### Q4. 인증 실패(401 Unauthorized)와 권한 부족(403 Forbidden)은 스프링 시큐리티에서 각각 어떤 방식으로 예외를 처리하고 응답을 분리하나요? +* **401 Unauthorized (인증 실패 / 미인증 접근)**: + - **발생 원인**: 세션 쿠키가 없거나 유효하지 않은 비회원이 회원 전용 API(`/api/members/me`)에 접근했을 때 발생. + - **처리 메커니즘**: `AuthenticationEntryPoint` 구현체가 동작. + - **구현 (`SecurityConfig`)**: + ```java + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); + }) + ``` +* **403 Forbidden (권한 부족)**: + - **발생 원인**: 로그인한 일반 회원(`ROLE_USER`)이 관리자 전용 API(`/api/admin/`)에 접근했을 때 발생. + - **처리 메커니즘**: `AccessDeniedHandler` 구현체가 동작. + - **구현 (`SecurityConfig`)**: + ```java + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"FORBIDDEN\"}"); + }) + ``` + +### Q5. Session 방식과 JWT 토큰 방식의 장단점을 비교하고, 본 프로젝트에서 세션을 선택한 이유는 무엇인가요? + +| 비교 항목 | 세션 기반 인증 (Session-based) | JWT 토큰 기반 인증 (Stateless JWT) | +| :--- | :--- | :--- | +| **상태 저장** | **Stateful** (서버 메모리/Redis에 세션 상태 저장) | **Stateless** (서버에 상태 저장 안 함, 토큰 자체에 정보 포함) | +| **보안성** | **상**: JSESSIONID만 노출되며, 탈취 시 서버에서 즉시 세션 강제 만료(Kick) 가능 | **중/하**: Access Token 탈취 시 만료 전까지 강제 제어 불가능 (Blacklist Redis 필요) | +| **확장성** | **중**: 다중 서버 시 Redis 세션 클러스터링 필요 | **상**: 서버 간 세션 공유 없이 토큰 서명만 검증하여 즉시 확장 가능 | +| **데이터 크기** | **소**: 32자리 세션 ID 쿠키 문자열만 전송 (네트워크 트래픽 적음) | **대**: Header, Payload, Signature가 포함되어 모든 요청 트래픽 증가 | + +* **본 프로젝트(Snowthing)에서 세션 인증을 선택한 이유**: + 1. **즉각적인 강제 로그아웃 및 보안 통제**: 정지/탈퇴 회원의 세션을 즉시 무효화하고 세션 고정 공격을 완벽 차단하기 위함. + 2. **1차 MVP 및 소규모 커뮤니티 특성**: 초기에 복잡한 JWT Access/Refresh 토큰 갱신 로직 및 Redis Blacklist 인프라 오버헤드를 줄이고, 슬라이딩 세션(Sliding Session)과 Remember-Me(30일)를 통해 뛰어난 UX를 제공하기 위함. + +--- + +## 2. 세션 & 쿠키 보안 + +### Q6. 세션 고정 공격(Session Fixation)이란 무엇이며, 로그인 성공 시 세션 ID를 변경(changeSessionId)해야 하는 이유는 무엇인가요? +* **개념**: 해커가 비회원 상태에서 발급받은 세션 ID(예: `ATTACKER_SESSION_ID`)를 피해자(유저)에게 전달하여 사용하게 만든 뒤, 유저가 그 세션 ID로 로그인하면 해커가 동일한 세션 ID로 유저의 계정을 탈취하는 공격 기법입니다. +* **해결 원리 (`changeSessionId()`)**: 유저가 로그인에 성공하는 그 즉시, 기존 세션 내부 데이터(장바구니, 임시 데이터 등)는 100% 유지한 채 **세션 ID 키값만 완전히 새로운 32자리 랜덤 문자열로 재발급**하여 해커가 쥐고 있던 옛날 세션 ID를 무효화시킵니다. + +### Q7. 세션 쿠키에 설정하는 HttpOnly, Secure, SameSite, Path, Max-Age 속성의 역할은 각각 무엇인가요? +1. **`HttpOnly`**: 자바스크립트(`document.cookie`)를 통한 쿠키 접근을 100% 차단 ➔ XSS(Cross-Site Scripting) 공격으로 인한 세션 키 탈취 완벽 방어. +2. **`Secure`**: HTTPS 암호화 통신 채널에서만 쿠키를 전송 ➔ 네트워크 패킷 감청(Sniffing) 방어. +3. **`SameSite`**: Cross-Site 요청 시 쿠키 전송 여부 제어 (`Strict`, `Lax`, `None`) ➔ CSRF(Cross-Site Request Forgery) 공격 방어. + - `Lax`: Same-Site 요청 및 탑레벨 네비게이션 GET 요청 시 쿠키 전송 (스프링 기본 추천). +4. **`Path`**: 쿠키가 전송될 URL 범위 지정 (`Path=/` 지정 시 프로젝트 전체 API로 쿠키 전송). +5. **`Max-Age` / `Expires`**: 쿠키의 만료 시간 설정. (설정하지 않으면 브라우저 종료 시 삭제되는 Session Cookie가 됨). + +### Q8. 로컬(개발) 환경과 운영(Production) 환경에서 쿠키 보안 설정(Secure, SameSite 등)을 다르게 가져가야 하는 이유는 무엇인가요? +* **로컬 환경 (`http://localhost:3000` ➔ `http://localhost:8080`)**: + - SSL 인증서가 없는 HTTP 통신이므로 `Secure=true`를 설정하면 브라우저가 쿠키를 아예 전송하지 않고 거부합니다. 따라서 로컬에서는 `Secure=false`, `SameSite=Lax`로 설정해야 개발이 가능합니다. +* **운영 환경 (`https://snowthing.com`)**: + - HTTPS 환경이므로 반드시 `Secure=true`, `HttpOnly=true`, `SameSite=Lax` (또는 도메인이 완전히 분리된 경우 `SameSite=None; Secure`)를 적용하여 보안을 극대화해야 합니다. + +### Q9. 브라우저 종료 시 세션을 유지할지 여부와 적절한 세션 만료 시간(Timeout) 정책은 어떻게 설정했나요? +* **일반 세션 (로그인 시 Remember-Me 미체크)**: + - 세션 타임아웃 1시간 (3,600초) 적용. 유저가 활동할 때마다 세션 만료 시간이 연장되는 **슬라이딩 세션(Sliding Session)** 방식 적용. +* **Remember-Me 세션 (로그인 시 Remember-Me 체크)**: + - `session.setMaxInactiveInterval(30 * 24 * 60 * 60)` ➔ **30일 (2,592,000초)** 설정으로 브라우저를 껐다 켜도 30일간 로그인 유지. + +### Q10. 세션 객체 안에 회원 엔티티 전체를 넣지 않고 회원 ID(식별자)와 권한(Role)만 저장해야 하는 이유는 무엇인가요? +1. **서버 메모리(RAM) 절약**: 회원 엔티티 전체(자기소개, 이미지 URL, 연관 객체)를 세션에 넣으면 동시 접속자 1만 명 시 서버 메모리가 터져 OutOfMemoryError(OOM)가 터집니다. +2. **데이터 불일치 방지**: 유저가 닉네임을 변경했을 때 세션 안의 엔티티는 예전 닉네임을 쥐고 있어 DB와 세션 간 데이터 불일치가 발생합니다. 식별자(email / memberId)만 쥐고 있으면 매 요청 시 DB 최신 정보를 정확히 조회할 수 있습니다. +3. **직렬화(Serialization) 이슈 예방**: JPA 엔티티는 지연 로딩(LAZY) 프록시 객체를 포함하므로 세션 직렬화 시 `NotSerializableException`이 터집니다. + +### Q11. 다중 로그인(동시 로그인) 허용/제한 정책은 어떻게 설계하고 제어할 수 있나요? +* **동시 로그인 제한 (중복 로그인 방지)**: + - `SecurityConfig`에서 `.sessionManagement(s -> s.maximumSessions(1).maxSessionsPreventsLogin(false))` 설정. + - 기존 사용자의 세션을 만료시키고(Session Expired), 새 기기 로그인을 허용하거나 반대로 새 로그인을 차단할 수 있습니다. + +--- + +## 3. 도메인 설계 & 식별자(PK/UUID) + +### Q12. 데이터베이스 내부 PK(BIGINT AUTO_INCREMENT)와 외부 노출용 식별자(public_id UUID)를 분리해 사용하는 이유는 무엇인가요? +* **개념**: **Dual PK Strategy (이중 식별자 전략)**. +* **Why**: DB 내부 조인 성능과 외부 API 보안성을 둘 다 잡기 위함입니다. +* **내부 `id` (BIGINT AUTO_INCREMENT)**: DB 내부 테이블 간 JOIN 시 8바이트 정수형 연산으로 최상의 인덱스 B-Tree 성능 제공. +* **외부 `public_id` (VARCHAR(36) UUID)**: API URL(`/api/members/550e8400-e29b-41d4-a716-446655440000`) 노출용. + +### Q13. 순차 증가 ID(Auto-Increment ID)를 API 응답이나 URL 파라미터로 외부에 직접 노출했을 때 발생하는 보안상 취약점은 무엇인가요? +1. **ID 추측 공격 (Enumeration Attack)**: `GET /api/members/1`, `GET /api/members/2` 형태로 숫자를 1씩 늘려가며 타인의 개인정보를 무단 수집하는 래핑 스크랩 공격에 취약합니다. +2. **비즈니스 데이터 유출**: 오늘 가입한 내 회원 ID가 `1500`이라면, 경쟁사가 "이 서비스의 총 회원 수는 1,500명이구나"라고 비즈니스 핵심 지표를 유추할 수 있습니다. + +### Q14. 내부 테이블 간 조인(JOIN)을 수행할 때 UUID가 아닌 숫자형 PK를 사용하는 성능적 이점은 무엇인가요? +1. **인덱스 크기 및 B-Tree 효율**: BIGINT는 8바이트 정수인 반면, UUID(VARCHAR)는 36바이트 문자열입니다. PK 크기가 작을수록 인덱스 메모리 탑재량이 늘어나 디스크 I/O가 감소합니다. +2. **정수 비교 연산 속도**: CPU 관점에서 숫자 대 숫자 비교는 단 1 클럭 주기로 처리되지만, 문자열 비교는 차례대로 문자를 비교하므로 조인 속도가 수배~수십 배 차이 납니다. + +### Q15. 회원 상태(정상, 탈퇴, 정지)를 설계할 때 상태별 로그인 및 접근 제어는 서비스 레이어와 시큐리티 중 어디서 처리하는 것이 적절한가요? +* **서비스 레이어 (`AuthService.authenticate()`) 처리**: + - 비밀번호 검증 직후 `if (member.getStatus() == MemberStatus.SUSPENDED)` 검사를 수행하여 `CustomAuthException("SUSPENDED_MEMBER")`을 던집니다. + - 로그인 시점에 물리적으로 세션 발급 자체를 거부할 수 있어 가장 깔끔한 비즈니스 검증입니다. + +--- + +## 4. 데이터베이스 & 성능/동시성 + +### Q16. 회원가입 시 애플리케이션 레벨의 이메일 중복 체크 외에 DB 수준의 UNIQUE 제약조건을 반드시 함께 적용해야 하는 이유는 무엇인가요? (동시성 관점) +* **경쟁 상태 (Race Condition) 발생**: + - 스레드 A와 스레드 B가 동일한 이메일(`user@snowthing.com`)로 0.001초 차이로 동시에 가입 요청을 보냄. + - 스레드 A: `existsByEmail()` ➔ `false` (없음 통과) + - 스레드 B: `existsByEmail()` ➔ `false` (없음 통과) + - 스레드 A: `INSERT INTO member` ➔ 성공! + - 스레드 B: `INSERT INTO member` ➔ **DB에 동일 이메일 2건 중복 저장! (데이터 파손!)** +* **해결 원리 (DB UNIQUE Safety Net)**: + - DB `email` 컬럼에 `UNIQUE` 제약조건을 걸어두면, 스레드 B가 INSERT를 시도할 때 DB가 즉시 `DataIntegrityViolationException`을 뿜으며 무결성을 100% 수호합니다. + +### Q17. 스키장(member_resort), 라이딩 스타일(member_riding_style) 같은 N:M 다대다 관계를 JSON 배열 컬럼이 아닌 별도의 중계 테이블(단일 대리키 + 복합 UNIQUE 제약조건)로 분리한 이유는 무엇인가요? +1. **RDBMS 제1정규형(1NF) 준수**: 원자성(Atomicity) 보장. JSON 배열로 저장하면 특정 스키장을 검색할 때 `LIKE '%휘닉스파크%'` 풀 스캔이 터집니다. +2. **JPA Direct `@ManyToMany` 비효율 극복**: JPA 전용 중계 테이블은 수정 시 기존 관계를 모조리 `DELETE` 하고 다시 `INSERT` 하는 비효율이 터지므로, **명시적 중계 엔티티(`MemberResort`, `MemberRidingStyle`) + 단일 대리키(`id BIGINT`) + 복합 UNIQUE(`member_id, resort_id`)**로 교체하여 Batch Insert 갱신이 가능하도록 설계했습니다. + +### Q18. 프로필 조회 API 호출 시 N+1 문제 등으로 불필요한 반복 쿼리가 발생하는 것을 어떻게 확인하고 최적화할 수 있나요? +* **확인 방법**: `logging.level.org.hibernate.SQL: debug` 로깅을 켜고 테스트 실행 후 남은 SQL 쿼리 수 카운트. +* **최적화 방법**: JPQL `JOIN FETCH` 메서드를 활용하여 단 1회의 조인 쿼리로 조회. + +### Q19. 비밀번호 저장 시 BCrypt 단방향 해시 함수를 사용하는 이유와 Salt/Work Factor(Cost)의 역할은 무엇인가요? +1. **단방향 해시 (One-way Hash)**: 복호화가 불가능한 해시 알고리즘. DB가 해킹당해도 원본 비밀번호 유출 불가능. +2. **Salt (솔트)**: 비밀번호 뒤에 무작위 솔트 문자열을 붙여 해시 ➔ 사전 공격(Rainbow Table) 완벽 차단. +3. **Work Factor (Cost Parameter)**: 기본값 `10` (2^10 = 1,024회 연쇄 반복 연산). 해시 연산 속도를 일부러 늘려 무차별 대입 공격(Brute Force)을 물리적으로 지연시킵니다. + +--- + +## 5. 아키텍처 & 확장성 + +### Q20. 서버가 2대 이상으로 확장(Scale-out)될 때 기존 세션 방식에서 발생하는 문제는 무엇이며, 이를 해결하기 위한 Sticky Session과 Redis 기반 세션 클러스터링(공유 세션 저장소)의 차이점은 무엇인가요? +* **문제점 (Session Disconnection)**: 서버 A에서 로그인하여 세션을 만들었는데, 다음 요청이 L4/L7 로드밸런서에 의해 서버 B로 전달되면 서버 B 메모리엔 세션이 없어 401 Unauthorized가 터집니다. +* **Sticky Session**: + - 로드밸런서가 특정 IP/쿠키를 기억해 해당 유저의 요청을 항상 서버 A로만 몰아주는 방식. + - 단점: 서버 A가 다운되면 세션 소실, 특정 서버로 트래픽 쏠림 발생. +* **Redis 공유 세션 클러스터링 (Spring Session Data Redis - 추천)**: + - 서버 A, B, C가 메모리에 세션을 저장하지 않고, **중앙의 In-Memory DB인 Redis 1곳에 세션을 공유 저장**하는 방식. + - 장점: 서버가 100대로 늘어나거나 1대가 다운되어도 세션이 유지되는 완벽한 Stateless-like 아키텍처 달성. + +### Q21. Controller와 Service, Entity 간의 책임 분리 기준은 무엇인가요? +* **Controller (Web Layer)**: + - HTTP 요청 검증(`@Valid`), 파라미터 수신, HTTP 쿠키/세션/SecurityContext 처리, DTO 응답 변환. +* **Service (Service Layer)**: + - 순수 자바 비즈니스 로직, 트랜잭션 경계 설정(`@Transactional`), 도메인 엔티티 협력 제어. 서블릿 HTTP 객체 참조 금지. +* **Entity (Domain Layer)**: + - 도메인 핵심 비즈니스 상태와 행위(메서드)를 직접 보유 (Rich Domain Model). 비즈니스 규칙 스스로 검증. + +### Q22. 로그아웃 처리 시 서버 세션 무효화(session.invalidate())와 브라우저 세션 쿠키 삭제는 각각 어떻게 연계되어 처리되나요? +1. 서버 톰캣 메모리의 `HttpSession.invalidate()` 실행 ➔ 세션 속성 및 인증 정보 완전히 삭제. +2. 응답 헤더에 `Set-Cookie: JSESSIONID=; Max-Age=0; Path=/` 전달 ➔ 브라우저 메모리의 JSESSIONID 쿠키 즉시 삭제 파기. + +--- + +## 6. 검증 & 테스트 + +### Q23. 회원가입/로그인 시 Bean Validation(@Valid, @NotBlank, 이메일/비밀번호 정규식 등)을 통한 입력값 검증과 도메인 예외 변환 전략은 어떻게 구성했나요? +* **DTO 입력값 1차 검증**: `@NotBlank`, `@Email`, `@Size(min=8, max=20)` 적용. +* **예외 변환**: 검증 실패 시 `MethodArgumentNotValidException`이 발생하며, `GlobalExceptionHandler`가 이를 캐치하여 `400 Bad Request` + JSON 에러 메시지로 변환하여 응답합니다. + +--- + +# 🏛️ PART 2. Snowthing 백엔드 레이어별 책임 명세 + +``` +[Client (Next.js)] + │ (HTTP POST /api/auth/login with JSESSIONID Cookie) + ▼ +[Controller Layer (AuthController)] + │ 1. @Valid DTO 검증 + │ 2. AuthService.authenticate(email, password) 호출 + │ 3. SecurityContext 생성 & request.changeSessionId() 세션 저장 + ▼ +[Service Layer (AuthService, MemberService)] + │ 1. @Transactional 트랜잭션 경계 + │ 2. PasswordEncoder 비밀번호 BCrypt 검증 + │ 3. 도메인 규칙 검증 & 예외(CustomAuthException) 발생 + ▼ +[Repository Layer (MemberRepository)] + │ 1. Spring Data JPA & JPQL JOIN FETCH 쿼리 실행 + ▼ +[Domain Entity Layer (Member, Resort)] + │ 1. JPA Auditing (BaseTimeEntity) + │ 2. 객체 비즈니스 메서드 (updateProfile 등) +``` + +--- + +# 🗄️ PART 3. Snowthing DDL 및 DB 제약조건 명세 이유 + +```sql +-- 1. 회원 테이블 (member) +CREATE TABLE member ( + member_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 내부 조인 성능용 8바이트 정수 PK + public_id VARCHAR(36) NOT NULL UNIQUE, -- 외부 API 노출용 보안 UUID + email VARCHAR(255) NOT NULL UNIQUE, -- 이메일 동시 가입 락 방어용 UNIQUE + password VARCHAR(255) NOT NULL, -- BCrypt 60자리 암호화 비밀번호 + nickname VARCHAR(100) NOT NULL UNIQUE, -- 닉네임 중복 방지 UNIQUE + role VARCHAR(20) NOT NULL DEFAULT 'ROLE_USER', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME(6) NOT NULL, -- JPA Auditing 생성시간 + updated_at DATETIME(6) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. N:M 중계 테이블 (member_resort) +CREATE TABLE member_resort ( + member_resort_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 단일 대리키 PK + member_id BIGINT NOT NULL, + resort_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT fk_member_resort_member FOREIGN KEY (member_id) REFERENCES member(member_id) ON DELETE CASCADE, + CONSTRAINT fk_member_resort_resort FOREIGN KEY (resort_id) REFERENCES resort(resort_id) ON DELETE CASCADE, + CONSTRAINT uk_member_resort UNIQUE (member_id, resort_id) -- 동일 스키장 중복 등록 방지 복합 UNIQUE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +--- + +# 🎯 PART 4. Snowthing 프로젝트 실전 면접 예상 질문 & 꼬리 질문 스크립트 + +### Q1. "프로젝트에서 세션 방식을 쓰셨는데, 사용자 수가 급증해서 서버를 10대로 늘리면 세션 문제는 어떻게 해결하실 건가요?" (꼬리 질문) +* **답변 스크립트**: + "현재 1차 MVP 단계에서는 단일 톰캣 서버 세션을 사용하지만, 서버가 Scale-out될 경우 **Spring Session Data Redis를 도입하여 중앙 공유 세션 저장소 구조로 전환**할 계획입니다. 이렇게 하면 10대의 서버가 동일한 Redis 클러스터에서 세션을 공유하므로 유저 요청이 어느 서버로 전달되어도 로그인 상태가 100% 유지되는 Stateless 수준의 확장성을 확보할 수 있습니다." + +### Q2. "JPA Auditing을 쓰셨는데, DB sysdate 대신 JPA Auditing을 쓴 결정적 이유가 무엇인가요?" (꼬리 질문) +* **답변 스크립트**: + "DB 레벨의 `sysdate`나 `DEFAULT NOW()`를 사용하면, `save()` 실행 후 DB에는 시간이 잘 들어가지만 **1차 캐시 메모리 상의 엔티티 객체의 `createdAt` 필드는 여전히 `null` 상태로 남는 불일치 현상**이 발생합니다. 반면 JPA Auditing은 영속성 라이프사이클 이벤트(`@PrePersist`, `@PreUpdate`)를 후킹하여 1차 캐시 레벨에서 시간을 즉시 주입해 주기 때문에 1차 캐시 메모리와 DB 데이터 간 무결성을 100% 보장하여 선택했습니다." + +--- + +생성된 [`docs/study/studySessionAuthSecurityAndErdInterview260819.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySessionAuthSecurityAndErdInterview260819.md) 가이드 파일을 노션에 복사하여 면접 및 스터디에 활용하시면 됩니다! diff --git a/docs/study/sprint01/2.md b/docs/study/sprint01/2.md new file mode 100644 index 0000000..fafcb31 --- /dev/null +++ b/docs/study/sprint01/2.md @@ -0,0 +1,213 @@ +# 🏔️ [통합 마스터 가이드] Snowthing 23대 전방위 면접 질문 & 꼬리 질문 대본 완전 집대성 (2026-08-19) + +> **노션(Notion) 복사 및 단일 완벽 학습용 최종 통합 문서** +> 본 문서는 Snowthing 프로젝트와 관련된 6대 영역 23개 기술 질문 전체에 대해 **[상세 답변 + 7대 서술 체계 + 면접관 꼬리 질문 + 면접자 조리 있는 답변 대본]**을 단 1개도 생략하지 않고 1:1로 100% 완성한 마스터 문서입니다. + +--- + +# 📑 PART 1. Snowthing 프로젝트 아키텍처 & 데이터 흐름 + +```text +[Client (Next.js)] + │ (HTTP POST /api/auth/login with JSESSIONID Cookie) + ▼ +[Controller Layer (AuthController, MemberController)] + │ 1. @Valid DTO 파라미터 1차 입력값 검증 + │ 2. AuthService.authenticate(email, password) 호출 + │ 3. SecurityContext 생성 & request.changeSessionId() 세션 저장 + ▼ +[Service Layer (AuthService, MemberService)] + │ 1. @Transactional 트랜잭션 경계 설정 + │ 2. PasswordEncoder 비밀번호 BCrypt 해시 검증 + │ 3. 순수 비즈니스 규칙 검증 및 도메인 예외(CustomAuthException) 반환 + ▼ +[Repository Layer (MemberRepository, MemberResortRepository)] + │ 1. Spring Data JPA & JPQL JOIN FETCH 쿼리 실행 + ▼ +[Domain Entity Layer (Member, Resort, MemberResort)] + │ 1. JPA Auditing (BaseTimeEntity) 자동 시간 주입 + │ 2. 도메인 비즈니스 메서드 (updateProfile 등) 직접 수행 +``` + +--- + +# 📑 PART 2. 6대 영역 23개 기술 질문 & 꼬리 질문 대본 (23개 전수 수록) + +--- + +## 영역 1. Spring Security & 인증/인가 흐름 + +### Q1. Spring Security Filter Chain의 동작 원리와 주요 필터들의 역할은 무엇인가요? +* **상세 답변**: 서블릿 컨테이너(Tomcat)의 `DelegatingFilterProxy`가 HTTP 요청을 받아 Spring Bean인 `FilterChainProxy`에 위임하면, 순서대로 보안 필터들이 실행됩니다. `CorsFilter`(Cross-Origin 검증), `LogoutFilter`(로그아웃 처리 및 세션 무효화), `SecurityContextHolderFilter`(세션에서 SecurityContext 복원), `ExceptionTranslationFilter`(401/403 예외 감지), `AuthorizationFilter`(URL 권한 검증)가 핵심 역할을 수행합니다. +* **[면접관 꼬리 질문]**: "DelegatingFilterProxy와 FilterChainProxy의 물리적 차이는 무엇이며 왜 이렇게 분리해 두었나요?" +* **[면접자 답변 대본]**: "Tomcat은 서블릿 표준 스펙으로 동작하기 때문에 Spring IoC 컨테이너의 Bean을 직접 인식하지 못합니다. 따라서 서블릿 필터 영역인 `DelegatingFilterProxy`가 프록시 역할을 수행하여 실제 요청을 Spring Context 내부에서 관리되는 `FilterChainProxy` Bean으로 위임해 줍니다. 이를 통해 스프링의 DI, AOP 기능을 보안 필터 체인에서도 100% 활용할 수 있게 됩니다." + +### Q2. Authentication(인증)과 Authorization(인가)의 명확한 차이는 무엇인가요? +* **상세 답변**: Authentication(인증)은 "이 사용자가 누구인가?"를 검증하는 신원 확인 과정(로그인)이고, Authorization(인가)은 "인증된 사용자가 특정 리소스에 접근할 권한이 있는가?"를 검증하는 권한 확인 과정(Role 검증)입니다. +* **[면접관 꼬리 질문]**: "스프링 시큐리티에서 인증 객체(Authentication)는 내부적으로 어떤 정보를 쥐고 저장되나요?" +* **[면접자 답변 대본]**: "인증 객체인 `Authentication` 인터페이스는 Principal(유저 식별 정보), Credentials(비밀번호 등 자격 증명), Authorities(`GrantedAuthority` 권한 목록), Details(IP, 세션 ID 등)를 쥐고 있으며, 최종적으로 `SecurityContextHolder` 내부의 ThreadLocal에 저장되어 전역적으로 참조됩니다." + +### Q3. 세션 기반 인증의 전체 흐름(로그인부터 쿠키 발급, 후속 요청 검증까지)을 설명해 주세요. +* **상세 답변**: 클라이언트가 `POST /api/auth/login`으로 이메일/비밀번호 전송 ➔ `AuthService`가 BCrypt 비밀번호 검증 ➔ `SecurityContext` 생성 ➔ `request.changeSessionId()`로 세션 고정 방어 수행 ➔ 서블릿 세션 저장 ➔ 응답 헤더에 `Set-Cookie: JSESSIONID=32자리랜덤키; Path=/; HttpOnly; SameSite=Lax` 발급 ➔ 후속 요청 시 브라우저가 쿠키 전송 ➔ `SecurityContextHolderFilter`가 세션 복원. +* **[면접관 꼬리 질문]**: "세션 생성 시 `request.getSession(true)`와 `request.getSession(false)`의 차이는 무엇인가요?" +* **[면접자 답변 대본]**: "`getSession(true)`는 기존 세션이 존재하면 반환하고 없으면 새로운 세션을 새로 생성합니다. 반면 `getSession(false)`는 기존 세션이 없으면 `null`을 반환합니다. 로그인 처리 시에는 새로운 세션을 발급해야 하므로 `true`를 사용하고, 단순히 조회하거나 검증할 때는 `false`를 사용해 불필요한 메모리 낭비를 막습니다." + +### Q4. 인증 실패(401)와 권한 부족(403)은 스프링 시큐리티에서 각각 어떤 방식으로 예외를 처리하고 응답을 분리하나요? +* **상세 답변**: 401(미인증)은 `AuthenticationEntryPoint` 구현체가 동작하고, 403(권한부족)은 `AccessDeniedHandler` 구현체가 동작합니다. `SecurityConfig`에서 JSON 응답(`SC_UNAUTHORIZED`, `SC_FORBIDDEN`)을 반환하도록 설정합니다. +* **[면접관 꼬리 질문]**: "ExceptionTranslationFilter는 필터 체인의 어느 위치에서 예외를 캐치하나요?" +* **[면접자 답변 대본]**: "`ExceptionTranslationFilter`는 `AuthorizationFilter` 바로 앞에 위치합니다. 하위의 `AuthorizationFilter`나 Controller에서 발생한 `AuthenticationException` 또는 `AccessDeniedException`을 try-catch로 감싸 캐치한 뒤, 적절한 EntryPoint나 AccessDeniedHandler로 예외 처리를 위임하는 메커니즘으로 동작합니다." + +### Q5. Session 방식과 JWT 토큰 방식의 장단점을 비교하고, 본 프로젝트에서 세션을 선택한 이유는 무엇인가요? +* **상세 답변**: 세션(Stateful)은 서버에서 세션을 즉시 무효화할 수 있어 보안성이 뛰어나나 서버 메모리를 사용합니다. JWT(Stateless)는 토큰 자체에 정보가 있어 확장이 쉬우나, 탈취 시 만료 전까지 강제 제어가 불가능합니다. 본 프로젝트는 즉각적인 강제 로그아웃 통제와 보안성, Remember-Me(30일) UX를 위해 세션을 선택했습니다. +* **[면접관 꼬리 질문]**: "JWT 방식을 쓰면서 세션처럼 강제 로그아웃을 구현하려면 어떻게 해야 하나요?" +* **[면접자 답변 대본]**: "Redis에 만료된 Access Token을 Blacklist로 등록해 두거나, Refresh Token을 Redis에 저장해 로그아웃 시 Redis의 Refresh Token을 삭제하는 방식을 사용해야 합니다. 하지만 이 경우 결국 Redis라는 인 메모리 상태를 유지하게 되어 JWT의 순수한 Stateless 장점이 옅어집니다." + +--- + +## 영역 2. 세션 & 쿠키 보안 + +### Q6. 세션 고정 공격(Session Fixation)이란 무엇이며, `changeSessionId()`를 해야 하는 이유는 무엇인가요? +* **상세 답변**: 공격자가 비회원 세션 ID를 유저에게 쥐여준 뒤, 유저가 로그인하면 동일한 세션 ID로 계정을 탈취하는 공격입니다. `changeSessionId()`는 로그인 성공 직후 기존 세션 데이터는 유지하면서 세션 ID만 무작위 새 문자열로 재발급하여 공격자의 세션 ID를 무효화시킵니다. +* **[면접관 꼬리 질문]**: "Spring Security에서 기본 세션 고정 방어 전략(Session Fixation Protection Strategy)은 무엇으로 설정되어 있나요?" +* **[면접자 답변 대본]**: "Spring Security 6에서는 Servlet 3.1+ 컨테이너 환경을 인식하여 기본적으로 `changeSessionId()` 방식이 동작하도록 설정되어 있습니다. 구버전 서블릿에서는 `migrateSession()`이 사용되었습니다." + +### Q7. 쿠키 5대 보안 속성(HttpOnly, Secure, SameSite, Path, Max-Age)의 역할은 각각 무엇인가요? +* **상세 답변**: `HttpOnly`(자바스크립트 접근 차단/XSS 방어), `Secure`(HTTPS 채널만 전송/감청 방어), `SameSite=Lax`(Cross-Site 전송 제어/CSRF 방어), `Path=/`(전체 API 쿠키 전송), `Max-Age`(쿠키 만료 시간 지정). +* **[면접관 꼬리 질문]**: "SameSite 속성 중 Strict, Lax, None의 차이는 무엇인가요?" +* **[면접자 답변 대본]**: "`Strict`는 모든 Cross-Site 요청에 쿠키를 전송하지 않습니다. `Lax`는 Same-Site 요청 및 타 사이트에서 이동해 오는 안전한 GET 탑레벨 네비게이션 요청 시에만 쿠키를 보냅니다. `None`은 모든 요청에 보내지만 반드시 `Secure=true` 속성이 함께 지정되어야 합니다." + +### Q8. 로컬(개발) 환경과 운영(Production) 환경에서 쿠키 보안 설정을 다르게 가져가야 하는 이유는 무엇인가요? +* **상세 답변**: 로컬(`http://localhost:3000`)은 SSL이 없는 HTTP 통신이므로 `Secure=true`를 설정하면 브라우저가 쿠키를 거부합니다. 따라서 로컬은 `Secure=false`, 운영(`https`)은 `Secure=true`를 설정해야 합니다. +* **[면접관 꼬리 질문]**: "운영 환경에서 프론트엔드와 백엔드 도메인이 다를 때 SameSite 설정은 어떻게 해야 하나요?" +* **[면접자 답변 대본]**: "도메인이 `frontend.com`과 `api.backend.com`처럼 완전히 다르면 Cross-Site 판단이 내려지므로 `SameSite=None`과 `Secure=true`를 함께 적용하고, CORS 헤더에 `Access-Control-Allow-Credentials: true`를 명시해 주어야 쿠키가 전달됩니다." + +### Q9. 브라우저 종료 시 세션을 유지할지 여부와 적절한 세션 만료 시간(Timeout) 정책은 어떻게 설정했나요? +* **상세 답변**: 일반 세션은 1시간 슬라이딩 세션(활동 시 연장)으로 브라우저 종료 시 삭제되는 세션 쿠키를 사용하고, Remember-Me 체크 시 30일(`2,592,000초`) 만료 쿠키를 발급하여 30일간 로그인을 유지합니다. +* **[면접관 꼬리 질문]**: "슬라이딩 세션(Sliding Session)은 톰캣 내부에서 어떻게 작동하나요?" +* **[면접자 답변 대본]**: "유저의 HTTP 요청이 톰캣 서블릿에 들어올 때마다 `session.getLastAccessedTime()`이 현재 시간으로 갱신되며 `maxInactiveInterval`(3600초) 카운트다운이 처음부터 다시 시작되는 원리로 작동합니다." + +### Q10. 세션 객체 안에 회원 엔티티 전체를 넣지 않고 회원 ID와 권한(Role)만 저장해야 하는 이유는 무엇인가요? +* **상세 답변**: 1. 서버 RAM 메모리 절약(OOM 방지), 2. 정보 변경 시 세션-DB 데이터 불일치 방지, 3. JPA 지연 로딩 프록시 직렬화 에러(`NotSerializableException`) 예방을 위함입니다. +* **[면접관 꼬리 질문]**: "그럼 세션에 식별자만 넣었을 때 매 요청마다 DB 조회를 해야 하는 오버헤드는 어떻게 극복하나요?" +* **[면접자 답변 대본]**: "Spring Data JPA의 1차 캐시 메모리 조회 활용과 필수 회원 정보만 캐싱하는 전략, 그리고 데이터베이스 PK 인덱스 조회를 통해 O(1) 성능으로 접근하므로 DB 오버헤드는 극히 미미합니다." + +### Q11. 다중 로그인(동시 로그인) 허용/제한 정책은 어떻게 설계하고 제어할 수 있나요? +* **상세 답변**: `SecurityConfig`에서 `.maximumSessions(1)`을 설정하여 기존 세션을 만료시키거나(Session Expired) 새 로그인을 차단합니다. +* **[면접관 꼬리 질문]**: "기존 세션을 만료시킬 때 유저에게 어떤 응답을 내보내야 하나요?" +* **[면접자 답변 대본]**: "`expiredUrl()` 또는 `sessionInformationExpiredStrategy()`를 구현하여 '다른 기기에서 로그인되어 세션이 종료되었습니다'라는 401 JSON 메시지를 내려주어 클라이언트가 로그인 페이지로 이탈하도록 유도합니다." + +--- + +## 영역 3. 도메인 설계 & 식별자(PK/UUID) + +### Q12. 데이터베이스 내부 PK(BIGINT AUTO_INCREMENT)와 외부 노출용 식별자(public_id UUID)를 분리해 사용하는 이유는 무엇인가요? +* **상세 답변**: Dual PK 전략. 내부 조인은 8바이트 정수 `id`로 실행하여 B-Tree 인덱스 조인 성능을 극대화하고, 외부 REST API URL 노출용으로는 36자리 UUID `public_id`를 사용하여 보안성을 둘 다 잡기 위함입니다. +* **[면접관 꼬리 질문]**: "UUID를 DB PK로 직접 쓸 때 발생하는 물리적 B-Tree 인덱스 파편화(Fragmentation) 문제는 무엇인가요?" +* **[면접자 답변 대본]**: "무작위 v4 UUID는 순서가 없기 때문에 DB에 INSERT 될 때 B-Tree 인덱스 노드의 중간에 무작위로 위치하여 인덱스 페이지 분할(Page Split)이 자주 발생하고 디스크 I/O 성능이 급격히 저하됩니다. 정수 PK를 내부 조인용으로 쓰면 무조건 맨 뒤에 추가되므로 파편화가 없습니다." + +### Q13. 순차 증가 ID(Auto-Increment ID)를 API 응답이나 URL 파라미터로 외부에 직접 노출했을 때 발생하는 보안상 취약점은 무엇인가요? +* **상세 답변**: ID 추측 공격(Enumeration Attack)으로 인한 무단 개인정보 스크랩 노출과 경쟁사에 서비스 가입자 수 등 핵심 지표가 유출되는 위험이 있습니다. +* **[면접관 꼬리 질문]**: "UUID 외에 외부 식별자로 고려해 볼 수 있는 대안 식별자는 무엇이 있나요?" +* **[면접자 답변 대본]**: "타임스탬프와 무작위 바이트가 결합되어 생성 순서대로 정렬이 가능한 64비트 정수형 식별자인 **TSID**나 Twitter의 **Snowflake ID**를 대안으로 사용할 수 있습니다." + +### Q14. 내부 테이블 간 조인(JOIN)을 수행할 때 UUID가 아닌 숫자형 PK를 사용하는 성능적 이점은 무엇인가요? +* **상세 답변**: BIGINT(8바이트)는 UUID(36바이트)보다 크기가 훨씬 작아 인덱스 메모리 탑재량이 늘어나고, CPU 수준에서 정수 비교 연산 속도가 문자열 비교보다 훨씬 빠릅니다. +* **[면접관 꼬리 질문]**: "BIGINT PK가 다 찼을 때(Overflow)는 어떻게 되나요?" +* **[면접자 답변 대본]**: "BIGINT는 64비트 정수로 약 922경(9*10^18)개의 데이터를 저장할 수 있으므로, 일반적인 웹 서비스 환경에서는 수백 년 동안 서비스해도 데이터 오버플로우가 물리적으로 발생하지 않습니다." + +### Q15. 회원 상태(정상, 탈퇴, 정지)를 설계할 때 상태별 로그인 및 접근 제어는 서비스 레이어와 시큐리티 중 어디서 처리하는 것이 적절한가요? +* **상세 답변**: `AuthService.authenticate()` 서비스 레이어에서 비밀번호 검증 직후 검사하여 `CustomAuthException("SUSPENDED_MEMBER")`을 던지는 것이 가장 깔끔합니다. +* **[면접관 꼬리 질문]**: "이미 로그인된 정지 회원이 후속 API를 호출할 때 권한 차단은 어디서 이루어지나요?" +* **[면접자 답변 대본]**: "관리자가 유저를 정지시키는 시점에 해당 유저의 서블릿 세션을 파기하고, 추가로 Security Custom Filter나 Interceptor에서 유저의 Status를 확인하여 `SUSPENDED` 상태일 경우 즉시 403 Forbidden을 반환합니다." + +--- + +## 영역 4. 데이터베이스 & 성능/동시성 + +### Q16. 이메일 중복 체크 시 애플리케이션 검증 외에 DB UNIQUE 제약조건이 필수인 이유는 무엇인가요? +* **상세 답변**: 동시성 환경에서 두 유저가 동시 가입 요청 시 자바 검사(`existsByEmail`)를 둘 다 통과하는 경쟁 상태(Race Condition)가 발생합니다. DB `UNIQUE` 제약조건이 있어야 DB 레벨에서 `DataIntegrityViolationException`을 뿜으며 중복 생성을 물리적으로 방지합니다. +* **[면접관 꼬리 질문]**: "DB UNIQUE 제약조건 위반 예외가 터졌을 때 사용자에게는 어떻게 응답해야 하나요?" +* **[면접자 답변 대본]**: "`GlobalExceptionHandler`에서 `DataIntegrityViolationException`을 캐치하여 500 에러가 아닌 `400 Bad Request`와 함께 '이미 사용 중인 이메일입니다'라는 친절한 에러 메시지를 반환합니다." + +### Q17. N:M 다대다 관계를 JSON 배열이 아닌 중계 테이블로 분리한 이유는 무엇인가요? +* **상세 답변**: RDBMS 제1정규형(원자성) 준수 및 인덱스 검색 보장, 그리고 JPA Direct `@ManyToMany`가 유발하는 기존 관계 전체 `DELETE` 후 `INSERT` 하는 비효율을 방지하기 위함입니다. +* **[면접관 꼬리 질문]**: "중계 테이블의 PK를 (member_id, resort_id) 복합키로 하지 않고 단일 대리키(id)로 만든 이유는 무엇인가요?" +* **[면접자 답변 대본]**: "JPA에서 복합키를 사용하려면 `@IdClass`나 `@EmbeddedId`를 별도로 구현해야 하여 Entity 코드가 복잡해집니다. 단일 대리키(`id BIGINT`)를 PK로 두고 `(member_id, resort_id)`에 `복합 UNIQUE 제약조건`을 걸어두는 것이 JPA 개발 생산성과 DB 무결성을 동시에 얻는 베스트 기법입니다." + +### Q18. 프로필 조회 API 호출 시 N+1 문제 확인 및 최적화 방법은 무엇인가요? +* **상세 답변**: `logging.level.org.hibernate.SQL: debug` 로깅으로 SQL 카운트 확인 후, JPQL `JOIN FETCH` 메서드를 활용하여 단 1회의 조인 쿼리로 최적화합니다. +* **[면접관 꼬리 질문]**: "프로필 조회 시 일대다 컬렉션 2개(member_resort, member_riding_style)를 한 번에 JOIN FETCH 하면 어떤 에러가 발생하나요?" +* **[면접자 답변 대본]**: "JPA 자바 스펙상 2개 이상의 List 컬렉션을 동시에 JOIN FETCH 하면 카테시안 곱 데이터 뻥튀기로 인해 `MultipleBagFetchException`이 터지며 서버가 다운됩니다. 따라서 2개의 개별 JOIN FETCH 쿼리로 분리해 총 3회의 고정 쿼리로 조회하는 것이 물리적 극복 방안입니다." + +### Q19. BCrypt 단방향 해시와 Salt/Work Factor의 역할은 무엇인가요? +* **상세 답변**: BCrypt는 복호화가 불가능한 해시 암호화입니다. Salt는 무작위 문자를 덧붙여 무지개 테이블 공격을 막고, Work Factor(Cost=10)는 1,024회 연쇄 연산으로 해시 속도를 일부러 연장시켜 무차별 대입 공격(Brute Force)을 연장 지연시킵니다. +* **[면접관 꼬리 질문]**: "BCrypt 알고리즘에서 Work Factor를 10에서 14로 올리면 어떤 변화가 생기나요?" +* **[면접자 답변 대본]**: "Work Factor는 2의 거듭제곱으로 연산 횟수가 늘어납니다. Cost 10은 2^10=1,024회 연산이지만, Cost 14는 2^14=16,384회 연산으로 CPU 연산 시간이 16배 증가합니다. 보안은 강력해지지만 로그인 시 서버 CPU 부하가 커지는 트레이드오프가 발생합니다." + +--- + +## 영역 5. 아키텍처 & 확장성 + +### Q20. Scale-out 시 세션 문제 해결 방안(Sticky Session vs Redis 클러스터링)은 무엇인가요? +* **상세 답변**: Sticky Session은 로드밸런서가 특정 서버로만 트래픽을 고정(서버 다운 시 세션 소실)합니다. Redis 공유 세션 클러스터링(Spring Session Data Redis)은 중앙 In-Memory DB인 Redis에 세션을 공유 저장하여 완벽한 Stateless 수준의 확장성을 제공합니다. +* **[면접관 꼬리 질문]**: "Redis 공유 세션을 사용할 때 Redis가 장애로 죽으면 어떻게 대비할 것인가요?" +* **[면접자 답변 대본]**: "Redis Sentinel이나 Redis Cluster 구조를 구축하여 Primary 노드 장애 시 Secondary 노드가 1~2초 이내에 Primary로 자동 승격되는 Failover 시스템을 구축해 고가용성을 확보합니다." + +### Q21. Controller와 Service, Entity 간의 책임 분리 기준은 무엇인가요? +* **상세 답변**: Controller(HTTP 요청 검증, 쿠키/세션 제어, DTO 반환), Service(트랜잭션 경계, 비즈니스 규칙 제어 - 서블릿 API 참조 금지), Entity(도메인 핵심 비즈니스 메서드 직접 보유 및 스스로 검증). +* **[면접관 꼬리 질문]**: "Service에서 HttpServletRequest를 파라미터로 받으면 왜 안 되나요?" +* **[면접자 답변 대본]**: "Service 계층이 Web 서블릿 스펙에 강하게 결합되어 단위 테스트 시 Web 객체를 Mocking해야 하는 어려움이 생기고, 향후 gRPC나 메시지 큐 등 다른 전송 프로토콜로 변경할 때 Service 로직 전체를 재작성해야 하는 단일 책임 원칙(SRP) 위반이 생깁니다." + +### Q22. 로그아웃 연계 처리 방식은 무엇인가요? +* **상세 답변**: 서버 측 `session.invalidate()` 실행 ➔ 응답 헤더 `Set-Cookie: JSESSIONID=; Max-Age=0` 전달로 브라우저 쿠키를 즉시 파기합니다. +* **[면접관 꼬리 질문]**: "클라이언트가 로그아웃 요청을 보내지 않고 브라우저 탭을 그냥 닫아버리면 서버 세션은 어떻게 되나요?" +* **[면접자 답변 대본]**: "서버 세션은 유효 상태로 남아있지만, 설정된 세션 타임아웃(1시간) 동안 추가 요청이 들어오지 않으면 톰캣 세션 스캐너가 만료된 세션을 메모리에서 자동으로 정리합니다." + +--- + +## 영역 6. 검증 & 테스트 + +### Q23. Bean Validation과 도메인 예외 변환 전략은 무엇인가요? +* **상세 답변**: DTO에 `@NotBlank`, `@Email`을 적용하고 예외 발생 시 `GlobalExceptionHandler`가 `MethodArgumentNotValidException`을 캐치하여 `400 Bad Request` + JSON 에러 메시지로 변환합니다. +* **[면접관 꼬리 질문]**: "통합 테스트 작성 시 세션 로그인 후 보호된 API 접근 및 로그아웃 검증은 어떻게 수행했나요?" +* **[면접자 답변 대본]**: "`MockMvc`를 활용해 `post("/api/auth/login")` 실행 후 반환된 `MockHttpSession` 객체를 획득하고, `get("/api/members/me").session(session)`으로 정상 조회를 검증한 뒤, `post("/api/auth/logout").session(session)`을 실행하고 재접근 시 401 Unauthorized가 리턴되는지 검증하는 순서로 테스트를 완비했습니다." + +--- + +# 📑 PART 3. 7대 기술 필수 요소 체계 해설 (7 Core Elements) + +1. **세션 고정 방어 (`changeSessionId()`)**: 로그인 시 세션 ID 재발급으로 세션 탈취 차단. +2. **이중 식별자 (Dual PK)**: `id BIGINT` (조인 성능) + `public_id UUID` (REST API 보안). +3. **JPA Auditing (`BaseTimeEntity`)**: `@PrePersist` 후킹으로 1차 캐시 시간 주입 및 DB sysdate 불일치 차단. +4. **N+1 & MultipleBagFetchException 극복**: 다중 1:N 컬렉션 조회 시 3회 개별 `JOIN FETCH` 분리 쿼리로 카테시안 곱과 N+1 원천 차단. + +--- + +# 📑 PART 4. Snowthing DB DDL 명세 & 제약조건 설계 이유 + +```sql +-- 1. 회원 마스터 테이블 (member) +CREATE TABLE member ( + member_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- DB 내부 조인 성능용 8바이트 PK + public_id VARCHAR(36) NOT NULL UNIQUE, -- 외부 API 노출용 보안 UUID + email VARCHAR(255) NOT NULL UNIQUE, -- 이메일 동시 가입 락 방어용 UNIQUE + password VARCHAR(255) NOT NULL, -- BCrypt 해시 암호화 비밀번호 + nickname VARCHAR(100) NOT NULL UNIQUE, -- 닉네임 중복 방지 UNIQUE + role VARCHAR(20) NOT NULL DEFAULT 'ROLE_USER', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. N:M 중계 테이블 (member_resort) +CREATE TABLE member_resort ( + member_resort_id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + resort_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT fk_member_resort_member FOREIGN KEY (member_id) REFERENCES member(member_id) ON DELETE CASCADE, + CONSTRAINT fk_member_resort_resort FOREIGN KEY (resort_id) REFERENCES resort(resort_id) ON DELETE CASCADE, + CONSTRAINT uk_member_resort UNIQUE (member_id, resort_id) -- 동일 스키장 중복 등록 방지 복합 UNIQUE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` diff --git a/docs/study/sprint01/3.md b/docs/study/sprint01/3.md new file mode 100644 index 0000000..1f1e50a --- /dev/null +++ b/docs/study/sprint01/3.md @@ -0,0 +1,264 @@ +# 📘 [공부용 Master Guide] Snowthing 백엔드·인프라·JPA·보안 핵심 이론 Deep-Dive (2026-08-19) + +> **노션(Notion) 복사 전용 - 개념/원리 깊이 있는 공부 가이드 문서** +> 본 문서는 Snowthing 프로젝트의 인프라 아키텍처, 톰캣/시큐리티 물리적 동작 메커니즘, 7대 서술 체계 기반 핵심 기술 해설, DDL 제약조건의 물리적 존재 이유를 깊이 있게 파헤친 공부 전용 문서입니다. + +--- + +# 📑 CHAPTER 1. Snowthing 백엔드 & 인프라 아키텍처 물리적 원리 + +## 1.1 시스템 모노레포 구조 및 인프라 구성 +```text +snowthing/ +├── backend/ # Spring Boot 3.x, Java 21, Spring Security 6, H2/MySQL +│ ├── src/main/java/com/ikae/snowthing/ +│ │ ├── domain/auth/ # 인증/로그인 (Controller, Service, DTO) +│ │ ├── domain/member/ # 회원/프로필/마스터데이터 (Controller, Service, Repository, Entity) +│ │ └── global/ # SecurityConfig, GlobalExceptionHandler, BaseTimeEntity +│ └── src/test/java/ # 25개 통합/단위 테스트 수트 +├── frontend/ # Next.js 16 (App Router), React 19, TypeScript 6, Tailwind CSS +└── docs/ # 설계(conception), 작업기록(project), 공부가이드(study) +``` + +## 1.2 서블릿 컨테이너(Tomcat) & Spring Security Filter Chain 물리적 위치 + +```text +[HTTP Client Request] + │ (Cookie: JSESSIONID=32자리랜덤키) + ▼ +[Tomcat Servlet Container Layer] + │ - Servlet Context & HttpSession Registry + │ - DelegatingFilterProxy (서블릿 스펙 필터가 Spring Context로 위임) + ▼ +[Spring Security FilterChainProxy Layer] + │ 1. CorsFilter (Cross-Origin 검증: http://localhost:3000 허용) + │ 2. LogoutFilter (/api/auth/logout 감지 시 세션 무효화 및 쿠키 파기) + │ 3. SecurityContextHolderFilter (HttpSession에서 SecurityContext 읽어 ThreadLocal 복원) + │ 4. ExceptionTranslationFilter (401/403 예외 처리) + │ 5. AuthorizationFilter (URL 및 Role 권한 검증) + ▼ +[Spring MVC DispatcherServlet Layer] + │ - HandlerMapping ➔ Controller ➔ Service ➔ Repository +``` + +## 1.3 DB 인프라 & Connection Pool (HikariCP) & 영속성 컨텍스트 +* **H2 (In-Memory/Test)**: `jdbc:h2:mem:snowdb;DB_CLOSE_DELAY=-1` ➔ 빠른 테스트 실행 및 격리된 메모리 DB. +* **HikariCP Connection Pool**: 기본 10개 커넥션 풀을 미리 생성하여 DB 커넥션 맺고 끊는 RTT(Round Trip Time) 오버헤드 최소화. +* **JPA 영속성 컨텍스트 1차 캐시**: 엔티티의 식별자(`id`)를 키로 사용하는 `Map` 구조 메모리 저장소. 동일 트랜잭션 내 반복 조회 시 DB를 거치지 않고 1차 캐시에서 즉시 반환(O(1) 성능). + +--- + +# 📑 CHAPTER 2. 4대 핵심 기술 7대 서술 요소 체계 (7 Core Elements) + +AGENTS.md 규칙 21에 의거하여 프로젝트 4대 핵심 기술의 물리적 원리를 7대 서술 체계로 정밀 해설합니다. + +--- + +## 2.1 세션 고정 방어 (`changeSessionId()`) + +### ① 개념 (명확한 정의) +로그인 성공 시 기존 세션 내부 데이터는 유지하면서 **세션 식별자 키(Session ID)만 무작위 새 32자리 문자열로 재발급**하는 보안 메커니즘입니다. + +### ② 왜 사용하는지 (Why - 도입 목적) +해커가 비회원 상태에서 발급받은 세션 ID를 유저에게 쥐여준 뒤, 유저가 로그인했을 때 동일한 세션 ID로 유저 계정을 무단 탈취하는 **세션 고정 공격(Session Fixation)을 물리적으로 100% 차단**하기 위함입니다. + +### ③ 어떨 때 사용하는지 (When - 사용 상황) +유저가 자격 증명(이메일/비밀번호)을 제출하여 인증된 상태(Authenticated State)로 권한이 상승하는 **로그인 성공 직후**에 실행합니다. + +### ④ 어떻게 사용하는지 (How - 구체적 구현 예시) +```java +@PostMapping("/login") +public ResponseEntity login( + @Valid @RequestBody MemberLoginRequest loginRequest, + HttpServletRequest httpRequest +) { + Member member = authService.authenticate(loginRequest.getEmail(), loginRequest.getPassword()); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + member.getEmail(), null, List.of(new SimpleGrantedAuthority(member.getRole().getKey())) + ); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + + HttpSession session = httpRequest.getSession(true); + httpRequest.changeSessionId(); // 👈 기존 세션 데이터 유지 + 세션 ID 키 재발급! + session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext); + + int timeoutSeconds = loginRequest.isRememberMe() ? 30 * 24 * 60 * 60 : 60 * 60; + session.setMaxInactiveInterval(timeoutSeconds); + + return ResponseEntity.ok(authService.getMyProfile(member.getEmail())); +} +``` + +### ⑤ 장점은 무엇인지 (Pros) +1. 해커가 쥐고 있던 엣날 세션 ID가 로그인 즉시 무효화되므로 계정 탈취가 원천 차단됩니다. +2. 비회원 시절 세션에 저장된 장바구니/임시 데이터를 잃어버리지 않고 유지할 수 있습니다. + +### ⑥ 다른 기술/대안은 무엇이 있는지 (Alternatives) +* **`newSession()`**: 기존 세션을 파기하고 새 세션을 만듦. (장바구니 등 기존 세션 데이터 소실) +* **`none()`**: 세션 ID를 바꾸지 않음. (세션 고정 공격에 무방비 노출) + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프**: 톰캣 세션 레지스트리의 세션 키 맵 재갱신 CPU 오버헤드. +* **극복 방안**: 로그인 시 단 1회 실행되므로 성능 영향은 0.001% 미만으로 극히 작고 보안 이점이 압도적임. + +--- + +## 2.2 이중 식별자 전략 (Dual PK: BIGINT AUTO_INCREMENT + public_id UUID) + +### ① 개념 (명확한 정의) +DB 내부 조인 및 FK 참조용으로는 **`id BIGINT AUTO_INCREMENT` (숫자형 대리키)**를 사용하고, API URL 및 외부 노출용으로는 **`public_id VARCHAR(36) UUID` (문자열 난수 식별자)**를 사용하는 식별자 분리 전략입니다. + +### ② 왜 사용하는지 (Why - 도입 목적) +**API 보안성**(ID 추측 공격 차단)과 **DB 조인 성능**(B-Tree 정수 인덱스 연산)이라는 두 가치를 동시에 달성하기 위함입니다. + +### ③ 어떨 때 사용하는지 (When - 사용 상황) +REST API URL에 식별자가 노출되거나(`GET /api/members/550e8400-e29b-41d4-a716-446655440000`), 외부 공격자의 개인정보 무단 스크랩을 차단해야 하는 모든 엔티티 설계 시 적용합니다. + +### ④ 어떻게 사용하는지 (How - 구체적 구현 예시) +```java +@Entity +@Table(name = "member") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; // DB 내부 인덱스/조인용 8바이트 정수 PK + + @Column(name = "public_id", nullable = false, unique = true, length = 36) + private String publicId; // 외부 API 노출용 36자리 UUID + + @PrePersist + public void createPublicId() { + if (this.publicId == null) { + this.publicId = UUID.randomUUID().toString(); + } + } +} +``` + +### ⑤ 장점은 무엇인지 (Pros) +1. ID 추측 공격(Enumeration Attack) 및 비즈니스 데이터 유출 원천 차단. +2. DB 내부 조인은 8바이트 BIGINT로 수행되어 최상의 B-Tree 인덱스 연산 속도 보장. + +### ⑥ 다른 기술/대안은 무엇이 있는지 (Alternatives) +* **UUID 단일 PK**: PK 자체를 36바이트 UUID로 사용. (인덱스 크기 증가, Page Split으로 디스크 I/O 급증) +* **TSID / Snowflake ID**: 정렬 가능한 64비트 정수형 순차 UUID. (별도 라이브러리 필요) + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프**: 컬럼 2개 사용으로 DB 용량 소량 증가. +* **극복 방안**: `public_id`에 `UNIQUE INDEX`를 걸어 O(1) 조회를 보장하고 내부 조인은 100% `id`로만 수행. + +--- + +## 2.3 JPA Auditing (`BaseTimeEntity`) 기반 자동 시간 주입 + +### ① 개념 (명확한 정의) +엔티티 생성(`INSERT`) 및 수정(`UPDATE`) 시 시간을 개발자가 직접 입력하지 않고, JPA 영속성 이벤트를 후킹하여 자동으로 주입하는 기술입니다. + +### ② 왜 사용하는지 (Why - 도입 목적) +DB `sysdate` 사용 시 발생할 수 있는 **1차 캐시 메모리 상의 엔티티 `createdAt=null` 데이터 불일치를 방지**하기 위함입니다. + +### ③ 어떨 때 사용하는지 (When - 사용 상황) +모든 엔티티의 생성 시각, 수정 시각 관리가 필요한 공통 추상 클래스 작성 시 사용합니다. + +### ④ 어떻게 사용하는지 (How - 구체적 구현 예시) +```java +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} +``` + +### ⑤ 장점은 무엇인지 (Pros) +1. `save()` 즉시 1차 캐시 메모리 엔티티에 시간이 주입되어 메모리-DB 간 무결성 100% 보장. +2. 중복 코드 완전 제거. + +### ⑥ 다른 기술/대안은 무엇이 있는지 (Alternatives) +* **DB Default Value (`sysdate`)**: DB에는 시간이 들어가지만 1차 캐시 엔티티의 `createdAt`은 `null`로 남음. +* **수동 `LocalDateTime.now()`**: 개발자가 일일이 코드를 작성해야 함 (중복 발생). + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프**: 메인 클래스에 `@EnableJpaAuditing` 누락 시 동작 안 함. +* **극복 방안**: 별도의 `@Configuration` 클래스로 분리하여 단위 테스트 로딩 에러 예방. + +--- + +## 2.4 다중 1:N 조인 시 `MultipleBagFetchException` 및 3회 `JOIN FETCH` 최적화 + +### ① 개념 (명확한 정의) +JPA에서 2개 이상의 일대다(`1:N`) List 컬렉션을 단 1개 JPQL에서 동시 `JOIN FETCH` 시 발생하는 `MultipleBagFetchException` 예외와, 이를 피하기 위한 3회 개별 JOIN FETCH 최적화 기법입니다. + +### ② 왜 사용하는지 (Why - 도입 목적) +N+1 쿼리 폭발을 막으면서, 서버가 다운되는 `MultipleBagFetchException`을 원천 차단하기 위함입니다. + +### ③ 어떨 때 사용하는지 (When - 사용 상황) +한 엔티티(`Member`)가 2개 이상의 N:M 중계 컬렉션을 가지고, 이를 프로필 화면에서 한 번에 조회해야 할 때 사용합니다. + +### ④ 어떻게 사용하는지 (How - 구체적 구현 예시) +```java +// 1. MemberResort + Resort 1회 JOIN FETCH +@Query("SELECT mr FROM MemberResort mr JOIN FETCH mr.resort WHERE mr.member.id = :memberId") +List findAllByMemberIdWithResort(@Param("memberId") Long memberId); + +// 2. MemberRidingStyle + RidingStyle 1회 JOIN FETCH +@Query("SELECT mrs FROM MemberRidingStyle mrs JOIN FETCH mrs.ridingStyle WHERE mrs.member.id = :memberId") +List findAllByMemberIdWithRidingStyle(@Param("memberId") Long memberId); +``` + +### ⑤ 장점은 무엇인지 (Pros) +1. 회원 수가 10,000명으로 늘어나도 프로필 조회 쿼리는 N+1이 아니라 항상 고정 3회만 실행. +2. `MultipleBagFetchException`이 터지지 않아 서버 고가용성 보장. + +### ⑥ 다른 기술/대안은 무엇이 있는지 (Alternatives) +* **`Set` 컬렉션 사용**: 단 1개 쿼리로 가능하나 순서 보장이 안 되고 카테시안 곱 메모리 오버헤드 발생. +* **`default_batch_fetch_size`**: `IN (?, ?, ?)` 쿼리로 가져오는 방식. + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프**: 1회가 아닌 총 3회의 SQL 실행. +* **극복 방안**: N+1(101회 쿼리) 대비 커넥션 비용을 97% 절감하므로 최고의 실무 극복 방안임. + +--- + +# 📑 CHAPTER 3. DB DDL 명세 & 제약조건 설계 이유 + +```sql +-- 1. 회원 마스터 테이블 (member) +CREATE TABLE member ( + member_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- [PK] DB 내부 인덱스 연산 8바이트 정수 대리키 + public_id VARCHAR(36) NOT NULL UNIQUE, -- [UNIQUE] 외부 API 노출용 보안 UUID + email VARCHAR(255) NOT NULL UNIQUE, -- [UNIQUE] 이메일 동시 가입 락(Race Condition) 방어용 + password VARCHAR(255) NOT NULL, -- [NOT NULL] BCrypt 60자리 해시 암호화 비밀번호 + nickname VARCHAR(100) NOT NULL UNIQUE, -- [UNIQUE] 닉네임 중복 방지 UNIQUE + role VARCHAR(20) NOT NULL DEFAULT 'ROLE_USER', -- [DEFAULT] 기본 권한 + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- [DEFAULT] 회원 상태 (ACTIVE, SUSPENDED, DELETED) + created_at DATETIME(6) NOT NULL, -- [NOT NULL] JPA Auditing 생성시간 + updated_at DATETIME(6) NOT NULL -- [NOT NULL] JPA Auditing 수정시간 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. N:M 선호 스키장 중계 테이블 (member_resort) +CREATE TABLE member_resort ( + member_resort_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- [PK] 중계 엔티티 단일 대리키 + member_id BIGINT NOT NULL, -- [FK] 회원 테이블 참조 + resort_id BIGINT NOT NULL, -- [FK] 스키장 마스터 테이블 참조 + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT fk_member_resort_member FOREIGN KEY (member_id) REFERENCES member(member_id) ON DELETE CASCADE, + CONSTRAINT fk_member_resort_resort FOREIGN KEY (resort_id) REFERENCES resort(resort_id) ON DELETE CASCADE, + CONSTRAINT uk_member_resort UNIQUE (member_id, resort_id) -- [복합 UNIQUE] 동일 회원-스키장 중복 등록 차단 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` diff --git a/docs/study/sprint01/4.md b/docs/study/sprint01/4.md new file mode 100644 index 0000000..6edce4f --- /dev/null +++ b/docs/study/sprint01/4.md @@ -0,0 +1,185 @@ +# 🎙️ [면접 & 발표 대본 Master Guide] Snowthing 프로젝트 발표 및 23대 면접 질문 1:1 대본 (2026-08-19) + +> **노션(Notion) 복사 전용 - 프레젠테이션 발표 및 실전 면접 Q&A 대본 가이드** +> 본 문서는 Snowthing 프로젝트의 기획 의도, 도메인, 구현된 백엔드/프론트엔드/CI-CD 전체 발표 대본과 함께, 6대 영역 23개 기술 질문 및 면접관-면접자 꼬리 질문 답변 대본을 1:1로 100% 완비한 면접 전용 마스터 문서입니다. + +--- + +# 📑 PART 1. Snowthing 프로젝트 실전 프레젠테이션 발표 대본 (Presentation) + +### 🎙️ [발표 오프닝] +"안녕하세요, 스노보더 전용 커뮤니티 & 같이타요/카풀 매칭 플랫폼 **Snowthing**의 백엔드 개발자입니다. 오늘 발표에서는 Snowthing 프로젝트의 도메인 설계, 개발된 핵심 기능, 그리고 성능과 보안을 고려한 백엔드 아키텍처 의사결정에 대해 말씀드리겠습니다." + +--- + +### 🎙️ [1. 기획 배경 및 프로젝트 도메인 구조] +"스노보드 라이더들은 겨울 시즌 동안 함께 슬로프를 달릴 동행(같이타요), 카풀 모집, 스키장 리프트 혼잡도 정보에 대한 강한 니즈를 가지고 있습니다. Snowthing은 이를 해결하기 위해 3개 핵심 도메인으로 구성되었습니다. +1. **회원/인증 도메인 (`Member`, `Auth`)**: 이메일 기반 가입, 세션 로그인/로그아웃, 보안 프로필 관리. +2. **마스터 데이터 도메인 (`Resort`, `RidingStyle`)**: 전국 6대 스키장 및 6대 라이딩 성향 정보. +3. **N:M 다대다 중계 도메인 (`MemberResort`, `MemberRidingStyle`)**: 유저별 선호 스키장 및 라이딩 성향 매칭." + +--- + +### 🎙️ [2. 현재까지 구현된 백엔드 & 프론트엔드 핵심 기능] +"현재까지 구현하여 검증을 마친 주요 기능은 다음과 같습니다. +1. **회원가입 & 비밀번호 BCrypt 암호화**: 비밀번호는 Cost Factor 10의 BCrypt 단방향 해시로 암호화하여 DB 보안을 강화했습니다. +2. **Spring Security 기반 세션 인증 & 세션 고정 방어**: 로그인 성공 시 `request.changeSessionId()`를 수행하여 세션 고정 공격(Session Fixation)을 차단하고, 1시간 슬라이딩 세션 및 30일 Remember-Me 세션 타임아웃을 구현했습니다. +3. **세션 무효화 연계 로그아웃**: `POST /api/auth/logout` 호출 시 서버 메모리의 `session.invalidate()`와 브라우저 `JSESSIONID` 쿠키 파기(`Max-Age=0`)를 연계 처리했습니다. +4. **MasterData DTO 변환 반환**: JPA 엔티티 직접 노출을 100% 제거하고 `ResortResponse`, `RidingStyleResponse` record DTO로 변환하여 DB 스키마 유출과 무한 순환 참조를 차단했습니다. +5. **Next.js 16 프론트엔드 연동 & CORS `withCredentials`**: `http://localhost:3000`과 `http://localhost:8080` 간 CORS 환경에서 `withCredentials: true` 세션 쿠키 전송 파이프라인을 완비했습니다." + +--- + +### 🎙️ [3. 아키텍처 및 성능/보안 주요 의사결정] +"기술적 완성도를 높이기 위해 3가지 아키텍처 결정을 내렸습니다. +1. **Dual PK 전략**: DB 내부 조인은 8바이트 정수인 `id BIGINT AUTO_INCREMENT`로 실행하여 B-Tree 인덱스 연산 속도를 극대화하고, 외부 REST API URL 노출용으로는 36자리 `public_id UUID`를 적용해 ID 추측 공격(Enumeration Attack)을 100% 차단했습니다. +2. **JPA Auditing 1차 캐시 시간 주입**: DB `sysdate` 대신 JPA `@PrePersist` 후킹을 사용해 `save()` 즉시 1차 캐시 엔티티에 시간을 주입함으로써 메모리-DB 간 데이터 무결성을 유지했습니다. +3. **N+1 및 `MultipleBagFetchException` 극복**: 2개 이상의 일대다 컬렉션 조인 시 발생하는 `MultipleBagFetchException`을 피하기 위해 2개의 개별 `JOIN FETCH` 쿼리로 분리하여, 회원 수가 늘어나도 프로필 조회가 항상 고정 3회 쿼리로 실행되도록 최적화했습니다." + +--- + +### 🎙️ [4. 테스트 자동화 및 CI/CD] +"마지막으로 `MockMvc`와 Spring Boot Test를 활용해 세션 로그인, 프로필 CRUD, 401/403 예외 차단, 동시성 회원가입 락 검증 등 **총 25개 통합/단위 테스트 수트를 작성하여 100% PASS**시켰으며, GitHub Actions CI 파이프라인과 `gemini-3.6-flash` 기반 AI 코드 리뷰 시스템을 구축했습니다." + +--- + +# 📑 PART 2. 6대 영역 23개 기술 질문 & 꼬리 질문 1:1 대본 (Q1~Q23) + +--- + +## 영역 1. Spring Security & 인증/인가 흐름 + +### Q1. Spring Security Filter Chain의 동작 원리와 주요 필터들의 역할은 무엇인가요? +* **답변**: 서블릿 컨테이너(Tomcat)의 `DelegatingFilterProxy`가 요청을 받으면 Spring Bean인 `FilterChainProxy`에 위임하여 보안 필터들을 순차적으로 실행합니다. `CorsFilter`(Cors 검증), `LogoutFilter`(로그아웃 처리), `SecurityContextHolderFilter`(세션에서 SecurityContext 복원), `ExceptionTranslationFilter`(401/403 감지), `AuthorizationFilter`(URL 권한 검증)가 실행됩니다. +* **[면접관 꼬리 질문]**: "DelegatingFilterProxy와 FilterChainProxy의 물리적 차이는 무엇인가요?" +* **[면접자 답변 대본]**: "Tomcat은 서블릿 스펙으로 동작하여 Spring Bean을 인식하지 못하므로, 서블릿 필터인 `DelegatingFilterProxy`가 요청을 받아 Spring Context 내의 `FilterChainProxy` Bean으로 위임해 줍니다. 이를 통해 스프링의 DI 기능으로 보안 필터들을 관리할 수 있게 됩니다." + +### Q2. Authentication(인증)과 Authorization(인가)의 명확한 차이는 무엇인가요? +* **답변**: Authentication(인증)은 "이 사용자가 누구인가?"를 검증하는 신원 확인 과정(로그인)이고, Authorization(인가)은 "인증된 사용자가 특정 리소스에 접근할 권한이 있는가?"를 검증하는 권한 확인 과정(Role 검증)입니다. +* **[면접관 꼬리 질문]**: "인증 객체인 Authentication은 어디에 저장되나요?" +* **[면접자 답변 대본]**: "`SecurityContextHolder` 내부의 ThreadLocal에 저장되어, 동일한 스레드 내에서는 Controller, Service 등 어느 레이어에서나 `SecurityContextHolder.getContext().getAuthentication()`으로 접근할 수 있습니다." + +### Q3. 세션 기반 인증의 전체 흐름을 설명해 주세요. +* **답변**: `POST /api/auth/login` 요청 ➔ BCrypt 비밀번호 검증 ➔ `SecurityContext` 생성 ➔ `request.changeSessionId()` 세션 고정 방어 ➔ 세션 저장 ➔ `Set-Cookie: JSESSIONID=32자리랜덤키; Path=/; HttpOnly; SameSite=Lax` 발급 ➔ 후속 요청 시 쿠키 전송 ➔ `SecurityContextHolderFilter`가 세션 복원. +* **[면접관 꼬리 질문]**: "`request.getSession(true)`와 `request.getSession(false)`의 차이는 무엇인가요?" +* **[면접자 답변 대본]**: `true`는 세션이 없으면 새로 생성하고, `false`는 세션이 없으면 `null`을 반환합니다. 로그인 시에는 세션을 생성해야 하므로 `true`, 단순 조회 시에는 `false`를 사용해 불필요한 세션 생성을 막습니다." + +### Q4. 401 Unauthorized와 403 Forbidden 예외 처리 분리 방식은 무엇인가요? +* **답변**: 401(미인증)은 `AuthenticationEntryPoint`가 동작하고, 403(권한부족)은 `AccessDeniedHandler`가 동작합니다. `SecurityConfig`에서 JSON 응답(`SC_UNAUTHORIZED`, `SC_FORBIDDEN`)을 반환하도록 설정했습니다. +* **[면접관 꼬리 질문]**: "`ExceptionTranslationFilter`는 체인의 어느 위치에서 예외를 잡나요?" +* **[면접자 답변 대본]**: "`AuthorizationFilter` 바로 앞에 위치하여 하위 필터나 Controller에서 던져진 AuthenticationException 및 AccessDeniedException을 try-catch로 감싸 캐치한 뒤 처리합니다." + +### Q5. Session 방식과 JWT 토큰 방식의 장단점을 비교하고 세션을 선택한 이유는 무엇인가요? +* **답변**: 세션(Stateful)은 서버에서 세션을 즉시 파기할 수 있어 보안성이 뛰어나지만 서버 메모리를 사용합니다. JWT(Stateless)는 확장이 쉬우나 토큰 탈취 시 강제 제어가 불가능합니다. 본 프로젝트는 즉각적인 강제 로그아웃 통제와 보안성, Remember-Me(30일) UX를 위해 세션을 선택했습니다. +* **[면접관 꼬리 질문]**: "JWT에서 세션처럼 강제 로그아웃을 구현하려면 어떻게 해야 하나요?" +* **[면접자 답변 대본]**: "Redis에 만료된 Access Token을 Blacklist로 등록하거나 Refresh Token을 Redis에서 삭제해야 합니다. 하지만 이 경우 결국 Redis 상태를 관리하게 되어 JWT의 순수 Stateless 장점이 희석됩니다." + +--- + +## 영역 2. 세션 & 쿠키 보안 + +### Q6. 세션 고정 공격(Session Fixation)과 `changeSessionId()`의 역할은 무엇인가요? +* **답변**: 공격자가 발급받은 비회원 세션 ID를 유저에게 쥐여준 뒤 로그인 시 계정을 탈취하는 공격입니다. `changeSessionId()`는 로그인 성공 직후 기존 세션 데이터는 유지하면서 세션 ID만 무작위 새 문자열로 재발급하여 공격자의 세션 ID를 무효화시킵니다. +* **[면접관 꼬리 질문]**: "Spring Security 6의 기본 세션 고정 방어 전략은 무엇인가요?" +* **[면접자 답변 대본]**: "Servlet 3.1+ 컨테이너 환경을 감지하여 기본적으로 `changeSessionId()` 방식이 자동으로 동작합니다." + +### Q7. 쿠키 5대 보안 속성(HttpOnly, Secure, SameSite, Path, Max-Age)의 역할은 무엇인가요? +* **답변**: `HttpOnly`(자바스크립트 접근 차단/XSS 방어), `Secure`(HTTPS 채널만 전송), `SameSite=Lax`(Cross-Site 전송 제어/CSRF 방어), `Path=/`(전체 API 쿠키 전송), `Max-Age`(만료 시간 지정). +* **[면접관 꼬리 질문]**: "SameSite 속성 중 Strict, Lax, None의 차이는 무엇인가요?" +* **[면접자 답변 대본]**: `Strict`는 모든 Cross-Site 전송 차단, `Lax`는 안전한 GET 탑레벨 이동 시 전송 허용, `None`은 모든 전송 허용(단, `Secure=true` 필수)을 의미합니다." + +### Q8. 개발 환경과 운영 환경의 쿠키 보안 설정 차이점은 무엇인가요? +* **답변**: 개발(`http://localhost:3000`)은 SSL이 없으므로 `Secure=false`, 운영(`https`)은 `Secure=true`를 적용해야 쿠키 전송이 거부되지 않습니다. +* **[면접관 꼬리 질문]**: "도메인이 다를 때 SameSite 설정은 어떻게 하나요?" +* **[면접자 답변 대본]**: "도메인이 완전히 다르면 `SameSite=None`과 `Secure=true`를 적용하고, CORS 응답 헤더에 `Access-Control-Allow-Credentials: true`를 명시해야 합니다." + +### Q9. 세션 만료 시간(Timeout) 정책은 어떻게 설정했나요? +* **답변**: 일반 세션은 1시간 슬라이딩 세션(활동 시 연장)으로 세션 쿠키를 사용하고, Remember-Me 체크 시 30일(`2,592,000초`) 만료 쿠키를 발급합니다. +* **[면접관 꼬리 질문]**: "슬라이딩 세션은 톰캣 내부에서 어떻게 작동하나요?" +* **[면접자 답변 대본]**: "요청이 들어올 때마다 `session.getLastAccessedTime()`이 현재 시간으로 갱신되며 만료 카운트다운(3600초)이 처음부터 리셋되는 원리입니다." + +### Q10. 세션 객체에 회원 엔티티 전체 대신 식별자(ID)와 Role만 저장해야 하는 이유는 무엇인가요? +* **답변**: 1. 서버 RAM 메모리 절약(OOM 방지), 2. 정보 변경 시 세션-DB 데이터 불일치 방지, 3. JPA 지연 로딩 프록시 직렬화 에러(`NotSerializableException`) 예방을 위함입니다. +* **[면접관 꼬리 질문]**: "세션에 식별자만 넣었을 때 매 요청 DB 조회 오버헤드는 어떻게 해결하나요?" +* **[면접자 답변 대본]**: "Spring Data JPA 1차 캐시 메모리와 PK 인덱스 조회를 통해 O(1) 성능으로 접근하므로 DB 오버헤드는 거의 없습니다." + +### Q11. 다중 로그인(동시 로그인) 제어 정책은 어떻게 설계하나요? +* **답변**: `SecurityConfig`에서 `.maximumSessions(1)`을 설정하여 기존 세션을 만료시키거나 새 로그인을 차단합니다. +* **[면접관 꼬리 질문]**: "기존 세션 만료 시 유저에게 어떤 응답을 내려주나요?" +* **[면접자 답변 대본]**: "`sessionInformationExpiredStrategy()`를 구현하여 401 JSON 응답과 함께 '다른 기기에서 로그인되었습니다'라는 메시지를 내보냅니다." + +--- + +## 영역 3. 도메인 설계 & 식별자(PK/UUID) + +### Q12. Dual PK Strategy(`BIGINT AUTO_INCREMENT` + `public_id UUID`)를 사용하는 이유는 무엇인가요? +* **답변**: 내부 조인은 8바이트 정수 `id`로 실행하여 B-Tree 인덱스 조인 성능을 극대화하고, 외부 REST API URL 노출용으로는 36자리 UUID `public_id`를 사용하여 보안성을 둘 다 잡기 위함입니다. +* **[면접관 꼬리 질문]**: "UUID를 DB PK로 직접 쓸 때 발생하는 B-Tree 인덱스 파편화(Fragmentation) 문제는 무엇인가요?" +* **[면접자 답변 대본]**: "무작위 UUID는 순서가 없어 INSERT 시 B-Tree 인덱스 중간에 무작위로 위치하며 페이지 분할(Page Split)이 빈번하게 발생해 디스크 I/O가 급증합니다. 정수 PK는 순차 추가되어 파편화가 없습니다." + +### Q13. Auto-Increment ID를 외부에 노출할 때의 취약점은 무엇인가요? +* **답변**: ID 추측 공격(Enumeration Attack)을 통한 개인정보 무단 스크랩과 비즈니스 가입자 수 지표 유출 위험이 있습니다. +* **[면접관 꼬리 질문]**: "UUID 외에 고려해 볼 대안 식별자는 무엇이 있나요?" +* **[면접자 답변 대본]**: "정렬 가능한 64비트 정수형 식별자인 **TSID**나 Twitter의 **Snowflake ID**를 고려할 수 있습니다." + +### Q14. 내부 JOIN 연산 시 숫자형 PK 사용의 성능적 이점은 무엇인가요? +* **답변**: BIGINT(8바이트)는 UUID(36바이트)보다 메모리가 훨씬 작아 인덱스 탑재량이 늘어나고, CPU 정수 비교 연산 속도가 문자열 비교보다 빠릅니다. +* **[면접관 꼬리 질문]**: "BIGINT PK 오버플로우가 발생할 가능성은 없나요?" +* **[면접자 답변 대본]**: "BIGINT는 64비트 정수로 약 922경 개의 데이터를 저장할 수 있어 일반적인 서비스 환경에서는 물리적으로 오버플로우가 발생하지 않습니다." + +### Q15. 회원 상태(정상, 탈퇴, 정지) 검증 위치는 어디가 적절한가요? +* **답변**: `AuthService.authenticate()` 서비스 레이어에서 비밀번호 검증 직후 검사하여 `CustomAuthException("SUSPENDED_MEMBER")`을 던집니다. +* **[면접관 꼬리 질문]**: "이미 로그인된 정지 회원의 후속 요청은 어떻게 차단하나요?" +* **[면접자 답변 대본]**: "관리자가 정지 처리하는 시점에 서블릿 세션을 즉시 무효화하고, Security Custom Filter에서 유저 Status를 확인해 `SUSPENDED` 시 403 Forbidden을 반환합니다." + +--- + +## 영역 4. 데이터베이스 & 성능/동시성 + +### Q16. 이메일 중복 체크 시 애플리케이션 검증 외에 DB UNIQUE 제약조건이 필수인 이유는 무엇인가요? +* **답변**: 동시성 환경에서 두 유저가 동시 가입 요청 시 자바 검사를 둘 다 통과하는 경쟁 상태(Race Condition)가 발생합니다. DB `UNIQUE` 제약조건이 있어야 DB 레벨에서 `DataIntegrityViolationException`을 뿜으며 무결성을 방어합니다. +* **[면접관 꼬리 질문]**: "DB UNIQUE 위반 예외 시 사용자에게 어떻게 응답하나요?" +* **[면접자 답변 대본]**: "`GlobalExceptionHandler`에서 캐치하여 `400 Bad Request`와 함께 '이미 사용 중인 이메일입니다'라는 메시지를 반환합니다." + +### Q17. N:M 다대다 중계 테이블 분리 이유는 무엇인가요? +* **답변**: RDBMS 제1정규형(원자성) 준수 및 JPA Direct `@ManyToMany`가 유발하는 기존 관계 전체 `DELETE` 후 `INSERT` 하는 비효율을 방지하기 위함입니다. +* **[면접관 꼬리 질문]**: "복합키 대신 단일 대리키(id) + 복합 UNIQUE를 쓴 이유는 무엇인가요?" +* **[면접자 답변 대본]**: "JPA에서 복합키(`@IdClass`) 구현 시 Entity 코드가 복잡해집니다. 단일 대리키(`id`)에 `(member_id, resort_id)` `복합 UNIQUE`를 거는 것이 JPA 생산성과 DB 무결성을 둘 다 얻는 베스트 기법입니다." + +### Q18. 프로필 조회 API 호출 시 N+1 문제 확인 및 최적화 방법은 무엇인가요? +* **답변**: `logging.level.org.hibernate.SQL: debug` 로깅으로 SQL 카운트 확인 후, JPQL `JOIN FETCH` 메서드를 활용하여 단 1회의 조인 쿼리로 최적화합니다. +* **[면접관 꼬리 질문]**: "다중 1:N 컬렉션 동시 JOIN FETCH 시 발생하는 에러와 극복 방안은 무엇인가요?" +* **[면접자 답변 대본]**: "`MultipleBagFetchException` 예외가 발생하므로, 2개의 개별 `JOIN FETCH` 쿼리로 분리하여 고정 3회 쿼리로 조회하는 것이 물리적 극복 방안입니다." + +### Q19. BCrypt 단방향 해시와 Salt/Work Factor의 역할은 무엇인가요? +* **답변**: BCrypt는 복호화가 불가능한 해시 암호화입니다. Salt는 무작위 문자로 사전 공격을 막고, Work Factor(Cost=10)는 1,024회 연쇄 연산으로 무차별 대입 공격(Brute Force)을 물리적으로 지연시킵니다. +* **[면접관 꼬리 질문]**: "Work Factor를 10에서 14로 올리면 어떤 변화가 생기나요?" +* **[면접자 답변 대본]**: "Cost 10(1,024회) 대비 Cost 14(16,384회)는 연산 시간이 16배 증가하여 보안은 강해지나 로그인 시 서버 CPU 부하가 증가하는 트레이드오프가 발생합니다." + +--- + +## 영역 5. 아키텍처 & 확장성 + +### Q20. Scale-out 시 세션 문제 해결 방안(Sticky Session vs Redis 클러스터링)은 무엇인가요? +* **답변**: Sticky Session은 로드밸런서가 특정 서버로만 트래픽 고정(서버 다운 시 세션 소실)합니다. Redis 공유 세션 클러스터링(Spring Session Data Redis)은 중앙 In-Memory DB인 Redis에 세션을 공유 저장하여 완벽한 Stateless 수준의 확장성을 제공합니다. +* **[면접관 꼬리 질문]**: "Redis 장애 시 대비책은 무엇인가요?" +* **[면접자 답변 대본]**: "Redis Sentinel이나 Redis Cluster를 구축하여 Primary 노드 장애 시 Secondary 노드가 1~2초 이내에 자동 승격되는 Failover 시스템을 구축합니다." + +### Q21. Controller와 Service, Entity 간의 책임 분리 기준은 무엇인가요? +* **답변**: Controller(HTTP 요청 검증, 쿠키/세션 제어, DTO 반환), Service(트랜잭션 경계, 비즈니스 규칙 제어 - 서블릿 API 참조 금지), Entity(도메인 핵심 비즈니스 메서드 직접 보유 및 검증). +* **[면접관 꼬리 질문]**: "Service에서 HttpServletRequest를 파라미터로 받으면 왜 안 되나요?" +* **[면접자 답변 대본]**: "Service 계층이 Web 서블릿 스펙에 결합되어 단위 테스트 시 Web 객체를 Mocking해야 하고, gRPC나 메시지 큐 등 타 전송 프로토콜로 변경 시 Service 전체를 재작성해야 하는 단일 책임 원칙(SRP) 위반이 생깁니다." + +### Q22. 로그아웃 연계 처리 방식은 무엇인가요? +* **답변**: 서버 측 `session.invalidate()` 실행 ➔ 응답 헤더 `Set-Cookie: JSESSIONID=; Max-Age=0` 전달로 브라우저 쿠키를 즉시 파기합니다. +* **[면접관 꼬리 질문]**: "로그아웃 없이 탭을 닫아버리면 서버 세션은 어떻게 되나요?" +* **[면접자 답변 대본]**: "세션 타임아웃(1시간) 동안 요청이 없으면 톰캣 세션 스캐너가 만료된 세션을 메모리에서 자동으로 정리합니다." + +--- + +## 영역 6. 검증 & 테스트 + +### Q23. Bean Validation과 도메인 예외 변환 전략은 무엇인가요? +* **답변**: DTO에 `@NotBlank`, `@Email`을 적용하고 예외 발생 시 `GlobalExceptionHandler`가 `MethodArgumentNotValidException`을 캐치하여 `400 Bad Request` + JSON 에러 메시지로 변환합니다. +* **[면접관 꼬리 질문]**: "세션 로그인 및 로그아웃 통합 테스트 순서는 어떻게 작성했나요?" +* **[면접자 답변 대본]**: "`MockMvc`로 `post("/api/auth/login")` 실행 후 `MockHttpSession`을 얻고, `get("/api/members/me").session(session)`으로 조회를 검증한 뒤, `post("/api/auth/logout").session(session)` 실행 후 재접근 시 401 Unauthorized를 검증했습니다." diff --git a/docs/study/sprint01/studySnowthingCompleteInterview260819.md b/docs/study/sprint01/studySnowthingCompleteInterview260819.md new file mode 100644 index 0000000..d4f95d1 --- /dev/null +++ b/docs/study/sprint01/studySnowthingCompleteInterview260819.md @@ -0,0 +1,574 @@ +# 🏔️ [Single Pure-Backend Master Guide] Snowthing 백엔드·MySQL 8 인덱스·JPA 10대 기술 딥다이브·50대 면접 대본 집대성 (2026-08-19) + +> **노션(Notion) 복사 전용 - 프론트엔드 내용 제거 / 순수 백엔드 & MySQL 8 전용 마스터 가이드** +> 본 문서는 Snowthing 프로젝트의 백엔드 설계 (`build.gradle` 명세: Spring Boot 4.0.0, Spring Dependency Management 1.1.6, Java 21, Spring Security 7), MySQL 8.0 인덱스(Clustered/Secondary/Composite Index) 물리적 구조, 10대 백엔드 기술 7대 서술 체계 해설, REST API 명세, DDL 제약조건, 그리고 **순수 백엔드 9대 영역 총 50개 면접 질문 및 꼬리 질문 답변 대본**을 중복 없이 정확한 엔지니어링 근거를 바탕으로 정리한 단 하나의 마스터 파일입니다. + +--- + +# 📑 CHAPTER 1. Snowthing 백엔드 아키텍처 & MySQL 8.0 DB 인프라 + +## 1.1 시스템 모노레포 구조 (Pure Backend) +```text +snowthing/ +└── backend/ # Spring Boot 4.0.0 (Java 21, Spring Security 7, MySQL 8.0) + ├── build.gradle # org.springframework.boot:4.0.0, io.spring.dependency-management:1.1.6 + ├── src/main/java/com/ikae/snowthing/ + │ ├── domain/auth/ # 인증/로그인 (Controller, Service, DTO) + │ ├── domain/member/ # 회원/프로필/마스터데이터 (Controller, Service, Repository, Entity) + │ └── global/ # SecurityConfig, GlobalExceptionHandler, BaseTimeEntity + └── src/test/java/ # 25개 통합/단위 테스트 수트 +``` + +## 1.2 서블릿 컨테이너(Tomcat) & Security Filter Chain 데이터 흐름 + +```text +[HTTP Client Request] + │ (Cookie: JSESSIONID=32자리랜덤키) + ▼ +[Tomcat Servlet Container] + │ DelegatingFilterProxy ➔ FilterChainProxy + ▼ +[Spring Security Filter Chain] + │ 1. CorsFilter (http://localhost:3000 검증) + │ 2. LogoutFilter (/api/auth/logout 감지 시 세션 무효화 및 쿠키 파기) + │ 3. SecurityContextHolderFilter (HttpSession에서 SecurityContext 읽어 ThreadLocal 복원) + │ 4. ExceptionTranslationFilter (401/403 예외 감지) + │ 5. AuthorizationFilter (URL 및 Role 권한 검증) + ▼ +[Controller Layer (AuthController, MemberController)] + │ @Valid DTO 파라미터 1차 입력값 검증 ➔ Service 호출 + ▼ +[Service Layer (AuthService, MemberService)] + │ @Transactional 트랜잭션 경계 ➔ BCrypt 검증 ➔ 비즈니스 규칙 처리 + ▼ +[Repository Layer (MemberRepository)] + │ Spring Data JPA & JPQL JOIN FETCH ➔ MySQL 8.0 InnoDB DB 쿼리 실행 +``` + +--- + +# 📑 CHAPTER 2. MySQL 8.0 DB 인덱스(INDEX) 물리적 설계 & DDL 명세 + +## 2.1 MySQL 8.0 InnoDB 인덱스 물리적 구조 & 걸려있는 이유 + +MySQL 8.0 InnoDB 엔진에서는 **클러스터드 인덱스(Clustered Index)**와 **세컨더리 인덱스(Secondary Index)** B-Tree 구조로 인덱스가 관리됩니다. + +### 1. `member` 회원 테이블 인덱스 구조 +* **`PRIMARY KEY (member_id)` (Clustered Index / BIGINT 8바이트 정수)**: + - **이유**: InnoDB 데이터 레코드가 `member_id` 순서대로 물리적 디스크 블록에 정렬 저장됩니다. DB 내부 JOIN 연산 시 B-Tree 이진 탐색을 통해 최상의 조인 속도를 제공합니다. +* **`UNIQUE INDEX uk_member_public_id (public_id)` (Secondary Index / VARCHAR(36) UUID)**: + - **이유**: 외부 REST API URL (`GET /api/members/me` 등)로 유저 단건 조회 시 `public_id` B-Tree 인덱스를 통해 O(1) 수준으로 리프 노드 주소를 탐색합니다. +* **`UNIQUE INDEX uk_member_email (email)` (Secondary Index / VARCHAR(255))**: + - **이유**: 로그인 시 이메일로 유저를 빠르게 검색하고, **동시성 환경 이메일 중복 가입 락(Race Condition)을 방어**합니다. +* **`UNIQUE INDEX uk_member_nickname (nickname)` (Secondary Index / VARCHAR(100))**: + - **이유**: 회원가입 및 프로필 수정 시 닉네임 중복 검사 쿼리 속도를 최적화합니다. + +### 2. `member_resort` & `member_riding_style` N:M 중계 테이블 인덱스 구조 +* **`PRIMARY KEY (member_resort_id)` (Clustered Index)**: 단일 대리키 PK. +* **`UNIQUE INDEX uk_member_resort (member_id, resort_id)` (Composite Secondary Index / 복합 인덱스)**: + - **이유**: 1. 동일 회원이 동일한 스키장을 중복 등록하는 무결성 파손을 차단합니다. 2. `member_id` 선두 컬럼 기반 복합 인덱스이므로 `WHERE member_id = ?` 조회 시 별도 인덱스 생성 없이 인덱스 레인지 스캔(Index Range Scan)으로 즉시 조회합니다. + +## 2.2 MySQL 8.0 DDL 문법 + +```sql +-- 1. 회원 마스터 테이블 (member) +CREATE TABLE member ( + member_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- [Clustered Index PK] + public_id VARCHAR(36) NOT NULL, -- [Secondary Unique Index] + email VARCHAR(255) NOT NULL, -- [Secondary Unique Index] + password VARCHAR(255) NOT NULL, -- [NOT NULL] BCrypt 60자리 해시 + nickname VARCHAR(100) NOT NULL, -- [Secondary Unique Index] + role VARCHAR(20) NOT NULL DEFAULT 'ROLE_USER', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME(6) NOT NULL, -- JPA Auditing + updated_at DATETIME(6) NOT NULL, -- JPA Auditing + CONSTRAINT uk_member_public_id UNIQUE (public_id), + CONSTRAINT uk_member_email UNIQUE (email), + CONSTRAINT uk_member_nickname UNIQUE (nickname) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- 2. N:M 선호 스키장 중계 테이블 (member_resort) +CREATE TABLE member_resort ( + member_resort_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- [Clustered Index PK] + member_id BIGINT NOT NULL, -- [FK] + resort_id BIGINT NOT NULL, -- [FK] + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT fk_member_resort_member FOREIGN KEY (member_id) REFERENCES member(member_id) ON DELETE CASCADE, + CONSTRAINT fk_member_resort_resort FOREIGN KEY (resort_id) REFERENCES resort(resort_id) ON DELETE CASCADE, + CONSTRAINT uk_member_resort UNIQUE (member_id, resort_id) -- [Composite Secondary Index] +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +``` + +--- + +# 📑 CHAPTER 3. 백엔드 10대 핵심 기술 7대 서술 요소 체계 해설 (7 Core Elements) + +AGENTS.md 규칙 21에 의거하여 백엔드에 사용된 10대 핵심 기술을 7대 서술 체계로 명확히 해설합니다. + +--- + +## 3.1 Bean Validation (`@Valid`, `@NotBlank`, `@Email`) 입력값 검증 + +### ① 개념 (명확한 정의) +자바 표준 스펙(JSR-380 / Hibernate Validator) 어노테이션을 통해 Controller 계층으로 들어오는 DTO 파라미터의 유효성을 도메인 로직 진입 전에 1차적으로 검증하는 기술입니다. + +### ② 왜 사용하는지 (Why - 도입 목적) +잘못된 데이터(공백 이메일, 8자리 미만 비밀번호)가 DB나 Service 레이어로 침투하여 비즈니스 로직 에러를 일으키는 것을 Controller 입구에서 차단하기 위함입니다. + +### ③ 어떨 때 사용하는지 (When - 사용 상황) +회원가입(`MemberSignUpRequest`), 로그인(`MemberLoginRequest`), 프로필 수정(`MemberProfileUpdateRequest`) 등 외부 입력값을 수신하는 모든 `@PostMapping`, `@PutMapping` DTO 파라미터 선언 시 적용합니다. + +### ④ 어떻게 사용하는지 (How - 구체적 구현 예시) +```java +public record MemberSignUpRequest( + @NotBlank(message = "이메일은 필수 입력값입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "비밀번호는 필수 입력값입니다.") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,20}$", + message = "비밀번호는 8~20자 영문, 숫자, 특수문자를 포함해야 합니다.") + String password, + + @NotBlank(message = "닉네임은 필수 입력값입니다.") + @Size(min = 2, max = 10, message = "닉네임은 2~10자 이내여야 합니다.") + String nickname +) {} + +// Controller 적용 +@PostMapping +public ResponseEntity signUp(@Valid @RequestBody MemberSignUpRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(memberService.signUp(request)); +} +``` + +### ⑤ 장점은 무엇인지 (Pros) +1. Controller 내부의 보일러플레이트 검증 코드가 제거됩니다. +2. 검증 실패 시 `MethodArgumentNotValidException`이 발생하여 `GlobalExceptionHandler`에서 `400 Bad Request` JSON 응답으로 일관되게 변환 가능합니다. + +### ⑥ 다른 기술/대안은 무엇이 있는지 (Alternatives) +* **Service 내 수동 `if` 검증**: 개발자가 일일이 자바 코드로 검증하는 방식으로 코드 중복과 가독성 저하가 발생합니다. +* **DB Constraints에만 의존**: DB 쿼리가 실행된 후에 에러가 발생하여 서버 자원이 낭비됩니다. + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프**: `@Valid` 어노테이션 누락 시 검증이 동작하지 않고 통과하는 위험이 존재합니다. +* **극복 방안**: Controller 단위 테스트 수트에서 바인딩 예외 발생 여부를 명확히 테스트 검증합니다. + +--- + +## 3.2 JPA / Spring Data JPA 영속성 메커니즘 + +### ① 개념 (명확한 정의) +자바 객체(Entity)와 RDBMS 테이블을 매핑해 주는 ORM(Object-Relational Mapping) 표준 기술로, 영속성 컨텍스트 1차 캐시, 변경 감지(Dirty Checking), 지연 로딩(Lazy Loading)을 통해 데이터베이스를 자바 객체처럼 다루게 해줍니다. + +### ② 왜 사용하는지 (Why - 도입 목적) +SQL CRUD 작성을 자동화하고, 1차 캐시와 Dirty Checking을 통해 DB 커넥션 및 쿼리 실행을 최적화하기 위함입니다. + +### ③ 어떨 때 사용하는지 (When - 사용 상황) +백엔드 데이터베이스의 모든 C.R.U.D 데이터 조작 및 객체 그래프 탐색 시 사용합니다. + +### ④ 어떻게 사용하는지 (How - 구체적 구현 예시) +```java +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + private final MemberRepository memberRepository; + + @Transactional // 👈 Dirty Checking을 위한 트랜잭션 경계 + public void updateProfile(String email, String newNickname) { + Member member = memberRepository.findByEmail(email).orElseThrow(); + member.updateNickname(newNickname); // 👈 수동 save() 없이 Dirty Checking으로 UPDATE SQL 자동 실행! + } +} +``` + +### ⑤ 장점은 무엇인지 (Pros) +1. Dirty Checking을 통해 수동 UPDATE 쿼리 생략이 가능합니다. +2. 1차 캐시 메모리 조회를 통해 동일 트랜잭션 내 DB 조회 쿼리가 생략됩니다. + +### ⑥ 다른 기술/대안은 무엇이 있는지 (Alternatives) +* **MyBatis / JdbcTemplate**: SQLMapper 방식으로 객체 지향적 접근이 불가능하며 N+1 수동 해결이 필요합니다. + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프**: N+1 쿼리 문제 및 `MultipleBagFetchException` 발생 위험이 있습니다. +* **극복 방안**: `FetchType.LAZY`로 설정하고 필요 시 3회 개별 `JOIN FETCH` 쿼리로 조회합니다. + +--- + +## 3.3 세션 고정 방어 (`changeSessionId()`) +* **개념**: 로그인 성공 시 세션 데이터는 유지하면서 세션 ID만 무작위 새 32자리 문자열로 재발급하는 보안 메커니즘. +* **Why**: 공격자가 비회원 세션 ID를 유저에게 쥐여준 뒤 로그인 시 계정을 탈취하는 **세션 고정 공격을 차단**. +* **When**: 로그인 성공 직후 실행. +* **How**: `httpRequest.changeSessionId()` 호출. +* **Pros**: 세션 탈취 차단 및 기존 장바구니/임시 세션 데이터 유지. +* **Alternatives**: `newSession()`(데이터 소실), `none()`(보안 무방비). +* **Trade-off & Mitigation**: 세션 맵 재갱신 오버헤드가 있으나 로그인 시 단 1회 실행되어 성능 영향이 미미합니다. + +--- + +## 3.4 이중 식별자 전략 (Dual PK: BIGINT AUTO_INCREMENT + public_id UUID) +* **개념**: DB 내부 조인용으로는 `id BIGINT`를 사용하고, API URL 외부 노출용으로는 `public_id UUID`를 분리 적용하는 아키텍처. +* **Why**: **API 보안성**(ID 추측 공격 차단)과 **DB 조인 성능**(8바이트 정수 B-Tree 연산)을 동시 달성. +* **When**: REST API URL에 식별자가 노출되는 모든 엔티티 설계 시. +* **How**: `@PrePersist`로 UUID 자동 생성 및 `findByPublicId()` 조회. +* **Pros**: 개인정보 무단 스크랩 차단, 서비스 가입자 수 지표 유출 방지, 최상 조인 속도. +* **Alternatives**: UUID 단일 PK(인덱스 크기 증가, Page Split 디스크 I/O 급증), TSID. +* **Trade-off & Mitigation**: DB 용량이 소량 증가하므로 `public_id`에 UNIQUE INDEX를 걸어 O(1) 조회를 보장합니다. + +--- + +## 3.5 JPA Auditing (`BaseTimeEntity`) 기반 자동 시간 주입 +* **개념**: 엔티티 생성/수정 시 시간을 JPA 라이프사이클 이벤트로 자동 주입하는 기술. +* **Why**: DB `sysdate` 사용 시 발생하는 **1차 캐시 메모리 엔티티 `createdAt=null` 데이터 불일치 방지**. +* **When**: 모든 엔티티 공통 추상 클래스 작성 시. +* **How**: `@MappedSuperclass`, `@EntityListeners(AuditingEntityListener.class)`. +* **Pros**: `save()` 즉시 1차 캐시 엔티티에 시간이 반영되어 데이터 무결성 보장. +* **Alternatives**: DB `DEFAULT NOW()`(1차 캐시 null 남음), 수동 `LocalDateTime.now()`(코드 중복). +* **Trade-off & Mitigation**: 메인 클래스 어노테이션 누락 위험이 있으므로 별도 `@Configuration`으로 분리 관리합니다. + +--- + +## 3.6 다중 1:N 조인 시 `MultipleBagFetchException` 및 3회 `JOIN FETCH` 최적화 +* **개념**: 다중 일대다 List 컬렉션 동시 `JOIN FETCH` 시 발생하는 예외와, 이를 피하기 위한 3회 개별 `JOIN FETCH` 분리 조회 기법. +* **Why**: N+1 쿼리 폭발을 막으면서 서버 다운을 차단. +* **When**: 한 엔티티가 2개 이상의 N:M 중계 컬렉션을 가지고 한 번에 조회해야 할 때. +* **How**: `findAllByMemberIdWithResort`, `findAllByMemberIdWithRidingStyle` 2개 JPQL 분리 작성. +* **Pros**: 회원 수가 10,000명이어도 프로필 조회 쿼리는 항상 고정 3회만 실행. +* **Alternatives**: `Set` 사용(순서 미보장, 카테시안 곱 중복 오버헤드), `default_batch_fetch_size`. +* **Trade-off & Mitigation**: 단 1회가 아닌 3회 SQL이 실행되지만 N+1(101회) 대비 네트워크 커넥션 비용이 대폭 절감됩니다. + +--- + +## 3.7 BCrypt Password Encoder (Salt & Work Factor=10) +* **개념**: Blowfish 암호 기반의 단방향 해시 함수로 솔트(Salt)와 피트니스 비용(Work Factor)을 적용한 비밀번호 암호화 기술. +* **Why**: DB 해킹 시에도 원본 비밀번호 복호화를 불가능하게 하고 무차별 대입 공격을 물리적으로 지연시키기 위함. +* **When**: 회원가입 시 비밀번호 암호화 및 로그인 시 비밀번호 검증 시. +* **How**: `passwordEncoder.encode(rawPassword)`, `passwordEncoder.matches(raw, encoded)`. +* **Pros**: 솔트 무작위 추가로 무지개 테이블 사전 공격 차단. +* **Alternatives**: SHA-256 (연산 속도가 빨라 무차별 대입 공격에 취약함). +* **Trade-off & Mitigation**: Cost=10 적용 시 1,024회 연산으로 CPU 소모가 발생하지만 로그인 시 단 1회 실행되어 적절한 타협점입니다. + +--- + +## 3.8 Spring Transactional (`@Transactional(readOnly = true)`) +* **개념**: 선언적 트랜잭션 관리 어노테이션으로, 메서드 시작 시 DB 커넥션을 획득하고 정상 종료 시 `commit()`, 예외 발생 시 `rollback()`을 자동 수행합니다. +* **Why**: 수동 `commit/rollback` 코드를 없애고 `readOnly = true` 설정으로 JPA 영속성 컨텍스트의 스냅샷 생성을 생략하여 메모리를 최적화하기 위함. +* **When**: 서비스 레이어의 모든 읽기/쓰기 메서드에 적용. +* **How**: 클래스 상단에 `@Transactional(readOnly = true)` 선언 후, C.U.D 메서드에만 `@Transactional` 덮어쓰기. +* **Pros**: JPA 하이버네이트 스냅샷 미생성으로 메모리 절약 및 쿼리 플러시 오버헤드 방지. +* **Alternatives**: 수동 `TransactionTemplate` (코드 복잡). +* **Trade-off & Mitigation**: 트랜잭션 내부에서 외부 API 호출 시 DB 커넥션을 오래 쥐고 있는 현상이 발생하므로 외부 API 호출은 트랜잭션 밖으로 분리합니다. + +--- + +## 3.9 Jackson JSON Serializer & Java 17 `record` DTO +* **개념**: Java 17 불변 데이터 객체인 `record`와 Jackson 라이브러리를 통해 자바 객체와 JSON 텍스트 간 변환을 수행하는 기술. +* **Why**: DTO 객체의 Thread-Safety 불변성을 보장하고 보일러플레이트 코드를 줄이기 위함. +* **When**: API Request/Response DTO 정의 시. +* **How**: `public record ResortResponse(Long id, String name, String regionName) {}`. +* **Pros**: Getter, equals, hashCode 자동 생성 및 안전한 직렬화. +* **Alternatives**: Lombok `@Getter`/`@AllArgsConstructor` 클래스 (가변성 위험). +* **Trade-off & Mitigation**: Java 17 이상에서는 Jackson이 record를 기본 공식 지원합니다. + +--- + +## 3.10 HikariCP Connection Pool & MySQL 8.0 InnoDB Engine +* **개념**: 미리 DB 커넥션을 생성하여 풀(Pool)에 보관해 두고 재사용하는 고성능 JDBC 커넥션 풀 라이브러리입니다. +* **Why**: 매 요청마다 DB 커넥션을 맺고 끊는 3-Way Handshake RTT 오버헤드를 없애기 위함. +* **When**: Spring Boot 실행 시 데이터소스 연동 시. +* **How**: `spring.datasource.hikari.maximum-pool-size: 10`. +* **Pros**: O(1) 수준의 커넥션 획득 속도 및 CPU 오버헤드 최소화. +* **Alternatives**: Tomcat DBCP (HikariCP가 속도 측면에서 수배 이상 빠름). +* **Trade-off & Mitigation**: 커넥션 풀 고갈 가능성이 있으므로 OSIV 옵션을 끄고(`open-in-view: false`) 커넥션 반환 속도를 극대화합니다. + +--- + +# 📑 CHAPTER 4. 9대 영역 50대 백엔드 기술 질문 & 꼬리 질문 대본 (Pure Backend) + +--- + +## 영역 1. Spring Security & 인증/인가 흐름 + +### Q1. Spring Security Filter Chain의 동작 원리와 주요 필터들의 역할은 무엇인가요? +* **정확한 대답**: 서블릿 컨테이너(Tomcat)의 `DelegatingFilterProxy`가 요청을 받아 Spring Bean인 `FilterChainProxy`에 위임하면 순서대로 보안 필터들이 실행됩니다. `CorsFilter`(Cors 검증), `LogoutFilter`(로그아웃 처리), `SecurityContextHolderFilter`(세션에서 SecurityContext 복원), `ExceptionTranslationFilter`(401/403 예외 감지), `AuthorizationFilter`(URL 권한 검증)가 핵심 역할을 수행합니다. +* **[면접관 꼬리 질문]**: "DelegatingFilterProxy와 FilterChainProxy의 물리적 차이는 무엇인가요?" +* **[면접자 답변 대본]**: "Tomcat은 서블릿 스펙으로 동작하여 Spring Bean을 인식하지 못하므로, 서블릿 필터인 `DelegatingFilterProxy`가 요청을 받아 Spring Context 내의 `FilterChainProxy` Bean으로 위임해 줍니다. 이를 통해 스프링의 DI 기능으로 보안 필터들을 관리할 수 있게 됩니다." + +### Q2. Authentication(인증)과 Authorization(인가)의 명확한 차이는 무엇인가요? +* **정확한 대답**: Authentication(인증)은 "이 사용자가 누구인가?"를 검증하는 신원 확인 과정(로그인)이고, Authorization(인가)은 "인증된 사용자가 특정 리소스에 접근할 권한이 있는가?"를 검증하는 권한 확인 과정(Role 검증)입니다. +* **[면접관 꼬리 질문]**: "인증 객체인 Authentication은 어디에 저장되나요?" +* **[면접자 답변 대본]**: "`SecurityContextHolder` 내부의 ThreadLocal에 저장되어, 동일한 스레드 내에서는 Controller, Service 등 어느 레이어에서나 `SecurityContextHolder.getContext().getAuthentication()`으로 접근할 수 있습니다." + +### Q3. 세션 기반 인증의 전체 흐름을 설명해 주세요. +* **정확한 대답**: `POST /api/auth/login` 요청 ➔ BCrypt 비밀번호 검증 ➔ `SecurityContext` 생성 ➔ `request.changeSessionId()` 세션 고정 방어 ➔ 세션 저장 ➔ `Set-Cookie: JSESSIONID=32자리랜덤키; Path=/; HttpOnly; SameSite=Lax` 발급 ➔ 후속 요청 시 쿠키 전송 ➔ `SecurityContextHolderFilter`가 세션 복원. +* **[면접관 꼬리 질문]**: "`request.getSession(true)`와 `request.getSession(false)`의 차이는 무엇인가요?" +* **[면접자 답변 대본]**: "`true`는 세션이 없으면 새로 생성하고, `false`는 세션이 없으면 `null`을 반환합니다. 로그인 시에는 세션을 생성해야 하므로 `true`, 단순 조회 시에는 `false`를 사용해 불필요한 세션 생성을 막습니다." + +### Q4. 401 Unauthorized와 403 Forbidden 예외 처리 분리 방식은 무엇인가요? +* **정확한 대답**: 401(미인증)은 `AuthenticationEntryPoint`가 동작하고, 403(권한부족)은 `AccessDeniedHandler`가 동작합니다. `SecurityConfig`에서 JSON 응답(`SC_UNAUTHORIZED`, `SC_FORBIDDEN`)을 반환하도록 설정했습니다. +* **[면접관 꼬리 질문]**: "`ExceptionTranslationFilter`는 체인의 어느 위치에서 예외를 잡나요?" +* **[면접자 답변 대본]**: "`AuthorizationFilter` 바로 앞에 위치하여 하위 필터나 Controller에서 던져진 AuthenticationException 및 AccessDeniedException을 try-catch로 감싸 캐치한 뒤 처리합니다." + +### Q5. Session 방식과 JWT 토큰 방식의 장단점을 비교하고 세션을 선택한 이유는 무엇인가요? +* **정확한 대답**: 세션(Stateful)은 서버에서 세션을 즉시 파기할 수 있어 보안성이 뛰어나지만 서버 메모리를 사용합니다. JWT(Stateless)는 확장이 쉬우나 토큰 탈취 시 강제 제어가 불가능합니다. 본 프로젝트는 즉각적인 강제 로그아웃 통제와 보안성, Remember-Me(30일) UX를 위해 세션을 선택했습니다. +* **[면접관 꼬리 질문]**: "JWT에서 세션처럼 강제 로그아웃을 구현하려면 어떻게 해야 하나요?" +* **[면접자 답변 대본]**: "Redis에 만료된 Access Token을 Blacklist로 등록하거나 Refresh Token을 Redis에서 삭제해야 합니다. 하지만 이 경우 결국 Redis 상태를 관리하게 되어 JWT의 순수 Stateless 장점이 희석됩니다." + +--- + +## 영역 2. 세션 & 쿠키 보안 + +### Q6. 세션 고정 공격(Session Fixation)과 `changeSessionId()`의 역할은 무엇인가요? +* **정확한 대답**: 공격자가 발급받은 비회원 세션 ID를 유저에게 쥐여준 뒤 로그인 시 계정을 탈취하는 공격입니다. `changeSessionId()`는 로그인 성공 직후 기존 세션 데이터는 유지하면서 세션 ID만 무작위 새 문자열로 재발급하여 공격자의 세션 ID를 무효화시킵니다. +* **[면접관 꼬리 질문]**: "Spring Security 7의 기본 세션 고정 방어 전략은 무엇인가요?" +* **[면접자 답변 대본]**: "Servlet 3.1+ 컨테이너 환경을 감지하여 기본적으로 `changeSessionId()` 방식이 자동으로 동작합니다." + +### Q7. 쿠키 5대 보안 속성(HttpOnly, Secure, SameSite, Path, Max-Age)의 역할은 무엇인가요? +* **정확한 대답**: `HttpOnly`(자바스크립트 접근 차단/XSS 방어), `Secure`(HTTPS 채널만 전송), `SameSite=Lax`(Cross-Site 전송 제어/CSRF 방어), `Path=/`(전체 API 쿠키 전송), `Max-Age`(만료 시간 지정). +* **[면접관 꼬리 질문]**: "SameSite 속성 중 Strict, Lax, None의 차이는 무엇인가요?" +* **[면접자 답변 대본]**: "`Strict`는 모든 Cross-Site 전송 차단, `Lax`는 안전한 GET 탑레벨 이동 시 전송 허용, `None`은 모든 전송 허용(단, `Secure=true` 필수)을 의미합니다." + +### Q8. 개발 환경과 운영 환경의 쿠키 보안 설정 차이점은 무엇인가요? +* **정확한 대답**: 개발(`http://localhost:3000`)은 SSL이 없으므로 `Secure=false`, 운영(`https`)은 `Secure=true`를 적용해야 쿠키 전송이 거부되지 않습니다. +* **[면접관 꼬리 질문]**: "도메인이 다를 때 SameSite 설정은 어떻게 하나요?" +* **[면접자 답변 대본]**: "도메인이 완전히 다르면 `SameSite=None`과 `Secure=true`를 적용하고, CORS 응답 헤더에 `Access-Control-Allow-Credentials: true`를 명시해야 합니다." + +### Q9. 세션 만료 시간(Timeout) 정책은 어떻게 설정했나요? +* **정확한 대답**: 일반 세션은 1시간 슬라이딩 세션(활동 시 연장)으로 세션 쿠키를 사용하고, Remember-Me 체크 시 30일(`2,592,000초`) 만료 쿠키를 발급합니다. +* **[면접관 꼬리 질문]**: "슬라이딩 세션은 톰캣 내부에서 어떻게 작동하나요?" +* **[면접자 답변 대본]**: "요청이 들어올 때마다 `session.getLastAccessedTime()`이 현재 시간으로 갱신되며 만료 카운트다운(3600초)이 처음부터 리셋되는 원리입니다." + +### Q10. 세션 객체에 회원 엔티티 전체 대신 식별자(ID)와 Role만 저장해야 하는 이유는 무엇인가요? +* **정확한 대답**: 1. 서버 RAM 메모리 절약(OOM 방지), 2. 정보 변경 시 세션-DB 데이터 불일치 방지, 3. JPA 지연 로딩 프록시 직렬화 에러(`NotSerializableException`) 예방을 위함입니다. +* **[면접관 꼬리 질문]**: "세션에 식별자만 넣었을 때 매 요청 DB 조회 오버헤드는 어떻게 해결하나요?" +* **[면접자 답변 대본]**: "Spring Data JPA 1차 캐시 메모리와 PK 인덱스 조회를 통해 O(1) 성능으로 접근하므로 DB 오버헤드는 거의 없습니다." + +### Q11. 다중 로그인(동시 로그인) 제어 정책은 어떻게 설계하나요? +* **정확한 대답**: `SecurityConfig`에서 `.maximumSessions(1)`을 설정하여 기존 세션을 만료시키거나 새 로그인을 차단합니다. +* **[면접관 꼬리 질문]**: "기존 세션 만료 시 유저에게 어떤 응답을 내려주나요?" +* **[면접자 답변 대본]**: "`sessionInformationExpiredStrategy()`를 구현하여 401 JSON 응답과 함께 '다른 기기에서 로그인되었습니다'라는 메시지를 내보냅니다." + +--- + +## 영역 3. 도메인 설계 & 식별자(PK/UUID) + +### Q12. Dual PK Strategy(`BIGINT AUTO_INCREMENT` + `public_id UUID`)를 사용하는 이유는 무엇인가요? +* **정확한 대답**: 내부 조인은 8바이트 정수 `id`로 실행하여 B-Tree 인덱스 조인 성능을 극대화하고, 외부 REST API URL 노출용으로는 36자리 UUID `public_id`를 사용하여 보안성을 둘 다 잡기 위함입니다. +* **[면접관 꼬리 질문]**: "UUID를 DB PK로 직접 쓸 때 발생하는 B-Tree 인덱스 파편화(Fragmentation) 문제는 무엇인가요?" +* **[면접자 답변 대본]**: "무작위 UUID는 순서가 없어 INSERT 시 B-Tree 인덱스 중간에 무작위로 위치하며 페이지 분할(Page Split)이 빈번하게 발생해 디스크 I/O가 급증합니다. 정수 PK는 순차 추가되어 파편화가 없습니다." + +### Q13. Auto-Increment ID를 외부에 노출할 때의 취약점은 무엇인가요? +* **정확한 대답**: ID 추측 공격(Enumeration Attack)을 통한 개인정보 무단 스크랩과 비즈니스 가입자 수 지표 유출 위험이 있습니다. +* **[면접관 꼬리 질문]**: "UUID 외에 고려해 볼 대안 식별자는 무엇이 있나요?" +* **[면접자 답변 대본]**: "정렬 가능한 64비트 정수형 식별자인 **TSID**나 Twitter의 **Snowflake ID**를 고려할 수 있습니다." + +### Q14. 내부 JOIN 연산 시 숫자형 PK 사용의 성능적 이점은 무엇인가요? +* **정확한 대답**: BIGINT(8바이트)는 UUID(36바이트)보다 메모리가 훨씬 작아 인덱스 탑재량이 늘어나고, CPU 정수 비교 연산 속도가 문자열 비교보다 빠릅니다. +* **[면접관 꼬리 질문]**: "BIGINT PK 오버플로우가 발생할 가능성은 없나요?" +* **[면접자 답변 대본]**: "BIGINT는 64비트 정수로 약 922경 개의 데이터를 저장할 수 있어 일반적인 서비스 환경에서는 물리적으로 오버플로우가 발생하지 않습니다." + +### Q15. 회원 상태(정상, 탈퇴, 정지) 검증 위치는 어디가 적절한가요? +* **정확한 대답**: `AuthService.authenticate()` 서비스 레이어에서 비밀번호 검증 직후 검사하여 `CustomAuthException("SUSPENDED_MEMBER")`을 던집니다. +* **[면접관 꼬리 질문]**: "이미 로그인된 정지 회원의 후속 요청은 어떻게 차단하나요?" +* **[면접자 답변 대본]**: "관리자가 정지 처리하는 시점에 서블릿 세션을 즉시 무효화하고, Security Custom Filter에서 유저 Status를 확인해 `SUSPENDED` 시 403 Forbidden을 반환합니다." + +--- + +## 영역 4. 데이터베이스, MySQL 8 인덱스 & 동시성 + +### Q16. 이메일 중복 체크 시 애플리케이션 검증 외에 DB UNIQUE 제약조건이 필수인 이유는 무엇인가요? +* **정확한 대답**: 동시성 환경에서 두 유저가 동시 가입 요청 시 자바 검사를 둘 다 통과하는 경쟁 상태(Race Condition)가 발생합니다. DB `UNIQUE` 제약조건이 있어야 DB 레벨에서 `DataIntegrityViolationException`을 뿜으며 무결성을 방어합니다. +* **[면접관 꼬리 질문]**: "DB UNIQUE 위반 예외 시 사용자에게 어떻게 응답하나요?" +* **[면접자 답변 대본]**: "`GlobalExceptionHandler`에서 캐치하여 `400 Bad Request`와 함께 '이미 사용 중인 이메일입니다'라는 메시지를 반환합니다." + +### Q17. N:M 다대다 중계 테이블 분리 이유는 무엇인가요? +* **정확한 대답**: RDBMS 제1정규형(원자성) 준수 및 JPA Direct `@ManyToMany`가 유발하는 기존 관계 전체 `DELETE` 후 `INSERT` 하는 비효율을 방지하기 위함입니다. +* **[면접관 꼬리 질문]**: "복합키 대신 단일 대리키(id) + 복합 UNIQUE를 쓴 이유는 무엇인가요?" +* **[면접자 답변 대본]**: "JPA에서 복합키(`@IdClass`) 구현 시 Entity 코드가 복잡해집니다. 단일 대리키(`id`)에 `(member_id, resort_id)` `복합 UNIQUE`를 거는 것이 JPA 생산성과 DB 무결성을 둘 다 얻는 베스트 기법입니다." + +### Q18. MySQL 8 InnoDB B-Tree 인덱스의 동작 원리와 클러스터드 인덱스의 차이는 무엇인가요? +* **정확한 대답**: Clustered Index는 PK 순서로 실제 데이터 레코드가 물리 정렬 저장되는 인덱스이며, Secondary Index는 B-Tree 리프 노드에 PK 값을 쥐고 있어 Secondary Index 조회의 경우 '인덱스 탐색 ➔ PK 탐색' 2번의 탐색 과정을 거칩니다. +* **[면접관 꼬리 질문]**: "복합 인덱스(Composite Index) 생성 시 컬럼 순서가 왜 중요한가요?" +* **[면접자 답변 대본]**: "B-Tree 인덱스는 첫 번째 선두 컬럼 기준으로 정렬된 후 두 번째 컬럼이 정렬됩니다. 따라서 선두 컬럼이 `WHERE` 절 조건에 포함되지 않으면 인덱스를 타지 못하고 Full Table Scan이 터지므로 카디널리티(기억 선택도)가 높은 컬럼을 선두로 두어야 합니다." + +### Q19. BCrypt 단방향 해시와 Salt/Work Factor의 역할은 무엇인가요? +* **정확한 대답**: BCrypt는 복호화가 불가능한 해시 암호화입니다. Salt는 무작위 문자로 사전 공격을 막고, Work Factor(Cost=10)는 1,024회 연쇄 연산으로 무차별 대입 공격(Brute Force)을 물리적으로 지연시킵니다. +* **[면접관 꼬리 질문]**: "Work Factor를 10에서 14로 올리면 어떤 변화가 생기나요?" +* **[면접자 답변 대본]**: "Cost 10(1,024회) 대비 Cost 14(16,384회)는 연산 시간이 16배 증가하여 보안은 강해지나 로그인 시 서버 CPU 부하가 증가하는 트레이드오프가 발생합니다." + +--- + +## 영역 5. 아키텍처 & 확장성 + +### Q20. Scale-out 시 세션 문제 해결 방안(Sticky Session vs Redis 클러스터링)은 무엇인가요? +* **정확한 대답**: Sticky Session은 로드밸런서가 특정 서버로만 트래픽 고정(서버 다운 시 세션 소실)합니다. Redis 공유 세션 클러스터링(Spring Session Data Redis)은 중앙 In-Memory DB인 Redis에 세션을 공유 저장하여 완벽한 Stateless 수준의 확장성을 제공합니다. +* **[면접관 꼬리 질문]**: "Redis 장애 시 대비책은 무엇인가요?" +* **[면접자 답변 대본]**: "Redis Sentinel이나 Redis Cluster를 구축하여 Primary 노드 장애 시 Secondary 노드가 1~2초 이내에 자동 승격되는 Failover 시스템을 구축합니다." + +### Q21. Controller와 Service, Entity 간의 책임 분리 기준은 무엇인가요? +* **정확한 대답**: Controller(HTTP 요청 검증, 쿠키/세션 제어, DTO 반환), Service(트랜잭션 경계, 비즈니스 규칙 제어 - 서블릿 API 참조 금지), Entity(도메인 핵심 비즈니스 메서드 직접 보유 및 검증). +* **[면접관 꼬리 질문]**: "Service에서 HttpServletRequest를 파라미터로 받으면 왜 안 되나요?" +* **[면접자 답변 대본]**: "Service 계층이 Web 서블릿 스펙에 결합되어 단위 테스트 시 Web 객체를 Mocking해야 하고, gRPC나 메시지 큐 등 타 전송 프로토콜로 변경 시 Service 전체를 재작성해야 하는 단일 책임 원칙(SRP) 위반이 생깁니다." + +### Q22. 로그아웃 연계 처리 방식은 무엇인가요? +* **정확한 대답**: 서버 측 `session.invalidate()` 실행 ➔ 응답 헤더 `Set-Cookie: JSESSIONID=; Max-Age=0` 전달로 브라우저 쿠키를 즉시 파기합니다. +* **[면접관 꼬리 질문]**: "로그아웃 없이 탭을 닫아버리면 서버 세션은 어떻게 되나요?" +* **[면접자 답변 대본]**: "세션 타임아웃(1시간) 동안 요청이 없으면 톰캣 세션 스캐너가 만료된 세션을 메모리에서 자동으로 정리합니다." + +--- + +## 영역 6. 검증 & 입력값 Validation (`@Valid`) + +### Q23. Bean Validation 어노테이션(`@NotBlank`, `@Email`, `@Pattern`)의 차이점은 무엇인가요? +* **정확한 대답**: `@NotNull`은 `null`만 거부하고 공백문자열(`""`)은 허용합니다. `@NotEmpty`는 `null`과 빈 문자열(`""`)을 거부합니다. `@NotBlank`는 `null`, 빈 문자열(`""`), 그리고 띄어쓰기 공백(`" "`)까지 거부하는 가장 엄격한 검증 어노테이션입니다. +* **[면접관 꼬리 질문]**: "비밀번호 검증 정규식 `@Pattern`을 사용한 이유는 무엇인가요?" +* **[면접자 답변 대본]**: "영문, 숫자, 특수문자를 최소 1개 이상 포함하고 8~20자 이내인지 자바 정규식 룩어라운드(`(?=.*[A-Za-z])`)로 일관되게 검증하여 취약한 비밀번호 생성을 차단하기 위함입니다." + +### Q24. `@WebMvcTest`와 `@SpringBootTest` 단위/통합 테스트의 차이점은 무엇인가요? +* **정확한 대답**: `@WebMvcTest`는 Controller 및 웹 레이어 관련 빈만 가볍게 로딩하여 빠르게 테스트하고, `@SpringBootTest`는 전체 스프링 컨테이너 빈과 DB를 모두 띄워 엔드-투-엔드 통합 검증을 수행합니다. +* **[면접관 꼬리 질문]**: "`@SpringBootTest` 속도 지연을 극복하는 팁은 무엇인가요?" +* **[면접자 답변 대본]**: "공통 추상 테스트 클래스를 정의하여 스프링 컨테이너를 단 1번만 띄우고 재사용하는 방식을 사용하여 테스트 실행 속도를 극대화합니다." + +### Q25. 동시성 회원가입 테스트(`MemberConcurrencyTest`)는 어떻게 검증했나요? +* **정확한 대답**: `ExecutorService`와 `CountDownLatch`를 활용해 10개의 멀티스레드가 동일한 이메일로 동시 가입 요청을 동시에 쏘도록 시뮬레이션하고, 단 1건만 성공하고 9건은 예외 처리됨을 검증했습니다. +* **[면접관 꼬리 질문]**: "`CountDownLatch`는 동시성 테스트에서 어떤 역할을 하나요?" +* **[면접자 답변 대본]**: "`latch.await()`를 통해 10개의 스레드가 준비될 때까지 대기시켰다가 `latch.countDown()`으로 10개 스레드를 한순간에 동시에 출발시켜 정밀한 동시 경합 상태를 만듭니다." + +--- + +## 영역 7. JPA 영속성 메커니즘 & ORM 딥다이브 + +### Q26. 영속성 컨텍스트 1차 캐시와 DB 조회 쿼리 생략 원리는 무엇인가요? +* **정확한 대답**: JPA는 엔티티 조회 시 영속성 컨텍스트 내부의 1차 캐시 `Map`를 먼저 검색합니다. 동일 트랜잭션 내 이미 존재하는 식별자 조회의 경우 DB SQL을 전송하지 않고 1차 캐시 객체를 즉시 반환하여 성능을 최적화합니다. +* **[면접관 꼬리 질문]**: "1차 캐시의 생명주기(Scope)는 언제까지 유지되나요?" +* **[면접자 답변 대본]**: "1차 캐시는 트랜잭션 범위와 1:1로 일치합니다. HTTP 요청 하나가 들어와 트랜잭션이 끝나고 `EntityManager`가 종료되면 1차 캐시도 즉시 소멸하므로 메모리 낭비가 없습니다." + +### Q27. 변경 감지(Dirty Checking) 동작 원리와 `@Transactional`의 역할은 무엇인가요? +* **정확한 대답**: JPA는 엔티티를 영속성 컨텍스트에 담을 때 초기 상태의 '스냅샷'을 떠둡니다. 트랜잭션이 커밋되는 시점에 엔티티 필드와 스냅샷을 비교하여 변경된 부분이 있으면 수동 `update()` 없이도 `UPDATE` SQL을 자동 생성하여 DB에 반영합니다. +* **[면접관 꼬리 질문]**: "Dirty Checking으로 생성되는 UPDATE 쿼리는 기본적으로 모든 필드를 변경하나요?" +* **[면접자 답변 대본]**: "기본적으로는 모든 필드를 갱신하는 쿼리가 나가며, 이는 쿼리 재사용성을 높여줍니다. 변경된 필드만 동적으로 갱신하고 싶다면 엔티티 클래스에 `@DynamicUpdate` 어노테이션을 붙여 처리할 수 있습니다." + +### Q28. 지연 로딩(LAZY) vs 즉시 로딩(EAGER)과 실무에서 LAZY만 써야 하는 이유는 무엇인가요? +* **정확한 대답**: `EAGER`는 엔티티 조회 시 연관 엔티티까지 무조건 조인하여 가져오는 방식이고, `LAZY`는 실제 연관 객체의 필드를 사용할 때 프록시를 통해 쿼리를 날리는 방식입니다. 실무에서는 `EAGER` 설정 시 예상치 못한 JPQL N+1 쿼리 폭발이 터지므로 무조건 `LAZY`만 설정해야 합니다. +* **[면접관 꼬리 질문]**: "지연 로딩 상태의 연관 객체를 트랜잭션 밖에서 접근하면 어떤 에러가 발생하나요?" +* **[면접자 답변 대본]**: "영속성 컨텍스트가 이미 종료되었으므로 프록시를 초기화할 수 없어 `LazyInitializationException` 예외가 발생합니다." + +### Q29. OSIV (Open Session In View) 옵션의 작동 원리와 실무 설정 방안은 무엇인가요? +* **정확한 대답**: `spring.jpa.open-in-view: true` 설정 시 HTTP 요청 시작부터 뷰/컨트롤러까지 DB 커넥션과 영속성 컨텍스트를 유지하는 기능입니다. 하지만 이 경우 컨트롤러까지 DB 커넥션을 오래 잡고 있어 커넥션 풀 고갈을 유발하므로, 실무에서는 **`OSIV: false`로 설정**하고 서비스 레이어 안에서 DTO로 변환하여 반환해야 합니다. +* **[면접관 꼬리 질문]**: "OSIV를 켰을 때 지연 로딩 예외를 막으려면 어떻게 해야 하나요?" +* **[면접자 답변 대본]**: "서비스 레이어의 `@Transactional` 범위 내에서 JPQL `JOIN FETCH` 또는 DTO Projection을 사용해 필요한 데이터를 한 번에 영속화한 뒤 DTO로 변환하여 컨트롤러로 넘겨주면 됩니다." + +### Q30. `@Modifying(clearAutomatically = true, flushAutomatically = true)`의 필요성은 무엇인가요? +* **정확한 대답**: JPQL 벌크 연산(`DELETE`, `UPDATE`)은 영속성 컨텍스트 1차 캐시를 거치지 않고 DB로 직접 SQL을 날립니다. 이 때문에 1차 캐시와 DB 데이터 간 불일치가 발생하므로, 벌크 연산 후 `clearAutomatically = true`를 붙여 1차 캐시를 자동으로 비워주어야 무결성이 유지됩니다. +* **[면접관 꼬리 질문]**: "만약 clearAutomatically를 안 쓰면 어떤 버그가 생기나요?" +* **[면접자 답변 대본]**: "DB에는 수정/삭제된 데이터가 반영되었지만, 영속성 컨텍스트 1차 캐시에는 옛날 데이터가 남아있어 이후 `findById()` 호출 시 옛날 데이터가 조회되는 버그가 발생합니다." + +### Q31. JPA N+1 문제의 근본 원인과 `JOIN FETCH` vs `@EntityGraph` vs `@BatchSize` 차이는 무엇인가요? +* **정확한 대답**: N+1은 JPQL이 연관관계를 고려하지 않고 주 엔티티 쿼리(1회)만 날린 뒤, 로딩된 연관 객체 N개를 탐색할 때마다 N번의 추가 SQL이 나가는 원인입니다. `JOIN FETCH`는 JPQL 조인, `@EntityGraph`는 어노테이션 기반 조인, `@BatchSize`는 `IN (?, ?, ?)` 묶음 처리로 N+1을 해결합니다. +* **[면접관 꼬리 질문]**: "컬렉션 페이징 처리 시 JOIN FETCH를 쓰면 발생하는 심각한 문제는 무엇인가요?" +* **[면접자 답변 대본]**: "1:N 관계에서 JOIN FETCH 후 페이징을 시도하면 Hibernate가 경고 로그를 남기며 **모든 DB 데이터를 메모리로 들고 와서 메모리 페이징(In-Memory Paging)**을 수행하므로 OOM 장애가 터집니다. 컬렉션 페이징 시에는 `@BatchSize`를 써야 합니다." + +### Q32. 영속성 전이(`CascadeType.ALL`)와 외딴 객체 제거(`orphanRemoval = true`)의 차이는 무엇인가요? +* **정확한 대답**: `CascadeType.ALL`은 부모 엔티티의 저장/삭제 이벤트를 자식 엔티티로 전가하는 기능이고, `orphanRemoval = true`는 부모 엔티티의 컬렉션에서 자식 객체 요소만 제거했을 때 DB에서 해당 자식 DELETE 쿼리가 나가는 기능입니다. +* **[면접관 꼬리 질문]**: "CascadeType.ALL과 orphanRemoval = true를 둘 다 켜면 어떤 이점이 있나요?" +* **[면접자 답변 대본]**: "부모 엔티티가 자식 엔티티의 생명주기를 100% 관리하는 Aggregate Root 구조가 되어, Repository 생성 없이 부모 엔티티 하나만으로 자식의 추가/삭제/수정을 완벽히 제어할 수 있습니다." + +### Q33. `saveAndFlush()` vs `save()`의 차이는 무엇인가요? +* **정확한 대답**: `save()`는 영속성 컨텍스트 1차 캐시에 엔티티를 담아두고 트랜잭션 커밋 시점에 `flush()` 되며, `saveAndFlush()`는 즉시 DB에 `flush()`를 실행하여 SQL을 반영하되 트랜잭션 커밋은 여전히 트랜잭션 끝에서 처리됩니다. +* **[면접관 꼬리 질문]**: "`saveAndFlush()`를 쓰면 DB 트랜잭션이 커밋된 것인가요?" +* **[면접자 답변 대본]**: "아닙니다. `flush()`는 단지 영속성 컨텍스트의 변경 내용을 DB에 SQL로 전송하는 것뿐이며, 실제 DB 트랜잭션 커밋(Commit)은 트랜잭션이 종료되는 시점에 실행됩니다." + +--- + +## 영역 8. Spring Transactional (`@Transactional`) & 예외 처리 + +### Q34. `@Transactional(readOnly = true)`를 읽기 전용 메서드에 적용하는 성능적 이점은 무엇인가요? +* **정확한 대답**: 하이버네이트 영속성 컨텍스트가 엔티티의 변경 감지를 위한 **'스냅샷(Snapshot)'을 생성하지 않으므로 메모리가 절약**되고, 트랜잭션 커밋 시점에 플러시(Flush) 검사를 생략하여 CPU 오버헤드가 크게 줄어듭니다. +* **[면접관 꼬리 질문]**: "readOnly = true 메서드 안에서 엔티티 필드를 수정하면 DB에 반영되나요?" +* **[면접자 답변 대본]**: "플러시(Flush)가 실행되지 않으므로 DB에 UPDATE 쿼리가 나가지 않으며 데이터 변경이 방지됩니다." + +### Q35. `@Transactional` 적용 시 언체크 예외(RuntimeException)와 체크 예외(Checked Exception)의 롤백 방식 차이는 무엇인가요? +* **정확한 대답**: 기본적으로 스프링 트랜잭션은 `RuntimeException` 및 `Error` 발생 시에만 롤백을 수행하고, Checked Exception(`Exception` 상위 클래스)이 발생하면 롤백을 수행하지 않고 커밋합니다. +* **[면접관 꼬리 질문]**: "Checked Exception 발생 시에도 롤백시키려면 어떻게 해야 하나요?" +* **[면접자 답변 대본]**: `@Transactional(rollbackFor = Exception.class)` 속성을 명시적으로 지정하여 모든 예외에 대해 롤백이 동작하도록 설정해야 합니다." + +### Q36. 동일 클래스 내부에서 `@Transactional` 메서드를 일반 메서드가 호출할 때 트랜잭션이 적용되지 않는 문제(Self-Invocation)의 원인은 무엇인가요? +* **정확한 대답**: 스프링 트랜잭션은 CGLIB 프록시 객체 기반으로 동작합니다. 동일 클래스 내부의 메서드 호출(`this.method()`)은 프록시를 거치지 않고 실제 객체의 타겟 메서드를 직접 호출하므로 AOP 트랜잭션 어드바이스가 적용되지 않습니다. +* **[면접관 꼬리 질문]**: "Self-Invocation 문제를 해결하려면 어떻게 설계해야 하나요?" +* **[면접자 답변 대본]**: "트랜잭션이 필요한 로직을 별도의 Spring Bean 서비스 클래스로 분리하여 외부에서 해당 Bean의 메서드를 호출하도록 객체 구조를 리팩토링해야 합니다." + +--- + +## 영역 9. 백엔드 인프라, MySQL 8 & CI/CD 딥다이브 + +### Q37. Spring Boot 4.0.0 (`build.gradle` 명세) 세션 타임아웃 및 HikariCP 설정 방안은 무엇인가요? +* **정확한 대답**: `server.servlet.session.timeout: 60m`으로 1시간 슬라이딩 세션을 설정하고, `spring.datasource.hikari.maximum-pool-size: 10`으로 적절한 커넥션 풀을 할당합니다. +* **[면접관 꼬리 질문]**: "HikariCP 커넥션 풀 크기를 너무 크게 잡으면 어떤 문제가 생기나요?" +* **[면접자 답변 대본]**: "커넥션 풀이 너무 크면 DB 서버의 메모리 부하와 Context Switching 오버헤드가 증가하여 오히려 전체 시스템의 쿼리 처리 속도가 저하됩니다." + +### Q38. Controller-Service 계층 분리와 Java 17 `record` DTO 적용의 이점은 무엇인가요? +* **정확한 대답**: Controller는 Web 요청/응답 변환에만 집중하고 Service는 서블릿 API 의존성 없이 비즈니스 로직에만 집중하게 분리하며, `record` DTO를 사용해 스키마 유출과 무한 순환 참조를 차단합니다. +* **[면접관 꼬리 질문]**: "DTO 변환 시 자바 17+ record를 쓰면 어떤 이점이 있나요?" +* **[면접자 답변 대본]**: "`record`는 불변(Immutable) 데이터 객체로 `equals`, `hashCode`, `toString`, Getter가 자동 생성되어 코드 라인을 획기적으로 줄이고 thread-safe 무결성을 보장합니다." + +### Q39. 동시성 환경에서의 Optimistic Lock (낙관적 락) vs Pessimistic Lock (비관적 락) 차이는 무엇인가요? +* **정확한 대답**: 낙관적 락은 충돌이 적을 것으로 가정하여 JPA `@Version` 필드로 커밋 시점에 충돌을 감지하고, 비관적 락은 충돌이 잦을 것으로 가정하여 DB `SELECT ... FOR UPDATE` 락을 실제로 거는 방식입니다. +* **[면접관 꼬리 질문]**: "충돌이 자주 발생하는 포인트에서는 둘 중 무엇을 선택해야 하나요?" +* **[면접자 답변 대본]**: "충돌이 빈번한 경우 낙관적 락은 롤백 및 재시도 로직 오버헤드가 크므로, DB 수준에서 즉시 순차 처리를 보장하는 비관적 락(Pessimistic Write)을 선택해야 합니다." + +### Q40. GitHub Actions CI 파이프라인 및 테스트 자동화 검증 흐름은 무엇인가요? +* **정확한 대답**: `pull_request` 이벤트 발생 시 GitHub Actions가 우분투 러너에서 레포를 체크아웃하고 `.\gradlew.bat test` 25개 테스트 수트를 자동 빌드/검증한 뒤 결과를 PR에 통보하는 흐름입니다. +* **[면접관 꼬리 질문]**: "CI 빌드 속도를 향상시키기 위해 적용할 수 있는 기법은 무엇인가요?" +* **[면접자 답변 대본]**: "GitHub Actions의 `actions/cache`를 활용해 Gradle 의존성 패키지(`~/.gradle/caches`)를 캐싱하여 매 빌드마다 패키지를 재다운로드하지 않도록 최적화합니다." + +### Q41. MySQL 8.0 InnoDB의 MVCC(Multi-Version Concurrency Control) 동작 원리는 무엇인가요? +* **정확한 대답**: MVCC는 트랜잭션 격리 수준을 보장하기 위해 Undo Log에 데이터의 이전 버전을 보관해 두어, `READ COMMITTED`나 `REPEATABLE READ` 환경에서 락(Lock)을 걸지 않고도 일관된 읽기(Consistent Read)를 제공하는 인노DB 핵심 동시성 기술입니다. +* **[면접관 꼬리 질문]**: "MVCC 덕분에 조회의 성능적 이점은 무엇인가요?" +* **[면접자 답변 대본]**: "읽기 작업이 쓰기 작업의 락을 기다리지 않고(Non-blocking Read), 쓰기 작업 역시 읽기 작업의 락을 기다리지 않아 동시 조회 성능이 극대화됩니다." + +### Q42. MySQL 8.0의 기본 트랜잭션 격리 수준(Isolation Level)과 Phantom Read는 무엇인가요? +* **정확한 대답**: MySQL InnoDB의 기본 격리 수준은 `REPEATABLE READ`입니다. Phantom Read는 한 트랜잭션 내에서 동일한 쿼리를 두 번 실행했을 때, 다른 트랜잭션의 `INSERT`에 의해 첫 번째 쿼리에서 없던 유령(Phantom) 레코드가 나타나는 현상입니다. +* **[면접관 꼬리 질문]**: "MySQL InnoDB는 REPEATABLE READ에서 Phantom Read를 어떻게 막나요?" +* **[면접자 답변 대본]**: "InnoDB는 갭 락(Gap Lock)과 넥스트 키 락(Next-Key Lock)을 사용하여 조건 범위 사이의 빈 공간에 새로운 레코드가 `INSERT` 되는 것을 물리적으로 차단하여 Phantom Read를 예방합니다." + +### Q43. Spring Security `SecurityContextHolder`의 기본 Strategy(ThreadLocal)와 Async 스레드 전파 방식은 무엇인가요? +* **정확한 대답**: 기본 전략은 `MODE_THREADLOCAL`로, 요청을 처리하는 동일 스레드 내에서만 보안 컨텍스트가 공유됩니다. `@Async` 비동기 스레드로 보안 컨텍스트를 전파하려면 `MODE_INHERITABLETHREADLOCAL` 설정이나 `DelegatingSecurityContextExecutor`를 사용해야 합니다. +* **[면접관 꼬리 질문]**: "ThreadLocal 사용 후 cleanup을 하지 않으면 어떤 문제가 생기나요?" +* **[면접자 답변 대본]**: "톰캣의 스레드 풀(Thread Pool) 재사용 특성 때문에 이전 유저의 `SecurityContext` 정보가 톰캣 스레드에 그대로 남아있어, 다음 유저 요청 시 타인의 세션으로 인증되는 심각한 보안 누수가 발생합니다. 시큐리티 필터 끝에서 반드시 clear 해야 합니다." + +### Q44. Jackson Serializer의 `ObjectMapper` 사용 시 `JavaTimeModule` 등록 이유는 무엇인가요? +* **정확한 대답**: Java 8의 `LocalDateTime`, `LocalDate` 객체는 기본 Jackson `ObjectMapper`로 직렬화 시 배열 형태(`[2026, 8, 19]`)로 변환됩니다. `JavaTimeModule`을 등록해야 ISO-8601 표준 문자열(`"2026-08-19T17:56:00"`)로 정상 직렬화됩니다. +* **[면접관 꼬리 질문]**: "Spring Boot 4.0.0에서는 JavaTimeModule이 기본 등록되어 있나요?" +* **[면접자 답변 대본]**: "네, Spring Boot 4.0.0 / Spring MVC의 `Jackson2ObjectMapperBuilder`가 자동으로 `JavaTimeModule`을 감지하여 등록해 줍니다." + +### Q45. REST API 응답 포맷 일관성을 위한 `ResponseEntity` 및 Common Response Wrapper 구조는 무엇인가요? +* **정확한 대답**: API 응답의 HTTP Status Code, Header, Body 데이터를 타입 안정하게 캡슐화하기 위해 `ResponseEntity`를 사용하며, 에러 발생 시에도 `GlobalExceptionHandler`를 통해 동일한 에러 JSON 구조(`{"error": "MESSAGE"}`)를 반환하는 일관성 전략입니다. +* **[면접관 꼬리 질문]**: "성공 응답에 200 OK 대신 201 Created를 사용하는 기준은 무엇인가요?" +* **[면접자 답변 대본]**: "`POST /api/members` 회원가입처럼 서버에 새로운 자원이 정상적으로 생성된 요청에는 `201 Created`와 함께 생성된 자원의 위치(Location)를 반환하는 것이 RESTful 스펙입니다." + +### Q46. Spring Security `CorsFilter`와 Custom Interceptor / Filter의 실행 순서는 어떻게 되나요? +* **정확한 대답**: `CorsFilter`는 Spring Security Filter Chain의 최상단(1번 필터) 부근에 위치하여, Custom Interceptor나 Controller에 요청이 도착하기 훨씬 전에 브라우저의 OPTIONS Preflight 요청을 미리 검증하고 응답합니다. +* **[면접관 꼬리 질문]**: "만약 CorsFilter보다 Custom Filter가 먼저 실행되면 어떤 에러가 발생하나요?" +* **[면접자 답변 대본]**: "Custom Filter에서 미인증 유저의 OPTIONS 예비 요청을 401 Unauthorized로 차단해 버려, 브라우저가 실제 요청(POST/GET)을 보내기도 전에 CORS 에러가 발생합니다." + +### Q47. 데이터베이스 커넥션 풀(HikariCP)의 `connectionTimeout`과 `maxLifetime` 속성의 역할은 무엇인가요? +* **정확한 대답**: `connectionTimeout`은 애플리케이션이 풀에서 커넥션을 얻기 위해 대기하는 최대 시간(기본 30초)이며, `maxLifetime`은 커넥션이 풀 안에서 유휴 상태로 존재할 수 있는 최대 수명(기본 30분)입니다. +* **[면접관 꼬리 질문]**: "HikariCP maxLifetime 설정 시 DB의 `wait_timeout`보다 크게 설정하면 어떻게 되나요?" +* **[면접자 답변 대본]**: "DB가 먼저 커넥션을 끊어버렸는데 애플리케이션 풀은 살아있다고 착각하여, 해당 커넥션을 가져와 쿼리를 날릴 때 `CommunicationsException` 끊김 장애가 터집니다. 반드시 maxLifetime을 DB wait_timeout보다 2~3분 짧게 설정해야 합니다." + +### Q48. `MemberRepository` 테스트 시 `@DataJpaTest` vs `@SpringBootTest` 선택 기준은 무엇인가요? +* **정확한 대답**: `@DataJpaTest`는 JPA 관련 컴포넌트만 슬라이스 로딩하고 기본적으로 `@Transactional` 롤백이 자동 적용되어 순수 Repository 쿼리 테스트에 최적이며, `@SpringBootTest`는 전체 의존성을 띄워 통합 검증 시 사용합니다. +* **[면접관 꼬리 질문]**: "`@DataJpaTest` 실행 시 실제 MySQL DB를 바라보게 하려면 어떻게 하나요?" +* **[면접자 답변 대본]**: "`@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)` 어노테이션을 붙여 임베디드 DB 대체 기능을 끄면 실제 설정된 MySQL DB 환경에서 레포지토리 테스트가 가능합니다." + +### Q49. JPA 엔티티 설계 시 `@EqualsAndHashCode`를 함부로 사용하면 안 되는 이유는 무엇인가요? +* **정확한 대답**: Lombok의 `@EqualsAndHashCode`를 엔티티 전체 필드에 걸면, 지연 로딩 필드가 호출되어 불필요한 SQL이 쏟아지거나 영속성 컨텍스트의 객체 동일성(`==`) 원칙이 깨집니다. PK(`id`) 필드만 기준으로 구현해야 합니다. +* **[면접관 꼬리 질문]**: "엔티티의 equals & hashCode 구현 시 id가 null인 비영속 상태 엔티티는 어떻게 처리하나요?" +* **[면접자 답변 대본]**: "비영속 엔티티는 ancora id가 없어 항상 `false`가 반환될 수 있으므로, 객체 동일성(`this == o`) 또는 비즈니스 키(public_id 등)를 비교하도록 안전하게 구현해야 합니다." + +### Q50. Spring Boot 4.0.0 기반 Snowthing 백엔드의 최종 아키텍처 및 품질 검증 요약은 무엇인가요? +* **정확한 대답**: Snowthing 백엔드는 Spring Boot 4.0.0 (Java 21), Spring Security 7, MySQL 8.0 인프라 기반 위에 **Dual PK 보안, 세션 고정 방어, 3회 개별 JOIN FETCH 성능 최적화, DTO record 캡슐화, Bean Validation `@Valid` 검증**을 적용하고 **25개 통합 테스트 통과**로 검증된 최상 품질의 백엔드 시스템입니다. +* **[면접관 꼬리 질문]**: "프로젝트를 진행하면서 얻은 최고의 아키텍처적 레슨은 무엇인가요?" +* **[면접자 답변 대본]**: "단순히 기능을 만드는 것에 그치지 않고, DB 인덱스 B-Tree 구조, 영속성 컨텍스트 1차 캐시, 경쟁 상태(Race Condition) 락 방어, N+1 쿼리 최적화 등 물리적 원리와 트레이드오프를 명확히 이해하고 아키텍처를 설계하는 것의 중요성을 체득한 점입니다." diff --git a/docs/study/sprint01/studySprint01BoardIssuesAndSolutions260821.md b/docs/study/sprint01/studySprint01BoardIssuesAndSolutions260821.md new file mode 100644 index 0000000..bda4408 --- /dev/null +++ b/docs/study/sprint01/studySprint01BoardIssuesAndSolutions260821.md @@ -0,0 +1,124 @@ +# 📚 [Study Guide] Sprint 01 게시판 & 댓글 5대 아키텍처 문제점, 극복 방안 및 대체 기술 비교 가이드 (2026-08-21) + +> **노션(Notion) 복사용 및 백엔드 기술 면접 대비용 마스터 가이드** +> 본 문서는 Snowthing 스프린트 01/02 커뮤니티 도메인(게시글 Post & 댓글/대댓글 Comment)을 구축하면서 발견된 **5대 아키텍처 문제점**, **실제 적용된 물리적 해결책**, 그리고 **현업에서 사용되는 다양한 대체 대안(Alternatives) 및 트레이드오프 분석**을 7대 필수 요소 체계에 입각하여 정리한 문서입니다. + +--- + +# 📑 PART 1. 커뮤니티 5대 문제점, 물리적 원인, 극복 방안 & 대체 대안 (Alternatives) + +--- + +## 1. 💥 [댓글] 이미 삭제된 댓글 재삭제 시 `comment_count` 음수 차감 문제 + +### ① 개념 (What) +Soft Delete 처리된 댓글에 대해 동시 요청이나 무효한 삭제 요청이 들어왔을 때, 게시글의 역정규화 컬럼인 `post.comment_count`가 계속 차감되어 **수치가 음수(Negative Value)로 오염**되는 현상입니다. + +### ② 물리적 원인 및 사이드 이펙트 (Why & Side-Effect) +- [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java) `deleteComment()` 메서드에서 `comment.softDelete()` 이후 `post.decreaseCommentCount()`를 실행합니다. +- 동시 2회 요청 시 `comment.isDeleted()` 예외 검사가 뚫리거나, 관리자 강제 삭제 쿼리 실행 시 `comment_count`가 0에서 차감되어 `-1`, `-2`가 되는 데이터 정합성 파괴가 발생합니다. + +### ③ 현재 적용된 물리적 해결책 (How) +1. **도메인 엔티티 캡슐화 방어**: `Post` 엔티티 내부 `decreaseCommentCount()` 메서드에서 `this.commentCount = Math.max(0, this.commentCount - 1)`로 물리적 하한선(0)을 강제합니다. +2. **DB Table CHECK 제약 조건**: `POST` 테이블의 `comment_count` 컬럼에 `CHECK (comment_count >= 0)`를 추가하여 DB 엔진 단에서 음수 저장을 차단합니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: DB Atomic SQL 함수 (`GREATEST`) 사용** + - **원리**: `UPDATE post SET comment_count = GREATEST(0, comment_count - 1) WHERE post_id = :id` SQL 쿼리 내에서 MySQL의 `GREATEST()` 함수를 사용하여 0 미만 차감을 원자적으로 물리 방지합니다. + - **장점**: JPA 영속성 컨텍스트를 거치지 않고 DB 엔진이 1회 SQL 쿼리로 방어하므로 안전합니다. +* **대안 B: 스케줄러 기반 비동기 카운터 재계산 (Scheduled Counter Reconciliation)** + - **원리**: 댓글 삭제 시 카운트를 즉시 차감하지 않고, 5분마다 `@Scheduled` 스케줄러가 `SELECT COUNT(*) FROM comment WHERE post_id = :id AND is_deleted = false` 쿼리로 실시간 숫자를 재계산하여 동기화합니다. + - **장점**: 연산 오류나 음수 차감 위험이 완전히 제거됩니다. + +--- + +## 2. 💥 [게시글] 인기 글 상세 조회 시 `increaseViewCount()` 쓰기 락(Row Lock) 병목 + +### ① 개념 (What) +유저가 게시글을 클릭하여 읽을 때마다 동기 트랜잭션(`@Transactional`) 내에서 `UPDATE post SET view_count = view_count + 1` 쿼리가 실행되어 DB 쓰기 병목이 발생하는 현상입니다. + +### ② 물리적 원인 및 사이드 이펙트 (Why & Side-Effect) +- 단순 읽기(Read) 요청임에도 불구하고 인기 게시글에 동접자 1,000명이 한 번에 들어오면, **동일한 DB Row에 대한 배타적 쓰기 락(Exclusive Row Lock)**을 얻기 위해 트랜잭션 대기 열(Lock Contention)이 형성됩니다. +- 이로 인해 읽기 응답 속도가 급격히 느려지고 DB 커넥션 타임아웃 예외가 발생합니다. + +### ③ 현재 적용된 물리적 해결책 (How) +* **DB Atomic Bulk Update 쿼리 실행**: JPA 엔티티 스냅샷 비교 대신 `@Modifying @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id")`를 호출하여 영속성 컨텍스트 1:1 비교 오버헤드를 줄입니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: Redis HyperLogLog (인메모리 고성능 카운팅 & 중복 제거)** + - **원리**: Redis의 HyperLogLog 자료구조(`PFADD post:views:{id} {memberId_or_ip}`)를 사용합니다. 단 12KB의 메모리만으로 100만 명의 중복 조회를 인메모리 O(1)로 차단하고 조회수를 카운팅합니다. + - **장점**: DB Row Lock이 100% 제거되고 중복 조회수 어뷰징까지 동시에 해결됩니다. +* **대안 B: Client-Side Cookie 쿨타임 제한 (24시간 중복 방지)** + - **원리**: 유저 브라우저 쿠키(`viewed_posts=1,4,12`)에 읽은 글 ID를 저장하고, 쿠키가 존재하는 24시간 동안은 백엔드로 조회수 증가 API를 아예 보내지 않도록 프론트엔드에서 차단합니다. + - **장점**: 백엔드 서버로 들어오는 HTTP 요청 수 자체를 줄여줍니다. + +--- + +## 3. 💥 [추천 비동기] `@Async` 이벤트 유실 시 투표 이력과 카운트 수치 불일치 + +### ① 개념 (What) +추천/비추천 투표 시 `post_reaction` 테이블 저장은 즉시 완료(COMMIT)되었으나, 비동기로 카운트를 올리는 `@Async` 핸들러가 예외나 서버 셧다운으로 유실될 경우 데이터 불일치가 남는 현상입니다. + +### ② 물리적 원리 및 사이드 이펙트 (Why & Side-Effect) +- `post_reaction` 테이블에는 투표 내역 1건이 정상 저장되어 유저는 중복 투표를 할 수 없는데, 게시글의 `like_count` 수치는 올라가지 않아 **두 테이블 간 정합성 불일치(Data Inconsistency)**가 발생합니다. + +### ③ 현재 적용된 물리적 해결책 (How) +* **비동기 예외 로그 수집 & UI 낙관적 반영**: 프론트엔드 Optimistic UI로 시각적 일치감을 보장하고 백엔드 예외 로그를 수집합니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: Transactional Outbox Pattern (트랜잭셔널 아웃박스 패턴)** + - **원리**: 이벤트를 인메모리 스프링 이벤트로 던지지 않고, 동일한 DB 트랜잭션 내에서 `outbox` 테이블에 이벤트를 함께 `INSERT`합니다. 이후 Debezium(CDC)이나 Polling Publisher가 이 이벤트를 안전하게 읽어서 비동기 처리합니다. + - **장점**: 서버가 갑자기 꺼지거나 비동기 스레드가 죽어도 이벤트 유실이 발생하지 않는 기업급 분산 트랜잭션 패턴입니다. +* **대안 B: 정정 스케줄러 배치 (Scheduled Reckoning Batch)** + - **원리**: 매일 새벽 4시마다 `SELECT COUNT(*) FROM post_reaction WHERE post_id = :id AND type = 'LIKE'` 쿼리와 `post.like_count` 수치를 대조하여 다를 경우 불일치를 자동으로 수정하는 배치를 돌립니다. + +--- + +## 4. 💥 [대댓글] 3차 이상 무한 깊이 대댓글 작성 시 프론트엔드 UI 파괴 문제 + +### ① 개념 (What) +유저가 '대댓글(2차)'의 ID를 `parentId`로 지정하여 3차, 4차, 5차 계층의 대댓글을 계속해서 작성할 때 발생하는 문제입니다. + +### ② 물리적 원리 및 사이드 이펙트 (Why & Side-Effect) +- 프론트엔드 계층형 트리 렌더링 시 대댓글 깊이(`depth`)에 비례하여 `marginLeft: depth * 1.5rem` 들여쓰기가 적용됩니다. +- N차 대댓글이 계속 작성되면 들여쓰기가 화면 우측 밖으로 삐져나가 **모바일 및 웹 UI 레이아웃이 완전히 깨지게 됩니다.** + +### ③ 현재 적용된 물리적 해결책 (How) +* **2단계 깊이(Depth) 제한Validation**: `parent.getParent() != null` 조건으로 부모 댓글이 이미 대댓글인 경우 400 Bad Request 예외를 던져 3차 이상 대댓글 작성을 차단합니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: Flat List + `@Mention` (유튜브 / 인스타그램 스타일 1차 평탄화)** + - **원리**: 대댓글 들여쓰기 깊이를 없애고 모든 대댓글을 원댓글 바로 아래 평탄한(Flat) 리스트로 렌더링하며, 누구에게 보낸 답글인지 `@닉네임` 태그로 표시합니다. + - **장점**: UI 레이아웃이 화면 밖으로 삐져나가는 현상이 아예 구조적으로 불가능해집니다. +* **대안 B: CSS Max-Indent Clamp (프론트엔드 들여쓰기 한계선 고정)** + - **원리**: 백엔드는 N차 대댓글을 허용하되, 프론트엔드 CSS에서 `margin-left: min(depth * 1.5rem, 4.5rem)`으로 최대 들여쓰기 한계를 4.5rem으로 고정합니다. + +--- + +## 5. 💥 [보안] 익명 비밀번호 URL 쿼리 스트링 평문 노출 위험 + +### ① 개념 (What) +비회원 익명 게시글/댓글 삭제 시 `DELETE /api/posts/{publicId}?anonymousPassword=1234` 형태처럼 URL 쿼리 파라미터로 비밀번호가 전달될 때 발생하는 보안 문제입니다. + +### ② 물리적 원리 및 사이드 이펙트 (Why & Side-Effect) +- HTTP URL 쿼리 스트링은 Nginx 웹 서버 Access Log, AWS ALB 액세스 로그, 브라우저 History에 **평문(Plaintext)으로 100% 그대로 기록**됩니다. +- 웹 서버 로그를 조회할 때 비회원의 비밀번호가 인가 없이 노출될 수 있습니다. + +### ③ 현재 적용된 물리적 해결책 (How) +* **BCrypt 해시 암호화 검증**: DB 저장 시 비밀번호를 BCrypt 해싱하여 원본 비밀번호 유출을 차단합니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: HTTP Custom Header (`X-Anonymous-Password`) 전달** + - **원리**: `DELETE` 요청 전송 시 URL 쿼리 스트링 대신 `X-Anonymous-Password: 1234` 커스텀 HTTP 헤더에 실어 보냅니다. + - **장점**: Nginx 및 액세스 로그에 비밀번호가 남지 않습니다. +* **대안 B: HMAC-SHA256 일회용 삭제 토큰 (Stateless Delete Token)** + - **원리**: 비회원이 글 작성 시 백엔드가 비밀번호를 저장하지 않고, `SHA256(publicId + secretKey + password)`로 생성된 일회용 삭제 토큰을 유저 클라이언트에 전달합니다. 삭제 시 유저는 비밀번호 대신 이 토큰을 제시하여 검증받습니다. + +--- + +# 📌 PART 2. 작업 완료 요약 + +* **생성된 파일 경로**: + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint01\studySprint01BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint01/studySprint01BoardIssuesAndSolutions260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studySprint01BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint01BoardIssuesAndSolutions260821.md) +* **작업 이력 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일 업데이트 완료. diff --git a/docs/study/sprint01/studySprint01CompleteCodeMaster260821.md b/docs/study/sprint01/studySprint01CompleteCodeMaster260821.md new file mode 100644 index 0000000..2efb27d --- /dev/null +++ b/docs/study/sprint01/studySprint01CompleteCodeMaster260821.md @@ -0,0 +1,897 @@ +# 📖 [Master Study] Snowthing Sprint 01 백엔드 전 과정 전체 코드, 상세 주석, 설계 배경 & 아키텍처 대안 집대성 (260821) + +> **문서 목적**: Snowthing 프로젝트 Sprint 01 백엔드 개발의 모든 코드(Spring Security 7, Global Exception, Auth, Member, JPA ORM, JPQL Fetch Join, DTO, 25개 테스트 수트 전체)를 불러와 **한 줄 한 줄 물리적 역할 해설 주석(Annotation)**을 달고, **"왜 그렇게 만들어졌는가(Design Rationale & Background)"**, **기술별 7대 필수 서술 체계(개념, Why, When, How, Pros, Alternatives, Trade-off & Mitigation)**, **파라미터/옵션 튜닝**, 그리고 **"여기서는 이렇게 설계했어도 좋았을 것이다" 5대 아키텍처 대안**까지 완벽하게 수록하여 노션(Notion)에 바로 복사해 공부할 수 있도록 만든 단 1개의 마스터 스터디 교재입니다. + +--- + +## 🏛️ PART 1. 요청 진입 & 글로벌 보안 파이프라인 레이어 + +### 💡 [WHY] 왜 SecurityConfig를 이렇게 설계했는가? +1. **왜 Lambda DSL 패턴을 사용했는가?**: Spring Security 6.1+ 및 7.0 버전부터 기존의 `http.cors().and().csrf().disable()`과 같은 메서드 체이닝(Method Chaining) 방식이 딥 네스팅 및 가독성 저하 문제로 Deprecated 되었습니다. 명확한 함수형 람다 표현식(`http.cors(cors -> ...).csrf(csrf -> ...)`)으로 구성하여 설정 간의 경계를 물리적으로 명확히 분리하기 위함입니다. +2. **왜 `AbstractHttpConfigurer::disable`로 Form/HttpBasic을 껐는가?**: 백엔드는 HTML 페이지를 응답하는 SSR(JSP, Thymeleaf)이 아니라 JSON 데이터를 주고받는 Pure REST API 서버입니다. 시큐리티 기본 제공 로그인 폼 렌더링과 HTTP Basic 헤더 인증 방식을 끔으로써 불필요한 필터 오버헤드를 차단했습니다. +3. **왜 `AuthenticationManager`와 `SecurityContextRepository`를 빈으로 노출했는가?**: Controller에서 수동 비밀번호 대조 코드를 배제하고, Spring Security의 표준 인증 파이프라인(`authenticationManager.authenticate()`) 및 표준 세션 저장(`securityContextRepository.saveContext()`)을 수행할 수 있도록 의존성을 주입받기 위함입니다. + +```java +// ================================================================================= +// 🛡️ 1-1. SecurityConfig.java - Spring Security 7 최신 Lambda DSL 필터체인 설정 +// ================================================================================= +package com.ikae.snowthing.global.config; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration // 👈 스프링 CGLIB 프록시 기반으로 클래스를 등록하고 싱글톤 객체 생성을 보장 +@EnableWebSecurity // 👈 Spring Security 필터 체인을 활성화하고 WebSecurityConfigurer를 스프링 컨텍스트에 등록 +public class SecurityConfig { + + /** + * [비밀번호 암호화 빈 등록] + * BCrypt 해시 함수를 사용하여 비밀번호를 단방향 암호화하는 PasswordEncoder 등록. + * Raw 비밀번호와 Encoded 비밀번호 대조는 passwordEncoder.matches()가 내부 솔트(Salt)를 파싱하여 수행. + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * [AuthenticationManager 빈 노출] + * Spring Security 표준 인증 처리를 총괄하는 AuthenticationManager를 스프링 빈으로 노출. + * AuthController에서 수동 비밀번호 대조 대신 authenticationManager.authenticate(...)를 호출할 수 있게 함. + */ + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + /** + * [CorsConfigurationSource 빈 등록] + * 프론트엔드 도메인(http://localhost:3000)과의 Cross-Origin 요청 허용 규칙을 정의. + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:3000")); // 👈 Next.js 프론트엔드 출처 허용 + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); // 👈 HTTP 메서드 허용 + config.setAllowedHeaders(List.of("*")); // 👈 모든 HTTP 헤더 허용 + config.setAllowCredentials(true); // 👈 쿠키(JSESSIONID) 및 인증 자격 증명 전송 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); // 👈 모든 API 경로에 CORS 규칙 등록 + return source; + } + + /** + * [SecurityContextRepository 빈 등록] + * HTTP 세션(HttpSession)에 SecurityContext를 저장하고 복원하는 HttpSessionSecurityContextRepository 지정. + */ + @Bean + public SecurityContextRepository securityContextRepository() { + return new HttpSessionSecurityContextRepository(); + } + + /** + * [SecurityFilterChain 메인 설정] + * HTTP 요청이 진입할 때 실행되는 시큐리티 필터 파이프라인 체인을 구성. + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // 1. CORS(Cross-Origin Resource Sharing) 설정 적용 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + // 2. CSRF 비활성화 (REST API + SPA 환경에서는 헤더/SameSite 세션 관리로 대처) + .csrf(AbstractHttpConfigurer::disable) + // 3. 폼 로그인 및 Basic HTTP 인증 비활성화 (JSON REST API 방식 사용) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + // 4. 시큐리티 컨텍스트 저장소 지정 (HttpSession 기반) + .securityContext(securityContext -> securityContext + .securityContextRepository(securityContextRepository()) + ) + // 5. 세션 고정 공격 방어 (로그인 성공 시 기존 JSESSIONID 무효화 및 새 ID 발급: changeSessionId) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .sessionFixation(sessionFixation -> sessionFixation.changeSessionId()) + ) + // 6. 시큐리티 표준 로그아웃 설정 (/api/auth/logout POST 요청 시 세션 무효화 및 쿠키 삭제) + .logout(logout -> logout + .logoutRequestMatcher(new AntPathRequestMatcher("/api/auth/logout", "POST")) + .invalidateHttpSession(true) + .clearAuthentication(true) + .deleteCookies("JSESSIONID") + .logoutSuccessHandler((request, response, authentication) -> { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"message\":\"LOGOUT_SUCCESS\"}"); + }) + ) + // 7. 예외 처리 핸들러 (401 Unauthorized / 403 Forbidden 표준 JSON 응답) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"UNAUTHORIZED\",\"code\":\"AUTH_001\",\"message\":\"로그인이 필요합니다.\"}"); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"FORBIDDEN\",\"code\":\"AUTH_002\",\"message\":\"접근 권한이 없습니다.\"}"); + }) + ) + // 8. URL별 인가(Authorization) 규칙 정의 + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/members", "/api/auth/login", "/api/resorts", "/api/riding-styles").permitAll() // 👈 비인증 엔드포인트 + .requestMatchers("/api/admin/**").hasRole("ADMIN") // 👈 관리자 권한 필요 + .requestMatchers("/api/members/me").authenticated() // 👈 로그인 인증 필요 + .anyRequest().authenticated() + ); + + return http.build(); + } +} +``` + +```java +// ================================================================================= +// 👤 1-2. CustomUserDetails.java - Spring Security UserDetails 구현체 +// ================================================================================= +package com.ikae.snowthing.global.security; + +import com.ikae.snowthing.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor // 👈 final 필드(member)에 대한 생성자 자동 생성 +public class CustomUserDetails implements UserDetails { + + private final Member member; // 👈 DB에서 조회한 실제 Member 엔티티 객체를 내부에 캡슐화 + + /** + * [권한 목록 반환] + * MemberRole(ROLE_USER, ROLE_ADMIN)을 SimpleGrantedAuthority 객체로 변환하여 시큐리티에 제공. + */ + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(member.getRole().getKey())); + } + + @Override + public String getPassword() { + return member.getPassword(); // 👈 BCrypt로 암호화된 비밀번호 반환 + } + + @Override + public String getUsername() { + return member.getEmail(); // 👈 로그인 식별자로 사용되는 이메일 반환 + } + + public String getPublicId() { + return member.getPublicId(); // 👈 컨트롤러 및 서비스에서 도메인 식별자로 사용 + } + + public String getNickname() { + return member.getNickname(); + } + + @Override + public boolean isAccountNonExpired() { + return true; // 👈 계정 만료 여부 (true: 만료 안 됨) + } + + @Override + public boolean isAccountNonLocked() { + // 👈 정지(SUSPENDED) 상태인 경우 계정 잠금 처리 + return member.getStatus() != com.ikae.snowthing.domain.member.entity.MemberStatus.SUSPENDED; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; // 👈 비밀번호 만료 여부 (true: 만료 안 됨) + } + + @Override + public boolean isEnabled() { + // 👈 ACTIVE 상태인 경우만 계정 활성화 + return member.getStatus() == com.ikae.snowthing.domain.member.entity.MemberStatus.ACTIVE; + } +} +``` + +```java +// ================================================================================= +// 🔍 1-3. CustomUserDetailsService.java - UserDetailsService DB 회원 조회 구현체 +// ================================================================================= +package com.ikae.snowthing.global.security; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service // 👈 Component Scan 대상 지정 +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + /** + * [이메일로 회원 조회 및 UserDetails 변환] + * DaoAuthenticationProvider가 로그인 시 호출하는 핵심 메서드. + */ + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException(ErrorCode.MEMBER_NOT_FOUND.getMessage())); + return new CustomUserDetails(member); // 👈 CustomUserDetails로 감싸서 반환 + } +} +``` + +--- + +## 🚨 PART 2. 글로벌 예외 처리 & 에러 응답 정형화 레이어 + +### 💡 [WHY] 왜 ErrorCode Enum 및 GlobalExceptionHandler로 일관화했는가? +1. **왜 예외 메시지 문자열 직접 생성을 금지했는가?**: `"INVALID_CREDENTIALS"` 같은 에러 문자열을 서비스 로직 곳곳에서 수동으로 생성하면, 오타로 인한 버그가 발생하고 에러 메시지가 수정될 때 수십 개의 클래스를 뒤져야 하는 비효율이 터집니다. `ErrorCode` Enum으로 정적 재사용하여 컴파일 시점 오타 방지 및 중앙 집권적 관리를 이뤘습니다. +2. **왜 `@RestControllerAdvice`로 예외를 캡처했는가?**: 컨트롤러 내부에서 `try-catch`로 예외를 일일이 받아서 처리하면 컨트롤러 코드가 비즈니스 외적인 에러 응답 코드로 더러워집니다. 스프링 AOP(Aspect Oriented Programming) 기반의 전역 예외 처리기를 구축하여 모든 에러를 1개의 규격화된 JSON 구조(`ErrorResponse`)로 응답하도록 설계했습니다. + +```java +// ================================================================================= +// 🏷️ 2-1. ErrorCode.java - 표준 에러코드 Enum +// ================================================================================= +package com.ikae.snowthing.global.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 일치하지 않습니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_001", "존재하지 않는 회원입니다."), + DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "MEMBER_002", "이미 사용 중인 이메일입니다."), + DUPLICATE_NICKNAME(HttpStatus.BAD_REQUEST, "MEMBER_003", "이미 사용 중인 닉네임입니다."), + INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); + + private final HttpStatus status; // 👈 HTTP 상태 코드 (400, 401, 404, 500) + private final String code; // 👈 클라이언트 추적용 고유 에러 코드 문자열 + private final String message; // 👈 사용자 친화적 에러 메시지 +} +``` + +```java +// ================================================================================= +// 📦 2-2. ErrorResponse.java - 표준 JSON 에러 응답 DTO +// ================================================================================= +package com.ikae.snowthing.global.error; + +public record ErrorResponse( + String code, + String error, + String message +) { + public static ErrorResponse from(ErrorCode errorCode) { + return new ErrorResponse(errorCode.getCode(), errorCode.name(), errorCode.getMessage()); + } + + public static ErrorResponse of(ErrorCode errorCode, String customMessage) { + return new ErrorResponse(errorCode.getCode(), errorCode.name(), customMessage); + } +} +``` + +```java +// ================================================================================= +// 💥 2-3. CustomAuthException.java - ErrorCode 보유 커스텀 예외 +// ================================================================================= +package com.ikae.snowthing.global.exception; + +import com.ikae.snowthing.global.error.ErrorCode; +import lombok.Getter; + +@Getter +public class CustomAuthException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomAuthException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomAuthException(String message) { + super(message); + this.errorCode = ErrorCode.INVALID_CREDENTIALS; + } +} +``` + +```java +// ================================================================================= +// 🛠️ 2-4. GlobalExceptionHandler.java - 전역 예외 처리 컨트롤러 어드바이스 +// ================================================================================= +package com.ikae.snowthing.global.exception; + +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.error.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice // 👈 모든 RestController에서 발생하는 예외를 전역 감지 +public class GlobalExceptionHandler { + + /** + * [CustomAuthException 처리] + * 도메인 인증 예외 발생 시 해당 ErrorCode 기반 JSON 응답 반환. + */ + @ExceptionHandler(CustomAuthException.class) + public ResponseEntity handleCustomAuthException(CustomAuthException e) { + ErrorCode errorCode = e.getErrorCode() != null ? e.getErrorCode() : ErrorCode.INVALID_CREDENTIALS; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.from(errorCode)); + } + + /** + * [Spring Security 인증 예외 처리] + * BadCredentialsException 및 UsernameNotFoundException을 401 UNAUTHORIZED 응답으로 통일. + */ + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) + public ResponseEntity handleAuthenticationException(Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ErrorResponse.from(ErrorCode.INVALID_CREDENTIALS)); + } + + /** + * [IllegalArgumentException 처리] + * 에러 메시지 키워드에 따른 적절한 ErrorCode 매핑 처리. + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + if ("INVALID_CREDENTIALS".equals(e.getMessage())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ErrorResponse.from(ErrorCode.INVALID_CREDENTIALS)); + } + if ("DUPLICATE_EMAIL".equals(e.getMessage())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.from(ErrorCode.DUPLICATE_EMAIL)); + } + if ("DUPLICATE_NICKNAME".equals(e.getMessage())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.from(ErrorCode.DUPLICATE_NICKNAME)); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, e.getMessage())); + } + + /** + * [Bean Validation 유효성 검증 예외 처리] + * @Valid 검증 실패 시 발생하며, DTO에 설정한 defaultMessage를 추출해 반환. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { + String defaultMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.of(ErrorCode.INVALID_INPUT, defaultMessage != null ? defaultMessage : ErrorCode.INVALID_INPUT.getMessage())); + } +} +``` + +--- + +## 🎮 PART 3. 인증 API & 회원가입/프로필 API 흐름 레이어 + +### 💡 [WHY] 왜 컨트롤러와 서비스 구조를 이렇게 정제했는가? +1. **왜 `AuthService.authenticate()` 수동 메서드를 없애고 `AuthenticationManager`에 위임했는가?**: `AuthService`에서 `passwordEncoder.matches()`를 수동 호출하면 시큐리티 인증 메커니즘과 서비스 코드가 강하게 결합됩니다. 시큐리티 전용 컴포넌트인 `AuthenticationManager`가 `DaoAuthenticationProvider`를 통해 인증을 처리하게 만들고, `AuthService`는 로그인 후 유저 프로필 및 연관 도메인 데이터 조립에만 전념하게 만들었습니다 (단일 책임 원칙 SRP 달성). +2. **왜 `List.copyOf()` 방어적 복사를 도입했는가?**: 자바에서 컬렉션을 전달받을 때 주소 참조값을 공유하면 외부 코드에서 `list.clear()`나 `list.add()`를 수행했을 때 DTO 내부 리스트 데이터까지 훼손됩니다. DTO가 항상 100% 불변 상태를 유지하도록 주소값을 끊고 새 불변 리스트를 생성하는 방어적 복사를 적용했습니다. +3. **왜 `@AuthenticationPrincipal`을 도입했는가?**: `SecurityContextHolder.getContext().getAuthentication().getPrincipal()` 같은 4줄짜리 캐스팅 보일러플레이트 코드를 완전히 제거하고, 스프링 MVC ArgumentResolver가 컨트롤러 메서드 파라미터에 즉시 `CustomUserDetails`를 바인딩해 주도록 구성했습니다. + +```java +// ================================================================================= +// 🔑 3-1. MemberLoginResponse.java - 방어적 복사(List.copyOf)가 적용된 로그인 DTO +// ================================================================================= +package com.ikae.snowthing.domain.auth.dto; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +public class MemberLoginResponse { + + private final String publicId; + private final String email; + private final String nickname; + private final Role role; + private final List resortNames; + private final List ridingStyleNames; + + @Builder + public MemberLoginResponse(String publicId, String email, String nickname, Role role, List resortNames, List ridingStyleNames) { + this.publicId = publicId; + this.email = email; + this.nickname = nickname; + this.role = role; + // 👈 방어적 복사 (Defensive Copy) 적용으로 100% 불변 리스트 보장 및 외부 변형 차단 + this.resortNames = resortNames != null ? List.copyOf(resortNames) : List.of(); + this.ridingStyleNames = ridingStyleNames != null ? List.copyOf(ridingStyleNames) : List.of(); + } + + public static MemberLoginResponse from(Member member, List resortNames, List ridingStyleNames) { + return MemberLoginResponse.builder() + .publicId(member.getPublicId()) + .email(member.getEmail()) + .nickname(member.getNickname()) + .role(member.getRole()) + .resortNames(resortNames) + .ridingStyleNames(ridingStyleNames) + .build(); + } +} +``` + +```java +// ================================================================================= +// 🔓 3-2. AuthController.java - Spring Security 표준 위임 로그인 컨트롤러 +// ================================================================================= +package com.ikae.snowthing.domain.auth.controller; + +import com.ikae.snowthing.domain.auth.dto.MemberLoginRequest; +import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; +import com.ikae.snowthing.domain.auth.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + // 👈 매직 넘버 리터럴을 정적 상수로 추출하여 가독성 및 유지보수성 확보 + private static final int REMEMBER_ME_TIMEOUT_SECONDS = 30 * 24 * 60 * 60; // 30일 (2,592,000초) + private static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 60 * 60; // 1시간 (3,600초) + + private final AuthenticationManager authenticationManager; // 👈 시큐리티 표준 인증 총괄 + private final SecurityContextRepository securityContextRepository; // 👈 세션 저장소 + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity login( + @Valid @RequestBody MemberLoginRequest loginRequest, // 👈 Bean Validation 입력값 검증 + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { + // 1. 미인증 UsernamePasswordAuthenticationToken 객체 생성 + Authentication authenticationToken = new UsernamePasswordAuthenticationToken( + loginRequest.getEmail(), + loginRequest.getPassword() + ); + + // 2. AuthenticationManager에 인증 위임 + // ➔ CustomUserDetailsService.loadUserByUsername() 호출 ➔ DaoAuthenticationProvider 비밀번호 대조 + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + // 3. SecurityContext 생성 및 ContextHolder 설정 (ThreadLocal 저장) + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + + // 4. 세션 고정 공격 방어(Session Fixation Protection) 및 시큐리티 표준 세션 저장 + HttpSession session = httpRequest.getSession(true); + httpRequest.changeSessionId(); // 👈 JSESSIONID 신규 재발급 + securityContextRepository.saveContext(securityContext, httpRequest, httpResponse); // 👈 HttpSession 저장 + + // 5. Remember-Me 세션 타임아웃 계산 및 적용 (별도 메서드 분리) + int timeoutSeconds = calculateSessionTimeoutSeconds(loginRequest.isRememberMe()); + session.setMaxInactiveInterval(timeoutSeconds); + + // 6. 회원 프로필 DTO 조립 반환 + MemberLoginResponse response = authService.getMyProfile(loginRequest.getEmail()); + return ResponseEntity.ok(response); + } + + /** + * [Remember-Me 타임아웃 계산 메서드 분리] + */ + private int calculateSessionTimeoutSeconds(boolean rememberMe) { + return rememberMe ? REMEMBER_ME_TIMEOUT_SECONDS : DEFAULT_SESSION_TIMEOUT_SECONDS; + } +} +``` + +```java +// ================================================================================= +// 👤 3-3. MemberController.java - @AuthenticationPrincipal 적용 회원 컨트롤러 +// ================================================================================= +package com.ikae.snowthing.domain.member.controller; + +import com.ikae.snowthing.domain.auth.dto.MemberLoginResponse; +import com.ikae.snowthing.domain.auth.service.AuthService; +import com.ikae.snowthing.domain.member.dto.MemberProfileUpdateRequest; +import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; +import com.ikae.snowthing.domain.member.dto.MemberSignUpResponse; +import com.ikae.snowthing.domain.member.service.MemberService; +import com.ikae.snowthing.global.security.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + private final AuthService authService; + + @PostMapping + public ResponseEntity signUp(@Valid @RequestBody MemberSignUpRequest request) { + MemberSignUpResponse response = memberService.signUp(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/me") + public ResponseEntity getMyProfile(@AuthenticationPrincipal CustomUserDetails userDetails) { + // 👈 SecurityContextHolder 파싱 없이 @AuthenticationPrincipal로 userDetails 직접 주입 + MemberLoginResponse profile = authService.getMyProfile(userDetails.getUsername()); + return ResponseEntity.ok(profile); + } + + @PutMapping("/me") + public ResponseEntity updateMyProfile( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody MemberProfileUpdateRequest request + ) { + String email = userDetails.getUsername(); + memberService.updateMyProfile(email, request); + MemberLoginResponse updatedProfile = authService.getMyProfile(email); + return ResponseEntity.ok(updatedProfile); + } +} +``` + +--- + +## 🗄️ PART 4. JPA 엔티티 & N+1 최적화 리포지토리 레이어 + +### 💡 [WHY] 왜 JPA 모델링과 쿼리를 이렇게 설계했는가? +1. **왜 `@ManyToMany`를 안 쓰고 `MemberResort` / `MemberRidingStyle` 중간 엔티티를 승격시켰는가?**: JPA `@ManyToMany`는 숨겨진 중계 테이블을 자동으로 조작하므로 중간 테이블에 `createdAt`, `status` 등의 추가 컬럼을 넣을 수 없으며, 수정 시 중간 테이블 전체 데이터를 삭제(`delete all`) 후 다시 인서트하는 심각한 N+1 연쇄 삭제 성능 결함이 터집니다. 1:N - N:1 양방향 엔티티로 직접 설계하여 조인 쿼리를 100% 제어하도록 만들었습니다. +2. **왜 리포지토리에서 1개 컬렉션만 Fetch Join하고 나머지는 Batch Size로 처리했는가?**: Hibernate에서 2개 이상의 1:N `List` 컬렉션을 동시 Fetch Join 하면 DB 카테시안 곱(Cartesian Product)이 뻥튀기되어 메모리 폭발이 터지고, Hibernate가 `MultipleBagFetchException` 예외를 던지며 서버를 멈춥니다. 1개의 메인 컬렉션(`MemberResort`)만 Fetch Join 조인하고, 나머지는 `default_batch_fetch_size: 1000` 옵션으로 IN 쿼리를 단 1번에 묶어 날리도록 최적화했습니다. + +```java +// ================================================================================= +// 🗄️ 4-1. MemberRepository.java - JPQL Fetch Join 성능 최적화 리포지토리 +// ================================================================================= +package com.ikae.snowthing.domain.member.repository; + +import com.ikae.snowthing.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + boolean existsByNickname(String nickname); + + /** + * [이메일 기준 단 1회 조인 회원 조회] + */ + @Query("SELECT m FROM Member m WHERE m.email = :email") + Optional findByEmailWithDetails(@Param("email") String email); +} +``` + +```java +// ================================================================================= +// 🏔️ 4-2. MemberResortRepository.java - 선호 스키장 Fetch Join 리포지토리 +// ================================================================================= +package com.ikae.snowthing.domain.member.repository; + +import com.ikae.snowthing.domain.member.entity.MemberResort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MemberResortRepository extends JpaRepository { + + /** + * [N+1 해결 쿼리] + * MemberResort와 Resort 엔티티를 JOIN FETCH로 단 1회의 SQL 조인 쿼리로 묶어 조회. + * 지연 로딩(LAZY) 시 발생하는 1+N 탐색 쿼리를 근본적으로 사전에 차단. + */ + @Query("SELECT mr FROM MemberResort mr JOIN FETCH mr.resort WHERE mr.member.id = :memberId") + List findAllByMemberIdWithResort(@Param("memberId") Long memberId); + + void deleteAllByMemberId(Long memberId); +} +``` + +--- + +## 🧪 PART 5. 백엔드 통합 & 단위 테스트 수트 (25개 테스트 연동 및 테스트 기법) + +### 💡 [WHY] 왜 테스트 기법을 이렇게 나눴는가? +1. **`@SpringBootTest` + `@AutoConfigureMockMvc` (통합 테스트)**: 전체 스프링 컨테이너, SecurityFilterChain, DB 연동까지 HTTP 요청 전체 라이프사이클을 실증 검증합니다. +2. **`CountDownLatch` + `ExecutorService` (동시성 테스트)**: 동일한 이메일/닉네임으로 10개의 스레드가 동시에 가입 요청을 보낼 때, DB 유니크 제약조건과 동시성 제어가 정확히 동작하는지 병렬 테스트를 수행합니다. + +```java +// ================================================================================= +// 🧪 5-1. AuthControllerTest.java - Spring Security 통합 인증 테스트 수트 +// ================================================================================= +package com.ikae.snowthing.domain.auth.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.auth.dto.MemberLoginRequest; +import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; +import com.ikae.snowthing.domain.member.service.MemberService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest // 👈 전체 통합 테스트용 컨테이너 로드 +@AutoConfigureMockMvc // 👈 MockMvc 객체 자동 생성 및 주입 +@Transactional // 👈 각 테스트 후 DB 롤백 처리 +@ActiveProfiles("test") +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MemberService memberService; + + @BeforeEach + void setUp() { + MemberSignUpRequest signUpRequest = new MemberSignUpRequest( + "authuser@snowthing.com", + "Password123!", + "보드왕", + List.of("용평리조트"), + List.of("트릭") + ); + memberService.signUp(signUpRequest); + } + + @Test + @DisplayName("올바른 로그인 요청 시 HTTP 200 OK 및 JSESSIONID 세션 생성 검증") + void login_Success() throws Exception { + MemberLoginRequest loginRequest = new MemberLoginRequest("authuser@snowthing.com", "Password123!", false); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("authuser@snowthing.com")) + .andExpect(jsonPath("$.nickname").value("보드왕")); + } + + @Test + @DisplayName("틀린 비밀번호 입력 시 ErrorCode 기반 401 UNAUTHORIZED 에러 JSON 반환 검증") + void login_Failure_WrongPassword() throws Exception { + MemberLoginRequest loginRequest = new MemberLoginRequest("authuser@snowthing.com", "WrongPassword!", false); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.error").value("INVALID_CREDENTIALS")); + } + + @Test + @DisplayName("로그인 후 세션 쿠키를 동봉하여 /api/members/me 호출 시 200 OK 프로필 반환 검증") + void getMyProfile_WithSession_Success() throws Exception { + MemberLoginRequest loginRequest = new MemberLoginRequest("authuser@snowthing.com", "Password123!", false); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andReturn() + .getRequest() + .getSession(); + + mockMvc.perform(get("/api/members/me").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("authuser@snowthing.com")); + } +} +``` + +```java +// ================================================================================= +// ⚡ 5-2. MemberConcurrencyTest.java - 멀티스레드 동시성 가입 테스트 수트 +// ================================================================================= +package com.ikae.snowthing.domain.member.service; + +import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class MemberConcurrencyTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("동시에 10개의 스레드가 동일 이메일로 가입 시도 시 1건만 성공하고 DB 중복이 발생하지 않는지 검증") + void concurrentSignUp_SameEmail_OnlyOneSucceeds() throws InterruptedException { + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + MemberSignUpRequest request = new MemberSignUpRequest( + "concurrent@snowthing.com", + "Password123!", + "동시성유저", + List.of("휘닉스파크"), + List.of("라이딩") + ); + + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + memberService.signUp(request); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); // 👈 10개 스레드가 완료될 때까지 대기 + + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(threadCount - 1); + } +} +``` + +--- + +## ⚙️ PART 6. 백엔드 적용 10대 핵심 기술 Why, How & 파라미터/옵션 탐구 (Options & Tuning) + +### 1. Spring Security Filter Options & Tuning +* **`SessionCreationPolicy` 옵션 비교**: + - `ALWAYS`: 항상 세션을 새로 생성 (메모리 낭비 심함) + - `NEVER`: 시큐리티가 세션을 직접 생성하진 않지만 기존 세션이 존재하면 활용 + - `IF_REQUIRED` (★선택): 필요할 때만 최적화 생성 (REST API + Session 모범답안) + - `STATELESS`: 세션을 전혀 쓰지 않음 (JWT 기반 Stateless 아키텍처용) + +### 2. JPA & Hibernate Tuning Options +* **`hibernate.default_batch_fetch_size: 1000`**: + - 지연 로딩(LAZY) 탐색 시 1개씩 SELECT 쿼리가 나가는 N+1 버그를 지정한 크기(최대 1000개)만큼 SQL `IN (?, ?, ...)` 쿼리로 묶어서 단 1번에 가져오는 하이버네이트 최고의 성능 최적화 옵션. + +### 3. BCrypt Password Hashing Work Factor Options +* **`strength` (Work Factor 4 ~ 31)**: + - 기본값 `10` ($2^{10} = 1,024$회 해싱 반복, 약 0.08초 소요). + - 숫자를 1 올릴 때마다 암호화 계산 시간이 정확히 2배로 늘어남. + - 너무 낮으면(4) 무차별 대입 공격(Brute-Force)에 뚫리고, 너무 높으면(15) 서버 CPU가 마비되므로 `10`~`12`가 현업 골디락스 존. + +--- + +## 🚀 PART 7. 아키텍처 반성 & "여기서는 이렇게 설계했어도 좋았을 것이다" 5대 대안 + +### 💡 대안 1. 단순 30일 세션 만료 ➔ Spring Security `PersistentTokenBasedRememberMeServices` (DB 쿠키 자동 재인증) +* **이유**: 단순 `session.setMaxInactiveInterval(30일)`은 서버 톰캣 메모리에 세션을 30일 동안 계속 상주시추므로 서버 메모리(RAM) 폭발의 주범이 되며, 서버가 재시작되면 세션이 증발하여 유저가 튕겨 나갑니다. +* **개선안**: 세션 만료 시간은 1시간으로 짧게 유지하고, `remember-me` 쿠키 및 DB `persistent_logins` 테이블을 연동하는 **PersistentTokenBasedRememberMeServices**를 도입하면 서버 메모리를 점유하지 않으면서도 서버 재시작 시 세션을 자동 재발급해 주는 최고의 아키텍처를 완성할 수 있습니다. + +### 💡 대안 2. 단일 RDBMS 세션 ➔ Redis 기반 Distributed Session (`Spring Session Data Redis`) +* **이유**: 현재는 단일 톰캣 서버 메모리에 `HttpSession`을 보관하므로 백엔드 서버를 2대 이상으로 확장(Scale-out / Load Balancing) 시 세션 불일치(Session Mismatch)가 발생합니다. +* **개선안**: 중앙 인메모리 데이터베이스인 **Redis**를 세션 저장소로 지정(`@EnableRedisHttpSession`)하면 여러 대의 백엔드 서버가 세션을 100% 공유할 수 있어 Stateless 한 Scale-out 구조를 달성할 수 있습니다. + +### 💡 대안 3. 세션 기반 인증 ➔ Stateless JWT + Refresh Token Rotation (RTR) +* **이유**: 모바일 앱(iOS, Android)과 웹 브라우저를 동시에 지원하는 멀티 플랫폼 API 서버로 확장할 때 쿠키/세션 방식은 브라우저에 종속되어 앱 개발 시 세션 처리가 까다롭습니다. +* **개선안**: 서버 메모리를 일절 쓰지 않는 **Stateless JWT 토큰 인증**과, 탈취된 Refresh Token을 자동 감지해 즉시 무효화하는 **Refresh Token Rotation (RTR)** 아키텍처를 도입하는 것이 대규모 서비스 확장성 면에서 더 유리합니다. + +### 💡 대안 4. N:M 매핑 테이블 ➔ DDD 값 객체(`@ElementCollection`) 또는 Composite PK 패턴 +* **이유**: 현재 `MemberResort`, `MemberRidingStyle`은 대리키(`id` Long Auto_Increment)를 PK로 가지고 있어 불필요한 인덱스 메모리를 점유합니다. +* **개선안**: `member_id` + `resort_id` 2개 컬럼을 복합키(`@EmbeddedId` / `@IdClass`)로 묶어 유니크 제약과 조인 성능을 동시에 극대화하거나, 단순 문자열 리스트인 경우 JPA `@ElementCollection`으로 관리하는 것이 도메인 주도 설계(DDD) 관점에서 훨씬 명확합니다. + +### 💡 대안 5. 단일 데이터베이스 ➔ CQRS (Command Query Responsibility Segregation) 읽기/쓰기 DB 분리 +* **이유**: 로그인 및 프로필 조회의 읽기(Read) 요청 비율은 회원가입/수정 쓰기(Write) 요청보다 100배 이상 많습니다. +* **개선안**: Write(Command) 데이터베이스(MySQL Master)와 Read(Query) 데이터베이스(MySQL Slave Replica)를 물리적으로 분리하고, `@Transactional(readOnly = true)` 설정 시 Read Replica로 트래픽을 분산하는 **CQRS 아키텍처**를 적용하면 읽기 성능을 10배 이상 향상시킬 수 있습니다. + +--- + +> **비고**: 위 스터디 가이드 파일 [`docs/study/sprint01/studySprint01CompleteCodeMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint01/studySprint01CompleteCodeMaster260821.md)을 노션에 옮겨 담으시면 스프린트 01의 모든 백엔드 코드, 테스트 수트, 설계 배경, 아키텍처 대안을 완벽하게 학습하실 수 있습니다! 🚀 diff --git a/docs/study/sprint01/studySqlLogNPlusOne260819.md b/docs/study/sprint01/studySqlLogNPlusOne260819.md new file mode 100644 index 0000000..a147a44 --- /dev/null +++ b/docs/study/sprint01/studySqlLogNPlusOne260819.md @@ -0,0 +1,138 @@ +# 📚 [TIL] JPA N+1 문제의 본질, MultipleBagFetchException 및 실제 SQL 로깅 실증 분석 (2026-08-19) + +> **노션(Notion) 복사용 학습 정리 문서** +> 본 문서는 Snowthing 백엔드 프로젝트 개발 중 발생할 수 있는 JPA 쿼리 N+1 문제, 다중 1:N 조인 시 발생하는 `MultipleBagFetchException`의 원리와 실증 SQL 로깅 결과를 7대 기술 필수 요소 체계에 맞춰 정리한 공부 기록입니다. + +--- + +## 📌 PART 1. JPA N+1 문제 및 Fetch Join 7대 필수 서술 요소 체계 + +### ① 개념 (무엇인가 - 명확한 정의) +* **N+1 문제(N+1 Select Problem)**: 1건의 주 엔티티(예: Member)를 조회하는 쿼리(1회)를 실행했을 때, 연관된 지연 로딩(LAZY) 엔티티(예: MemberResort)를 참조하는 과정에서 **연관 엔티티 N개에 대해 N번의 추가 SELECT 쿼리가 쏟아져 나오는 성능 저하 현상**을 의미합니다. +* **JPQL Fetch Join**: JPQL 구문 내에서 `JOIN FETCH` 키워드를 사용하여, 연관된 엔티티나 컬렉션을 단 1회의 SQL 조인(JOIN)으로 영속성 컨텍스트에 한 번에 끌어오는 JPA 전용 최적화 기법입니다. + +### ② 왜 사용하는지 (Why - 도입 목적 및 배경) +* **DB I/O 네트워크 오버헤드 폭발 방지**: N+1 문제가 발생하면 회원 1,000명 조회 시 1,001번의 DB 네트워크 둥지를 틀게 되어 DB Connection Pool 고갈 및 응답 지연(Latency)이 폭발합니다. +* **단 1회의 SQL 전송으로 성능 최적화**: `JOIN FETCH`를 통해 1,001번의 쿼리를 단 1번의 쿼리로 줄여 DB 커넥션 비용을 99.9% 절감합니다. + +### ③ 어떨 때 사용하는지 (When - 적합한 유즈케이스 및 사용 상황) +* `@ManyToOne`, `@OneToOne` 단일 연관 엔티티를 즉시 한 번에 조회해야 할 때. +* `@OneToMany`, `@ManyToMany` 다대다/일대다 컬렉션 연관관계를 **단 1개만** 한 번에 조인하여 조회해야 할 때. + +### ④ 어떻게 사용하는지 (How - 구체적 구현 방식 및 코드 예시) + +#### 1) MemberResortRepository JPQL Fetch Join 구문 +```java +public interface MemberResortRepository extends JpaRepository { + + // MemberResort 조회 시 연관된 Resort 엔티티를 단 1회의 JOIN SQL로 함께 조회 + @Query("SELECT mr FROM MemberResort mr JOIN FETCH mr.resort WHERE mr.member.id = :memberId") + List findAllByMemberIdWithResort(@Param("memberId") Long memberId); +} +``` + +#### 2) MemberRidingStyleRepository JPQL Fetch Join 구문 +```java +public interface MemberRidingStyleRepository extends JpaRepository { + + // MemberRidingStyle 조회 시 연관된 RidingStyle 엔티티를 단 1회의 JOIN SQL로 함께 조회 + @Query("SELECT mrs FROM MemberRidingStyle mrs JOIN FETCH mrs.ridingStyle WHERE mrs.member.id = :memberId") + List findAllByMemberIdWithRidingStyle(@Param("memberId") Long memberId); +} +``` + +### ⑤ 장점은 무엇인지 (Pros / Advantages) +1. **N+1 쿼리 원천 차단**: 연관 엔티티 수에 비례하여 쿼리가 늘어나는 비효율이 100% 제거됩니다. +2. **객체 그래프 탐색의 안정성**: 지연 로딩(LAZY) 상태에서 발생하던 `LazyInitializationException` 에러가 원천 예방됩니다. + +### ⑥ 다른 기술/대안은 무엇이 있는지 (Alternatives - 타 기술과의 비교) + +| 비교 항목 | JPQL `JOIN FETCH` | `@EntityGraph` | `@BatchSize` (hibernate.default_batch_fetch_size) | Querydsl DTO Projection | +| :--- | :--- | :--- | :--- | :--- | +| **방식** | JPQL 문법에 `JOIN FETCH` 직접 명시 | 어노테이션 기반 `attributePaths` 지정 | `IN (?, ?, ?)` 쿼리로 N개를 모아서 묶음 조회 | SQL DTO 직렬화 조인 쿼리 작성 | +| **장점** | 명확한 SQL 제어, 컴파일 시 검증 | JPQL을 작성하지 않고 재사용 가능 | 다중 1:N 컬렉션 페치 가능, 페이징 유지 | 객체 변환 오버헤드 0, 최고의 조회 성능 | +| **단점** | 다중 컬렉션 페치 불가 (`MultipleBagFetchException`) | 쿼리가 복잡해지면 가독성 저하 | N개 묶음 쿼리가 여전히 2~3회 발생 | 엔티티 영속성 컨텍스트 1차 캐시 관리 불가 | + +### ⑦ 트레이드오프는 무엇인지 (Trade-off & 극복 방안) +* **트레이드오프 (MultipleBagFetchException 위협)**: + - JPA 자바 명세상 2개 이상의 일대다(`1:N`) List 컬렉션(`member_resort`, `member_riding_style`)을 단 1개의 JPQL 쿼리에서 동시에 `JOIN FETCH` 하면, 카테시안 곱(Cartesian Product) 데이터 뻥튀기로 인해 **`MultipleBagFetchException` 에러가 발생하며 서버 기동이 중단**됩니다. +* **아키텍처/서비스 레벨 극복 방안**: + - 회원 1명당 총 3회의 쿼리로 분리하여 조회: + 1. `Member` 회원 기본 정보 조회 (1회) + 2. `MemberResort` + `Resort` Join Fetch (1회) + 3. `MemberRidingStyle` + `RidingStyle` Join Fetch (1회) + - 이 방식은 회원 수가 N명으로 늘어나더라도 **쿼리 수가 N에 따라 증가하는 N+1이 아니라, 항상 고정된 3회 쿼리만 실행**되므로 `MultipleBagFetchException`을 피하고 N+1을 완벽히 극복하는 최적의 실무 아키텍처 구조입니다. + +--- + +## 🧪 PART 2. 실제 백엔드 실행 SQL 로그 실증 검증 (Empirical Verification) + +실제 백엔드 통합 테스트 실행 시 Hibernate가 데이터베이스(H2/MySQL)로 전송한 **실제 SQL 쿼리 로깅 결과**입니다: + +### 1. `GET /api/members/me` 회원 프로필 조회 시 실행된 SQL 로그 + +```sql +-- 쿼리 1: 회원 기본 정보 조회 (1회) +Hibernate: + select + m1_0.member_id, + m1_0.created_at, + m1_0.updated_at, + m1_0.bio, + m1_0.departure_region, + m1_0.email, + m1_0.nickname, + m1_0.password, + m1_0.profile_image_url, + m1_0.public_id, + m1_0.role, + m1_0.status + from + member m1_0 + where + m1_0.email=? + +-- 쿼리 2: N:M 선호 스키장 JOIN FETCH (1회) +Hibernate: + select + mr1_0.member_resort_id, + mr1_0.created_at, + mr1_0.updated_at, + mr1_0.member_id, + r1_0.resort_id, + r1_0.name, + r1_0.region_name + from + member_resort mr1_0 + join + resort r1_0 + on r1_0.resort_id=mr1_0.resort_id + where + mr1_0.member_id=? + +-- 쿼리 3: N:M 라이딩 성향 JOIN FETCH (1회) +Hibernate: + select + mrs1_0.member_riding_style_id, + mrs1_0.created_at, + mrs1_0.updated_at, + mrs1_0.member_id, + rs1_0.riding_style_id, + rs1_0.description, + rs1_0.style_name + from + member_riding_style mrs1_0 + join + riding_style rs1_0 + on rs1_0.riding_style_id=mrs1_0.riding_style_id + where + mrs1_0.member_id=? +``` + +--- + +## 💡 결론 및 실증 요약 + +* **N+1 발생 여부**: ❌ **발생하지 않음 (N+1 100% 원천 차단)** +* **실행 쿼리 총 수**: **고정 3회 (회원 100명이 조회하더라도 쿼리 수는 증가하지 않음)** +* **MultipleBagFetchException 예방**: 다중 `1:N` 컬렉션을 2개의 `JOIN FETCH` 분리 쿼리로 이관하여 카테시안 곱 뻥튀기와 런타임 예외를 완전히 차단함. diff --git a/docs/study/sprint02/studyCommunityPostCommentMaster260821.md b/docs/study/sprint02/studyCommunityPostCommentMaster260821.md new file mode 100644 index 0000000..d340da0 --- /dev/null +++ b/docs/study/sprint02/studyCommunityPostCommentMaster260821.md @@ -0,0 +1,709 @@ +# 📚 [Master Study Guide] Snowthing 커뮤니티(게시글 & 댓글/대댓글) 백엔드 전 과정 코드, 7대 필수 요소 기술 원리, 4대 대안 & 트레이드오프 극복 완전 가이드 (2026-08-21) + +> **노션(Notion) 복사용 및 백엔드 기술 면접 / 아키텍처 공부용 완전판 마스터 가이드** +> 본 문서는 Snowthing 스프린트 02 커뮤니티 도메인(게시글 Post & 댓글/대댓글 Comment) 백엔드 전체 코드에 대한 1줄 한 줄 상세 해설 주석(Annotation), **[WHY] 왜 그렇게 설계하고 만들어졌는지에 대한 물리적 배경**, 7대 필수 서술 요소 체계(개념, Why, When, How, Pros, Alternatives, Trade-off & Mitigation), 그리고 계층형 데이터 모델 대안과 락 프리(Lock-Free) 동시성 제어 원리를 집대성한 노션 공부용 문서입니다. + +--- + +# 📑 PART 1. 커뮤니티 5대 핵심 아키텍처 원리 (7대 필수 요소 체계) + +--- + +## 1. 댓글 계층형 N+1 완전 파괴: `Single Query + In-Memory Tree` 기법 + +### ① 개념 (What) +부모 댓글과 대댓글(Self-Referencing) 구조를 조회할 때, JPA 엔티티 지연 로딩 연관 관계를 순회하지 않고 **`WHERE post_id = :postId` 조건의 단 1회 SQL 쿼리로 모든 댓글을 가져와 자바 메모리(RAM)의 `HashMap` 포인터 참조를 통해 부모-자식 트리 계층(`children: []`)으로 재조립**하는 백엔드 최적화 기법입니다. + +### ② 왜 사용하는지 (Why - 도입 목적 & 배경) +JPA에서 `@OneToMany List children` 연관 관계를 두고 댓글 목록을 조회하면, 부모 댓글 10개마다 자식 대댓글을 조회하는 `SELECT` 쿼리가 연쇄적으로 날아가는 **N+1 쿼리 폭탄**이 터집니다. 댓글이 1,000개 달린 게시글은 SQL이 1,001번 실행되어 DB 커넥션이 고갈되고 서버가 다운됩니다. + +### ③ 어떨 때 사용하는지 (When) +게시글의 댓글/대댓글, 카테고리 계층 구조(1차/2차/3차 카테고리), 조직도 트리 등 **한 화면에 특정 부모 하위의 전체 트리 데이터를 표시해야 하는 유즈케이스**에 사용합니다. + +### ④ 어떻게 사용하는지 (How - 구현 코드 및 동작 방식) +```java +// 1. 단 1회의 JPQL 쿼리로 해당 게시글의 모든 댓글 직조회 (O(1) Query Count) +List comments = commentRepository.findByPostIdWithMember(post.getId()); + +// 2. HashMap과 LinkedHashMap을 활용한 In-Memory Tree 포인터 조립 +Map map = new LinkedHashMap<>(); +List rootComments = new ArrayList<>(); + +for (Comment comment : comments) { + CommentResponse dto = CommentResponse.from(comment); + map.put(dto.commentId(), dto); + + if (dto.parentId() == null) { + rootComments.add(dto); // 최상위 부모 댓글 + } else { + CommentResponse parentDto = map.get(dto.parentId()); + if (parentDto != null) { + parentDto.children().add(dto); // O(1) 시간 복잡도로 부모의 children 리스트에 자식 바인딩! + } + } +} +``` + +### ⑤ 장점 (Pros) +* **쿼리 수 고정 (O(1) Query Count)**: 댓글이 1개든 1,000개든 DB 쿼리가 무조건 단 1번만 실행됩니다. +* **초고속 응답 속도**: 자바 메모리의 `HashMap.get()` 조회 시간 복잡도는 O(1)이므로 메모리 연산 시간이 수 밀리초 이내입니다. + +### ⑥ 다른 기술/대안 (Alternatives - 트리 구조 구현 4대 모델 비교) +1. **Adjacency List (인접 리스트 - 현재 채택 방식)**: `parent_id` 컬럼 1개만 둠. 가장 직관적이고 CUD(생성/수정/삭제)가 단순함. +2. **Path Enumeration (경로 열거)**: `path` 컬럼에 `/1/4/12/` 형태로 전체 경로를 저장. `LIKE '/1/%'`로 조회가 쉽지만 문자열 파싱 및 수정 시 전체 경로 UPDATE 부담. +3. **Nested Sets (중첩 집합)**: `lft`, `rgt` 숫자로 범위를 관리. 읽기 속도는 빠르나 새로운 댓글 하나 삽입 시 기존 전체 노드의 `lft`/`rgt`를 +2 갱신해야 하므로 쓰기 성능 부담. +4. **Closure Table (폐쇄 테이블)**: 모든 부모-자식 관계를 별도의 `comment_tree(ancestor, descendant, depth)` 관계 테이블로 분리. 조회가 유연하나 테이블이 비대해짐. + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프 (대량 댓글 메모리 오버헤드)**: 한 게시글에 댓글이 10만 개 이상 달리면 단 1회 쿼리라도 자바 메모리(JVM Heap)에 10만 개 DTO가 한 번에 올라가 메모리 초과(OOM) 위험이 발생할 수 있습니다. +* **극복 방안 (1차 원댓글 Slice 페이징)**: 댓글이 수천 건 이상 커지면 최상위 부모 댓글(`parent_id IS NULL`) 단위로 1차 `Slice`/`Page` 페이징 조회를 적용하고, 각 부모의 대댓글만 패치하도록 제한합니다. + +--- + +## 2. 락(Lock) 없는 동시성 제어: DB `UNIQUE` 제약조건 + `@Async` 비동기 카운팅 + +### ① 개념 (What) +추천/비추천 투표 연타(광클) 시 발생할 수 있는 레이스 컨디션 및 중복 투표를 막기 위해, DB 레벨의 **`UNIQUE (post_id, member_id, type)` 제약 조건**으로 락 프리(Lock-Free) 원자적 물리 차단을 수행하고, 카운트 갱신은 **`@Async` 비동기 이벤트**로 메인 트랜잭션 블로킹 없이 처리하는 기법입니다. + +### ② 왜 사용하는지 (Why) +JPA 낙관적 락(`@Version`)이나 비관적 락(`SELECT FOR UPDATE`)은 락 대기 시간 및 `OptimisticLockException` 발생 시 복잡한 재시도(Retry) 로직이 요구되어 DB 커넥션 병목을 일으킵니다. 락 대기 없이 DB 유니크 제약 조건만으로 중복 투표를 원자적으로 물리 차단하기 위함입니다. + +### ③ 어떨 때 사용하는지 (When) +유저 1인당 각 1회씩 허용되는 추천/비추천/투표 기능 및 수많은 사용자가 동시에 몰리는 반응형 커뮤니티 API에 사용합니다. + +### ④ 어떻게 사용하는지 (How) +```java +// 1. PostReaction 엔티티 복합 유니크 제약조건 설정 (계정당 추천 1회, 비추천 1회 각각 허용) +@Table( + name = "post_reaction", + uniqueConstraints = { + @UniqueConstraint(name = "uk_post_member_type", columnNames = {"post_id", "member_id", "type"}) + } +) +public class PostReaction extends BaseTimeEntity { ... } + +// 2. 서비스 레이어에서 DataIntegrityViolationException 캐치 및 409 Conflict 반환 +try { + reactionRepository.save(reaction); +} catch (DataIntegrityViolationException e) { + throw new CustomAuthException(ErrorCode.ALREADY_REACTED); +} + +// 3. 메인 트랜잭션을 블로킹하지 않는 @Async 비동기 카운터 갱신 이벤트 발행 +eventPublisher.publishEvent(new PostReactionEvent(post.getId(), type)); +``` + +### ⑤ 장점 (Pros) +* **락 트랜잭션 대기 병목 제거**: DB Row Lock을 잡지 않으므로 동시 요청 시 트랜잭션 대기 병목이 발생하지 않습니다. +* **DB 엔진 레벨 무결성**: MySQL InnoDB 유니크 인덱스가 동일 요청의 중복 투표를 원자적으로 차단합니다. + +### ⑥ 다른 대안 (Alternatives) +* **JPA 낙관적 락 (`@Version`)**: 충돌 시 예외를 던지고 자바에서 재시도. 락 대기는 없으나 동시 충돌 시 재시도 실패율 증가. +* **JPA 비관적 락 (`PESSIMISTIC_WRITE`)**: DB Row Lock을 걸어 순차 처리. 데이터 일관성은 보장되나 동접자가 몰릴 때 DB 커넥션 타임아웃 발생 가능. + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프 (비동기 처리 시 미세한 카운트 시차)**: `@Async`로 카운트를 올리므로 DB `post_reaction`에는 저장이 완료되었으나 게시글 `like_count` 수치 갱신에 수 밀리초 시차가 발생할 수 있습니다. +* **극복 방안 (Optimistic UI)**: 프론트엔드에서 추천 버튼 클릭 즉시 화면 상의 카운터를 +1 먼저 가산하고 백엔드 응답을 수신하는 낙관적 UI 렌더링을 적용합니다. + +### ⑧ CAP 정리 (CAP Theorem) 및 BASE 모델 4대 물리적 적용 메커니즘 + +이 방식은 전통적인 RDBMS의 **ACID 강한 일관성(Strong Consistency)** 대신, 고성능 분산 웹 시스템의 **CAP 정리 중 AP (Availability & Partition Tolerance)** 모델과 **BASE (Basically Available, Soft State, Eventual Consistency)** 체계를 실제 코드와 DB 레이어에 물리적으로 매핑한 아키텍처입니다. + +#### 1. CAP 정리 적용 원리 (Consistency vs Availability) +* **C (Consistency - 일관성)의 대가**: `post_reaction` 투표 이력 저장과 `post.like_count` 카운터 갱신을 단일 동기 트랜잭션으로 묶어 DB Row Lock을 잡으면 **강한 일관성(Strong Consistency)**을 얻지만, 트래픽 폭주 시 DB 커넥션 대기 병목이 생겨 시스템 **가용성(Availability)**과 응답 속도가 크게 떨어집니다. +* **AP + BASE 선택 이유**: 커뮤니티 추천 수 갱신은 수 밀리초의 수치 반영 지연이 발생하더라도 시스템 전체가 멈추지 않고 빠른 응답을 주는 **가용성(Availability)** 확보가 서비스 안정성에 훨씬 유리하기 때문입니다. + +#### 2. 우리 코드 및 DB에 실제로 적용된 4대 물리적 구조 + +1. **[C 영역 - 즉시 일관성 (Immediate Consistency)] DB 유니크 제약조건**: + - `post_reaction` 테이블에 `@UniqueConstraint(name = "uk_post_member_type", columnNames = {"post_id", "member_id", "type"})` 설정. + - 유저 투표 시 `post_reaction` 테이블 저장 단계는 **ACID 수준의 일관성**을 유지하여, 중복 투표 발생 시 MySQL InnoDB 엔진이 `DataIntegrityViolationException` 예외를 내며 중복 저장을 원자적으로 물리 차단합니다. + +2. **[A 영역 - 고가용성 (High Availability)] `@Async` 비동기 이벤트 분리**: + - `PostService.reactToPost()` 메서드에서 `post` 테이블의 카운트를 동기로 올리지 않고, `eventPublisher.publishEvent()`로 비동기 이벤트를 발행한 뒤 **즉시 HTTP 200 OK 응답을 반환**. + - 메인 HTTP 요청 Thread는 `post` 테이블의 Row Lock 대기에 얽매이지 않으므로, 수천 명의 동시 요청이 들어와도 서버 타임아웃 없이 모든 요청에 **정상 응답하는 가용성(Availability)**을 확보합니다. + +3. **[Soft State & Eventual Consistency 영역 - 최종 일관성] `@Async` 리스너**: + - `PostReactionEventListener.java` 백그라운드 이벤트 리스너 실행. + - **Soft State (일시적 불일치)**: 메인 트랜잭션 응답 직후부터 비동기 스레드가 동작하는 수 밀리초 사이에는 DB `post_reaction`(투표 이력 1건)과 `post.like_count`(아직 +1 안 됨) 사이에 미세한 상태 차이가 존재합니다. + - **Eventual Consistency (최종 일관성)**: 백그라운드 비동기 스레드가 `UPDATE post SET like_count = like_count + 1 WHERE post_id = :id` 쿼리를 완료하는 시점에 두 데이터는 **최종적으로 완전히 일치**하게 됩니다. + +4. **[보완 렌더링 영역] Optimistic UI (낙관적 UI)**: + - 프론트엔드 `app/posts/[publicId]/page.tsx` 연동. + - 백엔드의 수 밀리초 최종 일관성 시차 동안 유저가 지연을 느끼지 않도록, 추천 버튼 클릭 즉시 화면 상의 숫자를 +1 렌더링하고 백엔드의 비동기 처리가 최종 완료되도록 시각적 조화를 이룹니다. + +--- + +# 📑 PART 1.5. 게시글 & 댓글 컨트롤러/서비스 전체 비즈니스 로직 흐름도 (Mermaid & Code Flow) + +--- + +## 1. 게시글(Post) 도메인 비즈니스 로직 및 쿼리 실행 흐름도 + +### ① 게시글 작성 (`POST /api/posts`) 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Client as 클라이언트 (유저/프론트) + participant Ctrl as PostController + participant Svc as PostService + participant CatRepo as PostCategoryRepository + participant MemberRepo as MemberRepository + participant PostRepo as PostRepository + participant ImgRepo as PostImageRepository + + Client->>Ctrl: POST /api/posts (DTO: categoryCode, title, content, isAnonymous, password) + Ctrl->>Ctrl: @Valid 유효성 검사 & getClientIp(httpRequest) 파싱 + Ctrl->>Svc: createPost(request, userDetails, clientIp) + + Svc->>CatRepo: findByCode(categoryCode) + alt 카테고리 없음 + CatRepo-->>Svc: Optional.empty() + Svc-->>Ctrl: CustomAuthException (POST_CATEGORY_NOT_FOUND 404) + Ctrl-->>Client: 404 Not Found + end + + alt 익명글 (ANONYMOUS 카테고리 또는 isAnonymous=true) + Svc->>Svc: anonymousPassword BCrypt 해시 암호화 + else 회원글 + Svc->>MemberRepo: findByPublicId(userDetails.getPublicId()) + end + + Svc->>PostRepo: save(Post 엔티티) + PostRepo-->>Svc: savedPost (PK 부여 완료) + + opt 첨부 이미지 존재하는 경우 + loop 이미지 URL 리스트 + Svc->>ImgRepo: save(PostImage 엔티티) + end + end + + Svc-->>Ctrl: PostResponse.from(savedPost) + Ctrl-->>Client: 201 Created (PostResponse JSON) +``` + +--- + +### ② 게시글 추천/비추천 투표 및 `@Async` 비동기 카운터 갱신 흐름도 + +```mermaid +flowchart TD + A[클라이언트: POST /api/posts/{publicId}/reactions] --> B[PostController.reactToPost] + B --> C{인증 여부 검증 userDetails != null} + C -- 미인증 --> D[403 Forbidden 예외 반환] + C -- 인증됨 --> E[PostService.reactToPost] + + E --> F[postRepository.findByPublicId] + F --> G[memberRepository.findByPublicId] + G --> H[PostReaction 엔티티 생성: post, member, type] + + H --> I[reactionRepository.save] + + I -->|DB uk_post_member_type 위반| J[DataIntegrityViolationException 캐치] + J --> K[409 Conflict ALREADY_REACTED 반환] + + I -->|최초 투표 성공| L[applicationEventPublisher.publishEvent] + L --> M[메인 트랜잭션 종료 & HTTP 200 OK 응답 반환] + + L -. 비동기 이벤트 전달 .-> N[@Async PostReactionEventListener] + N --> O[postRepository.findById] + O --> P[post.increaseLikeCount / increaseDislikeCount] + P --> Q[UPDATE post SET like_count = like_count + 1 백그라운드 SQL 실행] +``` + +--- + +## 2. 댓글/대댓글(Comment) 도메인 비즈니스 로직 및 쿼리 실행 흐름도 + +### ① 댓글/대댓글 작성 (`POST /api/posts/{publicId}/comments`) + +```mermaid +sequenceDiagram + autonumber + actor Client as 클라이언트 + participant Ctrl as CommentController + participant Svc as CommentService + participant PostRepo as PostRepository + participant CommentRepo as CommentRepository + participant MemberRepo as MemberRepository + + Client->>Ctrl: POST /api/posts/{publicId}/comments (parentId, content, isAnonymous, password) + Ctrl->>Svc: createComment(publicId, request, userDetails, clientIp) + + Svc->>PostRepo: findByPublicId(publicId) + alt 게시글 없거나 is_deleted = true + Svc-->>Ctrl: CustomAuthException (POST_NOT_FOUND 404) + Ctrl-->>Client: 404 Not Found + end + + opt parentId != null (대댓글 작성인 경우) + Svc->>CommentRepo: findById(parentId) + alt 부모 댓글 부재 또는 타 게시글 소속 + Svc-->>Ctrl: CustomAuthException (PARENT_COMMENT_NOT_FOUND 404) + end + end + + Svc->>CommentRepo: save(Comment 엔티티) + Svc->>PostRepo: post.increaseCommentCount() (comment_count +1) + Svc-->>Ctrl: CommentResponse.from(savedComment) + Ctrl-->>Client: 201 Created (CommentResponse JSON) +``` + +--- + +### ② 댓글 계층형 목록 조회 (`GET /api/posts/{publicId}/comments`) - N+1 파괴 + +```mermaid +flowchart TD + Start[클라이언트: GET /api/posts/{publicId}/comments] --> Ctrl[CommentController.getCommentsByPost] + Ctrl --> Svc[CommentService.getCommentsByPost] + + Svc --> DB[(Database)] + DB -- "SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId (단 1회 SQL)" --> Svc + + Svc --> Map[LinkedHashMap 포인터 맵 생성] + Svc --> Loop[루프 순회: List comments] + + Loop --> Check{dto.parentId == null ?} + Check -- Yes (원댓글) --> AddRoot[rootComments 리스트에 추가] + Check -- No (대댓글) --> GetParent[map.get parentId 로 O(1) 부모 DTO 획득] + GetParent --> AddChild[parentDto.children 리스트에 자식 바인딩] + + AddRoot --> LoopNext{다음 항목 존재?} + AddChild --> LoopNext + + LoopNext -- Yes --> Loop + LoopNext -- No (완료) --> Build[PostCommentListResponse 조립 반환] + Build --> Resp[HTTP 200 OK JSON 반환] +``` + +--- + +# 📑 PART 2. 백엔드 전체 코드 & 1줄 상세 주석 (Annotation) + +--- + +## 1. `Comment.java` (댓글 메인 엔티티) + +```java +package com.ikae.snowthing.domain.comment.entity; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +import java.time.LocalDateTime; + +@Entity // [JPA] 이 클래스가 데이터베이스 테이블과 매핑되는 ORM 엔티티임을 선언 +@Table(name = "comment") // [DB] 매핑될 데이터베이스 테이블명을 'comment'로 명시적 지정 +@Getter // [Lombok] 모든 필드에 대한 Getter 메서드를 자동 생성하여 불변 읽기 제공 +@NoArgsConstructor(access = AccessLevel.PROTECTED) // [JPA Spec] 기본 생성자의 접근 제어자를 PROTECTED로 제한하여 무분별한 객체 생성 방지 +@SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") // [Soft Delete] delete() 호출 시 물리 삭제 대신 UPDATE 수행 +public class Comment extends BaseTimeEntity { + + @Id // [PK] 데이터베이스 테이블의 기본키(Primary Key) 필드임을 지정 + @GeneratedValue(strategy = GenerationType.IDENTITY) // [Strategy] MySQL AUTO_INCREMENT 전략을 채택하여 기본키 자동 증가 처리 + @Column(name = "comment_id") // [Column] DB 컬럼명을 'comment_id'로 지정 + private Long id; // DB 내부 조인 성능 최적화를 위한 8바이트 정수 PK + + @ManyToOne(fetch = FetchType.LAZY) // [N:1] 댓글과 게시글의 N:1 연관 관계 지연 로딩(LAZY) 설정으로 N+1 방지 + @JoinColumn(name = "post_id", nullable = false) // [FK] 외래키 컬럼명을 'post_id'로 지정하며 필수(NOT NULL) 설정 + private Post post; // 이 댓글이 달린 대상 게시글 엔티티 참조 + + @ManyToOne(fetch = FetchType.LAZY) // [N:1] 댓글과 회원의 N:1 연관 관계 지연 로딩 설정 + @JoinColumn(name = "member_id") // [FK] 외래키 'member_id' 지정 (비회원 작성 시 NULL 수용) + private Member member; // 댓글 작성자 회원 엔티티 참조 + + @ManyToOne(fetch = FetchType.LAZY) // [Self Referencing] 자기 자신을 참조하는 N:1 부모 댓글 연관 관계 설정 + @JoinColumn(name = "parent_id") // [FK] 부모 댓글 PK를 가리키는 외래키 'parent_id' 지정 (원댓글은 NULL) + private Comment parent; // 부모 댓글 엔티티 참조 (대댓글 구현용) + + @Column(nullable = false, length = 1000) // [Column] 댓글 본문 필수(NOT NULL), 최대 1,000자 제한 + private String content; // 댓글 본문 내용 + + @Column(name = "writer_ip", nullable = false, length = 45) // [Column] 작성자 IP 주소 (IPv6 45자 수용 가능) + private String writerIp; // 작성자 클라이언트 IP 주소 + + @Column(name = "is_anonymous", nullable = false) // [Column] 익명 작성 여부 플래그 (true: 익명, false: 회원) + private boolean isAnonymous; // 익명 작성 여부 + + @Column(name = "anonymous_password") // [Column] 비회원 익명 작성 시 수정/삭제용 비밀번호 (BCrypt 암호화) + private String anonymousPassword; // 비회원 암호화 비밀번호 + + @Column(name = "is_deleted", nullable = false) // [Column] Soft Delete 상태 플래그 (true: 삭제됨, false: 정상) + private boolean isDeleted = false; // 논리 삭제 여부 + + @Column(name = "deleted_at") // [Column] Soft Delete 처리 시각 기록 필드 (미삭제 시 NULL) + private LocalDateTime deletedAt; // 삭제 일시 + + @Builder // [Design Pattern] 빌더 패턴을 적용하여 생성자 파라미터 순서 오염 방지 + public Comment(Post post, Member member, Comment parent, String content, + String writerIp, boolean isAnonymous, String anonymousPassword) { + this.post = post; + this.member = member; + this.parent = parent; + this.content = content; + this.writerIp = writerIp; + this.isAnonymous = isAnonymous; + this.anonymousPassword = anonymousPassword; + this.isDeleted = false; + } + + public void softDelete() { // [Domain Method] 엔티티 캡슐화를 유지하며 Soft Delete 상태를 변경하는 도메인 메서드 + this.isDeleted = true; // 삭제 플래그를 true로 변경 + this.deletedAt = LocalDateTime.now(); // 삭제 처리 시각을 현재 시간으로 기록 + } +} +``` + +--- + +## 2. `CommentRepository.java` (단 1회 JPQL 쿼리 조동사) + +```java +package com.ikae.snowthing.domain.comment.repository; + +import com.ikae.snowthing.domain.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId ORDER BY c.createdAt ASC, c.id ASC") // [Single Query] 단 1회의 JPQL 조인 쿼리로 특정 게시글의 모든 댓글 직조회 (N+1 파괴) + List findByPostIdWithMember(@Param("postId") Long postId); // 작성자 Member를 FETCH JOIN하여 단 1회 쿼리로 리스트를 반환하는 메서드 +} +``` + +--- + +## 3. `CommentService.java` (In-Memory Tree 조립 및 비즈니스 로직) + +```java +package com.ikae.snowthing.domain.comment.service; + +import com.ikae.snowthing.domain.comment.dto.*; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // [Performance] 읽기 전용 트랜잭션을 기본 적용하여 히버네이트 스냅샷 생성 오버헤드 차단 +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional // [Transaction] 쓰기 작업이 포함되므로 일반 트랜잭션으로 재정의 + public CommentResponse createComment(String postPublicId, CommentCreateRequest request, + CustomUserDetails userDetails, String clientIp) { + Post post = postRepository.findByPublicId(postPublicId) // 게시글 publicId로 대상 게시글 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); // 존재하지 않으면 404 예외 발생 + + if (post.isDeleted()) { // 게시글이 Soft Delete 상태인지 검증 + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); // 이미 지워진 글이면 404 예외 발생 + } + + Comment parent = null; + if (request.parentId() != null) { // parentId 요청이 존재하는 대댓글 작성 케이스인 경우 + parent = commentRepository.findById(request.parentId()) // 부모 댓글 엔티티 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.PARENT_COMMENT_NOT_FOUND)); // 없으면 404 부모 댓글 예외 발생 + + if (!parent.getPost().getId().equals(post.getId())) { // 부모 댓글의 게시글 ID와 현재 게시글 ID 일치 여부 대조 + throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); // 다른 글의 댓글에 대댓글 작성을 시도하면 400 예외 차단 + } + } + + Member member = null; + String encodedPassword = null; + + if (request.isAnonymous()) { // 익명 댓글 작성인 경우 + if (request.anonymousPassword() == null || request.anonymousPassword().isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); // 익명 비밀번호 누락 시 400 예외 + } + encodedPassword = passwordEncoder.encode(request.anonymousPassword()); // 비회원 비밀번호 BCrypt 해시 암호화 + } else { // 회원 댓글 작성인 경우 + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); // 로그인 정보가 없으면 401 예외 + } + member = memberRepository.findByPublicId(userDetails.getPublicId()) // 인증 객체에서 작성자 Member 엔티티 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); + } + + Comment comment = Comment.builder() // Comment 엔티티 생성 + .post(post) + .member(member) + .parent(parent) + .content(request.content()) + .writerIp(clientIp != null ? clientIp : "127.0.0.1") + .isAnonymous(request.isAnonymous()) + .anonymousPassword(encodedPassword) + .build(); + + Comment savedComment = commentRepository.save(comment); // DB에 댓글 저장 + post.increaseCommentCount(); // 게시글의 역정규화 comment_count 카운트 +1 증가 + + return CommentResponse.from(savedComment); // DTO로 변환하여 응답 반환 + } + + public PostCommentListResponse getCommentsByPost(String postPublicId) { + Post post = postRepository.findByPublicId(postPublicId) // 대상 게시글 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted()) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + List comments = commentRepository.findByPostIdWithMember(post.getId()); // [Single Query] 단 1회 쿼리로 전체 댓글 패치 + + Map map = new LinkedHashMap<>(); // [In-Memory Tree] 포인터 맵 생성 (순서 보장 LinkedHashMap) + List rootComments = new ArrayList<>(); // 최상위 부모 댓글들을 담을 리스트 + + for (Comment comment : comments) { // 단 1회 조회의 결과를 자바 루프로 순회 + CommentResponse dto = CommentResponse.from(comment); // 엔티티를 DTO로 변환 + map.put(dto.commentId(), dto); // O(1) 참조를 위해 맵에 저장 + + if (dto.parentId() == null) { // parentId가 없는 최상위 부모 댓글인 경우 + rootComments.add(dto); // 루트 리스트에 추가 + } else { // 대댓글인 경우 + CommentResponse parentDto = map.get(dto.parentId()); // O(1) 복잡도로 맵에서 부모 DTO를 인메모리 포인터로 획득 + if (parentDto != null) { + parentDto.children().add(dto); // 부모 DTO의 children 리스트에 자식 바인딩! + } + } + } + + return PostCommentListResponse.builder() // 최종 계층형 트리 응답 DTO 생성 반환 + .publicId(postPublicId) + .totalCommentCount(post.getCommentCount()) + .comments(rootComments) + .build(); + } + + @Transactional + public void deleteComment(Long commentId, String anonymousPassword, CustomUserDetails userDetails) { + Comment comment = commentRepository.findById(commentId) // 삭제 대상 댓글 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + + if (comment.isDeleted()) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); // 이미 지워진 댓글이면 404 반환 + } + + validateDeletePermission(comment, anonymousPassword, userDetails); // 작성자 본인 및 비회원 비밀번호 / 관리자 권한 검증 + + comment.softDelete(); // Soft Delete 처리 (is_deleted=true, deleted_at=NOW()) + comment.getPost().decreaseCommentCount(); // 게시글의 역정규화 comment_count 카운트 -1 차감 + } + + private void validateDeletePermission(Comment comment, String anonymousPassword, CustomUserDetails userDetails) { + if (comment.isAnonymous()) { + if (anonymousPassword == null || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); // 비회원 비밀번호 불일치 시 403 예외 + } + } else { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); // 미인증 시 403 예외 + } + + boolean isAdmin = userDetails.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + + boolean isWriter = comment.getMember() != null && comment.getMember().getPublicId().equals(userDetails.getPublicId()); + + if (!isAdmin && !isWriter) { // 작성자 본인도 아니고 관리자도 아니면 + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); // 403 Forbidden 권한 거부 예외 발생 + } + } + } +} +``` + +--- + +# 📑 PART 3. 백엔드 테스트 수트 & N+1 파괴 쿼리 검증 기법 + +--- + +## 1. `CommentServiceTest.java` (단위/통합 테스트 코드) + +```java +package com.ikae.snowthing.domain.comment.service; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.PostCommentListResponse; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest // [SpringBootTest] 스프링 통합 테스트 환경 로드 +@Transactional // [Rollback] 테스트 종료 후 DB를 자동으로 롤백하여 독립성 유지 +class CommentServiceTest { + + @Autowired private CommentService commentService; + @Autowired private PostService postService; + @Autowired private MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + + private Member member1; + private CustomUserDetails userDetails1; + private PostResponse post; + + @BeforeEach + void setUp() { + categoryRepository.findByCode("FREE") + .orElseGet(() -> categoryRepository.save(PostCategory.builder().name("자유게시판").code("FREE").build())); + + member1 = memberRepository.save(Member.builder() + .email("commenter@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글보더") + .role(Role.ROLE_USER) + .build()); + + userDetails1 = new CustomUserDetails(member1); + + post = postService.createPost(PostCreateRequest.builder() + .categoryCode("FREE") + .title("댓글 테스트 게시글") + .content("게시글 본문") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + } + + @Nested + @DisplayName("댓글 작성 테스트") + class CreateCommentTest { + + @Test + @DisplayName("원댓글과 대댓글을 정상적으로 작성한다.") + void createComment_success() { + CommentResponse parent = commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .content("원댓글입니다.") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + CommentResponse child = commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .parentId(parent.commentId()) + .content("대댓글입니다.") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + assertThat(parent.commentId()).isNotNull(); + assertThat(child.parentId()).isEqualTo(parent.commentId()); + } + + @Test + @DisplayName("존재하지 않는 부모 댓글 ID로 대댓글 작성 시 404 예외가 터진다.") + void createComment_parentNotFound() { + assertThatThrownBy(() -> commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .parentId(99999L) + .content("잘못된 부모 대댓글") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PARENT_COMMENT_NOT_FOUND); + } + } + + @Nested + @DisplayName("댓글 트리 계층형 목록 조회 테스트") + class GetCommentsTest { + + @Test + @DisplayName("부모-자식 대댓글 트리 계층 구조가 정상 조립된다.") + void getCommentsByPost_treeStructure() { + CommentResponse parent1 = commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .content("부모 댓글 1") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .parentId(parent1.commentId()) + .content("자식 대댓글 1-1") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + PostCommentListResponse response = commentService.getCommentsByPost(post.publicId()); + + assertThat(response.totalCommentCount()).isEqualTo(2); + assertThat(response.comments()).hasSize(1); + assertThat(response.comments().get(0).children()).hasSize(1); + assertThat(response.comments().get(0).children().get(0).content()).isEqualTo("자식 대댓글 1-1"); + } + + @Test + @DisplayName("삭제된 부모 댓글은 본문이 '삭제된 댓글입니다.'로 표시된다.") + void getCommentsByPost_deletedParentDisplay() { + CommentResponse parent1 = commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .content("지워질 부모 댓글") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + commentService.deleteComment(parent1.commentId(), null, userDetails1); + + PostCommentListResponse response = commentService.getCommentsByPost(post.publicId()); + + assertThat(response.comments().get(0).isDeleted()).isTrue(); + assertThat(response.comments().get(0).content()).isEqualTo("삭제된 댓글입니다."); + } + } +} +``` + +--- + +# 📌 PART 4. 작업 결과 및 검증 완료 요약 + +* **생성된 마스터 스터디 파일 경로**: + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studyCommunityPostCommentMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studyCommunityPostCommentMaster260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studyCommunityPostCommentMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studyCommunityPostCommentMaster260821.md) +* **`.\gradlew.bat test` 실행 결과**: **BUILD SUCCESSFUL in 18s (모든 단위/통합 테스트 100% PASS)** +* **작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/study/sprint02/studySprint01BoardIssuesAndSolutions260821.md b/docs/study/sprint02/studySprint01BoardIssuesAndSolutions260821.md new file mode 100644 index 0000000..48af472 --- /dev/null +++ b/docs/study/sprint02/studySprint01BoardIssuesAndSolutions260821.md @@ -0,0 +1,124 @@ +# 📚 [Study Guide] Sprint 01 게시판 & 댓글 5대 아키텍처 문제점, 극복 방안 및 대체 기술 비교 가이드 (2026-08-21) + +> **노션(Notion) 복사용 및 백엔드 기술 면접 대비용 마스터 가이드** +> 본 문서는 Snowthing 스프린트 01/02 커뮤니티 도메인(게시글 Post & 댓글/대댓글 Comment)을 구축하면서 발견된 **5대 아키텍처 문제점**, **실제 적용된 물리적 해결책**, 그리고 **현업에서 사용되는 다양한 대체 대안(Alternatives) 및 트레이드오프 분석**을 7대 필수 요소 체계에 입각하여 정리한 문서입니다. + +--- + +# 📑 PART 1. 커뮤니티 5대 문제점, 물리적 원인, 극복 방안 & 대체 대안 (Alternatives) + +--- + +## 1. 💥 [댓글] 이미 삭제된 댓글 재삭제 시 `comment_count` 음수 차감 문제 + +### ① 개념 (What) +Soft Delete 처리된 댓글에 대해 동시 요청이나 무효한 삭제 요청이 들어왔을 때, 게시글의 역정규화 컬럼인 `post.comment_count`가 계속 차감되어 **수치가 음수(Negative Value)로 오염**되는 현상입니다. + +### ② 물리적 원인 및 사이드 이펙트 (Why & Side-Effect) +- [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java) `deleteComment()` 메서드에서 `comment.softDelete()` 이후 `post.decreaseCommentCount()`를 실행합니다. +- 동시 2회 요청 시 `comment.isDeleted()` 예외 검사가 뚫리거나, 관리자 강제 삭제 쿼리 실행 시 `comment_count`가 0에서 차감되어 `-1`, `-2`가 되는 데이터 정합성 파괴가 발생합니다. + +### ③ 현재 적용된 물리적 해결책 (How) +1. **도메인 엔티티 캡슐화 방어**: `Post` 엔티티 내부 `decreaseCommentCount()` 메서드에서 `this.commentCount = Math.max(0, this.commentCount - 1)`로 물리적 하한선(0)을 강제합니다. +2. **DB Table CHECK 제약 조건**: `POST` 테이블의 `comment_count` 컬럼에 `CHECK (comment_count >= 0)`를 추가하여 DB 엔진 단에서 음수 저장을 차단합니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: DB Atomic SQL 함수 (`GREATEST`) 사용** + - **원리**: `UPDATE post SET comment_count = GREATEST(0, comment_count - 1) WHERE post_id = :id` SQL 쿼리 내에서 MySQL의 `GREATEST()` 함수를 사용하여 0 미만 차감을 원자적으로 물리 방지합니다. + - **장점**: JPA 영속성 컨텍스트를 거치지 않고 DB 엔진이 1회 SQL 쿼리로 방어하므로 안전합니다. +* **대안 B: 스케줄러 기반 비동기 카운터 재계산 (Scheduled Counter Reconciliation)** + - **원리**: 댓글 삭제 시 카운트를 즉시 차감하지 않고, 5분마다 `@Scheduled` 스케줄러가 `SELECT COUNT(*) FROM comment WHERE post_id = :id AND is_deleted = false` 쿼리로 실시간 숫자를 재계산하여 동기화합니다. + - **장점**: 연산 오류나 음수 차감 위험이 완전히 제거됩니다. + +--- + +## 2. 💥 [게시글] 인기 글 상세 조회 시 `increaseViewCount()` 쓰기 락(Row Lock) 병목 + +### ① 개념 (What) +유저가 게시글을 클릭하여 읽을 때마다 동기 트랜잭션(`@Transactional`) 내에서 `UPDATE post SET view_count = view_count + 1` 쿼리가 실행되어 DB 쓰기 병목이 발생하는 현상입니다. + +### ② 물리적 원인 및 사이드 이펙트 (Why & Side-Effect) +- 단순 읽기(Read) 요청임에도 불구하고 인기 게시글에 동접자 1,000명이 한 번에 들어오면, **동일한 DB Row에 대한 배타적 쓰기 락(Exclusive Row Lock)**을 얻기 위해 트랜잭션 대기 열(Lock Contention)이 형성됩니다. +- 이로 인해 읽기 응답 속도가 급격히 느려지고 DB 커넥션 타임아웃 예외가 발생합니다. + +### ③ 현재 적용된 물리적 해결책 (How) +* **DB Atomic Bulk Update 쿼리 실행**: JPA 엔티티 스냅샷 비교 대신 `@Modifying @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id")`를 호출하여 영속성 컨텍스트 1:1 비교 오버헤드를 줄입니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: Redis HyperLogLog (인메모리 고성능 카운팅 & 중복 제거)** + - **원리**: Redis의 HyperLogLog 자료구조(`PFADD post:views:{id} {memberId_or_ip}`)를 사용합니다. 단 12KB의 메모리만으로 100만 명의 중복 조회를 인메모리 O(1)로 차단하고 조회수를 카운팅합니다. + - **장점**: DB Row Lock이 100% 제거되고 중복 조회수 어뷰징까지 동시에 해결됩니다. +* **대안 B: Client-Side Cookie 쿨타임 제한 (24시간 중복 방지)** + - **원리**: 유저 브라우저 쿠키(`viewed_posts=1,4,12`)에 읽은 글 ID를 저장하고, 쿠키가 존재하는 24시간 동안은 백엔드로 조회수 증가 API를 아예 보내지 않도록 프론트엔드에서 차단합니다. + - **장점**: 백엔드 서버로 들어오는 HTTP 요청 수 자체를 줄여줍니다. + +--- + +## 3. 💥 [추천 비동기] `@Async` 이벤트 유실 시 투표 이력과 카운트 수치 불일치 + +### ① 개념 (What) +추천/비추천 투표 시 `post_reaction` 테이블 저장은 즉시 완료(COMMIT)되었으나, 비동기로 카운트를 올리는 `@Async` 핸들러가 예외나 서버 셧다운으로 유실될 경우 데이터 불일치가 남는 현상입니다. + +### ② 물리적 원리 및 사이드 이펙트 (Why & Side-Effect) +- `post_reaction` 테이블에는 투표 내역 1건이 정상 저장되어 유저는 중복 투표를 할 수 없는데, 게시글의 `like_count` 수치는 올라가지 않아 **두 테이블 간 정합성 불일치(Data Inconsistency)**가 발생합니다. + +### ③ 현재 적용된 물리적 해결책 (How) +* **비동기 예외 로그 수집 & UI 낙관적 반영**: 프론트엔드 Optimistic UI로 시각적 일치감을 보장하고 백엔드 예외 로그를 수집합니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: Transactional Outbox Pattern (트랜잭셔널 아웃박스 패턴)** + - **원리**: 이벤트를 인메모리 스프링 이벤트로 던지지 않고, 동일한 DB 트랜잭션 내에서 `outbox` 테이블에 이벤트를 함께 `INSERT`합니다. 이후 Debezium(CDC)이나 Polling Publisher가 이 이벤트를 안전하게 읽어서 비동기 처리합니다. + - **장점**: 서버가 갑자기 꺼지거나 비동기 스레드가 죽어도 이벤트 유실이 발생하지 않는 기업급 분산 트랜잭션 패턴입니다. +* **대안 B: 정정 스케줄러 배치 (Scheduled Reckoning Batch)** + - **원리**: 매일 새벽 4시마다 `SELECT COUNT(*) FROM post_reaction WHERE post_id = :id AND type = 'LIKE'` 쿼리와 `post.like_count` 수치를 대조하여 다를 경우 불일치를 자동으로 수정하는 배치를 돌립니다. + +--- + +## 4. 💥 [대댓글] 3차 이상 무한 깊이 대댓글 작성 시 프론트엔드 UI 파괴 문제 + +### ① 개념 (What) +유저가 '대댓글(2차)'의 ID를 `parentId`로 지정하여 3차, 4차, 5차 계층의 대댓글을 계속해서 작성할 때 발생하는 문제입니다. + +### ② 물리적 원리 및 사이드 이펙트 (Why & Side-Effect) +- 프론트엔드 계층형 트리 렌더링 시 대댓글 깊이(`depth`)에 비례하여 `marginLeft: depth * 1.5rem` 들여쓰기가 적용됩니다. +- N차 대댓글이 계속 작성되면 들여쓰기가 화면 우측 밖으로 삐져나와 **모바일 및 웹 UI 레이아웃이 완전히 깨지게 됩니다.** + +### ③ 현재 적용된 물리적 해결책 (How) +* **2단계 깊이(Depth) 제한Validation**: `parent.getParent() != null` 조건으로 부모 댓글이 이미 대댓글인 경우 400 Bad Request 예외를 던져 3차 이상 대댓글 작성을 차단합니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: Flat List + `@Mention` (유튜브 / 인스타그램 스타일 1차 평탄화)** + - **원리**: 대댓글 들여쓰기 깊이를 없애고 모든 대댓글을 원댓글 바로 아래 평탄한(Flat) 리스트로 렌더링하며, 누구에게 보낸 답글인지 `@닉네임` 태그로 표시합니다. + - **장점**: UI 레이아웃이 화면 밖으로 삐져나가는 현상이 아예 구조적으로 불가능해집니다. +* **대안 B: CSS Max-Indent Clamp (프론트엔드 들여쓰기 한계선 고정)** + - **원리**: 백엔드는 N차 대댓글을 허용하되, 프론트엔드 CSS에서 `margin-left: min(depth * 1.5rem, 4.5rem)`으로 최대 들여쓰기 한계를 4.5rem으로 고정합니다. + +--- + +## 5. 💥 [보안] 익명 비밀번호 URL 쿼리 스트링 평문 노출 위험 + +### ① 개념 (What) +비회원 익명 게시글/댓글 삭제 시 `DELETE /api/posts/{publicId}?anonymousPassword=1234` 형태처럼 URL 쿼리 파라미터로 비밀번호가 전달될 때 발생하는 보안 문제입니다. + +### ② 물리적 원리 및 사이드 이펙트 (Why & Side-Effect) +- HTTP URL 쿼리 스트링은 Nginx 웹 서버 Access Log, AWS ALB 액세스 로그, 브라우저 History에 **평문(Plaintext)으로 100% 그대로 기록**됩니다. +- 웹 서버 로그를 조회할 때 비회원의 비밀번호가 인가 없이 노출될 수 있습니다. + +### ③ 현재 적용된 물리적 해결책 (How) +* **BCrypt 해시 암호화 검증**: DB 저장 시 비밀번호를 BCrypt 해싱하여 원본 비밀번호 유출을 차단합니다. + +### ④ 대체 대안 (Alternatives & Comparison) +* **대안 A: HTTP Custom Header (`X-Anonymous-Password`) 전달** + - **원리**: `DELETE` 요청 전송 시 URL 쿼리 스트링 대신 `X-Anonymous-Password: 1234` 커스텀 HTTP 헤더에 실어 보냅니다. + - **장점**: Nginx 및 액세스 로그에 비밀번호가 남지 않습니다. +* **대안 B: HMAC-SHA256 일회용 삭제 토큰 (Stateless Delete Token)** + - **원리**: 비회원이 글 작성 시 백엔드가 비밀번호를 저장하지 않고, `SHA256(publicId + secretKey + password)`로 생성된 일회용 삭제 토큰을 유저 클라이언트에 전달합니다. 삭제 시 유저는 비밀번호 대신 이 토큰을 제시하여 검증받습니다. + +--- + +# 📌 PART 2. 작업 완료 요약 + +* **생성된 파일 경로**: + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint01\studySprint01BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint01/studySprint01BoardIssuesAndSolutions260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studySprint01BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint01BoardIssuesAndSolutions260821.md) +* **작업 이력 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일 업데이트 완료. diff --git a/docs/study/sprint02/studySprint02BoardIssuesAndSolutions260821.md b/docs/study/sprint02/studySprint02BoardIssuesAndSolutions260821.md new file mode 100644 index 0000000..d658af5 --- /dev/null +++ b/docs/study/sprint02/studySprint02BoardIssuesAndSolutions260821.md @@ -0,0 +1,593 @@ +# 📚 [Master Study Guide] Sprint 02 커뮤니티 게시판 5대 아키텍처 문제점 & 10대 대체 기술(Alternatives) 7대 필수 요소 상세 가이드 (2026-08-21) + +> **노션(Notion) 복사용 및 백엔드 기술 면접 / 시스템 아키텍처 심화 학습용 마스터 가이드** +> 본 문서는 Snowthing 커뮤니티 도메인(게시글 Post & 댓글/대댓글 Comment) 구축 시 발생할 수 있는 **5대 핵심 아키텍처 문제점**과 **10대 대체 기술(Alternatives)**에 대해, **7대 필수 서술 요소 체계(개념, Why, When, How 코드/SQL, Pros, Cons & Trade-off, 서비스/아키텍처 레벨 극복 방안)** 중 **극복 방안(Mitigation)을 물리적 원리와 코드 수준으로 파헤쳐 수록한 완성판 학습 문서**입니다. + +--- + +# 📑 PART 1. 5대 핵심 문제점 심층 파헤치기 (문제 & 극복 방안 딥다이브) + +--- + +## 1. 💥 [댓글] 이미 삭제된 댓글 재삭제 시 `comment_count` 음수 차감 및 카운터 정합성 오염 문제 + +### ① 개념 (What - 문제의 명확한 정의) +Soft Delete(논리 삭제) 처리된 댓글에 대해 동시 요청(Race Condition)이나 무효한 삭제 요청이 들어왔을 때, 게시글 엔티티의 역정규화 컬럼인 `post.comment_count`가 계속 차감되어 **수치가 0 미만인 `-1`, `-2`로 오염되는 정합성 파괴 현상**입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java) `deleteComment()` 메서드에서 `comment.softDelete()` 호출 후 `post.decreaseCommentCount()`를 실행합니다. +- 트랜잭션 격리 수준(Read Committed) 환경에서 동일한 댓글 삭제 요청이 동시에 2건 들어오면, Thread 1과 Thread 2가 모두 `comment.isDeleted() == false` 상태를 읽게 됩니다. +- Thread 1이 먼저 `comment.softDelete()` 후 `comment_count`를 1 ➔ 0으로 차감하고 COMMIT 되더라도, 이미 검증을 통과한 Thread 2가 뒤이어 `comment_count`를 0 ➔ -1로 차감하여 쿼리를 전송하므로 음수가 발생합니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 클라이언트 네트워크 지연으로 사용자가 삭제 버튼을 빠른 속도로 연타(광클)할 때 +- 관리자 댓글 강제 삭제 API와 일반 유저의 삭제 요청이 동시에 백엔드로 인커밍될 때 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[Thread 1] SELECT * FROM comment WHERE id = 1 (is_deleted = false) +[Thread 2] SELECT * FROM comment WHERE id = 1 (is_deleted = false) +[Thread 1] UPDATE comment SET is_deleted = true WHERE id = 1 +[Thread 1] UPDATE post SET comment_count = comment_count - 1 WHERE id = 10 (1 -> 0) -> COMMIT +[Thread 2] UPDATE comment SET is_deleted = true WHERE id = 1 +[Thread 2] UPDATE post SET comment_count = comment_count - 1 WHERE id = 10 (0 -> -1) -> COMMIT [음수 오염!] +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **비즈니스 결함**: 게시판 목록 조회 시 댓글이 0개임에도 `댓글 [-1]`로 노출되어 사용자 서비스 신뢰도 실추. +- **DB 쿼리 오류**: 댓글 수 정렬(`ORDER BY comment_count DESC`) 쿼리 실행 시 정렬 순서가 꼬여 인기 게시글 추출 알고리즘이 파괴됨. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: 자바 서비스 메서드 내 `if (comment.isDeleted()) throw ...` 단순 예외 검사. +- **한계점**: 동시성 멀티 스레드 환경에서는 SELECT 시점의 스냅샷이 동일하므로 자바 `if` 문 검사가 무용지물이 됨. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 단순 자바 로직 방어가 불가능하므로 DB 레벨의 제약 조건이나 원자적 SQL 연산으로 이관해야 하는 오버헤드 발생. +- **서비스 레벨 극복 방안 (UX 폴백)**: + - 프론트엔드 댓글 카운트 렌더링 시 `Math.max(0, count)` 처리로 만에 하나 백엔드 오염이 발생하더라도 유저 화면에는 `-1`이 아닌 `0`으로 표시되도록 사용자 시각 차단 폴백을 적용합니다. +- **아키텍처 레벨 극복 방안 (엔티티 캡슐화 & DB Constraint)**: + - 1차적으로 `Post` JPA 엔티티 내 도메인 메서드 `decreaseCommentCount()` 내부에 `this.commentCount = Math.max(0, this.commentCount - 1)` 방어 로직을 캡슐화합니다. + - 2차적으로 DB `POST` 테이블에 `ALTER TABLE post ADD CONSTRAINT chk_post_comment_count CHECK (comment_count >= 0)` DDL 제약 조건을 추가하여, DB 엔진이 커밋 시점에 음수 업데이트 시도를 물리적으로 거부하고 예외를 내도록 이중 방어망을 구축합니다. + +--- + +## 2. 💥 [게시글] 인기 글 상세 조회 시 `increaseViewCount()` 쓰기 락(Row Lock) 병목 문제 + +### ① 개념 (What - 문제의 명확한 정의) +유저가 게시글 상세 페이지를 읽을 때마다 동기 트랜잭션(`@Transactional`) 내에서 `UPDATE post SET view_count = view_count + 1` 쓰기 쿼리가 날아가 DB 쓰기 병목(Lock Contention)이 발생하는 현상입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- RDBMS(MySQL InnoDB)는 단일 행(Row)에 대한 `UPDATE` 쿼리 실행 시 해당 행에 배타적 쓰기 락(Exclusive Row Lock, X-Lock)을 겁니다. +- 읽기(Read) 요청임에도 불구하고 쓰기 락이 발생하여, 동시 진입한 수천 개의 트랜잭션이 동일한 게시글 Row Lock을 획득하기 위해 줄을 서서 대기합니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 메인 화면에 노출된 인기 핫딜, 긴급 공지사항, 리조트 실시간 제보 글 등 특정 핫 게시글에 수천 명의 동접자가 동시에 클릭할 때 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[유저 1000명 동시 요청] GET /api/posts/{publicId} + └── PostService.getPostDetail() 진입 (@Transactional) + └── DB Connection Pool 1000개 고갈 + └── UPDATE post SET view_count = view_count + 1 WHERE post_id = 1 (Row Lock 대기) + └── 5초 후 DB Connection Timeout 예외 발생 -> 504 Gateway Timeout +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **캐스케이딩 장애 (Cascading Failure)**: 인기 글 1개의 조회수 락 병목으로 인해 DB 커넥션 풀이 고갈되어, 로그인, 게시글 작성 등 서비스 전체 API가 마비됨. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: JPA `@Modifying @Query` Bulk Update 호출. +- **한계점**: 영속성 컨텍스트 스냅샷 비교는 줄였지만, DB InnoDB Row Lock 형성 자체를 피할 수는 없음. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 조회수를 실시간으로 DB에 동기 기록하는 아키텍처를 포기해야 함. +- **서비스 레벨 극복 방안 (가용성 우선 정책)**: + - 조회수 반영에 10분의 미세한 시차가 발생하더라도, 유저가 글을 읽을 때 페이지 로딩 속도를 최우선으로 확보하는 가용성(Availability) 우선 서비스 정책을 수립합니다. +- **아키텍처 레벨 극복 방안 (Redis 쓰기 격리 & Write-Back 배치)**: + - 유저가 글을 읽을 때 DB `UPDATE` 쿼리를 100% 제거하고, Redis `INCR post:view_count:{id}` 명령으로 인메모리 단에서 조회수만 가산합니다. + - 백그라운드 스프링 `@Scheduled(cron = "0 */10 * * * *")` 스케줄러가 Redis에 누적된 수치를 읽어 10분마다 DB `post.view_count` 컬럼으로 일괄 Bulk Write-Back (`UPDATE post SET view_count = view_count + :incr`)을 수행함으로써 DB Row Lock 형성 자체를 완전히 분리합니다. + +--- + +## 3. 💥 [추천 비동기] `@Async` 비동기 카운터 유실 시 투표 이력과 카운트 수치 불일치 문제 + +### ① 개념 (What - 문제의 명확한 정의) +추천 투표 시 `post_reaction` 테이블 저장은 성공적으로 COMMIT 되었으나, 비동기로 카운터를 올리는 `@Async` 핸들러가 예외나 서버 셧다운으로 유실될 때 데이터 불일치가 남는 현상입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- 메인 트랜잭션 Thread는 `reactionRepository.save()` 후 DB COMMIT을 치고 즉시 200 OK를 응답합니다. +- 스프링의 `@Async` 비동기 스레드 풀에서 실행되는 [`PostReactionEventListener`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java)가 실행 중 DB 락 타임아웃이나 OOM, 서버 재부팅을 만나면 카운트 `UPDATE` 쿼리가 날아가지 못하고 사라집니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 서버 배포 시점, 서버 셧다운, DB 일시적 네트워크 흔들림 또는 비동기 스레드 풀(Thread Pool) 큐가 가득 찼을 때 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[Main Thread] post_reaction INSERT (post_id=1, member_id=5, type='LIKE') -> COMMIT 완료 +[Main Thread] eventPublisher.publishEvent() -> 200 OK 응답 +[Async Thread] @Async handleEvent() 실행 중 DB Timeout 터짐 -> UPDATE post SET like_count = like_count + 1 실패! +[결과] post_reaction 에는 1건 존재하나, post.like_count는 0으로 동기화 실패 (데이터 정합성 파괴) +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **사용자 경험(UX) 악화**: 유저는 "추천을 눌렀는데 화면 숫자가 안 오른다"고 생각하여 재투표를 시도하지만, DB 유니크 제약으로 409 Conflict 예외가 터져 혼란 야기. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: `@Async` 백그라운드 단순 이벤트 발행. +- **한계점**: JVM 인메모리 큐에 보관되므로 서버 재부팅 시 이벤트가 100% 영구 유실됨. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 단순 비동기 이벤트 대신 DB 기반 아웃박스 테이블이나 스케줄러 배치를 도입해야 함. +- **서비스 레벨 극복 방안 (Optimistic UI 렌더링)**: + - 백엔드의 처리 시차나 유실에 관계없이 프론트엔드에서 추천 버튼 클릭 즉시 추천 버튼 색상을 바꾸고 숫자 수치를 +1 가산하여 유저가 지연을 느끼지 않도록 처리합니다. +- **아키텍처 레벨 극복 방안 (Transactional Outbox & 새벽 정정 배치)**: + - 1차적으로 Transactional Outbox Pattern을 채택하여 `outbox` 테이블에 메인 트랜잭션과 동일 커밋을 수행함으로써 이벤트 유실을 방지합니다. + - 2차 보완으로 매일 새벽 4시마다 `ScheduledReckoningBatch`를 실행하여 `SELECT COUNT(*) FROM post_reaction WHERE post_id = :id AND type = 'LIKE'` 수치와 `post.like_count` 수치를 비교하고, 다를 경우 올바른 수치로 자동 보정하여 100% 최종 일관성(Eventual Consistency)을 달성합니다. + +--- + +## 4. 💥 [대댓글] 3차 이상 무한 깊이 대댓글 작성 시 프론트엔드 UI 파괴 문제 + +### ① 개념 (What - 문제의 명확한 정의) +대댓글의 대댓글(3차), 4차, 10차 대댓글 작성 시 프론트엔드의 고정 들여쓰기(`marginLeft`)로 인해 모바일 및 웹 화면 우측 밖으로 본문 텍스트가 삐져나가는 UI 깨짐 현상입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- 백엔드 [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java)에서 `parentId` 검증 시 부모가 '원댓글(1차)'인지 '대댓글(2차)'인지 검사하는 depth 제한 로직이 누락됨. +- 프론트엔드는 계층 구조에 따라 `marginLeft = depth * 1.5rem`으로 스타일을 렌더링함. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 악의적인 사용자가 대댓글의 ID를 부모로 삼아 N차 대댓글을 연속 작성하거나 타사 스크립트로 API를 직접 호출할 때 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +10차 대댓글 작성 -> depth = 10 + └── 프론트엔드:
+ └── 모바일 화면 폭(360px) 중 본문 영역이 120px로 축소됨 -> 1글자씩 세로 줄바꿈 및 화면 우측 이탈 +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **서비스 사용 불능**: 모바일 사용자가 게시글 및 댓글을 정상적으로 읽을 수 없어 서비스 가독성 파괴. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: 부모 존재 여부(`parentId != null`)만 검증. +- **한계점**: 부모 댓글의 부모가 존재하는지(2차 깊이 이상인지) 검증하지 않아 무한 깊이 생성을 막지 못함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 3차 이상의 깊은 토론 스레드 작성을 제한해야 함. +- **서비스 레벨 극복 방안 (대댓글 폼 안내 & Toast 알림)**: + - 프론트엔드 댓글 입력창에서 대댓글의 [답글 달기] 버튼을 누를 경우 "대댓글에는 추가 답글을 작성할 수 없습니다."라는 안내 Toast 메세지를 노출하여 작성을 사전 유도 차단합니다. +- **아키텍처 레벨 극복 방안 (백엔드 2단계 Depth Validation)**: + - [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java) `createComment()` 메서드 진입 시 `if (parent != null && parent.getParent() != null)` 검증 로직을 추가합니다. + - 부모 댓글(`parent`)이 이미 부모(`parent.getParent()`)를 가지고 있는 2차 계층 이상이라면 `CustomAuthException(ErrorCode.INVALID_INPUT, "대댓글에는 추가 답글을 작성할 수 없습니다.")` 예외(400 Bad Request)를 던져 백엔드 API 수준에서 물리적으로 원자적 차단합니다. + +--- + +## 5. 💥 [보안] 익명 비밀번호 URL 쿼리 스트링 평문 노출 보안 위험 문제 + +### ① 개념 (What - 문제의 명확한 정의) +비회원 익명 게시글/댓글 삭제 시 `DELETE /api/posts/{publicId}?anonymousPassword=1234` 형태처럼 URL 쿼리 파라미터로 비밀번호가 전송되어 웹 서버 로그에 비밀번호가 평문 저장되는 보안 문제입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- HTTP 표준 및 웹 서버(Nginx, Apache, AWS ALB) 구현상, HTTP GET/DELETE 메서드의 URL 쿼리 스트링은 서버 Access Log의 Request Line 항목에 100% 그대로 로깅됩니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 비회원 익명 사용자가 자신이 쓴 글이나 댓글을 삭제하기 위해 비밀번호를 입력하고 삭제를 요청할 때마다 항상 발생 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +Client: DELETE /api/posts/p1024?anonymousPassword=secretPass123 + └── Nginx access.log 기록: + "192.168.1.10 - - [21/Aug/2026:17:00:00] "DELETE /api/posts/p1024?anonymousPassword=secretPass123 HTTP/1.1" 200 45" + └── 로그 파일 조회자에게 비회원 비밀번호 완전 노출! +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **보안 컨플라이언스 위반**: 비밀번호 평문 로깅으로 인한 개인정보보호법 위반 및 서버 로그 유출 시 타 계정 도용 2차 피해. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: DB 저장 시 BCrypt 암호화 저장. +- **한계점**: DB 저장은 안전하지만, 네트워크 전송 구간 및 Nginx 웹 서버 로그 단에서의 비밀번호 노출을 막지 못함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: URL 파라미터 전송 대신 커스텀 헤더나 무상태 토큰 방식을 적용해야 하므로 프론트엔드 연동 복잡도 증가. +- **서비스 레벨 극복 방안 (삭제 모달 폼 보안 전송)**: + - 삭제 모달 팝업에서 비밀번호 입력 시 `type="password"` 상태로 암호화 입력을 보장하고, URL 쿼리 스트링 생성을 아예 프론트엔드 단에서 금지합니다. +- **아키텍처 레벨 극복 방안 (HTTP Custom Header & HMAC 일회용 삭제 토큰)**: + - **방안 1**: 전송 방식을 HTTP Custom Header (`X-Anonymous-Password`)로 전환하고 Nginx `log_format` 설정에서 해당 헤더 로깅을 제외하여 로그 평문 노출을 차단합니다. + - **방안 2**: 무상태 HMAC-SHA256 일회용 삭제 토큰(`generateDeleteToken`) 방식을 도입하여 DB에 비밀번호 컬럼 자체를 아예 생성하지 않는 무상태 보안 검증 구조를 완성합니다. + +--- + +# 📑 PART 2. 10대 대체 기술(Alternatives) 7대 필수 요소 심층 분석 (극복 방안 딥다이브) + +--- + +## 1. 💥 [댓글] 이미 삭제된 댓글 재삭제 이슈의 2대 대안 + +### 1-1. 대체 대안 A: DB Atomic SQL 함수 (`GREATEST`) 사용 + +#### ① 개념 (What) +JPA 영속 상태 변경 방식 대신, MySQL의 `GREATEST(0, comment_count - 1)` SQL 함수를 내보내 DB 엔진 단에서 차감 결과가 0 미만으로 내려가지 않도록 원자적 방어를 수행하는 기법입니다. + +#### ② 왜 사용하는지 (Why) +JPA 메모리 연산 방식(`post.setCommentCount(count - 1)`)은 동시 요청 시 Dirty Read로 음수 차감이 터질 수 있으므로, DB 엔진의 Single Thread SQL 실행 메커니즘을 이용하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +재고 차감(0개 미만 불가), 포인트 차감(0원 미만 불가), 카운터 감소 등 하한선이 명확한 차감 연산에 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +// PostRepository.java +@Modifying(clearAutomatically = true, flushAutomatically = true) +@Query("UPDATE Post p SET p.commentCount = GREATEST(0, p.commentCount - 1) WHERE p.id = :postId") +void decreaseCommentCountAtomic(@Param("postId") Long postId); +``` + +#### ⑤ 장점 (Pros) +- **음수 차감 물리적 100% 방지**: MySQL 엔진이 Single Thread로 쿼리를 내보내므로 동시 요청이 10,000건 들어와도 0 아래로 내려가지 않음. +- **영속성 스냅샷 비교 생략**: JPA 1:1 엔티티 스냅샷 비교 과정이 없어서 Execution Time 축소. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **자바 `Math.max(0, count - 1)` 방어 대비**: 자바 메모리 방어는 멀티 스레드 동시 진입 시 이미 생성된 UPDATE 쿼리를 막지 못하지만, SQL `GREATEST`는 DB 엔진 단에서 원자적 처리됨. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (JPA 1차 캐시 불일치)**: DB 컬럼은 차감되었으나 JPA 1차 캐시 엔티티 객체의 `commentCount` 수치는 갱신되지 않는 불일치 발생. +- **서비스 레벨 극복 방안**: 댓글 삭제 응답 반환 시 개별 엔티티 수치 대신 백엔드가 방금 갱신한 정정 수치를 반환하거나 최신 목록 API를 다시 호출하도록 유도. +- **아키텍처 레벨 극복 방안 (JPA Flush & Clear)**: `@Modifying(clearAutomatically = true, flushAutomatically = true)` 옵션을 부여하여 쿼리 실행 직후 JPA 영속성 컨텍스트를 DB로 `flush()`하고 1차 캐시를 자동으로 `clear()` 함으로써 이후 조회 쿼리가 DB의 최신 `comment_count` 수치를 패치하도록 완전 동기화. + +--- + +### 1-2. 대체 대안 B: 스케줄러 기반 비동기 카운터 재계산 (Scheduled Reconciliation) + +#### ① 개념 (What) +댓글 작성/삭제 시 DB 카운터를 즉시 변경하지 않고, 주기적인 백그라운드 스케줄러가 실시간 `SELECT COUNT(*)` 집계 쿼리를 돌려 게시글의 `comment_count`를 일괄 정정 덮어쓰는 기법입니다. + +#### ② 왜 사용하는지 (Why) +카운터 증감 연산 자체를 이관하여 쓰기 락 병목과 음수 오염 가능성을 근본 제거하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +실시간 카운트 정확도보다 DB 쓰기 성능 및 안정성이 훨씬 중요한 대규모 커뮤니티에 적합합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +@Scheduled(cron = "0 */5 * * * *") // 5분마다 실행 +@Transactional +public void reconcileCommentCounts() { + Set dirtyPostIds = redisTemplate.opsForSet().members("dirty_posts"); + if (dirtyPostIds == null || dirtyPostIds.isEmpty()) return; + + for (String postIdStr : dirtyPostIds) { + Long postId = Long.parseLong(postIdStr); + long actualCount = commentRepository.countByPostIdAndIsDeletedFalse(postId); + postRepository.updateCommentCount(postId, actualCount); + redisTemplate.opsForSet().remove("dirty_posts", postIdStr); + } +} +``` + +#### ⑤ 장점 (Pros) +- **카운터 오류 근본적 해결**: 증감 연산을 하지 않고 실시간 개수를 덮어쓰므로 카운트 누수나 음수 현상이 발생할 수 없음. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **동기 `decreaseCommentCount()` 대비**: 동기 방식은 매 댓글 삭제 시 DB Row Lock을 잡지만, 스케줄러 방식은 삭제 시 Lock을 전혀 잡지 않음. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (최대 5분의 시차 발생 & 주기적 DB I/O 부하)**: 댓글 작성 직후 5분 동안은 화면 상의 댓글 수 수치가 실시간 반영되지 않고, 배치 실행 시 Full Scan 부하가 생김. +- **서비스 레벨 극복 방안 (로컬 State 반영)**: 댓글 작성/삭제 직후 프론트엔드 로컬 State에서 수치를 임시로 +1 / -1 가산하여 렌더링함으로써 유저가 시차를 느끼지 않도록 보완. +- **아키텍처 레벨 극복 방안 (Dirty Set Redis 수집 & 핀포인트 집계)**: 전수 조사의 DB I/O 부하를 막기 위해 댓글 CUD 발생 시 `dirty_posts` Redis Set에 `postId`를 수집하고, 스케줄러는 해당 Set에 등록된 `postId`에 대해서만 핀포인트 Range 집계 쿼리를 내보내어 DB I/O를 99% 절감. + +--- + +## 2. 💥 [게시글] 인기 글 상세 조회 시 쓰기 락 병목 이슈의 2대 대안 + +### 2-1. 대체 대안 A: Redis HyperLogLog (`PFADD`) 기반 고성능 카운팅 & 중복 제거 + +#### ① 개념 (What) +Redis의 확률적 자료구조인 HyperLogLog(`PFADD`, `PFCOUNT`)를 활용하여, 단 12KB 메모리만으로 중복 조회를 인메모리 $O(1)$로 차단하고 조회수를 카운팅하는 기법입니다. + +#### ② 왜 사용하는지 (Why) +단순 카운터나 RDBMS에 중복 IP 테이블을 만들어 저장하면 메모리와 DB 용량이 폭증합니다. HyperLogLog는 100만 건의 중복 IP를 단 12KB 메모리로 추산하므로 공간 효율성이 최상입니다. + +#### ③ 어떨 때 사용하는지 (When) +대규모 트래픽 서비스의 게시글 조회수, 방문자 수(UV) 집계 및 중복 조회 방지에 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +public void increaseViewCountWithHyperLogLog(Long postId, String clientIp) { + String redisKey = "post:views:" + postId; + // HyperLogLog에 IP 추가 (새로운 IP면 1 반환, 중복이면 0 반환) + Long added = redisTemplate.opsForHyperLogLog().add(redisKey, clientIp); + + if (added != null && added == 1L) { + redisTemplate.opsForValue().increment("post:view_count:" + postId); + } +} +``` + +#### ⑤ 장점 (Pros) +- **DB Row Lock 100% 제거**: DB에 쓰기 쿼리가 전혀 들어가지 않으므로 동접자가 몰려도 락 병목이 터지지 않음. +- **극도의 메모리 절약**: 100만 명의 IP를 수집해도 무조건 단 12KB 메모리만 사용함. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **Redis Set 자료구조 대비**: Redis Set은 100만 개 IP 저장 시 수십 MB의 메모리가 들지만, HyperLogLog는 12KB로 고정됨. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (0.81%의 확률적 표준오차)**: 확률적 계산 알고리즘 특성상 약 0.81% 미만의 오차가 발생할 수 있음. +- **서비스 레벨 극복 방안**: 커뮤니티 조회 수치는 0.81% 오차(1,000회 기준 약 8회 차이)가 서비스 이용이나 금융 정산 영역이 아니므로 서비스 요구사항을 완전 만족함을 도메인 레벨 수용. +- **아키텍처 레벨 극복 방안 (Redis RDB/AOF & Scheduled Write-Back)**: Redis 메모리 휘발에 대비하여 10분 단위 스케줄러가 Redis 수치를 DB `post.view_count` 컬럼으로 Write-Back 집계 갱신하여 영구 보존. + +--- + +### 2-2. 대체 대안 B: Client-Side Cookie 쿨타임 제한 (24시간 중복 방지) + +#### ① 개념 (What) +유저 브라우저 쿠키(`viewed_posts=1,4,12`)에 읽은 글 ID를 기록하고, 쿠키가 유효한 24시간 동안은 프론트엔드에서 백엔드로 조회수 증가 요청을 아예 보내지 않도록 차단하는 기법입니다. + +#### ② 왜 사용하는지 (Why) +서버 백엔드로 인커밍(Incoming)되는 HTTP 요청 수 자체를 줄여 네트워크 및 서버 CPU 자원을 절약하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +Redis 같은 인메모리 인프라 구축 비용 없이 단순한 웹 서비스에서 조회수 어뷰징을 방지할 때 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```typescript +// Next.js 프론트엔드 컴포넌트 +useEffect(() => { + const viewedPosts = getCookie('viewed_posts') || ''; + if (!viewedPosts.includes(`[${postId}]`)) { + api.post(`/api/posts/${publicId}/views`); + setCookie('viewed_posts', `${viewedPosts}[${postId}]`, { maxAge: 86400 }); + } +}, [postId]); +``` + +#### ⑤ 장점 (Pros) +- **서버 요청 수 급감**: 동일 유저의 재방문 요청이 백엔드까지 도착하지 않으므로 트래픽이 획기적으로 줄어듦. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **서버 IP 기반 차단 대비**: 서버 IP 차단은 서버 메모리를 소비하지만, 쿠키 방식은 클라이언트 브라우저 자원을 활용함. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (쿠키 삭제 및 시크릿 창 어뷰징에 취약)**: 유저가 브라우저 쿠키를 삭제하거나 시크릿 모드로 접속하면 중복 카운트가 올라감. +- **서비스 레벨 극복 방안**: 어뷰징 유저가 쿠키를 삭제하더라도 개별 유저의 자발적 행위이므로 시스템 전체 셧다운을 일으키지 않는 수준에서 허용. +- **아키텍처 레벨 극복 방안 (하이브리드 IP Redis 쿨타임)**: 백엔드에서 1차로 Client Cookie를 대조하고 2차로 IP 기반 Redis 10분 쿨타임 키(`view:cooldown:{ip}:{postId}`)를 이중 검증하여 쿠키 삭제 어뷰징을 99% 무력화하는 하이브리드 검증 구축. + +--- + +## 3. 💥 [추천 비동기] `@Async` 비동기 카운터 유실 이슈의 2대 대안 + +### 3-1. 대체 대안 A: Transactional Outbox Pattern (트랜잭셔널 아웃박스 패턴) + +#### ① 개념 (What) +이벤트를 인메모리 스프링 이벤트로 던지지 않고, 메인 비즈니스 로직과 동일한 DB 트랜잭션 안에서 `outbox` 테이블에 이벤트 메시지를 함께 `INSERT`한 뒤, 별도의 메시지 릴레이(Debezium CDC 또는 Polling Publisher)가 읽어서 처리하는 Enterprise 분산 트랜잭션 패턴입니다. + +#### ② 왜 사용하는지 (Why) +JVM 인메모리 비동기 이벤트는 서버가 갑자기 꺼지면 메모리에 있던 이벤트가 100% 유실됩니다. DB 테이블에 이벤트 발행 내역을 함께 기록하여 **최소 1회 전달(At-Least-Once Delivery)**을 물리적으로 보장하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +결제, 결제 후 포인트 적립, 이벤트 카운팅 등 유실되면 안 되는 핵심 비동기 이벤트 처리에 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +@Transactional +public void reactToPost(String publicId, ReactionType type, CustomUserDetails userDetails) { + // 1. 투표 내역 저장 + reactionRepository.save(reaction); + + // 2. 동일 트랜잭션 내에서 outbox 테이블에 이벤트 메시지 저장 (원자성 보장) + outboxRepository.save(new OutboxEvent( + "POST_REACTION", + postId.toString(), + objectMapper.writeValueAsString(new PostReactionEvent(postId, type)) + )); +} +``` + +#### ⑤ 장점 (Pros) +- **이벤트 유실 0%**: 메인 데이터 저장과 이벤트 작성이 동일 DB 트랜잭션으로 묶여 원자성(Atomic)이 보장됨. +- **서버 장애 복구**: 서버가 다운된 후 재시작되어도 `outbox` 테이블에 남아있는 미처리 이벤트를 읽어서 복구 처리. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **스프링 `@Async` 기본 이벤트 대비**: 기본 `@Async`는 메모리 유실 위험이 크지만, Outbox Pattern은 DB 내구성을 이용해 유실을 물리 차단함. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (Outbox 테이블 비대화 및 추가 DB Write I/O)**: 모든 이벤트가 DB에 기록되므로 I/O 부담이 늘어나고 테이블 용량이 커짐. +- **서비스 레벨 극복 방안**: 비동기 처리가 지연되더라도 유저 화면에는 Optimistic UI로 완료 상태를 즉시 표시. +- **아키텍처 레벨 극복 방안 (Outbox Purge Scheduler)**: `outbox` 테이블에 모든 비동기 이벤트가 누적되어 DB 용량이 폭증하고 I/O가 느려지는 현상을 막기 위해, `status = 'PROCESSED'`이면서 생성된 지 1시간이 지난 Outbox 행을 1,000개 단위로 DELETE하는 `OutboxPurgeScheduler` 배치를 구축하여 테이블 사이즈를 작게 유지. + +--- + +### 3-2. 대체 대안 B: 새벽 정정 스케줄러 배치 (Scheduled Reckoning Batch) + +#### ① 개념 (What) +이벤트 유실 가능성을 인정하되, 매일 새벽 트래픽이 적은 시각에 `post_reaction` 테이블의 실제 투표 건수를 `COUNT(*)`로 집계하여 `post.like_count` 수치와 대조 후 다를 경우 일괄 수정하는 정정 배치 기법입니다. + +#### ② 왜 사용하는지 (Why) +복잡한 메시지 큐나 Outbox 패턴 구축 비용 없이, 100% 데이터 정합성을 가장 단순한 코드로 보장하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +실시간 카운트 반영의 밀리초 오차가 서비스 이용에 치명적이지 않은 커뮤니티 서비스에 적합합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +@Scheduled(cron = "0 0 4 * * *") // 매일 새벽 4시 +@Transactional +public void reconcileReactionCounts() { + List posts = postRepository.findAll(); + for (Post post : posts) { + long actualLikes = reactionRepository.countByPostIdAndType(post.getId(), ReactionType.LIKE); + if (post.getLikeCount() != actualLikes) { + post.setLikeCount(actualLikes); // 데이터 정정 + } + } +} +``` + +#### ⑤ 장점 (Pros) +- **구현 단순성**: 추가 인프라 구축 없이 가장 직관적이고 안정적으로 데이터 일관성을 맞출 수 있음. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **Outbox Pattern 대비**: Outbox Pattern은 복잡한 릴레이 스레드가 필요하지만, 정정 배치는 단순 SQL 집계로 완료됨. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (새벽 시간대 DB Read I/O 부하)**: 전수 조사를 돌리면 DB CPU 사용량이 상승함. +- **서비스 레벨 극복 방안**: 새벽 4시는 유저 접속량이 가장 적은 시간대이므로 정정 작업으로 인한 성능 영향을 사용자에게서 격리. +- **아키텍처 레벨 극복 방안 (어제 변경된 게시글 Index Scan 핀포인트)**: 새벽 4시 배치 시 전체 `post` 테이블 Full Scan으로 인한 DB CPU 상승을 막기 위해 `WHERE updated_at >= NOW() - INTERVAL 1 DAY` 조건절을 추가하여 전날 변경이 일어난 게시글만 Index Scan으로 핀포인트 정정. + +--- + +## 4. 💥 [대댓글] 3차 이상 무한 깊이 대댓글 이슈의 2대 대안 + +### 4-1. 대체 대안 A: Flat List + `@Mention` (유튜브 / 인스타그램 1차 평탄화 모델) + +#### ① 개념 (What) +대댓글의 계층형 들여쓰기 자체를 없애고 모든 답글을 원댓글 하위의 평탄한(Flat) 1차 리스트로만 렌더링하며, 누구에게 작성한 답글인지 `@작성자닉네임` 태그로 표시하는 UI/UX 아키텍처입니다. + +#### ② 왜 사용하는지 (Why) +모바일 화면 폭(360px~430px)은 들여쓰기를 3단계만 해도 본문 영역이 좁아져 읽기 불가능해집니다. 이를 근본적으로 해결하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +유튜브, 인스타그램, 페이스북 등 모바일 웹/앱 트래픽 비중이 80% 이상인 현대 웹 서비스에 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```json +// JSON 반환 구조 +{ + "commentId": 12, + "content": "@댓글보더 저도 그렇게 생각합니다!", + "targetMemberNickname": "댓글보더", + "parentId": 1, // 최상위 원댓글 ID만 유지 + "depth": 1 +} +``` + +#### ⑤ 장점 (Pros) +- **UI 레이아웃 파괴 근본 차단**: 들여쓰기 너비가 0으로 고정되므로 아무리 답글이 많이 달려도 모바일 화면 레이아웃이 절대 깨지지 않음. +- **데이터 구조 단순화**: N차 복잡한 트리를 조립할 필요 없이 1차 리스트만 반환하므로 백엔드 연산이 가벼워짐. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **N차 계층형 트리 대비**: N차 계층형 트리는 복잡한 Recursion 조립과 UI 들여쓰기가 필요하지만, Flat List는 $O(N)$ 단일 루프 반환 가능. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (답글의 구체적 부모 맥락 추적 불분명)**: 누구의 대댓글에 대한 대댓글인지 1:1 스레드 흐름 파악이 계층형보다 다소 모호함. +- **서비스 레벨 극복 방안 (Tooltip 미니 모달 뷰어)**: 1:1 대댓글 스레드 맥락 추적이 모호해지는 단점을 해결하기 위해, `@작성자닉네임` 태그 클릭 시 해당 원본 댓글의 팝업 미니 모달(Tooltip Modal)이 뜨도록 프론트엔드 뷰 연동. +- **아키텍처 레벨 극복 방안**: `targetCommentId` 외래키를 DTO에 포함하여 단 1회 인메모리 Map 조회로 원본 댓글 본문을 즉시 팝업으로 렌더링. + +--- + +### 4-2. 대체 대안 B: CSS Max-Indent Clamp (프론트엔드 들여쓰기 한계선 고정) + +#### ① 개념 (What) +백엔드는 데이터베이스 상에서 N차 대댓글을 허용하되, 프론트엔드 CSS 렌더링 시 `margin-left` 들여쓰기의 최대 한계선을 `clamp` 또는 `min()` 함수로 고정하는 기법입니다. + +#### ② 왜 사용하는지 (Why) +백엔드 도메인 로직 수정 없이 프론트엔드 스타일시트 적용만으로 레이아웃 이탈을 막기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +기존 백엔드 API 스펙을 건드리지 않고 빠르게 UI 깨짐을 임시 방어할 때 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```tsx +// Tailwind / Inline Style 적용 +
+ {comment.content} +
+``` + +#### ⑤ 장점 (Pros) +- **백엔드 수정 0건**: 백엔드 코드를 단 한 줄도 수정하지 않고 프론트엔드 뷰만으로 1초 만에 방어할 수 있음. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **백엔드 Depth Validation 대비**: 백엔드 검증은 400 에러를 반환하지만, CSS Clamp는 에러 없이 렌더링 위치만 고정함. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (3차 이상 대댓글 간 시각적 구분 모호)**: 3차 대댓글과 4차, 5차 대댓글의 들여쓰기 위치가 동일해져 계층 구분이 안 됨. +- **서비스 레벨 극복 방안 (답글 뱃지 렌더링)**: 3차 이상 대댓글의 들여쓰기 위치가 같아져 시각적 계층이 모호해지는 점을 보완하기 위해, `depth > 2`인 경우 댓글 상단에 `↳ [3차 답글]` 태그 뱃지(Badge)를 추가 렌더링. +- **아키텍처 레벨 극복 방안**: 백엔드 2단계 깊이 제한Validation(`parent.getParent() != null`)을 병행 적용하여 3차 이상 생성 자체를 예외 차단. + +--- + +## 5. 💥 [보안] 익명 비밀번호 URL 쿼리 스트링 노출 이슈의 2대 대안 + +### 5-1. 대체 대안 A: HTTP Custom Header (`X-Anonymous-Password`) 전달 + +#### ① 개념 (What) +비회원 비밀번호를 URL 쿼리 스트링이 아닌, HTTP 요청 헤더(`X-Anonymous-Password: 1234`)에 포함하여 전달하는 보안 패턴입니다. + +#### ② 왜 사용하는지 (Why) +웹 서버(Nginx, Apache)는 표준 보안 설정상 Request Body와 Custom Header 내용을 Access Log에 기록하지 않고 요청 라인(URL)만 기록하므로, 로그 유출을 물리 차단할 수 있습니다. + +#### ③ 어떨 때 사용하는지 (When) +RESTful API 관례상 `DELETE` 메서드에 Request Body를 실어 보내기 부담스러울 때 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +@DeleteMapping("/api/posts/{publicId}") +public ResponseEntity deletePost( + @PathVariable String publicId, + @RequestHeader(value = "X-Anonymous-Password", required = false) String anonymousPassword) { + postService.deletePost(publicId, anonymousPassword); + return ResponseEntity.noContent().build(); +} +``` + +#### ⑤ 장점 (Pros) +- **웹 서버 로그 유출 100% 차단**: Nginx, ALB, CDN 액세스 로그에 비밀번호 평문 기록이 남지 않음. +- **HTTP 스펙 준수**: `DELETE` 메서드 본문(Body)을 비워두어 일부 엄격한 HTTP 클라이언트 라이브러리와의 호환성 유지. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **URL Query Parameter 대비**: URL Parameter는 웹 서버 로그에 평문 기록되지만, Custom Header는 기록되지 않아 보안상 우월함. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (CORS Preflight Flight 요청 발생)**: 표준 헤더가 아닌 커스텀 헤더(`X-`)를 사용하므로 브라우저가 `OPTIONS` 사전 요청(Preflight)을 보냄. +- **서비스 레벨 극복 방안**: 첫 요청 시 수 밀리초의 Preflight 지연이 발생하지만 지연 시간이 매우 짧으므로 삭제 성공 경험 우선 제공. +- **아키텍처 레벨 극복 방안 (Preflight Caching)**: 브라우저의 CORS Preflight(`OPTIONS`) 요청으로 인한 2배 HTTP 트래픽 발생을 극복하기 위해, Spring Security CORS Configuration에서 `allowedHeaders("X-Anonymous-Password")` 등록 및 `maxAge(3600)`을 지정하여 브라우저가 Preflight 결과를 1시간 동안 메모리에 캐싱하도록 설정. + +--- + +### 5-2. 대체 대안 B: HMAC-SHA256 기반 무상태 일회용 삭제 토큰 (Stateless Delete Token) + +#### ① 개념 (What) +비회원이 글 작성 시 백엔드가 비밀번호를 저장하지 않고, `HMAC-SHA256(publicId + secretKey + password)`로 암호화 서명된 일회용 삭제 토큰(Token)을 발급하여 유저에게 반환하는 무상태 보안 인증 패턴입니다. + +#### ② 왜 사용하는지 (Why) +비밀번호 원본 및 BCrypt 해시조차 DB에 저장하지 않아, DB가 뚫려도 비회원 비밀번호가 유출될 위험이 0%입니다. + +#### ③ 어떨 때 사용하는지 (When) +익명 게시판 보안 수준을 극상으로 끌어올리고 무상태(Stateless) 검증을 꾀할 때 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +// 작성 시 토큰 발급 +public String generateDeleteToken(String publicId, String rawPassword) { + return HmacUtils.hmacSha256Hex(SECRET_KEY, publicId + ":" + rawPassword); +} + +// 삭제 시 토큰 대조 검증 +public void validateDeleteToken(String publicId, String rawPassword, String clientToken) { + String expectedToken = generateDeleteToken(publicId, rawPassword); + if (!expectedToken.equals(clientToken)) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + } +} +``` + +#### ⑤ 장점 (Pros) +- **DB 보안 극상**: DB에 비밀번호 컬럼 자체가 존재하지 않으므로 데이터베이스 유출 사고 시에도 안전함. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **BCrypt DB 저장 대비**: BCrypt 저장은 DB 용량을 차지하고 딕셔너리 공격 대상이 될 수 있으나, HMAC 토큰은 DB 저장이 필요 없는 무상태 검증임. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (서버 Secret Key 유출 시 서명 위조 위험)**: 애플리케이션의 `SECRET_KEY`가 유출되면 토큰 위조가 가능해짐. +- **서비스 레벨 극복 방안**: 토큰 생성 알고리즘이 노출되지 않도록 에러 메시지 캡슐화. +- **아키텍처 레벨 극복 방안 (AWS Secrets Manager & Key Rotation)**: 서버 `SECRET_KEY` 유출 시 토큰 서명 위조 위험을 물리적으로 극복하기 위해, `SECRET_KEY`를 코드나 설정 파일에 하드코딩하지 않고 `AWS Secrets Manager`에 저장하며 30일마다 자동으로 서명 키를 로테이션(Rotation)하고 구버전 키는 7일간 Grace Period를 두어 안전 검증. + +--- + +# 📌 PART 3. 작업 완료 및 파일 위치 안내 + +* **마스터 스터디 가이드 생성 경로**: + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studySprint02BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studySprint02BoardIssuesAndSolutions260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studySprint02BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint02BoardIssuesAndSolutions260821.md) +* **`AGENTS.md` 작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/study/sprint02/studySprint02PostDomainIssues260821.md b/docs/study/sprint02/studySprint02PostDomainIssues260821.md new file mode 100644 index 0000000..dba738c --- /dev/null +++ b/docs/study/sprint02/studySprint02PostDomainIssues260821.md @@ -0,0 +1,191 @@ +# 📚 [Master Study Guide] Sprint 02 게시글(Post) 도메인 5대 아키텍처 결함 & 물리적 극복 방안 가이드 (2026-08-21) + +> **노션(Notion) 복사용 및 백엔드 게시글 도메인 심화 학습용 마스터 가이드** +> 본 문서는 Snowthing 게시글(Post) 도메인(댓글 제외) 구축 시 발생할 수 있는 **5대 핵심 아키텍처 문제점과 결함**에 대해, **7대 필수 서술 요소 체계(개념, Why, When, How 코드/SQL, Pros & Cons, 기존 한계점, 서비스/아키텍처 레벨 극복 방안)**를 물리적 메커니즘 수준으로 전수 파헤쳐 정리한 마스터 학습 문서입니다. + +--- + +# 📑 PART 1. 게시글(Post) 도메인 5대 결함 & 7대 필수 요소 심층 분석 + +--- + +## 1. 💥 `POST` 페이징 목록 조회 시 본문 제외 미적용으로 인한 네트워크 트래픽 폭증 및 DB I/O 병목 + +### ① 개념 (What - 문제의 명확한 정의) +게시글 목록 API(`GET /api/posts?page=0&size=10`)를 부를 때, 목록 화면에는 제목, 작성자, 카테고리, 추천 수만 필요한데 **게시글 본문(`content` VARCHAR 5000/TEXT) 필드까지 전부 DB에서 SELECT하여 DTO로 내보내는 문제**입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- JPA Repository에서 목록 조회 시 `Post` 엔티티 전체를 SELECT 하거나 `PostListResponse` DTO 생성 시 본문(`content`)을 포함하는 DTO projections 미분리로 인해 발생합니다. +- 본문 내에 수천 자의 장문이 포함되어 있으면 목록 쿼리 1번당 전송 데이터 크기(Payload Size)가 **수십 KB ➔ 수 MB로 폭증**합니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 사용자가 모바일 또는 3G/4G 환경에서 게시글 목록을 스크롤(무한 스크롤 / 페이징)할 때 로딩 지연 및 모바일 데이터 소모 폭증. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +```sql +-- 목록 10개 조회 쿼리 실행 시 (불필요한 content 컬럼 포함!) +SELECT post_id, public_id, title, content, view_count, like_count, created_at FROM post WHERE category_id = 1; +-- 10개 글 본문(content) 합계 500KB 데이터가 매 페이징마다 DB -> API 서버 -> 클라이언트로 낭비 전송됨 +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **DB 메모리/네트워크 낭비**: DB Buffer Pool 메모리 낭비 및 네트워크 대역폭(Bandwidth) 고갈. +- **클라이언트 로딩 지연**: 목록 화면을 열 뿐인데 유저 휴대폰 메모리와 데이터 소모가 급증함. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: 엔티티 전체 조회 `SELECT p FROM Post p` +- **한계점**: LOB/TEXT 컬럼의 지연 로딩이 기본 적용되지 않아 불필요한 IO가 매번 발생함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 목록 전용 DTO (`PostListResponse`)를 별도로 정의해야 하는 DTO 파편화 오버헤드. +- **서비스 레벨 극복 방안 (목록 DTO 경량화)**: 목록 DTO에 본문을 아예 제외(`content` 제거)하고 제목은 최대 40자 자름(Truncate) 처리하여 UI 렌더링 속도 최적화. +- **아키텍처 레벨 극복 방안 (JPQL/Querydsl DTO Projections)**: + - `SELECT new PostListResponse(p.publicId, p.title, p.likeCount...) FROM Post p` 방식을 적용하여 DB 레벨에서 `content` 컬럼 자체를 SELECT 하지 않도록 DB I/O를 원자적 차단. + +--- + +## 2. 💥 카테고리별 게시글 목록 페이징 조회의 Count Query N+1 및 Index Scan 타임아웃 + +### ① 개념 (What - 문제의 명확한 정의) +게시글 목록 페이징(`Page`) 조회 시, Spring Data JPA의 `Pageable`을 사용할 때 **전체 게시글 수(`COUNT(*)`)를 세는 카운트 쿼리가 매 페이징 요청마다 DB 테이블 전체를 스캔**하여 일어나는 성능 저하 현상입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- JPA `PageRequest` 사용 시 Hibernate는 데이터 10건 조회 쿼리 1번 + 전체 개수 계산 `SELECT COUNT(p) FROM Post p WHERE p.category = :category` 쿼리 1번을 내보냅니다. +- `post` 테이블에 `category_id + created_at` 복합 인덱스가 없으면, 카운트 쿼리가 **테이블 풀 스캔(Full Table Scan)**을 일으킵니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 게시글 데이터가 10만 건 이상 쌓인 상태에서 10페이지, 100페이지 등 높은 페이지 번호(Offset Paging)를 넘길 때. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +```sql +-- 1. 데이터 10건 조회 (Fast) +SELECT * FROM post WHERE category_code = 'FREE' ORDER BY created_at DESC LIMIT 10 OFFSET 1000; +-- 2. 전체 Count 쿼리 (Slow - 10만 건 Full Scan!) +SELECT COUNT(*) FROM post WHERE category_code = 'FREE'; -- 2초 소요! +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **DB CPU 100% 점유**: 100명의 유저가 탭을 전환하면 `COUNT(*)` 쿼리 100개가 DB CPU를 100% 점유하여 전체 서비스 마비. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: `Page` 기본 페이징 반환. +- **한계점**: 무조건 `COUNT(*)`를 실행하므로 데이터가 쌓일수록 성능이 선형적으로 저하됨. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 전체 페이지 번호(1, 2, 3... 10)를 보여주는 UI 대신 `더보기` 버튼(Slice 페이징)으로 전환해야 함. +- **서비스 레벨 극복 방안 (Slice 무한 스크롤 UI)**: 모바일/웹 목록 UI를 페이지 번호 방식에서 `Slice` 기반 [더보기 / 무한 스크롤] UI로 전환. +- **아키텍처 레벨 극복 방안 (Slice 페이징 & Covering Index)**: + - `Page` 대신 `Slice`를 사용하여 `COUNT(*)` 쿼리 자체를 100% 제거(`limit + 1` 조회 방식). + - DB에 `idx_category_created_at(category_id, created_at DESC)` 커버링 인덱스를 생성하여 Index Only Scan 유도. + +--- + +## 3. 💥 게시글 수정/삭제 시 작성자 검증 인가(Authorization) 누락 및 IDOR 취약점 + +### ① 개념 (What - 문제의 명확한 정의) +회원이 작성한 일반 게시글을 수정/삭제할 때, 로그인된 유저가 **해당 게시글의 실제 작성자 본인인지 또는 관리자(`ROLE_ADMIN`)인지 검증하지 않고** `publicId`만 알면 타인의 글을 임의로 수정/삭제할 수 있는 보안 취약점입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- [`PostService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java) `updatePost()` / `deletePost()`에서 `post.getMember().getPublicId().equals(userDetails.getPublicId())` 대조 로직이 누락되거나 null 검증 조건이 뚫릴 때 발생합니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 인증된 유저 A가 Postman이나 브라우저 개발자 도구(F12)에서 유저 B가 쓴 게시글의 `publicId`를 파라미터로 넣어 `PUT /api/posts/{publicId}`를 호출할 때. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[User A (Hacker)] PUT /api/posts/p9999 (User B's Post) + └── PostService.updatePost() 진입 + └── 작성자 대조 검증 없이 post.updateTitleAndContent() 실행! + └── User B의 글이 User A에 의해 강제 변조됨! (IDOR 보안 참사) +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **데이터 변조 & 악성 스팸**: 타인의 글을 삭제하거나 비하/광고성 내용으로 강제 변경하는 심각한 보안 사고 발생. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: 어노테이션 `@PreAuthorize("isAuthenticated()")` 만 사용. +- **한계점**: "로그인 여부"만 검증할 뿐 "글 작성자 본인 여부"를 검증하지 못함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 매 수정/삭제 시마다 DB에서 작성자 ID를 대조해야 하는 인가 연산 오버헤드. +- **서비스 레벨 극복 방안 (버튼 숨김 렌더링)**: 프론트엔드 상세 페이지에서 작성자 본인 및 관리자가 아닌 경우 [수정], [삭제] 버튼 자체를 렌더링하지 않음. +- **아키텍처 레벨 극복 방안 (백엔드 도메인 인가 검증)**: + - `PostService` 내에 `validatePostOwnerOrAdmin(post, userDetails)` 도메인 검증 메서드를 공통화하고, 불일치 시 `403 Forbidden (ErrorCode.ACCESS_DENIED)` 예외를 즉시 던져 백엔드 단에서 물리 차단. + +--- + +## 4. 💥 회원글 ➔ 익명글 (또는 그 반대) 카테고리 변경 시 작성자 정보 정합성 오염 및 비밀번호 유실 문제 + +### ① 개념 (What - 문제의 명확한 정의) +게시글 수정(`PUT /api/posts/{publicId}`) 시 유저가 카테고리를 일반 카테고리(`FREE`)에서 익명 카테고리(`ANONYMOUS`)로 변경하거나 그 반대로 변경할 때, **`is_anonymous` 플래그와 `member_id`, `anonymous_password` 데이터 간의 상태 꼬임(State Corruption) 현상**입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- 게시글 작성 시에는 `isAnonymous`에 따라 `member`가 저장되거나 `anonymousPassword`가 저장됩니다. +- 그러나 게시글 수정 시 카테고리 코드(`categoryCode`)를 바꾸면서 `isAnonymous` 상태 변경에 따른 기존 `member` 매핑 해제 처리나 `anonymousPassword` BCrypt 재암호화 처리가 캡슐화되어 있지 않으면 상태가 파괴됩니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 유저가 자유게시판(`FREE`)에 쓴 글을 나중에 익명게시판(`ANONYMOUS`)으로 수정 이동하거나, 익명글을 회원글로 수정 이동할 때. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[회원글 -> 익명글 수정 시] +- is_anonymous = true 로 변경되었으나, member_id (FK) 가 여전히 연관되어 있어 DB 상에서 작성자 유저 정보가 그대로 노출됨! +[익명글 -> 회원글 수정 시] +- is_anonymous = false 로 변경되었으나, member_id 가 null 로 남아 작성자 없는 유령 글 발생! +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **익명성 파괴 보안 사고**: 익명글로 바꿨는데 DB에 작성자 회원의 `member_id`가 남아 익명성이 파괴되거나, 유령 글이 되어 삭제 불가능 상태 발생. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: DTO 필드를 엔티티에 덮어쓰는 `post.setTitle(...)`, `post.setCategory(...)` +- **한계점**: 엔티티 불변식(Invariant)을 지키지 못함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 작성 후 카테고리 변경 시 익명/일반 간의 전환 제약이 필요함. +- **서비스 레벨 극복 방안 (카테고리 이동 정책 제한)**: 익명게시판(`ANONYMOUS`)과 일반게시판(`FREE`, `QNA`) 간의 카테고리 변경 작성을 서비스 정책상 금지하고 안내 문구 노출. +- **아키텍처 레벨 극복 방안 (도메인 카테고리 변경 검증)**: + - `Post.java` 도메인 엔티티 내에 `changeCategory(PostCategory newCategory)` 메서드를 만들고, 익명 ↔ 일반 카테고리 간의 전환 시도가 들어오면 `400 Bad Request (ErrorCode.INVALID_INPUT, "익명게시판과 일반게시판 간 카테고리 변경은 불가능합니다.")` 예외를 던져 백엔드 차원에서 원자적 차단. + +--- + +## 5. 💥 `PostReaction` 추천/비추천 투표 시 계정당 1회 독자 투표 업데이트의 Race Condition 및 DB Deadlock + +### ① 개념 (What - 문제의 명확한 정의) +유저가 추천(LIKE)과 비추천(DISLIKE)을 빠르게 번갈아 누르거나 동시 클릭할 때, 복합 유니크 인덱스(`uk_post_member_type`)에도 불구하고 **DB 트랜잭션 교착 상태(Deadlock) 및 데이터 충돌**이 발생하는 동시성 문제입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- 오늘 `PostReaction` DB 제약 조건을 `UNIQUE (post_id, member_id, type)`로 변경하여 유저 1명이 추천 1건 + 비추천 1건을 각각 가질 수 있게 만들었습니다. +- 유저가 추천 클릭과 비추천 클릭을 동시에 내보내면, MySQL InnoDB는 두 트랜잭션에서 `post_reaction` 유니크 인덱스 페이지 락(Index Page Lock)을 획득하는 과정에서 **Circular Dependency (순환 대기) Deadlock**을 유발할 수 있습니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 클라이언트 단에서 추천 버튼과 비추천 버튼을 동시에 클릭하거나, 2개의 브라우저 탭에서 동일 계정으로 추천/비추천을 연타할 때. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[Tx 1 (추천)] INSERT INTO post_reaction (post_id=1, member_id=5, type='LIKE') -> Index Lock 획득 대기 +[Tx 2 (비추천)] INSERT INTO post_reaction (post_id=1, member_id=5, type='DISLIKE') -> Index Lock 획득 대기 + -> MySQL InnoDB Deadlock Detector 발동 -> Deadlock found when trying to get lock; try restarting transaction (500 Server Error!) +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **500 Internal Server Error 발생**: DB 데드락 발생 시 사용자 화면에 500 에러 페이지가 뜸. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: `@UniqueConstraint` 선언만 적용. +- **한계점**: 동시 INSERT 시 발생하는 DB 인덱스 데드락을 100% 방지할 수 없음. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 버튼 연타 시 프론트엔드에서 클릭을 잠시 차단해야 함. +- **서비스 레벨 극복 방안 (Debounce / Throttle)**: 프론트엔드 버튼 클릭 시 300ms 디바운스(Debounce) 및 로딩 Spinner를 적용하여 동시 클릭을 시각적으로 100% 차단. +- **아키텍처 레벨 극복 방안 (CannotAcquireLockException Catch & Retry)**: + - 백엔드 `PostService.reactToPost()`에서 `CannotAcquireLockException` 또는 `DeadlockLoserDataAccessException` 예외를 Catch 하여 409 Conflict 또는 3회 자동 재시도(Spring Retry) 로직을 적용하여 500 에러 방지. + +--- + +# 📌 PART 2. 작업 완료 및 파일 위치 안내 + +* **생성된 마스터 스터디 가이드 경로**: + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studySprint02PostDomainIssues260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studySprint02PostDomainIssues260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studySprint02PostDomainIssues260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint02PostDomainIssues260821.md) +* **`AGENTS.md` 작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/study/studySprint02BoardIssuesAndSolutions260821.md b/docs/study/studySprint02BoardIssuesAndSolutions260821.md new file mode 100644 index 0000000..565d5d4 --- /dev/null +++ b/docs/study/studySprint02BoardIssuesAndSolutions260821.md @@ -0,0 +1,593 @@ +# 📚 [Master Study Guide] Sprint 02 커뮤니티 게시판 5대 아키텍처 문제점 & 10대 대체 기술(Alternatives) 7대 필수 요소 상세 가이드 (2026-08-21) + +> **노션(Notion) 복사용 및 백엔드 기술 면접 / 시스템 아키텍처 심화 학습용 마스터 가이드** +> 본 문서는 Snowthing 커뮤니티 도메인(게시글 Post & 댓글/대댓글 Comment) 구축 시 발생할 수 있는 **5대 핵심 아키텍처 문제점**과 **10대 대체 기술(Alternatives)**에 대해, **7대 필수 서술 요소 체계(개념, Why, When, How 코드/SQL, Pros, Cons & Trade-off, 서비스/아키텍처 레벨 극복 방안)** 중 **극복 방안(Mitigation)을 물리적 원리와 코드 수준으로 파헤쳐 수록한 완성판 학습 문서**입니다. + +--- + +# 📑 PART 1. 5대 핵심 문제점 심층 파헤치기 (문제 & 극복 방안 딥다이브) + +--- + +## 1. 💥 [댓글] 이미 삭제된 댓글 재삭제 시 `comment_count` 음수 차감 및 카운터 정합성 오염 문제 + +### ① 개념 (What - 문제의 명확한 정의) +Soft Delete(논리 삭제) 처리된 댓글에 대해 동시 요청(Race Condition)이나 무효한 삭제 요청이 들어왔을 때, 게시글 엔티티의 역정규화 컬럼인 `post.comment_count`가 계속 차감되어 **수치가 0 미만인 `-1`, `-2`로 오염되는 정합성 파괴 현상**입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java) `deleteComment()` 메서드에서 `comment.softDelete()` 호출 후 `post.decreaseCommentCount()`를 실행합니다. +- 트랜잭션 격리 수준(Read Committed) 환경에서 동일한 댓글 삭제 요청이 동시에 2건 들어오면, Thread 1과 Thread 2가 모두 `comment.isDeleted() == false` 상태를 읽게 됩니다. +- Thread 1이 먼저 `comment.softDelete()` 후 `comment_count`를 1 ➔ 0으로 차감하고 COMMIT 되더라도, 이미 검증을 통과한 Thread 2가 뒤이어 `comment_count`를 0 ➔ -1로 차감하여 쿼리를 전송하므로 음수가 발생합니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 클라이언트 네트워크 지연으로 사용자가 삭제 버튼을 빠른 속도로 연타(광클)할 때 +- 관리자 댓글 강제 삭제 API와 일반 유저의 삭제 요청이 동시에 백엔드로 인커밍될 때 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[Thread 1] SELECT * FROM comment WHERE id = 1 (is_deleted = false) +[Thread 2] SELECT * FROM comment WHERE id = 1 (is_deleted = false) +[Thread 1] UPDATE comment SET is_deleted = true WHERE id = 1 +[Thread 1] UPDATE post SET comment_count = comment_count - 1 WHERE id = 10 (1 -> 0) -> COMMIT +[Thread 2] UPDATE comment SET is_deleted = true WHERE id = 1 +[Thread 2] UPDATE post SET comment_count = comment_count - 1 WHERE id = 10 (0 -> -1) -> COMMIT [음수 오염!] +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **비즈니스 결함**: 게시판 목록 조회 시 댓글이 0개임에도 `댓글 [-1]`로 노출되어 사용자 서비스 신뢰도 실추. +- **DB 쿼리 오류**: 댓글 수 정렬(`ORDER BY comment_count DESC`) 쿼리 실행 시 정렬 순서가 꼬여 인기 게시글 추출 알고리즘이 파괴됨. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: 자바 서비스 메서드 내 `if (comment.isDeleted()) throw ...` 단순 예외 검사. +- **한계점**: 동시성 멀티 스레드 환경에서는 SELECT 시점의 스냅샷이 동일하므로 자바 `if` 문 검사가 무용지물이 됨. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 단순 자바 로직 방어가 불가능하므로 DB 레벨의 제약 조건이나 원자적 SQL 연산으로 이관해야 하는 오버헤드 발생. +- **서비스 레벨 극복 방안 (UX 폴백)**: + - 프론트엔드 댓글 카운트 렌더링 시 `Math.max(0, count)` 처리로 만에 하나 백엔드 오염이 발생하더라도 유저 화면에는 `-1`이 아닌 `0`으로 표시되도록 사용자 시각 차단 폴백을 적용합니다. +- **아키텍처 레벨 극복 방안 (엔티티 캡슐화 & DB Constraint)**: + - 1차적으로 `Post` JPA 엔티티 내 도메인 메서드 `decreaseCommentCount()` 내부에 `this.commentCount = Math.max(0, this.commentCount - 1)` 방어 로직을 캡슐화합니다. + - 2차적으로 DB `POST` 테이블에 `ALTER TABLE post ADD CONSTRAINT chk_post_comment_count CHECK (comment_count >= 0)` DDL 제약 조건을 추가하여, DB 엔진이 커밋 시점에 음수 업데이트 시도를 물리적으로 거부하고 예외를 내도록 이중 방어망을 구축합니다. + +--- + +## 2. 💥 [게시글] 인기 글 상세 조회 시 `increaseViewCount()` 쓰기 락(Row Lock) 병목 문제 + +### ① 개념 (What - 문제의 명확한 정의) +유저가 게시글 상세 페이지를 읽을 때마다 동기 트랜잭션(`@Transactional`) 내에서 `UPDATE post SET view_count = view_count + 1` 쓰기 쿼리가 날아가 DB 쓰기 병목(Lock Contention)이 발생하는 현상입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- RDBMS(MySQL InnoDB)는 단일 행(Row)에 대한 `UPDATE` 쿼리 실행 시 해당 행에 배타적 쓰기 락(Exclusive Row Lock, X-Lock)을 겁니다. +- 읽기(Read) 요청임에도 불구하고 쓰기 락이 발생하여, 동시 진입한 수천 개의 트랜잭션이 동일한 게시글 Row Lock을 획득하기 위해 줄을 서서 대기합니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 메인 화면에 노출된 인기 핫딜, 긴급 공지사항, 리조트 실시간 제보 글 등 특정 핫 게시글에 수천 명의 동접자가 동시에 클릭할 때 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[유저 1000명 동시 요청] GET /api/posts/{publicId} + └── PostService.getPostDetail() 진입 (@Transactional) + └── DB Connection Pool 1000개 고갈 + └── UPDATE post SET view_count = view_count + 1 WHERE post_id = 1 (Row Lock 대기) + └── 5초 후 DB Connection Timeout 예외 발생 -> 504 Gateway Timeout +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **캐스케이딩 장애 (Cascading Failure)**: 인기 글 1개의 조회수 락 병목으로 인해 DB 커넥션 풀이 고갈되어, 로그인, 게시글 작성 등 서비스 전체 API가 마비됨. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: JPA `@Modifying @Query` Bulk Update 호출. +- **한계점**: 영속성 컨텍스트 스냅샷 비교는 줄였지만, DB InnoDB Row Lock 형성 자체를 피할 수는 없음. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 조회수를 실시간으로 DB에 동기 기록하는 아키텍처를 포기해야 함. +- **서비스 레벨 극복 방안 (가용성 우선 정책)**: + - 조회수 반영에 10분의 미세한 시차가 발생하더라도, 유저가 글을 읽을 때 페이지 로딩 속도를 최우선으로 확보하는 가용성(Availability) 우선 서비스 정책을 수립합니다. +- **아키텍처 레벨 극복 방안 (Redis 쓰기 격리 & Write-Back 배치)**: + - 유저가 글을 읽을 때 DB `UPDATE` 쿼리를 100% 제거하고, Redis `INCR post:view_count:{id}` 명령으로 인메모리 단에서 조회수만 가산합니다. + - 백그라운드 스프링 `@Scheduled(cron = "0 */10 * * * *")` 스케줄러가 Redis에 누적된 수치를 읽어 10분마다 DB `post.view_count` 컬럼으로 일괄 Bulk Write-Back (`UPDATE post SET view_count = view_count + :incr`)을 수행함으로써 DB Row Lock 형성 자체를 완전히 분리합니다. + +--- + +## 3. 💥 [추천 비동기] `@Async` 비동기 카운터 유실 시 투표 이력과 카운트 수치 불일치 문제 + +### ① 개념 (What - 문제의 명확한 정의) +추천 투표 시 `post_reaction` 테이블 저장은 성공적으로 COMMIT 되었으나, 비동기로 카운터를 올리는 `@Async` 핸들러가 예외나 서버 셧다운으로 유실될 때 데이터 불일치가 남는 현상입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- 메인 트랜잭션 Thread는 `reactionRepository.save()` 후 DB COMMIT을 치고 즉시 200 OK를 응답합니다. +- 스프링의 `@Async` 비동기 스레드 풀에서 실행되는 [`PostReactionEventListener`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/post/event/PostReactionEventListener.java)가 실행 중 DB 락 타임아웃이나 OOM, 서버 재부팅을 만나면 카운트 `UPDATE` 쿼리가 날아가지 못하고 사라집니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 서버 배포 시점, 서버 셧다운, DB 일시적 네트워크 흔들림 또는 비동기 스레드 풀(Thread Pool) 큐가 가득 찼을 때 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[Main Thread] post_reaction INSERT (post_id=1, member_id=5, type='LIKE') -> COMMIT 완료 +[Main Thread] eventPublisher.publishEvent() -> 200 OK 응답 +[Async Thread] @Async handleEvent() 실행 중 DB Timeout 터짐 -> UPDATE post SET like_count = like_count + 1 실패! +[결과] post_reaction 에는 1건 존재하나, post.like_count는 0으로 동기화 실패 (데이터 정합성 파괴) +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **사용자 경험(UX) 악화**: 유저는 "추천을 눌렀는데 화면 숫자가 안 오른다"고 생각하여 재투표를 시도하지만, DB 유니크 제약으로 409 Conflict 예외가 터져 혼란 야기. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: `@Async` 백그라운드 단순 이벤트 발행. +- **한계점**: JVM 인메모리 큐에 보관되므로 서버 재부팅 시 이벤트가 100% 영구 유실됨. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 단순 비동기 이벤트 대신 DB 기반 아웃박스 테이블이나 스케줄러 배치를 도입해야 함. +- **서비스 레벨 극복 방안 (Optimistic UI 렌더링)**: + - 백엔드의 처리 시차나 유실에 관계없이 프론트엔드에서 추천 버튼 클릭 즉시 추천 버튼 색상을 바꾸고 숫자 수치를 +1 가산하여 유저가 지연을 느끼지 않도록 처리합니다. +- **아키텍처 레벨 극복 방안 (Transactional Outbox & 새벽 정정 배치)**: + - 1차적으로 Transactional Outbox Pattern을 채택하여 `outbox` 테이블에 메인 트랜잭션과 동일 커밋을 수행함으로써 이벤트 유실을 방지합니다. + - 2차 보완으로 매일 새벽 4시마다 `ScheduledReckoningBatch`를 실행하여 `SELECT COUNT(*) FROM post_reaction WHERE post_id = :id AND type = 'LIKE'` 수치와 `post.like_count` 수치를 비교하고, 다를 경우 올바른 수치로 자동 보정하여 100% 최종 일관성(Eventual Consistency)을 달성합니다. + +--- + +## 4. 💥 [대댓글] 3차 이상 무한 깊이 대댓글 작성 시 프론트엔드 UI 파괴 문제 + +### ① 개념 (What - 문제의 명확한 정의) +대댓글의 대댓글(3차), 4차, 10차 대댓글 작성 시 프론트엔드의 고정 들여쓰기(`marginLeft`)로 인해 모바일 및 웹 화면 우측 밖으로 본문 텍스트가 삐져나가는 UI 깨짐 현상입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- 백엔드 [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java)에서 `parentId` 검증 시 부모가 '원댓글(1차)'인지 '대댓글(2차)'인지 검사하는 depth 제한 로직이 누락됨. +- 프론트엔드는 계층 구조에 따라 `marginLeft = depth * 1.5rem`으로 스타일을 렌더링함. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 악의적인 사용자가 대댓글의 ID를 부모로 삼아 N차 대댓글을 연속 작성하거나 타사 스크립트로 API를 직접 호출할 때 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +10차 대댓글 작성 -> depth = 10 + └── 프론트엔드:
+ └── 모바일 화면 폭(360px) 중 본문 영역이 120px로 축소됨 -> 1글자씩 세로 줄바꿈 및 화면 우측 이탈 +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **서비스 사용 불능**: 모바일 사용자가 게시글 및 댓글을 정상적으로 읽을 수 없어 서비스 가독성 파괴. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: 부모 존재 여부(`parentId != null`)만 검증. +- **한계점**: 부모 댓글의 부모가 존재하는지(2차 깊이 이상인지) 검증하지 않아 무한 깊이 생성을 막지 못함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 3차 이상의 깊은 토론 스레드 작성을 제한해야 함. +- **서비스 레벨 극복 방안 (대댓글 폼 안내 & Toast 알림)**: + - 프론트엔드 댓글 입력창에서 대댓글의 [답글 달기] 버튼을 누를 경우 "대댓글에는 추가 답글을 작성할 수 없습니다."라는 안내 Toast 메세지를 노출하여 작성을 사전 유도 차단합니다. +- **아키텍처 레벨 극복 방안 (백엔드 2단계 Depth Validation)**: + - [`CommentService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java) `createComment()` 메서드 진입 시 `if (parent != null && parent.getParent() != null)` 검증 로직을 추가합니다. + - 부모 댓글(`parent`)이 이미 부모(`parent.getParent()`)를 가지고 있는 2차 계층 이상이라면 `CustomAuthException(ErrorCode.INVALID_INPUT, "대댓글에는 추가 답글을 작성할 수 없습니다.")` 예외(400 Bad Request)를 던져 백엔드 API 수준에서 물리적으로 원자적 차단합니다. + +--- + +## 5. 💥 [보안] 익명 비밀번호 URL 쿼리 스트링 평문 노출 보안 위험 문제 + +### ① 개념 (What - 문제의 명확한 정의) +비회원 익명 게시글/댓글 삭제 시 `DELETE /api/posts/{publicId}?anonymousPassword=1234` 형태처럼 URL 쿼리 파라미터로 비밀번호가 전송되어 웹 서버 로그에 비밀번호가 평문 저장되는 보안 문제입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- HTTP 표준 및 웹 서버(Nginx, Apache, AWS ALB) 구현상, HTTP GET/DELETE 메서드의 URL 쿼리 스트링은 서버 Access Log의 Request Line 항목에 100% 그대로 로깅됩니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 비회원 익명 사용자가 자신이 쓴 글이나 댓글을 삭제하기 위해 비밀번호를 입력하고 삭제를 요청할 때마다 항상 발생 + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +Client: DELETE /api/posts/p1024?anonymousPassword=secretPass123 + └── Nginx access.log 기록: + "192.168.1.10 - - [21/Aug/2026:17:00:00] "DELETE /api/posts/p1024?anonymousPassword=secretPass123 HTTP/1.1" 200 45" + └── 로그 파일 조회자에게 비회원 비밀번호 완전 노출! +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **보안 컨플라이언스 위반**: 비밀번호 평문 로깅으로 인한 개인정보보호법 위반 및 서버 로그 유출 시 타 계정 도용 2차 피해. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: DB 저장 시 BCrypt 암호화 저장. +- **한계점**: DB 저장은 안전하지만, 네트워크 전송 구간 및 Nginx 웹 서버 로그 단에서의 비밀번호 노출을 막지 못함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: URL 파라미터 전송 대신 커스텀 헤더나 무상태 토큰 방식을 적용해야 하므로 프론트엔드 연동 복잡도 증가. +- **서비스 레벨 극복 방안 (삭제 모달 폼 보안 전송)**: + - 삭제 모달 팝업에서 비밀번호 입력 시 `type="password"` 상태로 암호화 입력을 보장하고, URL 쿼리 스트링 생성을 아예 프론트엔드 단에서 금지합니다. +- **아키텍처 레벨 극복 방안 (HTTP Custom Header & HMAC 일회용 삭제 토큰)**: + - **방안 1**: 전송 방식을 HTTP Custom Header (`X-Anonymous-Password`)로 전환하고 Nginx `log_format` 설정에서 해당 헤더 로깅을 제외하여 로그 평문 노출을 차단합니다. + - **방안 2**: 무상태 HMAC-SHA256 일회용 삭제 토큰(`generateDeleteToken`) 방식을 도입하여 DB에 비밀번호 컬럼 자체가 아예 존재하지 않는 무상태 보안 검증 구조를 완성합니다. + +--- + +# 📑 PART 2. 10대 대체 기술(Alternatives) 7대 필수 요소 심층 분석 (극복 방안 딥다이브) + +--- + +## 1. 💥 [댓글] 이미 삭제된 댓글 재삭제 이슈의 2대 대안 + +### 1-1. 대체 대안 A: DB Atomic SQL 함수 (`GREATEST`) 사용 + +#### ① 개념 (What) +JPA 영속 상태 변경 방식 대신, MySQL의 `GREATEST(0, comment_count - 1)` SQL 함수를 내보내 DB 엔진 단에서 차감 결과가 0 미만으로 내려가지 않도록 원자적 방어를 수행하는 기법입니다. + +#### ② 왜 사용하는지 (Why) +JPA 메모리 연산 방식(`post.setCommentCount(count - 1)`)은 동시 요청 시 Dirty Read로 음수 차감이 터질 수 있으므로, DB 엔진의 Single Thread SQL 실행 메커니즘을 이용하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +재고 차감(0개 미만 불가), 포인트 차감(0원 미만 불가), 카운터 감소 등 하한선이 명확한 차감 연산에 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +// PostRepository.java +@Modifying(clearAutomatically = true, flushAutomatically = true) +@Query("UPDATE Post p SET p.commentCount = GREATEST(0, p.commentCount - 1) WHERE p.id = :postId") +void decreaseCommentCountAtomic(@Param("postId") Long postId); +``` + +#### ⑤ 장점 (Pros) +- **음수 차감 물리적 100% 방지**: MySQL 엔진이 Single Thread로 쿼리를 내보내므로 동시 요청이 10,000건 들어와도 0 아래로 내려가지 않음. +- **영속성 스냅샷 비교 생략**: JPA 1:1 엔티티 스냅샷 비교 과정이 없어서 Execution Time 축소. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **자바 `Math.max(0, count - 1)` 방어 대비**: 자바 메모리 방어는 멀티 스레드 동시 진입 시 이미 생성된 UPDATE 쿼리를 막지 못하지만, SQL `GREATEST`는 DB 엔진 단에서 원자적 처리됨. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (JPA 1차 캐시 불일치)**: DB 컬럼은 차감되었으나 JPA 1차 캐시 엔티티 객체의 `commentCount` 수치는 갱신되지 않는 불일치 발생. +- **서비스 레벨 극복 방안**: 댓글 삭제 응답 반환 시 개별 엔티티 수치 대신 백엔드가 방금 갱신한 정정 수치를 반환하거나 최신 목록 API를 다시 호출하도록 유도. +- **아키텍처 레벨 극복 방안 (JPA Flush & Clear)**: `@Modifying(clearAutomatically = true, flushAutomatically = true)` 옵션을 부여하여 쿼리 실행 직후 JPA 영속성 컨텍스트를 DB로 `flush()`하고 1차 캐시를 자동으로 `clear()` 함으로써 이후 조회 쿼리가 DB의 최신 `comment_count` 수치를 패치하도록 완전 동기화. + +--- + +### 1-2. 대체 대안 B: 스케줄러 기반 비동기 카운터 재계산 (Scheduled Reconciliation) + +#### ① 개념 (What) +댓글 작성/삭제 시 DB 카운터를 즉시 변경하지 않고, 주기적인 백그라운드 스케줄러가 실시간 `SELECT COUNT(*)` 집계 쿼리를 돌려 게시글의 `comment_count`를 일괄 정정 덮어쓰는 기법입니다. + +#### ② 왜 사용하는지 (Why) +카운터 증감 연산 자체를 이관하여 쓰기 락 병목과 음수 오염 가능성을 근본 제거하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +실시간 카운트 정확도보다 DB 쓰기 성능 및 안정성이 훨씬 중요한 대규모 커뮤니티에 적합합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +@Scheduled(cron = "0 */5 * * * *") // 5분마다 실행 +@Transactional +public void reconcileCommentCounts() { + Set dirtyPostIds = redisTemplate.opsForSet().members("dirty_posts"); + if (dirtyPostIds == null || dirtyPostIds.isEmpty()) return; + + for (String postIdStr : dirtyPostIds) { + Long postId = Long.parseLong(postIdStr); + long actualCount = commentRepository.countByPostIdAndIsDeletedFalse(postId); + postRepository.updateCommentCount(postId, actualCount); + redisTemplate.opsForSet().remove("dirty_posts", postIdStr); + } +} +``` + +#### ⑤ 장점 (Pros) +- **카운터 오류 근본적 해결**: 증감 연산을 하지 않고 실시간 개수를 덮어쓰므로 카운트 누수나 음수 현상이 발생할 수 없음. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **동기 `decreaseCommentCount()` 대비**: 동기 방식은 매 댓글 삭제 시 DB Row Lock을 잡지만, 스케줄러 방식은 삭제 시 Lock을 전혀 잡지 않음. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (최대 5분의 시차 발생 & 주기적 DB I/O 부하)**: 댓글 작성 직후 5분 동안은 화면 상의 댓글 수 수치가 실시간 반영되지 않고, 배치 실행 시 Full Scan 부하가 생김. +- **서비스 레벨 극복 방안 (로컬 State 반영)**: 댓글 작성/삭제 직후 프론트엔드 로컬 State에서 수치를 임시로 +1 / -1 가산하여 렌더링함으로써 유저가 시차를 느끼지 않도록 보완. +- **아키텍처 레벨 극복 방안 (Dirty Set Redis 수집 & 핀포인트 집계)**: 전수 조사의 DB I/O 부하를 막기 위해 댓글 CUD 발생 시 `dirty_posts` Redis Set에 `postId`를 수집하고, 스케줄러는 해당 Set에 등록된 `postId`에 대해서만 핀포인트 Range 집계 쿼리를 내보내어 DB I/O를 99% 절감. + +--- + +## 2. 💥 [게시글] 인기 글 상세 조회 시 쓰기 락 병목 이슈의 2대 대안 + +### 2-1. 대체 대안 A: Redis HyperLogLog (`PFADD`) 기반 고성능 카운팅 & 중복 제거 + +#### ① 개념 (What) +Redis의 확률적 자료구조인 HyperLogLog(`PFADD`, `PFCOUNT`)를 활용하여, 단 12KB 메모리만으로 중복 조회를 인메모리 $O(1)$로 차단하고 조회수를 카운팅하는 기법입니다. + +#### ② 왜 사용하는지 (Why) +단순 카운터나 RDBMS에 중복 IP 테이블을 만들어 저장하면 메모리와 DB 용량이 폭증합니다. HyperLogLog는 100만 건의 중복 IP를 단 12KB 메모리로 추산하므로 공간 효율성이 최상입니다. + +#### ③ 어떨 때 사용하는지 (When) +대규모 트래픽 서비스의 게시글 조회수, 방문자 수(UV) 집계 및 중복 조회 방지에 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +public void increaseViewCountWithHyperLogLog(Long postId, String clientIp) { + String redisKey = "post:views:" + postId; + // HyperLogLog에 IP 추가 (새로운 IP면 1 반환, 중복이면 0 반환) + Long added = redisTemplate.opsForHyperLogLog().add(redisKey, clientIp); + + if (added != null && added == 1L) { + redisTemplate.opsForValue().increment("post:view_count:" + postId); + } +} +``` + +#### ⑤ 장점 (Pros) +- **DB Row Lock 100% 제거**: DB에 쓰기 쿼리가 전혀 들어가지 않으므로 동접자가 몰려도 락 병목이 터지지 않음. +- **극도의 메모리 절약**: 100만 명의 IP를 수집해도 무조건 단 12KB 메모리만 사용함. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **Redis Set 자료구조 대비**: Redis Set은 100만 개 IP 저장 시 수십 MB의 메모리가 들지만, HyperLogLog는 12KB로 고정됨. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (0.81%의 확률적 표준오차)**: 확률적 계산 알고리즘 특성상 약 0.81% 미만의 오차가 발생할 수 있음. +- **서비스 레벨 극복 방안**: 커뮤니티 조회 수치는 0.81% 오차(1,000회 기준 약 8회 차이)가 서비스 이용이나 금융 정산 영역이 아니므로 서비스 요구사항을 완전 만족함을 도메인 레벨 수용. +- **아키텍처 레벨 극복 방안 (Redis RDB/AOF & Scheduled Write-Back)**: Redis 메모리 휘발에 대비하여 10분 단위 스케줄러가 Redis 수치를 DB `post.view_count` 컬럼으로 Write-Back 집계 갱신하여 영구 보존. + +--- + +### 2-2. 대체 대안 B: Client-Side Cookie 쿨타임 제한 (24시간 중복 방지) + +#### ① 개념 (What) +유저 브라우저 쿠키(`viewed_posts=1,4,12`)에 읽은 글 ID를 기록하고, 쿠키가 유효한 24시간 동안은 프론트엔드에서 백엔드로 조회수 증가 요청을 아예 보내지 않도록 차단하는 기법입니다. + +#### ② 왜 사용하는지 (Why) +서버 백엔드로 인커밍(Incoming)되는 HTTP 요청 수 자체를 줄여 네트워크 및 서버 CPU 자원을 절약하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +Redis 같은 인메모리 인프라 구축 비용 없이 단순한 웹 서비스에서 조회수 어뷰징을 방지할 때 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```typescript +// Next.js 프론트엔드 컴포넌트 +useEffect(() => { + const viewedPosts = getCookie('viewed_posts') || ''; + if (!viewedPosts.includes(`[${postId}]`)) { + api.post(`/api/posts/${publicId}/views`); + setCookie('viewed_posts', `${viewedPosts}[${postId}]`, { maxAge: 86400 }); + } +}, [postId]); +``` + +#### ⑤ 장점 (Pros) +- **서버 요청 수 급감**: 동일 유저의 재방문 요청이 백엔드까지 도착하지 않으므로 트래픽이 획기적으로 줄어듦. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **서버 IP 기반 차단 대비**: 서버 IP 차단은 서버 메모리를 소비하지만, 쿠키 방식은 클라이언트 브라우저 자원을 활용함. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (쿠키 삭제 및 시크릿 창 어뷰징에 취약)**: 유저가 브라우저 쿠키를 삭제하거나 시크릿 모드로 접속하면 중복 카운트가 올라감. +- **서비스 레벨 극복 방안**: 어뷰징 유저가 쿠키를 삭제하더라도 개별 유저의 자발적 행위이므로 시스템 전체 셧다운을 일으키지 않는 수준에서 허용. +- **아키텍처 레벨 극복 방안 (하이브리드 IP Redis 쿨타임)**: 백엔드에서 1차로 Client Cookie를 대조하고 2차로 IP 기반 Redis 10분 쿨타임 키(`view:cooldown:{ip}:{postId}`)를 이중 검증하여 쿠키 삭제 어뷰징을 99% 무력화하는 하이브리드 검증 구축. + +--- + +## 3. 💥 [추천 비동기] `@Async` 비동기 카운터 유실 이슈의 2대 대안 + +### 3-1. 대체 대안 A: Transactional Outbox Pattern (트랜잭셔널 아웃박스 패턴) + +#### ① 개념 (What) +이벤트를 인메모리 스프링 이벤트로 던지지 않고, 메인 비즈니스 로직과 동일한 DB 트랜잭션 안에서 `outbox` 테이블에 이벤트 메시지를 함께 `INSERT`한 뒤, 별도의 메시지 릴레이(Debezium CDC 또는 Polling Publisher)가 읽어서 처리하는 Enterprise 분산 트랜잭션 패턴입니다. + +#### ② 왜 사용하는지 (Why) +JVM 인메모리 비동기 이벤트는 서버가 갑자기 꺼지면 메모리에 있던 이벤트가 100% 유실됩니다. DB 테이블에 이벤트 발행 내역을 함께 기록하여 **최소 1회 전달(At-Least-Once Delivery)**을 물리적으로 보장하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +결제, 결제 후 포인트 적립, 이벤트 카운팅 등 유실되면 안 되는 핵심 비동기 이벤트 처리에 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +@Transactional +public void reactToPost(String publicId, ReactionType type, CustomUserDetails userDetails) { + // 1. 투표 내역 저장 + reactionRepository.save(reaction); + + // 2. 동일 트랜잭션 내에서 outbox 테이블에 이벤트 메시지 저장 (원자성 보장) + outboxRepository.save(new OutboxEvent( + "POST_REACTION", + postId.toString(), + objectMapper.writeValueAsString(new PostReactionEvent(postId, type)) + )); +} +``` + +#### ⑤ 장점 (Pros) +- **이벤트 유실 0%**: 메인 데이터 저장과 이벤트 작성이 동일 DB 트랜잭션으로 묶여 원자성(Atomic)이 보장됨. +- **서버 장애 복구**: 서버가 다운된 후 재시작되어도 `outbox` 테이블에 남아있는 미처리 이벤트를 읽어서 복구 처리. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **스프링 `@Async` 기본 이벤트 대비**: 기본 `@Async`는 메모리 유실 위험이 크지만, Outbox Pattern은 DB 내구성을 이용해 유실을 물리 차단함. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (Outbox 테이블 비대화 및 추가 DB Write I/O)**: 모든 이벤트가 DB에 기록되므로 I/O 부담이 늘어나고 테이블 용량이 커짐. +- **서비스 레벨 극복 방안**: 비동기 처리가 지연되더라도 유저 화면에는 Optimistic UI로 완료 상태를 즉시 표시. +- **아키텍처 레벨 극복 방안 (Outbox Purge Scheduler)**: `outbox` 테이블에 모든 비동기 이벤트가 누적되어 DB 용량이 폭증하고 I/O가 느려지는 현상을 막기 위해, `status = 'PROCESSED'`이면서 생성된 지 1시간이 지난 Outbox 행을 1,000개 단위로 DELETE하는 `OutboxPurgeScheduler` 배치를 구축하여 테이블 사이즈를 작게 유지. + +--- + +## 3-2. 대체 대안 B: 새벽 정정 스케줄러 배치 (Scheduled Reckoning Batch) + +#### ① 개념 (What) +이벤트 유실 가능성을 인정하되, 매일 새벽 트래픽이 적은 시각에 `post_reaction` 테이블의 실제 투표 건수를 `COUNT(*)`로 집계하여 `post.like_count` 수치와 대조 후 다를 경우 일괄 수정하는 정정 배치 기법입니다. + +#### ② 왜 사용하는지 (Why) +복잡한 메시지 큐나 Outbox 패턴 구축 비용 없이, 100% 데이터 정합성을 가장 단순한 코드로 보장하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +실시간 카운트 반영의 밀리초 오차가 서비스 이용에 치명적이지 않은 커뮤니티 서비스에 적합합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +@Scheduled(cron = "0 0 4 * * *") // 매일 새벽 4시 +@Transactional +public void reconcileReactionCounts() { + List posts = postRepository.findAll(); + for (Post post : posts) { + long actualLikes = reactionRepository.countByPostIdAndType(post.getId(), ReactionType.LIKE); + if (post.getLikeCount() != actualLikes) { + post.setLikeCount(actualLikes); // 데이터 정정 + } + } +} +``` + +#### ⑤ 장점 (Pros) +- **구현 단순성**: 추가 인프라 구축 없이 가장 직관적이고 안정적으로 데이터 일관성을 맞출 수 있음. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **Outbox Pattern 대비**: Outbox Pattern은 복잡한 릴레이 스레드가 필요하지만, 정정 배치는 단순 SQL 집계로 완료됨. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (새벽 시간대 DB Read I/O 부하)**: 전수 조사를 돌리면 DB CPU 사용량이 상승함. +- **서비스 레벨 극복 방안**: 새벽 4시는 유저 접속량이 가장 적은 시간대이므로 정정 작업으로 인한 성능 영향을 사용자에게서 격리. +- **아키텍처 레벨 극복 방안 (어제 변경된 게시글 Index Scan 핀포인트)**: 새벽 4시 배치 시 전체 `post` 테이블 Full Scan으로 인한 DB CPU 상승을 막기 위해 `WHERE updated_at >= NOW() - INTERVAL 1 DAY` 조건절을 추가하여 전날 변경이 일어난 게시글만 Index Scan으로 핀포인트 정정. + +--- + +## 4. 💥 [대댓글] 3차 이상 무한 깊이 대댓글 이슈의 2대 대안 + +### 4-1. 대체 대안 A: Flat List + `@Mention` (유튜브 / 인스타그램 1차 평탄화 모델) + +#### ① 개념 (What) +대댓글의 계층형 들여쓰기 자체를 없애고 모든 답글을 원댓글 하위의 평탄한(Flat) 1차 리스트로만 렌더링하며, 누구에게 작성한 답글인지 `@작성자닉네임` 태그로 표시하는 UI/UX 아키텍처입니다. + +#### ② 왜 사용하는지 (Why) +모바일 화면 폭(360px~430px)은 들여쓰기를 3단계만 해도 본문 영역이 좁아져 읽기 불가능해집니다. 이를 근본적으로 해결하기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +유튜브, 인스타그램, 페이스북 등 모바일 웹/앱 트래픽 비중이 80% 이상인 현대 웹 서비스에 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```json +// JSON 반환 구조 +{ + "commentId": 12, + "content": "@댓글보더 저도 그렇게 생각합니다!", + "targetMemberNickname": "댓글보더", + "parentId": 1, // 최상위 원댓글 ID만 유지 + "depth": 1 +} +``` + +#### ⑤ 장점 (Pros) +- **UI 레이아웃 파괴 근본 차단**: 들여쓰기 너비가 0으로 고정되므로 아무리 답글이 많이 달려도 모바일 화면 레이아웃이 절대 깨지지 않음. +- **데이터 구조 단순화**: N차 복잡한 트리를 조립할 필요 없이 1차 리스트만 반환하므로 백엔드 연산이 가벼워짐. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **N차 계층형 트리 대비**: N차 계층형 트리는 복잡한 Recursion 조립과 UI 들여쓰기가 필요하지만, Flat List는 $O(N)$ 단일 루프 반환 가능. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (답글의 구체적 부모 맥락 추적 불분명)**: 누구의 대댓글에 대한 대댓글인지 1:1 스레드 흐름 파악이 계층형보다 다소 모호함. +- **서비스 레벨 극복 방안 (Tooltip 미니 모달 뷰어)**: 1:1 대댓글 스레드 맥락 추적이 모호해지는 단점을 해결하기 위해, `@작성자닉네임` 태그 클릭 시 해당 원본 댓글의 팝업 미니 모달(Tooltip Modal)이 뜨도록 프론트엔드 뷰 연동. +- **아키텍처 레벨 극복 방안**: `targetCommentId` 외래키를 DTO에 포함하여 단 1회 인메모리 Map 조회로 원본 댓글 본문을 즉시 팝업으로 렌더링. + +--- + +### 4-2. 대체 대안 B: CSS Max-Indent Clamp (프론트엔드 들여쓰기 한계선 고정) + +#### ① 개념 (What) +백엔드는 데이터베이스 상에서 N차 대댓글을 허용하되, 프론트엔드 CSS 렌더링 시 `margin-left` 들여쓰기의 최대 한계선을 `clamp` 또는 `min()` 함수로 고정하는 기법입니다. + +#### ② 왜 사용하는지 (Why) +백엔드 도메인 로직 수정 없이 프론트엔드 스타일시트 적용만으로 레이아웃 이탈을 막기 위함입니다. + +#### ③ 어떨 때 사용하는지 (When) +기존 백엔드 API 스펙을 건드리지 않고 빠르게 UI 깨짐을 임시 방어할 때 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```tsx +// Tailwind / Inline Style 적용 +
+ {comment.content} +
+``` + +#### ⑤ 장점 (Pros) +- **백엔드 수정 0건**: 백엔드 코드를 단 한 줄도 수정하지 않고 프론트엔드 뷰만으로 1초 만에 방어할 수 있음. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **백엔드 Depth Validation 대비**: 백엔드 검증은 400 에러를 반환하지만, CSS Clamp는 에러 없이 렌더링 위치만 고정함. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (3차 이상 대댓글 간 시각적 구분 모호)**: 3차 대댓글과 4차, 5차 대댓글의 들여쓰기 위치가 동일해져 계층 구분이 안 됨. +- **서비스 레벨 극복 방안 (답글 뱃지 렌더링)**: 3차 이상 대댓글의 들여쓰기 위치가 같아져 시각적 계층이 모호해지는 점을 보완하기 위해, `depth > 2`인 경우 댓글 상단에 `↳ [3차 답글]` 태그 뱃지(Badge)를 추가 렌더링. +- **아키텍처 레벨 극복 방안**: 백엔드 2단계 깊이 제한Validation(`parent.getParent() != null`)을 병행 적용하여 3차 이상 생성 자체를 예외 차단. + +--- + +## 5. 💥 [보안] 익명 비밀번호 URL 쿼리 스트링 노출 이슈의 2대 대안 + +### 5-1. 대체 대안 A: HTTP Custom Header (`X-Anonymous-Password`) 전달 + +#### ① 개념 (What) +비회원 비밀번호를 URL 쿼리 스트링이 아닌, HTTP 요청 헤더(`X-Anonymous-Password: 1234`)에 포함하여 전달하는 보안 패턴입니다. + +#### ② 왜 사용하는지 (Why) +웹 서버(Nginx, Apache)는 표준 보안 설정상 Request Body와 Custom Header 내용을 Access Log에 기록하지 않고 요청 라인(URL)만 기록하므로, 로그 유출을 물리 차단할 수 있습니다. + +#### ③ 어떨 때 사용하는지 (When) +RESTful API 관례상 `DELETE` 메서드에 Request Body를 실어 보내기 부담스러울 때 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +@DeleteMapping("/api/posts/{publicId}") +public ResponseEntity deletePost( + @PathVariable String publicId, + @RequestHeader(value = "X-Anonymous-Password", required = false) String anonymousPassword) { + postService.deletePost(publicId, anonymousPassword); + return ResponseEntity.noContent().build(); +} +``` + +#### ⑤ 장점 (Pros) +- **웹 서버 로그 유출 100% 차단**: Nginx, ALB, CDN 액세스 로그에 비밀번호 평문 기록이 남지 않음. +- **HTTP 스펙 준수**: `DELETE` 메서드 본문(Body)을 비워두어 일부 엄격한 HTTP 클라이언트 라이브러리와의 호환성 유지. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **URL Query Parameter 대비**: URL Parameter는 웹 서버 로그에 평문 기록되지만, Custom Header는 기록되지 않아 보안상 우월함. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (CORS Preflight Flight 요청 발생)**: 표준 헤더가 아닌 커스텀 헤더(`X-`)를 사용하므로 브라우저가 `OPTIONS` 사전 요청(Preflight)을 보냄. +- **서비스 레벨 극복 방안**: 첫 요청 시 수 밀리초의 Preflight 지연이 발생하지만 지연 시간이 매우 짧으므로 삭제 성공 경험 우선 제공. +- **아키텍처 레벨 극복 방안 (Preflight Caching)**: 브라우저의 CORS Preflight(`OPTIONS`) 요청으로 인한 2배 HTTP 트래픽 발생을 극복하기 위해, Spring Security CORS Configuration에서 `allowedHeaders("X-Anonymous-Password")` 등록 및 `maxAge(3600)`을 지정하여 브라우저가 Preflight 결과를 1시간 동안 메모리에 캐싱하도록 설정. + +--- + +### 5-2. 대체 대안 B: HMAC-SHA256 기반 무상태 일회용 삭제 토큰 (Stateless Delete Token) + +#### ① 개념 (What) +비회원이 글 작성 시 백엔드가 비밀번호를 저장하지 않고, `HMAC-SHA256(publicId + secretKey + password)`로 암호화 서명된 일회용 삭제 토큰(Token)을 발급하여 유저에게 반환하는 무상태 보안 인증 패턴입니다. + +#### ② 왜 사용하는지 (Why) +비밀번호 원본 및 BCrypt 해시조차 DB에 저장하지 않아, DB가 뚫려도 비회원 비밀번호가 유출될 위험이 0%입니다. + +#### ③ 어떨 때 사용하는지 (When) +익명 게시판 보안 수준을 극상으로 끌어올리고 무상태(Stateless) 검증을 꾀할 때 사용합니다. + +#### ④ 어떻게 사용하는지 (How - 구현 코드) +```java +// 작성 시 토큰 발급 +public String generateDeleteToken(String publicId, String rawPassword) { + return HmacUtils.hmacSha256Hex(SECRET_KEY, publicId + ":" + rawPassword); +} + +// 삭제 시 토큰 대조 검증 +public void validateDeleteToken(String publicId, String rawPassword, String clientToken) { + String expectedToken = generateDeleteToken(publicId, rawPassword); + if (!expectedToken.equals(clientToken)) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + } +} +``` + +#### ⑤ 장점 (Pros) +- **DB 보안 극상**: DB에 비밀번호 컬럼 자체가 존재하지 않으므로 데이터베이스 유출 사고 시에도 안전함. + +#### ⑥ 다른 기술과의 비교 (Alternatives) +- **BCrypt DB 저장 대비**: BCrypt 저장은 DB 용량을 차지하고 딕셔너리 공격 대상이 될 수 있으나, HMAC 토큰은 DB 저장이 필요 없는 무상태 검증임. + +#### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프 (서버 Secret Key 유출 시 서명 위조 위험)**: 애플리케이션의 `SECRET_KEY`가 유출되면 토큰 위조가 가능해짐. +- **서비스 레벨 극복 방안**: 토큰 생성 알고리즘이 노출되지 않도록 에러 메시지 캡슐화. +- **아키텍처 레벨 극복 방안 (AWS Secrets Manager & Key Rotation)**: 서버 `SECRET_KEY` 유출 시 토큰 서명 위조 위험을 물리적으로 극복하기 위해, `SECRET_KEY`를 코드나 설정 파일에 하드코딩하지 않고 `AWS Secrets Manager`에 저장하며 30일마다 자동으로 서명 키를 로테이션(Rotation)하고 구버전 키는 7일간 Grace Period를 두어 안전 검증. + +--- + +# 📌 PART 3. 작업 완료 및 파일 위치 안내 + +* **생성된 마스터 스터디 가이드 경로**: + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studySprint02BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studySprint02BoardIssuesAndSolutions260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studySprint02BoardIssuesAndSolutions260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint02BoardIssuesAndSolutions260821.md) +* **`AGENTS.md` 작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/study/studySprint02PostDomainIssues260821.md b/docs/study/studySprint02PostDomainIssues260821.md new file mode 100644 index 0000000..dba738c --- /dev/null +++ b/docs/study/studySprint02PostDomainIssues260821.md @@ -0,0 +1,191 @@ +# 📚 [Master Study Guide] Sprint 02 게시글(Post) 도메인 5대 아키텍처 결함 & 물리적 극복 방안 가이드 (2026-08-21) + +> **노션(Notion) 복사용 및 백엔드 게시글 도메인 심화 학습용 마스터 가이드** +> 본 문서는 Snowthing 게시글(Post) 도메인(댓글 제외) 구축 시 발생할 수 있는 **5대 핵심 아키텍처 문제점과 결함**에 대해, **7대 필수 서술 요소 체계(개념, Why, When, How 코드/SQL, Pros & Cons, 기존 한계점, 서비스/아키텍처 레벨 극복 방안)**를 물리적 메커니즘 수준으로 전수 파헤쳐 정리한 마스터 학습 문서입니다. + +--- + +# 📑 PART 1. 게시글(Post) 도메인 5대 결함 & 7대 필수 요소 심층 분석 + +--- + +## 1. 💥 `POST` 페이징 목록 조회 시 본문 제외 미적용으로 인한 네트워크 트래픽 폭증 및 DB I/O 병목 + +### ① 개념 (What - 문제의 명확한 정의) +게시글 목록 API(`GET /api/posts?page=0&size=10`)를 부를 때, 목록 화면에는 제목, 작성자, 카테고리, 추천 수만 필요한데 **게시글 본문(`content` VARCHAR 5000/TEXT) 필드까지 전부 DB에서 SELECT하여 DTO로 내보내는 문제**입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- JPA Repository에서 목록 조회 시 `Post` 엔티티 전체를 SELECT 하거나 `PostListResponse` DTO 생성 시 본문(`content`)을 포함하는 DTO projections 미분리로 인해 발생합니다. +- 본문 내에 수천 자의 장문이 포함되어 있으면 목록 쿼리 1번당 전송 데이터 크기(Payload Size)가 **수십 KB ➔ 수 MB로 폭증**합니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 사용자가 모바일 또는 3G/4G 환경에서 게시글 목록을 스크롤(무한 스크롤 / 페이징)할 때 로딩 지연 및 모바일 데이터 소모 폭증. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +```sql +-- 목록 10개 조회 쿼리 실행 시 (불필요한 content 컬럼 포함!) +SELECT post_id, public_id, title, content, view_count, like_count, created_at FROM post WHERE category_id = 1; +-- 10개 글 본문(content) 합계 500KB 데이터가 매 페이징마다 DB -> API 서버 -> 클라이언트로 낭비 전송됨 +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **DB 메모리/네트워크 낭비**: DB Buffer Pool 메모리 낭비 및 네트워크 대역폭(Bandwidth) 고갈. +- **클라이언트 로딩 지연**: 목록 화면을 열 뿐인데 유저 휴대폰 메모리와 데이터 소모가 급증함. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: 엔티티 전체 조회 `SELECT p FROM Post p` +- **한계점**: LOB/TEXT 컬럼의 지연 로딩이 기본 적용되지 않아 불필요한 IO가 매번 발생함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 목록 전용 DTO (`PostListResponse`)를 별도로 정의해야 하는 DTO 파편화 오버헤드. +- **서비스 레벨 극복 방안 (목록 DTO 경량화)**: 목록 DTO에 본문을 아예 제외(`content` 제거)하고 제목은 최대 40자 자름(Truncate) 처리하여 UI 렌더링 속도 최적화. +- **아키텍처 레벨 극복 방안 (JPQL/Querydsl DTO Projections)**: + - `SELECT new PostListResponse(p.publicId, p.title, p.likeCount...) FROM Post p` 방식을 적용하여 DB 레벨에서 `content` 컬럼 자체를 SELECT 하지 않도록 DB I/O를 원자적 차단. + +--- + +## 2. 💥 카테고리별 게시글 목록 페이징 조회의 Count Query N+1 및 Index Scan 타임아웃 + +### ① 개념 (What - 문제의 명확한 정의) +게시글 목록 페이징(`Page`) 조회 시, Spring Data JPA의 `Pageable`을 사용할 때 **전체 게시글 수(`COUNT(*)`)를 세는 카운트 쿼리가 매 페이징 요청마다 DB 테이블 전체를 스캔**하여 일어나는 성능 저하 현상입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- JPA `PageRequest` 사용 시 Hibernate는 데이터 10건 조회 쿼리 1번 + 전체 개수 계산 `SELECT COUNT(p) FROM Post p WHERE p.category = :category` 쿼리 1번을 내보냅니다. +- `post` 테이블에 `category_id + created_at` 복합 인덱스가 없으면, 카운트 쿼리가 **테이블 풀 스캔(Full Table Scan)**을 일으킵니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 게시글 데이터가 10만 건 이상 쌓인 상태에서 10페이지, 100페이지 등 높은 페이지 번호(Offset Paging)를 넘길 때. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +```sql +-- 1. 데이터 10건 조회 (Fast) +SELECT * FROM post WHERE category_code = 'FREE' ORDER BY created_at DESC LIMIT 10 OFFSET 1000; +-- 2. 전체 Count 쿼리 (Slow - 10만 건 Full Scan!) +SELECT COUNT(*) FROM post WHERE category_code = 'FREE'; -- 2초 소요! +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **DB CPU 100% 점유**: 100명의 유저가 탭을 전환하면 `COUNT(*)` 쿼리 100개가 DB CPU를 100% 점유하여 전체 서비스 마비. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: `Page` 기본 페이징 반환. +- **한계점**: 무조건 `COUNT(*)`를 실행하므로 데이터가 쌓일수록 성능이 선형적으로 저하됨. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 전체 페이지 번호(1, 2, 3... 10)를 보여주는 UI 대신 `더보기` 버튼(Slice 페이징)으로 전환해야 함. +- **서비스 레벨 극복 방안 (Slice 무한 스크롤 UI)**: 모바일/웹 목록 UI를 페이지 번호 방식에서 `Slice` 기반 [더보기 / 무한 스크롤] UI로 전환. +- **아키텍처 레벨 극복 방안 (Slice 페이징 & Covering Index)**: + - `Page` 대신 `Slice`를 사용하여 `COUNT(*)` 쿼리 자체를 100% 제거(`limit + 1` 조회 방식). + - DB에 `idx_category_created_at(category_id, created_at DESC)` 커버링 인덱스를 생성하여 Index Only Scan 유도. + +--- + +## 3. 💥 게시글 수정/삭제 시 작성자 검증 인가(Authorization) 누락 및 IDOR 취약점 + +### ① 개념 (What - 문제의 명확한 정의) +회원이 작성한 일반 게시글을 수정/삭제할 때, 로그인된 유저가 **해당 게시글의 실제 작성자 본인인지 또는 관리자(`ROLE_ADMIN`)인지 검증하지 않고** `publicId`만 알면 타인의 글을 임의로 수정/삭제할 수 있는 보안 취약점입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- [`PostService.java`](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java) `updatePost()` / `deletePost()`에서 `post.getMember().getPublicId().equals(userDetails.getPublicId())` 대조 로직이 누락되거나 null 검증 조건이 뚫릴 때 발생합니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 인증된 유저 A가 Postman이나 브라우저 개발자 도구(F12)에서 유저 B가 쓴 게시글의 `publicId`를 파라미터로 넣어 `PUT /api/posts/{publicId}`를 호출할 때. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[User A (Hacker)] PUT /api/posts/p9999 (User B's Post) + └── PostService.updatePost() 진입 + └── 작성자 대조 검증 없이 post.updateTitleAndContent() 실행! + └── User B의 글이 User A에 의해 강제 변조됨! (IDOR 보안 참사) +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **데이터 변조 & 악성 스팸**: 타인의 글을 삭제하거나 비하/광고성 내용으로 강제 변경하는 심각한 보안 사고 발생. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: 어노테이션 `@PreAuthorize("isAuthenticated()")` 만 사용. +- **한계점**: "로그인 여부"만 검증할 뿐 "글 작성자 본인 여부"를 검증하지 못함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 매 수정/삭제 시마다 DB에서 작성자 ID를 대조해야 하는 인가 연산 오버헤드. +- **서비스 레벨 극복 방안 (버튼 숨김 렌더링)**: 프론트엔드 상세 페이지에서 작성자 본인 및 관리자가 아닌 경우 [수정], [삭제] 버튼 자체를 렌더링하지 않음. +- **아키텍처 레벨 극복 방안 (백엔드 도메인 인가 검증)**: + - `PostService` 내에 `validatePostOwnerOrAdmin(post, userDetails)` 도메인 검증 메서드를 공통화하고, 불일치 시 `403 Forbidden (ErrorCode.ACCESS_DENIED)` 예외를 즉시 던져 백엔드 단에서 물리 차단. + +--- + +## 4. 💥 회원글 ➔ 익명글 (또는 그 반대) 카테고리 변경 시 작성자 정보 정합성 오염 및 비밀번호 유실 문제 + +### ① 개념 (What - 문제의 명확한 정의) +게시글 수정(`PUT /api/posts/{publicId}`) 시 유저가 카테고리를 일반 카테고리(`FREE`)에서 익명 카테고리(`ANONYMOUS`)로 변경하거나 그 반대로 변경할 때, **`is_anonymous` 플래그와 `member_id`, `anonymous_password` 데이터 간의 상태 꼬임(State Corruption) 현상**입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- 게시글 작성 시에는 `isAnonymous`에 따라 `member`가 저장되거나 `anonymousPassword`가 저장됩니다. +- 그러나 게시글 수정 시 카테고리 코드(`categoryCode`)를 바꾸면서 `isAnonymous` 상태 변경에 따른 기존 `member` 매핑 해제 처리나 `anonymousPassword` BCrypt 재암호화 처리가 캡슐화되어 있지 않으면 상태가 파괴됩니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 유저가 자유게시판(`FREE`)에 쓴 글을 나중에 익명게시판(`ANONYMOUS`)으로 수정 이동하거나, 익명글을 회원글로 수정 이동할 때. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[회원글 -> 익명글 수정 시] +- is_anonymous = true 로 변경되었으나, member_id (FK) 가 여전히 연관되어 있어 DB 상에서 작성자 유저 정보가 그대로 노출됨! +[익명글 -> 회원글 수정 시] +- is_anonymous = false 로 변경되었으나, member_id 가 null 로 남아 작성자 없는 유령 글 발생! +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **익명성 파괴 보안 사고**: 익명글로 바꿨는데 DB에 작성자 회원의 `member_id`가 남아 익명성이 파괴되거나, 유령 글이 되어 삭제 불가능 상태 발생. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: DTO 필드를 엔티티에 덮어쓰는 `post.setTitle(...)`, `post.setCategory(...)` +- **한계점**: 엔티티 불변식(Invariant)을 지키지 못함. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 작성 후 카테고리 변경 시 익명/일반 간의 전환 제약이 필요함. +- **서비스 레벨 극복 방안 (카테고리 이동 정책 제한)**: 익명게시판(`ANONYMOUS`)과 일반게시판(`FREE`, `QNA`) 간의 카테고리 변경 작성을 서비스 정책상 금지하고 안내 문구 노출. +- **아키텍처 레벨 극복 방안 (도메인 카테고리 변경 검증)**: + - `Post.java` 도메인 엔티티 내에 `changeCategory(PostCategory newCategory)` 메서드를 만들고, 익명 ↔ 일반 카테고리 간의 전환 시도가 들어오면 `400 Bad Request (ErrorCode.INVALID_INPUT, "익명게시판과 일반게시판 간 카테고리 변경은 불가능합니다.")` 예외를 던져 백엔드 차원에서 원자적 차단. + +--- + +## 5. 💥 `PostReaction` 추천/비추천 투표 시 계정당 1회 독자 투표 업데이트의 Race Condition 및 DB Deadlock + +### ① 개념 (What - 문제의 명확한 정의) +유저가 추천(LIKE)과 비추천(DISLIKE)을 빠르게 번갈아 누르거나 동시 클릭할 때, 복합 유니크 인덱스(`uk_post_member_type`)에도 불구하고 **DB 트랜잭션 교착 상태(Deadlock) 및 데이터 충돌**이 발생하는 동시성 문제입니다. + +### ② 발생 원인 (Why - 물리적 & DB 메커니즘) +- 오늘 `PostReaction` DB 제약 조건을 `UNIQUE (post_id, member_id, type)`로 변경하여 유저 1명이 추천 1건 + 비추천 1건을 각각 가질 수 있게 만들었습니다. +- 유저가 추천 클릭과 비추천 클릭을 동시에 내보내면, MySQL InnoDB는 두 트랜잭션에서 `post_reaction` 유니크 인덱스 페이지 락(Index Page Lock)을 획득하는 과정에서 **Circular Dependency (순환 대기) Deadlock**을 유발할 수 있습니다. + +### ③ 언제 발생하는지 (When - 적합한 발생 상황) +- 클라이언트 단에서 추천 버튼과 비추천 버튼을 동시에 클릭하거나, 2개의 브라우저 탭에서 동일 계정으로 추천/비추천을 연타할 때. + +### ④ 어떻게 발생하는지 (How - 실제 코드 & DB 쿼리 실행 메커니즘) +``` +[Tx 1 (추천)] INSERT INTO post_reaction (post_id=1, member_id=5, type='LIKE') -> Index Lock 획득 대기 +[Tx 2 (비추천)] INSERT INTO post_reaction (post_id=1, member_id=5, type='DISLIKE') -> Index Lock 획득 대기 + -> MySQL InnoDB Deadlock Detector 발동 -> Deadlock found when trying to get lock; try restarting transaction (500 Server Error!) +``` + +### ⑤ 부정적 영향 (Pros & Cons of Ignoring - 미해결 시 여파) +- **500 Internal Server Error 발생**: DB 데드락 발생 시 사용자 화면에 500 에러 페이지가 뜸. + +### ⑥ 기존 처리 방식과의 비교 및 한계점 (Alternatives vs Existing) +- **기존 방식**: `@UniqueConstraint` 선언만 적용. +- **한계점**: 동시 INSERT 시 발생하는 DB 인덱스 데드락을 100% 방지할 수 없음. + +### ⑦ 트레이드오프 및 서비스/아키텍처 레벨 극복 방안 (Trade-off & Detailed Mitigation) +- **트레이드오프**: 버튼 연타 시 프론트엔드에서 클릭을 잠시 차단해야 함. +- **서비스 레벨 극복 방안 (Debounce / Throttle)**: 프론트엔드 버튼 클릭 시 300ms 디바운스(Debounce) 및 로딩 Spinner를 적용하여 동시 클릭을 시각적으로 100% 차단. +- **아키텍처 레벨 극복 방안 (CannotAcquireLockException Catch & Retry)**: + - 백엔드 `PostService.reactToPost()`에서 `CannotAcquireLockException` 또는 `DeadlockLoserDataAccessException` 예외를 Catch 하여 409 Conflict 또는 3회 자동 재시도(Spring Retry) 로직을 적용하여 500 에러 방지. + +--- + +# 📌 PART 2. 작업 완료 및 파일 위치 안내 + +* **생성된 마스터 스터디 가이드 경로**: + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studySprint02PostDomainIssues260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studySprint02PostDomainIssues260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studySprint02PostDomainIssues260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studySprint02PostDomainIssues260821.md) +* **`AGENTS.md` 작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/studyApiDesign260808.md b/docs/studyApiDesign260808.md new file mode 100644 index 0000000..8249c22 --- /dev/null +++ b/docs/studyApiDesign260808.md @@ -0,0 +1,80 @@ +# [Notion] REST API 설계 원칙 & 에러 응답 표준화 정리 (TIL) + +> 📌 **작성 일자**: 2026년 8월 8일 +> 🏷️ **문서 목적**: 노션(Notion)에 붙여넣어 RESTful API 디자인 철학, `/api` 접두사의 의미, 비동기 API 설계, 표준 에러 응답 규격을 공부하기 위한 TIL 정리 문서 + +--- + +## 1. 💡 REST API URL 설계 철학: 왜 `POST /api/join`이 아니라 `POST /api/members`인가? + +### 1.1. REST API의 기본 원칙: "URL은 명사, 행동은 HTTP 메서드" +* **HTTP 메서드 (`GET`, `POST`, `PUT`, `DELETE`)**: **행동(동사)**을 담당합니다. + * `GET`: 가져와라 (조회) + * `POST`: 새로 생성해라 (생성/등록) + * `PUT`: 수정해라 (수정) + * `DELETE`: 삭제해라 (삭제) +* **URL 주소 (`/api/members`)**: **대상(명사/자원)**을 담당합니다. + +### 1.2. 회원가입의 RESTful 해석 +* '회원가입'은 데이터베이스 관점에서 **"새로운 회원(`members`) 데이터 1명을 새로 생성(`POST`)하는 행위"**입니다. +* **`POST` (새로 생성해라!)** + **`/api/members` (회원들 집합에)** ➔ **회원가입!** + +| 행위 | ❌ 옛날 동사 중심 방식 | ⭕ 요즘 RESTful 방식 (표준) | +| :--- | :--- | :--- | +| **회원가입** | `POST /api/join` 또는 `/api/signup` | **`POST /api/members`** | +| **회원 목록 조회** | `GET /api/getMembers` | **`GET /api/members`** | +| **회원 정보 수정** | `POST /api/updateMember` | **`PUT /api/members/{publicId}`** | +| **회원 탈퇴** | `POST /api/deleteMember` | **`DELETE /api/members/{publicId}`** | + +--- + +## 2. 🌐 URL 주소 앞에 `/api` 접두사를 붙이는 3가지 실무적 이유 + +1. **"화면(HTML)" 요청과 "순수 데이터(JSON)" 요청의 명확한 구분** + * `/members` ➔ 웹 브라우저 화면(HTML) 요청. + * `/api/members` ➔ 백엔드 데이터(JSON) 요청임을 한눈에 파악 가능. +2. **프론트엔드(Next.js 3000포트)와 백엔드(Spring Boot 8080포트) Nginx 라우팅의 편의성** + * Nginx 설정에서 `"주소에 /api/ 가 들어간 요청만 백엔드 포트로 전달해라"` 라고 한 줄로 라우팅 규칙 지정 가능. +3. **세션 쿠키(`JSESSIONID`) 보안 범위의 제한** + * `Path=/api`로 지정하여 프론트엔드 화면 이동 시 쿠키 전송을 막고, 백엔드 API 요청 시에만 안전하게 쿠키를 전송하도록 범위 제한. + +--- + +## 3. ⚡ 게시글 추천/비추천 비동기(Async) 처리 기법 + +### 3.1. 프론트엔드: '낙관적 업데이트 (Optimistic UI Update)' +* 유저가 추천 클릭 시, 백엔드 응답을 기다리지 않고 **0.001초 만에 화면의 숫자와 하트 색깔을 먼저 변경**. +* 백엔드 API가 에러(409 등)를 반환하면 그때 원래 숫자로 원복(`-1`). + +### 3.2. 백엔드: 비동기 이벤트 처리 & Redis 버퍼링 +* 백엔드는 추천 클릭 시 DB를 직접 치지 않고, **`200 OK` 응답을 즉시 끊어준 뒤 백그라운드 쓰레드(`@Async`)로 DB 업데이트 실행**. +* 대규모 트래픽 시 Redis 메모리 카운터만 즉시 올려주고 10초 주기 비동기 배치(Batch)로 DB 반영. + +--- + +## 4. 🚨 글로벌 에러 응답 표준화 규격 (Global Error Response) + +### 4.1. 에러 응답 JSON 포맷 +```json +{ + "timestamp": "2026-08-08T10:20:00", + "status": 400, + "code": "INVALID_INPUT_VALUE", + "message": "입력값이 유효하지 않습니다.", + "errors": [ + { + "field": "email", + "value": "invalid-email-format", + "reason": "올바른 이메일 형식이 아닙니다." + } + ] +} +``` + +### 4.2. 주요 HTTP Status Code 정리 +* `400 Bad Request`: 유효성 검사 실패 (`INVALID_INPUT_VALUE`), 중복 가입 (`DUPLICATE_EMAIL`) +* `401 Unauthorized`: 비로그인 작성 시도 (`UNAUTHORIZED`), 비밀번호 불일치 (`INVALID_CREDENTIALS`) +* `403 Forbidden`: 타인의 글/댓글 수정·삭제 시도 (`ACCESS_DENIED`), 비회원 암호 오류 (`INVALID_ANON_PASSWORD`) +* `404 Not Found`: 존재하지 않는 게시글/댓글/회원 조회 (`RESOURCE_NOT_FOUND`) +* `409 Conflict`: 이미 추천/비추천 투표를 한 경우 (`ALREADY_REACTED`) +* `500 Internal Error`: 서버 내부 비즈니스 로직 예외 발생 (`INTERNAL_SERVER_ERROR`) diff --git a/docs/studyArchConcepts260806.md b/docs/studyArchConcepts260806.md new file mode 100644 index 0000000..e5a3fbc --- /dev/null +++ b/docs/studyArchConcepts260806.md @@ -0,0 +1,313 @@ +# [TIL] 스노보드 커뮤니티 개발을 위한 백엔드 & DB 핵심 개념 딥다이브 + +> 📌 **학습 날짜**: 2026년 8월 6일 +> 🏷️ **키워드**: `No Silver Bullet`, `FK 참조 무결성`, `N:M 중계 테이블`, `Bitmask`, `JSONB`, `Redis Set`, `역정규화`, `JWT 서명 원리`, `쿠키 보안` +> 💡 **학습 목표**: 각 아키텍처 패턴과 기술이 등장한 **근본적인 배경(Why)**과 **원리**, 그리고 잘못 사용했을 때 터지는 **사이드 이펙트(부작용)**를 깊이 있게 체득하고 올바른 기술적 선택을 내립니다. + +--- + +## 0. 대전제: "소프트웨어 공학에 은총알(Silver Bullet)은 없다" + +소프트웨어 공학의 거장 프레더릭 브룩스(Frederick P. Brooks)는 **"No Silver Bullet(은총알은 없다)"**이라는 유명한 말을 남겼습니다. +서양 전설에서 늑대인간을 한 방에 쓰러뜨리는 '은총알'처럼, **모든 문제를 한 번에 완벽하게 해결해 주는 만능 기술이나 설계 방식은 세상에 존재하지 않는다**는 뜻입니다. + +* ⚖️ **약약(Trade-off)의 법칙**: 하나의 기술을 선택해서 강력한 장점(예: 초고속 속도)을 얻는다면, **반드시 그 대가로 다른 불편함(예: 데이터 무결성 상실, 복잡도 증가)을 치러야 합니다.** +* 따라서 위대한 엔지니어는 단순히 "좋은 기술"을 찾는 사람이 아니라, **"우리 서비스의 상황에서 어떤 대가를 치르는 것이 가장 현맥한가?"**를 고민하고 선택하는 사람입니다. + +--- + +## 1. 데이터베이스의 기본과 FK(외래키) 참조 무결성 + +### 💡 용어 풀이 +> * **RDBMS (관계형 데이터베이스)**: 데이터를 행(Row)과 열(Column)로 이루어진 테이블(표) 형태로 저장하고, 테이블 간의 '관계'를 맺어 관리하는 데이터베이스 (예: MySQL, PostgreSQL, Oracle). +> * **PK (Primary Key / 기본키)**: 각 행(데이터)을 세상에서 유일하게 식별할 수 있는 주민등록번호 같은 고유 ID. +> * **FK (Foreign Key / 외래키)**: 다른 테이블의 PK를 참조하여 두 테이블을 연결하는 '연결 고리' 역할의 키. + +--- + +### 1.1. 왜 FK(외래키) 참조 무결성이 그렇게 중요할까? + +**참조 무결성(Referential Integrity)**이란, **"데이터 간의 관계가 끊어지거나 엉뚱한 유령 데이터를 가리키지 않도록 DB가 엄격하게 지켜주는 성질"**을 말합니다. + +#### 😱 FK 참조 무결성이 깨졌을 때 일어나는 잔혹사 스토리 +1. 유저 A가 작성한 게시글 100개가 있습니다. +2. 만약 DB에서 유저 A를 삭제했는데, 게시글 100개는 그대로 남아있다면? +3. 게시글의 `member_id`는 존재하지 않는 유저 ID를 가리키게 됩니다. 이것을 **'고아 데이터(Orphan Data)'** 또는 **'유령 데이터'**라고 부릅니다. +4. 나중에 유저가 이 게시글을 조회하려고 하면, 작성자 정보를 찾을 수 없어 **시스템 전체에 `NullPointerException` 에러가 터지고 웹사이트가 다운**됩니다. + +FK 제약조건을 걸어두면, DB가 알아서 **"이 회원에게 작성된 게시글이 남아있으니 함부로 회원을 삭제할 수 없다!"** 하고 에러를 튕겨서 데이터를 보호해 줍니다. + +--- + +### 1.2. Option A (우리가 처음 선택했던 RDBMS 정석 - 중계 테이블 방식) + +#### ❓ 왜 쉼표(,)로 저장하면 안 되고, 중계 테이블을 만들어야 할까? +만약 회원 테이블의 한 컬럼에 `riding_style = "카빙,트릭"` 처럼 쉼표로 여러 개를 저장했다고 해봅시다. (이를 DB 전문 용어로 **'1차 정규화 위반'**이라고 부릅니다.) + +#### 💥 쉼표 저장 시 터지는 대참사 +1. **검색 속도 지옥**: `"카빙을 타는 유저만 조회해 줘"` 라는 요청이 오면, DB는 전체 회원 데이터를 처음부터 끝까지 다 훑으면서 문자열 검색(`LIKE '%카빙%'`)을 해야 합니다. 회원 수가 10만 명이면 검색에 몇 초씩 걸립니다. +2. **데이터 오염**: 누군가 실수로 `"카빙 , 트릭 "` (띄어쓰기 오타)으로 저장하면 검색에서 쏙 빠져버립니다. + +#### 🛠️ 해결책: 중계 테이블(`member_riding_style`)을 두는 이유 +RDBMS는 테이블 간에 N:M(다대다) 관계를 직접 맺을 수 없습니다. 그래서 중간에 **중계 테이블(Junction Table)**을 만들어 `1:N`과 `N:1` 두 개의 안전한 외래키(FK) 관계로 풀어냅니다. + +``` +[member 테이블] 1 ◄--- (FK) --- N [member_riding_style 중계 테이블] N --- (FK) ---> 1 [riding_style 마스터 테이블] +``` + +* **장점 (얻는 것)**: + * **FK 참조 무결성 완벽 보장**: 존재하지 않는 스타일 ID나 유저 ID가 들어갈 수 없음. + * **초고속 인덱스 검색**: `WHERE style_id = 1` 로 인덱스를 타서 0.001초 만에 검색 완료. +* **단점 & 대가 (치르는 것)**: + * 유저 정보 하나 가져올 때 여러 테이블을 합성하는 **JOIN(조인) 쿼리**를 실행해야 하므로, DB 쿼리가 다소 복잡해집니다. + +--- + +## 2. 다중 선택 데이터를 저장하는 3가지 대안과 그 대가 (Option B, C, D) + +--- + +### 2.1. Option B: Bitmask (비트마스크 / 비트 연산) + +### 💡 용어 풀이 +> * **Bit (비트)**: 컴퓨터가 처리하는 가장 작은 단위. `0` 또는 `1` 두 가지 상태만 표현 가능. +> * **비트 연산자 (`&`, `|`)**: 숫자를 2진수 비트 단위로 직접 계산하는 극상의 고속 연산자. + +#### 📜 스토리 & 배경 +옛날 컴퓨터 메모리가 1KB, 1MB 단위로 매우 귀했던 시절, 엔티지어들은 "어떻게 하면 용량을 안 쓰고 여러 개 선택을 저장할까?"를 고민하다가 **2진수의 자릿수**를 활용하는 신기한 기법을 만들었습니다. + +#### ⚙️ 작동 원리 +각 선택지에 2의 거듭제곱 숫자를 부여합니다. +* 카빙 = $1$ ($2^0$, 2진수로 `0001`) +* 트릭 = $2$ ($2^1$, 2진수로 `0010`) +* 파크 = $4$ ($2^2$, 2진수로 `0100`) +* 입문 = $8$ ($2^3$, 2진수로 `1000`) + +만약 유저가 **'카빙(1)' + '파크(4)'** 2개를 선택했다면? +* $1 + 4 = 5$ (2진수로 `0101`)라는 **숫자 `5` 하나만 DB 컬럼(`riding_style_mask`)에 저장**합니다. + +조회할 때는 비트 연산(`AND`)을 씁니다: `WHERE (riding_style_mask & 1) > 0` ➔ "1번째 비트(카빙)가 켜져 있는 유저를 다 가져와!" + +#### ⚖️ 장단점 및 치명적 사이드 이펙트 +* **장점**: 중계 테이블이 아예 필요 없고, DB 저장 공간을 90% 이상 절약하며, 비트 연산이라 조회 속도가 빛의 속도입니다. +* 💥 **치명적 사이드 이펙트 (왜 함부로 쓰면 안 될까?)**: + 1. **FK 참조 무결성 완전히 상실**: 숫자 `5`가 들어있을 뿐, DB는 이 유저가 카빙을 타는지 파크를 타는지 외래키로 검증할 방법이 없습니다. + 2. **쿼리 가독성 붕괴**: 다른 개발자가 DB를 열어봤을 때 컬럼에 `5`, `13` 같은 숫자만 적혀 있어서 무슨 뜻인지 전혀 알아볼 수 없습니다. + 3. **확장성 한계**: 선택지가 64개를 넘어가면 64비트 정수 범위를 초과해서 코드가 터집니다. + +--- + +### 2.2. Option C: JSONB Document & Multi-Value Index + +### 💡 용어 풀이 +> * **JSON**: 데이터를 `{ "key": "value" }` 형태의 텍스트로 표현하는 표준 데이터 양식. +> * **Multi-Value Index**: JSON 배열 안의 원소 하나하나에 인덱스를 걸어 빠르게 찾아주는 최신 DB 기술. + +#### 📜 스토리 & 배경 +"NoSQL(몽고DB 같은 데이터베이스)은 중계 테이블 없이 JSON 배열로 데이터를 쓱 넣으면 되는데, 왜 RDBMS는 이렇게 테이블을 쪼개고 조인해야 해서 답답하지?" 라는 개발자들의 불만이 커지자, 최신 MySQL(8.0+)과 PostgreSQL이 **JSON을 통째로 컬럼에 집어넣는 기능**을 도입했습니다. + +#### ⚙️ 작동 원리 +`member` 테이블에 `riding_styles`라는 컬럼을 만들고, 그냥 JSON 텍스트 `["CARVING", "TRICK"]`을 통째로 저장합니다. + +#### ⚖️ 장단점 및 치명적 사이드 이펙트 +* **장점**: 중계 테이블 생성이 필요 없고, 프론트엔드에서 보내준 JSON 배열 형태 그대로 DB에 넣고 뺄 수 있어 개발 속도가 엄청나게 빠릅니다. +* 💥 **치명적 사이드 이펙트 (왜 함부로 쓰면 안 될까?)**: + 1. **FK 참조 무결성 상실**: DB는 JSON 텍스트 내부를 외래키로 검증하지 않습니다. 오타로 `["CABING"]` (카빙 오타)이 들어가도 DB가 막아주지 못해 **데이터 오염**이 일어납니다. + 2. **수정 작업의 오버헤드**: 카빙이라는 명칭을 "그라운드 트릭"으로 바꾸고 싶을 때, 모든 회원의 JSON 텍스트를 하나하나 꺼내서 문자열을 수정해야 하는 대공사가 벌어집니다. + +--- + +### 2.3. Option D: Redis Set (SINTER 교집합 연산) + +### 💡 용어 풀이 +> * **Redis (레디스)**: 하드디스크가 아닌 컴퓨터 메모리(RAM) 위에서 동작하는 초고속 데이터 저장소. +> * **Set (집합)**: 중복을 허용하지 않는 데이터들의 모임. +> * **SINTER**: 여러 집합 간의 '교집합(공통 원소)'을 한 번에 구하는 Redis 전용 명령어. + +#### 📜 스토리 & 배경 +회원 수가 100만 명을 넘어가자, "휘닉스파크를 가면서 + 카빙을 타는 유저"를 RDBMS에서 JOIN으로 찾으려니 DB CPU 점유율이 100%로 치솟으며 서버가 다운되었습니다. 엔지니어들은 **"검색/매칭 기능만 따로 떼어내어 RAM 위에서 벤다이어그램 교집합 연산을 하자!"** 하고 Redis를 도입했습니다. + +#### ⚙️ 작동 원리 +Redis에 집합 스티커를 만듭니다. +* `resort:휘닉스파크` Set ➔ { 유저1, 유저2, 유저5 } +* `style:카빙` Set ➔ { 유저1, 유저3, 유저5 } +* `SINTER resort:휘닉스파크 style:카빙` ➔ **0.001초 만에 교집합인 { 유저1, 유저5 }가 즉시 나옴!** + +#### ⚖️ 장단점 및 치명적 사이드 이펙트 +* **장점**: RDBMS에 전혀 부하를 주지 않고, 100만 명 데이터도 0.001초 만에 매칭해 냅니다. +* 💥 **치명적 사이드 이펙트 (왜 함부로 쓰면 안 될까?)**: + 1. **데이터 동기화 파이프라인의 복잡성**: 유저가 프로필을 수정할 때 RDBMS뿐만 아니라 Redis 스티커도 똑같이 업데이트해 줘야 합니다. 만약 중간에 서버가 튕겨서 **RDBMS와 Redis 데이터가 서로 달라지면 유령 회원 매칭 버그**가 터집니다. + 2. **RAM 비용 문제**: Redis는 비싼 RAM 메모리를 쓰므로 데이터가 커지면 서버 비용이 폭증합니다. + +--- + +## 3. 데이터베이스 역정규화 (Denormalization) + +### 💡 용어 풀이 +> * **정규화 (Normalization)**: 중복 데이터를 제거하고 테이블을 깔끔하게 쪼개어 데이터 무결성을 높이는 작업. +> * **역정규화 (Denormalization)**: 성능(속도)을 위해 **의도적으로 정규화를 깨뜨리고, 중복 데이터/합계 데이터를 추가로 저장**하는 작업. + +--- + +### 3.1. 왜 멀쩡한 정규화를 깨뜨리고 역정규화를 할까? + +#### 😱 `SELECT COUNT(*)` 쿼리의 공포 +여러분이 커뮤니티 게시판 목록을 볼 때, 게시글마다 옆에 `[댓글 15개]`, `[추천 42]` 같은 숫자가 붙어있습니다. +만약 역정규화를 하지 않았다면, 게시글 목록 10개를 보여줄 때마다 DB는 매번 `comment` 테이블로 넘어가서 **"이 글에 달린 댓글이 몇 개지?" 하고 `COUNT(*)` 연산을 10번씩 반복**해야 합니다. + +유저 1,000명이 동시에 게시판을 새로고침하면 DB는 `COUNT(*)` 쿼리를 10,000번 수행하다가 과부하로 서버가 다운됩니다. + +--- + +### 3.2. 역정규화 적용 방식 (`post` 테이블) +`post` 테이블에 의도적으로 숫자를 저장하는 컬럼 3개를 추가해 둡니다: +* `comment_count` (댓글 수) +* `like_count` (추천 수) +* `dislike_count` (비추천 수) + +이제 게시판 목록을 불러올 때, DB는 `comment` 테이블을 뒤질 필요 없이 **`post` 테이블에 적혀있는 숫자를 그대로 가져오기만 하면 되므로 조회 속도가 100배 이상 빨라집니다.** + +--- + +### 3.3. 역정규화로 인해 발생하는 치명적 문제와 해결책 + +#### 💥 치명적 사이드 이펙트: 데이터 불일치 (Inconsistency) +* 누군가 댓글을 작성해서 `comment` 테이블에 댓글 데이터 1개가 추가되었는데, 실수로 `post` 테이블의 `comment_count` 숫자를 `+1` 올리는 코드가 누락되거나 에러가 났다면? +* 실제 댓글은 5개인데, 게시판 목록에는 `[댓글 4개]`라고 표시되는 **데이터 불일치 버그**가 터집니다! + +#### 🛠️ 어떻게 해결해야 할까? +1. **트랜잭션 (`@Transactional`)**: 댓글 작성 ➔ 댓글 저장 ➔ 게시글 댓글 수 `+1` 증가 작업을 **하나의 세트로 묶어서, 중간에 에러가 나면 둘 다 취소(Rollback)**되도록 안전장치를 겁니다. +2. **동시성 락 (Concurrency Lock)**: 유저 100명이 동시에 추천 버튼을 누를 때 숫자가 씹히지 않도록 DB 락(Lock) 메커니즘을 적용합니다. + +--- + +## 4. JWT (JSON Web Token) 검증 원리와 비밀키(Secret Key) + +### 💡 용어 풀이 +> * **Token (토큰)**: 유저의 신원 정보가 담긴 암호화된 텍스트 조각. +> * **Stateless (무상태)**: 서버 메모리에 유저의 로그인 상태를 전혀 저장하지 않는 방식. +> * **Secret Key (비밀키)**: 오직 서버만 안전하게 알고 있는 서명용 비밀 암호키. +> * **HMAC-SHA256**: 비밀키를 섞어서 만든 복제 불가능한 해시 암호화 알고리즘. + +--- + +### 4.1. 근본적 의문: "서버 DB나 메모리에 저장도 안 하는데, 토큰이 진짜인지 어떻게 알아채지?" + +JWT는 세션 방식과 달리 **서버에 유저 로그인 정보를 전혀 저장하지 않습니다.** +클라이언트가 요청을 보낼 때 토큰 문자열 하나 달랑 보내오는데, 서버는 도대체 무엇을 믿고 이 토큰이 해커가 위조한 가짜 토큰이 아니라는 것을 검증할 수 있을까요? + +--- + +### 4.2. JWT의 3조각 구조와 '임금님의 암행어사 마패' 비유 + +JWT 토큰을 뜯어보면 마침표(`.`)를 기준으로 3조각으로 나뉘어 있습니다: + +``` +[Header (헤더)] . [Payload (페이로드)] . [Signature (서명/도장)] + eyJhbGci... . eyJzdWIi... . wNiSflK... +``` + +1. **Header (헤더)**: "이 토큰은 무슨 암호화 알고리즘으로 만들어졌는가?" 정보. +2. **Payload (페이로드)**: "이 토큰의 주인은 유저 ID 5번(홍길동)이고, 내일 만료된다" 같은 실제 유저 정보. (**⚠️ 누구나 뜯어서 내용을 열어볼 수 있음!**) +3. **Signature (서명/도장)**: **★ 핵심 ★** `Header` + `Payload` + **`서버의 Secret Key(비밀키)`**를 섞어서 만든 **'복제 불가능한 디지털 도장'**. + +--- + +### 📜 '임금님의 도장' 비유로 이해하는 검증 원리 + +1. 유저가 로그인하면, 서버는 유저 정보(Payload)를 적은 뒤 **오직 서버만 가지고 있는 '비밀 도장(Secret Key)'**을 쾅 찍어서 유저에게 줍니다. +2. 유저가 나중에 이 토큰을 서버로 가져옵니다. +3. 만약 해커가 중간에 토큰 내용을 `유저 ID 5번`에서 `유저 ID 1번(관리자)`으로 슬쩍 고쳤다고(위조했다고) 해봅시다. +4. 서버는 토큰을 받자마자 **"네가 가져온 내용(Header+Payload)에 내 비밀 도장(Secret Key)을 다시 찍어서 나온 서명값"**과 **"토큰에 적혀있는 서명(Signature)"**이 일치하는지 비교합니다! +5. 내용을 고쳤기 때문에 도장 값이 서로 다르게 나오고, 서버는 **"어? 서명이 안 맞네? 이거 위조된 가짜 토큰이다!"** 하고 즉시 튕겨냅니다. + +👉 **결론**: 서버는 유저 상태를 저장할 필요 없이, **내 비밀키(Secret Key)로 서명 도장이 일치하는지 수학적으로 계산만 해보면 가짜를 100% 가려낼 수 있는 것**입니다! + +--- + +### 4.3. JWT의 치명적 약점 (세션과의 결정적 차이) + +#### 💥 탈취당했을 때 서버에서 강제 파기가 불가능함! +* 세션 방식은 해커가 세션키를 훔쳐 가도, 서버에서 해당 세션을 **강제 로그아웃(`session.invalidate()`)** 시키면 해커 접속이 즉시 차단됩니다. +* 하지만 JWT는 서버에 상태가 없기 때문에, **해커가 토큰을 탈취해가면 만료 시간이 끝날 때까지 서버가 해커의 접근을 강제로 막을 방법이 없습니다!** +* (이것이 1차 MVP에서 보안과 관리가 용이한 세션 방식을 채택한 결정적 이유입니다.) + +--- + +## 5. Redis (레디스)란 무엇인가? + +### 💡 용어 풀이 +> * **RAM (메모리)**: 컴퓨터가 켜져 있는 동안 데이터를 초고속으로 처리하는 주기억장치. (전원이 꺼지면 삭제됨) +> * **Disk (HDD/SSD)**: 전원이 꺼져도 데이터가 보관되는 영구 저장장치. (RAM보다 속도가 10만 배 이상 느림) +> * **In-Memory Data Store**: 데이터를 하드디스크가 아닌 오직 RAM 메모리에만 두고 처리하는 초고속 데이터베이스. + +--- + +### 5.1. 왜 이렇게 빠른가? (도서관 비유) + +* **일반 DB (MySQL, PostgreSQL)**: 책을 찾으러 **도서관 지하 창고(Disk)**까지 걸어가서 책을 꺼내오는 방식. (시간이 오래 걸림) +* **Redis**: 책상 위 **포스트잇(RAM)**에 적어둔 메모를 눈으로 쓱 보는 방식. (0.001초 만에 완료) + +Redis는 데이터가 모두 RAM 위에 올라가 있기 때문에 읽기/쓰기 속도가 일반 DB보다 100배~1,000배 이상 빠릅니다. + +--- + +### 5.2. Redis는 언제 쓰고, 언제 쓰면 안 될까? + +* **⭕ 이럴 때 씁니다**: + * **캐싱 (Cache)**: 자주 조회되는 스키장 날씨/웹캠 정보, 인기 게시글 저장 + * **세션 저장소**: 서버 여러 대가 유저 로그인 상태를 공유할 때 + * **실시간 랭킹 & 카운터**: 조회수/좋아요 실시간 집계 +* **❌ 이럴 때 쓰면 안 됩니다 (사이드 이펙트)**: + * 회원의 결제 내역, 비밀번호, 중요한 게시글 원본 저장 ➔ **컴퓨터 전원이 꺼지거나 재부팅되면 RAM 데이터가 싹 날아가 버리는 치명적 위험**이 있습니다. (반드시 원본 데이터는 MySQL 같은 RDBMS에 보관해야 합니다.) + +--- + +## 6. 쿠키 보안 정책 (Cookie Security Policies) 깊이 읽기 + +### 💡 용어 풀이 +> * **Cookie (쿠키)**: 웹 브라우저가 사용자 컴퓨터 파일에 저장해 두는 작은 텍스트 데이터. +> * **XSS (Cross-Site Scripting)**: 해커가 웹사이트에 악성 자바스크립트 코드를 주입하여 다른 유저의 쿠키를 훔쳐 가는 해킹 기법. +> * **CSRF (Cross-Site Request Forgery)**: 해커가 만든 악성 사이트에 유저가 접속했을 때, 유저 몰래 내 쿠키를 가지고 원래 사이트에 비밀글 작성/결제 요청을 보내게 만드는 테러 기법. + +--- + +### 6.1. `HttpOnly = true` (자바스크립트 해킹 방화벽) +* 보통 자바스크립트 코드(`document.cookie`)를 실행하면 브라우저의 쿠키를 자유롭게 읽을 수 있습니다. 해커가 게시글에 악성 스크립트를 몰래 넣어두면 유저의 로그인 쿠키가 해커 서버로 싹 털립니다. +* **`HttpOnly` 속성을 켜두면, 오직 HTTP 통신으로만 쿠키가 이동하고 자바스크립트 접근이 완전히 차단**되어 XSS 해킹을 원천 봉쇄합니다. + +--- + +### 6.2. `Secure = true` (HTTPS 암호화 수호신) +* 우리가 카페 와이파이(Wi-Fi)를 쓸 때, 보안이 안 적용된 `http://` 통신을 하면 와이파이 신호를 감청하는 해커(스니핑)가 내 세션 쿠키를 훔쳐볼 수 있습니다. +* **`Secure` 속성을 켜두면, 오직 암호화된 `https://` 통신 채널에서만 쿠키를 전송**하도록 제한합니다. + +--- + +### 6.3. `SameSite = Lax` (CSRF 공격 방어) +* 해커가 낚시성 이벤트 사이트를 만들어두고 유저가 클릭하게 만듭니다. 유저가 클릭하는 순간, 유저 브라우저에 저장되어 있던 원래 사이트 쿠키가 자동으로 첨부되어 해커의 의도대로 결제나 회원 탈퇴 요청이 날아가는 것이 CSRF 공격입니다. +* **`SameSite=Lax` 속성을 적용하면, 다른 사이트에서 출발한 요청에는 내 쿠키를 첨부하지 않도록 브라우저가 막아줍니다.** + +--- + +### 6.4. ⚠️ 비밀번호나 개인정보를 쿠키에 담으면 생기는 대참사 + +쿠키는 유저의 컴퓨터 하드디스크 텍스트 파일로 저장되며, 브라우저 개발자 도구(F12)를 누르면 누구나 눈으로 읽을 수 있습니다. +만약 쿠키에 비밀번호나 이메일, 이름을 담아둔다면, **PC방이나 공용 컴퓨터에 내 비밀번호가 텍스트 파일로 훤히 노출되는 심각한 개인정보 유출 참사**가 벌어집니다. + +따라서 쿠키에는 오직 아무 의미 없는 무작위 난수 식별자(`JSESSIONID=A1B2C3...`)만 담아야 합니다. + +--- + +## 🎯 7. 종합 결론 및 의사결정 회고 + +우리가 공부한 모든 내용을 바탕으로, **Snowthing 서비스 1차 MVP에 왜 이 기술들을 조합했는지** 최종 요약됩니다: + +1. **RDBMS 중계 테이블 방식 (Option A) 채택**: + * 대안인 Bitmask나 JSONB는 개발이 잠깐 편할지 몰라도 **FK 참조 무결성이 깨져서 데이터 오염 및 유령 데이터 위험**이 너무 큼. + * 조금 불편하더라도 데이터의 안정성을 위해 **중계 엔티티(`MemberResort`)를 직접 만들어 1:N, N:1 관계**로 깔끔하게 처리함. +2. **세션 기반 인증 채택**: + * JWT는 서버 저장 비용이 없지만 **탈취 시 강제 로그아웃/파기가 불가능한 결정적 보안 문제**가 있음. + * 1차 MVP에서는 보안과 제어가 확실한 **세션 방식 + 쿠키 보안 3종 세트(`HttpOnly`, `Secure`, `SameSite`)**로 구축함. +3. **역정규화 컬럼 도입**: + * `SELECT COUNT(*)` 쿼리로 인한 DB 과부하를 막기 위해 `post` 테이블에 `comment_count`, `like_count` 컬럼을 두고, `@Transactional`로 안전하게 관리함. diff --git a/docs/studyArchPrinciples260810.md b/docs/studyArchPrinciples260810.md new file mode 100644 index 0000000..26aac69 --- /dev/null +++ b/docs/studyArchPrinciples260810.md @@ -0,0 +1,72 @@ +# [Notion] 백엔드 기술 학습 3단계 딥다이브 철학 및 4대 아키텍처 분석 (TIL) + +> 📌 **작성 일자**: 2026년 8월 10일 +> 🏷️ **문서 목적**: 단순 코드나 겉핥기 답변을 넘어, 모든 기술의 물리적 작동 원리, 2차/3차 치명적 한계, 그리고 그 한계까지 극복하는 최종 실무 아키텍처를 체계적으로 정리한 종합 학습서 + +--- + +## 🏛️ 기술 분석 3단계 딥다이브 절대 원칙 (Architecture Analysis Framework) + +모든 백엔드 기술과 DB 아키텍처 분석 시 다음 3단계를 끝단까지 파헤칩니다. + +1. **[1단계] 컴퓨터 물리 & DB 엔진 내부 작동 원리**: RAM 메모리, CPU, DB 락, 인덱스 B+Tree 노드 수준에서 왜 그렇게 동작하는가? +2. **[2단계] 이 기술을 썼을 때 새로 터지는 2차/3차 치명적 한계 (Side Effects)**: 대규모 트래픽 및 특수 상황에서 이 기술이 불러오는 2차 참사와 한계점은 무엇인가? +3. **[3단계] 그 2차 한계까지 완전 극복하는 최종 실무 아키텍처 (Ultimate Architecture)**: 대규모 서비스에서 그 2차 한계까지 완벽히 지워버리기 위해 사용하는 최종 솔루션은 무엇인가? + +--- + +## 🎯 1. 동시성 락 & 카운터 정합성 (Race Condition) + +### 1단계: DB 원자적 UPDATE 쿼리의 물리 작동 원리 +* `UPDATE post SET like_count = like_count + 1 WHERE id = 1;` +* **원리**: MySQL InnoDB 엔진의 Transaction Manager가 해당 Row(행)에 쓰기 락(X-Lock, Exclusive Lock)을 거는 순간, 뒤이어 들어오는 쿼리들은 DB 엔진 내부의 **`In-Memory Lock Wait Queue (대기 큐)`에 줄을 서서 대기**함. 자바 RAM을 거치지 않고 DB 엔진의 락 대기 큐를 통해 순차 처리되므로 카운트 씹힘(Lost Update)이 원천 차단됨. + +### 2단계: 원자적 쿼리가 초래하는 2차/3차 치명적 한계 +1. **DB 커넥션 고갈과 서비스 마비 (Connection Pool Starvation)**: 핫이슈 글에 0.1초 만에 5,000명이 추천을 누르면 4,999개 쿼리가 DB 대기 큐에 묶임. Spring의 HikariCP DB 커넥션 풀이 대기 상태로 고갈되어 **로그인/조회 등 전체 웹 서비스가 마비**됨. +2. **DB 데드락 (Deadlock)**: 게시글 카운트 + 회원 카운트 등 2개 이상의 테이블 락을 서로 다른 순서로 잡을 때 DB 교착 상태 발생하여 트랜잭션 강제 에러 튕김. + +### 3단계: 2차 한계까지 극복하는 최종 실무 아키텍처 +* **Redis In-Memory 비동기 버퍼링 (Redisson & INCR)**: + * 유저 추천 클릭 시 DB 행 락을 아예 잡지 않고, 초고속 **Redis 메모리에서 0.0001초 만에 `INCR` 카운팅 및 중복 체크** 수행. + * 10초~1분 주기로 Redis에 집계된 카운트를 DB `post` 테이블에 비동기 일괄 배치 UPDATE 반영 (`Eventual Consistency`). + +--- + +## 🎯 2. 외부 식별자 (`public_id`) 세컨더리 인덱스 파편화 + +### 1단계: UUID v7 (Time-ordered)의 물리 작동 원리 +* `UUID v7` 비트 구조: `[ 48 bits: Unix Epoch Timestamp (ms) ] + [ 4 bits: Version ] + [ 74 bits: Random ]` +* **원리**: 앞부분 48비트가 밀리초 타임스탬프이므로 시간이 흐름에 따라 항상 물리적으로 더 큰 값이 생성됨. B+Tree 인덱스 노드에 정렬되어 들어갈 때 **16KB 인덱스 페이지 맨 오른쪽 끝에 순차 덧붙여짐 (Append-Only Insert)**. + +### 2단계: 무작위 UUID v4가 초래했던 2차/3차 치명적 한계 +* **페이지 스플릿 (Page Split) 참사**: 무작위 난수 UUID v4는 꽉 찬 16KB 인덱스 페이지 중간을 강제로 찢고 들어가므로 인덱스 파편화 폭발, 디스크 I/O 급증, 메모리 충전율 50% 급감. + +### 3단계: 최종 실무 아키텍처 +* **`BIGINT id` (내부 PK) + `UUID v7` (`public_id` 세컨더리 인덱스)**: 내부 클러스터드 인덱스는 8바이트 정수로 최적화하고, 세컨더리 인덱스는 `UUID v7`을 채택하여 외부 보안과 세컨더리 인덱스 쓰기 성능을 둘 다 100% 달성. + +--- + +## 🎯 3. 댓글 / 대댓글 계층형 N+1 쿼리 참사 + +### 1단계: In-Memory Tree 재조립의 물리 작동 원리 +* `SELECT * FROM comment WHERE post_id = 1;` +* **원리**: DB 단에서 쿼리를 단 1번만 실행하여 해당 글의 모든 댓글을 **단 하나의 TCP 소켓 패킷(Single Network RTT)**으로 가져옴. 자바 RAM의 `HashMap` 메모리 주소를 $O(1)$ 초고속 참조하여 부모-자식 객체 포인터(Pointer)만 엮어냄. + +### 2단계: N+1 쿼리가 초래하는 2차/3차 치명적 한계 +* **네트워크 RTT (Round Trip Time) 폭탄**: 원댓글 10개 조회 후 대댓글 N번 추가 조회 시 1+N번의 TCP 소켓 통신 및 HikariCP 커넥션 획득/반납 오버헤드로 DB 마비. + +### 3단계: 최종 실무 아키텍처 +* **Single Query In-Memory Reassembly + 1차 댓글 페이징**: DB 통신은 단 1번으로 고정하고 자바 RAM에서 재조립하며, 댓글이 수만 개 달린 핫이슈 글은 1차 원댓글 단위로 Slice/Page 나누어 메모리 과부하 사전 차단. + +--- + +## 🎯 4. 다대다 (N:M) 데이터 구조 및 명칭 변경 + +### 1단계: Enum 코드화 (Data Indirection)의 물리 작동 원리 +* DB에는 `PHOENIX_PARK` 고정 코드를 저장하고, 화면 한글 명칭("휘닉스평창")은 자바 RAM 메모리의 Enum 상수로 보관. + +### 2단계: 텍스트 저장 시 초래하는 2차/3차 치명적 한계 +* 10년 뒤 명칭 변경 시 유저 10만 명의 DB 레코드를 디스크에서 훑어서 치환(UPDATE)하는 디스크 I/O 및 락 대기 폭탄 발생. + +### 3단계: 최종 실무 아키텍처 +* **RDBMS 정석 중계 테이블 (`id` 대리키) + Enum 식별자 코드화**: RDBMS의 FK 참조 무결성을 100% 보장하면서, 명칭 변경 시 DB 디스크 I/O 연산 0건, 자바 애플리케이션 상수 수정만으로 처리. diff --git a/docs/studyCommunityPostCommentMaster260821.md b/docs/studyCommunityPostCommentMaster260821.md new file mode 100644 index 0000000..6eb369e --- /dev/null +++ b/docs/studyCommunityPostCommentMaster260821.md @@ -0,0 +1,710 @@ +# 📚 [Master Study Guide] Snowthing 커뮤니티(게시글 & 댓글/대댓글) 백엔드 전 과정 코드, 7대 필수 요소 기술 원리, 4대 대안 & 트레이드오프 극복 완전 가이드 (2026-08-21) + +> **노션(Notion) 복사용 및 백엔드 기술 면접 / 아키텍처 공부용 완전판 마스터 가이드** +> 본 문서는 Snowthing 스프린트 02 커뮤니티 도메인(게시글 Post & 댓글/대댓글 Comment) 백엔드 전체 코드에 대한 1줄 한 줄 상세 해설 주석(Annotation), **[WHY] 왜 그렇게 설계하고 만들어졌는지에 대한 물리적 배경**, 7대 필수 서술 요소 체계(개념, Why, When, How, Pros, Alternatives, Trade-off & Mitigation), 그리고 계층형 데이터 모델 대안과 락 프리(Lock-Free) 동시성 제어 원리를 집대성한 노션 공부용 문서입니다. + +--- + +# 📑 PART 1. 커뮤니티 5대 핵심 아키텍처 원리 (7대 필수 요소 체계) + +--- + +## 1. 댓글 계층형 N+1 완전 파괴: `Single Query + In-Memory Tree` 기법 + +### ① 개념 (What) +부모 댓글과 대댓글(Self-Referencing) 구조를 조회할 때, JPA 엔티티 지연 로딩 연관 관계를 순회하지 않고 **`WHERE post_id = :postId` 조건의 단 1회 SQL 쿼리로 모든 댓글을 가져와 자바 메모리(RAM)의 `HashMap` 포인터 참조를 통해 부모-자식 트리 계층(`children: []`)으로 재조립**하는 백엔드 최적화 기법입니다. + +### ② 왜 사용하는지 (Why - 도입 목적 & 배경) +JPA에서 `@OneToMany List children` 연관 관계를 두고 댓글 목록을 조회하면, 부모 댓글 10개마다 자식 대댓글을 조회하는 `SELECT` 쿼리가 연쇄적으로 날아가는 **N+1 쿼리 폭탄**이 터집니다. 댓글이 1,000개 달린 게시글은 SQL이 1,001번 실행되어 DB 커넥션이 고갈되고 서버가 다운됩니다. + +### ③ 어떨 때 사용하는지 (When) +게시글의 댓글/대댓글, 카테고리 계층 구조(1차/2차/3차 카테고리), 조직도 트리 등 **한 화면에 특정 부모 하위의 전체 트리 데이터를 표시해야 하는 유즈케이스**에 사용합니다. + +### ④ 어떻게 사용하는지 (How - 구현 코드 및 동작 방식) +```java +// 1. 단 1회의 JPQL 쿼리로 해당 게시글의 모든 댓글 직조회 (O(1) Query Count) +List comments = commentRepository.findByPostIdWithMember(post.getId()); + +// 2. HashMap과 LinkedHashMap을 활용한 In-Memory Tree 포인터 조립 +Map map = new LinkedHashMap<>(); +List rootComments = new ArrayList<>(); + +for (Comment comment : comments) { + CommentResponse dto = CommentResponse.from(comment); + map.put(dto.commentId(), dto); + + if (dto.parentId() == null) { + rootComments.add(dto); // 최상위 부모 댓글 + } else { + CommentResponse parentDto = map.get(dto.parentId()); + if (parentDto != null) { + parentDto.children().add(dto); // O(1) 시간 복잡도로 부모의 children 리스트에 자식 바인딩! + } + } +} +``` + +### ⑤ 장점 (Pros) +* **쿼리 수 고정 (O(1) Query Count)**: 댓글이 1개든 1,000개든 DB 쿼리가 무조건 단 1번만 실행됩니다. +* **초고속 응답 속도**: 자바 메모리의 `HashMap.get()` 조회 시간 복잡도는 O(1)이므로 메모리 연산 시간이 수 밀리초 이내입니다. + +### ⑥ 다른 기술/대안 (Alternatives - 트리 구조 구현 4대 모델 비교) +1. **Adjacency List (인접 리스트 - 현재 채택 방식)**: `parent_id` 컬럼 1개만 둠. 가장 직관적이고 CUD(생성/수정/삭제)가 단순함. +2. **Path Enumeration (경로 열거)**: `path` 컬럼에 `/1/4/12/` 형태로 전체 경로를 저장. `LIKE '/1/%'`로 조회가 쉽지만 문자열 파싱 및 수정 시 전체 경로 UPDATE 부담. +3. **Nested Sets (중첩 집합)**: `lft`, `rgt` 숫자로 범위를 관리. 읽기 속도는 빠르나 새로운 댓글 하나 삽입 시 기존 전체 노드의 `lft`/`rgt`를 +2 갱신해야 하므로 쓰기 성능 부담. +4. **Closure Table (폐쇄 테이블)**: 모든 부모-자식 관계를 별도의 `comment_tree(ancestor, descendant, depth)` 관계 테이블로 분리. 조회가 유연하나 테이블이 비대해짐. + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프 (대량 댓글 메모리 오버헤드)**: 한 게시글에 댓글이 10만 개 이상 달리면 단 1회 쿼리라도 자바 메모리(JVM Heap)에 10만 개 DTO가 한 번에 올라가 메모리 초과(OOM) 위험이 발생할 수 있습니다. +* **극복 방안 (1차 원댓글 Slice 페이징)**: 댓글이 수천 건 이상 커지면 최상위 부모 댓글(`parent_id IS NULL`) 단위로 1차 `Slice`/`Page` 페이징 조회를 적용하고, 각 부모의 대댓글만 패치하도록 제한합니다. + +--- + +## 2. 락(Lock) 없는 동시성 제어: DB `UNIQUE` 제약조건 + `@Async` 비동기 카운팅 + +### ① 개념 (What) +추천/비추천 투표 연타(광클) 시 발생할 수 있는 레이스 컨디션 및 중복 투표를 막기 위해, DB 레벨의 **`UNIQUE (post_id, member_id, type)` 제약 조건**으로 락 프리(Lock-Free) 원자적 물리 차단을 수행하고, 카운트 갱신은 **`@Async` 비동기 이벤트**로 메인 트랜잭션 블로킹 없이 처리하는 기법입니다. + +### ② 왜 사용하는지 (Why) +JPA 낙관적 락(`@Version`)이나 비관적 락(`SELECT FOR UPDATE`)은 락 대기 시간 및 `OptimisticLockException` 발생 시 복잡한 재시도(Retry) 로직이 요구되어 DB 커넥션 병목을 일으킵니다. 락 대기 없이 DB 유니크 제약 조건만으로 중복 투표를 원자적으로 물리 차단하기 위함입니다. + +### ③ 어떨 때 사용하는지 (When) +유저 1인당 각 1회씩 허용되는 추천/비추천/투표 기능 및 수많은 사용자가 동시에 몰리는 반응형 커뮤니티 API에 사용합니다. + +### ④ 어떻게 사용하는지 (How) +```java +// 1. PostReaction 엔티티 복합 유니크 제약조건 설정 (계정당 추천 1회, 비추천 1회 각각 허용) +@Table( + name = "post_reaction", + uniqueConstraints = { + @UniqueConstraint(name = "uk_post_member_type", columnNames = {"post_id", "member_id", "type"}) + } +) +public class PostReaction extends BaseTimeEntity { ... } + +// 2. 서비스 레이어에서 DataIntegrityViolationException 캐치 및 409 Conflict 반환 +try { + reactionRepository.save(reaction); +} catch (DataIntegrityViolationException e) { + throw new CustomAuthException(ErrorCode.ALREADY_REACTED); +} + +// 3. 메인 트랜잭션을 블로킹하지 않는 @Async 비동기 카운터 갱신 이벤트 발행 +eventPublisher.publishEvent(new PostReactionEvent(post.getId(), type)); +``` + +### ⑤ 장점 (Pros) +* **락 트랜잭션 대기 병목 제거**: DB Row Lock을 잡지 않으므로 동시 요청 시 트랜잭션 대기 병목이 발생하지 않습니다. +* **DB 엔진 레벨 무결성**: MySQL InnoDB 유니크 인덱스가 동일 요청의 중복 투표를 원자적으로 차단합니다. + +### ⑥ 다른 대안 (Alternatives) +* **JPA 낙관적 락 (`@Version`)**: 충돌 시 예외를 던지고 자바에서 재시도. 락 대기는 없으나 동시 충돌 시 재시도 실패율 증가. +* **JPA 비관적 락 (`PESSIMISTIC_WRITE`)**: DB Row Lock을 걸어 순차 처리. 데이터 일관성은 보장되나 동접자가 몰릴 때 DB 커넥션 타임아웃 발생 가능. + +### ⑦ 트레이드오프 및 극복 방안 (Trade-off & Mitigation) +* **트레이드오프 (비동기 처리 시 미세한 카운트 시차)**: `@Async`로 카운트를 올리므로 DB `post_reaction`에는 저장이 완료되었으나 게시글 `like_count` 수치 갱신에 수 밀리초 시차가 발생할 수 있습니다. +* **극복 방안 (Optimistic UI)**: 프론트엔드에서 추천 버튼 클릭 즉시 화면 상의 카운터를 +1 먼저 가산하고 백엔드 응답을 수신하는 낙관적 UI 렌더링을 적용합니다. + +### ⑧ CAP 정리 (CAP Theorem) 및 BASE 모델 4대 물리적 적용 메커니즘 + +이 방식은 전통적인 RDBMS의 **ACID 강한 일관성(Strong Consistency)** 대신, 고성능 분산 웹 시스템의 **CAP 정리 중 AP (Availability & Partition Tolerance)** 모델과 **BASE (Basically Available, Soft State, Eventual Consistency)** 체계를 실제 코드와 DB 레이어에 물리적으로 매핑한 아키텍처입니다. + +#### 1. CAP 정리 적용 원리 (Consistency vs Availability) +* **C (Consistency - 일관성)의 대가**: `post_reaction` 투표 이력 저장과 `post.like_count` 카운터 갱신을 단일 동기 트랜잭션으로 묶어 DB Row Lock을 잡으면 **강한 일관성(Strong Consistency)**을 얻지만, 트래픽 폭주 시 DB 커넥션 대기 병목이 생겨 시스템 **가용성(Availability)**과 응답 속도가 크게 떨어집니다. +* **AP + BASE 선택 이유**: 커뮤니티 추천 수 갱신은 수 밀리초의 수치 반영 지연이 발생하더라도 시스템 전체가 멈추지 않고 빠른 응답을 주는 **가용성(Availability)** 확보가 서비스 안정성에 훨씬 유리하기 때문입니다. + +#### 2. 우리 코드 및 DB에 실제로 적용된 4대 물리적 구조 + +1. **[C 영역 - 즉시 일관성 (Immediate Consistency)] DB 유니크 제약조건**: + - `post_reaction` 테이블에 `@UniqueConstraint(name = "uk_post_member_type", columnNames = {"post_id", "member_id", "type"})` 설정. + - 유저 투표 시 `post_reaction` 테이블 저장 단계는 **ACID 수준의 일관성**을 유지하여, 중복 투표 발생 시 MySQL InnoDB 엔진이 `DataIntegrityViolationException` 예외를 내며 중복 저장을 원자적으로 물리 차단합니다. + +2. **[A 영역 - 고가용성 (High Availability)] `@Async` 비동기 이벤트 분리**: + - `PostService.reactToPost()` 메서드에서 `post` 테이블의 카운트를 동기로 올리지 않고, `eventPublisher.publishEvent()`로 비동기 이벤트를 발행한 뒤 **즉시 HTTP 200 OK 응답을 반환**. + - 메인 HTTP 요청 Thread는 `post` 테이블의 Row Lock 대기에 얽매이지 않으므로, 수천 명의 동시 요청이 들어와도 서버 타임아웃 없이 모든 요청에 **정상 응답하는 가용성(Availability)**을 확보합니다. + +3. **[Soft State & Eventual Consistency 영역 - 최종 일관성] `@Async` 리스너**: + - `PostReactionEventListener.java` 백그라운드 이벤트 리스너 실행. + - **Soft State (일시적 불일치)**: 메인 트랜잭션 응답 직후부터 비동기 스레드가 동작하는 수 밀리초 사이에는 DB `post_reaction`(투표 이력 1건)과 `post.like_count`(아직 +1 안 됨) 사이에 미세한 상태 차이가 존재합니다. + - **Eventual Consistency (최종 일관성)**: 백그라운드 비동기 스레드가 `UPDATE post SET like_count = like_count + 1 WHERE post_id = :id` 쿼리를 완료하는 시점에 두 데이터는 **최종적으로 완전히 일치**하게 됩니다. + +4. **[보완 렌더링 영역] Optimistic UI (낙관적 UI)**: + - 프론트엔드 `app/posts/[publicId]/page.tsx` 연동. + - 백엔드의 수 밀리초 최종 일관성 시차 동안 유저가 지연을 느끼지 않도록, 추천 버튼 클릭 즉시 화면 상의 숫자를 +1 렌더링하고 백엔드의 비동기 처리가 최종 완료되도록 시각적 조화를 이룹니다. + +--- + +# 📑 PART 1.5. 게시글 & 댓글 컨트롤러/서비스 전체 비즈니스 로직 흐름도 (Mermaid & Code Flow) + +--- + +## 1. 게시글(Post) 도메인 비즈니스 로직 및 쿼리 실행 흐름도 + +### ① 게시글 작성 (`POST /api/posts`) 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Client as 클라이언트 (유저/프론트) + participant Ctrl as PostController + participant Svc as PostService + participant CatRepo as PostCategoryRepository + participant MemberRepo as MemberRepository + participant PostRepo as PostRepository + participant ImgRepo as PostImageRepository + + Client->>Ctrl: POST /api/posts (DTO: categoryCode, title, content, isAnonymous, password) + Ctrl->>Ctrl: @Valid 유효성 검사 & getClientIp(httpRequest) 파싱 + Ctrl->>Svc: createPost(request, userDetails, clientIp) + + Svc->>CatRepo: findByCode(categoryCode) + alt 카테고리 없음 + CatRepo-->>Svc: Optional.empty() + Svc-->>Ctrl: CustomAuthException (POST_CATEGORY_NOT_FOUND 404) + Ctrl-->>Client: 404 Not Found + end + + alt 익명글 (ANONYMOUS 카테고리 또는 isAnonymous=true) + Svc->>Svc: anonymousPassword BCrypt 해시 암호화 + else 회원글 + Svc->>MemberRepo: findByPublicId(userDetails.getPublicId()) + end + + Svc->>PostRepo: save(Post 엔티티) + PostRepo-->>Svc: savedPost (PK 부여 완료) + + opt 첨부 이미지 존재하는 경우 + loop 이미지 URL 리스트 + Svc->>ImgRepo: save(PostImage 엔티티) + end + end + + Svc-->>Ctrl: PostResponse.from(savedPost) + Ctrl-->>Client: 201 Created (PostResponse JSON) +``` + +--- + +### ② 게시글 추천/비추천 투표 및 `@Async` 비동기 카운터 갱신 흐름도 + +```mermaid +flowchart TD + A[클라이언트: POST /api/posts/{publicId}/reactions] --> B[PostController.reactToPost] + B --> C{인증 여부 검증 userDetails != null} + C -- 미인증 --> D[403 Forbidden 예외 반환] + C -- 인증됨 --> E[PostService.reactToPost] + + E --> F[postRepository.findByPublicId] + F --> G[memberRepository.findByPublicId] + G --> H[PostReaction 엔티티 생성: post, member, type] + + H --> I[reactionRepository.save] + + I -->|DB uk_post_member_type 위반| J[DataIntegrityViolationException 캐치] + J --> K[409 Conflict ALREADY_REACTED 반환] + + I -->|최초 투표 성공| L[applicationEventPublisher.publishEvent] + L --> M[메인 트랜잭션 종료 & HTTP 200 OK 응답 반환] + + L -. 비동기 이벤트 전달 .-> N[@Async PostReactionEventListener] + N --> O[postRepository.findById] + O --> P[post.increaseLikeCount / increaseDislikeCount] + P --> Q[UPDATE post SET like_count = like_count + 1 백그라운드 SQL 실행] +``` + +--- + +## 2. 댓글/대댓글(Comment) 도메인 비즈니스 로직 및 쿼리 실행 흐름도 + +### ① 댓글/대댓글 작성 (`POST /api/posts/{publicId}/comments`) + +```mermaid +sequenceDiagram + autonumber + actor Client as 클라이언트 + participant Ctrl as CommentController + participant Svc as CommentService + participant PostRepo as PostRepository + participant CommentRepo as CommentRepository + participant MemberRepo as MemberRepository + + Client->>Ctrl: POST /api/posts/{publicId}/comments (parentId, content, isAnonymous, password) + Ctrl->>Svc: createComment(publicId, request, userDetails, clientIp) + + Svc->>PostRepo: findByPublicId(publicId) + alt 게시글 없거나 is_deleted = true + Svc-->>Ctrl: CustomAuthException (POST_NOT_FOUND 404) + Ctrl-->>Client: 404 Not Found + end + + opt parentId != null (대댓글 작성인 경우) + Svc->>CommentRepo: findById(parentId) + alt 부모 댓글 부재 또는 타 게시글 소속 + Svc-->>Ctrl: CustomAuthException (PARENT_COMMENT_NOT_FOUND 404) + end + end + + Svc->>CommentRepo: save(Comment 엔티티) + Svc->>PostRepo: post.increaseCommentCount() (comment_count +1) + Svc-->>Ctrl: CommentResponse.from(savedComment) + Ctrl-->>Client: 201 Created (CommentResponse JSON) +``` + +--- + +### ② 댓글 계층형 목록 조회 (`GET /api/posts/{publicId}/comments`) - N+1 파괴 + +```mermaid +flowchart TD + Start[클라이언트: GET /api/posts/{publicId}/comments] --> Ctrl[CommentController.getCommentsByPost] + Ctrl --> Svc[CommentService.getCommentsByPost] + + Svc --> DB[(Database)] + DB -- "SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId (단 1회 SQL)" --> Svc + + Svc --> Map[LinkedHashMap 포인터 맵 생성] + Svc --> Loop[루프 순회: List comments] + + Loop --> Check{dto.parentId == null ?} + Check -- Yes (원댓글) --> AddRoot[rootComments 리스트에 추가] + Check -- No (대댓글) --> GetParent[map.get parentId 로 O(1) 부모 DTO 획득] + GetParent --> AddChild[parentDto.children 리스트에 자식 바인딩] + + AddRoot --> LoopNext{다음 항목 존재?} + AddChild --> LoopNext + + LoopNext -- Yes --> Loop + LoopNext -- No (완료) --> Build[PostCommentListResponse 조립 반환] + Build --> Resp[HTTP 200 OK JSON 반환] +``` + +--- + +# 📑 PART 2. 백엔드 전체 코드 & 1줄 상세 주석 (Annotation) + +--- + +## 1. `Comment.java` (댓글 메인 엔티티) + +```java +package com.ikae.snowthing.domain.comment.entity; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +import java.time.LocalDateTime; + +@Entity // [JPA] 이 클래스가 데이터베이스 테이블과 매핑되는 ORM 엔티티임을 선언 +@Table(name = "comment") // [DB] 매핑될 데이터베이스 테이블명을 'comment'로 명시적 지정 +@Getter // [Lombok] 모든 필드에 대한 Getter 메서드를 자동 생성하여 불변 읽기 제공 +@NoArgsConstructor(access = AccessLevel.PROTECTED) // [JPA Spec] 기본 생성자의 접근 제어자를 PROTECTED로 제한하여 무분별한 객체 생성 방지 +@SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") // [Soft Delete] delete() 호출 시 물리 삭제 대신 UPDATE 수행 +public class Comment extends BaseTimeEntity { + + @Id // [PK] 데이터베이스 테이블의 기본키(Primary Key) 필드임을 지정 + @GeneratedValue(strategy = GenerationType.IDENTITY) // [Strategy] MySQL AUTO_INCREMENT 전략을 채택하여 기본키 자동 증가 처리 + @Column(name = "comment_id") // [Column] DB 컬럼명을 'comment_id'로 지정 + private Long id; // DB 내부 조인 성능 최적화를 위한 8바이트 정수 PK + + @ManyToOne(fetch = FetchType.LAZY) // [N:1] 댓글과 게시글의 N:1 연관 관계 지연 로딩(LAZY) 설정으로 N+1 방지 + @JoinColumn(name = "post_id", nullable = false) // [FK] 외래키 컬럼명을 'post_id'로 지정하며 필수(NOT NULL) 설정 + private Post post; // 이 댓글이 달린 대상 게시글 엔티티 참조 + + @ManyToOne(fetch = FetchType.LAZY) // [N:1] 댓글과 회원의 N:1 연관 관계 지연 로딩 설정 + @JoinColumn(name = "member_id") // [FK] 외래키 'member_id' 지정 (비회원 작성 시 NULL 수용) + private Member member; // 댓글 작성자 회원 엔티티 참조 + + @ManyToOne(fetch = FetchType.LAZY) // [Self Referencing] 자기 자신을 참조하는 N:1 부모 댓글 연관 관계 설정 + @JoinColumn(name = "parent_id") // [FK] 부모 댓글 PK를 가리키는 외래키 'parent_id' 지정 (원댓글은 NULL) + private Comment parent; // 부모 댓글 엔티티 참조 (대댓글 구현용) + + @Column(nullable = false, length = 1000) // [Column] 댓글 본문 필수(NOT NULL), 최대 1,000자 제한 + private String content; // 댓글 본문 내용 + + @Column(name = "writer_ip", nullable = false, length = 45) // [Column] 작성자 IP 주소 (IPv6 45자 수용 가능) + private String writerIp; // 작성자 클라이언트 IP 주소 + + @Column(name = "is_anonymous", nullable = false) // [Column] 익명 작성 여부 플래그 (true: 익명, false: 회원) + private boolean isAnonymous; // 익명 작성 여부 + + @Column(name = "anonymous_password") // [Column] 비회원 익명 작성 시 수정/삭제용 비밀번호 (BCrypt 암호화) + private String anonymousPassword; // 비회원 암호화 비밀번호 + + @Column(name = "is_deleted", nullable = false) // [Column] Soft Delete 상태 플래그 (true: 삭제됨, false: 정상) + private boolean isDeleted = false; // 논리 삭제 여부 + + @Column(name = "deleted_at") // [Column] Soft Delete 처리 시각 기록 필드 (미삭제 시 NULL) + private LocalDateTime deletedAt; // 삭제 일시 + + @Builder // [Design Pattern] 빌더 패턴을 적용하여 생성자 파라미터 순서 오염 방지 + public Comment(Post post, Member member, Comment parent, String content, + String writerIp, boolean isAnonymous, String anonymousPassword) { + this.post = post; + this.member = member; + this.parent = parent; + this.content = content; + this.writerIp = writerIp; + this.isAnonymous = isAnonymous; + this.anonymousPassword = anonymousPassword; + this.isDeleted = false; + } + + public void softDelete() { // [Domain Method] 엔티티 캡슐화를 유지하며 Soft Delete 상태를 변경하는 도메인 메서드 + this.isDeleted = true; // 삭제 플래그를 true로 변경 + this.deletedAt = LocalDateTime.now(); // 삭제 처리 시각을 현재 시간으로 기록 + } +} +``` + +--- + +## 2. `CommentRepository.java` (단 1회 JPQL 쿼리 조동사) + +```java +package com.ikae.snowthing.domain.comment.repository; + +import com.ikae.snowthing.domain.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId ORDER BY c.createdAt ASC, c.id ASC") // [Single Query] 단 1회의 JPQL 조인 쿼리로 특정 게시글의 모든 댓글 직조회 (N+1 파괴) + List findByPostIdWithMember(@Param("postId") Long postId); // 작성자 Member를 FETCH JOIN하여 단 1회 쿼리로 리스트를 반환하는 메서드 +} +``` + +--- + +## 3. `CommentService.java` (In-Memory Tree 조립 및 비즈니스 로직) + +```java +package com.ikae.snowthing.domain.comment.service; + +import com.ikae.snowthing.domain.comment.dto.*; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.entity.Post; +import com.ikae.snowthing.domain.post.repository.PostRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // [Performance] 읽기 전용 트랜잭션을 기본 적용하여 히버네이트 스냅샷 생성 오버헤드 차단 +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional // [Transaction] 쓰기 작업이 포함되므로 일반 트랜잭션으로 재정의 + public CommentResponse createComment(String postPublicId, CommentCreateRequest request, + CustomUserDetails userDetails, String clientIp) { + Post post = postRepository.findByPublicId(postPublicId) // 게시글 publicId로 대상 게시글 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); // 존재하지 않으면 404 예외 발생 + + if (post.isDeleted()) { // 게시글이 Soft Delete 상태인지 검증 + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); // 이미 지워진 글이면 404 예외 발생 + } + + Comment parent = null; + if (request.parentId() != null) { // parentId 요청이 존재하는 대댓글 작성 케이스인 경우 + parent = commentRepository.findById(request.parentId()) // 부모 댓글 엔티티 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.PARENT_COMMENT_NOT_FOUND)); // 없으면 404 부모 댓글 예외 발생 + + if (!parent.getPost().getId().equals(post.getId())) { // 부모 댓글의 게시글 ID와 현재 게시글 ID 일치 여부 대조 + throw new CustomAuthException(ErrorCode.INVALID_COMMENT_PARENT); // 다른 글의 댓글에 대댓글 작성을 시도하면 400 예외 차단 + } + } + + Member member = null; + String encodedPassword = null; + + if (request.isAnonymous()) { // 익명 댓글 작성인 경우 + if (request.anonymousPassword() == null || request.anonymousPassword().isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); // 익명 비밀번호 누락 시 400 예외 + } + encodedPassword = passwordEncoder.encode(request.anonymousPassword()); // 비회원 비밀번호 BCrypt 해시 암호화 + } else { // 회원 댓글 작성인 경우 + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.INVALID_CREDENTIALS); // 로그인 정보가 없으면 401 예외 + } + member = memberRepository.findByPublicId(userDetails.getPublicId()) // 인증 객체에서 작성자 Member 엔티티 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.MEMBER_NOT_FOUND)); + } + + Comment comment = Comment.builder() // Comment 엔티티 생성 + .post(post) + .member(member) + .parent(parent) + .content(request.content()) + .writerIp(clientIp != null ? clientIp : "127.0.0.1") + .isAnonymous(request.isAnonymous()) + .anonymousPassword(encodedPassword) + .build(); + + Comment savedComment = commentRepository.save(comment); // DB에 댓글 저장 + post.increaseCommentCount(); // 게시글의 역정규화 comment_count 카운트 +1 증가 + + return CommentResponse.from(savedComment); // DTO로 변환하여 응답 반환 + } + + public PostCommentListResponse getCommentsByPost(String postPublicId) { + Post post = postRepository.findByPublicId(postPublicId) // 대상 게시글 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.POST_NOT_FOUND)); + + if (post.isDeleted()) { + throw new CustomAuthException(ErrorCode.POST_NOT_FOUND); + } + + List comments = commentRepository.findByPostIdWithMember(post.getId()); // [Single Query] 단 1회 쿼리로 전체 댓글 패치 + + Map map = new LinkedHashMap<>(); // [In-Memory Tree] 포인터 맵 생성 (순서 보장 LinkedHashMap) + List rootComments = new ArrayList<>(); // 최상위 부모 댓글들을 담을 리스트 + + for (Comment comment : comments) { // 단 1회 조회의 결과를 자바 루프로 순회 + CommentResponse dto = CommentResponse.from(comment); // 엔티티를 DTO로 변환 + map.put(dto.commentId(), dto); // O(1) 참조를 위해 맵에 저장 + + if (dto.parentId() == null) { // parentId가 없는 최상위 부모 댓글인 경우 + rootComments.add(dto); // 루트 리스트에 추가 + } else { // 대댓글인 경우 + CommentResponse parentDto = map.get(dto.parentId()); // O(1) 복잡도로 맵에서 부모 DTO를 인메모리 포인터로 획득 + if (parentDto != null) { + parentDto.children().add(dto); // 부모 DTO의 children 리스트에 자식 바인딩! + } + } + } + + return PostCommentListResponse.builder() // 최종 계층형 트리 응답 DTO 생성 반환 + .publicId(postPublicId) + .totalCommentCount(post.getCommentCount()) + .comments(rootComments) + .build(); + } + + @Transactional + public void deleteComment(Long commentId, String anonymousPassword, CustomUserDetails userDetails) { + Comment comment = commentRepository.findById(commentId) // 삭제 대상 댓글 조회 + .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + + if (comment.isDeleted()) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); // 이미 지워진 댓글이면 404 반환 + } + + validateDeletePermission(comment, anonymousPassword, userDetails); // 작성자 본인 및 비회원 비밀번호 / 관리자 권한 검증 + + comment.softDelete(); // Soft Delete 처리 (is_deleted=true, deleted_at=NOW()) + comment.getPost().decreaseCommentCount(); // 게시글의 역정규화 comment_count 카운트 -1 차감 + } + + private void validateDeletePermission(Comment comment, String anonymousPassword, CustomUserDetails userDetails) { + if (comment.isAnonymous()) { + if (anonymousPassword == null || !passwordEncoder.matches(anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); // 비회원 비밀번호 불일치 시 403 예외 + } + } else { + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); // 미인증 시 403 예외 + } + + boolean isAdmin = userDetails.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + + boolean isWriter = comment.getMember() != null && comment.getMember().getPublicId().equals(userDetails.getPublicId()); + + if (!isAdmin && !isWriter) { // 작성자 본인도 아니고 관리자도 아니면 + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); // 403 Forbidden 권한 거부 예외 발생 + } + } + } +} +``` + +--- + +# 📑 PART 3. 백엔드 테스트 수트 & N+1 파괴 쿼리 검증 기법 + +--- + +## 1. `CommentServiceTest.java` (단위/통합 테스트 코드) + +```java +package com.ikae.snowthing.domain.comment.service; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.PostCommentListResponse; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest // [SpringBootTest] 스프링 통합 테스트 환경 로드 +@Transactional // [Rollback] 테스트 종료 후 DB를 자동으로 롤백하여 독립성 유지 +class CommentServiceTest { + + @Autowired private CommentService commentService; + @Autowired private PostService postService; + @Autowired private MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + + private Member member1; + private CustomUserDetails userDetails1; + private PostResponse post; + + @BeforeEach + void setUp() { + categoryRepository.findByCode("FREE") + .orElseGet(() -> categoryRepository.save(PostCategory.builder().name("자유게시판").code("FREE").build())); + + member1 = memberRepository.save(Member.builder() + .email("commenter@example.com") + .password(passwordEncoder.encode("Password123!")) + .nickname("댓글보더") + .role(Role.ROLE_USER) + .build()); + + userDetails1 = new CustomUserDetails(member1); + + post = postService.createPost(PostCreateRequest.builder() + .categoryCode("FREE") + .title("댓글 테스트 게시글") + .content("게시글 본문") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + } + + @Nested + @DisplayName("댓글 작성 테스트") + class CreateCommentTest { + + @Test + @DisplayName("원댓글과 대댓글을 정상적으로 작성한다.") + void createComment_success() { + CommentResponse parent = commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .content("원댓글입니다.") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + CommentResponse child = commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .parentId(parent.commentId()) + .content("대댓글입니다.") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + assertThat(parent.commentId()).isNotNull(); + assertThat(child.parentId()).isEqualTo(parent.commentId()); + } + + @Test + @DisplayName("존재하지 않는 부모 댓글 ID로 대댓글 작성 시 404 예외가 터진다.") + void createComment_parentNotFound() { + assertThatThrownBy(() -> commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .parentId(99999L) + .content("잘못된 부모 대댓글") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.PARENT_COMMENT_NOT_FOUND); + } + } + + @Nested + @DisplayName("댓글 트리 계층형 목록 조회 테스트") + class GetCommentsTest { + + @Test + @DisplayName("부모-자식 대댓글 트리 계층 구조가 정상 조립된다.") + void getCommentsByPost_treeStructure() { + CommentResponse parent1 = commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .content("부모 댓글 1") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .parentId(parent1.commentId()) + .content("자식 대댓글 1-1") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + PostCommentListResponse response = commentService.getCommentsByPost(post.publicId()); + + assertThat(response.totalCommentCount()).isEqualTo(2); + assertThat(response.comments()).hasSize(1); + assertThat(response.comments().get(0).children()).hasSize(1); + assertThat(response.comments().get(0).children().get(0).content()).isEqualTo("자식 대댓글 1-1"); + } + + @Test + @DisplayName("삭제된 부모 댓글은 본문이 '삭제된 댓글입니다.'로 표시된다.") + void getCommentsByPost_deletedParentDisplay() { + CommentResponse parent1 = commentService.createComment(post.publicId(), CommentCreateRequest.builder() + .content("지워질 부모 댓글") + .isAnonymous(false) + .build(), userDetails1, "127.0.0.1"); + + commentService.deleteComment(parent1.commentId(), null, userDetails1); + + PostCommentListResponse response = commentService.getCommentsByPost(post.publicId()); + + assertThat(response.comments().get(0).isDeleted()).isTrue(); + assertThat(response.comments().get(0).content()).isEqualTo("삭제된 댓글입니다."); + } + } +} +``` + +--- + +# 📌 PART 4. 작업 결과 및 검증 완료 요약 + +* **생성된 마스터 스터디 파일 경로**: + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\studyCommunityPostCommentMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/studyCommunityPostCommentMaster260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\studyCommunityPostCommentMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/studyCommunityPostCommentMaster260821.md) + - [`c:\Users\ikaes\IdeaProjects\snowthing\docs\study\sprint02\studyCommunityPostCommentMaster260821.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/study/sprint02/studyCommunityPostCommentMaster260821.md) +* **`.\gradlew.bat test` 실행 결과**: **BUILD SUCCESSFUL in 18s (모든 단위/통합 테스트 100% PASS)** +* **작업 기록 완료**: [`docs/project/work.md`](file:///c:/Users/ikaes/IdeaProjects/snowthing/docs/project/work.md) 파일에 수록 완료. diff --git a/docs/studyDomainErd260807.md b/docs/studyDomainErd260807.md new file mode 100644 index 0000000..dd457ba --- /dev/null +++ b/docs/studyDomainErd260807.md @@ -0,0 +1,107 @@ +# [Notion] Snowthing 도메인 모델 & ERD 종합 설계서 + +> 📌 **작성 일자**: 2026년 8월 7일 +> 🏷️ **문서 목적**: 노션(Notion)에 기록하여 핵심 도메인, ERD 스키마, DB 제약조건, 회원-게시글 관계를 한눈에 파악하기 위한 종합 가이드 + +--- + +## 1. 📌 핵심 도메인 목록 (Core Domains) + +Snowthing 플랫폼의 비즈니스 영역을 4개 도메인 경계(Bounded Context)로 분리하고, 1차 MVP 대상을 정립합니다. + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ Snowthing Platform │ +├───────────────────┬───────────────────┬────────────────┬───────────────┤ +│ 1. 회원/프로필 │ 2. 게시판/커뮤니티 │ 3. 리조트/현장 │ 4. 소셜/매칭 │ +│ (Member) │ (Community) │ (Resort) │ (Social) │ +└───────────────────┴───────────────────┴────────────────┴───────────────┘ +``` + +### 1.1. 회원 / 프로필 도메인 (`Member & Profile Domain`) [1차 MVP Core] +* **도메인 역할**: 유저 신원 인증 및 보더 라이딩 정체성(보더명함) 관리. +* **주요 객체**: + * `Member` (회원): 이메일, 비밀번호, 닉네임, 전역 권한(`ROLE_USER`), 계정 상태(`ACTIVE`). + * `Profile` (프로필): 프로필 이미지 URL, 자기소개(`bio`), 주 출발/거주 지역(`departure_region`). + * `BaseResort` (선호 스키장): 주 베이스 및 서브 베이스 스키장 다중 선택. + * `RidingStyle` (라이딩 성향): 카빙, 트릭, 파크, 입문, 관광 등 성향 다중 선택. + +### 1.2. 게시판 / 커뮤니티 도메인 (`Board & Community Domain`) [1차 MVP Core] +* **도메인 역할**: 유저 간 자유로운 정보 교류, 질의응답, 노하우 공유 및 댓글/추천 소통. +* **주요 객체**: + * `PostCategory` (카테고리): 자유, 익명, 질문, 장비VS, 맛집. + * `Post` (게시글): 제목, 본문, 조회수, 역정규화 카운트(댓글/추천/비추천), Soft Delete. + * `PostImage` (첨부 이미지): 1:N 이미지 업로드. + * `PostReaction` (게시글 반응): 추천(`LIKE`) / 비추천(`DISLIKE`) (1인 1회 제한). + * `Comment` (댓글/대댓글): 원댓글 및 계층형 대댓글 (`parentId`), Soft Delete. + +### 1.3. 리조트 / 현장 정보 도메인 (`Resort & Field Domain`) [2차 로드맵] +* **도메인 역할**: 전국 스키장 실시간 웹캠 및 AI 리프트 혼잡도 정보 통합 제공. + +### 1.4. 소셜 / 동행 매칭 도메인 (`Social & Matching Domain`) [3차 로드맵] +* **도메인 역할**: 1/N 카풀 비용 정산 및 1:1 같이타요 동행 매칭. + +--- + +## 2. 📊 ERD 초안 (Database ERD Draft) + +### 2.1. Mermaid ERD 다이어그램 +```mermaid +erDiagram + CREW ||--o{ MEMBER : "소속됨 (1:N)" + + MEMBER ||--o{ MEMBER_RESORT : "가지고 있음" + RESORT ||--o{ MEMBER_RESORT : "속해 있음" + + MEMBER ||--o{ MEMBER_RIDING_STYLE : "가지고 있음" + RIDING_STYLE ||--o{ MEMBER_RIDING_STYLE : "속해 있음" + + MEMBER ||--o{ POST : "작성함 (1:N)" + POST_CATEGORY ||--o{ POST : "포함함" + + POST ||--o{ POST_IMAGE : "첨부함 (1:N)" + POST ||--o{ POST_REACTION : "받음" + MEMBER ||--o{ POST_REACTION : "투표함" + + POST ||--o{ COMMENT : "달림 (1:N)" + MEMBER ||--o{ COMMENT : "작성함 (1:N)" + COMMENT ||--o{ COMMENT : "대댓글 (Self Reference)" +``` + +### 2.2. 엔티티 간 정규화 타협 및 관계 요약 (총 11개 테이블) +* **`member` 1 : N `post`**: 회원이 여러 게시글 작성. (비회원 작성 시 `member_id NULLABLE`) +* **`member` 1 : N `comment`**: 회원이 여러 댓글 작성. (비회원 작성 시 `member_id NULLABLE`) +* **`post` 1 : N `comment`**: 게시글 1개에 여러 댓글 작성. +* **`member` N : M `resort`**: `member_resort` 중계 테이블로 1:N, N:1 분리. +* **`member` N : M `riding_style`**: `member_riding_style` 중계 테이블로 1:N, N:1 분리. + +--- + +## 3. 🔒 주요 DB 제약조건 (Database Constraints & Rules) + +| 구분 | 제약조건 / 정책 | 설정 이유 및 방어 목적 | +| :--- | :--- | :--- | +| **PK 보안 전략** | DB 내부 `BIGINT id` + 외부 노출 `public_id (UUID)` | DB 조인 성능(8바이트 정수)과 외부 URL 크롤링/ID 추측 해킹 테러 100% 차단 | +| **중계 테이블 PK** | 단일 대리키 `id` (PK) + `UNIQUE (member_id, target_id)` | JPA 복합키(`@EmbeddedId`) 개발 지옥 탈출 및 중복 등록 방지 | +| **추천/비추천 제약** | `UNIQUE (post_id, member_id)` | 유저 1명당 게시글별 1회만 투표 중복 제한 | +| **소프트 삭제 (Soft Delete)** | `is_deleted = true` | 댓글 삭제 시 대댓글 흐름 유지 ("삭제된 댓글입니다" 표시) | +| **역정규화 컬럼** | `post.comment_count`, `like_count`, `dislike_count` | 게시글 목록 조회 시 매번 `SELECT COUNT(*)`로 인한 DB 과부하 차단 | +| **OAuth2 소셜 대비** | `member.password NULLABLE` | 구글/카카오 소셜 가입 시 비밀번호 부재로 인한 DB 에러 방지 | + +--- + +## 4. 🤝 게시글과 회원 관계 검토 (Post & Member Relationship) + +### 4.1. 회원-게시글 관계의 확장 (비회원 & 익명 작성 지원) +일반 커뮤니티는 `member_id`가 필수(NOT NULL)이지만, Snowthing은 **비회원 작성 및 100% 익명성 보장**을 위해 다음과 같이 수용합니다. + +1. **`member_id` (BIGINT, NULLABLE)** + * **로그인 유저 작성 시**: 세션의 `member_id` 저장 (내 프로필/작성글 관리 가능). + * **비회원 작성 시**: `member_id = NULL` 로 저장. +2. **`anonymous_password` (VARCHAR 255, NULLABLE)** + * 비회원이 글/댓글을 쓴 경우, 수정/삭제 시 본인 인증을 위해 입력한 익명 비밀번호를 BCrypt 해시로 저장. +3. **`writer_ip` (VARCHAR 45, NOT NULL)** + * 로그인 여부와 관계없이 악성 어그로, 비매너, 법적 추적을 위해 작성자 IP 필수 보관. + +### 4.2. 익명성 표기 규칙 +* `is_anonymous = true` 인 경우 닉네임을 완전 무시하고, **무조건 시스템 지정 텍스트 `익명 (123.456.***.***)`** 처럼 IP 마스킹 형태로 화면에 전원 일괄 표시됩니다. diff --git a/docs/studyPkStrategy260807.md b/docs/studyPkStrategy260807.md new file mode 100644 index 0000000..8e3ebfc --- /dev/null +++ b/docs/studyPkStrategy260807.md @@ -0,0 +1,133 @@ +# [TIL] DB PK(기본키) 설계 전략 및 3가지 대참사 방지 딥다이브 + +> 📌 **학습 날짜**: 2026년 8월 7일 +> 🏷️ **키워드**: `Primary Key`, `AUTO_INCREMENT`, `UUID`, `TSID`, `대리키 (Surrogate Key)`, `복합키 (Composite Key)`, `보안 크롤링`, `JPA @EmbeddedId` +> 💡 **학습 목표**: DB PK(기본키)를 부실하게 설계했을 때 나중에 터지는 3가지 대참사를 이해하고, 실무에서 사용하는 4가지 PK 전략의 장단점과 대가를 익혀 서비스에 맞는 최적의 PK를 스스로 선택할 수 있도록 돕습니다. + +--- + +## 0. 대전제: "PK(기본키) 설계 하나가 서비스 전체의 생사를 가른다" + +데이터베이스의 **PK(Primary Key / 기본키)**는 단순한 순번 표기가 아닙니다. 데이터베이스 인덱싱(B-Tree)의 기준점이며, 애플리케이션 프레임워크(JPA/Hibernate) 연동의 핵심축이자, 외부 시스템과 소통하는 **식별 통로**입니다. + +초기에 PK 전략을 깊이 고민하지 않고 단순히 `1, 2, 3...` 증가하는 숫자만 써두면, 서비스가 성장했을 때 **보안 테러, 개발 코드 꼬임, DB 분산 불가능**이라는 3대 대참사를 맞이하게 됩니다. + +--- + +## 1. PK 설계를 대충 했을 때 나중에 터지는 3가지 대참사 + +### 💥 대참사 1. 보안 테러: "ID 추측을 통한 데이터 싹쓸이 (크롤링 & 권한 탈취)" + +#### 😱 무슨 일이 일어날까? +만약 여러분이 작성한 게시글 조회/삭제 API URL이 다음과 같다고 해봅시다: +```http +GET /api/posts/105 +DELETE /api/posts/105 +``` + +* **ID 추측 공격 (ID Enumeration Attack)**: 숫자가 1씩 규칙적으로 증가하는 것을 눈치채고, 악의적인 해커나 크롤링 스크립트가 `104`, `103`, `102`... 순서대로 숫자만 올려가며 전체 데이터를 1초 만에 싹 긁어갑니다. +* **비즈니스 기밀 유출**: 회원가입을 했는데 내 회원 ID가 `500`번인 것을 보고 경쟁사가 **"아, 이 서비스 총 가입자가 500명밖에 안 되네?"** 하고 회사 비즈니스 규모를 훤히 파악하게 됩니다. +* **권한 검증 허점 클릭**: 권한 검증 로직에 헛점이 생기면 숫자를 바꿔가며 남의 글이나 회원 정보를 무단 삭제/수정하는 대참사가 벌어집니다. + +--- + +### 💥 대참사 2. JPA 개발 지옥: "중계 테이블 복합 PK (`member_id` + `resort_id`)의 비극" + +#### 💡 용어 풀이 +> * **복합키 (Composite Key)**: 2개 이상의 컬럼을 합쳐서 하나의 PK로 사용하는 방식. (예: `member_id` + `resort_id`) +> * **대리키 (Surrogate Key)**: 비즈니스 의미가 없는 인공적인 고유 ID (예: `id` BIGINT AUTO_INCREMENT)를 새로 만들어서 PK로 쓰는 방식. + +#### 😱 무슨 일이 일어날까? +회원과 리조트의 N:M 중계 테이블(`member_resort`)에 `member_id`와 `resort_id` 두 개를 묶어서 복합 PK로 지정했을 때, 자바 JPA(Hibernate) 개발 시 다음과 같은 지옥이 펼쳐집니다: + +1. **지저분한 복합키 클래스 파편화**: JPA에서 복합키를 쓰려면 `@EmbeddedId` 또는 `@IdClass`라는 클래스를 별도로 만들어 `Serializable` 구현, `equals()`, `hashCode()` 메서드를 일일이 오버라이딩해야 합니다. +2. **개발 생산성 폭망**: 중계 데이터를 조회나 수정할 때마다 단일 숫자 ID 대신 복합키 객체를 매번 생성해서 넘겨야 하므로 **코드가 엄청나게 길어지고 오작동 에러가 속출**합니다. + +--- + +### 💥 대참사 3. DB 확장 불가능: "서버 여러 대(분산 DB)로 늘릴 때 PK 충돌" + +#### 😱 무슨 일이 일어날까? +서비스가 대박이 나서 DB 서버 1대로 감당이 안 되어 DB를 2대(A 서버, B 서버)로 분할(Sharding)하는 상황을 맞이했습니다. + +* A 서버의 DB도 `post_id = 1`을 생성하고, B 서버의 DB도 `post_id = 1`을 생성합니다. +* 두 DB의 데이터를 통합하거나 조인할 때 **PK 충돌 참사**가 터져서 데이터가 엉망진창으로 뒤죽박죽 섞이고 복구가 불가능해집니다. + +--- + +## 2. 실무에서 사용하는 4가지 PK 전략 및 장단점 비교 + +### 🛡️ 전략 A: AUTO_INCREMENT (내부 PK) + `public_id` UUID (외부 노출용) [실무 정석 ⭐] + +#### ⚙️ 작동 원리 +* **DB 내부 (PK)**: DB 조인 성능 및 저장 공간 최적화를 위해 **8바이트 숫자 `id` (BIGINT AUTO_INCREMENT)**를 PK로 사용합니다. +* **외부 노출 (Public ID)**: 외부 API나 URL에는 `posts/a3b2c1d4-5e6f-4a...` 같은 **36자리 UUID 문자열 (`public_id`)**을 따로 만들어서 보여줍니다. + +#### ⚖️ 장단점 & 대가 (Trade-off) +* **장점 (얻는 것)**: + * DB 내부 조인(JOIN) 및 B-Tree 인덱스 성능이 8바이트 정수라 **빛의 속도로 빠름.** + * 외부에 노출되는 ID는 추측 불가능한 UUID이므로 **해킹/크롤링/비즈니스 노출 위험 100% 차단.** +* **단점 & 대가 (치르는 것)**: + * `member` 및 `post` 테이블에 `public_id` 컬럼을 하나 더 만들어야 함. + * 외부 요청이 왔을 때 `public_id`로 DB를 조회하는 쿼리가 한 번 더 들어감. + +--- + +### 🛡️ 전략 B: 36자리 UUID (Universally Unique Identifier) 문자열 PK + +#### ⚙️ 작동 원리 +* `d3b07384-d113-46e4-a719-d640b728040d` 같은 36자리 무작위 난수 문자열을 DB PK로 아예 직접 사용합니다. + +#### ⚖️ 장단점 & 대가 (Trade-off) +* **장점 (얻는 것)**: + * 세상에서 유일한 고유값이므로 ID 추측 불가능. + * DB가 여러 대(분산 DB)여도 PK 충돌 위험 0%. +* 💥 **치명적 단점 (치르는 대가)**: + * **DB 성능 저하 (인덱스 파편화)**: UUID는 무작위 문자열이라 DB B-Tree 인덱스에 저장될 때 순서 없이 무작위 위치로 쑤셔 지므로, DB 저장 속도가 둔화되고 메모리를 4배 이상 더 먹습니다. + +--- + +### 🛡️ 전략 C: TSID (Time-Sorted Unique Identifier) / ULID [최신 트렌드 🚀] + +#### ⚙️ 작동 원리 +* **"현재 시간(Timestamp)" + "무작위 난수"**를 조합하여 만드는 64비트 정수(BIGINT) 형태의 고유 ID. + +#### ⚖️ 장단점 & 대가 (Trade-off) +* **장점 (얻는 것)**: + * 숫자가 1씩 증가하지 않아 **해커가 절대 추측 불가능**. + * 생성된 시간 순서대로 정렬(Sortable)되므로 DB 인덱스 성능이 BIGINT 정수처럼 **극상으로 빠름**. + * 분산 DB 환경에서도 PK 충돌 제로. +* **단점 & 대가 (치르는 것)**: + * 자바 라이브러리(TSID Creator 등)를 프로젝트에 별도로 추가하여 ID 생성 로직을 적용해야 함. + +--- + +### 🛡️ 전략 D: [중계 테이블 전용] 단일 대리키(`id`) + `UNIQUE KEY` 조합 + +#### ⚙️ 작동 원리 +* 중계 테이블(`member_resort`, `member_riding_style`)에 `member_id` + `resort_id` 복합 PK를 쓰지 않습니다! +* 대신 **`id` (BIGINT AUTO_INCREMENT)** 단일 대리키를 PK로 새로 만들어주고, `(member_id, resort_id)`에는 **`UNIQUE KEY` 제약조건**을 겁니다. + +#### ⚖️ 장단점 & 대가 (Trade-off) +* **장점 (얻는 것)**: + * JPA 복합키 지옥(`@EmbeddedId`)에서 완전히 벗어나 **자바 코드가 10배 깔끔해짐**. + * 중복 저장 방지 제약조건(`UNIQUE`)은 DB 레벨에서 완벽하게 유지됨. +* **단점 & 대가 (치르는 것)**: + * 중계 테이블에 `id`라는 8바이트 컬럼이 하나 더 생겨 용량을 아주 미세하게 더 먹음. + +--- + +## 🎯 3. 최종 요약 및 비교표 + +| PK 설계 전략 | 보안성 | DB 조회/인덱스 성능 | JPA 개발 편의성 | 분산 DB 확장성 | +| :--- | :--- | :--- | :--- | :--- | +| **1. 순수 AUTO_INCREMENT** | ❌ 취약 (추측 가능) | 🟢 극상 | 🟢 나쁨 (복합키 시) | ❌ 충돌 발생 | +| **2. AUTO_INCREMENT + Public ID(UUID)** | 🟢 완벽 | 🟢 극상 | 🟢 양호 | 🟡 보통 | +| **3. 순수 UUID 문자열 PK** | 🟢 완벽 | 🔴 느림 (인덱스 파편화) | 🟢 양호 | 🟢 완벽 | +| **4. TSID (시간정렬 정수)** | 🟢 완벽 | 🟢 극상 | 🟢 최고 | 🟢 완벽 | +| **5. 중계 테이블 단일 대리키(`id`)** | - | 🟢 극상 | 🟢 최고 (JPA 지옥 해방) | - | + +--- + +이제 각 PK 방식이 가진 **장점과 치명적 대가(Trade-off)**를 비교하실 수 있습니다! +이 학습 문서를 읽어보시고, 우리 Snowthing 서비스에 어떤 PK 방식을 적용하는 것이 가장 좋을지 천천히 고민해 보세요! 😊 diff --git a/docs/studySessionAuth260806.md b/docs/studySessionAuth260806.md new file mode 100644 index 0000000..66c4615 --- /dev/null +++ b/docs/studySessionAuth260806.md @@ -0,0 +1,67 @@ +# [TIL] 세션 기반 인증/인가 정책 및 쿠키 보안 설계 + +> 📌 **학습 날짜**: 2026년 8월 6일 +> 🏷️ **키워드**: `Session Authentication`, `Cookie Security`, `JSESSIONID`, `Session Fixation`, `Redis`, `Technical Debt` +> 💡 **한 줄 요약**: 1차 MVP 서비스에 적용할 세션 인증 및 쿠키 보안 정책을 정립하고, 기술적 트레이드오프와 향후 Redis 분산 세션 확장 방향을 정리합니다. + +--- + +## 1. 세션 인증 정책 (Session Authentication Policy) + +| 정책 항목 | 결정 사항 | 상세 설명 및 설계 배경 | +| :--- | :--- | :--- | +| **로그인 시 세션 저장 정보** | `memberId`, `role` | 최소 식별 정보만 메모리에 보관. 비밀번호, 이메일, 프로필 전체 저장 지양 (메모리 절약 및 보안) | +| **세션 만료 시간** | `30분` (Idle Timeout) | 비활동 시간 30분 기준 세션 만료. 보안성과 유저 이용 편의성의 최적 타협점 | +| **로그아웃 처리** | `session.invalidate()` | 백엔드 세션 객체 즉시 파기 및 클라이언트 쿠키 만료 (`Max-Age=0`) 처리 | +| **동시 로그인 정책** | 동시 접속 허용 | 1차 MVP에서는 동일 계정 다중 기기 접속 허용 (향후 중복 로그인 제어 고려) | +| **세션 ID 재발급 시점** | 로그인 성공 시 | 로그인 성공 시점에 `changeSessionId()`를 호출하여 **세션 고정 공격(Session Fixation)** 방어 | +| **인증 필요 API 구분** | 비인증 / 인증 API 분리 | **비인증**: 목록/상세 조회, 회원가입, 로그인
**인증 필요**: 게시글/댓글 작성·수정·삭제, 프로필 조회 | +| **권한 검증 방식** | 작성자(소유자) 검증 | 수정/삭제 요청 시 `세션 memberId == DB 작성자 memberId` 비교 검증 후 처리 | + +--- + +## 2. 쿠키 보안 정책 (Cookie Security Policy) + +세션 식별자(`JSESSIONID`)를 전달하는 쿠키에 엄격한 보안 속성을 적용하여 주요 Web Vulnerability를 방어합니다. + +``` +Set-Cookie: JSESSIONID=A1B2C3D4E5F6...; Path=/; HttpOnly; Secure; SameSite=Lax +``` + +* 🛡️ **HttpOnly (`true`)** + * 자바스크립트(`document.cookie`)를 통한 세션 쿠키 접근을 원천 차단합니다. + * **방어 목적**: XSS(Cross-Site Scripting) 공격으로 인한 세션 키 탈취 방지. +* 🔒 **Secure (`true`)** + * 암호화된 `HTTPS` 통신 채널에서만 쿠키가 전송되도록 제한합니다. (로컬 개발 환경에서는 조건부 적용) + * **방어 목적**: 네트워크 패킷 스니핑을 통한 세션 키 유출 방지. +* 🌐 **SameSite (`Lax`)** + * 타 사이트에서 발생하는 서드파티 요청 시 쿠키 전송을 제한하되, 일반적인 서프(링크 클릭) 이동 시에는 쿠키를 전송합니다. + * **방어 목적**: CSRF(Cross-Site Request Forgery) 공격 방어. +* 📍 **Path (`/`)** + * 웹 애플리케이션의 모든 API 경로(`/api/...`)에서 쿠키가 정상적으로 전송되도록 전역 설정합니다. +* ⏳ **Max-Age 및 세션 쿠키 정책** + * 별도의 Max-Age를 지정하지 않는 **세션 쿠키(Session Cookie)** 방식을 기본으로 채택합니다. + * 브라우저가 종료되면 클라이언트 단에서 세션 쿠키가 자동 파기됩니다. +* ⚠️ **개인정보 쿠키 저장 절대 금지** + * 쿠키에는 오직 서버 세션을 가리키는 무작위 난수 식별자(`JSESSIONID`)만 저장합니다. + * 비밀번호, 이메일, 닉네임, 개인정보 등은 **절대로 쿠키에 담지 않습니다.** + +--- + +## 3. 기술적 깊이 및 배경 (Deep Dive & Trade-offs) + +### 3.1. JWT 대신 세션(Session) 방식을 선택한 이유 +* **세션 제어의 용이성**: JWT는 Stateless 특성상 이미 발급된 토큰을 즉시 강제 만료(블랙리스트 관리 없이)시키기 어렵습니다. 반면 세션은 보안 이슈나 로그아웃 시 서버에서 `session.invalidate()`로 즉시 파기할 수 있습니다. +* **보안성**: 토큰을 로컬스토리지에 저장할 경우 XSS에 노출되기 쉽고, 쿠키에 담더라도 JWT 크기(헤더+페이로드+서명)로 인한 네트워크 오버헤드가 발생합니다. 세션 방식은 오직 난수 형태의 키만 쿠키로 전송하므로 유출 리스크가 적습니다. + +### 3.2. 세션 저장 정보의 최소화 기준 +* 세션 메모리(RAM)는 서버의 한정된 자원입니다. 세션 객체에 유저 전체 프로필이나 대용량 객체를 담으면 유저 수가 늘어날 때 **OutOfMemoryError(OOM)**가 발생할 수 있습니다. +* 따라서 오직 PK값인 `memberId`와 권한 정보 `role`만 담고, 필요한 유저 상세 정보는 DB 조회를 통해 가져오도록 최소화 기준을 수립했습니다. + +### 3.3. 다중 서버 환경에서의 개선 방향 (Scale-out 대비) +* **현 상태의 한계**: 현재 구현은 단일 톰캣 서버의 **인메모리(In-Memory) 세션**을 사용합니다. +* **개선 방향**: 서버가 여러 대(Scale-out)로 증설되면, 유저 요청이 다른 서버로 로드밸런싱될 때 세션이 유실되는 문제가 발생합니다. 이를 위해 향후 **Spring Session + Redis** 기반의 **중앙 집중식 분산 세션 저장소**를 도입하여 상태를 공유하도록 개선할 예정입니다. + +### 3.4. 의도적으로 남긴 기술 부채 (Technical Debt) +* 1차 MVP 단계에서는 백엔드 개발 속도 및 도메인 핵심 검증을 위해 **단일 서버 톰캣 메모리 세션**을 채택했습니다. +* 세션 유실 및 Scale-out 한계라는 기술 부채가 존재함을 인지하고 있으며, 2차 확장 시 **Redis 분산 세션 연동**으로 매끄럽게 전환될 수 있도록 스프링 세션 Abstraction Layer를 고려하여 설계했습니다. diff --git a/docs/studySessionFlow260810.md b/docs/studySessionFlow260810.md new file mode 100644 index 0000000..c828dfc --- /dev/null +++ b/docs/studySessionFlow260810.md @@ -0,0 +1,83 @@ +# [Notion] 세션 인증 순서도 (Session Authentication Flowchart) + +> 📌 **작성 일자**: 2026년 8월 10일 (최종 수정: 2026년 8월 12일) +> 🏷️ **문서 목적**: 마크다운(Markdown) 및 노션(Notion) 환경에서 100% 정상 visual 다이어그램으로 렌더링되는 **Spring Session Redis** 기반 세션 인증 순서도 + +--- + +## 📊 1. 세션 인증 전체 플로우차트 (Flowchart TD) + +Below is the Mermaid flowchart rendered directly in GitHub Markdown and Notion. + +```mermaid +flowchart TD + A["클라이언트 API 요청 /api/..."] --> B{"1. 비인증 허용 API인가? (PermitAll)"} + + %% 비인증 허용 경로 + B -- Yes --> C["비인증 로직 즉시 실행 (회원가입/목록조회/비회원글작성)"] + C --> END1["200 OK / 201 Created 응답"] + + %% 인증 필요 경로 + B -- No --> D{"2. JSESSIONID 쿠키가 헤더에 존재하는가?"} + + %% 쿠키 없음 + D -- No --> E1["401 Unauthorized 에러 (로그인이 필요합니다)"] + + %% 쿠키 존재 + D -- Yes --> E2{"3. Spring Session Redis 저장소에 유효한 세션이 존재하는가?"} + + %% 세션 만료/파기됨 + E2 -- No --> F1["401 Unauthorized 에러 (세션이 만료되었습니다)"] + F1 --> F2["Set-Cookie: JSESSIONID=; Max-Age=0 (쿠키 클리어)"] + + %% 세션 유효함 + E2 -- Yes --> G{"4. 요청 종류 구분"} + + %% 경로 1: 로그인 요청 처리 (Session Fixation 방어) + G -- 로그인 요청 --> H1["Security: changeSessionId (기존 세션 파기 & 신규 세션ID 재발급)"] + H1 --> H2["Spring Session Redis에 memberId & role 0.0001초 저장"] + H2 --> H3["Set-Cookie: JSESSIONID=new_id (HttpOnly, Secure, SameSite=Lax)"] + H3 --> END2["200 OK 로그인 성공"] + + %% 경로 2: 일반 인증 API 요청 (프로필/글작성 등) + G -- 일반 인증 API --> I1["Spring Session Redis에서 memberId & role 0.0001초 추출"] + I1 --> I2["SecurityContextHolder에 인증 객체 등록"] + I2 --> I3["소유자 권한 검증 & 비즈니스 로직 실행"] + I3 --> END3["200 OK 응답 데이터 반환"] + + %% 경로 3: 로그아웃 요청 + G -- 로그아웃 요청 --> J1["Redis 세션 완전 파기 (spring:session 키 삭제)"] + J1 --> J2["Set-Cookie: JSESSIONID=; Max-Age=0 (브라우저 쿠키 즉시 만료)"] + J2 --> END4["200 OK 로그아웃 성공"] + + %% 스타일링 + style A fill:#333333,stroke:#ffffff,color:#ffffff + style B fill:#1f618d,stroke:#ffffff,color:#ffffff + style D fill:#1f618d,stroke:#ffffff,color:#ffffff + style E2 fill:#1f618d,stroke:#ffffff,color:#ffffff + style G fill:#28b463,stroke:#ffffff,color:#ffffff + style E1 fill:#922b21,stroke:#ffffff,color:#ffffff + style F1 fill:#922b21,stroke:#ffffff,color:#ffffff + style H1 fill:#d4ac0d,stroke:#ffffff,color:#000000 + style J1 fill:#d4ac0d,stroke:#ffffff,color:#000000 +``` + +--- + +## 🔍 2. 플로우차트 단계별 조건 분기 설명 + +### 1단계: API 접근 권한 판별 (`PermitAll` vs `Authenticated`) +* `/api/members` (회원가입), `/api/auth/login` (로그인), `/api/posts` (목록/상세 조회) 같은 **비인증 공개 API는 쿠키 검증을 스킵하고 즉시 실행**됩니다. + +### 2단계 & 3단계: 2중 세션 쿠키 검증 (`JSESSIONID` + Spring Session Redis) +* **1차 검증 (브라우저 쿠키)**: 요청 헤더에 `JSESSIONID` 쿠키가 아예 없으면 즉시 `401 Unauthorized`를 반환합니다. +* **2차 검증 (Spring Session Redis RAM)**: 쿠키가 있더라도 Redis 세션 저장소(`spring:session:sessions:...`)에서 만료되거나 삭제되었으면 `401 Unauthorized` 반환과 함께 브라우저 쿠키를 만료(`Max-Age=0`)시킵니다. + +### 4단계: 요청 종류별 세션 락 & 처리 메커니즘 +1. **로그인 시 (Session Fixation 방어)**: + * 기존 임시 세션 ID를 파기하고 신규 세션 ID를 발급하는 `request.changeSessionId()`를 호출하여 세션 탈취 공격을 방어합니다. + * `Set-Cookie: JSESSIONID=...; Path=/api; HttpOnly; Secure; SameSite=Lax` 쿠키를 내려줍니다. +2. **일반 인증 API 요청 시**: + * Redis에서 0.0001초 만에 `memberId`를 추출하여 `SecurityContextHolder`에 등록 후 비즈니스 로직을 수행합니다. +3. **로그아웃 시**: + * Redis 세션 키를 삭제하여 서버 세션을 완전 파기하고, 쿠키 만료 헤더를 내려줍니다. diff --git a/docs/studySystemArch260810.md b/docs/studySystemArch260810.md new file mode 100644 index 0000000..0fa58bd --- /dev/null +++ b/docs/studySystemArch260810.md @@ -0,0 +1,87 @@ +# [Notion] Snowthing 전체 시스템 아키텍처 구성도 (TIL) + +> 📌 **작성 일자**: 2026년 8월 10일 +> 🏷️ **문서 목적**: 노션(Notion) 및 마크다운에 복사하여 전체 시스템 구성도, 계층별 역할, 데이터 흐름을 한눈에 파악하고 공부하기 위한 종합 가이드 + +--- + +## 📊 1. 시스템 구성도 다이어그램 (Mermaid System Architecture) + +Below is the system architecture diagram rendered directly in GitHub Markdown and Notion. + +```mermaid +flowchart TB + %% 클라이언트 레이어 + subgraph Client_Layer [👤 Client / Browser Layer] + Browser[🌐 Web Browser / Mobile Client] + end + + %% 프론트엔드 레이어 + subgraph Frontend_Layer [💻 Frontend Layer] + NextJS[⚡ Next.js 14+ Application
App Router / React Query / Optimistic UI] + end + + %% 네트워크 & 프록시 레이어 + subgraph Proxy_Layer [🛡️ Network & Proxy Layer] + Nginx[🔒 Nginx Reverse Proxy
SSL/HTTPS Termination
Route /api/* -> Spring Boot] + end + + %% 백엔드 애플리케이션 레이어 + subgraph Backend_Layer [⚙️ Backend Application Layer - Spring Boot 3.2+ / Java 21] + SecFilter[🔒 Spring Security Filter
JSESSIONID / HttpOnly / changeSessionId] + + + subgraph Core_App [App Core Services] + MemberSvc[👤 Member Service
Profile & Crew Management] + PostSvc[📝 Post Service
Single Query + In-Memory Tree] + CommentSvc[💬 Comment Service
Single Query + Flat List] + ReactionSvc[👍 Reaction Service
Async Event Publisher] + end + + BatchJob[⏰ Scheduled Batch Sync Job
10s Write-Behind DB Flush] + end + + %% 인메모리 캐시 & 분산 락 레이어 + subgraph Cache_Layer [🚀 In-Memory Cache & Buffer Layer] + Redis[(🧠 Redis 7.x In-Memory
SADD Voters / INCR Like Counter
AOF Persistence)] + end + + %% 릴레이셔널 데이터베이스 레이어 + subgraph Database_Layer [🗄️ Relational Database Layer] + MySQL[(🐬 MySQL 8.0 Container
InnoDB Engine / 11 Tables
BIGINT id + UUID v7 Secondary Index)] + end + + %% 데이터 흐름 연결 + Browser <-->|HTTP/HTTPS Page Request| NextJS + Browser <-->|HTTPS API Request / Cookies| Nginx + NextJS <-->|Server Component Fetch| Nginx + + Nginx <-->|Forward /api/*| SecFilter + SecFilter <-->|Session Check / Context| Core_App + + ReactionSvc -->|0.0001s In-Memory INCR & SADD| Redis + BatchJob <-->|Read Counter & Flush| Redis + + Core_App <-->|JPA / JPQL / Single Query| MySQL + BatchJob -->|Write-Behind Batch UPDATE| MySQL + + %% 스타일링 + style Browser fill:#333,stroke:#fff,color:#fff + style NextJS fill:#000,stroke:#61dafb,color:#61dafb + style Nginx fill:#009639,stroke:#fff,color:#fff + style SecFilter fill:#d4ac0d,stroke:#fff,color:#000 + style Core_App fill:#2e4053,stroke:#fff,color:#fff + style Redis fill:#dc382d,stroke:#fff,color:#fff + style MySQL fill:#00758f,stroke:#fff,color:#fff + style BatchJob fill:#8e44ad,stroke:#fff,color:#fff +``` + +--- + +## 🔍 2. 5대 계층 구조 및 핵심 기술 요약 + +1. **`Client & Frontend`**: Next.js 14+, React Query의 **낙관적 UI 업데이트**를 통해 추천 0.001초 미친 반응성 제공. +2. **`Proxy & Network`**: Nginx를 통해 `/api/*` 라우팅 분리 및 **6대 쿠키 보안 속성** 적용. +3. **`Backend Core`**: Spring Boot 3.x, **`Single Query + In-Memory Tree`**로 N+1 문제 완파, 10초 주기 **`Write-Behind` 배치 스케줄러** 구동. +4. **`In-Memory Cache`**: Redis 7.x `INCR`/`SADD`로 DB 락 병목 제거, AOF로 데이터 유실 방지. +5. **`Relational Database`**: MySQL 8.0 (11개 테이블), `BIGINT id` + **`UUID v7` 세컨더리 인덱스** 적용. diff --git a/docs/study_sprint01_session_concurrency_jpa_260817.md b/docs/study_sprint01_session_concurrency_jpa_260817.md new file mode 100644 index 0000000..65fc06c --- /dev/null +++ b/docs/study_sprint01_session_concurrency_jpa_260817.md @@ -0,0 +1,539 @@ +# 📚 [Snowthing Study Report] Sprint 1 세션 인증, 동시성 락 실증, JPA & DB 제약조건 통합 공부 가이드 + +> **본 문서는 노션(Notion)에 그대로 복사하여 학습할 수 있도록 작성된 종합 공부용 문서입니다.** +> **파일명**: `docs/study_sprint01_session_concurrency_jpa_260817.md` +> **작성일**: 2026년 8월 17일 +> **주요 키워드**: `Spring Session`, `changeSessionId`, `BCrypt`, `N:M 중계 테이블`, `CountDownLatch 동시성 락 실증`, `JPA JOIN FETCH`, `DB UNIQUE 제약조건`, `@Modifying(clearAutomatically = true)` + +--- + +# 📑 **목차 (Table of Contents)** + +1. [Part 1: 핵심 기술 선택의 이유, 비교군, Trade-Off & 극복 방안](#part-1-핵심-기술-선택의-이유-비교군-trade-off--극복-방안) + - 1.1 인증 방식: Spring Session (세션) vs JWT (JSON Web Token) + - 1.2 세션 고정 방어: `changeSessionId()` vs `newSession()` vs `none()` + - 1.3 비밀번호 암호화: `BCrypt` vs `Argon2` vs `PBKDF2` vs `SHA-256` + - 1.4 N:M 관계 매핑: 대리키 `id` PK 중계 엔티티 vs 복합키(`@EmbeddedId`) vs 단일 JSON 저장 + - 1.5 N+1 성능 극복: `JOIN FETCH` vs `FetchType.EAGER` vs `@BatchSize` +2. [Part 2: 동시성 제어(Concurrency Lock) & 비정상 파라미터 실증 테스트 레포트](#part-2-동시성-제어concurrency-lock--비정상-파라미터-실증-테스트-레포트) + - 2.1 왜 Mockito 가 아닌 실제 DB + 멀티스레드로 동시성을 테스트했는가? + - 2.2 `CountDownLatch` + `ExecutorService` 10개 멀티스레드 동시 가입 물리적 원리 + - 2.3 Race Condition 락 검증 결과 분석 (Java 차단 실패 ➔ DB UNIQUE 인덱스 차단 성공) + - 2.4 경계값 & 비정상 파라미터 2중 검증 테스트 결과 +3. [Part 3: 완성 코드 & JPA 옵션 - DB 제약조건 연동 종합 해설서](#part-3-완성-코드--jpa-옵션---db-제약조건-연동-종합-해설서) + - 3.1 엔티티 & N:M 중계 구조 (`Member.java`, `MemberResort.java`, `Resort.java`) 주석 해설 + - 3.2 JPA 주요 어노테이션 & 옵션 상세 파헤치기 + - 3.3 DB 제약조건(`PK`, `UNIQUE`, `FK`)과 JPA 연관관계의 물리적 연동 원리 + - 3.4 전체 실증 테스트 수트 통합 모음 (25개 전체 통합/단위 테스트 수트) + +--- + +# 🧠 **[Part 1] 핵심 기술 선택의 이유, 비교군, Trade-Off & 극복 방안** + +## 1.1 인증 방식: Spring Session (세션) vs JWT (JSON Web Token) + +```text + [Spring Session (서버 중앙 제어)] [JWT (Stateless 무상태)] + - 세션 데이터: 서버 RAM/Redis 보관 - 토큰 데이터: 클라이언트 브라우저 보관 + - 보안성: 무작위 식별자(JSESSIONID) - 보안성: 서명된 Claim 데이터 포함 + - 강제 로그아웃: 가능 (서버 세션 삭제) - 강제 로그아웃: 불가능 (만료 시까지 유효) +``` + +### 🎯 선택: Spring Session (세션 기반 인증) +* **비교군**: JWT (JSON Web Token) +* **선택 이유**: + * 커뮤니티 플랫폼 특성상 특정 악성 유저 발생 시 **관리자가 즉시 계정을 정지시키고 접속 세션을 강제 파기(Session Invalidation)**할 수 있는 **서버 중앙 통제권**이 필수적임. + * JWT는 클라이언트가 토큰을 보관하므로, 서버에서 특정 유저를 즉시 차단(Blacklist)하기 어렵고 별도의 Redis Blacklist 저장소를 둬야 하는 아키텍처 복잡성이 생김. +* **장점**: + * 클라이언트 브라우저에는 민감 정보(PII)가 들어있지 않은 무작위 세션 키(`JSESSIONID`)만 쿠키로 전달됨. + * 세션 상태를 서버가 100% 제어하므로 즉시 로그아웃 및 강제 세션 파기가 가능함. +* **치러야 하는 대가 (Trade-off)**: + * 서버 메모리(RAM) 사용량 증가 및 서버를 여러 대 증설(Scale-Out)할 때 세션 불일치(Session Discrepancy) 문제 발생. +* **아키텍처 레벨 극복 방안**: + 1. `server.servlet.session.timeout=30m` 30분 세션 타임아웃을 설정하여 30분간 활동이 없는 세션은 톰캣 백그라운드 스레드가 GC로 메모리를 자동 정리하도록 구성. + 2. 서버 확장 시 DB/서버 RAM이 아닌 인메모리 세션 서버인 **Spring Session Redis** 로 전환 가능하도록 `@EnableRedisHttpSession` 아키텍처 확장 레이어를 설계함. + +--- + +## 1.2 세션 고정 방어: `changeSessionId()` vs `newSession()` vs `none()` + +### 🎯 선택: `request.changeSessionId()` +* **비교군**: `newSession()`, `none()` (세션 유지) +* **선택 이유**: + * **세션 고정 공격 (Session Fixation Attack)**: 해커가 미리 자신이 발급받은 세션 ID를 피해자 유저의 쿠키에 심어두고, 피해자가 해당 세션으로 로그인하면 해커가 피해자의 계정 권한을 그대로 훔쳐 쓰는 공격. +* **동작 원리 및 비교**: + * `none()`: 로그인 후에도 세션 ID를 바꾸지 않음 ➔ 세션 고정 공격에 노출. + * `newSession()`: 기존 세션의 모든 속성을 삭제하고 아예 새 세션을 만듦 ➔ 로그인 전 유저가 설정해 둔 스키장/라이딩 성향 검색 필터 세션 데이터까지 모두 소실됨. + * **`changeSessionId()` (선택)**: 세션 객체 내부의 속성 데이터(로그인 전 선택한 스키장/성향 검색 필터 상태 등)는 그대로 유지하면서, **외부에 노출된 `JSESSIONID` 세션 식별자만 암호학적 난수로 교체**함. +* **Trade-off & 극복**: + * 기존 세션 Map 에서 식별자 키를 갱신하는 메모리 참조 교체 연산 비용 발생 ➔ 톰캣 및 Spring Security 6 세션 매니저의 인메모리 HashMap 키 교체 처리로 연산 오버헤드를 극복함. + +--- + +## 1.3 비밀번호 암호화: `BCryptPasswordEncoder` vs `Argon2` vs `PBKDF2` vs `SHA-256` + +### 🎯 선택: `BCryptPasswordEncoder` +* **비교군**: `SHA-256` (단순 해시), `PBKDF2`, `Argon2` +* **선택 이유**: + * `SHA-256` 같은 단순 단방향 해시는 GPU 병렬 연산을 이용한 **레인보우 테이블(Rainbow Table) 공격**으로 빠르게 복호화가 시도될 수 있음. + * BCrypt는 비밀번호 암호화 시 **솔트(Salt)**를 매번 무작위로 생성하여 저장하며, **Work Factor (Key Stretching Cost)**를 적용하여 무차별 대입(Brute-Force) 연산 속도를 물리적으로 지연시킴. +* **Argon2 / PBKDF2 대비 장점**: + * Argon2 가 최신 표준이나 추가 외부 라이브러리 연동이 필요함. Spring Security 의 표준 검증 모듈인 BCrypt가 유지보수성 측면에서 검증됨. +* **Trade-off & 극복**: + * BCrypt 해싱 연산은 의도적으로 CPU 연산 리소스를 소모함 (1건당 수십ms 소요). + * 회원가입/로그인 시점에만 한정되어 발생하므로 전체 서비스 응답 성능에 영향을 주지 않음. + +--- + +## 1.4 N:M 관계 매핑: 대리키 `BIGINT id` PK 중계 엔티티 vs 복합키(`@EmbeddedId`) vs 단일 JSON 저장 + +### 🎯 선택: 대리키 `BIGINT id` PK + 복합 UNIQUE 제약조건 중계 엔티티 (`MemberResort`, `MemberRidingStyle`) +* **비교군**: + 1. 회원 테이블 내 문자열/JSON 컬럼에 `[1, 2, 3]` 형태로 저장 + 2. `@EmbeddedId` (member_id + resort_id) 복합키 식별 관계 중계 엔티티 +* **선택 이유 & 물리적 비교**: + * **JSON 저장 방식의 한계**: 데이터베이스 정규화 1NF(원자성) 위반. 특정 스키장을 이용하는 회원 목록 검색(`WHERE resort_id = 1`) 시 Full Table Scan 이 발생하여 성능 저하. + * **복합키(`@EmbeddedId`)의 한계**: JPA 복합키 클래스(`MemberResortId`)를 별도로 작성해야 하며, `EqualsAndHashCode` 재정의 필수 및 부모 엔티티 조인 시 식별자 객체 생성 비용 발생. + * **대리키 `id` PK 방식 (선택)**: 8바이트 정수 `id` AUTO_INCREMENT 에 독립 PK를 두고, `UNIQUE (member_id, resort_id)` 복합 유니크 제약을 걸어 정규화 3NF 준수 + JPA 엔티티 조작 편의성을 확보함. + +--- + +## 1.5 N+1 성능 극복: `JOIN FETCH` vs `FetchType.EAGER` vs `@BatchSize` + +### 🎯 선택: JPQL `JOIN FETCH` 쿼리 +* **비교군**: `@ManyToOne(fetch = FetchType.EAGER)` (즉시 로딩), `@BatchSize` +* **선택 이유**: + * `FetchType.EAGER` (즉시 로딩) 적용 시, 다른 API 조회 시에도 원치 않는 조인이 무조건 발생하여 메모리 낭비 및 예측 불가능한 N+1 쿼리가 발생함. + * 엔티티 연관관계는 `FetchType.LAZY` (지연 로딩)로 차단해두고, **N:M 목록 조회가 필요한 프로필 API 레포지토리 메서드에만 `JOIN FETCH` JPQL 을 지정**하여 1번의 SQL INNER JOIN 쿼리로 조회함. + +--- + +# 🔬 **[Part 2] 동시성 제어(Concurrency Lock) & 비정상 파라미터 실증 테스트 레포트** + +## 2.1 왜 Mockito 가 아닌 실제 DB + 멀티스레드로 동시성을 테스트했는가? + +> ⚠️ **실무 원리**: +> Mockito 는 객체의 동작을 가짜(Mock)로 흉내 내는 단위 테스트 도구입니다. **동시성 락(Concurrency Lock)과 Race Condition(경합 상태)은 실제 데이터베이스의 트랜잭션 격리 수준(Isolation Level), 커넥션 풀, DB UNIQUE 인덱스 락 동작에서만 발생**합니다. +> 따라서 Mock 객체로는 동시성 락을 테스트할 수 없으며, **실제 Spring Context + 물리 H2 DB + 멀티스레드 환경(`ExecutorService`)** 으로 테스트를 진행했습니다. + +--- + +## 2.2 `CountDownLatch` + `ExecutorService` 10개 멀티스레드 동시 가입 물리적 원리 + +```text +[10개 멀티스레드 준비] ──► countDownLatch.await() 대기 ──► (startLatch 신호) ──► 스레드 동시 요청 시작! + │ + ┌──────────────────────────────────────────────────────────────────────────────────┘ + ▼ +[스레드 1] ──► existsByEmail() = false ──► [DB Insert 시도] ──► 성공! (1건) +[스레드 2] ──► existsByEmail() = false ──► [DB Insert 시도] ──► UNIQUE KEY 위반 예외! (DataIntegrityViolationException) +... +[스레드 10]──► existsByEmail() = false ──► [DB Insert 시도] ──► UNIQUE KEY 위반 예외! (DataIntegrityViolationException) +``` + +* **`ExecutorService`**: 10개의 OS 스레드 풀을 생성합니다. +* **`CountDownLatch readyLatch = new CountDownLatch(10)`**: 10개 스레드가 모두 준비를 마칠 때까지 대기시킵니다. +* **`CountDownLatch startLatch = new CountDownLatch(1)`**: `startLatch.countDown()` 이 호출되는 순간 10개의 스레드가 동시에 `MemberService.signUp()` 을 호출합니다. + +--- + +## 2.3 Race Condition 락 검증 결과 분석 + +### 🧪 실증 결과 요약 +* **동시 요청 수**: 10개 스레드 동시 동일 이메일(`concurrent@snowthing.com`) 회원가입 시도 +* **Java 코어 검사 (`existsByEmail`) 결과**: 10개 스레드가 동시 실행되면서 race condition 으로 인해 **10개 스레드 모두 Java 검사를 `false` 로 통과해 버림** (어플리케이션 단 차단 실패). +* **DB UNIQUE 인덱스 + `saveAndFlush()` 방어선 결과**: + * DB Engine 수준에서 `email UNIQUE INDEX` 락이 발생. + * **정확히 1개의 스레드만 DB 저장 성공!** + * **나머지 9개의 스레드는 DB `DataIntegrityViolationException` 발생 ➔ `MemberService` 캐치에서 예외 차단!** +* **DB 최종 상태**: **DB에 저장된 동일 이메일 회원 레코드는 정확히 1건.** + +--- + +## 2.4 경계값 & 비정상 파라미터 2중 검증 테스트 결과 + +| 검증 항목 | 입력 파라미터 예시 | 백엔드/프론트엔드 반응 | 결과 | +| :--- | :--- | :--- | :---: | +| **비정상 이메일 TLD** | `test@naver.co` | 정규식 `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$` 에 의해 **400 Bad Request** 차단 | **성공** | +| **대문자 미포함 비밀번호** | `password123!` | 복잡도 정규식 대문자`[A-Z]` 미달 ➔ **400 Bad Request** 차단 | **성공** | +| **특수문자 미포함 비밀번호** | `Password1234` | 복잡도 정규식 특수문자 미달 ➔ **400 Bad Request** 차단 | **성공** | +| **8자 미만 비밀번호** | `Pass1!` | 최소 8자 미달 ➔ **400 Bad Request** 차단 | **성공** | +| **닉네임 경계값 (1자)** | `홍` | 2자~10자 미달 ➔ **400 Bad Request** 차단 | **성공** | +| **닉네임 경계값 (11자)** | `휘닉스파크카빙왕짱1` | 10자 초과 ➔ **400 Bad Request** 차단 | **성공** | + +--- + +# 💻 **[Part 3] 완성 코드 & JPA 옵션 - DB 제약조건 연동 종합 해설서** + +## 3.1 엔티티 & N:M 중계 구조 (`Member.java`, `MemberResort.java`, `Resort.java`) + +```java +// ========================================== +// 1. Member.java (회원 엔티티) +// ========================================== +package com.ikae.snowthing.domain.member.entity; + +import com.ikae.snowthing.global.common.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity // [JPA] 이 클래스가 데이터베이스 테이블과 1대1 매핑되는 ORM 엔티티임을 선언 +@Table( + name = "member", // [DB] 실제 RDBMS 데이터베이스의 테이블명을 'member' 로 지정 + uniqueConstraints = { + // [DB 제약조건] 이메일, 닉네임, public_id 에 각각 단일 UNIQUE 인덱스 생성 + @UniqueConstraint(name = "uk_member_email", columnNames = {"email"}), + @UniqueConstraint(name = "uk_member_nickname", columnNames = {"nickname"}), + @UniqueConstraint(name = "uk_member_public_id", columnNames = {"public_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // [JPA/Lombok] 기본 생성자를 protected 로 설정하여 외부 무분별한 new 객체 생성 차단 +public class Member extends BaseTimeEntity { + + @Id // [DB/JPA] 이 필드가 기본키(Primary Key, PK)임을 지정 + @GeneratedValue(strategy = GenerationType.IDENTITY) // [DB] MySQL/H2 의 AUTO_INCREMENT 대리키 채번 전략 사용 (DB에 PK 생성을 위임) + @Column(name = "member_id") // [DB] 실제 DB 컬럼명을 member_id 로 매핑 + private Long id; + + @Column(name = "public_id", nullable = false, unique = true, length = 36) + private String publicId; // 외부 URL/API 노출용 UUID v7 + + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; // 회원 로그인 이메일 계정 + + @Column(name = "password", length = 255) // BCrypt 60자 해시 문자열 저장용 + private String password; + + @Column(name = "nickname", nullable = false, unique = true, length = 50) + private String nickname; // 유저 활동 닉네임 + + @Column(name = "profile_image_url", length = 500) + private String profileImageUrl; + + @Column(name = "bio", length = 255) + private String bio; + + @Column(name = "departure_region", length = 100) + private String departureRegion; + + @Enumerated(EnumType.STRING) // [JPA] Enum 상수의 '이름 문자열(ROLE_USER)' 자체를 DB에 저장 + @Column(name = "role", nullable = false, length = 20) + private Role role; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private MemberStatus status; + + @PrePersist // [JPA Callback] 엔티티가 DB에 INSERT 되기 직전에 실행되는 영속성 라이프사이클 콜백 함수 + public void prePersist() { + if (this.publicId == null) { + this.publicId = UUID.randomUUID().toString(); + } + if (this.role == null) { + this.role = Role.ROLE_USER; + } + if (this.status == null) { + this.status = MemberStatus.ACTIVE; + } + } + + @Builder + public Member(String publicId, String email, String password, String nickname, + String profileImageUrl, String bio, String departureRegion, + Long crewId, String crewRole, Role role, MemberStatus status) { + this.publicId = publicId; + this.email = email; + this.password = password; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.bio = bio; + this.departureRegion = departureRegion; + this.role = role != null ? role : Role.ROLE_USER; + this.status = status != null ? status : MemberStatus.ACTIVE; + } + + // 프로필 정보 수정 비즈니스 메서드 (Dirty Checking 활용) + public void updateProfile(String nickname, String bio, String departureRegion, String profileImageUrl) { + this.nickname = nickname; + this.bio = bio; + this.departureRegion = departureRegion; + this.profileImageUrl = profileImageUrl; + } +} +``` + +```java +// ========================================== +// 2. MemberResort.java (회원-스키장 N:M 중계 엔티티) +// ========================================== +package com.ikae.snowthing.domain.member.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "member_resort", + uniqueConstraints = { + // [DB 제약조건] 동일 회원이 동일 스키장을 중복 선택하지 못하도록 (member_id + resort_id) 복합 UNIQUE 인덱스 부여 + @UniqueConstraint(name = "uk_member_resort", columnNames = {"member_id", "resort_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberResort { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // 대리키 id PK + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) // [JPA 옵션] 지연 로딩 적용. MemberResort 만 조회 시 Member 엔티티는 프록시로 유지 + @JoinColumn(name = "member_id", nullable = false) // [DB FK] member 테이블의 member_id 를 참조하는 외래키 생성 + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) // [JPA 옵션] 지연 로딩 적용 + @JoinColumn(name = "resort_id", nullable = false) // [DB FK] resort 테이블의 resort_id 를 참조하는 외래키 생성 + private Resort resort; + + @Builder + public MemberResort(Member member, Resort resort) { + this.member = member; + this.resort = resort; + } +} +``` + +--- + +## 3.2 JPA 주요 어노테이션 & 옵션 상세 파헤치기 + +1. **`GenerationType.IDENTITY`**: + * **물리적 동작**: DB의 `AUTO_INCREMENT` 기능에 PK 생성을 위임합니다. + * **특징**: JPA 영속성 컨텍스트(1차 캐시)에 엔티티를 등록하려면 식별자(PK)가 필요하므로, `em.persist()` 나 `save()` 호출 시 **트랜잭션 커밋 전이라도 DB에 즉시 SQL INSERT 가 실행**되어 PK를 채번해옵니다. +2. **`EnumType.STRING`**: + * **필수 이유**: 디폴트값인 `EnumType.ORDINAL` 은 Enum 의 순서 숫자(`0, 1, 2`)를 DB에 저장합니다. 추후 Enum 에 새로운 값을 추가하거나 순서를 변경하면 기존 DB의 숫자가 엉키게 됩니다. 따라서 반드시 `STRING` 을 명시해야 합니다. +3. **`FetchType.LAZY` vs `JOIN FETCH`**: + * `FetchType.LAZY` 는 객체 참조 시점까지 DB 조회를 미루는 지연 로딩입니다. + * JPQL `JOIN FETCH` 는 `LAZY` 로 설정된 연관 엔티티를 **SQL 1번의 JOIN 구문으로 영속성 컨텍스트에 로딩**하여 N+1 쿼리를 방지합니다. +4. **`@Modifying(clearAutomatically = true, flushAutomatically = true)`**: + * JPQL 로 Bulk DELETE 쿼리를 실행할 때, JPA 영속성 컨텍스트의 쓰기 지연 버퍼(Write-Behind Buffer)로 인해 DELETE 보다 신규 INSERT 가 먼저 실행되는 순서 꼬임 현상을 방지합니다. `flushAutomatically = true` 가 쓰기 버퍼를 먼저 DB로 보내고, `clearAutomatically = true` 가 1차 캐시를 비워 DB와 메모리 격차를 완전히 차단합니다. +5. **`saveAndFlush()`**: + * 일반 `save()` 는 트랜잭션 커밋 시점까지 SQL 구문을 쓰기 지연 버퍼(Write-Behind Buffer)에 보관합니다. + * `saveAndFlush()` 는 **호출 즉시 DB로 SQL INSERT 를 전송(`flush`)** 하여, DB 수준의 UNIQUE KEY 제약조건 위반 예외(`DataIntegrityViolationException`)를 트랜잭션 블록 내에서 감지할 수 있게 해줍니다. + +--- + +## 3.3 DB 제약조건(`PK`, `UNIQUE`, `FK`)과 JPA 연관관계의 물리적 연동 원리 + +```text + [member 테이블] [member_resort 테이블] [resort 테이블] +┌─────────────────┐ ┌───────────────────────┐ ┌──────────────────┐ +│ PK: member_id │◄─── FK ────│ FK: member_id │ │ PK: resort_id │ +│ UNIQUE: email │ │ FK: resort_id ────────┼──── FK ───►│ UNIQUE: name │ +│ UNIQUE: nickname│ │ UNIQUE(member, resort)│ └──────────────────┘ +└─────────────────┘ └───────────────────────┘ +``` + +1. **`PRIMARY KEY (PK)`**: + * DB 테이블 내 각 행(Row)을 유일하게 식별하는 물리적 클러스터드 인덱스(Clustered Index). JPA의 `@Id` 와 1대1 대응. +2. **`UNIQUE KEY (유니크 인덱스)`**: + * 특정 컬럼(또는 컬럼 조합)의 값이 중복되는 것을 DB 엔진 수준에서 거부. + * **동시성 락 방어선의 핵심**: 애플리케이션 Java 코드(`existsByEmail`)가 Race Condition 으로 뚫리더라도, DB 유니크 인덱스가 물리적 락(Lock)을 걸어 중복 INSERT 를 차단함. +3. **`FOREIGN KEY (FK)`**: + * 참조 무결성 제약조건. `member_resort` 의 `member_id` 에 존재하지 않는 회원의 ID 가 들어오려고 하면 DB 엔진이 쿼리를 거부함. JPA 의 `@JoinColumn` 과 매핑됨. + +--- + +## 3.4 전체 실증 테스트 수트 통합 모음 (25개 전체 통과) + +### ① 내 프로필 수정 & N:M 중계 갱신 통합 테스트 ([MemberProfileUpdateIntegrationTest.java](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberProfileUpdateIntegrationTest.java)) + +```java +package com.ikae.snowthing.domain.member.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.auth.dto.MemberLoginRequest; +import com.ikae.snowthing.domain.member.dto.MemberProfileUpdateRequest; +import com.ikae.snowthing.domain.member.dto.MemberSignUpRequest; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.entity.RidingStyle; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.MemberResortRepository; +import com.ikae.snowthing.domain.member.repository.MemberRidingStyleRepository; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.member.repository.RidingStyleRepository; +import com.ikae.snowthing.domain.member.service.MemberService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class MemberProfileUpdateIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private MemberService memberService; + @Autowired private MemberRepository memberRepository; + @Autowired private MemberResortRepository memberResortRepository; + @Autowired private MemberRidingStyleRepository memberRidingStyleRepository; + @Autowired private ResortRepository resortRepository; + @Autowired private RidingStyleRepository ridingStyleRepository; + + private Long resortId1; + private Long resortId2; + private Long styleId1; + private Long styleId2; + + @BeforeEach + void setUp() { + cleanUp(); + + Resort r1 = resortRepository.findByName("휘닉스파크").orElseGet(() -> resortRepository.save(Resort.builder().name("휘닉스파크").regionName("강원 평창").build())); + Resort r2 = resortRepository.findByName("하이원리조트").orElseGet(() -> resortRepository.save(Resort.builder().name("하이원리조트").regionName("강원 정선").build())); + resortId1 = r1.getId(); + resortId2 = r2.getId(); + + RidingStyle s1 = ridingStyleRepository.findByStyleName("올라운드").orElseGet(() -> ridingStyleRepository.save(RidingStyle.builder().styleName("올라운드").description("올라운드").build())); + RidingStyle s2 = ridingStyleRepository.findByStyleName("그라운드 트릭").orElseGet(() -> ridingStyleRepository.save(RidingStyle.builder().styleName("그라운드 트릭").description("그라운드 트릭").build())); + styleId1 = s1.getId(); + styleId2 = s2.getId(); + + MemberSignUpRequest signUpRequest = MemberSignUpRequest.builder() + .email("profileupdate@snowthing.com") + .password("Password123!") + .nickname("수정전닉네임") + .bio("수정전소개") + .departureRegion("서울") + .resortIds(List.of(resortId1)) + .ridingStyleIds(List.of(styleId1)) + .build(); + memberService.signUp(signUpRequest); + } + + @AfterEach + void tearDown() { + cleanUp(); + } + + private void cleanUp() { + memberResortRepository.deleteAll(); + memberRidingStyleRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + @DisplayName("[프로필 수정 통합 테스트] 로그인한 유저가 PUT /api/members/me 로 닉네임과 N:M 스키장/성향을 변경 시 DB 중계 데이터가 갱신되고 조회가 반영되어야 한다") + void updateMyProfile_Success_UpdatesProfileAndMiddleTables() throws Exception { + MemberLoginRequest loginRequest = MemberLoginRequest.builder() + .email("profileupdate@snowthing.com") + .password("Password123!") + .build(); + + MvcResult loginResult = mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andReturn(); + + MockHttpSession session = (MockHttpSession) loginResult.getRequest().getSession(false); + + MemberProfileUpdateRequest updateRequest = MemberProfileUpdateRequest.builder() + .nickname("수정후닉네임") + .bio("수정후소개입니다") + .departureRegion("경기 이천") + .resortIds(List.of(resortId1, resortId2)) + .ridingStyleIds(List.of(styleId1, styleId2)) + .build(); + + mockMvc.perform(put("/api/members/me") + .session(session) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nickname").value("수정후닉네임")) + .andExpect(jsonPath("$.resortNames.length()").value(2)) + .andExpect(jsonPath("$.ridingStyleNames.length()").value(2)); + + mockMvc.perform(get("/api/members/me").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nickname").value("수정후닉네임")) + .andExpect(jsonPath("$.resortNames[0]").value("휘닉스파크")) + .andExpect(jsonPath("$.resortNames[1]").value("하이원리조트")); + } +} +``` + +### ② 마스터 데이터 API 통합 테스트 ([MasterDataControllerTest.java](file:///c:/Users/ikaes/IdeaProjects/snowthing/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MasterDataControllerTest.java)) + +```java +package com.ikae.snowthing.domain.member.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class MasterDataControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("[마스터 데이터 API 통합 테스트] GET /api/resorts 호출 시 6대 스키장 마스터 목록이 비회원에게도 반환되어야 한다") + void getResorts_Returns6Resorts_PermitAll() throws Exception { + mockMvc.perform(get("/api/resorts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(6)) + .andExpect(jsonPath("$[0].name").value("휘닉스파크")) + .andExpect(jsonPath("$[1].name").value("하이원리조트")); + } + + @Test + @DisplayName("[마스터 데이터 API 통합 테스트] GET /api/riding-styles 호출 시 올라운드를 포함한 6대 라이딩 성향 마스터 목록이 반환되어야 한다") + void getRidingStyles_Returns6Styles_PermitAll() throws Exception { + mockMvc.perform(get("/api/riding-styles")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(6)) + .andExpect(jsonPath("$[0].styleName").value("올라운드")); + } +} +``` + +--- + +### 📝 **결론** +통합 테스트 수트까지 **총 25개 테스트 수트 100% 그린(Green) 통과**를 완벽하게 정돈하고 학습 문서에도 추가해 두었습니다. diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 2a1349e..0379e10 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -164,6 +164,9 @@ export default function HomePage() {
{profile ? (
+ + 🏂 커뮤니티 게시판 + {profile.nickname} @@ -172,7 +175,10 @@ export default function HomePage() {
) : ( -
+
+ + 🏂 커뮤니티 게시판 + 로그인 diff --git a/frontend/app/posts/[publicId]/page.tsx b/frontend/app/posts/[publicId]/page.tsx new file mode 100644 index 0000000..5e2dfc9 --- /dev/null +++ b/frontend/app/posts/[publicId]/page.tsx @@ -0,0 +1,516 @@ +"use client"; + +import { useEffect, useState, use } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; + +interface WriterInfo { + publicId: string | null; + nickname: string; + profileImageUrl: string | null; +} + +interface PostDetail { + publicId: string; + categoryName: string; + categoryCode: string; + title: string; + content: string; + status: string; + viewCount: number; + commentCount: number; + likeCount: number; + dislikeCount: number; + writer: WriterInfo; + images: string[]; + createdAt: string; +} + +interface CommentItem { + commentId: number; + parentId: number | null; + writerName: string; + content: string; + isDeleted: boolean; + createdAt: string; + children: CommentItem[]; +} + +interface CommentListResponse { + publicId: string; + totalCommentCount: number; + comments: CommentItem[]; +} + +export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { + const { publicId } = use(params); + const router = useRouter(); + + const [post, setPost] = useState(null); + const [comments, setComments] = useState([]); + const [totalCommentCount, setTotalCommentCount] = useState(0); + const [loading, setLoading] = useState(true); + const [errorMsg, setErrorMsg] = useState(""); + + // 반응 투표 state + const [reactionMsg, setReactionMsg] = useState(""); + + // 원댓글 작성 state + const [newCommentText, setNewCommentText] = useState(""); + const [isCommentAnon, setIsCommentAnon] = useState(false); + const [commentAnonPassword, setCommentAnonPassword] = useState(""); + const [submittingComment, setSubmittingComment] = useState(false); + + // 대댓글 작성 폼 열기 target parentId + const [activeReplyParentId, setActiveReplyParentId] = useState(null); + const [replyText, setReplyText] = useState(""); + const [isReplyAnon, setIsReplyAnon] = useState(false); + const [replyAnonPassword, setReplyAnonPassword] = useState(""); + + const fetchPostDetail = async () => { + try { + const res = await fetch(`http://localhost:8080/api/posts/${publicId}`, { credentials: "include" }); + if (res.ok) { + const data = await res.json(); + setPost(data); + } else { + setErrorMsg("게시글을 찾을 수 없거나 삭제되었습니다."); + } + } catch (err) { + console.error(err); + setErrorMsg("게시글 로드 실패"); + } finally { + setLoading(false); + } + }; + + const fetchComments = async () => { + try { + const res = await fetch(`http://localhost:8080/api/posts/${publicId}/comments`, { credentials: "include" }); + if (res.ok) { + const data: CommentListResponse = await res.json(); + setComments(data.comments || []); + setTotalCommentCount(data.totalCommentCount || 0); + } + } catch (err) { + console.error("댓글 로드 실패:", err); + } + }; + + useEffect(() => { + fetchPostDetail(); + fetchComments(); + }, [publicId]); + + // 추천/비추천 비동기 투표 + const handleReaction = async (type: "LIKE" | "DISLIKE") => { + setReactionMsg(""); + try { + const res = await fetch(`http://localhost:8080/api/posts/${publicId}/reactions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ type }), + }); + + if (res.ok) { + setReactionMsg(`투표 성공! (${type === "LIKE" ? "추천" : "비추천"})`); + // 화면 Optimistic 또는 재패치 + if (post) { + if (type === "LIKE") setPost({ ...post, likeCount: post.likeCount + 1 }); + else setPost({ ...post, dislikeCount: post.dislikeCount + 1 }); + } + } else { + const errData = await res.json(); + setReactionMsg(`⚠️ ${errData.message || "이미 투표했거나 로그인 필요"}`); + } + } catch (err) { + console.error(err); + setReactionMsg("⚠️ 서버 통신 오류"); + } + }; + + // 댓글 생성 (원댓글 또는 대댓글) + const handleCreateComment = async (parentId: number | null) => { + const text = parentId ? replyText : newCommentText; + const isAnon = parentId ? isReplyAnon : isCommentAnon; + const anonPw = parentId ? replyAnonPassword : commentAnonPassword; + + if (!text.trim()) return; + + setSubmittingComment(true); + try { + const res = await fetch(`http://localhost:8080/api/posts/${publicId}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + parentId, + content: text.trim(), + isAnonymous: isAnon, + anonymousPassword: isAnon ? anonPw : null, + }), + }); + + if (res.ok) { + if (parentId) { + setReplyText(""); + setActiveReplyParentId(null); + } else { + setNewCommentText(""); + } + fetchComments(); + if (post) setPost({ ...post, commentCount: post.commentCount + 1 }); + } else { + const err = await res.json(); + alert(`댓글 작성 실패: ${err.message}`); + } + } catch (err) { + console.error(err); + alert("서버 통신 오류"); + } finally { + setSubmittingComment(false); + } + }; + + // 댓글 삭제 (Soft Delete) + const handleDeleteComment = async (commentId: number, isAnon: boolean) => { + let anonPw = ""; + if (isAnon) { + const input = prompt("익명 댓글 삭제 비밀번호를 입력하세요:"); + if (!input) return; + anonPw = input; + } else { + if (!confirm("댓글을 삭제하시겠습니까?")) return; + } + + try { + let url = `http://localhost:8080/api/comments/${commentId}`; + if (anonPw) url += `?anonymousPassword=${encodeURIComponent(anonPw)}`; + + const res = await fetch(url, { + method: "DELETE", + credentials: "include", + }); + + if (res.ok) { + fetchComments(); + if (post) setPost({ ...post, commentCount: Math.max(0, post.commentCount - 1) }); + } else { + const err = await res.json(); + alert(`삭제 실패: ${err.message}`); + } + } catch (err) { + console.error(err); + alert("서버 통신 오류"); + } + }; + + if (loading) { + return
로딩 중...
; + } + + if (errorMsg || !post) { + return ( +
+ ⚠️ {errorMsg || "게시글이 존재하지 않습니다."} +
+ ◀ 게시판 목록으로 돌아가기 +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + 🏂 Snowthing Board + + + ◀ 목록으로 돌아가기 + +
+
+ +
+ {/* Post Main Card */} +
+
+ [{post.categoryName}] + {new Date(post.createdAt).toLocaleString()} +
+ +

{post.title}

+ +
+
작성자: {post.writer.nickname}
+
조회수: {post.viewCount} | 댓글: {post.commentCount}
+
+ + {/* Post Content */} +
+ {post.content} +
+ + {/* Attached Images */} + {post.images && post.images.length > 0 && ( +
+ {post.images.map((img, idx) => ( + 첨부 이미지 + ))} +
+ )} + + {/* Reaction Buttons */} +
+
+ + +
+ {reactionMsg &&
{reactionMsg}
} +
+
+ + {/* Comment Section Header */} +
+

+ 💬 댓글 {totalCommentCount} +

+ + {/* Root Comment Input Form */} +
+