공부/Spring

스프링 회원 관리 예제 2

Stair 2024. 10. 21. 15:11
반응형

https://surrealcode.tistory.com/86

 

스프링 회원 관리 예제

이번엔 회원 관리 예제를 만들어보려 한다.기본 환경설정에 관한 내용은 아래를 참고하자.https://surrealcode.tistory.com/85 스프링 입문 - 프로젝트 환경 설정2https://surrealcode.tistory.com/84 스프링 입문 -

surrealcode.tistory.com

 

회원 관리 예제 - 웹 MVC개발

 

회원 웹 기능

회원 웹 기능 폼을 만들어보자.

@Controller
public class HelloController {

    //localhost:8080으로 들어오면 나오는 홈 화면
    @GetMapping("/")
    public String home(){
        return "home";
    }
    
}
<!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>


</body>
</html>

 

@GetMapping("/")는 localhost:8080으로 들어가는 홈 화면을 매핑하였는데

home을 리턴한다 따라서 home.HTML이 홈 화면으로 시작이 된다.

 

홈 화면에서 회원가입을 누르게 되면

localhost:8080/members/new 도메인으로 들어가게 된다.

이 도메인을 Mapping하는 코드는 아래와 같다

 

@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("/member/new")
    public String create(MemberForm form){
        Member member = new Member();
        member.setName(form.getName());

        memberService.join(member);

        return "redirect:/";
    }
}
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
    <form action="/members/new" method="post"> <div class="form-group">
        <label for="name">이름</label>
        <input type="text" id="name" name="name" placeholder="이름을 입력하세요">
    </div>
        <button type="submit">등록</button>
    </form>
</div> <!-- /container -->
</body>
</html>

localhost:8080/members/new로 들어오게 되면 createForm() 이라는 메서드가

members/createMemberForm이라는 HTML 파일로 리턴을 때린다.

 

createMemberForm라는 HTML을 확인해보면 액션태그와 값을 입력할 수 있는 기능이 있다.

이 상태에서 이름을 입력하고 등록을 하게되면 /member/new로

name:입력한 값

이 키값 쌍으로 넘어가게 되고

/member/new를 PostMapping하는 create()라는 메서드가 받게 된다.

 

이 create() 메서드는 MemberForm이라는 형태의 매개변수를 받고 있는데

MemberForm의 형태는 다음과 같다.

public class MemberForm {
    private String name;

    public String getName() {
        return name;
    }

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

 

name : 입력한값

이 post방식으로 넘어오며 setName을 통해

MemberForm에 name을 입력한 값으로 저장하게 된다.

 

 

    @PostMapping("/member/new")
    public String create(MemberForm form){
        Member member = new Member();
        member.setName(form.getName());

        memberService.join(member);

        return "redirect:/";
    }

 

이후 멤버 객체를 생성하고 멤버 객체에 form.getName()을 통해 저장한 값을 setName해주며, 이 멤버를 memberService.join()메서드로 memberRepository.save()를 해준다.

@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.findMembers();
        model.addAttribute("members", members);
        return "members/memberList";
    }
}
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
  <div>
    <table>
      <thead>
      <tr>
        <th>#</th>
        <th>이름</th>
      </tr>
      </thead>
      <tbody>
      <tr th:each="member : ${members}">
        <td th:text="${member.id}"></td>
        <td th:text="${member.name}"></td>
      </tr>
      </tbody>
    </table>
  </div>
</div> <!-- /container -->
</body>
</html>

 

위의 코드는 memberService에 저장되었던 id, name을 꺼내서 HTML템플릿에 뿌려주는 코드이다.

 

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

 

 

위 코드에서 @GetMapping("/members")는 

home.html의 a태그 링크인

<a href="/members">회원 목록</a>

을 매핑하고 있다.

memberService.findMembers()메서드를 통하여 memberService에 저장하였던 회원들을 리스트 형식으로 저장하고, model에 addAttribute()메서드를 통하여 저장한 뒤

return 'members/memberList' HTML로 리턴하였다.

 

memberList HTML은

<tr th:each="member : ${members}">
  <td th:text="${member.id}"></td>
  <td th:text="${member.name}"></td>

 

 

thymeleaf 템플릿 엔진을 통해 ${}를 통해 값을 동적으로 뿌려줄 수 있는데 member에 저장된 member의 id와 name값을 전부 뿌려주는 HTML이다.

 

 

