πŸ”„ API μš”μ²­ 흐름 및 μ—λŸ¬ 처리 κ°€μ΄λ“œ

이 λ¬Έμ„œλŠ” ν”„λ‘œμ νŠΈμ˜ λͺ¨λ“  API μš”μ²­μ΄ μ–΄λ–»κ²Œ μ²˜λ¦¬λ˜λŠ”μ§€, 성곡/μ‹€νŒ¨ μ‹œ μ–΄λ–€ μ½”λ“œκ°€ μ‹€ν–‰λ˜μ–΄ μ–΄λ–€ 응닡이 μƒμ„±λ˜λŠ”μ§€λ₯Ό μ •λ¦¬ν•©λ‹ˆλ‹€.


πŸ“‹ λͺ©μ°¨

  1. ν•„ν„° 체인 ꡬ쑰
  2. 둜그인 κ΄€λ ¨ 흐름
  3. 인증이 ν•„μš”ν•œ API 흐름
  4. λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μ—λŸ¬ 흐름
  5. μœ νš¨μ„± 검증 μ—λŸ¬ 흐름
  6. 응닡 ν˜•μ‹ 정리

1. ν•„ν„° 체인 ꡬ쑰

1.1 μš”μ²­ 처리 μˆœμ„œ

[ν΄λΌμ΄μ–ΈνŠΈ μš”μ²­]
       ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  1. JwtAccessTokenCheckAndSaveUserInfoFilter                 β”‚
β”‚     - 토큰 μΆ”μΆœ 및 검증                                        β”‚
β”‚     - μœ νš¨ν•˜λ©΄ SecurityContext에 인증 정보 μ €μž₯                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  2. JwtLoginFilter ("/api/login" URL만 λ™μž‘)                  β”‚
β”‚     - 둜그인 μš”μ²­ 처리                                         β”‚
β”‚     - JWT 토큰 λ°œκΈ‰                                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  3. Spring Security Authorization                            β”‚
β”‚     - URL별 인증/인가 검사                                     β”‚
β”‚     - .authenticated() μ„€μ •λœ URL에 인증 μ—†μœΌλ©΄ 차단            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  4. Controller β†’ Service β†’ Repository                        β”‚
β”‚     - λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 처리                                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  5. GlobalExceptionHandler (μ˜ˆμ™Έ λ°œμƒ μ‹œ)                     β”‚
β”‚     - λͺ¨λ“  μ˜ˆμ™Έλ₯Ό 톡합 처리                                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       ↓
[응닡 λ°˜ν™˜]

1.2 ν•„ν„° 등둝 μœ„μΉ˜

πŸ“ 파일: SecurityConfig.java (Line 167-174)

http
    .addFilterAt(
        new JwtLoginFilter(..., "/api/login"),
        UsernamePasswordAuthenticationFilter.class)
    .addFilterBefore(
        new JwtAccessTokenCheckAndSaveUserInfoFilter(jwtUtil, customUserDetailsService),
        UsernamePasswordAuthenticationFilter.class);

2. 둜그인 κ΄€λ ¨ 흐름

2.1 βœ… 둜그인 성곡 (ID/PW 일치)

μš”μ²­:

POST /api/login
Content-Type: application/json

{
  "username": "testuser",
  "password": "correct_password"
}

흐름:

[1] JwtAccessTokenCheckAndSaveUserInfoFilter.doFilterInternal()
    β”‚
    β”œβ”€ πŸ“ JwtAccessTokenCheckAndSaveUserInfoFilter.java (Line 35-36)
    β”‚   String token = getTokenFromRequest(request);  // 토큰 μ—†μŒ β†’ null
    β”‚   if (token == null) { chain.doFilter(); return; }  // κ·Έλƒ₯ 톡과
    β”‚
    ↓
[2] JwtLoginFilter.attemptAuthentication()
    β”‚
    β”œβ”€ πŸ“ JwtLoginFilter.java (Line 40-56)
    β”‚   // /api/login URLμ΄λ―€λ‘œ 이 ν•„ν„° λ™μž‘
    β”‚   Map<String, String> credentials = new ObjectMapper()
    β”‚       .readValue(request.getInputStream(), ...);
    β”‚   String username = credentials.get("username");
    β”‚   String password = credentials.get("password");
    β”‚   return authenticationManager.authenticate(authRequest);
    β”‚
    ↓
[3] CustomUserDetailsService.loadUserByUsername()
    β”‚
    β”œβ”€ πŸ“ CustomUserDetailsService.java (Line 19-25)
    β”‚   UserEntity userEntity = userRepository.findByUsername(username)
    β”‚       .orElseThrow(() -> new UsernameNotFoundException(...));
    β”‚   return new CustomUserAccount(UserDTO.from(userEntity));
    β”‚
    ↓
