클래스는 객체지향 프로그래밍을 위한 도구로서 많은 개발자들이 객체지향에 대해 생각하면 떠오르는 키워드일 것이다. 그러나 클래스는 프로그래밍 도구일 뿐 설계를 클래스에 초점을 두어선 안된다.
정말 중요한 것은 객체들이 주고 받는 메시지이며 이 메시지가 객체의 퍼블릭 인터페이스를 결정하고, 클래스를 결정한다.

협력과 메시지

클라이언트 - 서버 모델

협력은 어떤 객체가 다른 객체에게 무언가를 요청할 때 시작된다. [위프스브록(Wirfs-Brock03)]

메시지를 매개로 하는 요청과 응답의 조합이 두 객체 사이의 협력을 결정한다. 두 객체 사이의 협력 관계를 설명하기 위한 전형적인 메타포는 클라이언트 - 서버(Client - Server) 모델 이다.
클라이언트가 서버의 서비스를 요청하는 단방향 상호작용을 협력으로써 활용한다. 객체지향 관점에서 클라이언트는 메시지를 보내는 주체가 되는 객체이며 서버는 메시지를 수신받은 협력 객체이다.

객체는 협력에 참여하는 동안 클라이언트와 서버의 역할을 동시에 수행하는 것이 일반적이다. 객체 외부로 전송하는 메시지의 집합과 외부의 객체로부터 수신하는 메시지의 집합의 두 가지의 메시지 집합으로 구성된다.

메시지와 메시지 전송

메시지 (Message)

  • 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단이다.
  • 다른 객체에게 도움을 요청하는 것을 메시지 전송(message sending) 또는 메시지 패싱(message passing)이라 한다.
  • 메시지를 전송하는 객체를 메시지 전송자(message sender), 수신하는 객체를 메시지 수신자(message receiver) 라 한다.
  • 클라이언트 - 서버 모델에선 메시지 전송자를 클라이언트, 메시지 수신자를 서버라 할 수 있다.
  • 오퍼레이션 명 (operation name) + 인자 (argument)로 구성.
  • 메시지 전송 = [메시지 수신자] + [오퍼레이션 명] + [인자].

메시지와 메서드

메시지를 수신했을 때 실제로 어떤 코드가 실행되는지는 메시지 수신자의 실제 타입이 무엇이가에 달려있다. 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라 한다.
메시지와 메서드의 구분은 메시지 전송자와 메시지 수신자가 느슨하게 결합될 수 있게 한다. 전송자는 전송할 메시지만 알면 되고 수신자는 받은 메시지를 자율적으로 처리하면 된다.
이렇게 실행 시점에 메시지와 메서드를 바인등하는 메커니즘은 객체간의 결합도를 낮춰 확장 가능한 코드를 작성할 수 있게 한다.

퍼블릭 인터페이스와 오퍼레이션

객체의 내부는 해당 객체만이 알고있다. 외부에선 장막으로 가린 듯이 명확하게 구분되어있다. 이러한 외부와 단절된 객체가 의사소통을 위해 외부에 공개하는 메시지의 집합을 퍼블릭 메인터페이스라 한다.
프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션(operation)이라고 부른다. 런타임에서 메시지 전송자가 메시지를 보내면 메시지 수신자는 실제 타입을 기반으로 적절한 메서드를 찾아 실행한다.
퍼블릭 인터페이스와 메시지의 관점에서 보면 '메서드 호출'보다는 '오퍼레이션 호출'이라는 용어가 더 적절한 표현이다.

시그니처

오퍼레이션(또는 메서드)의 이름과 파라미터 목록을 합쳐 시그니처 (signature)라고 한다. 오퍼레이션은 실행 코드 없이 시그니처만을 정의한 것이다.

  • 메서드 = 시그니처 + 구현
  • 시그니처 = 오퍼레이션 명 + 인자

인터페이스와 품질

퍼블릭 인터페이스의 품질에 영향을 미치는 4가지 원칙

  • 디미터 법칙
  • 묻지 말고 시켜라
  • 의도를 드러내는 인터페이스
  • 명령 - 쿼리 분리

디미터 법칙(Law of Demeter)

객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하는 원칙이다.

낮선 자에게 말하지 마라 [Larman04]
오직 인접한 이웃하고만 말하라 [Metz12]
오직 하나의 도트만 사용하라 [Metz12]

디미터 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 대상에게만 메시지를 전송하도록 제한해야 한다.

모든 클래스 C와 C에 구현된 모든 메서드 M에 대해

  • M의 인자로 전달된 클래스
  • C의 인스턴스 변수의 클래스

이를 좀더 구체화 해보면 아래와 같이 나열할 수 있다.

  • this 객체
  • 메서드의 매개변수
  • this의 속성
  • this의 속성인 컬렉션의 요소
  • 메서드 내에서 생성된 지역 객체

디미터의 법칙을 따르면 부끄럼타는 코드(shy code) 를 작성할 수 있다. 즉, 불필요한 어떤 것도 외부에 공개하지 않으며 다른 객체의 구현에 의존하지 않는 코드를 작성 할 수 있다.
이로 인해 클라이언트와 서버 사이에 낮은 결합도를 유지할 수 있다.

디미터 법칙을 위반하는 코드의 경우 아래와 같은 모습일 것이다.

a.getB().getC().doSometing();

각 메서드의 결과 반환 타입이 A -> B -> C로 변화하는 것을 알 수 있다. 이 로직을 사용하는 부분은 A, B, C 모두에게 결합되어 유연하지 못한 코드가 된다.

묻지말고 시켜라

훌륭한 메시지는 객체의 상태에 관해 묻지 말고 원하는 것을 시키는 것이다. 메시지 전송자는 메시지 수신자의 상태를 기반으로 결정을 내린 후 메시지 수신자의 상태를 바꿔서는 안된다.
모든 구현되는 로직은 메시지 수자가 담당해야할 책임인 것이다.

묻지 말고 시켜라 원칙을 따르도록 메시지를 결정하면 자연스럽게 정보 전문가에게 책임을 할당하게 되고 높은 응집도를 가진 클래스를 얻을 확률이 높아진다.
객체 내부의 상태를 이용해 어떤 결정을 내리는 로직이 객체 외부에 존재한다면 해당 객체가 책임져야하는 어떤 행동이 객체 외부로 누수된 것이다.

의도를 드러내는 인터페이스

메서드의 이름을 짓는 방법에는 두 가지가 있다.

  • 메서드의 내부 구현 방법을 드러내는 이름 (어떻게 동작하는가?)
  • 메서드가 짊어진 책임이 무엇인지를 드러내는 이름 (무엇을 하는가?)

첫 번째의 경우는 나쁘지는 않지만 다형성과 같은 기법을 사용하기 어려워진다. 같은 역할의 다른 구현인 객체는 모두 다른 메서드 이름을 갖게 될 것이기 때문이다. 더 큰 문제는 메서드 수준에서 이미 캡슐화를 위반한다는 것이다.
이런 이름의 메서드들은 협력하는 객체의 종류를 알도록 강요한다.
두 번째의 경우를 따르게 되면 프로그래머는 메서드가 하는 일에 대한 충분한 정보를 보여주며 역할과 책임에 기반한 코드를 작성할 수 있다.
이 원칙에서 중요한 것은 구현과 관련된 모든 정보를 캡슐화하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현한다는 것이다.

원칙의 함정

디미터 원칙과 묻지말고 시켜라 스타일은 객체지향에서 좋은 원칙임에는 분명하다. 그러나 그 어떤 원칙도 절대적인 것은 아니다.
설계는 트레이드오프의 결과물이라는 사실을 잊지 말아야 한다. 적용하려는 원칙이 충돌하는 상황에서도 원칙에 너무 얽메여 억지로 끼워맞추는 상황이 발생하고 결과적으로 설계는 일관성을 잃고 코드는 무질서 속에 파묻히게 된다.

디미터 법칙은 하나의 도트(.)를 강제하는 규칙이 아니다.

앞서 말한 디미터 법칙에서 단 하나의 도트를 사용하라[Metz12]고 했었다. 이를 보고 대부분의 사람들이 IntStream이 디비터 법칙을 위반했다고 생각할 것이다.
그러나 IntStream의 메서드는 다른 어떤 것을 반환하지 않고 또다른 IntStream의 인스턴스를 반환한다. A 가 B 가 되는 것이 아닌 A 가 A'이 되는 것일 뿐이다.
디미터 법칙은 결합도와 관련된 것이며 결합도가 문제가 되는 것은 객체의 내부 구조가 외부로 노출되는 경우로 한정된다.

결합도와 응집도의 충돌

묻지말고 시켜라와 디미터 법칙을 준수하는 것이 항상 긍정적인 결과로만 귀결되는 것은 아니다. 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. 결과적으로 객체는 필요없는 책임을 지게 되어 응집도가 낮아진다.

