AOP


AOP가 필요한 상황

  • 모든 메소드의 호출 시간을 측정하고 싶다면?
    • 초 단위로 만들었는데 ms 단위로 만들라고 하면??
  • 공통 관심 사항(cross-cutting concern) vs 핵심 관심 사항(core concern)
  • 회원 가입 시간, 회원 조회 시간을 측정하고 싶다면?

MemberService 회원 조회 시간 측정 추가

package hello.hellospring.service;
@Transactional
public class MemberService {
        /**
        * 회원가입 */
    public Long join(Member member) {
        long start = System.currentTimeMillis();
        try {
            validateDuplicateMember(member); //중복 회원 검증 memberRepository.save(member);
            return member.getId();
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("join " + timeMs + "ms");
                } 
        }
        /**
        * 전체 회원 조회 */
    public List<Member> findMembers() {
        long start = System.currentTimeMillis();
        try {
            return memberRepository.findAll();
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("findMembers " + timeMs + "ms");
                } 
        }
}
  • 이런 식으로 1000개 메서드에 대해서 전부 작성하려면...
  • 처음 테스트 돌릴때는 시간이 오래걸림(웜업을 하기도 함)

문제점

  • 회원가입, 회원 조회에 시간을 측정하는 기능은 핵심 관심 사항이 아니다.
  • 시간을 측정하는 로직은 공통 관심 사항이다.
  • 시간을 측정하는 로직과 핵심 비즈니스의 로직이 섞여서 유지보수가 어렵다.
  • 시간을 측정하는 로직을 별도의 공통 로직으로 만들기 매우 어렵다.
  • 시간을 측정하는 로직을 변경할 때 모든 로직을 찾아가면서 변경해야 한다.

AOP 적용

  • AOP: Aspect Oriented Programming
  • 공통 관심 사항(cross-cutting concern) vs 핵심 관심 사항(core concern) 분리

시간 측정 AOP 등록

package hello.hellospring.aop;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Component
@Aspect
public class TimeTraceAop {
        @Around("execution(* hello.hellospring..*(..))") // 특정 패키지 하위 특정 클래스 지정 가능
        // @Around("execution(* hello.hellospring.service..*(..))") // service 하위 클래스만 timetrace
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        System.out.println("START: " + joinPoint.toString()); // 어떤 메서드를 call 하는지
        try {
            return joinPoint.proceed(); // 다음 메서드로 진행
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("END: " + joinPoint.toString()+ " " + timeMs + "ms");
                }
        }
}

Spring 설정 변경(Component 스캔 안쓴다면)

package hello.hellospring;
import hello.hellospring.repository.*;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SpringConfig {
        // 스프링 데이터 JPA가 만들어둔 구현체가 등록이 됨
    private final MemberRepository memberRepository;

        // @Autowired 생략 가능
        // 스프링데이터 JPA가 구현해 만들어 등록해둔 거를 DI
    public SpringConfig(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
        }
    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository);
    }

        @Bean
        public TimeTraceApp() {
                return new TimeTraceApp();
        }
}

해결

  • 회원가입, 회원 조회등 핵심 관심사항과 시간을 측정하는 공통 관심 사항을 분리한다.
  • 시간을 측정하는 로직을 별도의 공통 로직으로 만들었다.
  • 핵심 관심 사항을 깔끔하게 유지할 수 있다.
  • 변경이 필요하면 이 로직만 변경하면 된다.
  • 원하는 적용 대상을 선택할 수 있다.

스프링의 AOP 동작 방식 설명

AOP 적용 전 의존관계

memberController --> memberService

AOP 적용 후 의존관계

memberController --> 프록시 memberService --> (jointPoint.proceed()) --> 실제 memberService

  • 스프링이 올라올 때 컨테이너에 가짜 스프링 빈을 앞에 세워 놓는다

AOP 적용 전 전체 그림

 

AOP 적용 후 전체 그림

 

  • 실제 Proxy가 주입되는지 콘솔에 출력해서 확인하기(getclass() 메서드 이용)

스프링 DB 접근 기술

  • H2 데이터베이스 설치
  • 순수 Jdbc
  • 스프링 JdbcTemplate
  • JPA
  • 스프링 데이터 JPA

