본문 바로가기

WINK-(Web & App)/Spring Boot 스터디

[2024-2 Spring Boot 스터디] 류상우 #1주차

반응형

프로젝트 환경설정

 

프로젝트 생성

https://start.spring.io/ 에서 기본 설정 후 IntelliJ IDEA에서 해당 파일을 열어주었다.

 

이후 ./src/main/java/hello.helloSpring/HelloSpringApplication 을 실행시키면 정상적으로 실행된 것을 확인할 수 있었다.


라이브러리 살펴보기

IntelliJ의 프로젝트 탭이나 Gradle 탭에서 해당 프로젝트의 외부 라이브러리를 살펴볼 수 있다. 특히 Gradle 탭에서는 라이브러리 간의 의 관계도 파악할 수 있다.

 

주로 사용되는 라이브러리는 이러한 것들이 있다.


View 환경설정

  • Welcome Page 만들기

Welcome Page란 도메인을 입력해 연결했을 때 나오는 가장 첫 화면인데 우선은 간단한 html 파일을 만들어 두었다.

 

이러한 Spring의 기능은 모두 외워서 활용하기 어려우니 Spring 공식 문서를 참고하여 작성하면 좋다.

 

  • Thymeleaft 템플릿 엔진 동작 확인

강의를 보고 받아쓰기를 열심히 해주면 이런 파일들이 생긴다.

//HelloController.java
package hello.helloSpring.controller;

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

@Controller
public class HelloController {

    @GetMapping("hello")
    public String hello(Model model) {
        model.addAttribute("data", "hello!!");

        return "hello";
    }
}
//hello.html
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
<p th:text="'안녕하세요. ' + ${data}">안녕하세요. 손님</p>
</body>
</html>

코드를 완벽히 이해하지는 못했지만, 내가 이해한 위 코드의 동작 방식은 이렇다.

우선, @GetMapping("hello") 에서의 Get은 Get Method 를 의미하기 때문에 url에 ./hello 를 치면 해당 파일을 매한다. 이후 Model 의 Attribute에 "data":"hello!!" 를 넣어 hello를 return 한다. 이 때 viewResolver가 동작하여 hello는 src/main/resources/templates/hello.html을 가르킨다. 그럼 hello.html이 받은 Model에서 data의 값을 참조하여 "안녕하세요. hello!!"가 페이지에 출력된다.

 

그런데 hello.html 파일을 살펴보면 p태그가 안녕하세요.손님을 감싸고 있는데 렌더링 된 것을 보면 th:text=" " 속의 텍스트가 출력된다. 혹시 Model 에 Attribute를 추가하지 않으면 저 텍스트가 나오나 했지만 그저 "안녕하세요. null"로 표시될 뿐이였다.

그렇다면 태그가 감싸고 있는 텍스트는 왜 있는 것일까 궁금해 알아보니 Thymeleaf가 정상적으로 렌더링되지 않았을 때 표시된다고 한다.


빌드하고 실행하기

빌드 후 실행하니 정상적으로 접속되는 걸 확인할 수 있었다.


스프링 웹 개발 기초

 

정적 컨텐츠 

정적 컨텐츠는 main/resources/static 경로에 html 파일을 만들면 된다.

이후 url에 /{name}.html을 입력하면 해당 html 파일을 확인할 수 있다.

이러한 정적 컨텐츠는 스프링 부트가 url을 받아서 path와 관련된 controller가 없다면 정적 컨텐츠를 반환한다고 한다.

그래서 test.html이라는 정적 컨텐츠를 만들고 위에서 만들었던 controller의 매핑 부분을 @GetMapping("test")로 수정해봤더니 여전히 정적 컨텐츠가 출력되었고 "test.html"로 수정하니 원하던 결과가 나왔다.

만약 정적 컨텐츠를 불러오는 방법이 모두 *.html 이라면 controller를 만들 때 굳이 신경쓰지 않아도 괜찮을 것 같다.


MVC와 템플릿 엔진

MVC: model - view - controller

각 기능들을 담당하는 부분을 구분하는 방식이다.

 

템플릿 엔진을 사용해보기 위해서 앞서 만들어뒀던 controller에 아래 코드를 추가한다.

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

        return "hello-template";
    }

 

이후 hello-mvc/Ryu 에 접속해보면