가끔씩은 객체의 상태를 묻는것 외에 다른 방법이 없는 경우도 있다. 컬렉션에 포함된 객체들을 처리하는 유일한 방법은 객체에게 물어보는 것 뿐이다. 객체가 정말로 데이터인 경우에는 묻지 않고서는 처리항 방법이 없다.
객체에게 시키는 것이 항상 가능한 것은 아니다. 늘 "경우에 따라 다르다"라는 사실을 명심해야한다.

명령 쿼리 분리 원칙

명령 - 쿼리 분리(Command - Operation Separation) 원칙은 퍼블릭 인터페이스에 오퍼레이션을 정의할 때 참고할 수 있는 지침을 제공한다.

  • 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈을 루틴(routine) 이라 한다.
  • 루틴은 프로시저와 함수로 구분할 수 있다.
  • 프로시저는 부수효과를 발생시킬 수 있지만 값을 반환할 수 없다.
  • 함수는 값을 반환할 수 있지만 부수 효과를 발생시킬 수 없다.

명령(Command)쿼리(Query) 는 객체의 인터페이스 측면에서 프로지서와 함수를 부르는 또 다른 이름이다.

  • 객체의 상태를 변경하는 명령은 반환값을 가질 수 없다.
  • 객체의 정보를 변환하는 쿼리는 상태를 변경할 수 없다.

어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안된다. 명령-쿼리 분리 원칙을 한 문장으로 표현하면 "질문이 답변을 수정해서는 안된다." 는 것이다.

명령-쿼리 분리와 참조 투명성

참조 투명성 (referential transparency) 이란 어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성을 의미한다.

f(1) + f(1) = 6
f(1) * 2 = 6
f(1) - 1 = 2

3 + 3 = 6
3 * 2 = 6
3 - 1 = 2

참조 투명성을 만족하는 식은 두 가지 장점을 제공한다.

  • 모든 함수를 이미 알고 잇는 하나의 결과값으로 대체할 수 있기 때문에 쉽게 계산할 수 있다.
  • 모든 곳에서 함수의 결과값이 동일하기 때문에 식의 순서를 변경하더라도 각 식의 결과는 달라지지 않는다.

책임에 초점을 맞춰라

앞서 소개된 원칙들의 장점을 정리하면 다음과 같다.

  • 디미터 법칙
    협력이라는 컨텍스트 안에서 객체보다 메시지를 먼저 결정하면 두 객체 사이의 구조적인 결합도를 낮출 수 있다. 수신할 객체를 알지 못한 상태에서 메시지를 먼저 선택하기 때문에 객체의 내부 구조에 대해 고민할 필요가 없어진다.
    따라서 메시지가 객체를 선택하게 함으로써 의도적으로 디미터 법칙을 위반할 위험을 최소화할 수 있다.
  • 묻지 말고 시켜라
    메시지 를 먼저 선택하면 묻지 말고 시켜라 스타일에 따라 협력을 구조화하게 된다. 클라이언트의 관점에서 메시지를 선택하기 때문에 필요한 정보를 물을 필요 없이 원하는 것을 표현한 메시지를 전송하면 된다.
  • 의도를 드러내는 인터페이스
    메시지를 먼저 선택한다는 것은 메시지를 전송하는 클라이언트의 관점에서 메시지의 이름을 정한다는 것이다. 당연히 그 이름에는 클라이언트가 무엇을 원하는지, 그 의도가 분명하게 드러날 수 밖에 없다.
  • 명령-쿼리 분리 원칙
    메시지를 먼저 선택한다는 것은 협력이라는 문맥 안에서 객체의 인터페이스에 관해 고민한다는 것을 의마한다. 객체가 단순히 어떤 일을 해야하는지 뿐만 아니라 협력 속에서 객체의 상태를 예측하고 이해하기 쉽게 만들기 위한 방법에 관해 고민하게 된다.
    따라서 예측 가능한 협력을 만들기 위해 명령과 쿼리를 분리하게 될 것이다.

훌륭한 메시지를 얻기 위해서는 책임 주도 설계 원칙을 따라야 한다. 객체가 메시지를 선택하는 것이 아니라 메시지가 객체를 선택하는 것이기 때문에 적합한 메시지를 결정할 수 있을 확률이 높아진다.


쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있습니다.

오브젝트:코드로 이해하는 객체지향 설계


책임에 초점을 맞춰 설계할 때 가장 큰 어려움은 어떤 객체에게 어떤 책임을 할당할지를 결정하기 쉽지 않다는 것이다.
모든 책임 활동은 트레이드오프 활동이며, 여러 방법중 최선의 할당 방법을 선택하는 것이다.

책임 주도 설계를 향해

책임 중심 설계를 위한 2가지 원칙

  • 데이터보다 행동을 먼저 결정하라
  • 협력이라는 문맥 안에서 책임을 결정하라.

위의 두 원칙의 핵심은 객체를 데이터가 아닌 협력을 중심으로 생각하는 것이다.

데이터보다 행동을 먼저 결정하라.

데이터는 객체가 책임을 수행하는 데 필요한 재료를 제공할 뿐이다. 책임 중심 설계는 "이 객체가 수행헤야할 책임은 무엇인가?"를 먼저 결정한 후에
"이 책임을 수행하는데 필요한 데이터는 무엇인가"를 결정한다. 책임을 먼저 결정한 후에 객체의 상태를 결정하는 것이다.

협력이라는 문맥 안에서 책임을 결정하라.

책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 한다. 협력은 메시지를 전송하는 객체로부터 시작되기 때문에 적합한 책임이란 메시지 전송자에게 적합한 책임을 의미한다.
즉, 메시지를 전송하는 클라이언트에게 적합한 책임을 할당해야 한다.

객체를 결정한 후에 메시지를 선택하는것이 아닌, 메시지를 결정한 후에 적합한 객체를 선택해야 한다. 메시지가 존재하기 때문에 객체가 존재하는 것이다.

객체를 갖고있기 때문에 메시지를 보내는것이 아니다. 메시지를 전송하기 때문에 객체를 갖게 된 것이다.[Metz12].

문맥 안에서 메시지에 집중하는 책임은 더 깔끔하고 수월하게 캡슐화의 원칙을 지켜낼 수 있다. 반면 데이터 중심의 설계는 내부의 상태를 밖에서 알아야하기 때문에 캡슐화가 약화된다.

책임 주도 설계

핵심은 책임을 결정한 후에 책임을 수행할 객체를 결정하는 것이다. 협력에 참여하는 객체들의 책임이 어느정도 정리될 때까지는 객체 내부의 상태에는 관심을 가지지 않는다.

책임 할당을 위한 GRASP 패턴

GRASP 패턴이란?

  • General Responsibility Assignment Software Pattern [Craig Larman]
  • 일반적 책임 할당을 위한 소프트웨어 패턴
  • 객체에게 책임을 할당할 때 지침으로 사용할 수 있는 원칙들
  • 총 9가지 원칙
    • Information Expert (정보 전문가)
    • Creator (창조자)
    • Controller (조율자)
    • Low coupling (낮은 결합도)
    • Polymorphism (다형성)
    • Pure fabrication (순수 제작?)
    • Indirection (간접 참조)
    • Protected variation (변경 보호)

도메인 개념에서 출발하기

어떤 책임을 할당할 때 가장 먼저 고려해야할 것은 도메인이다. 도메인 안에는 무수히 많은 개념이 존재하며 도메인의 개념들을 책임 할당의 대상을 생각하면 좀더 수월하다.
그러나 도메인 개념을 정리하는데 오랜 시간을 할애해서는 안된다. 완벽한 도메인 모델이란 존재하지 않고, 정말 중요한 것은 설계를 시작하는 것이기 때문이다.

정보 전문가에게 책임을 할당하라

앞서 2장에서 본 내용이다. 어떤 책임을 수행하기에 가장 적합한 객체는 그 책임에 대해 가장 잘 알고 가장 잘 할수 있는 객체이다.
가장 처음 객체에게 책임을 할당하기 위한 질문은 어떤 메시지가 필요한지에 대한 질문이다.

메시지를 전송할 객체는 무엇을 원하는가?

이는 해결해야할 문제를 파악하는 질문이기도 하다. 해결해야할 문제가 무엇이고 해결하기 위해서 필요한 메시지는 무엇인지를 찾는다.

메시지를 결정한 다음으로 생각해야할 질문을 이 메시지를 처리할 객체는 무엇인가에 대한 질문이다.

메시지를 수신할 적합한 객체는 누구인가?

이 질문에 답하기 위해서는 객체가 상태화 행동을 통합한 캡슐화의 단위라는 사실에 집중해야 한다. 객체의 책임과 책임을 수행하는데 필요한 상태는 동일한 객체 내에 존재해야 한다.
따라서 객체에게 책임을 할당하는 첫 번째 원칙은 정보 전문가에게 책임을 할당하는 것이다.

