Level 10
queryDSL 을 사용한 검색 기능
일정 검색하기
일단 List 로 받아서 pageImpl 로 넘겨줘야하는데 아래와 같은 오류 발생

Projections 쓰는 문법이 틀렸었다
constructor 안에 형태, 넣을값들 순서로 다 넣어줘야하기 때문에 select 부분을 아래와 같이 수정
.select(Projections.constructor(TodoSummaryResponse.class,
todo.contents,
todo.managers.size(),
todo.comments.size()))
-> count 가 제대로 출력이 안 된다...
List<TodoSummaryResponse> contents = factory
.select(Projections.constructor(
TodoSummaryResponse.class,
todo.title,
JPAExpressions
.select(manager.count())
.from(manager)
.where(manager.todo.eq(todo)),
JPAExpressions
.select(comment.count())
.from(comment)
.where(comment.todo.eq(todo))
))
.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(manager.user, user)
.where(builder)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.distinct().fetch();
count 를 셀 때 ArrayList 의 size 를 계산할 수 없다고 함 -> JPAExpressions 를 사용해야함
builder 를 사용해서 구현, 조건이 4개였는데 첫번째꺼가 좀 어려웠다 (ai 와 함께했어요...)
# 매니저들 중 한 명이라도 키워드가 포함되는지
if (StringUtils.hasText(request.getManagerName())) {
builder.and(todo.managers.any().user.nickname.contains(request.getManagerName()));
}
# 제목 일치
if (request.getTitle() != null && !request.getTitle().isBlank()){
builder.and(todo.title.eq(request.getTitle()));
}
# 생성일 시작점
if (request.getStartCreated() != null){
builder.and(todo.modifiedAt.goe(request.getStartCreated()));
}
# 생성일 종료점
if (request.getEndCreated() != null){
builder.and(todo.modifiedAt.loe(request.getEndCreated()));
}
total count 계산해주고, pageImpl 로 넘겨주고 구현

Level 11
# level 11 : 매니저 등록 요청 시 로그 기록
# transaction option 을 활용해서 save manager 와 logging 이 개별로 이루어질 수 있도록 하기
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Long saveInitialLog(Long loginUserId, Long todoId, Long managerId){
ManagerLog log = new ManagerLog(loginUserId, todoId, managerId);
ManagerLog savedLog = managerLogRepository.save(log);
return savedLog.getId();
}
# 해당 요청이 성공했는지 실패했는지,,, 결과값도 저장
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveResultLog(Long logId, boolean status, String message){
ManagerLog log = managerLogRepository.findById(logId).orElseThrow();
log.addResult(status, message);
}
https://kjw81024.tistory.com/79
@Transactional 트러블슈팅
일단 Transactional option 이 뭔지 잘 모르겠어서 찾아봤다더보기참고자료 https://hstory0208.tistory.com/entry/Spring-Transactional-%EC%98%B5%EC%85%98-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%A0%84%ED
kjw81024.tistory.com
트러블 슈팅이 생겼다.... 백년만인듯 (과장)
Level 13
진짜 죽어도 배포 하기 싫어서 13번부터 풀었다 ㅋㅋ ㅎㅎ
아래처럼 구분해서 test1 vs test2 비교
test 3, 4 5 비교하면 될 듯 하다
@SpringBootTest
public class MassDataProcessingTest {
@Test
@DisplayName("test1: 1번 조회 - 아무런 추가 없음")
void test_case01(){}
@Test
@DisplayName("test2: 1번 조회 - nickname 에 idx 추가")
void test_case02(){}
@Test
@DisplayName("test3: 여러번 조회 - 아무런 추가 없음")
void test_case03(){}
@Test
@DisplayName("test4: 여러번 조회 - nickname 에 idx 추가")
void test_case04(){}
@Test
@DisplayName("test5: 여러번 조회 - nickname 에 idx 추가, 캐싱")
void test_case05(){}
}
일단 유저 데이터 500만건을 생성 - > JDBC 를 활용해서 Bulk Insert 를 진행한다
@Autowired
private JdbcTemplate jdbcTemplate;
JDBC 랑 JPA 차이를 모르겠어서 정리해두기
| JPA | JDBC Template |
| 메모리 부하가 크고 배치가 까다로움 | 메모리 효율이 좋고 Batch 처리에 최적화 |
| 안정적이지만 느림 | 빠름 |
직접 SQL 문을 작성해서 보내는 형태
매번 500만건을 만들어주긴 너무 오래걸릴 거 같아서 일단 @BeforeAll 사용
@BeforeAll
void initData() {
# 테이블 초기화 및 스키마 설정
jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); # foriegnkey 제약 풀어주기
# 풀지 않으면 drop table 할 때 오류가 발생!
jdbcTemplate.execute("DROP TABLE IF EXISTS users");
jdbcTemplate.execute("CREATE TABLE users (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"nickname VARCHAR(255))");
# 일정 시간이 지났을 때 한 번에 업데이트
for (int i = 0; i < COUNT / BATCH_SIZE; i++) {
final int offset = i * BATCH_SIZE;
String sql = "INSERT INTO users (nickname) VALUES (?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setString(1, "user_" + (offset + i));
}
@Override
public int getBatchSize() {
return BATCH_SIZE;
}
});
}
}
@AfterAll
void clean() {
jdbcTemplate.execute("TRUNCATE TABLE users");
}
기본적인 SQL 문은 아래를 따랐으며, cache Service class 를 따로 생성하여 성능을 확인하기 위해 test 1, 2 는 Test class 에 작성하고 3, 4, 5 는 Service 계층에 함수를 작성하고 호출하는 형식으로 진행
String sql = "SELECT * FROM users WHERE nickname = ? LIMIT 1";
idx 를 생성해주는 로직을 test 2, 4, 5에 추가하고
idx 를 모두 생성한 이후 시간 측정을 시작해서 idx 를 시작하는 시간이 결과값에 영향을 미치지 않도록 설정했다
jdbcTemplate.execute("CREATE INDEX idx_nickname ON users(nickname)");
캐싱은 서비스를 분리하고 테스트코드 위에 enable caching annotation 을 추가하여 실험
@EnableCaching
@Service
public class UserCacheService {
@Autowired
private JdbcTemplate jdbcTemplate;
// "userCache"라는 이름의 저장소에 결과를 저장.
// 동일한 nickname으로 호출되면 DB에 안 가고 캐시된 result를 바로 반환!
@Cacheable(value = "userCache", key = "#nickname")
public List<Map<String, Object>> getUserByNickname(String nickname) {
String sql = "SELECT * FROM users WHERE nickname = ? LIMIT 1";
return jdbcTemplate.queryForList(sql, nickname);
}
}


