maeil-mail 질문 모음 (1~311)

총 수집 성공: 152 / 실패: 9
출처: maeil-mail.kr

[1] OSIV(Open Session In View) 옵션에 대해서 설명해주세요.

백엔드

OSIV(Open Session In View) 옵션에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

OSIV(Open Session In View)

OSIV(open session in view) 는 영속성 컨텍스트를 뷰까지 열어둔다는 의미입니다. 영속성 컨텍스트가 살아있으면 엔티티는 영속 상태로 유지될 수 있어, 뷰에서도 지연 로딩을 사용할 수 있어요. OSIV의 핵심은 뷰에서도 지연 로딩이 가능하도록 하는 것입니다. 가장 단순한 구현은 클라이언트 요청이 들어올때 필터나 인터셉터에서 트랜잭션을 시작하는 방법인데요. 이를 트랜잭션 방식 OSIV라고 합니다. 하지만, 트랜잭션 방식 OSIV는 표현 계층에서도 엔티티를 수정할 수 있기 때문에 유지보수하기 어려운 코드를 만들 수 있습니다.

트랜잭션 방식의 OSIV의 문제는 어떻게 풀어볼 수 있을까요? 🤔

최신 방식의 OSIV는 트랜잭션 방식의 문제를 해결합니다. 스프링 OSIV는 OSIV를 사용하면서 트랜잭션은 비즈니스 계층에서만 사용해요. 표현 계층에서는 트랜잭션이 없기 때문에 수정이 불가능합니다. 하지만, 표현 계층에서 트랜잭션 없는 읽기를 이용해 지연 로딩은 가능합니다. 동작 원리는 다음과 같습니다.

  1. 클라이언트의 요청이 들어오면 서블릿 필터나 스프링 인터셉터에서 영속성 컨텍스트를 생성합니다.
  2. 응용 계층에서 @Transactional로 트랜잭션을 시작할 때 미리 생성한 영속성 컨텍스트를 찾아와서 트랜잭션을 시작합니다.
  3. 응용 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시합니다. (영속성 컨텍스트는 종료하지 않습니다.)
  4. 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속 상태를 유지할 수 있습니다.
  5. 필터, 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료하는데 이때 플러시는 수행하지 않습니다.

스프링 방식의 OSIV의 문제점을 한 번 생각해볼까요? 😀

충분히 고민해보신 다음에 펼쳐보세요!
  • 표현 계층에서 엔티티를 수정하면 데이터베이스에 반영하지 않습니다. 하지만, 엔티티를 수정한 이후 트랜잭션을 시작하는 응용 계층을 시작한 경우 문제가 발생합니다. 응용 계층 트랜잭션이 끝나고 영속성 컨텍스트를 플러시하는 과정에서 변경 감지가 동작할 수 있습니다.
  • OSIV 기능을 사용하면 상대적으로 오래 DB 커넥션을 점유하기 때문에 커넥션 고갈로 이어질 수 있습니다.

OSIV 기능을 비활성화하여 성능 최적화를 해볼 수 있어요. 🤓

OSIV 기능이 활성화되어 있는 경우에는 트랜잭션의 범위를 벗어나도 커넥션을 계속 유지해요. 만약 트래픽을 많이 받는 상황이라면, 커넥션 고갈로 이어질 수 있습니다. OSIV 기능을 비활성화하여 데이터베이스 커넥션을 효율적으로 사용할 수 있습니다.

그러면 무조건 OSIV 기능을 비활성화해야 할까요? 🤔

무조건 비활성화하기 보다는 꺼야하는 근거가 필요해요. 만약 트랜잭션 범위 밖에서 지연로딩을 반드시 수행해야하는 경우에는 비활성화하기 어려울 수도 있어요.

데이터베이스를 복제하여 사용하는 경우, 데이터소스도 분리해야하는데요. OSIV 기능으로 인해 예기치 않은 데이터베이스로 요청이 전달될 수 있어요. 그리고, 대량의 트래픽이 발생하는 경우처럼 데이터베이스 커넥션을 효율적으로 사용해야할 수도 있습니다. 위와 같은 경우에는 OSIV 비활성화를 고려해볼 수 있을 것 같아요.

결국, 요지는 상황에 적합한 경우 OSIV 기능을 비활성화하는 것이 적절하다고 생각합니다.

참고 링크

참고 링크 없음

[6] RAID 기술에 대해서 설명해주세요.

백엔드

RAID 기술에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

RAID(Reduntant Array of Independent Disks)

RAID는 수 많은 데이터들을 안전하게 저장하거나 성능을 높이기 위해 여러 하드 디스크나 SSD를 마치 하나의 장치처럼 사용하는 기술입니다.

RAID 구성 방식에 대해서 설명해주세요. 🤓

RAID-0 은 여러 보조기억장치에 데이터를 나누어 저장하는 구성 방식입니다. 데이터를 저장할 때 하드 디스크는 각 장치에 번갈아 데이터를 저장합니다. 이때 줄무늬처럼 분산되어 저장된 데이터를 스트라이프라고 하며 분산하여 저장하는 방식을 스트라이핑이라고도 합니다.

데이터를 분산하여 저장하기 때문에 저장된 데이터를 읽고 쓰는 속도가 빨라집니다. 하나의 대용량 저장 장치를 사용하면 여러 번에 걸쳐 읽고 써야하는 데이터를 동시에 읽고 쓸 수 있기 때문입니다. 가령, 4TB 저장 장치 한개 읽기 속도보다 RAID 0 구성으로된 1TB 장치 네 개 읽기 속도가 더욱 빠릅니다. 하지만, 저장된 정보가 안전하지 않다는 단점이 존재합니다.

RAID-1 은 복사본을 만드는 구성이며 미러링이라고도 부릅니다. 디스크에 문제가 발생해도 복구가 가능합니다. 하지만 하드 디스크 개수가 한정되었을 때, 사용 가능한 용량이 적어지며 RAID-0에 비해 쓰기 속도가 느립니다.

RAID-4는 복사본을 만드는 대신 오류를 검출하고 복구하기 위한 정보를 저장한 장치를 두는 구성 방식입니다. RAID-1 보다 적은 하드 디스크로도 데이터를 안전하게 보관할 수 있습니다. 다만, 새로운 데이터가 저장될 때마다 패리티 저장 디스크에도 데이터를 써야 하므로 병목 현상이 발생할 수 있다는 단점이 있습니다.

RAID-5RAID-4의 병목 현상을 극복하기 위해 패리티 정보를 분산하여 저장합니다. 그리고, RAID-6 는 서로 다른 두 개의 패리티를 두는 방식입니다. RAID-6는 오류 검출 및 복구 수단이 두 개가 생기므로, RAID-4RAID-5 보다 안전합니다. 하지만, 패리티가 두 개이므로 쓰기 속도는 RAID-5보다 느립니다.

RAID를 직접 구성 해보신 적이 있나요? 🤔

예시입니다! 각자 경험하신 프로젝트 상황에 맞는 대답을 준비해주세요.

RAID를 직접 구성한 적은 없습니다. 하지만, 매일메일 서비스의 백엔드를 구축하면서 AWS RDS를 사용했는데요. AWS RDS는 필요한 스토리지 용량에 따라 자동으로 데이터를 여러 Amazon EBS 볼륨에 스트라이핑하여 성능을 강화하는 것으로 알고 있습니다.

참고 : Amazon RDS DB 인스턴스 스토리지 작업

참고 링크

[7] @Value 어노테이션 사용 시 주의할 점을 설명해주세요.

백엔드

@Value 어노테이션 사용 시 주의할 점을 설명해주세요.

백엔드와 관련된 질문이에요.

@Value 어노테이션 주의점

@Value은 설정 파일에 설정한 값을 주입할 수 있는 어노테이션입니다. 첫 번째로 주의해야 할 부분은 주입 시점입니다. @Value 어노테이션은 대상 컴포넌트가 스프링 빈으로 등록되고 의존 관계를 주입할 때 동작합니다. 따라서 환경 변수를 주입받는 대상 클래스에 @Component 어노테이션을 붙여주지 않는다면 해당 클래스는 컴포넌트 스캔이 대상이 되지 않아 스프링 빈으로 등록되지 않고, @Value 어노테이션 또한 동작하지 않습니다.

또한 상황에 따라서 적절한 주입 방식을 선택해야 하는데요. 빈을 주입받을 때와 마찬가지로 @Value 어노테이션을 사용할 때도 필드 주입, 생성자 주입, setter 주입 등의 방식을 사용할 수 있습니다. 따라서 이들의 장단점을 비교하고, 상황에 따라 적절한 주입 방식을 선택해야 합니다.

마지막으로 프로퍼티 파일의 경로와 스코프를 확인해야 합니다. application.yaml 이 클래스 패스에 존재해야 하고, 프로퍼티 파일이 여러 개일 경우 우선순위를 고려해야 합니다.

@ConfigurationProperties 어노테이션과의 차이점은 무엇인가요? 🤔

스프링의 프로퍼티 파일의 값은 Environment에 등록되는데요. 두 어노테이션 모두 이 값을 불러올 수 있다는 공통점이 있습니다. 단, @Value의 경우에는 단일 값을 주입받기 위해서 사용되며, RelaxedBinding이 적용되지 않습니다. RelaxedBingding이란 프로퍼티 이름이 조금 달라도 유연하게 바인딩을 시켜주는 규칙을 의미합니다. 반면, @ConfigurationProperties 어노테이션은 프로퍼티에 있는 값을 클래스로 바인딩하기 위해 사용됩니다. 그리고, 한 번에 여러 값을 바인딩 받을 수 있으며 RelaxedBinding을 적용합니다.

참고 링크

참고 링크 없음

[8] @ExceptionHandler 어노테이션은 무엇인가요?

백엔드

@ExceptionHandler 어노테이션은 무엇인가요?

백엔드와 관련된 질문이에요.

@ExceptionHandler란?

@ExceptionHandler 애너테이션은 Spring MVC에서 컨트롤러(@Controller)나 전역 예외 처리를 위한 @ControllerAdvice 클래스의 메서드에서 발생하는 예외를 처리하는 데 사용되는데요. 이 애너테이션은 특정 예외를 처리하는 메서드를 지정하거나 메서드의 파라미터로 처리할 예외를 설정할 수 있습니다.

어떤 방식으로 동작하나요? 🤔

Spring MVC 애플리케이션에서 예외가 발생하면, DispatcherServlet이 적절한 HandlerExceptionResolver를 찾아 예외를 처리합니다. Spring에 기본적으로 등록된 HandlerExceptionResolver는 세 가지가 있으며, 각 리졸버는 우선순위에 따라 예외를 처리합니다. 그 중 ExceptionHandlerExceptionResolver가 가장 먼저 동작하며, 발생한 예외가 @ExceptionHandler에 등록되어 있는지 확인합니다. 만약 처리할 수 없는 예외라면 다음 리졸버로 넘어갑니다. ExceptionHandlerExceptionResolver의 특징은 예외가 WAS로 던져지지 않고 직접 처리된다는 것입니다.

이렇게 함으로써 예외가 발생했을 때 적절한 방법으로 처리되어 사용자에게 친화적인 에러 메시지를 제공하거나 로깅 등의 추가 작업을 수행할 수 있습니다.

참고 링크

참고 링크 없음

[9] @ResponseBody(or ResponseEntity<T>)가 있을 때와 없을 때의 동작 방식의 차이점을 말해주세요.

백엔드

@ResponseBody(or ResponseEntity<T>)가 있을 때와 없을 때의 동작 방식의 차이점을 말해주세요.

백엔드와 관련된 질문이에요.

@ResponseBody(or ResponseEntity<T>)가 있을 때와 없을 때 차이점

@ResponseBody 혹은 ResponseEntity<T> 반환을 사용한다면, 스프링은 컨트롤러에서 반환된 값을 HTTP 응답 본문에 직접 씁니다. 이때 자바 객체를 자동으로 JSON이나 XML 등의 타입으로 직렬화합니다. 만약, 없는 경우에는 스프링은 반환값을 뷰 이름으로 해석합니다. 뷰 이름으로 해석한 이후에, 뷰 리졸버를 사용해 뷰를 찾고 응답합니다.(뷰에 전달할 모델이 있다면, 이를 뷰에 전달하고 응답합니다.)

@ResponseBody와 ResponseEntity<T> 반환 중 어떤 방식이 더욱 좋나요? 😀

@ResponseBody를 사용하는 경우, 코드를 간결하게 유지할 수 있습니다. 하지만, 상태코드와 헤더를 유연하게 변경하기는 어렵습니다. 반면, ResponseEntity<T> 반환의 경우 상태코드와 헤더를 유연하게 변경할 수 있으나 작성할 코드량이 증가한다는 단점이 있습니다. 팀 상황에 맞게 적절한 방법을 사용하는 것이 중요하다고 생각합니다.

추가 학습 자료를 공유합니다.

참고 링크

[10] Filter와 Interceptor의 차이점을 말해주세요.

백엔드

Filter와 Interceptor의 차이점을 말해주세요.

백엔드와 관련된 질문이에요.

Filter

Filter는 요청 및 응답의 전처리와 후처리를 수행하고 서블릿 컨테이너에 의해 실행되는 Java 클래스입니다. 주로 요청 로깅, 인증, 인코딩 설정, CORS 처리, 캐싱, 압축 등의 공통 기능을 구현하는 데 사용됩니다.

특징

  1. Filter는 서블릿 컨테이너(예: Tomcat) 수준에서 동작합니다. 모든 요청이 서블릿으로 전달되기 전에 Filter를 거칩니다.
  2. 생명 주기: Filter는 doFilter 메서드를 통해 요청 및 응답을 처리합니다. FilterChain을 통해 다음 필터 또는 최종 서블릿으로 요청을 전달합니다.
  3. 순서: web.xml이나 @WebFilter 애노테이션을 통해 설정할 수 있으며, 필터의 순서는 설정 파일에서 정의합니다.

Interceptor

Interceptor는 특정 핸들러 메서드 실행 전후에 공통 기능을 구현합니다. 주로 요청 로깅, 인증, 권한 검사, 세션 검사, 성능 모니터링 등을 수행하는 데 사용됩니다.

특징

  1. Interceptor는 Spring MVC의 핸들러 수준에서 동작합니다. Dispatcher Servlet이 컨트롤러를 호출하기 전에 Interceptor를 거칩니다.
  2. 생명 주기
    • preHandle 메서드: 컨트롤러의 메서드가 호출되기 전에 실행됩니다.
    • postHandle 메서드: 컨트롤러의 메서드가 실행된 후, 뷰가 렌더링되기 전에 실행됩니다.
    • afterCompletion 메서드: 뷰가 렌더링된 후 실행됩니다.
  3. 순서: WebMvcConfigurer를 구현한 클래스에서 addInterceptors 메서드를 사용하여 설정합니다. 인터셉터의 순서는 등록 순서에 따릅니다.

(Servlet) Filter vs (Handler) Interceptor

Characteristics(Servlet) Filter(Handler) Interceptor
DefinitionHTTP 요청이나 응답이 수신될 때마다 서블릿 컨테이너는 Java 클래스 Filter를 실행합니다.인터셉터는 Spring Context에 대한 액세스를 통한 사용자 정의 사후 처리와 핸들러 실행을 금지할 가능성이 있는 사용자 정의 사전 처리만 허용합니다.
Interfacejakarta.servlet.FilterHandlerInterceptor
실행 순서서블릿 이전/이후, 서블릿 필터컨트롤러 이전이나 이후에는 스프링 인터셉터가 필터 이후까지 작동하지 않습니다.
Level of operation서블릿 수준에서 작동컨트롤러 수준에서 작동
MethodInterceptor의 postHandle에 비해 Filter의 doFilter 기술은 훨씬 더 유연합니다. 요청이나 응답을 수정하거나, FilterChain인으로 전달하거나, 요청 처리를 중지할 수도 있습니다.실제 대상 "핸들러"에 액세스할 수 있으므로 HandlerInterceptor는 필터보다 더 정확한 제어를 제공합니다. 핸들러 메소드의 annotation status도 확인할 수 있습니다.

참고 링크

참고 링크 없음

[11] Spring MVC의 실행 흐름에 대해 설명해주세요.

백엔드

Spring MVC의 실행 흐름에 대해 설명해주세요.

백엔드와 관련된 질문이에요.

[View를 응답하는 경우]

이미지 출처 : www.egovframe.go.kr

  1. 클라이언트로부터 HTTP 요청이 들어옵니다. 이때 DispatcherServlet이 프론트 컨트롤러의 역할을 수행합니다.
  2. HandlerMapping을 통해 URL에 매핑된 핸들러를 조회합니다.
  3. DispatcherServlet은 찾은 핸들러를 실행하기 위해 HandlerAdapter를 사용합니다.
  4. HandlerAdapter가 실제로 요청을 처리하는 메서드를 호출합니다.
  5. 컨트롤러는 결과 데이터를 Model 객체에 담고, View 이름을 반환합니다.
  6. ViewResolver는 View 이름을 기반으로 적절한 뷰를 찾습니다.
  7. ViewResolver가 찾은 뷰를 사용해 최종적으로 HTML과 같은 응답을 생성합니다.

[Message Converter의 동작]

JSON과 문자열 등을 응답하는 경우는 View를 응답하는 경우와 유사한 흐름으로 동작합니다. 단, ViewResolver 대신 HttpMessageConverter 가 동작합니다. MessageConverter는 HTTP 요청과 응답 두 경우 모두 사용될 수 있습니다. 이때 클라이언트의 HTTP Accept 헤더와 반환 타입 정보, Content-Type 등을 조합하여 타입에 맞는 HttpMessageConverter가 선택됩니다.

  1. 클라이언트로부터 HTTP 요청이 들어옵니다. 이때 DispatcherServlet이 Front Controller의 역할을 수행합니다.
  2. HandlerMapping을 통해 URL에 매핑된 핸들러(컨트롤러)를 조회합니다.
  3. @RequestMapping 을 처리하는 핸들러 어댑터인 RequestMappingHandlerAdapter가 ArgumentResolver를 호출해 핸들러가 필요로 하는 파라미터의 값을 생성하고, 컨트롤러 메서드를 호출하면서 값을 넘겨줍니다. 이때 ArgumentResolver가 HttpMessageConverter를 사용해 필요한 객체를 생성합니다.
  4. 컨트롤러는 전달받은 파라미터를 사용하여 서비스 계층과 데이터 접근 계층을 호출해 비즈니스 로직을 수행합니다.
  5. @ResponseBodyHttpEntity 를 처리하는 ReturnValueHandler가 HTTPMessageConverter를 호출해 응답 결과를 만들어냅니다.

참고 링크

[12] @Controller 와 @RestController 의 차이점을 설명해주세요.

백엔드

@Controller 와 @RestController 의 차이점을 설명해주세요.

백엔드와 관련된 질문이에요.

이 두 어노테이션의 주요 차이점은 HTTP 응답의 처리 방식에 있습니다.

@Controller

주로 뷰(View)를 반환하는 컨트롤러를 정의할 때 사용됩니다. 메서드가 반환하는 값은 뷰 리졸버(View Resolver)에 의해 해석되어 JSP, Thymeleaf 등과 같은 템플릿 엔진을 통해 HTML을 생성합니다.

@RestController

주로 RESTful 웹 서비스 API를 정의할 때 사용됩니다. 메서드가 반환하는 값은 자동으로 JSON 또는 XML 형식으로 변환되어 HTTP 응답 본문에 포함됩니다. 이는 @Controller@ResponseBody의 결합된 형태입니다.

참고 링크

참고 링크 없음

[13] ControllerAdvice에 대해 설명해주세요.

백엔드

ControllerAdvice에 대해 설명해주세요.

백엔드와 관련된 질문이에요.

@ControllerAdvice는 모든 컨트롤러에 대해 전역 기능을 제공하는 애너테이션입니다. @ControllerAdvice가 선언된 클래스에 @ExceptionHandler, @InitBinder, @ModelAttribute를 등록하면 예외 처리, 바인딩 등을 한 곳에서 처리할 수 있어, 코드의 중복을 줄이고 유지보수성을 높일 수 있습니다. @ControllerAdvice는 내부에 @Component가 포함되어 있어 컴포넌트 스캔 과정에서 빈으로 등록됩니다. @RestControllerAdvice는 내부에 @ResponseBody를 포함하여 @ExceptionHandler와 함께 사용될 때 예외 응답을 Json 형태로 내려준다는 특징이 있습니다.

참고 링크

참고 링크 없음

[14] RequestBody VS ModelAttribute의 차이점을 말해주세요.

백엔드

RequestBody VS ModelAttribute의 차이점을 말해주세요.

백엔드와 관련된 질문이에요.

이들은 클라이언트 측에서 보낸 데이터를 Java 객체로 만들어주는데 RequestBody 는 요청의 본문(Body)에 있는 값을 바인딩할 때 사용하고, ModelAttribute 는 요청 파라미터나 multipart/form-data 형식을 바인딩할 때 사용합니다.

RequestBody

  • 클라이언트가 보내는 요청의 본문을 자바 객체로 변환합니다.
  • 내부적으로 HttpMessageConverter를 거치는데, 이때 ObjectMapper를 통해 JSON 값을 java 객체로 역직렬화합니다.
  • 따라서 변환될 java 객체에 기본 생성자를 정의해야 하고, getter나 setter를 선언해야 합니다. 참고
  • cf. record에 기본 생성자를 따로 정의하지 않았는데 역직렬화가 되는 이유
    • record 는 기본생성자를 자동으로 제공하지 않는 대신, ’모든 필드를 초기화하는 생성자’를 제공합니다.
    • jackson 은 일반 객체와 달리, record를 역직렬화할 때는 ’모든 필드를 초기화하는 생성자’를 사용해 역직렬화하기 때문입니다.

ModelAttribute

  • 두가지 사용법이 있습니다.
  • 첫번째 사용법인 메서드 단에서의 사용법은 jsp의 Model에 하나 이상의 속성을 추가하고 싶을 때 사용합니다.
    • e.g. model.addAttribute(“속성 이름”, “속성 값”)
  • 두번째 사용법인 인자 단에서의 사용으로 클라이언트가 보내는 요청의 파라미터나 multipart/form-data 형식의 데이터를 자바 객체로 변환합니다.
  • 내부적으로 ModelAttributeMethodProcessor를 거치는데, 이때 지정된 클래스의 생성자를 찾아 객체로 변환합니다.

출처: https://tecoble.techcourse.co.kr/post/2021-05-11-requestbody-modelattribute/

출처: https://blog.karsei.pe.kr/59

참고 링크

[22] 톰캣에 대해서 설명해주세요.

백엔드

톰캣에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

Tomcat

웹 서버와 웹 컨테이너의 결합한 형태입니다. 현재 가장 일반적이고 많이 사용되는 WAS입니다. 컨테이너, 웹 컨테이너, 서블릿 컨테이너라고도 부릅니다. JSP와 서블릿 처리, 서블릿의 수명 주기 관리, 요청 URL을 서블릿 코드로 매핑, HTTP 요청 수신 및 응답, 필터 체인 관리 등을 처리해줍니다.

서블릿이 무엇인가요? 🤔

서블릿은 자바를 이용해 웹 서비스를 만들기 위한 스펙입니다. 클라이언트가 프로그램으로 요청을 보내면 그 요청에 대한 결과를 응답해주기 위해서 사용됩니다. 서블릿은 다음과 같이 동작합니다.

  1. 사용자가 URL을 입력하면 사용자의 요청이 서블릿 컨테이너로 전송됩니다.
  2. 요청을 받은 컨테이너는 HttpServletRequest, HttpServletResponse를 생성합니다.
  3. 서블릿 매핑 정보를 이용해 사용자가 요청한 경로를 처리할 수 있는 서블릿을 찾습니다.
  4. 서블릿의 service 메서드를 호출하고 HTTP 메서드 여부에 따라서 doGet(), doPost()를 호출합니다.
  5. 각 메서드는 요청을 처리하고 HttpServletResponse 객체를 이용해 응답을 처리합니다.

서블릿의 생명주기는 어떻게 되나요? 🤓

사용자의 요청이 들어오면 서블릿 컨테이너가 서블릿이 존재하는지 확인하고 없는 경우 init() 메서드를 호출하여 생성합니다. 이후 요청은 service() 메서드를 실행합니다. 만약 서블릿에 종료 요청이 들어오는 경우에는 destroy() 메서드를 호출합니다.

참고 링크

참고 링크 없음

[23] AutoConfiguration 동작 원리를 설명해주세요.

백엔드

AutoConfiguration 동작 원리를 설명해주세요.

백엔드와 관련된 질문이에요.

AutoConfiguration의 시작은 @SpringBootApplication 어노테이션 안에 있는@EnableAutoConfiguration 이라는 애노테이션입니다.

@EnableAutoConfiguration@Import(AutoConfigurationImportSelector.class)를 통해 자동 구성 클래스를 가져옵니다.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

    Class<?>[] exclude() default {};

    String[] excludeName() default {};
}

자동 구성 클래스를 가져올 때는 AutoConfigurationImportSelector 클래스의 selectImports(AnnotationMetadata annotationMetadata) 라는 메서드를 이용하고, getAutoConfigurationEntry(AnnotationMetadata annotationMetadata); 메서드를 통해 Import할 클래스가 무엇인지 알 수 있게 됩니다.

  • 간단한 메서드 동작 과정 설명
  1. getCandidateConfigurations(annotationMetadata, attributes); - AutoConfiguration의 후보들을 가져온다.
  2. removeDuplicates(configurations); - 중복을 제거한다.
  3. getExclusions(annotationMetadata, attributes); - 자동 설정에서 제외되는 설정에 대한 정보를 가져온다.
  4. configurations.removeAll(exclusions); - 제외되는 설정을 제거한다.
  5. getConfigurationClassFilter().filter(configurations); - 필터를 적용한다.

출처 : https://velog.io/@realsy/Spring-Boot-AutoConfiguration-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC

참고 링크

[24] Spring과 Spring Boot의 차이를 말해주세요.

백엔드

Spring과 Spring Boot의 차이를 말해주세요.

백엔드와 관련된 질문이에요.

Spring은 Spring Framework의 핵심 모듈들을 기반으로 한 프레임워크로 엔터프라이즈 애플리케이션 개발을 지원하기 위한 대규모 오픈 소스 프로젝트입니다. Spring Framework를 사용하기 위해서는 설정 파일 작성을 통한 스프링 컨테이너 구성, 필요한 빈 객체 등록 및 의존성 설정, 데이터베이스 연결, 트랜잭션 관리 등 다양한 설정을 개발자가 직접 수동으로 구성해야 했습니다. 따라서 프로젝트 초기화 과정에서 많은 설정과 의존성을 추가하게 되며 프로젝트는 시작하는데 시간이 많이 걸렸습니다. 또한 스프링을 통해 웹 애플리케이션을 구축하기 위해서는 별도의 WAS를 설치하고 설정해야 했습니다.

Spring Boot는 Spring의 문제점을 해결해주고, 더 쉽고 빠르게 스프링 애플리케이션을 개발할 수 있도록 해주는 도구입니다. Spring Boot를 사용하면 Spring에서 제공하는 여러 기능들을 자동으로 설정하여 개발자가 보다 쉽게 사용할 수 있도록 해줍니다.

Spring Boot의 주요 특징

  1. 자동 설정(Auto Configuration)

    • Spring Boot는 애플리케이션의 설정을 자동으로 구성합니다.
    • @EnableAutoConfiguration, @SpringBootApplication 어노테이션을 통해 자동 설정을 활성화합니다.
  2. 의존성 관리 간소화

    • 특정 기능을 쉽게 추가할 수 있도록 여러 개의 라이브러리와 의존성을 하나의 패키지로 묶어 제공하는 starter 의존성 통합 모듈을 제공합니다.
    • 예: spring-boot-starter-web, spring-boot-starter-data-jpa, spring-boot-starter-security
  3. 내장 서버

    • Tomcat, Jetty, Undertow와 같은 내장 웹 서버를 제공하여, 애플리케이션을 독립 실행형 JAR 파일로 배포하고, 바로 실행할 수 있게 합니다.
    • 배포를 위해 War 파일을 생성해서 Tomcat에 배포할 필요 없으며, JAR 파일에는 모든 의존성 라이브러리가 포함되어 있어 외부 서버 없이도 애플리케이션을 실행할 수 있습니다.

참고 링크

참고 링크 없음

[25] JPA를 사용하는 이유를 설명해주세요.

백엔드

JPA를 사용하는 이유를 설명해주세요.

백엔드와 관련된 질문이에요.

데이터 액세스 기술을 사용하는 Spring 기반 애플리케이션을 더 쉽게 구축할 수 있습니다.

애플리케이션에 대한 데이터 액세스 계층을 구현하는 것은 상당히 번거로울 수 있습니다. 가장 간단한 쿼리를 실행하려면 너무 많은 상용구 코드를 작성해야 합니다. 페이지 매김, Auditing, 기타 자주 필요한 옵션을 추가하면 결국 길을 잃게 됩니다.

Spring Data JPA는 실제로 필요한 만큼의 노력으로 데이터 액세스 계층의 구현을 크게 개선하는 것을 목표로 합니다. 개발자는 다양한 기술을 사용하여 리포지토리 인터페이스를 작성하면 Spring이 자동으로 이를 연결해 줍니다. 심지어 사용자 정의 파인더를 사용하거나 예제를 통해 쿼리를 작성하면 Spring이 쿼리를 작성해줍니다.

  1. 더 이상 DAO 구현이 필요하지 않습니다. 인터페이스를 확장함으로써 표준 DAO에서 사용할 수 있는 표준 데이터 액세스에 가장 적합한 CRUD 방법을 얻을 수 있습니다.
  2. 사용자 정의 액세스 메서드 및 쿼리
    • 인터페이스에서 새로운 메소드를 정의하기만 하면 됩니다.
    • @Query 주석을 사용하여 JPQL 쿼리 제공
    • Spring Data의 고급 사양 및 Querydsl 지원을 사용
    • JPA Named 쿼리를 통해 사용자 정의 쿼리 사용
  3. Automatic 사용자 정의 쿼리: 정의된 모든 메서드를 분석하고 메서드 이름에서 쿼리를 자동으로 생성 하려고 시도합니다
  4. Transaction Configuration: 클래스 수준에서 읽기 전용 @Transactional 주석을 사용하고, 읽기 전용이 아닌 메서드에 대해 재정의됩니다.

참고 링크

참고 링크 없음

[26] JPA, Hibernate, Spring Data JPA 의 차이가 무엇인가요?

백엔드

JPA, Hibernate, Spring Data JPA 의 차이가 무엇인가요?

백엔드와 관련된 질문이에요.
  • JPA는 기술 명세입니다. 자바 애플리케이션에서 관계형 데이터베이스를 사용하는 방식을 정의한 인터페이스입니다. JPA는 단순한 명세이기 때문에 인터페이스와 규약만 정의하며, 실제 구현체는 제공하지 않습니다.

  • Hibernate는 JPA의 구현체 중 하나입니다. JPA가 정의한 javax.persistence.EntityManager와 같은 인터페이스를 직접 구현한 라이브러리입니다. JPA의 구현체 중 하나일 뿐이므로, DataNucleus, EclipseLink 등 다양한 JPA 구현체로 대체할 수 있습니다.

  • Spring Data JPA는 JPA를 쉽게 사용할 수 있도록 지원하는 모듈입니다. JPA를 한 단계 추상화시킨 Respository 라는 인터페이스를 제공합니다. 개발자가 Respository 인터페이스에 정해진 규칙대로 메서드를 만들어주기만 하면, 해당 메서드 이름에 적합한 쿼리를 날리는 구현체를 만들어 자동으로 Bean으로 등록해줍니다.

    Spring Data JPA는 JPA를 기반으로 하며, 반복적인 코드 작성을 줄이고 데이터 접근 계층을 단순화 합니다. 이 때 JPA를 추상화 했다는 의미는, Spring Data JPA의 Repository 의 구현에서 JPA를 사용하고 있다는 것입니다. 예를 들어 Respository 인터페이스의 기본 구현체인 SimpleJpaResporitory 는 내부적으로 EntityManager 를 사용합니다.

참고 링크

참고 링크 없음

[27] Spring Data JPA에서 새로운 Entity인지 판단하는 방법은 무엇일까요?

백엔드

Spring Data JPA에서 새로운 Entity인지 판단하는 방법은 무엇일까요?

백엔드와 관련된 질문이에요.
@Override
public boolean isNew(T entity) {

    if(versionAttribute.isEmpty()
          || versionAttribute.map(Attribute::getJavaType).map(Class::isPrimitive).orElse(false)) {
        return super.isNew(entity);
    }

    BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);

    return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true);
}

새로운 Entity인지 여부는 JpaEntityInformation의 isNew(T entity)에 의해 판단됩니다. 다른 설정이 없으면 JpaEntityInformation의 구현체 중 JpaMetamodelEntityInformation 클래스가 동작합니다. @Version이 사용된 필드가 없거나 @Version이 사용된 필드가 primitive 타입이면 AbstractEntityInformation의 isNew()를 호출합니다. @Version이 사용된 필드가 wrapper class이면 null여부를 확인합니다.

public boolean isNew(T entity) {

    Id id = getId(entity);
    Class<ID> idType = getIdType();

    if (!idType.isPrimitive()) {
        return id == null;
    }

    if (id instanceof Number) {
        return ((Number) id).longValue() == 0L;
    }

    throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}

@Version이 사용된 필드가 없어서 AbstractEntityInformation 클래스가 동작하면 @Id 어노테이션을 사용한 필드를 확인해서 primitive 타입이 아니라면 null 여부, Number의 하위 타입이면 0인지 여부를 확인합니다.@GeneratedValue 어노테이션으로 키 생성 전략을 사용하면 데이터베이스에 저장될 때 id가 할당됩니다. 따라서 데이터베이스에 저장되기 전에 메모리에서 생성된 객체는 id가 비어있기 때문에 isNew()는 true가 되어 새로운 entity로 판단합니다.

직접 ID를 할당하는 경우에는 어떻게 동작하나요? 🤔

키 생성 전략을 사용하지 않고 직접 ID를 할당하는 경우 새로운 entity로 간주되지 않습니다. 이 때는 엔티티에서 Persistable<T> 인터페이스를 구현해서 JpaMetamodelEntityInformation 클래스가 아닌 JpaPersistableEntityInformation의 isNew()가 동작하도록 해야 합니다.

public class JpaPersistableEntityInformation<T extends Persistable<ID, ID> 
        extends JpaMetamodelEntityInformation<T, ID> {

    public JpaPersistableEntityInformation(Class<T> domainClass, Metamodel metamodel, 
            PersistenceUnitUtil persistenceUnitUtil) {
        super(domainClass, metamodel, persistenceUnitUtil);
    }

    @Override
    public boolean isNew(T entity) {
        return entity.isNew();
    }

    @Nullable
    @Override
    public ID getId(T entity) {
        return entity.getId();
    }
}

새로운 Entity인지 판단하는게 왜 중요할까요? 🤓

@Override
@Transactional
public <S extends T> S save(S entity) {

    Assert.notNull(entity, "Entity must not be null");

	if (entityInformation.isNew(entity)) {
		entityManager.persist(entity);
		return entity;
	} else {
		return entityManager.merge(entity);
	}
}

SimpleJpaRepository의 save() 메서드에서 isNew()를 사용하여 persist를 수행할지 merge를 수행할지 결정합니다. 만약 ID를 직접 지정해주는 경우에는 신규 entity라고 판단하지 않기 때문에 merge를 수행합니다. 이때 해당 entity는 신규임에도 불구하고 DB를 조회하기 때문에 비효율적입니다. 따라서, 새로운 entity인지 판단하는 것은 중요한 부분입니다.

추가 학습 자료를 공유합니다.

참고 링크

[28] JPA의 ddl-auto 옵션은 각각 어떤 동작을 하고 어떤 상황에서 사용해야 할까요?

백엔드

JPA의 ddl-auto 옵션은 각각 어떤 동작을 하고 어떤 상황에서 사용해야 할까요?

백엔드와 관련된 질문이에요.

ddl-auto 옵션은 스프링 부트 애플리케이션에서 Hibernate와 같은 JPA 구현체를 사용할 때 데이터베이스 스키마 관리를 제어하는 설정입니다. 이 옵션은 application.properties 또는 application.yml 파일에서 설정할 수 있으며, 다양한 값에 따라 데이터베이스 스키마에 대해 다른 동작을 수행합니다. ddl-auto 옵션에는 none, validate, update, create, create-drop 등이 존재합니다.

각 옵션에 대한 설명을 해주시겠어요? 🤔

none으로 설정하면 데이터베이스 스키마와 관련된 어떠한 작업도 수행하지 않습니다. 데이터베이스 스키마를 수동으로 관리하고 싶을 때 유용하며, 프로덕션 환경에서 주로 사용됩니다.

validate는 애플리케이션이 시작될 때, 엔티티 매핑이 데이터베이스 스키마와 일치하는지 검증하며 스키마 변경은 따로 수행하지 않습니다. 프로덕션 환경에서 엔티티와 데이터베이스 스키마가 일치하는지 확인하고 싶을 때 사용합니다.

update는 엔티티 매핑과 데이터베이스 스키마를 비교하여 필요한 경우 스키마를 업데이트합니다. 기존 데이터는 유지되지만, 새로운 엔티티나 변경된 엔티티 필드는 스키마에 반영됩니다. 해당 옵션은 엔티티에 변경이 발생할 때 자동으로 스키마를 업데이트하고 싶을 때 유용합니다. 프로덕션 환경에서는 예기치 않은 스키마 변경을 방지하기 위해 주의가 필요합니다.

create는 애플리케이션이 시작될 때 기존 스키마를 삭제하고 새로 생성합니다. 데이터가 모두 삭제되며 엔티티 매핑을 기반으로 새로운 스키마가 생성됩니다. 개발 초기에 빈 데이터베이스 스키마를 반복적으로 생성해야 할 때 유용합니다. 기존 데이터가 모두 삭제되므로 프로덕션 환경에서는 사용하지 않습니다.

create-dropcreate와 유사하지만, 애플리케이션이 종료될 때 스키마를 삭제한다는 점이 다릅니다. 해당 옵션은 테스트 환경에서 일시적인 데이터베이스 스키마가 필요한 경우 유용하며, 매 테스트 실행 시마다 깨끗한 데이터베이스 상태를 유지하고자 할 때 사용됩니다. 프로덕션 환경에서는 사용하지 않습니다.

프로덕션 환경에서 스키마 변경은 어떻게 해야하나요? 🤓

스키마 변경이 필요할 때는 적절한 데이터베이스 마이그레이션 도구(Flyway, Liquibase 등)를 사용하여 제어된 방식으로 스키마를 관리하거나, 사용자가 없는 새벽에 스키마 변경 작업을 수동으로 진행하는 것이 더욱 안전할 수 있습니다.

참고 링크

참고 링크 없음

[29] 엔티티 매니저에 대해 설명해주세요.

백엔드

엔티티 매니저에 대해 설명해주세요.

백엔드와 관련된 질문이에요.

엔티티 매니저에 대해 알기 위해선 영속성 컨텍스트에 대해 알아야 합니다. 영속성 컨텍스트는 엔티티를 영구 저장하는 환경으로 1차 캐싱, 쓰기 지연, 변경 감지를 통해 영속 로직을 효율적으로 할 수 있게 해줍니다. 이러한 효율적인 영속 로직 수행을 위해서 엔티티는 영속성 컨텍스트에 관리되어야 합니다. 이런 작업을 도와주는 것이 바로 엔티티 매니저입니다. 엔티티 매니저는 엔티티의 상태를 변경하고, 영속성 컨텍스트와 상호작용함으로써 영속 로직을 수행하는 역할을 가지고 있습니다.

조금 더 구체적으로 엔티티 매니저의 역할을 설명해 주실 수 있을까요? 🤔

엔티티는 영속성 컨텍스트와 관련하여 4가지 상태(비영속, 영속, 준영속, 삭제)를 가질 수 있는데요. 엔티티 매니저는 persist, merge, remove, close 메서드를 이용하여 엔티티의 상태를 변경할 수 있습니다. 또한, 엔티티 매니저는 영속성 컨텍스트의 1차 캐시로부터 엔티티를 조회할 수 있으며, 쓰기 지연 저장소에 있는 쿼리들을 flush하여 DB와 동기화시킬 수 있습니다. 또한 JPQL이나 Native Query를 이용해 직접 DB로부터 데이터를 불러올 수도 있습니다.

엔티티의 각 상태에 대해서 설명해주세요. 😀

Member member = new Member("산초");

비영속 상태는 엔티티 객체가 새로 생성되었지만, 아직 영속성 컨텍스트와 연관되지 않은 상태입니다. 이 상태에서는 데이터베이스와 전혀 관련이 없으며, 엔티티 객체는 메모리 상에만 존재합니다.

em.persist(member);
em.merge(detagedMember);
em.find(Member.class, 1L);

영속 상태는 엔티티 객체가 영속성 컨텍스트에 관리되고 있는 상태입니다. 이 상태에서는 엔티티의 변경 사항이 자동으로 데이터베이스에 반영됩니다.

em.detach(member);
em.clear();
em.close();

준영속 상태는 엔티티 객체가 한 번 영속성 컨텍스트에 의해 관리되었지만, 현재는 영속성 컨텍스트와 분리된 상태입니다. 이 상태에서는 엔티티 객체의 변경 사항이 더 이상 데이터베이스에 반영되지 않습니다. 영속성 컨텍스트 종료, 트랜잭션 종료 등으로도 준영속 상태로 전환됩니다.

em.remove(member);

삭제 상태는 엔티티 객체가 영속성 컨텍스트에서 제거된 상태입니다. 이 상태에서는 엔티티 객체가 데이터베이스에서 삭제됩니다.

추가 학습 자료를 공유합니다.

참고 링크

[49] JPA의 N + 1 문제에 대해서 설명해주세요.

백엔드

JPA의 N + 1 문제에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

JPA N + 1 문제는 연관 관계가 설정된 엔티티를 조회할 경우에, 조회된 데이터 개수(N)만큼 연관관계의 조회 쿼리가 추가로 발생하는 현상입니다. 예를 들어, 블로그 게시글과 댓글이 있는 경우, 게시글을 조회한 후 각 게시글마다 댓글을 조회하기 위한 추가 쿼리가 발생할 수 있습니다. 이를 N + 1 문제라고 합니다.

findAll 메서드의 글로벌 패치 전략 별 N + 1 문제 상황에 대해서 설명해주세요. 🤓

spring data jpa에서 제공하는 repository의 findAll 메서드입니다!

글로벌 패치 전략을 즉시로딩으로 설정하고 findAll()을 실행하면 N + 1 문제가 발생합니다. 이는 findAll()은 select u from User u라는 JPQL 구문을 생성해서 실행하기 때문입니다. JPQL은 글로벌 패치 전략을 고려하지 않고 쿼리를 실행합니다. 모든 User를 조회하는 쿼리 실행 후, 즉시로딩 설정을 보고 연관관계에 있는 모든 엔티티를 조회하는 쿼리를 실행합니다.

글로벌 패치 전략을 지연 로딩으로 설정하고 findAll()을 실행하면 N + 1 문제가 발생하지 않습니다. 이는 연관관계에 있는 엔티티를 실제 객체 대신에 프록시 객체로 생성하여 주입하기 때문입니다. 하지만 프록시 객체를 사용할 경우에 실제 데이터가 필요하여 조회하는 쿼리가 발생하고 N + 1 문제가 발생할 수 있습니다.

N + 1 문제는 어떻게 해결할 수 있을까요? 🤔

N + 1 문제를 해결하기 위해서는 fetch join, @EntityGraph를 사용해 볼 수 있습니다. fetch join은 연관 관계에 있는 엔티티를 한번에 즉시 로딩하는 구문입니다. @EntityGraph도 비슷한 효과를 만들어내며, 쿼리 메서드에 해당 어노테이션을 추가해 사용할 수 있습니다.

select distinct u
from User u
left join fetch u.posts
@EntityGraph(attributePaths = {"posts"}, type = EntityGraphType.FETCH)
List<User> findAll();

추가 학습 자료를 공유합니다.

참고 링크

[50] 자바에서 Checked Exception과 Unchecked Exception에 대해서 설명해주세요.

백엔드

자바에서 Checked Exception과 Unchecked Exception에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

Checked Exception은 컴파일 시점에 확인되며, 반드시 처리해야 하는 예외입니다. 자바에서는 IOException, SQLException 등이 이에 속합니다. Checked Exception을 유발하는 메서드를 호출하는 경우, 메서드 시그니처에 throws를 사용하여 호출자에게 예외를 위임하거나 메서드 내에서 try-catch를 사용하여 해당 예외를 반드시 처리해야합니다.

Unchecked Exception은 런타임 시점에 발생하는 예외로, 컴파일러가 처리 여부를 강제하지 않습니다. 자바에서는 RuntimeException을 상속한 예외들이 해당됩니다. 일반적으로 프로그래머의 실수나 코드 오류로 인해 발생합니다.

각각 언제 사용해야 할까요? 🤔

정답이 없는 영역이라고 생각해요. 자신의 주관을 만들면서 학습해봐도 좋을 것 같아요!

Checked Exception은 외부 환경과의 상호작용에서 발생할 가능성이 높은 예외에 적합합니다. 예를 들어, 파일 입출력, 네트워크 통신 등에서 발생할 수 있는 예외는 Checked Exception으로 처리하는 것이 좋습니다. 이러한 예외는 예측 가능하며, 호출하는 쪽에서 적절히 처리할 수 있는 여지가 있습니다.

Uncheked Exception은 코드 오류, 논리적 결함 등 프로그래머의 실수로 인해 발생할 수 있는 예외에 적합합니다. 예를 들어, null 참조 또는 잘못된 인덱스 접근 등은 호출자가 미리 예측하거나 처리할 수 없기 때문에 Unchecked Exception으로 두는 것이 좋습니다.

Error와 Exception의 차이는 무엇인가요? 🤓

Error는 주로 JVM에서 발생하는 심각한 문제로, OutOfMemoryError, StackOverflowError 등 시스템 레벨에서 발생하는 오류입니다. 이는 일반적으로 프로그램에서 처리하지 않으며, 회복이 어려운 오류에 속하며, 애플리케이션 코드에서 복구할 수 없는 심각한 문제를 나타냅니다.

반면, Exception은 프로그램 실행 중 발생할 수 있는 오류 상황을 나타냅니다. 대부분의 경우 회복 가능성이 있으며, 프로그램 내에서 예외 처리를 통해 오류 상황을 제어할 수 있습니다. Exception은 다시 Checked ExceptionUnchecked Exception으로 나눌 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[53] 일급 컬렉션이 무엇인가요?

백엔드

일급 컬렉션이 무엇인가요?

백엔드와 관련된 질문이에요.

일급 컬렉션(First-Class Collection)은 하나의 컬렉션을 감싸는 클래스를 만들고, 해당 클래스에서 컬렉션과 관련된 비즈니스 로직을 관리하는 패턴을 말합니다. 아래 코드 중에서 Order의 List 자료구조를 감싼 Orders가 일급 컬렉션의 예시입니다.

// 일급 컬렉션
public class Orders {

    private final List<Order> orders;

    public Orders(List<Order> orders) {
        validate(orders); // 검증 수행
        ...
    }

    public void add(Order order) {
        if (order == null) {
            throw new IllegalArgumentException("Order cannot be null");
        }
        orders.add(order);
    }

    public List<Order> getAll() {
        return Collections.unmodifiableList(orders);
    }

    public double getTotalAmount() {
        return orders.stream()
                     .mapToDouble(Order::getAmount)
                     .sum();
    }
}
public class OrderService {
  
    private final Orders orders = new Orders();

    public void addOrder(Order order) {
        orders.add(order);
    }

    public Orders getOrders() {
        return orders;
    }

    // 추가 비즈니스 로직...
}

일급 컬렉션을 사용해야하는 이유는 무엇인가요? 😀

일급 컬렉션 클래스에 로직을 포함하거나 비즈니스에 특화된 명확한 이름을 부여할 수 있습니다. 또한, 불필요한 컬렉션 API를 외부로 노출하지 않도록 할 수 있으며, 컬렉션을 변경할 수 없도록 만든다면 예기치 않은 변경으로부터 데이터를 보호할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[60] 데이터베이스 인덱스에 대해서 설명해주세요.

백엔드

데이터베이스 인덱스에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

인덱스는 데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료구조로 백과사전의 색인과 같습니다. 저장되는 컬럼의 값을 사용하여 항상 정렬된 상태를 유지하는 것이 특징입니다. 이러한 특징으로 인해 인덱스는 INSERT, UPDATE, DELETE의 성능이 희생된다는 것이 단점입니다.

인덱스는 어떤 자료 구조로 이루어져있나요? 🤔

MySQL InnoDB를 기준으로 설명드리자면, B+Tree와 같은 변형 B-Tree 자료구조를 이용해서 인덱스를 구현합니다. 기본 토대는 B-Tree 인덱스이기 때문에 이를 기준으로 설명합니다. B-Tree 인덱스는 컬럼의 값을 변형하지 않고 인덱스 구조체 내에서 항상 정렬된 상태로 유지합니다.

B-Tree(Balanced-Tree)에서는 크게 3가지 노드가 존재합니다. 최상위에 하나의 루트 노드가 존재하며, 가장 하위 노드인 리프 노드가 존재합니다. 이 두 노드의 중간에 존재하는 브랜치 노드가 존재합니다. 최하위 노드인 리프 노드에는 실제 데이터 레코드를 찾아가기 위한 주소값을 가지고 있습니다.

InnoDB 스토리지 엔진에서는 세컨더리 인덱스(프라이머리 인덱스를 제외한 모든 인덱스)의 리프 노드에는 레코드의 PK가 저장됩니다. 따라서 세컨더리 인덱스 검색에서는 레코드를 읽기 위해 PK를 가지고 있는 B-Tree를 다시 한번 검색해야합니다.

MySQL 스캔 방식은 어떤 게 있나요? 😀

MySQL에는 크게 인덱스 레인지 스캔, 인덱스 풀 스캔, 루스 인덱스 스캔 방식이 있습니다.

인덱스 레인지 스캔은 검색할 인덱스 범위가 결정되었을 경우 사용하며 가장 빠릅니다.

  • 인덱스에서 조건을 만족하는 값이 저장된 시작 리프 노드를 찾습니다.(index seek)
  • 시작 리프 노드부터 필요한 만큼 인덱스를 차례대로 읽습니다. (index scan)
  • 인덱스 키와 레코드 주소를 이용해 저장된 페이지를 가져오고 레코드를 읽어옵니다.

레코드를 읽어오는 과정에서 랜덤 IO가 발생할 수 있습니다. 읽어야할 데이터 레코드가 전체 20-25%의 경우에는 풀 테이블 스캔(순차 IO를 이용)이 더욱 좋을 수 있습니다.

인덱스 풀 스캔은 인덱스를 사용하지만 인덱스를 처음부터 끝까지 모두 읽는 방식입니다.

  • 인덱스를 ABC 순서로 만들었는데 조건절에 B 혹은 C로 검색하는 경우 사용됩니다.
  • 인덱스를 생성하는 목적은 아니지만, 그래도 풀 테이블 스캔보다는 낫습니다. (데이터 레코드까지 읽지 않는 경우)

루스 인덱스 스캔은 듬성듬성하게 인덱스를 읽는 것을 의미합니다. (앞서 언급한 인덱스 레인지, 인덱스 풀 스캔은 타이트 인덱스 스캔으로 분류됩니다.)

  • 중간에 필요하지 않은 인덱스 키 값은 무시하고 다음으로 넘어가는 형태로 처리합니다.
  • group by, max(), min() 함수에 대해 최적화하는 경우에 사용됩니다.

추가 학습 자료를 공유합니다.

참고 링크

[61] 트랜잭션 격리수준은 무엇인가요?

백엔드

트랜잭션 격리수준은 무엇인가요?

백엔드와 관련된 질문이에요.

트랜잭션의 격리 수준은 동시에 여러 트랜잭션이 실행될 때 한 트랜잭션이 다른 트랜잭션의 연산에 영향을 받지 않도록 하는 정도를 말합니다. 낮은 격리 수준은 동시 처리 능력을 높이지만, 데이터의 일관성 문제를 발생시킬 수 있습니다. 반면, 높은 격리 수준은 데이터의 일관성을 보장하지만, 동시 처리 능력이 떨어질 수 있습니다. 즉, 데이터 정합성과 성능은 반비례합니다. 트랜잭션 격리 수준은 개발자가 트랜잭션 격리 수준을 설정할 수 있는 기능을 제공하는 기능입니다.

트랜잭션 격리 수준은 어떤 것이 있고 각각 어떤 특징이 있나요? 🤔

트랜잭션 격리 수준은 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ가 존재합니다.

READ UNCOMMITTED는 커밋이 되지 않은 트랜잭션의 데이터 변경 내용을 다른 트랜잭션이 조회하는 것을 허용합니다. 또한 해당 격리 수준에서는 Dirty Read, Phantom Read, Non-Repeatable Read 문제가 발생할 수 있습니다.

READ COMMITTED는 커밋이 완료된 트랜잭션의 변경사항만 다른 트랜잭션에서 조회할 수 있도록 허용합니다. 특정 트랜잭션이 이루어지는 동안, 다른 트랜잭션은 해당 데이터에 접근할 수 없습니다. Dirty Read는 발생하지 않지만, Phantom Read, Non-Repeatable Read 문제가 발생할 수 있습니다.

REPEATABLE READ는 한 트랜잭션에서 특정 레코드를 조회할 때 항상 같은 데이터를 응답하는 것을 보장합니다. 하지만, SERIALIZABLE과 다르게 행이 추가되는 것을 막지는 않습니다. Non-Repeatable Read 문제가 발생하지 않지만, Phantom Read 문제가 발생할 수 있습니다.

SERIALIZABLE은 특정 트랜잭션이 사용중인 테이블의 모든 행을 다른 트랜잭션이 접근할 수 없도록 잠급니다. 가장 높은 데이터 정합성을 가지지만 성능이 가장 낮습니다. MySQL의 경우 단순한 SELECT 쿼리가 실행되더라도 데이터베이스 잠금이 걸려 다른 트랜잭션에서 데이터에 접근할 수 없습니다.

발생하는 문제를 기준으로 설명을 잘해주셨네요. 그런데 각 문제들은 어떤 문제들인가요? 🤓

Dirty Read는 한 트랜잭션이 다른 트랜잭션이 변경 중인 데이터를 읽는 경우 발생합니다. 다른 트랜잭션이 아직 커밋되지 않은 (즉, 롤백할 가능성이 있는) 데이터를 읽어서, 그 데이터가 나중에 롤백될 경우 트랜잭션의 결과가 변경될 수 있습니다. 이는 데이터의 일관성을 깨뜨릴 수 있습니다.

Phantom Read는 한 트랜잭션이 동일한 쿼리를 두 번 실행했을 때, 두 번의 쿼리 사이에 다른 트랜잭션이 삽입, 갱신, 삭제 등의 작업을 수행하여 결과 집합이 달라지는 경우를 말합니다. 이로 인해 한 트랜잭션 내에서 일관성 없는 결과를 가져올 수 있습니다.

Non-Repeatable Read는 같은 트랜잭션 안에서 동일한 쿼리를 실행했을 때, 다른 결과를 얻는 경우를 의미합니다. 예를 들어, 한 트랜잭션이 같은 데이터를 두 번 읽을 때, 첫 번째 읽기와 두 번째 읽기 사이에 다른 트랜잭션이 해당 데이터를 변경했을 경우 발생할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[62] 얕은 복사와 깊은 복사에 대해서 설명해주세요.

백엔드

얕은 복사와 깊은 복사에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

자바에서 객체를 복사할 때 얕은 복사깊은 복사라는 두 가지 방식이 있습니다. 먼저 Book과 Author라는 두 클래스를 사용해서 예제를 살펴볼게요. Book은 책의 이름(name)과 저자(author) 정보를 가지고 있고, Author는 저자의 이름을 가지고 있습니다.

class Book {

    private String name; // 책 이름
    private Author author; // 저자

    public Book(String name, Author author) {
        this.name = name;
        this.author = author;
    }

    public Book shallowCopy() { // 얕은 복사
        return new Book(this.name, this.author);
    }

    public Book deepCopy() { // 깊은 복사
        Author copiedAuthor = new Author(this.author.getName());
        return new Book(this.name, copiedAuthor);
    }

    public void changeAuthor(String name) { // 저자 이름 변경
        author.setName(name);
    }

    @Override
    public String toString() {
        return "Book name : " + name + ", " + author;
    }

    static class Author {

        private String name; // 저자 이름

        public Author(String name) {
            this.name = name;
        }

        public String getName() { // 저자 이름 반환
            return name;
        }

        public void setName(String name) { // 저자 이름 변경
            this.name = name;
        }

        @Override
        public String toString() {
            return "Author : " + name;
        }
    }

    public static void main(String[] args) {
        Author author1 = new Author("조슈아 블로크");
        Book book1 = new Book("이펙티브 자바", author1);

        // 얕은 복사 후 변경
        Book shallowCopyBook = book1.shallowCopy();
        shallowCopyBook.changeAuthor("Joshua Bloch");

        // 얕은 복사 결과 출력
        System.out.println("After shallow copy and change:");
        System.out.println("Original book1: " + book1);
        System.out.println("Shallow copied book: " + shallowCopyBook);

        Author author2 = new Author("마틴 파울러");
        Book book2 = new Book("리팩터링", author2);

        // 깊은 복사 후 변경
        Book deepCopyBook = book2.deepCopy();
        deepCopyBook.changeAuthor("Martin Fowler");

        // 깊은 복사 결과 출력
        System.out.println("
After deep copy and change:");
        System.out.println("Original book2: " + book2);
        System.out.println("Deep copied book: " + deepCopyBook);
    }
}

shallowCopy() 메서드는 새로운 Book 객체를 만들지만, 내부의 Author 객체는 원본과 동일한 객체를 참조합니다. 즉, Book 객체는 새로 만들었지만, Author 객체는 새로 만들지 않고 기존의 것을 그대로 사용합니다. 예를 들어, book1에서 shallowCopyBook을 만든 후, shallowCopyBook의 저자 이름을 “Joshua Bloch”로 바꾸면 book1의 저자 이름도 “Joshua Bloch”로 바뀝니다. 둘이 같은 Author 객체를 공유하고 있기 때문에 두 Book 객체의 Author가 동시에 변경되는 거죠.

반면 deepCopy() 메서드는 Book 객체와 Author 객체 모두 새로운 객체로 만들어줘요. 그래서 book2에서 deepCopyBook을 만들고 deepCopyBook의 저자 이름을 “Martin Fowler”로 바꾸어도, book2의 저자 이름은 여전히 “마틴 파울러”로 남아 있어요. deepCopyBook과 book2가 서로 다른 Author 객체를 참조하고 있기 때문이에요.

출력 결과를 보면,

After shallow copy and change:
Original book1: Book name : 이펙티브 자바, Author : Joshua Bloch
Shallow copied book: Book name : 이펙티브 자바, Author : Joshua Bloch

얕은 복사에서 shallowCopyBook과 book1이 같은 Author를 공유하니까, shallowCopyBook의 저자 이름을 바꾸면 book1의 저자 이름도 바뀐 거예요.

After deep copy and change:
Original book2: Book name : 리팩터링, Author : 마틴 파울러
Deep copied book: Book name : 리팩터링, Author : Martin Fowler

깊은 복사한 deepCopyBook과 book2는 서로 다른 Author 객체를 참조하니까, deepCopyBook의 저자 이름을 바꿔도 book2는 영향을 받지 않습니다.

참고 링크

참고 링크 없음

[66] 로그와 메트릭을 설명해주세요.

백엔드

로그와 메트릭을 설명해주세요.

백엔드와 관련된 질문이에요.

로그는 서버가 동작할 때 서버의 상태와 동작 정보를 시간 경과에 따라 기록된 결과입니다. 로그는 시스템의 오류와 문제들을 쉽게 찾아낼 수 있도록 도와줍니다. 반면, 메트릭은 시스템의 성능과 상태에 대한 통계적인 정보를 의미합니다. 메트릭을 잘 수집하면 시스템의 현재 상태를 손쉽게 파악할 수 있고, 사업 현황에 관한 유용한 정보를 얻을 수 있습니다. 가령, 메트릭은 DAU, Retension, CPU 사용량, 메모리 사용량 등이 있습니다.

로그와 메트릭을 수집해 보신 적이 있나요? 🤔

아래부터는 예시로 생각해주세요! 각자 진행하신 프로젝트 상황에 맞는 답변을 생각해 주세요. 😀

스프링 부트 액추에이터를 사용해 메트릭을 생성하고 프로메테우스에 저장한 다음 그라파나로 시각화한 경험이 있습니다. 수집한 지표는 다음과 같습니다.

  • CPU, 메모리, JVM 힙 사용량
  • 톰캣 스레드 풀과 데이터베이스 커넥션 풀 상태
  • error 레벨 로그 증가량

로깅은 LogBack을 이용했습니다. 그리고, Loki에 7일동안 보관하도록 설정했으며 로그 추적을 위해 MDC를 사용했습니다.

언급해주신 메트릭을 수집하신 이유가 있으신가요? 🤓

CPU, 메모리, JVM 사용량 지표를 수집한 이유는 서비스가 현재 안정적으로 동작하고 있는지 파악하기 위함입니다. 또한, 톰캣 스레드 풀과 커넥션 풀의 상태와 error 레벨 로그를 수집한 이유는 서버 프로그램 내부에 비정상적인 상황이 생기는 것을 신속히 대응하기 위함입니다.

System.out.println을 사용하면 로깅 프레임워크는 사용하지 않아도 되지 않나요? 👀

로그를 출력하는 경우 대기 시간이 발생합니다. 그리고, 로그 또한 데이터이기 때문에 저장 공간을 요구합니다. 따라서, 정말로 필요한 경우에만 로깅을 수행하는 것이 비용 효율적입니다. 하지만, System.out.println은 로그 레벨 설정과 환경 별 필터링을 적용하기 까다롭습니다. 반면, 로깅 프레임워크는 로그 레벨 설정, 필터링 등 로그의 양 조절을 하기 위한 기능을 제공하기 때문에 이를 사용하는 것이 서비스 운영에 유리합니다.

추가 학습 자료를 공유합니다.

참고 링크

[69] JPA에서 ID 생성 전략에 대해 설명해주세요.

백엔드

JPA에서 ID 생성 전략에 대해 설명해주세요.

백엔드와 관련된 질문이에요.

JPA에서 ID를 생성하기 위해서는 직접 할당과 자동 할당을 사용할 수 있습니다. 직접 할당은 @Id어노테이션만을 사용하여 Id값을 직접 할당하는 방식입니다. 반면, 자동 할당은 @Id@GeneratedValue를 함께 사용해서 원하는 키 생성 전략을 선택하는 방식입니다. @GeneratedValue의 stretagy 옵션을 통해 생성 전략을 설정할 수 있는데, 여기에 올 수 있는 값인 GenerationType는 다음과 같습니다.

@Target({ElementType.METHOD, ElementType.FIELD})  
@Retention(RetentionPolicy.RUNTIME)  
public @interface GeneratedValue {  
    GenerationType strategy() default GenerationType.AUTO;  
  
    String generator() default "";  
}

public enum GenerationType { 
	AUTO,
	IDENTITY,
	SEQUENCE, 
	TABLE
}

자동 생성 방식을 사용할 때 각 전략에 대해서 설명해주세요. 🤔

IDENTITY 전략은 기본 키 생성을 DB에 위임하는 전략입니다. 주로 MySQL, PostgreSQL, SQL Server, DB2에서 사용됩니다. 해당 전략을 사용하면 엔티티를 생성할 때 쓰기 지연이 적용되지 않습니다. 왜냐하면 JPA에서 엔티티를 영속하기 위해선 식별자가 필요한데, IDENTITY 전략에서는 이 식별자가 DB에 저장되어야 할당되기 때문입니다. 따라서 엔티티를 생성할 때 즉시 INSERT 쿼리가 실행되어야 합니다. 이때 하이버네이트를 사용하는 경우에는 INSERT 쿼리의 결과를 다시 조회하지 않기 위해서 내부적으로 Statement.getGeneratedKeys를 사용합니다. 추가로 IDENTITY 전략을 사용하면 배치 인서트가 불가하다는 점을 주의해야합니다.

SEQUENCE 전략은 시퀀스 키 생성 전략을 지원하는 DB에서 사용할 수 있습니다. 데이터베이스 시퀀스란, 유일한 값을 자동으로 생성하게 하는 객체입니다. auto_increment와 달리 초기 값과 한번에 증가할 크기를 설정할 수 있습니다. 해당 시퀀스를 키 생성 전략으로 갖는 DB에 대해 SEQUENCE 전략을 사용할 수 있습니다. 어떤 시퀀스를 사용할 것인지를 @SequenceGenerator 로 설정할 수 있습니다. SEQUENCE 전략은 em.persist()를 호출하는 경우 먼저 데이터베이스 시퀀스를 이용하여 식별자를 조회합니다. 이후 조회한 식별자를 엔티티에 할당한 후에 엔티티를 영속성 컨텍스트에 저장합니다. 트랜잭션을 커밋하여 플러시가 일어나면 엔티티를 저장한다는 점에서 IDENTITY 전략과 차이가 있습니다.

TABLE 전략은 키 생성 전용 테이블을 만들어 시퀀스를 흉내내는 전략입니다. 어떤 테이블을 사용할 것인지를 @TableGenerator로 설정할 수 있습니다. TABLE 전략은 값을 조회하면서 SELECT 쿼리를 사용하며 증가를 위해 UPDATE 쿼리를 사용합니다. SEQUENCE 전략보다 DB와 한번 더 통신한다는 점에서 성능이 안좋다는 단점이 있지만, 모든 DB에 적용할 수 있다는 장점이 있습니다.

AUTO 전략은 데이터베이스 방언에 따라서 IDENTITY, SEQUENCE, TABLE 중 하나를 자동으로 선택합니다. 데이터베이스를 변경해도 코드를 수정할 필요가 없다는 장점이 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[70] equals와 hashCode는 왜 함께 재정의해야 할까요?

백엔드

equals와 hashCode는 왜 함께 재정의해야 할까요?

백엔드와 관련된 질문이에요.

equals와 hashCode 메서드는 객체의 동등성 비교와 해시값 생성을 위해서 사용할 수 있습니다. 하지만, 함께 재정의하지 않는다면 예상치 못한 결과를 만들 수 있습니다. 가령, 해시값을 사용하는 자료구조(HashSet, HashMap..)을 사용할 때 문제가 발생할 수 있습니다.

class EqualsHashCodeTest {

    @Test
    @DisplayName("equals만 정의하면 HashSet이 제대로 동작하지 않는다.")
    void test() {
        // 아래 2개는 같은 구독자
        Subscribe subscribe1 = new Subscribe("team.maeilmail@gmail.com", "backend");
        Subscribe subscribe2 = new Subscribe("team.maeilmail@gmail.com", "backend");
        HashSet<Subscribe> subscribes = new HashSet<>(List.of(subscribe1, subscribe2));

        // 결과는 1개여야하는데..? 2개가 나온다.
        System.out.println(subscribes.size());
    }

    class Subscribe {

        private final String email;
        private final String category;

        public Subscribe(String email, String category) {
            this.email = email;
            this.category = category;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Subscribe subscribe = (Subscribe) o;
            return Objects.equals(email, subscribe.email) && Objects.equals(category, subscribe.category);
        }
    }
}

왜 이런 현상이 발생하나요? 🤔

해시값을 사용하는 자료구조는 hashCode 메서드의 반환값을 사용하는데요. hashCode 메서드의 반환 값이 일치한 이후 equals 메서드의 반환값 참일 때만 논리적으로 같은 객체라고 판단합니다. 위 예제에서 Subscribe 클래스는 hashCode 메서드를 재정의하지 않았기 때문에 Object 클래스의 기본 hashCode 메서드를 사용합니다. Object 클래스의 기본 hashCode 메서드는 객체의 고유한 주소를 사용하기 때문에 객체마다 다른 값을 반환합니다. 따라서 2개의 Subscribe 객체는 다른 객체로 판단되었고 HashSet에서 중복 처리가 되지 않았습니다.

참고 링크

참고 링크 없음

[71] 동일성과 동등성에 대해서 설명해주세요.

백엔드

동일성과 동등성에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

동일성과 동등성은 객체를 비교할 때 중요한 개념입니다. 자바에서는 이 두 개념을 equals() 메서드와 == 연산자를 통해 구분할 수 있습니다.

equals()==의 차이는 무엇인가요?

equals()는 객체의 내용을 비교하는 반면, ==는 객체의 참조(레퍼런스)를 비교합니다. 따라서 두 객체의 내용이 같더라도 서로 다른 객체라면 equals()는 true를 반환할 수 있지만, ==는 false를 반환합니다.

동등성(Equality)은 뭔가요?

동등성은 논리적으로 객체의 내용이 같은지를 비교하는 개념입니다. 자바에서는 equals() 메서드를 사용하여 객체의 동등성을 비교합니다. Apple 클래스를 예시로 보면, Object.equals 메서드를 오버라이딩하여 객체의 실제 데이터를 비교하도록 했습니다. 그래서 apple과 anotherApple은 다른 객체이지만, 무게가 같기 때문에 동등성 비교 결과 true가 반환됩니다.

public class Apple {

    private final int weight;

    public Apple(int weight) {
        this.weight = weight;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Apple apple = (Apple) o;
        return weight == apple.weight;
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(weight);
    }

    public static void main(String[] args) {
        Apple apple = new Apple(100);
        Apple anotherApple = new Apple(100);

        System.out.println(apple.equals(anotherApple)); // true
    }
}

왜 equals() 메서드를 오버라이딩 했나요?

public class Object {
    ...
    public boolean equals(Object obj) {
        return (this == obj);
    }
    ...
}

Object 클래스의 equals() 메서드는 == 연산자를 사용하여 동일성을 비교합니다. 그리고 모든 클래스는 Object 클래스를 상속하여 동일성 비교를 기본으로 동작하기 때문에, 동등성 비교가 필요한 클래스에서 필요에 맞게 equals & hashCode 메서드를 오버라이딩해야 합니다.

동일성(Identity)은 뭔가요?

동일성은 두 객체가 메모리 상에서 같은 객체인지 비교하는 개념입니다. 자바에서는 == 연산자를 사용하여 객체의 동일성을 비교합니다. == 연산자는 객체의 레퍼런스(참조)를 비교하므로, 두 변수가 동일한 객체를 가리키고 있는지를 확인합니다.

public static void main(String[] args) {
    Apple apple1 = new Apple(100);
    Apple apple2 = new Apple(100);
    Apple apple3 = apple1;

    System.out.println(apple1 == apple2); // false
    System.out.println(apple1 == apple3); // true
}

apple1과 apple2는 참조가 다르기 때문에 == 연산 결과 false가 반환되지만, apple1의 참조를 가지는 apple3은 == 연산 결과 true를 반환합니다.

String은 객체인데 == 비교해도 되던데 어떻게 된건가요?

문자열 리터럴은 문자열 상수 풀(String Constant Pool) 에 저장되기 때문에, 동일한 문자열 리터럴을 참조하면 == 연산자가 true를 반환할 수 있습니다. 하지만 new 키워드를 사용하여 문자열을 생성하면 새로운 객체가 생성되므로 == 연산자가 false를 반환할 수 있습니다. 따라서 문자열 비교 시 항상 equals() 메서드를 사용한 동등성 비교를 하는 것이 좋습니다.

public class StringComparison {
    public static void main(String[] args) {
        String str1 = "안녕하세요";
        String str2 = "안녕하세요";
        String str3 = new String("안녕하세요");
        
        // 동일성 비교
        System.out.println(str1 == str2); // true
        System.out.println(str1 == str3); // false
        
        // 동등성 비교
        System.out.println(str1.equals(str2)); // true
        System.out.println(str1.equals(str3)); // true
    }
}

// String.class equals 오버라이딩 되어있음.
public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    return (anObject instanceof String aString)
            && (!COMPACT_STRINGS || this.coder == aString.coder)
            && StringLatin1.equals(value, aString.value);
}

Integer 같은 래퍼 클래스는 어떻게 비교하나요?

래퍼 클래스도 객체이기 때문에 == 연산자는 참조를 비교합니다. 값 비교를 원할 경우 equals() 메서드를 사용해야 합니다. 다만, 자바는 특정 범위의 래퍼 객체를 캐싱하므로 같은 값의 Integer 객체가 같은 참조를 가질 수 있습니다(-128 ~ 127). 하지만 일반적으로 equals()를 사용하는 것이 안전합니다.

추가 학습 자료를 공유합니다.

참고 링크

[72] @Component, @Controller, @Service, @Repository의 차이점에 대해서 설명해주세요.

백엔드

@Component, @Controller, @Service, @Repository의 차이점에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

@Component, @Service, @Controller, @Repository는 각각의 클래스를 특정 역할을 수행하는 Spring Bean으로 등록할 때 사용됩니다. 각 애너테이션은 클래스가 어떤 역할을 하는지를 명시적으로 나타내며, Spring의 @ComponentScan 기능을 통해 자동으로 Bean으로 등록됩니다. @Service, @Controller, @Repository 어노테이션은 내부적으로 @Component 어노테이션을 사용하고 있으며, 각 특징과 용도는 아래와 같습니다.

  • @Component는 가장 일반적인 형태의 어노테이션으로, 특정 역할에 종속되지 않는 일반적인 Spring Bean을 나타냅니다. 공통 기능을 제공하는 유틸리티 클래스나, 특정 계층에 속하지 않는 일반적인 컴포넌트를 정의할 때 사용됩니다.

  • @Service는 비즈니스 로직을 수행하는 클래스에 사용되며 서비스 레이어의 Bean을 나타냅니다.

  • @Controller는 Spring MVC에서 웹 요청을 처리하는 컨트롤러 클래스에 사용되며 프레젠테이션 레이어의 Bean을 나타냅니다.

  • @Repository는 데이터베이스와의 상호작용을 수행하는 클래스에 사용되며. 데이터 액세스 레이어의 Bean을 나타냅니다.

@Controller, @Repository 대신 @Component 사용하면 안되나요?

Spring 6(Spring Boot 3) 이전 버전에서는 @Component + @RequestMapping으로도 Bean 및 핸들러로 등록되었습니다. 하지만 Spring 6 이후 부터 @Controller 외에는 핸들러로 등록하지 않아 웹 요청을 정상적으로 수행할 수 없습니다.

public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping
		implements MatchableHandlerMapping, EmbeddedValueResolverAware {
    ...
    @Override
    protected boolean isHandler(Class<?> beanType) {
        return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class); // 컨트롤러 애너테이션인지 확인
    }
    ...
}

@Repository를 @Component로 대체할 경우, PersistenceExceptionTranslationPostProcessor에 의해 예외가 DataAccessException으로 변환되지 않습니다. 이 경우 데이터 액세스 계층에서 발생하는 예외 처리에 영향을 미칠 수 있습니다.

또 @Service, @Controller, @Repository는 각각 특정 계층을 나타내므로, AOP의 포인트컷을 정의할 때 유용하게 사용될 수 있습니다. @Component를 사용하면 이러한 계층 구분이 불분명해져 AOP 적용이 어려울 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[74] 동기 방식으로 외부 서비스를 호출할 때 외부 서비스 장애가 나면 어떻게 조치할 수 있나요?

백엔드

동기 방식으로 외부 서비스를 호출할 때 외부 서비스 장애가 나면 어떻게 조치할 수 있나요?

백엔드와 관련된 질문이에요.

외부 서비스 장애로 인해 응답이 오래 걸린다고 했을 때 외부 API 응답으로 대기하는 자원들이 운영 서버 내부에 쌓이면서 성능에 악영향을 줄 수 있습니다. 이를 해결하기 위한 가장 기본적인 방법은 타임아웃을 설정하는 것입니다. 크게 타임아웃에는 커넥션 타임아웃과 리드 타임아웃, HTTP 커넥션 풀 타임아웃을 설정해 볼 수 있습니다.

다음과 같이 특정 서비스의 장애가 전체 서비스에 영향을 주는 경우는 어떻게 해결할 수 있을까요? 🤔

1. A 서비스, B 서비스, C 서비스 연동 코드가 HTTP 커넥션 풀을 공유한다.
2. A 서비스의 장애로 응답 시간 지연이 발생하는 경우
    2-1. 풀에 남은 커넥션이 점점 줄어든다.
    2-2. 풀에서 커넥션을 구하는 대기 시간이 증가한다.
    2-3. B, C 서비스에 대한 연동도 함께 대기한다.

이 경우는 벌크헤드 패턴을 적용해 볼 수 있습니다. 벌크헤드 패턴은 기능의 종류마다 자원 사용을 분리하는 것을 의미하는데요. 자원을 격리하여 서비스 일부에 장애가 발생해도 전체로 전파되지 않도록 보장해 주는 패턴입니다. 위 예시에서는 외부 서비스마다 다른 HTTP 커넥션 풀을 사용하도록 벌크헤드 패턴을 적용할 수 있습니다. 서로 다른 커넥션 풀을 사용하기 때문에 A 서비스에 문제가 발생해도 B,C의 영향을 최소화할 수 있습니다.

외부 서비스 장애가 계속 발생하면 어떻게 되나요?

지속되는 외부 서비스 장애로 타임아웃에 의한 서비스 에러가 발생할 수 있습니다. 외부 서비스가 장애가 발생했는데도 불구하고 운영 서버는 계속 요청을 보내게 되니, 불필요하게 응답 시간이 저해되고, 처리량도 감소하게 됩니다. 이 문제를 해결하기 위해서는 서킷 브레이커를 적용할 수 있는데요. 서킷 브레이커는 오류가 지속되는 경우 일정 시간 동안 기능 실행을 차단할 수 있습니다. 서킷 브레이커가 빠른 실패를 도와주기 때문에 외부 서비스 장애에 의한 응답 시간 증가를 예방할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[76] TCP 3-way handshake에 대해서 설명해주세요.

백엔드

TCP 3-way handshake에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

TCP 3-way handshake는 TCP/IP 네트워크에서 안정적이고 연결 지향적인 통신을 설정하기 위해 사용되는 절차입니다. 이 절차는 클라이언트와 서버 간에 신뢰할 수 있는 연결을 설정하기 위해 세 개의 메시지(세그먼트)를 교환하는 과정을 포함합니다.

우선 클라이언트는 서버에 연결을 요청하는 SYN 세그먼트를 보내는데요. 이 세그먼트에는 초기 순서 번호(Sequence Number)와 윈도우 크기(Window Size) 정보가 포함되어 있습니다.

이후 서버는 클라이언트의 요청을 수락하고, SYN과 ACK 플래그가 설정된 세그먼트를 클라이언트에 보냅니다. 이 세그먼트는 서버의 초기 순서 번호와 클라이언트의 초기 순서 번호에 대한 응답(ACK=클라이언트의 초기 순서 번호 + 1)을 포함합니다.

클라이언트는 서버의 응답을 확인하고, ACK 플래그가 설정된 세그먼트를 서버에 보냅니다. 이 세그먼트는 서버의 순서 번호에 대한 응답(ACK=서버의 초기 순서 번호 + 1)을 포함합니다. 이 절차가 완료되면 클라이언트와 서버 간에 신뢰할 수 있는 연결이 설정되고, 데이터 전송이 시작될 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[77] 동기와 비동기의 차이점은 무엇인가요?

백엔드

동기와 비동기의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

동기와 비동기는 호출하는 함수의 작업 완료를 기다리는지 여부의 차이가 있습니다. 함수 A가 동기로 함수 B를 호출하면 A는 B의 작업이 완료될 때까지 기다려야 합니다. 따라서 작업이 순차적으로 진행됩니다. 반면, 함수 A가 비동기로 함수 B를 호출하면 A는 B의 작업 완료를 신경 쓰지 않고 따로 동작합니다. 따라서 작업이 순차적으로 진행되지 않습니다.

블로킹과 동기는 어떤 차이가 있나요? 🤔

두 개념은 유사하면서도 다른데요. 동기 호출에서는 호출된 함수가 작업을 완료할 때까지 호출한 함수가 기다립니다. 즉, 작업이 순차적으로 진행되는 것을 의미합니다. 반면, 블로킹은 함수가 호출된 후, 호출한 함수의 결과를 기다리기 위해 실행을 멈추는 상태를 의미합니다. 즉, 제어권이 반환되지 않고 대기하는 상황입니다.

스프링에서 비동기 처리는 어떻게 하며 무엇을 주의해야 하나요?

스프링에서는 @Async 어노테이션을 사용하여 비동기 처리를 수행할 수 있습니다. 해당 어노테이션을 사용하기 위해서는 몇 가지 주의할 부분이 있는데요. 기본적으로 @Async 가 적용된 메서드에서 발생하는 예외는 호출자에게 전파되지 않습니다. 비동기 메서드에서 예외를 정상적으로 처리하기 위해서는 별도의 비동기 예외 처리기를 사용해야 합니다. 또한, @Async 어노테이션은 프록시 기반으로 동작하기 때문에 같은 클래스 내부에서 직접 호출하는 경우 별도의 스레드에서 메서드가 실행되지 않습니다. 그리고, 비동기 메서드 내에서 생성한 트랜잭션은 상위 트랜잭션과 무관한 생명주기를 가집니다.

추가 학습 자료를 공유합니다.

참고 링크

[80] 공유 락과 배타 락에 대해서 설명해주세요.

백엔드

공유 락과 배타 락에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

DBMS에서 트랜잭션을 특별한 제어 없이 병행 수행을 허용한다면 데이터의 일관성과 무결성을 보장하기 어려울 수 있습니다. 이때, 병행 수행되는 트랜잭션들을 제어하기 위해서 락을 사용할 수 있으며 DBMS에서 락은 크게 공유 락과 배타 락으로 분류할 수 있습니다.

공유 락(Shared Lock) 은 읽기 락(Read Lock)이라고 부르며, 공유 락이 걸린 데이터에 대해서 다른 트랜잭션에서도 공유 락을 획득할 수 있지만, 배타 락은 획득할 수 없습니다. 즉, 공유 락을 사용하면 트랜잭션 내에서 조회한 데이터가 변경되지 않는다는 것을 보장합니다.

SELECT * FROM table_name WHERE id = 1 FOR SHARE;

배타 락(Exclusive Lock) 은 쓰기 락(Write Lock)이라고 부르며, 배타 락이 걸린 데이터에 대해서 다른 트랜잭션에서는 공유 락과 배타 락을 획득할 수 없습니다. 즉, 배타 락을 획득한 트랜잭션은 데이터에 대한 독점권을 가집니다.

SELECT * FROM table_name WHERE id = 1 FOR UPDATE;

정리하자면, 공유 락이 걸린 데이터는 다른 트랜잭션에서 공유 락을 획득 할 수 있고, 배타 락이 걸린 데이터는 다른 트랜잭션에서 어떤 종류의 락도 획득할 수 없어서 대기하는 상황이 발생할 수 있습니다.

데드 락은 언제 발생하며 어떻게 해결할 수 있나요? 🤔

데드 락(Dead Lock) 이란 교착 상태로, 두개 이상의 트랜잭션이 서로 필요로 하는 데이터의 락을 점유하고 있어서 무한히 대기하는 상황을 말합니다. 트랜잭션은 락을 획득하지 못하는 경우, 다른 트랜잭션이 점유하고 있는 락이 해제될 때까지 대기합니다. 예를 들어, 다음과 같은 트랜잭션들이 존재한다고 가정하겠습니다.

  • 트랜잭션 A, B가 있고 id가 1, 2인 데이터가 있는 상황에 두 트랜잭션이 시작합니다.
  • 트랜잭션 A는 id 1번을 읽고, 2번 데이터를 변경하는 트랜잭션입니다.
  • 트랜잭션 B는 id 2번을 읽고 1번을 변경하는 트랜잭션입니다.

이때 다음과 같은 상황에서 데드 락이 발생할 수 있습니다.

  • A는 1번, B는 2번 데이터에 대해 공유 락을 획득합니다.
  • A는 2번 데이터의 공유 락을 가지고 있는 B 트랜잭션이 락을 해제할 때까지 대기합니다.
  • B는 1번 데이터의 공유 락을 가지고 있는 A 트랜잭션이 락을 해제할 때까지 대기합니다.

데드 락을 해결하기 위해서 트랜잭션에서 락 획득 순서를 일관되게 할 수 있습니다. 모든 트랜잭션에서 1번 데이터, 2번 데이터 순으로 락을 획득할 시 데드 락이 발생하지 않습니다. 혹은 락 타임 아웃을 설정하여 데드 락 상황을 해결할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[83] 단위 테스트와 통합 테스트의 차이점은 무엇인가요?

백엔드

단위 테스트와 통합 테스트의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

단위 테스트는 소프트웨어의 가장 작은 단위, 즉 개별 메서드나 함수의 기능을 검증하는 테스트입니다. 특정 기능이 올바르게 동작하는지 확인하기 위함이며 독립적이고 빠르게 실행됩니다. 반면 통합 테스트는 개별 모듈들이 결합되어 전체 시스템이 올바르게 동작하는지 검증하는 테스트입니다. 모듈 간의 상호작용이 올바르게 동작하는지 확인하기 위함이며 실제 데이터베이스, 네트워크 등의 외부 시스템과의 통합을 테스트합니다.

슬라이스 테스트는 무엇인가요? 🤔

슬라이스 테스트는 특정 레이어(ex. controller, service, repository)에 대한 테스트입니다.애플리케이션의 특정 슬라이스가 올바르게 동작하는지 확인하기 위해 작성됩니다. 스프링의 특정 컴포넌트만 로드하여 테스트하므로 상대적으로 빠르게 실행됩니다. 관련된 어노테이션으로는 @WebMvcTest, @DataJpaTest 등이 있습니다.

테스트 코드를 작성해야하는 이유가 무엇인가요? 🤓

테스트 코드를 작성하면 버그를 조기에 발견할 수 있으며 리팩터링을 수행할 경우 유용합니다. 또한, 개발 속도를 향상 시킬 수 있으며 코드에 대한 문서로서 역할을 수행할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[84] 스레드, 프로세스, 코어의 수는 많을수록 좋을까요?

백엔드

스레드, 프로세스, 코어의 수는 많을수록 좋을까요?

백엔드와 관련된 질문이에요.

스레드, 프로세스, 코어의 수가 많을수록 시스템 성능이 향상된다고 생각할 수 있지만, 실제로는 그렇지 않을 확률이 큽니다.

스레드가 많으면?

스레드가 지나치게 많아지면 운영체제가 스레드 간 컨텍스트 스위칭을 자주 수행해야 하여 CPU 자원이 스레드 관리에 소모됩니다. 이로 인해 실제 작업 수행 효율이 떨어질 수 있으며, 많은 스레드가 동시에 실행될 경우 메모리나 캐시, 락 등의 자원을 경쟁하게 되어 성능 저하나 데드 락이 발생할 가능성이 높아집니다. 또한, 스레드가 많아지면 동기화와 상태 관리가 복잡해져 버그 발생 가능성도 커집니다.

프로세스가 많으면?

각 프로세스는 독립된 메모리 공간을 가집니다. 그래서 많은 프로세스가 동시에 실행되면 메모리 사용량이 급격히 증가할 수 있습니다. 또한, 프로세스를 생성하고 관리하는 데는 상당한 시스템 자원이 소모되며, 프로세스 간 통신(IPC)이 필요할 경우 성능 저하가 발생할 수 있습니다. 프로세스 간 컨텍스트 스위칭은 스레드 간 컨텍스트 스위칭보다 더 많은 오버헤드를 수반하기 때문에, 프로세스 수가 많아지면 시스템 성능이 저하될 수 있습니다. 운영체제는 동시에 실행할 수 있는 프로세스 수에 제한이 있으며, 이를 초과하면 새로운 프로세스 생성이 불가능하거나 시스템이 불안정해질 수 있습니다.

코어가 많으면?

많은 코어를 가진 CPU는 병렬 처리 성능을 향상시킬 수 있지만, 이를 최대한 활용하기 위해서는 소프트웨어가 멀티코어 환경에 최적화되어 있어야 합니다. 단일 스레드 작업이 주를 이루는 경우, 추가 코어의 이점을 제대로 활용하지 못할 수 있습니다. 또한, 코어 수가 많아질수록 CPU의 비용과 전력 소비가 증가할 수 있으며, 발열 관리도 더 복잡해집니다.

추가 학습 자료를 공유합니다.

참고 링크

[88] 데이터베이스 커넥션 풀(Connection Pool)을 사용하지 않으면 어떤 문제가 발생할 수 있나요?

백엔드

데이터베이스 커넥션 풀(Connection Pool)을 사용하지 않으면 어떤 문제가 발생할 수 있나요?

백엔드와 관련된 질문이에요.

애플리케이션과 데이터베이스가 통신을 하기 위해서는 데이터베이스 커넥션이 필요합니다.

데이터베이스 커넥션의 생애주기 :

  1. 데이터베이스 드라이버를 사용하여 데이터베이스에 연결
  2. 데이터 읽기/쓰기를 위한 TCP 소켓 열기
  3. 소켓을 통한 데이터 읽기/쓰기
  4. 연결 종료
  5. 소켓 닫기

커넥션 풀이 없다면 애플리케이션에서 데이터베이스에 접근해야하는 요청을 처리할 때마다 커넥션을 새로 생성하여 연결하고 해제하는 과정을 반복해야 합니다. 이 과정은 비용이 상당히 많이 들기 때문에 요청의 응답시간이 길어집니다.

또 동시에 많은 요청이 들어올 경우 매번 새로운 커넥션을 생성하게 되는데, 데이터베이스의 최대 연결 수를 초과할 수 있습니다. 데이터베이스는 일반적으로 동시에 처리할 수 있는 요청 개수에 제한이 있는데, 이 제한을 초과하면 요청이 거부되어 사라지거나, 데이터베이스 자체가 비정상 종료될 수 있습니다.

데이터베이스 커넥션 풀을 사용함으로써 얻을 수 있는 장점은 무엇인가요?

커넥션 풀(Connection Pool)은 애플리케이션과 데이터베이스 간의 데이터베이스 연결(Connection)을 미리 생성해두고, 이를 재사용하는 기법을 말합니다. 데이터베이스에 접근할 때마다 새로운 연결을 생성하고 종료하는 대신, 미리 준비된 연결을 재사용함으로써 성능을 향상시키고 자원 사용을 최적화할 수 있습니다.

커넥션 풀의 주요 구성 요소는 초기 풀 크기(Initial Pool Size), 최소 풀 크기(Minimum Pool Size), 최대 풀 크기(Maximum Pool Size), 연결 대기 시간(Connection Timeout) 등이 있고, 이를 통해 커넥션을 효율적으로 관리하고 사용할 수 있습니다.

그럼 커넥션 풀 사이즈는 클 수록 좋나요? 🤔

커넥션을 사용하는 주체는 스레드(Thread)이기 때문에, 커넥션과 스레드를 연결지어 생각해야 합니다. 만약 커넥션 풀 사이즈가 스레드 풀 사이즈보다 크면, 스레드가 모두 사용하지 못해서 리소스가 낭비됩니다. 반대로 커넥션 풀 사이즈가 스레드 풀 사이즈보다 작으면, 스레드가 커넥션이 반환되기를 기다려야 하기 때문에 작업이 지연됩니다.

커넥션 풀 사이즈와 스레드 풀 사이즈의 균형이 맞더라도, 너무 큰 사이즈로 설정하면, 데이터베이스 서버, 애플리케이션 서버의 메모리와 CPU를 과도하게 사용하게 되므로 성능이 저하됩니다.

추가 학습 자료를 공유합니다.

참고 링크

[89] 사용자가 웹사이트에 처음 접근했을 때 발생하는 일련의 과정에 대해 설명해 주세요.

백엔드

사용자가 웹사이트에 처음 접근했을 때 발생하는 일련의 과정에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

예를들어 사용자가 www.google.com을 입력하면, 브라우저는 HTTP 프로토콜을 사용해 구글 웹 서버와 통신하려고 합니다. HTTP는 OSI 7계층애플리케이션 계층에서 동작하는 프로토콜입니다.

이때 브라우저는 요청한 도메인 이름(www.google.com)에 대한 IP 주소를 알아야 하기 때문에 DNS(Domain Name System) 서버에 질의합니다. 이 질의 과정 또한 애플리케이션 계층에서 이루어지며, DNS 서버는 해당 도메인에 대한 IP 주소(예를 들어, 142.250.190.78)를 응답합니다.

IP 주소를 얻은 후, 브라우저는 구글 서버와 통신을 시작합니다. HTTP는 TCP/IP를 기반으로 작동하므로, 데이터를 주고받기 전에 TCP 3-Way Handshake 과정이 필요합니다. 이 단계는 전송 계층(4계층) 에서 이루어집니다.

TCP 연결이 성립된 후, 브라우저는 HTTP Request 메시지를 생성하여 구글 서버에 보냅니다. 예를 들어, 브라우저는 “GET / HTTP/1.1”이라는 요청을 TCP 프로토콜을 통해 80번 포트로 전송합니다. 이때 데이터는 패킷(Packet) 형태로 네트워크를 통해 전달됩니다. 네트워크를 통해 데이터를 전송하기 위해서는 네트워크 계층(3계층) 에서 IP 주소를 사용하고, 데이터 링크 계층(2계층) 에서 MAC 주소를 사용하여 패킷이 전송됩니다.

구글 서버는 클라이언트의 요청을 수신하고 이를 처리한 후, HTTP Response 메시지를 생성하여 응답합니다. 서버는 요청이 성공했음을 알리는 200 OK 상태 코드와 함께 웹 페이지 데이터를 전송합니다. 브라우저는 이 응답을 받아 HTML, CSS, 자바스크립트 등의 데이터를 해석하여 화면에 페이지를 렌더링합니다.

모든 데이터 전송이 완료되면 클라이언트와 서버는 4-Way Handshake 과정을 거쳐 TCP 연결을 종료합니다.

추가 학습 자료를 공유합니다.

참고 링크

[90] HTTP 메서드에서 멱등성이란 무엇인가요?

백엔드

HTTP 메서드에서 멱등성이란 무엇인가요?

백엔드와 관련된 질문이에요.

연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질을 멱등성이라고 합니다. HTTP 메서드의 멱등성은 동일한 요청을 한번 보내는 것과 여러번 보내는 것이 서로 동일한 효과를 지니며, 서버의 상태도 동일하게 남을 경우에 멱등하다고 이야기할 수 있습니다. 대표적으로 멱등한 메서드는 GET, HEAD, PUT, DELETE, TRACE, OPTIONS 가 있습니다.

멱등성은 어떻게 활용될 수 있나요? 🤔

모종의 이유로 전송 커넥션이 끊어졌을 때, 멱등성은 클라이언트가 다시 같은 요청을 해도 되는가에 대한 판단 근거가 될 수 있습니다. 멱등하다면 요청을 재시도할 때 같은 서버의 상태를 보장하기 때문에 문제가 없습니다. 반면, 멱등하지 않다면 재시도 요청시 중복 요청을 보내 문제를 발생 시킬 수 있습니다. 예를 들어, 사용자가 결제하는 시점에 타임아웃으로 인해 정상 응답을 못받는 상황을 생각해 볼 수 있습니다. 해당 경우에서 멱등하지 않은 결제 API 경우에는 결제가 성공했는지 수동으로 확인하고 재요청해야합니다. 반면, 멱등한 결제 API의 경우에는 안심하고 여러 번 요청할 수 있으며 중복 요청으로 발생하는 문제(중복 결제)를 방지할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[92] 데이터베이스 시스템에서 동시성을 제어하는 방법에 대해 설명해주세요.

백엔드

데이터베이스 시스템에서 동시성을 제어하는 방법에 대해 설명해주세요.

백엔드와 관련된 질문이에요.

대표적인 동시성 제어 방식으로 MVCC(Multi-Version Concurrency Control)Lock-Based Concurrency Control이 있습니다.

MVCC(Multi-Version Concurrency Control)

MVCC는 데이터의 여러 버전을 유지하여 트랜잭션이 동시에 데이터를 읽고 쓸 수 있도록 하는 방식입니다. 각 트랜잭션은 자신만의 일관된 스냅샷을 기반으로 데이터를 읽어, 다른 트랜잭션의 변경 사항에 영향을 받지 않습니다.

데이터의 각 버전을 유지하여 읽기 작업이 쓰기 작업과 독립적으로 이루어질 수 있습니다. 트랜잭션은 시작 시점의 스냅샷을 기반으로 데이터를 읽어, 다른 트랜잭션의 변경 사항을 보지 못합니다.

또한 읽기 작업 시 잠금을 사용하지 않아 높은 동시성을 제공합니다. 읽기 작업이 잠금에 의해 지연되지 않아, 읽기 중심의 애플리케이션에서 우수한 성능을 보입니다. 읽기 작업 시 잠금을 사용하지 않으므로, 쓰기 작업과의 충돌이 줄어듭니다. 하지만 여러 버전의 데이터를 유지해야 하므로 저장 공간이 더 많이 필요할 수 있습니다.

트랜잭션이 시작된 시점의 데이터 상태를 기반으로 읽기 작업을 수행하여 일관성을 유지합니다. 또 갭락과 넥스트키 락을 통해 팬텀 리드를 방지합니다.

Lock-Based Concurrency Control

Lock-Based 방식은 데이터에 접근할 때 잠금(Lock) 을 사용하여 동시성을 제어합니다. 트랜잭션이 데이터를 읽거나 수정할 때 해당 데이터에 잠금을 걸어 다른 트랜잭션의 접근을 제한합니다. 즉, 잠금을 통해 데이터의 일관성과 무결성을 직접적으로 제어합니다.

데이터에 접근할 때 잠금을 걸어 다른 트랜잭션의 접근을 제한합니다. 읽기 작업은 공유 잠금을, 쓰기 작업은 배타 잠금을 사용하여 동시성을 제어합니다. 많은 다수의 트랜잭션이 동일한 데이터에 접근할 경우 성능 저하가 발생할 수 있습니다. 또 잘못된 잠금 순서나 설계로 인해 교착 상태(Deadlock)가 발생할 위험이 있습니다.

MVCC와 Lock-Based Concurrency Control 둘 중 어떤 걸 사용해야 하나요? 🤔

실제 데이터베이스 시스템, 특히 MySQL의 InnoDB는 MVCC와 Lock-Based 방식의 장점을 결합하여 동시성 제어를 최적화합니다.

읽기 트랜잭션은 MVCC를 사용하여 일관된 스냅샷을 기반으로 데이터를 읽으므로, 잠금을 최소화하고 높은 동시성을 유지할 수 있습니다.

쓰기 트랜잭션은 잠금을 사용하여 데이터의 일관성과 무결성을 유지하면서, 동시에 데이터 충돌을 방지합니다.

추가 학습 자료를 공유합니다.

참고 링크

[93] MySQL InnoDB에서 갭락과 넥스트키 락이란 무엇이며, 어떻게 팬텀 리드를 방지하나요?

백엔드

MySQL InnoDB에서 갭락과 넥스트키 락이란 무엇이며, 어떻게 팬텀 리드를 방지하나요?

백엔드와 관련된 질문이에요.

Phantom Read란 무엇인가요?

Phantom Read는 트랜잭션이 동일한 조건의 쿼리를 반복 실행할 때, 나중에 실행된 쿼리에서 처음에는 존재하지 않았던 새로운 행이 나타나는 현상을 말합니다. 이는 주로 읽기 일관성(Read Consistency) 을 유지하는 과정에서 발생할 수 있는 문제로, 데이터의 삽입이나 삭제가 다른 트랜잭션에 의해 이루어질 때 발생합니다.

-- 트랜잭션 A 시작
START TRANSACTION;

-- 트랜잭션 A 첫 번째 조회
SELECT * FROM orders WHERE amount > 150;

-- 트랜잭션 B 시작
START TRANSACTION;

-- 트랜잭션 B 새로운 행 삽입
INSERT INTO orders (customer_id, amount) VALUES (4, 250);

-- 트랜잭션 B 커밋
COMMIT;

-- 동일한 조건으로 트랜잭션 A 두 번째 조회시, 트랜잭션 A의 첫 번째 조회에는 존재하지 않던, 트랜잭션 B에서 삽입된 새로운 행이 함께 조회된다.
-- 단, MVCC를 지원하는 경우 해당 케이스에서 팬텀 리드가 발생하지 않는다.
SELECT * FROM orders WHERE amount > 150;

갭락(Gap Lock)이란?

갭 락은 특정 인덱스 값 사이의 공간을 잠그는 락입니다. 기존 레코드 간의 간격을 보호하여 새로운 레코드의 삽입을 방지합니다. 갭 락은 범위 내에 특정 레코드가 존재하지 않을 때 적용됩니다. 트랜잭션이 특정 범위 내에서 데이터의 삽입을 막아 팬텀 읽기(Phantom Read) 현상을 방지합니다. 예를 들어, 인덱스 값 10과 20 사이의 갭을 잠그면 이 범위 내에 새로운 레코드 15를 추가할 수 없습니다.

-- id 1, 3, 5가 저장된 orders 테이블

-- 트랜잭션 A 시작
START TRANSACTION;

-- 트랜잭션 A 1-3과 3-5 사이의 갭과 3 레코드 락 설정(넥스트키 락)
SELECT * FROM orders WHERE orders_id BETWEEN 2 AND 4 FOR UPDATE;

-- 트랜잭션 B 시작
START TRANSACTION;

-- 트랜잭션 B가 id 4에 데이터 삽입 시도 시, 갭락으로 인해 삽입이 차단되어 대기
INSERT INTO orders (orders_id, orders_amount) VALUES (4, 200);
...

넥스트키 락(Next-Key Lock)이란?

넥스트키 락레코드 락갭락을 결합한 형태로, 특정 인덱스 레코드와 그 주변의 갭을 동시에 잠그는 락입니다. 이를 통해 레코드 자체의 변경과 함께 그 주변 공간의 변경도 동시에 제어할 수 있습니다.

넥스트키 락은 특정 레코드와 그 주변 공간을 잠그기 때문에, 다른 트랜잭션이 새로운 레코드를 삽입하여 팬텀 리드를 발생시키는 것을 방지합니다.

orders_idorders_amount
1100
2200
3300
-- 트랜잭션 A 시작
START TRANSACTION;

-- 트랜잭션 A amount = 200인 orders_id = 2 레코드에 대한 레코드 락과 1-2, 2-3에 대한 갭락을 동시에 잠금으로써 넥스트키 락을 설정
SELECT * FROM orders WHERE orders_amount = 200 FOR UPDATE;

-- 트랜잭션 B 시작
START TRANSACTION;

-- 트랜잭션 B orders_id = 4, orders_amount = 200인 레코드 삽입 시도 시, 넥스트키 락으로 인해 차단되어 대기
INSERT INTO orders (orders_id, order_amount) VALUES (4, 200);
...

갭락과 넥스트키 락을 통한 팬텀 리드 방지 메커니즘

트랜잭션 A가 특정 범위의 데이터를 조회할 때, 해당 범위에 대해 갭락 또는 넥스트키 락을 설정합니다. 락이 설정된 범위 내에서는 트랜잭션 B가 새로운 레코드를 삽입하거나 기존 레코드를 수정하는 것이 차단됩니다. 따라서, 트랜잭션 A가 다시 동일한 조건으로 조회를 수행하더라도, 트랜잭션 B에 의해 새로운 데이터가 삽입되지 않아 팬텀 리드가 발생하지 않습니다.

추가 학습 자료를 제공합니다.

참고 링크

[96] CORS란 무엇인가요?

백엔드

CORS란 무엇인가요?

백엔드와 관련된 질문이에요.

CORS(Cross Origin Resource Sharing)는 출처가 다른 곳의 리소스를 요청할 때 접근 권한을 부여하는 메커니즘입니다. 리소스를 주고받는 두 곳의 출처가 다르면 출처가 교차한다고 합니다. 이때 출처는 URL뿐만 아니라 프로토콜과 포트까지 포함됩니다. 만약 클라이언트의 출처가 허용되지 않았다면 CORS 에러가 발생할 수 있습니다.

CORS는 왜 필요한가요?

과거에는 크로스 사이트 요청 위조(CSRF, Cross-Site Request Forgery) 문제가 있었습니다. 피해자의 브라우저에서 다른 애플리케이션으로 가짜 클라이언트 요청을 전송하는 공격입니다.

CSRF를 예방하기 위해 브라우저는 동일 출처 정책(SOP, same-origin policy)을 구현했습니다. SOP가 구현된 브라우저는 클라이언트와 동일한 출처의 리소스로만 요청을 보낼 수 있습니다.

하지만, SOP는 한계가 있습니다. 현대의 웹 애플리케이션은 다른 출처의 리소스를 사용하는 경우가 많기 때문입니다. 따라서, SOP를 확장한 CORS가 필요합니다.

CORS는 어떻게 작동할까요? 🤔

브라우저가 요청 메시지에 Origin 헤더와 응답 메시지의 Access-Control-Allow-Origin 헤더를 비교해서 CORS를 위반하는지 확인합니다. 이때, Origin에는 현재 요청하는 클라이언트의 출처(프로토콜, 도메인, 포트)가, Access-Control-Allow-Origin은 리소스 요청을 허용하는 출처가 작성됩니다.

이렇게 단순하게 요청하는 것을 Simple Request라고 합니다. Simple Request은 요청 메서드(GET, POST, HEAD), 수동으로 설정한 요청 헤더(Accept, Accept-Language, Content-Language, Content-Type, Range), Content-Type 헤더(application/x-www-form-urlencoded, multipart/form-data, text/plain)인 경우에만 해당합니다.

브라우저가 사전 요청을 보내는 경우도 있습니다. 이때 사전 요청을 Preflight Request라고 합니다. 브라우저가 본 요청을 보내기 이전, Preflight Request를 OPTIONS 메서드로 요청을 보내어 실제 요청이 안전한지 확인합니다.

Preflight Request는 추가로 Access-Control-Request-Method로 실 요청 메서드와, Access-Control-Request-Headers 헤더에 실 요청의 추가 헤더 목록을 담아서 보내야 합니다.

이에 대한 응답은 대응되는 Access-Control-Allow-Methods와 Access-Control-Headers를 보내야 하고, Preflight Request로 인한 추가 요청을 줄이기 위해 캐시 기간을 Access-Control-Max-Age에 담아서 보내야 합니다.

또한 인증된 요청을 사용하는 방식도 있는데요. 이를 Credential Request라고 합니다. 쿠키나 토큰과 같은 인증 정보를 포함한 요청은 더욱 안전하게 처리되어야 합니다. 이때 Credential Request를 수행합니다.

Credential Request를 요청하는 경우에는 서버에서는 Access-Control-Allow-Credentials를 true로 설정해야 하며 Access-Control-Allow-Origin에 와일드카드를 사용하지 못합니다.

추가 학습 자료를 공유합니다.

참고 링크

[97] 리버스 프록시와 포워드 프록시의 차이점에 대해 설명해주세요.

백엔드

리버스 프록시와 포워드 프록시의 차이점에 대해 설명해주세요.

백엔드와 관련된 질문이에요.

포워드 프록시(Forward Proxy)

포워드 프록시는 주로 클라이언트 측에 위치하여, 사용자가 인터넷에 접근할 때 중개자 역할을 합니다.

예를 들어, 회사 내부 네트워크에서 근무하는 직원이 외부 웹사이트에 접속하려고 할 때, 포워드 프록시 서버를 통해 요청이 전달됩니다. 이 과정에서 사용자의 실제 IP 주소는 숨겨지고, 프록시 서버의 IP 주소가 대신 사용됩니다.

포워드 프록시의 핵심 기능 중 하나는 익명성 제공입니다. 사용자의 실제 IP를 숨김으로써 개인정보 보호와 보안 측면에서 큰 장점을 제공합니다.

또한 캐싱을 통해 네트워크 성능을 향상시킵니다. 자주 요청되는 웹 페이지나 파일을 프록시 서버에 저장해두면, 동일한 요청이 다시 들어올 때 빠르게 응답할 수 있어 네트워크 대역폭을 절약할 수 있습니다.

이와 함께 보안 강화 기능도 포워드 프록시의 중요한 역할 중 하나입니다. 악성 웹사이트나 불법적인 콘텐츠에 대한 접근을 차단하여 네트워크 보안을 강화하고, 바이러스나 악성 코드의 유입을 예방할 수 있습니다.

리버스 프록시(Reverse Proxy)

리버스 프록시는 서버 측에 위치하여 외부에서 들어오는 클라이언트의 요청을 내부 서버로 전달하는 역할을 합니다.

리버스 프록시의 핵심 기능 중 하나는 로드 밸런싱입니다. 다수의 백엔드 서버로 트래픽을 분산시켜 서버 과부하를 방지하고, 서비스의 고가용성을 유지할 수 있습니다.

또한 외부에서 직접 백엔드 서버에 접근하지 못하게 하여 DDoS 공격이나 해킹 시도로부터 서버를 보호할 수 있습니다.

SSL 종료는 리버스 프록시의 또 다른 중요한 기능입니다. SSL/TLS 암호화를 리버스 프록시에서 처리함으로써 백엔드 서버의 부담을 줄이고, 중앙에서 인증서를 관리할 수 있습니다.

또한, 리버스 프록시는 캐싱 및 콘텐츠 최적화 기능을 통해 정적 콘텐츠를 캐싱하여 응답 속도를 향상시키고 서버 부하를 줄일 수 있습니다.

추가 학습 자료를 제공합니다.

참고 링크

[100] private 메서드에 @Transactional 선언하면 트랜잭션이 동작할까요?

백엔드

private 메서드에 @Transactional 선언하면 트랜잭션이 동작할까요?

백엔드와 관련된 질문이에요.

기본적으로 @Transactional, @Cacheable, @Async 등의 애너테이션은 런타임에 동작하는 Spring AOP를 기반으로 동작합니다. Spring AOP가 제공하는 JDK Dynamic Proxy, CGLIB 방식 모두 타깃이 구현하는 인터페이스나 구체 클래스를 대상으로 프록시를 만들어서 타깃 클래스의 메서드 수행 전후에 횡단 관심사에 대한 처리를 할 수 있습니다.

Spring은 빈 생성시, 해당 빈에 AOP 애너테이션이 있는지 검사하고, 있다면 프록시 객체를 생성하여 빈을 대체합니다. AOP 적용 대상인 클래스의 경우, 즉, @Transactional과 같은 AOP 애너테이션이 하나라도 선언된 클래스는 프록시로 감싸집니다.

JDK Dynamic Proxy의 경우 타깃 클래스가 구현하는 인터페이스를 기준으로 프록시를 생성하여public 메서드만 AOP 적용 가능합니다. CGLIB 방식의 경우 인터페이스를 구현하지 않는 클래스를 상속하여 프록시를 생성하고, private을 제외한 public, protected, package-private 메서드에 AOP 적용 가능합니다.

@Slf4j  
@RequiredArgsConstructor  
@Service  
public class SelfInvocation {  
  
    private final MemberRepository memberRepository;  
  
    public void outerSaveWithPublic(Member member) {  
        saveWithPublic(member);  
    }  
  
    @Transactional  
    public void saveWithPublic(Member member) {  
        log.info("call saveWithPublic");  
        memberRepository.save(member);  
        throw new RuntimeException("rollback test");  
    }  
  
    public void outerSaveWithPrivate(Member member) {  
        saveWithPrivate(member);  
    }  
  
    @Transactional  
    private void saveWithPrivate(Member member) {  
        log.info("call saveWithPrivate");  
        memberRepository.save(member);  
        throw new RuntimeException("rollback test");  
    }  
}

public interface MemberRepository extends JpaRepository<Member, Long> {  
}
@SpringBootTest  
class SelfInvocationTest {  
  
    private static final Logger log = LoggerFactory.getLogger(SelfInvocationTest.class);  
  
    @Autowired  
    private SelfInvocation selfInvocation;  
  
    @Autowired  
    private MemberRepository memberRepository;  
  
    @AfterEach  
    void tearDown() {  
        memberRepository.deleteAllInBatch();  
    }  
  
    @Test  
    void aopProxyTest() {  
        // @Transactional 애너테이션을 가지고 있으므로, 빈이 Proxy 객체로 대체되어 주입된다.  
        assertThat(AopUtils.isAopProxy(selfInvocation)).isTrue();  
        // interface를 구현하지 않은 클래스이므로 CGLIB Proxy가 생성된다.  
        assertThat(AopUtils.isCglibProxy(selfInvocation)).isTrue();  
    }  
  
    @Test  
    void outerSaveWithPublic() {  
        Member member = new Member("test");  
  
        try {  
            selfInvocation.outerSaveWithPublic(member);  
        } catch (RuntimeException e) {  
            log.info("catch exception");  
        }  
  
        List<Member> members = memberRepository.findAll();  
        // self invocation 문제로 인해 트랜잭션이 정상 동작하지 않음.  
        // 예외 발생으로 인한 롤백이 동작하지 않고 남아있음.
    
      assertThat(members).hasSize(1);  
    }  
  
    @Test  
    void outerSaveWithPrivate() {  
        try {  
            selfInvocation.outerSaveWithPrivate(new Member("test"));  
        } catch (RuntimeException e) {  
            log.info("catch exception");  
        }  
  
        List<Member> members = memberRepository.findAll();  
  
        // self invocation 문제로 인해 트랜잭션이 정상 동작하지 않음.  
        // 예외 발생으로 인한 롤백이 동작하지 않고 남아있음.        assertThat(members).hasSize(1);  
    }  
  
    @Test  
    void saveWithPublic() {  
        Member member = new Member("test");  
  
        try {  
            selfInvocation.saveWithPublic(member);  
        } catch (RuntimeException e) {  
            log.info("catch exception");  
        }  
  
        List<Member> members = memberRepository.findAll();  
  
        // 외부에서 프록시 객체를 통해 메서드가 호출되었기 때문에 트랜잭션 정상 동작, 롤백 성공.  
        assertThat(members).hasSize(0);  
    }  
}

Spring AOP는 외부에서 프록시 객체를 통해 메서드가 호출될 때만 AOP 어드바이스(트랜잭션 관리)를 적용합니다. 같은 클래스 내에서 메서드를 호출하면, 프록시를 거치지 않고 직접 호출되므로 트랜잭션 어드바이스가 적용되지 않습니다.

이를 해결하기 위해서는 자기 자신을 프록시로 주입 받아 프록시를 통해 메서드를 호출하거나, 별도의 클래스로 분리하거나, AspectJ를 이용하는 방법이 있습니다. AspectJ를 사용하면 동일 클래스 내에서의 메서드 호출에도 AOP 어드바이스를 적용할 수 있습니다.

자기 자신을 프록시로 주입 받는 방법

@Slf4j  
@RequiredArgsConstructor  
@Service  
public class SelfInvocation {  
  
    private final MemberRepository memberRepository;  
    private final SelfInvocation selfInvocation;  
  
    public void outerSaveWithPublic(Member member) {  
        selfInvocation.saveWithPublic(member);  
    }  
  
    @Transactional  
    public void saveWithPublic(Member member) {  
        log.info("call saveWithPublic");  
        memberRepository.save(member);  
        throw new RuntimeException("rollback test");  
    }
    ...
}

이 방법은 순환 의존성 문제를 일으킬 수 있어 권장되지 않습니다.

별도의 클래스로 분리하는 방법

@Slf4j  
@RequiredArgsConstructor  
@Service  
public class TransactionService {  
  
    @Transactional  
    public void outer() {  
        log.info("call outer");  
        logCurrentTransactionName();  
        logActualTransactionActive();  
        inner();  
    }  
  
    @Transactional(propagation = Propagation.REQUIRES_NEW)  
    public void inner() {  
        log.info("call inner");  
        logCurrentTransactionName();  
        logActualTransactionActive();  
    }  
  
    private void logActualTransactionActive() {  
        boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();  
        log.info("actualTransactionActive = {}", actualTransactionActive);  
    }  
  
    private void logCurrentTransactionName() {  
        String currentTransactionName = TransactionSynchronizationManager.getCurrentTransactionName();  
        log.info("currentTransactionName = {}", currentTransactionName);  
    }  
}

// 로그
// call outer  
// currentTransactionName = server.transaction.TransactionService.outer  
// actualTransactionActive = true  
// call inner  
// currentTransactionName = server.transaction.TransactionService.outer  
// actualTransactionActive = true

outer가 inner 메서드를 호출하는데, outer의 propagation 속성은 REQUIRED, inner는 REQUIRES_NEW로 서로 다른 트랜잭션으로 분리되어야 합니다. 하지만, 로그를 보면 동일한 outer의 트랜잭션에 속해있습니다. 이처럼 트랜잭션 전파 속성이 다른 두 메서드가 동일한 클래스 내부에서 self invocation 호출하면 의도대로 동작하지 않습니다. 이 때 outer와 inner 메서드를 각각 다른 클래스로 분리하여 호출하면 해결할 수 있습니다.

// OuterTransactionService
@Slf4j  
@RequiredArgsConstructor  
@Service  
public class OuterTransactionService {  
  
    private final InnerTransactionService innerTransactionService;  
  
    @Transactional  
    public void outer() {  
        log.info("call outer");  
        logCurrentTransactionName();  
        logActualTransactionActive();  
        innerTransactionService.inner();  
    }  
  
    private void logActualTransactionActive() {  
        boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();  
        log.info("actualTransactionActive = {}", actualTransactionActive);  
    }  
  
    private void logCurrentTransactionName() {  
        String currentTransactionName = TransactionSynchronizationManager.getCurrentTransactionName();  
        log.info("currentTransactionName = {}", currentTransactionName);  
    }  
}

// InnerTransactionService
@Slf4j  
@RequiredArgsConstructor  
@Service  
public class InnerTransactionService {  
  
    @Transactional(propagation = Propagation.REQUIRES_NEW)  
    public void inner() {  
        log.info("call inner");  
        logCurrentTransactionName();  
        logActualTransactionActive();  
    }  
  
    private void logActualTransactionActive() {  
        boolean actualTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();  
        log.info("actualTransactionActive = {}", actualTransactionActive);  
    }  
  
    private void logCurrentTransactionName() {  
        String currentTransactionName = TransactionSynchronizationManager.getCurrentTransactionName();  
        log.info("currentTransactionName = {}", currentTransactionName);  
    }  
}

// 로그
// call outer  
// currentTransactionName = server.transaction.OuterTransactionService.outer  
// actualTransactionActive = true  
// call inner  
// currentTransactionName = server.transaction.InnerTransactionService.inner  
// actualTransactionActive = true

이처럼 각각 프록시를 생성할 수 있게 두 클래스로 분리하면 AOP 어드바이스가 적용되어 의도한 대로 독립적인 트랜잭션을 시작할 수 있게 됐습니다.

추가 학습 자료를 제공합니다.

참고 링크

[102] Connection Timeout, Socket Timeout, Read Timeout의 차이점은 무엇인가요?

백엔드

Connection Timeout, Socket Timeout, Read Timeout의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

Connection Timeout은 클라이언트가 서버에 연결을 시도할 때, 일정 시간 내에 연결이 이루어지지 않으면 발생하는 타임아웃입니다. TCP 소켓 통신에서 클라이언트와 서버가 연결될 때, 정확한 전송을 보장하기 위해 사전에 세션을 수립하는데, 이 과정을 3-way-handshake라고 합니다. Connection Timeout은 이 3-way-handshake가 일정 시간 내에 완료되지 않을 때 발생합니다. 즉, 서버의 장애나 응답 지연으로 인해 연결을 맺지 못하면 Connection Timeout이 발생합니다.

Socket Timeout은 Connection Timeout 이후에 발생할 수 있는 타임아웃입니다. 클라이언트와 서버가 연결된 후, 서버는 데이터를 클라이언트에게 전송합니다. 이때 하나의 데이터 덩어리가 아니라 여러 개의 패킷 단위로 쪼개서 전송되는데, 각 패킷이 전송될 때의 시간 차이 제한을 Socket Timeout이라고 합니다. 만약 서버가 일정 시간 내에 다음 패킷을 보내지 않으면, 클라이언트는 Socket Timeout을 발생시키고 연결을 종료할 수 있습니다.

Read Timeout은 클라이언트와 서버가 연결된 후, 특정 I/O 작업이 일정 시간 내에 완료되지 않으면 발생하는 타임아웃입니다. 클라이언트와 서버가 연결된 상태에서, 서버의 응답이 지연되거나 I/O 작업이 길어져 요청이 처리되지 않을 때 클라이언트는 연결을 끊습니다. Read Timeout은 이러한 상황을 방지하기 위해 설정된 타임아웃으로, 일정 시간 내에 데이터가 읽혀지지 않으면 클라이언트가 연결을 종료합니다.

네트워크 통신에 타임아웃이 필요한 이유는 무엇인가요?

타임아웃이 필요한 이유는 자원을 절약하기 위함입니다. 가령, 외부 서비스로 요청을 보냈지만 해당 요청이 무한정 길어질 수 있습니다. 이때 서비스의 요청이 자원을 가지고 있으면, 서비스의 자원이 고갈되어 장애가 발생할 수 있습니다. 타임아웃을 설정하면 이렇게 요청이 무한정 길어지는 상황을 예방할 수 있습니다.

타임아웃 테스트는 어떻게 해볼 수 있을까요? 🤔

그리고, 정말로 필요할까요?

가상 서버를 띄우고 임의로 지연을 추가하여 타임아웃을 테스트할 수 있습니다. 하지만, 테스트 환경을 구축하기 위한 시간이 들며 자동화된 테스트에 지연 시간이 추가되는 것이 단점입니다. 타임아웃 설정을 테스트하여 얻을 수 있는 것과 테스트를 하기 위해서 잃어야 하는 것을 신중히 고려하여 필요성을 따지는 것이 중요하다고 생각합니다.

추가 학습 자료를 공유합니다.

참고 링크

[103] 서버 사이드 렌더링과 클라이언트 사이드 렌더링의 차이점은 무엇인가요?

백엔드

서버 사이드 렌더링과 클라이언트 사이드 렌더링의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

서버 사이드 렌더링(SSR) 은 서버 측에서 렌더링하는 방식입니다. 클라이언트가 서버에 컨텐츠를 요청하면, 서버는 페이지에 필요한 데이터를 즉시 얻어와 모두 삽입하고, CSS까지 모두 적용해 렌더링 준비를 마친 HTML과 JS 코드를 응답합니다. 브라우저에서는 JS 코드를 다운로드하고, HTML에 JS를 연결합니다.

이처럼 모든 데이터가 이미 HTML에 담긴 채로 브라우저에 전달되기 때문에 SEO에 유리합니다. 또한 JS 코드를 다운로드 받고 실행하기 전에 사용자가 이미 렌더링된 HTML을 볼 수 있으므로, JS 다운로드를 기다려야 하는 CSR에 비해 초기 구동 속도가 빠릅니다.

클라이언트 사이드 렌더링(CSR) 은 클라이언트 측에서 렌더링하는 방식입니다. 클라이언트가 서버에 컨텐츠를 요청하면, 서버는 빈 뼈대만 있는 HTML을 응답합니다. 클라이언트는 연결된 JS 링크를 통해 서버로부터 다시 JS 파일을 다운로드 받은 뒤, JS를 통해 동적으로 페이지를 만들어 브라우저에 보여줍니다.

빈 뼈대만 있는 HTML을 받아오기 때문에 웹 크롤러 봇 입장에서 색인할만한 콘텐츠가 존재하지 않아 SEO에 불리하다는 단점이 있습니다. 또 브라우저가 JS 파일을 다운로드하고, 동적으로 DOM을 생성하는 시간을 기다려야 하기 때문에 초기 로딩 속도가 느리다는 단점이 존재합니다. 하지만 초기 로딩 이후 페이지 일부를 변경할 때에는 서버에 해당 데이터만 요청하면 되기 때문에 이후 구동 속도가 빠릅니다. 서버는 HTML 뼈대를 넘겨주는 역할만 수행하면 되므로 서버 측의 부하가 적고, 클라이언트 측에서 연산과 라우팅 등을 직접 처리하기 때문에 반응속도가 빠르고 UX도 우수하다는 장점이 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[104] 자료구조 스택에 대해서 설명해주세요.

백엔드

자료구조 스택에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

스택(Stack) 은 후입선출이라는 개념을 가진 선형 자료구조입니다. 스택 자료구조에서 삭제(pop)는 가장 최상단(top)에서만 이루어집니다. 비어있는 스택에서 값을 추출하려고 시도하는 경우를 스택 언더플로우라고 하며, 스택이 넘치는 경우를 스택 오버플로우라고 합니다. 대표적인 활용 사례는 스택 메모리, 브라우저 뒤로가기 기능, 언두 기능, 수식 괄호 검사 등이 있습니다.

자바에서 스택은 어떻게 사용할 수 있나요?

Stack이라는 클래스를 사용할 수 있습니다. 하지만, Deque 인터페이스 구현체를 사용하는 것이 권장됩니다. 왜냐하면, Stack 클래스는 내부적으로 Vector를 상속 받고 있기 때문입니다. Vector를 상속받은 Stack은 인덱스를 통한 접근, 삽입, 제거 등이 실질적으로 가능합니다. 이는 후입선출 특징에 맞지 않기 때문에 개발자가 실수할 여지가 있습니다.

또한, Vector의 메소드들은 synchronized로 구현되어 있어 멀티 스레드 환경에서는 동기화의 이점이 있으나, 단일 스레드 환경에서는 불필요한 동기화 작업으로 인해 성능 측면에서 좋지 않습니다. 반면에, Deque 인터페이스는 후입선출의 특성을 완전히 유지하면서도 동기화 작업을 가지는 구현체와 그렇지 않은 구현체를 선택할 수 있습니다. 이는 개발자가 필요에 따라 동기화 작업의 오버헤드를 회피하고 성능을 최적화할 수 있도록 합니다.

추가 학습 자료를 공유합니다.

참고 링크

[105] WAS와 웹 서버의 차이점은 무엇인가요?

백엔드

WAS와 웹 서버의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

웹 서버는 정적 컨텐츠(HTML, CSS, JS, 이미지 등)를 제공하는 역할을 수행합니다. 동적 컨텐츠 요청 시 요청을 WAS로 전달할 수도 있습니다. 대표적인 웹 서버로는 Apache, Nginx 등이 있습니다. 반면, 자바 진영에서 WAS(Web Application Server) 는 서블릿 컨테이너 기능을 제공하고, 동적 컨텐츠를 생성하거나, 애플리케이션 로직을 실행하는 데 특화되어 있습니다. 대표적인 WAS로는 Tomcat이 있습니다. 정리하자면, 웹 서버는 정적 컨텐츠 제공에 특화되어 있으며, WAS는 동적인 컨텐츠 생성과 데이터 처리에 특화되어 있습니다.

WAS도 정적 컨텐츠를 제공할 수 있는데 웹 서버가 따로 필요한 이유는 무엇인가요? 🤔

WAS가 너무 많은 역할을 담당하면 과부하될 수 있습니다. 웹 서버를 따로 분리하면 WAS는 중요한 애플리케이션 로직에 집중할 수 있으며, 웹 서버는 정적 리소스를 처리하면서 업무 분담이 가능합니다. 또한, 시스템 리소스를 효율적으로 관리할 수 있습니다. 정적 컨텐츠가 많이 사용되는 경우에는 웹 서버를 증설하고, 애플리케이션 자원이 많이 사용되면 WAS를 증설하면 됩니다. 이외에도 로드 밸런싱을 하거나, 캐싱 및 압축, HTTPS 등을 웹 서버에서 처리하도록 할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[106] HTTPS에 대해서 설명해주세요.

백엔드

HTTPS에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

HTTP(Hypertext Transfer Protocol) 는 웹에서 클라이언트와 서버 간 통신을 위한 통신 규약입니다. 하지만, HTTP는 암호화되지 않는 평문 데이터를 전송하기 때문에 제 3자가 정보를 조회할 수 있다는 위험이 있습니다. 이를 해결하기 위해서 HTTPS가 등장했습니다.

HTTPS(Hyertext Transfer Protocol Secure) 는 HTTP에 데이터 암호화가 추가되었습니다. 암호화된 데이터를 전송하기 때문에 제 3자가 볼 수 없도록 할 수 있습니다.

HTTPS는 어떻게 적용할 수 있나요?

HTTPS를 적용하기 위해서는 인증된 기관(Certificate Authority, CA)에게 인증서를 발급받아야 합니다. CA에 인증서를 요청하면 CA 이름, 서버의 공개키, 서버의 정보를 활용하여 인증서를 생성하고 이를 CA 개인 키로 암호화하여 서버로 전송합니다. 이때 인증서는 CA 개인 키로 암호화되니 신뢰성을 확보할 수 있습니다. 이러한 인증서를 서버측에서 발급받으면 HTTPS를 적용할 수 있습니다.

HTTPS 동작 원리에 관해서 설명해 주세요. 🤔

클라이언트가 서버로 최초로 요청할 때 암호화 알고리즘, 프로토콜 버전, 무작위 값을 전달합니다. 이를 받은 서버는 클라이언트에게 암호화 알고리즘, 인증서, 무작위 값을 전달하며, 클라이언트는 서버의 인증서를 CA의 공개키로 복호화하여 검증합니다. 검증이 끝난 이후에는 클라이언트와 서버에서 생성된 무작위 값을 조합하여 Pre Master Secret 값을 생성하여 서버 공개키로 암호화하여 전달합니다.

서버는 전달받은 암호화된 데이터를 개인 키로 복호화하여 Pre Master Secret를 얻습니다. 클라이언트와 서버는 일련의 과정을 통해 Pre Master Secret를 Master Secret으로 변경하고, 해당 정보를 이용해 세션 키를 생성합니다. 이러한 과정을 TLS 핸드 쉐이크라고 하며, 이후부터 클라이언트와 서버는 세션 키를 활용한 대칭키 암호화 방식으로 데이터 송수신을 수행합니다.

추가 학습 자료를 공유합니다.

참고 링크

[107] Record를 DTO로 사용하는 이유가 뭔가요?

백엔드

Record를 DTO로 사용하는 이유가 뭔가요?

백엔드와 관련된 질문이에요.

Record는 Java 16에서 정식 출시된 특별한 유형의 클래스로 불변성(Immutable) 을 기본으로 합니다.

기존의 클래스와 달리 모든 필드가 final 키워드로 선언되며, 객체 생성 후 변경할 수 없습니다. 또한 필드 선언만으로 자동으로 생성자, getter, equals(), hashCode(), toString() 등 메서드를 자동으로 생성해 주어 보일러 플레이트 코드를 줄일 수 있습니다. 이러한 특성으로 인해 멀티 스레드 환경에서 데이터가 의도치 않게 변경되지 않고 안전하게 전달할 수 있습니다.

// 기존 클래스 기반 DTO
public class MemberDto {

	private final String name;
	private final String email;
	private final int age;

	public MemberDto(String name, String email, int age) {
		this.name = name;
		this.email = email;
		this.age = age;
	}

	public String getName() {
		return name;
	}
	
	public String getEamil() {
		return email;
	}
	
	public int getAge() {
		return age;
	}
}
// Record. 생성자, getter, hashCode(), equals(), toString() 자동 완성
public record MemberDto(String name, String email, int age) {}

그럼 Record로 생성한 모든 객체는 DTO인가요?

모든 Record 객체가 DTO인 것은 아닙니다. Record는 단순히 데이터를 캡슐화하는 역할을 하는데, DTO 외에도 값 객체(Value Objects) 등의 다양한 용도로 사용될 수 있습니다.

// 값 객체로 사용
public record Coordinates(double x, double y) {}

DTO는 계층 간 데이터 전송을 목적으로 하는 객체인 반면, VO는 도메인 모델 내에서 특정 값을 표현하는 객체로 사용됩니다. 따라서, Record는 이 두 가지 모두에 적합하게 사용할 수 있지만, 그 목적에 따라 사용 방법이 달라집니다.

Record와 VO를 비교해주세요

Record와 VO는 모두 객체의 상태가 변경되지 않는 것을 보장합니다. 또 데이터를 캡슐화하여 표현하는 데 초점을 맞춥니다. 마지막으로 VO는 값 기반의 동등성을 가지며, Record도 동일한 필드 값을 가지면 동일한 객체로 간주된다는 점이 공통점입니다.

VO는 도메인 모델내에서 특정 개념을 표현하고, 도메인 로직과 밀접하게 관련이 있습니다. 즉, VO는 비즈니스 로직이나 규칙을 가질 수 있습니다. 반면에 Record는 단순히 데이터를 캡슐화하여 전달하는데 의미가 있습니다.

결론적으로 Record는 VO를 구현하는 데 적합하지만, VO의 모든 특성을 완벽히 대체하지는 않습니다. VO는 더 넓은 도메인 맥락에서 사용되며, 비즈니스 로직을 포함할 수 있습니다.

Record의 한계는 뭐가 있을까요?

Record는 extends를 사용하여 다른 클래스를 상속할 수 없고, 필드가 final로 선언되기 때문에 확장이 어렵습니다. 또 주로 데이터를 전달하려는 목적으로 설계되었기 때문에 비즈니스 로직을 포함하기에 적절하지 않습니다. 마지막으로 Java 14 또는 16 이전 버전에서 호환이 불가능하다는 점이 있습니다.

추가 학습 자료를 제공합니다.

참고 링크

[109] DB Replication에 대해서 설명해주세요.

백엔드

DB Replication에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

DB Replication은 데이터베이스의 고가용성과 데이터 안정성을 보장하기 위해 널리 활용되는 핵심 기술입니다. 특히, 대규모 애플리케이션 환경에서는 데이터의 지속적인 가용성과 신뢰성이 매우 중요하기 때문에, 원본(Source) 서버와 복제(Replica) 서버 간의 데이터 동기화는 필수입니다. MySQL 기준으로 설명하겠습니다.

바이너리 로그(Binary log)를 저장하는 방식은?

Replication은 Source 서버에서 발생하는 모든 데이터 변경 사항을 Replica 서버로 복제하여 두 서버 간의 데이터 일관성을 유지하는 메커니즘입니다. 이러한 과정은 주로 Binary log를 기반으로 이루어지며, Binary log는 Source 서버에서 실행된 모든 데이터 변경 쿼리를 기록하는 역할을 합니다. MySQL에서는 이 Binary log를 저장하는 방식으로 Row, Statement, Mixed의 세 가지 방식을 제공하며, 각 방식은 고유한 장단점을 가지고 있습니다.

Row

Row 방식은 데이터베이스의 각 행별로 변경된 내용을 정확히 기록합니다. 이 방식은 데이터 일관성을 매우 높게 유지할 수 있다는 큰 장점이 있습니다. 예를 들어, 특정 행이 수정되었을 때 그 행의 이전 상태와 변경된 상태를 모두 기록하므로, 복제 서버에서도 원본 서버와 동일한 데이터 상태를 유지할 수 있습니다. 그러나 모든 행의 변경 사항을 저장하기 때문에 Binary log 파일의 크기가 급격히 증가할 수 있어 저장 공간에 부담을 줄 수 있는 단점이 존재합니다.

Statement

반면에 Statement 방식은 데이터 변경을 일으킨 SQL 문 자체를 Binary log에 기록합니다. 이 방식은 로그 파일의 크기를 상대적으로 작게 유지할 수 있어 저장 공간을 절약할 수 있는 장점이 있습니다. 하지만 실행할 때마다 다른 값을 반환하는 함수와 같이 비확정적(non-deterministic) SQL 쿼리가 실행될 경우, 동일한 쿼리가 Source와 Replica 서버에서 다른 결과를 초래할 수 있어 데이터 불일치 문제가 발생할 수 있습니다. 예를 들어, SELECT NOW()와 같은 함수는 실행 시점에 따라 다른 결과를 반환할 수 있기 때문에, 이를 포함한 쿼리는 복제 시 문제가 될 수 있습니다.

Mixed

이러한 문제를 보완하기 위해 MySQL은 Mixed 방식을 제공합니다. Mixed 방식은 상황에 따라 row 기반과 statement 기반을 혼합하여 로그를 기록합니다. 비확정적 SQL이 아닌 경우에는 statement 방식을 사용하여 저장 공간을 절약하고, 비확정적 SQL이 실행되는 경우에는 row 방식을 사용하여 데이터 일관성을 유지합니다. 이를 통해 두 방식의 장점을 모두 활용할 수 있으며, 데이터 불일치 문제를 최소화할 수 있습니다. 다만, 구현이 다소 복잡할 수 있다는 단점이 존재합니다.

복제 과정

Source 서버에서 데이터 변경 쿼리가 실행되고, 선택된 로그 저장 방식에 따라 Binary log에 기록된 후, Replica 서버의 IO Thread가 Binary log를 읽어와 Replica 서버의 Relay log로 전송합니다. Relay log는 Replica 서버에서 Source 서버의 Binary log를 저장하는 임시 저장소 역할을 하며, 이곳에 저장된 로그를 기반으로 Replica 서버의 SQL 스레드가 실제 데이터베이스에 변경 사항을 적용합니다. 이 과정은 매우 효율적으로 설계되어 일반적으로 약 100밀리초 이내에 데이터 동기화가 완료됩니다. 이러한 빠른 동기화 속도 덕분에 원본과 복제 서버 간의 데이터 일관성이 실시간에 가깝게 유지될 수 있습니다.

참고 링크

참고 링크 없음

[110] SOLID 원칙에 대해서 설명해 주세요.

백엔드

SOLID 원칙에 대해서 설명해 주세요.

백엔드와 관련된 질문이에요.

SOLID 원칙은 객체지향 설계 5원칙이라고도 불리며, 각 원칙의 앞 글자를 따서 만들어졌습니다. 객체지향설계의 핵심 중 하나는 의존성을 관리하는 것인데요. 의존성을 잘 관리하기 위해서는 SOLID 원칙을 준수해야 합니다.

단일 책임 원칙(Single Responsibilty Principle) 은 클래스가 오직 하나의 목적이나 이유로만 변경되어야 한다는 것을 강조합니다. 여기서 “책임”이란 단순히 메서드의 개수를 뜻하지 않고, 특정 사용자나 기능 요구사항에 따라 소프트웨어의 변경 요청을 처리하는 역할을 의미합니다.

즉, 클래스는 한 가지 변화의 이유만 가져야 하며, 이를 통해 변경이 발생했을 때 다른 기능에 영향을 덜 미치도록 설계됩니다. 이렇게 하면 유지보수가 쉬워지고 코드가 더 이해하기 쉬워집니다.

개방 폐쇄 원칙(Open-Closed Principle) 은 확장에는 열려있고, 변경에는 닫혀 있어야 함을 강조합니다. 이때 확장이란 새로운 타입을 추가함으로써 새로운 기능을 추가하는 것을 의미하며, 폐쇄란 확장이 일어날 때 상위 레벨의 모듈이 영향을 받지 않아야 함을 의미합니다. 이를 통해서 모듈의 행동을 쉽게 변경할 수 있습니다. 모듈이란 크기와 상관없이 클래스, 패키지, 라이브러리와 같이 프로그램을 구성하는 임의의 요소를 의미합니다.

리스코브 치환 원칙(Liskov Substitution Principle) 은 서브 타입은 언제나 상위 타입으로 교체할 수 있어야 합니다. 즉, 서브 타입은 상위 타입이 약속한 규약을 지켜야 함을 강조합니다. 이 원칙은 부모 쪽으로 업 캐스팅하는 것이 안전함을 보장하기 위해 존재합니다. 상위 타입에 대해 기대되는 역할과 행동 규약이 있는데 이를 벗어나면 안 됩니다. 만약, 하위 타입이 상위 타입에 기대되는 역할을 만족하지 않는다면, 상위 타입을 사용하는 클라이언트 코드에서는 하위 타입이 누구인지 물어봐야 하는데, 이는 OCP를 달성하기 어렵게 합니다. LSP를 위반하는 대표적인 사례로는 Rectangle 예제가 있습니다.

인터페이스 분리 원칙(Interface Segregation Principle) 은 클라이언트 입장에서 인터페이스를 분리해야 함을 강조합니다. 사용하지 않지만 의존성을 가지고 있다면 해당 인터페이스가 변경되는 경우 영향을 받습니다. 따라서, 독립적인 개발과 배포가 불가합니다. 사용하는 기능만 제공하도록 인터페이스를 분리해 변경의 여파를 최소화할 수 있습니다.

의존성 역전 원칙(Dependency Inversion Principle) 은 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 되며, 모두 추상화에 의존해야 함을 강조합니다. SOLID는 서로 연관이 있는데요. 의존성 역전 원칙을 통해서 하위 레벨의 모듈은 개방 폐쇄 원칙을 준수하면서 새로운 타입이 추가 가능합니다.

앞으로 어떤 확장이 필요한지 완벽히 알아야 OCP를 제대로 할 수 있는 거 아닌가요? 🤔

그런데.. 소프트웨어에 어떤 기능을 추가하고 변경할지 예측이 어렵지 않나요?

변경을 예상하고, 준비하지 말고 고객이 원한 것만 만들어서 빨리 전달하고 피드백을 수용하는 방법을 사용해 볼 수 있습니다. 변화에 대한 가장 좋은 예측은 변화를 경험하는 것이라고 생각합니다. 발생할 것 같은 변화를 발견한다면 향후 해당 변화와 같은 종류의 변화로부터 코드를 보호할 수 있습니다. 즉, 고객이 요구할 모든 종류의 변경을 완벽하게 예측하고, 이에 대한 변경에 대응하기 위해 추상화를 적용하는 대신에 고객이 변경을 요구할 때까지 기다리고 추상화를 만들어서 향후 추가로 재발하는 변화로부터 보호될 수 있도록 하는 것입니다. 이때 OCP를 준수하도록 코드를 작성할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[112] 다중 서버 환경에서 세션 기반 인증 방식을 사용하는 경우 발생할 수 있는 문제점은 무엇인가요?

백엔드

다중 서버 환경에서 세션 기반 인증 방식을 사용하는 경우 발생할 수 있는 문제점은 무엇인가요?

백엔드와 관련된 질문이에요.

다중 서버 환경에서 세션 기반 인증 방식을 사용하는 경우에는 세션 불일치 문제가 발생할 수 있습니다. 만약 서버 A, B를 관리하고 있을 때, 로드밸런서는 사용자의 요청을 상황에 맞게 A, B 중 한 곳으로 전달합니다. 유효한 로그인 요청이 A 서버로 처음 도착하면 사용자에 대한 세션 정보는 A 서버에 저장됩니다. 이후에 해당 사용자의 또 다른 요청이 로드 밸런서에 도착했을 때, B 서버로 도착하게 되면 사용자의 세션 데이터가 존재하지 않기 때문에 요청이 제대로 처리되지 않습니다. 이를 세션 불일치 문제라고 합니다.

세션 불일치 문제는 어떻게 해결할 수 있나요? 🤔

세션 불일치 문제는 크게 3가지 방식으로 해결할 수 있습니다. 스티키 세션 방식, 세션 클러스터링 방식, 스토리지 분리 방식입니다.

각 방식에 대한 설명과 장단점을 말해주세요. 😀

스티키 세션 방식은 사용자 요청이 항상 사용자 세션 정보가 저장된 서버로 가도록 고정하는 방식입니다. 사용자 요청의 쿠키나 IP를 통해서 어느 서버로 고정 시킬지 결정합니다. 해당 방식은 단순하다는 장점이 존재합니다. 반면, 특정 서버에 트래픽이 집중될 수 있다는 문제점과 사용자의 세션 정보를 가지고 있는 서버가 다운되면 해당 서버에 고정된 사용자는 다시 로그인해야하는 문제점이 존재합니다.

세션 클러스터링 방식은 특정 서버에 사용자 세션 정보가 생성될 때, 다른 서버로 정보를 복제하는 방식입니다. 여러 서버에 세션 정보를 중복으로 저장하므로 스티키 세션의 트래픽 몰림 현상과 세션 정보 유실 문제를 해결한다는 장점이 있습니다. 반면, 세션 정보를 중복 저장한다는 점에서 메모리를 비효율적으로 사용하며, 세션 정보 복제 과정에서 발생하는 네트워크 트래픽 문제, 세션 정보 복제 지연으로 인한 일시적인 세션 정보 유실 문제가 발생할 수 있습니다.

스토리지 분리 방식은 세션 정보를 저장하는 공간을 외부로 분리하는 방식입니다. 세션 클러스터링 방식, 스티키 세션 방식에서 발생하는 문제를 해결할 수 있습니다. 해당 방식은 스토리지에 대한 단일 장애 지점(Single Point Of Failure)이 문제가 될 수 있으며, 클러스터링과 같은 HA 구성으로 단일 장애 지점을 해소하여도 복제 지연으로 인한 일시적인 세션 정보 유실 문제는 발생할 수 있습니다. 또한, 외부 스토리지를 관리하기 위한 추가적인 리소스가 요구될 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[115] 로드 밸런싱에 대해서 설명해주세요.

백엔드

로드 밸런싱에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

로드 밸런싱이란 애플리케이션을 지원하는 리소스 풀에 들어오는 네트워크 트래픽(들어오는 요청)을 균등하게 분산하는 것을 의미합니다. 이를 수행하는 로드 밸런서는 애플리케이션 서버 앞단에 위치하며 클라이언트 요청을 지시하고 제어합니다. 이를 통해서 애플리케이션의 가용성, 확장성, 보안 및 성능을 확보할 수 있습니다.

알고 계신 로드 밸런싱 알고리즘이 존재하나요? 🤔

각 방식을 설명해 주세요. 필요하시면 화이트보드를 사용해 주셔도 좋습니다. 😀

라운드 로빈(Round Robin) 방식은 모든 요청이 순서대로 처리되는 방식입니다. 서버가 3대(A, B, C)가 존재하면 요청은 ABCABC 순서대로 전달됩니다. 모든 서버의 처리 능력이 동등하고, 요청의 고른 분산이 중요한 경우 고려해볼 수 있습니다. 구현이 쉬우며 고른 분산을 보장한다는 것이 장점입니다. 하지만, 서버 부하나 응답 시간을 고려하지 않고 서버의 처리 능력이 다른 경우 비효율적이라는 것이 단점입니다.

가중치 라운드 로빈(Weighted Round Robin) 방식은 라운드 로빈 방식에 가중치라는 개념을 추가합니다. 각 서버는 처리 능력과 가용 자원에 따라서 가중치를 할당 받게 됩니다. 그리고, 라운드 로빈 방식을 사용하되 가중치가 높은 서버는 가중치에 비례하여 상대적으로 더욱 많은 요청을 받게 됩니다.

라운드 로빈보다 상대적으로 구현이 복잡하지만 각 서버의 처리 능력을 고려하지 않는다는 라운드 로빈 방식의 단점을 개선합니다. 하지만, 여전히 서버의 상태를 고려하지 않는 방식이라는 점을 유의해야합니다.

최소 연결(Least Connections) 방식은 각 서버의 활성 연결 수를 모니터링하고 있는 경우에 사용할 수 있습니다. 가장 적은 활성 연결이 존재하는 서버에게 요청을 전달하는 방식입니다. 각 서버의 처리 능력이 다른 경우에는 적합하지 않을 수 있습니다. 처리 능력이 큰 서버는 상대적으로 활성 연결을 더욱 많이 수립할 수 있기 때문입니다. 최소 연결 방식은 각 서버의 처리 능력이 비슷하지만 특정 이유로 한 서버에 동시 연결 수가 많아 지는 상황이 존재하는 경우 고려해볼 수 있습니다.

로드 밸런싱 대상에 상대적으로 처리 능력이 큰 서버가 존재하는 경우에는 라운드 로빈과 마찬가지로 가중치라는 개념을 사용해볼 수 있습니다. 이를 가중치 최소 연결(Weighted Least Connections) 방식이라고 합니다.

최소 응답 시간(Least Response Time) 방식은 각 서버의 응답 시간을 모니터링하고 있는 경우에 사용할 수 있습니다. 응답 시간이 가장 빠른 서버에 요청을 전달하는 방식입니다. 서버들마다 응답 시간이 다양할 경우, 가장 빠른 서버에 요청을 전달하여 사용자 경험을 개선하는데 도움이 될 수 있습니다. 응답 시간을 기반으로 하기 때문에 서버의 부하 상태, 활성 연결 수와 같은 다른 요소들을 고려해야하는 경우에는 적합하지 않을 수 있습니다.

IP 해시 방식은 클라이언트 요청의 IP를 기반으로 요청을 전달합니다. IP를 이용해 구한 해시값을 기반으로 요청을 전달할 서버를 결정합니다. IP 해시 방식은 클라이언트와 서버 간의 친화성 유지에 초점을 맞춘 방식으로 클라이언트의 상태에 관리에 용이하다는 장점이 있습니다. 하지만, 상황에 따라서 부하가 균등하게 이루어지지 않는다는 단점이 존재합니다.

추가 학습 자료를 공유합니다.

참고 링크

[120] 동시성과 병렬성에 대해서 설명해주세요.

백엔드

동시성과 병렬성에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

동시성(Concurrency)이란?

동시성이란 이름처럼 실제로 여러 작업을 동시에 수행하는 것이 아니라, 논리적으로 동시에 실행되는 것처럼 보이게 만드는 개념입니다. 단일 코어를 기준으로 시간 분할을 통해 여러 스레드를 번갈아 가며 작업을 수행함으로써, 마치 동시에 여러 작업이 처리되는 것처럼 보이게 합니다.

사용자의 입력을 기다리거나, 네트워크 요청, 파일 입출력 등의 I/O 작업 시에는 CPU가 유휴 상태로 대기하게 됩니다. 이때 CPU가 아무 일도 하지 않고 대기하는 대신, 컨텍스트 스위칭을 통해 다른 스레드의 작업을 처리할 수 있습니다. 이러한 특성 덕분에 서버는 여러 클라이언트의 요청을 동시에 처리할 수 있어 효율적입니다. 다만, 동시성 환경을 신중하게 고려하지 않으면 여러 스레드를 사용하면서 Deadlock, Race Condition, Starvation 등의 문제가 발생할 수 있습니다.

병렬성(Parallelism)이란?

병렬성이란 물리적으로 동일한 시간에 여러 작업을 독립적으로 수행하는 것을 의미합니다. 여러 개의 코어가 각각 독립된 스레드의 작업을 동시에 처리함으로써, 실제로 여러 작업이 동시에 실행됩니다. 동시성과는 달리, 하나의 코어가 여러 스레드를 번갈아 가며 처리할 필요 없이, 각 코어에서 독립적으로 작업을 실행합니다.

독립적인 하위 작업으로 나눌 수 있는 계산과 같은 작업을 여러 코어에 분산함으로써, 작업 완료 시간을 최소화할 수 있어 고성능 컴퓨팅에 이상적입니다. 하지만 병렬 처리는 데이터나 리소스를 공유할 때 작업 간 동기화가 필요할 경우가 많아, 이러한 동기화로 인해 상당한 오버헤드가 발생할 수 있습니다.

추가 학습 자료를 제공합니다.

참고 링크

[121] 캐싱 전략에 대해서 설명해주세요.

백엔드

캐싱 전략에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

캐시는 성능 향상과 부하 감소를 목표로 합니다. 이때 캐시를 사용하는 양상이 서비스에 큰 영향을 끼치기도 합니다. 따라서, 캐싱 전략을 이해하는 것은 중요합니다.

Cache Aside(Lazy Loading) 방식에 대해서 설명해주세요. 😀

Cache Aside 방식은 캐시 히트 시 캐시에서 데이터를 불러오며, 캐시 미스 발생 시 원본 데이터베이스에서 조회하여 반환합니다. 애플리케이션은 캐시 미스가 발생하면 해당 데이터를 캐시에 적재합니다.

해당 방식은 실제 요청된 데이터만 캐시에 저장되므로 불필요한 데이터 캐싱을 줄일 수 있습니다. 또한, 캐시에 문제가 발생해도 애플리케이션은 원본 데이터베이스에 직접 접근할 수 있기 때문에 서비스가 계속 작동할 수 있다는 장점이 있습니다. 하지만, 캐시 미스가 발생하는 경우에만 데이터를 캐시에 적재하기 때문에 원본 데이터베이스와 같은 데이터가 아닐 수도 있으며, 초기에는 대량의 캐시 미스로 인한 데이터베이스 부하가 발생할 수 있습니다.

캐시 불일치를 해소할 수 있는 쓰기 전략에 대해서 알고 계신가요? 🤔

캐시 불일치(Cache Inconsistency) 란 원본 데이터베이스에 저장된 데이터와 캐시에 저장된 데이터가 서로 다른 상황을 의미합니다. Write Through, Cache Invalidation, Write Behind 방식으로 이러한 캐시 불일치를 해소할 수 있습니다.

Write Through 방식은 원본 데이터에 대한 변경분이 생긴 경우, 매번 캐시에 해당 데이터를 찾아 함께 변경하는 방식입니다. 2번 쓰기가 발생하지만, 캐시는 항상 최신 데이터를 가지고 있습니다. 캐시는 다시 조회되는 경우에 빛을 발휘합니다. 무작정 데이터를 갱신하거나 저장하는 방식은 리소스 낭비가 될 수 있으니 해당 방식을 사용하는 경우, 만료 시간을 사용하는 것이 권장됩니다.

Cache Invalidation 방식은 원본 데이터에 대한 변경분이 생긴 경우, 캐시 데이터를 만료시키는 방식입니다. Write Through 방식의 단점을 보완한 방식이며 캐시에 데이터가 삭제되니 캐시 불일치에 대한 걱정을 하지 않아도 됩니다.

Write Behind(Write Back) 방식은 원본 데이터에 대한 변경분이 생긴 경우, 캐시를 먼저 업데이트한 이후 추후에 원본 데이터를 변경합니다. 디스크 쓰기 작업을 비동기 작업으로 수행하여 성능을 개선할 수 있습니다. 원본 데이터와 캐시가 일시적으로 일치하지 않을 수 있있기 때문에 쓰기 작업이 빈번하며 일시적인 캐시 불일치를 허용하는 서비스에서 유용하게 사용될 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[122] REST란 무엇인가요?

백엔드

REST란 무엇인가요?

백엔드와 관련된 질문이에요.

REST(Representational State Transfer) 는 자원의 표현을 이용하여 상태를 주고받는 것을 의미합니다. 여기서 자원이란 소프트웨어가 관리하는 모든 것을 의미하며 자원의 표현은 자원을 나타내기 위한 이름을 의미합니다. 가령, 서버가 관리하는 주문 데이터는 order 라고 표현할 수 있습니다. 최근에는 일반적으로 자원의 상태를 나타내기 위해 JSON 포맷을 사용합니다. REST는 네트워크 상에서 클라이언트와 서버의 통신 방식 중 하나이며, HTTP 프로토콜을 사용합니다. 구체적으로는 HTTP URI를 활용하여 자원을 명시하고 HTTP METHOD를 통해 CRUD 연산을 적용하는 것을 의미합니다.

API(Application Programming Interface) 란 컴퓨터 프로그램 간 정보를 주고받을 수 있도록 하는 일종의 출입구와 같은 역할을 수행합니다. API가 REST 기반으로 구현되어 있다면, 이를 REST API라고 부릅니다.

REST의 장단점은 무엇인가요? 🤔

REST는 서버와 클라이언트의 역할을 명확하게 분리해 주며, HTTP 프로토콜을 따르는 모든 플랫폼에서 사용할 수 있습니다. 또한, CURL, Postman을 사용하여 간단하게 테스트할 수 있다는 장점도 존재합니다. 반면, 요청 및 응답 스타일의 통신만 지원합니다. 그리고, HTTP 메서드로 행위를 표현하기 어려운 경우도 존재하며 요청 한 번으로 여러 자원을 가져오기 어렵다는 단점이 존재합니다.

그리고 REST 방식의 경우 자원의 상태를 전송하기 위해서 JSON 메시지 포맷을 사용합니다. JSON과 같은 텍스트 포맷은 자기 서술적이며, 메시지 소비자가 자신이 관심이 있는 값만 골라서 사용하고 나머지는 무시하면 되므로 메시지 구조가 자주 바뀌어도 하위 호환성을 보장하는 것이 유리합니다. 하지만, 메시지가 다소 길다는 것이 단점입니다. 메시지가 길다면 네트워크 트래픽을 더욱 사용하며 전송 속도가 느릴 수 있으며, 메시지를 해석하는 데에 오버헤드가 발생할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[123] ACID에 대해서 설명해주세요.

백엔드

ACID에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

ACID는 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)의 약자이며, 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 의미합니다.

각 속성은 어떤 의미를 가지나요? 🤔

원자성(Atomicity) 은 트랜잭션 내부 연산들이 부분적으로 실행되고 중단되지 않는 것을 보장합니다. 쉽게 말하자면, 트랜잭션은 전체 성공과 전체 실패 중 한 가지만 수행한다는 것입니다. 예를 들면 계좌 이체 트랜잭션은 다음과 같은 연산으로 이루어져 있습니다. 이때 2번 과정에서 에러가 발생하면 1번 과정을 취소해야 합니다.

1. A 계좌에 3000원 출금
2. B 계좌에 3000원 입금 

일관성(Consistency) 은 트랜잭션이 성공적으로 완료되면 일관성 있는 데이터베이스 상태로 유지되는 것을 보장합니다. 가령, 제약조건과 같이 데이터베이스에 정의된 규칙을 트랜잭션이 위반하는 경우에는 해당 트랜잭션은 취소되어야 합니다.

격리성(Isolation) 은 동시에 실행되는 여러 트랜잭션이 서로 독립적임을 보장합니다. 가장 엄격할 경우에는 트랜잭션을 순차적으로 실행하기도 합니다. 트랜잭션을 수행할 때 다른 트랜잭션이 해당 작업 사이에 끼어들지 못하도록 보장합니다. 쉽게 이야기하자면 트랜잭션 밖에서 어떠한 연산도 중간 단계의 데이터를 볼 수 없음을 의미합니다. 가령, 계좌 이체 작업에서 A 계좌의 잔고와 B 계좌의 잔고 총합이 10,000원인 상태로 시작했을 때, 특정 순간에는 총합이 10,000원이 아닌 경우도 있을 것입니다. 하지만, 다른 트랜잭션은 항상 잔고의 총합인 10,000원을 볼 수 있도록 보장되어야 합니다.

지속성(Durability) 은 성공적으로 수행된 트랜잭션은 영원히 반영되어야 함을 보장합니다. 시스템에 장애가 발생해도 성공적으로 수행된 트랜잭션의 결과는 항상 데이터베이스에 반영되어 있어야 합니다. 전형적으로 트랜잭션은 로그로 남고, 로그가 저장되어야 트랜잭션이 성공되었다고 간주합니다. 추후 장애가 발생한다면 이 로그를 활용해 데이터베이스를 회복합니다.

추가 학습 자료를 공유합니다.

참고 링크

[128] 스케일 아웃과 스케일 업의 차이점을 설명해주세요.

백엔드

스케일 아웃과 스케일 업의 차이점을 설명해주세요.

백엔드와 관련된 질문이에요.

기존 개발하고 있던 서비스의 서버가 한계에 도달하는 경우, 스케일 업(Scale-Up) 혹은 스케일 아웃(Scale-Out) 을 고려할 수 있습니다.

스케일 업 은 기존의 서버를 더욱 높은 사양으로 업그레이드하는 것을 의미합니다. 예를 들어, AWS에서 EC2 t2.micro에서 t2.small로 업그레이드하는 방식이 스케일 업입니다. 스케일 업 방식은 상대적으로 간단하게 서버의 성능을 항상 시킬 수 있다는 장점이 있습니다. 하지만, 특정 서버를 무한정 업그레이드할 수 없으며, 장애에 대한 자동복구(failover)나 다중화(re-dundancy) 방안을 제시하지 않습니다. 또한 스케일 업 전략을 선택하는 경우에는 향후 사용량을 미리 추정하여 미리 고사양의 서버를 확보하는 경우가 있습니다. 이러한 경우 실제 필요한 서버의 사양보다 과한 사양의 장비를 확보할 수 있기 때문에 비용적인 손실이 존재할 수 있습니다.

스케일 아웃 은 비슷한 사양의 장비를 추가하여 수평으로 확장하는 방식입니다. 서버로 들어오는 많은 요청을 비슷한 사양의 서버 n대로 분산시켜 성능을 향상시킵니다. 스케일 아웃 방식은 그때그때 필요한 만큼 서버를 추가할 수 있으므로 상대적으로 스케일 업 방식보다 비용 효율적일 수 있습니다. 또한, 특정 서버의 장애 발생 상황에서도 스케일 업 방식보다 가용성이 높습니다. 하지만, 스케일 아웃 방식은 n대의 서버를 관리해야 하므로 관리 포인트가 늘어나며, 각 서버에 부하를 분산하기 위한 로드 밸런싱에 대한 고민이 추가로 필요하다는 단점이 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[129] 프로세스보다 스레드의 컨텍스트 스위칭이 더 빠른 이유는 무엇인가요?

백엔드

프로세스보다 스레드의 컨텍스트 스위칭이 더 빠른 이유는 무엇인가요?

백엔드와 관련된 질문이에요.

컨텍스트 스위칭(Context Switching)이란?

컨텍스트 스위칭은 CPU나 코어에서 실행 중이던 프로세스나 스레드가 다른 프로세스나 스레드로 교체되는 과정을 말합니다. 이는 멀티태스킹 시스템에서 여러 작업을 효율적으로 관리하기 위해 필수적인 메커니즘입니다.

컨텍스트(Context)란?

컨텍스트는 프로세스나 스레드의 현재 상태를 의미합니다. 여기에는 CPU의 레지스터 상태(프로그램 카운터, 스택 포인터 등)와 메모리 상태가 포함됩니다. 컨텍스트는 프로세스나 스레드가 실행을 중단하고 나중에 다시 시작할 때 필요한 모든 정보를 담고 있습니다.

컨텍스트 스위칭이 필요한 이유

멀티태스킹 시스템에서는 여러 프로세스나 스레드가 동시에 실행되는 것처럼 보이도록, CPU가 짧은 시간 단위로 작업을 전환하며 여러 작업을 처리합니다. 이를 통해 사용자에게는 여러 작업이 동시에 진행되는 것처럼 인식되며, 시스템 자원을 효율적으로 활용할 수 있습니다.

컨텍스트 스위칭이 발생하는 시점

  • Time Slice(Quantum)를 다 사용했을 때: 각 프로세스나 스레드에게 할당된 CPU 시간이 다 소진되면 스위칭이 발생합니다.
  • I/O 작업이 필요할 때: 프로세스나 스레드가 I/O 작업을 수행해야 할 경우, CPU는 다른 작업을 처리하기 위해 현재 작업을 중단하고 스위칭을 수행합니다.
  • 우선순위 변경: 더 높은 우선순위의 작업이 발생하면 현재 작업을 중단하고 우선순위가 높은 작업으로 전환할 수 있습니다.

컨텍스트 스위칭을 수행하는 주체

컨텍스트 스위칭은 운영체제의 커널(kernel) 에 의해 수행됩니다. 커널은 시스템 자원을 관리하고 프로세스 및 스레드의 상태를 조정하여 효율적인 작업 처리를 담당합니다.

컨텍스트 스위칭의 구체적인 과정

프로세스 컨텍스트 스위칭(다른 프로세스에 속한 스레드 간의 컨텍스트 스위칭)

  1. 현재 프로세스의 상태 저장: 실행 중인 프로세스의 레지스터 상태와 메모리 정보를 저장합니다.
  2. 다음 프로세스의 상태 로드: 스케줄러가 선택한 다음 실행할 프로세스의 저장된 상태를 로드합니다.
  3. 프로세스 전환: CPU는 새로운 프로세스의 실행을 시작합니다.
  4. 추가 메모리 처리: 새로운 프로세스의 가상 메모리 주소 체계를 설정하고, MMU(Memory Management Unit)와 TLB(Translation Lookaside Buffer)를 업데이트합니다.

스레드 컨텍스트 스위칭(같은 프로세스에 속한 스레드 간의 컨텍스트 스위칭)

  1. 현재 스레드의 상태 저장: 실행 중인 스레드의 레지스터 상태를 저장합니다.
  2. 다음 스레드의 상태 로드: 동일한 프로세스 내에서 실행할 다음 스레드의 상태를 로드합니다.
  3. 스레드 전환: CPU는 새로운 스레드의 실행을 시작합니다.
  4. 메모리 처리 생략: 동일한 프로세스 내 스레드 간 전환이므로 메모리 관련 추가 처리가 필요 없습니다.

프로세스와 스레드 컨텍스트 스위칭의 공통점

  1. 커널 모드에서 실행: 컨텍스트 스위칭은 항상 운영체제의 커널 모드에서 수행됩니다.
  2. CPU 레지스터 상태 교체: 현재 실행 중인 작업의 레지스터 상태를 저장하고, 다음 작업의 레지스터 상태를 복원합니다.

프로세스와 스레드 컨텍스트 스위칭의 차이점

같은 프로세스에 속한 스레드들 간의 컨텍스트 스위칭은 같은 프로세스에 속하기 때문에 메모리 영역을 공유합니다. 그래서 스위칭이 발생해도 메모리와 관련한 추가적인 작업이 발생하지 않습니다.

하지만 다른 프로세스에 속한 스레드 간의 컨텍스트 스위칭의 경우 메모리 프로세스간 메모리 주소 체계가 다르기 때문에, 메모리 주소 관련 처리를 추가적으로 수행해야 합니다.

그래서 MMU(Memory Manage Unit) 또한 새로운 프로세스의 주소 체계를 바로보도록 수정해줘야 하고, TLB(Translation Lookaside Buffer)라는 가상 메모리 주소와 실제 메모리 주소의 매핑 정보를 들고 있는 캐시도 비워줘야 합니다. 만약 TLB 캐시를 비워주지 않는 경우 이전에 작업했던 프로세스의 주소에 접근할 가능성이 있기 때문에 반드시 수행해야 합니다.

스레드 컨텍스트 스위칭이 프로세스보다 빠른 이유

스레드 컨텍스트 스위칭(Thread Context Switching)의 경우 프로세스 컨텍스트 스위칭(Process Context Switching)과 달리 MMU 새로운 주소 체계 바로보도록 수정하고 TLB 가상 메모리, 실제 메모리 저장된 캐시를 비우는 등의 메모리 주소 관련 작업을 하지 않고, CPU의 상태 정보만 바꿔주면 되기 때문입니다.

추가 학습 자료를 제공합니다.

참고 링크

[130] HTTP/1.1과 HTTP/2.0에 대해서 설명해주세요.

백엔드

HTTP/1.1과 HTTP/2.0에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

HTTP/1.1에 대해서 설명해주세요.

HTTP는 웹상에서 클라이언트와 서버 간 통신을 위한 프로토콜입니다. HTTP/1.0의 경우에는 한 개의 요청과 응답마다 TCP 커넥션을 생성하여 사용됐습니다. 하지만, 이러한 방식은 매 요청마다 연결을 생성하는 오버헤드가 발생합니다. HTTP/1.1은 이러한 문제를 지속 커넥션(Persistent Connection) 이라는 지정한 타임아웃만큼 커넥션을 종료하지 않는 방식으로 해결합니다.

또한 파이프라이닝(Pipelining) 을 지원하여 요청의 응답 지연을 감소합니다. 파이프라이닝에서 HTTP 요청은 연속적이며, 순차적으로 전달됩니다. 기존에는 요청한 이후에 응답을 기다리고 그 다음 요청을 보냈는데요. 파이프라이닝에서는 필요한 모든 자원에 대한 요청을 순차적으로 서버로 전송한 다음 모든 요청에 대한 응답을 한 번에 기다리게 됩니다.

HTTP/1.1은 1.0 버전에 비해 상당히 개선됐지만 여전히 문제가 존재하는데요. 대표적으로 Head-of-Line Blocking(HOL Blocking) 문제가 있습니다. 만약 3개의 요청을 파이프라인을 통해 전송을 한다고 했을 때, 서버는 모든 요청을 순서에 맞춰서 응답해야 합니다. 이때 첫 번째 요청이 오래 걸린다고 하면, 나머지 요청은 첫 번째 요청의 처리를 기다려야 합니다. 또한, 1.1 버전은 매 요청마다 동일한 헤더를 반복하여 전송한다는 문제점도 존재합니다.

HTTP/2.0에 대해서 설명해주세요.

HTTT/1.1는 메시지를 일반 텍스트 형식으로 전송했습니다. 2.0부터는 기존 HTTP 메시지를 프레임이라는 단위로 분할하고 이를 바이너리 형태로 만들어서 전송합니다. 따라서, 기존 1.1 버전에 비해 파싱 및 전송 속도가 향상되었습니다.

또한, HTTP/2.0 부터는 멀티플렉싱(Multiplexing) 을 지원합니다. 이는 하나의 커넥션을 사용하여 요청과 응답을 병렬로 처리할 수 있는 방식입니다. 클라이언트가 서버로 여러 요청을 동시에 보내도 각 요청이 독립적으로 처리되기 때문에 애플리케이션 레이어의 HOL Blocking 문제를 해결합니다. 또한 HPACK 헤더 압축 방식을 사용해 반복되는 헤더를 효율적으로 관리하여 대역폭 사용이 최적화되었습니다.

추가 학습 자료를 공유합니다.

참고 링크

[131] 관계형 데이터베이스와 비 관계형 데이터베이스의 차이점은 무엇인가요?

백엔드

관계형 데이터베이스와 비 관계형 데이터베이스의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

관계형 데이터베이스는 고정된 로우와 컬럼으로 구성된 테이블에 데이터를 저장합니다. 그리고 SQL을 사용하여 여러 테이블에 존재하는 데이터와 관계에 따라서 조인하여 합칠 수도 있습니다. 관계형 데이터베이스는 데이터를 중복 없이 한 번만 저장하고, 데이터 무결성을 보장합니다. 관계형 데이터베이스의 경우 일반적으로 스케일 업을 사용하여 확장합니다. 관계형 데이터베이스는 스키마를 유연하게 바꾸기 어렵다는 한계가 존재합니다. 또한, 관계가 존재하기 때문에 비즈니스 요구사항이 더욱 발전하면 복잡한 쿼리가 생기게 됩니다.

반면, 비 관계형 데이터베이스는 NoSQL이라고 불리기도 하는데요. 정해진 스키마가 존재하지 않으며 자유롭게 데이터를 저장하고 조회할 수 있습니다. 문서, 키-값, 와이드 컬럼, 그래프 유형이 존재합니다. 대량의 데이터와 높은 사용자 부하에서도 손쉽게 확장할 수 있습니다. 반면 중복을 허용하는 NoSQL의 경우 데이터의 일관성이 저하되며 용량이 증가한다는 단점이 존재합니다.

어떤 상황에서 각 유형의 데이터베이스를 사용하는 것이 적절할까요? 🤔

데이터가 구조화되어 있고, 자주 변경되지 않으며, 트랜잭션과 복잡한 쿼리, 그리고 데이터 무결성과 일관성이 중요한 경우 관계형 데이터베이스를 선택할 수 있습니다. 트랜잭션이 필요한 서버 애플리케이션을 개발하는 경우가 대표적인 예시입니다. 반면, 아주 낮은 응답 지연시간이 요구되거나, 다루는 데이터의 스키마가 빈번히 변경되거나, 아주 많은 양의 데이터를 저장해야 하는 상황에서는 비 관계형 데이터베이스를 고려할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[132] 캐시 스탬피드 현상에 대하여 설명해주세요.

백엔드

캐시 스탬피드 현상에 대하여 설명해주세요.

백엔드와 관련된 질문이에요.

그림과 함께 설명해 주시겠어요? 😀

대규모 트래픽 환경에서 캐시를 운용하는데, Cache Aside(캐시 미스 발생 시 적재) 전략을 사용한다고 가정하겠습니다. 이때, 수많은 요청들이 동시에 캐시 미스를 확인하고 원본 저장소에서 데이터를 가져와 캐시에 적재하는 상황이 발생할 수 있는데요. 이를 캐시 스탬피드 현상 혹은 Thundering Herd 문제라고 표현합니다. 캐시 스탬피드 현상은 원본 데이터베이스와 캐시의 성능을 저하할 수도 있습니다.

이 문제는 어떻게 풀어볼 수 있을까요? 🤓

해당 방식은 크게 잠금, 외부 재계산, 확률적 조기 재계산 방식으로 풀어볼 수 있습니다.

잠금(Locking) 방식 은 한 요청 처리 스레드가 해당 캐시 키에 대한 잠금을 획득합니다. 이로인해 다른 요청 처리 스레드들은 잠시 대기합니다. 잠금을 획득한 스레드는 사용자 요청에 응답하는 과정동안 캐시 적재 작업은 비동기 스레드로 처리할 수 있습니다. 잠금을 사용하기 때문에 성능 저하 가능성이 존재하며, 잠금 획득 스레드의 실패, 잠금의 생명 주기, 데드락 등 다양한 상황을 고려해야한다는 단점이 존재합니다.

외부 재계산(External Recomputation) 방식 은 모든 요청 처리 스레드가 캐시 적재를 수행하지 않습니다. 대신, 캐시를 주기적으로 모니터링하는 스레드를 별도로 관리하여 캐시의 만료시간이 얼마 남지 않은 경우, 데이터를 갱신하여 문제를 예방합니다. 해당 방식은 다시 사용되지 않을 데이터를 포함하여 갱신하기 때문에 메모리에 대한 불필요한 연산이 발생하고, 메모리 공간을 비효율적으로 사용할 가능성이 존재합니다.

확률적 조기 재계산(Probablistic Early Recomputation) 방식 은 캐시 만료 시간이 얼마 남지 않았을 경우, 확률이라는 개념을 사용하여 여러 요청 처리 스레드 중에서 적은 수만이 캐시를 적재하는 작업을 수행하여 스탬피드 현상을 완화할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[137] 시스템 간 비동기 연동 방식에는 무엇이 있나요?

백엔드

시스템 간 비동기 연동 방식에는 무엇이 있나요?

백엔드와 관련된 질문이에요.

분리된 시스템 간의 비동기 연동은 시스템 간의 결합도를 낮출 수 있으며, 호출된 시스템의 응답을 기다리지 않으므로 더욱 빨리 사용자의 요청에 응답할 수 있다는 장점이 있습니다. 비동기 연동 방식으로 메시징 시스템 활용, 데이터베이스 활용, CDC 활용 방식을 알고 있습니다.

각 방식에 대한 설명과 고려 사항을 설명해 주세요. 😀

메시징 시스템 활용 방식은 두 시스템 사이에 메시징 시스템을 두어 비동기로 연동하는 방식입니다. 해당 방식은 한 시스템에서 메시지를 생성해서 메시징 시스템에 송신한 이후, 다른 시스템에서 메시징 시스템으로부터 메시지를 읽어와 메시지를 처리합니다. KafKa, RabbitMQ가 주로 메시징 시스템으로 활용됩니다. 해당 방식은 처리량이 높은 것이 장점입니다. 하지만, 메시지 유실, 메시지 소비 순서, 트랜잭션에 대한 고민이 추가적으로 필요합니다. 트랜잭션에 대한 고민을 더욱 말씀 드리자면, 1개의 트랜잭션 내에 메시지 전송과 데이터베이스 삽입이 존재한다고 했을 때, 데이터베이스 삽입이 실패했는 데 메시지는 전송되거나, 데이터베이스 삽입은 성공했는데, 메시지 전송이 실패한 경우를 떠올려 볼 수 있습니다. 만약 이러한 상황이 존재한다면, 2개의 작업을 어떻게 원자적으로 수행할 수 있을 지 추가적인 고민이 필요합니다.

데이터베이스 활용 방식은 데이터베이스를 메시징 시스템처럼 사용하는 방법입니다. 한 시스템에서 데이터베이스 테이블에 필요한 메시지 레코드를 추가하고 연동 시스템이 테이블을 주기적으로 읽습니다. 만약 새로운 메시지가 추가되면 연동 시스템은 다른 시스템으로 메시지를 전송합니다. 해당 방식은 트랜잭션과 메시지 순서가 보장되며, 메시지 유실에 대한 걱정이 없다는 것이 장점입니다. 하지만, 해당 방식은 범용성이 떨어질 수 있습니다. 메시지에 대한 형식이 빈번히 변경될때마다 메시지 레코드에 대한 스키마도 변경해주어야하기 때문입니다. 추가적으로 해당 방식은 삭제 정책과 읽기 빈도 등 추가적으로 고민해야할 부분들이 존재합니다.

CDC(Change Data Capture) 활용 방식은 데이터베이스의 변경 사항을 조회하여, 이를 다른 시스템에 전파하는 방식입니다. 가령 별도의 시스템이 변경 감지 대상 데이터베이스의 바이너리 로그를 조회하여 변경을 전파하도록 구현할 수 있습니다. 해당 방식은 트랜잭션이 보장되며, 메시지를 생성하거나 별도로 저장할 필요가 없으니 상대적으로 애플리케이션 로직이 단순하다는 장점이 있습니다. 하지만, 변경 로그만 존재할뿐 왜 바뀌었는지에 대한 추가적인 정보가 없기 때문에 사용하는데 제약이 존재할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[138] CAP 정리에 대해서 알고 계신가요?

백엔드

CAP 정리에 대해서 알고 계신가요?

백엔드와 관련된 질문이에요.

CAP 정리는 분산 데이터베이스 시스템이 CAP 중 2개의 속성만을 제공할 수 있다는 이론입니다. CAP 정리에 따르자면, 일관성(Consistency), 가용성(Availability), 분할 내성(Partition Tolerance) 등 3가지 속성을 모두 만족하는 분산 데이터베이스 시스템은 존재하지 않습니다.

각 속성에 대해서 설명해주시겠어요? 🤔

  • 일관성(Consistency) 은 모든 클라이언트 요청은 어느 노드에 연결되어도 같은 데이터를 볼 수 있음을 의미합니다.
  • 가용성(Availability) 은 노드 일부에 문제가 발생하여도 시스템은 클라이언트의 모든 요청에 유효한 응답을 전해줄 수 있어야 함을 의미합니다.
  • 분할 내성(Partition Tolerance) 은 노드 사이에 통신이 불가능한 상황(파티션)에서도 시스템이 계속 동작한다는 것을 의미합니다.

각 속성의 조합을 예시와 함께 설명해 주실 수 있나요? 🤓

3개의 분산된 데이터베이스가 존재한다고 가정해 보겠습니다. 해당 분산 데이터베이스 시스템에서는 특정 서버에 쓰기 작업이 발생하면 나머지 서버에 데이터가 전파됩니다. 이때, 만약 A 파티션(1대의 노드), B 파티션(2대의 노드)으로 네트워크가 분할되었다면 이때 파티션 간 노드들은 서로 통신할 수 없기 때문에 데이터 전파가 불가능합니다.

CA 시스템은 일관성과 가용성을 지원하며 분할 내성을 희생합니다. 하지만, 통상적으로 네트워크 장애는 피할 수 없는 일로 여겨지므로 분산 시스템에서 분할 내성은 희생하기 어렵습니다. 따라서 실세계에 CA 시스템은 존재하지 않습니다.

그러면 CP 혹은 AP 시스템이 현실적인 대안이 될 수 있는데요. 파티션이 발생한 상황에서 CP 시스템은 파티션이 해결되기 전까지 다른 데이터베이스의 연산을 중단시켜 일관성을 지키고 가용성을 희생합니다.

반면, AP 시스템은 파티션 문제가 발생해도 읽기 및 쓰기 작업을 중단하지 않습니다. 이 경우 일관성은 희생되지만 파티션 문제가 해결되는 경우 동기화 작업을 수행하여 최종적인 일관성을 보장할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[139] 응집도와 결합도에 대해서 설명해주세요.

백엔드

응집도와 결합도에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

응집도(Cohesion) 는 모듈에 포함된 내부 요소들이 연관되어 있는 정도를 나타냅니다. 결합도(Coupling) 는 의존성의 정도를 나타내며, 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타냅니다.

응집도와 결합도는 변경과 관련이 깊으며, 일반적으로 좋은 설계란 높은 응집도와 낮은 결합도를 가진 모듈로 구성된 설계를 의미합니다. 객체의 행동에서 객체가 알고 있는 모든 정보를 사용하거나, 변경이 존재하는 경우 특정 모듈만 수정되면 응집도가 높다고 판단할 수 있습니다. 또한, 특정 모듈을 변경하는 경우에 다른 모듈도 변경해야 하는 상황에서는 결합도가 높다고 판단할 수 있습니다.

캡슐화의 정도가 응집도와 결합도에 영향을 미치는데요. 캡슐화를 지키면, 모듈 안의 응집도는 높아지고 모듈 사이의 결합도는 낮아집니다. 따라서, 응집도와 결합도를 고려하기 전에 먼저 캡슐화를 향상하기 위해 노력해야 합니다.

캡슐화는 무엇인가요? 😀

객체 내부의 세부적인 사항을 감추는 것을 캡슐화라고 합니다. 캡슐화의 목적은 변경하기 쉬운 객체를 만드는 것입니다. 캡슐화를 통해 객체 내부로의 접근을 제한하면 객체와 객체 사이의 결합도를 낮출 수 있기 때문에 설계를 더욱 쉽게 변경할 수 있습니다. 만약 캡슐화가 약화되어 있다면, 클라이언트 코드로 변경이 전파되어 변경이 쉽지 않을 수 있습니다. 그리고, 협력을 재사용하기 어려운 상황을 만들어낼 수 있는데요. 이러한 상황을 예방하기 위해서 캡슐화는 객체지향 프로그래밍에서 기본기로 여겨집니다.

추가 학습 자료를 공유합니다.

참고 링크

[140] Redis가 싱글 스레드로 만들어진 이유를 설명해주세요.

백엔드

Redis가 싱글 스레드로 만들어진 이유를 설명해주세요.

백엔드와 관련된 질문이에요.

Redis가 단일 스레드(single-threaded)로 설계된 이유는 주로 성능 최적화, 복잡성 감소, 그리고 데이터 일관성을 유지에 있습니다.

단일 스레드 모델은 멀티스레드 모델에 비해 설계와 구현이 상대적으로 간단합니다. 멀티스레드 환경에서는 동시성 문제(레이스 컨디션, 데드락 등)를 처리하기 위해 복잡한 동기화 메커니즘이 필요하지만, 단일 스레드 환경에서는 이런 문제를 자연스럽게 회피할 수 있습니다.

동시에 여러 스레드가 동일한 데이터를 수정하려고 할 때 발생할 수 있는 데이터 불일치 문제를 방지합니다. 모든 명령어가 순차적으로 처리되기 때문에, 복잡한 락(lock) 메커니즘 없이도 데이터의 일관성을 자연스럽게 유지할 수 있습니다.

Redis는 주로 메모리 내에서 빠르게 수행되는 I/O 작업을 처리하는 인메모리 데이터베이스로 설계되어, 매우 빠른 응답 시간을 제공합니다. 단일 스레드 이벤트 루프(event loop)를 사용함으로써 컨텍스트 스위칭(Context Switching)에 소요되는 오버헤드를 최소화할 수 있습니다.

Redis는 이벤트 기반(event-driven) 아키텍처를 채택하여 네트워크 요청을 효율적으로 처리합니다. 단일 스레드 이벤트 루프는 비동기적으로 여러 클라이언트의 요청을 처리할 수 있으며, 이를 통해 높은 동시성을 구현할 수 있습니다. 멀티스레드 모델에서는 이러한 비동기 처리의 이점을 충분히 활용하기 어려울 수 있습니다.

Redis 6.0 부터 클라이언트로 부터 전송된 네트워크를 읽는 부분과 전송하는 I/O 부분은 멀티 스레드를 지원합니다. 하지만 실행하는 부분은 싱글 스레드로 동작하기 때문에 기존과 같이 Atomic을 보장합니다.

추가 학습 자료를 공유합니다.

참고 링크

[141] 교착 상태에 대해서 설명해주세요.

백엔드

교착 상태에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

교착 상태(deadlock) 는 두 개 이상의 작업이 서로 상대방의 작업이 끝나기만을 기다리고 있어 결과적으로 아무것도 완료되지 못하는 상태를 의미합니다. 가령, A 프로세스가 자원 A를 가지고 자원 B를 필요로 합니다. 그리고, B 프로세스는 자원 B를 가지고 자원 A가 필요할 때 두 개의 프로세스는 교착 상태에 빠져 어느 작업도 진행할 수 없는 상황이 됩니다.

교착 상태가 발생하는 조건을 알고 계신가요? 🤔

4가지 조건(상호 배제, 점유 대기, 비선점, 원형 대기)이 모두 만족하는 경우, 교착 상태에 빠질 수 있습니다.

  • 상호 배제(mutual exclusion) 는 한 프로세스가 사용하는 자원을 다른 프로세스가 사용할 수 없는 경우를 의미합니다.
  • 점유 대기(hold and wait) 는 자원을 할당받은 상태에서 다른 자원을 할당받기를 기다리는 상태를 의미합니다.
  • 비선점(non-preemption) 은 자원이 강제적으로 해제될 수 없으며 점유하고 있는 프로세스의 작업이 끝난 이후에만 해제되는 것을 의미합니다.
  • 원형 대기(circular wait) 은 프로세스들이 원의 형태로 자원을 대기하는 것을 의미합니다.

자바에서 교착 상태는 어떻게 해결할 수 있나요? 😀

// thread 1
synchronized (resource1) { 
  synchronized(resource2) { ... }
}

// thread 2
synchronized (resource2) { 
  synchronized(resource1) { ... }
}

예를 들어, 자바의 syncronized 키워드로 인한 교착 상태가 발생했다고 가정해보겠습니다. 위와 같은 경우에는 외부 synchronized 블록 내부에 synchronized 블록을 포함하지 않도록 개선하여 점유 대기 조건을 제거하여 교착 상태를 해결할 수 있습니다.

이외에도 ReentrantLock을 사용하는 경우에는 tryLock() 메서드를 사용하여 타임아웃을 설정하거나, lockInterruptibly() 메서드를 사용하여 데드락이 발생하는 경우, 인터럽트를 통해 스레드를 깨울 수 있습니다.

정리하자면 교착 상태가 발생하는 4가지 조건 중 하나를 충족하지 못하게 하거나, 대기하는 경우 무한정 기다리지 않는 방식으로 교착 상태를 풀어볼 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[145] Call By Value와 Call By Reference에 대해서 설명해주세요.

백엔드

Call By Value와 Call By Reference에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

특정 메서드를 호출하는 경우 인자로 전달하는 방법은 크게 2가지가 존재하는데요. 값에 의한 호출(Call By Value), 참조에 의한 호출(Call By Reference)이 이에 해당됩니다.

값에 의한 호출(Call By Value) 은 메서드를 호출할 때, 값 자체를 넘겨주는 방식입니다. 메서드를 호출하는 함수의 변수와 호출된 함수의 파라미터는 서로 다른 변수입니다.

반면, 참조에 의한 호출은(Call By Reference) 는 메서드를 호출할 때, 참조를 직접 전달하는 방식입니다. 참조를 직접 전달하기 때문에 호출하는 함수의 변수와 호출된 함수의 파라미터는 동일한 변수입니다. 따라서, 파라미터를 수정하는 경우 그대로 원본에도 영향을 미칩니다.

자바는 어떤 방식을 채택하나요? 🤓

자바는 값에 의한 호출(Call By Value)만 존재합니다.

자바의 변수는 스택 영역에 할당됩니다. 이때, 변수의 타입이 원시 타입인 경우에는 값 또한 스택 영역에 저장됩니다. 그리고, 참조 타입인 경우 객체 자체는 힙 영역에 저장되고 스택 영역에 존재하는 변수가 객체의 주소를 가지고 있습니다.

만약 특정 메서드에 원시 타입의 변수를 인자로 전달하여 호출하면, 호출된 메서드의 해당 파라미터가 변경되어도 원본은 수정되지 않습니다. 왜냐하면, 호출된 메서드의 스택 프레임에 인자로 주어진 변수의 값이 복사되어 사용되기 때문입니다.

반면, 참조 타입의 변수를 인자로 전달하여 호출하는 경우에는 호출된 메서드 내부에서 원본이 수정될 수 있습니다. 하지만, 이러한 방식은 호출된 메서드의 스택 프레임에 참조 타입 변수를 중복하여 생성하기 때문에 값에 의한 호출로 판단됩니다. 가령, 다음과 같은 코드가 존재할 때, foo 메서드의 스택 프레임과 var 메서드의 스택 프레임에 각각 같은 student 객체의 주소를 가지고 있는 참조 타입 변수인 student가 존재합니다.

public void foo() {
    Student student = new Student();
    var(student);
}

public void var(Student student) {
    student.study();
}

추가 학습 자료를 공유합니다.

참고 링크

[146] 방어적 복사에 대해서 설명해주세요.

백엔드

방어적 복사에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

방어적 복사(Defensive Copy) 는 원본과의 참조를 끊은 복사본을 만들어 사용하는 방식이며, 원본의 변경에 의한 예상치 못한 사이드 이펙트를 방지하여 안전한 코드를 만들 수 있는데 도움이 됩니다.

방어적 복사는 2가지 시점이 존재하는데요. 생성자의 인자로 받은 객체의 복사본을 만들어 내부 필드를 초기화하거나, getter 메서드에서 객체를 반환할 때, 복사본을 만들어 반환할 수 있습니다. 만약 컬렉션 자료구조를 반환하는 경우라면 자바의 Unmodifiable Collection을 사용하여, 외부에서 Collection에 대해 조회만 할 수 있도록 강제할 수 있습니다. 자바에서 Unmodifiable Collection은 set(), add(), addAll() 처럼 컬렉션에 요소를 추가하거나 변경하는 메서드를 사용하는 경우, 예외를 발생합니다.

다음 코드에서 발생할 수 있는 문제점은 무엇일까요? 🤔


public class Lotto {

  private final List<LottoNumber> numbers;

  public Lotto(List<LottoNumber> numbers) {
      validateSize(numbers);
      this.numbers = new ArrayList<>(numbers);  // 방어적 복사
  }
}
충분히 고민해 보신 다음에 펼쳐보세요!

위 코드는 두 가지 문제점이 발생할 수 있습니다.

첫 번째는 생성자의 파라미터로 주어진 LottoNumber 리스트의 각 요소가 외부에서 변경될 수 있는 가능성이 존재합니다. 이러한 문제가 발생하는 이유는 방어적 복사가 깊은 복사가 아니기 때문입니다. 가령, 외부에서 다음과 같은 코드를 작성할 수 있습니다.

Lotto lotto = new Lotto(numbers);
numbers.get(0).changeNumber(1);

이 문제를 해결하기 위해서는 생성자의 파라미터에 Integer 리스트를 입력받거나, 방어적 복사 수행 시 내부 객체까지 깊은 복사를 수행할 수 있습니다.

또한 위 코드는 검증을 수행하는 시점에 외부에서 컬렉션이 변경이 발생할 수 있는 가능성이 존재합니다. 예를 들어, validateSize 메서드를 통과하고 방어적 복사를 수행하기 전에 외부에서 numbers에 값을 추가하는 경우, 검증은 성공했지만 객체의 값은 유효하지 않을 수 있습니다. 이 잠재적인 문제를 해결하기 위해서는 방어적 복사가 검증이 수행되기 이전에 이루어져야 합니다.

추가 학습 자료를 공유합니다.

참고 링크

[147] 해시 충돌에 대해서 설명해주세요.

백엔드

해시 충돌에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

해시(Hash) 자료 구조는 키값 쌍으로 이루어진 데이터 구조로 키를 이용해 값을 O(1) 시간 복잡도로 찾을 수 있습니다. 해시 자료 구조는 키를 해시 함수에 넣어서 나오는 결과를 기반으로 값을 관리하는데요. 해시 함수는 다른 키를 사용해도 같은 결과가 나오는 경우가 존재합니다. 이를 해시 충돌(Hash Collision) 이라고 합니다.

해시 충돌은 어떻게 완화할 수 있나요? 🤓

해시 충돌을 완화하기 위한 접근 방법으로 개방 주소법과 분리 연결법이 대표적인데요. 개방 주소법(Open Addressing) 은 특정 값이 들어가야 하는 자리(버킷)가 이미 사용되고 있는 경우 다른 해시 버킷에 데이터를 삽입하는 반면, 분리 연결법(Separate Chaining) 은 버킷을 연결 리스트나 트리 형태로 관리하여 버킷에 들어갈 값의 수에 제한을 두지 않도록 하여 충돌을 완화합니다.

개방 주소법에서 다른 해시 버킷을 찾기 위한 방법에는 어떤 것이 존재하나요? 🤔

버킷이 이미 사용되고 있는 경우, 다른 해시 버킷을 찾기 위한 여러 방법이 존재합니다. 선형 탐사법, 제곱 탐사법, 이중 해싱이 이에 해당됩니다.

선형 탐사법(Linear Probing) 은 임의의 고정된 크기만큼 한 칸씩 옮기면서 빈 버킷을 찾는 방법입니다. 선형 탐사법은 특정 버킷 주변이 모두 채워져 있는 경우 해시 성능이 저하될 수 있습니다. (1차 군집 현상) 따라서, 해당 접근 방법은 해시 자료 구조 전체에 해시 충돌이 균등하게 발생할 때 유용합니다.

제곱 탐사법(Quadratic Probing) 은 선형 탐사법처럼 한 칸씩 찾는 것이 아닌 제곱으로 늘리면서 빈 버킷을 찾습니다. 보폭이 점점 늘어나기 때문에 선형 탐사법처럼 특정 영역에 값이 밀집되어 저장되어도 해당 영역을 빠르게 벗어날 수 있습니다. 하지만, 여러 값이 해시 함수로 같은 값을 갖게 될 경우 모두 같은 순서로 탐사할 수밖에 없어 비효율적인 상황이 발생할 수 있습니다. (2차 군집 현상)

이중 해싱(Double Hashing) 은 해시 충돌이 발생하는 경우, 보조 해시 함수를 사용하는 방법입니다. 해당 방법은 해시 충돌 가능성이 가장 작지만, 추가적인 보조 해시 함수에서 연산이 발생하기 때문에 다른 방식에 비해 많은 연산량이 요구됩니다.

추가 학습 자료를 공유합니다

참고 링크

[148] 디스크 접근 시간에 대해서 설명해주세요.

백엔드

디스크 접근 시간에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

일반적으로 단일-헤드 디스크 시스템에서 특정 데이터 블록(하나 이상의 섹터로 이루어짐)을 읽거나 쓰기 위해서는 헤드를 데이터가 존재하는 트랙으로 이동시키는 과정, 원하는 데이터가 저장된 섹터가 헤드 아래로 회전되어 올 때까지 기다리는 과정, 데이터를 전송하는 과정이 필요합니다. 그리고 이 모든 과정을 수행하는 데 걸리는 시간을 디스크 접근 시간(Disk Access Time) 이라고 합니다. 디스크 접근 시간은 탐색 시간, 회전 지연 시간, 데이터 전송 시간을 합쳐 계산할 수 있습니다.

  • 탐색 시간(Seek Time) 은 헤드를 데이터가 존재하는 트랙으로 이동시키는 과정에서 소요되는 시간을 의미합니다. 기계적인 이동이기 떄문에 시동 시간이 필요합니다. 액세스할 데이터들의 지역성이 높은 경우에는 헤드의 이동 거리가 짧아지기 때문에 평균 탐색 시간을 단축할 수 있습니다.

  • 회전 지연 시간(Rotational Latency) 은 원하는 데이터가 저장된 섹터가 헤드 아래로 회전되어 올 때까지 기다리는데 소요되는 시간을 의미합니다.

  • 데이터 전송 시간(Data Transfer Time) 데이터를 전송하는데 소요되는 시간을 의미합니다. 주로, 블록의 크기, 회전 속도, 트랙의 저장 밀도, 버스 전송률 및 제어기 내부 전자회로의 속도에 따라 결정됩니다.

하드 디스크에서 랜덤 액세스보다 순차 액세스가 더욱 빠른 이유가 무엇인가요? 😀

충분히 고민해 보신 다음에 펼쳐보세요!

랜덤 액세스의 경우, 디스크 다른 위치에 흩어져 있는 데이터 블록을 읽어야 하기 때문에 헤드를 여러 번 움직여야 하며, 각 블록에 접근할 때마다 회전 지연 시간을 기다려야 합니다. 이러한 기계적인 이동으로 발생하는 지연으로 인해 랜덤 액세스가 순차 액세스보다 상대적으로 느립니다. 반면, 순차 액세스는 데이터가 연속적인 경우 발생하는데요. 상대적으로 랜덤 액세스에 비해 기계적인 이동이 적기 때문에 빠른 속도를 보입니다.

추가 학습 자료를 공유합니다.

참고 링크

[149] URI, URL, URN의 차이점은 무엇인가요?

백엔드

URI, URL, URN의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

URI (Uniform Resource Identifier) 는 인터넷에서 자원을 식별하기 위한 문자열입니다. URI는 URL과 URN을 포함하는 상위 개념입니다. 즉, 특정 자원을 식별하기 위한 포괄적인 방법을 제공하며, 자원의 위치나 이름을 나타낼 수 있습니다.

URL (Uniform Resource Locator) 는 URI의 한 형태로, 인터넷상에서 자원의 위치를 나타내는 방식입니다. 자원이 어디에 있는지를 설명하는데 사용되며, 자원에 접근하기 위한 프로토콜을 포함합니다. 예를 들어, 웹페이지의 URL은 해당 페이지가 위치한 서버의 주소와 접근 방법(예: HTTP)을 포함합니다. ex) https://www.example.com/path/to/resource

URN (Uniform Resource Name) 은 URI의 또 다른 형태로, 자원의 위치와 상관없이 자원의 이름을 식별하는 방식입니다. 자원의 위치가 변하더라도 동일한 식별자를 유지할 수 있게 합니다. 특정 스키마를 따르며, 자원에 대한 영구적인 식별자를 제공합니다. ex) urn:isbn:0451450523 (특정 책의 ISBN 번호)

추가 학습 자료를 공유합니다.

참고 링크

[150] CPU 스케줄링에 대해서 설명해주세요.

백엔드

CPU 스케줄링에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

CPU 스케줄링은 운영체제가 프로세스들에게 공정하고 합리적으로 CPU 자원을 배분하는 것을 의미합니다. 만약 CPU 스케줄링이 없다면, 반드시 실행되어야 할 프로세스들이 실행되지 못할 수 있으며, 당장 급하지 않은 프로세스가 실행되는 등 무질서한 상태가 발생할 수 있습니다. CPU 스케줄링은 선점형과 비선점형 방식으로 구현할 수 있으며, 다양한 스케줄링 알고리즘이 존재합니다.

선점형 스케줄링과 비선점형은 각각 어떤 특징이 존재하나요? 🤓

  • 선점형 스케줄링(Preemptive Scheduling) 은 프로세스가 CPU를 사용하고 있더라도 운영체제가 프로세스로부터 자원을 강제로 빼앗아 다른 프로세스에 할당할 수 있는 방식입니다. 해당 방식은 응답 시간이 낮으며, 효율적이지만 컨텍스트 스위칭 오버헤드가 발생할 수 있으며 경쟁 상태가 발생할 수 있습니다.

  • 비선점형 스케줄링(Non-preemptive Scheduling) 은 하나의 프로세스가 자원을 사용하고 있다면 해당 프로세스가 종료되거나 스스로 대기 상태로 전환되기 전까지 다른 프로세스가 자원을 점유할 수 없는 스케줄링 방식을 의미합니다. 컨텍스트 스위칭 비용이 상대적으로 적지만, 응답 시간이 길 수 있습니다.

알고 계신 CPU 스케줄링 알고리즘에 대해서 간략하게 설명해 주세요. 😀

  • 선입 선처리 스케줄링(FCFS, First Come First Served Scheduling) 은 준비 큐에 삽입된 순서대로 프로세스들을 처리하는 비선점형 스케줄링 방식입니다. 공정해보이지만, 호위효과(Convoy Effect) 가 발생할 수 있다는 점에서 부작용이 존재합니다. 예를 들어, 준비 큐에 A(실행 시간 10ms), B(실행 시간 5ms), C(실행 시간 2ms)이 순서대로 존재할 때, C 프로세스는 2ms를 실행하기 위해서 15ms를 기다려야 합니다.

  • 최단 작업 우선 스케줄링(SJF, Shortest Job First Scheduling) 은 준비 큐에 삽입된 프로세스 들 중에서 CPU 이용 시간이 가장 짧은 프로세스부터 실행되는 비선점형 스케줄링 방식이에요. 호위효과를 방지하고, 평균 대기 시간이 짧다는 이점이 존재합니다. 하지만, CPU 실행 시간이 긴 프로세스는 기아 현상(Starvation) 발생할 가능성이 존재합니다. 기아 현상은 프로세스가 자원을 할당받지 못하고 무한정 준비 큐에 존재하게 되는 상황을 의미합니다.

  • 라운드 로빈 스케줄링(RR, Round Robin Scheduling) 은 선입 선처리 스케줄링에 타임 슬라이스(각 프로세스가 CPU를 사용할 수 있는 정해진 시간) 개념이 추가된 방식입니다. 라운드 로빈 방식은 정해진 타임 슬라이스만큼의 시간 동안 돌아가며 CPU를 이용한 선점형 스케줄링 방식입니다. 타임 슬라이스의 크기가 크다면 선입 선출과 비슷해져 호위 효과가 발생할 수 있으며, 너무 작은 경우에는 문맥 교환 비용이 커질 수 있습니다.

  • 최소 잔여 시간 우선 스케줄링(SRT, Shortest Remaininng Time Scheduling) 은 최단 작업 우선(SJF)에 선점형 스케줄링을 혼합한 방식입니다. 프로세스가 실행되는 도중 실행 시간이 짧은 프로세스가 추가되면 현재 실행되는 프로세스를 중단하고 해당 프로세스에게 CPU를 할당합니다.

  • 다단계 큐 스케줄링(Multilevel Queue Scheduling) 은 우선 순위별로 준비 큐를 여러 개 사용하는 방식입니다. 우선 순위가 가장 높은 큐에 있는 프로세스들을 먼처 처리하고, 우선순위가 가장 높은 큐가 비어 있다면, 그 다음 우선순위 큐의 프로세스들을 처리합니다. 해당 방식은 큐를 여러 개 두어서 프로세스 유형 별로 우선순위를 구분하여 실행하는 것이 편리해지며, 큐별로 타임 슬라이스를 지정하거나, 다른 스케줄링 알고리즘을 사용할 수 있습니다.

우선 순위가 존재하는 스케줄링은 기아 현상이 발생할 텐데, 어떻게 해결할 수 있나요? 🤔

최단 작업 우선(SJF), 최소 잔여 시간(SRT)이나 다단계 큐 스케줄링은 일종의 우선 순위 스케줄링이며, 말씀 주신 기아 현상이 발생할 수 있습니다. 기아 현상을 방지하기 위해서는 대표적으로 에이징 기법이 존재합니다. 에이징(Aging) 이란 오랫동안 대기한 프로세스의 우선순위를 점차 높이는 방식입니다.

예를 들어, 다단계 큐 스케줄링의 기아 현상 문제를 해결하기 위해서 에이징 기법이 적용된 다단계 피드백 큐 스케줄링(Multilevel Feedback Queue Scheduling) 방식이 존재하는데요. 해당 방식은 새로 준비 상태가 된 프로세스가 있다면, 우선순위가 가장 높은 우선순위 큐에 삽입되고 타임 슬라이스 동안 실행됩니다. 만약, 해당 큐에서 실행이 끝나지 않는다면 프로세스는 다음 우선순위 큐에 삽입됩니다.(결국 CPU를 오래 사용해야 하는 프로세스는 점차 우선순위가 낮아집니다.) 만약 우선순위가 낮은 큐에서 너무 오래 기다리고 있는 프로세스가 있다면 점차 우선순위가 높은 큐로 이동시키는 에이징 기법을 적용해 기아 현상을 예방합니다.

추가 학습 자료를 공유합니다.

참고 링크

[154] 시스템 콜이란 무엇인가요?

백엔드

시스템 콜이란 무엇인가요?

백엔드와 관련된 질문이에요.

운영체제는 사용자가 실행하는 프로그램이 하드웨어 자원에 직접 접근하는 것을 방지해 자원을 보호합니다. 왜냐하면, 프로그램이 CPU, 메모리, 하드 디스크에 마음대로 접근하고 조작할 수 있다면, 자원이 무질서하게 관리 될 수 있으며 한 프로그램의 실수가 전체 컴퓨터에 영향을 주기 때문입니다. 운영체제는 프로그램들이 자원에 접근하려 할 때 오직 자신을 통해서만 접근하도록 하여 자원을 보호합니다.

따라서, 프로그램은 자원에 접근하기 위해서 운영체제에게 도움을 요청해야 합니다. 그리고, 프로그램의 요청을 받은 운영체제는 응용 프로그램 대신 자원에 접근해 요청한 작업을 수행합니다. 이러한 과정은 이중 모드로 구현되는데요. 이중 모드(Dual Mode) 는 CPU가 명령을 실행하는 모드를 사용자 모드와 커널 모드로 구분하는 방식입니다.

  • 사용자 모드(User Mode) 는 운영체제 서비스를 제공받을 수 없는 실행 모드입니다. CPU가 해당 모드인 경우, 입출력 명령어와 같은 하드웨어 자원 접근 명령을 실행할 수 없습니다. 일반 프로그램은 기본적으로 사용자 모드로 실행됩니다.

  • 커널 모드(Kernel Mode) 는 운영체제 서비스를 제공받을 수 있는 실행 모드로 커널 영역의 코드를 실행할 수 있습니다. CPU가 커널 모드로 명령어를 실행하면 자원에 접근하는 명령어를 비롯한 모든 명령어를 실행할 수 있으며, 운영체제는 커널 모드로 실행됩니다.

사용자 모드로 실행되는 프로그램이 자원에 접근하는 운영체제 서비스를 제공받으려면 운영체제에 요청을 보내 커널 모드로 전환되어야 하는데요. 말씀 주신 시스템 콜(System Call) 은 이때 운영체제 서비스를 제공받기 위한 요청을 의미합니다. 시스템 콜은 일종의 소프트웨어 인터럽트입니다. 시스템 콜을 발생시키는 명령어가 실행된다면, CPU는 현재까지의 작업을 백업한 뒤에 커널 영역 내에 시스템 콜을 수행하는 인터럽트 서비스 루틴을 실행한 이후, 다시 기존 실행하고 있었던 프로그램으로 복귀하여 실행을 계속합니다.

추가 학습 자료를 공유합니다.

참고 링크

[155] JVM에서 GC 대상 객체를 판단하는 기준은 무엇인가요?

백엔드

JVM에서 GC 대상 객체를 판단하는 기준은 무엇인가요?

백엔드와 관련된 질문이에요.

GC(Garbage Collection)는 자바의 메모리 관리 방법의 하나이며, JVM의 힙 영역에서 동적으로 할당했던 메모리 중에서 필요 없어진 객체를 주기적으로 제거하는 것을 의미합니다. GC는 특정 객체가 사용 중인지 아닌지 판단하기 위해서 도달 가능성(Reachability) 라는 개념을 사용하는데요. 특정 객체에 대한 참조가 존재하면 도달할 수 있으며, 참조가 존재하지 않는 경우에 도달할 수 없는 상태로 간주합니다. 이때, 도달할 수 없다는 결론을 내린다면 해당 객체는 GC의 대상이 됩니다.

도달 가능성은 어떻게 판단하나요? 🤓

힙 영역에 있는 객체에 대한 참조는 4가지 케이스가 존재하는데요. 힙 내부 객체 간의 참조, 스택 영역의 변수에 의한 참조, JNI에 의해 생성된 객체에 대한 참조(네이티브 스택 영역), 메서드 영역의 정적 변수에 의한 참조가 이에 해당됩니다. 이때, 힙 내부 객체 간의 참조를 제외한 나머지를 Root Set이라고 합니다. Root Set으로부터 시작한 참조 사슬에 속한 객체들은 도달할 수 있는 객체이며, 이 참조 사슬과 무관한 객체들은 도달하기 어렵기 때문에 GC에 대상이 됩니다.

개발자가 GC 대상 판단에 관여할 수는 없나요? 🤔

Origin o = new Origin();
WeakReference<Origin> wo = new WeakReference<>(o);

자바에서는 java.lang.ref 패키지의 SoftReference, WeakReference 클래스를 통해 개발자가 GC 대상 판단에 일정 부분 관여할 수 있습니다. 해당 클래스들의 객체(reference object)는 원본 객체(referent)를 감싸서 생성하는데요. 이렇게 생성된 객체는 GC가 특별하게 취급합니다. SoftReference 객체에 감싸인 객체는 Root Set으로부터 참조가 없는 경우에, 남아있는 힙 메모리의 크기에 따라서 GC 여부가 결정됩니다. 반면, WeakReference 객체에 감싸인 객체는 Root Set으로부터 참조가 없는 경우, 바로 GC 대상이 됩니다.

추가 학습 자료를 공유합니다.

참고 링크

[156] 테스트 주도 개발이 무엇인가요?

백엔드

테스트 주도 개발이 무엇인가요?

백엔드와 관련된 질문이에요.

테스트 주도 개발(Test Driven Development) 은 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스입니다. 개발자는 먼저 요구사항을 검증하는 자동화된 테스트 케이스를 작성합니다. 그 이후에는 테스트 케이스를 통과하기 위한 최소한의 코드를 생성하고, 작성한 코드를 리팩토링하는 과정을 반복합니다.

테스트 주도 개발 사이클에는 다음과 같이 몇 가지 의식할 부분들이 존재하는데요.

  • 일단 간단하고, 해보기 쉬운 것을 먼저 시도합니다.
  • 실패하는 테스트를 통과하기 위해서는 최소한의 코드를 작성해야 합니다.
  • 테스트를 점점 구체화할수록 프로덕션 코드는 점점 범용적으로 됩니다. (커버 가능한 케이스가 점점 많아집니다.)
  • 실패하는 테스트가 있을 때만 프로덕션 코드를 작성합니다.
  • 실패를 나타내는 데 충분한 정도의 테스트만 작성합니다.

위와 같은 부분들을 의식하면서, 테스트 주도 개발 사이클을 반복하다 보면, 작성한 코드가 가지는 불안정성을 개선하여 생산성을 높일 수 있습니다. 또한, 테스트 가능하며 결합이 느슨한 시스템을 점진적으로 만들어 나갈 수 있습니다. 하지만, 테스트 주도 개발이 오히려 비효율적인 경우도 존재하기 때문에 다른 모든 기술과 마찬가지로 비판적으로 사고하는 것도 중요하다고 생각합니다.

추가 학습 자료를 공유합니다.

참고 링크

[157] 대칭키 및 비대칭키 암호화 방식에 대해서 설명해주세요.

백엔드

대칭키 및 비대칭키 암호화 방식에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

평문을 암호화하고 복호화하는 경우 키를 사용할 수 있는데요. 이때, 암복호화에 사용하는 키가 동일한 경우 대칭키 암호화(Symmetric Key Cryptography) 라고 하며, 암복호화에 사용하는 키가 서로 다른 경우를 비대칭키 암호화 혹은 공개키 암호화(Asymmetric Key Cryptography) 라고 합니다.

대칭키 암호화는 비대칭키 암호화에 비해서 속도가 빠르다고 알려져 있습니다. 하지만, 대칭키를 교환하는 과정에서 탈취 위험성이 존재할 수 있습니다. 또한, 대칭키 암호화 방식에서는 각 통신 참여자 쌍마다 다른 키가 필요할 수 있는데요. 이 경우 통신 대상이 많아질수록 대칭키의 수가 많아지므로 키 관리가 복잡해질 수 있습니다.

비대칭키 암호화에는 공개키와 개인키가 존재합니다. 일반적으로 이 방식에서 송신자는 수신자의 공개키를 이용해 암호화를 수행하고, 암호화된 데이터는 수신자에게 전달됩니다. 수신자에게 전달된 이후, 수신자는 개인키를 사용해 복호화를 수행합니다. 이 방식은 대칭키 암호화 방식에서 발생하는 키 교환 문제를 해결하지만, 상대적으로 대칭키 암호화에 비해 느린 것이 단점입니다.

비대칭키 암호화에서 개인키로 데이터를 암호화하고, 공개키로 복호화하는 경우도 존재하는데요. 이러한 방식은 암호화를 수행한 자에 대한 검증이나 서명을 위한 용도로 사용됩니다.

추가 학습 자료를 공유합니다.

참고 링크

[161] 클래스풀 IP 주소 체계에 대해서 설명해주세요.

백엔드

클래스풀 IP 주소 체계에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

클래스풀 주소 체계(Classful Addressing) 은 IP 주소를 규격화된 크기별로 구분시키는 방식입니다. IP 주소를 클래스(A,B,C 등)별로 규격화(유형화)시켜, 쉽게 식별할 수 있도록 합니다.

IPv4 주소는 4바이트(32비트)이며, 1바이트씩 끊어서 표기합니다. 이때 각 바이트를 옥텟이라고 부르며, 각 옥텟은 0부터 255까지의 숫자를 표현할 수 있어요. ex) 0.0.0.0 ~ 255.255.255.255

  • A 클래스는 초기 비트가 0(2진수)으로 시작하는 1옥텟을 네트워크 주소로 사용하며, 3옥텟을 호스트 주소로 사용합니다. 호스트 주소의 0과 255는 특수한 용도(전자는 네트워크 식별, 후자는 브로드캐스트 주소)로 사용되므로, A 클래스에는 2^7개의 네트워크 수와 2^24 - 2개의 호스트를 가질 수 있습니다.

  • B 클래스는 초기 비트가 10(2진수)으로 시작하는 2옥텟을 네트워크 주소로 사용하며, 2옥텟을 호스트 주소로 사용합니다. 따라서, B 클래스에는 2^14개의 네트워크와 2^16 - 2개의 호스트를 가질 수 있습니다.

  • C 클래스는 초기 비트가 110(2진수)으로 시작하는 3옥텟을 네트워크 주소로 사용하며, 1옥텟을 호스트 주소로 사용합니다. 따라서, C 클래스에는 2^21개의 네트워크와 2^8 - 2개의 호스트를 가질 수 있습니다.

클래스풀 주소 체계는 클래스별로 네트워크 크기가 고정되어 있기 때문에 다수의 IP 주소가 낭비될 수 있다는 한계점이 존재합니다. 예를 들어, 특정 조직에 컴퓨터가 255개라면 C 클래스 주소를 사용하지 못하고, B 클래스를 사용해야 하는데요. 이러한 상황에서 IP 주소의 낭비가 발생할 수 있습니다. 이러한 문제를 해결하고, 더욱 유동적인 방식으로 네트워크를 구획할 수 있도록 클래스리스 주소 체계가 등장했습니다.

클래스리스 주소 체계는 무엇인가요? 🤔

클래스리스 주소 체계는(Classless Addressing) 클래스가 아닌 서브넷 마스크를 이용해 네트워크 주소와 호스트 주소를 구분하는 IP 주소 체계입니다. 이때, 서브넷 마스크는 네트워크 구분을 위한 비트열을 의미합니다. 해당 비트열에서 네트워크 주소는 연속된 1(이진수), 호스트 주소는 연속된 0(이진수)으로 표현합니다. 특정 IP 주소와 서브넷 마스크를 비트연산을 수행하면, 네트워크 주소를 알아낼 수 있습니다. 예를 들어, 서브넷 마스크가 255.255.255.0, IP 주소가 168.168.168.168인 경우, 비트연산을 수행하면 네트워크 주소인 168.168.168.0 를 알아낼 수 있습니다. 이 경우, 1옥텟을 호스트 주소로 사용할 수 있습니다.

서브넷 마스크를 표기할 때는 CIDR(Classless Inter Domain Routing Notation) 표기법을 사용할 수 있는데요. IP 주소/서브넷 마스크 비트열의 1의 수 형식으로 표현합니다. IP 주소가 168.168.168.168이며, 서브넷 마스크가 255.255.255.0인 경우에는 168.168.168.168/24 와 같이 표기할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[162] 함수형 프로그래밍에 대해서 설명해주세요.

백엔드

함수형 프로그래밍에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

함수형 프로그래밍(Functional Programming) 은 객체지향 패러다임과 마찬가지로 하나의 프로그래밍 패러다임입니다. 객체지향 프로그래밍은 움직이는 부분을 캡슐화하여 코드의 이해를 도우며, 함수형 프로그래밍은 움직이는 부분을 최소화하여 코드 이해를 돕습니다. 이 둘은 상충하는 개념이 아니며, 함께 조화되어 사용될 수 있습니다. 함수를 합성하여 복잡한 프로그램을 쉽게 만들고, 부수 효과를 공통적인 방법으로 추상화하는 것이 함수형 프로그래밍의 핵심 개념입니다.

부수 효과(Side Effect) 는 값을 반환하는 것 이외에 부수적으로 발생하는 일들을 의미해요. 변수를 수정하거나, I/O 작업 등이 해당됩니다. 사람이 한 번에 인지할 수 있는 작업은 한정되어 있습니다. 부수 효과가 많은 코드는 이해하고 결과를 예측하기 어려울 수 있습니다. 함수형 프로그래밍은 부수 효과를 추상화하고 분리하여 코드를 이해하기 쉽게 만들 수 있습니다.

함수를 합성한다는 게 무슨 의미인지 궁금해요. 🤔

특정 함수의 공역이 다른 함수의 정의역과 일치하는 경우, 두 함수를 이어서 새로운 함수를 만드는 연산을 함수 합성(Function Composition) 이라고 해요. 프로그래밍에서 공역과 정의역은 타입에 해당됩니다. 쉽게 말씀드리자면, A 함수에서 int 타입을 반환하고, B 함수에서 int 타입을 인자로 받는다면, B(A())와 같은 형태로 호출하는 것을 함수 합성이라고 합니다.

함수형 프로그래밍은 함수를 합성하여 복잡한 프로그램을 쉽게 만듭니다. 함수는 입력이 들어오면 부수 효과의 발생과 함께 결과를 반환할 수 있습니다. 하지만, 부수 효과가 존재하는 함수는 합성하기가 까다롭습니다.

// 부수효과가 존재하는 sum 함수는 다른 함수와 합성하기 까다로울 수 있다.
//     1. 다른 함수에서 1부터 1,000까지 더하는 함수가 필요하다면?
//     2. 다른 함수에서 1부터 100까지 곱하는 함수가 필요하다면?
int sum() {
    int sum = 0;
    for(int i = 1; i <= 100; i++) {
        sum += i;
    }

    return sum;
}

순수 함수(Pure Function) 은 같은 입력이 들어오면, 항상 같은 값을 반환하는 함수를 의미하는데요. 순수 함수는 부수효과를 일으키지 않습니다. 함수형 프로그래밍에서 함수 합성은 순수 함수로 이뤄집니다.

class FunctionCompositionTest {

    @Test
    @DisplayName("함수 합성")
    void fp() {
        System.out.println(sum()); 
        System.out.println(factorial(10));
    }

    // 1부터 100까지의 합
    private int sum() {
        return loop((a, b) -> a + b, 0, range(1, 100));
    }

    // 팩토리얼
    private int factorial(int n) {
        return loop((a, b) -> a * b, 1, range(1, n));
    }

    private int loop(BiFunction<Integer, Integer, Integer> fn, int sum, Queue<Integer> queue) {
        if (queue.isEmpty()) {
            return sum;
        }

        return loop(fn, fn.apply(sum, queue.poll()), queue);
    }

    private Queue<Integer> range(Integer start, Integer to) {
        return IntStream.rangeClosed(start, to)
                .boxed()
                .collect(Collectors.toCollection(LinkedList::new));
    }
}

추가 학습 자료를 공유합니다.

참고 링크

[163] 연결 리스트에 대해서 설명해주세요.

백엔드

연결 리스트에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

연결 리스트(Linked List) 는 리스트 내의 요소(노드)들을 포인터로 연결하여 관리하는 선형 자료구조입니다. 각 노드는 데이터와 다음 요소에 대한 포인터를 가지고 있는데요. 이때, 첫 번째 노드를 HEAD, 마지막 노드를 TAIL 이라고 합니다. 연결 리스트는 메모리가 허용하는 한 요소를 계속 삽입할 수 있으며, 시각 복잡도는 탐색에는 O(n), 노드 삽입과 삭제는 O(1)라는 특징을 가지고 있습니다. 해당 아이디어로 파생된 자료구조는 단일 연결 리스트(Singly Linked List), 이중 연결 리스트(Doubly Linked List, Circular Linked List)가 존재합니다.

배열은 순차적인 데이터가 들어가기 때문에 메모리 영역을 연속적으로 사용합니다. 반면, 연결 리스트는 메모리 공간에 흩어져서 존재한다는 점에서 배열과 차이가 있습니다.

단일 연결 리스트의 탐색, 추가, 삭제에 대해서 더욱 자세히 설명해 주세요. 🤔

class SinglyLinkedList {

    public Node head;
    public Node tail;

    public Node insert(int newValue) {
        Node newNode = new Node(null, newValue);
        if (head == null) {
            head = newNode;
        } else {
            tail.next = newNode;
        }
        tail = newNode;

        return newNode;
    }

    public Node find(int findValue) {
        Node currentNode = head;
        while (currentNode.value != findValue) {
            currentNode = currentNode.next;
        }

        return currentNode;
    }

    public void appendNext(Node prevNode, int value) {
        prevNode.next = new Node(prevNode.next, value);
    }

    public void deleteNext(Node prevNode) {
        if (prevNode.next != null) {
            prevNode.next = prevNode.next.next;
        }
    }
}

단일 연결 리스트에서 탐색은 최악의 경우, 시간 복잡도는 O(n)입니다. 왜냐하면, 노드를 탐색하기 위해서 HEAD의 포인터부터 데이터를 찾을 때까지 다음 요소를 반복적으로 탐색하기 때문입니다.

삽입의 경우, 삽입될 위치 이전에 존재하는 노드와 신규 노드의 포인터를 조작하면 됩니다. 가령, 3번 위치에 신규 노드를 삽입해야 한다면 2번 위치에 있는 노드의 포인터를 신규 노드를 가리키도록 하고, 신규 노드의 포인터는 기존 3번 노드의 위치를 가리키도록 하면 됩니다. 삭제는 삭제할 노드의 이전 노드가 삭제 대상 노드의 다음 노드를 가리키도록 수정하고, 삭제 대상 노드를 메모리에서 지우면 됩니다. 삽입과 삭제 연산 자체의 경우 시간 복잡도는 O(1)이지만, 탐색이 필요한 경우 선형 시간이 걸립니다.

추가 학습 자료를 공유합니다.

참고 링크

[164] NAT 기능을 사용하는 이유를 알고 계신가요?

백엔드

NAT 기능을 사용하는 이유를 알고 계신가요?

백엔드와 관련된 질문이에요.

IP 주소는 공인 IP 주소(Public IP Address)사설 IP 주소(Private IP Address) 가 존재합니다. 공인 IP 주소는 고유하며, 사설 IP 주소는 고유하지 않고 특정 사설 네트워크에서만 사용됩니다. 이때, 사설 네트워크는 외부 네트워크에 공개되지 않은 네트워크를 의미합니다. 사설 IP는 일반적으로 라우터가 할당하며, 할당받은 사설 IP 주소는 사설 네트워크 상에서만 유효합니다.

사설 IP 주소만을 가지고 외부 네트워크와 통신하기 어렵습니다. 이 문제를 해결하기 위해서 라우터 혹은 공유기의 NAT(Network Address Translation) 기능을 사용하는데요. NAT는 IP 주소를 변환하는 기술입니다. 해당 기능을 사용하면, 사설 IP 주소를 외부 네트워크에 사용되는 공인 IP 주소로 변환하여 외부 네트워크와 통신할 수 있습니다.

예를 들어, 사설 네트워크에서 출발한 패킷에 존재하는 사설 IP 주소(송신지)가 라우터를 거쳐 공인 IP로 변경됩니다. 그리고, 외부 네트워크로부터 출발한 패킷에 존재하는 공인 IP 주소(수신지)는 NAT 기능을 통해서, 사설 IP 주소로 변경되고 사설 네트워크 속 호스트에게 전달됩니다. NAT에서 주소 변환을 하기 위해서 내부적으로 공인 IP 주소와 사설 IP 주소가 대응되어 있는 NAT 변환 테이블을 사용합니다.

NAT 변환 테이블에서 공인 IP 주소와 사설 IP 주소는 일대일로 대응되어 있나요? 🤔

일대일 대응이라면 사설 IP 주소만큼 공인 IP가 필요하기 때문에 한계가 있습니다. 따라서, 일반적으로 공인 IP와 사설 IP가 일대일로 대응하지 않고, NAPT(Network Address Port Translation) 이라는 포트 기반의 NAT를 사용합니다. 해당 기능에서는 NAPT 변환 테이블에 IP 주소 쌍과 함께 포트도 함께 기록됩니다. 다음은 NAPT 변환 테이블의 예시입니다.

Private IPPrivate PortPublic IPPublic Port
192.168.10.280001.2.3.48001
192.168.10.380001.2.3.48002

추가 학습 자료를 공유합니다.

참고 링크

[168] CSRF 공격에 대해서 설명해주세요.

백엔드

CSRF 공격에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

사이트 간 요청 위조(Cross-site Request Forgery, CSRF) 공격은 사용자가 자신의 의지와 상관없이 공격자가 의도한 행위를 특정 웹사이트에 요청하도록 하는 것을 의미합니다.

예를 들어, 특정 사용자가 매일메일 서비스에서 로그인을 수행하고 서버는 해당 사용자에 대한 세션 ID를 Set-Cookie 헤더에 담아서 응답합니다. 그리고, 클라이언트는 쿠키를 저장하고 요청마다 자동으로 전달합니다.

이러한 사용자를 대상으로 공격자는 악성 스크립트가 담긴 페이지에 접속하도록 유도합니다. 유도하는 방법은 다양한데요. 악성 스크립트가 포함된 메일이나 게시글을 작성하거나, 악성 스크립트가 포함된 공격자 사이트 접속 링크를 전달하는 것이 대표적입니다.

사용자가 악성 스크립트가 포함된 페이지에 접속하게 되면 악성 스크립트가 실행됩니다. 이 스크립트는 사용자의 의도와 상관없는 특정한 요청(결제, 비밀번호 변경)을 공격 대상 서버로 보내도록 구현되어 있습니다. 해당 요청은 브라우저에 의해서 자동으로 쿠키에 저장된 세션 ID가 함께 전달됩니다.

예를 들어, 공격자가 만든 사이트 내부에는 다음과 같은 태그가 존재할 수 있습니다.

<img src ="https://maeil-mail.com/member/changePassword?newValue=1234" />

공격자 사이트에 방문한 사용자는 자신의 의지와 무관하게 img 태그로 인해 세션 ID가 포함된 쿠키와 함께 비밀번호 변경 요청을 매일메일 서버로 전달합니다.

CSRF 공격은 어떻게 방어할 수 있나요? 🤓

교차 출처인 상황에서의 요청을 막는 방식으로 CSRF를 방어할 수 있습니다.

첫 번째로, HTTP 헤더 중에 하나인 Referer 요청 헤더를 사용하는 방법이 있습니다. Referer 요청 헤더로 현재 요청을 보낸 페이지의 주소를 알 수 있는데요. 해당 주소와 Host(서버의 도메인 이름) 헤더를 비교하여 다른 경우, 예외를 발생시킬 수 있습니다. 하지만, Referer 요청 헤더는 조작될 수 있다는 점에서 한계가 있습니다.

두 번째로, 템플릿 엔진 기술을(JSP, 타임리프, Pug, Ejs 등) 사용하고 있는 경우라면 CSRF 토큰 방법을 사용해 볼 수 있습니다. 페이지를 생성하기 이전에 사용자 세션에 임의의 CSRF 토큰을 저장합니다. 그리고, 특정 API 요청에 대한 제출 폼을 생성할 때 해당 CSRF 토큰값이 설정된 input 태그를 추가합니다.

<input type = "hidden" name = "csrf_token" value = "csrf_token_12341234" />

실제로 요청이 전달될 때, 해당 input 태그의 CSRF 토큰과 사용자 세션 내부에 존재하는 CSRF 토큰의 일치 여부를 판단하여, CSRF 공격에 대해 방어할 수 있습니다.

이외에도 SameSite 쿠키를 사용하여 크로스 사이트에 대한 쿠키 전송을 제어하거나, 브라우저의 SOP(Same Origin Policy) 정책을 사용하고, CORS 설정으로 교차 출처 접근을 일부분 허용하는 방식으로도 CSRF 공격을 방어할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[171] 트랜잭셔널 아웃박스 패턴에 대해서 설명해주세요.

백엔드

트랜잭셔널 아웃박스 패턴에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern) 은 분산 시스템에서 단일 작업에 데이터베이스 쓰기 작업과 메시지 혹은 이벤트 발행이 모두 포함된 경우 발생하는 이중 쓰기 문제를 해결하기 위해서 사용할 수 있습니다. 예를 들어, 다음과 같은 코드가 존재한다고 가정하겠습니다.


@Transactional
public void propagateSample() {
   Product product = new Product("신규 상품");
   productRepository.save(product);
   eventPublisher.propagate(new NewProductEvent(product.getId()));
}

위와 같이 신규 상품을 생성하고, 이벤트를 발행하는 코드를 트랜잭션 AOP 로직이 적용된 간단한 의사코드로 작성한다면 다음과 같을 텐데요.

public void doInTransaction() {
   try {
     transaction.begin();
     Product product = new Product("신규 상품");
     productRepository.save(product);
     eventPublisher.propagate(new NewProductEvent(product.getId()));
     transaction.commit();
   } catch(Exception e) {
     transaction.rollback();
   }
}

위와 같은 코드에서 트랜잭션은 커밋됐지만 이벤트 발행은 실패할 수 있으며, 반대로 이벤트 발행은 성공했지만 커밋 연산이 모종의 이유로 실패하여 트랜잭션은 롤백 될 수 있습니다. 이러한 이중 쓰기로 인해 발생하는 문제는 전체 서비스의 데이터 정합성에 문제를 만들거나 서비스 장애로 이어질 수 있습니다. 이 문제를 해결하기 위해서 서비스 로직의 실행과 이벤트 발행을 원자적으로 함께 수행하는 것을 트랜잭셔널 메시징(Transactional Messaing) 이라고 하며, 트랜잭셔널 아웃박스 패턴의 사용 이유기도 합니다.

@Transactional
public void propagateSample() {
   Product product = new Product("신규 상품");
   productRepository.save(product);
   productOutboxRepository.save(new ProductEvent(product.getId()));
}

Product 발행 이벤트를 저장하기 위한 Outbox 테이블을 만들고, 같은 트랜잭션 내부에서 이벤트를 저장합니다. 원자성을 보장해 주는 데이터베이스 트랜잭션을 사용하기 때문에 이벤트와 신규 상품은 모두 저장되거나, 모두 저장에 실패합니다. 그리고, 별도의 프로세스가 Outbox 테이블에 저장된 레코드들을 주기적으로 폴링하여 외부 시스템에 성공할 때까지 이벤트를 발행하는 것이 트랜잭셔널 아웃 박스 패턴의 기본적인 구현 방식입니다.

추가 학습 자료를 공유합니다.

참고 링크

[174] JWT 특징과 주의 사항을 설명해주세요.

백엔드

JWT 특징과 주의 사항을 설명해주세요.

백엔드와 관련된 질문이에요.

JWT(Json Web Token) 은 통신 정보를 JSON 형식을 사용하여 안전하게 전송하기 위해 사용됩니다. JWT는 토큰 자체에 정보가 포함되어 있는 클레임 기반 토큰입니다. 일반적인 애플리케이션에서 JWT는 주로 인증과 인가를 구현하기 위해 사용됩니다. JWT는 헤더, 페이로드, 시그니처로 구분됩니다. 헤더에는 토큰의 암호화 알고리즘이나 타입을 가지며, 페이로드에는 데이터(만료일, 사용자 정보 등)을 가집니다. 시그니처는 헤더와 페이로드가 변조되지 않았는지 판단하기 위해 사용되는데요. 헤더와 페이로드를 비밀 키를 사용하여 헤더에 명시된 암호화 알고리즘으로 암호화하여 시그니처가 만들어집니다.

JWT를 사용하여 인가를 구현하는 경우, 클레임 기반 토큰의 특성 덕분에 세션 기반 인증에 비해서 사용자 정보를 조회하기 위한 추가적인 작업이 필요하지 않습니다. 또한, 서버가 상태를 관리하지 않기 때문에 서버가 이중화된 환경에서도 사용자의 로그인 정보를 일관성 있게 관리할 수 있습니다. (세션 불일치 문제가 발생하지 않습니다.)

하지만 JWT를 사용하는 경우, 몇 가지 주의 사항이 존재합니다.

  • JWT는 디코딩이 쉽습니다. Base64로 디코딩하면 페이로드를 확인할 수 있습니다. 따라서, 민감한 정보를 담는 것에 유의해야 합니다.
  • 시크릿 키의 복잡도가 낮은 경우, 무작위 대입 공격(Brute force Attack)에 노출될 수 있습니다. 따라서, 강력한 시크릿 키를 사용하는 것이 권장됩니다.
  • 시크릿 키는 유출되면 안되기 때문에 안전한 공간에 관리해야합니다.
  • JWT 탈취에 유의해야 합니다. 이를 위해서 JWT 저장 공간, 리프레시 토큰 도입 여부, Refresh Token Rotation, 탈취 감지 및 대응에 대해서 고민이 필요합니다.
  • 토큰의 잦은 갱신이 사용자 경험을 저해하는지 고려해야 합니다. 예를 들어, 사용자가 게시글을 3시간 동안 작성하고 제출했지만 JWT가 만료되어 사용자가 작성한 글은 사라질 수 있습니다. 이를 해결하기 위해서 슬라이딩 세션과 같은 전략을 고민해 볼 수 있습니다.
  • JWT none 알고리즘 공격을 유의해야 합니다. 공격자가 토큰의 헤더에 명시된 알고리즘을 none으로 변경하여, 페이로드가 변조되어도 시그니처 검증을 우회할 수 있습니다. 이를 해결하기 위해서 none 알고리즘 공격을 예방한 라이브러리를 사용하거나, none 알고리즘과 같이 약한 알고리즘에 대해서 필터링하는 등 주의가 필요합니다.

추가 학습 자료를 공유합니다.

참고 링크

[175] 단일 장애 지점(SPOF)이란 무엇인가요?

백엔드

단일 장애 지점(SPOF)이란 무엇인가요?

백엔드와 관련된 질문이에요.

단일 장애 지점(Single Point of Failure, SPOF) 이란 전체 시스템에서 제대로 동작하지 않는 경우, 전체 시스템이 중단되는 특정 구성 요소를 의미합니다. 서버와 네트워크, 프로그램 등 정보 시스템이 정상적으로 사용할 수 있는 정도를 가용성(Availability) 이라고 합니다. 가용성은 정상적인 사용 시간(Uptime)을 전체 사용 시간(Uptime+Downtime)으로 나누어 구할 수 있습니다. 이때, 가용성이 99.999% 처럼 높은 경우에 고가용성(High Availability, HA) 이라 합니다. 시스템이 고가용성을 만족하기 위해서는 SPOF를 식별하고, 개선하는 작업이 필요합니다.

다음과 같은 상황에서 SPOF를 식별하고 개선해 주세요. 🤔

1. API Server 1대를 운용합니다.
2. DB는 Master Replica 구성을 사용합니다.
3. Master DB는 1대이며, 3대의 Replica DB를 사용하고 있습니다. (Failover는 지원됩니다.)

현재 서버는 단일 서버로 구성되어 있습니다. OOM(Out Of Memory), 네트워크 장애, 자원 고갈, 하드웨어 장애와 같은 문제가 발생할 경우 서버의 고장이 전체 시스템의 고장으로 이어질 수 있습니다.

이 문제를 개선하기 위해서 서버를 이중화하고, 로드 밸런서를 사용할 수 있습니다. 이중화는 동일한 애플리케이션을 여러 서버에 배포하여 한 서버가 다운되더라도 다른 서버가 서비스를 제공할 수 있도록 합니다. 로드 밸런서는 서버에 들어오는 트래픽을 여러 서버로 분산시켜주는 역할을 합니다. 만약, 특정 서버가 고장나는 경우에는 로드 밸런서에서 해당 서버로 향하는 요청을 다른 정상 서버로 전달합니다.

서버를 이중화할 때 점검해야 하는 부분들은 무엇인가요? 😀

서버를 이중화할 때 점검해야할 지점들은 애플리케이션 특성에 따라 상이합니다. 대표적으로 점검해야할 부분들은 동시성 문제, 세션 불일치 문제, 로그 및 메트릭 수집, 로드 밸런싱 알고리즘, 배포 등이 있습니다.

  • 애플리케이션 내부에서 동시성 문제를 해결하기 위한 코드가 이중화 환경에서도 안전하게 작동하는지 점검해볼 필요성이 있습니다. 예를 들어, 단일 서버인 경우에는 자바의 synchronized, ReentrantLock을 사용하여 멀티 스레드 환경에서 동시성 문제를 해결 할 수 있었습니다. 하지만, 이중화된 환경에서는 해당 방법들이 제대로 동작하지 않을 가능성이 매우 높습니다. 이 경우, 분산 잠금이나 DB 잠금을 사용하는 것이 적절할 수 있습니다.
  • 만약 서버에서 세션 기반 인증을 사용하고 있다면, 세션 불일치 문제를 겪을 수 있습니다. 이 경우, 세션 클러스터링이나 스티키 세션, 토큰 기반 인증, 외부 세션 저장소 등을 고려해야합니다.
  • 서버에서 생성되는 로그와 메트릭 데이터가 여러 서버에 걸쳐서 쌓이게 됩니다. 해당 데이터를 시각화하여 한눈에 확인하기 위해서는 각 서버에서 발생되는 데이터를 수집 및 통합하여 관리할 필요성이 생깁니다.
  • 적절한 로드 밸런싱 알고리즘을 선택해야할 필요성이 있습니다. 비효율적인 로드 밸런싱 알고리즘을 사용한다면, 한 서버에만 요청이 몰려서 이중화와 로드 밸런싱을 적용한 의미가 퇴색될 수 있습니다.
  • 서버 배포에 대해서 고민해볼 필요성이 있습니다. 서버가 늘어날 수록, 동일한 서비스의 다양한 버전이 운영될 수 있으며, 배포 시간이 증가하거나 배포 중 장애에 대한 대응이 복잡해질 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[176] 정적 IP 주소 할당 방식과 동적 IP 주소 할당 방식의 차이점을 설명해주세요.

백엔드

정적 IP 주소 할당 방식과 동적 IP 주소 할당 방식의 차이점을 설명해주세요.

백엔드와 관련된 질문이에요.

네트워크에서 호스트에게 IP를 할당하는 방식은 크게 정적 할당 방식과 동적 할당 방식이 존재합니다.

정적 할당 방식은 호스트에게 IP를 할당할 때 수동으로 설정하는 것을 의미합니다. 일반적으로 정적으로 IP를 할당하기 위해서는 부여하고자 하는 IP 주소, 자신의 네트워크의 서브넷마스크, 자신의 게이트웨이, DNS 주소가 필요합니다. 만약, IP 주소를 정적으로만 할당한다면 호스트의 수가 많아질수록 IP 할당이 번거로워질 수 있으며, 중복 IP를 입력하는 등 실수를 유발할 수 있습니다. 이러한 경우에 동적 할당 방식을 사용할 수 있는데요.

동적 할당은 호스트 IP를 자동으로 할당하는 방식이라는 점에서 정적 할당 방식과 차이가 있습니다. 동적 할당 방식은 주로 DHCP(Dynamic Host Configuration Protocol) 을 사용합니다. 동적 할당 방식은 DHCP를 이용해 현재 사용하지 않는 IP를 호스트에게 임대해줍니다. 따라서, 동적 할당 방식을 사용하는 경우에는 IP 주소가 고정적이지 않으며, 바뀔 가능성이 존재합니다. 또한, IPv4에 대한 DHCP는 DHCPv4, IPv6에 대한 DHCP는 DHCPv6가 존재합니다.

DHCP를 이용한 IP 주소 할당 과정은 어떻게 되나요? 🤔

DHCP를 이용한 동적 할당 방식은 호스트와 DHCP 서버(일반적으로 라우터)간에 통신으로 이루어집니다. 크게 4가지 단계로 나누어 IP 주소를 할당합니다. (Discover, Offer, Request, Acknowledgment)

  • Discover 단계에서 호스트는 Discover 메시지를 브로드캐스팅하여 DHCP 서버를 찾습니다.
  • Offer 단계에서 DHCP 서버는 Offer 메시지를 호스트에게 전송합니다. Offer 메시지에는 호스트에게 할당해 줄 IP 주소와 임대 기간이 포함되어 있습니다.
  • Request 단계에서 호스트는 DHCP Offer 메시지에 대한 응답을 수행합니다. 호스트는 Request Message를 브로드캐스팅합니다.
  • Acknowledgment 단계에서 DHCP 서버는 ACK 메시지를 호스트에게 전송하여 IP 임대를 승인합니다.

위 과정이 모두 끝난 이후 클라이언트는 할당받은 IP 주소를 자신의 IP 주소로 설정하고 임대 기간 동안 사용할 수 있습니다. 임대 기한이 만료된 경우 DHCP 과정을 다시 반복해야 하지만, DHCP 임대 갱신(DHCP Lease Renewal) 을 통해서 임대 기간을 연장할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[177] 전략 패턴에 대해서 설명해주세요.

백엔드

전략 패턴에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

전략 패턴(Strategy Pattern) 은 객체의 행위를 동적으로 변경하고 싶은 경우, 코드를 직접 수정하는 것이 아닌 추상화된 전략의 구현만을 바꿔 객체의 행위를 변경하는 디자인 패턴입니다. 자바 언어의 요소와 함께 설명해 드리자면, 객체의 행위를 Interface로 정의하고, Interface의 메서드를 구현하는 구현체들을 주입하는 것이 전략 패턴의 대표적인 형태입니다.


class Car {

    private final MoveStrategy strategy;
    private final int position;
   
    public Car(MoveStrategy strategy, int position) {
        this.strategy = strategy;
        this.position = position;
    }

    public Car move(int input) {
        if(strategy.isMovable(input)) {
            return new Car(strategy, car + 1);
        }

        return this;
    }
}

interface MoveStrategy {
    boolean isMovable(int input);
}

class EvenNumberMoveStrategy implements MoveStrategy {

    @Override
    public boolean isMovable(int input) {
        return (input % 2) == 0;
    }
}

class OddNumberMoveStrategy implements MoveStrategy { ... }

class PrimeNumberMoceStrategy implements MoveStrategy { ... }

주어진 숫자에 따라서 자동차의 움직임을 결정하는 요구사항이 존재하는 경우, 위 예시처럼 MoveStrategy 타입 필드를 선언하고 외부에서 이를 구현한 전략을 주입받도록 구현하면 유연하게 자동차의 움직임 전략을 교체할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[181] 스프링 트랜잭션 AOP 동작 흐름에 대해서 설명해주세요.

백엔드

스프링 트랜잭션 AOP 동작 흐름에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

@Transactional어노테이션을 사용한 선언적 트랜잭션 관리(Declarative Transaction Management)의 전체 흐름에는 크게 3가지 요소가 등장합니다. 트랜잭션 매니저, 트랜잭션 AOP 프록시, 트랜잭션 동기화 매니저가 이에 해당됩니다.

클라이언트 코드로부터 요청이 들어오면 트랜잭션 AOP 프록시가 트랜잭션 매니저를 획득하고, 트랜잭션을 시작하기 위해서 트랜잭션 매니저에게 요청합니다. 트랜잭션 시작 요청 받은 트랜잭션 매니저는 데이터소스를 통해 커넥션을 받아오고 트랜잭션을 시작합니다. 그리고, 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 동기화 매니저에 보관합니다. 이후 트랜잭션이 종료되는 경우 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관한 커넥션을 가져와 트랜잭션을 종료하고 커넥션을 반환하거나 종료합니다.

트랜잭션 매니저와 트랜잭션 동기화 매니저가 무엇인가요? 🤔

JDBC를 사용한 트랜잭션 관리 코드와 JPA를 사용한 트랜잭션의 양상이 다릅니다. 스프링은 개발자가 트랜잭션에 대한 구현 세부 사항을 신경 쓰지 않도록 트랜잭션 추상화인 PlatformTransactionManager를 제공합니다. 개발자는 상황에 맞게 DataSourceTransactionManager, JpaTransactionManager를 사용할 수 있으며, 이를 트랜잭션 매니저(Transaction Manager) 라고 부릅니다.

서비스 로직에서 여러 서비스 로직을 호출할 수 있고, 데이터 접근 로직을 호출할 수도 있습니다. 이때 트랜잭션을 유지하기 위해서는 해당 트랜잭션을 시작한 커넥션이 여러 코드에 걸쳐 필요하게 됩니다. 트랜잭션 동기화 매니저(Transaction Syncronization Manager) 는 이를 도와줍니다. 만약, 트랜잭션 동기화 매니저가 없다면, 다른 코드의 메서드를 호출할 때마다 커넥션을 인자로 넘겨줘야 하는 문제가 발생합니다.

추가 학습 자료를 공유합니다.

참고 링크

[183] 의존성 주입이란 무엇인가요?

백엔드

의존성 주입이란 무엇인가요?

백엔드와 관련된 질문이에요.

A 객체가 어떤 작업을 수행하기 위해 B 객체를 필요로 하는 경우에 두 객체 사이에 의존성이 존재한다고 표현합니다. 이때, A 객체가 아닌 외부의 C 객체가 B를 생성한 뒤에 이를 전달해서 의존성을 해결하는 방법을 의존성 주입(Dependency Injection) 이라고 합니다.

유연하고 재사용할 수 있는 설계를 만들기 위해서는 코드의 변경 없이 다양한 실행 구조를 만들 수 있어야 합니다. 의존성 주입은 이를 돕습니다. 예를 들어, A 객체 내부에서 B를 직접 생성하는 경우에는 B에 대한 결합도가 높아집니다. 반면, B에 대한 생성 책임을 C에게 위임하고, C가 A에게 다시 전달해 주는 방식(의존성 주입)을 통해서 A는 B에 대한 결합도를 낮추고 유연한 설계를 만들 수 있습니다.

의존성 주입에는 어떤 방식이 있고, 각각 언제 사용할 수 있나요? 😀

의존성 주입은 주입 받는 위치에 따라서 생성자 주입(constructor injection), setter 주입(setter injection), 메서드 주입(method injection) 으로 나뉩니다.

실행할때마다 의존 대상이 매번 달라지는 것처럼 일시적인 의존이 필요한 경우에는, 메서드 주입을 사용할 수 있습니다. 반면, 동일한 의존이 필요한 경우에는 생성자 주입이나 setter 주입을 사용할 수 있습니다. 이때, setter 주입만을 사용한다면 객체가 일시적으로 불완전한 상태일 수 있으니 생성자 주입과 함께 사용하는 것도 좋은 방법일 수 있습니다. 개인적으로 생성자 주입을 가장 선호하는 편인데요. 정답이 있는 영역이 아니기에 팀 내부에서 결정된 합의를 따르는 것이 가장 좋은 방식 이라고 생각합니다.

추가 학습 자료를 공유합니다.

참고 링크

[185] 코드 커버리지에 대해서 설명해주세요.

백엔드

코드 커버리지에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

테스트 케이스들이 프로덕션 코드를 실행한 정도를 나타낸 것을 코드 커버리지(Code Coverage) 라고 합니다.

코드 커버리지는 측정하는 기준에 따라서 크게 구문 커버리지(Statement Coverage), 조건 커버리지(Condition Coverage), 결정 커버리지(Decision Coverage) 로 나뉩니다.

구문 커버리지는 라인 커버리지라고도 불립니다. 단순히 프로덕션 코드의 라인이 실행된 것을 확인합니다. 예를 들어, 5줄의 코드를 포함하는 A 메서드를 테스트했는데, 5줄 모두 실행된 경우 구문 커버리지는 100%가 됩니다.

결정 커버리지는 브랜치 커버리지라고도 불립니다. 이는 프로덕션 코드에 모든 조건식이 참이거나 거짓으로 평가되는 케이스가 최소 한 번씩 실행되는 것을 판단합니다. 예를 들어 아래와 같은 코드가 존재했을 때, 조건식이 참과 거짓으로 평가시킬 수 있는 테스트 케이스를 작성해야 결정 커버리지 기준을 만족합니다. 예를 들어, productionCode(1, 1), productionCode(0, 1)인 경우, 결정 커버리지를 만족합니다. 결정 커버리지는 코드 내에서 실행 흐름이 분기되는 모든 경로를 테스트하는 것을 목표로합니다.

public void productionCode(int a, int b) {
    if(a > 0 && b > 0) { // 조건식
    }
}

조건 커버리지는 메서드 내부의 모든 조건식이 참과 거짓으로 모두 평가되는 것을 의미합니다. 예를 들어, 위 코드에서 결정 커버리지는 a > 0 && b > 0을 참과 거짓으로 평가할 수 있는 케이스가 있어야 하는 반면에, 조건 커버리지는 각 a > 0, b > 0이 참과 거짓으로 평가되는지 확인합니다. productionCode(1, 0), productionCode(0, 1)를 입력하는 상황처럼 조건 커버리지는 만족하여도 다른 커버리지는 만족하지 못할 수 있는 상황도 존재합니다.

커버리지가 높다고 무조건 좋을까요? 🤔

커버리지가 높다는 것은 코드의 일부가 테스트에 의해 실행되어 검증되었다는 것을 의미합니다. 높은 커버리지는 일반적으로 코드의 안정성과 신뢰성을 높일 수 있지만, 이것이 무조건 항상 좋다고 말할 수는 없습니다.

또한, 커버리지가 높다고 해서 모든 버그를 찾아낼 수 있는 것은 아닙니다. 커버리지가 높더라도 테스트 케이스가 부족하거나 부적절하게 작성되었다면 여전히 중요한 버그가 발생할 수 있습니다. 또한, 모든 코드를 테스트하는 것이 현실적이지 않을 수도 있습니다. 특히 예외 상황이나 경계 조건을 모두 다루기는 어려울 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[186] CI/CD 파이프라인에 대해서 설명해주세요.

백엔드

CI/CD 파이프라인에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

개발자가 작성한 작은 코드 변경을 코드 베이스에 통합합니다. 변경한 부분이 통합되면, 자동으로 새로운 시스템을 빌드하고 현재 시스템에 존재하는 모든 테스트를 실행합니다. 만약 이전에 동작했던 어떤 부분이 망가졌다면, 개발자는 해당 부분을 다시 수정합니다. 이러한 일련의 과정을 포함하는 소프트웨어 개발 방식을 지속적 통합(Continuous Integration) 이라고 합니다. 지속적 통합의 핵심 목표는 소프트웨어의 품질을 개선하고, 새로운 소프트웨어의 변경 사항을 검증하는데 소요되는 시간을 단축 시키며, 버그를 조기에 발견하기 위함입니다.

지속적 배포(Continuous Deployment) 는 지속적 통합을 통해서 빌드된 코드(빌드 아티팩트)를 프로덕션 환경에 자동으로 배포하는 것을 의미합니다. 지속적 전달(Continuous Delivery) 은 빌드 아티팩트를 프로덕션 환경에 바로 배포하기 위해서 수동으로 작업해야 한다는 점에서 지속적 배포와 차이가 있습니다. CD 과정에는 빌드 아티팩트를 관리 및 저장하는 공간이 필요할 수도 있습니다. 예를 들어, AWS S3, Docker Registry, Nexus를 사용할 수 있습니다.

일반적으로 위 방식들을 합쳐 CI/CD 파이프라인이라고 부르며, CI/CD 파이프라인을 구축하기 위한 도구로 Jenkins, Travis CI, Github Action 등이 존재합니다.

추가 학습 자료를 공유합니다.

참고 링크

[187] CQRS 패턴이란 무엇인가요?

백엔드

CQRS 패턴이란 무엇인가요?

백엔드와 관련된 질문이에요.

시스템은 크게 상태 변경과 조회 기능을 제공하는데요. 주문 취소, 결제 기능은 상태 변경에 해당되며, 주문서 조회, 사용자 조회 등이 조회에 해당됩니다. 명령 쿼리 책임 분리 패턴(Command Query Responsibility Segregation, CQRS) 는 상태를 변경하기 위한 명령을 위한 모델과 상태를 제공하는 조회(Query)를 위한 모델을 분리하는 패턴을 의미합니다. 예를 들어, Order라는 리소스를 Order(명령용), OrderData(조회용) 2개의 모델로 나누어서 관리할 수 있습니다. 이때 OrderData를 이용해서 표현 계층에 데이터를 출력하는 데 사용하고, 애플리케이션에서는 Order를 활용해 변경을 수행할 수 있습니다.

CQRS 패턴의 장단점은 무엇인가요? 🤔

CQRS 패턴을 따르면, 소프트웨어의 유지보수성을 높일 수 있습니다. 그리고, 모델별로 성능이나 요구사항에 맞는 데이터베이스나 데이터 접근 기술을 사용할 수 있습니다.

예를 들어, 명령 모델은 트랜잭션이 지원되는 RDB를 사용하고, 조회 모델은 조회 성능이 높은 NoSQL을 사용할 수 있습니다. 단, 해당 방식은 명령 모델의 변경을 조회 모델로 전파하여 동기화시켜야 할 필요가 있을 수 있습니다. 또 다른 예시로, 단일 데이터베이스의 테이블에 대해 CQRS 패턴을 사용한다고 가정했을 때는 명령 모델은 도메인 모델을 구현하는데 유리한 JPA를 사용하고, 조회 모델에 대해서는 SQL 데이터 조회에 유리한 MyBatis를 사용할 수 있습니다.

하지만, CQRS 패턴은 구현 코드가 많고, 더 많은 구현 기술이 필요하다는 점이 단점입니다. 따라서 단일 모델을 사용할 때 발생하는 복잡함 때문에 발생하는 구현 비용과 조회 전용 모델을 만들 때 발생하는 복잡함 때문에 발생하는 구현 비용을 비교해서 신중하게 도입을 결정해야 합니다.

추가 학습 자료를 공유합니다.

참고 링크

[190] Graceful Shutdown의 필요성에 대해서 설명해주세요.

백엔드

Graceful Shutdown의 필요성에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

우아한 종료(Graceful Shutdown) 란 애플리케이션이 종료될 때 바로 종료하는 것이 아니라, 현재 처리하고 있는 작업을 마무리하고 리소스를 정리한 이후 종료하는 방식을 의미합니다. 서버 애플리케이션에서 일반적인 Graceful Shutdown은 SIGTERM 신호를 받았을 때, 새로운 요청은 차단하고 기존 처리 중인 요청을 모두 완료한 뒤에 프로세스를 종료합니다. 만약, 서버 애플리케이션이 요청을 처리하는 중에 즉각적으로 애플리케이션을 종료한다면 트랜잭션 비정상 종료, 데이터 손실, 사용자 경험 저하 문제가 발생할 수 있습니다.

SIGTERM과 SIGKILL의 차이점은 무엇인가요? 🤓

SIGTERM과 SIGKILL은 유닉스 및 리눅스 운영체제에서 사용되는 프로세스 종료 시그널입니다. 그중에서 SIGKILL은 프로세스를 강제 종료하는 신호입니다. 프로세스가 종료하기 이전에 수행되어야 하는 절차들을 실행하지 않고 즉시 종료합니다. 반면, SIGTERM은 프로세스가 해당 시그널을 핸들링할 수 있습니다. 따라서, 프로세스가 종료하기 이전에 수행되어야 하는 절차들을 안전하게 수행할 수 있습니다.

스프링 환경에서 Graceful Shutdown을 하는 방법은 무엇인가요? 🤔

server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s // 타임 아웃

스프링에서는 Graceful Shutdown 설정을 지원해 줍니다. 단, 한 가지 유의해야 할 부분이 있는데요. 기존 처리 중인 요청에서 데드락이나 무한 루프가 발생하면 프로세스가 종료되지 않을 수 있습니다. 스프링은 이러한 상황을 예방하기 위해서 타임아웃 설정을 지원합니다. 위 예시에서 기존 진행 중인 작업들의 완료가 20초를 넘기는 경우 프로세스를 바로 종료합니다.

추가 학습 자료를 공유합니다.

참고 링크

[191] 데이터베이스 정규화에 대해서 설명해주세요.

백엔드

데이터베이스 정규화에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

데이터베이스에서 정규화(Normalization) 는 테이블을 정리하여 중복 데이터를 최소화하고, 데이터 무결성을 보장하는 과정을 의미합니다. 이를 통해 데이터 저장 용량을 줄이고, 삽입·갱신·삭제 이상(Anomaly) 현상을 해결할 수 있습니다. 정규화는 여러 단계가 존재하며, 대표적으로 1정규화(1NF), 2정규화(2NF), 3정규화(3NF), BCNF가 있습니다.

각 정규화 단계를 설명해주세요. 🤔

  • 1 정규화(1NF) 는 테이블 컬럼의 값이 원자값(Atomic Value)을 가지도록 정리하는 것을 의미합니다.
  • 2 정규화(2NF) 는 1 정규화를 진행한 테이블에서 완전 함수 종속을 만족할 수 있도록 테이블을 분해하는 것인데요. 쉽게 표현하자면, 기본 키의 일부에만 종속된 속성이 없도록 분해하는 것을 의미합니다. A 속성을 통해서 B 속성의 값이 유일하게 정해지는 관계에서 A를 결정자라고 합니다. 가령 사용자 ID가 기본 키이며 값이 1입니다. 그리고, 사용자 ID 1번에 대응되는 유일한 이름 속성 값이 "B" 이라고 했을 때, 이름 속성의 결정자는 사용자 ID입니다. 2NF를 만족하기 위해서는 기본키의 부분 집합이 결정자가 될 수 없습니다.
  • 3 정규화(3NF) 는 2 정규화를 진행한 테이블에서 이행적 종속을 제거하기 위해 테이블을 분해하는 것을 의미합니다. A가 B를 결정하고, B가 C를 결정하는 경우에는 A가 C의 결정자가 되는데요. 이를 이행적 종속이라고 합니다. 3NF을 만족하기 위해서는 이행적 종속이 제거되도록 테이블을 분리해야 합니다.
  • BCNF 정규화 는 3 정규화를 진행한 테이블의 모든 결정자가 반드시 후보키가 될 수 있도록 테이블을 분해하는 것을 의미합니다. BCNF를 만족하기 위해서는 후보키가 아닌 결정자가 존재하지 않도록 테이블을 분리해야 합니다.

역정규화에 대해서 알고 계신가요? 🤓

역정규화(Denormalization) 는 정규화된 데이터베이스에서 쓰기 성능을 희생하고 읽기 성능을 향상하기 위해 사용되는 전략입니다. 예를 들어, 전체 게시글의 수나 좋아요의 수를 계산해 특정 컬럼에 저장해서 읽기 성능을 항상 시킬 수 있습니다. 역정규화된 데이터베이스에서는 데이터의 중복을 허용하기 때문에 데이터의 일관성을 맞추기 위한 추가적인 작업이 필요합니다.

추가 학습 자료를 공유합니다.

참고 링크

[194] 분산 환경에서 Redis를 활용한 잠금은 어떻게 구현할 수 있나요?

백엔드

분산 환경에서 Redis를 활용한 잠금은 어떻게 구현할 수 있나요?

백엔드와 관련된 질문이에요.

Redis의 SET 명령어를 사용해서 분산 잠금을 구현할 수 있습니다. 가령, 1대 이상의 서버가 특정 Key에 대해서 SET 명령어에 NX 옵션을 추가하여 Redis에 전달하여 잠금 획득을 시도합니다. NX 옵션을 사용하면 특정 Key에 해당하는 값이 존재하지 않는 경우에만 값 추가 작업이 성공합니다. SET 작업을 성공적으로 수행한 서버는 잠금을 획득합니다. 잠금을 획득한 서버는 작업이 끝난 이후, Key에 해당하는 값을 제거하여 잠금을 해제합니다.

잠금이 유실될 가능성이 있지 않나요? 🤔

만약, 레플리케이션 구성으로 이루어져 있으면 잠금이 유실될 가능성이 존재합니다. 예를 들어, 마스터 노드가 존재하고 레플리카 노드에 주기적으로 데이터를 복제하는 구성이라고 가정하겠습니다. 이때, 특정 서버가 잠금을 획득하고 작업을 수행하는 도중에 마스터 노드에 장애가 발생하는 경우에는 레플리카 노드가 마스터 노드로 승격될 수 있습니다. (failover)

위와 같은 가정에서 기존 마스터 노드가 가지고 있던 잠금용 데이터를 레플리카 노드가 가지고 있지 않은 상태라면, 작업을 처리하고 있는 서버의 잠금이 유실됩니다. 즉, 복제 지연으로 인해 분산 잠금의 상호 배제라는 특성을 잃게 됩니다. 이러한 문제를 해결하기 위해서 RedLock 알고리즘이 등장했습니다.

RedLock 알고리즘은 어떻게 동작하나요? 😀

RedLock 알고리즘은 1대 이상의 단일 레디스 노드들을 이용하여 분산 잠금을 구현하는데요. 과반수 이상의 노드에 잠금을 획득한다면 잠금을 획득하는 것으로 간주합니다. 만약, 5개의 단일 레디스 노드가 존재한다고 가정하겠습니다. 작업 서버가 모든 노드에게 잠금 획득을 시도했는데, 3개(과반수) 노드에서 잠금을 획득다면, 분산 잠금을 획득한 것으로 판단합니다.

추가 학습 자료를 공유합니다.

참고 링크

[195] 무중단 배포가 무엇인가요?

백엔드

무중단 배포가 무엇인가요?

백엔드와 관련된 질문이에요.

무중단 배포(Zero-Downtime Deployment) 는 서비스에 다운 타임이 발생하지 않으면서, 새로운 버전의 애플리케이션을 서버에 배포하는 것을 의미합니다. 무중단 배포 패턴에는 대표적으로 순차적으로 배포하는 롤링 배포, 전체 서버를 통째로 바꾸는 블루/그린 배포, 트래픽을 순차적으로 이동시키는 카나리 배포가 존재합니다.

각 배포 방식을 설명해 주시겠어요? 🤓

  • 롤링 배포(Roling Deployment) 는 서버를 한 대씩 순차적으로 업데이트하는 가장 기본적인 방식입니다. 특정 시점에는 두 가지 버전이 공존하기 때문에 새로운 버전은 기존 버전 기능을 지원하는 등 하위 호환성(Backward Compatibility) 에 신경을 써야 합니다. 롤링 배포는 새로운 버전을 배포하기 위해서 새로운 서버를 생성하지 않습니다. 배포가 진행 중인 서버는 요청 처리가 불가하기 때문에 다른 서버에 전달되는 트래픽이 증가할 수 있습니다.
  • 블루/그린 배포(Blue/Green Deployment) 는 기존의 서버와 동일한 스펙과 사이즈의 서버를 미리 준비하고, 신규 버전을 배포한 이후에 기존 서버는 폐기하고 트래픽을 신규 버전의 서버로 이전 시키는 방법입니다. 블루/그린 배포의 경우에는 기존의 버전을 가지고 있기(폐기 이전이라 가정) 때문에 롤백을 빠르게 수행할 수 있습니다. 하지만, 배포 과정에서 새로운 서버를 미리 준비해야 한다는 점에서 비용이 발생할 수 있습니다.
  • 카나리 배포(Canary Deployment) 는 기존 버전의 서버와 새로운 버전의 서버들을 구성한 이후, 전체 트래픽의 퍼센티지로 관리하는 방법입니다. 예를 들어 트래픽을 기존 서버 70퍼, 신규 서버 30퍼로 나누고 점점 신규 서버로 트래픽을 보내어 나중엔 신규 서버가 100퍼센트가 되어 배포가 완료됩니다. 롤링 배포처럼 특정 시점에 다른 두 버전의 서버가 공존하기 때문에 하위 호환성을 신경 써야 합니다.

어떤 상황에 각 배포 전략을 선택할 수 있을까요? 🤔

  • 배포를 위해 새로운 서버를 생성하는 비용을 감수하기 어려운 경우, 롤링 배포를 선택할 수 있습니다. 또한, 서버 API 구간에서 버그가 생겼을 때 이것을 수정하고 개발 서버에서 충분히 테스트한 이후 상용에 올려보고 싶을 때 롤링 배포가 유용할 수 있습니다. 서버 10대 중 1대만 버그를 수정해서 배포하고 디버그 레벨로 로깅을 하면서 수정한 버그가 해결됐는지 확인합니다. 만약 실제 상용 서버에서도 문제가 없다면 1대씩 순차적으로 롤링 배포를 진행합니다.
  • 대규모 업데이트가 있을 때는 블루/그린 배포를 선택할 수 있습니다. 예를 들어 전면적으로 기술 부채가 해결되거나 중요한 변화가 있을 때, 비용을 감수하고 블루/그린 배포를 채택하는 것이 유용할 수 있습니다.
  • 카나리 배포는 통계적으로 무언가 확인하고 싶은 것 (오류율, 성능)이 있을 때, 혹은 A/B 테스트를 하고 싶을 때 채택할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[197] 테스트 더블에 대해서 설명해주세요.

백엔드

테스트 더블에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

테스트 코드에서 실제 의존성을 사용하기 어려운 경우, 테스트 더블(Test Double) 을 사용할 수 있습니다. 테스트 더블은 의존성을 시뮬레이션하지만, 테스트에 더욱 적합하게 사용할 수 있도록 만듭니다. 실제 의존성을 포함하는 테스트는 외부 세계에 부수 효과를 유발할 수 있으며, 외부 세계에 의존적이기 때문에 비결정적인 동작을 유발할 수 있습니다. 또한, 실제 의존성을 포함하기 위해서 복잡한 설정이 필요한 경우도 존재합니다. 테스트 더블은 테스트로부터 외부 세계를 보호하고, 또 반대로 외부로부터 테스트를 보호하며, 복잡한 설정을 단순화할 수 있도록 해주는 가짜 의존성입니다.

테스트 더블의 종류에는 무엇이 있나요? 🤔

테스트 더블은 수행하는 역할에 따라서 더미, 스텁, 페이크, 스파이, 목으로 분류할 수 있습니다.

  • 더미(Dummy) 는 아무런 동작도 하지 않으며, 인스턴스화된 객체만 필요한 경우에 사용됩니다.
  • 스텁(Stub) 은 구현을 단순한 것으로 대체합니다. 테스트에 맞게 단순히 원하는 동작을 수행합니다.
  • 페이크(Fake) 는 제품에는 적합하지 않지만, 실제 동작하는 구현을 제공합니다.
  • 스파이(Spy) 는 호출된 내역을 기록합니다. 기록한 내용은 테스트 결과를 검증할 때 주로 사용되며, 스텁의 일종이기도 합니다.
  • 목(Mock) 은 기대한 대로 상호작용하는지 행위를 검증합니다. 기대한 것처럼 동작하지 않는다면, 예외를 발생할 수 있습니다. 목 객체는 스텁이자 스파이기도 합니다.

추가 학습 자료를 공유합니다.

참고 링크

[199] 자바에서 클래스 정보는 어떻게 알아낼 수 있나요?

백엔드

자바에서 클래스 정보는 어떻게 알아낼 수 있나요?

백엔드와 관련된 질문이에요.

자바에서 클래스 정보를 가져오기 위해서 Reflection API를 사용할 수 있습니다. reflection 패키지에서 제공하는 클래스를 사용하면, JVM에 로딩되어 있는 클래스와 메서드의 정보를 읽어올 수 있습니다. 대표적으로 Class 클래스, Method 클래스, Field 클래스가 존재합니다.

Reflection API를 사용하면 구체적인 클래스의 타입을 몰라도, 클래스의 정보에 접근할 수 있습니다. 개발자는 이러한 특성을 이용하여 인스턴스를 감싸는 프록시를 만들거나, 사용자로부터 전달된 값을 처리할 메서드를 유연하게 선택하는 등 다양한 구현을 할 수 있습니다. Reflection API는 특히 프레임워크나 라이브러리를 개발하는 과정에서 사용되는 경우가 많습니다. 프레임워크나 라이브러리의 개발자는 사용자가 작성한 클래스에 대한 정보를 알 수 없기 때문입니다.

Reflection API의 단점은 무엇인가요? 🤓

Reflection API는 동적으로 클래스의 정보에 접근할 수 있다는 점에서 강력한 기능입니다. 하지만, 일반적인 코드보다 복잡한 코드가 필요할 수 있습니다. 또한, 캡슐화가 약화되어 강결합으로 이어질 수 있습니다. 일반적인 메서드 호출과 Method 클래스의 invoke 호출의 성능을 비교했을 때, JIT 최적화가 어려워질 수 있어 일반적인 메서드 호출보다 성능이 저하될 가능성이 있습니다. 단, 이 부분은 사용 중인 JVM의 버전과 프로그램 상황에 따라 다를 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[200] 연속 메모리 할당 기법에 대해서 설명해주세요.

백엔드

연속 메모리 할당 기법에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

연속 메모리 할당 기법(Continuous Memory Allocation) 은 운영체제가 프로세스에 연속적인 메모리 공간을 할당하는 방법을 의미합니다. 연속 메모리 할당 기법을 사용하면, 하나의 프로세스는 메모리 주소 공간에서 연속적으로 존재하게 됩니다.

연속 메모리 할당 기법은 크게 가변 크기 메모리 할당과 고정 크기 메모리 할당이 존재하는데요. 고정 크기 메모리 할당은 물리적인 메모리 공간을 고정된 크기로 나누어 프로세스에 고정된 크기만큼 할당합니다. 반면, 가변 크기 메모리 할당 방식은 프로세스의 크기에 맞춰 동적으로 메모리를 할당합니다.

외부 단편화와 내부 단편화가 무엇인가요? 🤔

외부 단편화(External Fragmentation) 는 프로세스를 할당하기 어려울 만큼 작은 메모리 공간들로 인해서 메모리가 낭비되는 현상입니다. 연속 메모리 할당 기법을 사용할 때 프로세스가 사용할 메모리 공간이 300MB라고 가정하겠습니다. 이때, 흩어져 있는 빈 공간들의 합은 300MB가 넘지만, 연속적인 300MB는 존재하지 않아 프로세스에 메모리를 할당하지 못하는 상황이 발생할 수 있습니다.

내부 단편화(Internal Fragmentation) 는 메모리를 할당할 때 프로세스가 필요한 양보다 더 큰 메모리가 할당되는 상황에서 메모리 공간이 낭비되는 상황을 의미합니다. 고정 크기 할당 방식에서 프로세스가 사용할 메모리 공간이 300MB이지만 실제로 할당된 공간은 500MB인 경우, 200MB가 낭비됩니다.

고정 크기 할당 방식은 고정된 크기만큼 프로세스에 공간을 할당하기 때문에 내부 단편화가 발생할 수 있습니다. 반면, 가변 크기 할당 방식은 필요한 크기만큼 프로세스에 할당하기 때문에 내부 단편화는 발생하지 않습니다. 단, 할당과 해제를 반복하다보면 외부 단편화가 발생할 수 있습니다.

메모리에 빈 공간이 여러 개라면, 어디에 신규 프로세스를 배치해야 하나요? 🤓

연속 메모리 할당 기법에서 사용할 수 있는 대표적인 방법으로 최초 적합, 최적 적합, 최악 적합 방식이 존재합니다.

  • 최초 적합(First Fit) 은 운영체제가 메모리 내의 빈 공간을 순서대로 검색하고, 최초로 발견된 공간에 프로세스를 배치합니다.
  • 최적 적합(Best Fit) 은 운영체제가 메모리 내의 빈 공간을 모두 검색하고, 적재될 수 있는 가장 작은 공간에 프로세스를 배치하는 방식입니다.
  • 최악 적합(Worst Fit) 은 운영체제가 메모리 내의 빈 공간을 모두 검색하고, 적재될 수 있는 가장 큰 공간에 프로세스를 배치하는 방식입니다.

추가 학습 자료를 공유합니다.

참고 링크

[201] DNS란 무엇인가요?

백엔드

DNS란 무엇인가요?

백엔드와 관련된 질문이에요.

IP 주소는 변환될 수 있으며, 기억하기 어렵기 때문에 대부분의 웹 서비스는 도메인 주소를 사용합니다. DNS(Domain Name System) 는 도메인 주소에 대응되는 원격 호스트 IP 주소를 관리하고 질의할 수 있는 시스템 혹은 이를 이용하기 위한 프로토콜을 의미합니다. DNS 시스템 내에서 도메인 주소를 관리하는 서버들을 네임 서버라고 부르며, 계층적인 형태로 존재합니다.

DNS 질의 과정을 설명해 주세요. 🤓

DNS를 이용해 IP 주소를 찾는 과정에는 로컬 네임 서버, 루트 네임 서버, TLD 네임 서버, 권한 네임 서버가 등장합니다.

  • 로컬 네임 서버(Local Name Server) 는 통신사 DNS, 구글 DNS 처럼 클라이언트와 가장 가까이 존재하는 네임 서버입니다.
  • 루트 네임 서버(Root Name Server) 는 루트 도메인을 관리하는 서버이며, TLD 네임 서버의 주소를 알고 있습니다.
  • TLD 네임 서버(Top-level Domain Server) 는 .com, .kr, .net을 관리하는 네임 서버입니다.
  • 권한 네임 서버(Authoriative Name Server) 는 네트워크를 운영하는 기관에서 보유하고 있는 도메인을 관리하는 네임 서버입니다. ex) .maeil-mail.kr을 관리하고, api.maeil-mail.kr, mail.maeil-mail.kr 의 정보를 관리하는 네임 서버

api.maeil-mail.kr의 IP 주소를 찾는 과정을 예시로 설명해 드리겠습니다.

가장 먼저 클라이언트는 로컬 네임 서버에 api.maeil-mail.kr의 IP 주소를 질의합니다. 만약, 로컬 네임 서버에서 값을 찾지 못했다면 로컬 네임 서버는 루트 네임 서버에게 IP 주소를 물어봅니다. 요청을 받은 루트 네임 서버는 .kr TLD 네임 서버 주소를 응답합니다. 이를 받은 로컬 네임 서버는 .kr TLD 네임 서버에 다시 요청을 보내고, maeil-mail.kr 권한 네임 서버의 주소를 응답 받습니다. 최종적으로 로컬 네임 서버는 권한 네임 서버에서 api.maeil-mail.kr IP 주소를 받아서 클라이언트에게 응답합니다.

추가 학습 자료를 공유합니다.

참고 링크

[203] 동시성 문제 중 경쟁 상태를 해결하려면 무엇이 보장되어야 하나요?

백엔드

동시성 문제 중 경쟁 상태를 해결하려면 무엇이 보장되어야 하나요?

백엔드와 관련된 질문이에요.

경쟁 상태(Race Condition) 는 두 개 이상의 스레드가 공유 자원에 동시에 접근할 때 스레드 간의 실행 순서에 따라 결과가 달라지는 현상으로, 원자성가시성 모두 보장되어야 해결할 수 있습니다.

원자성(Atomicity) 은 공유 자원에 대한 작업의 단위가 더 이상 쪼갤 수 없는 하나의 연산처럼 동작하는 성질을 의미합니다.

가시성(Visibility) 은 한 스레드에서 변경한 값이 다른 스레드에서 즉시 확인 가능한 성질을 의미합니다.

원자성을 보장하지 않으면 어떤 문제가 발생하나요?

예를 들어 i++ 연산은 하나의 문장이지만 CPU가 이를 수행하려면 세 단계의 instruction으로 분리됩니다.

  1. i 변수의 기존 값을 읽음 (Read)
  2. 기존 값에 1을 더함 (Modify)
  3. 결과 값을 다시 i 변수에 할당 (Write)

연산 사이에 다른 스레드가 개입하면 기대하지 않은 결과가 발생할 수 있습니다.

만약 두 개의 스레드가 동시에 i++ 연산을 수행할 때, Thread 1이 i + 1을 하기 전에 Thread 2가 i를 읽어서 i + 1을 수행한 후 반영하면 Thread 2의 연산은 무시됩니다.

가시성을 보장하지 않으면 어떤 문제가 발생하나요?

현대의 컴퓨터는 여러 개의 CPU 코어가 있고 각 코어마다 CPU 캐시가 존재하는데요. 한 스레드에서 공유 자원을 변경할 경우 메인 메모리에서 CPU 캐시로 값을 읽어들인 후, 변경된 값을 자신의 CPU 캐시에 반영합니다. 하지만 변경된 값이 메인 메모리에 언제 반영될지 알 수 없기 때문에 다른 스레드가 공유 자원을 읽을 때 변경 사항을 즉시 확인할 수 없습니다.

java에서 원자성과 가시성을 보장하기 위해 어떤 방법을 사용할 수 있나요?

원자성과 가시성을 모두 보장하려면 synchronized 키워드, CAS(Compare-And-Swap) 알고리즘을 사용하는 Atomic 클래스, ReentrantLock과 같은 lock 클래스, Concurrent Collections 등을 사용해서 동기화할 수 있습니다.

가시성만 보장하려면 volatile 키워드로 CPU 캐시를 사용하지 않고 메인 메모리에서 공유 자원을 직접 읽거나 쓸 수 있습니다. 이 때 하나의 스레드에서만 쓰기 작업을 수행하고, 나머지 스레드는 읽기 작업만 수행해야 합니다.

추가 학습 자료를 공유합니다.

참고 링크

[205] 스프링 트랜잭션 전파 속성에 대해서 설명해주세요.

백엔드

스프링 트랜잭션 전파 속성에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

스프링에서 트랜잭션 전파(Transaction Propagation) 는 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 있을 때 또는 없을 때 어떻게 동작할 것인가를 결정하는 기능입니다. 가령, @Transactional 어노테이션이 존재하는 메서드를 호출했을 때, 기존에 트랜잭션이 존재하면 재사용할지, 예외를 던질지 등 행동을 결정할 수 있습니다.

트랜잭션 전파 속성에는 REQUIRED, REQUIRED_NEW, MANDATORY, SUPPORTS, NOT_SUPPORTED, NESTED, NEVER가 존재하며, @Transactional 어노테이션의 propagation 속성에 값을 설정할 수 있습니다.

각 트랜잭션 전파 속성을 설명해 주세요. 🤔

  • REQUIRED는 트랜잭션이 존재하는 경우 해당 트랜잭션 사용하고, 트랜잭션이 없는 경우 트랜잭션을 생성합니다.
  • REQUIRED_NEW는 트랜잭션이 존재하는 경우 트랜잭션을 잠시 보류시키고, 신규 트랜잭션을 생성하여 사용합니다.
  • MANDATORY는 트랜잭션이 반드시 있어야 합니다. 트랜잭션이 없다면, 예외가 발생합니다. 만약, 트랜잭션이 존재한다면 해당 트랜잭션을 사용합니다.
  • SUPPORTS는 트랜잭션이 존재하는 경우 트랜잭션을 사용하고, 트랜잭션이 없다면 트랜잭션 없이 실행합니다.
  • NOT_SUPPORTED는 트랜잭션이 존재하는 경우 트랜잭션을 잠시 보류하고, 트랜잭션이 없는 상태로 처리합니다.
  • NESTED는 트랜잭션이 있다면 SAVEPOINT를 남기고 중첩 트랜잭션을 시작합니다. 만약 없는 경우에는 새로운 트랜잭션을 시작합니다.
  • NEVER는 트랜잭션이 존재하는 경우 예외를 발생시키고, 트랜잭션이 없다면 생성하지 않습니다.

추가 학습 자료를 공유합니다.

참고 링크

[209] CDN이란 무엇인가요?

백엔드

CDN이란 무엇인가요?

백엔드와 관련된 질문이에요.

콘텐츠 전송 네트워크(Content Delivery Network, CDN) 은 전 세계에 분산된 서버 네트워크며, 사용자와 물리적으로 가까운 위치에서 정적 콘텐츠(혹은 동적 콘텐츠)를 제공하여 서버 과부하를 방지하고 통신 지연을 단축합니다. CDN은 통신에 참여하는 호스트 간에 중간 서버를 두어 성능을 향상합니다. 대표적인 CDN 서비스로는 CloudFront, CloudFlare 등이 있습니다.

CDN에서 Push 방식과 Pull 방식의 차이점은 무엇인가요? 🤓

Push 방식은 원본 서버가 콘텐츠를 미리 CDN 서버로 전달하는 방식입니다. Push 방식은 적절한 시기에 정확한 콘텐츠를 제공할 수 있습니다. 하지만, 지속적으로 업데이트를 해줘야 한다는 점에서 관리 비용이 상대적으로 높습니다.

Pull 방식은 CDN 서버가 원본 서버로 요청을 보내 콘텐츠를 가져오는 방식입니다. 클라이언트가 요청을 보내는 경우, CDN에 원하는 콘텐츠가 없다면 원본 서버로부터 콘텐츠를 가져와서 업데이트하는 방식으로 동작합니다. 초기 요청에서는 원본 서버에서 콘텐츠를 가져오는 작업을 수행해야 하므로 응답 속도가 저하될 수 있습니다.

CDN을 사용해야 할 때 고려해야 할 점은 무엇인가요? 🤔

CDN을 사용해야 할 때는 크게 비용, 만료 시간, CDN 장애 대응, 콘텐츠 무효화를 고려해야 합니다.

  • CDN은 주로 제 3 사업자(클라우드 서비스 등)에게 비용을 지불하고 사용합니다. 비용 대비 효과를 고려하여 비판적으로 도입하고, 성능 향상이 필요한 콘텐츠만 캐싱하는 등 비용을 절약하기 위한 고민이 필요합니다.
  • 콘텐츠의 적절한 만료 시간에 대해서 고려해야 합니다. 콘텐츠의 만료 시간이 길다면 신선도가 떨어지며, 짧다면 원본 서버로의 요청이 빈번해집니다.
  • CDN에 장애가 발생했을 때 어떻게 대응해야 할지 고려해야 합니다. 예를 들어, CDN에서 콘텐츠를 응답할 수 없는 경우, 클라이언트에서 원본 서버로부터 직접 콘텐츠를 가져오도록 구성할 수 있습니다.
  • CDN에 존재하는 콘텐츠를 무효화하기 위해서 어떤 방식을 선택할 것인지 고려해야 합니다. 예를 들어, 오브젝트 버저닝을 사용하거나 CDN 서비스에서 제공되는 API를 사용할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[210] 시간 복잡도와 공간 복잡도의 차이점은 무엇인가요?

백엔드

시간 복잡도와 공간 복잡도의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

하나의 문제를 해결하는 여러 알고리즘이 존재할 수 있습니다. 그리고, 개발자는 성능을 평가하여 하나를 결정해야 합니다. 이때, 코드가 실행될 때 걸리는 정확한 시간을 측정하는 방법으로 속도를 비교할 수 있습니다. 하지만, 실행 시간은 기계에 의존적이며 대안으로 나온 알고리즘들이 모두 짧은 시간 내로 수행되어 비교가 어려울 수 있습니다.

이러한 경우, 직접 속도를 측정하는 것이 아닌 컴퓨터가 처리해야 하는 연산의 수를 세는 것이 나은 방법일 수 있습니다. 이러한 아이디어를 기반으로 특정 입력을 기준으로 개략적인 연산의 수를 계산한 것이 시간 복잡도(Time Complexity) 입니다. 반면, 공간 복잡도(Space Complexity) 는 특정 입력을 기준으로 알고리즘이 얼마나 많은 공간을 차지하는지를 다룹니다.

정리하자면, 시간 복잡도와 공간 복잡도는 모두 알고리즘 평가의 척도로 사용될 수 있습니다. 시간 복잡도는 개략적인 연산의 수를 기준으로 알고리즘 속도를 평가하는 척도로 사용되는 반면, 공간 복잡도는 알고리즘의 메모리 사용량을 평가하는 척도로 사용되는 것이 차이점입니다.

빅오 표기법은 무엇인가요? 😀

빅오 표기법은 복잡도를 표현하는 표기법 중 하나로 불필요한 상세를 무시하고 필수적인 부분에 집중하는 점근적 표기법을 따릅니다. 빅오 표기법은 O(n) 형식으로 어떤 함수의 입력 값에 따라 알고리즘의 실행 시간 및 공간 사용량이 어떻게 변하는지를 설명합니다. 빅오 표기법의 예시는 다음과 같습니다.

// n이 입력되면 n번 루프가 반복되므로, O(n)으로 표기합니다.
for (int i = 0; i < n; i++) { ... }

// 아래 루프는 O(n)으로 표기합니다. n이 무한에 가까울 수록 k가 의미가 없기 때문입니다. (상수항과 계수 무시)
int k = 5;
for (int i = 0; i < n * k; i++) { ... }

// 입력값인 n과 m이 독립적이라면 빅오는 더할 수 있습니다. O(n + m)으로 표기합니다.
for (int i = 0; i < n; i++) { ... }
for (int i = 0; i < m; i++) { ... }

// 빅오는 곱해질 수 있습니다. O(n^2)으로 표기합니다.
for (int i = 0; i < n; i++) {
  for (int j = 0; j < n * 5; j++) {}
}

// 가장 큰 항 외에는 무시할 수 있습니다. O(n^2)로 표기합니다.
for (int i = 0; i < n; i++) {}

for (int i = 0; i < n; i++) {
  for (int j = 0; j < n; j++) {}
}

추가 학습 자료를 공유합니다.

참고 링크

[211] Micrometer가 무엇인지 설명해주세요.

백엔드

Micrometer가 무엇인지 설명해주세요.

백엔드와 관련된 질문이에요.

Micrometer란 무엇이며, 왜 사용하나요?

Micrometer는 벤더 중립적인 메트릭 계측 라이브러리로, 애플리케이션에서 발생하는 다양한 지표(예: CPU 사용량, 메모리 소비, HTTP 요청 및 커스텀 이벤트)를 수집합니다. 이 라이브러리는 Prometheus, Datadog, Graphite 등 여러 모니터링 시스템에 메트릭을 전송할 수 있도록 단순하고 일관된 API(파사드)를 제공하여, 각 백엔드 클라이언트의 복잡한 세부 구현을 감춥니다. 특히 Spring Boot Actuator와 깊이 통합되어, 기본 메트릭을 자동으로 수집하고 노출할 수 있습니다.

Spring Boot Actuator와 Micrometer의 관계는 무엇인가요?

Spring Boot Actuator는 애플리케이션의 상태, 헬스 체크, 환경, 로그 등 여러 운영 정보를 노출하는 관리 엔드포인트를 제공합니다. 내부적으로 Actuator는 Micrometer를 사용하여 JVM, HTTP, 데이터베이스 등 다양한 메트릭을 수집합니다. 즉, Actuator는 모니터링 및 관리 인터페이스를 제공하고, Micrometer는 그 밑에서 실제 메트릭 데이터를 계측하고 여러 모니터링 시스템으로 전송하는 역할을 담당합니다.

Micrometer를 사용하여 커스텀 메트릭을 생성하는 방법을 설명해주세요.

아래는 Micrometer를 활용하여 커스텀 메트릭(카운터, 타이머, 게이지)을 생성하고 업데이트하는 예제 코드입니다. 이 코드는 HTTP 요청의 건수와 처리 시간, 그리고 현재 활성 세션 수를 측정하는 예제입니다.

package com.example.metrics;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;

@Service
public class CustomMetricsService {

    private final Counter requestCounter;
    private final Timer requestTimer;
    private final CustomGauge customGauge;

    // 생성자에서 MeterRegistry를 주입받아 필요한 메트릭을 등록합니다.
    public CustomMetricsService(MeterRegistry meterRegistry) {
        // HTTP 요청 총 건수를 세는 Counter (태그로 엔드포인트 구분)
        this.requestCounter = meterRegistry.counter("custom.requests.total", "endpoint", "/api/test");

        // HTTP 요청 처리 시간을 측정하는 Timer (태그로 엔드포인트 구분)
        this.requestTimer = meterRegistry.timer("custom.request.duration", "endpoint", "/api/test");

        // Gauge: 예를 들어, 현재 활성 세션 수를 측정하기 위한 커스텀 객체를 등록
        this.customGauge = new CustomGauge();
        Gauge.builder("custom.active.sessions", customGauge, CustomGauge::getActiveSessions)
                .tag("region", "us-east")
                .register(meterRegistry);
    }

    /**
     * 실제 비즈니스 로직을 실행할 때 요청 카운트와 처리 시간을 측정합니다.
     * @param requestLogic 실제 처리할 로직 (예: HTTP 요청 처리)
     */
    public void processRequest(Runnable requestLogic) {
        // 요청 수 증가
        requestCounter.increment();
        // 요청 처리 시간 측정
        requestTimer.record(requestLogic);
    }

    /**
     * 활성 세션 수 업데이트 (예를 들어, 로그인/로그아웃 이벤트에서 호출)
     * @param activeSessions 현재 활성 세션 수
     */
    public void updateActiveSessions(int activeSessions) {
        customGauge.setActiveSessions(activeSessions);
    }

    /**
     * 커스텀 Gauge의 값을 저장하는 내부 클래스.
     */
    private static class CustomGauge {
        // 현재 활성 세션 수를 저장 (volatile을 사용해 스레드 안정성 확보)
        private volatile double activeSessions = 0;

        public double getActiveSessions() {
            return activeSessions;
        }

        public void setActiveSessions(double activeSessions) {
            this.activeSessions = activeSessions;
        }
    }
}
  • MeterRegistry 사용

    생성자에서 MeterRegistry를 주입받아 애플리케이션의 모든 메트릭을 중앙에서 관리하고, 설정된 모니터링 백엔드로 주기적으로 전송합니다.

  • Counter

    requestCounter는 /api/test 엔드포인트에 대한 요청 건수를 카운트합니다. 매 HTTP 요청마다 increment() 호출로 증가시킵니다.

  • Timer

    requestTimer는 HTTP 요청 처리 시간을 측정합니다. record() 메서드를 사용해 실제 로직 실행 시간을 기록합니다.

  • Gauge

    customGauge는 현재 활성 세션 수를 측정하는 데 사용됩니다. Gauge는 항상 현재 상태를 조회하는 함수(getActiveSessions())를 호출하여 실시간 값을 반영합니다.

참고 링크

참고 링크 없음

[213] try-with-resources에 대해 설명해 주세요.

백엔드

try-with-resources에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

커넥션, 입출력 스트림과 같은 자원을 사용한 후에는 자원을 해제해서 성능 문제, 메모리 누수 등을 방지해야 합니다. try-with-resources는 이러한 자원을 자동으로 해제하는 기능으로, java 7부터 도입되었습니다.

try-with-resources가 정상적으로 동작하려면 AutoCloseable 인터페이스를 구현한 객체를 사용해야 하고, try() 괄호 내에서 변수를 선언해야 합니다.

try (BufferedReader br = new BufferedReader(new FileReader("path"))) {
    return br.readLine();
} catch (IOException e) {
    return null;
}

try-catch-finally 대신 try-with-resources를 사용해야 하는 이유는 무엇인가요?

BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader("path"));
    return br.readLine();
} catch (IOException e) {
    return null;
} finally {
    if (br != null) {
        try {
            br.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

try-catch-finally는 finally 블록에서 close()를 명시적으로 호출해야 합니다. 하지만 close() 호출을 누락하거나 이 과정에서 또 다른 예외가 발생하면 예외 처리가 복잡해지는 문제가 있습니다.

또한 여러 개의 자원을 다룰 경우, 먼저 close()를 호출한 자원에서 에러가 발생하면 다음에 close()를 호출한 자원은 해제되지 않습니다. 이를 해결하려면 추가적인 try-catch-finally가 필요하기 때문에 가독성이 떨어지고, 실수할 가능성이 높습니다.

try-with-resources를 사용하면 try-catch-finally의 문제를 해결할 수 있습니다.

  • try 블록이 종료될 때 close()를 자동으로 호출해서 자원을 해제합니다.
  • finally 블록 없이도 자원을 안전하게 정리하기 때문에 코드가 간결해집니다.
  • try 문에서 여러 자원을 선언하면, 선언된 반대 순서로 자동 해제됩니다.

Suppressed Exception(억제된 예외)란 무엇인가요?

Suppressed Exception은 예외가 발생했지만 무시되는 예외를 의미합니다.

try-with-resourcesclose() 과정에서 발생한 예외를 Suppressed Exception으로 관리합니다.

class CustomResource implements AutoCloseable {
    
    @Override
    public void close() throws Exception {
        throw new Exception("Close Exception 발생");
    }

    void process() throws Exception {
        throw new Exception("Primary Exception 발생");
    }
}

public class Main {
    
    public static void main(String[] args) throws Exception {
        try (CustomResource resource = new CustomResource()) {
            resource.process();
        }
    }
}
Exception in thread "main" java.lang.Exception: Primary Exception 발생
    at CustomResource.process(CustomResource.java:9)
    at Main.main(Main.java:5)
    Suppressed: java.lang.Exception: Close Exception 발생
        at CustomResource.close(CustomResource.java:5)
        at Main.main(Main.java:4)

Suppressed Exception이 필요한 이유는 원래 예외(Primary Exception)를 유지하면서 추가 예외도 함께 추적할 수 있고, 자원을 안전하게 해제하면서 예외를 효율적으로 처리할 수 있습니다.

try-catch-finallyclose()를 호출할 때 예외가 발생하면 원래 예외가 사라지고 close()에서 발생한 예외만 남을 수 있습니다.

public class Main {
    
    public static void main(String[] args) throws Exception {
        CustomResource resource = null;
        try {
            resource = new CustomResource();
            resource.process();
        } finally {
            if (resource != null) {
                resource.close();
            }
        }
    }
}
Exception in thread "main" java.lang.Exception: Close Exception 발생
    at CustomResource.close(CustomResource.java:5)
    at Main.main(Main.java:16)

이처럼 원래 예외가 사라지면 디버깅이 어려워질 수 있습니다. Throwable의 addSuppressed()를 사용하면 문제를 해결할 수 있지만 코드가 더욱 복잡해지기 때문에 try-with-resources를 사용하는 것이 좋습니다.

추가 학습 자료를 공유합니다.

참고 링크

[217] 네트워크에서 회선 교환 방식과 패킷 교환 방식은 어떤 차이점 있나요?

백엔드

네트워크에서 회선 교환 방식과 패킷 교환 방식은 어떤 차이점 있나요?

백엔드와 관련된 질문이에요.

네트워크에서 회선 교환 방식(Circuit Switching) 이란 특정 사용자를 위한 회선의 경로를 미리 설정하고 이 경로를 이용해서 호스트끼리 메시지를 주고받는 방식을 의미합니다. 회선 교환 방식은 미리 회선을 설정한다는 점에서 주어진 시간 동안에 전송되는 데이터의 양이 비교적 일정하고 안정적입니다. 다만, 회선 이용 효율이 떨어진다는 단점이 존재합니다. 회선 교환 방식의 대표적인 사례로는 유선 전화망이 있습니다.

반면, 패킷 교환 방식(Packet Switching) 은 목적지를 정해두고 메시지를 패킷으로 분할해서 보내고, 목적지에서 패킷을 조립해서 확인하는 방식입니다. 패킷 교환 방식에서 라우터는 주어진 패킷을 최적 경로로 전달하는 핵심적인 역할을 수행합니다. 이러한 특성으로 인해서 경로는 수시로 변경될 수 있고, 데이터를 전송하는 동안에만 네트워크 자원을 사용한다는 점에서 회선 교환 방식과 차이가 있습니다. 패킷 교환 방식은 회선 이용 효율이 높습니다. 하지만, 경로 탐색에서 지연이 발생하거나, 패킷을 위한 헤더로 인한 오버헤드가 발생할 수 있습니다.

이미지 출처: 위키피디아

추가 학습 자료를 공유합니다.

참고 링크

[218] String 객체는 가변일까요, 불변일까요? 그렇게 생각하신 이유도 함께 설명해 주세요.

백엔드

String 객체는 가변일까요, 불변일까요? 그렇게 생각하신 이유도 함께 설명해 주세요.

백엔드와 관련된 질문이에요.

String 객체는 불변(Immutable) 입니다. String 클래스는 내부적으로 final 키워드가 선언된 byte[] 필드를 사용해서 문자열을 저장하기 때문입니다. 또한, String은 참조 타입(Reference Type)이기 때문에 concat(), replace(), toUpperCase()와 같은 String 메서드를 호출하면 새로운 String 객체를 참조하고 기존 객체를 수정하지 않습니다. 따라서 String 객체를 불변하게 유지할 수 있습니다.

String을 불변으로 설계한 이유는 무엇일까요?

String을 불변으로 설계한 덕분에 많은 이점을 얻을 수 있습니다.

  1. String Constant Pool을 사용할 수 있습니다. 이를 통해 동일한 문자열의 String 변수들은 같은 객체를 공유하기 때문에 메모리를 효율적으로 사용할 수 있습니다.
  2. 불변한 객체는 멀티 스레드 환경에서 thread-safe합니다. 문자열을 변경하면 String Constant Pool에 새로운 객체를 생성하기 때문에 동기화를 신경쓸 필요가 없습니다.
  3. 해시코드를 한 번만 계산하고 이를 캐싱해서 재사용할 수 있습니다.
  4. 비밀번호, 토큰, URL 등의 민감한 정보를 안전하게 다룰 수 있습니다. 불변한 객체는 변경할 수 없기 때문에 민감한 정보가 예기치 않게 수정되는 것을 방지할 수 있습니다.

리터럴로 생성한 String 객체와 생성자로 생성한 String 객체를 비교하면 어떤 차이가 있을까요?

두 방식으로 생성한 객체는 같은 문자열을 갖더라도 메모리 상에서 다르게 처리됩니다.

String first = "hello"; // 리터럴로 생성
String second = new String("hello"); // 생성자로 생성
String third = "hello";

System.out.println(System.identityHashCode(first)); // 498931366
System.out.println(System.identityHashCode(second)); // 2060468723
System.out.println(System.identityHashCode(third)); // 498931366

리터럴로 생성한 String 객체는 Heap 영역의 String Constant Pool에 저장되어 동일한 문자열을 재사용할 수 있습니다. 문자열이 String Constant Pool에 이미 존재하면 같은 주소를 참조합니다. 반면, 생성자로 생성한 String 객체는 Heap 영역에 저장되어 동일한 문자열이더라도 항상 새로운 객체를 생성합니다.

String first = "hello";
String second = new String("hello");
String third = second.intern(); // intern() 메서드 사용

System.out.println(System.identityHashCode(first)); // 498931366
System.out.println(System.identityHashCode(second)); // 2060468723
System.out.println(System.identityHashCode(third)); // 498931366

intern() 메서드를 사용하면 Heap 영역에 저장된 String 객체를 String Constant Pool에 저장할 수 있습니다. intern() 메서드는 해당 문자열이 String Constant Pool에 존재할 경우 그 주솟값을 반환하고, 없을 경우 String Constant Pool에 추가하고 새로운 주솟값을 반환합니다.

추가 학습 자료를 공유합니다.

참고 링크

[219] Infrastructure as Code(IaC)에 대해 설명해 주세요.

백엔드

Infrastructure as Code(IaC)에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

코드형 인프라(Infrastructure as Code, IaC) 는 수동 프로세스 대신 코드를 통해 인프라를 프로비저닝하고 관리하는 방법입니다. 기존의 수동 설정 방식은 반복 작업이 많고 휴먼 에러가 발생하기 쉬우며, 인프라 설정을 별도로 문서화해 관리해야 하는 번거로움이 있습니다. IaC는 이러한 문제를 해결하기 위해 등장했으며, 인프라를 코드로 관리함으로써 일관성을 보장하고 운영 효율성을 높일 수 있습니다.

IaC는 크게 선언적(Declarative)방식과 명령형(Imperative)방식으로 나뉩니다. 선언적 방식은 최종 상태를 정의하면 IaC 도구가 이를 자동으로 구성하는 방식입니다. 사용자는 원하는 결과를 기술하기만 하면 되고, 수행 과정은 도구가 처리합니다. 대표적인 도구로는 Terraform과 AWS CloudFormation 등이 있습니다. 명령형 방식은 구성 방법을 직접 정의하는 방식입니다. 사용자가 인프라를 설정하는 단계를 코드로 정의하며 명령어 기반으로 실행됩니다. 대표적인 도구로는 Ansible과 AWS CDK 등이 있습니다.

Infrastructure as Code의 장점과 단점은 무엇인가요?

장점

  • Git과 같은 형상 관리 도구를 활용해서 변경 사항을 추적할 수 있습니다.
  • 코드 자체가 문서 역할을 하며 협업할 때 코드 리뷰를 통해 인프라 변경 사항을 검토할 수 있습니다.
  • 수동 작업없이 코드 실행만으로 인프라 구축을 자동화할 수 있습니다.
  • 코드를 재사용할 수 있기 때문에 비슷한 인프라를 구축할 때 시간을 절약할 수 있습니다.

단점

  • 다양한 도구의 사용법을 익혀야 하기 때문에 러닝 커브가 발생할 수 있습니다.
  • 인프라의 상태 관리가 복잡할 수 있습니다.
  • 인프라 변경 시 문제가 발생했을 때 디버깅이 어려울 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[220] @OneToOne 연관관계에서 Lazy Loading을 설정할 때 주의할 점은 무엇일까요?

백엔드

@OneToOne 연관관계에서 Lazy Loading을 설정할 때 주의할 점은 무엇일까요?

백엔드와 관련된 질문이에요.

양방향 @OneToOne일 때 연관관계의 주인이 아닌 엔티티를 조회할 경우 Lazy Loading이 동작하지 않습니다.

JPA는 연관된 엔티티가 없으면 null로 초기화하고, 있으면 Lazy Loading이 설정되어 있을 경우 프록시 객체로 초기화합니다. 하지만 데이터베이스의 테이블 관점에서 보면, 연관관계의 주인이 아닌 엔티티는 연관관계를 참조할 FK가 없기 때문에 연관관계의 존재 여부를 알지 못합니다. 그래서 JPA는 null 혹은 프록시 객체 중 무엇으로 초기화할지 결정할 수 없게 되고, 결과적으로 연관된 엔티티의 존재 여부를 확인하는 추가 쿼리를 실행하기 때문에 Lazy Loading이 동작하지 않습니다. JPA의 한계이기 때문에 단방향으로 모델링하거나 Lazy Loading이 정말 필요한 것인지 다시 검토해 보아야 합니다.

@Entity(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
    private Account account;
}

@Entity
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    private User user;
}
@Test
void lazyTest() {
    userRepository.save(new User());

    userRepository.findById(1L).orElseThrow();
}

추가 학습 자료를 공유합니다.

참고 링크

[221] 자바에서 Object 타입인 value를 String으로 타입 캐스팅하는 것과 String.valueOf()를 사용하는 것의 차이점은 무엇인가요?

백엔드

자바에서 Object 타입인 value를 String으로 타입 캐스팅하는 것과 String.valueOf()를 사용하는 것의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

두 방식 모두 String 타입으로 변환하는 것은 동일하지만, 동작 방식과 예외 처리에서 차이가 있습니다.

(String) value로 타입 캐스팅 하는 것은 value가 String 타입이 아닌 경우 ClassCastException이 발생하며, value가 null인 경우 그대로 null을 반환하여 이후 메서드를 호출할 때 NullPointerException이 발생합니다. 타입 캐스팅은 타입 안정성이 부족하기 때문에 캐스팅하는 타입이 확실할 때만 사용해야 합니다.

Object intValue = 10;
String str1 = (String) intValue; // ClassCastException

Object nullValue = null;
String str2 = (String) nullValue; // null
str2.concat("maeilmail"); // NullPointerException

String.valueOf(value)는 value가 String 타입이 아닌 경우 value.toString()을 호출하여 String으로 변환하며, value가 null인 경우 "null" 문자열을 반환합니다.

Object intValue = 10;
String str1 = String.valueOf(intValue); // "10"

Object nullValue = null;
String str2 = String.valueOf(nullValue); // "null"
str2.concat("maeilmail"); // "nullmaeilmail"

타입 캐스팅에서 발생하는 예외는 런타임 시점에 발생하기 때문에 String.valueOf()가 더 안전하고 예외를 방지할 수 있습니다.

타입 캐스팅할 때 ClassCastException을 방지하는 방법은 무엇이 있을까요?

캐스팅할 타입과 맞는지 먼저 확인 후 캐스팅하면 ClassCastException을 방지할 수 있습니다. 이때 instanceof를 사용하면 안전하게 변환할 수 있습니다.

Object intValue = 10;

if (intValue instanceof String str) {
    System.out.println(str);
} else {
    // ...
}

String.valueOf(null)이 "null"을 반환하는 것은 문제가 될 수도 있지 않나요?

"null"이라는 문자열과 null 자체는 다른 의미를 가질 수 있기 때문에 문제가 될 수 있습니다. 특히, JSON 변환이나 데이터베이스에 저장할 때 null이 "null" 문자열로 저장되어서 오류가 발생할 가능성이 있습니다. 원치 않는 "null" 문자열을 방지하려면 미리 null 여부를 검증하고 따로 처리하거나, Objects.toString()을 사용해서 null일 경우 다른 문자열로 처리하는 방법을 사용할 수 있습니다.

참고 링크

참고 링크 없음

[227] 자료구조 트라이에 대해서 설명해주세요.

백엔드

자료구조 트라이에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

자료구조 트라이(Trie) 는 문자열을 저장하고 효율적으로 탐색하기 위한 트리 형태의 자료 구조입니다. 트라이는 문자열을 탐색할 때 단순히 비교하는 것에 비해서 효율적으로 찾을 수 있지만, 각 정점이 자식에 대한 링크를 모두 가지고 있기 때문에 저장 공간을 더욱 많이 사용한다는 특징이 있습니다. 주로, 검색어 자동완성이나 사전 찾기 기능을 구현할 때 트라이 자료구조를 고려할 수 있습니다.

출처 : 위키백과

트라이는 어떻게 구현할 수 있나요? 🤔

트라이 자료구조에서 루트는 항상 비어있으며, 각 간선은 추가될 문자를 키로 가지고 있습니다. 또한, 각 정점은 이전 정점의 값과 간선의 키를 더한 결과를 값으로 가집니다. 트라이를 구현할 때는 이러한 구조를 염두에 두면서 해시 테이블과 연결 리스트를 이용하여 구현할 수 있습니다.

class TrieTest {

    @Test
    void trieTest() {
        Trie trie = new Trie();
        trie.insert("maeilmail");
        assertThat(trie.has("ma")).isTrue();
        assertThat(trie.has("maeil")).isTrue();
        assertThat(trie.has("maeilmail")).isTrue();
        assertThat(trie.has("mail")).isFalse();
    }

    class Trie {

        private final Node root = new Node("");

        public void insert(String str) {
            Node current = root;
            for (String ch : str.split("")) {
                if (!current.children.containsKey(ch)) {
                    current.children.put(ch, new Node(current.value + ch));
                }
                current = current.children.get(ch);
            }
        }

        public boolean has(String str) {
            Node current = root;
            for (String ch : str.split("")) {
                if (!current.children.containsKey(ch)) {
                    return false;
                }
                current = current.children.get(ch);
            }
            return true;
        }
    }

    class Node {

        public String value;
        public Map<String, Node> children;

        public Node(String value) {
            this.value = value;
            this.children = new HashMap<>();
        }
    }
}

추가 학습 자료를 공유합니다.

참고 링크

[229] 자바에서 제네릭의 공변, 반공변, 무공변에 대해 설명해 주세요.

백엔드

자바에서 제네릭의 공변, 반공변, 무공변에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

자바에서 제네릭(Generic) 은 기본적으로 무공변(Invariant) 입니다. 무공변이란 타입 S, T가 있을 때 서로 관계가 없다는 것을 의미합니다. S와 T가 서로 상속 관계이면 공변성이 있지만 제네릭은 상속 관계가 호환되지 않습니다. 따라서 타입이 정확히 일치하지 않으면 컴파일 에러가 발생합니다.

public class Animal {
}

public class Cat extends Animal {
}

List<Animal> animals = new ArrayList<Cat>(); // 컴파일 에러
List<Cat> cats = new ArrayList<Animal>(); // 컴파일 에러

무공변은 타입 안정성을 보장하지만 타입의 유연성이 부족하다는 단점이 있어, 자바에서는 와일드카드(?)와 extends, super 키워드로 공변과 반공변을 지원합니다.

공변(Covariant) 은 S가 T의 하위 타입일 때 S는 T가 될 수 있다는 것을 의미합니다. 제네릭에서는 <? extends T>를 사용하여 하위 타입을 허용하고 읽기 전용으로 사용할 수 있습니다. 쓰기는 null만 가능합니다.

반공변(Contravariant) 은 S가 T의 하위 타입일 때 T는 S가 될 수 있다는 것을 의미합니다. 제네릭에서는 <? super S>를 사용하여 상위 타입을 허용하고 쓰기 전용으로 사용할 수 있습니다. 읽기는 Object 타입으로만 가능합니다.

PECS란 무엇인가요?

PECS(Producer Extends, Consumer Super) 는 제네릭에서 와일드카드의 상위 또는 하위 경계를 설정할 때 사용하는 가이드라인입니다. 객체를 생산할 때는 <? extends T>를 사용하고, 소비할 때는 <? super T>를 사용합니다.

public void produce(List<? extends Animal> animals) { // animals가 생산자 역할
    for (Animal a : animals) {
        System.out.println(a);
    }
}

public void consume(List<? super Cat> cats) { // cats가 소비자 역할
    cats.add(new Cat());
}

<?>와 <Object>의 차이점은 무엇인가요?

<?><Object>는 모든 타입을 수용하는 것처럼 보이지만 동작 방식에 차이가 있습니다.

<?>는 모든 타입을 메서드 인자로 받을 수 있지만 null 외에는 값을 추가할 수 없기 때문에 읽기 전용으로 사용됩니다. <Object><Object> 외의 타입을 메서드 인자로 받을 수 없지만 모든 객체를 추가할 수 있기 때문에 읽기, 쓰기 모두 가능합니다.

추가 학습 자료를 공유합니다.

참고 링크

[232] Keep Alive에 대해 설명해 주세요.

백엔드

Keep Alive에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

Keep Alive는 네트워크 또는 시스템에서 커넥션을 지속해서 유지하기 위해 사용되는 기술이나 설정을 의미합니다.

HTTP 프로토콜에서 Keep-Alive는 하나의 TCP 커넥션으로 여러 개의 HTTP 요청과 응답을 주고받을 수 있도록 하는 기능입니다. HTTP/1.0에서는 요청마다 새로운 커넥션을 열고 닫았지만, HTTP/1.1부터는 Keep-Alive가 기본적으로 활성화되어 있어 커넥션을 재사용할 수 있습니다.

TCP 프로토콜에서 Keep-Alive는 커넥션이 유휴 상태일 때 커넥션이 끊어지지 않도록 주기적으로 패킷을 전송하는 기능입니다.

Keep Alive의 장점과 단점은 무엇이 있을까요?

장점

  • 커넥션을 재사용하여 네트워크 비용을 절감할 수 있습니다.
  • handshake에 필요한 RTT(Round Trip Time)가 감소하여 네트워크 지연 시간(Latency)을 줄일 수 있습니다.
  • handshake 과정에서 발생하는 CPU, 메모리 등의 리소스 소비를 줄일 수 있습니다.

단점

  • 유휴 상태일 때에도 커넥션을 점유하고 있기 때문에 서버의 소켓이 부족해질 수 있습니다.
  • DoS 공격으로 다수의 연결을 길게 유지하여 서버를 과부하시킬 수 있습니다.
  • 타임아웃 설정이 적절하지 않으면 커넥션 리소스가 낭비될 수 있습니다.

HTTP와 TCP의 Keep Alive는 어떤 차이가 있나요?

HTTP의 Keep-Alive는 클라이언트에서 일정 시간 동안 요청이 없으면 타임아웃만큼 커넥션을 유지하고, 타임아웃이 지나면 커넥션이 끊어집니다.

TCP의 Keep-Alive는 커넥션이 유휴 상태일 때 주기적으로 패킷을 전송해서 커넥션이 살아있음을 확인하고, 살아있으면 커넥션을 지속해서 유지합니다.

추가 학습 자료를 공유합니다.

참고 링크

[233] 단일 프로세스 시스템에 대해서 설명해주세요.

백엔드

단일 프로세스 시스템에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

단일 프로세스 시스템은 한 번에 하나의 프로그램만 실행합니다. 또 다른 프로그램을 실행하려면, 먼저 실행 중이던 프로그램을 종료시키고 그 다음 프로그램을 실행해야 합니다.

단일 프로세스의 단점은 무엇이고 어떻게 개선할 수 있나요?

CPU 사용률이 좋지 않습니다. 프로세스가 CPU를 사용하는 작업을 처리하다가, IO 작업을 하게 되면 CPU는 그 때 마다 아무런 일을 하지 않는 상태로 대기합니다. 이 문제를 개선하기 위해 멀티 프로그래밍을 사용할 수 있습니다.

멀티 프로그래밍은 단일 프로세스 시스템과 달리 하나의 프로세스가 IO 작업을 하게 되어 대기하게 되면, 다른 프로세스가 실행되어 CPU가 아무 일도 하지 않는 시간을 줄입니다. 멀티 프로그래밍의 주된 목적은 CPU 사용률을 극대화 하는데 있습니다.

멀티 프로그래밍의 단점은 무엇이고 어떻게 개선할 수 있나요?

멀티 프로그래밍의 단점은 하나의 프로세스가 작업 시 CPU 사용하는 작업을 하면 계속 점유하게 되어 다른 프로세스가 작업을 하지 못하고 대기하는 것 입니다. 이를 위한 해결책은 두개 이상의 프로세스가 CPU를 사용할 때, 아주 짧은 시간 동안(Quantum)만 번갈아가며 실행될 수 있도록 하여 하나의 프로세스가 CPU를 독점하거나, 대기해야 하는 비효율을 개선할 수 있습니다. 이런 종류의 시스템을 멀티 태스킹(Multi-Tasking) 이라고 합니다.

멀티 프로그래밍과 멀티 태스킹의 차이는 무엇인가요?

멀티태스킹과 멀티프로그래밍은 동시에 여러 프로그램을 실행시킨다는 면에서 유사하지만, CPU 타임을 아주 짧게 쪼개서 CPU 타임 안에서 프로세스들이 서로 번갈아 가면서 실행될 수 있도록 했다는 점이 차이가 있습니다.

멀티태스킹은 프로세스의 응답 시간을 최소화 시키는데 목적입니다. 응답시간을 최소화 시킨다는 것은 사용자 입장에서 마치 정말 여러 프로그램이 동시에 실행되는 것 처럼 느껴지게 만듭니다. 아주 짧은 타임 슬라이스 안에서 여러 프로세스들이 교대로 실행이 되면서 즉각적인 응답을 보여줍니다.

멀티태스킹의 핵심은 타임 슬롯을 아주 짧게 쪼개서 프로세스들이 번갈아가며 실행을 하게 됨으로써 어떤 프로그램을 실행시키거나 어떤 동작을 했을 때 즉각적인 반응을 받을 수 있도록 하여 동시에 여러 프로그램이 실행되는 것과 같은 느낌을 주는 것 입니다.

추가 학습 자료를 공유합니다.

참고 링크

[234] 멀티 태스킹 시스템의 한계에 대해서 설명해주세요.

백엔드

멀티 태스킹 시스템의 한계에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

멀티 태스킹 시스템을 사용하더라도 아래와 같은 문제점이 남아 있습니다.

  1. 하나의 프로세스가 동시에 여러 작업을 수행하지 못함
    • 여러 프로세스를 생성하여 문제를 해결할 수는 있으나, 프로세스가 많아지면 관리와 자원 소모 측면에서 여러 가지 단점이 발생합니다.
  2. 무거운 프로세스 간 컨텍스트 스위칭
    • 컨텍스트 스위칭은 CPU가 한 프로세스에서 다른 프로세스로 전환할 때 발생하며, 이 작업은 상대적으로 무겁고 비용이 큽니다.
  3. 프로세스 간 데이터 공유의 어려움
    • 각 프로세스는 독립적인 메모리 공간을 사용하기 때문에, 서로 다른 프로세스 간에 데이터를 공유하는 것이 까다롭습니다.

스레드(Thread)의 등장과 특징

이러한 문제점을 해결하기 위해 등장한 것이 스레드입니다. 스레드는 한 프로세스 내에서 여러 작업을 동시에 실행할 수 있도록 도와줍니다.

  1. 프로세스 내 여러 스레드 보유
    • 하나의 프로세스는 하나 이상의 스레드를 가질 수 있으며, 각 스레드가 하나의 작업을 담당합니다. 이는 여러 작업을 동시에 실행할 수 있도록 하는 핵심 요소입니다.
  2. CPU 실행 단위
    • 과거에는 프로세스가 CPU에서 실행되는 단위였다면, 현재는 스레드가 CPU에서 실행되는 최소 단위가 되었습니다.
    • 기본적으로 프로세스는 하나의 스레드를 가지고 있으며, 필요에 따라 추가적인 스레드를 생성할 수 있습니다.
  3. 가벼운 스레드 간 컨텍스트 스위칭
    • 스레드는 동일 프로세스 내에서 메모리 영역(특히 Heap)을 공유하므로, 스레드 간의 컨텍스트 스위칭은 프로세스 간 스위칭보다 훨씬 가볍습니다.
    • 다만, 각 스레드는 고유한 Stack, 포인터, 프로그램 카운터 등을 가지고 있어, 자신만의 실행 상태를 유지합니다. 같은 프로세스 내의 스레드들은 메모리 영역은 공유하지만, 각 스레드만의 고유한 정보(스택, 프로그램 카운터 등)를 보유합니다.

멀티스레딩과 멀티프로세싱

  • 멀티스레딩 (Multi-threading):
    하나의 프로세스 내에서 여러 스레드를 통해 동시에 여러 작업을 실행하는 기법입니다.
  • 확장된 멀티태스킹 개념:
    과거에는 프로세스 간 아주 짧은 시간의 스위칭으로만 작업을 나누었다면,
    이제는 여러 프로세스와 스레드가 아주 짧게 쪼개진 CPU 타임을 나눠가져 실행됩니다.
  • 멀티프로세싱 (Multi-processing):
    두 개 이상의 프로세서나 코어를 활용하여, 여러 프로세스가 동시에 실행되는 시스템입니다.

참고 링크

참고 링크 없음

[235] JCF 자료구조의 초기 용량을 지정하면 좋은 점이 무엇인가요?

백엔드

JCF 자료구조의 초기 용량을 지정하면 좋은 점이 무엇인가요?

백엔드와 관련된 질문이에요.

JCF에서 ArrayList를 기준으로 설명하겠습니다. ArrayList의 기본 용량(capacity)은 10이며, 용량이 가득 차면 기존 크기의 1.5배(oldCapacity + (oldCapacity >> 1)) 로 증가합니다. 예를 들어, MAX = 5,000,000일 때 기본 설정으로 리스트를 생성하면 여러 번의 리사이징이 발생해 최종 capacity가 6,153,400까지 증가하고, 약 70MB의 메모리를 사용합니다. 반면, new ArrayList(MAX)로 초기 용량을 설정하면 불필요한 리사이징 없이 5,000,000 크기로 고정되며, 약 20MB의 메모리만 사용하게 됩니다.

public class Main {

    private static final int MAX = 5_000_000;

    public static void main(String[] args) {
        MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
        printUsedHeap(1, memoryMXBean);

        List<String> arr = new ArrayList<>();
        for (int i = 0; i < MAX; i++) {
            arr.add("a");
        }

        printUsedHeap(2, memoryMXBean);
        printUsedHeap(3, memoryMXBean);
    }

    private static void printUsedHeap(int logIndex, MemoryMXBean memoryMXBean) {
        MemoryUsage heapUsage = memoryMXBean.getHeapMemoryUsage();
        long used = heapUsage.getUsed();
        System.out.println("[" + logIndex + "] " + "Used Heap Memory: " + used / 1024 / 1024 + " MB");
    }
}

정리하자면, JCF에서 가변 크기의 자료 구조를 사용하는 경우, 초기 용량을 설정하면 리사이징을 줄이고 메모리와 연산 비용을 절약할 수 있습니다.

로드 팩터와 임계점이란 무엇인가요?

로드 팩터(load factor) 란 특정 크기의 자료 구조에 데이터가 어느 정도 적재되었는지 나타내는 비율입니다. 가변적인 크기를 가진 자료구조에서 크기를 증가시켜야 하는 임계점(Threshold) 을 계산하기 위해서 사용됩니다. 예를 들어, JCF에서 HashMap의 경우에는 내부적으로 배열을 사용하며, 초기 사이즈는 16입니다. 이때, HashMap의 기준 로드 팩터는 0.75이므로 임계점은 12(capacity * load factor = threshold) 입니다. 만약, HashMap 내부 배열의 사이즈가 12를 넘는 경우 내부 배열의 크기를 2배 늘리고, 재해싱을 수행합니다.

추가 학습 자료를 공유합니다.

참고 링크

[236] 가상화에 대해 설명해 주세요.

백엔드

가상화에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

가상화(Virtualization) 란 하나의 물리적인 컴퓨팅 리소스를 논리적으로 분리하여 여러 개의 가상 리소스를 생성해 사용하는 기술을 의미합니다. 서버, 스토리지, 네트워크 등 다양한 IT 리소스를 가상화할 수 있으며, 클라우드 컴퓨팅에 핵심이 되는 기술 중 하나로 활용됩니다.

가상화가 왜 필요한가요?

  1. 서버를 가상화할 경우 물리 서버에 여러 가상 서버를 배치하여 하드웨어 리소스를 효율적으로 사용할 수 있습니다.
  2. 물리적인 하드웨어 수를 줄일 수 있어 초기 구축 비용 및 유지 관리 비용을 절감할 수 있습니다.
  3. 각 가상 리소스들은 격리되어 있어 하나의 가상 리소스에 장애가 발생해도 다른 가상 리소스에 영향을 미치지 않습니다.
  4. 가상 리소스를 필요에 따라 빠르게 생성하거나 삭제할 수 있어 인프라 운영이 유연해집니다.
  5. 가상 리소스는 소프트웨어 기반이기 때문에 장애 복구가 용이하고 가용성을 높일 수 있습니다.

서버 가상화에 대해 자세히 설명해 주세요.

가장 일반적인 가상화 형태로, 물리적인 서버를 여러 개의 가상 머신(VM, Virtual Machine) 으로 나누어 사용하는 기술입니다. VM을 사용하면 하나의 물리 서버에서 여러 운영 체제를 실행할 수 있으며, 각각의 VM은 독립적으로 운영됩니다. 이 때 VM은 게스트, VM들이 실행되는 물리 서버는 호스트라고 합니다.

하이퍼바이저(Hypervisor) 는 물리 서버에 설치되는 가상화 소프트웨어로, 하드웨어로부터 VM에 필요한 CPU, 메모리 등의 리소스를 할당하고 VM들이 서로 격리되어 동작할 수 있도록 관리하는 역할을 합니다. 하이퍼바이저는 실행 위치에 따라 Type 1과 Type 2로 나뉩니다.

Type 1은 Native 또는 Bare Metal 하이퍼바이저라도 하며, 하드웨어에서 직접 실행되어 별도의 호스트 OS가 필요하지 않습니다. 일반적으로 엔터프라이즈 데이터 센터에서 사용하며 KVM과 Microsoft의 Hyper-V 등이 대표적인 예시입니다.

Type 2는 Hosted 하이퍼바이저라고도 하며, 호스트 OS 위에서 하나의 애플리케이션으로 실행됩니다. 개인용 PC나 개발 환경에서 주로 사용하며 Oracle의 VirtualBox와 VMware의 Workstation 등이 대표적인 예시입니다.

VM과 컨테이너의 차이점을 알고 계신가요?

VM은 하이퍼바이저를 통해 호스트 시스템에서 다수의 게스트 OS를 동시에 실행하는 기술입니다. VM은 다양한 OS를 실행할 수 있고 높은 수준의 격리를 제공하지만, 게스트 OS를 포함하기 때문에 무겁고 성능이 느리다는 단점이 있습니다.

컨테이너는 호스트 OS의 커널을 공유하며, 컨테이너 이미지를 통해 애플리케이션을 실행하는 기술입니다. 컨테이너는 VM보다 가볍고 빠른 성능, 높은 확장성을 보이지만, 커널을 공유하기 때문에 보안에 취약할 수 있고 호스트 OS와 동일한 환경으로 구성해야 한다는 단점이 있습니다.

다양한 OS가 필요한 환경이거나 높은 격리 수준이 중요할 경우 VM을 사용하고, 빠른 배포와 확장이 필요한 클라우드 네이티브 환경이거나 마이크로서비스 아키텍처를 활용할 경우 컨테이너를 사용할 수 있습니다. 하지만 VM과 컨테이너는 서로 대체하는 기술이 아니기 때문에 VM 위에서 컨테이너를 실행하는 방식과 같이 목적에 따라 함께 활용할 수도 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[242] 자바 프로그램이 실행되는 흐름을 설명해 주세요.

백엔드

자바 프로그램이 실행되는 흐름을 설명해 주세요.

백엔드와 관련된 질문이에요.

우리가 작성한 .java 파일은 JDK에 포함된 javac(java compiler) 를 통해 컴파일됩니다. 이 과정에서 JVM이 이해할 수 있는 바이트 코드로 변환되어 .class 파일이 생성됩니다. 이후부터는 JVM이 담당하는데요. 먼저 클래스 로더(Class Loader) 가 바이트 코드를 JVM 메모리에 동적으로 로드합니다. 로드된 바이트 코드는 Method Area에 저장되며, 이때 로딩(Loading), 링킹(Linking), 초기화(Initialization) 단계를 거칩니다. 그다음, 실행 엔진(Execution Engine) 이 로드된 바이트 코드를 실행합니다. 하지만 바이트 코드는 컴퓨터가 읽을 수 없기 때문에 인터프리터(Interpreter)JIT 컴파일러(Just-In-Time Compiler) 를 함께 사용하여 기계어로 변환합니다. 인터프리터는 바이트 코드를 한 줄씩 읽어서 실행하는 방식이고, JIT 컴파일러는 자주 실행되는 메서드(Hotspot)를 감지하면 해당 메서드 전체를 네이티브 코드로 변환하여 캐싱합니다.

클래스 로더가 바이트 코드를 동적으로 로드한다는 것은 무슨 의미인가요?

프로그램이 시작될 때 모든 클래스를 한꺼번에 로드하는 것이 아니라, 런타임 시점에 필요한 클래스만 로드하는 것을 의미합니다. 클래스 로드는 인스턴스를 생성할 때, static 메서드나 변수를 사용할 때, static 변수에 값을 할당할 때 이루어집니다. 이러한 동적 로드 방식은 불필요한 클래스 로드를 방지하여 메모리를 효율적으로 사용할 수 있습니다.

로딩, 링킹, 초기화 단계가 무엇인가요?

로딩(Loading) 은 클래스 로더가 .class 파일을 읽어 JVM 메모리에 로드하는 단계입니다. 로드된 클래스는 Method Area에 저장됩니다.

링킹(Linking) 은 로드된 클래스가 실행될 수 있도록 준비하는 단계이며 세 가지의 과정으로 이루어집니다.

  1. Verification: .class 파일이 구조적으로 올바른지 확인합니다.
  2. Preparation: static 변수를 메모리에 할당하고 기본값으로 초기화합니다.
  3. Resolution: 런타임 상수 풀에 있는 심볼릭 레퍼런스를 실제 메모리 레퍼런스로 교체합니다.

초기화(Initialization) 는 static 변수를 사용자가 지정한 값으로 초기화하고 static 블록을 실행하는 단계입니다.

실행 엔진이 바이트 코드를 기계어로 변환할 때 인터프리터와 JIT 컴파일러를 함께 사용하는 이유는 무엇인가요?

인터프리터는 바이트 코드를 한 줄씩 읽어서 실행하는 방식이기 때문에 초기 실행 속도가 빠릅니다. 하지만 같은 코드가 반복적으로 실행될 경우 매번 해석해야 해서 성능이 저하되는 단점이 있습니다. 초기 JVM은 인터프리터만 사용했지만, 이러한 단점을 보완하기 위해 JIT 컴파일러가 도입되었습니다.

JIT 컴파일러는 자주 실행되는 메서드를 네이티브 코드로 변환하여 캐싱합니다. 이렇게 변환된 코드는 반복 실행 시 인터프리터보다 훨씬 빠르게 실행됩니다. 하지만 JIT 컴파일 과정 자체에 시간이 소요되기 때문에 초기 실행 시 오버헤드가 발생할 수 있습니다. 따라서 JVM은 두 방식을 함께 사용하여 초기 실행 속도와 높은 반복 실행 성능을 동시에 달성할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[243] 낙관적 락과 비관적 락에 대해 설명해 주세요.

백엔드

낙관적 락과 비관적 락에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

낙관적 락과 비관적 락은 데이터베이스 트랜잭션에서 동시성 제어를 위한 주요 기법입니다. 데이터 무결성을 유지하면서 여러 트랜잭션이 동시에 데이터에 접근할 때 발생할 수 있는 충돌을 해결할 때 사용됩니다.

낙관적 락(Optimistic Lock) 은 데이터 충돌이 적을 것으로 가정하고, 데이터를 읽을 때 락을 설정하지 않고 트랜잭션이 데이터를 수정할 때 충돌이 발생하지 않았는지 확인하는 방식입니다. 보통 version과 같은 별도의 구분 컬럼을 사용해서 데이터가 변경되었는지 확인하며, 충돌이 발생하면 데이터베이스가 아닌 애플리케이션에서 직접 롤백하거나 재시도 처리를 해야 합니다.

비관적 락(Pessimistic Lock) 은 데이터 충돌이 많을 것으로 가정하고, 트랜잭션이 시작될 때 공유락(Shared Lock, S-Lock) 또는 베타락(Exclusive Lock, X-Lock)을 설정하여 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 하는 방식입니다.

S-Lock과 X-Lock은 다음과 같습니다.

S-Lock: 다른 트랜잭션에서 읽기는 가능하지만 쓰기는 불가능합니다.
X-Lock: 다른 트랜잭션에서 읽기, 쓰기 모두 불가능합니다.
    cf. MySQL은 일관된 읽기(Consistent Nonlocking Reads)를 지원하여 X-Lock이 걸려있어도 단순 SELECT로 읽을 수 있습니다.

두 방식의 차이점은 무엇인가요?

첫 번째는 충돌 가능성입니다. 낙관적 락은 충돌이 자주 발생하지 않을 것이라고 가정하고, 비관적 락은 충돌이 자주 발생할 것이라고 가정합니다.

두 번째는 데이터베이스 락 사용 여부입니다. 낙관적 락은 락을 사용하지 않고, 비관적 락은 트랜잭션이 시작될 때 락을 설정합니다.

세 번째는 성능입니다. 낙관적 락은 락을 설정하지 않기 때문에 성능이 더 좋을 수 있습니다. 하지만 충돌이 발생할 경우 롤백하거나 재시도 처리를 해야 하기 때문에 성능이 떨어질 수 있습니다. 비관적 락은 락을 설정하기 때문에 다른 트랜잭션이 대기해야 하며, 이로 인해 성능이 저하될 수 있습니다.

결론적으로 낙관적 락은 충돌이 발생하면 해결하는 방식이고, 비관적 락은 애초에 충돌을 방지하는 방식입니다.

언제 어떤 락을 사용하는 것이 유리할까요?

데이터 충돌이 자주 발생하거나 데이터 무결성이 중요한 경우에는 비관적 락을 사용하는 것이 유리할 수 있습니다. 조회 작업이 많고 동시 접근 성능이 중요한 경우에는 낙관적 락을 사용하는 것이 유리할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[245] RDB에서 페이징 쿼리의 필요성을 설명해 주세요.

백엔드

RDB에서 페이징 쿼리의 필요성을 설명해 주세요.

백엔드와 관련된 질문이에요.

페이징 쿼리(Paging Query) 는 전체 데이터를 부분적으로 나누어 데이터를 조회하거나 처리할 때 사용됩니다. 데이터를 상대적으로 작은 단위로 나누어 처리하기 때문에 데이터베이스나 애플리케이션의 리소스 사용 효율이 증가하며, 로직 처리 시간을 단축 시킬 수 있습니다. MySQL에서 페이징 쿼리는 일반적으로 LIMIT, OFFSET 구문을 사용하여 작성합니다.

select *
from subscribe
limit 500
offset 0;

LIMIT, OFFSET 방식 페이징 쿼리의 단점은 무엇이고, 어떻게 해결할 수 있나요?

LIMIT, OFFSET 방식의 페이징 쿼리는 뒤에 있는 데이터를 읽을 수록 점점 응답 시간이 길어질 수 있는데요. 왜냐하면, DBMS는 지정된 OFFSET 수만큼 모든 레코드를 읽은 이후에 데이터를 가져오기 때문입니다. 이 문제를 해결하기 위해서 OFFSET을 활용하지 않는 페이징 쿼리를 사용하는 것이 대표적입니다. 예를 들어, 다음과 같은 테이블이 있다고 가정하겠습니다.


create table subscribe (
   id int not null auto_increment,
   deleted_at datetime null,
   created_at datetime not null,
   primary key(id),
   key idx_deleted_at_id(deleted_at, id)
);

이때, 특정 기간 동안 구독을 해제한 사용자를 조회하는 쿼리는 다음과 같습니다.

select *
from subscribe
where
    deleted_at >= ? and deleted_at < ?

OFFSET을 사용하지 않는 페이징은 이전 페이지의 마지막 데이터 값을 기반으로 다음 페이지를 조회합니다. 이때, 상황에 따라서 첫 페이지 조회 쿼리와 N 회차 쿼리의 모양이 다를 수 있습니다. 위 예제에서 첫 페이지를 조회하는 쿼리는 다음과 같이 작성할 수 있습니다.

select *
from subscribe
where
    deleted_at >= ? and deleted_at < ?
order by deleted_at, id
limit 10;

그리고 첫 페이지 이후의 페이지는 이전에 조회된 페이지의 마지막 값을 기반으로 다음 페이지를 조회합니다. 만약, 이전 페이지의 마지막 값의 deleted_at이 '2024-01-01'이고, 식별자가 78이라면 다음과 같은 쿼리가 작성됩니다.

select *
from subscribe
where
   # deleted_at이 같은 케이스를 대응
   (deleted_at = '2024-01-01 00:00:00' and id > 78) or
   # 마지막 데이터 이후 데이터 조회
   (deleted_at > '2024-01-01 00:00:00' and deleted_at < ?)
order by deleted_at, id
limit 10;

추가 학습 자료를 공유합니다.

참고 링크

[247] 멀티 쓰레딩에 대해서 설명해 주세요.

백엔드

멀티 쓰레딩에 대해서 설명해 주세요.

백엔드와 관련된 질문이에요.

멀티쓰레딩은 여러 프로세스가 동시에 실행되는 멀티 태스킹과 달리 하나의 프로세스 내에서 여러 작업을 여러 쓰레드를 통해 동시에 실행할 수 있도록 하는 방식입니다.

멀티쓰레딩(Multi-Thread)의 주요 특징

1. 경량화된 실행 단위

  • 낮은 오버헤드: 스레드는 같은 프로세스 내에서 실행되므로, 프로세스 간의 컨텍스트 스위칭에 비해 스레드 간 전환은 훨씬 가볍고 빠릅니다.
  • 빠른 전환: 각 스레드는 자신만의 스택과 레지스터(프로그램 카운터)를 갖지만, 코드나 힙 메모리 등은 공유하기 때문에 전환 시 재설정해야 할 데이터의 양이 적어 전환 속도가 빠릅니다.

2. 효율적인 데이터 공유

  • 공유 메모리: 같은 프로세스 내의 스레드들은 힙 영역 등 주요 메모리 공간을 공유하므로, 데이터 전달이 빠르고 간편합니다.
  • 동기화 관리: 스레드 간의 데이터 공유는 IPC와 같은 복잡한 메커니즘 없이도 이루어지지만, 동시에 접근할 경우 동기화 문제는 여전히 존재합니다.

3. 응답성 및 처리 성능 향상

  • 병렬 처리: 멀티쓰레딩을 통해 I/O 작업과 CPU 집약적 작업을 분리하여 동시에 처리할 수 있으므로, 시스템 전체의 응답성이 향상됩니다.
  • 리소스 활용 최적화: CPU의 멀티코어 환경에서 각 스레드를 개별 코어에 할당하여 병렬 처리가 가능해지므로, 시스템 자원을 더욱 효율적으로 사용할 수 있습니다.

멀티쓰레딩을 사용하면 프로세스 기반의 멀티태스킹보다 낮은 비용의 컨텍스트 스위칭과 효율적인 메모리 사용, 그리고 빠른 데이터 공유가 가능해집니다. 결과적으로, I/O 작업이나 대기 작업을 별도의 스레드로 처리하여 주 스레드가 차단되지 않고 사용자 입력이나 다른 중요한 작업에 빠르게 대응할 수 있게 됩니다.

물론, 멀티쓰레딩도 완벽한 해결책은 아닙니다. 스레드들이 같은 메모리를 공유하다 보니 경쟁 상태교착 상태와 같은 동기화 문제가 발생할 수 있고, 이를 해결하는 과정이 복잡해지거나 디버깅이 어려워질 수 있습니다. 또한, 스레드 관리를 소홀히 하면 시스템 자원이 과도하게 사용될 위험도 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[248] PRG 패턴에 대해서 설명해 주세요.

백엔드

PRG 패턴에 대해서 설명해 주세요.

백엔드와 관련된 질문이에요.

PRG 패턴 은 Post/Redirect/Get 패턴의 약자로, 웹 애플리케이션에서 폼 제출 후 페이지 새로 고침이나 브라우저 뒤로 가기 등의 문제를 방지하기 위해 사용하는 디자인 패턴입니다. 일반적으로 멱등성이 보장되지 않는 POST 요청에 사용합니다. 예를 들어, 사용자가 웹 페이지에서 주문 버튼을 클릭하고 새로고침을 수행하면 2번의 POST 요청이 서버로 전달되는데요. 이러한 상황에서 PRG 패턴이 주로 사용됩니다.

출처 : 위키백과

PRG 패턴은 다음과 같은 단계를 따릅니다.

  • 사용자가 폼을 제출하면 클라이언트는 서버에 POST 요청을 보냅니다. 서버는 이 요청을 처리하여 데이터베이스를 업데이트하거나 다른 작업을 수행합니다. (Post)
  • 서버는 POST 요청을 처리한 후, 클라이언트에게 새로운 URL로 리디렉션하라는 응답을 보냅니다. 이 리디렉션은 클라이언트에게 302 Found 상태 코드와 함께 새로운 URL을 포함한 Location 헤더를 반환하여 수행됩니다. (Redirect)
  • 클라이언트는 서버의 응답을 받아 새로운 URL로 GET 요청을 보냅니다. 서버는 이 GET 요청을 처리하여 최종 결과 페이지를 클라이언트에게 반환합니다. (Get)

추가 학습 자료를 공유합니다.

참고 링크

[249] GC 알고리즘은 어떤 것이 있나요?

백엔드

GC 알고리즘은 어떤 것이 있나요?

백엔드와 관련된 질문이에요.

Serial GC는 JDK에 도입된 최초의 가바지 컬렉터이며, 단일 스레드로 동작하는 가장 단순한 형태입니다. 작은 힙 메모리와 단일 CPU 환경에 적합하며 Stop-The-World 시간이 가장 길게 발생합니다.

Parallel GC는 Java 5부터 8까지 default 가비지 컬렉터로 사용되었으며, Serial GC와 달리 Young 영역의 GC를 멀티 스레드로 수행합니다. 높은 처리량에 초점을 두기 때문에 Throughput GC라고도 불립니다.

Parallel Old GC는 Parallel GC의 향상된 버전으로, Old 영역에서도 멀티 스레드를 활용하여 GC를 수행합니다.

CMS(Concurrent Mark-Sweep) GC는 Java 5부터 8까지 사용된 가비지 컬렉터로, 애플리케이션 스레드와 병렬로 실행되어 Stop-The-World 시간을 최소화하도록 설계되었습니다. 하지만 메모리와 CPU 사용량이 많고, 메모리 압축을 수행하지 않아 메모리 단편화 문제가 있습니다. Java 9부터 deprecated 되고, Java 14에서 완전히 제거되었습니다.

G1(Garbage First) GC는 Java 9부터 default 가비지 컬렉터이며, 기존의 GC 방식과 달리 힙을 여러 개의 region으로 나누어 논리적으로 Young, Old 영역을 구분합니다. 처리량과 Stop-The-World 시간 사이의 균형을 유지하며 32GB보다 작은 힙 메모리를 사용할 때 가장 효과적입니다. GC 대상이 많은 region을 먼저 회수하기 때문에 garbage first라는 이름이 붙었습니다.

ZGC는 Java 11부터 도입된 가비지 컬렉터로, 10ms 이하의 Stop-The-World 시간과 대용량 힙을 처리할 수 있도록 설계되었습니다.

Shenandoah GC는 Red Hat에서 개발한 가비지 컬렉터로, Java 12부터 도입되었습니다. G1 GC와 마찬가지로 힙을 여러 개의 region으로 나누어 처리하며, ZGC처럼 저지연 Stop-The-World와 대용량 힙 처리를 목표로 합니다.

Epsilon GC는 Java 11부터 도입되었으며 GC 기능이 없는 실험용 가비지 컬렉터입니다. 애플리케이션 성능 테스트에서 GC 영향을 분리하거나 GC 오버헤드 없이 메모리 한계를 테스트할 때 사용되지만, 프로덕션 환경에는 적합하지 않습니다.

G1 GC에서 Humongous 객체란 무엇이며 어떻게 처리되나요?

Humongous 객체는 region 크기의 50% 이상을 차지하는 큰 객체를 의미합니다. Humongous 객체는 크기에 따라 하나 또는 여러 개의 연속된 region을 차지할 수 있고, region 내 잉여 공간은 다른 객체에 할당되지 않아 메모리 단편화가 발생할 수 있습니다. 또한, Young 영역을 거치지 않고 바로 Old 영역에 할당되기 때문에 Full GC가 발생할 가능성이 높아집니다. 이 문제를 해결하려면 -XX:G1HeapRegionSize 옵션을 사용하여 region 크기를 조정하거나, 큰 객체를 작은 객체로 분할하여 처리해 볼 수 있습니다.

2vCPU, 1GB 메모리를 가진 Linux 서버에 JDK 17을 설치하면 어떤 가비지 컬렉터가 사용될까요?

JDK 9부터 G1 GC가 default 가비지 컬렉터이지만, 서버 스펙에 따라 자동으로 결정됩니다.

OpenJDK에서는 CPU 코어 수가 2개 이상이고 메모리가 2GB 이상일 경우 서버를 Server-Class Machine으로 인식합니다. Server-Class Machine이라면 가비지 컬렉터로 G1 GC가 선택되지만, 이 서버는 조건을 충족하지 않기 때문에 Serial GC가 선택됩니다. G1 GC를 사용하려면 서버를 스케일업하거나 -XX:+UseG1GC 옵션을 명시적으로 설정해야 합니다.

실행 중인 JVM의 GC 확인 방법

sudo jcmd {jar PID} VM.info

또는

sudo jinfo {jar PID}

추가 학습 자료를 공유합니다.

참고 링크

[254] 페이지 교체 알고리즘에 대해서 설명해 주세요.

백엔드

페이지 교체 알고리즘에 대해서 설명해 주세요.

백엔드와 관련된 질문이에요.

가상 메모리 관리 기법(Virtual Memory Management) 은 프로그램의 일부만을 메모리에 적재하여 실제 사용 가능한 물리 메모리 양보다 큰 프로세스를 실행할 수 있도록 하는 기법입니다. 그리고, 가상 메모리 관리 기법 중 하나인 페이징은 메모리의 물리 주소 공간을 프레임 단위로 나눈 이후, 프로세스의 논리 주소 공간을 페이지로 나누어 프레임에 할당하는 방식입니다.

프로세스를 메모리에 적재할 때, 실제로 필요할 때만 페이지를 메모리에 적재하는 것을 요구 페이징(Demand Paging) 이라 하는데요. 요구 페이징을 사용하는 운영체제에서 새로운 페이지를 할당하려고 하는데, 공간이 부족한 경우 메모리에 존재하는 다른 페이지와 신규 페이지를 교체해야합니다. 이때, 교체 대상을 결정하는 방법을 페이지 교체 알고리즘(Page Replacement Algorithm) 라고 합니다.

페이지 교체 알고리즘의 종류를 간단하게 설명해 주세요.

대표적인 페이지 교체 알고리즘은 FIFO, OPT, LRU, LFU, NUR이 존재합니다.

  • FIFO 페이지 교체 알고리즘(First In First Out Page Replacement Algorithm) 은 적재된 페이지의 순서대로 교체하는 알고리즘입니다. 단순히 먼저 적재되었다는 이유로 교체된다는 비효율을 개선하기 위해 2차 기회 페이지 교체 알고리즘과 같은 변형 알고리즘이 존재합니다.
  • OPT 페이지 교체 알고리즘(Optimal Page Replacement Algorithm) 은 앞으로 가장 나중에 사용될 페이지를 교체합니다. 하지만, 페이지의 미래 사용 빈도와 접근 패턴을 알기 어렵기 때문에 이론적 알고리즘으로 분류됩니다.
  • LRU 페이지 교체 알고리즘(Least Recently Used Page Replacement Algorithm) 은 가장 오래 사용되지 않은 페이지를 교체합니다.
  • LFU 페이지 교체 알고리즘(Least Frequently Used Page Replacement Algorithm) 은 가장 적게 사용된 페이지를 교체합니다. 사용 빈도가 같은 경우에는 다른 페이지 교체 알고리즘을 사용할 수 있습니다.
  • NUR 페이지 교체 알고리즘(Not Used Recently Page Replacement Algorithm) 은 최근에 사용된 적이 없는 페이지를 교체합니다. LRU, LFU 알고리즘과 유사한 성능이 보이면서, LRU, LFU의 메모리 오버헤드를 줄일 수 있습니다. LRU와 LFU는 페이지에 대한 추가적인 정보(사용 횟수, 마지막 접근 시간)를 관리해야 하는데요. NUR은 페이지 테이블 엔트리의 참조 비트와 수정 비트를 활용하여 교체 대상을 결정합니다.

LRU, LFU, NUR, 그리고 FIFO의 변형 알고리즘은 OPT와 달리 과거의 데이터를 기반으로 미래의 메모리 접근 패턴을 예측하기 때문에 최적 근접 알고리즘으로 분류됩니다.

추가 학습 자료를 공유합니다.

참고 링크

[255] 열 기반 DB와 행 기반 DB의 차이점은 무엇인가요?

백엔드

열 기반 DB와 행 기반 DB의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

행 기반 데이터베이스(Row-oriented Database) 는 데이터를 행 단위로 관리하는 DBMS입니다. 행 단위 읽기 맟 쓰기 연산에 최적화돼 있습니다. PostgreSQL, MySQL이 대표적인 행 기반 데이터베이스입니다.

반면, 열 기반 데이터베이스(Column-oriented Database) 는 열 기반으로 데이터를 관리한다는 점에서 행 기반 데이터베이스와 차이가 있습니다. 데이터를 조회할 때 필요한 열만 로드하기 때문에 디스크 I/O를 줄일 수 있으며, 같은 종류의 데이터가 연속적으로 저장되므로 압축 효율이 높습니다. 이러한 특징으로 인해 주로 데이터 분석에 사용됩니다. 대표적인 열 기반 데이터베이스로는 BigQuery, Redshift, Snowflake가 존재합니다.

예를 들어, 다음과 같은 데이터가 존재한다고 가정하겠습니다.

NameCreatedAt
Atom2024-01-23
Prin2024-02-01
Gosmdochee2024-02-03

행 기반 데이터베이스의 경우, 아래와 같이 데이터를 관리합니다.

[Atom, 2024-01-23] [Prin, 2024-02-01] [Gosmdochee, 2024-02-03]

반면, 열 기반 데이터베이스의 경우, 아래와 같이 데이터를 관리합니다.

[Atom, Prin, Gosmdochee] [2024-01-23, 2024-02-01, 2024-02-03]

추가 학습 자료를 공유합니다.

참고 링크

[257] 이진 트리에 대해서 설명해 주세요.

백엔드

이진 트리에 대해서 설명해 주세요.

백엔드와 관련된 질문이에요.

트리(Tree) 는 방향이 존재하는 그래프의 일종으로 부모 정점 밑에 여러 자식 정점이 연결되고, 자식 정점 각각에 다시 자식 정점이 연결되는 재귀적 형태의 자료구조입니다. 그 중에서 각 정점이 최대 2개의 자식 정점을 가지는 트리를 이진 트리(Binary Tree) 라고 합니다.

이진 트리의 종류는 무엇이 있나요?

정점이 채워져 있는 형태에 따라서 대표적으로 포화 이진 트리, 완전 이진 트리, 편향 이진 트리가 존재합니다.

마지막 레벨까지 모든 정점이 채워져 있는 경우, 포화 이진 트리라고 부릅니다.

        1                --- level 1
      /   \
    2       3            --- level 2
   / \     / \
  4   5   6   7          --- level 3

마지막 레벨을 제외하고 모든 정점이 채워져 있는 경우, 완전 이진 트리라고 부릅니다.

        1                --- level 1
      /   \
    2       3            --- level 2
   / \     /
  4   5   6              --- level 3

한 방향으로만 정점이 이어지는 경우, 편향 이진 트리라고 부릅니다.

    1                    --- level 1
     \
      2                  --- level 2
       \
        3                --- level 3
         \
          4              --- level 4
           \
            5            --- level 5

이진 트리의 특징과 활용 사례를 설명해 주세요.

이진 트리의 대표적인 특징은 다음과 같습니다.

  • 이진 트리의 정점이 N개인 경우, 최악의 경우 높이가 (N - 1)이 될 수 있습니다.
  • 포화 이진 트리와 완전 이진 트리의 높이는 log N입니다.
  • 높이가 h인 포화 이진 트리는 2^(h + 1) - 1개의 정점을 가집니다.

이진 트리는 다른 자료 구조를 만드는 경우에 주로 활용되는데요. 대표적으로 힙(Heap), 이진 탐색 트리(Binary Search Tree)가 존재합니다.

이진 트리의 탐색 방법은 무엇이 있나요?

이진 트리를 탐색하기 위한 방법으로 중위 순회(in-order), 전위 순회(pre-order), 후위 순회(post-order), 층별 순회(level-order)가 존재합니다.

  • 중위 순회는 왼쪽 정점, 부모 정점, 오른쪽 정점 순서로 방문합니다.
  • 전위 순회는 부모 정점, 왼쪽 정점, 오른쪽 정점 순서로 방문합니다.
  • 후위 순회는 왼쪽 정점, 오른쪽 정점, 부모 정점 순서로 방문합니다.
  • 층별 순회는 시작 정점의 레벨을 순서대로 모두 방문하고, 이후에 다음 레벨의 정점을 순서대로 방문합니다. (층별로 방문)

추가 학습 자료를 공유합니다.

참고 링크

[258] 객체 지향 프로그래밍이란 무엇이고, 어떤 특징이 있나요?

백엔드

객체 지향 프로그래밍이란 무엇이고, 어떤 특징이 있나요?

백엔드와 관련된 질문이에요.

객체 지향 프로그래밍(OOP, Object-Oriented Programming) 은 상태(필드)와 행위(메서드)를 가진 객체를 중심으로 프로그램을 설계하는 프로그래밍 패러다임입니다. 객체에 역할과 책임을 부여하고, 이 객체들이 서로 협력하는 방식으로 프로그램을 구성합니다.

객체 지향 프로그래밍의 특징으로는 캡슐화, 추상화, 다형성, 상속이 있습니다.

캡슐화(Encapsulation) 는 객체의 상태와 행위를 하나의 단위로 묶는 것을 말합니다. 내부 구현은 숨기고 외부에서 접근할 수 있는 인터페이스만 제공함으로써 객체의 무결성을 보호하고 코드의 유지보수성을 높일 수 있습니다.

추상화(Abstraction) 는 불필요한 세부 사항을 감추고 핵심적인 기능만 간추려내는 것을 말합니다. 객체의 공통적인 특징은 추출하여 인터페이스 또는 추상 클래스로 정의하고, 구체적인 세부 사항은 구현체에게 위임함으로써 객체의 핵심 기능에만 집중할 수 있습니다.

다형성(Polymorphism) 은 하나의 인터페이스가 여러 형태로 동작할 수 있는 것을 말합니다. 오버로딩과 오버라이딩을 사용하여 같은 메서드명이더라도 객체에 따라 다르게 동작하도록 할 수 있습니다.

상속(Inheritance) 은 상위 클래스의 특징을 하위 클래스가 물려받아 확장하는 것을 말합니다. 기존 기능을 수정하지 않고 새로운 기능을 추가할 수 있어 확장성이 뛰어나고, 중복을 제거하여 코드의 재사용성을 높일 수 있습니다.

TDA 원칙을 알고 계신가요?

TDA(Tell Don't Ask) 원칙은 객체의 데이터를 직접 요청하지 말고, 객체에게 필요한 동작을 수행하도록 메시지를 보내라는 원칙입니다. TDA 원칙을 따르면 캡슐화를 지킬 수 있고, 객체 스스로 데이터를 다루기 때문에 객체의 응집도를 높이고 객체 간 결합도를 낮출 수 있습니다.

// 위반 예시 (Bad)
public class Account {

    private BigDecimal balance;
    
    public Account(BigDecimal balance) {
        this.balance = balance;
    }

    public BigDecimal getBalance() {
        return this.balance;
    }
    
    public void setBalance(BigDecimal balance) {
        this.balance = balance;
    }
}

Account account = new Account(BigDecimal.TEN);
BigDecimal withdrawalAmount = BigDecimal.ONE;

BigDecimal balance = account.getBalance(); // 객체의 데이터를 직접 가져와서 사용
if (balance.compareTo(withdrawalAmount) < 0) {
    throw new IllegalStateException("잔액이 부족합니다.");
}

balance = balance.subtract(withdrawalAmount);
account.setBalance(balance);
// 준수 예시 (Good)
public class Account {

    private BigDecimal balance;

    public Account(BigDecimal balance) {
        this.balance = balance;
    }

    public void withdraw(BigDecimal withdrawalAmount) {
        if (balance.compareTo(withdrawalAmount) < 0) {
            throw new IllegalStateException("잔액이 부족합니다.");
        }
        this.balance = balance.subtract(withdrawalAmount);
    }
}

Account account = new Account(BigDecimal.TEN);
BigDecimal withdrawalAmount = BigDecimal.ONE;

account.withdraw(withdrawalAmount); // 객체에 메시지 전달

추상화에서 추상 클래스와 인터페이스의 차이는 무엇인가요?

추상 클래스(Abstract Class) 는 공통된 기능을 재사용하려는 목적으로 사용됩니다. 일반 메서드와 추상 메서드를 포함할 수 있고, 인스턴스 변수를 가질 수 있습니다.

인터페이스(Interface) 는 구현을 강제하려는 목적으로 사용됩니다. 추상 메서드와 JDK 8부터 default 메서드를 포함할 수 있고, 상수만 가질 수 있습니다.

is-a 관계이거나 코드의 재사용이 중요한 경우 추상 클래스를 사용하고, can-do 관계이거나 다중 상속이 필요한 경우 인터페이스를 사용할 수 있습니다.

다형성에서 오버로딩과 오버라이딩의 차이는 무엇인가요?

오버로딩(Overloading) 은 클래스 내에서 같은 이름의 메서드를 여러 개 정의하는 것을 말합니다. 중요한 점은 매개변수의 개수, 타입, 순서가 달라야 합니다. 반환 타입만 다른 경우는 오버로딩이 성립하지 않습니다.

오버라이딩(Overriding) 은 상위 클래스의 메서드를 하위 클래스에서 재정의하는 것을 말합니다. 상위 클래스의 메서드 시그니처(메서드명, 매개변수 타입, 개수, 순서)와 반환 타입이 동일해야 하며, 접근 제어자는 상위 클래스와 같거나 더 넓은 범위로 변경할 수 있습니다.

한마디로 정리하면, 오버로딩은 같은 기능을 다르게 사용하는 것, 오버라이딩은 상속받은 기능을 변경하는 것입니다.

다이아몬드 문제에 대해 설명해 주세요.

다이아몬드 문제(Diamond Problem) 는 다중 상속에서 발생하는 모호성 문제를 의미합니다.

예를 들어, A 클래스에 hello() 메서드가 있고, B와 C 클래스는 A 클래스를 상속받아 hello() 메서드를 오버라이딩합니다. 이때 B, C 클래스를 상속받는 D 클래스에서 hello() 메서드를 호출하면 어떤 클래스의 메서드를 호출해야 할지 모호해지는 문제가 발생합니다.

C++와 같이 다중 상속을 지원하는 언어는 이러한 문제를 적절히 해결해야 하며, Java에서는 클래스에 대한 다중 상속을 지원하지 않고 인터페이스에서만 허용됩니다.

추가 학습 자료를 공유합니다.

참고 링크

[259] 널 오브젝트 패턴이란 무엇인가요?

백엔드

널 오브젝트 패턴이란 무엇인가요?

백엔드와 관련된 질문이에요.

널 오브젝트 패턴(Null Object Pattern) 이란 객체가 존재하지 않을 때, 널을 전달하는 것이 아닌 아무 일도 하지 않는 객체를 전달하는 기법입니다. 예를 들어, 개발하다 보면 아래와 같이 널 체크 코드를 작성할 때가 많은데요.

public void doSomething(MyObject obj) {
  if(obj == null) {
    throw new Exception();
  }

  obj.doMethod();
}

이러한 유형의 코드가 여러 곳에서 계속 반복해서 등장하게 된다면 코드를 복잡하게 만들 수도 있습니다. 널 오브젝트 패턴은 널 값을 아무런 행위도 하지 않는 객체로 다뤄 널 체크 코드를 간소화합니다.

class MyNullObject implements MyObject {

  @Override
  public void doMethod() {
    // 아무것도 하지 않음
  }
}

class MyRealObject implements MyObject {

  @Override
  public void doMethod() {
      System.out.println("무엇인가 수행합니다.")
  }
}

public void doSomething(MyObject obj) {
  obj.doMethod();
}

값이 널인 경우에만 사용되는 것이 아닌 특별한 케이스에서 모두 응용할 수 있습니다. 가령, 스택이라는 자료구조를 만들 때 용량이 0인 경우, ZeroCapacityStack을 만들 수 있습니다. 널 오브젝트 패턴은 반복적인 널 체크 코드를 간소화하고 협력을 재사용하는데 용이하다는 장점이 있지만, 오히려 예외를 탐지하기 어려운 상황을 만들 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[260] 서버리스란 무엇인가요?

백엔드

서버리스란 무엇인가요?

백엔드와 관련된 질문이에요.

서버리스(Serverless) 란 클라우드 업체에서 직접 인프라를 관리하고 동적으로 크기를 조정하면서 유지 관리하는 방식을 의미합니다. 만약, AWS EC2를 사용해도 OS 관리 및 보안, 파일 시스템 관리는 직접 해야 했는데요. 서버리스에서는 클라우드 업체가 이 모든 것을 직접 맡아서 관리합니다. 서버리스를 도입한다면, 직접 컴퓨터를 관리할 필요가 없고, 유연하게 확장할 수 있으며 사용하지 않은 용량에 대해서는 비용을 지불하지 않을 수 있다는 이점을 얻을 수 있습니다. AWS Lambda(서버리스 컴퓨팅), S3(스토리지), SQS(메시지 큐)가 서버리스의 예시입니다.

서버리스 아키텍처에서 백엔드 코드는 어떻게 개발하나요?

서버리스 아키텍처에서 백엔드 코드는 FaaS(Function as a service) 를 활용하여 개발할 수 있습니다. FaaS는 특정 이벤트가 발생했을 때만 함수가 실행되는 방식을 의미합니다. 예를 들어, GET /hello 이라는 API 경로에 호출(이벤트)이 발생할 때, 이를 처리할 수 있는 함수를 잠시 실행하고 결과를 클라이언트에게 응답합니다. 개발자는 비즈니스 로직을 작성하고, 클라우드 인프라에 코드 조각(함수)을 배포하면 됩니다.

혹은 BaaS(Backend as a service) 를 활용할 수도 있는데요. BaaS를 활용하면 클라우드 제공업체에서 만든 인증, 소셜 서비스 등의 완성된 백엔드 기능을 사용할 수 있습니다. 예를 들어, Firebase Authentication을 활용하면 비교적 적은 코드로 로그인 기능을 구현할 수 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[261] JPA Fetch Join과 페이징을 함께 사용할 때 주의점을 설명해 주세요.

백엔드

JPA Fetch Join과 페이징을 함께 사용할 때 주의점을 설명해 주세요.

백엔드와 관련된 질문이에요.

~ToMany 관계에서 Fetch Join과 페이징을 함께 사용하면 OutOfMemoryError가 발생할 수 있다는 점을 주의해야 합니다. 예를 들어, 아래와 같이 Product(1)-ProductCategory(N) 관계가 있을 때, ProductJpaRepository의 findProductWithSlice 처럼 Fetch Join과 페이징을 함께 사용하는 경우에 OOM이 발생할 수 있습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "product", cascade = CascadeType.PERSIST)
    private List categories = new ArrayList();

    // ... 중략
}

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class ProductCategory {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Product product;

    @ManyToOne(fetch = FetchType.LAZY)
    private Category category;
}

interface ProductJpaRepository extends JpaRepository {


    @Query("""
                 select p
                 from Product p
                 join fetch p.categories pc
                 join fetch pc.category c
                 order by p.id desc
            """)
    Slice findProductWithSlice(Pageable pageable);
}

왜 OOM이 발생할 수 있나요?

실제로 findProductWithSlice를 호출하면 서버에서 다음과 같은 경고 메시지를 보여주는데요.

firstResult/maxResults specified with collection fetch; applying in memory

실행되는 쿼리를 확인해도 페이징 쿼리가 발생하지 않습니다.

    select
        p.id,
        pc.product_id,
        pc.id,
        c.id,
        c.name 
    from
       product p 
    join
       product_category pc 
       on p.id = pc.product_id 
    join
       category c 
       on c.id = pc.category_id 
    order by p.id desc

ProductCategory를 조인하면 Product의 결과도 함께 증가합니다. (카티션 프로덕트) 따라서, 페이징을 위해 설정한 값이 의도한 대로 동작하기 어려워 JPA는 전체 결과를 메모리에 적재한 다음에 가공하여 페이징을 수행합니다. 이때, 수많은 데이터가 메모리에 적재된다면 OOM이 발생할 가능성이 있습니다.

이 문제는 어떻게 해결해 볼 수 있을까요?

단순히 Fetch Join을 사용하지 않으면 됩니다. 하지만, Fetch Join을 사용하지 않으면 ProductCategory 리스트를 조회하기 위해서 N + 1 쿼리가 발생할 수 있습니다.

Slice result = productJpaRepository.findProductWithSlice(pageRequest);
result.forEach(product -> System.out.println(product.getCategories())); // N + 1

이를 해결하기 위해서는 @BatchSize와 default_batch_fetch_size 옵션을 사용할 수 있는데요. 이 기능은 Parent 엔티티(Product)의 Child 엔티티 컬렉션(ProductCategory)을 조회할 때, 영속성 컨텍스트에서 관리하는 Parent 엔티티의 식별자를 IN 절에 추가하여 Child 엔티티를 조회하는 기능입니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @BatchSize(size = 10)
    @OneToMany(mappedBy = "product", cascade = CascadeType.PERSIST)
    private List categories = new ArrayList();

    // ... 중략
}

예를 들어, 위와 같이 categories 위에 @BatchSize를 추가하면 다음과 같은 쿼리가 발생합니다.

    select
        pc.product_id,
        pc.id,
        pc.category_id 
    from
        product_category pc 
    where
        pc.product_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

추가 학습 자료를 공유합니다.

참고 링크

[267] Gradle에 대해 설명해 주세요.

백엔드

Gradle에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

Gradle은 Java, Kotlin, Scala 등 JVM에서 실행되는 언어에서 자주 사용되는 빌드 자동화 도구입니다. 기존의 Ant와 Maven의 단점을 보완하여 증분 빌드, 빌드 캐시, 데몬 프로세스를 활용해 빌드 속도를 최적화하고, 멀티 프로젝트를 쉽게 관리할 수 있도록 설계되었습니다. 또한, 다양한 플러그인과 커스텀 태스크를 사용해 확장성을 높일 수 있으며, Groovy 또는 Kotlin DSL을 사용해 유연한 빌드 스크립트를 작성할 수 있습니다.

빌드 자동화 도구를 왜 사용할까요?

  1. 컴파일, 테스트, 패키징, 배포와 같은 반복 작업을 자동화하여 개발 생산성을 높일 수 있습니다.
  2. 일관된 빌드 환경을 제공하여 어떤 환경에서나 동일한 빌드 결과를 보장할 수 있습니다.
  3. 증분 빌드, 빌드 캐시, 병렬 처리 등을 통해 빌드 속도를 최적화할 수 있습니다.
  4. 테스트 누락 등 수동 작업 시 발생할 수 있는 휴먼 에러를 방지할 수 있습니다.
  5. 외부 라이브러리를 자동으로 관리하여 의존성 버전 충돌을 줄일 수 있습니다.
  6. CI/CD와 연동하여 빌드 후 패키징, 배포까지 연속적으로 처리할 수 있습니다.

Maven과 Gradle의 차이점을 설명해 주세요.

가장 큰 차이점은 빌드 스크립트 작성 방식과 빌드 속도입니다.

Maven은 XML 기반(pom.xml)의 정형화된 방식으로 작성하지만, Gradle은 Groovy 또는 Kotlin DSL을 사용해 더 유연하고 가독성이 좋은 빌드 스크립트를 작성할 수 있습니다. 그리고 Maven은 항상 전체 프로젝트를 빌드하는 방식이라 빌드 속도가 느리지만, Gradle은 증분 빌드와 빌드 캐시를 지원해서 훨씬 빠르게 동작합니다.

자세한 차이는 아래의 표를 참고해 주세요.

MavenGradle
빌드 스크립트XMLGroovy/Kotlin DSL
빌드 속도느림빠름
의존성 관리기본적인 의존성 관리동적 버전 관리, 의존성 캐싱 최적화
확장성한정적인 플러그인 기능다양한 플러그인, 커스텀 태스크
Android 지원공식적으로 지원 XAndroid 공식 빌드 도구
멀티 프로젝트 빌드상속 방식, 설정이 복잡함설정 주입 방식, 멀티 프로젝트 관리 최적화

Dependency Configuration이 무엇이고 어떤 종류가 있을까요?

Dependency Configuration은 애플리케이션에 필요한 의존성의 사용 범위를 정의하는 설정입니다. 사용 범위를 명확히 구분하는 이유는 빌드 성능 개선과 불필요한 의존성을 제거해서 빌드 결과물의 크기를 최적화하기 위함입니다.

  • implementation은 컴파일 및 런타임 시점에 모두 필요한 의존성입니다. 해당 의존성이 현재 모듈에서만 필요할 때 사용하고, 일반적인 라이브러리나 프레임워크를 추가할 때 주로 사용합니다.
  • api는 implementation과 비슷하지만, 다른 모듈에서도 접근할 수 있는 의존성을 정의할 때 사용합니다. 예를 들어, a -> b -> c의 의존성이 있을 때, a에서 c를 사용하려면 b에서 c를 api로 추가해야 합니다.
  • compileOnly는 컴파일 시점에만 필요한 의존성으로, Lombok과 같은 라이브러리에서 사용합니다.
  • annotationProcessor는 컴파일 시점에 실행되는 어노테이션 프로세서를 추가할 때 사용합니다. 예를 들어, MapStruct와 Lombok과 같이 컴파일 시점에 어노테이션을 기반으로 특정 프로세스를 수행하는 라이브러리에서 사용합니다.
  • runtimeOnly는 런타임 시점에만 필요한 의존성입니다. 예를 들어, 데이터베이스 드라이버는 컴파일 시점에 필요하지 않지만, 애플리케이션이 실행될 때만 필요하므로 runtimeOnly로 설정합니다.
  • testImplementation, testCompileOnly, testRuntimeOnly와 같이 'test'가 포함된 설정은 테스트 코드에서만 사용되는 의존성을 추가할 때 사용합니다.

추가 학습 자료를 공유합니다.

참고 링크

[269] ThreadLocal에 대해 설명해 주세요.

백엔드

ThreadLocal에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

ThreadLocal은 Java에서 각 스레드마다 독립적인 변수를 저장할 수 있도록 도와주는 클래스입니다. 보통 여러 스레드가 공유 자원을 사용하면 동시성 문제가 발생할 수 있는데, ThreadLocal을 사용하면 스레드별로 데이터를 분리할 수 있어 동기화 없이 안전하게 활용할 수 있습니다. 각 스레드는 자신만의 ThreadLocalMap을 가지고 있고 ThreadLocal을 키로 사용하여 값을 저장합니다. 즉, 하나의 스레드에서 여러 개의 ThreadLocal을 사용할 수 있으며, ThreadLocal은 현재 스레드의 ThreadLocalMap을 제어하는 역할을 합니다.

Spring 생태계에서는 ThreadLocal을 사용하여 트랜잭션 동기화 관리(TransactionSynchronizationManager), 사용자 인증 정보 관리(SecurityContextHolder), 웹 요청의 attribute 관리(RequestContextHolder) 등의 기능을 제공하고 있습니다.

ThreadLocal의 장점은 무엇인가요?

각 스레드는 ThreadLocal에 접근할 때 다른 스레드와 격리된 값을 가질 수 있습니다. 그리고 공유 자원이 없기 때문에 synchronized 키워드 등을 사용해서 동기화할 필요가 없습니다.

ThreadLocal을 사용할 때 주의할 점은 무엇인가요?

스레드풀을 사용하면 스레드가 재사용됩니다. 이때 ThreadLocal에 이전 스레드의 값이 남아있으면 재사용된 스레드가 올바르지 않은 데이터를 참조할 수 있습니다. 이를 방지하려면 스레드가 끝나는 시점에 remove() 메서드를 호출하여 ThreadLocal에 저장된 값을 제거해야 합니다.

비동기 작업을 수행할 때 ThreadLocal이 예상대로 동작하지 않을 수 있습니다. 예를 들어, @Async 어노테이션을 사용하면 새로운 스레드에서 비동기 작업이 실행되는데, 비동기 스레드는 기존 스레드에서 ThreadLocal에 저장한 값을 참조할 수 없습니다. 이 문제는 Spring 4.3 이상에서 제공하는 TaskDecorator를 사용하여 기존 스레드의 ThreadLocal 값을 비동기 스레드에 복사하는 방식으로 해결할 수 있습니다.

ThreadLocal을 대체할 수 있는 방법이 있나요?

ThreadLocal 대신 메서드 인자로 값을 전달하거나 ConcurrentHashMap과 같이 thread-safe한 자료구조를 사용하는 방법이 있습니다. Spring에서는 @RequestScope 어노테이션을 사용하여 HTTP 요청 별로 데이터를 관리할 수 있습니다.

NamedThreadLocal을 알고 계신가요?

NamedThreadLocal은 Spring에서 제공하는 ThreadLocal의 확장 클래스로, 디버깅을 쉽게 하기 위해 이름을 부여할 수 있도록 설계되었습니다. 기본적인 기능은 ThreadLocal과 같지만, 여러 개의 ThreadLocal을 사용할 때 이름을 명확히 설정하면 어떤 목적의 ThreadLocal인지 구분할 수 있어 디버깅이 용이합니다.

추가 학습 자료를 공유합니다.

참고 링크

[270] 스레드 풀 포화 정책이란 무엇인가요?

백엔드

스레드 풀 포화 정책이란 무엇인가요?

백엔드와 관련된 질문이에요.

자바의 ThreadPoolExecutor를 기준으로 설명해 드리겠습니다. 스레드 풀 포화 정책(Saturation Policies) 이란 말 그대로 스레드 풀이 포화 상태인 경우의 행동을 결정하는 정책을 의미합니다. ThreadPoolExecutor 설정에는 상시 유지하는 스레드의 수인 corePoolSize, 작업 대기열 크기인 workQueueSize, 스레드를 추가할 수 있는 최대 수인 maxPoolSize가 존재하는데요. 스레드가 maxPoolSize까지 늘어나고 대기열까지 꽉 찬 상태를 포화 상태라고 합니다. 이때 새로운 작업 요청이 들어오면, RejectedExecutionHandler의 구현체인 포화 정책이 실행됩니다.

포화 정책의 종류에는 무엇이 존재하나요?

포화 정책 종류는 RejectedExecutionHandler의 구현체를 기준으로 AbortPolicy, CallerRunsPolicy, DiscardOldestPolicy, DiscardPolicy가 존재합니다.

  • AbortPolicy는 포화 상태일 경우, RejectedExecutionException을 발생시킵니다.
  • DiscardPolicy는 포화 상태일 경우, 신규 요청을 무시합니다.
  • DiscardOldestPolicy는 포화 상태일 경우, 작업 대기열에서 가장 오래된 요청을 버리고 신규 요청을 대기열에 추가합니다.
  • CallerRunsPolicy는 포화 상태일 경우, 요청 스레드에서 해당 작업을 실행합니다.

기본적으로 제공되는 포화 정책은 4가지지만, 직접 RejectedExecutionHandler 인터페이스를 구현하여 커스텀 포화 정책을 만들 수 있습니다.

class CustomPolicy implements RejectedExecutionHandler {

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        // ... 중략
    }
}

추가 학습 자료를 공유합니다.

참고 링크

[271] 어떤 예외가 발생하면 트랜잭션을 롤백하나요?

백엔드

어떤 예외가 발생하면 트랜잭션을 롤백하나요?

백엔드와 관련된 질문이에요.

예외 종류에 따른 트랜잭션 롤백은 개발 환경에 따라 다르게 동작합니다.

Spring 트랜잭션 예외 처리 동작

  • Checked Exception 기본적으로 Checked Exception이 발생하더라도 트랜잭션을 롤백하지 않습니다. Checked Exception은 컴파일 시점에 예외 처리를 강제하는 예외로, 개발자가 예외 발생 가능성을 예상하고 이를 적절히 처리할 수 있는 정상적인 예외 상황이라고 가정하기 때문입니다.

  • Unchecked Exception(RuntimeException, Error) Spring은 기본적으로 Unchecked Exception 또는 Error가 발생하면 트랜잭션을 롤백합니다. 이는 Unchecked Exception이 보통 프로그래머의 실수나 시스템적인 문제로 인한 회복하기 어려운 상황(NullPointerException, IllegalArgumentException 등) 이라고 가정하기 때문입니다. Spring은 JDBC, JPA, Hibernate 등 하위 데이터 액세스 계층에서 발생하는 다양한 예외를 모두 공통의 Unchecked Exception인 DataAccessException 계층으로 변환하여 처리합니다. 이를 통해 일관된 예외 처리 전략을 수립할 수 있습니다.

기본 동작과 별개로 @TransactionalrollbackFornoRollbackFor 속성을 사용하여 특정 Checked Exception에 대해서도 롤백을 유도하거나, 반대로 Unchecked Exception에 대해 롤백하지 않도록 설정할 수 있습니다.

반면, 자바(EE) 환경에서는 컨테이너가 관리하는 트랜잭션(CMT)과 개발자가 직접 관리하는 프로그래밍 방식의 트랜잭션 제어 모두 존재하며, 이들 환경에서는 트랜잭션 롤백 동작이 Spring과는 다르게 동작할 수 있습니다.

  • 컨테이너 관리 트랜잭션(CMT) 환경 (EJB, CDI 등) 기본적으로 Unchecked Exception이 발생하면 컨테이너가 자동으로 트랜잭션을 롤백합니다. Checked Exception은 기본적으로 롤백을 트리거하지 않으며, 필요하면 @ApplicationException(rollback=true) 등의 어노테이션으로 롤백을 강제할 수 있습니다.

  • 프로그램 방식의 트랜잭션 제어 개발자가 트랜잭션의 시작, 커밋, 롤백 등을 직접 관리해야 합니다. 이 경우, 예외가 발생하면 예외 종류와 관계없이 개발자가 상황에 맞게 명시적으로 rollback()을 호출하는 방식으로 트랜잭션을 종료합니다.

추가 학습 자료를 공유합니다.

참고 링크

[275] 논리 삭제와 물리 삭제의 차이점은 무엇인가요?

백엔드

논리 삭제와 물리 삭제의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

데이터베이스에서 데이터를 삭제하는 방법은 크게 물리 삭제(Hard Delete)논리 삭제(Soft Delete) 가 존재합니다. 물리 삭제는 DELETE 명령어를 통해 직접 데이터를 삭제하는 방식이며, 논리 삭제는 UPDATE 명령을 사용하여 삭제를 여부를 나타내는 컬럼을 수정하는 방식을 의미합니다. 즉, 물리 삭제는 실제로 데이터를 삭제하는 반면 논리 삭제는 데이터가 삭제되었음을 표시만 한다는 점에서 차이가 있습니다.

# 물리 삭제 처리
delete from member where id = 1;

# 논리 삭제 처리와 조회
update member set deleted_at = curdate() where id = 1;
select * from member where deleted_at is null;

어떤 방식을 선택하는 것이 가장 좋은가요?

방식마다 장단점이 존재하기 때문에 상황에 맞는 선택을 하는 것이 옳다고 생각합니다.

물리 삭제의 경우에는 실제로 데이터를 삭제하기 때문에 저장 공간을 새로 확보할 수 있으며, 테이블의 크기를 줄여 검색 속도 향상을 기대할 수 있습니다. 하지만, 데이터를 다시 복구하기 어렵다는 점과 삭제된 데이터가 비즈니스 의사결정에 사용되기 어렵다는 단점이 있습니다.

논리 삭제는 데이터를 삭제하지 않기 때문에 데이터 복구에 용이하고, 비즈니스 의사결정에 사용될 수 있습니다. 하지만, 테이블에 데이터가 많아져 성능에 악영향을 줄 수 있고, 논리 삭제된 데이터를 제외하지 않고 조회하는 실수가 발생할 수 있다는 단점이 존재합니다.

추가 학습 자료를 공유합니다.

참고 링크

[276] 템플릿 메서드 패턴이란 무엇인가요?

백엔드

템플릿 메서드 패턴이란 무엇인가요?

백엔드와 관련된 질문이에요.

템플릿 메서드 패턴(Template Method Pattern) 은 기능의 뼈대와 구현을 분리하는 행위 디자인 패턴입니다. 템플릿 메서드 패턴은 실행 단계의 절차를 결정하는 상위 클래스와 실행 단계를 구현하는 하위 클래스로 구성됩니다.

public abstract class Student {
  
    public abstract void study();
    public abstract void watchYoutube();
    public abstract void sleep();

    // 템플릿 메서드
    final public void doDailyRoutine() {
       study();
       watchYoutube();
       sleep();
    }
}

class BackendStuduent extends Student {

    @Override
    public void study() {
        System.out.println("영한님 JPA 강의를 수강합니다.");
    }

    @Override
    public void watchYoutube() {
        System.out.println("개발바닥 유튜브를 시청합니다.");   
    }

    @Override
    public void sleep() {
        System.out.println("7시간 잠을 잡니다.");   
    }
}

템플릿 메서드 패턴은 공통 로직을 상위 클래스에 모아 중복 코드를 줄일 수 있으며, 코드의 재사용성을 높일 수 있다는 장점이 있습니다. 하지만, 하위 클래스를 개발할 때 상위 클래스의 내용을 알기 전까지 어떠한 방식으로 동작할지 예측하기 어렵고, 상위 클래스 수정이 발생하는 경우 모든 하위 클래스를 변경해야 하는 단점이 존재합니다.

추가 학습 자료를 공유합니다.

참고 링크

[281] NoSQL 데이터베이스의 유형에는 어떤 것들이 있나요?

백엔드

NoSQL 데이터베이스의 유형에는 어떤 것들이 있나요?

백엔드와 관련된 질문이에요.

NoSQL 데이터베이스의 유형은 키-값, 문서 지향, 열 지향, 그래프, 시계열이 있습니다.

키-값 데이터베이스(Key-value Database) 는 키를 고유한 식별자로 사용하는 키-값 쌍의 형태로 데이터를 저장합니다. 구조가 단순하고, 빠른 읽기 및 쓰기 성능을 제공합니다. Redis, Amazon DynamoDB가 대표적인 예시이고, 세션 저장, 캐시, 실시간 순위 등으로 사용할 수 있습니다.

문서 지향 데이터베이스(Document-oriented Database) 는 JSON, BSON, XML 등의 형식으로 데이터를 저장합니다. 유연한 스키마를 가지고 있으며, 복잡한 데이터 구조를 쉽게 표현할 수 있습니다. MongoDB, CouchDB가 대표적인 예시이고, 콘텐츠 관리 시스템, 사용자 프로필 저장 등으로 사용할 수 있습니다.

열 지향 데이터베이스(Column Family Database) 는 데이터를 열 단위로 저장합니다. 대량의 데이터를 처리하는 데 적합하며, 행마다 각기 다른 수의 열과 여러 데이터 유형을 가질 수 있습니다. Apache Cassandra, HBase가 대표적인 예시이고, 대규모 데이터 분석, 로그 수집 등으로 사용할 수 있습니다.

그래프 데이터베이스(Graph Database) 는 노드, 엣지 구조로 구성된 그래프로 데이터를 저장합니다. 복잡한 관계를 표현하는 데 사용되며, 레이블(그룹화된 노드)을 통해 쿼리를 쉽게 작성하고 효율적으로 실행할 수 있습니다. Neo4j, Amazon Neptune이 대표적인 예시이고, 소셜 네트워크 분석, 추천 시스템 등으로 사용할 수 있습니다.

시계열 데이터베이스(Time Series Database) 는 시간에 따라 변화하는 데이터를 저장합니다. 타임스탬프가 있는 메트릭, 이벤트 등을 처리하기 위해 사용되며, 시간 경과에 따른 변화를 측정하는데 최적화되어 있습니다. InfluxDB, Prometheus, TimescaleDB가 대표적인 예시이고, IoT 데이터 수집, 금융 데이터 분석 등으로 사용할 수 있습니다.

실시간 채팅 앱에 적합한 NoSQL을 사용한다면 어떻게 구성하실 건가요?

개인의 경험과 지식에 따라 다르게 답변할 수 있습니다. 아래는 하나의 예시로 참고해주세요.

실시간 채팅 앱에서는 메시지를 빠르게 주고받는 처리 속도와, 유연하고 수평 확장이 가능한 저장 구조가 중요하다고 생각합니다.
먼저, 실시간 메시지 전송은 Redis의 Pub/Sub 기능을 사용하겠습니다. 이 기능은 낮은 지연 시간으로 사용자 간 메시지를 브로드캐스트할 수 있기 때문입니다.

Redis는 영구 저장보다 캐시나 메시지 브로커 역할에 더 적합하므로 실제 메시지를 영구 저장할 때는 MongoDB를 사용하겠습니다. MongoDB는 문서 지향 데이터베이스로서 채팅 메시지나 사용자 정보 등을 JSON 형식으로 유연하게 저장할 수 있습니다. 특히, 샤딩 기능으로 수평 확장이 가능하기 때문에 사용자 수가 증가하거나 메시지 양이 많아져도 성능 저하 없이 안정적으로 확장할 수 있다고 생각합니다.

추가 학습 자료를 공유합니다.

참고 링크

[282] 테스트 격리란 무엇인가요?

백엔드

테스트 격리란 무엇인가요?

백엔드와 관련된 질문이에요.

테스트 격리(Test Isolation) 는 각 테스트가 서로 독립적으로 실행되도록 보장하는 것을 말합니다. 즉, 어떤 테스트가 실행되더라도 다른 테스트의 결과나 상태에 영향을 주거나 받지 않아야 한다는 의미입니다.

테스트 격리가 중요한 이유는 격리가 제대로 이루어지지 않으면 비결정적 테스트가 발생할 수 있기 때문입니다. 비결정적(Non-deterministic) 테스트는 같은 테스트를 여러 번 실행했을 때 항상 같은 결과를 내지 않는 테스트를 말합니다. 예를 들어, 테스트가 데이터베이스와 같은 공유 자원에 의존할 경우 실행 순서나 다른 테스트의 실행 여부에 따라 성공 또는 실패 결과가 달라질 수 있습니다. 비결정적 테스트는 실패했을 때 실제 코드의 문제인지, 비결정적 요인 때문인지 판단하기 어려워집니다. 따라서 테스트가 항상 동일한 조건에서 예측 가능한 결과를 낼 수 있도록 격리하는 것이 중요합니다.

Spring에서 같은 데이터베이스를 사용하는 테스트는 어떻게 격리할 수 있을까요?

@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
@SpringBootTest
class MyIntegrationTest {
    // ...
}

Spring의 테스트 컨텍스트는 애플리케이션 컨텍스트를 캐싱해서 각각의 테스트에서 재사용합니다. @DirtiesContext 어노테이션을 사용하면 테스트마다 새로운 애플리케이션 컨텍스트를 로드하여 완전한 격리를 보장할 수 있습니다. 하지만 애플리케이션 컨텍스트를 매번 새로 로드하는 것은 비용이 크고 시간이 오래 걸리는 작업이기 때문에 성능이 저하된다는 단점이 있습니다.


@Sql("/truncate.sql")
@SpringBootTest
class MyIntegrationTest {
    // ...
}

@Sql 어노테이션을 사용하면 테스트 실행 전 또는 후에 특정 SQL 스크립트를 실행할 수 있습니다. 이때 TRUNCATE DDL을 사용하여 테이블 자체를 비움으로써 테스트 간 독립된 테이블을 사용할 수 있습니다. 하지만 테이블이 추가될 때마다 SQL 스크립트를 수정해야 하기 때문에 유지보수 비용이 발생한다는 단점이 있습니다.


@Transactional
@SpringBootTest
class MyIntegrationTest {
    // ...
}

@Transactional 어노테이션을 사용하면 테스트가 실행된 후 트랜잭션을 롤백하여 데이터베이스 상태를 원래대로 유지할 수 있습니다. 이 방법을 사용할 때는 몇 가지 주의할 점이 있습니다.

  1. 의도치 않은 트랜잭션 적용으로 프로덕션 환경과 다른 조건에서 테스트될 수 있습니다. 예를 들어, OSIV를 꺼두고 @Transactional도 없는 상태에서 지연로딩된 엔티티를 조회하면 LazyInitializationException이 발생하지만, 테스트에서는 트랜잭션이 열려 있어서 예외가 발생하지 않습니다. 따라서 실제로는 실패할 코드가 테스트에서는 성공하는 거짓 음성이 나타날 수 있습니다.

    거짓 양성(False Positive): 프로덕션 코드는 정상 동작하지만 테스트는 실패

    거짓 음성(False Negative): 프로덕션 코드는 실패하지만 테스트는 통과

  2. @SpringBootTest의 WebEnvironment가 DEFINE_PORT 또는 RANDOM_PORT일 경우, 별도의 스레드에서 서블릿 컨테이너가 실행되기 때문에 테스트의 트랜잭션 롤백이 적용되지 않습니다.

  3. 프로덕션 코드의 트랜잭션 전파 레벨을 REQUIRES_NEW로 설정했을 경우 새로운 트랜잭션을 생성하기 때문에 테스트 트랜잭션과 무관하여 롤백되지 않습니다.

  4. 비동기 메서드는 새로운 스레드에서 실행되기 때문에 롤백되지 않습니다.

추가 학습 자료를 공유합니다.

참고 링크

[283] SQL 인젝션에 대해 설명해 주세요.

백엔드

SQL 인젝션에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

SQL 인젝션(SQL Injection) 은 웹 애플리케이션에서 사용자의 입력값이 SQL 쿼리에 안전하게 처리되지 않을 때 발생하는 보안 취약점입니다. 공격자는 이 취약점을 이용해 쿼리를 조작하여 인증을 우회하거나, 데이터를 조작하거나, 테이블 자체를 삭제할 수도 있습니다.

예를 들어, 로그인 검증 시 아래와 같은 코드를 사용한다고 가정해 보겠습니다.

public boolean login(String username, String password) {
    String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";

    try (Connection conn = DriverManager.getConnection("url");
         Statement stmt = conn.createStatement();
         ResultSet rs = stmt.executeQuery(sql)) {
        return rs.next();
    } catch (SQLException e) {
        throw new RuntimeException("Database error", e);
    }
}

사용자가 로그인 폼에 아래와 같이 입력한다면

username: admin' -- 
password: (아무거나)

생성되는 쿼리는 다음과 같이 변형됩니다.

SELECT * FROM users WHERE username = 'admin' -- ' AND password = '1q2w3e4r!';

-- 이후는 주석 처리되므로, 비밀번호 조건은 무시되어 admin 사용자에 대한 정보가 반환됩니다. 이처럼 공격자는 SQL 쿼리를 조작하여 인증을 우회하거나 데이터베이스의 정보를 탈취할 수 있습니다.

이 외에도 공격자는 다음과 같은 페이로드를 사용할 수 있습니다.

  • ' OR '1'='1: 항상 참이 되는 조건
  • ' UNION SELECT * FROM accounts --: 다른 테이블의 정보 조회
  • '; DROP TABLE users; --: 테이블 삭제

SQL 인젝션을 방지하는 방법은 무엇인가요?

  1. PreparedStatement를 사용하면 place holder(?)에 값을 바인딩하고 내부적으로 이스케이프 처리하기 때문에 SQL 인젝션을 방지할 수 있습니다.
    String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
    PreparedStatement pstmt = conn.prepareStatement(sql);
    pstmt.setString(1, username);
    pstmt.setString(2, password);
    
  2. JPA, Hibernate와 같은 ORM 프레임워크를 사용하면 SQL 쿼리를 직접 작성하지 않고도 데이터베이스와 상호작용할 수 있습니다.
  3. 사용자 입력에 대해 공격에 사용되는 SQL 구문의 포함 여부를 검증합니다.
  4. 웹 애플리케이션에서 사용하는 데이터베이스 계정에 최소한의 권한만 부여합니다.
  5. SQL 오류나 예외 메시지를 사용자에게 직접 노출하지 않도록 합니다.

추가 학습 자료를 공유합니다.

참고 링크

[284] 최종적 일관성이란 무엇인가요?

백엔드

최종적 일관성이란 무엇인가요?

백엔드와 관련된 질문이에요.

최종적 일관성(Eventual Consistency) 이란 분산 시스템에서 고가용성을 유지하기 위해서 사용하는 일관성 모델입니다. 데이터가 수정되면, 그 변경 내용은 비동기적으로 다른 노드에 전파되기 때문에 일시적으로 각 노드의 데이터가 다를 수 있습니다. 하지만 시간이 지나면 모든 노드에 변경 사항이 전달되어 결국에는 모든 노드가 동일한 데이터를 가지게 되는 것을 의미합니다.

만약, 사용자가 특정 노드에 데이터를 수정하면 다른 노드에 변경 사항이 복제되는 상황을 가정하겠습니다.

이미지 출처 : Google Cloud

위 구성은 복제를 수행하고 있는 노드에 대해 조회 연산을 허용하여 높은 가용성을 유지할 수 있으며, 최종적으로는 모든 노드가 같은 데이터를 가지고 있는 최종적 일관성을 달성할 수 있습니다. 하지만, 일시적인 데이터 불일치가 발생하여 클라이언트는 오래된 데이터를 읽을 수 있다는 단점이 있습니다.

최종적 일관성과 강한 일관성의 차이는 무엇인가요? 🤔

강한 일관성(Strong Consistency) 은 최종적 일관성과는 다르게, 특정 연산이 끝난 직후 모든 노드가 동일한 데이터를 갖도록 보장하는 것을 의미합니다. 즉, 사용자가 데이터를 수정한 직후 다른 노드에서 해당 데이터를 읽더라도, 반드시 최신 값을 얻게 됩니다.

이미지 출처 : Google Cloud

예를 들어, 위 구성에서는 복제가 완료되기 전까지 다른 노드의 읽기 연산을 차단하여 강한 일관성을 달성할 수 있습니다. 하지만, 클라이언트로부터의 요청을 처리할 수 없다는 점에서 가용성이 희생된다는 단점이 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[285] NOT IN 쿼리를 사용할 때 발생할 수 있는 문제와 최적화 방법에 대해 설명해 주세요.

백엔드

NOT IN 쿼리를 사용할 때 발생할 수 있는 문제와 최적화 방법에 대해 설명해 주세요.

백엔드와 관련된 질문이에요.

아래와 같이 NOT IN을 사용한 쿼리는 직관적이고 사용하기 쉽지만, 대규모 데이터셋에서 심각한 성능 저하를 일으킬 수 있습니다.

SELECT p 
FROM Post p
WHERE p.id NOT IN :postIds

문제점

  1. NOT IN은 부정 조건으로, 대부분의 DBMS에서 전체 테이블 스캔이나 인덱스 풀 스캔을 유발합니다. 전체 데이터나 테이블을 스캔한 후 조건에 맞지 않는 레코드를 필터링 해야하기 때문에 데이터베이스 옵티마이저가 효율적인 실행 계획을 세우기 어렵습니다.
  2. 인덱스를 효과적으로 활용하지 못합니다. IN 절은 인덱스 Range Scan을 통해 빠르게 처리할 수 있지만, NOT IN은 인덱스 활용도가 현저히 떨어집니다.
  3. 대량의 값을 IN 절에 넣으면 실행 계획 생성이 늘어나고, 파싱 및 최적화 단계에서 추가적인 오버헤드가 발생합니다.
  4. NULL 값 처리 로직으로 인한 예상치 못한 결과가 발생할 수 있습니다. 예를 들어, column NOT IN (1, 2, NULL)은 항상 빈 결과를 반환합니다.

최적화 방안

1. NOT EXISTS 활용

SELECT p FROM Post p
WHERE NOT EXISTS (
    SELECT 1 FROM Post temp
    WHERE temp.id = p.id AND temp.id IN :postIds
)

NOT EXISTS는 행 단위로 평가되어 매칭되는 첫 행을 찾자마자 평가를 중단합니다. 이는 DBMS가 '존재하지 않음'을 확인하기 위해 특별히 최적화된 방식입니다. 대규모 데이터셋에서 가장 안정적이고 확장성 있는 성능을 제공합니다.

2. LEFT JOIN + IS NULL 패턴

SELECT p FROM Post p 
LEFT JOIN (
    SELECT temp.id FROM Post temp WHERE temp.id IN :postIds
) filtered ON p.id = filtered.id
WHERE filtered.id IS NULL

이 방식은 서브쿼리 결과가 작을 때 특히 효율적입니다. 인덱스를 효과적으로 활용할 수 있으며, PK 인덱스를 사용한 JOIN 연산이 최적화됩니다.

참고 링크

참고 링크 없음

[291] Statement와 PreparedStatement의 차이점은 무엇인가요?

백엔드

Statement와 PreparedStatement의 차이점은 무엇인가요?

백엔드와 관련된 질문이에요.

JDBC에서 Statement와 PreparedStatement는 모두 SQL 실행을 담당하지만, 사용 방식과 성능, 보안 측면에서 차이가 존재합니다.

Statement 클래스는 문자열 연결을 이용해 SQL을 동적으로 구성해야 합니다. 이러한 특성으로 인해 SQL 인젝션 공격에 취약하다는 단점이 있습니다.

Statement stmt = conn.createStatement();
ResultSet rs =  30")" >stmt.executeQuery("select * from users where age > 30");

반면, PreparedStatement는 동적으로 파라미터를 바인딩할 수 있는 기능을 제공합니다. 값을 바인딩하면 내부적으로 이스케이프 처리하기 때문에 SQL 인젝션 공격을 방지할 수 있습니다.

String sql = "select * from users where age > ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, 30);
ResultSet rs = pstmt.executeQuery();

또한, 쿼리 구조를 미리 확정하고 플레이스홀더를 활용하여 값을 바인딩하는 PreparedStatement를 사용하면 SQL 구문 분석 결과를 캐싱할 수 있어 반복 실행 시 Statement보다 성능이 높은 것으로 알려져 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[292] 이벤트 소싱이란 무엇인가요?

백엔드

이벤트 소싱이란 무엇인가요?

백엔드와 관련된 질문이에요.

이벤트 소싱(Event Sourcing) 은 데이터의 최종 상태를 저장하는 대신, 상태를 변경시킨 이벤트들의 이력을 저장하는 방식을 의미합니다. 예를 들어, 체스 프로그램을 개발할 때 체스판의 상태를 데이터베이스에 저장하는 방법은 크게 두 가지가 있습니다.

  • 첫 번째 방법은 체스판의 상태를 그대로 옮겨서 저장하는 방식입니다. 1a 컬럼에 검은색 폰을 나타내는 bp라는 값을 설정할 수 있습니다.
  • 두 번째 방법은 체스의 기보를 저장하는 방식입니다. '검은색 폰이 1a로 이동했다.'라는 이벤트를 기록합니다. 순서대로 쌓여있는 이벤트를 매 순간 재생(replay)하여 체스판의 최종 상태를 나타낼 수 있습니다.

이때, 후자의 방식처럼 모든 상태 변경을 이벤트로 저장하는 방식을 이벤트 소싱이라고 합니다.

이벤트 소싱의 장단점은 무엇인가요?

이벤트 소싱은 모든 이벤트를 저장하기 때문에 언제든지 특정 상태를 재현할 수 있습니다. 최종 상태를 저장하는 방법에서는 과거 기록을 재현하기 어렵지만, 이벤트 소싱을 사용하면 과거의 상태를 재현하는 데 용이합니다. 또한, 특정 문제 상황에 대한 재현과 테스트가 용이하며, 시간에 따라서 비즈니스 로직이 달라지는 경우에 새로운 규칙에 따라서 이벤트를 재생할 수 있기 때문에 비즈니스 로직 변경에 유연하다는 장점이 있습니다.

하지만, 모든 이벤트를 재생해야하기 때문에 읽기 성능이 약화될 수 있습니다. 이는 중간 계산 결과를 따로 저장하는 스냅샷이라는 개념을 사용할 수 있지만, 전통적인 최종 상태를 저장하는 방식에 비해서는 읽기 성능이 떨어질 수 있습니다. 또한, 이벤트를 계속 추가되기만 하기 때문에 상대적으로 대용량의 데이터를 처리해야한다는 단점이 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[293] 참조 지역성의 원리란 무엇인가요?

백엔드

참조 지역성의 원리란 무엇인가요?

백엔드와 관련된 질문이에요.

참조 지역성의 원리(Locality of reference) 는 CPU가 메모리에 접근할 때 주된 경향을 바탕으로 만들어진 원리며, 주로 캐시 메모리의 적중률을 높여 CPU의 메모리 접근 횟수를 줄이는 데 이용됩니다. 참조 지역성의 원리는 크게 시간 지역성과 공간 지역성이 존재하는데요.

  • 시간 지역성(Temporal locality) 이란 CPU는 최근에 접근했던 메모리 공간에 다시 접근하려는 경향이 있다는 것을 의미합니다.
  • 공간 지역성(Spatial locality) 이란 CPU는 접근한 메모리 공간 근처에 접근하려는 경향이 있다는 것을 의미합니다.

프로그래밍에서 지역 변수에 값을 저장하면 나중에 다시 지역 변수에 접근할 가능성이 높은 것이 시간 지역성의 예시이며, CPU가 인텔리제이 프로그램을 실행할 때는 인텔리제이 프로그램이 모여 있는 공간 근처를 집중적으로 접근하는 것이 공간 지역성의 예시입니다.

다음 코드를 어떻게 개선해 볼 수 있을까요?

public class LocalityTest {

     @Test
     void test() {
        int size = 10240;
        int[][] array = new int[size][size];

        long beforeTime = System.currentTimeMillis();

        for (int j = 0; j < size; j++) {
            for (int i = 0; i < size; i++) {
                array[i][j]++;
            }
        }

        long afterTime = System.currentTimeMillis();
        long diffTime = afterTime - beforeTime;
       
        System.out.println("수행시간(m) : " + diffTime); // 577ms
    }
}

위 코드는 2차원 배열의 열을 먼저 순회하면서 값을 증가하는 프로그램입니다. 자바에서 2차원 배열은 내부적으로는 1차원 배열(int[])에 대한 참조 배열입니다. array[i]는 각각 독립된 int[] 객체이며, 이들은 메모리상에 반드시 연속적으로 배치되는 것이 보장되지 않습니다.

CPU 캐시는 공간 지역성 원리에 근거를 두어 물리적인 메모리 공간상에 인접한 데이터를 미리 캐싱합니다. 하지만, 열을 먼저 순회하고 행에 접근하게 된다면 물리적인 메모리상에서 멀리 떨어져 있는 데이터를 읽게 되어 캐시 히트율이 떨어집니다. 이 코드를 개선하기 위한 가장 쉬운 방법은 행에 먼저 접근하여 캐시 히트율을 높이는 것입니다.

public class LocalityTest {

     @Test
     void test() {
        int size = 10240;
        int[][] array = new int[size][size];

        long beforeTime = System.currentTimeMillis();

        for (int i = 0; i < size; i++) {
            for (int j = 0; j < size; j++) {
                array[i][j]++;
            }
        }

        long afterTime = System.currentTimeMillis();
        long diffTime = afterTime - beforeTime;
       
        System.out.println("수행시간(m) : " + diffTime); // 28ms
    }
}

추가 학습 자료를 공유합니다.

참고 링크

[294] Spring에서 객체를 Bean으로 관리하는 이유를 설명해주세요.

백엔드

Spring에서 객체를 Bean으로 관리하는 이유를 설명해주세요.

백엔드와 관련된 질문이에요.

Bean으로 객체를 관리하는 이유는 애플리케이션의 설계, 확장성, 유지보수 측면에서 많은 이점을 제공하기 때문입니다.

1. 의존성 관리 자동화

빈으로 등록된 객체들은 Spring 컨테이너(BeanFactory, ApplicationContext)가 자동으로 의존성을 주입해줍니다. 개발자가 직접 객체를 생성하고 의존성을 연결할 필요가 없어집니다. 또 컨테이너가 빌드 시점에 순환 의존성을 감지하여 설계 오류를 조기에 발견할 수 있습니다.

@Service
class OrderService(
    private val productRepository: ProductRepository,  // 자동 주입
    private val paymentGateway: PaymentGateway        // 자동 주입
)

2. 싱글톤 패턴 구현

기본적으로 Spring은 빈을 싱글톤으로 관리하여 메모리 사용을 최적화하고, 불필요한 객체 생성을 방지합니다.

// 아래 두 userRepository는 동일한 인스턴스
@Service
class UserService(private val userRepository: UserRepository)

@Service
class AuthService(private val userRepository: UserRepository)

3. 생명주기 관리

Spring은 빈의 초기화와 소멸 과정을 자동으로 관리합니다. 이를 통해 리소스 할당 및 해제를 체계적으로 처리할 수 있습니다.

@Component
class DatabaseConnection {
    @PostConstruct
    fun initialize() {
        // 초기화 로직
    }
    
    @PreDestroy
    fun cleanup() {
        // 리소스 정리 로직
    }
}

4. AOP(관점 지향 프로그래밍) 지원

빈으로 관리되는 객체들은 트랜잭션 관리, 로깅, 보안 등의 공통 관심사를 쉽게 적용할 수 있습니다.

@Service
class TransferService(private val accountRepository: AccountRepository) {
    @Transactional  // AOP를 통한 트랜잭션 관리
    fun transferMoney(from: String, to: String, amount: BigDecimal) {
        // 송금 로직
    }
}

5. 테스트 용이성

빈으로 관리되는 컴포넌트는 모킹(mocking)이나 테스트용 구현체로 쉽게 대체할 수 있어 단위 테스트와 통합 테스트가 용이합니다.

@SpringBootTest
class UserServiceTest {
    @MockBean
    lateinit var userRepository: UserRepository
    
    @Autowired
    lateinit var userService: UserService
    
    @Test
    fun testGetUser() {
        // given
        val userId = 1L
        whenever(userRepository.findById(userId)).thenReturn(User(userId, "Test User"))
        
        // when
        val result = userService.getUser(userId)
        
        // then
        assertEquals("Test User", result.name)
    }
}

6. 설정의 중앙화

애플리케이션의 구성 요소들을 Bean으로 관리함으로써 설정을 중앙화하고 일관된 방식으로 관리할 수 있습니다.

@Configuration
class AppConfig {
    @Bean
    fun dataSource(): DataSource {
        return HikariDataSource().apply {
            jdbcUrl = "jdbc:postgresql://localhost:5432/mydb"
            username = "user"
            password = "password"
            maximumPoolSize = 10
        }
    }
}

추가 학습 자료를 공유합니다.

참고 링크

[300] 명령어 파이프라이닝에 대해서 설명해 주세요.

백엔드

명령어 파이프라이닝에 대해서 설명해 주세요.

백엔드와 관련된 질문이에요.

명령어 파이프라이닝(instruction pipelining) 은 CPU가 여러 명령어를 동시에 처리하기 위해 각 명령어를 여러 단계로 분할하고, 각 단계를 다른 명령어와 겹쳐서 실행하는 방법입니다. 전통적인 CPU는 한 번에 하나의 명령어를 처리하는 반면, 파이프라인 기법을 사용하는 CPU는 여러 명령어를 각기 다른 단계에서 동시에 처리할 수 있습니다.

출처 : 위키 백과

파이프라인 위험에 대해서 알고 계시나요?

파이프라이닝은 높은 성능을 가져오지만, 때로는 성능 향상에 실패하기도 하는데요. 이를 파이프라인 위험(pipeline hazard) 이라고 합니다. 파이프라인 위험에는 크게 데이터 위험, 제어 위험, 구조적 위험이 존재합니다.

  • 데이터 위험(data hazard) 이란 데이터 의존적인 두 명령어를 동시에 실행하는 경우, 파이프라인이 제대로 작동하지 않는 것을 의미합니다. 예를 들어, 명령어 B가 명령어 A의 연산 결과인 R에 의존하는 경우, 명령어 A의 결과 저장 단계가 완료되어야 명령어 B가 R을 사용할 수 있습니다.
  • 제어 위험(control hazard) 이란 분기(branch)나 조건문 등으로 인해 다음에 실행할 명령어가 무엇인지 결정되지 않아 파이프라인이 멈추게 되는 것을 의미합니다. 분기 예측 기법이 사용되기도 하지만, 예측에 실패하면 계산된 파이프라인은 모두 버려야 해서 성능 저하가 발생합니다.
  • 구조적 위험(structural hazard) 이란 한 명령어가 자원을 사용하면, 해당 자원이 필요한 다른 명령어는 멈추게 되어 파이프라인이 제대로 작동하지 않는 것을 의미합니다. 서로 다른 명령어가 동시에 ALU, 레지스터와 같은 CPU 자원을 사용하려 할 때 발생합니다.

추가 학습 자료를 공유합니다.

참고 링크

[307] 쿠키와 세션의 차이에 대해서 설명해주세요.

백엔드

쿠키와 세션의 차이에 대해서 설명해주세요.

백엔드와 관련된 질문이에요.

쿠키와 세션은 HTTP의 무상태(stateless) 특성을 보완하여 사용자 상태를 유지하는 메커니즘이지만, 여러 측면에서 중요한 차이가 있습니다.

데이터 저장 위치

쿠키는 클라이언트 측 브라우저에 저장되는 반면, 세션은 서버 측에 저장됩니다. 세션은 서버에 데이터를 저장하고 세션 ID만 쿠키를 통해 클라이언트에 전달합니다.

보안성

쿠키는 클라이언트에 저장되므로 사용자가 직접 접근하거나 수정할 수 있어 보안에 취약합니다. 반면 세션은 중요 정보가 서버에 저장되어 상대적으로 안전합니다.

용량 제한

쿠키는 일반적으로 브라우저당 도메인별로 4KB 정도로 제한되어 있습니다. 세션은 서버 리소스에 따라 다르지만 쿠키보다 훨씬 많은 데이터를 저장할 수 있습니다.

라이프사이클

쿠키는 개발자가 설정한 만료 시간까지 유지되며, 만료 시간을 설정하지 않으면 브라우저를 닫을 때 삭제됩니다. 세션은 서버의 설정에 따라 관리되며 일정 시간 요청이 없으면 만료되는 경우가 많습니다.

성능 영향

쿠키는 모든 HTTP 요청에 함께 전송되므로 쿠키가 많을수록 네트워크 트래픽이 증가합니다. 세션은 서버 메모리를 사용하므로, 서버에서 관리하는 사용자의 세션이 많을수록 서버 부하가 증가할 수 있습니다.

일반적으로 사용자 선호 설정이나 로그인 하지 않은 유저의 장바구니와 같은 비민감 정보는 쿠키에, 로그인 정보와 같은 중요 데이터는 세션에 저장하는 것이 적합합니다. 최근에는 JWT와 같은 토큰 기반 인증 방식이 많이 사용되고 있습니다.

추가 학습 자료를 공유합니다.

참고 링크

[308] 어떤 이유로 코루틴을 사용한 작업 처리가 기존 스레드 방식보다 가벼운지 설명해주세요.

백엔드

어떤 이유로 코루틴을 사용한 작업 처리가 기존 스레드 방식보다 가벼운지 설명해주세요.

백엔드와 관련된 질문이에요.

메모리 사용량 차이

  • 스레드: 각 스레드는 자체 스택 메모리를 필요로 하며, JVM에서 기본적으로 약 1MB의 스택 크기를 할당합니다. 이 메모리는 스레드가 생성될 때 예약되며 스레드가 종료될 때까지 유지됩니다.
  • 코루틴: 코루틴은 스레드 내에서 실행되며 자체 스택을 필요로 하지 않습니다. 일반적으로 코루틴은 단지 몇 KB의 메모리만 사용합니다.

컨텍스트 스위칭 비용

  • 스레드: 스레드 간 전환은 운영체제 수준의 컨텍스트 스위칭을 필요로 하며, 이는 CPU 레지스터, 메모리 맵 등의 상태를 저장하고 복원하는 비용이 큽니다.
  • 코루틴: 코루틴 간 전환은 운영체제의 개입 없이 사용자 공간(user space)에서 발생하며, 단순히 실행 지점과 로컬 변수 상태만 heap 메모리에 저장하면 됩니다.

생성 및 관리 비용

  • 스레드: 새 스레드를 생성하는 것은 운영체제에 시스템 호출을 필요로 하며, 커널 수준의 리소스가 할당됩니다.
  • 코루틴: 코루틴 생성은 단순한 객체 할당과 유사하며, 운영체제 리소스를 직접 소비하지 않습니다.

일시 중단 메커니즘

  • 스레드: 스레드는 블로킹 작업(I/O 등) 중에는 완전히 차단되어 다른 작업을 수행할 수 없습니다.
  • 코루틴: 코루틴은 일시 중단 지점(suspend 함수)에서 실행을 중단하고 기본 스레드를 해제하여 다른 코루틴이 사용할 수 있게 합니다. 이로써 수천 개의 코루틴을 소수의 스레드에서 효율적으로 실행할 수 있습니다.

코드 예시로 비교

스레드 방식

// 1000개의 스레드를 생성하면 약 1GB의 메모리가 필요함
for (i in 1..1000) {
    Thread {
        // 각 스레드마다 작업 수행
        Thread.sleep(1000) // 스레드 블로킹
        println("Task $i completed")
    }.start()
}

코루틴 방식

runBlocking {
    // 1000개의 코루틴을 생성해도 몇 MB의 메모리만 사용
    for (i in 1..1000) {
        launch {
            // 각 코루틴마다 작업 수행
            delay(1000) // 코루틴만 일시 중단, 스레드는 다른 코루틴 실행 가능
            println("Task $i completed")
        }
    }
}

추가 학습 자료를 공유합니다.

참고 링크

[309] 싱글턴 패턴이란 무엇인가요?

백엔드

싱글턴 패턴이란 무엇인가요?

백엔드와 관련된 질문이에요.

싱글턴 패턴(Singleton Pattern) 이란 생성자를 여러 차례 호출해도 실제로 생성되는 객체를 하나로 유지하는 것을 의미합니다. 객체가 최초로 생성된 이후에 생성자나 객체 생성 메서드는 기존에 만들어진 객체를 반환합니다.

public class Singleton {

  private static final Singleton INSTANCE = new Singleton();

  // 생성자 호출 제한
  private Singleton() { ... }

  public static Singleton getInstance() {
    return INSTANCE;
  }
}

싱글턴 패턴의 장단점은 무엇인가요?

싱글턴 패턴은 하나의 객체를 여러 상황에서 재사용할 수 있기 때문에 메모리 낭비를 방지할 수 있습니다. 또한, 여러 다른 객체가 하나의 인스턴스에 쉽게 접근할 수 있어 편리하다는 장점이 있습니다.

하지만, 싱글턴은 전역 객체를 생성한다는 특성상 코드의 복잡도를 높이고, 테스트하기 어려운 코드를 만들 수 있는 단점이 있습니다.

  • 상황에 따라 더욱 복잡한 구현이 필요할 수 있습니다. 예를 들어, 싱글턴 객체를 지연 초기화(lazy initialization) 하고 싶을 때 여러 스레드가 동시에 생성자에 접근하면 두 개 이상의 객체가 생성될 수 있으므로 동시성 문제를 고려해야 합니다.
  • 테스트에서는 싱글턴 객체의 상태를 초기화하는 과정이 필요합니다. 예를 들어, 1번 테스트에서 싱글턴 객체를 수정한 경우, 2번 테스트는 싱글턴의 상태를 초기화한 후 테스트를 실행해야 합니다.
  • 싱글턴 객체가 인터페이스를 구현하지 않은 경우, 테스트 환경에서 가짜 구현체로 대체하여 주입하기 어렵습니다.

추가 학습 자료를 공유합니다.

참고 링크

[310] 레이어드 아키텍처란 무엇인가요?

백엔드

레이어드 아키텍처란 무엇인가요?

백엔드와 관련된 질문이에요.

레이어드 아키텍처(Layered Architecture) 란 소프트웨어를 관심사별로 여러 계층으로 나누어 수직적으로 배열한 것을 의미합니다. 여기서 관심사란 유사한 책임들을 의미합니다. 예를 들어, 데이터베이스 접근과 관련된 책임들을 하나의 관심사로 볼 수 있습니다.

레이어드 아키텍처의 대표적인 구성에는 3가지 레이어가 존재하는데요. 표현 계층, 도메인 계층, 데이터 소스 계층이 이에 해당합니다. 레이어의 종류와 수는 프로젝트 상황마다 달라질 수 있습니다.

  • 표현 계층(Presentation Layer) 은 사용자 입력을 처리하기 위해 존재합니다.
  • 도메인 계층(Domain Layer) 은 비즈니스와 관련된 로직을 수행하기 위해 존재합니다.
  • 데이터 소스 계층(Data Source Layer) 은 데이터베이스 접근 및 데이터 조작과 관련된 작업을 수행하기 위해 존재합니다.

레이어드 아키텍처를 사용하면 특정 레이어만 독립적으로 확장하거나 변경할 수 있으며, 특정 레이어만 별도로 테스트 환경을 구축하여 테스트할 수 있다는 장점이 있습니다.

싱크홀 안티 패턴에 대해서 알고 계시나요?

일반적으로 레이어드 아키텍처에서 요청은 상위 레이어(표현 계층)에서 중간 레이어를 거쳐 하위 레이어(데이터 소스 계층)로 전달되는데요. 이때, 중간 레이어는 아무 일도 하지 않음에도 불구하고 요청을 무작정 중간 레이어를 통과시키는 것을 싱크홀 안티 패턴(Achitecture Sinkhole Anti-Pattern) 이라고 합니다. 이는 불필요하게 요청을 전달받고, 다시 전달만 하는 중간 코드를 작성하고, CPU 및 메모리 자원을 낭비한다는 문제가 있습니다.

@Service
public class OrderService {

  private OrderDao orderDao;

  // 아무 일도 수행하지 않음
  public OrderResponse getOrder(Long orderId) {
      return orderDao.getOrderById(orderId);
  }
}

하지만 이를 무조건 피해야 하는 것은 아니라고 생각합니다. 상위 레이어가 직접 하위 레이어에 접근하는 방식을 허용하면 일관성이 약해져 추가적인 소통과 문서가 필요할 수 있기 때문입니다. 따라서, 프로젝트와 팀의 상황에 맞게 트레이드오프를 고려하여 팀만의 규칙을 만드는 것이 중요하다고 생각합니다.

추가 학습 자료를 공유합니다.

참고 링크

[311] 헬스체크에 대해서 설명해 주세요.

백엔드

헬스체크에 대해서 설명해 주세요.

백엔드와 관련된 질문이에요.

헬스체크(Health Check) 는 현재 서버의 상태가 정상인지 파악하는 것을 의미합니다. API 엔드포인트를 호출하거나 특정 포트로 TCP 연결을 시도하는 방식을 사용할 수 있으며, 스프링 액추에이터(Spring Actuator)를 활용하여 헬스체크 기능을 사용할 수 있습니다.

헬스체크의 필요성은 무엇인가요?

서버가 헬스체크 기능을 제공하면 최신 코드를 배포할 때 신규 배포가 정상적으로 이뤄졌는지 확인할 수 있으며, 장애를 감지하여 대응할 수 있다는 이점이 있습니다.

장애 대응의 예시로, 로드 밸런서가 존재하고 트래픽 분산 대상 서버 2대(A, B)가 있을 때, A 서버의 헬스체크 결과가 비정상으로 판단되면 로드 밸런서는 A 서버를 트래픽 분산 대상에서 제외하고, 이후 B 서버로만 요청을 전달할 수 있습니다.

비정상 서버는 CPU, 메모리, I/O 자원이 고갈되었거나 내부 오류 상태일 가능성이 높습니다. 이러한 상태에서 요청이 전달되면, 정상 서버가 존재함에도 사용자는 오류 응답을 받을 수 있습니다. 또한, 클라이언트가 재시도를 수행할 경우 전체 트래픽이 증가할 위험도 있습니다.

추가 학습 자료를 공유합니다.

참고 링크