[Spring] 플러스 주차 개인과제 - Level3

Yuri·2025년 3월 21일

Spring

목록 보기
20/21

10. QueryDSL 을 사용하여 검색 기능 만들기

  • QueryDSL을 활용한 쿼리 최적화
    • 검색 기능의 성능 및 사용성을 높일 수 있다
    • Projections를 활용해서 필요한 필드만 반환
  • 🔍 검색 조건
    • 일정 제목: 부분 일치
    • 일정 생성일 범위: 최신순 정렬
    • 담당자 닉네임: 부분 일치
  • 결과
    • 일정의 모든 정보가 아닌 제목만 포함
    • 해당 일정의 담당자 수
    • 해당 일정의 총 댓글 개수
    • 검색 결과는 페이징 처리되어 반환

✏️ 코드 추가

▶︎ build.gradle 👉 QClass가 생성되는 경로를 지정하여 협업 프로그래밍 시 오류 방지

def generated = 'src/main/generated'

tasks.withType(JavaCompile).configureEach {
    options.getGeneratedSourceOutputDirectory().set(file(generated))
}

clean {
    delete file(generated)
}

▶︎ TodoSearchRequest: 제목, 일정 범위(시작 ~ 끝), 닉네임

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TodoSearchRequest {
    private String title;
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
    private LocalDate startDate;
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
    private LocalDate endDate;
    private String nickname;
}

▶︎ TodoSearchResponse: @QueryProjection 어노테이션 사용

@Getter
public class TodoSearchResponse {
    private final String title;
    private final Integer managerCount;
    private final Integer commentsCount;

    @QueryProjection
    public TodoSearchResponse(String title, Integer managerCount, Integer commentsCount) {
        this.title = title;
        this.managerCount = managerCount;
        this.commentsCount = commentsCount;
    }
}

🧐 @QueryProjection 이 붙은 DTO는 QueryDSL이 QClass를 자동 생성
→ QTodoSearchResponse 클래스를 QueryDSL에서 사용 가능

⭐️ 장점

  • 컴파일 타임 체크가 가능하여, 타입 안전하고 깔끔한 코드 작성이 가능하다.

▶︎ TodoRepositoryQueryImpl

	@Override
    public Page<TodoSearchResponse> searchTodos(TodoSearchCond cond, Pageable pageable) {
        // 조회
        var query = findTodosQuery(QTodoSearchResponse(
                todo.title,
                todo.managers.size(),  // 일정 담당자 수
                todo.comments.size()), cond)
                .orderBy(todo.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize());
        var todos = query.fetch();

        // 페이징 totalSize
        long totalSize = findTodosCountQuery(cond).fetch().get(0);

        return PageableExecutionUtils.getPage(todos, pageable, () -> totalSize);
    }

    private <T> JPAQuery<T> findTodosQuery(Expression<T> expr, TodoSearchCond cond) {
        return jpaQueryFactory.select(expr)
                .from(todo)
                .where(
                        todoTitleContains(cond.getTitle()),
                        todoCreateAtAfter(cond.getStartDate()),
                        todoCreateAtBefore(cond.getEndDate()),
                        todoManagerNicknameContains(cond.getNickname())
                );
    }

    private JPAQuery<Long> findTodosCountQuery(TodoSearchCond cond) {
        return jpaQueryFactory.select(Wildcard.count)
                .from(todo)
                .where(
                        todoTitleContains(cond.getTitle()),
                        todoCreateAtAfter(cond.getStartDate()),
                        todoCreateAtBefore(cond.getEndDate()),
                        todoManagerNicknameContains(cond.getNickname())
                );
    }

    private BooleanExpression todoTitleContains(String title) {
        return StringUtils.isEmpty(title) ? null : todo.title.contains(title);
    }

    private BooleanExpression todoCreateAtAfter(LocalDate startDate) {
        return Objects.nonNull(startDate) ? todo.createdAt.after(startDate.atStartOfDay()) : null;
    }

    private BooleanExpression todoCreateAtBefore(LocalDate endDate) {
        return Objects.nonNull(endDate) ? todo.createdAt.before(endDate.plusDays(1).atStartOfDay()) : null;
    }

    private BooleanExpression todoManagerNicknameContains(String nickname) {
        return StringUtils.isEmpty(nickname) ? null : todo.managers.any().user.nickname.contains(nickname);
    }

@QueryProjection

👉 Projections.constructor() 사용

findTodosQuery(Projections.constructor(TodoSearchResponse.class,
                todo.title,
                todo.managers.size(), // 일정 담당자 수
                todo.comments.size()), cond)