[4] Spring Securityκ°€ λΉ„λ°€λ²ˆν˜Έ 검증 (BCryptPasswordEncoder)
    β”‚
    ↓
[5] JwtLoginFilter.successfulAuthentication()
    β”‚
    β”œβ”€ πŸ“ JwtLoginFilter.java (Line 63-93)
    β”‚   String accessToken = jwtUtil.createAccessToken(username);
    β”‚   String refreshToken = jwtUtil.createRefreshToken(username);
    β”‚   refreshService.saveRefresh(refreshToken);
    β”‚   
    β”‚   // λΈŒλΌμš°μ €: μΏ ν‚€ μ„€μ •
    β”‚   addCookie(response, "access_token", accessToken, -1);
    β”‚   addCookie(response, "refresh_token", refreshToken, -1);
    β”‚   response.setStatus(HttpServletResponse.SC_OK);
    β”‚
    ↓
[응닡] HTTP 200 OK + Set-Cookie 헀더

응닡:

HTTP/1.1 200 OK
Set-Cookie: access_token=eyJhbGc...; HttpOnly; Path=/
Set-Cookie: refresh_token=eyJhbGc...; HttpOnly; Path=/

2.2 ❌ 둜그인 μ‹€νŒ¨ (ID/PW 뢈일치)

μš”μ²­:

POST /api/login
Content-Type: application/json

{
  "username": "testuser",
  "password": "wrong_password"
}

흐름:

[1] JwtAccessTokenCheckAndSaveUserInfoFilter.doFilterInternal()
    β”‚
    β”œβ”€ 토큰 μ—†μŒ β†’ κ·Έλƒ₯ 톡과
    β”‚
    ↓
[2] JwtLoginFilter.attemptAuthentication()
    β”‚
    β”œβ”€ πŸ“ JwtLoginFilter.java (Line 53)
    β”‚   return authenticationManager.authenticate(authRequest);
    β”‚   // λΉ„λ°€λ²ˆν˜Έ 뢈일치 β†’ AuthenticationException λ°œμƒ!
    β”‚
    ↓
[3] JwtLoginFilter.unsuccessfulAuthentication()
    β”‚
    β”œβ”€ πŸ“ JwtLoginFilter.java (Line 107-117)
    β”‚   response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    β”‚   response.setContentType("application/json;charset=UTF-8");
    β”‚   
    β”‚   ErrorResponse errorResponse = ErrorResponse.of(
    β”‚       "아이디 λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.",
    β”‚       "AUTHENTICATION_FAILED"
    β”‚   );
    β”‚   response.getWriter().write(new ObjectMapper().writeValueAsString(errorResponse));
    β”‚
    ↓
[응닡] HTTP 401 Unauthorized

응닡:

{
  "success": false,
  "message": "아이디 λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.",
  "errorCode": "AUTHENTICATION_FAILED",
  "timestamp": "2026-01-16T14:30:00"
}

2.3 ❌ 둜그인 μ‹€νŒ¨ (μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ‚¬μš©μž)

μš”μ²­:

POST /api/login
Content-Type: application/json

{
  "username": "nonexistent",
  "password": "any_password"
}

흐름:

[1] JwtAccessTokenCheckAndSaveUserInfoFilter β†’ 톡과
    β”‚
    ↓
[2] JwtLoginFilter.attemptAuthentication()
    β”‚
    ↓
[3] CustomUserDetailsService.loadUserByUsername()
    β”‚
    β”œβ”€ πŸ“ CustomUserDetailsService.java (Line 20-22)
    β”‚   UserEntity userEntity = userRepository.findByUsername(username)
    β”‚       .orElseThrow(() -> new UsernameNotFoundException(
    β”‚           "μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: " + username));
    β”‚   // UsernameNotFoundException λ°œμƒ!
    β”‚
    ↓
[4] Spring Securityκ°€ AuthenticationException으둜 λ³€ν™˜
    β”‚
    ↓
[5] JwtLoginFilter.unsuccessfulAuthentication() μ‹€ν–‰
    β”‚
    ↓
[응닡] HTTP 401 + ErrorResponse

3. 인증이 ν•„μš”ν•œ API 흐름

3.1 βœ… λ‘œκ·ΈμΈν•œ μ‚¬μš©μžκ°€ κ²Œμ‹œκΈ€ μž‘μ„±

μš”μ²­:

POST /api/communities
Cookie: access_token=eyJhbGc...
Content-Type: application/json

{
  "title": "ν…ŒμŠ€νŠΈ 제λͺ©",
  "content": "ν…ŒμŠ€νŠΈ λ‚΄μš©μž…λ‹ˆλ‹€."
}

