10 minute read

주말이 얼마 남지 않았다..

이사이에 뭘 할까 하다, 강의를 다시 한번 더 보기로 하였다.

강의는 이전에도 몇번 다루었던 최범균님의 객체 지향 프로그래밍 입문 정리

보고 봐도 이걸 잘 지키는건 어렵다…

이건 강의의 포인트들을 적어보기 위한 그냥 강의 메모장이다.

1. 들어가며

시간이 지날수록 코드 한줄을 작성하는데 걸리는 시간이 증가하는 원인

  1. 코드의 분석 시간 증가
  2. 코드의 변경 시간 증가

소프트웨어의 가치는 변화 이다.

소프트웨어를 이전처럼 독작하게 유지하는것이 유지보수가 아닌, 변경하는 세계에서 유용하게 변화하는것이 소프트웨어 이다.
Jessica Kerr

이말을 엄청나게 공감하는건,, 내가 통계청 하루 2억개의 데이터 동기화 프로그램을 만들고 굉장히 뿌듯하였지만, 2년이 지난 지금은, 그 프로그램을 보는것이 두렵다.

내가 조금더 성장한 상태에서 그 포로젝트를 진행할수 있었다면,, (지금은 뭐가 다른가?… 다르려고 노력해야지..)

결론적으로 위의 말들을 정리해보자면

  • 낮은 비용으로 변화할 수 있어한다.
  • 이를 위한 방법
    • 패러다임
      • 객체지향, 함수형, 리액티브
    • 코드, 설계, 아키텍쳐
      • TDD, DDD
      • 클린 아키텍쳐
    • 업무 프로세스/문화
      • 애자일, devOp

객체 지향이 비용을 낮추는 방법

  • 캡슐화 + 다형성(추상화)

1. 객체지향

객체지향 vs 절차지향 : 절차지향

절차지향은 여러 프로시저가 데이터를 공유 하는 방식을 말한다.

처음엔 변수선언하고 필드 선언하고 이를 조작하는 방식이라 굉장히 간단하고 쉽다.

하지만 시간이 흘러갈수록 데이터를 공유하는 방식에 있어서 구조를 복잡하게 만들어 수정을 힘들게 한다.

example


// 인증 api
Account account = findOne(id);
if (account.getState() == DELETED){
    ...
}
// 암호 변경 api
Account account = findOne(id);
if (account.getState() == DELETED){
    ...
}

를 아래와 같이 변경한다 생각해보자. (인증할떄 blockcount 도 확인하자)

// 인증 api
Account account = findOne(id);
if (account.getState() == DELETED || account.getBlockCount()> 0){
    ...
}
// 암호 변경 api
Account account = findOne(id);
if (account.getState() == DELETED  || account.getBlockCount()> 0){
    ...
}

를 또 추후 이메일 검사까지 검사하도록 하자.

// 인증 api
Account account = findOne(id);
if (account.getState() == DELETED 
    || account.getBlockCount()> 0
    || account.getActiveStatus() == 0){
    ...
}
// 암호 변경 api
Account account = findOne(id);
if (account.getState() == DELETED  
    || account.getBlockCount()> 0
    || account.getActiveStatus() == ENALBLE){
    ...
}

이렇게 요구 사항들이 추가되기 시작하면, 시간이 갈수록 복잡해지고 수정이 어려워 지기 시작한다.

분명히 getState와 getBlockcount 를 사용하는곳이 괸장히 많을텐데, 이를 추후에 유지보수 하는게 굉장히 어려워질것,

그러면 이러한걸 객체지향으로 어떻게 풀어갈수 있을까??

객체지향

객체란?

스크린샷 2022-02-27 오후 10 25 35

객체지향은 데이터와 프로시적를 객체 단위로 묶어버린다.

또한 객체 지향은 프로시저에만 접근이 가능하게 한다. 그래서 다른 객체에서는 다른 객체의 데이터에 직접적으로 접근이 불가능 하다.

객체들은 다른 객체의 데이터를 사용하는것이 아닌, 다른 객체의 프로시저의 기능을 빌려 사용한다,

  • 객체의 핵심 -> 기능제공
    • 객체는 제공하는 기능으로 정의
      • 내부적으로 가진 필드로 정의하지 않음
  • 예: 회원 객체
    • 암호 변경하기 기능
    • 차단 여부 확인하기 기능
  • 예: 소리 제어기
    • 소리 크기 증가하기 기능
    • 소리 크기 감소하기 기능 즉 객체는 __기능__으로 정의 한다.
기능명세
  • 메서드(오퍼레이션)을 이용해서 기능 명세
    • 이름, 파라미터, 결과로 구성

