본문 바로가기
대외활동/DRACONIST-백엔드

스프링부트 스터디 2주차. 4단원 머스테치, 게시판 만들기

by 피스타0204 2025. 1. 13.

 

0. 3단원에서 배운 내용요약

더보기
 

1. JPA/Hibernate/Spring Data JPA의 관계

  • JPA (Java Persistence API):
    자바에서 객체와 관계형 데이터베이스를 매핑(ORM)하는 표준 API입니다. JPA는 인터페이스 집합이며, 구현체는 없습니다.
  • Hibernate:
    JPA의 가장 대표적인 구현체입니다. Hibernate는 JPA 인터페이스를 구현하여 데이터베이스와의 상호작용을 지원하며, JPA의 기능 외에도 추가적인 고급 기능을 제공합니다.
  • Spring Data JPA:
    Spring 프레임워크에서 JPA를 편리하게 사용할 수 있도록 추상화한 모듈입니다. Spring Data JPA는 JPA 및 Hibernate와 밀접하게 통합되어, 개발자가 간단한 인터페이스 선언만으로 CRUD, 페이징, 커스텀 쿼리 등을 구현할 수 있게 합니다.

관계 요약:
JPA는 ORM의 표준, Hibernate는 JPA의 구현체, Spring Data JPA는 JPA/Hibernate를 활용하여 쉽게 개발할 수 있도록 돕는 Spring의 추상화 도구입니다.


2. Spring Data JPA를 이용하여 관계형 데이터베이스를 객체지향적으로 관리하는 법

