Spring과 Spring Boot, 무엇을 해결하려고 나왔나


Spring은 EJB 시대의 무거운 엔터프라이즈 개발을 대체하려고 나왔고, Spring Boot는 그 Spring 자체가 무거워진 설정 문제를 해결하려고 나왔다. 이 글은 그 흐름을 따라가며 생긴 질문 네 개를 정리한 것이다.

1. Spring이 해결한 문제

2000년대 초 자바 서버 개발의 표준은 J2EE, 그중 EJB 2.x였다. 문제는 세 가지였다.

  • 비즈니스 로직이 프레임워크에 묶였다. 클래스마다 EJB 인터페이스를 구현하고 배포 서술자 XML을 써야 했다. 테스트하려면 애플리케이션 서버를 띄워야 했다.
  • 객체 생성과 의존성이 코드에 박혔다. new로 직접 만들거나 JNDI로 찾아와야 했고, 구현체 교체가 어려웠다.
  • 횡단 관심사가 흩어졌다. 트랜잭션, 로깅, 보안 코드가 모든 메서드에 반복됐다.

Rod Johnson이 “EJB 없이도 된다”를 보여준 코드가 Spring의 출발점이다. 해법은 세 가지다.

  • IoC/DI 컨테이너. 객체 생명주기와 의존성 주입을 컨테이너가 담당한다. 평범한 자바 객체(POJO)가 그대로 컴포넌트가 된다.
  • AOP. 트랜잭션·보안 같은 횡단 관심사를 프록시로 분리한다. @Transactional 한 줄이 그 결과다.
  • 추상화 계층. JDBC, JMS, 트랜잭션 API를 일관된 템플릿으로 감쌌다.

장단점

장점은 명확하다. POJO 기반이라 단위 테스트가 쉽고, 구현체 교체가 설정 변경으로 끝나며, 생태계가 넓다.

단점도 명확하다. 설정이 무겁다. XML 수백 줄이 Java Config로 바뀌었어도 DataSource, 트랜잭션 매니저, MVC 설정을 직접 조립해야 했다. 모듈 간 버전 호환을 사람이 맞춰야 했고, WAR로 패키징해 외부 톰캣에 올려야 했다. 프록시와 빈 생명주기를 모르면 디버깅이 어렵다.

2. Spring Boot가 해결한 문제

역설적으로 Spring이 성공하면서 EJB의 자리를 Spring이 차지했다. “새 프로젝트 하나 만드는 데 설정만 이틀”이 됐고, 그 사이 Rails와 Node.js는 “명령 하나로 서버 뜨는 경험”을 보여주고 있었다.

Boot는 Spring을 대체하지 않는다. Spring 위에 얹는 의견이 있는(opinionated) 조립 레이어다.

  1. 자동 설정. 클래스패스에 H2가 있으면 DataSource를, Spring MVC가 있으면 DispatcherServlet을 알아서 등록한다. 직접 설정하면 그게 우선한다.
  2. 스타터. spring-boot-starter-web 하나면 MVC, Jackson, 톰캣이 호환되는 버전으로 묶여 온다.
  3. 내장 서버. 톰캣이 jar 안에 들어간다. java -jar app.jar로 끝난다.
  4. Actuator. 헬스체크, 메트릭 엔드포인트가 기본 제공된다.
Spring Framework Spring Boot
나온 해 2003 2014
EJB의 무거움 Spring 설정의 무거움
핵심 IoC/DI, AOP, 추상화 자동 설정, 스타터, 내장 서버
비용 설정 학습 “왜 이게 자동으로 켜졌지?” 이해

Boot의 단점은 하나다. 편한 만큼 안 보인다. 자동 설정이 왜 켜졌는지 모르면 문제가 생겼을 때 막막하다. 그래서 Boot를 잘 쓰려면 결국 Spring의 IoC와 자동 설정 조건을 이해해야 한다.

3. 싱글턴 빈은 Spring이 처음 만든 게 아니다

Spring의 특징으로 싱글턴 빈을 떠올리기 쉽다. 하지만 싱글턴 자체는 Spring 발명이 아니다. 정확히는 이렇다.

  • EJB 2.x에는 싱글턴 빈이 없었다. Stateless Session Bean은 컨테이너가 **풀(pool)**로 여러 개 유지하고 요청마다 하나씩 빌려줬다. 한 인스턴스가 동시에 여러 스레드를 처리하는 걸 스펙이 금지했기 때문이다.
  • Spring은 반대로 갔다. “상태 없는 서비스 객체라면 인스턴스 하나가 모든 스레드를 처리해도 된다. 풀링은 낭비다.” 그래서 기본 스코프를 싱글턴으로 잡았다.
  • EJB가 @Singleton을 추가한 건 2009년 EJB 3.1이다. Spring보다 6년 늦다.

