Sparta/What I Learned

22.12.8

코딩하는 또롱이 2022. 12. 8. 18:22
JPA 기초

 

🧐 애플리케이션이 직접 데이터를 다룬다면?

1. 훨씬 더 번거롭다.

  • 데이터베이스 테이블 만들기
  • 애플리케이션에서 쿼리 직접 생성
  • 쿼리를 jdbc api 통해 직접 실행
  • 쿼리 결과로 해당 객체도 직접 생성

2. SQL 의존적이라 변경에 취약하다.

  • 쿼리문도 애플리케이션에서 직접 수정
  • 유저객체에 값 넣어주는 부분 추가

3. 객체지향 모델과 관계형 데이터베이스의 패러다임 불일치가 발생한다.
       👉 자바-스프링 서버와 관계형 데이터 베이스 모두 사용해야하는데 두 모델에서 패러다임의 불일치 발생

출처 : https://knoc-story.tistory.com/m/90

  객체 릴레이션
밀도문제 다양한 크기의 객체를 만들 수 있음,
타입을 커스텀하기 쉬움
테이블, 기본 데이터 타입
서브타입 문제 상속, 다형성 구현 쉬움 상속 없음, 다형적 관계 표현 불가
식별성 문제 레퍼런스 & 인스턴스 동일성 오직 PK
관계 문제 서로의 객체 참조를 통해 표현,
다대다 가능,
방향 있음
다대다 불가능,
다대다로 맺어주는 테이블로 처리
FK로 바로 조회 가능(방향 X)
데이터 네비게이션 문제 래퍼런스를 타고 마음대로 이동 가능 JOIN으로만 가져올 수 있고(비효율적), 성능 이슈가 발생할 수 있음
  • 데이터베이스의 데이터가 더 정형화되어있고 까다롭다.
  • 관계형 데이터 베이스에는 상속 개념이 없다.
    (상속은 객체의 역할과 구현을 분리해주기 위해 객체 지향 프로그래밍에서 가장 핵심적인 기능 중 하나)
  • 관계 문제와 데이터 네비게이션 문제
    👉 아래 예시를 통해 설명 
👉 예시
class Member {
    private int memberId;
    private String mamberName;
    private Team team;

}

class Team {
    private int teamId;
    private String teamName;
}


    Member member = new Member();
    Team team = new Team();

member.setTeam(team);

        member.team(); //가능
        team.member(); // 불가능

Member 클래스 안에 Team 객체가 들어가 있고 각자 인스턴스화 했을 떄, Member 인스턴스 안에 Team 인스턴스를 넣을 수 있다. 이 때, Member 인스턴스를 통해 Team을 불러올 순 있지만 Team 인스턴스를 통해 Member를 불러올 수는 없다.

 

하지만 DB로 보면 member 안에 team_id가 FK로 들어 있으므로, 양 쪽에서 전부 데이터를 가져올 수 있다.

 

이러한 패러다임 불일치에서 기인한 문제들과, 반복적이고 번거로운 어플리케이션 단에서의 쿼리 작업을 줄여주기 위해서 ORM(객체 관계 매핑)기술과 JPA(Java Persistence API : 자바 ORM 기술에 대한 표준 명세)들이 등장했다.

 

 

 

😎 특히 JPA는

  1. 쿼리를 자동으로 만들어준다.
  2. 그리고 어플리케이션 계층에서 SQL의존성을 줄여서 번거로운 작업이 매우매우 단축된다.
  3. 패러다임의 불일치를 해결해준다.
  4. 특정한 상황을 제외하고는,성능도 좋다. 
  5. 방언도 지원해준다. h2 Databse를 붙여도, mySQL, oracle 등 뭘 붙여도 코드의 변경은 없다. 관계형 DB이자 표준을 준수한 SQL을 지원한다면, JPA가 방언들도 알아서 처리해준다.
🤔 그럼 ORM기술, 특히 자바의 JPA가 다 해주는데 왜 지난시간에 귀찮게 데이터 베이스와 쿼리를 공부했을까?
일단 해당 쿼리를 발생시키는 개발자가 쿼리에 대하여 정확하게 이해하고 있어야 최적화-성능 등 다양한 이슈에 대응 할 수 있으며, 간혹 발생하는 실시간 처리 목표가 아닌 쿼리의 경우 쿼리를 직접 만들어야한다. 그리고 DB설계등 다양한 지식은 꾸준히 공부하셔야 하는 주제이다.

 

🧐 JPA 샘플

 

1) Java 클래스

@Entity // DB 테이블 역할을 합니다.
public class User {
    // ID가 자동으로 생성 및 증가합니다.
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    // nullable: null 허용 여부
    // unique: 중복 허용 여부 (false 일때 중복 허용)
    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;

    @Column(unique = true)
    private Long kakaoId;
}

2) DB 테이블 및 컬럼

 

 

 

 

 

 

 

 

 

 

3) 테이블 형식

ID  EMAIL  KAKAO_ID  PASSWORD  ROLE  USERNAME
           

 

🧐 DB 의 연관관계 이해

▶️ 자바는 객체와 레퍼런스로, 데이터베이스는 테이블사이의 관계(FK)로 정보 사이의 연관관계를 표현하고 처리한다. 이 두 방식의 차이를 해결해주기 위해서 JPA에는 “Java 어플리케이션 상에서”, “데이터베이스의 연관관계”를 표현해주기 위한 장치들을 가지고 있다.

 

1)DB 의 연관관계 체크사항

✔️ JPA 가 제공하는 연관관계는 결국 DB 의 연관관계를 표현하기 위함이다.

✔️ 따라서 먼저 DB 의 연관관계를 이해해야한다.

✔️ DB 의 연관관계는 비즈니스 요구사항에 맞춰 이루어진다.

 

