이 글은 객체지향을 공부하고 스터디했던 내용들을 정리한 글입니다.

OOP를 요리로 비유한다면?

  • 요리도구 : 객체 지향의 4대 특성(캡슐화, 상속, 추상화, 다형성)

  • 요리도구 사용법 : 객체 지향의 SOLID 개념

  • 레시피 : 디자인 패턴

스프링은 객체 지향의 특성과 설계 원칙을 극한까지 적용한 프레임워크

객체 지향

  • 처음에는 절차지향적인 프로그래밍으로도 괜찮았습니다.
  • 그러나 시스템의 규모가 커질수록 변화에 대응하기 어렵고 그로 인해 유지보수가 어려워졌습니다.
  • 객체지향은 데이터와 프로시저를 하나로 묶어 자신만의 기능을 제공하기 떄문에 관리가 쉽습니다.
  • 또한 각 객체들은 서로 연결돼 다른 객체가 제공하는 기능을 사용할 수 있습니다.

  • “객체가 갖는 책임은 어떻게 될까?” 이 결정을 하는 것이 바로 객체 지향 설계의 출발점입니다.
  • 객체에 기능이 많아지거나 데이터를 공유하게 되면 절차 지향적인 구조를 갖게 됩니다.
  • 그러므로 객체가 갖는 책임의 크기는 작을수록 좋습니다. (SRP)

객체지향 설계 과정

  1. 제공해야 할 기능을 찾고 세분화하고, 그 기능을 알맞은 객체에 할당한다
  2. 객체 간 메세지를 어떻게 주고받을지 결정한다
  3. 1~2 반복 반복
  • 객체지향의 설계는 한 번에 완성되지 않는다.
  • 구현을 진행하는 과정에서 점진적으로 완성된다.

캡슐화

  • 데이터 보존을 위해 코드와 데이터를 단일 단위로 묶는 프로세스
  • 대표적인 예시로, 상태값의 접근제한자를 private으로 한 후, settergetter를 추가해서 보존해 줄 수 있습니다.
  • 외부에서 settergetter 메서드를 쓸 뿐, 그 값이 어떻게 저장되는지, 어떻게 꺼내오는지는 알 수 없기 때문에 객체가 내부적으로 기능을 어떻게 구현한 지 모르게 됩니다.
  • 다만 setter 메서드는 상태값을 변경시킬 여지가 있어 쓰면 안되고, getter 메서드 또한 상태값 자체를 가져오기 때문에 도메인 로직을 작성할 때는 쓰지 않는 게 좋습니다.
  • 그래서 데이터를 get 해서 가져오기보다 그 객체에게 메시지를 보내서 접근하는 것이 좋은데 이 방법 자체가 캡슐화를 한 것과 동일한 효과를 보입니다.

캡슐화를 위한 두 개의 규칙

  • Tell, Don’t Ask
    • 말 그대로 데이터를 읽지말고 실행해달라고 물어보는 것(메세지를 보내는 것)
    • 데이터를 읽는 것(getter)은 데이터를 중심으로 코드를 작성하게 만드는 원인이 되며, 이는 절차지향적인 코드를 작성하게 합니다.
  • 데미테르의 법칙
    • 메서드에서 생성한 객체의 메서드만 호출
    • 파라미터로 받은 객체의 메서드만 호출
    • 필드로 참조하는 객체의 메서드만 호출
    • 쉽게 말해서 get 하지말고 두 번 들어가지 마라!
class A {
    private B b;

    public myMethod(OtherObject other) {
        // ...
    }

    /* 디미터의 법칙을 잘 따른 예 */
    public goodOfDemeter(Paramemter param) {
        myMethod();   // 자신의 메소드
        b.method();   // 자신의 필드의 메서드
        Local local = new Local();
        local.method();    // 직접 생성한 객체의 메서드
        param.method();    // 메소드의 인자로 넘어온 메서드
    }