정보 전문가 패턴(Information Expert pattern)은 객체가 자신이 소유하고 있는 정보와 관련된 작업을 수행한다는 일반적인 직관을 표현한 것이다.
여기서 한가지 중요한 것은 정보 !== 데이터 라는 사실이다. 객체가 정보를 필요로 한다 해서 꼭 저장 하고있을 필요는 없다.

높은 응집도와 낮은 결합도

높은 응집도와 낮은 결합도는 객체에 책임을 할당할 때에 항상 고려해야 하는 기본 원리이다. 객체가 자신의 밖의 내용을 많이 알 수록 결합도는 높아지며 내부에만 집중할 수록 응집도는 높아진다.

위 그림은 높은 결합도를 간략히 그림으로 나타낸 것이다. D 객체에서 C 객체가 참조하고있는 내용에 변경이 생기면 C 객체도 변경해야 하며 이 변경은 다시 C 객체의 내부에 의존하고있는 다른 객체에게도 전파된다.

위 그림은 각 객체가 높은 응집도를 보일 경우를 그림으로 표현한 것이다. 각 객체의 관심은 내부에 집중되어있기 때문에 각 객체의 변경은 외부를 오염시키지 않고 객체 내부에서만 유지된다.

낮은 결합도와 높은 응집도는 개발 이후의 유지 보수성을 높여준다. 객체가 자기 자신에게 집중할 수록 변경은 외부로 흘러나가지 않고, 외부의 객체는 변경되지 않는 퍼블릭 인터페이스를 참조한다.
Low Coupling(낮은 결합도)와 High Cohesion(높은 응집도)는 설계를 진행하면서 책임과 협력의 품질을 검토하는데 사용할 수 있는 중요한 평가 기준이다.

창조자에게 객체 생성 책임을 할당하라.

Creator 패턴의 의도는 어떤 방식으로든 생성되는 객체와 연결되거나 관련될 필요가 있는 객체에 해당 객체를 생성할 책임을 맞기는 것이다.
이 둘은 이미 강한 결합을 갖고있기 때문에 이 객체에 생성 책임을 할당하는 설계의 전체적인 결합도에 영향을 미치지 않는다. 이미 존재하는 객체 사이의 관계를 사용하기 때문에 전체 설계는 낮는 결합도를 유지할 수 있다.

다형성을 통해 분리하기

객체의 타입에 따라 행동이 달라진다면, 우선 객체를 타입으로 분리하고 행동을 각 타입의 책임으로 할당하는 방법을 GRASP에서는 Polymorphism이라 한다.
로직의 분기를 if

else, switch

case 로만 분리한다면, 이후 유지보수시 수정을 어렵게하고 변경에 취약하게 만든다. 이때 다형성을 사용해 분리하게되면 확장성을 챙길 수 있게된다.

변경으로부터 보호하기

Protected Variation(변경 보호)는 쉽게 말해 변경할 이유에 따라 분리하고, 캡슐화 하는 것을 말한다. 하나의 객체는 하나의 변경이유로만 변경해야 한다.
하나의 변경을 위해 여러 객체를 변경해야 하는 것은 프로그램의 유연성을 떨어뜨리고 확장성을 떨어뜨린다.

이를 위해서는 변경 예상지점을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당해야 한다. 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화 하는 것은 변화의 전파를 최소화 한다.

변경과 유연성

개발자들이 변경에 대비할 수 있는 방법은 두 가지가 있다.

  1. 코드를 이해하고 수정하기 쉽도록 단순하게 작성
  2. 코드를 수정하지 않고도 변경을 수용할 수 있도록 유연하게 설계

대부분의 경우 1.이 좋은 방법이지만, 코드가 빈번하게 반복될 경우 복잡성을 감수하더라도 2.를 하는것이 더 좋은 방법이다.

책임 주도 설계의 대안

책임 주도 설계를 적용하는데 어려움이 있다면, 다음의 대안을 활용할 수 있다.
우선 목표로 하는 기능을 우선 구현한 후, 해당 기능을 유지한 채 리팩터링 하는 것이다.

메서드 응집도

복잡한 기능을 구현하다보면 하나의 메서드가 프로세스와 같이 굉장히 길어지고 파악하기 어려워지는 경우가 생긴다. 이런 메서드를 몬스터 메서드라고 부른다. [Michael Feathers]
대부분의 이런 메서드들은 응집도가 낮아 변경의 이유가 하나 이상이며 메서드 흐름을 파악하기 위한 주석이 필요하다.

응집도 높은 메서드는 변경의 이유가 단 하나여야 한다. 메서드의 흐름을 한 눈에 볼 수 있어야 하며 메서드의 흐름을 읽는 것 자체가 일련의 주석을 읽는 것과 같이 간결해야 한다.

객체를 자율적으로 만들자

메서드의 응집도를 높여 분리했다면 객체의 응집도를 높일 차례다. 응집도 높은 메서드 중 객체의 책임에 맞지 않는 메서드를 올바른 책임을 진 객체로 옮기는 것이다.
리팩터링 결과는 올바름 책임을 갖고, 하나의 관심사에 집중되어있는 설계를 얻게 된다. 높은 캡슐화, 높은 응집도, 낮은 결합도의 설계가 구현되는 것이다.


쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있습니다.

오브젝트:코드로 이해하는 객체지향 설계


객체지향 패러다임의 관점에서 핵심은 역할(role), 책임(responsibility), 협력(collaboration) 이다.
객체지향 설계의 핵심은 협력을 구성하기 위해 적쩔한 객체를 찾고 적절한 책임을 할당하는 과정에서 드러난다.

역할

  • 객체들이 협력 안에서 수행하는 책임들이 모여 객체가 수행하는 역할을 구성.
    책임
  • 객체가 협력에 참여하기 위해 수행하는 로직.
    협력
  • 애플리케이션의 기능을 구현하기위해 수행하는 상호작용.

협력

객체들은 혼자서 존재할 수 없다. 다른 객체와의 협력을 통해 기능을 구성한다. 다른 객체와의 협력할 수 있는 유일한 수단은 메시지 전송(message sending) 뿐이다.
객체는 다른 객체의 상세한 내부 구현에 접근할 수 없기 때문에 오직 메시지 전송을 통해 자신의 요청을 전달한다.
메시지를 수신한 객체는 메시지에 응답하기 위해 알아서 실행할 메서드를 골라 수행한다.

협력은 객체를 설계하는데 필요한 일종의 문맥(context) 를 제공한다.

객체가 참여한ㄴ 협력이 객체를 구성하는 행동과 상태를 모두 결정한다.

책임

협력에 참여하기위해 객체가 수행하는 행동을 책임이라 한다. 책임은 객체에 의해 정의되는 응집도 있는 행위의 집합이다.
크게 하는 것(doing)아는 것(knowing) 의 두가지로 나누어 세분화 할 수 있다 [크레이그 라만]

하는 것

  • 객체를 생성하거나 계산을 수행 하는 등의 스스로 하는 것.
  • 다른 객체의 행동을 시작시키는 것.
  • 다른 객체의 활동을 제어하고 조절하는 것.

아는 것

  • 사적인 정보에 관해 아는 것.
  • 관련된 객체에 관해 아는 것.
  • 자신이 유도하거나 계산할 수 있는 것에 관해 아는 것.

일반적으로 책임과 메시지의 크기는 다르다. 책임은 객체가 수행해야 할 행동을 종합적이고 더 간략하게 서술하기 때문에 더 추상적이고 개념적으로도 더 크다.
책임의 관점에서 아는 것과 하는 것이 밀접하게 연관되어있다. 어떤 책임을 수행하기 위해서는 그 책임을 수행하는데 필요한 정보도 함께 알아야 하기 때문이다.

책임 할당

INFORMATION EXPERT PATTERN (정보 전문가 패턴)

책임을 수행하는 데 필요한 정보를 가장 잘 알고 있는 전문가에게 그 책임을 할당하는 방법이다. 이는 일상상활에서 어떤 문제를 해결하기 위해 그 분야의 전문가에게 도움을 구하는 것과 같다.
그 전문가 객체는 자율적으로 자신이 가장 잘 알고있는 정보와 방법을 이용해 문제를 해결하고 메시지에 응답한다.

책임 주도 설계 (RDD - Responsibility driven design)

어떤 책임을 선택하느냐가 전체적인 설계의 방향과 흐름을 결정한다. 먼저 책임을 먼저 찾고 책임을 수행할 적절한 객체를 찾아 책임을 할당하는 방식으로 협력을 설계하는 방식이다.

  • 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다.
  • 시스템 책임을 더 작은 책임으로 분할한다.
  • 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.
  • 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.
  • 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 한다.

