[Clean Coding] 10. Classes

라을·2024년 12월 10일

Clean Coding

목록 보기
10/10

지금까지는 코드 라인을 어떻게 잘 쓸 수 있는지에 포커스를 두었다. 이번 챕터에서는 더 상위 레벨인 클래스를 잘 작성하는 방법에 대해 살펴보자!

클래스와 오브젝트의 차이

  • Class : 객체에 대한 정의
  • Object : 클래스의 인스턴스

🖋️ Class Organization

Java에서의 클래스 표준 관례를 살펴보자

  1. Public static constants
  2. Private static variables
  3. Private instance variables
  4. Public functions
  5. Private functions (or private utilities)

앞서 클래스 혹은 메서드의 내부는 숨겨야한다고 했다. 하지만, public variable을 가지면 좋은 이유가 드물게 있다.

이것은 step-down rule을 따르고, 뉴스 기사처럼 프로그램을 잘 읽을 수 있게 도와주기도 한다

아래 코드를 보면 알 수 있듯이 바로 static을 통해 public variable으로서 사용할 때이다!

public class Car {
	//pubic static constants
    final public static int MAX_SPEED = 200;
    
    //private static variables
    private static int numberOfCars = 0;
    
    //priavte instance variables
    private int year;
    private String owner;
    
    //public functions
    public Car(int year, String owner) {...}
    public static int getNumberOfCars() { return numberOfCars;}
    public int getYear() { return year; }
    public String getOwner() { return owner; }
    public void drive() { ...}
    ...
    
    //private functions
    private void changeGears(int gear) {...}
    private int checkGasCondition() {...}
  • MAX_SPEED처럼 어떤 객체든 상관이 없는 코드라면 의무적으로 static을 붙여줘야 한다.
  • static이 없으면 car의 객체를 만들어서 '.'으로만 접근이 가능하다
  • 이 클래스의 객체들은 static 변수들을 모두 볼 수 있고 수정할 수 있다
  • private 변수는 객체에 달려있는 변수로, 특정한 객체에 가서만 볼 수가 있다
  • private 변수는 test를 하기 위해 protected로 열어주기도 한다
  • main에 static이 붙여있는 이유는, 어느 특정 객체에 국한된 것이 아니고 누구나 접근이 가능하도록 하기 위함이다

Static이란

자바에서 static 키워드는 클래스 레벨에서 동작하는 요소를 정의할 때 사용되며, 인스턴스에 귀속되지 않고 클래스 자체에 속하는 것을 의미한다

이를 통해 메모리 효율성을 높이고, 특정 상황에서 코드의 간결성을 유지할 수 있다.

static의 주요 역할과 특징은 다음과 같ㅋ다

  1. static 변수 (클래스 변수)
  • 특정 데이터가 클래스 전체에서 공통으로 사용되어야 할 때 적합함
  • 인스턴스 없이 클래스 이름으로 접근 가능
  • 모든 객체가 동일한 값을 참조
  • 값이 변경되면 모든 인스턴스에 영향을 미침

  1. static 메서드
  • 주로 인스턴스 변수에 의존하지 않고 독립적으로 동작하는 유틸리티 메서드를 작성할 때 사용된다
  • 클래스 이름으로 직접 호출 가능
  • 인스턴스 변수나 메서드에 접근할 수 있음 (객체와 관련이 없으므로)
  • this나 super 키워드를 사용할 수 없음

  1. static 블록
  • 클래스 로드 시 한 번 실행되며, static 변수의 초기화에 사용된다
  • 복잡한 초기화 로직이 필요한 경우 유용하다

  1. static 클래스 (중첩 클래스)
  • 내부 클래스(inner class)를 static으로 선언하면, 외부 클래스의 인스턴스 없이도 접근할 수 있다
  • 외부 클래스와 독립적으로 동작해야 하는 내부 클래스를 정의할 때 사용한다

  1. static import
  • import static을 사용하면 클래스 이름 없이 static 변수나 메서드를 사용할 수 있다
  • 주로 자주 사용하는 상수나 유틸리티 메서드에 활용된다

Encapsulation

우리는 변수와 활용할 기능을 private하게 하고 싶다

위에서 언급했듯이, 테스트를 하기 위해서 protected or package로 선언하기도 한다

하지만, 우리는 우선 privacy를 유지할 수 있는 방법을 살펴볼 것이다. 캡슐화를 느슨하게 하는 것은 언제나 마지막의 일!


🖋️ Classes should be small

항상 얘기해왔지만! 클래스는 작아야 한다. 생각한 것보다 더 작아야 한다

얼만큼?

