주요글: 도커 시작하기
반응형
TDD. 테스트 주도 개발에 대한 책들이 많이 있고, 그 책들은 이론에서 실제 예제까지 테스트 주도 개발에 대한 다양한 지식을 제공해 주고 있다. 하지만, 여전히 많은 개발자들은 단위 테스트를 만들지 않으며, 단위 테스트에 대한 이해가 깊은 개발자도 충분하지 않은 것 같다. 그래서 이 글을 쓰게 되었다. 물론, 필자가 TDD의 진수를 제대로 느꼈다고 자신있게 말할 수 있을만큼 TDD를 실천한 것은 아니지만, TDD를 중간 중간 섞어서 개발한 경험과 최근에 작성한 코드를 중심으로 TDD 경험을 공유해보도록 하겠다.

TDD를 왜 하지?

필자 개인적으로 TDD를 하는 이유는 다음 두 가지 정도로 압축해 볼 수 있을 것 같다.
  • 쓸데 없는 코드 만들지 않기
  • 테스트를 자동화해서 테스트 시간 줄이기
    • 코드 수정 후, 회귀(regression) 테스트 용으로 사용
TDD를 하지 않아도 필요한 코드만 만들 수 있지만, TDD를 하다보면 자연스럽게 필요한 기능을 구현하는데 필요한 코드만 만들게 된다. 뒤에서 ATDD를 해 보겠지만, ATDD를 하면 이 경향이 더 강해진다.

구현할 기능에 대한 테스트를 먼저 만들고 시작하기 때문에, 수시로 기능 테스트를 실행할 수 있다. 이는 기능 추가나 변경에 의해 코드를 수정해야 할 때, 테스트를 보다 빠르게 할 수 있도록 도와준다. 이 얘기는 테스트를 만들어 놓지 않을 때와 비교해서 테스트 시간이 줄어준다는 것을 의미한다.

개발할 기능은?

개발할 기능은 팀 별 주간 업무 요약을 한 페이지로 보여주는 기능이다. 전주에 입력한 각 팀의 주간 보고를 한 페이지로 출력하고, 이를 주간 회의 때 사용할 수 있도록 하는 게 목표다. 참고로, 기존에 주간 업무를 관리하는 시스템이 있었고, 이곳에 추가 기능을 넣는 작업이다.

Acceptance Test로 시작하기

ATDD(Acceptance TDD)는 테스트로부터 시작해서 구현을 마무리 짓는 TDD의 범위를 코드 수준에서 기능 테스트 수준까지 확장한 것이다. 필자 개인적인 생각으로 클래스 수준의 TDD로 시작해도 좋지만, ATDD를 최대한 많이 할 때, TDD의 힘이 배가된다고 믿고 있다.

Acceptance Test를 위한 도구는 다양하게 있는데, 예를 들어, Fit, FitNess, Selenium 등이 있다. 하지만, 이것들은 코드를 만들어가는 과정에서 밀접하게 사용하기에는 부족함을 많이 느낀다. 사실 Selenium과 JUnit을 엮어서 시도해 보고 싶은 마음도 있었지만, 그걸 파고 있을 시간이 다소 부족해 빠르게 진행할 수 있는 기술을 선턱했다.

ATDD를 위해 필요한 것/선택한 것

새로 추가할 기능은 웹 관련 기능인데, 웹 기반 시스템에서 ATDD를 한다는 것은 다음을 의미한다.
  • 테스트를 시작하면 웹 서버를 구동한다.
  • 가상의 웹 브라우저를 이용해서 웹 서버에 연결한다.
  • 응답 결과를 분석해서 결과가 맞는 지 확인한다.
  • 테스트가 끝나면 웹 서버를 종료한다.
하나의 테스트 케이스를 실행할 때 마다 위와 같은 과정을 반복해야 한다. 이를 위해서 필요한 건 다음과 같은 것들이다.
  • 테스트 코드에서 개발중인 웹 어플리케이션을 제공할 웹 서버를 구동할 수 있어야 한다.
  • 웹 브라우저의 역할을 코드로 만들어야 한다.
  • 웹 서버의 응답 결과를 확인할 수 있어야 한다.
  • 자동화된 Acceptance테스트 시 사용될 DB