Spring Data JPA는 엔티티(Entity) 클래스를 통해 데이터베이스의 테이블을 객체지향적으로 표현합니다.

  • Entity 클래스 설계:
    데이터베이스 테이블을 자바 객체로 표현하며, 각 필드는 테이블의 열(Column)에 매핑됩니다.
  • java
    코드 복사
    @Entity public class User { @Id @GeneratedValue private Long id; // Primary Key private String name; private String email; }
  • Repository 사용:
    Spring Data JPA의 JpaRepository 인터페이스를 상속받아 데이터베이스에 접근합니다.
    CRUD 작업이 자동으로 제공되며, 커스텀 쿼리도 정의할 수 있습니다.
  • java
    코드 복사
    public interface UserRepository extends JpaRepository<User, Long> { List<User> findByName(String name); // 이름으로 검색 }
  • 관계 매핑:
    JPA의 애노테이션(@OneToOne, @OneToMany, @ManyToOne, @ManyToMany)을 사용하여 객체 간의 관계를 정의하고, 데이터베이스의 테이블 간 관계를 표현합니다.
  • java
    코드 복사
    @OneToMany(mappedBy = "user") private List<Order> orders;

Spring Data JPA는 이러한 매핑 및 데이터 접근을 간소화하여 객체지향적인 방식으로 데이터베이스를 관리하도록 도와줍니다.


3. JPA의 더티 체킹(Dirty Checking)

더티 체킹은 JPA의 강력한 기능으로, 엔티티가 변경된 것을 자동으로 감지하여 업데이트 쿼리를 생성합니다.

  • 작동 방식:
    1. 엔티티를 영속성 컨텍스트(Persistence Context)에 저장합니다.
    2. 엔티티의 상태를 변경하면 JPA가 변경 내용을 감지합니다.
    3. 트랜잭션이 커밋될 때, 변경된 내용만 데이터베이스에 반영합니다.
  • 예제:
  • java
    코드 복사
    @Transactional public void updateUserName(Long userId, String newName) { User user = userRepository.findById(userId).orElseThrow(); user.setName(newName); // 엔티티의 상태 변경 // 더티 체킹에 의해 자동으로 UPDATE 쿼리 생성 }
  • 장점:
    • 개발자가 명시적으로 update 쿼리를 작성하지 않아도 됩니다.
    • 변경된 데이터만 업데이트하여 성능 최적화가 가능합니다.

4. JPA Auditing을 이용하여 등록/수정 시간을 자동화하는 방법

JPA Auditing은 엔티티가 생성되거나 수정될 때, 등록 시간(createdDate)과 수정 시간(lastModifiedDate)을 자동으로 관리하는 기능을 제공합니다.

  • 설정 방법:
    1. @EnableJpaAuditing 활성화: Spring Boot에서 Auditing 기능을 활성화합니다.
    2. java
      코드 복사
      @SpringBootApplication @EnableJpaAuditing public class Application {}
    3. Auditing 필드 정의: 엔티티에 등록/수정 시간을 저장할 필드를 정의하고 관련 애노테이션을 추가합니다.
    4. java
      코드 복사
      @Entity @EntityListeners(AuditingEntityListener.class) public class BaseEntity { @CreatedDate @Column(updatable = false) private LocalDateTime createdDate; @LastModifiedDate private LocalDateTime lastModifiedDate; }
    5. 엔티티에서 상속: 공통 필드가 필요할 경우, 이를 상속받아 재사용합니다.
    6. java
      코드 복사
      @Entity public class User extends BaseEntity { private String name; private String email; }
  • 장점:
    • 별도의 로직 없이 등록/수정 시간을 자동으로 관리할 수 있어 생산성이 향상됩니다.
    • 코드의 중복을 줄이고, 일관성을 유지할 수 있습니다.

 

1. 서버 템플릿 엔진과 머스테치 소개

이번 장에서는 mustache를 통해 화면 영역(view)을 개발하는 방법을 배워 보겠습니다. 

서버 템플릿 엔진과 클라이언트 템플릿 엔진의 차이, 왜 JSP가 아닌 머스테치를 배우는지, 머스테치를 통한 CRUD화면 구현 순으로 배우겠습니다.


먼저 템플릿 엔진이 무엇일까요? 

일반적으로 웹 개발에서 템플릿 엔진은 지정된 템플린 양식과 데이터가 합쳐져 HTML문서를 출력하는 소프트웨어 엔진을 이야기합니다. 스프링이나 servlet을 사용해보신 분들은 아마 JSP, Freemarker가 익숙하실 겁니다. 요즘 개발을 시작한 사람들은 React, View의 뷰 템플릿을 떠올리실 것입니다. 

 

#JSP 란 JavaServer Pages 의 약자이며 HTML 코드에 JAVA 코드를 넣어 동적웹페이지를 생성하는 웹어플리케이션 도구

#서블릿이란 웹페이지를 동적으로 생성하기 위해 서버측 프로그램을 말한다. 

이는 자바 언어를 기반으로 만들지며 웹 어플리케이션 서버 ( Web Application Sever ) 위에서 컴파일 되고 동작한다.

 

그런데 JSP, Freemarker와 React, View는 확실한 차이가 있습니다.  JSP, Freemarker는 서버 템플릿 엔진으로 서버에서 java코드로 문자열을 만든 후, 이 문자열을 html로 변환하여 브라우저로 전달합니다.

 

React, View는 클라이언트 템플릿 엔진으로 자바스크립트를 브라우저 위에서 작동시킵니다. 브라우저 위에서 작동할 때는 서버 템플릿 엔진으로 제어할 수 없습니다. 즉, Vue.js나 React.js를 이용한 SPA(Single Page Application)은 브라우저 위에서 화면을 생성합니다. 그래서 서버에서는 Json혹은 XMl형식의 데이터만 전달하고 클라이언트에서 조립합니다.

 

JSP와 서블릿과 같은 서버 템플릿 엔진의 작동과정

더보기

JSP 는 HTML 내부에 JAVA 소스코드가 들어감으로 인해 HTML 코드를 작성하기 간편하다는 장점이있으며

서블릿은 자바코드내에 HTML 코드가 있어서 읽고 쓰기가 굉장히 불편하기 때문에 작업의 효율성이 떨어집니다.

하지만, JSP로 작성된 프로그램은 서버로 요청시 servlet파일로 변환-> 순서 HTML로 변환 과정을 거치기 때문에 두 웹템플릿 엔진을 보통 공부할 때 같이 배웁니다.

1. 클라이언트가 JSP 페이지를 요청
GET  /hello.jsp

 

2. 서버가 JSP 파일을 읽음

JSP는 HTML 파일처럼 보이지만, 동적 콘텐츠를 생성하기 위한 Java 코드가 포함되어 있습니다.

 

3. JSP를 서블릿(Servlet)으로 변환

JSP 컨테이너는 JSP 파일을 **서블릿(Java 클래스)**으로 변환합니다.

 

  • 이 과정에서 JSP 파일의 태그(<% %> 같은 Java 코드)와 HTML이 분리됩니다.
  • JSP 태그는 Java 코드로 변환되고, 정적인 HTML 부분은 그대로 유지됩니다.

4. 서블릿 파일(.java)을 컴파일

변환된 서블릿 파일(.java)은 컴파일되어 **바이트코드(.class 파일)**로 변환됩니다.

 

5. 서블릿 실행

 

실행 과정에서 Java 코드는 동적인 데이터를 생성하고, 이를 HTML 형식으로 만들어냅니다.

 

HTML 생성 및 클라이언트로 전달

실행 결과로 생성된 HTML 파일은 JSP 컨테이너를 통해 HTTP 프로토콜로 클라이언트(브라우저)에게 전달됩니다. 브라우저는 HTML을 렌더링하여 사용자에게 보여줍니다.

참고 사이트)

 

[JSP] JSP (JavaServer Pages ) 란 무엇인가?

JSP (JavaServer Pages ) 란 무엇인가? JSP 란 JavaServer Pages 의 약자이며HTML 코드에 JAVA 코드를 넣어 동적웹페이지를 생성하는 웹어플리케이션 도구이다.JSP 가 실행되면 자바 서블릿(Servlet) 으로 변환되며

javacpro.tistory.com

 

 

 

서버 템플릿 엔진은 html을 완성하여 전달하기 때문에 클라이언트 템플릿 엔진보다 SEO( 'Search Engine Optimization; 검색엔진 최적화)에 유리합니다. 하지만 요즘에는 react나 vue에서 서버사이드 렌더링으로 이를 해결하였습니다.

# 서버 사이드 렌더링이란 서버에서 페이지를 그려 클라이언트(브라우저)로 보낸 후 화면에 표시하는 기법을 의미합니다. 서버 사이드 렌더링을 쓰는 목적은 크게 "검색 엔진 최적화"와 "빠른 페이지 렌더링"입니다. 

 

우리는 앞으로 react Native와 협업하는 것을 목표로 하고 있지만 결과물을 확인하기 위해 머스테치라는 범용 템플릿 엔진을 사용할 것 입니다. 머스테치는 루비, 자바스크립트, 파이썬, PHP, 자바, 펄, Go, ASP등 현존하는 대부분의 언어를 지원하고 있습니다. 그렇기 때문에 자바로 쓸 때는 서버 템플릿 엔진, 자바스크립트로 쓸때는 클라이언트 템플릿 엔진으로 사용됩니다.

 


2. 머스테치 사용하기

 

1) 머스테치 플러그인 설치

인텔리제이 플러그인 설치에 대한 참고 사이트)

 

IntelliJ - 인텔리제이 플러그인(plugin) 설치하기

IntelliJ - 인텔리제이 플러그인(plugin) 설치하기 1. File - Setting 2. Plugins - 검색어 입력 - Search in repositories 3. 설치 할 플러그인 선택 후 Install 4. 인스톨이 끝나면 Restart IntelliJ IDEA 클릭 5. 해당창을 빠

bigphu.tistory.com

 




2) 머스테치 스타터 의존성을 build.gradle에 등록합니다.