public class VolumeContoller {
    public void increase(int inc) {

    }

    public int volumne(){

    }
}
객체와 객체
  • 객체와 객체는 기능을 사용하여 연결
    • 기능 사용 = 메서드 호출 하는것

메세지

  • 객체와 객체 상호 작용 : 메세지를 주고 받는다고 표현한다.
    • 메서드를 호출하는 메세지, 리턴하는 메세지, 익셉션 메세지등

그럼 아래 처럼 데이터가 있고 메서드가 있는 클래스는 __객체__라 할수 있을까?

public class Member {
    private String name;

    public void setName(String name) {
        this.name = name;
    }

    public void getName() {
        return name;
    }
}

위 처럼 단순히 name을 get, set 하는 class 는 객체 보단 데이터 클래스(c에선 구조체)에 가깝다. 여기에 기능이 들어간다면, 객체라 볼수 있을듯 하다.

다시한번 강조하자면 객체는 __기능__에 따라 구현된다!!

3. 캡슐화

캡슐화만 잘해도 좋은 코드를 만들어 낼수 있다.

  • 데이터 + 관련 기능 묶기
  • 객체가 기능을 어떻게 구현했는지 외부에 감추는 것
    • 구현에 상용된 데이터의 상세 내용을 외부에 감춤
  • 정보 은닉 의미를 포함한다.
  • 외부에 영향없이 객체 내부 구현 변경 가능

캡슐를 하지 않으면

스크린샷 2022-02-27 오후 11 22 30

캡슐화를 사용하면


if (acc.hasRegularPermission()){
    //.. 정회원기능
}
public class Account() {
    private Membership membership;
    private Date expDate;
    
    // account 클래스내에 관련 기능을 묶음
    // 외부에 이 기능을 감춤
    public boolean hasRegularPermission(){
        return membership == REGULAR && expDate.isAfter(now())
    }
}

이 상태에서 기능 조건이 변경 된다면?

if (acc.hasRegularPermission()){
    //.. 정회원기능
}

는 동일 하고

public class Account() {
    private Membership membership;
    private Date expDate;
    
    public boolean hasRegularPermission(){
        // 이부분만 수정 !!
    }
}

요구사항이 변경해도 account class 만 변경되고, 연쇄적인 변경 전파를 최소화할 수 있다. 또한 캡슐화 시도 -> 기능에 대한 (의도) 이해를 높일수 있다.

캡슐화를 위한 규칙

  • Tell, Don’t Ask
    • 데이터를 달라 하지 말고 해달라 하자

변경전

if (acc.getMembership() == REGULAR){
    ...
}

if (acc.hasRegularPermission()){
    ...
}
  • Demeter’s Law
    • 메서드에서 생성한 객체의 메서드만 호출
    • 파라미터로 받은 객체의 메서드만 호출
    • 필드로 참조하는 객체의 메서드만 호출

변경전

acc.getExpDate().getAfter(now);


Date date = acc.getExpDate();
date.isAfter(now);

변경후

acc.isExpired();
acc.isValid();

와 같이 한번의 메세드 호출로 변경하자.

정리

  • 캡슐화 : 기능의 구현을 외부에 감춤
  • 캡슐화를 토앻 기능을 사용하는 코드에 영향을 주지 않고, 내부 구현을 변경할 수 있는 유연함이 생긴다.

예제

여러 예제중 하나를 가져옴

Timer t = new Timer();
//1
t.startTime = System.currentTimeMillis();
...
//2
t.stopTime = Sytems.currentTimeMillis();
//3
long elasepedTime = t.stopTime - t.startTime;

1, 2, 3 부분 모두 t클래스를 직접 사용하고 있음, 굉장히 절차 지향적이다. 이걸 조금더 객체지향적으로 변경해보자.

Timer t = new Timer();

t.start();
t.stop();

long elasepedTime = t.elapsedTime(MILLISECOND);
public class Timer() {
    private long startTime;
    private long stopTime;

    public void start() {
        this.startTime = System.currentTimeMillis();
    }

    public void stop() {
        this.stopTime = System.currentTimeMillis();
    }

    public long elapsedTime(TimeUnit unit) {
        switch(unit) {
            case MILLISECOND:
                return stopTime - startTime;
        }
    }

}

로 변경해 볼수 있다..

4. 다형성과 추상화

다형성 (Polymorphism)

  • 여러 (Poly) 모습(Morph) 을 갖는것

  • 객체 지향에서는 한 객체가 여러 타입을 갖는 것

    • 즉 한 객체가 여러 타입의 기능을 제공하는것
    • 타입 상속으로 다형성 구현 하위 타입은 상위 타입도 된다.