위 작업을 수행하기 위해 필자가 선택한 방법은 다음의 세 가지이다.
  • Jetty를 임베딩해서 웹 어플리케이션을 제공할 웹 서버 실행
  • HtmlUnit을 이용해서 웹 서버와 통신
  • DbUnit을 이용한 데이터 초기화 (테스트 픽스처 구성을 위해 사용)
Acceptance 테스트 코드 만들기

자, 일단 먼저 할 일은 무작정 테스트 코드부터 만들어보는 것이다. Acceptance 테스트 코드는 기능을 테스트 하기 때문에, 자세한 구현은 나오지 않는다. 단지, 시스템 외부에서 봤을 때 입력을 주고 원하는 결과가 나오는지 확인하는 방식으로 코드를 작성하면 된다.

웹 어플리케이션의 기능을 ATDD로 만들 클래스는 다음의 두 가지이다.
  • AllTeamsSummaryReportEndToEndTest
    JUnit 테스트 케이스. 테스트 픽스처를 구성하고, 제티 서버를 시작/종료한다.
  • WebFunctionTestDriver
    실제 웹 기능에 대한 테스트를 수행하고 결과를 검증한다.
AllTeamsSummaryReportEndToEndTest 만들기

먼저 AllTeamsSummaryReportEndToEndTest는 JUnit 테스트 케이스로, 끝에서 끝까지(웹 브라우저 - DB) 테스트할 수 있는 환경을 만들고, 그 다음 WebFunctionTestDriver를 이용해서 실제 기능 테스트를 실행하고 결과를 검증한다. 실제 AllTeamsSummaryReportEndToEndTest 클래스의 코드는 다음과 같다.

public class AllTeamsSummaryReportEndToEndTest {

    @BeforeClass
    public static void init() throws Exception {
        // 기본 데이터 삽입(본부, 팀, 직원-본부장,팀장)
        SeedDataLoader.loadIntegrationTestDataSeed();
        
        // 테스트를 위한 각 팀별 요약 보고서 삽입
        WeekSummaryDataHelper.cleanAndInsertTeamSummaryData();

        JettyServer.start();
    }

    @AfterClass
    public static void close() throws Exception {
        JettyServer.stop();
    }

    @Test
    public void queryAllTeamsWeekSummaryReport() throws Throwable {
        WebFunctionTestDriver driver = new WebFunctionTestDriver();
        driver.loginAndAssertSeccess(DataConstant.LEADER1_ID, DataConstant.PASS);
        driver.visitWeekSummaryReportPageAndAssertReport();
    }

}


AllTeamsSummaryReportEndToEndTest 클래스의 코드가 매우 낯설게 느껴지는 개발자가 많을 것이다. 왜냐면, 대부분의 테스트 케이스는 테스트 클래스에서 검증을 수행하는데 반해 위 JUnit 테스트 클래스는 검증 코드를 포함하고 있지 않기 때문이다. AllTeamsSummaryReportEndToEndTest 클래스는 init() 메서드를 이용해서 테스트 픽스처를 구성하고, 실제 기능 테스트는 WebFunctionTestDriver 객체에 위임한다.

위 코드에서 픽스처 구성에 사용된 세 개의 클래스는 다음의 역할을 수행한다.

  • SeedDataLoader: XML 파일에 초기 데이터를 설정하고, DbUnit에서 XML을 읽어와 테이블 데이터를 초기화한다.
  • WeekSummaryDataHelper: 주간 업무 요약 보고 기능을 테스트 하기 위한 데이터를 초기화한다. 날짜 별로 데이터가 변경되어야 하기 때문에 XML 파일을 사용하지 않고 별도로 구현하였다.
  • JettyServer: Jetty WAS를 임베딩으로 실행시켜 주는 기능을 제공한다.
WebFunctionTestDriver 만들기

WebFunctionTestDriver 클래스를 이용한 테스트 클래스는 아래와 같다.

    @Test
    public void queryAllTeamsWeekSummaryReport() throws Throwable {
        WebFunctionTestDriver driver = new WebFunctionTestDriver();
        driver.loginAndAssertSuccess(DataConstant.LEADER1_ID, DataConstant.PASS);
        driver.visitWeekSummaryReportPageAndAssertReport();
    }

