들어가며

3주차 끝에 블랙박스 리스트를 적어뒀었다. 그 중에 하나가 이거였다.

"같은 타입 빈이 두 개일 때 누구를 주입해야 하나?"

이번 주차에서 그 답이 나왔다. 이름으로 구분한다. 단순한 말인데, 직접 구현해보니까 왜 그렇게 할 수밖에 없는지 순서대로 이해가 됐다.

그리고 또 하나. 3주차까지는 컨트롤러를 새로 추가할 때마다 LecturesDispatcherServlet을 열어서 직접 줄을 추가해야 했다. 반복 작업이고, 실수하기도 쉬웠다. 이번 주차에서 그 제약이 사라졌다.


1. 왜 빈을 이름으로도 저장해야 하나

3주차에서 만든 BeanFactoryMap<Class<?>, Object> 하나로 빈을 관리했다. 클래스 타입이 key였다.

 

LectureService, LectureRepository처럼 타입당 빈이 하나씩일 때는 아무 문제가 없다. getBean(LectureService.class) 하면 딱 하나가 나오니까.

 

근데 같은 타입의 빈이 2개 필요한 상황이 되면 한계가 생긴다. DB 서버를 읽기용(replica)과 쓰기용(primary)으로 분리하는 경우가 대표적이다. 둘 다 DataSource 타입인데 getBean(DataSource.class) 하면 어느 쪽을 줘야 할지 알 수 없다.

 

getBean("readDataSource")   // 읽기 전용 DB
getBean("writeDataSource")  // 쓰기 DB

 

이름이 있어야 둘을 구분할 수 있다.

그래서 Map<String, Object> beansByName을 하나 더 추가했다. 빈 등록할 때 두 맵에 동시에 저장한다.

beans.put(clazz, instance);                    // 기존 타입 맵
beansByName.put("lectureService", instance);   // 이름 맵 추가

 

이름 규칙은 클래스 simple name 첫 글자만 소문자로 바꾸는 거다. LectureService"lectureService".

Spring에서 @Qualifier("readDataSource")로 주입받을 빈을 이름으로 지정하는 게 있는데, 그게 필요한 이유가 여기서 보였다.


2. @Bean - 내 손이 닿지 않는 클래스를 빈으로 등록하는 방법

@Component는 클래스에 직접 붙여야 한다. 내가 만든 클래스면 가능한데, Jackson의 ObjectMapper처럼 외부 라이브러리 클래스는 소스를 수정할 수 없다.

// Jackson 라이브러리 클래스 - 소스 수정 불가
// @Component  ← 붙이고 싶어도 못 붙임
public class ObjectMapper { ... }

 

그래서 내가 만든 클래스 안에 메서드를 하나 두고, 그 메서드가 반환하는 객체를 프레임워크가 빈으로 등록하게 하는 방식이 @Bean이다.

@Component
class AppConfig {

    @Bean("objectMapper")
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }
}

 

 

BeanFactory가 처리하는 흐름은 이렇다.

@Component 클래스 스캔 → 인스턴스 생성 (기존과 동일)
        ↓
생성된 빈 중 @Bean 메서드가 있는 클래스 탐색
        ↓
@Bean 메서드 invoke → 반환값을 빈으로 추가 등록

 

 

@Component@Bean이든 결과물은 그냥 자바 객체다. 만드는 방법만 다를 뿐 저장과 조회는 똑같이 동작한다.


3. @RequestMapping - 컨트롤러 매핑 자동화

3주차까지는 LecturesDispatcherServlet에 이렇게 수동으로 매핑을 추가했다.

handlerMapping.setMapping("GET",    "/lectures", beanFactory.getBean(LectureListController.class));
handlerMapping.setMapping("POST",   "/lectures", beanFactory.getBean(LectureCreateController.class));
handlerMapping.setMapping("PUT",    "/lectures", beanFactory.getBean(LectureUpdateController.class));
handlerMapping.setMapping("DELETE", "/lectures", beanFactory.getBean(LectureDeleteController.class));

 

컨트롤러 하나 추가할 때마다 이 파일을 열어서 줄을 추가해야 했다. 파일이 여러 개로 나뉘어서 관리도 번거로웠다.

@RequestMapping 애너테이션을 만들고 각 컨트롤러에 붙인 다음, BeanFactory가 스캔한 빈을 돌면서 자동으로 매핑하도록 바꿨다.

@Component
@RequestMapping(method = "GET", uri = "/lectures")
public class LectureListController implements Controller { ... }
// 자동 스캔
beanFactory.getBeans().forEach((clazz, bean) -> {
    if (bean instanceof Controller && clazz.isAnnotationPresent(RequestMapping.class)) {
        RequestMapping rm = clazz.getAnnotation(RequestMapping.class);
        handlerMapping.setMapping(rm.method(), rm.uri(), (Controller) bean);
    }
});

 

이제 컨트롤러를 추가할 때 @RequestMapping만 붙이면 된다. LecturesDispatcherServlet을 건드릴 필요가 없다.

 

 

이게 컴파일 타임에 일어나는 건가?

아니다. 전부 런타임이다.

@RequestMapping이 붙어있다는 사실은 컴파일 타임에 .class 파일에 기록된다. 하지만 실제로 읽고 처리하는 건 서버가 뜰 때 BeanFactory가 리플렉션으로 하는 거다.

컴파일 타임: @RequestMapping 정보가 .class 파일에 기록됨
런타임:      BeanFactory 생성자 호출 → 리플렉션으로 @RequestMapping 읽음 → HandlerMapping 등록

 

 

3주차에서 @Retention(RetentionPolicy.RUNTIME) 없으면 isAnnotationPresent()가 항상 false를 반환한다고 했는데, 그게 여기서도 똑같이 적용된다. 런타임에 읽어야 하니까 RUNTIME 보존이 필수다.


Before / After

학습 전 학습 후
@Bean이 뭔지는 알지만 @Component랑 뭐가 다른지 몰랐다 소스를 수정할 수 없는 외부 라이브러리 클래스를 빈으로 등록하기 위한 우회 방법이다
같은 타입 빈이 두 개면 어떻게 되는지 몰랐다 이름으로 구분한다. @Qualifier가 존재하는 이유가 여기서 보였다
@RequestMapping이 컨트롤러에 붙어있으면 Spring이 알아서 매핑해준다고만 알았다 런타임에 리플렉션으로 애너테이션을 읽어서 핸들러 맵에 등록하는 거였다

비밀번호 하나 검증하려다 VO를 만들고, 레이어 책임을 다시 생각하게 된 이야기


왜 이 글을 쓰게 됐나

루퍼스 백엔드 부트캠프 1주차 과제는 TDD로 Member 도메인 구현하기였다.

솔직히 처음엔 테스트 코드를 먼저 짜야 하는 이유를 잘 몰랐다. 그냥 "먼저 짜는 거구나" 하고 시작했는데, 막상 해보니 테스트 코드가 내 설계의 문제를 나보다 먼저 알아채고 있었다.

 

이 글은 그 과정에서 겪은 고민들을 정리한 글이다.


TDD가 뭔데?

TDD는 Test-Driven Development, 테스트 주도 개발이다. 코드를 먼저 짜는 게 아니라, 테스트를 먼저 짜고 그 테스트를 통과시키는 방식으로 개발한다.

 

흐름은 이렇다.

🔴 Red    -> 실패하는 테스트를 먼저 작성한다
🟢 Green  -> 테스트를 통과시키는 최소한의 코드를 작성한다
🔵 Refactor -> 동작은 유지하면서 코드를 정리한다

 

이 사이클을 반복한다.

 

처음엔 "테스트가 먼저 실패하는 게 뭐가 의미 있지?" 싶었는데, 직접 해보니까 테스트를 먼저 쓰는 행위 자체가 "이 코드가 뭘 해야 하는지"를 먼저 정의하는 과정이라는 걸 느꼈다. 구현하기 전에 인터페이스와 책임을 먼저 고민하게 되는 것이다.

 

테스트 작성은 3A 패턴을 따른다.

@Test
void returns201_whenAllFieldsAreValid() {
    // Arrange - 준비: 테스트에 필요한 데이터와 환경 설정
    MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest(
        "testUser1", "Password1!", "홍길동", "1990-01-01", "test@example.com"
    );

    // Act - 실행: 테스트 대상 기능 호출
    ResponseEntity<ApiResponse<Void>> response = testRestTemplate.exchange(
        ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType
    );

    // Assert - 검증: 결과 확인
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}

테스트 더블이란?

테스트를 짜다 보면 외부 의존성(DB, 다른 서비스 등)이 문제가 된다. 실제 DB를 매번 띄우면 느리고, 특정 상황을 재현하기도 어렵다.

이때 쓰는 게 테스트 더블(Test Double) 이다. 영화 촬영에서 위험한 장면을 대신 찍는 스턴트 더블처럼, 테스트에서 실제 객체 대신 가짜 객체를 사용하는 것이다.

 

대표적인 종류 세 가지만 정리하면,

