[Java] 36. 코딩할 때 자주 사용되는 패턴과 스탭 줄이는 방법


Study/Java  2020. 6. 17. 18:26

안녕하세요. 명월입니다.


이 글은 Java에서 코딩할 때, 자주 사용되는 코딩 패턴과 스탭 줄이는 방법에 대한 글입니다.


이전에 제가 자바 코딩 표준 규약을 설명한 적이 있습니다.

링크 - [Java] 35. 코딩 스타일 설정(Google Standard coding style)


프로그램 프로젝트를 설계하다보면 코딩 규약도 중요하지만 어떻게 설계할 것인가에 대한 디자인 패턴 적용과 스탭을 줄이기 위한 알고리즘을 사용하게 됩니다.

디자인 패턴 적용과 알고리즘의 설명은 따로 카테고리를 만들어서 설명하고 디자인 패턴이라고 하기에는 간단한 패턴이고 알고리즘이라고 하기에는 하나의 팁이라고 할 수 있는 것을 소개할까 합니다.


클래스를 만들 때 인터페이스, 추상 클래스를 활용하기

Java에서는 일반 인터페이스와 다르게 특수한 문법을 구성할 수 있는 인터페이스가 있습니다.

예를 들면 Runnable과 Callable의 경우는 람다식을 만들 수 있고, Clonable는 클래스 복재를 할 수 있습니다. Closeable의 인터페이스는 try ~ catch에서 사용할 수 있는 인터페이스입니다.

Throwable의 경우는 예외처리 클래스를 만들 수 있습니다. Serializable의 경우는 직렬화를 할 수 있는 인터페이스입니다.

import java.io.Closeable;
import java.io.Serializable;
import java.util.concurrent.Executors;
// 데이터형 추상 클래스, Cloneable 인터페이스와 Serializable 인터페이스를 상속
abstract class Data implements Cloneable, Serializable {
  // Serializable 인터페이스의 직렬화 시리얼 키
  private static final long serialVersionUID = 1L;
  // Cloneable 인터페이스를 통해서 클래스를 복제할 수 있다.
  @Override
  public Data clone() {
    // 클래스 복제
    return (Data) this.clone();
  }
  // 추상 메서드
  public abstract void add() throws PrintingException;
}
// 처리형 추상 클래스, Runnable 인터페이스와 Closeable 인터페이스를 상속
abstract class Process implements Runnable, Closeable {
  // Closeable 인터페이스로 close 함수를 재선언 해야 한다.
  @Override
  public void close() {
    System.out.println("close");
  }
}
// 예외 처리 클래스, Throwable 인터페이스를 상속
class PrintingException extends Throwable {
  // Serializable 인터페이스의 직렬화 시리얼 키 (Throwable 인터페이스가 Serializable를 상속 받음)
  private static final long serialVersionUID = 1L;
  // 생성자
  public PrintingException() {
    super("PrintingException");
  }
}
// 데이터형 클래스, Data의 추상 클래스를 상속
class Entity extends Data {
  // Serializable 인터페이스의 직렬화 시리얼 키
  private static final long serialVersionUID = 1L;
  // 맴버 변수
  private int data;
  // data 맴버 변수의 getter
  public int getData() {
    return data;
  }
  // data 맴버 변수의 setter
  public void setData(int data) {
    this.data = data;
  }
  // Data 추상 클래스의 add 를 재정의
  @Override
  public void add() throws PrintingException {
    if (this.data > 50) {
      throw new PrintingException();
    }
    this.data++;
  }
}
// 처리형 클래스, Process의 추상 클래스를 상속
class PrintingProcess extends Process {
  // 맴버 변수
  private Entity entity;
  // entity 맴버 변수의 getter
  public Entity getEntity() {
    return entity;
  }
  // entity 맴버 변수의 setter
  public void setEntity(Entity entity) {
    this.entity = entity;
  }
  // Runnable 인터페이스의 run 함수 재정의
  @Override
  public void run() {
    // 0부터 9까지 반복
    for (int i = 0; i < 10; i++) {
      try {
        // transEntity 함수 호출
        transEntity(entity);
        // 콘솔 출력
        System.out.println(entity.getData());
      } catch (Throwable e) {
        // 예외 처리 콘솔 출력
        e.printStackTrace();
      }
    }
  }
  // 인터페이스 data를 받아서 add 함수를 호출
  private void transEntity(Data entity) throws PrintingException {
    entity.add();
  }
}
public class Example {
  // 실행 ㅎ마수
  public static void main(String[] args) {
    // 자동 close 호출
    try (PrintingProcess process = new PrintingProcess()) {
      // 데이터 선언
      Entity entity = new Entity();
      // 데이터 설정
      entity.setData(10);
      // 처리형 클래스에 데이터를 입력
      process.setEntity(entity);
      // 처리형 클래스를 스레드에 넘김
      Executors.newSingleThreadExecutor().submit(process).get();
    } catch (Throwable e) {
      // 예외 처리 콘솔 출력
      e.printStackTrace();
    }
  }
}

