약 3달 전에 "Readable Code: 읽기 좋은 코드를 작성하는 사고법" 강의를 들으며 정리한 내용을 미루다 미루다 드디어 포스팅하였다.
이 강의를 듣고 공부한 내용은 실제 업무에서 리팩터링과 코드를 작성을 하는 데에 많은 도움이 되었다고 느꼈다. 기회가 된다면, 실제 업무에 적용한 사례도 정리하면 좋을 것 같다.
아무쪼록 지금까지 들었던 강의 중에 많이 와닿고 유익한 강의라, 적극 추천합니다.
우리가 클린 코드를 추구해야 하는 이유는 아래와 같이 이유가 있겠지만 결국, 우리의 시간과 자원 절약하기 위해서이다.
- 가독성을 위해서
- 코드를 잘 읽고, 읽히게 하기 위해서
- 코드를 쉽게 이해를 하기 위해서
- 유지보수 하기를 수월하게 하기 위해
읽기 좋은 코드와 추상화는 매우 밀접한 관계가 있다. 적절한 추상화는 복잡한 데이터와 복잡한 로직을 단순화하여 이해하기 쉽도록 한다.
여기서 추상화한, 구체적인 것에서 중요한 정보를 가려내어 남기고, 덜 중요한 정보는 숨기는 것을 말한다.
네이밍
네이밍은 추상화의 가장 대표적인 행위이면서, 단순하지만, 중요하고 고도의 추상적 사고 행위이다.
중요한 핵심 개념만을 추출하여 들어내는 것이 중요하며, 우리 도메인의 문맥 안에서 이해되는 용어이면 좋다.
(1) 단수와 복수를 구분하기
- 끝에 '-(e)s' 를 붙여 어떤 데이터가 단수인지 복수인지를 나타내는 것
(2) 이름 줄이지 않기
- 줄일말이라는 것은 가독성을 뒤로하고, 효율성을 얻는 것으로 대부분 잃는 것에 비해 얻는 것이 적다.
- 자제하는 것이 좋으나, 관용어처럼 많은 사람들이 자주 사용하는 줄임말 정도는 존재한다.
- 자주 사용하는 줄임말이 이해될 수 있는 것은 문맥 때문이다.
- 예를 들어, GeoPoint 클래스 내에 lat, lon이라는 변수가 있을 때, 위도 경도라고 유추가 가능하다.
(3) 은어/방언 사용하지 않기
- 농담에서 파생된 용어, 일부 팀원/현재의 우리 팀만 아는 용어 금지
- 새로운 사람이 팀에 합류했을 때 단번에 이해가능할지를 고려하는 것이 적절하다.
- 도메인 용어 사용하기
(4) 좋은 코드를 보고 습득하기
- 비슷한 상황에서 자주 사용하는 단어, 개념 습득하기 (ex. pool, candidate, threshold 등)
메서드와 추상화
한 메서드의 주제는 반드시 하나여야 한다. 메서드의 이름으로 구체적인 내용을 추상화할 수 있다.
반환타입 메서드명(파라미터) { 메서드 구현부 }
(1) 메서드명
- 추상화된 구체를 유추할 수 있는, 적절한 의미가 담긴 이름
- 파라미터와 연결 지어 더 풍부한 의미를 전달할 수 있다.
(2) 파라미터
- 파라미터의 타입, 개수, 순서를 통해 의미를 전달할 수 있다.
- "이 기능을 수행하기 위해서는 이런, 이런 데이터가 필요하니, 이런 데이터를 주면 이런 기능을 동작시켜 줄게"라고 생각하면 좋다.
(3) 반환타입
- 메서드 시그니처에 납득이 가는, 적절한 타입의 반환값을 돌려주는 것도 중요하다.
- void 대신 충분히 반환할 만한 값이 있는지 고민해 보자.
매직 넘버, 매직 스트링
- 의미는 갖고 있으나, 상수로 추출되지 않은 숫자와 문자열을 말한다.
- 이를 상수로 추출하여 이름을 짓고 의미를 부여함으로써 가독성과 유지보수성을 높일 수 있다.
- 주의 깊게 봐야 하는 부분이라고 읽는 사람에게도 알릴 수 있다.
동등한 추상화 레벨
동등한 추상화 레벨을 이해할 때, 아주 좋은 예시가 있다.
'' 우리는 서점에 가면 책의 제목을 가장 먼저 보게 된다. 그리고 책의 내용이 궁금하다면, 책의 내용을 보면 된다.
하지만 만약 서점에 제목이 있는 책과 제목이 없는 종이 뭉치가 섞여 있다면, 우리는 중간중간 멈칫하게 될 것이다. 어떤 책은 제목을 보고 책의 내용을 유추할 수 있지만, 제목이 없는 종이 뭉치의 경우 책의 내용까지는 궁금하지 않지만, 종이 뭉치를 읽게 봐야 한다. ''
책의 제목 = 메서드로 추출한 호출부 (추상화 레벨이 높다.)
제목이 없는 종이 뭉치 = 추출되지 않은 구현부 (추상화 레벨이 낮다.)
코드도 동일하다. 같은 클래스, 함수 내에 있는 코드의 추상화 레벨은 동등해야 한다. 추상화 레벨이 동등해야, 코드를 읽으면서 중간중간 멈칫하지 않고 읽을 수 있으며, 자세한 로직/구현부가 궁금하다면 그때서야 구현부를 읽으면 된다.
메서드를 추출하는 이유는 단순히 코드가 길거나, 복잡하거나, 코드에 의미를 부여하기 위한 경우도 있겠지만, 메서드 추출은 추상화 레벨을 동등하기 맞추기 위한 것이다.
Early Return
else if 나 else를 사용하게 된다면, 앞 선 모든 조건을 다 기억하고 있어야 한다.
[AS-IS]
if (조건1) { doSomething1(); }
else if (조건2) {doSomething2(); }
else { doSomething3(); }
[TO-BE]
if (조건1) { doSomething1(); return; }
if (조건2) { doSomething2(); return; }
doSomething3();
- AS-IS 코드에서 doSomething3()가 호출되는 경우를 따지기 위해서는, 조건 1과 조건 2를 모두 기억하면서 코드를 읽어야 한다.
- 조건을 메서드로 추출하여 아래와 같이 Early return을 사용하여야, else 사용을 지양하자.
사고의 Depth 줄이기
중첩 분기문, 중첩 반복문
[AS-IS]
for (int i = 0; i < 20; i++) {
for (int j = 20; j < 30; j++) {
if (i >= 10 && j < 25) {
doSomething();
}
}
}
[TO-BE]
for (int i = 0; i < 20; i++) {
doSomeThingWithI(i);
}
private void doSomeThindWithI(int i) {
for (int j = 20; j < 30; j++) {
doSomeThingWithIJ(i, j);
}
}
private void doSomeThingWithIJ(int i, int j) {
if (i >= 10 && j < 25) {
doSomething();
}
}
- 무조건 1 depth로 만들어야 한다. 라기보다는 추상화를 통한 사고 과정의 depth를 줄이는 것이 중요하다.
- 2중 중첩 구조로 표현하는 것이 사고하는 데에 더 도움이 된다고 판단한다면, 그대로 두는 것이 더 나을 수 있다.
사용하는 변수는 가깝게 선언하기
[AS-IS]
int i = 20;
// 코드 10줄
int j = i + 40;
[TO-BE]
// 코드 10줄
int i = 20;
int j = i + 40;
- AS-IS와 같이 작성하게 되면, 변수 i를 읽고 코드 10줄을 읽고, j = i + 40 라인을 보았을 때, i 가 뭐였지 생각하게 된다.
- 사용하는 변수는 사용하는 라인과 가깝게 선언하는 것이 적절하다.
공백 라인
- 공백 라인도 의미를 가질 수 있다.
- 복잡한 로직의 의미 단위를 나누어 보여줌으로써 읽는 사람에게 추가적인 정보를 전달할 수 있다.
- 공백을 통해 읽는 사람에게 "이런 단락을 통해 이해했으면 좋겠어"라는 메시지를 전달할 수 있다.
부정 조건문
[AS-IS]
if (!isLeftDirection()) {
doSomething();
}
[TO-BE]
if (isRightDirection()) {
doSomething();
}
if (isNotLeftDirection()) {
doSomething();
}
- !isLeftDirection 이라는 함수를 읽을 때, isLeftDirection()을 읽고, 다시한번 !isLeftDirection() 을 읽게 된다. 두 번의 사고 과정이 필요하다.
- 부정 조건문을 긍정 조건문으로 변경하자.
- 부정의 의미를 담은 다른 단어가 존재하는지 체크하자 (!isLeftDirection → isRightDirection)
- 없다면, 부정어구로 메서드명을 구성하자 (!isLeftDirection →isNotLeftDirection)
해피 케이스와 예외처리
예외처리를 꼼꼼하게 하는 것 또한 개발자의 역량이다.
- 예외가 발생할 가능성 낮추기
- 어떤 값의 검증이 필요한 부분은 주로 외부 세계와의 접점 (사용자 입력, 객체 생성자, 외부 서버의 요청 등)
- 의도한 예외와 예상하지 못한 예외를 구분하기 (사용자에게 보여줄 예외와 개발자가 보고 처리해야 할 예외 구분)
NULL
- 항상 NPE을 방지하는 방향으로 경각심을 가져야 한다.
- 메서드를 설계할 때 return null을 자제하자. Optional 사용을 고민해 보자.
Optional
- 비싼 객체이다. 꼭 필요한 상황에서만 반환 타입에 사용하자.
- Optional을 파라미터로 받지 않도록 한다. 반환값에만 사용한다.
- isPresent()-get() 대신 풍부한 API 사용 (orElseGet(), orElseThrow(), ifPresent(), ifPresentOrElse())
- orElse(), orElseGet() 차이 숙지하자.
- orElse() : 항상 실행, 확정된 값일 때 사용 ( ex. orElse(0) )
- orElseGet() : null인 경우 실행, 값을 제공하는 동작 정의 ( ex. orElseGet(() → performanceHeavy())
객체 설계하기
객체의 책임이 나뉨에 따라 객체 간 협력이 발생하며, 객체 간의 협력은 공개 메서드 선언부를 통해 이루어지게 된다.
(1) 새로운 객체를 만들 때, 1개의 관심사로 명확하게 책임이 정의되어 있는지 확인하자. 해당 객체가 외부와 어떤 소통을 하려는지 고민하는 것이 필요하다.
(2) 생성자, 정적 팩토리 메서드에서 유효성 검증이 가능하다. 도메인에 특화된 검증 로직이 포함될 수 있다.
(3) setter 사용 자제하자
- 데이터는 불변이 최고이다. 변하는 데이터라도 객체가 핸들링할 수 있어야 한다.
- 객체 내부에서 외부 개입 없이 자체적인 변경/가공으로 처리할 수 있는지 확인해야 한다.
- 만약 외부에서 가지고 있는 데이터로 데이터 변경을 해야 하는 경우, setter 보다는 update~ 와 같이 의도를 드러내는 네이밍을 고려해 보자.
(4) getter 도 처음에는 사용을 자제해 보자.
Person person = new Person();
[AS-IS]
if (person.get지갑().get신분증().findAge() >= 19) { pass(); }
[TO-BE]
if (persion.isAgeGreaterThanOrEqualTo(19)) { pass(); }
- getter를 사용하는 것보다는, 객체에 메시지를 보내는 것이 중요하다.
- getter를 통해 캡슐화가 깨질 수 있다.
(5) 필드의 수는 적을수록 좋다.
- 불필요한 데이터가 많을수록 복잡도가 높아지고 대응할 변화가 많아진다.
상속과 조합
- 상속보다는 조합을 사용하자.
- 상속은 부모와 자식의 결합도가 높아져 수정하기 어렵다. 자식이 부모의 스펙을 잘 알고 있어야 하며, 부모 클래스 수정 시, 모든 자식도 변경된다.
- 조합과 인터페이스를 활용하는 것이 더 유연하다. 상속을 통한 코드 중복 제거가 주는 이점보다, 중복이 생기더라도 유연한 구조가 더 많은 이점을 준다.
상속 : 상속을 통해 부모 클래스의 속성과 메서드를 자식 클래스가 물려받을 수 있다.
조합 : 객체가 다른 객체를 포함하여 기능을 확장하는 방법입니다. 이는 상속보다 더 유연하고, 객체 간의 결합도를 낮출 수 있습니다.
Value Object
도메인의 어떤 개념을 추상화하여 표현한 객체이다. 아래 특징을 가진다.
- 불변성 : final 필드사용, setter 사용금지
- 동등성 : 서로 다른 인스턴스여도 내부의 값이 같으면 같은 객체로 취급한다. equals() & hashCode() 재정의가 필요하다.
- 유효성 검증 : 객체가 생성되는 시점에 값에 대한 유효성을 보장한다.
public class Money {
private final long value;
public Money(long value) {
if (value < 0) {
throw new IllegalArgumentException("돈은 0원 이상이어야 합니다.");
}
this.value = value;
}
public Money addMoney(Money money) {
return new Money(this.value + money.getValue());
}
// equal() & hashCode() 재정의
}
일급 컬렉션
컬렉션을 포장하면서, 컬렉션만을 유일하게 필드로 가지는 객체이다.
- 컬렉션을 추상화하여 의미를 담을 수 있고, 가공 로직을 포함할 수 있다.
- 가공 로직에 대한 테스트 코드도 작성 가능하다. (비공개 메서드로 정의된 로직이 일급 컬렉션을 통해 public으로 변경될 수 있어 테스트가 가능하다.)
- getter로 컬렉션을 반환하는 경우, 새로운 컬렉션을 만들어서 반환해야 한다.
class CreditCards {
private final List<CreditCard> cards;
// 생성자
public List<CreditCard> findValidCards() {
return this.cards.stream()
.filter(CreditCard::isValid)
.toList();
}
}
Enum의 특성과 활용
- 상수의 집합이며, 상수와 관련된 로직을 담을 수 있는 공간이다.
- 특정 도메인 개념에 대해 그 종류와 기능을 명시적으로 표현해 줄 수 있다.
- 만약 변경이 정말 잦은 개념은, Enum 보다 DB로 관리하는 것이 나을 수도 있다.
다형성 활용하기
반복적인 if 문을 단순하게 만들어볼 수 없을까?라는 고민으로 시작된다.
다형성을 활용하기 위해서는 아래 경우가 있어야 한다.
어떤 조건을 만족하면, 그 조건에 해당하는 행위를 수행한다.
OCP를 지키기 위해서
- 변화하는 것 : 조건 & 행위 (구체)
- 변화하지 않는 것 : 조건을 만족하는가? 행위를 수행한다. (추상)
변하하는 것과 변하지 않는 것을 분리하여 추상화하고, OCP를 지키는 구조이다.
public interface CellSignProvidable { // CellSign 을 제공할 수 있는
boolean supports(CellSnapshot cellSnapshot);
String provide(CellSnapshot cellSnapshot);
}
Sign이 추가된다면, 기존 코드를 수정하지 않고 NewSignProvider 클래스를 추가하면 된다.
public class EmptyCellSignProvider implements CellSignProvidable {
private static final String EMPTY_SIGN = "■";
@Override
public boolean supports(CellSnapshot cellSnapshot) {
return cellSnapshot.isSameStatus(CellSnapShotStatus.EMPTY);
}
@Override
public String provide(CellSnapshot cellSnapshot) {
return EMPTY_SIGN;
}
}
public class FlagCellSignProvider implements CellSignProvidable {
private static final String FLAG_SIGN = "⚑";
@Override
public boolean supports(CellSnapshot cellSnapshot) {
return cellSnapshot.isSameStatus(CellSnapShotStatus.FLAG);
}
@Override
public String provide(CellSnapshot cellSnapshot) {
return FLAG_SIGN;
}
}
public class LandMineCellSignProvider implements CellSignProvidable {
private static final String LAND_MINE_SIGN = "☼";
@Override
public boolean supports(CellSnapshot cellSnapshot) {
return cellSnapshot.isSameStatus(CellSnapShotStatus.LAND_MINE);
}
@Override
public String provide(CellSnapshot cellSnapshot) {
return LAND_MINE_SIGN;
}
}
위와 같이 클래스가 너무 많아진다면, 하나의 enum으로 관리할 수 있다.
public enum CellSignProvider implements CellSignProvidable {
EMPTY(CellSnapShotStatus.EMPTY) {
@Override
public String provide(CellSnapshot cellSnapshot) {
return EMPTY_SIGN;
}
},
FLAG(CellSnapShotStatus.FLAG){
@Override
public String provide(CellSnapshot cellSnapshot) {
return FLAG_SIGN;
}
},
LAND_MINE(CellSnapShotStatus.LAND_MINE){
@Override
public String provide(CellSnapshot cellSnapshot) {
return LAND_MINE_SIGN;
}
},
NUMBER(CellSnapShotStatus.NUMBER){
@Override
public String provide(CellSnapshot cellSnapshot) {
return String.valueOf(cellSnapshot.getNearbyLandMineCount());
}
},
UNCHECKED(CellSnapShotStatus.UNCHECKED){
@Override
public String provide(CellSnapshot cellSnapshot) {
return UNCHECKED_SIGN;
}
},
;
private final CellSnapShotStatus status;
private static final String EMPTY_SIGN = "■";
private static final String FLAG_SIGN = "⚑";
private static final String LAND_MINE_SIGN = "☼";
private static final String UNCHECKED_SIGN = "□";
CellSignProvider(CellSnapShotStatus status) {
this.status = status;
}
@Override
public boolean supports(CellSnapshot cellSnapshot) {
return cellSnapshot.isSameStatus(status);
}
public static String findCellSignFrom(CellSnapshot snapshot) {
CellSignProvider cellSignProvider = findBy(snapshot);
return cellSignProvider.provide(snapshot);
}
private static CellSignProvider findBy(CellSnapshot snapshot) {
return Arrays.stream(values())
.filter(provider -> provider.supports(snapshot))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("확인할 수 없는 셀입니다."));
}
}
주석의 양면성
주석은 세게 말하면 죄악이다. 비즈니스 요구사항을 코드에 잘 녹이지 못해, 주석을 사용하는 경우가 많을 것이다.
- 코드를 설명하는 주석을 쓰면, 코드가 아닌 주석에 의존하게 된다.
- 적절하지 않은 추상화 레벨로 인해 코드 품질이 낮아진다.
- 코드가 변경될 때, 주석도 같이 업데이트해주어야 한다.
좋은 주석도 있다.
- 히스토리를 전혀 알 수 없는 코드에 대한 주석
- 의사 결정의 히스토리를 도저히 코드로 표현할 수 없을 경우에는 주석으로 상세하게 설명하자.
모든 표현 방법을 총동원하여 코드에 의도를 녹여내고, 그럼에도 불구하고 전달해야 할 정보가 있을 경우에만 주석을 사용하자.
변수와 메서드 나열 순서
(1) 변수는 사용하는 순서대로 나열한다.
(2) 메서드의 순서
- 공개 메서드를 상단에 배치
- 객체는 협력을 위한 존재이다. 외부 세계에 내가 어떤 기능을 제공할 수 있는지를 드러내자.
- 공개 메서드끼리도 기준을 가지고 배치하는 것이 좋다.
- 중요도 순, 종류별로 그룹화하여 배치하면 실수로 비슷한 로직의 메서드를 중복으로 만드는 것을 방지하고, 일관성 있는 로직을 유지할 수 있다.
- 상태 변경 >> 판별 ≥ 조회 메서드
- 비공개 메서드는 공개 메서드에서 언급된 순서대로 배치한다.
- 공통으로 사용하는 메서드라면, 가장 하단과 같은 적당한 곳에 배치한다.
중요한 것은, 나열 순서로도 의도와 정보를 전달할 수 있다는 것이다.
기억하면 좋은 조언들
능동적 읽기
복잡하거나 엉망인 코드를 읽고 이해하려 할 때, 리팩터링을 하면서 읽자
- 공백으로 단락을 구분하고,
- 메서드와 객체로 추상화하고,
- 주석으로 이해한 내용을 표기하며 읽고
우리에게는 "git reset --hard" 을 마음껏 쓸 수 있기 때문에 능동적이고 적극적으로 리팩터링을 하면서 "우리의 도메인 지식을 늘리고, 이전 개발자의 의도를 파악해 보자"
오버 엔지니어링
필요한 적정 수준보다 더 높은 수준의 엔지니어링을 말한다. 예를 들어 구현체가 하나인 인터페이스와 같이.
- 인터페이스 형태가 아키텍처 이해에 도움을 주거나, 근시일 내에 구현체가 추가될 가능성이 높다면 괜찮다.
- 하지만 그렇지 않을 경우, 구현체를 수정할 때마다 인터페이스도 수정해야 한다.
- 코드 탐색에도 영향을 주며, 애플리케이션이 비대해지는 단점이 있다.
이른 추상화도 동일하다.
- 정보가 숨겨지기 때문에 복잡도 더 높아질 수도 있다.
- 후대 개발자들이 선대의 개발자의 의도를 파악하기 어렵게 만들 수도 있다.
은탄환은 없다.
클린 코드도 인탄환이 아니다.
- 실제 실무는 지속 가능한 소프트웨어의 품질 vs 기술 부채를 안고 가는 빠른 결과물 이 두 가지 사이의 줄다리기이다.
- 대부분의 회사는 돈을 벌고 성장해야 하고, 시장에서 빠르게 살아남는 것이 목표이기 때문이다.
- 이런 경우, 클린 코드를 추구하지 말라는 것이 아니라, 미래 시점에 잘 고치도록 할 수 있는 코드 센스가 필요하다.
끗.
실제 강의의 모든 내용을 담지는 못했습니다. 기회가 된다면,
"Readable Code: 읽기 좋은 코드를 작성하는 사고법" 강의를 다시한번 추천드립니다.
'개발' 카테고리의 다른 글
[Spring] MDC, 로그 트레이싱하기 (0) | 2024.04.02 |
---|---|
[Spring] @OneToOne 에서 N+1 문제 해결하기 (0) | 2024.03.18 |
ERD 설계 이후 개발하면서 반정규화 하기 (0) | 2024.03.17 |
[Test] Test Code에 필요한 Test Fixture 재사용하기 (0) | 2023.07.11 |