seungriyou / spring-study

자바 스프링 부트를 배워봅시다 🔥
0 stars 0 forks source link

[강의 정리] 01. 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 #4

Open seungriyou opened 9 months ago

seungriyou commented 9 months ago

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

Contents

seungriyou commented 9 months ago

1. 프로젝트 환경 설정

이전과는 다르게 tomcat 웹 서버를 임베디드 하고 있다.

image


build 시에는 다음의 과정으로 생성된 파일을 실행하기만 하면 된다.

./gradlew build
cd build/libs
java -jar hello-spring-0.0.1-SNAPSHOT.jar

# build 폴더 삭제
./gradlew clean
# build 폴더 삭제 후 재빌드
./gradlew clean build
seungriyou commented 9 months ago

2. 스프링 웹 개발 기초

웹 개발의 세 가지 방법

  1. 정적 컨텐츠

    서버에서 뭐 하는 것 없이 파일을 그대로 브라우저로 내려주는 것이다.

  2. MVC와 템플릿 엔진

    Model-View-Controller (모델, 템플릿 엔진, 화면)

    서버에서 프로그래밍해서 HTML을 동적으로 바꾸어서 전달한다.

    요즘에는 이 패턴으로 많이 개발한다.

  3. API

    JSON과 같은 데이터 구조로 클라이언트에게 데이터를 전달한다.

    안드로이드, Vue, React 등을 쓸 때에도 API로 데이터만 내려주면 화면은 클라이언트가 알아서 그린다.

    서버끼리만 통신할 때도 데이터만 주고 받으면 되기 때문에 사용한다.

[!TIP] 크게 HTML로 내리냐, API를 통해 데이터를 바로 내리냐 중에서 생각하면 된다.


[1] 정적 컨텐츠

image

스프링은 우선 hello-static 관련 컨트롤러를 먼저 찾아본다.

없으면 static 리소스에서 찾는다.


[2] MVC와 템플릿 엔진

[!Note] 템플릿 엔진이 변환한 HTML 파일을 웹 브라우저에 반환한다.

image

http://localhost:8080/hello-mvc?name=spring!


[3] API

[!Note] 데이터 그 자체를 반환한다. (view 필요 X)

컨트롤러에서 (1) 객체를 반환하면서 (2) @ResponseBody annotation을 달아둔다면 디폴트로 해당 데이터를 JSON으로 변환한다. (예전에는 xml 방식도 사용)

API 방식의 진가는 다음과 같이 객체 자체를 반환할 때에 알 수 있다.

@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로 만들면 class 안에서 또 class를 만들어서 사용할 수 있다.
    static class Hello {
        private String name;

        public String getName() {
            return name;
        }

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

다음의 URL로 접속하면 JSON 방식으로 데이터가 전달되었음을 확인할 수 있다.

http://localhost:8080/hello-api?name=spring!

image

image

스프링 컨테이너는 @ResponseBody annotation을 통해 HTML 파일을 찾지 않고,

컨트롤러에서 반환되는 객체를 HTTP response body에 담아 데이터 자체를 전달한다.

이때, 디폴트로 JSON 방식으로 데이터를 만들어서 반환한다.


그러면 이전처럼 ViewResolver가 동작하는 것이 아닌, HttpMessageConverter가 동작한다.

동작하여 클라이언트로 데이터를 전달한다.


[!Tip] (1) 클라이언트의 HTTP Accept 헤더(2) 서버의 컨트롤러 반환 타입 정보를 조합해서 HttpMessageConverter가 선택된다.


Java Bean 규약

getter / setter를 통해 private 필드를 public 메서드를 통해 접근하도록 한다.

→ 프로퍼티 접근 방식

seungriyou commented 9 months ago

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

회원 도메인과 리포지토리 생성

image

데이터베이스가 선정되지 않은 상황이므로 interface로 구현 클래스를 변경할 수 있도록 설계한다.

(우선은 가벼운 메모리 기반의 데이터 저장소)


테스트 코드

테스트 코드는 test/ 디렉토리 밑에 작성하고, 각 메서드 위에는 @Test annotation을 달아준다.

테스트 코드를 작성하는 방법은 주로 junit 또는 assertj를 사용하는 것이다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    // 메서드 실행이 끝난 후 repository 데이터를 정리하도록 콜백 메서드 정의
    @AfterEach
    public void afterEach() {
        repository.clearStore();
    }

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

        repository.save(member);

        // optional에서 값을 꺼낼 때는 get으로 꺼낼 수 있다. (좋은 방법은 아니지만 테스트 코드에서는 ok)
        Member result = repository.findById(member.getId()).get();

        /* <테스트 방법 #1> 직접 println 찍어보기 */
        System.out.println("result = " + (result == member));

        /* <테스트 방법 #2> junit의 Assertions 이용하기 */
        Assertions.assertEquals(member, null);

        /* <테스트 방법 #3> assertj의 Assertions 이용하기 (더 편리) */
        // (option + enter) 치고 static import 해두면 편리하게 메서드 이름으로만 사용 가능
        assertThat(member).isEqualTo(result);

    }

    ...
}

