지난 글에서 SOLID와 디자인 패턴에 대해 알아보았다. (여기 클릭!)
Java 섹션이지만, 가장 먼저 트랜잭션 스크립트에 대해 알아보고 Spring 공식문서에서 소개하는 각각의 Stereotype Annocation인
@Controller, @Service, @Repository를 알아보고
DDD와 OOP를 어떻게 섞어야 하는지를 알가볼 것이다.
한국에서 자바 개발자는 대부분 스프링 쓰자나?
해당 내용도 자바/스프링 개발자를 위한 실용주의 프로그래밍 을 많이 참고했으니 꼭! 구매를 바란다.
그럼... 후비고...!!!
유노윤호가 말했지... 우리에게 가장 해로운 해충은 대충이다. 대충 하지 말고 제대로 해보겠다 ㅠㅠ...
1. 액티브 레코드(Active Record) vs 트랜잭션 스크립트(Transaction Script)
우선 안티패턴에서 자주 보이는 코드 방식에 대해 이해를 해야한다.
액티브 레코드 패턴
레코드는 데이터베이스에서의 레코드를 말한다.
즉, 제 데이터베이스 상에 저장되어 있는 값들의 모임 이다. 튜플이라고도 한다.
데이터 한 줄을 생각하면 된다.
여기서 액티브 레코드 패턴이란
모든 query메소드들을 모델에 정의하고 객체의 저장, 제거 그리고 불러오는 기능들은 모델의 메소드를 통해 사용하는 패턴이다.
다르게 말하면 엔티티가 테이블 ‘행(레코드)’과 1:1로 대응하고, 그 객체 자체가 save() / delete() 같은 CRUD를 수행하는 스타일이 액티브 레코드 패턴이다.
벌써부터 피곤하다.
하나의 객체를 DB에 맞춘 Etntiy로 생성하고, 이 안에 crud작업을 한다고?
단순하기야 하겠는데, class 하나가 너무 많은 기능을 가지고 있어서 프로젝트가 커지면 기존 로직 파악이 힘들것 같다.
더군다나 비즈니스 로직과 데이터 접근 로직이 짬뽕되어 있기에 유지보수에 힘들것 같다.
코드 예시는 클로드에게 질문한 것을 첨부한다.
궁금하면 열어서 봐주시길!
public class User {
private Long id;
private String name;
private String email;
// 생성자, getter, setter
public User(String name, String email) {
this.name = name;
this.email = email;
}
// CREATE - 자기 자신을 DB에 저장
public void save() {
String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
try (Connection conn = getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
pstmt.setString(1, this.name);
pstmt.setString(2, this.email);
pstmt.executeUpdate();
ResultSet rs = pstmt.getGeneratedKeys();
if (rs.next()) {
this.id = rs.getLong(1);
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
// READ - ID로 사용자 찾기
public static User findById(Long id) {
String sql = "SELECT * FROM users WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setLong(1, id);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
User user = new User(rs.getString("name"), rs.getString("email"));
user.id = rs.getLong("id");
return user;
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return null;
}
// UPDATE - 자기 자신을 수정
public void update() {
String sql = "UPDATE users SET name = ?, email = ? WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, this.name);
pstmt.setString(2, this.email);
pstmt.setLong(3, this.id);
pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
// DELETE - 자기 자신을 삭제
public void delete() {
String sql = "DELETE FROM users WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setLong(1, this.id);
pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static Connection getConnection() throws SQLException {
// DB 연결 로직
return DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "user", "password");
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
// 새 사용자 생성 및 저장
User user = new User("김철수", "chulsoo@example.com");
user.save();
// 조회
User foundUser = User.findById(1L);
// 수정
foundUser.setEmail("newemail@example.com");
foundUser.update();
// 삭제
foundUser.delete();
}
}
클로드에게 완벽하게 만들어 달라고 한 건 아니고 예시로 보여달랫는데 위와 같이 답변해줬다.
어짜피 이렇게 코딩 안하니까... 그런가보다~ 하고 봐주길 바란다.
트랜잭션 스크립트
트랜잭션 스크립트는 비즈니스 레이어에 위치하는 서비스 컴포넌트에서 발생하는 안티패턴이다.
말 그대로 서비스 컴포넌트의 구현이 사실상 어떤 "트랜잭션이 걸려 있는 스크립트" 를 실행하는 것처럼 보이기 때문이다.
다른 말로 정리하면 트랜잭션 스크립트는 하나의 비즈니스 트랜잭션(요청)을 하나의 프로시저(메서드)로 처리하는 패턴이다.
해당 코드를 보면 이해가 좀 될 것이다.
예시 : BankTransactionService.java
public class BankTransactionService {
// 계좌 이체 트랜잭션
public void transferMoney(TransferRequest request) {
Connection conn = null;
try {
conn = getConnection();
conn.setAutoCommit(false); // 트랜잭션 시작
// 1. 출금 계좌 조회
Account fromAccount = getAccount(conn, request.getFromAccountId());
if (fromAccount == null) {
throw new IllegalArgumentException("출금 계좌를 찾을 수 없습니다.");
}
// 2. 입금 계좌 조회
Account toAccount = getAccount(conn, request.getToAccountId());
if (toAccount == null) {
throw new IllegalArgumentException("입금 계좌를 찾을 수 없습니다.");
}
// 3. 잔액 확인
if (fromAccount.getBalance().compareTo(request.getAmount()) < 0) {
throw new IllegalStateException("잔액이 부족합니다.");
}
// 4. 출금
BigDecimal newFromBalance = fromAccount.getBalance().subtract(request.getAmount());
updateBalance(conn, fromAccount.getId(), newFromBalance);
// 5. 입금
BigDecimal newToBalance = toAccount.getBalance().add(request.getAmount());
updateBalance(conn, toAccount.getId(), newToBalance);
// 6. 거래 내역 기록
insertTransactionHistory(conn, request);
conn.commit(); // 트랜잭션 커밋
} catch (Exception e) {
if (conn != null) {
try {
conn.rollback(); // 롤백
} catch (SQLException ex) {
ex.printStackTrace();
}
}
throw new RuntimeException("계좌 이체 실패: " + e.getMessage(), e);
} finally {
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
/// 여러개... 이러쿵 저러쿵
}
}
사실 SI에서는 layered architecture를 주로 선택해서 작업을 했었는데
이는 사실 객체지향에 대해 이해하지 않고 그냥 일하다보니... 생기는 문제로 생각된다.
어짜피 작동만 하면 되니까 하는 안일한 방식이 유지보수가 힘든 코드를 양산하는 것이다.
또한 프로시저로 개발을 진행하는 옛날 방식같은 코드에는... 애초에 DB중심적으로 개발을 진행 할 것이니
크게 관심이 없었을 것이다.
service는 단순히 호출하고, 그를 조합하는 곳이 되었기에 관련 도메인 서비스를 다 한곳에 작성한 것이다.
우리가 흔히 아는 layered architecture 에서 service는 repository에 여러번 접근해서 데이터를 가져오고,
이것을 service쪽에서 순차적으로 처리하는 구조가 되는 것이다.
아무리 주석들 달아줘도 큰 문제는 어짜피 직접 까봐야 하고,
테스트 코드는 절대는 아니지만... 거진 뭐 작성을 못한다는 문제가 있다.
이전에 최범균 씨의 TDD Start! 라는 책을 읽었는데,
해당 책을 읽으면서 크게 느낀 점은 기능 단위로 개발을 하고 이것을 위한 단위 테스트 코드를 작성하다보면
위와 같은 상황에서 smelly 한 코드를 작성하는 나를 돌아보게 된다는 점이다.
테스트 코드가 있어야 실제로 원하는대로 작동을 하는지 나중에도 확인이 가능하겠지만,
물론... 모두가 그럴 필요는 없지만 처음부터 고려를 안하고 작성하는 것과, 처음부터 논외인 것은 또 다른 이야기지 않은가?
그런데... 도메인 서비스? 이것이 정확히 뭔지 다시 집고 넘어가보자.
2. 애플리케이션 서비스(application sevice) vs 도메인 서비스(domain service)
기존에 필자가 느낀 키메라의 잘못은... 객체지향을 기존 프로젝트에 어떻게 적용을 해야 하는지 이해를 잘 못했다는 점이다.
이를 위해서는 어플리케이션 서비스와 도메인 서비스에 이해를 해야 명확하게 SOLID한 코드를 개발할 수 있다고 생각한다.
우선 해당 글을 읽어보자.
Indicates that an annotated class is a "Service", originally defined by Domain-Driven Design (Evans, 2003) as "an operation offered as an interface that stands alone in the model, with no encapsulated state.
"May also indicate that a class is a "Business Service Facade" (in the Core J2EE patterns sense), or something similar.
This annotation is a general-purpose stereotype and individual teams may narrow their semantics and use as appropriate.
This annotation serves as a specialization of @Component, allowing for implementation classes to be autodetected through classpath scanning.
주석이 달린 클래스를 “서비스(Service)”로 표시함을 나타낸다. 원래 도메인 주도 설계(에릭 에반스, 2003)에서 서비스는 “모델 안에서 독립적으로 존재하며 캡슐화된 상태가 없는, 인터페이스로 제공되는 연산”으로 정의된다.
또한 이 애너테이션은 코어 J2EE 패턴 관점의 “비즈니스 서비스 파사드(Business Service Facade)” 또는 이와 유사한 것을 나타낼 수도 있다.
이 애너테이션은 범용적인 스테레오타입이며, 각 팀은 필요에 따라 그 의미와 사용 범위를 더 좁혀서 사용할 수 있다.
이 애너테이션은 @Component의 특수화로 동작하므로, 구현 클래스들은 클래스패스 스캐닝을 통해 자동으로 탐지될 수 있다.
@Service는 원래 DDD에서 정의된 서비스로부터 파생한, Spring 에서 제공하는 스테레오 타입 어노테이션이다.
그러면 DDD는 뭔가?
DDD 를 보면 Bounded Context, Aggregate등 다양한 용어가 나온다.
필자는 관련 도시를 읽었었는데도 또 기억이 안난다(망할 기억력 ㅠㅠ...)
하지만 DDD를 간단하게 한 줄로 표현해달라고 claude에게 요청했다.
비즈니스 로직을 도메인 객체 중심으로 설계하고, 도메인 전문가와 같은 언어로 소통하는 것
비즈니스 로직은 정확히 뭐라고 말할 수 있나? 우리가 해결하고자 하는 문제에 대한 코드이다.
도메인이란 우리가 해결하고자 하는 문제 혹은 비즈니스를 말한다.
도메인 객체란 무엇인가? 업무 문제 영역(도메인)의 개념·규칙·행동을 코드로 표현한 객체이다.
문제 영역을 해결하는 비즈니스 로직을 도메인 중심으로 설계하는 것을 DDD라고 한다.
DDD의 정의에서 비즈니스 로직은 도메인 객체 중심으로, 즉 도메인에 있어야 한다고 했다.
여기서 도메인 개발이 필요하지만 객체로 표현하기 애매한 연산 로직을 가지고 있는 컴포넌트를 도메인 서비스라고 한다.
예시를 보자.
// 주문 생성 시 재고 확인
// 이걸 어디에 넣어야 할까?
// 방법 1: Order에 넣기?
public class Order {
public void place(Product product, int quantity) {
// 어색함: Order가 Product의 재고를 확인?
if (product.getStock() < quantity) {
throw new OutOfStockException();
}
// ...
}
}
// 방법 2: Product에 넣기?
public class Product {
public void reserve(int quantity) {
// 어색함: Product가 주문을 알아야 함?
if (this.stock < quantity) {
throw new OutOfStockException();
}
this.stock -= quantity;
}
}
// 좋은 예: 도메인 서비스로 분리
public class OrderPlacementService {
public void placeOrder(Order order, Product product, int quantity) {
// "주문하기"는 Order와 Product 모두 관련
// 둘 사이의 협력을 조율하는 것이 서비스의 책임
if (product.getStock() < quantity) {
throw new OutOfStockException();
}
product.decreaseStock(quantity);
order.addItem(new OrderItem(product, quantity));
}
}
Order와 Product가 있는데, 이 두 객체가 서로 각자의 정보를 확인해야하는 애매한 상황이 있다.
그 어느 객체에도 속하지 않는 애매한 로직들을 도메인 서비스로 작성하는 것이다.
이전의 트랜잭션 스크립트를 생각하면 그러한 것은 생각할 수가 없다.
애초에 서비스, 도메인에 대한 이해가 없었기 때문에 트랜잭션 스크립트가 나온 것이다.
이와 다른 이야기로 애플리케이션 서비스에 대해서도 이해해야한다.
@Service의 정의에서 다음과 같은 표현이 있다.
애너테이션은 코어 J2EE 패턴 관점의 “비즈니스 서비스 파사드(Business Service Facade)
결국 우리가 Layered Architecture에서 선언한 Service는 애플리케이션 레벨의 서비스, 즉 매니저 클래스로
어떠한 객체도 갖기 어려운 경우에 만들어지는 서비스다.
에릭 에반스가 작성한 글인 Domain-Driven Design Tackling Complexity in the Heart of Software 에 Manager에 대한 글이 나와 있다.
출처는 달아놓겠지만, 알음알음 (나는 아파서 앓아요(?)) GPT, claude에게 이게 정확히 어디서 나온 표현인지 찾아보니 여기까지 올라갔다. 물론 책에서도 참고를 했다.
매니저 클래스... 단순히 도메인 서비스를 찾아서 조합하는 부분인것이다.
이를 우리는 애플리케이션 서비스라고 부르는 것이다.
정리를 하자.
애플리케이션 서비스
어떠한 객체도 갖기 어려운 경우에 만들어지는 서비스. 도메인 서비스가 하기 애매한 경우 만들어지는 서비스
도메인 서비스
객체로 표현하기 애매한 로직을 처리하는 서비스
두 개의 서비스 애매한 부분을 처리한다.
다만 처리를 해주는 담당 부분이 다를 뿐이다.
그렇기 떄문에 Application Service를 비즈니스 서비스 파사드라고 표현한 것이다.
파사드처럼 단순히 여러개를 조합해서 사용하기 위한 역할만 하기 때문이다.
3. 객체지향으로 보는 서비스
필자가 참고중인 도서에서 객체지향으로 서비스를 우리는 개발해야하는데
그러면 서비스를 바라봐야 하는지 방향성을 제시했다.
1. 서비스는 가능한 적게, 얇게!
2. 서비스보다 풍부한 도메인 모델 만들기!
개발 우선순위는 도메인 모델 > 도메인 서비스 > 애플리케이션 서비스
어찌보면 위의 흐름을 따라가면 당연한 것이긴 하다.
우리가 해결하고자 하는 도메인에 대해 먼저 이해를 하고, 이 도메인들끼리의 애매한 연산을 처리하는 도메인 서비스를 처리한 후에
spring 에서 지원하는 annocation를 통해 application service을 작성하는 것이 핵심개발 후 조합하는 방식으로 너무 자연스럽다.
4. Layered Architecture에서의 개발
여태 service에 대해서 봐았는데,
그러면 레이어드 아키텍처에서는 어느 부분부터 개발을 하는걸까?
레이어드 아키텍처는 소프트웨어를 여러 개의 계층으로 분리해서 개발하는 아키텍처로
Presentation Layer, Business Layer, Infrastructure Layer로 3개로 분할해서 단방향 의존하도록 개발한다.
이전의 글을 보면 도메인을 먼저 개발하는것이 자연스러운데 Presentation Layer는 응답이 들어오는 부분이고,
Infrastructure Layer는 DB와의 연결을 담당하는 부분이라고 생각하면 자연스럽게 Business layer부터 작성을 해야한다.
그렇다면 이 안에 도메인 관련 코드가 들어갈 것이다.
그럼 구조는 다음과 같다.
빨간 화살표를 기준으로
왼쪽은 단순히 알던 Layerd Architecture이고,
오른쪽은 도메인 서비스와 애플리케이션에 서비스에 대한 이해가 바탕이 된 Layered Architecture이다.
결국, 비즈니스 레이어를 애플리케이션 레이어와 도메인 레이어로 확장한 것이다.
실제로 개발을 진행한다고 하면 Domain부분을 먼저 작성하고,
service(application service)부분에서 특정 DB를 사용하는 경우에는 convert 메소드를 domain 에 혹은 infrastructure layer에서 선언하고 이를 사용하면 될 것이다.
이렇게 되면 사실 spring에 국한되는 방법이 아닌, 모든 백엔드 개발시에 domain 주도로 개발을 진행할 시에는 다 비슷한 구조를 따라가게 된다. 이렇게 생각하니 spring 에 국한되지 않고 프레임워크를 사용해도 크게 다르지 않겠구나 하는 생각이 들었다.
spring을 사용한 해당 코드의 예제는 그냥 claude 나 GPT를 이용해서 검색하면 금방 나오니, 그걸 사용하면 될 듯 하다.
어짜피 다음 섹션부터는 직접 디자인 패턴용 코드를 작성할 것이니, 그걸 봐도 될 듯 하다.
다만 ORM을 사용하는 경우 (예시 : JPA) JPA용 객체와 도메인용 객체를 같이 작성할 것인지 아니면 합쳐서 하나로 퉁쳐서 사용할 것인지는 취향과 프로젝트의 논의 상황에 따라서 하면 된다.
이번에는 해당 책을 많이 참고해서 글을 작성했다.
흐름과 내용은 크게 다르지 않지만
해당 내용이 어디서 있는지, 그리고 나의 생각도 아주 살짝 덧붙여서 설명을 추가했다.
물론 내가 이해하는 흐름으로 다시 요약해서 정리하다보니 나에게는 더 이해가 잘 될것이다.
필자가 가장 크게 도움을 받은 부분은 기존의 객체지향 부분을 Spring 에 어떻게 접목해서 우리가 해야하는지를 잘 몰랐다.
이전 회사에서 직장 동료분과 대화를 했는데, spring을 사용하면 그냥 객체지향 코드가 나오는게 아닌가...
하는 오해를 했었던 적이 있었고, 이를 그대로 생각하고 있어서 디자인 패턴이나 OOP가 허울뿐인가 하는 오해도 했다.
해당 책을 통해서 공부하면서 그동안의 개발 방식이 OOP에서 벗어났으며 이를 각각 서비스에 대한 이해가 안되었으며
이를 실제 spring 프로젝트에 어떻게 녹여낼지를 잘 몰랐기에 생긴 문제였음을 확인했고 덕분이 잘 배울 수 있었다.
하지만 이 글을 읽는 당신들께서는 처음일 수 있으니...
꼭! 자바/스프링 개발자를 위한 실용주의 프로그래밍을 읽어보시길 바란다. 절대 후회 안함 Never... ㅠㅠ
이 다음 글에는 이러한 사항을 고려해서, 직접 테스트 코드를 작성하면서 OOP에 준수한 코드를
참고
https://fabiofumarola.github.io/nosql/readingMaterial/Evans03.pdf
'programming language > Java' 카테고리의 다른 글
[Java] 객체지향 연습 3 - SOLID 원칙과 디자인패턴 (2) | 2025.09.30 |
---|---|
[Java] 객체지향 연습 2 - 행위로 코드 설계하기 (0) | 2025.09.22 |
[Java] 객체지향 연습 1 - 상황 부여와 객체지향 예시 (0) | 2025.09.21 |
[Java] Stream(스트림) - 2탄 (0) | 2025.05.08 |
[Java] 람다 표현식(Lambda Expreesion) - 2탄 (0) | 2025.05.01 |