Python_Day.9

조현기·5일 전

Python_Day.9

가변 인자와 예외 문제 풀이, filter/map 복습, 정규 표현식(re), os 모듈로 파일·경로 다루기.

Day.8까지 이어진 예외, 클래스, 컴프리헨션 문제 풀이를 몇 개 더 하고, 새로 정규 표현식과 os 모듈을 배웠다. 정규 표현식은 문자열에서 특정 패턴(전화번호, 이메일 등)을 찾는 도구고, os는 파일과 폴더 경로를 다루는 모듈이다.


1. 가변 인자: 최댓값 구하기

인자를 몇 개 받을지 모를 때 *args로 받는다. (Day.3의 *args)

def find_max(*args):
    if not args:              # 인자가 하나도 없으면 args는 빈 튜플 ()
        return None
    return max(args)


print(find_max(3, 8, 2))       # 8
print(find_max(10))            # 10
print(find_max(-3, -8, -2))    # -2
print(find_max())              # None
  • *args는 받은 인자를 튜플로 묶는다. 인자가 없으면 빈 튜플 ()다.
  • 빈 튜플에 max()를 쓰면 ValueError: max() iterable argument is empty가 나기 때문에 먼저 검사했다.
  • 필기에서는 if args == ():로 비교하고 문자열 'None'을 반환했는데, 비어 있는지는 if not args로 검사하는 것이 일반적이다. (Day.2의 참/거짓 판별: 빈 튜플은 거짓) 또 문자열 'None'은 값이 없다는 뜻의 None과 다르다. 반환값을 None으로 두면 호출한 쪽에서 if result is None으로 구분할 수 있다.

2. 예외 문제 풀이

2-1. 재고 관리

사려는 수량이 재고보다 많으면 OutOfStockError, 수량이 0 이하면 InvalidError를 발생시킨다.

class OutOfStockError(Exception):
    pass

class InvalidError(Exception):
    pass


class Product:
    def __init__(self, name, stock):
        self.name = name
        self.stock = stock

    def sell(self, qty):
        if qty <= 0:
            raise InvalidError("수량은 0보다 커야 한다")
        if qty > self.stock:
            raise OutOfStockError("재고가 부족해서 팔지 못한다")

        self.stock -= qty


product = Product("노트북", 5)     # 재고 5개짜리 상품

product.sell(2)                    # 정상 판매
print(product.stock)               # 3

try:
    product.sell(10)               # 재고보다 많이 → OutOfStockError
except OutOfStockError as e:
    print("판매실패", e)           # 판매실패 재고가 부족해서 팔지 못한다

try:
    product.sell(-1)               # 0 이하 → InvalidError
except InvalidError as e:
    print("판매실패", e)           # 판매실패 수량은 0보다 커야 한다

Day.8의 은행 출금과 같은 구조다. 검사는 sell 안에서 하고, 에러 처리는 호출하는 쪽의 try/except에서 한다는 점이 포인트다. 검사를 통과하지 못하면 self.stock -= qty까지 가지 못하기 때문에 재고가 잘못 줄어들지 않는다.

2-2. 나이 확인

19세 미만이면 AgeError를 발생시키고, 숫자가 아닌 입력도 처리하는 문제다. 처음에는 이렇게 짰다.

class AgeError(Exception):
    pass

def check_age():
    while True:
        age = int(input("나이 입력: "))          # ← try 바깥에 있다
        try:
            if age < 19:
                raise AgeError("19세 미만은 입장할 수 없다")
            print("입장 가능~")
            return age
        except AgeError as e:
            print(e)
        except ValueError:                       # ← 실행될 일이 없다
            print("숫자만 입력해라")

abc를 입력하면 숫자만 입력해라가 나오지 않고 ValueError로 프로그램이 그대로 종료된다. ValueError는 int(input(...))에서 나는데, 이 줄이 try 바깥에 있어서 except ValueError가 잡지 못한다. try 안으로 옮기면 된다.

def check_age():
    while True:
        try:
            age = int(input("나이 입력: "))      # try 안으로
            if age < 19:
                raise AgeError("19세 미만은 입장할 수 없다")
            print("입장 가능~")
            return age
        except AgeError as e:
            print(e)
        except ValueError:
            print("숫자만 입력해라")


result = check_age()
print(result)

입력 abc, 15, 20을 차례로 넣어 보면 이렇게 나온다.

나이 입력: 숫자만 입력해라
나이 입력: 19세 미만은 입장할 수 없다
나이 입력: 입장 가능~
20
  • 잘못된 입력은 while True로 다시 입력받고, 성공하면 return age로 함수와 반복을 함께 끝낸다.
  • 예외가 날 수 있는 줄은 전부 try 안에 있어야 한다. Day.6의 나이 입력 예제와 같은 교훈이다.

