포스트

스프링 시작

스프링 입문

자바 스프링을 공부해보면 사고의 폭을 넓힐 수 있다는 동료의 추천에 도전해보기로 했다. 일단 당장 Node.js 기반의 프레임워크밖에 사용을 해본 경험이 없기 때문에, 별도의 언어와 프레임워크를 학습하는 경험을 가져가면, 자바-스프링뿐만이 아니더라도 다른 스택을 요구하는 상황에 놓일 때 더욱 원활하게 터득하여 사용할 수 있을 것이라는 생각 또한 있다.

다만, 일단 생각보다 어렵다. Express가 확실히 처음 시작하는 사람 입장에서 서버 구축하는 게 훨씬 편한 것 같다. 스프링은 프로젝트를 시작하는 것부터 난관이다…

자바 학습은 나도코딩의 자바 기본편을 들으면서 기본기를 터득했고, 스프링은 김영한님의 스프링 입문으로 시작했다.

기초

우선 스프링에서 컨텐츠를 클라이언트로 전달하는 방법은 크게 정적 컨텐츠, MVC와 템플릿 엔진, 그리고 API가 있다.

정적 컨텐츠는 말 그대로 변동이 없는, 정적인 컨텐츠를 전달하는 것이다. URL로 요청을 받으면 스프링의 톰캣 내장 서버는 우선 관련 컨트롤러가 있는지 확인한 후에, 없는 경우에 ResourceHttpRequestHandler라는 핸들러 클래스를 사용하여 /static이라는 디렉토리에서 요청된 페이지를 찾아 HTTP응답으로 반환한다. 보통 뒤에 파일명 .html이 붙기 떄문에 URL도 www.example.com/hello.html과 같은 형태를 띈다.

MVC패턴을 통해 동적 컨텐츠를 제공하고자 한다면 Thymeleaf로 대표되는 템플릿 엔진을 사용해 서버에서 HTML을 동적으로 렌더링한다. /templates 디렉토리에 있는 HTML 파일이 타임리프 템플릿 엔진을 통해 동적으로 변환되어 클라이언트에 전달된다. 이 동적 HTML파일은 다음과 같은 형태의 컨텐츠를 제공한다:

1
2
3
4
5
6
7
8
9
10
<!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>

변수 표현식 ${}를 사용하여 동적으로 변동될 값을 지정하며, 여기에 있는 값은 아래와 같은 컨트롤러 메서드로부터 받는다.

1
2
3
4
5
    @GetMapping("hello")
    public String hello(Model model){
        model.addAttribute("data", "hello!!");
        return "hello";
    }

hello 메서드가 호출될 때 data라는 이름으로 “hello!!”값을 모델에 담아 hello.html로 전달한다. 여기서 어떤 템플릿 파일로 값을 전달할지는 반환값에 의해 결정된다. 위 코드에서 return "hello"; 로 반환되는 “hello” 문자열은 템플릿 파일의 이름이다 (hello.html)

API는 데코레이터(어노테이션) @ResponseBody를 붙여 사용한다. 해당 데코레이터를 사용하면 뷰 리졸버를 사용하지 않으며, 대신 HttpMessageConverter가 동작하여 HTTP의 Body에 문자 내용을 직접 반환하며, 값이 객체일 경우 자동적으로 JSON형태로 반환된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    @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;
        }
    }

실전 코딩 시작

가장 먼저 해야할 건 비즈니스 요구사항 정리이다. 우선 예제로 간단한 회원 관리 시스템을 구현할 예정이었기에 복잡한 요구사항은 없다.

  • 데이터: 회원ID, 이름
  • 기능: 회원 등록, 조회
  • 데이터 저장소는 선정 X

요구사항을 정리했으면 도메인과 리포지토리를 만들어야 한다.

스프링에서 도메인은 클래스 형태로 정의되며, 클래스의 필드와 이들의 Getter 및 Setter을 정의해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
    }
}

Node.js 기준으로는 Entity 객체와 비슷한 느낌이다.