2) 음식 주문앱 DB 설계 예제

 예를 들어, 음식 주문 앱 DB를 설계한다고 가정할 때, "고객이 1개의 음식을 주문할 수 있다"라는 요구사항을 받았다고 가정해보자.

📌 이 부분은 말 그대로, Java, JPA가 아닌 데이터 베이스 설계 관련한 내용이다.
아래의 자료들은 말 그대로 오직 “데이터 베이스 테이블” 설계 내용이다.

1.일단 각 주체의 테이블 설계가 필요

 

  • 고객(User) 테이블
ID name
1 삼식이
2 먹깨비
  • 음식(Food) 테이블
ID name price
1 후라이드 치킨 10,000
2 양념 치킨 12,000
3 반반 치킨 13,000
4 고구마 피자 9,000
5 아보카도 피자 110,000

2.연관 관계 고민

고객이 음식 주문 시, 주문 정보는 어느 테이블에 들어가야 할까?

→ 테이블 설계 시 실제 값을 넣어보면 짐작하기 조금 더 쉽다.

이렇게 중복 되는 것들을 막기 위해서 '주문'을 위한 테이블이 필요하다.

  • 회원 1명은 주문 N개를 할 수 있다. (회원 : 주문 = 1 : N )
  • 음식 1개는 주문 N개를 받을 수 있다. (음식 : 주문  = 1 : N )
  • ⁖  회원 : 음식 = N : N  → 다대다 관계

 

🧐 JPA 연관관계

1) JPA 연관관계 설정방법

 

JPA 의 경우는 Enitity 클래스의 필드 위에 연관관계 어노테이션 (@) 을 설정해 주는 것만으로 연관관계가 형성된다.

2) JPA 코드 구현

엔티티와 테이블을 그대로 보여드리기 위한 예제 코드인 만큼 일부 비효율적인 로직이 있다. 어떻게 엔티티를 매핑하면 어떻게 데이터베이스 테이블로 나오는지 이해한다는 생각으로 강의와 함께 코드를 봐주삼.

 

[프로젝트 생성]

더보기
종속성 설정
application.properties에서 h2 설정
pakage 설정

 

[Entity_Member]

더보기
package com.example.springjpa.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Getter
@Entity
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String memberName;

    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
//    mappedBy가 연관 관계를 이어줌
//    Orders의 Joincolumn부분의 member_id랑 이 부분이랑 연결 되어 있다는 의미
    private List<Orders> orders = new ArrayList<>();

    public Member(String memberName) {
        this.memberName = memberName;

    }
}

[Entity_Food]

더보기
package com.example.springjpa.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Getter
@Entity
@NoArgsConstructor

public class Food {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String foodName;
    @Column(nullable = false)
    private int price;

    @OneToMany(mappedBy = "food",fetch = FetchType.EAGER)
//    mappedBy가 연관 관계를 이어줌
//    Orders의 Joincolumn부분의 food_id랑 이 부분이랑 연결 되어 있다는 의미
    private List<Orders> orders = new ArrayList<>();

    public Food(String foodName, int price) {
        this.foodName = foodName;
        this.price = price;
    }

}

[Entity_Orders]

더보기
package com.example.springjpa.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@NoArgsConstructor
public class Orders {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "food_id")
    private Food food;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    public Orders(Food food, Member member) {
        this.food = food;
        this.member = member;
    }
}

[Repository_MemberRepository]

더보기
package com.example.springjpa.repository;

import com.example.springjpa.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}

[Repository_FoodRepository]

더보기
package com.example.springjpa.repository;

import com.example.springjpa.entity.Food;
import org.springframework.data.jpa.repository.JpaRepository;

public interface FoodRepository extends JpaRepository<Food, Long> {
//    JpaRepository 를 상속받는 FoodRepository 인터페이스를 생성할건데
//    Food Entity 와 관련있고, Food id가 long 타입이었기 때문에 제너릭스 타입 또한 Long 으로 설정

}

[Repository_OrdersRepository]

더보기
package com.example.springjpa.repository;

import com.example.springjpa.entity.Orders;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrdersRepository extends JpaRepository<Orders, Long> {
}

[Restaurant]

더보기
package com.example.springjpa;

import com.example.springjpa.entity.Food;
import com.example.springjpa.entity.Member;
import com.example.springjpa.entity.Orders;
import com.example.springjpa.repository.FoodRepository;
import com.example.springjpa.repository.MemberRepository;
import com.example.springjpa.repository.OrdersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
@RequiredArgsConstructor
public class Restaurant implements ApplicationRunner {

    private final FoodRepository foodRepository;
    private final OrdersRepository ordersRepository;
    private final MemberRepository memberRepository;

