[스프링 핵심 원리] 기본편 - 1

스프링 핵심 원리 - 스프링 탄생 배경

시작

스프링은 왜 만들어졌는가


스프링은 객체 지향 언어가 가진 강력한 특징을 살려내는 프레임워크입니다. 기존 EJB(Enterprise JavaBeans)의 경우 객체 지향의 장점을 살릴 수 없었고, 복잡하고 무거웠으며 비용도 많이 들었습니다. 이에 로드 존슨은 EJB의 문제점을 지적하며 스프링을 통해 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크를 만들었습니다.

스프링 프레임워크


  • 핵심 기술 : 스프링 DI 컨테이너, AOP, 이벤트, 기타
  • 웹 기술 : 스프링 MVC, 스프링 WebFlux
  • 데이터 접근 기술 : 트랜잭션, JDBC, ORM 지원, XML 지원
  • 기술 통합 : 캐시, 이메일, 원격접근, 스케줄링
  • 테스트 : 스프링 기반 테스트 지원
  • 언어 : 코틀린, 그루비
  • 최근들어 스프링 부트를 통해 스프링 프레임워크의 기술들을 편리하게 사용

스프링 프레임워크는 모듈화가 잘 되어있어 필요한 부분만 선택하여 사용할 수 있습니다. 특히 스프링의 핵심인 DI 컨테이너는 객체지향 설계를 편리하게 구현할 수 있게 도와줍니다.

스프링 부트


  • 스프링을 편리하게 사용할 수 있도록 지원, 최근에는 기본으로 사용됨.
  • 단독으로 실행할 수 있는 스프링 애플리케이션을 쉽게 생성
  • Tomcat 같은 웹 서버를 내장해 별도 웹 서버를 설치하지 않아도 됨.
  • 손쉬운 빌드 구성을 위한 starter 종속성 제공
  • 스프링과 3rd parth(외부) 라이브러리 자동 구성
  • 메트릭, 상태 확인, 외부 구성 같은 프로덕션 준비 기능 제공
  • 관례에 의한 간결한 설정

스프링 부트는 스프링 프레임워크를 더욱 쉽게 사용할 수 있게 해주는 도구입니다. 특히 복잡한 설정을 자동화하고, 개발자가 비즈니스 로직에 집중할 수 있게 도와줍니다.

좋은 객체 지향 프로그래밍이란?


객체지향의 특징은 다음과 같다.

  • 추상화: 복잡한 현실세계를 단순화하여 프로그램으로 표현
  • 캡슐화: 데이터와 해당 데이터를 처리하는 메서드를 하나로 묶고 은닉
  • 상속: 기존 클래스를 재사용하여 새로운 클래스를 작성
  • 다형성: 하나의 타입에 여러 객체를 대입할 수 있는 성질

유연하고 용이


다형성의 특징으로 유연하고 용이하게 작동할 수 있다. 자동차의 경우 뼈대는 똑같습니다. 뼈를 채우는 것은 기아, 현대 등이죠. 클라이언트는 대상의 역할(인터페이스)만 알면 되고 내부 구조를 몰라도 되고 변경되어도 영향을 받지 않는다. 또한 대상 자체를 변경해도 영향을 받지 않는다. 이것이 역할과 구현을 분리할 수 있는 다형성의 장점이다.

  • 자바 언어의 다형성응 활용
    • 역할 : 인터페이스
    • 구현 : 인터페이스를 구현한 클래스, 구현 객체
    • 구현보다 설계가 먼저!

다형성은 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있도록 해주는 것으로 코드의 재사용성을 높이고 유지보수를 용이하게 합니다.

객체의 협력이라는 관계부터 생각


혼자 존재하는 객체는 없다.

  • 클라이언트 : 요청
  • 서버 : 응답

수 많은 객체 클라이언트와 객체 서버는 서로 협렵 관계를 가진다. 이러한 협력 관계는 인터페이스를 통해 정의되며, 구현체는 언제든지 변경될 수 있습니다.

자바 언어의 다형성


오버라이딩(부모 클래스로부터 상속받은 메서드의 내용을 재정의)로 인해 구현한 객체를 실행 시점에 유연하게 변경할 수 있다. 다형성의 본질은 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다. 클라이언트를 변경하지 않고 서버의 구현 기능을 유연하게 변경할 수 있다.

실세계의 역할과 구현이라는 편리한 컨셉을 다형성을 통해 객체 세상으로 가져올 수 있다. 자동차 등. 인터페이스를 안정적으로 구현하는 것이 가장 중요하다. 한계점은 인터페이스를 변경하게되면 구현체 또한 영향을 받는다는 점이다. 때문에 설계할 때 인터페이스를 변화가 없도록 설계하는것이 가장 중요하다.