H2 데이터베이스 설치

  • 개발이나 테스트 용도로 가볍고 편리한 DB, 웹 화면 제공 [https://www.h2database.com]
  • 다운로드 및 설치
    • h2 데이터베이스 버전은 스프링 부트 버전에 맞춘다.
    • 권한 주기: chmod 755 h2.sh (윈도우 사용자는 x)
    • 실행: ./h2.sh (윈도우 사용자는 h2.bat)
    • 실행하면 exploler에 콘솔이 뜨는데, 접속이 안되면 주소를 localhost로 해주자
    • 데이터베이스 파일 생성 방법
      • jdbc:h2:~/test (최초 한번)
      • ~/test.mv.db 파일 생성 확인
      • 이후부터는 jdbc:h2:tcp://localhost/~/test 이렇게 접속
  • h2 데이터베이스 버전은 스프링 부트 버전에 맞춘다.
    • 스프링 부트 2.x를 사용하면 1.4.200 버전을 다운로드 받으면 된다.
    • 스프링 부트 3.x를 사용하면 2.1.214 버전 이상 사용해야 한다.

테이블 생성하기

  • 테이블 관리를 위해 프로젝트 루트에 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)
);
  • DB 접속이 안된다면 포트번호 이후는 가만히 두고, IP주소만 local host로 바꿔준다. http://localhost:8082/

순수 Jdbc

환경 설정

  • build.gradle 파일에 jdbc, h2 DB 관련 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
  • 스프링 부트 데이터베이스 연결 설정 추가
    • resources/application.properties
    • 주의) 스프링부트 2.4부터는 spring.datasource.username=sa 를 꼭 추가해주어야 한다
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa

Jdbc 리포지토리 구현

  • 엄청 오래 전 개발 방식이니, 참고만 하자
  • 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);
        } 
}
  • 스프링 설정 변경
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);
    }
}
  • DataSource는 데이터베이스 커넥션을 획득할 때 사용하는 객체다. 스프링 부트는 데이터베이스 커넥션 정보를 바탕으로 DataSource를 생성하고 스프링 빈으로 만들어둔다. 그래서 DI를 받을 수 있다.

구현 클래스 추가 이미지

 

 

스프링 설정 이미지

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

  • 확장에는 열려있고, 수정, 변경에는 닫혀있다.
  • 스프링의 DI (Dependencies Injection)을 사용하면 기존 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경할 수 있다.
  • 회원을 등록하고 DB에 결과가 잘 입력되는지 확인하자.
  • 데이터를 DB에 저장하므로 스프링 서버를 다시 실행해도 데이터가 안전하게 저장된다.

스프링 통합 테스트

  • 회원 서비스 스프링 통합 테스트
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));//예외가 발생해야 한다. assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        } 
}
  • @SpringBootTest : 스프링 컨테이너와 테스트를 함께 실행
  • @Transactional : 테스트 케이스에 이 애노테이션이 있으면, 테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백한다. 이렇게 하면 DB에 데이터가 남지 않으므로 다음 테스트에 영향을 주지 않는다.

스프링 JdbcTemplate

  • 순수 Jdbc와 동일한 환경설정을 하면 된다.
  • 스프링 JdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서 본 반복 코드를 대부분 제거해준다. 하지만 SQL은 직접 작성해야 한다.
    • result set, coneection과 같은 반복 코드 제거

스프링 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;

        // 생성자가 딱 하나 있으면 @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 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();
    }
    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> { // rs : 넘어온 resultset
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return member; 
                }; 
        }
}
  • 템플릿 메서드 패턴, 콜백을 통해서 구현되어있다.

JdbcTemplate을 사용하도록 스프링 설정 변경

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

JPA

  • JPA는 기존의 반복 코드는 물론이고, 기본적인 SQL도 JPA가 직접 만들어서 실행해준다.
  • JPA를 사용하면, SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임을 전환을 할 수 있다.
  • JPA를 사용하면 개발 생산성을 크게 높일 수 있다.

build.gradle 파일에 JPA, h2 데이터베이스 관련 라이브러리 추가

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') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}
  • spring-boot-starter-data-jpa 는 내부에 jdbc 관련 라이브러리를 포함한다. 따라서 jdbc는 제거해도 된다

스프링 부트에 JPA 설정 추가

  • resources/application.properties
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver

<- 다음을 추가 -> 
spring.datasource.username=sa
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
  • 스프링부트 2.4부터는 spring.datasource.username=sa 를 꼭 추가해주어야 한다. 그렇지 않으면 오류가 발생한다.
  • show-sql : JPA가 생성하는 SQL을 출력한다.
  • ddl-auto : JPA는 테이블을 자동으로 생성하는 기능을 제공하는데 none 를 사용하면 해당 기능을 끈다.
    • create 를 사용하면 엔티티 정보를 바탕으로 테이블도 직접 생성해준다. 해보자.

