주소를 입력하면 MP3 파일을 다운받을 수 있는 컨트롤러를 만들고 있었습니다. 먼저 아래 글을 먼저 참고하세요. 그리고 글에 사족이 많습니다. 전체 코드는 맨 밑에 있습니다.

 

파일 다운로드도 잘 되고 재생도 되었습니다만, 크롬과 사파리(맥, iOS)에서 <audio> 태그에 넣으면 구간 탐색이 안되는 문제가 있었습니다.

위는 사파리 스샷입니다. MP3 파일을 소스에 배정했는데 “라이브 방송(live broadcast)“이라면서 재생만 되고 일시정지조차 제대로 되지 않는 문제가 있었습니다. 게다가 파일도 끝부분을 제대로 읽어들이지 못하고 있었습니다.

먼저 크롬에서는, 헤더를 추가하는 것으로 간단하게 해결됩니다. 컨트롤러에 아래 헤더들을 추가하면 구간 탐색이 정상적으로 됩니다. (전체 코드는 밑에 있습니다.)

response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Length", initFile.length() + "");

“Accept-ranges” “bytes”로 설정하고, “Content-Length”에 바이트 단위로 파일 길이를 넣어주면 됩니다. (MDN 링크)

 

문제는 사파리입니다. 사파리에서는 <audio> 태그에서 욕이 나올 만큼 황당한 문제가 있었습니다. 사파리에서는 mp3를 불러올 때 0-1 바이트 범위의 처음 2바이트의 의미 없는 리퀘스트를 보냅니다. 그 리퀘스트에서 제대로 된 응답이 없으면, 이 파일은 제대로된 파일이 아니라고 간주하고 멋대로 “라이브 방송“이라는 메시지를 띄우며 구간 탐색은 물론 일시정지도 제대로 못하게 합니다. 맨 처음 스샷 리퀘스트의 Range: byte=0-1 부분입니다.

 

해외에서도 이 문제는 오래 전부터 존재했던 것 같습니다. 스택오버플로에서 관련 글 하나를 링크합니다.

이 글에서 자세한 해결 방법을 알려주지 않았으나, 사파리 리퀘스트 과정이 독특하며 Range에 대응하라는 답변이 나왔습니다. “Safari는 미디어를 다운로드하는 데 완전히 뇌손상을 입은(braindamaged) 접근 방식을 가지고 있습니다.”라는 평까지 나왔습니다. (전적으로 동감하는 바입니다.)

이러한 해결 방법을 바탕으로 문제를 해결하려 했으나, 리퀘스트 범위가 0~1바이트라는게 무슨 의미인지도 몰랐고, 기존의 FileInputStream으로는 파일의 일부를 잘라 전하는 방법을 찾기 어려웠습니다.

자바에서 파일을 어떻게 잘라야하나 알아보는 중에 RandomAccessFile이라는 것이 있었습니다. 여기의 seek() 메소드를 이용하면 파일의 특정 위치부터 탐색 한 뒤, 잘라서 전송할 수 있습니다.

 

try(RandomAccessFile randomAccessFile = new RandomAccessFile(initFile, "r");
    ServletOutputStream sos = response.getOutputStream();	){

    Integer bufferSize = 1024, data = 0;
    byte[] b = new byte[bufferSize];
    Long count = startRange;
    Long requestSize = endRange - startRange + 1;

    randomAccessFile.seek(startRange);
    while(true) {

        if(requestSize <= 2) {
            sos.flush();
            break;
        }

        data = randomAccessFile.read(b, 0, b.length);

        if(count <= endRange) {
            sos.write(b, 0, data);
            count += bufferSize;
            randomAccessFile.seek(count);
        } else {
            break;
        }

    }

    sos.flush();
}

RandomAccessFile에 대한 것은 추후 따로 글을 작성하도록 하겠습니다. 위의 예제 코드를 보면 randomAccessFile을 생성하고, seek(시작 지점) 으로부터 read할 수 있는 기능이 있습니다. 요청 범위에 맞게 파일을 잘라 soswrite() 한 후 flush() 하면 됩니다.

이러한 방법으로 파일의 1~2바이트를 전송하려고 시도하였습니다. 하지만 사파리가 처음에 요청한 0-1 범위 리퀘스트는 파일을 1~2바이트로 잘라서 보내라는 의미가 아니었습니다. 저는 계속 파일을 1바이트만 보낼 수 있을지 고민하고 있었는데, 사실은 1바이트를 보내라는 게 아니었던 것 같습니다.

 

if(requestSize < bufferSize) {
    // Range byte 0-1은 아래 의미가 아님.
    // data = randomAccessFile.read(b, 0, requestSize.intValue());
    // sos.write(b, 0, data);

    sos.flush();
    break;
}

위의 주석 처리한 부분은 동작이 되지 않았는데, 여러 시도를 해보다 혹시나 해서 그냥 아무 데이터도 넣지 않고 sos.flush() 만 시켰는데 이렇게 하니 어이없게도 사파리에서도 “라이브 방송“이 사라지고 구간 탐색이 가능해지게 되었습니다.

 

아직 파일을 분할하는 과정에서 약간의 속도 지연이 있거나, 혹시 파일 무결성에 손상이 있지 않을 지 문제를 더 살펴봐야겠지만, 일단 mp3 파일은 원래 파일대로 잘 읽고 탐색도 잘 되는 것 같습니다.

아이폰에서도 정상 동작합니다.

 

소스 코드


