Skip to main content
Hea02y
developer @vestellalab
View all authors

MongoDB 연동을 위한 튜토리얼

· 2 min read
//Config

@Configuration
@EnableMongoAuditing
class MongoConfig {
}
//Repository

interface MailContentsRepository : MongoRepository<MailContents, String> {
}
//Entity(Document)
@Document(collection = "mail_contents")
class MailContents(

@Id
val id : String? = null,

var name : String,

var content : String,

var category: MailCategory = MailCategory.NONE,

var siteLink: String,

@CreatedDate
val createdAt: Instant? = null,

@LastModifiedDate
val updatedAt: Instant? = null,
)

yaml 에서 설정을 할때 2가지 방식으로 가능하다. 나는 첫번째 방식인 URI를 통해서 연동을 진행했다.

spring:  
data:
mongodb:
uri: mongodb+srv://{id}:{password}@{uri}/{dbName}?retryWrites=true&w=majority
spring:
data:
mongdb:
host:
port:
user:
password:

TestCode를 통해 DB에 insert를 진행하니 정상적으로 데이터가 저장된다.

@Test  
@DisplayName("Insert 테스트")
fun insert() {
val mailContents =
MailContents(null, "name", "contents", MailCategory.NONE, "test.com", Instant.now(), Instant.now())
mailContentsRepository.save(mailContents)
}

작업결과

작업결과

확인

확인

이때 RDB는 서버 기동과 동시에 DDL을 통해 테이블과 스키마를 만들지만, MongoDB에서는 컬렉션을 미리 만들지 않고, 첫 insert가 진행되는 시점에서 자동 생성된다. 간단하게 정리해보자.

RDB

  • 테이블 먼저 생성
  • 그 다음에 INSERT 가능

MongoDB

  • 컬렉션을 미리 안 만들어도 됨
  • 첫 insert 시점에 자동 생성

레빗엠큐 구성하면서 어려웠던거

· 2 min read

자 A가 Publish(발행) B가 Consume(구독)한다면, A는 큐의 존재를 알필요가 없다. 그저 exchange에다가 넣어두면 구독자가 스스로 내가 어떤큐를 소비할지를 정하면 된다. 즉 큐를 만드는 주체는 B가 된다. 아래 소비 시나리오를 참고하자.

  1. B 서버가 실행됨
  2. “나는 APT001/B1 데이터를 소비한다” → Queue + Binding 생성
  3. A서버는 routingKey만 보고 발행
  4. RabbitMQ가 그때 만들어진 Queue로 메시지 라우팅
  5. B서버 가 안정적으로 소비

레빗엠큐 기본철학 "발행자는 Queue 이름을 몰라야 한다." Queue는 Consumer(Application that receives messages)가 선언해야 한다. Producer는 Exchange + Routing Key만 알면 충분하다.

자바로 배우는 자료구조 알고리즘 정리

· 22 min read

이 내용은 '자바로 배우는 자료구조 알고리즘' 책을 읽고 내용을 정리한 내용이다. 해당 내용에 대한 실습 및 예제 코드는 아래 깃허브 링크를 참고하면 된다.

깃허브 Repo

LinkedList 클래스

ArrayList의 이점은 get, set 메서드에서 나온다. LinkedList는 심지어 이중연결리스트인 경우에도 선형 시간이 필요하다.

만약 프로그램의 실행시간이 get, set에 의존한다면 ArrayList는 좋은 선택이다, 실행시간이 시작, 끝 근처의 요소에 추가 삭제 연산에 의존한다면 LinkedListrk 좋은 선택이다.

이외에 고려할 요소는

  1. 연산이 응용프로그램의 실행시간에 뚜렷한 영향을 미치지 않는다면 List 구현에 대한 선택은 의미없음
  2. 작업하는 리스트가 매우 크지않으면 큰 성능차이를 얻기 어렵다. 작은 문제에서는 이차 알고리즘이 선형알고리즘보다 빠르기도 하고, 또 선형알고리즘이 상수시간보다 빠르기도 하다. 즉 작은 문제에서는 이러한 차이가 큰 의미를 두지 않는다.
  3. 공간에 대해서도 잊지 말아야한다. ArrayList에서는 배열 기반으로 요소들은 한덩어리의 메모리안에서 나란히 저장되어 거의 낭비되는 공간이 없고, 컴퓨터 하드웨어도 연속된 공간에서 연산속도가 종종 더 빠르다. LinkedList에서 각 요소는 하나 혹은 두개의 참조가 있는 노드가 필요하고, 참조는 공간을 차지한다. 또한 메모리 여기저기에 노드가 흩어지면 하드웨어의 효율이 떨어진다.

성능 측정

JavaFX 라이브러리 사용해서 한번 그려보자.

트리순회

Q: Stack과 같은 자료형은 List보다 더 적은 메서드를 가지는데 굳이 써야하는가?

  1. 메서드 개수를 작게 유지하면, 코드의 가독성 향상, 오류 발생 가능성 감소
  2. 자료구조에서 작은 API를 제공하면, 효율적인 구현이 쉬움. 스택을 구현하는 단순한 방법은 단일 연결 리스트를 사용. 요소를 스택에 push하면 리스트의 시작에 요소를 추가. pop하면 시작에서 요소를 제거. 연결리스트에서 시작에 요소를 추가 삭제 하는건 상수시간 연산이므로 구현이 효율적임
  3. ArrayDeque나 Deque 인터페이스를 구현한 클래스를 사용하는것이 가장 좋은 방법

철학으로 가는길

Iterable 과 Iterator

Iterable, Iterator는 컬렉션을 순회하기위해 사용하는 두가지 중요한 인터페이스다.

iterable 상속 구조

iterable 상속 구조

Iterable

자바에서 반복가능한 객체를 나타내는 인터페이스. 이를 구현한 클래스로는 for-each문을 사용하여 요소 순회가 가능. Iterator<T> iterator() : Iterable 인터페이스의 핵심 메서드로, 이를 통해 Iterator 객체를 반환한다. 이 메서드를 구현함으로써, 컬렉션 클래스는 반복 가능한 객체로 사용할 수 있게 된다.

import java.util.ArrayList;
import java.util.List;

public class IterableExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("cherry");

// for-each 문을 사용하여 순회
for (String item : list) {
System.out.println(item);
}
}
}

Iterator 인터페이스

이터레이터는 컬렉션의 요소를 순차접근하기위한 인터페이스로 Iterator는 Iterable이 제공한 컬렉션을 직접 탐색하며, 각 요소에 접근할수있는 방법을 제공한다. Iterator를 사용하면 컬렉션의 각요소를 직접 제어하며 순회 가능하다.

  • boolean hasNext() : 다음 요소가 있는지 확인한다. 요소가 남아 있으면 true, 아니면 false를 반환한다.
  • T next() : 다음 요소를 반환하며, 현재 위치를 다음으로 이동시킨다.
  • void remove() : 현재 Iterator가 가리키고 있는 요소를 제거한다. (선택적 메서드로, 모든 Iterator에서 반드시 구현해야 하는 것은 아니다)
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class IteratorExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("cherry");

// Iterator를 사용하여 순회
Iterator<String> iter = list.iterator(); // iterator 객체를 얻는다
while (iter.hasNext()) {
String item = iter.next();
System.out.println(item);
}
}
}