지금 구현했던 코드들은 메모리상에 데이터가 저장이 되는 것이기 때문에, 서버를 내렸다 다시 올리게 되면 저장했던 값이 모두 날아간다.

따라서 DB를 활용하여 데이터 저장을 한다.

 

데이터베이스는 h2database를 사용한다.

https://h2database.com/html/main.html

 

H2 Database Engine

H2 Database Engine Welcome to H2, the Java SQL database. The main features of H2 are: Very fast, open source, JDBC API Embedded and server modes; in-memory databases Browser based Console application Small footprint: around 2.5 MB jar file size     Supp

h2database.com

여기서 All platforms를 다운받아 실행해주자

 

**참고 : JAVA JDK가 설치가 되어있지 않으면 에러가 난다. JDK 설치하고 환경변수까지 편집 후 실행하자

 

JDBC URL과 도메인의 IP를 localhost로 설정해 준 후 연결을 한다.

 

 

 

1. 순수JDBC(옛날 방식)

우선 build.gradle의 dependencies에 아래 두줄을 추가해준다.

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

 

그 후 application.properties에 내용을 추가해준다.(버전업이 됨에 따라 id도 추가해줘야한다.

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

 

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

 

위와 같은 코드를 작성한 후 SpringConfig의 MemoryMembeerRepository를 변경한다.

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

helloController는 memberService에 의존한다.

 

memory상에 저장되는 memberRepository와

jdbc로 DB에 저장되는 memberRepository의 구현체가 만들어져 있다.

여기서 memory상에 저장되는 memberRepository를 빼고 jdbc로 DB에 저장되는 memberRepository로 바꿔끼워주기만 하면 된다.

 

개방-폐쇄 원칙(OCP, Open-Closed Principle)

- 확장에는 열려있고, 수정, 변경에는 닫혀있다.

 

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

 

 

 

스프링 컨테이너와 DB까지 연결한 통합 테스트는 아래와 같은 코드처럼 진행하면 된다.

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

    @Test
    void 회원가입() {
        //given
        Member member = new Member();
        member.setName("spring");

        //when
        Long savedId = memberService.join(member);

        //then
        Member findMember = memberService.findOne(savedId).get();
        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));

        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

        /*memberService.join(member1);
        try {
            memberService.join(member2);
        } catch (IllegalStateException e){
            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        }*/

        //then
    }


    @Test
    void 회원찾기서비스() {
    }

    @Test
    void 회원한명찾기() {
    }
}

여기서 클래스 이름 위에 붙은 두개의 어노테이션이 중요하다.

@SpringBootTest : 스프링 컨테이너와 테스트를 함께 실행한다.

@Transactional : 테스트 케이스에 이 어노테이션이 있으면, 테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백한다. 이렇게 하면 DB에 데이터가 남지 않으므로 다음 테스트에 영향을 주지 않는다.

 

 

 

스프링 JdbcTemplate

순수 jdbc와 동일한 환경설정을 하면 된다.

스프링 jdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서 본 반복 코드를 대부분 제거해준다. 하지만 SQL은 직접 작성해야 한다.

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

    private final JdbcTemplate jdbcTemplate;

    @Autowired
    public JdbcTemplateMemberRepository(DataSource dataSource) {
        jdbcTemplate = new JdbcTemplate(dataSource);
    }

 

JdbcTemplate이라는 것을 쓰면 된다.

생성자를 통해서 datasource를 주입시켜주면 된다.

 

이후 SpringConfig에서

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

JdbcTemplateMemberRepository로 바꿔치기 해주기만 하면 된다

 

그럼 이전에 작성했던 통합 테스트 또한 정상 동작하며 테스팅을 할 수 있게 도와준다

 

 

 

JPA

JPA는 기존의 반복 코드는 물론이고, 기본적인  SQL도 JPA가 직접 만들어서 실행해준다.

JPA를 사용하면, SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임 전환을 할 수 있다.

JPA를 사용하면 개발 생산성을 크게 높일 수 있다.

 

 

JPA 기술을 사용하기 위해서는 몇가지 설정을 해줘야 한다.

 

우선 build.gradle의 dependencies에

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 관련 설정을 해준다.

 

그리고 application.properties에

spring.application.name=hello-spring
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.dadasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto= none

설정을 진행해준다.

 

 

JPA를 사용하려면 엔티티를 매핑을 해야 한다.