이렇게 파라미터의 값을 참조한 것을 볼 수 있다.

 

위 코드에서는 파라미터에 name의 값을 넣지 않으면 오류가 나는데 @requestParam() 안에 required = false 를 작성하면 해결된다. required 의 기본값이 true 라서 값이 없을 때 오류가 나던 것.

수정 후 /hello-mvc 에 접속하면 위와 같이 출력된다.


API

    @GetMapping("hello-string")
    @ResponseBody
    public String helloString(@RequestParam("name") String name) {
        return "hello " + name;
    }

controller에 위 코드를 추가 후에 url에 /hello-string?name=Ryu 를 작성하면 템플릿 엔진에서와 똑같은 결과가 나온다. 하지만 이 코드는 원래 작성된 템플릿을 통해 불러오는 것이 아니라 "hello " + name 이라는 문자열만 반환하기 때문에 페이지의 소스코드에서는 차이가 난다.

 

@ResponseBody  는 응답 http의 body에 해당 내용을 직접 넣어준다는 의미이다.

이 어노테이션 때문에 위와 같은 결과가 생긴다. 추가로 return에 "hello-template" 즉, 존재하는 템플릿을 작성해도 템플릿이 아닌 단순 string인 "hello-template"을 출력한다.

 

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

controller에 또 새로운 코드를 추가한 후 url에 /hello-api?name=Ryu 를 작성해보자

이러한 JSON 파일이 나온다.

json과 xml은 이전에도 많이 겪어봤으므로 굳이 알아보지는 않았다.

    static class Hello {
        private String name;

        public String getName() {
            return name;
        }

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

이 부분은 GetterSetter 라고 하는 프로퍼티 접근 방식으로, private인 name에 직접 접근하지는 못하고 getName()이나 setName()으로 접근이 가능하다. 상단 코드에서도 hello.setName(name)으로 접근한 걸 확인할 수 있다.

 

@ResponseBody를 사용할 때는 리턴할 때 viewResolver가 아닌 HttpMessageConverter가 동작한다. 리턴값이 그냥 string이라면 StringHttpMessageConverter가 동작하여 string을 그대로 반환하지만 객체라면 MappingJackson2HttpMessageConverter가 동작하여 JSON형식으로 반환한다.

여기서 Jackson2라는 건 객체를 JSON으로 변환해주는 라이브러리로 만약 구글의 Gson이라는 라이브러리를 쓰고싶다거나 JSON이 아닌 XML로 반환하고 싶다거나 하면 이러한 부분을 수정하면 된다.


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

비즈니스 요구 사항 정리

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

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

//member.java
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;
    }
}
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> findNyId(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

이 인터페이스는 앞서 설정한 시나리오인 데이터 저장소가 결정되지 않았음에 따른 파일이다.

//MemoryMemberRepository
package hello.helloSpring.repository;

import hello.helloSpring.domain.Member;

import java.util.*;

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> findNyId(Long id) {
        return Optional.ofNullable(store.get(id));
    }

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

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

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

개발한 기능을 테스트할 때 Java의 메인 메서드를 직접 실행하거나 웹엡의 컨트롤러를 통해 해당 기능을 실행하는데 이러한 방법은 여러 단점(소요 시간 많음, 반복 실행 힘듦, 여러 테스트 동시 진행 힘듦)이 있기 때문에 JUnit 이라는 프레임워크를 통해 테스트를 진행한다.

 

우선 test 디렉터리에 테스트 케이스를 작성해주고

앞서 작성한 save 기능이 잘 작동하는지 확인해보았다.

package hello.helloSpring.repository;

import hello.helloSpring.domain.Member;
import org.junit.jupiter.api.Test;

class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @Test //테스트 위해 필요
    public void save() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).get(); 
        //findById의 반환값이 Optional이므로 .get()을 사용함(바람직한 방법X)
        System.out.println("result = " + (result == member));
    }
}

정상적으로 작동하는 것을 확인했다.

 

여기서는 콘솔에 출력되는 텍스트로 확인을 했지만

Assert라는 기능을 통해 확인할 수도 있다.

import org.junit.jupiter.api.Assertions;
Assertions.assertEquals(member.getName(), result.getName());
import org.assertj.core.api.Assertions;
Assertions.assertThat(member).isEqualTo(result);

