MyBatis 대용량 Select시 OOM

나길진·2024년 3월 12일

문제해결

목록 보기
2/2

회사 관리자 서비스가 어느순간 속도가 점점 느려지면서 OOM을 뱉어내면서 죽는 상황이 발생되기 시작했다.

로그를 열어서 확인해보니 통계 Excel파일을 다운로드 할 때 OOM을 발생시키는 것이였다. 바로 해당 select문을 DB에서 실행시켜보니 수백만건의 데이터가 조회되는 것이였다. 숫자를 보고 메모리가 감당가능한가 라는 생각이 들면서 해결할 방법을 찾아야 할 것 같았다.

해결방법

일단 처음으로 설정하는건 fetch size이다. Mybatis가 DB에서 한번에 가져오는 레코드의 수? 라고 생각하면 되는 값인데 기본값으로 10이 설정되어 있다.

예를 들면 총 레코드 수가 1000건이라면 fetch size(10)만큼 100번을 불러오는 것인데 무작정 늘리면 그 만큼 메모리가 부담이 가기때문에 보통 fetch size를 1000건으로 설정해준다고 한다.

fetch size를 설정해주는 것으로는 OOM을 막을 수 없다. 최종적으로는 모든 레코드가 메모리에 올라가기 때문이다. 그래서 모든 레코드를 받아와서 처리하기보다 한건씩 불러와서 처리하는 방식인 resultHandler로 구현 했다.

대략적인 순서를 설명하면 fetch size만큼 메모리에 올리고 내가 구현한 resultHandler를 통해서 한 건씩 데이터를 처리하고 다시 메모리를 비우고 모든 데이터를 처리할 때까지 반복된다. 이러면서 메모리의 부담을 확 줄여준다.

예시소스

  • ExcelResultHandle
public class ExcelResultHandler implements ResultHandler<User> {
    private SXSSFWorkbook workbook;
    private Sheet sheet;
    private int rowCount;

    public ExcelResultHandler() {
    	//workbook 생성 및 엑셀 헤더 제작
        this.workbook = new SXSSFWorkbook();
        this.sheet = workbook.createSheet("Users");
        this.rowCount = 0;
        createHeaderRow();
    }

    private void createHeaderRow() {
        Row headerRow = sheet.createRow(rowCount++);
        headerRow.createCell(0).setCellValue("ID");
        headerRow.createCell(1).setCellValue("Name");
        headerRow.createCell(2).setCellValue("Email");
    }

	//데이터를 한 건씩 불러오는 메소드
    @Override
    public void handleResult(ResultContext<? extends User> resultContext) {
    	//excel의 1row씩 데이터 넣기
        User user = resultContext.getResultObject();
        Row row = sheet.createRow(rowCount++);
        row.createCell(0).setCellValue(user.getId());
        row.createCell(1).setCellValue(user.getName());
        row.createCell(2).setCellValue(user.getEmail());
    }

    public SXSSFWorkbook getWorkbook() {
        return workbook;
    }
    
    public ByteArrayOutputStream createOutputStream() throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        workbook.write(out);
        return out;
    }
}
  • UserService
@Service
public class UserService {

    private final SqlSessionFactory sqlSessionFactory;

    @Autowired
    public UserService(SqlSessionFactory sqlSessionFactory) {
        this.sqlSessionFactory = sqlSessionFactory;
    }

    public void exportUsers(ResultHandler<User> resultHandler) {
        try (SqlSession session = sqlSessionFactory.openSession()) {
            UserMapper mapper = session.getMapper(UserMapper.class);
            // 쿼리에 파라미터를 추가하고싶으면 파라미터 인자를 추가해주면 됌
            mapper.selectAllUsers(resultHandler);
        }
    }
}
  • UserController
@Controller
public class UserController {

    private final UserService userService;

    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/users/export")
    public ResponseEntity<ByteArrayInputStream> exportToExcel() throws IOException {
    	//handler 인스턴스 생성
        ExcelResultHandler resultHandler = new ExcelResultHandler();
        //직접 생성한 resultHandler로 데이터 불러와서 excel 제작
        userService.exportUsers(resultHandler);

        ByteArrayOutputStream out = resultHandler.createOutputStream();
        ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray());

        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=users.xlsx");

        return ResponseEntity
                .ok()
                .headers(headers)
                .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
                .body(in);
    }
}

신경써야 할 점은 Mapper에서 반환 타입을 void로 해주어야 한다. 그렇지 않으면 직접 생성한 resultHandler를 타지않기 때문이다.

후 작업

기능적인 부분은 위에 방식으로 수정가능했지만 시간이 오래걸리는 것은 막을 수 없었다.(쿼리자체가 느리기 때문에) 그래서 생각한 방식은 과거의 데이터는 미리 Batch를 통해서 미리 Excel파일을 서버에 만들어 두자라는 생각을 하게 되었다.

주로 한달단위로 많이 출력하기 때문에 매달 스케줄러로 통계 Excel파일을 저장시켜두고 저장된 파일을 다운로드 시켜주는 방식으로 변경했다.

참고사이트

profile
백엔드 개발자

0개의 댓글