이전에 회원관리에 대한 비즈니스 로직을 memberService를 통해 구현하였다.

그럼 이제 Front에서 회원 정보를 받아서 Controller에게 데이터를 넘겨주고 다시 Service에게 넘겨서 데이터를 저장하는 과정을 구현하도록 하겠다.

프론트 --> 컨트롤러 --> 서비스 --> 리포지토리

 

먼저 프론트 페이지 코드이다.


Hello Spring

회원 기능

회원 가입 회원 목록


 

회원 가입을 클릭하면 /members/new 를 컨트롤러에게 GET 방식으로 전달한다.

@Controller
public class MemberController {
    @GetMapping("/members/new")
    public String createForm() {
        return "members/createMemberForm";
    }
}

 

 

컨트롤러는 다시 프론트에게 (templates/)members/createMemberForm 경로의 html 파일을 넘겨준다.

그럼 프론트에서 아래와 같은 페이지가 나오게 된다.


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
    <form action="/members/new" method="post">
        <div class="form-group">
            <label for="name">이름</label>
            <input type="text" id="name" name="name" placeholder="이름을
입력하세요">
        </div>
        <button type="submit">등록</button>
    </form>
</div> <!-- /container -->
</body>
</html>

여기서 값을 입력하고 등록 버튼을 누르면 프론트는 컨트롤러에게

action="member/new", POST 방식으로 <"name" : 입력받은 값> 형태로 데이터를 넘겨준다.

    @PostMapping("/members/new")
    public String create(MemberForm form) {
        Member member = new Member();
        member.setName(form.getName());

        memberService.join(member);

        return "redirect:/";
    }
public class MemberForm {
    private String name;

    public String getName() {
        return name;
    }

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

그럼 컨트롤러는 MemberForm 객체를 생성하고 프론트에서 받은 <"name" : 입력받은 값> 을 객체에 저장한다.

이후 컨트롤러에서 또 Member 객체를 만들고 MemberForm의 name값을 받는다.

그리고 이 Member 객체를 memberService.join으로 넘기면 프론트에서 받은 값을 리포지토리에 저장할 수 있게 된다.

마지막으로 "redirect:/"를 통해 다시 홈화면으로 페이지를 변경한다.

 

 

 

이제 입력한 값이 정상적으로 리포지토리에 저장되었는지 확인해보도록 하겠다.

홈화면의 회원 목록 링크를 클릭하면 "/members/memberList" 경로로 이동한다.

    @GetMapping("/members")
    public String list(Model model) {
        List<Member> members = memberService.findMembers();
        model.addAttribute("members", members);
        return "members/memberList";
    }
<!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>

그럼 컨트롤러에서는 서비스의 findMembers 함수를 통해 저장된 member 리스트를 받는다.

그리고 이때 thymeleaf의 도움을 받을 수 있다!!

서비스에서 전달받은 member 리스트를 model에 추가한다.

이후 html에서 여러줄로 나타내기 위해 th:each="member : ${members}" 구문을 사용한다. 그럼 thymeleaf의 for each문이 돌면서 member.id와 member.name을 한줄한줄 출력하게 된다. 

<!DOCTYPE HTML>
<html>
<body>
<div class="container">
    <div>
        <table>
            <thead>
            <tr>
                <th>#</th>
                <th>이름</th>
            </tr>
            </thead>
            <tbody>
            <tr>
                <td>1</td>
                <td>spring1</td>
            </tr>
            <tr>
                <td>2</td>
                <td>spring2</td>
            </tr>
            </tbody>
        </table>
    </div>
</div> <!-- /container -->
</body>
</html>

출력 화면

-의존관계란 무엇일까?

회원가입 프로세스에서 컨트롤러가 데이터를 입력받고 이를 서비스에 넘겨준다고 하면 이는 컨트롤러와 서비스간에 의존관계가 있다고 말할 수 있다. 스프링에서는 이러한 의존관계를 Annotation을 통해 설정해 줄 수 있다.

 

-컴포넌트 스캔 방식(@Annotation)과 자동 의존관계 설정

@Controller
public class MemberController {