    @Override
    public void run(ApplicationArguments args) throws Exception {

        List<Food> foods = new ArrayList<>();
        Food food1 = new Food("후라이드", 10000);
        foods.add(food1);
        Food food2 = new Food("양념치킨", 12000);
        foods.add(food2);
        Food food3 = new Food("반반치킨", 13000);
        foods.add(food3);
        Food food4 = new Food("고구마피자", 9000);
        foods.add(food4);
        Food food5 = new Food("아보카도피자", 110000);
        foods.add(food5);
        foodRepository.saveAll(foods);

        List<Member> members = new ArrayList<>();
        Member member1 = new Member("삼식이");
        members.add(member1);
        Member member2 = new Member("먹깨비");
        members.add(member2);
        memberRepository.saveAll(members);

        System.out.println("==================================================================");

        System.out.println("Member 데이터");
        List<Member> findMembers = memberRepository.findAll();
        for (Member findMember : findMembers) {
            System.out.println("findMember = " + findMember.getMemberName());
        }

        System.out.println("==================================================================");

        System.out.println("Food 데이터");
        List<Food> findFoods = foodRepository.findAll();
        for (Food findFood : findFoods) {
            System.out.println("findFood = " + findFood.getFoodName());
        }

        List<Orders> ordersList = new ArrayList<>();
        Orders orders1 = new Orders(findFoods.get(0), findMembers.get(0));
        ordersList.add(orders1);
        Orders orders2 = new Orders(findFoods.get(3), findMembers.get(1));
        ordersList.add(orders2);
        Orders orders3 = new Orders(findFoods.get(4), findMembers.get(1));
        ordersList.add(orders3);
        Orders orders4 = new Orders(findFoods.get(2), findMembers.get(0));
        ordersList.add(orders4);
        Orders orders5 = new Orders(findFoods.get(2), findMembers.get(0));
        ordersList.add(orders5);
        Orders orders6 = new Orders(findFoods.get(1), findMembers.get(1));
        ordersList.add(orders6);
        Orders orders7 = new Orders(findFoods.get(1), findMembers.get(0));
        ordersList.add(orders7);
        Orders orders8 = new Orders(findFoods.get(3), findMembers.get(1));
        ordersList.add(orders8);
        ordersRepository.saveAll(ordersList);

        System.out.println("==================================================================");
        int num = 1;

        System.out.println("Orders 데이터");
        List<Orders> orderList = ordersRepository.findAll();

        for (Orders orders : orderList) {
            System.out.println(num);
            System.out.println("주문한 사람 = " + orders.getMember().getMemberName());
            System.out.println("주문한 음식 = " + orders.getFood().getFoodName());
            num++;
        }

        System.out.println("==================================================================");
        System.out.println("삼식이 주문한 음식");
        Member samsik = memberRepository.findById(1L).orElseThrow(
                ()->new RuntimeException("없음")
        );

        num = 1;
        for (Orders orders : samsik.getOrders()) {
            System.out.println(num);
            System.out.println("주문한 음식 = " + orders.getFood().getFoodName());
            System.out.println("주문한 음식 가격 = " + orders.getFood().getPrice());
            num++;
        }


        System.out.println("==================================================================");
        System.out.println("아보카도피자 주문한 사람");
        Food avocado = foodRepository.findById(5L).orElseThrow(
                ()->new RuntimeException("없음")
        );

        for (Orders order : avocado.getOrders()) {
            System.out.println("주문한 사람 = " + order.getMember().getMemberName());
        }


    }
}

 

❣️ 웹으로 확인 해 본 위의  JPA 코드들

FOOD를 누르고 Run을 누르면 콘솔창에 데이터들이 예쁘게 나온다 😎
Member
Orders

3) 상속을 이용해서 생성 수정시간 관리하기

📌 데이터의 생성(created_at), 수정(last_modified_at) 시간은 포스팅, 게시글, 댓글 등 다양한 데이터에 매우 자주 활용된다. 각각의 엔티티의 생성 수정 시간을 매번 작성하는건 시간낭비이다. 이럴 땐 어떻게 할까?
import jakarta.persistence.Column;

import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class Timestamped {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column
    private LocalDateTime modifiedAt;
}

엔티티를 만들 때 이런 객체를 만들고,

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;

@Entity // 게시글
public class Post extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, unique = true)
    private String content;
}

위와 같이 “extends Timestamped”로 해당 객체를 상속받는다면 상속받는 엔티티 객체들은 항상 아래와 같은 칼럼들을 가지고 있게 된다.

@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;

@LastModifiedDate
@Column
private LocalDateTime modifiedAt;

extends Timestamped” 두 단어만으로 상속받는 객체들은 자동으로 생성 / 수정 시간을 데이터베이스에 입력하고, 필요하다면 해당 값을 편하게 Get 할 수 있다.

import jakarta.persistence.Column;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;

@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;

@LastModifiedDate
@Column
private LocalDateTime modifiedAt;
📌 @SpringBootApplication 이 있는 class 에 @EnableJpaAuditing 추가!

 

🧐 Spring Data JPA 이해

1) Spring Data JPA?

  • JPA 를 편리하게 사용하기 위해, 스프링에서 JPA를 Wrapping한 Dependency
  • 스프링 개발자들이 JPA를 사용할 때 필수적으로 생성해야 하나, 예상 가능하고 반복적인 코드들
    → Spring Data JPA가 대신 작성
  • Repostiory 인터페이스만 작성하면, 필요한 구현은 스프링이 알아서 구현한다.

2)예제

[상품 Entity 선언]

[Spring Data JPA - 상품 Repository 생성]

[Spring Data JPA - 기본 제공해 주는 기능]

[ID 외의 필드에 대한 추가 기능은 interface 만 선언해 주면, 구현은 Spring Data JPA 가 대신!]

 

👉 Restaurant에 함수 추가해보기

- MemberRepository에 함수를 만들기

- Restaurant에 추가한 함수 불러오기

- 콘솔 값 확인하기

 

👉 Spring Data JPA 추가기능 구현방법은 공식문서에 명시되어 있음

Spring Data JPA 의 Query Methods
 

Spring Data JPA - Reference Documentation