JPA 엔티티 매핑

  • JPA를 사용하려면 entity를 매핑해줘야 한다
  • JPA는 인터페이스이고, 구현체로 Hibernate, Eclipse Link 등 구현하는 기술들이 여러 개의 벤더들에 있음 (거의 Hibernate만 사용)
  • JPA를 JAVA의 표준인터페이스로 보고, 구현은 여러 업체가 한다고 보면 됨
  • JPA는 객체 + ORM 기술
    • object, relational(rdb), mapping(테이블 매핑)
package hello.hellospring.domain;
// JPA가 관리하는 Entity
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Member {
        // @Id : 얘가 primary key임을 알려줌
        // @GeneratedValue : DB에서 pk를 생성해주고 있음 (이런거를 identity strategy라고 함)
    @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;
        } 
}

 

위 코드에서 import 가 제대로 되지 않는다면 아래 코드로

package hello.hellospring.domain;
// JPA가 관리하는 Entity
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Member {
        // @Id : 얘가 primary key임을 알려줌
        // @GeneratedValue : DB에서 pk를 생성해주고 있음 (이런거를 identity strategy라고 함)
    @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;
        } 
}

JPA 회원 리포지토리

package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;
public class JpaMemberRepository implements MemberRepository {
        // JPA는 EntityManager 기반으로 모든게 동작함
        // build.gradle에 data-jpa 라이브러리를 받음 --> 스프링 부트가 자동으로 EntityManager를 생성하여 DB까지 연결도 하고 프로퍼티도 알아서 연동해줌(DataSource가 em 내부에서 다 처리함)
    private final EntityManager em;
        // entitiy manager 인젝션
    public JpaMemberRepository(EntityManager em) {
        this.em = em;
        }
    public Member save(Member member) {
        em.persist(member); // 영구 저장
        return member;
        }
    public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);
        }
    public List<Member> findAll() {
            // select id, name 이런 걸 적을 필요 없이 m으로 처리해주면 됨
        return em.createQuery("select m from Member m", Member.class)
        }

        public Optional<Member> findByName(String name) {
                // select id, name 이런 걸 적을 필요 없이 m으로 처리해주면 됨
        List<Member> result = em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();
        return result.stream().findAny();
        } 
}

 

위 코드가 제대로 작동하지 않는다면 아래 코드 사용

package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;

import java.util.List;
import java.util.Optional;
public class JpaMemberRepository implements MemberRepository {
    // JPA는 EntityManager 기반으로 모든게 동작함
    // build.gradle에 data-jpa 라이브러리를 받음 --> 스프링 부트가 자동으로 EntityManager를 생성하여 DB까지 연결도 하고 프로퍼티도 알아서 연동해줌(DataSource가 em 내부에서 다 처리함)
    private final EntityManager em;
    // entitiy manager 인젝션
    public JpaMemberRepository(EntityManager em) {
        this.em = em;
    }
    public Member save(Member member) {
        em.persist(member); // 영구 저장
        return member;
    }
    public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);
    }
    public List<Member> findAll() {
        // select id, name 이런 걸 적을 필요 없이 m으로 처리해주면 됨
        return em.createQuery("select m from Member m", Member.class).getResultList();
    }

    public Optional<Member> findByName(String name) {
        // select id, name 이런 걸 적을 필요 없이 m으로 처리해주면 됨
        List<Member> result = em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();
        return result.stream().findAny();
    }
}
  • 커맨드 옵션 n : inline으로 바꿔줌 (int a = 1; return a를 return 1로)
  • findById의 경우 id가 PK이기 때문에 find로 처리하지만, findByName이나 findAll 이런 것들은 jpql을 작성해줘야 함
    • JPA를 스프링에 감싸서 제공하는 기술인 스프링 데이터 JPA를 사용하면 jpql을 작성할 필요가 없음

서비스 계층에 트랜잭션 추가

import org.springframework.transaction.annotation.Transactional

@Transactional
public class MemberService {}
  • JPA를 사용하려면 @Transactional이 있어야 함
  • 스프링은 해당 클래스의 메서드를 실행할 때 트랜잭션을 시작하고, 메서드가 정상 종료되면 트랜잭션을 커밋한다. 만약 런타임 예외가 발생하면 롤백한다.
  • JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행해야 한다.

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

