반응형

이번 시간에는 Spring Framework에서 동시성 문제를 해결해 줄 ThreadLocal에 대해서 알아보겠습니다.

스프링은 빈을 싱글톤으로 등록한다. 애플리케이션에 딱 1개만 존재한다는 뜻이다.

이렇게 하나만 있는 인스턴스에 하나의 필드에 여러 쓰레드가 동시에 접근하여 수정하게 되면 A 사용자가 B 사용자의 데이터를 보게 되는 사고가 발생될 수 있다. 아래 목차와 같이 순서대로 동시성 원인부터 해결 방안까지 알아보겠습니다. 


<목차>

1. 동시성 문제 발생하는 예시

2. ThreadLocal을 이용해서 동시성 문제 해결 방안

3. ThreadLocal의 주의사항

 


1. 동시성 문제 발생하는 예시

FieldService.java

  1. nameStore을 맴버변수로 선언

  2. nameStore 변수에 값을 저장하고 1초 뒤에 저장한 nameStore 출력

 

@Slf4j
public class FieldService {

    private String nameStore;

    public String logic(String name) {
        log.info("저장 name={} -> nameStore={}", name, nameStore);
        nameStore = name;
        sleep(1000);
        log.info("조회 nameStore = {}", nameStore);
        return nameStore;
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

FieldServiceTest.java

  1. threadA.setName("thread-A");   -- Thread 이름 설정

  2. Thread A 시작하고 2초 잠시 멈추기 때문에 동시성 발생 안함.

  3. Thread B 시작하고 sleep(2000) 안하면 쓰레드 끝나기 전에 테스트 코드가 종료되서 모든 로그가 출력이 안되서 추가해야 한다.

 

@Slf4j
public class FieldServiceTest {

    private FieldService fieldService = new FieldService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            fieldService.logic("userA");
        };
        Runnable userB = () -> {
            fieldService.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A"); // 1. thread 이름 설정
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(2000); // 2. 동시성 문제 발생x
        threadB.start();

        sleep(2000); // 3. 메인 쓰레드 종료 대기
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

TestCode 결과

  1. thead-A, thread-B 위에서 설정한 이름

  2. 정상적으로 thread-A에 userA thread-B에 userB 출력

 

 

동시성 문제 발생

FieldServiceTest.java

  1. ThreadA가 시작하고 0.1초만에 ThreadB 접근

 

@Slf4j
public class FieldServiceTest {

    private FieldService fieldService = new FieldService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            fieldService.logic("userA");
        };
        Runnable userB = () -> {
            fieldService.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(100); // 1. 동시성 문제 발생
        threadB.start();

        sleep(2000);
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

TestCode 결과

  1. thread-A에 조회할 때 그 전에 thread-B가 nameStore을 수정해서 동시성 문제 발생 

 

 

 


2. ThreadLocal을 이용해서 동시성 문제 해결 방안

ThreadLocal은 해당 쓰레드만 접근할 수 있는 별도의 특별한 내부 저장소이다.

 

ThreadLocalService.java

  1. String nameStore -> ThreadLocal<String> nameStore 변경

  2. nameStore.set() - 값 저장

      nameStore.get() - 값 조회

      nameStore.remove() - 값 제거

 

@Slf4j
public class ThreadLocalService {

    private ThreadLocal<String> nameStore = new ThreadLocal<>();

    public String logic(String name) {
        log.info("저장 name={} -> nameStore={}", name, nameStore.get());
        nameStore.set(name);
        sleep(1000);
        log.info("조회 nameStore = {}", nameStore.get());
        return nameStore.get();
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

ThreadLocalServiceTest.java

  1. FieldService만 ThreadLocalService로 수정

 

@Slf4j
public class ThreadLocalServiceTest {

    private ThreadLocalService threadLocal = new ThreadLocalService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            threadLocal.logic("userA");
        };
        Runnable userB = () -> {
            threadLocal.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(100); // 동시성 문제 발생x
        threadB.start();

        sleep(2000); // 메인 쓰레드 종료 대기
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

 

TestCode 결과

  1. ThreadLocal이라는 별도의 쓰레드 저장소를 사용함으로써 동시성 문제를 해결하였습니다.

 

 


3. ThreadLocal의 주의사항

ThreadLocal 값을 사용 후 제거하지 않고 그냥 두면 WAS에서 쓰레드 풀을 사용하는 경우 심각한 문제가 발생할 수 있다.

보통 was는 쓰레드 생성 비용이 비싸기 때문에 쓰레드를 사용하면 반납하고 재사용하게 된다. 

그러므로 thead-A를 사용하고 쓰레드 값을 제거하지 않을 상태에서 반납 후 다른 사용자 HTTP 요청이 thead-A에 할당되면 thead-A 보관소에 그대로 값이 있어서 다른 사용자에게 잘못된 정보가 노출될 수 있다.

이런 문제를 예방하기 위해서는 사용자 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove()를 통해서 꼭 제거해야 한다.

* Spring Framework 같은 경우 Filter나 Interceptor를 이용해서 깔끔하게 제거해 주는 것이 안전하다.

 

 

 

참조

  - inflearn 강의(스프링 핵심 원리 - 고급편, 김영한)

728x90
반응형
반응형

목표

  • 객체와 테이블 연관관계의 차이를 이해
  • 객체의 참조와 테이블의 외래 키를 매핑

목차

  • 연관관계가 필요한 이유
  • 단방향 연관관계
  • 양방향 연관관계와 연관관계의 주인

연관관계가 필요한 이유

'객체지향 설계의 목표는 자율적인 객체들의 협력 공동체를 만드는 것이다.'   - 조영호(객체지향의 사실과 오해)

 

예제 시나리오

  1. 회원과 팀이 있다.
  2. 회원은 하나의 팀에만 소속될 수 있다.
  3. 회원과 팀은 다대일 관계다.

- 객체를 테이블에 맞추어 모델링(연관관계가 없는 객체)

- 객체를 테이블에 맞추어 모델링(참조 대신에 외래 키를 그대로 사용)

Member.java
Team.java

외래 키 식별자를 직접 다룰 수밖에 없음

객체를 테이블에 맞추어 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.

  • 테이블은 외래 키로 조인을 사용해서 연관된 테이블을 찾는다.
  • 객체는 참조를 사용해서 연관된 객체를 찾는다.
  • 테이블과 객체 사이에는 이런 큰 간격이 있다.

- 객체 지향 모델링(객체 연관관계 사용)

Member.java

@ManyToOne - 한팀에 여러 맴버가 있으므로 Member 입장에서 N:1이므로 ManyToOne이다.

@JoinColumn - 이름 그대로 조인하는 칼럼 이름 명시

 

JpaMain.java

전에는 teamId로 연관관계가 매핑되어 있어 teamId로 team 테이블에서 find 해서 가져와야 했다면 객체 연관관계로 매핑하면 getTeam으로 바로 꺼내올 수 있다. 

 

양방향 연관관계와 연관관계의 주인

테이블은 같은 경우는 TEAM_ID(FK) 하나로 Member, Team에서 서로 매핑되는 정보를 알 수 있었다. 하지만 객체에서는 단반향 일 때는 Member에서 Team 객체를 추가해서 알 수 있었지만 Team에서는 알 수 있는 방법이 없다.

 

Team.java

이제는 반대 방향일 때의 사용자의 팀을 가져올 수 있다. 

 

객체와 테이블이 관계를 맺는 차이

객체 연관관계 = 2개

회원 -> 팀 연관관계 1개(단방향)

팀 -> 회원 연관관계 1개(단방향)

 

테이블 연관관계 = 1개

회원 <-> 팀의 연관관계 1개(양방향)

 

연관관계 주인(Owner)

양방향 매핑 규칙

  • 객체의 두 관계중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래 키를 관리(등록, 수정)
  • 주인이 아닌쪽은 읽기만 가능
  • 주인은 mappedBy 속성 사용X
  • 주인이 아니면 mappedBy 속성으로 주인 지정

누구를 주인으로?

외래 키가 있는 곳을 주인으로 정해라

 

양방향 매핑시 가장 많이 하는 실수.. (연관관계의 주인에 값을 입력하지 않음)

JpaMain.java
H2 DB

TEAM_ID에 NULL값이 들어간다.

원인 : mappedBy는 읽기 전용 변경할 때는 아예 보지를 않는다.

Team.java
JpaMain.java

양방향 매핑시 연관관계 주인에 값을 입력해야 한다. (순수한 객체 관계를 고려하면 항상 양쪽다 값을 입력해야 한다.)

 

양방향 매핑 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐
  • JPQL에서 역방향으로 탐색할 일이 많음
  • 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 됨(테이블에 영향을 주지 않음)

연관관계의 주인을 정하는 기준

  • 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
  • 연관관계의 주인은 외래 키의 위치를 기준으로 정해야함 

 

 

참고 - 인프런 강의(자바 ORM 표준 JPA 프로그래밍 - 기본편, 김영한)

728x90
반응형

'JPA' 카테고리의 다른 글

JPA 순환 참조 원인 및 해결 방법  (0) 2021.08.26
JPA 엔티티 매핑  (0) 2021.06.08
JPA 영속성 관리  (0) 2021.06.06
JPA 소개  (0) 2021.06.04
반응형

목차

  1. 객체와 테이블 매핑
  2. 데이터베이스 스키마 자동 생성
  3. 필드와 칼럼 매핑
  4. 기본 키 매핑

엔티티 매핑 소개

1. 객체와 테이블 매핑 : @Entity, @Table

 - @Entity가 붙은 클래스는 JPA가 관리, 엔티티라 한다.

 - JPA를 사용해서 테이블과 매핑할 클래스는 @Entity 필수

주의 

 - 기본 생성자 필수(파라미터가 없는 public 또는 protected 생성자)

 - final 클래스, enum, interface, inner 클래스 사용x

 - 저장할 필드에 final 사용x 

 

@Entity

- JPA에서 사용할 엔티티 이름을 지정한다.

- 기본값 : 클래스 이름을 그대로 사용

- 가급적 기본값 사용 권장

 

@Table 

- 엔티티와 매핑할 데이블 지정

name - 매핑할 테이블 이름

@Table(name = "MBR")을 사용해서 insert 쿼리가 MBR 테이블로 생성된다.

 

2. 데이터베이스 스키마 자동 생성

- DDL을 애플리케이션 실행 시점에 자동 생성

- 테이블 중심 -> 객체 중심

- 데이터베이스 방언을 활용해서 데이터베이스에 맞는 적절한 DDL 생성

- 이렇게 생성된 DDL은 개발 장비에서만 사용

 

옵션 설명
create 기존테이블 삭제 후 다시 생성(DROP + CREATE)
create-drop create와 같으나 종료시점에 테이블 DROP
update 변경분만 반영(운영 DB에는 사용하면 안됨)
validate 엔티티와 테이블이 정상 매핑되었는지만 확인
none 사용하지 않음

 

persistence.xml

DB 테이블을 DROP하고 CREATE 한다.

 

주의!

운영 장비에는 절대 create, create-drop, update 사용하면 안된다.

개발 초기 단계는 create 또는 update

테스트 서버는 update 또는 validate

스테이징과 운영 서버는 validate 또는 none

 

DDL 생성 기능

- DDL 생성 기능은 DDL을 자동 생성할 때만 사용되고 JPA의 실행 로직에는 영향을 주지 않는다.

크게 애플리케이션에 영향을 주지 않음 길이나 ALTER DDL 쿼리만 추가된다. 

3. 필드와 컬럼 매핑 : @Column

어노테이션 설명
@Column 컬럼 매핑
@Temporal 날짜 타입 매핑
@Enumerated enum 타입 매핑
@Lob BLOB, CLOB 매핑
@Transient 특정 필드를 칼럼에 매핑하지 않음(매핑 무시)

@Lob - 문자열 타입이면 기본으로 clob으로 생성된다.

@Enumerated(EnumType.STRING) - EnumType.STRING 사용O ORDINAL 사용X

 

참고 - LocalDate, LocalDateTime을 사용할 때는 @Temporal(TemporalType.TIMESTAMP) 생략 가능(최신 하이버네이트 지원)

4. 기본 키 매핑 : @Id

기본 키 매핑 방법

직접 할당 : @Id만 사용

자동 생성(@GeneratedValue)

 - IDENTITY : 데이터베이스에 위임, MYSQL

 - SEQUENCE : 데이터베이스 시쿼스 오브젝트 사용, ORACLE, @SequenceGenerator 필요

 - TABLE : 키 생성용 테이블 사용, 모든 DB에서 사용, @TableGenerator 필요

 - AUTO : 방언에 따라 자동 지정, 기본 값

 

IDENTITY 전략 - 특징

  • 기본 키 생성을 데이터베이스 위임
  • 주로 MySQL, PostgreSQL, SQL Server, DB2에서 사용
  • JPA는 보통 트랜잭션 커밋 시점에 INSERT SQL 실행
  • AUTO_INCREMENT는 데이터베이스에 INSERT SQL을 실행 한 후 이후에 ID 값을 알 수 있음
  • IDENTITY 전략은 entityManager.persist() 시점에 즉시 INSERT SQL 실행하고 DB에서 식별자를 조회

SEQUENCE 전략 - 특징

  • 데이터베이스 시쿼스는 유일한 값을 순서대로 생성하는 특별한 데이터베이스 오브젝트
  • 오라클, PostgreSQL, DB2, H2 데이터베이스에서 사용
  • 속성 allocationSize - 시퀀스 한 번 호출에 증가하는 수(성능 최적화에 사용됨, 데이터베이스 시퀀스 값이 하나씩 증가하도록 설정되어 있으면 값을 반드시 1로 설정해야 한다.)

TABLE 전략 - 특징

  • 키 생성 전용 테이블을 하나 만들어서 데이터베이스 시퀀스를 흉내내는 전략
  • 장점 : 모든 데이터베이스에 적용 가능
  • 단점 : 성능

 

직접 할당

Member.java
JpaMain.java

자동 할당 - IDENTITY 전략

Member.java
JpaMain.java

자동 할당 - SEQUENCE 전략

시퀀스 DROP 이후 생성

 

자동 할당 - TABLE 전략

키 값을 채번하는 테이블 생성

 

참고 - 인프런 강의(자바 ORM 표준 JPA 프로그래밍 - 기본편, 김영한)

728x90
반응형

'JPA' 카테고리의 다른 글

JPA 순환 참조 원인 및 해결 방법  (0) 2021.08.26
JPA 연관관계 매핑 기초  (0) 2021.06.17
JPA 영속성 관리  (0) 2021.06.06
JPA 소개  (0) 2021.06.04
반응형

들어가기전에 JPA 환경 세팅

  • idea - Intellij 
  • maven 프로젝트

디렉토리 구조

 

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>jpa-basic</groupId>
<artifactId>ex1-hello-jpa</artifactId>
<version>1.0.0</version>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>

<dependencies>
<!-- JPA 하이버네이트 -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>5.3.10.Final</version>
</dependency>
<!-- H2 데이터베이스 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
</dependency>

<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
</project>

 

JPA 설정 파일 (/META-INF/persisatence.xml 위치)

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="hello">
<properties>
<!-- 필수 속성 -->
<property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
<property name="javax.persistence.jdbc.user" value="sa"/>
<property name="javax.persistence.jdbc.password" value=""/>
<property name="javax.persistence.jdbc.url" value="jdbc:h2:tcp://localhost/~/test"/>
<property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/> <!--데이터베이스 방언-->

<!-- 옵션 -->
<property name="hibernate.show_sql" value="true"/>
<property name="hibernate.format_sql" value="true"/>
<property name="hibernate.use_sql_comments" value="true"/>
<!--<property name="hibernate.hbm2ddl.auto" value="create" />-->
</properties>
</persistence-unit>
</persistence>

 

JPA는 특정 데이터베이스에 종속X

각각의 데이터베이스가 제공하는 SQL 문법과 함수는 조금씩 다름
방언: SQL 표준을 지키지 않는 특정 데이터베이스만의 고유한 기능

<property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/> <!--데이터베이스 방언-->

사용하는 데이터베이스에 따라서 dialect를 다르게 설정한다. 이번 실습은 H2 데이터베이스 사용

 

JpaMain.java

EntityManagerFactory - 하나만 생성해서 애플리케이션 전체에서 공유

EntityManager - 쓰레드간에 공유X (사용하고 버려야 한다.)

JPA의 모든 데이터 변경은 트랜잭션 안에서 실행

JPQL - 테이블이 아닌 객체를 대상으로 검색하는 객체 지향 쿼리

 

JPA에서 가장 중요한 2가지

  • 객체와 관계형 데이터베이스 매핑하기(Object Relational Mapping)
  • 영속성 컨텍스트

엔티티 매니저 팩토리와 엔티티 매니저

출처 - inflean 자바 ORM 표준 JPA 프로그래밍 - 기본편 수업자료, 김영한

영속성 컨텍스트

  • JPA를 이해하는데 가장 중요한 용어
  • "엔티티를 영구 저장하는 환경"이라는 뜻
  • EntityManager.persist(entity);

EntityManager가 영속성 컨테스트라고 생각하면 쉽다.

 

엔티티의 생명주기

비영속(new/transient) - 영속성 컨텍스트와 전혀 관계가 없는 새로운 상태

영속(managed) - 영속성 컨텍스트 관리되는 상태

준영속(detached) - 영속성 컨텍스트에 저장되었다가 분리된 상태

삭제(removed) - 삭제된 상태

출처 - inflean 자바 ORM 표준 JPA 프로그래밍 - 기본편 수업자료, 김영한

비영속 상태 - 객체를 생성한 상태

영속 상태 - 객체를 저장한 상태

준영속, 삭제 상태

영속성 컨텍스트의 이점

  • 1차 캐시
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
  • 변경 감지(Dirty Checking)
  • 지연 로딩(Lazy Loading)

1차 캐시에서 조회

entityManager.persist(member) - 영속성 컨텍스트에서 관리되기 때문에 db select 쿼리 필요 없이 1차 캐시에서 바로 해당 member name 출력 가능

id가 2인 member를 찾기위해서 db select 쿼리 후 member name 출력

 

영속 엔티티의 동일성 보장

1차 캐시로 반복 가능한 읽기(REPEATABLE READ) 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공

 

트랜잭션을 지원하는 쓰기 지연

EntityTransaction transaction = entityManager.getTransaction();
transaction.begin(); - 엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.

entityManager.persist(member2); - 여기까지 INSERT 쿼리를 데이터베이스에 보내지 않는다.

커밋하는 순간 데이터 베이스에 INSERT 쿼리를 보낸다.

쓰기 지연 SQL 저장소에 쿼리를 저장해놓다가 commit 하는 순간 데이터베이스에 쿼리를 날린다.

 

변경 감지(Dirty Checking)

영속성 컨텍스트에서 관리하고 있다가 엔티티와 스냅샷을 비교하여 변경이 감지되면 commit 할 때 update 쿼리를 데이터베이스에 날린다.

 

엔티티 삭제

entityManager.remove(member1) - 엔티티 삭제

플러시

영속성 컨텍스트의 변경내용을 데이터베이스에 반영

 

플러시 발생

  • 변경 감지
  • 수정된 엔티티 쓰기 지연 SQL 저장소에 등록
  • 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송(등록, 수정,  삭제 쿼리)

영속성 컨텍스트를 플러시하는 방법

  • entityManager.flush() - 직접 호출
  • 트랜잭션 커밋 - 플러시 자동 호출
  • JPQL 쿼리 실행 - 플러시 자동 호출

JPQL 쿼리 실행 시 플러시가 자동으로  호출되는 이유 - 영속성 컨텍스트에서만 관리되면 해당 테이블 모두 조회하는 쿼리가 실행 시 데이터베이스가 동기화되지 않아서 문제 발생 소지가 있기 때문에

EX)

플러시는 

  • 영속성 컨텍스트를 비우지 않음
  • 연속성 컨텍스트의 변경내용을 데이터베이스에 동기화
  • 트랜잭션이라는 작업 단위가 중요 -> 커밋 직전에만 동기화하면 됨

준영속 상태

  • 영속 -> 준영속
  • 영속 상태의 엔티티가 영속성 컨텍스트에서 분리(detached)
  • 영속성 컨텍스트가 제공하는 기능을 사용 못함

준영속 상태로 만드는 방법

entityManager.detach(entyty) - 특정 엔티티만 준영속 상태로 전환

entityManager.clear() - 영속성 컨텍스트를 완전히 초기화

entityManager.close() - 영속성 컨텍스트를 종료

 

 

참고 - 인프런 강의(자바 ORM 표준 JPA 프로그래밍 - 기본편, 김영한)

728x90
반응형

'JPA' 카테고리의 다른 글

JPA 순환 참조 원인 및 해결 방법  (0) 2021.08.26
JPA 연관관계 매핑 기초  (0) 2021.06.17
JPA 엔티티 매핑  (0) 2021.06.08
JPA 소개  (0) 2021.06.04

+ Recent posts