본문 바로가기
Backend/인프런 강의: 김영한 스프링

스프링 입문: 섹션 1부터 4까지

by ccooll 2023. 11. 17.

섹션 1: 프로젝트 환경설정

 

1. 핵심 라이브러리

Gradle은 의존관계가 있는 라이브러리를 함께 다운로드 함.

  • 스프링 부트 라이브러리
  1. spring-boot-starter-webspring-webmvc : 스프링 웹 MVC
  2. spring-boot-starter-tomcat : 톰캣 (웹서버)
  3. spring-boot-starter-thymeleaf : 타임리프 템플릿 엔진(View)
  4. spring-boot-starter(공통) : 스프링부트 + 스프링 코어 + 로깅spring-boot-starter-logging > logback/slf4j
  5. —> 위치 : [Gradle > Dependencies > complileClasspath >> ch.qos.logback:logback-classisc:1.4.11 / org.slf4j:slf4j-api:2.0.9]
  6. spring-boot > spring-core

 

  • 테스트 라이브러리
  1. spring-boot-starter-test—> 위치 : [Gradle > Dependencies > testComplileClasspath >> org.junit.jupiter:junit-jupiter:5.9.3 (5버전을 요즘에는 많이 씀)]assertj : 테스트 코드를 좀 더 편하게 작성하게 도와주는 라이브러리
  2. spring-test : 스프링 통합 테스트 지원
  3. mockito : 목 라이브러리
  4. junit : 테스트 프레임워크

 

2. View 환경설정

  • return: hello —> resources > templates > hello.html (resources:templates/ 밑에 있는 파일을 찾아서 랜더링 함)
  • 컨트롤러에서 리턴 값으로 문자를 반환하면 ‘viewResolver’가 화면을 찾아서 처리함‘resources:templates/’ + {ViewName} + ‘.html’ —> ex) resources:templates/hello.html
  • 스프링 부트 템플릿엔진 기본 viewName 매핑

 

 

3. 빌드하고 실행하기

cmd 실행

D:

cd D:\study\hello-spring\hello-spring

gradlew

gradlew build

cd D:\study\hello-spring\hello-spring\build\libs

dir

java -jar hello-spring-0.0.1-SNAPSHOT.jar

 

서버 배포할 때는 hello-spring-0.0.1-SNAPSHOT.jar 파일 복사해서 서버에 넣고 java -jar.exe 실행

→ 서버에서도 스프링이 동작하게 됨

 

 

 


 

섹션 2: 스프링 웹 개발 기초

  • 정적 컨텐츠 : 파일을 그대로 웹 브라우저에 내려줌
  • MVC : 모델, 뷰, 컨트롤러 (컨트롤러 : 모델, 템플릿 엔진, 화면)
  • 템플릿 엔진 : 서버에서 프로그래밍해서 HTML을 동적으로 바꿔서 내리는 것
  • API : JSON이라는 데이터 포맷으로 클라이언트한테 데이터를 전달하는 방식(요즘 API 방식)

 

1. 정적 컨텐츠

① 웹 브라우저에서 localhost:8080/hello-static.html → 내장 톰캣 서버

② 내장 톰캣 서버 → 스프링

③ 먼저 컨트롤러 쪽에서 hello-static 이 있는지 찾아봄(컨트롤러가 우선순위를 가짐)

④ hello-static이라는 컨트롤러 없음 → resources:static/hello-static.html을 찾음

⑤ 있으면 반환

 

 

2. MVC와 템플릿 엔진

MVC : Model, View, Controller

// HelloController

@GetMapping("hello-mvc")
    public String helloMvc(@RequestParam(value = "name") String name, Model model){
        model.addAttribute("name", name);
        return "hello-template";
    }

 

  • @RequestParam(value = "name") String name: 이 어노테이션은 요청 URL에서 "name"이라는 쿼리 매개변수의 값을 추출하여 name 변수에 바인딩합니다.
  • Model model: Model 객체는 Spring에서 제공되며, 컨트롤러에서 뷰로 데이터를 전달하는 데 사용됩니다.
  • model.addAttribute("name", name): 이 라인은 "name"이라는 속성을 모델에 추가하고 해당 값으로 요청에서 받은 name 매개변수를 설정합니다.

① localhost:8080/hello-mvc?name=spring! 했을 때 name = spring!

