반응형

🧩 들어가며

현대의 자바 기반 애플리케이션에서 데이터 접근 계층은 단순한 SQL 실행 그 이상을 요구받고 있습니다.
객체 지향 언어인 Java와 관계형 데이터베이스(RDB) 사이의 간극을 효율적으로 연결해주는 기술이 바로 JPA(Java Persistence API) 입니다.

이번 글에서는 실무 관점에서 JPA가 왜 필요한지, 어떤 구조로 동작하는지, 그리고 실질적으로 우리가 어떻게 다뤄야 하는지에 대해 깊이 있는 시선으로 정리해보려 합니다.


🔍 JPA란 무엇인가?

JPA는 Java 진영의 ORM(Object Relational Mapping) 표준 명세입니다.
즉, 자바 객체 ↔ 관계형 데이터베이스 사이의 매핑을 선언적으로 처리할 수 있도록 도와주는 인터페이스 모음이죠.

💡 Hibernate, EclipseLink, OpenJPA는 JPA의 구현체입니다.
스프링부트에서 주로 사용하는 건 Hibernate이며, JPA를 추상 레이어로 사용합니다.


📐 왜 JPA인가?

1. SQL 중심 개발의 한계

  • 중복되는 SQL
  • 비즈니스 로직에 쿼리 로직이 섞임
  • 테이블 구조 변경 시 코드 유지보수 어려움
  • 객체와 테이블 간의 불일치

2. 객체 지향적으로 설계된 애플리케이션의 요구

  • 컬렉션 기반의 연관 관계 관리
  • 캡슐화된 비즈니스 메서드
  • 영속성 생명주기와 트랜잭션 추적

👉 이를 해결하기 위해 JPA는 다음과 같은 추상화를 제공합니다.


🧱 핵심 개념 정리

🗃️ Entity

@Entity
@Table(name = "users")
public class User {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @Column(unique = true)
    private String email;
}

 

  • 클래스 자체가 데이터베이스 테이블과 매핑됩니다.
  • @Id, @Column, @Table 등은 모두 매핑 메타데이터입니다.

🔄 EntityManager

JPA의 핵심 동작을 담당하는 인터페이스.
Hibernate의 Session과 유사한 개념으로, 영속성 컨텍스트를 관리합니다.

EntityManager em = entityManagerFactory.createEntityManager();
em.getTransaction().begin();

User user = em.find(User.class, 1L);
user.setName("변경된 이름");

em.getTransaction().commit();

 

📌 주의: Spring Data JPA에서는 직접 EntityManager를 다룰 일은 거의 없습니다. 대신, Repository가 이를 추상화합니다.


📦 영속성 컨텍스트 (Persistence Context)

  • 엔티티 객체를 1차 캐시에 보관
  • 같은 트랜잭션 내 동일 객체 반환 (== 동일성 보장)
  • 변경 감지(Dirty Checking) → flush 시 자동 update
User user = em.find(User.class, 1L);  
user.setName("홍길동"); // 별도 update 쿼리 없이도 자동 감지됨

🧠 Dirty Checking & Flush

  • Dirty Checking: 엔티티 객체의 변경 여부 추적
  • Flush: 변경된 내용을 SQL로 동기화 (트랜잭션 커밋 시 자동 수행)
em.flush(); // DB에 SQL 전송

🏗️ Spring Data JPA와의 관계

Spring Data JPA는 JPA의 Repository 패턴을 자동화한 구현체입니다.
즉, JpaRepository 인터페이스만 상속받으면 기본적인 CRUD는 따로 SQL 없이 구현됩니다.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}


🚀 커스텀 쿼리는 @Query 어노테이션으로 처리하거나, QueryDSL, Specification으로 확장 가능


🧬 연관관계 매핑

예시: 회원과 주문 (1:N)

@Entity
public class Member {

    @Id
    private Long id;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

@Entity
public class Order {

    @Id
    private Long id;

    @ManyToOne
    private Member member;
}

 

  • 양방향 매핑 시 mappedBy 설정 필수
  • 지연 로딩(LAZY)이 기본값이므로 주의

⚠️ 실무에서 자주 하는 실수

항목 설명
양방향 매핑 남용 단방향으로도 충분한 경우가 많음
즉시 로딩(EAGER) 사용 성능 저하, N+1 문제 발생 가능
식별자 전략 잘못 사용 GenerationType.IDENTITY는 제약이 있음
트랜잭션 없이 EntityManager 사용 변경 감지 미동작

 


🎯 마무리

JPA는 단순히 SQL을 안 쓰기 위한 도구가 아닙니다.
도메인 모델을 중심으로 한 객체 지향 아키텍처를 구현하기 위한 전략이며,
이는 결국 유지보수성과 생산성을 극대화합니다.

Spring Boot 3와 함께 JPA를 적절히 활용하면,
비즈니스 로직에 집중할 수 있는 유연하고 강력한 백엔드 아키텍처를 구성할 수 있습니다.

 

반응형

'Spring Boot' 카테고리의 다른 글

Spring Boot 3의 Repository, Service, Controller 흐름  (3) 2025.07.19
JWT  (0) 2024.01.26
스프링 시큐리티  (0) 2024.01.26
front + backend 게시판 CRUD 구현 (1)  (0) 2023.10.19
반응형

안녕하세요, 오늘은 스프링부트(Spring Boot) 3에서 가장 기본이자 핵심!
바로 Repository → Service → Controller 구조에 대해
재미있고 직관적으로 설명해보겠습니다 😎


🧭 흐름 먼저 보고 가자

Controller (API 입구)
   ↓
Service (비즈니스 로직)
   ↓
Repository (DB 연결)

 

  • Controller: "누가 요청했어? 무슨 데이터가 필요해?" → API 요청을 받는 문지기
  • Service: "오케이, 이 요청 처리해줄게!" → 핵심 로직 처리 담당
  • Repository: "데이터 줘! 저장도 해줘!" → DB와 소통하는 영업사원

🧱 각각 뭐하는 앤데?

1. 🎮 Controller — API의 입구이자 전면 배너

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public UserDto getUser(@PathVariable Long id) {
        return userService.getUserById(id);
    }
}

 

  • 클라이언트가 /api/users/1 호출하면 여기로 들어옴.
  • 요청 파라미터 확인하고, 서비스에 “요거 처리 좀” 요청함.

Tip: @RestController = JSON 응답을 반환하는 컨트롤러


2. 🧠 Service — 진짜 ‘일’하는 핵심 로직 덩어리

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public UserDto getUserById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("유저 없음!"));
        return new UserDto(user);
    }
}
  • DB에 직접 접근은 안 해요! Repository한테 시켜요~
  • 예외 처리, 데이터 가공, 트랜잭션 처리 등 실질적인 비즈니스 로직 담당!

Tip: @Service 붙이면 스프링이 빈으로 관리해줌 (IoC/DI)


3. 🗃️ Repository — 진짜 DB랑 통신하는 데이터 엔지니어

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // 커스텀 쿼리도 만들 수 있어요
    Optional<User> findByEmail(String email);
}
  • JPA를 통해 CRUD 메서드를 자동으로 제공받음
  • DB에게 “user 테이블에서 이거 가져와!”라고 요청하는 역할

Tip: JpaRepository 덕분에 findAll(), save(), delete() 같은 메서드는 이미 다 있음.

스프링부트는 복잡한 설정 없이도 효율적인 구조로 개발할 수 있게 도와줘요.
하지만 구조의 의도를 정확히 이해하고 써야 실무에서도 "개발자 맛집" 되는 거죠 🍜

반응형

'Spring Boot' 카테고리의 다른 글

JPA 제대로 이해하기 — 객체 지향 개발자의 데이터 접근 전략  (4) 2025.07.19
JWT  (0) 2024.01.26
스프링 시큐리티  (0) 2024.01.26
front + backend 게시판 CRUD 구현 (1)  (0) 2023.10.19
반응형

Spring Security를 이용해 회원 관리나 로그인 기능을 구현할 때, 비밀번호를 평문(plain text)으로 저장하면 보안상 매우 취약합니다. 이번 글에서는 Spring Security에서 제공하는 BCryptPasswordEncoder를 사용하여 안전하게 비밀번호를 암호화하는 방법을 소개하겠습니다.

 

 

1. BCryptPasswordEncoder란?

BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 도구로, 단방향 해싱(hashing)을 수행합니다. 한번 암호화된 비밀번호는 복호화가 불가능하며, 로그인 시에는 입력된 비밀번호를 다시 암호화하여 데이터베이스에 저장된 암호화된 비밀번호와 비교하여 인증합니다.

주요 특징:

  • 단방향 암호화 방식
  • 안전한 salt 자동 생성 및 관리
  • 비밀번호 강도 조절 가능

 

2. BCryptPasswordEncoder 설정 방법

다음은 Spring 프로젝트에서 BCryptPasswordEncoder를 Bean으로 등록하는 예제입니다.

@Configuration
public class PasswordConfig {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

위 코드에서 @Configuration 어노테이션은 Spring에게 이 클래스가 설정 클래스임을 알려줍니다. 그리고 @Bean어노테이션을 사용하여 BCryptPasswordEncoder 인스턴스를 Spring의 관리 대상(Bean)으로 등록합니다.

 

3. 회원가입에서 사용 방법

회원가입 시 사용자의 비밀번호를 암호화하여 저장하려면 아래와 같이 작성합니다.

@Autowired
private BCryptPasswordEncoder passwordEncoder;

public void registerUser(User user) {
    String encodedPassword = passwordEncoder.encode(user.getPassword());
    user.setPassword(encodedPassword);
    userRepository.save(user);
}

 

4. 로그인 시 비밀번호 검증 방법

로그인 시 비밀번호를 검증할 때는 사용자가 입력한 비밀번호와 데이터베이스에 저장된 암호화된 비밀번호를 비교합니다.

@Autowired
private BCryptPasswordEncoder passwordEncoder;

public boolean authenticate(String rawPassword, String encodedPassword) {
    return passwordEncoder.matches(rawPassword, encodedPassword);
}

 

5. 주의사항

  • 절대 비밀번호를 평문으로 저장하지 말기!
  • strength 파라미터를 통해 암호화 강도를 조절할 수 있지만, 기본값(10)이 일반적인 환경에서 충분!
  • 성능이 매우 중요한 환경이라면 암호화 강도를 적절히 설정할 것!

마무리

지금까지 Spring Security의 BCryptPasswordEncoder를 활용한 안전한 비밀번호 관리 방법을 알아봤습니다. 이를 통해 애플리케이션 보안을 강화하고 사용자 정보를 안전하게 보호할 수 있습니다.

반응형

'Spring Boot > 스프링부트 예제' 카테고리의 다른 글

답변형 게시판 구현 (3)  (0) 2023.10.27
답변형 게시판 구현 (2)  (1) 2023.10.27
답변형 게시판 만들기 (1)  (1) 2023.10.26
QnA 다양한 검색 게시판 CRUD (1)  (1) 2023.10.24
게시판 페이징 처리  (1) 2023.10.23
반응형

JWT(JSON Web Token)은 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰

JWT는 URL로 이용할 수 있는 문자열로만 구성되어 있으며, 디지털 서명이 적용되어 있어 신뢰할 수 있음.

JWT는 주로 서버와의 통신에서 권한 인가를 위해 사용된다. URL에서 사용할 수 있는 문자열로만 구성되어 있기 때문에 HTTP 구성요소 어디든 위치할 수 있다.

 

JWT의 구조

 

JWT는 점('.')으로 구분된 아래의 세 부분으로 구성되어있다.

  • 헤더(Header)
  • 내용(Payload)
  • 서명(Signature)

따라서 JWT는 일반적으로 아래와 같은 형식을 띠고 있다.

 

헤더

 

JWT의 헤더는 검증과 관련한 내용을 담고 있다. 헤더에는 두 가지 정보를 포함하고 있는데, 바로 alg와 typ 속성이다.

 

{
	"alg": "HS256"
	"typ": "JWT"
}

 

alg 속성에는 해싱 알고리즘을 지정한다. 해싱 알고리즘은 보통 SHA256 또는 RSA를 사용하며, 토큰을 검증할 때 사용되는 서명 부분에서 사용된다.

위의 예제에 있는 HS256은 'HMAC SHA256' 알고리즘을 사용한다는 의미, 그리고 typ 속성에는 토큰의 타입을 지정

 

이렇게 완성된 헤더는 Base64Url 형식으로 인코딩되어 사용된다.

 

내용(Payload)

 

JWT의 내용에는 토큰에 담는 정보를 포함합니다. 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 크게 세 가지로 분류된다.

 

  • 등록된 클레임(Registerd Claims)
  • 공개 클레임(Public Claims)
  • 비공개 클레임(Private Claims)

등록된 클레임은 필수는 아니지만, 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻한다.

등록된 클레임은 다음과 같이 정의되어 있다.

  • iss: JWT의 발급자(Issuer) 주체를 나타낸다. iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열
  • sub: JWT의 제목(Subject)이다.
  • aud: JWT의 수신인(Audience)이다. JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 한다. 요청을 처리하는 주체가 'aud' 값으로 자신을 식별하지 않으면 JWT는 거부된다.
  • exp: JWT의 만료시간(Expriation)입니다. 시간은 NumericDate 형식으로 지정해야한다.
  • nbf: 'Not Before'를 의미
  • iat: JWT가 발급된 시간(Issued at)이다.
  • jti: JWT의 식별자(JWT ID)이다. 주로 중복 처리를 방지하기 위해 사용

공개 클레임은 키 값을 마음대로 정의할 수 있다. 다만 충돌이 발생하지 않을 이름으로 설정해야 한다.

 

비공개 클레임은 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미

 

{
    "sub": "junyeoke payload",
    "exp": "123241546",
    "userId": "junyeoke",
    "username": "junhyuk"
}

 

이렇게 완성된 내용은 Base64Url 형식으로 인코딩되어 사용

 

서명

 

JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성된다.

예를 들어, HMAC SHA256 알고리즘을 사용해서 서명을 생성한다면 아래와 같은 방식으로 생성된다.

HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret
)

 

서명은 토큰의 값을 포함해서 암호화하기 때문에 메시지가 도중에 변경되지 않았는지 확인할 때 사용된다.

반응형
반응형

 