책임을 할당할 때에 고려해야 하는 두가지

  • 메시지가 객체를 결정한다.
  • 행동이 상태를 결정한다.

메시지가 객체를 결정한다.

메시지가 객체를 선택헤야하는 두 가지 중요한 이유.

  • 객체가 최소한의 인터페이스를 가질 수 있게 된다.
    필요한 메시지가 식별될 때까지 퍼블릭 인터페이스에 어떤 것도 추가하지 않기 때문에 필요한 크기의 인터페이스를 갖게 된다.

  • 객체는 충분히 추상적인 인터페이스를 가질 수 있게 된다.
    메시지는 외부의 객체가 요청하는 무언가를 의미하기 때문에 메시지를 먼저 식별하면 무엇을 수행할지에 초점을 맞추는 인터페이스를 얻을 수 있다.

협력을 구성하는 객체들의 인터페이스는 충분히 추상적인 동시에 최소한의 크기를 유지할 수 있다.

행동이 상태를 결정한다.

객체의 행동은 협력에 참여할 수 있는 유일한 방법이다. 객체가 협력에 적합한지를 결정하는 것은 객체의 상태가 아닌 행동이다.

Data-Driven Design (데이터 주도 설계)

객체의 내부 구현에 초점을 맞춘 설계 방법으로 책임 주도 설계와는 반대로 객체 내부의 상태를 우선 생각하고 인터페이스를 구성하는 방법이다.
DDD의 문제는 내부 구현이 퍼블릭 인터페이스로 노출된다는 것에 있다. 객체 내부의 상태를 컨트롤하기 위해 getter, setter가 퍼블릭 인터페이스로 노출이되어 결국 캡슐화를 저해한다.

캡슐화를 위반하지 않도록 구현에 대한 결정을 뒤로 미루면서 객체의 행위를 고려하기 위해서는 항상 협력이라는 문맥 안에서 객체를 생성해야 한다.
상태는 단지 객체가 행동을 정상적으로 수행하기 위해 필요한 재료일 뿐이다.

역할

역할과 협력

객체가 어떤 특정한 협력 안에서 수행해야할 책임의 집합을 역할이라 한다. 협력을 모델링 할때는 특정한 객체가 아니라 역할에게 책임을 할당한다 생각하는게 좋다.

유연하고 재사용 가능한 협력

역할이 중요한 이유는 역할을 통해 유연하고 재사용 가능한 협력을 얻을 수 있기 때문이다. 객체가 아닌 책임에 초점을 맞추면 만들어야할 객체의 추상화된 개념을 알 수 있다.
이를 통해 설계단계에서 효과적으로 중복을 제거할 수 있다. 역할은 동일한 역할을 하게 될 다른 객체를 포괄하는 추상화라는 점을 주목해야한다.

객체 대 역할

역할은 객체가 참여할 수 있는 일종의 슬롯이다. 협력에 오직 한 가지의 객체만 참여한다면 역할을 고려해야 할까?

협력에 참여하는 후보가 여러 종류의 객체에 의해 수행 될 필요가 있다면 그 후보는 역할이 되지만, 단지 한 종류의 객체만이 협력에 참여할 필요가 있다면 후보는 객체가 된다.
-- 레베카 워프스브록 --

즉 협력에 적합한 대상이 한 종류라면 간단히 개게로 간주하고, 대상이 여러 종류라면 역할로 간주하면 된다.

협력은 역할들의 상호작용으로 구성되고, 협력을 구성하기 위해 역할에 적합한 객체가 선택되며, 객체는 클래스를 이용해 구현되고 생성된다. -- 트리그비 린스카우
협력(Collaboration) -- referene --> 역할(Role) -- select from --> 객체(Object) -- instance of --> 클래스(Class)

역할과 추상화

추상화는 세부 사항에 억눌리지 않고도 상위 수준의 정책을 쉽고 간단하게 표현할 수 있다. 불필요한 세부 사항을 생략하고 핵심적인 개념을 강조할 수 있다.
협력이라는 관점에서는 세부적인 사항을 무시하고 추상화에 집중하는 것이 유리하다.

또한 추상화는 설계를 유연하게 만들수 있다. 역할을 다양한 종류의 객체가 놓일 수 있는 슬롯이라 생각할 수 있다. 컴파일 타임과 런타임의 의존성을 다르게 할 수 있다.
협력안에서 동일한 책임의 다른 객체는 서로 대체될수 있다. 역할은 다양한 환경에서 다양한 객체들을 수용할 수 있게 해주므로 협력을 유연하게 만든다.


쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있습니다.

오브젝트:코드로 이해하는 객체지향 설계


요구사항 확인

모든 설계가 그러하듯 요구사항을 확인하는 것이 우선이다. 요구사항 분석을 통해 필요한 객체를 정할 수 있고, 각 객체의 책임을 어떻게 분배할지 방향을 잡을 수 있다.

협력, 객체, 클래스

보통 프로그래밍을 어떻게 하는지 생각해보자. 객체지향으로 프로그래밍을 한다고 했을때 가장 먼저 하는것이 무엇인가? 대부분 어떤 class 가 필요할 지 고민한다.
클래스를 먼저 고민하고, 각 클래스는 어떤 속성과 메서드를 가지고 있어야 할 지를 생각한다.

객체지향 프로그래밍을 위해서는 우선 클래스가 아닌 객체 에 초점을 맞춰야 한다.

  1. 어떤 클래스가 필요한 지가 아닌, 어떤 객체가 필요한지를 생각한다.
    클래스는 공통적인 상태와 행동을 공유하는 객체를 추상화 한것이다. 추상화를 하기 위한 개념을 먼저 정리해야한다.

  2. 객체를 독립적인 존재가 아니라 기능을 구현하기위해 협력하는 공동체의 구성원으로 봐야한다.
    객체는 혼자서 할 수 있는 일은 거의 없다. 다른 객체와 상호작용하며 서로 협력하는 존재이며 살아 움직이는 생명체로서 봐야한다.

도메인의 구조를 따르는 프로그램 구조

도메인?

도메인이란 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야라 할수 있다. 영화 예매 시스템을 예로 들면, 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결 하는 것이다.
여기서 도메인은 영화 예매라 할 수 있다. 도메인은 이처럼 문제를 다루는 범위로 볼 수 있으며 더 작은 단위로도 생각해 볼 수 있다.

자바와 같은 객체지향 언어를 사용하면 이런 도메인이 class로 이어진다는 것을 쉽게 볼 수 있다. 클래스의 이름은 도메인을 쉽게 유추할 수 있는 이름으로 해야 하고 클래스 간의 관계 또한 도메인의 관계가 반영되어야 한다.

클래스 구현

도메인의 정의 다음으로 해야할 일은 도메인을 반영하는 클래스를 구현하는 것이다. 이때, 클래스 외부와 내부의 경계를 구분지어 명확한 경계를 세워야한다. 클래스를 구현하거나 다른 개발자에 의해 개발된 클래슬ㄹ 사용할 때 가장 중요한 것은 클래스의 경계를 구분짓는 것이다.
이 경계가 명확할 수록 객체의 자율성이 보장된다 (chapter 01 참고). 또한 프로그래머에게 구현의 자유를 제공하기 때문에 경계를 명확히 해야 한다.

자율적인 객체

객체에 대해 명심해야할 두 가지 사실이 있다.

  1. 객체는 상태 (state)와 행동 (behavior)ㅇㅇ 함께 가지는 복합적인 존재이다.
  2. 객체는 스스로 판단하고 행동하는 자율적인 존재이다.

객체 지향 개념은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로서 문제 영역의 아이디어를 적절하게 표현할 수 있게한다 (캡슐화).
객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서이다. 객체 외부의 그 무엇도 객체 내부의 상태에 직접 간섭해서는 안되며 객체가 어떤 생각을 하는지도 알아선 안된다.

객체의 캡슐화와 접근제어는 2가지로 나뉜다.

  1. public interface (퍼블릭 인터페이스)
    외부에서 접근 가능한 부분이며 메시지 교환을 위해 외부에 노출하는 부분이다.

  2. implementation (구현)
    외부에서는 접근 불가능하고 오직 내부에서만 접근 가능한 부분이다.

이는 인터페이스와 구현의 분리 원칙으로 훌륭한 객체지향 프로그램의 원칙 중 하나이다. 일반적으로 객체의 상탠ㄴ 숨기고 행동만 외부에 공개해야 한다. 속성은 private로 감추고 메서드는 public으로 공개하는 식이다.

프로그래머의 자유

프로그래머의 역할을 클래스 작성자와 클라이언트 프로그래머로 구분하는 것이 유용하다 [Eckel06].