② 컨트롤러에서, String name 에서 name → spring! 으로 바뀜

③ model.addAttribute(attributeName: “name”, name); 에서 name → spring!으로 바뀐 후 모델에 담김

④ return “hello-template”에서 hello-template으로 넘어감

 

// hello-template.html

<html xmlns:th="http://www.thymeleaf.org">
<body>
	<p th:text="'hello ' + ${name}">hello! empty</p> // hello! empty?
</body>

⑤ ${name} 에서 name → spring!으로 바뀜

    (이때 달러 표시($)는 모델에서 값을 꺼내는 것. 모델의 키값이 name인 것에서 값을 꺼냄)

 

 

+) thymeleaf 템플릿의 장점 : HTML을 그대로 쓰고 그 파일을 서버 없이 바로 열어볼 수 있음

( 방법 : 코드 copy > copy path > Absolute Path )

이때 템플릿 엔진으로 동작을 하면 hello empty 위치에 있는 값으로 내용이 치환되어 보여짐

 

 

3. API

@GetMapping("hello-string")
@ResponseBody
	   public String helloString(@RequestParam("name") String name){
        return "hello " + name;
    }
  • @ResponseBody : http의 응답 body에 이 데이터를 직접 넣어주겠다는 의미
  • 뷰 없이 이 문자가 그대로 내려감, 페이지 소스 보기에도 코드 없음

 

 

최근에는 거의 JSON 방식

+) JSON : 일반적으로 서버에서 클라이언트로 데이터를 보낼 때 사용하는 양식. 클라이언트가 사용하는 언어에 관계 없이 통일된 데이터를 주고받을 수 있도록, 일정한 패턴을 지닌 문자열을 생성해 내보내면 클라이언트는 그를 해석해 데이터를 자기만의 방식으로 온전히 저장, 표시할 수 있게 된다.

 

 

- JSON 예제 : 한 사람에 관한 정보를 갖는 JSON 객체

  키-값 쌍(이름:값)의 패턴으로 표현된다.

{
    "이름": "홍길동",
    "나이": 55,
    "성별": "남",
    "주소": "서울특별시 양천구 목동",
    "특기": ["검술", "코딩"],
    "가족관계": {"#": 2, "아버지": "홍판서", "어머니": "춘섬"},
    "회사": "경기 수원시 팔달구 우만동"
 }

 

 

+) Getter Setter 단축기 : Alt + Insert

 


 

섹션 3: 회원 관리 예제 - 백엔드 개발

 

1. 비즈니스 요구사항 정리

- 데이터 : 회원id, 이름

- 기능 : 회원 등록하기, 회원 조회하기

- 아직 데이터 저장소가 선정되지 않았다고 가정함.

 

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

 

- 클래스 의존 관계

아직 데이터 저장소 선정 안됨 → 인터페이스로 구현 클래스 변경 가능하도록 설계(MemberRepository)

초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용(Memory MemberRepository)

 

 

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

① 회원 도메인

main/java/hello.hellospring/domain/Member.java

@Id 
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;        // 시스템에 저장을 할 때 시스템이 정해주는 id

private String name;    // 고객이 회원가입할 때 적는 이름

 

- id : 시스템에 저장할 때 시스템이 정해주는 id

- name : 고객이 회원가입할 때 적는 이름

 

 

② 회원 리포지토리

main/java/hello.hellospring/repository/MemberRepository.java

public interface MemberRepository {
    // save : 회원이 저장소에 저장됨
    Member save(Member member);

    // findById, findByName 으로 저장소에서 찾아올 수 있음
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    
    // findAll : 지금까지 저장된 모든 회원 리스트를 다 반환해줌
    List<Member> findAll();
}

 

- save : 회원이 저장소에 저장됨

- findById, findByName : 저장소에서 찾아올 수 있음

- findAll : 지금까지 저장된 모든 회원 리스트를 다 반환해줌

 

 

main/java/hello.hellospring/repository/MemoryMemberRepository.java

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);   // id setting
        store.put(member.getId(), member);      // 시스템에 저장 → map에 저장됨
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                .findAny();
    }

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

    public void clearStore(){
        store.clear();
    }
}

 

- sequence : 0, 1, 2 등 키값을 생성해줌

- 실무에서는 Long 보다는 동시성 문제를 고려해서 AtomicLong 등으로 함