package hello.hellospring;
import hello.hellospring.repository.*;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.persistence.EntityManager;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {

        // 이제 dataSource를 사용하지 않아도 된다
    // private final DataSource dataSource;
    private final EntityManager em;
    public SpringConfig(EntityManager em) {
        // this.dataSource = dataSource;
                this.em = em;
        }
        @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 기능도 스프링 데이터 JPA가 모두 제공합니다.
스프링 부트와 JPA라는 기반 위에, 스프링 데이터 JPA라는 환상적인 프레임워크를 더하면 개발이 정말 즐거워집니다. 지금까지 조금이라도 단순하고 반복이라 생각했던 개발 코드들이 확연하게 줄어듭니다. 따라서 개발자는 핵심 비즈니스 로직을 개발하는데, 집중할 수 있습니다.
실무에서 관계형 데이터베이스를 사용한다면 스프링 데이터 JPA는 이제 선택이 아니라 필수 입니다.
  • 앞의 JPA 설정을 그대로 사용

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

package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
// JpaRepository와 MemberRepository를 다중 상속 받는다
public interface SpringDataJpaMemberRepository extends JpaRepository<Member,Long>, MemberRepository {
    Optional<Member> findByName(String name);
}
  • 스프링 데이터 JPA가 JPA 리포지토리를 받고 있으면 스프링 데이터 JPA가 구현체를 자동으로 만들어서 스프링 빈에 등록해줌
    • 스프링 데이터 JPA가 프록시 기술을 이용해서 객체를 생성한 후 스프링 빈에 등록
    • 사실 find all, find by id 이런 것들은 JpaRepository에서 기본 메서드들을 다 제공해줌
      • 처음 예제와 스프링 Data JPA와 시그니처가 다 맞춰져있었음(Optional 등등)
      • Optional findByName(String name);는 비지니스가 다르기 때문에 공통으로 제공하는게 불가능함(주문 번호일수도, 등등)
        • 인터페이스에 위 코드를 작성하면 "m from member m where m.name = ?" 라는 jpql을 짜줌
        • 인터페이스 이름 findBy___ 규칙을 따라서 작성하는 것

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

package hello.hellospring;
import hello.hellospring.repository.*;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SpringConfig {
        // 스프링 데이터 JPA가 만들어둔 구현체가 등록이 됨
    private final MemberRepository memberRepository;

        // @Autowired 생략 가능
        // 스프링데이터 JPA가 구현해 만들어 등록해둔 거를 DI
    public SpringConfig(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
        }
    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository);
    }
}

스프링 데이터 JPA 제공 클래스

 

스프링 데이터 JPA 제공 기능

  • 인터페이스를 통한 기본적인 CRUD
  • findByName() , findByEmail() 처럼 메서드 이름 만으로 조회 기능
  • 제공 페이징 기능 자동 제공

회원 관리 예제 - 웹 MVC 개발


회원 웹 기능 - 홈 화면 추가

  • HomeController를 만들자
package hello.hellospring.controller;

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

@Controller
public class HomeController {

    // localhost:8080/ 요청이 오면 이 메서드가 호출되어 home.html이 호출됨
    @GetMapping("/")
    public String home() {
        return "home";
    }
}
  • GetMapping("/")는 그냥 첫번째 localhost:8080으로 들어오면 매핑된 메서드가 호출된다 --> home.html 호출
  • home.html
<!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>
  • 참고: 컨트롤러가 정적 파일보다 우선순위가 높다.
    • 따라서 이전에 만들어둔 Welcom page인 index.html으로 가지 않는다
    • 이유 : 스프링이 관련 Controller를 먼저 찾고 static 파일을 찾기 때문

회원 등록 컨트롤러

  • 웹 등록 화면에서 데이터를 전달 받을 폼 객체
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class MemberController {

    private final MemberService memberService;

    @Autowired
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/members/new") // HTTP Get방식에 의해 호출됨
    public String createForm() {
        return "members/createMemberForm"; // 이 템플릿을 찾아 thymeleaf 템플릿 엔진이 렌더링
        // form이라는 값을 입력할 수 있는 태그를 통해 input 박스가 생기고, 데이터가 action url의 PostMapping으로 이동
        // 보통 등록할 때 Post, 조회할 때 Get을 사용함
    }

    @PostMapping("/members/new")
    public String create(MemberForm form) {
        Member member = new Member();
        member.setName(form.getName());
        memberService.join(member);
        return "redirect:/"; // 홈 화면으로 보낸다
    }
}

회원 웹 기능 - 조회

  • 회원 컨트롤러에서 조회 기능
@GetMapping(value = "/members")
public String list(Model model) {
    List<Member> members = memberService.findMembers();
    model.addAttribute("members", members); // model에 멤버리스트 자체를 다 담아서 view 템플릿에 넘김
    return "members/memberList";
}
  • 회원 리스트 HTML
