Clean Code - 객체자료 / 예외 / 경계

8 minute read

이게 맞다는 확신은 없다. 오로지 나의 경험과 지혜, 지식을 이용해서 해결해 나갈 뿐이다.


자료추상화

변수 사이에 함수라는 계층을 넣는다고 구현이 저절로 감춰지지는 않는다. 구현을 감추려면 추상화가 필요하다! 그저 조회함수와 설정 함수로 변수를 다룬다고 클래스가 되지는 않는다. 그보다는 추상 인터페이스를 제공해 사용자가 구현을 모른 채 자료의 핵심을 조작할 수 있어야 진정한 의미의 클래스다. 자료를 세세하게 공개하기 보다는 추상적인 개념으로 표한하는 편이 좋다. 아무 생각 없이 조회/설정 함수를 추가하는 방법이 가장 나쁘다.


public interface Vehicle {
  double getPercentFuelRemaining();
}

객체의 대칭/비대칭

절차지향과 객체지향은 상호 보완적인 관계가 존재한다.

  • (자료 구조를 사용하는) 절차적인 코드는 기존 자료 구조를 변경하지 않으면서 새 함수를 추가하기 쉽다. 반면, 객체지향 코드는 기본 함수를 변경하지 않으면서 새 클래스를 추가하기 쉽다.
  • 절차지향 코드는 새로운 자료 구조를 추가하기 어렵다. 그러려면 모든 함수를 고쳐야 한다. 객체 지향 코드는 새로운 함수를 추가하기 어렵다. 그러려면 모든 클래스를 고쳐야 한다.

정리하면, 객체 지향 코드에서 어려운 변경은 절차적인 코드에서 쉬우며, 절차적인 코드에서 어려운 변경은 객체 지향 코드에서 쉽다.!

객체는 동작을 공개하고 자료를 숨긴다. 그래서 기본 동작을 변경하지 않으면서 새 객체 타입을 추가하기는 쉬운 반면에 기존의 객체에 새 동작을 추가하기는 어렵다.
자료구조는 별다른 동작없이 자료를 노출한다.
그래서 기존 자료구조에 새 동작을 추가하기는 쉬우나, 기존 함수에 새 자료 구조를 추가하기는 어렵다.
시스템을 구현할 때,
새로운 자료 타입을 추가하는 유연성이 필요하면 객체가 더 적합하다.
다른 경우로 새로운 동작을 추가하는 유연성이 필요하면 자료구조와 절차적인 코드가 더 적합하다.

  • 절차지향의 예

public class Square {
  public Point topLeft;
  public double side;
}

public class Rectangle {
  public Point topLeft;
  public double height;
  public double width;
}

public class Circle {
  public Point center;
  public double radius;
}

public class Geometry {
  public final double PI = 3.141592653589793;

  public double area(Object shape) throws NoSuchShapeException {
    if( shape instanceof Square ){
      Square s = (Square)shape;
      return s.side * s.side;
    }
    else if ( shape instanceof Rectangle) {
      Rectangle r = (Rectangle)shape;
      return r.height * r.width;
    }
    else if ( shape instanceof Circle) {
      Circle c = (Circle)shape;
      return PI * c.radius * c.radius;
    }

    throw new NoSuchShapeException();
  }
}

  • 객체지향의 예

public class Square implements Shape {
  private Point topLeft;
  private double side;

  public double area(){
    return side * side;
  }
}

public class Rectangle implements Shape {
  private Point topLeft;
  private double height;
  private double width;

  public double area() {
    return height * width;
  }
}

public class Circle implements Shape {
  private Point center;
  private double radius;
  public final double PI = 3.141592653589793;

  public double area() {
    return height * width;
  }
}

절차지향시 고려할 수 있는 대안

노력한 객체 지향 설계자는 VISITOR 혹은 Dual-Patch 등과 같이 잘 알려진 기법을 사용해 이 문제를 해결한다. 하지만 이들 기법 역시 대가가 따르며, 일반적으로 절차적인 프로그램에서 볼수 있는 구조를 반환한다.

  • Visitor Pattern

GOF : 객체 구조를 이루는 원소에 대해 수행할 연산을 표현하는 패턴으로, 연산을 적용할 원소의 클래스를 변경하지 않고도 새로운 연산을 정의할 수 있게 합니다. 알고리즘을 객체 구조에서 분리시키는 방법


public class VisitorDemo {
    static public void main(String[] args){
        // 사업
        Business business = new Business();
        // 전체 사업의 틀에서 어떤 분야를 상세하게 진행할 것인지 
        business.accept(new HospitalBusiness());
        business.accept(new AirplanBusiness());
    }
}

class Plan implements BusinessStructure{
  public void accept(BusiVisitor busiVisitor){
    busiVisitor.visit();
  }
}

class Meeting implements BusinessStructure {
  public void accept(BusiVisitor busiVisitor){
    busiVisitor.visit();
  }
}

class Business implements BusinessStructure {

  private ArrayList<BusinessStructure> elements = null;

  public void AddStartegy(BusiVisitor busiVisitor){
    elements.add(elements);
  }