- null이 반환될 가능성이 있으면 Optional로 감쌈.

- Optional.ofNullable : null이어도 가능해짐

 

 

save

private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    @Override
    public Member save(Member member) {
        member.setId(++sequence);   // id setting
        store.put(member.getId(), member);      // 시스템에 저장 → map에 저장됨
        return member;
    }

 

- 'store'는 'Member' 객체를 저장하는 데 사용되는 HashMap 타입의 맵이다. 이 맵은 'Long' 형식의 키(여기서는 ID)와 'Member' 객체를 값으로 가진다.

- 'sequence'는 'Member' 객체의 고유한 ID를 생성하는 데 사용되는 변수. 각 save 호출 시마다 이 값이 증가하여 고유한 ID 생성한다.

- memser.setId(++sequence) : Member 객체의 ID 설정, sequence 값을 1씩 증가시키면서 고유한 ID 생성

- store.put(member.getId(), member) : Member 객체를 store 맵에 저장한다. ID가 키로 사용되며, 해당 ID에 대응하는 Member 객체가 값으로 저장된다.

- return member : 메서드에 저장된 Member 객체를 반환한다.

 

 

findById

@Override
public Optional<Member> findById(Long id) {
    return Optional.ofNullable(store.get(id));
}

 

- id를 사용하여 store 맵에서 해당하는 멤버를 찾아 Optional로 감싸서 반환한다.

- Optional.ofNullable은 null인 경우 빈 Optional을 반환하며, 그렇지 않은 경우 해당 멤버를 감싸서 반환한다.

 

findByName

@Override
public Optional<Member> findByName(String name) {
    return store.values().stream()
            .filter(member -> member.getName().equals(name))
            .findAny();
}

 

- name을 사용하여 store 맵에서 해당하는 멤버를 찾아 Optional로 감싸서 반환한다.

- store.values() 는 store 맵에서 값들만 가져온다. 여기서 값은 Member 객체들이다.

- stream()을 사용하여 맵의 값들을 스트림으로 변환한다. 스트림은 데이터를 처리하기 위한 일련의 연속적인 작업을 지원하는 Java 8 이후의 기능이다.

- filter를 통해 주어진 이름과 일치하는 멤버를 찾는다.

- findAny 는 찾은 멤버 중 아무거나 하나를 반환한다.

 

findAll

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

 

- 모든 멤버를 반환한다.

- new ArrayList<>(...) : store.values()에서 반환된 값들을 새로운 ArrayList에 담아준다

- 최종적으로 새로 생성된 ArrayList를 반환한다.

 

 

 

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

JUnit 프레임워크로 테스트 코드를 만들고 테스트 코드 자체를 실행

 

test/java/hello.hellospring/repository/MemoryMemberRepositoryTest

 

save 테스트

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

        repository.save(member);

        Member result = repository.findById(member.getId()).get();
        
        // Assertions.assertEquals(member, result);
        assertThat(member).isEqualTo(result);
    }

 

- member 생성

- 이름 spring으로 설정

- repository에 member 저장

- repository에서 member의 id와 같은 멤버를 찾아 꺼내기

- Assertions.assertEquals(expected, actual) : member(expected)와 result(actual)가 같은지

- Assertions.assertThat(member).isEqualsTo(result) 로 많이 씀

 

- 이때 Assertions에서 Alt+Enter → Add on-demand static import for ~~ 클릭

  → import static org.assertj.core.api.Assertions.*; 추가되고, assertThat(member).isEqualTo(result)로 간단하게 입력 가능

 

 

 

findByName

    @Test
    public 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.findByName("spring1").get();

        assertThat(result).isEqualTo(member1);

    }

 

- member1, member2 생성

- member1 이름 : spring1, member2 이름 : spring2

- repository에서 spring1 이름을 가진 멤버 꺼내기

- result와 member1이 같은지 확인

 

 

findAll

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

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

        List<Member> result = repository.findAll();

        assertThat(result.size()).isEqualTo(2);
    }

 

- repository에 member1(spring1), member2(spring2) 2명 저장

- repository의 모든 멤버 꺼내기

- result.size()가 2 인지 확인

 

 

 

error!

- save, findByName, findAll 을 모두 실행하면 에러가 뜸

- 실행순서 : findAll() → findByName() → save() 실행순서는 보장되지 않음

 