리포지토리는 인터페이스로 정의하는데, 인터페이스란 클래스가 구현해야 하는 메서드의 집합을 정의하는 틀이다. 추상화 개념이기에 공통의 행동만을 정의하며 직접 인스턴스를 생성할 수는 없다. 추상 클래스(abstract class)와의 차이가 좀 헷갈리는데, 가장 큰 차이는 추상 클래스는 일부 메서드를 구현할 수 있지만 인터페이스는 오로지 추상 메서드만 정의할 수 있다는 점과, 추상 클래스는 단일 상속만 가능하지만 인터페이스는 여러 인터페이스 구현이 가능하다는 점이다.

즉, 인터페이스는 기능의 계약을 정의하는 데에만 사용되며, 일종의 명세를 제공하는 기능이 크다고 생각한다.

1
2
3
4
5
6
public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

기능을 갖는 구현부 코드 없이 메서드명, 메서드의 반환 타입, 그리고 매개변수 정도만 정의되어 있다

이렇게 인터페이스를 만들면 이를 상속할 구현체가 필요하다. 구현체란, 인터페이스(또는 추상 클래스)의 구체적인 동작을 정의한 클래스를 의미하며, 인터페이스에서 정의만 된 메서드를 실제로 구현하여 사용 가능한 형태로 만드는 것을 의미한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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());
    }
}

MemoryMemberRepository는 MemberRepository의 구현체이기에 MemberRepository에 정의된 메서드를 반드시 구현해야 한다.

우선 Map<Long, Member> 타입의 key:value 해시맵 Store과 회원ID를 자동으로 저장하기 위한 변수 sequence를 정의한다.

save 메서드는 member 도메인에 구현한 setter setId를 사용하여 ID는 자동으로 만들고, 정의한 해시맵 Store에 ID와 member를 저장한다.

ID로 조회하는 findByID와 findByName은 모두 Optional이라는 클래스를 사용하는데, 이는 null을 안전하게 처리하기 위한 클래스로 null값을 직접 다루는 것보다 안정성을 높이고 명시적으로 null 체크를 강제한다. ID는 해시맵에 저장되어 있기 때문에 바로 찾을 수 있으며, name은 필터를 사용하여 해시맵의 member클래스를 확인하여 찾는다.

findAll 메서드는 배열 형태의 리스트로 모든 회원정보를 반환한다.

테스트 케이스

테스트 케이스는 node.js 프로젝트에서도 Jest를 사용해서 해봐야겠다고 쭉 생각했던 건데, 오히려 spring 코드를 먼저 하다니… 그래도 개념은 크게 다르지 않을거라 예상된다.

Spring에서는 JUnit이라는 테스트 프레임워크를 사용한다.

1
2
3
4
5
6
7
8
9
10
    @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);
    }

리포지토리 메서드의 테스트 케이스를 작성해보았다. 회원가입 시점에 회원을 저장하는 로직인데, 테스트 케이스에서는 @Test 데코레이터를 사용하여 테스트임을 명시해주고, Assertions.assertEquals(expected, actual)을 사용하여 값을 비교, 결과에 따라 테스트 케이스의 합불여부를 확인할 수 있다.

testCase

전체 테스트 케이스를 실행하는 경우엔는 테스트 케이스의 작동 순서는 임의로 설정이 되기 때문에 리포지토리를 클리어할 필요가 있다. @AfterEach 데코레이터를 사용하여 하나의 테스트 케이스가 끝난 뒤 실행될 내용을 정의할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
//구현체
    public void clearStore(){
        store.clear();
    }


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

서비스 개발 (비즈니스 로직)

비즈니스 로직을 만들어준다. 요구사항에 기능란에 정리해둔 ‘회원 등록’과 ‘회원 조회’를 만들어주면 되는데, 우선 회원 등록 로직을 만들어준다. 클라이언트로부터 전달받은 Member를 매개변수로 동일 name이 존재하는지 파악하는 로직을 만들어줬다. 만든 후에는 Ctrl + Alt + Shift + T를 통해 리팩터링을 해주어 로직을 빼준다. 멤저 찾기와 전체 멤버 찾기도 리포지토리 메서드를 사용하여 만들어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MemberService {
    private final MemberRepository memberRepository = new MemoryMemberRepository();

    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);
    }
}
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.