"UI, 비즈니스 로직, 데이터를 분리하여 독립적으로 개발하자"
// 문제 1: UI와 로직이 뒤섞임
public class UserManagementScreen extends JFrame {
private JTextField nameField;
private JTextField emailField;
private JButton saveButton;
public UserManagementScreen() {
// UI 구성
nameField = new JTextField(20);
emailField = new JTextField(20);
saveButton = new JButton("저장");
// 이벤트 핸들러에 모든 로직!
saveButton.addActionListener(e -> {
// 1. 입력 검증
String name = nameField.getText();
if (name.isEmpty()) {
JOptionPane.showMessageDialog(this, "이름을 입력하세요");
return;
}
// 2. 비즈니스 로직
String email = emailField.getText();
if (!email.contains("@")) {
JOptionPane.showMessageDialog(this, "잘못된 이메일");
return;
}
// 3. 데이터베이스 저장
try {
Connection conn = DriverManager.getConnection(DB_URL);
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO users (name, email) VALUES (?, ?)"
);
stmt.setString(1, name);
stmt.setString(2, email);
stmt.executeUpdate();
// 4. UI 업데이트
JOptionPane.showMessageDialog(this, "저장 완료");
nameField.setText("");
emailField.setText("");
} catch (SQLException ex) {
JOptionPane.showMessageDialog(this, "저장 실패: " + ex.getMessage());
}
});
// UI + 검증 + 비즈니스 로직 + DB + UI 업데이트
// 모두 한 곳에! 😱
}
}
// 문제 2: 중복 코드 폭발
public class WebUserController {
public void saveUser(HttpServletRequest request, HttpServletResponse response) {
String name = request.getParameter("name");
String email = request.getParameter("email");
// 똑같은 검증 로직 반복
if (name.isEmpty()) { /* ... */ }
if (!email.contains("@")) { /* ... */ }
// 똑같은 저장 로직 반복
Connection conn = DriverManager.getConnection(DB_URL);
// ...
}
}
public class MobileUserController {
public void saveUser(UserRequest request) {
// 또 똑같은 로직 반복! 😱
// Web과 Mobile에서 같은 코드 복붙
}
}
// 문제 3: 테스트 불가능
public class ProductListScreen extends JFrame {
private JTable productTable;
public ProductListScreen() {
// UI 초기화
productTable = new JTable();
// 데이터 로드
loadProducts();
}
private void loadProducts() {
try {
Connection conn = DriverManager.getConnection(DB_URL);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM products");
DefaultTableModel model = new DefaultTableModel();
// ResultSet → TableModel 변환
productTable.setModel(model);
} catch (SQLException e) {
e.printStackTrace();
}
}
// 어떻게 테스트?
// DB 없이 테스트 불가능!
// UI 없이 비즈니스 로직만 테스트 불가능!
}
// 문제 4: 변경 취약성
public class OrderForm extends JFrame {
private JLabel totalLabel;
private List<OrderItem> items = new ArrayList<>();
public void addItem(Product product) {
items.add(new OrderItem(product));
// 총액 계산 (비즈니스 로직)
double total = 0;
for (OrderItem item : items) {
total += item.getPrice();
}
// UI 업데이트
totalLabel.setText("총액: " + total);
}
// 총액 계산 로직이 UI에 종속!
// 다른 곳에서 재사용 불가!
// 계산 로직 변경 시 UI 코드 수정!
}- 관심사 혼재: UI, 비즈니스 로직, 데이터 접근이 섞임
- 중복 코드: 같은 로직이 여러 곳에 반복
- 테스트 어려움: UI와 로직이 분리 안 됨
- 변경 취약: 한 부분 수정이 전체에 영향
- 재사용 불가: 로직이 UI에 종속
애플리케이션을 Model(데이터/비즈니스 로직), View(UI), Controller(입력 처리)로 분리하여, 각 컴포넌트가 독립적으로 변경될 수 있게 하는 아키텍처 패턴
- 관심사 분리: 데이터, UI, 제어 로직 분리
- 독립적 개발: 각 컴포넌트 독립 개발
- 재사용성: 같은 Model을 여러 View에서 사용
- 테스트 용이: Model과 Controller 독립 테스트
// Before: 모든 게 섞임
public class Screen {
void onButtonClick() {
String data = textField.getText();
validate(data); // 검증
saveToDatabase(data); // 저장
label.setText("완료"); // UI 업데이트
}
}
// After: MVC로 분리
// Model: 데이터 + 비즈니스 로직
public class User {
private String name;
void validate() { /* ... */ }
void save() { /* ... */ }
}
// View: UI만
public class UserView {
void display(User user) { /* ... */ }
void showMessage(String msg) { /* ... */ }
}
// Controller: 입력 처리 + 조정
public class UserController {
void handleSave(String name) {
user.setName(name);
user.validate();
user.save();
view.display(user);
}
} ┌──────────┐
│ User │
└──────────┘
│
│ 입력
▼
┌──────────┐
│Controller│ ◄───────────────┐
└──────────┘ │
│ │
│ 업데이트 │ 통지
▼ │
┌─────────┐ │
│ Model │─────────────────┘
└─────────┘
│
│ 데이터 요청
▼
┌─────────┐
│ View │
└─────────┘
│
│ 표시
▼
┌──────────┐
│ User │
└──────────┘
┌──────────┐
│ User │
└──────────┘
│
│ 입력
▼
┌──────────┐
│Controller│
└──────────┘
│
│ 업데이트
▼
┌─────────┐
│ Model │ (Observable)
└─────────┘
│
│ notify()
▼
┌─────────┐
│ View │ (Observer)
│ View2 │ (Observer)
│ View3 │ (Observer)
└─────────┘
┌────────────────────────────────────┐
│ Browser (Client) │
└────────────────────────────────────┘
│
│ HTTP Request
▼
┌────────────────────────────────────┐
│ DispatcherServlet │
│ (Front Controller) │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ @Controller │ ← Controller
│ - @RequestMapping │
│ - @GetMapping │
└────────────────────────────────────┘
│
│ 호출
▼
┌────────────────────────────────────┐
│ @Service │ ← Model (비즈니스)
│ - 비즈니스 로직 │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ @Repository │ ← Model (데이터)
│ - 데이터 접근 │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ Database │
└────────────────────────────────────┘
▲
│ ModelAndView
│
┌────────────────────────────────────┐
│ View (JSP, Thymeleaf) │ ← View
│ - HTML 렌더링 │
└────────────────────────────────────┘
│
│ HTML Response
▼
┌────────────────────────────────────┐
│ Browser │
└────────────────────────────────────┘
| 컴포넌트 | 역할 | 책임 | 예시 |
|---|---|---|---|
| Model | 데이터 + 비즈니스 로직 | - 데이터 관리 - 비즈니스 규칙 - 상태 변경 통지 |
User, Order |
| View | UI 표현 | - 데이터 표시 - 사용자 입력 수집 - Model 관찰 |
UserView, JSP |
| Controller | 입력 처리 + 조정 | - 사용자 입력 해석 - Model 업데이트 - View 선택 |
UserController |
/**
* ============================================
* MODEL (데이터 + 비즈니스 로직)
* ============================================
*/
/**
* Todo 엔티티
*/
public class Todo {
private Long id;
private String title;
private String description;
private boolean completed;
private LocalDateTime createdAt;
public Todo(String title, String description) {
this.title = title;
this.description = description;
this.completed = false;
this.createdAt = LocalDateTime.now();
}
/**
* 비즈니스 로직: 완료 토글
*/
public void toggleComplete() {
this.completed = !this.completed;
System.out.println("✓ Todo 상태 변경: " + title + " → " +
(completed ? "완료" : "미완료"));
}
/**
* 비즈니스 로직: 수정
*/
public void update(String title, String description) {
if (title == null || title.trim().isEmpty()) {
throw new IllegalArgumentException("제목은 필수입니다");
}
this.title = title;
this.description = description;
System.out.println("✏️ Todo 수정: " + title);
}
/**
* 검증 로직
*/
public void validate() {
if (title == null || title.trim().isEmpty()) {
throw new IllegalArgumentException("제목은 필수입니다");
}
if (title.length() > 100) {
throw new IllegalArgumentException("제목은 100자 이하");
}
}
// Getters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public String getDescription() { return description; }
public boolean isCompleted() { return completed; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
/**
* TodoModel: Observable 역할
* - Todo 관리
* - Observer에게 변경 통지
*/
public class TodoModel {
private List<Todo> todos;
private List<TodoObserver> observers;
private Long nextId = 1L;
public TodoModel() {
this.todos = new ArrayList<>();
this.observers = new ArrayList<>();
}
/**
* Observer 등록
*/
public void addObserver(TodoObserver observer) {
observers.add(observer);
System.out.println("👀 Observer 등록: " + observer.getClass().getSimpleName());
}
/**
* Observer에게 변경 통지
*/
private void notifyObservers() {
System.out.println("📢 변경 통지 → " + observers.size() + "개 Observer");
for (TodoObserver observer : observers) {
observer.update(new ArrayList<>(todos));
}
}
/**
* Todo 추가
*/
public void addTodo(Todo todo) {
todo.validate(); // 검증
todo.setId(nextId++);
todos.add(todo);
System.out.println("➕ Todo 추가: " + todo.getTitle());
notifyObservers();
}
/**
* Todo 수정
*/
public void updateTodo(Long id, String title, String description) {
Todo todo = findById(id);
todo.update(title, description); // 비즈니스 로직
notifyObservers();
}
/**
* Todo 삭제
*/
public void removeTodo(Long id) {
Todo todo = findById(id);
todos.remove(todo);
System.out.println("🗑️ Todo 삭제: " + todo.getTitle());
notifyObservers();
}
/**
* Todo 완료 토글
*/
public void toggleComplete(Long id) {
Todo todo = findById(id);
todo.toggleComplete(); // 비즈니스 로직
notifyObservers();
}
/**
* Todo 조회
*/
public Todo findById(Long id) {
return todos.stream()
.filter(t -> t.getId().equals(id))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Todo not found: " + id));
}
/**
* 전체 Todo 조회
*/
public List<Todo> getAllTodos() {
return new ArrayList<>(todos);
}
}
/**
* Observer 인터페이스
*/
public interface TodoObserver {
void update(List<Todo> todos);
}
/**
* ============================================
* VIEW (UI)
* ============================================
*/
/**
* Console View (텍스트 기반 UI)
*/
public class TodoConsoleView implements TodoObserver {
private TodoController controller;
public void setController(TodoController controller) {
this.controller = controller;
}
/**
* Model 변경 시 호출됨 (Observer)
*/
@Override
public void update(List<Todo> todos) {
System.out.println("\n╔════════════════════════════════════╗");
System.out.println("║ 📝 TODO LIST ║");
System.out.println("╠════════════════════════════════════╣");
if (todos.isEmpty()) {
System.out.println("║ (할 일이 없습니다) ║");
} else {
for (Todo todo : todos) {
String status = todo.isCompleted() ? "✓" : " ";
String line = String.format("║ [%s] %d. %s",
status, todo.getId(), todo.getTitle());
System.out.println(line);
}
}
System.out.println("╚════════════════════════════════════╝\n");
}
/**
* 메뉴 표시
*/
public void showMenu() {
System.out.println("1. 추가 2. 완료 3. 삭제 4. 종료");
System.out.print("선택: ");
}
/**
* 사용자 입력 처리 (간단한 UI)
*/
public void run() {
Scanner scanner = new Scanner(System.in);
while (true) {
showMenu();
String choice = scanner.nextLine();
try {
switch (choice) {
case "1": // 추가
System.out.print("제목: ");
String title = scanner.nextLine();
System.out.print("설명: ");
String desc = scanner.nextLine();
controller.addTodo(title, desc);
break;
case "2": // 완료
System.out.print("완료할 Todo ID: ");
Long completeId = Long.parseLong(scanner.nextLine());
controller.toggleComplete(completeId);
break;
case "3": // 삭제
System.out.print("삭제할 Todo ID: ");
Long removeId = Long.parseLong(scanner.nextLine());
controller.removeTodo(removeId);
break;
case "4": // 종료
System.out.println("👋 종료합니다");
return;
default:
System.out.println("❌ 잘못된 선택");
}
} catch (Exception e) {
System.out.println("❌ 오류: " + e.getMessage());
}
}
}
}
/**
* Swing GUI View (그래픽 UI)
*/
public class TodoSwingView extends JFrame implements TodoObserver {
private TodoController controller;
private DefaultListModel<String> listModel;
private JList<String> todoList;
private JTextField titleField;
private JTextArea descArea;
private JButton addButton;
private JButton completeButton;
private JButton deleteButton;
public TodoSwingView() {
setTitle("Todo List");
setSize(500, 400);
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
// UI 컴포넌트 초기화
listModel = new DefaultListModel<>();
todoList = new JList<>(listModel);
titleField = new JTextField(20);
descArea = new JTextArea(3, 20);
addButton = new JButton("추가");
completeButton = new JButton("완료");
deleteButton = new JButton("삭제");
// 레이아웃 구성
setLayout(new BorderLayout());
// 왼쪽: Todo 리스트
add(new JScrollPane(todoList), BorderLayout.CENTER);
// 오른쪽: 입력 폼
JPanel inputPanel = new JPanel();
inputPanel.setLayout(new BoxLayout(inputPanel, BoxLayout.Y_AXIS));
inputPanel.add(new JLabel("제목:"));
inputPanel.add(titleField);
inputPanel.add(new JLabel("설명:"));
inputPanel.add(new JScrollPane(descArea));
JPanel buttonPanel = new JPanel();
buttonPanel.add(addButton);
buttonPanel.add(completeButton);
buttonPanel.add(deleteButton);
inputPanel.add(buttonPanel);
add(inputPanel, BorderLayout.EAST);
}
public void setController(TodoController controller) {
this.controller = controller;
// 버튼 이벤트 → Controller로 전달
addButton.addActionListener(e -> {
String title = titleField.getText();
String desc = descArea.getText();
controller.addTodo(title, desc);
// 입력 필드 초기화
titleField.setText("");
descArea.setText("");
});
completeButton.addActionListener(e -> {
int index = todoList.getSelectedIndex();
if (index >= 0) {
Long id = (long) (index + 1); // 간단히 index+1을 ID로
controller.toggleComplete(id);
}
});
deleteButton.addActionListener(e -> {
int index = todoList.getSelectedIndex();
if (index >= 0) {
Long id = (long) (index + 1);
controller.removeTodo(id);
}
});
}
/**
* Model 변경 시 호출됨 (Observer)
*/
@Override
public void update(List<Todo> todos) {
// UI 업데이트
SwingUtilities.invokeLater(() -> {
listModel.clear();
for (Todo todo : todos) {
String status = todo.isCompleted() ? "✓" : " ";
listModel.addElement(String.format("[%s] %s", status, todo.getTitle()));
}
});
}
}
/**
* ============================================
* CONTROLLER (입력 처리 + 조정)
* ============================================
*/
/**
* TodoController
* - 사용자 입력 처리
* - Model 업데이트
* - View는 직접 업데이트 안 함 (Observer 패턴으로 자동)
*/
public class TodoController {
private TodoModel model;
public TodoController(TodoModel model) {
this.model = model;
}
/**
* Todo 추가 처리
*/
public void addTodo(String title, String description) {
try {
// Model 생성 및 추가
Todo todo = new Todo(title, description);
model.addTodo(todo);
System.out.println("✅ Controller: Todo 추가 완료");
} catch (IllegalArgumentException e) {
System.out.println("❌ Controller: 검증 실패 - " + e.getMessage());
throw e; // View에서 처리하도록
}
}
/**
* Todo 완료 토글
*/
public void toggleComplete(Long id) {
try {
model.toggleComplete(id);
System.out.println("✅ Controller: 완료 토글");
} catch (Exception e) {
System.out.println("❌ Controller: 오류 - " + e.getMessage());
throw e;
}
}
/**
* Todo 삭제
*/
public void removeTodo(Long id) {
try {
model.removeTodo(id);
System.out.println("✅ Controller: Todo 삭제");
} catch (Exception e) {
System.out.println("❌ Controller: 오류 - " + e.getMessage());
throw e;
}
}
/**
* Todo 수정
*/
public void updateTodo(Long id, String title, String description) {
try {
model.updateTodo(id, title, description);
System.out.println("✅ Controller: Todo 수정");
} catch (Exception e) {
System.out.println("❌ Controller: 오류 - " + e.getMessage());
throw e;
}
}
}
/**
* ============================================
* APPLICATION (메인)
* ============================================
*/
public class MVCExample {
public static void main(String[] args) {
System.out.println("=== MVC 패턴 Todo 애플리케이션 ===\n");
// 1. Model 생성
TodoModel model = new TodoModel();
// 2. Controller 생성
TodoController controller = new TodoController(model);
// 3. View 생성 및 연결
TodoConsoleView consoleView = new TodoConsoleView();
consoleView.setController(controller);
// 4. View를 Observer로 등록
model.addObserver(consoleView);
// 5. 초기 데이터
System.out.println("\n--- 초기 데이터 추가 ---");
controller.addTodo("MVC 패턴 학습", "Model, View, Controller 이해");
controller.addTodo("Observer 패턴 복습", "MVC와의 관계 정리");
// 6. View 실행
System.out.println("\n--- 애플리케이션 시작 ---");
consoleView.run();
}
}실행 결과:
=== MVC 패턴 Todo 애플리케이션 ===
👀 Observer 등록: TodoConsoleView
--- 초기 데이터 추가 ---
➕ Todo 추가: MVC 패턴 학습
📢 변경 통지 → 1개 Observer
╔════════════════════════════════════╗
║ 📝 TODO LIST ║
╠════════════════════════════════════╣
║ [ ] 1. MVC 패턴 학습 ║
╚════════════════════════════════════╝
✅ Controller: Todo 추가 완료
➕ Todo 추가: Observer 패턴 복습
📢 변경 통지 → 1개 Observer
╔════════════════════════════════════╗
║ 📝 TODO LIST ║
╠════════════════════════════════════╣
║ [ ] 1. MVC 패턴 학습 ║
║ [ ] 2. Observer 패턴 복습 ║
╚════════════════════════════════════╝
--- 애플리케이션 시작 ---
1. 추가 2. 완료 3. 삭제 4. 종료
선택: 2
완료할 Todo ID: 1
✓ Todo 상태 변경: MVC 패턴 학습 → 완료
📢 변경 통지 → 1개 Observer
╔════════════════════════════════════╗
║ 📝 TODO LIST ║
╠════════════════════════════════════╣
║ [✓] 1. MVC 패턴 학습 ║
║ [ ] 2. Observer 패턴 복습 ║
╚════════════════════════════════════╝
✅ Controller: 완료 토글
/**
* ============================================
* MODEL (Domain + Service + Repository)
* ============================================
*/
/**
* Entity: 게시글
*/
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@Column(nullable = false)
private String author;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private int viewCount;
protected Post() {}
public Post(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
this.createdAt = LocalDateTime.now();
this.viewCount = 0;
}
/**
* 비즈니스 로직: 조회수 증가
*/
public void increaseViewCount() {
this.viewCount++;
}
/**
* 비즈니스 로직: 수정
*/
public void update(String title, String content) {
if (title != null && !title.trim().isEmpty()) {
this.title = title;
}
if (content != null) {
this.content = content;
}
}
// Getters
public Long getId() { return id; }
public String getTitle() { return title; }
public String getContent() { return content; }
public String getAuthor() { return author; }
public LocalDateTime getCreatedAt() { return createdAt; }
public int getViewCount() { return viewCount; }
}
/**
* Repository: 데이터 접근
*/
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByAuthor(String author);
@Query("SELECT p FROM Post p ORDER BY p.createdAt DESC")
List<Post> findAllOrderByCreatedAtDesc();
}
/**
* Service: 비즈니스 로직
*/
@Service
@Transactional
public class PostService {
private final PostRepository postRepository;
@Autowired
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
/**
* 게시글 생성
*/
public Post createPost(String title, String content, String author) {
Post post = new Post(title, content, author);
return postRepository.save(post);
}
/**
* 게시글 조회 (조회수 증가)
*/
public Post getPost(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
post.increaseViewCount(); // 비즈니스 로직
return post;
}
/**
* 전체 게시글 조회
*/
@Transactional(readOnly = true)
public List<Post> getAllPosts() {
return postRepository.findAllOrderByCreatedAtDesc();
}
/**
* 게시글 수정
*/
public Post updatePost(Long id, String title, String content) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
post.update(title, content); // 비즈니스 로직
return post;
}
/**
* 게시글 삭제
*/
public void deletePost(Long id) {
postRepository.deleteById(id);
}
}
/**
* ============================================
* VIEW (DTO)
* ============================================
*/
/**
* Request DTO: 게시글 생성
*/
public class CreatePostRequest {
@NotBlank(message = "제목은 필수입니다")
@Size(max = 100, message = "제목은 100자 이하")
private String title;
@NotBlank(message = "내용은 필수입니다")
private String content;
@NotBlank(message = "작성자는 필수입니다")
private String author;
// Getters, Setters
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
}
/**
* Response DTO: 게시글 응답
*/
public class PostResponse {
private Long id;
private String title;
private String content;
private String author;
private LocalDateTime createdAt;
private int viewCount;
public PostResponse(Post post) {
this.id = post.getId();
this.title = post.getTitle();
this.content = post.getContent();
this.author = post.getAuthor();
this.createdAt = post.getCreatedAt();
this.viewCount = post.getViewCount();
}
// Getters
public Long getId() { return id; }
public String getTitle() { return title; }
public String getContent() { return content; }
public String getAuthor() { return author; }
public LocalDateTime getCreatedAt() { return createdAt; }
public int getViewCount() { return viewCount; }
}
/**
* ============================================
* CONTROLLER (입력 처리)
* ============================================
*/
/**
* REST Controller
*/
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
@Autowired
public PostController(PostService postService) {
this.postService = postService;
}
/**
* POST /api/posts - 게시글 생성
*/
@PostMapping
public ResponseEntity<PostResponse> createPost(
@Valid @RequestBody CreatePostRequest request) {
// Service 호출 (Model)
Post post = postService.createPost(
request.getTitle(),
request.getContent(),
request.getAuthor()
);
// Response DTO 변환 (View)
PostResponse response = new PostResponse(post);
return ResponseEntity.ok(response);
}
/**
* GET /api/posts/{id} - 게시글 조회
*/
@GetMapping("/{id}")
public ResponseEntity<PostResponse> getPost(@PathVariable Long id) {
Post post = postService.getPost(id);
PostResponse response = new PostResponse(post);
return ResponseEntity.ok(response);
}
/**
* GET /api/posts - 전체 게시글 조회
*/
@GetMapping
public ResponseEntity<List<PostResponse>> getAllPosts() {
List<Post> posts = postService.getAllPosts();
List<PostResponse> responses = posts.stream()
.map(PostResponse::new)
.collect(Collectors.toList());
return ResponseEntity.ok(responses);
}
/**
* PUT /api/posts/{id} - 게시글 수정
*/
@PutMapping("/{id}")
public ResponseEntity<PostResponse> updatePost(
@PathVariable Long id,
@Valid @RequestBody CreatePostRequest request) {
Post post = postService.updatePost(
id,
request.getTitle(),
request.getContent()
);
PostResponse response = new PostResponse(post);
return ResponseEntity.ok(response);
}
/**
* DELETE /api/posts/{id} - 게시글 삭제
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
postService.deletePost(id);
return ResponseEntity.noContent().build();
}
}
/**
* Web Controller (HTML 반환)
*/
@Controller
@RequestMapping("/posts")
public class PostWebController {
private final PostService postService;
@Autowired
public PostWebController(PostService postService) {
this.postService = postService;
}
/**
* GET /posts - 게시글 목록 페이지
*/
@GetMapping
public String listPosts(Model model) {
List<Post> posts = postService.getAllPosts();
model.addAttribute("posts", posts);
return "post/list"; // View 이름 (list.html)
}
/**
* GET /posts/{id} - 게시글 상세 페이지
*/
@GetMapping("/{id}")
public String viewPost(@PathVariable Long id, Model model) {
Post post = postService.getPost(id);
model.addAttribute("post", post);
return "post/detail"; // View 이름 (detail.html)
}
/**
* GET /posts/new - 게시글 작성 페이지
*/
@GetMapping("/new")
public String newPostForm(Model model) {
model.addAttribute("post", new CreatePostRequest());
return "post/form"; // View 이름 (form.html)
}
/**
* POST /posts - 게시글 생성 처리
*/
@PostMapping
public String createPost(@Valid @ModelAttribute CreatePostRequest request,
BindingResult result) {
if (result.hasErrors()) {
return "post/form";
}
Post post = postService.createPost(
request.getTitle(),
request.getContent(),
request.getAuthor()
);
return "redirect:/posts/" + post.getId();
}
}Thymeleaf View (list.html):
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>게시글 목록</title>
</head>
<body>
<h1>📝 게시글 목록</h1>
<a href="/posts/new">새 글 작성</a>
<table>
<thead>
<tr>
<th>번호</th>
<th>제목</th>
<th>작성자</th>
<th>조회수</th>
<th>작성일</th>
</tr>
</thead>
<tbody>
<tr th:each="post : ${posts}">
<td th:text="${post.id}">1</td>
<td>
<a th:href="@{/posts/{id}(id=${post.id})}"
th:text="${post.title}">제목</a>
</td>
<td th:text="${post.author}">작성자</td>
<td th:text="${post.viewCount}">0</td>
<td th:text="${#temporals.format(post.createdAt, 'yyyy-MM-dd')}">
2025-01-01
</td>
</tr>
</tbody>
</table>
</body>
</html>| 패턴 | 특징 | 사용처 |
|---|---|---|
| MVC (전통적) | Model이 View에 직접 통지 | Swing, JavaFX |
| MVP | View가 수동적, Presenter가 모든 제어 | Android (구형) |
| MVVM | ViewModel + 데이터 바인딩 | WPF, Angular, Vue |
| MVC Model 2 | Front Controller 패턴 | Spring MVC, Struts |
=== MVC ===
View ──→ Controller ──→ Model
↑ │
└────── notify ──────────┘
=== MVP ===
View ←──→ Presenter ──→ Model
(View는 완전 수동)
=== MVVM ===
View ←─ Data Binding ─→ ViewModel ──→ Model
(자동 동기화)
| 장점 | 설명 | 실무 효과 |
|---|---|---|
| 관심사 분리 | UI, 로직, 데이터 독립 | 유지보수 용이 |
| 재사용성 | Model을 여러 View에서 재사용 | Web + Mobile |
| 테스트 용이 | Model, Controller 독립 테스트 | 단위 테스트 |
| 병렬 개발 | MVC 각각 독립 개발 | 팀 협업 |
| 확장 용이 | 새 View 추가 쉬움 | UI 변경 |
| 단점 | 설명 | 해결책 |
|---|---|---|
| 복잡도 | 간단한 UI도 3개 컴포넌트 | 상황에 맞게 |
| Controller 비대 | 로직이 Controller에 집중 | Service Layer |
| View-Model 결합 | View가 Model 구조 알아야 | DTO 사용 |
// 잘못된 예: Controller에 모든 로직
@Controller
public class UserController {
@PostMapping("/users")
public String createUser(@RequestParam String name,
@RequestParam String email) {
// ❌ 검증 로직이 Controller에
if (name.isEmpty()) {
return "error";
}
if (!email.contains("@")) {
return "error";
}
// ❌ 비즈니스 로직이 Controller에
String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt());
// ❌ DB 접근까지 Controller에
Connection conn = DriverManager.getConnection(DB_URL);
// ...
return "success";
}
}해결:
// 올바른 예: Controller는 조정만
@Controller
public class UserController {
@Autowired
private UserService userService; // ✅ Service에 위임
@PostMapping("/users")
public String createUser(@Valid UserRequest request) {
// ✅ Service 호출
User user = userService.createUser(request);
return "redirect:/users/" + user.getId();
}
}
@Service
public class UserService {
// ✅ 비즈니스 로직은 Service에
public User createUser(UserRequest request) {
User user = new User(request.getName(), request.getEmail());
user.validate(); // 도메인 로직
return userRepository.save(user);
}
}// 잘못된 예: Model이 Getter/Setter만
public class Order {
private List<OrderItem> items;
private BigDecimal total;
// Getter/Setter만 있음
public List<OrderItem> getItems() { return items; }
public void setItems(List<OrderItem> items) { this.items = items; }
public BigDecimal getTotal() { return total; }
public void setTotal(BigDecimal total) { this.total = total; }
}
// ❌ 로직이 Service에
@Service
public class OrderService {
public void addItem(Order order, OrderItem item) {
order.getItems().add(item); // ❌ Service가 직접 조작
// ❌ 총액 계산도 Service에
BigDecimal total = BigDecimal.ZERO;
for (OrderItem i : order.getItems()) {
total = total.add(i.getPrice());
}
order.setTotal(total);
}
}해결:
// 올바른 예: Rich Domain Model
public class Order {
private List<OrderItem> items;
private BigDecimal total;
// ✅ 비즈니스 로직이 Model에
public void addItem(OrderItem item) {
items.add(item);
calculateTotal(); // 도메인 로직
}
private void calculateTotal() {
this.total = items.stream()
.map(OrderItem::getPrice)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// ✅ 불변성 보호
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
}
@Service
public class OrderService {
public void addItemToOrder(Long orderId, OrderItem item) {
Order order = orderRepository.findById(orderId);
order.addItem(item); // ✅ 도메인 로직 호출
orderRepository.save(order);
}
}/**
* 도메인 주도 설계와 MVC 통합
*/
// Aggregate Root
@Entity
public class Order {
@Id
private Long id;
@Embedded
private OrderStatus status;
@ElementCollection
private List<OrderLine> orderLines;
// ✅ 도메인 로직
public void place() {
if (orderLines.isEmpty()) {
throw new CannotPlaceEmptyOrderException();
}
this.status = OrderStatus.PLACED;
}
public void cancel() {
if (!status.canCancel()) {
throw new CannotCancelOrderException();
}
this.status = OrderStatus.CANCELLED;
}
}
// Value Object
@Embeddable
public class OrderStatus {
private String value;
public boolean canCancel() {
return value.equals("PLACED") || value.equals("CONFIRMED");
}
}
// Repository (인터페이스는 Domain에)
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(Long id);
}
// Application Service
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
@Transactional
public void placeOrder(PlaceOrderCommand command) {
// 1. Aggregate 생성
Order order = new Order(command.getCustomerId());
// 2. 도메인 로직 실행
for (OrderItemDTO item : command.getItems()) {
order.addOrderLine(new OrderLine(item));
}
order.place();
// 3. 저장
orderRepository.save(order);
}
}/**
* DispatcherServlet (Front Controller)
*/
public class DispatcherServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response) {
// 1. Handler Mapping: URL → Controller 매핑
HandlerExecutionChain handler = getHandler(request);
// 2. Handler Adapter: Controller 실행
ModelAndView mav = handleRequest(handler, request, response);
// 3. View Resolver: View 이름 → 실제 View
View view = resolveViewName(mav.getViewName());
// 4. View Rendering
view.render(mav.getModel(), request, response);
}
}
/**
* 실제 Controller
*/
@Controller
public class ProductController {
@GetMapping("/products/{id}")
public String getProduct(@PathVariable Long id, Model model) {
// Model 업데이트
Product product = productService.findById(id);
model.addAttribute("product", product);
// View 이름 반환
return "product/detail";
}
}✅ Model: 데이터 + 비즈니스 로직 (독립적)
✅ View: UI만 (Model 관찰)
✅ Controller: 입력 처리 + 조정 (얇게)
✅ Model ↔ View 직접 의존 없음
✅ Observer 패턴으로 Model → View 통지
✅ 비즈니스 로직은 Model에
✅ Service Layer 분리 (복잡한 경우)
| 상황 | 추천도 | 이유 |
|---|---|---|
| 웹 애플리케이션 | ⭐⭐⭐ | 표준 패턴 |
| Desktop GUI | ⭐⭐⭐ | Swing, JavaFX |
| 복잡한 UI | ⭐⭐⭐ | 관심사 분리 |
| 간단한 CRUD | ⭐⭐ | 오버엔지니어링 가능 |
- Model은 독립적 (UI 몰라야 함)
- View는 수동적 (Model 관찰만)
- Controller는 얇게 (조정만)
- 비즈니스 로직은 Model
// ✅ DO: Model에 비즈니스 로직
public class User {
public void changePassword(String newPassword) {
validatePassword(newPassword);
this.password = encrypt(newPassword);
}
}
// ❌ DON'T: Controller에 비즈니스 로직
@Controller
public class UserController {
public void changePassword(String password) {
if (password.length() < 8) { /* ... */ } // ❌
user.setPassword(encrypt(password)); // ❌
}
}
// ✅ DO: Service Layer 분리 (복잡한 경우)
@Service
public class UserService {
@Transactional
public void changePassword(Long userId, String newPassword) {
User user = userRepository.findById(userId);
user.changePassword(newPassword); // ✅ 도메인 로직
userRepository.save(user);
emailService.sendPasswordChangeNotification(user);
}
}