Example 119. Using @Transactional at query methods @Transactional(readOnly = true) interface UserRepository extends JpaRepository { List findByLastname(String lastname); @Modifying @Transactional @Query("delete from User u where u.active = false") void del

docs.spring.io

 

 


 

Project Memo Part 1

 

▶️ 개요

앞에서 배운 WEB, API, Spring, DB, JPA 등의 개념 및 기술을 활용하여 간단한 메모장 사이트를 만들어 보겠습니다.

 

▶️ 필수 기능

  1. 접속 하자마자 메모 전체 목록 조회하기
    1. GET API 사용해서 메모 목록 불러오기
    2. 메모 마다 HTML 만들고 붙이기
  2. 메모 생성하기
    1. 사용자가 입력한 메모 내용 확인하기
    2. POST API 사용해서 신규 메모 생성하기
    3. 화면 새로고침하여 업데이트 된 메모 목록 확인하기
  3. 메모 변경하기
    1. 사용자가 선택하고 수정한 메모가 어떤 것인지 확인
    2. 변경한 메모 내용 확인
    3. PUT API 사용해서 메모 내용 변경하기
    4. 화면 새로고침하여 업데이트 된 메모 목록 확인하기
  4. 메모 삭제하기
    1. 사용자가 선택한 메모가 어떤 것인지 확인
    2. DELETE API 사용해서 메모 삭제하기
    3. 화면 새로고침하여 업데이트 된 메모 목록 확인하기

 

인데 일단 CSS, HTML,JavaScript, jQuery, 스프링 MVC에 대해 알아 보자! 는 내가 모르는 부분만 정리해 놓을 예정 

흐름을 알고 싶으면 강의 노션 확인 고고!

 

HTML

기본은 이미 지겹도록 했다. 원한다면 책을 보도록하자. 

아래의 링크는 알아두면 좋은 링크들!

 

Learn HTML | Codecademy

Start at the beginning by learning HTML basics — an important foundation for building and editing web pages.

www.codecademy.com

 

HTML 수업 - 생활코딩

수업의 목적 본 수업은 HTML에 대한 심화된 내용을 다룹니다. HTML의 기본문법과 HTML의 주요한 태그들에 대한 수업을 담고 있습니다.  선행학습 본 수업을 효과적으로 수행하기 위해서는 웹애플리

opentutorials.org

 

스프링 MVC 이해 - Response

👉 MVC (Model - View - Controller) 디자인 패턴

👉 Server 에서 HTML 을 내려 주는 경우

스프링에서는 타임리프를 추천

👉 타임리프를 이용한 스프링 MVC 실습

더보기

1.Intellij - New project 생성

 springjpa와 마찬가지로 아무것도 건들이지 않고 종속성만 !!!

 

2. 정적 웹페이지 생성

[static/hello.html]

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>Hello Spring</title>
</head>
<body>
<div>
    Hello, Spring 정적 웹 페이지!!
</div>
</body>
</html>


3. 동적 웹페이지 생성

[templates/hello.html]

<!DOCTYPE html>
<html lang="ko" >
<head>
  <meta charset="UTF-8">
  <title>Hello Spring</title>
</head>
<body>
<div>
  Hello, Spring templates 페이지!!
</div>
</body>
</html>

[templates/hello-visit.html]

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Hello Spring</title></head>
<body>
<div>
  Hello, Spring 동적 웹 페이지!!
</div>
<div>
  (방문자 수: <span th:text="${visits}"></span>)
</div>
</body>
</html>

[HelloResponseController.java]

package com.example.springmvc;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

@Controller
@RequestMapping("/response")
public class HelloResponseController {

    private static long visitCount = 0;

    @GetMapping("/html/redirect")
//    정적 웹페이지
    public String htmlFile() {
        return "redirect:/hello.html";
    }

    @GetMapping("/html/templates")
//    정적 웹페이지
//    Template engine 에 View 전달
//
//    타임리프 default 설정
//     - prefix: classpath:/templates/
//     - suffix: .html
//    resources**/templates/**hello**.html**
//
    public String htmlTemplates() {
        return "hello";
    }

    @ResponseBody
    @GetMapping("/body/html")
//    정적 웹페이지
    public String helloStringHTML() {
        return "<!DOCTYPE html>" +
                "<html>" +
                "<head><meta charset=\"UTF-8\"><title>By @ResponseBody</title></head>" +
                "<body> Hello, 정적 웹 페이지!!</body>" +
                "</html>";
    }

    @GetMapping("/html/dynamic")
//    동적 웹페이지
//    View,  Model 정보 → 타임리프에게 전달
//    타임리프 처리방식
//      View 정보
//          hello-visit" → resources/templates/hello-visit.html
//      Model 정보
//          visits: 방문 횟수 (visitCount)
    public String helloHtmlFile(Model model) {
        visitCount++;
        model.addAttribute("visits", visitCount);
        return "hello-visit";
    }

    @ResponseBody
    @GetMapping("/json/string")
//    반환값: String
    public String helloStringJson() {
        return "{\"name\":\"르세라핌\",\"age\":20}";
    }

    @ResponseBody
    @GetMapping("/json/class")
//    반환값: String 외 자바 클래스
//    "자바 객체 → JSON 으로 변환" 은 스프링이 해 줌
    public Star helloJson() {
        return new Star("르세라핌", 20);
    }
}

메서드들마다 @ResponsBody 애노테이션을 붙여야 한다. View 를 사용하지 않고, HTTP Body 에 들어갈 String 을 직접 입력해야하기 때문이다.

[HelloRestController.java]

= @Controller + @ResponseBody

package com.example.springmvc;

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

import java.util.Arrays;
import java.util.List;

@RestController
@RequestMapping("/rest")
public class HelloRestController {

    @GetMapping("/json/string")
    public String helloHtmlString() {
        return "<html><body>Hello @ResponseBody</body></html>";
    }

    @GetMapping("/json/list")
    public List<String> helloJson() {
        List<String> words = Arrays.asList("Hello", "Controller", "And", "JSON");

        return words;
    }
}

Response와 달리 Rest는 상단에 @RestController 하나만 붙이면 메서드들마다 따로 애노테이션을 안 붙여도 된다.

[Star.html]

package com.example.springmvc;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@AllArgsConstructor
public class Star {
    String name;
    int age;
}

 

👉 스프링 MVC 동작원리

더보기

@Controller 는 스프링 서버 개발자 입장에서는 시작점과 끝점으로 보이지만, 사실 스프링이 뒤에서 많은 부분을 보이지 않게 처리해 주고 있다.

 

  1. Client → DispatcherServlet
    1. 가장 앞 단에서 요청을 받아 FrontController 라고도 불림
  2. DispatcherServlet → Controller
    • API 를 처리해 줄 Controller 를 찾아 요청을 전달
    • Handler mapping 에는 API path 와 Controller 함수가 매칭되어 있음
    💡 [Sample]
    GET /response/html/dynamic → HelloResponseController 의 helloHtmlFile() 함수
    GET /response/json/string → HelloResponseController 의 helloStringJson() 함수
    • 함수 이름을 개발자 마음대로 설정 가능했던 이유!!
  3. Controller → DispathcerServlet
    • Controller 가 Client 으로 받은 API 요청을 처리
    • 'Model' 정보와 'View' 정보를 DispatcherServlet 으로 전달
  4. DispatcherServlet → Client
    • ViewResolver 통해 View 에 Model 을 적용
    • View 를 Client 에게 응답으로 전달

 

스프링 MVC 이해 - Request

▶️ Controller 와 HTTP Request 메시지

 

[HelloRequestController.java]

더보기
package com.example.springmvc;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

@Controller
@RequestMapping("/request")
public class HelloRequestController {

    @GetMapping("/form/html")
    public String helloForm() {
        return "hello-request-form";
    }

    @GetMapping("/star/{name}/age/{age}")
    @ResponseBody
    public String helloRequestPath(@PathVariable String name, @PathVariable int age)
    {
        return String.format("Hello, @PathVariable.<br> name = %s, age = %d", name, age);
    }

    @GetMapping("/form/param")
    @ResponseBody
    public String helloGetRequestParam(@RequestParam String name, @RequestParam int age) {
        return String.format("Hello, @RequestParam.<br> name = %s, age = %d", name, age);
    }
//    Get은 URL에 값이 그대로 노츌 (쿼리스트링 방식)

    @PostMapping("/form/param")
    @ResponseBody
    public String helloPostRequestParam(@RequestParam String name, @RequestParam int age) {
        return String.format("Hello, @RequestParam.<br> name = %s, age = %d", name, age);
    }
//    POST는 URL에 값이 노출되지 않음


    @PostMapping("/form/model")
    @ResponseBody
    public String helloRequestBodyForm(@ModelAttribute Star star) {
        return String.format("Hello, @RequestBody.<br> (name = %s, age = %d) ", star.name, star.age);
    }
//    @ModelAttribute 생략하고 Star star 객체만 받아도 된다. 값이 많을때 유용

    @PostMapping("/form/json")
    @ResponseBody
    public String helloPostRequestJson(@RequestBody Star star) {
        return String.format("Hello, @RequestBody.<br> (name = %s, age = %d) ", star.name, star.age);
    }
//    JSON 형식으로 값이 넘어간다.
//    이 방식은 애노테이션 없애면 안된다.
//    객체로 받아오는 메서드들은 클라이언트에서 보내주는 키 값과 서버에서 받는 필드명이 전부 정확히 일치해야만한다.
}

[hello-request-form.html]

더보기
<!DOCTYPE html>
<html lang="ko">
<head>
  <title>Hello Request</title>
</head>
<body>
<h2>GET /request/star/{name}/age/{age}</h2>
<form id="helloPathForm">
  <div>
    이름: <input name="name" type="text">
  </div>
  <div>
    나이: <input name="age" type="text">
  </div>
</form>
<div>
  <button id="helloPathFormSend">전송</button>
</div>
<br>

<h2>GET /request/form/param</h2>
<form method="GET" action="/request/form/param">
  <div>
    이름: <input name="name" type="text">
  </div>
  <div>
    나이: <input name="age" type="text">
  </div>
  <button>전송</button>
</form>

<br>

<h2>POST /request/form/param</h2>
<form method="POST" action="/request/form/param">
  <div>
    이름: <input name="name" type="text">
  </div>
  <div>
    나이: <input name="age" type="text">
  </div>
  <button>전송</button>
</form>
<br>

<h2>POST /request/form/model</h2>
<form method="POST" action="/request/form/model">
  <div>
    이름: <input name="name" type="text">
  </div>
  <div>
    나이: <input name="age" type="text">
  </div>
  <button>전송</button>
</form>
<br>

<h2>POST /request/form/json</h2>
<form id="helloJsonForm">
  <div>
    이름: <input name="name" type="text">
  </div>
  <div>
    나이: <input name="age" type="text">
  </div>
</form>
<div>
  <button id="helloJsonSend">전송</button>
</div>
<div>
  <div id="helloJsonResult"></div>
</div>
</body>
<script>
  // GET /star/{name}/age/{age}
  const helloPathForm = document.querySelector("#helloPathFormSend")
  helloPathForm.onclick = (e) => {
    const form = document.querySelector("#helloPathForm");
    const name = form.querySelector('input[name="name"]').value;
    const age = form.querySelector('input[name="age"]').value;
    const relativeUrl = `/request/star/${name}/age/${age}`;
    window.location.href = relativeUrl;
  }

  // POST /hello/request/form/json
  const helloJson = document.querySelector("#helloJsonSend")
  helloJson.onclick = async (e) => {
    const form = document.querySelector("#helloJsonForm");

    const data = {
      name: form.querySelector('input[name="name"]').value,
      age: form.querySelector('input[name="age"]').value
    }

    const response = await fetch('/request/form/json', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    })

    const text = await response.text(); // read response body as text
    document.querySelector("#helloJsonResult").innerHTML = text;
  };