test 1 vs test 2
| 인덱스 미사용 | 인덱스 사용 |
| 1084 ms | 4 ms |
한 번만 조회하는건 어차피 캐시의 성능을 확인할 수 없기 때문에 cache + idx는 단일 조회에서는 제외했습니다
여러건 조회 (같은 값을 반복해서 조회) 로 넘어갔다
인덱스가 존재하지 않는 경우에 대해서는 차이가 명확히 드러남



내 생각대로라면 caching 까지 더하면 결과가 더 좋게 나와야할 거 같은데 이상하게 소요시간이 더 늘어났다
다른 service 를 호출하고 가져오는 시간에서 더 오래걸리는가 해서 test 3, 4도 service 를 거친 후 값을 가져올 수 있도록 수정했지만 속도가 비슷 -> ai 로 검색해봤다
| Cacheable 이 캐시에 데이터가 있는지 확인하고 결과 저장하는 로직이 추가됨 자바 객체를 저장하기 위해 직렬화 하는 과정이 추가됨 -> 인덱스 최적화가 너무 잘 되어있으면 캐시 관리에 비용이 더 많이 드는 경우가 생길 수 있다 |
그래도 캐싱이 어떻게 잘 작동하는지 확인하고 싶어서 cnt 를 100까지 늘려봤다 (조회 횟수 증가시키기)


표는 아래와 같다
| 기본 (Full scan) | idx | idx + caching | |
| 1번 조회 | 1084 ms | 4 ms | (실험 불필요) |
| 5번 조회 | 5576 ms | 8 ms | 32 ms |
| 100번 조회 | (실험 안 함: 아마 1000ms * 100) | 98 ms | 28 ms |
정리하자면
1) Full scan (기본) 대비 idx 가 약 250~700배 빠른 것을 확인 가능
2) caching 의 성능을 확인하기 위해 5번을 조회했을때 (소량 조회 시) 캐싱이 더 느림
-> 캐시 오버헤드 때문: 저장로직, AOP 프록시 거치는 과정에서 손해를 보게됨
3) 대량 조회 시 캐시가 인덱스보다 3.5배 빨라짐
idx 만 사용한 경우 소량(5회) -> 대량(5*20배) 비율을 완전히 따르지 않았지만 12.25배 증가한 시간이 소요되었고
idx 와 캐싱을 함께 사용한 경우 성능이 유지되는 모습을 확인할 수 있었다
Level 12
EC2, S3, RDS 설정 캡쳐

22번 포트를 내 ip 만 접속할 수 있도록 수정하면 좋을 것 같다



| actuator/health api 사용 가능 |

user 가 본인의 image 를 추가할 수 있도록 로직을 추가
@PutMapping("/users/image")
public String updateImage(@AuthenticationPrincipal AuthUser authUser, @RequestParam MultipartFile file){
return userService.updateImage(authUser.getId(), file);
}
@Transactional
public String updateImage(Long userId, MultipartFile file) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new InvalidRequestException("User not found"));
String imageUrl;
try{
String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename();
S3Resource resource = s3Template.upload(bucketName, fileName, file.getInputStream());
imageUrl = resource.getURL().toString();
}catch (IOException e){
throw new RuntimeException("file upload failed");
}
user.updateProfileImage(imageUrl);
return user.getUrl();
}



'SPARTA 과제 > SPRING' 카테고리의 다른 글
| K6 테스트 (0) | 2026.04.29 |
|---|---|
| JOOQ 코드 생성 트러블슈팅 (1) | 2026.04.13 |
| Plus spring 필수 과제 (0) | 2026.04.03 |
| 심화) LV 7 Test code (1) | 2026.03.07 |
| 심화) LV 6 코드 리팩토링 (0) | 2026.03.07 |