낙서하듯이 그린 그림을 멋진 그림으로 바꿔주는 인공지능 기반 웹 서비스를 개발하고 있다. 이번 포스팅에서는 프로젝트를 진행하면서 고민했던 부분에 대해서 적어볼려고 한다.
프로젝트 구조
GPU 가상 서버는 비싸기에 로컬 컴퓨터를 이용하여 프로젝트를 진행했다.
- Ubuntu기반의 가상 머신을 Nginx로 사용하였으며 URL기준으로 프론트, 백엔드 포트로 포워딩해준다.
- 백엔드 서버는 요청으로 들어온 사진을 인공지능 서버에 가공 요청한다.
- 인공지능 서버의 작업이 완료되면 결과를 메일로 전송한다.
Ubuntu 가상 머신을 사용한 이유
- 로컬PC를 이용해 다른 작업도 하다 보니 제한된 메모리와 자원을 할당하고 관리하고 싶었다.
- 가상환경에서, 특히 VirtualBox에서 GPU사용이 생각보다 까다로웠다.
프로젝트 기능 구현 고민
인공지능 모델의 적절한 임계값 설정
ControlNet이라는 오픈 소스를 기반으로 인공지능 모델을 수정 및 개발하였다. 설정할 수 있는 옵션 값들이 너무 많아 자연스럽게 사진이 나올 수 있는 임계값을 찾아 기본값으로 설정해놓았다.
GPU 메모리 초과
클라이언트가 백엔드 서버로 사진을 전송하면 백엔드 서버는 사진을 저장소에 저장하고 인공지능 모델을 가동한다. 이때 ProcessBuilder를 이용하여 파이썬 스크립트를 실행한다. 문제는 사용자의 요청만큼 파이썬 스크립트를 실행한다는 것이다. 따라서 한 번에 하나의 요청만 처리할 수 있음에도 불구하고 하나 이상의 프로세스를 실행하게 되어 결국 메모리 초과가 발생한다.
이를 해결하기 위해 생각한 방법은 다음과 같다.
- Synchronized를 이용한 동기화 방법 : 뒤로 들어온 요청들은 쓰레드를 물고 대기하고 있어야 하기 때문에 효율적이지 않다고 판단하여 제외했다.
- Queue와 공회전을 이용한 방법 : 이것도 불필요한 자원을 소모하기에 제외했다.
while(true) {
if(!queue.isEmpty()) {
Request request = queue.poll();
ProcessBuilder processBuilder = new ProcessBuilder("cmd.exe", "/c", request.script);
}
}
3. newSingleThreadExecutor()을 이용한 방법 : 쓰레드를 하나만 가지는 Executor를 생성하고 이 쓰레드가 인공지능 모델을 동작시킨다.
private ExecutorService aiThreadPool = Executors.newSingleThreadExecutor();
private volatile boolean isRunning = false;
private void processImage() {
if(!isRunning) startAiModel();
}
private void startAiModel() {
isRunning = true;
aiThreadPool.execute(() -> {
try {
while(!queue.isEmpty()) {
Request request = queue.poll();
ProcessBuilder processBuilder = new ProcessBuilder("cmd.exe", "/c", request.script);
processBuilder.inheritIO();
Process process = processBuilder.start();
process.waitFor();
emaiLService.sendEmail(request.email, request.filename);
}
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
});
isRunning = false;
}
처음에는 newFixedThreadPool(1)을 이용하였는데, newFixedThreadPool()은 이름과 달리 요청이 여러개 들어오면 setCorePoolSize만큼 용량을 늘린다. 따라서 쓰레드를 꼭 하나로 고정시켜야 한다면 newSingleThreadExecutor를 사용해야 한다.
SSE를 통한 결과 통보
이미지 처리 과정을 실시간으로 사용자에게 보여주기 위해 SSE를 구현했다. SseEmitter를 통해 사용자가 구독하면 인공지능 서버의 로그를 실시간으로 클라이언트에게 전달했다.
// 구독
@GetMapping("/connect")
public ResponseEntity<SseEmitter> readyProcess(String key) {
SseEmitter emitter = scribbleService.ready(key);
try {
emitter.send(SseEmitter.event()
.name("connect")
.data("connected"));
} catch (IOException e) {
throw new RuntimeException(e);
}
emitter.onCompletion(() -> {
log.info("onCompletion callback");
ScribbleService.emitters.remove(emitter);
});
emitter.onTimeout(() -> {
log.info("complete");
emitter.complete();
});
return ResponseEntity.ok(emitter);
}
public void startProcess(String key, String filename, String command) {
SseEmitter sseEmitter = emitters.get(key);
try {
...
Process process = processBuilder.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
sseEmitter.send(SseEmitter.event()
.name("stage")
.data(line)
);
}
} catch (IOException e) {}
...
}
이 방법은 사용자 실시간으로 처리율을 볼 수 있다는 장점은 있었지만, 많은 요청이 있는 경우 대기하는 시간도 길어지기에 이메일 전송 기능으로 대체했다.