Development/Spring

[Spring] 스프링 입문 - 강의 정리 6

kangkyunghyun 2026. 2. 8. 14:56
728x90
반응형

본 게시물은 김영한님의 “스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술” 강의를 바탕으로 작성했습니다.

스프링 DB 접근 기술

H2 데이터베이스 설치

H2 데이터베이스 설치 및 실행

H2 데이터베이스를 설치해보겠습니다. https://h2database.com/html/main.html에 접속해서 최신 버전을 All Platforms로 다운로드합니다.

압축을 풀고 Terminal을 켜서 다음 명령어를 순서대로 입력합니다. 저는 Downloads 폴더에서 압축을 풀었습니다.

cd downloads
cd h2
cd bin
ls -arlth
chmod 755 h2.sh
./h2.sh

ls -arlth 명령어를 입력하면 모든 파일들을 볼 수 있습니다. h2.sh라는 파일이 있는데 MacOS 기준으로 실행 전에 권한을 줘야합니다. chmod 755 h2.sh가 해당 명령어입니다. ./h2.sh를 입력하면 데이터베이스가 실행되고 잠시 후 웹 페이지가 뜹니다.

사용자명과 비밀번호는 만지지 않고 연결을 누릅니다.

이런 페이지가 뜹니다. 이제 Terminal에서 ls -arlth를 입력해서 test.mv.db 파일이 있는지 확인합니다.

DB가 잘 생성되었음을 볼 수 있습니다.

이제 좌측 상단의 빨간 버튼을 눌러 접속을 종료합니다.

방금은 접속 URL을 jdbc:h2:~/test로 파일에 직접 접근했습니다. 이 방법은 나중에 여러 곳에서 접근할 시 충돌이 일어날 수 있으므로 소켓을 통해 접근하도록 jdbc:h2:tcp://localhost/~/test로 변경합니다.

연결을 해봅니다.

잘 됩니다.

오류가 발생할 때

  1. H2 DB를 종료합니다. 터미널에서 실행중에 ⌃C로 종료할 수 있습니다.
  2. 아까 확인했던 test.mv.db 파일이 있다면 제거합니다. rm test.mv.db로 제거할 수 있습니다. 파일이 없었으면 상관없습니다.
  3. H2 DB를 실행하고 jdbc:h2~/test 링크로 접속합니다. 최초 1회는 반드시 이 링크로 접속해야 test.mv.db가 생성됩니다. 소켓으로 접속하려고 하면 test.mv.db가 존재하지 않는다고 뜹니다.
  4. 정상 작동하면 이후부턴 jdbc:h2:tcp://localhost/~/test로 접속합니다.

테이블 생성하고 조회하기

테이블을 생성하고 값을 넣어보겠습니다.

위 사진처럼 SQL 문을 작성하여 실행(⌘Enter)합니다.

drop table if exists member CASCADE; // member 테이블이 이미 있으면 제거합니다.
create table member // member 테이블을 생성합니다.
(
id bigint generated by default as identity, // id값으로 null이 들어오면 자동으로 값을 채웁니다.
name varchar(255), // name 가변 길이 문자열입니다.
primary key (id) // pk를 id로 설정합니다.
);

SQL 문을 실행하면 입력된 내용과 결과와 함께 MEMBER 테이블이 생성된 것을 볼 수 있습니다.

SQL 문을 빈칸으로 만들고 좌측의 테이블을 클릭하면 SELECT 문이 생깁니다. 실행시킵니다.

데이터를 입력하지 않아서 아무 값도 아직 없습니다. INSERT 문으로 값을 채워보겠습니다.

INSERT INTO MEMBER(NAME) VALUES('spring')

다시 SELECT 문으로 전체 값을 조회해보겠습니다.

NAME으로 입력했던 **‘spring’**이 들어갔고 ID는 자동으로 1이 채워진 것을 볼 수 있습니다.

SQL 문 관리

테이블을 생성하는 코드를 IntelliJ에서 관리하기 위해 새로운 폴더를 만들겠습니다. 스프링 부트 프로젝트의 최상위(src와 같은 계층)에 sql 폴더를 만들고 ddl.sql을 생성하여 아래 구문을 작성합니다.

drop table if exists member CASCADE;
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);

순수 JDBC