Stub 미리 정해진 값을 반환하도록 설정한 가짜 객체 특정 상황을 재현하고 싶을 때
Mock 메서드가 호출됐는지, 몇 번 호출됐는지 검증하는 가짜 객체 상호작용(호출 여부)을 검증하고 싶을 때
Spy 실제 객체처럼 동작하면서 호출 기록도 남기는 객체 실제 로직은 유지하되 호출 여부도 확인하고 싶을 때

 

코드로 보면 이렇다.

// Mock - 가짜 객체. 실제로 아무것도 하지 않음
MemberRepository memberRepository = mock(MemberRepository.class);

// when().thenReturn() 으로 반환값 설정 (Stubbing)
when(memberRepository.findByLoginId("testUser1")).thenReturn(Optional.of(member));

// verify로 호출 검증
verify(memberRepository, times(1)).save(any(Member.class));

// Spy - 실제 객체를 감싸서 호출 기록도 남김
BCryptPasswordEncoder passwordEncoder = spy(new BCryptPasswordEncoder());

// 실제 encode()가 실행되면서 호출됐는지도 검증 가능
verify(passwordEncoder, times(1)).encode("NewPassword2@");

 

실제 스프링 빈을 쓰려면 스프링 컨텍스트 전체를 띄워야 한다. mock/spy를 쓰면 순수 자바 객체로 만들 수 있어서 훨씬 빠르고 가볍다. 그게 단위 테스트에서 테스트 더블을 쓰는 핵심 이유다.


처음엔 Member 안에 다 때려넣었다

개념은 알았는데, 실제로 구현할 때는 별 고민 없이 Member 생성자 안에 모든 검증 로직을 넣었다.

public Member(String loginId, String password, String name, String birthDate, String email) {
    validatePassword(password, birthDate);
    validateEmail(email);
    validateBirthDate(birthDate);
    this.loginId = loginId;
    this.password = password;
    ...
}

 

처음엔 "뭐 돌아가니까 됐다"고 생각했다. 근데 단위 테스트를 작성하기 시작하면서 이상한 느낌이 들었다.


테스트를 짜다가 멈췄다

MemberTest를 작성하는데, 비밀번호 검증 실패 케이스를 쓰다 보니 이런 생각이 들었다.

"지금 내가 뭘 테스트하는 거지? Member를 테스트하는 건가, 비밀번호 규칙을 테스트하는 건가?"

 

Member 단위 테스트인데 비밀번호 길이 검증, 특수문자 규칙, 생년월일 포함 여부까지 죄다 MemberTest에 들어가고 있었다.

게다가 비밀번호 암호화를 나중에 추가해야 한다는 걸 뒤늦게 깨달았는데, 암호화된 비밀번호가 생성자에 들어오게 되면 검증 자체가 이상해진다.

// 이미 암호화된 "$2a$10$..." 같은 값이 들어오면
// 길이도 16자 초과, 특수문자 규칙도 다 깨진다

 

테스트를 짜다가 "이거 뭔가 잘못됐다"는 생각이 들었다..


Value Object란?

설계를 고민하다가 Value Object(VO) 개념을 찾아보게 됐다.

VO는 값 자체로 동등성을 판단하는 객체다. 일반 엔티티가 ID로 식별되는 것과 달리, VO는 가진 값이 같으면 같은 객체로 본다.

 

VO의 특징은 이렇다.

 

불변성 생성 후 상태가 바뀌지 않는다
값 동등성 ID가 아니라 값이 같으면 동일하다
자기 검증 생성 시점에 유효성을 스스로 보장한다
행위 포함 값과 관련된 행위를 직접 가진다

 

비밀번호는 자체적인 규칙(길이, 형식, 생년월일 포함 여부)과 행위(검증, 변경)가 있으니 VO로 분리하기 딱 좋은 후보였다.

반면 이메일이나 생년월일은 포맷 검증만 있고 별도 행위가 없어서 굳이 VO로 만들지 않았다. 이게 맞는 판단인지는 멘토링에서 여쭤볼 예정이다.


@Embedded / @Embeddable이란?

VO를 JPA 엔티티에서 활용할 수 있게 해주는 게 @Embedded와 @Embeddable이다.

  • @Embeddable : 이 클래스는 다른 엔티티에 포함될 수 있는 값 타입이다
  • @Embedded : 이 필드는 @Embeddable 클래스다

핵심은 자바 객체는 분리되어 있지만, DB 테이블은 하나라는 것이다.

// 자바 코드에서는 Password 객체가 분리되어 있고
@Embeddable
public class Password {
    @Column(name = "password")
    private String encodedValue;
}

// Member 엔티티에서 @Embedded로 포함시키면
@Entity
public class Member {
    @Embedded
    private Password password;
}
-- DB 테이블은 members 하나에 password 컬럼이 그냥 들어간다
CREATE TABLE members (
    id BIGINT PRIMARY KEY,
    login_id VARCHAR(255),
    password VARCHAR(255),  -- Password 객체의 encodedValue
    name VARCHAR(255),
    ...
);

 

객체지향적으로 설계하면서도 DB 스키마는 단순하게 유지할 수 있는 게 장점이다.


Password를 분리하기로 했다

이 개념들을 적용해서 Password를 @Embeddable VO로 분리했다.

@Embeddable
public class Password {

    @Column(name = "password", nullable = false)
    private String encodedValue;

    protected Password() {}  // JPA 기본 생성자

    private Password(String encodedValue) {
        this.encodedValue = encodedValue;
    }

    // 생성 시 검증 책임
    public static Password of(String rawPassword, String birthDate, String encodedValue) {
        validate(rawPassword, birthDate);
        return new Password(encodedValue);
    }

    // 비밀번호 일치 검증 책임
    public boolean matches(String rawPassword, PasswordEncoder encoder) {
        return encoder.matches(rawPassword, this.encodedValue);
    }

    // 비밀번호 변경 책임
    public Password change(String oldRaw, String newRaw, String birthDate, String newEncoded, PasswordEncoder encoder) {
        if (!matches(oldRaw, encoder)) throw new CoreException(ErrorType.UNAUTHORIZED, "기존 비밀번호가 올바르지 않습니다.");
        if (matches(newRaw, encoder)) throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.");
        return Password.of(newRaw, birthDate, newEncoded);
    }
}

 

Member는 이제 비밀번호에 직접 관여하지 않고 위임만 한다.

 

@Entity
public class Member {
    @Embedded
    private Password password;

    // 단순 위임
    public void changePassword(String oldRaw, String newRaw, String newEncoded, PasswordEncoder encoder) {
        this.password = password.change(oldRaw, newRaw, this.birthDate, newEncoded, encoder);
    }
}

 

분리하고 나니 Member는 멤버 객체 생성에, Password는 비밀번호 규칙에 집중하게 됐다.


또 다른 고민: encode()는 어디서 해야 할까

 

분리는 했는데 또 막히는 지점이 생겼다.

처음엔 Password.of() 내부에서 암호화까지 같이 처리했다.

// 초기 버전 - 도메인 안에서 encode() 실행
public static Password of(String rawPassword, String birthDate, PasswordEncoder encoder) {
    validate(rawPassword, birthDate);
    return new Password(encoder.encode(rawPassword)); // 암호화가 도메인에서
}

 

PasswordEncoder를 외부에서 주입받는 방식이라 직접 빈을 참조하진 않지만, encode() 실행 자체가 도메인 안에서 일어나고 있다.

멘토링에서 들었던 말이 생각났다.

 

"비즈니스 규칙(검증)은 도메인, 인프라 관심사(암호화)는 서비스 계층"

 

그래서 encode()를 서비스 계층으로 올렸다.

// 변경 후 - MemberService에서 encode()
@Transactional
public Member register(String loginId, String password, String name, String birthDate, String email) {
    String encoded = passwordEncoder.encode(password); // 암호화는 서비스에서
    Password encodedPassword = Password.of(password, birthDate, encoded);
    return memberRepository.save(new Member(loginId, encodedPassword, name, birthDate, email));
}

// Password.of() - 검증만 남음
public static Password of(String rawPassword, String birthDate, String encodedValue) {
    validate(rawPassword, birthDate);
    return new Password(encodedValue);
}

 

원칙대로 분리했는데, 테스트를 짜면서 또 어색한 부분이 생겼다.

// 검증 실패 케이스인데 어차피 예외 나서 쓰이지도 않는 값을 encode()로 만들어야 함
Password.of(shortPassword, "1990-01-01", encoder.encode(shortPassword))
//                                        ↑ 이게 의미가 있나?

 

원칙을 따르면 테스트 가독성이 떨어지고, 테스트 가독성을 살리면 원칙에서 벗어나는 트레이드오프가 생겼다. 아직 이 부분은 답을 못 찾았고, 멘토링에서 여쭤볼 예정이다.


테스트 책임도 나눴다

Password VO를 분리하고 나서 각 테스트 레이어의 책임도 자연스럽게 정리됐다.

PasswordTest        -> 비밀번호 규칙 검증 (길이, 형식, 생년월일 포함 여부)
                    -> 비밀번호 변경 규칙 (기존 비번 틀림, 동일 비번 불가)
                    -> 새 비밀번호가 올바르게 암호화됐는지

