hyeonga_code

Project_13_카테고리 기능 구현 Cache 적용하기 본문

Project_HYEONGARL

Project_13_카테고리 기능 구현 Cache 적용하기

hyeonga 2024. 6. 21. 05:59
반응형

 

Cache

한 번 처리한 데이터를 임시로 저장소에 저장한다.

임시 데이터를 동일하거나 유사한 요청이 오는 경우 저장소에서 바로 읽어와 성능/응답 속도를 향상하기 위한 기술이다.

 

고려 사항.

별도의 연산 수행 없이 동일한 응답 값을 전달할 수 있어야 한다.

Cache를 적용할 기준을 결정해야 한다.

유지 기간을 결정해야 한다.

저장 공간을 설계해야 한다.

 

카테고리의 경우 변경이 많지 않은 부분이라 적용해보려고 한다.

 

캐싱 전략 : 웹 서비스 환경에서 시스템 성능 향상을 기대할 수 있는 기술

메모리를 사용하므로 데이터베이스보다 빠르게 응답을 할 수 있다.

RAM의 용량은 16-32GB정도로 용량 부족에 대한 고민이 필요하다.

 


Cache Hit
 : 캐시스토어에 데이터가 있는 경우 바로 가져와 빠르다.

Cache Miss
: 캐시스토어에 데이터가 없는 경우 DB에서 가져와 느리다.
(Cache를 확인 > App 에 없음을 알림 > DB에 확인 > Cache에 저장)

 

 

Cache의 데이터 정합성 문제

데이터 정합성은 하나의 데이터가 캐시와 데이터베이스에서 같은 데이터임에도 정보값이 다른 현상을 의미한다.

하여 캐시 읽기 전략과 캐시 쓰기 전략을 통해 불일치 문제를 극복할 수 있어야 한다.

 

 

Read Cache Strategy

: 캐시 읽기 전략 

 

1. Look Aside 패턴 = Cache Aside

데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략으로 캐시에 데이터가 없는 경우 DB에서 조회한다.

반복적인 읽기가 많은 호출에 적합하다.

캐시와 DB가 분리되어 원하는 데이터만 별도로 구성하여 캐시에 저장한다.

캐시 장애 대비 구성이 되어 있다.(만일 Redis가 다운되어도 DB에서 데이터를 가져올 수 있어 서비스 자체에 문제는 없다.)

대신 캐시가 붙은 connection이 많은 경우 redis가 다운된 순간 DB에 몰려 부하가 발생한다.

 

1) Cache Store에 검색하는 데이터가 있는지 확인한다.(Cache hit)

2) Cache Store에 없는 경우 DB에서 데이터를 조회한다.(Cache miss)

3) DB에서 조회한 데이터를 Cache Store에 업데이트한다.

 

애플리케이션에서 캐싱을 이용하는 경우 일반적으로 사용되는 기본적인 캐시 전략이다.

정합성 유지 문제가 발생할 수 있다.

초기 조회시 무조건 DB를 호출하므로 단건 호출 빈도가 높은 서비스에 적합하지 않다.

 

이런 경우 Cache Warming 으로 데이터를 캐시에 미리 넣는 작업을 한다.

미리 cache로 DB의 데이터를 넣지 않는 경우 서비스 초기에 트래픽 급증시 대량의 cache miss가 발생하여 데이터베이스 부하가 급증할 수 있다.

일정 시간이 지나면 expire되는데 이 때 Thundering Head가 발생할 수 있으므로 캐시의 TTL을 조정할 필요가 있다.

 

 

2. Read Through 패턴

캐시에서만 데이터를 읽어오는 전략이다.

데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임하는 방식으로 전체적으로 속도가 느리다.

데이터 조회를 전적으로 캐시에 의지하여 redis가 다운되는 경우 서비스 이용에 문제가 발생할 수 있다.

데이터베이스와 데이터 동기화가 항상 이루어져 데이터 정합성 문제에서 벗어날 수 있다.

 

1) Cache Store에 데이터가 있는지 확인한다.(Cache hit)