</script>
</html>

 


 

Project Memo Part 1

 

🧐 프로젝트 만들고 API 설계하기

👉 개요

  • 앞 강의들을 통해 Controller - Service - Repository 3계층이 존재한다는 것을 학습했다.
  • 메모장 프로젝트를 완성해가면서 해당 구조를 이해해보자.

 

👉 프로젝트 새로 만들기

더보기

똑같이 자바 17로 선택하고 종속성 아래와 같이 추가해주고 생성!

 

👉 API 설계하기 (CRUD)

 

🧐 메모장 프로젝트 Client 구축하기

👉 시작코드

더보기

📌 src > main > resources > static 에 images 폴더를 만들고, 이미지 4장을 생성

 

📌 src > main > resources > templates 에 아래 index.html 생성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Timeline Service</title>

  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@500&display=swap" rel="stylesheet">
  <script>
    $(document).ready(function () {
      // HTML 문서를 로드할 때마다 실행합니다.
      getMessages();
    })

    // 메모 하나를 HTML로 만들어서 body 태그 내 원하는 곳에 붙입니다.
    function addHTML(id, username, contents, modifiedAt) {
      let tempHtml = makeMessage(id, username, contents, modifiedAt);
      $('#cards-box').append(tempHtml);
    }

    function makeMessage(id, username, contents, modifiedAt) {
      return `<div class="card">
                        <!-- date/username 영역 -->
                        <div class="metadata">
                            <div class="date">
                                ${modifiedAt}
                            </div>
                            <div id="${id}-username" class="username">
                                ${username}
                            </div>
                        </div>
                        <!-- contents 조회/수정 영역-->
                        <div class="contents">
                            <div id="${id}-contents" class="text">
                                ${contents}
                            </div>
                            <div id="${id}-editarea" class="edit">
                                <textarea id="${id}-textarea" class="te-edit" name="" id="" cols="30" rows="5"></textarea>
                            </div>
                        </div>
                        <!-- 버튼 영역-->
                        <div class="footer">
                            <img id="${id}-edit" class="icon-start-edit" src="/images/edit.png" alt="" onclick="editPost('${id}')">
                            <img id="${id}-delete" class="icon-delete" src="/images/delete.png" alt="" onclick="deleteOne('${id}')">
                            <img id="${id}-submit" class="icon-end-edit" src="/images/done.png" alt="" onclick="submitEdit('${id}')">
                        </div>
                    </div>`;
    }

    // 사용자가 내용을 올바르게 입력하였는지 확인합니다.
    function isValidContents(contents) {
      if (contents == '') {
        alert('내용을 입력해주세요');
        return false;
      }
      if (contents.trim().length > 140) {
        alert('공백 포함 140자 이하로 입력해주세요');
        return false;
      }
      return true;
    }

    // 익명의 username을 만듭니다.
    function genRandomName(length) {
      let result = '';
      let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
      let charactersLength = characters.length;
      for (let i = 0; i < length; i++) {
        let number = Math.random() * charactersLength;
        let index = Math.floor(number);
        result += characters.charAt(index);
      }
      return result;
    }

    // 수정 버튼을 눌렀을 때, 기존 작성 내용을 textarea 에 전달합니다.
    // 숨길 버튼을 숨기고, 나타낼 버튼을 나타냅니다.
    function editPost(id) {
      showEdits(id);
      let contents = $(`#${id}-contents`).text().trim();
      $(`#${id}-textarea`).val(contents);
    }

    function showEdits(id) {
      $(`#${id}-editarea`).show();
      $(`#${id}-submit`).show();
      $(`#${id}-delete`).show();

      $(`#${id}-contents`).hide();
      $(`#${id}-edit`).hide();
    }

  </script>

  <style>
    @import url(//spoqa.github.io/spoqa-han-sans/css/SpoqaHanSans-kr.css);

    body {
      margin: 0px;
    }

    .area-edit {
      display: none;
    }

    .wrap {
      width: 538px;
      margin: 10px auto;
    }

    #contents {
      width: 538px;
    }

    .area-write {
      position: relative;
      width: 538px;
    }

    .area-write img {
      cursor: pointer;
      position: absolute;
      width: 22.2px;
      height: 18.7px;
      bottom: 15px;
      right: 17px;
    }

    .background-header {
      position: fixed;
      z-index: -1;
      top: 0px;
      width: 100%;
      height: 428px;
      background-color: #339af0;
    }

    .background-body {
      position: fixed;
      z-index: -1;
      top: 428px;
      height: 100%;
      width: 100%;
      background-color: #dee2e6;
    }

    .header {
      margin-top: 50px;
    }

    .header h2 {
      /*font-family: 'Noto Sans KR', sans-serif;*/
      height: 33px;
      font-size: 42px;
      font-weight: 500;
      font-stretch: normal;
      font-style: normal;
      line-height: 0.79;
      letter-spacing: -0.5px;
      text-align: center;
      color: #ffffff;
    }

    .header p {
      margin: 40px auto;
      width: 217px;
      height: 48px;
      font-family: 'Noto Sans KR', sans-serif;
      font-size: 16px;
      font-weight: 500;
      font-stretch: normal;
      font-style: normal;
      line-height: 1.5;
      letter-spacing: -1.12px;
      text-align: center;
      color: #ffffff;
    }

    textarea.field {
      width: 502px !important;
      height: 146px;
      border-radius: 5px;
      background-color: #ffffff;
      border: none;
      padding: 18px;
      resize: none;
    }

    textarea.field::placeholder {
      width: 216px;
      height: 16px;
      font-family: 'Noto Sans KR', sans-serif;
      font-size: 16px;
      font-weight: normal;
      font-stretch: normal;
      font-style: normal;
      line-height: 1;
      letter-spacing: -0.96px;
      text-align: left;
      color: #868e96;
    }

    .card {
      width: 538px;
      border-radius: 5px;
      background-color: #ffffff;
      margin-bottom: 12px;
    }

    .card .metadata {
      position: relative;
      display: flex;
      font-family: 'Spoqa Han Sans';
      font-size: 11px;
      font-weight: normal;
      font-stretch: normal;
      font-style: normal;
      line-height: 1;
      letter-spacing: -0.77px;
      text-align: left;
      color: #adb5bd;
      height: 14px;
      padding: 10px 23px;
    }

    .card .metadata .date {

    }

    .card .metadata .username {
      margin-left: 20px;
    }

    .contents {
      padding: 0px 23px;
      word-wrap: break-word;
      word-break: break-all;
    }

    .contents div.edit {
      display: none;
    }

    .contents textarea.te-edit {
      border-right: none;
      border-top: none;
      border-left: none;
      resize: none;
      border-bottom: 1px solid #212529;
      width: 100%;
      font-family: 'Spoqa Han Sans';
    }

    .footer {
      position: relative;
      height: 40px;
    }

    .footer img.icon-start-edit {
      cursor: pointer;
      position: absolute;
      bottom: 14px;
      right: 55px;
      width: 18px;
      height: 18px;
    }

    .footer img.icon-end-edit {
      cursor: pointer;
      position: absolute;
      display: none;
      bottom: 14px;
      right: 55px;
      width: 20px;
      height: 15px;
    }

    .footer img.icon-delete {
      cursor: pointer;
      position: absolute;
      bottom: 12px;
      right: 19px;
      width: 14px;
      height: 18px;
    }

    #cards-box {
      margin-top: 12px;
    }
  </style>