위의 코드에서 iterator() 메서드를 호출하여 Iterator 객체를 얻고, while 문을 사용해 hasNext()로 다음 요소가 있는지 확인하면서 next()를 통해 요소를 가져온다.

둘의 차이점

  • Iterable : 순회 할수있는 객체를 의미, Iterator() 메서드를 통해 Iterator를 생성, for-each를 통해 컬렉션을 간결하게 순회가능
  • Iterator : 실제로 순회작업을 수행하는 객체로 컬렉션요소에 하나씩 접근 가능, 직접 요소를 순회하며 특정조건에 따라 요소를 제거하거나 순회 과정을 세밀하게 제어 가능

DFS 구현 방법 2가지

  • DFS는 깊이우선탐색으로 트리의 루트에서 시작하여 첫번째 자식노드를 선택하고 선택된 노드가 자식을 가지고 있다면 첫번째 자식을 다시선택, 반복후 자식이 없는 노드에 도착하면 부모노드로 거슬러 올라가 다음자식이 있다면 그쪽으로 이동하는 식으로 동작한다.
    dfs 순서

    dfs 순서

방법1. 재귀적 방법

메서드의 루트에서 시작해서 트리에 있는 모든 노드를 호출할때까지 반복된다. 아래 구현 코드를 보면 자식노드를 탐색하기전 TextNode의 내용을 출력하므로 전위순회에 해당한다.

private static void recursiveDFS(Node node) {  
if (node instanceof TextNode) {
System.out.print(node);
}
for (Node child: node.childNodes()) {
recursiveDFS(child);
}
}

방법2. 반복적 방법

반복적 방법으로 구현을 할때는 Stack 자료구조를 통해 구현이 가능하다. 반복적인 방법으로 구현을 알아보기에 앞서서 스택이라는 자료구조를 아래에 간단히 정리해보자.

스택은 리스트와 유사한 자료구조형으로 요소의 순서를 유지하는 컬렉션이다. 스택의 pop메서드는 항상 최상단의 요소를 반환하므로 스택은 LIFO 자료구조 이다. 이에 반해 큐 자료구조는 FIFO 이다. 일반적인 규약에서 제공하는 스택의 메서드는 다음과 같다.

  • push : 스택의 최상단에 요소를 추가
  • pop : 스택의 최상단에 있는 요소를 제거하고 반환
  • peek : 최상단 요소를 반환하지만 요소는 그대로 둠
  • isEmpty : 스택이 비었는지 알려줌
private static void iterativeDFS(Node root) {  
Deque<Node> stack = new ArrayDeque<Node>();
stack.push(root);

while (!stack.isEmpty()) {

Node node = stack.pop();
if (node instanceof TextNode) {
System.out.println(node);
}

List<Node> nodes = new ArrayList<Node>(node.childNodes());
Collections.reverse(nodes);

for (Node child: nodes) {
stack.push(child);
}
}
}

인덱스

Index는 인덱스란 검색 속도를 향상시키기 위한 자료구조이다.

인덱스는 조회 연산이다. 가장 단순한 구현은 페이지의 컬렉션이다. 검색어가 주어지면 페이지의 내용을 반복 조사하여 검색어를 포함하는 페이지를 선택하는 방법이다. 하지만 실행시간이 모든 페이지의 전체 단어수에 비례하여 매우 느리다.

Map 과 Set 자료구조

이보다 나은 대안으로 Map 자료구조를 활용해보자. Map은 키-값 쌍의 컬렉션으로 키와 키에 해당하는 값을 찾는 빠른 방법을 제공한다. 자바의 Map 인터페이스는 맵을 구현하는데 필요한 메서드를 정의한다. 자바 Map 인터페이스는 몇가지의 구현을 제공하는데 이중 HashMap, TreeMap 두 클래스를 집중적으로 알아볼 것이다.

  • get(key) : 키를 조사하여 관련된 값을 반환
  • put(key,value) : Map에 새로운 키-값을 추가하거나 이미 있는 키면 값을 업데이트 한다.

LinearMap을 구현한뒤 HashMap, TreeMap과의 성능을 비교해보자. 그전에 Entry에 대해서 알아보자. 엔트리는 아래와 같이 설명할수있다.

Java의 Map.Entry는 Map 인터페이스 내부에 정의된 중첩 인터페이스(Inner Interface)로, Map에 저장되는 키-값(Key-Value) 쌍 그 자체를 다루는 객체이다. entrySet() 메서드를 통해 Map의 각 요소를 쌍으로 순회하거나, 키와 값을 정렬 및 조작하는 데 주로 사용된다.

Map인터페이스내부의 Entry

Map인터페이스내부의 Entry

Entry는 단지 키와 값의 컨테이너로 Map클래스에 중첩되어 있으므로 같은 타입 파라미터인 K,V를 사용한다. LinearMap의 핵심 로직은 findEntry(), equals()에 있다. put, get, remove와 같은 메서드는 findEntry를 호출하여 구현한다.

public class MyLinearMap<K, V> implements Map<K, V> {  
private List<Entry> entries = new ArrayList<Entry>();

...
}
private Entry findEntry(Object target) {  
for (Entry entry: entries) {
if (equals(target, entry.getKey())) {
return entry;
}
}
return null;
}

private boolean equals(Object target, Object obj) {
if (target == null) {
return obj == null;
}
return target.equals(obj);
}

equals 메서드의 실행시간은 target과 key에 의존하지만 엔트리 개수에 해당하는 n에는 의존하지 않는다. 즉 equals는 상수시간이다. 그리고 findEntry 메서드는 운좋으면 첫번째에 원하는 키를 찾을수도있지만, n(엔트리 개수)에 비례하므로 선형시간의 연산이된다. put,get,remove와 같은 메서드들이 findEntry를 사용한다고 했으므로, 이 메서드들은 선형시간의 연산이다. 전체적인 LinearMap의 구현은 repository를 확인해보자.

이전에 작성한 MyLinearMap의 성능을 향상시킨 버전인 MyBetterMap 클래스를 만들고 테스트해보자. 먼저 엔트리를 하나의 커다란 List에 저장하는 대신 다수의 작은 리스트로 쪼개고, 각 키에 대해서 해시코드를 사용해서 어느 리스트를 사용할지 선택하는 방식으로 구현한다.

public class MyBetterMap<K, V> implements Map<K, V> {  
protected List<MyLinearMap<K, V>> maps;

...
}

MyBetterMap에서는 내장된 맵에 따라 리스트를 나누므로 각 맵별로 엔트리 개수가 줄어든다. 이부분이 findEntry 메서드와 이를 호출하는 메서드의 속도를 빠르게 해준다.

내가 만든 MyLinearMap의 선형시간을 개선하기 위해 Hashing을 사용해보자. 해싱은 임의의 길이를 가진 데이터를 고정된 길이의 고유한 데이터로 변환하는 방법이다. 이함수는 Object 객체를 인자로 받아 해시코드라는 정수로 반환한다. 중요한 점은 같은 Object 객체에 대해서 항상 같은 해시코드를 반환해야한다. 이렇게 해시코드를 사용하여 키를 저장하면, 키를 조회할때 항상 같은 해시코드를 얻게 된다.

해싱

해싱

자바에서 모든 Object 객체는 hashCode라는 메서드를 제공하여 해시함수를 계산한다. MyBetterMap에서는chooseMap() 메서드에서 해시코드를 사용한다. 이 메서드는 키에 대한 적합한 하위 맵을 고르는 헬퍼 메서드이다.