package com.example.awsboard.web;
import com.example.awsboard.config.auth.LoginUser;
import com.example.awsboard.config.auth.dto.SessionUser;
import com.example.awsboard.service.posts.MidiService;
import com.example.awsboard.util.TimidityRunner;
import com.example.awsboard.web.dto.midi.MidiPublicResponseDTO;
import com.example.awsboard.web.dto.midi.MidiRequestDTO;
import com.example.awsboard.web.dto.midi.MidiResponseDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.support.ServletContextResource;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.swing.filechooser.FileSystemView;
import java.io.*;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.util.*;
@RequiredArgsConstructor
@RestController
public class MidiApiController {
private final MidiService midiService;
private final String DEFAULT_URI = "/api/v1/midi";
// ………. 생략 ………. //
@GetMapping(DEFAULT_URI + "/mp3/{id}")
@ResponseStatus(HttpStatus.OK)
public void mp3Play(ModelAndView modelAndView, @PathVariable Long id,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
// mp3 파일 경로 지정
String rootPath = FileSystemView.getFileSystemView().getHomeDirectory().toString();
String basePath = rootPath + "/" + "app/midi";
MidiResponseDTO mp3 = midiService.findById(id);
String mp3Path = basePath + mp3.getOriginalMp3Path();
// File 객체 생성
File initFile = new File(mp3Path);
// ***** RANGE 추출 ***** //
Long startRange = 0l;
Long endRange = 0l;
Boolean isPartialRequest = false;
try {
if(request.getHeader("range") != null) {
String rangeStr = request.getHeader("range");
System.out.println(rangeStr);
String[] range = rangeStr
.replace("bytes=", "").split("-");
startRange = range[0] != null ? Long.parseLong(range[0]) : 0l;
if(range[1] != null) {
endRange = Long.parseLong(range[1]);
isPartialRequest = true;
}
System.out.println(">>>>> range >>>>> " + range[0] + " : " + range[1]);
}
} catch(NullPointerException | ArrayIndexOutOfBoundsException e) {
System.err.println(e);
}
// ****************** //
// 파일 다운로드 이름 생성
String downloadName = id + "-" + mp3.getCustomTitle() + ".mp3";
String browser = request.getHeader("User-Agent");
// 파일 인코딩
if(browser.contains("MSIE") || browser.contains("Trident") || browser.contains("Chrome")){
// 브라우저 확인 파일명 encode
downloadName = URLEncoder.encode(downloadName, "UTF-8").replaceAll("\\+", "%20");
} else {
downloadName = new String(downloadName.getBytes("UTF-8"), "ISO-8859-1");
}
// 리스폰스 헤더 설정
response.setHeader("Content-Disposition", "filename=\"" + downloadName +"\"");
response.setContentType("audio/mpeg");
response.setHeader("Accept-Ranges", "bytes"); // 크롬 구간문제 해결용
response.setHeader("Content-Transfer-Encoding", "binary;");
// 부분 범위 리퀘스트인지, 전체 범위 리퀘스트인지에 따라 Content-Range 값을 다르게
if(isPartialRequest) {
response.setHeader("Content-Range", "bytes " + startRange + "-"
+ endRange + "/" + initFile.length());
} else {
response.setHeader("Content-Length", initFile.length() + "");
response.setHeader("Content-Range", "bytes 0-"
+ initFile.length() + "/" + initFile.length());
startRange = 0l;
endRange = initFile.length();
}
// 랜덤 액세스 파일을 이용해 mp3 파일을 범위로 읽기
try(RandomAccessFile randomAccessFile = new RandomAccessFile(initFile, "r");
ServletOutputStream sos = response.getOutputStream(); ){
Integer bufferSize = 1024, data = 0;
byte[] b = new byte[bufferSize];
Long count = startRange;
Long requestSize = endRange – startRange + 1;
// startRange에서 출발
randomAccessFile.seek(startRange);
while(true) {
// 버퍼 사이즈 (1024) 보다 범위가 작으면
if(requestSize <= 2) {
// Range byte 0-1은 아래 의미가 아님.
// data = randomAccessFile.read(b, 0, requestSize.intValue());
// sos.write(b, 0, data);
// ** write 없이 바로 flush ** //
sos.flush();
break;
}
// 나머지는 일반적으로 진행
data = randomAccessFile.read(b, 0, b.length);
// count가 endRange 이상이면 요청 범위를 넘어선 것이므로 종료
if(count <= endRange) {
sos.write(b, 0, data);
count += bufferSize;
randomAccessFile.seek(count);
} else {
break;
}
}
sos.flush();
}
}
// ………. 생략 ………. //
}

 

문의 | 코멘트 또는 yoonbumtae@gmail.com




2개의 댓글

곰팡이 · 2021년 12월 2일 3:25 오후

와우.. 덕분에 문제해결에 큰 도움이 되었어요
사파리는 진짜 노답인듯하네요 ㅠ
다른 브라우저에 대응이 되도, 사파리만 항상 문제가 발생해서 파일 seek 생각했지만
실제 고민하셨던분이 또 계셨군요 ㅎㅎ

코드도 깔끔하고 많이 배워갑니다~

스프링 부트(Spring Boot) 미디 플레이어 만들기 (2): 업로드 페이지, 임시 재생 플레이어 만들기 - BGSMM · 2020년 8월 17일 7:59 오후

[…] 스프링 부트 (Spring Boot): mp3을 전송하는 컨트롤러 – 크롬 및 사파리(Safari… […]

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다