From 215bea3c5110f89ae4d6609bc28b6097a2640587 Mon Sep 17 00:00:00 2001 From: yongho9064 Date: Thu, 25 Sep 2025 09:24:13 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat[repository]:=20=EB=B2=95=EB=A0=B9=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B8=B0=EB=B0=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ai/lawyer/domain/law/repository/HangRepository.java | 3 +++ .../com/ai/lawyer/domain/law/repository/HoRepository.java | 4 ++++ .../com/ai/lawyer/domain/law/repository/JangRepository.java | 3 +++ .../com/ai/lawyer/domain/law/repository/JoRepository.java | 3 +++ 4 files changed, 13 insertions(+) diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/HangRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/HangRepository.java index e91430ab..652750cb 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/law/repository/HangRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/HangRepository.java @@ -1,6 +1,7 @@ package com.ai.lawyer.domain.law.repository; import com.ai.lawyer.domain.law.entity.Hang; +import com.ai.lawyer.domain.law.entity.Jo; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -13,4 +14,6 @@ public interface HangRepository extends JpaRepository { // Hang + Ho만 페치 @EntityGraph(attributePaths = "hoList") List findByJoId(Long joId); + + List findByJo(Jo jo); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/HoRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/HoRepository.java index fb810632..d8d686d2 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/law/repository/HoRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/HoRepository.java @@ -1,9 +1,13 @@ package com.ai.lawyer.domain.law.repository; +import com.ai.lawyer.domain.law.entity.Hang; import com.ai.lawyer.domain.law.entity.Ho; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface HoRepository extends JpaRepository { + List findByHang(Hang hang); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/JangRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/JangRepository.java index 6d8f2b56..8e86a88b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/law/repository/JangRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/JangRepository.java @@ -1,6 +1,7 @@ package com.ai.lawyer.domain.law.repository; import com.ai.lawyer.domain.law.entity.Jang; +import com.ai.lawyer.domain.law.entity.Law; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -12,4 +13,6 @@ public interface JangRepository extends JpaRepository { // Jang + Jo만 페치 @EntityGraph(attributePaths = "joList") List findByLawId(Long lawId); + + List findByLaw(Law law); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/law/repository/JoRepository.java b/backend/src/main/java/com/ai/lawyer/domain/law/repository/JoRepository.java index a0baa07f..fdb82d5a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/law/repository/JoRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/law/repository/JoRepository.java @@ -1,5 +1,6 @@ package com.ai.lawyer.domain.law.repository; +import com.ai.lawyer.domain.law.entity.Jang; import com.ai.lawyer.domain.law.entity.Jo; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; @@ -13,4 +14,6 @@ public interface JoRepository extends JpaRepository { // Jo + Hang만 페치 @EntityGraph(attributePaths = "hangList") List findByJangId(Long jangId); + + List findByJang(Jang jang); } From 09f29ba2bee7100db11d748faf7d55d53bbe6b2a Mon Sep 17 00:00:00 2001 From: DooHyoJeong Date: Thu, 25 Sep 2025 09:47:30 +0900 Subject: [PATCH 2/6] refactor[cicd]: work --- .github/workflows/CI-CD_Pipeline.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/CI-CD_Pipeline.yml b/.github/workflows/CI-CD_Pipeline.yml index fcc138d4..9e17d78a 100644 --- a/.github/workflows/CI-CD_Pipeline.yml +++ b/.github/workflows/CI-CD_Pipeline.yml @@ -44,7 +44,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 21 uses: actions/setup-java@v4 with: From 048693ca99f4a7feb8719df0c711262aa364e736 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Thu, 25 Sep 2025 12:10:26 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat[datasource]:=20h2=20console=EC=9D=84?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ai/lawyer/global/security/SecurityConfig.java | 6 +++++- backend/src/main/resources/application.yml | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java index 471b60b2..defb715a 100644 --- a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java @@ -6,8 +6,8 @@ import org.springframework.context.annotation.Configuration; 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.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -36,10 +36,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) + .headers(headers -> headers + .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) + ) .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/auth/**", "/api/public/**").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers("/api/posts/**").permitAll() + .requestMatchers("/h2-console/**").permitAll() .anyRequest().authenticated() ) // JWT 필터 추가 diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b58a5a85..5a9ac0b2 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -19,6 +19,11 @@ spring: format_sql: true highlight_sql: true + h2: + console: + enabled: true + path: /h2-console + security: oauth2: client: From b254d93a77d1640a64d090c27f8d2b880b98ec22 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Thu, 25 Sep 2025 12:11:47 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix[member]:=20JWT=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EB=B3=80=EA=B2=BD=EA=B3=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B3=84=20=EC=9D=B8=EA=B0=80=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B0=98=ED=99=98=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 88 ++++++------------- .../member/dto/MemberErrorResponse.java | 19 ++++ .../MemberAuthenticationException.java | 7 ++ .../exception/MemberExceptionHandler.java | 48 ++++++++++ .../domain/member/service/MemberService.java | 8 +- .../global/jwt/JwtAuthenticationFilter.java | 25 ++++-- .../ai/lawyer/global/jwt/TokenProvider.java | 24 ++++- 7 files changed, 144 insertions(+), 75 deletions(-) create mode 100644 backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberErrorResponse.java create mode 100644 backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberAuthenticationException.java create mode 100644 backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberExceptionHandler.java diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index 1c5949c6..26e28646 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -3,7 +3,6 @@ import com.ai.lawyer.domain.member.dto.MemberLoginRequest; import com.ai.lawyer.domain.member.dto.MemberResponse; import com.ai.lawyer.domain.member.dto.MemberSignupRequest; -import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -37,14 +36,9 @@ public class MemberController { public ResponseEntity signup(@Valid @RequestBody MemberSignupRequest request) { log.info("회원가입 요청: email={}, name={}", request.getLoginId(), request.getName()); - try { - MemberResponse response = memberService.signup(request); - log.info("회원가입 성공: memberId={}", response.getMemberId()); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } catch (IllegalArgumentException e) { - log.warn("회원가입 실패: {}", e.getMessage()); - return ResponseEntity.badRequest().build(); - } + MemberResponse response = memberService.signup(request); + log.info("회원가입 성공: memberId={}", response.getMemberId()); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } @PostMapping("/login") @@ -57,14 +51,9 @@ public ResponseEntity login(@Valid @RequestBody MemberLoginReque HttpServletResponse response) { log.info("로그인 요청: email={}", request.getLoginId()); - try { - MemberResponse memberResponse = memberService.login(request, response); - log.info("로그인 성공: memberId={}", memberResponse.getMemberId()); - return ResponseEntity.ok(memberResponse); - } catch (IllegalArgumentException e) { - log.warn("로그인 실패: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } + MemberResponse memberResponse = memberService.login(request, response); + log.info("로그인 성공: memberId={}", memberResponse.getMemberId()); + return ResponseEntity.ok(memberResponse); } @PostMapping("/logout") @@ -102,18 +91,12 @@ public ResponseEntity refreshToken(HttpServletRequest request, String refreshToken = extractRefreshTokenFromCookies(request); if (refreshToken == null) { - log.warn("리프레시 토큰이 없음"); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("리프레시 토큰이 없습니다."); } - try { - MemberResponse memberResponse = memberService.refreshToken(refreshToken, response); - log.info("토큰 재발급 성공: memberId={}", memberResponse.getMemberId()); - return ResponseEntity.ok(memberResponse); - } catch (IllegalArgumentException e) { - log.warn("토큰 재발급 실패: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } + MemberResponse memberResponse = memberService.refreshToken(refreshToken, response); + log.info("토큰 재발급 성공: memberId={}", memberResponse.getMemberId()); + return ResponseEntity.ok(memberResponse); } @DeleteMapping("/withdraw") @@ -124,25 +107,18 @@ public ResponseEntity refreshToken(HttpServletRequest request, @ApiResponse(responseCode = "404", description = "존재하지 않는 회원") }) public ResponseEntity withdraw(Authentication authentication, HttpServletResponse response) { - if (authentication == null || authentication.getName() == null) { - log.warn("인증되지 않은 회원탈퇴 요청"); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + if (authentication == null || authentication.getPrincipal() == null) { + throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("인증이 필요합니다."); } - String loginId = authentication.getName(); - log.info("회원탈퇴 요청: email={}", loginId); - - try { - // loginId로 Member를 조회하여 실제 memberId 사용 - Member member = memberService.findByLoginId(loginId); - memberService.withdraw(member.getMemberId()); - memberService.logout(loginId, response); // 탈퇴 후 로그아웃 처리 - log.info("회원탈퇴 성공: email={}, memberId={}", loginId, member.getMemberId()); - return ResponseEntity.ok().build(); - } catch (IllegalArgumentException e) { - log.warn("회원탈퇴 실패: {}", e.getMessage()); - return ResponseEntity.notFound().build(); - } + Long memberId = (Long) authentication.getPrincipal(); + String loginId = (String) authentication.getDetails(); + log.info("회원탈퇴 요청: memberId={}, email={}", memberId, loginId); + + memberService.withdraw(memberId); + memberService.logout(loginId, response); // 탈퇴 후 로그아웃 처리 + log.info("회원탈퇴 성공: memberId={}, email={}", memberId, loginId); + return ResponseEntity.ok().build(); } @GetMapping("/me") @@ -152,24 +128,16 @@ public ResponseEntity withdraw(Authentication authentication, HttpServletR @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자") }) public ResponseEntity getMyInfo(Authentication authentication) { - if (authentication == null || authentication.getName() == null) { - log.warn("인증되지 않은 정보 조회 요청"); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + if (authentication == null || authentication.getPrincipal() == null) { + throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("인증이 필요합니다."); } - String loginId = authentication.getName(); - log.info("내 정보 조회 요청: email={}", loginId); - - try { - // loginId로 Member를 조회하여 실제 memberId 사용 - Member member = memberService.findByLoginId(loginId); - MemberResponse response = memberService.getMemberById(member.getMemberId()); - log.info("내 정보 조회 성공: memberId={}", response.getMemberId()); - return ResponseEntity.ok(response); - } catch (IllegalArgumentException e) { - log.warn("내 정보 조회 실패: {}", e.getMessage()); - return ResponseEntity.notFound().build(); - } + Long memberId = (Long) authentication.getPrincipal(); + log.info("내 정보 조회 요청: memberId={}", memberId); + + MemberResponse response = memberService.getMemberById(memberId); + log.info("내 정보 조회 성공: memberId={}", response.getMemberId()); + return ResponseEntity.ok(response); } private String extractRefreshTokenFromCookies(HttpServletRequest request) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberErrorResponse.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberErrorResponse.java new file mode 100644 index 00000000..8d33614a --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberErrorResponse.java @@ -0,0 +1,19 @@ +package com.ai.lawyer.domain.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class MemberErrorResponse { + private final String message; + private final int status; + private final String error; + private final LocalDateTime timestamp; + + public static MemberErrorResponse of(String message, int status, String error) { + return new MemberErrorResponse(message, status, error, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberAuthenticationException.java b/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberAuthenticationException.java new file mode 100644 index 00000000..8544ee77 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberAuthenticationException.java @@ -0,0 +1,7 @@ +package com.ai.lawyer.domain.member.exception; + +public class MemberAuthenticationException extends RuntimeException { + public MemberAuthenticationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberExceptionHandler.java b/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberExceptionHandler.java new file mode 100644 index 00000000..046cfd8b --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberExceptionHandler.java @@ -0,0 +1,48 @@ +package com.ai.lawyer.domain.member.exception; + +import com.ai.lawyer.domain.member.dto.MemberErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "com.ai.lawyer.domain.member") +@Slf4j +public class MemberExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleMemberIllegalArgumentException(IllegalArgumentException e) { + log.warn("Member 도메인 IllegalArgumentException: {}", e.getMessage()); + MemberErrorResponse errorResponse = MemberErrorResponse.of( + e.getMessage(), + HttpStatus.BAD_REQUEST.value(), + "잘못된 요청" + ); + return ResponseEntity.badRequest().body(errorResponse); + } + + @ExceptionHandler(MemberAuthenticationException.class) + public ResponseEntity handleMemberAuthenticationException(MemberAuthenticationException e) { + log.warn("Member 도메인 AuthenticationException: {}", e.getMessage()); + MemberErrorResponse errorResponse = MemberErrorResponse.of( + e.getMessage(), + HttpStatus.UNAUTHORIZED.value(), + "인증 실패" + ); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMemberValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getAllErrors().getFirst().getDefaultMessage(); + log.warn("Member 도메인 유효성 검증 실패: {}", message); + MemberErrorResponse errorResponse = MemberErrorResponse.of( + message, + HttpStatus.BAD_REQUEST.value(), + "유효성 검증 실패" + ); + return ResponseEntity.badRequest().body(errorResponse); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index 5531d11a..3bb48eaf 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -57,10 +57,12 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp } public void logout(String loginId, HttpServletResponse response) { - // Redis에서 리프레시 토큰 삭제 - tokenProvider.deleteRefreshToken(loginId); + // loginId가 있는 경우에만 Redis에서 리프레시 토큰 삭제 + if (loginId != null && !loginId.trim().isEmpty()) { + tokenProvider.deleteRefreshToken(loginId); + } - // 쿠키 삭제 + // 쿠키는 항상 클리어 (인증 정보가 없어도 클라이언트의 쿠키는 삭제해야 함) cookieUtil.clearTokenCookies(response); } diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index c6663193..07f6b5cb 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -45,15 +45,22 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable private void setAuthentication(String token) { try { - // TODO: 실제 JWT 구현 시 토큰에서 사용자 정보 추출 - // 현재는 임시로 토큰에서 사용자명 추출 - String username = tokenProvider.getUsernameFromToken(token); + // 토큰에서 사용자 정보 추출 + Long memberId = tokenProvider.getMemberIdFromToken(token); + String role = tokenProvider.getRoleFromToken(token); - // 간단한 권한 설정 (실제로는 토큰에서 권한 정보도 추출) - List authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + if (memberId == null) { + log.warn("토큰에서 memberId를 추출할 수 없습니다."); + return; + } + + // 권한 설정 (토큰에서 추출한 role 사용) + String authority = "ROLE_" + (role != null ? role : "USER"); + List authorities = List.of(new SimpleGrantedAuthority(authority)); + // memberId를 principal로 사용하는 인증 객체 생성 UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(username, null, authorities); + new UsernamePasswordAuthenticationToken(memberId, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { @@ -64,8 +71,10 @@ private void setAuthentication(String token) { @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - // 인증이 필요없는 경로들 - return path.startsWith("/api/auth/") || + // 인증이 필요없는 경로들 (구체적으로 명시) + return path.equals("/api/auth/signup") || + path.equals("/api/auth/login") || + path.equals("/api/auth/refresh") || path.startsWith("/api/public/") || path.startsWith("/swagger-") || path.startsWith("/v3/api-docs"); diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java index 307d8776..9a64dedb 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Component; import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Date; import java.util.UUID; @@ -26,7 +27,7 @@ public class TokenProvider { private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7일 private SecretKey getSigningKey() { - return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes()); + return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8)); } public String generateAccessToken(Member member) { @@ -38,6 +39,7 @@ public String generateAccessToken(Member member) { .setIssuedAt(now) .setExpiration(expiry) .claim("memberId", member.getMemberId()) + .claim("role", member.getRole().name()) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } @@ -73,16 +75,30 @@ public boolean validateToken(String token) { return false; } - public String getUsernameFromToken(String token) { + public Long getMemberIdFromToken(String token) { try { Claims claims = Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); - return claims.getSubject(); + return claims.get("memberId", Long.class); } catch (Exception e) { - log.warn("토큰에서 사용자 정보 추출 실패: {}", e.getMessage()); + log.warn("토큰에서 회원 ID 추출 실패: {}", e.getMessage()); + return null; + } + } + + public String getRoleFromToken(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.get("role", String.class); + } catch (Exception e) { + log.warn("토큰에서 역할 정보 추출 실패: {}", e.getMessage()); return null; } } From 08cd9b52405fb80b5a61b69358a3f237f88be6bb Mon Sep 17 00:00:00 2001 From: DooHyoJeong Date: Thu, 25 Sep 2025 12:25:31 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor[yml]:=20redis=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/resources/application-prod.yml | 2 ++ backend/src/main/resources/application.yml | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 540d59e1..f5bc1cf8 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -1,4 +1,6 @@ spring: + autoconfigure: + exclude: datasource: url: ${PROD_DATASOURCE_URL} driver-class-name: ${PROD_DATASOURCE_DRIVER} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b58a5a85..bdb4da29 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,4 +1,8 @@ spring: + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration config: import: optional:file:.env[.properties] application: @@ -8,7 +12,6 @@ spring: output: ansi: enabled: always - jpa: show-sql: true hibernate: @@ -54,6 +57,17 @@ logging: org.springframework: INFO org.hibernate: INFO com.ai.lawyer: DEBUG +management: + endpoints: + web: + base-path: /actuator # 기본값이지만 명시 + exposure: + include: health,info # 필요시 metrics 등 추가 + endpoint: + health: + probes: + enabled: true # /actuator/health/{liveness,readiness} 활성화 + show-details: never # 프로브 용도면 never 권장(민감정보 차단) custom: jwt: secretKey: ${CUSTOM_JWT_SECRET_KEY} From 2cf30d5eea19bb9bfcd77d1218c326353eeb5a6b Mon Sep 17 00:00:00 2001 From: asowjdan Date: Thu, 25 Sep 2025 12:49:11 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix[member]:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=EC=97=90=20=EC=88=98=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95,=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.default | 2 +- .../member/controller/MemberController.java | 13 ++- .../exception/MemberExceptionHandler.java | 40 +++++-- .../domain/member/service/MemberService.java | 26 +++-- .../global/jwt/JwtAuthenticationFilter.java | 18 ++-- .../ai/lawyer/global/jwt/TokenProvider.java | 8 +- .../global/security/SecurityConfig.java | 2 +- .../controller/MemberControllerTest.java | 102 +++++++++--------- 8 files changed, 123 insertions(+), 88 deletions(-) diff --git a/backend/.env.default b/backend/.env.default index 8e65dfe4..81c9a2ab 100644 --- a/backend/.env.default +++ b/backend/.env.default @@ -7,7 +7,7 @@ SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__KAKAO__CLIENT_SECRET=NEED_TO_SET SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__NAVER__CLIENT_ID=NEED_TO_SET SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__NAVER__CLIENT_SECRET=NEED_TO_SET -CUSTOM__JWT__SECRET_KEY=NEED_TO_SET +CUSTOM_JWT_SECRET_KEY=NEED_TO_SET CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=NEED_TO_SET PROD_DATASOURCE_URL=NEED_TO_SET diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index 26e28646..8b56a4be 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -67,9 +67,9 @@ public ResponseEntity logout(Authentication authentication, HttpServletRes if (authentication != null && authentication.getName() != null) { String loginId = authentication.getName(); memberService.logout(loginId, response); - log.info("로그아웃 완료: email={}", loginId); + log.info("로그아웃 완료: memberId={}", loginId); } else { - // 인증 정보가 없어도 쿠키는 클리어 + // 인증되지 않은 상태에서도 클라이언트 쿠키 클리어 처리 memberService.logout("", response); log.info("인증 정보 없이 로그아웃 완료"); } @@ -87,7 +87,7 @@ public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response) { log.info("토큰 재발급 요청"); - // 쿠키에서 리프레시 토큰 추출 (간단한 방법) + // HTTP 쿠키에서 리프레시 토큰 추출 String refreshToken = extractRefreshTokenFromCookies(request); if (refreshToken == null) { @@ -116,7 +116,7 @@ public ResponseEntity withdraw(Authentication authentication, HttpServletR log.info("회원탈퇴 요청: memberId={}, email={}", memberId, loginId); memberService.withdraw(memberId); - memberService.logout(loginId, response); // 탈퇴 후 로그아웃 처리 + memberService.logout(loginId, response); // 회원 탈퇴 후 세션 및 토큰 정리 log.info("회원탈퇴 성공: memberId={}, email={}", memberId, loginId); return ResponseEntity.ok().build(); } @@ -140,6 +140,11 @@ public ResponseEntity getMyInfo(Authentication authentication) { return ResponseEntity.ok(response); } + /** + * HTTP 쿠키에서 리프레시 토큰을 추출합니다. + * @param request HTTP 요청 객체 + * @return 리프레시 토큰 값 또는 null + */ private String extractRefreshTokenFromCookies(HttpServletRequest request) { if (request.getCookies() != null) { for (jakarta.servlet.http.Cookie cookie : request.getCookies()) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberExceptionHandler.java b/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberExceptionHandler.java index 046cfd8b..c9baf4bd 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberExceptionHandler.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/exception/MemberExceptionHandler.java @@ -12,17 +12,40 @@ @Slf4j public class MemberExceptionHandler { + /** + * IllegalArgumentException 고도화 처리 + * 메시지에 따라 HTTP 상태코드와 에러 메시지 다르게 반환 + */ @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleMemberIllegalArgumentException(IllegalArgumentException e) { log.warn("Member 도메인 IllegalArgumentException: {}", e.getMessage()); - MemberErrorResponse errorResponse = MemberErrorResponse.of( - e.getMessage(), - HttpStatus.BAD_REQUEST.value(), - "잘못된 요청" - ); - return ResponseEntity.badRequest().body(errorResponse); + + String msg = e.getMessage(); + HttpStatus status; + String error = switch (msg) { + case "이미 존재하는 이메일입니다.", "잘못된 입력입니다." -> { + status = HttpStatus.BAD_REQUEST; + yield "잘못된 요청"; + } + case "존재하지 않는 회원입니다.", "비밀번호가 일치하지 않습니다." -> { + status = HttpStatus.UNAUTHORIZED; + yield "인증 실패"; + } + default -> { + status = HttpStatus.BAD_REQUEST; + yield "오류 발생"; + } + }; + + // 메시지 기반으로 상태코드 결정 + + MemberErrorResponse errorResponse = MemberErrorResponse.of(msg, status.value(), error); + return ResponseEntity.status(status).body(errorResponse); } + /** + * 인증 관련 예외 처리 + */ @ExceptionHandler(MemberAuthenticationException.class) public ResponseEntity handleMemberAuthenticationException(MemberAuthenticationException e) { log.warn("Member 도메인 AuthenticationException: {}", e.getMessage()); @@ -34,6 +57,9 @@ public ResponseEntity handleMemberAuthenticationException(M return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); } + /** + * 유효성 검증 실패 처리 + */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMemberValidationException(MethodArgumentNotValidException e) { String message = e.getBindingResult().getAllErrors().getFirst().getDefaultMessage(); @@ -45,4 +71,4 @@ public ResponseEntity handleMemberValidationException(Metho ); return ResponseEntity.badRequest().body(errorResponse); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index 3bb48eaf..b58f87b3 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -46,34 +46,32 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); } - // 토큰 생성 및 쿠키 설정 + // JWT 액세스 토큰과 리프레시 토큰 생성 후 HTTP 쿠키에 설정 String accessToken = tokenProvider.generateAccessToken(member); String refreshToken = tokenProvider.generateRefreshToken(member); cookieUtil.setTokenCookies(response, accessToken, refreshToken); - // TODO: 추후 레디스에 토큰-회원 매핑 정보 저장 - return MemberResponse.from(member); } public void logout(String loginId, HttpServletResponse response) { - // loginId가 있는 경우에만 Redis에서 리프레시 토큰 삭제 + // 로그인 ID가 존재할 경우 Redis에서 리프레시 토큰 삭제 if (loginId != null && !loginId.trim().isEmpty()) { tokenProvider.deleteRefreshToken(loginId); } - // 쿠키는 항상 클리어 (인증 정보가 없어도 클라이언트의 쿠키는 삭제해야 함) + // 인증 상태와 관계없이 클라이언트 쿠키 클리어 cookieUtil.clearTokenCookies(response); } public MemberResponse refreshToken(String refreshToken, HttpServletResponse response) { - // Redis에서 리프레시 토큰으로 사용자를 찾기 (Redis 키 패턴: refresh_token:loginId) + // Redis에서 리프레시 토큰으로 사용자 찾기 String username = tokenProvider.findUsernameByRefreshToken(refreshToken); if (username == null) { throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); } - // Redis에서 리프레시 토큰 유효성 검증 + // 리프레시 토큰 유효성 검증 if (!tokenProvider.validateRefreshToken(username, refreshToken)) { throw new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다."); } @@ -82,14 +80,14 @@ public MemberResponse refreshToken(String refreshToken, HttpServletResponse resp Member member = memberRepository.findByLoginId(username) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // 기존 리프레시 토큰 Redis에서 삭제 (RTR 패턴) + // RTR(Refresh Token Rotation) 패턴: 기존 리프레시 토큰 삭제 tokenProvider.deleteRefreshToken(username); // 새로운 액세스 토큰과 리프레시 토큰 생성 String newAccessToken = tokenProvider.generateAccessToken(member); String newRefreshToken = tokenProvider.generateRefreshToken(member); - // 새로운 토큰들을 쿠키로 설정 + // 새로운 토큰들을 HTTP 쿠키에 설정 cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken); return MemberResponse.from(member); @@ -110,11 +108,11 @@ public MemberResponse getMemberById(Long memberId) { return MemberResponse.from(member); } - public Member findByLoginId(String loginId) { - return memberRepository.findByLoginId(loginId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - } - + /** + * 로그인 ID 중복 검사 + * @param loginId 검사할 로그인 ID + * @throws IllegalArgumentException 중복된 로그인 ID인 경우 + */ private void validateDuplicateLoginId(String loginId) { if (memberRepository.existsByLoginId(loginId)) { throw new IllegalArgumentException("이미 존재하는 이메일입니다."); diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index 07f6b5cb..779f81c3 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -31,9 +31,8 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable if (request != null) { String accessToken = cookieUtil.getAccessTokenFromCookies(request); - // 액세스 토큰이 있는 경우 + // JWT 액세스 토큰 검증 및 인증 처리 if (accessToken != null && tokenProvider.validateToken(accessToken)) { - // 유효한 토큰 - 인증 정보 설정 setAuthentication(accessToken); } } @@ -43,9 +42,12 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable } } + /** + * JWT 토큰에서 사용자 정보를 추출하여 Spring Security 인증 객체를 설정합니다. + * @param token JWT 액세스 토큰 + */ private void setAuthentication(String token) { try { - // 토큰에서 사용자 정보 추출 Long memberId = tokenProvider.getMemberIdFromToken(token); String role = tokenProvider.getRoleFromToken(token); @@ -54,11 +56,11 @@ private void setAuthentication(String token) { return; } - // 권한 설정 (토큰에서 추출한 role 사용) + // Spring Security 권한 형식으로 변환 String authority = "ROLE_" + (role != null ? role : "USER"); List authorities = List.of(new SimpleGrantedAuthority(authority)); - // memberId를 principal로 사용하는 인증 객체 생성 + // memberId를 principal로 하는 인증 객체 생성 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(memberId, null, authorities); @@ -68,10 +70,14 @@ private void setAuthentication(String token) { } } + /** + * JWT 인증이 필요하지 않은 경로들을 필터링에서 제외합니다. + * @param request HTTP 요청 + * @return true인 경우 필터 제외 + */ @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - // 인증이 필요없는 경로들 (구체적으로 명시) return path.equals("/api/auth/signup") || path.equals("/api/auth/login") || path.equals("/api/auth/refresh") || diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java index 9a64dedb..875142c6 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java @@ -47,7 +47,7 @@ public String generateAccessToken(Member member) { public String generateRefreshToken(Member member) { String refreshToken = UUID.randomUUID().toString(); - // Redis에 리프레시 토큰 저장 + // Redis에 리프레시 토큰 저장 (만료시간: 7일) String redisKey = REFRESH_TOKEN_PREFIX + member.getLoginId(); redisTemplate.opsForValue().set(redisKey, refreshToken, Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME)); @@ -114,6 +114,12 @@ public void deleteRefreshToken(String loginId) { redisTemplate.delete(redisKey); } + /** + * 리프레시 토큰으로 사용자명을 찾습니다. + * Redis에서 모든 리프레시 토큰 키를 순회하며 일치하는 토큰을 찾습니다. + * @param refreshToken 찾을 리프레시 토큰 + * @return 사용자명 또는 null + */ public String findUsernameByRefreshToken(String refreshToken) { String pattern = REFRESH_TOKEN_PREFIX + "*"; var keys = redisTemplate.keys(pattern); diff --git a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java index defb715a..cec76181 100644 --- a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java @@ -60,7 +60,7 @@ public PasswordEncoder passwordEncoder() { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("http://localhost:3000")); // 프론트엔드 주소 + configuration.setAllowedOrigins(List.of("http://localhost:3000")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java index 7d6092c3..f2e53fce 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java @@ -5,6 +5,8 @@ import com.ai.lawyer.domain.member.dto.MemberSignupRequest; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.service.MemberService; +import com.ai.lawyer.domain.member.exception.MemberAuthenticationException; +import com.ai.lawyer.domain.member.exception.MemberExceptionHandler; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; @@ -28,6 +30,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -62,7 +65,10 @@ class MemberControllerTest { @BeforeEach void setUp() { - mockMvc = MockMvcBuilders.standaloneSetup(memberController).build(); + mockMvc = MockMvcBuilders.standaloneSetup(memberController) + .setControllerAdvice(new MemberExceptionHandler()) + .build(); + objectMapper = new ObjectMapper(); signupRequest = MemberSignupRequest.builder() @@ -88,10 +94,16 @@ void setUp() { .build(); authentication = new UsernamePasswordAuthenticationToken( - "test@example.com", + 1L, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); + ) { + @Override + public String getName() { + return "test@example.com"; + } + }; + ((UsernamePasswordAuthenticationToken) authentication).setDetails("test@example.com"); } @Test @@ -120,11 +132,9 @@ void signup_Success() throws Exception { @Test @DisplayName("회원가입 실패 - 이메일 중복") void signup_Fail_DuplicateEmail() throws Exception { - // given given(memberService.signup(any(MemberSignupRequest.class))) .willThrow(new IllegalArgumentException("이미 존재하는 이메일입니다.")); - // when and then mockMvc.perform(post("/api/auth/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -181,11 +191,9 @@ void login_Success() throws Exception { @Test @DisplayName("로그인 실패 - 존재하지 않는 회원") void login_Fail_MemberNotFound() throws Exception { - // given given(memberService.login(any(MemberLoginRequest.class), any())) .willThrow(new IllegalArgumentException("존재하지 않는 회원입니다.")); - // when and then mockMvc.perform(post("/api/auth/login") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -199,11 +207,9 @@ void login_Fail_MemberNotFound() throws Exception { @Test @DisplayName("로그인 실패 - 비밀번호 불일치") void login_Fail_PasswordMismatch() throws Exception { - // given given(memberService.login(any(MemberLoginRequest.class), any())) .willThrow(new IllegalArgumentException("비밀번호가 일치하지 않습니다.")); - // when and then mockMvc.perform(post("/api/auth/login") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -268,11 +274,13 @@ void refreshToken_Fail_NoRefreshToken() { // given given(request.getCookies()).willReturn(null); - // when - ResponseEntity result = memberController.refreshToken(request, response); + // when & then - 예외가 발생해야 함 + try { + memberController.refreshToken(request, response); + } catch (MemberAuthenticationException e) { + assertThat(e.getMessage()).isEqualTo("리프레시 토큰이 없습니다."); + } - // then - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); verify(memberService, never()).refreshToken(anyString(), any()); } @@ -285,23 +293,18 @@ void refreshToken_Fail_InvalidToken() { given(memberService.refreshToken(eq("invalidRefreshToken"), eq(response))) .willThrow(new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.")); - // when - ResponseEntity result = memberController.refreshToken(request, response); + // when & then + assertThatThrownBy(() -> memberController.refreshToken(request, response)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("유효하지 않은 리프레시 토큰입니다."); - // then - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); verify(memberService).refreshToken(eq("invalidRefreshToken"), eq(response)); } @Test @DisplayName("회원탈퇴 성공") void withdraw_Success() { - // given - Member mockMember = Member.builder() - .memberId(1L) - .loginId("test@example.com") - .build(); - given(memberService.findByLoginId("test@example.com")).willReturn(mockMember); + // given - 현재 Controller는 직접 memberId를 사용 doNothing().when(memberService).withdraw(1L); doNothing().when(memberService).logout(eq("test@example.com"), eq(response)); @@ -310,7 +313,6 @@ void withdraw_Success() { // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - verify(memberService).findByLoginId("test@example.com"); verify(memberService).withdraw(1L); verify(memberService).logout(eq("test@example.com"), eq(response)); } @@ -318,11 +320,11 @@ void withdraw_Success() { @Test @DisplayName("회원탈퇴 실패 - 인증되지 않은 사용자") void withdraw_Fail_Unauthenticated() { - // when - ResponseEntity result = memberController.withdraw(null, response); + // when & then + assertThatThrownBy(() -> memberController.withdraw(null, response)) + .isInstanceOf(MemberAuthenticationException.class) + .hasMessage("인증이 필요합니다."); - // then - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); verify(memberService, never()).withdraw(anyLong()); verify(memberService, never()).logout(anyString(), any()); } @@ -331,28 +333,22 @@ void withdraw_Fail_Unauthenticated() { @DisplayName("회원탈퇴 실패 - 존재하지 않는 회원") void withdraw_Fail_MemberNotFound() { // given - given(memberService.findByLoginId("test@example.com")) - .willThrow(new IllegalArgumentException("존재하지 않는 회원입니다.")); + doThrow(new IllegalArgumentException("존재하지 않는 회원입니다.")) + .when(memberService).withdraw(1L); - // when - ResponseEntity result = memberController.withdraw(authentication, response); + // when & then + assertThatThrownBy(() -> memberController.withdraw(authentication, response)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다."); - // then - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - verify(memberService).findByLoginId("test@example.com"); - verify(memberService, never()).withdraw(anyLong()); + verify(memberService).withdraw(1L); verify(memberService, never()).logout(anyString(), any()); } @Test @DisplayName("내 정보 조회 성공") void getMyInfo_Success() { - // given - Member mockMember = Member.builder() - .memberId(1L) - .loginId("test@example.com") - .build(); - given(memberService.findByLoginId("test@example.com")).willReturn(mockMember); + // given - 현재 Controller는 직접 memberId를 사용 given(memberService.getMemberById(1L)).willReturn(memberResponse); // when @@ -361,18 +357,17 @@ void getMyInfo_Success() { // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isEqualTo(memberResponse); - verify(memberService).findByLoginId("test@example.com"); verify(memberService).getMemberById(1L); } @Test @DisplayName("내 정보 조회 실패 - 인증되지 않은 사용자") void getMyInfo_Fail_Unauthenticated() { - // when - ResponseEntity result = memberController.getMyInfo(null); + // when & then + assertThatThrownBy(() -> memberController.getMyInfo(null)) + .isInstanceOf(MemberAuthenticationException.class) + .hasMessage("인증이 필요합니다."); - // then - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); verify(memberService, never()).getMemberById(anyLong()); } @@ -380,15 +375,14 @@ void getMyInfo_Fail_Unauthenticated() { @DisplayName("내 정보 조회 실패 - 존재하지 않는 회원") void getMyInfo_Fail_MemberNotFound() { // given - given(memberService.findByLoginId("test@example.com")) + given(memberService.getMemberById(1L)) .willThrow(new IllegalArgumentException("존재하지 않는 회원입니다.")); - // when - ResponseEntity result = memberController.getMyInfo(authentication); + // when & then + assertThatThrownBy(() -> memberController.getMyInfo(authentication)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 회원입니다."); - // then - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - verify(memberService).findByLoginId("test@example.com"); - verify(memberService, never()).getMemberById(anyLong()); + verify(memberService).getMemberById(1L); } } \ No newline at end of file