의존성 다시 한 번 확인

// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

 

터미널에 입력

./gradlew clean compileJava

그러면 아래 경로에 잘 생긴다!

1. 의존성 추가

implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

의존성이 제대로 추가되었다면 Q 클래스가 생성됨

 

2. QuerydslConfig 파일 생성

@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

3. Repository 생성

1.  TodoRepository에서 TodoRepositoryQuery를 상속하기

public interface TodoRepository extends JpaRepository<Todo, Long>, TodoRepositoryQuery {
}

 

2. TodoRepositoryQuery 인터페이스 생성

public interface TodoRepositoryQuery {

    Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);

    Optional<Todo> findByTitle(String title);

    Page<Todo> findBySearchKeyword(Pageable pageable, String title, LocalDate createdStartAt, LocalDate createdEndAt, String managerNickName);

}

 

3. TodoRepositoryQueryImpl 생성

@RequiredArgsConstructor
public class TodoRepositoryQueryImpl implements TodoRepositoryQuery {}

 

4. QueryDSL 기본 사용법

var query = jpaQueryFactory.select(threadEmotion.thread)
        .from(threadEmotion)
        .join(threadEmotion.thread)
        .where(userEq(user))
        .orderBy(threadEmotion.createdAt.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize());

 

5. 동적쿼리 처리하기

1. BooleanBuilder을 이용하여 검색 조건의 데이터 유무에 따라서 동적 쿼리 수행

  • 메서드를 각각 확인하면서 쿼리 조건을 확인해야하기 때문에 코드를 읽기가 어려움
  • 유지보수성이 좋지 않음