  public void accept(BusiVisitor busiVisitor){
    for(BusinessStructure busiStrategy : elements ){
      busiStrategy.visit();
    }
    busiVisitor.visit();
  }
}

디미터의 법칙

모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다는 법칙이다.
클래스의 성향이 자료구조냐, 객체냐에 따라 달라짐
객체일 경우, 추상적일 필요가 있음

  • 뭔가를 하려고 이야기를 해야지 속을 드러내서 표현하면 안됨.

기차 충돌


final string outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

흔히 위와 같은 코드를 기차 충돌이라 부른다. 일반적으로 조잡하다고 여겨지는 방식이므로 피하는 편이 좋다. 따라서 아래와 같이 작성하는 것이 더욱 좋다.


Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();

위의 예제가 디미터 법칙을 위반하는지 여부는 ctxt, Options, scratchDir 이 객체인지 아니면 자료 구조인지에 달렸다. 객체라면 내부 구조를 숨겨야 하므로 확실히 디미터 법칙을 위반한다. 반면, 자료 구조라면 당연히 내부 구조를 노출하므로 디미터 법칙이 적용되지 않는다.

잡종구조

잡종 구조는 중요한 기능을 수행하는 함수도 있고, 공개 변수나 공개 조회/설정 함수도 있다. 공개 조회/설정 함수는 비공개 변수를 그대로 노출한다.


String outFile = outputDir + "/" + className.replace('.','/') + ".class";
FileOutputStream fout = new FileOutputStream(outFile);
BufferedOutputStream bos = new BufferedOutputStream(fout);

// 위의 구조보다 아래의 구조가 디미터의 법칙을 위반하지 않는 객체 자료구조를 가지게 된다. 
BufferedOutputStream box = ctxt.createScratchFileStream(classFileName);

자료 전달 객체

자료 구조체의 전형적인 형태는 공개 변수만 있고 함수가 없는 클래스다. 이런 자료 구조체를 때로는 자료 전달 객체라 한다. DTO 는 굉장히 유용한 구조체다. 특히 데이터 베이스와 통신하거나 소켓에서 받은 메시지의 구문을 분석할 때 유용하다.

  • DAO ( Data Access Object) : DB를 사용해 데이터를 조회하거나 조작하는 기능을 전담하도록 만든 오브젝트
  • DTO ( Data Transfer Object ) : VO로 바꿔 말할 수 있는데, 계층간 데이터 교환을 위한 자바빈즈를 말한다. 여기서 말하는 계층간의 컨트롤러,뷰,비즈니스계층을 말하며 각 계층 간의 데이터 교환을 위한 객체

오류 코드 보다는 예외를 사용하라.

코드가 확실히 깨끗해질 수 있다. 앞서 뒤섞였던 개념, 즉 디바이스를 종료하는 알고리즘과 오류를 처리하는 알고리즘을 분리했다.


private DeviceHandle getHandle(DeviceId id){
  ... 
  throw new DeviceShutdownError("Invalid handle for : " + id.toString());
  ... 
}

Try-Catch-Finally

예외에서 프로그램 안에다 범위를 정의한다는 사실은 매우 흥미롭다. try-catch-finally 문에서 try 블록에 들어가는 코드를 실행하면 어느 시점에서든 실행이 중단된 후 catch 블록으로 넘어갈 수 있다.

어떤 면에서 try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 그러므로 예외악 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 낫다. 그러면 try 블록에서 무슨일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.

먼저 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하는 방법을 권장한다. 그러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되므로 범위 내에서 트랜잭션 본질을 유지하기 쉬워진다.


@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName(){

  sectionStore.retrieveSection("invalide - file");

}         

public List<RecordedGrip> retrieveSection(String sectionName){

  try{
    FileInputStream stream = new FileInputStream(sectionName);
    stream.close();
  
  }catch(FileNotFoundException e){
    throw new StorageException("retrieval error", e);
  }

  return new ArrayList<RecordedGrip>();
}

미확인 예외를 사용하라.

확인된 예외는 OCP를 위반한다. 메서드에서 확인된 예외를 던졌는데 catch 블록이 세 단계 위에 있다면 그 사이 메서드 모두가 선언부에 해당 예외를 정의해야 한다. 즉, 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다는 말이다. 확인된 예외를 위해서 메서드에 선언하는 구체적인 예외는 캡슐화를 깨트린다.

예외에 의미를 제공하라.

예외를 던질때는 전수 상황을 충분히 덧 붙인다. 그러면 오류가 발생한 원인과 위치를 찾기가 쉬워진다. 자바는 모든 예외에 호출 스택을 제공한다. 하지만 실패한 코드의 의도를 파악하라면 호출 스택 만으로 부족하다. 오류 메세지에 정보를 담아 예외와 함께 던진다. 실패한 연산 이름과 실패 유형도 언급한다.

호출자를 고려해 예외 클래스를 정의하라.