MemberTest          -> changePassword() 호출 후 password 필드가 교체됐는가

MemberServiceTest   -> encode()가 올바른 시점에 호출됐는가 (spy 검증)
                    -> save(), findByLoginId() 등 메서드 호출 검증 (mock 검증)

MemberServiceIntegrationTest  -> 레이어가 실제로 연결됐을 때 DB 저장/조회가 되는가

 

MemberTest의 비밀번호 변경 테스트가 이렇게 바뀌었다.

 

// before - 암호화 검증까지 같이 하고 있었음
void changesPassword_whenCredentialsAreValid() {
    member.changePassword("Password1!", "NewPassword2@", encoder);
    assertThat(member.matchesPassword("NewPassword2@", encoder)).isTrue(); // ← Password 책임
}

// after - 필드 교체 여부만 본다
void changesPassword_whenCredentialsAreValid() {
    Password oldPassword = member.getPassword();

    member.changePassword("Password1!", "NewPassword2@", encoder.encode("NewPassword2@"), encoder);

    assertThat(member.getPassword()).isNotSameAs(oldPassword); // ← Member의 관심사만
}

 

MemberServiceTest도 "encode가 호출됐는가"만 보도록 바꿨다.

// before
assertThat(member.matchesPassword("NewPassword2@", passwordEncoder)).isTrue();

// after - passwordEncoder를 spy()로 선언해서 호출 검증
verify(passwordEncoder, times(1)).encode("NewPassword2@");

 

각 테스트가 자기 레이어의 관심사에만 집중하게 됐다.


결과와 배운 것

솔직히 처음엔 테스트 코드를 먼저 짜야 하는 이유를 체감하지 못했다.

 

근데 직접 해보면서 느낀 건, 테스트를 짜다가 "이거 왜 이렇게 짜기 불편하지?" 라는 감각이 오면 그게 설계가 뭔가 잘못되었고, 다시 살펴 볼 수 있다는 것이다.

 

MemberTest에서 비밀번호 규칙 케이스를 쓰는 게 불편했던 이유는 Member에 책임이 너무 많이 몰려 있었기 때문이었다. 테스트가 먼저 그걸 알아챘다.

 

각 계층의 검증 책임도 정리됐다.

  • 컨트롤러 : HTTP 요청 형식, API 스펙 검증
  • 도메인 : 비즈니스 규칙 검증 (비밀번호 형식, 생년월일 포함 여부 등)
  • 서비스 : 인프라 관심사 처리 (암호화 등)

아직 트레이드오프를 어떻게 판단해야 하는지, VO 분리 기준이 어디에 있는지는 명확한 답을 얻지는 못했다. 앞으로의 멘토링과 라이브세션 등을 통해 최대한 여쭤보고 답을 얻을려고 한다. 

 

들어가며

Spring Boot로 프로젝트를 몇 번 굴려봤다. @Service 붙이면 어디서든 주입돼서 쓸 수 있었고, @Autowired 달면 알아서 객체가 들어와 있었다. 사실 그게 왜 되는지 한 번도 깊게 생각 안 해봤다. "Spring이 알아서 해주는 거"라고 믿고 넘겼다.

 

이번 주차는 그 "알아서 해주는 거"의 안쪽을 들춰보는 시간이었다. 결론부터 말하면, 마법은 없었다. 리플렉션 + 재귀 + Map 하나로 거의 다 만들어진다.

 

근데 글 쓰면서 한 가지 더 알게 됐다. "Spring이 왜 리플렉션을 쓸 수밖에 없는지" 자체가 가장 큰 수확이었다는 거다. 5년을 써도 거기까지는 한 번도 안 닿았더라.


1. 두 개의 컴파일 시점

3주차에서 막혔던 첫 번째 질문은 이거였다.

 

"리플렉션이 없으면 프레임워크가 왜 성립할 수 없어?"

 

답을 들었을 때 처음엔 이해가 안 갔다. "프레임워크가 컴파일될 시점에는 내가 만들 클래스가 존재하지 않는다"는 말이 있는데, 내가 컴파일 누르면 내 코드 다 컴파일되는 거 아닌가? 컴파일러가 다 알고 있는 거 아닌가?

 

여기서 함정이 있었다. "누가" 컴파일되는 시점인지 를 놓쳤다.

 

2003년쯤  ─ Spring 소스 코드 작성/컴파일 → spring-core.jar 박제됨
                                              │
2026년    ─ 내가 LectureService.java 작성    │ ← 이 jar를 라이브러리로 끌어옴
                                              ▼
            javac LectureService.java     컴파일됨
                          │
                          ▼
            java -jar 앱 실행 → 그제서야 Spring jar와 내 코드가 같은 JVM에서 만남

 

Spring jar는 이미 옛날에 컴파일이 끝나서 .class 파일이 박제된 상태다. 그 박제된 파일 안에 LectureService라는 단어가 박혀 있을 리가 없다. 미래에 어떤 사용자가 무슨 이름의 클래스를 만들지 Spring 개발자가 미리 알 방법이 없으니까.

 

식당으로 비유하면 이런 모양이 된다.

 

식당(Spring): 이미 영업 시작. 메뉴판도 인쇄 완료.
              메뉴판에는 "김치찌개, 된장찌개..." 만 적혀 있다.

손님(나):     도시락 들고 옴. 식당에 "이거 데워주세요" 하고 내민다.
              식당은 이 도시락이 뭔지 영업 중에 처음 본다.

 

식당이 처음 만들어질 때 손님 도시락이 세상에 없었다. 그런데 손님이 와서 처리해달라고 한다. 식당이 도시락을 처리하려면 "도시락의 정체를 그때그때 살펴보는 능력" 이 필요하다. 그게 리플렉션이다.


2. 클래스를 데이터로 다룬다는 말

두 번째로 막힌 지점은 "그러면 어떻게 다루는데?"였다. "코드로 못 박으면 데이터로 받는다"는 표현 자체가 추상적이었다.

 

평소에 우리가 변수에 담는 건 인스턴스다.

 

LectureService service = new LectureService();  // 인스턴스

 

자바에는 클래스 자체를 가리키는 객체가 따로 있다. 그게 Class 타입이다.

 

Class<?> clazz = LectureService.class;  // 클래스 자체

 

clazz에는 인스턴스가 들어 있는 게 아니다. "LectureService라는 클래스가 어떻게 생겼는지에 대한 설명서" 가 들어 있다.

 

붕어빵 틀로 비유하면 이렇게 갈린다.

 

붕어빵 (인스턴스)         →  손에 들고 먹는 실체. 평소 우리가 다루는 것.
붕어빵 틀 (Class 객체)    →  붕어빵을 찍어내는 도구. 리플렉션이 다루는 것.

 

내가 한 가지 더 헷갈렸던 게 "그 Class 객체를 어떻게 받는데?"였는데, 받는 방법은 단순했다.

 

Class<?> clazz = LectureService.class;                          // 직접
Class<?> clazz = Class.forName("com.diy...LectureService");     // 문자열로
Class<?> clazz = service.getClass();                            // 인스턴스에서 거꾸로
Set<Class<?>> classes = scanner.scanClasses...(Component.class); // 라이브러리가 한꺼번에

 

JVM이 클래스 로딩할 때 각 클래스마다 Class 객체를 메모리에 자동으로 만들어둔다. 우리는 그 객체의 참조를 변수에 잡기만 하면 된다. "만든다"가 아니라 "수집한다" 가 더 정확한 표현이다.

 

변수에 담겼다는 게 왜 중요하냐면, 변수면 자유롭게 다룰 수 있기 때문이다.

 

Class<?> clazz = LectureService.class;

createBean(clazz);              // 다른 함수에 넘길 수 있다
beans.add(clazz);               // 컬렉션에 담을 수 있다
for (Class<?> c : beans) {...}  // 루프로 돌릴 수 있다

 

만약 클래스가 변수가 아니라 코드에 박혀야만 한다면, new LectureService(); new LectureRepository(); ... 를 손으로 100번 적어야 한다. 변수로 받을 수 있다는 것 하나가 프레임워크의 자동화를 가능하게 한다.


3. @Component - 그냥 표식일 뿐

이제 리플렉션으로 클래스를 다룰 수 있게 됐다. 그러면 어떤 클래스를 빈으로 만들지 누가 알려주나? 답은 단순했다. 사용자가 클래스 위에 표식을 붙인다.

 

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {
}

 

이게 우리가 만든 @Component다. 내용물이 비어 있다는 게 핵심이다. 이 애너테이션은 정보가 아니라 표식이다. 컴파일러도 JVM도 이 애너테이션 자체로는 아무 일도 안 한다. 우리(프레임워크)가 이걸 읽고 행동할 뿐이다.

 

도서관에 책 1만 권이 있는데, 그 중에 노란 스티커 붙은 책만 골라내는 거랑 똑같다. 스티커는 책 내용과 무관하지만, 그 표시 하나로 어떤 책을 처리할지 결정할 수 있다.

 

여기서 한 가지 주의해야 할 게 있다. @Retention(RUNTIME)이 빠지면 망한다.

 