다형성은 컴파일 시점의 타입 체크와 실행 시점의 바인딩을 분리함으로써 유연한 설계를 가능하게 합니다.

스프링과 객체 지향


스프링은 다형성을 극대화해서 이용할 수 있게 도와준다. 제어의 역전(IoC), 의존관계 주입(DI)은 다형성을 활용해 역할과 구현을 편리하게 다룰 수 있도록 지원한다. 스프링을 사용하면 레고 블럭을 조립하듯이 구현을 편리하게 변경할 수 있다.

스프링이 제공하는 IoC 컨테이너는 객체의 생성과 의존관계 설정을 담당하며, 이를 통해 느슨한 결합도를 가진 애플리케이션을 만들 수 있습니다.

그래서 가장 중요한것은


좋은 객체 지향 설계의 5가지 원칙(SOLID), 다형성이다. 이러한 원칙들을 잘 지키면서 개발하면 유지보수가 쉽고 확장성 있는 애플리케이션을 만들 수 있습니다.

좋은 객체 지향 설계의 5가지 원칙


클린코드로 유명한 로버트 마틴이 좋은 객체 지향 설계의 5가지 원칙을 정리했습니다. 이 원칙들은 객체 지향 프로그래밍에서 지켜야 할 핵심적인 원칙들입니다. 이러한 원칙들을 잘 지키면 유지보수가 쉽고 확장성 있는 애플리케이션을 만들 수 있습니다.

I 단일 책임 원칙 SRP(Single responsibility principle)


  • 한 클래스는 하나의 책임만 가져야 한다.
  • 중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적다면 단일 책임 원칙을 잘 따른 것.
    • ex) UI 변경, 객체의 생성과 사용을 분리
  • 하나의 책임이라는 것은 모호하다
    • 클 수도 있고 작을 수도 있다
    • 문맥과 상황에 따라 다르다
    • 중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것

클래스가 여러 책임을 가지면 한 책임의 변경이 다른 책임에 영향을 미칠 수 있으므로, 각 클래스는 하나의 책임만 가져야 합니다.

java
// 사용자 정보만 관리
public class User {
    private String name;
    private String email;
    
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
    
    // getter, setter
}

// 데이터베이스 처리만 담당
public class UserRepository {
    public void save(User user) {
        // DB 저장 로직
    }
    
    public User findById(Long id) {
        // DB 조회 로직
        return user;
    }
}

// 이메일 발송만 담당
public class EmailService {
    public void sendEmail(String to, String message) {
        // 이메일 발송 로직
    }
}

// 사용자 입력 검증만 담당
public class UserValidator {
    public boolean validateUser(User user) {
        // 검증 로직
        return true;
    }
}

II 개방-폐쇄 원칙 OCP(Open/Closed principle)


  • 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
    • 확장을 하려면 당연히 기존 코드를 변경해야할텐데? 뭔소리지
      • 다형성을 활용해보자
      • 인터페이스를 통해 구현체를 분리하면 기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있다
  • 인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현하는것은 이 원칙을 따르고 있다고 볼 수 있다. 인터페이스를 잘 설계하면 확장은(객체) 열려 있으나 변경에는 닫혀 있어야 한다.
java
public class MemberService {

  // 기존 코드
  // private MemberRepository memberRepository = new MemoryMemberRepository();
  
  // 변경 코드
  private MemberRepository memberRepository = new JdbcMemberRepository();
}
  • 구현 객체를 변경하려면 클라이언트 코드를 변경해야 한다. 분명 다형성을 사용했지만 OCP원칙을 따른 다고는 볼 수 없다. 즉 클라이언트는 코드를 변경을 해야한다. 즉 OCP 원칙을 지킬 수 없다.

  • 객체를 생성하고, 연관관계를 맺어주는 별도의 조립, 설정자가 필요한데, 이를 스프링이 지원한다. DI, IoC, 컨테이너 등으로..

  • 다음은 스프링을 통해 이를 해결한 코드이다.

java
// 1. 인터페이스 정의
public interface PaymentProcessor {
    void processPayment(double amount);
}

// 2. 구현체들
public class CreditCardProcessor implements PaymentProcessor {
    @Override
    public void processPayment(double amount) {
        System.out.println("신용카드로 " + amount + "원 결제 처리");
    }
}

