스프링 초급 (4) - DI: 객체를 직접 만들지 않기
객체를 직접 만들었을 때 데이터가 갈라지는 문제를 재현하고 조립을 스프링에게 넘기는 과정을 다룹니다.
스프링 초급 시리즈의 4편입니다. 전체 목차는 0편에 있습니다.
3편 끝에 걸리는 줄이 두 개 있었습니다.
1
2
private final MemberRepository memberRepository = new MemberRepository(); // MemberService 안
private final MemberService memberService = new MemberService(); // MemberController 안
쓰는 쪽이 쓸 것을 직접 만들고 있습니다. 지금은 아무 문제 없어 보입니다. 저장소를 쓰는 곳이 하나뿐이라서 그렇습니다.
주문 기능을 붙여 봅니다.
주문 기능을 추가하면
주문을 하려면 그런 회원이 있는지 확인해야 합니다. 그러니 OrderService도 회원 저장소가 필요합니다.
1
2
3
4
5
6
package com.example.demo.order;
import com.example.demo.member.Member;
public record Order(Long id, Member member, String itemName, int price) {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.demo.order;
import com.example.demo.member.Member;
import com.example.demo.member.MemberRepository;
public class OrderService {
private final MemberRepository memberRepository = new MemberRepository(); // 똑같이 만든다
private long sequence = 0L;
public Order order(Long memberId, String itemName, int price) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("회원이 없습니다: " + memberId));
return new Order(++sequence, member, itemName, price);
}
}
MemberService가 했던 것과 똑같이 new MemberRepository()를 썼습니다. 자연스러운 코드입니다.
확인 1: 저장한 회원이 안 보인다
스프링 없이 확인합니다. 웹 서버를 띄우면 로그에 섞여서 잘 안 보이기 때문입니다. com.example.demo 아래에 Main 클래스를 하나 만듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.demo;
import com.example.demo.member.Member;
import com.example.demo.member.MemberService;
import com.example.demo.order.Order;
import com.example.demo.order.OrderService;
public class Main {
public static void main(String[] args) {
MemberService memberService = new MemberService();
OrderService orderService = new OrderService();
Member member = memberService.join("민아", "abcd1234@gmail.com");
System.out.println("가입 완료: id = " + member.id());
Order order = orderService.order(member.id(), "키보드", 50000);
System.out.println("주문 완료: " + order.itemName());
}
}
가입시키고 바로 주문합니다. 실행하면
1
2
3
4
5
6
7
가입 완료: id = 1
Exception in thread "main" java.lang.IllegalArgumentException: 회원이 없습니다: 1
at com.example.demo.order.OrderService.lambda$order$0(OrderService.java:15)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at com.example.demo.order.OrderService.order(OrderService.java:15)
at com.example.demo.Main.main(Main.java:17)
가입은 성공해서 id까지 받았는데, 바로 다음 줄에서 그 회원이 없다고 합니다.
저장소를 찍어보면 이유가 나옵니다. 두 서비스에 확인용 메서드를 잠깐 추가합니다.
1
2
3
public MemberRepository getMemberRepository() { // 확인용, 나중에 지운다
return memberRepository;
}
1
2
3
4
System.out.println("memberService의 저장소 = " + memberService.getMemberRepository());
System.out.println("orderService의 저장소 = " + orderService.getMemberRepository());
System.out.println("같은 객체인가 = "
+ (memberService.getMemberRepository() == orderService.getMemberRepository()));
1
2
3
memberService의 저장소 = com.example.demo.member.MemberRepository@1b6d3586
orderService의 저장소 = com.example.demo.member.MemberRepository@4554617c
같은 객체인가 = false
@ 뒤 숫자가 다릅니다. new MemberRepository()를 두 곳에서 호출했으니 저장소가 두 개입니다. MemberService가 회원을 넣은 Map과 OrderService가 뒤진 Map이 아예 다른 객체입니다.
저장소는 하나여야 하는데, 필요한 곳에서 각자 만들면 하나라는 걸 보장할 방법이 없습니다. 클래스가 늘어날수록 위험해집니다.
저장소를 바꾸려면
문제가 하나 더 있습니다. 5편에서 Map 대신 진짜 DB를 쓸 겁니다. 그때를 대비해 저장소를 인터페이스로 분리해 둡니다.
1
2
3
4
5
6
7
8
9
package com.example.demo.member;
import java.util.Optional;
public interface MemberRepository {
Member save(String name, String email);
Optional<Member> findById(Long id);
Optional<Member> findByEmail(String email);
}
3편에서 만든 구현은 이름을 바꿔 인터페이스를 구현하게 합니다.
1
2
3
public class MemoryMemberRepository implements MemberRepository {
// 내용은 3편 그대로
}
인터페이스로 나눴으니 나중에 JdbcMemberRepository로 갈아끼우기 쉬울 거라고 기대합니다. 그런데 실제로 손댈 곳을 세어보면
| 손댈 파일 | 바꾸는 줄 |
|---|---|
MemberService |
new MemoryMemberRepository() → new JdbcMemberRepository() |
OrderService |
똑같은 줄을 똑같이 |
| 앞으로 저장소를 쓸 클래스 | 생길 때마다 한 줄씩 더 |
인터페이스로 나눈 의미가 없어졌습니다. MemberService는 “회원 저장소가 필요하다”까지만 알면 되는데, new를 쓰는 순간 “그게 메모리 구현체다”까지 알아버립니다.
원인은 하나다
두 문제의 원인은 같습니다.
쓰는 쪽이 만드는 일까지 하고 있다.
MemberService는 회원 가입 규칙만 알면 되는데, 저장소를 몇 개 만들지와 어느 구현체를 쓸지까지 결정하고 있습니다. 그건 서비스가 판단할 일이 아닙니다.
만드는 일을 밖으로 뺍니다. 서비스는 만들지 않고 생성자로 받기만 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
package com.example.demo.member;
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) { // 받는다
this.memberRepository = memberRepository;
}
// join, find는 그대로
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.demo.order;
public class OrderService {
private final MemberRepository memberRepository;
private long sequence = 0L;
public OrderService(MemberRepository memberRepository) { // 받는다
this.memberRepository = memberRepository;
}
// order는 그대로
}
그리고 만드는 일은 Main 한 곳에서만 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Main {
public static void main(String[] args) {
MemberRepository memberRepository = new MemoryMemberRepository(); // 딱 한 번
MemberService memberService = new MemberService(memberRepository);
OrderService orderService = new OrderService(memberRepository);
Member member = memberService.join("민아", "abcd1234@gmail.com");
System.out.println("가입 완료: id = " + member.id());
System.out.println("같은 객체인가 = "
+ (memberService.getMemberRepository() == orderService.getMemberRepository()));
Order order = orderService.order(member.id(), "키보드", 50000);
System.out.println("주문 완료: " + order.itemName() + " (" + order.price() + "원)");
}
}
확인 2: 저장소를 하나만 만들면
1
2
3
가입 완료: id = 1
같은 객체인가 = true
주문 완료: 키보드 (50000원)
주문이 성공했습니다. 두 문제가 같이 풀렸습니다.
- 저장소가 하나입니다.
new를 한 번만 했으니 갈라질 수가 없습니다 - 구현체를 바꿀 때 손대는 곳은
Main의 한 줄뿐입니다.MemberService와OrderService는 자기가 뭘 받는지 모릅니다
서비스 코드는 오히려 단순해졌습니다. 무엇을 만들지 고민하던 부분이 사라졌기 때문입니다.
이걸 IoC/DI라고 부른다
방금 한 일에 이름이 붙어 있습니다.
- 제어의 역전(IoC, Inversion of Control) — 객체를 만들고 연결하는 결정권이 쓰는 쪽에서 밖으로 넘어간 것
- 의존성 주입(DI, Dependency Injection) — 필요한 객체를 직접 만들지 않고 밖에서 넣어 받는 것
이름이 거창한데, 방금 우리가 한 게 전부입니다. new를 서비스에서 Main으로 옮겼습니다.
여기서 Main의 역할을 스프링이 대신하는 것이 스프링 컨테이너입니다. 객체를 필요한 만큼 만들어 두고, 필요한 곳에 넣어줍니다. 우리가 손으로 짠 Main과 하는 일이 같습니다.
스프링에게 맡기기
Main이 하던 조립을 넘깁니다. 클래스에 애노테이션만 붙이면 됩니다.
1
2
3
4
@Repository
public class MemoryMemberRepository implements MemberRepository {
// 내용 그대로
}
1
2
3
4
5
6
7
8
9
10
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 내용 그대로
}
1
2
3
4
5
6
7
8
9
10
@Service
public class OrderService {
private final MemberRepository memberRepository;
public OrderService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 내용 그대로
}
1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
public MemberController(MemberService memberService) { // new가 사라졌다
this.memberService = memberService;
}
// 내용 그대로
}
생성자 코드는 순수 자바 때와 한 글자도 안 다릅니다. 달라진 건 값을 넣어주는 사람이 Main에서 스프링으로 바뀐 것뿐입니다.
스프링이 하는 일은 이렇습니다.
1
2
3
1. 시작할 때 패키지를 훑어서 @Component 계열이 붙은 클래스를 모은다
2. 그 클래스들의 객체를 하나씩 만들어 둔다 ← 이 객체를 '빈(bean)'이라고 부른다
3. 생성자가 요구하는 타입을 보고 알맞은 빈을 찾아 넣는다
3번이 핵심입니다. MemberService의 생성자가 MemberRepository를 요구하니, 스프링이 그 타입의 빈(MemoryMemberRepository)을 찾아 넣습니다. 타입으로 찾기 때문에 구현체 이름을 아무도 안 적어도 됩니다.
패키지를 훑는 범위는 1편에서 본 그대로 메인 클래스가 있는 패키지와 그 하위입니다. com.example.demo.member와 com.example.demo.order는 그 아래라서 잘 잡힙니다.
@Component / @Service / @Repository
네 가지가 나왔는데, 셋은 사실상 같은 것입니다.
| 애노테이션 | 붙이는 곳 | 추가로 하는 일 |
|---|---|---|
@Component |
특별한 역할이 없는 클래스 | 없음. 기본형이다 |
@Service |
서비스 계층 | 없음. 읽는 사람에게 역할을 알려주는 이름표 |
@Repository |
저장소 계층 | DB에서 난 예외를 스프링 예외로 바꿔준다 |
@RestController |
웹 요청 처리 | 요청 매핑 대상이 된다 |
@Service와 @Component는 기능이 같습니다. 그래도 @Service를 쓰는 이유는 파일을 열었을 때 이게 무슨 계층인지 바로 보이기 때문입니다. 3편에서 나눈 계층이 애노테이션으로 드러납니다.
생성자가 하나뿐이면
@Autowired를 안 붙여도 됩니다. 스프링 4.3부터 생성자가 하나면 자동으로 주입 대상으로 봅니다. 예제 코드에@Autowired가 없는 이유입니다.
확인 3: 스프링이 넣어준 게 같은 객체인가
Main에서는 new를 한 번만 해서 하나라는 걸 눈으로 확인했습니다. 스프링도 그럴까요.
애플리케이션이 다 뜬 뒤에 한 번 실행되는 클래스를 만듭니다.
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
package com.example.demo;
import com.example.demo.member.MemberService;
import com.example.demo.order.OrderService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class BeanCheckRunner implements CommandLineRunner {
private final MemberService memberService;
private final OrderService orderService;
public BeanCheckRunner(MemberService memberService, OrderService orderService) {
this.memberService = memberService;
this.orderService = orderService;
}
@Override
public void run(String... args) {
System.out.println("memberService의 저장소 = " + memberService.getMemberRepository());
System.out.println("orderService의 저장소 = " + orderService.getMemberRepository());
System.out.println("같은 객체인가 = "
+ (memberService.getMemberRepository() == orderService.getMemberRepository()));
}
}
CommandLineRunner를 구현하고 빈으로 등록해 두면 스프링이 시작을 마친 뒤 run을 한 번 불러줍니다. 서버를 띄웁니다.
1
2
3
4
5
INFO 12345 --- [demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path ''
INFO 12345 --- [demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 1.318 seconds (process running for 1.605)
memberService의 저장소 = com.example.demo.member.MemoryMemberRepository@5b6813df
orderService의 저장소 = com.example.demo.member.MemoryMemberRepository@5b6813df
같은 객체인가 = true
@5b6813df로 같습니다. 스프링은 같은 타입의 빈을 하나만 만들어 두고 필요한 곳마다 그 하나를 돌려줍니다. 이걸 싱글톤이라고 부릅니다. 3편에서 컨트롤러 필드에 담은 값이 요청 사이에 유지됐던 것도 같은 이유입니다.
요청이 초당 수백 개씩 와도 객체를 새로 안 만듭니다. 만약 매번 만든다면 요청마다 저장소가 새로 생겨서, 확인 1에서 본 문제가 요청 단위로 일어날 겁니다.
빈이 하나뿐이라는 건 여러 요청이 같은 객체를 동시에 쓴다는 뜻이기도 합니다. 그래서 빈의 필드에 요청마다 달라지는 값을 담아두면 안 됩니다. 지금은
Map에 데이터를 모아두는 용도라 괜찮지만, 5편에서 DB로 옮기면 이 걱정도 같이 사라집니다.
실제로 되는지
OrderController를 만들어 끝까지 확인합니다.
1
2
3
4
package com.example.demo.order;
public record OrderCreateRequest(Long memberId, String itemName, int price) {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.demo.order;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public Order order(@RequestBody OrderCreateRequest request) {
return orderService.order(request.memberId(), request.itemName(), request.price());
}
}
3편에서 하던 대로 회원을 먼저 가입시키면 {"id":1, ...}이 돌아옵니다. 그 id로 주문을 넣습니다.
1
2
3
curl -X POST localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"memberId":1,"itemName":"키보드","price":50000}'
1
{"id":1,"member":{"id":1,"name":"민아","email":"abcd1234@gmail.com"},"itemName":"키보드","price":50000}
다른 컨트롤러에서 가입시킨 회원을 주문에서 찾았습니다. 저장소가 하나라는 게 API 수준에서 확인됐습니다.
응답에 회원 정보가 통째로 들어간 게 눈에 걸립니다. 6편에서 손봅니다.
왜 생성자로 받나
주입받는 방법이 생성자만 있는 건 아닙니다. 필드에 바로 꽂는 방법도 있습니다.
1
2
3
4
5
6
@Service
public class MemberService {
@Autowired
private MemberRepository memberRepository; // 생성자가 없다
}
짧아서 편해 보이지만, 이 시리즈에서는 생성자만 씁니다. 이유가 셋입니다.
1. final을 붙일 수 있다. 한 번 정해지면 안 바뀌는 게 컴파일 단계에서 보장됩니다. 필드 주입에는 final을 못 붙입니다.
2. 무엇이 필요한지가 생성자 한 줄에 다 적혀 있다. public MemberService(MemberRepository memberRepository)만 보면 이 클래스를 만들려면 무엇이 있어야 하는지 알 수 있습니다. 필드 주입은 클래스 전체를 훑어야 합니다.
3. 스프링 없이 만들 수 있다. 3편에서 서비스를 new MemberService()로 만들어 테스트했습니다. 생성자에 파라미터가 생겼으니 이렇게 바뀝니다.
1
2
3
4
5
6
7
8
9
@Test
void 회원을_가입시키면_찾을_수_있다() {
MemberRepository memberRepository = new MemoryMemberRepository();
MemberService memberService = new MemberService(memberRepository); // 여전히 그냥 new
Member saved = memberService.join("민아", "abcd1234@gmail.com");
assertEquals("민아", memberService.find(saved.id()).name());
}
1
2
3
MemberServiceTest > 회원을_가입시키면_찾을_수_있다() PASSED
BUILD SUCCESSFUL in 2s
스프링을 안 띄우고 그대로 됩니다. 필드 주입이었다면 필드가 private이라 값을 넣을 방법이 없어서 스프링을 띄워야 합니다.
확인이 끝났으면 Main, BeanCheckRunner, getMemberRepository()는 지웁니다. 실험용이라 남길 이유가 없습니다.
정리
- 쓰는 쪽이
new로 직접 만들면 같은 저장소가 여러 개로 갈라진다. 구현체를 바꿀 때도new를 쓴 곳마다 고쳐야 해서 인터페이스로 나눈 의미가 사라진다 - 만드는 일을 밖으로 빼고 생성자로 받기만 하면 둘 다 풀린다. 이게 IoC/DI다
- 스프링 컨테이너는 우리가 짠
Main의 역할을 대신한다. 빈을 만들어 두고 타입을 보고 찾아 넣어준다 @Component/@Service/@Repository는 거의 같다. 계층을 드러내려고 이름을 나눠 쓴다. 생성자가 하나면@Autowired를 생략할 수 있다 (Spring 4.3+)- 빈은 하나만 만들어져서 필요한 곳마다 같은 객체가 들어간다 (
==비교가true) - 생성자 주입을 쓰면
final을 붙일 수 있고, 필요한 것이 한눈에 보이고, 스프링 없이 테스트할 수 있다
다음 5편에서는 Map에 담아두던 데이터를 진짜 DB에 저장합니다.