최근 프로젝트를 수정하면서 테스트가 가능한 코드에 대해 고민했다. 갑자기 고민한 것은 아니고 내가 작성한 코드가 테스트하기 좋지않다는 것을 느꼈기 때문이다.
프로젝트에 jsoup라이브러리를 사용하여 웹 사이트를 크롤링하는 기능이 있었다.
Jsoup 라이브러리를 사용하려면 Jsoup
클래스의 connect
스태틱 메서드를 사용해서 jsoup Connection을 연결했어야했다. 이를 아래와 같이 작성했다.
// JobSearchServiceImpl.java
...
String url = "크롤링할 URL";
Connection connection = Jsoup.connect(url);
Document documenet = connection.get();
...
Document
는 url에서 가져온 html 문서가 저장되는 객체이다. 이 코드는 @Service
빈에 해당하는 객체에 작성되었으며 Service
를 테스트하기 위해서는 라이브러리 역시 테스트해야했다.
Jsoup
은 내가 정의한 객체가 아니기때문에 매 번 실제로 크롤링해오는 API에 테스트의 성공여부를 맡길 순 없었다. 테스트를 할 때마다 실제로 크롤링해와야 하므로 테스트 성능 저하도 발생했다.
복잡한 로직을 메서드로 추출하려다보니 private 메서드가 많이 생겼다.
public List<JobCrawlerDto> getSearchList(Map<String, String> searchOption, String crawlerName) throws IOException {
...
method1();
method2();
method3();
...
}
private String method1(){
}
private String method2(){
}
private List<JobCrawler> method3(){
method4();
}
private void method4();
실제 코드는 아니며, 이런 식으로 여러 개의 private 메서드
가 객체를 이루고 있었다는 것이다. private
접근제어자는 클래스 외부에서는 접근할 수 없다. 테스트 파일 역시 클래스 외부이다. 정상적인 방법으로 private 메서드를 테스트할 수 없었다.
내가 작성한 JobSearchService 인터페이스는 다음과 같은 구현체를 가지고 있었다.
public interface JobSearchService {
List<JobCrawlerDto> getSearchList();
}
👉 사람인
@Service
public class SaraminCrawler implements JobSearchService {
@Override
List<JobCrawlerDto> getSearchList() {J}
private String method1(){A}
private String method2(){B}
private String method3(){C}
}
👉 인크루트
@Service
public class IncruitCrawler implements JobSearchService {
@Override
ist<JobCrawlerDto> getSearchList() {K}
private String method1(){D}
private String method2(){E}
private String method3(){F}
}
👉 잡코리아
@Service
public class JobKoreaCrawler implements JobSearchService {
@Override
List<JobCrawlerDto> getSearchList() {L}
private String method1(){G}
private String method2(){H}
private String method3(){I}
}
메서드 내를 알파벳으로 표현한 것은 모두 다른 코드형식이라는 의미
세 개의 서비스 구현체는 똑같은 메서드 시그니처를 가졌지만 그 안에 로직은 달랐다. 나름 전략패턴으로 생각하여 하나의 인터페이스에 서비스 컴포넌트를 세 개 두었다. 여기까진 그럴 수 있다고 생각한다. 하지만 오버라이딩 하는 메서드도 아닌데 모두 동일한 시그니처의 메서드? 뭔가 중복된 것 같고 좋은 코드가 아니라는 생각이 들었다.
실제로 Jsoup 라이브러리를 사용하지 않고 테스트하기 위해 Jsoup 객체를 Mocking할 필요가 있었다. 올바른 Mocking을 위해 Jsoup 라이브러리를 인터페이스로 감싸서 의존성을 주입해주었다.
public interface JsoupConnection {
Document get(String url) throws IOException;
}
@Component
public class JsoupConnectionImpl implements JsoupConnection {
private Connection connect(String url) {
return Jsoup.connect(url);
}
@Override
public Document get(String url) throws IOException {
Connection connect = connect(url);
return connect.get();
}
}
private 메서드를 테스트하는 것은 쉬운 일이 아니었다. public 메서드를 테스트하면서 거기에 포함된 private 메서드
를 간접적으로 테스트하는 것이 최선이었다. private method에 별 다른 추가 기능이 없는 순수 자바코드였다면 아마 이 방법으로도 잘 해결되었을 것이다. 하지만 내 코드는 Jsoup 라이브러리에서 제공하는 객체와 메서드가 난무했고, 메서드의 결과로 html문서가 넘어오는 경우도 있었다. 테스트하자고 html 코드를 넣어 테스트하는 것은 그리 좋은 방법같진 않았다.
또한, reflection
을 사용하면 접근제어자와 관계없이 메서드에 접근할 수 있지만 캡슐화를 저해하는 명백한 안티패턴이라 생각했다.
어떻게 하면 중복된 private
메서드들을 테스트하기 좋은 코드로 리팩터링하면 좋을까 고민했다. 내가 내린 결론은 '책임을 나누자' 였다.
기존 코드에서 JobSearchServiceImpl 객체는 서비스의 역할과 크롤링까지 해야하는 막중한 책임을 떠안았다. 그러다보니 본인 본연의 행위도 아닌 private
메서드가 난무했고, 그 결과 테스트할 수가 없었다.
따라서 JobSearchServiceImpl
로부터 크롤러를 떼어낼 필요가 있었다.
크롤러 기능을 서비스에서 떼어내자 private
으로 난무하던 메서드들은 당당하게 한 객체의 기능으로서 public
의 역할을 해내게되었다.
public interface Crawler {
String getBaseUrl();
String makeSearchListUrl(Map<String, String> searchOption);
boolean checkSearchOption(Map<String, String> searchOption);
boolean checkValidSortOption(String recruitSort);
List<JobCrawlerDto> parseHTML(Document document);
List<JobCrawlerDto> extracted(Elements itemRecruit, int resultCount);
}
@Component
public class SaraminCrawler implements Crawler {
final private String baseUrl = "www.saramin.co.kr";
@Override
public String getBaseUrl() {
return baseUrl;
}
@Override
public boolean checkSearchOption(Map<String, String> searchOption) {
return searchOption.get("searchWord") == null || searchOption.get("recruitPage") == null || searchOption.get("recruitSort") == null;
}
@Override
public boolean checkValidSortOption(String recruitSort){
SaraminRecruitSort[] saraminRecruitSorts = SaraminRecruitSort.values();
return Arrays.stream(saraminRecruitSorts).noneMatch(sort -> sort.getResultSort().equals(recruitSort));
}
@Override
public String makeSearchListUrl(Map<String, String> searchOption){
if(searchOption.isEmpty()) return "empty";
return "https://" + baseUrl +"/zf_user/search/recruit?search_done=y&search_optional_item=n&company_cd=0%2C1%2C2%2C3%2C4%2C5%2C6%2C7%2C9%2C10&show_applied=&quick_apply=&except_read=&ai_head_hunting=&mainSearch=n&loc_mcd=101000&inner_com_type=&recruitPageCount=20"
+ "&searchword=" +searchOption.get("searchWord")
+ "&recruitPage=" + searchOption.get("recruitPage")
+ "&recruitSort=" + searchOption.get("recruitSort");
}
@Override
public List<JobCrawlerDto> parseHTML(Document document) {
Elements itemRecruit = document.select(".item_recruit");
try {
int resultCount = Integer.parseInt(document.select(".cnt_result").text().replaceAll("[^0-9]",""));
return extracted(itemRecruit, resultCount);
}catch (NumberFormatException ne){
throw new CrawlerException(CrawlerErrorResult.RESULT_NOT_FOUND);
}
}
@Override
public List<JobCrawlerDto> extracted(Elements itemRecruit, int resultCount) {
// 크롤링 로직이 너무 길어서 생략합니다.
}
}
위의 코드가 그 동안 꽁꽁 감춰왔던 private 골칫덩이들이다. 딱 봐도 테스트하기 어렵게 생겼지 않는가..
이렇게 서비스 객체에서 크롤러를 분리하니 서비스 객체는 단 하나만 존재할 수 있었으며 코드 또한 매우 간단해졌다.
@Service
public class JobSearchServiceImpl implements JobSearchService {
private final JsoupConnection jsoupConnection;
private final Map<String,Crawler> crawlerMap;
@Autowired
public JobSearchServiceImpl(JsoupConnection jsoupConnection, Map<String, Crawler> crawlerMap) {
this.jsoupConnection = jsoupConnection;
this.crawlerMap = crawlerMap;
}
@Override
public List<JobCrawlerDto> getSearchList(Map<String, String> searchOption, String crawlerName) throws IOException {
Crawler crawler = crawlerMap.get(crawlerName);
if (crawler.checkSearchOption(searchOption)){
throw new CrawlerException(CrawlerErrorResult.OPTION_NULL_EXCEPTION);
}
final String recruitSort = searchOption.get("recruitSort");
if (crawler.checkValidSortOption(recruitSort)){
throw new CrawlerException(CrawlerErrorResult.OPTION_BAD_REQUEST);
}
String searchList = crawler.makeSearchListUrl(searchOption);
try {
Document document = jsoupConnection.get(searchList);
return crawler.parseHTML(document);
} catch (IOException e) {
throw new IOException("JsoupConnect Error");
}
}
}
이번 경험을 통해 테스트하기 좋은 코드에 대해 많이 고민했다.
고민 끝에 도출한 좋은 코드는
SOLID 원칙을 지키려고 노력하는 코드이다.
SOLID 원칙에 완전 적합한 코드가 무엇인 지 모르기때문에 노력이라는 키워드를 사용했다.
이 코드는 SOLID 원칙을 지키면서 리팩터링한 것은 아니다. 테스트하기 좋은 코드로 리팩터링하고 보니 SOLID 원칙에 근접했다.
서비스 객체의 책임을 분할하고 (SRP),
크롤러를 계속 추가할 수 있게 확장에 열려있으며 (OCP),
하위 타입(다양한 구직사이트 크롤러)은 언제나 상위 타입(Crawler)과 교체할 수 있고 (LSP),
서비스 인터페이스를 크롤러와 분할하고 (ISP),
구현체(Jsoup
)가 아닌 인터페이스(JsoupConnection
)에 의존하는 (DIP)
그런 코드가 되어 있었다.
이번 경험을 통해 SOLID원칙이 어떻게 빛을 내는 지 알게되었다.
매 순간 SOLID를 생각하며 개발하긴 어렵지만 좋은 객체지향 코드, 테스트하기 좋은 코드를 작성하기위해 원칙을 되새기며 작성해야겠다.