3. 클래스 + 컴프리헨션: 플레이리스트

Song 객체를 Playlist에 담고, 전체 재생 시간과 가장 긴 곡을 구한다.

class Song:
    def __init__(self, title, singer, minute):
        self.title = title
        self.singer = singer
        self.minute = minute


class Playlist:
    def __init__(self):
        self.songs = []                       # Song 객체를 담을 빈 리스트

    def add_song(self, song):
        self.songs.append(song)

    def total_minute(self):
        return sum(i.minute for i in self.songs)

    def long_song(self):
        minutes = [i.minute for i in self.songs]      # [3, 3, 4, 4]
        max_minute = max(minutes)

        for i in self.songs:
            if i.minute == max_minute:
                return i.title                          # 처음 찾은 곡에서 바로 종료


pl = Playlist()
pl.add_song(Song("Song A", "Artist 1", 3))
pl.add_song(Song("Song B", "Artist 2", 3))
pl.add_song(Song("Song C", "Artist 3", 4))
pl.add_song(Song("Song D", "Artist 4", 4))

print("전체 재생시간 :", pl.total_minute(), "분")    # 14 분
print("가장 긴 곡 :", pl.long_song())                # Song C
  • sum(i.minute for i in self.songs)는 Day.7의 제너레이터 표현식을 sum에 넣은 것이다. 리스트를 따로 만들지 않고 합한다.
  • long_song은 가장 긴 시간을 먼저 구하고, 그 시간을 가진 곡을 찾아 반환한다. 가장 긴 곡이 둘 이상이면 먼저 추가된 곡만 나온다. (return이 함수를 끝내기 때문)
  • max의 key 옵션을 쓰면 한 줄로 줄일 수 있다.
def long_song(self):
    return max(self.songs, key=lambda i: i.minute).title

key에 넘긴 lambda가 비교 기준을 정한다. 최댓값이 여러 개일 때 먼저 나온 것을 반환하는 것은 같다.


4. filter와 map 복습

nums = [1, 2, 3, 4, 5, 6, 7, 8]

# 짝수만 골라내기: filter + lambda
evens = list(filter(lambda x: x % 2 == 0, nums))
print(evens)       # [2, 4, 6, 8]

# 제곱한 값으로 바꾸기: map + lambda
square = list(map(lambda x: x ** 2, nums))
print(square)      # [1, 4, 9, 16, 25, 36, 49, 64]
함수역할결과 개수
filter(함수, 리스트)함수가 참인 요소만 남긴다줄어들 수 있다
map(함수, 리스트)모든 요소를 변환한다그대로다

Day.4에서 정리한 대로 컴프리헨션으로도 쓸 수 있다.

[x for x in nums if x % 2 == 0]    # filter와 같다
[x ** 2 for x in nums]             # map과 같다

5. 정규 표현식 (re)

정규 표현식은 문자열에서 패턴을 찾는 문법이다. "010으로 시작하는 전화번호", "@가 들어간 이메일"처럼 규칙이 있는 문자열을 찾을 때 쓴다. 파이썬에서는 re 모듈로 사용한다.

5-1. 자주 쓰는 기호

기호뜻
[a-z]a부터 z까지 중 한 글자
+바로 앞의 것이 1번 이상 반복
\d숫자 한 글자 ([0-9]와 같다)
\w문자, 숫자, _ 한 글자
\s공백 문자
{3,4}바로 앞의 것이 3번에서 4번 반복
( )그룹으로 묶는다

패턴 문자열 앞에는 r(raw string)을 붙인다. \d의 \를 파이썬이 특수 문자로 해석하지 않고 그대로 정규식에 전달하기 위해서다.

5-2. match vs search vs findall

import re

p = re.compile('[a-z]+')      # 영어 소문자가 1글자 이상 이어진 것

print(p.match('python'))      # <re.Match object; span=(0, 6), match='python'>
print(p.match('3 python'))    # None
print(p.search('3 python'))   # <re.Match object; span=(2, 8), match='python'>
print(p.search('pytho1n'))    # <re.Match object; span=(0, 5), match='pytho'>
print(p.findall('pytho1n 3 python'))    # ['pytho', 'n', 'python']
함수동작
match문자열의 맨 앞부터 패턴과 맞는지 검사한다. 앞이 다르면 None
search문자열 어디든 처음으로 맞는 곳 하나를 찾는다
findall맞는 것을 전부 찾아 리스트로 반환한다
  • '3 python'은 맨 앞이 숫자 3이라 match는 None이지만, search는 중간의 python을 찾는다.
  • 'pytho1n'에서는 1이 [a-z]가 아니라서 pytho에서 끊긴다. 이어서 찾으려면 findall을 쓰면 ['pytho', 'n']처럼 나뉘어 나온다. 필기에 "중간에 다른 문자가 끼어 있으면 끊기는 듯하다"고 적은 부분이 이것이다.
  • 결과는 문자열이 아니라 Match 객체(또는 None)다. 매치된 문자열은 m.group()으로 꺼낸다.