테스트 할 기능을 알 수 있도록 WebFunctionTestDriver 클래스의 메서드 이름을 작성하였다. 위 코드의 경우 다음의 기능을 테스트 한다.
  • loginAndAssertSuccess:
    지정한 계정으로 로그인 시도하고, 성공했는지 검증한다.
  • visitWeekSummaryReportPageAndAssertReport:
    주 요약 리포트 페이지로 이동하고, 응답 리포트가 올바른지 검증한다.
새로 추가한 기능이 visitWeekSummaryReportPageAndAssertReport() 이므로, visitWeekSummaryReportPageAndAssertReport() 메서드를 살펴보자. 먼저, 기능 테스트를 다음과 같이 진행하게 될 것이다.
  1. 로그인을 한다. (loginAndAssertSuccess로 처리)
  2. 주 요약 페이지로 이동한다. (이후 visitWeekSummaryReportPageAndAssertReport에서 처리)
  3. 응답 결과가 올바른지 확인한다.
  4. 검색 폼의 날짜를 변경해서 서브밋한다.
  5. 응답 결과가 올바른지 확인한다.
이 테스트를 만들기 위해 HtmlUnit을 사용하였고, 실제로 작성한 코드는 다음과 같다.

    public void visitWeekSummaryReportPageAndAssertReport() throws Exception {
        // 주 요약 보고 페이지 이동
        HtmlPage page = (HtmlPage) webClient.getPage(CONTEXT_URL
                + "/report/query/allteamssummary");
        String data = page.asText();

        // 응답 데이터 검증
        String expectedDateString = "2011.10.10 - 2011.10.14";
        Assert.assertTrue(data.contains(expectedDateString));
       
        Assert.assertTrue(data.contains("1본부"));
        Assert.assertTrue(data.contains("1팀"));

        Assert.assertTrue(data.contains("3본부"));
        Assert.assertTrue(data.contains("2팀"));
        Assert.assertTrue(data.contains("3팀"));
        Assert.assertTrue(data.contains("4팀"));
       
        // 폼에 날짜 값을 설정해서 서브밋
        HtmlForm form = page.getFormByName("searchForm");
        HtmlTextInput startDateField = (HtmlTextInput) form.getInputByName("startDate");
        startDateField.setValueAttribute("20111005");

        HtmlSubmitInput button = (HtmlSubmitInput) form.getInputByName("searchBtn");

        HtmlPage pageAfterSearch = (HtmlPage) button.click();

        // 서브밋 후 결과 응답 페이지 검증
        HtmlForm searchForm = pageAfterSearch.getFormByName("searchForm");
        startDateField = (HtmlTextInput) searchForm.getInputByName("startDate");
        data = pageAfterSearch.asText();
       
        Assert.assertTrue(data.contains("1팀 금주 예정 할일"));
        Assert.assertTrue(data.contains("1팀 금주 한일"));
        Assert.assertTrue(data.contains("1팀 차주 예정 할일"));
        Assert.assertTrue(data.contains("4팀 금주 한일"));
        Assert.assertTrue(data.contains("4팀 차주 예정 할일"));
    }

webClient는 HtmlUnit이 제공하는 WebClient 클래스의 객체로서, webClient.getPage(url) 메서드를 사용하면 해당 페이지에 대한 정보를 담고 있는 HtmlPage 객체를 리턴한다. 위 코드에서 일단 검증과 관련된 데이터는 상수로 안 빼고 넣었다. 향후에 리팩토링을 통해 데이터 처리 부분을 상수나 데이터 제공 객체로 뺄 것이다.

End-to-End 테스트 실패하기

테스트 코드를 만들었으니, 이제 AllTeamsSummaryReportEndToEndTest를 실행해 보자. 테스트 케이스를 실행하면, DB를 초기화하고 필요한 주 요약 보고 정보를 삽입하고, Jetty 서버를 실행한다. 그런 뒤, queryAllTeamsWeekSummaryReport() 메서드를 이용해서 테스트를 실행한다. 이미 기존에 존재하는 웹 어플리케이션에 대한 테스트기 때문에 스프링 설정이나 web.xml 파일 등의 작업은 할 필요 없이 바로 테스트를 실행할 수 있었다. (만약 최초의 Acceptance 테스트를 만드는 것이었다면, 프레임워크 설정, 아키텍처 구성 등 상당히 많은 양의 작업을 수행해야 비로서 테스트를 제대로 시작할 수 있게 된다.)

