본문 바로가기

스프링 부트

📋 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강의 노트

2021-04-16글

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


비즈니스 요구사항

  • 데이터 : 회원 ID, 이름
  • 기능 : 회원 등록, 조회
  • 아직 데이터 저장소가 선정되지 않음

일반적인 웹 애플리케이션 계층 구조

  • 컨트롤러 : 웹 MVC의 컨트롤러 역할
  • 서비스 : 핵심 비즈니스 로직 구현
  • 레포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인 : 비즈니스

클래스 의존 관계

image

회원을 저장하는 것은 Repository 인터페이스.
일단은 메모리 구현체.
DBMS를 자유롭게 바꿔 끼우기 위해 인터페이스로 구현하였음.

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

MemberRepository

public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByNames(String name);
    List<Member> findAll();
}
  • findById(), findByNames() 은 null을 반환할 수 있으니 Optional로 감싸서 반환한다.

MemoryMemberRepository

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

    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id)); // null이어도 감쌀 수 있다. 클라에서 무언갈 할 수 있
    }

    @Override
    public Optional<Member> findByNames(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                .findAny(); // 루프를 돌면서 없으면 optional에 null이 포함되서 반환
    }

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }
}

MemoryMemberRepositoryTest

public class MemoryMemberRepositoryTest {
    MemoryMemberRepository repository = new MemoryMemberRepository();

    @Test
    void save() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).get();
        assertThat(member).isEqualTo(result);
    }

    @Test
    void findByName() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        Member result = repository.findByNames("spring1").get();

        assertThat(result).isEqualTo(member1);
    }

  @Test
    void findAll() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);
    }
}

모든 테스트는 메서드마다 독립적으로 실행되어야 한다.
순서에 의존도가 생기면 안된다.

@AfterEach
void afterAll() {
    repository.clearStore();
}

때문에 하나의 테스트가 실행될 때 마다 초기화를 진행해준다.

테스트 코드없이 개발은 불가능하다.

MemberService

public class MemberService {
    private final MemberRepository memberRepository = new MemoryMemberRepository();

    public long join(Member member) {
        validateDuplicatedMember(member);

        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicatedMember(Member member) {
        // 중복 회원은 안된다.
        memberRepository.findByNames(member.getName())
                .ifPresent(m -> {
                    throw new IllegalArgumentException("이미 존재하는 회원입니다.");
                });
    }

    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

하지만 Optional을 바로 반환하는 것은 좋지 않다.

// 중복 회원은 안된다.
memberRepository.findByNames(member.getName())
  .ifPresent(m -> {
    throw new IllegalArgumentException("이미 존재하는 회원입니다.");
  });

ifPresent() 를 사용해 바로 확인.

  • 서비스 클래스는 비즈니스적인 네이밍을 써야한다.

그런데 서비스 테스트와 레포지토리 테스트에서 각각의 MemoryMemberRepository 인스턴스를 생성해 테스트하고 있다.
현재는 static으로 선언되어 있지만, 이치에 맞지 않는 현상.
생성자를 통해 외부에서 주입하도록 한다.

생성자 주입(DI)

@BeforeEach
void beforeEach() {
    memberRepository = new MemoryMemberRepository();
    memberService = new MemberService(memberRepository);
}

각 테스트 실행 전에 호출되며 테스트가 서로 영향 없이 항상 새로운 객체를 생성하고 의존관계도 새로 맺어준다.

Controller 에서 요청을 받고, Service에서 비즈니스 로직을 처리하고, Repository에서 데이터를 저장하고 이게 정형화된 로직이다.

스프링이 관리를 하면 애플리케이션이 포함된 하위 패키지 내에서 스프링이 컴포넌트를 스캔한다.
스프링은 스프링 컨테이너에 스프링 빈을 등록할 때, 기본으로 싱글톤으로 등록한다. (메모리도 절약된다.)

4. 스프링 빈과 의존관계


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

생성자에 @Autowired가 있으면 스프링이 연관된 객체를 스프링 컨테이너에서 찾아서 넣어준다.
객체 의존 관계를 외부에서 넣어주는 것을 DI라고 한다.

스프링 빈을 등록하는 방법

  • 컴포넌트 스캔과 자동 의존관계 설정
  • 자바 코드로 직접 스프링 빈 등록하기

@Component 가 있으면 스프링 빈으로 자동 등록된다. (@Controller, @Service, @Repository).
생성자에 @Autowired 를 사용하면 객체 생성 시점에 스프링 컨테이너에서 해당 스프링 빈을 찾아서 주입한다.
생성자가 1개만 있으면 @Autowired 는 생략 가능하다.

스프링은 스프링 빈을 등록할 때 기본으로 싱글톤으로 등록한다. (유일하게 하나만 등록).
따라서 같은 스프링 빈이면 모두 같은 인스턴스다.

@Autowired 를 통한 DI는 스프링이 관리하는 객체(빈)에서만 동작한다.

DI에는 필드 주입, setter 주입, 생성자 주입 3가지가 있는데,
의존 관계가 동적으로 변하는 경우는 거의 없으므로 생성자 주입을 권장한다.

5. 회원 관리 예제 - 웹 MVC 개발


요청이 올 경우 스프링 컨테이너에서 컨트롤러를 찾고 없으면 static 파일을 찾는다.

6. 스프링 DB 접근 기술


jdbc만 사용할 경우 중복이 너무 많으니 spring에서는 jdbc templates를 제공한다.

sql을 다루지 않고, 쿼리 없이 객체를 db에 저장할 수 있다.

jpa를 편리하게 쓸 수 있도록 감싼 것이 스프링 JPA

SOLID - 개방 폐쇄 원칙(OCP)

확장에는 열려있고, 수정과 변경에는 닫혀있다.
스프링의 DI를 사용하면 기존 코드는 전혀 손대지 않고, 설정만으로 구현 클래스를 변경할 수 있다.

스프링 통합 테스트

지금까지는 순수 자바 코드만으로 테스트를 진행했는데,
스프링 부트가 모든 정보가 들고 있으니 스프링 컨테이너와 DB까지 연결한 통합 테스트를 진행해야 한다.

스프링 통합 테스트는 DI가 아닌 필드 인젝션을 사용한다.
테스트를 다른데서도 쓰이는 것이 아니라 한 클래스 내에서 사용하고 끝이기 때문이다.

@SpringBootTest
@Transactional
public class MemberServiceIntegrationTest {
    @Autowired
    MemberService memberService;
    @Autowired
    MemberRepository memberRepository;

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

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

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

    @Test
    void 중복회원예외() {
        //given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring");

        // when
        memberService.join(member1);

        // then
        assertThatThrownBy(() -> {
            memberService.join(member2);
        }).isInstanceOf(IllegalArgumentException.class);

        IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> memberService.join(member2));
        assertThat(e.getMessage().equals("이미 존재하는 회원입니다.")).isTrue();
    }
}
  • @SpringBootTest : 스프링 컨테이너와 테스트를 함께 실행한다.
  • @Transactional : 각각의 테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백한다. 이렇게 하면 DB에 데이터가 남지 않으므로 다음 테스트에 영향을 주지 않는다
  • @Commit : 테스트 케이스가 끝나면 커밋한다.

JDBC Templates

JDBC Templates를 바로 주입받을 수 없다.

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

JDBC Template은 템플릿 메서드 패턴이다.

@Overridepublic Optional<Member> findById(Long id) {    List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);    return result.stream().findAny();}private RowMapper<Member> memberRowMapper() {  return (rs, rowNum) -> {    Member member = new Member();    member.setId(rs.getLong("id"));    member.setName(rs.getString("name"));    return member;  };}

