본문 바로가기

Back-End 공부/Spring

스프링 DB 연결 및 Transactional 처리

[ 시작 코드 ]

 

findById 추가 Update - Dto 관련코드 수정 · yujeong-shin/spring_encore@4576dda

yujeong-shin committed Jan 16, 2024

github.com

 

 

 

🎈 실습

Repository에 메서드명 변경 -> Service에 수정

1. memberCreate -> save로 변경. return 타입 Member

2. findById -> return 타입 Optional<Member>

3. 리스트 조회 -> findAll

 

 

[ MemberRepository.java ]

인터페이스에서 메서드명, 반환타입 변경

public interface MemberRepository {
    public List<Member> findAll();
    public Member save(Member member);
    public Optional<Member> findById(int id);
}

 

 

[ MemoryMemberRepository.java ]

Optional.of()와 Optional.empty() 활용

@Repository
public class MemoryMemberRepository implements MemberRepository{
    private final List<Member> memberDB;
    public MemoryMemberRepository(){
        memberDB = new ArrayList<>();
    }

    @Override
    public List<Member> findAll(){
        return memberDB;
    }

    @Override
    public Member save(Member member){
        memberDB.add(member);
        return member; //지정된 format 때문에 바꾸는 것
    }

    @Override
    public Optional<Member> findById(int id) {
        for(Member member : memberDB){
            if(member.getId() == id){
                return Optional.of(member);
            }
        }
        return Optional.empty();
    }
}

 

 

[ MemberService.java ]

findAll()로 메서드명 변경, findAll() 내부 memberRepository.findAll()로 변경

findById() 메서드 Optional과 throw, orElseThrow를 사용해 처리

public MemberResponseDto findById(int id) throws NoSuchElementException { // Optional, 예외처리 디테일 챙기기
    //Member 객체를 MemberResponseDto로 변환
    //생성자 초기화보다는 유연성이 좋다.
    // Member member = memberRepository.findById(id).orElseThrow(()-> new NoSuchElementException());
    // Optional로 객체가 반환되지 않으면(비어있으면) 예외 터져서 아래 코드로 내려가지 않음.
    //개발자 간 NoSuchElementException이 발생하면 어떻게 처리할지 다루려고 적는 것이지,
    //에러만 적는다고 페이지 상으로 404 에러가 나가지는 않음! ResponseEntity로 잘 처리하자!
    Member member = memberRepository.findById(id).orElseThrow(NoSuchElementException::new);
    MemberResponseDto memberResponseDto = new MemberResponseDto();
    memberResponseDto.setId(member.getId());
    memberResponseDto.setName(member.getName());
    memberResponseDto.setEmail(member.getEmail());
    memberResponseDto.setPassword(member.getPassword());
    memberResponseDto.setCreate_time(member.getCreate_time());
    return memberResponseDto;
}

 

 

 

[ templates/404-error-page.html ]

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>404 Error Page</title>
</head>
<body>
<h1>잘못된 요청입니다.</h1>
</body>
</html>

 

 

 

 

DB 연결하기

1.  DB 생성

basic 스키마 생성 -> member 데이터베이스 생성

(1) workbench 사용

 

 

(2) CREATE TABEL문 사용