2) Cache Store에 없는 경우 캐시에서 데이터베이스에 데이터를 조회하여 자체 업데이트(Cache miss)

3) Cache에서 데이터를 가져온다.

 

Read Through 방식은 Cache Aside 방식과 비슷하나 Cache Store에 저장하는 주체가 Server인지, Data Store인지의 차이가 있다.

 

 

 

Write Cache Strategy

: 캐시 쓰기 전략 

1. Write Back 패턴 = Write Behind

캐시와 DB 동기화를 비동기하므로 동기화 과정이 생략된다.

데이터를 저장할 때 DB에 바로 쿼리하지 않고 일정 주기 배치 작업을 통해 DB에 반영된다.

쓰기 쿼리 회수 비용과 부하를 줄일 수 있다.

Write가 빈번하며 Read를 하는데 많은 양의 Resource가 소모되는 서비스에 적합하다.

데이터 정합성을 확보할 수 있다.

자주 사용되지 않는 불필요한 리소스를 저장하고 캐시에서 오류가 발생하는 경우 데이터를 영구적으로 손실한다.

 

1) 모든 데이터를 Cache Store에 저장한다.

2) 일정 시간이 지나면 DB에 저장한다.

 

 

2. Write Through 패턴

데이터베이스와 Cache에 동시에 데이터를 저장

데이터를 저장할 때 먼저 캐시에 저장하고 바로 DB에 저장한다.

DB 동기화 작업을 캐시에 위임한다.

DB와 캐시가 항상 동기화 되어 있어 캐시의 데이터는 항상 최신 상태로 유지된다.

캐시와 백업 저장소에 업데이트를 같이 하여 데이터 일관성을 유지할 수 있어 안정적이다.

데이터 유실이 발생하면 안 되는 상황에 적합하다.

자주 사용되지 않는 불필요한 리소스를 저장한다.

매 요청마다 두 번의 Write가 발생하여 빈번한 생성, 수정이 발생하는 서비스에서는 성능 이슈가 발생한다.

 

1) DB에 저장할 데이터가 있는 경우 Cache Store에 저장한다.

2) Cache Store에서 DB에 저장한다.

 

 

3. Write Around 패턴

Write Through 패턴보다 훨씬 빠르다

모든 데이터를 DB에 저자하고 캐시를 갱신하지 않는다.

Cache miss가 발생하는 경우에만 DB와 캐시에 데이터를 저장한다.

캐시와 DB 내의 데이터가 다를 수 있어 데이터 불일치가 발생할 수 있다.

 

 

Read Through + Write Through 조합

캐시에 데이터가 없는 경우 DB에서 바로 업데이트

데이터를 사용할 때 항상 캐시에서 먼저 사용하므로 최신 캐시 데이터를 보장할 수 있다.

데이터를 저장할 때 캐시에서 DB로 보내 데이터 정합성이 보장된다.

 

 

캐시는 애플리케이션의 여러 인스턴스에서 공유하도록 설계된다.

각 애플리케이션 인스턴스가 캐시에서 데이터를 읽고 수정할 수 있다.

따라서 어느 한 애플리케이션이 캐시에 보유하는 데이터를 수정하는 경우 애플리케이션의 인스턴스가 만드는 업데이트가 다른 인스턴스가 만든 변경을 덮어쓰지 않도록 해야 한다.

데이터 정합성 문제가 발생하기 때문이다.

 

1. 캐시 데이터를 변경하기 직전 데이터가 검색된 이후 변경되지 않았는지 일일히 확인하는 방법

변경되지 않았다면 즉시 업데이트하고 변경되었다면 업데이트 여부를 애플리케이션 레벨에서 결정하도록 수정해야 한다.

이와 같은 방식은 업데이트가 드물고 충돌이 발생하지 않는 상황에 적용하기 용이하다.

 

2. 캐시 데이터를 업데이트하기 전에 Lock을 잡는 방법

조회성 업무를 처리하는 서비스에 Lock으로 인한 대기 현상이 발생한다.

이 방식은 데이터의 사이즈가 작아 빠르게 업데이트가 가능한 업무와 빈번한 업데이트가 발생하는 상황에 적용하기 용이하다.

 

 