  • Function : 라인 개수로 판단
  • Class : 몇가지 역할(responsiblity)을 가지고 있는가로 판단

이 역할은 무엇을 의미할까?

🔻 Responsibilities

이러한 경우.. 책임이 굉장히 많은 것이다
누가봐도 그렇지만..^^

70개가 넘는 public method로 클래스가 구성되어있는 이 코드의 문제점은 무엇일까?

  1. 재활용 할 수가 없음

  2. Maintenance 측면 : 함수를 하나 넣으려고 하더라도 그 함수가 어디까지 여파를 미칠지 알 수 없다. 따라서 하나하나 검토를 해야만 한다

  3. 메서드 간 연관성이 생길 수밖에 없어진다 -> dependency가 발생하여 하나가 다른 하나를 호출하는 형태가 발생함!

그렇다면 클래스가 비교적 작을 때를 살펴보자

이 클래스는 GUR 얘기와 버전과 관련된 얘기로, 두 가지의 역할을 하고 있다!

이런 경우, 하나를 다른 클래스로 뽑아낸 다음에 필요시 new로 객체를 생성해서 사용하면 된다.

🔻 클래스명

클래스의 이름은 반드시 그것이 어떤 역할을 하고 있는지가 드러나도록 작명되어야 한다

만약 클래스가 하고 있는 것에 대하여 클래스명을 간결하게 작성할 수 없다면, 클래스가 너무 크다는 것을 반증한다

클래스명이 모호해질수록 클래스가 너무 많은 역할을 가진다는 것을 말해준다. 예를 들어, recoveryManager가 아니라 Processor or Manager이라면 너무 큰 범위를 다루고 있는 클래스인 셈이다

또한, 클래스에 대해서 설명을 할 때 'if', 'and', 'or', 'but'이라는 단어들 없이 25자 안으로 설명이 가능해야 한다


🖋️ SOLID

📌 SRP (Single Responsibility Priniciple)

클래스나 모듈은 하나만 바꿀 의도를 가지고 있어야 한다

public class SuperDashBoard extends JFrame implements MetaDataUser {
	public Component getLastFocusedComponent()
    public void setLastFocused(Component lastFocused)
    public int getMajorVersionNumber()
    public int getMinorVersionNumber()
    public int getBuildNumber()
}

SuperDashBoard 클래스도 두 가지 역할을 하고 있기에 바뀌어야 하는 것!

바꾸면 아래와 같다

public class SuperDashboard extends JFrame implements MetaDataUser {
	public Component getLastFocusedComponent()
    public void setLastFocused(Component lastFocused)
}
public class Version {
	public int getMajorVersionNumber()
    public int getMinorVersionNumber()
    public int getBuildNumber()
}

이제 하나의 역할만 가진 클래스가 된것!

Why SRP?

SRP는 OO design에서 가장 중요한 개념중 하나이다

비교를 해보자면..

클래스가 커지면 내부고주도 복잡해질 수밖에 없다
그러면 전체를 이해하기 어려워지고,
유지보수가 어려워지고,
테스트 측면에서도 어려워지는 것!

🔻 Cohesive Class

클래스는 응집도를 가지고 있어야 한다
응집도 큰 것이 좋은 것이고, 응집도가 크냐, 작냐에 따라 클래스가 크다, 작다라고 말할 수 있다

그렇다면 언제 응집도가 크다고 말할 수 있는 것일까?

메소드가 클래스의 변수를 다 접근하고 있다면 응집도가 높은 것이다! 각각의 메서드를 보고 메서드가 클래스의 변수를 접근하고 있는가를 살펴보고, 접근하는 개수가 적다면 응집다고 낮다고 이야기한다

예시 코드를 살펴보자

Stack을 구현하고 있는 클래스이기에, 어떤 스택이든 공통적으로 제공해야 하는 기능 세가지를 메서드로 구현한 상태이다

size() 메서드만이 선언된 두 변수를 모두 사용하는 것을 실패했다. 하지만 떼어내기 어려운 메서드이기 때문에 응집도가 높다고 이야기 할 수 있다

Maintaining Cohesion

자신이 접근하는 변수들로 구성된 클래스로 분리를 해서 응집도를 높일 수 있다

정리를 하자면, 클래스가 작은지 큰지는 역할 관점과, 응집도 관점에서 나누어 볼 수 있는 것!


함수를 작은 단위로 나누는 중요성

"우리는 큰 함수를 작은 함수들로 나누어야 한다"는 점을 상기시키고 있다.작은 함수로 나누는 것은 코드의 가독성과 유지보수성을 높이는 데 도움을 준다.

클래스의 응집도 손실 가능성

큰 함수를 작은 함수들로 나누면, 클래스의 응집도가 낮아질 수 있다고 경고한다. 이는 작은 함수들이 클래스의 모든 인스턴스 변수(instance variables)를 사용하지 않을 경우 발생한다.

응집도를 유지하기 위한 클래스 분리

클래스가 응집도를 잃게 되면, 관련된 메서드들을 기준으로 클래스를 나누는 것이 필요하다.이렇게 하면 각 클래스의 응집도가 높아지고, 설계가 더 명확하고 깔끔해질 수 있다.

클래스를 나누면 결과적으로 더 많은 작은 클래스들이 생성된다. 하지만 이는 프로그램을 더 잘 조직화하게 해준다!


또 다른 예시를 살펴보면...

Editor에서 너무 많은 일들이 수행되고 있다. Editor라는 클래스명에 맞는 메서드로만 클래스를 구성하는 것이 중요하다


📌 OCP (Open-Closed Principle)

우리는 클래스가 변화에 잘 적응할 수 있도록 구성을 해야 한다

구조를 잘 설계한다면 어떤 새로운 기능을 넣을 때 기존의 코드를 건드리지 않고 새로운 클래스를 추가하는 것이 가능해진다!

how? -> 상속을 이용하면 된다!

OCP는 확장에는 open하고 수정에는 close한 태도를 가져야 한다는 원리다

즉, 기능을 추가할 때 기존의 코드를 건드리지 않은채 추가하여 확장하자는 것!

class C {
	void f();
}

class C' extends C {
	void g() ..
}

