주류 정적 타입 OOP가 다수 개발자·장기 프로젝트에서 특히 잘하는 점을 로거/DB 예제로 보여준다. Haskell의 ADT, 타입클래스, 효과 모나드와 합성 효과 접근과 비교하며 OOP의 강점을 논한다.
2024년 10월 9일 - 태그: en, plt, haskell.
OOP는 분명 내가 가장 좋아하는 패러다임은 아니지만, 주류의 정적 타입 OOP는 여러 사람이 오랜 기간 함께 프로그래밍할 때 대단히 중요한 몇 가지를 잘해낸다고 생각한다.
이 글에서는 주류 정적 타입 OOP 언어들이 잘하는 것들 가운데 내가 가장 중요하다고 생각하는 한 가지를 설명하려 한다.
그다음 OOP 코드와 Haskell을 비교해, 일부 함수형 프로그래머들이 생각하듯 OOP가 모든 면에서 그렇게 나쁜 것은 아니라는 점을 말해보겠다.
이 글에서 나는 “OOP”를 다음과 같은 기능을 갖춘 정적 타입 언어에서의 프로그래밍을 뜻하는 말로 쓴다:
B의 값을 A로서 전달할 수 있게 해주는 서브타이핑.이 정의에 따른 OO 언어의 예: C++, Java, C#, Dart.
이 기능 세트는 합성 가능한 라이브러리를 간단하고 편리하게 개발하고, 하위 호환을 유지하며 새 기능을 추가해 라이브러리를 확장하는 쉬운 방법을 제공한다.
예로 설명하는 게 가장 좋겠다. 간단한 로거 라이브러리가 있다고 하자:
class Logger {
// private 생성자: 상태를 초기화하고 `Logger` 인스턴스를 반환한다.
Logger._();
// public 팩토리: `Logger` 또는 그 하위 타입을 반환할 수 있다.
factory Logger() => Logger._();
void log(String message, Severity severity) { /* ... */ }
}
enum Severity {
Info,
Error,
Fatal,
}
그리고 데이터베이스 관련 작업을 하는 다른 라이브러리:
class DatabaseHandle {
/* ... */
}
이 둘을 함께 사용하는 애플리케이션:
class MyApp {
final Logger _logger;
final DatabaseHandle _dbHandle;
MyApp()
: _logger = Logger(),
_dbHandle = DatabaseHandle(...);
}
보통 네트워크 연결을 만들거나, 공유 상태를 바꾸는 것 등은 애플리케이션을 테스트하려면 모킹, 페이킹, 스텁화가 필요하다. 또한 라이브러리를 새로운 기능으로 확장하고 싶을 수 있다. 우리가 가진 기능들 덕분에, 이런 필요를 미리 예측해 타입을 준비해 둘 필요가 없다.
첫 번째 이터레이션에서는 단순히 현재 클래스의 복사본인 구체 클래스를 추가하고, 현재 클래스를 추상 클래스로 만들 수 있다:
// 이제 클래스는 추상 클래스다.
abstract class Logger {
// public 팩토리는 이제 구체 하위 타입의 인스턴스를 반환한다.
factory Logger() => _SimpleLogger();
Logger._();
// `log`는 이제 추상 메서드다.
void log(String message, Severity severity);
}
class _SimpleLogger extends Logger {
factory _SimpleLogger() => _SimpleLogger._();
_SimpleLogger._() : super._() {/* ... */}
@override
void log(String message, Severity severity) {/* ... */}
}
이 변경은 하위 호환이며, 사용자 코드 변경이 전혀 필요 없다.
이제 더 많은 구현을 추가할 수도 있다. 예를 들어 로그 메시지를 무시하는 구현:
abstract class Logger {
factory Logger() => _SimpleLogger();
// 새로 추가됨.
factory Logger.ignoring() => _IgnoringLogger();
Logger._();
void log(String message, Severity severity);
}
class _IgnoringLogger extends Logger {
factory _IgnoringLogger() => _IgnoringLogger._();
_IgnoringLogger._() : super._() {}
@override
void log(String message, Severity severity) {}
}
비슷하게 파일에 로깅하는 구현, DB에 로깅하는 구현 등도 추가할 수 있다.
데이터베이스 핸들 클래스도 테스트에서 모킹/페이킹/스텁화를 위해 같은 방식으로 만들 수 있다.
이 새로운 하위 타입들을 앱에서 사용할 수 있도록, 팩토리를 구현하거나 생성자를 추가해 로거와 DB 핸들을 전달받게 만들자:
class MyApp {
final Logger _logger;
final DatabaseHandle _dbHandle;
MyApp()
: _logger = Logger(),
_dbHandle = DatabaseHandle();
MyApp.withLoggerAndDb(this._logger, this._dbHandle);
}
어떤 타입도 바꿀 필요가 없었고, 타입 매개변수를 추가할 필요도 없었다는 점에 주목하자. _logger와 _dbHandle 필드를 사용하는 MyApp의 어떤 메서드도 이런 변경 사항을 알 필요가 없다.
이제 DatabaseHandle 구현 중 하나가 로거 라이브러리를 사용하기 시작했다고 해보자:
abstract class DatabaseHandle {
factory DatabaseHandle.withLogger(Logger logger) =>
_LoggingDatabaseHandle._(logger);
factory DatabaseHandle() => _LoggingDatabaseHandle._(Logger.ignoring());
DatabaseHandle._();
/* ... */
}
class _LoggingDatabaseHandle extends DatabaseHandle {
final Logger _logger;
_LoggingDatabaseHandle._(this._logger) : super._();
/* ... */
}
우리 앱에서는 테스트 시 DB 라이브러리의 로깅을 끄고, 프로덕션에서는 DB 작업 로깅을 켜도록 할 수 있다:
class MyApp {
// 새로 추가됨
MyApp.testingSetup()
: _logger = Logger(),
_dbHandle = DatabaseHandle.withLogger(Logger.ignoring());
// DB 라이브러리의 로깅 기능을 사용하도록 업데이트
MyApp()
: _logger = Logger(),
_dbHandle = DatabaseHandle.withLogger(Logger.toFile(...));
/* ... */
}
타입에 더 많은 상태를 추가하는 예로, 특정 심각도 이상의 메시지만 기록하는 로거 구현을 추가할 수 있다:
class _LogAboveSeverity extends _SimpleLogger {
// 이 심각도 이상인 메시지만 기록한다.
final Severity _severity;
_LogAboveSeverity(this._severity) : super._();
@override
void log(String message, Severity severity) { /* ... */ }
}
Logger 추상 클래스에 이 타입을 반환하는 또 다른 팩토리를 추가할 수도 있고, 심지어 이를 다른 라이브러리에서 구현할 수도 있다:
// `Logger`의 라이브러리가 아니라, 다른 라이브러리에서 구현됨.
class LogAboveSeverity implements Logger {
// 이 심각도 이상인 메시지만 기록한다.
final Severity _severity;
final Logger _logger;
LogAboveSeverity(this._severity) : _logger = Logger();
LogAboveSeverity.withLogger(this._severity, this._logger);
@override
void log(String message, Severity severity) { /* ... */ }
}
마지막으로, 더 많은 “상태”가 아니라 더 많은 “연산”을 추가하는 예로, 파일에 기록하고 flush 연산을 제공하는 로거를 만들 수 있다:
class FileLogger implements Logger {
final File _file;
FileLogger(this._file);
@override
void log(String message, Severity severity) {/* ... */}
void flush() {/* ... */}
}
요약:
중요하게도, 이러한 변경을 하는 동안 어떤 타입도 바꿀 필요가 없었고, 새 코드는 이전만큼 타입 안전하다.
로거와 데이터베이스 라이브러리는 완전히 하위 호환되는 방식으로 진화했다.
우리 애플리케이션에서 사용하는 타입은 전혀 바뀌지 않았으므로, MyApp의 메서드들은 전혀 변경할 필요가 없었다.
새 기능을 활용하기로 결정했을 때도, 앱에서 로거와 DB 핸들 인스턴스를 구성하는 부분만 업데이트했다. 앱의 나머지 부분은 변하지 않았다.
이제 Haskell에서는 이런 것을 어떻게 할 수 있을지 생각해보자.
처음부터 이것을 표현하는 방법에 몇 가지 선택지가 있다.
옵션 1: 나중에 다양한 로거를 추가할 수 있도록 콜백 필드를 가진 ADT(대수적 데이터 타입)
data Logger = MkLogger
{ _log :: Message -> Severity -> IO ()
}
simpleLogger :: IO Logger
data Severity = Info | Error | Fatal
deriving (Eq, Ord)
log :: Logger -> String -> Severity -> IO ()
이 표현에서는, _LogAboveSeverity에서의 최소 심각도 같은 추가 상태를 타입에 넣지 않고, 클로저에 캡처한다:
logAboveSeverity :: Severity -> IO Logger
logAboveSeverity minSeverity = MkLogger
{ _log = \message severity -> if severity >= minSeverity then ... else pure ()
}
클로저들이 공유하는 일부 상태를 업데이트해야 한다면, 그 상태를 IORef 같은 참조 타입에 저장해야 한다.
OOP 코드와 비슷하게, FileLogger는 별도의 타입이 되어야 한다:
data FileLogger = MkFileLogger
{ _logger :: Logger -- 콜백이 파일 디스크립터/버퍼를 캡처해 그곳에 씀
, _flush :: IO () -- 마찬가지로 파일 디스크립터/버퍼를 캡처해 flush 수행
}
logFileLogger :: FileLogger -> String -> Severity -> IO ()
logFileLogger = log . _logger
하지만 OOP 예제와 달리, Logger 타입과 log 함수를 사용하던 기존 코드는 이 새 타입과 함께 그대로 동작하지 않는다. 어느 정도 리팩터링이 필요하고, 사용자 코드가 어떻게 리팩터링되어야 하는지는 이 새 타입을 사용자에게 어떻게 노출하고 싶은지에 따라 달라진다.
옵션 2: 구체 로거 타입들에 대해 구현할 수 있는 타입클래스
class Logger a where
log :: a -> String -> Severity -> IO ()
data SimpleLogger = MkSimpleLogger { ... }
simpleLogger :: IO SimpleLogger
simpleLogger = ...
instance Logger SimpleLogger where
log = ...
로거 라이브러리에서 하위 호환 변경을 가능하게 하려면, 구체 로거 클래스를 숨겨야 한다:
module Logger
( Logger
, simpleLogger -- 반환 타입을 내보내지 않고도 이건 export할 수 있다
) where
...
이 모듈을 사용하면, Logger를 사용하는 함수나 다른 타입들에 타입 매개변수를 추가하거나, 존재 타입을 사용해야 한다.
타입 매개변수를 추가하는 것은 하위 호환 변경이 아니고, 일반적으로는 직접 사용자, 그 사용자의 사용자, … 로 눈덩이처럼 전파되어 거대한 변경과 사용하기 어려운 타입을 만들어낸다.
존재 타입의 문제는 이를 사용할 수 있는 방식이 제한적이고, 몇몇 영역에서는 다소 낯설다는 점이다. 애플리케이션에서는 다음과 같이 할 수 있다:
data MyApp = forall a . Logger a => MkMyApp
{ _logger :: a
}
하지만 이런 존재 타입을 가진 지역 변수를 만들 수는 없다:
createMyApp :: IO MyApp
createMyApp = do
-- 구체 타입 없이 myLogger에 타입 주석을 달 수 없다
myLogger <- simpleLogger -- simpleLogger :: IO SimpleLogger
return MkMyApp { _logger = myLogger }
함수 인자로 존재 타입을 둘 수도 없다:
-- 시그니처는 컴파일러가 받아들이지만, 값을 사용할 수 없다.
doStuffWithLogging :: (forall a . Logger a => a) -> IO ()
doStuffWithLogging logger = log logger "test" Info -- 난해한 타입 오류 발생
대신 타입클래스 사전과 로거 값을 새 타입에 “포장”해야 한다:
data LoggerBox = forall a . Logger a => LoggerBox a
doStuffWithLogging :: LoggerBox -> IO ()
doStuffWithLogging (LoggerBox logger) = log logger "test" Info
이 접근의 다른 문제와 제약:
forall a . Logger a => ... a ...처럼 단순한 Logger 대신 써야 한다.FileLogger를 구현할 수는 있지만,
FileLogger의 구체 타입을 알지 못한 채 Logger 값을 FileLogger로 안전하게 다운캐스팅하는 데 사용할 수 없다.효과 모나드 접근은 옵션 (2)에서 존재 타입을 쓰지 않는 변형이다. 즉,
class Logger a where
log :: a -> String -> Severity -> IO ()
대신 모나드 타입 매개변수에 로깅 능력을 담는다:
class MonadLogger m where
log :: String -> Severity -> m ()
그런 다음 각 로거 구현에 대해 “모나드 변환기”를 제공한다:
newtype SimpleLoggerT m a = SimpleLoggerT { runSimpleLoggerT :: m a }
instance MonadIO m => MonadLogger (SimpleLoggerT m) where
log msg sev = SimpleLoggerT { runSimpleLoggerT = liftIO (logStdout msg sev) }
newtype FileLoggerT m a = FileLoggerT { runFileLoggerT :: Handle -> m a }
instance MonadIO m => MonadLogger (FileLoggerT m) where
log msg sev = FileLoggerT { runFileLoggerT = \handle -> liftIO (logFile handle msg sev) }
데이터베이스 라이브러리도 같은 방식으로 만들고, 앱은 이들을 함께 조합한다:
newtype MyAppMonad a = ...
instance MonadLogger MyAppMonad where ...
instance MonadDb MyAppMonad where ...
로깅 하나, 데이터베이스 연산 하나처럼 각각에 대해 타입 매개변수를 두는 대신, 모든 부수효과를 하나의 타입 매개변수에 캡슐화하기 때문에, 사용 위치에서 타입 매개변수가 눈덩이처럼 불어나는 문제를 피할 수 있다.
데이터베이스 라이브러리도 사용자 코드를 깨뜨리지 않고 로거 의존성을 추가할 수 있다.
내 생각에 Haskell에서 얻을 수 있는 최선의 해법이며, 사용자 코드에서 필요한 변경의 관점에서는 OOP 해법과 상당히 비슷하다.
하지만 이것이 잘 작동하려면 생태계 전체의 라이브러리들이 이 방식을 따라야 한다. 만약 데이터베이스 라이브러리가 ADT 접근을 선택한다면, 라이브러리 함수를 호출하기 위해 DB 연산용 모나드 타입클래스와, 이를 위한 구체 모나드 변환기 타입 같은 “어댑터”가 필요해진다.
이것이 바로 합성 가능한 효과 라이브러리들이 가진 핵심 문제이기도 하다.
(런타임 성능과 관련된 문제도 있지만, 그건 아마 다른 글에서 다룰 주제일 것이다.)
Haskeller들은 DB 연산, 로깅 같은 부수효과를 “효과”로 모델링하고, 이를 합성하는 다양한 방식을 개발해왔다.
앞 절에서 본 것처럼, 효과 모나드를 통한 간단하고 널리 쓰이는 방식도 있다.
하지만 이 시스템들은 우리의 OOP 해법과 비교했을 때 몇 가지 단점이 있다:
서로 다른 효과 라이브러리는 보통 함께 동작하지 않는다. 예를 들어 mtl과 eff의 함수는 어떤 것을 다른 것으로 바꿔주는 어댑터 없이 함께 사용할 수 없다.
설령 Haskell 생태계 전체가 하나의 효과 시스템을 쓰기로 한다고 해도, 프로그램의 서로 다른 부분에서 서로 다른 핸들러를 쓰는 것(예: DB 라이브러리에서는 A 로거, 메인 앱에서는 B 로거)은 타입 곡예를 요구한다. 어떤 효과 라이브러리에서는 이것조차 불가능하다.
마지막으로, 이 글에서 보인 OOP 코드는 매우 기초적이고 직관적이며, OOP를 처음 배우는 사람도 쓸 수 있는 코드다. 새로 합류한 사람이나 일회성 버그 수정을 하려는 기여자도 라이브러리나 앱 코드 어느 쪽이든 쉽게 작업할 수 있다. 합성 가능한 효과 라이브러리들에 대해 같은 말을 하기는 어렵다.
주류 정적 타입 OOP는 타입을 쉽게 합성할 수 있게 유지하면서, 타입을 하위 호환적으로 직관적으로 진화시킬 수 있게 해준다. 나는 이것이 주류 정적 타입 OOP의 킬러 피처 중 하나라고 생각하고, 많은 사람이 오랜 기간 함께 프로그래밍하기 위해 필수적인 특징이라고 믿는다.
OOP와 마찬가지로 Haskell에도 설계 패턴이 있다. 위에서 본 효과 모나드 패턴 같은 것들이다. 이런 설계 패턴들 중 일부는 문제를 깔끔하게 해결하지만, 유용하려면 생태계 전체가 같은 패턴을 따라야 한다.
함수형 프로그래밍 커뮤니티가 산업 현장에서의 OOP 성공을 단지 역사적 사고의 산물로 치부하지 말고, OOP가 잘하는 것이 무엇인지 이해하려는 태도를 가지면 좋겠다.
이 글 초안을 검토해 준 Chris Penner와 Matthías Páll Gissurarson에게 감사한다.