스프링 초급 (3) - Layer: 컨트롤러에서 서비스를 떼어내기
컨트롤러 하나에 다 넣은 코드가 불편해지는 지점을 확인하고 세 계층으로 나누는 과정을 다룹니다.
스프링 초급 시리즈의 3편입니다. 전체 목차는 0편에 있습니다.
2편까지는 값을 받아서 되돌려주기만 했습니다. 이제 진짜로 저장하고 조회합니다. DB는 아직 안 씁니다. Map에 담아두는 걸로 충분합니다.
일단 컨트롤러 하나에 다 넣기
먼저 한 클래스 내에 만들어 봅니다. 나쁜 예를 보여주려는 게 아니라, 실제로 처음 짤 때 이렇게 되기 때문입니다.
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.demo;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/members")
public class MemberController {
private final Map<Long, Member> store = new HashMap<>();
private long sequence = 0L;
@PostMapping
public Member join(@RequestBody MemberCreateRequest request) {
// ① 형식이 맞나
if (request.email() == null || !request.email().contains("@")) {
throw new IllegalArgumentException("이메일 형식이 아닙니다: " + request.email());
}
// ② 이미 가입한 사람인가
boolean duplicated = store.values().stream()
.anyMatch(m -> m.email().equals(request.email()));
if (duplicated) {
throw new IllegalStateException("이미 가입된 이메일입니다: " + request.email());
}
// ③ 저장한다
Member member = new Member(++sequence, request.name(), request.email());
store.put(member.id(), member);
return member;
}
@GetMapping("/{id}")
public Member find(@PathVariable Long id) {
Member member = store.get(id);
if (member == null) {
throw new IllegalArgumentException("회원이 없습니다: " + id);
}
return member;
}
}
동작합니다.
1
2
3
curl -X POST localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"민아","email":"abcd1234@gmail.com"}'
1
{"id":1,"name":"민아","email":"abcd1234@gmail.com"}
1
curl localhost:8080/members/1
1
{"id":1,"name":"민아","email":"abcd1234@gmail.com"}
같은 이메일로 또 가입하면 막힙니다.
1
2
3
curl -X POST localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"다른사람","email":"abcd1234@gmail.com"}'
1
{"timestamp":"2026-07-30T01:31:12.048+00:00","status":500,"error":"Internal Server Error","path":"/members"}
500이라 좀 이상하지만 일단 넘어갑니다. 예외를 제대로 된 응답으로 바꾸는 건 7편에서 합니다.
컨트롤러의
store가 요청마다 초기화되지 않는 이유가 궁금할 수 있습니다. 스프링은 컨트롤러 객체를 시작할 때 한 번만 만들어 두고 계속 재사용합니다. 그래서 필드에 담아둔 값이 요청 사이에 유지됩니다. 이 성질은 4편에서 다시 씁니다.
무엇이 불편해지나
동작하는데 왜 나누라고 할까요. 위 join 메서드가 하는 일을 세어봅니다.
1
2
3
4
5
① JSON을 자바 객체로 받는다 ← 웹 이야기
② 이메일 형식을 검사한다 ← 입력 검증
③ 이미 가입했는지 판단한다 ← 우리 서비스의 규칙
④ Map에 넣는다 ← 저장 방법
⑤ 객체를 JSON으로 내보낸다 ← 웹 이야기
한 메서드에 다섯 종류의 일이 섞여 있습니다. 이게 왜 문제가 되는지는 뭔가를 고치려고 할 때 드러납니다.
바뀌는 이유가 서로 다르다
- 응답에 가입 날짜를 추가해 달라 → 클라이언트 요구가 바뀐 것
- 이메일 중복이 아니라 휴대폰 번호 중복을 막아 달라 → 서비스 정책이 바뀐 것
Map말고 진짜 DB에 저장해라 → 기술 선택이 바뀐 것
셋은 완전히 다른 이유로, 다른 시점에, 다른 사람의 요청으로 생깁니다. 그런데 셋 다 같은 파일, 심지어 같은 메서드를 열어야 합니다. DB로 바꾸려고 파일을 열었다가 응답 형태를 건드려 버리는 일이 여기서 생깁니다.
다른 입구에서 못 쓴다
“가입시킨다”는 로직을 HTTP 말고 다른 데서 쓰고 싶어지는 순간이 옵니다.
- 관리자 도구에서 회원을 일괄 등록해야 한다
- 서버가 뜰 때 테스트용 계정을 몇 개 넣어두고 싶다
- 가입 로직만 따로 테스트하고 싶다
지금은 방법이 없습니다. 가입 로직이 @PostMapping이 붙은 메서드 안에 있어서, 부르려면 HTTP 요청을 흉내 내야 합니다. MemberCreateRequest를 만들어 컨트롤러 메서드를 직접 호출할 수는 있지만, 그러면 저장소가 컨트롤러 필드에 있으니 컨트롤러 객체까지 통째로 만들어야 합니다.
로직이 특정 입구에 묶여 있다는 게 문제입니다.
세 계층으로 나눈다
나누는 기준은 위에서 본 그대로입니다. 바뀌는 이유가 다른 것을 다른 파일로 뺍니다.
| 계층 | 하는 일 | 바뀌는 이유 | 여기 있으면 안 되는 것 |
|---|---|---|---|
| Controller | HTTP 요청을 자바 값으로 바꾸고, 결과를 응답으로 되돌린다 | API 형태가 바뀔 때 | 서비스 규칙, 저장 방법 |
| Service | 무엇이 맞고 틀린지 판단한다. 일의 순서를 정한다 | 정책이 바뀔 때 | HTTP, 상태 코드, JSON |
| Repository | 저장하고 꺼내온다 | 저장 기술이 바뀔 때 | 판단, 규칙 |
MemberService가 HTTP를 모르면, 나중에 배치에서 부르든 테스트에서 부르든 그대로 씁니다. 반대로 HTTP를 알기 시작하면 웹 요청 없이는 못 부르는 코드가 됩니다.
나눠보기
파일이 늘어나니 패키지도 하나 만듭니다.
1
2
3
4
5
6
7
8
com.example.demo
├── DemoApplication.java
└── member
├── Member.java
├── MemberCreateRequest.java
├── MemberController.java
├── MemberService.java
└── MemberRepository.java
1편에서 확인한 대로 com.example.demo 아래라서 스프링이 잘 찾습니다.
Repository — 저장하고 꺼내온다
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
package com.example.demo.member;
import java.util.*;
public class MemberRepository {
private final Map<Long, Member> store = new HashMap<>();
private long sequence = 0L;
public Member save(String name, String email) {
Member member = new Member(++sequence, name, email);
store.put(member.id(), member);
return member;
}
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public Optional<Member> findByEmail(String email) {
return store.values().stream()
.filter(member -> email.equals(member.email()))
.findFirst();
}
}
판단하는 코드가 하나도 없습니다. 넣고, 꺼내고, 찾기만 합니다. id를 여기서 붙이는 것도 의도한 것입니다. 5편에서 진짜 DB로 바꾸면 id는 DB가 만들어 주는데, 그때 이 클래스만 바꾸면 됩니다.
Optional을 반환하는 이유는 “없을 수도 있다”를 타입으로 말하기 위해서입니다. null을 돌려주면 받는 쪽이 확인을 잊어버립니다.
Service — 판단한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.demo.member;
public class MemberService {
private final MemberRepository memberRepository = new MemberRepository();
public Member join(String name, String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("이메일 형식이 아닙니다: " + email);
}
memberRepository.findByEmail(email).ifPresent(member -> {
throw new IllegalStateException("이미 가입된 이메일입니다: " + email);
});
return memberRepository.save(name, email);
}
public Member find(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("회원이 없습니다: " + id));
}
}
join의 파라미터가 MemberCreateRequest가 아니라 String name, String email입니다. MemberCreateRequest는 HTTP 요청 본문을 담는 그릇이라 웹 냄새가 나기 때문입니다. 서비스가 그걸 받으면 웹이 아닌 곳에서 부를 때도 요청 객체를 만들어야 합니다.
이메일 형식 검사는 원래 컨트롤러 경계에서 하는 편이 낫습니다. 7편에서 @Valid로 옮깁니다. 지금은 검증 도구가 없으니 서비스에 두었습니다.
Controller — HTTP만 다룬다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.demo.member;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService = new MemberService();
@PostMapping
public Member join(@RequestBody MemberCreateRequest request) {
return memberService.join(request.name(), request.email());
}
@GetMapping("/{id}")
public Member find(@PathVariable Long id) {
return memberService.find(id);
}
}
각 메서드가 한 줄이 됐습니다. 하는 일은 요청에서 값을 꺼내 서비스에 넘기고, 받은 걸 그대로 돌려주는 것뿐입니다.
동작은 나누기 전과 똑같습니다.
1
2
3
curl -X POST localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"민아","email":"abcd1234@gmail.com"}'
1
{"id":1,"name":"민아","email":"abcd1234@gmail.com"}
밖에서 보면 아무것도 안 바뀌었습니다. 계층 분리는 사용자가 아니라 코드를 고치는 사람을 위한 작업이라서 그렇습니다.
확인 1: 서비스를 테스트에서 바로 부르기
src/test/java/com/example/demo/member/MemberServiceTest.java를 만듭니다.
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
29
30
31
32
package com.example.demo.member;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MemberServiceTest {
@Test
void 회원을_가입시키면_찾을_수_있다() {
MemberService memberService = new MemberService(); // 그냥 new
Member saved = memberService.join("민아", "abcd1234@gmail.com");
Member found = memberService.find(saved.id());
assertEquals("민아", found.name());
assertEquals("abcd1234@gmail.com", found.email());
}
@Test
void 같은_이메일로_두_번_가입하면_실패한다() {
MemberService memberService = new MemberService();
memberService.join("민아", "abcd1234@gmail.com");
IllegalStateException e = assertThrows(
IllegalStateException.class,
() -> memberService.join("다른사람", "abcd1234@gmail.com")
);
assertEquals("이미 가입된 이메일입니다: abcd1234@gmail.com", e.getMessage());
}
}
1
./gradlew test --tests MemberServiceTest
1
2
3
4
5
6
> Task :test
MemberServiceTest > 회원을_가입시키면_찾을_수_있다() PASSED
MemberServiceTest > 같은_이메일로_두_번_가입하면_실패한다() PASSED
BUILD SUCCESSFUL in 2s
주목할 건 코드가 아니라 없는 것입니다.
- 서버를 안 띄웠습니다
curl을 안 썼습니다- 스프링 애노테이션이 하나도 없습니다
MemberService를new로 그냥 만들었습니다
가입 규칙을 HTTP에서 떼어냈기 때문에 평범한 자바 클래스를 테스트하듯 부를 수 있습니다.
확인 2: 나누기 전이었다면
같은 걸 나누기 전 코드로 테스트하려면 어떻게 해야 했을까요. 가입 로직이 @PostMapping 안에 있으니 웹 요청을 흉내 내는 수밖에 없습니다.
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
package com.example.demo.member;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class MemberControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void 회원을_가입시킨다() throws Exception {
mockMvc.perform(post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"민아\",\"email\":\"abcd1234@gmail.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("민아"));
}
}
1
./gradlew test --tests MemberControllerTest
1
2
3
4
5
6
7
8
9
10
11
> Task :test
INFO 23456 --- [demo] [ main] c.e.demo.member.MemberControllerTest : Starting MemberControllerTest using Java 21.0.3
INFO 23456 --- [demo] [ main] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
INFO 23456 --- [demo] [ main] o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
INFO 23456 --- [demo] [ main] o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 1 ms
INFO 23456 --- [demo] [ main] c.e.demo.member.MemberControllerTest : Started MemberControllerTest in 2.104 seconds
MemberControllerTest > 회원을_가입시킨다() PASSED
BUILD SUCCESSFUL in 6s
같은 통과인데 차이가 큽니다.
| 서비스 테스트 | 컨트롤러 테스트 | |
|---|---|---|
| 스프링을 띄우나 | 안 띄운다 | 띄운다 (Started ... in 2.104 seconds) |
| 준비물 | new MemberService() |
@SpringBootTest + @AutoConfigureMockMvc, MockMvc, JSON 문자열 |
| 실패하면 | 규칙이 틀린 것 | 규칙일 수도, 주소일 수도, JSON일 수도 있다 |
| 걸린 시간 | 2초 | 6초 |
마지막 줄이 중요합니다. 테스트가 실패했을 때 어디가 틀렸는지 좁혀지느냐가 다릅니다. 컨트롤러 테스트는 통과 여부만 알려주고, 원인은 직접 찾아야 합니다.
컨트롤러 테스트가 나쁘다는 뜻은 아닙니다. 주소와 JSON 형태가 맞는지는 컨트롤러 테스트로만 확인할 수 있습니다. 확인하려는 게 다르면 테스트도 다른 자리에 두는 게 맞다는 이야기입니다.
위 코드에 나온
@Autowired는 아직 설명하지 않았습니다. “스프링이 알아서 넣어준다”는 뜻이고, 4편에서 제대로 봅니다.
@SpringBootTest아래@AutoConfigureMockMvc가 같이 붙어 있는 걸 봐두세요. Spring Boot 4부터는@SpringBootTest만으로MockMvc빈이 제공되지 않습니다. 이 줄을 빼면MockMvc를 주입받지 못해 테스트가 시작도 못 합니다.그래도
MockMvcimport가 안 잡히면 테스트 의존성을 추가합니다. Boot 4에서 테스트 인프라가 기술별 모듈로 쪼개졌습니다.
1 testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
남은 찜찜함
나눠놓고 보면 한 줄이 걸립니다.
1
2
3
4
public class MemberService {
private final MemberRepository memberRepository = new MemberRepository();
// ↑ 서비스가 저장소를 직접 만든다
}
1
2
3
4
public class MemberController {
private final MemberService memberService = new MemberService();
// ↑ 컨트롤러가 서비스를 직접 만든다
}
계층은 나눴는데, 쓰는 쪽이 쓸 것을 직접 만들고 있습니다. 지금은 문제없어 보입니다. 컨트롤러가 하나뿐이고 저장소도 하나뿐이라서요.
주문 기능을 추가해서 저장소를 쓰는 곳이 두 군데가 되면 어떻게 될까요. 그게 다음 편입니다.
정리
- 컨트롤러 하나에 다 넣어도 동작한다. 문제는 고칠 때 생긴다
- 나누는 기준은 바뀌는 이유다. API 형태 / 서비스 정책 / 저장 기술은 다른 이유로, 다른 시점에 바뀐다
- Controller는 HTTP 변환, Service는 판단, Repository는 저장·조회를 맡는다
- 헷갈리면 “이 계층이 웹인 걸 알아야 하나”로 판단한다. 서비스가 HTTP를 알면 새고 있는 것이다
- 서비스가 HTTP를 모르면
new로 만들어서 바로 테스트할 수 있다. 스프링도 서버도 필요 없다 - 계층 분리는 사용자에게 보이는 동작을 바꾸지 않는다. 코드를 고치는 사람을 위한 작업이다
다음 4편에서는 new로 직접 연결한 부분을 스프링에게 넘깁니다.