| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 백엔드
- 백엔드 회원가입 구현
- springboot
- docker
- B+Tree
- Slice Test
- Integration Test
- performance test
- SelfHostedRunner
- rest api
- SolveGO
- branch protection
- 테스트 코드
- ERD설게
- 틀린문제조회
- Service Layer
- AWS
- JPA
- 책임분리
- EC2
- MySQL
- 제2종오류
- backend
- JWT
- Spring Security
- CI
- 제1종오류
- query optimization
- 백엔드 로그인 구현
- Spring Boot
- Today
- Total
노트
[SolveGO #8] 기하로 푸는 N+1 문제와 DB 인덱스 - 문제 목록 조회 API 구현 본문

이 글은 SolveGO의 4번째 UseCase인 문제 목록 조회 API를 다룬다.
API 상세 설계는 이미 이전 단계에서 완료해두었고, 단순 CRUD 구현 역시 이전 포스팅에서 충분히 다루었다.
따라서 반복되는 부분에 대한 설명은 최소화하였다.
대신 문제 목록 조회 API를 구현하는 과정에서 새롭게 마주한 문제에 집중한다.
겉으로는 단순한 목록 조회처럼 보이지만, 실제 구현 과정에서는 N+1 문제와 DB 인덱스처럼 성능과 관련된 고민이 필요했다.
이번 글에서는 먼저 문제 목록 조회 API의 역할과 호출 흐름을 정리한 뒤, 구현 과정을 간단히 살펴본다.
이후 별도의 인사이트 섹션에서 N+1 문제와 인덱스를 기하적인 관점으로 해석한다.
글의 구조는 다음과 같다.
1. 문제 정의
2. 문제 해결 방법 설계
3. 구현 과정
4. 테스트
5. 인사이트 (N+1 문제와 DB 인덱스)
문제 정의
문제 목록 조회는 사용자가 등록된 문제들을 탐색하기 위한 기능이다.
초기 MVP 단계에서는 등록되는 문제 수가 많지 않다고 가정했기 때문에, 별도의 페이징 기능은 제외하였다.
따라서 이번 단계에서 구현할 API는 전체 문제 목록을 조회하고, 각 문제의 기본 정보를 응답하는 단순한 목록 조회 API이다.
이전에 설계한 API 명세는 다음과 같다.
UC-04 문제 목록 조회
Scenario
- 사용자는 문제 목록 페이지에 접근한다.
- 시스템은 등록된 문제 목록을 최신순으로 조회한다.
- 시스템은 문제 목록을 사용자에게 보여준다.
API 명세
- 관여 도메인 : Problem,User
- Action : Read
- Method : GET
- API URL : /api/problems
- 인증 필요 여부 : X
- 정렬 기준 : 최신 등록순
Response Body
{
"problems": [
{
"problemId": 1,
"title": "화점 사활 문제",
"creatorName": "username"
},
{
"problemId": 2,
"title": "귀 사활 문제",
"creatorName": "badukUser"
}
]
}
Status Code
- 200 OK : 문제 목록 조회 성공
[SolveGO #2] 유죄추정의 원칙 - API 설계
서론 우리나라 헌법에는 무죄추정의 원칙이 있다.이는 유죄가 확정되기 전까지 모든 사람을 무죄로 간주한다는 원칙이다.왜 이런 원칙이 존재할까? 그 이유는 아래 두 종류의 오류 중 하나를 더
forwarder1121.tistory.com
겉으로 보면 이 API는 단순히 Problem 목록을 조회하는 CRUD API처럼 보인다.
하지만 응답에는 문제의 제목뿐만 아니라 작성자의 이름인 creatorName도 포함되어 있다.
즉, 문제 목록 조회 API는 Problem만 조회하는 것이 아니라, 각 문제를 작성한 User 정보까지 함께 필요로 한다.
이 지점에서 단순 목록 조회처럼 보였던 API가 실제 구현에서는 N+1 문제와 연결될 수 있다.
문제 해결 방법 설계
API 호출 시퀀스 다이어그램은 아래와 같다.

인증 필요 여부
문제 목록 조회 API는 인증이 필요하지 않은 공개 API로 설계했다.
따라서 사용자가 로그인하지 않은 상태에서도 문제 목록 페이지에 접근할 수 있어야 한다.
API 호출 흐름
클라이언트는 GET /api/problems 요청을 보내고, 서버는 등록된 문제 목록을 최신순으로 조회하여 응답한다.
응답에는 각 문제의 problemId, title, creatorName이 포함된다.
여기서 주목할 점은 문제 목록 조회가 단순히 Problem 정보만 반환하는 API가 아니라는 점이다.
화면에는 문제의 제목뿐만 아니라 해당 문제를 등록한 작성자의 이름도 함께 보여주어야 한다.
따라서 서버는 문제 목록을 조회하는 과정에서 다음 정보를 함께 준비해야 한다.
- 문제 ID
- 문제 제목
- 작성자 이름
이 흐름 자체는 일반적인 목록 조회 API와 크게 다르지 않다.
하지만 응답에 작성자 이름이 포함되기 때문에, 구현 과정에서는 Problem과 User의 관계를 어떻게 조회할 것인지 고려해야 한다.
이 지점에서 단순 CRUD 구현만으로는 놓치기 쉬운 N+1 문제가 발생할 수 있다.
또한 문제 목록을 최신순으로 보여주어야 하므로, 데이터가 많아졌을 때 정렬 성능을 어떻게 유지할 것인지에 대한 고민도 필요하다.
이 두 문제는 구현 과정 이후의 인사이트 섹션에서 따로 다룬다.
구현 과정
구현은 바텀업 방식으로 DTO → Repository → Service → Controller → Security Config 순서로 진행했다.
DTO
문제 목록 조회 API는 GET /api/problems 요청을 사용한다.
요청 과정에서 별도의 Request Body를 전달하지 않으므로, 이번 API에서는 Request DTO를 따로 만들지 않았다.
반면 응답에는 여러 문제의 요약 정보가 포함되어야 한다.
따라서 먼저 하나의 문제 정보를 표현하는 ProblemSummaryResponse를 만들고,
최종 응답 DTO인 ProblemListResponse가 이 목록을 감싸도록 설계하였다.
package com.kdh.solvego.domain.problem.dto;
import com.kdh.solvego.domain.problem.Problem;
public record ProblemSummaryResponse(
Long problemId,
String title,
String creatorName
) {
public static ProblemSummaryResponse from(Problem problem) {
return new ProblemSummaryResponse(
problem.getId(),
problem.getTitle(),
problem.getCreator().getUsername()
);
}
}
위 ProblemSummaryResponse는 문제 목록 화면에서 하나의 문제를 표현하기 위한 DTO이다.
여기에는 문제의 ID, 제목, 작성자 이름만 포함된다.
문제의 상세 내용이나 정답 좌표는 포함하지 않는다.
package com.kdh.solvego.domain.problem.dto;
import com.kdh.solvego.domain.problem.Problem;
import java.util.List;
public record ProblemListResponse(
List<ProblemSummaryResponse> problems
) {
public static ProblemListResponse from(List<Problem> problems) {
return new ProblemListResponse(
problems.stream()
.map(ProblemSummaryResponse::from)
.toList()
);
}
}
ProblemListResponse는 여러 개의 ProblemSummaryResponse를 하나로 감싸는 최종 응답 DTO이다.
다만 여기서 주의할 점이 있다.
ProblemSummaryResponse.from() 내부에서는 작성자 이름을 만들기 위해 다음 코드를 사용한다.
problem.getCreator().getUsername()
즉, 문제 목록 응답을 만들기 위해 Problem뿐만 아니라 각 문제의 작성자인 User 정보도 필요하다.
이 부분은 겉으로 보기에는 단순한 필드 접근처럼 보이지만, creator가 지연 로딩으로 설정되어 문제 목록을 DTO로 변환하는 과정에서 추가 쿼리가 발생할 수 있다.
이 지점이 뒤에서 다룰 N+1 문제와 연결된다.
Repository
package com.kdh.solvego.domain.problem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface ProblemRepository extends JpaRepository<Problem, Long> {
@Query("""
select p
from Problem p
join fetch p.creator
order by p.createdAt desc
""")
List<Problem> findAllWithCreatorOrderByCreatedAtDesc();
}
Repository는 Service가 사용할 문제 목록 조회 메서드를 제공해야 한다.
API는 문제 목록을 최신순으로 조회해야 하며, 응답에는 각 문제의 작성자 이름도 포함되어야 한다.
최신순 조회 자체는 Spring Data JPA의 메서드 이름 규칙만으로도 구현할 수 있다.
List<Problem> findAllByOrderByCreatedAtDesc();
하지만 이 방식은 Problem 목록을 최신순으로 조회할 뿐, 각 문제의 작성자인 User까지 함께 조회하는 것은 아니다.
따라서 이후 DTO 변환 과정에서 다음 코드가 실행되면,
problem.getCreator().getUsername()
현재 creator는 FetchType.LAZY로 설정되어 있기 때문에, 별도의 fetch join 없이 문제 목록을 조회하면 작성자 정보는 함께 로딩되지 않는다.
이후 DTO 변환 과정에서 creatorName을 만들기 위해 problem.getCreator().getUsername()을 호출하는 순간,
작성자 조회 쿼리가 추가로 발생한다.
이를 방지하기 위하여 JPQL을 직접 작성하고, fetch join을 사용하여 문제와 작성자를 한 번에 조회하도록 하였다.
@Query("""
select p
from Problem p
join fetch p.creator
order by p.createdAt desc
""")
List<Problem> findAllWithCreatorOrderByCreatedAtDesc();
이렇게 하면 문제 목록을 최신순으로 조회하면서도, 각 문제의 작성자 정보를 함께 가져올 수 있다.
즉, 단순히 조회 메서드를 직접 정의한 것이 아니라 fetch join을 사용하여 문제와 작성자를 함께 조회함으로써 N+1 문제를 방지한 것이다.
N+1 문제와 fetch join에 대한 자세한 내용은 뒤의 인사이트 섹션에서 따로 다룬다.
또한 문제 목록은 최신순으로 조회되어야 한다.
인덱스가 없다면 DB는 조건에 맞는 데이터를 읽은 뒤, 최신순으로 정렬하는 작업을 수행해야 한다.
일반적인 정렬 비용은 O(N log N)에 가깝다. 이를 O(N)으로 줄이는 방식인 DB Index 역시 뒤에서 다룰 예정이다.
Service
package com.kdh.solvego.domain.problem;
@Service
public class ProblemService {
// other fields and constructor omitted
@Transactional(readOnly = true)
public ProblemListResponse getProblems(){
List<Problem> problems = problemRepository.findAllWithCreatorOrderByCreatedAtDesc();
return ProblemListResponse.from(problems);
}
}
Service는 문제 목록 조회 유스케이스의 흐름을 담당한다.
getProblems() 메서드는 Repository를 통해 최신순으로 정렬된 문제 목록을 조회한 뒤, 이를 응답 DTO인 ProblemListResponse로 변환하여 반환한다.
이때 실제 DTO 변환 로직은 ProblemListResponse.from() 내부에 두었고, Service는 조회 결과를 응답 형태로 조립하는 역할을 한다.
또한 이 API는 데이터를 변경하지 않는 조회 기능이므로, @Transactional(readOnly = true)를 사용하여 읽기 전용 트랜잭션으로 동작하도록 하였다.
Spring Data JPA 공식 문서에 따르면, Hibernate와 함께 사용할 경우 readOnly 트랜잭션에서는 flush mode가 MANUAL로 설정되고, 이로 인해 Hibernate가 dirty checking을 건너뛸 수 있다.
따라서 조회 전용 API에서 불필요한 변경 감지 비용을 줄이는 데 도움이 된다.

Transactionality :: Spring Data JPA
Example 3. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void delet
docs.spring.io
Controller
package com.kdh.solvego.domain.problem;
@RestController
@RequestMapping("/api/problems")
public class ProblemController {
// other fields and constructor omitted
@GetMapping
public ProblemListResponse getProblems() {
return problemService.getProblems();
}
}
Controller는 HTTP 요청과 응답의 흐름을 담당한다.
클라이언트가 GET /api/problems 요청을 보내면, DispatcherServlet은 요청 URL과 HTTP Method를 기준으로 이를 처리할 수 있는 Controller 메서드를 찾는다. 이 요청은 /api/problems 경로에 대한 GET 요청이므로, ProblemController의 getProblems() 메서드가 호출된다.
Controller는 요청을 받은 뒤 상세한 비즈니스 로직을 직접 처리하지 않는다.
문제 목록을 어떤 방식으로 조회할지, 어떤 Repository 메서드를 사용할지, 조회 결과를 어떻게 응답 형태로 만들지는 Service에게 위임한다.
따라서 ProblemController는 problemService.getProblems()를 호출하고, Service가 반환한 ProblemListResponse를 그대로 클라이언트에게 응답한다.
이때 @RestController가 사용되었기 때문에, 반환된 객체는 View 이름으로 해석되지 않고 HTTP Response Body에 담긴다.
Spring MVC는 ProblemListResponse 객체를 JSON 응답으로 변환하여 클라이언트에게 반환하며, 이 객체를 JSON으로 변환하는 과정은 HttpMessageConverter가 담당한다.
별도의 상태 코드를 지정하지 않았기 때문에, 정상적으로 메서드가 반환되면 Spring MVC는 기본적으로 200 OK 응답을 반환한다.
Security Config
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter
) throws Exception {
http
// other security settings omitted
.authorizeHttpRequests(auth -> auth
// other public endpoints omitted
.requestMatchers(HttpMethod.GET, "/api/problems").permitAll()
// other authorization rules omitted
)
// JWT filter configuration omitted
;
return http.build();
}
}
문제 목록 조회 API는 인증이 필요 없는 공개 API로 설계했기 때문에, Security 설정에서도 해당 요청을 허용해야 한다.
따라서 GET /api/problems 요청은 permitAll()로 설정하였다.
이 설정을 통해 JWT 토큰을 가지고 있지 않은 사용자도 문제 목록 조회 API에 접근할 수 있다.
문제 목록 조회 API는 문제의 ID, 제목, 작성자 이름만 제공하는바,
사용자의 개인정보나 정답 좌표처럼 보호해야 할 정보는 포함하지 않으므로 공개 API로 제공해도 문제가 없다고 판단하였다.
참고로 현재 JwtAuthenticationFilter 구현에서는 위조된 JWT 토큰이 포함된 요청이 들어와도 즉시 401 응답을 반환하지 않는다.
대신 인증 정보를 SecurityContext에 저장하지 않고 다음 필터로 요청을 넘긴다.
따라서 permitAll()로 열려 있는 문제 목록 조회 API는 위조된 JWT 토큰이 포함된 경우에도 정상적으로 호출할 수 있다.
테스트
마지막으로 Security 설정이 의도한 대로 동작하는지 확인하였다.
이번 API에서 기대하는 동작은 다음과 같다.
GET /api/problems → 인증 불필요
POST /api/problems → 인증 필요
로그인하지 않은 상태, 즉 JWT 토큰을 포함하지 않은 상태에서 GET /api/problems 요청을 보내면 문제 목록 조회 API는 정상적으로 응답을 반환해야 한다.
반면 문제 등록 API인 POST /api/problems는 인증이 필요한 API이므로, JWT 토큰 없이 요청할 경우 401 Unauthorized 응답이 반환되어야 한다.


