feat: 비디오 스트리밍 기능 안정화
parent
bd5d965402
commit
5d10ef2575
|
|
@ -102,7 +102,7 @@ public class ManageListController {
|
|||
return "/web/manage/list";
|
||||
};
|
||||
|
||||
// 지반정보등록 (관리자)
|
||||
// 지반정보등록 (관리자) - 일반 입력자도 여기 로직을 통해 화면이 보여짐.
|
||||
@RequestMapping(value = "/meta_info.do")
|
||||
public String meta_info(@RequestParam HashMap<String, Object> params,
|
||||
ModelMap model, HttpServletRequest request,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
package geoinfo.videos;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import egovframework.com.cmm.service.EgovProperties;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/video")
|
||||
public class VideoStreamingController {
|
||||
|
||||
// 동영상 파일이 저장된 기본 경로
|
||||
private final String VIDEO_DIRECTORY = EgovProperties.getProperty("Geoinfo.FilePath") + "videos";
|
||||
|
||||
@RequestMapping(value = "/stream.do", method = RequestMethod.GET)
|
||||
public void streamVideo(@RequestParam("name") String videoName, HttpServletRequest request, HttpServletResponse response) {
|
||||
|
||||
// 1. 경로 및 파일 유효성 검사
|
||||
if (!StringUtils.hasText(videoName)) {
|
||||
sendError(response, "비디오 파일명이 비어있습니다.", HttpServletResponse.SC_BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
|
||||
// URL 인코딩된 파일 이름 디코딩
|
||||
String decodedVideoName;
|
||||
try {
|
||||
decodedVideoName = URLDecoder.decode(videoName, "UTF-8");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
sendError(response, "비디오 파일명 디코딩에 실패했습니다.", HttpServletResponse.SC_BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
|
||||
File videoFile = new File(VIDEO_DIRECTORY, decodedVideoName);
|
||||
|
||||
// Path Traversal 공격을 방지하기 위한 보안 코드 추가
|
||||
try {
|
||||
String videoDirPath = new File(VIDEO_DIRECTORY).getCanonicalPath();
|
||||
String requestedFilePath = videoFile.getCanonicalPath();
|
||||
|
||||
// 요청된 파일의 실제 경로가 허용된 비디오 디렉토리 내에 있는지 확인
|
||||
if (!requestedFilePath.startsWith(videoDirPath)) {
|
||||
sendError(response, "잘못된 파일 경로입니다.", HttpServletResponse.SC_BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// 경로 처리 중 오류 발생
|
||||
sendError(response, "파일 경로 처리 중 오류가 발생했습니다.", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!videoFile.exists() || !videoFile.isFile()) {
|
||||
sendError(response, "요청하신 동영상 파일을 찾을 수 없습니다.", HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
RandomAccessFile randomFile = null;
|
||||
OutputStream outputStream = null;
|
||||
|
||||
try {
|
||||
long rangeStart = 0;
|
||||
long rangeEnd = 0;
|
||||
boolean isRangeRequest = false;
|
||||
|
||||
// 2. Range 헤더 파싱
|
||||
String rangeHeader = request.getHeader("Range");
|
||||
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
|
||||
isRangeRequest = true;
|
||||
rangeHeader = rangeHeader.substring("bytes=".length());
|
||||
String[] ranges = rangeHeader.split("-");
|
||||
try {
|
||||
rangeStart = Long.parseLong(ranges[0]);
|
||||
if (ranges.length > 1) {
|
||||
rangeEnd = Long.parseLong(ranges[1]);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// Range 형식이 잘못된 경우 무시하고 전체 전송 시도
|
||||
rangeStart = 0;
|
||||
rangeEnd = 0;
|
||||
isRangeRequest = false;
|
||||
}
|
||||
}
|
||||
|
||||
long fileLength = videoFile.length();
|
||||
|
||||
// Range 헤더가 없거나 잘못된 경우, rangeEnd를 파일 끝으로 설정
|
||||
if (rangeEnd == 0) {
|
||||
rangeEnd = fileLength - 1;
|
||||
}
|
||||
|
||||
// 3. 응답 헤더 설정
|
||||
String fileName = videoFile.getName();
|
||||
String mimeType = "video/mp4"; // video/webm, video/ogg 등 파일에 맞게 설정
|
||||
|
||||
// 브라우저별 한글 파일명 처리
|
||||
String header = request.getHeader("User-Agent");
|
||||
String encodedFilename;
|
||||
if (header.contains("MSIE") || header.contains("Trident")) { // IE, Edge
|
||||
encodedFilename = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
|
||||
} else { // Chrome, Firefox, Opera
|
||||
encodedFilename = new String(fileName.getBytes("UTF-8"), "ISO-8859-1");
|
||||
}
|
||||
|
||||
response.setHeader("Content-Disposition", "inline; filename=\"" + encodedFilename + "\"");
|
||||
response.setContentType(mimeType);
|
||||
response.setHeader("Accept-Ranges", "bytes");
|
||||
|
||||
long rangeLength = rangeEnd - rangeStart + 1;
|
||||
|
||||
if (isRangeRequest) {
|
||||
// 스트리밍 (부분 전송)
|
||||
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206
|
||||
response.setHeader("Content-Length", String.valueOf(rangeLength));
|
||||
response.setHeader("Content-Range", "bytes " + rangeStart + "-" + rangeEnd + "/" + fileLength);
|
||||
} else {
|
||||
// 전체 전송
|
||||
response.setStatus(HttpServletResponse.SC_OK); // 200
|
||||
response.setHeader("Content-Length", String.valueOf(fileLength));
|
||||
}
|
||||
|
||||
// 4. 파일 데이터 전송
|
||||
randomFile = new RandomAccessFile(videoFile, "r");
|
||||
randomFile.seek(rangeStart);
|
||||
|
||||
outputStream = response.getOutputStream();
|
||||
byte[] buffer = new byte[8192]; // 8KB 버퍼
|
||||
int bytesRead;
|
||||
|
||||
long bytesToWrite = rangeLength;
|
||||
while (bytesToWrite > 0 && (bytesRead = randomFile.read(buffer, 0, (int) Math.min(bytesToWrite, buffer.length))) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
bytesToWrite -= bytesRead;
|
||||
}
|
||||
|
||||
outputStream.flush();
|
||||
|
||||
} catch (IOException e) {
|
||||
// 클라이언트가 연결을 중단하는 경우 (예: 동영상 탐색) IOException이 발생할 수 있음
|
||||
// 이 경우는 정상적인 동작이므로 에러 로그를 남기지 않음
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
sendError(response, "알 수 없는 오류가 발생했습니다.", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||
} finally {
|
||||
if (outputStream != null) {
|
||||
try {
|
||||
outputStream.close();
|
||||
} catch (IOException e) {}
|
||||
}
|
||||
if (randomFile != null) {
|
||||
try {
|
||||
randomFile.close();
|
||||
} catch (IOException e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트에게 에러 메시지를 전송하는 메소드.
|
||||
* 한글 깨짐을 방지하기 위해 응답 인코딩을 UTF-8로 직접 설정합니다.
|
||||
* @param response HttpServletResponse 객체
|
||||
* @param message 전송할 에러 메시지
|
||||
* @param status HTTP 상태 코드
|
||||
*/
|
||||
private void sendError(HttpServletResponse response, String message, int status) {
|
||||
try {
|
||||
// 1. 응답 상태 코드를 직접 설정합니다.
|
||||
response.setStatus(status);
|
||||
|
||||
// 2. 응답의 컨텐츠 타입과 문자 인코딩을 UTF-8로 명확하게 지정합니다.
|
||||
response.setContentType("text/plain; charset=UTF-8");
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
|
||||
// 3. 응답 본문에 직접 에러 메시지를 작성합니다.
|
||||
response.getWriter().write(message);
|
||||
response.getWriter().flush();
|
||||
|
||||
} catch (IOException e) {
|
||||
// 클라이언트가 연결을 끊었거나 다른 I/O 오류 발생 시
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@
|
|||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<link href="/js/map/gis/css/map.css" rel="stylesheet"/>
|
||||
|
||||
|
||||
<title>지도</title>
|
||||
</head>
|
||||
<body onload="initMap();">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
<filter-name>encodingFilter</filter-name>
|
||||
<url-pattern>*.do</url-pattern>
|
||||
<url-pattern>*.json</url-pattern>
|
||||
<url-pattern>/video/stream/*</url-pattern>
|
||||
</filter-mapping>
|
||||
|
||||
<!-- 멀티파트필터 적용 -->
|
||||
|
|
@ -74,6 +75,10 @@
|
|||
<servlet-name>action</servlet-name>
|
||||
<url-pattern>*.json</url-pattern>
|
||||
</servlet-mapping>
|
||||
<servlet-mapping>
|
||||
<servlet-name>action</servlet-name>
|
||||
<url-pattern>/video/stream/*</url-pattern>
|
||||
</servlet-mapping>
|
||||
<servlet>
|
||||
<servlet-name>ImageServlet</servlet-name>
|
||||
<servlet-class>net.sf.jasperreports.j2ee.servlets.ImageServlet</servlet-class>
|
||||
|
|
|
|||
Loading…
Reference in New Issue