클래스 작성자는 새로운 데이터 타입을 추가하고, 클라이언트 프로그래머는 클래스 작성자가 추가한 데이터 타입을 사용한다.
클래스 작성자는 클라이언트 프로그래머가 접근하면 안되는 곳을 접근 제한을 통해 구현 은닉(implementation hiding)을 통해 제한한다. 이를 통해 클래스 작성자는 클라이언트 프로그래머에게 미칠 영향으로부터 자유롭게 내부 구현을 변경할 수 있다.
이 구현 은닉은 양쪽에게 이로운 개념이다. 클라이언트 프그래머는 내부 구현은 무시한 채 공개 인터페이스만 알고 있으면 된다. 클래스 작성자는 외부에 미칠 영향을 걱정하지 않고 변경을 할 수 있다.

협력하는 객체들의 공동체

앞서 말했듯 객체들은 서로 협력하며 존재한다. 객체는 다른 객체의 인터페잇에 공개된 행동을 수행하도록 요청할 수 있다. 요청을 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답한다.
객체가 받은 요청을 처리하기 위한 방법을 메서드라고 한다.

추상화 - 상속과 다형성

컴파일 시간 의존성과 실행시간 의존성

코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다. 유연하고 쉽게 재사용 할 수 있으며 확장 가능한 설계가 된다.
그러나 이렇게 유연해 질 수록 복잡도는 높아진다. 실행시점과 컴파일 시점의 의존성이 달라지면 코드를 이해하기 어려워진다. (Trade off :: 유연성, 재사용성 vs 유지보수)

차이에 의한 프로그래밍

상속은 코드를 재사용하기 위해 가장 널리 사용되는 방법이다. 부모의 코드를 그대로 자식에게 물려줄 수 있으며 자식은 부모와 다른 부분만을 구현하면 쉽게 확장할 수 있다. 이런 방식을 차이에 의한 프로그래밍이라 한다.

상속이 가치있는 이유는 부모의 변수를 재활용하기 때문이라기 보다는 부모가 가진 모든 인터페이스를 자식 클래스가 물려 받을 수 있기 때문이다. 인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의하는 것임을 생각하면 알수 있다.
실제로 객체간의 관계에서 중요한 것은 어떤 인스턴스인지가 아니라 어떤 메시지를 수신할 수 있느냐가 중요한 것이다.

런타임에서 자식은 부모의 모든 메시지를 수신 할 수 있기에 부모 클래스와 같은 타입으로 취급하기도 한다. 이를 업캐스팅이라 하는데 자식 클래스가 부모의 타입으로 자동 형변환이 되는 것을 말한다.

다형성

메서드와 메시지는 다른 개념임을 상기하자. 객체가 메시지를 수신하면 어떤 메서드를 실행할 지는 그 객체와 연결된 클래스가 무엇인가에 따라 달라진다.
다시 말해 동일한 메시지에 대해 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성이라 부른다.

학부 과정을 이수했다면 들어봤을 동적바인딩, 정적바인딩이 여기서 사용된다. 다형성을 구현하는 방식은 다양하지만 메시지와 메서드를 실행 시점에 연결한다는 것은 동일하다.
이때 컴파일 타임에 연결하게되면 정적 바인딩, 실행 시점에 연결하는 것을 동적 바인딩이라 한다.

인터페이스와 다형성

자바 프로그래머에게 친숙한 인터페이스는 이러한 다형성을 위해 제공된 프로그래밍 요소이다. 클래스의 구현은 필요없고 순수하게 인터페이스 만을 공유하고 싶을때 사용한다.
기본적으로는 Abstract Base Class로 인터페이스를 정의할 수 있다.

추상화와 유연성

추상화의 힘

추상화는 크게 2가지의 장점을 갖고있다.

  1. 추상화 계층만 보면 요구 사항에 대해 높은 수준에서 서술할 수 있다.
    세부적인 정책을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다. 이는 다른 요소를 배제하고 상위 개념만으로 도메인의 중요 개념을 설명할 수 있게 해준다.

  2. 추상화를 이용하면 설계가 유연해진다.
    상위 정책을 추상화를 통해 표현하면 구조를 수정핮지 않고 새로운 기능을 쉽게 추가하고 확장할 수 있다.

유연한 설계

추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다. 세부 정책을 구현한 클래스가 추상화된 상위 정책을 상속받고 있다면 어떤 클래스와도 협력 가능하다.

추상 클래스와 인터페이스 트레이드 오프

구현과 관련된 모든 것들은 트레이드 오프의 대상이 될 수 있다. 코드의 작성에 있어 이유가 없는 코드는 없어야 한다. 사소한 결정이라도 심사숙고한 끝에 나온 코드와 그렇지 않은 코드는 품질에서 큰 차이가 있다.

코드 재사용

상속은 일반적으로 코드를 재사용하는데 널리 쓰이는 방법이다. 그러나 널리 사용된다고 해서 베스트는 아니다. 합성(composition)도 들어 보았을 것이다. 사용하고자 하는 코드를 담고있는 인스턴스를 변수로 포함해 사용하는 방식이다.
많은 컨텍스트나 프레임워크들이 합성을 선호하는 모습을 보인다., Spring framework애서도 대부분의 의존성을 합성 관계로 사용하는 것을 볼 수 있다.

상속

왜 상속 대신 합성을 선호할까? 상속의 문제점은 캡슐화를 위반한다는 것과 설계를 유현하지 않게 한다는 것이다.
가장 큰 문제는 캡슐화를 위반한다는 것이다. 상속을 하기 위한 기본 전제는 부모 클래스를 잘 알고있어야 한다는 것이다. 부모 내부의 다른 메서드가 내부의 어떤 추상 메서드를 호출하고 있다는 것을 알고 이를 구현해야 한다.
결과적으로 자식에게 부모의 구현이 고스란히 노출되기 때문에 캡슐화가 약화된다. 부모가 변경된 경우 자식도 변경될 확률을 높인다.

설계가 유연하지 않게 되는 것은 부모와 자식의 관계가 강하게 결합되기 때문이다. 부모와 자식의 관계는 컴파일 시점에 결정되기 때문에 실행 시점에 객체의 종류를 변경할 수 없게된다.

합성

앞서 말했듯 합성은 인스턴스를 내부에 포함하는 방법으로, 객체의 인스턴스를 통해서만 코드를 재사용할 수 있다. 합성은 상속이 가지는 두가지 문제점을 모두 해결한다.
인터페이스에 정의된 메시지만을 사용하기 때문에 구현을 효과적으로 캡슐화할 수 있다. 또한 의존하는 클래스를 교체하기 쉽기 때문에 설계를 유연하게 만든다.
따라서 코드 재사용을 위해서는 상속 보다는 합성을 선호하는 것이 더 좋은 방법이다.

그러나 합성만을 사용하는 것은 좋은 방법이 아니다. 대부분의 설계는 상속과 합성을 함께 사용해야 한다. 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수밖에 없다.


쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있습니다.

