diff --git a/README.md b/README.md index 5d5cefe..19c4c6e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,119 @@ -# Book-Web -온라인 서점 서버 구축 +# Book Bot - 온라인 서점 시스템 + +Spring Boot 기반의 온라인 서점 시스템입니다. 네이버 도서 API를 활용한 도서 검색, 주문 관리, 재고 알림 시스템 등을 제공합니다. + +## 기술 스택 + +- **Backend**: Spring Boot, Spring Security, Spring Data JPA +- **Database**: MySQL, Redis +- **External API**: Naver Book Search API +- **Communication**: WebClient +- **Authentication**: JWT +- **Email**: Spring Mail +- **Build Tool**: Gradle/Maven + +## 주요 기능 + +### 1. 사용자 인증 시스템 +- JWT 기반 사용자 인증 +- Spring Security를 통한 보안 처리 +- Redis 세션 관리 + +### 2. 도서 관리 +- 네이버 도서 API 연동 +- 도서 검색 및 정보 조회 +- 외부 API 데이터 MySQL 저장 + +### 3. 주문 시스템 +- 주문 생성 및 관리 +- 결제 처리 +- 베스트셀러 집계 + +### 4. 등급 관리 (전략 패턴) +- 사용자 등급 시스템 +- Redis 캐싱을 통한 성능 최적화 +- 전략 패턴 기반 등급 처리 + +### 5. 재고 알림 시스템 (옵저버 패턴) +- 옵저버 패턴 기반 재고 관리 +- 이메일 알림 기능 +- 실시간 재고 상태 모니터링 + +### 6. 예외 처리 +- GlobalExceptionHandler를 통한 통합 예외 처리 +- 커스텀 Exception 클래스 +- 일관된 API 응답 구조 + +## 아키텍처 + +``` +src/main/java/com/fastcampus/book_bot/ +├── common/ # 공통 설정 및 유틸리티 +│ ├── config/ # Spring 설정 클래스 +│ ├── exception/ # 커스텀 예외 처리 +│ ├── response/ # API 응답 구조 +│ └── utils/ # 유틸리티 클래스 +├── controller/ # REST 컨트롤러 +│ ├── api/ # 외부 API 컨트롤러 +│ ├── auth/ # 인증 관련 컨트롤러 +│ ├── book/ # 도서 관련 컨트롤러 +│ └── order/ # 주문 관련 컨트롤러 +├── domain/ # JPA 엔티티 +│ ├── book/ # 도서 엔티티 +│ ├── noti/ # 알림 엔티티 +│ ├── orders/ # 주문 엔티티 +│ ├── payment/ # 결제 엔티티 +│ └── user/ # 사용자 엔티티 +├── repository/ # JPA Repository +├── service/ # 비즈니스 로직 +│ ├── api/ # 외부 API 서비스 +│ ├── auth/ # 인증 서비스 +│ ├── book/ # 도서 서비스 +│ ├── grade/ # 등급 서비스 (전략 패턴) +│ ├── noti/ # 알림 서비스 (옵저버 패턴) +│ └── order/ # 주문 서비스 +└── dto/ # 데이터 전송 객체 +``` + +## 디자인 패턴 + +### 전략 패턴 (Strategy Pattern) +- **위치**: `service/grade/` +- **목적**: 사용자 등급별 다른 처리 로직 구현 +- **구성**: `GradeStrategy`, `GradeStrategyFactory`, `RedisGradeStrategy` + +### 옵저버 패턴 (Observer Pattern) +- **위치**: `service/noti/` +- **목적**: 재고 변동 시 구독자들에게 알림 전송 +- **구성**: `StockSubject`, `StockObserver`, `SubscriptionObserver` + +## 실행 방법 + +### 환경 설정 +```bash +# MySQL 및 Redis 실행 (Docker Compose 사용) +cd mysql-docker +docker-compose up -d +``` + +### API 테스트 +- **Base URL**: `http://localhost:8080` +- **API 문서**: Swagger UI 또는 Postman 활용 + +## 설정 파일 + +### application.yml +```yaml +# 데이터베이스, Redis, 외부 API 설정 +# JWT 토큰 설정 +# 메일 서버 설정 +``` + +## 개발 내용 + +- **도메인 중심 설계**: 각 도메인별로 명확한 책임 분리 +- **디자인 패턴 적용**: 전략 패턴과 옵저버 패턴을 통한 유연한 설계 +- **외부 API 연동**: WebClient를 활용한 비동기 통신 +- **캐싱 전략**: Redis를 활용한 성능 최적화 +- **보안**: JWT 기반 인증 및 Spring Security 적용 +- **예외 처리**: 전역 예외 처리기를 통한 일관된 오류 응답 diff --git a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java index 9886cac..fa6a58f 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java @@ -1,14 +1,17 @@ package com.fastcampus.book_bot.controller.auth; import com.fastcampus.book_bot.common.response.SuccessApiResponse; +import com.fastcampus.book_bot.common.utils.JwtUtil; import com.fastcampus.book_bot.domain.user.User; import com.fastcampus.book_bot.dto.user.SignupRequestDTO; import com.fastcampus.book_bot.dto.user.UserDTO; import com.fastcampus.book_bot.service.auth.AuthRedisService; import com.fastcampus.book_bot.service.auth.AuthService; import com.fastcampus.book_bot.service.auth.MailService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -19,11 +22,13 @@ @RestController @RequestMapping("/api/member") @RequiredArgsConstructor +@Slf4j public class AuthController { private final MailService mailService; private final AuthService authService; private final AuthRedisService authRedisService; + private final JwtUtil jwtUtil; // JWT 유틸리티 추가 /** 로그인 * @param request SignupRequestDTO @@ -31,7 +36,13 @@ public class AuthController { */ @PostMapping("/login") public ResponseEntity>> login(@RequestBody SignupRequestDTO request, - HttpServletResponse response) { + HttpServletResponse response, + HttpServletRequest httpRequest) { + + log.info("=== 로그인 요청 ==="); + log.info("요청 IP: {}", httpRequest.getRemoteAddr()); + log.info("이메일: {}", request.getEmail()); + User user = authService.login(request); String accessToken = authRedisService.setTokenUser(user, response); @@ -41,6 +52,8 @@ public ResponseEntity>> login(@RequestBod data.put("accessToken", accessToken); data.put("user", userDTO); + log.info("로그인 성공 - 사용자 ID: {}, 닉네임: {}", user.getUserId(), user.getUserNickname()); + return ResponseEntity.ok(SuccessApiResponse.of("로그인 완료!", data)); } @@ -53,22 +66,57 @@ public ResponseEntity>> login(@RequestBod @PostMapping("/logout") public ResponseEntity> logout(@RequestHeader(value = "Authorization", required = false) String authorization, @CookieValue(value = "refreshToken", required = false) String refreshToken, - HttpServletResponse response) { + HttpServletResponse response, + HttpServletRequest request) { + + log.info("=== 로그아웃 요청 ==="); + log.info("요청 IP: {}", request.getRemoteAddr()); + log.info("Authorization 헤더: {}", authorization != null ? "존재" : "없음"); + log.info("RefreshToken 쿠키: {}", refreshToken != null ? "존재" : "없음"); + authRedisService.deleteTokenUser(authorization, refreshToken, response); return ResponseEntity.ok(SuccessApiResponse.of("로그아웃이 완료되었습니다.")); } /** - * Access Token 갱신 + * Access Token 갱신 (개선된 버전) * @param refreshToken 쿠키의 refreshToken */ @PostMapping("/refresh") - public ResponseEntity>> refreshToken(@CookieValue(value = "refreshToken", required = false) String refreshToken) { + public ResponseEntity>> refreshToken( + @CookieValue(value = "refreshToken", required = false) String refreshToken, + HttpServletRequest request) { + + log.info("=== Refresh Token 요청 ==="); + log.info("요청 IP: {}", request.getRemoteAddr()); + log.info("User-Agent: {}", request.getHeader("User-Agent")); + log.info("쿠키에서 받은 refreshToken: {}", refreshToken != null ? "존재" : "null"); + + // 요청 헤더의 모든 쿠키 로깅 + String cookieHeader = request.getHeader("Cookie"); + log.info("Cookie 헤더: {}", cookieHeader != null ? cookieHeader : "없음"); + + if (refreshToken != null) { + log.info("refreshToken 길이: {}", refreshToken.length()); + log.info("refreshToken 앞 10자리: {}", refreshToken.substring(0, Math.min(10, refreshToken.length()))); + } + String newAccessToken = authRedisService.refreshAccessToken(refreshToken); - Map data = new HashMap<>(); + // 사용자 정보도 함께 반환하도록 수정 + Integer userId = jwtUtil.extractUserId(newAccessToken); + + // 기본 사용자 정보 (실제 구현에서는 UserService에서 조회) + Map userInfo = new HashMap<>(); + userInfo.put("userId", userId); + userInfo.put("nickname", "사용자"); // 기본값, 실제로는 DB에서 조회 + + Map data = new HashMap<>(); data.put("accessToken", newAccessToken); + data.put("user", userInfo); // 사용자 정보 포함 + + log.info("Refresh Token 갱신 완료 - 사용자 ID: {}", userId); return ResponseEntity.ok(SuccessApiResponse.of("Access Token이 갱신되었습니다.", data)); } @@ -78,6 +126,8 @@ public ResponseEntity>> refreshToken(@Coo */ @PostMapping("/signup") public ResponseEntity> signup(@RequestBody SignupRequestDTO request) { + log.info("회원가입 요청 - 이메일: {}", request.getEmail()); + authService.signup(request); return ResponseEntity.status(HttpStatus.CREATED) @@ -89,7 +139,7 @@ public ResponseEntity> signup(@RequestBody SignupReques public ResponseEntity> checkNickname(@RequestParam String nickname) { boolean response = authService.isDuplicateNickname(nickname); - String message = response ? "이미 사용 중인 이메일입니다" : "사용 가능한 이메일입니다"; + String message = response ? "이미 사용 중인 닉네임입니다" : "사용 가능한 닉네임입니다"; return ResponseEntity.ok(SuccessApiResponse.of(message)); } @@ -98,7 +148,7 @@ public ResponseEntity> checkNickname(@RequestParam Stri public ResponseEntity> checkEmail(@RequestParam String userEmail) { boolean response = authService.isDuplicateEmail(userEmail); - String message = response ? "이미 사용 중인 닉네임입니다" : "사용 가능한 닉네임입니다"; + String message = response ? "이미 사용 중인 이메일입니다" : "사용 가능한 이메일입니다"; return ResponseEntity.ok(SuccessApiResponse.of(message)); } diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java b/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java index 6be852b..621e11e 100644 --- a/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java +++ b/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java @@ -36,17 +36,24 @@ public String setTokenUser(User user, HttpServletResponse response) { String redisKey = "refresh_token:" + user.getUserId(); redisTemplate.opsForValue().set(redisKey, refreshToken, 7, TimeUnit.DAYS); + log.info("Redis에 Refresh Token 저장 - Key: {}, 만료시간: 7일", redisKey); + + // 쿠키 설정 개선 Cookie refreshCookie = new Cookie("refreshToken", refreshToken); - refreshCookie.setHttpOnly(true); // XSS 공격 방지 - refreshCookie.setSecure(false); // HTTP에서만 전송 - refreshCookie.setPath("/"); // 모든 경로에서 접근 가능 - refreshCookie.setMaxAge(7 * 24 * 60 * 60); + refreshCookie.setHttpOnly(true); // XSS 공격 방지 + refreshCookie.setSecure(false); // 개발환경: false, 운영환경: true + refreshCookie.setPath("/"); // 모든 경로에서 접근 가능 + refreshCookie.setMaxAge(7 * 24 * 60 * 60); // 7일 - response.addCookie(refreshCookie); + // SameSite 속성을 포함한 쿠키 헤더 직접 설정 + response.setHeader("Set-Cookie", + String.format("%s=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Lax", + "refreshToken", refreshToken, 7 * 24 * 60 * 60)); - log.info("로그인 성공 - 사용자 ID: {}", user.getUserId()); + log.info("로그인 성공 - 사용자 ID: {}, Refresh Token 쿠키 설정 완료", user.getUserId()); return accessToken; } catch (Exception e) { + log.error("토큰 생성 중 오류 발생: ", e); throw UserDomainException.badRequest(UserErrorCode.LOGIN_FAILED.getMessage(), UserErrorCode.LOGIN_FAILED.getCode()); } @@ -56,10 +63,13 @@ public String setTokenUser(User user, HttpServletResponse response) { * */ public void deleteTokenUser(String authorization, String refreshToken, HttpServletResponse response) { try { + log.info("=== 로그아웃 처리 시작 ==="); + // Authorization 헤더에서 Access Token 추출 String accessToken = null; if (authorization != null && authorization.startsWith("Bearer ")) { accessToken = authorization.substring(7); + log.info("Access Token 추출됨"); } // Access Token 기본 유효성 검사 후 사용자 ID 추출 @@ -68,62 +78,111 @@ public void deleteTokenUser(String authorization, String refreshToken, HttpServl try { if (!jwtUtil.isTokenExpired(accessToken)) { userId = jwtUtil.extractUserId(accessToken); + log.info("Access Token에서 사용자 ID 추출: {}", userId); } } catch (Exception e) { log.warn("Access Token 파싱 실패: {}", e.getMessage()); } } + // Refresh Token에서도 사용자 ID 추출 시도 + if (userId == null && refreshToken != null) { + try { + if (!jwtUtil.isTokenExpired(refreshToken)) { + userId = jwtUtil.extractUserId(refreshToken); + log.info("Refresh Token에서 사용자 ID 추출: {}", userId); + } + } catch (Exception e) { + log.warn("Refresh Token 파싱 실패: {}", e.getMessage()); + } + } + // Redis에서 Refresh Token 삭제 if (userId != null) { String redisKey = "refresh_token:" + userId; - redisTemplate.delete(redisKey); - log.info("Redis에서 사용자 삭제 ID: {}", userId); + Boolean deleted = redisTemplate.delete(redisKey); + log.info("Redis에서 사용자 토큰 삭제 - ID: {}, 삭제 성공: {}", userId, deleted); } - // 4. HttpOnly 쿠키에서 Refresh Token 삭제 - if (refreshToken != null) { - Cookie deleteCookie = new Cookie("refreshToken", null); - deleteCookie.setHttpOnly(true); - deleteCookie.setSecure(true); - deleteCookie.setPath("/"); - deleteCookie.setMaxAge(0); // 즉시 만료 - response.addCookie(deleteCookie); - log.info("쿠키에서 Refresh Token 삭제"); - } + // HttpOnly 쿠키에서 Refresh Token 삭제 + Cookie deleteCookie = new Cookie("refreshToken", null); + deleteCookie.setHttpOnly(true); + deleteCookie.setSecure(false); // 개발환경에 맞게 설정 + deleteCookie.setPath("/"); + deleteCookie.setMaxAge(0); // 즉시 만료 + response.addCookie(deleteCookie); + + // 추가적으로 Set-Cookie 헤더로도 삭제 + response.setHeader("Set-Cookie", + "refreshToken=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"); + + log.info("쿠키에서 Refresh Token 삭제 완료"); + log.info("=== 로그아웃 처리 완료 ==="); + } catch (Exception e) { + log.error("로그아웃 처리 중 오류 발생: ", e); throw UserDomainException.internalServerError(UserErrorCode.SYSTEM_ERROR.getMessage(), UserErrorCode.SYSTEM_ERROR.getCode()); } } - /** AccessToken 갱신 + /** AccessToken 갱신 (개선된 버전) * */ public String refreshAccessToken(String refreshToken) { try { - if (refreshToken == null) { + log.info("=== Refresh Token 갱신 시작 ==="); + log.info("Refresh Token 존재 여부: {}", refreshToken != null ? "존재" : "null"); + + if (refreshToken == null || refreshToken.trim().isEmpty()) { + log.warn("Refresh Token이 null이거나 비어있음"); throw UserDomainException.badRequest("RefreshToken이 없습니다.", UserErrorCode.INVALID_DATA.getCode()); } + // 토큰 만료 확인 if (jwtUtil.isTokenExpired(refreshToken)) { + log.warn("만료된 Refresh Token"); throw UserDomainException.badRequest("만료된 RefreshToken입니다.", UserErrorCode.INVALID_DATA.getCode()); } - Integer userId = jwtUtil.extractUserId(refreshToken); + // 토큰에서 사용자 ID 추출 + Integer userId; + try { + userId = jwtUtil.extractUserId(refreshToken); + log.info("Refresh Token에서 사용자 ID 추출: {}", userId); + } catch (Exception e) { + log.error("Refresh Token 파싱 실패: ", e); + throw UserDomainException.badRequest("유효하지 않은 Refresh Token 형식입니다.", UserErrorCode.INVALID_DATA.getCode()); + } + // Redis에서 저장된 토큰 조회 String redisKey = "refresh_token:" + userId; String storedRefreshToken = redisTemplate.opsForValue().get(redisKey); - if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) { + log.info("Redis 조회 - Key: {}, 저장된 토큰: {}", redisKey, storedRefreshToken != null ? "존재" : "없음"); + + if (storedRefreshToken == null) { + log.warn("Redis에 저장된 Refresh Token이 없음 - 사용자 ID: {}", userId); + throw UserDomainException.badRequest("세션이 만료되었습니다. 다시 로그인해주세요.", UserErrorCode.INVALID_DATA.getCode()); + } + + if (!storedRefreshToken.equals(refreshToken)) { + log.warn("Refresh Token 불일치 - 사용자 ID: {}", userId); + log.debug("요청된 토큰 앞 10자리: {}", refreshToken.substring(0, Math.min(10, refreshToken.length()))); + log.debug("저장된 토큰 앞 10자리: {}", storedRefreshToken.substring(0, Math.min(10, storedRefreshToken.length()))); throw UserDomainException.badRequest("유효하지 않은 Refresh Token입니다.", UserErrorCode.INVALID_DATA.getCode()); } + // 새로운 Access Token 생성 String newAccessToken = jwtUtil.createAccessToken(userId, "USER"); log.info("Access Token 갱신 성공 - 사용자 ID: {}", userId); + log.info("=== Refresh Token 갱신 완료 ==="); return newAccessToken; + } catch (UserDomainException e) { + log.error("도메인 예외 발생: {}", e.getMessage()); throw e; } catch (Exception e) { + log.error("토큰 갱신 중 예상치 못한 오류 발생: ", e); throw UserDomainException.internalServerError("토큰 갱신 중 오류 발생!", UserErrorCode.SYSTEM_ERROR.getCode()); } } diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html index f3ca014..6735a0e 100644 --- a/src/main/resources/templates/auth/login.html +++ b/src/main/resources/templates/auth/login.html @@ -355,12 +355,6 @@

로그인

또는 - - - Google - Google로 로그인 - -