흐름:

[1] JwtAccessTokenCheckAndSaveUserInfoFilter.doFilterInternal()
    β”‚
    β”œβ”€ πŸ“ JwtAccessTokenCheckAndSaveUserInfoFilter.java (Line 35-67)
    β”‚   String token = getTokenFromRequest(request);  // μΏ ν‚€μ—μ„œ 토큰 μΆ”μΆœ
    β”‚   
    β”‚   String tokenType = jwtUtil.getTokenType(token);  // "access"
    β”‚   
    β”‚   if (!jwtUtil.validateToken(token)) { ... }  // μœ νš¨ν•¨ β†’ 톡과
    β”‚   
    β”‚   // βœ… SecurityContext에 인증 정보 μ €μž₯
    β”‚   String username = jwtUtil.extractUsername(token);
    β”‚   UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    β”‚   SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    β”‚   
    β”‚   chain.doFilter(request, response);  // 인증된 μƒνƒœλ‘œ 톡과!
    β”‚
    ↓
[2] JwtLoginFilter
    β”‚
    β”œβ”€ /api/login이 μ•„λ‹ˆλ―€λ‘œ λ™μž‘ μ•ˆ 함 β†’ 톡과
    β”‚
    ↓
[3] Spring Security Authorization
    β”‚
    β”œβ”€ πŸ“ SecurityConfig.java (Line 107-109)
    β”‚   .requestMatchers(HttpMethod.POST, "/api/communities", "/api/communities/**")
    β”‚   .authenticated()
    β”‚   // SecurityContext에 인증 정보 있음 β†’ 톡과!
    β”‚
    ↓
[4] CommunityController.createCommunity()
    β”‚
    β”œβ”€ πŸ“ CommunityController.java (Line 51-57)
    β”‚   @PostMapping
    β”‚   public ResponseEntity<ApiResponse<Long>> createCommunity(
    β”‚       @Valid @RequestBody CommunityCreateDTO createDTO,
    β”‚       @AuthenticationPrincipal CustomUserAccount userAccount) {
    β”‚       
    β”‚       Long communityId = communityService.createCommunity(
    β”‚           createDTO, userAccount.getUsername());
    β”‚       return ResponseEntity.status(HttpStatus.CREATED)
    β”‚           .body(ApiResponse.success("κ²Œμ‹œκΈ€μ΄ μž‘μ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€", communityId));
    β”‚   }
    β”‚
    ↓
[5] CommunityService.createCommunity()
    β”‚
    β”œβ”€ πŸ“ CommunityService.java (Line 29-35)
    β”‚   UserEntity user = userRepository.findByUsername(username)
    β”‚       .orElseThrow(() -> EntityNotFoundException.of("μ‚¬μš©μž", username));
    β”‚   CommunityEntity community = createDTO.toEntity(user);
    β”‚   CommunityEntity savedCommunity = communityRepository.save(community);
    β”‚   return savedCommunity.getId();
    β”‚
    ↓
[응닡] HTTP 201 Created

응닡:

{
  "success": true,
  "message": "κ²Œμ‹œκΈ€μ΄ μž‘μ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€",
  "data": 123
}

3.2 ❌ 둜그인 μ•ˆ ν•œ μ‚¬μš©μžκ°€ κ²Œμ‹œκΈ€ μž‘μ„± μ‹œλ„

μš”μ²­:

POST /api/communities
Content-Type: application/json
(토큰 μ—†μŒ)

{
  "title": "ν…ŒμŠ€νŠΈ 제λͺ©",
  "content": "ν…ŒμŠ€νŠΈ λ‚΄μš©μž…λ‹ˆλ‹€."
}

흐름:

[1] JwtAccessTokenCheckAndSaveUserInfoFilter.doFilterInternal()
    β”‚
    β”œβ”€ πŸ“ JwtAccessTokenCheckAndSaveUserInfoFilter.java (Line 35-39)
    β”‚   String token = getTokenFromRequest(request);  // null
    β”‚   if (token == null) {
    β”‚       chain.doFilter(request, response);  // 인증 없이 톡과
    β”‚       return;
    β”‚   }
    β”‚
    ↓
[2] JwtLoginFilter β†’ /api/login μ•„λ‹ˆλ―€λ‘œ 톡과
    β”‚
    ↓
[3] Spring Security Authorization
    β”‚
    β”œβ”€ πŸ“ SecurityConfig.java (Line 107-109)
    β”‚   .requestMatchers(HttpMethod.POST, "/api/communities", "/api/communities/**")
    β”‚   .authenticated()
    β”‚   // SecurityContext에 인증 정보 μ—†μŒ β†’ 차단!
    β”‚
    ↓