[[위키북스] 오브젝트))](https://coupa.ng/bPjrmA)


객체

응집도와 캡슐화 (Encapsulation)

객체 내부의 상태를 캡슐화하고 객체 간에는 오직 메시지를 통해서만 상호작용하도록 만드는 것을 캡슐화(Encapsulation)이라 한다.
자신 이외의 객체는 내부의 상태, 구현등을 알 수 없어야 한다.

어느 한 객체가 다른 객체의 내부를 속속들이 알 수 있을 때, 결함도가 높다 할 수 있다. 결합도가 높을 수록 변경이 발생할 때 그 범위가 커진다.
알고있는 것이 많을 수록 건드려야 할 부분이 많아진다.

반대로 객체가 적절히 캡슐화되어 자신이 해야할 일만 하게 되어 자율적인 객체가 될 수록 응집도가 높아지고, 결합도가 낮아진다 할 수 있다.
결합도가 낮을 수록, 응집도가 높을 수록 변경에 유연해진다. 유지 보수에 필요한 수고가 줄어들게 된다.

객체는 자신의 데이터를 스스로 처리하는 자율적인 존재여야한다.외부의 간섭을 ㅊ최대한 배체하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 객체지향 설계이다.

절차지향과 객체지향

절차적 프로그래밍?

하나의 기능에 여러 다른 데이터를 알고, 사용하는 방식의 프로그래밍을 절차적이라 할 수 있다.
프로세스와 데이터를 각기 다른 모듈로 위치하고, 프로세스에서 데이터를 다루는 방식을 절차적 프로그래밍이라 할 수 있다.
이 경우, 어느 한 곳에서 변경이 발생하면, 해당 프로세스에 참여한 다른 구성원에도 변경이 필요할 수 있다. 즉, 변경에 유연하지 않다.

이 경우, 절차를 말로 서술해보면 직관에 맞지 않게 된다.
저자는 이에 소극장을 예로 들며 설명한다.

소극장은 관람객의 가방을 열어 그 안에 초대장이 들어있는지 살펴본다. 가방 안에 초대장이 들어 있으면 판매원은 매표소에 보관 돼있는 티켓을 관람객의 가방 안으로 옮긴다.
가방 안에 초대장이 들어있지 않다면 관람객의 가방에서 티켓 금액만큼의 현금을 꺼내 매표소에 적립한 후에 매표소에 보관돼 있는 티켓을 관람객의 가방 안으로 옮긴다.

절차지향 프로그래밍은 우리의 일반적인 관념과 일치하지 않는 모습을 보인다. 이는 데이터에 해당하는 구성원들은 모두 수동적인 존재로 취금되었기 때문이다.
프로세스가 모든 데이터에 접근해 변화를 일으키고, 모든 변화를 관리하기 때문에 직관과 다른 작업 흐름이 발생한 것이다.
또한 위 예로도 결합도가 아주 높다는 것을 알 수 있을 것이다. 소극장은 관람객, 가방, 티켓, 판매원 모두에게 의존하고있다.

객체지향 프로그래밍?

위의 절차적 프로그래밍의 단점을 해결하기 위해선, 각 구성원이 자신의 데이터를 스스로 처리하도록 프로세스의 적절한 단계를 각 구성원의 내부로 옮기는 것이다.
위 서술에서 첫 부분을 바꿔보면 아래와 같을 것이다.

소극장은 관람객의 가방을 열어 그 안에 초대장이 들어있는지 살펴본다. -> 판매원은 관람객에게 초대장이 있는지 확인을 요청한다. 관람객은 자신의 가방을 열어 초대장이 있는지 확인해 판매원에게 알려준다.

위 변화는 우리가 일반적으로 생각할 수 있는 구성원의 행동이다. 객체지향 프로그래밍을 생각할 때 이렇게 우리가 생각할 수있는 행동을 서술해보면 알기 쉽게 어디가 문제인지 알수 있다.
만약 관람객이 초대장을 확인하기위해 지갑을 확인해야 한다 가정하면 절차적 프로그래밍의 예에서는 관람객은 물론 소극장에도 변화가 필요하게 된다.

책임의 이동

절차적 프로그래밍과 객체지향 프로그래밍의 근본적인 차이를 만드는 것은 "책임의 이동" 이다. 여기서 책임은 기능이라 할수 있다.
위 절차적 프로그래밍의 예시는 소극장에게 책임이 집중되어있다. 반면 객체지향 프로그래밍의 예시 서술은 한소절 뿐이지만 이미 소극장은 보이지 않고, 같은 일을 위한 단계가 적절히 나눠져있음을 알수 있다.

객체 지향에서는 독재자는 존재하지 않는다. 구성원 모두에게 적절한 책임이 있고, 각 구성원은 각자의 책임을 벗어나는 행위를 하지 않는다.

설계가 왜 필요한가?

책에서는 설계를 다음과같이 인용하고있다

설계란 코드를 배치하는 것이다 [Metz12]

한창 학과 공부를 할때는 설계가 굉장히 귀찮고, 힘든 일이며 구현하는 것보다 더 어려운 것으로 여겼다.
그러나 실무를 해보며 느낀 바로는 전혀 그렇지 않았다. 설계와 구현은 함께 가야하는 것이며, 코드를 작성하는 매 순간이 설계이며 설계는 코드 없이는 검증할 수 없다.

좋은 설계란 무엇인가? 오늘 완성해야 하는 기능을 구현하는 코드를 작성하는 동시에 내일 쉽게 변경할 수 있는 코드를 작성할수있는 설계이다.
변경을 수용할수 없는 설계는 이후에 비싼 대가를 치루며 더 어려운 코드를 만들거나, 새로만들게 된다. 최소한의 수정으로 원하는 변경을 충족해야한다.

객체지향 설계

객체지향 설계는 우리가 세상을 보는 관념대로 코드를 작성할 수 있게 돕는다. 객체는 서로를 의존하며 메시지를 주고받으며 기능을 구성한다.
훌륭한 객체지향 설계는 객체 사이의 의존성을 적절하게 관리하는 설계이다. 객체간의 의존성은 프로그램의 변경을 어렵게하는 주범이다.


쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있습니다.

[[위키북스] 오브젝트)](https://coupa.ng/bPjrmA)


AutoMonitor 중간 정리

  1. 개요

    • Spring Boot 스터디를 위한 프로젝트로 AutoMonitor를 개발.

    • Mockito를 Springboot 환경에서 TDD방법론으로 접근해 사용해보는 것이 학습 목표.

    • 궁극적으로, CI/CD Tool (e.g. jenkins) 를 활용하여 자동화 하는 것이 개발 목표.

      AutoMonitor란?

    • Api Testing Application

    • 주어진 API가 주어진 payload로 정상 작동하는가를 확인

    • API Health checking

      Stack

    • Kotlin

    • Springboot

    • JPA

    • Junit

    • Mockito

    • Hibernate

    • JPA

    • Vue

      학습 전략

    • TDD (Test Driven Development)

    • 레이어 별로 Mock-up

    • 항상 Test Case를 먼저 작성할 것

      환경 설정에 대한 설명은 생략.

  2. Test Driven Development

    TDD란?

    테스트 주도 개발(Test-driven development TDD)은 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스 중 하나이다.
    개발자는 먼저 요구사항을 검증하는 자동화된 테스트 케이스를 작성한다. 그런 후에, 그 테스트 케이스를 통과하기 위한 최소한의 코드를 생성한다.
    마지막으로 작성한 코드를 표준에 맞도록 리팩토링한다.
    이 기법을 개발했거나 '재발견' 한 것으로 인정되는 Kent Beck은 2003년에 TDD가 단순한 설계를 장려하고 자신감을 불어넣어준다고 말하였다.
    -- Wiki에서 발췌 링크

    주목할 것은 '먼저 요구사항을 검증하는 자동화된 테스트 케이스를 작성한다.' 이다.
    테스트 케이스 자체가 기능 명세가 되고, 프로그램의 청사진이 된다.

    1. JPA Repository Mocking

      테스트의 목적은 작성한 Entity를 사용한 JpaRepository를 Mock-up 해보기 위함이다.

       /* Test Case */
       @RunWith(MockitoJUnitRunner::class) // Mockito runner
       class ApiSpecJpaRepositoryTest {
           @Mock
           lateinit var apiSpecJpaRepository: ApiSpecJpaRepository; // jpaRepository를 Mockito로 Mock-up
      
           @InjectMocks
           lateinit var apiSpecRepository: ApiSpecRepository; // jpaRepository를 이용한 Respository. apiSpecJpaRepository를 의존 객체로 갖는다.
      
           @BeforeAll
           fun setUp() { // Mockito의 어노테이션을 실행.
               MockitoAnnotations.initMocks(this);
           }
       }
       /* JPA Repository */
       @Repository
       interface ApiSpecJpaRepository: JpaRepository<ApiSpec, Long>
       /* Repository */
       @Repository
       class ApiSpecRepository(
               val jpaApiSpecRepository: ApiSpecJpaRepository
       )

      MockitoRunner가 의존성을 주입해주며 Mock-up을 만들어 준다. 위 테스트 클래스에서는 apiSpecJpaRepository가 Mock-up 객체가 되며 이를 apiSpecRepository가 사용한다.

      등록된 API를 조회하는 기능을 구현하고자 한다. 이 때, Respository에 해당 기능을 바로 구현하지 않고, 인터페이스만 놓아둔다.

       /* Repository */
       @Repository
       class ApiSpecRepository(
               val jpaApiSpecRepository: ApiSpecJpaRepository
       ) {
           /**
             * 등록된 api 목록 가져오기
             */
           fun getApis(page: Int): List<ApiSpec> {
               return listOf();
           }
       }

      그리고 바로 테스트 케이스를 작성한다.

       /* Test Case */
       class ApiSpecJpaRepositoryTest {
           ...
      
           private fun mockResultList(size: Long): List<ApiSpec> {
               return LongStream
                       .range(0, size)
                       .mapToObj {
                           val mocked = mock<ApiSpec>(); // Mock-up
                           // Data mocking
                           whenever(mocked.id).thenReturn(it + 1);
                           whenever(mocked.alias).thenReturn("Test Api - ${it + 1}");
                           whenever(mocked.url).thenReturn("/api/v1/test/${it + 1}");
                           whenever(mocked.payload).thenReturn("{ \"data\": { \"number\": ${it + 1} } }");
                           mocked;
                       }
                       .toList();
           }
      
           /**
           * 첫 페이지에 해당하는 API 목록을 불러온다.
           */
           @Test
           fun getApis_페이지_번호로_조회하기() {
               // when
               val page: Int = 0;
               val recordSize: Int = 12;
               val paging = PageRequest.of(page, recordSize);
      
               // mocking
               val mockedResults = mockResultList(12)
               val mockedPage = PageImpl<ApiSpec>(mockedResults)
               whenever(apiSpecJpaRepository.findAll(paging))
                       .thenReturn(mockedPage);
      
               // then
               assertEquals(12, apiSpecRepository.getApis(page).size);
           }
       }

      여기서 주목할 것은 mockResultList와 테스트 케이스의 mocking방법이다.

      mockResultList는 integer를 인자로 받아 그 값을 크기로 갖는 ApiSpec의 Mock-up 객체의 List를 반환한다.

      데이터들은 실제 값을 직접 바인드 하지 않고, Mockito의 whenever로 Mock-up 데이터를 생성한다.

      테스트 케이스에서는 apiSpecJpaRepository.findAll 의 결과를 mock-up 한다.

      apiSpecJpaRepository의 findAll 함수에 PageRequest 인자를 넘겨주어 실행하면 12개의 원소를 가진 List객체가 반환된다는 의미이다.

      물론 데이터는 모두 Mock-up된 상태이다.

      테스트를 수행하면 당연히 통과하지 못한다. apiSpecRepository에서 테스트하려는 getApis가 의미없는 반환을 하고있기 때문이다.

      이제 이 테스트 케이스를 통과하기 위해 ApiSpecRepository의 getApis함수를 마저 구현한다.

       @Repository
       class ApiSpecRepository(
               val jpaApiSpecRepository: ApiSpecJpaRepository
       ) {
           val recordSize: Int = 12;
           /**
            * 등록된 api 목록 가져오기
            */
           fun getApis(page: Int): List<ApiSpec> {
               val paging = PageRequest.of(page, recordSize);
               val result: Page<ApiSpec>? = jpaApiSpecRepository.findAll(paging);
      
               if (result != null) {
                   return result.content;
               }
      
               return listOf();
           }
       }

      PageRequest를 이용해 쿼리 결과를 제한하며, 반환할 내용은 모두 List로 변환한다.

      이제 테스트를 다시 수행하면 통과할 것이다. 테스트가 통과할 수 있는 이유는 바로 apiSpecJpaRepository가 Mock-up 되었기 때문이다.

      Mockito Test Runner가 @Mock으로 지정된 필드를 Mock-up해두고, Mock-up된 apiSpecJpaRepository를 @InjectMocks로 지정된 apiSpecRepository에 의존성 주입을 실행한다. 따라서 apiSpecRepository 내부에는 Mock-up된 jpaRepository가 위치하게 되며 내부에서 apiSpecJpaRepository를 통해 메서드를 호출할 수 있게 되었다.

      또한, 내부의 함수를 whenever로 Mock-up했기 때문에 실제 Database에 쿼리를 질의하지 않고 기능이 동작 할 수 있었다.

      이런 방법으로 테스트 케이스를 먼저 작성하고, 테스트를 수행하고, 실패한 테스트에 대해 리팩터링을 진행하는 것이 TDD의 기본이다.

    2. Repository Test

      Repository Test에서는 데이터의 저장, 조회가 내가 의도한 대로 이뤄지는지를 확인해야 하기 때문에 별도의 Mock-up을 만들지 않는다.
      간단하게 Save-Load Test를 수행한다.

       @RunWith(SpringRunner::class)
       @SpringBootTest
       @TestInstance(TestInstance.Lifecycle.PER_CLASS)
       class ApiSpecRepositoryTest {
      
           @Autowired
           lateinit var apiSpecRepository: ApiSpecRepository
      
           @BeforeAll
           fun setUp() {
               for (i: Int in 1..60) {
                   val data = ApiForm(
                           url = "http://localhost/api/v1/test/$i",
                           alias = "TestApi - $i",
                           payload = "{ data: [] }"
                   );
      
                   apiSpecRepository.saveApi(data);
               }
           }
      
           @AfterAll
           fun cleanUp() {
               apiSpecRepository
                       .jpaApiSpecRepository
                       .deleteAll();
           }
       }

      우선 setup과 cleanUp을 구성한다. 테스트 시작 전에 데이터를 미리 구성해 두고, 이후엔 테스트에 사용한 모든 데이터를 모두 제거한다.

       @RunWith(SpringRunner::class)
       @SpringBootTest
       @TestInstance(TestInstance.Lifecycle.PER_CLASS)
       class ApiSpecRepositoryTest {
           ...
           @Test
           fun `saveApi - 새로운 API 등록`() {
               // mocking
               val inputData = ApiForm(
                       url = "http://test/api/v1/mocked",
                       alias = "test",
                       payload = "{ \"data\" : \"test\" }"
               );
      
               val inserted = apiSpecRepository.saveApi(inputData);
      
               val result = apiSpecRepository.jpaApiSpecRepository.findById(inserted.id!!).get();
      
               Assertions.assertEquals(inputData.url, result.url);
               Assertions.assertEquals(inputData.alias, result.alias);
               Assertions.assertEquals(inputData.payload, result.payload);
           }
       }

      Save 테스트에서는 데이터를 넣어보고, 이를 다시 꺼내어 확인하는 방식으로 테스트를 진행했다.

       @RunWith(SpringRunner::class)
       @SpringBootTest
       @TestInstance(TestInstance.Lifecycle.PER_CLASS)
       class ApiSpecRepositoryTest {
           ...
           @Test
           fun `getApi - 단건 조회`() {
               // when
               val id : Long = 1;
      
               // then
               val result = apiSpecRepository.getApi(id);
               Assertions.assertNotNull(result);
               Assertions.assertEquals(result!!.id!!, id);
           }
       }

      Load Test에선 기존 준비한 데이터를 꺼내보는 방향으로 진행했다.

    3. Controller Test
      Controller Test는 앞서서 만든 Repository를 Mock-Up해서 진행한다. Controller Test의 목적은 매핑된 함수들이 제대로 동작 하는지를 보기 위함이지 데이터를 꺼내오는 것이 제대로 동작하는 것을 보는 것이 아니기 때문이다. 관심사를 생각해서 테스트 케이스와 Mock-Up 범위를 결정한다.

       @RunWith(MockitoJUnitRunner::class)
       @SpringBootTest
       @AutoConfigureMockMvc
       @TestInstance(TestInstance.Lifecycle.PER_CLASS)
       class DashboardControllerTest {
           @Mock
           lateinit var apiSpecRepository: ApiSpecRepository;
      
           @InjectMocks
           lateinit var dashboardController: DashboardController;
      
           @Before
           fun setUp() {
               MockitoAnnotations.initMocks(this);
           }
      
           private fun mockResultList(size: Long): List<ApiSpec> {
               return LongStream
                       .range(0, size)
                       .mapToObj {
                           ApiSpec(
                                   id = it + 1,
                                   alias = "Test Api - ${it + 1}",
                                   url = "/api/v1/test/${it + 1}",
                                   payload = "{ \"data\": { \"number\": ${it + 1} } }"
                           );
                       }
                       .toList();
           }
       }

      우선 테스트 준비를 위와 같이 해둔다. 실제 Request를 주고 받는 테스트를 할 것이기 때문에 MockMVC를 사용한다. 앞서서 언급한 대로 apiSpecRepository는 Mock-UP해 두고 테스트 케이스 마다 예상 값을 정한다. 테스트 데이터는 엔티티를 사용하였다. JpaRepository테스트와 다른 점은 데이터 자체를 다시 Mockito로 Mock-Up하지 않은 것이다. Object De/Serialize에서 Mock-up객체의 다른 메서드, 프로퍼티들이 함께 결과에 포함 될 수 있기 때문에 데이터를 직접 구성하게 했다.

       @RunWith(MockitoJUnitRunner::class)
       @SpringBootTest
       @AutoConfigureMockMvc
       @TestInstance(TestInstance.Lifecycle.PER_CLASS)
       class DashboardControllerTest {
           ...
           @Test
           fun `getRegisteredApis - API Calling 테스트 - 데이터 조회`() {
               val mockedList = mockResultList(12)
               whenever(apiSpecRepository.getApis(1))
                       .thenReturn(mockedList);
      
               val request = get("/api/v1/apis/1")
                       .accept(MediaType.APPLICATION_JSON);
      
               MockMvcBuilders
                       .standaloneSetup(dashboardController)
                       .build()
                       .perform(request)
                       .andExpect(status().isOk)
                       .andDo(ResultHandler {
                           println(it.response.contentAsString);
                       })
                       .andExpect(jsonPath("$").isArray)
                       .andExpect(jsonPath("$.*", Matchers.hasSize<ApiSpec>(12)))
      
           }
       }

      조회 테스트이다. Mock-Up 데이터를 12개 (1 page 분량)을 준비해두고 페이지 번호를 주어 요청하는 테스트 시나리오이다.
      이제 테스트를 수행하면 실패할 것이다. 컨트롤러에 가서 해당 기능을 구현한다.

       @RestController
       class DashboardController(
               val apiSpecRepository: ApiSpecRepository
       ) {
           @GetMapping("/api/v1/apis/{page}")
           fun getRegisteredApis(@PathVariable("page") page: Int): List<ApiSpec> = apiSpecRepository.getApis(page)
       }

      Get요청 테스트를 작성했으니 GetMapping을 구성한다. Restful 디자인을 사용했다.
      다시 테스트를 수행하면 통과한다.

    4. 반복...

      이제 기능을 개발할 때 마다 위 흐름을 반복하면된다.

      1. Test Case 작성
        • JpaRepository
          • 어떤 메서드를 사용해 데이터를 가져올 지 정한다.
          • Repository에 해당 메서드를 사용하는 기능의 껍데기를 만든다.
          • Repository에 JpaRepository의 Mock-Up을 의존성으로 주입한다.
        • Repository
          • mock-up없이, 실제 데이터의 CRUD가 이뤄지는지를 확인하는 테스트 케이스를 작성한다.
        • Controller
          • Repository로 꺼내온 데이터를 어떻게 핸들링 할 지에 대해 테스트 케이스를 작성한다.
          • 실제 Request와 유사하게 테스트해야 하므로 MockMVC를 사용하는 것을 권장한다.
          • Repository의 Mock-Up을 Controller의 의존성으로 주입한다.
      2. Test 수행
      3. Refactoring
  3. 앞으로...

    1. Frontend와 Api연동.
      • 현재까지 작성된 Api들을 Frontend와 연동해 기능의 동작을 확인한다.
    2. CI/CD툴과 연동.
      • Jenkins로 배치 프로세스를 구성해 테스트를 주기적으로, 자동화 한다.

쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있습니다.


'스터디 > Spring Boot' 카테고리의 다른 글

SpringApplication (2)  (0) 2021.01.08
SpringApplication (1)  (0) 2021.01.07

spring logo

Application Event

Application Event Listener 생성

package com.example;

import org.springframework.boot.context.event.ApplicationStartingEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

/**
 * Application이 시작 될 때의 이벤트 리스너
 */
@Component
public class SampleListener implements ApplicationListener<ApplicationStartingEvent> {

    @Override
    public void onApplicationEvent(ApplicationStartingEvent arg0) {
        System.out.println("=======================");
        System.out.println("Starting Application...");
        System.out.println("=======================");
    }

}

그런데 위의 ApplicationStartingEvent는 @Component 어노테이션으로 Bean등록을 했음에도 ApplicationContext 생성 이전의, 완전 시작점의 이벤트라서 정상 작동하지 않는다.
따라서 Application에 따로 등록해야 한다.

package com.example;


import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(Application.class);
        app.addListeners(new SampleListener());
        app.run(args);
    }
}
package com.example;