private List<Member> searchMember(String nameCond, Integer ageCond) {
    BooleanBuilder builder = new BooleanBuilder();
    
    if(nameCond != null) {
        builder.and(member.name.eq(nameCond));
    }
    if(ageCond != null) {
        builder.and(member.age.eq(ageCond));
    
    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}

 

2. BooleanExpression을 이용하여 검색 조건의 데이터 유무에 따라서 동적 쿼리 수행

  • where()에 null이 들어오면 where()절을 사용하지 않음
  • where()에 , 을 and 조건으로 사용
    @Override
    public Page<Todo> findBySearchKeyword(Pageable pageable, String title, LocalDate createdStartAt, LocalDate createdEndAt, String managerNickName) {
        List<Todo> results = jpaQueryFactory.select(todo)
                .from(todo)
                .join(todo.managers)
                .fetchJoin()
                .where(titleEq(title)
                        ,managerNickNameEq(managerNickName)
                        ,createdStartAtEq(createdStartAt)
                        ,createdEndAtEq(createdEndAt))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(todo.createdAt.desc())
                .fetch();

        JPAQuery<Long> countQuery = jpaQueryFactory.select(todo.count())
                .from(todo)
                .where(titleEq(title),managerNickNameEq(managerNickName),createdStartAtEq(createdStartAt),createdEndAtEq(createdEndAt));

        return PageableExecutionUtils.getPage(results, pageable, countQuery::fetchOne);
    }


    private BooleanExpression titleEq(String title) {
        if (title == null) {
            return null;
        }
        return todo.title.contains(title);
    }

    private BooleanExpression managerNickNameEq(String managerNickName) {
        if (managerNickName == null) {
            return null;
        }
        return todo.managers.any().user.nickName.like("%" + managerNickName +"%");
    }

    private BooleanExpression createdStartAtEq(LocalDate createdStartAt) {
        if (createdStartAt == null) {
            return null;
        }

        LocalDateTime startAt = createdStartAt.atStartOfDay();


        return todo.createdAt.goe( startAt ); // createdAt >= startAt
    }

    private BooleanExpression createdEndAtEq(LocalDate createdEndAt) {
        if (createdEndAt == null) {
            return null;
        }

        LocalDateTime endAt = createdEndAt.atTime(LocalTime.MAX);

        return todo.createdAt.loe(endAt); // createdAt <= endAt
    }

 

Service

public Page<TodoSearchResponse> getSearchTodos(int page, int size, String title, LocalDate createdStartAt, LocalDate createdEndAt, String managerNickName) {
        Pageable pageable = PageRequest.of(page - 1, size);
        Page<Todo> todos = todoRepository.findBySearchKeyword(pageable, title, createdStartAt, createdEndAt, managerNickName);
        Page<TodoSearchResponse> todoDtos = todos.map(TodoSearchResponse::from);
        return todoDtos;
    }

 

Controller

@GetMapping("/todos/search")
    public ResponseEntity<Page<TodoSearchResponse>> getSearchTodos(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(required = false) String title,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate start,
            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end,
            @RequestParam(required = false) String managerNickName
    ) {
        return ResponseEntity.ok(todoService.getSearchTodos(page, size, title, start, end, managerNickName));
    }

 

 

요구사항

1. 상품 조회 시 해당 게시글의 조회수를 카운팅 + 어뷰징을 방지할 것

2. 상품을 조회할 때 상품의 상세정보와 함께 조회수도 같이 볼 수 있어야 함

3. 조회수를 기준으로 상위 10개의 상품정보를 랭킹을 포함하여 조회할 수 있어야 함

 

조회수 카운팅 구현

Global Cache 방식 사용

  • Local Cache 전략을 사용하게 되면 속도가 빠르지만 다중 서버 환경에서는 각 서버마다 중복된 데이터를 보관해야 하고, 동기화되지 않기 때문에 데이터의 정합성 문제가 발생하기 때문에 속도가 Local Cache 보다는 느리지만 정합성과 중복성 문제를 해결하기 위한  Global Cache 방식을 선택!

 

Redis 선택 이유

  • 다양한 캐시 데이터베이스 중에서도 Redis는 다양한 자료구조를 지원함 -> 이번에 구현해야 할 상위 10개 검색 상품을 저장할 때 ZSet 자료구조를 이용하여 key-value임에도 불구하고 빠르게 정렬해서 조회가 가능
  • 다양한 라이브러리를 지원하기 때문에 spring boot에서도 간단하게 사용할 수 있음
  • 조회수 같은 숫자 데이터는 메모리 부담이 적기 때문에 사용하기 적절하다고 판단!

 

 

ZSet (Sorted Set) 자료구조 사용

  • Key-Value 구조인 Redis에서 정렬이 가능한 자료구조
  • 각 요소에 점수(Score)를 부여하고 이를 오름차순이나 내림차순으로 정렬할 수 있음 
  • O(log N)의 빠른 성능을 가짐

 

조회수 데이터 저장 : Redis에 조회수 데이터를 ZSet을 이용하여 구현

// 조회수가 없는 경우엔 1로 초기화, 존재하는 경우엔 조회수를 1만큼 증가
    public Long addReadCount(Long productId) {
        if (redisUtils.notExistsReadCount(productId)){
            redisUtils.setReadCount(productId);
            return 1L;
        }
        return redisUtils.addReadCount(productId);
    }
  • 조회수같이 자주 조회되고, 정각마다 초기화되는 데이터는 RDB에 저장하는 것보다 캐싱하여 빠르게 조회되고 갱신하는 것이 더 낫다고 판단함
  • 해당 상품의 조회수가 존재하지 않는다면 product:readCount라는 ZSet의 키 이름을 저장하고"product: + id"라는 이름의 값에Score를 1로 세팅
  • 존재한다면 "product: + id"라는 이름의 값에Score를 1만큼 증가

 

어뷰징 방지 : Redis에 방문기록을 저장하여 어뷰징 검증

public Boolean isNotViewed (Long productId, Long userId) {
        Boolean isNotViewed = redisTemplate.opsForValue().setIfAbsent("product:" + productId + ":user:" + userId, "viewed");
        return isNotViewed;
    }
  • "product:" + productId + ":user:" + userId 키가 존재하지 않는다면 방문기록을 저장하고 true 반환
  • "product:" + productId + ":user:" + userId 키가 존재한다면 false 반환

 

// 어뷰징 검증, 24시간 이내에 방문했다면 기존 조회수 조회, 아니라면 조회수 증가
    public Long findReadCount(Long productId, Long userId) {
        Boolean isNotViewed = redisUtils.isNotViewed(productId, userId);
        if (Boolean.TRUE.equals(isNotViewed)) {
            return addReadCount(productId);
        }
        return redisUtils.getReadCount(productId);
    }
  • isNotViewed 메서드를 통해서 사용자가 정각 이전에 1번 이상 해당 상품을 조회했는지 검증한 후에 조회한 적이 있다면 기존 조회수 조회
  • 조회한 적이 없다면 조회수를 1만큼 증가시킴

 

조회수 상위 10개 상품 랭킹 조회 : Redis에 저장된 상위 10개 상품 id를 RDB에 보내서 조회 후 캐싱

  1. 레디스에 상품조회를 할 때마다 조회수를 누적한다 (정각에 초기화)
  2. 사용자가 랭킹조회 api를 누르면 레디스에 저장된 조회수 리스트 상위 10개가져옴(상품 키값)
    1. 10개의 키값들을 가져와서 그 리스트와 매칭하여 엔티티들을 10개 가져와서 순위와 함께 엔티티 리스트를 보여준다 → in 사용하면 됨, pk조회는 빨라서 부담 없음
    2. 해당 리스트를 레디스에 저장하고 ttl이 만료되기 전까지는 레디스에서 조회
 // 조회수 상위 10개의 상품 id 리스트 조회
    public List<Long> findProductIds() {
        Set<Object> objectSet = redisTemplate.opsForZSet().reverseRange("product:readCount",0, 9);
        if (objectSet == null) {
            return Collections.emptyList();
        }

        //"product:7" -> ["product", "7"] -> ["7"]로 변환
        List<Long> productIdList = new ArrayList<>();
        for (Object o : objectSet) {
            String value = (String) o;
            String[] parts = value.split(":");
            productIdList.add(Long.parseLong(parts[1]));
        }
        return productIdList;
    }
  • ZSet을 이용하여 상위 10개의 조회수 id값을 순서대로 정렬하여 조회

 

@Transactional(readOnly = true)
    @Cacheable(cacheNames = "rank", key = "'products:rank'", cacheManager = "productCacheManager")
    public List<ProductRankResponse> findProductsByReadCount() {
        List<Long> idList = redisUtils.findProductIds();
        List<Product> products = productRepository.findProductsByRank(idList);

        // 순위별로 정렬
        products.sort(Comparator.comparingInt(p -> idList.indexOf(p.getId())));

        List<ProductRankResponse> readCountResponses = ProductRankResponse.toProductRankResponseList(products);
        return readCountResponses;
    }
  • 조회된 상위 10개의 상품 id리스트를 in절을 사용해서 RBD에서 상품데이터를 조회
    • -> 기본키(PK)를 이용하여 조회하기 때문에 성능상 RDB에서 조회한다고 해도 큰 부담이 없음
  • in절을 이용하여 상품을 조회하는 경우 데이터의 반환 순서를 보장하지 않기 때문에 products.sort를 이용하여 원래 id리스트 순서(랭킹)별로 다시 정렬
  • @Cacheable을 이용하여 조회수별 인기 순위 상품 결과를 캐시에 저장

 

자정에 조회수가 자동으로 리셋되도록 설정한 방법

// 자정에 캐시 리셋
    @Scheduled(cron = "0 0 0 * * ?")
    public void clearCache() {

        Set<String> visitorKeys = redisTemplate.keys("product*");
        if (visitorKeys != null && !visitorKeys.isEmpty()) {
            redisTemplate.delete(visitorKeys);
        }

        if (Boolean.TRUE.equals(redisTemplate.hasKey("product:readCount"))) {
            redisTemplate.opsForZSet().removeRange("product:readCount", 0, -1);
        }
    }
  • Spring Scheduler를 이용하여 정각마다 방문자수와 인기 검색 상품리스트를 초기화

 

캐싱조회 전 / 후 성능 비교

테스트조건

  • 1초동안 1000번의 요청을 30번 반복

 

데이터 100만건 중에서 상품 조회수 조회 성능비교

1. 데이터베이스에 조회수를 저장하고 조회

평균 응답시간(ms) : 569

Throughput (초당 응답할 수 있는 요청의 처리 수) : 1671

 

2. Redis에 조회수를 저장해서 캐싱으로 조회

평균 응답시간(ms) : 293

Throughput (초당 응답할 수 있는 요청의 처리 수) : 3035

 

  • 응답 시간: 48.5% 개선
  • 처리량 (Throughput): 81.7% 개선

 

데이터 100만건 중에서 랭킹 10위 상품 정보 조회 성능 비교하기

 

1.  조회수 랭킹 10위 리스트 캐싱 적용 안한 경우 조회

평균 응답시간(ms) : 605

Throughput (초당 응답할 수 있는 요청의 처리 수) : 1541

 

2.  조회수 랭킹 10위 리스트 캐싱 적용 한 경우 조회

평균 응답시간(ms) : 387

Throughput (초당 응답할 수 있는 요청의 처리 수) : 2405

 

개선점

  • 응답 시간: 36.0% 개선
  • 처리량 (Throughput): 56.0% 개선

정리

  평균 응답시간(ms) Throughput
상품 조회수 조회 (캐싱 x) 569 1671
상품 조회수 조회 (캐싱 o) 293 3035
상품 랭킹 정보 조회 (캐싱 x) 605 1541
상품 랭킹 정보 조회 (캐싱 o) 387 2405

 

응답시간은 대략 48.5% 감소했고, Throughput은 56.0% 증가했다!

'Spring' 카테고리의 다른 글

QClass 구현 안 될때 해결법  (0) 2025.04.04
QueryDSL을 이용하여 동적 쿼리 구현하기  (0) 2025.03.31
failed to create jar file 문제 해결하기  (1) 2025.03.25
테스트 코드 작성하기  (1) 2025.03.23
JPA와 Transaction  (1) 2025.03.21

팀원분이 올려주신 프로젝트를 클론하고 실행하려는데

java.util.concurrent.executionexception: org.gradle.api.gradleexception: failed to create jar file

에러가 발생한 것이다😱

 

다른 분들은 잘 실행된다고 해서 더 멘붕에 빠졌다

문제 상황을 파악해보니

gradle 파일에 문제가 있었음

-> 다른 팀원분들은 컴퓨터에 gradle이 깔려있어서 해당 gradle이 작동해서 빌드가 잘 됐음-> 나는 gradle이 내컴퓨터에 없었기 때문에 저 에러가 뜬 것

gradle -v

 

을 했을때 gradle이 존재하지 않는다고 떴다!

 

해결방법

1. 컴퓨터에 gradle깔기 

brew install gradle

 

2. 다시 실행하기!

Gradle Wrapper : (Gradle 실행 파일)를 생성하는 명령어

gradle wrapper

 

그럼 이렇게 잘 뜨고

 

다시 스프링 애플리케이션을 실행하면 잘 수행된다!

 

참고

  • 나중에 CI/CD를 배포할 때는 개인 컴퓨터에 있는 gradle로 빌드를 하는 것이 아닌 스프링 프로젝트 내부에 있는 gradle을 이용해서 빌드해야하기 때문에 gradle이 깨지거나 문제가 없는지 잘 확인하고 배포를 시도해야 한다
  • CI/CD 할때 참고할 자료 (팀원분이 공유해주셨다 ㅎㅎ) : https://jungseob86.tistory.com/21

'Spring' 카테고리의 다른 글

QueryDSL을 이용하여 동적 쿼리 구현하기  (0) 2025.03.31
조회수 및 어뷰징 방지 Redis로 구현하기  (0) 2025.03.30
테스트 코드 작성하기  (1) 2025.03.23
JPA와 Transaction  (1) 2025.03.21
Spring Security의 JWT 적용  (1) 2025.03.21

테스트 작성하는 법

테스트하고 싶은 레이어에 가서 cmd + n 을 누른다!

 

H2가 아닌 다른 데이터베이스에 연결하고 싶을 때

1. 아래 경로에 설정 파일 만들기

 

 

2. 테스트 코드 파일에 어노테이션 2개 추가하기

replace = AutoConfigureTestDatabase.Replace.NONE Spring Boot가 테스트를 위해 다른 데이터베이스(H2 등)로
교체하지 않도록 설정
@TestPropertySource(locations = {"classpath:/application-test.properties"}) 해당 경로의 application.properties를 사용하겠다는 뜻

 

@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(locations = {"classpath:/application-test.properties"})

 

Static import 해줄 것

단축키 : art+enter

import static org.assertj.core.api.Assertions.*;

 

Repository 테스트 작성 연습

  • @DataJpaTest
  • 테스트 하고자 하는 객체(repository)에 @Autowired 붙이기

 

참고

Repository 테스트는 작성해도 커버리지가 올라가지 않는다

그리고 사실 검증이 jpa로 잘 됐기 때문에 거의 작성하지 않는다고 한다

 

assertThat

  • isEqualTo : 숫자가 동일한지 비교
  • isSameAs : 문자가 동일한지 비교

 

 

시행착오

만약 댓글 저장 repository를 테스트 할 건데 아래와 같이 코드를 짜면 에러가 난다

comment를 저장하기 위해서는 user와 todo가 저장되어 있어야 하기 때문이다

@DataJpaTest
class CommentRepositoryTest {

    @Autowired
    private CommentRepository commentRepository;
    
    @Test
    public void 댓글이_정상적으로_저장된다() {
        // given
        User user = new User("yn1013@naver.com", "password", UserRole.USER);
        Todo todo = new Todo("title", "contents", "sunny", user);
        Comment comment = new Comment("comments", user, todo);

        // when
        Comment savedComment = commentRepository.save(comment);
    
        // then
        assertThat(comment).isSameAs(savedComment);
        assertThat(comment.getId()).isSameAs(savedComment.getId());
        assertThat(savedComment.getId()).isNotNull();
        assertThat(commentRepository.count()).isEqualTo(1);
    }

}

 

TransientPropertyValueException : Spring Data JPA에서 잘못된 데이터 접근 API 사용 시 발생하는 예외

org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientPropertyValueException: Not-null property references a transient value - transient instance must be saved before current operation : org.example.expert.domain.comment.entity.Comment.user -> org.example.expert.domain.user.entity.User
	at org.springframework.orm.jpa.EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(EntityManagerFactoryUtils.java:368)

 

수정된 코드

그래서 아래와 같이 수정해서 짜야 한다! -> user와 todo 또한 @Autowired로 주입한 후에 save를 때려준다

@DataJpaTest
class CommentRepositoryTest {

    @Autowired
    private CommentRepository commentRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TodoRepository todoRepository;
    
    @Test
    public void 댓글이_정상적으로_저장된다() {
        // given
        User user = new User("yn1013@naver.com", "password", UserRole.USER);
        Todo todo = new Todo("title", "contents", "sunny", user);
        Comment comment = new Comment("comments", user, todo);
        userRepository.save(user);
        todoRepository.save(todo);

        // when
        Comment savedComment = commentRepository.save(comment);
    
        // then
        assertThat(comment).isSameAs(savedComment);
        assertThat(comment.getId()).isSameAs(savedComment.getId());
        assertThat(savedComment.getId()).isNotNull();
        assertThat(commentRepository.count()).isEqualTo(1);
    }

}

 

 

조인되서 함께 조회가 되는지 테스트 해보는 코드

@DataJpaTest
class TodoRepositoryTest {

    @Autowired
    private TodoRepository todoRepository;

    @Autowired
    private UserRepository userRepository;

    @Test
    public void Todo와_작성자를_함께_조회한다() {
        // given
        String email = "yn@abc.com";
        User user = new User(email, "password", UserRole.USER);
        userRepository.save(user);

        String weather = "맑음";

        Todo todo = new Todo(
                "할일 제목",
                "할일 내용",
                weather,
                user
        );
        Todo savedTodo = todoRepository.save(todo);

        // when
        Todo foundTodo = todoRepository.findByIdWithUser(savedTodo.getId()).orElse(null);

        // then
        assertNotNull(foundTodo);
        assertEquals("할일 제목", foundTodo.getTitle());
        assertEquals("할일 내용", foundTodo.getContents());
        assertEquals("yn@abc.com", foundTodo.getUser().getEmail());
    }
}

 

저장, 삭제, 조회 테스트 해보는 코드

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    public void 사용자가_저장된다() {
        // given
        User user = new User("yn1013@naver.com", "password", UserRole.USER);

        // when
        User savedUser = userRepository.save(user);

        // then
        assertThat(user).isSameAs(savedUser);
        assertThat(user.getEmail()).isSameAs(savedUser.getEmail());
        assertThat(savedUser.getId()).isNotNull();
        assertThat(userRepository.count()).isEqualTo(1);
    }

    @Test
    public void 사용자를_id로_조회한다() {
        // given
        User user1 = new User("yn1013@naver.com", "password", UserRole.USER);
        User user2 = new User("yn1122@naver.com", "password", UserRole.USER);
        User savedUser1 = userRepository.save(user1);
        User savedUser2 = userRepository.save(user2);

        // when
        User findUser1 = userRepository.findById(savedUser1.getId())
                .orElseThrow(() -> new InvalidRequestException(ErrorCode.USER_NOT_FOUND));
        User findUser2 = userRepository.findById(savedUser2.getId())
                .orElseThrow(() -> new InvalidRequestException(ErrorCode.USER_NOT_FOUND));

        // then
        assertThat(userRepository.count()).isEqualTo(2);
        assertThat(findUser1.getEmail()).isEqualTo("yn1013@naver.com");
        assertThat(findUser2.getEmail()).isEqualTo("yn1122@naver.com");
    }
    
    @Test
    public void 사용자를_id로_삭제한다() {
        // given
        User user = new User("yn1013@naver.com", "password", UserRole.USER);
        User savedMember = userRepository.save(user);
        Long userId = savedMember.getId();

        // when
        userRepository.deleteById(userId);

        // then
        assertThat(userRepository.count()).isEqualTo(0);
        Optional<User> deletedUser = userRepository.findById(userId);
        assertThat(deletedUser).isEmpty();
    }

    @Test
    public void 이메일로_사용자를_조회할_수_있다() {
        // given
        String email = "yn@abc.com";
        User user = new User(email, "password", UserRole.USER);
        userRepository.save(user);

        // when
        User foundUser = userRepository.findByEmail(email).orElse(null);

        // then
        assertNotNull(foundUser);
        assertEquals(email, foundUser.getEmail());
        assertEquals(UserRole.USER, foundUser.getUserRole());
    }

    @Test
    public void 이메일이_존재하는지_확인할_수_있다() {
        // given
        String email = "yn@abc.com";
        User user = new User(email, "password", UserRole.USER);
        userRepository.save(user);

        // when
        Boolean emailExists = userRepository.existsByEmail(email);

        // then
        assertNotNull(emailExists);
        assertEquals(true, emailExists);
    }


}

 