public class PaypalProcessor implements PaymentProcessor {
    @Override
    public void processPayment(double amount) {
        System.out.println("페이팔로 " + amount + "원 결제 처리");
    }
}

// 3. 나쁜 예시 - OCP 위반
public class PaymentService {
    // 직접 구현체를 생성하여 의존성을 가짐
    private PaymentProcessor processor = new CreditCardProcessor();
    
    public void makePayment(double amount) {
        processor.processPayment(amount);
    }
}

// 4. 좋은 예시 - OCP 준수
@Service
public class PaymentService {
    private final PaymentProcessor processor;
    
    // 의존성 주입을 통해 구현체를 외부에서 주입받음
    @Autowired
    public PaymentService(PaymentProcessor processor) {
        this.processor = processor;
    }
    
    public void makePayment(double amount) {
        processor.processPayment(amount);
    }
}

// 5. 설정 클래스
@Configuration
public class PaymentConfig {
    @Bean
    public PaymentProcessor paymentProcessor() {
        // 여기서 구현체를 변경하더라도 PaymentService의 코드는 변경되지 않음
        return new CreditCardProcessor();
        // return new PaypalProcessor(); // 구현체만 교체하면 됨
    }
}

위 코드를 보면 구현체를 변경하더라도 클라이언트 코드는 변경되지 않는다. 이는 OCP 원칙을 따르고 있음을 알 수 있다.

  1. 확장에 열려있음:
  • 새로운 결제 방식을 추가하고 싶을 때, PaymentProcessor 인터페이스를 구현하는 새로운 클래스만 만들면 됩니다.
  • 예를 들어 KakaoPayProcessor, NaverPayProcessor 등을 추가할 때 기존 코드를 수정할 필요가 없습니다.
  1. 변경에 닫혀있음:
  • PaymentService 클래스는 구체적인 구현체가 아닌 PaymentProcessor 인터페이스에만 의존합니다.
  • 생성자 주입을 통해 의존성을 외부에서 주입받기 때문에, PaymentService의 코드를 전혀 수정하지 않고도 다른 결제 방식으로 변경할 수 있습니다.

III 리스코프 치환 원칙(Liskov substitution principle)


  • 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 변경할 바꿀 수 있어야 한다.
  • 단순히 컴파일에 성공하는 것을 넘어서는 이야기이다. 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것, 다형성을 지원하기 위한 원칙, 인터페이스를 구현한 구현체는 믿고 사용하려면 이 원칙이 적용되어야한다.
  • 자동차 인터페이스의 엑셀은 앞으로 가라는 기능, 뒤로 가게 구현하면 LSP에 위반한다. 느리더라도 앞으로 가야함.
  • 예를 들어 자동차의 엔진을 교체할 때, 새로운 엔진이 기존 엔진의 모든 기능을 정확히 수행할 수 있어야 한다.
  • 이 원칙을 지키지 않으면 다형성을 사용했을 때 예상치 못한 버그가 발생할 수 있다.
java
// 1. 직사각형을 표현하는 기본 클래스
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

// 2. 정사각형 클래스 - LSP 위반 예시
public class Square extends Rectangle {
    // 정사각형은 가로와 세로가 항상 같아야 하므로 둘 중 하나만 변경해도 둘 다 변경됨
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // LSP 위반!
    }

    @Override
    public void setHeight(int height) {
        this.width = height;  // LSP 위반!
        this.height = height;
    }
}

// 3. LSP를 준수하는 더 나은 설계
public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

public class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

// 4. 테스트 코드
public class LSPTest {
    public static void main(String[] args) {
        // LSP 위반 케이스
        Rectangle rectangle = new Square();
        rectangle.setWidth(4);
        rectangle.setHeight(5);
        // 예상: area = 20
        // 실제: area = 25 (마지막 설정된 높이 값으로 너비도 변경됨)
        System.out.println(rectangle.getArea());

        // LSP 준수 케이스
        Shape rectangle2 = new Rectangle(4, 5);
        Shape square = new Square(5);
        // 예상대로 동작
        System.out.println(rectangle2.getArea());  // 20
        System.out.println(square.getArea());      // 25
    }
}

이 예제는 다음과 같은 LSP 원칙을 설명합니다

  1. 잘못된 상속 관계:
  • Square가 Rectangle을 상속하는 것은 직관적으로 보이지만, 실제로는 LSP를 위반합니다.
  • Square의 setWidth/setHeight가 Rectangle의 계약을 위반하기 때문입니다.
  1. 더 나은 설계:
  • Shape 인터페이스를 도입하여 각 도형을 독립적으로 구현
  • Rectangle과 Square는 각각 자신의 특성에 맞게 area 계산을 구현
  • 상위 타입(Shape)의 기대사항을 정확히 충족
  1. LSP 준수의 이점:
  • 코드의 예측 가능성이 높아짐
  • 다형성을 안전하게 사용 가능
  • 확장성과 유지보수성이 향상