Object.hashCode

Object.hashCode

protected MyLinearMap<K, V> chooseMap(Object key) {  
int index = key==null ? 0 : Math.abs(key.hashCode()) % maps.size();
return maps.get(index);
}

hashCode 메서드를 호출해서 정수를 얻고, Math.abs 메서드를 호출해서 절대값을 만든뒤 나머지 연산을 통해 결과가 0에서 map.size()-1 사이의 값임을 보장한다. 이에따라 index는 항상 maps의 유효한 인덱스가 되고, chooseMap은 선택한 맵의 참조를 반환한다. 이에 대한 성능은 n개의 엔트리를 k개의 하위 맵으로 나누면 맵당 엔트리는 평균 n/k개가 된다. 키를 조회할때 해시코드를 계산해야하는데 이때 시간이 조금 추가된다. 그다음에 키에 맞는 하위맵을 검색한다. 이를 통해 MyBetterMap은 MyLinear맵에 비해 k배 빨라졌다. 하지만 실행시간은 여전히 n에 비례하므로 선형시간이다.

해싱에 대해서 조금더 깊이 알아보자. 해시함수의 근본적인 요구사항은 같은 객체는 매번 같은 해시코드를 만들어야한다는 것이다. 불변객체(Immutable Object)일때는 상대적으로 쉽지만, 가변객체(Mutable Object)일때는 좀더 고민이 필요하다.

불변 객체의 예시로 String을 캡슐화하는 SillyString을 정의해보자

  public class SillyString {  
private final String innerString;

public SillyString(String innerString) {
this.innerString = innerString;
}

public String toString() {
return innerString;
}

@Override
public int hashCode() {
int total = 0;
for(int i=0;i<innerString.length();i++) {
total += innerString.charAt(i);
}
return total;
}

@Override
public boolean equals(Object obj) {
return this.toString().equals(obj.toString());
}
}

SillyString클래스는 equals, hashCode를 오버라이드 하여 동작한다. 제대로 동작하려면 equals메서드는 hashCode메서드와 일치해야한다. 이는 두객체가 같다면(equals메서드가 true를 반환하면) 두객체의 해시코드 또한 같아야한다. 이는 단방향으로 두객체의 해시코드가 같더라도 그들이 같은 객체일 필요는 없다.

위에서 구현한 해시함수는 정확하게 동작하지만 좋은 성능을 보장하진않는다. 왜냐하면 많은 서로다른 문자열을 위해 같은 해시코드를 반환하기 때문이다. 두 문자열에 같은 문자가 순서만 다르게 포함되어있다면 이들은 해시코드가 같아진다. 예시로 'ac', 'ca' , 'bb' 는 모두 같은 해시코드를 같는다.

많은 객체가 동일한 해시코드를 가지면 같은 하위 맵으로 몰리게 된다. 어떤 하위맵에 다른맵보다 많은 엔트리가 있으면 k개의 하위맵으로 인한 성능향상이 k보다 줄어들게 된다. 그래서 해시함수의 목표중 하나는 균등함이다. 즉 일정 범위에 있는 어떤 값으로 골고루 퍼지도록 해시코드가 생성되게 설계해야한다.

String 클래스는 불변이고, innerString 변수가 final로 선언되어 SillyString 클래스 또한 불변이다. 일단 SillyString 객체가 생성되면 innerString 변수는 다른 String 객체를 참조할수없고 이변수가 참조하는 String 객체도 변경할수없다. 즉 항상 같은 해시코드를 같게 된다. 하지만 가변 객체라면 어떨까?

public class SillyArray {  

private final char[] array;

public SillyArray(char[] array) {
this.array = array;
}

public String toString() {
return Arrays.toString(array);
}

@Override
public int hashCode() {
int total = 0;
for (int i = 0; i < array.length; i++) {
total += array[i];
}
System.out.println("code : " + total);
return total;
}

@Override
public boolean equals(Object obj) {
return this.toString().equals(obj.toString());
}

public void setChar(int i, char c) {
this.array[i] = c;
}
}

SillyArray클래스는 위와같다. 인스턴스 변수로 String이 아닌 문자 배열을 사용하는데, SillyArray클래스는 setChar 메서드를 통해 배열에 있는 문자를 변경할수있다. 간단한 예제를 살펴보자.

SillyArray array = new SillyArray("abc".toCharArray());  
MyBetterMap<SillyArray, Integer> map = new MyBetterMap<SillyArray, Integer>();
map.put(array, 1);

array.setChar(0, 'd'); //값 변경
map.get(array);

결과

결과

변경후 해시코드가 변경된다. 해시코드가 달라서 잘못된 하위맵을 조회할수도 있는것이다. 이러한 상황에서는 키가 맵에 있어도 찾을수없게된다. 일반적으로 해싱을 사용하는 자료구조에서 가변객체를 키로 사용하는것은 위험하다. 키가 맵에 있는동안 변형되지 않는다고 보장할수있거나 어떤 변화가 해시코드에 영향을 미치지않아야한다.

다음으로 Set 인터페이스에 대해서 알아보자. 교집합 연산이 필요한 경우 Set을 사용할수있다. Set은 실제 교집합 연산을 제공하지는 않지만 교집합연산과 다른 집합 연산을 효율적으로 구현 할수있는 메서드를 제공한다.

  • add(element) : 집합에 요소를 추가, 동일한 집합이 있다면 효과가 없음
  • contains(element) : 주어진 요소가 집합에 포함되어 있는지 확인

자바는 HashSet, TreeSet 클래스로 Set인터페이스의 구현을 제공한다.

개인프로젝트에 AI 코드리뷰를 도입해보자 (feat. 코드래빗)

· 6 min read

개인 프로젝트를 진행하면서 백엔드를 혼자 구현하다보니 코드리뷰가 불가능한 상황이였다. 이전에 만들었던 딥시크 기반 코드리뷰어를 사용해 볼까 하다가, 써보자 하고 계속 미뤄졌던 coderabbit서비스를 한번 도입해 보기로 하였다. 구현 과정에서 놓친 부분을 AI로 보완하고, 추가적인 학습 인사이트를 얻을 수있었던 경험을 글로 남겨보려고한다.

코드래빗이 뭔가요?

코드레빗 소개

코드레빗 소개

위의 이미지는 코드래빗 공식문서에 나와있는 내용이다. 간단히 말해 AI를 통해 코드 검토를 진행하는 AI코드 리뷰어 라고 할수있다.

적용 방법

  1. 코드래빗 사이트에서 Github 계정으로 로그인을 한다.

메인 화면

메인 화면

  1. 로그인 후 코드래빗 적용을 원하는 repository를 선택한다.

레포지토리 선택

레포지토리 선택

  1. 적용이 끝났다. 이제 설정해둔 Repo에 PR이 올라오면 자동으로 코드래빗이 리뷰를 달아준다. 만약 PR을 올려도 동작하지 않는다면 4번을 따라하자.

  2. Organization Settings > Configurations > Review > Auto Review > Drafts에 BaseBranch 설정을 해주자. 이때 설정은 .*을 넣어주면 된다.

    설정1

    설정1

    설정2

    설정2

  3. 결과를 보면 정말 상세하게 리뷰를 잘달아준다. 특히 3번째 이미지를 보면 리뷰에 남긴 댓글에 대댓글까지 남겨준다.

    walkthrough 펼쳐보기

    walkthrough 펼쳐보기

    다이어그램

    다이어그램

    댓글

    댓글

