
핵심 기능


src/main/java/com/onandhome/
├── favorite/
│ ├── entity/
│ │ └── Favorite.java # 찜 엔티티
│ ├── dto/
│ │ └── FavoriteDTO.java # 찜 DTO
│ ├── FavoriteRepository.java # 찜 DB 접근
│ ├── FavoriteService.java # 찜 비즈니스 로직
│ └── FavoriteRestController.java # 찜 REST API
└── admin/adminProduct/
├── entity/Product.java # 상품 엔티티
└── ProductService.java # 인기 순위 로직
@Entity
@Getter
@Setter
@Builder
@Table(name = "favorite", uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "product_id"})
})
public class Favorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 찜한 사용자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
// 찜한 상품
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
// 찜한 시간
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@Getter
@Setter
@Builder
public class FavoriteDTO {
private Long id;
private Long userId;
private Long productId;
// 상품 정보
private String productName;
private String productCode;
private Integer price;
private Integer salePrice;
private String thumbnailImage;
private String category;
private Integer stock;
private LocalDateTime createdAt;
}
@Repository
public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
// 사용자의 모든 찜 목록 조회 (최신순)
List<Favorite> findByUserIdOrderByCreatedAtDesc(Long userId);
// 특정 사용자가 특정 상품을 찜했는지 확인
Optional<Favorite> findByUserIdAndProductId(Long userId, Long productId);
// 찜 여부 확인 (boolean)
boolean existsByUserIdAndProductId(Long userId, Long productId);
// 상품의 찜 개수 (인기도 지표)
long countByProductId(Long productId);
// 사용자의 찜 개수
long countByUserId(Long userId);
}
| 메서드 | 설명 | 활용 |
|---|---|---|
findByUserIdOrderByCreatedAtDesc | 사용자 찜 목록 | 마이페이지 |
findByUserIdAndProductId | 특정 찜 조회 | 토글 기능 |
existsByUserIdAndProductId | 찜 여부 확인 | 하트 아이콘 상태 |
countByProductId | 상품 찜 개수 | 인기도 측정 |
countByUserId | 사용자 찜 개수 | 찜 배지 표시 |
@Service
@RequiredArgsConstructor
public class FavoriteService {
private final FavoriteRepository favoriteRepository;
private final UserRepository userRepository;
private final ProductRepository productRepository;
/**
* 찜하기 토글 (있으면 삭제, 없으면 추가)
*/
@Transactional
public FavoriteDTO toggleFavorite(Long userId, Long productId) {
log.info("찜하기 토글 - userId: {}, productId: {}", userId, productId);
// 이미 찜한 상품인지 확인
Optional<Favorite> existingFavorite =
favoriteRepository.findByUserIdAndProductId(userId, productId);
if (existingFavorite.isPresent()) {
// 찜 취소
favoriteRepository.delete(existingFavorite.get());
log.info("찜하기 취소 완료");
return null; // 삭제 표시
} else {
// 찜 추가
return addFavorite(userId, productId);
}
}
/**
* 찜하기 추가
*/
@Transactional
public FavoriteDTO addFavorite(Long userId, Long productId) {
log.info("찜하기 추가 - userId: {}, productId: {}", userId, productId);
// 사용자 확인
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException(
"사용자를 찾을 수 없습니다."));
// 상품 확인
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException(
"상품을 찾을 수 없습니다."));
// 중복 체크
if (favoriteRepository.existsByUserIdAndProductId(userId, productId)) {
throw new IllegalStateException("이미 찜한 상품입니다.");
}
// 찜 생성 및 저장
Favorite favorite = Favorite.builder()
.user(user)
.product(product)
.build();
Favorite savedFavorite = favoriteRepository.save(favorite);
return convertToDTO(savedFavorite);
}
/**
* 사용자 찜 목록 조회
*/
@Transactional(readOnly = true)
public List<FavoriteDTO> getFavoritesByUserId(Long userId) {
List<Favorite> favorites =
favoriteRepository.findByUserIdOrderByCreatedAtDesc(userId);
return favorites.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
/**
* 찜 여부 확인
*/
@Transactional(readOnly = true)
public boolean isFavorite(Long userId, Long productId) {
return favoriteRepository.existsByUserIdAndProductId(userId, productId);
}
/**
* 상품의 찜 개수 조회 (인기도 지표)
*/
@Transactional(readOnly = true)
public long getFavoriteCountByProductId(Long productId) {
return favoriteRepository.countByProductId(productId);
}
/**
* Entity → DTO 변환
*/
private FavoriteDTO convertToDTO(Favorite favorite) {
Product product = favorite.getProduct();
return FavoriteDTO.builder()
.id(favorite.getId())
.userId(favorite.getUser().getId())
.productId(product.getId())
.productName(product.getName())
.productCode(product.getProductCode())
.price(product.getPrice())
.salePrice(product.getSalePrice())
.thumbnailImage(product.getThumbnailImage())
.category(product.getCategory())
.stock(product.getStock())
.createdAt(favorite.getCreatedAt())
.build();
}
}
@RestController
@RequestMapping("/api/favorites")
@RequiredArgsConstructor
public class FavoriteRestController {
private final FavoriteService favoriteService;
private final JWTUtil jwtUtil;
private final UserRepository userRepository;
/**
* JWT 토큰에서 사용자 정보 추출
*/
private User getUserFromToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new IllegalArgumentException("로그인이 필요합니다.");
}
String token = authHeader.substring(7);
Map<String, Object> claims = jwtUtil.validateToken(token);
String userId = (String) claims.get("userId");
return userRepository.findByUserId(userId)
.orElseThrow(() -> new IllegalArgumentException(
"사용자를 찾을 수 없습니다."));
}
/**
* 찜 목록 조회
* GET /api/favorites
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getFavorites(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
Map<String, Object> response = new HashMap<>();
try {
User user = getUserFromToken(authHeader);
List<FavoriteDTO> favorites =
favoriteService.getFavoritesByUserId(user.getId());
response.put("success", true);
response.put("data", favorites);
response.put("count", favorites.size());
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "찜 목록을 조회하는 중 오류가 발생했습니다.");
return ResponseEntity.status(500).body(response);
}
}
/**
* 찜하기 토글
* POST /api/favorites/toggle
*/
@PostMapping("/toggle")
public ResponseEntity<Map<String, Object>> toggleFavorite(
@RequestBody ToggleFavoriteRequest request,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
Map<String, Object> response = new HashMap<>();
try {
User user = getUserFromToken(authHeader);
// productId 검증
if (request.getProductId() == null || request.getProductId() <= 0) {
response.put("success", false);
response.put("message", "올바른 상품 ID를 입력하세요.");
return ResponseEntity.badRequest().body(response);
}
// 토글 실행
FavoriteDTO result = favoriteService.toggleFavorite(
user.getId(), request.getProductId());
response.put("success", true);
if (result == null) {
// 찜 취소
response.put("message", "찜하기가 취소되었습니다.");
response.put("isFavorite", false);
} else {
// 찜 추가
response.put("message", "찜하기에 추가되었습니다.");
response.put("isFavorite", true);
response.put("data", result);
}
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "찜하기 처리 중 오류가 발생했습니다.");
return ResponseEntity.status(500).body(response);
}
}
/**
* 찜 여부 확인
* GET /api/favorites/check/{productId}
*/
@GetMapping("/check/{productId}")
public ResponseEntity<Map<String, Object>> checkFavorite(
@PathVariable Long productId,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
Map<String, Object> response = new HashMap<>();
try {
// 비로그인 시 false 반환
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.put("success", true);
response.put("isFavorite", false);
return ResponseEntity.ok(response);
}
User user = getUserFromToken(authHeader);
boolean isFavorite = favoriteService.isFavorite(
user.getId(), productId);
response.put("success", true);
response.put("isFavorite", isFavorite);
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", true);
response.put("isFavorite", false);
return ResponseEntity.ok(response);
}
}
/**
* 상품의 찜 개수 조회
* GET /api/favorites/count/product/{productId}
*/
@GetMapping("/count/product/{productId}")
public ResponseEntity<Map<String, Object>> getFavoriteCountByProduct(
@PathVariable Long productId) {
Map<String, Object> response = new HashMap<>();
try {
long count = favoriteService.getFavoriteCountByProductId(productId);
response.put("success", true);
response.put("count", count);
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "찜 개수를 조회하는 중 오류가 발생했습니다.");
return ResponseEntity.status(500).body(response);
}
}
/**
* 사용자의 찜 개수 조회
* GET /api/favorites/count
*/
@GetMapping("/count")
public ResponseEntity<Map<String, Object>> getFavoriteCount(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
Map<String, Object> response = new HashMap<>();
try {
// 비로그인 시 0 반환
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.put("success", true);
response.put("count", 0);
return ResponseEntity.ok(response);
}
User user = getUserFromToken(authHeader);
long count = favoriteService.getFavoriteCountByUserId(user.getId());
response.put("success", true);
response.put("count", count);
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", true);
response.put("count", 0);
return ResponseEntity.ok(response);
}
}
/**
* 요청 DTO
*/
@Getter
@Setter
public static class ToggleFavoriteRequest {
private Long productId;
}
}
| Method | Endpoint | 설명 | 인증 |
|---|---|---|---|
| GET | /api/favorites | 찜 목록 조회 | 필수 |
| POST | /api/favorites/toggle | 찜 토글 | 필수 |
| GET | /api/favorites/check/{id} | 찜 여부 확인 | 선택 |
| GET | /api/favorites/count/product/{id} | 상품 찜 개수 | 불필요 |
| GET | /api/favorites/count | 사용자 찜 개수 | 선택 |
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final FavoriteRepository favoriteRepository;
/**
* 인기 상품 조회 (찜 개수 기준)
*/
@Transactional(readOnly = true)
public List<ProductDTO> getPopularProducts(int limit) {
// 1. 모든 상품 조회
List<Product> products = productRepository.findAll();
// 2. 각 상품의 찜 개수 조회 및 DTO 변환
List<ProductDTO> productDTOs = products.stream()
.map(product -> {
ProductDTO dto = ProductDTO.fromEntity(product);
// 찜 개수 조회
long favoriteCount = favoriteRepository
.countByProductId(product.getId());
dto.setFavoriteCount(favoriteCount);
return dto;
})
.collect(Collectors.toList());
// 3. 찜 개수로 정렬 (내림차순)
productDTOs.sort((p1, p2) ->
Long.compare(p2.getFavoriteCount(), p1.getFavoriteCount()));
// 4. 상위 N개만 반환
return productDTOs.stream()
.limit(limit)
.collect(Collectors.toList());
}
/**
* 카테고리별 인기 상품
*/
@Transactional(readOnly = true)
public List<ProductDTO> getPopularProductsByCategory(
String category, int limit) {
// 카테고리 필터링 후 인기순 정렬
List<Product> products = productRepository.findByCategory(category);
List<ProductDTO> productDTOs = products.stream()
.map(product -> {
ProductDTO dto = ProductDTO.fromEntity(product);
long favoriteCount = favoriteRepository
.countByProductId(product.getId());
dto.setFavoriteCount(favoriteCount);
return dto;
})
.sorted((p1, p2) ->
Long.compare(p2.getFavoriteCount(), p1.getFavoriteCount()))
.limit(limit)
.collect(Collectors.toList());
return productDTOs;
}
}
@Getter
@Setter
@Builder
public class ProductDTO {
private Long id;
private String name;
private Integer price;
private Integer salePrice;
// ... 기타 필드
// 찜 개수 (인기도 지표)
private Long favoriteCount;
}
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductRestController {
private final ProductService productService;
/**
* 인기 상품 TOP 10
* GET /api/products/popular
*/
@GetMapping("/popular")
public ResponseEntity<Map<String, Object>> getPopularProducts(
@RequestParam(defaultValue = "10") int limit) {
Map<String, Object> response = new HashMap<>();
try {
List<ProductDTO> products = productService.getPopularProducts(limit);
response.put("success", true);
response.put("data", products);
response.put("count", products.size());
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "인기 상품 조회 중 오류가 발생했습니다.");
return ResponseEntity.status(500).body(response);
}
}
/**
* 카테고리별 인기 상품
* GET /api/products/popular/{category}
*/
@GetMapping("/popular/{category}")
public ResponseEntity<Map<String, Object>> getPopularProductsByCategory(
@PathVariable String category,
@RequestParam(defaultValue = "10") int limit) {
Map<String, Object> response = new HashMap<>();
try {
List<ProductDTO> products = productService
.getPopularProductsByCategory(category, limit);
response.put("success", true);
response.put("data", products);
response.put("category", category);
response.put("count", products.size());
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", "카테고리별 인기 상품 조회 중 오류가 발생했습니다.");
return ResponseEntity.status(500).body(response);
}
}
}


