토비의 스프링 | 5장 서비스 추상화 (학습)

주싱·2022년 10월 9일
0

토비의 스프링

목록 보기
18/30

토비의 스프링 5장 서비스 추상화를 읽고 인상 깊게 배운 점들을 정리합니다.

1. 더 나은 설계

데이터 대신 작업 요청하기

객체지향적인 코드는 다른 오브젝트의 데이터를 가져와서 작업하는 대신 데이터를 갖고 있는 다른 오브젝트에게 작업을 요청한다. 오브젝트에게 데이터를 요구하지 말고 작업을 요청하라는 것이 객체지향 프로그래밍의 가장 기본이 되는 원리이기도 하다.

중복의 제거

테스트 코드와 애플리케이션 코드에 나타난 같은 의미를 가지는 숫자의 중복도 제거해줘야 할까? 당연하다. 한 가지 변경 이유가 발생했을 때 여러 군데를 고치게 만든다면 중복이기 때문이다.

리팩토링을 위한 시선

다음의 관점으로 코드를 다시 살펴보자.

  • 코드에 중복이 없는가?
  • 코드가 무엇을 하는지 이해하기 불편하지 않은가?
  • 코드가 자신이 있어야 할 자리에 있는가?
  • 앞으로 어떤 변경이 있을 수 있고, 그 변화에 쉽게 대응할 수 있게 작성되어 있는가?

추상화

여러 기술의 사용 방법에 공통점이 있다면 추상화를 생각해볼 수 있다. 추상화를 통해 하위 시스템이 어떤 것인지 알지 못해도, 또는 하위 시스템이 바뀌더라도 일관된 방법으로 접근할 수 있다.

if-else 체인에서 나는 냄새

나쁜 코드의 냄새가 나는 if-else 체인에는 대게 어떤 상태의 변화 과정, 상태를 확인하는 조건, 상태가 만족했을 때 해야할 작업이 한데 섞여 있어 로직을 이해하기 어려운 경우가 많다. 이는 성격이 다른 여러 가지 로직이 한데 섞여 있기 때문이다.

수직, 수평 계층구조와 의존관계

  • 애플리케이션 로직의 관심, 책임, 성격이 다르기 때문에 분리하는 것을 수평적인 분리라고 한다.
  • 애플리케이션의 비지니스 로직과 그 하위에서 동작하는 기술을 분리하는 것을 수직적인 분리라고 한다.

단일 책임 원칙이란?

하나의 모듈은 한 가지 책임을 가져야 한다. 하나의 모듈이 바뀌는 이유는 하나여야 한다.

단일 책임 원칙을 위배하는 두 유형

단일 책임 원칙에 위배되는 코드 유형은 크게 두 가지로 나누어 생각할 수 있다.

  • 첫째는 같은 책임을 가지는 코드가 여러 모듈에 나누어져 있는 경우이다. 이때 한 가지 이유로 변경이 발생하면 여러 모듈의 코드를 같이 수정해야 한다. 수정하는 작업량에서 먼저 부담이 되고 그로인해 실수가 일어날 확률도 높아진다. 그래서 엄청난 부담을 안고 시작하거나 겁나서 도저히 못하겠다는 저향을 하게 될지도 모른다.
  • 두 번째는 하나의 모듈에 여러 책임을 가지는 코드가 섞여 있는 경우이다. 이때 한 가지 이유로 변경이 발생하면 모듈 내 코드를 수정하며 다른 역할의 코드에 영향을 줄 수 있게 된다. 따라서 변경이 발생한 이유와 관련 없는 코드에 부수효과를 가져올 수 있게 된다.

단일 책임 원칙을 잘 지킨다면

여러 모듈들이 각자의 책임에 충실함으로 코드를 이해하기 쉽다. 또, 변경이 필요할 때 수정 대상이 명확해 진다.

단일 책임원칙을 돕는 DI

DI를 통해 성격이 다른 모듈은 외부에서 제어하고 주입받아 사용할 수 있도록 한다.

높은 응집성, 낮은 결합도

높은 응집성, 낮은 결합도를 유지함으로 성격이 다른 모듈의 코드가 서로 영향을 주지 않게 되고, 서로 독립적으로 확장될 수 있게 된다.

런타임 시점보다 컴파일 시점에 오류가 드러나도록

