Now Loading ...
-
애그리거트
객체 기반 도메인 모델의 구성요소는 다음과 같다.
엔티티
밸류
애그리거트
리포지토리 -> 구현을 위한 도메인 모델
도메인 서비스
애그리거트(Aggregate) - 연관된 엔티티와 밸류를 개념적으로 하나로 묶은 것
애그리거트는 관련된 객체를 하나의 군으로 묶어 주며, 개념상 완전한 한 개의 도메인 모델을 표현한다. 애그리거트를 사용하면 모델 간의 관계를 상위 수준과 개별 모델 수준에서 모두 파악할 수 있다.
위와 같이 개별 객체 수준에서만 모델을 바라보면 상위 수준에서의 관계를 파악하기 어렵다. 이로 인해 전반적인 구조에서 도메인 간의 관계를 파악하기 어려워지고, 이는 코드를 변경하고 확장하는 것을 어렵게 만든다.
애그리거트의 경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다. 도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다. 그러나 “A가 B를 갖는다”라는 요구사항이 있을 때, 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미하는 것은 아니다.
“상품 상세 페이지에 들어가면 리뷰 내용도 함께 보여줘야 한다.”라는 요구사항이 있을 때, Product 엔티티와 Review 엔티티는 함께 생성되지 않고 함께 변경되지도 않는다.
한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖는다. 도메인 규칙에 따라 일부 객체를 같은 시점에 생성할 필요가 없는 경우도 있지만 애그리거트에 속한 구성요소는 대부분 함께 생성하고 함께 제거한다.
주문 애그리거트를 만들려면 Order(엔티티), ShippingInfo(밸류), Orderer(밸류)와 같은 관련 객체를 함께 생성해야 한다. Order는 생성했는데 ShippingInfo는 생성하지 않거나, ShippingInfo를 생성했는데 Orderer를 생성하지 않는 경우는 없다.
각 애그리거트는 독립된 객체 군이며 다른 애그리거트를 관리하지 않는다.
주문 애그리거트에서 회원의 비밀번호를 변경하지는 않는다.
애그리거트 루트
애그리거트 루트는 한 애그리거트 전체를 관리하는 책임을 가진 엔티티이다. 도메인 규칙을 지키려면 애그리거트에 속한 모든 객체가 정상 상태를 가져야 하는데, 이 책임을 지는 것이 애그리거트 루트이다. 애그리거트에 속한 객체는 애그리거트 루트에 직·간접적으로 속하게 된다.
주문 애그리거트의 애그리거트 루트는 Order이며, ShippingInfo, Orderer 등 주문 애그리거트에 속한 객체는 Order에 직·간접적으로 속한다.
// 엔티티, 애그리거트 루트
public class Order {
// 밸류 타입, 불변 객체
private ShippingInfo shippingInfo;
// 애그리거트 루트는 도메인 규칙을 구현한 기능을 제공한다.
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
private void verifyNotYetShipped() {
if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
throw new IllegalStateException("Already shipped");
}
}
// 애그리거트 루트를 통해서만 애그리거트에 속한 객체를 변경해야 한다.
// set 메서드의 접근 허용 범위는 private이다.
private void setShippingInfo(ShippingInfo newShippingInfo) {
// 밸류 타입인 ShippingInfo는 불변이므로 새로운 객체를 할당해서 값을 변경해야 한다.
// this.shippingInfo.setAddress(newShippingInfo.getAddress())와 같은 코드를 사용할 수 없다.
this.shippingInfo = newShippingInfo;
}
}
애그리거트 루트에서 도메인 규칙을 구현한 기능을 제공한다. 애그리거트 루트가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다. (객체의 일관성이란 객체의 속성이 불완전하거나 모순되지 않도록 유지하는 원칙이다.)
“배송이 시작되기 전까지만 배송지 정보를 변경할 수 있다”는 도메인 규칙이 있다면, 애그리거트 루트인 Order가 이 기능을 구현한 메서드(changeShippingInfo())를 제공한다. 이 때, 해당 메서드는 이 규칙에 따라 배송 시작 여부를 확인하고 규칙을 충족할 때만 배송지 정보를 변경해야 한다.
애그리거트에 속한 객체의 일관성을 위해, 애그리거트 루트를 통해서만 애그리거트에 속한 객체를 변경해야 한다. 이를 위해, 엔티티에서 공개 set 메서드를 가급적 피하고 밸류 타입은 불변으로 구현하는 것을 습관적으로 적용해야 한다.
애그리거트 루트인 Order에서 ShippingInfo를 가져와 직접 정보를 변경하면 안된다.
참고 자료
도메인 주도 개발 시작하기(저자: 최범균)
https://velog.io/@andy230/Aggregate-%EC%95%A0%EA%B7%B8%EB%A6%AC%EA%B1%B0%ED%8A%B8
-
-
도메인 모델
DDD(Domain-Driven Design)는 도메인과 일치하도록 소프트웨어를 모델링하는 데 중점을 둔 소프트웨어 설계 접근 방식이다. (feat. 위키백과)
이번 글을 포함한 DDD 관련 글에서, “온라인 서점”이라는 도메인을 예시로 들어서 설명하겠다.
도메인
도메인 = 소프트웨어로 해결하고자 하는 문제 영역
개발자 입장에서 바라보면 온라인 서점은 구현해야 할 소프트웨어의 대상, 즉 도메인에 해당한다.
한 도메인은 다시 하위 도메인으로 나눌 수 있다. 한 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공한다.
온라인 서점 도메인은 위와 같이 몇 개의 하위 도메인으로 나눌 수 있다.
주문 도메인은 고객의 주문을 처리하고, 카탈로그 도메인은 고객에게 구매할 수 있는 상품 목록을 제공한다.
특정 도메인을 위한 소프트웨어라고 해서 도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아니다.
많은 온라인 쇼핑몰이 배송 도메인의 일부 기능은 자체 시스템으로 구현하고, 나머지 기능은 외부 업체의 시스템을 사용한다. 결제 시스템도 직접 구현하기보다는 결제 대행업체를 이용해서 처리할 때가 많다.
도메인 모델
정의 1 : 도메인 모델 (개념 모델) = 도메인 자체를 이해하기 위한 모델
위와 같이 클래스 다이어그램(UML 표기법)을 사용하여 주문 도메인의 도메인 모델을 표현할 수 있다.
주문 도메인을 생각해보자. 온라인 서점에서 주문을 하려면 상품을 몇 개 살지 선택하고 배송지를 입력한다. 선택한 상품 가격을 이용해서 총 지불 금액을 계산하고, 금액 지불을 위한 결제 수단을 선택한다. 주문한 뒤에도 배송 전이면 배송지 주소를 변경하거나 주문을 취소할 수 있다. 이를 위한 주문 도메인 모델을 객체로 모델링한 것이 위 그림이다.
도메인 모델을 객체로만 모델링할 수 있는 것은 아니며, UML 표기법만 사용해야 하는 것도 아니다. 도메인을 이해하는 데 도움이 된다면 표현 방식이 무엇인지는 중요하지 않다.
주문 도메인의 경우에는 상태 다이어그램을 사용하여 주문의 상태 전이를 모델링할 수도 있다. 또한, 관계가 중요한 도메인이라면 그래프를 이용해서 도메인을 모델링할 수 있고, 계산 규칙이 중요하다면 수학 공식을 활용해서 도메인 모델을 만들 수도 있다.
각 하위 도메인마다 별도로 도메인 모델을 만들어야 한다. 각 하위 도메인이 다루는 영역은 서로 달라서 같은 용어라도 하위 도메인마다 의미가 달라질 수 있기 때문이다.
카탈로그 도메인의 상품이 상품 가격, 상세 내용을 담고 있는 정보를 의미한다면 배송 도메인의 상품은 고객에게 실제 배송되는 물리적인 상품을 의미한다.
처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알 수 있는 수준으로 개념 모델을 작성하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.
소프트웨어를 개발하는 동안 개발자와 관계자들은 해당 도메인을 더 잘 이해하게 된다. 또한, 프로젝트 초기에 이해한 도메인 지식이 시간이 지나 새로운 통찰을 얻으면서 완전히 다른 의미로 해석되는 경우도 있다. 즉, 프로젝트 초기에 완벽한 도메인 모델을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 변경하는 일이 발생한다.
도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는 데 도움이 된다.
정의 2 : 도메인 모델 (구현 모델) = 도메인 계층의 객체 모델
일반적인 애플리케이션의 아키텍처는 위와 같이 네 개의 영역으로 구성되며, 도메인 계층은 도메인 규칙을 구현한다. 도메인 규칙이란 도메인에서 반드시 지켜야 하는 제약 조건이나 로직을 의미하며, 도메인 계층의 주요 구성요소는 다음과 같다.
엔티티
밸류
애그리거트
리포지토리
도메인 서비스
도메인 모델은 도메인 모델 패턴을 의미하며, 도메인 모델 패턴은 아키텍처 상의 도메인 계층을 객체 지향 기법으로 구현하는 패턴을 말한다. (feat. 마틴 파울러의 “엔터프라이즈 애플리케이션 아키텍처 패턴”)
// 엔티티
public class Order {
private String orderNumber;
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
if (!isShippingChangeable()) {
throw new IllegalStateException("Can't change shipping in" + state);
}
this.shippingInfo = newShippingInfo;
}
private boolean isShippingChangeable() {
return state == OrderState.PAYMENT_WAITING ||
state = OrderState.PREPARING;
}
}
public enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}
위와 같이 Java 코드로 주문 도메인의 도메인 모델(엔티티)을 구현할 수 있다.
Order 클래스는 주문 도메인 모델의 엔티티이며, 주문 도메인의 “출고 전에 배송지를 변경할 수 있다”라는 도메인 규칙을 구현하고 있다.
어떤 도메인과 관련된 중요 업무 규칙은 해당 도메인 모델에서 구현한다. 도메인 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에, 규칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 도메인 모델에 반영할 수 있게 된다.
주문 도메인의 경우, 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState 등에서 구현한다.
도메인 모델 정의에 대한 나의 요약
도메인 모델에 대한 나의 생각을 정리한 내용이다. 누군가 나에게 “도메인 모델이 무엇인가요?”라고 물어본다면, 아래와 같이 이야기할 것이다.
도메인 모델은 일반적으로 도메인 자체를 이해하기 위한 모델을 의미하며, “개념 모델”이다. 이때, 조직의 구성원들이 이러한 도메인 모델(개념 모델)을 보고 도메인을 이해할 수 있다면, 도메인 모델(개념 모델)을 꼭 클래스 다이어그램으로 나타낼 필요는 없다.
조직의 궁극적인 목표는 소프트웨어를 개발하는 것이고, 이는 도메인 모델(개념 모델)을 구현해야 하는 것을 의미한다. 객체 지향 프로그래밍을 기반으로 개발한다면, 도메인 모델(개념 모델)의 구현은 객체(클래스) 형태가 된다. 따라서, 도메인 모델(개념 모델)을 꼭 클래스 다이어그램으로 나타낼 필요는 없지만, 객체 지향 프로그래밍을 기반으로 개발하는 개발자 입장에서 도메인 모델(개념 모델)이 클래스 다이어그램으로 나타내져 있다면 압도적으로 구현하기 편리하다.(개념 모델 그대로 코드로 구현하면 되기 때문이다.)
요약하자면, 객체 지향 프로그래밍을 기반으로 소프트웨어를 개발하는 조직에서 도메인 모델(개념 모델)은 도메인을 객체로 모델링한 것, 즉 객체 기반 도메인 모델을 의미한다고 볼 수 있다!
도메인 모델의 구성요소
객체 기반 도메인 모델의 구성요소는 다음과 같다.
엔티티
밸류
애그리거트
리포지토리 -> 구현을 위한 도메인 모델
도메인 서비스
참고 자료
도메인 주도 개발 시작하기(저자: 최범균)
-
테스트 코드 및 TDD의 필요성
테스트 코드의 필요성
TDD의 필요성을 살펴보기 전에 테스트 코드의 필요성부터 살펴보자. 테스트 코드를 작성해야 하는 이유에 대해서는 이미 많은 글에서 말하고 있지만, 이 글에서는 내가 지금까지 개발을 해오며 느낀 주관적인 생각을 담아 작성했다!
1. 코드의 품질 보장
테스트 코드를 작성하기 전에 가장 처음 들었던 생각은, “버그 없이 완벽하게 코드를 작성하면, 굳이 테스트 코드가 필요할까?”였다. 지금 와서 이 생각에 대한 답을 해보자면, “버그 없이 완벽하게 코드를 작성했다는 결론을 어떻게 내렸는가?”이다. 즉, 테스트 코드를 작성하고 이것이 성공했음을 보여줌으로써 코드의 품질에 대해서 말할 수 있는 것이다.
테스트 코드를 작성할 때는 다양한 시나리오를 검증하는 것이 가장 중요하다고 생각한다. 정상 동작뿐만 아니라 엣지 케이스, 예외 처리, 오류 상황 등을 충분히 테스트해야 한다.
2. 회귀 테스트로 사용
회귀 테스트는 개발하고 테스트한 소프트웨어가 이후에 코드를 수정하거나 추가해도 기존 코드가 올바르게 동작하는지 확인하기 위한 테스트이다. 간단히 말해서, 이전에 정상 동작하던 기능이 변경으로 인해 손상되지 않았는지 확인하는 테스트이다.
테스트 코드는 회귀 테스트로 사용할 수 있다. 코드는 지속적으로 수정되거나 추가되는데, 코드를 수정하거나 추가할 때 앞서 작성한 테스트 코드를 사용하면 다른 기능에 문제가 없는지 바로 확인할 수 있다. 즉, 테스트 코드는 변경한 코드로 인해 소프트웨어가 비정상적으로 동작하는 것을 사전에 막아준다.
테스트 코드가 없다면 회귀 테스트를 체계적으로 진행하기 어렵고, 더 많은 시간과 노력이 필요하다.
3. 테스트 자동화를 통한 테스트 시간 감소
REST API E2E 테스트의 경우를 보면, “직접 Postman과 같은 API 테스트 도구를 사용해서 테스트하면 되는데 굳이 테스트 코드를 작성해야 할까?”라는 생각이 들기도 한다. 이에 대한 답은 “수동으로 모든 기능을 테스트할 필요 없이, 자동화된 테스트로 빠르게 확인할 수 있다.”이다. 즉, 테스트 자동화는 테스트 시간을 줄여준다.
이전 글들에서도 이야기했듯이, “테스트를 자동화한다는 것 = 코드로 작성한 테스트를 실행한다는 것“이다.
TDD 필요성
위에서 테스트 코드의 필요성에 대해 살펴보았다. 그렇다면 TDD의 필요성은 무엇일까? TDD의 필요성을 살펴보기 전에 TDD의 흐름을 다시 짚어보면, TDD는 구현 전에 테스트를 작성하고 리팩토링을 포함하는 개발 방법론이다.
1. 설계 과정을 진행
TDD 자체가 설계는 아니지만, TDD를 하다 보면 테스트 코드를 작성하는 과정에서 일부 설계를 진행하게 된다. TDD 흐름과 예시를 보면, 테스트 코드를 작성하는 과정에서 (1)클래스 이름, (2)메서드 이름, (3)메서드 파라미터, (4)실행 결과를 결정했다. 이 네 가지를 결정하는 것이 곧 설계 과정이다. 이러한 설계 과정은 내가 무엇을 만들어야 하는지 명확하게 파악할 수 있도록 해준다.
2. 디버깅 시간 감소
개발 시간은 크게 처음 코딩 시간, 테스트 시간, 디버깅 시간으로 나눌 수 있고, 이 과정은 개발을 완료할 때까지 반복된다. 전체 개발 시간을 줄이려면 코딩 시간뿐만 아니라 테스트 시간과 디버깅 시간을 줄여야 한다. 테스트 시간은 위의 테스트 코드의 필요성에서 말했듯 테스트 자동화, 즉 테스트 코드 작성을 통해 줄일 수 있다.
코드를 작성한 시점과 테스트를 하는 시점에 차이가 날수록 어떤 문제가 발생했을 때 원인을 찾는 데 시간이 필요하다. 아무리 코드를 잘 만들었다 해도 코드를 다시 읽고 분석해야 하기 때문이다. 반면에 TDD는 기능을 구현하자마자 테스트를 진행한다. 바로 전에 코드를 작성했기 때문에 테스트에 실패해도 원인을 빨리 찾을 수 있다. 즉 TDD는 디버깅 시간을 줄여준다.
사실 TDD를 적용하지 않고 구현 이후 테스트 코드를 작성할 때, 구현을 끝내고 즉시 테스트 코드를 작성한다면 꼭 TDD가 아니여도 디버깅 시간을 감소할 수 있다. 그러나, 테스트 코드 작성을 미루는 경우가 많다. 시간이 지나고 나서야 테스트 코드를 작성하고 디버깅을 진행한다면, 디버깅은 물론 테스트 코드 작성에도 많은 시간이 소요될 수 있다.
3. 지속적인 코드 정리
TDD는 리팩토링할 대상이 눈에 들어오면 리팩토링을 진행해서 코드 구조와 가독성을 개선한다. 즉, TDD는 개발 과정에서 지속적으로 코드 정리를 하므로 코드 품질이 급격히 나빠지지 않게 막아준다. 또한, 정리된 코드는 향후 코드 수정과 추가를 쉽게 할 수 있게 해주는데, 이는 곧 미래의 코딩 시간을 줄여준다.
참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://www.quora.com/Is-it-necessary-to-perform-tests-in-software-development-even-if-the-code-is-written-to-never-break-down-during-operation
https://stackoverflow.com/questions/247086/should-unit-tests-be-written-before-the-code-is-written
https://www.growin.com/the-importance-of-testing-code
-
REST API E2E 테스트 (feat. REST-assured)
이전 글들을 모두 읽고 오자 !!
Dependencies
- Spring Boot 3.4.1
- Junit 5.10.5
- REST-assured 5.3.1
REST API E2E 테스트 예시 - 스프링 부트
REST API E2E 테스트를 작성하는 것은 E2E 테스트를 자동화하는 것이다. 우리가 일반적으로 Postman을 사용하여 API를 테스트하는 것과 거의 같은 흐름이며, 예시에서는 아래 두 가지 도구를 사용한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
REST-assured
* @SpringBootTest의 MOCK vs RANDOM_PORT
MOCK(default 값)
서블릿 컨테이너(Tomcat)를 실행하지 않는다. 즉, 서버 실행 없이 Spring Context만 로드된다.
서버가 실행되지 않기 때문에 가상으로 HTTP 요청과 응답을 테스트한다. 이를 위해, MockMvc를 사용한다.
RANDOM_PORT
서블릿 컨테이너(Tomcat)를 실행한다. 즉, 임의의 포트에서 실제로 서버가 실행된다.
서버가 실행되기 때문에 실제 HTTP 요청을 보내고 응답을 받으며 테스트한다. 이를 위해, REST-assured나 TestRestTemplate 등을 사용한다.
* MockMvc vs Rest-assured
MockMvc
DispatcherServlet을 통해 컨트롤러를 호출하고 응답을 확인한다.
일반적으로, @WebMvcTest를 사용하는 Controller 단위 테스트나 @SpringBootTest(MOCK)을 사용하는 Controller 통합 테스트에서 사용된다.
Rest-assured
실제 HTTP 요청을 서버에 보내고 응답을 확인한다.
일반적으로, @SpringBootTest(RANDOM_PORT)를 사용하는 REST API E2E 테스트에서 사용된다.
0. 테스트에 사용될 객체들
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email")
private String email;
@Column(name = "password")
private String password;
@Builder
public User(String email, String password) {
this.email = email;
this.password = password;
}
}
public interface UserRepository extends Repository<User, Long> {
User save(User user);
Optional<User> findByEmail(String email);
Optional<User> findById(Long id);
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public Long signUp(UserSignUpRequest userSignUpRequest) {
if(userRepository.findByEmail(userSignUpRequest.getEmail()).isPresent()) {
throw new ExpectedException(ErrorCode.ALREADY_EXISTED_USER);
}
User user = User.builder()
.email(userSignUpRequest.getEmail())
.password(userSignUpRequest.getPassword())
.build();
return userRepository.save(user).getId();
}
public UserInfoResponse getUserInfo(Long userId) {
User foundUser = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException(ErrorCode.USER_NOT_FOUND));
return new UserInfoResponse(foundUser.getEmail());
}
}
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// 회원 가입
@PostMapping("/signup")
public ResponseEntity<Long> signUp(@RequestBody UserSignUpRequest userSignUpRequest) {
Long userId = userService.signUp(userSignUpRequest);
return new ResponseEntity<>(userId, HttpStatus.CREATED);
}
// 내 정보 보기
@GetMapping("/{id}")
public ResponseEntity<UserInfoResponse> getUserInfo(@PathVariable("id") Long id) {
UserInfoResponse userInfo = userService.getUserInfo(id);
return ResponseEntity.ok(userInfo);
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserSignUpRequest {
private String email;
private String password;
public UserSignUpRequest(String email, String password) {
this.email = email;
this.password = password;
}
}
@Getter
public class UserInfoResponse {
private String email;
public UserInfoResponse(String email) {
this.email = email;
}
}
@Getter
@RequiredArgsConstructor
public class ExpectedException extends RuntimeException {
private final ErrorCode errorCode;
}
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
ALREADY_EXISTED_USER("ALREADY_EXISTED_USER", "이미 존재하는 유저입니다."),
USER_NOT_FOUND("USER_NOT_FOUND", "존재하지 않는 유저입니다.");
private final String errorCode;
private final String errorMessage;
}
1. 스프링 부트의 내장 서버를 이용한 REST API E2E 테스트 (feat. REST-assured)
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserApiE2ETest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private UserRepository userRepository;
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
jdbcTemplate.update("truncate table users");
RestAssured.port = this.port;
}
@Test
@DisplayName("회원 가입 성공")
void signUp_Success() {
UserSignUpRequest signUpRequest
= new UserSignUpRequest("joyuri@gmail.com", "yuri123");
RestAssured.given()
.contentType(ContentType.JSON)
.body(signUpRequest)
.when()
.post("/api/users/signup")
.then()
.statusCode(201)
.body("", Matchers.greaterThanOrEqualTo(1));
}
@Test
@DisplayName("내 정보 찾기 성공")
void getUserInfo_Success() {
// Given
User existingUser = User.builder().email("joyuri@gmail.com").password("yuri123").build();
Long userId = userRepository.save(existingUser).getId();
// When & Then
RestAssured.given()
.pathParam("id", userId)
.when()
.get("/api/users/{id}")
.then()
.statusCode(200)
.body("email", Matchers.equalTo("joyuri@gmail.com"));
}
}
참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://martinfowler.com/articles/practical-test-pyramid.html
-
통합 테스트
이전 글들을 모두 읽고 오자 !!
Dependencies
- Spring Boot 3.4.1
- Junit 5.10.5
- WireMock
통합 테스트 예시 - 스프링 부트
스프링 부트 통합 테스트는 크게 2가지로 나눠볼 수 있다.
Controller 통합 테스트
Controller와 관련된 계층(Controller, Service, Repository 등)을 통합적으로 테스트
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 사용
Service 통합 테스트 -> 예시에서 보여줄 테스트!
Service와 관련된 계층(Service, Repository 등)을 통합적으로 테스트
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 사용
1은 2에 비해 테스트 범위가 넓다는 장점이 있지만, 테스트 속도가 느리다는 단점이 있다.
0. 테스트에 사용될 객체들
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email")
private String email;
@Column(name = "password")
private String password;
@Column(name = "card_number")
private String cardNumber;
@Builder
public User(String email, String password) {
this.email = email;
this.password = password;
}
public boolean registerCard(String cardNumber) {
this.cardNumber = cardNumber;
}
}
public interface UserRepository extends Repository<User,Long> {
User save(User user);
Optional<User> findByEmail(String email);
Optional<User> findById(Long id);
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final CardValidator cardValidator;
@Transactional
public Long signUp(String email, String password) {
userRepository.findByEmail(email)
.ifPresent(user -> throw new ExpectedException(ErrorCode.ALREADY_EXISTED_USER););
User user = User.builder()
.email(email)
.password(password)
.build();
return userRepository.save(user).getId();
}
@Transactional
public boolean registerCard(Long userId, String cardNumber) {
CardValidity cardValidity = cardValidator.validate(cardNumber);
if (cardValidity == CardValidity.TIMEOUT) {
throw new ExpectedException(ErrorCode.EXTERNAL_API_TIMEOUT);
}
if (cardValidity != CardValidity.VALID) {
throw new ExpectedException(ErrorCode.INVALID_CARD_NUMBER);
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException(ErrorCode.USER_NOT_FOUND));
return user.registerCard(cardNumber);
}
}
public enum CardValidity {
INVALID, ERROR, EXPIRED, UNKNOWN, THEFT, TIMEOUT, VALID
}
@Service
public class CardNumberalidator {
private final String server;
public CardNumberValidator(@Value("${card.server.url}") String server) {
this.server = server;
}
public CardValidity validate(String cardNumber) {
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(server + "/card"))
.header("Content-Type", "text/plain")
.POST(BodyPublishers.ofString(cardNumber))
.timeout(Duration.ofSeconds(3))
.build();
try {
HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString());
switch (response.body()) {
case "ok": return CardValidity.VALID;
case "bad": return CardValidity.INVALID;
case "expired": return CardValidity.EXPIRED;
case "theft": return CardValidity.THEFT;
default: return CardValidity.UNKNOWN;
}
} catch (HttpTimeoutException e) {
return CardValidity.TIMEOUT;
} catch (IOException | InterruptedException e) {
return CardValidity.ERROR;
}
}
}
@Getter
@RequiredArgsConstructor
public class ExpectedException extends RuntimeException {
private final ErrorCode errorCode;
}
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
ALREADY_EXISTED_USER("ALREADY_EXISTED_USER", "이미 존재하는 유저입니다."),
USER_NOT_FOUND("USER_NOT_FOUND", "존재하지 않는 유저입니다."),
INVALID_CARD_NUMBER("INVALID_CARD_NUMBER", "유효하지 않는 카드번호입니다."),
EXTERNAL_API_TIMEOUT("EXTERNAL_API_TIMEOUT", "외부 API 응답 타임아웃 발생");
private final String errorCode;
private final String errorMessage;
}
1. 스프링 부트 통합 테스트 - 데이터베이스
@ActiveProfiles("test")
@SpringBootTest // 스프링 컨테이너 초기화
public class UserServiceIntegrationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private UserRepository userRepository; // 대역 대신 실제 객체 사용
@Autowired
private UserService userService;
@BeforeEach
void setUp() {
jdbcTemplate.update("truncate table users"); // 테스트 전 데이터를 초기화
}
@Test
@DisplayName("회원 가입 성공")
void signUp_Success() {
// given
User user = User.builder()
.email("joyuri@gmail.com")
.password("yuri123")
.build();
// when
Long userId = userService.signUp("joyuri@gmail.com", "yuri123");
// then
assertThat(userId).isGreaterThan(0);
}
@Test
@DisplayName("회원 가입 시 이미 존재하는 유저이면 예외 발생")
void signUp_AlreadyExistedUser_Then_Exception() {
// given
User existingUser = User.builder()
.email("joyuri@gmail.com")
.password("yuri123")
.build();
userRepository.save(existingUser);
// when & then
assertThatThrownBy(() -> userService.signUp("joyuri@gmail.com", "yuri123"))
.isInstanceOf(ExpectedException.class)
.satisfies(e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.ALREADY_EXISTED_USER);
}
);
}
}
2. 스프링 부트 통합 테스트 - 외부 API
통합 테스트하기 어려운 대상이 외부 API이다. 외부 API를 사용하는 로직에 대한 통합 테스트 작성 방법은 크게 2가지가 있다.
테스트에서 실제 외부 API 호출
외부 API를 Mocking (Stub 역할)
방법 1은 아래와 같은 불편함이 존재한다.
다양한 테스트 상황을 만들기 어렵다. (ex) 타임아웃이 발생하도록 처리 시간 설정)
일부 외부 API는 호출에 비용이 발생한다.
방법 2는 이러한 불편함을 해소할 수 있다.
외부 API를 Mocking 해주는 도구로는 WireMock이 있다.
아래의 예시처럼 WireMockServer 객체를 수동으로 관리하는 방법 대신 @AutoConfigureWireMock 어노테이션을 사용하는 방법도 있다. @AutoConfigureWireMock 예시
@ActiveProfiles("test")
@SpringBootTest
@TestPropertySource(properties = "card.server.url=http://localhost:8089")
public class UserServiceIntegrationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private UserRepository userRepository;
@Autowired
private UserService userService;
private WireMockServer wireMockServer; // WireMockServer는 HTTP 서버를 흉내냄
@BeforeEach
void setUp() {
jdbcTemplate.update("truncate table users"); // 테스트 전 데이터를 초기화
wireMockServer = new WireMockServer(options().port(8089));
wireMockServer.start(); // 실제 HTTP 서버가 뜸
}
@AfterEach
void tearDown() {
wireMockServer.stop();
}
@Test
@DisplayName("카드 번호가 유효하면 카드 등록 성공")
void valid() {
// given
wireMockServer.stubFor(post(urlEqualTo("/card"))
.withRequestBody(equalTo("123456789"))
.willReturn(aResponse()
.withHeader("Content-Type", "text/plain")
.withBody("ok"))
); // Stub => WireMockServer의 동작을 기술
User user = User.builder()
.email("joyuri@gmail.com")
.password("yuri123")
.build();
Long userId = userRepository.save(user).getId();
// when
boolean isValidated = userService.registerCard(userId, "123456789");
// then
assertThat(isValidated).isTrue();
User savedUser = userRepository.findById(userId)
.orElseThrow(() -> new ExpectedException(ErrorCode.USER_NOT_FOUND));
assertThat(savedUser.getCardNumber()).isEqualTo("123456789");
}
@Test
@DisplayName("외부 API 응답 타임아웃 테스트")
void timeout() {
// given
wireMockServer.stubFor(post(urlEqualTo("/card"))
.willReturn(aResponse()
.withFixedDelay(5000)) // 5초
); // Stub => WireMockServer의 동작을 기술
User user = User.builder()
.email("joyuri@gmail.com")
.password("yuri123")
.build();
Long userId = userRepository.save(user).getId();
// when & then
assertThatThrownBy(() -> userService.registerCard(userId, "123456789"))
.isInstanceOf(ExpectedException.class)
.satisfies(e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.EXTERNAL_API_TIMEOUT);
}
);
}
}
참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://martinfowler.com/articles/practical-test-pyramid.html
https://techblog.woowahan.com/17674/
-
단위 테스트
이전 글들을 모두 읽고 오자 !!
Dependencies
- Spring Boot 3.4.1
- Junit 5.10.5
- Mockito
단위 테스트 예시
0. 테스트에 사용될 객체들
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email")
private String email;
@Column(name = "password")
private String password;
@Column(name = "age")
private int age;
@Builder
public User(String email, String password, int age) {
this.email = email;
this.password = password;
this.age = age;
}
public void changeAge(int age) {
this.age = age;
}
}
public interface UserRepository extends Repository<User,Long> {
User save(User user);
Optional<User> findByEmail(String email);
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public Long signUp(String email, String password, int age) {
userRepository.findByEmail(email)
.ifPresent(user -> throw new ExpectedException(ErrorCode.ALREADY_EXISTED_USER););
User user = User.builder()
.email(email)
.password(password)
.age(age)
.build();
return userRepository.save(user).getId();
}
}
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// 회원 가입
@PostMapping("/signup")
public ResponseEntity<Long> signUp(@RequestBody UserSignUpRequest signUpRequest) {
Long userId = userService.signUp(signUpRequest.getEmail(),
signUpRequest.getPassword(),
signUpRequest.getAge());
return new ResponseEntity<>(userId, HttpStatus.CREATED);
}
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserSignUpRequest {
private String email;
private String password;
private int age;
public UserSignUpRequest(String email, String password, int age) {
this.email = email;
this.password = password;
this.age = age;
}
}
@Getter
@RequiredArgsConstructor
public class ExpectedException extends RuntimeException {
private final ErrorCode errorCode;
}
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
ALREADY_EXISTED_USER("ALREADY_EXISTED_USER", "이미 존재하는 유저입니다.");
private final String errorCode;
private final String errorMessage;
}
1. Domain 단위 테스트
public class UserTest {
@Test
@DisplayName("유저 생성")
void createUser() {
// when
User user = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build();
// then
assertThat(user.getEmail()).isEqualTo("joyuri@gmail.com");
assertThat(user.getPassword()).isEqualTo("yuri123");
assertThat(user.getAge()).isEqualTo(20);
}
@Test
@DisplayName("유저 나이 변경")
void changeUserAge() {
// given
User user = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build();
// when
user.changeAge(25);
// then
assertThat(user.getAge()).isEqualTo(25);
}
}
2. Service 단위 테스트
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
@DisplayName("회원 가입 성공")
void signUp_Success() {
// given
User userArgs = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build();
User userResult = User.builder()
.email(userArgs.getEmail())
.password(userArgs.getPassword())
.age(userArgs.getAge())
.build();
ReflectionTestUtils.setField(userResult, "id", 3L);
BDDMockito.given(userRepository.save(userArgs))
.willReturn(userResult); // Stub
// when
Long userId = userService.signUp(userArgs.getEmail(), userArgs.getPassword(), userArgs.getAge());
// then
assertThat(userId).isEqualTo(3L);
}
@Test
@DisplayName("회원 가입 시 이미 존재하는 유저이면 예외 발생")
void signUp_AlreadyExistedUser_Then_Exception() {
// given
User existingUser = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build();
BDDMockito.given(userRepository.findByEmail(existingUser.getEmail()))
.willReturn(Optional.of(existingUser)); // Stub
// when & then
assertThatThrownBy(() -> userService.signUp(existingUser.getEmail(), existingUser.getPassword(), existingUser.getAge()))
.isInstanceOf(ExpectedException.class)
.satisfies(e -> {
assertThat(e.getErrorCode()).isEqualTo(ErrorCode.ALREADY_EXISTED_USER);
}
);
}
}
3. Controller 단위 테스트 & Repository 단위 테스트
Domain과 Service의 경우에는 별 다른 설정 없이 작성한 코드, 즉 순수 로직을 테스트할 수 있었다. 그러나, Controller와 Repository의 경우에는 어떨까?
Controller를 테스트하기 위해서는 웹(Spring MVC) 관련 컴포넌트들이 필요하다. 이를 위해 @WebMvcTest 어노테이션을 사용한다.
Repository를 테스트하기 위해서는 (JPA를 사용할 경우) JPA 관련 컴포넌트들이 필요하다. 이를 위해 @DataJpaTest 어노테이션을 사용한다.
@WebMvcTest, @DataJpaTest를 사용하는 테스트는 Spring Context의 특정 부분을 로드한다고 해서 슬라이스 테스트(Slice Test)라고 불리긴 하지만, 개인적으로는 단위 테스트라고 부르고자 한다.
단위 테스트는 일반적으로 한 클래스나 한 메서드를 테스트하는 것을 의미한다. @WebMvcTest 또는 @DataJpaTest는 일부라도 Spring Context를 로드하기 때문에 테스트 범위가 더 넓어 보일 수 있지만, 본질적으로는 특정 클래스(Controller나 Repository)를 테스트하는 것이기 때문에 단위 테스트로 간주할 수 있다고 생각한다.
3-1. Controller 단위 테스트
@WebMvcTest(UserController.class)
public class UserControllerTest {
@MockBean // @Mock은 Spring Context에 등록되지 않으므로 userService의 의존성이 주입되지 않음
private UserService userService;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("회원 가입 성공")
void signUp_Success() {
// given
UserSignUpRequest signUpRequest
= new UserSignUpRequest("joyuri@gmail.com", "yuri123", 20);
BDDMockito.given(userService.signUp(signUpRequest.getEmail(),
signUpRequest.getPassword(),
signUpRequest.getAge()))
.willReturn(3L);
// when & then
mockMvc.perform(post("/api/users/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(signUpRequest)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$").value(3L));
}
}
3-2. Repository 단위 테스트
@DataJpaTest
public class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("유저 저장")
void saveUser() {
// given
User newUser = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build();
// when
User savedUser = userRepository.save(newUser);
// then
assertThat(savedUser.getId()).isGreaterThan(0L);
assertThat(savedUser.getEmail()).isEqualTo(newUser.getEmail());
}
@Test
@DisplayName("이메일로 유저 찾기")
void findUserByEmail() {
// given
User existingUser = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build();
userRepository.save(existingUser);
// when
Optional<User> foundUser = userRepository.findByEmail("joyuri@gmail.com");
// then
assertThat(foundUser).isPresent();
assertThat(foundUser.get().getEmail()).isEqualTo(existingUser.getEmail());
assertThat(foundUser.get().getAge()).isEqualTo(existingUser.getAge());
}
}
참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://martinfowler.com/articles/practical-test-pyramid.html
-
테스트 범위와 종류
테스트 범위에 따른 테스트 종류
일반적인 웹 애플리케이션 구조에서, 테스트 종류는 테스트 범위에 따라 세 가지로 나눠볼 수 있다.
단위 테스트(Unit Testing)
단위 테스트는 개별 코드나 컴포넌트가 기대한대로 동작하는지 확인한다.
일반적으로, 한 클래스나 한 메서드를 테스트한다.
외부 의존성은 테스트 대역(Stub, Mock 등)으로 대체한다.
통합 테스트(Integration Testing)
통합 테스트는 시스템의 각 구성 요소가 올바르게 연동되는지 확인한다.
일반적으로, 데이터베이스, 외부 API, 프레임워크, 라이브러리, 구현한 코드 간의 연동을 테스트한다.
E2E 테스트(End to end Testing)
E2E 테스트는 사용자 입장에서 시스템이 제공하는 기능이 올바르게 동작하는지 확인한다. QA 조직에서 수행하는 테스트가 주로 E2E 테스트이다.
일반적으로, UI(웹 브라우저나 모바일 앱)을 통해 특정 사용자의 흐름이나 요구 사항을 테스트한다.
E2E 테스트를 자동화하기 위한 도구는 대표적으로 Selenium과 WebDriver가 있다.
테스트를 자동화한다는 것 = 코드로 작성한 테스트를 실행한다는 것
REST API E2E 테스트
-> 애플리케이션의 UI를 통해 테스트하는 것이 매우 어려운 경우 또는 UI가 아예 없고 대신 REST API를 제공하는 경우에는 REST API에 대한 테스트를 작성하는 것이 유용하다.
(feat. https://martinfowler.com/articles/practical-test-pyramid.html)
테스트 작성 요령
단위 테스트 vs 통합 테스트
테스트 상황
통합 테스트는 상황을 준비하거나 결과 확인이 어렵거나 불가능할 때가 있다. 외부 시스템과 연동해야 하는 기능이 특히 그렇다. 이런 경우에는 단위 테스트와 대역을 조합해서 상황을 만들고 결과를 확인해야 한다.
테스트 구성
통합 테스트를 실행하려면 DB와 같은 연동 대상을 구성해야 한다. 즉, 통합 테스트는 테스트 상황을 만들어내기 위해 많은 노력이 필요하다. 반면에 단위 테스트는 테스트 코드를 빼면 따로 준비할 것이 없다.
테스트 속도
통합 테스트는 DB 연결, 스프링 컨테이너 초기화와 같이 테스트 실행 속도를 느리게 만드는 요인이 많다. 반면에, 단위 테스트는 서버를 구동하거나 DB를 준비할 필요가 없다. 테스트 대상이 의존하는 기능을 대역으로 처리하면 되므로 테스트 실행 속도가 빠르다.
TDD를 하는지 여부에 상관없이 개발자는 단위 테스트와 통합 테스트를 섞어서 작성한다. 어떤 테스트를 더 많이 작성해야 한다는 절대적인 규칙은 없지만, 위와 같은 차이로 통합 테스트보다는 단위 테스트를 더 많이 작성한다.
통합 테스트는 단위 테스트에 비해 준비할 것이 많고 실행 시간도 길지만, 그래도 통합 테스트는 필요하다. 아무리 단위 테스트를 많이 만든다고 해도 결국은 각 구성 요소가 올바르게 연동되는 것을 확인해야 하기 때문이다.
테스트 범위에 따른 테스트 코드 개수와 시간
E2E 테스트를 수행하려면 클라이언트부터 DB까지 모든 환경이 갖춰져야 하기 때문에 자동화하거나 다양한 상황별로 테스트하기 가장 어렵다. 또한, E2E 테스트를 수행하기 위한 알맞은 도구가 없으면 E2E 테스트 코드를 작성하기 힘들 수도 있다.
이런 이유로 정기적으로 수행하는 E2E 테스트에서는 정상적인 경우와 몇 가지 특수한 상황만 테스트한다.
통합 테스트는 E2E 테스트에 비해 제약이 덜하며, 상대적으로 실행 시간이 짧고 상황을 보다 유연하게 구성할 수 있다.
이런 이유로 보통 E2E 테스트보다 통합 테스트를 더 많이 작성한다.
단위 테스트는 통합 테스트로도 만들기 힘든 상황을 쉽게 구성할 수 있고, 더 작은 단위를 대상으로 테스트 코드를 만들 수 있다.
이런 이유로 보통 통합 테스트보다 단위 테스트를 더 많이 작성한다.
E2E 테스트나 통합 테스트에서 모든 예외 상황을 테스트하면 단위 테스트는 줄어든다. 왜냐하면 각 테스트가 다루는 내용이 중복되기 때문이다.
그러나, 테스트 속도는 E2E 테스트와 통합 테스트보다 단위 테스트가 빠르기 때문에 가능하면 단위 테스트에서 다양한 상황을 다루고, E2E 테스트나 통합 테스트는 주요 상황에 초점을 맞춰야 한다. 그래야 테스트 실행 시간이 증가해 피드백이 느려지는 것을 방지할 수 있다.
참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://martinfowler.com/articles/practical-test-pyramid.html
https://reflectoring.io/spring-boot-web-controller-test/
https://dingdingmin-back-end-developer.tistory.com/entry/Springboot-Test-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1-1
https://stackoverflow.com/questions/64541192/assertthatthrownby-check-field-on-custom-exception
-
테스트 대역(Test Double)
Martin Fowler : 테스트 대역은 테스트를 목적으로 프로덕션 객체를 대체하는 모든 경우를 포괄하는 일반적인 용어이다.
(Test Double is a generic term for any case where you replace a production object for testing purposes.)
Gerard Meszaros : 테스트를 작성할 때, DOC를 사용할 수 없거나 사용하지 않기로 선택한 경우, 이를 테스트 대역으로 대체할 수 있다. 테스트 대역은 실제 DOC와 동일하게 동작할 필요는 없으며, 단지 실제 DOC와 동일한 API를 제공하여 SUT가 이를 실제 DOC로 인식하게 만들면 된다.
(When we are writing a test in which we cannot (or chose not to) use a real depended-on component (DOC), we can replace it with a Test Double. The Test Double doesn’t have to behave exactly like the real DOC; it merely has to provide the same API as the real one so that the SUT thinks it is the real one!)
SUT (System Under Test)
- 테스트 대상 시스템을 의미한다.
- 단위 테스트에서의 SUT는 테스트 대상이 되는 클래스, 객체, 메서드를 의미한다.
DOC (Depended-On Component)
- SUT가 의존하는 개별 클래스 또는 대규모 컴포넌트를 의미한다.
테스트 대역 종류
종류
설명
부가설명
관련검증
스텁(Stub)
테스트만을 위한 단순한 구현을 가지고 있는 객체
상태 검증(State Verification)
가짜(Fake)
프로덕션에는 적합하지 않지만 실제 동작하는 구현을 가지고 있는 객체
상태 검증
스파이(Spy)
호출된 내역을 가지고 있는 객체, Stub이기도 하다.
호출된 내역이란 테스트 중 Spy 객체의 메서드가 몇 번 호출되었는지, 어떤 인자로 호출되었는지에 대한 정보이다.
상태 검증, 행위 검증
모의(Mock)
실제 객체를 흉내 내는 가짜 객체, Stub과 Spy를 대신할 수 있다.
mock 객체의 주요 기능은 기대한 대로 상호작용하는지 확인(= 행위 검증)하는 것이다.
행위 검증(Behavior Verification)
테스트 대역 예시 - TDD (참고 : TDD 흐름과 예시)
UserRegister(테스트 대상) : 회원 가입에 대한 핵심 로직을 수행
WeakPasswordChecker : 암호가 약한지 검사
UserRepository : 회원 정보 저장 및 조회 기능
EmailNotifier : 이메일 발송 기능
첫 번째 테스트 : 암호가 약한 경우 회원 가입에 실패 - Stub 사용
테스트 코드 작성
public class UserRegisterTest {
private UserRegister userRegister;
private StubWeakPasswordChecker stubPasswordChecker =
new StubWeakPasswordChecker();
@BeforeEach
void setUp() {
userRegister = new UserRegister(stubPasswordChecker);
}
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
// given
stubPasswordChecker.setWeak(true);
// when & then
assertThatThrownBy(() -> userRegister.register("id", "pw", "email"))
.isInstanceOf(WeakPasswordException.class);
}
}
구현
public class UserRegister {
private WeakPasswordChecker passwordChecker;
public UserRegister(WeakPasswordChecker passwordChecker) {
this.passwordChecker = passwordChecker;
}
public void register(String id, String pw, String email) {
if(passwordChecker.checkPasswordWeak(pw)) {
throw new WeakPasswordException();
}
}
}
public interface WeakPasswordChecker {
boolean checkPasswordWeak(String pw);
}
public class StubWeakPasswordChecker implements WeakPasswordChecker {
private boolean weak;
public void setWeak(boolean weak) {
this.weak = weak;
}
@Override
public boolean checkPasswordWeak(String pw) {
return weak;
}
}
public class WeakPasswordException extends RuntimeException {
}
두 번째 테스트 : 동일 ID를 가진 회원이 존재할 경우 회원 가입에 실패 - Fake 사용
테스트 코드 작성
public class UserRegisterTest {
private UserRegister userRegister;
private StubWeakPasswordChecker stubPasswordChecker =
new StubWeakPasswordChecker();
private MemoryUserRepository fakeRepository =
new MemoryUserRepository(); // 추가
@BeforeEach
void setUp() {
userRegister = new UserRegister(stubPasswordChecker,
fakeRepository); // 추가
}
@DisplayName("이미 같은 ID가 존재하면 가입 실패")
@Test
void dupIdExists() {
// given
fakeRepository.save(new User("id", "pw1", "email1"));
// when & then
assertThatThrownBy(() -> userRegister.register("id", "pw2", "email2"))
.isInstanceOf(DubIdException.class);
}
}
구현
public class UserRegister {
private WeakPasswordChecker passwordChecker;
private UserRepository userRepository; // 추가
public UserRegister(WeakPasswordChecker passwordChecker,
UserRepository userRepository) {
this.passwordChecker = passwordChecker;
this.userRepository = userRepository; // 추가
}
public void register(String id, String pw, String email) {
if(passwordChecker.checkPasswordWeak(pw)) {
throw new WeakPasswordException();
}
// 추가
User user = userRepository.findById(id);
if (user != null) {
throw new DupIdException();
}
userRepository.save(new User(id, pw, email));
}
}
public interface UserRepository {
void save(User user);
User findById(String id);
}
public class MemoryUserRepository implements UserRepository {
private Map<String, User> users = new HashMap<>();
@Override
public void save(User user) {
users.put(user.getId(), user);
}
@Override
public User findById(String id) {
return users.get(id);
}
}
public class DubIdException extends RuntimeException {
}
세 번째 테스트 : 회원 가입 성공 시 메일 발송 - Spy 사용
이메일 발송 여부를 어떻게 확인할 수 있을까?
이를 확인할 수 있는 방법 중 하나는, UserRegister가 EmailNotifier의 메일 발송 기능을 실행할 때 이메일 주소로 “email@naver.com”을 사용했는지 확인하는 것이다. 이런 용도로 사용할 수 있는 것이 스파이(Spy) 대역이다.
테스트 코드 작성
public class UserRegisterTest {
private UserRegister userRegister;
private StubWeakPasswordChecker stubPasswordChecker =
new StubWeakPasswordChecker();
private MemoryUserRepository fakeRepository =
new MemoryUserRepository();
private SpyEmailNotifier spyEmailNotifier =
new SpyEmailNotifier(); // 추가
@BeforeEach
void setUp() {
userRegister = new UserRegister(stubPasswordChecker,
fakeRepository,
spyEmailNotifier); // 추가
}
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
// when
userRegister.register("id", "pw", "email@naver.com");
// then
// sendRegisterEmail가 호출되었는지 확인
assertThat(spyEmailNotifier.isCalled()).isTrue();
// sendRegisterEmail가 호출될 때, 인자로 "email@naver.com"가 사용되었는지 확인
assertThat(spyEmailNotifier.getEmail()).isEqualTo("email@naver.com");
}
}
구현
public class UserRegister {
private WeakPasswordChecker passwordChecker;
private UserRepository userRepository;
private EmailNotifier emailNotifier; // 추가
public UserRegister(WeakPasswordChecker passwordChecker,
UserRepository userRepository,
EmailNotifier emailNotifier) {
this.passwordChecker = passwordChecker;
this.userRepository = userRepository;
this.emailNotifier = emailNotifier; // 추가
}
public void register(String id, String pw, String email) {
if(passwordChecker.checkPasswordWeak(pw)) {
throw new WeakPasswordException();
}
User user = userRepository.findById(id);
if (user != null) {
throw new DupIdException();
}
userRepository.save(new User(id, pw, email));
emailNotifier.sendRegisterEmail(email); // 추가
}
}
public interface EmailNotifier {
void sendRegisterEmail(String email);
}
public class SpyEmailNotifier implements EmailNotifier {
private boolean called;
private String email;
public boolean isCalled() {
return called;
}
public String getEmail() {
return email;
}
@Override
public void sendRegisterEmail(String email) {
this.called = true;
this.email = email;
}
}
Stub과 Spy 대체 - Mock 사용
public class UserRegisterTest {
private UserRegister userRegister;
private WeakPasswordChecker mockPasswordChecker =
Mockito.mock(WeakPasswordChecker.class); // 수정
private MemoryUserRepository fakeRepository =
new MemoryUserRepository();
private EmailNotifier mockEmailNotifier =
Mockito.mock(EmailNotifier.class); // 수정
@BeforeEach
void setUp() {
userRegister = new UserRegister(mockPasswordChecker,
fakeRepository,
mockEmailNotifier); // 수정
}
// Mock 객체를 사용하여 Stub을 대신함
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
// given
BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw"))
.willReturn(true);
// when & then
assertThatThrownBy(() -> userRegister.register("id", "pw", "email"))
.isInstanceOf(WeakPasswordException.class);
}
// Mock 객체를 사용하여 Spy를 대신함
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
// when
userRegister.register("id", "pw", "email@naver.com");
// then
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
// sendRegisterEmail가 호출되었는지 확인 + 인자를 captor에 저장
BDDMockito.then(mockEmailNotifier)
.should().sendRegisterEmail(captor.capture());
String realEmail = captor.getValue();
// sendRegisterEmail가 호출될 때, 인자로 "email@naver.com"가 사용되었는지 확인
assertThat(realEmail).isEqualTo("email@naver.com");
}
}
Behavior Verification - Mock 사용
@DisplayName("회원 가입시 암호 검사 수행함")
@Test
void checkPassword() {
// when
userRegister.register("id", "pw", "email");
// then
BDDMockito.then(mockPasswordChecker) // 해당 Mock 객체의
.should() // 특정 메서드가 호출됐는지 검증하는데
.checkPasswordWeak(BDDMockito.anyString()); // 임의의 String 타입 인자를 이용해서 checkPasswordWeak 메서드 호출 여부 확인
}
대역 사용의 이점과 필요성
대기 시간을 줄여준다.
ex) 다른 개발자가 A 기능을 구현하고 있을 때, 구현이 완료될 때까지 기다리지 않고 A 기능을 테스트할 수 있다.
ex) DB 서버가 구축되기 전에도 데이터가 올바르게 저장되는지 확인할 수 있다.
ex) 회원 가입 성공 시 메일 발송 여부를 확인할 때, 메일함에 메일이 도착할 때까지 기다릴 필요가 없다.
다양한 상황에 대해 테스트할 수 있다.
ex) 외부 API를 사용할 때, 해당 API 서버의 응답을 5초 이내에 받지 못하는 상황을 테스트해볼 수 있다.
테스트 수행이 외부에 영향을 주면 안되는 경우, 대역을 통해 해결할 수 있다.
ex) 은행 계좌에서 돈을 인출하는 기능을 테스트할 때, 실제 인출 요청이 API 서버에 전달되어서는 안된다.
테스트 내에서 같은 요청을 보냈을 때 외부의 응답이 항상 동일할 것이라고 신뢰할 수 없는 경우, 대역을 통해 해결할 수 있다.
ex) 발급받은 오픈 API의 Key가 만료되면 응답이 달라진다.
ex) 외부 API 서버에 장애가 발생하면 응답이 달라진다.
참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://martinfowler.com/bliki/TestDouble.html
http://xunitpatterns.com/Test%20Double.html
http://xunitpatterns.com/SUT.html
http://xunitpatterns.com/DOC.html
https://gyuwon.github.io/blog/2020/05/10/do-you-really-need-test-doubles.html
https://beomseok95.tistory.com/295
https://jonghoonpark.com/2023/12/05/test-double-for-well-grounded-java-developer
https://greeng00se.github.io/test-double
https://umbum.dev/1233/
https://f-lab.kr/insight/test-code-and-mock-object
-
(CI/CD) Github Actions Secret의 JSON 파일 저장 오류
구글 인앱결제 서비스 로직을 구현할 때 겪은 오류이다.
1. 오류 발생 상황
전체 코드
// InAppPurchaseService.java
@Service
public class InAppPurchaseService {
@Value("${google-account.file-path}")
private String googleAccountFilePath;
@Value("${google-application.package-name}")
private String googleApplicationPackageName;
@Transactional
public Boolean validateReceipt(Long userId, PurchaseRequest purchaseRequest) {
HttpTransport httpTransport;
GoogleCredentials credentials;
try {
httpTransport = GoogleNetHttpTransport.newTrustedTransport();
InputStream inputStream = new ClassPathResource(googleAccountFilePath).getInputStream();
credentials = GoogleCredentials
.fromStream(inputStream)
.createScoped(AndroidPublisherScopes.ANDROIDPUBLISHER);
} catch (IOException | GeneralSecurityException e) {
throw ExpectedException.withLogging(ResponseCode.GoogleCredentialException, e);
}
AndroidPublisher.Builder builder = new AndroidPublisher.Builder(httpTransport,
GsonFactory.getDefaultInstance(),
new HttpCredentialsAdapter(credentials));
AndroidPublisher publisher = builder.setApplicationName(googleApplicationPackageName).build();
//...(중략)
}
}
# ci-cd.yml
jobs:
CI-CD:
runs-on: ubuntu-22.04
steps:
# ...(중략)
- name: Create Google key.json file
if: contains(github.ref, 'main') || contains(github.ref, 'staging')
run: |
cd ./genti-api/src/main/resources
mkdir -p ./jsonkey
echo "${secrets.GOOGLE_ACCOUNT_KEY }" > ./jsonkey/key.json
shell: bash
# ...(중략)
오류 메세지
com.google.gson.stream.MalformedJsonException: Use JsonReader.setLenient(true) to accept malformed JSON at line 2 column 4 path $.
2. 오류 분석
코드 오류 부분
// InAppPurchaseService.java
public Boolean validateReceipt(Long userId, PurchaseRequest purchaseRequest) {
//...
try {
//...
credentials = GoogleCredentials
.fromStream(inputStream) // 오류 발생 부분
.createScoped(AndroidPublisherScopes.ANDROIDPUBLISHER);
}
//...
}
오류 분석
오류 메시지를 보면, JSON 파싱 중 문제가 있다고 한다. 로컬에서는 발생하지 않던 오류라서, 서버 측에 저장되는 JSON 파일에 문제가 있을 것으로 추측했다.
따라서, 서버 측에 저장되는 JSON 파일을 확인하기 위해 해당 JSON 파일을 S3에 저장하는 부분을 ci-cd.yml에 임시로 추가했다.
- name: Upload key.json to S3
if: contains(github.ref, 'staging')
run: |
aws s3 cp ./genti-api/src/main/resources/jsonkey/key.json s3://$S3_BUCKET_NAME/jsonkey/key.json
S3에서 key.json 파일을 다운받아 확인해보았더니, 아래처럼 큰따옴표가 다 사라져 있었다.
{
type: service_account,
project_id: example,
(...)
}
결론 - 오류의 원인
Github Actions Secret에 JSON 파일 내용을 넣으면 JSON 파일 내용의 큰따옴표가 다 사라지게 된다.
3. 오류 해결
create-json 라이브러리 사용
# ci-cd.yml
- name: Create jsonkey directory
if: contains(github.ref, 'main') || contains(github.ref, 'staging')
run: mkdir -p ./genti-api/src/main/resources/jsonkey
- name: Create Google key.json file
if: contains(github.ref, 'main') || contains(github.ref, 'staging')
id: create-json
uses: jsdaniell/create-json@1.1.2
with:
name: "./genti-api/src/main/resources/jsonkey/key.json"
json: ${ secrets.GOOGLE_ACCOUNT_KEY }
참조 자료
https://stackoverflow.com/questions/11484353/gson-throws-malformedjsonexception
https://choo.oopy.io/fd2d4fc6-21ac-45b6-bd0c-05768920bb00
https://roel-yomojomo.tistory.com/38
https://velog.io/@godkimchichi/Github-Actions-secret%EC%97%90-json-%EB%84%A3%EA%B3%A0-%EC%8B%B6%EC%9D%84-%EB%95%8C
-
(JPA) 삭제 상태 Entity(객체)를 merge()할 때 발생하는 ObjectDeletedException
Spring Data JPA를 쓰고 있는 Repository 계층의 단위 테스트를 작성할 때 겪은 오류이다.
1. 오류 발생 상황
전체 코드
@DataJpaTest
public class ResponseExampleRepositoryTest {
@Autowired
ResponseExampleRepository responseExampleRepository;
private List<ResponseExample> mockResponseExamples;
@BeforeEach
void setUp() {
mockResponseExamples = List.of(
createExample("피드뷰 - 예시 프롬프트1", PictureRatio.RATIO_SERO, null, FALSE),
createExample("피드뷰 - 예시 프롬프트2", PictureRatio.RATIO_SERO, null, FALSE),
createExample("피드뷰 - 예시 프롬프트3", PictureRatio.RATIO_SERO, null, FALSE),
// ..(중략)
);
responseExampleRepository.saveAll(mockResponseExamples);
}
@Test
@DisplayName("피드뷰 - 조건에 맞는 예시가 없을 때 빈 리스트가 반환되는지 검증")
void findAllFeedViewWhenNoMatchingData_Then_Return_emptyList() {
//given
responseExampleRepository.deleteAll();
responseExampleRepository.saveAll(mockResponseExamples.subList(10, mockResponseExamples.size()));
//when
List<ResponseExample> result = responseExampleRepository.findAllByPromptOnlyIsFalse();
//then
assertThat(result).isEmpty();
}
}
오류 메세지
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.ObjectDeletedException: deleted instance passed to merge
2. 오류 분석
코드 오류 부분
@Test
@DisplayName("피드뷰 - 조건에 맞는 예시가 없을 때 빈 리스트가 반환되는지 검증")
void findAllFeedViewWhenNoMatchingData_Then_Return_emptyList() {
responseExampleRepository.deleteAll();
responseExampleRepository.saveAll(mockResponseExamples.subList(10, mockResponseExamples.size()));
// ..(중략)
}
오류 분석 요약
Hibernate의 merge()는 삭제 상태 객체를 처리할 수 없다.
merge()하려고 할 때 삭제된 객체가 포함되어 있다면 오류(ObjectDeletedException)를 던진다.
deleteAll()을 호출한 이후에 삭제된 객체를 saveAll()로 다시 저장하려고 해서 오류가 발생한 것이다.
테스트 메서드의 saveAll()에서 인자로 넣어준 객체들이 setUp 메서드의 saveAll()에서 인자로 넣어준 객체들과 같은 객체이기 때문이다.
saveAll()은 내부적으로 merge()를 사용한다.
오류 분석 상세
@DataJpaTest 어노테이션이 테스트 클래스에 붙어 있기 때문에, 각 테스트 메서드는 트랜잭션 내에서 실행된다. 또한, 각 테스트 메서드는 테스트 클래스가 실행되는 동안 하나의 트랜잭션 범위 내에서 진행된다.
@Transactional 어노테이션이 기본적으로 사용하는 전파 방식은 Propagation.REQUIRED이다. REQUEST 옵션은 현재 트랜잭션이 없으면 새로 시작하고, 이미 트랜잭션이 있으면 해당 트랜잭션에 참여하는 동작을 한다.
테스트 메서드는 @Transcational을 사용하고 있으며, deleteAll()과 saveAll()를 호출하고 있다. 이때, deleteAll()과 saveAll()은 모두 내부적으로 @Transactional 어노테이션을 사용하고 있는데, 이 메서드들을 호출하는 테스트 메서드에서 트랜잭션이 존재하기 때문에 두 메서드 모두 새로운 트랜잭션을 시작하지 않고 이미 존재하는 트랜잭션(테스트 메서드 것)에 참여한다.
스프링은 기본으로 트랜잭션 범위의 영속성 컨텍스트 전략을 사용한다. 이는 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다는 의미이다. 또한, 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.
테스트 메서드 내의 deleteAll()과 saveAll()은 같은 트랜잭션(테스트 메서드 것)에 참여하고 있으므로 같은 영속성 컨텍스트를 사용한다.
결론 - 오류의 원인
deleteAll()과 saveAll()이 같은 영속성 컨텍스트를 사용하고 있다.
1번의 이유만으로는 오류가 발생하진 않는다. 그러나 나는 1번의 상황 속에서 deleteAll()에서 삭제된 객체를 saveAll()에서 사용하기 때문에 오류가 발생한 것이다.
3. 오류 해결
방법 1 : 삭제된 객체를 다시 저장하지 않고, 새로운 객체를 생성하여 저장
void findAllFeedViewWhenNoMatchingData_Then_Return_emptyList() {
// given
responseExampleRepository.deleteAll();
List<ResponseExample> newObjects = mockResponseExamples.subList(10, mockResponseExamples.size())
.stream()
.map(example ->
createExample(example.getExamplePrompt(),
example.getPictureRatio(),
example.getType(),
example.getPromptOnly())) // 새 객체 생성
.collect(Collectors.toList());
responseExampleRepository.saveAll(newObjects);
// ...(동일)
}
방법 2 : 영속성 컨텍스트 초기화
@PersistenceContext
private EntityManager entityManager;
void findAllFeedViewWhenNoMatchingData_Then_Return_emptyList() {
// given
responseExampleRepository.deleteAll();
entityManager.flush(); // DB와 동기화
// flush()로 데이터베이스에 반영된 변경 사항도 테스트가 끝난 후 롤백되므로
// 최종적으로 데이터는 삭제되지 않은 상태로 유지된다!
entityManager.clear(); // 영속성 컨텍스트 초기화
responseExampleRepository.saveAll(mockResponseExamples.subList(10, mockResponseExamples.size()));
// ...(동일)
}
참고 자료
https://stackoverflow.com/questions/18358407/org-hibernate-objectdeletedexception-deleted-object-would-be-re-saved-by-cascad
https://stackoverflow.com/questions/31335211/autowired-vs-persistencecontext-for-entitymanager-bean
https://www.inflearn.com/community/questions/527211/%EC%98%81%EC%86%8D%EC%84%B1-%EC%BB%A8%ED%85%8D%EC%8A%A4%ED%8A%B8%EC%99%80-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-propagation?srsltid=AfmBOordQwcCFu6HJkovFm-_NBBIR6DOSjiUAWJYAvy0H_xmAGEZMWnG
https://milenote.tistory.com/107
https://insanelysimple.tistory.com/314
ChatGPT
-
테스트 코드의 구성
테스트 코드의 구성 요소 : 상황, 실행, 결과 확인
테스트 코드는 기능을 실행하고 그 결과를 확인하므로 상황, 실행, 결과 확인의 세 가지 요소로 테스트를 구성할 수 있다.
어떤 상황이 주어지고(given), 그 상황에서 기능을 실행하고(when), 실행한 결과를 확인하는(then) 세 가지가 테스트 코드의 기본 골격을 이루게 된다.
기능은 상황에 따라 결과가 달라진다. 상황에 따라 다르게 동작하는 예시(숫자 야구 게임)를 살펴보자.
숫자 야구 게임은 0~9 사이의 서로 다른 숫자 세 개를 고르면 상대방이 그 숫자를 맞추면 게임이다. 즉, 이 게임에서의 상황은 정답 숫자이다.
@Test
void exactMatch() {
// 정답이 456인 상황 (= given)
BaseballGame game = new BaseballGame("456");
// 실행 (= when)
Score score = game.guess("456");
// 결과 확인 (= then)
assertThat(score.strike()).isEqaulTo(3);
}
@Test
void noMatch() {
// 정답이 123인 상황 (= given)
BaseballGame game = new BaseballGame("123");
// 실행 (= when)
Score score = game.guess("456");
// 결과 확인 (= then)
assertThat(score.strike()).isEqaulTo(0);
}
추가로, 여러 테스트에서 같은 상황이 있으면 @BeforeEach를 적용한 메서드에서 상황을 설정하는 것이 좋다.
상황이 없는 경우도 존재한다. TDD 흐름과 예시에서 본 예제가 이에 해당한다.
암호 강도 측정의 경우 결과에 영향을 주는 상황이 존재하지 않으므로 테스트는 다음처럼 기능을 실행하고 결과를 확인하는 코드만 포함하고 있다.
@Test
void meetsAllCriteria_Then_Strong() {
// 실행 (= when)
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@AB");
// 결과 확인 (= then)
assertThat(result).isEqualTo(PasswordStrength.STRONG);
}
또한, 실행 결과가 항상 리턴 값으로 존재하는 것은 아니다. 실행 결과로 예외를 발생하는 것이 정상인 경우도 있다.
@Test
void game_With_DupNumber_Then_Fail() {
assertThatThrownBy(() -> new BaseballGame("110"))
.isInstanceOf(IllegalArgumentException.class);
}
상황(given)-실행(when)-결과 확인(then) 구조에 너무 집착하지는 말자.
이 구조가 테스트 코드를 작성하는데 도움이 되는 것은 맞지만 꼭 모든 테스트 메서드를 이 구조로 만들어야 하는 것은 아니다. 테스트 코드를 보고 테스트 내용을 이해할 수 있으면 된다.
상황(given)에 대한 나의 생각
상황을 좀 더 구체적으로 말하자면, 결과에 영향을 주는 상황이다. 아래의 예시를 한번 보자.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@InjectMocks
private UserService userService;
@Mock
private UserRepository userRepository; // UserRepository의 대역으로 Mock 사용
@Test
@DisplayName("회원 조회")
void findUser() {
// given
Long userId = 1L;
User user = User.builder()
.userId(userId)
.name("joyuri")
.build();
BDDMockito.given(userRepository.findByUserId(userId))
.willReturn(user); // Mock 객체가 Stub의 역할을 대신함
// when
User findUser = userService.findUser(userId);
// then
assertThat(findUser.getUserId()).isEqualTo(user.getUserId());
assertThat(findUser.getName()).isEqualTo(user.getName());
}
}
BDDMockito.given()는 userService.findUser() 기능에 영향을 주고, 이는 곧 결과에 영향을 주므로 “결과에 영향을 주는 상황”의 의미가 잘 들어맞는다. 그런데 Long userId = 1L과 User user = User.builder()... 부분은 어떨까? 이 부분은 “결과에 영향을 주는 상황”이라고 말하기에는 조금 애매해보인다.
위의 예시를 아래와 같이 하드 코딩하여 수정해보았다. 테스트는 문제 없이 잘 작동한다.
@Test
@DisplayName("회원 조회")
void findUser() {
// given
BDDMockito.given(userRepository.findByUserId(1L))
.willReturn(User.builder()
.userId(userId)
.name("joyuri")
.build(););
// when
User findUser = userService.findUser(1L);
// then
assertThat(findUser.getUserId()).isEqualTo(1L);
assertThat(findUser.getName()).isEqualTo("joyuri");
}
즉, Long userId = 1L과 User user = User.builder()... 부분은 “결과에 영향을 주는 상황”이라기보다는, when과 then에서의 “하드 코딩을 피하기 위해 변수를 선언하고 초기화한 것”의 의미가 더 적합해보인다.
그래서, 나의 결론은 다음과 같다.
테스트 코드도 코드이기 때문에, 하드 코딩보다는 변수를 활용하는 것이 좋다. 그러면 변수를 선언하고 초기화하는 부분은 어디에 작성할까? given 부분에 작성하지 말고 다른 부분에 작성하는 것보다는, 이 부분도 그냥 given 부분에 작성하는 것이 좋아보인다. given-when-then 구조를 유지할 수 있고, 일반적으로 많은 사람들도 given 부분에 작성하기 때문이다!
외부 상황과 외부 결과
상황에는 외부 요인도 있다.
상황에 영향을 주는 외부 요인은 파일, DBMS, 외부 서버 등 다양하다.
또한, 외부에서 결과를 확인해야 할 때도 있다.
예를 들어, 처리 결과를 지정한 경로의 파일에 저장하는 기능의 경우, 이 기능을 실행한 결과를 검증하려면 해당 경로에 파일이 원하는 내용으로 만들어졌는지 확인해야 한다.
외부 상황 예시 1 : 파일에서 데이터를 읽어오는 경우
@Test
void dataFileSumTest() {
// given
File dataFile = new File("src/test/resources/datafile.txt");
// when
long sum = MathUtils.sum(dataFile);
// then
assertThat(sum).isEqualTo(6L);
}
이 경우 다음 데이터를 갖는 “datafile.txt” 파일을 해당 경로에 미리 만들어야 하며, 다른 개발자도 테스트를 실행할 수 있어야 하므로 해당 파일은 버전 관리 대상에 추가해야 한다.
// datafile.txt
1
2
3
외부 상황 예시 2 : 계좌 번호 검증을 위해 외부 API를 사용하는 경우
@Test
void isValidAccountTest() throws Exception {
// given
String apiUrl = "https://example.com/api";
AccountValidator validator = new AccountValidator(apiUrl);
// when
boolean isValid = validator.isValidAccount("123456-78-910");
// then
assertThat(isValid).isTrue();
}
위와 같이 REST API 응답 결과가 유효한(또는 유효하지 않은) 계좌 번호인 상황은 테스트해볼 수 있지만, 아래의 상황들은 테스트해볼 수 없다.
REST API 서버에 연결할 수 없는 상황
REST API 서버에서 응답을 5초 이내에 받지 못하는 상황
이처럼 테스트 대상의 상황과 결과에 외부 요인이 관여할 경우에, 대역을 사용하면 테스트 작성이 쉬워진다.
대역은 테스트 대상이 의존하는 대상의 실제 구현을 대신하는 구현인데, 이 대역을 통해서 외부 상황이나 결과를 대체할 수 있다.
참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
-
TDD 흐름과 예시
TDD(Test-Driven Development)는 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스이다. 먼저, 개발자는 먼저 요구사항을 검증하는 자동화된 테스트 케이스를 작성한다. 그런 후에, 그 테스트 케이스를 통과하기 위한 최소한의 코드를 생성한다. 마지막으로 작성한 코드를 표준에 맞도록 리팩토링한다. (feat. 위키백과)
TDD 흐름
테스트(Red) : 기능을 검증하는 테스트 코드를 먼저 작성한다.
테스트에 대한 구현을 하지 않았으므로 실패해야 한다.
실패 예시 : 존재하지 않는 객체나 메서드 등을 작성하여 실패(컴파일 에러), 객체나 메서드가 이미 존재하지만 테스트할 상황에 대한 구현이 되어 있지 않아서 실패 등
코딩(Green) : 테스트를 통과할 만큼만 코드를 작성한다.
지금까지 작성한 테스트들을 통과할 만큼만 구현을 진행하며, 아직 추가하지 않은 테스트들을 고려하지 않는다.
리팩토링(Refactor) : 테스트를 통과한 뒤에는 개선할 코드가 있으면 리팩토링한다. 리팩토링을 수행한 뒤에는 다시 테스트를 실행해서 기존 기능이 망가지지 않았는지 확인한다.
테스트 코드도 코드이기 때문에 유지보수 대상이다. 즉, 테스트 메서드에서 발생하는 중복을 제거하거나 의미가 잘 드러나게 코드를 수정할 필요가 있다.
그러나 테스트 코드의 중복을 무턱대고 제거하면 안된다. 중복을 제거한 뒤에도 테스트 코드의 가독성이 떨어지지 않고 수정이 용이한 경우에만 중복을 제거해야 한다.
=> 이 과정들을 반복하면서 점진적으로 기능을 완성해 나가는 것이 전형적인 TDD의 흐름이며, 테스트 코드를 먼저 작성함으로써 테스트가 개발을 주도하게 된다
TDD 예시(단위 테스트) - 암호 검사기
암호 검사기 기능을 TDD로 구현해보자. 암호 검사기는 문자열을 검사해서 규칙을 준수하는지에 따라 암호를 “강함”, “보통”, “약함”으로 구분한다.
검사할 규칙은 다음 두 가지이다.
길이가 8글자 이상
0부터 9 사이의 숫자를 포함
암호의 구분은 다음과 같다.
2개의 규칙 모두 충족 => “강함”
1개의 규칙 충족 => “보통”
0개의 규칙 충족 => “약함”
첫 번째 테스트를 잘 선택하는 것이 중요하다. 첫 번째 테스트를 선택할 때에는 가장 쉽거나 가장 예외적인 상황을 선택해야 한다.
모든 규칙을 충족하는 경우 -> 해당 상황을 선택!
모든 규칙을 충족하지 않는 경우
값이 없는 경우(null 또는 빈 문자열)
첫 번째 테스트 : 모든 규칙을 충족하는 경우
테스트 코드 작성
테스트할 기능을 제공할 클래스의 이름, 테스트 메서드 이름, 메서드의 리턴 타입 등을 결정했다.
PasswordStrengthMeter 타입과 PasswordStrength 타입이 존재하지 않으므로 컴파일 에러가 발생한다.
public class PasswordStrengthMeterTest {
@Test
void meetsAllCriteria_Then_Strong() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@AB");
assertThat(result).isEqualTo(PasswordStrength.STRONG);
}
}
구현
지금까지 작성한 테스트들을 통과할 만큼만 구현한다.
public enum PasswordStrength {
STRONG
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
return PasswordStrength.STRONG;
}
}
리팩토링
아직 개선할 부분이 없어 보인다!
두 번째 테스트 : 길이만 8글자 미만이고 나머지 조건은 충족하는 경우
테스트 코드 작성
PasswordStrength 열거 타입에 NORMAL이 없으므로 컴파일 에러가 발생하고, 암호의 글자수 확인에 대한 구현이 되어 있지 않아 실패한다.
public class PasswordStrengthMeterTest {
@Test
void meetsOtherCriteria_except_for_length_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@A");
assertThat(result).isEqualTo(PasswordStrength.NORMAL);
}
}
구현
지금까지 작성한 테스트들을 통과할 만큼만 구현한다.
public enum PasswordStrength {
STRONG, NORMAL
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
// 추가
if (s.length() < 8) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
}
리팩토링
아직 개선할 부분이 없어 보인다!
세 번째 테스트 : 숫자를 포함하지 않고 나머지 조건은 충족하는 경우
테스트 코드 작성
컴파일 에러는 발생하지 않지만, 암호의 숫자 포함 여부에 대한 구현이 되어 있지 않아 실패한다.
public class PasswordStrengthMeterTest {
@Test
void meetsOtherCriteria_except_for_number_Then_Normal() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab!@ABCDEF");
assertThat(result).isEqualTo(PasswordStrength.NORMAL);
}
}
구현
지금까지 작성한 테스트들을 통과할 만큼만 구현한다.
public enum PasswordStrength {
STRONG, NORMAL
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s.length() < 8) return PasswordStrength.NORMAL;
// 추가
boolean containsNum = false;
for (char ch : s.toCharArray()) {
if (ch >= '0' && ch <= '9') {
containsNum = true;
break;
}
}
if (!containsNum) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
}
리팩토링
세 개의 테스트 메서드에서 PasswordStrengthMeter 객체를 생성하는 코드의 중복, 기능을 실행하고 이를 확인하는 코드의 중복을 제거했다.
숫자 포함 여부를 확인하는 코드가 다소 길어지므로 해당 코드를 메서드로 추출해서 가독성을 개선하고 매서드 길이도 줄였다.
public class PasswordStrengthMeterTest {
private PasswordStrengthMeter meter = new PasswordStrengthMeter();
@Test
void meetsAllCriteria_Then_Strong() {
assertStrength("ab12!@AB");
}
@Test
void meetsOtherCriteria_except_for_length_Then_Normal() {
assertStrength("ab12!@A");
}
@Test
void meetsOtherCriteria_except_for_number_Then_Normal() {
assertStrength("ab!@ABCDEF");
}
private void assertStrength(String password, PasswordStrength pwstr) {
PasswordStrength result = meter.meter(password);
assertThat(result).isEqualTo(pwstr);
}
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s.length() < 8) return PasswordStrength.NORMAL;
// 수정
boolean containsNum = meetsContainingNumberCriteria(s);
if(!containsNum) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
// 추가
private boolean meetsContainingNumberCriteria(String s) {
for (char ch : s.toCharArray()) {
if (ch >= '0' && ch <= '9') {
return true
}
}
return false;
}
}
네 번째 테스트 : 값이 없는 경우(null 또는 빈 문자열)
테스트 코드 작성
null 또는 빈 문자열을 입력할 경우에, IllegalArgumentException 발생시키는 방법과 유효하지 않은 암호를 의미하는 PasswordStrength.INVALID를 리턴하는 방법을 떠올렸고 이 중에 후자의 방법을 선택했다.
PasswordStrength 열거 타입에 INVALID이 없으므로 컴파일 에러가 발생하고, 값이 없는 상황에 대한 구현이 되어 있지 않아 실패한다.
public class PasswordStrengthMeterTest {
@Test
void nullOrEmptyInput_Then_Invalid() {
assertStrength(null, PasswordStrength.INVALID);
assertStrength("", PasswordStrength.INVALID);
}
}
구현
지금까지 작성한 테스트들을 통과할 만큼만 구현한다.
public enum PasswordStrength {
STRONG, NORMAL, INVALID
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
//추가
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
if (s.length() < 8) return PasswordStrength.NORMAL;
boolean containsNum = meetsContainingNumberCriteria(s);
if(!containsNum) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
}
리팩토링
개선할 부분이 없어 보인다!
다섯 번째 테스트 : 모든 규칙을 충족하지 않는 경우
테스트 코드 작성
PasswordStrength 열거 타입에 WEAK가 없으므로 컴파일 에러가 발생하고, 모든 규칙을 충족하지 않는 상황에 대한 구현이 되어 있지 않아 실패한다.
public class PasswordStrengthMeterTest {
@Test
void meetsNoCriteria_Then_Weak() {
assertStrength("abc", PasswordStrength.WEAK);
}
}
구현
지금까지 작성한 테스트들을 통과할 만큼만 구현한다.
WEAK를 리턴하기 위해 꽤 많은(?) 코드를 수정했다.
public enum PasswordStrength {
STRONG, NORMAL, INVALID, WEAK
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
boolean lengthEnough = s.length() >= 8; // 수정
boolean containsNum = meetsContainingNumberCriteria(s);
// 추가
if(!lengthEnough && !containsNum) return PasswordStrength.WEAK;
if(!lengthEnough) return PasswordStrength.NORMAL; // 수정
if(!containsNum) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
}
리팩토링
지금까지의 코드도 전체적으로 나쁘진 않았지만, “~개의 규칙을 충족하면 강도가 ~이다.”라는 느낌을 주도록(코드 가독성 개선) 코드를 리팩토링했다.
public class PasswordStrengthMeter {
public PasswordStrength meter(String s) {
if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
int metCounts = getMetCriteriaCounts(s);
if(metCounts == 0) return PasswordStrength.WEAK;
if(metCounts == 1) return PasswordStrength.NORMAL;
return PasswordStrength.STRONG;
}
private int getMetCriteriaCounts(String s) {
int metCounts = 0;
if (s.length() >= 8) metCounts++;
if (meetsContainingNumberCriteria(s)) metCounts++;
return metCounts;
}
}
테스트에서 메인으로 코드 이동
마지막으로, src/test/java 소스 폴더에 위치한 PasswordStrength.java 파일과 PasswordStrengthMeter.java 파일을 배포 대상인 src/main/java로 이동해야 비로서 구현이 끝난다.
테스트가 개발을 주도
가장 먼저 통과해야 할 테스트를 작성했다. 이 과정에서 구현을 생각하지 않았고, 테스트를 추가한 뒤 지금까지 작성한 테스트들을 통과시킬 만큼 기능을 구현했다. 테스트 코드가 추가되면서 검증하는 범위가 넓어질수록 구현도 점점 완성되어간다.
-> 이렇게 테스트가 개발을 주도해 나간다.
참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://tech.inflab.com/20230404-test-code/
https://jayhooney.github.io/tdd/TDD-part1/
-
String 클래스
String은 문자열을 나타내는 클래스이다. Java에서 모든 문자열 리터럴(값)은 String 클래스의 인스턴스로 구현된다. (feat. Oracle)
String 객체의 생성과 메모리 저장 방식
String(참고 : 참조형)은 Stack 영역에 실제 값이 저장된 공간의 주소를 저장하고, Heap 영역에 실제 값을 저장한다.
Java에서 String 객체를 생성하는 방법은 2가지이다.
문자열 리터럴(값) : 컴파일 타임에 String Constant Pool에 객체 생성, 이미 존재한다면 해당 객체 재사용
new 키워드 : 일반적인 객체 생성처럼 런타임에 Heap 영역에 객체 생성
ex)
String 멤버함수
// equals() : 문자열 내용 비교 -> boolean
String s1 = "java";
String s2 = "java";
String s3 = new String("java");
System.out.println(s1 == s2); // 출력 : true -> 주소 비교
System.out.println(s1.equals(s2)); // 출력 : true -> 문자열 내용 비교
System.out.println(s1 == s3); // 출력 : false
System.out.println(s1.equals(s3)); // 출력 : true
// length() : 문자열 길이 -> int
String s1 = "";
String s2 = " ";
String s3 = "1234";
String s4 = " 12 34 ";
System.out.println(s1.length()); // 출력 : 0
System.out.println(s2.length()); // 출력 : 1
System.out.println(s3.length()); // 출력 : 4
System.out.println(s4.length()); // 출력 : 7
// charAt() : 특정 인덱스의 문자 -> char
String s1 = "1234";
System.out.println(s1.charAt(2)); // 출력 : 3
// substring() : 특정 인덱스 범위의 문자열 -> String
String s1 = "123456";
System.out.println(s1.substring(1)); // 출력 : 23456 -> begin 부터 마지막 까지
System.out.println(s1.substring(0, 3)); // 출력 : 123 -> begin 부터 end - 1 까지
System.out.println(s1.substring(0, 1)); // 출력 : 1
// contains() : 특정 문자열의 존재 여부 -> boolean
String s1 = "ab cdef";
System.out.println(s1.contains("cd")); // 출력 : true
// indexOf() / lastIndexOf() : 특정 문자열의 시작/마지막 인덱스 -> int
String s1 = "aa bb aa bb";
System.out.println(s1.indexOf("b")); // 출력 : 3
System.out.println(s1.lastIndexOf("b")); // 출력: 10
// startsWith() / endsWith() : 문자열이 특정 접두사 또는 접미사로 시작/끝나는지 확인 > boolean
String s1 = "image_file.png";
System.out.println(s1.startsWith("image")); // 출력 : true
System.out.println(s1.endsWith(".png")); // 출력 : true
// split() : 특정 문자열을 기준으로 문자열을 분리하여 배열로 반환 -> String[]
String s1 = "java python";
String[] languages1 = s1.split(" ");
String[] languages2 = s1.split("");
String[] languages3 = s1.split("a");
System.out.println(Arrays.toString(languages1)); // 출력 : [java,python]
System.out.println(Arrays.toString(languages2)); // 출력 : [j,a,v,a, ,p,y,t,h,o,n]
System.out.println(Arrays.toString(languages3)); // 출력 :[j,v, python] -> python 앞에 공백
// replace() : 문자열 내의 특정 문자열을 다른 문자열로 대체 -> String
String s1 = "aaa bbb aaa";
System.out.println(s1.replace("aa", "bb")); // 출력 : bba bbb bba
System.out.println(s1.replace(" ", "")); // 출력 : aaabbbaaa
System.out.println(s1); // 출력 : aaa bbb aaa
String 참고 사항
String은 불변 객체이므로, 값이 변경되면 새로운 객체가 생성된다. 따라서, 잦은 연산은 성능 저하(메모리 낭비, 속도 저하 등)가 발생할 수 있다. 대안으로는 가변 객체인 StringBuilder와 StringBuffer가 있다.
예시 1 : 객체의 주소
String a = "안녕";
System.out.println(a); // 출력 : 안녕
System.out.println(a.hashCode()); // 출력 : 1611021
a += "하세요";
System.out.println(a); // 출력 : 안녕하세요
System.out.println(a.hashCode()); // 출력 : 803356551
예시 2 : 연산 속도 비교 : String vs StringBuilder
long startTime_pre = System.nanoTime();
// 1. String 연산
String s = "";
for (int i = 0; i < 100_000; i++) {
// for문의 코드 실행할 때마다 새로운 객체 생성
s += i; // s = s + String.valueOf(i);
}
long endTime_pre = System.nanoTime();
System.out.println("String 실행 시간: " + (endTime_pre - startTime_pre) + " ns");
// ---------------> String 실행 시간: 3524436890 ns ( = 약 3.524초)
long startTime_ref = System.nanoTime();
// 2. StringBuilder 연산
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100_000; i++) {
// 같은 객체를 수정
sb.append(i); // sb.append(String.valueOf(i));
}
String s_sb = sb.toString();
long endTime_ref = System.nanoTime();
System.out.println("StringBuilder 실행 시간: " + (endTime_ref - startTime_ref) + " ns");
// ---------------> StringBuilder 실행 시간: 3767563 ns ( = 약 0.0038초)
// 실행시간 차이가 크다.
// 즉, 잦은 연산이 필요할 때 CPU와 메모리를 낭비하고 싶지 않다면
// StringBuilder나 StringBuffer를 사용해야 할 것이다.
Java 컴파일러는 자동으로 (String s = a + b + c)와 같은 표현식을 StringBuilder를 사용하여 최적화한다.
String s = "Hello" + " " + "World!";
// 위 코드는 컴파일러가 아래의 코드로 자동 변환하여 최적화한다.
String s = new StringBuilder()
.append("Hello")
.append(" ")
.append("World!")
.toString();
참고 자료
https://docs.oracle.com/javase/8/docs/api/
https://stackoverflow.com/questions/16783971/string-constant-pool-vs-string-pool
https://docs.oracle.com/javase/specs/jls/se7/html/jls-3.html#jls-3.10.5
https://www.youtube.com/watch?v=gp6NY01XFoE
https://stackoverflow.com/questions/47605/string-concatenation-concat-vs-operator
https://stackoverflow.com/questions/5234147/why-stringbuilder-when-there-is-string
-
기본 래퍼 클래스(Primitive Wrapper Class)
기본 래퍼 클래스(Primitive Wrapper Class)는 8개의 기본형을 캡슐화(객체로 변환)하여, 이를 다른 클래스나 메서드에서 객체로 사용할 수 있도록 해주는 클래스이다 (feat. Wikipedia)
기본 래퍼 클래스의 메모리 저장 방식
기본 래퍼 클래스(참고 : 참조형)는 Stack 영역에 실제 값이 저장된 공간의 주소를 저장하고, Heap 영역에 실제 값을 저장한다.
ex)
기본 래퍼 클래스 종류
기본형
기본 래퍼 클래스
byte
Byte
char
Character
int
Integer
float
Float
double
Double
boolean
Boolean
long
Long
short
Short
오토 박싱(Auto Boxing), 오토 언박싱(Auto Unboxing)
박싱 : 기본형을 기본 래퍼 클래스의 객체(인스턴스)로 변환하는 것
언박싱 : 기본 래퍼 클래스의 객체(인스턴스)에 저장된 실제 값을 기본형으로 변환하는 것
오토 박싱과 오토 언박싱은 자바 컴파일러가 상황에 따라 박싱과 언박싱을 자동으로 처리해주는 것을 의미한다. (JDK 1.5 버전부터 도입)
Integer i_ref = 1000; // 오토 박싱
// Integer i_ref = Integer.valueOf(1000); // 박싱
int i_pre = i_ref; // 오토 언박싱
// int i_pre = i_ref.intValue(); // 언박싱
int sum_prem = i_pre + i_ref; // 오토 언박싱
// int sum_prem = i_pre + i_ref.intValue(); // 언박싱
Integer sum_ref = i_pre + i_ref; // 오토 언박싱과 오토 박싱
// Integer sum_ref = Integer.valueOf(i_pre + i_ref.intValue()); // 언박싱과 박싱
기본 래퍼 클래스(ex) Integer) 참고 사항
Java 9 버전부터는 기본 래퍼 클래스의 생성자 사용이 deprecated 처리되었다.
Integer i1 = 777;
Integer i2 = Integer.valueOf(777);
Integer i3 = Integer.valueOf("777"); // 박싱
Integer i4 = "777"; // 컴파일 오류
Integer i5 = new Integer(777); // 컴파일 오류
Integer i6 = new Integer("777"); // 컴파일 오류
자바 컴파일러는 오토 박싱 시 생성자 대신 Integer.valueOf()를 사용하며, 해당 메서드는 캐싱된 Integer 객체를 반환한다.
JVM은 -128에서 127까지의 int 값에 대한 Integer 객체를 캐싱하고, 이러한 객체들은 Heap 영역의 내의 Constant Pool에 생성된다. 메모리 절약 및 속도 향상의 이점이 있다.
Integer i1 = 127;
Integer i2 = 127;
System.out.println(i1 == i2); // 출력 : true -> i1과 i2는 같은 객체를 참조 -> 주소 비교
System.out.println(i1.equals(i2)); // 출력 : true -> Integer의 값 비교
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4); // 출력 : false -> i3와 i4는 다른 객체를 참조 -> 주소 비교
System.out.println(i3.equals(i4)); // 출력 : true -> Integer의 값 비교
// Integer 클래스의 valueOf() 메서드
public final class Integer extends Number implements Comparable<Integer>, Constable, ConstantDesc {
...
@IntrinsicCandidate
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
...
}
기본 생성 클래스는 불변 객체이므로, 값이 변경되면 새로운 객체가 생성된다. 따라서, 잦은 연산은 성능 저하(메모리 낭비, 속도 저하 등)가 발생할 수 있다.
예시 1 : 객체의 주소
Integer i = 1000;
System.out.println(System.identityHashCode(i)); // 출력 : 1915318863
i += 500;
System.out.println(System.identityHashCode(i)); // 출력 : 1283928880
예시 2 : 연산 속도 비교 : 기본형 vs 기본 래퍼 클래스
long startTime_pre = System.nanoTime();
// 1. 기본형 연산
int sum_pre = 0;
for (int i = 0; i < 10_000; i++) {
sum_pre += i;
}
long endTime_pre = System.nanoTime();
System.out.println("기본형 실행 시간: " + (endTime_pre - startTime_pre) + " ns");
// ---------------> 기본형 실행 시간: 101979 ns
long startTime_ref = System.nanoTime();
// 2. 기본 래퍼 클래스 연산
Integer sum_ref = 0; // Integer.valueOf(0);
for (int i = 0; i < 10_000; i++) {
// for문의 코드 실행할 때마다 새로운 객체 생성
sum_ref += i; // sum_ref = Integer.valueOf(sum_ref.intValue() + i);
}
long endTime_ref = System.nanoTime();
System.out.println("기본 래퍼 클래스 실행 시간: " + (endTime_ref - startTime_ref) + " ns");
// ---------------> 기본 래퍼 클래스 실행 시간: 1822995 ns
// 실행시간 차이가 크다.
// 즉, 잦은 연산이 필요할 때 CPU와 메모리를 낭비하고 싶지 않다면
// 기본 타입인 int을 사용하거나
// 객체를 재사용할 수 있는 새로운 클래스를 정의해야 할 것이다.
참고 자료
https://stackoverflow.com/questions/13098143/why-does-the-behavior-of-the-integer-constant-pool-change-at-127
https://stackoverflow.com/questions/3689745/what-exactly-does-comparing-integers-with-do
https://stackoverflow.com/questions/3131136/integers-caching-in-java
https://inpa.tistory.com/entry/JAVA-%E2%98%95-wrapper-class-Boxing-UnBoxing
https://jaehoney.tistory.com/101
-
참조형(Reference Data Type)
데이터 타입 또는 자료형은 컴퓨터 과학과 프로그래밍 언어에서 실수치, 정수, 불린 자료형 따위의 여러 종류의 데이터를 식별하는 분류이다. (feat. 위키백과)
참조형의 메모리 저장 방식
참조형은 Stack 영역에 실제 값이 저장된 공간의 주소를 저장하고, Heap 영역에 실제 값을 저장한다.
ex)
참조형 종류 (기본형 제외 나머지)
대표적인 참조형을 살펴보겠다.
타입
설명
배열
동일한 타입의 데이터를 고정된 크기의 연속된 메모리 공간에 저장하는 자료구조
ArrayList (Collection)
크기가 동적으로 변하는 배열 기반의 자료구조
String
문자열을 다루는 클래스
Wrapper 클래스
기본형을 객체로 감싸서 다룰 수 있게 하는 클래스 (ex) Integer, Long)
사용자 정의 클래스
개발자가 정의한 데이터 구조를 표현하는 클래스
참조형 선언 및 초기화, 출력, 비교
배열
// 선언 및 초기화
int[] arr = {1, 2};
// 또는
// int[] arr = new int[]{1, 2};
// 또는
// int[] arr = new int[2]; // arr[0], arr[1]의 초기값은 0
// arr[0] = 1;
// arr[1] = 2;
int[] arr_same = {1, 2};
// 출력
System.out.println(arr); // 출력 : [I@7229724f -> 주소
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i] + " "); // 출력 : 1 2
}
System.out.println(Arrays.toString(arr)); // 출력 : [1, 2]
// 비교
System.out.println(arr == arr_same); // 출력 : false -> 주소 비교
System.out.println(Arrays.equals(arr, arr_same)); // 출력 : true -> 배열의 내용 비교
ArrayList
// 선언 및 초기화
List<Integer> list = new ArrayList<>(Arrays.asList(1,2));
// 또는
// List<Integer> list = new ArrayList<>();
// list.add(1);
// list.add(2);
List<Integer> list_same = new ArrayList<>(Arrays.asList(1,2));
// 출력
for (int i = 0; i < list.size(); i++) {
System.out.print(list.get(i) + " "); // 출력 : 1 2
}
System.out.println(list); // 출력 : [1, 2] -> ArrayList 클래스에서 toString() 메서드가 Override돼있음
// 비교
System.out.println(list == list_same); // 출력 : false -> 주소 비교
System.out.println(list.equals(list_same)); // 출력 : true -> List의 내용 비교
String
// 선언 및 초기화
String str_ltr_1 = "hello"; // 문자열 리터럴을 사용하여 생성
String str_ltr_2 = "hello";
String str_new_1 = new String("hello"); // new 키워드를 사용하여 생성
String str_new_2 = new String("hello");
// 출력
System.out.println(str_ltr_1); // 출력 : hello
System.out.println(str_new_1); // 출력 : hello
// 비교
System.out.println(str_ltr_1 == str_ltr_2); // 출력 : true -> 리터럴로 생성한
// 문자열은 같은 객체(String pool) -> 주소 비교
System.out.println(str_ltr_1 == str_new_1); // 출력 : false -> 다른 객체
System.out.println(str_new_1 == str_new_2); // 출력 : false -> new로 생성한
// 문자열은 다른 객체
System.out.println(str_ltr_1.equals(str_ltr_2)); // 출력 : true -> 문자열의 내용 비교
System.out.println(str_ltr_1.equals(str_new_1)); // 출력 : true
System.out.println(str_new_1.equals(str_new_2)); // 출력 : true
사용자 정의 클래스
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Person person = (Person) obj;
return age == person.age && name.equals(person.name);
}
}
public class Main {
public static void main(String[] args) {
// 선언 및 초기화
Person p1 = new Person("백엔드", 25);
Person p2 = new Person("백엔드", 25);
// 출력
System.out.println(p1); // 출력 : Person@4c873330 -> 주소
// 비교
System.out.println(p1 == p2); // 출력 : false -> 주소 비교
System.out.println(p1.equals(p2)); // 출력 : true -> 객체의 내용 비교
// Person 클래스에서 equals() 메서드를
// 개발자가 직접 Override하여 작성했음
}
}
-
-
(Spring) - 스프링이 제공하는 트랜잭션 AOP
서비스 계층
애플리케이션 구조
여러가지 애플리케이션 구조가 있지만, 가장 단순하면서 많이 사용하는 방법은 역할에 따라 3가지 계층으로 나누는 것이다.
프레젠테이션 계층
UI와 관련된 처리, 웹 요청과 응답, 사용자 요청 검증을 담당
주 사용 기술: 서블릿과 HTTP 같은 웹 기술, 스프링 MVC
서비스 계층
비즈니스 로직을 담당
주 사용 기술: 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
데이터 접근 계층
실제 데이터베이스에 접근을 담당
주 사용 기술: JDBC, JPA, File, Redis, Mongo
서비스 계층 (이전 글 참고)
서비스 계층은 가급적 특정 구현 기술에 직접 의존해서는 안된다.
서비스 계층에서 트랜잭션을 사용하기 위해 JDBC 기술에 의존하던 문제
-> 트랜잭션 매니저를 통해서 해결했다.
서비스 계층은 가급적 핵심 비즈니스 로직만 구현해야 한다.
아직 서비스 계층에서 비즈니스 로직 뿐만 아니라 트랜잭션을 처리하는 기술 로직이 함께 포함되어 있는 문제가 남아있다.
-> 스프링 AOP를 통해 프록시를 도입하면 해결할 수 있다.
트랜잭션 AOP
트랜잭션 프록시
프록시를 사용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.
트랜잭션 프록시 코드 예시
public class TransactionProxy {
private MemberService target;
public void logic() {
TransactionStatus status = transactionManager.getTransaction(..);
try {
target.logic(); //실제 대상 호출
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
}
}
트랜잭션 프록시 적용 후 서비스 코드 예시
public class Service {
public void logic() {
bizLogic(fromId, toId, money); // 비즈니스 로직
}
}
스프링이 제공하는 AOP 기능을 사용하면 프록시를 편리하게 적용할 수 있다.
스프링 AOP를 사용하려면 어드바이저, 포인트컷, 어드바이스가 필요하다.
트랜잭션 AOP
스프링 AOP를 직접 사용해서 트랜잭션을 처리해도 되지만, 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다.
스프링은 트랜잭션 AOP 처리를 위해 다음 클래스를 제공한다.
어드바이저: BeanFactoryTransactionAttributeSourceAdvisor
포인트컷: TransactionAttributeSourcePointcut
어드바이스: TransactionInterceptor
트랜잭션 처리가 필요한 곳에 @Transactional 어노테이션만 붙여주면, 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.
트랜잭션 AOP 예시
이전 글 참고
MemberRepositoryV4 클래스
@Repository
public class MemberRepositoryV4 implements MemberRepository {
// MemberRepositoryV2와 동일
}
MemberServiceV4 클래스
@Service
public class MemberServiceV4 {
private final MemberRepository memberRepository;
public MemberServiceV4(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money); // 비즈니스 로직
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
}
서비스 계층에서 아직 JDBC 기술인 SQLException에 의존하지만, 이 부분은 일단 넘어가자.
그래도 간단하게 말하자면, SQLException은 체크 예외인데, 리포지토리 계층에서 체크 예외인 SQLException을 언체크(런타임) 예외로 바꿔서 던지면 된다.
참고 자료
“김영한 스프링 DB 1편”
https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html
-
(Spring) - 스프링이 제공하는 트랜잭션 기술
이전 글의 트랜잭션 예시에서 여러 문제가 있었는데, 스프링은 이 문제들을 해결할 수 있는 다양한 방법과 기술들을 제공한다. 지금부터 스프링을 사용해서 이러한 문제들을 하나씩 해결해보자.
트랜잭션 추상화와 리소스 동기화(공유)
문제 1 : 서비스 계층이 트랜잭션을 사용하기 위해 JDBC 기술에 의존하던 문제
-> 해결 : 트랜잭션 추상화
문제 2 : 리포지토리 계층에서 Connection을 인자로 받는 메소드와 받지 않는 메소드를 중복해서 만들어야 하던 문제
-> 해결 : 리소스 동기화(공유)
스프링은 트랜잭션 기능을 추상화하는 PlatformTransactionManager 인터페이스를 제공하며, 커넥션을 동기화(공유)해주는 TransactionSynchronizationManager 클래스를 제공한다.
해당 글에서는 PlatformTransactionManager 인터페이스와 구현체를 포함해서 “트랜잭션 매니저”, TransactionSynchronizationManager 클래스를 “트랜잭션 동기화 매니저”라고 이야기하겠다.
트랜잭션 매니저는 내부에서 트랜잭션 동기화 매니저를 사용한다.
PlatformTransactionManager
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
// 트랜잭션을 시작
TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;
// 트랜잭션을 커밋
void commit(TransactionStatus status) throws TransactionException;
// 트랜잭션을 롤백
void rollback(TransactionStatus status) throws TransactionException;
}
서비스 계층에서는 특정 트랜잭션 기술에 직접 의존하는 것이 아니라, PlatformTransactionManager 인터페이스에 의존한다.
데이터 접근 기술에 따른 PlatformTransactionManager 인터페이스의 구현체는 대부분 만들어져 있다.
TransactionSynchronizationManager
package org.springframework.transaction.support;
public abstract class TransactionSynchronizationManager {
...
}
트랜잭션 동기화 매니저는 쓰레드 로컬(ThreadLocal)을 사용해서 커넥션을 동기화(공유)해준다. 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 상황에 안전하게 커넥션을 동기화할 수 있다.
쓰레드 로컬을 사용하면 각각의 쓰레드마다 별도의 저장소가 부여된다. 따라서 해당 쓰레드만 해당 데이터에 접근할 수 있다.
트랜잭션 매니저와 트랜잭션 동기화 매니저의 동작 방식 - DataSourceTransactionManager 예시
1.서비스 계층에서 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다.
2.트랜잭션 매니저는 내부에서 데이터소스를 사용해서 커넥션을 생성한다.
3.트랜잭션 매니저는 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
4.트랜잭션 매니저는 커넥션을 트랜잭션 동기화 매니저에 보관한다.
5.트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다.
6.서비스는 비즈니스 로직을 실행하면서 리포지토리 계층의 메서드들을 호출한다.
7.리포지토리 계층에서 DataSourceUtils.getConnection()을 호출해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. (트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환)
8.획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.
9.비즈니스 로직이 끝난 후 transactionManager.commit()나 transactionManager.rollback()를 호출하여 트랜잭션을 종료한다.
10.트랜잭션을 종료하려면 동기화된 커넥션이 필요한데, 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
11.트랜잭션 매니저는 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
12.트랜잭션 매니저는 전체 리소스를 정리한다. (트랜잭션 동기화 매니저 정리, 커넥션을 자동 커밋 모드로 변경한 후 커넥션 풀에 반환 등)
트랜잭션 매니저 예시
이전 글 참고
MemberRepositoryV2 클래스 - JDBC 사용
@Repository
public class MemberRepositoryV2 implements MemberRepository {
private final DataSource dataSource;
public MemberRepositoryV2(DataSource dataSource) {
this.dataSource = dataSource;
}
private Connection getConnection() throws SQLException {
//트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
//트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다.
//트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환한다.
Connection con = DataSourceUtils.getConnection(dataSource);
return con;
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
//트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
//트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다.
//트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.
DataSourceUtils.releaseConnection(con, dataSource);
}
//save(Member member)...
//findById(String memberId)...
//update(String memberId, int money)....
//delete(String memberId)....
}
MemberServiceV2 클래스
@Service
public class MemberServiceV2 {
private final PlatformTransactionManager transactionManager;
private final MemberRepository memberRepository;
public MemberServiceV2(PlatformTransactionManager transactionManager, MemberRepository memberRepository) {
this.transactionManager = transactionManager;
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
//트랜잭션 시작
//TransactionStatus status를 반환
// 현재 트랜잭션의 상태 정보가 포함되어 있다. 이후 트랜잭션을 커밋, 롤백할 때 필요하다.
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
bizLogic(fromId, toId, money); //비즈니스 로직
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
}
트랜잭션 템플릿
문제 3 : 서비스 계층에서 트랜잭션 적용 코드(트랜잭션 시작, 비즈니스 로직 실행, 성공 시 커밋, 실패 시 롤백)에 반복이 있던 문제
-> 해결 : 템플릿 콜백 패턴
스프링은 템플릿 콜백 패턴을 지원하는 TransactionTemplate 클래스를 제공한다.
템플릿 콜백 패턴이란 코드의 반복적인 구조와 변하지 않는 부분을 템플릿으로 만들고, 변화가 필요한 부분만을 콜백으로 분리하여 제공하는 설계 패턴이다.
TransactionTemplate
package org.springframework.transaction.support;
public class TransactionTemplate {
private PlatformTransactionManager transactionManager;
// 응답 값이 있을 때 사용
public <T> T execute(TransactionCallback<T> action) {..}
// 응답 값이 없을 때 사용
void executeWithoutResult(Consumer<TransactionStatus> action) {..}
...
}
트랜잭션 템플릿 예시
MemberRepositoryV3 클래스
@Repository
public class MemberRepositoryV3 implements MemberRepository {
// MemberRepositoryV2와 동일
}
MemberServiceV3 클래스
@Service
public class MemberServiceV3 {
private final TransactionTemplate txTemplate;
private final MemberRepository memberRepository;
// TransactionTemplate을 사용하려면 transactionManager가 필요하다.
// 생성자에서 transactionManager를 주입 받으면서 TransactionTemplate을 생성
public MemberServiceV3(PlatformTransactionManager transactionManager, MemberRepository memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager);
this.memberRepository = memberRepository;
}
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
txTemplate.executeWithoutResult((status) -> {
try {
bizLogic(fromId, toId, money); //비즈니스 로직
} catch (SQLException e) {
throw new IllegalStateException(e);
}
});
}
private void bizLogic(String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
memberRepository.update(toId, toMember.getMoney() + money);
}
}
accountTransfer 메소드에서 예외를 처리하기 위해 try~catch가 들어갔는데, bizLogic() 메소드를 호출하면 SQLException 체크 예외를 넘겨준다. 해당 람다에서 체크 예외를 밖으로 던질 수 없기 때문에 언체크(런타임) 예외로 바꾸어 던지도록 예외를 전환했다.
참고 자료
“김영한 스프링 DB 1편”
-
트랜잭션
트랜잭션과 세션
트랜잭션은 더 이상 쪼갤 수 없는 업무 처리의 최소 단위를 의미한다.
트랜잭션은 ACID를 보장해야 한다. ACID란 트랜잭션이 안전하게 수행되기 위한 4가지 필수적인 성질을 말한다.
Atomicity(원자성) : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하거나 모두 실패해야 한다.
Consistenecy(일관성) : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어, 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
Isolation(격리성) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어, 동시에 같은 데이터를 수정하지 못하도록 해야 한다.
격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준(Isolation Level)을 선택할 수 있다.
Durability(지속성) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.
트랜잭션의 모든 작업이 성공해서 데이터베이스에 정상 반영하는 것을 커밋(Commit)이라 하고, 작업 중 하나라도 실패해서 트랜잭션 이전으로 되돌리는 것을 롤백(Rollback)이라 한다.
트랜잭션 격리 수준(Isolation Level)
격리성(Isolation)을 완벽히 보장하려면 트랜잭션을 거의 순서대로 실행해야 하는데, 이렇게 하면 동시 처리 성능이 매우 나빠진다. 이런 문제로 인해 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의했다.
READ UNCOMMITED(커밋되지 않은 읽기)
READ COMMITTED(커밋된 읽기)
REPEATABLE READ(반복 가능한 읽기)
SERIALIZABLE(직렬화 가능)
-> 일반적으로 READ COMMITTED(커밋된 읽기) 트랜잭션 격리 수준을 많이 사용한다.
데이터베이스 연결 구조와 세션
사용자는 애플리케이션 서버, DB 접근 툴과 같은 클라이언트를 사용해서 데이터베이스 서버에 접근할 수 있다.
클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺는데, 이때 데이터베이스 서버는 내부에 세션이라는 것을 만든다. 그리고 앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 된다.
개발자가 클라이언트를 통해 SQL을 전달하면 현재 커넥션에 연결된 세션이 SQL을 실행한다.
세션은 트랜잭션을 시작하고, 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그리고 이후에 새로운 트랜잭션을 다시 시작할 수 있다.
사용자가 커넥션을 닫거나, DB 관리자가 세션을 강제로 종료하면 세션은 종료된다.
트랜잭션 모드 - 자동 커밋과 수동 커밋
DBMS가 트랜잭션을 처리하는 모드는 자동 커밋과 수동 커밋이 있다.
트랜잭션 모드는 연결 단위(세션)별로 설정하며, 연결 중에도 언제든지 변경할 수 있다.
자동 커밋으로 설정하면 각각의 쿼리 실행 직후에 자동으로 commit을 호출한다.
set autocommit true; // 자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000);
// 자동 커밋
insert into member(member_id, money) values ('data2',10000);
// 자동 커밋
자동 커밋에서는 쿼리를 하나하나 실행할 때마다 자동으로 커밋이 되어버리기 때문에 우리가 원하는 트랜잭션 기능을 제대로 사용할 수 없다. 따라서, 트랜잭션 기능을 제대로 수행하려면 수동 커밋을 사용해야 한다.
수동 커밋으로 설정하면 이후에 꼭 commit이나 rollback을 호출해야 한다.
set autocommit false; // 수동 커밋 모드 설정
insert into member(member_id, money) values ('data3',10000);
insert into member(member_id, money) values ('data4',10000);
commit; // 수동 커밋
보통 자동 커밋 모드가 기본으로 설정된 경우가 많기 때문에, 수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 표현할 수 있다.
트랜잭션 간단한 예시와 문제
이전 글 참고
MemberRepositoryV1 클래스
@Repository
public class MemberRepositoryV1 implements MemberRepository {
// 기존 메소드
// ...
// findById(String memberId)...
// update(String memberId, int money)...
// ...
// 커넥션 유지가 필요한 findById, update 메소드 추가
// 기존 findById, update 메소드와 같은 내용이지만, 2가지 부분이 다르다.
// 1. con = getConnection() 삭제
// 2. dbcUtils.closeConnection(con) 삭제
public Member findById(Connection con, String memberId) throws SQLException {
...
}
public void update(Connection con, String memberId, int money) throws SQLException {
...
}
}
MemberServiceV0 클래스
@Service
public class MemberServiceV1 {
private static final Logger log = LoggerFactory.getLogger(MemberRepositoryJdbc.class);
private final DataSource dataSource;
private final MemberRepository memberRepository;
public MemberServiceV1(DataSource dataSource, MemberRepository memberRepository) {
this.dataSource = dataSource;
this.memberRepository = memberRepository;
}
// 간단한 계좌이체
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
Connection con = dataSource.getConnection();
try {
con.setAutoCommit(false); //트랜잭션 시작
bizLogic(con, fromId, toId, money); //비즈니스 로직
con.commit(); //성공시 커밋
} catch (Exception e) {
con.rollback(); //실패시 롤백
throw new IllegalStateException(e);
} finally {
if (con != null) {
try {
con.setAutoCommit(true); //커넥션 풀 고려
con.close(); //커넥션 풀을 사용하므로 커넥션이 종료되는 것이 아니라 풀에 반납됨
} catch (Exception e) {
log.info("error", e);
}
}
}
}
private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException {
Member fromMember = memberRepository.findById(con, fromId);
Member toMember = memberRepository.findById(con, toId);
memberRepository.update(con, fromId, fromMember.getMoney() - money);
memberRepository.update(con, toId, toMember.getMoney() + money);
}
}
문제
서비스 계층은 트랜잭션을 사용하기 위해서 JDBC 기술에 의존하고 있다. 향후 JPA 같은 다른 기술로 바꾸어 사용하게 되면 서비스 코드도 모두 함께 변경해야 한다. (데이터 접근 기술마다 트랜잭션을 사용하는 코드가 다름)
Repository에서 Connection을 인자로 받는 새로운 findById, update 메소드를 작성한 것처럼, 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능으로 분리해야 한다.
트랜잭션 적용 코드(try, catch, finally 부분)에 반복이 많다.
스프링은 위의 문제들을 해결할 수 있는 다양한 방법과 기술들을 제공한다.
-> 스프링이 제공하는 트랜잭션 기술
참고 자료
“김영한 스프링 DB 1편”
http://wiki1.kr/index.php/트랜잭션
http://soen.kr/book/sql/book/19-2.htm
-
커넥션 풀과 DataSource
데이터베이스 커넥션 생성
이전 글에서는, 커넥션(Connection 객체)을 획득하기 위해 DriverManager를 사용했다.
DriverManager는 JDBC 드라이버를 통해 커넥션을 “획득”하는데, 아래의 과정은 JDBC 드라이버가 커넥션을 “생성”하는 과정에 대한 설명이다.
커넥션 생성 과정
애플리케이션 로직은 JDBC 드라이버를 통해 커넥션을 조회한다.
JDBC 드라이버는 데이터베이스 서버와 TCP/IP 커넥션을 연결한다. 이 과정에서 3 way handshake 같은 TCP/IP 연결을 위한 네트워크 동작이 발생한다.
TCP/IP 커넥션이 연결되면, JDBC 드라이버는 ID, PW, 기타 부가정보를 데이터베이스 서버에 전달한다.
데이터베이스 서버는 ID, PW를 통해 내부 인증을 완료하고, 내부에 세션을 생성한다.
데이터베이스 서버는 커넥션 생성이 완료되었다는 응답을 보낸다.
JDBC 드라이버는 커넥션(Connection) 객체를 생성해서 클라이언트(애플리케이션 서버)에 반환한다.
매번 커넥션을 새로 생성할 때 발생하는 문제
위와 같이, 커넥션을 새로 생성하는 과정은 복잡하기 때문에 애플리케이션 로직에서 매번 커넥션을 새로 생성한다면 다음과 같은 문제들이 발생한다.
데이터베이스 서버는 물론이고 애플리케이션 서버에서도 커넥션을 새로 생성하기 위한 리소스를 매번 사용해야 한다.
고객이 애플리케이션을 사용할 때, SQL을 실행하는 시간 뿐만 아니라 커넥션을 새로 만드는 시간이 추가되기 때문에 결과적으로 응답 속도에 영향을 준다.
이런 문제들을 한번에 해결하는 아이디어가 바로 커넥션을 미리 생성해두고 사용하는 커넥션 풀(커넥션을 관리하는 풀) 방법이다.
커넥션 풀
커넥션 풀은 미리 일정 수의 데이터베이스 커넥션을 생성하여 풀로 관리하고, 필요할 때마다 풀에서 기존의 연결을 재사용하도록 하는 기법이다.
이전처럼 DriverManager를 통해 새로운 커넥션을 획득하는 방식이 아니라 이미 생성되어 있는 커넥션을 획득하는 방식이다.
따라서, 커넥션 풀 방식을 사용하면 애플리케이션 로직을 수행할 때 커넥션을 새로 생성하는 복잡한 과정을 수행하지 않는다.
커넥션 풀은 개념적으로 단순해서 직접 구현할 수도 있지만, 사용도 편리하고 성능도 뛰어난 오픈소스 커넥션 풀이 많기 때문에 오픈소스를 사용하는 것이 좋다.
대표적인 커넥션 풀 오픈소스는 commons-dbcp2 , tomcat-jdbc pool , HikariCP 등이 있으며, Spring Boot는 기본적으로 HikariCP를 커넥션 풀로 사용한다.
커넥션 풀은 별도의 쓰레드를 사용해서 커넥션을 채운다.
커넥션 풀에 커넥션을 채우는 것은 상대적으로 오래 걸리는 일이기 때문에, 애플리케이션을 실행할 때 커넥션 풀을 채울 때 까지 마냥 대기하고 있다면 애플리케이션 실행 시간이 늦어진다. 따라서 별도의 쓰레드를 사용해서 커넥션 풀을 채워야 애플리케이션 실행 시간에 영향을 주지 않는다.
커넥션 풀 초기화와 연결 상태
애플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관한다.
기본값은 보통 10개이며, 적절한 커넥션 풀 숫자는 서비스의 특징과 애플리케이션 서버 스펙, DB 서버 스펙에 따라 다르기 때문에 성능 테스트를 통해서 정해야 한다.
커넥션 풀은 서버당 최대 커넥션 수를 제한할 수 있다. 따라서 DB에 무한정 연결이 생성되는 것을 막아주어서 DB를 보호하는 효과도 있다.
커넥션 풀에 들어 있는 커넥션은 TCP/IP로 데이터베이스 서버와 커넥션이 연결되어 있는 상태이기 때문에, 언제든지 즉시 SQL을 데이터베이스 서버에 전달할 수 있다.
커넥션 풀 사용 과정
애플리케이션 로직은 커넥션 풀을 통해 커넥션을 조회한다.
커넥션 풀에 커넥션을 요청하면, 커넥션 풀은 자신이 가지고 있는 커넥션 중에 하나를 반환한다.
애플리케이션 로직은 커넥션 풀에서 받은 커넥션을 사용해서 SQL을 데이터베이스 서버에 전달하고 그 결과를 받아서 처리한다.
커넥션을 모두 사용하고 나면, 커넥션을 종료하는 것이 아니라 다음에 다시 사용할 수 있도록 해당 커넥션이 살아있는 상태로 커넥션 풀에 반환한다.
DataSource
커넥션을 획득하는 방법
지금까지 설명한 것처럼, 애플리케이션 로직에서 커넥션을 획득하는 방법은 대표적으로 2가지 방법이 있다. (실무에서는 기본으로 커넥션 풀 사용)
직접 DriverManager 사용 - 새로운 커넥션
커넥션 풀 사용 - 이미 생성되어 있는 커넥션
그러나, 애플리케이션 로직에서 DriverManager를 사용해서 커넥션을 획득하다가 HikariCP 같은 커넥션 풀을 사용하도록 변경할 때 문제가 발생한다.
의존관계가 DriverManager에서 HikariCP로 변경되기 때문에, 커넥션을 획득하는 애플리케이션 코드도 함께 변경해야 한다.
이런 문제를 해결하기 위해 자바에서는 커넥션을 획득하는 방법을 추상화하는 인터페이스인 DataSoucre를 제공한다.
DataSouce
public interface DataSource extends CommonDataSource, Wrapper {
Connection getConnection() throws SQLException;
...
}
애플리케이션 로직은 커넥션을 획득할 때 DataSource 인터페이스에만 의존하면 된다.
대부분의 커넥션 풀은 DataSource 인터페이스를 이미 구현해두었으며, 커넥션 풀 구현 기술을 변경하고 싶으면 해당 구현체로 갈아끼우기만 하면 된다.
DriverManager는 DataSource 인터페이스를 사용하지 않기 때문에, DriverManager를 사용하다가 DataSource 기반의 커넥션 풀을 사용하도록 변경하면 관련 코드를 다 고쳐야 한다.
이런 문제를 해결하기 위해 스프링은 DriverManager도 DataSource를 통해서 사용할 수 있도록 DriverManagerDataSource라는 DataSource를 구현한 클래스를 제공한다.
DataSource 사용 예시 - Spring
이전 글 참고
프로퍼티 파일 작성 (src/main/resources/application.properties)
jdbc.url=jdbc:mysql://(DB 서버 주소):3306/(DB명)?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
jdbc.username=(DB 사용자 이름)
jdbc.password=(DB 사용자 비밀번호)
스프링 설정 클래스 작성 (src/main/java/com/example/myproject/config/)
DispatcherConfig
@Configuration
@ComponentScan(basePackages = "com.example.myproject")
@EnableWebMvc
public class DispatcherConfig implements WebMvcConfigurer {
...
}
DataSourceConfig
@Configuration
@PropertySource("classpath:application.properties")
public class DataSourceConfig {
// Environment를 사용하여 프로퍼티 파일의 값을 로드
@Autowired
private Environment env;
@Bean
public DataSource dataSource() {
// 1. DriverManagerDataSource
// DriverManagerDataSource dataSource =
// new DriverManagerDataSource(jdbc.url, jdbc.username, jdbc.password);
// 2. 커넥션 풀의 DataSource
HikariDataSource dataSource = new HikariDataSource();
// 필수
dataSource.setJdbcUrl(env.getProperty("jdbc.url"));
dataSource.setUsername(env.getProperty("jdbc.username"));
dataSource.setPassword(env.getProperty("jdbc.password"));
// 선택
dataSource.setMaximumPoolSize(20); // 설정하지 않으면 기본값은 10
return dataSource;
}
}
MemberRepositoryJdbcDataSource 클래스 작성
@Repository
public class MemberRepositoryJdbcDataSource implements MemberRepository {
// 외부에서 DataSource를 주입 받아서 사용
private final DataSource dataSource;
public MemberRepositoryJdbcDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
private Connection getConnection() throws SQLException {
Connection con = dataSource.getConnection();
return con;
}
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
//save(Member member)...
//findById(String memberId)...
//update(String memberId, int money)....
//delete(String memberId)....
}
참고 자료
“김영한 스프링 DB 1편”
https://docs.oracle.com/javase/8/docs/api/javax/sql/DataSource.html
-
JDBC
애플리케이션 서버와 데이터베이스
커넥션 연결 : 주로 TCP/IP를 사용해서 커넥션을 연결한다.
SQL 전달 : 애플리케이션 서버는 DB가 이해할 수 있는 SQL을 연결된 커넥션을 통해 DB에 전달한다.
결과 응답 : DB는 전달된 SQL을 수행하고 그 결과를 응답한다. 애플리케이션 서버는 응답 결과를 활용한다.
문제
각각의 데이터베이스마다 커넥션 연결, SQL 전달, 결과를 응답 받는 방법이 모두 다르다. 즉, 데이터베이스 종류를 변경하면 애플리케이션 서버의 데이터베이스 사용 코드도 함께 변경해야 한다.
이런 문제를 해결하기 위해 JDBC라는 자바 표준이 등장한다.
JDBC
JDBC(Java Database Connectivity)는 데이터베이스와의 연결 및 상호작용을 위한 자바 API이다.
JDBC API는 자바 패키지 java.sql과 javax.sql에 여러 표준 인터페이스와 클래스를 정의하고 있다.
java.sql 패키지는 JDBC API의 핵심 부분을 구성하며, javax.sql 패키지는 JDBC API의 확장 부분을 구성한다.
각 DB 벤더는 자신의 DB에 맞도록 JDBC API에서 정의한 표준 인터페이스들을 구현해서 라이브러리로 제공하는데, 이것을 JDBC 드라이버라고 한다.
즉, JDBC 표준 인터페이스의 구현체는 JDBC 드라이버이다.
개발자는 JDBC 표준 인터페이스만 사용해서 개발하면 되고, 애플리케이션 로직은 JDBC 표준 인터페이스에만 의존한다.
데이터베이스를 다른 종류의 데이터베이스로 변경하고 싶다면 JDBC 드라이버만 변경하면 되므로, 애플리케이션 서버의 사용 코드를 그대로 유지할 수 있다.
주요 JDBC 표준 인터페이스와 클래스
Connection, Statement, ResultSet (인터페이스, java.sql)
데이터베이스와의 연결, 쿼리 실행, 쿼리 결과의 표준을 정의한다.
Driver (인터페이스, java.sql)
데이터베이스와 연결을 설정하는 방법의 표준을 정의한다.
DriverManager (클래스, java.sql)
JDBC 드라이버들을 관리하고, 등록된 드라이버 중에서 적합한 드라이버의 Connection 객체를 획득하는 기능을 제공한다.
DataSource (인터페이스, javax.sql)
Connection 객체를 획득하는 방법의 표준을 정의한다.
커넥션 풀과 DataSource
SQLException (클래스, java.sql)
JDBC에서 데이터베이스와의 상호작용 중에 발생할 수 있는 모든 예외를 처리하는 기본 예외 클래스이다.
Connection, Statement, ResultSet
Connection 주요 메소드
public interface Connection extends Wrapper, AutoCloseable {
Statement createStatement() throws SQLException; // SQL 쿼리를 실행할 수 있는 Statement 객체를 생성
PreparedStatement prepareStatement(String var1) throws SQLException; // 미리 컴파일된 SQL 쿼리를 실행할 수 있는 PreparedStatement 객체를 생성
void setAutoCommit(boolean var1) throws SQLException; // 자동 커밋 모드로 설정
void commit() throws SQLException; // 현재 트랜잭션을 커밋
void rollback() throws SQLException; // 현재 트랜잭션을 롤백
void close() throws SQLException; // Connection 종료
}
Statement 주요 메소드
public interface Statement extends Wrapper, AutoCloseable {
ResultSet executeQuery(String var1) throws SQLException; // SELECT 문을 실행하고, 결과를 ResultSet 객체로 반환
int executeUpdate(String var1) throws SQLException; // INSERT, UPDATE, DELETE 문을 실행하고, 영향을 받은 행의 수를 반환
boolean execute(String var1) throws SQLException; // SQL 문을 실행하고, 쿼리의 실행 결과를 나타내는 boolean 값을 반환
void close() throws SQLException; // Statement 종료
}
ResultSet 주요 메소드
// ResultSet은 테이블 형식이다.
public interface ResultSet extends Wrapper, AutoCloseable {
boolean next() throws SQLException; // 커서를 다음 행으로 이동시키며, 결과 집합에 더 이상 행이 없으면 false를 반환
String getString(String var1) throws SQLException; // 지정된 열의 문자열 값을 반환
int getInt(String var1) throws SQLException; // 지정된 열의 정수 값을 반환
double getDouble(String var1) throws SQLException; // 지정된 열의 실수 값을 반환
void close() throws SQLException; // ResultSet 종료
}
Driver, DriverManager
public interface Driver {
// 주어진 URL과 정보를 바탕으로 데이터베이스와 연결하고, 그 연결 객체(Connection 객체)를 반환
Connection connect(String var1, Properties var2) throws SQLException;
...
}
public class DriverManager {
...
// Driver 객체를 DriverManager에 등록
public static void registerDriver(Driver driver) throws SQLException {
registerDriver(driver, (DriverAction)null);
}
public static void registerDriver(Driver driver, DriverAction da) throws SQLException {
...
registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
}
// Driver 객체의 connect 메소드를 호출하여 Connection 객체를 획득
public static Connection getConnection(String url, String user, String password) throws SQLException {
...
return getConnection(url, info, Reflection.getCallerClass());
}
private static Connection getConnection(String url, Properties info, Class<?> caller) throws SQLException {
...
Iterator var5 = registeredDrivers.iterator();
while(true) {
while(var5.hasNext()) {
DriverInfo aDriver = (DriverInfo)var5.next();
...
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
return con;
}
...
}
...
}
Driver 클래스 로딩 시 Driver 객체가 DriverManager에 등록되는데, Driver 클래스를 로딩하는 방식은 아래와 같이 2가지 방식이 있다.
명시적 로딩 : Class.forName(“…”) 메소드를 사용하여 Driver 클래스를 명시적으로 로드하는 방식
자동 로딩 : Java 6 이후, JDBC 4.0부터는 JDBC 드라이버 JAR 파일의 “META-INF/services/java.sql.Driver” 파일이 포함되어 있으면 자동으로 Driver 클래스가 로드되는 방식
DriverManager는 JDBC 드라이버들의 Driver 객체를 관리하고, 등록된 Driver 객체들의 connect 메소드를 호출하여 Connection 객체가 얻어지면 해당 Connection 객체를 반환하는 기능을 제공한다.
Connection 객체를 얻기 위한 이런 복잡한 작업들을 DriverManager가 해주기 때문에, 개발자는 간편하게 DriverManager만 사용해서 Connection 객체를 얻으면 된다.
DriverManager와 JDBC 드라이버 - MySQL 예시
MySQL JDBC 드라이버 JAR 파일의 “com.mysql.cj.jdbc 패키지” 내에는 Driver, Connection, Statement, ResultSet 등 JDBC 표준 인터페이스를 구현하는 클래스들이 존재한다.
MySQL JDBC 드라이버의 Driver 클래스가 로드될 때, Driver 클래스의 정적 초기화 블록이 실행되어 Driver 객체가 DriverManager에 등록된다.
public class Driver extends NonRegisteringDriver implements java.sql.Driver
{
public Driver() throws SQLException { }
static {
try {
DriverManager.registerDriver(new Driver());
} catch (SQLException var1) {
throw new RuntimeException("Can't register driver!");
}
}
}
애플리케이션에서 DriverManager.getConnection(url, user, password) 메소드를 호출하면, 해당 메소드에서는 등록된 Driver 객체들의 connect 메소드를 호출하여 Connection 객체가 얻어지면 해당 Connection 객체를 반환한다.
MySQL JDBC 드라이버의 Driver 객체의 connect 메소드는 MySQL 서버에 연결한 후 ConnectionImpl 객체(Connection 인터페이스의 구현체)를 반환한다.
JDBC를 사용한 간단한 CRUD 예시
DBConnectUtil 클래스 작성 (Connection 획득)
public class DBConnectionUtil {
public static Connection getConnection() {
try {
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
return connection;
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
Member 클래스 작성
public class Member {
private String memberId;
private int money;
public Member() {
}
public Member(String memberId, int money) {
this.memberId = memberId;
this.money = money;
}
...
// Getter, Setter 메소드
// toString, equals, hashCode 메소드 - @Override
}
JdbcMemberRepository 클래스 작성
@Repository
public class MemberRepositoryJdbc implements MemberRepository {
private static final Logger log = LoggerFactory.getLogger(MemberRepositoryJdbc.class);
// Coonection 획득
private Connection getConnection() {
return DBConnectionUtil.getConnection();
}
// 리소스 정리
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
JdbcUtils.closeConnection(con);
}
@Override
public Member save(Member member) throws SQLException {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
@Override
public Member findById(String memberId) throws SQLException {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
// 조회 결과가 항상 1건(member_id는 PK)이므로
// while 대신에 if 사용
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, rs);
}
}
@Override
public void update(String memberId, int money) throws SQLException {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
@Override
public void delete(String memberId) throws SQLException {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
log.error("db error", e);
throw e;
} finally {
close(con, pstmt, null);
}
}
}
참고 자료
“김영한 스프링 DB 1편”
https://ko.wikipedia.org/wiki/JDBC
https://www.ibm.com/docs/ko/i/7.3?topic=connections-java-drivermanager-class
https://www.ibm.com/docs/ko/i/7.3?topic=exceptions-java-sqlexception-class
-
-
(Spring) - 빈과 컴포넌트 스캔
Spring MVC 살펴보기, 스프링이 제공하는 ServletContainerInitializer 먼저 읽고 오자.
빈(Bean)
스프링에서 빈은 스프링 컨테이너(=IoC 컨테이너=DI 컨테이너)에 의해 관리되는 객체이다.
스프링 빈은 일반적인 객체와는 달리 스프링 프레임워크의 기능과 규칙에 따라 동작하는 특별한 객체로 볼 수 있다.
스프링 컨테이너는 이러한 빈들을 생성 및 관리하고, 필요에 따라 의존성을 주입하여 개발자가 객체의 생명주기와 관련된 복잡한 작업을 처리하도록 도와준다.
특징
스프링 빈은 개발자가 직접 생성하고 관리하는 것이 아니라, 스프링 컨테이너에 의해 생성되고 관리된다. 개발자는 빈의 설정을 정의하고, 스프링 컨테이너는 이를 바탕으로 빈을 생성하고 관리한다.
스프링 빈은 의존 객체를 직접 생성하고 주입할 필요 없이 의존성 주입(DI)을 통해 다른 빈들과의 관계를 설정할 수 있다. 의존성 주입은 xml 설정, 자바 설정(@Configuration과 @Bean), @Autowired 어노테이션 등을 통해 이루어진다.
스프링 빈은 기본적으로 Singleton 스코프를 가지며, 다양한 스코프(Prototype, Request, Session 등)를 설정할 수 있다. 스코프는 빈을 생성하기 위한 방법을 말하며, Singleton 스코프는 스프링 컨테이너당 하나의 인스턴스만 존재함을 의미한다.
스프링 빈 등록 방법
xml 설정
자바 설정(@Configuration과 @Bean)
어노테이션(@Component, @Service, @Controller 등) - 컴포넌트 스캔
자바 설정 간단한 예시
public class MemoryMemberRepository implements MemberRepository
{
private static Map<Long, Member> store = new HashMap<>();
@Override
public void save(Member member)
{ store.put(member.getId(), member); }
}
public class MemberServiceImpl implements MemberService
{
private final MemberRepository memberRepository;
public MemberServiceImpl(MemberRepository memberRepository)
{ this.memberRepository = memberRepository; }
@Override
public void join(Member member)
{ memberRepository.save(member); }
}
@Configuration
public class AppConfig
{
@Bean
public MemberRepository memberRepository()
{
System.out.println("memberRepository() 호출");
return new MemoryMemberRepository();
}
@Bean
public MemberService memberService()
{
return new MemberServiceImpl(memberRepository());
}
}
public class Example
{
public static void main(String[] args)
{
ApplicationContext ac
= new AnnotationConfigWebApplicationContext();
ac.register(AppConfig.class);
ac.refresh();
MemberService memberService
= ac.getBean("memberService", MemberService.class);
Member member = new Member("A", 20);
memberService.join(member);
}
}
ApplicationContext를 스프링 컨테이너라고 한다.
스프링 컨테이너는 AppConfig를 설정 정보로 사용하며, 여기서 @Bean이 붙은 모든 메소드를 호출해서 반환된 객체를 스프링 컨테이너에 등록한다. 이렇게 스프링 컨테이너에 등록된 객체를 스프링 빈이라고 한다. (@Bean이 붙은 메소드명 = 스프링 빈의 이름)
스프링 컨테이너는 설정 정보를 참고해서 의존관계를 주입(DI)한다.
설정 정보(AppConfig.class)에서 @Configuration 없이 @Bean만 적용하면 어떻게 될까?
@Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다. memberService() 메소드의 memberRepository() 메소드처럼, 의존관계 주입이 필요해서 메소드를 직접 호출할 때 싱글톤을 보장하지 않는다. 쉽게 얘기해서, “memberRepository() 호출” 메시지가 2번 출력될 수 있다는 말이다.
반면, @Configuration을 적용하면 @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환하고, 스프링 빈이 없으면 생성해서 스프링 빈으로 등록하고 반환한다. 즉, 싱글톤이 보장된다. 쉽게 얘기해서, “memberRepository() 호출” 메시지는 1번만 출력된다는 말이다.
-> 스프링 설정 정보는 항상 @Configuration을 사용하자.
컴포넌트 스캔
컴포넌트 스캔은 xml 설정 정보(‘bean’ 태그)나 자바 설정 정보(@Configuration과 @Bean)가 없어도 자동으로 스프링 빈을 등록할 수 있는 기능이다.
컴포넌트 스캔을 사용하려면 설정 정보에 @ComponentScan을 붙여주면 된다.(기존의 자바 설정 정보와는 다르게 @Bean으로 등록한 클래스가 없다.)
@ComponentScan은 탐색 위치에 @Component 어노테이션이 붙은 모든 클래스를 스캔해서 스프링 빈으로 등록한다.
기본 스캔 대상
컴포넌트 스캔은 @Component뿐만 아니라 아래의 내용도 추가로 대상에 포함한다.(아래의 내용의 코드들을 보면 @Component를 포함하고 있다.)
@Controller : 스프링 MVC 컨트롤러에서 사용
@Service : 스프링 비즈니스 로직에서 사용
@Repository : 스프링 데이터 접근 계층에서 사용
@Configuration : 스프링 설정 정보에서 사용
탐색 위치
@ComponentScan(basePackages = "hello.core", )
basePackages : 탐색할 패키지의 시작 위치(src/main/java 이후의 위치)를 지정하며, 해당 패키지를 포함해서 하위 패키지를 모두 탐색한다. 여러 개의 시작 위치를 지정할 수도 있다.
basePackageClasses : 지정한 클래스가 속한 패키지를 탐색 시작 위치로 지정한다. 하위 패키지도 모두 탐색한다.
패키지 위치를 지정하지 않으면, @ComponentScan이 붙은 설정 정보 클래스가 속한 패키지가 시작 위치가 된다. 패키지 위치를 지정하지 않고, 설정 정보 클래스의 위치를 프로젝트 최상단에 두는 방법이 자주 쓰인다.
컴포넌트 스캔 간단한 예시 (위의 자바 설정 예시 수정)
@Component
public class MemoryMemberRepository implements MemberRepository
{ ... }
@Component
public class MemberServiceImpl implements MemberService
{
private final MemberRepository memberRepository;
@Autowired
public MemberServiceImpl(MemberRepository memberRepository)
{ this.memberRepository = memberRepository; }
...
}
@Configuration
@ComponentScan
public class AppConfig
{ }
public class Example
{
public static void main(String[] args)
{
ApplicationContext ac
= new AnnotationConfigWebApplicationContext();
ac.register(AppConfig.class);
ac.refresh();
MemberService memberService
= ac.getBean("memberService", MemberService.class);
Member member = new Member("A", 20);
memberService.join(member);
}
}
@Autowired
@Autowired는 의존관계를 자동으로 주입해준다.
생성자에 @Autowired를 지정하면, 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입한다. 여러 의존관계도 한번에 주입받을 수 있다.
Spring MVC의 컴포넌트 스캔
SpringServletContainerInitializer(WebApplicationInitializer 직접 구현)를 바탕으로 두 가지 예시를 살펴보자.
DispatcherServlet WebApplicationContext만 사용하는 경우
Root WebApplicationContext와 DispatcherServlet WebApplicationContext를 사용하는 경우
단일 WebApplicationContext의 경우
DispatcherConfig.class 작성
@Configuration
@ComponentScan
@EnableWebMvc
public class DispatcherConfig implements WebMvcConfigurer
{
// 필요한 메소드 Override
// ex1) 뷰 리졸버 구성
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
registry.viewResolver(resolver);
}
// ex2) 메시지 컨버터 구성
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new MappingJackson2HttpMessageConverter());
}
}
WebApplicationInitializer 구현
public class MyWebApplicationInitializer implements WebApplicationInitializer
{
@Override
public void onStartup(ServletContext servletContext)
{
AnnotationConfigWebApplicationContext dispatcherContext
= new AnnotationConfigWebApplicationContext();
dispatcherContext.register(DispatcherConfig.class);
ServletRegistration.Dynamic dispatcher
= servletContext.addServlet("dispatcher", new DispatcherServlet(dispatcherContext));
dispatcher.addMapping("/");
dispatcher.setLoadOnStartup(1);
}
}
@EnableWebMvc
@EnableWebMvc는 기본적인 Spring MVC 설정을 자동으로 활성화해준다.
예를 들어, 스프링 컨테이너를 생성할 때 입력으로 받는 설정 클래스에는 HandlerMapping, HandlerAdapter 등의 빈이 등록되어 있어야 하는데, 설정 클래스에 @EnableWebMvc를 붙여주면 해당 빈들을 자동으로 추가해주므로 개발자가 직접 빈으로 등록할 필요가 없다.
@EnableWebMvc를 사용할 때, WebMvcConfigurer 인터페이스를 구현하여 Spring MVC의 설정을 추가 설정하거나 커스터마이징할 수 있다.
예를 들어, 필요한 메소드를 오버라이드하여 인터셉터 추가, 뷰 리졸버 설정 등을 할 수 있다.
Root WebApplicationContext가 추가된 경우
AppConfig.class 작성
@Configuration
@ComponentScan(excludeFilters={@Filter(org.springframework.stereotype.Controller.class)})
public class AppConfig
{ }
DispatcherConfig.class 작성
@Configuration
@ComponentScan(basePackageClasses = AppConfig.class, useDefaultFilters=false, includeFilters={@Filter(org.springframework.stereotype.Controller.class)})
@EnableWebMvc
public class DispatcherConfig implements WebMvcConfigurer
{ }
WebApplicationInitializer 구현
public class MyWebAppInitializer implements WebApplicationInitializer
{
@Override
public void onStartup(ServletContext container)
{
// Root WebApplicationContext
AnnotationConfigWebApplicationContext rootContext
= new AnnotationConfigWebApplicationContext();
rootContext.register(AppConfig.class);
container.addListener(new ContextLoaderListener(rootContext));
// DispatcherServlet의 WebApplicationContext
AnnotationConfigWebApplicationContext dispatcherContext
= new AnnotationConfigWebApplicationContext();
dispatcherContext.register(DispatcherConfig.class);
ServletRegistration.Dynamic dispatcher
= container.addServlet("dispatcher", new DispatcherServlet(dispatcherContext));
dispatcher.addMapping("/");
dispatcher.setLoadOnStartup(1);
}
}
컴포넌트 스캔 시점
Root WebApplicationContext가 초기화될 때 AppConfig.class의 컴포넌트 스캔이 실행된다. (ContextLoaderListener의 contextInitialized 메소드가 호출될 때)
DispatcherServlet의 WebApplicationContext가 초기화될 때 DispatcherConfig.class의 컴포넌트 스캔이 실행된다. (DispatcherServlet이 초기화될 때)
참고 자료
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/Configuration.html
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/config/annotation/EnableWebMvc.html
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/ComponentScan.html
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/config/annotation/EnableWebMvc.html
https://stackoverflow.com/questions/28293400/spring-root-and-servlet-context-with-java-config
https://catsbi.oopy.io/c760561d-50fd-4874-aa01-17feb45fe980
https://ittrue.tistory.com/229#google_vignette
https://steady-coding.tistory.com/594
https://castleone.tistory.com/2
https://mangkyu.tistory.com/75
https://mangkyu.tistory.com/176
https://galid1.tistory.com/532
-
(Spring) - 스프링이 제공하는 ServletContainerInitializer
web.xml을 대체하는 ServletContainerInitializer 먼저 읽고 오자.
스프링은 ServletContainerInitializer 인터페이스를 구현한 클래스로 SpringServletContainerInitializer를 제공하며, 개발자가 실제 구현해야 하는 것은 WebApplicationInitializer이다.
SpringServletContainerInitializer
@HandlesTypes({WebApplicationInitializer.class})
public class SpringServletContainerInitializer implements ServletContainerInitializer
{
public void onStartup(@Nullable Set<Class<?>> webAppInitializerClasses,
ServletContext servletContext)
throws ServletException
{
...
WebApplicationInitializer initializer = (WebApplicationInitializer) var4.next();
initializer.onStartup(servletContext);
...
}
}
SpringServletContainerInitializer는 WebApplicationInitializer 구현체를 인스턴스화하고 ServletContext를 위임하는 역할을 한다.
즉, 서블릿 컨테이너가 생성한 ServletContext를 SpringServletContainerInitializer가 받고, ServletContext를 초기화하는 실제 작업은 WebApplicationInitializer 구현체가 수행하는 것이다.
동작 매커니즘
spring-web 모듈 JAR가 클래스패스에 있으면, SpringServletContainerInitializer는 서블릿 컨테이너가 시작될 때 로드되고 인스턴스화되며 onStartup 메소드가 호출된다.
JAR 서비스 API의 ServiceLoader.load(Class) 메소드가 spring-web 모듈의 /META-INF/services/jakarta.servlet.ServletContainerInitializer 파일을 감지함으로써 발생하는 것이다.
onStartup 메소드는 WebApplicationInitializer 구현체들을 찾아서 초기화하는 역할을 한다.
만약 클래스패스에서 WebApplicationInitializer 구현체를 찾을 수 없다면, onStartup 메서드는 아무 작업도 수행하지 않는다.
하나 이상의 WebApplicationInitializer 타입이 감지되면, 이 구현체들은 인스턴스화된다. 그런 다음, 각 WebApplicationInitializer 인스턴스들의 onStartup(ServletContext) 메서드가 호출된다.
WebApplicationInitializer
public interface WebApplicationInitializer
{
void onStartup(ServletContext servletContext) throws ServletException;
}
WebApplicationInitializer는 ServletContext를 프로그래밍 방식으로 구성하기 위해 구현해야 하는 인터페이스이다.
WebApplicationInitializer 인터페이스를 구현한 클래스는 서블릿 컨테이너 실행 시 SpringServletContainerInitializer에 의해 자동으로 감지되고 실행된다.
SpringServletContainerInitializer, WebApplicationInitializer 구현에서 Spring MVC와 무조건적으로 결합해야 할 필요는 없다. WebApplicationInitializer에는 Spring MVC 요소 이외에 서블릿, 필터, 리스너, 각종 필터를 등록 할 수 있다.
ApplicationContext 설정 및 DispatcherServlet 등록 예시
ApplicationContext 설정 및 DispatcherServlet 등록을 위해서 두 가지 방식을 사용할 수 있다.
WebApplicationInitializer 인터페이스를 직접 구현(implements)
WebApplicationInitializer 인터페이스를 구현한 추상 클래스인 AbstractAnnotationConfigDispatcherServletInitializer를 구현(extends)
WebApplicationInitializer 직접 구현
public class MyWebAppInitializer implements WebApplicationInitializer
{
@Override
public void onStartup(ServletContext servletContext)
{
// Root WebApplicationContext 생성
AnnotationConfigWebApplicationContext rootContext
= new AnnotationConfigWebApplicationContext();
// Root WebApplicationContext 설정 클래스 등록
rootContext.register(AppConfig.class);
// ContextLoaderListener를 등록하여 Root WebApplicationContext를 초기화
servletContext.addListener(new ContextLoaderListener(rootContext));
// DispatcherServlet의 WebApplicationContext 생성
AnnotationConfigWebApplicationContext dispatcherContext
= new AnnotationConfigWebApplicationContext();
// DispatcherServlet WebApplicationContext 설정 클래스 등록
dispatcherContext.register(DispatcherConfig.class);
// DispatcherServlet 등록 및 매핑
ServletRegistration.Dynamic dispatcher
= servletContext.addServlet("dispatcher", new DispatcherServlet(dispatcherContext));
dispatcher.addMapping("/");
dispatcher.setLoadOnStartup(1); // DispatcherServlet이 웹 애플리케이션 시작 시 로드되도록 설정
}
}
=> Root WebApplicationContext와 DispatcherServlet의 WebApplicationContext의 초기화 시점
Root WebApplicationContext
ContextLoaderListener에 의해 초기화되는데, ContextLoaderListener의 contextInitialized 메소드가 호출되면서 초기화된다.
또한, Root WebApplicationContext가 초기화될 때 AppConfig.class의 컴포넌트 스캔이 실행된다.
DispatcherServlet의 WebApplicationContext
DispatcherServlet에 의해 초기화되는데, DispatcherServlet이 초기화될 때 initWebApplicationContext 메소드가 호출되면서 초기화된다.
또한, DispatcherServlet의 WebApplicationContext가 초기화될 때 DispatcherConfig.class의 컴포넌트 스캔이 실행된다.
ContextLoaderListener
ContextLoaderListener는 스프링의 Root WebApplicationContext를 시작하고 종료하는 데 사용되는 부트스트랩 리스너이다. Root WebApplicationContext를 리스너로 관리해야 ServletContext의 종료 이벤트를 받았을 때 자원을 적절히 반환할 수 있다.
ContextLoaderListener는 ServletContextListener를 구현하고, ContextLoader를 상속한다. ContextLoaderListener는 ServletContextListener를 구현하여 ServletConetxt의 시작 및 종료 이벤트를 처리할 수 있다.
AbstractAnnotationConfigDispatcherServletInitializer 구현
public class MyWebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer
{
@Override
protected Class<?>[] getRootConfigClasses()
{
// Root WebApplicationContext 설정 클래스 등록
return new Class<?>[] { AppConfig.class };
}
@Override
protected Class<?>[] getServletConfigClasses()
{
// DispatcherServlet WebApplicationContext 설정 클래스 등록
return new Class<?>[] { DispatcherConfig.class };
}
@Override
protected String[] getServletMappings()
{
// DispatcherServlet 매핑
return new String[] { "/" };
}
}
참고 자료
https://tomcat.apache.org/tomcat-7.0-doc/servletapi/javax/servlet/ServletContainerInitializer.html
https://tomcat.apache.org/tomcat-8.0-doc/servletapi/javax/servlet/ServletContext.html
https://tomcat.apache.org/tomcat-7.0-doc/servletapi/javax/servlet/ServletRegistration.Dynamic.html
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/SpringServletContainerInitializer.html
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/WebApplicationInitializer.html
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/context/ContextLoaderListener.html
https://kimcoder.tistory.com/511
https://recordsoflife.tistory.com/490
https://escapefromcoding.tistory.com/174
https://offbyone.tistory.com/215
https://nhs0912.tistory.com/81
https://blog.naver.com/kitepc/221314687808
https://velog.io/@rolroralra/Chapter0.-Servlet-컨테이너-Spring-컨테이너
-
(Tomcat) - web.xml을 대체하는 ServletContainerInitializer (+ServletContextListener)
web.xml과 ServletContainerInitializer
web.xml : 서블릿 컨테이너가 웹 애플리케이션을 초기화할 때 필요한 정보를 제공한다.
ServletContainerInitializer : 서블릿 컨테이너가 시작될 때 ServletContainerInitializer를 구현한 클래스를 실행하여 웹 애플리케이션을 초기화한다.
ServletContext는 서블릿 컨테이너라고 볼 수 있다. 따라서 web.xml이나 ServletContainerInitializer의 핵심은 결국 ServletContext 객체를 초기화하기 위함이다.
ServletContainerInitializer
public interface ServletContainerInitializer
{
void onStartup(Set<Class<?>> var1, ServletContext var2) throws ServletException;
}
Servlet 3.0부터 ServletContainerInitializer의 등장으로 기존의 web.xml 기반 대신 코드 기반으로 서블릿 컨테이너를 초기화하는 작업(ServletContext 객체 초기화)이 가능해졌다.
ServletContainerInitializer 인터페이스를 구현한 클래스가 있으면, ServletContainerInitializer를 구현한 클래스는 서블릿 컨테이너가 시작될 때 로드되고 인스턴스화되며 onStartup 메소드가 호출된다.
ServletContainerInitializer 구현
ServletContainerInitializer 인터페이스를 구현하는 클래스를 만든 후, /META-INF/services/jakarta.servlet.ServletContainerInitializer 파일을 생성하고 ServletContainerInitializer 인터페이스를 구현한 클래스의 이름을 해당 파일에 적어준다.
ServletContainerInitializer 인터페이스를 구현하는 클래스에 @HandlesTypes 어노테이션으로 클래스를 지정해주면, 서블릿 컨테이너가 시작될 때 지정된 클래스를 찾아서 ServletContainerInitializer 인터페이스를 구현하는 클래스의 onStartup() 메소드에 지정된 클래스와 ServletContext 객체를 인자로 넣어준다.
ServletContext 객체 초기화
서블릿 컨테이너가 시작될 때 수행되는 웹 애플리케이션을 초기화하는 작업인 “ServletContext 객체 초기화”에 대해서 web.xml 방식과 ServletContainerInitializer 방식의 예시를 살펴보자.
web.xml 방식
서블릿 컨테이너가 시작되고 웹 애플리케이션이 초기화될 때, 서블릿 컨테이너는 web.xml을 읽은 후 ServletContext 객체를 초기화한다.
ServletContainerInitializer 방식
서블릿 컨테이너가 시작되고 웹 애플리케이션이 초기화될 때, 서블릿 컨테이너는 ServletContainerInitializer 인터페이스를 구현한 클래스를 찾은 후 해당 클래스를 로딩하고 인스턴스화한다. 이후 해당 클래스의 onStartup() 메소드를 호출하여 ServletContext 객체를 초기화한다.
ServletContext 객체 초기화 1 - ServletContext 초기화 파라미터 설정, 서블릿 등록 등
web.xml 방식
웹 애플리케이션 구조
/src
/main
/java
/com
/example
ExampleServlet.java
/webapp
/WEB-INF
web.xml
서블릿 클래스 생성
public class ExampleServlet extends HttpServlet
{
@Override
protected void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException
{
System.out.println("service 메소드 호출");
}
}
web.xml 작성
<!-- ServletContext 초기화 파라미터 설정 -->
<context-param>
<param-name>contextParamName_example</param-name>
<param-value>contextParamValue_example</param-value>
</context-param>
<!-- 서블릿 등록 -->
<servlet>
<servlet-name>exampleServlet</servlet-name>
<servlet-class>com.example.ExampleServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>exampleServlet</servlet-name>
<url-pattern>/example</url-pattern>
</servlet-mapping>
<!-- 필터, 리스너 등 웹 애플리케이션 구성 요소 관련 -->
...
ServletContainerInitializer 방식
웹 애플리케이션 구조
/src
/main
/java
/com
/example
ExampleServlet.java
MyServletContainerInitializer.java
MyAppInitializer.java
MyAppInitializerV1.java
/resources
/META-INF
/services
jakarta.servlet.ServletContainerInitializer
서블릿 클래스 생성
public class ExampleServlet extends HttpServlet
{
@Override
protected void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException
{
System.out.println("service 메소드 호출");
}
}
ServletContainerInitializer 구현 클래스 생성
@HandlesTypes(MyAppInitializer.class)
public class MyServletContainerInitializer implements ServletContainerInitializer
{
@Override
public void onStartup(Set<Class<?>> classes, ServletContext servletContext) throws ServletException
{
for (Class<?> myAppInitClass : classes) {
try {
MyAppInitializer initializer = (MyAppInitializer) myAppInitClass.getDeclaredConstructor.newInstance();
initializer.onStartup(servletContext);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
MyAppInitializer 인터페이스 작성 및 구현
public interface MyAppInitializer
{
void onStartUp(ServletContext servletContext);
}
public class MyAppInitializerV1 implements MyAppInitializer
{
@Override
public void onStartUp(ServletContext servletContext)
{
// setInitParamater와 setAtturibute는 용도와 동작 방식 측면에서 여러 차이가 있음
// setInitParameter()
// - 설정 후 변경 불가 -> 이미 해당 초기화 파라미터가 설정되있으면 실패함
// - 값은 문자열 형식
// - 설정은 주로 초기화 시에 한 번만 수행
// - 웹 애플리케이션의 전역 설정에 주로 사용
// - web.xml의 context-param 태그와 유사함 -> 읽기 전용
// setAttribute()
// - 설정, 변경, 제거 가능
// - 값은 객체 형식
// - 런타임 동안 동적으로 관리
// - 애플리케이션 컴포넌트 간 데이터 공유 및 상태 저장에 주로 사용
// ServletContext 초기화 파라미터 설정
servletContext.setInitParameter("contextParamName_example", "contextParamValue_example");
// 서블릿 등록
ServletRegistration.Dynamic exampleServlet = servletContext.addServlet("exampleServlet", new ExampleServlet());
exampleServlet.addMapping("/example");
}
}
/META-INF/services/jakarta.servlet.ServletContainerInitializer 파일 생성 후 아래 내용 작성
com.example.MyServletContainerInitializer
=> ServletContainerInitializer, web.xml의 핵심은 결국 ServletContext 객체를 초기화하는 것
ServletContainerInitializer 방식의 코드(MyAppInitializerV1.class)에서 서블릿을 등록하는 부분인 servletContext.addServlet()를 보면, ServletContext 객체에 서블릿을 등록하는 형태로 이루어진다. 즉, 서블릿을 ServletContext 객체에 등록하는 것이 서블릿 등록의 본질이라고 볼 수 있다.마찬가지로, ServletContext 초기화 파라미터 설정은 물론, 필터나 리스너 등록 등의 작업도 결국은 ServletContext 객체에 등록하는 작업이다.
web.xml 방식에서도 서블릿, 필터, 리스너, ServletContext 초기화 파라미터 등의 설정을 작성해두면, 해당 설정들이 ServletContext 객체에 등록된다. 따라서, ServletContainerInitializer와 web.xml은 ServletContext 객체를 초기화하는 작업을 수행하는 방식이 다를 뿐, 결과적으로 두 방식 모두 ServletContext 객체를 초기화한다.
ServletContext 객체 초기화 2 - 서블릿에 대한 추가 설정
web.xml 방식
web.xml 작성
<servlet>
<servlet-name>exampleServlet</servlet-name>
<servlet-class>com.example.ExampleServlet</servlet-class>
<!-- 서블릿 초기화 파라미터 설정 -->
<init-param>
<param-name>servletParamName_example</param-name>
<param-value>servletParamValue_example</param-value>
</init-param>
<!-- 서블릿 초기화 시점 설정 -->
<load-on-startup>1</load-on-startup>
</servlet>
<!-- 서블릿 매핑 -->
<servlet-mapping>
<servlet-name>exampleServlet</servlet-name>
<url-pattern>/example</url-pattern>
</servlet-mapping>
ServletContainerInitializer 방식
MyAppInitializer 구현
public class MyAppInitializerV1 implements MyAppInitializer
{
@Override
public void onStartUp(ServletContext servletContext)
{
ServletRegistration.Dynamic exampleServlet = servletContext.addServlet("exampleServlet", new ExampleServlet());
// 서블릿 초기화 파라미터 설정
exampleServlet.setInitParameter("servletParamName_example", "servletParamValue_example");
// 서블릿 초기화 시점 설정
exampleServlet.setLoadOnStartup(1);
// 서블릿 매핑
exampleServlet.addMapping("/example");
}
}
=> 서블릿 초기화 파라미터 설정, 서블릿 매핑 등의 서블릿에 대한 추가 설정 작업도 ServletContext 객체를 초기화하는 작업의 일부로 간주
ServletContainerInitializer 방식의 코드(MyAppInitializerV1.class)를 보면, ServletContext 객체에 서블릿을 등록할 때 addServlet() 메소드가 사용되고 이 메소드는 ServletRegistration.Dynamic 객체를 반환한다. 즉, ServletRegistration.Dynamic 객체는 ServletContext를 통해 반환되며, 반환된 ServletRegistration.Dynamic 객체를 통해 서블릿에 대한 추가 설정을 수행할 수 있다.
ServletContext의 getServletRegistration() 메소드를 사용하여 등록된 서블릿의 정보를 가져올 수 있다.
3. @WebServlet 어노테이션 방식
서블릿 클래스 생성(@WebServlet)
@WebServlet(name = "exampleServlet",
urlPatterns = "/example",
initParams = {@WebInitParam(name = "servletParamName_example", value = "servletParamValue_example")},
loadOnStartup = 1)
public class ExampleServlet extends HttpServlet
{
@Override
protected void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException
{
System.out.println("service 메소드 호출");
}
}
@WebServlet을 사용하면 web.xml이나 ServletContainerInitializer 없이 서블릿 등록 및 매핑, 서블릿 초기화 파라미터 설정 등 서블릿에 대한 설정을 할 수 있다.
@WebServlet은 개별 서블릿에 대한 간편한 설정을 제공하지만, 리스너 설정이나 ServletContext 객체의 초기화 파라미터 설정 등 복잡한 설정이나 웹 애플리케이션 전체 설정이 필요한 경우 web.xml이나 ServletContainerInitializer가 필요하다.
ServletContextListener
public interface ServletContextListener extends EventListener
{
default void contextInitialized(ServletContextEvent sce)
{
// 웹 애플리케이션이 초기화(시작)될 때 호출
// 필터나 서블릿이 초기화되기 전에 호출됨
}
default void contextDestroyed(ServletContextEvent sce)
{
// 웹 애플리케이션이 종료될 때 호출
// 모든 필터와 서블릿이 파괴된 후에 호출됨
}
}
ServletContextListener 인터페이스를 구현한 클래스는 웹 애플리케이션의 ServletContext가 변경될 때 알림을 받는다.
ServletContextListener는 ServletContext의 시작 및 종료 이벤트를 처리할 수 있으며, 웹 애플리케이션의 초기화(시작) 및 종료 시에 특정 작업을 수행하기 위해 사용된다.
ServletContextListener는 주로 웹 애플리케이션 초기화 및 종료 시 필요한 설정 작업을 수행한다. 데이터베이스 연결을 설정하거나, 로깅을 구성하는 등의 다양한 작업을 포함한다.
ServletContextListener 예시
ServletContextListener를 구현한 클래스는 web.xml 설정, @WebListener 어노테이션, ServletContext의 addListener() 메소드를 통해 등록한다.
web.xml 방식
ServletContextListener 인터페이스 구현 클래스 생성
public class MyServletContextListener implements ServletContextListener
{
@Override
public void contextInitialized(ServletContextEvent sce)
{
// JDBC 드라이버 로드
Class.forName("com.mysql.cj.jdbc.Driver");
// 데이터베이스 연결 설정
String dbUrl = sce.getServletContext().getInitParameter("DB_URL");
Connection connection = DriverManager.getConnection(dbUrl, "username", "password");
// ServletContext에 연결 객체 저장
sce.getServletContext().setAttribute("DBConnection", connection);
}
@Override
public void contextDestroyed(ServletContextEvent sce)
{
connection.close();
}
}
web.xml 작성
<listener>
<listener-class>com.example.MyServletContextListener</listener-class>
</listener>
어노테이션 방식
ServletContextListener 인터페이스 구현 클래스 생성(@WebListener)
@WebListener
public class MyServletContextListener implements ServletContextListener
{
// 위와 동일
}
addListener() 방식 - ServletContainerInitializer
ServletContextListener 인터페이스 구현 클래스 생성
public class MyServletContextListener implements ServletContextListener
{
// 위와 동일
}
MyAppInitializer 구현
public class MyAppInitializerV1 implements MyAppInitializer
{
@Override
public void onStartUp(ServletContext servletContext)
{
// ServletContext를 통해 리스너 등록
servletContext.addListener(new MyServletContextListener());
}
}
ServletContainerInitializer와 ServletContextListener
ServletContainerInitializer VS ServletContextListener
목적
ServletContainerInitializer : 서블릿 및 필터 등록 및 매핑, 리스너 등록 등
ServletContextListener : 데이터베이스 연결 설정 및 해제, 로그 설정 등
호출 시점
ServletContainerInitializer : 서블릿 컨테이너 시작 시 호출
ServletContextListener : 웹 애플리케이션 초기화(시작) 및 종료 시 호출
ServletContainerInitializer의 onStartUp 메소드와 ServletContextListener의 contextInitialized 메소드 호출 순서
서블릿 컨테이너가 시작되면 서블릿 컨테이너는 ServletContainerInitializer 구현 클래스의 onStartUp(ServletContext servletContext) 메소드를 호출
onStartUp 메소드에 전달되는 ServletContext 객체는 기본적인 초기화가 완료된 상태이며, onStartUp 메소드를 통해 추가적인 초기화 작업(서블릿, 필터, 리스너 등록 등)을 수행한다.
onStartUp 메소드의 실행이 끝난 후, 서블릿 컨테이너는 등록된 모든 ServletContextListener 구현 클래스의 contextInitialized 메소드를 호출
즉, ServletContextListener의 contextInitialized 메소드가 호출되는 시점은 ServletContainerInitializer나 web.xml을 통해 ServletContext 객체가 초기화된 직후이다.
참고 자료
https://tomcat.apache.org/tomcat-7.0-doc/servletapi/javax/servlet/ServletContainerInitializer.html
https://tomcat.apache.org/tomcat-8.0-doc/servletapi/javax/servlet/ServletContext.html
https://tomcat.apache.org/tomcat-7.0-doc/servletapi/javax/servlet/ServletRegistration.Dynamic.html
https://tomcat.apache.org/tomcat-8.5-doc/servletapi/javax/servlet/ServletContextListener.html
https://docs.oracle.com/javaee%2F6%2Fapi%2F%2F/javax/servlet/ServletContextListener.html
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/SpringServletContainerInitializer.html
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/WebApplicationInitializer.html
https://kimcoder.tistory.com/511
https://recordsoflife.tistory.com/490
https://escapefromcoding.tistory.com/174
https://offbyone.tistory.com/215
https://nhs0912.tistory.com/81
https://blog.naver.com/kitepc/221314687808
https://velog.io/@rolroralra/Chapter0.-Servlet-컨테이너-Spring-컨테이너
https://amy-it.tistory.com/86
https://erjuer.tistory.com/20
https://scshim.tistory.com/399
-
(Tomcat) - Servlet Container (feat. ServletContext, ServletConfig)
서블릿 컨테이너와 웹 애플리케이션
서블릿 컨테이너는 웹 애플리케이션을 실행하고 관리하는 데 사용된다.
서블릿 컨테이너는 여러 개의 웹 애플리케이션을 호스팅할 수 있으며, 웹 애플리케이션은 서블릿 컨테이너 내에서 독립적으로 실행된다.
ServletContext
public interface ServletContext {
...
String getInitParameter(String var1);
Enumeration<String> getInitParameterNames();
...
URL getResource(String var1) throws MalformedURLException;
InputStream getResourceAsStream(String var1);
...
Object getAttribute(String var1);
void setAttribute(String var1, Object var2);
void removeAttribute(String var1);
...
RequestDispatcher getRequestDispatcher(String var1);
...
void log(String var1);
void log(String var1, Throwable var2);
...
}
ServletContext 인터페이스는 서블릿이 서블릿 컨테이너와 통신하는 데 사용되는 메소드들을 정의한다.
ServletContext 인터페이스를 구현한 클래스는 ApplicationContext(Facade) 클래스이다. (org.apache.catalina.core.ApplicationContext)
ServletContext를 서블릿 컨테이너라고 볼 수 있다.
ServletContext 객체는 서블릿 컨테이너가 시작될 때 서블릿 컨테이너에 의해 각 웹 애플리케이션마다 한 개 생성된다. 서블릿 컨테이너가 종료될 때 소멸된다.
ServletContext 객체는 웹 애플리케이션 전체의 자원 및 설정에 대한 정보를 제공한다.
서블릿은 ServletContext 객체를 통해 서블릿 컨테이너와 통신하여 다양한 작업(파일 접근, 자원 바인딩, 로그 파일 쓰기 등)을 수행할 수 있다.
서블릿이 초기화될 때 서블릿 컨테이너는 해당 서블릿에 대한 초기화 정보를 담은 ServletConfig 객체를 생성하여 서블릿에게 제공하는데, 서블릿은 ServletConfig의 getServletContext() 메소드를 통해 ServletContext 객체를 얻을 수 있다.
ServletConfig
public interface ServletConfig {
String getServletName();
String getInitParameter(String var1);
Enumeration<String> getInitParameterNames();
ServletContext getServletContext();
}
ServletConfig 인터페이스는 서블릿 컨테이너가 서블릿을 초기화할 때 서블릿에게 전달하는 정보에 대한 메소드들을 정의한다.
ServletContext 인터페이스를 구현한 클래스는 StandardWrapper(Facade) 클래스이다. (org.apache.catalina.core.StandardWrapper)
ServletConfig 객체는 서블릿 컨테이너가 서블릿을 초기화할 때 서블릿 컨테이너에 의해 각 서블릿마다 한 개 생성된다. 서블릿이 소멸될 때 함께 소멸된다.
ServletConfig 객체는 ServletContext에 대한 참조와 서블릿에 대한 초기화 매개변수 등을 제공한다.
서블릿 초기화 시점
서블릿 컨테이너가 실행될 때 초기화 (‘load-on-startup’ 속성이 설정된 경우)
최초 HTTP 요청이 들어올 때 초기화 (‘load-on-startup’ 속성이 설정되지 않은 경우)
서블릿 초기화 과정
서블릿 컨테이너가 서블릿 클래스를 로드
서블릿 컨테이너가 서블릿 클래스의 인스턴스를 생성
서블릿 컨테이너가 ServletConfig 객체를 생성
서블릿 컨테이너가 서블릿 인스턴스의 init(ServletConfig config) 메소드를 호출
서블릿의 초기화 메소드 (GenericServlet)
// HttpServlet 클래스의 부모 클래스인 GenericServlet 클래스
public abstract class GenericServlet implements Servlet, ServletConfig, Serializable
{
...
private transient ServletConfig config;
// 서블릿 컨테이너가 먼저 호출하는 메소드
public void init(ServletConfig config) throws ServletException
{
// 전달받은 ServletConfig 객체를 현재 서블릿 인스턴스의 인스턴스 변수 config에 저장
this.config = config;
// init() 메소드 호출
this.init();
}
// 기본 구현으로 아무 작업도 하지않으며, 서블릿에서 필요에 따라 Override
public void init() throws ServletException { }
...
public ServletConfig getServletConfig()
{ return this.config; }
public ServletContext getServletContext()
{
// ServletConfig의 getServletContext() 메소드
return this.getServletConfig().getServletContext();
}
...
}
위와 같이 GenericServlet 클래스를 보면, 서블릿의 초기화 메소드는 init(ServletConfig config), init() 두 가지가 있다.
서블릿 컨테이너는 서블릿을 초기화할 때 init(ServletConfig config) 메소드를 먼저 호출한다.
서블릿에서 초기화 메소드 Override
두 가지 초기화 메소드를 모두 Override 하지 않음
서블릿 컨테이너가 서블릿을 초기화할 때, GenericServlet의 init(ServletConfig config)을 먼저 호출하고 해당 메소드에서 GenericServlet의 init()을 호출함
init()을 Override
서블릿 컨테이너가 서블릿을 초기화할 때, GenericServlet의 init(ServletConfig config)을 먼저 호출하고 해당 메소드에서 서블릿이 Override한 init()을 호출함
init(ServletConfig config)를 Override
서블릿 컨테이너가 서블릿을 초기화할 때, 서블릿에서 Override한 init(ServletConfig config)을 호출함
이 경우, init(ServletConfig config)에서 super.init(config)를 작성하지 않으면 해당 서블릿의 인스턴스에서는 ServletConfig 객체가 GenericServlet의 private 변수 config에 저장되지 않으며, ServletConfig 객체가 변수에 저장되지 않으면 서블릿에서는 ServletContext 객체에 접근할 수가 없다.
ServletContext, ServletConfig 예시
서블릿에서 초기화 메소드를 Override하지 않았을 때, ServletConfig, ServletContext 객체를 가져오고 사용하기
public class ExampleServlet extends HttpServlet
{
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException
{
// ServletConfig 객체 가져오기
// GenericServlet의 getServletConfig() 메소드
SerlvetConfig config = getServletConfig();
// ServletContext 객체 가져오기
// GenericServlet의 getServletContext() 메소드
ServletContext context = getServletContext();
// 서블릿의 초기화 매개변수 가져오기
String configParamValue =
config.getInitParameter("configParamName");
// 웹 애플리케이션의 초기화 매개변수 가져오기
String contextParamValue =
context.getInitParameter("contextParamName");
}
}
참고 자료
https://tomcat.apache.org/tomcat-8.0-doc/servletapi/javax/servlet/ServletContext.html
https://tomcat.apache.org/tomcat-8.0-doc/servletapi/javax/servlet/ServletConfig.html
https://javaee.github.io/javaee-spec/javadocs/javax/servlet/Servlet.html
https://javaee.github.io/javaee-spec/javadocs/javax/servlet/ServletConfig.html
https://javaee.github.io/javaee-spec/javadocs/javax/servlet/ServletContext.html
https://vibeee.tistory.com/140
https://ckddn9496.tistory.com/47
https://velog.io/@cocodori/ServletContext-ServletConfig
https://kgvovc.tistory.com/38
https://blog.naver.com/crint/90068104505
https://codevang.tistory.com/193
-
(Tomcat) - Tomcat의 요청 처리 과정
Tomcat의 요청 처리 과정 (필터 X)
Client가 Web Server로 HTTP Request 보냄
정적 콘텐츠의 경우 Web Server에서 처리, 동적 콘텐츠의 경우 Web Server가 Servlet Container로 요청 전달 - (1)
요청을 받은 Servlet Container는 HttpServletRequest, HttpServletResponse 객체를 생성
Servlet Container는 주로 web.xml 또는 어노테이션 또는 ServletContainerInitializer 인터페이스의 구현체를 통해서 요청한 URL에 맞는 서블릿을 찾음 - (2)
Servlet Container에 해당 서블릿 인스턴스가 없는 경우
Servlet Container가 서블릿 인스턴스를 생성하고 서블릿의 init() 메소드 호출
Servlet Container에 해당 서블릿 인스턴스가 있는 경우
서블릿 인스턴스를 다시 생성하지 않고 재사용 (싱글톤 패턴)
Servlet Container는 요청을 처리할 Thread를 Thread Pool에서 가져옴 (멀티 스레드 환경)
Thread는 서블릿의 service() 메소드 호출 - (3)
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
...
}
6-1. service() 메소드에서는 요청에 따라 doGET() 또는 doPost() 메소드 호출
6-2. 응답 값을 HttpServletResponse 객체에 담음
Servlet Container는 응답 값을 HTTP Response로 바꾼 후 Web Server로 전송
HttpServletRequest, HttpServletResponse 객체를 소멸시키고 Thread를 종료
Tomcat의 요청 처리 과정 (필터 O)
Client가 Web Server로 HTTP Request 보냄
정적 콘텐츠의 경우 Web Server에서 처리, 동적 콘텐츠의 경우 Web Server가 Servlet Container로 요청 전달 - (1)
요청을 받은 Servlet Container는 HttpServletRequest, HttpServletResponse 객체를 생성
Servlet Container는 주로 web.xml 또는 어노테이션 또는 ServletContainerInitializer 인터페이스의 구현체를 통해서 요청한 URL에 맞는 필터와 서블릿을 찾음 - (2)
필터
Tomcat이 시작될 때 Servlet Container는 모든 필터 인스턴스를 생성(싱글톤 패턴)하고 init() 메소드 호출
Servlet Container에 해당 서블릿 인스턴스가 없는 경우
Servlet Container가 서블릿 인스턴스를 생성하고 서블릿의 init() 메소드 호출
Servlet Container에 해당 서블릿 인스턴스가 있는 경우
서블릿 인스턴스를 다시 생성하지 않고 재사용 (싱글톤 패턴)
Servlet Container는 FilterChain을 생성 - (3)
FilterChain은 필터들과 서블릿의 연결고리라고 볼 수 있음
Servlet Container는 요청을 처리할 Thread를 Thread Pool에서 가져옴 (멀티 스레드 환경)
Thread는 첫 번째 필터의 doFilter() 메소드 호출 - (4)
@Override
public void doFilter(ServletRequest request,ServletResponse response,
FilterChain chain) throws ServletException, IOException {
...
chain.doFilter(request, response);
}
7-1. chain.doFilter(request, response)에서는 다음 필터가 있으면 필터를 호출하고, 다음 필터가 없으면 서블릿 service() 메소드 호출
7-2. service() 메소드에서는 요청에 따라 doGET() 또는 doPost() 메소드 호출
7-3. 서블릿의 service() 메소드가 끝나면 이전 필터로 돌아가고, 반복해서 첫 번째 필터까지 돌아감
7-4. 응답 값을 HttpServletResponse 객체에 담음
Servlet Container는 응답 값을 HTTP Response로 바꾼 후 Web Server로 전송
HttpServletRequest, HttpServletResponse 객체를 소멸시키고 Thread를 종료
참고 자료
https://ko.wikipedia.org/wiki/웹_컨테이너
https://ko.wikipedia.org/wiki/아파치_톰캣
https://itwiki.kr/w/아파치_톰켓
https://docs.spring.io/spring-security/reference/servlet/architecture.html
https://www.inflearn.com/questions/1132952/servlet%EC%97%90-%EB%8C%80%ED%95%B4-%EC%A0%9C-%EC%83%9D%EA%B0%81%EC%9D%84-%ED%95%9C%EB%B2%88-%EC%A0%95%EB%A6%AC%ED%95%B4%EB%B4%A4%EC%8A%B5%EB%8B%88%EB%8B%A4
https://stackoverflow.com/questions/3386254/servlets-filters-and-threads-in-tomcat
https://d-memory.tistory.com/37
https://taes-k.github.io/2020/02/16/servlet-container-spring-container/
https://forsaken.tistory.com/entry/Servlet-JSP-%EC%84%9C%EB%B8%94%EB%A6%BF-filter
https://velog.io/@bey1548/Servlet-Filter
https://curiousjinan.tistory.com/entry/spring-filterchain-dofilter
https://steady-coding.tistory.com/599
https://tecoble.techcourse.co.kr/post/2021-05-23-servlet-servletcontainer/
https://github.com/mangdo/TIL/blob/main/Spring/Controller/HttpServletRequest.md
https://velog.io/@zini9188/Spring-Security-Filter%EC%99%80-FilterChain
https://cbw1030.tistory.com/215
https://velog.io/@yoho98/%EC%84%9C%EB%B8%94%EB%A6%BFServlet%EA%B3%BC-%EC%84%9C%EB%B8%94%EB%A6%BF-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88Servlet-Container-y88kny7g
https://forsaken.tistory.com/entry/Servlet-JSP-%EC%84%9C%EB%B8%94%EB%A6%BF-filter
-
(Spring) - Spring 살펴보기 (+WebApplicationContext)
Spring MVC와 DispatcherServlet
Spring MVC는 프론트 컨트롤러 패턴을 중심으로 설계되었으며, DispatcherServlet은 Spring MVC에서 프론트 컨트롤러 기능을 담당하는 서블릿이다.
DispatcherServlet은 모든 HTTP 요청을 받아서 공통 기능을 처리한다. 즉, 핸들러(컨트롤러)에서 공통으로 처리해야 하는 부분을 DispatcherServlet에서 핸들러(컨트롤러) 호출 전에 먼저 처리한다.(수문장 역할)
클라이언트로부터 요청이 오면 DispatcherServlet이 직접 모든 작업을 수행하는 것이 아니라, 요청을 적절한 구성 요소(delegate componenets = special beans)로 전달하여 작업을 위임한다.
위임(delegation)
위임은 한 객체가 자신이 직접 작업을 수행하지 않고, 다른 객체가 그 작업을 수행하도록 맡기는 설계 패턴을 의미한다.
간단히 말해서, A 객체가 B 객체를 가지고 있고, A 객체에서 B 객체의 메서드를 호출하여 B 객체가 작업을 수행하면, 이는 A 객체가 B 객체에게 작업을 위임하는 것이다.
특별한 빈(special bean)
특별한 빈(special bean)이란 스프링이 관리하는 객체인 빈(bean) 중에서, 스프링 프레임워크에서 정해진 역할이나 기능을 수행하기 위해 구현된 빈(bean)을 말한다.
ex) HandlerMapping, HandlerAdapter, viewResolver, HandlerExceptionResolver 등
ApplicationContext, WebApplicationContext, AnnotationConfigWebApplicationContext
ApplicationContext
ApplicationContext 인터페이스를 구현한 객체(ex) AnnotationConfigWebApplicationContext)를 스프링이 관리하는 빈들이 담겨 있는 컨테이너(스프링 컨테이너 = IoC 컨테이너 = DI 컨테이너)라고 한다.
스프링 컨테이너는 빈의 생명주기 관리, 빈의 의존성 주입(DI) 등을 담당한다.
ApplicationContext 인터페이스를 구현한 클래스는 여러 종류가 있다.
WebApplicationContext
DispatcherServlet은 자체 설정을 위해 WebApplicationContext를 필요로 한다.
WebApplicationContext는 ApplicationContext에 getServletContext() 메소드 등이 추가된 인터페이스로, 자신이 속한 ServletContext와 서블릿에 대한 링크를 가지고 있다.
AnnotationConfigWebApplicationContext
AnnotationConfigWebApplicationContext는 WebApplicationContext 인터페이스를 구현한 클래스이다.
AnnotationConfigWebApplicationContext 클래스를 사용할 때 어노테이션 기반의 자바 코드 설정을 넘기면 된다. 자바 코드로된 설정에서는 @Configuration, @Bean 등의 어노테이션을 사용하여 빈을 정의하고 구성한다.
XmlWebApplicationContext는 어노테이션 기반의 자바 코드 설정이 아닌, xml 설정 파일을 사용한다.
AnnotationConfigWebApplicationContext 예시
@Configuration
public class AppConfig
{
@Bean
public MemberRepository memberRepository()
{ return new MemoryMemberRepository(); }
@Bean
public MemberService memberService()
{ return new MemberServiceImpl(memberRepository()); }
}
public class Example
{
public static void main(String[] args)
{
// ApplicationContext ac
// = new AnnotationConfigWebApplicationContext();
// AnnotationConfigWebApplicationContext : 어노테이션 기반 자바 코드 사용
AnnotationConfigWebApplicationContext ac
= new AnnotationConfigWebApplicationContext();
ac.register(AppConfig.class);
ac.refresh(); // 컨텍스트를 초기화하여 등록된 클래스들이 처리되도록 함
// WebApplicationInitializer를 사용한다면 refresh() 메소드를 직접 호출할 필요가 없음
MemberService memberService
= ac.getBean("memberService", MemberService.class);
Member member = new Member("A", 20);
memberService.join(member);
}
}
위 코드에서, AnnotationConfigWebApplicationContext 객체를 스프링 컨테이너라고 한다.
Context Hierarchy(계층 구조)
대부분의 웹 애플리케이션은 하나의 웹 애플리케이션에서 단일 WebApplicationContext를 사용하는 것으로 충분하다. 그러나, 더 복잡한 경우에는 Context 계층 구조를 사용할 수 있다.
Context 계층 구조란, 하나의 Root WebApplicationContext가 여러 DispatcherServlet(또는 다른 서블릿)과 공유되고, 각 서블릿마다 자체적인 자식 WebApplicationContext을 가지는 구조이다.
Context 계층 구조
Root WebApplicationContext
Root WebApplicationContext는 데이터 저장소, 비즈니스 서비스와 같이 여러 서블릿에서 공유해야 하는 인프라 빈을 포함
Root WebApplicationContext의 빈은 자식 WebApplicationContext에서 상속됨
Servlet WebApplicationContext (자식 WebApplicationContext)
각 서블릿에 특화된 빈을 포함
Root WebApplicationContext의 빈을 상속함
필요에 따라 Root WebApplicationContext의 빈을 재정의 가능
참고 자료
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet.html
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/special-bean-types.html
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/context-hierarchy.html
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/container-config.html
https://www.inflearn.com/questions/226425/servletcontext%EC%99%80-webapplicationcontext%EC%9D%98-%EA%B4%80%EA%B3%84-%EC%A7%88%EB%AC%B8
https://blog.naver.com/kitepc/221314687808
https://escapefromcoding.tistory.com/174
https://mangkyu.tistory.com/18
https://kimcoder.tistory.com/511
https://recordsoflife.tistory.com/490
https://blog.naver.com/PostView.nhn?blogId=inho1213&logNo=220516464519&parentCategoryNo=&categoryNo=19&viewDate=&isShowPopularPosts=false&from=postView
https://live-everyday.tistory.com/164
https://velog.io/@ruinak_4127/Context
https://dev-wnstjd.tistory.com/440
https://devlogofchris.tistory.com/71
https://unordinarydays.tistory.com/131
-
(Spring) - Spring MVC 요청 처리 과정 (+Interceptor)
Spring MVC 요청 처리 과정 (인터셉터 X)
Client가 Web Server로 HTTP Request 보냄
정적 콘텐츠의 경우 Web Server에서 처리, 동적 콘텐츠의 경우 Web Server가 Servlet Container로 요청 전달 - (1)
요청을 받은 Servlet Container는 HttpServletRequest, HttpServletResponse 객체를 생성
Servlet Container는 요청을 처리할 Thread를 Thread Pool에서 가져옴
Thread는 DispatcherServlet의 doDispatch() 메소드 호출(doDispatch() 메소드를 호출하기까지의 과정은 생략) - (2)
protected void doDispatch(HttpServletRequest request,
HttpServletResponse response)
throws Exception
{
...
// 5-1. 핸들러 조회 - (3)
// 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러) 조회
mappedHandler = this.getHandler(processedRequest);
if (mappedHandler == null) {
this.noHandlerFound(processedRequest, response);
return;
}
...
// 5-2. 핸들러 어댑터 조회
// 핸들러를 실행할 수 있는 핸들러 어댑터 조회
HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
...
// 5-3. 핸들러 어댑터 실행 - (4)
// 핸들러 어댑터가 핸들러 실행
// 핸들러 어댑터는 핸들러가 반환하는 정보를 ModelAndView로 변환해서 반환
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
...
this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);
}
private void processDispatchResult(HttpServletRequest request,
HttpServletResponse response,
@Nullable HandlerExecutionChain mappedHandler,
@Nullable ModelAndView mv,
@Nullable Exception exception)
throws Exception
{
...
this.render(mv, request, response);
...
}
protected void render(ModelAndView mv,
HttpServletRequest request,
HttpServletResponse response)
throws Exception
{
String viewName = mv.getViewName();
View view;
...
// 5-4. 뷰 찾기 - (5)
// 뷰 리졸버를 통해 뷰 찾기
// 뷰 리졸버는 View 반환
view = this.resolveViewName(viewName, mv.getModelInternal(), locale, request);
...
// 5-5. 뷰 렌더링
// HTML 등의 응답 생성
// 주로 템플릿 엔진을 통해 동적으로 생성
view.render(mv.getModelInternal(), request, response);
}
5-6. 응답 값을 HttpServletResponse 객체에 담음
Servlet Container는 응답 값을 HTTP Response로 바꾼 후 Web Server로 전송
HttpServletRequest, HttpServletResponse 객체를 소멸시키고 Thread를 종료
Spring MVC 요청 처리 과정 (인터셉터 O)
Client가 Web Server로 HTTP Request 보냄
정적 콘텐츠의 경우 Web Server에서 처리, 동적 콘텐츠의 경우 Web Server가 Servlet Container로 요청 전달 - (1)
요청을 받은 Servlet Container는 HttpServletRequest, HttpServletResponse 객체를 생성
Servlet Container는 요청을 처리할 Thread를 Thread Pool에서 가져옴
Thread는 DispatcherServlet의 doDispatch() 메소드 호출(doDispatch() 메소드를 호출하기까지의 과정은 생략) - (2)
protected void doDispatch(HttpServletRequest request,
HttpServletResponse response)
throws Exception
{
...
// 5-1. 핸들러 조회 - (3)
// 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러) 조회
mappedHandler = this.getHandler(processedRequest);
if (mappedHandler == null) {
this.noHandlerFound(processedRequest, response);
return;
}
...
// 5-2. 핸들러 어댑터 조회
// 핸들러를 실행할 수 있는 핸들러 어댑터 조회
HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
...
// 5-3. 인터셉터 호출(핸들러 어댑터 호출 전) - (4)
// applyPreHandle() 메소드에서 preHandle() 메소드 호출
// preHandle()의 반환 값이 true : 다음으로 진행함
// preHandle()의 반환 값이 false : 다음으로 진행하지 않음
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
...
// 5-4. 핸들러 어댑터 실행 - (5)
// 핸들러 어댑터가 핸들러 실행
// 핸들러 어댑터는 핸들러가 반환하는 정보를 ModelAndView로 변환해서 반환
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
...
// 5-5. 인터셉터 호출(핸들러 어댑터 호출 후) - (6)
// applyPostHandle() 메소드에서 postHandle() 메소드 호출
mappedHandler.applyPostHandle(processedRequest, response, mv);
...
this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);
}
private void processDispatchResult(HttpServletRequest request,
HttpServletResponse response,
@Nullable HandlerExecutionChain mappedHandler,
@Nullable ModelAndView mv,
@Nullable Exception exception)
throws Exception
{
...
this.render(mv, request, response);
...
// 5-8. 인터셉터 호출(뷰 렌더링 후) - (8)
// triggerAfterCompletion() 메소드에서 afterCompletion() 메소드 호출
if (mappedHandler != null) {
mappedHandler.triggerAfterCompletion(request, response, (Exception)null);
}
}
protected void render(ModelAndView mv,
HttpServletRequest request,
HttpServletResponse response)
throws Exception
{
String viewName = mv.getViewName();
View view;
...
// 5-6. 뷰 찾기 - (7)
// 뷰 리졸버를 통해 뷰 찾기
// 뷰 리졸버는 View 반환
view = this.resolveViewName(viewName, mv.getModelInternal(), locale, request);
...
// 5-7. 뷰 렌더링
// HTML 등의 응답 생성
// 주로 템플릿 엔진을 통해 동적으로 생성
view.render(mv.getModelInternal(), request, response);
}
5-9. 응답 값을 HttpServletResponse 객체에 담음
Servlet Container는 응답 값을 HTTP Response로 바꾼 후 Web Server로 전송
HttpServletRequest, HttpServletResponse 객체를 소멸시키고 Thread를 종료
참고 자료
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/context-hierarchy.html#page-title
https://codevang.tistory.com/248
https://gowoonsori.com/blog/spring/architecture/
https://velog.io/@suhongkim98/DispatcherServlet%EA%B3%BC-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EC%83%9D%EC%84%B1-%EA%B3%BC%EC%A0%95
https://jaeseo.tistory.com/entry/DispatcherServlet-%EB%8F%99%EC%9E%91-%EA%B3%BC%EC%A0%95%EA%B3%BC-Special-Bean-Types
https://jeonyoungho.github.io/posts/Spring-MVC-%EB%8F%99%EC%9E%91-%EA%B3%BC%EC%A0%95/
https://jaeseo.tistory.com/entry/DispatcherServlet-%EB%8F%99%EC%9E%91-%EA%B3%BC%EC%A0%95%EA%B3%BC-Special-Bean-Types
https://yoonbing9.tistory.com/80
https://velog.io/@seculoper235/3
https://appleg1226.tistory.com/31
-
(Tomcat) - Tomcat 살펴보기
Tomcat 설치 (Ubuntu)
다운로드
~$ wget https://dlcdn.apache.org/tomcat/tomcat-9/v9.0.89/bin/apache-tomcat-9.0.89.tar.gz
tar.gz 압축 해제
~$ tar -zxvf apache-tomcat-9.0.89.tar.gz
다운로드 완료
Tomcat 시작
~/apache-tomcat-9.0.89/bin$ ./startup.sh
http://localhost:8080 에서 확인
Tomcat 중지
~/apache-tomcat-9.0.89/bin$ ./shutdown.sh
Tomcat 디렉토리 구조
bin : Tomcat의 시작, 중지 및 기타 관리 스크립트가 포함된 디렉토리
주요 파일
startup.sh : Tomcat 시작 스크립트
shutdown.sh : Tomcat 중지 스크립트
catalina.sh : 주요 제어 스크립트
conf : Tomcat의 설정 파일들이 포함된 디렉토리
주요 파일
server.xml : Tomcat의 전반적인 설정 파일
web.xml : 모든 웹 애플리케이션에 대한 전역 설정 파일
context.xml : 개별 웹 애플리케이션에 대한 설정 파일
lib : Tomcat을 작동하는 데 필요한 라이브러리들(.jar)이 포함된 디렉토리
logs : Tomcat의 로그 파일들이 저장되는 디렉토리
주요 파일
catalina.out : Tomcat의 표준 출력 및 표준 오류 로그 파일
temp : Tomcat이 실행 중 사용하는 임시 파일들(ex) 업로드, 캐시 파일)이 저장되는 디렉토리
webapps : 배포된 웹 애플리케이션들이 위치하는 디렉토리
work : JSP 파일을 서블릿 형태로 변환한 .java 파일과 이를 컴파일한 결과인 .class 파일이 저장되는 디렉토리
JSP는 처음 요청될 때 서블릿(.java)으로 변환되고, 이후 재사용을 위해 컴파일된 서블릿(.class)이 저장된다.
webapps 디렉토리
각 웹 애플리케이션은 webapps 디렉토리 내에 하나의 디렉토리로 존재하며, 각 웹 애플리케이션은 특정한 디렉토리 구조를 따른다.
war 파일을 webapps 디렉토리에 배치(배포)하면, Tomcat은 자동으로 war 파일의 압축을 푼다.
webapps 디렉토리 내 웹 애플리케이션 디렉토리(war 파일 압축 푼 결과)의 구조
WEB-INF : 웹 애플리케이션의 핵심 설정 파일과 자원을 포함하는 디렉토리
클라이언트가 직접 접근할 수 없다.
주요 하위 디렉토리와 파일
web.xml : 웹 애플리케이션의 서블릿, 필터, 리스너, 초기화 매개변수 등의 설정 파일
classes 디렉토리 : 웹 애플리케이션의 컴파일된 파일(.class)들이 위치하는 디렉토리
lib 디렉토리 : 웹 애플리케이션이 사용하는 외부 라이브러리 파일(.jar)들이 위치하는 디렉토리
META-INF : JAR 파일 및 웹 애플리케이션 메타데이터를 포함하는 디렉토리
기타 정적 자원 디렉토리 : 정적 파일(css, js 등)들을 포함하는 디렉토리
클라이언트가 직접 접근할 수 있다.
conf/web.xml과 webapps/WEB-INF/web.xml
공통점 : 웹 애플리케이션의 설정을 정의
서블릿, 필터, 리스너, 초기화 매개변수 등을 정의한다.
차이점 : 적용 범위와 목적
conf/web.xml : Tomcat에서 실행되는 모든 웹 애플리케이션에 적용되는 기본 설정
각 개별 웹 애플리케이션의 web.xml 파일에 정의되지 않은 설정은 여기에서 정의된 기본 설정을 따른다.
기본 서블릿이나 필터 설정을 제공하여, 모든 애플리케이션이 공통적으로 사용해야 하는 서블릿 또는 필터를 정의할 수 있다.
공통적인 보안 설정이나 JNDI 리소스 등의 설정을 정의할 수 있다.
webapps/WEB-INF/web.xml : 개별 웹 애플리케이션에만 적용되는 설정
conf/web.xml에서 정의된 전역 설정을 덮어쓰거나 추가한다.
예를 들어, 특정 서블릿이나 필터가 동일한 이름으로 두 파일에 모두 정의되어 있으면, webapps/WEB-INF/web.xml에 정의된 설정이 우선이다.
참고 자료
https://tomcat.apache.org/tomcat-5.5-doc/appdev/deployment.html
https://tomcat.apache.org/download-90.cgi
https://stackoverflow.com/questions/70216/whats-the-purpose-of-meta-inf
https://www.youtube.com/watch?v=WdBAto3IQOg&list=PLqaSEyuwXkSoeqnsxz0gYWZMihw519Kfr&index=39
https://www.youtube.com/watch?v=K84mSiC_q6I&list=PLqaSEyuwXkSoeqnsxz0gYWZMihw519Kfr&index=40
https://www.youtube.com/watch?v=aP4Lw3SfffQ&list=PLqaSEyuwXkSoeqnsxz0gYWZMihw519Kfr&index=6
https://lifesteps.tistory.com/84
https://jake-seo-dev.tistory.com/436
https://velog.io/@xangj0ng/Linux-Ubuntu-Tomcat-%EC%84%A4%EC%B9%98
https://jokerkwu.tistory.com/117
https://xzio.tistory.com/1345
https://infoinhere.tistory.com/85
https://yangbox.tistory.com/16
-
데이터베이스 서버 해킹 사건
개발의 테스트 용도로, EC2 인스턴스에서 MySQL 컨테이너를 실행하여 사용하고 있었다. (로컬에서는 데이터베이스 관리 도구로 DBeaver를 사용 중이다.)
그런데.. 어제까지만 해도 잘 사용하고 있었는데 오늘 DBeaver를 켜보니..?
연결이 거부되었다고 한다. 컨테이너에 문제가 생긴 것 같아 EC2 인스턴스에서 컨테이너가 실행 중인지 확인해봤더니 컨테이너가 중지되어 있었다. 컨테이너가 중지된 이유를 확인하기 위해 컨테이너의 로그를 확인해보았다.
어제와 오늘 사이에 발생한 로그가 있었고, 위의 세부 설명처럼 MySQL 서버가 root 사용자로부터 종료 명령을 받았다고 한다. (MySQL 컨테이너는 그 안에서 실행되는 프로세스가 종료되면 종료된다.)
-> 나는 종료한 적이 없다. 따라서 다른 누군가가 MySQL 서버에 root 사용자로 로그인하여 종료했다는 것이므로, 해킹이 의심되었다.
컨테이너를 재시작하고 접속한 후, MySQL 서버에 root 사용자로 로그인하여 데이터베이스를 확인해보았다.
아니나 다를까, 내가 사용 중이던 “test” 데이터베이스가 없었다. 대신에 “RECOVER_YOUR_DATA”라는 데이터베이스가 있었고, 이 데이터베이스 안에 같은 이름의 테이블이 있었다. 이 테이블에 저장된 데이터는 위의 내용과 같은데, 요약하자면 “데이터를 살리고 싶으면 0.0123BTC를 달라”는 것이었다. (찾아보니 랜섬웨어라고 한다.)
-> 그러나, 테스트용 데이터 밖에 없어서 전혀 살릴 필요가 없었다.
컨테이너에 접속해서 MySQL 로그(/var/log/mysqld.log)도 확인해보려고 했지만, 아쉽게도 mysqld.log 파일은 비어있었다.
MySQL은 기본적으로 로그 기록하는 부분이 OFF 처리되어 있기 때문이다.
내 데이터베이스 서버를 해킹한 이유는 모르겠지만, 해킹 당한 이유를 추측해보면 다음과 같다.
EC2 인바운드 규칙에서 누구나 3306 포트로 접근 가능하도록 설정한 것 + root 사용자의 비밀번호를 1234로 설정한 것
앞으로 이런 상황을 염두해서 다음 사항들을 고려하여 개발해나가려고 한다.
백업 설정 + root 사용자의 비밀번호 설정 + 포트 번호 설정
-
API
API는 “소프트웨어 간의 인터페이스 또는 소프트웨어와 하드웨어 간의 인터페이스”라고 말할 수 있다.
“소프트웨어 간의 인터페이스” 의미에 초점을 두고 API를 좀 더 구체적으로 얘기해보자면 다음과 같다.
A 소프트웨어가 B 소프트웨어의 다양한 기능 중 하나를 이용하려고 할 때, A 소프트웨어가 B 소프트웨어에 어떻게 접근하는 지, 어떤 정보를 전달하고 어떤 정보를 받는 지 등의 통신 방법 집합을 API라고 하는 것이다. 그리고 API에 대해 기술하는 문서나 표준, 즉 설명서를 “API 문서(사양, 규격)“라고 한다.
API란?
API(Application Programming Interface) : 어떤 소프트웨어의 기능을 사용할 수 있도록 해주는 인터페이스
클라이언트는 API를 통해 다른 소프트웨어(라이브러리, 외부 서비스, 운영체제 등)에서 제공하는 기능을 사용할 수 있다.
API 제공 예시 1 : 라이브러리 API
-> 라이브러리는 기능을 호출하고 사용할 수 있도록 해주는 API를 제공한다.
-> 클라이언트에서 라이브러리에 실질적으로 접근하는 방법은 함수 호출을 통한 접근이다.
ex) Mongoose, Numpy, Pandas 등
API 제공 예시 2 : 외부 서비스 API
-> 외부 서비스는 기능을 호출하고 사용할 수 있도록 해주는 API를 제공한다.
-> 클라이언트에서 외부 서비스에 실질적으로 접근하는 방법은 대부분 URL(엔드포인트)과 HTTP 요청을 통한 접근이다.
ex) Google Maps API, Twitter API 등
API 제공 예시 3 : 애플리케이션 서버 API
-> 애플리케이션 서버는 기능을 호출하고 사용할 수 있도록 해주는 API를 제공한다.
-> 클라이언트에서 애플리케이션 서버에 실질적으로 접근하는 방법은 대부분 URL(엔드포인트)과 HTTP 요청을 통한 접근이다.
ex) 데이터 저장, 검색 등의 비즈니스 로직 => 애플리케이션(모바일, 웹)에서 사용할 백엔드 기능
Web API : 웹 기술을 기반으로 한 API
ex) HTTP API, WebSocket API 등
HTTP API : HTTP 프로토콜을 사용하여 통신하는 API
다양한 클라이언트에서 HTTP API를 호출하여 데이터(주로 JSON 형식)만 주고 받는다. UI 화면이 필요하면 클라이언트가 별도로 처리한다.
앱 클라이언트(모바일 앱, PC 앱)
웹 클라이언트(React, Vue.js 등)
웹 브라우저에서 자바스크립트를 통한 HTTP API 호출
서버에서 HTTP API 호출
외부 서비스에서 제공하는 API, 애플리케이션 서버에서 제공하는 API 등 우리가 사용하는 API는 대부분 HTTP API이다.
참고 자료
https://ko.wikipedia.org/wiki/API
https://en.wikipedia.org/wiki/API
https://aws.amazon.com/ko/what-is/restful-api/
https://www.ibm.com/kr-ko/topics/api
https://ko.wikipedia.org/wiki/%EC%9B%B9_API
https://www.guru99.com/ko/what-is-api.html
https://www.quora.com/What-is-a-Web-API
https://www.quora.com/Is-an-API-just-a-library
https://www.inforad.co.kr/single-post/api-library
chatgpt
bard
-
Web Server, Application Server
웹 서버란?
웹 서버 : 일반적으로, 클라이언트(웹 브라우저)로부터 HTTP 요청을 받아들이고 정적 리소스를 반환하는 서버
웹 서버는 정적 리소스 제공은 물론, 기타 부가기능도 제공한다.
웹 서버는 동적 리소스를 요청 받으면 애플리케이션 서버에게 해당 요청을 넘겨주고, 애플리케이션 서버에서 처리한 결과를 받아 클라이언트에게 전달해주는 역할을 한다.
웹 서버도 애플리케이션 서버와 같이 프로그램을 실행하는 기능을 포함할 수도 있다.
정적 리소스 : HTML 문서, CSS, JavaScript, 이미지, 파일 등
정적 리소스는 일반적으로 웹 서버의 저장 장치에 저장되어 있고, 항상 동일한 내용을 반환한다.
-> 정적 웹 페이지는 이러한 정적 리소스를 기반으로 구성된다.
동적 리소스 : 검색 결과, 실시간 데이터 등
동적 리소스는 주로 데이터베이스와 상호 작용하여 동적으로 생성되고, 항상 동일한 내용을 반환하지 않는다.
-> 동적 웹 페이지는 이러한 동적 리소스를 기반으로 구성된다.
웹 서버 작동 방식 - 정적 웹 페이지 요청
브라우저는 URL을 사용하여 웹 서버의 IP 주소를 찾는다.
브라우저는 정보에 대한 HTTP 요청을 보낸다.
웹 서버는 저장 장치에서 관련 데이터를 찾는다.
웹 서버는 HTTP 응답으로 HTML, 이미지, 파일과 같은 정적 리소스를 브라우저에 반환한다.
브라우저가 정보를 표시한다.
이미지 파일 등의 정적인 리소스들은 HTML 문서가 클라이언트로 보내질 때 함께 가는 것이 아니다.
클라이언트는 HTML 문서를 먼저 받고, HTML 문서 안에 포함된 이미지 파일이나 기타 정적인 리소스들에 대한 URL을 통해 서버로 추가 요청을 보내서 받아온다.
애플리케이션 서버란?
애플리케이션 서버 : 일반적으로, 클라이언트의 요청에 따라 비즈니스 로직(사용자의 요구사항을 해결하기 위한 실질적인 코드)을 실행하여 데이터 처리 등 다양한 서비스를 제공하는 서버
애플리케이션 서버는 주로 데이터 처리와 같은 작업을 담당하는데, 이를 위해 데이터베이스 서버(대부분), 외부 서비스 API 등과 상호 작용한다.
즉, 애플리케이션 서버는 데이터베이스 등의 소프트웨어 구성 요소와 상호 작용하고 비즈니스 로직을 실행할 수 있는 런타임 환경을 제공한다.
애플리케이션 서버에서 제공하는 API
애플리케이션 서버는 클라이언트에게 API, 웹 페이지 등 다양한 서비스를 제공한다.
-> 애플리케이션 서버는 데이터 처리 등의 비즈니스 로직의 실행을 담당하는데, API는 이러한 애플리케이션 서버의 기능을 외부에 노출하여 클라이언트가 사용할 수 있도록 하는 인터페이스이다.
-> 즉, 애플리케이션 서버는 API를 통해 클라이언트(모바일 앱 등)에게 데이터와 기능을 제공한다.
일반적으로, 애플리케이션 서버와 웹 애플리케이션 서버는 같은 용어이다. 웹 애플리케이션 서버의 의미는 주로 웹 애플리케이션에서의 애플리케이션 서버의 의미로 사용되며, HTTP 프로토콜을 사용하는 모든 종류의 애플리케이션 서버(API를 제공하는 애플리케이션 서버 등)의 의미도 포함한다.
애플리케이션 서버는 웹 서버가 할 수 있는 대부분의 작업(HTTP 요청 처리, 정적 리소스 제공 등) 또한 수행할 수 있다.
그러나, 이상적인 웹 서비스 아키텍처는 웹 서버와 함께 사용하는 것이다. 이러한 아키텍처에서 애플리케이션 서버는 주로 웹 서버로부터 요청을 받아들이고, 해당 요청에 따라 비즈니스 로직을 실행한다. 그 후에 처리된 결과를 웹 서버로 전달하고, 웹 서버가 이를 사용자에게 전송한다.
API만 제공하는 애플리케이션 서버는 굳이 웹 서버를 함께 사용하지 않아도 된다.
애플리케이션 서버 작동 방식(웹 서버 x) - 동적 웹 페이지 요청
브라우저는 URL을 사용하여 애플리케이션 서버의 IP 주소를 찾는다.
브라우저는 정보에 대한 HTTP 요청을 보낸다.
애플리케이션 서버는 데이터베이스 서버 등의 외부 시스템과 상호 작용하며 비즈니스 로직을 수행한다.
애플리케이션 서버는 주로 HTML 문서를 동적으로 생성(서버 측 렌더링)하고 브라우저에 반환한다.
브라우저가 정보를 표시한다.
서버 측 렌더링(Server-Side Rendering, SSR)
-> 서버에서 HTML 최종 결과를 생성해서 클라이언트(웹 브라우저)에 전달하고, 이후 클라이언트에서는 간단한 렌더링 작업을 처리하는 방식
-> 주로 화면이 정적이고, 복잡하지 않을 때 사용
-> 관련 기술: JSP, 타임리프 등
클라이언트 측 렌더링(Client-Side Rendering, CSR)
-> 서버에서 초기 렌더링을 수행하지 않고, 브라우저에서 모든 렌더링 작업을 처리하는 방식
-> 주로 화면이 동적이고, 복잡할 때 사용 (ex) 구글 지도)
-> 관련 기술: React, Vue.js 등
웹 서비스 아키텍처
웹 서비스는 다양한 아키텍처를 가질 수 있다.
클라이언트 - 웹 서버 - 데이터베이스 서버
클라이언트 - 애플리케이션 서버 - 데이터베이스 서버
클라이언트 - 웹 서버 - 애플리케이션 서버 - 데이터베이스 서버 : 이상적인 웹 서비스 아키텍처
애플리케이션 서버만 사용하지 않고, 웹 서버와 함께 사용하는 이유
기능을 분리하여 서버 부하 방지
애플리케이션 서버는 데이터베이스 조회 등의 다양한 비즈니스 로직을 처리하고, 웹 서버는 정적 리소스를 클라이언트에게 제공한다.
효율적인 리소스 관리
정적 리소스가 많이 사용되면 웹 서버를 증설하고, 동적 리소스가 많이 사용되면 애플리케이션 서버를 증설한다.
오류 화면 제공 가능
정적 리소스만 제공하는 웹 서버는 잘 죽지 않고, 비즈니스 로직을 처리하는 애플리케이션 서버는 잘 죽는다. 애플리케이션 서버나 데이터베이스 서버 장애 시 웹 서버가 오류 화면을 제공할 수 있다.
여러 대의 애플리케이션 서버를 연결 가능
앞 단의 웹 서버에서, 오류가 발생한 애플리케이션 서버를 이용하지 못하도록 한 후 애플리케이션 서버를 재시작함으로써 사용자는 오류를 느끼지 못하고 이용할 수 있다.
여러 웹 애플리케이션 서비스 가능
ex) 하나의 서버에서 PHP Application과 Java Application을 함께 사용할 수 있다.
참고 자료
https://en.wikipedia.org/wiki/Application_server
https://ko.wikipedia.org/wiki/웹_서버
https://ko.wikipedia.org/wiki/웹_애플리케이션_서버
https://aws.amazon.com/ko/compare/the-difference-between-web-server-and-application-server/
https://gmlwjd9405.github.io/2018/10/27/webserver-vs-was.html
https://gmlwjd9405.github.io/2018/10/29/web-application-structure.html
https://yozm.wishket.com/magazine/detail/1780/
https://youwjune.tistory.com/41
https://www.quora.com/What-is-the-difference-between-a-web-API-and-an-application-server
https://www.quora.com/Web-Applications-What-is-a-web-server
https://www.quora.com/Can-you-explain-server-side-rendering-in-the-simplest-possible-way
chatgpt
bard
-
Domain Name, DNS
도메인 네임, DNS란?
도메인 네임(Domain Name) : IP 주소에 매핑되는 문자열 주소 체계
컴퓨터는 IP 주소로 통신하는데, 도메인 네임은 서버(대부분 웹 서버)를 쉽게 찾을 수 있도록 해주는 주소라고 할 수 있다.
도메인 네임은 DNS를 통해 해당하는 IP 주소로 변환되어 웹 브라우저나 다른 응용 프로그램에서 서버를 찾을 수 있게 해준다.
도메인은 영구적으로 소유하는 개념이 아니라, 일정기간 임대하는 개념이다.
도메인 네임의 계층적 구조 : Subdomain.Domain.TLD
Subdomain(= Hostname)
Domain의 앞에 오는 추가적인 식별 요소로, 선택적으로 사용된다.
-> “www”가 대표적인 Subdomain이며, “blog”, “mail”, “shop” 등도 사용된다.
Domain(= Second-Level Domain)
TLD 앞에 위치하며, 일반적으로 특정 기업, 조직, 서비스를 식별하는데 사용된다.
-> “google”이나 “naver”가 Domain이다.
TLD(Top-Level Domain)
도메인 이름의 가장 오른쪽에 위치하며, 인터넷에서 특정 종류의 기관, 지역 또는 용도를 나타낸다.
-> “com”, “org”, “net” 등이 대표적인 TLD이며, “kr”(한국), “jp”(일본)과 같은 국가 코드도 TLD에 해당한다.
일반적으로 도메인 네임이라 하면 “Domain.TLD”를 의미한다.
Domain Name : naver.com
FQDN(Fully Qualified Domain Name) : 도메인 네임의 전체 이름을 표기하는 방식
FQDN : www.naver.com
DNS(Domain Name System) : 인터넷에서 도메인 네임을 IP 주소로 변환하는 전세계적인 거대한 분산 데이터베이스 시스템
DNS는 여러 DNS Server들이 서로 상호 작용하며 동작한다.
Domain Name Space : DNS가 저장 및 관리하는 계층적 구조
각 레벨은 그 하위 도메인에 관한 정보를 관리하는 구조를 가진다.
DNS 구성 요소
Stub Resolver ( = DNS Client)
Stub Resolver : 클라이언트 기기에 내장된 작은 DNS 클라이언트 소프트웨어
Stub Resolver는 사용자가 웹 브라우저나 다른 응용 프로그램을 통해 도메인 네임을 입력할 때 동작한다.
Stub Resolver는 수많은 Name Server(Root DNS Server, TLD DNS Server, SLD DNS Server)의 구조를 파악할 필요가 없다.
Stub Resolver 동작 과정 - 캐시 x
사용자가 웹 브라우저에 도메인 네임을 입력하면, 웹 브라우저는 사용자의 운영체제에서 설정된 Stub Resolver를 사용한다.
-> 운영체제에서 설정된 Stub Resolver는 현재 연결된 ISP의 Local DNS Server(= Resolver)와 통신한다.
Stub Resolver는 Resolver로 DNS Query를 전송한다.
Stub Resolver는 Resolver로부터 도메인 네임에 대한 IP 주소를 받아 웹 브라우저에 전달한다.
Resolver ( = Local DNS Server ) => ISP가 관리
Resolver : DNS Client로부터 받은 DNS Query를 처리하여 DNS Client에게 IP 주소를 반환하는 서버
Resolver는 도메인 네임에 대한 IP 주소를 찾기 위해 계층적으로 Name Server들에게 DNS Query를 전송한다.
Resolver는 이전에 처리된 DNS Query의 결과를 캐시에 저장하여, 동일한 DNS Query에 대한 응답 속도를 향상시킬 수 있다.
Resolver 동작 과정 - 캐시 x
DNS Client에서 Resolver에게 DNS Query를 전송한다.
Resolver는 먼저 Root DNS Server에게 DNS Query를 보낸다.
Root DNS Server는 TLD DNS Server의 IP 주소를 반환한다.
Resolver는 Root DNS Server로부터 받은 응답을 기반으로, TLD DNS Server에게 DNS Query를 보낸다.
TLD DNS Server는 SLD DNS Server의 IP 주소를 반환한다.
Resolver는 TLD DNS Server로부터 받은 응답을 기반으로, SLD DNS Server에게 DNS Query를 보낸다.
SLD DNS Server는 도메인 네임에 대한 IP 주소를 반환한다.
Resolver는 최종적으로 SLD DNS Server로부터 받은 도메인 네임에 대한 IP 주소를 DNS Client에게 전달한다.
Name Server
Name Server : Domain Name Space에 대한 정보를 가지고 있는 서버
Name Server는 계층적 구조로 조직되어 있는데, 이것은 Name Server 간의 상호 작용이 계층적이라는 의미이다.
Name Server는 데이터 저장 및 관리, 요청 처리 및 응답 구현 등의 역할을 수행한다.
Name Server는 Root DNS Server, TLD DNS Server, SLD DNS Server로 분류할 수 있다.
Root DNS Server
DNS의 최상위 계층에 위치하는 서버로, DNS Query 프로세스의 시작점이다.
TLD DNS Server의 IP 주소를 제공한다.
ICANN이 직접 관리하는 서버이며, 전세계에 13개의 Root DNS Server가 존재한다.
TLD(Top-Level Domain) DNS Server
TLD를 관리하는 서버이다.
ex) “com”, “net”
SLD DNS Server의 IP 주소를 제공한다.
일반적으로 특정 조직이나 단체에 의해 관리된다.
SLD(Second-Level Domain) DNS Server(= Authoritative DNS Server)
SLD를 관리하는 서버이다.
ex) “google(.com)”
도메인 네임에 대한 IP 주소를 제공한다.
일반적으로 도메인/호스팅 업체에 의해 관리된다.
일부 조직이나 기업, 또는 개인이 자체적으로 SLD DNS Server를 운영하거나 관리할 수도 있다.
DNS 동작 과정 예시
사용자가 웹 브라우저에 “www.google.com”을 입력한다.
먼저, 웹 브라우저와 운영체제는 캐시를 확인한다.
a. 이전의 결과가 이미 캐시에 저장되어 있는 경우 바로 해당 정보를 반환하고, 아래 과정을 생략한다.
b. 웹 브라우저 및 운영체제 캐시에 해당 정보가 없거나 만료된 경우, 웹 브라우저는 DNS Client를 사용하여 Resolver에게 “www.google.com”의 IP 주소를 요청한다.
Resolver는 캐시를 확인한다.
a. 이전의 결과가 이미 캐시에 저장되어 있는 경우 바로 해당 정보를 DNS Client에게 해당 정보를 반환하고, 아래 과정을 생략한다.
b. Resolver 캐시에 해당 정보가 없거나 만료된 경우, 아래 과정을 진행한다.
Resolver는 Root DNS Server에게 “com” 도메인에 대한 정보를 요청한다.
Root DNS Server는 “com” 도메인을 관리하는 TLD DNS Server의 IP 주소를 응답한다.
Resolver는 “com” 도메인을 관리하는 TLD DNS Server에게 “google” 도메인에 대한 정보를 요청한다.
TLD DNS Server는 “google” 도메인을 관리하는 SLD DNS Server의 IP 주소를 응답한다.
Resolver는 “google” 도메인을 관리하는 SLD DNS Server에게 “www” 도메인에 대한 정보를 요청한다.
SLD DNS Server는 “www.google.com”의 IP 주소인 “209.85.227.104”를 응답한다.
Resolver는 DNS Client에게 “209.85.227.104”를 전달하고, 최종적으로 DNS Client는 웹 브라우저에게 “209.85.227.104”를 전달한다.
참고 자료
https://ko.wikipedia.org/wiki/도메인_네임
https://ko.wikipedia.org/wiki/도메인_네임_시스템
https://www.cloudflare.com/ko-kr/learning/dns/glossary/what-is-a-domain-name/
https://aws.amazon.com/ko/route53/what-is-dns/
https://www.ibm.com/kr-ko/topics/dns
https://hanamon.kr/dns란-도메인-네임-시스템-개념부터-작동-방식까지/
https://velog.io/@qltkd5959/DNSDomain-Name-System란
https://velog.io/@dnwlsrla40/DNS-Domain-Name-System-zrombqvk
https://copycode.tistory.com/124
https://www.quora.com/What-are-domain-names
chatgpt
bard
-
-
웹
웹(World Wide Web, WWW, W3) : 인터넷에 연결된 컴퓨터를 통해 사람들이 정보를 공유할 수 있는 전 세계적인 정보 공간
웹은 여러 인터넷 서비스 중 하나라고 볼 수 있다.
인터넷에서 제공하는 서비스는 웹(HTTP) 등 여러 서비스들이 있다.
웹 페이지(Web Page) : 웹 상에 있는 단일 문서
아래의 내용들은 일반적인 웹 서비스 구조인 <클라이언트 - 웹 서버 - 애플리케이션 서버> 아키텍처를 바탕으로 정리하였다.
웹 페이지는 HTML, CSS, JavaScript 등으로 구성되어 있다.
웹 페이지들은 서로 하이퍼링크로 연결시킬 수 있으며, 하이퍼링크를 통해 다른 웹 페이지로 이동할 수 있다.
정적 웹 페이지 : 변경되지 않는 구조의 웹 페이지
정적 웹 페이지는 일반적으로 웹 서버에 저장되어 있고, 클라이언트가 요청할 때마다 동일한 내용을 제공한다.
ex) 회사 소개 웹 페이지(하이퍼링크 포함 or 포함하지 않음)
동적 웹 페이지 : 클라이언트의 요청에 따라 변경되는 구조의 웹 페이지
클라이언트 사이드 동적 웹 페이지 : 웹 브라우저에서 실행되는 스크립트에 의해 동적으로 생성되는 구조의 웹 페이지
웹 브라우저는 서버로부터 받은 초기 HTML 및 정적 리소스를 기반으로, 사용자와의 상호작용에 따라 동적으로 리소스를 업데이트하고 변경한다.
클라이언트 사이드 스크립트 언어 : 웹 브라우저에서 실행되는 스크립트 언어
JavaScript, TypeScript 등
ex) 구글 지도, 사용자가 입력한 텍스트를 모두 소문자로 변환해주는 웹 페이지
서버 사이드 동적 웹 페이지 : 애플리케이션 서버에서 실행되는 스크립트에 의해 동적으로 생성되는 구조의 웹 페이지
애플리케이션 서버는 클라이언트의 요청에 따라 동적으로 웹 페이지를 생성한 후 클라이언트로 전달한다.
서버 사이드 스크립트 언어 : 애플리케이션 서버에서 실행되는 스크립트 언어
PHP, JavaScript(Node.js), Java(Servlets, JSP) 등
ex) 데이터베이스를 조회하는 웹 페이지
웹사이트(Website) : 웹 페이지들의 의미 있는 묶음
웹사이트는 기업, 기관 등의 정보를 담고 있거나 서비스를 제공하는 데 사용된다.
공식적으로 접속할 수 있는 모든 웹사이트는 총체적으로 웹을 이루고 있다.
웹 브라우저(Web Browser) : 웹 서버와 통신하여 웹 페이지를 출력하는 GUI 기반의 응용 소프트웨어
웹 브라우저는 HTML, CSS, JavaScript를 해석하고 렌더링하는 역할을 한다.
웹 브라우저는 웹 서버와 대부분 URL과 HTTP(HTTPS)를 통해 웹 페이지를 가져올 뿐 아니라 웹 서버에 정보를 송신하기도 한다.
웹 애플리케이션, 웹 앱(Web Application) : 웹 브라우저를 통해 실행되는 응용 프로그램
참고 자료
https://ko.wikipedia.org/wiki/월드_와이드_웹
https://ko.wikipedia.org/wiki/웹_페이지
https://ko.wikipedia.org/wiki/동적_웹페이지
https://ko.wikipedia.org/wiki/웹사이트
https://ko.wikipedia.org/wiki/웹_브라우저
https://ko.wikipedia.org/wiki/웹_애플리케이션
chatgpt
-
서버리스
서버리스란?
서버리스 : 개발자가 서버 또는 백엔드 인프라를 프로비저닝하거나 관리하지 않고도 애플리케이션을 설계하고 실행할 수 있는 클라우드 컴퓨팅 애플리케이션 개발 모델
서버리스는 보통 “서버리스 컴퓨팅” 또는 “서버리스 아키텍처”로 불린다.
서버리스 컴퓨팅에서는 CSP가 서버를 관리 및 실행한다.
CSP는 운영 체제 관리, 보안 패치, 파일 시스템 및 용량 관리, 로드 밸런싱, 모니터링, 로깅과 같은 여러 작업을 처리한다.
개발자는 서버 관리에서 자유로워지며 실제 구현해야 할 기능에 더 집중할 수 있다.
즉, “서버리스”는 서버가 없는 것이 아니라, 개발자가 서버를 직접 관리할 필요가 없다는 의미이다.
서버리스 아키텍처 유형 - FaaS, BaaS
FaaS(Function as a Service) : 특정 이벤트가 발생할 때 코드를 실행하고 관리해주는 클라우드 컴퓨팅 서비스
개발자는 이벤트에 따라 실행되는 함수(코드)를 작성하고 클라우드 인프라에 배포(업로드)만 하면 된다.
함수(코드)는 이벤트(HTTP 요청, 파일 업로드 등)가 발생할 때만 실행되며, 실행 후에는 리소스가 자동으로 해제된다.
개발자는 코드를 실행하는 데 필요한 인프라를 구축하고 관리하지 않아도 된다.
ex) AWS Lambda, Amazon API Gateway, Google Cloud Functions
BaaS(Backend as a Service) : 백엔드 인프라(데이터베이스, 스토리지, 인증, 푸시 알림 등)를 제공하는 클라우드 컴퓨팅 서비스
개발자는 API를 사용하여 백엔드 기능에 액세스한다.
개발자는 백엔드 인프라를 구축하고 관리하지 않아도 된다.
ex) Amazon DynamoDB, AWS RDS, AWS S3, AWS Amplify, AWS SQS
서버리스 아키텍처 유형에는 FaaS와 BaaS가 있지만, 일반적으로 서버리스는 FaaS를 의미한다.
API 서버를 예시로 들어보면, (AWS EC2 - Amazon DynamoDB(BaaS)) 아키텍처와 (AWS Lambda(FaaS) - Amazon DynamoDB(BaaS)) 아키텍처가 있을 때, 후자를 서버리스 아키텍처라고 말한다.
참고 자료
https://aws.amazon.com/ko/serverless/
https://aws.amazon.com/ko/what-is/serverless-computing/
https://www.samsungsds.com/kr/insights/1232763_4627.html
https://www.ibm.com/kr-ko/topics/serverless
https://www.oracle.com/kr/cloud/cloud-native/functions/what-is-serverless/
https://www.redhat.com/ko/topics/cloud-native-apps/what-is-serverless
https://ko.wikipedia.org/wiki/서버리스_컴퓨팅
https://yozm.wishket.com/magazine/detail/2168/
chatgpt
-
데이터베이스 시스템
데이터베이스 시스템
데이터베이스 시스템 : 데이터를 저장하고 관리하는 모든 시스템
데이터베이스 시스템 구성 요소
데이터베이스 : 구조화된 데이터 집합
DBMS : 데이터베이스를 관리하는 소프트웨어 도구 집합
하드웨어 : 데이터베이스 시스템에서의 물리적 장치(CPU, 네트워크, 저장 장치 등)
클라이언트 : 데이터베이스에 접근하는 개인 또는 소프트웨어
데이터베이스(DB, Database)
데이터베이스 : 데이터의 효율적인 검색 및 갱신 등을 위해 구조화된 데이터의 집합
데이터베이스는 서로 관련 있는 데이터의 집합이다.
데이터베이스의 사전적인 의미는 구조화된 데이터의 집합이지만, 데이터베이스는 아래와 같이 여러가지 의미로 쓰인다.
구조화된 데이터 집합
DBMS
구조화된 데이터가 저장되는 저장 장치(스토리지)
-> 일반적으로, “DBMS” 의미로 자주 쓰인다. 해당 게시글에서는 1의 의미로 정리하였다.
데이터베이스 관리 시스템(DBMS, Database Management System)
DBMS : 데이터베이스 시스템의 핵심 부분으로, 사용자가 데이터베이스 내의 데이터를 접근할 수 있도록 해주는 소프트웨어 도구의 집합
DBMS는 데이터 관리(CRUD 등), 성능 최적화, 보안 및 권한 관리, 백업 및 복구 등의 작업을 수행한다.
DBMS마다 다양한 쿼리 언어(SQL 등)를 지원한다.
DBMS 구성 요소
데이터베이스 엔진(= 스토리지 엔진) : 데이터의 실제 저장 및 관리하는 역할
쿼리 언어 인터페이스, 쿼리 옵티마이저 등
DBMS 종류
RDBMS(Relational DBMS) : 스키마 필요
Table 형태로 데이터 저장
SQL 지원
ex) MySQL, PostgreSQL, SQLite, SQLServer, Oracle 등
NoSQL : 스키마가 없거나 유연한 스키마
Document 형태
파일(문서) 안에 JSON 형태로 데이터 저장
ex) MongoDB, Cloud Firestore
Key-Value 형태
Key-Value 형태로 데이터 저장
ex) Redis, DynamoDB
Column Family 형태
Table 등의 형태이지만, 유연하게 데이터 저장
ex) Cassandra, Hbase
Graph 형태
Node 안에 데이터와 Node 간의 관계 저장
ex) Neo4j
DBMS는 데이터베이스 서버 소프트웨어라고 할 수 있다. 즉, 데이터베이스 서버는 DBMS가 실행되는 컴퓨터 시스템(물리적 또는 가상적 환경)을 말한다.
-> 데이터베이스 서버는 DBMS 설치, 초기 설정, 외부 연결 허용 등의 단계를 거쳐 구축된다.
-> 데이터베이스 서버는 클라이언트로부터 데이터베이스 요청을 수신하고, 해당 요청을 처리하여 결과를 반환한다.
데이터베이스 시스템에서의 스토리지
일반적으로, 스토리지는 데이터를 저장하고 있는 저장 장치를 의미한다.
데이터베이스 시스템에서 스토리지란, 구조화된 데이터(데이터베이스)를 저장하고 있는 저장 장치를 의미한다.
데이터베이스 서버에서는 데이터베이스를 서버의 저장 장치에 직접 저장하거나 Cloud Storage에 저장한다.
-> 일반적으로, 개인 PC에서 데이터베이스 서버를 가동하면, 데이터베이스는 개인 PC의 저장 장치(HDD나 SSD)에 저장된다.
-> 일반적으로, AWS EC2 인스턴스에서 데이터베이스 서버를 가동하면, 데이터베이스는 Cloud Storage(ex) AWS EBS)에 저장된다.
데이터베이스 시스템에서 이미지와 같은 파일을 저장하는 방법
파일 자체를 데이터베이스 시스템의 스토리지에 저장
-> 파일을 데이터베이스 필드에 이진 데이터 형태로 저장한다.
파일 자체를 다른 스토리지에 저장
-> 파일을 다른 스토리지(ex) AWS S3)에 저장한 후, 저장된 파일의 위치(URI)를 데이터베이스 필드에 저장한다.
-> 일반적으로, 2의 방법이 효율적이다.
참고 자료
https://ko.wikipedia.org/wiki/데이터베이스_시스템
https://ko.wikipedia.org/wiki/데이터베이스_관리_시스템
https://survey.stackoverflow.co/2022
https://terms.tta.or.kr/dictionary/dictionaryView.do?subject=데이터베이스
https://jaemunbro.medium.com/nosql-데이터베이스-특성-비교-c9abe1b2838c
chatgpt
-
클라우드 스토리지(Block, File, Object)
Cloud Storage 유형
Cloud Storage Service에서 말하는 “블록 스토리지, 파일 스토리지, 객체 스토리지”라는 용어들은, Cloud Storage가 데이터를 저장하고 관리하는 기술을 의미한다.
블록 스토리지(ex) AWS EBS)는 데이터를 고정된 크기의 블록 단위로 나누어 저장하고 관리한다.
파일 스토리지(ex) AWS EFS)는 데이터를 파일 단위로 나누어 저장하고 관리한다.
객체 스토리지(ex) AWS S3)는 데이터를 객체 단위로 나누어 저장하고 관리한다.
각각의 스토리지 유형은 특정한 용도와 특성을 가지고 있으며, 어떤 스토리지 유형을 선택할지는 데이터의 특성과 요구 사항에 따라 다르다.
Cloud Storage Service에서 제공하는 데이터 처리 방식의 감춤, 추상화된 인터페이스 등은 사용자가 스토리지 유형 간의 차이를 잘 느끼지 못하게 한다.
일반적인 사용자 입장에서 각 스토리지 유형의 기술적인 측면에 집중할 필요가 없다. 사용자는 각 스토리지의 기술적인 측면보다는 데이터를 다루는 방식과 필요한 기능에 집중한다.
AWS Cloud Storage 예시
-> AWS EC2를 사용하여 가상 서버(인스턴스)를 생성할 때 기본 저장 장치로 AWS EBS(블록 스토리지)를 사용하는데, 이는 일반적인 컴퓨터에서의 하드 디스크와 비슷한 역할을 한다.
-> 대용량의 정적 파일이나 이미지 등을 저장하기 위해서 AWS S3(객체 스토리지)를 사용한다.
기술적 측면
일반적인 사용자 입장에서 각 스토리지 유형의 기술적인 측면에 집중할 필요는 없지만, 그래도 간단하게 살펴보자.
스토리지 유형
데이터 저장 및 관리 단위(논리적)
실제 데이터의 저장 공간(물리적)
블록 스토리지
블록
HDD, SSD 등의 저장 장치
파일 스토리지
파일
HDD, SSD 등의 저장 장치
객체 스토리지
객체
HDD, SSD 등의 저장 장치
일반적으로, “블록 스토리지, 파일 스토리지, 객체 스토리지”는 데이터를 저장하고 관리하는 기술을 설명하는 개념이다.
HDD와 SSD 등의 저장 장치는 블록 스토리지를 구현한 하드웨어라고 할 수 있으며, 파일 스토리지와 객체 스토리지는 블록 스토리지를 기반으로 한다.
파일 스토리지 ≒ 블록 스토리지 + 파일 시스템
객체 스토리지 ≒ 블록 스토리지 + 객체 스토리지 시스템
사용자가 PC에서 블록 스토리지가 아니라 파일 스토리지처럼 사용할 수 있는 이유
-> 기본적으로 운영 체제는 자체적인 파일 시스템을 가지고 있기 때문이다. 파일 시스템은 블록 단위로 저장되는 데이터를 파일 단위로 조직화하고 관리하여 사용자가 편리하게 파일을 다룰 수 있도록 해준다.
참고 자료
https://www.redhat.com/ko/topics/data-storage/file-block-object-storage
https://www.alibabacloud.com/ko/knowledge/difference-between-object-storage-file-storage-block-storage
https://www.alibabacloud.com/ko/knowledge/what-is-object-storage?spm=a2c64.255190.1234557720.5.3ef51326H49VXc
https://www.youtube.com/watch?v=YBc8Mx89Af0
https://www.iwinv.kr/storage/block.html
chatgpt
-
스토리지
스토리지란?
데이터 : 우리가 인식할 수 있는 어떤 것 (ex) 이미지, 텍스트, DB 테이블 등)
기억 장치 : 컴퓨터에서 데이터를 일시적 또는 영구적으로 보존하는 장치
주기억 장치
ex) RAM, ROM 등
주기억 장치는 휘발성(RAM) 또는 비휘발성(ROM)이다.
일반적으로, 주기억 장치를 메모리 또는 RAM이라고도 부른다.
보조기억 장치(= 저장 장치)
ex) HDD, SSD 등
보조기억 장치는 모두 비휘발성이다.
일반적으로, 스토리지는 (대용량) 저장 장치를 지칭한다.
스토리지 연결 방식
서버(또는 PC)와 스토리지 간의 연결 방식
직접 연결하거나 스토리지와 연결된 네트워크에 연결 (→ DAS, NAS, SAN)
인터넷을 통한 Cloud Service 이용 (→ Cloud Storage)
직접 연결 스토리지(DAS, Direct Attached Storage) 방식
DAS 방식은 스토리지를 서버의 HBA(Host Bus Adapter, 컴퓨터와 스토리지 또는 컴퓨터와 네트워크 장치 사이의 연결을 담당하는 하드웨어)에 전용 케이블로 직접 연결하는 방식이다.
일반적으로, 데이터를 블록 단위로 읽고 쓴다.(블록 레벨 엑세스)
서버에서 파일 시스템을 사용하면 파일 레벨 엑세스를 한다. 즉, 서버에서 파일 시스템을 직접 관리한다.
특징
일반적으로 서버와 직접적으로 연결되어 있어 다른 서버에서는 직접 액세스할 수 없다.
서버에 직접 외장 저장장치를 추가하므로 속도가 빠르고 확장이 쉽지만, 연결 수에 한계가 있다.
일반적으로 개인용 데스크탑, 작은 기업 또는 부서 수준에서 사용된다.
네트워크 결합 스토리지(NAS, Network Attached Storage) 방식
NAS 방식은 서버와 스토리지를 일반적인 네트워크(LAN, 인터넷)를 통해 연결하는 방식이다.
일반적으로, 파일이나 디렉토리 단위로 데이터를 읽고 쓴다.(파일 레벨 엑세스)
NAS 장치가 파일 시스템을 설정하고 관리한다. 즉, NAS 장치는 파일 서버라고 볼 수 있다.
특징
네트워크를 통해 데이터를 외부로 공유할 수도 있으며, 여러 장치들과 연결이 가능하다.
네트워크를 사용하기 때문에 대역폭으로 인한 전송 속도에 제한과 병목 현상이 일어날 수 있다.
RAID 기술을 사용하므로 저장된 데이터를 여러 하드 디스크에 분산하고 복제할 수 있다.
일반적으로 개인 사용자나 작은 비즈니스 등 작은 규모의 환경에서 사용되고, 파일 공유, 중앙 파일 관리, 백업, 멀티미디어 스트리밍 등에 주로 사용된다.
스토리지 영역 네트워크(SAN, Storage Area Network) 방식
SAN 방식은 서버와 스토리지를 전용 네트워크(SAN)를 통해 연결하는 방식이다.
일반적으로, 데이터를 블록 단위로 읽고 쓴다.(블록 레벨 엑세스)
특징
광케이블을 사용하기 때문에 데이터 접근이 빠르며, LAN을 사용하지 않아 네트워크 부하를 최소화할 수 있다.
관리가 어렵고, 네트워크 구성에 따라 전문 인력이 필요하다.
일반적으로 대규모 기업이나 데이터 센터에서 사용되고, 가상화, 데이터베이스 등 높은 성능과 가용성이 필요한 시스템에서 사용된다.
Cloud Storage
Cloud Storage : 일반적으로, 물리적인 스토리지가 CSP에 의해 관리되는 여러 개의 서버들에 걸쳐 있는 스토리지 모델
-> Cloud Storage는 가상화에 기반을 두고 있다.
-> 보통 파일은 한 개의 스토리지에 저장이 되는데, Cloud Storage에서는 한 개의 파일을 여러 대의 가상화된 물리 서버에 공통적으로 저장한다.
Cloud Storage 유형
사용자(서버 또는 PC)는 인터넷을 통해 Cloud Storage를 사용한다.
일반적으로, 데이터를 객체 단위로 읽고 쓴다.
Cloud Storage Service : Google Drive, Microsoft OneDrive, AWS S3 등
특징
사용자는 필요에 따라 저장 공간을 쉽게 확장할 수 있다.
데이터를 안전하게 백업하고 복구할 수 있는 기능을 제공한다.
사용자는 Public 또는 Private 네트워크 연결을 통해 액세스할 수 있다.
Cloud Computing을 통해서 작동하기 때문에 외부에서 쉽게 접속할 수 있고, 웹 서비스를 통해서 접속할 수도 있으며, API를 이용하여 애플리케이션으로 접속할 수도 있다.
참고 자료
http://wiki.hash.kr/index.php/스토리지
https://ko.wikipedia.org/wiki/기억_장치
https://ko.wikipedia.org/wiki/주기억장치
https://ko.wikipedia.org/wiki/고정_기억_장치
https://ko.wikipedia.org/wiki/직접_연결_저장장치
https://ko.wikipedia.org/wiki/네트워크_결합_스토리지
https://ko.wikipedia.org/wiki/스토리지_에어리어_네트워크
https://ko.wikipedia.org/wiki/호스트_어댑터
https://ko.wikipedia.org/wiki/클라우드_스토리지
https://aws.amazon.com/ko/what-is/nas/
https://aws.amazon.com/ko/what-is/cloud-storage/
https://www.ibm.com/kr-ko/topics/network-attached-storage
https://www.vmware.com/kr/topics/glossary/content/storage-area-network-san.html
https://server-talk.tistory.com/366
https://mindstation.tistory.com/152
https://www.youtube.com/watch?v=QVG-MK014ck
chatgpt
-
클라우드 컴퓨팅
Cloud Computing
클라우드 컴퓨팅 : 인터넷을 통해 가상의 컴퓨팅 리소스를 사용하는 기술
여기서 말하는 컴퓨팅 리소스 개념은 CPU, RAM, 스토리지 등의 하드웨어 리소스는 물론, 데이터베이스 서버, 프로그램 실행 환경, 각종 응용 프로그램 등도 포함한다.
클라우드 컴퓨팅의 주요 특징
사용자는 인프라 관리에 신경 쓸 필요가 없다.
사용자는 컴퓨팅 리소스에 언제 어디서든 접근이 가능하며, 다양한 장치에서도 접근할 수 있다.
사용자는 필요에 따라(On-demand) 컴퓨팅 리소스를 즉시 이용할 수 있고, 필요에 맞게 확장하거나 축소할 수 있어 비용 효율적이다.
Cloud Computing - 가상화
클라우드 컴퓨팅을 실현하기 위한 핵심 기술 중 하나가 가상화이다.
가상화 없이도 클라우드 컴퓨팅은 가능하지만, 클라우드 컴퓨팅을 더 효율적으로 구현하고 관리하기 위해 가상화 등 여러 기술들을 사용한다.
CSP는 가상화를 통해 하드웨어 리소스를 더욱 효율적으로 활용하고, 사용자는 필요에 따라 빠르게 확장할 수 있는 장점을 얻는다.
CSP는 물리적 서버의 하드웨어 리소스를 가상화하여 가상 서버로 분할하고, 이를 클라우드 환경에서 관리한다.
CSP는 데이터 센터를 관리하고, 가상 환경을 생성, 관리, 제공하는 역할을 한다.
가상화는 기술, 클라우드는 그 기술을 이용하여 제공되는 환경이라고 볼 수 있다.
Cloud Service
클라우드 컴퓨팅 모델(서비스 제공 형태) : 클라우드에서 무엇을 제공하는지
IaaS(Infrastructure as a Service)
인프라(Network, Storage, Computing 등) 제공
사용자는 OS 등을 직접 설치하고 애플리케이션을 개발
ex) AWS EC2, AWS S3 등
PaaS(Platform as a Service)
애플리케이션 개발 구성 요소(인프라, OS, 개발 환경 등) 제공
사용자는 애플리케이션 개발에 집중
ex) Google App Engine, Microsoft Azure App Service, Heroku 등
SaaS(Software as a Service)
완전한 서비스(인프라, OS, 사용할 애플리케이션) 제공
사용자는 소프트웨어 사용에 집중
ex) Gmail, Google Docs, DropBox, Slack 등
클라우드 컴퓨팅 배포 모델(서비스 유형) : 클라우드 컴퓨팅을 어떻게 제공하는지
Public Cloud
인터넷에 접속 가능한 모든 사용자를 대상으로 하는 클라우드 서비스 모델
공개형(클라우드)
낮은 비용과 높은 확장성
ex) AWS, Microsoft Azure, Google Cloud 등
Private Cloud
제한된 네트워크 상에서 특정 기업이나 특정 사용자만을 대상으로 하는 클라우드 서비스 모델
폐쇄형(온프레미스)
높은 초기 비용 및 유지 보수 비용
높은 수준의 커스터마이징 가능하며, 보안이 중요한 기업에서 선호
ex) 기업이 자체적으로 구축한 클라우드 환경
Hybrid Cloud
Public Cloud와 Private Cloud를 함께 사용하는 환경
ex) 기업에서 주요 데이터는 온프레미스에서 유지하고, 예측하기 어려운 트래픽(이벤트 또는 신규 서비스 등의 특정한 상황)에서는 Public Cloud를 활용
참고 자료
https://aws.amazon.com/ko/what-is-cloud-computing/
https://ko.wikipedia.org/wiki/클라우드_컴퓨팅
https://ko.wikipedia.org/wiki/컴퓨터_성능
https://www.samsungsds.com/kr/cloud-glossary/cloud-computing.html
https://times.postech.ac.kr/news/articleView.html?idxno=5086
https://velog.io/@noyohanx/0.-클라우드란-무엇일까
https://glossary.cncf.io/ko/cloud-computing/
https://selog.tistory.com/entry/가상화-Virtualization가상화-개념-쉽게-이해하기
https://www.youtube.com/watch?v=JjiYqBl2328&list=PLfth0bK2MgIan-SzGpHIbfnCnjj583K2m&index=1
https://www.youtube.com/watch?v=s75iONF6XFw&list=PLfth0bK2MgIan-SzGpHIbfnCnjj583K2m&index=3
chatgpt
-
Touch background to close