개발/Spring

[Springboot] 대용량 Select Query OOM 방지를 위한 스트림

joon95 2022. 12. 13. 00:12
반응형

서론

대량의 select 쿼리를 날릴 때 대부분의 개발자들은 Memory 요소를 파악하지 못하는 것 같다.

 

이번에도 몇십만건의 데이터를 한번에 load 해서 front에 뿌리는 프로세스에서 was가 후두둑 죽어가는게 아닌가.

이전 프로젝트에서도 배치개발을 하던 과장급 프리랜서가 있었는데,

운영에 올리자마자 터지고 난리도 아니였다.

 

그들의 답변은 항상 '개발환경에선 잘된다, 단지 데이터가 많을 뿐 -> 인프라문제다.'

 

제발 메모리 이슈좀 알아서 해결해라!!!!!!!!!!!!

 

테스트 목적

'직접'해보자

Q. 수십~수백만 건의 데이터를 어떻게 사용자에게 보여줄 것인가???

 

검색해보니 제일먼저 Mybatis fetchSize 조정이 있었다.(default 10)

- 만약 1000개의 데이터를 select 한다면 fetchSize만큼 짤라서 함, 1000/10=100번의 db요청.

- 성능개선이 어마어마하다고 함, 보통 1000 사용하는듯)

 

다음은 Mybatis resultHandler를 이용한 컨트롤 제일 정확한 핸들러인거 같다.

- 추가적으로 excel 파일 생성 시, 이 핸들러로 oom 방지를 많이 한다고 한다.(나중에 해봐야지)

 

마지막으로 브라우저에 뿌리는 역할은 Stream 방식이 있다.(본 블로그는 이걸 사용할 것)

- Stream 방식은 이전에 tail -f 기능을 springboot로 구현하기 에 포스팅한 SSE와 비슷한 개념인 것 같다.

 

 

springboot Sse(Server Send Event) 단방향 통신을 이용해 tail -f 기능 구현

1. 난 이걸 왜 쓰게되었나? OCP 웹콘솔에 보면 pod의 log를 지속적으로 호출하는 페이지가 있는데, 말 그대로 서버가 클라이언트 쪽에 로그를 일방적으로 보내는 방식인거 같았고, 이걸 구현해보고

flowlog.tistory.com

sse 는 접속한 브라우저가 구독을 해놓아서 구독을 해지할 때까지 지속적으로 데이터를 뿌리는 역할이였지만,

여기서 사용할 Stream은 그냥 ResponseBodyEmitter 로 설정한 시간 동안 전송하고 완료처리를 하게 된다.

 

대용량 데이터 샘플

수백만건 데이터를 어떻게 해볼까 하다가 검색해보니 샘플 데이터가 바로나왔다.

아래 링크에서 다운해서 mysql에 올리면 2,838,426건의 데이터가 있는 테이블이 있다.

 

필자는 이 database의 salaries 테이블을 조회하려 한다.

 

GitHub - datacharmer/test_db: A sample MySQL database with an integrated test suite, used to test your applications and database

A sample MySQL database with an integrated test suite, used to test your applications and database servers - GitHub - datacharmer/test_db: A sample MySQL database with an integrated test suite, use...

github.com

 

Springboot

자, 이제 stream 이 되도록 소스를 구현하자.

 

Spring initializr

spring init 사이트에서 mybatis, mysql, lombok, web, devTools 를 선택해 가져왔다.

알아서 다운받고 maven project import !

 

귀찮아할 수 있으니 pom.xml도 첨부하겠다..

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.0.0</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>demo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>demo</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>3.0.0</version>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>com.mysql</groupId>
			<artifactId>mysql-connector-j</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>

 

project 구조

간단하게 만들어질 프로젝트 구조에 대해 확인해보자.

 

mybatis 사용 시 늘 보던 구조^^

(service interface는 귀찮아서 뺏다ㅋ)

 

application.properties

간단히 로그레벨, datasource, mybatis 설정을 넣어주었다.

(대학생 때는 항상 root-context.xml 만들고 난리였는데, 이제 이거만해도 잘된당)

(springboot-mybatis-starter 가 생겨서 그렇다나 뭐라나..?)