Enum을 사용하면 정수를 상수로 정의해서 사용하는 것 보다 안전하다. 정의된 상수만 사용해야 하는 정수형 변수에 실수로 정의되지 않은 정수 값을 입력하더라도 컴파일러가 이를 인지하지 못하고 런타임에 교묘하게 문제가 드러날 수 있다. Enum을 사용하면 정의되지 않은 값 입력 자체를 할 수 없는 구조가 되고 실수로 작성한 코드는 컴파일 시점에 드러난다.

스프링의 빈으로 등록하기 전에 검토할 것

어떤 클래스든 스프링의 빈으로 등록할 때 먼저 검토해야 할 것은 상태를 가지지 않는 싱글톤으로 만들어져 여러 스레드에서 동시에 사용해도 괜찮은가 하는 점이다.

Enum을 처리하는 컬럼의 디폴트값

DB에서 정수 타입 컬럼이 애플리케이션에서 Enum에 의해 처리되면 DB에서 해당 컬럼의 디폴트 값은 뭘로 해야 할까? 정답인지는 모르겠지만 도메인에서 사용되는 Enum 타입 특정 값을 디폴트 값으로 하지 않고 도메인에 영향을 받지 않을 디폴트 값 하나를 내부적으로 정의해 두는 것은 어떨까?

2. 더 나은 테스트

테스트를 위해 서비스 추상화라는 개념을 도입하는 생각의 과정

  • 사용자의 레벨을 업그레이드하는 기능을 테스트하는 과정에서 준비되지 않은 메일 서버 때문에 정상적으로 테스트를 수행할 수 없는 환경을 만난다.
  • 실제 DB 대신에 테스트 DB를 사용하듯이 테스트 때는 메일 서버 설정을 다르게 해서 테스트용으로 따로 준비된 메일 서버를 이용하는 방법은 어떨까?
  • 메일 서버는 충분히 테스트된 시스템이다. SMTP로 메일 전송 요청을 받으면 별문제 없이 잘 전송됐다고 믿어도 충분하다.
  • 결국 테스트용으로 준비한 메일 서버는 업그레이드 작업 시 테스트에서 메일 전송 관련 예외가 발생하지 않고 테스트를 마치게 해주는 역할을 맡을 뿐이다.
  • 똑같은 원리를 UserService와 JavaMail 사이에도 적용할 수 있지 않을까?

테스트에 서비스 추상화를 적용하면

  • 테스트 대상 객체에서 의존 객체의 인터페이스를 참조하고 있다면 테스트를 위해 인터페이스 구현 객체를 바꾸어 사용할 수 있다.
  • 또는 기존의 인터페이스 구현 클래스에 부가적인 기능을 추가할 수 있다.
  • 애플리케이션 계층 코드는 아래 계층에서 어떤 일이 일어나고 있는지 상관없이 메일 발송을 요청한다는 기본 기능에 충실하게 작성하면 된다.

테스트 대역(Double)이 왜 필요한가?

  • 테스트에서도 역시 테스트의 관심사(어떤 조건에서 어떤 입력을 가하면 어떤 출력을 기대하는가)가 무엇인가가 중요하다.
  • 테스트에서 집중하고 있는 관심사는 아니지만 관심있는 테스트를 수행하기 위해 필수적으로 필요한 의존 대상이 존재할 수 있는데 만약 의존 대상이 일관된 동작을 하도록 설정하는데 시간이 많이 걸리거나 매우 까다롭다면 이를 테스트 대역(Double)로 대체하여 테스트를 진행할 수 있다.

4가지 타입 테스트 대역

테스트 대역을 사용하는 케이스는 크게 다음의 4 가지 경우가 존재한다

  • 테스트 대상이 출력하는 값을 받아주기만 하면 되는 경우(의존 대상이 받은 값의 이후의 처리는 정상이라고 가정)
  • 테스트 대상이 일관된 동작을 하도록 의도한 고정 입력을 반환하는 경우
  • 테스트 수행 중 의존 대상에서 발생할 수 있는 예외를 모의하는 경우
  • 테스트 대상이 출력하는 값 자체를 검증하는 경우

전략 패턴/DI 없이 불가능

  • 테스트 의존 객체를 테스트 대역으로 교체하기 위해서는 테스트 대상 객체가 의존 객체를 인터페이스로써 참조하고 DI를 통해 구현 객체를 주입받도록 해야한다. 그렇지 않다면 테스트 대역을 사용하기 위해 서비스 코드를 수정해야 하는 일이 생기고 이는 좋은 방법이 아니다.

