본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.



패스트캠퍼스의 '9개 도메인 프로젝트로 끝내는 백엔드 웹 개발 (Java/Spring) 초격차 패키지 Online' 강의 수강 33일차!
오늘은 커뮤니티 피드 서비스의 admin 대시보드 기능 구현 작업을 진행하였다.
select date(cu.reg_dt) as date, count(*) as dailyUserCount
from community_feed.community_user cu
group by date(cu.reg_dt)
order by date;
이 쿼리는 일별 유저 가입 수를 조회하는 쿼리이다.
강사님께서 위 쿼리의 실행계획을 확인하면 어떤 type이 나올지에 대한 질문을 하셨다.
community_user 테이블의 index는 primary key밖에 없는데 조회하고자 하는 값은 reg_dt이기에, 아마 테이블 전체 스캔을 할 것 같다는 생각을 했다.

select 쿼리 앞에 explain 키워드를 붙여서 실행계획을 확인한 결과, 예상과 같게 ALL이 나온 것을 볼 수 있었다.

reg_dt 인덱스를 생성한 후 다시 실행계획을 확인하니 type이 index로 바뀐 것을 확인할 수 있다.
하지만 또 다른 문제가 있었다. 실행계획의 Extra 속성을 보니 'Using temporary'와 'Using filesort'가 표시되어 있었는데, 이는 임시 테이블을 생성하여 사용하고, 파일 기반의 정렬을 수행하고 있다는 것을 뜻한다.
테이블에 date 타입의 reg_date 컬럼을 만들고, 인덱스를 reg_dt가 아닌 reg_date로 타게 새로 만든 후 쿼리를 다시 실행해보았다.

date() 함수를 사용하지 않고, 바로 인덱스를 타게 되니 아까 보았던 Extra 속성값이 사라지고 Using index만 표시된다.
데이터베이스의 함수를 사용하여 데이터를 가공하게 되면 예상치 못하게 인덱스를 타지 못하거나 임시 테이블이 생성되는 경우 등의 불필요한 작업이 추가될 수 있다.
쿼리상에서의 기능을 확인하였으니, 코드로 기능을 구현하였다.
@Override
public List<GetDailyRegisterUserResponseDto> getDailyRegisterUserStats(int beforeDays) {
return queryFactory
.select(
Projections.fields(
GetDailyRegisterUserResponseDto.class,
userEntity.regDate.as("date"),
userEntity.count().as("count")
)
)
.from(userEntity)
.where(userEntity.regDate.after(TimeCalculator.getDateDaysAgo(beforeDays)))
.groupBy(userEntity.regDate)
.orderBy(userEntity.regDate.asc())
.fetch();
}
코드를 보면, 7일 전부터의 데이터만 조회한 뒤 이를 기준으로 group by를 수행하고 있다.
mysql에서는 쿼리가 from → where → group by → having → select → order by 순으로 처리되기 때문에,
만약 모든 데이터를 먼저 group by한 후 having절로 7일 전 데이터를 필터링한다면
(데이터 양에 따라 차이가 있겠지만) 불필요한 연산이 많아져 전체 수행 시간이 더 오래 걸릴 수 있다.
많은 양의 데이터를 조회하는 기능을 만들 때는, 데이터의 범위를 한정적으로 잡는 것이 매우 중요하다.
실행계획을 통해 현재 쿼리의 작동 흐름을 파악하고, 데이터베이스의 이해를 바탕으로 하여 성능을 개선하는 경험을 해보게 되었다.
한번 테이블 구조를 잡은 후에는 혹시 모를 문제가 발생할까봐 중간에 컬럼을 추가하기가 어려웠는데, 이번에 컬럼과 인덱스를 추가하여 불필요한 작업을 줄여보니 생각이 달라졌다. 앞으로는 테이블 구조에 변화를 주는 것을 두려워하지 않고, 그럴 수 있도록 중간에 변화가 생겨도 영향이 가지 않는 유연한 프로젝트 구조를 설계할 수 있게 노력해야겠다.