[4] AuthenticationEntryPoint μ‹€ν–‰
    β”‚
    β”œβ”€ πŸ“ SecurityConfig.java (Line 178-205)
    β”‚   http.exceptionHandling(ex -> ex
    β”‚       .authenticationEntryPoint((request, response, authException) -> {
    β”‚           response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    β”‚           response.setContentType("application/json;charset=UTF-8");
    β”‚           
    β”‚           String errorCode = "NOT_AUTHENTICATED";
    β”‚           String errorMessage = "인증이 ν•„μš”ν•©λ‹ˆλ‹€.";
    β”‚           
    β”‚           // ErrorResponse ν˜•μ‹μœΌλ‘œ 응닡
    β”‚           String jsonResponse = String.format(
    β”‚               "{\"success\":false,\"message\":\"%s\",\"errorCode\":\"%s\",...}",
    β”‚               errorMessage, errorCode);
    β”‚           response.getWriter().write(jsonResponse);
    β”‚       })
    β”‚   );
    β”‚
    ↓
[응닡] HTTP 401 Unauthorized

응닡:

{
  "success": false,
  "message": "인증이 ν•„μš”ν•©λ‹ˆλ‹€.",
  "errorCode": "NOT_AUTHENTICATED",
  "timestamp": "2026-01-16T14:30:00"
}

3.3 ❌ Access Token 만료 μƒνƒœλ‘œ μš”μ²­

μš”μ²­:

POST /api/communities
Cookie: access_token=eyJhbGc...(만료된 토큰)
Content-Type: application/json

{
  "title": "ν…ŒμŠ€νŠΈ 제λͺ©",
  "content": "ν…ŒμŠ€νŠΈ λ‚΄μš©μž…λ‹ˆλ‹€."
}

흐름:

[1] JwtAccessTokenCheckAndSaveUserInfoFilter.doFilterInternal()
    β”‚
    β”œβ”€ πŸ“ JwtAccessTokenCheckAndSaveUserInfoFilter.java (Line 50-54)
    β”‚   String token = getTokenFromRequest(request);  // 토큰 있음
    β”‚   
    β”‚   if (!jwtUtil.validateToken(token)) {  // ❌ 만료됨!
    β”‚       request.setAttribute("ERROR_CAUSE", "ν† ν°λ§Œλ£Œ");
    β”‚       chain.doFilter(request, response);  // 인증 없이 톡과
    β”‚       return;
    β”‚   }
    β”‚
    ↓
[2] Spring Security Authorization β†’ 인증 μ—†μŒ β†’ 차단!
    β”‚
    ↓
[3] AuthenticationEntryPoint μ‹€ν–‰
    β”‚
    β”œβ”€ πŸ“ SecurityConfig.java (Line 178-205)
    β”‚   String errorCause = request.getAttribute("ERROR_CAUSE");  // "ν† ν°λ§Œλ£Œ"
    β”‚   
    β”‚   if ("ν† ν°λ§Œλ£Œ".equals(errorCause)) {
    β”‚       errorMessage = "Access Token이 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. 토큰을 μž¬λ°œκΈ‰ν•΄μ£Όμ„Έμš”.";
    β”‚       errorCode = "TOKEN_EXPIRED";
    β”‚   }
    β”‚
    ↓
[응닡] HTTP 401 Unauthorized

응닡:

{
  "success": false,
  "message": "Access Token이 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. 토큰을 μž¬λ°œκΈ‰ν•΄μ£Όμ„Έμš”.",
  "errorCode": "TOKEN_EXPIRED",
  "timestamp": "2026-01-16T14:30:00"
}

4. λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μ—λŸ¬ 흐름

4.1 ❌ λ‘œκ·ΈμΈν•œ μ‚¬μš©μžκ°€ λ‹€λ₯Έ μ‚¬λžŒ κΈ€ μ‚­μ œ μ‹œλ„

μš”μ²­:

DELETE /api/communities/123
Cookie: access_token=eyJhbGc...(userB의 토큰)

κ²Œμ‹œκΈ€ 123의 μž‘μ„±μžλŠ” userA

흐름:

[1] JwtAccessTokenCheckAndSaveUserInfoFilter.doFilterInternal()
    β”‚
    β”œβ”€ 토큰 유효 β†’ SecurityContext에 userB 인증 정보 μ €μž₯
    β”‚
    ↓
[2] Spring Security Authorization
    β”‚
    β”œβ”€ πŸ“ SecurityConfig.java (Line 113-115)
    β”‚   .requestMatchers(HttpMethod.DELETE, "/api/communities/**")
    β”‚   .authenticated()
    β”‚   // userB 인증됨 β†’ 톡과!
    β”‚
    ↓