테스트 대역을 통해 얻는 이점

  • 테스트 대역을 사용함으로 테스트 대상 코드는 수정하지 않고, 테스트 관심사에 집중하여 테스트를 빠르게, 자주 실행할 수 있게 된다.

절대 바뀌지 않는 의존 객체라도 테스트에서는 바뀔 수 있다.

  • 의존하는 객체가 절대 바뀌지 않더라도 인터페이스를 통해서 의존관계 주입을 외부에서 받도록 하는 것이 좋다. 왜냐하면 테스트에서는 바꾸어 사용해야 할 수 있기 때문이다.

테스트 스텁(Stub)

  • 테스트 대상 오브젝트의 의존객체로서 존재하면서 테스트 동안에 코드가 정상적으로 수행될 수 있도록 돕는다.
  • 일반적으로 테스트 대상 오브젝트의 메서드 파라미터와 달리 테스트 내부 코드에서 간접적으로 사용된다. 따라서 DI를 통해 미리 의존 오브젝트 테스트 스텁으로 변경해야 한다.
  • 리턴 값이 있다면 테스트에 필요한 값을 리턴해 주도록 만들 수도 있다. 또는 메서드를 호출하면 강제로 예외를 발생하게 해서 테스트에 활용할 수도 있다.

목(Mock) 오브젝트

  • 테스트 대상 오브젝트의 메서드가 돌려주는 결과뿐 아니라 테스트 오브젝트가 간접적으로 의존 오브젝트에 넘기는 값과 그 행위 자체에 대해서도 검증하고 싶다면 어떻게 해야 할까?
  • 목 오브젝트는 스텁처럼 테스트 오브젝트가 정상적으로 실행되도록 도와주면서, 테스트 오브젝트와 자신의 사이에서 일어나는 커뮤니케이션 내용을 저장해뒀다가 테스트 결과를 검증하는 데 활용할 수 있게 해준다.
  • 테스트 대상 오브젝트가 의존 오브젝트에게 출력한 값에 관심이 있을 경우 또는 의존 오브젝트를 얼마나 사용했는가 하는 커뮤니케이션 행위 자체에 관심이 있을 경우 사용할 수 있다.

상속을 통한 테스트 대역(Double)

  • 테스트 대상 클래스를 상속하고 테스트 메서드를 오버라이딩하는 방법을 사용하면 애플리케이션 코드는 그대로 두고 애플리케이션의 동작을 바꾸는 특별한 테스트를 수행할 수 있다.
  • 예를 들면 일련의 작업 중 특정 작업 수행 중 예외를 모의 하는 작업을 할 수 있다.

변경했다면 변경되지 않았음도 테스트하자

구현한 기능이 특정 조건의 값을 변경하는 것이라면 다른 값들은 그대로인지 검사해줄 필요도 있다. 실수로 다른 조건의 값을 덩당아 바꾸는 버그가 있을 수 있기 때문이다.

스프링 컨테이너의 빈 주입 테스트 역시 가능

JUnit 테스트 클래스에서 주입받아야 할 특정 빈이 제대로 주입되었는지 null 체크하는 테스트 코드를 만들 수 있구나.

의존 대상의 동작 테스트는 분리할 수 있다

  • 메일 발송 기능 자체는 별도로 때어서 학습 테스트를 만들어 보는 것도 방법이다.
  • 테스트 대역에서 콘솔을 통해 발송정보를 출력하도록 만들고 한 번쯤은 콘솔에 찍힌 내용을 확인해보는 방법을 사용할 수도 있다.

3. 개념

트랜잭션

  • 둘 이상의 작업을 하나의 논리적 그룹으로 묶어서 처리한다. 논리적인 그룹으로 묶여있는 모든 작업이 일어나든지(Commit) 모두 일어나지 않던지(Rollback) 둘 중 하나의 상태를 보장한다.
  • 하나의 SQL 명령은 DB가 트랜잭션을 보장해준다고 믿을 수 있다.
  • 복잡한 비지니스 로직에서 하나의 트랜잭션으로 처리할 경계를 설정하는 부분이 중요하다.
  • 하나의 DB 커넥션 안에서 만들어지는 트랜잭션을 로컬 트랜잭션이라고도 한다.
  • 글로벌 트랜잭션 방식을 사용하면 DB 연결뿐 아니라 JMS 같은 메시징 작업도 하나의 트랜잭션으로 처리 가능하다.

