Computer Science/Java

디자인 패턴, 클린 코드, OOP 클린 코드 원리 SOLID 개념과 예시

토마토. 2022. 11. 1. 10:57



서론

웹 개발 동아리에서는 개발 과제를 마치고 나서 코드리뷰를 진행한다. Kotlin Spring 슬랙에 경험이 많은 코드리뷰어 분이 '다른 사람과 공유할 수 있는 깨끗한 코드'를 작성하는 것의 중요성에 대해 말씀해주신 것이 참 기억에 남는다. 기본적인 Coding Convention(indentation, naming convention, camelCase/PascalCase), 코드 길이, git commit 단위 등을 고려해야 한다는 것이었다. 그때 이후로 남들과 공유하는 코드, 코드 작성법에 대해 관심이 많이 생겼는데, 컴퓨터프로그래밍 수업에서도 이를 다룬 수업이 있어 너무나 반가웠다.

아래 내용은 서울대학교 컴퓨터공학부 컴퓨터프로그래밍 수업(이영기 교수님) 중 Design Pattern 강의 내용을 정리한 것이다.

Design Patterns

필요성

코드를 깔끔하고 구조적으로 짜는 것은 매우 중요하다.

디자인 원리와 팁

  • Use clear names
  • Use one word for one concept
  • Make functions smaller
  • Minimize side effects
  • Keep your code DRY
  • Use clear comment

클린 코드

Why clean code?

Functioning code에서 나아가서 Clean code, 즉, 잘 구조화되어있고 관리하기 쉬운 코드를 작성하는 것이 필요하다.
클린 코드는 생산성에 기여한다.

Use clear names

클래스, 변수, 함수의 이름은 그 목적을 분명히 나타낼 수 있어야 한다.
변수명을 약어로 짓는 것을 지양해야 한다.

  • 좋은 변수명의 사례
    int d; // NO
    int ds; // NO
    int dsm; //NO
    

int elapsedTimeInDays; //YES
int daysSinceCreation; // YES
int daysSinceModification; // YES