상세 조정

코드래빗 설정 페이지의 Organization Settings 메뉴에서 조직의 설정을 하는것이 가능하지만, 누군가는 각 레포지토리 별로 상세 설정을 하고 싶을수있다. 이런경우 YAML을 이용해서 코드래빗을 구성하는것도 가능하다.

파일의 위치는 루트 폴더에 .coderabbit.yaml으로 배치하면 작성해둔 커스텀한 설정이 반영된다. 파일 설정 방법은 아래의 공식문서에 자세히 나와있으니 참고해서 구성해보자.

(예시)

# ---------------------------------------------------------------- #
# AI 기본 설정 (언어 및 페르소나)
# ---------------------------------------------------------------- #

# AI가 응답할 때 사용할 기본 언어를 설정합니다. (예: ko-KR, en-US)
language: ko-KR
# AI의 페르소나와 응답 톤앤매너를 상세하게 지시합니다. 최대 200자까지 작성할 수 있습니다.
tone_instructions: >
당신은 대한민국 1등 백엔드 개발자입니다. 목표는 현재 진행중인 프로젝트의 코드 품질을 개선하고 적용할수있도록 돕는것입니다.
1. 피드백은 명확하고 구체적이어야 하며, 문제의 원인과 개선 방법을 반드시 제시하세요.
2. 리뷰는 교육적이어야 하며, 관련 개념이나 공식 문서를 함께 추천하세요.
3. 비판보다는 개선 중심의 제안을 우선하세요.
4. 칭찬은 짧고 위트 있게 작성하세요.

# ---------------------------------------------------------------- #
# 자동 리뷰 트리거 설정
# ---------------------------------------------------------------- #
auto_review:
# PR이 생성됐을 때
# `@coderabbitai review`를 코멘트에 입력하면 리뷰를 시작합니다.
enabled: false
# 이미 리뷰가 진행된 PR에 새로운 커밋이 추가될 때, 변경된 부분에 대해서만 자동으로 리뷰를 진행할지 여부입니다.
auto_incremental_review: false

# ---------------------------------------------------------------- #
# CodeRabbit의 지식 기반(Knowledge Base) 설정
# ---------------------------------------------------------------- #
knowledge_base:
# 웹 검색을 통해 최신 정보나 문서를 참조할 수 있도록 허용할지 여부입니다.
web_search:
enabled: true
# 레포지토리 내의 특정 파일을 AI의 코드 스타일 가이드라인으로 사용하도록 설정합니다.
code_guidelines:
enabled: true
filePatterns:
- docs/be-code-convention.md
- docs/fe-code-convention.md
# CodeRabbit이 대화나 리뷰를 통해 학습한 내용을 어디에 저장하고 참조할지 범위를 설정합니다. 'local'은 현재 레포지토리에 한정됨을 의미합니다.
learnings:
scope: local
# 이슈 정보를 참조할 범위를 설정합니다.
issues:
scope: local
# 풀 리퀘스트 정보를 참조할 범위를 설정합니다.

API 아키텍처s (feat. WSDL 연동하기...)

· 10 min read

백앤드 개발을 하면서 RESTful한 API은 자주 사용하였지만, 다른 API 아키텍처를 반영하는 경우는 거의 없었다. 특히 SOAP과 같은 조금은 오래된 기술들은 개념적으로 잠깐 찾아본적은 있어도 동작 방식이나 사용방법은 전혀 알지 못했다. 최근에 진행한 프로젝트에서 타사 프로그램을 연동하는 과정에서 WSDL를 통해 연동을 진행했는데 이때 배운 내용을 기록하고자 블로그를 작성한다.

다양한 아키텍처

둘 이상의 개별 애플리케이션이 통신하기 위해 개발자는 한시스템에서 다른 시스템의 정보나 기능에 접근할수있도록 API (Application Programming Interface) 라는 브릿지를 구축한다. 다양한 애플리케이션을 빠르고 대규모로 통합하기 위해서는 프로토콜 or Spec에 맞게 네트워크를 통해 전달되는 메세지의 의미, 구문을 정의한다. 이러한 사양이 바로 API 아키텍처이다.

시간이 지나가며 API 아키텍처도 다양한 방식으로 고도화되고 있다. 아래의 그림은 API 아키텍처의 발전과정을 볼수있는 타임라인이다.

출처 : Rob Crowley

출처 : Rob Crowley

각 아키텍처마다 데이터 교환에 대한 고유한 표준화 패턴을 가지고 있으며, 선택의 폭이 넓어짐에 따라 어떤 아키텍처가 적합한지에 대해서는 다양한 의견이 있고 현대의 API는 대부분 REST를 가리킨다. 하지만 기술의 실제 속성과 특성을 고려하지 않은채 기술 자체를 인기에 따라 편향적으로 선택하는 것은 지양해야한다. 자 그럼 본론에 들어가기 전에 간단하게 아래 그림을 확인해보자.

architecture

architecture

REST

REST는 Representational State Transfer의 약자로, 2000년도에 로이필딩 박사를 통해 웹의 장점을 최대한 활용할 수 있는 아키텍처로써 REST가 발표되었다. 이러한 REST는 아래 3가지로 구성된다.

  • 자원(Resource) - URI
  • 행위(Verb) - HTTP Method
  • 표현(Representations)

REST 아키텍처는 제한 조건을 준수하며 각 개별 컴포넌트는 제한조건안에서 자유롭게 구현이 가능하다. 아래의 6가지 제한조건을 확인해보자.

  1. 인터페이스 일관성
    • URI로 지정한 리소스에 대한 조작을 통일되고 한정적인 인터페이스로 수행하는 아키텍처 스타일
  2. 무상태(stateless)
    • 작업을 위한 상태정보를 따로 저장하고 관리하지 않는다.
    • 세션정보, 쿠키 정보를 별도로 저장 관리 하지않아 API 서버는 들어오는 요청만을 단순히 처리한다.
    • 서비스 자유도 상승, 서버에서 불필요한 정보 관리 X
  3. 캐시 처리 가능(cacheable)
    •  HTTP라는 기존 웹표준을 그대로 사용하여 HTTP의 캐싱기능 사용이 가능
    • HTTP 프로토콜 표준에서 사용하는 Last-Modified 태그나 E-Tag를 이용하면 캐싱 구현이 가능
  4. 계층화(layer system)
    • REST 서버는 다중 계층으로 구성될 수 있으며 보안, 로드 밸런싱, 암호화 계층을 추가해 구조상의 유연성을 둘 수 있고 PROXY, 게이트웨이 같은 네트워크 기반의 중간매체를 사용할 수 있게 한다.
  5. 클라이언트/서버 구조
    • REST 서버는 API 제공, 클라이언트는 사용자 인증이나 컨텍스트(세션, 로그인 정보)등을 직접 관리하는 구조
    • 각각의 역할이 확실히 구분되기 때문에 클라이언트와 서버에서 개발해야 할 내용이 명확해지고 서로간 의존성이 줄어든다.
  6. Code on demand (optional)
    • 자바 애플릿이나 자바스크립트의 제공을 통해 서버가 클라이언트가 실행시킬 수 있는 로직을 전송하여 기능을 확장시킬 수 있다.

이러한 내용을 바탕으로 REST API를 디자인 할때 URI는 정보의 자원을 표현해야하며, 자원에 대한 행위는 HTTP Method로 표현하는 2가지의 내용을 꼭 기억자.