5-3. 전화번호 찾기

import re

def phone_number(text):
    pattern = r'010-\d{3,4}-\d{4}'
    return re.findall(pattern, text)


text = '내 연락처는 010-1234-5678이고, 동생 연락처는 010-94-2345입니다.'
print(phone_number(text))    # ['010-1234-5678']

패턴을 읽어 보면 이렇다.

  • 010-: 글자 그대로 010-
  • \d{3,4}: 숫자가 3개 또는 4개
  • -\d{4}: 하이픈 뒤 숫자 4개

010-94-2345는 가운데 숫자가 2개뿐이라 패턴에 맞지 않아 결과에서 빠졌다.

  • 같은 텍스트에서 search를 쓰면 첫 번째로 맞는 하나(010-1234-5678)만, match를 쓰면 맨 앞이 내라서 None이다.
  • findall은 맞는 것이 하나도 없으면 빈 리스트 []를 돌려준다.
  • 가운데가 5자리인 010-12345-6789처럼 너무 긴 번호도 패턴({3,4})에 맞지 않아 걸러진다.

5-4. 그룹: ( )

괄호로 묶은 부분을 그룹이라고 한다. 매치된 전체 중 일부만 따로 꺼낼 때 쓴다.

p = re.compile(r"(\w+)\s+\d+[-]\d+[-]\d+")
m = p.search('park 010-1234-1234')

print(m)             # <re.Match object; span=(0, 18), match='park 010-1234-1234'>
print(m.group(0))    # park 010-1234-1234   (매치된 전체)
print(m.group(1))    # park                 (첫 번째 괄호 안)
print(m.groups())    # ('park',)
  • group(0)은 항상 매치 전체이고, group(1)부터가 괄호 순서대로다.
  • 이름 + 공백 + 전화번호 형태에서 괄호로 묶은 이름만 꺼냈다.

괄호에 이름을 붙일 수도 있다. (?P<이름>...) 형태다.

p = re.compile(r"(?P<hello>\w+)\s+((\d+)[-]\d+[-]\d+)")
m = p.search('park 010-1234-1234')

print(m.group('hello'))    # park
print(m.group(1))          # park              (이름 붙인 그룹도 번호가 있다)
print(m.group(2))          # 010-1234-1234     (전화번호 전체)
print(m.group(3))          # 010               (전화번호 안쪽 첫 번째 \d+)
print(m.groupdict())       # {'hello': 'park'}

