💻문제점1
영상관련 파일과 썸네일은 도커 볼륨에 저장된다. 원활한 스트리밍을 위해서는 m3u8파일과 재생목록에 대응되는 ts 파일을 지속적으로 수신해 재생해야 하는데, 데이터가 계속 쌓이면서 문제가 발생했다. 클라이언트가 올바른 m3u8 파일을 받지 못해 정상적으로 재생이 되지 않는 것이다.
K6를 통해 테스트하고, Grafana를 통해 시각화하였다. 시나리오는 다음과 같다.
한 명의 스트리머가 방송을 송출하고, 방송 시청을 보내는 가상 유저 1000명, Duration 5분으로 테스트 해보았다.
그랬더니, 다음과 같이 성공률이 23%로 매우 낮은 성능을 보였다.
실시간 스트리밍 서비스는 '스트리밍 서비스'라는 특성 상, 시간이 지나면 영상 데이터는 사용 가치가 없어지는 단발적이고 소모적인 일회성 성질이 강했다. (그래서 도커 볼륨에 저장한 이유기도 하다) 그래서 우리는 데이터가 쌓이는 문제를 해결하기 위해 영상 관련 데이터를 주기적으로 삭제하기로 했다.
🔍문제점1-해결
@Value("${stream.directory}")
private String path;
// 수행할 시간 간격
private static final long delete_interval = 1L;
// 주기적인 작업을 스케쥴링하고 실행할 수 있는 단일 스레드 스케쥴러를 생성
private final ScheduledExecutorService deleteFile = Executors.newSingleThreadScheduledExecutor();
private void init() {
/* this::deleteOldFiles: deleteOldFiles 메서드 주기적으로 실행
* delete_interval: 작업이 처음으로 실행될 때까지의 초기 지연시간
* delete_interval: 이후 작업이 반복 실행될 시간 간격
* TimeUnit.MINUTES: delete_interval이 표현하는 시간 단위가 분
*/
deleteFile.scheduleAtFixedRate(this::deleteOldFiles, delete_interval, delete_interval, TimeUnit.MINUTES);
}
// 기존 .ts 파일 삭제를 수행하는 메서드
private void deleteOldFiles() {
log.info("Deleting old .ts files ...");
try {
// 현재 시간에서 1분 전 시간 계산
final long oneMinuteAgo = Instant.now().minus(1, ChronoUnit.MINUTES).toEpochMilli();
Files.walk(Paths.get(path))
.filter(Files::isRegularFile)
.filter(file -> file.getFileName().toString().endsWith(".ts"))
.forEach(file -> {
try {
BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
FileTime fileTime = attrs.lastModifiedTime();
if (fileTime.toMillis() < oneMinuteAgo) {
Files.delete(file);
log.info("Deleted file: {}", file.toAbsolutePath().toString());
}
} catch (IOException e) {
log.error("Failed to delete file {}", file.toAbsolutePath().toString(), e);
}
});
} catch (IOException e) {
log.error("Failed to delete old .ts files", e);
}
}
단일 스레드 스케줄러를 생성해서, 주기적으로 파일을 삭제하는 deleteOldFiles() 메서드를 수행하고자 했다.
파일 삭제 로직은 1분마다 동작하도록 설정했다.
deleteOldFiles() 메서드를 살펴보자.
1) 1분 전 시간 계산
final long oneMinuteAgo = Instant.now().minus(1, ChronoUnit.MINUTES).toEpochMilli();
Time API의 Instant 클래스를 사용했다. Intstant.now() 를 통해 현재 시간의 Instant 객체를 얻고, .minus(1, ChronoUnit) 을 통해 현재 시간에서 1분을 빼서 1분 전의 시간을 계산했다. (이 메서드가 실행된 시점에서 1분 전의 파일을 삭제해야하니까!)
2) 파일 탐색
Files.walk(Paths.get(path))
.filter(Files::isRegularFile)
.filter(file -> file.getFileName().toString().endsWith(".ts"))
.forEach(file -> {
try {
BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
FileTime fileTime = attrs.lastModifiedTime();
if (fileTime.toMillis() < oneMinuteAgo) {
Files.delete(file);
log.info("Deleted file: {}", file.toAbsolutePath().toString());
}
} catch (IOException e) {
log.error("Failed to delete file {}", file.toAbsolutePath().toString(), e);
}
});
- Files.walk(Paths.get(path))
Files.walk에 Path 객체를 전달하면 해당 경로의 하위 폴더 및 파일들을 탐색할 수 있다.
- filter부분
파일에 대한 Fath 객체로 필터링 한 후, 파일의 이름이 .ts로 끝나는 파일을 필터링 했다.
3) 파일 마지막 수정 시간
우리는 현재 시간에서 1분 시간을 안다. 그러면 이 1분 전 시간에 생성된 파일을 찾아 삭제해야 한다.
readAttributes() 메서드를 통해서 파일의 모든 속성을 가져와서 BasicFileAttributes 클래스로 리턴하도록 하고, lastModifiedTime() 메서드를 통해 마지막 수정 시간을 가져왔다.
4) 파일 삭제
앞서서 구한 (현재 시간 - 1분) 시간이 밀리초이기 때문에, 파일의 마지막 수정시간을 toMillies() 메서드를 통해 밀리초로 변환환했다. 마지막 수정 시간이 1분 전보다 더 됐을 경우 파일을 삭제하도록 했다.
이렇게 파일 삭제 로직을 작성하고 같은 환경에서 테스트를 해보았다.
기존 파일 자동 삭제 로직을 추가하여 로직이 없는 경우와 비교하여 성공률이 23% → 100%로 향상된 것을 확인할 수 있다!
💻문제점2
하지만 문제가 있었다. 여러 스트리머가 동시에 방송을 송출하는 상황에서 낮은 성능을 보여준 것이다.
테스트한 시나리오는 다음과 같다.
세 명의 스트리머가 방송을 송출하고, 방송 시청을 보내는 가상 유저가 각 채널당 15000명, Duration 30분으로 테스트 해보았다.
결과를 보면, 요청 실패율이 11.09%이다. 우리는 이 현상의 원인을 삭제 로직이 동기식으로 작성되어 있기 때문이라고 생각했다.
동기식 처리는 한 작업이 완료될 때까지 다음 작업이 대기해야한다. 때문에 동기식으로 작성하게 되면, 스트리머 한 명의 영상 관련 파일들이 모두 삭제될 때까지 다른 스트리머의 파일들은 삭제되지 못하고 대기해야 한다. 이 과정에서 한 파일이라도 삭제 작업에 지연이 발생하면 이후 작업도 영향을 받을 것이다. 결국 전체 시스템 성능에 영향을 주고 응답 시간도 증가할 것이다.
때문에 삭제 로직을 비동기로 변경하였다.
🔍문제점2-해결
@Service
@Slf4j
public class ProcessManagingService {
@Value("${ffmpeg.command}")
private String template;
@Value("${rtmp.server}")
private String address;
@Value("${stream.directory}")
private String path;
private static final long delete_interval = 1L;
private final ScheduledExecutorService deleteFile = Executors.newSingleThreadScheduledExecutor();
private final AtomicBoolean stopSearching = new AtomicBoolean(true); //여러 스레드에서 동시에 변수에 접근할 때 일관된 동작을 보장
/* 생략 */
private Flux<Path> walkFilesFlux(Path path) {
try {
// 파일의 확장자가 .ts 또는 .jpg인 파일 필터링
return Flux.fromStream(Files.walk(path)
.filter(file -> file.toString().endsWith(".ts") || file.toString().endsWith(".jpg")));
} catch (IOException e) {
log.error("Failed to walk files", e);
return Flux.empty();
}
}
// 주기적으로 .ts와 .jpg를 삭제하는 메서드
private void deleteOldTsAndJpgFiles(String owner) {
// 파일 탐색 동작이 중지(true)인 경우
if (stopSearching.get()) {
// 이전값이 true인 경우 if문 통과
// 현재값 가져오는 동시에 false로 재설정
if (showMessage.getAndSet(false)) {
log.info("No longer searching for files.");
}
return;
}
log.info("Deleting .ts and .jpg files ({}) ...", owner);
Path directoryPath = Paths.get(path);
try {
// 디렉토리 경로 내의 파일 목록
Flux<Path> dirPaths = Flux.fromStream(Files.list(directoryPath));
// 디렉토리인 경우만 필터링
dirPaths.filter(Files::isDirectory)
.filter(dirPath -> dirPath.toFile().getName().equals(owner)) // 해당 스트리머의 디렉토리만 필터링
.flatMap(dirPath -> {
List<Path> filesToDelete = new ArrayList<>(); // 삭제할 파일을 담을 리스트
return walkFilesFlux(dirPath)
.doOnNext(file -> {
try {
BasicFileAttributes attributes = Files.readAttributes(file, BasicFileAttributes.class); // 파일 속성
Instant currentInstant = Instant.now(); // 현재 시간
Instant fileCreationInstant = attributes.creationTime().toInstant(); // 파일 생성 시간
//파일 생성 후 경과 시간
final long elapsedTime = Duration.between(fileCreationInstant, currentInstant).toMinutes();
// 1분 이상된 파일인 경우 삭제 리스트에 추가
if (elapsedTime >= 1) {
filesToDelete.add(file);
}
} catch (IOException e) {
log.error("Failed to read attributes of file {}", file, e);
}
})
.doOnComplete(() -> {
// 삭제 리스트 내 파일 제거
for (Path file : filesToDelete) {
AtomicBoolean hasFiles = new AtomicBoolean(false); // 삭제한 파일이 있는지 여부
try {
// 확장자가 .ts 또는 .jpg인 경우
if (file.toString().endsWith(".ts") || file.toString().endsWith(".jpg")) {
// 파일 존재하면 삭제
Files.deleteIfExists(file);
hasFiles.set(true);
log.info("File deleted: {}", file);
}
} catch (IOException e) {
log.error("Failed to delete file {}", file, e);
}
if (!hasFiles.get()) {
stopSearching.set(true);
}
}
})
.then()
.subscribeOn(Schedulers.boundedElastic());
})
.subscribe();
} catch (IOException e) {
log.error("Failed to list directories", e);
}
}
/* 생략 */
}
walkFilesFlux() 메서드를 살펴보자.
이 메서드에서는 매개변수로 주어진 경로 하위의 폴더 및 파일을 탐색하고, 파일명이 .ts 또는 .jpg로 끝나는 파일을 필터링했다. (.jpg는 썸네일 이미지도 주기적으로 삭제하기 위해 추가했다) 이후, 필터링된 파일 경로의 스트림을 비동기로 처리하기 위해 Flux 타입으로 변환한다.
deleteOldTsAndJpgFiles() 메서드를 살펴보자.
1) 스트리머의 디렉토리
Path directoryPath = Paths.get(path);
try {
// 디렉토리 경오 내의 파일 목록
Flux<Path> dirPaths = Flux.fromStream(Files.list(directoryPath));
// 디렉토리인 경우만 필터링
dirPaths.filter(Files::isDirectory)
.filter(dirPath -> dirPath.toFile().getName().equals(owner)) // 해당 스트리머의 디렉토리만 필터링
파일을 삭제하고자 하는 스트리머의 닉네임과 이름이 같은 디렉토리를 찾는다.
2) 파일 탐색
.flatMap(dirPath -> {
List<Path> filesToDelete = new ArrayList<>(); // 삭제할 파일을 담을 리스트
return walkFilesFlux(dirPath)
.doOnNext(file -> {
try {
BasicFileAttributes attributes = Files.readAttributes(file, BasicFileAttributes.class); // 파일 속성
Instant currentInstant = Instant.now(); // 현재 시간
Instant fileCreationInstant = attributes.creationTime().toInstant(); // 파일 생성 시간
//파일 생성 후 경과 시간
final long elapsedTime = Duration.between(fileCreationInstant, currentInstant).toMinutes();
// 1분 이상된 파일인 경우 삭제 리스트에 추가
if (elapsedTime >= 1) {
filesToDelete.add(file);
}
} catch (IOException e) {
log.error("Failed to read attributes of file {}", file, e);
}
})
위에서 찾은 스트리머의 디렉토리를 인자로 walkFilesFlux()메서드를 호출한다. (.ts 또는 .jpg 파일만 필터링된 Flux)데이터가 Flux 시퀀스로 변환되어, 각 Path 객체가 전달되면서 doOnNext()가 수행된다.
파일이 수정되는 경우는 없어서, 생성 시간으로 변경한 점을 제외하면 동기식 처리 흐름과 거의 동일하다.
- filesToDelete.add(file)
파일을 바로 삭제하지 않고 삭제 목록(리스트)에 추가했다.
3) 파일 삭제
.doOnComplete(() -> {
// 삭제 리스트 내 파일 제거
for (Path file : filesToDelete) {
AtomicBoolean hasFiles = new AtomicBoolean(false); // 삭제한 파일이 있는지 여부
try {
// 확장자가 .ts 또는 .jpg인 경우
if (file.toString().endsWith(".ts") || file.toString().endsWith(".jpg")) {
// 파일 존재하면 삭제
Files.deleteIfExists(file);
hasFiles.set(true);
log.info("File deleted: {}", file);
}
} catch (IOException e) {
log.error("Failed to delete file {}", file, e);
}
if (!hasFiles.get()) {
stopSearching.set(true);
}
}
})
doOnComplete() 는 모든 Flux 데이터 방출이 끝났을 때(완료) 수행된다.
이 과정에서 삭제 리스트에 있는 Path 객체를 삭제한다.
AtomicBoolean 클래스를 통해 여러 스레드에서 동시에 변수에 접근할 때 동시성을 보장하도록 했다.
삭제할 파일이 없으면 stopSearching 변수를 true로 설정해서 파일 탐색을 더이상 수행하지 않도록 했다.
동일한 상황에서 다시 테스트 해보았다.
동기식 처리 시에는 영상 데이터 전송 실패율은 11.09% 였으나, 비동기식으로 변경한 뒤에는 0.49%로 총 95.57% 감소율을 확인할 수 있다.
'TIL' 카테고리의 다른 글
[TIL-20231220] 리플렉션을 사용한 private 필드 접근 및 값 설정 (0) | 2023.12.20 |
---|---|
[TIL - 20230914~15] Github Actions+Docker CI/CD 트러블슈팅 (0) | 2023.09.15 |
[커뮤 프로젝트] 서버 아키텍처 변경을 통한 성능 개선 (0) | 2023.07.02 |
[TIL - 20230628] 후원 비관적 락 -> Redis 분산 락 변경 (동시성) (0) | 2023.06.29 |
[TIL - 20230623] rsocket 채팅창에 후원 내역 띄우기 (0) | 2023.06.24 |