    /* 디미터의 법칙을 어긴 예 */
    public badOfDemeter(Paramemter param) {
        C c = param.getC();
        c.method();    // 인자로 받은 객체에서 get()을 통해 가져온 뒤에 메서드를 꺼내는 경우
        param.getC().method();      // 위와 같음.
        param.paramValue.method();  // 위와 같음.
    }
}

다형성과 추상 타입

  • 인터페이스에 정의된 기능을 실제로 구현하는 클래스를 콘크리트 클래스(concrete class)라고 부른다
  • 추상화는 언제할까?
    • 기존 요구사항 : 파일에서 바이트 데이터를 읽어와…
    • 추가 요구사항 : 소켓에서 바이트 데이터를 읽어와…
    • 데이터를 읽어오는 것이 공통점이다. 이는 동일한 개념으로 추상화할 수 있다는 것을 의미한다.
    • 그 공통점을 인터페이스로 정의해서 인터페이스의 타입을 사용할 수 있도록 한다.
  • 인터페이스를 사용해 추상화를 적용하고 이를 통해 책임을 분리할 수 있었습니다.
  • 이로 인해 유연성있는 구조를 가지게 되고, 쉽게 Mock 객체를 만들 수 있어 테스트에도 좋습니다.

상속보단 조립

상속의 단점

  1. 상위 클래스 변경의 어려움
    • 상속받으면서 의존하기 때문에 클래스를 변경한 여파가 계층도를 따라 하위 클래스에 전파된다.
    • 최악의 경우, 상위 클래스의 변화가 모든 하위 클래스에 영향을 줄 수 있다
  2. 상속의 오용
    • 상위 클래스에 이미 작성된 메서드를 하위 클래스에서 재사용할 시에 클라이언트쪽에 혼동을 줄 수 있다.
    • 즉, 그 메서드를 어떻게 쓰는지 알려면 상위 클래스에 내부 구현도 어떻게 되어있는지 직접 알아봐야 한다.
    • 상위 클래스의 메서드를 모두 쓸 수 있기에 상속받은 하위클래스에 있는 메서드를 사용하고 싶어도 상위 클래스의 메서드를 모두 살펴봐야 한다.
    • 예를 들어, Stack의 add()은 사실 Vector의 add()을 그대로 사용하고 있다.

조립을 이용한 재사용

  1. 조립(Composition)은 여러 객체를 묶어서 더 복잡한 기능을 제공하는 객체를 만들어내는 것이다
  2. 필드에서 다른 객체를 참조해 조립할 수 있다.
  3. 클래스가 불필요하게 증가하지 않으며 런타임에 setter 등으로 객체를 교체할 수 있다.
  4. 필드에 있는 다른 객체를 통해 어떠한 요청을 그 다른 객체가 하도록 위임해줄 수 있다.
  5. 다만 클래스의 구조가 복잡해진다.

-> 상속은 기능이 완전히 똑같으면서 확장되는 곳에 쓰고 그게 아니라면 상속보다는 조립을 사용하자.

  • 상속을 잘한 케이스는 LinkedHashMapHashMap을 상속한 것. 상위 클래스가 그 기능을 완전히 똑같이 하고 확장만 한 형태니까.

DI(Dependency Injection)와 서비스 로케이터

  • 소프트웨어를 두 개의 영역으로 설명할 수 있다.
    • 구현을 포함한 어플리케이션 영역
    • 어플리케이션이 동작하도록 각 객체들을 연결해주는 메인 영역
  • 메인 영역은 어플리케이션 영역의 객체를 생성하고, 설정하고, 실행하는 책임을 갖는다
  • 메인영역에서 객체를 생성하고 조립하기 때문에 모든 의존은 메인 영역에서 어플리케이션 영역으로 향한다
    • 즉, 메인 영역의 코드를 수정하는 것은 어플리케이션 영역에는 어떤 영향도 끼치지 않는다
    • Controller 에서 실행시키고 출력하는 것처럼

