본문 바로가기

우아한 테크코스 7기

[우아한 테크코스 7기] 방탈출 예약 관리 미션 회고

지난 레벨1의 인터뷰 과정에서 배웠던 내용들을 다시 떠올려 키워드를 선정하는 데에 생각보다 어려움을 겪었다.

 

이전에는 노션에 키워드들을 뽑아내고 정리하는 방식으로만 기록을 해뒀었는데, 이렇게 하니 어떤 맥락에서 이 고민들을 했었는지에 대한 기억이 휘발되어서 아쉬움을 느꼈다. 그래서 레벨2부터는 매 미션마다 회고를 기록할 것을 다짐했다.

 


🌞 다시 처음으로 돌아가보기

이전에도 스프링 부트를 활용해 본 경험이 있었으나, 차근차근 개념을 익혀 가며 습득했다기보다는 필요할 때마다 개념들을 마구잡이로 학습했다. 그래서 이번 미션에서 스프링 부트를 시작할 때 내가 모르는 키워드가 있다면 놓치지 않고 학습할 것을 다짐했었다. 

 

그래서 이번 미션을 진행하는 과정에서는 '적어도 내가 쓴 코드에서는 모르는 내용이 없도록' 하는 것이 목표였다.

새롭게 정리한 내용들을 하나씩 간략하게 짚고 넘어가고자 한다.


📝 HTTP 요청 이후, Spring Boot 내부에서 일어나는 일

Spring Boot로 서버 애플리케이션을 개발할 때, 코드를 어떻게 작성해야 하는지는 알고 있어도, 그 코드가 실제로 어떤 흐름을 통해 실행되는지에 대한 이해는 부족한 경우가 많았다.

 

이번 미션에서 코드를 짤 때도 처음에는 처음에는 String을 반환했을 때, 어떤 경우에는 View의 이름으로 처리되고, 어떤 경우에는 JSON으로 직렬화되는지 그 기준이 불명확하게 느껴졌었다.

 

그래서 이번 기회에 스스로 Spring Boot 내부의 요청 처리 흐름을 정리해보았다.

 

우선 스프링부트에서는 요청이 들어오면, 서블릿 컨테이너가 HTTP 요청을 DispatcherServlet 으로 전달하게 된다.

DispatcherServlet은 요청 URL에 매핑되는 컨트롤러 메서드를 찾기 위해, 여러 HandlerMapping 구현체들을 순차적으로 탐색한다.

 

HandlerMapping 의 대표적인 구현체들은 아래와 같다.

HandlerMapping 클래스 설명
RequestMappingHandlerMapping 가장 일반적이며 가장 많이 사용됨. @RequestMapping, @GetMapping 등의 애노테이션 기반 매핑 처리
SimpleUrlHandlerMapping XML이나 Java Config를 통해 직접 URL ↔ Handler 매핑을 지정할 수 있음
BeanNameUrlHandlerMapping URL이 Bean 이름과 일치하면 매핑. 예: /hello → hello라는 이름의 Bean이 있으면 해당 핸들러로 사용
ResourceHandlerMapping /static, /public 등의 정적 리소스를 서빙하기 위한 매핑 처리기
WelcomePageHandlerMapping / 경로 요청에 대해 index.html 정적 파일이 존재하면 이를 자동으로 반환 (Spring Boot에서 자동 등록됨)

 

HandlerMapping의 구현체를 통해 메서드를 찾는 과정 속에서 / 경로 요청에 대해 아무 HandlerMapping 에서 걸리지 않게 된다면 WelcomePageHandlerMapping 까지 내려가게 되어 자동으로 index.html 이 로드된다.

 

이후, HandlerAdapter 가 직접적으로 메서드가 호출된다. 이 과정에서 ParameterResolver 를 이용해 @ReqeustBody / @Valid 와 같은 처리가 일어난다. 

 

HandlerAdapter 를 통해 메서드가 실행되고 난 뒤에는 상황에 따라 HTTPMessageConverter 가 동작하거나 ViewResolver 가 동작한다.

 