Service 테스트 작성 연습

  • @ExtendWith(MockitoExtension.class) 붙이기
  • 테스트하고자 하는 객체에 @InjectMocks를 붙이기
  • 그 외의 객체에 @Mock을 붙이기

 

시행착오

테스트 하려는 부분

 

여기서 passwordEncode를 사용하고 있고, 유저 서비스에서도 이것을 private final로 주입받고 있기 때문에 Mock을 주입 해야 함

 

작성된 코드

→ 사실 이러면 틀린다!

  • passwordEncoder.matches()를 모킹하지 않음
  • 실제 passwordEncoder.matches()가 실행되어 예상과 다르게 동작된다

인코딩된 값이 아닌 문자열 자체를 비교해버림!

@Test
    public void 비밀번호_변경시에_기존_비밀번호가_유효하지_않으면_InvalidRequestException을_던진다() {
        // given
        String email = "yn1013@naver.com";
        long userId = 1L;
        User user = new User(email, "password", UserRole.USER);
        ReflectionTestUtils.setField(user, "id", userId);
        given(userRepository.findById(anyLong())).willReturn(Optional.of(user));

        String oldPassword = "oldPassword";
        String newPassword = "newPassword";
        UserChangePasswordRequest request = new UserChangePasswordRequest(oldPassword, newPassword);

        // when & then
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.changePassword(userId, request));
        assertEquals("비밀번호가 잘못되었습니다.", exception.getErrorCode().getMessage());
    }

 