<!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>
  • tip : 커맨드 + e : 가장 최근에 본 창으로 돌아갈 수 있음

참조) 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강좌 (인프런 김영한)

'Spring' 카테고리의 다른 글

[Spring] 07. AOP  (0) 2024.05.13
[Spring] 06. 스프링 DB 접근 기술  (0) 2024.05.13
[Spring] 스프링 빈과 의존관계  (0) 2024.04.29
[Spring] 회원 관리 예제 - 백엔드 개발  (0) 2024.04.29
[Spring] 스프링 웹 개발 기초  (0) 2024.04.28

스프링 빈과 의존관계

컴포넌트 스캔과 자동 의존관계 설정

  • 회원 컨트롤러가 회원서비스와 회원 리포지토리를 사용할 수 있게 의존관계를 준비하자

회원 컨트롤러에 의존관계 추가

package hello.hellospring.controller;

import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
@Controller
public class MemberController {

		private final MemberService memberService = new MemberService();
		// 위 방식 보단 아래의 방식이 더 권장됨(생성자 주입) : 여러군대에서 memberService를 사용하기 때문

		/*
		AutoWire를 통해 스프링이 스프링 컨테이너에 등록된 memberService를 가져옴(연결해줌)
		autowired가 컨트롤러, 서비스, 리포지토리의 연관관계를 연결해주기 때문
		오류가 나는 이유 : memBerService를 스프링이 관리하지 않기 때문(순수한 자바 코드임)
		구현체에 @Service를 추가해줘서 Spring container에 멤버 서비스를 등록해줘야 한다
		Repository도 구현체에 @Repository를 추가해줘야 한다
		위와 같은 방식을 컴포넌트 스캔 방식이라고 함 (@service, @Repository 안에 컴포넌트라는 anotation이 있기 때문에)
		@Component 애노테이션이 있으면 스프링 빈으로 자동으로 등록 된다.
		hello.hellospring에 있는 모든 패키지를 스캔해서 스프링 빈으로 등록해줌(하위 패키지가 아닌 얘들은 기본적으로 스캔이 안됨, 설정을 따로 해줘야 함)
		Service, Repository, Controller는 정형화된 패턴임
		컨트롤러를 통해서 외부 요청을 받고, 그 다음에 서비스에서 비즈니스 로직을 만들고, 리포지토리에서 데이터를 저장하는 패턴
		스프링 컨테이너에 스프링 빈을 등록할 때 기본적으로 싱글톤으로 등록한다. (설정으로 바꿀 수 있음)

		생성자 주입 : 생성자를 통해서 주입이 되는 거

		필드 주입 : 별로 안좋음
		@Autowired private MemberService memberService;
		생성될 때 autowired되고, 중간에 바꿔치기할 방법이 없음

		Setter 주입 : 멤버 컨트롤을 호출했을 때 public으로 열려있어야 함
		중간에 바꿔치기 할 이유가 없는데도 public으로 노출이 되는 문제가 있음
		중간에 잘못 바꾸면 문제가 생김
		@Autowired
		public void setMemberService(MemberService memberService) {
				this.memberService = memberService;
		


		요즘 권장하는 방식은 생성자 주입
		멤버 컨트롤러가 생성이 될 때 스프링 빈에 등록되어 있는 멤버 서비스 객체를 가져다가 넣어줌(Dependency Injection)
		*/
    private final MemberService memberService;


    @Autowired
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }
}
  • 생성자에 @Autowired 가 있으면 스프링이 연관된 객체를 스프링 컨테이너에서 찾아서 넣어준다. 이렇게 객체 의존관계를 외부에서 넣어주는 것을 DI (Dependency Injection), 의존성 주입이라 한다.
  • 이전 테스트에서는 개발자가 직접 주입했고, 여기서는 @Autowired에 의해 스프링이 주입해준다.
  • 그냥 위 코드를 실행하면, memberService가 스프링 빈으로 등록되어 있지 않아 오류가 발생함

컴포넌트 스캔원리

  • @Component 애노테이션이 있으면 스프링 빈으로 자동 등록된다.
  • @Controller 컨트롤러가 스프링 빈으로 자동 등록된 이유도 컴포넌트 스캔 때문이다.
  • @Component 를 포함하는 다음 애노테이션도 스프링 빈으로 자동 등록된다.
    • @Controller
      • @Service
      • @Repository
      • @Controller, @Service, @Repository 안을 봐보면 Component가 있음
@Service

public class MemberService {
    private final MemberRepository memberRepository;
    @Autowired
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

@Repository
public class MemoryMemberRepository implements MemberRepository {

 