[REDIS] 📚 캐시(Cache) 설계 전략 지침 💯 총정리

Redis - 캐시(Cache) 전략 캐싱 전략은 웹 서비스 환경에서 시스템 성능 향상을 기대할 수 있는 중요한 기술이다. 일반적으로 캐시(cache)는 메모리(RAM)를 사용하기 때문에 데이터베이스 보다 훨씬 빠

inpa.tistory.com

 

 

Spring Boot Cache Annotation

@EnableCaching

캐시 활성화를 위한 어노테이션

CacheManager()를 구현한 @Configuration에 선언하여 사용한다.

value: 캐시 매니저 이름 설정

order: 캐시 관련 빈의 우선순위 지정

keyGenerator: 키 생성기 지정

cacheManager: 캐시 매니저 지정

cacheResolver: 캐시 리졸버 지정

proxyTargetClass: CGLIB/JDK Dynamic Proxy 결정

 

@CacheConfig

캐시 정보를 클래스 단위로 사용하고 관리하기 위한 어노테이션

 

@Cacheable **

캐시 정보를 메모리 상에 저장하거나 조회해오는 기능을 수행하는 어노테이션

캐시 조회, 저장 기능

캐시가 존재하는 경우 메소드 호출 전 실행

캐시가 존재하지 않는 경우 메소드 호출 후 실행

 

@CachePut **

캐시 정보를 메모리상에 저장하며 존재시 갱신하는 기능을 수행하는 어노테이션

캐시 존재 여부와 상관없이 항상 저장 혹은 갱신을 수행

캐시 저장 기능

캐시가 존재하는 경우 메소드 호출 후 실행

캐시가 존재하지 않는 경우 메소드 호출 후 실행

 

 

@CacheEvict **

캐시 정보를 메모리상에 삭제하는 기능을 수행하는 어노테이션

캐시 삭제 기능

beforeInvocation 속성 값이 true인 경우 메소드 호출 전 실행
beforeInvocation 속성 값이 false인 경우 메소드 호출 후 실행

 

@Caching

여러 개의 캐시 어노테이션을 함께 사용할 때 사용하는 어노테이션

 

 

메소드 호출 전에 실행하는 경우 DB로부터 데이터를 처리하는 부분에 대해 줄어든다.

메도스 호출 후에 실행하는 경우 DB로부터 데이터를 처리해 가져오는 부분이 늘어난다.

 

beforeInvocation 속성

캐시의 데이터를 삭제하는데 메소드 호출 전/후에 발생할지에 대해 설정하는 속성

기본 값은 false이며 메소드 실행 전 캐시에서 데이터를 삭제하여 예외가 발생하는 경우 캐시에서 데이터가 삭제되지 않는다.

예외가 발생할 가능성이 있는 경우 true를 사용하지 않는다.

 

 


CategoryController

> 작성  코드

package com.hyeongarl.controller;

import com.hyeongarl.config.Logger;
import com.hyeongarl.dto.CategoryRequestDto;
import com.hyeongarl.dto.CategoryResponseDto;
import com.hyeongarl.service.CategoryService;
import com.hyeongarl.service.TokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/category")
public class CategoryController {
    private final CategoryService categoryService;
    private final TokenService tokenService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public CategoryResponseDto saveCategory(@RequestBody CategoryRequestDto categoryRequest) {
        Logger.logging("saveCategory");
        return CategoryResponseDto.fromEntity(categoryService.save(categoryRequest.toEntity(), tokenService.getUserId()));
    }

    @GetMapping
    public CategoryResponseDto getCategory() {
        Logger.logging("getCategory");
        return CategoryResponseDto.fromEntity(categoryService.getCategory(tokenService.getUserId()));
    }

    @PutMapping
    public CategoryResponseDto updateCategory(@RequestBody CategoryRequestDto categoryRequest) {
        Logger.logging("updateCategory");
        return CategoryResponseDto.fromEntity(categoryService.updateCategory(categoryRequest.toEntity(), tokenService.getUserId()));
    }

    // 사용자 탈퇴시 전체 삭제
    @DeleteMapping("/{categoryId}")
    public ResponseEntity<String> deleteCategory(@PathVariable Long categoryId) {
        Logger.logging("deleteCategory");
        categoryService.deleteCategory(categoryId, tokenService.getUserId());
        return ResponseEntity.ok("삭제완료");
    }
}

 

 


CategoryService

코드를 작성하고 추후에 어떤 전략으로 작성한건지 다시 확인하는 과정에서 불필요한 코드를 발견했다.


Look Aside +  Write Through 조합

애플리케이션이 직접 캐시를 관리하고 데이터 업데이트 시 캐시와 데이터베이스를 동시에 업데이트한다.

Read Through + Write Through 조합

캐시 시스템이 데이터베이스와 통합되어 있어 애플리케이션이 캐시를 조회하면 캐시가 자동으로 데이터베이스를 조회한다.

데이터 업데이트 시 캐시와 데이터베이스를 동시에 업데이트한다.

 

현재 작성한 코드는 Look Aside +  Write Through 조합이다.

@Cacheable 어노테이션을 사용하는 경우 캐시를 조회하고 캐시에 데이터가 없는 경우 데이터베이스에서 조회한 후 캐시에 저장되므로 checkCache() 로직이 필요가 없다.

@CachePut 어노테이션을 사용하면 저장되는 데이터를 캐시에 업데이트 한다.

@CacheEvict 어노테이션을 사용하면 캐시에서 데이터를 제거한다.

 

기존의 다른 코드들은 HTTP 헤더의 token 정보를 통해 userId를 추출하는 로직을 Service에서 작성했는데, Cache에 저장하는 key를 userId로 지정하기 위해 Controller에서 파라미터로 넘겨주는 로직으로 변경했다.

 

> 기존 코드

더보기
package com.hyeongarl.service;

import com.hyeongarl.config.Logger;
import com.hyeongarl.entity.Category;
import com.hyeongarl.error.CategoryNotFoundException;
import com.hyeongarl.repository.CategoryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class CategoryService {
    private final CategoryRepository categoryRepository;
    private final CacheManager cacheManager;

    // Look Aside : 캐시를 먼저 조회하고 데이터 없는 경우 DB에서 로드후 캐시에 저장
    private Category checkCache(Long userId) {
        Logger.servicelogging("checkCache");

        Cache cache = cacheManager.getCache("category");
        if(cache != null) {
            Cache.ValueWrapper valueWrapper = cache.get(userId);
            if(valueWrapper != null) {
                log.info("valueWrapper is not null");
                return (Category) valueWrapper.get();
            }
            Category category = categoryRepository.findByUserId(userId)
                    .orElseThrow(CategoryNotFoundException::new);
            cache.put("category", category);
            return category;
        }
        log.info("cache is null");
        throw new CategoryNotFoundException();
    }


    @Cacheable(value = "category", key = "#userId")
    public Category getCategory(Long userId) {
        Logger.servicelogging("getCategoryTree");

        return checkCache(userId);
    }

    // Write Through: 데이터 업데이트시 캐시와 데이터베이스를 동시에 업데이트
    @CachePut(value = "category", key = "#userId")
    public Category save(Category category, Long userId) {
        Logger.servicelogging("save");
        category.setUserId(userId);
        return categoryRepository.save(category);
    }

    @CachePut(value = "category", key = "#userId")
    public Category updateCategory(Category category, Long userId) {
        Logger.servicelogging("save");
        Category existCategory = checkCache(userId);
        existCategory.setCategoryTree(category.getCategoryTree() != null ?
                category.getCategoryTree() : existCategory.getCategoryTree());
        return categoryRepository.save(existCategory);
    }

    @CacheEvict(value = "category", key = "#userId")
    public void deleteCategory(Long categoryId, Long userId) {
        Logger.servicelogging("save");
        Category existCategory = checkCache(userId);
        categoryRepository.delete(existCategory);
    }

}

 

> 작성  코드

package com.hyeongarl.service;

