legacy code refactoring video rental system

101
Legacy Code Refactoring Workshop - Video Rental 오재훈 ([email protected])

Upload: jaehoon-oh

Post on 12-Aug-2015

639 views

Category:

Software


2 download

TRANSCRIPT

Legacy CodeRefactoring Workshop

- Video Rental오재훈 ([email protected])

목표

Refactoring(Martin Fowler) 1장의 비디오 대여 시스템을 리팩토링하면서 - 코드 스멜을 찾는 방법- 단위 테스트를 작성하는 방법- 레거시 코드를 리팩토링을 하는 방법- Eclipse 의 리팩토링 자동화를 사용하는 방법- 클린 코드를 작성하는 방법을 배운다.

Refactoring

Martin Fowler

프로젝트 준비1. Eclipse 를 실행하고 Java Project 를 생성한다. ( 프로젝트 이름을 VideoRental )

2. VideoRental 프로젝트에서 src 디렉토리를 선택한다.

3. 압축 파일을 풀고, chapter1 디렉토리를 VideoRental 의 src 디렉토리로 복사한다.

4. src/chapter1/Movie.java, Customer.java, Rental.java 클래스가 복사되었는지 확인한다.

Coverage 측정 도구 준비1. Eclipse 를 실행한다.

2. Help 메뉴를 선택하고, Eclipse Marketplace 서브메뉴를 선택한다.

3. Search 탭에서 Find 에 eclEmma 를 입력한 후 enter 키를 입력한다.

4. 검색 결과에 "eclEmma Java Code Coverage 2.3.2" 플러그인이 뜨면, 아래쪽에 있는 "Insall" 버튼

을 클릭해서 설치한다.

설치확인하기

1. Package Explorer 에서 Java Project 를 선택한다.

2. 오른쪽 버튼을 눌러서 "Run As" 메뉴 아래에 "Coverage As" 메뉴가 보이는지 확인한다.

Legacy Code 정의하기

Legacy Code 란?

과거에 Legacy Code 에 기능을 추가했던 기억들을 떠 올려 보고, 영향이 가장 컸던 버그를 포스트잇에 적는다. ( 2분 )

레거시 코드 기능추가시 발생했던 버그에 대한 경험을 공유한다. ( 10분 )- 레거시 코드에 기능을 추가하면서 했던 실수 중 가장 큰 실수는?

- 그 실수는 누가 발견했나요?- 그 실수는 언제 발견되었나요?- 그 실수를 수정하고 배포하는 데 비용이 얼마나 들었나요?

- 그 실수가 발견되었을 때, 여러분의 감정은 어떤 상태였나요?

Legacy Code 정의하기

“Code Without Test” ( Michael Feathers )