설계 원칙, 디자인 패턴 등이 가지는 의미

  • 좋은 코드를 만들기 위한 개발자 스스로의 노력과 고민이 있을 때 도움을 준다.
  • 멋진 이름을 달고 있는 패턴이나 원칙은 사실 많은 선배 개발자가 좋은 코드를 만들려고 고민했던 시간을 통해 만들어진 유산일 뿐이다.

4. 스프링

트랜잭션 동기화

  • 스프링의 트랜잭션 동기화 저장소를 활용하면 트랜잭션 동기화를 위한 Connection 객체를 파라미터로 넘기며 처리할 필요가 없다.
  • 트랜잭션 동기화 저장소는 작업 쓰레드마다 독립적으로 Connection 오브젝트를 저장하고 관리하기 때문에 다중 사용자를 처리하는 서버의 멀티쓰레드 환경에서도 충돌이 날 염려가 없다.

트랜잭션 추상화

  • 스프링이 제공하는 모든 PlatformTransactionManager의 구현 클래스는 싱글톤으로 사용이 가능하다.

5. 코드에서 배우기

네이밍 규칙 예제

getIntValue() 대신 intValue()를, setValue() 대신 valueOf() 메서드 네이밍을 사용하네. 오히려 간결한 것 같다. 그리고 많은 Java 라이브러리에서 이 네이밍 방식을 사용하는 것 같다.

값을 받아서 객체 반환하는 static 메서드

valueOf() 메서드가 static 메서드이구나! 그래서 값을 받아서 Enum 타입 객체를 반환해 주는 구나!

코드를 수정하는 자세

“기존 코드에 새로운 기능을 추가하려면 테스트를 먼저 만드는 것이 안전하다.” 이런 습관을 가지자.

역시 컴파일 마저 실패하는 테스트 코드 먼저

역시 컴파일 마저 실패하는 테스트 코드 먼저 작성하는 부분이 인상적이다. 배운다.

IDE에서 완성해 주는 컴파일이 실패하는 코드

이럴수가! 이런건 몰랐다. 컴파일이 안되는 코드에서 인터페이스나 클래스, 메서드 생성을 IDE 도움을 받아 할 수 있다니!

테스트 메서드의 ‘Test’ postfix

테스트 메서드 뒤에 ‘featureTest’와 같이 ~Test라는 Postfix를 붙이는 습관이 있었는데 Test클래스에 이미 붙였으니 중복된 의미 없는 정보임을 깨닫게 된다.

boolean 대신 Boolean 사용

boolean 타입은 true, false 두 값만을 가진다. 두 값 모두 애플리케이션에 어떤 의미를 가지는 값이다. 그런데 애플리케이션 작성 시 실수로 의미 있는 값을 할당하지 않더라도 후에 변수를 확인하는 코드에서 어떤 의미로 해석하게 된다. 반면 Boolean 값은 null 값을 가질 수 있다. 그래서 null 값으로 초기화 해두면 후에 변수를 확인할 때 프로그래밍 실수로 값이 할당되지 않았음을 알 수 있게 된다.

굳이 Static 메서드로? (Intellij에 조언에 집중하자)

멤버변수에 접근하지 않는 메서드라면 Static으로 선언하면 좋은 것 같다. 왜냐하면 의도적으로 객체 상태에 접근하지 않는 메서드로 엄격하게 설계했는데 추후 유지보수 과정 중에는 설계의도가 드러나지 않아 객체 상태에 접근하고 상태를 변경하도록 수정해 버릴 수도 있다. 만약 Static으로 선언해 두면 나중에 인스턴스 멤버 변수 자체에 접근할 수 없어 설계 원칙을 깨는 오류가 자동으로 인지되기 때문이다.

새로운 Switch 문 + Enum

최신 Switch 문에서 case로 체크하지 않는 값을 Enum에 추가하면 컴파일 오류가 발생한다. 그래서 Enum 값을 추가했는데 실수로 Switch 문 case 조건을 추가하지 않으면 즉시 실수를 알게된다.

profile
소프트웨어 엔지니어, 일상

0개의 댓글