1. POJO는 왜 여전히 중요한가
토비의 스프링이 출간되던 시절, POJO는 EJB라는 무거운 컨테이너 종속 구조를 벗어나기 위한 반란이었습니다. 마틴 파울러가 "Plain Old Java Object"라는 이름을 붙인 이유 자체가, 개발자들이 단순한 자바 객체를 쓰는 것을 '초라하게' 느끼지 않도록 하기 위함이었습니다.
2026년 현재 EJB는 사실상 사라졌고, Spring Boot가 사실상 자바 웹 프레임워크의 표준이 되었습니다. 그런데 아이러니하게도, 시간이 흘러 함수형 패러다임과 이벤트 패러다임 등 많은 이론들이 나왔지만 여전히 객체 지향은 더 중요해졌습니다. 그리고 Spring Boot의 자동 설정, 수많은 스타터 의존성, 그리고 각종 애노테이션 조합 속에서 우리의 도메인 객체가 여전히 "진짜 POJO"인지 돌아볼 필요가 있습니다.
DDD 관점에서 POJO의 핵심 가치를 한 문장으로 정리하면 이렇습니다: 도메인 모델이 정말로 순수한 POJO인가?
2. 스프링 애플리케이션의 구조: 과거와 현재
토비의 스프링이 말한 구조
토비의 스프링에서는 스프링 애플리케이션을 두 가지로 구분했습니다: POJO로 만든 애플리케이션 코드, 그리고 POJO 간의 관계를 정의하는 설계 정보(XML 설정, 애노테이션 등). IoC/DI, AOP, PSA(Portable Service Abstraction)는 이 POJO 기반 개발을 가능하게 하는 "가능 기술(Enabling Technology)"로 정의되었습니다.
현대 스프링에서의 재해석
현대 Spring Boot 3.x 애플리케이션에서 이 구조를 다시 그려보면, 핵심 구조는 변하지 않았지만 표현 방식이 크게 달라졌습니다.