SOAP

SOAP(Simple Object Access Protocol)은 네트워크를 통해 구조화된 정보를 교환하기 위한 XML 기반의 프로토콜이다. SOAP API는 일반적으로 HTTP 프로토콜을 사용하지만 SMTP, TCP와 같은 프로토콜 사용도 가능하다. SOAP API는 높은 수준의 프로토콜 추상화를 제공하고 암호화 / 트랜잭션 관리와 같은 고급 기능을 지원한다. 은행 및 금융 도메인 등에서 보안을 이유로 SOAP을 사용한다.

SOAP API의 로직은 웹서비스 기술언어인 WSDL로 작성된다. WSDL은 엔드포인트를 정의하고 수행가능한 모든 프로세스를 설명한다. XML 데이터 형식은 많은 제약을 요구하는데 아래 이미지를 참고해보자.

SOAP 예시

SOAP 예시

  • 모든 메세지를 시작하고 끝내는 envelope 태그
  • 요청과 응답을 포함하는 본문
  • 메세지에 특정 사항이나 추가 요구하사항을 결정하는 헤더
  • 요청 처리과정 전반에 걸쳐 발생할수있는 오류

이러한 SOAP은 언어와 플랫폼에 구애받지 않고 다양한 전송 프로토콜에 바인딩이 가능하다. 또한 오류 처리 기능이 내장되어 Retry XML 로 메세지 반환이 가능하며 다양한 보안 확장 기능을 제공한다. 하지만 XML 구조만 지원하며 XML파일 기반의 단점인 파일 크기가 커서 비용이 증가한다. 또한 생태계가 작아 레퍼런스를 찾기가 어렵다. 이러한 이유로 SOAP 아키텍처는 기업 내부에서 통합을 진행하거나, 신뢰성 있는 파트너와의 연동에서 사용된다.

WSDL에 대해 좀더 자세히 알아보고 싶다면 링크를 참고하자. 이번 연동 과정에서 도움을 받았던 내용이다.

결론

이번 연동을 통해 REST 중심으로 개발해오던 기존 경험에서 벗어나, 서로 다른 시스템 간 통신 방식이 어떻게 설계되고 표준화되는지를 이해할 수 있었다. 결국 API 아키텍처 선택은 기술의 최신성이나 선호도가 아니라, 연동 대상 시스템의 특성·보안 요구사항·신뢰성 수준·운영 환경 등을 종합적으로 고려해 결정해야 한다. 이번 경험을 통해 다양한 API 아키텍처의 차이를 이해하고, 특정 기술에 국한되지 않고 상황에 맞는 통신 방식을 선택하는 것이 백엔드 개발자에게 중요한 역량임을 느꼈다.

SQL Server NVARCHAR 해결

· 7 min read

프로젝트 QA중 특정 기능에서 조회 성능이 굉장히 떨어져 타임아웃까지 응답이 불가능한 문제가 발생하였다. 조회 요청을 보내는 테이블에 데이터가 꽤나 많았고, 추후 최적화를 진행하려고 했던터라 개발 단계에서 신경쓰지 못했었지만, 이번 오류 대응 과정을 통해서 배웠고, 앞으로도 주의해야할 내용을 공유해 보고자 한다.

문제점

InOutDTO agoInOut = inOutDAO.selectInOut(inoutModifyRequest.getTicketNo());

TicketNo를 통해 이전 데이터를 가져온후 다양한 service에 변경 사항을 update해야하는 내용이 있었고, update 할곳이 꽤나 많다보니 여기서 문제가 발생한다고 생각했었는데 알고보니 select 자체에서 문제가 발생하고 있었다. 형변환도 정상적으로 되고 있고, 조회도 느리긴하지만 되기 때문에 큰문제가 아니라고 생각했지만 오산이였다. DB Lock 이슈를 유발하는 SQL 문은 아래와 같다.

 <select id="selectInOut"
resultType="com.vstl.ansan.api.dto.InOutDTO">
SELECT
PH.ticketNo,
PM.parkAreaName,
PH.carNo,
PH.parkingDay,
DM.discountName AS discountName,
...
FROM parking_history PH
INNER JOIN ...
...
WHERE PH.ticketNo = #{ticketNo}
AND PH.useOk = 1
</select>

해당 Query를 모니터링 해보니 CPU를 많이 소모 하고있었다. WHERE 절에 걸려있는 #{ticketNo}는 parking_history 에서 Primary Key로 주요 INDEX가 걸려있다. DB에서 정의된 ticketNochar(13) 타입으로 정의되어있다.

SQL Server JDBC Driver 는 String 파라미터를 모두 NVARCHAR로 매핑한다. PreparedStatement.set 호출시 명시적으로 VARCHAR 매핑을 지정해도 NVARCHAR로 변환하여 매핑한다. 조회요청 이후 Lock된 session의 상태를 살펴보자.

[ 
{ "session_id": 71,
"status": "runnable",
"command": "SELECT",
...
"text":
"
(@P0 nvarchar(4000))
SELECT\n
...
WHERE PH.ticketNo = @P0 \n AND PH.useOk = 1"
} ]

 위의 내용에서 (@P0 nvarchar(4000)) 부분을 살펴보자. SQL Server JDBC Driver는 String type의 파라미터를 유니코드 타입인 nvarchar(4000) 형식으로 전달한다. MSSQL Data Type 우선순위를 보면 NVARCHAR가 VARCHAR / CHAR 보다 높기 때문에 char(13)컬럼에 대해 조회조건으로 String 파라미터를 사용하게 되면, NVARCHAR로 형변환이 일어나게 되고, 이상태로 조건을 비교한다. 이때 PK 에 걸려있는 CHAR INDEX도 무시된다.

  1. CHAR - NVARCHAR 형변환 비용 발생
  2. NVARCHAR 변환으로 인한 INDEX 무시

이 두가지 이유로 비용이 크게 증가되고, 성능 저하가 발생한다. 특히 조회하고 있는 parking_histroy테이블에는 1억건 이상의 데이터가 존재하여 많은 CPU소모가 일어나게 된것이다.

문제 해결

sendStringParametersAsUnicode=false 추가

가장 쉬운 해결방법은 아래와 같이 JDBC URL 에 sendStringParametersAsUnicode=false 를 추가하는 방법이다.

spring:
datasource:
type: org.apache.tomcat.jdbc.pool.DataSource
driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://myhost:1234;database=SbSvc;sendStringParametersAsUnicode=false

이설정을 통해 모든 String 파라미터를 VARCHAR 타입으로 매핑 한다.

NVARCHAR 파라미터에 Cast 사용

NVARCHAR 파라미터에 CAST를 사용해서 VARCHAR / CHAR로 변경 시켜주면, VARCHAR, CHAR 간의 비교여서 형변환이 일어나지 않는다.

 <select id="selectInOut"
