▶︎ 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);
}
👉 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)
Page<T> 객체를 생성
▶︎ 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));
}
}





/health // health check
implementation 'org.springframework.boot:spring-boot-starter-actuator'




▶︎ 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);
}
}

테스트코드로 유저 데이터 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()));
}
CRAETE INDEX index_user_nickname ON users (nickname);







인덱스 적용 후 DB 조회 속도 280ms → 6ms (46배)
인덱스 적용 후 응답 속도: 430ms → 22ms (19배)
인덱스를 적용하여 조회 속도를 효과적으로 개선할 수 있었다 → 조회 조건이 여러개라면 조건 중 가장 사용이 잦은 조건, 복합 인덱스 설정도 고려하자.