원문글은 okta blog 에서 Matt Raible 이 작성한 내용을 가져와서 따라 할 수 있도록 작성된 문서 입니다.
원본 문서는 하기 링크를 참조하시기 바랍니다.
https://developer.okta.com/blog/2018/07/19/simple-crud-react-and-spring-boot
1. Spring Boot 2.0 API App 생성하기
https://start.spring.io 사이트를 통한 App 생성하기
ㅏㄴㅣㅏㄴㅏ
- Group: com.okta.developer
- Artifact: jugtours
- Dependencies: JPA, H2, Web, Lombok
2. Spring-Boot Back-end Rest API 설정하기
Group.java, Event.java, User.java GroupRepository.java Class를
src/main/java/com/okta/developer/jugtours/model 디렉토리 생성
1. Group.java
package com.okta.developer.jugtours.model;
import lombok.Data; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.RequiredArgsConstructor;
import javax.persistence.*; import java.util.Set;
@Data @NoArgsConstructor @RequiredArgsConstructor @Entity @Table(name = "user_group") public class Group {
@Id @GeneratedValue private Long id; @NonNull private String name; private String address; private String city; private String stateOrProvince; private String country; private String postalCode; @ManyToOne(cascade=CascadeType.PERSIST) private User user;
@OneToMany(fetch = FetchType.EAGER, cascade=CascadeType.ALL) private Set<Event> events; } |
2. Event.java
package com.okta.developer.jugtours.model;
import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor;
import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.ManyToMany; import java.time.Instant; import java.util.Set;
@Data @NoArgsConstructor @AllArgsConstructor @Builder @Entity public class Event {
@Id @GeneratedValue private Long id; private Instant date; private String title; private String description; @ManyToMany private Set<User> attendees; } |
3. User.java
package com.okta.developer.jugtours.model;
import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor;
import javax.persistence.Entity; import javax.persistence.Id;
@Data @NoArgsConstructor @AllArgsConstructor @Entity public class User {
@Id private String id; private String name; private String email; } |
4. GroupRepository.java
package com.okta.developer.jugtours.model;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface GroupRepository extends JpaRepository<Group, Long> { Group findByName(String name); } |
Main 함수가 있는 com.okta.developer.jugtours 에 Initializer.java파일 생성
5. Initializer.java
import com.okta.developer.jugtours.model.Event; import com.okta.developer.jugtours.model.Group; import com.okta.developer.jugtours.model.GroupRepository; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component;
import java.time.Instant; import java.util.Collections; import java.util.stream.Stream;
@Component class Initializer implements CommandLineRunner {
private final GroupRepository repository;
public Initializer(GroupRepository repository) { this.repository = repository; }
@Override public void run(String... strings) { Stream.of("Denver JUG", "Utah JUG", "Seattle JUG", "Richmond JUG").forEach(name -> repository.save(new Group(name)) );
Group djug = repository.findByName("Denver JUG"); Event e = Event.builder().title("Full Stack Reactive") .description("Reactive with Spring Boot + React") .date(Instant.parse("2018-12-12T18:00:00.000Z")) .build(); djug.setEvents(Collections.singleton(e)); repository.save(djug);
repository.findAll().forEach(System.out::println); } } |
터미널에서 spring-boot를 수행해봄
--> mvnw spring-boot:run 혹은 mvn spring-boot:run 환경에 따라 다름
대략 다음과 같은 결과가 console창에 나옴
Group(id=1, name=Denver JUG, address=null, city=null, stateOrProvince=null, country=null, postalCode=null, user=null, events=[Event(id=5, date=2018-12-12T18:00:00Z, title=Full Stack Reactive, description=Reactive with Spring Boot + React, attendees=[])]) Group(id=2, name=Utah JUG, address=null, city=null, stateOrProvince=null, country=null, postalCode=null, user=null, events=[]) Group(id=3, name=Seattle JUG, address=null, city=null, stateOrProvince=null, country=null, postalCode=null, user=null, events=[]) Group(id=4, name=Richmond JUG, address=null, city=null, stateOrProvince=null, country=null, postalCode=null, user=null, events=[]) |
다시 돌아가서 com.okta.developer.jugtours.web 폴더(혹은 패키지)를 만들고.. Web application을 사용하기 위해서
GroupController.java class를 추가해줌
6. GroupController.java 가장 중요한 것은 local에서 두개의 서버(springboot, react)를 동작시키기 위해서는
@CrossOrigin(origins = {"http://localhost:3000"}) 와 같은 CrossOrigin이 필요합니다.
package com.okta.developer.jugtours.web;
import com.okta.developer.jugtours.model.Group; import com.okta.developer.jugtours.model.GroupRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*;
import javax.validation.Valid; import java.net.URI; import java.net.URISyntaxException; import java.util.Collection; import java.util.Optional;
@RestController @RequestMapping("/api") @CrossOrigin(origins = {"http://localhost:3000"})
class GroupController {
private final Logger log = LoggerFactory.getLogger(GroupController.class); private GroupRepository groupRepository;
public GroupController(GroupRepository groupRepository) { this.groupRepository = groupRepository; }
@GetMapping("/groups") Collection<Group> groups() { return groupRepository.findAll(); }
@GetMapping("/group/{id}") ResponseEntity<?> getGroup(@PathVariable Long id) { Optional<Group> group = groupRepository.findById(id); return group.map(response -> ResponseEntity.ok().body(response)) .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); }
@PostMapping("/group") ResponseEntity<Group> createGroup(@Valid @RequestBody Group group) throws URISyntaxException { log.info("Request to create group: {}", group); Group result = groupRepository.save(group); return ResponseEntity.created(new URI("/api/group/" + result.getId())) .body(result); }
@PutMapping("/group") ResponseEntity<Group> updateGroup(@Valid @RequestBody Group group) { log.info("Request to update group: {}", group); Group result = groupRepository.save(group); return ResponseEntity.ok().body(result); }
@DeleteMapping("/group/{id}") public ResponseEntity<?> deleteGroup(@PathVariable Long id) { log.info("Request to delete group: {}", id); groupRepository.deleteById(id); return ResponseEntity.ok().build(); } } |
3. React Front-end 설정하기
1. React App 생성하기
yarn 기준으로 되어 있어서.. 미리 설정확인 필요
project 생성
yarn create react-app app |
추가로 Bootstrap, cookie support for React, React Router, and Reactstrap 설치
버전 정보는 빼고 사용하셔도 됩니다
Bootstrap의 css파일을 import하기 위해서 app/src/index.js 파일의 상위에 bootstrap을 import 해줘야 함
app/src/index.js
cd app yarn add bootstrap@4.1.3 react-cookie@3.0.4 react-router-dom@4.3.1 reactstrap@6.5.0 |
import 'bootstrap/dist/css/bootstrap.min.css'; |
2. React를 통한 Spring-Boot API 호출하기
/api/group를 호출하는 Api 작성하기
app/src/App.js 수정
import React, { Component } from 'react'; import logo from './logo.svg'; import './App.css';
class App extends Component { state = { isLoading: true, groups: [] };
async componentDidMount() { const response = await fetch('/api/groups'); const body = await response.json(); this.setState({ groups: body, isLoading: false }); }
render() { const {groups, isLoading} = this.state;
if (isLoading) { return <p>Loading...</p>; }
return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <div className="App-intro"> <h2>JUG List</h2> {groups.map(group => <div key={group.id}> {group.name} </div> )} </div> </header> </div> ); } }
export default App; |
/api를 http://localhost:8080/api를 사용하기 위해서 app/package.json 수정
"scripts": {...}, "proxy": "http://localhost:8080", |
yarn start와 같은 방법으로 react app을 수행하면 다음과 같은 결과가 나옴
3. React GroupList Component 생성
app/src/GroupList.js
import React, { Component } from 'react'; import { Button, ButtonGroup, Container, Table } from 'reactstrap'; import AppNavbar from './AppNavbar'; import { Link } from 'react-router-dom';
class GroupList extends Component {
constructor(props) { super(props); this.state = {groups: [], isLoading: true}; this.remove = this.remove.bind(this); }
componentDidMount() { this.setState({isLoading: true});
fetch('api/groups') .then(response => response.json()) .then(data => this.setState({groups: data, isLoading: false})); }
async remove(id) { await fetch(`/api/group/${id}`, { method: 'DELETE', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }).then(() => { let updatedGroups = [...this.state.groups].filter(i => i.id !== id); this.setState({groups: updatedGroups}); }); }
render() { const {groups, isLoading} = this.state;
if (isLoading) { return <p>Loading...</p>; }
const groupList = groups.map(group => { const address = `${group.address || ''} ${group.city || ''} ${group.stateOrProvince || ''}`; return <tr key={group.id}> <td style={{whiteSpace: 'nowrap'}}>{group.name}</td> <td>{address}</td> <td>{group.events.map(event => { return <div key={event.id}>{new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: '2-digit' }).format(new Date(event.date))}: {event.title}</div> })}</td> <td> <ButtonGroup> <Button size="sm" color="primary" tag={Link} to={"/groups/" + group.id}>Edit</Button> <Button size="sm" color="danger" onClick={() => this.remove(group.id)}>Delete</Button> </ButtonGroup> </td> </tr> });
return ( <div> <AppNavbar/> <Container fluid> <div className="float-right"> <Button color="success" tag={Link} to="/groups/new">Add Group</Button> </div> <h3>My JUG Tour</h3> <Table className="mt-4"> <thead> <tr> <th width="20%">Name</th> <th width="20%">Location</th> <th>Events</th> <th width="10%">Actions</th> </tr> </thead> <tbody> {groupList} </tbody> </Table> </Container> </div> ); } }
export default GroupList; |
Component 간의 common UI
AppNavbar.js 생성
import React, { Component } from 'react'; import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap'; import { Link } from 'react-router-dom';
export default class AppNavbar extends Component { constructor(props) { super(props); this.state = {isOpen: false}; this.toggle = this.toggle.bind(this); }
toggle() { this.setState({ isOpen: !this.state.isOpen }); }
render() { return <Navbar color="dark" dark expand="md"> <NavbarBrand tag={Link} to="/">Home</NavbarBrand> <NavbarToggler onClick={this.toggle}/> <Collapse isOpen={this.state.isOpen} navbar> <Nav className="ml-auto" navbar> <NavItem> <NavLink href="https://twitter.com/oktadev">@oktadev</NavLink> </NavItem> <NavItem> <NavLink href="https://github.com/oktadeveloper/okta-spring-boot-react-crud-example">GitHub</NavLink> </NavItem> </Nav> </Collapse> </Navbar>; } } |
app/src/Home.js
import React, { Component } from 'react'; import './App.css'; import AppNavbar from './AppNavbar'; import { Link } from 'react-router-dom'; import { Button, Container } from 'reactstrap';
class Home extends Component { render() { return ( <div> <AppNavbar/> <Container fluid> <Button color="link"><Link to="/groups">Manage JUG Tour</Link></Button> </Container> </div> ); } }
export default Home; |
마지막으로 메인화면인 app/src/App.js 파일 수정
import React, { Component } from 'react'; import './App.css'; import Home from './Home'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import GroupList from './GroupList';
class App extends Component { render() { return ( <Router> <Switch> <Route path='/' exact={true} component={Home}/> <Route path='/groups' exact={true} component={GroupList}/> </Switch> </Router> ) } }
export default App; |
app/arc/App.css 수정
.container, .container-fluid { margin-top: 20px; }
|
http://localhost:300으로 가면 다음과 같은 화면을 볼수 있음
Manage JUG Tour 클릭
4. React GroupEdit Component 생성
app/src/GroupEdit.js 를 생성 URL로 부터 ID를 사용하여 componentDidMount()안에 fetch를 사용하여 API 호출
app/src/GroupEdit.js
import React, { Component } from 'react'; import { Link, withRouter } from 'react-router-dom'; import { Button, Container, Form, FormGroup, Input, Label } from 'reactstrap'; import AppNavbar from './AppNavbar';
class GroupEdit extends Component {
emptyItem = { name: '', address: '', city: '', stateOrProvince: '', country: '', postalCode: '' };
constructor(props) { super(props); this.state = { item: this.emptyItem }; this.handleChange = this.handleChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); }
async componentDidMount() { if (this.props.match.params.id !== 'new') { const group = await (await fetch(`/api/group/${this.props.match.params.id}`)).json(); this.setState({item: group}); } }
handleChange(event) { const target = event.target; const value = target.value; const name = target.name; let item = {...this.state.item}; item[name] = value; this.setState({item}); }
async handleSubmit(event) { event.preventDefault(); const {item} = this.state;
await fetch('/api/group', { method: (item.id) ? 'PUT' : 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(item), }); this.props.history.push('/groups'); }
render() { const {item} = this.state; const title = <h2>{item.id ? 'Edit Group' : 'Add Group'}</h2>;
return <div> <AppNavbar/> <Container> {title} <Form onSubmit={this.handleSubmit}> <FormGroup> <Label for="name">Name</Label> <Input type="text" name="name" id="name" value={item.name || ''} onChange={this.handleChange} autoComplete="name"/> </FormGroup> <FormGroup> <Label for="address">Address</Label> <Input type="text" name="address" id="address" value={item.address || ''} onChange={this.handleChange} autoComplete="address-level1"/> </FormGroup> <FormGroup> <Label for="city">City</Label> <Input type="text" name="city" id="city" value={item.city || ''} onChange={this.handleChange} autoComplete="address-level1"/> </FormGroup> <div className="row"> <FormGroup className="col-md-4 mb-3"> <Label for="stateOrProvince">State/Province</Label> <Input type="text" name="stateOrProvince" id="stateOrProvince" value={item.stateOrProvince || ''} onChange={this.handleChange} autoComplete="address-level1"/> </FormGroup> <FormGroup className="col-md-5 mb-3"> <Label for="country">Country</Label> <Input type="text" name="country" id="country" value={item.country || ''} onChange={this.handleChange} autoComplete="address-level1"/> </FormGroup> <FormGroup className="col-md-3 mb-3"> <Label for="country">Postal Code</Label> <Input type="text" name="postalCode" id="postalCode" value={item.postalCode || ''} onChange={this.handleChange} autoComplete="address-level1"/> </FormGroup> </div> <FormGroup> <Button color="primary" type="submit">Save</Button>{' '} <Button color="secondary" tag={Link} to="/groups">Cancel</Button> </FormGroup> </Form> </Container> </div> } }
export default withRouter(GroupEdit); |
app/src/App.js 파일에 GroupEdit를 추가하고 component를 path를 추가해줌
import GroupEdit from './GroupEdit';
class App extends Component { render() { return ( <Router> <Switch> ... <Route path='/groups/:id' component={GroupEdit}/> </Switch> </Router> ) } } |
결과
인증관련 부분은 테스트 해보지 않아서 올리지 않았습니다.
해보고 싶으신분은 상위화면의 링크 확인해주시면 됩니다.