위 예제는 Data형 추상 클래스와 Process 추상 클래스로 나누었습니다.

Data형 추상 클래스는 데이터를 담는 Entity형 클래스이고 Process 추상 클래스는 실행을 담당하는 Controller형 클래스입니다.

그래서 Data형에는 클래스 복제가 가능한 Cloneable 인터페이스와 직렬화를 할 수 있는 Serializable 인터페이스를 상속 받았습니다.

Process형에는 스레드에서 돌리기 위한 Runnable 인터페이스와 닫기가 가능한 Closeable 인터페이스를 상속 받았습니다.

물론 클래스에 바로 인터페이스를 상속받아도 되기는 합니다만, 추상 클래스를 중간에 두어서 클래스를 만들 때, 공통 부분과 파생 부분을 나누어 개발할 수 있습니다.


분기문은 if보다 break, continue

프로그램을 작성할 때 반목분(for) 안에 분기문(if ~ else)를 많이 사용합니다.

여기서 분기문(if ~ else)이 나쁜 문법은 아니고 depth(깊이)가 많은 것이 문제가 됩니다. depth(깊이) 높아지면 가독성이 떨어지고 프로그램이 헷갈리기 쉬워집니다.

가능하면 반복문(for)안에는 if ~ break or continue를 사용하는 것이 좋습니다.

public class Example {
  // 실행 함수
  public static void main(String... args) {
    int sum = 0;
    // 아래와 같이 작성하면 가독성이 떨어진다.
    // 반복문
    for (int i = 0; i < 1000; i++) {
      // 짝수면...
      if (i % 2 == 0) {
        // 반복문
        for (int j = i; j < 1000; j++) {
          // 3의 배수면
          if (j % 3 == 0) {
            // 더한다. 여기까지 4-depth
            sum += i + j;
            // .....
          }
        }
        // .....
      }
    }
    // 콘솔 출력
    System.out.println(sum);
    sum = 0;
    // 아래와 같이 작성하면 depth(깊이)가 깊지 않기 때문에 보기가 조금 편합니다.
    // 반복문
    for (int i = 0; i < 1000; i++) {
      // 홀수면 continue
      if (i % 2 != 0) {
        continue;
      }
      // 반복문
      for (int j = i; j < 1000; j++) {
        // 3의 배수가 아니면 continue
        if (j % 3 != 0) {
          continue;
        }
        // 더한다. 여기까지 2-depth
        sum += i + j;
      }
      // ...
    }
    // 콘솔 출력
    System.out.println(sum);
  }
}

위 예제를 보면 첫번째는 for안에 if를 넣었고 다시 for를 넣었고 if를 넣었습니다.

그러나 두번째는 for안에 if continue를 사용했기 때문에, depth가 더이상 내려가지 않네요. 단순한 for ~ for 구분이 됩니다.