애플리케이션의 인증, 인가 등의 보안기능을 제공하는 스프링 하위 프로젝트 중 하나.

 

스프링 시큐리티의 동작구조

 

스프링 시큐리티는 서블릿 필터(Servlet Filter)를 기반으로 동작, DispatcherServlet 앞에 필터가 배치되어 있음

 

위 그림의 필터체인은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미한다.

클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑한다.

스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작하기위해 DelegatingFilterProxy를 사용

 

DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트(Application Context) 사이에서 다리 역할을 수행하는 필터 구현체이다.

 

표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시(FilterChainProxy)를 내부에 가지고 있다.

필터체인 프록시는 스프링부트 자동 설정에 의해 자동 생성된다.

필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용할 수 있다.

필터체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있게 설정되어 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용하게 된다.

 

보안 필터체인은 WebSecurityConfigurerAdapter 클래스를 상속받아 설정할 수 있다.

필터체인 프록시는 여러 보안 필터체인을 가질 수 있어, 여러 보안 필터체인을 만들기위해서는 WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러개 생성하면 된다. 이때, WebSecurityConfigurerAdapter 클래스에는 @Order 어노테이션을 통해 우선순위가 지정되어 있는데 2개 이상의 클래스를 생성했을 대 똑같은 설정으로 우선순위가 100이 설정되어 있으면 예외가 발생하기 때문에 상속받은 클래스에서 @Order 어노테이션을 지정해 순서를 지정하는것이 중요하다.

만약 별도의 설정이 없다면, 스프링시큐리티는 아래와 같이 SecurityFilterChain에서 사용하는 필터 중 UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다.

 

위 그림의 인증 수행 과정을 설명하면

 

1. 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고, 그 중 UsernamePasswordAuthenticationFilter(위 그림에서 AuthenticationFilter에 해당)에서 인증을 처리한다.

2. AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 토큰을 생성

3. AuthenticationManager에게 토큰을 전달. AuthenticationManager는 인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager 이다.

4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달

5. AuthenticationProvider는 토큰의 정보를 UserDetailsService에 전달

6. UserDetailsService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성

7. 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하게 되면 ProviderManager로 권한을 담은 토큰을 전달

8. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달

9. AuthenticationFilter는 검증된 SecuritycontexHolder에 있는 SecuritryContext에 저장

 

위 과정에서 사용된 UsernamePasswordAuthenticationfilter는 접근 권한을 확인하고 인증이 실패한 경우 로그인 폼이라는 화면을 보내는 역할을 수행한다.

반응형
반응형

https://devjunyeok.tistory.com/193

 

답변형 게시판 구현 (2)

https://devjunyeok.tistory.com/192 답변형 게시판 만들기 (1) 네이버 카페 등의 게시판을 주로 보면 질문에 대한 답글이 게시되어 있는 기능을 종종 볼 수 있습니다. 이번에는 답글을 달 수 있는 게시판을

devjunyeok.tistory.com

📝 실행결과

지난 글에 이어 이번에는 게시글 생성 기능구현을 해보겠습니다.

 

💻 프론트 작업

게시글 추가 페이지를 만들기 위해 AddReplyBoard.tsx 페이지를 생성합니다.

rfce 단축키를 이용해 함수를 생성하고, return 문 아래에 html 코드를 작성합니다.

// return문 아래에 작성
    <div className="row">
      {submitted ? (
        <div className="col-6 mx-auto">
          <h4>You submitted successfully!</h4>
          <button className="btn btn-success" onClick={newReplyBoard}>
            Add
          </button>
        </div>
      ) : (
        <>
          {/* 제목 start */}
          <TitleCom title="Add Reply Board" />
          {/* 제목 end */}

          <div className="col-6 mx-auto">
            <div className="row g-3 align-items-center mb-3">
              <div className="col-3">
                <label htmlFor="boardTitle" className="col-form-label">
                  boardTitle
                </label>
              </div>

              <div className="col-9">
                <input
                  type="text"
                  id="boardTitle"
                  required
                  className="form-control"
                  value={replyBoard.boardTitle}
                  onChange={handleInputChange}
                  placeholder="boardTitle"
                  name="boardTitle"
                />
              </div>
            </div>

            <div className="row g-3 align-items-center mb-3">
              <div className="col-3">
                <label htmlFor="boardContent" className="col-form-label">
                  boardContent
                </label>
              </div>

              <div className="col-9">
                <input
                  type="text"
                  id="boardContent"
                  required
                  className="form-control"
                  value={replyBoard.boardContent}
                  onChange={handleInputChange}
                  placeholder="boardContent"
                  name="boardContent"
                />
              </div>
            </div>

            <div className="row g-3 align-items-center mb-3">
              <div className="col-3">
                <label htmlFor="boardWriter" className="col-form-label">
                  boardWriter
                </label>
              </div>

              <div className="col-9">
                <input
                  type="text"
                  id="boardWriter"
                  required
                  className="form-control"
                  value={replyBoard.boardWriter}
                  onChange={handleInputChange}
                  placeholder="boardWriter"
                  name="boardWriter"
                />
              </div>
            </div>

            <div className="row g-3 mt-3 mb-3">
              <button
                onClick={saveReplyBoard}
                className="btn btn-outline-primary ms-2 col"
              >
                Submit
              </button>
            </div>
          </div>
        </>
      )}
    </div>

변수 및 함수 정의

return 문 위쪽에 html에 사용된 변수와 함수들을 정의합니다.

// return 문 위에 작성

// todo: 변수 정의
  // todo: 객체 초기화
  const initialReplyBoard = {
    bid: null,
    boardTitle: "",
    boardContent: "",
    boardWriter: "",
    viewCnt: 0,
    boardGroup: null,
    boardParent: 0,
  };

  // replyBoard 객체
  const [replyBoard, setReplyBoard] = useState<IReplyBoard>(initialReplyBoard);
  // 저장버튼 클릭후 submitted = true 변경됨
  const [submitted, setSubmitted] = useState<boolean>(false);

  // todo: 함수 정의
  // input 태그에 수동 바인딩
  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target; // 화면값
    setReplyBoard({ ...replyBoard, [name]: value }); // 변수저장
  };

  // 저장 함수
  const saveReplyBoard = () => {
    // 임시 객체
    var data = {
      boardTitle: replyBoard.boardTitle,
      boardContent: replyBoard.boardContent,
      boardWriter: replyBoard.boardWriter,
      viewCnt: replyBoard.viewCnt,
      boardGroup: null,             // 입력시 제외
      boardParent: 0,               // 입력시 제외
    };

    ReplyBoardService.createBoard(data) // 게시물 저장 요청
      .then((response: any) => {
        setSubmitted(true);
        console.log(response.data);
      })
      .catch((e: Error) => {
        console.log(e);
      });
  };

  // 새폼 보여주기 함수 : 변수값 변경 -> 화면 자동 갱신(리액트 특징)
  const newReplyBoard = () => {
    setReplyBoard(initialReplyBoard); // replyBoard 초기화
    setSubmitted(false); // submitted 변수 초기화
  };

프론트 서버를 재시작 후 화면을 테스트합니다.

 

 

💻 백엔드 작업

ReplyBoardRepository.java 파일에 게시물 생성(수정) 함수 작성

// 게시물 저장함수 : 최초 생성(board_group(그룹번호)), board_parent(부모번호))
//  => board_group(부모번호 == 자식번호(bid)), board_parent(최초생성시 0, 댓글이 달리면 부모 번호가 들어감)
// todo : JPA insert문 직접 작성(DML : 테이블 데이터 변경, 트랜잭션을 동반)
//  =>  @Transactional, @Modifying
//  => 예 ) 변수 전달  : :#{#replyBoard.boardTitle}
@Transactional
@Modifying
@Query(value = "INSERT INTO TB_REPLY_BOARD " +
        "VALUES(sq_reply_board.nextval, :#{#replyBoard.boardTitle}, " +
        ":#{#replyBoard.boardContent}, " +
        ":#{#replyBoard.boardWriter}, " +
        "0, sq_reply_board.CURRVAL, 0, 'N', " +
        "        TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'), NULL, NULL)", nativeQuery = true)
int insertByBoard(@Param("replyBoard") ReplyBoard replyBoard);

JPA insert 문을 직접 작성하기 위해서는 @Transactional, @Modifying 어노테이션을 함수위에 붙여줍니다.

그리고 쿼리문을 작성해야 하는데 먼저 SQL Developer에서 테스트 후 정상적으로 데이터가 나오는 것을 확인하여 쿼리문을 복사하여 붙여넣기 합니다.

 

INSERT INTO TB_REPLY_BOARD
VALUES(sq_reply_board.nextval, '제목', '내용', '홍길동', 0, sq_reply_board.CURRVAL, 0, 'N',
        TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'), NULL, NULL);

 

정상적으로 쿼리문을 작성하였다면 위 처럼 메세지가 나옵니다. 테스트용으로 데이터를 insert 하였으므로 ROLLBACK을 시켜줍니다.

        ROLLBACK;

SQL Developer의 쿼리문을 ;을 제외한 모든 문장을 복사하여 @Query(value = " ")의 " " 사이에 붙혀넣기합니다.

// 게시물 저장함수 : 최초 생성(board_group(그룹번호)), board_parent(부모번호))
//  => board_group(부모번호 == 자식번호(bid)), board_parent(최초생성시 0, 댓글이 달리면 부모 번호가 들어감)
// todo : JPA insert문 직접 작성(DML : 테이블 데이터 변경, 트랜잭션을 동반)
//  =>  @Transactional, @Modifying
//  => 예 ) 변수 전달  : :#{#replyBoard.boardTitle}
@Transactional
@Modifying
@Query(value = "INSERT INTO TB_REPLY_BOARD " +
        "VALUES(sq_reply_board.nextval, :#{#replyBoard.boardTitle}, " +
        ":#{#replyBoard.boardContent}, " +
        ":#{#replyBoard.boardWriter}, " +
        "0, sq_reply_board.CURRVAL, 0, 'N', " +
        "        TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'), NULL, NULL)", nativeQuery = true)
int insertByBoard(@Param("replyBoard") ReplyBoard replyBoard);

 

변수를 전달하기 위해 '제목', '내용', '홍길동' 부분을 아래와 같이 수정합니다.

 

ReplyBoardService에 함수 추가

// 게시물 저장
public int saveBoard(ReplyBoard replyBoard) {
    int insertCount = replyBoardRepository.insertByBoard(replyBoard);

    return insertCount;
}

 

ReplyBoardController에 함수추가

// 게시글 저장
    @PostMapping("/reply-board")
    public ResponseEntity<Object> createBoard(@RequestBody ReplyBoard replyBoard) {

        try {
            int insertCount = replyBoardService.saveBoard(replyBoard);  // db 저장

            return new ResponseEntity<>(insertCount, HttpStatus.OK);
        } catch (Exception e) {
//            DB 에러가 났을경우 : INTERNAL_SERVER_ERROR 프론트엔드로 전송
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

 

💻 프론트 + 백엔드 테스트

 

 

반응형
반응형

https://devjunyeok.tistory.com/192

 

답변형 게시판 만들기 (1)

네이버 카페 등의 게시판을 주로 보면 질문에 대한 답글이 게시되어 있는 기능을 종종 볼 수 있습니다. 이번에는 답글을 달 수 있는 게시판을 제작해 보겠습니다. 단, 간단한 예제를 위해 답글은

devjunyeok.tistory.com

지난 시간에 답변형 게시판 전체조회 기능까지 구현하였습니다.

이번에는 답글을 생성하는 기능을 구현해보겠습니다.

 

프론트 작업은 이미 앞에서 답변글 생성 함수 및 Service를 코딩하였으므로 백엔드 부분만 코딩하도록 하겠습니다.

 

 

ReplyBoardService에 답변글 저장(수정) 함수 추가

📂 src > main > java > 프로젝트 폴더 > service > normal > ReplyBoardService.java 수정

// 전체 조회 함수 밑에 답변글 저장(수정) 함수를 작성합니다.

// 답변글 저장(수정)
public ReplyBoard save(ReplyBoard replyBoard){
    ReplyBoard replyBoard2 = replyBoardRepository.save(replyBoard);

    return replyBoard2;
}

 

ReplyBoardController에 답변글 저장(수정) 함수 추가

📂 src > main > java > 프로젝트 폴더 > controller> normal > ReplyBoardController.java 수정

// 전체조회 함수 바로 아래에 작성합니다.

 

// 답변 글 저장(수정)
    @PostMapping("/reply")
    public ResponseEntity<Object> create(@RequestBody ReplyBoard replyBoard) {

        try {
            ReplyBoard replyBoard2 = replyBoardService.save(replyBoard);

            return new ResponseEntity<>(replyBoard2, HttpStatus.OK);
        } catch (Exception e) {
//            DB 에러가 났을경우 : INTERNAL_SERVER_ERROR 프론트엔드로 전송
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

 

서버 재시작 후 답변글 기능 구현 테스트

 

 

반응형
반응형

네이버 카페 등의 게시판을 주로 보면 질문에 대한 답글이 게시되어 있는 기능을 종종 볼 수 있습니다.
이번에는 답글을 달 수 있는 게시판을 제작해 보겠습니다.
단, 간단한 예제를 위해 답글은 1번만 달 수 있도록 제한하고 테이블 설계 및 코딩을 하도록 하겠습니다.

요소 기술 및 테이블 설계는 아래와 같습니다.

요소 기술 :
- 프로젝트 키워드 : ReplyBoard
- 프론트엔드 : 리액트 & 타입스크립트
- 벡엔드 : 스프링부트 & JPA & Oracle 18xe(Oracle Cloud 19c)


프론트 작업

1) 답변형 게시판 TYPE 정하기

IReplyBoard.ts 파일을 types 폴더에 저장합니다.

export default interface IReplyBoard {
  bid?: any | null,					// 게시글 번호
  boardTitle: string,				// 게시글 제목
  boardContent: string,				// 게시글 내용
  boardWriter: string,				// 게시글 작성자
  viewCnt: number,					// 조회수
  boardGroup: any | null,			// 게시글 그룹 번호
  boardParent: any | null			// 부모 게시글 번호
}

2) 답변형 게시판 axios CRUD 서비스 코딩

ReplyBoardService.ts 파일을 service 폴더에 저장합니다.

여기서 생성(저장)함수는 부모 게시글 생성, 답변글 생성 함수를 2개 작성해야하며, 삭제 함수의 경우도 부모+답변글 모두 삭제 기능 함수와, 답변 삭제 기능 함수 총 2개를 작성하여야 합니다.

 

2-1) ReplyBoard type import / axios 공통 파일 import

import IReplyBoard from "../../types/normal/IReplyBoard";
import http from "../../utils/http-common";

2-2) 전체조회함수 + like 검색(페이징 기능)

// 전체 조회 + like 검색(paging 기능 : page(현재페이지), size(한 페이지당 개수))
const getAll = (boardTitle:string, page:number, size:number) => {
  return http.get<Array<IReplyBoard>>(`/normal/reply-board?boardTitle=${boardTitle}&page=${page}&size=${size}`);
};

2-3) 상세조회