  • memberServicememberRepository 가 스프링 컨테이너에 스프링 빈으로 등록되었다.
  • 참고) 스프링은 스프링 컨테이너에 스프링 빈을 등록할 때, 기본적으로 싱글톤으로 등록한다(물론 설정으로 바꿀 수 있긴 함). 따라서 같은 스프링 빈이면 모두 같은 인스턴스인 것이다.

스프링 빈을 등록하는 2가지 방법

  • 컴포넌트 스캔과 자동 의존관계 설정 (위에서 했던 방식)
  • 자바 코드로 직접 스프링 빈 등록하기

자바 코드로 직접 스프링 빈 등록하기

  • 회원 서비스와 회원 리포지토리의 @Service, @Repository, @Autowired 애노테이션을 제거하고 진행 한다.
package hello.hellospring.service;

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; 직접 스프링 빈에 등록하기
@Configuration
public class SpringConfig {
    @Bean spring bean에 등록할거야
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
}

지금 데이터 저장소가 선정되지 않았던 시나리오에 의해 인터페이스를 설계하고 메모리 멤버 리포지토리를 쓰고 있었음
근데 나중에 이거를 다른 리포지토리로, 기준에 운영 중인 코드를 하나도 손대지 않고 바꿔치기 할 수 있는 방법이 있음
이거를 하려면 구현체 바꿔치기를 해야 한다. 상황에 따라 구현 클래스를 변경해야하면 설정을 통해서 스프링 빈으로 등록하는게 좋음
DB에 연결한다면, SpringConfig에서 MemberRepository() 대신 DbMemberRepository()를 리턴하면 됨

  • 참고)
    • XML로 설정하는 방식도 있지만 최근에는 잘 사용하지 않으므로 생략한다.
    • DI에는 필드 주입, setter 주입, 생성자 주입 이렇게 3가지 방법이 있다. 의존관계가 실행중에 동적으로 변하는 경우는 거의 없으므로 생성자 주입을 권장한다.
    • 실무에서는 주로 정형화된 컨트롤러, 서비스, 리포지토리 같은 코드는 컴포넌트 스캔을 사용한다. 그리고 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하면 설정을 통해 스프링 빈으로 등록한다 (위의 데이터 저장소의 경우)
    • @Autowired 를 통한 DI는 helloController , memberService 등과 같이 스프링이 관리하는 객체에서만 동작한다. 스프링 빈으로 등록하지 않고 내가 직접 생성한 객체에서는 동작하지 않는다.

참조) 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강좌 (인프런 김영한)

회원관리 예제 - 백엔드 개발

  • 비지니스 요구사항 정리
  • 회원 도메인과 리포지토리 만들기
  • 회원 리포지토리 테스트 케이스 작성
  • 회원 서비스 개발
  • 회원 서비스 테스트

비지니스 요구사항

  • 데이터: 회원ID, 이름
  • 기능: 회원 등록, 조회
  • 아직 데이터 저장소가 선정되지 않음(가상의 시나리오)

일반적인 웹 application layer 구조

  • 컨트롤러: 웹 MVC의 컨트롤러 역할
  • 서비스: 핵심 비즈니스 로직 구현
    • ex) 중복 가입 불가
  • 리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인: 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨

클래스 의존관계

  • 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
  • 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
  • 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용

회원 도메인과 리포지토리 만들기

  • 회원 객체
package hello.hellospring.domain;
  
  public class Member {
      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;
      }
 }
  • 회원 리포지토리 인터페이스
    • Optional로 null을 Wrapping하여 반환
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;

public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();   
}
  • 회원 리포지토리 메모리 구현체
    • Tip) 옵션 + enter로 implement method 해줌
    package hello.hellospring.repository;
    
    import hello.hellospring.domain.Member;
    import java.util.*;
    /* 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려 */
    public class MemoryMemberRepository implements MemberRepository {
              private static Map<Long, Member> store = new HashMap<>();
              private static long sequence = 0L;
              @Override
              public Member save(Member member) {
    
                      member.setId(++sequence);
                      store.put(member.getId(), member);
                      // 이렇게 하는 이유는 id를 시스템에서 정해주기 때문(클래스 변수)
                      return member;
    
              }
              @Override
              public Optional<Member> findById(Long id) {
    
                      return Optional.ofNullable(store.get(id));
                      // null이 반환될 가능성이 있다면 Optional로 감싸서 반환하면 클라이언트에서 이에 맞춰 처리해준다
    
              }
              @Override
              public List<Member> findAll() {
    
                      return new ArrayList<>(store.values());
    
              }
              @Override
              public Optional<Member> findByName(String name) {
    
                      return store.values().stream()
                                      .filter(member -> member.getName().equals(name))
                                      .findAny();
                      // 찾아서 하나라도 있으면 반환해줌. 끝까지 돌렸는데 없으면 optional에 null이 포함되어 반환된다.
    
              }
              public void clearStore() {
    
                      store.clear();
    
              }
    }