SOURCE  → 컴파일하면 사라진다 (@Override 같은 거)
CLASS   → .class 파일까진 살아남지만 JVM 메모리에선 버린다
RUNTIME → JVM 메모리에도 살아남는다. 리플렉션으로 읽을 수 있다.

 

프레임워크가 런타임에 @Component를 봐야 하니까 RUNTIME이 필수다. 빼먹으면 isAnnotationPresent(Component.class)가 항상 false로 나와서 디버깅하기 굉장히 힘들다.


4. BeanFactory - 재귀가 곧 위상 정렬이다

@Component 붙은 클래스를 찾았으니 이제 빈으로 만들 차례다. 근데 그게 호락호락하지 않았다. 의존성이 있기 때문이다.

 

@Component
public class LectureRepository {
    public LectureRepository() { /* 의존성 없음 */ }
}

@Component
public class LectureService {
    @Autowired
    public LectureService(LectureRepository repo) { ... }   // 의존성 있음
}

 

LectureService를 만들려면 LectureRepository가 먼저 있어야 한다. 그럼 누가 순서를 정해주나?

 

답은 재귀다. 우리 BeanFactory.createBean이 이렇게 짜여 있다.

 

private Object createBean(Class<?> clazz) {
    if (beans.containsKey(clazz)) {       // (A) 이미 만들었으면 재사용
        return beans.get(clazz);
    }

    Constructor<?> constructor = findConstructor(clazz);            // (B) @Autowired 생성자 찾기
    Object[] params = Arrays.stream(constructor.getParameterTypes())
            .map(this::createBean)                                   // (C) 파라미터를 재귀로 먼저 만듦
            .toArray();
    Object instance = constructor.newInstance(params);              // (D) 인스턴스 생성
    beans.put(clazz, instance);                                      // (E) 캐시에 저장
    return instance;
}

 

LectureService를 만들라고 호출했다 치면 이렇게 흐른다.

 

createBean(LectureService)
  ├─ beans에 있나? 없음
  ├─ @Autowired 생성자 찾음 → LectureService(LectureRepository)
  ├─ 파라미터 LectureRepository를 먼저 만들어야 함
  │   └─ createBean(LectureRepository)
  │        ├─ beans에 있나? 없음
  │        ├─ 기본 생성자, 파라미터 없음
  │        ├─ new LectureRepository()
  │        ├─ beans에 저장
  │        └─ return
  ├─ new LectureService(repository)
  ├─ beans에 저장
  └─ return

 

의존성 그래프를 재귀로 따라 올라간다. 잎(의존 없는 빈)부터 만들어지고, 줄기(이를 의존하는 빈)로 거꾸로 올라온다. 컴퓨터공학에서는 이걸 위상 정렬이라고 부르는데, 우리는 명시적으로 정렬하지 않고 재귀+캐시로 같은 효과를 얻고 있다.

 

캐시 한 줄(beans.containsKey())이 의외로 일을 많이 한다. 두 개의 컨트롤러가 같은 LectureService를 의존할 때, 캐시 없이 짜면 LectureService가 두 번 만들어진다. 그 안의 LectureRepository도 두 번 만들어진다. 데이터가 다른 인스턴스에 흩어져서 망한다. 캐시 덕분에 싱글톤이 자동으로 보장된다. Spring의 빈이 기본적으로 싱글톤인 이유와 정확히 같다.


5. 실제로 어떻게 갈아끼웠나

2주차 LecturesDispatcherServlet 코드는 이랬다.

 

@Override
protected void initHandlerMappings(HandlerMapping handlerMapping) {
    LectureRepository lectureRepository = new LectureRepository();
    LectureService lectureService = new LectureService(lectureRepository);

    handlerMapping.setMapping("GET", "/lectures", new LectureListController(lectureService));
    handlerMapping.setMapping("POST", "/lectures", new LectureCreateController(lectureService));
    handlerMapping.setMapping("PUT", "/lectures", new LectureUpdateController(lectureService));
    handlerMapping.setMapping("DELETE", "/lectures", new LectureDeleteController(lectureService));
}

 

new로 객체 만들고, 의존성 손으로 끼워 넣고 있다. 3주차 끝에 이걸 이렇게 바꿨다.

 

@Override
protected void initHandlerMappings(HandlerMapping handlerMapping) {
    BeanFactory beanFactory = new BeanFactory("com.diy");

    handlerMapping.setMapping("GET", "/lectures", beanFactory.getBean(LectureListController.class));
    handlerMapping.setMapping("POST", "/lectures", beanFactory.getBean(LectureCreateController.class));
    handlerMapping.setMapping("PUT", "/lectures", beanFactory.getBean(LectureUpdateController.class));
    handlerMapping.setMapping("DELETE", "/lectures", beanFactory.getBean(LectureDeleteController.class));
}

 

new가 다 사라졌다. 의존성 끼워넣기도 사라졌다. 컨트롤러 새로 추가해도 이 파일은 두 줄만 늘어난다(컨트롤러에 @Component 붙이고, 매핑 한 줄 추가). 이게 IoC(제어의 역전) 라는 말이 가리키는 정확한 변화다. 객체를 만드는 책임이 사용 코드에서 컨테이너로 넘어갔다.


6. 삽질: ComponentTest가 갑자기 깨졌다

3주차 작업 중에 한 번 깨졌다. ComponentTest가 처음에는 잘 돌아갔는데, LectureService@Autowired 생성자를 추가하니까 갑자기 NoSuchMethodException이 났다.

 

java.lang.NoSuchMethodException: ...LectureService.<init>()

 

원인은 단순했다. ComponentTest가 이렇게 짜여 있었다.

 

Object instance = clazz.getDeclaredConstructor().newInstance();

 

getDeclaredConstructor()기본 생성자를 찾는다. 그런데 LectureService@Autowired(LectureRepository) 생성자만 있고 기본 생성자가 없어서 메서드 자체가 없었다.

 

해결은 두 가지였다.

  1. 이 테스트가 검증하려는 의도("빈 생성 1단계")로 되돌려서 BeanScanner만 검증하게 단순화
  2. BeanFactory를 사용해서 의존성까지 풀게 변경

 

나는 1번을 택했다. ComponentTest는 BeanScanner의 스캔 동작만 검증하고, 빈 주입 검증은 AutowiredTest(이제 BeanFactory를 호출)가 맡도록 분리했다.

 

이번 삽질에서 배운 건 "테스트가 검증하려는 의도가 흐려지면 깨진 채로 방치된다" 는 거였다. 빈 생성 1단계 때 작성한 테스트가, 빈 주입 단계로 넘어오면서 깨졌는데, 깨진 채로 두면 다음 변경 때 또 헷갈린다. 의도를 다시 정리하고 단순화하니까 깔끔해졌다.


Before / After

학습 전후로 머릿속의 그림이 어떻게 바뀌었는지 정리해봤다.

 

학습 전 학습 후
@Component 붙이면 Spring이 알아서 빈을 만든다. 어떻게? 모름 Spring은 패키지를 훑어서 표식 붙은 클래스의 Class 객체를 수집한 다음, 리플렉션으로 newInstance() 한다
@Autowired 붙이면 의존성이 알아서 들어온다 @Autowired 생성자를 찾고, 파라미터 타입을 보고, 빈 맵에서 꺼내서 주입한다. 없으면 재귀로 먼저 만든다
리플렉션은 들어본 적 있지만 어디 쓰는지 감 없음 리플렉션이 없으면 프레임워크 자체가 성립할 수 없다. Spring jar가 내 클래스 이름을 알 수 없기 때문
빈이 기본적으로 싱글톤이라는 건 들어봤지만 왜인지 모름 Map<Class, Object> 캐시 한 줄로 자동 달성된다. 마법이 아니라 자료구조다

Spring Boot 생각해보니까

직접 만들어보고 나서 Spring Boot의 평소 보던 것들이 다 매핑됐다.

 

Spring Boot에서 보던 것 우리가 만든 것
@SpringBootApplication@ComponentScan BeanScanner("com.diy")
ApplicationContext BeanFactory
@Service, @Repository, @Controller 전부 @Component의 별칭일 뿐
applicationContext.getBean(LectureService.class) beanFactory.getBean(LectureService.class)
모든 빈이 기본 싱글톤 beans 맵 캐시로 자동 달성

 

특히 @Service, @Repository, @Controller가 사실은 다 @Component라는 걸 알았을 때가 인상 깊었다. Spring 소스를 보면 진짜로 @Service 위에 @Component가 메타-애너테이션으로 붙어 있다. 이름만 다를 뿐 컨테이너 입장에서는 다 똑같은 빈이다.


진짜 큰 수확

3주차에서 코드를 짠 것보다 더 큰 수확은 "Spring이 왜 리플렉션을 쓸 수밖에 없는지" 를 처음으로 잡았다는 거였다.

 

5년 Spring을 써도 거기까진 한 번도 안 닿는다. 책도 강의도 거의 안 다룬다. 다들 @Component 붙이면 빈이 된다고만 알려주지, "Spring jar가 내 코드보다 먼저 컴파일됐기 때문에 클래스를 데이터로 다루는 능력이 필수다"라고는 안 말해준다.

 