이렇게 하면 C를 수정하지 않으면서 g()를 추가할 수 있다!

또 따른 예시를 살펴보자

public class Sql {
    public Sql(String table, Column[] columns) // select ~~ from ~~ where ~~
    
    public String create() // create table ___ ~~
    public String insert(Object[] fields)
    public String selectAll() // select * from ~
    public String findByKey(String keyColumn, String keyValue)
    public String select(Column column, String pattern)
    public String select(Criteria criteria)
    public String preparedInsert()

    private String columnList(Column[] columns)
    private String valuesList(Object[] fields, final Column[] columns)
    private String selectWithCriteria(String criteria)
    private String placeholderList(Column[] columns)
}

이 부분은 오버엔지니어링의 여지가 있기 때문에 좋은 예시는 아닐 수 있다. 하지만 일단 책에서 나온 것이기에~

이 코드에서 update, delete와 같은 새로운 기능을 수정하려면 기존의 class를 들어갈 수밖에 없고, public method를 추가할 수밖에 없다. 이것은 위 그림에서의 usual way에 해당하는 것!

이 구조의 문제는, 이렇게 메서드를 클래스에 추가를 하면 전체를 다 테스트 돌려야 한다.

이처럼 새로운 기능을 추가할 때 OCP의 기존 코드는 수정하지 않고 확장해야한다는 원리에 어긋나고, 또 하나의 역할만 가지고 있어야 한다는 SRP의 원리도 어긋나게 되는 형태.

▶︎ Refactored Code

abstract public class Sql { // 앞으로 나를 상속받는 애들은 이 틀을 제거해야~ 라고 하는 템플릿과 같은 개념 (abstract)
    public Sql(String table, Column[] columns)
    abstract public String generate(); // 하나라도 abstract 메서드가 있으면 그 클래스는 abstract 클래스
                                       // 추상메서드: interface만 정의되어 있고 body(본문)는 비어있는 메서드
}

public class CreateSql extends Sql {
    public CreateSql(String table, Column[] columns)
    @Override public String generate()
}

public class SelectSql extends Sql {
    public SelectSql(String table, Column[] columns)
    @Override public String generate()
}

public class InsertSql extends Sql {
    public InsertSql(String table, Column[] columns, Object[] fields)
    @Override public String generate()
    private String valuesList(Object[] fields, final Column[] columns)
}

public class SelectWithCriteriaSql extends Sql {
    public SelectWithCriteriaSql(
        String table, Column[] columns, Criteria criteria)
    @Override public String generate()
}

public class SelectWithMatchSql extends Sql {
    public SelectWithMatchSql(
        String table, Column[] columns, Column column, String pattern)
    @Override public String generate()
}

public class FindByKeySql extends Sql {
    public FindByKeySql(
        String table, Column[] columns, String keyColumn, String keyValue)
    @Override public String generate()
}

public class PreparedInsertSql extends Sql {
    public PreparedInsertSql(String table, Column[] columns)
    @Override public String generate()
    **private String placeholderList(Column[] columns)**
}

public class Where { // 필요할 때만 가져와서 new로 만들어 쓸 때
    public Where(String criteria)
    public String generate()
}

public class UpdateSql extends Sql { // 이 경우 OCP를 만족한다고 봄!
    @Override
    public String generate() {
        ...
    }
}

