본문 바로가기

Haskell

데이터를 입력받는 방법 - 1

이번 시간에는 기본 입출력 액션에 대해서 살펴보자. 컴퓨터 프로그램이 하는 일을 일반화해보면 주어진 자료를 처리하여 그 결과를 내놓는 것이다. 여기서 "자료가 주어진다"는 것은 소스코드에 정적으로 포함된 데이터만 처리하는 것이 아니라 (물론 이렇게 동작하는 프로그램도 많다. 초보들이 연습용으로 작성하는 코드들 대부분이 여기에 속한다.) 외부로 부터 데이터를 입력받을 수 있다는 것이다.

외부로부터의 데이터 입력이란 키보드로부터 문자열을 입력 받거나, 텍스트 파일을 읽어들이거나, 혹은 외부 네트워크에 요청하여 데이터를 받아올 수도 있다는 의미이다.

대응해야 하는 케이스가 엄청 많은 것 같지만, 이로부터 유발되는 혼란을 피하기 위해서 우리의 선조(?) 아키텍트들은 프로그램이 외부와 소통하는 창구를 표준화하였다. 그것이 '표준입력'과 '표준출력'이다.

콘솔에서 동작하는 프로그램의 표준출력은 기본적으로 터미널로의 출력으로 연결된다. print와 같은 함수로 어떤 값을 찍어내면 콘솔에 글자가 찍히는 것이 그것이다.

표준입력의 경우에는 기본적으로 키보드와 연결된다. 하지만 쉘 환경에서 프로그램을 실행하는 방법에 따라서 (파이핑 및 리다이렉트) 표준입력은 이전에 실행한 명령의 결과가 넘어오기도 하고, 특정한 파일의 내용이 될 때도 있다.

예를 들어 앞서 작성했던 최대공약수 구하는 프로그램을 다음과 같이 작성했다고 하자.

main :: IO ()
main = do
  inputData <- getLine
  let (a:b:_) = map read . words $ inputData
  putStrLn . show $ gcd a b

이 프로그램은 그나마 간단한데,

  1. getLine으로 받아온 문자열을 inputData::String에 바인딩한 후
  2. 이를 공백단위로 자르고 다시 각 단어를 정수로 읽어 a, b에 매칭한다.
  3. 이후 a, bgcd 함수를 이용해서 최대공약수를 구하고 그값을 출력한다.

이렇게 구성된다. 그리고 코드 자체도 별 군더더기 없이 잘 쓰여졌다. 만약 우리가 이 코드를 컴파일하여 프로그램으로 만들었다고 하자. 이 프로그램을 쓰기 위해서 우리는 매번 프로그램을 실행하고 두 개의 숫자를 입력해준다. 그러면 그 두 수의 최대공약수가 출력될 것이다. 이것이 이 프로그램이 쓰여질 때 상상된 사용 시나리오이다.

그러나 실제로 이것이 유용해야 하는 상황이라면, 수 백의 숫자쌍이 있고 각 숫자쌍들의 최대공약수를 구해야하는 상황일 것이다. 그렇다면 어떻게 처리되는 것이 맞을까?

이 코드의 main 함수와 거의 비슷한 역할을 하는 processLine 함수를 작성해보겠다.

processLine :: IO ()
processLine = do
  inputData <- getLine
  if (>0) . length $ inputData
     then do
       let (a:b:_) = map read . words $ inputData
       putStrLn . show $ gcd a b
       processLine
     else return ()
  
main = processLine

processLine 함수는 한줄을 입력받고, 해당 줄이 빈 줄이 아니면 입력값을 파싱해서 최대공약수를 출력한 다음, 다시 processLine을 호출한다. (재귀적으로 반복한다.)

반대로 입력된 문자열의 길이가 0이면 아무것도 하지 않는 액션을 수행하고 더 이상 반복하지 않는다.

이제 이 프로그램을 실행하면 한 번의 실행으로 여러줄의 입력을 받아서 처리할 수 있다. 이런식으로 처리할 수 있다면 우리는 일일이 키보드로 출력할 것이 아니라, 파일을 표준입력으로 대체하여 처리할 수 있다.


$ printGCD < numbers.txt > reseult.txt

다만 이 동작은 마지막에 에러를 출력하게 된다. 왜냐하면 파일의 끝은 EOF이지 빈줄이 아니기 때문이다.

getLine

위 코드에서 살펴봤던 함수 중에서 getLine을 잠깐 살펴보자. 이 함수의 타입은 IO String이다. 즉 호출하게되면 표준입력으로부터 한 줄의 문자열을 받아오게 된다. 그런데 이 함수는 별도의 인자를 받지 않는다.

순수함수형 언어에서는 함수란, 인자가 동일한 경우에 그 출력값은 항상 같아야 한다. 따라서 전달되는 인자가 없는 getLine이 만약 String 타입이라면 이는 항상 같은 문자열을 리턴해야 한다. 이런 함수는 사실 얼마든지 임의로 정의할 수 있다. 우리가 "변수"라 불렀던 것과 똑같은 문법으로 말이다.