그래서 수정된 코드!

전체 서비스 메서드 코드

 

리포지토리에서 사용자 조회하는 부분

@Test
    public void User를_Id로_조회할_수_있다() {
        // given
        String email = "yn1013@naver.com";
        long userId = 1L;
        User user = new User(email, "password", UserRole.USER);
        ReflectionTestUtils.setField(user, "id", userId);

        given(userRepository.findById(anyLong())).willReturn(Optional.of(user));

        // when
        UserResponse userResponse = userService.getUser(userId);

        // then
        assertThat(userResponse).isNotNull();
        assertThat(userResponse.getId()).isEqualTo(userId);
        assertThat(userResponse.getEmail()).isEqualTo(email);
    }
 @Test
    public void 존재하지_않는_User_조회시에_InvalidRequestException을_던진다() {
        // given
        long userId = 1L;
        given(userRepository.findById(anyLong())).willReturn(Optional.empty());

        // when & then
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.getUser(userId));
        assertEquals("유저가 존재하지 않습니다.", exception.getErrorCode().getMessage());
    }

 

첫 번째 예외 처리 부분

@Test
    public void 비밀번호_변경시에_기존_비밀번호와_동일하면_InvalidRequestException을_던진다() {
        // given
        String email = "yn1013@naver.com";
        long userId = 1L;
        User user = new User(email, "password", UserRole.USER);
        ReflectionTestUtils.setField(user, "id", userId);

        given(userRepository.findById(anyLong())).willReturn(Optional.of(user));

        String oldPassword = "oldPassword";
        String newPassword = "oldPassword";
        UserChangePasswordRequest request = new UserChangePasswordRequest(oldPassword, newPassword);

        given(passwordEncoder.matches(request.getNewPassword(), user.getPassword())).willReturn(true);

        // when & then
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.changePassword(userId, request));
        assertEquals("이미 사용 중인 비밀번호 입니다.", exception.getErrorCode().getMessage());
    }

 