이 예제는 "is-a" 관계가 있다고 해서 반드시 상속을 사용해야 하는 것은 아니며, 때로는 별도의 타입으로 분리하는 것이 더 좋은 설계가 될 수 있다는 것을 보여줍니다.

IV 인터페이스 분리 원칙 ISP(Interface segregation principle)


특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

  • 자동차 인터페이스 -> 운전 인터페이스, 정비 인터페이스로 분리
  • 사용자 클라이언트 -> 운전자 클라이언트, 정비사 클라이언트로 분리
  • 스프링의 경우 철저히 분리되어 있다.
  • 인터페이스가 명확해지고 대체 가능성이 높아진다.
  • 예를 들어 스마트폰의 경우:
    • 전화 기능 인터페이스
    • 카메라 기능 인터페이스
    • 인터넷 기능 인터페이스 로 분리하면 각각의 기능을 독립적으로 개선하거나 교체할 수 있다.
java
// 전화 기능 인터페이스
public interface Phone {
    void makeCall(String number);
    void receiveCall(String number);
    void sendSMS(String number, String message);
}

// 카메라 기능 인터페이스
public interface Camera {
    void takePhoto();
    void recordVideo();
    void applyFilter(String filter);
}

// 인터넷 기능 인터페이스
public interface Internet {
    void browse(String url);
    void downloadFile(String url);
    void streamVideo(String videoUrl);
}

// 스마트폰 구현체
public class SmartPhone implements Phone, Camera, Internet {
    // Phone 구현
    @Override
    public void makeCall(String number) {
        System.out.println("Calling " + number);
    }

    @Override
    public void receiveCall(String number) {
        System.out.println("Receiving call from " + number);
    }

    @Override
    public void sendSMS(String number, String message) {
        System.out.println("Sending SMS to " + number + ": " + message);
    }

    // Camera 구현
    @Override
    public void takePhoto() {
        System.out.println("Taking photo");
    }

    @Override
    public void recordVideo() {
        System.out.println("Recording video");
    }

    @Override
    public void applyFilter(String filter) {
        System.out.println("Applying filter: " + filter);
    }

    // Internet 구현
    @Override
    public void browse(String url) {
        System.out.println("Browsing " + url);
    }

    @Override
    public void downloadFile(String url) {
        System.out.println("Downloading file from " + url);
    }

    @Override
    public void streamVideo(String videoUrl) {
        System.out.println("Streaming video from " + videoUrl);
    }
}

// 전화 기능만 사용하는 클라이언트
public class PhoneUser {
    private final Phone phone;

    public PhoneUser(Phone phone) {
        this.phone = phone;
    }

    public void makeEmergencyCall() {
        phone.makeCall("112");
    }
}

// 카메라 기능만 사용하는 클라이언트
public class PhotoGrapher {
    private final Camera camera;

    public PhotoGrapher(Camera camera) {
        this.camera = camera;
    }

    public void takePortrait() {
        camera.takePhoto();
        camera.applyFilter("Portrait");
    }
}

위 코드는 ISP 원칙을 따르고 있음을 알 수 있다.

  1. 인터페이스 분리:
  • 각 기능이 독립적인 인터페이스로 분리됨
  • 클라이언트는 필요한 기능만 의존할 수 있음
  1. 유지보수성:
  • 각 기능을 독립적으로 수정/개선 가능
  • 한 기능의 변경이 다른 기능에 영향을 주지 않음
  1. 테스트 용이성:
  • 각 기능을 독립적으로 테스트 가능
  • 목(mock) 객체 생성이 쉬움
  1. 확장성:
  • 새로운 기능을 추가할 때 기존 코드 수정 없이 새로운 인터페이스 추가 가능
  • 각 기능의 구현을 쉽게 교체 가능

V 의존관계 역전 원칙 DIP(Dependency inversion principle)


프로그래머는 추상화에 의존해야지 구체화에 의존하면 안된다. 의존성 주입은 이 원칙을 따르는 방법 중 하나다.

  • 구현 클래스에 의존하지 않고 인터페이스에 의존하라는 뜻
  • 상위 모듈은 하위 모듈에 의존해서는 안된다. 둘 다 추상화에 의존해야 한다.
  • 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
  • 하지만 아래코드에서 MemberService는 인터페이스에 의존하지만, 구현 클래스도 동시에 의존한다.
