📃 요약
네이버 카페 등의 게시판을 기본 기능을 제작하는 예제입니다.
기본적인 DB CRUD 를 사용해 제작합니다.
DB 프레임워크는 JPA 를 사용해 자동화기능을 강화합니다.
뷰(Vue) & 스프링부트 연동 기초 예제
axios CRUD 함수들과 스프링부트의 컨트롤러 함수들과 네트웍으로 연결됨
이때 컨트롤러는 @RestController 어노테이션을 사용해야함Vue : axios 라이브러리의 get(), post(), put(), delete() 함수 사용
스프링부트 – @RestController 어노테이션 사용해서 컨트롤러 클래스 생성
CRUD : @GetMapping, @PostMapping, @PutMapping, @DeleteMapping
나머지 모델, 레포지토리, 서비스는 JSP 와 동일함성능 향상 :
1) Redis 를 이용해 캐싱 DB를 사용해 봄 :
2) 주로 쇼핑몰에서 이벤트성 기획 페이지에 순간 사용자가 몰릴것을 대비해 사용
3) 일반적인 게시판에서는 레디스 DB 를 적용하지 않지만 실습을 위해 부서 게시판의 조회와 수정기능에 추가해보도록 함
요소 기술 및 테이블 설계는 아래와 같습니다.
요소 기술 :
– 프론트엔드 : Vue
– 벡엔드 : 스프링부트 & JPA & Oracle 18xe(Oracle Cloud 19c) & Redis 메모리 DB
결과 화면 :
프로젝트 탐색기 : Vue
프로젝트 탐색기 : String Boot
Rest API :
메소드 | URL | 설명 |
---|---|---|
GET | dept | 전체 조회 |
GET | dept/{dno} | 상세조회 |
POST | /dept | 저장 |
PUT | /dept/{dno} | 수정 |
DELETE | /dept/deletion/{dno} | 삭제 |
📃 기술 구현
스펙 :
- jdk 17 - spring boot 3.x - gradle
테이블 설계
-- 부서 게시판 DROP SEQUENCE SQ_DEPT; CREATE SEQUENCE SQ_DEPT START WITH 50 INCREMENT BY 10; CREATE TABLE TB_DEPT ( DNO NUMBER NOT NULL PRIMARY KEY, DNAME VARCHAR2(255), LOC VARCHAR2(255), INSERT_TIME VARCHAR2(255), UPDATE_TIME VARCHAR2(255) );
부서 게시판 구현을 위한 테이블 설계입니다.
데이터베이스를 오라클을 사용하여 구현해 보겠습니다.
Redis DB 설치 :
윈도우
1) Redis 설치 프로그램 다운로드 : https://github.com/microsoftarchive/redis/releases
2) Redis-x64-3.0.504.msi 다운로드 기본설치
3) Redis 설치 확인 : 설치를 정상적으로 마치면 윈도우 작업 관리자의 서비스 탭에서 실행 중인 것을 확인
4) 설치한 경로에 redis-cli.exe를 실행하여 redis를 사용
5) ping 입력 -> pong 뜨면 정상 설치된 것임맥
1) homebrew 설치된 상태에서 진행
2) // Homebrew(Mac OS용 패키지 관리자) 설치 여부 확인
brew –version
3) // redis 설치
brew install redis
4) // redis 설치 확인
redis-server –version
5) // redis foreground로 실행
redis-server
6) // redis-cli 사용
redis-cli
Redis 기본 명령어 : 터미널(명령 프롬프트) 명령
서버 가동 & 중지 & 접속 명령어
# 레디스 서버 실행 Redis-server # 레디스 실행 확인 redis-cli ping pong (응답) # 레디스 서버 중지 redis-cli shutdown # localhost:6379접속 redis-cli # 정보보기 reids-cli info # 원격접속 redis-cli -h #{호스트명} -p #{포트번호}
CRUD 명령어
127.0.0.1:6379> keys * (empty list or set) # set key / value 형태로 저장하기 127.0.0.1:6379>set k_one "one" OK 127.0.0.1:6379>keys * 1) "k_one" # mset 여러개의 key / value 형태로 저장하기 127.0.0.1:6379> mset k_two "two" k_tree "tree" OK 127.0.0.1:6379> keys * 1) "k_tree" 2) "k_one" 3) "k_two" # setex 소멸시간 지정해서 저장하기 127.0.0.1:6379> setex k_four 10 "four" OK #mget 여러개의 key를 조회하기 127.0.0.1:6379> mget k_one k_two 1) "one" 2) "two" # del 해당 key와 value을 삭제하기 127.0.0.1:6379> del k_tree (integer) 1 127.0.0.1:6379> keys * 1) "k_one" 2) "k_two" # keys *검색어* key 검색하기 127.0.0.1:6379> keys *k* 1) "k_one" 2) "k_two" #rename key의 이름을 변경하기 rename 기존key 변경할key 127.0.0.1:6379> rename k_one one OK 127.0.0.1:6379> keys * 1) "one" 2) "k_two" # flushall 모든 데이터(key와 value)를 삭제 127.0.0.1:6379> flushall OK
Redis 메모리 DB 설정 :
build.gradle 라이브러리 수동 설치 : 레디스 DB 라이브러리 설치
... // todo: Redis : cache server implementation 'org.springframework.boot:spring-boot-starter-data-redis' ...
설정(application.properties) : 레디스 DB ip & port 추가
# redis server 설정 : redis-server 실행(터미널에 실행해야 스프링 정상 실행됨) redis.host=localhost redis.port=6379
설정(Config) 자바 파일 : 레디스 기본 설정하기
RedisConfig.java
package com.example.simpledms.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; import java.time.Duration; /** * packageName : com.example.simpledms.config * fileName : RedisConfig * author : kangtaegyung * date : 2022/06/14 * description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2022/06/14 kangtaegyung 최초 생성 */ @Configuration public class RedisConfig { // Redis IP @Value("${redis.host}") private String redisHost; // Redis Port Number @Value("${redis.port}") private int redisPort; // 레디스 연결 @Bean public LettuceConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(redisHost, redisPort); return new LettuceConnectionFactory(configuration); } // 레디스 관리 : 1) 생성 dept : ttl - 1 minute // @Bean public RedisCacheManager cacheManager() { // Redis 캐시 설정 함수를 호출해서 래디스 설정 RedisCacheConfiguration cacheConfig = myDefaultCacheConfig(Duration.ofMinutes(10)) .disableCachingNullValues(); // 캐싱할 때 null 값을 허용하지 않음 return RedisCacheManager.builder(redisConnectionFactory()) .cacheDefaults(cacheConfig) .withCacheConfiguration("depts", myDefaultCacheConfig(Duration.ofMinutes(5))) // depts 캐쉬 생성 .withCacheConfiguration("dept", myDefaultCacheConfig(Duration.ofMinutes(3))) // dept 캐쉬 생성 .withCacheConfiguration("emps", myDefaultCacheConfig(Duration.ofMinutes(5))) // emps 캐쉬 생성 .withCacheConfiguration("emp", myDefaultCacheConfig(Duration.ofMinutes(3))) // emp 캐쉬 생성 .build(); } // Redis 캐시 설정 함수 : 1) 설정 default config 2) 설정 ttl 3) 설정 serialize private RedisCacheConfiguration myDefaultCacheConfig(Duration duration) { return RedisCacheConfiguration .defaultCacheConfig() .entryTtl(duration) // ttl(만료시간) 설정 .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); // Value 를 직렬화할 때 사용하는 규칙. Jackson2 를 많이 사용함 } }
- redisConnectionFactory() : 레디스 DB 연결 (IP & port)
- cacheManager() : 캐쉬를 이름으로 생성 및 만료시간(ttl : 지워지는 시간) 설정
- myDefaultCacheConfig() :
1) 설정 ttl(만료시간)
2) 통신설정 (json 데이터 ->(변환)-> redis DB 에 (키, 값)으로 넣기)
통신설정시 Json 데이터 ->(변환)-> Redis DB 로 전송
package com.example.simpledms.model.common; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import java.util.List; /** * packageName : com.example.backedu.entity * fileName : RestPage * author : kangtaegyung * date : 10/21/23 * description : Redis Page Json 변환시 오류 해결을 위한 래퍼클래스 (pageable 속성을 레디스가 변환할때 에러가 발생함) * - Page 객체에 포함되어 있는 pageable 를 json 변환시 해당 속성 무시 * - content, number, size, totalElements 만 리턴할 수 있도록 재정의함 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 10/21/23 kangtaegyung 최초 생성 */ @JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"}) public class RestPage<T> extends PageImpl<T> { @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) public RestPage(@JsonProperty("content") List<T> content, @JsonProperty("number") int page, @JsonProperty("size") int size, @JsonProperty("totalElements") long total) { super(content, PageRequest.of(page, size), total); } public RestPage(Page<T> page) { super(page.getContent(), page.getPageable(), page.getTotalElements()); } }
- JPA 페이징처리시 에러가 발생함 : pageable 객체가 Json -> Redis 로 변환시 에러가 발생함
- Page 객체에 포함되어 있는 pageable 를 json 변환시 해당 속성 무시
- content, number, size, totalElements 만 리턴할 수 있도록 재정의함
모델 : 엔티티
공통 모델
package com.example.jpaexam.model.common; import lombok.Getter; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import javax.persistence.EntityListeners; import javax.persistence.MappedSuperclass; import javax.persistence.PrePersist; import javax.persistence.PreUpdate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; /** * packageName : com.example.jpaexam.model * fileName : BaseTimeEntity * author : GGG * date : 2023-10-16 * description : JPA 에서 자동으로 생성일자/수정일자를 만들어 주는 클래스 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ————————————————————————————— * 2023-10-16 GGG 최초 생성 */ @Getter // todo: 자동으로 생성일자/수정일자 컬럼을 sql 문에 추가시키는 어노테이션 2개 @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseTimeEntity { // todo: 공통속성 : yyyy-MM-dd HH:mm:ss 아니고 기본 패턴으로 보임 private String insertTime; private String updateTime; // todo: 해당 테이블에 데이터가 만들어 질때(insert 문) 실행되는 이벤트 함수 @PrePersist void OnPrePersist() { this.insertTime = LocalDateTime.now() .format(DateTimeFormatter .ofPattern("yyyy-MM-dd HH:mm:ss")); } // todo: 해당 테이블에 데이터가 수정 질때(update 문) 실행되는 이벤트 함수 @PreUpdate void OnPreUpdate() { this.updateTime = LocalDateTime.now() .format(DateTimeFormatter .ofPattern("yyyy-MM-dd HH:mm:ss")); this.insertTime = this.updateTime; // 생성일시 == 수정일시 동일하게 처리 } }
부서 게시판 : 엔티티
package com.example.jpaexam.model.entity.basic; import com.example.jpaexam.model.common.BaseTimeEntity; import lombok.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; import javax.persistence.*; /** * packageName : com.example.jpaexam.model * fileName : Dept * author : GGG * date : 2023-10-16 * description : 부서 모델 클래스 ( 엔티티(entity) ) * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ————————————————————————————— * 2023-10-16 GGG 최초 생성 */ // todo: @Entity - JPA 기능을 클래스에 부여하는 어노테이션 @Entity // todo: @Table(name = "생성될테이블명") @Table(name = "TB_DEPT") // todo 사용법 : @SequenceGenerator( // name = "시퀀스함수이름" // , sequenceName = "DB에생성된시퀀스이름" // , initialValue = 시작값 // , allocationSize = jpa에서관리용숫자(성능지표) //) @SequenceGenerator( name = "SQ_DEPT_GENERATOR" , sequenceName = "SQ_DEPT" , initialValue = 1 , allocationSize = 1 ) @Getter @Setter @ToString @NoArgsConstructor @AllArgsConstructor // todo: jpa 어노테이션 sql 자동 생성시 null 값 컬럼은 제외하고 생성 // 예) insert into 테이블명(컬럼1, 컬럼2, 컬럼3) values(1, 2, null); // => insert into 테이블명(컬럼1, 컬럼2) values(1, 2); @DynamicInsert @DynamicUpdate public class Dept extends BaseTimeEntity { @Id // todo: @GeneratedValue(strategy = GenerationType.SEQUENCE // , generator = "시퀀스함수이름" // ) @GeneratedValue(strategy = GenerationType.SEQUENCE , generator = "SQ_DEPT_GENERATOR" ) // todo: @Column(columnDefinition = "DB컬럼자료형") @Column(columnDefinition = "NUMBER") private Integer dno; // 부서번호(기본키) - 시퀀스 기능 부여 @Column(columnDefinition = "VARCHAR2(255)") private String dname; // 부서명 @Column(columnDefinition = "VARCHAR2(255)") private String loc; // 부서위치 }
부서 레포지토리
package com.example.jpaexam.repository.basic; import com.example.jpaexam.model.entity.basic.Dept; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; /** * packageName : com.example.jpaexam.repository * fileName : DeptRepository * author : GGG * date : 2023-10-16 * description : JPA 레포지토리 인터페이스 ( DB 접속 함수들(CRUD) 있음) * == DAO 비슷함 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ————————————————————————————— * 2023-10-16 GGG 최초 생성 */ // todo: @Repository - 클래스 위에 붙이고, 스프링서버가 실행될때 자동으로 // 객체 1개를 만들어줌 ( IOC ) // 사용법 : 인터페이스명 extends JpaRepository<모델클래스명, 기본키의자료형> @Repository public interface DeptRepository extends JpaRepository<Dept, Integer> { }
select : @Query 이용한 오라클 기반 쿼리(nativeQuery = true) 임
부서 게시판 서비스
package com.example.simpledms.service.basic; import com.example.simpledms.model.common.RestPage; import com.example.simpledms.model.entity.basic.Dept; import com.example.simpledms.repository.basic.DeptRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.util.Optional; /** * packageName : com.example.modelexam.service * fileName : DeptService * author : kangtaegyung * date : 2022/10/12 * description : 부서 업무 서비스 클래스 * 요약 : * 1) 레디스 캐쉬 적용 : @EnableCaching, @Cacheable, * 2) @CacheEvict * <p> * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2022/10/12 kangtaegyung 최초 생성 */ // springboot 프레임워크에 객체를 생성함 : 싱글톤 유형 @Slf4j @Service // 레디스 캐싱 기능 활성화 @EnableCaching public class DeptService { @Autowired DeptRepository deptRepostory; // 샘플데이터 DB에 접근하는 객체 public Page<Dept> findAll(Pageable pageable) { Page<Dept> page = deptRepostory.findAll(pageable); return page; } @Cacheable("dept") public Optional<Dept> findById(int dno) { log.debug("캐쉬 안됨"); Optional<Dept> optionalDept = deptRepostory.findById(dno); return optionalDept; } public Dept insert(Dept dept) { Dept dept2 = deptRepostory.save(dept); return dept2; } // 사용법 : @CacheEvict(value = "값", key = "#객체명.속성명") @CacheEvict(value = "dept", key = "#dept.dno") public Dept update(Dept dept) { Dept dept2 = deptRepostory.save(dept); return dept2; } // 사용법 : @CacheEvict(value = "키이름", key = "#매개변수명") @CacheEvict(value = "dept", key = "#dno") public boolean removeById(int dno) { if (deptRepostory.existsById(dno)) { deptRepostory.deleteById(dno); return true; } return false; } public void removeAll() { deptRepostory.deleteAll(); } // dname like 검색 @Cacheable("depts") public Page<Dept> findAllByDnameContaining(String dname, Pageable pageable) { Page<Dept> page = deptRepostory.findAllByDnameContaining(dname, pageable); return new RestPage<>(page); } }
@EnableCaching : 레디스 캐싱 기능 활성화 어노테이션, 클래스 위에 붙임
읽기 전략 : 캐시를 먼저 확인하고, 캐시에 데이터가 없을 경우 DB에서 데이터를 가져오는 전략 사용(Look Aside 전략)
쓰기 전략 : 데이터를 DB 에 업데이트 할 대마다 캐시는 제거하는 전략 사용(Write Around 전략)
사용법 : @Cacheable(value = “cacheManager() 에서 설정한이름”)
1) 조회 함수 위에 붙임
2) 최초에 DB 에 조회시 캐시 DB 에 결과를 저장해둠, 이후에는 캐시 DB에서 결과를 가져옴사용법 : @CacheEvict(value = “cacheManager() 에서 설정한이름”, key = “#함수의 매개변수명(객체의 속성, 변수등)”)
1) 수정 함수 위에 붙임
2) DB 의 데이터가 수정되면 캐싱 DB 의 데이터에 불일치가 발생함, 그래서 캐싱 데이터 삭제하는 어노테이션사용법 : @CacheEvict(value = “cacheManager() 에서 설정한이름”, key = “#함수의 매개변수명(객체의 속성, 변수등)”)
1) 삭제 함수 위에 붙임
2) DB 의 데이터가 수정되면 캐싱 DB 의 데이터에 불일치가 발생함, 그래서 캐싱 데이터 삭제하는 어노테이션
부서 게시판 컨트롤러 : Rest Controller 사용 ( Vue, React, Angular.js 등 )
package com.example.simpledms.controller.basic; import com.example.simpledms.model.entity.basic.Dept; import com.example.simpledms.service.basic.DeptService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.*; /** * packageName : com.example.modelexam.controller * fileName : DeptController * author : kangtaegyung * date : 2022/10/12 * description : 부서 컨트롤러 * 요약 : * <p> * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2022/10/12 kangtaegyung 최초 생성 */ @Slf4j @RestController @RequestMapping("/api/basic") public class DeptController { @Autowired DeptService deptService; @GetMapping("/dept") public ResponseEntity<Object> findAllByDnameContaining(@RequestParam(defaultValue = "") String dname, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "3") int size ) { try { // 페이지 변수 저장 Pageable pageable = PageRequest.of(page, size); // List<Dept> list = Collections.emptyList(); Page<Dept> deptPage; deptPage = deptService.findAllByDnameContaining(dname, pageable); Map<String, Object> response = new HashMap<>(); response.put("dept", deptPage.getContent()); response.put("currentPage", deptPage.getNumber()); response.put("totalItems", deptPage.getTotalElements()); response.put("totalPages", deptPage.getTotalPages()); if (deptPage.isEmpty() == false) { // 성공 return new ResponseEntity<>(response, HttpStatus.OK); } else { // 데이터 없음 return new ResponseEntity<>(HttpStatus.NO_CONTENT); } } catch (Exception e) { // 서버 에러 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } @GetMapping("/dept/{dno}") public ResponseEntity<Object> findById(@PathVariable int dno) { try { Optional<Dept> optionalDept = deptService.findById(dno); if (optionalDept.isPresent()) { // 성공 return new ResponseEntity<>(optionalDept.get(), HttpStatus.OK); } else { // 데이터 없음 return new ResponseEntity<>(HttpStatus.NO_CONTENT); } } catch (Exception e) { // 서버 에러 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } @PostMapping("/dept") public ResponseEntity<Object> create(@RequestBody Dept dept) { try { Dept dept2 = deptService.save(dept); return new ResponseEntity<>(dept2, HttpStatus.OK); } catch (Exception e) { // DB 에러가 났을경우 : INTERNAL_SERVER_ERROR 프론트엔드로 전송 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } @PutMapping("/dept/{dno}") public ResponseEntity<Object> update(@PathVariable int dno, @RequestBody Dept dept) { try { Dept dept2 = deptService.save(dept); return new ResponseEntity<>(dept2, HttpStatus.OK); } catch (Exception e) { // DB 에러가 났을경우 : INTERNAL_SERVER_ERROR 프론트엔드로 전송 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } @DeleteMapping("/dept/deletion/{dno}") public ResponseEntity<Object> delete(@PathVariable int dno) { // 프론트엔드 쪽으로 상태정보를 보내줌 try { boolean bSuccess = deptService.removeById(dno); if (bSuccess == true) { // delete 문이 성공했을 경우 return new ResponseEntity<>(HttpStatus.OK); } // delete 실패했을 경우( 0건 삭제가 될경우 ) return new ResponseEntity<>(HttpStatus.NO_CONTENT); } catch (Exception e) { // DB 에러가 날경우 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } @DeleteMapping("/dept/all") public ResponseEntity<Object> deleteAll() { // 프론트엔드 쪽으로 상태정보를 보내줌 try { deptService.removeAll(); return new ResponseEntity<>(HttpStatus.OK); } catch (Exception e) { // DB 에러가 날경우 return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } }
Vue 페이지 : 부서와 동일 생략
📃 결론
부서 게시판 샘플 예제를 Vue & Spring boot 연동 예제를 살펴보았습니다.
기본적인 CRUD 기능을 구현했으며 게시판의 페이징 처리도 해봤습니다.
Spring Boot 는 @RestController 어노테이션을 이용해 구현했으며, 결과는 JSON 데이터로 리턴했으며,
Vue 에 axios 라이브러리를 이용해 전달했습니댜.
DB 프레임워크는 JPA 를 이용해서 sql 문을 직접 제작하지 않고 자동화기능을 이용해 구현했습니다.
Mybatis 의 직접 sql 문 제작 기능에 대응해 자주 반복되고 쉬운 기능은 JPA 의 sql 자동생성 기능을 이용하고,
복잡한 sql 문은 @Query 를 이용해 직접 작성할 수 있어 요즘 많이 서비스 업체 기준으로 사용되고 있습니다.
감사합니다.