logging.level.root: INFO
# database
spring.datasource.url: jdbc:mysql://localhost:3306/employees?characterEncoding=utf8
spring.datasource.username: root
spring.datasource.password: admin1234
spring.datasource.driver-class-name: com.mysql.cj.jdbc.Driver
spring.datasource.hikari.maximum-pool-size: 30
# mybatis
mybatis.type-aliases-package: com.example.demo.model
mybatis.mapper-locations: classpath:mybatis/mapper/**/*.xml

근데 datasource.hikari 로 다 선언해야되는거로 알고잇었는데 url 오류가 계속 나서 이거로했다.(아는 사람 댓글 좀요)

debug 로그를 확인해보니 pool 갯수가 잘 변경되고 있음;

 

SalaryMapper.xml

resources/mybatis/mapper 아래 위치하는 파일이다.

mybatis 에서 모든 쿼리들을 바로 볼 수 있는 xml ㅎㅎ

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.SalaryMapper">
  <select id="getSalaryOri" resultType="SalaryModel">
    SELECT * FROM salaries;
  </select>
  <select id="getSalary" resultType="SalaryModel" fetchSize="1000">
    SELECT * FROM salaries;
  </select>
  <select id="getAllSalary" parameterType="map" resultType="map" fetchSize="-2147483648">
    SELECT * FROM salaries;
  </select>
</mapper>

fetchSize가 있는 경우 / 없는 경우 와 stream 용도로 사용할 All을 작성하였다.

 

Model(DTO)

샘플데이터로 사용할 Salary Table 구조를 확인한 뒤 작성하였다.

package com.example.demo.model;

import lombok.Builder;
import lombok.Data;

@Builder
@Data
public class SalaryModel {
	private int emp_no;
	private int salary;
	private String from_date;
	private String to_date;
}

 

Mapper

package com.example.demo.mapper;

import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.session.ResultHandler;
import com.example.demo.model.SalaryModel;

@Mapper
public interface SalaryMapper {
	List<SalaryModel> getSalaryOri();
	List<SalaryModel> getSalary();
	void getAllSalary(ResultHandler handler);
}

stream 용도로 사용할 mapper는 void 형식으로 작성해야한다고 함

 

Handler

stream 을 위한 핸들러이다. (deprecated 된 라이브러리이니 주의바람)

package com.example.demo.handler;

import java.util.Observable;
import org.apache.ibatis.session.ResultContext;
import org.apache.ibatis.session.ResultHandler;

public class RowHandler extends Observable implements ResultHandler{
	@Override
	public void handleResult(ResultContext resultContext) {
		// TODO Auto-generated method stub
		super.setChanged();
		super.notifyObservers(resultContext.getResultObject());
	}
}

 

Service

package com.example.demo.service;

import java.util.List;
import java.util.Observer;

import org.apache.ibatis.session.ResultContext;
import org.apache.ibatis.session.ResultHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.example.demo.handler.RowHandler;
import com.example.demo.mapper.SalaryMapper;
import com.example.demo.model.SalaryModel;

@Service
public class SalaryService {
	@Autowired
	public SalaryMapper mapper;
	public List<SalaryModel> getSalaryOri(){
		return mapper.getSalaryOri();
	}
	public List<SalaryModel> getSalary(){
		return mapper.getSalary();
	}
	public void getAllSalary(Observer observer) {
		RowHandler resultHandler = new RowHandler();
		resultHandler.addObserver(observer);
		mapper.getAllSalary(resultHandler);
	}
}

 

Controller

package com.example.demo.controller;

import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;

import com.example.demo.model.SalaryModel;
import com.example.demo.service.SalaryService;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
public class JoonController {

	@Autowired
	SalaryService salaryService;
	@GetMapping(value="/ori")
	private List<SalaryModel> salaryOri() {
		List<SalaryModel> salaryList = salaryService.getSalaryOri();
    	return salaryList;
	}
	@GetMapping(value="/sal")
	private List<SalaryModel> salary() {
		List<SalaryModel> salaryList = salaryService.getSalary();
    	return salaryList;
	}
	@GetMapping(value="/stream")
	private ResponseBodyEmitter stream() {
		// timeout 10분
		final ResponseBodyEmitter emitter = new ResponseBodyEmitter(600000L);

		ExecutorService executorService = Executors.newSingleThreadExecutor();
		executorService.execute(() -> {
			salaryService.getAllSalary((observer, args) -> {
			  try {
			    emitter.send(args.toString() + "\n");
			  } catch (IOException e) {
			    log.error("### Stream 방식으로 목록을 전달하던 중 에러 발생", e);
			  }
			});
			emitter.complete();
		});
		return emitter;
	}	
}

 

