쿠버네티스구조를보면나머지구성정보는 Pod 가재빠르게뜨면 문제가없을수있지만, API 서버와 etcd 는죽으면가용성에문제가생긴다. 그래서 API 서버는 2대, etcd 는 3대가 최소 필요하다. etcd 는 RAFT 라는분산합의알고리즘으로 인해 홀수 개를유지해야 해서 최소 3대가필요하다.
자바8 부터람다를 지원하면서 템플릿 메서드 패턴보다는 전략을 함수 객체 인자 형태로 전달하는 템플릿 콜백 패턴(전략 패턴) 이 모범이 되었다.
템플릿 메서드 패턴
abstract class Car {
// 공통 메서드
public void drive() {
System.out.println("운전 시작");
moving();
System.out.println("운전 완료");
}
// 변화하는 부분
abstract void moving();
}
class Hyundai extends Car {
@override
public void moving() {
...
}
}
상위 클래스의 변하는 부분들을 메서드로 분리해 하위 클래스에 재정의하는 기법을 템플릿 메서드 패턴이라고 한다. 상속을 사용한다.
템플릿 콜백 패턴 (전략 패턴)
// 선언부
class Car {
// 공통 메서드
public void drive(MovingStrategy movingStrategy) {
System.out.println("운전 시작");
movingStrategy.moving();
System.out.println("운전 완료");
}
}
// 사용부
// MovingStrategy 인터페이스에는 moving 이라는 추상 메서드 하나만 존재해야 한다.
Car myCar = new Car();
myCar.drive(() -> System.out.println('100km/h 주행 중'));
이와는 반대로 함수 객체를 사용하는 시점(moving 함수 호출)에 전략 함수 객체를 인자로 받아 실행하는 형태를 템플릿 콜백 패턴. 즉 전략패턴이라고 한다. 함수 생성 시점에 전략 객체를 같이 생성하여 강하게 결합하는 것이 아닌, 사용하는 시점에 전략 객체를 인자로 받아 사용하고 제거하는 형태로 결합해 결합도가 낮아 유연하다는 장점이 있다.
LinkedHashMap 를 커스터마이징해서 더 자세히 알아보자.
LinkedHashMap 의 removeEldestEntry()함수는 맵에 새로운 키를 추가할 때 호출되는put()메서드에 의해 호출되는데, 해당 메서드가true를 반환하면 맵에서 가장 오래된 원소를 제거한다. 이 removeEldestEntry 함수를 오버라이드해서 가장 최신 5 개인 데이터만 유지하는 캐시를 만들어보자.
일단, removeEldestEntry 메서드는 인스턴스 메서드이므로 자기 자신도 함수 객체로 건네주어야 한다. Map<K, V> map
그리고 해당 인터페이스가 함수형 인터페이스임을 알 수 있도록 @FunctionalInterface 어노테이션을 붙여준다.
FunctionalInterface 어노테이션을 붙여주면 아래와 같은 이점이 있다.
해당 클래스의 코드나 문서를 읽을 이에게 인터페이스가 람다용으로 설계된 것임을 알려준다.
해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일 되게 해준다.
유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.
하지만 위와 같이 두 개의 인자를 받고 boolean 값을 리턴하는 모양의 인터페이스가 이미 자바 표준 라이브러리에 존재한다. BiPredicate 인터페이스다.
// java.util.function 패키지에 선언되어 있는 BiPredicate 형태
@FunctionalInterface
public interface BiPredicate<T, U> {
boolean test(T t, U u);
}
// 별도로 인터페이스를 만들 필요 없다. BiPredicate 사용
BiPredicate<Map<String, String>, Map.Entry<String, String>> removalFunction
= (map, eldest) -> map.size() > 5;
별도로 인터페이스를 선언할 필요없이 바로 사용하면 된다. "두 개의 인자를 받아 검증한다" 이외에 다른 기능이 존재하지 않는다면 별도로 만들지 않고 표준 라이브러리 기능을 사용하는 것을 권장한다.
아래 코드는 위에서 언급한 3가지 경우의 수를 테스트해볼 수 있는 코드이다.
OverrideLinkedHashMap 이 직접 클래스에 전략을 override 를 한 케이스(템플릿 메서드 패턴)이고, FunctionalLinkedHashMap 과 BiPredicateLinkedHashMap 이 전략을 함수 인자 형태로 전달한 케이스(템플릿 전략 패턴) 이다.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiPredicate;
public class MyLinkedHashMap{
@FunctionalInterface
public interface EldestEntryRemovalFunction<K, V> {
boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
}
public static void main(String[] args) {
// OverrideLinkedHashMap
Map<Integer, Integer> overrideMap = new OverrideLinkedHashMap<>();
for (int i = 0; i < 10; i++){
overrideMap.put(i, i);
}
System.out.println(overrideMap);
// FunctionalLinkedHashMap
Map<Integer, Integer> functionalMap = new FunctionalLinkedHashMap<>((map, eldest) -> map.size() > 5);
for (int i = 0; i < 10; i++){
functionalMap.put(i, i);
}
System.out.println(functionalMap);
// BiPredicateLinkedHashMap
Map<Integer, Integer> functional2Map = new BiPredicateLinkedHashMap<>((map, eldest) -> map.size() > 5);
for (int i = 0; i < 10; i++){
functional2Map.put(i, i);
}
System.out.println(functional2Map);
}
private static class OverrideLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > 5;
}
}
private static class FunctionalLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
private final EldestEntryRemovalFunction<K, V> eldestEntryRemovalFunction;
public FunctionalLinkedHashMap(EldestEntryRemovalFunction<K, V> function) {
this.eldestEntryRemovalFunction = function;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return eldestEntryRemovalFunction.remove(this, eldest);
}
}
private static class BiPredicateLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
private final BiPredicate<Map<K, V>, Map.Entry<K, V>> eldestEntryRemovalFunction;
public BiPredicateLinkedHashMap(BiPredicate<Map<K, V>, Map.Entry<K, V>> function) {
this.eldestEntryRemovalFunction = function;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return eldestEntryRemovalFunction.test(this, eldest);
}
}
}
java.util.function 패키지에 43개의 인스턴스가 포함되어 있으며 아래 6개의 인터페이스가 기본적인 인터페이스다. 나머지는 충분히 유추해낼 수 있다.
인터페이스
함수 시그니처
예
UnaryOperator<T>
T apply(T t)
String::toLowerCase
BinaryOperator<T>
T apply(T t1, T t2)
BigInteger::add
Predicate<T>
boolean test(T t)
Collection::isEmpty
Function<T,R>
R apply(T t)
Arrays::asList
Supplier<T>
T get()
Instant::now
Consumer<T>
void accept(T t)
System.out::println
아래의 6개 인터페이스들은 모두 참조 타용이다. 참고로 기본 인터페이스는, 기본 타입인int,long,double용으로 각 3개씩 변형이 생겨난다. 나머지는 코드나 문서를 참고하자.
보통의 경우에는 직접 작성하지 않고 표준 함수형 인터페이스를 사용해야 하지만, 구조적으로 같아도 직접 작성해야 하는 경우가 존재한다. 예를 들어Comparator<T>인터페이스는 구조적으로ToIntBiFunction<T,U>와 동일하다. 하지만Comparator가 독자적인 인터페이스로 남아야 하는 이유는 다음과 같다.
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
API에서 자주 사용되며, 이름 자체가 용도를 명확히 설명해준다.
구현하는 쪽에서 반드시 따라야 하는 규약이 있다.
유용한 디폴트 메서드를 여러 개 제공할 필요가 있다. (위 Comparator 예에서는 비교자들을 조합하고 변환하는 메서드를 제공)
마지막으로, 위와 같은 함수형 인터페이스도 주의사항이 있다. 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안된다. 클라이언트에게 모호함을 안겨줄 뿐만 아니라, 둘 중에 어떤 타입인지 알기 위해 사용하는 쪽에서 타입 형변환을 해야할 수도 있다. "다중정의는 주의해서 사용하라" 라는 아이템 52 조언을 한 번 더 강조한다.
위 코드는 자바 8 때, Map 에 추가된 merge 메서드이다. 키, 값, 함수를 인수로 받으며 주어진 키가 맵에 없다면 주어진 [키, 값] 쌍을 그대로 저장하고, 반대로 키가 있으면 [키, 함수의 결과] 쌍을 저장한다. 깔끔해 보이지만, count 와 incr 가 크게 하는 일 없이 공간만 차지한다. 자바 8이 되면서 Integer 클래스는 이 람다와 같은 기능을 가진 정적 메서드 sum 을 제공했다.
map.merge(key, 1, Integer::sum);
더 간결해진 것을 볼 수 있다. 하지만 메서드 참조는 함수의 이름만 명시하기 때문에 단번에 이해가 되지 않을 수도 있다.
매개변수의 이름 자체가 프로그래머에게 힌트를 준다면 람다가 더 좋은 선택지가 될 수도 있다. (매개변수가 여러 개이고 함수 이름으로 단 번에 파악이 안 된다면)
단 번에 파악할 목적이 아니고 해당 함수의 선언 부분으로 이동하는 수고로움을 감수할 수 있다면, 똑같은 인자 구성으로 함수를 만든 다음, 메서드 참조를 사용하는 것이 더 좋다. 메서드 참조에는 기능을 잘 드러내는 이름을 지어줄 수 있고, 친절한 설명을 문서에도 남길 수 있으니 말이다. 하지만 같은 클래스 안에 있는 기능을 호출하는 것이라면 람다가 더 간결하다.
// 1 번째 방법
service.execute(GoshThisClassNameIsHumongous::action);
// 2 번째 방법
service.execute(() -> action());
메서드 참조에는 아래와 같이 5가지 유형이 있다.
메서드 참조 유형
예
같은 기능을 하는 람다
정적
Integer::parseInt
str -> Integer.parseInt(str)
한정적 (인스턴스)
Instant.now()::isAfter
Instant then = Instant.now(); t -> then.isAfter(t)
비한정적 (인스턴스)
String::toLowerCase
str -> str.toLowerCase()
클래스 생성자
TreeMap<K, V>::new
() -> new TreeMap<K, V>()
배열 생성자
int[]::new
len -> new Int[len]
"메서드 참조 쪽이 짧고 명확하다면 메서드 참조를 사용하고, 그렇지 않을 때만 람다를 사용한다"
Java 8 이전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스를 사용했었다. 이런 인터페이스의 인스턴스를 함수 객체라고 하는데 다른 곳에서 절대 사용하지 않는 가벼운 함수 객체라면 함수 객체를 인자로 받는 공간에 바로 new 연산자를 통해 생성할 수 있었다.
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
이렇게 인터페이스 (위 예제에서는 Comparator 인터페이스) 를 바로 new 연산자를 통해 사용하고, 구체적인 전략은 익명 클래스 내부에 작성함으로써 전략 패턴의 장점도 사용할 수 있다. 하지만 매우 간단한 코드임에도 불구하고 매우 길다.
Java 8 부터는 추상 메서드 하나짜리 인터페이스는 람다식으로 간결하게 작성할 수 있다.
매개변수와 반환값에 대한 타입은 각각 String 과 Int 이지만 생략할 수 있다. 컴파일 타임 때 컴파일러가 맥락을 파악하고 자동으로 타입을 추론해주기 때문에 가능하다. 간혹 컴파일러가 타입을 추론하지 못해 컴파일 오류가 발생할 수도 있지만, 그럴 때 직접 프로그래머가 명시하면 된다. 타입을 명시해야 코드가 명확한 경우를 제외하고 람다의 모든 매개변수 타입은 생략하자.
각 묶음의 카드의 개수가 A, B 라고 주어질 때, 한 번 두 묶음을 합칠 때 A+B 번의 비교를 해야한다. 만약 A, B, C, D 의 카드 개수를 순서대로 하나로 합치려고 할 때, (A+B) + ((A+B)+C) + ((A+B+C)+D) 번 총 비교해야 한다.
카드의 개수가 연속으로 주어질 때, 최소한 몇 번 비교해야 하는지 알아내시오.
2.문제예제
3.문제풀이(수동)
주어진 문제예제를 살펴보면, (10+20) 번 비교하고, 다시 그 묶음을 40 번과 비교해서 (10+20) + (30+40) 번 비교해야 한다. 정렬이 되어 있지 않는 경우도 살펴볼 수 있다. 10, 20, 28, 25 라면, (10+20) 번 비교하고, (28+25) 번 비교한 다음 두 카드비교를 더해 (10+20) + (28+25) + (10+20) + (28+25) 로 최소 166 번 비교할 수도 있다.
4.팩트추출
Fact 1: 한 번 합쳐진 값도 다시 하나의 값으로 인식해서 나머지 다른 값들과 비교해 최소끼리 계속 더해야 한다.
문제풀이(수동) 두 번째 예제를 보면 10+20 번 비교해서 한 번 합쳐진 값이 다른 값들과 비교하는데 나머지 25, 28 숫자보다 커서 25와 28끼리 합쳐야 한 것을 볼 수 있다.
Fact 2: Fact 1 번을 통해 연산한 결과 값들도 나머지 다른 값들과 함께 정렬되어 다시 연산에 쓰여야 된다는 것을 알 수 있다. 다시 말해보면, 삽입하면서 정렬이 되고 그 중 항상 최소값을 가져와야 한다.
5.문제전략
Fact 2 번을 통해 연산한 결과 값이 삽입하면서 정렬이 되어야 하고, 항상 최소값을 가져와야 한다는 점 때문에 우선순위 큐를 사용하면 된다.
5.소스코드
import java.io.*;
import java.util.PriorityQueue;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
int N = Integer.parseInt(br.readLine());
long total = 0;
PriorityQueue<Long> pq = new PriorityQueue<>();
for (int i = 0; i < N; i++) {
long cardValue = Long.parseLong(br.readLine());
pq.add(cardValue);
}
while (pq.size() > 1) {
long first = pq.poll();
long second = pq.poll();
total += first + second;
pq.add(first + second);
}
System.out.println(total);
}
}
집과 사무실의 시작점, 끝점과 주어진 길이 d 가 주어질 때, 길이 d 에 포함되는 집과 사무실 구간의 개수가 최대인 경우를 구하시오.
2.문제예제
3.문제풀이(수동)
예제들을 문제풀이하면서 아이디어를 얻을 수는 없다. 아래 팩트추출을 통해 모든 경우의 수를 점검해야 한다.
4.팩트추출
Fact 1: 범위가 포함이 되어있는지 확인할 때, 나머지 좌표들을 정렬하면 다른 한 쪽만 대소비교할 수 있어 복잡도를 줄일 수 있다. 아래는 구간들의 오른쪽 끝점을 기준으로 오름차순으로 정렬한 모습이다. 가장 아래쪽부터 1번, 2번, 3번이라고 지칭할 때 다양한 특징들이 발견된다.
먼저 1번을 기준으로 2번이 포함되지 않는다면 3번, 4번에서도 2번은 포함되지 않는다. (오른쪽 끝점이 계속 순차적으로 증가하는 형태이기 때문에) 한 번 제외된 구간은 다른 구간에서 계산할 때도 제외된다는 뜻이다.
Fact 2 : 거리 N 에 포함되지 않는 구간은 계산할 필요가 없다.
Fact 3: 이전 결과에서는 거리 N 에 포함이 되었더라도 다음에 계산할 때는 오른쪽 끝점이 이동하므로 포함되지 않을 수 있다. 이전 결과를 활용하기 위해 시작점도 정렬이 필요하다. 거리 N 에 포함되면 시작점을 포함했다가 오른쪽 끝점이 이동할 때 정렬되어 있는 시작점들에서 하나씩 꺼내 비교하면 모두 다 비교하지 않아도 된다.
5.문제전략
한 쪽을 정렬해서 이전 결과 값을 재활용해야 한다는 점, 다른 한 쪽도 삽입을 하면서 정렬을 해야한다는 점을 파악해야 한다. 삽입하면서 정렬하는 자료구조는 우선순위큐가 적절하다.
5. 소스코드
private static class Interval implements Comparable<Interval> {
int start, end;
Interval(int startVariable, int endVariable) {
start = startVariable;
end = endVariable;
}
@Override
public int compareTo(Interval o) {
return Integer.compare(this.end, o.end);
}
}
public static void main(String[] args) {
// 거리 N, Interval 은 집과 구간, 오른쪽 끝점으로 정렬
// ... (생략) ...
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (Interval interval : intervals) {
if (interval.start >= interval.end - N) {
count++;
pq.add(interval.start);
}
while (!pq.isEmpty() && pq.peek() < interval.end - N) {
pq.poll();
count--;
}
maximum = Math.max(maximum, count);
}
}
마커 인터페이스란, "아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스"이다. 예를 들어 아래와 같은 Cloneable 인터페이스나 Serializable 인터페이스 등이 있다.
public interface Cloneable {
}
Serializable 인터페이스를 구현한 클래스의 인스턴스들은 ObjectOutputStream을 통해 write 할 수 있다고, 즉 직렬화(serialization)할 수 있다고 "Serializable" 단어만으로도 바로 알 수 있다. 마커 애너테이션도 같은 역할을 한다. 그렇다면 마커 인터페이스와 마커 애너테이션은 어느 특징을 가지고 있고 언제 사용해야 하는지 확인해보자.
마커 인터페이스가 마커 애너테이션보다 나은 점은 다음과 같다.
1 . 타입으로 사용가능하다.
마커 인터페이스를 구현한 클래스의 인스턴스들은 타입으로 구분할 수 있으나, 마커 애너테이션은 그렇지 않다. 마커 인터페이스는 어엿한 타입이기 때문에, 마커 애너테이션을 사용했다면 런타임에야 발견될 오류를 컴파일타임에 잡을 수 있다.
2 . 마커 인터페이스는 적용 대상을 더 정밀하게 지정할 수 있다.
애너테이션은 @Target 이라는 메타 애너테이션을 통해 TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE 에만 적용할 수 있다. 만약 특정 인터페이스를 구현한 클레스에만 적용하고 싶은 마커가 있을 경우, 애너테이션만으로는 더 세밀하게 제한할 수 없다. 마커 인터페이스를 통해 구현하면 그 인터페이스의 하위 타입임을 보장할 수 있다.
마커 인터페이스가 마커 애너테이션이 마커 인터페이스보다 나은 점은 다음과 같다.
스프링같이 거대한 애너테이션 시스템의 지원을 받아 손쉽게 사용할 수 있다. 애너테이션을 적극 활용하는 프레임워크에서는 마커 애너테이션을 쓰는 쪽이 일관성을 지키는 데 유리하다.
마커 인터페이스를 사용해야 하는 경우
마킹이 된 객체를 매개변수로 받는 메서드를 작성할 필요가 있다면 반드시 인터페이스를 사용해야 한다. 컴파일타임에 오류를 잡을 수 있는 강점이 있다.
마커 애너테이션을 사용해야 하는 경우 클래스와 인터페이스 외 프로그램 요소 (모듈, 패키지, 필드, 지역변수 등)에 마킹해야 할 때는 애너테이션을 쓸 수 밖에 없다. 클래스와 인터페이스만이 인터페이스를 구현하거나 확장할 수 있기 때문이다. 애너테이션을 활발히 활용하는 프레임워크를 사용하는 경우에도 사용할 수 있다.
INSERT INTO Bugs_2010 (..., date_reported, ...)
VALUES (..., '2010-06-01', ...);
INSERT INTO Bugs_2011 (..., date_reported, ...)
VALUES (..., '2011-02-20', ...);
날짜연도에 따라서 테이블을 생성해야 하는데 테이블 생성하는 것을 잊어버렸다면 에러가 발생할 수 있다.
또한, 한 해 동안 버그 개수를 세보려고 하는데 통계 정합성이 맞지 않을 수 있다. 2010 년 관련 값이 Bugs_2009 테이블에 삽입되었던 것이다. 잘못된 어플리케이션의 로직을 방어하기 위해 데이터베이스 기준으로 제약할 수 있는 방법이 있긴 하다. 아래와 같이 CEHCK 조건을 필드에 적용하면 된다.
UPDATE 구문으로 단순히 데이터를 변경하고 싶지만, 테이블을 나눈 기준 열 칼럼의 값이 바뀌면 해당 테이블에서 삭제하고 다른 테이블에 데이터를 옮겨야한다.
INSERT INTO Bugs_2009 (bug_id, date_reported, ...)
SELECT bug_id, date_reported, ...
FROM Bugs_2010
WHERE bug_id = 1234;
DELETE FROM Bugs_2010 WHERE bug_id = 1234;
3 . 유일성 보장
테이블을 나누는 기준인 PK 칼럼은 유일함이 보장되어야 한다. 한 테이블에서 다른 테이블로 행을 옮겼을 때 PK 값이 다른 행과 충돌하지 않는다는 확신이 있어야 한다.
4 . 여러 테이블에 걸쳐 조회
여러 테이블에 걸쳐 조회할 필요가 생길 때 분리된 모든 테이블을 UNION 으로 묶어서 재구성한 다음 쿼리를 실행해야 한다.
SELECT b.status COUNT(*) AS count_per_status FROM (
SELECT * FROM Bugs_2008
UNION ALL
SELECT * FROM Bugs_2009
UNION ALL
SELECT * FROM Bugs_2010 ) AS b
GROUP BY b.status;
5 . 테이블에 새로운 칼럼 추가 시 모두 변경
테이블에 새로운 칼럼 추가 시 모든 분리된 테이블에 똑같은 칼럼을 추가해야 한다.
[해결방법]
수평 분할 사용과 수직 분할 사용을 고려할 수 있다.
1 . 수평 분할
행을 여러 파티션으로 분리하는 규칙과 함께 논리적 테이블을 생성하는 것만으로도 충분하다. 물리적으로는 테이블이 분리되었지만, 논리적으로는 하나의 테이블처럼 사용할 수 있다.
CREATE TABLE Bugs (
bug_id SERIAL PRIMARY KEY,
...
date_reported DATE
) PARTITION BY HASH (YEAR(date_reported))
PARTITIONS 4;
테이블을 직접 분리했을 때랑 다르게 잘못된 데이터가 분리된 테이블로 들어갈 위험이 없다는 장점이 있다. 분리 기준이 되었던 칼럼의 값을 업데이트해도 문제가 없고 분리 테이블을 모두 접근해서 쿼리를 할 필요도 없다.
다만, 위 예제에서 4년 이상이 된 데이터가 있다면, 파티션 중 하나에는 두 연도의 데이터가 들어갈 수 있다.
2 . 수직 분할
수직 분할은 테이블에 있는 칼럼 중 크기가 아주 큰 칼럼이거나 거의 사용되지 않는 칼럼이 있을 경우 고려할 수 있는 방법이다. BLOB 이나 TEXT 칼럼은 크기가 가변적이고 매우 커질 수 있는 칼럼이라서 같은 테이블에서 조회 & 수정 & 삭제 시 성능 저하를 초래한다. 거의 사용되지 않는 칼럼도 리소스 낭비가 있을 뿐이다. 이렇게 가변적이거나 크기가 아주 크거나 자주 사용되지 않는 칼럼들만 모아서 별도의 종속 테이블로 분리하는 것이 좋다.
// 고정 크기의 타입만 정의
CREATE TABLE Bugs (
bug_id SERIAL PRIMARY KEY,
summary CHAR(80),
date_reported DATE,
reported_by BIGINT UNSIGNED,
FOREIGN KEY (reported_by) REFERENCES Accounts(account_id)
)
// 가변 크기의 타입만 정의
CREATE TABLE BugDescriptions (
bug_id BIGINT UNSIGNED PRIMARY KEY,
description VARCHAR(1000),
resolution VARCHAR(1000),
FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id)
)