- 네이밍 컨벤션을 따를 것
```java
const int maxCount = 1;
boolean isChanged = true;
private String name;
public class PersonAddress;
Order[] getAllOrders();

Use one word for one concept

단어를 한 단어로 표현하는 것 뿐만 아니라, 클래스, 함수, 변수 등이 하나의 기능만을 수행하도록 모듈화되어야한다는 것을 의미한다.

  • ex) 헷갈리지 않도록 getter method 이름으로 get, fetch, retrieve 중에 통일하여 사용할 것.
    Class Student {
      String getStudentId(){}
      int getAge(){}
      String getDepartment(){}
    }

Make functions smaller

함수는 하나의 기능만을 수행해야 한다.
함수는 하나의 단계를 정확히 추상화해야 한다.

개발하는 당시에는 편의를 위해 긴 함수를 작성하고 싶어지기 쉽다. 그러나, 협업이나 유지보수를 위해서는 작은 함수가 더 효과적이다.

함수가 15줄을 넘어간다면, 두 개 이상으로 쪼개는 것이 좋다.

ex1) 큰 함수의 예시

void withdrawUI(int id, String password, int amount) {
    for (int i = 0; i < numAccounts; i++) {
        BankAccount account = accounts[i];
        if (account.getId() == id) {
            if (account.password.equals(password)) {
                balance -= amount;
                System.out.println("Withdraw success!");
            } else {
                System.out.println("Authentication fail");
            }
        } else {
            System.out.println("No such account");
        }
    }
}

길이가 적당해보일 수 있으나, withdrawUI 함수는 find account, authentication, withdraw 이렇게 세 기능을 한 함수에서 수행하고 있다.

ex2) 작은 함수로 쪼개기

BankAccount findAccount(int id) {
    for (int i = 0; i < numAccounts; i++) {
        BankAccount account = accounts[i];
        if (account.getId() == id) { 
            return account; 
        }
    }
    return null;
}
void withdrawUI(int id, String password, int amount) {
    BankAccount account = findAccount(id);
    if (account == null) {
        System.out.println("No such account");
    } else if (account.password.equals(password)) {
        balance -= amount;
        System.out.println("Withdraw success!");
    } else {
        System.out.println("Authentication fail");
    }
}

모듈화를 통해 디버깅이 쉬워진다.

Minimize side effects

여기서 side effect이란, 함수 너머에 있는 변수를 변경하는 경우를 의미한다.
전역 변수를 여러 함수에서 변경하는 것이 버그의 원인이 된다.

ex) side effect

public class School {
    private static int totalStudent = 0;
    ArrayList<Student> studentList = new ArrayList<Student>();
    int getNewID() {
        return ++totalStudent;
    }
    void registerStudent(String name) {
        Student newStudent = New Student(name, getNewID());
        studentList.add(newStudent);
        ++totalStudent;
    }
}

totalStudent라는 변수를 여러 함수(getNewID, registerStudent)에서 변경하고 있다.
한 변수는 하나의 함수가 책임지고 업데이트해주는 것이 디버깅에 좋다.

Keep your code DRY!

DRY는 Don't Repeat Yourself의 약어다.
반복을 하는 경우, 코드를 변경하거나 디버깅하기 어려워진다.

ex) repeated code

public class Printer {
    static void printInt(Integer i) {
        System.out.println("Type: Integer, Value: " + i);
    }
    static void printString(String str) {
        System.out.println("Type: String, Value: " + str);
    }
    static void printDouble(Double d) {
        System.out.println("Type: Double, Value: " + d);
    }
    public static void main(String[] args) {
        printInt(2);
        printString("Hello World!");
        printDouble(1.23);
    }
}

반복을 통해 직접 구현하여 코드가 경직되어있다.

ex) Non-repeated code

public class Main {
    static void printVariable(Object object) {
        System.out.printf("Type: %s, Value: %s\n",
        object.getClass().getSimpleName(), object);
    }
    public static void main(String[] args) {
        printVariable(2);
        printVariable("Hello World!");
        printVariable(1.23);
    }
}

Use clear comment

코드만 읽어도 이해가 가도록 하는 것이 우선이다.
주석에서는 추상적인 의도나 코드에서 알 수 없는 추가적인 정보를 제공하는 것이 좋다.

ex) Bad comment

/*
 * Find an account with findAccount,
 * and then return true if account is not null,
 * and the account is authorized with the password
 */

ex) Good comment

/*
 * BankAccount authorization api for external libraries
 */

Code Small Bits and Test

코드에 dependency가 있으므로, 작은 부분을 완성하고 테스트한다.

클린 코드 in OOP

앞서 소개한 내용은 일반적인 프로그래밍에 적용할 수 있는 클린 코드 원리였다.
OOP에서는 어떻게 클린 코드를 작성해야할까?
2000년에 Robert C. Martin이 제안한 SOLID라는 OOP 디자인 원리가 널리 사용된다.
SOLID는 다음의 약어다.

  • Sinlge Responsibility Principle
  • Open-closde Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

Single Responsibility Principle SRP

하나의 클래스는 하나의 이유(작업)만을 가지고 있어야 한다.
SRP 원리는 package, method 등을 설계할 때도 적용될 수 있다.

ex) Design without SRP

public class UserSettingService {
    public void changeEmail(User user) {
        if (checkAccess(user)) {
        // Change user's email.
        }
    }
    public boolean checkAccess(User user) {
        // Verify if the user is valid.
    }
}

User 정보를 Setting하는 클래스에서 Authentication 기능까지를 수행하고 있다.
이는 SRP 원칙을 위반한다.

ex) Design with SRP

public class UserSettingService {
    public void changeEmail(User user) {
    // Change user's email
    }
}
public class SecurityService {
    public boolean checkAccess(User user) {
    // Verify if the user is valid.
    }
}

UserSettingService 클래스에서 User 정보를 설정하고,
Authentication 기능을 SecurityService 클래스에서 수행하도록 수정한 코드다.

Open Closed Principle OCP

Object는 extension에는 열려있어야 하지만, Object 그 자체를 수정할 수는 없도록 되어있어야 한다. 코드 수정이 아니라 상속을 통해 구현하도록 하는 것.

ex) Design without OCP

public class AreaCalculator {
    public double totalArea(Shape[] shapes) {
        double area = 0;
        for (Shape shape : shapes) {
            if (shape instanceof Rectangle) {
                Rectangle rectangle = (Rectangle) shape;
                area += rectangle.width * rectangle.height;
            } else {
                Circle circle = (Circle) shape;
                area += circle.radius * circle.radius * Math.PI;
            }
        }
        return area;
    }
}

모든 세부 구현을 AreaCalculator 클래스에 구현할 때 문제가 생길 수 있다.
도형 클래스를 삼각형, 원 등으로 확장할 수 있게 해주되, 도형 클래스는 수정할 수 없도록 하기.

ex) Design with OCP

public interface Shape {

    double getArea();
}

public class Rectangle implements Shape {

    private double width;
    private double height;

    @Override
    public double getArea() {
        return width * height;
    }
}

public class Circle implements Shape {

    private double radius;

    @Override
    public double getArea() {
        return radius * radius * Math.PI;
    }
}

상속을 통해 확장 구현.

Liskov Substitution Principle LSP

Subclass는 superclass를 확장하지, 기능을 좁히지 않는다.

ex) Design without LSP

abstract class Bird {
    abstract void setLocation(double lon, double lat);
    abstract void setAltitude(double alt);
    abstract void draw();
}
class Penguin extends Bird {
 // Cannot set altitude because penguins cannot fly.
 @Override
 public void setAltitude(double alt) { }
}

개념적으로 Bird는 날 수 있다. 그래서 Bird를 상속하여 Penguin을 구현하였지만, 펭귄은 날지 못하므로 setAltitude 함수를 펭귄 클래스에서 사용할 수 없다.

ex) Design with LSP

abstract class Bird {
    abstract void setLocation(double lon, double lat);
    abstract void draw();
}
abstract class FlyingBird extends Bird {
    abstract void setAltitude(double alt);
}

subclass는 superclass의 모든 기능을 갖고 있다는 기대가 있지 때문에, subclass는 항상 superclass + alpha 형태여야 한다.
따라서 위 예시에서는 Bird class의 setAltitude를 없애 공통적인 기능 setLocation, draw만 남긴다. 그리고 Bird를 상속받은 FlyingBird 클래스를 생성하여 여기서 setAltitude 함수를 정의한다.

Integrate segregation Principle ISP

Interface에서는 모든 subclass에서 공통적으로 사용하는 method를 정의해야 한다.

ex) Design without ISP

Interface Human {
 void run();
 void scubaDive();
 void study();
 void createContents();
 void deliverPizza();
 ...
}

class Student implements Human {
    @Override
    void study() { } 
}

Student 클래스에서는 study 매소드만 필요함에도, scubaDive, deliverPizza 등의 매소드를 모두 구현해야 한다.

ex) Design without ISP #2

public interface ArticleService {
    void list();
    void write();
    void delete();
}
public class UIList implements ArticleService {
    @Override
    public void list() {}
    @Override
    public void write() {}
    @Override
    public void delete() {}
}
public class UIWrite implements ArticleService {
    @Override
    public void list() {}
    @Override
    public void write() {}
    @Override
    public void delete() {}
}

ArticleService 인터페이스에 list, write, delete 메소드가 있다.
위 사례에서는 UIList 클래스는 list만 필요함에도 write, delete까지 구현해야 하며, UIWrite 클래스는 write만 필요함에도 list, delete까지 구현해야 하는 문제가 발생한다.

ex) Design with ISP

public interface ArticleListService {
    void list();
}
public interface ArticleWriteService {
    void write();
}
public interface ArticleDeleteService {
    void delete();
}
public class UIList implements ArticleListService {
    @Override
    public void list() { }
}
public class UIWrite implements ArticleWriteService {
    @Override
    public void write() { }
}

Dependency Inversion Principle

Entity는 abstraction에 의존해야 하며, class 같은 구체적인 구현물에 의존해서는 안된다.
즉, high-level 모듈은 low-level 모듈에 의존해서는 안된다.

  • 다음 순서로 프로그램을 개발하는 것을 추천한다
    • Design packages
    • Design class inheritance tree
    • Implement public methods
    • Implement private methods

ex) Design without DIP

// "Low level Module" equivalent
public class Logger {
    public void logInformation(String logInfo) {
        System.out.println(logInfo);
    }
}

// "High level module" equivalent.
public class Foo {
    // direct dependency to a low level module.
    private Logger logger = new Logger();
    public void doStuff() {
        logger.logInformation("Something important.");
    }
}

high level 모듈은 low level 모듈에 dependency가 만들어져서는 안된다.

ex) Design with DIP

public interface ILogger {
    void logInformation(String logInfo);
}
public class GoodLogger implements ILogger {
    @Override
    public void logInformation(string logInfo) {
        System.out.println(logInfo);
    }
}
public class Foo {
    private ILogger logger;
    public void setLoggerImpl(ILogger loggerImpl) {
        this.logger = loggerImpl;
    }
    public void doStuff() {
        logger.logInformation("Something important.");
    }
}

high-level module에서는 concrete class에 dependency를 만들지 않고, interface 정도까지 정의함.

출처

서울대학교 컴퓨터공학부 컴퓨터프로그래밍 이영기 교수님 수업 - Design Pattern 차시
이영기 교수님 랩 홈페이지 => Human-Centered-Computer Systems Lab