페이지 로딩 속도 개선 기록
페이지 로딩 속도 개선 사례 기록
문제점
새로 개발한 서비스의 핵심 기능을 제공하는 페이지에 로딩 속도 관련된 이슈가 있었습니다.
해당 페이지에서 보여줄 데이터가 일반적인 수준에서는 불편함이 없었지만,
보여줘야 하는 데이터가 일정 수준이 넘어가면 로딩 속도가 눈에 띄게 느려지고,
Worst case에서는 처음 페이지 로딩시 20여초 가량 소요되었습니다.
문제 확인
문제가 되는 지점을 확인하기 위해서 우선 브라우저의 네트워크 도구를 사용해보니 두가지 문제가 발견되었습니다.
- 서버쪽 응답 자체가 느리다 (8초 가량 소요)
- 브라우저의 렌더링 과정에도 문제가 되는 지점이 있다 (11초 가량 소요)
결과적으로 이 두가지 포인트에 대해서 개선 방향을 잡고 문제를 조금 더 파악해 보기로 했습니다.
문제 해결 - Server Side
API 자체에 별다른 로직이 없었기 때문에 자연스럽게 응답 지연 지점은 DB로 의심하게 되었고,
DAO단의 응답시간을 확인해 본 후 실제로 문제가 된 쿼리들을 확인 할 수 있었습니다.
처음엔 쿼리 자체의 문제거나 DB index가 필요한 문제일까 의심을 했엇는데 실제 DB에 동일한 로직의 쿼리를 질의해봤을때는 응답에 아무런 문제가 없었습니다.
DB 접근에 Mybatis를 사용중이기 때문에 관련 내용을 검색해보다가 이유를 찾을 수 있었는데요. N+1 Select Problem
이 원인이었습니다.
N+1 Select Problem이란 간단히 N건의 쿼리 결과를 완성하기 위해서 N번 더 DB에 접근하게 되는 문제를 말합니다.
- 예를 들어
Device
테이블과Maker
테이블이 있다고 가정했을때, Device의 이름과 Maker의 이름이 필요한 경우
위와 같은 형태의 쿼리의 결과로 N건의 row가 나오고SELECT d.name, d.maker_id FROM device d
위와 같은 쿼리를 N건만큼 더 돌려서 총 N건의 Device 이름과 Maker 이름을 조회하는데 N+1번 select를 수행하게 되는 문제입니다.SELECT m.name FROM maker m WHERE m.id = #{m.id}
Mybatis에서는 크게 두가지 방식으로 join을 처리할 수 있습니다.
Nested Select
resultMap
에 포함된association
혹은collection
에 해당값을 맵핑해줄 select 쿼리를 명시해 주는 방식입니다.<resultMap id="inspection" type="com.somepackage.model.Inspection" extends="inspectionBase"> <association property="releaseScope" column="release_scope" javaType="com.somepackage.common.model.SelectValue" select="com.somepackage.common.repository.SelectValueMapper.selectByCode"/> <collection property="repositories" column="id" javaType="java.util.ArrayList" ofType="com.somepackage.model.Repository" select="com.somepackage.repository.RepositoryMapper.selectRepositoriesByInspectionId"/> </resultMap>
- Pros.
- 쿼리에 직접 join을 사용하지 않아도 됩니다.
- 사용이 간편하고 가독성이 좋아집니다.
- 쿼리를 재사용하기 용이합니다.
- Cons.
- N+1 Select 문제를 유발합니다.
- Pros.
Nested Results
resultMap
에 포함된association
혹은collection
에 직접 맵핑될 결과를 명시해 주는 방식입니다.<resultMap id="inspection" type="com.somepackage.model.Inspection" extends="inspectionBase"> <association property="releaseScope" javaType="com.somepackage.common.model.SelectValue"> <result property="selectType" column="rs_select_type"/> <result property="name" column="rs_name"/> <result property="nameEnglish" column="rs_name_eng"/> <result property="code" column="rs_code"/> <result property="order" column="rs_select_value_order"/> </association> <collection property="repositories" javaType="java.util.ArrayList" ofType="com.somepackage.model.Repository"> <result property="id" column="r_id"/> <result property="ossUrl" column="r_oss_url"/> <result property="reviewBranch" column="r_review_branch"/> <result property="inspectionId" column="r_inspection_id"/> <result property="newRepo" column="r_new_repo"/> </collection> </resultMap>
- Pros.
- Nested Select 방식에 비해 DB 접근 횟수가 크게 감소합니다.
- Cons.
- 관련된 모든 테이블의 join 쿼리를 직접 작성해야 하며 각 result에 column 속성에 맞춰 alias를 지정해줘야 합니다.
- 가독성이 나빠집니다.
- 쿼리를 재사용하기 어렵습니다.
- Pros.
현재 서비스에서는 대부분의 join이 Nested Select
방식으로 처리되고 있었고
문제가 된 페이지의 응답을 위해서는 최소 4개 테이블의 join이 필요하였기 때문에 더욱 큰 성능 하락이 발생했습니다.
모든 join을 Nested Result
방식으로 변경하는것은 공수도 많이들 뿐더러 질의 결과가 작은 쿼리들에 대해서는 단점들만 부각되기 때문에
질의 결과가 많을것으로 예상되는 쿼리들에 대해서만 Nested Select
방식으로 변경하였습니다.
그 결과 응답속도가 1/10 수준으로 개선되었습니다.
문제 해결 - 브라우저 렌더링
브라우저쪽 이슈에서 의아했던 부분은 11초나 걸려서 받아오는 파일이 단순히 웹폰트 였다는 점이었습니다.
그래서 네트워크 탭을 더 자세히 들여다보니 실제 다운로드에는 22μs밖에 들지 않았고, 11초는 대기(stalled) 시간이었습니다.
Stalled. The request could be stalled for any of the reasons described in Queueing.
https://developers.google.com/web/tools/chrome-devtools/network/reference#timing-explanation
대기시간이 11초라는건 네트워크 탭에 찍힌 웹폰트 문제가 아니라 Api 응답값을 통해 받은 데이터를 렌더링하는 과정에서 지연이 생기는것이라는 추측을 하게되었습니다.
현재 서비스에 front-end로는 vue.js를 사용하고 있는데 처음 의심했던 내용은 컴포넌트의 잘못된 상속 구조 때문에 렌더링 과정에서 예상보다 많은 컴포넌트가 생성되었고 이 때문에 지연이 발생하는게 아닐까 하는것 이었습니다.
가장 많이 그려지는 컴포넌트인 UsedSourceInfo
는 내부에 몇가지 다른 컴포넌트들을 가지고 있었는데 이 컴포넌트들 모두 또 다시 상속 구조로 상위 컴포넌트를 매번 생성하는 형태였습니다.
이 추측을 확인해보기 위해 의미없는 상속 구조를 모두 제거해보니 실제로 로딩 속도가 1/10 수준이 된 것을 확인할 수 있었습니다.
이때 1/10 수준으로 로딩 속도가 빨라진것은 맞았지만 상속구조를 제거로 컴포넌트 생성 수가 줄어들어서 빨라진것은 아니였습니다
상속구조로 인해 수백~수천개 정도의 컴포넌트가 더 생기는것이 성능 저하의 원인이라면 Vue.js를 사용해도 되는게 맞나? 라는 생각을 하고있었는데
단순한 구조의 컴포넌트에 10000개에 대해서 상속유무에 따른 렌더링 시간 차이는 4초 정도가 걸리는것을 확인 할 수 있었습니다.
물론 4초면 적지 않은 차이지만 현재 Worst case에도 1000개 미만의 컴포넌트가 생성되고 11초 보다는 훨씬 짧은 시간임으로 원인이 상속 구조 그 자체는 아니라는것을 알았습니다.
그렇다면 의심되는 내용은 상속과 관련있는 이벤트 였습니다.
@Component({
components: {Status}
})
export default class InspectionDetailBase extends Base {
mounted() {
this.super(); //의심스럽다..
}
...
}
@Component
export default class Base extends Vue {
super() {
// 이중에 뭐가 문제일까..
$("#pasta-main-content").find(".scrollbar-inner").scrollbar();
if (window.pasta.menu) {
window.pasta.menu.refresh();
}
}
...
}
결론적으로는 jquery 라이브러리로 추가한 scrollbar
메서드가 문제였습니다.
컴포넌트 생성시 마다 lifecycle hook으로 스크롤바가 생성될 영역을 계산하고 적용하여 렌더링 시간이 길어졌고,
대기 시간이 길어짐에 따라 리소스를 다운로드 하는 커넥션이 오랜시간 대기하고 있던 것 이었습니다.
이를 해결하기 위해 super()
메서드에서 해당 함수를 호출하지 않도록 수정하였고
최종적으로 응답속도를 1/10 수준으로 개선할 수 있었습니다.
결론 & 느낀점
Mybatis 사용시 가능한 한(혹은 쿼리에 예상되는 row수가 많은 경우는 필수적으로)
Nested Result
방식을 사용하여N+1 Select Problem
을 예방해야 합니다.예상했던 방식으로 이슈를 해결했다고 해서 성급하게 결론 내리지 말고, 정말 그 방식이 답이었는지, 다른 이유로 해결된 것은 아닌지 다시 한번 검토해 봐야겠습니다.
Worst case라고는 해도 절대적인 시간으로 보면 아직도 로딩속도가 느린편에 속하는것 같습니다. 기회를 봐서 Worst case도 1초 이내로 응답을 받을 수 있도록 개선해봐야겠습니다.