[3] CommunityController.deleteCommunity()
    β”‚
    β”œβ”€ πŸ“ CommunityController.java (Line 75-81)
    β”‚   @DeleteMapping("/{communityId}")
    β”‚   public ResponseEntity<ApiResponse<Void>> deleteCommunity(
    β”‚       @PathVariable Long communityId,
    β”‚       @AuthenticationPrincipal CustomUserAccount userAccount) {
    β”‚       
    β”‚       communityService.deleteCommunity(communityId, userAccount.getUsername());
    β”‚       // userAccount.getUsername() = "userB"
    β”‚   }
    β”‚
    ↓
[4] CommunityService.deleteCommunity()
    β”‚
    β”œβ”€ πŸ“ CommunityService.java (Line 77-87)
    β”‚   CommunityEntity community = communityRepository.findByIdAndIsDeletedFalse(communityId)
    β”‚       .orElseThrow(() -> EntityNotFoundException.of("κ²Œμ‹œκΈ€", communityId));
    β”‚   
    β”‚   // community.getUser().getUsername() = "userA"
    β”‚   // username = "userB"
    β”‚   if (!community.isWrittenBy(username)) {  // userA != userB
    β”‚       throw AccessDeniedException.forDelete("κ²Œμ‹œκΈ€");  // ❌ μ˜ˆμ™Έ λ°œμƒ!
    β”‚   }
    β”‚
    ↓
[5] GlobalExceptionHandler.handleBusinessException()
    β”‚
    β”œβ”€ πŸ“ GlobalExceptionHandler.java (Line 31-37)
    β”‚   @ExceptionHandler(BusinessException.class)
    β”‚   public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
    β”‚       log.warn("Business Exception: {} - {}", e.getErrorCode(), e.getMessage());
    β”‚       
    β”‚       ErrorResponse response = ErrorResponse.of(e.getMessage(), e.getErrorCode());
    β”‚       return ResponseEntity.status(e.getStatus()).body(response);
    β”‚       // e.getStatus() = HttpStatus.FORBIDDEN (403)
    β”‚   }
    β”‚
    ↓
[응닡] HTTP 403 Forbidden

응닡:

{
  "success": false,
  "message": "본인의 κ²Œμ‹œκΈ€λ§Œ μ‚­μ œν•  수 μžˆμŠ΅λ‹ˆλ‹€.",
  "errorCode": "ACCESS_DENIED",
  "timestamp": "2026-01-16T14:30:00"
}

4.2 ❌ μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ²Œμ‹œκΈ€ 쑰회

μš”μ²­:

GET /api/communities/999999

흐름:

[1] ν•„ν„° 톡과 (곡개 API)
    β”‚
    ↓
[2] CommunityController.getCommunityDetail()
    β”‚
    β”œβ”€ πŸ“ CommunityController.java (Line 40-44)
    β”‚   @GetMapping("/{communityId}")
    β”‚   public ResponseEntity<ApiResponse<CommunityDTO>> getCommunityDetail(
    β”‚       @PathVariable Long communityId) {
    β”‚       CommunityDTO community = communityService.getCommunityDetail(communityId);
    β”‚   }
    β”‚
    ↓
[3] CommunityService.getCommunityDetail()
    β”‚
    β”œβ”€ πŸ“ CommunityService.java (Line 50-58)
    β”‚   CommunityEntity community = communityRepository
    β”‚       .findByIdAndIsDeletedFalse(communityId)  // 999999 μ—†μŒ!
    β”‚       .orElseThrow(() -> EntityNotFoundException.of("κ²Œμ‹œκΈ€", communityId));
    β”‚   // EntityNotFoundException λ°œμƒ!
    β”‚
    ↓
[4] GlobalExceptionHandler.handleBusinessException()
    β”‚
    β”œβ”€ πŸ“ GlobalExceptionHandler.java (Line 31-37)
    β”‚   // EntityNotFoundException extends BusinessException
    β”‚   // e.getStatus() = HttpStatus.NOT_FOUND (404)
    β”‚   // e.getMessage() = "κ²Œμ‹œκΈ€μ„(λ₯Ό) 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: 999999"
    β”‚   // e.getErrorCode() = "NOT_FOUND"
    β”‚
    ↓
[응닡] HTTP 404 Not Found

응닡:

{
  "success": false,
  "message": "κ²Œμ‹œκΈ€μ„(λ₯Ό) 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: 999999",
  "errorCode": "NOT_FOUND",
  "timestamp": "2026-01-16T14:30:00"
}

4.3 ❌ λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ μœ„λ°˜ μ˜ˆμ‹œ