// 상세 조회
const get = (bid:any) => {
  return http.get<IReplyBoard>(`/normal/reply-board/${bid}`);
};

2-4) 저장함수 : 부모 게시글 저장

// 저장 함수 : 게시물 생성(부모글)
const createBoard = (data:IReplyBoard) => {
  return http.post<IReplyBoard>("/normal/reply-board", data);
};

2-5) 저장함수 : 답변글 저장

// 저장 함수 : 답변글 생성(자식글)
const create = (data:IReplyBoard) => {
    return http.post<IReplyBoard>("/normal/reply", data);
  };

2-6) 수정함수

// 수정 함수
const update = (bid:any, data:IReplyBoard) => {
  return http.put<any>(`/normal/reply-board/${bid}`, data);
};

2-7) 삭제함수 : 부모글 + 답변글 모두 삭제

그룹번호 : 부모글과 자식글은 모두 그룹번호가 같음

// 삭제 함수 : 게시물(부모글) + 답변글(자식글) 모두 삭제
// 그룹 번호 : 부모글과 자식글은 모두 그룹 번호가 같음
const removeBoard = (boardGroup:any) => {
  return http.delete<any>(`/normal/reply-board/deletion/${boardGroup}`);
};

2-8) 삭제함수 : 답변글만 삭제

// 삭제 함수 : 답변글만 삭제
const remove = (bid:any) => {
    return http.delete<any>(`/normal/reply/deletion/${bid}`);
  };

2-9) 함수 내보내기

const ReplyBoardService = {
  getAll,
  get,
  createBoard,
  create,
  update,
  removeBoard,
  remove,
};

export default ReplyBoardService;

 

3) 답변형 게시글 리스트 페이지 작성

ReplyBoardList.tsx 파일을 reply-board 폴더에 저장합니다.

더보기
import React, { useEffect, useState } from "react";
import TitleCom from "../../../components/common/TitleCom";
import { Pagination } from "@mui/material";
import { Link } from "react-router-dom";
import IReplyBoard from "../../../types/normal/IReplyBoard";
import ReplyBoardService from "../../../services/normal/ReplyBoardService";

function ReplyBoardList() {
  // todo ) 변수정의
  // 답변형 게시판(게시물+답변) 배열 변수
  // 답변글 1개만 달리게 제한
  const [replyBoard, setReplyBoard] = useState<Array<IReplyBoard>>([]);

  // 검색어 변수
  const [searchBoardTitle, setSearchBoardTitle] = useState<string>("");

  // todo 공통 변수 : page(현재페이지 번호), count(총 페이지 건수), pageSize(3,6,9 배열)
  const [page, setPage] = useState<number>(1);
  const [count, setCount] = useState<number>(1);
  const [pageSize, setPageSize] = useState<number>(3); // 한 페이지당 개수
  // todo 공통 pageSizes : 배열 (selectbox에 사용)
  const pageSizes = [3, 6, 9];

  // todo ) 함수정의

  //  1) 컴포넌트가 mounted 될때 한번만 실행됨 : useEffect(() => {},[])
  //  2) 컴포넌트의 변수값이 변할 때 실행됨 : useEffect(() => {실행문},[감시할 변수])
  useEffect(() => {
    retrieveReplyBoard(); // 전체 조회
  }, [page, pageSize]);

  // 전체 조회 함수
  const retrieveReplyBoard = () => {
    // 벡엔드 매개변수 전송 : 현재페이지(page), 1페이지당 개수(pageSize)
    ReplyBoardService.getAll(searchBoardTitle, page - 1, pageSize) // 벡엔드 전체조회요청
      .then((response: any) => {
        // 벡엔드 성공시 실행됨
        // es6(모던js) 문법 : 객체 분해 할당
        // 원래 코드
        // const dept =  response.data.dept;  // 부서배열
        // const totalPage = response.data.totalPages;  // 전체페이지수
        const { replyBoard, totalPages } = response.data;
        // dept 저장
        setReplyBoard(replyBoard);
        setCount(totalPages);
        // 로그 출력
        console.log("response", response.data);
      })
      .catch((e: Error) => {
        // 벡엔드 실패시 실행됨
        console.log(e);
      });
  };

  // 검색어 수동 바인딩 함수
  const onChangeSearchBoardTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchBoardTitle(e.target.value);
  };

  // todo handlePageSizeChange(공통) : pageSize 값 변경시 실행되는 함수
  // select 태그 수동바인딩 : 화면 값을 변수에 저장하는 것
  const handlePageSizeChange = (e: any) => {
    setPageSize(e.target.value); // 1 페이지당 개수 저장(3, 6, 9)
    setPage(1); // 현재페이지 번호 : 1로 강제설정
  };

  // todo Pagination 수동바인딩(공통)
  // page 번호를 누르면 => page 변수에 값 저장
  const handlePageChange = (e: any, value: number) => {
    // value == 화면의 페이지번호
    setPage(value);
  };

  // -------------------------------------------------------

  // todo :: 답변 변수 정의
  // 1) reply 객체 초기화
  const initialReply = {
    bid: null,
    boardTitle: "",
    boardContent: "",
    boardWriter: "",
    viewCnt: 0,
    boardGroup: null,
    boardParent: 0,
  };

  // 2) 답변 글 입력 객체 변수
  const [reply, setReply] = useState(initialReply);
  // 3) reply 버튼 클릭시 상태 저장할 변수 : true/false
  const [replyClicked, setReplyClicked] = useState(false);

  // todo :: 답변 함수 정의
  // 1) input 수동 바인딩 함수
  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target; // 화면값
    setReply({ ...reply, [name]: value }); // 변수저장
  };

  // 2) 답변글 생성 함수(insert)
  const saveReply = () => {
    // 임식 객체
    let data = {
      boardTitle: reply.boardTitle,
      boardContent: reply.boardContent,
      boardWriter: reply.boardWriter,
      viewCnt: 0,
      // 그룹번호(부모글 == 자식글)
      // rule : 1) 부모글 최초생성 또는 답변글 없을때 0 저장
      //        2) 답변글 생성이면 부모글 게시판 번호(bid)를 저장
      boardGroup: reply.bid,
      // 부모글번호
      // rule : 1) 부모글 최초생성 또는 답변글 없을때 자신의 게시판 번호(bid) 저장
      //        2) 답변글 생성이면 부모글 번호(bid) 저장
      boardParent: reply.bid,
    };

    ReplyBoardService.create(data) // 백엔드 답변글 저장 요청
      .then((response: any) => {
        alert("답변글이 생성되었습니다.");
        // 전체 재조회 실행
        retrieveReplyBoard();
        console.log(response.data);
      })
      .catch((e: Error) => {
        console.log(e);
      });
  };

  // 3) 게시물 reply 버튼 클릭시 화면에 답변입력창 보이게 하는 함수
  const newReply = (data: any) => {
    // 매개변수 데이터(객체) 수정 : boardContent: "" 으로 수정
    setReply({ ...data, boardContent: "" });
    // 답변 입력창 화면 보이기 : replyClicked : true
    setReplyClicked(true);
  };

  // 4) 답변 입력창 숨기기
  const closeReply = () => {
    // 답변 화면창 숨기기
    setReplyClicked(false);
  };

  return (
    <div>
      {/* 제목 start */}
      <TitleCom title="Reply Board List" />
      {/* 제목 end */}

      {/* search start (검색어 입력창)*/}
      <div className="row mb-5 justify-content-center">
        <div className="col-12 w-50 input-group mb-3">
          <input
            type="text"
            className="form-control"
            placeholder="Search by title"
            value={searchBoardTitle}
            onChange={onChangeSearchBoardTitle}
          />
          <button
            className="btn btn-outline-secondary"
            type="button"
            onClick={retrieveReplyBoard}
          >
            Search
          </button>
        </div>
      </div>
      {/* search end */}

      {/* page start (페이지 번호)*/}
      <div className="mt-3">
        {"Items per Page: "}
        <select onChange={handlePageSizeChange} value={pageSize}>
          {pageSizes.map((size) => (
            <option key={size} value={size}>
              {size}
            </option>
          ))}
        </select>

        <Pagination
          className="my-3"
          count={count}
          page={page}
          siblingCount={1}
          boundaryCount={1}
          variant="outlined"
          shape="rounded"
          onChange={handlePageChange}
        />
      </div>
      {/* page end */}

      {/* 게시판(폼1) + 답변글(폼2) */}
      <div className="col-md-12">
        {/* table start(게시판) */}
        <table className="table">
          <thead>
            <tr>
              <th scope="col">board No</th>
              <th scope="col">board Title</th>
              <th scope="col">board Content</th>
              <th scope="col">board Writer</th>
              <th scope="col">view Cnt</th>
              <th scope="col">reply</th>
              <th scope="col">Actions</th>
            </tr>
          </thead>
          <tbody>
            {replyBoard &&
              replyBoard.map((data, index) => (
                // 키값 추가 않하면 react 에서 경고를 추가 : 키는 내부적으로 리액트가 rerending 할때 체크하는 값임
                <tr key={index}>
                  <td>{data.bid}</td>
                  <td>{data.boardTitle}</td>
                  <td>{data.boardContent}</td>
                  <td>{data.boardWriter}</td>
                  <td>{data.viewCnt}</td>
                  <td>
                    {/* 클릭하면 아래 답변 폼이 열림 */}
                    {data.boardParent == 0 && (
                      <Link to={"#"}>
                        {/* 리액트 : onClick={함수명} : 매개변수 없으면  */}
                        {/* 리액트 : onClick={()=> 함수명(매개변수)} : 매개변수 있으면  */}
                        <span
                          className="badge bg-warning"
                          onClick={() => newReply(data)}
                        >
                          Reply
                        </span>
                      </Link>
                    )}
                  </td>
                  <td>
                    {/* 클릭시 상세화면 이동 */}
                    <Link
                      to={
                        "/reply-board/bid/" +
                        data.bid +
                        "/boardParent/" +
                        data.boardParent
                      }
                    >
                      <span className="badge bg-success">Edit</span>
                    </Link>
                  </td>
                </tr>
              ))}
          </tbody>
        </table>
        {/* table end */}

        {/* reply form start (답변글)*/}
        <div>
          {/* 변수명 && 태그 : 변수명 = true 태그가 보이고 */}
          {/* 변수명 && 태그 : 변수명 = false 태그가 안보임 */}
          {replyClicked && (
            <div className="col-md-12 row">
              <div className="col-md-12 row mt-2">
                <label htmlFor="bid" className="col-md-2 col-form-label">
                  bid
                </label>
                <div className="col-md-10">
                  <input
                    type="text"
                    className="form-control-plaintext"
                    id="bid"
                    placeholder={reply.bid || ""}
                    disabled
                    name="bid"
                  />
                </div>
              </div>

              <div className="col-md-12 row mt-2">
                <label htmlFor="boardTitle" className="col-md-2 col-form-label">
                  board Title
                </label>
                <div className="col-md-10">
                  <input
                    type="text"
                    className="form-control-plaintext"
                    id="boardTitle"
                    disabled
                    placeholder={reply.boardTitle}
                    name="boardTitle"
                  />
                </div>
              </div>

              <div className="col-md-12 row mt-2">
                <label
                  htmlFor="boardContent"
                  className="col-md-2 col-form-label"
                >
                  board Content
                </label>
                <div className="col-md-10">
                  <input
                    type="text"
                    className="form-control"
                    id="boardContent"
                    required
                    value={reply.boardContent}
                    onChange={handleInputChange}
                    name="boardContent"
                  />
                </div>
              </div>

              <div className="col-md-12 row mt-2">
                <label
                  htmlFor="boardWriter"
                  className="col-md-2 col-form-label"
                >
                  board Writer
                </label>
                <div className="col-md-10">
                  <input
                    type="text"
                    className="form-control"
                    id="boardWriter"
                    required
                    value={reply.boardWriter}
                    onChange={handleInputChange}
                    name="boardWriter"
                  />
                </div>
              </div>

              <div className="row px-4 mt-2">
                <button
                  onClick={saveReply}
                  className="btn btn-success mt-3 col-md-5"
                >
                  Submit
                </button>
                <div className="col-md-2"></div>

                <button
                  onClick={closeReply}
                  className="btn btn-danger mt-3 col-md-5"
                >
                  Close
                </button>
              </div>
            </div>
          )}
        </div>
        {/* reply form end */}
      </div>
    </div>
  );
}

export default ReplyBoardList;

4) 프론트 서버 시작 후 화면 확인

 

백엔드 작업

1) ReplyBoart 엔티티 생성

package com.example.simpledms.model.entity.normal;

import com.example.simpledms.model.common.BaseTimeEntity;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;

import javax.persistence.*;

/**
 * packageName : com.example.simpledms.model.entity.normal
 * fileName : ReplyBoard
 * author : GGG
 * date : 2023-10-26
 * description :
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-26         GGG          최초 생성
 */
@Entity
@Table(name="TB_REPLY_BOARD")
@SequenceGenerator(
        name = "SQ_REPLY_BOARD_GENERATOR"
        , sequenceName = "SQ_REPLY_BOARD"
        , initialValue = 1
        , allocationSize = 1
)
@Getter
@Setter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DynamicInsert
@DynamicUpdate
// soft delete
@Where(clause = "DELETE_YN = 'N'")
@SQLDelete(sql = "UPDATE TB_REPLY_BOARD SET DELETE_YN = 'Y', DELETE_TIME=TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') WHERE BID = ?")
public class ReplyBoard extends BaseTimeEntity {
    // 속성 추가
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "SQ_REPLY_BOARD_GENERATOR")
    private Integer bid;                    // 기본키, 시퀀스

    private String boardTitle;              // 제목

    private String boardContent;            // 내용

    private String boardWriter;             // 작성자

    private Integer viewCnt;                // 조회수

    private Integer boardGroup;             // 트리구조 최상위 부모 노드( 부모가 있을 경우 : 부모번호, 없을 경우 : 자신의 게시판번호 ) (정렬)

    private Integer boardParent;            // 자신의 부모 노드 ( 부모가 있을 경우 : 부모번호, 없을 경우 : 0 ) (핵심)

}

