1. 웹 개발의 종류
이번 장에서는 본격적으로 스프링을 이용해 웹 애플리케이션 개발을 해보고자 합니다. 시작에 앞서, 먼저 웹 개발의 종류를 먼저 살펴보도록 하죠. 종류는 크게 정적 컨텐츠, MVC와 템플릿 엔진 그리고 API를 개발하는 것이 있으며, 아래에서 각 항목별로 조금 더 설명을 드리겠습니다.
1.1 정적 컨텐츠
가장 먼저 정적 컨텐츠를 살펴보도록 하죠. 단어의 뜻 그대로 별다른 서버의 동작 없이 파일을 그대로 웹 브라우저에 보여주는 것들을 의미합니다. 대표적인 예시로는 앞선 장에서 예시로 했던 HelloSpring 의 index.html 웹 페이지 같은 것이 되겠습니다.
스프링에서는 정적 컨텐츠를 지원하는데, 이 때 "resources/static"이라는 폴더 이하에 저장된 컨텐츠를 기본적으로 사용하게 됩니다. 확인해 보기 위해 예시로 아래 HTML 코드를 저장한 후, 실행해 보겠습니다.
[src/resources/static/hello-static.html]
<!DOCTYPE html>
<html lang="ko-KR">
<head>
<meta http-equiv="Content-Type" content="text/html"; charset="UTF-8">
<title>Static Content</title>
</head>
<body>
정적 컨텐츠 입니다.
</body>
</html>
서버 실행을 하고 나서는 위의 파일명 그대로 localhost:8080 다음에 넣어주시면 됩니다.
대신 정적 컨텐츠는 HTML과 같이 있는 그대로의 파일을 보여주기 때문에, 별도의 프로그래밍 작업은 할 수 없습니다. 다음으로 동작에 대한 원리를 간략하고 쉽게 설명드리면 아래 그림과 같습니다.
먼저, 웹 브라우저를 통해 사용자가 저장되어있는 hello-static.html 파일을 호출합니다. 요청은 내장 톰켓 서버를 통해 스프링 컨테이너 내에 hello-static.html 과 관련된 컨트롤러가 있는지 확인하지만, 정적 컨텐츠이기 때문에 요청에 매핑되는 컨트롤러는 존재하지 않습니다. 다음으로 resources 의 static 폴더에 hello-static.html 파일이 있는지 조회하는데, 결과가 있기 때문에, 해당 HTML의 코드를 웹 브라우저로 전달해 표시해주는 것입니다.
1.2 MVC & 템플릿 엔진
앞서 본 정적 컨텐츠와 달리, 서버에서 특정 작업을 거쳐, HTML을 동적으로 구성해 웹 브라우저로 보여주는 "템플릿 엔진"을 사용하고, 동적으로 구성하기 위해 서버에서 "모델-뷰-컨트롤러" 의 패턴으로 로직을 구현한 것을 "MVC 패턴" 이라고 합니다. "동적으로 구성한다"는 의미는 "서버에서 MVC 패턴을 통해 생성한 결과를 템플릿 엔진으로 보내 HTML을 구성하는 요소에 변형을 가한다" 정도로 이해하시면 될 것 같습니다.
MVC 패턴을 사용하게 된 이유로는 이전에 개발하던 방식 중에 관심사를 분리하기 위함이였습니다. 이전에 개발하던 방식은 JSP 처럼 서블릿으로 HTML 코드 안에 프로그래밍 로직을 넣어서 처리하는 방식이였는데, 이 때 화면을 그리는 부분과 비즈니스 로직을 처리하는 부분을 분리하기 위해서 위와 같이 역할에 따라 3가지로 분할한 것입니다.
이해를 돕기 위해, 아래 예시를 통해서 조금 더 알아보도록 하겠습니다. 이번에는 http://localhost:8080/hello-mvc?name=spring 이라는 주소를 전달했을 때, 웹 브라우저에 "hello spring" 을 출력해주는 컨트롤러(Controller)와 뷰(View) 를 생성해보도록 하겠습니다. 먼저, 컨트롤러(Controller)는 우리가 웹 브라우저를 포함해 여러 소프트웨어에서 서버로 요청되는 주소를 받아, 그에 매핑되는 로직을 실행하고, 실행 결과를 반환하는 역할을 합니다. 이번 예제에서 사용할 컨트롤러 코드는 앞서 만들었던 HelloController.java 에 다음과 같이 추가해줍니다.
[src/main/java/hello/com/example/hellospring/controller/HelloController.java]
package com.example.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class HelloController {
...
@GetMapping("hello-mvc")
public String helloMvc(@RequestParam("name") String name, Model model) {
model.addAttribute("name", name);
return "hello-template";
}
}
코드에 대한 요소들을 추후에 다른 장에서 설명하겠지만, 간단하게 이야기해서, 주소와 함께 넘어온 요청변수(주소에서 ? 다음 부분)의 값을 받아 "hello-template.html" 이라는 모델로 값을 넘기겠다는 의미입니다. 다음으로 템플릿을 만들어보겠습니다. 구체적인 코드는 다음과 같습니다.
[src/resourcs/templates/hello-template.html]
<!DOCTYPE html>
<html lang="ko-KR" xmlns:th="http://www.thymeleaf.org">
<head>
<meta http-equiv="Content-Type" content="text/html"; charset="UTF-8">
<title>Hello MVC</title>
</head>
<body>
<p th:text="'hello ' + ${name}">hello! empty</p>
</body>
</html>
앞선 예제와 유사하게 컨트롤러에서 name 변수에 할당된 값을 가져와 출력하는 HTML 템플릿을 구성했습니다. 이제 서버를 재실행해서 잘 동작하는 지 확인해보겠습니다.
위의 그림과 비슷하게 출력되는 것까지 확인해보았습니다. 위의 예제에서는 Thymeleaf 엔진을 사용했지만, 이를 포함해 템플릿 엔진을 사용했을 때의 장점이라고 하자면, 서버를 실행하지 않고, 저장된 경로의 파일을 열었을 때, HTML 파일의 소스와 결과를 모두 확인할 수 있다는 점이 좋습니다.
끝으로 동작 방식도 간략하게 아래 그림으로 살펴보도록 하겠습니다.
앞선 예제에서도 비슷한 그림을 보셨을 텐데, 여기서도 간략하게 설명하자면, 정적 컨텐츠 때와 동일하게 웹 브라우저에서 주소를 입력하고 이를 내장 톰켓 서버가 받아 스프링 컨테이너에게 넘깁니다. 스프링 컨테이너에는 매핑된 "hello-mvc" 와 매핑된 로직인 helloController() 메소드가 있고, 요청변수 name 으로 받은 값인 spring 과 함께, 반환 결과인 hello-template.html 템플릿으로 넘깁니다. 반환 결과는 뷰를 관리하는 viewResolver 에게 전달되고, resource/templates 이하에 저장된 hello-template.html 을 동작하며, 이 때 Thymeleaf 템플릿 엔진을 통해 요청 변수 값과 함께 HTML을 변환하여, 웹 브라우저에 전달하는 과정입니다.
viewResolver 를 포함해 위의 그림에 대한 자세한 이야기는 추후에 MVC와 관련된 글에서 자세하게 다룰 예정이니, 참고 바랍니다.
1.3 API (Application Programming Interface)
끝으로 API라고 하는 것이 있는데, 웹 브라우저를 포함해 다양한 소프트웨어에서 다른 소프트웨어와의 통신을 하기 위해 데이터를 송수신하도록 만든 규칙 집합을 의미합니다. 이 때, 요청을 보내는 측의 소프트웨어를 클라이언트(Client), 요청을 받는 측의 소프트웨어를 서버(Server) 라고 부릅니다. 규칙 집합이라고 한 만큼, 송수신 간에도 특정 형식이 있으며, 주로 JSON 방식으로 송수신을 하게 됩니다.
구체적인 내용은 예시와 함께 살펴보도록 하겠습니다. 앞서 만든 HelloController.java 에 "hello-string" 으로 요청했을 때의 결과를 아래 코드와 같이 실행되도록 작성해줍니다.
[src/main/java/hello/com/example/hellospring/controller/HelloController.java]
package com.example.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class HelloController {
...
@GetMapping("hello-string")
@ResponseBody
public String helloString(@RequestParam("name") String name) {
return "hello " + name; // hello spring
}
}
이전 예제인 MVC 예제와의 차이점이라고 하자면, 위에서는 뷰를 통해 웹 브라우저에 HTML 문서가 표시가 되었지만, 이번 예제는 아래와 같이 단순 문자열이 출력되는 것을 확인할 수 있습니다.
그러면 어떤 상황에서 잘 활용할 수 있을까요? 예를 들어서 위와 같은 문자가 아니라, 데이터를 받는다라고 가정을 해볼게요. 이를 위해서 예제 하나를 더 추가해주도록 하겠습니다. 먼저 아래의 코드를 작성하고, 이어서 설명을 드리겠습니다.
[src/main/java/hello/com/example/hellospring/controller/HelloController.java]
package com.example.hellospring.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@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를 확인해보면 JSON 형식을 지원하는 것을 볼 수 있습니다. JSON 에 대해 궁금하신 분들은 별도로 검색해서 찾아보시는 것을 권장드립니다.
마지막으로 API 예제의 동작원리를 간략하게 그림으로 표현하자면, 아래 순서와 같이 표현됩니다.
우선 앞선 다른 예제들과 동일하게 사용자가 웹 브라우저에서 주소를 입력하고, 이를 내장 톰켓 서버에서 받아, 스프링 컨테이너로 전달합니다. 그리고 hello-api 와 매핑된 helloApi() 가 실행되는데, 이 때 @ResponseBody 어노테이션을 통해 메소드에서 실행된 결과를 HTTP의 Body에 저장합니다. 위의 경우 hello 객체에 담긴 상태로 결과를 반환하는데, 반환된 결과는 HttpMessageConverter가 받아서 동작하며, 스프링의 정책 상, 문자가 전달되면, 전달받은 문자를 그대로 넘기지만, 객체가 전달될 경우에는 JSON 형식으로 변환하여 전달하도록 기본적으로 설정되어 있습니다. 때문에 위의 예제의 경우, JsonConverter가 동작을 하게되며, JSON 형태로 변환한 결과를 웹 브라우저에게 전달하는 것입니다.
2. 실습: 회원 관리 웹 애플리케이션 개발하기
이제 위에서 배운 내용들을 토대로 회원 관리 서비스를 개발해 볼 것입니다. 과정은 크게 비즈니스 요구사항 정리, 회원 도메인 및 레포지토리 만들기, 테스트 케이스 작성, 회원 서비스 개발, 회원 서비스 테스트 단계로 진행을 하겠습니다.
2.1 비즈니스 요구사항 정리
서비스를 개발하기에 앞서, 해당 서비스에서 요구하는 비즈니스 요구사항을 먼저 정리해봅시다. 간단하게 구현하는 실습인 만큼 요구사항도 아래와 같이 간단한 것들만 구현을 해보고자 합니다.
[비즈니스 요구사항 정리]
- 데이터: 회원ID, 이름
- 기능: 회원 등록, 조회
- 특이사항: 아직 데이터 저장소가 선정되지 않음
다음으로 실행하고자 하는 서비스의 구조를 표현해야하는데, 아래와 같이 단순하게 표현해보도록 하겠습니다.
위의 요소들에 대한 설명은 다음과 같습니다.
- 컨트롤러: 웹 MVC의 컨트롤러 역할
- 서비스: 핵심 비즈니스 로직 구현
- 리포지토리: 데이터베이스 접근 및 도메인 객체를 DB에 저장/관리
- 도메인: 비즈니스 도메인 객체, 주로 데이터베이스에 저장하고 관리됨
추가적으로 위의 요구사항 중 특이사항으로 "아직 데이터 저장소가 선정되지 않음" 이라고 했기 때문에, 이번 예제에서는 이를 메모리 저장소에 구현하는 것으로 만들도록 하겠습니다. 이를 위해 아래와 같은 클래스 의존관계가 존재하게 되는데, 방법은 인터페이스로 구현 클래스를 변경할 수 있도록 설계를 하고, 현재는 개발 단계이기 때문에 가벼운 메모리 기반의 데이터 저장소를 사용하도록 할 것입니다.
2.2 회원 도메인 및 레포지토리 만들기
다음으로 회원 정보를 정의할 클래스와 저장소를 만들어보도록 하겠습니다. 먼저, 회원 정보를 저장할 Member 클래스에 대해 아래와 같이 정의해줍니다. Member 클래스 내의 필드는 함부로 변경되면 안되기 때문에 private 으로 접근제어자를 설정하고, 대신 값을 가져오거나, 변경할 때 사용할 메소드인 getter/setter 메소드를 같이 구현해주도록 하겠습니다.
package com.example.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;
}
}
다음으로 회원 레포지토리를 만들어보도록 하겠습니다. 인터페이스로 구현할 예정이며, 기능은 크게 Member 객체를 저장해주는 save 메소드와 ID, 이름으로 검색하는 경우와 조건없이 전체 검색을 할 수 있는 findById, findByName, findAll 기능을 구현해주도록 하겠습니다. 앞서 인터페이스를 생성한다고 말씀드렸기 때문에, 기능에 대해서만 명시를 하고, 구현코드가 없는 상태로 마무리합니다.
[Java Code - MemberRepository.java]
package com.example.hellospring.repository;
import com.example.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();
}
특이한 점이라고 하자면, findById(), findByName 메소드가 모두 Optional 타입으로 설정되어 있습니다. Java8에 포함된 기능이고, 위의 예제에서는 조회결과가 NULL 일 경우, Optional 타입으로 감싸서 결과가 반환되도록 처리하기 위한 방법이라고 할 수 있겠습니다.
이제 위의 MemberRepository 인터페이스를 상속받는 구현체인 MemoryMemberRepository 클래스를 생성해 보겠습니다. 가장 먼저 Member 객체를 저장하는 객체인 store 객체를 생성해줍니다. 보통 메소드간 공유하는 변수에는 동시성 문제가 발생할 수 있는데, 위의 예제는 간단하게 기능만 구현하기 위한 예제이기 때문에 HashMap 구조로 만들었습니다. 그리고 결과 값 및 ID로 사용하기 위한 변수인 sequence 도 Long 타입으로 선언하겠습니다.
다음으로 우리가 구현해야될 4가지의 기능 중에서 첫 번째인 save() 메소드를 먼저 구현해보죠. 단순히 Member 객체를 저장하는 것이 목적이기 때문에, 저장할 Member 객체에 sequence 값에 1씩 더해서 ID 를 부여한 후, store 객체의 put() 메소드를 사용해서 해시 맵에 저장합니다.
[Java Code - MemoryMemberRepository.java]
package com.example.hellospring.repository;
import com.example.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;
}
...
}
다음으로 ID 및 이름을 통해 조회하는 메소드인 findById() 와 findByName() 메소드를 만들어보겠습니다. 두 메소드 모두 store 에서 get() 메소드를 통해 검색한 결과를 가져오면 되는데, 앞서 말씀드린 것처럼 결과가 NULL일 경우에 대해서도 처리를 해야됩니다. 이를 위해 먼저 ID로 조회하는 findById에서는 Optional.ofNullable() 메소드를 사용해 감싸줍니다.
[Java Code - MemoryMemberRepository.java]
package com.example.hellospring.repository;
import com.example.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository {
...
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id)); // NULL 인 경우에, 별도 처리하여 반환
}
...
]
한편, findByName() 의 경우, store 에 저장된 "값" 이기 때문에, 람다 기능을 통해서 구현을 해보도록 하겠습니다. 먼저 입력변수로 넘어온 이름을 조회하기 위해서 store.values() 로 저장된 모든 값을 가져오고, 이를 stream() 메소드를 사용해 순회하도록 만들어줍니다. 순회를 하면서, 이름과 같은 값이 있는지 확인하기 위해 .filter() 메소드를 사용하고, 람다식으로 조건을 추가한 후, .findByAny() 로 조건에 일치하는 모든 결과를 가져옵니다.
[Java Code - MemoryMemberRepository.java]
package com.example.hellospring.repository;
import com.example.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository {
...
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
}
...
}
끝으로, 저장된 모든 Member 객체를 출력하는 findAll() 메소드는 store 에 저장된 모든 값을 출력하면 되기 때문에, 아래 코드처럼 store.values() 의 결과를 ArrayList에 담아 반환해주는 방식으로 구현하면 됩니다.
[Java Code - MemoryMemberRepository.java]
package com.example.hellospring.repository;
import com.example.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository {
...
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
}
이렇게 하면, 우리가 구현해야하는 4가지 기능을 완성했는데, 위의 코드들이 앞서 이야기한 기능대로 동작하는 지도 확인해봐야 합니다. 이를 위해 스프링에서는 테스트 케이스를 작성할 수 있도록 기능을 제공하는 데, 테스트 코드를 어떻게 작성하면 되는지 확인해보시죠.
2.3 테스트 케이스 작성하기
설명을 하기에 앞서 테스트와 관련된 내용을 잠깐 설명하고 넘어가보려합니다. 앞서 이야기했듯이, 우리가 구현한 코드들이 정상적으로 동작하는 지 확인해보기 위해, 과거에는 코드 구현하고, 서버에 올려서 실행해서 확인한 뒤, 이상이 있다면 수정해서 다시 서버에 올린 후 재기동하는 식의 번거로운 작업이였습니다. 물론 테스트 케이스가 적다면, 위의 내용과 같이 해서 문제될 것은 없지만, 만약 검사해야되는 항목이 100개만 하더라도 일일이 서버에 올려서 테스트 해보는 작업은 번거로울 것입니다.
이를 위해 자바에서는 단위 테스트를 수행하기 위해 JUnit 이라는 프레임워크를 지원하고 있습니다. 여기서 말하는 단위 테스트는 소위 TDD (Test-Driven Development, 테스트 주도 개발)라고 해서, 코드의 유지보수 및 운영환경에서의 에러를 미리 방지하고자 단위별로 수행하는 테스트를 의미합니다. 물론 자바에는 JUnit 이외에도 NUnit, JMockit 등 각 언어별로 단위테스트를 진행할 수 있도록 지원되고 있습니다.
하지만, 이번에 알아볼 JUnit 을 이용한 테스트는 자바 기반이면서, 어노테이션을 사용해 테스트를 지원해주고, Assert 문을 사용해 검증할 수 있다는 점이 특징입니다. 기본적인 내용은 여기까지 설명하고, 실제로 코드를 작성해보면서 위의 내용에 대한 의미를 좀 더 설명드리겠습니다.
테스트를 하기 위해서는 먼저 아래 그림과 같이 main 이 아닌 Test 로 되어있는 폴더 이하에 코드파일을 생성해야합니다. 테스트 코드파일을 생성할 때, 관례로 "[내가 테스트하고자 하는 클래스명]Test.java" 와 같이 명명해주는 것입니다. 위의 예제에서는 "test/java/com/example/hellospring" 이하에 controller 패키지를 생성하고, "MemoryMemberRepositoryTest" 라는 이름의 테스트 클래스를 생성해줍니다.
다음으로 테스트 코드를 작성하기 앞서, 레포지토리 객체를 하나 생성해주도록 하겠습니다. 이 후, 가장 먼저 save() 메소드에 대해서 테스트 해볼 것인데, 먼저 아래의 코드를 작성하고나서 설명을 이어가도록 하겠습니다.
[Java Code - MemoryMemberRepositoryTest.java]
package com.example.hellospring.controller;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
public class MemoryMemberRepositoryTest {
private 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();
System.out.println("result == " + (result == member));
assertEquals(member, result);
}
...
}
위의 코드에서 중요한 부분을 몇 가지 짚고 넘어가도록 하겠습니다. 먼저, 위의 테스트 코드를 포함해 모든 테스트와 관련된 코드를 작성하고 나서 반드시 @Test 어노테이션을 추가해줘야 합니다. 이를 통해 해당 메소드가 테스트를 위한 것임을 표시하는 것이지요.
다음으로 위의 코드는 객체를 저장하는 기능이 잘 동작하는 지 테스트를 해보는 것이기 때문에, 기본적으로 Member 객체를 생성해주어야합니다. 그다음 repository 에 저장하는 것까지 동작한 후, 실제로 잘 저장됬는지 확인을 해보는 작업이 나오는데, 위의 코드에서처럼 System.out.println() 구문을 사용해 값을 직접 출력해보아도 되지만, 테스트 케이스가 많을 경우, 하나하나 출력해보고 확인하는 과정은 어려울 것입니다.
이를 해결할 방법 중 하나로 Assert 문을 이용하는 것입니다. 위의 코드에서는 assertEquals() 만 사용이 됬는데, 이는 static import 를 했기 때문이며, 본래는 "Assertion.assertEquals()" 라고 이해하면 됩니다. 우리가 확인하고자 하는 것은 result 객체가 우리가 저장한 member 객체와 같은 것인지를 확인해보기 위해 테스트를 하는 것이기 때문에, assertEquals() 메소드를 사용해 우리가 기대하는 값(member)이 실제 결과(result)와 같은지를 매개변수에 순서대로 넣은 것이며, 테스트를 실행해보면 아래 그림처럼 Assert 문에 대해서는 아무런 내용이 나오지 않지만, 테스트는 통과한 것을 확인할 수 있습니다.
이처럼 성공을 한 경우에는 별도로 출력하지 않지만, 만약 에러가 발생한다면 어떻게 출력되는지도 한 번 보도록 하겠습니다. 이를 위해 이번에는 findByName() 에 대해 테스트를 아래와 같이 작성해보도록 하겠습니다.
package com.example.hellospring.controller;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class MemoryMemberRepositoryTest {
...
@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("Spring2").get();
assertEquals(member1, result);
}
...
}
위의 테스트를 실행하게되면 아래 그림처럼 에러 문구가 나오게 됩니다. 이유는 result 에 저장된 객체는 member2와 동일하게 "Spring2" 에 대한 조회결과가 담겨있지만, member1 과 비교했기 때문입니다.
이처럼 Assert 문을 사용해서 테스트를 하고, 에러메세지를 통해서 개발한 로직에 대한 테스트를 할 수 있고, 더 나아가 TDD 로 개발도 가능합니다. 끝으로, findAll() 에 대한 테스트로 만들어보겠습니다. 코드는 다음과 같습니다.
[Java Code - MemoryMemberRepository.java]
package com.example.hellospring.controller;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class MemoryMemberRepositoryTest {
...
@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();
assertEquals(result.size(), 2);
}
}
findAll() 의 경우에는 모든 객체를 가져오기 때문에, 이를 위해 List 구조를 사용해 결과를 받았으며, 예제로 2개의 Member 객체를 생성했기 때문에 result의 크기를 조회할 때 값은 2가 나와야 통과할 수 있습니다.
여기까지 테스트 예제를 구현해보았으니, 전체 테스트 코드도 한 번 돌려보도록 하겠습니다. 문제 없이 잘 될 수도 있지만, 몇몇 분들은 아래와 유사하게 테스트가 실패했다는 메세지를 보시게 될 것입니다.
저의 경우에는 findByName() 에 대한 테스트에서 에러가 났습니다만, 위에서 코드를 수정한 것이 없음에도 에러가 발생한 것입니다. 왜일까요?
이에 대한 답은 잘보면 찾을 수 있는데, 왼쪽 테스트가 실행된 내용을 보면, 우리가 생성한 테스트 순서가 아니라 findAll() 이 먼저 실행되었고, findByName() 이 실행되었습니다. 이 때, findAll() 이 종료되고 나서 repository 객체를 비워주지 않았고, findByName()에서 다른 객체가 생성되어 다르게 표시가 된 것입니다.
이를 해결하려면 어떻게 해야될까요? 간단하게 repository 를 비워주면 됩니다. 이를 위해, MemoryMemberRepository 클래스로 이동해 아래와 같이 store 를 초기화하는 메소드를 먼저 생성해줍니다.
[Java Code - MemoryMemberRepository.java]
package com.example.hellospring.repository;
import com.example.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository {
...
public void clearStore()
{
store.clear();
}
}
다음으로 테스트 코드의 위쪽에 아래와 같이 repository를 초기화 하는 코드를 추가해줍니다.
[Java Code - MemoryMemberRepositoryTest.java]
package com.example.hellospring.controller;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class MemoryMemberRepositoryTest {
private MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach()
{
repository.clearStore();
}
...
}
위의 코드에서 @AfterEach 어노테이션은 각 테스트가 실행된 이후 아래 메소드를 실행하라는 의미가 담긴 어노테이션이며, 위의 코드로 인해, 각 테스트가 종료될 때마다 repository 는 clearStore() 메소드를 실행해 초기화를 수행할 것입니다. 다시 한 번 전체 테스트를 실행시켜보면 정상적으로 테스트 통과하는 것까지 확인할 수 있습니다.
2.4 회원 서비스 개발하기
네 번째로 회원 레포지토리와 도메인을 활용하여 실제 비즈니스 로직이 포함되는 서비스를 개발해보도록 하겠습니다. 시작에 앞서, "service" 패키지를 생성해주도록 하겠습니다. 다음으로 "MemberService" 라는 이름의 클래스를 생성합니다. 앞서 요건에 대해 정의할 때, 신규회원의 회원가입 기능과 저장된 회원들의 조회 기능을 추가하기로 했기 때문에, 해당 클래스에 이 2가지 기능에 대한 메소드를 추가해줄 것입니다.
먼저, 회원가입에 대한 기능을 구현해보겠습니다. 우리는 앞에서 회원들에 대한 정보를 MemberRepository 를 상속받는 MemoryMemberRepository 객체를 만들어 저장했습니다. 때문에 회원 서비스 역시 마찬가지로 MemoryMemberRepository 객체를 하나 만들어 주고, 이 후 가입하는 모든 회원은 해당 객체에 저장하도록 하겠습니다.
이제 회원가입 기능을 구현해보겠습니다. 메소드 명은 "join" 으로 만들겠습니다. 구체적인 코드는 아래와 같으며, 코드를 작성하고 나서 설명을 이어가겠습니다.
[Java Code - MemberService.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemberRepository;
import com.example.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) {
Optional<Member> result = memberRepository.findByName(member.getName());
result.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
memberRepository.save(member);
return member.getId();
}
...
}
위의 코드처럼 회원의 이름을 사용해 findByName() 메소드를 호출하여 결과를 저장할 수 있고, 값이 존재하는 경우에는 "이미 존재하는 회원입니다" 와 같이 예외처리로 저장이 되지 않으면서, 에러 문구까지 출력하도록 만들어 줍니다. 끝으로 결과는 memberRepository에 저장하고, 메소드 반환 값으로는 회원ID 값을 반환하도록 했습니다.
하지만 위의 코드에서 Optional 로 결과를 반환하는데, 이 부분에 대해 위의 코드처럼 쓰기 보다는 아래와 같은 방식을 더 권장합니다.
[Java Code - MemberService.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemberRepository;
import com.example.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) {
validateDuplicatedMembers(member);
memberRepository.save(member);
return member.getId();
}
private void validateDuplicatedMembers(Member member) {
// 같은 이름의 중복 회원은 제외
memberRepository
.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
...
}
2단계로 나눠서 설명드리자면, 먼저 기존에 "memberRepository.findByName(member.getName());" 부분은 결과가 Optional 타입이고, 기존에 존재하는 지 여부를 확인하기 위함이므로, 같은 Optional 타입의 변수에 대입하기 보다는 저장하지 않고, 저장소에서 조회하는 기능으로 변경하였습니다. 그리고 이 부분은 회원가입 과정의 일부이면서, 목적은 중복 확인을 위한 코드이기 때문에 "validateDuplicatedMembers()" 라는 별도의 메소드로 빼서 분리하였습니다.
다음으로는 전체 회원을 조회하는 기능을 구현해보겠습니다. 여기에서는 앞서 MemoryMemberRepository 에서 만들어 둔 findAll() 메소드를 활용할 것입니다. 참고로 findAll() 메소드의 반환값은 ArrayList 로 했기 때문에 아래 예제에서도 동일하게 리스트 타입으로 결과를 받습니다.
[Java Code - MemberService.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemberRepository;
import com.example.hellospring.repository.MemoryMemberRepository;
import java.util.List;
import java.util.Optional;
public class MemberService {
...
/**
* 전체 회원 조회
*/
public List<Member> findMembers() {
return memberRepository.findAll();
}
}
이렇게 해서 회원 서비스를 생성하는 것까지 완료했습니다. 마지막으로 다음 절에서 테스트 케이스 작성하는 것까지 확인해보겠습니다.
2.5 회원 서비스 테스트하기
앞서 회원 도메인 및 레포지토리 구현을 테스트할 때 처럼 이번에는 회원 서비스에 대해 테스트 케이스를 작성해보도록 하겠습니다. 이번 예제에서는 조금 직관적으로 확인할 수 있도록 메소드명을 한글로 기입하도록 하겠습니다. 본래 서비스나 프로덕트용 코드에서는 한글 사용을 지양하지만, 테스트 코드이기도 하고, 영어권에서 업무를 하는 것도 아니며, 명확하게 확인하기 위한 용도로 사용할 수 있다는 점을 참고하시기 바랍니다.
먼저, 회원가입에 대한 테스트를 진행해 볼 텐데, 신규 멤버를 생성해주어야 하는 문제가 하나 발생합니다. 물론, 기존처럼 Member 객체를 생성하면 되겠지만, 이번 테스트에서는 "given-when-then" 구문으로 테스트를 해보려합니다. 단어의 의미처럼 "given 이하의 특정 조건이 주어지고, when 이하의 어떤 상황 혹은 로직을 실행할 때, then 이하의 결과가 나온다." 라는 의미입니다.
[Java Code - MemberServiceTest.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
MemberService memberService = new MemberService();
@Test
void 회원가입() {
// given
Member member = new Member();
member.setName("hello");
// when
Long saveId = memberService.join(member);
// then
Member findMember = memberService.findOne(saveId).get();
assertEquals(member.getName(), findMember.getName());
}
...
}
하지만, 위의 코드는 사실 반쪽짜리 코드입니다. 위의 코드에서처럼 신규 회원을 저장소에 추가하는 작업만이라면 위의 내용이 맞지만, 앞서 중복 회원에 대한 처리까지 구현했기 때문에 그에 대한 테스트도 추가해서 같이 테스트를 진행해보겠습니다.
[Java Code - MemberServiceTest.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
...
@Test
public void 중복회원_예외() {
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
memberService.join(member1);
try {
memberService.join(member2);
fail();
} catch(IllegalStateException e) {
assertEquals("이미 존재하는 회원입니다.", e.getMessage())
}
}
...
}
위의 코드와 같이 try~catch 구문을 이용해서 테스트를 구현한다면, member2 가 회원가입을 할 때 예외가 발생할 것이고, 발생하면 그에 맞는 검증을 위해 catch 문에서 Assertion 구문으로 출력 메세지를 비교해서 테스트를 해볼 수 있습니다. 하지만, 위의 과정에서 try 구문에 예외가 발생하지 않으면, fail() 을 추가해야하고, 코드 구문도 번거로울 수 있습니다. 이를 위해 앞서 배운 "given-when-then" 구문으로 바꿔보도록 하겠습니다.
[Java Code - MemberServiceTest.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
...
@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
assertEquals("이미 존재하는 회원입니다.", e.getMessage());
}
...
}
위의 코드에서처럼 assertThrows() 구문을 사용해 발생할 예외 클래스와 어떤 상황일 때 발생하는 지를 매개변수로 넣어준 후, 예외 메세지 출력 및 비교를 위해 then 부분에서 assertEquals 로 메세지 내용을 비교하는 식으로 바꿀 수 있습니다. 여기까지 생성한 테스트 케이스들이 잘 동작하는 지 확인해보도록 하겠습니다.
위의 그림처럼 정상적으로 잘 동작하는 것까지 확인했습니다. 하지만 여기서 문제가 발생합니다. 이전 회원 도메인 및 레포지토리 구현 테스트 때와 동일하게 테스트 실행 순서에 보장이 없기 때문에, 회원가입() 에서 "spring"이란 이름의 Member 객체가 생성되면, 중복여부 테스트에서 실패를 할 수 있다는 점이지요.
이를 위해 앞선 경우와 마찬가지로 레포지토리를 초기화 하는 로직을 만들어야하기 때문에, 먼저 레포지토리 객체를 선언하고, clearStore() 메소드가 각 테스트가 종료된 후 실행되도록 구현해야합니다.
[Java Code - MemberServiceTest.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
MemberService memberService = new MemberService();
MemoryMemberRepository memberRepository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
...
}
위와 같이 구현은 했지만, 그래도 코드 상 이상한 부분이 있습니다. 바로 레포지토리를 선언한 부분인데요. 사실 MemberService 에서 우리는 이미 레포지토리를 하나 생성했습니다. 이 상황에서 위의 테스트에서 new MemoryMemberRepository() 를 실행해 새로운 레포지토리를 하나 더 생성한 것입니다. 테스트를 초기화하기 위해서 굳이 2개의 객체를 만들어서 쓰는 상황이 애매하게 되는 것이죠.
이러한 혼란한 상황을 해결하기 위해서 아래 코드와 같이 변경해보도록 하겠습니다. 먼저, MemberService 에서 memberRepository 선언부를 아래와 같이 변경해줍니다.
[Java Code - MemberService.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemberRepository;
import com.example.hellospring.repository.MemoryMemberRepository;
import java.util.List;
import java.util.Optional;
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
...
}
그리고 테스트 코드에서는 아래와 같이 변경해줍니다.
[Java Code - MemberServiceTest.java]
package com.example.hellospring.service;
import com.example.hellospring.domain.Member;
import com.example.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
...
}
위의 2개 코드를 같이 살펴보면, 먼저 MemberService 의 생성자에 MemberRepository를 매개변수로 추가했으며, 이로 인해 memberService 객체를 이용하면, MemberService 에서 생성한 레포지토리를 어디서든 이용할 수 있게 되었습니다. 이를 테스트 코드에서 각 테스트 이전에 레포지토리 변수를 생성하고, 이를 MemberService 생성자에 매개변수로 추가했기 때문에, 결과적으로는 MemberService 코드에서 사용됬던 레포지토리를 같이 사용할 수 있게 된 것입니다.
이처럼 클래스 외부에서 다른 클래스의 생성자를 통해 필요한 변수 등을 가져와 사용하는 것을 가리켜 "의존성 주입 (Dependencies Injection)" 이라고 부릅니다. 자세한 건 추후에 한 번 더 설명드리기로 하고, 이렇게 해서 첫 번째 실습을 마무리하도록 하겠습니다.
'BackEnd > Spring 🍃' 카테고리의 다른 글
[Spring] 5. 좋은 객체지향 프로그래밍이란 Ⅱ : 실습편 - 회원 도메인 (0) | 2025.03.12 |
---|---|
[Spring] 4. 좋은 객체지향 프로그래밍이란 Ⅰ : 이론편 (0) | 2025.02.03 |
[Spring] 3. 스프링 빈과 의존관계 찍먹하기 (0) | 2025.01.11 |
[Spring] 1. 스프링을 시작하며... (0) | 2024.12.29 |