입출력
입출력
하스켈의 입출력(I/O) 시스템은 순수 함수형이면서도 일반 언어만큼의 표현력을 가져요. 그 비결은 부수 효과를 IO 모나드라는 경계 안으로 깔끔하게 격리하는 거예요. 여기서는 getChar/putChar 같은 기본 동작에서부터 do 표기로 동작을 이어붙이는 법, 예외 처리, 그리고 게으른 파일 읽기까지 정리해 볼게요.
본문
IO는 순수 함수형
명령형 언어에서 프로그램은 세계의 현재 상태를 조사하고 수정하는 **동작(action)**으로 진행돼요. 전역 변수 읽기·설정, 파일 쓰기, 입력 읽기처럼요. 하스켈에도 이런 동작이 있지만, 언어의 순수 함수형 핵심과 깔끔하게 분리되어 있어요.
하스켈의 I/O는 다소 벅찬 수학적 토대, 즉 모나드 위에 세워져 있어요. 하지만 그 모나드 이론을 이해하지 않아도 I/O를 프로그래밍하는 데는 지장이 없어요. 단순한 산수를 위해 군론(group theory)을 이해할 필요가 없는 것과 같죠. 우선은 IO 모나드를 그냥 추상 데이터 타입이라 생각하는 게 가장 편해요.
하스켈의 표현식 언어 안에서는 동작이 "정의"되지 "호출"되지 않아요. 동작의 정의를 평가한다고 해서 실제로 동작이 일어나진 않아요. 동작의 호출은 지금까지 본 표현식 평가 밖에서 이루어져요.
동작은 모두 값을 돌려준다
모든 I/O 동작은 값을 돌려줘요. 타입 시스템에서 반환 값은 IO 타입으로 "표시"되어, 동작을 다른 값과 구분해 줘요. 예를 들어 getChar의 타입은 이래요.
getChar :: IO Char
IO Char는 getChar이 호출되면 문자를 돌려주는 어떤 동작을 수행한다는 뜻이에요. 흥미로운 값을 돌려주지 않는 동작은 단위 타입 ()을 써요. 예를 들어 putChar는
putChar :: Char -> IO ()
문자를 인자로 받지만 유용한 값을 돌려주진 않아요. 단위 타입은 다른 언어의 void와 비슷해요.
동작 이어붙이기와 do 표기
동작은 다소 수수께끼 같은 이름의 연산자 >>=(bind)로 이어붙여요. 하지만 직접 쓸 필요는 없고, do 표기라는 문법적 설탕으로 일반 언어에 가까운 문법 아래에 시퀀싱 연산자를 숨길 수 있어요. do 표기는 >>=로 쉽게 풀어낼 수 있어요.
키워드 do는 순서대로 실행되는 문장의 시퀀스를 도입해요. 문장은 동작이거나, <-로 동작 결과에 바인딩되는 패턴이거나, let으로 도입되는 로컬 정의예요. do는 let이나 where처럼 레이아웃을 쓰므로 적절한 들여쓰기로 중괄호와 세미콜론을 생략할 수 있어요. 문자 하나를 읽어 다시 출력하는 간단한 프로그램을 볼게요.
main :: IO ()
main = do c <- getChar
putChar c
main이라는 이름은 중요해요. 하스켈 프로그램의 진입점(사실상 C의 main처럼)으로 정의되고, 반드시 IO 타입, 보통 IO ()를 가져야 하거든요. 이 프로그램은 두 동작을 순서대로 수행해요. 먼저 문자를 읽어 변수 c에 바인딩하고, 그다음 문자를 출력해요. let과 달리 <-로 정의된 변수는 그 뒤의 문장에서만 유효해요.
return
동작을 호출하고 결과를 조사할 수는 있는데, 동작 시퀀스에서 값을 돌려주려면 어떻게 해야 할까요? 문자를 읽어 y이면 True를 돌려주는 ready 함수를 생각해 봐요.
ready :: IO Bool
ready = do c <- getChar
c == 'y' -- Bad!!!
이건 동작하지 않아요. do의 두 번째 문장이 동작이 아니라 그냥 부울 값이기 때문이에요. 이 부울을 받아 아무것도 하지 않으면서 그 값을 결과로 돌려주는 동작을 만들어야 해요. return 함수가 바로 그 일을 해요.
return :: a -> IO a
ready의 마지막 줄은 return (c == 'y')가 되어야 해요.
좀 더 복잡한 I/O 함수도 볼게요. 먼저 getLine을 보면,
getLine :: IO String
getLine = do c <- getChar
if c == '\n'
then return ""
else do l <- getLine
return (c:l)
else 절에 두 번째 do가 있다는 점에 주목하세요. 각 do는 단일 문장 체인을 도입해요. if 같은 개입 구조가 있으면 그 안에서 동작을 계속 이어가기 위해 새 do를 써야 해요.
순수 코드에서는 I/O를 할 수 없어요
return은 부울 같은 값을 I/O 동작의 영역으로 들여보내요. 반대 방향은 어떨까요? 순수 표현식 안에서 I/O 동작을 호출할 수 있을까요? 답은 "안 된다"예요. 순수 함수형 코드 한가운데서 명령형 세계로 몰래 들어갈 수는 없어요. 명령형 세계에 "오염된" 값은 반드시 그렇게 표시되어야 해요. 다음과 같은 함수는
f :: Int -> Int -> Int
IO가 반환 타입에 없으므로 절대 I/O를 할 수 없어요. 이 사실은 근래 디버깅할 때 print 문을 마음껏 흩뿌리는 데 익숙한 프로그래머를 꽤 당황하게 하죠. 이 문제를 우회하는 안전하지 않은(unsafe) 함수들도 있지만, 고급 프로그래머에게 맡기는 게 좋아요.
동작 목록 만들기
putStr을 볼게요. putChar에서 sequence_를 이용해 만들 수 있어요.
putStr :: String -> IO ()
putStr s = sequence_ (map putChar s)
하스켈과 관용 명령형 프로그래밍의 차이 하나가 여기 드러나요. 명령형 언어에서는 문자열에 putChar의 명령형 버전을 매핑하는 것만으로 출력이 끝나요. 하지만 하스켈에서 map은 어떤 동작도 수행하지 않아요. 대신 문자열의 각 문자에 대한 동작 목록을 만들 뿐이에요. sequence_의 접기(folding)가 >> 연산으로 개별 동작을 모두 하나의 동작으로 합쳐 주죠. 여기 쓰인 return ()은 꼭 필요해요 — foldr이 만드는 동작 체인의 끝에 (문자가 없을 경우 특히) null 동작이 필요하기 때문이에요.
예외 처리
지금까지는 I/O 중 발생하는 예외를 다루지 않았어요. getChar이 파일 끝에 닿으면 어떻게 될까요? I/O 모나드 안에서 예외를 다루는 처리 메커니즘이 있는데, 표준 ML의 것과 기능이 비슷해요. 특별한 문법이나 의미론은 없고, 예외 처리는 I/O 시퀀싱 연산의 정의의 일부예요.
오류는 특수 데이터 타입 IOError로 인코딩돼요. 이 타입은 IO 모나드에서 발생할 수 있는 모든 예외를 나타내요. 추상 타입이라 사용자에게 IOError의 생성자는 노출되지 않고, 술어(predicate)로 값을 조회할 수 있어요. 예를 들어
isEOFError :: IOError -> Bool
은 오류가 파일 끝 조건 때문인지 판별해요. IOError를 추상으로 유지하면 시스템에 새 오류 종류를 추가해도 타입에 눈에 띄는 변화가 없어요. isEOFError는 별도 라이브러리 IO에 정의되어 명시적으로 임포트해야 해요.
예외 핸들러는 타입 IOError -> IO a를 가져요. catch 함수가 동작에 예외 핸들러를 연결해 줘요.
catch :: IO a -> (IOError -> IO a) -> IO a
catch의 인자는 동작과 핸들러예요. 동작이 성공하면 결과가 핸들러를 호출하지 않고 반환돼요. 오류가 발생하면 그 오류가 IOError 값으로 핸들러에 전달되고, 핸들러가 연결한 동작이 호출돼요. 예를 들어 오류를 만나면 개행 문자를 돌려주는 getChar 버전은 이래요.
getChar' :: IO Char
getChar' = getChar `catch` (\e -> return '\n')
이건 모든 오류를 똑같이 다뤄서 다소 투박해요. 파일 끝만 인식하려면 오류 값을 조회해야 해요.
getChar' :: IO Char
getChar' = getChar `catch` eofHandler where
eofHandler e = if isEofError e then return '\n' else ioError e
여기 쓰인 ioError 함수는 예외를 다음 예외 핸들러로 던져요. 타입은
ioError :: IOError -> IO a
return과 비슷하지만 제어를 다음 I/O 동작으로 진행하는 대신 예외 핸들러로 넘겨요. catch는 중첩될 수 있고, 중첩된 예외 핸들러를 만들 수 있어요. 편의상 하스켈은 프로그램 최상위에 기본 예외 핸들러를 제공해서, 예외를 출력하고 프로그램을 종료해요.
파일과 핸들
I/O 모나드와 예외 처리를 빼면, 하스켈의 I/O 기능은 대부분 다른 언어와 비슷해요. 파일을 여는 건 I/O 트랜잭션에 쓸 핸들(Handle 타입)을 만들고, 핸들을 닫으면 연결된 파일이 닫혀요.
type FilePath = String -- path names in the file system
openFile :: FilePath -> IOMode -> IO Handle
hClose :: Handle -> IO ()
data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode
핸들은 채널과도 연결될 수 있고, 표준 입력 stdin, 표준 출력 stdout, 표준 오류 stderr 같은 채널 핸들은 미리 정의되어 있어요. 문자 단위 I/O에는 핸들을 인자로 받는 hGetChar, hPutChar가 있어요. 그동안 쓴 getChar는 이렇게 정의돼요.
getChar = hGetChar stdin
하스켈은 파일이나 채널의 전체 내용을 단일 문자열로 돌려주는 것도 허용해요.
getContents :: Handle -> IO String
실용적으로 보면 getContents가 파일 전체를 즉시 읽어 공간·시간 성능이 나빠질 것 같지만, 그렇지 않아요. 핵심은 getContents가 "게으른"(비엄격한) 문자 리스트를 돌려준다는 거예요. 문자열이 하스켈에서 문자 리스트임을 기억하면 되는 거죠. 원소는 다른 리스트처럼 "요구에 따라" 읽혀요. 구현체는 계산이 요구할 때마다 파일에서 문자를 하나씩 읽는 방식으로 이 동작을 구현할 수 있어요.
전체 파일 복사 예시
한 파일을 다른 파일로 복사하는 하스켈 프로그램을 볼게요.
main = do fromHandle <- getAndOpenFile "Copy from: " ReadMode
toHandle <- getAndOpenFile "Copy to: " WriteMode
contents <- hGetContents fromHandle
hPutStr toHandle contents
hClose toHandle
putStr "Done."
getAndOpenFile :: String -> IOMode -> IO Handle
getAndOpenFile prompt mode =
do putStr prompt
name <- getLine
catch (openFile name mode)
(\_ -> do putStrLn ("Cannot open "++ name ++ "\n")
getAndOpenFile prompt mode)
게으른 getContents 덕분에 파일 전체를 한 번에 메모리에 읽어 들일 필요가 없어요. hPutStr이 고정 크기 블록으로 출력을 버퍼링하도록 한다면, 입력 파일의 한 블록만 메모리에 있으면 돼요. 입력 파일은 마지막 문자를 읽은 뒤 암묵적으로 닫혀요.
결국 명령형을 재발명한 걸까?
getLine 같이 생긴 코드는 명령형 프로그래밍을 연상시켜요.
getLine = do c <- getChar
if c == '\n'
then return ""
else do l <- getLine
return (c:l)
는 이런 (어떤 실제 언어도 아닌) 명령형 코드와 놀라울 정도로 닮았어요.
function getLine() {
c := getChar();
if c == `\n` then return ""
else {l := getLine();
return c:l}}
그렇다면 하스켈이 그냥 명령형 바퀴를 재발명한 걸까요? 어떤 의미에서는 그렇다고 할 수 있어요. IO 모나드는 하스켈 안의 작은 명령형 하위 언어로, 프로그램의 I/O 부분이 평범한 명령형 코드처럼 보일 수 있어요. 하지만 중요한 차이가 하나 있어요. 사용자가 다뤄야 할 특별한 의미론이 없다는 거예요. 특히 하스켈에서 등식 추론(equational reasoning)이 손상되지 않아요. 모나딕 코드의 명령형 느낌이 함수형 측면을 해치지 않아요. 경험 많은 함수형 프로그래머는 명령형 부분을 최소화해서, 최소한의 최상위 시퀀싱에만 IO 모나드를 쓰도록 할 수 있어요. 모나드는 함수형과 명령형 프로그램 구성요소를 깔끔하게 분리해 주죠. 반면 함수형 하위 집합을 가진 명령형 언어들은 순수 함수형 세계와 명령형 세계 사이에 잘 정의된 경계가 없는 경우가 대부분이에요.
더 알아보기
- 모나드 — IO가 실제로 쓰는
>>=,return, do 문법 - 함수 — 지연 평가와 부분 적용
- 값, 타입, 그리고 리스트 —
String = [Char], 리스트