4 기능과 책임 분리

source: categories/study/oop_programming/opp_programming_4.md

4. 기능과 책임 분리

  • 하나의 기능은 여러 하위 기능으로 분해할 수 있다.

  • 암호 변경
    1. 변경 대상 확인
      1. 변경 대상 구함
      2. 대상 없으면 오류 응답
    2. 대상 암호 변경
      1. 암호 일치 여부 확인
        1. 불일치하면 암호 불일치 응답
      2. 암호 데이터 변경

  • "암호 변경 기능"을 구현하기 위해선 "변경 대상 확인" 기능과 "대상 암호 변경" 기능이 필요하다.
  • "변경 대상 확인"하려면 "변경 대상을 구해야돼고" "대상이 없으면 오류를 응답하는" 그런 기능이 필요하다.
  • "대상 암호 변경" 기능은 "암호 일치 여부 확인" 기능과 "암호 데이터 변경" 기능이 필요하다.

  • 이렇게 하나의 기능은 여러 하위 기능을 이용해서 구현하게된다.

기능을 누가 제공할 것인가?

  • 분리한 하위 기능을 누가 제공할지 결정하는 것, 이것이 바로 객체 지향 설계의 기본 과정이다.
  • 즉, 기능을 분리하고 각 객체에게 분리한 기능을 제공할 책임을 배분하는 것이다.

  • 기능은 곧 책임
    • 분리한 각 기능을 알맞게 분배

  • 암호 변경 전체 기능: ChangePasswordService 객체에 할당
  • 변경 대상 구하는 기능: MemberRepository 객체에 할당
  • 대상 암호 변경하는 기능: Member 객체에 할당

  • 이렇게 하위 기능을 알맞은 객체에게 분배하는 식으로 객체 지향 설계를 진행하게 된다.
  • 실제 코드를 보면 분리한 하위 기능을 이용해서 전체 기능을 완성하게 된다.
  • 아래 예에서는 전체 기능에 흐름을 제공하는 ChangePasswordService 클래스가 있는데, 이 클래스는 memberRepositoryMember가 제공하는 기능을 이용해서 전체 암호 변경 기능을 완성하고 있다.


public class ChangePasswordService {
    public Result changePassword(String id, String oldPw, String newPw) {
        Member mem = memberRepository.findOne(id);
        if (mem == null) {
            return Result.NO_MEMBER;
        }
        try {
            mem.changePassword(oldPw, newPw);
            return Result.SUCCESS;
        } catch(BadPasswordException ex) {
            return Result.BAD_PASSWORD;
        }
    }
}


큰 클래스, 큰 메서드

  • 그런데 한 클래스나 메서드가 커지면, 즉 소스 코드가 길다면 절차 지향과 동일한 문제가 발생한다.

  • 클래스나 메서드가 커지면 절차 지향의 문제 발생
    • 큰 클래스 -> 많은 필드를 많은 메서드가 공유
    • 큰 메서드 -> 많은 변수를 많은 코드가 공유
    • 여러 기능이 한 클래스/메서드에 섞여 있을 가능성
  • 책임에 따라 알맞게 코드 분리 필요
    • 클래스나 메서드가 크다면, 알맞은 객체로 분리해서 하위 기능을 분배할 필요가 있다.

몇 가지 책임 분배/분리 방법

  • 클래스나 메서드가 커지지 않도록하는 몇가지 분배/분리 방법이 존재한다.

  • 패턴 적용
  • 계산 기능 분리
  • 외부 연동 분리
  • 조건별 분기는 추상화

패턴 적용

  • 전형적인 역할 분리
    • 간단한 웹
      • 컨트롤러, 서비스, DAO
    • 복잡한 도메인
      • 엔티티, 밸류, 리포지토리, 도메인 서비스
    • AOP
      • Aspect(공통 기능)
    • GoF
      • 팩토리, 빌더, 전략, 템플릿 메서드, 프록시/데코레이터 등