import org.springframework.boot.context.event.ApplicationStartingEvent;
import org.springframework.context.ApplicationListener;

/**
 * Application이 시작 될 때의 이벤트 리스너
 */
public class SampleListener implements ApplicationListener<ApplicationStartingEvent> {

    @Override
    public void onApplicationEvent(ApplicationStartingEvent arg0) {
        System.out.println("=======================");
        System.out.println("Starting Application...");
        System.out.println("=======================");
    }

}

실행 결과

Bean으로 등록한 이벤트의 경우

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(Application.class);
        app.run(args);
    }
}
package com.example;

import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

/**
 * Application이 시작 될 때의 이벤트 리스너
 */
@Component
public class SampleListener implements ApplicationListener<ApplicationStartedEvent> {

    @Override
    public void onApplicationEvent(final ApplicationStartedEvent arg0) {
        System.out.println("=======================");
        System.out.println("Application Started....");
        System.out.println("=======================");
    }

}

실행 결과


Web Application Type

기본적으로 servlet으로 동작하며 이후 사용 의존성에 따라 설정이 필요하다.
의존성에 Spring MVC가 없고, WebFlux로 구성할 경우, REACTIVE로 변경해야 하며
WebApp이 아닌 경우에는 NONE으로 설정해야 한다.

Application 실행부에서 이를 조작할 수 있다.