@RequestBody 가 붙은 컨트롤러이거나 @ResponseBody 가 붙었거나 ResponseEntity 를 반환하는 메서드라면 HTTPMessageConverter 가 동작하여 직렬화된 응답을 반환한다.

 

이 이외의 경우라면 모두 이를 View 의 이름으로 간주해 ViewResolver 가 동작해 적절한 View 를 찾아 반환한다.


📝 ResultSet

JPA를 통해 고수준의 추상화된 방식으로만 코드를 작성해오다 처음으로 JdbcTemplate을 직접 다루게 되면서, 몰랐던 부분들이 많다는 걸 실감했다. 그중에서도 가장 핵심이면서도 막막했던 개념은 ResultSet이었다.

 

처음에는 단순히 데이터베이스 쿼리를 실행하면 결과가 여러 개의 ResultSet 객체로 생성되고, 각각 하나의 행(row)을 담고 있을 거라고 막연히 생각했다.

 

하지만 실제로 ResultSet은 결과 집합 전체를 포괄하면서도 커서를 통해 한 행씩 탐색할 수 있도록 해주는 구조였다. 즉, .next()를 통해 커서를 이동시키면서 한 줄씩 데이터를 가져오는 방식이다.

 

레벨1의 장기 미션에서 실제로 작성했던 코드를 보면 아래와 같다

public <T> List<T> executeQueryForList(String sql, StatementSetter setter, RowMapper<T> rowMapper) {
    try (var connection = janggiConnection.getConnection();
         var preparedStatement = connection.prepareStatement(sql)) {

        setter.setValues(preparedStatement);

        try (ResultSet resultSet = preparedStatement.executeQuery()) {
            List<T> results = new ArrayList<>();
            while (resultSet.next()) {
                results.add(rowMapper.mapRow(resultSet));
            }
            return results;
        }

    } catch (SQLException e) {
        throw new RuntimeException(e);
    }
}

 

위와 같은 구조에서 핵심은 ResultSet을 순회하며 RowMapper를 통해 자바 객체로 매핑해간다는 점이다. 

 

이 때 두 가지의 의문이 들었다.

 

1. 데이터는 어디에 저장되는 걸까? 혹시 매번 .next()를 호출할 때마다 데이터베이스에 쿼리를 날리는 건 아닐까?

2. 만약 메모리 어딘가에 저장된다면 어떤 형태로 저장되는 걸까?

 

찾아본 결과, 이는 JDBC 드라이버(즉, 벤더)마다 전략이 조금씩 다르다고 한다. 일부 드라이버는 쿼리 결과를 한 번에 모두 메모리에 올리는 반면, 일부는 fetchSize를 설정해 일정 단위로 나눠서 가져온다.

 

메모리에 올라온 데이터는 벤더에 따라 byte[], Value[], Tuple[] 등 다양한 형태로 저장된다. 그리고 우리가 .getInt("id"), .getString("name")처럼 데이터를 호출하면, 해당 타입으로 자동 캐스팅되어 반환된다.

 

결국 최종적으로 자바의 객체로 변환되는 시점은 RowMapper가 호출될 때이며, 이때 힙 영역에 매핑된 객체가 생성된다.


📝 BeanDefinition

@Test
void test3() {
    StaticApplicationContext context = new StaticApplicationContext();
    context.registerBeanDefinition("printer", new RootBeanDefinition(Printer.class));

    BeanDefinition helloDef = new RootBeanDefinition(Hello.class);
    helloDef.getPropertyValues().addPropertyValue("name", "Spring");
    // 아이디가 printer 인 빈을 찾아서 printer 프로퍼티에 DI
    helloDef.getPropertyValues().addPropertyValue("printer", new RuntimeBeanReference("printer"));
    context.registerBeanDefinition("hello", helloDef);

    System.out.println("------");

    Hello hello = context.getBean("hello", Hello.class);
    hello.print();
}

 

학습 자료로 공부를 하던 중, ❓ 왜 Bean 을 바로 등록하는 게 아니라 굳이 BeanDefintion 을 등록하는거지 하는 의문이 들어 BeanDefitnion 에 대해 찾아보기 시작했다.

 

