CS · 기본
개발자를 위한 이산수학 - 논리·집합·그래프·확률을 코드로
불 대수와 상태 기계 - 회로·정규식·상태 관리
불 대수와 논리 회로, 유한 상태 기계, 정규 표현식과의 관계, 화면·주문 상태 설계, 완성 코드는 주문 상태 기계와 잘못된 전이 거부
개발자KR · 원고 갱신
이 장에서 배우는 것
주문 화면에 결제 여부, 배송 여부, 취소 여부를 각각 저장했다고 하자. 세 값은 모두 참이나 거짓이므로 다루기 쉬워 보인다. 그러나 시간이 지나면 ‘배송했지만 결제하지 않은 주문’처럼 설명하기 어려운 조합이 생긴다. 조건 하나의 참과 거짓을 계산하는 일과, 시간이 흐르면서 허용된 순서로 상태를 바꾸는 일은 구분해야 한다.
앞 장에서 계층 구조를 따라 대상을 찾았다면, 여기서는 사건이 들어올 때 현재 상태가 어떻게 달라지는지 살펴본다. 불 대수로 조건을 정리하고, 유한 상태 기계로 주문의 진행 순서를 표현한다. 이어서 같은 사건의 나열을 정규 표현식으로 검사해 두 표현이 어디에서 만나는지 확인한다.
- 참과 거짓의 계산을 불 대수와 논리 회로로 표현한다.
- 현재 상태와 사건을 입력받아 다음 상태를 결정한다.
- 유한 상태 기계와 정규 표현식이 입력 순서를 검사하는 방식을 비교한다.
- 화면 상태와 주문 상태를 구분하고, 허용하지 않은 전이를 코드로 거부한다.
문제 상황
작은 주문 관리 도구를 만든다고 하자. 주문은 생성된 뒤 결제되고, 출고되고, 배송 완료된다. 아직 결제하지 않았다면 취소할 수 있다. 결제했지만 출고하지 않았다면 환불할 수 있다. 이 예제에서는 부분 배송, 부분 환불, 배송 후 반품을 다루지 않는다. 실제 서비스의 모든 규칙을 한 번에 담기보다, 정한 범위를 코드와 같은 말로 설명하는 것이 먼저다.
처음에는 paid, shipped, cancelled 같은 변수를 둘 수 있다. 하지만 독립적인 참·거짓 변수 세 개는 여덟 조합을 만든다. 그중 무엇이 유효한지는 변수만 보고 알 수 없다. 취소된 주문의 결제 버튼이 다시 활성화되는 문제도 생길 수 있다. 버튼마다 조건을 따로 작성하면 한쪽에서 고친 규칙이 다른 쪽에는 남는다.
이번 장에서는 주문의 현재 단계를 문자열 하나로 표현한다. 허용한 변화는 표 한 곳에 모으고, 표에 없는 요청은 거부한다. 거부된 요청이 현재 상태를 바꾸지 않았는지도 확인한다. 이는 오류 메시지를 출력하는 일보다 중요한 검사다. 잘못된 요청을 알렸더라도 주문 상태가 이미 바뀌었다면 다음 요청을 올바르게 처리하기 어렵다.
불 대수로 조건을 계산한다
참과 거짓 두 값에 관한 계산 규칙을 불 대수(Boolean algebra)라고 한다. 전공 수업에서는 참을 1, 거짓을 0으로 적기도 한다. 여기서 1과 0은 조건의 결과를 나타내는 기호다. 일반적인 정수 덧셈과 같은 규칙으로 계산하지 않는다.
두 조건이 모두 참이어야 한다면 논리곱을 쓴다. Python에서는 and다. 하나 이상 참이면 되는 조건은 논리합이며 or로 쓴다. 참과 거짓을 뒤집는 연산은 부정이며 not이다. 각 입력 조합과 출력을 빠짐없이 적은 표를 전공 수업에서는 진리표라고 부른다.
| A | B | A and B | A or B |
|---|---|---|---|
| False | False | False | False |
| False | True | False | True |
| True | False | False | True |
| True | True | True | True |
결제 버튼을 보여 줄 조건을 ‘회원이며, 차단되지 않았으며, 재고가 있음’으로 정해 보자. 이를 member and not blocked and stocked로 쓸 수 있다. 하나의 연산을 수행하는 작은 장치를 논리 게이트라고 하며, 여러 게이트를 연결한 구조를 논리 회로(logic circuit)라고 한다. 다음 그림은 이 조건을 입력과 출력의 연결로 나타낸 것이다.
이 그림은 현재 입력만으로 출력이 결정되는 회로다. 전공 수업에서는 이런 회로를 조합 논리 회로라고 부른다. 앞선 주문이 결제되었는지 같은 과거 정보는 기억하지 않는다. 기억한 상태까지 사용해 출력을 결정하는 회로는 순차 논리 회로라고 부른다. 뒤에서 만드는 상태 기계는 이러한 상태의 역할을 코드로 살피는 모델이다.
조건을 줄일 때에는 보기 좋은 식보다 같은 결과를 내는지가 먼저다. 예를 들어 not (a or b)는 (not a) and (not b)와 같다. ‘둘 중 하나라도 해당한다’를 부정하면 ‘둘 다 해당하지 않는다’가 된다. 전공 수업에서는 이 관계를 드모르간 법칙이라고 부른다. 다음 코드는 가능한 입력을 모두 넣어 확인한다.
for a in (False, True):
for b in (False, True):
assert (not (a or b)) == ((not a) and (not b))
assert (not (a and b)) == ((not a) or (not b))
assert는 뒤의 조건이 참인지 검사하는 문장이다. 거짓이면 실행을 멈추고 검사 실패를 알린다. 여기서는 입력이 네 조합뿐이므로 전부 확인할 수 있다. 사용자 요청의 유효성 검사에는 이 문장을 쓰지 않는다. Python의 최적화 실행 옵션에서는 이 검사가 생략될 수 있기 때문이다.
첫 번째 식의 오른쪽을 (not a) or (not b)로 잘못 바꿨다고 하자. a=True, b=False를 넣으면 원래 식은 거짓인데 바꾼 식은 참이다. 식이 같지 않음을 보여 주는 작은 입력 하나를 찾은 셈이다. 다음 코드로 직접 차이를 확인할 수 있다.
a = True
b = False
assert (not (a or b)) is False
assert ((not a) or (not b)) is True
수학의 불 연산과 Python 연산을 대응시킬 때에는 입력 범위를 정해야 한다. Python의 and와 or는 일반 객체에 적용하면 피연산자 중 하나를 돌려준다. 예를 들어 "" or "대기"의 결과는 문자열 "대기"다. 이 절의 계산은 입력을 False와 True로 제한했기 때문에 결과도 참·거짓이다. 정수의 각 이진 자리를 계산하는 &, |를 조건문의 연산자와 섞어 쓰지 않는다.
유한 상태 기계로 순서를 제한한다
조건식은 지금 가능한지를 계산한다. 상태 기계는 여기에 ‘지금 어느 단계인가’를 보탠다. 정해진 수의 상태 중 하나를 기억하고, 입력을 받을 때 규칙에 따라 상태를 바꾸는 모델을 유한 상태 기계(finite state machine)라고 한다. 여기서 유한하다는 말은 사용할 상태의 수가 한정되어 있다는 뜻이다.
상태는 현재 상황을 구분하는 이름이다. 사건은 상태를 바꿔 달라는 입력이다. 한 상태에서 다른 상태로 이동하는 것을 전공 수업에서는 전이라고 부른다. 주문의 ‘결제됨’은 상태이고, ‘결제 요청’은 사건이다. 둘을 같은 종류의 이름으로 취급하면 표를 읽을 때 현재 상황과 요청을 혼동하기 쉽다.
| 현재 상태 | 사건 | 다음 상태 | 의미 |
|---|---|---|---|
| NEW | pay | PAID | 신규 주문 결제 |
| NEW | cancel | CANCELLED | 결제 전 취소 |
| PAID | ship | SHIPPED | 결제 후 출고 |
| PAID | refund | REFUNDED | 출고 전 환불 |
| SHIPPED | deliver | DONE | 배송 완료 |
표에 없는 조합은 허용하지 않는다. 예를 들어 NEW에서 ship을 받거나 SHIPPED에서 cancel을 받으면 거부한다. 같은 현재 상태와 사건에 대해서 다음 상태는 하나로 정해진다. 이러한 성질을 전공 수업에서는 결정적이라고 부른다. 요청의 유효성에 대한 판단을 실행할 때마다 달리 하지 않는다는 뜻이다.
그림의 화살표가 없는 곳으로는 이동할 수 없다. DONE, CANCELLED, REFUNDED는 이번 모델에서 처리가 끝난 상태다. 여기서는 이를 종료 상태라고 부른다. 종료 상태에서 나가는 화살표가 없는 것은 우리가 선택한 업무 규칙이다. 모든 상태 기계의 종료 상태가 반드시 이렇게 생겨야 하는 것은 아니다.
전이 표는 Python의 사전으로 옮긴다. 사전은 키와 값을 짝지어 저장하는 자료형이다. {"pay": "PAID"}에서는 사건 이름이 키이고 다음 상태가 값이다. 사전 안에 사전을 넣으면 현재 상태를 먼저 찾고, 그 안에서 사건을 찾을 수 있다. 표의 행을 조건문 여러 곳에 나누지 않고 한 자료에 모으는 방식이다.
수학에서 모든 상태와 입력 조합에 다음 상태를 지정하는 형태가 필요하다면 ‘거부 상태’를 하나 추가할 수 있다. 허용하지 않은 입력은 그곳으로 보내고, 이후 입력도 계속 거부 상태에 머물게 한다. 이번 프로그램에서는 같은 거부 의미를 예외로 표현한다. 예외는 정상적인 반환 대신 처리할 수 없는 상황을 호출한 쪽에 알리는 실행 흐름이다. 호출한 쪽은 그 예외를 받아 현재 상태를 유지한다.
실무에서 만나는 곳
화면에도 상태가 있다. 조회 전, 조회 중, 조회 성공, 조회 실패를 각각 하나의 상태로 두면 ‘조회 중이면서 실패 화면도 표시됨’ 같은 조합을 줄일 수 있다. 다음 코드는 조회 시작과 실패, 재시도를 작은 표로 표현한다. 사전에서 키에 해당하는 값을 꺼내는 대괄호 표기를 사용한다.
screen_moves = {
"IDLE": {"load": "LOADING"},
"LOADING": {"success": "READY", "fail": "ERROR"},
"ERROR": {"retry": "LOADING"},
"READY": {},
}
screen = "IDLE"
for event in ["load", "fail", "retry", "success"]:
screen = screen_moves[screen][event]
assert screen == "READY"
화면 상태와 주문 상태는 목적이 다르다. 주문은 결제된 채로 유지되면서 화면 조회만 실패할 수 있다. 이 둘을 하나의 상태 이름에 모두 붙이기보다 별도로 저장하고, 버튼을 보여 줄 때 필요한 조건을 조합하는 편이 읽기 쉽다. 예를 들어 order == "PAID" and screen == "READY"로 환불 버튼의 표시 조건을 계산할 수 있다.
버튼을 숨기는 것은 사용자 안내이며, 주문 규칙을 집행하는 일은 요청을 처리하는 쪽의 책임이다. 화면이 오래 열려 있었거나 다른 작업이 먼저 상태를 바꿨을 수 있다. 실제 요청을 받을 때 현재 상태로 전이를 다시 검사해야 한다. 불 대수는 표시 조건을 정리하고, 상태 기계는 요청의 순서를 제한하는 데 쓰인다.
정규 표현식으로 사건의 나열을 읽는다
상태 기계를 한 번 움직이는 대신, 지금까지 들어온 사건을 처음부터 나열해 볼 수도 있다. 사건 pay, ship, deliver, cancel, refund를 각각 문자 P, S, D, C, R로 기록하자. 이렇게 입력으로 사용할 기호의 모음을 전공 수업에서는 알파벳이라고 부른다. 여기서는 영문자 전체가 아니라 이 다섯 기호만 뜻한다.
종료 상태까지 도달한 유효한 기록은 C, PR, PSD다. 이처럼 받아들일 입력 문자열의 모음을 형식 언어라고 한다. 문자열이 정해진 형태에 맞는지 표현하는 도구가 정규 표현식(regular expression)이다. 이 모델의 완료 기록은 C|PR|PSD라는 패턴으로 나타낼 수 있다. 세로줄은 앞뒤의 선택지 중 하나를 뜻한다.
import re
pattern = r"(C|PR|PSD)"
assert re.fullmatch(pattern, "PSD") is not None
assert re.fullmatch(pattern, "PR") is not None
assert re.fullmatch(pattern, "PS") is None
assert re.fullmatch(pattern, "PSDX") is None
re는 Python 표준 라이브러리의 정규 표현식 모듈이다. 모듈은 기능을 묶어 둔 단위이며 import로 불러온다. 문자열 앞의 r은 역슬래시를 다루기 편한 문자열 표기다. 여기서는 역슬래시가 없어 일반 문자열과 내용이 같다. 괄호는 선택지를 묶는다. fullmatch는 문자열 전체가 맞을 때 일치 결과를, 맞지 않으면 None을 돌려준다.
PS는 잘못된 주문 진행이 아니다. 결제하고 출고했지만 아직 종료되지 않은 기록이다. 반면 SP는 첫 사건부터 허용되지 않는다. ‘현재까지 유효한 기록’과 ‘완료된 기록’을 검사하는 목적이 다르다는 점을 구분해야 한다. 다음 완성 코드의 정규 표현식은 완료된 기록만 받아들인다.
전공 수업에서는 입력 문자열을 읽고 마지막 상태가 받아들이는 상태인지 판정하는 모델을 유한 오토마톤이라고 부른다. 여기서는 세 종료 상태를 받아들이는 상태로 선택한다. 수학에서 다루는 정규 표현식과 유한 오토마톤은 같은 종류의 언어를 표현한다. 단, Python 정규 표현식의 모든 확장 기능을 이 설명에 포함하지는 않는다. 앞에서 잡은 내용을 다시 요구하는 역참조 같은 기능까지 일반화해서는 안 된다.
이번 예제에는 되돌아오는 전이가 없어서 완료 기록을 세 개 나열할 수 있다. 반복을 허용하는 상태 기계는 받아들이는 문자열이 끝없이 많을 수도 있다. 상태 수가 유한하다는 말과 기록 길이가 제한된다는 말은 다르다. 또한 정규 표현식이 사건 순서를 확인하더라도 결제 금액이나 재고 수량까지 확인하지는 않는다. 그런 값은 별도의 업무 데이터다.
완성 코드
다음 내용을 main.py에 저장한다. 외부 패키지나 입력 파일은 사용하지 않는다. 전이 함수는 전달받은 현재 상태를 바꾸지 않고 다음 상태를 반환한다. 호출한 쪽은 반환이 성공했을 때만 현재 상태를 새 값으로 대입한다.
import re
TRANSITIONS = {
"NEW": {"pay": "PAID", "cancel": "CANCELLED"},
"PAID": {"ship": "SHIPPED", "refund": "REFUNDED"},
"SHIPPED": {"deliver": "DONE"},
"DONE": {},
"CANCELLED": {},
"REFUNDED": {},
}
TERMINAL = {"DONE", "CANCELLED", "REFUNDED"}
SYMBOLS = {
"P": "pay",
"S": "ship",
"D": "deliver",
"C": "cancel",
"R": "refund",
}
COMPLETED_PATTERN = r"(C|PR|PSD)"
def transition(state, event):
if state not in TRANSITIONS:
raise ValueError(f"알 수 없는 상태: {state}")
choices = TRANSITIONS[state]
if event not in choices:
raise ValueError(f"허용하지 않은 전이: {state} / {event}")
return choices[event]
def accepts_completed(word):
state = "NEW"
for symbol in word:
if symbol not in SYMBOLS:
return False
try:
state = transition(state, SYMBOLS[symbol])
except ValueError:
return False
return state in TERMINAL
def check_boolean_rules():
for a in (False, True):
for b in (False, True):
assert (not (a or b)) == ((not a) and (not b))
assert (not (a and b)) == ((not a) or (not b))
def check_completed_records():
words = [""]
checked = 0
for length in range(4):
for word in words:
by_machine = accepts_completed(word)
by_pattern = re.fullmatch(COMPLETED_PATTERN, word) is not None
assert by_machine == by_pattern, word
checked += 1
words = [word + symbol for word in words for symbol in SYMBOLS]
return checked
def run_order(label, events):
state = "NEW"
print(f"[{label}] 시작: {state}")
for event in events:
before = state
try:
state = transition(state, event)
except ValueError as error:
assert state == before
print(f"거부: {error}; 유지: {state}")
else:
print(f"허용: {before} + {event} -> {state}")
print(f"[{label}] 최종: {state}")
def main():
check_boolean_rules()
print("불 대수 검사: 통과")
checked = check_completed_records()
print(f"완료 기록 비교: {checked}개 통과")
run_order("배송", ["ship", "pay", "ship", "cancel", "deliver", "pay"])
run_order("환불", ["pay", "refund", "ship"])
run_order("취소", ["cancel", "pay"])
if __name__ == "__main__":
main()
줄별 해설
import re는 정규 표현식 기능을 가져온다. 그 아래의 이름들은 여러 함수가 함께 읽는 규칙 자료다. 대문자 이름은 이 프로그램에서 고정된 규칙으로 사용한다는 관례를 나타낸다. Python이 수정을 막아 주는 것은 아니므로 실행 중에 이 사전을 바꾸지 않는다.
TRANSITIONS의 바깥 키는 현재 상태다. 안쪽 사전은 사건과 다음 상태를 짝짓는다. 종료 상태도 빈 사전으로 등록했다. 따라서 ‘알려진 상태지만 이동할 수 없음’과 ‘상태 이름 자체가 잘못됨’을 구분한다. TERMINAL은 완료 여부를 검사할 때 사용하며, 전이 가능 여부와 별도로 둔다.
transition의 첫 if는 현재 상태 이름을 확인한다. 다음 if는 그 상태에서 사건을 받을 수 있는지 확인한다. raise ValueError(...)는 유효하지 않은 값 때문에 요청을 처리할 수 없다고 알린다. 두 검사를 지나야 return으로 다음 상태를 돌려준다. 아무 입력이나 현재 상태로 되돌려 보내며 성공처럼 처리하지 않는다.
accepts_completed는 기록 하나를 처음부터 재생한다. 알 수 없는 기호를 만나거나 허용하지 않은 전이가 발생하면 False를 반환한다. try 안에서 발생한 지정 예외는 except가 처리한다. 모든 입력을 읽은 뒤에는 마지막 상태가 종료 상태인지 확인한다. 빈 문자열은 반복문을 건너뛰고 NEW에서 끝나므로 거짓이다.
check_boolean_rules의 중첩 반복문은 두 입력의 네 조합을 확인한다. 이 함수는 화면 버튼 조건을 직접 처리하지 않고, 조건식을 바꾸는 규칙이 맞는지 검사한다. 값 두 개만 갖는 입력이라 가능한 경우를 모두 살필 수 있다.
check_completed_records의 words는 같은 길이의 기록을 담는 목록이다. 처음에는 길이 0인 빈 문자열 하나만 있다. range(4)는 0, 1, 2, 3을 차례로 만든다. 반복할 때마다 현재 목록을 검사한 뒤 각 기록 뒤에 다섯 기호를 하나씩 붙인다.
대괄호 안에 for를 적어 새 목록을 만드는 문법을 리스트 내포라고 한다. 여기의 두 for는 바깥에서 기록 하나를 고르고 안쪽에서 기호 하나를 고르는 중첩 반복과 같다. 사전의 반복은 키를 차례로 읽으므로 SYMBOLS에서 기호를 얻는다. 마지막 반복 뒤에 만든 길이 4 목록은 사용하지 않으며, 검사 수에도 포함되지 않는다.
검사 수는 1 + 5 + 25 + 125 = 156이다. 상태 기계와 정규 표현식의 판정이 다른 기록이 있으면 assert가 그 기록을 알려 준다. 이 검사는 길이 3까지의 비교다. 이 모델에서는 완료 기록이 모두 그 범위에 있지만, 유한한 테스트를 통과했다는 사실만으로 임의의 패턴과 상태 기계가 모든 길이에서 같다고 결론 내리지는 않는다.
run_order는 요청이 거부되어도 다음 요청을 계속 처리한다. state = transition(...)에서는 오른쪽 함수 호출이 먼저 실행된다. 함수가 예외를 내면 대입이 일어나지 않는다. 따라서 except 안의 상태는 before와 같아야 한다. else는 try가 예외 없이 끝났을 때 실행된다.
f"..."는 중괄호 안의 값을 문자열에 넣는 표기다. main은 검사를 실행한 뒤 배송, 환불, 취소 사례를 출력한다. 마지막 조건은 이 파일을 직접 실행했을 때 main을 호출한다. 입력을 기다리거나 반복 실행을 유지하는 부분이 없어 출력 후 바로 끝난다.
실행 결과
파일을 저장한 디렉터리에서 다음 명령을 실행한다.
python3 main.py
예상 출력은 다음과 같다. 잘못된 요청 다음의 ‘유지’ 값과, 그 뒤에 허용되는 요청을 함께 살펴본다.
불 대수 검사: 통과
완료 기록 비교: 156개 통과
[배송] 시작: NEW
거부: 허용하지 않은 전이: NEW / ship; 유지: NEW
허용: NEW + pay -> PAID
허용: PAID + ship -> SHIPPED
거부: 허용하지 않은 전이: SHIPPED / cancel; 유지: SHIPPED
허용: SHIPPED + deliver -> DONE
거부: 허용하지 않은 전이: DONE / pay; 유지: DONE
[배송] 최종: DONE
[환불] 시작: NEW
허용: NEW + pay -> PAID
허용: PAID + refund -> REFUNDED
거부: 허용하지 않은 전이: REFUNDED / ship; 유지: REFUNDED
[환불] 최종: REFUNDED
[취소] 시작: NEW
허용: NEW + cancel -> CANCELLED
거부: 허용하지 않은 전이: CANCELLED / pay; 유지: CANCELLED
[취소] 최종: CANCELLED
실무에서 자주 틀리는 것
조건식 안에 상태 이름만 나열한다
다음 코드는 종료 상태를 확인하려 하지만, 비교하지 않은 문자열이 남아 있다. 비어 있지 않은 문자열은 조건에서 참으로 취급된다. 따라서 신규 주문도 종료되었다고 판단한다.
state = "NEW"
finished = state == "DONE" or "CANCELLED" or "REFUNDED"
assert bool(finished) is True
각 상태와 비교하거나 집합의 원소인지 검사한다. 상태 이름이 늘어날 수 있다면 집합으로 모은 쪽이 수정 지점을 찾기 쉽다.
state = "NEW"
finished = state in {"DONE", "CANCELLED", "REFUNDED"}
assert finished is False
사건 이름만 보고 상태를 바꾼다
다음 코드는 출고 요청이면 현재 상태와 무관하게 출고됨으로 바꾼다. 결제하지 않은 주문도 출고할 수 있게 된다.
state = "NEW"
event = "ship"
if event == "ship":
state = "SHIPPED"
assert state == "SHIPPED"
완성 코드의 전이 함수를 사용하면 현재 상태와 사건을 함께 검사한다. 아래 코드는 그 함수가 정의된 파일에서 실행한다.
state = "NEW"
try:
state = transition(state, "ship")
except ValueError:
pass
assert state == "NEW"
pass는 해당 블록에서 아무 작업도 하지 않는 문장이다. 이 짧은 검사는 상태 유지에만 초점을 맞춘다. 실제 요청 처리에서는 완성 코드처럼 거부 사실을 호출한 쪽에 알려야 한다.
정규 표현식으로 앞부분만 확인한다
re.match는 문자열의 시작 위치에서 패턴이 맞는지 찾는다. 다음 코드는 완료 기록 뒤에 남은 잘못된 문자까지 검사하지 않는다.
import re
pattern = r"(C|PR|PSD)"
accepted = re.match(pattern, "PSDX") is not None
assert accepted is True
전체 기록이 유효해야 한다면 fullmatch를 사용한다. 검사 범위를 함수 이름에 드러내면 끝까지 확인하려는 의도도 읽기 쉽다.
import re
pattern = r"(C|PR|PSD)"
accepted = re.fullmatch(pattern, "PSDX") is not None
assert accepted is False
검사와 저장 사이의 상태 변화를 놓친다
완성 코드는 한 실행 흐름에서 순서대로 처리한다. 여러 요청이 같은 주문을 동시에 바꾸는 환경에서는 다음 두 줄 사이에 다른 요청이 끼어들 수 있다. 이미 환불된 주문을 오래된 PAID 값으로 출고 처리하는 식이다. 다음은 데이터 저장소의 동작을 설명하는 함수 형태의 예다.
def unsafe_ship(load_state, save_state):
state = load_state()
next_state = transition(state, "ship")
save_state(next_state)
저장할 때도 읽었던 상태와 같은지 확인하고, 확인과 변경을 하나의 작업으로 수행해야 한다. 전공 수업과 시스템 설계에서는 다른 작업이 중간에 끼어들 수 없게 수행하는 성질을 원자성이라고 부른다. 아래 예제는 그 조건을 작은 메모리 저장소에서 실행해 보는 코드다.
def replace_if_current(store, expected, next_state):
if store["state"] != expected:
return False
store["state"] = next_state
return True
store = {"state": "PAID"}
observed = store["state"]
store["state"] = "REFUNDED"
saved = replace_if_current(store, observed, "SHIPPED")
assert saved is False
assert store["state"] == "REFUNDED"
이 함수 자체가 여러 실행 흐름에서 원자성을 보장하는 것은 아니다. 여기서는 오래된 상태를 거부하는 조건만 확인했다. 실제 저장소에서는 상태 비교와 갱신을 묶어 제공하는 기능이 필요하다. 전이 표가 올바른 것과, 동시에 들어온 요청을 올바르게 저장하는 것은 별도로 확인할 문제다.
한눈에 보기
| 도구 | 확인하는 것 | 코드 표현 | 주의점 |
|---|---|---|---|
| 불 대수 | 현재 조건의 참·거짓 | and, or, not | 입력의 자료형과 부정 범위를 확인한다. |
| 조합 논리 회로 | 현재 입력에 따른 출력 | 조건을 계산하는 식 | 이전 입력을 기억하지 않는다. |
| 유한 상태 기계 | 현재 상태에서 가능한 사건 | 전이 사전과 함수 | 표에 없는 요청의 처리 방침을 정한다. |
| 정규 표현식 | 기록 전체의 허용된 형태 | re.fullmatch | 유효한 진행 중 기록과 완료 기록을 구분한다. |
| 상태 저장 | 검사한 상태와 저장 시점의 일치 | 조건을 둔 갱신 | 동시 요청에서는 비교와 변경을 묶어야 한다. |
조건을 정리한 뒤에는 가능한 작은 입력을 넣어 본다. 상태를 설계한 뒤에는 화살표를 따라가는 성공 사례뿐 아니라, 화살표가 없는 요청도 넣어 본다. 기록을 검사할 때에는 ‘진행 중도 받아들일 것인가’와 ‘종료된 것만 받아들일 것인가’를 먼저 정한다. 이 세 질문이 코드의 검사 위치를 구체적으로 만들어 준다.
연습 문제
not (busy or failed)와 같은 조건식을and를 사용해 작성하라. 두 입력의 모든 조합을 검사하고,or로 잘못 바꾼 식의 반례도 코드로 확인하라.현재 모델에서
PS,SP,PR를 각각 ‘유효한 진행 중’, ‘허용하지 않은 진행’, ‘유효한 완료’로 분류하라. 전이 함수와accepts_completed를 사용해 확인하라.종료되지 않은 주문에
inspect사건을 추가하라. 이 사건은 현재 상태를 유지한다. 세 종료 상태에서는 거부해야 한다. 허용 사례와 거부 사례를 각각 검사하라. 완료 기록의 정규 표현식 검사에서는 기존 다섯 기호만 계속 사용한다.완성 코드의 자료를 바꾸지 않고, 등록된 모든 상태와 다섯 사건의 조합을 검사하라. 허용된 전이는 표에 적힌 상태로 가야 하고, 나머지는
ValueError를 내야 한다. 전체 조합 수와 허용·거부 수를 확인하라.
정답과 해설
부정할 범위를 유지한다
전체 논리합을 부정하면 두 입력을 각각 부정한 논리곱이 된다. busy=True, failed=False는 잘못된 변환을 드러내는 입력이다.
for busy in (False, True):
for failed in (False, True):
original = not (busy or failed)
changed = (not busy) and (not failed)
assert original == changed
busy = True
failed = False
assert (not (busy or failed)) is False
assert ((not busy) or (not failed)) is True
진행의 유효성과 완료 여부를 나눈다
PS는 출고됨에서 멈추므로 유효한 진행 중이다. SP는 신규 주문의 출고 요청부터 거부된다. PR는 환불됨에서 끝나므로 유효한 완료다. 다음 코드는 완성 코드의 함수와 자료가 정의된 환경에서 실행한다.
state = transition("NEW", "pay")
state = transition(state, "ship")
assert state == "SHIPPED"
assert accepts_completed("PS") is False
try:
transition("NEW", "ship")
except ValueError:
rejected = True
else:
rejected = False
assert rejected is True
assert transition(transition("NEW", "pay"), "refund") == "REFUNDED"
assert accepts_completed("PR") is True
accepts_completed("SP")도 거짓이지만, 그 결과 하나만으로 진행 중인지 잘못된 기록인지 구분할 수는 없다. 함수가 답하도록 정한 질문이 완료 여부이기 때문이다.
같은 상태로 돌아오는 전이를 추가한다
자기 자신으로 돌아오는 전이도 전이다. 다음 함수는 기존 표를 수정하지 않고 점검 사건의 규칙을 덧붙인다. 기존 사건은 원래 전이 함수에 맡긴다.
def transition_with_inspect(state, event):
if state not in TRANSITIONS:
raise ValueError(f"알 수 없는 상태: {state}")
if event == "inspect" and state not in TERMINAL:
return state
return transition(state, event)
assert transition_with_inspect("PAID", "inspect") == "PAID"
assert transition_with_inspect("NEW", "inspect") == "NEW"
try:
transition_with_inspect("DONE", "inspect")
except ValueError:
rejected = True
else:
rejected = False
assert rejected is True
점검을 기록 기호에도 추가한다면 같은 주문 경로에 여러 점검 기록이 끼어들 수 있다. 그러면 완료 기록의 패턴도 수정해야 한다. 이 문제에서는 입력 기호의 범위를 그대로 두었으므로 기존 기록 검사는 달라지지 않는다.
전이 표 밖의 조합도 검사한다
상태 여섯 개와 사건 다섯 개로 전체 조합은 30개다. 허용된 조합은 표의 다섯 행이며 나머지 25개는 거부되어야 한다. 완성 코드 뒤에서 다음 검사를 실행할 수 있다.
allowed = 0
rejected = 0
for state in TRANSITIONS:
for event in SYMBOLS.values():
expected = TRANSITIONS[state].get(event)
try:
actual = transition(state, event)
except ValueError:
assert expected is None
rejected += 1
else:
assert expected is not None
assert actual == expected
allowed += 1
assert allowed == 5
assert rejected == 25
assert allowed + rejected == 30
values()는 사전의 값들을 차례로 제공한다. get은 키가 있으면 그 값을, 없으면 기본적으로 None을 반환한다. 이 검사는 전이 함수가 표의 허용과 거부를 따르는지 살핀다. 표 자체가 업무 요구에 맞는지는 배송·환불·취소 같은 구체적인 사례로 따로 확인해야 한다. 다음 장에서는 문제 설명 속에서 이런 조건과 상태 구조를 찾아 코드로 옮기는 판단을 이어 간다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.