</head>

<body>
<div class="background-header">

</div>
<div class="background-body">

</div>
<div class="wrap">
  <div class="header">
    <h2>Timeline Service</h2>
    <p>
      공유하고 싶은 소식을 입력하세요.
    </p>
  </div>
  <div class="area-write">
        <textarea class="field" placeholder="공유하고 싶은 소식을 입력하세요." name="contents" th:id="contents" cols="30"
                  rows="10"></textarea>
    <img src="/images/send.png" alt="" onclick="writePost()">
  </div>
  <div id="cards-box" class="area-read">

  </div>
</div>
</body>

</html>

 

🧐 메모장 프로젝트 구현하기

👉 Repository

[테이블]

더보기

 

DB와 연결하고 테이블 역할을 하는 entity 완성

[Memo]

PK인 ID, username, contents 3개의 필드가 있다.

package com.example.hanghaememo.entity;

import com.example.hanghaememo.dto.MemoRequestDto;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import lombok.Getter;
import lombok.NoArgsConstructor;



@Getter
@Entity
@NoArgsConstructor
public class Memo extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String contents;

    public Memo(String username, String contents) {
        this.username = username;
        this.contents = contents;
    }

    public Memo(MemoRequestDto requestDto) {
        this.username = requestDto.getUsername();
        this.contents = requestDto.getContents();
    }

    public void update(MemoRequestDto memoRequestDto) {
        this.username = memoRequestDto.getUsername();
        this.contents = memoRequestDto.getContents();
    }


}

 