2) ReplyBoard Repository 생성 및 ReplyBoard DTO 생성

 

📝 ReplyBoardRepository.java

package com.example.simpledms.repository.normal;

import com.example.simpledms.model.dto.normal.ReplyBoardDto;
import com.example.simpledms.model.entity.normal.ReplyBoard;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

/**
 * packageName : com.example.simpledms.repository.normal
 * fileName : ReplyBoardRepository
 * author : GGG
 * date : 2023-10-26
 * description :
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-26         GGG          최초 생성
 */
@Repository
public interface ReplyBoardRepository extends JpaRepository<ReplyBoard, Integer> {
    //    계층형 조회(특수) 쿼리 : @Query(, nativeQuery=true)
    @Query(value = "SELECT BID           AS bid " +
            "     , LPAD(' ', (LEVEL-1))|| board_title   AS BoardTitle " +
            "     , board_content AS boardContent " +
            "     , board_writer  AS boardWriter " +
            "     , view_cnt      AS viewCnt " +
            "     , board_group   AS boardGroup " +
            "     , board_parent  AS boardParent " +
            "FROM TB_REPLY_BOARD " +
            "WHERE BOARD_TITLE LIKE '%'|| :boardTitle ||'%' " +
            "AND   DELETE_YN = 'N' " +
            "START WITH BOARD_PARENT = 0    " +
            "CONNECT BY PRIOR BID = BOARD_PARENT  " +
            "ORDER SIBLINGS BY BOARD_GROUP DESC", nativeQuery = true)
    Page<ReplyBoardDto> selectByConnectByPage(
            @Param("boardTitle") String boardTitle,
            Pageable pageable);

}

답변형 게시판의 경우 레포지토리 생성시 계층형 쿼리를 이용하여 코딩합니다.

-- 계층형 쿼리 : level 의사(가상)컬럼(예 : 부모 1 ~ 2,3,4 ...)
-- START WITH BOARD_PARENT(부모컬럼) = 0 (최초시작값: 부모)
-- CONNECT BY PRIOR BID(게시판 번호) = BOARD_PARENT(부모 번호)
SELECT BID          AS bid
    , LPAD('└', (LEVEL-1))|| board_title   AS BoardTitle
    , board_content AS BoardContent
    , board_writer  AS BoardWriter
    , view_cnt      AS viewCnt
    , board_group   AS boardGroup
    , board_parent  AS boardParent
FROM TB_REPLY_BOARD
WHERE BOARD_TITLE LIKE '%%'
AND   DELETE_YN = 'N'
START WITH BOARD_PARENT = 0 
CONNECT BY PRIOR BID = BOARD_PARENT 
ORDER SIBLINGS BY BOARD_GROUP DESC;

SQL Developer 에서 계층형 쿼리 실행 결과

댓글의 경우 └제목 으로 표시가 된것을 확인할 수 있음

 

📝 ReplyBoardDto.java

package com.example.simpledms.model.dto.normal;

/**
 * packageName : com.example.simpledms.model.dto.normal
 * fileName : ReplyBoardDto
 * author : GGG
 * date : 2023-10-26
 * description : 계층형 쿼리 DTO
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-26         GGG          최초 생성
 */
public interface ReplyBoardDto {
//    속성 x => getter 함수


    public Integer getBid();
    public String getBoardTitle();
    public String getBoardContent();
    public String getBoardWriter();
    public Integer getViewCnt();
    public Integer getBoardGroup();
    public Integer getBoardParent();

}

3) ReplyBoardService 생성

package com.example.simpledms.service.normal;

import com.example.simpledms.model.dto.normal.ReplyBoardDto;
import com.example.simpledms.repository.normal.ReplyBoardRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

/**
 * packageName : com.example.simpledms.service.normal
 * fileName : ReplyBoardService
 * author : GGG
 * date : 2023-10-26
 * description :
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-26         GGG          최초 생성
 */
@Service
public class ReplyBoardService {

    @Autowired
    ReplyBoardRepository replyBoardRepository;

    // 계층형 쿼리 조회(DTO) : like 검색
    public Page<ReplyBoardDto> selectByConnectByPage(String boardTitle, Pageable pageable){
        Page<ReplyBoardDto> page
                = replyBoardRepository.selectByConnectByPage(boardTitle, pageable);
        return page;
    }


}

 

4) ReplyBoardController 생성

package com.example.simpledms.controller.normal;

import com.example.simpledms.model.dto.normal.ReplyBoardDto;
import com.example.simpledms.model.entity.basic.Dept;
import com.example.simpledms.service.normal.ReplyBoardService;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
 * packageName : com.example.simpledms.controller.normal
 * fileName : ReplyBoardController
 * author : GGG
 * date : 2023-10-26
 * description :
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-26         GGG          최초 생성
 */
@RestController
@Slf4j
@RequestMapping("/api/normal")
public class ReplyBoardController {
    @Autowired
    ReplyBoardService replyBoardService;

    // 전체 조회(계층형, DTO) : LIKE 검색
    @GetMapping("/reply-board")
    public ResponseEntity<Object> selectByConnectByPage(
            @RequestParam(defaultValue = "") String boardTitle,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "3") int size

    ) {
        try {
            Pageable pageable = PageRequest.of(page, size);

            Page<ReplyBoardDto> replyBoardDtoPage
                    = replyBoardService
                    .selectByConnectByPage(boardTitle, pageable);

            Map<String, Object> response = new HashMap<>();
            response.put("replyBoard", replyBoardDtoPage.getContent());
            response.put("currentPage", replyBoardDtoPage.getNumber());
            response.put("totalItems", replyBoardDtoPage.getTotalElements());
            response.put("totalPages", replyBoardDtoPage.getTotalPages());

//            신호 보내기
            if (replyBoardDtoPage.isEmpty() == false) {
//                성공
                return new ResponseEntity<>(response, HttpStatus.OK);
            } else {
//                데이터 없음
                return new ResponseEntity<>(HttpStatus.NO_CONTENT);
            }


        } catch (Exception e) {
            log.debug(e.getMessage());
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
}

 

5) API 테스트

더보기
GET http://localhost:8000/api/normal/reply-board

HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 26 Oct 2023 07:13:59 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "totalItems": 8,
  "replyBoard": [
    {
      "boardWriter": "작성자",
      "boardParent": 0,
      "bid": 8,
      "boardTitle": "제목",
      "boardContent": "내용",
      "viewCnt": 0,
      "boardGroup": 8
    },
    {
      "boardWriter": "작성자",
      "boardParent": 0,
      "bid": 7,
      "boardTitle": "제목",
      "boardContent": "내용",
      "viewCnt": 0,
      "boardGroup": 7
    },
    {
      "boardWriter": "작성자",
      "boardParent": 0,
      "bid": 5,
      "boardTitle": "제목",
      "boardContent": "내용",
      "viewCnt": 0,
      "boardGroup": 5
    }
  ],
  "totalPages": 3,
  "currentPage": 0
}
응답 파일이 저장되었습니다.
> 2023-10-26T161359.200.json

Response code: 200; Time: 241ms (241 ms); Content length: 395 bytes (395 B)

6) 프론트 + 백엔드 연동테스트

반응형
반응형

📖 프론트엔드 작업

(1) Qna Type 지정

// IQna.ts : 타입 인터페이스

export default interface IQna {
    qno?: any | null,
    question: string,
    answer: string,
    questioner: string,
    answerer: string 
}

(2) Qna 게시판 CRUD 함수 생성을 위한 QnaService 생성

// QnaService.ts : axios 공통함수 (벡엔드 CRUD 연동함수)

import IQna from "../../types/basic/IQna";
import http from "../../utils/http-common";

// 전체 조회 + like 검색(paging 기능 : page(현재페이지), size(한 페이지당 개수))
// 셀렉트박스 : (question) 입력창 : 질문 like 검색
// 셀렉트박스 : (questioner) 입력창 : 질문자 like 검색
// 변수 : searchSelect(question, questioner)
// 변수 : searchKeyword : 검색어
const getAll = (searchSelect:string, searchKeyword:string, page:number, size:number) => {
    return http.get<Array<IQna>>(`/basic/qna?searchSelect=${searchSelect}&searchKeyword=${searchKeyword}&page=${page}&size=${size}`);
  };
  
  // 상세 조회
  const get = (qno:any) => {
    return http.get<IQna>(`/basic/qna/${qno}`);
  };
  
  // 저장 함수
  const create = (data:IQna) => {
    return http.post<IQna>("/basic/qna", data);
  };
  
  // 수정 함수
  const update = (qno:any, data:IQna) => {
    return http.put<any>(`/basic/qna/${qno}`, data);
  };
  
  // 삭제 함수
  const remove = (qno:any) => {
    return http.delete<any>(`/basic/qna/deletion/${qno}`);
  };
  
  const QnaService = {
    getAll,
    get,
    create,
    update,
    remove,
  };
  
  export default QnaService;

(3) Qna 화면 및 JS 코딩

// QnaList.tsx : rfce
import { Pagination } from "@mui/material";
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import TitleCom from "../../../components/common/TitleCom";
import IQna from "../../../types/basic/IQna";
import QnaService from "../../../services/basic/QnaServics";


function QnaList() {
  // 변수 정의
  // qna 배열 변수
  const [qna, setQna] = useState<Array<IQna>>([]);
  // select 태그에 선택된 값을 저장할 변수 : 기본 (question)
  const [searchSelect, setSearchSelect] = useState<string>("question");
  // 검색어(input) 변수
  const [searchKeyword, setSearchKeyword] = useState<string>("");

  // todo: 공통 페이징 변수 4개
  // todo: 공통 변수 : page(현재페이지번호), count(총페이지건수), pageSize(3,6,9 배열)
  const [page, setPage] = useState<number>(1);
  const [count, setCount] = useState<number>(1);
  const [pageSize, setPageSize] = useState<number>(3); // 1페이지당개수
  const pageSizes = [3, 6, 9]; // 공통 pageSizes : 배열 (셀렉트 박스 사용)

  // 함수 정의
  //   화면이 뜰때 실행되는 이벤트 + 감시변수
  useEffect(() => {
    retrieveQna(); // 전체조회 실행
  }, [page, pageSize]);

  // 전체조회
  const retrieveQna = () => {
    // 벡엔드 매개변수 전송 : + 현재페이지(page), 1페이지당개수(pageSize)
    QnaService.getAll(searchSelect, searchKeyword, page - 1, pageSize) // 벡엔드 전체조회요청
      .then((response: any) => {
        const { qna, totalPages } = response.data;
        setQna(qna);
        setCount(totalPages);
        // 로그 출력
        console.log("response", response.data);
      })
      .catch((e: Error) => {
        console.log(e);
      });
  };

  //   select 태그 수동바인딩
  const onChangeSearchSelect = (e: any) => {
    setSearchSelect(e.target.value); // 화면값 -> 변수저장
  };

  //   input 태그 수동바인딩
  const onChangeSearchKeyword = (e: any) => {
    setSearchKeyword(e.target.value); // 화면값 -> 변수저장
  };

  //   todo: 공통 페이징 함수 2개
  // todo: handlePageSizeChange(공통) : pageSize 값 변경시 실행되는 함수
  //  select 태그 수동 바인딩 : 화면값 -> 변수에 저장
  const handlePageSizeChange = (event: any) => {
    setPageSize(event.target.value); // 1페이지당 개수저장(3,6,9)
    setPage(1); // 현재페이지번호 : 1로 강제설정
  };

  //  todo: Pagination 수동 바인딩(공통)
  //  페이지 번호를 누르면 => page 변수에 값 저장
  const handlePageChange = (event: any, value: number) => {
    // value == 화면의 페이지번호
    setPage(value);
  };

  return (
    // 여기
    <>
      {/* 제목 start */}
      <TitleCom title="Qna List" />
      {/* 제목 end */}

      {/* question start(다양한 검색어 부분) */}
      <div className="row mb-5 justify-content-center">
        <div className="col-md-8">
          <div className="input-group mb-3">
            {/* 다양한 검색(select : question,questioner) 시작 */}
            <div className="col-2">
              <select
                className="form-select"
                onChange={onChangeSearchSelect}
                value={searchSelect}
              >
                <option key="question" value="question">
                  question
                </option>
                <option key="questioner" value="questioner">
                  questioner
                </option>
              </select>
            </div>
            {/* 다양한 검색(select) 끝 */}

            {/* 검색어(searchKeyword) 입력창(input) 시작 */}
            <div className="col-9  w-50 input-group mb-3">
              <input
                type="text"
                className="form-control"
                placeholder="Search by Question"
                value={searchKeyword}
                onChange={onChangeSearchKeyword}
              />
            </div>
            {/* 검색어 입력창 끝 */}

            {/* 검색버튼 시작 */}
            <div className="input-group-append col-md-1">
              <button
                className="btn btn-outline-secondary"
                type="button"
                onClick={retrieveQna}
              >
                Search
              </button>
            </div>
            {/* 검색버튼 끝 */}
          </div>
        </div>
      </div>
      {/* question end */}

      <div className="col-md-12">
        {/* page control start(페이징 html) */}
        <div className="mt-3">
          {"Items per Page: "}
          <select onChange={handlePageSizeChange} value={pageSize}>
            {pageSizes.map((size) => (
              <option key={size} value={size}>
                {size}
              </option>
            ))}
          </select>

          <Pagination
            className="my-3"
            count={count}
            page={page}
            siblingCount={1}
            boundaryCount={1}
            variant="outlined"
            shape="rounded"
            onChange={handlePageChange}
          />
        </div>
        {/* page control end */}

        {/* table start(본문) */}
        <table className="table">
          <thead>
            <tr>
              <th scope="col">Question</th>
              <th scope="col">Questioner</th>
              <th scope="col">Answer</th>
              <th scope="col">Answerer</th>
            </tr>
          </thead>
          <tbody>
            {qna &&
              qna.map((data) => (
                // 키값 추가 않하면 react 에서 경고를 추가 : 키는 내부적으로 리액트가 rerending 할때 체크하는 값임
                <tr key={data.question}>
                  <td>{data.question}</td>
                  <td>{data.questioner}</td>
                  <td>{data.answer}</td>
                  <td>{data.answerer}</td>
                  <td>
                    <Link to={"/qna/" + data.qno}>
                      <span className="badge bg-success">Edit</span>
                    </Link>
                  </td>
                </tr>
              ))}
          </tbody>
        </table>
        {/* table end */}
      </div>
    </>
  );
}