java
public class MemberService {

  // 기존 코드
  // private MemberRepository memberRepository = new MemoryMemberRepository();
  
  // 변경 코드
  private MemberRepository memberRepository = new JdbcMemberRepository();
}
  • 이는 DIP의 원칙을 위반한다.

아래는 이를 잘 준수한 예제이다.

java
// 1. 인터페이스 정의
public interface MemberRepository {
    void save(Member member);
    Member findById(Long id);
}

// 2. 서비스 클래스 - DIP 준수
public class MemberService {
    // 인터페이스에만 의존
    private final MemberRepository memberRepository;
    
    // 생성자 주입을 통해 구현체 주입받음
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
    
    public void signUp(Member member) {
        memberRepository.save(member);
    }
    
    public Member findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

// 3. 스프링 설정을 통한 구현체 주입
@Configuration
public class AppConfig {
    @Bean
    public MemberRepository memberRepository() {
        // 구현체는 설정 클래스에서 결정
        return new JdbcMemberRepository();
        // return new MemoryMemberRepository(); // 구현체 변경이 용이함
    }
    
    @Bean
    public MemberService memberService() {
        // 생성자를 통해 의존관계 주입
        return new MemberService(memberRepository());
    }
}

위 코드는 다음 사항을 준수하여 작성되었다.

  1. DIP 준수:
  • MemberService는 오직 MemberRepository 인터페이스에만 의존
  • 구체적인 구현체(JdbcMemberRepository, MemoryMemberRepository)에 대해 전혀 알지 못함
  1. 유연한 확장:
  • 구현체 변경이 필요할 때 AppConfig만 수정하면 됨
  • MemberService 코드는 전혀 변경할 필요가 없음
  1. 테스트 용이성:
  • 테스트 시 Mock 구현체를 쉽게 주입할 수 있음
  • 각 구현체별로 독립적인 테스트 가능
  1. 관심사의 분리:
  • 객체의 생성과 의존관계 설정은 AppConfig가 담당
  • MemberService는 순수하게 비즈니스 로직에만 집중
  • 이것이 스프링이 지향하는 DIP 원칙을 따르는 좋은 설계 방식입니다.

정리


객체 지향의 핵심은 다형성이지만, 이것만으로는 유연하고 확장 가능한 설계를 달성하기 어렵다.

  • 다형성의 한계

    • 구현 객체를 변경하려면 클라이언트 코드도 함께 변경해야 함
    • 인터페이스를 도입해도 구현 객체를 생성하고 연결하는 책임이 클라이언트에 존재
    • 다형성만으로는 OCP, DIP를 완벽하게 지킬 수 없음
  • SOLID 원칙을 지키기 위한 노력

    • 객체를 생성하고 관리하는 책임을 가지는 별도의 설정 클래스 필요
    • 이런 복잡한 관리를 위해 스프링 프레임워크가 등장

객체 지향 설계와 스프링


스프링은 객체 지향의 핵심 원칙을 지원하는 다양한 기능을 제공한다.

  • 스프링이 지원하는 핵심 기술

    • DI(Dependency Injection): 의존관계, 의존성 주입
      • 객체 간의 의존관계를 외부에서 주입
      • 클라이언트 코드의 변경 없이 구현체 변경 가능
    • DI 컨테이너
      • 객체의 생성과 관리를 담당
      • 의존관계 주입을 자동으로 처리
    • AOP(Aspect Oriented Programming)
      • 공통 관심사를 분리하여 모듈화
      • 핵심 비즈니스 로직에 집중 가능
  • 스프링의 장점

    • 클라이언트 코드의 변경 없이 기능 확장 가능
    • 객체 생성과 의존관계 주입을 자동화
    • 개발자는 비즈니스 로직에만 집중 가능
    • 테스트 용이성 증가

스프링이 없던 시절


스프링이 없던 시절에는 OCP, DIP 원칙을 지키기 위해 많은 수작업이 필요했다.

  • 객체 생성과 의존관계 설정을 위한 복잡한 코드 작성
  • 설정의 변경이 필요할 때마다 코드 수정 필요
  • 테스트와 유지보수의 어려움

이러한 문제를 해결하기 위해 스프링 프레임워크가 등장했으며, DI 컨테이너를 통해 이러한 복잡성을 효과적으로 관리할 수 있게 되었다. 구체적인 내용은 이후 코드를 통해 자세히 살펴보도록 하자.