People are writing legacy code right now, maybe on your project.( http://www.objectmentor.com/resources/articles/WorkingEffectivelyWithLegacyCode.pdf )

Legacy Code: Characteristics● Poor Architecture● Non-Uniform Coding Styles● Poor or Non-Existing Documentation● Mythical Oral Documentation

레거시 코드에 기능 추가하기

추가 요구사항

- 대여정보를 출력할 때, 고객이 대여한 영화 수를 영화 종류별로 출력한다.- New Release : 1- Children : 0- Regular : 2

- 진행방식 : Pair Programming- 시간 : 20분

코드 리뷰

어떤 방식으로 기능을 추가했는가?

기능 추가 방법

Making change inline - 기존 코드에 새로운 코드 추가하는 방법- 단점

- 새로운 기능에 대한 자동화된 테스트가 없다.- 기존의 메소드가 더 길어진다.- 기존의 메소드가 하는 일이 더 많아진다.

기능 추가 방법

Delegate Method- 새로운 기능을 새로운 메소드로 추가한다.- 장점

- 현재 메소드가 더 길어지지 않는다.- 새로운 메소드를 테스트할 수 있다.

- 한계- 현재 메소드는 여전히 테스트 할 수 없다.- 이렇게 하기 어려울 때가 있다.

Sprouting Pattern : Sprout Method추가 기능을 새로운 메소드로 정의하고, TDD 로 개발한다.

원래 메소드에서 새로운 메소드를 부른다.(delegate 한다.)

Sprouting Pattern : Sprout Class새로운 메소드가 있는 클래스를 test harness 안에서 실행하기 어렵다면?- 새로운 클래스를 추가한다.- TDD 를 이용해서, 추가 기능을 새로운 클래스의 메소드로 구현한다.

- 원래 메소드에서 새로운 클래스를 생성한다.- 새로 생성한 객체의 새로운 메소드를 부른다.(delegate 한다. )

Sprouting Pattern이미 존재하는 코드에 더 많은 코드를 추가하는 것을 피하라.

대신에, 새로운 클래스와 메소드에 tested code 를 만들어라.

기존의 코드에서 새로운 메소드로 위임하라.

Sprout Method

조금 전의 요구사항을 Sprout Method 로 작성해 본다. ( 기간 : 20분 )- 진행방식 : Pair Programming

Guide- Red-Green-Refactoring 리듬을 잘 지킨다.- 적극적으로 리팩토링한다.

- 중복을 찾고, 중복을 적극적으로 제거한다.- 문자열(혹은 문자열 구조)의 중복도 찾아서 제거한다.- 리팩토링할 때마다 반드시 테스트를 실행한다.

Working Effectively with Legacy Code

Michael Feathers

코드 스멜 찾기

코드스멜 찾기코드 스멜(Code Smell)- Kent Beck 이 만든 용어- 리팩토링이 필요한 코드를 식별하게 도와준다.

- 언제 리팩토링을 해야 하는지를 알려준다.

Code Smells1 Alternative Classes with Different Interfaces

2 Combinatorial Explosion

3 Comments

4 Conditional Complexity

5 Data Class

6 Data Clumps

7 Divergent Change

8 Duplicated Code

9 Feature Envy

10 Freeloader ( Lazy Class )

Code Smells11 Inappropriate Intimacy

12 Incomplete Library Class

13 Incedent Exposure

14 Large Class

15 Long Method

16 Long Parameter List

17 Message Chain

18 Middle Man

19 Oddball Solution

20 Parallel Inheritance Hiearchy

Code Smells21 Primitive Obsessiion

22 Refused Bequest

23 Shortgun Surgery

24 Solution Sprawl

25 Speculative Generality

26 Switch Statement

27 Temporary Field

Code Smell to Refactoring Cheat Sheet ( Industrial Logic )( http://www.industriallogic.com/wp-content/uploads/2005/09/smellstorefactorings.pdf )

코드스멜 찾기

Customer 의 statement() 메소드를 보자.- 이 코드는 좋은 코드인가? 나쁜 코드인가? - 코드에 개선할 부분이 있는가?- 코드 스멜을 찾아보자. - 시간 ( 5분 )

Session 2 - 코드스멜 찾기

발견된 코드 스멜들

리팩토링을 시작해 볼까요?

잠깐!! 리팩토링이란?1.명사형- 소프트웨어를 더 쉽게 이해할 수 있고, - 적은 비용으로 수정할 수 있도록 - 겉으로 보이는 동작의 변화 없이 내부 구조를 변경하는 것

2.동사형(Refactor)- 일련의 리팩토링을 적용하여 겉으로 보이는 동작의 변화없이

- 소프트웨어의 구조를 바꾸다.

리팩토링

그림 출처 : http://vitalflux.com/top-6-refactoring-patterns-to-help-you-score-80-in-code-quality/

리팩토링의 전제조건

리팩토링=외부 동작을 바꾸지 않으면서 내부 구조를 개선하는 것

코드를 변경했을 때, 외부 동작이 바뀌지 않았다는 것을 증명해야 한다.

외부 동작이 바뀌지 않았다는 것을 어떻게 증명할 것인가?

증명방법

바뀌지 않았다고 믿는다.

영향을 받을 수 있는 기능을 수동으로 테스트한다.

테스트 코드를 이용하여 자동으로 테스트한다.

리팩토링 vs 리프로그래밍

외부 동작이 바뀌지 않았다는 것을 증명할 수 있는 단위테스트가 없다면?- 리팩토링을 하고 있는가?- 리프로그래밍을 하고 있는가?

레거시 코드 테스트 작성하기

테스트 작성하기비디오 대여시스템 코드는 현재 테스트 코드가 없다.

리팩토링을 하고 싶다면, 먼저 테스트를 작성해야 한다.

레거시 코드 단위 테스트 작성하기1. 생성자 테스트 작성하기2. 테스트 커버리지를 측정한다.3. 테스트 되지 않은 코드를 커버할 수 있는 새로운 테스트를 작성한다. ( Characterization Test )

4. 테스트 커버리지가 100%에 가까워질 때까지 테스트 코드를 작성한다.

생성자 테스트 작성하기목적 : 객체 생성 비용을 파악한다.

1. JUnit Test Class 를 생성한다. ( CustomerTest )2. 테스트 메소드 이름을 testCreate 라고 한다.3. Customer 클래스의 객체를 생성해 본다.4. 단위 테스트를 실행한다.

생성자 테스트 작성하기

public class CustomerTest {

@Testpublic void testCreate() {

Customer customer = new Customer(null);}

}

Customer 의 statement() 테스트하기실패하는 테스트 코드 만들기1. 생성자 테스트 메소드의 이름을 testX 로 바꾼다.2. assert 문을 추가한다.

assertEquals(“”,customer.statement())3. 테스트를 실행한다. ( 빨간불 )

Customer 의 statement() 테스트하기실패하는 테스트 코드 만들기public class CustomerTest {

@Testpublic void testX() {

Customer customer = new Customer(null);assertEquals(“”,customer.statement())

}

}

Customer 의 statement() 테스트하기실패하는 테스트 코드 만들기1. JUnit 창에서 statement() 의 반환값을 복사해서, expected value 에 복사한다.

2. 테스트를 실행한다. ( 초록불 )3. 테스트 메소드의 이름을 변경한다.

Customer 의 statement() 테스트하기테스트 코드 이름 변경하기 (rename)

@Testpublic void amountShouldBeZeroWhenCustomerRentNoMovie() {

assertEquals("Rental Record for John\n"+ "Amount owed is 0.0\n"+ "You earned 0frequent renter points",customer.statement());

}

테스트 커버리지 실행하기테스트 커버리지 도구를 이용하여, 테스트가 되지 않은 코드를 파악한다.

- Coverage As 메뉴에서 JUnit Test 를 선택해서 실행

statement() 함수를 확인해서, 테스트 되지 않은 코드를 파악한다.

테스트 되지 않은 코드를 커버하기 위해 새로운 테스트를 작성한다.

테스트 커버리지 확인하기

레거시 코드 테스팅 알고리즘1. 커버리지를 확인하고, 새로운 테스트가 커버할 코드 영역을 결정한다.

2. 테스트 메소드를 생성하고, 해당 코드 영역을 커버하기 위한 조건을 만든다.

3. 실패하는 테스트 코드를 작성한다.4. JUnit 결과를 이용하여 테스트를 통과하게 만든다.5. 커버리지가 100%에 가까워질 때까지 이 과정을 반복한다.

레거시 코드 테스팅

미션 : 테스트 커버리지가 100%에 가까워질 때까지 테스트 코드를 작성한다.시간 : 30분

Characterization Test

레거시 코드의 특성을 이해하기 위해서 작성하는 테스트

Refactoring

Refactoring 시작하기

테스트 커버리지가 100%에 가까워지면 이제 리팩토링을 시작할 수 있다.

Code Smell 찾기

Customer.statement() 에 어떤 코드 스멜이 있는지 다시 한번 찾아보자.

Long Method발견하기 가장 쉬운 코드 스멜가장 많이 생기는 코드 스멜긴 메소드가 왜 코드 스멜인가?왜 긴 메소드가 생기는가?

Long Method긴 메소드를 없애기 위한 리팩토링 : - Extract Method ( Alt+Shift+M )메소드로 추출할 코드를 선정하는 방법- 주석이 있는 코드- 조건문과 루프- 메소드로 뽑았을 때, 의도를 더 잘 전달할 수 있는 코드

Comments

주석은 왜 코드 스멜인가?

Comments

주석이 도움이 되는 경우

Refactoring - Extract Methodstatement() 에서 주석이 달린 코드를 찾아서 리팩토링 해 보자.

while (rentals.hasMoreElements()) { double thisAmount = 0; Rental each = (Rental)rentals.nextElement();

//각 영화에 대한 요금 결정

switch ( each.getMovie().getPriceCode() ) { case Movie.REGULAR: thisAmount += 2; if (each.getDaysRented() > 2) thisAmount += (each.getDaysRented() - 2) * 1.5; break; case Movie.NEW_RELEASE: thisAmount += each.getDaysRented() * 3; break; case Movie.CHILDREN: thisAmount += 1.5; if (each.getDaysRented() > 3) thisAmount += (each.getDaysRented() - 3) * 1.5; break; }

영화 요금 - Extract Methodwhile (rentals.hasMoreElements()) { double thisAmount = 0; Rental each = (Rental)rentals.nextElement();

//각 영화에 대한 요금 결정

switch ( each.getMovie().getPriceCode() ) { case Movie.REGULAR: thisAmount += 2; if (each.getDaysRented() > 2) thisAmount += (each.getDaysRented() - 2) * 1.5; break; case Movie.NEW_RELEASE: thisAmount += each.getDaysRented() * 3; break; case Movie.CHILDREN: thisAmount += 1.5; if (each.getDaysRented() > 3) thisAmount += (each.getDaysRented() - 3) * 1.5; break; }

// 포인트(frequent renter points) 추가 frequentRenterPoints ++;

// 최신을 이틀이상 대여하는 경우 추가 포인트 제공 if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1) frequentRenterPoints ++;

// 이 대여에 대한 요금 계산 결과 표시 result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(thisAmount) + "\n"; totalAmount += thisAmount;}

1. 코드 블록 선택후 Extract Method ( Alt+Shift+M)2. 메소드 이름 : amountFor

영화 요금 - Extract Methodwhile (rentals.hasMoreElements()) { double thisAmount = 0; Rental each = (Rental)rentals.nextElement();

thisAmount = amountFor(thisAmount,each)

// 포인트(frequent renter points) 추가 frequentRenterPoints ++;

// 최신을 이틀이상 대여하는 경우 추가 포인트 제공 if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) && each.getDaysRented() > 1) frequentRenterPoints ++;

// 이 대여에 대한 요금 계산 결과 표시 result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(thisAmount) + "\n"; totalAmount += thisAmount;}

private double amountFor(double thisAmount,Rental each) { //각 영화에 대한 요금 결정

switch ( each.getMovie().getPriceCode() ) { case Movie.REGULAR: thisAmount += 2; if (each.getDaysRented() > 2) thisAmount += (each.getDaysRented() - 2) * 1.5; break; case Movie.NEW_RELEASE: thisAmount += each.getDaysRented() * 3; break; case Movie.CHILDREN: thisAmount += 1.5; if (each.getDaysRented() > 3) thisAmount += (each.getDaysRented() - 3) * 1.5; break; } return thisAmount;}

테스트 실행

리팩토링을 한 후에는 반드시 테스트를 실행해서

- 기존의 동작이 변경되지 않았는지 확인한다.

amountFor 리팩토링 하기

amountFor 에는 어떤 코드 스멜이 있는가?

amountFor 리팩토링double amountFor(double thisAmount, Rental each)

에서 thisAmount 는 패러미터로 받을 필요가 없음

메소드에서 패러미터를 삭제하는 리팩토링 : Remove Parameter이클립스에서는 Change Method Signature 에서 패러미터를 삭제할 수 있음 ( Alt+Shift+C )1. thisAmount Parameter 선택2. Remove 클릭

amountFor 리팩토링thisAmount 패러미터가 삭제되면, thisAmount 가 정의되지 않았기 때문에 컴파일 오류 발생

- 컴파일 오류가 thisAmount 를 선택 후, Quick Fix 로 로컬 변수 생성- 테스트 코드 실행 : 테스트가 실패함- 왜 테스트 코드가 실패할까?

private double amountFor(double thisAmount,Rental each) { int thisAmount = 0;

switch ( each.getMovie().getPriceCode() ) { case Movie.REGULAR: thisAmount += 2; if (each.getDaysRented() > 2) thisAmount += (each.getDaysRented() - 2) * 1.5; ...

amountFor 리팩토링amountFor(Rental each) 에서 변수 이름 each 이름 변경하기

- 패러미터 선택후 Rename Method ( Alt+Shift+R)- 변경이유 :

- each 는 루프에서 사용되던 변수 이름- 패러미터는 루프와 관계 없기 때문에 이름을 맞게 수정

로컬 변수인 thisAmount 의 이름 변경하기- 변수 선택후 Rename Local Variable ( Alt+Shift+R )- 변경 이유

- 변수 이름이 너무 길다.

amountFor 리팩토링amountFor 는 Customer 객체의 멤버를 전혀 사용하지 않는다.Rental 과 Movie 객체의 메소드를 사용한다.amountFor 에는 Feature Envy(기능 욕심) 코드 스멜이 있다.

Feature Envy자신이 속한 클래스보다 다른 클래스에 관심을 가지고 있는 경우

적용할 리팩토링 : Move Method

Feature Envy

메소드가 여러 클래스의 메소드를 사용하고 있다면 어디로 옮겨야 할까?

amountFor 리팩토링

amountFor 를 Rental 클래스로 옮긴다.- 커서를 amountFor signature 로 이동한다.- Move Method ( Alt+Shift+V ) 를 실행한다.

amountFor 리팩토링amountFor 를 Rental 클래스로 옮기고 나면, statement() 에서 each.amountFor() 형식으로 불린다.

a. Rental 클래스로 옮기기 전에는 … amountFor(each) 형식이어서 영어 문장의 어순과 일치한다.

b. Rental 클래스로 옮긴 후에는 each.amountFor() 라서 영어의 어순과 일치하지 않는다.

c. amountFor 메소드의 이름을 바꾸는 것이 좋다.

로컬 변수 삭제 하기statement() 의 thisAmount 로컬 변수를 삭제한다.로컬 변수를 삭제하는 이유는?

// 리팩토링 후 코드while (rentals.hasMoreElements()) { Rental each = (Rental)rentals.nextElement();

frequentRenterPoints += each.getFrequentRenterPoints()

// 이 대여에 대한 요금 계산 결과 표시 result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(each.getCharge()) + "\n"; totalAmount += each.getCharge();}

로컬 변수 삭제 하기적용할 리팩토링 : Replace Temp with Query

// 리팩토링 후 코드while (rentals.hasMoreElements()) { Rental each = (Rental)rentals.nextElement();

frequentRenterPoints += each.getFrequentRenterPoints()

// 이 대여에 대한 요금 계산 결과 표시 result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(each.getCharge()) + "\n"; totalAmount += each.getCharge();}

Refactoring - 포인트 추가

// 포인트(frequent renter points) 추가

“// 포인트(frequent renter points) 추가” 에 대해서도 amountFor 와 동일한 리팩토링을 수행한다.

// 리팩토링 후 코드while (rentals.hasMoreElements()) { double thisAmount = 0; Rental each = (Rental)rentals.nextElement();

thisAmount = amountFor(thisAmount,each)

frequentRenterPoints += each.getFrequentRenterPoints()

// 이 대여에 대한 요금 계산 결과 표시 result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(each.getCharge()) + "\n"; totalAmount += each.getCharge();}

임시 변수 제거하기

statement() 에 어떤 임시 변수가 있는가?

totalAmount 임시변수 제거하기1. statement() 함수를 복사해서 getTotalCharge() 를 만든다.2. statement() 함수에서 totalAmount 를 사용하는 곳을 getTotalCharge() 로 바꾼다.

3. statement() 함수에서 totalAmount 변수를 삭제한다.

totalAmount 임시변수 제거하기public String statement() { Enumeration rentals = _rentals.elements(); String result ="Rental Record for " + getName() + "\n";

while (rentals.hasMoreElements()) { Rental each = (Rental)rentals.nextElement();

frequentRenterPoints += each.getFrequentRenterPoints()

result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(each.getCharge()) + "\n"; }

result += "Amount owed is " + String.valueOf(getTotalCharge()) + "\n"; result += "You earned " + String.valueOf(frequentRenterPoints) + "frequent renter points";

return result;}

frequentRenterPoints 제거하기public String statement() { Enumeration rentals = _rentals.elements(); String result ="Rental Record for " + getName() + "\n";

while (rentals.hasMoreElements()) { Rental each = (Rental)rentals.nextElement();

result += "\t" + each.getMovie().getTitle() + "\t" + String.valueOf(each.getCharge()) + "\n"; }

result += "Amount owed is " + String.valueOf(getTotalCharge()) + "\n"; result += "You earned " + String.valueOf(getTotalFrequentRenterPoints()) + "frequent renter points";

return result;}

멈추고 생각하기코드량

- 일반적으로 리팩토링하면 코드량이 줄어든다.- 이번 리팩토링은?성능

- 이번 리팩토링은 성능에는 어떤 영향을 미치는가?이번 리팩토링은- 왜 해야 하는가?- 어떤 이익을 볼 수 있는가?

변경된 구조

while ( ) {

}

영화 요금 계산

영화 요금 출력

포인트 계산

영화 요금 계산while () {}

영화 요금 출력while () {}

포인트 계산while () {}

기능추가 - HTML 로 구현해 보자.

html 로 출력하는 기능 구현

statement() 과 동일한 내용을html 로 출력하는 기능을 구현해 보자.

Composed Method 패턴

Composed Method켄트 벡이 발견한 패턴

1. 코드를 메소드로 나눈다. 메소드는 한가지 일만 해야 한다.

2. 메소드에 있는 코드의 추상화 레벨은 같아야 한다.

3. Composed Method 패턴을 따르면, 메소드 크기가 작아진다.

Composed Method 로 만들기package refactoring;

public class List { private Object[] _elements = new Object[10]; private boolean _readOnly; private int _size = 0;

public void Add(Object child) { if (!_readOnly) {

int newSize = _size + 1;if (newSize > _elements.length) {

Object[] newElements = new Object[_elements.length + 10];for (int i = 0; i < _size; i++) {

newElements[i] = _elements[i];}_elements = newElements;

}_elements[_size] = child;_size++;

} }}

출처: Refactoring to Patterns (Joshua Kerievsky )

statement() 리팩토링 하기

Composed Method 로 만들어 보자.

영화 분류 방법 변경

새로운 분류 방법 도입대여점에서 영화를 분류하는 방법을 변경할 예정이다.- 어떻게 변경할지는 결정하지 않았다.- 새로운 분류방법이 도입될 것이다.- 새로운 분류방법에 따라 요금과 포인트를 할당할 예정이다.

현재 시점에서 이런 변경하는 것이 쉬울까?

이런 변화를 쉽게 반영하려면 어떻게 해야 할까?

switch statement

switch statement 는 왜 코드 스멜인가?switch statement 는 언제 리팩토링해야 하는가?

switch statement

리팩토링 방법- Replace Type Code with Subclasses- Replace Type Code with State/Strategy- Replace Conditional with Polymorphism

switch statement

Rental 의 getCharge 에서 개선할 것은?

switch statementRental 의 getCharge 를 Movie 로 옮기자

설계면에서 어느 것이 더 좋은 선택인가?- 영화 종류를 Rental 에서 사용하는 것과- 대여기간을 Movie 로 넘겨주는 것

Refactoring - Replace Type Code with Subclasses

switch statement영화 종류가 여러개이고, 각 영화종류는 같은 질문에 다른 답을 한다.- Switch 문을 직접 다형성으로 바꿔보자.

Movie

getCharge

getCharge

RegularMovie Children Movie

getCharge

New Release Movie

getCharge

switch statement

State Pattern 을 이용하기

Refactoring - Replace Type Code with State/Strategy

Replace type code with State/Strategy

Step 1. type code 에 Self Encapsulate Field 를 적용하자.Step 2. Movie 클래스에서 Price 클래스를 Extract 한다.

Move Method

Movie 의 getCharge 를 price 로 옮기기

Replace Conditional with Polymorphism

Price getCharge 코드를 subclass 로 옮기기모든 작업이 끝나면, price getCharge 를 추상 메소드로 변경

getFrequentRenterPoints

Rental 에서 Movie 로 옮기자동일하게 적용하자

멈추고 생각하기

스테이트 패턴을 적용하는데 많은 노력이 들었다.그럴만한 가치가 있었을까?이전보다 어떤 점이 개선되었는가?

회고하기

이 과정을 통해서 배운 것중 기억하고 싶은 것을 한가지만 고른다면?이 과정을 통해서 새로 알게 된 것들이 있다면?이 과정에서 아쉬운 점이 있었다면?