테스트 결과, GET /api/problems 요청은 API 명세와 일치하는 응답을 정상적으로 반환하였다.
반대로 POST /api/problems 요청은 JWT 토큰이 없는 상태에서 401 Unauthorized 응답을 반환하였다.
이를 통해 문제 목록 조회 API는 공개 API로 접근 가능하고, 문제 등록 API는 인증이 필요한 보호 API로 동작함을 확인할 수 있었다.
인사이트
지금까지 문제 목록 조회 API의 구현 과정을 살펴보았다.
겉으로 보면 단순한 목록 조회 API지만, 실제 구현 과정에서는 두 가지 중요한 성능 이슈를 마주하게 됐다.
첫 번째는 creatorName을 응답하기 위해 Problem과 User의 관계를 조회하는 과정에서 발생할 수 있는 N+1 문제이다.
두 번째는 문제 목록을 최신순으로 제공하기 위해 필요한 정렬 비용과 DB 인덱스 문제이다.
이제부터 이 두 가지 문제를 조금 더 자세히 살펴보자.
N+1 문제
먼저 문제 목록 조회 API가 어떤 데이터를 필요로 하는지 다시 확인해보자.
문제 목록 응답에는 problemId, title, creatorName이 포함된다.
problemId와 title은 Problem이 가지고 있는 정보이다.
하지만 creatorName은 Problem 자체의 정보가 아니라, 해당 문제를 작성한 User의 정보이다.