👉 @QueryProjection 사용

findTodosQuery(QTodoSearchResponse(
                todo.title,
                todo.managers.size(),  // 일정 담당자 수
                todo.comments.size()), cond)

BooleanExpression

  • QueryDSL에서 동적 쿼리를 작성할 때 조건을 표현하는 객체
  • 조건을 메서드로 분리하여 재사용 가능, 조건이 null 이면 자동으로 제외되어 가독성이 좋은 동적 쿼리 생성 가능

PageableExecutionUtils

  • getPage()
    • 데이터 리스트와 Pageable 정보를 기반으로 Page<T> 객체를 생성
    • 전체 개수를 구하는 countQuery가 꼭 실행될 필요가 없도록 최적화

👀 결과

11. Transaction 심화

  • 담당자(manager) 등록 요청 시 로그 생성
    • 로그 테이블 (log) 생성
    • 매니저 등록이 실패해도, 로그는 반드시 저장되어야 함
    • 로그 생성 시간은 반드시 필요

✏️ 코드 추가

▶︎ ManagerService

	@Transactional
    public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSaveRequest managerSaveRequest) {
        // 일정을 만든 유저
        try {
            User user = User.fromAuthUser(authUser);
            Todo todo = todoRepository.findById(todoId)
                    .orElseThrow(() -> new InvalidRequestException("Todo not found"));

            if (todo.getUser() == null || !ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
                throw new InvalidRequestException("담당자를 등록하려고 하는 유저가 유효하지 않거나, 일정을 만든 유저가 아닙니다.");
            }

            User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
                    .orElseThrow(() -> new InvalidRequestException("등록하려고 하는 담당자 유저가 존재하지 않습니다."));

            if (ObjectUtils.nullSafeEquals(user.getId(), managerUser.getId())) {
                throw new InvalidRequestException("일정 작성자는 본인을 담당자로 등록할 수 없습니다.");
            }

            Manager newManagerUser = new Manager(managerUser, todo);
            Manager savedManagerUser = managerRepository.save(newManagerUser);

            return new ManagerSaveResponse(
                    savedManagerUser.getId(),
                    new UserResponse(managerUser.getId(), managerUser.getEmail())
            );
        } finally {
            logService.saveLog(todoId, authUser.getId(), managerSaveRequest.getManagerUserId());
        }
    }

👉 담당자 등록 성공, 실패와 상관없이 LogService 호출

▶︎ LogService

@Service
@RequiredArgsConstructor
public class LogService {
    private final LogRepository logRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(Long postId, Long userId, Long managerId) {
        logRepository.save(new Log(postId, userId, managerId));
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)

  • 전파(Propagation) 방식을 설정할 때 사용
  • 메서드 실행 시 항상 새로운 트랜잭션을 생성하고, 기존 트랜잭션이 있더라도 보류시킨 후 독립적인 트랜잭션을 수행
    • 기존 트랜잭션과 분리되므로 성공 및 실패 여부가 서로 영향을 주지 않음
    • 로그 저장, 메일 발송, 외부 API 호출 등 독립적인 작업이 필요한 경우 유용

👀 결과

  • 🎬 시나리오
    • 유저 1로 로그인한 상태
    • Todo ID : 4 (생성 유저 : 2) 의 담당자를 유저 3으로 등록 시도
    • 일정을 만든 유저가 아니므로 Exception 발생 → 로그 생성

12. AWS 활용

12-1. EC2

  • EC2 인스턴스에서 어플리케이션 실행

  • 서버 접속 및 Live 상태를 확인할 수 있는 health check API: /health
    ▶︎ build.gradle
	// health check
    implementation 'org.springframework.boot:spring-boot-starter-actuator'

12-2. RDS

  • RDS 데이터베이스 구축 및 EC2 실행 어플리케이션 연결

12-3. S3

  • S3 버킷 생성 및 유저 프로필 이미지 업로드 및 관리 API

▶︎ build.gradle

	// AWS
    implementation 'io.awspring.cloud:spring-cloud-aws-starter:3.1.1'
    // S3
    implementation "software.amazon.awssdk:s3:2.13.0"

▶︎ S3Config

@Configuration
public class S3Config {

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public S3Client s3Client() {
        AwsCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
        return S3Client.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                .build();
    }
}

▶︎ S3Service

@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
    private final S3Client s3Client;

    @Getter
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    @Value("${cloud.aws.region.static}")
    private String region;