이 한 줄을 잡으니까 다른 것들도 같은 눈으로 보이기 시작했다.

 

  • JPA의 엔티티 매니저 - 리플렉션으로 @Entity 찾고, 필드(@Column) 읽어서 SQL 만든다
  • Jackson의 JSON 매핑 - 리플렉션으로 필드 읽어서 JSON 키와 매핑한다
  • JUnit의 테스트 러너 - 리플렉션으로 @Test 메서드 찾아서 호출한다
  • Mockito - 리플렉션으로 메서드 가로채고 가짜 응답 주입한다

 

전부 같은 원리였다. 프레임워크는 내 코드보다 먼저 만들어졌으니까, 내 클래스를 런타임에 리플렉션으로 다룰 수밖에 없다. 이 한 줄이 자바 생태계 전체에 깔려 있는 셈이다.


한 줄 요약

@Controller는 HTML 뷰를 렌더링하고, @RestController(@Controller + @ResponseBody)는 Java 객체를 JSON으로 직렬화해서 반환한다.


개념 정리

Spring에서 클라이언트 요청이 들어오면 DispatcherServlet이 먼저 받아서 알맞은 컨트롤러로 매핑해준다. 이때 어떤 어노테이션을 쓰느냐에 따라 응답 방식이 완전히 달라진다.

  • @Controller → 반환값이 뷰 파일 이름(String) → ViewResolver가 해당 파일을 찾아서 템플릿 엔진이 데이터를 채워 HTML 완성
  • @RestController → 반환값이 Java 객체 → Jackson이 JSON으로 직렬화해서 응답

핵심 차이 / 동작 원리

@Controller @RestController

반환값 뷰 이름 (String) Java 객체
거치는 것 ViewResolver → 템플릿 엔진 HttpMessageConverter (Jackson)
최종 응답 HTML JSON
렌더링 위치 서버 클라이언트

@RestController는 사실 합성 어노테이션이다.

@RestController = @Controller + @ResponseBody

@ResponseBody가 붙으면 반환값을 뷰 이름으로 취급하지 않고, HTTP 응답 본문에 직접 데이터를 쓴다. 모든 메서드에 자동 적용된다.


코드 예시

// @Controller - 뷰 이름 반환
@Controller
public class HomeController {
    @GetMapping("/")
    public String home(Model model) {
        model.addAttribute("name", "철수");
        return "home"; // home.html을 찾아서 렌더링
    }
}

// @RestController - 객체 반환 → JSON
@RestController
public class UserController {
    @GetMapping("/user")
    public User getUser() {
        return new User("철수", 25); // {"name":"철수","age":25}
    }
}

언제 쓸까?

@Controller 선택:

  • Thymeleaf, JSP 등 템플릿 엔진을 쓰는 웹 애플리케이션
  • SEO가 중요한 경우 — 구글 같은 검색엔진은 봇이 인터넷을 돌아다니며 페이지 HTML을 크롤링해서 "이 페이지는 어떤 내용이다" 하고 색인에 등록한다. 쇼핑몰 상품 페이지나 블로그처럼 검색 결과에 노출돼야 하는 페이지는 서버에서 완성된 HTML을 바로 내려줘야 봇이 내용을 읽을 수 있다. @RestController는 JSON만 반환하기 때문에 봇이 내용을 파악하기 어렵고, 검색 결과에 잘 안 뜰 수 있다.

@RestController 선택:

  • 모바일 앱, React/Vue 같은 SPA의 백엔드 API
  • 서버가 데이터만 내려주는 역할을 할 때
  • 마이크로서비스 간 통신

면접 포인트

  1. @RestController와 @Controller의 차이는? → 반환값 처리 방식의 차이. @RestController는 @ResponseBody가 자동 적용돼 객체를 JSON으로 직렬화한다.
  2. SEO 관점에서 왜 @Controller가 유리한가? → 구글 봇이 검색 결과에 페이지를 노출하려면 HTML을 크롤링해야 한다. @Controller는 서버에서 완성된 HTML을 바로 내려주기 때문에 봇이 내용을 바로 읽을 수 있다. 반면 @RestController는 JSON만 반환하므로 React 같은 클라이언트가 화면을 그리기 전까지 봇이 내용을 파악하기 어렵다.
  3. Java 객체가 JSON으로 변환되는 과정은? → @ResponseBody → HttpMessageConverter → Jackson 라이브러리가 직렬화

참고

들어가며

Spring Boot로 프로젝트를 몇 번 했는데, 사실 내부에서 뭐가 돌아가는지 한 번도 생각해본 적이 없었다. @Controller 달고, return "viewName" 하면 화면이 뜨고, Model에 데이터 담으면 JSP에서 꺼내 쓸 수 있었다. 그냥 그런 줄 알고 썼다.

이번 주차는 그 "그냥 그런 줄 알았던 것"들을 직접 만들어보는 시간이었다.


1. 왜 DispatcherServlet이 필요한가

1주차에는 URL마다 서블릿을 하나씩 만들었다. /lecturesLectureServlet, 나중에 /users가 생기면 UserServlet... 이 식으로 가면 URL 100개에 서블릿 100개가 생긴다.

문제는 중복이다. 모든 서블릿마다 아래 코드가 반복된다.