반복문은 함수로 치환

하나의 함수 안에 여러가지 처리식을 넣는 것보단 최대한 함수로 잘게 나누는 것이 유지 관리면에서 편합니다.

특히나 중첩 반복문이면 함수안에 하나의 반복문으로 만드는 것이 좋습니다.

public class Example implements Runnable {
  // 실행 함수
  public static void main(String... args) {
    // 인스턴스 생성
    Example test = new Example();
    // run 함수 호출
    test.run();
  }
  // Runnable 인터페이스의 run함수 재정의
  @Override
  public void run() {
    // 변수 선언
    int sum = 0;
    // sumFirstLoop 함수 호출
    sum = sumFirstLoop(sum);
    // 콘솔 출력
    System.out.println(sum);
  }
  // 첫 번째 루프
  private int sumFirstLoop(int sum) {
    // 반복문
    for (int i = 0; i < 1000; i++) {
      // 짝수가 아니면 continue
      if (i % 2 != 0) {
        continue;
      }
      // sumSecondLoop 함수 호출
      sum = sumSecondLoop(i, sum);
    }
    // 리턴
    return sum;
  }
  // 두번째 루프
  private int sumSecondLoop(int i, int sum) {
    // 반복문
    for (int j = i; j < 1000; j++) {
      // 3의 배수가 아니면 continue
      if (j % 3 != 0) {
        continue;
      }
      // 합을 더하기
      sum += i + j;
      // ...
    }
    // 리턴
    return sum;
  }
}

위 예제는 for ~ if ~ for ~ if를 함수로 나누어서 실행한 것입니다.

함수로 나눈다고 해서 성능의 차이는 크게 없습니다.


전역 변수는 메인 메소드에서만 데이터를 사용하고 계산형 메소드는 파라미터로만 데이터를 받는다.

맴버 변수의 경우는 같은 클래스 내에서는 어디든지 참조할 수 있습니다. 그러나 이것도 변수와 함수가 많고 복잡한 처리하는 클래스의 경우, 맴버 변수를 이곳 저곳에서 참조하면 나중에 값 관리가 힘들어집니다. 이때는 이걸 파라미터로 전달받아야 데이터의 흐름을 알 수 있습니다.

맴버 변수가 많을 경우, 파라미터가 길어질 수도 있는데 맴버 변수를 하나의 데이터 클래스로 묶어서 정리할 수 있습니다.

public class Example implements Runnable {
  // 실행 함수
  public static void main(String... args) {
    // 인스턴스 생성
    Example test = new Example();
    // run 함수 호출
    test.run();
  }
  // 맴버 변수
  private int sum = 0;
  // Runnable 인터페이스의 run함수 재정의
  // 메인 메소드에서는 맴버변수를 사용
  @Override
  public void run() {
    // sumFirstLoop 함수 실행
    sum = sumFirstLoop(sum);
    // 콘솔 출력
    System.out.println(sum);
  }
  // 첫 번째 루프
  // 계산형 함수는 맴버를 직접 참조 하지 않고 파라미터로 데이터를 전달 받는다.
  private int sumFirstLoop(int sum) {
    // 반복문
    for (int i = 0; i < 1000; i++) {
      // 짝수가 아니면 continue
      if (i % 2 != 0) {
        continue;
      }
      // sumSecondLoop 함수 호출
      sum = sumSecondLoop(i, sum);
      // ...
    }
    return sum;
  }
  // 두번째 루프
  // 계산형 함수는 맴버를 직접 참조 하지 않고 파라미터로 데이터를 전달 받는다.
  private int sumSecondLoop(int i, int sum) {
    // 반복문
    for (int j = i; j < 1000; j++) {
      // 3의 배수가 아니면 continue
      if (j % 3 != 0) {
        continue;
      }
      // 합을 더하기
      sum += i + j;
      // ...
    }
    return sum;
  }
}

