포스트

Python tqdm, froglog 줄바꿈 문제 해결하기 (우회, 콜백)

프로세스의 진행 상황을 시각적으로 표시하는 진행 표시줄(Progress Bar)은 사용자 경험의 중요한 부분입니다.

1.

python의 tqdm의 경우에는 기본적으로 이어서 진행되지만,

진행바가 변하는 동안 다른 print나 logger 출력이 끼어들게 되면 줄바꿈이 일어납니다.

저는 이 문제를 다음과 같이 해결했습니다.

“진행바 설정에 관련 세팅을 해도 해결이 안됨”

-> “진행바가 업데이트 될 때, 그 외 stdout은 다른 로그 stdout으로 우회해놓아서 기존 진행바가 줄바꿈 되지 않도록 함.”

-> “해결”

2.

이렇게 해결할 수 없는 경우도 있습니다.

바로 라이브러리나 패키지 내에서 자체적으로 진행상황을 출력하는 경우입니다.

보통은 문제 없이 한 줄로 보여서 문제 없겠지만,

간혹 저처럼 프로그램 간에 로그 출력을 전달하고자 할 때 여러 줄로 줄바꿈이 일어난다거나,

어떤 다른 이유로 한 줄로 출력을 못하고 있는 경우에는

내부 코드 변경이 불가하기에 해결하기가 다소 난해해보일 수 있습니다.

저는 moviepy라는 패키지의 write_videofile이라는 메소드 내에서 그런 진행바 출력이 이뤄지고 있었습니다.

그런데 다행히 write_videofile 메소드가 froglog 타입의 logger를 인풋으로 받을 수 있는 구조였습니다.

그래서 froglog를 상속받은 커스텀 로거에 callback 메소드를 구현해서 거기서 내부의 진행상황 수치를 밖으로 꺼낼 수 있었습니다.

밖으로 꺼낸 후에는, 익숙한 tqdm을 사용해 커스텀 진행바를 구현하여 (1)의 문제 해결방안을 마찬가지로 적용해 해결했습니다.

“moviepy 내의 진행바의 경우, 로거를 인풋으로 넣게 되어있으나 세밀한 조작에 한계가 있음”

-> “로거에 콜백을 넣어서 받아낸 진행률을 바깥에서 커스텀 진행바로 출력하도록 함.”

-> “해결”

*이어지는 글은 chatgpt의 도움을 받아 이해하기 쉽도록 재작성한 글입니다. *


문제 배경

복잡한 프로세스의 진행 상황을 시각적으로 표시하는 진행 표시줄(Progress Bar)은 사용자 경험의 중요한 부분입니다.

특히 장시간 실행되는 작업에서는 더욱 중요합니다.

그러나 프로그램 구조가 복잡해지면 진행 표시줄의 표시에도 문제가 발생할 수 있습니다.

우리의 시스템 구조는 다음과 같습니다:

1
2
3
4
5
6
┌─────────────────────┐      ┌─────────────────────┐      ┌─────────────────┐
│                     │      │                     │      │                 │
│  매니저 프로그램       │ ──▶  │  서브 프로그램        │ ──▶ │  로그 출력        │
│  (외부 관리자)        │      │  (실제 작업 수행)     │      │  (진행 표시줄)    │
│                     │      │                     │      │                 │
└─────────────────────┘      └─────────────────────┘      └─────────────────┘
  • 외부 매니저 프로그램은 서브 프로그램을 실행하고 그 로그를 받아 표시함
  • 서브 프로그램은 tqdm 또는 MoviePy의 froglog를 사용해 진행 표시줄을 출력함
  • 문제: 진행 표시줄이 매 업데이트마다 새로운 줄에 표시되어 가독성 저하

문제 상세 분석

  1. tqdm 진행 표시줄 문제 tqdm 라이브러리는 기본적으로 한 줄에서 진행 표시줄을 업데이트하지만, 다른 출력이 중간에 끼어들면 다음과 같은 문제가 발생합니다:
1
2
3
4
5
바디 타임라인 블록을 완성하는 중: 10%|██        | 1/10 [00:00<00:00, 5.00block/s]
[DEBUG] 캡션 이미지 처리 중...
바디 타임라인 블록을 완성하는 중: 20%|████      | 2/10 [00:00<00:00, 4.95block/s]
[DEBUG] 캡션 이미지 처리 중...
바디 타임라인 블록을 완성하는 중: 30%|██████    | 3/10 [00:00<00:00, 4.92block/s]

진행 표시줄이 매번 새 줄에 표시되어 로그가 길어지고 가독성이 떨어집니다.

  1. MoviePy 진행 표시줄 문제 MoviePy는 자체 진행 상황 표시 메커니즘을 가지고 있어 더 복잡한 문제를 야기합니다:
