
단순한 MVC 패턴의 한계를 극복하기 위해 현대적 아키텍처는 Service, Repository, DTO 계층까지 확장되었다. Spring Boot에서 확장된 흐름은 다음과 같다.
┌─────────────────┐
│ Presentation │ ← View (Thymeleaf, React, 모바일 앱 등)
│ Layer │ • UI 컴포넌트, 템플릿, 정적 리소스
├─────────────────┤
│ Controller │ ← 요청 해석, 흐름 제어, 응답 형태 결정
│ Layer │ • HTTP 요청/응답 처리, 라우팅, 기본 검증
├─────────────────┤
│ Service │ ← 트랜잭션 관리, 비즈니스 로직 조율
│ Layer │ • 업무 규칙 적용, 계층 간 조정, 외부 서비스 연동
├─────────────────┤
│ Domain │ ← 핵심 비즈니스 로직, 엔티티
│ Model Layer │ • 도메인 객체, 비즈니스 규칙, 불변성 보장
├─────────────────┤
│ Persistence │ ← 데이터 접근, CRUD 연산
│ Layer │ • 데이터베이스 연결, 쿼리 실행, 영속성 관리
└─────────────────┘
모든 것이 한 클래스에 섞여 있는 경우
public class QuestionController {
public String processQuestion(String subject, String content) {
// 1. HTTP 요청 처리
if (subject == null || subject.isEmpty()) {
return "error: subject required";
}
// 2. 비즈니스 로직
if (subject.length() > 200) {
subject = subject.substring(0, 200);
}
// 3. 데이터베이스 직접 접근
Connection conn = DriverManager.getConnection("jdbc:mysql://...");
PreparedStatement pstmt = conn.prepareStatement(
"INSERT INTO question (subject, content) VALUES (?, ?)");
pstmt.setString(1, subject);
pstmt.setString(2, content);
pstmt.executeUpdate();
// 4. HTML 생성
return "<html><body>Question saved!</body></html>";
}
}
손님이 커피를 주문할 때, 한 직원이 주문을 받고 음료를 제조하며 창고까지 관리하면 회전율도 떨어지고, 과부하가 올 수 있다. 하지만 카운터 직원(= Controller), 바리스타 (= Service), 커피 레시피(= Domain), 창고 관리자(= Repository)가 각자 맡은 일만 하게 되면 효율적이게 되고, 누군가 아프면 그 역할만 대체하면 되니 카페 운영을 효율적으로 할 수 있게 된다.
단일 책임 원칙 (Single Responseibility Principle)
각 계층은 하나의 명확한 책임만 가져야 한다.
사용자 인터페이스를 담당하는 최상위 계층으로, 사용자와 직접 상호작용하는 모든 요소를 포함한다.
실제 비즈니스 환경으로 비유하자면, 은행의 ‘고객 대면 창구’나 ‘ATM 기계’와 같은 역할을 한다. 고객이 직접 보고 만질 수 있는 모든 접점이 이 계층에 해당한다.
주요 구성 요소:
같은 상품 정보라도 웹 브라우저, 모바일 앱, 액셀 파일 등 다양한 방식과 디바이스로 값을 전달받아야 할 경우가 있다. 만약 이걸 구분하지 않고 한 곳에 다 섞게 될 경우, 디자인 변경이나 새로운 디바이스 지원 시 핵심 비즈니스 로직까지 수정해야 하는 위험이 발생한다. 이 계층을 분리함으로써 동일한 데이터를 다양한 형태로 표현할 수 있고, UI 변경이 비즈니스 로직에 영향을 주지 않는다.
HTTP 요청을 해석하고 흐름을 제어하는 계층으로, 웹 애플리케이션의 진입점 역할을 수행한다.
은행으로 비유하자면 ‘접수 데스크’와 같다. 고객의 요청을 받아 어떤 서비스가 필요한지 판단하고, 적절한 담당자(Service)에게 업무를 전달한다.
주요 책임:
@Controller
@RequestMapping("/question")
public class QuestionController {
@GetMapping("/list")
public String list(Model model) {
List<QuestionDto> questions = questionService.getQuestionList();
model.addAttribute("questions", questions);
return "question_list"; // View 이름 반환
}
@PostMapping("/create")
public String create(@Valid @ModelAttribute QuestionForm form,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "question_form"; // 검증 실패 시 폼으로 돌아가기
}
questionService.createQuestion(form.toDto());
return "redirect:/question/list";
}
}
사용자가 폼을 제출하면 HTTP 요청이 오는데, 여기엔 쿠키/세션/보안 토큰 등 많은 정보가 섞여 있다. 만약 실제 비즈니스 로직을 처리하는 Service에서 이런 웹 기술을 직접 다루면, 나중에 모바일 앱을 만들 때 똑같은 로직을 또 만들어야 한다. 이렇게 될 경우, 각 모듈의 재사용성을 떨어뜨리고, 테스트를 어렵게 만들며, 자바의 객체지향이라는 특성을 잘 살리지 못하게 된다. 따라서 Controller는 HTTP의 복잡성을 흡수하여 Service 계층이 순수한 비즈니스 로직에만 집중할 수 있도록 한다.
트랜잭션을 관리하고 비즈니스 로직을 조율하는 계층으로, 컨트롤러와 데이터 접근 계층 사이에서 중재자 역할을 수행한다.
실제 비즈니스 환경으로 비유하면, 서비스 계층은 은행에서 ‘창구 직원’과 같은 역할을 한다. 고객(Controller)의 요청을 받아 내부 시스템(Repository)과 소통하며, 복잡한 업무 처리 과정을 관리하고 최종 결과를 고객에게 전달한다.
주요 책임:
@Service
@Transactional(readOnly = true)
public class QuestionService {
private final QuestionRepository questionRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
public List<QuestionDto> getQuestionList() {
List<Question> questions = questionRepository.findAll();
return questions.stream()
.map(QuestionDto::from)
.collect(Collectors.toList());
}
@Transactional
public Question createQuestion(String subject, String content, Long authorId) {
// 1. 사용자 검증
User author = userRepository.findById(authorId)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다"));
// 2. 비즈니스 규칙 적용
if (subject.length() > 200) {
throw new BusinessException("제목은 200자를 초과할 수 없습니다");
}
// 3. 질문 생성
Question question = Question.builder()
.subject(subject)
.content(content)
.author(author)
.build();
// 4. 저장
Question savedQuestion = questionRepository.save(question);
// 5. 알림 발송 (외부 서비스 연동)
notificationService.sendQuestionCreatedNotification(savedQuestion);
return savedQuestion;
}
@Transactional
public void deleteQuestion(Long questionId, Long userId) {
Question question = questionRepository.findById(questionId)
.orElseThrow(() -> new EntityNotFoundException("질문을 찾을 수 없습니다"));
// 삭제 권한 검증 (비즈니스 규칙)
if (!question.canDelete(userId)) {
throw new BusinessException("질문을 삭제할 권한이 없습니다");
}
questionRepository.deleteById(questionId);
}
}
실제 비즈니스는 복잡하다. 예를 들어 온라인 쇼핑몰에서 물건을 구매하게 되면, 구매 확정까지 1) 재고를 확인하고 2) 재고를 빼고 3) 결제하고 4) 포인트 적립하고 5) 배송을 준비하고 6) 알림 보내는 과정이 하나로 묶여야 한다. 만일, 중간에 3번 과정인 결제하기 과정을 실패하게 되면 1, 2번째 작업도 다시 되돌려야 한다. 이런 복잡한 업무 흐름을 Controller 계층에서 하게 되면, 너무 복잡하고 재사용이 어려워진다. Service 계층은 이런 비즈니스 흐름을 체계적으로 관리한다.
Service vs Domain 구분
- Service: 여러 도메인 객체를 조율하고, 업무 흐름 관리
- Domain: 객체 자체의 규칙(자기 검증, 불변성, 정책)
핵심 비즈니스 로직과 엔티티를 포함하는 계층으로, 애플리케이션의 ‘비즈니스 심장부’에 해당한다.
은행으로 비유하면 ‘업무 규정집’이나 ‘상품 정책서’와 같다. 은행이 어떤 업무를 어떻게 처리해야 하는지에 대한 핵심 규칙들이 모여 있는 곳이다.
주요 구성 요소:
@Entity
@Table(name = "question")
public class Question {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String subject;
@Column(columnDefinition = "TEXT")
private String content;
@CreationTimestamp
private LocalDateTime createDate;
@OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
private List<Answer> answerList = new ArrayList<>();
// 비즈니스 로직: 답변 추가 시 검증
public void addAnswer(Answer answer) {
if (this.answerList.size() >= 10) {
throw new BusinessException("답변은 최대 10개까지 등록 가능합니다.");
}
this.answerList.add(answer);
answer.setQuestion(this);
}
// 비즈니스 로직: 질문 수정 권한 검증
public boolean canModify(User user) {
return this.author.equals(user) &&
this.createDate.isAfter(LocalDateTime.now().minusHours(24));
}
}
만약 주문에 대한 모든 규칙이 Service에 흩어져 있다면, “주문은 인당 두 개까지”라는 규칙을 여러 곳에서 중복해 사용해야 한다. 그리고 그 규칙을 나중에 “인당 세 개까지”로 바꾸고 싶으면 또 여기저기 다 찾아서 바꿔야 한다. 하지만 주문 객체가 스스로 이 규칙을 알고 있으면, 한 곳에서만 바꾸면 된다. 마치 청소년이 “나는 아직 성인이 되지 않아, 대리인의 동의가 필요해”라고 스스로 알고 있는 것처럼, 각 객체가 자신의 규칙을 스스로 관리하면 일관성 있게 데이터를 처리할 수 있게 된다.
데이터 저장소와의 모든 상호작용을 담당하는 계층으로, 데이터의 생성, 읽기, 수정, 삭제를 관리한다.
은행으로 비유하면 '금고'나 '서류 보관소'와 같다. 모든 중요한 데이터를 안전하게 보관하고, 필요할 때 정확하게 찾아서 제공하는 역할을 한다.
주요 구성 요소:
@Repository
public interface QuestionRepository extends JpaRepository<Question, Long> {
// 메서드 이름 기반 쿼리 생성
List<Question> findBySubjectContainingIgnoreCase(String keyword);
// 커스텀 쿼리 정의
@Query("SELECT q FROM Question q WHERE q.createDate >= :fromDate ORDER BY q.createDate DESC")
List<Question> findRecentQuestions(@Param("fromDate") LocalDateTime fromDate);
// 네이티브 쿼리 사용
@Query(value = "SELECT * FROM question WHERE MATCH(subject, content) AGAINST(?1 IN NATURAL LANGUAGE MODE)",
nativeQuery = true)
List<Question> searchByFullText(String searchTerm);
// 페이징 처리
Page<Question> findAll(Pageable pageable);
}
데이터베이스는 복잡하다. 연결하고, SQL 쿼리를 쓰고, 결과를 받아서 변환하고, 에러를 처리하고, 작업이 끝나면 연결을 끊는 과정을 매번 Service에서 직접 쓰면 비즈니스 로직보다 데이터베이스 코드가 더 많아질 것이다. 또한, 수정이 필요할 때 해당 로직이 작성된 것을 모두 찾아 수정해야 하는 불편함도 있을 것이다. Repository 패턴은 이런 복잡한 일을 대신 해 주는 도우미다. 우리는 그냥 “제목에 ‘스프링’이 포함된 질문을 찾고 싶어”라고 하면, Repository가 알아서 복잡한 SQL을 만들어서 실행하고 결과를 깔끔하게 정리해서 준다.