이전에 만들었던 회원 예제에서 Memory에 저장하던 방식을 DB에 저장하도록 할 것입니다.

순수 JDBC 환경설정

build.gradledependenceisjdbc, h2 관련 라이브러리를 추가합니다.

dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	runtimeOnly 'com.h2database:h2'
	...
}

application.properties 파일에 스프링 부트 데이터베이스 연결 설정을 추가합니다.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa

스프링 부트 2.4 이상 버전에서는 spring.datasource.username=sa를 꼭 추가해야 합니다. 그렇지 않으면 Wrong user name or password 오류가 발생합니다.

JDBC 리포지토리 구현

JDBC API로 직접 코딩하는 것은 20년 전에나 하던 것이므로 참고만 합니다. JdbcMemberRepository를 생성하고 SpringConfig를 수정합니다.

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;
    }

    @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);
    }
}
@Configuration
public class SpringConfig {
		...
		private DataSource dataSource;

    @Autowired
    public SpringConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    @Bean
    public MemberRepository memberRepository() {
//        return new MemoryMemberRepository();
        return new JdbcMemberRepository(dataSource);
    }
}

SpringConfig에서 MemberRepository의 구현체를 MemoryMemberRepository에서 JdbcMemberRepository로 변경합니다.

구현 클래스 이미지

memberService가 의존하는는 memberRepository의 구현체가 memory 버전에서 jdbc 버전으로 변경되었고 새로운 스프링 빈으로 등록합니다.

**개방-폐쇄 원칙(OCP, Open-Closed Principle)**이 지켜졌습니다. 이는 확장에는 열려있고, 수정과 변경에는 닫혀있다는 뜻입니다. 조립하는 코드는 수정해야 하지만, 스프링의 **DI(Dependencies Injection)**을 사용하면 기존 코드를 변경하지 않고, 설정만으로 구현 클래스를 변경할 수 있습니다.

실행 및 결과

h2 데이터베이스를 실행합니다. build.gradle을 수정했으니 한 번 Sync 해주고 스프링 부트도 실행합니다.

http://localhost:8080/members 에 접속하면 이전에 추가했던 h2 데이터베이스의 값들이 보입니다.

새롭게 추가한 값들도 데이터베이스에 잘 저장됩니다. 이제 스프링 서버를 다시 실행해도 데이터가 안전하게 저장됩니다.

스프링 통합 테스트

스프링 컨테이너와 DB까지 연결한 통합 테스트를 진행합니다.

회원 서비스 스프링 통합 테스트

회원 서비스 테스트(MemberServiceTest) 파일을 복사 후 붙여넣기 합니다. 이름은 MemberServiceIntegrationTest로 하겠습니다. 코드를 아래와 같이 수정합니다.

...
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;
    ...
}

지금까지는 순수한 자바 코드를 테스트했다면, @SpringBootTest 애너테이션은 스프링과 함께 테스트하도록 합니다. @Transactional 애너테이션은 테스트를 실행할 때 트랜잭션을 먼저 실행하고 DB에 데이터를 넣습니다. 테스트가 끝나면 항상 롤백을 해줍니다. 테스트는 항상 여러번 실행할 수 있도록 설계되어야 합니다. @Transactional 애너테이션은 데이터가 DB에 남아 테스트를 실행하기 전 데이터를 지워야 하는 불편함을 해결해줍니다.

@BeforeEach, @AfterEach 애너테이션은 지웁니다. 테스트에 필요한 MemberServiceMemberRepository는 스프링과 함께 테스트를 진행하므로 스프링 빈에서 불러와 주입되도록 @Autowired 애너테이션을 작성합니다.

정상적으로 동작하는 것을 확인할 수 있습니다.

스프링 JdbcTemplate

순수 Jdbc와 동일한 환경 설정이 필요합니다. SQL문을 제외한 Jdbc API의 반복되는 코드 대부분을 제거해줍니다.

JdbcTemplate 회원 리포지토리 생성

package hello.hello_spring.repository;

import hello.hello_spring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
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 JdbcTemplate jdbcTemplate;

    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.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 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());
    }

    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> {

            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return member;
        };
    }
}

작성한 JdbcTemplate을 사용할 수 있도록 스프링 설정을 변경합니다.