ERD를 보면 Problem과 User는 다음 관계로 연결되어 있다.
problems.creator_id → users.id
즉, 문제 목록 조회 API는 단순히 Problem 목록만 조회하는 기능이 아니다.
응답을 완성하기 위해서는 각 Problem이 가리키는 User 정보까지 함께 필요하다.
여기서 N+1 문제가 발생할 수 있다.
현재 Problem 엔티티에서 작성자인 creator는 지연 로딩으로 설정되어 있다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "creator_id", nullable = false)
private User creator;
따라서 Problem 목록만 먼저 조회하면, 각 문제의 작성자 정보는 아직 함께 로딩되지 않는다.
이후 DTO 변환 과정에서 다음 코드가 실행되면,
problem.getCreator().getUsername()
Hibernate는 그때서야 해당 Problem의 작성자인 User를 조회한다.
문제가 N개라면 흐름은 다음과 같아진다.
1번: Problem 목록 조회
N번: 각 Problem의 creator 조회
즉, 전체 쿼리 수는 1 + N번이 된다.
이처럼 하나의 목록 조회 이후, 연관된 데이터를 가져오기 위해 N번의 추가 쿼리가 발생하는 문제를 흔히 N+1 문제라고 부른다.
하지만 N+1 문제의 본질은 단순히 필요한 데이터의 양이 많다는 것이 아니다.
문제 목록 응답에 creatorName이 필요하다면 작성자 정보를 가져오는 일 자체는 피할 수 없다.
문제는 이 데이터를 한 번에 가져오지 않고, 문제마다 다시 DB에 접근해서 가져온다는 점이다.
N+1 문제를 본질적으로 이해하기 위해서는 몇 가지 관점을 먼저 정리할 필요가 있다.
첫째, 애플리케이션 내부 메모리 접근과 DB 접근은 비용이 다르다.
서버 애플리케이션 입장에서 가장 가까운 것은 현재 프로세스 안의 메모리이다. 반면 DB는 애플리케이션 외부에 존재하는 별도의 시스템이다. 따라서 DB에 접근한다는 것은 단순한 메모리 접근이 아니라, 네트워크 왕복, SQL 실행, 결과 반환, 객체 매핑 등의 과정을 포함한다.
즉, DB 접근은 상대적으로 레이턴시가 큰 작업이다.
둘째, N+1 문제는 데이터의 양을 줄이는 문제가 아니다.
문제 목록 응답을 만들기 위해서는 문제마다 problemId, title, creatorName이 필요하다. 이 사실은 변하지 않는다.
문제는 이 데이터를 한 번에 가져오느냐, 아니면 문제마다 다시 DB에 접근해서 가져오느냐에 있다.
즉, N+1 문제의 핵심은 필요한 데이터를 조회한다는 사실 자체가 아니라, 필요한 데이터를 너무 잘게 나누어 여러 번 조회한다는 점이다.