회원 리포지토리 테스트 케이스 작성

  • 메인 메서드를 실행허거나, 웹 application의 컨트롤러를 통해서 해당 기능을 실행하면 준비/실행 시간이 오래걸리고, 반복 실행하기 어렵고, 여러 테스트를 한 번에 실행하기 어렵다는 단점이 존재함
    • JUnit 프레임워크 사용
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;

class MemoryMemberRepositoryTest {
  MemoryMemberRepository repository = new MemoryMemberRepository();
  @AfterEach
  public void afterEach() {
      repository.clearStore();
  }
  @Test
  public void save() {
//given
      Member member = new Member();
      member.setName("spring");
//when
      repository.save(member);
//then
      Member result = repository.findById(member.getId()).get(); // Optional에서 값을 꺼냄(.get())
      // Assertions.assertEquals(member, null);--> 기대했던 값과 실제 값이 달라 오류 발생
      assertThat(result).isEqualTo(member);
  }
  @Test
  public void findByName() {
//given
      Member member1 = new Member();
      member1.setName("spring1");
      repository.save(member1);
            // 위 세줄을 복붙하고, shift + f6 단축키를 이용하여 rename하자
      Member member2 = new Member();
      member2.setName("spring2");
      repository.save(member2);
//when
      Member result = repository.findByName("spring1").get();
//then
      assertThat(result).isEqualTo(member1);
  }
  @Test
  public void findAll() {
//given
      Member member1 = new Member();
      member1.setName("spring1");
      repository.save(member1);
      Member member2 = new Member();
      member2.setName("spring2");
      repository.save(member2);
//when
      List<Member> result = repository.findAll();
//then
      assertThat(result.size()).isEqualTo(2);
  }
}
  • 그냥 코드를 실행시키면 오류가 발생함
  • 테스트의 순서는 보장이 안됨
    • 순서에 의존적으로 설정하면 절대 안된다
  • 이대로 전체 Test를 돌리면 findAll 실행 후 저장되어있던 spring1과 spring2가 findByName에서 이전에 저장했던 객체가 나오게 되는 것
  • afterEach를 통해 테스트가 한 번 끝날 때 저장소를 지우자

회원 서비스 개발

  • hello.hellospring에 service 패키지 생성 후 MemberService 클래스 작성
package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.repository.MemberRepository;

import java.util.List;
import java.util.Optional;
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) {
        //find By name의 리턴타입이 optional이라서 아래처럼 사용 가능
        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);
    }
}
  • command + option + v : 반환 타입 + 변수 = 값 형태로 만들어줌
    • ex) memberRepository.findByName(member.getName()); 상태에서 저 단축키를 쓰면
    • Optional byName = memberRepository.findByName(member.getName()); 로 바꿔줌
  • 서비스는 보통 비즈니스에 의존적으로 설계를 한다
  • 리포지토리는 서비스보다 단순히 기계적으로 개발에 가까운 용어들을 선택한다.

회원 서비스 테스트

  • 중복 회원 비지니스 로직이 제대로 작동하는지 테스트 케이스를 작성해보자
  • 클래스를 만들고 커맨드 쉬프트 T --> Create New Test --> Junit5, 원하는 클래스 선택하면 껍데기를 만들어줌
  • 커맨드 옵션 / --> 주석처리
  • 테스트 함수는 한글로 만들어도 됨
  • given when then 문법
    • given : 뭔가가 주어짐(데이터)
    • when : 이거를 실행했을 때
    • then : 결과가 이게 나와야 해
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

class MemberServiceTest {
    MemberService memberService;
    MemoryMemberRepository memberRepository;
    @BeforeEach
    public void beforeEach() {
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
                // 따로 만드는 것보다 이렇게 만드는게 좋다
                // 외부에서 메모리 멤버 리포지토리를 넣어주고 있는데, 이런 것을 Dependency Injection이라고 함
    }
    @AfterEach
    public void afterEach() {
        memberRepository.clearStore();
    }
    @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));//예외가 발생해야 한다.
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
}
  • BeforeEach : 각 테스트 실행 전에 호출되어 테스트가 서로 영향받지 않도록 새로운 객체를 생성하고, 의존관계도 맺어준다