그러던 중, 알게 된 충격적인 사실이 있었다.

 

나는 ApplicationContext 가 생성되면 그 이후에 Bean 이 생성되어 등록된다고 인지하고 있었다. 그러나 사실은 BeanDefinition 이 등록되고 필요할 때에 / ApplicationContext 가 refresh 될 때에 빈으로 생성된다는 것이었다.

 

그래서 사실상 컴포넌트 스캔이 일어날 때에는 '빈이 등록됩니다'가 아니라 '빈의 정의가 등록됩니다' 가 더 맞는 말인 것이다.

이가 빈으로 바뀌는 시점에 대해서는 개발자가 관리하는 것이 아니라 컨테이너가 내부적으로 관리하게 된다.

 

그렇다면 왜 이렇게 해야 할까? 어차피 굳이 Bean 으로 사용할건데 번거롭게 왜 정의를 등록하는 것일까?

 

그 이유는 스프링에서 생성되고 관리되는 인스턴스들은 POJO 환경에서 관리되는 단순한 자바의 인스턴스들과는 다르기 때문이다.

 

이들은 다양한 기능을 해야 하는 객체들이다. 

✅ DI
✅ AOP 프록시 적용
✅ 스코프 적용 (ex. 싱글톤인지 아닌지 등등..)
✅ Proeprty 적용 (ex. @Value 와 같은 정보들에 대한 적용)
✅ Lazy Loading 등..

 

이 모든 것들을 적용해야 하고, 프록시나 의존성 주입이 이뤄지는 경우에도 객체의 생성 시점이 중요하게 작용하기 때문이다.

 

이렇게 이해하고 난 뒤, 최근 ApplicationContext 의 구현체 중에서는 #registerBean 이라는 메서드를 제공하기도 한다는 것을 알고 다시 혼란스러웠었다.

public class AnnotationConfigApplicationContext {
	@Override
	public <T> void registerBean(@Nullable String beanName, Class<T> beanClass,
		@Nullable Supplier<T> supplier, BeanDefinitionCustomizer... customizers) {
	
	this.reader.registerBean(beanClass, beanName, supplier, customizers);
	}
}

private <T> void doRegisterBean(Class<T> beanClass, @Nullable String name,
			@Nullable Class<? extends Annotation>[] qualifiers, @Nullable Supplier<T> supplier,
			@Nullable BeanDefinitionCustomizer[] customizers) {
		
		// .. 생략
		
		// BeanDefinition 을 등록
		BeanDefinitionReaderUtils.registerBeanDefinition(definitionHolder, this.registry);
}

 

그러나 사실상 내부 코드를 까보면 Bean 이 아니라 BeanDefinition 을 등록하는 것을 확인할 수 있다.

즉, 단순히 개발자 친화적으로 편의를 제공하는 것일 뿐 내부적으로는 여전히 빈의 정의를 등록하고 있는 것이다.


🙋  정답은 없다, 그래서 더 많이 고민하기

이번 미션을 진행하면서, 이전에 있던 습관을 버리고자 노력했다. 일관성 없이, 명확한 근거 없이 API 를 짜는 것에 대해 경계했다.

그래서 다양한 고민들을 해볼 수 있었다.

 

이에 대해서는 대부분 결론이 난 것들이므로 간략하게 고민했던 내용과 결론들을 언급하여 정리해보겠다.


✅ command - query 의 분리

이번 미션 내용에서는 저장 로직 이후에 바로 저장된 해당 객체를 반환해야 했다.

처음에는 그냥 불필요한 select 쿼리를 보낼 필요 없이 바로 반환값으로 해당 객체를 보내줘야겠다는 생각을 했었다.

 

그러나 이렇게 될 경우 다음과 같은 요소들에 대한 고려가 필요하다고 생각했다.

 

1. 반환값이 저장된 객체라는 것은 api 상의 스펙인데, 서비스 코드가 이에 의존하게 된다.

2. 데이터가 데이터베이스에 들어가는 과정에서 프로세싱이 일어나 형태가 변할 수도 있다.

 

