아키텍처는 여러 가지 방식으로 정의되고 이해될 수 있는 용어다. 가장 단순한 정의는 아래와 같다.
아키텍처란 어떤 경계 안에 있는 내부 구성요소들이 어떤 책임을 갖고 있고, 어떤 방식으로 서로 관계를 맺고 동작하는지를 규정하는 것이다.
아키텍처는 관계와 동작의 규정이므로 많고 다양하다. 그 중 웹 개발에 많이 사용하는 계층형 아키텍처와 오브젝트 중심 아키텍처에 대해 알아보았다.
2. 계층형 아키텍처
책임과 성격이 다른 것을 그룹으로 만들어 분리해두는 것을 아키텍처 차원에서는 계층형 아키텍처라고 한다. 보통 웹 기반의 계층은 데이터 액세스 계층, 서비스 계층, 프레젠테이션 계층, 이 세 개의 계층을 갖는다고 해서 3계층 애플리케이션, 3-tire 애플리케이션 이라고도 한다.
1) 데이터 액세스 계층(DAO)
데이터 액세스 계층은 DB, ERP, 레거시 시스템 등에 접근하는 역할을 주로 처리하는 계층이다.
2) 서비스 계층
핵심 비지니스 로직을 처리하는 계층이다. 이 계층의 클래스는 POJO로 작성되기에 객체지향적인 설계 기법이 적용된 코드를 통해 쉽게 테스트하고 유연하게 확장할 수 있다.
3) 프레젠테이션 계층
가장 복잡한 계층이며, 매우 다양한 기술과 프레임워크의 조합을 가질 수 있다. 엔터프라이즈 애플리케이션에서는 HTTP 프로토콜을 사용하는 서블릿이 바탕이 된다.
3. 계층형 아키텍처 설계 원칙
가장 중요한 설계 원칙은 응집도가 높으면서 다른 계층과는 낮은 결합도를 유지하는 것이다. 각 계층은 자신의 계층의 책임에만 충실해야 한다. 예를들어 아래의 코드는 다른 계층과의 결합도를 갖는 코드이다.
public ResultSet findUserByName(String name) throws SQLException;
서비스 계층이 DAO를 호출할 때 ResultSet를 처리해야 한다면, 이는 서비스 계층이 JDBC라는 특정 데이터 액세스 계층 기술에 종속되는 것이다.
예외도 마찬가지이다. SQLException은 Checked Exception이다. 서비스 계층에서는 이를 무시할 수 없기에 예외 상황을 분석하기 처리하는 코드를 만들어야 한다. 이 코드는 다음과 같이 수정돼야 한다.
public List<User> findUserByName(String name) throws DataAccessException;
User는 사용자 정보를 담는 오프젝트이므로 특정 계층에 종속되지 않는다. 결과는 이렇게 계층에 종속되지 않는 오브젝트로 반환해야 한다.
예외 또한 DataAccessException과 같은 런타임 예외로 만들어야 한다. 이를 통해 서비스 계층에서는 이에 대한 예외처리를 하지 않아도 된다.
이 원칙에 위배되는 흔한 실수 중 하나가 프레젠테이션 계층의 오브젝트를 서비스 계층으로 전달하는 것이다. HttpServeltRequest나 HttpSession과 같은 타입을 서비스 계층 인터페이스 메서드의 파라미터 타입으로 사용하면 안된다.
웹 방식이 아닌 클라이언트가 이 비지니스 로직을 사용해야 할 경우 재사용이 불가능하며, 단위테스트가 복잡해지는 단점이 있다.
4. DB/SQL 중심의 로직 구현 방식
쉽게 말해서 비지니스 로직을 SQL 을 통해 처리하는 방식이다. 로직이 복잡해지면 SQL이 복잡해지고, 하나의 트랜잭션 단위마다 하나 이상의 SQL이 생성되어야 한다. 이러한 방식은 자바를 DB와 연결해주는 단순한 인터페이스 도구로 전락시키는 것이다.
5. 거대한 서비스 계층 방식
복잡한 SQL을 피하면서, 핵심 비지니스 로직은 서비스 계층에서 처리하는 방식이다. DAO가 응답한 정보를 분석, 가공하는 것이 서비스 계층의 핵심 코드가 된다. 이처럼 어플리케이션 코드에 비지니스 로직이 담겨있기 때문에 자바 언어의 장점을 잘 활용하는 것이고, 테스트하기 용이하다.
하지만 DAO 계층의 SQL은 서비스 계층의 비지니스 로직의 필요에 따라 만들어지기 쉽기 때문에 강한 결합을 여전히 갖고 있다. 서비스 계층의 코드도 업무 단위로 만들어지므로 DAO를 공유할 수 있는 것을 제외하면 코드의 중복이 발생할 수 있다.
6. 오브젝트 중심 아키텍처
오브젝트를 만들고 오브젝트 구조 안에 정보를 담아서 각 계층 사이에 전달하게 하는 아키텍처 방식이다.
재사용하기 쉽고, 전 계층에서 일관된 구조를 유지한채 사용할 수 있다.
하지만 최적화된 SQL을 사용하지 못하고, 멤버필드가 많아지면 사용하지 않는 필드가 증가하므로 데이터 중심 아키텍처보다 성능이 떨어질 수 있다. 이러한 문제는 지연 로딩(Lazy Loading)을 통해 어느정도 해결할 수 있으며, 실제로 JPA, 하이버네이트와 같은 ORM 기술에서 지연 로딩을 많이 사용한다.
오브젝트에는 단순히 정보 뿐 아니라 기능도 함께 담고있어야 한다. 그래야 서비스 계층의 비지니스 로직에서 재사용할 수 있기 때문이다.
똑같은 기능의 객체를 매번 사용하기보다는 객체 하나를 재사용하는 편이 낫다. 실제로 Boolean.valueOf() 는 호출할 때마다 객체를 생성하지 않고 내부에 캐싱된 객체를 사용한다.
Boolean.valueOf("true");
우리는 문자열을 초기화할때 매번 new 생성자를 통해 생성하지 않는다. 하나의 String 인스턴스를 사용한다. 이 경우 JVM의 String pool이라는 문자열 풀에서 캐싱하게 되고, 재사용하게 된다.
String a = "abc";
String b = "abc";
System.out.println(a == b); // String Pool에서 조회 : true
String c = new String("abc");
String d = new String("abc");
System.out.println(c == d); // Heap 메모리에 새로 생성된 객체를 조회 : false
들어온 문자열이 로마 숫자인지를 체크하는 메서드이다. 보면 String.matches() 메서드가 호출되는데 내부에서 입력받은 문자열를 통해 Pattern 인스턴스를 생성한다. Pattern은 인스턴스 생성 비용이 높다. 즉, 이 메서드를 호출할 때마다 비용이 비싼 인스턴스를 생성 후 한번만 사용하고 버리는 것이다.
이를 개선하기 위해 Pattern 인스턴스를 캐싱해두고 재사용하는 코드로 변경하였다. 책에서는 실제 속도를 비교해보니 개선 전에 비해 6.5 배의 성능 향상을 가져왔다고 한다.
private static final Pattern ROMAN = Pattern.compile("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
static boolean isRomanNumber(String s){
return ROMAN.matcher(s).matches();
}
2.2. 오토박싱 거둬내기
오토박싱은 프리미티브 타입과 레퍼런스 타입을 섞어 쓸때 상호 변환해주는 기술이다. 코드에서 Long 타입의 sum과 long 타입의 i를 연산하는 과정에서 오토박싱이 발생하여 연산 결과를 Long 인스턴스로 생성하게 된다. Integer.MAX_VALUE가 '2의 31승 -1' 이므로, 이 만큼의 Long 인스턴스가 새로 생성되는 것이다. 실행 시간은 6초가 걸렸다.
Long sum = 0L;
long start = System.currentTimeMillis();
for(long i = 0; i <= Integer.MAX_VALUE; i++){
sum += i;
}
System.out.println(sum);
System.out.println(System.currentTimeMillis() - start);
불필요한 Long 인스턴스의 생성을 막으려면 오토박싱을 거둬내면 되며, sum의 타입을 Long에서 long으로 바꿔주면 된다. 수정 후 실행 시간 0.6초가 걸렸다. 오토박싱이 적용된 코드보다 약 10배 빨라졌다.
public static void main(String[] args) {
long sum = 0L; // 수정
long start = System.currentTimeMillis();
for(long i = 0; i <= Integer.MAX_VALUE; i++){
sum += i;
}
System.out.println(sum);
System.out.println(System.currentTimeMillis() - start);
}
3. 혼란을 야기할 수 있는 재사용
객체가 불변이라면 재사용해도 안전하다. 이러한 특성을 살려 자바의 기본 라이브러리에서 많이 활용하고 있는데 만약 개발자가 이런 활용 사실을 인지하지 못할 경우 오히려 혼란을 야기할 수 있다.
3.1. 어댑터
어댑터는 실제 작업은 뒷단 객체에 위임하고, 자신은 제2의 인터페이스 역할을 해주는 객체이다. 예를들어 Map 인터페이스의 keySet 메서드는 Map 안의 키를 전부 담은 어댑터를 반환한다. 다시말하면 키를 가진 객체를 새로 생성하는게 아닌 내부 객체를 재사용하여 키 정보를 반환하는 것이다.
앞서 keySet 메서드는 키 정보를 내부에서 가져온다고 했지만, 필자의 경우 키를 담은 객체를 생성하여 반환하는 줄 알았다. 만약 필자와 같이 keySet의 어댑터 특성을 몰랐다면 keySet() 메서드를 활용해 두개의 키 셋을 구하고 이를 각각 활용해야 하는 상황에서 아래와 같은 결과에 대해 혼란을 느낄 수 있을 것이다.
조슈아 블로크의 'Effective Java' 책을 읽고 제멋대로 정리한 내용입니다. :)
1. 개요
많은 클래스가 하나 이상의 자원에 의존한다. 가령 맞춤법 검사기는 사전에 의존하는데, 이런 클래스를 정적 유틸리티 클래스나 싱글톤 클래스 구현한 모습을 드물지 않게 볼 수 있다.
1) 정적 유틸리티 클래스로 구현한 예
public class SpellChecker {
// 의존 객체 생성
private static final KoreaDictionary dictionary = new KoreaDictionary();
private SpellChecker(){} // 외부 객체 생성 방지
public static boolean isValid(String word){
...
}
public static List<String> suggestions(String typo){
...
}
}
2) 싱글톤 클래스로 구현한 예
public class SpellChecker {
// 의존 객체 생성
private final KoreaDictionary dictionary = new KoreaDictionary();
// 싱글턴 패턴을 사용해 생성한 인스턴스
public static SpellChecker INSTANCE = new SpellChecker();
private SpellChecker(){
}
public static boolean isValid(String word){
...
}
public static List<String> suggestions(String typo){
...
}
}
2. 위 방식의 문제
두 방식 모두 한국어 사전만 사용한다면 큰 문제가 되지 않지만 다른 종류의 사전을 사용해야한다면 변경이 필요할 때마다 의존 객체 생성 부분 코드를 수정해야한다.
3. 수정자 메서드를 통한 문제 해결?
수정자 메서드를 추가하여 의존객체를 변경하는 방법이 있다. 이를 사용하면 사전의 종류가 바뀌는 건 맞지만, 멀티쓰레드 환경에서는 변경되는 상태 값을 공유하면 안되므로 사용해선 안된다.
그렇다면 이러한 클래스는 어떻게 구현해야할까? 바로 생성자를 통해 의존 객체를 주입해야한다.
4. 생성자를 통한 의존 객체 주입
public class SpellChecker {
private final Dictionary dictionary;
// 생성자를 통한 의존 객체 주입
public SpellChecker(Dictionary dictionary){
this.dictionary = dictionary;
}
public static boolean isValid(String word){
...
}
public static List<String> suggestions(String typo){
...
}
}
생성자를 통해 객체를 생성할 때만 의존 객체를 주입하고 있다. 의존 객체는 불변성을 갖게 되어 멀티 쓰레드 환경에서도 안심하고 사용할 수 있다.
KoreaDictionary 클래스 대신 Dictionary 인터페이스를 사용하고, KoreaDictionary와 같은 여러 사전 클래스들이 Dictionary 인터페이스를 구현하도록 했다. 만약 영어사전에 대한 맞춤법 검사 기능을 제공해야한다면 Dictionary 인터페이스를 구현한 EnglishDictionary 클래스를 만들고, 이를 외부에서 생성자를 통해 주입하면 된다. 이로써 의존 객체가 바뀌더라도 SpellChecker의 코드는 변경할 필요가 없게 되었다.
이렇게 의존성을 외부로부터 주입받는 패턴을 의존 객체 주입 패턴이라고 하며 이 패턴의 변형으로 자바 8에서 등장한 Supplier<T> 인터페이스가 있다. 한정적 와일드 카드 타입을 사용해 팩터리의 타입 매개변수를 제한하여 사용할 수 있으며, 클라이언트에서는 해당 타입의 하위 타입을 포함하여 무엇이든 생성할 수 있는 팩터리를 넘길 수 있다.
public class SpellChecker {
...
public SpellChecker(Supplier <? extends Dictionary> factory){
this.dictionary = factory.get();
}
...
}
SpellChecker spellChecker = new SpellChecker(() -> new KoreaDictionary());
※ 개방 폐쇄 원칙
SpellChecker의 맞춤법 검사 기능이 영어까지 '확장' 되었지만, SpellChecker의 코드 '변경'은 일어나지 않는다. 이처럼 '확장'에는 열려있고, '변경'에는 닫혀있는 것을 '개방 폐쇄 원칙' 이라고 한다.
5. 정리
클래스가 하나 이상의 객체에 의존한다면 확장성을 고려하여 싱글턴과 정적 유틸리티 클래스 형태로 사용하지 않는 것이 좋다. 의존 객체는 내부에서 직접 생성하지 말고 생성자 혹은 정적 팩터리 메서드를 통해 넘겨주는 것이 좋다. 의존 객체 주입은 변경에 대한 유연성, 재사용성, 테스트 용이성을 제공한다.
조슈아 블로크의 'Effective Java' 책을 읽고 제멋대로 정리한 내용입니다. :)
1. 개요
필자는 클래스를 정의할 때 매개변수가 많으면 Builder를, 매개변수가 많이 없으면 생성자 메서드를 사용한다. Setter 메서드는 존재 자체로 객체의 상태 변경 가능성을 열어두기 때문에 사용을 지양하고 있으며, 상태 변경이 필요할 경우 Setter 대신 다른 Update와 같이 다른 네이밍을 사용하여 '비지니스적으로 필요한 상태변경이 있을 수 있다' 라는 것을 암시해주었다.
이번 아이템에서는 필자가 알고있는 내용을 다시한번 정리했고, 빌더를 사용하는 이유와, Setter보다 어떤면에서 더 효율적인지를 이해하게 되었다.
2. 자바 빈즈 패턴
2.1. 자바 빈즈 패턴이란?
자바 빈즈 패턴이란 기본 생성자로 객체를 만든 후, Setter 메서드를 호출해 원하는 매개변수의 값을 결정하는 방식이다. 인스턴스를 만들기 쉽고, 사용법도 매우 간단하지만 이 방식은 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓인다는 매우 심각한 단점을 갖고 있다.
public class NutritionFact {
private int servingSize;
private int servings;
private int calories;
private int fat;
private int sodium;
private int carbohydrate;
public void setServingSize(int servingSize) {this.servingSize = servingSize;}
public void setServings(int servings) {this.servings = servings;}
public void setCalories(int calories) {this.calories = calories;}
public void setFat(int fat) {this.fat = fat;}
public void setSodium(int sodium) {this.sodium = sodium;}
public void setCarbohydrate(int carbohydrate) {this.carbohydrate = carbohydrate;}
}
2.2. 일관성이 뭔가요?
일관성(Consistency)이란 객체의 상태가 프로그램 실행 도중 항상 유효한 상태를 유지해야 한다는 원칙을 말한다. 즉, 객체 내부 상태가 항상 유효한 상태로 일관되게 유지되어야 한다는 뜻이다.
즉, 객체의 상태 값이 할당이 되지 않거나, 할당이 되더라도 유효한 상태가 아닌 것이다. 후자의 경우 은행 계좌 잔고를 예로 들 수 있는데, 잔고는 항상 0 이상의 값으로 일관되게 유지되어야 한다. 이를 위해 아래와 같이 유효성 검사 역할을 하는 비지니스 로직을 넣기도 한다. 이처럼 객체의 상태를 유효한 상태로 일관성 있게 유지하는 것이다.
public class BankAccount{
private double balance;
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
// 출금 시 balance에 대한 일관성 유지를 위해 유효성 검사하는 로직이 추가됨
public boolean withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return true;
}
return false;
}
...
}
2.3. 자바 빈즈 패턴은 왜 일관성이 무너지나요?
그럼 자바 빈즈 패턴을 사용할 경우 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태. 즉, 유효하지 않은 상태가 되는 이유는 뭘까? 필수적으로 설정되어야할 객체의 상태 값이 설정되지 않을 수 있기 때문이다. 이를 책에서는 '객체가 완전히 생성되기 전' 이라고 표현하고 있다.
예를들어 객체를 생성한 후 실수로 setServings()을 호출하지 않는다면 servings를 사용하는 어떤 비지니스 로직이 있을 때 예상치 못한 버그가 발생할 수 있다. 컴파일 단계에서 에러가 나는것도 아니기에 이를 사전에 인지하지 못한다면 버그를 찾기 힘들 수 있다. 참고로 아래 예제에서는 예외 또한 발생하지 않는다. int의 경우 기본 값인 0이 설정되기 때문이다. 이 모든 건 단순 Setter 메서드 호출 누락으로 인해 발생했다.
NutritionFact nutritionFact = new NutritionFact();
//nutritionFact.setServings(20); 실수로 누락
nutritionFact.setServingSize(1);
nutritionFact.setCalories(1);
nutritionFact.setFat(10);
nutritionFact.setCarbohydrate(10);
nutritionFact.setSodium(10);
...
int servings = nutritionFact.getServings();
if(servings >= 10){
// 1회 제공량이 10개 이상일 경우에 대한 비지니스 로직
}
그렇다면 위 단점을 극복하는 방법은 뭘까? 바로 일관성을 보장하는 빌더 패턴을 사용하는 것이다.
3. 빌더 패턴
빌더 패턴은 객체를 생성하는 과정을 보다 유연하게 하기 위한 디자인 패턴 중 하나로, Builder라는 내부 클래스를 통해 객체를 생성하는 방법이다.
NutritionFacts 의 생성자 메서드를 보면 알 수 있듯이 Builder를 통해서만 객체를 생성하고 있고, Builder의 생성자 메서드를 통해 servings나 servingSize와 같은 필수 값을 받도록 설정한다. 나머지 값은 체이닝 메서드 설정하도록 하고 있다.
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder{
// 필수 매개변수는 final
private final int servings;
// 선택 매개변수는 기본값으로 초기화
private int servingSize;
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public Builder(int servings){
this.servings = servings;
}
public Builder servingSize(int val){
servingSize = val;
return this;
}
public Builder calories(int val){
calories = val;
return this;
}
public Builder fat(int val){
fat = val;
return this;
}
public Builder sodium(int val){
sodium = val;
return this;
}
public Builder carbohydrate(int val){
carbohydrate = val;
return this;
}
public NutritionFacts build(){
return new NutritionFacts(this);
}
}
private NutritionFacts(Builder builder){
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
여기서 생성된 NutritionFacts 객체는 필수 값인 servings 값이 없을 수 없다. 누락시킬 경우 컴파일 단계에서 에러가 발생하기 때문이다. 즉, Builder를 통해 생성한 인스턴스는 일관성이 보장되는 것이다.
4. 자바 빈즈 패턴 + 명시적 생성자
그럼 자비 빈즈 패턴에서 명시적 생성자를 통해 필수 값을 설정하면 되지 않을까? 그래도 된다. 이 경우 필수 값에 대한 일관성을 보장할 수 있다.
public class NutritionFact {
private int servingSize;
private int servings;
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public NutritionFact(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate){
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
...
}
그리고 사용 시 아래처럼 생성자 메서드의 파라미터의 순서에 맞게 값을 입력하기만 하면 된다. 그런데 문제가 있다. 이것만 봐서는 어떤 파라미터가 객체의 멤버필드에 매핑되는지 바로 알 수 없다. 직접 생성자 메서드를 확인해야하고, 순서를 일일이 세어야 하는 수고가 필요하다.
NutritionFact nutritionFact = new NutritionFact(10, 10,1,1,1,1);
이에 반해 빌더 패턴은 체인 메서드를 통해 설정하기 때문에 어떤 멤버필드에 값을 설정하는지 바로 알 수 있어 가독성을 향상시킨다. 또한 Setter 메서드가 없으니 중간에 객체의 상태가 변경되지 않음을 보장한다. 즉, 안전한 객체가 되는것이다.
new NutritionFacts.Builder(10)
.calories(100)
.sodium(10)
.carbohydrate(10)
.servingSize(10)
.fat(1)
.build();
5. 정리
생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다. 매개변수 중 다수가 필수가 아니거나 같은 타입이면 특히 더 그렇다. 빌더는 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈보다 훨씬 안전하다.
조슈아 블로크의 'Effective Java' 책을 읽고 제멋대로 정리한 내용입니다. :)
1. 개요
인스턴스를 생성할 때 생성자를 많이 사용하는데, '정적 팩터리 메서드'를 사용하기도 한다. 책에서는 정적 팩터리 메서드 사용을 권장하고 있는데, 과연 어떤 장점을 갖고 있길래 이를 권장하는 것일까?
2. 정적 팩터리 메서드가 뭔가요?
자바에서의 '팩터리'는 '인스턴스를 만드는 공장' 을 의미한다. 즉, 정적 팩터리 메서드란 인스턴스를 만드는 역할을 하는 Static 메서드이다. 아래와 같이 말이다.
public class User {
private String name;
// 접근 제한자를 private 로 하여 외부 호출을 막음.
private User(String name){
this.name = name;
}
// 유저 인스턴스를 생성하는 정적 팩터리 메서드
public static User from(String name){
return new User(name);
}
}
정적 팩터리 메서드 안에서 생성자를 호출하고 있고, 생성자는 private로 하여 외부 호출을 막고있다. public 생성자를 사용했을때보다 코드가 늘어나고 복잡해진것 같은데, 과연 어떤 장점을 갖고 있길래 생성자보다 정적 팩터리 메서드 방식을 권장하는 걸까?
3. 장점
3.1. 이름을 지정함으로써 가독성을 증가시킨다.
생성자는 이름이 없다. 굳이 따지자면 클래스 명이다. 이에 반해 정적 팩터리 메서드는 이름을 지정할수 있다. 이름과 대학 입학년도를 갖는 Student 클래스로 예를들어 설명하겠다. 멤버필드인 name은 학생 이름, admissionYear는 입학년도이다.
1) public 생성자 사용
public class Student {
private String name;
private int admissionYear;
public Student(String name){
LocalDate now = LocalDate.now();
this.name = name;
this.admissionYear = now.getYear();
}
public Student(String name, int admissionYear){
this.name = name;
this.admissionYear = admissionYear;
}
}
class Main{
public static void main(String[] args) {
Student student1 = new Student("김철수");
Student student2 = new Student("곽영희", 2020);
}
}
2) 정적 팩터리 메서드 사용
public class Student {
private String name;
private int admissionYear;
private Student(String name, int admissionYear) {
this.name = name;
this.admissionYear = admissionYear;
}
public static Student createFreshman(String name) {
LocalDate now = LocalDate.now();
return new Student(name, now.getYear());
}
public static Student createOfAdmissionYear(String name, int year) {
return new Student(name, year);
}
}
class Main{
public static void main(String[] args) {
Student student1 = Student.createFreshman("김철수");
Student student2 = Student.createOfAdmissionYear("곽영희", 2020);
}
}
먼저 생성자의 신입생과 재학생에 대한 학생 인스턴스를 생성할 때 시그니처가 다른 public 생성자를 호출하고 있다.
여기서 중요한 점은 메인 메서드에서 이를 사용할 때 public 생성자의 시그니처만 봐서는 어떤 특성을 갖는 인스턴스를 생성하는지 알 수 없다. 직접 생성자 코드를 봐야 알 수 있다.
반면 정적 팩터리 메서드를 사용한 경우 메서드 명을 통해 생성할 인스턴스의 특성을 묘사할 수 있다. createFreshman은 올해 입학한 학생, createOfAdmissionYear는 특정 년도에 입학한 학생 인스턴스를 생성하고 있다. Student라는 생성자 명만 덩그러니 있는것보다 특성을 묘사할 수 있는 메서드 이름을 사용함으로써 가독성을 증가시킨 것이다.
3.2. 인스턴스 재활용을 통한 메모리 효율 증대
인스턴스를 미리 만들어 놓거나, 새로 생성한 인스턴스를 캐싱하여 불필요한 객체 생성을 피할 수 있다. 대표적으로 Boolean.valueOf(boolean) 메서드는 미리 만들어 놓은 인스턴스를 리턴하는 방식으로 사용된다.
만약 생성 비용이 큰 객체가 자주 생성될 경우에 이 방식을 활용한다면 객체 생성 시 사용하게 되는 힙 메모리의 사용율을 줄일 수 있어 메모리 효율적이라고 할 수 있다.
public final class Boolean{
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
...
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
}
3.3. 하위 타입 객체 반환을 통한 유연성 증대
Java 8버전부터 인터페이스 내에 정적 팩터리 메서드를 구현할 수 있다. 이를 통해 인터페이스의 구현체 클래스를 메서드마다 자유롭게 지정할 수 있어 인스턴스 생성에 대한 유연성을 제공한다.
아래는 Weapon 인터페이스에 정의한 정적 팩터리 메서드를 통해 Weapon의 구현체 클래스인 Sword, Gun에 대한 인스턴스를 생성하고 있다.
public interface Weapon {
static Weapon createSword(){
return new Sword();
}
static Weapon createGun(){
return new Gun();
}
}
...
public class Main {
public static void main(String[] args) {
Weapon sword = Weapon.createSword();
Weapon gun = Weapon.createGun();
}
}
3.4. 조건에 따른 하위 타입 객체 반환을 통한 유연성 증대
앞에서는 구현체 클래스에 대한 인스턴스 생성을 위해 외부에서 직접 해당 메서드를 호출하고 있다. 만약 요구사항이 바뀌어 직업이 검사일때는 Sword, 스나이퍼일때는 Gun 인스턴스를 생성해야한다면 어떻게 할까? main 메서드에서 직업에 대해 분기 후 검사일때는 createSword() 메서드를, 스나이퍼일때는 createGun() 메서드를 호출할 수도 있지만, 정적 팩터리 메서드 내에서 직업을 파라미터로 받고, 내부 조건문에 따라 무기 인스턴스를 반환할 수도 있다.
public interface Weapon {
static Weapon createFromJob(Job job){
if(job == Job.SNIPER){
return new Gun();
}else{
return new Sword();
}
}
}
...
public class Main {
public static void main(String[] args) {
Weapon sword = Weapon.createFromJob(Job.SWORDS_MAN);
Weapon gun = Weapon.createFromJob(Job.SNIPER);
}
}
3.5. 캡슐화를 통한 코드중복 및 파편화 방지
위 예제를 보면 Weapon 인터페이스에서 무기 생성에 대한 구현부를 캡슐화시키고, 메인 메서드에서는 캡슐화된 메서드를 호출하고 있다. 그렇다면 캡슐화란 무엇이고 목적은 뭘까?
※ 캡슐화
캡슐화란 객체의 속성과 행위를 하나로 묶고, 실제 구현부는 외부로부터 은닉하는 것을 말한다.
환자가 약사에게 독감 진단서를 제출하면 약사는 정해진 재료와 제조 과정을 거쳐 약을 조제한다. 알약을 받는 환자는 여기에 사용된 재료나 제조 과정을 이해할 필요도, 알 필요도 없다. 다만 진단서를 제출할 뿐이다.
자바 코드로 이해하면 약사 객체인 Chemist 클래스를 만들고 약을 제조하는 전 과정을 makePill 이라는 약을 조제하는 메서드로 묶는 것이다. 외부 클래스에서 약이 필요하다면 Chemist 클래스에서 해당 메서드를 호출하기만 하면 된다. 호출하는 클래스는 어떤 과정을 거쳐 약이 만들어지는지는 알 필요가 없게 된다.
이처럼 약을 조제하는데 필요한 여러 속성들과 행위를 makePill 이라는 하나의 메서드로 묶고, 이에 대한 구현부는 외부로부터 은닉하여 알 필요가 없게하는 것이 바로 캡슐화이다.
public class Chemist {
public Pill makePill(String 진단서){
// 약을 만드는 방법에 대한 로직
return pill; // 생성된 약
}
}
만약 약사라는 클래스를 통해 해당 로직을 캡슐화를 하지 않는다면 어떤 일이 벌어질까? 약이 필요한 모든 곳에서 약을 조제하는 로직을 구현해야 하며, 아래와 같이 중복코드와 메서드 파편화가 발생하게 된다.
public void hospitalCare(){
// 1. 병원 진료
// 2. 약은 만드는 방법에 대한 로직 >> 코드중복 ! 메서드 파편화!
}
// 병원 진료 내역을 조회한 후 해당 내역에 대한 처방전을 받는 메서드
public void getHospitalCareHistory(){
// 1. 병원 진료 내역 조회
// 2. 약은 만드는 방법에 대한 로직 >> 코드중복 ! 메서드 파편화!
}
이 상태에서 약을 만드는 방법에 대한 로직이 변경되면 어떻게될까? 모든 메서드의 로직을 전부 바꿔야한다. 이때 작은 실수가 발생한다면 심각한 버그가 발생하게 된다. 만약 캡슐화가 되어있다면? 앞서 약사 클래스의 makePill 메서드만 변경해주면 된다.
정리하면 캡슐화는 구현부를 외부로부터 은닉함으로써 책임을 분리하고, 코드의 중복 및 파편화를 예방하고, 유지보수하기 용이한 코드로 만들어주는 유용한 프로그래밍 기법인것이다.
4. 정적 팩터리 메서드 명명 방식
메서드 명은 개발자 마음이지만, 정적 팩터리 메서드 같은 특별한 메서드의 경우 권장하는 명명 방식이 있다.
HTTP 메시지는 애플리케이션 간 주고받는 데이터의 블록들로 시작줄, 헤더, 본문으로 구성된다. 이 데이터 블록 안에 어떤 데이터들이 있는지 알아보고, 요청 메시지와 응답 메시지의 데이터가 약간 다르기때문에,이 차이도 알아보도록 하자.
2. 시작줄
요청 메시지의 시작줄에는 메서드, URL, HTTP 버전이, 응답 메시지의 시작줄에는 HTTP 버전, 상태 코드, 사유 구절 정보가 포함된다.
2.1. 메서드
메서드는 서버에게 어떤 형식의 작업을 해야하는지 알려준다.
메서드
설명
GET
서버에서 데이터를 가져온다.
HEAD
서버에서 데이터에 대한 헤더만 가져온다.
POST
서버가 처리해야할 데이터를 보내거나, 새로 저장시킨다.
PUT
서버에 요청 메시지의 본문을 (덮어)저장한다.
PATCH
서버에 저장된 데이터의 일부분을 수정한다. (2010년 RFC 표준화됨)
TRACE
메시지가 프락시를 거쳐 서버에 도달하는 과정을 추적한다.
OPTIONS
어떤 메서드를 지원하는지 확인한다.
DELETE
서버에서 데이터를 제거한다.
2.2. POST와 PUT ??
사실 위는 책의 내용을 필자가 재해석하여 쓴것이다. 책에 기재된 POST와 PUT의 설명을 보고 바로 이해하기 어려웠기 때문인데, 책의 내용은 아래와 같다.
메서드
설명
POST
서버가 처리해야할 데이터를 보낸다.
PUT
서버에 요청 메시지의 본문을 저장한다.
필자의 경우 POST는 데이터를 저장할때, PUT은 덮어쓸때 사용했었다. 그런데 POST 에는 없고 PUT에는 있는 '저장'이라는 단어가 잘 이해되지 않았다. HTTP 메서드에 대해 공식문서를 찾아본 결과 '멱등성'이라는 개념을 통해 이해하게 되었고, '저장'이라는 단어에 대해 편협하게 바라보고 있었다는 걸 깨닫게 되었다.
멱등성 동일한 요청을 한 번 보내는 것과 여러 번 연속으로 보내는 것이 같은 효과를 지니고, 서버의 상태도 동일하게 유지될 때 해당 HTTP 메서드가 멱등성을 가졌다고 말한다.
모질라 개발자 페이지를 보면 HTTP 메서드 중 GET, HEAD, PUT, DELETE, OPTION, TRACE 는 멱등성 메서드, POST, PATCH는 비 멱등성 메서드라는 것을 알 수 있다.
PUT 메서드는 Word나 한글파일에서의 '저장'과 같다고 생각했다. 처음 저장할때는 디스크에 새로 저장하지만, 이후부터는 계속 덮어쓴다. 동일한 내용을 한번 저장하는 것과 여러번 저장하는 것이 같은 효과를 지님과 동시에 서버의 상태도 동일하게 유지(리소스가 1개 -> 리소스가 1개)된다는 점에서 멱등성을 보장함을 알 수 있다.
이에 반해 POST는 '다른이름으로 저장' 과 같다고 생각했다. 저장할때마다 무조건 디스크에 새로 저장하게 된다. 동일한 내용을 한번 '다른이름으로 저장'하는 것과 여러번 '다른이름으로 저장'하는 것이 같은 효과를 지니고 있지만, 리소스를 계속 생성하여 서버의 상태를 변경한다는 점에서 비멱등성이라는 것을 알 수 있다.
이를 이해하니 PUT은 '서버에 요청 메시지의 본문을 저장한다'라는 내용도 이해할 수 있었다.
2.3. PATCH는 비멱등성?
멱등성을 알아보던 중 한번의 물음표가 더 나왔다. PATCH는 특정 부분만 수정하니 당연히 멱등성 메서드일줄 알았으나 비멱등성 메서드라는 부분때문이었다. 결론은 PATCH는 로직에 따라 멱등성을 보장할 수 없기 때문에 비멱등성 메서드로 정의하고 있었다. 비멱등을 유발하는 케이스를 아래에 정리해보았다.
사용자의 나이를 한살 증가시키는 HTTP API가 있다고 가정하자. 이는 일부분을 변경하는 것이므로 PATCH 메서드를 사용할 것이며, 사용자의 나이에 1을 더하는 로직이 들어갈 것이다.
만약 A라는 사용자의 나이가 5살이었다면, 한번 요청했을 때는 6살이, 두번 요청했을 때는 7살이 될 것이다. 이는 멱등성을 보장하지 않는다고 할 수 있다.
반대로 만약 사용자의 이름을 변경하는 API가 있다고 가정하자. 마찬가지로 일부분을 변경하는 것이므로 PATCH 메서드를 사용할 것이며, 사용자의 이름을 요청 이름으로 변경하는 로직이 들어갈 것이다. 만약 A라는 사용자의 이름을 B로 변경한다면, 한번 요청하던, 두번 요청하던 이름은 B가 될 것이다. 이는 멱등성을 보장한다고 할 수 있다.
즉, 로직에 따라 멱등성을 보장할수도, 보장하지 못할수도 있으므로 비멱등성 메서드로 정의되는 것이다.
2.4. 상태코드
상태코드는 서버에서 어떤 행위가 일어났는지에 대한 것을 코드로 표현한 것이다. 백단위마다 다른 종류로 분류된다.
전체 범위
분류
100 ~ 199
정보
200 ~ 299
성공
300 ~ 399
리다이렉션
400 ~ 499
클라이언트 에러
500 ~ 599
서버에러
2.5. 사유구절
사유 구절은 상태 코드에 대한 설명을 말한다. 예를들어 200 상태 코드에 대해서는 OK이라는 사유구절이 포함된다.
2.6. HTTP 버전
HTTP 애플리케이션들이 자신이 따르는 프로토콜의 버전을 상대방에게 말해주기 위한 수단으로 사용된다.
3. 헤더
헤더는 HTTP의 요청과 응답 메시지에 더하는 추가 정보로, 이름/값 쌍의 목록으로 관리되며, 일반적으로 쿠키나 인증, 컨텐츠와 같은 메타 정보가 포함된다. 헤더는 목적에 따라 총 5가지로 분류된다.
3.1. 일반 헤더(General Header)
클라이언트와 서버 양쪽 모두가 사용하는 헤더이다. 예를들어 Date 헤더는 서버와 클라이언트를 가리지 않고 메시지가 만들어진 일시를 지칭하기 위해 사용된다.
3.2. 요청 헤더(Request Header)
요청 메시지를 위한 헤더이다. 예를 들어 "Accept : */*" 헤더는 서버에게 어떤 미디어 타입도 받을 수 있다는 것을 의미한다.
3.3. 응답 헤더(Response Header)
응답 메시지를 위한 헤더이다. 예를 들어 "Location : http://~" 헤더는 클라이언트에게 알려준 URL로 재 요청하라는 것을 의미한다.
3.4. 엔티티 헤더(Entity Header)
본문에 대한 헤더를 말한다. 예를들어 Content-Type : text/html 헤더는 클라이언트에게 본문에 들어간 데이터가 HTML 문서라는 것을 의미한다.
3.5. 확장 헤더(Extension Header)
개발자에 의해 커스텀되어 만들어졌지만 HTTP 명세에는 추가되지 않는 비표준 헤더이다.
4. 본문
HTTP 메시지에 덱스트, 이미지, 비디오, HTML 문서 등 여러 종류의 디지털 데이터를 포함시켜 요청하기 위해 사용된다.
'자바 엔터프라이즈 개발을 편하게' 해주는 '오픈소스' '경량급' '애플리케이션 프레임워크'
1.1. 애플리케이션 프레임워크
일반적으로 프레임워크는 특정 업무 분야나 한가지 기술에 특화된 목표를 가지고 만들어진다. 스프링은 특정 계층이나, 기술, 업무 분야에 국한되지 않고 애플리케이션 전 영역을 포괄하는 범용적인 프레임워크이다.
1.2. 경량급
스프링 자체가 가볍다는 뜻이 아니다. 개발환경, 빌드, 테스트 과정, 코드 등 매우 무겁고 복잡했던 EJB에 비해 불필요하게 무겁지 않다는 뜻이다.
1.3. 자바 엔터프라이즈 개발을 편하게
IoC/DI, 서비스 추상화, AOP 등을 통해 로우 레벨의 트랜잭션이나 상태관리, 스레딩, 리소스 풀링과 같은 복잡한 로우레벨의 API를 이해하지 못하더라도 아무 문제 없이 애플리케이션 개발을 할 수 있다.
1.4. 오픈소스
스프링은 오픈소스 프로젝트 방식으로 개발되고 있으며, 2009년 세계적인 IT 기업에 합병되었다. 즉, 전문적이고 성공적인 오픈소스 프레임워크이다.
2. 복잡한 엔터프라이즈 시스템?
'자바 엔터프라이즈 개발을 편하게' 라는 부분에서 알 수 있듯이 스프링을 사용하기 전 자바 엔터프라이즈 개발은 불편하고 복잡했다고 한다. 그 이유는 무엇일까?
2.1. 기술적인 복잡함
엔터프라이즈 시스템은 기업과 조직의 업무를 처리해주는 시스템이다. 기업 내 많은 사용자들의 요청을 처리해야 하기에 시스템의 안정성을 고려해야한다. 또한 조직의 업무 처리는 곧, 조직의 핵심 정보 처리이다. 핵심 정보에 대한 보안성도 고려해야 한다. 이 뿐 아니라 업무 자체에도 여러 기술이 사용된다.
정리하면 안정성과 보안성, 업무적인 기술 등, 다양한 기술적인 복잡도를 갖게 된다.
2.2. 비지니스 로직의 복잡함
기업의 핵심 업무는 복잡하고, 한정되어 있지 않다. 처리하는 업무가 많아짐에 따라 비지니스 로직도 늘어나고, 복잡해진다.
2.3. 기술과 비지니스 로직의 복잡함
엔터프라이즈 시스템은 기술적인 복잡함과 비지니스 로직의 복잡함이 얽혀있다. 코드를 담는 하나의 파일 안에 여러 기술이 담긴 코드와 비지니스 로직 코드가 얽혀있다고 생각하면 된다.
3. 스프링의 목적
스프링의 목적은 엔터프라이즈 애플리케이션 개발을 편하게 하는 것이다. 스프링은 이를 위해 기술 부분과 비지니스 로직 부분을 분리하고 사용할 수 있도록 도와준다. OOP와 DI를 통해서 말이다.
사용하는 기술들은 추상화 되어있다. 기술에 대한 로우 레벨의 코드를 이해하고 있지 않아도 사용할 수 있으며, 기술이 변경되어도 비지니스 로직을 수정할 필요가 없게된다.
AOP를 사용하면 트랜잭션과 같은 기술을 담당하는 부분을 비지니스 로직과 아예 분리할 수도 있다.
이처럼 스프링은 엔터프라이즈 애플리케이션 개발을 편하게 하도록 돕는다.
4. 결론
스프링은 객체지향이라는 도구를 극대화해서 애플리케이션 개발을 편하게 할 수 있도록 도울 뿐이다. 객체 지향적인 프로그래밍을 얼마나 잘 하냐에 따라 스프링의 활용도가 달라지게 된다.
때문에 스프링 공부와 함께 개발자 본인의 객체지향 설계와 개발 능력을 키워야 비로소 복잡한 엔터프라이즈 시스템을 잘 개발할 수 있다는 점을 잊지 말자.
브라우저가 웹 서버에 접속하여 받아온 정적 컨텐츠 (html, 이미지, js 등)를 메모리 또는 디스크에 저장해 놓는 것을 말한다. 이후 HTTP 요청을 할 경우 해당 리소스가 캐시에 있는지 확인하고 이를 재사용함으로써 응답시간과 네트워크 대역폭을 줄일 수 있다.
웹 캐시의 장점
1. 불필요한 네트워크 통신을 줄인다.
클라이언트가 서버에게 문서를 요청할 때, 서버는 해당 문서를 클라이언트에게 전송하게 된다. 재차 똑같은 문서를 요청할 경우 똑같이 전송하게 된다.
캐시를 이용하면, 첫번째 응답은 브라우저 캐시에 보관되고, 클라이언트가 똑같은 문서를 요청할 경우 캐시된 사본이 이에대한 응답으로 사용될 수 있기 때문에, 중복해서 트래픽을 주고받는 네트워크 통신을 줄일 수 있다.
2. 네트워크 병목을 줄여준다.
많은 네트워크가 원격 서버보다 로컬 네트워크에 더 넓은 대역폭을 제공한다. WAN 보다 LAN이 구성이 더 쉽고, 거리도 가까우며, 비용도 적게들기 때문이다. 만약 클라이언트가 빠른 LAN에 있는 캐시로부터 사본을 가져온다면, 캐싱 성능을 대폭 개선할 수 있다.
예를들어 샌프란시스코 지사에 있는 사용자는 애틀랜타 본사로부터 문서를 받는데 30초가 걸릴 수 있다. 만약 이 문서가 샌프란시스코의 사무실에 캐시되어 있다면, 로컬 사용자는 같은 문서를 이더넷 접속, 즉 LAN을 통해 1초 미만으로 가져올 수 있을 것이다.
3. 거리로 인한 네트워크 지연을 줄여준다.
대역폭이 문제가 되지 않더라도, 거리가 문제될 수 있다. 만약 보스턴과 샌프란시스코 사이에 네트워크 통신을 한다면 그 거리는 4,400 킬로미터이고, 신호의 속도를 빛의 속도(300,000 킬로미터 / 초)로 가정하면 편도는 약 15ms, 왕복은 30ms 가 걸린다.
만약 웹페이지가 20개의 작은 이미지를 포함하고 있다면, 이 속도에 비례하여 통신 시간이 소요된다. 추가 커넥션에 의한 병렬처리가 이 속도를 줄일 수 있지만, 이보다 더 떨어진 거리거나, 훨씬 더 복잡한 웹페이지일 경우 거리로 인한 속도 지연은 무시할 수 없다.
캐시는 이러한 거리를 수천 킬로미터에서 수십 미터로 줄일 수 있다.
4. 갑작스런 요청 쇄도(Flash Crowds)에 대처 가능하다.
원 서버로의 요청을 줄이기때문에 갑작스런 요청 쇄도 (Flash Crowds) 에 대처할 수 있다.
적중과 부적중 (cache hit, cache miss)
캐시에 요청이 도착했을 때, 그에 대응하는 사본이 있다면 이를 이용해 요청이 처리될 수 있다. 이를 캐시 적중(cache hit)라고 하고, 대응하는 사본이 없다면 원 서버로 요청이 전달된다. 이를 캐시 부적중(cache miss)라고 한다.
웹 캐시 체감하기
1. 기본 셋팅
실제로 웹 캐시를 적용했을때와 그렇지 않았을 때의 차이를 비교해보자. 테스트 환경으로는 웹서버인 Apache 2.4 버전을 사용했으며, 보다 확실하게 체감하기 위해 Network 속도를 Slow 3G로 설정하였다. 이는 크롬 개발자 도구에서 설정 가능하다.
1. 웹 캐시로부터 읽어오지 않은 응답
1) 최초 HTTP 통신
최초 웹서버 접속 시 HTML 형태의 응답을 받고, HTML 내에 존재하는 정적 리소스를 로딩하기 위해 서버로 요청하고 있다. 이때 해당 리소스의 Size는 5~20 kb, Time은 약 2초 정도 걸렸다.
2) 두번째 HTTP 통신
이후 동일 URI로 재요청 한다. 마찬가지로 HTML 형태의 응답을 받고, HTML 내에 존재하는 정적 리소스를 로딩하기 위해 다시 서버로 요청하고 있다. Size와 Time 모두 이 전 요청과 동일하다. 이를 통해 정적데이터가 필요할 때마다 서버로 요청한다는 것을 알 수 있다.
2. 웹 캐시로부터 읽어온 응답
1) 최초 HTTP 통신
최초 웹서버 접속 시 HTML 형태의 응답을 받고, HTML 내에 존재하는 정적 리소스를 읽어오기 위해 서버로 요청하고 있다. 이때 해당 리소스의 Size는 5~20 kb, Time은 약 2초 정도 걸렸다.
2) 두번째 HTTP 통신
이후 동일 URI로 재요청 한다. 마찬가지로 HTML 형태의 응답을 받고, HTML 내에 존재하는 정적 리소스를 읽고 있다. 그런데 다른점이 있다. Size에 memory cache 가 적혀있고, Time은 0ms이다. 해당 리소스를 서버가 아닌 memory에서 조회했다는 것을 알 수 있다. 이를 통해 정적데이터가 캐시에 저장되어 있을 경우 캐시에서 로드한다는 것을 알 수 있다.
캐시 옵션 설정하기
캐시는 Cache-Control이라는 HTTP Header로 설정할 수 있다. 먼저 해당 옵션을 살펴보자.
1. Cache-Control
설정 값
내용
no-store
캐시에 리소스를 저장하지 않는다.
no-cache
캐시 만료기간에 상관하지 않고 항상 원 서버에게 리소스의 재검사를 요청한다.
must-revalidate
캐시 만료기간이 지났을 경우에만 원 서버에게 리소스의 재검사 요청한다.
public
해당 리소스를 캐시 서버에 저장한다.
private
해당 리소스를 캐시 서버에 저장하지 않는다. 개인정보성 리소스이거나 보안이 필요한 리소스의 경우 이 옵션을 사용한다.
max-age
캐시의 만료기간(초단위)을 설정한다.
※ 재검사가 뭔가요?
재검사(Revalidation)는 신선도 검사라고도 하며, 캐시가 갖고 있는 사본이 최신 데이터인지를 서버를 통해 검사하는 작업을 말한다. 최신 데이터인 경우 304 Not Modifed 응답을 받게 되는데, 이는 '캐시에 있는 사본 데이터가 최신이며, 수정되지 않았다'라는 뜻을 의미한다. 이 경우 클라이언트는 해당 리소스를 캐시로부터 로드하게 된다.
2. Apache httpd.conf 설정
Apache 웹서버의 httpd.conf를 통해 설정할 수 있다. 필자의 경우 캐시의 만료기간을 10초로 설정하기 위해 아래 구문을 최하단에 넣어주었다. 서버를 재시작하여 설정을 적용하고 서버로 요청을 보내보자.
Header Set Cache-Control "max-age=10"
만료기간 10초가 지나기 전에 다시 요청할 경우 캐시 메모리에 저장된 리소스를 가져옴을 확인할 수 있다.
3. HTTP Status 304
캐시의 만료기간인 10초가 지나자 재검사를 진행했고, 서버의 리소스가 바뀌지 않아 304 상태코드를 리턴받고 있다. 그럼 재검증은 HTTP 메시지의 어떤 값을 통해 확인할 수 있는걸까?
서버를 통해 리소스를 응답받으면 Response Header에 해당 리소스의 마지막 수정날짜가 들어간다. 아래 이미지를 보면 Last-Modified 헤더에 Sat, 04 May 2013 12:52:00 GMT로 되어있다.
이를 우리나라 시간으로 환산하면 2013년 5월 4일 21시 52분인데, 실제 서버에 있는 리소스의 마지막 수정날짜이다.
이 후 서버로 리소스 요청을 보낼 때 요청 헤더의 If-Modified-Since 에 리소스의 마지막 수정날짜를 보낸다. 서버는 이 값과 실제 수정 날짜를 비교하여 일치하지 않을 경우, 즉 리소스가 변경된 경우에 200 코드와 함께 해당 리소스를 내려준다.
리소스가 변경되지 않았을 땐 304를, 리소스가 삭제되었다면 404를 응답한다.
캐시 포톨로지
캐시는 한 명의 사용자에게만 할당될 수 있고, 수천 명의 사용자에게 공유될 수도 있다. 한명에게만 할당된 캐시를 전용 캐시, private cache라 하고, 여러 사용자가 공유하는 캐시는 공용 캐시, public cache 라고 한다.
private cache의 대표적인 예는 방금 설명했던 브라우저 캐시이다. 웹 브라우저는 개인 전용 캐시를 내장하고 있으며 컴퓨터의 디스크 및 메모리에 캐시해놓고 사용한다.
public cache의 대표적인 예는 프락시 캐시라고 불리는 프락시 서버이다. 각각 다른 사용자들의 요청에 대해 공유된 사본을 제공할 수 있어 private cache보다 네트워크 트래픽을 줄일 수 있다.
캐시 처리 단계
웹 캐시의 기본적인 동작은 총 일곱 단계로 나뉘어져 있다.
1. 요청 받기
먼저 캐시는 네트워크 커넥션에서의 활동을 감지하고, 들어오는 데이터를 읽어들인다. 즉, 서버로 요청하기 전 캐시에서 선 작업이 진행된다. (캐시는 HTTP의 응용계층에서 처리된다.)
2. 파싱
캐시는 요청 메시지를 여러 부분으로 파싱하여 헤더 부분을 조작하기 쉬운 자료구조에 담는다. 이는 캐싱 소프트웨어가 헤더 필드를 처리하고 조작하기 쉽게 만들어준다.
3. 검색
캐시는 URL을 알아내고 그에 해당하는 로컬 사본이 있는지 검사한다. 만약 문서를 로컬에서 가져올 수 없다면, 그것을 원 서버를 통해 가져오거나 실패를 반환한다.
4. 신선도 검사
HTTP는 캐시가 일정 기간 동안 서버 문서의 사본을 보유할 수 있도록 해준다. 이 기간동안 문서는 신선하다고 간주되고 캐시는 서버와의 접촉 없이 이 문서를 제공할 수 있다. 하지만 max-age를 넘을 정도로 너무 오래 갖고 있다면, 그 객체는 신선하지 않은 것으로 간주되며, 캐시는 그 문서를 제공하기 전 문서에 어떤 변경이 있었는지 검사하기 위해 서버와 통신하여 재검사 작업을 진행한다.
5. 응답 생성
캐시는 캐시된 응답을 원 서버에서 온 것처럼 보이게 하고 싶기 때문에, 캐시된 서버 응답 헤더를 토대로 응답 헤더를 새로 생성한다.
6. 전송
응답 헤더가 준비되면, 캐시는 응답을 클라이언트에게 돌려준다.
7. 로깅
대부분의 캐시는 로그 파일과 캐시 사용에 대한 통계를 유지한다. 각 캐시 트랜잭션이 완료된 후, 캐지 적중과 부적중 횟수에 대한 통계를 갱신하고, 로그파일에 요청 종류, URL 그리고 무엇이 일어났는지를 알려주는 항목을 추가한다.
올해 6월부터 코로나 위기 단계가 하향 조정됨에 따라 7일 격리였던 격리 수준이 5일로 완화되었다. :) 어떤 단계에 따라 격리 수준 변경되고 있는데 우리가 사용하는 DB, 트랜잭션에도 격리 수준이란게 존재한다. 트랜잭션 격리수준이란 무엇인지 차근차근 이해해보자.
2. 트랜잭션 격리수준이란?
2.1. 정의
트랜잭션 격리수준이란 동시에 여러 트랜잭션이 처리될 때, 트랜잭션끼리 얼마나 고립(격리)되어 있는가에 대한 수준을 말한다.
2.2. 트랜잭션은 원래 격리된거 아닌가?
트랜잭션의 성질 중 격리성(Isolation)이 있다. 격리성이란 트랜잭션 수행 시 다른 트랜잭션이 끼어들이 못하도록 보장하는 것을 말한다. 이는 '트랜잭션은 서로 완전히 격리되어 있으니 다른 트랜잭션이 끼어들지 못합니다. 땅땅땅!' 이 아니다. 격리성을 강화시킨다면 다른 트랜잭션이 끼어들지 못하게 보장할 수 있고, 약화시킨다면 다른 트랜잭션도 중간에 끼어들 수 있다는 의미이다. 코로나처럼 7일간 무조건 집콕일수도 있고, 5일간 집콕 권고일수도 있다.
그럼 이렇게 격리수준을 나눈 이유는 뭘까? 먼저 트랜잭션의 성질을 살펴보자.
2.3. 트랜잭션 ACID
1) Atomicity (원자성)
한 트랜잭션의 연산은 모두 성공하거나, 모두 실패해야한다. 예를들어 돈을 이체할 때 보내는 쪽에서 돈을 빼오는 작업만 성공하고, 받는 쪽에 돈을 넣는 작업은 실패하면 안된다.
2) Consistency (일관성, 정합성)
트랜잭션 처리 후에도 데이터의 상태는 일관되어야 한다. '모든 계좌 정보에는 계좌 번호가 있어야 한다.'라는 제약 조건 즉, 상태가 걸려있을 때, 계좌번호를 삭제하거나 계좌번호가 없는 상태로 계좌정보를 추가하려 한다면 '모든 계좌 정보에는 계좌 번호가 있어야 한다' 에서 '모든 계좌 정보에는 계좌 번호가 없어도 된다' 라는 상태로 변경되게 된다. 때문에 상태를 변화시키는 트랜잭션은 실패하는것이다.
3) Isolation (격리성)
트랜잭션 수행 시 다른 트랜잭션의 연산이 끼어들이 못하도록 보장한다. 하나의 트랜잭션이 다른 트랜잭션에게 영향을 주지 않도록 격리되어 수행되어야한다.
4) Durability (지속성)
성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다. 중간에 DB에 오류가 발생해도 다시 복구되어야 한다. 즉, 트랜잭션에 대한 로그가 반드시 남아야한다는 의미하기도 한다.
2.4. 트랜잭션의 Isolation
여기서 격리성을 따져보자. 트랜잭션 수행 중간에 다른 트랜잭션이 끼어들수 없다면 어떻게될까? 모든 트랜잭션은 순차적으로 처리될 것이고, 데이터의 정확성은 보장될 것이다. 속도는 어떨까? 트랜잭션이 많아질수록 처리를 기다리는 트랜잭션은 쌓여간다. 앞에 있는 트랜잭션이 기다리는 시간만큼 대기 시간은 늘어난다. 결국 트랜잭션이 처리되는 속도가 느려지게 되고, 어플리케이션 운용에 심각한 문제가 발생할 수 있다.
결국 준수한 처리 속도를 위해서는 트랜잭션의 완전한 격리가 아닌 완화된 수준의 격리가 필요하다. 이처럼 속도와 데이터 정확성에 대한 트레이드 오프를 고려하여 트랜잭션의 격리성 수준을 나눈것이 바로 트랜잭션의 격리수준이다.
3. 트랜잭션 격리수준 단계
3.1. DBMS마다 다른 격리수준
트랜잭션 격리수준은 총 4단계로 Uncommitted Read, Committed Read, Repeatable Read, Serializable 로 구성된다. DBMS 마다 격리 수준에 대한 내용이 다를 수 있으니 보다 정확하게 알기 위해서는 공식 문서를 확인해야 한다. 필자는 MySQL 에서 제공하는 격리수준을 기준으로 하였으며, 필요에 따라서는 타 DBMS에서의 격리수준도 비교 분석하였다. 참고로 MySQL의 기본 격리수준은 Repeatable Read 이다.
먼저 격리수준이 가장 낮은 Uncommitted Read부터 알아보자.
3.2. Uncommitted Read (커밋되지 않은 읽기)
다른 트랜잭션에서 커밋되지 않은 데이터에 접근할 수 있게 하는 격리 수준이다. 가장 저수준의 격리수준이며, 일반적으로 사용하지 않는 격리수준이다.
10번 트랜잭션이 '박기영'이라는 데이터를 '박경'으로 UPDATE 한 후 Commit 하지 않았을 때 13번 트랜잭션에서 접근하여 커밋되지 않은 데이터를 읽을 수 있다.
그런데 13번 트랜잭션이 데이터를 읽은 후 10번 트랜잭션에 문제가 발생하여 롤백된다면 데이터 부정합을 발생시킬 수 있다.
데이터 부정합은 어플리케이션에 치명적인 문제를 야기할 수 있다. 그래서인지 오라클에서는 이 수준을 아예 지원하지 않는다. 이처럼 커밋되지 않는 트랜잭션에 접근하여 부정합을 유발할 수 있는 데이터를 읽는 것을 더티읽기(Dirty Read)라고 한다.
3.2 Committed Read (커밋된 읽기)
다른 트랜잭션에서 커밋된 데이터로만 접근할 수 있게 하는 격리 수준이다. MySQL을 제외하고 대부분 이를 기본 격리수준으로 사용한다.
10번 트랜잭션이 '박기영'이라는 데이터를 '박경'으로 UPDATE 한 후 Commit 하지 않았을 때, 13번 트랜잭션에서 이를 조회할 경우 UPDATE 전 데이터인 '박기영' 이라는 값이 조회된다. Dirty Read 현상은 발생하지 않는다.
그럼 어떻게 Read Committed는 UPDATE 전 값을 조회한걸까? 그 키는 바로 Undo 영역에 있다.
※ Undo 영역
앞서 살펴본 트랜잭션의 성질 중 지속성(Durability)을 보면 다음과 같이 정의되어 있다.
성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다. 중간에 DB에 오류가 발생해도, 다시 복구되어야 한다. 즉, 트랜잭션에 대한 로그가 반드시 남아야한다는 성질을 의미하기도 한다.
트랜잭션에 대한 로그가 반드시 남아있어야 한다. 즉, 복구는 로그를 기반으로 처리된다. 이 로그는 크게 두 가지가 있다. 오류에 의한 복구에 사용되는 Redo Log와 트랜잭션 롤백을 위해 사용되는 Undo Log이다.
다시실행의 뜻을 갖는 Redo는 커밋된 트랜잭션에 대한 정보를 갖고 있고(왜? 복구하려면 다시 실행해줘야하니까), 실행 취소의 뜻을 갖는 Undo는 데이터베이스의 변경이 발생할 경우 변경되기 전 값과 이에 대한 PK 값을 갖고 있다(왜? 롤백하면 다시 되돌려야 하니까)
그런데 Undo 영역이라고 말한 이유는 Undo Log가 Undo Log Buffer 형태로 메모리에 저장되고, 특정 시점에 디스크에 저장된 Undo Log File 에 I/O 작업으로 쓰여지기 때문이다. 추가로 이렇게 단계가 나눠지는 이유는 데이터에 변경사항이 생길때마다 Disk에 I/O 작업을 하는것보다 메모리 입력하고, 읽는것이 속도와 리소스 측면에서 유리하기 때문이다.
정리하면, Undo 영역이란 변경 전 데이터가 저장된 영역이고, Commit 하기 전 데이터를 읽어올 수 있는 이유는 Undo 영역에 있는 데이터를 읽어오기 때문이다.
※ Non Repeatable Read(반복 가능하지 않은 읽기) 현상 발생
Committed Read 수준에서는 Non Repeatable Read 현상이 발생한다. 이는 하나의 트랜잭션에서 동일한 SELECT 쿼리를 실행했을 때 다른 결과가 나타나는 것을 말한다.
아래 그림을 보면 13번 트랜잭션이 동일한 SELECT 쿼리를 두 번 실행했을 때 결과가 다른 것을 볼 수 있는데, 10번 트랜잭션이 데이터 UPDATE 후 COMMIT 하기 전, 후에 SELECT 쿼리를 실행했었기 때문이다.
3.3. Repeatable Read (반복 가능한 읽기)
Non Repeatable Read 문제를 해결하는 격리 수준으로, 커밋된 데이터만 읽을 수 있되 자신보다 낮은 트랜잭션 번호를 갖는 트랜잭션에서 커밋한 데이터만 읽을 수 있는 격리수준이다. 이게 가능한 이유는? 그렇다 Undo 로그때문이다. 또한 트랜잭션 ID를 통해 Undo 영역의 데이터를 스냅샷처럼 관리하여 동일한 데이터를 보장하는 것을 MVCC(Multi Version Concurrency Control) 라고 한다.
아래 그림에서 10번 트랜잭션은 10번 보다 작은 트랜잭션에서 커밋한 데이터만 읽을 수 있으므로 13번 트랜잭션에서 변경한 내용은 조회할 수 없다. 같은 SELECT 쿼리가 두 번 실행됐을 때 같은 결과가 조회되므로 Non-Repeatable-Read 현상이 해결됨을 확인할 수 있다.
※ Repeatable Read를 지원하지 않는 오라클?!
오라클은 Repeatable Read 수준을 지원하지 않는다. 그럼 Non Repeatable Read 문제를 해결할 수 없을까? 아니다! 해결할 수 있는 방법이 있다. 바로 Exclusive Lock을 사용하는 방법이다.
※ Exclusive Lock (배타적 잠금 / 쓰기 잠금)
Exclusive Lock이란 특정 레코드나 테이블에 대해 다른 트랜잭션에서 읽기, 쓰기 작업을 할 수 없도록 하는 Lock 이다. SELECT ~ FOR UPDATE (업데이트 하려고 조회하는거에요~ 그러니까 다른 트랜잭션에서 접근못하도록 막아주세요~) 구문을 통해 사용할 수 있다.
아래 참고 자료를 보자. SELECT ~ FOR UPDATE 를 사용하여 조회된 레코드에 대해 Exclusive Lock 을 걸면 다른 트랜잭션에서 해당 레코드에 대해 쓰기 작업 시 LOCK이 해제될때까지 대기하는 것을 볼 수 있다.
그런데 이상한 점이 하나 있다. Exclusive Lock은 읽기나 쓰기 작업을 할 수 없도록 한다고 했는데 아래 gif를 보니 다른 트랜잭션에서 SELECT를 통해 읽기 작업을 하고있다. 이건 바로 MVCC 기술을 통해Undo 영역에서 읽어오는 것이다.
이제 이 과정을 그림으로 이해해보자. 10번 트랜잭션이 select id, name, from user for update 를 실행하여 레코드를 조회함과 동시에 Exclusive Lock이 건다. 다른 트랜잭션에서 접근 시 Lock이 풀릴때까지 대기하게 된다. 이후 10번 트랜잭션이 똑같은 쿼리를 실행해도 처음 조회했던 데이터와 같은 데이터가 조회되게 된다. Non-Repeatable Read 문제가 해결된것이다.
※ Phantom Read (유령 읽기)
Repeatable Read와 Exclusive Lock 를 통해 Non Repeatable Read 문제를 해결했다. 그런데 새로운 문제가 발생한다. 바로 Phantom Read 현상이다.
Phantom Read는 하나의 트랜잭션 내에서 여러번 실행되는 동일한 SELECT 쿼리에 대해 결과 레코드 수가 달라지는 현상을 말한다. Non Repeatable Read는 레코드의 데이터가 달라지는 것을 의미한다면 Phantom Read는 기존 조회했던 레코드의 데이터는 달라지지 않지만, 새로운 레코드가 나왔다가 사라졌다가 하는 것이다. 마치 유령처럼!! :(
먼저 Exclusive Lock 을 보자. UPDATE, DELETE에 대한 Lock을 걸어 읽기, 쓰기 작업을 막을 수 있었지만, INSERT에 대한 LOCK은 걸 수 없다. 그 이유는 조회된 레코드에 대해서만 Exclusive Lock을 거는 것이지 조회되지 않은 레코드, 즉 나중에 추가할 레코드에 대해서는 Lock을 걸지 않기 때문이다.
이는 Exclusive Lock을 사용해도 다른 트랜잭션에서 INSERT 작업이 가능하다는 뜻이고, 아래와 같이 처음엔 조회되지 않았던 레코드가 조회될 수 있다는 것이다. 마치 유령처럼!! :)
※ MySQL 에서는 발생하지 않는 Phantom Read
InnoDB 엔진을 사용하는 MySQL 에서는 Repeatable Read 수준에서 Phantom Read 현상이 발생하지 않는다. 그 이유는 SELECT ~ FOR UPDATE를 통해 Lock을 걸때 Exclusive Lock이 아닌 Next Key Lock 방식을 사용하기 때문이다.
※ Next Key Lock
Next Key Lock은 조회된 레코드에 대한 Lock 뿐 아니라 실행 쿼리에 대한 범위에 설정되는 Lock이다. 즉, Next Key Lock은 Record Lock, Gap Lock 이 조합된 Lock 이다.
예를 들어 SELECT * FROM USERS WHERE ID BETWEEN 0 AND 10 FOR UPDATE쿼리를 실행시키면, 조회된 레코드에 대한 Record Lock과,0 < ID <=10 에 해당하는 범위에 해당하는 Gap Lock이 걸린다. 이뿐 아니다! 마지막으로 조회된 레코드의 Index인 ID에 대해 그 다음 존재하는 ID 까지의 범위를 Gap Lock으로 설정한다. 만약 아래와 같이 2 이후 ID가 20인 레코드가 있다면 2 ~ 20 까지 Gap Lock을 건다.
때문에 다른 트랜잭션에서 SELECT 쿼리를 통해 정해진 GAP에 해당하는 데이터를 INSERT 시도할 경우 Gap Lock으로 인해 대기상태에 들어가기 되고, 이는 기존 트랜잭션의 여러 동일 SELECT 쿼리에 대한 동일성이 보장되게 된다.
ID
NAME
1
심승갱
2
박기영
20
홍길동
참고로 SELECT * FROM USERS FOR UPDATE 쿼리를 실행한다면 조회된 모든 레코드에 대한 Lock과 모든 범위에 대한 GAP LOCK이 걸리게 된다.
3.4. Serializable
가장 고수준의 격리수준으로 트랜잭션을 무조건 순차적으로 진행시킨다. 트랜잭션이 끼어들 수 없으니 데이터의 부정합 문제는 발생하지 않으나, 동시 처리가 불가능하여 처리 속도가 느려진다.
트랜잭션이 중간에 끼어들 수 없는 이유는 SELECT 쿼리 실행 시 Shared Lock(공유 잠금)을, INSERT, UPDATE, DELETE 쿼리 실행 시 Exclusive Lock (MySQL의 경우 Nexy Key Lock)을 걸어버리기 때문이다.
※ Shared Lock
Shared Lock이란 다른 트랜잭션에서의 읽기 작업은 허용하지만, 쓰기 작업은 불가능하도록 한다. SELECT ~ FOR SHARE 문법을 통해 사용하는데, 키 포인트는 이 Lock의 경우 동시에 Exclusive Lock을 허용하지 않는다는 것이다.
SELECT 쿼리를 실행하면 Shared Lock이 걸리게 되고, 다른 트랜잭션에서 UPDATE, DELETE, INSERT와 같은 쿼리 실행 시Exclusive Lock, Next Key Lock을 얻어오려고 할텐데 Shared Lock 은 이를 허용하지 않아 대기 상태가 된다. 이러한 원리에 의해 트랜잭션들이 중간에 끼어들 수 없고 순차적으로 되는것이다.
4. 회고
처음엔 트랜잭션의 격리 수준에 따라 트랜잭션 내에서 실행한 쿼리들이 어떻게 동작하는지만 이해하고 넘어가려 했지만, 이를 이해하기 위해서는 Commit, Rollback의 내부 동작 부터 시작해 Undo Log, Undo Log Buffer와 File, Cache Miss, Hit, MVCC, Lock 등.. DB에 대한 전반적인 흐름과 개념들을 알아야 했다. 또한 DBMS 마다 격리 수준의 내용이 달라지다보니 이를 비교 분석해야 했다.
먼저 영상을 통해 기본 개념들을 학습했고, 여러 블로그 글들을 참고하여 정리해나갔다. chat gpt를 통해 이해한 내용을 검증하기도 했다. (근데 이녀석이 자꾸... 말을 바꾸네...??) 잘못된 내용을 바로잡을때마다 쓰고, 지우고를 반복했다. 그림을 갈아 엎어야할때는 마음이 너무 아팠지만, 그때 나사가 빠진(?) 부분이나 이해가 필요한 부분들을 시각적으로 찾을 수 있어서 매우 유익했다.
이 글은 필자가 이해한 내용을 정리한거라 틀린 내용이 있을 수 있다. 독자분들께서 읽고 수정이 필요한 부분이나 추가 정보가 있다면 꼭! 댓글로 부탁드린다! :)