export default QnaList;

(4) 프론트 엔드 서버 시작 후 화면 확인

 

📖 벡엔드 작업

(1) Qna Entity 생성

package com.example.simpledms.model.entity.basic;

import com.example.simpledms.model.common.BaseTimeEntity;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;

import javax.persistence.*;

/**
 * packageName : com.example.simpledms.model.entity.basic
 * fileName : Qna
 * author : GGG
 * date : 2023-10-24
 * description : Qna 엔티티(vo)
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-24         GGG          최초 생성
 */
@Entity
@Table(name="TB_QNA")
@SequenceGenerator(
        name = "SQ_QNA_GENERATOR"
        , sequenceName = "SQ_QNA"
        , initialValue = 1
        , allocationSize = 1
)
@Getter
@Setter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DynamicInsert
@DynamicUpdate
// soft delete
@Where(clause = "DELETE_YN = 'N'")
@SQLDelete(sql = "UPDATE TB_QNA SET DELETE_YN = 'Y', DELETE_TIME=TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') WHERE QNO = ?")
public class Qna extends BaseTimeEntity {
    //    속성
//    단축키 : ctrl + shift + u
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE,
            generator = "SQ_QNA_GENERATOR")
    private Integer qno; // 기본키(@Id), 시퀀스

    private String question;

    private String answer;

    private String questioner;

    private String answerer;

}

🔎 빌더 패턴?

빌더 패턴은 여러 생성자 인수가 필요한 복잡한 개체를 만드는 방법을 제공하는 소프트웨어 디자인 패턴입니다. 선택적 매개변수가 많은 객체를 생성할 필요가 있을 때 자주 사용됩니다.

빌더 패턴은 여러 생성자 인수가 필요한 복잡한 개체를 만드는 문제를 해결하는 데 사용되는 생성 디자인 패턴입니다. 빌더 패턴은 객체의 구성과 표현을 분리합니다. 이를 통해 동일한 구성 프로세스에서 다른 표현을 만들 수 있습니다.

팩토리 패턴은 동일한 유형의 객체를 생성해야 할 때 사용됩니다. 빌더 패턴은 다른 유형의 객체를 생성해야 할 때 사용됩니다.

빌더 패턴에는 다음과 같은 이점이 있습니다.

  • 객체의 구성과 표현을 분리합니다. 이를 통해 동일한 구성 프로세스에서 다른 표현을 만들 수 있습니다.
  • 복잡한 개체를 단계별로 생성할 수 있습니다.
  • 클라이언트 코드에 영향을 주지 않고 구성 프로세스를 변경할 수 있습니다.

빌더 패턴에는 다음과 같은 단점이 있습니다.

  • 많은 상용구 코드로 이어질 수 있습니다.
  • 빌더 패턴을 올바르게 사용하지 않으면 결과 코드를 이해하기 어려울 수 있습니다.

🔎 빌더 패턴은 언제 사용할까?

빌더 패턴은 여러 생성자 인수가 필요한 복잡한 객체를 생성해야 할 때 사용해야 합니다. 선택적 매개변수가 많은 객체를 생성할 필요가 있을 때 자주 사용됩니다.

(2) QnaRepository 생성

package com.example.simpledms.repository.basic;

import com.example.simpledms.model.entity.basic.Qna;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
 * packageName : com.example.simpledms.repository.basic
 * fileName : QnaRepository
 * author : GGG
 * date : 2023-10-24
 * description : Qna CRUD Repository
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-24         GGG          최초 생성
 */
@Repository
public interface QnaRepository extends JpaRepository<Qna, Integer> {
//    1) question(질문) like 검색
    Page<Qna> findAllByQuestionContaining(String question, Pageable pageable);

//    2) questioner(질문자) like 검색
    Page<Qna> findAllByQuestionerContaining(String questioner, Pageable pageable);
}

(3) QnaService 생성

package com.example.simpledms.service.basic;

import com.example.simpledms.model.entity.basic.Qna;
import com.example.simpledms.repository.basic.QnaRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

/**
 * packageName : com.example.simpledms.service.basic
 * fileName : QnaService
 * author : GGG
 * date : 2023-10-24
 * description : Qna 서비스(업무로직 : 비지니스 로직)
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-24         GGG          최초 생성
 */
@Service
public class QnaService {
    @Autowired
    QnaRepository qnaRepository;    // DI

//    1) question like 검색
    public Page<Qna> findAllByQuestionContaining(String question, Pageable pageable) {
        Page<Qna> page = qnaRepository.findAllByQuestionContaining(question, pageable);
        return page;
    }

//    2) questioner like 검색
    public Page<Qna> find(String questioner, Pageable pageable){
        Page<Qna> page = qnaRepository.findAllByQuestionerContaining(questioner, pageable);
        return page;
    }


}

(4) QnaController 생성

package com.example.simpledms.controller.basic;

import com.example.simpledms.model.entity.basic.Dept;
import com.example.simpledms.model.entity.basic.Qna;
import com.example.simpledms.service.basic.QnaService;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
 * packageName : com.example.simpledms.controller.basic
 * fileName : QnaController
 * author : GGG
 * date : 2023-10-24
 * description :
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-24         GGG          최초 생성
 */
@RestController
@Slf4j
@RequestMapping("/api/basic")
public class QnaController {

    @Autowired
    QnaService qnaService;  // DI

//    전체조회 + question/questioner like 검색
    @GetMapping("/qna")
    public ResponseEntity<Object> findAllByContaining(
            @RequestParam(defaultValue = "") String searchSelect,
            @RequestParam(defaultValue = "") String searchKeyword,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "3") int size

    ) {
        try {
//            페이지 변수 저장 (page:현재페이지 번호, size: 한 페이지당 개수)
//            함수 매개변수 : Pageable(위의 값을 넣기)
//            사용법 : Pageable pageable = PageRequest.of(현재페이지번호, 한페이지당개수);
            Pageable pageable = PageRequest.of(page, size);
            Page<Qna> qnaPage;
            if(searchSelect.equals("question")){    // searchSelect가 question 이면
                qnaPage
                        = qnaService.findAllByQuestionContaining(searchSelect, pageable);   // question like 검색하기

            } else {
                qnaPage = qnaService.findAllByQuestionerContaining(searchKeyword, pageable); // 아니면 questioner like 검색하기
            }
//            question like 검색

//            리액트 전송 : qna배열, 페이징 정보 [자료구조 : Map<키이름, 값>]
            Map<String, Object> response = new HashMap<>();
            response.put("qna", qnaPage.getContent());                // qna배열 전송
            response.put("currentPage", qnaPage.getNumber());          // 현재페이지번호 전송
            response.put("totalItems", qnaPage.getTotalElements());    // 총 건수(개수) 전송
            response.put("totalPages", qnaPage.getTotalPages());       // 총 페이지수 전송

//            신호 보내기
            if (qnaPage.isEmpty() == false) {
//                성공
                return new ResponseEntity<>(response, HttpStatus.OK);
            } else {
//                데이터 없음
                return new ResponseEntity<>(HttpStatus.NO_CONTENT);
            }


        } catch (Exception e) {
            log.debug(e.getMessage());
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }


}

(5) 프론트 + 벡엔드 연동확인

1) 전체검색

2) Question like 검색 = 3으로 검색 시

3) Questioner like 검색 = '홍'으로 검색 시

반응형

'Spring Boot > 스프링부트 예제' 카테고리의 다른 글

답변형 게시판 구현 (2)  (1) 2023.10.27
답변형 게시판 만들기 (1)  (1) 2023.10.26
게시판 페이징 처리  (1) 2023.10.23
front + backend 게시판 CRUD 구현 (2)  (0) 2023.10.20
JPA - 연관관계 매핑  (0) 2023.10.19
반응형

이번에는 페이징 처리를 적용한 게시판을 만들어보겠습니다.

 

우선 DeptList 라는 페이지를 생성하고 변수를 정의합니다.

 

1) 부서 배열을 담을 변수 정의

const [dept, setDept] = useState<Array<IDept>>([]);

2) 상단 검색어 값을 담을 변수 정의

const [searchDname, setSearchDname] = useState<string>("");

3) 공통 변수 정의 : page(현재페이지 번호), count(총 페이지 건수), pageSize(3, 6, 9 배열)

  const [page, setPage] = useState<number>(1);
  const [count, setCount] = useState<number>(1);
  const [pageSize, setPageSize] = useState<number>(3); // 한 페이지당 개수

4) pageSizes 배열 정의 => selectbox에 3, 6, 9 선택 옵션

 const pageSizes = [3, 6, 9];

 

변수를 정의하였다면 게시판에 사용될 함수를 정의합니다.

 

1) useEffect를 사용하여 컴포넌트의 변수값이 변할 때 실행되는 함수 정의

  // TODO: 1) 컴포넌트가 mounted 될때 한번만 실행됨 : useEffect(() => {},[])
  // TODO: 2) 컴포넌트의 변수값이 변할 때 실행됨 : useEffect(() => {실행문},[감시할 변수])
  useEffect(() => {
    retrieveDept(); // 전체 조회
  }, [page, pageSize]);

2) 전체 조회 함수 정의

// 전체 조회 함수
  const retrieveDept = () => {
    // 벡엔드 매개변수 전송 : 현재페이지(page), 1페이지당 개수(pageSize)
    DeptService.getAll(searchDname, page -1, pageSize)  // 벡엔드 전체조회요청
    .then((response:any)=>{
      // 벡엔드 성공시 실행됨
      // es6(모던js) 문법 : 객체 분해 할당
      // 원래 코드
      // const dept =  response.data.dept;  // 부서배열
      // const totalPage = response.data.totalPages;  // 전체페이지수
      const { dept, totalPages } = response.data;
      // dept 저장
      setDept(dept);
      setCount(totalPages);
      // 로그 출력
      console.log("response", response.data);


    })
    .catch((e:Error)=>{
      // 벡엔드 실패시 실행됨
      console.log(e);
    })
  };

3) 검색어 수동 바인딩 함수 정의

  // 검색어 수동 바인딩 함수
  const onChangeSearchDname = (e: React.ChangeEvent<HTMLInputElement>) => {
    const searchDname = e.target.value;
    setSearchDname(searchDname);
  };

4) OnChange event 함수 정의

// handlePageSizeChange : pageSize 값 변경시 실행되는 함수
  // 수동바인딩 : 화면 값을 변수에 저장하는 것
  const handlePageSizeChange = (e: any) => { 
      setPageSize(e.target.value);  // 1 페이지당 개수 저장(3, 6, 9)
      setPage(1); // 현재페이지 번호 : 1로 강제설정


   }
   
   // todo Pagination 수동바인딩(공통)
  // page 번호를 누르면 => page 변수에 값 저장
  const handlePageChange = (e:any, value:number) => {
    // value == 화면의 페이지번호
    setPage(value);

  };

✅ 화면 확인

// 벡엔드 작업

벡엔드 프로젝트를 인텔리제이에서 생성

build.gradle 파일에 추가

//    오라클 라이브러리( 19c )
implementation 'com.oracle.database.jdbc:ucp:19.14.0.0'
implementation 'com.oracle.database.security:oraclepki:19.14.0.0'
implementation 'com.oracle.database.security:osdt_cert:19.14.0.0'
implementation 'com.oracle.database.security:osdt_core:19.14.0.0'
//    logback , log4jdbc 설정
implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'
implementation 'ch.qos.logback:logback-classic:1.2.11'
implementation 'org.slf4j:slf4j-api:1.7.36'
implementation 'org.slf4j:jcl-over-slf4j:1.7.36'

application.properties 파일 추가

# 서버 포트
server.port=8000

# 오라클 설정 : log4j 적용 : 도커
spring.datasource.driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy
spring.datasource.url=jdbc:log4jdbc:oracle:thin:@localhost:1521/xepdb1
spring.datasource.username=scott
spring.datasource.password=!Ds1234567890

## 오라클 설정 ( 오라클 클라우드 전자지갑 설정 ) : log4j 적용
#spring.datasource.driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy
## jdbc:log4jdbc:oracle:thin:@orcl_medium?TNS_ADMIN=전자지갑경로
#spring.datasource.url=jdbc:log4jdbc:oracle:thin:@orcl_low?TNS_ADMIN=전자지갑경로
#spring.datasource.username=scott
#spring.datasource.password=!Ds1234567890

# jpa 설정
spring.jpa.hibernate.ddl-auto=none
#spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect
spring.jpa.show-sql=true
# sql log 찍기
spring.jpa.properties.hibernate.format_sql=true
#Logging Setting , hibernate info 레벨 로깅 설정 : debug, trace 등
logging.level.org.hibernate=info
# batch size 설정 : 연관관계 설정 시 N+1 문제 최소화
#  여러 개의 SELECT 쿼리들을 하나의 IN 쿼리로 만들어줍
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
# 1) resource/data.sql 자동 실행 ( DML 실행 )
#  -> data.sql ( dml 실행 ), schema.sql ( ddl 실행 )
spring.jpa.defer-datasource-initialization=true
# 2)  resource/data.sql 자동 실행 ( DML 실행 )
#  -> data.sql ( dml 실행 ), schema.sql ( ddl 실행 )
spring.sql.init.mode=always
# sql 에러 무시하고 스프링 서버 로딩
spring.sql.init.continue-on-error=true

# 자바 소스 변경시 스프링 서버 자동 재시작
spring.devtools.restart.enabled=true

# todo :: HikariCP settings : DB 커넥션 풀(기본 10개) -> 기본 1개로 제한
spring.datasource.hikari.minimumIdle=1
spring.datasource.hikari.maximumPoolSize=1
spring.datasource.hikari.poolName=HikariPoolBooks