두번째 예외처리 부분

@Test
    public void 비밀번호_변경시에_기존_비밀번호가_유효하지_않으면_InvalidRequestException을_던진다() {
        // given
        String email = "yn1013@naver.com";
        long userId = 1L;
        User user = new User(email, "password", UserRole.USER);
        ReflectionTestUtils.setField(user, "id", userId);
        given(userRepository.findById(anyLong())).willReturn(Optional.of(user));

        String oldPassword = "oldPassword";
        String newPassword = "newPassword";
        UserChangePasswordRequest request = new UserChangePasswordRequest(oldPassword, newPassword);

        given(passwordEncoder.matches(request.getNewPassword(), user.getPassword())).willReturn(false);
        given(passwordEncoder.matches(request.getOldPassword(), user.getPassword())).willReturn(false);

        // when & then
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> userService.changePassword(userId, request));
        assertEquals("비밀번호가 잘못되었습니다.", exception.getErrorCode().getMessage());
    }

 

비밀번호 검증 부분

@Test
    public void 비밀번호가_성공적으로_변경된다() {
        // given
        long userId = 1L;
        User user = new User("yn1013@naver.com", "oldPassword", UserRole.USER);
        ReflectionTestUtils.setField(user, "id", userId);

        String oldPassword = "oldPassword";
        String newPassword = "newPassword";
        UserChangePasswordRequest request = new UserChangePasswordRequest(oldPassword, newPassword);

        // 서비스 내부에서 findById 실행
        given(userRepository.findById(anyLong())).willReturn(Optional.of(user));
        // 첫 번째 검증 로직 통과하도록 설정
        given(passwordEncoder.matches(request.getNewPassword(), user.getPassword())).willReturn(false);
        // 두 번째 검증 로직 통과하도록 설정
        given(passwordEncoder.matches(request.getOldPassword(), user.getPassword())).willReturn(true);
        // changePassword 안에 들어갈 값 설정
        given(passwordEncoder.encode(request.getNewPassword())).willReturn("encodedNewPassword");

        // when
        userService.changePassword(userId, request);

        // then
        assertEquals("encodedNewPassword", user.getPassword());
    }

 

이제 잘 돌아간다!

 

Controller 테스트 작성 연습

1. `@WebMvcTest(XxxController.class)`를 붙인다.

2. @MockBean 혹은 @MockitoBean을 붙인다. (전자는 3.4 이전, 후자는 3.4 이후)

3. API 호출은 아래의 MockMvc를 사용

 

테스트할 부분

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    public void user_단건_조회() throws Exception {
        // given
        long userId = 1L;
        String email = "yn1013@naver.com";

        BDDMockito.given(userService.getUser(userId)).willReturn(new UserResponse(userId, email));

        // when & then
        mockMvc.perform(get("/users/{userId}", userId))
                .andExpect(status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(userId))
                .andExpect(MockMvcResultMatchers.jsonPath("$.email").value(email));
    }
}

 

컨트롤러 에러난 경우 테스트

아래 로그를 보고 똑같이 맞춰주면 된다

@Test
    public void user_단건_조회_시_user가_존재하지_않아_예외가_발생한다() throws Exception {
        // given
        long userId = 1L;

        // when
        Mockito.when(userService.getUser(userId))
                .thenThrow(new InvalidRequestException(ErrorCode.USER_NOT_FOUND));

        // then
        mockMvc.perform(get("/users/{userId}", userId))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(HttpStatus.BAD_REQUEST.value())) //숫자
                .andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.name())) //문자
                .andExpect(jsonPath("$.message").value("유저가 존재하지 않습니다."));
    }

 

리스트 조회 테스트