테스트를 돌려보면 빨간 막대가 출현하고, 테스트 에러 메시지를 보면 다음과 같은 내용을 확인할 수 있다.

com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException: 404 Not Found for http://localhost:9090/goodjob/report/query/allteamssummary
    at com.gargoylesoftware.htmlunit.WebClient.throwFailingHttpStatusCodeExceptionIfNecessary(
WebClient.java:540)
    at com.gargoylesoftware.htmlunit.WebClient.getPage(WebClient.java:360)
    at com.gargoylesoftware.htmlunit.WebClient.getPage(WebClient.java:407)
    at com.gargoylesoftware.htmlunit.WebClient.getPage(WebClient.java:395)
    at com....WebFunctionTestDriver.visitWeekSummaryReportPageAndAssertReport(
WebFunctionTestDriver.java:57)
    at com.....AllTeamsSummaryReportEndToEndTest.queryAllTeamsWeekSummaryReport(
AllTeamsSummaryReportEndToEndTest.java:39)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    ...

테스트가 실패한 이유는 /report/query/allteamssummary URL을 처리해 스프링 컨트롤러를 만들지 않았기 때문이다. (아~ 이 프로젝트는 스프링 2.5 기반이고, 스프링 컨트롤러를 이용해서 웹 요청을 처리하고 있다.) 즉, 테스트 실패는 우리에게 '이제 /report/query/allteamssummary URL을 처리할 컨트롤러를 만들어'라고 알려주고 있다.

웹 요청을 처리할 컨트롤러 만들기

/report/query/allteamssummary URL을 처리할 컨트롤러를 만들 차례이다. 여기서부터 다음과 같은 고민을 해 봐야 한다.
  • 각 레이어마다 단위 테스트를 만어서 나갈까? 아니면 통합 테스트를 만들까?
필자는 코드를 만들 때면 위와 같은 고민을 많이 하는데, 그 이유는 경우에 따라 속도를 내기 위해 TDD의 몇 단계를 건너 뛰어도 되기 때문이다. 이 경우에는 단위 테스트로 시작하는 것이 좋아 보인다. 그 이유는 단위 테스트를 해야 최종적으로 사용자가 요구한 기능을 구현하는데 필요한 것들을 더 빠르게 도출할 수 있기 때문이다. 예를 들어, 전 팀의 요약 보고서를 본부별로 묶어서 화면에 출력하고 싶다고 하자. 이 경우 딱 그 화면을 만들어내느데 필요한 만큼만 컨트롤러 및 컨트롤러와 연동되는 객체를 정의할 수 있을 만큼 테스트를 만들면 된다.

컨트롤러 테스트 만들기

컨트롤러에 대한 TDD를 해 보자. 컨트롤러 이름은 AllTeamSummaryReportController로 정했고, 이 클래스에 대한 테스트를 다음과 같이 작성하였다.

@RunWith(MockitoJUnit44Runner.class)
public class AllTeamSummaryReportControllerTest {

    private AllTeamSummaryReportController controller = new AllTeamSummaryReportController();

    @Mock
    private WeekSummaryReportService weekSummaryReportService;
    @Mock
    private AllTeamsWeekSummaryRequest request;
    @Mock
    private AllDivisionSummaryReport expectedResult;

    @Before
    public void init() {
        when(
                weekSummaryReportService
                        .getAllDivisionSummaryReportOfWeek(request))
                .thenReturn(expectedResult);

        controller.setWeekSummaryReportService(weekSummaryReportService);
    }

    @Test
    public void allTeamsSummary() {
        ModelMap model = new ModelMap();
        controller.allTeamsSummary(request, model);
        AllDivisionSummaryReport result = (AllDivisionSummaryReport) model
                .get("allDivisionSummaryReport");
        assertNotNull(result);
        verify(weekSummaryReportService, times(1))
                .getAllDivisionSummaryReportOfWeek(request);
        assertSame(expectedResult, result);
    }

}

Mock 텍스트를 위해 Mockito를 사용했고, 웹 요청을 처리하는 컨트롤러의 메서드를 allTeamsSummary() 메서드로 정하였다. 그리고, 컨트롤러 외에 다음의 세 가지 타입을 추가로 정의하였다.
  • AllTeamsWeekSummaryRequest: 요약 리포트 요청을 표현
  • AllDivisionSummaryReport: 요약 리포트 결과를 표현. 아직 상세 내용은 없다.
  • WeekSummaryReportService: 요약 리포트를 제공해주는 서비스 인터페이스.