추상화 (Abstraction)

  • 데이터나 프로세스 등을 의미가 비슷한 개념이나 의미있는 표현으로 정의하는 과정
  • 두 가지 방식의 추상화
    • 특정한 성질, 공통 성질 (일반화)
  • 간단한 예
    • 디비의 User 테이블 : 아이디 이름 이메일
    • Money 클래스 : 통화, 금액

타입 추상화

  • 여러 구현 클래스를 대표하는 상위 타입 도출
    • 흔히 인터페이스 타입으로 추상화
    • 추상화 타입과 구현은 타입 상속으로 연결
  • ((Interface)) Notifier (기능에 대한 의미 제공, 구현은 제공하지 않는다. 어떻게 구현할지 알 수 없음)
    • EmailNotifiler
    • SMSNotifier
    • KakaoNotifier
    • 위 3가지를 concrete 클래스라 한다

추상 타입 사용

  • 추상 타입을 이용한 프로그래밍
      Notifier notifier = getNotifier(...);
      notifier.notify(someNoti);
    
  • 추상 타입은 구현을 감춘다.
    • 기능의 구현이 아닌 의도를 더 잘 들어낸다.

추상 타입 사용에 따른 이점: 유연함

  • 콘크리트 클래스를 직접 사용하면,
1번. 최초 주문취소시 sms만 만 보냄
private SmsSender smsSender;
public void cancel(String ono) {
    ... 주문 취소 처리
    smsSender.sendSms(...);
}
2번. 취소시 카카오 푸시 추가
private SmsSender smsSender;
private KakaoPush kakaoPush;
public void cancel(String ono) {
    ... 주문 취소 처리
    if (pushEnabled) { 
        kakaoPush.push(...);
    } else {
        smsSender.sendSms(...);
    }
    ....
    .... 계속 추가 될것
}
  • 주문 취소 처리의 본질은 변경없지만, 요구사항에 따라 본질이 변하는 이상한 케이스
  • 공통점을 도출하면 통지 라는 부분이 있다.

그럼 추상화를 해보자

public void cancel(String ono) {
    Notifier notifier = getNotifier(...);
    notifier.notify(someNoti);
}

private Notifier getNotifier(...) {
    // Notifier 를 생성하는 부분을 팩토리를 써서 변경해 보자 
    if(...)
        return new KakaoNotifier();
    else 
        return new SmsNotifier();
}

이걸 한번더 추상화할 여지가 있다.

public void cancel(String ono) {
    Notifier notifier = NotifierFactory.instance().getNotifier(...);
    notifier.notify(someNoti);
}

private Notifier getNotifier(...) {
    Notifier getNotifier(...);

    static NotifierFactory instance() {
        return new DefaultNotifierFactory();
    }
}

public class DefaultNotifierFactory implements NotifierFactory {
    public Notifier getNotifier(...) {
        if (pushEnabled) return new KakaoNotifier();
        else return new SmsNotifier();
    }
}

위 방식을 사용하면 통신 방식이 변경되더라도 cancle메서드는 변경되지 않는다는 점에 있다. 이러한 예시가 바로 추상타입을 사용하는 이유이다.

정리 추상화는 언제 사용할까?

  • 추상화 -> 추상 타입 증가 -> 복잡도 증가로 이어짐
    • 아직 존재하지 않는 기능에 대해서는 이른 추상화는 주의
    • 실제 변경, 확장이 발생할 떄 추상화를 시도한다.
    • 구현한 이유가 무엇 떄문인지 생각을 하자,
    • 확장에는 열려있고 수정에는 닫혀 있는,

5. 상속 보단 조립

객체 지향의 주요 특성으로 재사용을 말하면 그 예로 상속을 드는 경우가 있다. 물론, 상속을 통해서 상위 클래스에 구현된 기능을 그대로 재 사용할수 있기 떄문에 상속을 사용하면 재사용을 쉽게 할 수 있는것은 분명하다.

상속을 통한 기능 재사용시 발생할 수 있는 단점

  • 상위 클래스 변경이 어려움 어떤 클래스를 상속 받는다는 것은 그 __클래스에 의존__한다는 뜻이다. 따라서 의존하는 클래스의 코드가 변경되면 영향을 받게 된다. 상속 계층을 따라 상위 클래스의 변경이 하위 클래스에 영향을 주기 때문에, 최악의 경우 상위 클래스의 변화가 모든 하위 클래스에 영향을 줄 수 있다.
  • 클래스 증가 유사한 기능을 확장하는 과정에서 클래스의 개수가 불필요하게 증가할 수 있다.
  • 상속 오용 잘못된 상속으로 인한 잘못된 메스드를 제공할 수 있다.