@Test
    void User_목록_조회_빈리스트() throws Exception {
        // given
        given(userService.getUsers()).willReturn(List.of());

        // when & then
        mockMvc.perform(get("/users"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").isEmpty());
    }

    @Test
    void User_목록_조회() throws Exception {
        // given
        long userId1 = 1L;
        long userId2 = 2L;
        String email1 = "user1@a.com";
        String email2 = "user2@a.com";
        List<UserResponse> userList = List.of(
                new UserResponse(userId1, email1),
                new UserResponse(userId2, email2)
        );
        given(userService.getUsers()).willReturn(userList);

        // when & then
        mockMvc.perform(get("/users"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(2))
                .andExpect(jsonPath("$[0].id").value(userId1))
                .andExpect(jsonPath("$[0].email").value(email1))
                .andExpect(jsonPath("$[1].id").value(userId2))
                .andExpect(jsonPath("$[1].email").value(email2));
    }

 

JPA

  • 자바에서 orm기술 표준으로 사용하는 인터페이스

 

orm

  • 자바 객체와 관계형 db의 데이터를 자동으로 매핑해주는 방법

 

Hibernate

  • 자바 언어를 위한 orm 프레임워크
  • JPA 인터페이스를 구현하고 내부적으로 JDBC API를 사용

 

Spring Data JPA

  • jpa를 더 쉽고 편하게 사용할 수 있게 도와주는 spring에서 제공되는 모듈
  • jpa를 한 단계 더 추상화 시킨 jparepository 인터페이스 제공
  • entitymaniger는 jpa에서 관리하는 것
  • @transactional은 스프링에서 관리하는 것

 

Raw JPA

 

 

Persistence Context

  • 엔티티 객체가 생성, 관리, 소멸 되기까지 데이터베이스와 상호작용하는 모든 과정
  • 엔티티를 JPA 영속성 컨텍스트에 영속화(persist) 하게 되면, 이후 JPA 영속성 컨텍스트에서 상태를 관리함

 

Entity Manager Factory

  • jpa에서 엔티티 메니저를 생성하고 관리하는 팩토리 객체
  • 애플리케이션 실행시 한번만 생성됨, 애플리케이션 전체에서 공유됨.
  • 여러 스트레드가 동시에 객체나 메서드에 접근할 때 데이터의 일관성을 보관할 수 있음
  • 엔티티 메시저 생성, 디비 연결, 트랜젝션 관리

Entity Manager

  • 디비와 상호작용하는 객체, 엔티티의 생명 주기를 관리, 여러 스레드가 동시에 접근할 때 일관성 보장 ㄴㄴ
  • 엔티티 객체의 라이프 사이클과 영속성 관리
  • 엔티티 객체에 대해서 디비 crud 연산 수행

 

OSIV(Open Session in View)

영속성 컨텍스트를 View 렌더링이 끝날 때까지 개방된 상태로 유지하는 방식

장점

  • 컨트롤러나 뷰에서도 지연 로딩 사용 가능

단점 : 디비 커넥션 점유

  • 컨트롤러와 뷰 렌더링이 끝날 때까지 디비 커넥션 유지
  • 커넥션 보유 시간이 길어져 서버 자원 많이 사용
  • 과도한 db 커넥션 사용으로 인한 성능 저하 가능성

OSIV를 비활성화(false) 하여 사용하는 것이 좋음

spring.jpa.open-in-view=false

 

 

Proxy

  • 프록시(Proxy): 실제 엔터티 객체 대신, 그 객체를 감싸고 있는 대리 객체
  • 실제 데이터는 데이터베이스에서 가져오지 않고, 필요한 시점에만 쿼리를 실행해 데이터를 조회하는 방식
  • 불필요한 쿼리의 발생을 방지
 

이게 바로 프록시 객체!

무비를 조회할 때 연관관계가 맺어진 감독들 객체를 lazy로 조회

감독들을 제외하고 조회하는 경우에 무비 안에 있는 배우는 프록시로 조회됨

-> 불필요한 DB 통신 미사용 및 DB 부하 감소

 

@Transactional을 사용할 때 private 접근제어자를 사용하면 안 됨

  • @transactional은 aop로 만들어져 있음
  • aop도 내부적으로 프록시를 사용하여 만들어져 있음
  • 프록시가 동작을 하려면 내부적으로 상속이든 구현체든 뭐가 되어서 접근을 해야하는데, 메서드가 private으로 되어 있으면 접근 못함

 

 

@Transactional

  • Spring에서 메서드 또는 클래스에 적용하여 트랜잭션을 선언적으로 관리하는 방법
  • 트랜잭션 범위에 대해 Proxy 패턴을 사용하여 트랜잭션 관리
  • 설정 가능한 옵션 제공
  • 종류 : propagation(기본), isolation, timeout, readOnly, rollbackFor, noRollbackFor
  • readOnly = true로 설정하여 성능상 이점을 가질 수 있음 -> 변경 감지(Dirty Checking)가 비활성화

예시 : rollbackFor - 해당 예외가 발생할 때만 롤백을 수행한다

@Transactional(rollbackFor = {IOException.class, SQLException.class})
    public void registerUser(User user) throws IOException, SQLException {
        userRepository.save(user);
        if (someCondition) {
            throw new IOException("IOException 발생!");  // 롤백
        }
        if (someOtherCondition) {
            throw new SQLException("SQLException 발생!");  // 롤백
        }
    }

 

 

Propagation

  • 트랜잭션이 다른 트랜잭션과 어떻게 상호작용할지를 결정하는 방식
  • 서로 다른 서비스에 있는 트랜잭션끼리 어떻게 관리되고 전파할것인지 결정하는 것
  • 종류
    • REQUIRED (기본값): 이미 진행 중인 트랜잭션이 있으면 그 트랜잭션을 사용하고, 없으면 새 트랜잭션을 시작
    • REQUIRES_NEW: 항상 새 트랜잭션을 시작하며, 진행 중인 트랜잭션은 잠시 중단
    • SUPPORTS: 트랜잭션이 이미 존재하면 그 트랜잭션 내에서 실행하고, 없으면 비트랜잭션 실행
    • MANDATORY: 현재 트랜잭션이 반드시 있어야 하며, 없으면 예외가 발생
    • NEVER: 트랜잭션 없이 실행되어야 하며, 트랜잭션이 존재하면 예외가 발생
    • NESTED: 진행 중인 트랜잭션 내부에 또 다른 트랜잭션을 중첩시켜 실행할 수 있으며, 이 경우 Savepoint를 이용

 

Propagation 예시 (REQUIRES_NEW)

현재 서로 다른 서비스에서 개별적으로 트랜잭션이 관리되고 있음

만약 changeUserRole에서 에러가 발생하면 사용자는 역할을 변경하지 못하고 로그도 찍히지 못할 것이다

기본값이 REQUIRED이기 때문에 saveLog와 changeUserRole은 같은 트랜잭션을 쓰기 때문이다!

@Transactional
    public void saveLog(Long userId, LocalDateTime requestTime) {
        Log log = new Log(userId, requestTime);
        logRepository.save(log);
    }
@Transactional
    public void changeUserRole(AuthUser authUser, long userId, UserRoleChangeRequest userRoleChangeRequest) {
        User requestUser = User.fromAuthUser(authUser);
        logService.saveLog(requestUser.getId(), LocalDateTime.now());
        User user = userRepository.findById(userId).orElseThrow(() -> new InvalidRequestException("User not found"));
        user.updateRole(UserRole.of(userRoleChangeRequest.getRole()));
    }

 

이때 changeUserRole에서 에러가 발생해도 saveLog는 그와 상관없이 독립적으로 수행되고 싶다면 Propagation 설정을 REQUIRES_NEW로 변경해주면 된다!

@Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(Long userId, LocalDateTime requestTime) {
    }

Spring Security

  • 스프링 기반 애플리케이션에 인증과 인가 기능을 제공하는 보안 프레임워크
  • 2003년에 나왔음 → 세션 방식으로 이루어져 있음 (JWT가 없던 시기)

 

Spring Security에서 인증을 통과하는 방식

  • SecurityContext : 현재 로그인한 사용자의 정보를 저장하는 객체
  • SecurityContext에 AbstractAuthenticationToken을 set해야 한다
  • 로그인을 하면 → SecurityContext 내부에 Authentication 객체가 저장
  • 필요할 때 SecurityContext에서 현재 로그인한 사용자의 정보를 꺼낼 수 있음
  • SecurityContextHolder를 통해 SecurityContext에 접근 가능
  • 로그아웃하면 SecurityContext가 초기화

 

SecurityContextHolder

  • SecurityContext를 저장하고 관리하는 저장소

📌 "할 일 등록 API" 요청 (/todos)

[ 새로운 SecurityContextHolder 생성 ]

-> 인증 정보 저장 (SecurityContext에 저장됨), 이 SecurityContext를 SecurityContextHolder에 저장

-> 할 일 등록 진행

-> 요청 종료 후 SecurityContext 초기화

 

이 과정은 각각의 스레드별로 독립적으로 진행됨!

 

Spring Security 문제점 : 세션 기반으로 사용하려는 것

Spring Security → 2003년에 나옴 → 세션 방식

JWT → 2015년 표준화됨 → 서버는 stateless

즉 JWT는 stateless 하지만 Security는 stateless하지 않다!

 

UserDetailsServiceImpl의 문제

  • user 의 정보를 알기 위해서 UserRepository를 통해 디비에 다녀옴 → 세션이랑 다를 게 없음 → 애초에 jwt에 사용자 id, 권한 등등을 넣어 놓는데 이렇게 되면 jwt는 세션키의 역할만 될 뿐! (차라리 세션키 쓰는게 훨 낫다 덜 뚱뚱하기 때문이다)
  • 또한 데이터베이스를 나눠놓는 아키텍쳐인 MSA에도 적용하지 못함 (만약 user db가 죽는다면 서비스는 아예 죽게 됨)
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
		
		@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));

        return new UserDetailsImpl(user);
    }
}

 

