[백엔드 입문 #3] Spring 의존성 주입과 Bean 싱글톤
지난 글에서 Java 문법을 정리하면서 계속 마음에 걸리는 코드가 있었다.
@UseCase
@RequiredArgsConstructor
public class DeleteFeatureFlag {
private final FindFeatureFlag findFeatureFlag; // 선언만 함
public void execute(DeleteFeatureFlagCommand command) {
findFeatureFlag.findFeatureFlag(...); // 바로 사용함
}
}
final 필드는 생성자에서 반드시 값을 넣어야 한다. 그런데 생성자도 없고, new DeleteFeatureFlag(...)를 부르는 곳도 없다. 그런데도 null이 아니다. 이 두 가지를 각각 다른 도구가 대신해 준다.
① 생성자는 Lombok이 만든다
@RequiredArgsConstructor는 “final 필드를 모두 받는 생성자를 만들어줘”라는 뜻이다. 컴파일하면 실제로 아래 코드가 생긴다.
public DeleteFeatureFlag(UserApplicationService userService,
FindFeatureFlag findFeatureFlag,
FindUserFeatureFlagRelation findUserFeatureFlagRelation,
LoadFeatureFlagHistory loadFeatureFlagHistory) {
this.userService = userService;
this.findFeatureFlag = findFeatureFlag;
...
}
② 생성자 호출은 Spring이 한다
서버가 시작될 때 Spring이 하는 일은 이렇다.
- 찾기:
@Component가 붙은 클래스를 전부 찾는다. - 분석: 각 생성자를 보고 무엇이 필요한지 파악한다.
- 조립: 필요한 것부터
new해서 생성자에 넣어준다.
이렇게 Spring이 만들어 관리하는 객체를 Bean이라고 부른다.
그런데 DeleteFeatureFlag에는 @Component가 아니라 @UseCase가 붙어 있었다. 정의를 열어 보면:
@Component
public @interface UseCase {}
@UseCase는 @Component인데 이름만 바꿔 붙인 것이었다. @RestController, @Repository도 안쪽에 @Component를 갖고 있다. 이름을 다르게 붙여서 코드를 읽을 때 역할이 바로 보이게 한 것이다.
③ 인터페이스 자리에는 구현체를 찾아서 넣는다
FindFeatureFlag는 인터페이스라 new 할 수 없다. Spring은 “FindFeatureFlag를 implements 한 Bean이 있나?” 하고 찾는다.
@Repository
public class FeatureFlagRepository implements LoadFeatureFlag, FindFeatureFlag, ExistFeatureFlag
하나 찾았으니 이걸 넣는다. 서버가 켜질 때의 조립 순서를 그려 보면 이렇다.
FeatureFlagJpaRepository (JPA가 자동 생성)
↓ 주입
FeatureFlagRepository (@Repository) ── FindFeatureFlag 자리에 들어감
↓ 주입
DeleteFeatureFlag (@UseCase)
↓ 주입
FeatureFlagController (@RestController)
DeleteFeatureFlag 코드에는 FeatureFlagRepository라는 이름이 한 번도 나오지 않는다. 그런데도 동작한다. 이게 “UseCase는 DB를 모른다”가 실제로 구현되는 방식이다. 그리고 Java는 implements를 명시해야 그 타입으로 인정하기 때문에(명목적 타이핑), Spring이 구현체를 정확히 찾을 수 있다.
프론트로 비유하면
// Spring이 서버 시작 시 해주는 일을 직접 쓴다면
const repo = new FeatureFlagRepository(jpa);
const deleteUseCase = new DeleteFeatureFlag(userService, repo, ...);
const controller = new FeatureFlagController(..., deleteUseCase);
이 조립 코드를 사람이 쓰지 않고 Spring이 자동으로 해준다. React의 Context Provider로 의존성을 내려주는 것과 비슷한데, 클래스가 수천 개라서 손으로 조립하는 건 사실상 불가능하다.
만약 @Repository를 지우면?
FeatureFlagRepository에서 @Repository를 지우면, Spring은 이 클래스를 관리해야 하는지 자체를 모르게 된다. 그러면:
FeatureFlagRepository ❌ Bean 아님 (아무도 안 만듦)
↓
DeleteFeatureFlag "FindFeatureFlag 넣어줘" → 구현한 Bean이 없음 → 💥
***************************
APPLICATION FAILED TO START
***************************
Parameter 1 of constructor in ...DeleteFeatureFlag required a bean of type
'...FindFeatureFlag' that could not be found.
API 하나만 안 되는 게 아니라 서버 전체가 켜지지 않는다. 런타임에 null로 터지는 대신 시작 시점에 바로 실패하는 게 DI의 장점이다. 프론트로 치면 런타임 undefined is not a function 대신 빌드 에러로 잡히는 셈이다.
이 에러를 보면 어노테이션이 빠졌거나, 구현체가 없구나를 먼저 의심하면 된다.
④ Bean은 서버 전체에서 하나 (싱글톤)
이번 공부에서 가장 중요하다고 느낀 부분이다.
Spring은 각 Bean을 딱 한 번만 만든다. 사용자 100명이 동시에 삭제 요청을 보내도 DeleteFeatureFlag 객체는 하나고, 100개의 요청이 동시에 그 객체의 execute()를 호출한다.
이렇게 쓰면 안 된다
private Long currentUserId; // ❌ Bean의 필드에 요청 데이터 저장
public void execute(DeleteFeatureFlagCommand command) {
this.currentUserId = command.getUserId();
...
featureFlag.delete(this.currentUserId);
}
처음 이 퀴즈를 받았을 때는 “delete에 userId를 넘기는 게 맞나?”를 묻는 줄 알았다.
delete(userId)가 userId를 받는 건 맞다. 누가 삭제했는지 기록(updatedBy)하기 위해서다. 문제는 그게 아니라 userId를 필드에 저장했다는 것이었다.
사용자 A와 B가 거의 동시에 삭제를 누르면:
시간 → DeleteFeatureFlag 객체 (서버에 1개뿐, 둘이 공유)
currentUserId 필드
A 요청: currentUserId = A
B 요청: currentUserId = B ← A의 값을 덮어씀
A 요청: delete(currentUserId) → B로 기록됨 ❌
B 요청: delete(currentUserId) → B
A가 지웠는데 DB에는 B가 지운 것으로 남는다. 에러도 나지 않고, 동시에 요청이 와야만 생겨서 테스트에서도 거의 재현되지 않는다. 이런 문제를 동시성 문제(race condition) 라고 한다.
프론트 코드로 바꾸면 이런 상황이다.
let currentUserId: number; // 모듈 최상단 변수 = 모두가 공유
export async function deleteFlag(userId: number) {
currentUserId = userId;
await something(); // 이 사이에 다른 호출이 currentUserId를 바꿈
save(currentUserId);
}
브라우저는 사용자 한 명이 쓰니까 이런 문제가 거의 없다. 하지만 서버는 한 프로세스가 수많은 사용자의 요청을 동시에 처리한다. 프론트에서 백엔드로 넘어오면서 가장 크게 바꿔야 하는 감각이 이거라고 느꼈다.
올바른 방식
public void execute(DeleteFeatureFlagCommand command) {
Long userId = command.getUserId(); // 지역 변수 = 이 요청에서만 존재
...
featureFlag.delete(userId);
}
메서드 안의 지역 변수는 호출할 때마다 새로 생기기 때문에 다른 요청과 섞이지 않는다.
규칙: Bean(
@UseCase,@Repository,@RestController)의 필드에는 다른 Bean만 넣고, 요청 데이터는 항상 파라미터나 지역 변수로 다룬다.
정리
| 코드 | Spring에게 하는 말 |
|---|---|
@Component / @UseCase / @Repository / @RestController |
“이 클래스는 네가 만들어서 관리해줘” |
private final X x; + @RequiredArgsConstructor |
“나를 만들 때 X를 넣어줘” |
implements X |
“X가 필요한 곳에 나를 넣어도 돼” |
다음 글에서는 메서드 이름만 써도 SQL이 만들어지는 JPA를 정리한다.