public class MemberService {
     private final MemberRepository memberRepository;
     public MemberService(MemberRepository memberRepository) {
         this.memberRepository = memberRepository;
}
... }
  • 회원 서비스 코드를 DI 가능하게 한다

스프링 웹 개발 기초


정적 컨텐츠

  • 서버에서 특별한 동작을 하지 않고 파일을 그대로 웹브라우저(client)로 전달
  • 스프링 부트는 정적 컨텐츠를 제공함
  <!DOCTYPE HTML>
  <html>
  <head>
      <title>static content</title>
      <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  </head>
  <body>
  정적 컨텐츠 입니다.
  </body>
  </html>
  • localhost:8080/hello-static.html
  • 원하는 파일을 넣으면 파일 그대로 반환됨
  • 내장 톰켓 서버가 요청을 받고 Spring에 controller가 있는지 찾아봄
  • hello-static이라는 컨트롤러, 맵핑된 컨트롤러가 존재X
  • resources 안에 있는 static/hello-static.html을 찾음
  • 찾은 파일을 반환

MVC와 템플릿 엔진

  • Model, View, Controller
  • 서버에서 변형을 해서(EX. HTML 파일 변경) 웹브라우저(client)로 전달
    <html xmlns:th="http://www.thymeleaf.org">
    <body>
    <p th:text="'hello ' + ${name}">hello! empty</p>
    </body>
    </html>
    • 단순히 hello-mvc를 열면 에러 발생
    • localhost:8080/hello-mvc?name=spring!!!!!!
      • 파라미터를 넘겨줘야 함
    • controller가 name=spring!!!!!!을 보고 model의 name에 spring!!!!!!을 담음
    • ${name} : 모델의 key 값이 name인 것에서 value를 꺼냄
  • @Controller public class HelloController { @GetMapping("hello-mvc") public String helloMvc(@RequestParam("name") String name, Model model) { model.addAttribute("name", name); return "hello-template"; } }
  • 작동방식
    • 웹브라우저에서 localhost 8080에 hello-mvc 넘기면 Spring Boot가 내장 톰켓 서버를 거침
    • 내장 톰캣 서버는 hello-mvc를 스프링한테 전달
    • 스프링은 helloController 메서드에 맵핑이 돼 있는걸 확인하고 이 메서드를 호출
    • helloController는 hello-template와 key(name):value(spring)을 스프링한테 전달
    • 스프링 viewResolver가 return의 string과 똑같은 파일을 찾아서 Thymeleaf 템플릿 엔진에서 처리해 달라고 전달
      • ViewResolver는 뷰를 찾고 템플릿을 연결시켜줌
    • 템플릿 엔진이 렌더링 후 변환한 HTML을 반환함

API

  • 데이터 구조 포맷으로 클라이언트 한테 데이터를 전달(요즘은 JSON 많이 사용)
    • @ResponseBody : http의 body부에 데이터를 직접 넣겠다
    • 템플릿 엔진과 차이점 : view가 따로 존재하지 않고, name 문자가 그대로 전달됨
    • http://localhost:8080/hello-string?name=spring 열어보면 html 태그가 존재하지 않고, 단순히 문자열 그대로 내려감. 즉 데이터가 그대로 내려감
     @Controller
     public class HelloController {
         @GetMapping("hello-api")
         @ResponseBody
         public Hello helloApi(@RequestParam("name") String name) {
             Hello hello = new Hello();
             hello.setName(name);
             return hello;
         }
         static class Hello {
             private String name;
             public String getName() {
                 return name;
    }
             public void setName(String name) {
                 this.name = name;
    } }
    }
  • @GetMapping("hello-string")
  • @ResponseBody
  • public String helloString(@RequestParam("name") String name) {
  • return "hello " + name; // "hello spring" }
  • 작동 방식
    • 톰켓 내장 서버에서 hello-api를 스프링에 전달
    • 스프링에서 hello-api controller를 찾음
    • responseBody의 annotaion이 존재(얘가 없으면 viewResolver로 전달됨)
    • httpMessageConverter가 동작 --> 리턴 타입이 객체임 --> JsonConverter가 동작해서 json 포맷으로 변환
      • 리턴 타입이 string이면 StringConverter가 동작
      • 기본 문자처리: StringHttpMessageConverter
      • 기본 객체처리: MappingJackson2HttpMessageConverter
    • json을 http 바디에 실어서 client에게 전달
    • HTTP Accept 해더에 특정 포맷을 지정해주면 해당 메시지컨버터가동작을 함

참조) 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강좌 (인프런 김영한)

+ Recent posts