상속의 단점 해결 방법 -> 조립

  • 조립(Composition)
    • 여러 객체를 묶어서 더 복잡한 기능을 제공
    • 보통 필드로 다른 객체를 참조하는 방식으로 조립 또는 객체를 필요 시점에 생성/구함

객체 조립은 여러 객체를 묶어서 더 복잡한 기능을 제공하는 객체를 만드는 것이다.

한 객체가 다른 객체를 조립해서 필드로 갖는다는 것은 다른 객체의 기능을 사용한다는 의미를 내포한다.

조립 방식의 또다른 장점으로는 런타임에 조립 대상 객체를 교체할 수 있다는 점이다.

언제 상속을 사용할까?

  • 상속을 사용할 떄에는 재사용이라는 관점이 아닌 기능의 확장이라는 관점에서 상속을 적용해야 한다.
  • 이처럼 상속은 명확한 is-a 관계에서 점진적으로 상위 클래스의 기능을 확장해 나갈떄 사용할 수 있다.

6. 기능과 책임 분리

기능분해

  • 기능은 하위 기능으로 분해

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

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

큰 클래스, 큰 메서드

  • 클래스나 메서드가 커지면 절차 지향의 문제가 발생한다.
    • 큰 클래스 -> 많은 필드를 많은 메서드가 공유
    • 큰 메서드 -> 많은 변수를 많은 코드가 공유
    • 여러 기능이 한 클래스/메서드에 섞여 있을 가능성
    • 필드나 변수도 데이터므로 서로 다른 코드가 데이터를 읽고 변경하므로 코드를 수정하기 어렵게 변경한다.
  • 책임에 따라 알맞게 코드 분리 필요

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

  • 패턴적용
  • 계산 기능 분리
  • 외부 연동 분리
  • 조건별 분기는 추상화 //연속적인 if-else는 추상화를 고민하자
    String fileUrl = "";
    
    if (fileId.startWith("local:")) {
        fileUrl = "/file/" + fileId.subString(6)
    } else if (fileId.startWith("ss:")) {
        fileUrl = "http://example/files/" + fileId.subString(3);
    }
    

    와 같이 계속해서 IF-ELSE 로 분기가 된다면 여기서 if-else에서 하는 일이 비슷하다면 추상화를 사용하는것이 가능하다. 여기서는 file id에 따라 url 을 구하는 공통적인 기능을 수행하고 있다.

그러면 우리는 위 예제를 아래와 같이 변경하는 것이 가능하다.

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://example/files/" + fileId.subString(3);
    }
}

패턴 적용

  • 전형적인 역할 분리
    • 간단한 웹
      • 컨트롤러, 서비스, 다오
    • 복잡한 도메인
      • 엔티티, 밸류, 레포지토리, 도메인 서비스
    • AOP
  • 역할을 분리하면 __테스트__가 용이해진다.

7. 의존과 DI

의존

  • 기능 구현을 위해 다른 구성 요서를 사용하는 것
    • 의존의 예: 객체 생성, 메서드 호출, 데이터 사용
  • 의존은 변경이 전파될 가능성을 의미
    • 의존하는 대상이 변경되면 바뀔가능 높다.
      • 호출하는 메서드의 파라미터 변경
      • 호출하는 메서드가 발생할수 있는 익셉션 타입 추가등

순환의존

  • 순환 의존 -> 변경 연쇄 전파 가능성
    • 클래스, 패키지, 모듈 등 모든 수준에서 순환 의존 없도록 설계 해야 한다.

의존 대상 많은 케이스 (1) 기능이 많은 경우

  • 한 클래스에서 많은 기능을 제공하는 경우
    public class UserService {
    
        public void regist(RegReq regReq) {
            ...
        }
    
        public void changePw(ChangeReq changeReq) {
            ...
        }
    
        public void blockUser(String id) {
            ...
        }
    }
    
  • 각 기능마다 의존하는 대상이 다름
  • 한 기능 변경이 다른 기능에 영향을 줄 수 있음

의존 대상 많은 케이스 (2) 묶어보기

  • 몇 가지 의존 대상을 단일 기능으로 묶어서 생각해보면 의존 대상을 줄일 수 있음

의존 대상 객체를 직접 생성하는방법

  • 생성 클래스가 변경되면 의존하는 코드도 변경된다.
  • 의존 대상 객체를 직접 생성하지 않는 방법
    • 팩토리, 빌더
    • 의존 주입 (DI)
    • 서비스 로케이터