import com.hyeongarl.config.Logger;
import com.hyeongarl.entity.Category;
import com.hyeongarl.error.CategoryNotFoundException;
import com.hyeongarl.repository.CategoryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class CategoryService {
    private final CategoryRepository categoryRepository;
    private final CacheManager cacheManager;

    // Look Aside : 캐시를 먼저 조회하고 데이터 없는 경우 DB에서 로드후 캐시에 저장
    @Cacheable(value = "category", key = "#userId")
    public Category getCategory(Long userId) {
        Logger.servicelogging("getCategoryTree");

        return categoryRepository.findByUserId(userId)
                .orElseThrow(CategoryNotFoundException::new);
    }

    // Write Through: 데이터 업데이트시 캐시와 데이터베이스를 동시에 업데이트
    @CachePut(value = "category", key = "#userId")
    public Category save(Category category, Long userId) {
        Logger.servicelogging("save");
        category.setUserId(userId);
        return categoryRepository.save(category);
    }

    @CachePut(value = "category", key = "#userId")
    public Category updateCategory(Category category, Long userId) {
        Logger.servicelogging("save");
        Category existCategory = categoryRepository.findByUserId(userId)
                .orElseThrow(CategoryNotFoundException::new);
        existCategory.setCategoryTree(category.getCategoryTree() != null ?
                category.getCategoryTree() : existCategory.getCategoryTree());
        return categoryRepository.save(existCategory);
    }

    @CacheEvict(value = "category", key = "#userId")
    public void deleteCategory(Long categoryId, Long userId) {
        Logger.servicelogging("save");
        Category existCategory = categoryRepository.findByUserId(userId)
                .orElseThrow(CategoryNotFoundException::new);
        categoryRepository.delete(existCategory);
    }
}

 

 

 

 


CategoryRepository

> 작성  코드

package com.hyeongarl.repository;

import com.hyeongarl.entity.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
    Optional<Category> findByUserId(Long userId);
}

 

 


Category

기존의 카테고리 엔티티는 카테고리를 개별로 저장하는 로직으로 작성했었는데 트리 자체를 하나의 컬럼으로 저장하는 방식을 제안받아 수정했다.

 

> 작성  코드

package com.hyeongarl.entity;

import com.hyeongarl.util.TreeConverter;
import jakarta.persistence.*;
import lombok.*;

import java.util.Map;

@Entity
@Table(name="category")
@Builder
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Category {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long categoryId;

    @Column(name = "user_id")
    private Long userId;

    @Convert(converter = TreeConverter.class)
    @Column(name = "category_tree", columnDefinition = "TEXT")
    private Map<String, Object> categoryTree;
}

 

 

 


CategoryRequestDto

> 작성  코드

package com.hyeongarl.dto;

import com.hyeongarl.entity.Category;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Map;

@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CategoryRequestDto {
    private Map<String, Object> categoryTree;

    public Category toEntity() {
        return Category.builder()
                .categoryTree(categoryTree)
                .build();
    }
}

 

 


CategoryResponseDto

> 작성  코드

package com.hyeongarl.dto;

import com.hyeongarl.entity.Category;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Map;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CategoryResponseDto {
    private Long categoryId;
    private Map<String, Object> categoryTree;

    public static CategoryResponseDto fromEntity(Category category) {
        return CategoryResponseDto.builder()
                .categoryId(category.getCategoryId())
                .categoryTree(category.getCategoryTree())
                .build();
    }
}

 

 

 


CategoryControllerTest

> 작성  코드

package com.hyeongarl.controller;

import com.hyeongarl.dto.CategoryRequestDto;
import com.hyeongarl.dto.CategoryResponseDto;
import com.hyeongarl.entity.User;
import com.hyeongarl.repository.UserRepository;
import com.hyeongarl.service.CategoryService;
import com.hyeongarl.service.TokenService;
import com.hyeongarl.service.UserService;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.*;
import org.springframework.test.context.ActiveProfiles;