사용할 객체를 제공하는 책임을 갖는 객체를 서비스 로케이터(Service Locator) 라고 부른다

  • 그러나 이렇게 서비스 로케이터를 사용해 필요로 하는 객체를 JobQueue jobQueue = Locator.getInstance().getJobQueue();를 통해서 객체를 직접 찾는 방식인데 몇 가지 단점이 존재한다. 그래서 보통은 사용할 객체를 주입하는 DI방식을 사용하는 것이 일반적이다.

의존

  • 객체가 다른 객체를 생성(new)하거나 다른 객체의 메서드를 호출할 때, 파라미터로 전달받을 때, 이를 그 객체에 의존한다고 표현합니다.
  • 다른 객체를 안에서 호출할 수 있으므로 지속적으로 안에서 호출하게 되면 그만큼 의존이 전파되어 의존하는 객체 모두 영향을 받습니다. 그래서 의존의 영향은 꼬리에 꼬리를 문 것처럼 전파되는 특징을 갖습니다.
  • 예를 들어, 아래와 같은 코드의 경우 AuthenticationHandler 클래스 안에서 Authenticator 객체를 생성하고 있으므로 AuthenticationHandlerAuthenticator에게 의존하고 있는 상황이 됩니다. 이는 Authenticator 클래스에게 변화가 생기면 AuthenticationHandler 클래스도 영향을 받게 된다는 의미입니다.
    • auth.authenticate() 의 반환 타입을 boolean 이 아니라 예외를 던지는 void로 바꾸게 된다면 AuthenticationHandler 클래스의 로직도 바꿔야 합니다.
public class AuthenticationHandler {
    public void handleRequest(String inputId, String inputPassword) {
        Authenticator auth = new Authenticator();
        if (auth.authenticate(inputId, inputPassword)) {
            // 아이디와 암호가 일치할 때 처리
        } else {
            // 일치하지 않을 때 처리
        }
    }
}
  • 쉽게 말하면 A가 B에 의존한다는 말은 A에서 B의 요소가 등장한다는 말입니다.
  • 그 말은 어떤 객체 안에서 다른 객체를 사용하기 때문에 그 다른 객체가 변경될 때, 함께 변경될 수 있다는 것을 말합니다.

의존성의 종류

  1. 연관관계
class A {
    private B b;
}
  • 연관관계는 영구적인 관계를 맺는다
  1. 의존관계
class A {
    public B method(B b) {
        return new B();
    }
}
  • 파라미터, 리턴 타입에 타입이 나오거나 생성한다면 의존관계
  • 일시적인 관계를 맺는다
  1. 상속관계
class A extends B {
}
  • B가 바뀔 때 A도 바뀐다
  • 구현이 변경되면 A도 영향을 받는다.
  1. 실체화관계
class A implements B{
}
  • 시그니처(B)가 바뀌면 영향을 받는다.

양방향 의존성을 제거하라

  • 양방향
class A {
    private B;
}

class B {
    private A;
}
  • 단방향
class A {
    private B;
}

class B {

  • 가장 좋은 건 의존성이 없는 것. 의존성이 필요없다면 제거하도록 합니다.
  • 이러한 의존이 발생하지 않도록 의존 역전 원칙(DIP) 라는 것이 있습니다.

의존성 주입(DI) 패턴

의존성 주입이란?

  • 내부가 아니라 외부에서 객체를 생성해서 넣어주는 것.

의존성 분리

  • 의존성 주입은 의존성을 분리시켜 사용한다.
  • 의존성을 분리시키는 방법으로는 대표적으로 인터페이스가 있다.
  • 이렇게 제어의 주체가 역전되는 상황을 IoC(Inversion Of Control)이라고 한다.

스프링에서의 의존성 주입은?