저장한 member와 찾은 member가 같은지 assertions로 확인하는 코드이다. assertj, 아래쪽을 더 자주 사용한다고 한다. 

result 부분을 null로 바꿨을 때 실패하는 모습도 확인할 수 있다.

 

다음은 findByName을 테스트할 차례이다.

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

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

        Member result = repository.findByName(member1.getName()).get();

        assertThat(result).isEqualTo(member1);
    }

 

 

Member result = repository.findByName(member2.getName()).get();
assertThat(result).isEqualTo(member1);

 

Member result = repository.findByName("spring1").get();
assertThat(result).isEqualTo(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);
}

여기서 findAill 부분만 테스트 하면 

이렇게 잘 되지만

모든 테스트를 실행하면 

원래는 잘  되던 곳에서 실패한다.

 

각 테스트가 별개로 작동하기 위해서 각 테스트가 끝나고 나면 데이터를 비워주어야 하는데 그 작업이 생략되어서 데이터가 꼬여 이런 오류가 난 것이다.

 

이를 해결하기 위해서 아래 코드들을 각 파일에 추가해준다.

//MemoryMemberRepositoryTest.java
@AfterEach //각 메서드가 끝났을 때 호출
public void afterEach() {
    repository.clearStore();
}
//MemoryMemberRepository.java
public void clearStore() {
    store.clear();
}

 

각 테스트들은 서로간의 의존 관계가 없어야 하기 때문에 이처럼 데이터 등을 모두 비워주는 작업을 해야한다.

 

여기서는 구현 클래스를 먼저 만들고 테스트를 진행 했는데 테스트를 먼저 만들고 구현하는 TDD라는 방식도 있다고 한다.


회원 서비스 개발

package hello.helloSpring.service;

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

import java.util.List;
import java.util.Optional;

public class MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    //회원가입
    public long join(Member member) {
        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);
    }
}

회원 서비스 테스트

테스트 코드를 만들 때 앞서 했던 것처럼 처음부터 직접 입력하는 것이 아니라

Ctril + Shift + T 를 눌러 간단히 틀을 만들어줄 수 있다.

그리고 이런 식으로 한글로 작성해도 괜찮다고 한다. 코드 봤을 때 상당히 어색하기는 한데 영어로 쓸 때보다 더 직관적인 것 같기는 하다.

 

테스트를 만들 때는 Given When Then 문법을 사용한다고 한다. Given 부분에는 어떤 것이 주어졌는데, When 부분에는 어떤 상황인지, Then 부분에는 결과를 작성해서 코드가 복잡해져도 목표를 간단히 알 수 있도록 이렇게 작성한다고 한다.

 

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

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

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

회원가입 기능 테스트이다.

그런데 회원 정보가 잘 저장되는 지는 알 수 있지만 중복 검증이 제대로 되는지는 알 수 없기 때문에 다른 테스트도 생성한다.

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

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

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

 

여기서 try catch 구문을 간단하게 만들 수 있는 코드가 있다.

IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

assertThrows()가 예외가 발생했는지 확인하고 메세지를 반환한다.

그럼 e의 메세지가 기댓값이 맞는지 검증한다.

 

MemoryMemberRepository memoryMemberRepository = new MemoryMemberRepository();

@AfterEach
void afterEach() {
    memoryMemberRepository.clearStore();
}

회원 리포지토리 테스트를 할 때와 마찬가지로 각 메서드가 종료될 때마다 데이터를 정리해야하는데

이렇게 작성해버리면 MemberService.java에서 생성한 레포와 MemberServiceTest.java에서 생성한 레포가 서로 다른 객체가 되어버린다.

 

MemoryMemberRepository가 static으로 선언이 되었기 때문에 여기서는 문제가 없지만 그래도 서로 같은 객체가 되기 위해서는 수정이 필요하다.

private final MemberRepository memberRepository;

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

 

MemberService memberService;
MemoryMemberRepository memoryMemberRepository;

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

MemberService가 외부의 객체를 참조하도록 만들고

각 테스트 시작 전에 memoryMemberRepository를 넣은 MemberService를 만들어주면 된다.

이러한 것을 DI(Dependency Injection): 의존성주입 이라고 한다.

반응형