계산 분리



Member mem = memberRepository.findOne(id);
Product prod = productRepository.findOne(prodId);

int payAmount = prod.price() * orderReq.getAmount();
double pointRate = 0.01;
if (mem.getMembership() == GOLD) {
    pointRate = 0.03;
} else if (mem.getMembership() == SILVER) {
    pointRate = 0.02;
}
if (isDoublePointTarget(prod)) {
    pointRate *= 2;
}

int point = (int) (payAmount * pointRate);
// ...


  • 위 코드를 아래와 같이 수정
  • 위 코드는 포인트를 계산하는 코드이다.
  • 위 코드만 아래쪽에 있는 PointCalculator라는 클래스로 분리한다.


Member mem = memberRepository.findOne(id);
Product prod = productRepository.findOne(prodId);

int payAmount = prod.price() * orderReq.getAmount();
PointCalculator cal = new PointCalculator(
    payAmount, mem.getMembership(), prod.getId()
);
int point = cal.calculate();
// ...




public class PointCalculator {
    ...membership, payAmount, prodId // 필드/생성자

    public int calculate() {
        double pointRate = 0.01;
        if (membership == GOLD)
            pointRate = 0.03;
        else if (membership == SILVER)
            pointRate = 0.02;

        if (isDoublePointTarget(prodId)) pointRate *= 2;
        return (int) (payAmount * pointRate);
    }
}


연동 분리

  • 네트워크, 메시징, 파일 등 연동 처리 코드 분리


Product prod = findOne(id);

RestTemplate rest = new RestTemplate();
List<RecoItem> recoItems = 
    rest.get("http://internal/recommend?id=" + prod.getId() + "&user=" + userId + "&category=" + prod.getCategory(), 
        RecoItem.class)


  • 위 코드를 아래와 같이 수정


Product prod = findOne(id);

RecommendService recoService = new RecommendService();
List<RecoItem> recoItems = 
    recoService.getRecoItems(prod.getId(), userId, prod.getCategory());


조건 분기는 추상화

  • 연속적인 if-else는 추상화 고민


String fileUrl = "";
if (fileId.startsWith("local:")) {
    fileUrl = "/files/" + fileId.substring(6);
} else if (fileId.startsWith("ss:")) {
    fileUrl = "http://fileserver/files/" + fileId.substring(3);
}


  • 위 코드를 아래와 같이 수정
  • if-else가 하는 일이 비슷하다면, 공통점을 이용해서 추상화해볼 수 있을 것이다.


FileInfo fileInfo = FileInfo.getFileInfo(fileUrl);
String fileUrl = fileInfo.getUrl();




public interface FileInfo {
    String getUrl();
        
    static FileInfo getFile(...) {...}
}

public class SSFileInfo implements FileInfo {
    private String fileId;

    public String getUrl() {
        return "http://fileserver/files/" + fileId.substring(3);
    }
}


  • 위 예제 코드는 fileId 값에 따라서 file에 대한 Url을 구하고 있다.
  • 여기서 url을 구한다는 것이 공통점이다.
  • 공통된 기능인 url을 제공하는 기능, 그것을 FileInfo라는 걸로 추상화를 해본 것이다.
  • fileId에 따라 url을 구하는 기능은 FileInfo의 각 하위 클래스에서 알맞게 제공하게된다.

  • 이런식으로 조건분기는 추상화와 하위클래스 형태로 역할을 분리해볼 수 있습니다.

주의: 의도가 잘 드러나는 이름 사용

  • 역할을 분리할 때 주의할 점은 이름이다. 이름을 잘 지어야된다.
  • 의미나 의도가 잘 맞는 이름을 사용해야 하는 것이다.

  • 예, HTTP로 추천 데이터 읽어오는 기능 분리시
    • RecommendService > HttpDataService
    • 추천의 의미를 담고 있는 RecommendServiceHttpDataService보다 더 좋은 이름인 것이다. 의도가 더 잘 드러나니깐.