import java.time.LocalDateTime;
import java.util.*;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class CategoryControllerTest {

    HttpHeaders headers;

    @Autowired
    private TestRestTemplate restTemplate;
    private HttpEntity<CategoryRequestDto> requestEntity;

    private CategoryResponseDto categoryResponse;

    @LocalServerPort
    private int port;
    @Autowired
    private CategoryService categoryService;
    @Autowired
    private TokenService tokenService;
    @Autowired
    private UserService userService;
    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        User testUser = User.builder()
                .userId(1L)
                .userEmail("testUser44@email.com")
                .password("testUserPassword")
                .userRegdate(LocalDateTime.now())
                .build();

        if(userRepository.findByUserEmail(testUser.getUserEmail()).isEmpty()) {
            userService.save(testUser);
        }
        String token = tokenService.login(testUser);
        headers = new HttpHeaders();
        headers.set("token", token);

        Map<String, Object> categoryTree = createSampleCategoryTree("root");

        CategoryRequestDto categoryRequest = CategoryRequestDto.builder()
                .categoryTree(categoryTree)
                .build();

        categoryResponse = CategoryResponseDto.builder()
                .categoryId(1L)
                .categoryTree(categoryTree)
                .build();

        requestEntity = new HttpEntity<>(categoryRequest, headers);
    }

    @Test
    @DisplayName("testSaveCategory")
    @Order(1)
    void testSaveCategory() {
        ResponseEntity<CategoryResponseDto> responseEntity
                = restTemplate.exchange("/category", HttpMethod.POST, requestEntity, CategoryResponseDto.class);

        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        CategoryResponseDto responseBody = responseEntity.getBody();
        assertThat(responseBody).isNotNull();
    }

    @Test
    @DisplayName("testGetCategory")
    @Order(2)
    void testGetCategory() {
        ResponseEntity<CategoryResponseDto> responseEntity
                = restTemplate.exchange("/category", HttpMethod.GET, requestEntity, CategoryResponseDto.class);

        assertThat(responseEntity).isNotNull();

        CategoryResponseDto responseBody = responseEntity.getBody();
        if (responseBody != null) {
            assertEquals(categoryResponse.getCategoryTree(), responseBody.getCategoryTree());
        }
    }

    @Test
    @DisplayName("testUpdateCategory")
    @Order(3)
    void testUpdateCategory() {
        String url = "http://localhost:" + port + "/category";
        Map<String, Object> updateTree = createSampleCategoryTree("UpdateRoot");
        CategoryRequestDto updateRequest = CategoryRequestDto.builder()
                .categoryTree(updateTree)
                .build();

        HttpEntity<CategoryRequestDto> updateEntity = new HttpEntity<>(updateRequest, headers);

        ResponseEntity<CategoryResponseDto> responseEntity
                = restTemplate.exchange(url, HttpMethod.PUT, updateEntity, CategoryResponseDto.class);

        assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
        assertThat(responseEntity).isNotNull();

        CategoryResponseDto responseBody = responseEntity.getBody();
        assertEquals(updateTree, responseBody.getCategoryTree());
    }

    @Test
    @DisplayName("testDeleteCategory")
    @Order(4)
    void testDeleteCategory() {
        String url = "http://localhost:" + port + "/category/1";

        requestEntity = new HttpEntity<>(null, headers);
        ResponseEntity<String> responseEntity
                = restTemplate.exchange(url, HttpMethod.DELETE, requestEntity, String.class);

        assertEquals("삭제완료", responseEntity.getBody());
    }

    private Map<String, Object> createSampleCategoryTree(String rootName) {
        Map<String, Object> rootNode = new HashMap<>();
        rootNode.put("name", rootName);

        Map<String, Object> childNode1 = new HashMap<>();
        childNode1.put("name", "child1");

        Map<String, Object> childNode2 = new HashMap<>();
        childNode2.put("name", "child2");

        List<Map<String, Object>> children = new ArrayList<>();
        children.add(childNode1);
        children.add(childNode2);

        rootNode.put("children", children);

        return rootNode;
    }
}

 

 

> 실행

 

 


CategoryServiceTest

> 작성  코드

package com.hyeongarl.service;

import com.hyeongarl.dto.CategoryRequestDto;
import com.hyeongarl.entity.Category;
import com.hyeongarl.error.CategoryNotFoundException;
import com.hyeongarl.repository.CategoryRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.cache.CacheManager;
import org.springframework.test.context.ActiveProfiles;