의존 주입(Dependency Injection)

  • 외부에서 의존 객체를 주입
    • 생성자나 메서드를 이용해서 주입

// EXAMPLE

public class ScheduleService {
    private UserRepository repository;
    private Calculator cal;

    public ScheduleService(UserRepository repository) {
        this.repository = repository
    }

    public void setCalculator(Calculator cal) {
        this.cal = cal;
    }
}

// 초기화 코드
UserRepository userRepo = new DbUserRepository();
Calculator cal = new Calculator();

ScheduleService schSvc = new ScheduleService(userRepo);
schSvc.setCalculator(cal);

조립기 (Assembler)

  • 프로그램을 시작하는 메인 메서드에서 의존 객체를 생성하고 주입할수도 있지만 대부분 조립기를 사용한다.
  • 조립기가 객체 생성, 의존 주입을 처리
    • 예: 스프링 프레임워크
  • 스프링 프레임워크는 아래의 왼쪽처럼 객체생성, 주입을 설정코드를 통해 진행하며, 이 설정을 어플리케이션 컨텍스라는 조립기를 생성한다.
    • 조립기를 초기화하는 시점에 설정 클래스를 사용해 객체를 만들고 의존 주입이 실행된다.
    @Configuration
    public class Config {
        @Bean
        public ScheduleService scheduleSvc() {
            ScheduleService svc = new ScheduleService(repo());
            svc.setCalculator(expCal());
            return svc;
        }
    
        @Bean
        public UserRepository repo() {...}
    
        @Bean
        public Calculator expCal() {...}
    }
    
    // 초기화 
    ctx = new AnnotationConfigApplicationContext(Config.class);
    
    // 사용할 객체 구함
    ScheduleService svc = ctx.getBean(ScheduleService.class);
    
    // 사용
    svc.getSchedule(..);
    

DI의 장점

  • 의존 대상이 변경되면 조립기의 설정만 변경하면 된다.
  • 의존하는 객체의 실제 구현이 없어도 대역 객체를 사용하여 테스트가 가능해진다.

+ DI 사용 이유

의존 하는 클래스에 대한 인스턴스를 직접 생성하지 않고, 컨테이너로 부터 생성된 빈을 setter나 생성자를 통해서 외부로 주입 받는것을 Dependency Injection 이라 하였다.

그렇다면 이러한 DI를 사용하는 이유는 무엇일까? 이전에도 조금은 다루었지만 부족한거 같아 조금 더 추가를 해보려 한다.

DI를 왜 사용할까? 왜 의존성을 외부에서 주입 할까?

class Program {
    private Language language;

    public Program() {
        this.language = new Java();
    }

    public StartLanguage() {
        this.language.run();
    }
}

위 코드에서 run 메소드를 호출하기 위해서는 Java 클래스가 필요하다. 여기서 Program클래스는 Java 클래스의 의존성을 가진다고 말한다. 하지만 위 코드는 의존성을 Program내에서 생성하고 있다.

위 처럼 코드를 작성하게 되면, 코드의 재활용성이 떨어지고, Java 클래스를 수정함에 Program클래스 까지 수정해야될수 있는 문제가 발생하게 된다. 지금은 하나의 클래스지만, 수많은 클래스에서 Java 클래스를 사용하게 된다면 큰 이슈가 된다.

결국 Coupling (결합도) 가 높아지는 것이다. 그리고 테스트 또한 어려워 진다.

위의 예제 코드를 변경해보자 .

class Program {
    private Language language;

    public Program(Language language) {
        this.language = language;
    }

    public StartJava() {
        this.language.run();
    }
}

Program에 Language 라는 인터페이스를 주입하였다. 인터페이스를 전달했으므로, 객체가 변화되더라도 Program Class는 변경할 필요가 없어진다. Language에 어떤 프로그래밍 언어를 전달할지만 지정하면 된다. 또한 연결된 Class를 변경해야 한다면, 해당 Class 이름만 변경하면 된다.

정리

DI를 사용하면 결합도가 낮아진다. 그로 인해 코드 수정이 쉬워지며, 테스트 코드 작성도 쉬워진다.

IoC를 실현하는데 DI가 필수이며 IoC를 사용하면, 귀찮은 객체 생명 주기 같은 프로그램 제어권을 프레임워크가 관리하도록 넘길 수 있다.

스프링 프레임워크 버전 4.3 이후에는 @Autowired도 생략되면서 생성자에 사용한 Bean만 선언하면 된다. 코드가 간결해졌다.

Categories:

Updated: