Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 119 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 적용
- **예외 처리**: 전역 예외 처리기를 통한 일관된 오류 응답
Original file line number Diff line number Diff line change
@@ -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.*;
Expand All @@ -19,19 +22,27 @@
@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
* @param response 쿠키 저장
*/
@PostMapping("/login")
public ResponseEntity<SuccessApiResponse<Map<String, Object>>> 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);

Expand All @@ -41,6 +52,8 @@ public ResponseEntity<SuccessApiResponse<Map<String, Object>>> login(@RequestBod
data.put("accessToken", accessToken);
data.put("user", userDTO);

log.info("로그인 성공 - 사용자 ID: {}, 닉네임: {}", user.getUserId(), user.getUserNickname());

return ResponseEntity.ok(SuccessApiResponse.of("로그인 완료!", data));
}

Expand All @@ -53,22 +66,57 @@ public ResponseEntity<SuccessApiResponse<Map<String, Object>>> login(@RequestBod
@PostMapping("/logout")
public ResponseEntity<SuccessApiResponse<Void>> 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<SuccessApiResponse<Map<String, String>>> refreshToken(@CookieValue(value = "refreshToken", required = false) String refreshToken) {
public ResponseEntity<SuccessApiResponse<Map<String, Object>>> 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<String, String> data = new HashMap<>();
// 사용자 정보도 함께 반환하도록 수정
Integer userId = jwtUtil.extractUserId(newAccessToken);

// 기본 사용자 정보 (실제 구현에서는 UserService에서 조회)
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("userId", userId);
userInfo.put("nickname", "사용자"); // 기본값, 실제로는 DB에서 조회

Map<String, Object> 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));
}
Expand All @@ -78,6 +126,8 @@ public ResponseEntity<SuccessApiResponse<Map<String, String>>> refreshToken(@Coo
*/
@PostMapping("/signup")
public ResponseEntity<SuccessApiResponse<Void>> signup(@RequestBody SignupRequestDTO request) {
log.info("회원가입 요청 - 이메일: {}", request.getEmail());

authService.signup(request);

return ResponseEntity.status(HttpStatus.CREATED)
Expand All @@ -89,7 +139,7 @@ public ResponseEntity<SuccessApiResponse<Void>> signup(@RequestBody SignupReques
public ResponseEntity<SuccessApiResponse<Void>> checkNickname(@RequestParam String nickname) {
boolean response = authService.isDuplicateNickname(nickname);

String message = response ? "이미 사용 중인 이메일입니다" : "사용 가능한 이메일입니다";
String message = response ? "이미 사용 중인 닉네임입니다" : "사용 가능한 닉네임입니다";
return ResponseEntity.ok(SuccessApiResponse.of(message));
}

Expand All @@ -98,7 +148,7 @@ public ResponseEntity<SuccessApiResponse<Void>> checkNickname(@RequestParam Stri
public ResponseEntity<SuccessApiResponse<Void>> checkEmail(@RequestParam String userEmail) {
boolean response = authService.isDuplicateEmail(userEmail);

String message = response ? "이미 사용 중인 닉네임입니다" : "사용 가능한 닉네임입니다";
String message = response ? "이미 사용 중인 이메일입니다" : "사용 가능한 이메일입니다";
return ResponseEntity.ok(SuccessApiResponse.of(message));
}

Expand Down
Loading