그룹 번호는 여는 괄호 (가 나오는 순서대로 센다. 그래서 (\w+)가 1번, 바깥 ((\d+)...)가 2번, 그 안쪽 (\d+)가 3번이다.

필기에는 3번 그룹을 "국번"이라고 적어 두었다. 이 예제에서 3번 그룹은 전화번호의 맨 앞 010이고, 가운데 4자리(1234)가 국번이다. 번호를 매기는 규칙은 필기 설명 그대로 맞다.

5-5. 이메일 형식 검사

def email(text):
    p = re.compile(r'\w+[@]\w+[.]\w+')
    m = p.match(text)
    return m


print(email('test@naver.com'))    # <re.Match object; ... match='test@naver.com'>  (매치됨)
print(email('test-naver.com'))    # None  (@가 없어서 매치 안 됨)

\w+ @ \w+ . \w+ 순서로 맞는지 검사한다. 이 패턴에는 한계가 있다.

print(email('a@b.co.kr'))
# <re.Match object; span=(0, 6), match='a@b.co'>   ← 'co'까지만 매치되고 '.kr'은 빠졌다

match는 앞에서부터 맞는 곳까지만 보고, 뒤에 남은 문자가 있어도 매치로 인정한다. 문자열 전체가 패턴과 맞아야 한다면 fullmatch를 쓰고, 점이 여러 번 나오는 경우도 받도록 패턴을 고친다.

print(re.fullmatch(r'\w+@\w+(\.\w+)+', 'a@b.co.kr'))     # 매치됨 (전체)
print(re.fullmatch(r'\w+@\w+(\.\w+)+', 'a@b.co.kr!'))    # None   (뒤에 ! 때문에)

또 결과가 Match 객체나 None이라서 참/거짓으로만 쓰고 싶으면 bool(email(...))로 바꾸면 된다. 이메일 검증을 정규식으로 완벽하게 하기는 어려워서, 실무에서는 간단한 형식 검사 후 실제 인증 메일로 확인하는 경우가 많다.

5-6. 바꾸기와 나누기

print(re.sub(r'\d', '#', 'a1b22'))             # a#b##    (숫자를 #로 바꿈)
print(re.split(r'[,;]\s*', 'a, b;c'))          # ['a', 'b', 'c']   (쉼표/세미콜론 기준으로 나눔)

Day.4의 replace, split이 고정된 문자열만 다룬다면, re.sub, re.split은 패턴으로 다룰 수 있다.


6. os 모듈: 파일과 경로

os는 디렉터리와 파일 같은 운영체제 자원을 제어하는 모듈이다.

6-1. 경로 확인

import os

file_path = './a.txt'

print(os.path.exists(file_path))          # 경로가 존재하는가       → True
print('파일 여부', os.path.isfile(file_path))      # 파일인가?     → True
print('디렉토리 여부', os.path.isdir(file_path))   # 폴더인가?     → False

exists는 파일이든 폴더든 존재하기만 하면 True고, isfile/isdir로 종류를 구분한다. 필기에서는 C:/Python/a.txt를 썼는데, 그런 파일이 내 컴퓨터에 없으면 모두 False가 나온다. 위 결과는 a.txt를 만들어 둔 폴더에서 실행한 것이다.

6-2. 경로 쪼개고 합치기

file_path = 'C:/Python/a.txt'

dir_name = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
print('디렉토리', dir_name)       # C:/Python    (파일이 위치한 디렉토리)
print('파일명', base_name)        # a.txt

# 경로 합치기
new_path = os.path.join(dir_name, 'backup', 'report_a.txt')
print('합친 경로', new_path)      # C:/Python/backup/report_a.txt
  • dirname은 폴더 부분, basename은 파일 이름만 돌려준다.
  • 경로를 '폴더' + '/' + '파일'처럼 문자열로 직접 이어 붙이지 않고 os.path.join을 쓰는 이유는 운영체제마다 구분자가 다르기 때문이다. Windows는 \, 리눅스와 macOS는 /를 쓰는데 join이 알맞은 구분자를 붙여 준다.
  • 확장자를 나누려면 os.path.splitext('report_a.txt')를 쓴다. 결과는 ('report_a', '.txt')다.

6-3. 폴더 안의 파일 목록과 크기

target_dir = '.'          # .은 현재 작업 디렉토리

for i in os.listdir(target_dir):                     # 폴더 안의 이름 목록
    full_path = os.path.join(target_dir, i)          # 폴더 + 이름
    if os.path.isfile(full_path):                    # 파일일 때만
        size = os.path.getsize(full_path)            # 파일 크기(바이트)
        print(i, size)

a.txt(6바이트), b.txt(6바이트)와 sub라는 폴더가 있는 곳에서 실행하면 이렇게 나온다.

a.txt 6
b.txt 6

(sub 폴더는 isfile이 False라서 출력되지 않는다.)

  • os.listdir(경로)는 그 폴더 안의 이름만 리스트로 돌려준다. 파일 크기나 종류를 보려면 join으로 전체 경로를 만들어서 isfile, getsize에 넘겨야 한다.
  • 파일 하나 안의 글자 수가 아니라 바이트 단위다. 한글은 UTF-8에서 글자당 3바이트다.

마무리

  • *args는 인자를 튜플로 받고, 비어 있는지는 if not args로 확인한다.
  • 예외를 검사하는 코드는 모두 try 안에 있어야 한다. int(input())이 try 밖에 있으면 ValueError가 처리되지 않는다.
  • 정규 표현식: match(맨 앞부터), search(처음 하나), findall(전부, 리스트). 패턴은 r'...'로 쓰고, ( )로 그룹을 묶어 일부만 group(번호)로 꺼낸다. 이름 붙인 그룹은 (?P<이름>...)이다.
  • 문자열 전체가 패턴에 맞는지는 fullmatch로 검사한다.
  • os 모듈: exists/isfile/isdir로 확인, dirname/basename/join으로 경로를 다루고, listdir/getsize로 폴더 안을 훑는다.

Tags: Python 정규표현식 re os 예외처리 filter map 컴프리헨션 개발자 학습기록

profile
난 뭘까

0개의 댓글