Spring Boot - 찜하기 & 인기 순위 구현

송진우·2026년 1월 6일
post-thumbnail

찜하기 기능

  • 찜하기는 사용자가 관심 있는 상품을 나중에 다시 볼 수 있도록 저장하는 기능
  • 찜 개수를 기반으로 인기 상품을 파악하고 순위를 매깁니다.

핵심 기능

  • 찜하기: 상품을 찜 목록에 추가/제거
  • 인기 상품: 찜을 많이 받은 상품
  • 정렬 기준: 찜 개수 내림차순

찜하기 전체 흐름

1. 찜하기 추가/삭제 (토글)


2. 인기 순위 조회


📁 프로젝트 구조

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            # 인기 순위 로직

1. Entity

Favorite.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();
    }
}

2. DTO

FavoriteDTO.java

@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;
}

3. Repository

FavoriteRepository.java

@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사용자 찜 개수찜 배지 표시

4. Service

FavoriteService.java

@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();
    }
}

5. REST API

FavoriteRestController.java

@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;
    }
}

주요 API

MethodEndpoint설명인증
GET/api/favorites찜 목록 조회필수
POST/api/favorites/toggle찜 토글필수
GET/api/favorites/check/{id}찜 여부 확인선택
GET/api/favorites/count/product/{id}상품 찜 개수불필요
GET/api/favorites/count사용자 찜 개수선택

6. 인기 순위 구현

ProductService.java

@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;
    }
}

ProductDTO에 찜 개수 필드 추가

@Getter
@Setter
@Builder
public class ProductDTO {
    private Long id;
    private String name;
    private Integer price;
    private Integer salePrice;
    // ... 기타 필드
    
    // 찜 개수 (인기도 지표)
    private Long favoriteCount;
}

7. REST API

ProductRestController.java

@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);
        }
    }
}

실제 구현 화면

  • 상품 카드에 찜(하트) 아이콘을 표시하여 사용자가 직관적으로 자신이 찜한 상품을 확인할 수 있습니다.

  • 찜 개수를 기준으로 상품을 내림차순으로 정렬하여 인기 상품이 상단에 노출됩니다.

8. 테스트

API 테스트 예시

1. 찜하기 토글

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
}

2. 찜 목록 조회

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
}

3. 인기 상품 조회

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

0개의 댓글