[1. 스프링 입문] 웹 MVC 개발
1. 회원 웹 기능 - 홈 화면 추가
home으로 갈 수 있는 기본적인 getMapping 컨트롤러 메소드를 추가한다. 그리고 viewResolver로 만들어진 home.html을 만든다.
@Controller
public class HomeController {
@GetMapping("/") // dom에 첫번째 호출
public String home() {
return "home";
}
}
이전에 만들었던 index.html이 실행되지 않고 새로운 home.html로 이동하는 이유는 뭘까?
이전 강의서 설명했던 정적 컨텐츠 이미지를 확인해 보면 먼저 요청이 오면 스프링 컨테이너에서 관련된 컨트롤러가 있는지 찾는다.
welcome도 마찬가지로 컨트롤러를 찾아보는데 이번 코드에서는 HomeCotroller에 컨트롤러를 추가했다. 그래서 index.html로 가지 않고 home을 찾아서 이동한다
2. 회원 웹 기능 - 등록
다음과 같이 컨트롤러의 @PostMapping("/members/new")에 매핑된다. MemberForm에는 name프로퍼티가 있고 setter, getter가 구현되어 있다. 폼에서 입력한 name값이 담긴다. getName()으로 값을 가져올 수 있다.
@PostMapping("members/new")
public String crate(MemberForm form) {
Member member = new Member();
member.setName(form.getName());
memberService.join(member);
return "redirect:/";
}
3. 회원 웹 기능 - 조회
홈 화면에서 목록을 누르면 @GetMapping("/members")로 이동한다. 구현한 findMembers()를 호출한 후 model에 담아 /members/memberList로 이동한다. ViewResolver로 인하여 memberList.html을 보내준다.
@GetMapping("/members")
public String list(Model model){
List<Member> members = memberService.findMembers();
model.addAttribute("members", members);
return "members/memberList";
}
하지만 이렇게 작성한 코드는 회원가입이 이루어져도 메모리상에 위치하기 때문에 서버가 종료되면 사라지게 된다.
다음 강의에서는 데이터베이스에 저장하고 관리하는 방법을 학습하자
4. 스프링 DB 접근 기술 - JDBC
기존 메모리방식DB가 아닌 JDBC방식을 사용하도록 바꾼다. 단순히 JDBC API로만 코딩하여 사용하며 과거에 많이 사용한 방식이다.
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);
}
}
흐름은 sql을 만들고 커넥션을 열어주고 DB에 접근하여 데이터를 save하고 pk를 가져온 후, 커넥션을 닫아준다. SELECT, DELETE 등 코드 흐름이 비슷하며 코드가 길기 때문에 최근에는 잘 안 쓴다고 한다.
4. JDBC 테스트
DataSource는 데이터베이스 커넥션을 획득할 때 사용하는 객체다. 스프링 부트는 데이터베이스 커넥션 정보를 바탕으로 DataSource를 생성하고 스프링 빈으로 만들어둔다. 그래서 DI를 받을 수 있다
기존 메모리방식DB가 아닌 JDBC방식을 사용하도록 바꾼다. 단순히 JDBC API로만 코딩하여 사용하며 과거에 많이 사용한 방식이다.
스프링 컨테이너, DB까지 연결한 테스트를 진행한다. 새로 만든 테스트 클래스 파일 상단에 다음과 같은 어노테이션을 추가한다.
@SpringBootTest : 스프링 컨테이너와 테스트를 함께 실행한다(설정 파일 등 참고하여 런타임 의존성, DB연결 등 모두 사용)
@Transactional : 이 어노테이션을 붙이면 TEST로 Insert 등을 실행해도 최종적으로 모두 롤백하여 실데이터에 영향이 안 갈수 있도록 해준다. 다음 테스트에 영향을 주지 않는다
5. 스프링 JdbcTemplate
순수 JDBC에서 반복된 코드를 제거해주는 장점이 있다.
public class JdbcTemplateMemberRepository implements MemberRepository {
private final JdbcTemplate jdbcTemplate;
@Autowired
public JdbcTemplateMemberRepository(DataSource dataSource){
jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public Member save(Member member) {
//SimpleJdbcInsert는 쿼리를 짤 필요 없이 값만 있으면 insert문을 생성해줌
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());
return result.stream().findAny();
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper());
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;
};
}
}
6. JPA
JPA는 기존의 반복 코드는 물론이고, 기본적인 SQL도 JPA가 직접 만들어서 실행해준다.
JPA를 사용하면, SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임을 전환을 할 수 있다.
JPA를 사용하면 개발 생산성을 크게 높일 수 있다.
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();
}
}
마지막으로 JPA사용시 Service 클래스 상단에 @Transactional을 붙여줘야 한다. JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행해야 한다.
6. 스프링 데이터 JPA
스프링 데이터 JPA를 사용하면 리포지토리 구현 클래스 없이 인터페이스만으로 개발을 할 수 있다. CRUD의 기본적 기능을 스프링 데이터 JPA가 모두 제공한다. 다음과 같이 Repository 인터페이스를 만들 수 있다.
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
Optional<Member> findByName(String name);
}
public class SpringConfig {
private final MemberRepository memberRepository;
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository);
}
}
MemberRepository 구현체를 스프링컨테이너가 관리하고 있기 때문에 이미지처럼 생성자방식으로 의존성주입을 해줄 수 있다. 생성자가 1개이기 때문에 굳이 @Autowired를 붙일 필요가 없다. 이후 통합테스트를 돌려보면 정상적으로 성공하는 것을 확인하였다.
스프링데이터 JPA의 기본 인터페이스인 JpaRepository 인터페이스는 Paging관련 인터페이스, CRUD관련인터페이스를 다중상속하고 있다. 그렇기 때문에 기본적인 CRUD 등을 쿼리나 구현메소드 없이 바로 동작하게 해준다. 복잡한 동적쿼리는 이후 QueryDsl을 사용한다고 한다.
또한 기본적으로 제공하는 메소드 이외에 비지니스 로직별로 다른 메소드가 있는 경우 표기룰만 맞춰준다면 리플렉션 기술로 알아서 SQL을 만들어준다. 위 이미지의 findByName은 사실 기본적인 스프링데이터JPA 제공 메소드가 아니다. 리플렉션 기술로 findByName은 다음처럼 해석되어 돌아간다.
select m from member m where m.name = ?
마지막으로 JPA만으로 꼭 모든 것을 할 필요 없게하도록 기본적인 SQL쿼리 방식을 사용할 수 있게 열어놨다고 한다. 그러나 대부분의 데이터접근 메소드는 JPA로 구현이 가능하다고 한다.