why?

findAll()이 실행될 때 spring1, spring2가 저장됨

그래서 findByName()이 실행될 때 이전에 저장했던 spring1이 나와버림

 

해결방법?

테스트가 하나 끝나고 나면 이 데이터를 클리어 해줘야 함.

→ 테스트 끝날 때마다 repository 를 깔끔하게 지워주는 코드 추가

 

    @AfterEach
    public void afterEach(){
        repository.clearStore();
    }
    public void clearStore(){
        store.clear();
    }

 

>> 테스트가 실행이 되고 끝날 때마다 한 번씩 저장소를 다 지움 → 순서와 상관 없어짐 → 해결

 

※ 테스트는 서로 그 순서와 관계없이 서로 의존관계 없이 설계가 되어야 함.

 

+) TDD : 테스트 주도 개발

+) Quick Fix, show intention actions 단축키 : Alt + Enter

    Rename 단축키 : Shift + F6

 

 

 

 

3. 회원 서비스 개발

main/java/hello.hellospring/service/MemberService.java

 

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


    /* 회원 가입 */
    public Long join(Member member){
        // 같은 이름이 있는 중복 회원은 가입 X
        validateDuplicateMember(member);    // 중복 회원 검증

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

    }

    private void validateDuplicateMember(Member member) {
        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);
    }
}

 

 

 

+) 변수 추출하기 단축키 : Ctrl + Alt + V

    refactor this 단축키 : Ctrl + Alt + Shift + T

 

 

 

 

4. 회원 서비스 테스트

 

test/java/hello.hellospring/repository/MemberServiceTest.java

 

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

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

    @AfterEach
    public void afterEach(){
        memberRepository.clearStore();
    }


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

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

        // then
        Member findMember = memberService.findOne(saveId).get();
        Assertions.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));
        
        // then
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

    }
}

 

- afterEach() : 각 테스트 끝나면 clearStore()

 

 

 

중복 회원 예외

    @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));
        
        // then
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }

 

- given

: 두 개의 Member 객체를 생성하고 각각에 동일한 이름("spring")을 설정한다

 

- when

 memberServcie.join(member1) 을 호출하여 첫 번째 회원 가입시킨다.

 memberService.join(member2) 을 호출하면 예외가 발생해야 한다.

 

- then

 assertThrows 를 사용하여 IllegalStateException 이 발생하는지 확인한다

 발생한 예외의 메시지가 "이미 존재하는 회원입니다." 와 일치하는지 확인한다

 

 


 

섹션 4: 스프링 빈과 의존관계

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

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

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

 

 

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

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

 

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

main/java/hello.hello-spring/controller/MemberController.java

@Controller
public class MemberController {

    private final MemberService memberService;

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

 

- @Controller 가 있으면 자동으로 스프링 빈으로 등록

- 생성자에 @Autowired가 있으면 스프링이 연관된 객체를 스프링 컨테이너에서 찾아서 넣어준다

  → 이렇게 객체 의존관계를 외부에서 넣어주는 것을 DI(의존성 주입) 이라고 한다.

 

 

 

 

- 컴포넌트 스캔 원리

 @Component 애노테이션이 있으면 스프링 빈으로 자동 등록됨

 → @Controller 컨트롤러가 스프링 빈으로 자동 등록된 이유도 컴포넌트 스캔 때문.

 

- @Component를 포함하는 애노테이션 

 @Controller

 @Service

 @Repository

 → 스프링 빈으로 자동 등록됨

 

 

// 회원 서비스 스프링 빈 등록

@Service
public class MemberService {

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

 

- 생성자에 @Autowired를 사용하면 객체 생성 시점에 스프링 컨테이너에서 해당 스프링 빈을 찾아서 주입함

- 생성자가 1개만 있으면 @Autowired는 생략 가능

 

 

 

// 회원 리포지토리 스프링 빈 등록

@Repository
public class MemoryMemberRepository implements MemberRepository {}

 

→ memberService, memberRepository가 스프링 컨테이너에 스프링 빈으로 등록됨

 

 

 

 

 

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

 

 @Configuration
 public class SpringConfig {
 
     @Bean
     public MemberService memberService() {
         return new MemberService(memberRepository());
     }
     
     @Bean
     public MemberRepository memberRepository() {
         return new MemoryMemberRepository();
     }
 }