순수 JDBC : SQL을 통해 애플리케이션 서버와 DB를 연결
JPA : 쿼리없이 객체를 DB에 저장, 접근 가능
1. H2 데이터베이스 설치
🍃 H2 데이터 베이스
Java로 작성된 오픈소스 경량형 관계형 데이터베이스 관리 시스템(RDBMS)
내장형(Embedded) 모드와 서버(Server) 모드를 동시에 사용할 수 있음 - 데이터베이스 파일(test.mv.db) 로컬에 저장되면서, 서버 모드로 실행된 H2 서버를 통해 네트워크를 통해 데이터베이스에 접근할 수 있다. ( 해당 파일을 삭제(rm)하여 데이터베이스를 삭제할 수도 있다. )
🔸로컬환경에서 H2 데이터베이스 활용방식
- 다운로드 받은 H2/bin파일내의 h2.sh (Linux/Mac) 또는 h2.bat (Windows) 스크립트를 사용하여 H2를 실행하여 데이터베이스 서버를 활성화( 스크립트가 실행될때만 접속가능 )
- H2 데이터베이스 서버가 시작되고 웹 브라우저에서 H2 콘솔이 열림 ( H2콘솔 : 데이터베이스를 관리하고 쿼리를 실행할 수 있는 웹 기반 인터페이스 )
- 내장형모드로 접속하여 DB생성 ( JDBC URL: jdbc:h2:~/test 로 접속 )
데이터베이스 파일이 생성되고 사용자 홈 디렉토리에 test.mv.db라는 이름으로 저장 - 이후 서버모드로 접속 ( JDBC URL: jdbc:h2:tcp://localhost/~/test 로 접속 )
네트워크 (TCP/IP 소켓)을 통해 데이터베이스에 접속
2. 순수 JDBC
🍃 환경설정
🔸 [1] build.gradle 파일에 jdbc, h2 데이터베이스 관련 라이브러리 추가
- JDBC API : 자바 프로그램에서 데이터베이스와 상호작용하기 위해 제공되는 표준 API
데이터베이스에 연결, 쿼리실행, 결과처리 등의 DB관련작업을 표준화한 문법( java.sql패키지의 Connection, Statement, ResultSet 등 ) - JDBC 드라이버 : 자바 프로그램에서 특정 데이터베이스 시스템 간의 통신을 가능하게 해주는 중간 계층 소프트웨어
JDBC API 호출을 특정 데이터베이스 시스템이 이해할 수 있는 명령어로 변환하여 실제적으로 처리
DBMS별로 구현되어 제공( MySQL: mysql-connector-java, PostgreSQL: postgresql, Oracle: ojdbc8, H2: h2, SQL Server: mssql-jdbc )
** 드라이버란 서로 다른 체계를 연결하거나 표준화하여 상호작용을 가능하게 하는 소프트웨어나 하드웨어( 중재자 )
특정기능을 표준화하거나 표준호출을 특정 시스템에 맞게 변환하는 역할
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
...
}
* Gradle 의존성 범위 : implementation( 컴파일 타임 + 런타임 ), compileOnly( 컴파일 타임 ), runtimeOnly( 런타임 )
자바 프로그램을 작성하고 컴파일할 때, JDBC API를 사용하여 데이터베이스와 상호작용( 어떤 데이터베이스를 사용할지 특정하지 않고 표준 API로 작성 ) -> 실제 구동시에 JDBC 드라이버가 JDBC API 호출을 H2 데이터베이스와의 실제 통신으로 변환하고 처리
🔸 [2] DB접속정보를 설정파일( application.properties )에 추가
src/main/resources/application.properties
spring.application.name=hello-spring
spring.datasoucre.url=jdbc:h2:tcp://localhost/~/test # 데이터베이스의 JDBC URL = jdbc:<subprotocol>://<host>:<port>/<database>
spring.datasource.driver-class-name=org.h2.Driver # 라이브러리 추가한 JDBC Driver
spring.datasource.username=sa # 접속 유저정보
이 작업을 통해 스프링이 해당 데이터베이스에 애플리케이션을 자동적으로 연결해준다.
🍃 순수 JDBC 코딩
- javax.sql.DataSource : 데이터베이스에 대한 접속 정보를 추상화한 것
JDBC API에서 데이터베이스 연결을 관리하고 제공하는 방법을 정의하는 핵심 인터페이스 => 연결 풀링(Connection Pooling), 트랜잭션 관리, 자원 관리 등을 통해 안전하고 효율적인 데이터베이스 연결을 지원
스프링부트를 통해 자동으로 의존성 주입을 받음( 애플리케이션의 설정파일(application.properties 또는 application.yml)에 정의된 데이터베이스 연결 정보를 바탕으로 자동으로 DataSource 빈을 설정하고 구성, Autowired 생략가능 )
public class JdbcMemberRepository implements MemberRepository{
private final DataSource dataSource;
public JdbcMemberRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
...
}
- JDBC 문법을 이용한 save(insert), find(select) 작업
(1) Connection 객체 생성 : DataSource, DataSourceUtils를 통해 얻음
(2) SQL 쿼리 작성 : 간단한 쿼리는 Statement객체, 복잡한 동적쿼리는 PreparedStatement 이용, 이후 setter로 파라미터 바인딩
(3) SQL 쿼리를 실행 : executeQuery( SELECT ), executeUpdate( INSERT, UPDATE, DELETE )
executeQuery는 ResultSet 객체로 결과반환
(4) 자원정리 : ResultSet, Statement, PreparedStatement, Connection 객체를 닫아서 자원반환
Connection 객체는 DataSourceUtils를 통해 반환
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import org.springframework.jdbc.datasource.DataSourceUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class JdbcMemberRepository implements MemberRepository{
private final DataSource dataSource;
public JdbcMemberRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
private Connection getConnection() {
return DataSourceUtils.getConnection(dataSource);
}
private void close(Connection conn) throws SQLException {
DataSourceUtils.releaseConnection(conn, dataSource);
}
private void close(Connection conn, PreparedStatement pstmt, ResultSet rs) {
try {
if (rs != null) {
rs.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
try {
if (pstmt != null) {
pstmt.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
try {
if (conn != null) {
close(conn);
}
} catch (SQLException e) {
e.printStackTrace();
}
}
@Override
public Member save(Member member) {
String sql = "insert into member(name) values(?)";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
pstmt.setString(1, member.getName());
pstmt.executeUpdate();
rs = pstmt.getGeneratedKeys();
if (rs.next()) {
member.setId(rs.getLong(1));
} else {
throw new SQLException("id 조회 실패");
}
return member;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public Optional<Member> findById(Long id) {
String sql = "select * from member where id = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, id);
rs = pstmt.executeQuery();
if(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return Optional.of(member);
} else {
return Optional.empty();
}
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public Optional<Member> findByName(String name) {
String sql = "select * from member where name = ?";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, name);
rs = pstmt.executeQuery();
if(rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return Optional.of(member);
}
return Optional.empty();
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@Override
public List<Member> findAll() {
String sql = "select * from member";
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection();
pstmt = conn.prepareStatement(sql);
rs = pstmt.executeQuery();
List<Member> members = new ArrayList<>();
while(rs.next()) { Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
members.add(member);
}
return members;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
}
🍃 리포지토리 빈의 구현체를 변경
멤버 리포지토리 빈을 JDBC 리포지토리 객체로 변경한다. 이때 기존의 상세적인 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경한다.
@Configuration
public class SpringConfig {
private DataSource dataSource;
@Autowired
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
....
@Bean
public MemberRepository memberRepository() {
// return new 다른 저장소의 리포지토리 객체;
return new JdbcMemberRepository(dataSource);
}
}
개방-폐쇄 원칙(OCP, Open-Closed Principle) : 확장에는 열려있고 수정, 변경에는 닫혀있다.( 구현체는 지속적으로 추가 가능 기존 상세코드의 수정X )
3. 스프링 통합 테스트
순수자바코드 테스트(단위테스트)를 넘어서 스프링컨테이너와 DB까지 연결한 통합테스트를 진행한다.
🔸 @SpringBootTest : 스프링 컨테이너와 테스트를 함께 실행한다.
🔸 @Transactional : 테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백한다. 테스트케이스 범위마다 진행하여 DB에 데이터를 남지 않으므로 다음 테스트에 영향을 주지 않는다.
@SpringBootTest
class MemberServiceIntegrationTest {
// 직접 생성하여 주입하는 기존방식
/*
@BeforeEach
void beforeEach(){
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
*/
// 스프링 컨테이너를 통한 자동주입
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
...
}
직접생성하여 주입하지 않는다. 스프링 컨테이너에서 관리하여 등록된 bean으로 자동주입한다.
스프링 테스트에서는 가장 간단한 방식인 필드주입으로 DI를 구현할 수 있다.
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
....
/*
@AfterEach
void afterEach(){
memberRepository.clearStore();
}
*/
....
}
테스트가 끝났을때마다 저장소를 초기화하지 않는다.
스프링부트에서 제공하는 @Transactional을 이용하여 트랜잭션단위의 처리를 항상 롤백한다.
4. 스프링 JdbcTemplate
🔸 데이터베이스 엑세스 라이브러리( ex. 스프링의 JdbcTemplate, MyBatis 등 ) : JDBC API에서 본 반복 코드를 대부분 제거하여 축약된 형태로 사용할 수 있게 한다. 하지만 여전히 SQL 쿼리를 개발자가 직접 작성해야하고 이에따라 세부사항을 제어할 수도 있다는 장점을 가진다.
이와 달리 ORM 프레임워크는 SQL을 간접적으로 작성하여 복잡성을 숨기고 객체지향적인 방식으로 데이터베이스와 상호작용할 수 있다.
🍃 JdbcTemplate
JdbcTemplate객체(org.springframework.jdbc.core.JdbcTemplate)를 이용한다.
자동으로 주입받을 수 없으며 자동주입받은 datasource객체를 통해 생성한다. ( 의존성주입(DI) : 생성자가 하나인 경우 autowired를 생략가능 )
public class JdbcTemplateMemberRepository implements MemberRepository{
private final JdbcTemplate jdbcTemplate;
@Autowired //생략가능
public JdbcTemplateMemberRepository(DataSource dataSource) {
jdbcTemplate = new JdbcTemplate(dataSource);
}
...
}
🍃 RowMapper 반환 메서드
RowMapper객체(org.springframework.jdbc.core.RowMapper) : 데이터베이스에서 조회한 결과(ResultSet)의 각 행을 사용자가 정의한 클래스의 인스턴스로 매핑하는 인터페이스
제너릭 타입으로 반환할 객체타입을 표기한다. 인터페이스 내부에는 단하나의 메서드 mapRow가 존재하며 SQL실행결과의 ResultSet객체와 행번호 rownum을 받는다. ResultSet에서 각 행을 하나씩 처리하도록 반복호출되어 각 행의 데이터를 매핑한 객체를 반환한다.
public class JdbcTemplateMemberRepository implements MemberRepository{
private final JdbcTemplate jdbcTemplate;
public JdbcTemplateMemberRepository(DataSource dataSource) {
jdbcTemplate = new JdbcTemplate(dataSource);
}
private RowMapper<Member> memberRowMapper(){
return new RowMapper<Member>() {
@Override
public Member mapRow(ResultSet rs, int rowNum) throws SQLException {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
}
}
};
/* 람다변환
private RowMapper<Member> memberRowMapper(){
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
*/
...
}
🍃JdbcTemplate을 이용한 코딩
🔸 조회작업 : jdbcTemplate.query()
query(String sql, RowMapper<T> rowMapper, Object... args) : SQL 쿼리에 가변인자를 전달하여 실행하고, RowMapper로 결과를 매핑
🔸삽입작업 : SimpleJdbcInsert
jdbcTemplate객체를 통해 SimpleJdbcInsert객체를 생성하고 테이블 이름, 컬럼 이름, 키 값을 지정
Map또는 MapSqlParameterSource형태로 데이터를 매핑하여 execute()로 수행한다.
public class JdbcTemplateMemberRepository implements MemberRepository{
private final JdbcTemplate jdbcTemplate;
public JdbcTemplateMemberRepository(DataSource dataSource) {
jdbcTemplate = new JdbcTemplate(dataSource);
}
private RowMapper<Member> memberRowMapper(){
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
@Override
public Member save(Member member) {
SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
jdbcInsert.withTableName("member").usingGeneratedKeyColumns("id");
Map<String, Object> parameters = new HashMap<>();
parameters.put("name", member.getName());
Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
member.setId(key.longValue());
return member;
}
@Override
public Optional<Member> findById(Long id) {
List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
return result.stream().findAny();
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper(), name);
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
return jdbcTemplate.query("select * from member", memberRowMapper());
}
}
5. JPA
기존의 반복 코드는 물론이고, 기본적인 SQL도 JPA가 직접 만들어서 실행할 수 있다.
SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임을 전환을 할 수 있다.
JPA는 객체-관계 매핑(ORM)을 위한 표준 인터페이스이고 구현체로 Hibernate, EclipseLink, OpenJPA 등 여러 벤더 기술들이 제공된다. ( 보통 jpa-hibernate로 이용 )
🍃 환경설정
🔸라이브러리 추가 ( spring-boot-starter-data-jpa )
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
//implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
JPA 라이브러리를 추가한다. spring-boot-starter-data-jpa 는 내부에 jdbc 관련 라이브러리를 포함한다. 따라서 jdbc는 제거해도 된다.
🔸스프링부트 환경설정( application.properties ) 추가( JPA 관련 환경설정 )
- show-sql 옵션 : JPA가 생성하는 SQL을 출력한다.( true/false )
- ddl-auto 옵션 : JPA가 테이블을 자동으로 생성하는 기능을 이용한다.( none/cretae )
-> create 를 사용하면 엔티티 정보를 바탕으로 테이블도 직접 생성해준다.
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
🍃 JPA 엔티티 매핑
JPA의 핵심은 ORM(Object-Relational Mapping)기술로 객체(domain)와 관계형데이터베이스의 테이블을 매핑한다.
- @Entity : JPA가 관리하는 객체임을 표기
- @Id : primary key의 역할을 할 객체의 필드를 지정
- @GeneratedValue : 기본키값을 자동으로 생성하는 옵션 - AUTO(기본값), IDENTITY(DB의 자동증가), SEQUENCE(시퀀스객체 활용), TABLE(별도의 테이블로 키관리)
- @Column(name="컬럼명") : 객체의 필드에 테이블의 컬럼을 명시적으로 매핑
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
🍃 JpaRepository
🔸EntityManager
: JPA의 핵심 인터페이스로 CRUD 작업(Create, Read, Update, Delete) 을 포함한 데이터베이스와 상호작용을 수행하고 엔티티의 생명 주기를 제어한다.
public class JPAMemberRepository implements MemberRepository{
private final EntityManager em;
public JPAMemberRepository(EntityManager em) {
this.em = em;
}
...
}
EntityManagerFactory를 통해 생성된 EntityManager가 스프링 컨텍스트에 빈으로 등록되고, 필요한 곳에 주입받을 수 있다. ( @PersistenceContext, @Autowired 등으로 명시하거나 생략한다. )
스프링 컨텍스트에서 관리하는 EntityManager는 애플리케이션의 설정정보를 반영하고 있고 등록된 Datasource를 활용하여 DB와 통신한다.
스프링 컨텍스트는 스프링 컨테이너의 구현체(동작방식)으로 스프링 빈을 관리하고 의존성주입, 애플리케이션 설정, 이벤트 처리 등을 지원한다.
🔸Entity Manager의 다양한 동작
- persist : 새로운 Entity를 데이터베이스에 저장한다.
엔티티는 영속상태로 전환( 영속성 컨텍스트에서 관리 ) -> 트랜잭션 커밋 시 데이터베이스에 실제로 저장된다. - find : 기본 키(식별자)를 사용하여 데이베이스에서 특정 Entity를 조회한다. ( 기본키가 아닌 식별자로 검색하고자 한다면 JPQL을 직접 작성 )
Entity가 영속성 컨텍스트에 존재한다면 데이터베이스에 쿼리를 수행하지 않고 영속성 컨텍스트에서 반환하고 그렇지 않으면 데이터베이스에서 조회하여 반환한다. - merge : 수정된 Entity를 데이터베이스에 반영한다.
비영속 상태(detached 상태)의 엔티티를 영속성 컨텍스트에 병합하고 데이터베이스에 반영한다. 영속성 컨텍스트에 새로운 Entity 인스턴스를 반환한다. 일반적으로 JPA에서는 merge 메서드를 직접 사용하는 경우가 드물고, 대부분의 경우 영속성 컨텍스트를 통해 엔티티의 상태를 자동으로 관리한다. - remove : 데이터베이스의 Entity를 삭제한다.
엔티티를 영속성 컨텍스트에서 제거하고 데이터베이스에서 삭제한다. 영속성 컨텍스트에 존재하지 않으면 아무런 작업을 수행하지 않는다. - createQuery : 직접 쿼리를 작성한다. JPQL(객체지향 쿼리)를 사용한다.
JPQL : 엔티티 객체를 대상으로 쿼리를 작성하면 매핑된 테이블로 번역되어 수행된다. 쿼리결과는 엔티티객체로 자동매핑되고 파라미터 바인딩(:)이 가능하다. 단일결과는 getSingleResult으로, 다중결과목록은 getResultList로 반환받는다.
@Transactional : JPA의 모든 데이터의 변경은 트랜잭션 내에서 이루어져야 한다.
@Transactional
public class JPAMemberRepository implements MemberRepository{
private final EntityManager em;
public JPAMemberRepository(EntityManager em) {
this.em = em;
}
@Override
public Member save(Member member) {
em.persist(member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
Member member = em.find(Member.class, id);
return Optional.ofNullable(member);
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = em.createQuery("select m from Member m where m.name = :name", Member.class)
.setParameter("name", name)
.getResultList();
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class).getResultList();
}
}
다양한 리포지토리의 구현체로 구현하기
@Configuration
public class SpringConfig {
// private DataSource dataSource;
// @Autowired
// public SpringConfig(DataSource dataSource) {
// this.dataSource = dataSource;
// }
private EntityManager em;
@Autowired
public SpringConfig(EntityManager em) {
this.em = em;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
// return new MemoryMemberRepository();
// return new JdbcMemberRepository(dataSource);
// return new JdbcTemplateMemberRepository(dataSource);
return new JPAMemberRepository(em);
}
}
6. 스프링 데이터 JPA
기본적으로 JpaRepository 인터페이스(org.springframework.data.jpa.repository.JpaRepository)를 확장하는 인터페이스를 만들어 놓으면 스프링JPA가 proxy기술을 통해 그 구현체를 만들어 repository bean에 등록하는 방식으로 진행된다.
@Configuration
public class SpringConfig {
private final MemberRepository memberRepository;
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository);
}
}
이때 리포지토리가 다룰 엔티티 도메인 클래스(Entity)와 해당 엔티티의 기본키타입(PK)을 제너릭으로 작성해야한다.
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
// 추가정의 생략
}
JpaRepository 인터페이스는 기본적인 메서드들을 제공한다. ( findAll, save, flush 등.. )
기본 CRUD기능 단순조회 및 페이징처리 기능까지 모두 제공된다.
이렇게 공통화된 처리는 모두 제공되지만 공통화할 수 없는 것, 비즈니스의 고유한 동( ex. 특정 필드를 이용하여 검색, 수정 등 )은 직접 작성해야한다. 이때 이름 규칙에 맞게 메서드이름을 작성하면 적절한 쿼리를 직접 구현해준다. 즉 인터페이스 이름만 제시하여 복잡한 비즈니스 로직의 개발이 가능하다. ( findByName, findByEmail, findByNameAndId 등.. )
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
@Override
Optional<Member> findByName(String name);
}
'web programming' 카테고리의 다른 글
[ 스프링 입문 ] 웹 MVC (0) | 2024.09.02 |
---|---|
[ 스프링 입문 ] 스프링 빈과 의존관계 (0) | 2024.09.02 |
[ 스프링 입문 ] 회원관리 예제 (1) | 2024.09.02 |
[ 스프링 입문 ] 프로젝트 환경설정, 스프링 웹개발 기초 (1) | 2024.08.24 |
JPA(Java Persistence API) 기본개념 (0) | 2024.03.10 |