buildscript {
    ext {
        springBootVersion = '2.6.3'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'org.example'
version = '1.0-SNAPSHOT'
sourceCompatibility = '11'
targetCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'com.h2database:h2'
    implementation 'org.projectlombok:lombok:1.18.30'  
    annotationProcessor 'org.projectlombok:lombok:1.18.30'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'junit:junit:4.13.2'

    // 추가된 머스테치 의존성
    implementation 'org.springframework.boot:spring-boot-starter-mustache'
}

 

 

3)  src/main/resourecs/templates에 머스테치 파일을 만듭니다.

template디렉터리 안에 만든 index.mustache(  index 파일은 프론트에서 보통 처음 시작 파일을 의미합니다. ; application느낌)

 

package com.ulbbang.book.firstproject.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class IndexController {
    @GetMapping("/")
    public String index() {
        return "index";
    }
}

 

머스테치 스타터 덕분에 컨트롤러에서 문자열을 반환할 때 앞의 경로( src\main\resources\templates )와 뒤의 파일 확장자(.mustache)는 자동으로 지정됩니다. 여기서 반환되는 "index"라는 파일이름이 중간에 들어가  src\main\resources\templates\index.mustache를 view resolver가 처리하게 됩니다.

 

#viewResolver는 url 요청의 결과를 전달할 타입과 값을 지정하는 관리자 격으로 볼수 있습니다. ViewResolver는 Spring MVC에서 사용되는 구성 요소로, 클라이언트의 URL 요청에 대한 응답을 처리할 **뷰(view)**를 결정하는 역할을 합니다. 이를 통해 특정 요청에 대해 어떤 뷰 템플릿(예: JSP, Thymeleaf, Mustache 등)을 렌더링할지, 또는 JSON이나 XML과 같은 데이터 형식으로 응답할지를 설정할 수 있습니다.

 


테스트 코드 검증)

