728x90
8장 기능 이동
8.1 함수 옮기기
배경
- 좋은 소프트웨어 설계의 핵심은 모듈성이다
- 모듈성이 높으면 프로그램의 어느 부분을 수정하려 할 때 관련된 작은 일부만을 이해해도 가능하게 해주는 능력이다.
- 연관된 요소들을 함께 묶고, 요소 사이의 연결 관계를 쉽게 찾고 이해할 수 있도록 해야 한다.
- 모든 함수는 컨텍스트안에 존재한다. 객체 지향 프로그래밍에서 핵심 모듈화 컨텍스트는 클래스다.
- 어떤 함수가 자신이 속한 모듈의 요소보다 다른 모듈의 요소들을 더 많이 참조한다면 옮겨주는것이 마땅하다.
- 함수 중 독립적으로도 고유한 가치가 있거나 다른 클래스로 옮겨두면 사용하기 더 편한 메소드도 옮기는게 낫다.
- 함수를 옮길지 말기 고민된다면 대상 함수의 현재 컨텍스트와 후보 컨텍스트를 둘러보자
- 대상 함수를 호출하는 함수
- 대상 함수가 호출하는 함수
- 대항 함수가 사용하는 데이터
- 함수가 있어야 할 최적의 장소를 정하기 어려울수록 큰 문제가 아닌 경우가 많다.
- 우선 한 컨텍스트에 모아두고 잘 맞지 않는다고 판단되면 위치를 옮기는 방법도 괜찮다.
절차
- 선택한 함수가 현재 컨텍스트에서 사용 중인 모든 프로그램 요소를 살피고, 요소들 중에 함께 옮겨야 할 게 있는지 본다.
- 호출되는 함수 중 함께 옮길 게 있다면 대체로 그 함수를 먼저 옮기는 게 낫다. 얽혀있는 함수가 여러 개라면 다른 곳에 미치는 영향이 적은 함수부터 옮긴다.
- 하위 함수들의 호출자가 고수준 함수 하나뿐이면 먼저 하위 함수들을 고수준 함수에 인라인하고, 고수준 함수들을 옮긴 뒤 옮긴 위치에서 개별 함수들로 추출한다.
- 선택한 함수가 다형 메서드인지 확인한다.
- 객체 지향 언어에서는 같은 메서드가 슈퍼클래스나 서브클래스에도 선언되어 있는지까지 고려해야 한다.
- 선택한 함수를 타깃 컨텍스트로 복사하고 다듬는다.
- 함수 본문에서 소스 컨텍스트의 요소를 사용한다면 해당 요소들을 매개변수로 넘기거나 소스 컨텍스트 자체를 참조로 넘겨준다.
- 함수를 옮기게 되면 새로운 컨텍스트에 어울리는 이름으로 바꿔줘야 할 경우가 있다.
- 정적 분석을 수행한다
- 소스 컨텍스트에서 타깃 함수를 참조할 방법을 찾아 반영한다.
- 소스 함수를 타깃 함수의 위임 함수가 되도록 수정한다.
- 테스트한다.
- 소스 함수를 인라인할지 고민해본다
- 소스 함수는 언제까지라도 위임 함수로 남겨둘 수 있다. 다만 소스 함수를 호출하는 곳에서 타깃 함수를 직접 호출하는 데 무기라 없다면 중간 단계 소스 함수는 제거하는 편이 낫다.
예시
public class Customer {
private String name;
private List<Rental> rentals;
public Customer(String name) {
this.name = name;
this.rentals = new ArrayList<>();
}
public void addRental(Rental rental) {
rentals.add(rental);
}
public String getName() {
return name;
}
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
StringBuilder result = new StringBuilder("영화대여 내역 " + getName() + "\n");
for (Rental rental : rentals) {
double thisAmount = getCharge(rental);
frequentRenterPoints++;
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE) && (rental.getDaysRented() > 1)) {
frequentRenterPoints++;
}
result.append("\t").append(rental.getMovie().getTitle()).append("\t").append(thisAmount).append("\n");
totalAmount += thisAmount;
}
result.append("전체금액 : ").append(totalAmount).append("\n");
result.append("총 ").append(frequentRenterPoints).append(" 포인트를 얻었습니다.");
return result.toString();
}
//요금을 계산하는 함수. Rental의 요소들을 더 많이 참조하고 있다.
public double getCharge(Rental rental) {
double result = 0;
switch (rental.getMovie().getPriceCode()) {
case Movie.REGULAR:
result += 2;
if (rental.getDaysRented() > 2) {
result += (rental.getDaysRented() - 2) * 1.5;
}
break;
case Movie.NEW_RELEASE:
result += rental.getDaysRented() * 3;
break;
case Movie.CHILDREN:
result += 1.5;
if (rental.getDaysRented() > 3) {
result += (rental.getDaysRented() - 3) * 1.5;
}
break;
}
return result;
}
}
public class Rental {
private Movie movie;
private int daysRented;
public Rental(Movie movie, int daysRented) {
this.movie = movie;
this.daysRented = daysRented;
}
public int getDaysRented() {
return daysRented;
}
public Movie getMovie() {
return movie;
}
}
public class Customer {
private String name;
private List<Rental> rentals;
public Customer(String name) {
this.name = name;
this.rentals = new ArrayList<>();
}
public void addRental(Rental rental) {
rentals.add(rental);
}
public String getName() {
return name;
}
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
StringBuilder result = new StringBuilder("영화대여 내역 " + getName() + "\n");
for (Rental rental : rentals) {
double thisAmount = rental.getCharge();
frequentRenterPoints++;
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE) && (rental.getDaysRented() > 1)) {
frequentRenterPoints++;
}
result.append("\t").append(rental.getMovie().getTitle()).append("\t").append(thisAmount).append("\n");
totalAmount += thisAmount;
}
result.append("전체금액 : ").append(totalAmount).append("\n");
result.append("총 ").append(frequentRenterPoints).append(" 포인트를 얻었습니다.");
return result.toString();
}
}
public class Rental {
private Movie movie;
private int daysRented;
public Rental(Movie movie, int daysRented) {
this.movie = movie;
this.daysRented = daysRented;
}
public int getDaysRented() {
return daysRented;
}
public Movie getMovie() {
return movie;
}
// 기존 Customer에 있던 함수를 Rental class로 이동
public double getCharge() {
double result = 0;
switch (movie.getPriceCode()) {
case Movie.REGULAR:
result += 2;
if (getDaysRented() > 2) {
result += (getDaysRented() - 2) * 1.5;
}
break;
case Movie.NEW_RELEASE:
result += getDaysRented() * 3;
break;
case Movie.CHILDREN:
result += 1.5;
if (getDaysRented() > 3) {
result += (getDaysRented() - 3) * 1.5;
}
break;
}
return result;
}
}
8.2 필드 옮기기
배경
- 데이터 구조는 굉장히 중요하지만 제대로 하기가 어렵다
- 그렇기 때문에 적절치 않은 데이터 구조는 최대한 빨리 바로잡아야 한다.
- 필드 옮기기 리팩터링은 대체로 더 큰 변경의 일환으로 수행된다.
절차
- 소스 필드가 캡슐화되어 있지 않다면 캡슐화한다.
- 테스트한다.
- 타깃 객체에 필드와 접근자 메서드들을 생성한다.
- 정적 검사를 수행한다.
- 소스 객체에서 타깃 객체를 참조할 수 있는지 확인한다.
- 기존 필드나 메서드 중 타깃 객체를 넘겨주는 게 았을지 모른다. 없다면 이런 기능의 메서드를 쉽게 만들 수 있는지 살펴보고 간단하지 않다면 타깃 객체를 저장할 새 필드를 소스 객체에 생성하자. 이는 더 넓은 맥락에서 리펙터링을 충분히 하고 나면 다시 없앨 수 있을 때도 있다.
- 접근자들이 타깃 필드를 사용하도록 수정한다.
- 여러 소스에서 같은 타깃을 공유한다면, 먼저 세터를 수정해 타깃 필드와 소스 필드 모두를 갱신하게 하고, 이어서 일관성을 깨뜨리는 갱신을 검출할 수 있도록 어서션을 추가하자. 모두 잘 마무리 되었다면 접근자들이 타깃 필드를 사용하도록 수정한다.
- 테스트한다.
- 소스 필드를 제거한다.
- 테스트한다.
예시
8.3 문장을 함수로 옮기기
배경
- 문장들을 함수로 옮기려면 그 문장들이 피호출 함수의 일부라는 확신이 있어야 한다.
- 피호출 함수와 한 몸은 아니지만 여전히 함께 호출돼야 하는 경우라면 단순히 해당 문장들과 피호출 함수를 통째로 또 하나의 함수로 추출한다. 다만 마지막의 인라인과 이름 바꾸기 단계만 제외한다.
절차
- 반복 코드가 함수 호출 부분과 멀리 떨어져 있다면 문장 슬라이드하기를 적용해 근처로 옮긴다
- 타깃 함수를 호출하는 곳이 한 곳뿐이면, 단순히 소스 위치에서 해당 코드를 잘라내어 피호출 함수로 복사하고 테스트한다. 이 경우 나머지 단계는 무시한다.
- 호출자가 둘 이상이면 호출자 중 하나에서 ‘타깃 함수 호출 부분과 그 함수로 옮기려는 문장들을 함께’ 다른 함수로 추출한다. 추출한 함수에 기억하기 쉬운 임시 이름을 지어준다.
- 다른 호출자 모두가 방금 추출한 함수를 사용하도록 수정한다. 하나씩 수정할 때마다 테스트한다.
- 모든 호출자가 새로운 함수를 사용하게 되면 원래 함수를 새로운 함수 안으로 인라인한 후 원래 함수를 제거한다.
- 새로운 함수의 이름을 원래 함수의 이름으로 바꿔준다. 더 나은 이름이 있다면 그 이름을 쓴다.
예시
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
StringBuilder result = new StringBuilder("영화대여 내역 " + getName() + "\n");
for (Rental rental : rentals) {
double thisAmount = rental.getCharge();
frequentRenterPoints++;
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE) && (rental.getDaysRented() > 1)) {
frequentRenterPoints++;
}
result.append("\t").append(rental.getMovie().getTitle()).append("\t").append(thisAmount).append("\n");
totalAmount += thisAmount;
}
printRentalInformation(result, totalAmount);
result.append("총 ").append(frequentRenterPoints).append(" 포인트를 얻었습니다.");
return result.toString();
}
public StringBuilder printRentalInformation(StringBuilder result, double totalAmount) {
result.append("전체금액 : ").append(totalAmount).append("\n");
return result;
}
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
StringBuilder result = new StringBuilder("영화대여 내역 " + getName() + "\n");
for (Rental rental : rentals) {
double thisAmount = rental.getCharge();
frequentRenterPoints++;
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE) && (rental.getDaysRented() > 1)) {
frequentRenterPoints++;
}
result.append("\t").append(rental.getMovie().getTitle()).append("\t").append(thisAmount).append("\n");
totalAmount += thisAmount;
}
printRentalInformation(result, totalAmount, frequentRenterPoints);
return result.toString();
}
public StringBuilder printRentalInformation(StringBuilder result, double totalAmount, int frequentRenterPoints) {
result.append("전체금액 : ").append(totalAmount).append("\n");
result.append("총 ").append(frequentRenterPoints).append(" 포인트를 얻었습니다.");
return result;
}
8.4 문장을 호출한 곳으로 옮기기
배경
- 초기에는 한 가지 일만 수행하던 함수가 어느새 둘 이상의 다른 일을 수행하게 되는 경우엔 우선 문장 슬라이드하기를 적용해 달라지는 함수의 시작 혹은 끝으로 옮긴 다음, 바로 이어서 문장을 호출한 곳으로 옮기기 리팩터링을 적용한다.
- 변경이 작다면 문장을 호출한 곳으로 옮기는 것으로 충분하지만 호출자와 호출 대상의 경계를 완전히 다시 그어야 할 수도 있다.
- 함수 인라인하기 부터 적용한 뒤, 문장 슬라이드하기 와 함수 추출하기로 적합한 경계를 설정한다.
절차
- 호출자가 한두 개뿐이고 피호출 함수도 간단한 단순한 상황이면, 피호출 함수의 처음 혹은 마지막줄들을 잘라내서 호출자들로 복사해 넣는다. 필요시 적당히 수정하고 테스트만 통과하면 이번 리팩터링은 여기서 끝이다.
- 더 복잡한 케이스에서는, 이동하지 ‘않길’원하는 모든 문장을 함수로 추출한 다음 검색하기 쉬운 임시 이름을 지어준다.
- 대상 함수가 서브클래스에서 오버라이드 됐다면 오버라이드한 서브클래스들의 메서드 모두에서 동일하게 남길 부분을 메서드로 추출한다..
- 이때 남겨질 메서드의 본문은 모든 클래스에서 똑같아야 한다.
- 그런 다음 슈퍼클래스의 메서드만 남기고 서브클래스들의 메서드를 제거한다.
- 원래 함수를 인라인한다.
- 추출된 함수의 이름을 원해 함수의 이름으로 변경한다.
예시
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
StringBuilder result = new StringBuilder("영화대여 내역 " + getName() + "\n");
for (Rental rental : rentals) {
double thisAmount = rental.getCharge();
frequentRenterPoints++;
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE) && (rental.getDaysRented() > 1)) {
frequentRenterPoints++;
}
result.append("\t").append(rental.getMovie().getTitle()).append("\t").append(thisAmount).append("\n");
totalAmount += thisAmount;
}
printRentalInformation(result, totalAmount, frequentRenterPoints);
return result.toString();
}
public StringBuilder printRentalInformation(StringBuilder result, double totalAmount, int frequentRenterPoints) {
result.append("전체금액 : ").append(totalAmount).append("\n");
result.append("총 ").append(frequentRenterPoints).append(" 포인트를 얻었습니다.");
return result;
}
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
StringBuilder result = new StringBuilder("영화대여 내역 " + getName() + "\n");
for (Rental rental : rentals) {
double thisAmount = rental.getCharge();
frequentRenterPoints++;
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE) && (rental.getDaysRented() > 1)) {
frequentRenterPoints++;
}
result.append("\t").append(rental.getMovie().getTitle()).append("\t").append(thisAmount).append("\n");
totalAmount += thisAmount;
}
printRentalInformation(result, totalAmount);
result.append("총 ").append(frequentRenterPoints).append(" 포인트를 얻었습니다.");
return result.toString();
}
public StringBuilder printRentalInformation(StringBuilder result, double totalAmount) {
result.append("전체금액 : ").append(totalAmount).append("\n");
return result;
}
8.5 인라인 코드를 함수 호출로 바꾸기
배경
- 이미 존재하는 함수와 똑같은 일을 하는 인라인 코드를 발견하면 보통은 해당 코드를 함수 호출로 바꿀텐데, 순전히 우연히 비슷한 코드가 만들어졌을 때 처럼 기존 함수의 코드를 수정하더라도 인라인 코드의 동작은 바뀌지 않아야 할 때는 예외적으로 처리해야한다.
- 이를 판단하기 위해서 인라인 코드 대신 함수 이름을 넣었을때 말이 되는지 확인한다.
- 어색하다면 이름이 잘못 되었거나 해당 함수의 목적이 인라인 코드의 목적과 다르기 때문일 것이다.
절차
- 인라인 코드를 함수 호출로 대체한다.
- 테스트한다.
예시
public class StringOperations {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("김");
names.add("박");
names.add("이");
names.add("조");
boolean containsCharlie = false;
for (String name : names) {
if (name.equals("김")) {
containsCharlie = true;
break;
}
}
int numberOfNames = names.size();
System.out.println("'김'씨를 포함 여부: " + containsCharlie);
System.out.println("성씨 수: " + numberOfNames);
}
}
public class StringOperations {
public static void main(String[] args) {
List<String> names = new ArrayList<>();
names.add("김");
names.add("박");
names.add("이");
names.add("조");
boolean containsCharlie = names.contains("김");
int numberOfNames = names.size();
System.out.println("'김'씨를 포함 여부: " + containsCharlie);
System.out.println("성씨 수: " + numberOfNames);
}
}
8.6 문장 슬라이드하기
배경
- 관련된 코드들은 서로 가까이 모여 있어야 이해하기 쉽다.
- 관련 코드끼리 모으는 작업은 다른 리팩터링, 주로 함수 추출하기의 준비 단계로 자주 행해진다.
절차
- 코드 조각을 이동할 목표 위치를 찾는다. 코드 조각의 원래 위치와 목표 위치 사이의 코드들을 훑어보면서, 조각을 모으고 나면 동작이 달라지는 코드가 있는지 살핀다.
- 아래와 같은 간섭이 있다면 이 리팩터링은 포기해야한다.
- 코드 조각에서 참조하는 요소를 선언하는 문장 앞으로는 이동할 수 없다.
- 코드 조각을 참조하는 요소의 뒤로는 이동할 수 없다.
- 코드 조각에서 참조하는 요소를 수정하는 문장을 건너뛰어 이동할 수 없다.
- 코드 조각이 수정하는 요소를 참조하는 요소를 건너뛰어 이동할 수 없다.
- 아래와 같은 간섭이 있다면 이 리팩터링은 포기해야한다.
- 코드 조각을 원래 위치에서 잘라내어 목표 위치에 붙여 넣는다.
- 테스트한다.
테스트 실패시 더 작게 나눠 시도해본다. 이동 거리를 줄이거나 한 번에 옮기는 조각의 크기를 줄이는 방법이 있다.
예시
List<Movie> rentedMovies = getRentedMovie();
int totalAmount = getTotalAmount();
List<String> redtedMovieTitles = rentedMovies.stream().map(e -> e.getTitle()).collect(Collections.toList());
int amountToPay = totalAmount - 20;
List<Movie> rentedMovies = getRentedMovie();
List<String> redtedMovieTitles = rentedMovies.stream().map(e -> e.getTitle()).collect(Collections.toList());
int totalAmount = getTotalAmount();
int amountToPay = totalAmount - 20;
8.7 반복문 쪼개기
배경
- 하나의 반복문에서 두 가지 일을 수행하는 경우가 있다. 이를 분리하여 각각의 반복문으로 분리한다.
- 리팩터링을 실행한 후 병목이라 밝혀지만 그때 다시 하나로 합치면 된다.
절차
- 반복문을 복제해 두 개로 만든다
- 반복문이 중복되어 생기는 부수효과를 파악해서 제거한다.
- 테스트한다.
- 완료됐으면, 각 반복문을 함수로 추출할지 고민한다.
예시
public void processTasks(List<String> items) {
for (String item : items)
System.out.println("Item: " + item);
if (item.contains("apple")) {
System.out.println("Found an apple!");
} else {
System.out.println("No apples here!");
}
int length = item.length();
System.out.println("Item length: " + length);
String upperCaseItem = item.toUpperCase();
System.out.println("Uppercase Item: " + upperCaseItem);
String processedItem = item + " - processed";
System.out.println("Processed Item: " + processedItem);
System.out.println("-------------------------");
}
}
public void processTasks(List<String> items) {
for (String item : items) {
System.out.println("Item: " + item);
}
for (String item : items) {
if (item.contains("apple")) {
System.out.println("Found an apple!");
} else {
System.out.println("No apples here!");
}
}
for (String item : items) {
int length = item.length();
System.out.println("Item length: " + length);
}
for (String item : items) {
String upperCaseItem = item.toUpperCase();
System.out.println("Uppercase Item: " + upperCaseItem);
}
for (String item : items) {
String processedItem = item + " - processed";
System.out.println("Processed Item: " + processedItem);
}
System.out.println("-------------------------");
}
8.8 반복문을 파이프라인으로 바꾸기
배경
- 파이프라인으로 표현하면 이해하기 훨씬 쉬워진다.
절차
- 반복문에서 사용하는 컬렉션을 가리키는 변수를 하나 만든다.
- 기존 변수를 단순히 복사한 것일 수도 있다.
- 반복문의 첫 줄부터 시작해서, 각각의 단위 행위를 적절한 컬렉션 파이프라인 연산으로 대체한다.
- 이때 컬렉션 파이프라인 연산은 1번에서 만든 반복문 컬렉션 변수에서 시작하여, 이전 연산의 결과를 기초로 연쇄적으로 수행된다. 하나를 대체할때마다 테스트한다.
- 반복문의 모든 동작을 대체했다면 반복문 자체를 지운다.
- 반복문이 결과를 누적 변수에 대입했다면 파이프라인의 결과를 그 누적 변수에 대입한다.
예시
public List<Movie> getMoviesFromRentals(List<Rental> rentals) {
List<Movie> movies = new ArrayList<>();
for (Rental rental : rentals) {
Movie movie = rental.getMovie();
movies.add(movie);
}
return movies;
}
public List<Movie> getMoviesFromRentals(List<Rental> rentals) {
return rentals.stream()
.map(Rental::getMovie)
.collect(Collectors.toList());
}
8.9 죽은 코드 제거하기
배경
- 사용되지 않는 코드는 소프트웨어의 동작을 이해하는 데 커다란 걸림돌이 된다.
절차
- 죽은 코드를 외부에서 참조할 수 있는 경우라면 혹시라도 호출하는 곳이 있는지 확인한다.
- 없다면 죽은 코드를 제거한다.
- 테스트한다.
public class Customer {
private String name;
private List<Rental> rentals;
public Customer(String name) {
this.name = name;
this.rentals = new ArrayList<>();
}
public void addRental(Rental rental) {
rentals.add(rental);
}
public String getName() {
return name;
}
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
StringBuilder result = new StringBuilder("영화대여 내역 " + getName() + "\n");
for (Rental rental : rentals) {
double thisAmount = rental.getCharge();
frequentRenterPoints++;
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE) && (rental.getDaysRented() > 1)) {
frequentRenterPoints++;
}
result.append("\t").append(rental.getMovie().getTitle()).append("\t").append(thisAmount).append("\n");
totalAmount += thisAmount;
}
result.append("전체금액 : ").append(totalAmount).append("\n");
result.append("총 ").append(frequentRenterPoints).append(" 포인트를 얻었습니다.");
return result.toString();
}
// public double getCharge(Rental rental) {
// double result = 0;
//
// switch (rental.getMovie().getPriceCode()) {
// case Movie.REGULAR:
// result += 2;
// if (rental.getDaysRented() > 2) {
// result += (rental.getDaysRented() - 2) * 1.5;
// }
// break;
//
// case Movie.NEW_RELEASE:
// result += rental.getDaysRented() * 3;
// break;
//
// case Movie.CHILDREN:
// result += 1.5;
// if (rental.getDaysRented() > 3) {
// result += (rental.getDaysRented() - 3) * 1.5;
// }
// break;
// }
//
// return result;
// }
}
public class Customer {
private String name;
private List<Rental> rentals;
public Customer(String name) {
this.name = name;
this.rentals = new ArrayList<>();
}
public void addRental(Rental rental) {
rentals.add(rental);
}
public String getName() {
return name;
}
public String statement() {
double totalAmount = 0;
int frequentRenterPoints = 0;
StringBuilder result = new StringBuilder("영화대여 내역 " + getName() + "\n");
for (Rental rental : rentals) {
double thisAmount = rental.getCharge();
frequentRenterPoints++;
if ((rental.getMovie().getPriceCode() == Movie.NEW_RELEASE) && (rental.getDaysRented() > 1)) {
frequentRenterPoints++;
}
result.append("\t").append(rental.getMovie().getTitle()).append("\t").append(thisAmount).append("\n");
totalAmount += thisAmount;
}
result.append("전체금액 : ").append(totalAmount).append("\n");
result.append("총 ").append(frequentRenterPoints).append(" 포인트를 얻었습니다.");
return result.toString();
}
}
728x90
'Study > Refactoring' 카테고리의 다른 글
리팩터링 11 (0) | 2023.08.29 |
---|---|
리팩터링 챕터 10 (0) | 2023.08.28 |
리팩터링 챕터 9 (0) | 2023.08.27 |
리팩터링 챕터7 (0) | 2023.07.17 |
리팩터링 챕터 6 (0) | 2023.07.16 |