...
@Configuration
public class SpringConfig {
		...
    @Bean
    public MemberRepository memberRepository() {
//        return new MemoryMemberRepository();
//        return new JdbcMemberRepository(dataSource);
        return new JdbcTemplateMemberRepository(dataSource);
    }
}

앞에 만든 스프링 통합 테스트를 실행하면 정상 작동하는 것을 확인할 수 있습니다.

JPA

JPA는 기존의 반복되는 코드와 기본적인 SQL도 직접 만들어서 실행합니다. SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임의 전환이 있습니다.

JPA, h2 데이터베이스 관련 라이브러리 추가

build.gradle 파일에 아래 코드를 추가합니다.

dependencies {
	...
//	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'com.h2database:h2'
	...
}

spring-boot-starter-data-jpa는 내부에 jdbc 관련 라이브러리를 포함하므로 기존 라이브러리는 제거합니다.

스프링 부트에 JPA 설정 추가

application.properties 파일에 아래 코드를 추가합니다.

...
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none

show-sqlJPA가 생성하는 SQL을 출력하고, ddl-autoJPA가 자동으로 테이블을 생성하는 기능을 제어합니다. none은 해당 기능을 끄고, create는 엔티티 정보를 바탕으로 테이블을 직접 생성해줍니다.

JPA 엔티티 매핑

JPA를 사용하기 전에 JPA 엔티티 매핑을 먼저 해야합니다.

...
@Entity
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
		...
}

JPA 회원 리포지토리 생성

JPA 회원 리포지토리를 생성하고 아래 코드를 작성합니다.

package hello.hello_spring.repository;

import hello.hello_spring.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 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();
    }
}

EntityManager를 사용합니다.

서비스 계층 트랜잭션 추가

...
@Transactional
public class MemberService {
	...
}

스프링이 해당 클래스의 메서드를 실행할 때 트랜잭션을 시작하고, 메서드가 정상 종료되면 트랜잭션커밋합니다. 만약 런타임 예외가 발생하면 롤백합니다.

JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행해야 합니다.

JPA를 사용하도록 스프링 설정 변경

...

@Configuration
public class SpringConfig {

    private EntityManager em;

    @Autowired
    public SpringConfig(EntityManager em) {
        this.em = em;
    }
		
		...

    @Bean
    public MemberRepository memberRepository() {
//        return new MemoryMemberRepository();
//        return new JdbcMemberRepository(dataSource);
//        return new JdbcTemplateMemberRepository(dataSource);
        return new JpaMemberRepository(em);
    }
}

이제 DataSource가 아닌 EntityManager를 사용합니다.

스프링 통합 테스트 실행 및 결과

정상 동작하는 것과 JPA는 기본적으로 Hibernate 구현체를 사용하는 것을 확인할 수 있습니다.

스프링 데이터 JPA

스프링 데이터 JPA를 사용하면 리포지토리구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 기본 CRUD 기능도 모두 제공합니다. 추가로 해야할 환경 설정도 없습니다.

스프링 데이터 JPA 회원 리포지토리 생성

스프링 데이터 JPA 회원 리포지토리는 자바 클래스가 아닌 인터페이스로 생성합니다.

package hello.hello_spring.repository;

import hello.hello_spring.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);
}

인터페이스인터페이스를 받을 때는 implements가 아닌 extends를 사용합니다. 인터페이스는 다중 상속이 되므로 JpaRepositoryMemberRepository를 받습니다.

스프링 데이터 JPA 회원 리포지토리를 사용하도록 스프링 설정 변경

SpringConfig를 수정합니다.

...

@Configuration
public class SpringConfig {

    private final MemberRepository memberRepository;

    @Autowired
    public SpringConfig(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @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);
//    }
}

스프링 데이터 JPA 회원 리포지토리 실행 및 결과

스프링 통합 테스트를 실행하면 정상 작동하는 것을 확인할 수 있습니다.

스프링 데이터 JPA 설명

  • 스프링 데이터 JPA는 인터페이스를 통한 기본적인 CRUD를 제공합니다.
  • findByName(), findByEmail(), findByNameAndEmail() 처럼 메서드 이름만으로 조회 기능을 제공합니다.
  • 페이징 기능도 자동으로 제공합니다.
  • 복잡한 동적 쿼리는 Querydsl이라는 라이브러리를 사용하면 됩니다.
728x90
반응형