UserDetailsServiceImpl Stateless 적용

사용자의 정보를 userRepository가 아닌, 이미 정보를 가지고 있는 JWT 토큰에서 까서 가져오면 된다

  1. 헤더에서 JWT 토큰을 가져오고
  2. JWT를 해석(파싱)하여 토큰 내부의 데이터(payload)를 반환한 Claims객체를 반환한다
  3. Claims객체를 이용하여 JwtAuthenticationToken을 생성하고 SecurityContext에 set 해준다
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest httpRequest,
            @NonNull HttpServletResponse httpResponse,
            @NonNull FilterChain chain
    ) throws ServletException, IOException {
        String authorizationHeader = httpRequest.getHeader("Authorization");
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            String jwt = jwtUtil.substringToken(authorizationHeader);
            try {
                Claims claims = jwtUtil.extractClaims(jwt);

                if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    setAuthentication(claims);
                }
            } catch (SecurityException | MalformedJwtException e) {
                ...
            }    
        }
        chain.doFilter(httpRequest, httpResponse);
    }

    private void setAuthentication(Claims claims) {
        Long userId = Long.valueOf(claims.getSubject());
        String email = claims.get("email", String.class);
        UserRole userRole = UserRole.of(claims.get("userRole", String.class));
        String nickName = claims.get("nickName", String.class);

        AuthUser authUser = new AuthUser(userId, email, userRole, nickName);
        JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }
}

 

위와 같이 적용하면 더 이상 UserDetailService를 사용하지 않아도 된다

또한! 사용자를 조회할 때 respository를 사용하지 않아도 된다

 

기존 코드

사용자를 조회하기 위해서 userId로 데이터베이스에 접근해서 사용자를 찾는다

 

하지만 더 이상 데이터베이스에 접근해서 사용자를 찾을 필요가 없다

 

JWT 활용 코드

db를 거치는 것이 아닌 jwt를 이용하여 얻은 객체인 AuthUser를 이용하여 사용자객체를 생성하면 된다

 

fromAuthUser

 

 

사용자의 권한을 변경했다면 데이터베이스에서 사용자의 정보를 대조해야 한다

만약 사용자의 권한을 admin에서 user로 바꿨다고 한다면, 권한이 바뀐 사용자는 아직 발급받은 jwt에서 admin으로 되어 있을 것이다

그렇기 때문에 이런 경우에는 컨트롤러에서 사용자의 권한을 한번 더 체크하는 로직을 짜줘야 한다 (필터는 통과했을 것이니)

 

 

100만건 생성 테스트

 첫 번째 시도 : NoSuchBeanDefinitionException 

  • jpa로 save를 100만건 시도
@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void 유저_데이터를_100만_건_생성() {
        for (long i = 0L; i < 1000000; i++){
            String email = "user" + i + "@example.com";
            String password = "securePassword123";
            UserRole role = UserRole.of("ROLE_USER");
            String nickName = UUID.randomUUID().toString().substring(0, 8);
            User user = new User(email, password, role, nickName);

            userRepository.save(user);
        }
    }
}

 

-> NoSuchBeanDefinitionException 발생

EntityManager를 찾을 수 없다고 한다

 

테스트 패키지 하위에 config 파일을 만들고 해결

@TestConfiguration  // 테스트 환경 전용 설정
public class TestQuerydslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

두 번째 시도 : JdbcSQLSyntaxErrorException

Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TODOS" not found; SQL statement:

생성된 쿼리를 보니 동일한 테이블을 생성하는 쿼리가 여러번 날라가면서 SyntaxErrorException이 계속 뜬다

 

원인

  • 기본적으로 설정된 테스트용 h2 데이터베이스
  • 따로 설정해둔 properties 파일이 적용되지 않음

 

현재 나는 테스트용 데이터베이스를 mysql애 연결하고 싶어서 설정파일을 따로 빼놓았는데 자동으로 h2로 실행하려 해서 mysql과 호환이 안되서 그런 것 같다

 

h2 의존성 주석 처리

    compileOnly 'org.projectlombok:lombok'
//    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'com.mysql:mysql-connector-j'

 

테스트용 poroperties 경로

이렇게 두면 자동으로 테스트할 때 적용되는 줄 알았는데 아니었다🙄

 

테스트 클래스에 테스트용 poroperties 설정파일을 적용하는 어노테이션 적용

@TestPropertySource(locations = {"classpath:/application-test.properties"})

 

실제 데이터베이스에 데이터를 저장하기 위한 설정 적용

UserRepositoryTest 클래스 상단에 써준다.

  • @SpringBootTest를 붙이면 자동으로 테스트가 끝나고 롤백이 되기 때문에 @Rollback(false)를 붙여준다
  • 실제 데이터베이스에 적용할 것이기 때문에 @AutoConfigureTestDatabase의 설정을 적용해준다