query 수행 결과는 RowMapper로 맵핑을 해주어야 한다.
객체 생성은 RowMapper에서 한다.

JPA

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

build.gradle

dependencies {implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'implementation 'org.springframework.boot:spring-boot-starter-web'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는 인터페이스다. Hibernate는 이의 구현체이다.

Member

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

데이터의 id를 자동으로 생성해주는 것을 identity 전략이라고 한다.

JpaMemberRepository

@Override
public List<Member> findAll() {
    return em.createQuery("select m from Member m", Member.class)
            .getResultList();
}
  • "select m from Member m" : jpql이라는 쿼리. 객체를 대상으로 쿼리를 날린다. 자동으로 쿼리문이 된다.

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

스프링 Data JPA

스프링 부트와 JPA만 사용해도 개발 생산성이 정말 많이 증가하고, 개발해야할 코드도 확연히 준다.
여기에 스프링 데이터 JPA를 사용하면, 리포지토리에 구현 클래스 없이 인터페이스 만으로 개발을 완료할 수 있습니다.
그리고 반복 개발해온 기본 CRUD 기능도 스프링 데이터 JPA가 모두 제공합니다.
따라서 개발자는 핵심 비즈니스 로직을 개발하는데, 집중할 수 있습니다.
실무에서 관계형 데이터베이스를 사용한다면 스프링 데이터 JPA는 이제 선택이 아니라 필수 입니다

스프링 데이터 JPA는 JPA를 편리하게 사용하도록 도와주는 기술이다.

스프링 JPA가 인터페이스에 대한 구현체를 자동으로 만들어낸다.

구현 클래스 작성 필요 없이 인터페이스 이름 만으로도 개발이 가능하다.

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

7. AOP


필요한 상황

  • 모든 메소드의 호출 시간을 측정하고 싶을 때
  • 공통 관심 사항 vs 핵심 관심 사항
  • 회원 가입 시간, 회원 조회 시간을 측정하고 싶을 때

메서드 별로 수행 시간을 찍어본다.

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

문제

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

AOP (Aspect Oriented Programming) 적용

공통 관심 사항과 핵심 관심 사항을 분리한다.

@Aspect@Componentpublic class TimeTraceAop {    @Around("execution(* hello.hellospring..*(..))")    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {        long start = System.currentTimeMillis();        System.out.println("START: " + joinPoint.toString());        try {            return joinPoint.proceed();        } finally {            long finish = System.currentTimeMillis();            long timeMs = finish - start;            System.out.println("END: " + joinPoint.toString() + " " + timeMs + "ms");        }    }}
  • 회원가입, 회원 조회등 핵심 관심사항과 시간을 측정하는 공통 관심 사항을 분리한다.
  • 시간을 측정하는 로직을 별도의 공통 로직으로 만들었다.
  • 핵심 관심 사항을 깔끔하게 유지할 수 있다.
  • 변경이 필요하면 이 로직만 변경하면 된다.
  • 원하는 적용 대상을 선택할 수 있다 (@Around("execution(* hello.hellospring.service.*(..))"))

AOP가 있으면 프록시를 만들어 가짜 빈을 만든다.
가짜 빈에서 joinPoint.proceed()가 일어나면 실제 빈을 호출해준다.

image

image

콘솔에 출력해보면 실제 MemberService가 아닌 Proxy가 출력되는 것을 볼 수 있다.

참고 자료