본문 바로가기
TIL/Spring

23.06.14

by J1-H00N 2023. 6. 14.

기존에 작성한 메모장 프로젝트는 하나의 클래스가 모든 api를 가지고 있어 클래스 안에 메서드가 너무 많다는 문제점이 있다. 이러면 다른 사람이 이 코드를 수정하려 할 때 어려움을 겪을 수 있다.

그래서 서버 개발자들은 처리 과정을 크게 Controller, Service, Repository 3개로 분리하는 일종의 약속을 만들었는데 이것이 3 Layer Architecture이다.

1. Controller

  • 클라이언트의 요청을 받습니다.
  • 요청에 대한 로직 처리는 Service에게 전담합니다.
    • Request 데이터가 있다면 Service에 같이 전달합니다.
  • Service에서 처리 완료된 결과를 클라이언트에게 응답합니다.

2. Service

  • 사용자의 요구사항을 처리 ('비즈니스 로직') 하는 실세 중에 실세입니다.
    • 따라서 현업에서는 서비스 코드가 계속 비대해지고 있습니다.
  • DB 저장 및 조회가 필요할 때는 Repository에게 요청합니다.

3. Repository

  • DB 관리 (연결, 해제, 자원 관리) 합니다.
  • DB CRUD 작업을 처리합니다.

전체 양상

 

public MemoResponseDto createMemo(@RequestBody MemoRequestDto requestDto) {

        MemoService memoService = new MemoService();
        return memoService.createMemo(requestDto);

와 같이 만들어 createMemo라는 메서드를 자동으로 생성하면 memoServie.createMemo가 반환되고 바로 MemoResponseDto를 반환해야 하니 createMemo의 반환타입을 자동으로 MemoResponseDto로 지정해준다.

그러고 나서 기존에 있던 createMemo메서드를 Service에 옮겨준다.

 

마찬가지로 기존에 있던 나머지 update, delete 등의 기능을 Service로 옮기고 Controller는 그 기능들을 불러오는 매개체로 만든다.

수정 후 코드

Memocontroller

@RestController
@RequestMapping("/api")
public class MemoController {

    // Jdbc template을 사용하기 위해 변수 선언
    private final JdbcTemplate jdbcTemplate;

    public MemoController(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @PostMapping("/memos")
    public MemoResponseDto createMemo(@RequestBody MemoRequestDto requestDto) {
        MemoService memoService = new MemoService(jdbcTemplate);
        return memoService.createMemo(requestDto);
    }

    @GetMapping("/memos")
    public List<MemoResponseDto> getMemos() {
        MemoService memoService = new MemoService(jdbcTemplate);
        return memoService.getMemos();
    }

    @PutMapping("/memos/{id}")
    public Long updateMemo(@PathVariable Long id, @RequestBody MemoRequestDto requestDto) {
        MemoService memoService = new MemoService(jdbcTemplate);
        return memoService.updateMemo(id, requestDto);
    }

    @DeleteMapping("/memos/{id}")
    public Long deleteMemo(@PathVariable Long id) {
        MemoService memoService = new MemoService(jdbcTemplate);
        return memoService.deleteMemo(id);
    }
}

MemoService

public class MemoService {
    private final JdbcTemplate jdbcTemplate;

    public MemoService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
    public MemoResponseDto createMemo(MemoRequestDto requestDto) {
        // RequestDto -> Entity
        Memo memo = new Memo(requestDto);

        // DB 저장
        KeyHolder keyHolder = new GeneratedKeyHolder(); // 기본 키를 반환받기 위한 객체

        // Insert 되는 값을 동적으로 처리하기 위해 ?로 둔다.
        String sql = "INSERT INTO memo (username, contents) VALUES (?, ?)";
        jdbcTemplate.update( con -> {
                    PreparedStatement preparedStatement = con.prepareStatement(sql,
                            Statement.RETURN_GENERATED_KEYS);

                    preparedStatement.setString(1, memo.getUsername());
                    preparedStatement.setString(2, memo.getContents());
                    return preparedStatement;
                },
                keyHolder);

        // DB Insert 후 받아온 기본키 확인
        Long id = keyHolder.getKey().longValue();
        memo.setId(id);

        // Entity -> ResponseDto
        MemoResponseDto memoResponseDto = new MemoResponseDto(memo);

        return memoResponseDto;
    }

    public List<MemoResponseDto> getMemos() {
        // DB 조회
        String sql = "SELECT * FROM memo";

        return jdbcTemplate.query(sql, new RowMapper<MemoResponseDto>() {
            @Override
            public MemoResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
                // SQL 의 결과로 받아온 Memo 데이터들을 MemoResponseDto 타입으로 변환해줄 메서드
                Long id = rs.getLong("id");
                String username = rs.getString("username");
                String contents = rs.getString("contents");
                return new MemoResponseDto(id, username, contents);
            }
        });
    }


    public Long updateMemo(Long id, MemoRequestDto requestDto) {
        // 해당 메모가 DB에 존재하는지 확인
        Memo memo = findById(id);
        if(memo != null) {
            // memo 내용 수정
            String sql = "UPDATE memo SET username = ?, contents = ? WHERE id = ?";
            jdbcTemplate.update(sql, requestDto.getUsername(), requestDto.getContents(), id);

            return id;
        } else {
            throw new IllegalArgumentException("선택한 메모는 존재하지 않습니다.");
        }
    }

    public Long deleteMemo(Long id) {
        // 해당 메모가 DB에 존재하는지 확인
        Memo memo = findById(id);
        if(memo != null) {
            // memo 삭제
            String sql = "DELETE FROM memo WHERE id = ?";
            jdbcTemplate.update(sql, id);

            return id;
        } else {
            throw new IllegalArgumentException("선택한 메모는 존재하지 않습니다.");
        }
    }

    private Memo findById(Long id) {
        // DB 조회
        String sql = "SELECT * FROM memo WHERE id = ?";

        return jdbcTemplate.query(sql, resultSet -> {
            if(resultSet.next()) {
                Memo memo = new Memo();
                memo.setUsername(resultSet.getString("username"));
                memo.setContents(resultSet.getString("contents"));
                return memo;
            } else {
                return null;
            }
        }, id);
    }
}

 

이렇게 Controller와 Service를 분리했고, 이제 Service와 Repository를 분리해보자

MemoService

public class MemoService {
    private final JdbcTemplate jdbcTemplate;

    public MemoService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
    public MemoResponseDto createMemo(MemoRequestDto requestDto) {
        // RequestDto -> Entity
        Memo memo = new Memo(requestDto);

        // DB 저장
        MemoRepository memoRepository = new MemoRepository(jdbcTemplate);
        Memo saveMemo = memoRepository.save(memo);

        // Entity -> ResponseDto
        MemoResponseDto memoResponseDto = new MemoResponseDto(saveMemo);

        return memoResponseDto;
    }

    public List<MemoResponseDto> getMemos() {
        // DB 조회
        MemoRepository memoRepository = new MemoRepository(jdbcTemplate);
        return memoRepository.findAll();
    }


    public Long updateMemo(Long id, MemoRequestDto requestDto) {
        MemoRepository memoRepository = new MemoRepository(jdbcTemplate);
        // 해당 메모가 DB에 존재하는지 확인
        Memo memo = memoRepository.findById(id);
        if(memo != null) {
            // memo 내용 수정
            memoRepository.update(id, requestDto);

            return id;
        } else {
            throw new IllegalArgumentException("선택한 메모는 존재하지 않습니다.");
        }
    }

    public Long deleteMemo(Long id) {
        MemoRepository memoRepository = new MemoRepository(jdbcTemplate);
        // 해당 메모가 DB에 존재하는지 확인
        Memo memo = memoRepository.findById(id);
        if(memo != null) {
            // memo 삭제
            memoRepository.delete(id);

            return id;
        } else {
            throw new IllegalArgumentException("선택한 메모는 존재하지 않습니다.");
        }
    }
}

MemoRepository

public class MemoRepository {
    private final JdbcTemplate jdbcTemplate;

    public MemoRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public Memo save(Memo memo) {
        // DB 저장
        KeyHolder keyHolder = new GeneratedKeyHolder(); // 기본 키를 반환받기 위한 객체

        // Insert 되는 값을 동적으로 처리하기 위해 ?로 둔다.
        String sql = "INSERT INTO memo (username, contents) VALUES (?, ?)";
        jdbcTemplate.update( con -> {
                    PreparedStatement preparedStatement = con.prepareStatement(sql,
                            Statement.RETURN_GENERATED_KEYS);

                    preparedStatement.setString(1, memo.getUsername());
                    preparedStatement.setString(2, memo.getContents());
                    return preparedStatement;
                },
                keyHolder);

        // DB Insert 후 받아온 기본키 확인
        Long id = keyHolder.getKey().longValue();
        memo.setId(id);

        return memo;
    }

    public List<MemoResponseDto> findAll() {
        String sql = "SELECT * FROM memo";

        return jdbcTemplate.query(sql, new RowMapper<MemoResponseDto>() {
            @Override
            public MemoResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
                // SQL 의 결과로 받아온 Memo 데이터들을 MemoResponseDto 타입으로 변환해줄 메서드
                Long id = rs.getLong("id");
                String username = rs.getString("username");
                String contents = rs.getString("contents");
                return new MemoResponseDto(id, username, contents);
            }
        });
    }

    public void update(Long id, MemoRequestDto requestDto) {
        String sql = "UPDATE memo SET username = ?, contents = ? WHERE id = ?";
        jdbcTemplate.update(sql, requestDto.getUsername(), requestDto.getContents(), id);
    }

    public void delete(Long id) {
        String sql = "DELETE FROM memo WHERE id = ?";
        jdbcTemplate.update(sql, id);
    }

    public Memo findById(Long id) {
        // DB 조회
        String sql = "SELECT * FROM memo WHERE id = ?";

        return jdbcTemplate.query(sql, resultSet -> {
            if(resultSet.next()) {
                Memo memo = new Memo();
                memo.setUsername(resultSet.getString("username"));
                memo.setContents(resultSet.getString("contents"));
                return memo;
            } else {
                return null;
            }
        }, id);
    }
}

 