package com.ulbbang.book.firstproject.web;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IndexControllerTest {
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void MainPage_loading() {
        //when
        String body = this.restTemplate.getForObject("/", String.class);

        //then
        assertThat(body).contains("스프링부트로 시작하는 웹 서비스");
    }
}

 

 

  • import org.junit.Test;
    • JUnit5의 @Test를 가져옵니다. JUnit4를 대체하여 최신 테스트 환경에 맞게 수정했습니다.
  • import org.springframework.beans.factory.annotation.Autowired;
    • 스프링 컨텍스트에서 TestRestTemplate 객체를 주입받기 위해 @Autowired를 사용합니다.
      의존성 주입은 객체가 자신이 사용할 객체(의존성)를 스스로 생성하지 않고, 외부에서 제공받는 설계를 말합니다.
      스프링에서는 이 작업을 스프링 컨테이너가 자동으로 수행하며, 객체 간 결합도를 낮춰 관리하기 쉽게 만듭니다.
      @Autowired를 사용하면 스프링 컨테이너에서 타입에 맞는 빈(Bean)을 자동으로 찾아서 주입합니다. 개발자가 명시적으로 객체를 생성하거나 설정하지 않아도, 필요한 객체를 자동으로 연결해줍니다.

      즉, 객체의 타입을 상속하거나 연결하는 과정을 자동으로 해줍니다. 이를 통해 다른 라이브러리에서 만들어져 있는 사용자 타입을 가져와 사용할 수 있습니다.
  • import org.springframework.boot.test.context.SpringBootTest;
    • 스프링 부트 테스트를 위한 설정을 제공합니다. @SpringBootTest는 통합 테스트에 사용되며, 스프링 애플리케이션 컨텍스트를 로드합니다.
  • import org.springframework.boot.test.web.client.TestRestTemplate;
    • RESTful API를 테스트하기 위한 스프링 부트 제공 클래스입니다.
  • import static org.assertj.core.api.Assertions.assertThat;
    • AssertJ의 assertThat 메서드를 가져옵니다. 이는 가독성이 뛰어나고 표현력이 강한 테스트 검증을 지원합니다.
  • @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    • 스프링 부트 애플리케이션 테스트 환경을 설정합니다. RANDOM_PORT는 애플리케이션을 임의의 포트에서 실행하도록 만듭니다.
  • @Autowired private TestRestTemplate restTemplate;
    • TestRestTemplate을 주입받아 HTTP 요청을 테스트하는 데 사용합니다. TestRestTemplate은 Spring Boot에서 제공하는 테스트용 HTTP 클라이언트입니다. 주로 RESTful API를 테스트할 때 사용되며, 간단한 HTTP 요청과 응답을 다룰 수 있습니다.
  • @Test
    • 이 메서드가 단위 테스트라는 것을 나타냅니다. JUnit5에서는 org.junit.jupiter.api.Test를 사용합니다.
  • String body = this.restTemplate.getForObject("/", String.class);
    • GET 요청을 보내고, 응답 본문을 String 형태로 받습니다. RESTful API를 테스트할 때 사용되는 TestRestTemplate객체의 getForObject메서드를 사용하였습니다.
  • assertThat(body).contains("스프링부트로 시작하는 웹서비스");
    • 응답 본문에 특정 문자열이 포함되어 있는지 검증합니다. 테스트의 핵심 검증 부분입니다. getForObject()로 얻은 응답 본문(body)을 받아 확인합니다.
    • .contains(): 특정 문자열이 응답 본문에 포함되어 있는지 확인합니다.

3. 테스트 코드가 하는 일! 검증! 이게 여기 있나...?

getForObject() 메서드가 GET 요청을 보내고 응답을 객체로 변환하는 역할을 한다면, assertThat()은 그 응답 데이터를 테스트하는 역할을 합니다.

assertThat()은 응답 데이터를 특정 조건이나 기대하는 값과 비교하여 검증합니다. 이 때 assertThat() 안에 전달하는 객체는 주로 응답 데이터 (body)이며, 그 데이터에 대해 조건을 확인하는 방식으로 사용됩니다.

assertThat()에서 사용할 수 있는 예시 조건들:

  1. contains():
    • 문자열 안에 특정 텍스트가 포함되어 있는지 검증합니다.
    java
    코드 복사
    assertThat(body).contains("스프링부트");
  2. isEqualTo():
    • 응답 본문이 예상되는 정확한 문자열과 일치하는지 검증합니다.
    java
    코드 복사
    assertThat(body).isEqualTo("스프링부트로 시작하는 웹서비스");
  3. startsWith():
    • 문자열이 특정 값으로 시작하는지 검증합니다.
    java
    코드 복사
    assertThat(body).startsWith("스프링부트");
  4. endsWith():
    • 문자열이 특정 값으로 끝나는지 검증합니다.
    java
    코드 복사
    assertThat(body).endsWith("웹서비스");
  5. isNotEmpty():
    • 응답 본문이 비어 있지 않은지 검증합니다.
     

 

Application..java의 main메소드를 실행하여 브라우저 창에서도 확인해봅시다.

"/" 루트 디렉터리로 설정하였기 때문에   http://localhost:8080   에서 바로 확인할 수 있습니다.


 

4. 게시글 등록 화면 만들기

1) 레이아웃 footer, header만들기

멋있게 오픈소스인 부트스트랩을 이용해 화면을 만들어봅시다. 부트스트램, 제이쿼리 등 프론트엔드 라이브러리를 사용할 수 있는 방법은 크게 외부 CDN을 사용하기, 직접 라이브러리를 받아 사용하기, 2가지 방법이 있습니다. 여기서는 외부 CDN을 사용합니다.

 

# 외부 CDN(Content Delivery Network)은 웹 콘텐츠를 사용자에게 더 빠르고 효율적으로 전달하기 위한 네트워크 시스템입니다. CDN은 전 세계 여러 위치에 분산된 서버를 통해 콘텐츠를 제공하여, 사용자가 요청한 콘텐츠를 가장 가까운 서버에서 제공함으로써 속도와 성능을 향상시킵니다.

 

레이아웃 파일을 작성할 것인데 아래 깃허브 코드에서 적당히 복사해서 써봅시다.

 

GitHub - jojoldu/freelec-springboot2-webservice

Contribute to jojoldu/freelec-springboot2-webservice development by creating an account on GitHub.

github.com

 

페이지로드 속도를 높이기 위해 css는 header에 js는 footer에 두었습니다.

<!DOCTYPE HTML>
<html>
<head>
    <title>스프링부트 웹서비스</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>