# todo :: file upload 최대 size 설정
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB

 

프로젝트 폴더 구성

// CORS 보안 허용 설정 파일 -> config 폴더에 저장

package com.example.simpledms.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * packageName : com.example.dongsungsi.controller
 * fileName : WebConfig
 * author : kangtaegyung
 * date : 2022/06/14
 * description :
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * -----------------------------------------------------------
 * 2022/06/14         kangtaegyung          최초 생성
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
//                아래 url 허용
                .allowedOrigins("http://localhost:3000")
//                .allowedOrigins("http://192.168.35.192:3000/")
//                Todo: 아래 추가해야 update, delete, insert, select 가 cors 문제가 안생김
                .allowedMethods(
                        HttpMethod.GET.name(),
                        HttpMethod.POST.name(),
                        HttpMethod.PUT.name(),
                        HttpMethod.DELETE.name(),
                        HttpMethod.PATCH.name()
                );
    }
}

// 공통 entity 인 BaseTimeEntity 추가

package com.example.simpledms.model.common;

import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * packageName : com.example.jpaexam.model
 * fileName : BaseTimeEntity
 * author : kangtaegyung
 * date : 2022/10/16
 * description : JPA 에서 자동으로 생성일자/수정일자 만들어주는 클래스
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * -----------------------------------------------------------
 * 2022/10/16         kangtaegyung          최초 생성
 */
@Getter
@Setter
// @MappedSuperclass : JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우
// 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 한다.
@MappedSuperclass
// @EntityListeners(AuditingEntityListener.class) : BaseTimeEntity 클래스에
// Auditing 기능을(자동 생성일, 수정일) 포함시킨다.
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    private String insertTime;

    private String updateTime;

    private String deleteYn;

    private String deleteTime;

    @PrePersist
        //해당 엔티티 저장하기 전
    void onPrePersist(){
        this.insertTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        this.deleteYn = "N";
    }

    @PreUpdate
        //해당 엔티티 수정 하기 전
    void onPreUpdate(){
        this.updateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        this.insertTime = this.updateTime;
        this.deleteYn = "N";
    }
}

// Dept entity 생성

package com.example.simpledms.model.entity.basic;

import com.example.simpledms.model.common.BaseTimeEntity;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;

import javax.persistence.*;

/**
 * packageName : com.example.modelexam.model
 * fileName : Dept
 * author : kangtaegyung
 * date : 2022/10/12
 * description : 부서 모델 클래스
 * 요약 :
 * Soft Delete는 물리적인 데이터 삭제로 발생할 수 있는 문제를 방지하고 쉽게 복원할 필요가 있거나 삭제된 데이터들을 보관하여 데이터로써 활용할 필요나 가치가 있는 경우에 사용
 * 실무에서는 법적으로 개인자료일 경우 3년 또는 1년이상 데이터를 보관할 의무가 있고 어길수 법적 문제가 생길 수 있음 -> 그래서 soft delete 방식을 대부분 구현하고 있음
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * -----------------------------------------------------------
 * 2022/10/12         kangtaegyung          최초 생성
 */
@Entity
@Table(name="TB_DEPT")
@SequenceGenerator(
        name = "SQ_DEPT_GENERATOR"
        , sequenceName = "SQ_DEPT"
        , initialValue = 1
        , allocationSize = 1
)
@Getter
@Setter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DynamicInsert
@DynamicUpdate
// soft delete
@Where(clause = "DELETE_YN = 'N'")
@SQLDelete(sql = "UPDATE TB_DEPT SET DELETE_YN = 'Y', DELETE_TIME=TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') WHERE DNO = ?")
public class Dept extends BaseTimeEntity {
    //    부서넘버
//    @Id : Primary Key 에 해당
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE
            , generator = "SQ_DEPT_GENERATOR"
    )
    private Integer dno;

    //    부서이름
    private String dname;

    //    부서위치
    private String loc;
}

// DeptRepository 생성

package com.example.simpledms.repository.basic;

import com.example.simpledms.model.entity.basic.Dept;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
 * packageName : com.example.simpledms.repository.basic
 * fileName : DeptRepository
 * author : GGG
 * date : 2023-10-23
 * description : JPA CRUD 인터페이스
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-23         GGG          최초 생성
 */
@Repository
public interface DeptRepository extends JpaRepository<Dept, Integer> {
    // dname like 검색 : 쿼리메소드 + 페이징(리턴: Page, 매개변수: Pageable) 처리
    Page<Dept> findAllByDnameContaining(String dname, Pageable pageable);

}

//DeptService 생성

package com.example.simpledms.service.basic;

import com.example.simpledms.model.entity.basic.Dept;
import com.example.simpledms.repository.basic.DeptRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

/**
 * packageName : com.example.simpledms.service.basic
 * fileName : DeptService
 * author : GGG
 * date : 2023-10-23
 * description : 부서 서비스
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-23         GGG          최초 생성
 */
@Service
public class DeptService {

    @Autowired
    DeptRepository deptRepository;  // DI

    //    전체조회
    public Page<Dept> findAll(Pageable pageable) {
        Page<Dept> page = deptRepository.findAll(pageable);
        return page;

    }


    //    dname like 조회 + paging
    public Page<Dept> findAllByDnameContaining(String dname, Pageable pageable) {
        Page<Dept> page
                = deptRepository.findAllByDnameContaining(dname, pageable);
        return page;

    }


}

// DeptController 생성

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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
 * packageName : com.example.simpledms.controller.basic
 * fileName : DeptController
 * author : GGG
 * date : 2023-10-23
 * description : 부서 컨트롤러
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-23         GGG          최초 생성
 */
@RestController
@Slf4j
@RequestMapping("/api/basic")
public class DeptController {

    @Autowired
    DeptService deptService;    // DI

    // 전체 조회 + dname like 검색
    @GetMapping("/dept")
    public ResponseEntity<Object> findAllByDnameContaining(
            @RequestParam(defaultValue = "") String dname,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "3") int size

    ) {
        try {
//            페이지 변수 저장 (page:현재페이지 번호, size: 한 페이지당 개수)
//            함수 매개변수 : Pageable(위의 값을 넣기)
//            사용법 : Pageable pageable = PageRequest.of(현재페이지번호, 한페이지당개수);
            Pageable pageable = PageRequest.of(page, size);

//            전체조회(dname="") + like 검색(dname="S")
            Page<Dept> deptPage
                    = deptService.findAllByDnameContaining(dname, pageable);

//            리액트 전송 : 부서배열, 페이징 정보 [자료구조 : Map<키이름, 값>]
            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){
            log.debug(e.getMessage());
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }



}

// 서버 시작 후 전체조회 controller API 테스트

GET http://localhost:8000/api/basic/dept

HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 23 Oct 2023 04:21:59 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "totalItems": 4,
  "totalPages": 2,
  "dept": [
    {
      "insertTime": "2023-10-23 03:46:21",
      "updateTime": null,
      "deleteYn": "N",
      "deleteTime": null,
      "dno": 50,
      "dname": "ACCOUNTING",
      "loc": "NEW YORK"
    },
    {
      "insertTime": "2023-10-23 03:46:21",
      "updateTime": null,
      "deleteYn": "N",
      "deleteTime": null,
      "dno": 60,
      "dname": "RESEARCH",
      "loc": "DALLAS"
    },
    {
      "insertTime": "2023-10-23 03:46:21",
      "updateTime": null,
      "deleteYn": "N",
      "deleteTime": null,
      "dno": 70,
      "dname": "SALES",
      "loc": "CHICAGO"
    }
  ],
  "currentPage": 0
}
응답 파일이 저장되었습니다.
> 2023-10-23T132159.200.json

Response code: 200; Time: 31ms (31 ms); Content length: 451 bytes (451 B)

 

// 프론트 + 벡엔드 동시 실행 후 화면 출력 테스트

반응형

'Spring Boot > 스프링부트 예제' 카테고리의 다른 글

답변형 게시판 만들기 (1)  (1) 2023.10.26
QnA 다양한 검색 게시판 CRUD (1)  (1) 2023.10.24
front + backend 게시판 CRUD 구현 (2)  (0) 2023.10.20
JPA - 연관관계 매핑  (0) 2023.10.19
JPA 페이징 처리  (1) 2023.10.18
반응형

https://devjunyeok.tistory.com/186#3)%20%EB%B6%80%EC%84%9C(Department)%20%EA%B2%8C%EC%8B%9C%ED%8C%90%20%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0-1

 

front + backend 게시판 CRUD 구현 (1)

안녕하세요 ! :) front + backend 를 통합한 게시판을 생성하는 예제를 만들어보겠습니다. 📖 frontend작업 프론트 작업은 VSCode를 이용하여 프로젝트를 생성하였습니다. frontend 프로젝트는 React + typescri

devjunyeok.tistory.com

지난시간에 이어 이번에는 부서 게시판 기능 중 부서 추가 페이지를 생성해보겠습니다 :)

 

먼저 프론트 프로젝트 작업부터 시작하겠습니다.

 

📖 프론트 작업

1) pages > dept-nop > AddDeptNop.tsx 파일 생성

이 페이지는 부서를 추가하는 기능을 하는 페이지 입니다. 그래서 번저 함수 안에 변수를 정의합니다.

// todo ) 새 부서(객체 1개) 저장 페이지
function AddDeptNop() {
    // 변수정의
    // todo : 초기화 객체
    const initialDept = {
        dno: null,
        dname: "",
        loc: "",
    }

그리고 부서를 담을 변수와 저장하면 바뀌는 변수 (true/false)를 선언합니다.

// todo 바인딩 변수
    // todo 새부서 객체 변수
    const [dept, setDept] = useState<IDept>(initialDept);
    // todo 저장하면 true, 아니면 false 인 변수(값에따라 화면바뀜)
    const [submitted, setSubmitted] = useState<boolean>(false);

그리고 아래에 함수를 정의합니다.

함수는 새로운 입력 폼을 보여주는 함수와 그리고 각 입력창에 수동 바인딩 공통함수와 저장함수를 작성합니다.

 

  // 함수정의
    // todo 새로운 폼(form)을 보여주는 함수
    const newDept = () => { 
        // 새 폼 == 객체 초기화, submitted 변수 초기화(false)
        setDept(initialDept);   // 객체 초기화
        setSubmitted(false);   
     }
     // todo 각각의 입력창 수동 바인딩 공통함수
     const handleInputChange = (e:React.ChangeEvent<HTMLInputElement>) => { 
        const { name, value } = e.target;   // 화면 값[이름]
        // 화면 값을 Dept 객체의 속성에 저장
        setDept({...dept, [name]: value});

      }
      // todo 저장 함수
      const saveDept = () => { 
        // 임시 부서 변수(저장될 객체)
        var data = {
            dname: dept.dname,
            loc: dept.loc
        }
        // 저장 함수 호출
        DeptService.create(data)   // 벡엔드로 저장 요청
        .then((response:any)=>{
            // 저장 성공유무 -> submitted 변수에 값을 true로 변경
            setSubmitted(true); // 화면 변경
            console.log(response.date);
        })
        .catch((e:Error)=>{
            console.log(e);
        })
       }

그리고 리턴 값 아래의 HTML 작성은 다음과 같이 하였습니다.

return (
    <div className="row">
      {submitted ? (
        // 저장버튼 클릭하면 아래 화면이 보임
        <div className="col-6 mx-auto">
          <h4>You submitted successfully!</h4>
          {/* Add 버튼 클릭하면 다시 새로운 부서 저장 페이지로 이동(새폼 보이기) */}
          <button className="btn btn-success" onClick={newDept}>
            Add
          </button>
        </div>
      ) : (
        <>
          {/* 제목 start */}
          <TitleCom title="Add Dept No Page" />
          {/* 제목 end */}

          <div className="col-6 mx-auto">
            {/* 부서명 입력창 */}
            <div className="row g-3 align-items-center mb-3">
              <div className="col-3">
                <label htmlFor="dname" className="col-form-label">
                  Dname
                </label>
              </div>

              <div className="col-9">
                <input
                  type="text"
                  id="dname"
                  required
                  className="form-control"
                  value={dept.dname}
                  onChange={handleInputChange}
                  placeholder="dname"
                  name="dname"
                />
              </div>
            </div>
            {/* 부서명 입력창 끝*/}
            {/* 부서위치 입력창 시작 */}
            <div className="row g-3 align-items-center mb-3">
              <div className="col-3">
                <label htmlFor="loc" className="col-form-label">
                  Loc
                </label>
              </div>
              <div className="col-9">
                <input
                  type="text"
                  id="loc"
                  required
                  className="form-control"
                  value={dept.loc}
                  onChange={handleInputChange}
                  placeholder="loc"
                  name="loc"
                />
              </div>
            </div>
            {/* 부서위치 입력창 끝 */}

            {/* 저장버튼 시작 */}
            <div className="row g-3 mt-3 mb-3">
              <button
                onClick={saveDept}
                className="btn btn-outline-primary ms-2 col"
              >
                Submit
              </button>
            </div>
            {/* 저장버튼 끝 */}
          </div>
          
        </>
      )}
    </div>
  );
}

export default AddDeptNop;

App.tsx 파일의 AddDeptNop 페이지 라우트 처리

 {/* dept */}
          <Route path="/dept-nop" element={<DeptListNop />} />
          <Route path="/add-dept-nop" element={<AddDeptNop />} />

📖 벡엔드 작업

1) service > DeptService.java 파일에 전체조회, 검색어 조회 함수를 작성합니다.

@Service
public class DeptService {
    @Autowired
    DeptRepository deptRepository;  // DI

    /** 전체조회 */
    public List<Dept> findAll(){
        List<Dept> list = deptRepository.findAll();
        return list;
    }

    /** 검색어(dname like) 조회 함수 */
    public List<Dept> findAllByDnameContaining(String dname){
        List<Dept> list = deptRepository.findAllByDnameContaining(dname);
        return list;
    }

2) controller > DeptController.java 파일에 전체 조회 함수를 작성합니다.

@Slf4j
@RestController
@RequestMapping("/api")
public class DeptController {
    @Autowired
    DeptService deptService;    // DI