IoC, DI는 객체지향의 SOLID 원칙 그리고 GoF 의 디자인 패턴과 같은 설계 원칙 및 디자인 패턴

IoC(제어의 역전) : 설계 원칙

DI(의존성 주입) : 디자인 패턴

 

DI를 이해하기 위해선 의존성에 대해 이해할 필요가 있는데, 발을 다치면 목발에 의존하는 것처럼, 음식을 할 땐 조리용 도구에 의존하는 것처럼, 그리고 메서드를 실행하기 위해 객체를 생성하는 것이 의존성이다.

public class Consumer {

    void eat(Food food) {
        food.eat();
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();
        consumer.eat(new Chicken());
        consumer.eat(new Pizza());
    }
}

interface Food {
    void eat();
}

class Chicken implements Food{
    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food{
    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

위 코드처럼 interface를 사용하면 어떤 음식을 먹을지 객체로 생성하여 정할 수 있기 때문에(eat이 꼭 치킨을 먹는다를 실행시키지는 않기 때문) 이를 약한 결합 및 약한 의존성이라고 할 수 있다.

 

주입 : 여러 방법을 통해 필요로 하는 객체를 해당 객체에 전달하는 것

필드에 직접 주입

더보기
public class Consumer {

    Food food;

    void eat() {
        this.food.eat();
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();
        consumer.food = new Chicken();
        consumer.eat();

        consumer.food = new Pizza();
        consumer.eat();
    }
}

interface Food {
    void eat();
}

class Chicken implements Food{
    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food{
    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

메서드를 통한 주입

더보기
public class Consumer {

    Food food;

    void eat() {
        this.food.eat();
    }

    public void setFood(Food food) {
        this.food = food;
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer();
        consumer.setFood(new Chicken());
        consumer.eat();

        consumer.setFood(new Pizza());
        consumer.eat();
    }
}

interface Food {
    void eat();
}

class Chicken implements Food{
    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food{
    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

생성자를 통한 주입(가장 많이 사용)

더보기
public class Consumer {

    Food food;

    public Consumer(Food food) {
        this.food = food;
    }

    void eat() {
        this.food.eat();
    }

    public static void main(String[] args) {
        Consumer consumer = new Consumer(new Chicken());
        consumer.eat();

        consumer = new Consumer(new Pizza());
        consumer.eat();
    }
}

interface Food {
    void eat();
}

class Chicken implements Food{
    @Override
    public void eat() {
        System.out.println("치킨을 먹는다.");
    }
}

class Pizza implements Food{
    @Override
    public void eat() {
        System.out.println("피자를 먹는다.");
    }
}

 

제어의 역전

  • 이전에는 Consumer가 직접 Food를 만들어 먹었기 때문에 새로운 Food를 만들려면 추가적인 요리준비(코드변경)가 불가피했습니다.
    • 그렇기 때문에 이때는 제어의 흐름이 Consumer → Food 였습니다.
  • 이를 해결하기 위해 만들어진 Food를 Consumer에게 전달해주는 식으로 변경함으로써 Consumer는 추가적인 요리준비(코드변경) 없이 어느 Food가 되었든지 전부 먹을 수 있게 되었습니다.
    • 결과적으로 제어의 흐름이 Food → Consumer 로 역전 되었습니다.

 

이제 메모장 프로젝트에 DI를 사용해서 IoC 설계원칙에 맞게 코드를 개선해보자.

현재 메모장 프로젝트가 가지고 있는 문제점

1. 객체 생성의 반복

MemoService memoService = new MemoService(jdbcTemplate);
MemoRepository memoRepository = new MemoRepository(jdbcTemplate);

위 코드가 각각 Controller와 Service의 메소드에서 계속 반복된다.

> 객체를 생성해서 jdbcTemplate를 받는 역할을 하는 생성자를 만들어서 메서드마다 중복되는 코드를 없앤다.

private final MemoService memoService;

    public MemoController(JdbcTemplate jdbcTemplate) {
        this.memoService = new MemoService(jdbcTemplate);
    }

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

2. 제어의 흐름이 Controller -> Service -> Repository이다. 즉, 강한 결합인 상태이다. 이 경우 Repository를 변경하면 Controller까지 모두 수정해야 할 수도 있다.

실제로 Repository에서 jdbcTemplate을 사용해야 하기 때문에 사용하지도 않는 jdbcTemplate을 Controller 와Service 생성자가 계속 받고 있다.

이를 해결하기 위해선 각 객체에 대한 객체 생성은 한번만 하고, 해당 객체를 모든 곳에서 재사용, 생성자 주입을 사용하여 필요로하는 객체에 해당 객체를 주입한다.

MemoController

public MemoController(MemoService memoService) {
        this.memoService = memoService;
    }

MemoService

public MemoService(MemoRepository memoRepository) {
        this.memoRepository = memoRepository;
    }

 

단, 이렇게 하면 MemoController에 memoService를 전달해주는 방법이 없고, memoService에 에러(Could not autowire. No beans of 'MemoService' type found. )가 발생한다.

 

Spring 프레임워크는 필요한 객체를 생성하고 관리하는 역할을 대신 해주는데, 여기서 에러에 있는 bean이 Spring이 관리하는 객체를 의미하고, Spring IoC 컨테이너는 bean을 모아둔 컨테이너를 의미한다.

위에서 에러가 생긴 이유는 생성자가 외부에서 주입을 받을 때는 이 역할을 Spring이 하기 때문에 bean 객체만 주입받을 수 있는데, 아직 memoRepository가 bean으로 등록이 안되어있기 때문이다.

 

그러면 bean으로 등록하는 방법을 알아보자.

bean으로 등록하고자 하는 객체에 @Component만 붙이면 된다.

@Component
public class MemoService {

이러면 Repository도 등록되어있지 않기 때문에 똑같이 등록해준다.

@Component
public class MemoRepository {

이러면 intellij 코드 좌측에 커피콩 모양이 생기고 bean으로 등록이 된다. 이때 제일 앞글자만 소문자로 바꿔서 등록한다.

이 커피콩 버튼을 누르고 select in Spring view를 누르면 bean으로 등록된 클래스들을 볼 수 있다. 여기에 이미 jdbcTemplate이 있는것을 확인 할 수 있는데, 그래서 

public class MemoRepository {
    private final JdbcTemplate jdbcTemplate;

    public MemoRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

여기서 JdbcTemplate을 따로 bean으로 등록하는 과정이 필요 없던 것이다.

 

그리고 원래 이 bean을 주입하기 위해서는 이 bean을 넣어주는 메서드에 

    @Autowired
    public MemoRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

와 같이 @Autowired를 달아줘야 하는데(일반적으로 객체의 불변성을 확보할 수 있기 때문에 생성자를 사용하여 DI하는 것이 좋다.), Spring 4.3 버전부터는 생성자 선언이 1개일 때만 이를 생략할 수 있도록 개선되었다. 따라서 생성자를 오버로딩하여 여러개 선언하면 모두 @Autowired를 붙여주어야 한다.

 

이는 Lombok 의 @RequiredArgsConstructor를 사용해도 생략할 수 있다. @RequiredArgsConstructor는 final로 선언된 변수를 사용한 생성자를 자동으로 생성해주기 때문에, 생성자도 생략할 수 있는 것이다.

@Component
@RequiredArgsConstructor
public class MemoRepository {
    private final JdbcTemplate jdbcTemplate;

//    public MemoRepository(JdbcTemplate jdbcTemplate) {
//        this.jdbcTemplate = jdbcTemplate;
//    }

 그 외에도 @Injection 등의 방법으로도 주입이 가능하나 @Autowired를 통해 생성자에 자동으로 주입하는게 일반적이고 범용적으로 쓰인다.

 

잘 쓰이진 않지만 bean을 수동으로 주입할 수 있다.

@Component
public class MemoService {

	private final MemoRepository memoRepository;

    public MemoService(ApplicationContext context) {
        // 1.'Bean' 이름으로 가져오기
        MemoRepository memoRepository = (MemoRepository) context.getBean("memoRepository");

        // 2.'Bean' 클래스 형식으로 가져오기
        // MemoRepository memoRepository = context.getBean(MemoRepository.class);

        this.memoRepository = memoRepository;
    }

		...		
}

 

처음에 배웠던 3 Layer Architecture들은 MemoController에 @RestController을 달았던 것처럼 bean에 등록할 때 해당 클래스의 역할을 명시하기 위해 @Component대신에 @Controller, @RestController / @Service / @Repository를 달아준다.

 

DB를 직접 다룰 때의 문제점

1. 테이블을 만들고, sql을 작성한 뒤 JDBC를 사용하여 직접 실행하고 결과를 객체로 만들어줘야 한다.

2. SQL 의존적이라 변경에 취약하다. 어떤 column를 추가한다고 하면 SQL을 직접 수정하고 객체에 값을 넣어주는 부분도 추가해줘야 한다.

 

여기서 ORM이 등장하는데, ORM이란 객체와 DB의 관계를 매핑 해주는 도구로, 반복적이고 번거로운 애플리케이션 단에서의 SQL 작업을 줄여주기 위해서 만들어졌다.

JPA는 자바 ORM 기술에 대한 표준 명세로, 애플리케이션과 JDBC 사이에서 동작한다. JPA를 사용하면 DB 연결 과정을 직접 개발하지 않아도 자동으로 처리해주고, 객체를 통해 간접적으로 DB 데이터를 다룰 수 있기 때문에 매우 쉽게 DB 작업을 처리할 수 있다.

JPA 는 표준 명세이고, 이를 실제 구현한 프레임워크 중 사실상 표준하이버네이트다. 스프링 부트에서도 기본적으로 ‘하이버네이트’ 구현체를 사용 중이다.

사실상 표준 (de facto, 디팩토)
보통 기업간 치열한 경쟁을 통해 시장에서 결정되는 비 공식적 표준이다

 

Entity : JPA에서 관리되는 클래스 즉, 객체

Entity 클래스는 DB의 테이블과 매핑되어 JPA에 의해 관리

 

프로젝트 생성

생성 설정 Java, Gradle, Groovy > resources 폴더 > META-INF 폴더 생성 > persistence.xml 파일 생성

더보기
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2"
             xmlns="http://xmlns.jcp.org/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
    <persistence-unit name="memo">
        <class>com.sparta.entity.Memo</class>
        <properties>
            <property name="jakarta.persistence.jdbc.driver" value="com.mysql.cj.jdbc.Driver"/>
            <property name="jakarta.persistence.jdbc.user" value="root"/>
            <property name="jakarta.persistence.jdbc.password" value="{비밀번호}"/>
            <property name="jakarta.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/memo"/>

            <property name="hibernate.hbm2ddl.auto" value="update" /> // value가 create면 있든 없든 drop하고 다시 만들어버림

            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>
            <property name="hibernate.use_sql_comments" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

> build.gradle > dependencies >

더보기
// JPA 구현체인 hibernate
implementation 'org.hibernate:hibernate-core:6.1.7.Final'
// MySQL
implementation 'mysql:mysql-connector-java:8.0.28'

test 폴더 > Java > EntityTest.java 생성 >

더보기
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class EntityTest {

    EntityManagerFactory emf;
    EntityManager em;

    @BeforeEach
    void setUp() {
        emf = Persistence.createEntityManagerFactory("memo");
        em = emf.createEntityManager();
    }

    @Test
    void test1() {

    }
}

> com.sparta > entity 패키지 생성 > Memo.java 생성 >

더보기
package com.sparta.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity // JPA가 관리할 수 있는 Entity 클래스 지정
@Table(name = "memo") // 매핑할 테이블의 이름을 지정
public class Memo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    // length: 컬럼 길이 지정
    @Column(name = "contents", nullable = false, length = 500)
    private String contents;
}

@Entity(name = "Memo")로 클래스 이름으로 지정할 수 있으나 기본값이 해당 클래스이므로 지금은 생략 가능

JPA가 Entity를 인스턴스화 할 때 기본생성자를 사용하기 때문에 반드시 현재 Entity 클래스에서 기본 생성자가 생성되고 있는지 확인해야 한다.

@Table도 Entity처럼 이름으로 지정할 수 있으나 기본값은 Entity명

@Column(name = "username") : 필드와 매핑할 테이블의 컬럼을 지정 기본값은 객체의 필드명

@Column(nullable = false) : 데이터의 null 값 허용 여부를 지정 기본값은 true

@Column(unique = true) : 데이터의 중복 값 허용 여부를 지정 기본값은 false

@Column(length = 500) : 데이터 값(문자)의 길이에 제약조건을 걸 수 있음 기본값은 255

@Id : 테이블의 기본 키를 지정 > 기본 키는 영속성 컨텍스트에서 Entity를 구분하고 관리할 때 사용되는 식별자 역할을 수행 > 따라서 기본 키 즉, 식별자 값을 넣어주지 않고 저장하면 오류가 발생

@GeneratedValue 옵션을 추가하면 기본 키 생성을 DB에 위임할 수 있음

 

영속성 컨텍스트 : Entity 객체를 효율적으로 쉽게 관리하기 위해 만들어진 공간

JPA를 사용하여 DB에 데이터를 저장하거나 조회할 수 있으며 수정, 삭제하는 과정을 효율적으로 처리하기 위해 JPA는 영속성 컨텍스트에 Entity 객체들을 저장하여 관리하면서 DB와 소통

 

EntityManager

영속성 컨텍스트에 접근하여 Entity 객체들을 조작하기 위해서는 EntityManager가 필요

Entity를 저장하고 조회하고 수정하고 삭제할 수 있음

EntityManagerFactory를 통해 생성하여 사용할 수 있음

 

EntityManagerFactory

일반적으로 DB 하나에 하나만 생성되어 애플리케이션이 동작하는 동안 사용

EntityManagerFactory를 만들기 위해서는 DB에 대한 정보를 전달해야함

이 정보를 저장하기 위해 resources 폴더 > META-INF 폴더 생성 > persistence.xml 파일 생성을 한 것

EntityManagerFactory emf = Persistence.createEntityManagerFactory("memo"); 코드를 호출하면 JPA는 persistence.xml 의 정보를 토대로 EntityManagerFactory를 생성

EntityMnager em = emf.createEntityManager(); 코드를 호출하면 EntityManagerFactory를 사용하여 EntityManager를 생성할 수 있음

 

트랜젝션 : DB 데이터들의 무결성과 정합성을 유지하기 위한 하나의 논리적 개념

DB의 데이터들을 안전하게 관리하기 위해서 생겨난 개념

가장 큰 특징은 여러 개의 SQL이 하나의 트랜잭션에 포함될 수 있다는 점

모든 SQL이 성공적으로 수행이 되면 DB에 영구적으로 변경을 반영하지만 SQL 중 단 하나라도 실패한다면 모든 변경을 되돌린다

START TRANSACTION; # 트랜잭션을 시작합니다.

INSERT INTO memo (id, username, contents) VALUES (1, 'Robbie', 'Robbie Memo');
INSERT INTO memo (id, username, contents) VALUES (2, 'Robbert', 'Robbert Memo');
SELECT * FROM memo;

COMMIT; # 트랜잭션을 커밋합니다.

SELECT * FROM memo;

커밋하기 전까지는 select를 통해 조회는 가능하지만, 실제 데이터베이스에는 적용되지 않는다.

 

JPA의 트랜젝션

DB에서 하나의 트랜잭션에 여러 개의 SQL을 포함하고 있다가 마지막에 영구적으로 변경을 반영하는 것 처럼 JPA에서도 영속성 컨텍스트로 관리하고 있는 변경이 발생한 객체들의 정보를 쓰기 지연 저장소에 전부 가지고 있다가 마지막에 SQL을 한번에 DB에 요청해 변경을 반영

 

Entity 클래스는 Setter를 만들 때 고민을 좀 해봐야한다. 왜냐하면 Setter는 이미 생성된 데이터를 수정할 수 있기 때문에 데이터 베이스와 Mapping을 하는 Entity 클래스의 특성상 의도치 않게 Setter를 통해 데이터베이스에 반영할 수 있기 때문이다.

 

더보기
@Test
@DisplayName("EntityTransaction 성공 테스트")
void test1() {
    EntityTransaction et = em.getTransaction(); // EntityManager 에서 EntityTransaction 을 가져옵니다.

    et.begin(); // 트랜잭션을 시작합니다.

    try { // DB 작업을 수행합니다.

        Memo memo = new Memo(); // 저장할 Entity 객체를 생성합니다.
        memo.setId(1L); // 식별자 값을 넣어줍니다.
        memo.setUsername("Robbie");
        memo.setContents("영속성 컨텍스트와 트랜잭션 이해하기");

        em.persist(memo); // EntityManager 사용하여 memo 객체를 영속성 컨텍스트에 저장합니다.

        et.commit(); // 오류가 발생하지 않고 정상적으로 수행되었다면 commit 을 호출합니다.
        // commit 이 호출되면서 DB 에 수행한 DB 작업들이 반영됩니다.
    } catch (Exception ex) {
        ex.printStackTrace();
        et.rollback(); // DB 작업 중 오류 발생 시 rollback 을 호출합니다.
    } finally {
        em.close(); // 사용한 EntityManager 를 종료합니다.
    }

    emf.close(); // 사용한 EntityManagerFactory 를 종료합니다.
}

위 코드를 통해 테스트를 해볼건데, 

@GeneratedValue(strategy = GenerationType.IDENTITY)

이 코드가 Memo에 남아있으면 데이터베이스에게 기본 키 생성을 위임했었는데 또 기본키를 지정하려고 하니 오류가 생긴다. 지워주고 실행한다.

 

실패하는 예시 코드

더보기
@Test
@DisplayName("EntityTransaction 실패 테스트")
void test2() {
    EntityTransaction et = em.getTransaction(); // EntityManager 에서 EntityTransaction 을 가져옵니다.

    et.begin(); // 트랜잭션을 시작합니다.

    try { // DB 작업을 수행합니다.

        Memo memo = new Memo(); // 저장할 Entity 객체를 생성합니다.
        memo.setUsername("Robbert");
        memo.setContents("실패 케이스");

        em.persist(memo); // EntityManager 사용하여 memo 객체를 영속성 컨텍스트에 저장합니다.

        et.commit(); // 오류가 발생하지 않고 정상적으로 수행되었다면 commit 을 호출합니다.
        // commit 이 호출되면서 DB 에 수행한 DB 작업들이 반영됩니다.
    } catch (Exception ex) {
        System.out.println("식별자 값을 넣어주지 않아 오류가 발생했습니다.");
        ex.printStackTrace();
        et.rollback(); // DB 작업 중 오류 발생 시 rollback 을 호출합니다.
    } finally {
        em.close(); // 사용한 EntityManager 를 종료합니다.
    }

    emf.close(); // 사용한 EntityManagerFactory 를 종료합니다.
}

 

 

영속성 컨텍스트의 기능

  • 1차 캐시
    • 영속성 컨텍스트는 내부적으로 캐시 저장소를 가지고 있다
    • 우리가 저장하는 Entity 객체들이 1차 캐시 즉, 캐시 저장소에 저장된다고 생각하시면됩니다.
    • 캐시 저장소는 Map 자료구조 형태로 되어있습니다.
      • key에는 @Id로 매핑한 기본 키 즉, 식별자 값을 저장합니다.
      • value에는 해당 Entity 클래스의 객체를 저장합니다.
      • 영속성 컨텍스트는 캐시 저장소 Key에 저장한 식별자값을 사용하여 Entity 객체를 구분하고 관리합니다.
        • Entity 저장
          • em.persist(memo); 메서드가 호출되면 memo Entity 객체를 캐시 저장소에 저장합니다.
        • Entity 조회
          • 캐시 저장소에 조회하고자 하는 id가 없을 경우 해당 값을 저장하고 반환
          • 조회만 하는 경우에는 데이터의 변경이 발생하지 않기 때문에 트랜젝션이 없어도 조회 가능
          • 캐시 저장소에 조회하고자 하는 id가 있을 경우 DB에 조회하지 않고 캐시 저장소에서해당 Entity객체를 반환
          • 장점1 DB 조회 횟수를 줄임
          • 장점2 DB row 하나당 하나의 객체가 사용되는 것을 보장 ==> 객체 동일성 보장
        • Entity 삭제
          • 삭제할 Entity를 조회한 후 캐시 저장소에 없다면 DB에 조회해서 저장
          • 삭제할 Entity를 DELETED 상태로 만든 후 트랜잭션 commit 후 Delete SQL이 DB에 요청

Entity 저장

  • 쓰기 지연 저장소
    • JPA가 트랜잭션 처럼 SQL을 모아서 한번에 DB에 반영하는데, JPA는 이걸 구현하기 위해 쓰기 지연 저장소를 만들어 SQL을 모아두고 있다가 트랜잭션 commit 후 한번에 DB에 반영
    • 트랜잭션 commit 후 추가적인 동작 => em.flush();
    • flush 메서드는 영속성 컨텍스트의 변경 내용들을 DB에 반영하는 역할을 수행. 즉, 쓰기 지연 저장소의 SQL들을 DB에 요청하는 역할을 수행
    • 트랜잭션을 설정하지 않고 플러시 메서드를 호출하면 no transaction is in progress메시지와 함께TransactionRequiredException 오류가 발생
  • 변경 감지
    • 영속성 컨텍스트에 저장된 Entity가 변경될 때마다 Update SQL이 쓰기 지연 저장소에 저장된다면 하나의 Update SQL로 처리할 수 있는 상황을 여러번 Update SQL을 요청하게 되기 때문에 비효율적
    • JPA는 영속성 컨텍스트에 Entity를 저장할 때 최초 상태(LoadedState)를 저장
    • 트랜잭션이 commit되고 em.flush();가 호출되면 Entity의 현재 상태와 저장한 최초 상태를 비교
    • 변경 내용이 있다면 Update SQL을 생성하여 쓰기 지연 저장소에 저장하고 모든 쓰기지연 저장소의 SQL을 DB에 요청
    • 마지막으로 DB의 트랜잭션이 commit 되면서 반영
    • 따라서 변경하고 싶은 데이터가 있다면 먼저 데이터를 조회하고 해당 Entity 객체의 데이터를 변경하면 자동으로 Update SQL이 생성되고 DB에 반영 => 이러한 과정을 변경 감지, Dirty Checking이라 부릅니다

 

Entity 상태

비영속

  • new 연산자를 통해 인스턴스화 된 Entity 객체
  • 아직 영속성 컨텍스트에 저장되지 않았기 때문에 JPA의 관리를 받지 않는다

영속

  • persist(entity) : 비영속 Entity를 EntityManager를 통해 영속성 컨텍스트에 저장하여 관리되고 있는 상태로 만듬

준영속

  • 영속성 컨텍스트에 저장되어 관리되다가 분리된 상태
  • 영속 상태에서 준영속 상태로 바꾸는 방법
    • detach(entity) : 특정 Entity만 준영속 상태로 전환
      • 영속성 컨텍스트에서 관리되다(Managed)가 분리된 상태(Detached)로 전환
    • clear() : 영속성 컨텍스트를 완전히 초기화
      • 영속성 컨텍스트의 모든 Entity를 준영속 상태로 전환
      • 영속성 컨텍스트 틀은 유지하지만 내용은 비워 새로 만든 것과 같은 상태가 됨
      • 따라서 계속해서 영속성 컨텍스트를 이용할 수 있음
    • close() : 영속성 컨텍스트를 종료
      • 해당 영속성 컨텍스트가 관리하던 영속성 상태의 Entity들은 모두 준영속 상태로 변경
      • 영속성 컨텍스트가 종료되었기 때문에 계속해서 영속성 컨텍스트를 사용할 수 없음
  • 준영속 상태에서 다시 영속 상태로 바꾸는 방법
    • merge(entity) : 전달받은 Entity를 사용하여 새로운 영속 상태의 Entity를 반환
      • 파라미터로 전달된 Entity의 식별자 값으로 영속성 컨텍스트를 조회
        1. 해당 Entity가 영속성 컨텍스트에 없다면?
          1. DB에서 새롭게 조회
          2. 조회한 Entity를 영속성 컨텍스트에 저장
          3. 전달 받은 Entity의 값을 사용하여 병합
          4. Update SQL이 수행됨 (수정)
        2. 만약 DB에서도 없다면 ?
          1. 새롭게 생성한 Entity를 영속성 컨텍스트에 저장
          2. Insert SQL이 수행됨 (저장)
      • 따라서 merge(entity) 메서드는 비영속, 준영속 모두 파라미터로 받을 수 있으며 상황에 따라 ‘저장’을 할 수도 ‘수정’을 할 수도 있음

삭제

  • remove(entity) : 삭제하기 위해 조회해온 영속 상태의 Entity를 파라미터로 전달받아 삭제 상태로 전환

 

Springboot JPA 강의를 듣기 위해 코드를 실행하던 중 오류가 났다.

1. 아래 코드를 추가해 기본 오류 수정

    testImplementation 'org.junit.jupiter:junit-jupiter-api'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'

2. 문제는 생성 뒤 spring이 database를 찾지 못해 경로를 설정하는데 문제가 있는 줄 알고 1시간 가량 삽질을 하다가 memo 데이터베이스 자체가 사라진걸 알게 됨. 왠진... 모르겠다. 그래서 데이터베이스를 생성하니 제대로 실행됐다.

 

Spring boot에서 JPA 설정법

build.gardle에 아래 코드 추가(오류 발생 시 위에 코드도 추가)

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

 

application.properties에 아래 코드 추가

spring.jpa.hibernate.ddl-auto=update

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

여기서 ddl-auto의 종류를 알아보자

  • create : 기존 테이블 삭제 후 다시 생성합니다. (DROP + CREATE)
  • create-drop : create와 같으나 종료시점에 테이블을 DROP 합니다.
  • update : 변경된 부분만 반영합니다.
  • validate : Entity와 테이블이 정상 매핑되었는지만 확인합니다.
  • none: 아무것도 하지 않습니다.

SpringBoot 환경에서는 EntityManagerFactory와 EntityManager를 자동으로 생성해준다.

application.properties에 @PersistenceConext 애너테이션을 사용하면 자동으로 생성된 EntityManager를 주입받아 사용할 수 있다.

Spring 프레임워크에서는 DB의 트랜잭션 개념을 애플리케이션에 적용할 수 있도록 트랜잭션 관리자를 제공

@Transactional 애너테이션을 클래스나 메서드에 추가하면 쉽게 트랜잭션 개념을 적용할 수 있다

스프링 컨테이너 환경에서는 영속성 컨텍스트와 트랜잭션의 생명주기가 일치

= 트랜잭션이 유지되는 동안은 영속성 컨텍스트도 계속 유지가 되기 때문에 영속성 컨텍스트의 기능을 사용할 수 있다

예시 코드

더보기

MemoRepository

    private final JdbcTemplate jdbcTemplate;

    public MemoRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public Memo save(Memo memo) {
        // DB 저장
        KeyHolder keyHolder = new GeneratedKeyHolder(); // 기본 키를 반환받기 위한 객체

        // Insert 되는 값을 동적으로 처리하기 위해 ?로 둔다.
        String sql = "INSERT INTO memo (username, contents) VALUES (?, ?)";
        jdbcTemplate.update( con -> {
                    PreparedStatement preparedStatement = con.prepareStatement(sql,
                            Statement.RETURN_GENERATED_KEYS);

                    preparedStatement.setString(1, memo.getUsername());
                    preparedStatement.setString(2, memo.getContents());
                    return preparedStatement;
                },
                keyHolder);

        // DB Insert 후 받아온 기본키 확인
        Long id = keyHolder.getKey().longValue();
        memo.setId(id);

        return memo;
    }

    public List<MemoResponseDto> findAll() {
        String sql = "SELECT * FROM memo";

        return jdbcTemplate.query(sql, new RowMapper<MemoResponseDto>() {
            @Override
            public MemoResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
                // SQL 의 결과로 받아온 Memo 데이터들을 MemoResponseDto 타입으로 변환해줄 메서드
                Long id = rs.getLong("id");
                String username = rs.getString("username");
                String contents = rs.getString("contents");
                return new MemoResponseDto(id, username, contents);
            }
        });
    }

    public void update(Long id, MemoRequestDto requestDto) {
        String sql = "UPDATE memo SET username = ?, contents = ? WHERE id = ?";
        jdbcTemplate.update(sql, requestDto.getUsername(), requestDto.getContents(), id);
    }

    public void delete(Long id) {
        String sql = "DELETE FROM memo WHERE id = ?";
        jdbcTemplate.update(sql, id);
    }

    public Memo findById(Long id) {
        // DB 조회
        String sql = "SELECT * FROM memo WHERE id = ?";

        return jdbcTemplate.query(sql, resultSet -> {
            if(resultSet.next()) {
                Memo memo = new Memo();
                memo.setUsername(resultSet.getString("username"));
                memo.setContents(resultSet.getString("contents"));
                return memo;
            } else {
                return null;
            }
        }, id);
    }

    @Transactional
    public Memo createMemo(EntityManager em) {
        Memo memo = em.find(Memo.class, 1);
        memo.setUsername("Robbert");
        memo.setContents("@Transactional 전파 테스트 중! 2");

        System.out.println("createMemo 메서드 종료");
        return memo;
    }

 

Spring Data JPA 

JPA를 쉽게 사용할 수 있게 만들어놓은 하나의 모듈 > JPA를 추상화시킨 Repository 인터페이스를 제공

Repository 인터페이스 Hibernate와 같은 JPA구현체를 사용해서 구현한 클래스를 통해 사용

Spring Data JPA에서는 JpaRepository 인터페이스를 구현하는 클래스를 자동으로 생성

Spring 서버가 뜰 때 JpaRepository 인터페이스를 상속받은 인터페이스가 자동으로 스캔이 되면, 해당 인터페이스의 정보를 토대로 자동으로 SimpleJpaRepository 클래스를 생성해 주고, 이 클래스를 Spring ‘Bean’으로 등록

따라서 인터페이스의 구현 클래스를 직접 작성하지 않아도 JpaRepository 인터페이스를 통해 JPA의 기능을 사용할 수 있습니다

 

Spring Data JPA 사용방법

JpaRepository 등록 > JpaRepository<"@Entity 클래스", "@Id 의 데이터 타입">를 상속받는 interface 로 선언

Spring Data JPA에 의해 자동으로 Bean 등록

제네릭스의 @Entity 클래스 위치에 Memo Entity를 추가했기 때문에 해당 MemoRepository는 DB의 memo 테이블과 연결되어 CRUD 작업을 처리하는 인터페이스가 됨

 

메모장 적용 예시

더보기

MemoRepository

@Repository
public interface MemoRepository extends JpaRepository<Memo, Long> {

}

MemoService

@Service
public class MemoService {
    private final MemoRepository memoRepository;

    // MemoRepository memoRepository = new MemoRepository(jdbcTemplate); 역할을 하는 생성자
    public MemoService(MemoRepository memoRepository) {
        this.memoRepository = memoRepository;
    }

    public MemoResponseDto createMemo(MemoRequestDto requestDto) {
        // RequestDto -> Entity
        Memo memo = new Memo(requestDto);

        // DB 저장
        Memo saveMemo = memoRepository.save(memo);

        // Entity -> ResponseDto
        MemoResponseDto memoResponseDto = new MemoResponseDto(saveMemo);

        return memoResponseDto;
    }

    public List<MemoResponseDto> getMemos() {
        // DB 조회
        return memoRepository.findAll().stream().map(MemoResponseDto::new).toList(); // 반환타입이 ArrayList여서 변환
    }

    @Transactional // 없으면 수정 X <- 변경감지가 일어나지 않아서
    public Long updateMemo(Long id, MemoRequestDto requestDto) {
        // 해당 메모가 DB에 존재하는지 확인
        Memo memo = findMemo(id);
        // memo 내용 수정
        memo.update(requestDto);

        return id;

    }

    public Long deleteMemo(Long id) {
        // 해당 메모가 DB에 존재하는지 확인
        Memo memo = findMemo(id);
        // memo 삭제
        memoRepository.delete(memo);
        return id;
    }

    private Memo findMemo(Long id) {
        return memoRepository.findById(id).orElseThrow(() -> // null 체크
                new IllegalArgumentException("선택한 메모는 존재하지 않습니다.")
        );
    }
}

 

메모 생성시간이나 수정시간을 추가하기 위해서는 각 메서드마다 변수를 생성하고 로직을 수정할 수도 있겠지만 이는 너무 비효율적이고 에러가 생기기 쉬우니, Spring에서 제공하는 기능을 사용해보자.

Timestamped

Spring Data JPA에서는 시간에 대해서 자동으로 값을 넣어주는 기능인 JPA Auditing을 제공

@MappedSuperclass

  • JPA Entity 클래스들이 해당 추상 클래스를 상속할 경우 createdAt, modifiedAt 처럼 추상 클래스에 선언한 멤버변수를 컬럼으로 인식할 수 있다

@EntityListeners(AuditingEntityListener.class)

  • 해당 클래스에 Auditing 기능을 포함시켜 줍니다.

@CreatedDate

  • Entity 객체가 생성되어 저장될 때 시간이 자동으로 저장됩니다.
  • 최초 생성 시간이 저장되고 그 이후에는 수정되면 안되기 때문에 updatable = false 옵션을 추가합니다.

@LastModifiedDate

  • 조회한 Entity 객체의 값을 변경할 때 변경된 시간이 자동으로 저장됩니다.
  • 처음 생성 시간이 저장된 이후 변경이 일어날 때마다 해당 변경시간으로 업데이트됩니다.

@Temporal

  • 날짜 타입(java.util.Date, java.util.Calendar)을 매핑할 때 사용합니다.
  • DB에는 Date(날짜), Time(시간), Timestamp(날짜와 시간)라는 세 가지 타입이 별도로 존재합니다.

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

  • JPA Auditing 기능을 사용하겠다는 정보를 전달해주기 위해 @EnableJpaAuditing 을 추가해야 한다

적용 예시

더보기
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Timestamped {

    @CreatedDate
    @Column(updatable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime modifiedAt;
}
@Getter
public class MemoResponseDto {
    private Long id;
    private String username;
    private String contents;
    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;

    public MemoResponseDto(Memo memo) {
        this.id = memo.getId();
        this.username = memo.getUsername();
        this.contents = memo.getContents();
        this.createdAt = memo.getCreatedAt();
        this.modifiedAt = memo.getModifiedAt();
    }

}
public class Memo extends Timestamped {
@EnableJpaAuditing
@SpringBootApplication
public class MemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(MemoApplication.class, args);
    }

}

 

Query Methods

Spring Data JPA에서는 메서드 이름으로 SQL을 생성할 수 있는 Query Methods 기능을 제공

JpaRepository 인터페이스에서 해당 인터페이스와 매핑되어있는 테이블에 요청하고자하는 SQL을 메서드 이름을 사용하여 선언할 수 있다

예시

@Repository
public interface MemoRepository extends JpaRepository<Memo, Long> {
    List<Memo> findAllByOrderByModifiedAtDesc();
    // Memo 테이블에서 ModifiedAt 즉, 수정 시간을 기준으로 전체 데이터를 내림차순으로 가져오는 SQL을 실행하는 메서드를 생성
}

SimpleJpaRepository 클래스가 생성될 때 위처럼 직접 선언한 JpaRepository 인터페이스의 모든 메서드를 자동으로 구현

인터페이스에 필요한 SQL에 해당하는 메서드 이름 패턴으로 메서드를 선언 하기만 하면 따로 구현하지 않아도 사용할 수 있다

Query Methods 는 메서드의 파라미터를 통해 SQL에 필요한 값을 동적으로 받아 처리할 수 있다

 

이렇게 2주차 강의를 모두 들었다. 내일은 2주차 숙제를 풀어보며 필요한 내용을 복습하고, 2주차 강의를 기반으로 개인과제를 마무리 한 뒤 남은 시간은 이번 강의를 처음부터 다시 들어볼 생각이다. 시간이 더 남는다면 Java의 정석도 조금 읽을 예정이다.

'TIL > Spring' 카테고리의 다른 글

23.06.20  (0) 2023.06.20
23.06.19  (0) 2023.06.19
23.06.15  (0) 2023.06.15
23.06.13  (0) 2023.06.13
23.06.12  (0) 2023.06.12