1
2
3
chunk:   0%|          | 0/7168 [00:00<?, ?it/s, now&#x3D;None]
chunk:   1%|1         | 101/7168 [00:00<00:07, 1008.70it/s, now&#x3D;None]
chunk:   5%|4         | 340/7168 [00:00<00:03, 1818.27it/s, now&#x3D;None]

이 역시 매번 새 줄에 진행 표시줄이 생성되어 보기 좋지 않습니다.


해결 방법

접근 1: 표준 출력(stdout) 리디렉션으로 tqdm 문제 해결 tqdm 진행 표시줄이 중간의 다른 출력으로 인해 끊기는 문제를 해결하기 위해 표준 출력을 리디렉션했습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 임시로 표준 출력을 로그 파일로 리디렉션
original_stdout &#x3D; sys.stdout
log_file_path &#x3D; new_log_filename("caption")
log_file &#x3D; open(log_file_path, "w", encoding&#x3D;"utf-8")
sys.stdout &#x3D; log_file

try:
    # tqdm은 original_stdout으로 출력
    with tqdm(total&#x3D;bt_total, desc&#x3D;"바디 타임라인 블록을 완성하는 중", unit&#x3D;"block", 
              file&#x3D;original_stdout, leave&#x3D;True, ascii&#x3D;True, 
              dynamic_ncols&#x3D;True, position&#x3D;0) as pbar:
        # 실행 코드...
finally:
    # 표준 출력 복원
    sys.stdout &#x3D; original_stdout
    log_file.close()
  • 모든 일반 출력(print 등)은 로그 파일로 리디렉션됨
  • tqdm 진행 표시줄만 원래 표준 출력(콘솔)에 표시됨
  • 결과적으로 다른 출력이 진행 표시줄을 방해하지 않음
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
class TqdmProgressWrapper(ProgressBarLogger):
    """MoviePy의 진행 상황을 포착하여 tqdm으로 표시하는 래퍼 클래스"""
    
    def __init__(self, init_state&#x3D;None, bars&#x3D;None, ignored_bars&#x3D;None, logged_bars&#x3D;None, **kwargs):
        super().__init__(init_state, bars, ignored_bars, logged_bars, **kwargs)
        self._tqdm_bars &#x3D; {}  # 진행 바 인스턴스를 저장할 딕셔너리
        self.tqdm_settings &#x3D; tqdm_settings.copy()
    
    def bars_callback(self, bar_name, attr, value, old_value&#x3D;None):
        # 진행 바 생성 (필요한 경우)
        if bar_name not in self._tqdm_bars:
            if attr &#x3D;&#x3D; &#x27;total&#x27;:
                # 진행 바 이름 설정 및 초기화
                desc &#x3D; f"{bar_name} 처리 중" if bar_name not in [&#x27;chunk&#x27;, &#x27;frame&#x27;, &#x27;writing&#x27;] else {
                    &#x27;chunk&#x27;: "청크 인코딩 중",
                    &#x27;frame&#x27;: "프레임 처리 중",
                    &#x27;writing&#x27;: "파일 쓰기 중"
                }[bar_name]
                
                self._tqdm_bars[bar_name] &#x3D; tqdm(
                    total&#x3D;value,
                    desc&#x3D;desc,
                    **self.tqdm_settings
                )
        
        # 진행 상황 업데이트
        if attr &#x3D;&#x3D; &#x27;index&#x27; and bar_name in self._tqdm_bars:
            bar &#x3D; self._tqdm_bars[bar_name]
            if value > bar.n:
                bar.update(value - bar.n)
  • MoviePy의 ProgressBarLogger 클래스를 상속하여 내부 이벤트 수신
  • 진행 상황 데이터를 캡처하여 외부 tqdm 객체로 표시
  • 데이터 흐름을 완전히 제어하여 원하는 형태로 진행 상황 표시
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
    def make_full_video(self, compositeVideoClips: list[CompositeVideoClip] &#x3D; None) -> CompositeVideoClip:
        if not compositeVideoClips:
            raise ValueError("비디오 클립이 없습니다.")
        myLogger.info("비디오 클립을 합성하는 중...")
        final_video &#x3D; concatenate_videoclips(compositeVideoClips)
        myLogger.info("비디오 클립을 저장하는 중...")

        # 임시로 표준 출력을 로그 파일로 리디렉션
        original_stdout &#x3D; sys.stdout
        log_file_path &#x3D; new_log_filename("moviepy")
        log_file &#x3D; open(log_file_path, "w", encoding&#x3D;"utf-8")
        sys.stdout &#x3D; log_file

        # tqdm 설정
        tqdm_settings &#x3D; {"leave": True, "ascii": True, "file": original_stdout, "dynamic_ncols": True, "position": 0, "miniters": 1, "mininterval": 0.1}

        # MoviePy 진행 상황 포착 및 tqdm 표시
        from proglog import ProgressBarLogger

        class TqdmProgressWrapper(ProgressBarLogger):
            """MoviePy의 진행 상황을 포착하여 tqdm으로 표시하는 래퍼 클래스"""

            def __init__(self, init_state&#x3D;None, bars&#x3D;None, ignored_bars&#x3D;None, logged_bars&#x3D;None, **kwargs):
                super().__init__(init_state, bars, ignored_bars, logged_bars, **kwargs)
                # bars 인스턴스 변수로 다시 초기화 (상속된 속성을 덮어씀)
                self._tqdm_bars &#x3D; {}  # 진행 바 인스턴스를 저장할 딕셔너리
                self.tqdm_settings &#x3D; tqdm_settings.copy()

            def bars_callback(self, bar_name, attr, value, old_value&#x3D;None):
                # 진행 바 생성 (필요한 경우)
                if bar_name not in self._tqdm_bars:
                    if attr &#x3D;&#x3D; "total":
                        # 진행 바 이름 설정
                        if bar_name &#x3D;&#x3D; "chunk":
                            desc &#x3D; "청크 인코딩 중"
                        elif bar_name &#x3D;&#x3D; "frame":
                            desc &#x3D; "프레임 처리 중"
                        elif bar_name &#x3D;&#x3D; "writing":
                            desc &#x3D; "파일 쓰기 중"
                        else:
                            desc &#x3D; f"{bar_name} 처리 중"

                        # 진행 바 초기화
                        self._tqdm_bars[bar_name] &#x3D; tqdm(total&#x3D;value, desc&#x3D;desc, **self.tqdm_settings)

                # 진행 상황 업데이트
                if attr &#x3D;&#x3D; "index" and bar_name in self._tqdm_bars:
                    # 현재 위치 값 업데이트
                    bar &#x3D; self._tqdm_bars[bar_name]
                    if value > bar.n:  # 진행이 있을 때만 업데이트
                        bar.update(value - bar.n)

                    # 작업 완료 시 100% 표시
                    if value >&#x3D; bar.total:
                        bar.n &#x3D; bar.total
                        bar.refresh()

                # 총 작업량 업데이트
                elif attr &#x3D;&#x3D; "total" and bar_name in self._tqdm_bars:
                    self._tqdm_bars[bar_name].total &#x3D; value

            def clean_up(self):
                """모든 진행 바 닫기"""
                for bar in self._tqdm_bars.values():
                    bar.close()
                self._tqdm_bars.clear()

        # 진행 상황 로거 인스턴스 생성
        progress_logger &#x3D; TqdmProgressWrapper()

        try:
            # 비디오 파일 작성
            myLogger.info("비디오 파일 작성 시작")
            final_video.write_videofile(self.output_video_path, fps&#x3D;self.fps, logger&#x3D;progress_logger, remove_temp&#x3D;False, temp_audiofile&#x3D;self.output_audio_path)
            myLogger.info(f"비디오 저장: {self.output_video_path}")
        finally:
            # 진행 바 정리
            progress_logger.clean_up()

            # 표준 출력 복원
            sys.stdout &#x3D; original_stdout
            log_file.close()

        return final_video

최종 솔루션 아키텍처

수정된 시스템 구조는 다음과 같습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────┐      ┌─────────────────────┐      ┌─────────────────┐
│                     │      │                     │      │                 │
│  매니저 프로그램       │ ──▶ │  서브 프로그램        │ ──▶  │  로그 출력       │
│  (외부 관리자)       │      │  (실제 작업 수행)     │      │  (파일로 저장)    │
│                     │      │                     │      │                 │
└─────────────────────┘      └─────────────────────┘      └─────────────────┘
                                       │
                                       ▼
                             ┌─────────────────────┐
                             │                     │
                             │  tqdm 진행 표시줄     │
                             │  (원래 콘솔에 표시)    │
                             │                     │
                             └─────────────────────┘

  • stdout 리디렉션: 일반 로그 메시지는 파일로 보내고, tqdm 진행 표시줄만 콘솔에 표시
  • 커스텀 로거와 콜백: 서드파티 라이브러리(MoviePy)의 내부 진행 상황 데이터를 캡처하여 자체 tqdm 객체로 시각화
  • 자원 관리: try-finally 블록으로 리소스를 안전하게 정리하여 메모리 누수 방지

  • 중첩된 프로세스에서의 사용자 인터페이스 관리는 어려운 문제가 될 수 있습니다.
  • 표준 출력 리디렉션은 출력 스트림을 분리하는 강력한 방법입니다.
  • 라이브러리의 내부 메커니즘을 이해하면 외부에서 그 동작을 제어할 수 있습니다.
  • 콜백 기반 아키텍처는 복잡한 프로세스의 상태를 모니터링하고 시각화하는 데 매우 유용합니다.

sticker

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.