37강. 예외 처리와 안정화 - 테스트와 버그 수정

0. 학습 목표
→ 이번 글에서 무엇을 이해하고, 무엇을 만들고, 무엇을 확인할지 먼저 정리합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
36강에서 최종 프로젝트 구조를 정리했습니다. 기능은 모두 들어 있지만, 기능이 있다고 해서 곧바로 "안정적인 프로그램"이 되는 것은 아닙니다. 정상 흐름만 만들어 두면 사용자가 예상 밖의 행동(서버가 꺼진 채 접속, 도중에 창 닫기 등)을 했을 때 프로그램이 그대로 멈춰 버립니다. 이번 강의에서는 실제 사용 중 생길 수 있는 오류 상황을 하나씩 점검하고 예외 처리를 더합니다.
서버가 꺼져 있다 → 접속 실패를 안내하고 멈추지 않는다
서버가 중간에 종료된다 → 연결 끊김을 감지해 상태를 되돌린다
클라이언트가 갑자기 창을 닫는다 → 서버가 접속자를 정리한다
같은 이름의 파일을 또 보낸다 → 덮어쓰지 않고 _1, _2 로 저장한다
파일 전송 중 오류가 발생한다 → 열어 둔 파일을 닫고 상태를 정리한다

| 구분 | 내용 |
| 이해할 것 | 기능 구현 후에는 비정상 흐름을 예상하고 안정화해야 한다는 것 |
| 만들 것 | 파일명 중복 방지 함수, 파일 수신 정리 함수, 연결 끊김·전송 실패 예외 처리 |
| 확인할 것 | 오류 상황에서도 서버와 GUI가 멈추지 않고 상태를 정리하는지 |
0.2 이번 강의에서 직접 다루는 구조
이번 강의에서는 server.py와 main.py 두 파일을 보완합니다. 새 파일은 만들지 않습니다.
chat_server/
├── protocol.py
└── server.py ← ✏️ 이번 강의에서 보완 (안정화)
chat_client/
├── protocol.py
├── client.py
├── main.py ← ✏️ 이번 강의에서 보완 (안정화)
└── downloads/
└── received_files/
(➕ 새로 생성 · ✏️ 수정 · 표시 없음은 변경 없음)
두 파일에 추가되는 안정화 항목은 다음과 같습니다.
server.py
├── get_unique_path() ← ➕ 파일명 중복 방지
├── cleanup_receiving_file() ← ➕ 수신 중 오류 정리
└── handle_file_chunk() ← ✏️ 예외 처리 보강
main.py (ChatWindow)
├── receive_loop() ← ✏️ 연결 끊김 시 state 전달
├── display_message() ← ✏️ state 분기 추가
└── send_file() ← ✏️ 전송 실패 시 상태 초기화
0.3 📦 Part 8 초반 구현
part8a_final.zip
├── README.md ← 36강 역할 경계 + 37강 안정화 항목 정리
├── chat_server/
│ ├── protocol.py ← Part 7과 동일
│ ├── server.py ← ★ 37강 완성본 (get_unique_path + cleanup + 예외처리)
│ └── received_files/
└── chat_client/
├── protocol.py ← Part 7과 동일
└── main.py ← ★ 37강 완성본 (state Signal + 진행률 초기화)
1. 안정화 기준 정리하기
→ 안정화가 필요한 이유와 이번 강의에서 처리할 우선순위를 정합니다.
1.1 안정화가 필요한 이유
처음 구현할 때는 "정상 흐름"을 먼저 만듭니다. 하지만 실제 사용에서는 "비정상 흐름"도 반드시 일어납니다. 이번 강의의 목표는 모든 오류를 완벽하게 막는 것이 아니라, 초급 프로젝트에서 자주 발생하는 오류를 친절하게 처리하고 프로그램이 갑자기 멈추지 않게 만드는 것입니다.
대표적인 예가 비행기와 자동차의 안전벨트입니다. 평소에는 쓸 일이 없지만, 사고 순간에 없으면 치명적입니다. 예외 처리도 정상 동작에는 보이지 않지만, 오류가 난 그 한 번의 순간에 프로그램을 살립니다.
1.2 안정화 우선순위
오류를 모두 같은 비중으로 다루면 끝이 없습니다. 사용자가 자주 만나는 순서대로 우선순위를 정해 처리합니다.
| 순위 | 항목 | 이유 |
| 1 | 서버 연결 실패 처리 | 사용자가 가장 자주 만나는 오류 |
| 2 | 연결 끊김 처리 | 서버 종료나 네트워크 문제 대비 |
| 3 | 파일명 중복 처리 | 파일 저장 시 원본 덮어쓰기 방지 |
| 4 | 파일 전송 실패 처리 | 파일 선택 후 삭제·권한 문제 대비 |
| 5 | 기능별 회귀 테스트 | 최종 제출 전 검증 기준 확보 |
✔ 확인 기준: 서버 없이 GUI를 실행하고 접속 버튼을 눌렀을 때, 프로그램이 종료되지 않고 [오류] 서버가 실행 중인지 확인하세요.가 표시되면 완료. (이 처리는 이전 강의에서 만든 접속 예외 처리로 동작합니다.)
2. 서버 - 파일명 중복 방지 만들기
→ get_unique_path() 함수를 만들고 handle_file_info()의 저장 경로에 적용합니다.
2.1 중복 파일명 방지 함수 만들기
34강에서는 같은 이름의 파일을 받으면 기존 파일을 덮어쓸 수 있었습니다. 이번에는 같은 이름이 이미 있으면 _1, _2를 붙여 새 이름으로 저장합니다.
def get_unique_path(directory, filename): # ➕ 겹치지 않는 저장 경로 반환
base_name, extension = os.path.splitext(filename) # 이름과 확장자 분리 (sample, .png)
candidate = os.path.join(directory, filename) # 우선 원래 이름으로 시도
count = 1
while os.path.exists(candidate): # 같은 이름이 이미 있는 동안 반복
new_filename = f"{base_name}_{count}{extension}" # sample_1.png, sample_2.png ...
candidate = os.path.join(directory, new_filename)
count += 1
return candidate # 겹치지 않는 최종 경로 반환
같은 파일을 반복해서 보내면 다음과 같이 이름이 바뀌며 저장됩니다.
sample.png → sample.png
sample.png (재전송) → sample_1.png
sample.png (재재전송) → sample_2.png
2.2 handle_file_info()에 적용하기
파일 수신을 시작할 때 저장 경로를 만드는 한 줄을 get_unique_path()로 바꿉니다. 나머지 코드는 그대로입니다.
# save_path = os.path.join(RECEIVE_DIR, safe_filename) # 기존 — 같은 이름이면 덮어씀
save_path = get_unique_path(RECEIVE_DIR, safe_filename) # ✏️ 겹치면 _1, _2 로 저장
✔ 확인 기준: 같은 파일을 두 번 전송했을 때 received_files/에 sample.png와 sample_1.png이 함께 남으면 완료. handle_file_info()의 저장 경로가 get_unique_path()를 거치는지 확인하세요.
3. 서버 - 파일 수신 오류 정리하기
→ cleanup_receiving_file()을 만들고 handle_file_chunk()에 예외 처리를 더합니다.
3.1 파일 수신 정리 함수 만들기
파일을 조각으로 받는 도중 오류가 나면, 열어 둔 파일을 닫고 수신 목록에서 제거해야 합니다. 그러지 않으면 깨진 파일이 남고 같은 file_id가 계속 메모리에 남습니다.
def cleanup_receiving_file(file_id): # ➕ 수신 중단 시 뒷정리
file_info = receiving_files.get(file_id)
if file_info is None: # 이미 정리됐으면 할 일 없음
return
try:
file_info["file"].close() # 열려 있던 파일 닫기
except Exception:
pass # 닫기 실패는 무시 (이미 닫힌 경우 등)
del receiving_files[file_id] # 수신 목록에서 제거
여기서 except Exception: pass는 "정리 과정에서 또 오류가 나도 무시한다"는 뜻입니다. 이미 오류로 정리 중인 상황이므로, 닫기 실패까지 잡아 프로그램을 멈출 필요는 없습니다.
3.2 handle_file_chunk() 예외 처리 보강
조각을 받을 때 알 수 없는 file_id, 빈 데이터, 디코딩·쓰기 오류를 각각 막습니다. 문제가 생기면 cleanup_receiving_file()로 정리한 뒤 반환합니다.
def handle_file_chunk(message):
file_id = message.get("file_id")
data = message.get("data")
file_info = receiving_files.get(file_id)
if file_info is None: # ➕ 등록 안 된 파일 조각 방어
print(f"알 수 없는 파일 조각입니다: {file_id}")
return
if data is None: # ➕ 데이터가 비어 있으면 정리
print(f"파일 조각 데이터가 없습니다: {file_id}")
cleanup_receiving_file(file_id)
return
try:
chunk = base64.b64decode(data.encode("utf-8")) # Base64 → 바이너리 복원
file_info["file"].write(chunk) # 파일에 조각 쓰기
file_info["received_size"] += len(chunk) # 받은 크기 누적
except Exception as error: # ➕ 디코딩·쓰기 오류 시 정리
print(f"파일 조각 저장 오류: {error}")
cleanup_receiving_file(file_id)
✔ 확인 기준: 잘못된 파일 조각이 들어와도 서버가 죽지 않고 로그만 남기며 계속 동작하면 완료. 오류 분기마다 cleanup_receiving_file()이 호출되는지 확인하세요.
4. 클라이언트 - 연결 끊김과 전송 실패 처리하기
→ 수신 루프에서 연결 끊김을 GUI에 알리고, 메시지·파일 전송 실패 시 상태를 정리합니다.
4.1 연결 끊김을 GUI에 전달하기
수신 루프(receive_loop())는 별도 스레드에서 돌기 때문에 화면을 직접 바꾸면 안 됩니다. 연결이 끊기면 state 메시지를 시그널로 보내고, GUI 메인 쪽에서 그 메시지를 받아 화면을 정리합니다.
# receive_loop() 끝에 추가 — 루프가 끝났다 = 연결이 끊겼다
self.message_received.emit({ # ➕ 끊김을 메인 스레드로 전달
"type": "state",
"connected": False
})
display_message()에 state 분기를 추가해, 연결이 끊겼다는 신호를 받으면 소켓을 비우고 화면을 접속 해제 상태로 되돌립니다.
# display_message() 안에 분기 추가
elif message_type == "state": # ➕ 연결 상태 변경 신호 처리
connected = message.get("connected", False)
if not connected:
self.client_socket = None # 끊긴 소켓 참조 제거
self.set_connected_state(False) # 입력창·버튼 비활성화
4.2 메시지·파일 전송 실패 처리
메시지를 보내는 도중 연결이 끊기면 오류를 안내하고 연결을 정리합니다. 파일 전송이 실패하면 상태 라벨과 진행률 바도 함께 되돌립니다.
# 메시지 전송 부분
except Exception as error: # ✏️ 전송 실패 시 연결 정리
self.show_error(f"메시지 전송 실패: {error}")
self.disconnect_from_server()
# 파일 전송 부분 (send_file)
except Exception as error: # ✏️ 실패 시 상태·진행률 초기화
self.file_status_label.setText("파일 상태: 전송 오류")
self.file_progress_bar.setValue(0) # ➕ 진행률 0으로 되돌림
self.show_error(f"파일 전송 실패: {error}")
✔ 확인 기준: 서버를 강제 종료했을 때 GUI가 멈추지 않고 입력창이 비활성화되며 다시 접속 가능한 상태로 돌아오면 완료. receive_loop()가 끝날 때 state 메시지를 emit하는지 확인하세요.
5. 실행 결과 확인하기
→ 연결 실패·강제 종료·중복 파일 전송을 확인하고, 기존 기능이 깨지지 않았는지 회귀 테스트합니다.
5.1 연결 실패·강제 종료·중복 파일 확인
서버 없이 접속 시도 — 서버를 켜지 않고 GUI에서 접속을 누릅니다.
[오류] 서버가 실행 중인지 확인하세요.
상태: 서버 연결 실패
접속 중 서버 강제 종료 — 접속된 상태에서 서버 터미널을 Ctrl + C로 끕니다.
[시스템] 서버와의 연결이 끊어졌습니다.
상태: 서버에 연결되지 않음
같은 파일 두 번 전송 — 동일한 파일을 두 번 보냅니다.
received_files/
├── sample.png
└── sample_1.png
5.2 기능별 회귀 테스트
예외 처리를 더한 뒤에는 기존 기능이 그대로 동작하는지 다시 확인해야 합니다. 새로 추가한 방어 코드가 정상 흐름까지 막는 경우가 흔하기 때문입니다.
| 기능 | 테스트 방법 | 성공 기준 |
| 전체 채팅 | GUI 2개 접속 후 메시지 전송 | 상대 GUI에 표시 |
| 사용자 목록 | GUI 접속·종료 | 목록 증가·감소 |
| 귓속말 | 대상 선택 후 전송 | 선택 대상에게만 표시 |
| 파일 전송 | 파일 선택 후 전송 | 진행률 100, 서버 저장 |
| 종료 처리 | exit 입력 또는 창 닫기 |
서버 접속자 수 감소 |
✔ 확인 기준: 오류 처리를 추가한 뒤에도 채팅·귓속말·파일 전송이 깨지지 않으면 완료. 실패하면 새로 넣은 예외 처리가 정상 흐름까지 막고 있지 않은지 확인하세요.
5.3 흔한 오류와 해결 방법
| 오류 상황 | 원인과 해결 방법 |
| 같은 이름 파일이 계속 덮어써짐 | get_unique_path()를 쓰지 않음. handle_file_info()의 저장 경로에 적용한다 |
| 서버 종료 후 GUI가 연결된 상태로 보임 | 수신 루프 종료 후 state 메시지를 보내지 않음. emit 후 set_connected_state(False)가 호출되는지 확인한다 |
| 파일 전송 실패 후 진행률이 그대로 남음 | 예외 블록에서 file_progress_bar.setValue(0)을 호출하지 않음 |
| 강제 종료 후 사용자 목록이 남음 | 끊어진 클라이언트 제거 후 broadcast_user_list()를 호출한다 |
PermissionError |
저장 폴더에 쓸 권한이 없거나 파일이 사용 중. 경로를 확인하고 파일을 닫은 뒤 다시 실행한다 |
| 파일이 일부만 저장됨 | cleanup_receiving_file()과 handle_file_end()의 close() 호출을 확인한다 |
6. 최종 코드 정리하기
→ 이번 강의에서 두 파일에 추가한 안정화 코드를 깨끗하게 정리합니다.
6.1 server.py — 추가·수정된 부분
def get_unique_path(directory, filename):
base_name, extension = os.path.splitext(filename)
candidate = os.path.join(directory, filename)
count = 1
while os.path.exists(candidate):
new_filename = f"{base_name}_{count}{extension}"
candidate = os.path.join(directory, new_filename)
count += 1
return candidate
def cleanup_receiving_file(file_id):
file_info = receiving_files.get(file_id)
if file_info is None:
return
try:
file_info["file"].close()
except Exception:
pass
del receiving_files[file_id]
def handle_file_chunk(message):
file_id = message.get("file_id")
data = message.get("data")
file_info = receiving_files.get(file_id)
if file_info is None:
print(f"알 수 없는 파일 조각입니다: {file_id}")
return
if data is None:
print(f"파일 조각 데이터가 없습니다: {file_id}")
cleanup_receiving_file(file_id)
return
try:
chunk = base64.b64decode(data.encode("utf-8"))
file_info["file"].write(chunk)
file_info["received_size"] += len(chunk)
except Exception as error:
print(f"파일 조각 저장 오류: {error}")
cleanup_receiving_file(file_id)
# handle_file_info() 안에서 저장 경로 한 줄을 교체
save_path = get_unique_path(RECEIVE_DIR, safe_filename)
6.2 main.py — 추가·수정된 부분
# receive_loop() 끝에 추가
self.message_received.emit({"type": "state", "connected": False})
# display_message() 안에 분기 추가
elif message_type == "state":
connected = message.get("connected", False)
if not connected:
self.client_socket = None
self.set_connected_state(False)
# 메시지 전송 실패 처리
except Exception as error:
self.show_error(f"메시지 전송 실패: {error}")
self.disconnect_from_server()
# 파일 전송 실패 처리 (send_file)
except Exception as error:
self.file_status_label.setText("파일 상태: 전송 오류")
self.file_progress_bar.setValue(0)
self.show_error(f"파일 전송 실패: {error}")
6.3 최종 확인 표
| 추가·수정된 코드 | 역할 |
get_unique_path() |
같은 이름 파일을 _1, _2로 저장해 덮어쓰기 방지 |
cleanup_receiving_file() |
수신 중 오류 시 파일을 닫고 수신 목록에서 제거 |
handle_file_chunk() 예외 처리 |
알 수 없는 조각·빈 데이터·쓰기 오류를 막고 정리 |
state 메시지 + 분기 |
연결 끊김을 감지해 GUI를 접속 해제 상태로 복구 |
메시지 전송 except |
전송 실패 시 안내 후 연결 정리 |
파일 전송 except |
전송 실패 시 상태 라벨·진행률 바 초기화 |
→ 다음 강의 (38강): 37강까지 직접 만든 소켓 채팅은 최종 제출에 가까운 상태가 되었습니다. 38강에서는 시야를 넓혀 FastAPI와 WebSocket을 소개합니다. 지금까지 손으로 만든 서버·수신 루프·메시지 구조가 프레임워크에서는 어떻게 달라지는지, async/await가 왜 등장하는지 비교합니다.