✏️[모던 자바 인 액션, 전문가를 위한 자바 8, 9, 10 기법 가이드] 스터디 관련 책 내용을 정리한 글입니다.
📌 이 장의 내용
- 도메인 전용 언어(domain-specific languages, DSL)란 무엇이며 어떤 형식으로 구성되는가?
- DSL을 API에 추가할 때의 장단점
- JVM에서 활용할 수 있는 자바 기반 DSL을 깔끔하게 만드는 대안
- 최신 자바 인터페이스와 클래스에 적용된 DSL에서 배움
- 효과적인 자바 기반 DSL을 구현하는 패턴과 기법
- 이들 패턴을 자바 라이브러리와 도구에서 얼마나 흔히 사용하는가?
10.1 도메인 전용 언어
DSL은 특정 비즈니스 도메인의 문제를 해결하기 위해 만든 언어로, 자바에서는 클래스와 메서드를 이용하여 도메인을 표현합니다. DSL은 해당 도메인을 인터페이스로 만든 API와 같은 역할을 합니다.
DSL은 특정 도메인에 국한된 용어와 동작을 갖는 범용 프로그래밍 언어가 아닙니다. DSL은 해당 도메인의 문제 해결에만 집중하며, 이를 통해 사용자가 해당 도메인의 복잡성을 더 잘 다룰 수 있도록 합니다. 이러한 특징을 통해 사용자 친화적인 DSL을 만들 수 있습니다.
DSL은 평문 영어가 아닌 특정 도메인에 국한된 용어와 동작을 갖는 언어입니다. DSL은 프로그래머가 아닌 사람도 이해할 수 있는 코드를 작성하고, 가독성을 유지하여 유지보수를 쉽게 할 수 있도록 합니다. 이러한 특징으로 인해 DSL은 의사소통의 왕이며, 코드를 한 번 구현하지만 여러 번 읽게 될 때 가독성이 유지되도록 만들어야 합니다.
10.1.1 DSL의 장점과 단점
DSL은 다음과 같은 장점을 제공합니다.
- 간결함 : 비즈니스 로직을 캡슐화하여 코드를 간결하게 만들 수 있습니다.
- 가독성 : 도메인 용어를 사용해서 비 도메인 전문가도 쉽게 이해할 수 있습니다.
- 유지보수 : 잘 설계된 DSL로 구현한 코드는 쉽게 유지보수하고 변경할 수 있습니다.
- 높은 수준의 추상화 : DSL은 도메인과 같은 추상화 수준에서 동작하므로 세부 사항을 숨길 수 있습니다.
- 집중 : 비즈니스 도메인의 규칙을 표현할 목적으로 설계되어 프로그래머가 특정 코드에 집중할 수 있고, 생산성이 향상됩니다.
- 관심사분리 : 지정된 언어로 비즈니스 로직을 표현함으로 애플리케이션의 인프라 구조와 관련된 문제와 독립적으로 비즈니스 관련된 코드에서 집중하기가 용이하며, 유지보수가 쉬운 코드를 구현할 수 있습니다.
반면, DSL로 인해 다음과 같은 단점도 발생합니다.
- DSL 설계의 어려움 : 간결하게 제한적인 언어에 도메인 지식을 담는 것이 쉬운 작업은 아닙니다.
- 개발 비용 : DSL을 추가하는 작업은 초기에 많은 비용과 시간이 소요되며, 또한 DSL의 유지보수와 변경은 프로젝트에 부담을 줍니다.
- 추가 우회 계층 : DSL은 도메인 모델을 감싸기 위해 추가적인 계층을 만들어야 합니다. 이때 가능한 한 계층을 작게 유지하여 성능 문제를 회피합니다.
- 새로 배워야 하는 언어 : DSL을 추가하면서 새로 배워야 하는 언어가 생기며, 여러 비즈니스 도메인을 다루는 개별 DSL을 사용하는 경우 이들을 유기적으로 동작하도록 합치는 일은 쉽지 않습니다. 이는 개별 DSL이 독립적으로 진화할 수 있기 때문입니다.
- 호스팅 언어 한계 : 일부 자바 같은 범용 프로그래밍 언어는 사용자 친화적인 DSL을 만들기 어렵습니다. 이러한 언어로 만든 DSL은 장황하고 엄격한 문법을 가지며, 성가신 문법의 제약을 받아 읽기가 어려워집니다. 하지만 자바 8의 람다 표현식은 이러한 문제를 해결할 수 있는 강력한 새 도구입니다.
10.1.2 JVM에서 이용할 수 있는 다른 DSL 해결책
DSL의 카테고리를 구분하는 가장 흔한 방법은 내부 DSL과 외부 DSL로 나누는 것입니다. 내부 DSL은 기존 호스팅 언어를 기반으로 구현하며, 외부 DSL은 자체적인 문법을 갖는 독립적인 언어입니다.
JVM으로 인해 내부 DSL과 외부 DSL의 중간에 해당하는 DSL인 다중 DSL이 생겨날 수 있습니다. 다중 DSL은 자바가 아니지만 JVM에서 실행되며, 스칼라나 그루비와 같이 더 유연하고 표현력이 강력한 언어들을 의미합니다.
1. 내부 DSL
자바는 예전에는 읽기 쉽고 간단하며 표현력 있는 DSL을 만드는데 한계가 있었지만, 람다 표현식이 등장하면서 이 문제가 어느 정도 해결되었습니다. 람다를 적극적으로 활용하면 익명 내부 클래스를 사용하는 것보다 간결한 DSL을 만들 수 있으며, 이를 통해 신호 대비 잡음 비율을 적정 수준으로 유지할 수 있습니다.
다음은 자바 7 문법으로 문자열을 출력하고, 자바 8의 새 forEach 메서드를 이용하는 예제로 신호 대비 잡음 비율이 무엇을 의미하는지 확인하는 코드입니다.
List<String> numbers = Arrays.asList("one", "two", "three");
numbers.forEach(new Consumer<String>() {
@Override
public void accept(String s) {
System.out.println(s);
}
});
위 코드 예제에서 다음처럼 익명 내부 클래스를 람다 표현식으로 바꿀 수 있습니다.
numbers.forEach(s -> System.out.println(s));
// 메서드 참조로 더 간단하게 할 수 있다.
numbers.forEach(System.out::println);
자바로 DSL을 구현하면 다음과 같은 장점을 얻을 수 있습니다.
- 기존 자바 언어를 이용하면 새로운 패턴과 기술을 배워 DSL을 구현하는 노력이 줄어듭니다.
- 순수 자바로 DSL을 구현하면 컴파일할 때 다른 언어의 컴파일러를 사용할 필요가 없으므로 추가 비용이 들지 않습니다.
- 새로운 언어나 외부 도구를 배울 필요 없이 자바를 이용해 DSL을 구현할 수 있습니다.
- 자바 IDE를 사용해 자동 완성, 자동 리팩터링 등의 기능을 사용할 수 있습니다.
- 자바로 구현한 DSL은 추가 DSL을 쉽게 합칠 수 있습니다.
DSL 합침 문제를 해결하는 방법으로, 같은 자바 바이트코드를 사용하는 JVM 기반 프로그래밍 언어를 이용하는 것이 있습니다. 이러한 언어는 다중 DSL이라고 불립니다.
2. 다중 DSL
현재 JVM에서 실행되는 언어는 100개가 넘으며, 스칼라, 루비, JRuby, Jython, 코틀린, 실론 등이 유명한 언어 중 하나입니다. 이들은 자바보다 젊으며 제약을 줄이고, 간편한 문법을 지향하도록 설계되었습니다. DSL은 기반 프로그래밍 언어의 영향을 받으므로, 새로운 언어의 특성들이 간결한 DSL을 만드는 데 아주 중요합니다. 특히, 스칼라는 커링, 임의 변환 등 DSL 개발에 필요한 여러 특성을 갖췄습니다.
3. 외부 DSL
외부 DSL을 구현하는 것은 새로운 문법과 구문을 갖는 언어를 설계하고 파싱, 분석, 실행 코드를 만드는 큰 작업입니다. 이 방법은 일반적인 작업이 아니며, 기술적인 어려움과 제어 범위를 벗어나는 문제가 발생할 수 있습니다. ANTLR 같은 자바 기반 파서 생성기를 사용하면 도움이 될 수 있습니다.
외부 DSL 개발의 큰 장점은 무한한 유연성으로, 필요한 특성을 완벽하게 제공하는 언어를 설계할 수 있다는 것입니다. 하지만 외부 DSL로 구현한 비즈니스 코드와 자바로 개발된 인프라구조 코드를 분리하는 것은 인공 계층을 만들어내어 양날의 검이 될 수 있습니다.
10.2 최신 자바 API의 작은 DSL
Java 8에서는 람다와 메서드 참조 등의 새로운 기능을 활용해 네이티브 자바 API도 DSL을 개발할 수 있게 되었습니다. Comparator 인터페이스에 새 메서드가 추가되어 람다 표현식이 재사용 가능한 Comparator 객체를 생성하는 데 사용될 수 있으며, 불필요한 무명 내부 클래스를 사용하지 않아도 됩니다. 이를 통해 네이티브 자바 API의 재사용성과 메서드 결합도를 높일 수 있습니다. 인터페이스가 정적 메서드와 디폴트 메서드를 가질 수 있다는 사실도 배우게 됩니다.
사람을 가리키는 객체 목록을 가지고 있고, 사람의 나이를 기준으로 객체를 정렬해야하는 상황에서 람다가 없으면 내부 클래스로 Comparator 인터페이스를 구현해야 합니다.
Collections.sort(persons, new Comparator<Person>() {
public int compare(Person p1, Person p2) {
return p1.getAge() - p2.getAge();
}
});
// 내부 클래스를 람다 표현식으로 바꿀 수 있습니다.
Collections.sort(people, (p1, p2) -> p1.getAge() - p2.getAge());
람다 표현식으로 Comparator 인터페이스를 구현하는 것이 좋지만, 자바에서는 Comparator 객체를 더 가독성 있게 구현하기 위해 Comparator 인터페이스에 정적 유틸리티 메서드를 제공합니다. 이러한 정적 메서드는 Comparator.comparing 메서드와 같이 인터페이스에 포함되어 있으며, 위의 예제를 더 간결하게 구현할 수 있도록 도와줍니다.
Collections.sort(persons, comparing(p -> p.getAge());
// 람다를 메서드 참조로 대신해 코드를 개선
Collections.sort(persons, comparing(Person::getAge));
// reverse 메서드를 이용해서 나이 역순으로 정렬
Collections.sort(persons, comparing(Person::getAge).revers());
// 이름으로 비교를 수행하는 Comparator를 구현해 같은 나이의 사람들을 알파벳 순으로 정렬
Collections.sort(persons, comparing(Person::GetAge)
.thenComparing(Person::getName));
// List 인터페이스에 추가된 새 sort 메서드를 이용해 코드를 깔끔하게 정리
persons.sort(comparing(Person::getAge)
.thenComparing(Person::getName));
위 예제는 컬렉션 정렬 도메인에 국한된 작은 API로, 람다와 메서드 참조를 이용한 DSL을 활용하여 코드의 가독성, 재사용성, 결합성을 높일 수 있다는 것을 보여줍니다. 이는 DSL을 만들 때, 자바에서 제공하는 기능을 최대한 활용하여 더 간결하고 효율적인 코드를 작성할 수 있음을 보여줍니다.
10.2.1 스트림 API는 컬렉션을 조작하는 DSL
Stream 인터페이스는 작은 내부 DSL을 적용한 좋은 예시입니다. Stream은 컬렉션의 항목을 필터, 정렬, 변환, 그룹화, 조작을 수행하는 강력한 DSL로 볼 수 있습니다. 이를 활용해 로그 파일에서 "ERROR"로 시작하는 첫 40행을 추출하는 작업도 수행할 수 있습니다.
List<String> errors = new ArrayList<>();
int errorCount = 0;
BufferedReader bufferedReader = new BufferedReader(new FIleReader(fileName));
String line = bufferedReader.readLine();
while (errorCount < 40 && line != null) {
if (line.startsWith("ERROR")) {
errors.add(line);
errorCount++;
}
line = bufferedReader.readLine();
}
코드에 에러 처리를 생략하더라도, 코드가 장황하고 의도를 파악하기 어려울 때 가독성과 유지보수성이 떨어집니다. 문제가 잘 분리되지 않아서 관련 코드가 여러 행에 걸쳐 분산되어 있습니다.
예를 들어, FileReader 생성, 파일 종료 확인, 파일의 다음 행 읽기와 같은 코드가 분산되어 있습니다. 또한, 첫 40행을 수집하는 코드도 초기화, while 루프 조건, "Error" 발견 시 카운터 증가 등 세 부분으로 나뉘어 있어 코드의 구조를 이해하기 어렵습니다.
Stream 인터페이스를 이용해 함수형으로 코드를 구현하면 더 쉽고 간결하게 구현할 수 있습니다.
List<String> errors = Files.lines(Paths.get(fileName)) // 파일을 열어서 문자열 스트림을 만듦
.filter(line -> line.startsWith("ERROR")) // "ERROR"로 시작하는 행 필터링
.limit(40) // 결과를 첫 40행으로 제한
.collect(toList()); // 결과 문자열을 리스트로 수집
스트림 API는 잘 설계된 DSL의 특징을 갖고 있으며, 플루언트 형식을 사용합니다. 중간 연산들은 게으른 방식으로 작동하며, 파이프라인에 포함될 수 있는 스트림을 반환합니다. 반면, 최종 연산은 적극적으로 동작하여 전체 파이프라인의 계산을 실행합니다.
10.2.2 데이터를 수집하는 DSL인 Collectors
Collector 인터페이스는 데이터 수집을 수행하는 DSL로 간주할 수 있습니다. DSL 관점에서, 이러한 메서드들은 특별한 방식으로 설계되었습니다. 예를 들어, Comparator 인터페이스는 다중 필드 정렬을 지원하기 위해 합쳐질 수 있고, Collectors는 다중 수준 그룹화를 달성하기 위해 합쳐질 수 있습니다. 이렇게 해서 DSL이 유연하고 확장 가능한 방식으로 작동하게 됩니다.
10.3 자바로 DSL을 만드는 패턴과 기법
DSL은 특정 도메인 모델에 적용할 친화적이고 가독성 높은 API를 제공합니다.
10.3.1 메서드 체인
메서드 체인은 객체 지향 프로그래밍에서 사용되는 기술로, 여러 메서드 호출을 하나의 연속된 작업으로 연결하는 것을 의미합니다. 메서드 체인을 하면 더 간결하게 작성할 수 있으며, 가독성이 높아집니다.
메서드 체인은 메서드가 호출된 객체를 반환함으로써 가능해지는데, 각 메서드 호출 후 반환된 객체의 다른 메서드로 바로 호출할 수 있기 때문에, 메서드 호출을 순차적으로 연결할 수 있습니다. 이렇게 연결된 메서드들을 체인이라고 하며, 마침내 최종 결과를 얻을 수 있습니다.
그러나 메서드 체인이 복잡해질수록 디버깅이 어려워지고 메모리 사용량이 높아지며, 오히려 가독성이 떨어질 수 있습니다.
10.3.2 중첩된 함수 이용
중첩된 함수 DSL 패턴은 이름에서 알 수 있듯이 다른 함수 안에 함수를 이용해 도메인 모델을 만듭니다.
다음 예제는 이 접근 방법을 적용한 DSL의 구현 코드입니다.
Order order = order("BigBank", buy(80,
stock("IBM", on("NYSE")), at(125.00)),
sell(50,
stock("GOOGLE", on("NASDAQ")), at(375.00))
);
다음 예제는 사용자에게 API를 제공할 수 있음을 보여주는 DSL 구현 코드입니다.
public class NestedFunctionOrderBuilder {
public static Order order(String customer, Trade... trades) {
Order order = new Order(); // 해당 고객의 주문 만들기
order.setCustomer(customer);
Stream.of(trades).forEach(order::addTrade); // 주문에 모든 거래 추가
return order;
}
public static Trade buy(int quantity, Stock stock, double price) {
return buildTrade(quantity, stock, price, Trade.Type.BUY); // 주식 매수 거래 만들기
}
public static Trade sell(int quantity, Stock stock, double price) {
return buildTrade(quantity, stock, price, Trade.Type.SELL); // 주식 매도 거래 만들기
}
private static Trade buildTrade(int quantity, Stock stock, double price, Trade.Type buy) {
Trade trade = new Trade();
trade.setQuantity(quantity);
trade.setType(buy);
trade.setStock(stock);
trade.setPrice(price);
return trade;
}
private static double at(double price) { // 거된 주식의 단가를 정의하는 더미 메서드
return price;
}
public static Stock stock(String symbol, String market) {
Stock stock = new Stock(); // 거래된 주식 만들기
stock.setSymbol(symbol);
stock.setMarket(market);
return stock;
}
public static String on(String market) { // 주식이 거래된 시장을 정의하는 더미 메서드 정의
return market;
}
}
메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영된다는 점이 장점입니다. 하지만 결과 DSL에 더 많은 괄호를 사용해야 한다는 단점이 있습니다.
10.3.3 람다 표현식을 이용한 함수 시퀀싱
다음 DSL 패턴은 람다 표현식을 사용한 함수 시퀀스를 통해 도메인 모델을 생성하는 빌더를 구현해야 합니다. 이 빌더들은 메서드 체인 패턴을 사용하여 객체의 중간 상태를 유지하고, 최상위 수준의 빌더를 포함합니다. 이 패턴은 객체를 빌더가 인수로 받아, DSL 사용자가 람다 표현식으로 인수를 구현할 수 있게 합니다.
이 패턴은 이전 두 가지 DSL 형식의 장점을 결합합니다. 메서드 체인 패턴과 같이 플루언트 방식으로 정의할 수 있으며, 중첩 함수 형식처럼 도메인 객체의 계층 구조를 유지하는 데 다양한 람다 표현식의 중첩 수준을 사용합니다.
이 패턴의 단점은 많은 설정 코드가 필요한 경우, 자바 8 람다 표현식 문법에 의한 잡음인 DSL에 영향을 줄 수 있습니다.
10.3.4 조합하기
위에 서본 메서드 체인, 중첩된 함수 이용, 람다 표현식을 이용한 함수 시퀀싱 세 가지 DSL 패턴 각자가 장단점을 갖고 있습니다. 하지만 한 DSL에 한 개의 패턴만 사용할 필요는 없습니다.
세 가지 DSL 패턴을 혼용해 가독성 있는 DSL을 만들어서 여러 패턴의 장점을 이용할 수 있지만 결점도 있습니다. 결과 DSL이 여러 가지 기법을 혼용하고 있으므로 한 가지 기법을 적용한 DSL에 비해 사용자가 DSL을 배우는데 시간이 오래 걸립니다.
10.4 실생활의 자바 8 DSL
10.3절에서는 자바로 DSL을 개발하는데 사용할 유용한 패턴을 살펴봤고 각각의 장단점을 확인했습니다. 다음 그림에서는 지금 가지 배운 내용을 요약합니다.
세 가지 유명한 자바 라이브러리에서 사용되는 패턴을 살펴보았습니다. SQL 매핑 도구, 동작 주도 개발 프레임워크, 엔터프라이즈 통합 패턴 구현 도구를 구현하는 도구 세 가지 자바 라이브러리를 확인합니다.
10.4.1 JOOQ
JOOQ는 자바 내부적 DSL로 SQL을 구현하며 형식 안전을 제공합니다. 데이터베이스 스키마 역공학을 통해 소스코드 생성기를 사용하고, 자바 컴파일러가 SQL 구문의 형식을 확인할 수 있습니다. 이를 통해 데이터베이스 스키마 탐색이 가능해집니다.
// SQL 이용
SELECT * FROM BOOK
WHERE BOOK.PUBLISHED_IN = 2016
ORDER BY BOOK.TITLE
// JOOQ DSL 이용
create.selectFrom(BOOK)
.where(BOOK.PUBLISHED_IN.eq(2106))
.orderBy(BOOK.TITLE)
스트림 API와 조합해 사용할 수 있다는 것이 JOOQ DSL의 또 다른 장점입니다.
Class.forName("org.h2.Driver");
try (Connection c =
getConnection("jdbc:h2:~/sql-goodies-with-mapping", "sa", "")) {
DSL.using(c)
.select(BOOK.AUTHOR, BOOK.TITLE)
.where(BOOK.PUBLISHED_IN.eq(2016))
.orderBy(BOOK.TITLE)
.fetch()
.stream()
.collect(groupingBy(
r -> r.getValue(BOOK.AUTHOR),
LinkedHashMap::new,
mapping(r -> r.getValue(BOOK.TITLE), toList())))
forEach((author, titles) ->
System.out.println(author + " is author if " + titles));
}
JOOQ DSL은 메서드 체인 패턴을 사용하여 잘 만들어진 SQL 질의 문법을 구현합니다. 이 패턴은 선택적 파라미터 허용과 미리 정해진 순서로 특정 메서드 호출을 가능하게 함으로써 필수적입니다.
10.4.2 큐컴버
BDD는 테스트 주도 개발의 확장으로, 비즈니스 시나리오를 구조적으로 서술하며 도메인 전문가와 프로그래머 간의 간격을 줄입니다. 큐컴버는 BDD 프레임워크로 평문 영어로 시나리오를 구현할 수 있게 돕습니다.
큐컴버는 BDD 프레임워크 중 하나로, 명령문을 전체 조건 정의(Given), 시험하려는 도메인 객체의 실질 호출(When), 테스트 케이스의 결과를 확인하는 어설션(Then)으로 분류하여 테스트 케이스를 작성합니다.
큐컴버의 DSL은 아주 간단하지만 외부적 DSL과 내부적 DSL이 어떻게 효과적으로 합쳐질 수 있으며 람다와 함께 가독성 있는 함축된 코드를 구현할 수 있는지를 잘 보여줍니다.
10.4.3 스프링 통합
스프링 통합은 엔터프라이즈 통합 패턴을 지원하며, 복잡한 통합 솔루션 구현을 단순화하고 비동기 및 메시지 주도 아키텍처를 적용하는 데 도움을 줍니다. 이는 경량 원격, 메시징, 스케쥴링을 지원하며, 유창한 DSL을 통해 설정을 제공합니다. 스프링 통합은 메시지 기반 애플리케이션에 필요한 공통 패턴을 구현하고, 가독성을 높이기 위해 엔드포인트를 동사로 구현하며, 메시지 흐름을 통한 통합 과정을 구성합니다.
'📚 서적 및 학습자료 > 모던 자바 인 액션[2회독]' 카테고리의 다른 글
[모던 자바 인 액션] 12장 새로운 날짜와 시간 API (0) | 2023.03.26 |
---|---|
[모던 자바 인 액션] 11장 null 대신 Optional 클래스 (0) | 2023.03.25 |
[모던 자바 인 액션] 9장 리팩터링, 테스팅, 디버깅 (0) | 2023.03.21 |
[모던 자바 인 액션] 8장 컬렉션 API 개선 (0) | 2023.03.18 |
[모던 자바 인 액션] 7장 병렬 데이터 처리와 성능 (0) | 2023.03.18 |