    private final MemberService memberService;

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

@Controller와 같이 Annotation을 달아주면 스프링 어플리케이션은 이 객체를 스프링 컨테이너에 빈으로 등록한다.

(위 코드의 경우 컨트롤러 빈으로 등록)

 

만약 스프링 컨테이너에 1개의 서비스가 존재하고 여러개의 컨트롤러가 이 서비스를 사용한다고 하면 어떻게 될까?

컨트롤러마다 동일한 서비스 객체를 만들어서 사용해야 할까?

스프링 빈이 있기 때문에 그럴 필요가 없다!!

위의 코드처럼 @Autowired를 통해 @Service로 선언된 아래 코드를 바로 사용할 수 있다!!

@Service
public class MemberService {

    private final MemberRepository memberRepository;

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

서비스 또한 마찬가지로 @Repository로 선언된 저장소와 의존관계로 설정해 줄 수 있다.

@Repository
public class MemoryMemberRepository implements MemberRepository {

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

이게 바로 DI(Dependency Injection)이다. 스프링이 동작하면서 class의 객체를 만들고 자동으로 의존 관계를 주입해준다.


DI란 외부에서 두 객체 간의 관계를 결정해주는 디자인 패턴으로, 인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않도록 하고 런타임 시에 관계를 다이나믹하게 주입

출처: https://mangkyu.tistory.com/150 [MangKyu's Diary]


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

Annotation을 사용하지 않고 스프링 컨테이너에 빈을 등록할 수 있다.

Application파일과 같은 경로에 Config파일을 만든다. 아래와 코드를 통해 MemberService, MemberRepository 클래스를 빈에 등록할 수 있다.

@Configuration
public class SpringConfig {

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }

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

이렇게 따로 config 파일에 빈을 등록하는 경우는 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하면 설정을 통해 스프링 빈으로 등록한다.

 

현재는 메모리 DB를 사용하고 있는데 나중에 DB를 변경할 경우 Config의 MemoryMemberRepository() 부분만 변경해주면 의존관계를 변경할 수 있다.

회원 관리 예제 구조

 

회원 관리 예제의 구조 구현을 위해서 아래와 같이 코드를 작성했다.

 

-도메인 회원 객체

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> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

-회원 리포지토리 메모리 구현체

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

구현한 코드가 잘 동작하는지 테스트하기 위해서는 테스트 코드를 작성해야 한다.

기본적으로 스프링 부트를 활용하면 src/test/java 경로가 만들어져 있어 경로에 테스트 코드를 작성하기만 하면 된다.

 

-테스트 코드

package hello.hellospring.repository;

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

import java.util.List;

class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

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

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

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

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

코드를 실행하면 각각의 메소드가 실행되게 된다. 이때 메소드의 실행 순서는 무작위여서 실행 순서에 따라 테스트가 원래 의도대로 실행되지 않을 수 있다.

예를 들어 findByName이 실행된 이후 findAll 메소드가 실행되면 result.size = 4 이므로 테스트가 실패하게 된다.

이를 방지하기 위해서 @AfterEach를 사용하면 테스트가 종료될 때 마다 이 기능을 실행한다. 이 예제에서는 메모리 DB에 저장된 데이터를 삭제한다.

(테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다!)

 

회원 서비스

서비스 부분은 비즈니스 로직이 구현되는 부분이다. 

 

-회원 서비스 개발

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();
*/
    
    private final MemberRepository memberRepository;

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

    /*
    회원 가입
    같은 이름이 있는 중복 회원X
    */
    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);
    }
}

-회원 서비스 테스트

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
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 = new MemberService();
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();
*/
    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("spring");

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

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

    @Test
    public void 중복_회원_예외() {
        //ginven
        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("이미 존재하는 회원입니다.");

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

        //then
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

Dependency Injection

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

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}
class MemberServiceTest {
/*
    MemberService memberService = new MemberService();
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();
*/
    MemberService memberService;
    MemoryMemberRepository memberRepository;

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

테스트 코드의 주석 처리된 기존 부분의 코드는 memberService의 리포지토리와 memberRepository가 서로 다른 객체였다. 물론 리포지토리 코드의 변수 부분은 static으로 다른 객체이더라도 문제가 없이 동기화 되지만, 이렇게 같은 의미를 가리키면서 다른 객체를 생성하는 것은 좋지 않다.

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

따라서 Dependency Injection(DI)를 통해 같은 memberService의 리포지토리와 생성한 memberRepository가 같게 만드는 것이 좋다.

 

@BeforeEach는 테스트 메소드가 실행되기 전에 실행되는 부분!

1) 메소드가 실행되기 전에 memberRepository 객체를 1개 만든다.

2) memberService 객체를 1개 만든다.

  2-1) memberService 객체의 생성자에 방금 만든 memberRepository를 넘긴다.