package com.example;


import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(Application.class);
        app.setWebApplicationType(WebApplicationType.SERVLET);
        app.setWebApplicationType(WebApplicationType.REACTIVE);
        app.setWebApplicationType(WebApplicationType.NONE);
        app.run(args);
    }
}

Application Arguments

기본적으로 ApplicationArguments를 Bean으로 등록해준다.
jar로 패키지된 Application을 실행할때 인자를 주기 위해서는 -- 를 사용하면 된다.
-D옵션은 JVM 옵션임을 유의해야한다.

ApplicationRunner 혹은 CommandLineRunner를 통해 인자를 활용할 수 있다.

ApplicationRunner

package com.example;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

@Component
public class SampleRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception { 
        System.out.println("is foo delivered : " + args.containsOption("foo"));
        System.out.println("foo : " + args.getOptionValues("foo"));
    }

}
java -jar /path/to/jar.jar --foo=bar

실행 결과

CommandLineRunner

package com.example;

import java.util.Arrays;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class SampleRunner implements CommandLineRunner {

    @Override
    public void run(String... args) throws Exception {
        Arrays.stream(args)
        .forEach(System.out::println);
    }

}
java -jar /path/to/jar.jar --foo=bar

실행 결과

순서 지정도 가능하다. (@Order 어노테이션)


쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있습니다.


'스터디 > Spring Boot' 카테고리의 다른 글

Spring boot TDD  (0) 2021.01.09
SpringApplication (1)  (0) 2021.01.07

spring logo

기본적인 Spring Application 실행 방법

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args); 
    }
}

위 코드는 SpringApplication 인스턴스를 직접 만들지 않고, 자동으로 설정등을 Spring 프레임워크에게 맞기고 내 application을 실행한다.
간단하고 좋은 방법이지만, Spring이 제공하는 Customize 기능들을 포기하게 된다.

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(Application.class);
        app.run(args);
    }
}

이렇게 인스턴스를 직접 만들어 App을 실행 할 수도 있다.


로그 레벨 조정

실행된 Application의 로그 레벨은 기본적으로 INFO 레벨이며 실행 인자에 디버그 모드를 명시하면 디버그 모드로 동작하게 된다.
디버그 모드로 동작 할 경우 로그 내용은 더 자세하게 표시되며 로드된 자동 설정 내역과 실패한 설정 로드 이력을 표시한다.

IDE의 실행 시 인자를 편집해 -Ddebug 옵션을 추가하거나 (JVM Options)
빌드된 결과물을 직접 Debug모드로 실행할 수 있다.

java -jar /path/to/jar -Ddebug

배너 변경

배너란?

  • Application 실행시 표시되는 로고, 버전 등.

Application의 실행시 표시되는 배너를 변경하기 위해서는 src/main/resources/banner.txt를 생성해 그 안에 원하는 배너를 텍스트로 구성하면 된다.

// src/main/resources/banner.txt
===========
test banner
===========

배너에 지원되는 변수가 있어 Application의 정보등을 배너에 표시할 수 있다.

// src/main/resources/banner.txt
=========== 
test banner ${spring-boot.version}
===========

지원되는 변수 목록은 링크를 확인

이미지도 지원한다. 파일이름을 banner로 저장 할 경우 자동으로 로드해 준다.

src/main/resources 이외의 위치에서 banner를 만들고 싶을 경우 application.properties, application.yml에서 설정할 수 있다.

spring:
    banner:
        location: :classpath/banner.txt

Application에서 banner를 조작할 수 있다.

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import ort.springframework.boot.Banner;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(Application.class);
        app.setBannerMode(Banner.Mode.Off); // 배너를 표시하지 않는다.
        app.run(args);
    }
}
package com.example;

import java.io.PrintStream;

import org.springframework.boot.Banner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.core.env.Environment;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(Application.class);
        // 배너를 직접 생성
        app.setBanner(new Banner(){

            @Override
            public void printBanner(Environment environment, Class<?> sourceClass, PrintStream out) {
                out.println("==================");
                out.println("test banner");
                out.println("==================");
            }
        });
        app.run(args);
    }
}

Builder Pattern 사용 (SpringApplicationBuilder)

package com.example;

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        new SpringApplicationBuilder()
            .sources(Application.class)
            .run(args);
    }
}

동일하게 banner 변경이 가능하다.

package com.example;

import java.io.PrintStream;

import org.springframework.boot.Banner;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.core.env.Environment;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        new SpringApplicationBuilder()
            .sources(Application.class)
            .banner(new Banner(){

                @Override
                public void printBanner(Environment environment, Class<?> sourceClass, PrintStream out) {
                    out.println("==================");
                    out.println("test banner");
                    out.println("==================");
                }
            })
            .run(args);
    }
}

쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있습니다.


'스터디 > Spring Boot' 카테고리의 다른 글

Spring boot TDD  (0) 2021.01.09
SpringApplication (2)  (0) 2021.01.08

+ Recent posts