@Rollback(false)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)

 

세 번째 시도 : OutOfMemoryError

아무래도 저렇게 save를 백만개 시도하면 데이터 베이스 입출력이 백만번 수행되는거다보니 시간이 매우 오래걸린다고 한다 (거의 40분,,)

그래서 jdbc templete의 batch insert를 사용하여 배치 처리를 적용해봤다

실무에서도 대용량 데이터를 삽입을 처리할 때 이 방식을 사용한다고 한다

 

jdbc templete batch insert

이런식으로 하나의 쿼리문으로 여러개의 데이터를 처리해준다

INSERT INTO table (col1, col2) VALUES
(val1, val11),
(val2, val22),
(val3, val33);

 

properties 파일 설정 변경

  • mysql의 경우 rewriteBatchedStatements=true를 적용하여 배치 처리를 허용한다고 설정한다
  • batch_size를 설정해준다 -> insert쿼리를 100개 모아놨다가 한번에 보낸다는 의미
spring.datasource.url=jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999
spring.jpa.properties.hibernate.jdbc.batch_size=100

 

UserBulkRepository

@Repository
@RequiredArgsConstructor
public class UserBulkRepository {

    private final JdbcTemplate jdbcTemplate;

    @Transactional
    public void saveAll(List<User> users) {
        String sql = "INSERT INTO users(email, image_url, nick_name, password, user_role)"
                + "VALUES (?, ?, ?, ?, ?)";

        jdbcTemplate.batchUpdate(sql,
                new BatchPreparedStatementSetter() {
                    @Override
                    public void setValues(PreparedStatement ps, int i) throws SQLException {
                        User user = users.get(i);
                        ps.setString(1, user.getEmail());
                        ps.setString(2, user.getImageUrl());
                        ps.setString(3, user.getNickName());
                        ps.setString(4, user.getPassword());
                        ps.setString(5, String.valueOf(user.getUserRole()));
                    }

                    @Override
                    public int getBatchSize() {
                        return users.size();
                    }
                });
    }
}

 

TestCode

@Test
    void 유저_데이터를_100만_건_생성() {
        List<User> userList = new ArrayList<>();
        for (long i = 0L; i < 1000000; i++){
            String email = "user" + i + "@example.com";
            String password = "securePassword123";
            UserRole role = UserRole.of("ROLE_USER");
            String nickName = UUID.randomUUID().toString().substring(0, 8);
            User user = new User(email, password, role, nickName);

            userList.add(user);
        }
        userBulkRepository.batchUpdate(userList);
    }

 

Exception in thread "mysql-cj-abandoned-connection-cleanup" java.lang.OutOfMemoryError: Java heap space

-> 힙 메모리가 부족하다고 한다

 

원인

  • 아무래도 BatchSize를 저장하려고 하는 데이터의 개수로 해서 부하가 온 것 같다
  • BatchSize를 줄여주자

 

네 번째 시도 : 성공!

공식문서를 참고하여 배치사이즈를 지정하고 배치 쿼리를 적용하도록 적용해봤다

참고 : https://docs.spring.io/spring-framework/reference/data-access/jdbc/advanced.html

 

100개씩 쌓아놨다가 insert 수행!

@Repository
@RequiredArgsConstructor
public class UserBulkRepository {

    private final JdbcTemplate jdbcTemplate;

    public int[][] batchUpdate(final List<User> users) {
        int[][] insertCounts = jdbcTemplate.batchUpdate(
                "INSERT INTO users(email, image_url, nick_name, password, user_role)" + "VALUES (?, ?, ?, ?, ?)",
                users,
                100,
                (PreparedStatement ps, User user) -> {
                        ps.setString(1, user.getEmail());
                        ps.setString(2, user.getImageUrl());
                        ps.setString(3, user.getNickName());
                        ps.setString(4, user.getPassword());
                        ps.setString(5, String.valueOf(user.getUserRole()));
                });
        return insertCounts;
    }
  }

 

테스트가 잘 수행됐다

 

100만개를 생성하는데 총 1분 28초가 걸렸다

 

참고로,,,,

DB를 열어봤는데 데이터가 1000개만 조회된 것이다

 

충격을 받고 계속 다른 코드를 찾아보면서 바꿔도 똑같은 결과가 나왔다…

그래서 일단 테스트 다시 할때마다 삭제를 진행해야 하기 때문에 삭제를 했다

 

음 삭제한 행이 백만개라고?

데이터 조회만 1000개 한거였다! 😭

데이터는 잘 생성된 것이었다고 한다...

다음부터 이런 착각은 하지 않도록 조심해야겠다..

 

User데이터 100만건 검색 성능 개선

jmeter라는 테스트 도구를 사용하여 테스트를 진행해봤다

참고로 내 기준보다 여러명의 사용자가 많은 요청을 해야 성능 개선 전후의 차이를 정확하게 비교할 수 있다

 

실행법

설치 후 터미널에 jmeter 쳐서 실행

 

JMeter 성능지표

Summary Report

  • Label : Sampler 명
  • Samples : 샘플 실행 수 (Number of Threads X Ramp-up period)
  • Average : 평균 걸린 시간 (ms)
  • Min : 최소
  • Max : 최대
  • Std. Dev. : 표준편차
  • Error % : 에러율
  • Throughput : 초당 처리량 (bps) = JMeter에서는 시간 단위를 보통 TPS (Transaction Per Second)로 표현
  • Received KB/sec : 초당 받은 데이터량
  • Sent KB/sec : 초당 보낸 데이터량
  • Avg. Bytes : 서버로부터 받은 데이터 평균

JMeter 테스트 용어

  • Thread Group : 쓰레드 1개당 사용자 1명
  • Sampler : 사용자의 액션 (예: 로그인, 게시물 작성, 게시물 조회 등)
  • Listener : 응답을 받아 리포팅, 검증, 그래프 등 다양한 처리
  • Configuration : Sampler 또는 Listener가 사용할 설정 값 (쿠키, JDBC 커넥션 등)
  • Assertion : 응답 확인 방법 (응답 코드, 본문 내용 비교 등)

nickname 컬럼에 index 걸기

사용자는 nickname으로 user를 검색하기 때문에 해당 컬럼에 인덱싱을 걸어주자

 

인덱싱 미적용 : 평균 4872ms

 

인덱싱 적용 : 평균 226ms

 

인덱싱을 적용하면 21.58배 빨라지는 것을 볼 수 있다

+ Recent posts