4 기능과 책임 분리
source: categories/study/oop_programming/opp_programming_4.md
4. 기능과 책임 분리
- 하나의 기능은 여러 하위 기능으로 분해할 수 있다.
- 암호 변경
- 변경 대상 확인
- 변경 대상 구함
- 대상 없으면 오류 응답
- 대상 암호 변경
- 암호 일치 여부 확인
- 불일치하면 암호 불일치 응답
- 암호 데이터 변경
- 암호 일치 여부 확인
- 변경 대상 확인
- "암호 변경 기능"을 구현하기 위해선 "변경 대상 확인" 기능과 "대상 암호 변경" 기능이 필요하다.
- "변경 대상 확인"하려면 "변경 대상을 구해야돼고" "대상이 없으면 오류를 응답하는" 그런 기능이 필요하다.
- "대상 암호 변경" 기능은 "암호 일치 여부 확인" 기능과 "암호 데이터 변경" 기능이 필요하다.
- 이렇게 하나의 기능은 여러 하위 기능을 이용해서 구현하게된다.
기능을 누가 제공할 것인가?
- 분리한 하위 기능을 누가 제공할지 결정하는 것, 이것이 바로 객체 지향 설계의 기본 과정이다.
- 즉, 기능을 분리하고 각 객체에게 분리한 기능을 제공할 책임을 배분하는 것이다.
- 기능은 곧 책임
- 분리한 각 기능을 알맞게 분배
- 암호 변경 전체 기능:
ChangePasswordService
객체에 할당 - 변경 대상 구하는 기능:
MemberRepository
객체에 할당 - 대상 암호 변경하는 기능:
Member
객체에 할당
- 이렇게 하위 기능을 알맞은 객체에게 분배하는 식으로 객체 지향 설계를 진행하게 된다.
- 실제 코드를 보면 분리한 하위 기능을 이용해서 전체 기능을 완성하게 된다.
- 아래 예에서는 전체 기능에 흐름을 제공하는
ChangePasswordService
클래스가 있는데, 이 클래스는memberRepository
와Member
가 제공하는 기능을 이용해서 전체 암호 변경 기능을 완성하고 있다.
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
- 추천의 의미를 담고 있는
RecommendService
가HttpDataService
보다 더 좋은 이름인 것이다. 의도가 더 잘 드러나니깐.
역할 분리와 테스트
- 이렇게 역할을 분리하고나면 장점이 하나 생긴다.
- 테스트가 용이해진다는 것이다.
- 역할 분리가 잘 되면 테스트도 용이해짐
- 아래 코드에서 포인트를 구하려면
memberRepository
와productRepository
와 관련된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);
// ...
- 반면에 아래와 같이 따로 포인트 구하는 코드만 따로 분리해놓으면,
- 이 코드는
memberRepository
와productRepository
와 상관 없이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
- 계산기능 분리 - (암호화, 복호화)
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
는 일종의 계산을 분리하는 방식이다.
- 이렇게 하위 기능을 분리하고, 각 기능을 알맞은 하위 객체에게 나눠서 배분하는 것.
- 이런식으로 객체지향 설계를 진행해볼 수 있다.