역할 분리와 테스트

  • 이렇게 역할을 분리하고나면 장점이 하나 생긴다.
    • 테스트가 용이해진다는 것이다.

  • 역할 분리가 잘 되면 테스트도 용이해짐
  • 아래 코드에서 포인트를 구하려면 memberRepositoryproductRepository와 관련된
    • Member mem = memberRepository.findOne(id);
    • Product prod = productRepository.findOne(prodId);
    • 이 코드를 실행해야, 나머지 실제 포인트와 관련된 코드를 실행할 수 있게된다.


Member mem = memberRepository.findOne(id);
Product prod = productRepository.findOne(prodId);

int payAmount = prod.price() * orderReq.getAmount();
double pointRate = 0.01;

if (mem.getMembership() == GOLD) {
    pointRate = 0.03;
} else if (mem.getMembership() == SILVER) {
    pointRate = 0.02;
}
if (isDoublePointTarget(prod)) {
    pointRate *= 2;
}

int point = (int) (payAmount * pointRate);
// ...


  • 반면에 아래와 같이 따로 포인트 구하는 코드만 따로 분리해놓으면,
  • 이 코드는 memberRepositoryproductRepository와 상관 없이 PointCalculator만 이용해서 포인트 계산 로직을 테스트할 수가 있게된다.


public class PointCalculator {
    ...membership, payAmount, prodId // 필드/생성자

    public int calculate() {
        double pointRate = 0.01;
        if (mem.getMembership() == GOLD) {
            pointRate = 0.03;
        } else if (mem.getMembership() == SILVER) {
            pointRate = 0.02;
        }

        if (isDoublePointTarget(prodId)) pointRate *= 2;
        return (int) (payAmount * pointRate);
    }
}


  • 이렇게 역할을 잘 분리하게되면, 특정 일부 기능만 테스트하는 것이 훨씬 용이해진다.

분리 연습 1

  1. 계산기능 분리 - (암호화, 복호화)
  2. restTemplate 외부 연동 부분


// CashClient 라는 클래스 
public class CashClient {
    private SecretKeySpec keySpec;
    private IvParameterSpec ivSpec;
    
    // post 라는 메소드를 가지고 있고 파라미터로 req를 받고, 리턴을 Res를 한다.
    private Res post(Req req) {
        String reqBody = toJson(req); // req를 받으면 json으로 바꿔주고
        
        // 암호화를 진행한다.
        Cipher cipher = Cipher.getInstance(DEFAULT_TRANSFORM);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
        String encReqBody = new String(Base64.getEncoder().encode(cipher.doFinal(reqBody)));
        
        // 암호화한 결과를 restTemplate을 이용해서 encReqBody라는 곳에 보내고,
        // 보낸 결과를 responseEntity 응답으로 받는다.
        ResponseEntity<String> responseEntity = restTemplate.postForEntity(api, encReqBody, String.class);
        
        String encRespBody = responseEntity.getBody();
        
        // 다시 응답으로 받은 것을 복호화를 하고, 
        Cipher cipher2 = Cipher.getInstance(DEFAULT_TRANSFORM);
        cipher2.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
        String respBody = new String(cipher.doFinal(Base64.getDecoder().decode(encRespBocy)));
        
        // 복호화한 결과를 다시 오브젝트로 바꿔서 리턴해준다.
        return jsonToObj(respBody);
    }
}


  • 여기서는 암복호화 기능을 분리해보도록 하겠다.

분리 연습 1 결과

  • Cryptor라는 클래스를 이용해서 암복호화 기능을 분리함
    • Cryptor 클래스 안에 encrypt(암호화), decrypt(복호화) 메소드를 만듦
    • CashClient 클래스에서 이 Cryptor 클래스를 사용해 해당 기능 구현

분리 연습 2