  • Spring에서 Bean을 사용하기 위해 의존성 주입을 처리하는 방법은 크게 3가지가 있다.
  • 생성자 주입 방식, 세터 주입 방식, @Autowired 를 이용한 주입 방식
    • 생성자 주입 방식은 생성하는 시점에 객체가 주입된다.
      • 생성자 방식을 사용한다면 객체를 생성하는 시점에 필요한 모든 의존 객체를 준비할 수 있고
      • 따로 setter가 없어 immutable 하다.
    • setter주입 방식은 객체를 찾거나 만든 후에 setter 메서드를 통해서 넣어준다.
      • 순환 참조, 양방향 의존을 막아줘야 하는데 setter는 그러지 못한다.
      • 또한 그래서 final도 못 써준다.
      • 설정 메서드 방식은 잘못하면 Null 예외가 날 수 있다.
    • @Autowired는 해당 객체를 생성한 후에 해당 어노테이션이 붙은 필드를 찾아 주입한다

스프링 에서 XML을 통한 DI 방식

  • 여기서도 생성자 방식과 설정 메서드 방식이 있다. 스프링은 XML 방식을 통해서 해준다.
  • constructor 태그를 통해서 생성자 방식으로 해줄 수 있고, property 태그를 통해서 설정 메서드 방식을 사용해줄 수 있다
  • 그러면 이제 (Worker) context.getBean("worker") 를 통해서 각자의 방식으로 불러올 수 있다.
  • XML 파일만 수정하면 되기 때문에 보다 유연하지만 XML을 디버깅하기 어렵다.
  • 그래서 요즘에는 자바 코드 안에서 어노테이션을 통해서 스프링 프레임워크를 설정해준다. @Bean
  • 그러나 이러면 XML 파일만 변경해주면 됐는데 자바 코드로 하게 되면 다시 컴파일하고 배포해줘야 한다.

DI와 테스트

DI의 장점

  • DI를 사용하면 의존 객체를 Mock 객체를 통해 쉽게 대체할 수 있도록 함으로써 단위 테스트를 할 수 있도록 돕는다.
  • 인터페이스를 상속한 클래스들은 Mock 객체를 이용해서 테스트할 수 있다.
  • Mockito 등…
  • DI를 사용하지 않고 테스트를 한다면 테스트를 하기 위해서 클래스를 고치고 그리고 그 고친 클래스 때문에 또 다른 클래스를 고쳐야한다.

서비스 로케이터를 이용한 의존 객체 사용

  • DI 패턴을 적용할 수 없는 경우, 서비스 로케이터를 구현해 각 객체를 넣어준 후, 가져와야 한다.
  • 접근을 위한 static 메서드를 만들고 원하는 곳에서 getInstance() 를 통해서 가져온 후, get 한다.

서비스 로케이터의 단점

  • 동일 타입의 객체가 다수 필요할 경우, 각 객체 별로 제공 메서드를 만들어줘야 함(계속 필드에 추가해줘야 함)
  • 서비스 로케이터 클래스에서 다른 구현체로 바꿔줘야 할 때 코드를 변경해줘야 한다.

출처

  • https://coding-start.tistory.com/264
  • 책 : 개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴
  • https://ko.dict.naver.com/seo.nhn?id=30296303
  • https://ko.dict.naver.com/#/search?range=word&query=%EC%9D%98%EC%A7%80%ED%95%98%EB%8B%A4
  • https://velog.io/@codemcd/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%84%B8%EB%AF%B8%EB%82%98-%EC%9A%B0%EC%95%84%ED%95%9C%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5-%EC%9D%98%EC%A1%B4%EC%84%B1%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%B4-%EC%84%A4%EA%B3%84-%EC%A7%84%ED%99%94%EC%8B%9C%ED%82%A4%EA%B8%B0-By-%EC%9A%B0%EC%95%84%ED%95%9C%ED%98%95%EC%A0%9C%EB%93%A4-%EA%B0%9C%EB%B0%9C%EC%8B%A4%EC%9E%A5-%EC%A1%B0%EC%98%81%ED%98%B8%EB%8B%98-vkk5brh7by
  • https://youtu.be/dJ5C4qRqAgA