πŸ’‘ 이 ν”„λ‘œμ νŠΈμ—μ„œλŠ” 리뷰 κΈ°λŠ₯이 μ—†μ§€λ§Œ, BusinessRuleException의 λ™μž‘ 원리λ₯Ό μ„€λͺ…ν•˜κΈ° μœ„ν•œ μ˜ˆμ‹œμž…λ‹ˆλ‹€. μ‹€μ œλ‘œ λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ μœ„λ°˜μ΄ ν•„μš”ν•œ 경우 μ•„λž˜ νŒ¨ν„΄μœΌλ‘œ κ΅¬ν˜„ν•˜λ©΄ λ©λ‹ˆλ‹€.

Serviceμ—μ„œ λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ 검증 μ‹œ:

// 예: κ²Œμ‹œκΈ€ μˆ˜μ • μ‹œ 이미 μ‚­μ œλœ 글인 경우
if (entity.getIsDeleted()) {
    throw DuplicateResourceException.alreadyDeleted("κ²Œμ‹œκΈ€");
}

// 예: νŠΉμ • 쑰건에 λ”°λ₯Έ λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ μœ„λ°˜
throw new BusinessRuleException("ν•΄λ‹Ή μž‘μ—…μ€ ν—ˆμš©λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.");

흐름:

[1] ν•„ν„° 톡과 (인증됨)
    β”‚
    ↓
[2] Serviceμ—μ„œ λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ 검증
    β”‚
    β”œβ”€ BusinessRuleException λ˜λŠ” DuplicateResourceException λ°œμƒ!
    β”‚
    ↓
[3] GlobalExceptionHandler.handleBusinessException()
    β”‚
    β”œβ”€ // BusinessRuleException extends BusinessException
    β”‚   // e.getStatus() = HttpStatus.BAD_REQUEST (400)
    β”‚   // e.getErrorCode() = "BUSINESS_RULE_VIOLATION"
    β”‚
    ↓
[응닡] HTTP 400 Bad Request

응닡:

{
  "success": false,
  "message": "ν•΄λ‹Ή μž‘μ—…μ€ ν—ˆμš©λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.",
  "errorCode": "BUSINESS_RULE_VIOLATION",
  "timestamp": "2026-01-16T14:30:00"
}

4.4 ❌ 이미 μ‚­μ œλœ κ²Œμ‹œκΈ€ λ‹€μ‹œ μ‚­μ œ μ‹œλ„

μš”μ²­:

DELETE /api/communities/123
Cookie: access_token=eyJhbGc...(μž‘μ„±μž 본인)

κ²Œμ‹œκΈ€ 123은 이미 μ‚­μ œλœ μƒνƒœ (isDeleted = true)

흐름:

[1] ν•„ν„° 톡과 β†’ 컨트둀러 도달
    β”‚
    ↓
[2] CommunityService.deleteCommunity()
    β”‚
    β”œβ”€ πŸ“ CommunityService.java (Line 77-79)
    β”‚   CommunityEntity community = communityRepository
    β”‚       .findByIdAndIsDeletedFalse(communityId)  // isDeleted=trueλΌμ„œ μ—†μŒ!
    β”‚       .orElseThrow(() -> EntityNotFoundException.of("κ²Œμ‹œκΈ€", communityId));
    β”‚
    ↓
[3] GlobalExceptionHandler β†’ HTTP 404

λ˜λŠ” (isDeleted 체크 μ•ˆ ν•˜λŠ” 쿼리둜 μ‘°νšŒν•œ 경우):

[2] CommunityEntity.softDelete()
    β”‚
    β”œβ”€ πŸ“ CommunityEntity.java (Line 76-80)
    β”‚   public void softDelete() {
    β”‚       if (this.isDeleted) {
    β”‚           throw DuplicateResourceException.alreadyDeleted("κ²Œμ‹œκΈ€");  // ❌
    β”‚       }
    β”‚       this.isDeleted = true;
    β”‚   }
    β”‚
    ↓
[3] GlobalExceptionHandler.handleBusinessException()
    β”‚
    β”œβ”€ // DuplicateResourceException extends BusinessException
    β”‚   // e.getStatus() = HttpStatus.CONFLICT (409)
    β”‚   // e.getErrorCode() = "DUPLICATE_RESOURCE"
    β”‚
    ↓
[응닡] HTTP 409 Conflict

응닡:

{
  "success": false,
  "message": "이미 μ‚­μ œλœ κ²Œμ‹œκΈ€μž…λ‹ˆλ‹€.",
  "errorCode": "DUPLICATE_RESOURCE",
  "timestamp": "2026-01-16T14:30:00"
}

5. μœ νš¨μ„± 검증 μ—λŸ¬ 흐름

5.1 ❌ κ²Œμ‹œκΈ€ μž‘μ„± μ‹œ 제λͺ© λˆ„λ½

μš”μ²­:

POST /api/communities
Cookie: access_token=eyJhbGc...
Content-Type: application/json

{
  "title": "",
  "content": "λ‚΄μš©λ§Œ 있음"
}

흐름:

[1] ν•„ν„° 톡과 β†’ 컨트둀러 도달
    β”‚
    ↓
[2] CommunityController.createCommunity()
    β”‚
    β”œβ”€ πŸ“ CommunityController.java (Line 51-53)
    β”‚   @PostMapping
    β”‚   public ResponseEntity<ApiResponse<Long>> createCommunity(
    β”‚       @Valid @RequestBody CommunityCreateDTO createDTO,  // ← @Valid 검증!
    β”‚       ...
    β”‚
    β”œβ”€ πŸ“ CommunityCreateDTO.java (Line 16-17)
    β”‚   @NotBlank(message = "제λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€")
    β”‚   @Size(max = 200, message = "제λͺ©μ€ 200자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€")
    β”‚   private String title;
    β”‚   
    β”‚   // title이 빈 λ¬Έμžμ—΄ β†’ @NotBlank μœ„λ°˜!
    β”‚   // MethodArgumentNotValidException λ°œμƒ!
    β”‚
    ↓
[3] GlobalExceptionHandler.handleValidationException()
    β”‚
    β”œβ”€ πŸ“ GlobalExceptionHandler.java (Line 44-67)
    β”‚   @ExceptionHandler(MethodArgumentNotValidException.class)
    β”‚   public ResponseEntity<ErrorResponse> handleValidationException(
    β”‚           MethodArgumentNotValidException e) {
    β”‚       
    β”‚       List<ErrorResponse.FieldError> fieldErrors = e.getBindingResult()
    β”‚           .getFieldErrors()
    β”‚           .stream()
    β”‚           .map(error -> ErrorResponse.FieldError.builder()
    β”‚               .field(error.getField())           // "title"
    β”‚               .message(error.getDefaultMessage()) // "제λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€"
    β”‚               .rejectedValue(error.getRejectedValue()) // ""
    β”‚               .build())
    β”‚           .collect(Collectors.toList());
    β”‚       
    β”‚       ErrorResponse response = ErrorResponse.of(
    β”‚           "μž…λ ₯값이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.",
    β”‚           "VALIDATION_ERROR",
    β”‚           fieldErrors
    β”‚       );
    β”‚       return ResponseEntity.badRequest().body(response);
    β”‚   }
    β”‚
    ↓
[응닡] HTTP 400 Bad Request

응닡:

{
  "success": false,
  "message": "μž…λ ₯값이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.",
  "errorCode": "VALIDATION_ERROR",
  "timestamp": "2026-01-16T14:30:00",
  "errors": [
    {
      "field": "title",
      "message": "제λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€",
      "rejectedValue": ""
    }
  ]
}

5.2 ❌ νšŒμ›κ°€μž… μ‹œ μ—¬λŸ¬ ν•„λ“œ 검증 μ‹€νŒ¨

μš”μ²­:

POST /api/users
Content-Type: application/json

{
  "username": "ab",
  "password": "12",
  "email": "invalid-email",
  "nickname": ""
}

흐름:

[1] JoinController.join()
    β”‚
    β”œβ”€ πŸ“ JoinController.java (Line 23)
    β”‚   @PostMapping
    β”‚   public ResponseEntity<ApiResponse<Void>> join(
    β”‚       @Valid @RequestBody JoinDTO joinDTO)  // ← @Valid 검증!
    β”‚
    β”œβ”€ πŸ“ JoinDTO.java
    β”‚   @Size(min = 4, ...) username = "ab"  β†’ μœ„λ°˜! (4자 미만)
    β”‚   @Size(min = 4, ...) password = "12"  β†’ μœ„λ°˜! (4자 미만)
    β”‚   @Email email = "invalid-email"       β†’ μœ„λ°˜! (이메일 ν˜•μ‹ X)
    β”‚   @NotBlank nickname = ""              β†’ μœ„λ°˜! (빈 λ¬Έμžμ—΄)
    β”‚
    ↓
[2] GlobalExceptionHandler.handleValidationException()
    β”‚
    ↓
[응닡] HTTP 400 Bad Request

응닡:

{
  "success": false,
  "message": "μž…λ ₯값이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.",
  "errorCode": "VALIDATION_ERROR",
  "timestamp": "2026-01-16T14:30:00",
  "errors": [
    {
      "field": "username",
      "message": "μ•„μ΄λ””λŠ” 4~20μžμ—¬μ•Ό ν•©λ‹ˆλ‹€",
      "rejectedValue": "ab"
    },
    {
      "field": "password",
      "message": "λΉ„λ°€λ²ˆν˜ΈλŠ” 4자 이상이어야 ν•©λ‹ˆλ‹€",
      "rejectedValue": "12"
    },
    {
      "field": "email",
      "message": "이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€",
      "rejectedValue": "invalid-email"
    },
    {
      "field": "nickname",
      "message": "λ‹‰λ„€μž„μ€ ν•„μˆ˜μž…λ‹ˆλ‹€",
      "rejectedValue": ""
    }
  ]
}

6. 응닡 ν˜•μ‹ 정리

6.1 성곡 응닡 (ApiResponse)

{
  "success": true,
  "message": "μž‘μ—…μ΄ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
  "data": { ... }  // μ‹€μ œ 데이터 (없을 μˆ˜λ„ 있음)
}

6.2 μ—λŸ¬ 응닡 (ErrorResponse)

{
  "success": false,
  "message": "μ—λŸ¬ λ©”μ‹œμ§€",
  "errorCode": "ERROR_CODE",
  "timestamp": "2026-01-16T14:30:00",
  "errors": [...]  // μœ νš¨μ„± 검증 μ—λŸ¬ μ‹œμ—λ§Œ 쑴재
}

6.3 μ—λŸ¬ μ½”λ“œ 정리

errorCode HTTP μƒνƒœ λ°œμƒ μœ„μΉ˜ μ„€λͺ…
AUTHENTICATION_FAILED 401 JwtLoginFilter 둜그인 μ‹€νŒ¨ (ID/PW 뢈일치)
NOT_AUTHENTICATED 401 SecurityConfig 인증 ν•„μš” (토큰 μ—†μŒ)
TOKEN_EXPIRED 401 SecurityConfig / RefreshController Access/Refresh Token 만료
TOKEN_REQUIRED 401 RefreshController Refresh Token 헀더 μ—†μŒ
TOKEN_DISCARDED 401 RefreshController 폐기된 토큰 (λ‘œκ·Έμ•„μ›ƒλ¨)
NOT_FOUND 404 GlobalExceptionHandler λ¦¬μ†ŒμŠ€ μ—†μŒ
ACCESS_DENIED 403 GlobalExceptionHandler κΆŒν•œ μ—†μŒ (본인 μ•„λ‹˜)
BUSINESS_RULE_VIOLATION 400 GlobalExceptionHandler λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ μœ„λ°˜
DUPLICATE_RESOURCE 409 GlobalExceptionHandler 쀑볡/이미 처리됨
VALIDATION_ERROR 400 GlobalExceptionHandler μœ νš¨μ„± 검증 μ‹€νŒ¨ (@Valid)
INVALID_JSON 400 GlobalExceptionHandler JSON νŒŒμ‹± μ‹€νŒ¨
MISSING_PARAMETER 400 GlobalExceptionHandler ν•„μˆ˜ νŒŒλΌλ―Έν„° λˆ„λ½
TYPE_MISMATCH 400 GlobalExceptionHandler νŒŒλΌλ―Έν„° νƒ€μž… 뢈일치
INTERNAL_SERVER_ERROR 500 GlobalExceptionHandler μ˜ˆμƒμΉ˜ λͺ»ν•œ μ„œλ²„ 였λ₯˜

πŸ“ κ΄€λ ¨ 파일 μœ„μΉ˜ μš”μ•½

파일 μ—­ν• 
jwt/config/SecurityConfig.java ν•„ν„° 등둝, URL κΆŒν•œ μ„€μ •, 인증 μ‹€νŒ¨ ν•Έλ“€λŸ¬
jwt/filter/JwtAccessTokenCheckAndSaveUserInfoFilter.java 토큰 검증 및 인증 정보 μ €μž₯
jwt/filter/JwtLoginFilter.java 둜그인 처리, 토큰 λ°œκΈ‰
jwt/service/CustomUserDetailsService.java μ‚¬μš©μž 쑰회 (둜그인 μ‹œ)
common/exception/GlobalExceptionHandler.java λͺ¨λ“  μ˜ˆμ™Έ 톡합 처리
common/exception/EntityNotFoundException.java 404 μ—λŸ¬
common/exception/AccessDeniedException.java 403 μ—λŸ¬
common/exception/BusinessRuleException.java 400 μ—λŸ¬ (λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™)
common/exception/DuplicateResourceException.java 409 μ—λŸ¬
common/dto/ApiResponse.java 성곡 응닡 DTO
common/dto/ErrorResponse.java μ—λŸ¬ 응닡 DTO