resultType="com.vstl.ansan.api.dto.InOutDTO">
SELECT
PH.ticketNo,
PM.parkAreaName,
PH.carNo,
PH.parkingDay,
DM.discountName AS discountName,
...
FROM parking_history PH
INNER JOIN ...
...
WHERE PH.ticketNo = CAST(#ticketNo# AS VARCHAR)
AND PH.useOk = 1
</select>

JPA인 경우

NVARCHAR 컬럼을 매핑한 필드에 @Nationalized를 사용하면, 해당 컬럼에 대한 조회 조건만 NVARCHAR로 쿼리가 만들어지고, 나머지 컬럼들은 VARCHAR로 매핑하게 된다.

@Nationalized
@Column(name = "ticketNo", length = 50)
private String ticketNo;

JPQL, Querydsl-JPA 모두 이규칙을 따르고 있지만, EntityManager.createNativeQuery는 이규칙을 따르지않는다. (@Query(native=true)인 경우) 그래서 이경우에는 NVARCHAR 매핑시, CONVERT(NVARCHAR, ?)를 사용해서 변환을 해줘야한다. 아니면 JdbcTemplate를 직접 사용한다.

JdbcTemplate에서 NVARCHAR를 매핑하는 방법은 아래와 같이 setNString()을 사용한다.

jdbcTemplate.query("select * from sqlserver_with_java.dbo.books where book_type = ? and title like ?",
ps -> {
ps.setString(1, bookType.name()); // VARCHAR 매핑
ps.setNString(2, title); // NVARCHAR 매핑
},
rs -> {
log.info("id , title, bookType", rs.getLong("id"), rs.getString("title"), rs.getString("book_type"));
});

보너스

SQL Server에서 Session 확인 쿼리

SELECT r.session_id, r.status, r.command, r.wait_type, r.wait_time, r.blocking_session_id,  
t.text
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.session_id <> @@SPID;

조회 결과

조회 결과

NVARCHAR로 조회가 필요한 경우는?

이경우에는 이전처럼 INDEX가 무시되거나, 성능저하가 발생하는 문제는 없을것이다. INDEX 가 NVARCHAR 인 컬럼에 VARCHAR 조건이 매핑된 경우라면, 우선순위에 따라 조건으로 들어온 Parameter 가 형변환이 일어나기 때문에 INDEX 에는 영향을 주지 않게 된다.

즉, NVARCHAR 의 경우에는 컬럼들이 모두 형변환이 일어나는 것이 아니라 파라미터만 VARCHAR 에서 NVARCHAR 로 형변환이 일어난 후 조건을 비교하기 때문에, INDEX 도 유지되고 성능에도 큰 문제가 되지 않는다.

마무리

사소해 보이는 내용이라도 한번더 확인하고 점검하자.

멀티모듈 적용

· 3 min read

MSA 전환을 염두해둔 상태에서 첫번째 스텝으로 멀티모듈에 대해서 공부하고 적용해보자.

MSA / 멀티모듈

사내의 서비스는 다음과 같이 나눠진다.

  1. API
  2. ADMIN
  3. BATCH(Gateway)
  4. WEB
  5. Cache 등

좋은 아키텍처는 시스템이 모놀리틱 구조로 태어나서 단일 파일로 배포되더라도, 이후에는 독립적으로 배포 가능한 단위의 집합으로 성장하고, 독립적인 서비스나 마이크로 서비스 수준까지 성장할수있도록 만들어져야한다. 또한 좋은 아키텍처라면 상황에 의해 진행방향을 거꾸로 돌려 원래 형태인 모놀리틱 구조로 되돌릴수 있어야한다.

멀티 모듈의 구조

먼저 멀티모듈의 구조에 대해서 살펴보자. 멀티모듈은 단일 모듈 멀티프로젝트, 단일모듈 멀티프로젝트 + 저장소 , 멀티모듈 단일 프로젝트로 나눠볼수있다.

단일 모듈 멀티 프로젝트

  • 각각의 프로젝트 단위
    • IDE를 쓴다면 3개의 IDE 화면을 띄워둔 상태로 개발을 진행하는 형태
  • Member 라는 클래스가 공유(중복)되고 있다.
    • member-internal-api에서 Member가 수정되면 나머지 프로젝트에도 수정이 필요

단일 모듈 멀티 프로젝트 + 내부 Maven 저장소

  • Nexus와 같은 사설 Maven Repository를 만들어서 각각의 프로젝트에서 공유하고 있는 DTO나 도메인 클래스들을 분리 후 프로젝트화 시켜서 Nexus에 업로드하는 방식
  • member-domainMember 수정 1회로 각각 프로젝트에 적용 가능해져 일관성이 보장
  • 개발시 귀찮은 작업이 발생
    • membr-internal-api 수정하여 Nexus 배포하고 member-domain에서 Nexus에 배포된 내용을 다운로드
    • 기능 개발 과정에서 리소스가 많이들게 됨

멀티 모듈 단일 프로젝트

  • 프로젝트는 하나고, 그안에 여러개의 모듈을 설치 가능한 방법
  • IDE 하나만 사용하여 시스템적으로 보장되는 일관성, 빠른 개발 사이클 확보가 가능

NCP와 함께하는 쿠버네티스 구축

· 8 min read

이번 프로젝트를 진행하면서 Naver Cloud Platform을 이용해 쿠버네티스를 구축하고 개발하게 되었다. 클라우드로 쿠버네티스를 구축하는 방법과 구축 과정에서 만났던 많은 어려움들을 어떻게 해결해 나갔는지 기록해보려고 한다.

구축 순서

1. VPC 준비
2. Subnet 생성
3. NKS클러스터 생성
4. 노드풀 생성
5. 도메인 및 LB 설정
6. 관리용 시스템 생성
7. Bastion Host 구축
8. KubeCtl 설치

NCP 설정

VPC 준비

VPC 및 서브넷을 생성한다.

image22

image22

myPage

myPage

BastionHost 생성

kubectl을 사용하기 위해서는 bastionHost가 필요하다.

NAT 설정

NKS 클러스터가 Private 서브넷에 구성되어있어 노드가 Public Internet에 접근할수없다. NKS 노드에 외부 인터넷이 열려야지 Docker Registry등의 서비스를 사용 가능하다. NAT Gateway 설정으로 노드는 외부에 노출하지 않고, 인터넷이 가능한 환경을 구축한다.

먼저 NAT Gatway를 생성하고, NAT Gateway를 Private 서브넷의 Route Table에 등록해준다.

testImage

testImage

kkkk

kkkk

BastionHost 설정

KubeCtl 설치

이번에 구축한 서버의 경우 X86-64 이고, 혹시 ARM 기반 으로 구축을 하는경우 아래 링크를 참고하자.

kubectl 구축

  1. 최신 릴리스 다운로드
   curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
  1. 바이너리 검증
   curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl.sha256"
echo "$(cat kubectl.sha256)  kubectl" | sha256sum --check

명령어를 통해 정상 출력시 아래와 같은 내용이 표출 된다.

정상 출력

정상 출력

  1. kubectl 설치
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
  1. 설치된 버전 확인
kubectl version --client
  1. ncloud 접속을 위한 Config 설정
## ~/.ncloud/configure

[DEFAULT]
ncloud_access_key_id = AK_XXX
ncloud_secret_access_key = SK_XXX
ncloud_api_url = https://ncloud.apigw.gov-ntruss.com
chmod 600 ~/.ncloud/configure

현재 프로젝트에서 Docker Hub가 아닌 NCP에서 지원하는 Docker Registry를 사용중이여서 해당 부분을 Secret으로 만들어 설정해줘야한다.

  1. 도커 레지스트리 설정

Ingress 설치

Ingress Controller의 사용 방식은 2가지가 있다. 이중 내가 사용할 방식은 ALB를 생성하여 Ingress로 컨트롤 하는 방식이다.

  1. Nginx Ingress

  2. ALB Ingress

  3. Ingress Controller 설치 확인

kubectl get pods -n kube-system | grep ingress
  1. 없으면 설치를 진행
  • ALB 방식
kubectl --kubeconfig=$KUBE_CONFIG apply -f https://raw.githubusercontent.com/NaverCloudPlatform/nks-alb-ingress-controller/main/docs/install/pub/install.yaml

설치 완료시 아래와 같은 응답을 받을수 있다.

  • Nginx 방식
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.1/deploy/static/provider/cloud/deploy.yaml

설치가 완료되면 이제 Ingress를 세팅해서 실제로 apply하는 작업을 수행해야 한다. 이를 위해 Ingress.yml을 작성하자. 자세한 설명은 하단의 링크를 참고하자. NCP - ALB Ingress 설정 방법

  1. Ingress.yml
ncloud@vstl-wm-kubectl:~$ cat watchmile-api-ingress.yaml 
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ansan-gov-ingress
annotations:
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}, {"HTTPS":443}]'
alb.ingress.kubernetes.io/ssl-certificate-no: "26109"
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/load-balancer-name: ansan-kube-alb
alb.ingress.kubernetes.io/load-balancer-type: "alb"
alb.ingress.kubernetes.io/network-type: public
alb.ingress.kubernetes.io/load-balancer-size: small
alb.ingress.kubernetes.io/healthcheck-path: /actuator/health
spec:
ingressClassName: alb
tls:
- hosts:
- parkingapi.ansanuc.net
secretName: dummy-tls
rules:
- host: parking-api.ansanuc.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ansan-daemin-api
port:
number: 80
- host: parkingm-api.ansanuc.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ansan-admin-api
port:
number: 80