byte[] bodyBytes = req.getInputStream().readAllBytes();
String body = new String(bodyBytes, StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();

그래서 나온 아이디어가 "서블릿은 하나만, 그 안에서 URL에 따라 분기하자"였다.

이전: URL마다 서블릿 1개
이후: DispatcherServlet 1개 → URL 보고 → Controller로 분기

2. HandlerMapping - 분기 테이블

URL에 따라 분기한다고 했는데, if-else 100개 쓸 수는 없다. 그래서 Map을 썼다.

Map<String, Controller> mappings = new HashMap<>();
mappings.put("GET:/lectures", new LectureListController());
mappings.put("POST:/lectures", new LectureCreateController());

 

키는 "METHOD:URI" 형태로 조합했다. 요청이 들어오면 req.getMethod() + ":" + req.getRequestURI()로 키를 만들어서 Map에서 꺼낸다. 이게 HandlerMapping이다.


3. Controller 인터페이스 - 공통 스펙

Map의 value 타입이 문제였다. Controller마다 생긴 게 다른데, 어떻게 하나의 Map에 넣지?

공통 인터페이스를 만들면 된다.

@FunctionalInterface
public interface Controller {
    ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
}

이걸 구현한 LectureListController, LectureCreateController... 이런 클래스들을 Map에 넣을 수 있게 됐다.


4. View 추상화 - 렌더링 방식 분리

Controller가 JSP를 직접 알고 있으면, JSP를 다른 템플릿 엔진으로 바꿀 때 Controller를 전부 고쳐야 한다.

그래서 View 인터페이스를 만들었다.

public interface View {
    void render(Map<String, Object> model, HttpServletRequest req, HttpServletResponse res) throws Exception;
}

 

JspViewreq.getRequestDispatcher().forward()로 구현하고, HtmlView는 HTML 파일을 직접 읽어서 문자열로 응답한다. Controller는 어떤 View인지 몰라도 된다.

ViewResolver"lecture-list" 같은 논리적 이름을 받아서 실제 View 객체를 돌려준다.

 

// suffix만 바꾸면 JSP → HTML 전환. Controller 코드는 그대로.
new ViewResolver("/", ".jsp")
new ViewResolver("/", ".html")

5. ModelAndView - 렌더링을 DispatcherServlet에게

여기까지 오면 Controller가 여전히 ViewResolver를 들고 있고 view.render()도 직접 호출한다. 렌더링 관심사가 Controller에 남아있는 거다.

그래서 Controller 반환 타입을 void에서 ModelAndView로 바꿨다.

// Before
public void handleRequest(req, res) {
    Model model = new Model();
    model.setAttribute("lectures", ...);
    View view = viewResolver.resolve("lecture-list");
    view.render(model, req, res);  // Controller가 직접 렌더링
}

// After
public ModelAndView handleRequest(req, res) {
    return new ModelAndView("lecture-list", Map.of("lectures", LectureStorage.LECTURES));
    // "이 데이터로 이 화면 보여줘" 만 반환. 렌더링은 모름.
}

실제 렌더링은 DispatcherServlet.render()가 처리한다.

 

 

// DispatcherServlet
ModelAndView mav = controller.handleRequest(req, resp);
if (mav != null) {
    render(mav, req, resp);  // 서블릿이 담당
}

리다이렉트도 "redirect:/lectures" 문자열로 표현하고, JspViewResolverRedirectView로 변환해서 처리한다.


전/후 비교

  학습 전 학습 후
요청 처리 URL마다 서블릿 1개 DispatcherServlet 1개 + HandlerMapping
렌더링 Controller가 JSP 직접 forward View 인터페이스 + ViewResolver
데이터 전달 req.setAttribute() ModelAndView
JSP 교체 Controller 전부 수정 ViewResolver suffix 한 줄

회고

Spring Boot에서 내가 쓰던 코드

@Controller
public class LectureController {
    @GetMapping("/lectures")
    public String list(Model model) {
        model.addAttribute("lectures", lectureRepository.findAll());
        return "lecture-list";  // 이게 ViewResolver가 처리하는 논리적 이름
    }
}

 

이게 한 줄처럼 보였는데, 사실 아래 것들이 다 자동으로 돌아가고 있었다.

  • DispatcherServlet → 모든 요청 수신
  • HandlerMapping@GetMapping("/lectures")으로 등록된 컨트롤러 찾기
  • ViewResolver"lecture-list"lecture-list.jsp 변환
  • Model → 파라미터로 주입해서 addAttribute() 호출

 

이번에 해독된 블랙박스

Spring MVC가 요청을 어떻게 분기하는지, 화면을 어떻게 렌더링하는지가 명확하게 보였다. 어노테이션 뒤에 있는 것들을 직접 짜보니까 "아 이게 이렇게 돌아가는 거였구나"가 됐다.

 

관심사 분리

이번 주차 내내 한 일이 결국 하나였다. 각 객체가 자기 일만 하게 쪼개는 것. Controller는 데이터만 준비하고, View는 화면만 그리고, DispatcherServlet은 교통 정리만 한다. 하나를 바꿀 때 나머지를 건드리지 않아도 되게 만드는 게 목표였다.

 

 

 

[TIL] 톰캣이 대체 뭐길래? 내장 서버의 정체

2026-04-19 · 스프링 프레임워크 DIY 스터디 1주차

들어가며

Spring Boot로 팀 프로젝트를 여러 번 해봤는데, 생각해보면 나한테는 한 번도 제대로 답 못했던 질문들이 있었다.

  • main 한 줄 실행하면 서버는 왜 뜨는 걸까?
  • @GetMapping 달면 그 메서드가 알아서 실행되는데, 누가 부르는 거지?
  • "내장 톰캣"이라고 하는데 톰캣이 뭐길래?

솔직히 Redis 같은 것도 쓰긴 했지만 "왜 쓰는지" 설명하라면 말이 잘 안 나왔다. 그냥 "그렇게 돌아가더라" 하고 넘겼던 것들이 쌓여있던 거다.

스프링을 직접 만들어보는 스터디를 시작하면서, 이 블랙박스들을 하나씩 뜯어보기로 했다. 그 첫 번째가 톰캣이랑 HTTP의 관계.


1. HTTP는 그냥 텍스트다

HTTP는 통신 규약이고, 규약이니까 생김새가 정해져 있다. 실제로 브라우저가 서버한테 보내는 요청 모양은 이렇다.

GET /lectures HTTP/1.1
Host: localhost:8080
Content-Type: application/json

{"title": "스프링 프레임워크 DIY"}

그냥 텍스트다. 신기한 바이너리 포맷이 아니고.

첫 줄에 메서드·경로·버전이 오고, 그 아래로 헤더, 빈 줄, body 이 순서.

HTTP랑 JSON 차이

둘이 같은 층위의 얘기가 아니다.

  • HTTP = 편지봉투 규격 자체 (텍스트 포맷)
  • JSON = 편지봉투 안에 들어갈 수 있는 내용물 형식 중 하나

JSON 말고도 HTML, XML, form 데이터 다 body에 들어갈 수 있다. JSON은 그중 하나의 표현 방식일 뿐이고.


2. 톰캣은 식당의 웨이터다

톰캣이 뭐냐고 한 문장으로 말하면, 클라이언트가 보낸 HTTP 텍스트를 내 자바 코드가 알아먹을 수 있는 객체로 바꿔주고, 다시 응답을 HTTP 텍스트로 돌려보내는 프로그램이다.

식당으로 비유하면 이런 느낌.

[손님(브라우저)] → 주문서(HTTP) → [웨이터(톰캣)]
                                       ↓
                                   주문 해석 + 전달
                                       ↓
                                 [주방장(내 자바 코드)]
                                       ↓
                                      요리
                                       ↓
                                  [웨이터(톰캣)]
                                       ↓
[손님]  ←  음식(HTTP 응답)  ←

톰캣도 그냥 자바로 짜여진 프로그램이다. 아파치 재단이 "매번 웨이터 짓 직접 짜기 귀찮잖아?" 하고 공개한 거다.

"서버"라는 단어 정리

이번에 공부하면서 "서버"라는 말이 맥락마다 다르게 쓰인다는 걸 알았다.

이렇게 말할 때 가리키는 건
"AWS에 서버 하나 올렸어" 하드웨어(컴퓨터)
"톰캣은 WAS(Web Application Server)지" 서버 프로그램
"서버 코드 짜" 내가 만든 자바 애플리케이션

즉 식당 비유로 돌아가면, 건물도 서버, 웨이터(톰캣)도 서버, 주방장(내 코드)도 서버. 셋 다 서버라고 부를 수 있다. 그래서 내가 헷갈렸던 거더라.


3. 톰캣 없으면 어떻게 되는데?

사실 톰캣 없이도 웹 서버 만들 수는 있다. 자바에서 ServerSocket 열고 바이트 직접 받으면 되니까.

근데 왜 다들 톰캣 쓰느냐? 간단하다.

  • HTTP 규격이 엄청 복잡하다. 청크 전송, 멀티파트, 쿠키, keep-alive 등등
  • 여러 요청이 동시에 들어오면 각각 스레드로 처리해야 함
  • 성능 최적화까지 하려면 수천 줄

이런 걸 API 만들 때마다 매번 직접 짠다? 미친 짓이다. 그래서 이 공통 작업을 다 해둔 게 톰캣이고, 우리는 그냥 비즈니스 로직에만 집중하면 된다.


4. 요청 한 번의 여정

브라우저에 localhost:8080/lectures 치고 엔터 눌렀을 때, 실제로 뭐가 일어나는지 쭉 따라가보자.

[브라우저]
   │ "GET /lectures HTTP/1.1 ..." 텍스트 요청 생성
   ▼
[OS (8080 포트)]
   │ 바이트 스트림 수신
   ▼
[톰캣]
   │ ① HTTP 텍스트 읽기
   │ ② HttpServletRequest 객체로 파싱
   │ ③ URL 보고 어느 서블릿으로 보낼지 매핑 조회
   │ ④ 그 서블릿 호출
   ▼
[내 자바 코드 (서블릿)]
   │ 비즈니스 로직 실행
   │ 응답 객체에 데이터 작성
   ▼
[톰캣]
   │ ⑤ 응답 객체를 HTTP 텍스트로 직렬화해서 전송
   ▼
[브라우저]
   화면에 결과 표시

톰캣이 하는 일만 뽑으면 이렇게 5개.

  1. 소켓에서 HTTP 텍스트 읽기
  2. HTTP 텍스트를 HttpServletRequest 객체로 파싱
  3. URL 보고 어느 서블릿으로 보낼지 매핑 찾기
  4. 그 서블릿의 메서드 호출 (servicedoGet/doPost/...)
  5. 응답 객체를 HTTP 텍스트로 직렬화해서 전송

그리고 용어 하나. 파싱이랑 직렬화는 방향이 반대다.

  • 파싱(parsing): 텍스트 → 객체
  • 직렬화(serialization): 객체 → 텍스트

"매핑 찾기" = 주문 분배

나는 처음에 ③번을 빠뜨리고 생각했다. 근데 막상 보면 이게 핵심이다. 식당 비유로 다시 보면,

한식 주문 들어오면 한식 주방장, 양식 주문 들어오면 양식 주방장. 누구한테 줄지 정하는 것도 웨이터의 일.

웹에서도 똑같다. /lecturesLectureServlet한테, /usersUserServlet한테. 이 교통정리를 톰캣이 하는 거다. 다음 주차에 이 매핑이 어떻게 이뤄지는지 더 깊게 볼 예정.


5. 그러면 Spring Boot는 대체 뭘 해줬던 거야?

이번 학습에서 나한테 제일 충격이었던 부분이다.

Spring Boot로 작업할 땐 이렇게만 쓰잖아.

@SpringBootApplication
public class MyApp {
    public static void main(String[] args) {
        SpringApplication.run(MyApp.class, args);
    }
}

@RestController
public class LectureController {
    @GetMapping("/lectures")
    public List<Lecture> list() { ... }
}

main 실행 → "와 서버 떴다!" 끝. 그런데 이 뒤에서 사실 이런 일이 돌아가고 있던 거더라.

  • Spring Boot가 톰캣을 내 jar에 같이 넣어둠 (= 내장)
  • main 실행되면 Spring Boot가 톰캣한테 "시작해" 명령
  • 톰캣이 8080에서 대기
  • @GetMapping 붙은 메서드들을 톰캣에 등록하는 것도 Spring Boot가 처리

결국 Spring Boot는 마법이 아니라, 이 설정을 다 자동으로 해주는 편의 도구였던 거다. "내장(Embedded)"이란 말도 이제 와닿는다. 내 자바 프로그램 실행될 때 톰캣도 같이 내 프로그램 안에서 실행되는 것.

잠깐, 그럼 서블릿 ≈ Spring의 컨트롤러 아닌가?

스터디 중에 자연스럽게 이런 생각이 들었다.

서블릿이 요청 받아서 처리하는 거면... 이거 @Controller랑 결국 같은 거 아니야?

 

찾아보니까 역할로는 거의 맞는 직관이었다.

순수 서블릿 시대 Spring MVC
LectureServletdoGet() @Controller@GetMapping 메서드
서블릿 = 요청 처리의 실체 컨트롤러 메서드 = 요청 처리의 실체

근데 구조적으로는 조금 다르다.

  • 옛날 방식: URL마다 서블릿 하나씩 (서블릿이 여러 개)
  • Spring MVC: 서블릿은 DispatcherServlet 하나. 그 안에서 "어느 컨트롤러 메서드 부를지" 골라서 실행

6. 이번 스터디에서 내가 할 일

한 줄로 정리하면, Spring Boot가 대신 해주던 것들을 내 손으로 직접 해보는 거.

이번 주차는 이거.

  • 톰캣 객체 직접 만들어서 서버 띄우기
  • HttpServlet 상속해서 서블릿 직접 작성
  • @WebServlet으로 URL 매핑
  • doGet / doPost / doPut / doDelete로 강의 API 구현

실습: 서블릿으로 강의 API 만들기

개념만 봐서는 감이 잘 안 와서, 강의 API 4개(GET/POST/PUT/DELETE /lectures)를 직접 짜봤다.

한 서블릿에 HTTP 메서드별로 분기하는 구조.

@WebServlet("/lectures")
public class LectureServlet extends HttpServlet {
    @Override protected void doGet(...)    { /* 목록 조회 */ }
    @Override protected void doPost(...)   { /* 등록 */ }
    @Override protected void doPut(...)    { /* 수정 */ }
    @Override protected void doDelete(...) { /* 삭제 */ }
}

 

HttpServlet 상속만 하면 톰캣이 "아, 얘는 서블릿이네" 하고 인식한다. @WebServlet("/lectures")로 URL 매핑 걸어두면 끝. doGet / doPost 같은 메서드는 내가 정의만 해놓으면 톰캣이 나 대신 호출해준다. 이게 내가 프레임워크 원리를 처음 몸으로 체감한 순간이었다.

삽질: MismatchedInputException

POST로 JSON body 받을 때 Jackson이 "Cannot deserialize ..." 에러 뱉었는데, 이유가 좀 허무했다.

// 이것만 있고 기본 생성자가 없음
public Lecture(Long id, String title, String instructor) { ... }

 

Jackson은 new Lecture()로 빈 객체 만든 다음 필드 세팅하는 방식으로 동작하는데, 기본 생성자가 없으니 객체를 못 만드는 거였다.

public Lecture() {}
💡 자바 규칙 다시 확인. 생성자 하나라도 직접 쓰면 기본 생성자가 자동으로 안 생긴다. Lombok @NoArgsConstructor가 왜 있는지 체감되는 순간이었다.

회고: 직접 해보니까 느낀 거

"이거 반복되는 거 아닌가?"

4개 API를 한 서블릿에 때려넣다 보니 같은 코드가 계속 반복되는 게 거슬렸다.

  • ObjectMapper를 매 메서드마다 new로 만들고 있었다. POST/PUT/GET 전부.
  • body 읽는 3단 콤보 (getInputStreamnew StringreadValue)를 POST랑 PUT에 똑같이 복붙.
  • 응답 세팅 (setContentTypesetCharacterEncodingwrite) 도 GET/POST/PUT에 다 반복.

강의 API만 4개인데도 이 정도다. 회원·주문·댓글 API까지 추가되면 진짜 복붙 지옥!!

Spring Boot 생각해보니까

평소에 컨트롤러 짤 땐 이랬다

@GetMapping("/lectures")
public List<Lecture> list() {
    return lectureService.findAll();
}

 

서비스 하나 불러서 결과 return 하는 게 전부. 근데 이번에 직접 짜보니 body 읽기, JSON 직렬화, Content-Type 세팅, 상태 코드... 이런 걸 내가 한 적이 없다는 게 확 느껴졌다.

그 작업들이 생략된 게 아니라, Spring(프레임워크)이 대신 해주고 있었던 거다. 프레임워크는 "일을 덜어주는" 게 아니라, "일을 대신 해주면서 내가 집중해야 할 부분(비즈니스 로직)만 남겨주는" 존재였다.

프레임워크 정의

프레임워크 = 내가 정의한 코드를 나 대신 적절한 타이밍에 호출해주는 것.

서블릿 직접 만들어보니까 이 정의가 몸으로 이해됐다. doGet을 내가 어디서도 안 불렀는데 실행됐고, Spring에서도 @GetMapping 메서드를 내가 부른 적 없는데 돌아갔다. 둘 다 같은 원리다. 프레임워크(톰캣이든 Spring이든)가 나 대신 호출해주는 것이었다.

Spring Boot에서 JWT 인증 구현하기

JWT 기반 인증을 직접 구현하면서 "왜 이렇게 설계하는가"를 정리했다.
단순히 동작하는 코드가 아니라, 각 클래스의 역할과 설계 이유를 이해하는 것에 초점을 맞췄다.


JWT가 뭔지 짚고 가기

JWT(JSON Web Token)는 .으로 구분된 세 파트로 이루어진다.

xxxxx.yyyyy.zzzzz
  ↑      ↑      ↑
Header Payload Signature

Header — 어떤 알고리즘으로 서명했는지

{ "alg": "HS256", "typ": "JWT" }

Payload (Claims) — 실제로 담긴 데이터

{
  "sub": "1",
  "name": "홍길동",
  "role": "ROLE_STAFF",
  "iat": 1710000000,
  "exp": 1710086400
}

Signature — 서버의 secret key로 Header + Payload를 서명한 값. 누군가 Payload를 변조하면 서명이 안 맞아서 예외가 터진다.

⚠️ Payload는 base64 인코딩이라 누구나 디코딩할 수 있다. 비밀번호 같은 민감한 정보는 절대 넣으면 안 된다.


Claims에 뭘 담을까

토큰에 어떤 정보를 담을지는 트레이드오프가 있다.

방식 장점 단점
staffId만 토큰 크기 작음, DB 정보 항상 최신 매 요청마다 DB 조회 필요
staffId + name + role DB 조회 없이 바로 사용 가능 role 변경 시 토큰 만료 전까지 반영 안 됨

사내 B2B 도구처럼 role 변경이 드문 환경에서는 staffId + name + role을 모두 담는 게 합리적이다. 단, role이 바뀌면 토큰을 재발급해야 한다는 제약은 인지해야 한다.


전체 구조

구현은 4개 클래스로 나뉜다. 각자 역할이 다르다.

JwtProvider             → 토큰 기술 담당 (생성/파싱/검증)
JwtAuthenticationFilter → 요청 처리 담당 (언제 꺼낼지)
CustomUserDetails       → 인증 주체 표현 담당 (누구인지)
SecurityConfig          → Spring Security 설정 담당

이렇게 분리한 이유는 단일 책임 원칙(SRP) 때문이다. 나중에 토큰 알고리즘을 바꾸고 싶으면 JwtProvider만 건드리면 되고, 토큰을 헤더 대신 쿠키에서 꺼내고 싶으면 JwtAuthenticationFilter만 수정하면 된다. 하나를 바꿔도 나머지에 영향이 없다.


JwtProvider — 토큰 생성/검증

@Component
public class JwtProvider {

    private final SecretKey key;
    private final long expiration;

    public JwtProvider(@Value("${jwt.secret}") String secret,
                       @Value("${jwt.expiration}") long expiration) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expiration = expiration;
    }

    public String generateToken(Long staffId, String name, String role) {
        Date now = new Date();
        return Jwts.builder()
                .setSubject(String.valueOf(staffId))
                .claim("role", "ROLE_" + role)
                .claim("name", name)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + expiration))
                .signWith(key)
                .compact();
    }

    public boolean isTokenValid(String token) {
        try {
            parseClaims(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    public Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

isTokenValid()에서 parseClaims()의 반환값을 쓰지 않는 게 이상해 보일 수 있는데, parseSignedClaims()가 실행되는 순간 토큰 형식 확인 + 서명 검증 + 만료 시간 확인이 동시에 일어난다. 예외가 터지는지만 보면 되니까 반환값은 필요 없다.

secret key는 절대 코드에 하드코딩하면 안 된다.

# application.yml
jwt:
  secret: ${JWT_SECRET}
  expiration: 86400000

환경변수로 주입받아야 git에 올라가도 안전하다.


CustomUserDetails — 인증 주체 표현

Spring Security의 UserDetails를 구현한 클래스다.

public class CustomUserDetails implements UserDetails {

    private final Long staffId;
    private final String name;
    private final String role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role));
    }

    @Override
    public String getUsername() {
        return String.valueOf(staffId);
    }
    // ...
}