public class ColumnList {
	public ColumnList(Column[] columns)
    public String generate()
}
  • abstract는 앞으로 나를 상속받는 애들은 이 틀을 지켜야해~ 라고 하는 템플릿과 같으 개념이다
  • 하나라도 abstract 메서드가 있으면 그 클래스는 abstract 클래스가 된다
  • 추상 메서드는, interface만 정의되어 있고 본체는 비어있는 메서드를 말한다

이렇게 상속을 받아서 작성을 하면, 필요한 애만 가져와서 new로 만들면 된다!

s = new CreateSql();
s  = new SelectSql();

s.generate();

또 하나 봐야할 것이, Where과 ColumnList 클래스가 분리되었는데, 이는 여러군데서 많이 쓰이기 때문에 중복을 피하기 위해서 떼어놓은 것이다!

장점

  • 새로운 것을 추가했을 때 다른 애들이 영향을 받지 않음
  • 이해하기 쉬워짐
  • 새로운 것을 추가할 때 기존의 코드를 recompile, retest 할 필요가 없어짐
  • 조각조각내면 SRP도 지키고 OCP도 지킬 수 있다

Isolating from Change

  • Abstract Class : 개념만 표현
    • 껍데기만 가지고 있는, 추상적인 내용만 담음
  • Concrete Class : 디테일한 구현(implementation)을 포함
    • abstract 클래스를 상속 받으면 abstract한 부분을 다 채워놓는 것 (ovveride)
public abstract class Car {
	private string type;
    public abstract void drive();
}


public class Mini extends Car {
	public void drive() {
    	//mini-specific drive code
    }
}

만약 추상 클래스를 객체로 만들면 컴파일 안됨 - 본체가 없기 때문!

ex. Sql s = new Sql();


📌 DIP (Dependency Inversion Principle)

클래스는 concrete 클래스가 아닌, abstract 클래스에 의존해야 한다

만약 concrete class이면 구현 디테일이 바뀌면 다 바뀌어야 하는데, 추상 클래스는 필요할 때마다 갈아낄 수 있다

이 경우 myCar는 Car 타입으로 선언되었기 때문에, Car 클래스 또는 Car 클래스에 선언된 메서드만 호출할 수 있다


다른 예시와 함께 살펴보자

public class Portfolio {
    private TokyoStockExchange exchange = new TokyoStockExchange(); 
    
    public Money value() {
        Money total = new Money(0);
        for (String item : itemList) { // 주식 리스트
            total.add(exchange.currentPrice(item));
        }
        return total;
    }
}

지금 코드의 문제는 Portfolio 클래스 내부에서 지역마다 객체를 생성해주고 있어, 코드를 수정하려면 매우 번거로워진다

이를 해결하기 위해서 StockExchange라는 abstract class를 만들고, 틀을 만들어주기 위해 abstract currentPrice()를 만들어준다

지역마다 이 StockExchange을 상속받아서 사용하면 되기 때문에 매우 편리해짐!

public interface StockExchange {
	Money currentPrice(String symbol);
}

public class TokyoStockExchange implements StockExchange {
	public Money currentPrice(String symbol) { //Tokyo-specific code
}

public class NewYorkStockExchange implements StockExchange {
	public Money currentPrice(String symbol) { //Tokyo-specific code
}

public class HongKongStockExchange implements StockExchange {
	public Money currentPrice(String symbol) { //Tokyo-specific code
}
public class Portfolio {
    private StockExchange exchange; // 추상화에 의존

    public Portfolio(StockExchange exchange) {
        this.exchange = exchange;
    }

    public Money value() {
        Money total = new Money(0);
        for (String item : itemList) { // 주식 리스트
            total.add(exchange.currentPrice(item));

        }
        return total;
    }
}

이처럼 매개변수로 처리하면 된다!!!

그럼 이런 식으로 객체 생성 후 사용이 가능해진다!

📌 LSP (Liskov Substitution Principle)

프로그램의 정확성을 변경하지 않고, 부모 클래스의 객체를 자식 클래스의 객체로 대체할 수 있어야 한다는 원칙

또 다른 이름으로 behavioral subtyping이라 불린다

square은 모든 변의 길이가 같아야 하는데, 만약 직사각형이 부모 클래스로 되어있으면 충돌 발생


📌 ISP (Interface Segregation Principle)

클라이언트는 사용하지 않는 메서드에 의존하면 안 된다는 원칙

하나의 일반적인 큰 인터페이스보다, 작고 구체적인 인터페이스를 여러 개 만드는 것이 더 낫다고 주장

업로드중..

EconomicPrinter는 Printer 인터페이스를 구현하지만, fax()와 scan() 기능을 지원하지 않으므로, 해당 메서드에서 예외를 던져야 한다

이는 클라이언트가 불필요한 메서드에 의존하도록 만들어 ISP를 위반


profile
욕심 많은 공대생

0개의 댓글