1. member 테이블 생성
CREATE DATABASE test;
USE test;
test 데이터베이스를 생성하고 선택한다.
create table member
(
id bigint auto_increment,
name varchar(255),
primary key (id)
);
member 테이블을 생성한다.
컬럼은 id, name 2개의 컬럼을 생성한다.
id는 bigint 타입을 사용하여, 아주 큰 정수를 저장하기 위해서이다.
name은 varchar 타입을 사용한다.
id를 고유키로 설정한다.
2. 순수 Jdbc
package hello.hellospring.repository;
import hello.hellospring.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;
}
@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 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);
}
}
@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);
}
}
private Connection getConnection() {
return DataSourceUtils.getConnection(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();
}
}
private void close(Connection conn) throws SQLException {
DataSourceUtils.releaseConnection(conn, dataSource);
}
}
이 코드는 순수 Jdbc이다.
Jdbc는 Java Database Connectivity로
Java에서 데이터베이스와의 연결을 하고 SQL을 실행하기 위해 필요한 API이자 드라이버이다.
집중적으로 봐야할 포인트는 DB와의 connection, preparedstatement의 ?파라미터, close 등이 있다.
DataSoource는 데이터베이스와 연결할 수 있는 Connection을 만들어내는 공장이다.
스프링 부트가 application.properties에 적어둔 주소와 패스워드를 보고
이 공장을 알아서 만든 뒤, JdbcMemberRepository 클래스에 주입한다.
insert into member(name) values(?) 구문을 사용한다.
파라미터 바인딩을 통해 이름을 문자열 더하기로 넣지 않고 ?로 비워둔다.
나중에 pstmt.setString(1, member.getName())으로 이 빈칸을 채우는데,
이러한 방식을 통해 SQL 인젝션 공격을 차단한다.
RETURN_GENERATED_KEYS를 통해
DB에 insert를 날릴 때 만든 id번호를 다시 돌려주게 된다.
executeUpdate()로 DB에 실제 쿼리를 날린다.
그리고 getGeneratedKeys()로 방금 생성된 키(id(가 담긴 ResultSet을 받아오고,
rs.next()로 값을 꺼내서 자바 객체(member)에 세팅한다.
rs.next()로 다음 줄에 데이터가 있으면, 그 줄로 이동한다.
rs.getString("name")과 rs.getLong("id")를 통해 id컬럼의 숫자와 name컬럼의 글자를 가져와
new Member() 객체에 담는다.
마지막으로 Optional로 감싸 member을 반환한다.
다중 데이터 조회는 rs.next를 계속 밑으로 내려가면서 한줄 씩 읽고,
읽을 때마다 Member 객체를 새로 만들어 members라는 리스트에 계속 담아준다.
최종적으로 members를 반환한다.
Connection을 가져올 때 dataSource.getConnection()이 아닌,
DataSourceUtils라는 스프링 전용 도구를 사용하는데,
이는 나중에 트랜잭션을 유지하기 위해 쓰는 중요한 도구라고 한다.
package hello.hello_spring;
import hello.hello_spring.repository.JdbcMemberRepository;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;
import hello.hello_spring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {
private DataSource dataSource;
@Autowired
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new JdbcMemberRepository(dataSource);
}
}
이제 스프링 환경설정을 수정해야한다.
@Autowired를 통해 만든 DataSource를 주입해달라고 요청한다.
@Bean을 통해 객체를 스프링 컨테이너에 등록한다.
memberRepository(): 아까 주입받은 dataSource(DB 연결선)를 JdbcMemberRepository에 넣어 생성한 뒤, 스프링에 등록한다.
memberService(): MemberService를 만들때, 방금 만든 memberRepository()를 통째로 넣는다. 즉, 의존성 주입을 한다.
서비스가 저장소에 의존하고 있는데 그걸 외부(SpringConfig)에서 주입해준 것이다.

위쪽 그림은 애플리케이션의 정적인 클래스 설계도를 보여준다.
MemberRepository (인터페이스): 데이터베이스 저장소의 '역할(표준 규격)'만 정의해 둔 껍데기이다. "회원을 저장하고, ID로 찾을 수 있어야 한다"는 규칙만 명시되어 있다.
MemoryMemberRepository / JdbcMemberRepository: 그 규칙을 실제로 어떻게 동작시킬지 구현한 실제 객체들이다. 각각 메모리와 실제 DB에 데이터를 저장하는 역할을 수행한다.
의존 관계: 설계도를 보면 MemberService는 구현체(Memory, Jdbc)가 아닌 오직 interface만을 바라보고 있다. 즉, MemberService는 데이터가 어디에 저장되든 상관없이 MemberRepository 규칙을 따르는 구현체라면 무엇이든 주입받아 명령을 수행하도록 설계된 것이다.
아래쪽 그림은 애플리케이션이 실행될 때 스프링 컨테이너 안에서 실제로 객체들이 어떻게 연결되는지를 보여준다.
스프링 컨테이너: 앞서 @Configuration과 @Bean을 작성했던 SpringConfig가 만들어낸 가상의 공간으로, 이 안에서 실제로 실행되는 객체(Bean)들이 관리된다.
의존성 교체: 기존에는 memberService가 메모리 기반의 <memory> memberRepository와 연결되어 있었다. 하지만 SpringConfig에서 코드를 한 줄 수정함에 따라 메모리 저장소와의 연결(빨간 X 표시)이 끊어지고, 새로 등록한 <jdbc> memberRepository로 연결이 완벽하게 교체된 것이다.
이 두 가지 원리가 합쳐져 객체지향 설계의 핵심인 개방-폐쇄 원칙(OCP)이 완성된다.
만약 MemberService가 인터페이스가 아닌 MemoryMemberRepository를 직접 의존(참조)하고 있었다면, 데이터베이스를 MySQL로 변경할 때 MemberService 내부의 로직도 전부 수정해야 했을 것이다.
하지만 인터페이스를 의존하도록 설계해 두고, 외부의 설정 파일(SpringConfig)에서 의존성 주입(DI)을 통해 부품을 교체했다. 그 결과, 기존의 핵심 비즈니스 로직(MemberService)은 단 한 줄도 수정하지 않으면서도(Closed), 애플리케이션의 저장소를 메모리에서 실제 데이터베이스로 완벽하게 확장(Open)할 수 있었다. 이는 향후 JPA, MyBatis, Redis 등 어떠한 기술을 도입하더라도 변하지 않는 백엔드 설계의 핵심 원칙이다.


잘 추가된 것을 볼 수 있다.
3. 스프링 통합 테스트
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
@Test
public void 회원가입() throws Exception {
//Given
Member member = new Member();
member.setName("hello");
//When
Long saveId = memberService.join(member);
//Then
Member findMember = memberRepository.findById(saveId).get();
assertEquals(member.getName(), findMember.getName());
}
@Test
public void 중복_회원_예외() throws Exception {
//Given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//When
memberService.join(member1);
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> memberService.join(member2)); //예외가 발생해야 한다.
//Then
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
}
지금까지는 자바 객체만을 띄워서 검증하는 단위 테스트(Unit Test)를 진행했다.
하지만 실제 애플리케이션은 데이터베이스와 스프링 컨테이너 위에서 동작한다.
따라서 이번에는 스프링 컨테이너와 DB까지 모두 연결하여 실제 운영 환경과 동일하게 동작하는지 확인하는 '통합 테스트'를 작성해 본다.
이 테스트 코드에서 가장 핵심적으로 살펴보아야 할 부분은 클래스 상단에 붙은 두 개의 어노테이션이다.
@SpringBootTest: 진짜 스프링 애플리케이션을 띄워서 테스트를 진행한다는 의미이다.
이 어노테이션이 붙으면 테스트를 실행할 때 스프링 컨테이너가 생성되며,
@Autowired를 통해 스프링이 관리하는 빈(MemberService, MemberRepository)을
테스트 코드에 직접 주입받아 사용할 수 있다.
@Transactional: 데이터베이스와 연동하는 테스트에서 가장 중요한 마법 같은 어노테이션이다. 테스트 케이스에 이 어노테이션을 붙이면, 테스트 시작 전에 데이터베이스 트랜잭션을 시작하고 테스트 로직이 모두 끝난 후에 쿼리를 강제로 롤백(Rollback) 해버린다
즉, 테스트 중에 INSERT 쿼리로 DB에 데이터를 넣더라도 테스트가 끝나면 흔적도 없이 사라진다. 덕분에 DB에 쓰레기 데이터가 남지 않아 다음 테스트에 영향을 주지 않고 무한히 반복 테스트가 가능하다.
테스트 로직은 Given(준비) - When(실행) - Then(검증) 패턴으로 직관적으로 구성되어 있다.
1. 회원가입() 테스트
새로운 Member 객체를 만들고(Given), memberService.join()을 호출해 실제 DB에 저장한다(When).
그 후 반환된 id값으로 DB에서 다시 회원을 조회하여 처음에 넣은 이름과 일치하는지 검증한다(Then).
2. 중복_회원_예외() 테스트
똑같은 "spring"이라는 이름을 가진 회원 2명을 만든다(Given).
첫 번째 회원을 DB에 저장한 뒤, 의도적으로 두 번째 똑같은 회원을 저장하려고 시도한다(When).
이때 비즈니스 로직에 의해 IllegalStateException 예외가 정상적으로 터지는지 assertThrows로 검증한다(Then).
가장 이상적인 테스트는 스프링을 띄우지 않고 자바 코드만으로 가볍게 검증하는 단위 테스트(Unit Test)이다. 하지만 실제 DB 쿼리가 잘 날아가는지, 설정 파일(SpringConfig)이 잘 조립되었는지 전체적인 흐름을 파악하기 위해서는 이와 같은 통합 테스트 역시 반드시 짚고 넘어가야 하는 중요한 과정이다.
4. 스프링 JdbcTemplate
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class JdbcTemplateMemberRepository implements MemberRepository {
private final JdbcTemplate jdbcTemplate;
// DataSource를 주입받아 JdbcTemplate 객체를 생성한다.
public JdbcTemplateMemberRepository(DataSource dataSource) {
jdbcTemplate = new JdbcTemplate(dataSource);
}
@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 List<Member> findAll() {
return jdbcTemplate.query("select * from member", memberRowMapper());
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper(), name);
return result.stream().findAny();
}
// 결과를 자바 객체로 매핑해주는 RowMapper
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
}
기존의 순수 JDBC 코드를 JdbcTemplate으로 변경한 JdbcTemplateMemberRepository 클래스를 작성한다. 코드가 얼마나 극적으로 짧아졌는지 확인할 수 있다.
JdbcTemplate 생성: 스프링으로부터 DataSource를 주입받아 JdbcTemplate을 초기화한다. 스프링의 권장 스타일에 따라 생성자가 1개일 경우 @Autowired는 생략 가능하다.
memberRowMapper(): 순수 JDBC에서 while(rs.next())를 돌며 데이터를 매핑했던 노가다를 대신해 주는 기능이다. DB에서 조회된 결과(ResultSet)를 람다식을 이용해 Member 객체로 깔끔하게 조립해 준다.
save() 메서드(SimpleJdbcInsert): insert into... 구문조차 작성할 필요가 없다. SimpleJdbcInsert에 테이블명(member)과 PK 컬럼명(id)만 넘겨주면, 스프링이 쿼리를 알아서 짜서 DB에 밀어 넣고 생성된 ID 키를 반환해 준다.
조회 로직의 압축: findById나 findAll을 보면 코드가 단 두 줄로 끝난다. jdbcTemplate.query()에 SQL문과 매퍼, 그리고 파라미터만 던져주면 내부적으로 커넥션을 열고 닫는 모든 과정을 스프링이 알아서 처리한다.
package hello.hellospring;
import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.JdbcTemplateMemberRepository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {
private final DataSource dataSource;
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
// return new MemoryMemberRepository();
// return new JdbcMemberRepository(dataSource);
return new JdbcTemplateMemberRepository(dataSource);
}
}
순수 JDBC 시절과 마찬가지로 MemberService 로직은 전혀 건드리지 않았다. 그저 @Bean에 등록하는 구현체를 new JdbcTemplateMemberRepository(dataSource)로 갈아 끼우기만 했다.
반복되고 길었던 순수 JDBC 코드가 JdbcTemplate 덕분에 핵심 비즈니스 로직만 남고 획기적으로 줄어들었다. 개발자는 자원 반납의 압박에서 벗어나,
어떤 SQL을 날리고 어떻게 매핑할 것인지에만 집중할 수 있게 되었다.
5. 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.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
}
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
먼저 build.gradle에 관련 라이브러리를 추가하고,
스프링 부트에 JPA 관련 설정을 추가한다.
show-sql=true: JPA가 내부적으로 만들어서 날리는 SQL 쿼리를 콘솔창에서 볼 수 있게 해주는 유용한 기능이다.
ddl-auto: JPA가 자바 객체를 보고 DB의 테이블을 알아서 생성(create)할지 묻는 옵션이다. 우리는 이미 워크벤치에서 테이블을 만들었으므로 none으로 설정해 자동 생성 기능을 끈다.
package hello.hellospring.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@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;
}
}
다음으로 데이터베이스 테이블과 자바 객체(Member)를 매핑시키는 작업을 한다.
@Entity: JPA가 관리하는 객체라는 것을 선언하는 어노테이션이다. (스프링 부트 3.0 이상부터는 javax 대신 jakarta 패키지를 임포트해야 한다.)
@Id: DB 테이블의 기본 키(Primary Key)인 id와 매핑됨을 의미한다.
@GeneratedValue(strategy = GenerationType.IDENTITY): 우리가 DB에서 auto_increment를 사용해 DB가 알아서 ID 번호를 생성하게 한 것을 JPA에게 알려주는 문법이다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.Optional;
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 List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
@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();
}
}
다음으로 순수 JPA를 활용하여 회원 레포지토리를 구현해본다.
EntityManager (em): JPA의 심장이다. 스프링 부트가 application.properties와 라이브러리를 보고 알아서 EntityManager를 생성해서 컨테이너에 올려주며,
우리는 이것을 주입받아 쓰기만 하면 된다. 이 객체가 내부적으로 DB와 통신하고 객체를 관리한다.
em.persist(member): 저장 쿼리를 작성할 필요가 없다. "JPA야, 이 객체를 영구적으로 저장해 줘"라고 persist 메서드를 호출하면 JPA가 알아서 INSERT 쿼리를 만들어 DB에 쏜다.
심지어 생성된 id까지 객체에 알아서 세팅해 준다.
em.find(Member.class, id): SELECT 쿼리도 필요 없다.
찾고 싶은 객체 타입과 PK(id) 값만 주면
JPA가 조회 쿼리를 만들어 데이터를 찾고 자바 객체로 반환해 준다.
package hello.hello_spring.service;
import hello.hello_spring.domain.Member;
import hello.hello_spring.repository.MemberRepository;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Optional;
@Transactional
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
/**
*
회원가입
*/
public Long join(Member member) {
validateDuplicateMember(member); //중복 회원 검증
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
/**
*
전체 회원 조회
*/
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
단, JPA를 사용할 때 데이터를 변경(저장, 수정, 삭제)하는 모든 작업은 반드시 트랜잭션 안에서 이루어져야 하므로 서비스 계층(MemberService)에 @Transactional 어노테이션을 반드시 추가해야 한다.
package hello.hello_spring;
//import hello.hello_spring.repository.JdbcMemberRepository;
//import hello.hello_spring.repository.JdbcTemplateMembereRepository;
import hello.hello_spring.repository.JpaMemberRepository;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.repository.MemoryMemberRepository;
import hello.hello_spring.service.MemberService;
import jakarta.persistence.EntityManager;
import org.hibernate.metamodel.mapping.EntityIdentifierMapping;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {
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 JpaMemberRepository(em);
}
}
Jpa를 사용하므로 springconfig 파일도 다음과 같이 수정한다.
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Transactional
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public Long join(Member member) {
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
또한, JPA를 사용할 떄는 데이터를 변경하는 계층(Service)에 반드시 트랜잭션이 있어야 한다.

잘 실행된 것을 볼 수 있다.
6. 스프링 데이터 JPA
JPA만 써도 놀라운데, 이를 한 번 더 감싸서 개발자를 극한으로 편하게 만들어주는 기술이 바로 '스프링 데이터 JPA'이다.
실무에서 관계형 데이터베이스를 쓴다면 선택이 아닌 필수 기술이다.
스프링 데이터 JPA를 사용하면 클래스(Class)를 만들 필요도 없이,
인터페이스(Interface)만 만들어도 개발이 끝난다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
@Override
Optional<Member> findByName(String name);
}
JpaRepository<T, ID>를 상속받는 것만으로도 마법이 시작된다.
스프링 데이터 JPA가 인터페이스를 스캔하여 내부적으로 구현체(클래스)를 스스로 만들어내어
스프링 빈으로 자동 등록해버린다.
우리는 그저 주입받아서 쓰기만 하면 된다. * save, findById, findAll, delete와 같은 기본적인 CRUD 기능은
부모 인터페이스에 이미 다 만들어져 있어서 그냥 가져다 쓰면 된다.
가장 소름 돋는 기능은 findByName 같은 메서드이다.
규칙에 맞게 메서드 이름만 findBy + Name으로 적어주면,
스프링 데이터 JPA가 이름을 분석해서
select m from Member m where m.name = ? 이라는 쿼리를 알아서 짜준다
findByEmailAndName 등 다양한 조합이 가능하다고 한다.
package hello.hellospring;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SpringConfig {
private final MemberRepository memberRepository;
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository);
}
}
위에서와 마찬가지로
이번에는 스프링 데이터 JPA를 사용하므로 springconfig 파일을
다음과 같이 수정해준다.
지금까지 회원 저장소라는 하나의 기능을 만들기 위해 진화해 온 과정을 살펴보았다.
- Memory: 재시작하면 데이터가 다 날아가는 원시 시대.
- 순수 JDBC: 수십 줄의 쿼리와 통신 코드를 직접 짰던 고대 시대.
- JdbcTemplate: 반복 코드는 줄었지만 여전히 SQL은 직접 써야 하는 중세 시대.
- JPA: SQL을 시스템이 짜주고 객체 중심으로 생각할 수 있게 된 근대 시대.
- 스프링 데이터 JPA: 구현체마저 프레임워크가 찍어내는 현대 시대.
실무에서는 JPA와 스프링 데이터 JPA를 기본 베이스로 깔고 간다. 여
기에 이름으로만 쿼리를 짜기엔 너무 복잡한 동적 쿼리(조건이 계속 바뀌는 검색 쿼리 등)가 필요한 경우 'Querydsl'이라는 라이브러리를 섞어 쓴다.
만약 이 조합으로도 감당이 안 되는 극한의 복잡한 통계 쿼리가 필요하다면,
그때는 우리가 배웠던 JdbcTemplate이나 네이티브 쿼리(생 SQL)를
부분적으로 꺼내 쓰는 것이 현대 백엔드 개발의 정석적인 아키텍처이다.
📊 스프링 데이터 접근 기술 총정리 및 비교
| 비교 항목 | 1. Memory 방식 | 2. 순수 JDBC | 3. 스프링 JdbcTemplate | 4. JPA | 5. 스프링 데이터 JPA |
| 데이터 저장소 | JVM 메모리 (HashMap 등) | RDBMS (MySQL, H2 등) | RDBMS | RDBMS | RDBMS |
| SQL 작성 여부 | 필요 없음 | 직접 작성 필수 | 직접 작성 필수 | 작성 불필요 (자동 생성) | 작성 불필요 (메서드 이름 분석) |
| 개발 패러다임 | 임시 저장용 | SQL, 데이터 중심 | SQL, 데이터 중심 | 객체(Object) 중심 | 객체(Object) 중심 |
| 반복/보일러플레이트 코드 | 없음 | 매우 많음 (커넥션, 예외처리, 자원반납) |
거의 없음 (스프링이 자원 관리) |
없음 (EntityManager가 관리) |
아예 없음 (인터페이스만 작성) |
| 주요 특징 및 동작 방식 | 자바의 Map, List 등을 사용하여 데이터를 임시로 메모리에 보관한다. | DB 드라이버를 직접 호출하여 통신한다. Connection 획득부터 close()까지 개발자가 100% 수동으로 제어한다. | 순수 JDBC의 반복 코드를 템플릿 콜백 패턴으로 제거했다. 쿼리 작성과 결과 매핑(RowMapper)에만 집중할 수 있다. | 객체와 테이블을 매핑(ORM)한다. EntityManager에 객체를 넘기면 JPA가 쿼리를 짜서 DB에 대신 날려준다. | JPA를 더 쉽게 쓰기 위한 프레임워크. 인터페이스 상속만으로 스프링이 프록시 구현체를 스스로 만들어낸다. |
| 치명적인 단점 | 서버를 껐다 켜면 데이터가 100% 증발한다. | 코드가 너무 길고, 자원 반납(close)을 실수로 누락하면 서버가 뻗는 대참사가 발생한다. | 자원 관리는 편해졌지만, 여전히 쿼리를 문자열로 직접 쳐야 하므로 오타나 스키마 변경 시 컴파일 에러를 잡기 힘들다. | 설정이 다소 복잡하고, 러닝 커브가 높다. 기본적인 CRUD 기능도 개발자가 메서드를 일일이 만들어야 한다. | 기반 기술인 JPA를 모르면 절대 쓸 수 없다. 쿼리가 내부에서 어떻게 도는지 모르면 심각한 성능 장애를 유발할 수 있다. |
| 생산성 | 빠름 (DB 세팅 불필요) | 최악 | 보통 | 높음 | 궁극의 생산성 |
| 실무 도입 여부 | ❌ 사용 안 함 (초기 테스트/목업용) |
❌ 사용 안 함 (20년 전 레거시) |
🔺 부분적 사용 (복잡한 통계용 생쿼리 필요시) |
⭕ 기본 베이스 | 🚀 실무 필수 표준 |
'백엔드' 카테고리의 다른 글
| Spring 입문 #9 - 트랜잭션(Tranjaction)이란? (0) | 2026.06.01 |
|---|---|
| Spring 입문 #7 - mySQL (2) (0) | 2026.05.09 |
| Spring 입문 #6 - mySQL (1) (0) | 2026.05.09 |
| Spring 입문 #5 - 회원 관리 예제: 웹 MVC 개발 (0) | 2026.05.09 |
| Spring 입문 #4 - 스프링 빈과 의존관계 (0) | 2026.05.04 |