<!--index.js 추가-->
<script src="/js/app/index.js"></script>
</body>
</html>

 

또 bootstrap.js의 경우 제이쿼리가 꼭 있어야 하기 때문에 부트스트랩보다 먼저 jquery가 불리도록 코드를 작성했습니다. 해당 프로젝트에서는 프론트 중심이 아니기 때문에 철지난 기술인 jquery를 그냥 사용했습니다. 실제 상업적 애플리케이션의 프론트엔드에서는 jquery를 사용하지 않는 것이 추세라는 것을 알아둡시다.

 


2) index.mustache, 

//index.mustache
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    {{>layout/header}}
    <h1>스프링부트로 시작하는 웹 서비스</h1>
    <div class="col-md-12">
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            </div>
        </div>
    </div>
    {{>layout/footer}}
</body>
</html>

 

{{>  }}는 현재 커스테치 파일을 기준으로 다른 파일을 가져옵니다.

 

3) indexController.java 에 컨트롤러 작성

package com.ulbbang.book.firstproject.web;

import org.springframework.web.bind.annotation.GetMapping;

public class IndexController {
    @GetMapping("/")
    public String index() {
        return "index";
    }

    @GetMapping("/posts/save")
    public String save() {
        return "posts-save";
    }

}

 

3) posts-save.mustache 추가

{{>layout/header}}

<h1>게시글 등록</h1>

<div class="col-md-12">
    <div class="col-md-4">
        <form>
            <div class="form-group">
                <label for="title">제목</label>
                <input type="text" class="form-control" id="title" placeholder="제목을 입력하세요">
            </div>
            <div class="form-group">
                <label for="author"> 작성자 </label>
                <input type="text" class="form-control" id="author" placeholder="작성자를 입력하세요">
            </div>
            <div class="form-group">
                <label for="content"> 내용 </label>
                <textarea class="form-control" id="content" placeholder="내용을 입력하세요"></textarea>
            </div>
        </form>
        <a href="/" role="button" class="btn btn-secondary">취소</a>
        <button type="button" class="btn btn-primary" id="btn-save">등록</button>
    </div>
</div>

{{>layout/footer}}

 

글 등록 클릭으로 이동할 수 있습니다.

 

한국어 주석 폰트 깨짐 해결

 

인텔리제이 한글깨짐

​ ​ 해결방법 5가지! 어쩔때는 이게되고 어쩔때는 이게되고 아직 상황이 구분이 잘 안간다.. ​ ​ ​ 1. vm 설정 ​ 쉬프트 2번 클릭 검색창에 vm edit custom vm options... 클릭 ​ -Dfile.encoding=UTF-8 -Dcon

nsmchan.tistory.com


4) api호출 js만들기