Spring 싱글턴은 GoF 싱글턴 패턴과도 다르다. 클래스가 스스로 인스턴스를 하나로 강제하는 게 아니라, 컨테이너가 빈 정의당 인스턴스 하나만 만들어 캐시한다. 클래스는 평범한 POJO라서 테스트에서는 그냥 new로 만들면 된다.

대가는 개발자에게 넘어왔다. 싱글턴 빈의 필드에 가변 상태를 두면 안 된다는 규칙이다.

4. 인스턴스 하나가 모든 스레드를 처리한다는 말

“인스턴스 하나로 스레드 여러 개를 처리한다”가 직관적으로 이해되지 않았다. 답은 JVM 메모리 구조에 있다. 스레드가 실행하는 건 인스턴스가 아니라 코드다.

어디 몇 개
메서드 바이트코드 Metaspace (메서드 영역) 클래스당 1벌
static 필드 값 힙 (Class 객체에 붙어서) 클래스당 1벌
인스턴스 필드 값 인스턴스당 1벌
지역변수, 매개변수, this 스레드 스택 호출당 1벌

객체 안에는 코드가 없다. 힙의 UserService 인스턴스는 필드 값 묶음일 뿐이고, findUser()의 코드는 Metaspace에 한 벌 있다.

@Service
public class UserService {
    private final UserRepository repo;   // 힙, 공유, 불변

    public User findUser(long id) {      // 코드는 Metaspace에 1벌
        String key = "user:" + id;       // 호출한 스레드의 스택
        return repo.findById(id);
    }
}

스레드 A와 B가 동시에 findUser를 호출하면, 각자 자기 스택에 프레임을 만들고 그 안에 this(힙 주소), id, key를 둔다. 둘 다 같은 바이트코드를 읽어 실행하고, 둘 다 this.repo로 힙의 같은 필드를 읽는다. 코드는 읽기 전용이고 repo는 불변이니 충돌할 게 없다.

[Metaspace]  UserService.findUser 바이트코드 (1벌)
                  ▲                    ▲
[스레드 A 스택]                 [스레드 B 스택]
  this ──┐ id=7                   this ──┐ id=42
         └────────┐    ┌─────────────────┘
                  ▼    ▼
[힙]        UserService 인스턴스 (1개) → repo

깨지는 순간은 인스턴스에 여러 스레드가 쓰는 가변 필드가 생길 때다. private int count; return ++count; 같은 코드다. ++count는 읽기, 더하기, 쓰기 세 단계라서 두 스레드가 겹치면 증가 한 번이 사라진다.

그래서 Spring 싱글턴 빈의 규칙은 이렇게 줄어든다. 필드에는 의존성과 불변 설정값만 둔다. 요청마다 달라지는 값은 매개변수와 지역변수로만 다룬다. 정말 공유 상태가 필요하면 AtomicInteger 같은 동기화 도구를 쓴다.

EJB는 이 안전을 컨테이너가 풀링으로 샀고, Spring은 개발자가 규칙을 지키는 것으로 옮겼다.

5. IoC는 Spring 없이도 된다

IoC와 DI는 패턴이지 Spring 기능이 아니다. 생성자와 main 함수만으로 구현된다.

public class Main {
    public static void main(String[] args) {
        DataSource ds = new HikariDataSource(config());
        UserRepository repo = new JdbcUserRepository(ds);
        UserService service = new UserService(repo);
        new HttpServer(service).start();
    }
}

UserServiceJdbcUserRepository를 모른다. 제어가 뒤집혔고, 의존성을 밖에서 넣어준다. 이 방식을 Pure DI라고 부른다.

Spring이 파는 건 IoC 자체가 아니라 수백 개 객체의 조립·생명주기·프록시를 일관되게 관리하는 컨테이너다. 빈이 5개면 main으로 충분하다. 500개가 되면 컴포넌트 스캔, @PostConstruct, 요청 스코프, AOP 프록시, 설정값 바인딩이 필요해지고, 그걸 직접 짜기 시작하면 작은 Spring을 만들게 된다.

컨테이너가 Spring만 있는 것도 아니다. Guice는 더 가볍고, Dagger와 Micronaut은 위의 main 코드를 사람이 아니라 컴파일러가 생성하게 한다. IoC는 패턴이고, 조립 코드를 누가 쓰느냐만 다르다.

어느 쪽이든 클래스는 생성자 주입으로 짜면 된다. 그러면 컨테이너를 붙이든 떼든 클래스는 안 바뀐다.