import java.util.*;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ActiveProfiles("test")
@ExtendWith(MockitoExtension.class)
public class CategoryServiceTest {

    @Mock
    private CategoryRepository categoryRepository;

    @Mock
    private CacheManager cacheManager;

    @InjectMocks
    private CategoryService categoryService;

    private final Map<String, Object> categoryTree = createSampleCategoryTree("root");
    private final CategoryRequestDto categoryRequest = CategoryRequestDto.builder()
            .categoryTree(categoryTree)
            .build();
    private final Category category = Category.builder()
            .categoryTree(categoryTree)
            .userId(1L)
            .categoryId(1L)
            .build();

    @Nested
    @DisplayName("Category 등록")
    class saveCategoryTests {
        @Test
        @DisplayName("save_success")
        void saveCategory_success() {
            when(categoryRepository.save(any(Category.class))).thenReturn(category);

            Category category = categoryService.save(categoryRequest.toEntity(), 1L);

            assertNotNull(category);
            assertEquals(categoryRequest.getCategoryTree(), category.getCategoryTree());

            verify(categoryRepository, times(1)).save(any(Category.class));
        }
    }

    @Nested
    @DisplayName("Category 조회")
    class getCategoryTests {

        @Test
        @DisplayName("get_success_cacheable")
        void getCategory_success() {
            when(categoryRepository.findByUserId(anyLong())).thenReturn(Optional.of(category));

            Category category = categoryService.getCategory(1L);

            assertNotNull(category);
            assertEquals(categoryTree, category.getCategoryTree());

            verify(categoryRepository, times(1)).findByUserId(anyLong());
        }

        @Test
        @DisplayName("get_fail_notFound")
        void getCategory_fail_notFound() {
            when(categoryRepository.findByUserId(anyLong())).thenReturn(Optional.empty());

            assertThrows(CategoryNotFoundException.class, () -> categoryService.getCategory(1L));

            verify(categoryRepository, times(1)).findByUserId(anyLong());
        }
    }

    @Nested
    @DisplayName("Category 수정")
    class updateCategoryTests {
        @Test
        @DisplayName("update_success")
        void updateCategory_success() {
            when(categoryRepository.findByUserId(anyLong())).thenReturn(Optional.ofNullable(category));
            when(categoryRepository.save(any(Category.class))).thenReturn(category);

            Category update = categoryService.updateCategory(categoryRequest.toEntity(), 1L);

            assertNotNull(update);
            assertEquals(categoryTree, update.getCategoryTree());

            verify(categoryRepository, times(1)).findByUserId(anyLong());
            verify(categoryRepository, times(1)).save(any(Category.class));
        }

        @Test
        @DisplayName("update_fail_notFound")
        void updateCategory_fail_notFound() {
            when(categoryRepository.findByUserId(anyLong())).thenReturn(Optional.empty());

            assertThrows(CategoryNotFoundException.class, () -> categoryService.getCategory(1L));

            verify(categoryRepository, never()).save(any(Category.class));
        }
    }

    @Test
    @DisplayName("delete_success")
    void deleteCategory_success() {
        when(categoryRepository.findByUserId(anyLong())).thenReturn(Optional.of(category));

        categoryService.deleteCategory(1L, 1L);

        verify(categoryRepository, times(1)).findByUserId(anyLong());
        verify(categoryRepository, times(1)).delete(any(Category.class));
    }

    private Map<String, Object> createSampleCategoryTree(String rootName) {
        Map<String, Object> rootNode = new HashMap<>();
        rootNode.put("name", rootName);

        Map<String, Object> childNode1 = new HashMap<>();
        childNode1.put("name", "child1");

        Map<String, Object> childNode2 = new HashMap<>();
        childNode2.put("name", "child2");

        List<Map<String, Object>> children = new ArrayList<>();
        children.add(childNode1);
        children.add(childNode2);

        rootNode.put("children", children);

        return rootNode;
    }
}

 

> 실행

 

 

 

반응형