POST http://localhost:8080/api/favorites/toggle
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Content-Type: application/json
{
"productId": 1
}
응답 (찜 추가):
{
"success": true,
"message": "찜하기에 추가되었습니다.",
"isFavorite": true,
"data": {
"id": 1,
"userId": 10,
"productId": 1,
"productName": "상품명",
"price": 10000,
"salePrice": 8000
}
}
응답 (찜 취소):
{
"success": true,
"message": "찜하기가 취소되었습니다.",
"isFavorite": false
}
GET http://localhost:8080/api/favorites
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
응답:
{
"success": true,
"data": [
{
"id": 1,
"productId": 100,
"productName": "노트북",
"price": 1500000,
"salePrice": 1200000,
"thumbnailImage": "laptop.jpg",
"createdAt": "2025-01-15T10:30:00"
},
{
"id": 2,
"productId": 200,
"productName": "마우스",
"price": 50000,
"salePrice": 40000,
"thumbnailImage": "mouse.jpg",
"createdAt": "2025-01-14T15:20:00"
}
],
"count": 2
}
GET http://localhost:8080/api/products/popular?limit=5
응답:
{
"success": true,
"data": [
{
"id": 1,
"name": "노트북 A",
"price": 1500000,
"favoriteCount": 150
},
{
"id": 2,
"name": "마우스 B",
"price": 50000,
"favoriteCount": 120
},
{
"id": 3,
"name": "키보드 C",
"price": 80000,
"favoriteCount": 95
}
],
"count": 3
}
| 기능 | 설명 | API |
|---|---|---|
| 찜 추가 | 상품을 찜 목록에 추가 | POST /api/favorites/toggle |
| 찜 취소 | 찜 목록에서 제거 | POST /api/favorites/toggle |
| 찜 목록 | 사용자의 모든 찜 | GET /api/favorites |
| 찜 여부 | 특정 상품 찜 확인 | GET /api/favorites/check/{id} |
| 찜 개수 | 상품의 인기도 | GET /api/favorites/count/product/{id} |
| 인기 순위 | 찜 많은 순 정렬 | GET /api/products/popular |