국내에서는 자바 웹 개발시 Spring Framework의 인기가 독보적이고 상용 환경에서도 많이 사용합니다. 반면에 구글 주스는 인기나 상용적용 여부면에서 (제가 알기론) 매우 적습니다.
오픈 소스중에 구글 주스를 적용한 케이스(예: Elasticsearch, Ratpack)가 간혹 있어 오픈소스 분석시 필요하여 구글 주스 소개 페이지를 보고 번역하였습니다.
필요하신 분들께서는 참고 부탁 드립니다. 각 문단의 헤딩은 번역하는것보다 원문으로 학습 및 용어를 익히시는게 더 나을듯 하여 유지하였습니다.
Motivation
어플리케이션을 개발하며 코드를 한군데다 몰아 짜는 것은 짜증나는 일이다. 데이터, 서비스 그리고 presentation 클래스를 각각 작성하여 연결하는 다양한 방법이 존재한다.
이러한 접근방법과 달리, 여기서는 이런 방법을 고려치 않고 피자 주문 웹사이트의 결제 코드를 작성해본다.
public interface BillingService {
/**
* Attempts to charge the order to the credit card. Both successful and
* failed transactions will be recorded.
*
* @return a receipt of the transaction. If the charge was successful, the
* receipt will be successful. Otherwise, the receipt will contain a
* decline note describing why the charge failed.
*/
Receipt chargeOrder(PizzaOrder order, CreditCard creditCard);
}
구현과 더불어, 우리는 이 코드를 위한 유닛 테스트를 작성해보자.
테스트에서는 실제 결제가 일어나는 것을 방지하기 위해 FakeCreditCardProcessor가 필요하다.
Direct constructor calls
메서드 내에서 단순히 credit card processor와 transaction logger를 new로 생성한다. 코드가 아래와 같다.
(역자 주 : CreditCardProcessor와 TransactionLog 인터페이스에 paypal 카드결제 구현체와 Database 방식 트랜젝션 로그 구현체를 생성했다.)
public class RealBillingService implements BillingService {
public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
CreditCardProcessor processor = new PaypalCreditCardProcessor();
TransactionLog transactionLog = new DatabaseTransactionLog();
try {
ChargeResult result = processor.charge(creditCard, order.getAmount());
transactionLog.logChargeResult(result);
return result.wasSuccessful()
? Receipt.forSuccessfulCharge(order.getAmount())
: Receipt.forDeclinedCharge(result.getDeclineMessage());
} catch (UnreachableException e) {
transactionLog.logConnectException(e);
return Receipt.forSystemFailure(e.getMessage());
}
}
}
이 코드는 모듈화 측면과 테스트 용이성 측면에서 문제을 안고 있다.
컴파일 타임까지 이어지는 creditCardProcessor에 대한 직접적인 의존성은 즉 유닛 테스트를 실행하더라도 신용카드 결제가 일어난다는 뜻이다.
또한 결제승인 여부나 결제서비스 장애여부에 따라 테스트 성공 여부가 의존적이게 되는 이상한 점도 존재한다.
Factories
팩토리 클래스는 client와 구현체 클래스의 커플링을 해소한다. 이 단순한 팩토리 클래스는는 static 메소드를 통해 mock 구현체를 생성하여 인터페이스 형태로 리턴한다. 팩토리는 아래와 같이 구현된다.
(역자 주 : 기본적으로 setInstance()를 통해 구현체를 주입받는데 instance 변수가 null 이면 SquareCreditCardProcessor를 인스턴스화 한다.)
public class CreditCardProcessorFactory {
private static CreditCardProcessor instance;
public static void setInstance(CreditCardProcessor processor) {
instance = processor;
}
public static CreditCardProcessor getInstance() {
if (instance == null) {
return new SquareCreditCardProcessor();
}
return instance;
}
}
client 코드에서 new를 통한 인스턴스 생성부분을 팩토리를 사용한 코드로 교체한다.
public class RealBillingService implements BillingService {
public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
CreditCardProcessor processor = CreditCardProcessorFactory.getInstance();
TransactionLog transactionLog = TransactionLogFactory.getInstance();
try {
ChargeResult result = processor.charge(creditCard, order.getAmount());
transactionLog.logChargeResult(result);
return result.wasSuccessful()
? Receipt.forSuccessfulCharge(order.getAmount())
: Receipt.forDeclinedCharge(result.getDeclineMessage());
} catch (UnreachableException e) {
transactionLog.logConnectException(e);
return Receipt.forSystemFailure(e.getMessage());
}
}
}
팩토리를 사용하여 적절한 유닛 테스트를 작성할수 있는게 가능해졌다.
public class RealBillingServiceTest extends TestCase {
private final PizzaOrder order = new PizzaOrder(100);
private final CreditCard creditCard = new CreditCard("1234", 11, 2010);
private final InMemoryTransactionLog transactionLog = new InMemoryTransactionLog();
private final FakeCreditCardProcessor processor = new FakeCreditCardProcessor();
@Override public void setUp() {
TransactionLogFactory.setInstance(transactionLog);
CreditCardProcessorFactory.setInstance(processor);
}
@Override public void tearDown() {
TransactionLogFactory.setInstance(null);
CreditCardProcessorFactory.setInstance(null);
}
public void testSuccessfulCharge() {
RealBillingService billingService = new RealBillingService();
Receipt receipt = billingService.chargeOrder(order, creditCard);
assertTrue(receipt.hasSuccessfulCharge());
assertEquals(100, receipt.getAmountOfCharge());
assertEquals(creditCard, processor.getCardOfOnlyCharge());
assertEquals(100, processor.getAmountOfOnlyCharge());
assertTrue(transactionLog.wasSuccessLogged());
}
}
하지만 이 코드는 어설프다. 전역변수가 mock 구현체를 들고 있어서 setUp과 tearDown에 신경써야 한다.
만약 tearDown이 실패하게 되면 전역변수는 테스트 인스턴스를 계속 가리킨다. 이는 다른 유닛 테스트에 문제를 일으킬수 있다. 또한, 이 테스트 케이스 구조로는 테스트를 병렬로 돌릴수 없다.
가장 큰 문제점은 의존성이 코드에 숨어있다는 것이다. 만약 CreditCardFraudTracker에 의존성을 추가하게 되면, 어떤 테스트가 실패될지 TC를 다시 돌려봐야 한다. 만약 우리가 상용 서비스를 위한 factory 초기화를 까먹었다면, 실제 결제가 일어나기 전까지는 알수 없다.
어플리케이션이 커짐에 따라, 팩토리들을 유지보수하는것은 점차 생산성을 떨어뜨리게 된다.
품질 문제는 QA나 acceptance 테스트에서 잡힐것이다. 이부분은 어떤 조직에는 충분할수도 있지만 엔지니어들은 더 잘할수 있는 여지가 많다.
Dependency Injection
팩토리와 같이 dependency injection(이하 글에서 DI)는 디자인 패턴이다. 핵심 원칙은 의존성 이슈로 부터 행동(behaviour)를 분리시키는 것이다.
예를 들어, RealBillingService는 TransactionLog와 CreditCardProcessor를 찾을 책임을 지닐 이유가 없다. 대신에, 생성자로부터 전달 받는다.
public class RealBillingService implements BillingService {
private final CreditCardProcessor processor;
private final TransactionLog transactionLog;
public RealBillingService(CreditCardProcessor processor,
TransactionLog transactionLog) {
this.processor = processor;
this.transactionLog = transactionLog;
}
public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
try {
ChargeResult result = processor.charge(creditCard, order.getAmount());
transactionLog.logChargeResult(result);
return result.wasSuccessful()
? Receipt.forSuccessfulCharge(order.getAmount())
: Receipt.forDeclinedCharge(result.getDeclineMessage());
} catch (UnreachableException e) {
transactionLog.logConnectException(e);
return Receipt.forSystemFailure(e.getMessage());
}
}
}
우리는 어떤 팩토리 클래스도 이코드에서 필요없고 TC도 setUp과 tearDown을 없애는 것으로 단순화 시킬수 있다.
public class RealBillingServiceTest extends TestCase {
private final PizzaOrder order = new PizzaOrder(100);
private final CreditCard creditCard = new CreditCard("1234", 11, 2010);
private final InMemoryTransactionLog transactionLog = new InMemoryTransactionLog();
private final FakeCreditCardProcessor processor = new FakeCreditCardProcessor();
public void testSuccessfulCharge() {
RealBillingService billingService
= new RealBillingService(processor, transactionLog);
Receipt receipt = billingService.chargeOrder(order, creditCard);
assertTrue(receipt.hasSuccessfulCharge());
assertEquals(100, receipt.getAmountOfCharge());
assertEquals(creditCard, processor.getCardOfOnlyCharge());
assertEquals(100, processor.getAmountOfOnlyCharge());
assertTrue(transactionLog.wasSuccessLogged());
}
}
이제, 언제든 의존성을 추가/삭제 할때마다 컴파일러는 TC가 수정되어야 되는지 알려준다. 왜냐하면, 의존성이 API 명세로 노출되었기 때문이다.
불행히도, BillingService의 클라이언트들은 의존성을 찾을 필요가 있다. 우리는 이부분을 디자인패턴을 다시 적용하여 해결 할 수 있다.
의존성있는 클래스들은 BillingService의 생성자에서 수용한다.
top-level 클래스들은 프레임워크를 이용하는게 유용하다. 그게 아니라면 서비스를 사용할 때 의존성을 recursive하게 생성할 필요가 있다.
public static void main(String[] args) {
CreditCardProcessor processor = new PaypalCreditCardProcessor();
TransactionLog transactionLog = new DatabaseTransactionLog();
BillingService billingService
= new RealBillingService(processor, transactionLog);
...
}
Dependency Injection with Guice
DI패턴(dependency injection, 의존성주입)은 코드를 더 모듈화하고 테스트용이하게 만드는데 구글 주스가 이런 코드 작성을 손쉽게 한다.
구글 주스를 결제 예제 코드에 적용하면, 우리는 어떤 인터페이스들을 어떤 구현체로 맵핑할지를 명시해야 한다.
이 설정은 구글 주스에서 Module 인터페이스를 통해서 완성된다.
public class BillingModule extends AbstractModule {
@Override
protected void configure() {
bind(TransactionLog.class).to(DatabaseTransactionLog.class);
bind(CreditCardProcessor.class).to(PaypalCreditCardProcessor.class);
bind(BillingService.class).to(RealBillingService.class);
}
}
우리는 RealBillingService 생성자에 @Inject 어노테이션을 명시하여 구글 주스에게 알린다.
구글 주스는 어노테이션이 달린 생성자를 검사하여 각 파라미터의 값들을 조사한다.
public class RealBillingService implements BillingService {
private final CreditCardProcessor processor;
private final TransactionLog transactionLog;
@Inject
public RealBillingService(CreditCardProcessor processor,
TransactionLog transactionLog) {
this.processor = processor;
this.transactionLog = transactionLog;
}
public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
try {
ChargeResult result = processor.charge(creditCard, order.getAmount());
transactionLog.logChargeResult(result);
return result.wasSuccessful()
? Receipt.forSuccessfulCharge(order.getAmount())
: Receipt.forDeclinedCharge(result.getDeclineMessage());
} catch (UnreachableException e) {
transactionLog.logConnectException(e);
return Receipt.forSystemFailure(e.getMessage());
}
}
}
끝으로, Injector는 연결된 어떤 클래스든 인스턴스를 획득할 수 있다.