테스트를 클래스 단위로 수행할 때, 실행되는 메서드 순서는 보장할 수 없으므로, 각 메서드에서 사용한 데이터를 정리해주어야 한다. 따라서 메서드를 서로 의존 관계가 없도록 작성해야 하며, 하나의 테스트가 끝날 때마다 저장소나 공용소의 데이터들을 다시 지워주어야 한다.

테스트를 먼저 만들고 구현 클래스를 만들면서 맞추는 것을 TDD라고 한다.

테스트 코드에 대해서는 더 자세히 공부해야 한다!


회원 서비스 (비즈니스 로직)

[!Tip] 보통 서비스는 비즈니스에 의존적으로 설계하고, 리포지토리는 개발 관련 용어를 사용한다.

→ 네이밍 시 유의하자.


Optional 객체를 반환 받는 경우, 굳이 assignment를 사용할 필요 없이 다음과 같이

  1. 곧바로 ifPresent 등의 메서드를 사용하고
  2. 해당 부분을 메서드로 빼도록

작성하면 깔끔하다.

public class MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    /**
     * 회원 가입
     */
    public Long join(Member member) {
        /* 같은 이름이 있는 회원은 저장 X */
        validateDuplicateMember(member);    // 메서드를 따로 빼는 편이 낫다.

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

    private void validateDuplicateMember(Member member) {
        // 다음 결과는 optional로 반환되므로, 그 안에 값이 있으면 이미 이름이 같은 회원이 존재한다는 것 (ifPresent)
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

    ...
}


테스트 코드

[!NOTE] DI (Dependency Injection)

seungriyou commented 9 months ago

4. 스프링 빈과 의존 관계

스프링 컨테이너와 스프링 빈

스프링 컨테이너에 스프링 빈을 등록하는 이유

어떤 서비스를 여러 컨트롤러에서 사용하려는 상황을 가정해보자. 만약 여러 컨트롤러의 구현 클래스에서 해당 서비스 인스턴스를 새롭게 생성한다면, 여러 컨트롤러에서 각기 다른 서비스 인스턴스를 사용하게 될 것이다.

어떤 리포지토리를 여러 서비스에서 사용하려는 상황에서도 마찬가지로, 여러 서비스에서 각기 다른 리포지토리 인스턴스를 사용하게 될 것이다.

[!Note] 이러한 이유로 스프링은 스프링 컨테이너에 컨트롤러, 서비스, 리포지토리를 스프링 빈으로 등록하고, 각 스프링 빈은 기본적으로 싱글톤이다. (특별한 경우가 아니면 대부분 싱글톤을 사용한다.) 따라서 스프링 컨테이너에 스프링 빈을 유일하게 하나만 등록하여 공유하는 것이므로, 같은 스프링 빈이면 같은 인스턴스이다.

컨트롤러, 서비스, 리포지토리

  • 컨트롤러를 통해 외부 요청을 받고
  • 서비스에서 비즈니스 로직을 만들고
  • 리포지토리에서 데이터를 저장한다.


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

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


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

방법

A(ex. 컨트롤러)에서 B(ex. 서비스)를 사용하기 위해 의존성 주입하려는 경우

  1. B 클래스에 적절한 어노테이션을 달아준다.

    • 컨트롤러 클래스에는 @Controller, 서비스 클래스에는 @Service, 리포지토리 클래스에는 @Repository 어노테이션을 달아준다.
  2. A의 생성자에 @Autowired 어노테이션을 달아준다.

    • 이렇게 하면 스프링이 객체 생성 시점에 스프링 컨테이너에서 해당 스프링 빈(= 1번에서 등록된 B 인스턴스)을 찾아 주입한다.
    • 생성자가 단 1개라면, @Autowired를 생략할 수 있다.
    • @Autowired 어노테이션은 스프링 컨테이너에서 관리되는 객체에 대해서만 동작한다.
@Repository
public class MemoryMemberRepository implements MemberRepository {}
@Service
public class MemberService {
    private final MemberRepository memberRepository;

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

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


컴포넌트 스캔 원리

@Component 어노테이션이 있으면 해당 인스턴스는 스프링 빈으로 자동 등록되는데,

@Controller, @Service, @Repository 어노테이션은 모두 @Component 어노테이션을 포함하므로 마찬가지로 스프링 빈으로 자동 등록되게 된다.

이때, XXXXApplication 클래스가 위치한 패키지 하위의 클래스들에 대해서만 컴포넌트 스캔이 이루어진다.


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

방법

컨트롤러에는 그대로 @Controller, @Autowired 어노테이션을 두고,

서비스, 리포지토리는 어노테이션을 삭제한 후 SpringConfig 클래스에서 직접 스프링 빈으로 등록해보자.

package hello.hellospring;

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
    public MemberService memberService() {
        return new MemberService(memberRepository());   // MemberRepository를 넣어주어야 하므로
    }

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


의존성 주입(DI)의 세 가지 방법

A에 B를 주입하는 경우를 가정하자. A는 MemberController, B는 MemberService가 된다.

  1. 생성자 주입: A의 생성자에 @Autowired 어노테이션 달기

    private final MemberService memberService;
    
    @Autowired
    public MemberController(MemberService memberService) {
       this.memberService = memberService;
    }
  2. 필드 주입: 생성자 없이, 필드 선언 시에 앞에 @Autowired 어노테이션 달기

    @Autowired private final MemberService memberService;
  3. setter 주입: A에 B 필드에 대한 setter를 선언하고, @Autowired 어노테이션 달기

    setter는 public으로 열어두어야 하는데, 이렇게 되면 해당 필드를 중간에도 바꿀 수 있게 되어 문제가 발생할 수도 있다. 하지만 조립 시점에 한 번 셋팅이 되고 나면 그 후에 바꿀 일은 없기 때문에 굳이 이렇게 할 이유가 없다.

    private MemberService memberService; // final X
    
    @Autowired
    public void setMemberService(MemberService memberService) {
            this.memberService = memberService;
    }

[!Tip] 의존 관계가 실행 중에 동적으로 변하는 경우는 거의 없으므로 생성자 주입을 권장한다. 진짜 바뀌어야 할 때는 수정 후 다시 서버에 올린다.


실무에서는 어떤 방법을 사용하나?

주로 정형화된 컨트롤러, 서비스, 리포지토리 같은 코드는 컴포넌트 스캔 방법을 사용한다.

하지만 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하는 경우(ex. 현재처럼 MemoryMemberRepository를 추후에 다른 구현 클래스로 변경), 설정을 통해 스프링 빈으로 등록한다. (이렇게 하면 다른 코드의 수정 없이 설정 파일에서만 구현 클래스를 바꿔치기 할 수 있게 된다.)

seungriyou commented 9 months ago

6. 스프링 DB 접근 기술 (리포지토리 구현)

데이터베이스 설정: h2

설치 방법

  1. https://www.h2database.com/html/main.html 에서 설치 파일 다운로드 및 압축 풀기

  2. h2/bin에서 ./h2.sh 실행

  3. 브라우저로 접속 (안 되면 IP 주소 부분을 localhost로 변경)

  4. JDBC URL에 다음과 같이 입력 후 생성하고, test.mv.db가 생성되었는지 확인

    jdbc:h2:~/workspace/spring-study/spring-study/01-introduction/hello-spring/test
  5. 이후로는 여러 군데에서 동시 접속이 가능하도록 JDBC URL에서 tcp://localhost/를 추가하여 소켓을 통해 접속

    jdbc:h2:tcp://localhost/~/workspace/spring-study/spring-study/01-introduction/hello-spring/test


[1] 순수 JDBC (JdbcMemberRepository)

옛날에는 이렇게 했었다. 코드는 강의 안에서 복사해와서 붙여 넣고, 자세히 볼 필요 없다.

설정

  1. build.gradle에 추가 후 새로고침

    dependencies {
        ...
        implementation 'org.springframework.boot:spring-boot-starter-jdbc'
        runtimeOnly 'com.h2database:h2'
        ...
    }
  2. application.properties에 설정 정보 추가

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


의존 관계 수정

SpringConfig에서 MemberRepository 부분을 MemoryMemberRepository에서 JdbcMemberRepository로 변경한다.

이때, 필요한 DataSource는 마찬가지로 스프링 빈으로 관리되므로 주입 받기 위해 생성자 주입 + @Autowired를 사용한다.

이렇게 함으로써 다른 코드는 전혀 수정할 필요 없이 설정만으로 기능을 변경할 수 있는 것이다.

@Configuration
public class SpringConfig {

    // DataSource는 application.properties에 적힌 값을 토대로 스프링 빈으로 생성 및 관리되기 때문에
    // 마찬가지로 DI 해준다. (생성자 주입 + @Autowired)
    private DataSource dataSource;

    @Autowired
    public SpringConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());   // MemberRepository를 넣어주어야 하므로
    }

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

[!Note] 스프링 컨테이너는 객체지향적인 설계, 의존성 주입 등을 편리하게 지원해준다.

개방 폐쇄 원칙 (OCP, Open-Closed Principle): 확장에는 열려있고, 수정/변경에는 닫혀있다.


통합 테스트 (DB 까지)

이전까지는 순수 자바 코드 테스트였으나, 이제는 데이터베이스 커넥션 정보도 스프링 부트가 들고 있기 때문에 스프링 통합 테스트를 수행해야 한다.

[!Tip]

  • 테스트 코드이므로 의존성 주입 받을 때, 그냥 편하게 필드 주입을 받아도 된다.
  • beforeEach, afterEach 대신 @Transactional을 사용할 수 있다.


[!Note] 테스트의 종류

  • 단위 테스트: 순수한 Java 코드로, 최소한의 단위로 수행하는 테스트

    → 잘 할 수 있도록 훈련해야 한다! (좋은 테스트일 확률이 높다.)

  • 통합 테스트: DB, 스프링 컨테이너까지 연동하는 테스트


[2] 스프링 JdbcTemplate (JdbcTemplateMemberRepository)


[3] JPA (JpaMemberRepository)

설정

  1. build.gradle 추가

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  2. application.properties 추가

    JPA는 표준 인터페이스이고, Hibernate는 구현체이다.

    spring.jpa.show-sql=true // JPA가 생성하는 SQL문 출력
    spring.jpa.hibernate.ddl-auto=none // 객체를 기반으로 테이블을 자동으로 생성하는 기능 off
  3. 도메인 엔티티 클래스 위에 @Entity 어노테이션, id 필드 위에 @Id, @GeneratedValue 어노테이션을 추가한다. (→ JPA가 관리하는 엔티티)

    DB에 데이터를 넣으면 id와 같은 값을 DB 자동으로 생성해주는 방식을 아이텐티티 전략이라 한다.

    package hello.hellospring.domain;
    
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    
    @Entity
    public class Member {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String name;

    만약, 대응되는 테이블 컬럼 이름엔티티 클래스 필드 이름이 다르다면, 다음과 같이 @Column 어노테이션을 통해 테이블 컬럼 이름을 명시해주면 된다.

    @Column(name = "username")
    private String name;
  4. 서비스 계층에 @Transactional 어노테이션을 달아야 한다.

    JPA는 데이터를 저장 및 변경할 때 항상 트랜잭션으로 동작해야 한다.

    @Transactional
    public class MemberService {


코드 작성


설정 파일(SpringConfig)에서 구현체 변경

@Configuration
public class SpringConfig {

    // Jdbc, JdbcTemplate에서는 DataSource를 DI 해주었으나,
    // JPA에서는 EntityManager를 DI 해준다. (생성자 주입 + @Autowired)
    // EntityManager는 DataSource와 마찬가지로 application.properties에 적힌 정보를 토대로 스프링 빈으로 생성 및 관리된다.
    private EntityManager em;

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

    ...

    @Bean
    public MemberRepository memberRepository() {
        return new JpaMemberRepository(em);
    }
}


[4] 스프링 데이터 JPA (SpringDataJpaMemberRepository)

장점

[!Note] 스프링 데이터 JPA는 JPA를 편리하게 사용하도록 도와주는 기술이므로, JPA를 먼저 학습해야 한다.


코드 작성

  1. 자바 클래스가 아닌 인터페이스를 생성한다.
  2. JpaRepositoryMemberRepository를 다중 상속 받도록(extends) 하도록 하고 다음과 같이 작성한다.

    JpaRepository를 상속 받고 있으면 스프링 데이터 JPA가 구현체를 자동으로 만들어 스프링 빈에 등록해준다.

    public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
    
        @Override
        Optional<Member> findByName(String name);
    }


설정 파일(SpringConfig)에서 구현체 변경

MemberRepository를 상속 받고 있는 리포지토리 인터페이스의 구현체가 스프링 데이터 JPA에 의해 이미 스프링 빈으로 등록되어 있다.

따라서 SpringConfig에서 새로 생성 및 @Bean으로 등록할 필요 없이, 생성자로 주입만 받으면 된다.

@Configuration
public class SpringConfig {

    // MemberRepository를 상속 받고 있는 리포지토리 인터페이스의 구현체가 스프링 데이터 JPA에 의해 이미 스프링 빈으로 등록되어 있다.
    // 따라서 SpringConfig에서 새로 생성 및 @Bean으로 등록할 필요 없이, 생성자로 주입만 받으면 된다.
    private final MemberRepository memberRepository;

    @Autowired
    public SpringConfig(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository);   // MemberRepository를 넣어주어야 하므로
    }

}


JpaRepository 간략히 살펴보기

기본적인 메서드(save, findById, findAll, 기본 CRUD 등)들이 제공된다. 따라서 가져다 쓰기만 하면 된다.

하지만, PK로 찾는 것이 아닌 경우라면 이러한 공통 클래스로 구현할 수 없다. 바로 이러한 경우에 다음과 같이 @Override하여 선언하는 것이다.

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

여기에는 규칙이 있는데, 위와 같이 메서드 이름findByName이라고 설정하면, 다음과 같은 JPQL로 변환된다.

"select m from Member m where m.name = ?"

예를 들어 메서드 이름을 findByNameAndId라고 설정한다면 name과 id를 인자로 받아 select 하는 JPQL로 변환하게 된다.


[!Tip] 스프링 데이터 JPA를 통해 인터페이스 상에서 이름만으로 개발이 끝난다. 물론, 많이 복잡한 동적 쿼리Querydsl이라는 라이브러리를 사용하여 해결한다. 혹은 JPA가 제공하는 네이티브 쿼리JdbcTemplate, MyBatis를 사용할 수도 있다.

(실무에서는 대부분 스프링 데이터 JPA + Querydsl 조합으로 사용한다.)

seungriyou commented 9 months ago

7. AOP

AOP (Aspect Oriented Programming)

핵심 관심 사항공통 관심 사항(ex. 메서드별 시간 측정 로직)을 분리하는 방법이다.

이러한 사항들과 핵심 비즈니스 로직이 섞이게 되면 유지보수가 어렵고, 이를 별도의 공통 로직으로 만들기 어렵다.


코드 작성

  1. aop 패키지를 만들어서 TimeTraceAop를 작성한다. 이때, 클래스에 @Aspect 어노테이션을 달아준다.

  2. 스프링 빈에 등록하기 위해 컴포넌트 스캔 방식을 사용할 수도 있으나(@Component 어노테이션), AOP임을 명확하게 명시하기 위해 SpringConfig직접 스프링 빈에 등록하는 방식을 권장한다.

    🤔 이렇게 하니까 순환 의존 에러가 발생하는 것 같다.. 우선은 그냥 @Component로!

  3. TimeTraceAop의 메서드에 @Around() 어노테이션을 통해 공통 관심 사항을 타겟팅한다.

[!NOTE]

  • AOP를 통해 다음 메서드 호출 시마다 중간에서 인터셉트 해서 동작을 수행할 수도 있다.
  • 보통 @Around에는 패키지 단위로 설정한다.


장점

  1. 핵심 관심 사항(ex. 회원가입, 회원 조회)과 공통 관심 사항(ex. 시간 측정)을 분리할 수 있다.
  2. 시간 측정 로직을 별도의 공통 로직으로 만들 수 있다.
  3. 변경이 필요하면 해당 로직만 변경하면 된다.
  4. 원하는 적용 대상을 선택할 수 있다.


동작 원리 (프록시 방식의 AOP)

AOP를 적용한 후의 컨트롤러와 서비스 간 의존 관계는 다음과 같다.

즉, memberController는 실제 memberService가 아닌 프록시 memberService 객체를 주입 받으며, joinPoint.proceed() 코드를 만나야지만 실제 memberService가 사용되게 된다.

이러한 방식을 “프록시 방식의 AOP”라고 하며, 실제로 프록시 객체를 콘솔에 출력해서 확인할 수 있다.