    /**
     * S3 이미지 업로드
     *
     * @param multipartFile
     * @return url
     */
    public String upload(MultipartFile multipartFile) {
        String fileName = UUID.randomUUID() + "_" + multipartFile.getOriginalFilename();

        List<String> allowedExtensions = Arrays.asList("jpg", "jpeg", "png", "gif");

        try {
            String originalFilename = multipartFile.getOriginalFilename();
            String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();

            if (!allowedExtensions.contains(fileExtension)) {
                throw new InvalidRequestException("파일 확장자 오류: Unsupported file format");
            }

            long maxSize = 1024 * 1024 * 5;
            if (multipartFile.getSize() > maxSize) {
                throw new InvalidRequestException("파일 크기 초과");
            }

            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(bucket)
                    .key(fileName)
                    .contentType(multipartFile.getContentType())
                    .contentLength(multipartFile.getSize())
                    .build();

            // S3에 파일 업로드
            s3Client.putObject(putObjectRequest, RequestBody.fromBytes(multipartFile.getBytes()));
        } catch (IOException e) {
            log.error(String.valueOf(e.getCause()));
            throw new ServerException("서버 오류: Failed to upload file");
        }

        return getPublicUrl(fileName);
    }

    /**
     * S3 이미지 삭제
     *
     * @param objectPath
     */
    public void delete(String objectPath) {
        try {
            DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
                    .bucket(bucket)
                    .key(objectPath)
                    .build();
            s3Client.deleteObject(deleteObjectRequest);
        } catch (SdkServiceException e) {
            log.error(String.valueOf(e.getCause()));
        }
    }

    private String getPublicUrl(String fileName) {
        return String.format("https://%s.s3.%s.amazonaws.com/%s", bucket, region, fileName);
    }
}

13. 대용량 데이터 처리

  • 테스트코드로 유저 데이터 100만건 생성
    - 데이터 생성 시 동일한 닉네임이 들어가지 않도록 구현

    - JDBCTemplate 을 사용하여 Bulk Insert 처리

    @Repository
      public class UserJdbcRepository {
      private final JdbcTemplate jdbcTemplate;
      private static final int BATCH_SIZE = 5000;
    
      public UserJdbcRepository(JdbcTemplate jdbcTemplate) {
          this.jdbcTemplate = jdbcTemplate;
      }
    
      public void batchInsert(String[] nicknames, String password) {
          String sql = """
                  INSERT INTO users (email, password, user_role, nickname, created_at, modified_at)
                          VALUES (?, ?, ?, ?, ?, ?)
                  """;
    
          for (int i = 0; i < nicknames.length; i += BATCH_SIZE) {
              String[] batch = Arrays.copyOfRange(nicknames, i, i + BATCH_SIZE);
    
              jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
                  @Override
                  public void setValues(PreparedStatement ps, int j) throws SQLException {
                      Timestamp timestamp = new Timestamp(System.currentTimeMillis() - new Random().nextLong(Integer.MAX_VALUE));
                      ps.setString(1, batch[j] + "@test.com");
                      ps.setString(2, password);
                      ps.setString(3, UserRole.ROLE_USER.name());
                      ps.setString(4, batch[j]);
                      ps.setTimestamp(5, timestamp);
                      ps.setTimestamp(6, timestamp);
                  }
    
                  @Override
                  public int getBatchSize() {
                      return batch.length;
                  }
              });
          }
      }
    }
      
  • 닉네임을 조건으로 유저 목록 검색 API

    • 닉네임은 정확히 일치해야 검색 가능
      ▶︎ UserService

      	
      public Page<UserResponse> findUsers(int page, int size, String nickname) {
          Pageable pageable = PageRequest.of(page - 1, size);
      
          Page<User> users = userRepository.findAllByNickname(pageable, nickname);
          return users.map(user -> new UserResponse(user.getId(), user.getEmail()));
      }
  • 조회속도 개선
    • 닉네임 INDEX 생성
CRAETE INDEX index_user_nickname ON users (nickname);

⏮️ 인덱스 생성 전 조회 속도

  • DB 조회 속도: 약 280ms

  • 응답 속도: 약 430ms

⏭️ 인덱스 생성 후 조회 속도

  • DB 조회 속도: 약 6ms

  • 응답 속도: 약 22ms

👀 결과

  • 인덱스 적용 후 DB 조회 속도 280ms → 6ms (46배)

  • 인덱스 적용 후 응답 속도: 430ms → 22ms (19배)

  • 인덱스를 적용하여 조회 속도를 효과적으로 개선할 수 있었다 → 조회 조건이 여러개라면 조건 중 가장 사용이 잦은 조건, 복합 인덱스 설정도 고려하자.

profile
안녕하세요 :)

0개의 댓글