이렇게 총 3개의 api 가 완성되었다.

GET /ori : default

GET /sal : fetchSize 1000 세팅

GET /stream : stream 핸들러

 

모니터링 환경 세팅

필자는 jconsole 을 이용하여 모니터링 할 것이다.

그리고 eclipse 에서 바로 run 과 동시에 모니터링을 하고 싶다.

 

그렇게 하기 위해선 eclipse 에 jconsole 이 모니터링할 포트를 설정해주어야한다.

eclipse

eclipse 상단 메뉴 중 run>run configurations 에 들어가 Arguments에 아래내용을 추가하자.

위 3개만 있으면 되고 추가적으로 메모리지정 및 gclog 도 추가해줬음.

port는 임의로 9611 로 지정하였으니 jconsole에서 이쪽으로 연결해주면 된다.

-Dcom.sun.management.jmxremote.port=9611
-Dcom.sun.management.jmxremote.rmi.port=9611
-Dcom.sun.management.jmxremote.ssl=false
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=C:\sts-workspace
-verbose:gc
-Xms2048m
-Xmx2048m

 

jconsole

jconsole 은 java에 기본적으로 설치되어있따!

더블클릭해서 실행한 뒤, localhost:port 로 연결하면 모니터링된다.

 

테스트

정확한 히스토리를 남기면서 하고싶어서 Postman을 중점으로 해보았다.

response 되는 Body Size가 195MB 정도 되어 postman에

Error: Maximum response size reached 가 발생하였다.

찾아보니 default로 설정된 값이 50MB 여서 settings 에서 0으로 잠시 세팅해주었다.

 

결과

먼저 필자는 fetchSize 에 대해 놀라운 변화가 없었다. 아마 Column 들의 Size가 작아서 그런거 같기도하다.

그럼 Stream과 비교하면 어느정도의 차이가 났을까?

어마어마하다.

 

메모리에 가지고 한번에 return 해주면

- 크롬에서 Memory 오류 발생하는 현상 간혹 발생

- 소요시간 5분19초~49초 사이

- Heap 메모리 사용률은 최대 1.7GB, 평균 1.5GB

 

반면에 Stream 테스트는

- 유저가 지속적으로 update 되는 데이터를 보기때문에 사용성 우수

- 소요시간 1분24초로 (4배의 속도상승)

- Heap 메모리 사용률 최대 1.3GB, 평균 0.7GB

 

마무리

물론 Stream이 정답은 아닐 것이다.

이번 실습으로 개발자의 역량이 얼마나 중요한지 개인적으로 느꼈다.

 

그리고 gc.log 는 뭐 기본적으로 보게되는데,

HeapDump 설정은 그냥 생각만 하고 귀찮아서 거의 방치한 상태였다.

개발자들의 실수를 support 하기 위해 다음부턴 꼭. ㅋ챙겨보자..ㅋ

Github

해당 테스트에 사용한 소스코드는 github에 업로드하였다.

 

GitHub - joonhyeok95/spring-stream-mysql: 280만건 데이터를 화면에 뿌릴 때 stream 방식으로 테스트하는 샘플

280만건 데이터를 화면에 뿌릴 때 stream 방식으로 테스트하는 샘플. Contribute to joonhyeok95/spring-stream-mysql development by creating an account on GitHub.

github.com

 

참고 사이트

 

[Spring] Stream 형태로 Response 응답 주기 (feat. MyBatis, Observer)

환경 Spring Version : 4.2 이상 MySQL + MyBatis 문서 Http Stream Return은 총 3가지가 있는듯하다. (Spring 4.2부터) - ResponseBodyEmitter : https://docs.spring.io/autorepo/docs/spring-framework/4.3.5.RELEASE/javadoc-api/org/springframework/w

seongtak-yoon.tistory.com

 

반응형