var main = {
    init : function () {
        var _this = this;
        $('#btn-save').on('click', function () {
            _this.save();
        });

        $('#btn-update').on('click', function () {
            _this.update();
        });

        $('#btn-delete').on('click', function () {
            _this.delete();
        });
    },
    save : function () {
        var data = {
            title: $('#title').val(),
            author: $('#author').val(),
            content: $('#content').val()
        };

        $.ajax({
            type: 'POST',
            url: '/api/v1/posts',
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 등록되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    },
    update : function () {
        var data = {
            title: $('#title').val(),
            content: $('#content').val()
        };

        var id = $('#id').val();

        $.ajax({
            type: 'PUT',
            url: '/api/v1/posts/'+id,
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            alert('글이 수정되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    },
    delete : function () {
        var id = $('#id').val();

        $.ajax({
            type: 'DELETE',
            url: '/api/v1/posts/'+id,
            dataType: 'json',
            contentType:'application/json; charset=utf-8'
        }).done(function() {
            alert('글이 삭제되었습니다.');
            window.location.href = '/';
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    }

};

main.init();

 

깔끔하게 복붙합시다.

 

 

5) 확인

SELECT * FROM POSTS

5. 전체 조회 화면 만들기

index.mustache

{{>layout/header}}

<h1>스프링부트로 시작하는 웹 서비스 Ver.2</h1>
<div class="col-md-12">
    <div class="row">
        <div class="col-md-6">
            <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            {{#userName}}
                Logged in as: <span id="user">{{userName}}</span>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
            {{/userName}}
            {{^userName}}
                <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
                <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
            {{/userName}}
        </div>
    </div>
    <br>
    <!-- 목록 출력 영역 -->
    <table class="table table-horizontal table-bordered">
        <thead class="thead-strong">
        <tr>
            <th>게시글번호</th>
            <th>제목</th>
            <th>작성자</th>
            <th>최종수정일</th>
        </tr>
        </thead>
        <tbody id="tbody">
        {{#posts}}
            <tr>
                <td>{{id}}</td>
                <td><a href="/posts/update/{{id}}">{{title}}</a></td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>
</div>

{{>layout/footer}}

 

{{#posts}} posts라는 list를 순회하는 머스테치 문법입니다. for in문과 유사합니다.

{{id}}는 변수명을 나타납니다.

 

src\main\java\com\ulbbang\book\firstproject\domain\posts

 

PostsRepository.java 파일은 Spring Data JPA를 사용하여 데이터베이스와 상호작용하는 리포지토리(Repository) 클래스입니다. JpaRepository를 상속받아서 기본적인 CRUD (Create, Read, Update, Delete) 작업을 쉽게 처리할 수 있도록 합니다. 이 파일에서 PostsRepository는 Posts 엔티티에 대한 데이터베이스 작업을 처리하는 역할을 합니다.

package com.ulbbang.book.firstproject.domain.posts;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface PostsRepository extends JpaRepository<Posts, Long> {
    @Query("SELECT p FROM Posts p ORDER BY p.id DESC")
    List<Posts> findAllDesc();
}

1. JpaRepository<Posts, Long>

  • JpaRepository는 Spring Data JPA에서 제공하는 인터페이스로, 기본적인 CRUD 작업을 자동으로 구현해 줍니다. Posts는 엔티티 클래스이고, Long은 해당 엔티티의 기본 키(primary key)의 타입입니다.

2. @Query("SELECT p FROM Posts p ORDER BY p.id DESC")

  • @Query는 JPQL(Java Persistence Query Language)을 사용하여 직접 쿼리를 작성할 수 있게 해주는 어노테이션입니다. 이 쿼리는 Posts 엔티티에 대해 id 값을 기준으로 내림차순으로 데이터를 정렬하여 반환하는 쿼리입니다.
  • SELECT p FROM Posts p ORDER BY p.id DESC는 데이터베이스에서 Posts 테이블의 모든 행을 가져오되, id 값을 기준으로 내림차순 정렬하라는 의미입니다.

3. List<Posts> findAllDesc();

  • 이 메소드는 Posts 객체를 내림차순으로 정렬하여 리스트 형식으로 반환하는 기능을 수행합니다.

SpringDataJpa에서 제공하지 않는 메소드는 위처럼 쿼리로 작성되는 것을 보여주고자 @Query를 사용했지만 SpringDataJpa로 도 작성할 수 있습니다. 하지만 Query가 더 가독성이 좋으니 이것을 사용합시다.

 

규모가 있는 프로젝트에서 데어터 조회는 FK의 조인 복잡한 조건등의 Enitity클래스만으로 처리하기 어렵기 때문에 querydsl(추천), jooq, MyBatis등의 조회용 프레임워크를 사용합니다.

 


PostsListResponseDto.java

package com.ulbbang.book.firstproject.web.dto;

import com.ulbbang.book.firstproject.domain.posts.Posts;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class PostsListResponseDto {
    private Long id;
    private String title;
    private String author;
    private LocalDateTime modifiedDate;

    public PostsListResponseDto(Posts entity) {
        this.id = entity.getId();
        this.title = entity.getTitle();
        this.author = entity.getAuthor();
        this.modifiedDate = entity.getModifiedDate();
    }
}

 

 

 

PostsService.java

package com.ulbbang.book.firstproject.service.posts;

import com.ulbbang.book.firstproject.domain.posts.Posts;
import com.ulbbang.book.firstproject.domain.posts.PostsRepository;
import com.ulbbang.book.firstproject.web.dto.PostsListResponseDto;
import com.ulbbang.book.firstproject.web.dto.PostsResponseDto;
import com.ulbbang.book.firstproject.web.dto.PostsSaveRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Transactional;  //수정됨
import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class PostsService {
    private final PostsRepository postsRepository;
    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getId();
    }
    @Transactional
    public Long update(Long id, PostsSaveRequestDto requestDto) {
        Posts posts = postsRepository.findById(id).orElseThrow(
                ()->new IllegalArgumentException("해당 게시물이 없습니다. id="+id));
        posts.update(requestDto.getTitle(), requestDto.getContent());
        return id;
    }
    public PostsResponseDto findById(Long id) {
        Posts entity = postsRepository.findById(id).orElseThrow(
                ()-> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
        return new PostsResponseDto(entity);

    }
    @Transactional(readOnly = true)
    public List<PostsListResponseDto> findAllDesc(){
        return postsRepository.findAllDesc().stream()
                .map(PostsListResponseDto::new).collect(Collectors.toList());
    }
}

 

 


@Transactional(readOnly = true)
@Transactional(readOnly = true)는 이 메서드가 트랜잭션 내에서 실행되도록 지정합니다. readOnly = true는 트랜잭션을 읽기 전용으로 설정하여, **쓰기 작업(INSERT, UPDATE, DELETE)**을 방지하고 읽기 작업 성능을 최적화합니다.

 

 

findAllDesc()

반환 타입은 List<PostsListResponseDto>이며, 여러 개의 PostsListResponseDto 객체를 담는 리스트를 반환합니다.

 

 

postsRepository.findAllDesc()

postsRepository.findAllDesc()는 데이터베이스에서 Posts 엔티티를 **최신순(내림차순)**으로 정렬하여 가져옵니다.

가져온 결과는 List<Posts> 타입의 컬렉션입니다.

 

.stream()

.stream()은 이 리스트를 **스트림(Stream)**으로 변환합니다. 스트림을 통해 데이터 변환이나 필터링 같은 작업을 체이닝 방식으로 처리할 수 있습니다.

 

.map(PostsListResponseDto::new)

.map()은 스트림의 각 요소를 변환하는 작업을 수행합니다. PostsListResponseDto::new는 생성자 참조를 사용하여 각 Posts 객체를 PostsListResponseDto 객체로 변환합니다. 즉, new PostsListResponseDto(Posts posts)를 호출하는 것과 동일합니다. 결과적으로, Posts 객체 리스트가 PostsListResponseDto 객체 리스트로 변환됩니다.

 

.collect(Collectors.toList())

.collect(Collectors.toList())는 스트림 작업의 결과를 다시 리스트 형태로 수집합니다. 변환된 PostsListResponseDto 객체들을 리스트에 담아 최종적으로 반환합니다.

 

 

 

 

 


indexController.java

 

postsService.findAllDesc()로 posts객체 리스트를 model에 PostsListResponseDto 객체 리스트로 반환합니다. PostListREsponseDto는 content(본문)제외 id, title, author, modifiedDate가 담겨 있습니다. 즉, PostsListResponseDto는 전체조회 시스템, PostsResponse는 상세조회의 기능을 하고 있습니다. 

이것은 indexController.java에서 model 객체에 담겨 index.mustache(프론트)로 전달됩니다. 서버 템플릿 엔진이 아닌 클라이언트 템플릿 엔지에서 사용되는 경우 xml이나 json객체에 담겨 프론트로 전달됩니다.

 

package com.ulbbang.book.firstproject.web;

import com.ulbbang.book.firstproject.service.posts.PostsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@RequiredArgsConstructor
@Controller
public class IndexController {
    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        return "index";
    }

    @GetMapping("/posts/save")
    public String save() {
        return "posts-save";
    }

}

 

import com.ulbbang.book.firstproject.service.posts.PostsService;

PostsService 클래스를 임포트합니다. PostsService는 Posts 엔티티의 각 동작이 실행되는 순서를 저장하고 있습니다.

여기서는 Posts 객체 리스트를 PostsListResponseDto 객체 리스트로 변환하는 등의 일을 합니다.

 

import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor

Lombok 라이브러리의 @RequiredArgsConstructor 어노테이션입니다. 이 어노테이션은 final 또는 @NonNull 필드에 대해 자동으로 생성자를 생성해주는 기능을 제공합니다.

 

import org.springframework.stereotype.Controller;
@Controller

이 클래스가 Spring MVC의 컨트롤러임을 나타냅니다. HTTP 요청을 처리하는 역할을 합니다.

 

private final PostsService postsService;

@RequiredArgsConstructor 덕분에 생성자를 통해 자동으로 주입됩니다. 이 필드는 final로 선언되어 의존성 주입을 강제합니다.

 

import org.springframework.web.bind.annotation.GetMapping;
    @GetMapping("/")
@GetMapping은 HTTP GET 요청을 처리하는 메서드에 매핑하는 어노테이션입니다.

 

public String index(Model model) 

Model은 뷰에 데이터를 전달할 때 사용하는 객체입니다. 데이터 모델을 뷰에 전달하기 위해 사용됩니다. Model은 서버 템플릿 엔진에서 사용할 수 잇는 객체를 저장할 수 있습니다.

 

model.addAttribute("posts", postsService.findAllDesc());

addAttribute() 메서드는 Spring MVC에서 Model 객체에 데이터를 추가할 때 사용하는 메서드입니다. 이 메서드를 통해 컨트롤러에서 뷰(HTML 파일)로 데이터를 전달할 수 있습니다.

postsService.findAllDesc() 메서드를 호출(posts-> PostsListResponseDto) 하여 게시글 목록을 가져온 후, posts라는 이름으로 model에 데이터를 추가합니다.

 


6. 서비스란

서비스는 도메인 객체들이 적절한 순서로 동작하게 하고, 트랜잭션의 경계를 설정하는 역할을 합니다.

Service에서는 비즈니스 로직을 처리하지 않고 트랜잭션, 도메인 간 순서 보장의 역할만 합니다.  도메인 방식에서는 각 객체가 스스로의 이벤트 처리를 하고 서비스 메소드는 트랜잭션과 도메인의 순서만 보장합니다.

 

@Transactional은 Spring 프레임워크에서 제공하는 트랜잭션 관리를 위한 어노테이션입니다.
트랜잭션(Transaction)은 작업 단위를 묶어 **"모두 성공하거나 모두 실패해야 한다"**는 원칙으로 처리하도록 하는 메커니즘입니다.

PostsService는 **서비스 레이어(Service Layer)**를 구현한 클래스로, **도메인 모델(Posts)**과 웹 계층(Controller) 사이에서 중간 다리 역할을 합니다.

예시로 이해하기

더보기

도메인과 서비스 협력의 흐름 예시:

시나리오

온라인 쇼핑몰에서 사용자가 상품을 구매하려고 할 때:

  1. 상품의 재고를 확인하고,
  2. 재고가 충분하다면 결제를 처리한 뒤,
  3. 구매 기록을 저장합니다.

도메인 클래스

public class Product {
    private int stock;

    public void decreaseStock(int quantity) {
        if (stock < quantity) {
            throw new IllegalStateException("재고가 부족합니다.");
        }
        stock -= quantity;
    }
}

public class Payment {
    public void processPayment(double amount) {
        // 결제 처리 로직 (예: 카드 결제 API 호출)
    }
}

서비스 클래스

@Service
@Transactional
public class OrderService {
    private final ProductRepository productRepository;
    private final PaymentService paymentService;

    public OrderService(ProductRepository productRepository, PaymentService paymentService) {
        this.productRepository = productRepository;
        this.paymentService = paymentService;
    }

    public void placeOrder(Long productId, int quantity, double amount) {
        // 1. 상품 재고 확인 및 감소
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다."));
        product.decreaseStock(quantity);

        // 2. 결제 처리
        paymentService.processPayment(amount);

        // 3. 주문 기록 저장
        saveOrderRecord(productId, quantity, amount);
    }

    private void saveOrderRecord(Long productId, int quantity, double amount) {
        // 주문 기록을 데이터베이스에 저장
    }
}

7. 게시글 수정, 삭제 화면 만들기

PostsApiController.java를 보면 update에 이미 수정 api를 만들어 두었습니다.

    @PutMapping("/api/v1/posts/{id}")
    public Long update(@PathVariable Long id, @RequestBody PostsSaveRequestDto requestDto) {
        return postsService.update(id, requestDto);
    }

index.mustache에는 다음과 같이 값을 업데이트 할 수 있는 화면으로 이동하는 a태그가 있습니다.

<a href="/posts/update/{{id}}">{{title}}</a>

 

IndexController.java

package com.ulbbang.book.firstproject.web;

import com.ulbbang.book.firstproject.service.posts.PostsService;
import com.ulbbang.book.firstproject.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@RequiredArgsConstructor
@Controller
public class IndexController {
//추가
    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post", dto);
        return "posts-update";
    }

}


8. 게시글 삭제

PostsSerive

 

JpaRepository에서 이미 delete 메소드를 지원하고 있으므로 이를 활용합니다. 엔티티를 파라미터로 삭제할 수 있고 deleteById메소드를 이용하면 id로 삭제할 수도 있습니다.

존재하는 Posts인지 확인을 위해 엔티티 조회 후 그대로 삭제합니다.

    @Transactional
    public void delete(Long id) {
        Posts posts = postsRepository.findById(id)
                .orElseThrow(
                        ()-> new IllegalArgumentException("해당 게시글이 없습니다. id="+id));
                        postsRepository.delete(posts);
    }

 

postsRepository.findById(id) 메서드를 호출하여 id에 해당하는 Posts 객체를 조회합니다. findById는 Optional<Posts>를 반환하므로, 해당 게시글이 존재하지 않으면 예외가 발생합니다.

조회 결과가 없으면 orElseThrow()를 통해 IllegalArgumentException 예외를 던지며, 예외 메시지에는 id를 포함하여 "해당 게시글이 없습니다."라는 메시지를 전달합니다.

 

postsRepository.delete(posts)를 호출하여 Posts 엔티티를 삭제합니다. JPA에서는 delete() 메서드가 엔티티를 인자로 받아서 삭제할 수 있기 때문에, 조회된 Posts 객체를 그대로 삭제할 수 있습니다. postsRepository에서 작성하지 않았고 JpaRepository에서 해당 내용을 상속받아 사용합니다.

 

PostsApiController

    @DeleteMapping("/api/v1/posts/{id}")
    public Long delete(@PathVariable Long id) {
        postsService.delete(id);
        return id;
    }

 

9. IndexController와 PostsApiController


IndexController는 웹 페이지의 화면을 담당하는 컨트롤러입니다. 이 클래스는 템플릿을 렌더링하는 데 사용됩니다. 즉, 사용자에게 보여지는 HTML 페이지를 반환하는 역할을 합니다.

 

PostsApiController는 RESTful API 요청을 처리하는 API 컨트롤러입니다. 이 클래스는 JSON 데이터를 주고받는 역할을 하며, REST API를 통해 클라이언트와 데이터를 주고받는 기능을 합니다.

 

즉 IndexController는 화면(view)과 관련된 요청을 처리하고, PostsApiController는 API 요청을 처리합니다.

 

 


이 포스트를 모두 읽은 당신에게 소개하는 동영상
주제 : react VS vue 

 

카카오가 리액트 냅두고 왜 Vue 쓰는지 알려드림 - 코딩애플 온라인 강좌

실은 카카오 프론트엔드 개발자들은 리액트랑 반반 섞어서 쓴다고 합니다. 다만 초절정 유행 중인 리액트라는 선택지를 놔두고 네이버든 카카오든 Vue로 새로운 페이지들을 만들어내는 경우가

codingapple.com