greetMessage = "Hello World"

greetMessage 는 변수가 아니라 사실 "상수함수"이며 인자 입력을 받지 않고 매번 "Hello World"라는 문자열값을 리턴한다.

그런데 키보드 입력은 "늘 같은" 문자열을 리턴한다는 보장이 없다. 따라서 순수함수에서는 사실 이를 처리할 방법이 없는 것이다. 하지만 입력을 받지 못하는 프로그램은 별 쓸모가 없기 때문에 이는 어떤 식으로든 극복되어야 한다. 따라서 하스켈에서는 모나드라는 수학적 개념을 이용한다. 모나드에 대한 설명을 여기서 간단히 풀어내기는 좀 어려운데, 일종의 "불순한" 상자에 넣은 값이라 가정하는 것이라 볼 수 있다.

따라서 "불순한 상자에 든 문자열"이라는 개념으로 getLine을 정의하면, getLine의 결과 자체는 순수한 함수 세계인 하스켈 프로그램 내에서는 직접 다룰 수 없지만, 맵핑과 같은 개념을 적용해서 상자 바깥 (순수한 세계)의 순수 함수를 상수속의 불순한 값에 투영하여 조작하는 것은 가능하다는 말이다.

따라서 대부분의 main 함수는 do 절을 포함하고 있으며, 이 do 절은 IO 모나드 내부에서 처리되어야 하는 코드를 담고 있다. 이 내부에서 적용되는 함수들은 모두 맵핑을 통해서 투영된 상태라 상상하면 된다. (그냥 이해가 안돼도 대충 넘어가자)

getContents

getContents 함수는 앞에서 processLine을 통해서 매 입력을 반복해서 처리하던 수고를 덜어줄 수 있는 함수이다. 이 함수는 표준 입력으로 전달되는 모든 입력을 하나의 문자열로 처리해준다.

입력된 문자열은 느긋하게 평가되는데, 실질적으로 "언제 평가될 것이냐"는 프로그램 내부에서 알아서 처리할 일이기 때문에 우리는 신경쓰지 않아도 된다. getContents를 사용하는 식으로 코드를 작성하면 processLine을 작성하지 않고도 다음과 같이 간결한 코드가 완성된다.

main :: IO
main = do
  inputData <- getContents
  mapM_ (lines inputData) $ \line ->
    putStrLn . show . (uncurry gcd) . list2tuple .
    map read . words $ line
 where list2tuple (a:b:_) = (a, b)

여기서 흥미로운 점은 getContents는 입력된 전체 내용을 담고 있는 멀티라인 문자열인데, 모든 입력이 완료된 후에 결과가 처리되는 것이 아니라, 매행을 입력하고 엔터치는 시점에 즉각즉각 그 결과가 출력된다는 것이다.

이는 어찌보면 상당히 놀라운 결과인데, 이 프로그램이 동작하는 방식을 보면,

  1. getContents 함수로 여러 줄의 입력을 받아 inputData에 바인딩
  2. lines inputData로 이를 매 행으로 쪼갠 후 mapM_을 통해서 각 행에 처리될 함수를 맵핑한다.
  3. 매핑되는 함수는 주어진 행을 단어로 자르고 각각을 정수로 읽어서 최대공약수를 구해 출력한다.

따라서 모든 입력이 끝나고 EOF를 입력해서 입력을 종결해야 lines inputData로 입력된 각 행이 쪼개질 것 같지만, 모든 계산이 느긋하게 실행되는 하스켈의 특성상, 처리되어야 하는 로직은 실제로 회로처럼 구축되고, inputData에 새로운 행이 발견될 때 마다 맵핑 로직으로 넘어가게 된다.

이는 "문자열을 행으로 쪼갠다"라는 동작이 느긋하게 수행됨으로써 얻는 이점이다. 쪼개질 수 있는 시점에 행으로 하나씩 쪼개진 문자열은 쪼개지고 난 이후에는 기다려야할 값이 없으므로 언제든 맵핑 로직으로 들어갈 수 있는 것이다. 그리고 이러한 동작의 가장 멋진 점은, 바로 getContents가 실제로 "무한히 많은 라인들"을 가져오는 상황이 발생하더라도, 엄청나게 큰 텍스트 데이터를 메모리로 로딩하지 않고 처리되기 때문에, 무한한 입력에 대해서도 처리가 가능하다는 장점이 있다.

어쨌든 getContents를 사용하면 명령줄에서 파일로부터 리다이렉트된 내용을 각 라인별로 처리하는 것이 가능하다.

부록

위 예의 함수는 아래의 한 줄 함수로 다시 줄여쓸 수 있다. (길이상 나눠썼다)


main = getContents >>= mapM_ (putStrLn . show .  (uncurry gcd) 
         . list2tuple . map read . words) . lines
  where list2tuple (x:y:_) = (x, y)

괄호속의 (putStrLn . show . (uncurry gcd) . map read . words)는 원래의 코드와 동일하고 이를 mapM_ 을 통해서 lines를 거친 getContents의 내부 문자열에 맵핑한다.