위 세 가지는 컨트롤러 단위 테스트를 만드는데 있어 상세 구현이 필요하지 않기 때문에, 세 개 타입을 모두 Mock으로 대체하였다.

여기서 중요한 점은 위 코드를 만드는 과정에서 다음과 같은 일들이 벌어진다는 것이다.
  • 컨트롤러에 대한 이름, 필요한 메서드, 메서드에 전달할 파라미터를 결정한다.
  • 컨트롤러 객체에서 연동할 서비스의 타입과 메서드가 결정된다.
  • 컨트롤러가 뷰에 전달할 데이터가 결정된다.
이는 다시 말하면 설계하는 과정을 거친다는 뜻이다. 테스트로부터 기능을 구현하는데 필요한 최소한의 설계를 유도하고 (최소한!!이다), 코드를 채워나가게 된다. 물론, 한번에 위 내용이 결정되는 것은 아니며 컨트롤러 클래스의 코드를 구현하고 단위 테스트를 수정하는 과정을 반복적으로 거치면서 클래스 이름, 메서드, 파라미터에 대한 것들이 명확해진다. 실제로 위 테스트 코드도 한 번에 만들어진 것은 아니며, 점진적인 과정을 거쳐 만들어졌다.

테스트를 돌리고 코드를 수정하는 과정을 반복해서 컨트롤러 클래스를 다음과 같이 완성했다.

@Controller
public class AllTeamSummaryReportController {

    private WeekSummaryReportService weekSummaryReportService;

    /**
     * 전 팀의 요약 리포트 조회.
     *
     * @param model
     * @return
     */
    @SuppressWarnings("unchecked")
    @RequestMapping("/report/query/allteamssummary")
    public String allTeamsSummary(AllTeamsWeekSummaryRequest request,
            ModelMap model) {
        model.put("allDivisionSummaryReport", weekSummaryReportService
                .getAllDivisionSummaryReportOfWeek(request));
        return "report/query/team/allTeamsWeekSummary";
    }
    ...
}

서비스 만들기

컨트롤러를 만드는 과정에서 자연스럽게 서비스 타입과 서비스 객체와 주고 받을 데이터가 결정되었다. 이제 이것들을 구체화할 차례이다. 서비스를 만들 때 단위 테스트로 할 것인가 아니면 서비스와 관련된 것들을 모두 구현해서 통합 테스트로 할 것인가를 고민해 봐야 하는데, 지금은 기존에 리포지토리가 구현되어 있는 것들이 많아서 (즉, 기능 테스트를 통과한 것들이 많아서) 통합 테스트를 바로 만들기로 했다.

여기서 만들 통합 테스트는 스프링 컨테이너를 생성하고 스프링 컨테이너에서 서비스 객체를 가져와 기능을 실행하게 된다. 실제로 구현한 코드는 다음과 같다. 참고로, SpringDBIntegrationTestBase  클래스는 스프링 통합 테스트를 위해 미리 만들어 놓은 상위 클래스로서 스프링이 제공하는 TestRunner를 사용한다.

public class WeekSummaryReportServiceIntegrationTest extends
        SpringDBIntegrationTestBase {

    @Autowired
    private WeekSummaryReportService reportService;

    @Before
    public void initData() throws SQLException {
        WeekSummaryDataHelper.cleanAndInsertTeamSummaryData();
    }

    @Test
    public void getAllDivisionSummaryReportOfWeekWithoutStartDate() {
        // 시작일이 없으므로 오늘을 기준으로 요약 리포트 조회
        AllTeamsWeekSummaryRequest request = new AllTeamsWeekSummaryRequest();
        AllDivisionSummaryReport allDivisionSummaryReport = reportService
                .getAllDivisionSummaryReportOfWeek(request);

        assertEquals(2, allDivisionSummaryReport.getDivisionSummaryReports().size());
        assertEquals(WeekIdHelper.createThisWeekId(), allDivisionSummaryReport
                .getWeekInfo().getId());

        DivisionSummaryReport division1 = allDivisionSummaryReport
                .getDivisionSummaryReports().get(0);

        // 픽스처는 2주전, 1주전 데이터를 갖고 있으므로
        // 금주 기준으로는 1주전 데이터 존재
        TeamWeekSummaryReport teamWeekSummaryReport = division1
                .getTeamWeekSummaryReportList().get(0);
        assertNotNull(teamWeekSummaryReport.getThisWeekPlanSummary());
        assertNull(teamWeekSummaryReport.getThisWeekResultSummary());
        assertNull(teamWeekSummaryReport.getNextWeekPlanSummary());
    }

}