셋째, 데이터를 공간의 점으로 해석할 수 있다.
Problem row 하나는 다음과 같은 좌표를 가지는 하나의 점으로 볼 수 있다.
Problem 점 = (problemId, title, creatorId, createdAt, ...)
마찬가지로 User row 하나도 하나의 점으로 볼 수 있다.
User 점 = (userId, username, createdAt, ...)
문제 목록 조회 API가 원하는 응답은 단순히 Problem 공간의 점만으로는 만들 수 없다.
응답에는 creatorName이 필요하므로, Problem 공간의 점은 자신이 가리키는 User 공간의 점과 연결되어야 한다.
N+1 문제는 이 연결을 집합 단위로 한 번에 처리하지 않고, Problem 점 하나마다 다시 User 공간으로 이동하면서 발생한다.
즉, N+1 문제는 집합 단위로 처리해야 할 관계 탐색을 점 단위 탐색으로 쪼개서 반복하는 문제라고 볼 수 있다.
반면 fetch join은 처음부터 Problem 공간과 User 공간을 한 번에 연결한다.
@Query("""
select p
from Problem p
join fetch p.creator
order by p.createdAt desc
""")
List<Problem> findAllWithCreatorOrderByCreatedAtDesc();
이렇게 하면 Problem과 User를 한 번의 쿼리로 함께 조회하고, Hibernate는 조회된 엔티티를 서버 메모리안의 영속성 컨텍스트에 저장한다.
따라서 이후 DTO 변환 과정에서 problem.getCreator().getUsername()을 호출하더라도 추가 쿼리가 발생하지 않는다.
정리하면 다음과 같다.
fetch join 미사용
→ Problem 목록 조회 1번
→ 각 Problem의 creator 조회 N번
→ 총 1 + N번의 쿼리 발생 가능
fetch join 사용
→ Problem과 creator를 함께 조회
→ 총 1번의 쿼리로 필요한 데이터 준비
이번 API에서는 목록 응답에 creatorName이 반드시 필요했기 때문에, Problem과 User를 함께 조회하는 fetch join을 사용하여 N+1 문제를 방지하였다.
DB 인덱스 문제
N+1 문제가 Problem과 User의 관계 조회에서 발생한 문제였다면,
DB 인덱스 문제는 문제 목록을 최신순으로 정렬하는 과정에서 발생할 수 있는 문제다.
문제 목록 조회 API는 등록된 문제들을 최신순으로 보여주어야 한다.
구현 코드에서도 이를 반영하기 위해 다음과 같이 createdAt 기준 내림차순 정렬을 사용하였다.
@Query("""
select p
from Problem p
join fetch p.creator
order by p.createdAt desc
""")
List<Problem> findAllWithCreatorOrderByCreatedAtDesc();
여기서 핵심은 order by p.createdAt desc이다.
앞에서 데이터를 공간의 점으로 해석할 수 있다고 했다.
따라서 Problem row 하나는 다음과 같은 좌표를 가지는 하나의 점으로 볼 수 있다.
Problem 점 = (problemId, title, creatorId, createdAt, ...)
이 관점에서 보면 createdAt은 Problem 공간을 구성하는 하나의 좌표축이다.
문제 목록을 최신순으로 보여준다는 것은 결국 Problem 공간에 흩어져 있는 점들을 createdAt 축을 기준으로 정렬하는 작업이다.
인덱스가 없다면 DB는 문제 데이터를 읽은 뒤, 매번 createdAt 기준으로 정렬해야 한다. 일반적인 정렬 비용은 O(N log N)에 가깝다.
반면 createdAt에 인덱스를 만든다는 것은, 기존의 createdAt 좌표축을 기준으로 정렬된 보조 축을 하나 더 만들어두는 것에 가깝다.
즉, 인덱스는 이미 존재하는 createdAt 값을 기준으로, 탐색하기 좋은 별도의 정렬 구조(B+Tree)를 만들어두는 것이다.
기하적으로 표현하면 다음과 같다.
원본 Problem 공간
= (problemId, title, creatorId, createdAt, ...)
createdAt 인덱스
= createdAt 축을 기준으로 정렬된 보조 축
= 각 항목은 원본 Problem 점을 가리킴
따라서 최신순 조회가 필요할 때 DB는 매번 전체 Problem 점들을 다시 정렬하지 않아도 된다.
이미 createdAt 기준으로 정렬된 보조 축을 따라가면서 필요한 데이터를 읽을 수 있기 때문이다.
전체 목록을 모두 조회하는 경우에는 결국 N개의 데이터를 반환해야 하므로 O(N)의 읽기 비용은 피할 수 없다.
하지만 인덱스를 사용하면 매번 발생하던 O(N log N)에 가까운 정렬 비용을 줄이고, 정렬된 인덱스 순서대로 데이터를 읽는 O(N)에 가까운 방식으로 동작할 수 있다.
특히 이후 페이징이 추가된다면 인덱스의 효과는 더 커진다.
예를 들어 최신 문제 20개만 조회한다고 해보자.
select *
from problems
order by created_at desc
limit 20;
인덱스가 없다면 DB는 전체 문제 데이터를 읽고 정렬한 뒤, 그중 상위 20개를 선택해야 할 수 있다.
반면 created_at 인덱스가 있다면, DB는 createdAt 기준으로 정렬된 보조 축에서 최신 방향으로 20개만 읽으면 된다.
이 경우 O(NlogN) -> O(log N + K) (여기서 log N은 B+Tree에서 시작 위치를 찾기 위한 탐색 비용이고, K는 실제로 가져올 데이터 개수 )
즉, 인덱스는 단순히 데이터를 빠르게 찾기 위한 장치일 뿐만 아니라, 이번 API에서는 최신순 정렬 비용을 줄이기 위한 장치이기도 하다.
이를 적용한다면 다음과 같이 created_at 컬럼에 인덱스를 둘 수 있다.
CREATE INDEX idx_problems_created_at
ON problems (created_at);
JPA 엔티티에 정의한다면 다음과 같이 표현할 수 있다.
@Table(
name = "problems",
indexes = {
@Index(name = "idx_problems_created_at", columnList = "created_at")
}
)
public class Problem {
// ...
}
다만 이번 단계에서는 아직 created_at 인덱스를 실제로 적용하지 않았다.
현재 MVP에서는 문제 수가 많지 않고, 페이징 기능도 아직 구현하지 않았기 때문에,
인덱스 적용에 따른 성능 차이를 의미 있게 측정하기 어렵다고 판단하였다.
따라서 실제 인덱스 적용은 페이징 기능을 구현한 이후 진행할 예정이다.
문제 수를 충분히 늘린 상태에서 인덱스 적용 전후의 실행 계획과 응답 시간을 비교하고,
created_at 인덱스가 문제 목록 조회 성능에 어떤 영향을 주는지 실험해볼 계획이다.
끝!
'Project > SolveGO' 카테고리의 다른 글
| [SolveGO #10] 심장을 꽂다 - 문제 풀이 API 구현 (0) | 2026.06.29 |
|---|---|
| [SolveGO #9] 책임 분리는 다이어트와 같다 - 문제 상세 조회 API 구현 (0) | 2026.06.27 |
| [SolveGO #7] 문제가 문제요 - 문제 등록 API 구현과 ERROR Dispatch 트러블슈팅 (0) | 2026.06.25 |
| [SolveGO #6] Crush 잊어버리지마 - JWT 토큰 발급, 검증 구현 (0) | 2026.06.23 |
| [SolveGO #5] 내 누군지 아니? - 로그인 인증 구현 (0) | 2026.06.22 |