여기서 주의할 점 두 가지.

 

@Component를 쓰면 안 된다.

CustomUserDetails는 Spring 빈이 아니다. 매 요청마다 JWT claims에서 새로 생성하는 객체다. 빈으로 등록하면 싱글턴이 되어 요청 간 데이터가 섞인다.

 

getUsername()에 staffId를 반환한다.

이름은 동명이인이 있을 수 있지만 staffId는 PK라서 항상 유일하다. Spring Security는 getUsername()을 인증 주체의 유일 식별자로 사용하기 때문에 이름보다 ID가 적합하다.


JwtAuthenticationFilter — 모든 요청의 검문소

모든 HTTP 요청이 컨트롤러에 도달하기 전에 통과하는 필터다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String token = extractToken(request);

        if (token != null
                && jwtProvider.isTokenValid(token)
                && SecurityContextHolder.getContext().getAuthentication() == null) {

            Claims claims = jwtProvider.parseClaims(token);

            Long staffId = jwtProvider.extractStaffId(claims).orElseThrow();
            String role = jwtProvider.extractRole(claims).orElseThrow();
            String name = claims.get("name", String.class);

            CustomUserDetails userDetails = new CustomUserDetails(staffId, name, role);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities()
                    );

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String extractToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            return null;
        }
        return header.substring(7);
    }
}