use basic;
  CREATE TABLE `basic`.`member` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL,
  `email` VARCHAR(255) NOT NULL,
  `password` VARCHAR(255) NOT NULL,
  `create_time` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`));

 

 

 

2. DB와 싱크 맞추면서 구조 조정하기

[ MemberService.java ]

id와 create_time은 DB에서 자동으로 생성되어 객체에 값이 세팅되기 때문에

id, create_time 관련 세팅 코드들은 Repository.java로 이동

public void createMember(MemberRequestDto memberRequestDto){
    Member member = new Member(memberRequestDto.getName(), memberRequestDto.getEmail(), memberRequestDto.getPassword());
    memberRepository.save(member);
}

 

 

[ MemoryMemberRepository.java ]

@Override
public Member save(Member member){
    ++total_id;
    LocalDateTime now = LocalDateTime.now();
    member.setId(total_id);
    member.setCreate_time(now);
    memberDB.add(member);
    return member;
}

 

 

 

3. DB 설정정보 세팅하기

[ application.yml ]

spring:
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost:3306/basic
    username: root
    password: 1234

 

 

[ build.gradle ]

//DB 연결을 위한 의존성 : jdbc, mariadb 클라이언트
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
   implementation 'org.mariadb.jdbc:mariadb-java-client'

 

 

[ 현재까지 코드 ]

 

DB 연결하기 초기 세팅 - 구조 조정 · yujeong-shin/spring_encore@ad13e8f

yujeong-shin committed Jan 16, 2024

github.com

 

 

 

 

3-(1). JDBC 연결

DDL 수행 -> Raw 쿼리 수행 -> 객체 직접 조립

 

✅ 특징

- Connection, PreparedStatement, ResultSet을 사용해 SQL문을 직접 작성 및 처리해주어야 한다

- SQL문에 들어가야 할 값들은 ?로 표시한다

- 쿼리문이 " " 안에 쌓여 있어서 오타가 났어도 컴파일 타임에 발견되지 않는 점이 단점이다

- ResultSet에 담긴 데이터를 일일이 객체에 담아줘야 한다

 

[ JdbcMemberRepository.java ]

save() 구현

@Override
public Member save(Member member) {
    try{
        Connection connection = dataSource.getConnection();
        String sql = "insert into member(name, email, password) values(?,?,?)";
        // preparedStatement : ? 빠진 SQL
        PreparedStatement preparedStatement = connection.prepareStatement(sql);
        // ?, ?, ? 값들을 세팅
        preparedStatement.setString(1, member.getName());
        preparedStatement.setString(2, member.getEmail());
        preparedStatement.setString(3, member.getPassword());
        preparedStatement.executeUpdate();

    }catch (SQLException e){
        e.printStackTrace();
    }
    return member;
}

 

 

[ MemberService.java ] 

구현체 변경 MemoryMemberRepository -> JdbcMemberRepository

public class MemberService {
    private final MemberRepository memberRepository;
    @Autowired
    public MemberService(JdbcMemberRepository jdbcMemberRepository) {
        this.memberRepository = jdbcMemberRepository;
    }

 

 

회원등록 테스팅. 회원등록 누르면 에러창 뜨는 게 맞음.

DB에 정상적으로 값 들어갔는지 확인

 

 

 

[ JdbcMemberRepository.java ]

findAll(), findById() 구현

@Override
public List<Member> findAll() {
    List<Member> members = new ArrayList<>();
    try{
        Connection connection = dataSource.getConnection();
        String sql = "select * from member";
        PreparedStatement preparedStatement = connection.prepareStatement(sql);
        ResultSet resultSet = preparedStatement.executeQuery();
        while(resultSet.next()){
            int id = resultSet.getInt("id");
            String name = resultSet.getString("name");
            String email = resultSet.getString("email");
            String password = resultSet.getString("password");
            LocalDateTime now = resultSet.getTimestamp("create_time").toLocalDateTime();

            Member member = new Member(name, email, password);
            member.setId(id);
            member.setCreate_time(now);

            members.add(member);
        }
    }catch (SQLException e){
        e.printStackTrace();
    }
    return members;
}

 

@Override
public Optional<Member> findById(int inputId) {
    Member member = null;
    try{
        Connection connection = dataSource.getConnection();
        String sql = "select * from member where id = ?";
        // preparedStatement : ? 빠진 SQL
        PreparedStatement preparedStatement = connection.prepareStatement(sql);
        // ?값을 세팅
        preparedStatement.setInt(1, inputId);
        // 결과 가져오기
        ResultSet resultSet = preparedStatement.executeQuery();

        resultSet.next();
        int id = resultSet.getInt("id");
        String name = resultSet.getString("name");
        String email = resultSet.getString("email");
        String password = resultSet.getString("password");
        LocalDateTime now = resultSet.getTimestamp("create_time").toLocalDateTime();

        member = new Member(name, email, password);
        member.setId(id);
        member.setCreate_time(now);
    }catch (SQLException e){
        e.printStackTrace();
    }
    return Optional.ofNullable(member);
}

 

 

[ 완성 코드 ]

 

 

3-(2). SpringDataJpa 연결

DDL 수행 X -> Raw 쿼리 X -> 객체 조립 X

 

✅ 특징

- SpringDataJpa 기술의 근간에는 JDBC 기술이 들어가 있다

- Hibernate는 JPA의 구현체이기 때문에. JPA == Hibernate 의미가 동일하다

- jpa 및 hibernate 설정을 추가해줘야 한다

- @Column으로 변경한 객체의 변수 이름과, 테이블 컬럼명이 일치하지 않아도 JPA가 인지해서 잘 넣어준다

- @Entity를 통해 mariaDB 테이블과 컬럼을 자동 생성한다

- DML 쿼리 생성 자동화 : 메서드 명으로 약속.  ex) findAll, findById, .. 

- 리플렉션 기술을 통해 런타임에 Setter를 이용해 객체를 초기화한다. 몇 개의 속성을 이용해 만든 생성자가 따로 존재한다면, @NoArgsConstructor을 통해 매개변수 없는 기본 생성자를 생성해줘야 한다 !

- return 이후 객체 변환까지 자동화되어 있다

 

 

[JPA 포스팅 추가 예정]

JPA : 자바에서 관계형 데이터베이스를 사용하는 방식을 정의한 인터페이스

[ORM 포스팅 추가 예정]

orm : 쿼리없이 프로그램으로 DB 컨트롤, 객체지향

ORM == JPA 의미가 통한

 

 

[ build.gradle ]

//spring data jpa 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

 

 

[ application.yml ]

jpa 및 hibernate 설정. 실무에서는 절대 create 사용하지 말 것 !!!!

spring:
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost:3306/basic
    username: root
    password: 1234
  jpa:
    databases: mysql
    # innoDB 엔진 사용 설정
    database-platform: org.hibernate.dialect.MariaDBDialect
    # 스키마는 사전 생성 필요
    # 테이블과 컬럼은 자동 생성해줌
    generate-ddl: true
    hibernate:
      #create 옵션은 매번 table을 drop 후 생성
      #update 옵션은 변경 사항만 alter를 적용
      #validate는 실제 DB만의 차이만을 check
      #실무에서는 update 또는 validate 사용
      ddl-auto: create
    #jpa가 자동으로 쿼리를 실행할 때, 쿼리가 console창에 보여지도록
    show-sql: true

 

 

ddl-auto : create이기 때문에, Spring 서버를 실행할 때마다 새로운 테이블이 생성되어 회원등록 전에는 데이터가 없다.

 

 

 

[ build.gradle ]

jdbc 설정 정보 "삭제"

jpa 설정에 jdbc가 포함되어 있음.

implementation 'org.springframework.boot:spring-boot-starter-jdbc'

 

 

[ MemberService.java ]

구현체 변경

public class MemberService {
    private final MemberRepository memberRepository;
    @Autowired
    public MemberService(SpringDataJpaMemberRepository springDataJpaMemberRepository) {
        this.memberRepository = springDataJpaMemberRepository;
    }

 

 

[ Member.java ]

@Entity를 통해 mariaDB 테이블과 컬럼을 자동 생성⭐

 

JPA는 리플렉션 기술을 통해 런타임에 Member 객체를 하나 생성해서,

모든 속성에 대해 .set으로 값을 넣어주게 되는데 매개변수 3개인 생성자만 덜렁 있으면 이 과정이 불가능하다.

@NoArgsConstructor을 통해 매개변수 없는 기본 생성자를 생성해줘야 한다 !

 

 

@Entity : 테이블 자동생성하는 JPA 기술(스프링 부트 기술XX, MyBatis 기술XXXX)
@Id : pk 설정
@Column : DB에서 사용할 컬럼명지정.

== @Column(name="created_time")과 private LocalDateTime create_time을 같은 것으로 인지

@GeneratedValue(strategy = GenerationType.IDENTITY) : Auto Increment

 


@Column 자체가 JPA 기술이기 때문에 MyBatis에서는 @Column인지하지 못해 변수명과 DB컬럼명이 다르면 문제가 발생한다.

@Getter
//Entity 어노테이션을 통해 mariaDB 테이블 및 컬럼을 자동 생성
//Class 명은 테이블명, 변수명은 컬럼명
@Entity
@NoArgsConstructor //기본 생성자 말고 다른 생성자가 있을 때 추가해줘야 함.
//// JPA가 모든 속성에 setter로 런타임에 값을 넣어줘야 하기 때문이다.
public class Member {
    @Setter
    @Id //pk 설정
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id; //Repository에서 set하기 위해 어쩔 수 없이 붙임 (DB와 싱크를 맞추기 위해 필요함)
    // String은ㅇ DB의 varchar로 자동 변환
    private String name;
    @Column(nullable = false, length = 50)
    private String email;
    private String password;
    @Setter
    @Column(name = "created_time") //name 옵션을 통해 DB의 컬럼명 별도 지정 가능
    //DB의 컬럼명은 created_time, 스프링에서 Member 객체 조립할 때 create_time으로 알아서 넣어줌
    private LocalDateTime create_time;
    public Member(String name, String email, String password){
        this.name = name;
        this.email = email;
        this.password = password;
        this.create_time = LocalDateTime.now();
    }
}

 

➡️ 추후 SpringDataJpa 사용 시에는 @CreationTimestamp 어노테이션을 사용해 DB와의 싱크를 맞춰준다.

@Column(name = "created_time") //name 옵션을 통해 DB의 컬럼명 별도 지정 가능
@CreationTimestamp
private LocalDateTime create_time;
@UpdateTimestamp
private LocalDateTime updated_time;
public Member(String name, String email, String password){
    this.name = name;
    this.email = email;
    this.password = password;
    //this.create_time = LocalDateTime.now();
}

 

 

[ SpringDataJpaMemberRepository.java ]

SpringDataJpaMemberRepository는 인터페이스는 MemberRepository, JpaRepository를 모두 상속하고 있다.

MemberRepository에도 findAll, JpaRepository에도 findAll이 있어, 같은 이름의 메서드 중 어떤 것을 호출할지의 이슈가 생긴다. 

구현체를 @Repository, extends JpaRepository를 쓰면 SpringDataJpa 기술을 통해 주입되므로, 별도의 구현체가 필요없다.

 

<Entity 타입명, PK 타입명>만 명시해주면 인터페이스를 가져다가 별도의 구현없이 구현체를 사용할 수 있다.

@Repository
//Spring Data Jpa의 기본 기능을 쓰기 위해서는 JpaRepository를 상속해야함
//상속 시 Entity명과 해당 Entitiy의 PK 타입을 명시
//<Class명, PK타입>
//구현 클래스와 스펙은 SimpleJpaRepository 클래스에 있고, 실질적인 구동 상황에서 hibernate 구현체에 동작을 위임⭐
public interface SpringDataJpaMemberRepository extends MemberRepository, JpaRepository<Member, Integer> { //인터페이스 간의 관계는 extends.
}

 

 

[ MemberService.java ]

기존에는 인터페이스를 코드에 넣어주면서 공동된 함수만 사용하도록 기능을 제한했는데,

JpaRepository안에 있는 다양한 메서드들을 사용하기 위해 구현체에 의존하도록 바꾸면서 기능 제한을 풀어준다.

private final MemberRepository memberRepository;

 

public class MemberService {
    private final SpringDataJpaMemberRepository springDataJpaMemberRepository;
    @Autowired
    public MemberService(SpringDataJpaMemberRepository springDataJpaMemberRepository) {
        this.springDataJpaMemberRepository = springDataJpaMemberRepository;
    }

 

 

Spring 코드를 돌리면 자동으로 테이블이 생성된다.

 

 

콘솔 창에 Hibernate가 동작하고 있는 것을 확인할 수 있다.

 

 

 

3-(3). MyBatis 연결

DDL 수행 -> Raw 쿼리 수행 -> 객체 조립 X

 

✅ 특징

- 일일이 조립해줘야 하기 때문에, 객체의 변수와 테이블 컬럼명이 일치해야 한다.

- @Mapper 어노테이션과 MemberMapper.xml을 사용한다.

Mapper 파일 확인 > MybatisMemberRepo랑 싱킹되어 있네? > Mapper 파일 가지고 구현체 생성해서 MemberService에서 해당 메소드들이 동작하게 함.

 

[ build.gradle ]

//mybatis 관련 의존성 추가
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

 

 

[ MybatisMemberRepository.java ]

@Mapper //mybatis 레파지토리로 쓰겠다는 어노테이션
@Repository
public interface MybatisMemberRepository extends MemberRepository{
    //본문에 MybatisRepository에서 사용할 메서드 명세를 정의해야 하나,
    //MemberRepository에서 상속받고 있으므로 생략 가능.
    //실질적인 쿼리 등 구현은 resources/mapper/MemberMapper.xml파일에서 수행
}

 

 

💡MybatisMemberRepository와 MemberMapper.xml연결

MybatisMemberRepository - @Mapper키워드, MemberMapper.xml - mapper namespace 키워드로 상호 연결

 

[ MemberMapper.xml ]

<?xml version="1.0" encoding="UTF-8" ?>
<!-- mapper DTD 선언 -->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.encore.basic.repository.MybatisMemberRepository">
    <select id="findAll" resultType="com.encore.basic.domain.Member">
        select * from member;
    </select>
    <select id="findById" resultType="com.encore.basic.domain.Member" parameterType="Integer">
        select * from member where id=#{id};
    </select>
    <insert id="save">
        insert into member(name, email, password, created_time) values(#{name}, #{email}, #{password}, #{create_time});
    </insert>
</mapper>

 

 

[ MemberService.java ]

Repo연결 변동사항 적용

private final MemberRepository memberRepository;
@Autowired
public MemberService(MybatisMemberRepository mybatisMemberRepository) {
    this.memberRepository = mybatisMemberRepository;
}

 

요렇게까지 바꾸고 나면 회원가입 시 WhiteLabel 에러 발생한다.

 

 

[ MemberMapper.xml ]

insert문 -> select문으로 변경해서 resultType으로 Member 받아줘야 DB에 잘 들어간다.

<select id="save" resultType="com.encore.basic.domain.Member">
    insert into member(created_time, name, email, password) values(#{create_time}, #{name}, #{email}, #{password});
</select>

 

 

 

 

3-(4). 순수 Jpa 연결

✅ 특징

- JPA의 쿼리 문법인 JPQL 사용 ex) select m from Member m

- 장점 : DB에 따라 문법이 달라지지 않는 객체지향 언어, 컴파일 타임에서 check⭐!!
- 단점 : DB 고유의 기능과 성능을 극대화하기는 어려움.

- SpringDataJpa에 비해 지원하는 자동화 메서드가 적다.

 

[ JpaMemberRepository.java ]

@Repository
public class JpaMemberRepository implements MemberRepository{
    //EntityManager는 JPA의 핵심클래스(객체)
    //Entity의 생명주기를 관리. 데이터베이스와의 모든 상호작용을 책임.
    //엔티티를 대상으로 CRUD하는 기능을 제공
    private final EntityManager entityManager;
    public JpaMemberRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Override
    public Member save(Member member) {
        //persist : 전달된 엔티티(member)가 EntityManager의 관리 상태가 되도록 만들어주고,
        // 트랜잭션이 커밋될 때 데이터베이스에 저장. insert
        // EntityManager는 매개변수로 넘어온 member의 id값이 이미 존재하면 update, 아니면 insert
        entityManager.persist(member);
        return member;
    }
    @Override
    public Optional<Member> findById(int id) {
        //find메서드는 pk를 매개변수로 준다.
        Member member = entityManager.find(Member.class, id); //Member 테이블 가서 id 값 가지는 member 찾음
        //그 외 컬럼으로 조회할 때는 select m from Member m where m.name = :name => 아래 findByName() 메서드
        return Optional.ofNullable(member);
    }

    @Override
    public void delete(Member member) {
        //remove 메서드 사용
        //update의 경우 merge 메서드 사용.
    }

    //pk아닌 컬럼으로 조회
    public List<Member> findByName(String name){
        List<Member> members = entityManager.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name).getResultList();
        return members;
    }

    @Override
    //따로 제공하는 문법이 없어 직접 만들어야 함.
    public List<Member> findAll() {
        //jpql : jpa의 쿼리문법
        //장점 : DB에 따라 문법이 달라지지 않는 객체지향 언어, 컴파일 타임에서 check⭐!!
        //단점 : DB 고유의 기능과 성능을 극대화하기는 어려움.
        List<Member> members = entityManager.createQuery("select m from Member m", Member.class).getResultList();
        return members;
    }
}

 

 

⭐ ⭐ ⭐ ⭐ Transactional ⭐ ⭐ ⭐ 

순수 JPA는 persist()에서 commit을 보장하지 않는다. => commit 필요 ==> @Transactional 처리 필요

SpringDataJpa는 @Transactional 어노테이션 없어도 commit 보장

 

 

💡 SpringDataJpa에서 Transactional 어노테이션이 필요한 상황
→ 전형적인 트랜잭션 작동이 필요한 상황 ex) save(); 예외; save();
→ 중간에 예외 발생했을 때 rollback 처리 하기위해 필요

 

 

따라서 MemberService.java 클래스 단에 @Transactional 어노테이션을 붙여,

아래 비즈니스 로직에서 예외가 발생했을 때 이전 상태로 돌아가거나 성공했을 때 DB에 저장해야 한다.

==> 원자성 보장 !

 

 

<순수 Jpa에서 commit 용도로 @Transactional이 사용될 경우 테스트>

@Transactional이 있는 경우

@Service
public class MemberService {
    private final MemberRepository memberRepository;
    @Autowired
    public MemberService(JpaMemberRepository jpaMemberRepository) {
        this.memberRepository = jpaMemberRepository;
    }
    @Transactional //⭐⭐
    public void createMember(MemberRequestDto memberRequestDto) throws IllegalArgumentException {
        Member member = new Member(memberRequestDto.getName(), memberRequestDto.getEmail(), memberRequestDto.getPassword());
        memberRepository.save(member);
    }
}

 

 

커밋 완료 ⭐

 

 

@Transactional이 없는 경우

커밋 실패 ⭐

 

 

 

 

<SpringDataJpa에서 롤백 용도로 @Transactional이 사용될 경우 테스트>

@Transactional이 있는 경우

@Service
@Transactional
public class MemberService {

... 

public void createMember(MemberRequestDto memberRequestDto){
        //Transaction 테스트
        Member member = new Member(memberRequestDto.getName(), memberRequestDto.getEmail(), memberRequestDto.getPassword());
        memberRepository.save(member);
        if(member.getName().equals("kim")){
            throw new IllegalArgumentException();
        }
    }
}

 

 

 

에러가 나서 데이터가 저장되지 않아야 하는 상황 => 데이터 삽입 X, 롤백 성공⭐

 

 

@Transactional이 없는 경우

@Service
public class MemberService {
    private final MemberRepository memberRepository;
    @Autowired
    public MemberService(SpringDataJpaMemberRepository springDataJpaMemberRepository) {
        this.memberRepository = springDataJpaMemberRepository;
    }
    // 사용자의 입력 값이 담긴 DTO를 통해, 실제 시스템에서 사용되는 정보를 조합해 Member 객체로 변환 후 저장
    public void createMember(MemberRequestDto memberRequestDto){
        //Transaction 테스트
        Member member = new Member(memberRequestDto.getName(), memberRequestDto.getEmail(), memberRequestDto.getPassword());
        memberRepository.save(member);
        if(member.getName().equals("kim")){
            throw new IllegalArgumentException();
        }
    }
}

 

@Transactional을 붙이지 않는다면 에러가 발생하면 들어가지 말아야 할 값들이  DB에 저장된다 !

에러가 나서 데이터가 저장되지 않아야 하는 상황 => 데이터 삽입 O, 롤백 실패⭐

 

 

 

[ Transactional 학습한 코드 ]

 

순수 Jpa 학습, Transactional 처리 · yujeong-shin/spring_encore@4c9cb29

yujeong-shin committed Jan 17, 2024

github.com