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";
}
회원가입 프로세스에서 컨트롤러가 데이터를 입력받고 이를 서비스에 넘겨준다고 하면 이는 컨트롤러와 서비스간에 의존관계가 있다고 말할 수 있다. 스프링에서는 이러한 의존관계를 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란외부에서 두 객체 간의 관계를 결정해주는 디자인 패턴으로,인터페이스를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않도록 하고 런타임 시에 관계를 다이나믹하게 주입
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.*;
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와 일치하게 된다.
정적 컨텐츠의 경우 컨트롤러에서 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가 기본으로 등록되어 있음