public class Rental {
    private Movie movie;
    private int daysRented;

    public int getFrequentRenterPoints() {
        if (movie.getPriceCode() == Movie.NEW_RELEASE && daysRented > 1)
            return 2;
        else
            return 1;
    }
}




public class Movie {
    public static int REGULAR = 0;
    public static int NEW_RELEASE = 1;
    private int priceCode;

    public int getPriceCode() {
        return priceCode;
    }
    // ...
}


  • if-else 부분을 캡슐화해서 분리했던 적이 있었음
    • 이것 보다 조금 더 나아간다면?
    • if-else 블록을 추상화하고 하위 클래스를 도출해서 역할을 분리해볼 수 있음

분리 연습 2 결과



public class Rental {
    private Movie movie;
    private int daysRented;

    public abstract int getFrequentRenterPoints() {
        return movie.getFrequentRenterPoints(daysRented);
    }
}




public class Movie {
    public abstract int getFrequentRenterPoints(int daysRented)
    // ...
}

public class NewReleaseMovie extends Movie {
    public int getFrequentRenterPoints(int daysRented) {
        return daysRented > 1 ? return 2 : 1;
    }
}

public class RegularMovie extends Movie {
    public int getFrequentRenterPoints(int daysRented) {
        return 1;
    }
}


  • 포인트 제공하는 부분을 Movie 클래스에 추상화함 getFrequentRenterPoints
    • 추상화한 기능을 각각의 하위 클래스가 제공하도록 구현 NewReleaseMovie, RegularMovie

분리 연습 3

  • 이번에는 상위기능 / 하위기능 분해하고 그 기능을 알맞은 객체에 할당해보겠다.

  • 기능: 회원 가입
    • 사용자는 이메일, 이름, 암호 입력
      • 모두 필수
    • 암호가 다음 규칙을 통과하지 않으면 다시 입력
      • 규칙1, 규칙2, 규칙3, …
    • 같은 이메일로 가입한 회원이 있으면 다시 입력
    • 이메일 인증 위한 메일 발송
      • 유효성 검증 위해 암호화된 토큰을 사용
    • 회원 가입 완료

  • 회원 가입 기능
    • 웹요청
      • 필수값 검증
      • 회원가입 처리
    • 회원가입
      • 암호규칙 검사
        • 검사에 통과하지 못하면 가입 실패
      • 같은 이메일 가입 여부확인
        • 이메일로 회원 조회
        • 존재하면 가입 실패
      • 인증메일 발송
        • 토큰 생성
        • 토큰 저장
        • 인증 메일 전송
      • 회원정보 저장

  • 누군가는 토큰 생성, 저장을 회원가입의 하위 기능으로 볼 수도 있다.
  • 위에선 일단 저렇게 기능을 정의하고 보겠다.

  • 이렇게 하위 기능을 정의했다면 그 하위 기능을 누가 제공할지, 어떤 객체에 하위 기능을 배분할지 설계해볼 수 있을 것이다.

  • 아래는 설계의 한 예시이다.

  • 회원 가입 기능
    • RegistController (웹요청)
      • RegistCommandValidator (필수값 검증, 회원가입 처리)
    • RegistService (회원가입)
      • PasswordPolicy (암호 규칙 검사)
        • MemberRepository (이메일로 회원 조회, 존재하면 가입 실패)
      • 같은 이메일 가입 여부 확인
      • AuthMailSender? MailAuthRequestor? (인증 메일 발송)
        • AuthTokenGen (토큰 생성, 저장, 인증 메일 전송)
      • MemberRepository (회원 정보 저장)

  • RegistCommandValidator, PasswordPolicy는 일종의 계산을 분리하는 방식이다.

  • 이렇게 하위 기능을 분리하고, 각 기능을 알맞은 하위 객체에게 나눠서 배분하는 것.
  • 이런식으로 객체지향 설계를 진행해볼 수 있다.