본문 바로가기
JAVA & SPRING/Spring 입문

스프링 입문 강의 - 4일차 (MVC, 순수 JDBC)

by 눈오는1월 2023. 7. 19.
728x90

이제 홈 화면을 추가해서 스프링을 실행했을때 화면이 나오게끔 하는 작업을 진행한다(스프링 MVC)

우선 화면을 보여주려면 컨트롤러를 추가해야한다.

package hello.hellospring.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(){
        return "home";
    }
}

이렇게 하면 GetMapping에 아무것도 없으므로 아무것도 없으면 "home" 이라는 html을 화면으로 보여준다. 그래서 templates에 홈화면을 만든다.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
    <div>
        <h1>Hello Spring</h1> <p>회원 기능</p>
        <p>
            <a href="/members/new">회원 가입</a>
            <a href="/members">회원 목록</a>
        </p>
    </div>
</div> <!-- /container -->
</body>
</html>

-> 그럼 저번에 말했던 index.html은 뭐야! 아무것도 없으면 spring에서 index.html을 보여준다면서! 라고 할 수 있는데, 과정은 스프링컨테이너에서 @Controller가 붙은거에서 먼저 확인을 한 후 없으면 static에 index.html을 보여주는식으로 진행한다(물론 매핑에 값이 없는것을 찾는다는것)

즉 간략하게 말하자면 컨트롤러가 정적 파일보다 우선순위가 높다

 

아직 회원가입과 회원 목록을 클릭해도 a태그에 적힌 html 파일이 없으므로 작동하지 않는다. 이제 회원 등록 컨트롤러를 만들어서 회원 가입과 회원 목록을 보여주는 컨트롤러를 만든다.

회원 객체 만들기 -> 컨트롤러 만들기 이 순서로 진행함

package hello.hellospring.controller;

public class MemberForm {

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

회원 객체를 만들어 준 후 

package hello.hellospring.controller;

import hello.hellospring.domain.Member;
import hello.hellospring.service.Memberservice;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import java.util.List;

@Controller  //컴포넌트 스캔으로 올라감
public class MemberController {

    private final Memberservice memberService;
    @Autowired  // 연결시켜줄때 사용 (외존관계 주입)
    public MemberController(Memberservice memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/members/new")
    public String createForm() {
        return "members/createMemberForm";
    }
    @PostMapping("/members/new")
    public String create(MemberForm form){
        Member member = new Member();
        member.setName(form.getName());

        memberService.join(member);

        return "redirect:/";
    }

    @GetMapping("/members")
    public String list(Model model){
        List<Member> members = memberService.findMember();
        model.addAttribute("members",members);
        return "members/memberList";
    }
}

회원 컨트롤러를 만든다. 포스트 매핑이 추가 됐는데 회원을 만들어서 요청하는 메소드이다. 장고에서view에서 메소드가 무엇인지에 따라 코드를 만드는 것처럼 이런식으로 POST를 정의함 Django보다는 node-js와 유사하다

postname에서 form 의 이름을 지정하는데 setName을 통해서 지정

(회원 등록 html과 회원 목록 html은 스프링에서 그렇게 중요한 내용이 아니므로 생략한다 html이 중요하지 않다라는 것이아님! 현재 나는 스프링을 배우고 있으니까 지금 배우는것에서는 중요하지 않다 라는 의미)

 

이렇게 만들면 현재 우리는 디비를 정하지 않았으므로 메모리에 값을 저장해서 보여주는 방식으로 사용하는데 이에 대한 문제점이 스프링을 다시 실행했을때 기존의 값이 저장되지 않는다(...DB가 없으니 당연한거긴함)

 

그래서 이제 DataBase와 스프링을 연결시킬것이다. h2 DataBase를 활용함

테이블 관리를위해 프로젝트 최상위 루트에서 sql폴더를 생성하고 ddl.sql파일을 생성한다.(꼭 해야 하는 작업은 아님(?))

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

이렇게 한 후 스프링과 h2DB를 연결해야하는데 h2 데이터베이스를 연결해주고 라이브러리를 추가한다

application.properties에 아래 코드를 추가한다.(연결하는 과정)

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

그리고 build.gradle파일에 라이브러리 추가(라이브러리 추가하는 과정)

plugins {
	id 'java'
	id 'org.springframework.boot' version '2.7.13'
	id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '11'
}

repositories {
	mavenCentral()  // 아래 라이브러리에 있는 것을 다운로드 받음
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // html만드는 템플릿 엔진
	implementation 'org.springframework.boot:spring-boot-starter-web'  // web
	implementation 'org.springframework.boot:spring-boot-starter-jdbc' // 추가된 부분JDBC 를 활용해서 데이터베이스와 연동
	runtimeOnly 'com.h2database:h2' // 추가된 부분
	testImplementation 'org.springframework.boot:spring-boot-starter-test'  // test 라이브러리가 기본적으로 제공
}

tasks.named('test') {
	useJUnitPlatform()
}

이제 이렇게 한 상태에서 순수JDBC를 통해 만들것인데 김영한 강사님 께서도 말씀하셨지만 JDBC API로 직접 코딩하는 것은 20년 전 이야기 이기에 일단 참고만 하는걸로 넘어가자 (실제로 강의들을때도 말은 쉽게해주셔서 이해는 조금 됐지만 코드를 완벽하게 이해하진 못했다)

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()); //1번 하면 위에 ?랑 매칭됨
            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); // datasource utils
    }
    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);
    }
}