파이프 라인

사용중인 레포지토리는 Bitbucket 인데, Pipeline이라는 좋은 기능을 제공한다. Github Action 처럼 Bitbucket 자체에 내장되어 CI/CD를 간편하게 해줄수있는 도구이다. 이를 통해 구축 하려고 하는 방식은 다음과 같다.

Bitbucket 설정

CI/CD를 적용할 레포지토리에 해당 yml을 세팅한다.

kube-deploy.yml

apiVersion: v1
kind: Service
metadata:
name: ansan-daemin-api
spec:
selector:
app: ansan-daemin-api
type: ClusterIP
ports:
- port: 80
targetPort: 7070
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ansan-daemin-api
spec:
replicas: 1
selector:
matchLabels:
app: ansan-daemin-api
template:
metadata:
labels:
app: ansan-daemin-api
spec:
imagePullSecrets:
- name: registry
containers:
- name: ansan-daemin-api
image: {{image}}
ports:
- containerPort: 7070

kube-deploy.yml은 관리의 편의성과 안정성을 위해서 필요하다. 만약 해당 yml파일이 없더라도

kubectl set image deployment/ansan-daemin-api ansan-daemin-api=이미지명

위의 CLI 명령어를 통해서 배포하는것도 가능하지만, 이렇게 하면 전체 Deployment에 대한 정의가 코드로 남지 않는다. kube-deploy.yml을 만듦으로써, 클러스터 초기화 / 재배포 상황에서 사용이 가능하고, 다른 환경을 구성 하더라도 재사용이 가능하다. 그리고 kubectl apply 를 통해 선언적으로 배포가 가능해진다.

bitbucket.yml

image: atlassian/default-image:4

options:
docker: true
size: 2x

definitions:
services:
docker:
memory: 2048

pipelines:
branches:
main:
- step:
name: Docker build & push
size: 2x
script:
- export IMAGE_NAME=$NCLOUD_CR_URL/$APPLICATION_NAME:$BITBUCKET_COMMIT
- docker build -t $APPLICATION_NAME .
- docker tag $APPLICATION_NAME $IMAGE_NAME
- echo "$NCLOUD_KEY" | docker login -u $NCLOUD_ID $NCLOUD_CR_URL --password-stdin
- docker push $IMAGE_NAME
services:
- docker
caches:
- docker
- step:
name: Deploy to NKS via Bastion (GitOps style)
deployment: production
script:
- export IMAGE_NAME=$NCLOUD_CR_URL/$APPLICATION_NAME:$BITBUCKET_COMMIT
- apt-get update && apt-get install -y sshpass openssh-client
- ssh-keyscan -H $DEV_SERVER_HOST >> ~/.ssh/known_hosts

# YAML에 이미지 치환
- sed -i "s|{{image}}|$IMAGE_NAME|g" kube-deployment.yml

# Bastion에 접속해서 apply
- cat kube-deployment.yml | sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no $SERVER_USER@$BASTION_HOST "
export PATH=\$PATH:/home1/ncloud/bin &&
kubectl --kubeconfig /home1/ncloud/kubeconfig-ansan.yaml apply -f -
"

## Develop
develop:
- step:
name: Docker build & push
size: 2x
script:
- export IMAGE_NAME=$NCLOUD_CR_URL/$APPLICATION_NAME:latest
- docker build -t $APPLICATION_NAME .
- docker tag $APPLICATION_NAME $IMAGE_NAME
- echo "$NCLOUD_KEY" | docker login -u $NCLOUD_ID $NCLOUD_CR_URL --password-stdin
- docker push $IMAGE_NAME
services:
- docker
caches:
- docker

- step:
name: Deploy to Server
deployment: test
script:
- export IMAGE_NAME=$NCLOUD_CR_URL/$APPLICATION_NAME:latest
- apt-get update && apt-get install -y sshpass openssh-client
- ssh-keyscan $DEV_SERVER_HOST >> ~/.ssh/known_hosts

- echo "$SERVER_PASS" | sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no $SERVER_USER@$BASTION_HOST "
cd $DOCKER_COMPOSE_PATH &&

echo -n \"$NCLOUD_KEY\" | docker login -u \"$NCLOUD_ID\" --password-stdin \"$NCLOUD_CR_URL\" &&

docker-compose stop ansan-daemin-api || true &&
docker-compose rm -f ansan-daemin-api || true &&

docker-compose pull ansan-daemin-api &&
docker-compose up -d ansan-daemin-api &&

docker image prune -f --filter=\"until=24h\"
"

마주쳤던 문제들

InvalidImageName 발생

ncloud@server:~$ kubectl get pods
NAME READY STATUS RESTARTS AGE
ansan-daemin-api-7578988667-ptkqw 0/1 InvalidImageName 0 15h

ALB Health-check 문제

Cluster IP를 기본값으로 설정해서 ALB에서 Health Check가 불가능하였다. Cluster IP는 외부에서 접근할수없어, ALB에서 HealthCheck를 보낼수없기 때문이다. 이를 해결하기 위해 ClusterIP -> NodePort로 변경하여 해결하였다.

Ref.

JPA 도입 그리고 ID 생성 방식

· 9 min read

이번 프로젝트에서 ORM으로 JPA를 사용하게 되었다. 설계단계에서 비즈니스 로직의 대부분이 CRUD로 이뤄져 생산성 향상 측면에서 유리할것 같아 제안했고, 도입이 이뤄졌다. 이로인해 실제로 코드의 복잡도나 유지보수성이 크게 향상되었지만 예상치 못한곳에서 문제가 발생했다. 바로 ID 생성 방식때문이였다.

기존에는 fn_sys_seq와 같은 프로시저를 정의해두고 호출해서 문자열 ID를 생성했었는데 JPA 자체의 ID 생성 전략과 기존방식이 근본적으로 충돌함을 확인하게 되었다.