    /**
     * 전체 조회 + like 검색
     */
    @GetMapping("/dept")
    public ResponseEntity<Object> getDeptAll(
            @RequestParam(defaultValue = "") String dname) {
        try {
            // 전체 조회 + like 검색
            List<Dept> list = deptService.findAllByDnameContaining(dname);
            if (list.isEmpty() == false) {
//                성공
                return new ResponseEntity<>(list, HttpStatus.OK);
            } else {
//                데이터 없음
                return new ResponseEntity<>(HttpStatus.NO_CONTENT);
            }
        } catch (Exception e) {
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

3) API 테스트 - 아래 화면과 같이 GET 메서드로 테스트 합니다.

결과

더보기
GET http://localhost:8000/api/dept

HTTP/1.1 200 
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 20 Oct 2023 07:42:35 GMT
Keep-Alive: timeout=60
Connection: keep-alive

[
  {
    "insertTime": "2023-10-20 07:42:32",
    "updateTime": null,
    "deleteYn": "N",
    "deleteTime": null,
    "dno": 50,
    "dname": "ACCOUNTING",
    "loc": "NEW YORK"
  },
  {
    "insertTime": "2023-10-20 07:42:32",
    "updateTime": null,
    "deleteYn": "N",
    "deleteTime": null,
    "dno": 60,
    "dname": "RESEARCH",
    "loc": "DALLAS"
  },
  {
    "insertTime": "2023-10-20 07:42:32",
    "updateTime": null,
    "deleteYn": "N",
    "deleteTime": null,
    "dno": 70,
    "dname": "SALES",
    "loc": "CHICAGO"
  },
  {
    "insertTime": "2023-10-20 07:42:32",
    "updateTime": null,
    "deleteYn": "N",
    "deleteTime": null,
    "dno": 80,
    "dname": "OPERATIONS",
    "loc": "BOSTON"
  }
]
응답 파일이 저장되었습니다.
> 2023-10-20T164235.200.json

Response code: 200; Time: 234ms (234 ms); Content length: 529 bytes (529 B)

4) 프론트엔드 + 벡엔드 연동 확인

프론트 엔드 서버와 벡엔드 서버를 재시작하여 전체 조회 기능을 확인합니다.

반응형

'Spring Boot > 스프링부트 예제' 카테고리의 다른 글

QnA 다양한 검색 게시판 CRUD (1)  (1) 2023.10.24
게시판 페이징 처리  (1) 2023.10.23
JPA - 연관관계 매핑  (0) 2023.10.19
JPA 페이징 처리  (1) 2023.10.18
JPA - JPQL(Java Persistence Query Language)  (1) 2023.10.17
반응형

안녕하세요 ! :)

front + backend 를 통합한 게시판을 생성하는 예제를 만들어보겠습니다.

 

📖 frontend작업

프론트 작업은 VSCode를 이용하여 프로젝트를 생성하였습니다.

frontend 프로젝트는 React + typescript를 이용하였습니다.

프로젝트 형태는 다음과 같습니다.

src 폴더 아래에는 다음과 같은 파일들이 들어갑니다.

  • assets(css파일, 이미지파일, javascript 파일)
  • compoents(common(헤더, 푸터, 타이틀))
  • page(auth(로그인관련), 각종 메뉴 페이지들)
  • common(공통으로 사용할 페이지 관련)
  • service(벡엔드에 요청할 서비스 함수작성 파일)
  • types(자바의 모델과 같은 파일)
  • utils(백앤드 서버와 연동) 파일을 생성하였습니다.

1) 패키지 설치

# 설치 패키지 
# 1) 메뉴 라이브러리 설치
npm i react-router-dom

# 2) 벡엔드 연동 라이브러리 설치 : ajax 업그레이드
npm i axios

# 3) pre css 컴파일러 : node-sass -> 더이상 안씀 : sass 설치할것
<!-- npm i node-sass -->
npm install sass
# 4) Material Page component 업그레이드 
# 과거 v4 -> v5 변경 설치
npm i @mui/material @emotion/react @emotion/styled

# 4-1) 소스에서 임포트 사용법 : <Pagination />
import Pagination from '@mui/material/Pagination';

# 5) typescript jquery, jqueryui type 넣기
# 5-1) typescript jquery 사용
npm i --save-dev @types/jquery

2) axios 설정

📂 utils > http-commons.ts

이곳에서는 axios를 이용하여 벡엔드와 연동하는 작업을 하는 곳입니다.

import axios from "axios";

// todo : baseURL: "http://스프링ip주소:스프링포트번호/공통url"
export default axios.create({
  baseURL: "http://localhost:8000/api",
  headers: {
    "Content-Type": "application/json"
  }
});

base url에는 스프링부트주소를 입력합니다. 주소뒤에는 공통url인 api로 지정하였습니다.

 

3) 부서(Department) 게시판 생성하기

1) 부서 인터페이스 생성

📂 types > IDept.ts 생성

// 자바의 모델 클래스와 같음
// 인터페이스 : 속성의 자료형을 미리 지정하는 것
export default interface IDept {
    dno?: any | null,
    dname: string,
    loc: string
}

2) 부서 서비스 생성

📂 service > DeptService.ts 생성

// DeptService.ts
// axios 통신을 위한 import 작업
import http from "../utils/http-common";
// type 인터페이스 import
import IDept from "../types/IDept";

// 화살표 함수 단축키 : nfn
/** 전체 조회 요청 함수 */
const getAll = () => {
  // 조회요청 : .get("/url")
  // 사용법 : http.get<리턴타입>("스프링부트의 컨트롤러url");
  return http.get<Array<IDept>>("/dept");
};

/** 상세 조회(1건조회) 요청 함수 : 기본키 */
//  매개변수로는 부서번호(dno) type는 any
const get = (dno: any) => {
  return http.get<IDept>(`/dept/${dno}`);
};

/** 저장 요청 함수 */
const create = (data: IDept) => {
  return http.post<IDept>("/dept", data);
};

/** 수정 요청 함수 : 기본키와 객체 필요 */
const update = (dno: any, data: IDept) => {
  return http.put<any>(`/dept/${dno}`, data);
};

/** 삭제 요청 함수 : 기본키(dno) */
const remove = (dno: any) => {
  return http.delete<any>(`/dept/deletion/${dno}`);
};

/** 부서명 검색 요청 함수 */
const findByDname = (dname: string) => {
  return http.get<Array<IDept>>(`/dept?dname=${dname}`);
};


// 함수 기능 내보내기
const DeptService = {
    getAll,
    get,
    create,
    update,
    remove,
    findByDname
}

export default DeptService;

3) 부서페이지 생성

📂 page> DeptListNop.tsx 생성

// DeptListNop.tsx

import React from 'react'

function DeptListNop() {
  return (
    <div>DeptListNop</div>
  )
}

export default DeptListNop

3-1) App.ts 페이지 라우터 처리

import React from "react";
// app css import
import "./assets/css/app.css";

import HeaderCom from "./components/common/HeaderCom";
import { Route, Routes } from "react-router-dom";
import Home from "./pages/Home";
import Login from "./pages/auth/Login";
import Register from "./pages/auth/Register";
import ForgotPassword from "./pages/auth/ForgotPassword";
import NotFound from "./pages/common/NotFound";
import DeptListNop from "./pages/dept-nop/DeptListNop";
import EmpListNop from "./pages/emp-nop/EmpListNop";

function App() {
  return (
    <div className="App">
      <HeaderCom />

      {/* <!-- 구분 막대 시작 --> */}
      <div className="gutter text-center text-muted fade-in-box">
        <div>클론 코딩 예제 사이트에 오신 것을 환영합니다.</div>
      </div>
      {/* <!-- 구분 막대 끝 --> */}

      <div id="content-wrapper">
        {/* 라우터 정의 시작 */}
        <Routes>
          {/* login */}
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />
          <Route path="/register" element={<Register />} />
          <Route path="/forgot-password" element={<ForgotPassword />} />

          {/* dept */}
          <Route path="/dept-nop" element={<DeptListNop />} />

          {/* emp */}
          <Route path="/emp-nop" element={<EmpListNop />} />

          {/* NotFound */}
          <Route path="*" element={<NotFound />} />
        </Routes>
        {/* 라우터 정의 끝 */}
      </div>
    </div>
  );
}

export default App;

4) 서버 시작

5) 리스트 페이지 변수 및 함수 정의

// DeptListNop.tsx

import React, { useEffect, useState } from "react";
import TitleCom from "../../components/common/TitleCom";
import { Link } from "react-router-dom";
import IDept from "../../types/IDept";
import DeptService from "./../../services/DeptService";

function DeptListNop() {
  // 변수 정의
  // todo: 부서 배열 변수
  const [dept, setDept] = useState<Array<IDept>>([]);
  // todo: 검색어 변수
  const [searchDname, setSearchDname] = useState<string>("");

  // 함수 정의
  // todo: useEffect : 화면이 뜨자마자 실행되는 이벤트 함수(1번)
  // 사용법 : useEffect(()=>{실행문},[])
  useEffect(() => {
    // 전체 조회 실행
    retrieveDept();
  }, []);

  // todo) 검색어 수동 바인딩 함수
  const onChangeSearchDname = (e: any) => {
    // todo) e.target : input 태그에 현재 걸린 이벤트
    // => e.target.value : 현재 조작하는 태그의 value 값
    setSearchDname(e.target.value);
  };

  // todo) 전체 조회 함수
  const retrieveDept = () => {
    DeptService.getAll() // backend에 전체조회요청
      .then((response: any) => {
        // todo 성공했을때 처리
        setDept(response.data);
        // todo 로그 찍기
        console.log("response", response.data);
      })
      .catch((e: Error) => {
        // todo 실패했을때 처리
        console.log(e);
      });
  };

  // todo) 검색어 조회 함수
  const findByDname = () => {
    DeptService.findByDname(searchDname) // backend 요청
      .then((response: any) => {
        // todo 성공했을때 처리
        setDept(response.data);
        // todo 로그 찍기
        console.log("response", response.data);
      })
      .catch((e: Error) => {
        // todo 실패했을때 처리
        console.log(e);
      });
  };

  return (
    <>
      {/* 제목 보여주는 공통 컴포넌트 */}
      <TitleCom title="Dept List No Page" />
      {/* 제목 end */}

      {/* dname start(검색창) */}
      <div className="row mb-5 justify-content-center">
        {/* w-50 : 크기 조정, mx-auto : 중앙정렬(margin: 0 auto), justify-content-center */}
        <div className="col-12 w-50 input-group mb-3">
          {/* 입력창 시작 */}
          <input
            type="text"
            className="form-control"
            placeholder="Search by dname"
            value={searchDname}
            onChange={onChangeSearchDname}
          />
          {/* 입력창 끝 */}

          {/* 검색버튼 시작 */}
          <div className="input-group-append">
            <button
              className="btn btn-outline-secondary"
              type="button"
              onClick={findByDname}
            >
              Search
            </button>
          </div>
          {/* 검색버튼 끝 */}
        </div>
      </div>
      {/* dname end */}

      {/* table start(본문) */}
      <div className="col-md-12">
        {/* table start */}
        <table className="table">
          {/* 테이블 제목 시작 */}
          <thead className="table-light">
            <tr>
              <th scope="col">Dname</th>
              <th scope="col">Loc</th>
              <th scope="col">Actions</th>
            </tr>
          </thead>
          {/* 테이블 제목 끝 */}
          {/* TODO: 테이블 데이터 시작 */}
          <tbody>
            {dept &&
              dept.map((data) => (
                <tr key={data.dno}>
                  <td>{data.dname}</td>
                  <td>{data.loc}</td>
                  <td>
                    <Link to={"/dept-nop/" + data.dno}>
                      <span className="badge bg-success">Edit</span>
                    </Link>
                  </td>
                </tr>
              ))}
          </tbody>
          {/* 테이블 데이터 끝 */}
        </table>
        {/* table end */}
      </div>
      {/* table end */}
    </>
  );
}

export default DeptListNop;

6) 서버 재시작 및 결과 확인

📖 backend 작업

frontend 작업 후 backend 작업을 해봅시다 :)

backend 작업은 인텔리제이에서 프로젝트를 생성하였습니다.

 

1) backend 프로젝트 생성

프로젝트 이름은 SimpleDMS로 정하고 위치는 프론트엔드 작업물과 같은 폴더 위치로 지정합니다.

JDK는 azul-11 버전, Java 버전은 11버전으로 하고 패키지 생성은 War 파일로 선택하였습니다.

 

스프링부트 버전은 2.7.16 으로 하고 종속성 추가는

  1. Spring Boot DevTools
  2. Lombok
  3. Spring Web
  4. Spring Data JPA
  5. Oracle Driver

를 선택후 프로젝트를 생성합니다.

 

2) backend 프로젝트 환경설정

2-1) build.gradle 파일에 dependencies 추가 

//    오라클 라이브러리( 19c )
implementation 'com.oracle.database.jdbc:ucp:19.14.0.0'
implementation 'com.oracle.database.security:oraclepki:19.14.0.0'
implementation 'com.oracle.database.security:osdt_cert:19.14.0.0'
implementation 'com.oracle.database.security:osdt_core:19.14.0.0'
//    logback , log4jdbc 설정
implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'
implementation 'ch.qos.logback:logback-classic:1.2.11'
implementation 'org.slf4j:slf4j-api:1.7.36'
implementation 'org.slf4j:jcl-over-slf4j:1.7.36'

2-2) 프로퍼티 파일 한글 깨짐 방지를 위한 설정

설정 > 에디터 > 파일 인코딩 > 프로퍼티 파일에 대한 디폴트 인코딩을 UTF-8로 저장 후 아래 체크박스를 체크한후 확인

2-3) 프로퍼티 파일 설정

main > resources > application.properties 파일에 설정을 추가합니다.

# 서버 포트
server.port=8000

# 오라클 설정 : log4j 적용
spring.datasource.driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy
spring.datasource.url=jdbc:log4jdbc:oracle:thin:@localhost:오라클서버포트/db명
spring.datasource.username=서버id
spring.datasource.password=서버패스워드

