WebSocket, JdbcTemplate을 활용한 엑셀업로드기능을 구현
사이드 프로젝트의 엑셀 일괄 업로드 기능을 구현하며 정리한 내용임.
구현에 고려할 부분
1. 업로드 진행상황 표시
- 서버측에서 작업 진행상황 데이터를 클라이언트측에 전달할 수 있어야함.
- 일반적인
http
방식으로는 해결하기 어렵기에 WebSocket을 도입.- 스프링에서도 역시 WebSocket 지원하는 프로젝트가 있음.
WebSocket 설정 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
// WebSocket 핸들러를 등록하는 메서드
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// "/progress/**" 경로에 ProgressWebSocketHandler를 추가
// 클라이언트측에서 ** 부분에 sessionId를 추가하여 연결 요청
registry.addHandler(progressWebSocketHandler(), "/progress/**");
}
// ProgressWebSocketHandler 빈(Bean)을 생성하는 메서드
@Bean
public ProgressWebSocketHandler progressWebSocketHandler() {
return new ProgressWebSocketHandler();
}
}
WebSocket 핸들러 클래스
ConcurrentHashMap<String, WebSocketSession> sessions
- 초기 형태는
ConcurrentHashMap이
아닌WebSocketSession session
하나만 필드로 가지고 있었다. - 이경우
ProgressWebSocketHandler
클래스는 Bean으로 등록 되어 있기 때문에 여러 사용자가 동시에 엑셀 업로드를 진행하면 동시성 문제가 발생함.
sessionID
와ConcurrentHashMap을
통해 여러 세션을 관리할 수 있도록 처리
- 초기 형태는
sendProgressUpdate()
- 클라이언트 측으로 엑셀 업로드 진행상황 데이터를 전송하는 메서드.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
@Slf4j
public class ProgressWebSocketHandler extends TextWebSocketHandler {
// WebSocket 세션을 저장할 ConcurrentHashMap
private final ConcurrentHashMap<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
// 새로운 WebSocket 연결이 성립되었을 때 호출되는 메서드
@Override
public void afterConnectionEstablished(WebSocketSession session) {
// 세션 ID를 추출하여 sessions 맵에 추가
String sessionId = requireNonNull(session.getUri()).getPath().split("/progress/")[1];
sessions.put(sessionId, session);
log.info("Connection established with session ID: {}", sessionId);
}
// WebSocket 연결이 종료되었을 때 호출되는 메서드
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
// 세션 ID를 추출하여 sessions 맵에서 제거
String sessionId = requireNonNull(session.getUri()).getPath().split("/progress/")[1];
sessions.remove(sessionId);
log.info("Connection closed for session ID: {}", sessionId);
}
// 클라이언트에게 진행 상황을 업데이트하는 메서드
public void sendProgressUpdate(String sessionId, String message) throws IOException {
WebSocketSession session = sessions.get(sessionId);
// 세션이 존재하고 열려있을 경우 메시지 전송
if (session != null && session.isOpen()) {
session.sendMessage(new TextMessage(message));
}
}
// 특정 세션을 종료하는 메서드
public void closeSession(String sessionId) throws IOException {
WebSocketSession session = sessions.get(sessionId);
// 세션이 존재하고 열려있을 경우 세션 종료
if (session != null && session.isOpen()) {
session.close();
log.info("Session closed for session ID: {}", sessionId);
}
}
}
시퀀스 다이어그램
- 클라이언트는 서버에 엑셀 파일을 전달함과 동시에 sessionId를 요청후 sessionId를 기반으로 WebSocket연결
- connect success 후 서버측에서 엑셀 업로드 현황데이터를 클라이어트측으로 전송한다.
- 모든 작업이 완료되면 서버측에서
session.close()
메서드를호출하여 WebSocket 연결을 종료한다.
클라이어트 코드
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
$('#send').on('click', function (event) {
event.preventDefault();
var fileInput = $('#file-input')[0];
if (fileInput.files.length === 0) {
alert("Please select a file!");
return;
}
var formData = new FormData();
formData.append('file', fileInput.files[0]);
// formData 에 엑셀 파일 셋팅
$.ajax({
url: 'http://localhost:8080/api/modems/v1/excel',
type: 'POST',
data: formData,
// sessionId 요청 및 엑셀 파일 업로드
contentType: false,
processData: false,
success: function (sessionId) {
var socket = new WebSocket("ws://localhost:8080/progress/" + sessionId);
// 반환된 sessionId를 기반으로 websocket 연결 시도
socket.onmessage = function (event) {
// 서버로부터 전달된 업로드 진행상황 데이터를 화면에 랜더링
var current = parseInt(event.data, 10);
$('#progress').text("progress : " + current + "%");
if (current >= 100) {
$('#complete').text('Upload successful!');
}
};
socket.onopen = function () {
console.log("WebSocket connection opened.");
};
socket.onclose = function () {
console.log("WebSocket connection closed.");
};
socket.onerror = function (error) {
console.log("WebSocket error: " + error);
};
},
error: function () {
$('#progress').text('Upload failed!');
}
});
});
서버 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api/modems/v1")
@RestController
public class ModemApiController {
private final ModemService modemService;
private final ModemExcelService modemExcelService;
/**
* - 단말기 일괄 엑셀 등록
*/
@PostMapping("/excel")
public ResponseEntity<String> addModemsByExcel(@RequestParam("file") MultipartFile file) {
String sessionId = UUID.randomUUID().toString();
// UUID로 sessionId 생성
new Thread(() -> {
modemExcelService.uploadModemExcel(file, sessionId);
}).start();
return ResponseEntity
.ok(sessionId);
}
}
이로서 사용자는 업로드 진행 상황 파악이 가능.
2. 데이터 일괄 저장 (Bulk Insert)
- spring-data-jpa를 사용하면 쉽게 insert를 구현할 수 있다.
- 문제점 : 느리다
- for-loop를 통해 매번 INSERT 쿼리 요청 (매 요청마다 네트워크 자원 사용)
- 해결 : JdbcTemplate 사용
JdbcTemplate.batchUpdate()
를 사용했을때와saveAll()
를 사용했을때 시간차이다 거의 4배 정도가 난다.JdbcTemplate.batchUpdate()
는 단건으로 매번 insert 쿼리를 전송하는것이 아니라 메모리에 일정양의 데이터가 쌓이면 한번에 insert 쿼리를 전송한다. 따라서 네트워크 요청도 획기적으로 줄어든다.
JdbcTemplate
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void bulkInsertModem(List<ModemDto.ModemRequest> requests) {
String sql = "INSERT INTO modem "
+ "(created_at, modem_no, imei, build_company, modem_status_cd, modem_type_cd) "
+ "VALUES (?,?,?,?,?,?)";
LocalDateTime now = LocalDateTime.now();
jdbcTemplate.batchUpdate(sql, requests, requests.size(), (ps, modemRequest) -> {
ps.setString(1, now.toString());
ps.setString(2, modemRequest.getModemNo());
ps.setString(3, modemRequest.getImei());
ps.setString(4, modemRequest.getBuildCompany());
ps.setString(5, modemRequest.getModemStatusCd());
ps.setString(6, modemRequest.getModemTypeCd());
});
}
- 추후 데이터의 양이 많아지면 batchSize를 적절하게 조절 필요.
SpringDataJpa
- 작성해야할 코드는 훨씬 간결하지만, 성능이 매우 아쉬운 받식
1 2 3 4 5 6 7 8 9 10 11 12 13 14
@Test void hibernate_벌크연산테스트() { List<Modem> requests = new ArrayList<>(); for (int i = 0; i < totalCount; i++) { requests.add(Modem.builder() .modemNo("modemNo-" + i) .imei("imei-" + i) .buildCompany("buildCompany-" + i) .modemStatusCd(Code.builder().code(MODEM_STAUTS_NORMAL.getCode()).build()) .modemTypeCd(Code.builder().code(MODEM_TYPE_LORA.getCode()).build()) .build()); } modemRepository.saveAll(requests); }
This post is licensed under CC BY 4.0 by the author.