[Timestamped]

createdAt, modifiedAt 2개의 필드가 있고, 이것들은 Memo entity에 상속된다.

package com.example.hanghaememo.entity;

import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class Timestamped {

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime modifiedAt;
}

 

[MemoRepository]

JpaRepository에 상속되어 DB와 연결한다.

package com.example.hanghaememo.repository;


import com.example.hanghaememo.entity.Memo;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MemoRepository extends JpaRepository<Memo, Long> {
    List<Memo> findAllByOrderByModifiedAtDesc();
}

 

[추가 설정]

더보기
Edit Configurations... 누른다.
Modify Options(파란색 글씨)를 누른다.
On frame deactivation을 눌러서 Update classes and resources를 누른다.

종속성(dependency)을 추가할 때 dev tools를 추가 했었다. 이 툴을 이용하여 spring boot를 수동으 재시작하지 않고 자동으로 재시작되게 하는 설정이다.

[application.properties]

더보기

📌 @SpringBootApplication 이 있는 class 에 @EnableJpaAuditing 추가!

  

spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:db;MODE=MYSQL;
spring.datasource.username=sa
spring.datasource.password=

spring.thymeleaf.cache=false

 

👉 메인 페이지

더보기
@GetMapping("/")
    public ModelAndView home() {
//        반환 값을 String으로 줬을 떄, 이 반환 값을 templates directory에 있는 html 파일 이름을 따라서 넣어줬었는데
//        여기 안에 Model이라는 객체를 받아서 사용을 했던 기억이 있다고 말하는데 난 기억 없어. 언제 했어..ㅡㅡ?
//        암튼 데이터도 넣어줄 수 있고 templates에 반환할 html 이름도 설정할 수 있다.
//        객체를 생성할 때 생성자에 templates에 반환할 html 이름을 명시해 주면 templates에 있는 index.html을 반환해준다.
        return new ModelAndView("index");
    }

이 부분이 이렇게 나오는 부분이다.

👉 메모 생성하기

[Client]

더보기

[index.html] 브라우저에서 입력한 값을 서버로 보다.

// 메모를 생성합니다.
function writePost() {
  // 1. 작성한 메모를 불러옵니다.
  let contents = $('#contents').val();

  // 2. 작성한 메모가 올바른지 isValidContents 함수를 통해 확인합니다.
  if (isValidContents(contents) == false) {
    return;
  }

  // 3. genRandomName 함수를 통해 익명의 username을 만듭니다.
  let username = genRandomName(10);

  // 4. 전달할 data JSON으로 만듭니다.
  let data = {'username': username, 'contents': contents};

  $.ajax({
    type: "POST",
    url: "/api/memos",
    contentType: "application/json",
    data: JSON.stringify(data),
    success: function (response) {
      alert('메시지가 성공적으로 작성되었습니다.');
      window.location.reload();
    }
  });
}

 