--> 이로써 memberService의 리포지토리가 생성한 memberRepository와 일치하게 된다.

1. 동적 컨텐츠

thymeleaf 템플릿 동작 흐름

웹 브라우저에서 GET 방식으로 hello를 요청하게 되면

Controller 에서 Model의 {data: hello!!} 라는 키:밸류 매칭을 만들게 된다.

이러한 모델의 키:밸류 매칭은 이후 리턴값인 hello.html의 {data} 부분에 'hello!!'라는 값이 들어가게 만든다.

그리고 리턴값으로 hello를 줘서 resources > templates에서 hello.html을 찾아 브라우저에 화면을 띄우게 된다.

@Controller
public class HelloController {
	@GetMapping("hello")
    public String hello(Model model) {
        model.addAttribute("data", "hello!!");
        return "hello";
    }
}
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Hello</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p th:text="'안녕하세요. ' + ${data}" >안녕하세요. 손님</p>
</body>
</html>

 

 

2. 정적 컨텐츠

정적 컨테츠 동작 흐름

정적 컨텐츠의 경우 컨트롤러에서 hello-static과 매핑되지 않기 때문에 컨트롤러에서 딱히 뭘 하지는 않는다.

그래서 바로 resources > static 에서 hello-static.html 을 바로 브라우저에 띄우게 된다.

 

결국! MVC와 템플릿 엔진은 html과 같은 자료를 서버에서 일부 변경(가공?)해서 클라이언트에게 전달해줌 -> 클라이언트별로 html 만들어서 전달 가능

(아직 초보자라 여기까지만 아는 것..ㅎㅎ)

 

또한 컨트롤러에서는 html 값을 가공해서 넘겨줄 수도 있지만 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 Hello {
        private String name;

        public String getName() {
            return name;
        }

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

API: html 자료로 주고 받지 않고 XML이나 JSON 형태의 파일을 주고 받음(최근에는 주로 JSON 형태)
1) 서버에서 '리액트/뷰'에 데이터를 넘겨줄 때 JSON형태의 파일을 넘겨줌.
2) 서버와 서버간 통신에서 JSON형태의 파일을 넘겨줌.
3) 안드로이드/IOS와 통신에서 JSON형태의 파일을 넘겨줌.
@ResponseBody: viewResolver를 사용하지 않고 HTTP BODY에 문자 내용을 직접 반환(그냥 리턴값 그대로)

 

@ResponseBody 사용 원리

- HTTP의 BODY에 문자 내용을 직접 반환

- viewResolver 대신에 HttpMessageConverter 가 동작

- 기본 문자처리: StringHttpMessageConverter

- 기본 객체처리: MappingJackson2HttpMessageConverter

- byte 처리 등등 기타 여러 HttpMessageConverter가 기본으로 등록되어 있음

 

#devtools

dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
   implementation 'org.springframework.boot:spring-boot-starter-web'
   implementation 'org.springframework.boot:spring-boot-devtools'
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

또한 build.gradle 파일에서 devtools 기능을 추가하면 html 파일을 변경하더라도 서버를 재실행하지 않고 변경된 결과값을 브라우저에서 볼 수 있다.

 

#빌드하고 실행하기

현재는 인텔리제이에서 서버를 실행했지만 만약 리눅스 환경처럼 에디터가 없을 경우는 서버 실행을 어떻게 해야 할까?

바로 이때 커맨드창에서 바로 실행하는 것이 필요하다.

윈도우의 경우 커맨드창에

cd (프로젝트 폴더) --> gradle.bat clean build --> java -jar (프로젝트명)-0.0.1-SNAPSHOT.jar

이러한 방식으로 빌드하고 실행할 수 있다.

CS 공부만 하자니 재미가 없어서...

백엔드 개발자의 필수 기술인 스프링을 병행해서 배워보려 한다.

국비 교육도 생각했지만 "이미 검증된 김영한님이 있는데 굳이 학원을 다닐 필요가 있을까...?"

라는 생각이 들었다.

그렇게 파트1?인 스프링 입문 강의를 시작!

 

1. 환경 설정

- 자바11 설치 / IntelliJ 설치

- 스프링 부트

  • ​프로젝트 선택

Project: Gradle Project

Spring Boot: 2.3.x

Language: Java

Packaging: Jar

Java: 11

  • Project Metadata

groupId: hello

artifactId: hello-spring

  • Dependencies: Spring Web, Thymeleaf

+ Recent posts