JPA라는 것은 인터페이스이다. 구현체로 Hibernate, EclipseLink 등 구현 기술들이 있다.

보통 JPA인터페이스의 Hibernate를 거의 쓴다.

 

import jakarta.persistence.*;

@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라는 어노테이션을 붙히고

@Id, @GeneratedValue(strategy = GenerationType.IDENTITY) 어노테이션을 추가해준다.

 

이 어노테이션을 가지고 데이터베이스랑 맵핑을 한다.

이렇게 해놓으면 이 정보를 가지고 CRUD를 할 수 있다.

 

JPA는 엔티티 매니저를 통해 동작을 한다.

따라서  JPA를 사용하려면 EntitiyManager를 주입받아야 한다.

private final Entity em;

public JpaMemberRepository(Entity em) {
    this.em = em;
}

 

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 {

    @PersistenceContext
    private EntityManager em;

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

    @Bean
    public MemberService memberService(){
        return new MemberService(MemberRepository());
    }

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

}

 

JPA를 한 Repository를 생성한 후 SpringConfig에서 DI를 해주었다.

 

통합테스트의 회원가입부분을 실행시켜보면 다음과 같은 결과가 나온다.

Hibernate: select m1_0.id,m1_0.name from member m1_0 where m1_0.name=?
Hibernate: insert into member (name,id) values (?,default)

 

Spring Data JPA를 세팅하면 기본적으로 Hibernate라는 오픈소스 구현체가 사용된다

 

 

 

 

스프링 부트와 JPA만 사용해도 개발 생산성이 정말 많이 증가하고, 개발해야할 코드도 확연하게 줄어든다.

여기에 스프링 데이터 JPA를 사용하면, 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있다. 그리고 CRUD기능도 스프링 데이터 JPA가 모두 제공한다.

스프링 부트와 JPA라는 기반 위에, 스프링 데이터 JPA라는 프레임 워크를 더하면 개발 코드들이 확연하게 줄기 때문에 개발자는 핵심 비즈니스 로직을 개발하는데 집중할 수 있다.

 

public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {

    @Override
    Optional<Member> findByName(String name);

}
@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 JdbcMemberRepository(dataSource);
//        return new MemoryMemberRepository();
//        return new JdbcTemplateMemberRepository(dataSource);
//        return new JpaMemberRepository(em);
//        return
//    }

}

 

스프링 데이터 JPA가 SpringDataJpaMemberRepository를 스프링 빈으로 자동 등록해준다.

 

 

"스프링 데이터 JPA 제공 기능"

- 인터페이스를 통한 기본적인 CRUD

- findByName(), findByEmail() 처럼 메서드 이름 만으로 조회 기능 제공

- 페이징 기능 자동 제공

 

참고 : 실무에서는 JPA와 스프링 데이터 JPA를 기본으로 사용하고, 복잡한 동적 쿼리는 Querydsl이라는 라이브러리를 사용하면 된다. Querydsl을 사용하면 쿼리도 자바 코드로 안전하게 작성할 수 있고, 동적 쿼리도 편리하게 작성할 수 있다. 이 조합으로 해결하기 어려운 쿼리는 JPA가 제공하는 네이티브 쿼리를 사용하거나, 앞서 학습한 스프링 JdbcTemplate를 사용하면 된다.

 

 

 

AOP

AOP가 필요한 상황

- 모든 메소드의 호출 시간을 측정하고 싶다면?

- 공통 관심 사항(cross-cutting concern) vs 핵심 관심 사항(core concern)

- 회원 가입 시간, 회원 조회 시간을 측정하고 싶다면?

 

 

System.currentTimeMillis()등과 같은 메서드를 활용하여

start, end를 찍고 차이를 구할 것이다.

 

하지만 여기엔 다음과 같은 문제가 있다.

- 회원가입, 회원 조회에 시간을 측정하는 기능은 핵심 관심사항이 아니다.

- 시간을 측정하는 로직은 공통 관심 사항이다.

- 시간을 측정하는 로직과 핵심 비즈니스의 로직이 섞여있어 유지보수가 어렵다.

- 시간을 측정하는 로직을 별도의 공통 로직으로 만들기 매우 어렵다.

- 시간을 측정하는 로직을 변경할 때 모든 로직을 찾아가면서 변경해야 한다.

 

-> 이 문제를 AOP를 활용하여 해결한다.

 

반응형