[Memo.java]

  public Memo(MemoRequestDto requestDto) {
//        MemoService의 createMemo() 생성자 이다.
        this.username = requestDto.getUsername();
        this.contents = requestDto.getContents();
    }

 

[Server]

더보기

[MemoController.java] Client에서 메모를 생성한 것을 서버로 받아온다.

@PostMapping("/api/memos")
public Memo createMemo(@RequestBody MemoRequestDto requestDto) {
    return memoService.createMemo(requestDto);
}

 

[MemoService.java] Client가 생성한 Memo를 DB에 저장

  @Transactional
    public Memo createMemo(MemoRequestDto requestDto) {
        Memo memo = new Memo(requestDto);
//        생성자는 Memo에서 받아온다.
        memoRepository.save(memo);
//        DB에 연결이 되면서 자동으로 쿼리생성 된다.
        return memo;
    }

 

[MemoRequestDto.java]

더보기
package com.example.hanghaememo.dto;

import lombok.Getter;

@Getter
public class MemoRequestDto {
//    Client에서 넘어오는 객체를 받는다.
    private String username;
    private String contents;
}

 

👉 메모 조회하기

[Client]

더보기

[index.html]

// 메모를 불러와서 보여줍니다.
function getMessages() {
  // 1. 기존 메모 내용을 지웁니다.
  $('#cards-box').empty();

  // 2. 메모 목록을 불러와서 HTML로 붙입니다.
  $.ajax({
    type: "GET",
    url: "/api/memos",
    data: {},
    success: function (response) {
      for (let i = 0; i < response.length; i++) {
        let message = response[i];
        let id = message['id'];
        let username = message['username'];
        let contents = message['contents'];
        let modifiedAt = message['modifiedAt'];
        addHTML(id, username, contents, modifiedAt);
      }
    }
  });
}

 

[Server]

더보기

[MemoController.java]

Client에서 넘겨주는 데이터가 없기 때문에 파라미터가 없다.

@GetMapping("/api/memos")
public List<Memo> getMemos() {
    return memoService.getMemos();
}

 

[MemoService.java]

 @Transactional(readOnly = true)
    public List<Memo> getMemos() {
        return memoRepository.findAllByOrderByModifiedAtDesc();
//        최신순으로 보여줘야하므로 findAll() 변형해서!
    }

 

[MemoRepository.java]

MemoService에서 써야할 함수 정

 List<Memo> findAllByOrderByModifiedAtDesc();
//    내림차순으로

메모 생성, 저장, 최신순 정렬

 

👉 메모 변경하기

[Client]

더보기

[index.html]

// 메모를 수정합니다.
function submitEdit(id) {
  // 1. 작성 대상 메모의 username과 contents 를 확인합니다.
  let username = $(`#${id}-username`).text().trim();
  let contents = $(`#${id}-textarea`).val().trim();

  // 2. 작성한 메모가 올바른지 isValidContents 함수를 통해 확인합니다.
  if (isValidContents(contents) == false) {
    return;
  }

  // 3. 전달할 data JSON으로 만듭니다.
  let data = {'username': username, 'contents': contents};

  // 4. PUT /api/memos/{id} 에 data를 전달합니다.
  $.ajax({
    type: "PUT",
    url: `/api/memos/${id}`,
    contentType: "application/json",
    data: JSON.stringify(data),
    success: function (response) {
      alert('메시지 변경에 성공하였습니다.');
      window.location.reload();
    }
  });
}

 

[Server]

더보기

[MemoController.java]

index.html의 ajax 부분을 받는다. 누가 어떤 내용을 바꿨는지 알아야 하므로 파라미터가 존재한다.

@PutMapping("/api/memos/{id}")
public Long updateMemo(@PathVariable Long id, @RequestBody MemoRequestDto requestDto) {
    return memoService.update(id, requestDto);
}

 

[MemoService.java]

MemoController에서 쓰일 메서드 생성

  @Transactional
    public Long update(Long id, MemoRequestDto requestDto) {
        Memo memo = memoRepository.findById(id).orElseThrow(
//      수정할 메모가 DB에 실재하는지 확인
                () -> new IllegalArgumentException("아이디가 존재하지 않습니다.")
        );
        memo.update(requestDto);
        return memo.getId();
    }

 

[Memo.java]

MemoService에서 쓰일 메서드 생성

public void update(MemoRequestDto memoRequestDto) {
    this.username = memoRequestDto.getUsername();
    this.contents = memoRequestDto.getContents();
}

 

👉 메모 삭제하기

[Client]

더보기

[index.html]

function deleteOne(id) {
  $.ajax({
    type: "DELETE",
    url: `/api/memos/${id}`,
    success: function (response) {
      alert('메시지 삭제에 성공하였습니다.');
      window.location.reload();
    }
  })
}

 

[Server]

더보기

[MemoController.java]

index.html의 ajax를 받는다. 어떤 걸 삭제해야하는지 알아야하므로 파라미터 값이 존재한다.

@DeleteMapping("/api/memos/{id}")
public Long deleteMemo(@PathVariable Long id) {
    return memoService.deleteMemo(id);
}

 

[MemoService.java]

MemoController에서 쓰일 메서드 생성

@Transactional
public Long deleteMemo(Long id) {
    memoRepository.deleteById(id);
    return id;
}

 

'Sparta > What I Learned' 카테고리의 다른 글

22.12.11  (0) 2022.12.12
22.12.9  (0) 2022.12.12
22.12.7  (0) 2022.12.07
22.12.6  (0) 2022.12.07
22.12.5  (1) 2022.12.05