호출하는 라이브러리 API를 감싸면서 예외 유형을 하나만 반환하는 방식의 처리 방법이 효율적이다. 아래와 같이 외부 API를 사용할 때는 감싸기 기법이 최선이다 ( Adapter 패턴 ). 외부 API를 감싸면 외부 라이브러리와의 의존성이 크게 줄어든다. 나중에 다른 라이브러리로 갈아타도 비용이 적다. 감싸기 기법을 사용하면 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다. 프로그램이 사용하기 편한 API를 정의하면 그만이다.


LocalPort port = new LocalPort(12);

try {
  port.open();
} catch(PortDeviceFailure e){
  reportError(e);
  logger.log(e.getMessage(), e);
} finally {
  ...
}


public class LocalPort {
  private ACMEPort innerPort;

  public LocalPort(int portNumber){
    innerPort = new ACMEPort(portNumber);
  }
}

public void open(){
  try {
    innerPort.open()
  } catch (DeviceResponseException e) {
    throw new PortDeviceFailure(e);
  } catch ( ATM1212UnlockedException e) {
    throw new PortDeviceFailure(e);
  } catch ( GMXError e) {
    throw new PortDeviceFailure(e);    
  } 
  ...
}

정상흐름을 정의하라.

예외에서 코드를 작성하면 예외로 인해서 논리를 따라가기가 어렵다.


// 해당 소스는 로직에 대한 처리를 예외에서 처리하여 분석시 혼란이 가중될 수 있다.                         
try {
  ShoppingExpenses expenses = expenseDAO.getShoppingPrice(shopping.getProductId());
  m_total += expenses.getTotalPrice();
}catch(ShoppingExceptionNotFound e){
  m_total += getExpenseForEntryFee;
}

// 위와 같은 코드는 ShoppingExpenses 객체를 구현하는 클래스를 생성하여 예외가 아닌 정상흐름에서 값을 반환한다. 
public class ExpenseForEntryFee implements ShoppingExpenses {
  public int getTotalPrice(){
      // 쇼핑 내역이 없을 경우, 입장료만 반환한다. 
  }
}
  

null을 반환하지 마라.

오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려하면 튼튼하고 깨끗한 코드를 작성할 수 있다.

기본적으로 null을 사용할 경우 null을 반환받는 로직에서 null에 대한 체크 불가피하게 반드시 들어가게 된다. 이와 같을 때 기본적으로 개발자는 NullPointerException에 대한 두려움이 생길 수 밖에 없다. 메서드에서 null을 반환하고픈 유폭이 든다면 그 대신 예외를 던지거나 특수 사례 객체를 구현한다.


public List<Employee> getEmployees() {
  if ( .. 직원이 없다면 .. ) 
    return Collections.emptyList();
}

null을 전달하지 마라.

메서드에서 null을 반환하는 방식도 나쁘지만 메서드로 null을 전달하는 방식은 더 나쁘다. 정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 null을 전달하는 코드는 최대한 피한다.


public class MoneyCalculator {
  public double xProjection(Point p1, Point p2) {
    if( p1 == null ||  p2 == null){
      throw InvalidArgumentException(
        "Invalide argument for MoneyCalculator"
      )
    } 
    return (p2.x - p1.x) * 1.5;
  }
}

위의 코드 또한 예외를 반환하지만 null을 전달하면 여전히 실행 홍류가 발생한다. 대다수 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없다. 그렇다면 애초에 null을 넘기지 못하다록 금지하는 정책이 합리적이다.

경계를 관리하기

Map 객체를 사용할 때, 제네릭스(Generics)를 사용하면 가독성이 높아진다.


MapString, Business busies = new HashMapString, Business()

하지만 Map 객체를 여기저기 파라미터로 넘겨서는 안된다


// Generics 자체를 넘기기 보다 실제 사용되어어할 클래스를 생성하여 Generics 자체는 숨긴다.                               
Public class Business {
  private Map business = new HashMap();

  public Business getByBusiness(String businessName){
    return (Business)business.get(businessName);
  }
}

즉, Map을 여기저기 넘기지 말라는 이야기이다.

외부 API를 바로 적용하는 것이 아닌 간단한 테스트 케이스를 작성해 외부코드를 익힌다.
학습 테스트
해당 모듈에 대한 별도의 테스트 케이스 모듈을 개발

경계와 관련해 또 다른 유형은 아는 코드와 모르는 코드를 분리하는 경계다.
때로는 우리 지식이 경계를 너머 미치지 못하는 코드 영역도 있다. 외부 시스템에서 인터페이스 개발이 늦어지고,
내부 시스템 동작을 구현해야한다면, 이때 우리는 외부와 내부의 경계를 구분하여 개발할 수 밖에 없다.

이와 같은 경우, 우리가 아직 모르는 경계에 대해서 Adapter 패턴을 구현하고, 내부 프로세스를 설계한다.

  • Adapter 패턴은 신규 인터페이스나 외부 인터페이스 등에 대해서 기존의 시스템에 연동할 때
  • Adapter 패턴을 통해 외부의 경계를 구성하여 내부의 시스템에 최소한의 영향으로 신규 시스템 연결 시 활용할 수 있다.
  • 외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리한다.
  • Map에서 보았듯이, 새로운 클래스로 경계를 감싸거나 아니면 Adapter 패턴을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환한다.