테스트를 만들었고, 그 테스트에서 통과해야 할 검증 코드를 만들었다. 이제 남은 일은 하나씩 채워나가는 것이다. 다시 한 번 말하지만, 위 코드가 한 번에 나온 것은 아니다. 실제 서비스 구현 코드와 테스트 코드를 점진적으로 수정해 나가면서 완성된 것이다. 최초의 코드는 실제로 다음과 같은 모습이었다.

public class WeekSummaryReportServiceIntegrationTest extends
        SpringDBIntegrationTestBase {

    @Autowired
    private WeekSummaryReportService reportService;

    @Before
    public void initData() throws SQLException {
        WeekSummaryDataHelper.cleanAndInsertTeamSummaryData();
    }

    @Test
    public void getAllDivisionSummaryReportOfWeekWithoutStartDate() {
        // 시작일이 없으므로 오늘을 기준으로 요약 리포트 조회
        AllTeamsWeekSummaryRequest request = new AllTeamsWeekSummaryRequest();
        AllDivisionSummaryReport allDivisionSummaryReport = reportService
                .getAllDivisionSummaryReportOfWeek(request);

        // 픽스처: 본부는 두 개, 1본부당 팀은 두개
        assertEquals(2, allDivisionSummaryReport.getDivisionSummaryReports().size());
        assertEquals(WeekIdHelper.createThisWeekId(), allDivisionSummaryReport
                .getWeekInfo().getId());

        // TODO
        fail ("본부에 속한 팀의 실제 요약 리포트 검증 필요");
    }

}

테스트의 가장 마지막에 fail()을 사용한 이유는 테스트가 완성되지 않았으므로 테스트를 실행하면 무조건 실패라고 나오도록 하기 위함이다. 만약 테스트 코드가 완전히 끝나지 않았는데 테스트 코드 실행 결과가 녹색으로 나온다면, 코드를 완성했는지 알고 더 이상 진행하지 않을 수도 있다. 이런 상황을 방지하기 위해 완성하지 못한 테스트는 무조건 실패하도록 작성하고 시작하였다.

Acceptance 테스트 다시 실행하기

컨트롤러와 서비스에 대한 코드를 모두 만들었다면, 이제 다시 Acceptance 테스트로 돌아올 차례이다. 최초로 코드 작성을 유도한 것이 Acceptance 테스트 임을 우리는 알고 있다. 이제 이 테스트를 통과시킴으로써 기능 구현의 끝을 볼 차례이다.

컨트롤러와 서비스 객체를 모두 스프링 설정 파일에 이미 추가했고, 기능 테스트를 돌리면 짠 하고 녹색이 날 반겨줄 것 같다. 자~ 다시 한번 AllTeamsSummaryReportEndToEndTest를 실행해 보았다.

웁스! 빨간 막대다. 콘솔에 출력된 로그를 보니 다음과 같은 문구가 눈에 띈다.

<body><h2>HTTP ERROR: 404</h2><pre>NOT_FOUND</pre>
<p>RequestURI=/goodjob/WEB-INF/view/report/query/team/allTeamsWeekSummary.jsp</p>

아! 뷰를 처리할 JSP를 만들지 않았다. 이제 남은 작업은 기능 테스트를 통과하도록 JSP를 만드는 것이다.

몇 번의 테스트 후, JSP까지 끝내고, 다시 AllTeamsSummaryReportEndToEndTest를 실행했다. 야호! 이제 녹색막대다. 드디어 기능 하나를 구현했다.

Jetty 임베딩이나 DbUnit 등에 대한 내용이 궁금한 분들이 분명히 있을텐데, 이미 본 글이 너무 길어진 관계로 별도 글로 작성해서 올릴 예정이다.

참고 서적

 
 



+ Recent posts