첫 Java 프로젝트의 생생한 후기
이번에 처음으로 Java 8 + Spring Boot + JPA 를 이용하여 프로젝트를 진행 하였는데, 그 때 느꼈던 점을 공유 하고자 합니다.
첫 자바 프로젝트 시작과 기대
저는 이전 회사에서는 Java 를 사용했었지만, Spring Framework 은 사용 하지 않았습니다. 주로 책을 보며 개념을 익혔고, 사외에서 스터디 등을 통해서 관련지식을 쌓아오고 있었습니다.
그래서 이번 프로젝트를 많은 기대를 하면서 시작 하게되었습니다.
이번 프로젝트에서는 주요 비지니스로직에는 Unit Test 코드를 꼭 작성하고자 했고, TDD 를 통한 개발이 아닌 TDD 를 지향하여 개발을 하였습니다. (사실 완전한 TDD 로 개발을 진행하기엔 스스로 내공이 부족하다고 느끼고 있습니다.)
결론부터 말씀 드리자면, JPA가 익숙하지 않았고, 객체지향프로그래밍이 익숙하지 않았으며 테스트코드 작성하는 것도 서툴렀습니다. 그래서 개발기간도 예상했던 것 보다 더 많이 걸렸지만 이 프로젝트를 통해서 경험한 것은 엄청나게 많은 것 같습니다.
몸으로 느낀걸 공유하기 위해 기록으로 남기게 되었습니다. 문자로 모든걸 전달하는데는 한계가 있을 것 같지만 그래도 천천히 읽어 보시면 감사하겠습니다.
클래스 나누는 것에 두려워 하지 말자.
Legacy 시스템은 MS-SQL 에 Stored Procedure 를 사용한 절차지향프로그래밍을 해왔다고 볼수 있겠습니다. View + Stored Procedure 의 구성으로 2~3개의 소스파일로 특정 기능을 개발하다보니 Java 프로젝트를 하면서 Class 를 잘게 나누는데에 낯설었던것 같습니다.
프로젝트 초기에는 Controller, Service, Repository, Entity 를 중심으로 Layer 로 나누어 개발을 시작 하였습니다. (기능별로 Layer 당 class 하나씩 생성했다고 보시면 될듯합니다.) 개발이 진행되면서 클래스의 덩치가 커지고 있었는데 여러 클래스로 나누는 것이 불편할 것 같다는 무의식적인 벽이 존재 했었던 것 같습니다. (현재 팀내에서도 스터디하고 있는) “소프트웨어 개발의 지혜“ 책에서 보았던 것을 떠올려 보면 다음과 같은 원칙을 알수 있습니다.
SRP (Single Responsibility Principle): 단일 책임의 원칙, 클래스는 단 한가지의 변경 이유만을 가져야 한다.
처음에는 상품구매라는 추상적인 책임을 가지고 접근을 했었습니다. 그러다보니 구매하기위한 유효성체크의 책임 결제처리의 책임 기타등등 여러 책임들이 추가되면서 Service Class 는 엄청나게 비대 해졌습니다. (물론 Unit Test 코드도 엄청나게 많아졌지만 이는 다음 단락에서 설명하도록 하겠습니다.)
QA 단계에서 버그를 수정하거나 리팩토링을 진행할 때, 비대해진 (많은 책임을 가지고 있는) 클래스를 수정하기는 쉬운일이 아니였습니다. 프로젝트가 진행되면 될수록 작은 책임단위로 클래스를 나눠야 한다는것을 깨닫게 되었습니다. 만약 처음 프로젝트를 진행하신다면 두려워 하지말고 클래스로 과감하게 나누길 바랍니다. (그게 후에 정신건강에 좋습니다.)
혹시, 너무 많은 클래스가 있어서 전체적인 흐름을 파악하기에 어렵다고 생각하시나요? 다음 인용구를 보시죠.
출처 « 로버트 C.마틴 - Clean Code 177page »
작은 클래스가 많은 시스템이든 큰 클래스가 몇 개뿐인 시스템이든 돌아가는 부품은 그 수가 비슷하다.
(중략)
“도구 상자를 어떻게 관리하고 싶은가? 작은 서랍을 많이 두고 기능과 이름을 명확한 컴포넌트를 나눠 넣고 싶은가? 아니면 큰 서랍 몇 개를 두고 모두를 던져 넣고 싶은가?”
클래스의 덩치가 커진다는 것은 해당 객체의 책임도 많아 진다는 뜻입니다. 따라서 단 한가지 이유만으로 클래스를 수정 할 수 있어야 하며, 여러가지 이유로 수정할 이슈가 생긴다면 그건 설계가 잘못된 것이라고 합니다.
Mocking 해야 할 Class가 많아지면, 설계가 잘못된 것!
저는 TDD 를 지향하는 개발을 하고 있었기 때문에 대부분의 중요 클래스에 Unit Test 를 작성하고 있었습니다.
Mockito를 이용하여 다른 객체간의 관계를 Mocking 하면서 개발을 했었는데 프로젝트 초기에는 하나의 클래스를 테스트하기 위해 10개 가량의 Mock 객체가 필요했습니다. 이건 Mocking 해야 할 객체간의 Collaboration 들이 엄청나게 많다는 뜻이였죠. 이게 무슨 문제일까요?
글로 적는것 보다 실제 코드를 보여주는게 좋을 것 같아 (부끄럽지만) 제가 작성한 실제 코드를 보시면 좋을 것 같습니다.
@Test
public void testAvailableRegionList() {
// Given
Long biddingMonthlyInfoId = 21L;
Long biddingItemId = 9987L;
String shopNumber = "419285";
String shopOwnerNumber = "201406180002";
String categoryId = "1";
SuperListSpotItem superListSpotItem1 = createSpotItemMock(1L, false, "11", "117170", "11710601", SECOND, 1_000, biddingItemId);
SuperListSpotItem superListSpotItem2 = createSpotItemMock(2L, false, "11", "117170", "11710601", FIRST, 1_000, biddingItemId);
SuperListSpotItem superListSpotItem3 = createSpotItemMock(3L, false, "11", "117170", "11710601", THIRD, 1_000, biddingItemId);
when(superListSpotItemRepository.findSelectableSpotItem(
biddingMonthlyInfoId, shopOwnerNumber, shopNumber, categoryId))
.thenReturn(Arrays.asList(superListSpotItem1, superListSpotItem2, superListSpotItem3));
when(commonCodeRepository.findSubRegionName(isA(String.class))).thenReturn("석촌동");
when(superListBuyingPolicyChecker.checkAlreadyAdvertisingAtSameShopAndSameRegion(isA(String.class), isA(String.class), isA(SuperListSpotItem.class)))
.thenReturn(true);
when(myShopDateUtils.getAdServiceDayOfMonthFromNow()).thenReturn(10);
when(shopRepositoryUsingNativeQuery.isIncludeWithinDeliveryCircle(eq(shopNumber), isA(RegionId.class)))
.thenReturn(true);
// When
List<SelectableSpotItemDto> selectableSpotItems
= spotItemFetchService.findSelectableSpotItemsByShopNumber(biddingMonthlyInfoId,
shopOwnerNumber, shopNumber, categoryId);
// Then
assertThat("구매가능한 즉시구매 지역리스트 조회 테스트", selectableSpotItems.size(), equalTo(1));
assertThat("구매가능한 즉시구매 지역명 조합 테스트",
selectableSpotItems.get(0).getSubRegionName(), equalTo("석촌동"));
assertThat("구매가능한 즉시구매 상위 1건만 조회되는지 테스트",
selectableSpotItems.get(0).getViewOrderKind(), equalTo(FIRST));
assertThat("구매가능한 즉시구매 상위 1건만 조회되는지 테스트",
selectableSpotItems.get(0).getSpotItemId(), equalTo(2L));
assertThat(selectableSpotItems.get(0).getSellingPrice(), equalTo(10_000));
}
그냥 보기에도 엄청 많은 객체들과의 관계가 존재하여서 Mock 객체들이 엄청 많이 존재했었습니다. 실제 테스트코드를 보면 다른 사람이 파악하기도 어려운 테스트 코드를 작성하고 있습니다.
처음 작성할 때는 코드의 대부분이 제 머릿속에 로딩이 되어 있었기 때문에 개발을 원활이 진행 할 수 있었습니다. 하지만 몇일이 지나고 이 코드를 머릿속으로 로딩하는 시간이 아주 많이 걸렸습니다.
왜 그럴까요? 위에서도 설명했듯이 한 클래스에 너무 많은 책임을 가지고 있기 때문입니다. 제가 읽었던 책에서 몇가지 문구를 인용해 보도록 하겠습니다.
출처 « 신정호, 박상오, 이규일, 전우균, 조건희 - TDD에 대한 오해와 진실 TDD 이야기 50 page »
“ 자신의 테스트 코드에 지나치게 많은 Mock객체가 필요하다면 테스트 코드 작성 이전에 리팩토링의 냄새가 있는지 확인해봐야 한다. “
지금 심하게 많은 Mock 객체가 이용되고 Mocking 하는 역할(Method)도 엄청나게 많습니다. 리팩토링 냄새가 엄청나게 나는 코드라고 볼 수 있습니다. 프로젝트를 진행하면서 대부분의 클래스가 이런식으로 작성이 되어서 다른 분이 본다면 파악하기 무척 어려울 것 같습니다. 다행히도 프로젝트 막바지에는 많이 나누려는 시도를 했고, 그 중 한 코드를 소개해 드립니다.
public class SpotItemOrderSeqCreatorTest {
@InjectMocks
private SpotItemOrderSeqCreator spotItemOrderSeqCreator;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
}
@Test
public void getDefaultOrder() {
// Given
BiddingItem biddingItem = mock(BiddingItem.class);
List<SuperListSpotItem> threeSuperListSpotItems = getThreeSuperListSpotItems();
System.out.println("threeSuperListSpotItems = " + threeSuperListSpotItems);
when(biddingItem.getSuperListSpotItems()).thenReturn(threeSuperListSpotItems);
// When
int firstOrderSeq = spotItemOrderSeqCreator.getOrderSeq(biddingItem, SuperListItemViewOrderKind.FIRST);
int secondOrderSeq = spotItemOrderSeqCreator.getOrderSeq(biddingItem, SuperListItemViewOrderKind.SECOND);
int thirdOrderSeq = spotItemOrderSeqCreator.getOrderSeq(biddingItem, SuperListItemViewOrderKind.THIRD);
// Then
assertThat(firstOrderSeq, equalTo(1));
assertThat(secondOrderSeq, equalTo(2));
assertThat(thirdOrderSeq, equalTo(3));
}
}
어떤가요? 딱 원하는 책임을 가지고 테스트하고 있다는 것이 느껴지시나요? Unit Test 를 잘 작성한다는 것은 객체별로 역할을 잘 나누었다는 것이고, 그에따라 설계도 잘되었다고 볼 수 있을 것입니다.
자, 처음에도 말했지만 핵심음 두려워 말고 클래스를 나눠라! 입니다. 클래스를 나누는 것 부터 시작이며 후에 설계는 어떻게 잘 할건지에 대해 고민하면 좋을 것 같습니다.
그럼 어떤 상황에서 클래스를 나눠야 할까요? 항상 그런건 아니겠지만 다음과 같은 상황이 아닐까요?
출처 « 신정호, 박상오, 이규일, 전우균, 조건희 - TDD에 대한 오해와 진실 TDD 이야기 77, 78page »
private 메서드가 너무 비대하거나 복잡하다면, 클래스 하나가 책임을 너무 많이 지고 있는 건 아닌지 고민해봐야 한다.
private 메소드에 대한 테스트 고민이 설계상의 경고가 아닌지 의심해 봐야한다.
객체지향적으로 잘 된 설계를 하는건 봐야할 책도 많고 알아야 할 지식도 많은 것 같습니다. 하지만, 시작단계에서는 클래스를 잘 나누는게 중요 한것 같습니다.
초록막대를 보기 힘들다면, 모두 지우고 다시 시작해라
이 내용은 Test First 로 작성할 때 해당되는 내용입니다.
프로젝트 초기에 테스트의 단위를 너무 크게 잡아서 빠른 피드백(흔히 테스트를 성공하는 것에대해 초록막대 본다고 합니다.)을 받지 못해서 흐름이 깨지는 경우가 생겼었습니다.
너무 일을 크게 잡고 시작했을 수도 있고 컨디션이 좋지 않았을 수도 있습니다. 그리고 구현 하려는 클래스의 역할이 너무 많아서 일지도 모르죠. 그럴 때는 과감하게 처음부터 시작하세요.
위 내용은 켄트백의 TDD by Example 에 나왔던 내용입니다. 그 책을 읽고 나서 프로젝트를 진행 했음에도 불구하고 나는 “원칙”을 까마득하게 잊어버리고 있었던 것이였죠.
초록막대를 보기위해 엄청 고생을 하고 Twitter 에다가 징징거렸었는데, 아샬 님이 해답을 일깨워 주었습니다. 한 곳만 보고 달려가다 보니 뒤돌아 보지 못하고 점점 진흙탕에 빠질 때도 있을지 모릅니다.
리팩토링 리팩토링 리팩토링
리팩토링은 항상 해야한다고 머릿속으로는 알고 있었지만, 이번 프로젝트에서는 리팩토링을 거의 진행하지 못한것 같습니다. “취소하기”라는 기능을 구현하는데 5일의 개발기간을 가져갔다면. 5일 동안 구현을 끝내고 정상적으로 동작한다면 개발을 끝냈던 것 같습니다.
프로젝트가 끝난 지금 생각해보면 4일은 구현을 하고 하루 정도는 코드에 금칠을 하는 시간을 가질수 있도록 플래닝을 했어야 하지 않을까 싶습니다.
물론 더 좋은 방법은 하나의 기능 구현을 위해 Test Case 를 작성하고, 구현하고, 코드를 리팩토링 하는 사이클을 가져가는게 제일 좋은 방법이 것 같은데 아직 익숙하지 않다는 게 문제죠.
*Extream Programing 실천방법에서는 테스트, 코드, 리팩터링 을 꾸준한 리듬으로 가져가길 권한다. *
그래서 요구사항 구현을 끝냈다고 개발이 끝난게 아니라는 걸 말하고 싶습니다. 즉, 개발공수를 잡을때 리팩터링을 위한 버퍼를 어느정도 가지고 가는건 어떨까요? 아니 강력하게 추천합니다.
저는 프로젝트 기간 동안은 리팩터링을 거의 못하고 끝났지만 리팩터링을 하고 싶은 마음은 굴뚝 같습니다. 시간나는 대로 조금씩 리팩터링을 진행해야 하지 않을까 생각하고 있습니다.
마무리 하며
저는 TDD 스터디, 객체지향스터디, Spring 스터디 등을 해왔지만 실제 프로젝트 단계에서는 이론으로 공부했던 대부분의 원칙들이 떠오르지 않았습니다. 그래서 처음 Java 프로젝트를 진행하면서 겪었던 경험들을 꼭 공유해주고 싶었습니다.
책으로 읽히는 이론적인 면도 중요하지만, 무엇보다도 직접 경험 해보는게 가장 중요하다고 생각합니다. 실제 프로젝트 도중에 책을 읽으면서 무릎을 탁! 쳤던 경험도 한두번이 아니더라구요.
지금 여러분이 Java 를 처음 시작하는 단계에서 적어도 이 한가지만 기억하시면 될 것 같습니다. 두려워 하지 말고 클래스를 나누세요.
프로젝트 QA를 끝내고 최철우 수석님이 해주신 말이 기억에 남아서 공유 드립니다. (정확한 워딩인지는.. 기억이 가물가물..)
“설계에 대한 고민은 항상 하기 마련이예요. 좋은 설계를 위해 꾸준히 고민하고 리팩터링 한다는게 좋은 것 같아요.”