기존 생성 방식

기존 시스템에서는 다음과 같은 방식으로 ID를 생성해왔다.

  • ID는 단순 숫자가 아니라 "USR0000001" 처럼 도메인 접두사 + 일련번호 포멧의 문자열 형태
  • 각 테이블에 대응되는 문자열 키를 테이블에 연계해놓고 이를 기준으로 ID를 생성
  • 해당 키를 기반으로 프로시저를 호출하여 최신의 새로운 ID를 가져옴
create  
definer = root@`%` function fn_sys_seq(p_seq_id char(3)) returns char(9) modifies sql data
BEGIN
DECLARE _curr INT(6);
DECLARE _max INT(6);
SELECT curr_val, max_val
INTO _curr, _max
FROM tb_sys_sequence
WHERE seq_id = p_seq_id;
SET _curr = _curr + 1;
IF (_curr > _max) THEN
SET _curr = 0;
END IF;
UPDATE tb_sys_sequence
SET curr_val = _curr
WHERE seq_id = p_seq_id;
RETURN CONCAT(UPPER(p_seq_id),lpad(_curr, 6, 0));
END;

grant execute on function fn_sys_seq to user;
seq_idtbl_nmcurr_valmax_val
USRtb_domain_user1999999

이방식으로 레거시 환경에서는 Mybatis기반으로 프로시저를 호출하여 사용하여 문제가 없었고, 현재 시스템 전체에서 사용되고 있는 방식이다. 하지만 JPA를 도입하면서 이방식은 더이상 단순히 통합 할 수없는 구조가 되었다.

발생한 문제 : JPA ID 생성전략과 문자열

JPA의 기본 ID 생성 전략은 문자열과 호환되지 않는다. JPA는 일반적으로 @GeneratedValue어노테이션을 통해 ID를 자동으로 생성하도록 구성된다. 하지만 IDENTITY, SEQUENCE, AUTO 전략을 통해 자동으로 숫자형 ID 를 생성하게 된다. 즉 문자열 기반의 외부 ID를 사용하는 경우 직접 생성을 하고 save() 호출전에 setId()로 값을 지정해줘야한다. 나는 이부분을 해결하기 위해 커스텀 어노테이션을 작성하였다.

@Retention(RetentionPolicy.RUNTIME)  
@Target(ElementType.TYPE)
@Documented
public @interface SequenceKey {
String value();
}
@Component  
public class IDGeneratorUtil {
@PersistenceContext
private EntityManager entityManager;

public String make(Class<?> entityClass) {
SequenceKey annotation = entityClass.getAnnotation(SequenceKey.class);

if(annotation == null) {
throw new IllegalArgumentException("SequenceKey 없음");
}

String seqId = annotation.value();

StoredProcedureQuery query = entityManager.createStoredProcedureQuery("fn_sys_sequence");

query.registerStoredProcedureParameter("seq_id", String.class, ParameterMode.IN);
query.registerStoredProcedureParameter("new_id", String.class, ParameterMode.OUT);

query.setParameter("seq_id", seqId);
query.execute();
return (String) query.getOutputParameterValue("new_id");
}
}

그리고 엔티티에 다음과 같이 커스텀 어노테이션을 적용해 동작하도록 하였다.

@Entity  
@Getter
@Table(name = "tb_domain_user")
@SequenceKey("USR")
public class User {

@Id
@Column(name = "usr_seq")
private String usrSeq;

...
}

발생한 문제 : 불필요한 쿼리 발생

위방식을 통해 정상적으로 USR와 매칭되는 ID가 생성되어 DB에 반영됨을 확인했다. 하지만 로그를 살펴보니 한가지 문제가 있었다. SELECT 쿼리가 예상과 다르게 3번 발생하게 되는 것이다. 이내용을 아래 표를 통해 살펴보자.

단계쿼리설명
1CALL fn_sys_seq(...)ID 생성용 프로시저 호출
2SELECT * FROM ... WHERE id = ?JPA 내부 merge 경로에서 id 존재 여부 확인
3INSERT INTO ...실제 persist

프로시저 1회 + SELECT 1회 + INSERT 1회 이렇게 총 3번의 쿼리가 발생하게 된다. JpaRepository에서 save(entity)를 호출시, JPA는 해당 엔티티가 신규인지 판단하기 위해 merge 실행 과정중 SELECT 쿼리로 ID 존재여부를 확인하게 된다. 그리고 존재하지 않음을 확인하고 다시 persist()를 수행한다.

if (isNew(entity)) {
    persist(entity)
} else {
    merge(entity) → select → 없으면 persist
}
  • IDGeneratorUtil로 String id를 만들어 setId()
  • id != null이므로 JPA는 기존 객체라고 판단
  • merge() 경로로 진입
  • SELECT 수행 (id 존재 여부 확인)
  • 존재하지 않음 → 다시 persist() 수행

우리가 fn_sys_seq를 통해 가져온 값은 이미 DB조회를 통해 최신 상태의 값으로 가져왔고 이를 테이블에서 확인할 필요는 없다.

해결방안

JPA의 Entity 상태 판별 로직은 SimpleJpaRepository 내부의entityInformation.isNew() 를 기반으로 동작한다. 기본적으로 @Id != null이면 기존 엔티티(Detached)로 간주하며, 이로 인해 merge()를 통해 저장을 시도한다. 하지만 이 방식은 외부에서 ID를 미리 주입하는 전략과 충돌하게 되며, 결국 불필요한 SELECT가 발생하게 된다. JPA 가 save() 시 merge() 를 선택하는 이유는 단하나다. @ID != null 을 통해 기존 객체라고 판단하기 때문이다. 이 흐름을 바꾸기 위해 JPA에서는 Persistable 인터페이스를 제공한다. 이를 통해 JPA가 내부적으로 isNew()를 호출하도록 유도할수있다.

public class User implements Persistable<String> {

@Id
private String usrSeq;

@Transient
private boolean isNew = true;

@Override
public String getId() {
return this.usrSeq;
}

@Override
public boolean isNew() {
return isNew;
}

@PostLoad
@PostPersist
private void markNotNew() {
this.isNew = false;
}
}

이렇게 적용하여 객체 생성 직후에 isNew = true로 설정하여 persist()가 호출되도록 유도하고 JPA가 엔티티를 로딩하거나 저장한뒤에 @PostLoad, @PostPersist 이벤트를 통해 isNew = false로 변경하여 save()호출시 불필요한 SELECT 없이 바로 INSERT를 수행하게 한다. 적용 결과를 아래 표를 통해 살펴보자.

단계쿼리설명
1CALL fn_sys_seq(…)프로시저로 ID 생성
2INSERT INTO …JPA가 persist()만 수행

이전과 달리 SELECT 가 발생하지 않으며, 기대한 대로 ID 생성과 INSERT만 수행된다.

마무리

이번 경험을 통해 레거시 시스템의 ID 전략을 유지하면서도 JPA의 이점을 살릴 수 있는 방법을 고민하게 되었다. 특히 Persistable 인터페이스를 통해 JPA 내부의 ID 판별 방식을 우회함으로써, 불필요한 SELECT를 제거하고 효율적인 저장을 이룰 수 있었다. 단순히 ORM을 도입하는 것보다도, 시스템 특성에 맞게 커스터마이징하는 과정에서 더 많은 학습이 있었다.