설계 포인트 몇 가지.

 

OncePerRequestFilter를 상속하는 이유.

서블릿 필터는 이론상 한 요청에 여러 번 실행될 수 있다(forward, include 등). OncePerRequestFilter는 요청당 딱 한 번만 실행을 보장한다.

 

토큰이 없어도 예외를 던지면 안 된다.

로그인/회원가입 엔드포인트는 토큰 없이 요청이 온다. 토큰이 없으면 그냥 다음 필터로 흘려보내고, 차단은 SecurityConfig가 담당한다.

 

filterChain.doFilter()는 무조건 호출해야 한다.

이 필터는 차단하는 게 아니라 인증 정보를 세팅하는 역할이다. 호출하지 않으면 요청이 거기서 막혀버린다.

 

UsernamePasswordAuthenticationToken 생성 시 credentials를 null로.

JWT가 isTokenValid()를 통과했다는 건 이미 인증이 끝난 상태라 비밀번호를 다시 검증할 필요가 없다. 3개짜리 생성자(authorities 포함)를 쓰면 "인증 완료" 상태, 2개짜리를 쓰면 "인증 전" 상태로 Spring Security가 구분한다.


SecurityConfig — 필터 체인 설정

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

CSRF를 비활성화하는 이유.

CSRF 공격은 브라우저가 쿠키를 자동으로 전송하는 특성을 이용한다. JWT는 Authorization 헤더로 전송하기 때문에 브라우저가 자동으로 붙여주지 않아서 CSRF 공격 자체가 불가능하다.

 

세션을 비활성화하는 이유.

JWT는 stateless 방식인데 세션을 만들면 서버 메모리를 쓰게 되어 JWT의 장점이 사라진다. STATELESS로 세션 생성 자체를 막는다.

 

addFilterBefore 위치.

UsernamePasswordAuthenticationFilter는 폼 로그인 처리 필터인데, JWT 방식에서는 쓰지 않지만 순서 기준점으로 활용한다. JwtAuthenticationFilter가 먼저 실행되어 SecurityContext에 인증 정보를 세팅해야 이후 권한 체크 필터가 정상 동작한다.


전체 인증 흐름 정리

1. 로그인 → JwtProvider.generateToken() → 클라이언트에 토큰 발급

2. 이후 요청마다:
   Authorization: Bearer {token} 헤더에 담아서 전송

3. JwtAuthenticationFilter:
   토큰 추출 → 유효성 검증 → claims 파싱
   → CustomUserDetails 생성 → SecurityContextHolder에 세팅

4. 컨트롤러에서 현재 사용자 꺼내기:
   @AuthenticationPrincipal CustomUserDetails userDetails

마무리

JWT 인증을 구현하면서 Spring Security의 필터 체인 구조를 이해하게 됐다. 단순히 "동작하게 만드는 것"보다 각 클래스가 왜 존재하고, 왜 이 순서로 실행되어야 하는지를 이해하는 게 핵심이었다.

JJWT 0.12.x 마이그레이션 - 바뀐 API 정리

JJWT 0.11.x에서 0.12.x로 올라가면서 API가 꽤 많이 바뀌었다.
구버전 코드를 그대로 쓰면 deprecated 경고가 잔뜩 뜨거나 아예 컴파일이 안 된다.


달라진 것들

0.11.x (구버전) 0.12.x (현재)
Jwts.parserBuilder() Jwts.parser()
.setSigningKey(key) .verifyWith((SecretKey) key)
.build().parseClaimsJws(token) .build().parseSignedClaims(token)
.getBody() .getPayload()
SignatureAlgorithm.HS256 deprecated → Jwts.SIG.HS256

코드로 보면

0.11.x

Jwts.parserBuilder()
    .setSigningKey(key)
    .build()
    .parseClaimsJws(token)
    .getBody();

0.12.x

Jwts.parser()
    .verifyWith(key)
    .build()
    .parseSignedClaims(token)
    .getPayload();

SecretKey 타입으로 선언하면 캐스팅 불필요

0.11.x에서는 Key 타입으로 필드를 선언하고 verifyWith() 호출 시 (SecretKey)로 캐스팅해야 했다.

0.12.x에서는 Keys.hmacShaKeyFor()의 반환 타입이 SecretKey이기 때문에, 처음부터 SecretKey로 선언하면 캐스팅 없이 바로 쓸 수 있다.

// 이렇게 선언하면
private final SecretKey key;

// 생성자에서
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));

// verifyWith에 그냥 넘길 수 있음 (캐스팅 불필요)
Jwts.parser().verifyWith(key)...

@NoArgsConstructor(PROTECTED)와 FetchType.LAZY를 쓰는 이유

JPA 엔티티를 만들다 보면 항상 등장하는 두 가지가 있다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ManyToOne(fetch = FetchType.LAZY)

처음엔 그냥 "관례니까 쓰는 거겠지" 하고 넘어갈 수 있는데, 이유를 알고 나면 훨씬 납득이 된다.


FetchType.LAZY - 필요할 때만 조회

ProductBrand가 다대일 관계라고 해보자.

Product product = productRepository.findById(1L);

이때 EAGER(기본값)면 Product 조회하는 순간 Brand도 JOIN해서 같이 가져온다. 쓸 일이 없어도.

LAZY면 다르다. Brand 자리에 빈 껍데기(프록시) 만 넣어두고, 실제로 접근할 때 그때 DB를 조회한다.

product.getBrand().getName(); // 이 시점에 Brand SELECT 쿼리 실행

불필요한 쿼리를 줄일 수 있어서 성능에 유리하다.


PROTECTED 생성자 - 프록시 때문에

위에서 말한 빈 껍데기(프록시) 가 핵심이다.

JPA가 LAZY 로딩을 위해 만드는 프록시는, 실제 Brand상속한 가짜 클래스다.

class Brand$Proxy extends Brand { ... }

자식 클래스가 생성될 때 부모 생성자를 호출해야 하는데, 부모 생성자가 private이면 호출 자체가 불가능하다.

그렇다고 public으로 열어두면 외부에서 new Brand() 로 빈 객체를 마구 만들 수 있어서 위험하다.

그래서 PROTECTED — 자식(프록시)은 생성 가능하되, 외부에서 직접 생성은 막는 딱 적당한 제한자다.

@Enumerated, 왜 STRING을 써야 할까?

JPA에서 Enum 타입 필드를 DB에 저장할 때 @Enumerated 어노테이션을 쓰는데, 여기서 중요한 선택지가 있다.

바로 EnumType.ORDINAL vs EnumType.STRING.


ORDINAL - 숫자로 저장

public enum Category {
    TOP,      // 0
    BOTTOM,   // 1
    OUTER,    // 2
    SHOES,    // 3
    ACCESSORY // 4
}

ORDINAL을 쓰면 DB에 "TOP" 대신 0, 1, 2... 숫자가 저장된다.

언뜻 보면 효율적인 것 같지만, 여기서 큰 함정이 있다.


ORDINAL이 위험한 이유

6개월 뒤에 DRESS를 추가해야 한다고 해보자.

public enum Category {
    TOP,      // 0
    DRESS,    // 1  ← 중간에 추가!
    BOTTOM,   // 2  ← 원래 1이었는데 밀려버림
    OUTER,    // 3
    SHOES,    // 4
    ACCESSORY // 5
}

DB에 1로 저장된 기존 데이터는 원래 BOTTOM이었는데, 이제 갑자기 DRESS가 된다.

에러도 안 나고, 조용히 데이터가 오염된다. 가장 무서운 버그다.


STRING - 문자열로 저장

@Enumerated(EnumType.STRING)
private Category category;

이렇게 하면 DB에 "BOTTOM" 문자열 자체가 저장된다.

Enum 순서가 바뀌어도, 중간에 뭘 추가해도 전혀 영향 없다.

저장 공간이 숫자보다 조금 더 필요하지만, 실무에서는 거의 STRING을 쓴다. 데이터 정합성이 훨씬 중요하니까.

+ Recent posts