여기서 주목할 점은 Domain Model 계층이 순수 POJO로 유지되어야 한다는 것입니다. @Entity, @Service, @Repository 같은 스프링/JPA 애노테이션이 도메인 모델 내부에 침투하는 순간, 그것은 더 이상 진정한 의미의 POJO가 아니라고 볼 수 있습니다.
3. POJO의 세 가지 조건을 현대적으로 재정의하기
토비의 스프링은 POJO의 조건을 세 가지로 정리했습니다. 이를 현대 스프링과 DDD 맥락에서 다시 해석해 봅니다.
3.1 특정 규약에 종속되지 않을 것 → 도메인 모델의 인프라 독립성
과거의 문제: EJB의 SessionBean 인터페이스를 구현해야 했고, 자바의 단일 상속 제약 때문에 객체지향 설계가 제한되었습니다.
현재의 문제: EJB는 사라졌지만, 종속성은 다른 형태로 남아 있습니다.
// ❌ JPA 규약에 종속된 "도메인" 객체
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private List<OrderLine> lines = new ArrayList<>();
@Enumerated(EnumType.STRING)
private OrderStatus status;
// JPA 요구사항: 기본 생성자
protected Order() {}
}
이 코드는 JPA라는 특정 퍼시스턴스 규약에 완전히 종속되어 있습니다. @Entity, @Table, @OneToMany 등의 애노테이션은 도메인 로직과 무관한 인프라 관심사입니다. JPA가 요구하는 기본 생성자는 도메인 불변식(invariant)을 깨뜨릴 위험이 있습니다.
// ✅ 순수 POJO 도메인 모델
public class Order {
private final OrderId id;
private final List<OrderLine> lines;
private OrderStatus status;
private final List<DomainEvent> domainEvents = new ArrayList<>();
public Order(OrderId id, List<OrderLine> lines) {
if (lines == null || lines.isEmpty()) {
throw new IllegalArgumentException("주문에는 최소 하나의 주문 항목이 필요합니다");
}
this.id = id;
this.lines = List.copyOf(lines);
this.status = OrderStatus.CREATED;
this.domainEvents.add(new OrderCreated(id));
}
public void confirm() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("생성 상태의 주문만 확정할 수 있습니다");
}
this.status = OrderStatus.CONFIRMED;
this.domainEvents.add(new OrderConfirmed(this.id));
}
public Money calculateTotal() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(Money.ZERO, Money::add);
}
}
이 객체는 JPA를 모릅니다. Spring을 모릅니다. 오직 비즈니스 규칙만 알고 있습니다. DDD에서 말하는 Aggregate Root의 이상적인 형태입니다. 불변식이 생성자에서 보장되고, 상태 전이에 명확한 규칙이 있으며, 도메인 이벤트를 통해 부수 효과를 표현합니다.
3.2 특정 환경에 종속되지 않을 것 → 헥사고날 아키텍처의 Port
과거의 문제: JNDI 룩업, 서블릿 컨테이너 등 특정 런타임 환경에 종속.
현재의 문제: 현대 스프링에서는 표현 계층의 DTO가 그대로 서비스 계층을 관통하거나, Spring의 ApplicationContext나 @Value 같은 인프라 관심사가 도메인 로직에 침투하는 경우입니다.
// ❌ 표현 계층의 DTO가 도메인 서비스까지 관통하는 구조
@RestController
public class OrderController {
private final OrderService orderService;
@PostMapping("/orders")
public ResponseEntity<OrderResponse> placeOrder(@RequestBody @Valid OrderRequest request) {
// Controller의 DTO가 Service 계층까지 그대로 전달됨
return ResponseEntity.ok(orderService.placeOrder(request));
}
}
@Service
public class OrderService {
// 표현 계층의 DTO에 직접 의존 — Jackson 애노테이션, Validation 애노테이션이
// 서비스 계층의 시그니처를 지배함
public OrderResponse placeOrder(OrderRequest request) {
// request는 @JsonProperty, @NotNull 등 HTTP 직렬화 관심사를 품고 있음
Order order = new Order(request.getUserId(), request.getItems());
repository.save(order);
return new OrderResponse(order.getId(), order.getStatus());
}
}
이 구조는 동작하지만, OrderService가 HTTP 표현 계층의 DTO에 종속되어 있습니다. 만약 같은 주문 로직을 메시지 큐 컨슈머나 배치 잡에서 호출해야 한다면, OrderRequest라는 HTTP용 DTO를 억지로 재사용하거나 중복 코드를 만들게 됩니다.
DDD와 헥사고날 아키텍처 관점에서, 이 문제의 해법은 Port와 Adapter 패턴입니다.
// ✅ 환경 독립적인 Application Service (Use Case)
public class PlaceOrderUseCase {
private final OrderRepository orderRepository;
private final InventoryChecker inventoryChecker;
private final DomainEventPublisher eventPublisher;
public PlaceOrderUseCase(OrderRepository orderRepository,
InventoryChecker inventoryChecker,
DomainEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.inventoryChecker = inventoryChecker;
this.eventPublisher = eventPublisher;
}
public OrderId execute(PlaceOrderCommand command) {
// 도메인 로직 수행
if (!inventoryChecker.isAvailable(command.productId(), command.quantity())) {
throw new InsufficientInventoryException(command.productId());
}
Order order = Order.create(command.customerId(), command.orderLines());
orderRepository.save(order);
order.domainEvents().forEach(eventPublisher::publish);
return order.id();
}
}
// Port (인터페이스) - 도메인 계층에 위치
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
// Adapter (구현) - 인프라 계층에 위치
@Repository
public class JpaOrderRepository implements OrderRepository {
private final JpaOrderEntityRepository jpaRepository;
private final OrderMapper mapper;
@Override
public void save(Order order) {
JpaOrderEntity entity = mapper.toEntity(order);
jpaRepository.save(entity);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value())
.map(mapper::toDomain);
}
}
PlaceOrderUseCase는 HTTP를 모르고, JPA를 모르고, 심지어 Spring도 모릅니다. OrderRepository라는 Port를 통해 인프라와 소통하며, 실제 구현인 JpaOrderRepository는 인프라 계층의 Adapter입니다.
3.3 객체지향 설계가 적용될 것 → 전술적 DDD 패턴의 활용
과거의 관점: 만능 클래스를 만들지 말고, 책임과 역할을 분리하라.
현대적 해석: DDD의 전술적 패턴(Aggregate, Entity, Value Object, Domain Service, Domain Event)이 이 원칙의 구체적 실현입니다.
// ❌ 빈약한 도메인 모델 (Anemic Domain Model)
// 데이터만 들고 있고 행위는 Service에 있음
public class Order {
private Long id;
private String status;
private List<OrderLine> lines;
// getter/setter만 존재
}
@Service
public class OrderService {
public void cancelOrder(Long orderId) {
Order order = repository.findById(orderId);
if ("CONFIRMED".equals(order.getStatus())) {
order.setStatus("CANCELLED");
for (OrderLine line : order.getLines()) {
inventoryService.restore(line.getProductId(), line.getQuantity());
}
repository.save(order);
}
}
}
이 구조에서 Order는 POJO처럼 보이지만, 마틴 파울러가 빈약한 도메인 모델(Anemic Domain Model)이라 비판한 안티패턴입니다. 도메인 지식이 Service에 흩어져 있고, Order 객체는 단순 데이터 홀더에 불과합니다.
// ✅ 풍부한 도메인 모델 (Rich Domain Model)
public class Order {
private final OrderId id;
private OrderStatus status;
private final CustomerId customerId;
private final List<OrderLine> lines;
private final List<DomainEvent> events = new ArrayList<>();
public void cancel(CancellationPolicy policy) {
if (!policy.canCancel(this)) {
throw new OrderCancellationDeniedException(
"취소 정책에 의해 주문 취소가 거부되었습니다: " + id
);
}
this.status = OrderStatus.CANCELLED;
this.events.add(new OrderCancelled(this.id, this.lines));
}
public boolean isWithinCancellationWindow(Clock clock) {
return Duration.between(createdAt, clock.instant())
.compareTo(Duration.ofHours(24)) < 0;
}
public Money calculateTotal() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(Money.ZERO, Money::add);
}
}
여기서 CancellationPolicy는 도메인 서비스 혹은 전략 패턴으로 구현된 정책 객체입니다. 재고 복구는 OrderCancelled 이벤트를 구독하는 별도 핸들러가 담당합니다. 이것이 단일 책임 원칙과 이벤트 기반 느슨한 결합의 조화입니다.
4. Value Object: POJO 원칙의 가장 순수한 구현
DDD에서 Value Object는 POJO 철학의 가장 순수한 형태입니다. Java 14 이후의 Record가 이를 언어 레벨에서 지원합니다. Value Object라고 불리는 값 객체라고 불리는 객체는 순수한 계산 로직이나 값을 처리하는 역활로 활용을 많이 합니다.
// Value Object as Record
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "금액은 필수입니다");
Objects.requireNonNull(currency, "통화는 필수입니다");
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("통화의 소수점 자릿수를 초과했습니다");
}
}
public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.getInstance("KRW"));
public Money add(Money other) {
validateSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
private void validateSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("서로 다른 통화는 연산할 수 없습니다");
}
}
}
public record OrderId(UUID value) {
public OrderId {
Objects.requireNonNull(value);
}
public static OrderId generate() {
return new OrderId(UUID.randomUUID());
}
}
이 Value Object들은 프레임워크 의존성이 전혀 없고, 불변이며, 동등성이 값으로 결정되고, 자체 유효성을 검증합니다. 테스트가 단순하고, 어떤 환경에서든 동일하게 동작합니다.
5. 현대 스프링의 Enabling Technology 재정의
토비의 스프링은 IoC/DI, AOP, PSA를 POJO 개발의 가능 기술로 정의했습니다. 현대 Spring Boot 3.x에서는 이들이 어떻게 진화했는지 살펴봅니다.
IoC/DI → 생성자 주입과 명시적 의존성
Spring Framework 4.3부터 단일 생성자의 경우 @Autowired가 불필요해졌습니다. 이것은 POJO 원칙에 더 가까워진 것입니다.
// Spring에 대한 import가 전혀 없는 Application Service
public class PlaceOrderUseCase {
private final OrderRepository orderRepository;
private final DomainEventPublisher eventPublisher;
// @Autowired 불필요 — Spring이 자동으로 주입
public PlaceOrderUseCase(OrderRepository orderRepository,
DomainEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
}
// Spring 설정은 Infrastructure 계층의 @Configuration에서
@Configuration
public class OrderConfig {
@Bean
public PlaceOrderUseCase placeOrderUseCase(
OrderRepository orderRepository,
DomainEventPublisher eventPublisher) {
return new PlaceOrderUseCase(orderRepository, eventPublisher);
}
}
PlaceOrderUseCase 클래스 자체에는 Spring 관련 import가 단 하나도 없습니다. 이것이 진정한 POJO입니다.
AOP → 횡단 관심사의 인프라 격리
트랜잭션, 로깅, 보안 같은 레이어 레벨에서 처리해야 하는 작업 들은 횡단 관심사를 도메인 밖으로 밀어냅니다.
// 도메인 계층은 트랜잭션을 모름
public class PlaceOrderUseCase {
public OrderId execute(PlaceOrderCommand command) {
Order order = Order.create(command.customerId(), command.orderLines());
orderRepository.save(order);
return order.id();
}
}
// 트랜잭션은 인프라 계층의 Adapter가 처리
@Component
@Transactional
public class TransactionalPlaceOrderAdapter implements PlaceOrderPort {
private final PlaceOrderUseCase useCase;
@Override
public OrderId placeOrder(PlaceOrderCommand command) {
return useCase.execute(command);
}
}
또는 @Configuration에서 AOP를 선언적으로 적용할 수도 있습니다.
PSA → 인터페이스 기반 Port 정의
서비스 추상화는 DDD의 Port 개념과 자연스럽게 합류합니다.
// Port: 도메인이 필요로 하는 인터페이스
public interface NotificationSender {
void send(Notification notification);
}
// Adapter 1: 이메일
@Component
@Profile("production")
public class EmailNotificationSender implements NotificationSender {
private final JavaMailSender mailSender;
// ...
}
// Adapter 2: 테스트용 인메모리
public class InMemoryNotificationSender implements NotificationSender {
private final List<Notification> sent = new ArrayList<>();
@Override
public void send(Notification notification) {
sent.add(notification);
}
public List<Notification> sentNotifications() {
return Collections.unmodifiableList(sent);
}
}
환경에 따라 구현체를 교체할 수 있고, 도메인 코드는 전혀 변경되지 않습니다. 이것이 토비의 스프링이 말한 PSA의 현대적 실현입니다.
6. 애노테이션과 POJO의 경계
토비의 스프링은 애노테이션이 특정 환경에 종속적 정보를 담지 않으면 POJO라 할 수 있다고 했습니다. 현대 스프링에서 이 경계를 명확히 구분해야 합니다.
// POJO 허용 범위의 애노테이션: Bean Validation
public class CreateOrderCommand {
@NotNull private final CustomerId customerId;
@NotEmpty private final List<OrderLineRequest> lines;
// ...
}
Bean Validation 애노테이션(@NotNull, @NotEmpty)은 표준 스펙(Jakarta Validation)이며, 특정 프레임워크에 종속되지 않습니다. 이 정도는 허용 가능합니다.
// ❌ POJO 위반: 도메인 객체에 인프라 애노테이션
public class Order {
@Id @GeneratedValue
private Long id;
@Column(name = "order_status")
@Enumerated(EnumType.STRING)
private OrderStatus status;
@JsonProperty("total_amount") // 직렬화 관심사
private BigDecimal totalAmount;
@Cacheable("orders") // 캐시 관심사
public OrderSummary toSummary() { ... }
}
JPA 매핑, JSON 직렬화, 캐시 전략은 모두 인프라 관심사입니다. 이들이 도메인 객체에 직접 부착되면, 도메인 모델의 변경이 인프라 변경에 의해 강제됩니다. 가령 JPA에서 MongoDB로 전환할 때, 도메인 모델 전체를 수정해야 하는 상황이 발생합니다.
실용적 타협: 현실의 많은 프로젝트에서 JPA 엔티티와 도메인 모델을 분리하는 것은 매핑 비용이 큽니다. 이 경우 모듈 경계를 명확히 하고, 최소한 핵심 Aggregate의 도메인 로직은 최소한의 연관 관계를 매핑한 구조로 활용하는 것으로 타협하는 것이 좋습니다.
7. POJO와 테스트: 비용 대비 효과의 핵심
POJO의 가장 직접적인 실용 가치는 테스트에 있습니다. 도메인 로직이 순수 POJO로 작성되면, 인프라와 프레임워크에 종속되어 있지 않기 때문에 별도의 컨테이너 없이 밀리초 단위로 수천 개의 테스트를 실행할 수 있습니다.
class OrderTest {
@Test
void 주문_생성_시_상태는_CREATED이다() {
Order order = Order.create(
CustomerId.of("cust-1"),
List.of(OrderLine.of(ProductId.of("prod-1"), 2, Money.won(10000)))
);
assertThat(order.status()).isEqualTo(OrderStatus.CREATED);
}
@Test
void 확정된_주문은_취소할_수_있다() {
Order order = createConfirmedOrder();
CancellationPolicy policy = new DefaultCancellationPolicy(Clock.fixed(...));
order.cancel(policy);
assertThat(order.status()).isEqualTo(OrderStatus.CANCELLED);
assertThat(order.domainEvents())
.hasSize(1)
.first()
.isInstanceOf(OrderCancelled.class);
}
@Test
void 빈_주문항목으로_주문을_생성할_수_없다() {
assertThatThrownBy(() -> Order.create(CustomerId.of("cust-1"), List.of()))
.isInstanceOf(IllegalArgumentException.class);
}
}
이 테스트들은 Spring ApplicationContext가 필요 없고, 데이터베이스가 필요 없고, 어떤 외부 의존성도 필요 없습니다.
@SpringBootTest의 수십 초 부팅 시간 없이, 순수 JUnit으로 수백 개의 도메인 규칙을 검증할 수 있습니다.
이것이 토비의 스프링이 말한 "POJO의 코드는 매우 유연한 방식으로 원하는 레벨에서 코드를 빠르고 명확하게 테스트할 수 있음"의 현대적 실현입니다.
8. 결론: POJO는 기술이 아니라 설계 원칙이다
토비의 스프링이 던진 메시지의 본질은 이것입니다: 스프링은 개발자가 객체지향적 설계와 개발의 원리에 집중할 수 있도록 하는 도구이지, 스프링을 쓴다고 좋은 설계가 자동으로 따라오는 것이 아니다.
그렇다고 좋은 설계가 소프트웨어를 개발하는 데 무조건 도움이 되는 것도 아닙니다. 오히려 방해가 될 수 있는 부분도 고려해야 합니다. 예를 들어 현재 데이터베이스와 레디스 정도의 간단한 인프라를 가지고 있고 복잡도가 높지 않은 도메인을 다룰 때 헥사고날 아키텍처를 도입한다면 오히려 더 큰 유지보수 비용이 발생합니다. 코드가 몇 줄 되지 않는다면 빈약한 도메인 모델에 절차지향적인 코드가 더 유지보수에 도움이 될 수도 있습니다.
이러한 판단은 개발자의 몫입니다. 그리고 이러한 판단을 하기 위해서는 코드와 기술로 설계를 하지 않고, 실제 비즈니스와 도메인의 현실적인 부분을 바라봐야지만 해석할 수 있습니다.
현대 스프링과 DDD 관점에서 이를 재정리하면 다음과 같습니다.
- 도메인 모델은 프레임워크를 모르는 순수 POJO여야 합니다. JPA, Spring, Jackson 등의 애노테이션이 도메인 객체에 침투하면, 도메인 모델의 진화가 인프라에 의해 제약됩니다.
- 경계를 명확히 하는 것이 핵심입니다. 헥사고날 아키텍처의 Port와 Adapter, DDD의 Bounded Context와 Anti-Corruption Layer는 모두 이 경계를 정의하는 도구입니다. POJO는 이 경계 안쪽에 존재하는 것입니다.
- 테스트 가능성은 설계 품질의 리트머스 시험지입니다. 도메인 로직을 테스트하기 위해 @SpringBootTest가 필요하다면, 그것은 도메인이 인프라에 종속되어 있다는 신호입니다.
- 설계의 수준은 비즈니스 복잡도에 맞춰야 합니다. 모든 프로젝트에 헥사고날 아키텍처와 Rich Domain Model이 필요한 것은 아닙니다. 단순한 CRUD 서비스에 과도한 추상화를 도입하면 오히려 유지보수 비용이 증가합니다. 프레임워크는 좋은 설계를 "가능하게" 할 뿐이고, 어떤 수준의 설계를 적용할지는 비즈니스와 도메인의 현실을 직시한 개발자의 판단에 달려 있습니다