# jpa 설정
spring.jpa.hibernate.ddl-auto=none
#spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect
spring.jpa.show-sql=true
# sql log 찍기
spring.jpa.properties.hibernate.format_sql=true
#Logging Setting , hibernate info 레벨 로깅 설정 : debug, trace 등
logging.level.org.hibernate=info
# batch size 설정 : 연관관계 설정 시 N+1 문제 최소화
#  여러 개의 SELECT 쿼리들을 하나의 IN 쿼리로 만들어줌
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
# 1) resource/data.sql 자동 실행 ( DML 실행 )
#  -> data.sql ( dml 실행 ), schema.sql ( ddl 실행 )
spring.jpa.defer-datasource-initialization=true
# 2)  resource/data.sql 자동 실행 ( DML 실행 )
#  -> data.sql ( dml 실행 ), schema.sql ( ddl 실행 )
spring.sql.init.mode=always
# sql 에러 무시하고 스프링 서버 로딩
spring.sql.init.continue-on-error=true

# 자바 소스 변경시 스프링 서버 자동 재시작
spring.devtools.restart.enabled=true

# HikariCP settings
spring.datasource.hikari.minimumIdle=1
spring.datasource.hikari.maximumPoolSize=1
spring.datasource.hikari.poolName=HikariPoolBooks

 

2-4) 실습용 스크립트 logback 추가

실습용 스크립트 파일 및 logback 파일을 resources 파일 아래에 추가합니다.

1. data.sql

더보기
INSERT INTO TB_DEPT
VALUES (SQ_DEPT.nextval, 'ACCOUNTING', 'NEW YORK','N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_DEPT
VALUES (SQ_DEPT.nextval, 'RESEARCH', 'DALLAS', 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_DEPT
VALUES (SQ_DEPT.nextval, 'SALES', 'CHICAGO', 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_DEPT
VALUES (SQ_DEPT.nextval, 'OPERATIONS', 'BOSTON', 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);

INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'SMITH', 'CLERK', 7902, TO_CHAR(to_date('17-12-1980', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 800, NULL,
        20, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'ALLEN', 'SALESMAN', 7698, TO_CHAR(to_date('20-2-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 1600,
        300, 30, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'WARD', 'SALESMAN', 7698, TO_CHAR(to_date('22-2-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 1250, 500,
        30, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'JONES', 'MANAGER', 7839, TO_CHAR(to_date('2-4-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 2975, NULL,
        20, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'MARTIN', 'SALESMAN', 7698, TO_CHAR(to_date('28-9-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 1250,
        1400, 30, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'BLAKE', 'MANAGER', 7839, TO_CHAR(to_date('1-5-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 2850, NULL,
        30, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'CLARK', 'MANAGER', 7839, TO_CHAR(to_date('9-6-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 2450, NULL,
        10, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'SCOTT', 'ANALYST', 7566, TO_CHAR(to_date('13-07-1987', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 3000,
        NULL, 20, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'KING', 'PRESIDENT', NULL, TO_CHAR(to_date('17-11-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 5000,
        NULL, 10, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'TURNER', 'SALESMAN', 7698, TO_CHAR(to_date('8-9-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 1500, 0,
        30, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'ADAMS', 'CLERK', 7788, TO_CHAR(to_date('13-07-1987', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 1100, NULL,
        20, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'JAMES', 'CLERK', 7698, TO_CHAR(to_date('3-12-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 950, NULL,
        30, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'FORD', 'ANALYST', 7566, TO_CHAR(to_date('3-12-1981', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 3000, NULL,
        20, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);
INSERT INTO TB_EMP
VALUES (SQ_EMP.nextval, 'MILLER', 'CLERK', 7782, TO_CHAR(to_date('23-1-1982', 'dd-mm-yyyy'), 'YYYY-MM-DD HH24:MI:SS'), 1300, NULL,
        10, 'N', TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS'),NULL, NULL);

COMMIT;

 

2. log4j 프로퍼티 파일

더보기
log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator
log4jdbc.dump.sql.maxlinelength=0

3. logback xml 파일

더보기
<?xml version="1.0" encoding="UTF-8"?>
<configuration debug="true">

	<!-- Appenders -->
	<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
		<encoder>
			<Pattern>%d %5p [%c] %m%n</Pattern>
		</encoder>
	</appender>

	<appender name="console-infolog" class="ch.qos.logback.core.ConsoleAppender">
		<encoder>
			<Pattern>%d %5p %m%n</Pattern>
		</encoder>
	</appender>

	<!-- Logger -->
	<logger name="com.example.simpledms" level="DEBUG" appender-ref="console" />
	<logger name="jdbc.sqlonly" level="INFO" appender-ref="console-infolog" />
	<logger name="jdbc.resultsettable" level="INFO" appender-ref="console-infolog" />

	<!-- Root Logger -->
	<root level="off">
		<appender-ref ref="console" />
	</root>
</configuration>

4. schema.sql 파일

더보기
DROP SEQUENCE SQ_DEPT;
CREATE SEQUENCE SQ_DEPT START WITH 50 INCREMENT BY 10;

DROP SEQUENCE SQ_EMP;
CREATE SEQUENCE SQ_EMP START WITH 8000 INCREMENT BY 1;

DROP TABLE TB_EMP CASCADE CONSTRAINT;
DROP TABLE TB_DEPT CASCADE CONSTRAINT;

CREATE TABLE TB_DEPT
(
    DNO         NUMBER NOT NULL PRIMARY KEY,
    DNAME       VARCHAR2(255),
    LOC         VARCHAR2(255),
    DELETE_YN   VARCHAR2(1) DEFAULT 'N',
    INSERT_TIME VARCHAR2(255),
    UPDATE_TIME VARCHAR2(255),
    DELETE_TIME VARCHAR2(255)
);

CREATE TABLE TB_EMP
(
    ENO         NUMBER NOT NULL PRIMARY KEY,
    ENAME       VARCHAR2(255),
    JOB         VARCHAR2(255),
    MANAGER     NUMBER,
    HIREDATE    VARCHAR2(255),
    SALARY      NUMBER,
    COMMISSION  NUMBER,
    DNO         NUMBER,
    DELETE_YN   VARCHAR2(1) DEFAULT 'N',
    INSERT_TIME VARCHAR2(255),
    UPDATE_TIME VARCHAR2(255),
    DELETE_TIME VARCHAR2(255)
);

2-5) com.example.simpledms > 패키지 폴더 생성

아래와 같이 설정, 컨트롤러, 모델, 레포지토리, 서비스 폴더를 생성합니다.

 

3) 파일 생성

3-1) model > BaseTimeEntity.java 생성

📂 model > BaseTimeEntity.java

더보기
package com.example.simpledms.model;

import lombok.Getter;
import org.hibernate.annotations.SQLDelete;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * packageName : com.example.jpaexam.model
 * fileName : BaseTimeEntity
 * author : kangtaegyung
 * date : 2022/10/16
 * description : JPA 에서 자동으로 생성일자/수정일자 만들어주는 클래스
 * 요약 :
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * -----------------------------------------------------------
 * 2022/10/16         kangtaegyung          최초 생성
 */
@Getter
// @MappedSuperclass : JPA Entity 클래스들이 BaseTimeEntity를 상속할 경우
// 필드들(createdDate, modifiedDate)도 칼럼으로 인식하도록 한다.
@MappedSuperclass
// @EntityListeners(AuditingEntityListener.class) : BaseTimeEntity 클래스에
// Auditing 기능을(자동 생성일, 수정일) 포함시킨다.
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    private String insertTime;

    private String updateTime;

    private String deleteYn;        // 삭제여부

    private String deleteTime;      // 삭제시간

    @PrePersist
        //해당 엔티티 저장하기 전
    void onPrePersist(){
        this.insertTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        this.deleteYn = "N";
    }

    @PreUpdate
        //해당 엔티티 수정 하기 전
    void onPreUpdate(){
        this.updateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        this.insertTime = this.updateTime;
        this.deleteYn = "N";
    }
}

3-2) model > Dept/Emp.java 생성

📂 Dept.java

더보기
package com.example.simpledms.model;

import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;

import javax.persistence.*;

/**
 * packageName : com.example.modelexam.model
 * fileName : Dept
 * author : kangtaegyung
 * date : 2022/10/12
 * description : 부서 모델 클래스
 * 요약 :
 * Hard Delete : 실제 데이터를 삭제하는 행위
 * Soft Delete는 물리적인 데이터 삭제로 발생할 수 있는 문제를 방지하고 쉽게 복원할 필요가 있거나 삭제된 데이터들을 보관하여 데이터로써 활용할 필요나 가치가 있는 경우에 사용
 * 실무에서는 법적으로 개인자료일 경우 3년 또는 1년이상 데이터를 보관할 의무가 있고 어길수 법적 문제가 생길 수 있음 -> 그래서 soft delete 방식을 대부분 구현하고 있음
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * -----------------------------------------------------------
 * 2022/10/12         kangtaegyung          최초 생성
 */
@Entity
@Table(name="TB_DEPT")
@SequenceGenerator(
        name = "SQ_DEPT_GENERATOR"
        , sequenceName = "SQ_DEPT"
        , initialValue = 1
        , allocationSize = 1
)
@Getter
@Setter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DynamicInsert
@DynamicUpdate
// soft delete
// 사용법 : 1) @Where(clause = "DELETE_YN = 'N'") : select 될 때 조건("DELETE_YN = 'N'")을 강제로 붙여줌
//         2) @SQLDelete(sql = "대체 sql문") : delete 될때 대체해서 실행될 쿼리문
@Where(clause = "DELETE_YN = 'N'")
@SQLDelete(sql = "UPDATE TB_DEPT SET DELETE_YN = 'Y', DELETE_TIME=TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') WHERE DNO = ?")
public class Dept extends BaseTimeEntity {
    //    부서넘버
//    @Id : Primary Key 에 해당
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE
            , generator = "SQ_DEPT_GENERATOR"
    )
    @Column
    private Integer dno;
    //    부서이름
    @Column
    private String dname;
    //    부서위치
    @Column
    private String loc;
}

📂 Emp.java

더보기
package com.example.simpledms.model;

import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;

import javax.persistence.*;

@Entity
@Table(name = "TB_EMP")
@SequenceGenerator(
        name = "SQ_EMP_GENERATOR"
        , sequenceName = "SQ_EMP"
        , initialValue = 1
        , allocationSize = 1
)
@Setter
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DynamicInsert
@DynamicUpdate
// soft delete
@Where(clause = "DELETE_YN = 'N'")
@SQLDelete(sql = "UPDATE TB_EMP SET DELETE_YN = 'Y', DELETE_TIME=TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') WHERE ENO = ?")
public class Emp extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE
            , generator = "SQ_EMP_GENERATOR"
    )
    @Column
    private Integer eno;

    @Column
    private String ename;

    @Column
    private String job;

    @Column
    private Integer manager;

    @Column
    private String hiredate;

    @Column
    private Integer salary;

    @Column
    private Integer commission;

    @Column
    private Integer dno;
}

3-3) DeptRepository.java 생성

📂 repository > DeptRepository.java

package com.example.simpledms.repository;

import com.example.simpledms.model.Dept;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;


@Repository
public interface DeptRepository extends JpaRepository<Dept, Integer> {
    // dname like 검색 - 쿼리메소드
    List<Dept> findAllByDnameContaining(String dname);
}

3-4) DeptService.java 생성

📂 service > DeptService.java

package com.example.simpledms.service;

import com.example.simpledms.model.Dept;
import com.example.simpledms.repository.DeptRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class DeptService {
    @Autowired
    DeptRepository deptRepository;  // DI
    
    /** 전체조회 */
    public List<Dept> findAll(){
        List<Dept> list = deptRepository.findAll();
        return list;
    }
    
    /** 검색어(dname like) 조회 함수 */
    public List<Dept> findAllByDnameContaining(String dname){
        List<Dept> list = deptRepository.findAllByDnameContaining(dname);
        return list;
    }
    
    
}

3-5) 프론트<->벡엔드 통신을 위해 CORS 보안 허용 설정 파일 생성

📂 config > WebConfig.java

package com.example.simpledms.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * packageName : com.example.dongsungsi.controller
 * fileName : WebConfig
 * author : kangtaegyung
 * date : 2022/06/14
 * description :
 * @Configuration : 자바클래스에 설정 기능을 부여하는 어노테이션 (application.properties 비슷)
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * -----------------------------------------------------------
 * 2022/06/14         kangtaegyung          최초 생성
 */

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
//                아래 url 허용
//                사용법 : .allowedOrigins("http://허용할 ip:허용할Port")
                .allowedOrigins("http://localhost:3000")

//                Todo: 아래 추가해야 update, delete, insert, select 가 cors 문제가 안생김
                .allowedMethods(
                        HttpMethod.GET.name(),
                        HttpMethod.POST.name(),
                        HttpMethod.PUT.name(),
                        HttpMethod.DELETE.name(),
                        HttpMethod.PATCH.name()
                );
    }
}

3-6) DeptController.java 생성

📂 controller > DeptController.java

package com.example.simpledms.controller;

import com.example.simpledms.model.Dept;
import com.example.simpledms.service.DeptService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

/**
 * packageName : com.example.simpledms.controller
 * fileName : DeptController
 * author : GGG
 * date : 2023-10-19
 * description : 부서 컨트롤러 (@RestController - react용)
 * 요약 :
 * react(3000) <-> springboot(8000) 연동 : axios
 * 인터넷 기본 보안 : ip, port 최초에 지정된 것과 달라지면
 * => 해킹으로 기본 인정 (블러킹 : 단절) : CORS 보안
 * <p>
 * ===========================================================
 * DATE            AUTHOR             NOTE
 * —————————————————————————————
 * 2023-10-19         GGG          최초 생성
 */
@Slf4j
@RestController
@RequestMapping("/api")
public class DeptController {
    @Autowired
    DeptService deptService;    // DI

    /**
     * 전체 조회 + like 검색
     */
    @GetMapping("/dept")
    public ResponseEntity<Object> getDeptAll(
            @RequestParam(defaultValue = "") String dname) {
        try {
            // 전체 조회 + like 검색
            List<Dept> list = deptService.findAllByDnameContaining(dname);
            if (list.isEmpty() == false) {
//                성공
                return new ResponseEntity<>(list, HttpStatus.OK);
            } else {
//                데이터 없음
                return new ResponseEntity<>(HttpStatus.NO_CONTENT);
            }
        } catch (Exception e) {
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

}

 

3-7) REST API 테스트

3-8) 프론트 연동 테스트

반응형

+ Recent posts