Kotlin + Ktor + React 프로젝트를 Java + Spring + React 프로젝트로(2 - MVC 패턴 먹이고 게시판 가져오자)
Spring Boot 기반의 프로젝트는 MVC 패턴을 따르면 코드의 구조화 및 유지보수성을 향상시킬 수 있다고 말을 하며 이전 포스팅을 마쳤다. 이번에도 해당 패턴을 적용할 것이다.
아 근데 좀 말투가 너무 딱딱한가요? 아까 쓴 글을 보니 뭔가 너무 딱딱한 사람 같아서 뭐 네 그냥 존댓말로 하도록 하겠습니다.
각 패키지들이 MVC 패턴에서 무슨 역할을 하느냐를 간단히 짚어보겠습니다
controller | Controller | 클라이언트의 HTTP 요청을 처리하고, 적절한 서비스 호출 및 응답 반환. |
entity | Model (Entity) | 데이터베이스 테이블과 매핑되는 클래스 정의 (ORM 엔티티). |
model | Model (DTO/VO) | 데이터 전달 객체(Data Transfer Object) 또는 값 객체(Value Object). |
repository | Data Access Layer | 데이터베이스와의 상호작용을 담당하며, CRUD 기능 제공. |
service | Service | 비즈니스 로직을 처리하며, 컨트롤러와 리포지토리 사이에서 중간 역할 수행. |
위처럼, 저 또한 이번 프로젝트를 생성하면서 Entity와 Model 두개가 있을 필요가 있나? 라는 생각이었는데 claude는 이렇게 두 개로 나누어서 Entity 패키지에서는 DB 테이블과 매핑되는 클래스를 정의하고, Model 패키지에서는 데이터 전달 객체 혹은 값 객체를 정의한다고 이야기 했습니다.
구현순서는 가장 쉬운 파트부터 하겠습니다.
기존 프로젝트는 주식 정보 + 주식 관련 뉴스정보 + 커뮤니티 기능이므로, 커뮤니티로 CRUD를 체험하고, 주식정보 불러오는 기능을 구현한 후 뉴스정보를 가져오는게 순서가 괜찮겠죠?
흠 그럼 기본 골조를 잡아볼까요 순서를 정하겠습니다.
1. Entity (Post.java) 정의하기
- 데이터베이스 테이블과 매핑되는 Java 객체
- JPA 엔티티로 정의되어 데이터베이스 테이블 구조를 표현
- Lombok 어노테이션을 통해 반복적인 코드(getter, setter 등) 자동 생성
package com.example.FinanceNewsProvider.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data // getter, setter, toString 등을 자동으로 생성
@Entity // JPA 엔티티임을 나타냄
@Table(name = "posts") // 실제 DB 테이블 이름 지정
@Builder // 빌더 패턴 구현
@NoArgsConstructor // 매개변수 없는 생성자
@AllArgsConstructor // 모든 필드를 매개변수로 받는 생성자
public class Post {
@Id // 기본키 지정
@GeneratedValue(strategy = GenerationType.IDENTITY) // 기본키 생성 전략 설정
private Integer id;
@Column(length=255, nullable=false) // 칼럼 속성 정의
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@Column(length=255, nullable = false)
private String author;
@Column(name="created_at")
private LocalDateTime createdAt;
}
2. Repository (PostRepository.java) 정의하기
- 데이터베이스 작업을 담당하는 인터페이스
- JpaRepository를 상속받아 기본적인 CRUD 작업 메서드 제공
- 필요한 경우 커스텀 쿼리 메서드 추가 가능
package com.example.FinanceNewsProvider.repository;
import com.example.FinanceNewsProvider.entity.Post;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostRepository extends JpaRepository<Post, Integer> {
// JpaRepository에서 기본적인 CRUD 메서드를 제공합니다 개꿀입니다
// findAll(), findById(), save(), deleteById() 등
}
3. Service (PostService.java) 정의하기
- 비즈니스 로직을 처리하는 계층
- Repository를 통해 데이터베이스 작업 수행
- @Transactional 어노테이션으로 트랜잭션 관리
- 데이터 검증, 변환 등의 작업 수행
package com.example.FinanceNewsProvider.service;
import com.example.FinanceNewsProvider.entity.Post;
import com.example.FinanceNewsProvider.repository.PostRepository;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
@Transactional
public Post createdPost(Post post){
post.setCreatedAt(LocalDateTime.now());
return postRepository.save(post);
}
@Transactional(readOnly=true)
public List<Post> getAllPosts(){
return postRepository.findAll();
}
@Transactional(readOnly=true)
public Post getPostById(Integer id){
return postRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Post not found"));
}
@Transactional
public Post updatePost(Integer id, Post postDetails){
Post post = getPostById(id);
post.setTitle(postDetails.getTitle());
post.setContent(postDetails.getContent());
post.setCreatedAt(LocalDateTime.now());
return postRepository.save(post);
}
@Transactional
public void deletePost(Integer id){
postRepository.deleteById(id);
}
}
4. Controller (PostController.java) 정의하기
- 클라이언트의 HTTP 요청을 처리하는 계층
- URL 매핑을 통해 적절한 엔드포인트 제공
- 요청 데이터를 검증하고 Service 계층으로 전달
- 응답 데이터를 클라이언트에게 반환
package com.example.FinanceNewsProvider.controller;
import com.example.FinanceNewsProvider.entity.Post;
import com.example.FinanceNewsProvider.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController // REST API 컨트롤러임을 나타냄 --> 차후 글 작성
@RequestMapping("/api/posts") // 기본 URL 경로 설정
@RequiredArgsConstructor // final 필드에 대한 생성자 자동 생성
@CrossOrigin(origins = "http://localhost:3000") // React 앱과의 CORS 설정
public class PostController {
private final PostService postService;
// 게시글 생성 기능
@PostMapping
public ResponseEntity<Post> createpost(@RequestBody Post post) {
Post createdPost = postService.createdPost(post);
return new ResponseEntity<>(createdPost, HttpStatus.CREATED);
}
// 모든 게시글 조회 기능
@GetMapping
public ResponseEntity<List<Post>> getAllPosts() {
List<Post> posts = postService.getAllPosts();
return ResponseEntity.ok(posts);
}
// 특정 게시글 조회 기능
@GetMapping("/{id}")
public ResponseEntity<Post> getPostById(@PathVariable Integer id){
Post post = postService.getPostById(id);
return ResponseEntity.ok(post);
}
// 게시글 수정 기능
@PutMapping("/{id}")
public ResponseEntity<Post> updatePost(@PathVariable Integer id,
@RequestBody Post post) {
Post updatedPost = postService.updatePost(id, post);
return ResponseEntity.ok(updatedPost);
}
// 게시글 삭제 기능
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Integer id) {
postService.deletePost(id);
return ResponseEntity.noContent().build();
}
}
이렇게 네 가지 파일이 준비되면 기본적인 골조는 완성된겁니다. CRUD 기능을 위해 백엔드에서 필요한 model, repository, service, controller을 생성했으니 이제 세부 설정을 해야겠죠
application.properties에 몇가지 정보를 추가해주면 됩니다.
spring.application.name=FinanceNewsProvider
# 서버 포트를 설정합니다 = 서버가 실행될 포트 번호를 8081로 설정
server.port = 8081
# MariaDB 정보를 설정합니다
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
# MariaDB 데이터베이스 드라이버 클래스 이름
spring.datasource.url = jdbc:mariadb://localhost:3306/vacation_ktor
# MariaDB 데이터베이스 URL (localhost의 3306 포트를 사용, 데이터베이스 이름은 vacation_ktor)
spring.datasource.username = m1
# MariaDB에 접속할 사용자 이름
spring.datasource.password = 123456
# MariaDB에 접속할 사용자 비밀번호
# JPA 설정을 합니다 --> 차후 글 작성
spring.jpa.hibernate.ddl-auto=validate
# Hibernate가 데이터베이스 스키마를 자동으로 관리하는 방식. 'validate'는 기존 스키마를 검증만 하고 수정하지 않음
spring.jpa.show-sql=true
# JPA 실행 시 SQL 쿼리를 콘솔에 출력하도록 설정
spring.jpa.properties.hibernate.format_sql=true
# 출력되는 SQL 쿼리를 보기 좋게 포맷팅해서 출력
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
# Hibernate에 사용될 MariaDB의 SQL 방언(dialect)을 설정
# Jackson JSON 설정 (날짜 형식) --> 차후 글 작성
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
# JSON 날짜를 출력할 형식 설정 (예: 2023-04-01 14:30:00)
spring.jackson.time-zone=Asia/Seoul
# JSON 날짜에 사용할 시간대 설정 (Asia/Seoul로 설정하여 대구 사람이지만 서울 시간대로 출력)
기본설정들을 위 처럼 마쳐주면, 이제 CRUD 기능을 사용할 수 있을까요? 우선 그 전에 build.gradle 파일에서 필요한 dependencies 들이 추가되었는지 확인해야합니다. 저는 아래처럼 되어 있네요.
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.mariadb.jdbc:mariadb-java-client:3.1.4'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
자 이제 그럼 실행 될까요? 아쉽게도 아직 프론트를 만지지 않았죠. 글 작성이고 뭐고 다 해당 form이 있어야 백엔드가 처리할 수 있으므로 만들어야 합니다. 그냥 이대로 실행하고 제 port인 http://localhost:8081로 들어간다면?
이런 화면이 나타나는데, 설정을 피해가기 위해 간단히 설정을 해줄까요.
resources-static에 간단한 index.html을 우선 생성해주고(Spring Security 에러 우회 확인용),
<!DOCTYPE html>
<html>
<head>
<title>Finance News Provider</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<h1>Welcome to Finance News Provider</h1>
<div id="root"></div>
<script src="/js/main.js"></script>
</body>
</html>
build.gradle에 dependecies를 추가해줘야 합니다
implementation 'org.springframework.boot:spring-boot-starter-security'
그리고 프로젝트 루트(com.example.프로젝트명)에서 config 폴더를 하나 만들고 거기 SecurityConfig.java를 만들어 줍니다
// src/main/java/com/example/FinanceNewsProvider/config/SecurityConfig.java
package com.example.FinanceNewsProvider.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화
.authorizeHttpRequests(auth -> auth
.requestMatchers("/**").permitAll() // 모든 요청 허용
);
return http.build();
}
}
이렇게 하고 실행해보면
아까와는 달리 모든 권한을 허용하고, 로그인 페이지가 나타나지 않으며 DB로의 접근 또한 기존에 설정한 username, password로 접근해서 잘 보여집니다
자 이제 front를 꾸미기 위해 React 프로젝트를 생성해주죠
기존 루트에서 아래 코드를 작성해주시면 되는데요.
각 line에 대해 설명하자면
-
- npx create-react-app frontend: React 애플리케이션을 frontend 디렉토리에 생성합니다.
- cd frontend: frontend 디렉토리로 이동합니다.
- npm install axios @mui/material @emotion/react @emotion/styled @mui/icons-material: React 애플리케이션에 axios와 Material UI 관련 라이브러리를 설치합니다.
확인해볼까요, ㅋㅋ
Front의 각 구조는 아래처럼 생성할 생각입니다.
저는 Java + SpringBoot 기반의 프로젝트 경험이 거의 없어서 여기서 Kotlin과의 차이점이구나! 착각하고 알게된 부분이 있습니다.
Kotlin의 frontEnd는 main - Resources에 생성하는게 일반적이어서 그렇게 개발하고 있었는데
Java + Spring + React 프로젝트를 개발하는데 frontEnd를 그냥 프로젝트 폴더에 생성하는 예제가 많았습니다. 물론 Java + Spring + React도 resources에 frontEnd 를 구현해도 되지만
- Ktor + React:
- 서버 중심 애플리케이션이나 소규모 프로젝트에 적합.
- 배포 간소화: 서버와 프론트를 함께 빌드하고 배포할 수 있어 편리.
- Spring + React:
- 대규모 프로젝트나 팀 개발에서 많이 사용.
- 프론트와 백엔드를 완전히 독립적으로 관리하고 개발할 수 있음.
- React 개발 서버를 별도로 활용하면 Hot Reloading 및 API Proxy 설정이 쉬워 프론트엔드 개발이 편
이렇게 규모와 효율에 따라서 다르게 프로젝트 틀을 고민해 결정하는것이 좋겠구나 생각했습니다 물론 정해진건 없습니다. 아무튼 각설하고 넘어가쥬.
지금 당장 사용하지 않는 파일들은 좀 삭제해주고(App.test.js - 단위테스트 시 사용하는 파일, setupTests.js - 테스트 환경설정 파일, reportWebVitals.js - 웹 성능측정 파일) src 폴더에 components 폴더를 생성했습니다. 그리고 내부에 Post 정보를 가져와 띄워줄 PostForm.js 와 PostList.js를 생성했습니다.
<PostForm.js> : PostList에서 글 작성을 위해 버튼을 누르면 나오게 되는 모달, 정보를 기입해서 DB로 보낸다
import React, { useState } from 'react'; // React와 useState 훅 import
// Material-UI 컴포넌트들 import
import {
Button, // 버튼
TextField, // 입력 필드
Dialog, // 모달 창
DialogTitle, // 모달 제목
DialogContent, // 모달 내용
DialogActions, // 모달 하단 버튼 영역
Box // 레이아웃 컴포넌트
} from '@mui/material';
// props로 받는 값들:
// open: 모달 창 열림/닫힘 상태
// handleClose: 모달 닫기 함수
// handleSubmit: 폼 제출 처리 함수
// initialData: 수정 시 기존 게시글 데이터
const PostForm = ({ open, handleClose, handleSubmit, initialData }) => {
// useState로 폼 데이터 상태 관리
// initialData가 있으면(수정 시) 그 값을 사용, 없으면(새글 작성 시) 빈 값으로 초기화
const [post, setPost] = useState(initialData || {
title: '',
content: '',
author: ''
});
// 폼 제출 처리 함수
const onSubmit = (e) => {
e.preventDefault(); // 기본 폼 제출 동작 방지
handleSubmit(post); // 상위 컴포넌트의 제출 함수 호출
handleClose(); // 모달 창 닫기
};
return (
// Dialog: 모달 창 컴포넌트
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
{initialData ? '게시글 수정' : '새 게시글 작성'}
</DialogTitle>
<DialogContent>
{/* 폼 컴포넌트 */}
<Box component="form" onSubmit={onSubmit} sx={{ mt: 2 }}>
{/* 제목 입력 필드 */}
<TextField
fullWidth
label="제목"
margin="normal"
value={post.title}
onChange={(e) => setPost({...post, title: e.target.value})}
required
/>
{/* 내용 입력 필드 */}
<TextField
fullWidth
label="내용"
margin="normal"
multiline // 여러 줄 입력 가능
rows={4} // 4줄 높이
value={post.content}
onChange={(e) => setPost({...post, content: e.target.value})}
required
/>
{/* 작성자 입력 필드 */}
<TextField
fullWidth
label="작성자"
margin="normal"
value={post.author}
onChange={(e) => setPost({...post, author: e.target.value})}
required
/>
</Box>
</DialogContent>
{/* 버튼 영역 */}
<DialogActions>
<Button onClick={handleClose}>취소</Button>
<Button onClick={onSubmit} variant="contained" color="primary">
{initialData ? '수정하기' : '작성하기'}
</Button>
</DialogActions>
</Dialog>
);
};
export default PostForm;
<PostList.js> : 글 작성 목록을 확인, 삭제 및 수정을 할 수 있다
import React, { useState, useEffect } from 'react'; // React와 훅들 import
// Material-UI 컴포넌트들 import
import {
Box,
Button,
Paper, // 카드 같은 elevation이 있는 컨테이너
List, // 목록 컴포넌트
ListItem, // 목록 항목
ListItemText, // 목록 항목 텍스트
Typography, // 텍스트 스타일링
IconButton, // 아이콘 버튼
} from '@mui/material';
// Material-UI 아이콘들 import
import { Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import axios from 'axios'; // HTTP 요청 라이브러리
import PostForm from './PostForm'; // 게시글 작성/수정 폼
const PostList = () => {
// 상태 관리
const [posts, setPosts] = useState([]); // 게시글 목록
const [openForm, setOpenForm] = useState(false); // 폼 모달 열림/닫힘
const [selectedPost, setSelectedPost] = useState(null); // 선택된 게시글(수정 시)
// 컴포넌트 마운트 시 게시글 목록 가져오기
useEffect(() => {
fetchPosts();
}, []);
// 게시글 목록 가져오기 함수
const fetchPosts = async () => {
try {
const response = await axios.get('/api/posts');
setPosts(response.data);
} catch (error) {
console.error('Failed to fetch posts:', error);
}
};
// 게시글 저장/수정 처리 함수
const handleSubmit = async (postData) => {
try {
if (selectedPost) { // 수정
await axios.put(`/api/posts/${selectedPost.id}`, postData);
} else { // 새글 작성
await axios.post('/api/posts', postData);
}
fetchPosts(); // 목록 새로고침
setSelectedPost(null); // 선택된 게시글 초기화
} catch (error) {
console.error('Failed to save post:', error);
}
};
// 게시글 삭제 처리 함수
const handleDelete = async (id) => {
try {
await axios.delete(`/api/posts/${id}`);
fetchPosts(); // 목록 새로고침
} catch (error) {
console.error('Failed to delete post:', error);
}
};
return (
<Box>
{/* 새 게시글 작성 버튼 */}
<Button
variant="contained"
color="primary"
onClick={() => setOpenForm(true)}
sx={{ mb: 2 }}
>
새 게시글 작성
</Button>
{/* 게시글 목록 */}
<Paper elevation={3}>
<List>
{/* 각 게시글을 순회하며 표시 */}
{posts.map((post) => (
<ListItem
key={post.id}
divider
secondaryAction={ // 우측에 표시될 버튼들
<Box>
{/* 수정 버튼 */}
<IconButton onClick={() => {
setSelectedPost(post);
setOpenForm(true);
}}>
<EditIcon />
</IconButton>
{/* 삭제 버튼 */}
<IconButton onClick={() => handleDelete(post.id)}>
<DeleteIcon />
</IconButton>
</Box>
}
>
{/* 게시글 내용 */}
<ListItemText
primary={post.title} // 제목
secondary={ // 내용과 메타 정보
<>
<Typography component="span" variant="body2">
{post.content}
</Typography>
<br />
<Typography component="span" variant="caption">
작성자: {post.author} | 작성일: {new Date(post.createdAt).toLocaleString()}
</Typography>
</>
}
/>
</ListItem>
))}
</List>
</Paper>
{/* 게시글 작성/수정 폼 모달 */}
<PostForm
open={openForm}
handleClose={() => {
setOpenForm(false);
setSelectedPost(null);
}}
handleSubmit={handleSubmit}
initialData={selectedPost}
/>
</Box>
);
};
export default PostList;
이렇게 해줍니다.
그리고 기존에 있던 App.js와 index.js를 요로코롬 바꿔주면
<App.js>
// 메인 컴포넌트 파일 App.js
import React from 'react';
// Material-UI 테마 관련 import
import { ThemeProvider, createTheme } from '@mui/material/styles';
// Material-UI 컴포넌트들 import
import {
Container,
CssBaseline, // CSS 초기화
AppBar, // 상단 네비게이션 바
Toolbar, // AppBar 내부 컨텐츠 영역
Typography, // 텍스트 표시 컴포넌트
Box // div와 비슷한 레이아웃 컴포넌트
} from '@mui/material';
import PostList from './components/PostList'; // 게시글 목록 컴포넌트
// Material-UI 기본 테마 생성
const theme = createTheme();
function App() {
return (
// ThemeProvider로 테마 적용
<ThemeProvider theme={theme}>
{/* CssBaseline: CSS 초기화 */}
<CssBaseline />
{/* Box: 전체 레이아웃을 감싸는 컨테이너 */}
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
{/* AppBar: 상단 네비게이션 바 */}
<AppBar position="static">
<Toolbar>
{/* Typography: 텍스트 스타일링 */}
<Typography variant="h6">
게시판
</Typography>
</Toolbar>
</AppBar>
{/* Container: 내용을 중앙에 배치하는 컨테이너 */}
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<PostList /> {/* 게시글 목록 표시 */}
</Container>
</Box>
</ThemeProvider>
);
}
export default App; // 다른 파일에서 import할 수 있도록 내보내기
<index. js>
import React from 'react'; // React 라이브러리 import
import { createRoot } from 'react-dom/client'; // React 18의 새로운 렌더링 방식
import './index.css'; // 전역 스타일
import App from './App'; // 최상위 컴포넌트
// DOM에서 'root' id를 가진 요소를 찾아 React 앱을 렌더링
const root = createRoot(document.getElementById('root'));
// React.StrictMode는 개발 모드에서 잠재적인 문제를 찾아주는 도구
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
기본 설정은 끝난겁니다.
그리고 package.json에 가서 proxy 설정을 해주어야 하는데요. 저는 spring 포트가 8081을 사용하고 있기에
package.json 가장 아래 단의 괄호 닫히기 전
"proxy": "http://localhost:8081"
을 추가해줬습니다. 이렇게 하고 spring 서버를 킨 후 terminal로 frontEnd로 가 npm start를 해주게 되면
이렇게 잘 나타나는 것을 확인할 수 있습니다.
이제 주식정보 검색 & 뉴스정보 매칭 기능이 남았네요. 다음 글에서 계속 하겠습니다.