그래서 결론적으로는 select 쿼리를 한번 더 보내 저장된 객체를 조회해와 반환하는 것으로 결정했다.


 DTO 에서는 어디까지 유효성 검사를 해도 될까?

 

나는 이번 미션에서 DTO 에서 예약을 요청한 날짜가 오늘로부터 미래 시점인지까지 검증을 했었다.

이를 바탕으로 DTO 에서 어디까지 검증해야 하는지에 대해 크루들과 다양한 이야기를 나누었었다.

 

🙋: DTO 에는 단순히 빈 값인지만 검사해야 해

🙋: 입력 형태만 검증해야 해 (ex. 13:23 과 같은 형태인지)

🙋: 내용까지 검증해야 해 (ex. 예약의 경우 오늘 이후의 날짜인지)

 

위가 주된 의견들이었다. 나의 경우에는 세번째였던 것이다.

 

처음에는 DTO 에서 내용까지 검증하게 되면 다음과 같은 문제가 있을 수도 있다는 생각을 했었다.

 

1. 도메인 로직의 노출인가

2. 도메인 로직과의 중복일까 

 

그러나 리뷰어였던 제이가 오히려 이는 노출해야 하는 정보이고, 사실상 중복이라기보다 역할이 서로 다르니 괜찮은 것 같다는 의견을 주셨었다. 또한 불필요한 요청을 빠르게 끊어낼 수 있다는 의견도 주셨다. 

 

그러나 크루들과 이야기를 하던 도중, 그렇다면 '공휴일에 운영을 하지 않는다' 라는 요구사항이 추가되어도 DTO 에서 이를 검증할 것이냐 하는 질문을 받았다.

 

이는 명백히 도메인 로직이라는 생각이 들었다. 공휴일에 운영하지 않는지는 기업마다 다르지만, '예약' 이 미래의 날짜에 대해 이루어져야 한다는 것은 기업마다 다르지 않다. 과거에 대한 예약을 받을 리는 없기 때문이다.

 

그러나 나의 이러한 판단 결과도 아직은 불만족이다. 

무언가에 대해 결론을 내리는 것은 추후에 같은 상황이 발생했을 때 빠르게 의사 판단 과정을 줄이고 결정해나가기 위함이라는 생각이 든다. 그러나 내가 내린 판단 기준은, 단어의 정의에 대해 고려해봐야 하는 판단 기준이다.

 

매번 단어의 정의를 찾아보며 고민하기에는 비효율적이다. 그렇기에 솔라가 말씀하신대로, 한 번에 결론을 내리지 않고 차차 앞으로 미션을 해 나가며 이에 대해 정립해나가 보고자 한다!


존재하지 않는 리소스에 대한 삭제 요청은 204 인가 404 인가?

🙋: 존재하지 않는 리소스임을 사용자에게 모두 노출하는 것은 위험해. 어차피 사용자가 요구한 것은 해당 리소스가 존재하지 않는 결과이니까 없는 리소스에 대한 삭제 요청에 대해서도 200 OK 나 204 NO CONTENT 도 적절해.

 

🙋: 사용자가 기대한 행동은 해당 리소스를 삭제하는거야. 그런데 없는 리소스에 대한 삭제 요청을 보내는 것이 만약 이미 존재하는 리소스에 대한 실수였다면, 기대한 바로 이어지지 않기 때문에 사용자의 요구사항을 제대로 반영하지 못할 가능성이 있어.

 

위 두 주장이 내가 고민했던 두 가지의 상황이다.

 

또한, 특정 리소스가 존재하는지 않는지를 매번 사용자에게 알려주게 된다면 이가 어뷰징의 요소가 될 수도 있으므로 더 위험할 수도 있다는 사실도 학습하게 됐다.

 

결론은 상황에 따라 유연하게 하는 게 좋다는 생각이 들었다. 어뷰징의 대상이 될 요소가 없는 리소스이고, 삭제 되었는지의 여부가 사용자에게 알려주는 것이 더 중요하다면 404 를 알려주고 그게 아니라면 204 로 일관성을 지키는 것이 좋다는 생각이 들었다.