한 라인에 두 처리 금지

저 같은 경우는 삼항식을 자주 사용하는 편입니다. 삼항식이 무엇이냐면 if ~ else 로 표현할 수 있는 구문을 조건식?참:거짓으로 표현 할 수 있는 문법입니다.

삼항식이 나쁜 문법은 아닙니다만, 삼항식이 중첩이 되었을 경우 가독성이 매우 떨어지게 되어있습니다.

public class Example implements Runnable {
  // 실행 함수
  public static void main(String... args) {
    // 인스턴스 생성
    Example test = new Example();
    // run 함수 호출
    test.run();
  }
  // Runnable 인터페이스의 run함수 재정의
  @Override
  public void run() {
    // 한라인 함수 처리 금지
    System.out.println(add1(add2(add3(0))));
    // 분할
    int temp = 0;
    temp = add3(temp);
    temp = add2(temp);
    temp = add1(temp);
    // 콘솔 실행
    System.out.println(temp);
  }
  // 함수
  private int add1(int data) {
    return data + 1;
  }
  // 함수
  private int add2(int data) {
    return data + 2;
  }
  // 함수
  private int add3(int data) {
    return data + 3;
  }
}

위 식을 보면 add1(add2(add3(0))) 작성하게 되면 어떤 함수부터 실행하는 것조차 헤갈리네요. 가장 안쪽의 add3부터 실행이 됩니다.

굳이 이렇게 암호화할 꺼 없이 스탭이 늘어난다고 성능이 느려지는 건 아니니깐 풀어서 처리하는 게 좋습니다.


참조 반환은 파라미터로, 데이터 반환은 함수 반환식으로

가끔 참조가 가능한 클래스 형(Pass by reference)으로 파라미터를 넘긴 후 반환형으로 데이터를 받는 경우가 있습니다. 이렇게 해도 상관은 없지만 변수명을 달리하게 되면 데이터의 혼동이 올 수 있습니다.

가독성을 위해서라도 클래스는 파라미터로 값은 반환으로 넘기는 식으로 작성을 하는 게 좋습니다.

// 테스트 클래스
class Node {
  // 맴버 변수
  private int node;
  // 맴버 변수의 getter
  public int getNode() {
    return node;
  }
  // 맴버 변수의 setter
  public void setNode(int node) {
    this.node = node;
  }
}
public class Example implements Runnable {
  // 실행 함수
  public static void main(String... args) {
    // 인스턴스 생성
    Example test = new Example();
    // run 함수 호출
    test.run();
  }
  // Runnable 인터페이스의 run함수 재정의
  @Override
  public void run() {
    // 인스턴스 생성
    Node node = new Node();
    // 데이터 설정
    node.setNode(0);
    // 함수 호출 - node가 0이면 true, 1이면 false
    if (trans(node)) {
      // node가 true로 되었다면 node 값은 1로 바뀌었음
      // 인스턴스 값 콘솔 출력
      System.out.println(node.getNode());
    }
  }
  // 값 반환의 종류 클래스는 파라미터, 데이터는 반환식으로 반환한다.
  public boolean trans(Node node) {
    // node node값이 0이면 1을 반환한다. node가 0이 아니면 변하지 않느다
    if (node.getNode() == 0) {
      // node 클래스 값 수정
      node.setNode(1);
      // 반환은 true로
      return true;
    }
    // 반환은 false로
    return false;
  }
}

프로그램 고수 분들은 자기만의 디자인 패턴과 코딩 기법이 있습니다. 위의 기법도 딱 어디에 정해져 있는 규약이나 기법은 아니고 제가 지금까지 개발하면서 이렇게 하면 좀 더 설계가 깔끔하게 되더라 하는 것을 정리해 본 것입니다. 많은 사람들이 참고했으면 하네요.


여기까지 Java에서 코딩할 때 자주 사용되는 패턴과 스탭 줄이는 방법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.