디비를 이렇게 연결하는데 (이부분보면서 그냥 그렇구나~~ 고대(?) 개발자들은 되게 어렵고 힘들게 코딩하셨구나 훨씬 대단하시다.. 이렇게 개발환경이 좋은시기인데도 나는 지금도 개발하는데 매우 어려워하는데.. 대단하시다는 생각밖에 안들었음..반성하고 노력하자..)

package hello.hellospring;

import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.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 MemorymemberRepository(); 
        return new JdbcMemberRepository(dataSource);
    }
}

위 DataSources는 데이터베이스 커넥션을 획득할 때 사용되는 객체라고함 스프링 부트는 이 정보를 바탕하고 DataSource를 생성하고 스프링 빈으로 만들어줌. 또한 저번에는 MemorymemberRepository(); 를 return했지만 이제 디비를 사용하므로 저렇게 바꿔준다.

이게 갓 스프링인가 다른 코드는 건들지 않아도 이런식으로만 바꾸면 다 해결됨

이렇게 객체지향적인 개발이 좋은 이유가 다형성이다.

스프링은 어셈블리 라고 어플리케이션을 설정하는 코드만 손대면 나머지 코드는 손대지 않아도 된다 -> 스프링의 장점

이렇게 MemorymemberRepository대신 jdbcmemberRepository를 해도 돌아감(갓프링)

이렇게 되는 이유는 개방-폐쇄 원칙(OCP, Open-Closed Principle)덕분이다 이 개방-폐쇄 원칙은 확장에는 열려있고, 수정 변경에는 닫혀있다. 라는 말이다 추후 다음강의때 이거에 관해서 수업을 듣고 정리를 하겠다.

결론 ! 스프링의 DI(Dependencies Injection)을 사용하면 기존 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경할 수 있다.

 

이렇게 디비도 연결했으니까 이제 디비까지 잘 작동되는지 통합 테스트를 만든다.

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.assertThrows;

@SpringBootTest
@Transactional  // 이걸로 @AfterEach, BeforeEach 역할을 해줄 수 있음 테스트를 할때 트랜젝션을 실행하고 다 끝나면 롤백됨 디비 반영 안됨
class MemberserviceIntegrationTest {

   @Autowired Memberservice memberService;
   @Autowired MemberRepository memberRepository; // test는 맨 끝단에서 생성되기에 그냥 편하게 하면됨 필드 주입
    @Test
    void 회원가입() {
        // given
        Member member = new Member();
        member.setName("spring");
        //when
        Long saveId = memberService.join(member);


        //then
        Member findMember= memberService.findOne(saveId).get();
        //Assertions.assertThat(member.getName()).isEqualTo(findMember.getName());
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

    @Test
    public void 중복_회원_에외() {
        // 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)); // command + option + v 단축키 사기
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

    }

    @Test
    void findMember() {
    }

    @Test
    void findOne() {
    }
}

이렇게 테스트를 만들었다. 예전 테스트코드와 다른점은 예전에는 테스트가 다른 테스트의 영향을 끼치지 않기 위해 @BeforeEach, AfterEach 어노테이션을 만들었는데, 이 역할을 @Transactional 이 해준다. 쉽게 얘기해서 이렇게 해야지 test에서 돌아가는 정보가 디비에 저장이 안됨

테스트는 통합테스트와 단위 테스트가 두가지 존재한다.

통합테스트는 지금한것처럼 테스트실행시 스프링 컨테이너가 올라가고 디비를 연동하는 테스트를 말하는거고 단위 테스트는 예전에 한것처럼 순수자바로만 하는것이다.

그럼 통합테스트가 있으니까 단위테스트는 필요없는게 아닌가? 라고 생각이 들 수 있는데 전혀 그렇지 않다.

테스트 돌아가는 시간이 단위테스트가 훨씬 빠르다. 그렇기에 테스트양이 엄청많으면 그만큼 단위테스트가 효율적이다.

그래서 단위테스트가 더 좋은테스트일 확률이 높기에 이 방법도 알아둬야한다.

 

 

728x90