Spring Security WebSecurityConfigurerAdapter / cors, csrf Deprecated 해결하기
Tech/Java & Spring2023. 8. 20. 13:06
728x90
728x90
서론
최근 토이 프로젝트 진행중에 간단하게 회원기능을 구현하고자 Security를 사용하는데 이전 사용했던 WebSecurityConfigurerAdapter을 상속받아 구현하던 Config설정에 바뀐 부분이 있고 또한 시큐리티를 통한 권한 확인이나 로그인, 로그아웃 등 기타 작업등은 하지 않을 계획이라 csrf(), cors() disable을 설정하는 과정에서도 Deprecated된 것을 확인하여 기록하고자 함
아래는 각각 WebSecurityConfigurerAdapter와, Spring Security 6.1.2버전에서의 Deprecated API를 정리해둔 공식 docs
select C.CHAMPION_ID, C.PRICE, ROUND((C.PRICE-CP.PRICE)/CP.PRICE*100) as PERCENT
from Champion C, (select CC.CHAMPION_ID, CC.PRICE
from ChampionPriceLog CC
where (CC.CHAMPION_ID, CC.CREATE_DATE) in(select CCC.CHAMPION_ID, MAX(CCC.CREATE_DATE) AS CREATE_DATE
from ChampionPriceLog CCC
group by CCC.CHAMPION_ID)
order by CC.CHAMPION_ID) as CP
where C.CHAMPION_ID = CP.CHAMPION_ID
order by C.CHAMPION_ID;
테스트
포스팅을 하게 만들었던 위의 쿼리를 사용하기 위해 QueryDSL을 사용해보기로 했다.
QChampion C = QChampion.champion;
QChampionPriceLog CPL = QChampionPriceLog.championPriceLog;
Tuple은 QueryDSL에서 제공하는 컨테이너 클래스로, 쿼리 결과의 각 튜플을 나타낸다.
이를 원하는 형태로 View시키기 위해 아래와 같은 DTO객체를 사용했고, DTO에 원하는 정보를 tuple에서 뽑아서 List형태로 반환시켜 줄 계획이기 때문에 아래처럼 DTO와 반환 코드를 작성했다.
package com.example.lolchampionsinvestment.domain.champion;
import lombok.Data;
@Data
public class ChampionPriceDto {
private String name;
private int price;
private int percent;
public ChampionPriceDto(String name, int price, int percent) {
this.name = name;
this.price = price;
this.percent = percent;
}
}
List<ChampionPriceDto> championPriceDtoList = tupleList.stream()
.map(tuple -> {
String name = tuple.get(C.name);
int price = tuple.get(C.price);
int cplPrice = tuple.get(CPL.price);
int percent = Math.round((price - cplPrice) * 100 / price);
return new ChampionPriceDto(name, price, percent);
})
.collect(Collectors.toList());
select C.CHAMPION_ID, C.PRICE, ROUND((C.PRICE-CP.PRICE)/CP.PRICE*100) as PERCENT
from Champion C, (select CC.CHAMPION_ID, CC.PRICE
from ChampionPriceLog CC
where (CC.CHAMPION_ID, CC.CREATE_DATE) in(select CCC.CHAMPION_ID, MAX(CCC.CREATE_DATE) AS CREATE_DATE
from ChampionPriceLog CCC
group by CCC.CHAMPION_ID)
order by CC.CHAMPION_ID) as CP
where C.CHAMPION_ID = CP.CHAMPION_ID;
위의 쿼리를 QueryDSL로 수행하기 위한 테스트 코드 전체는 아래와 같다.
@SpringBootTest
@Transactional
public class ChampionPriceQueryRepositoryTest {
@PersistenceContext
EntityManager em;
JPAQueryFactory jpaQueryFactory;
@Autowired
ChamiponPriceQueryRepository championPriceQueryRepository;
@Autowired
ChampionDataParsingService championDataParsingService;
@BeforeEach
void init() {
jpaQueryFactory = new JPAQueryFactory(em);
}
@DisplayName("챔피언 별 가장 최근 가격을 가져온다.")
@Test
void getChampionsLatestPrice(){
// given
List<ChampionPriceDto> championPriceDtoList = championPriceQueryRepository.findAllLatestChampions();
// when // then
assertThat(championPriceDtoList.size()).isEqualTo(164);
assertThat(championPriceDtoList)
.extracting("name", "price", "percent")
.containsAnyOf(
tuple("아트록스", 7000, -36),
tuple("아리", 2000, 0),
tuple("카이사", 8000, 0)
);
}
}
테스트를 위해, 아트록스 데이터는 11000원을 입력해두었다.
원하는 값인 현재 금액 : 7000, 금일 등락 퍼센트인 -36%가 잘 반환된 모습이다.
적용
테스트코드를 적용시킨 Repository를 만들고
아래의 서비스 객체를 만든 뒤
@Service
@RequiredArgsConstructor
@Transactional
public class ChampionService {
private final ChamiponPriceQueryRepository championPriceQueryRepository;
public List<List<ChampionPriceDto>> getAllLatestChampions() {
List<ChampionPriceDto> list = championPriceQueryRepository.findAllLatestChampions();
int listSize = list.size();
List<List<ChampionPriceDto>> groupingList = new ArrayList<>();
for(int i = 0; i < listSize; i+=8) {
int endIdx = Math.min(i + 8, listSize);
groupingList.add(list.subList(i, endIdx));
}
return groupingList;
}
}
Controller를 통해 View를 시켜 보았다.
@RestController
public class MainViewController {
@Autowired
ChampionService championService;
@GetMapping("/")
public ModelAndView mainView() {
ModelAndView mv = new ModelAndView();
List<List<ChampionPriceDto>> groupingList = championService.getAllLatestChampions();
mv.addObject("groupingList", groupingList);
mv.setViewName("main/main.html");
return mv;
}
}
8개씩 그룹핑 한 이유는, View를 그렇게 하기 위함이었는데
일단 원하는 대로 잘 들어오긴 했고, 모든 데이터가 다 들어오기 때문에, 메인 화면에 보여주는 것이다 보니 등락폭이 큰 녀석들만 추려서 View시키던, 페이징을 시키는 방향으로 보수작업을 할 것 같다.
또한 아래의 Config 클래스를 생성하여 JPAQueryFactory를 Bean으로 등록해주었다.
필요 시 Repository에서 JPAQueryFactory를 DI하여 사용할 수 있다.
@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {
private final EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em){
return new JPAQueryFactory(em);
}
}
모든 설정을 마쳤다면 Gradle - Build - Clean 후 compileJava를 수행하거나. (Gradle 빌드 도구 활용)
토이 프로젝트 진행 중 아래와 같은 쿼리 사용이 필요했고, QueryDSL을 한 번 사용해보기로 했음.
select C.CHAMPION_ID, C.PRICE, ROUND((C.PRICE-CP.PRICE)/CP.PRICE*100) as PERCENT
from Champion C, (select CC.CHAMPION_ID, CC.PRICE
from ChampionPriceLog CC
where (CC.CHAMPION_ID, CC.CREATE_DATE) in(select CCC.CHAMPION_ID, MAX(CCC.CREATE_DATE) AS CREATE_DATE
from ChampionPriceLog CCC
group by CCC.CHAMPION_ID)
order by CC.CHAMPION_ID) as CP
where C.CHAMPION_ID = CP.CHAMPION_ID;
위에서 언급했든 QueryDSL은 컴파일 단계에서 Entity를 매핑하여 QClass를 생성한다.
JPAAnnotationProcessor 클래스(docs 링크)가 컴파일 시점에 작동하여 @Entity 애너테이션을 찾아 분석하여 Entity와 형태가 같은 Static Class로 QClass를 생성하며, QueryDSL은 쿼리 작성 시 이 QClass를 기반으로 쿼리를 실행하게 된다.
QueryDSL JPA 사용 이유
그렇다면 QueryDSL을 왜 사용할까?
JPQL은 결국 쿼리를 문자열로 입력하기 때문에, 오타가 발생하거나 관리하기가 어렵고, 컴파일 단계에서가 아닌 런타임 시 오류를 확인할 수 있다는 문제가 있다.
QueryDSL은 단순 문자열이 아닌 코드를 통해 작성하기 때문에, 코드 작성자의 실수(오타)를 예방할 수 있으며, 객체 지향적인 개발이 가능하다. 또한 컴파일 단계에서도 오류를 빠르게 발견할 수 있다는 장점이 있다.
아래의 예시는 JPQL과 QueryDSL의 같은 상황에 대한 사용 예시이다.
String jpql = "select * from Member m join Point p on p.member_id = m.id"
List<Member> result = em.createQuery(jpql, Member.class).getResultList();
엔티티 객체를 대상으로 하는 객체지향 쿼리로 SQL을 추상화한 객체지향 쿼리이며, 작성된 JPQL은 SQL로 변환된다.
기존 JPA의 메서드 호출만으로는 섬세한 쿼리 작성이 어렵다는 문제를 해결하기 위해 JPQL이 나타나게 되었으며 SQL을 추상화했기 때문에 특정 데이터베이스 SQL에 의존하지 않는다는 장점이 있다.
SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN을 지원한다.
예시
Member 객체를 대상으로 이름에 "bazzi"가 포함된 모든 회원을 검색하는 쿼리
String jpql = "select m From Member m where m.name like ‘%bazzi%'";
List<Member> result = em.createQuery(jpql, Member.class).getResultList();
for (Member member : result) {
System.out.println("Member = " + member);
}
SQL과의 차이점
1. 대소문자 구분
엔티티와 필드는 대소문자를 구분한다.
위의 예시에서는, Member와 name은 대소문자를 반드시 구분해주어야 한다.
select, from, as와 같은 키워드들은 대소문자를 구분하지 않아도 된다.
2. 엔티티 이름
Member은 Entity 이름으로, @Entity(name = "Menber")로 설정 가능하며, 생략 시 기본 값으로 클래스 이름을 사용한다.
3. 별칭(Alias)
JPQL에서 Alias는 필수로 명시해야 하며, AS 키워드는 생략 가능하다.
구현 방법
구현 방법에는 TypedQuery, Query가 있다.
TypedQuery는 변환 타입이 명확할 때, 아래처럼 타입 정보를 작성하여 리턴받을 수 있다.
TypedQuery<Member> query1 = em.createQuery("select m from Member m", Member.class);
TypedQuery<String> query2 = em.createQuery("select m.username from Member m", String.class);
기본적으로 엔티티 타입을 기입했고, query2와 같이 리턴 타입이 username과 같이 명확할 경우 타입 정보를 기입할 수 있다.
Query는 데이터 검색 결과의 타입을 명시하지 않는다.
Query query3 = em.createQuery("select m.username, m.age from Member m");
조회한 결과는 getResultList()혹은 getSingleResult()와 같은 API를 사용하여 List 혹은 단일 객체를 반환한다.
TypedQuery<Member> query1 = em.createQuery("select m from Member m", Member.class);
List<Member> resultList = query1.getResultList();
for (Member m : resultList) {
System.out.println("m = " + m.getUsername());
}
TypedQuery<Member> query2 = em.createQuery("select m from Member m where m.id = 1L", Member.class);
Member singleResult = query2.getSingleResult();
System.out.println("singleResult = " + singleResult.getUsername());
파라미터 바인딩
이름 기준 파라미터
Member result = em.createQuery("select m from Member m where m.username = :username", Member.class)
.setParameter("username", "member1")
.getSingleResult();
System.out.println("result = " + result.getUsername());
이름을 기준으로 파라미터를 바인딩하며, 콜론(:)을 사용해 데이터가 추가될 곳을 지정하며
query.setParameter()을 호출하여 데이터를 동적으로 바인딩한다.
위치 기준 파라미터
Member result = em.createQuery("select m from Member m where m.username = ?1", Member.class)
.setParameter(1, "member1")
.getSingleResult();
System.out.println("result = " + result.getUsername());
위치 기준 파라미터를 사용하려면 ? 다음에 위치 값을 주면 된다.
위치가 변경될 가능성이 높기 때문에, 위치 기준 파라미터보다는 이름 기준 파라미터를 사용하는 것을 권장한다고 한다.
JPQL의 문제점
객체지향 쿼리이지만, 문자열을 통해 JPQL을 작성하는 것이기 때문에 오타가 발생하기 쉽고, 유지보수가 어렵다.
또한 문자열 자체의 검증이 컴파일 단계에서 불가능하기 때문에, 쿼리가 직접 실행되는 런타임에서 해당 오류를 발견할 수 있다. 물론 이는 테스트 코드에서 해소할 수 있는 부분이 있지만, 실제 운영 시에도 발생할 수 있다는 불안을 해소할 수 없다.
입력된 데이터가 버퍼를 거쳐 전달되어 데이터 처리 효율성이 높다. 즉 속도가 훨씬 빠르다. 그래서 많은 양의 데이터를 처리할 때 유리하며, 코테 문제에서도 StringBuffer을 사용하는 것이 더 좋다. 라고 하는 것이다.
BufferedReader의 기본 사용법은 아래와 같다.
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String str = bf.readLine();
int num = Integer.parseInt(bf.readLine());
입력 시에는 readLine()을 활용하며, 아래의 int 선언구와 같이 String이 아닌 다른 타입으로 사용 시 형변환이 필수적이다. 또한 예외처리가 필수적이며, IDE를 사용 시 readLine을 사용하다 보면 IOException에 대한 예외처리를 해달라고 컴파일 에러를 낸다. 말그대로 Input / Output에 대한 Exception이다.
또한 위의 예시에서처럼, Line단위로 구분을 짓기 때문에, 가공을 위해서 후처리를 해주어야 한다.
StringTokenizer st = new StringTokenizer(str);
int num1 = Integer.parseInt(st.nextToken());
int num2 = Integer.parseInt(st.nextToken());
String str[] = str.split(" ");
StringTokenizer에 입력받은 문자열을 넣어, nextToken()을 활용하여 입력받은 줄 단위의 문자열에서 공백 단위로 구분지어 순서대로 호출할 수 있다. 또한 split을 사용하는 것도 방법이 될 수 있다.
그리고, 버퍼를 사용하여 데이터를 읽기 때문에 버퍼에 남아있는 데이터를 소비하고 해제하여야 하며 이를 위해 close메서드를 마지막에 사용해 주는 것이 좋다.
우선 flush()와 close()가 눈에 띄는데, 객체 생성 시 새로운 버퍼가 생성되기 때문에, flush()를 사용하여 버퍼에 남아있는 데이터를 강제로 출력하고, 버퍼를 비워주어야하며, close()를 사용하여 버퍼를 닫고 리소스를 해제하는 후처리 작업이 동행되어야 한다.
import java.io.*;
import java.math.*;
import java.security.*;
import java.text.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
import java.util.regex.*;
import java.util.stream.*;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
class Result {
/*
* Complete the 'simpleArraySum' function below.
*
* The function is expected to return an INTEGER.
* The function accepts INTEGER_ARRAY ar as parameter.
*/
public static int simpleArraySum(List<Integer> ar) {
// Write your code here
}
}
public class Solution {
public static void main(String[] args) throws IOException {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(System.getenv("OUTPUT_PATH")));
int arCount = Integer.parseInt(bufferedReader.readLine().trim());
List<Integer> ar = Stream.of(bufferedReader.readLine().replaceAll("\\s+$", "").split(" "))
.map(Integer::parseInt)
.collect(toList());
int result = Result.simpleArraySum(ar);
bufferedWriter.write(String.valueOf(result));
bufferedWriter.newLine();
bufferedReader.close();
bufferedWriter.close();
}
}
단순 배열안에 있는 모든 값을 더하는 문제였고 아래와 같이 해결함.
public static int simpleArraySum(List<Integer> ar) {
int answer = 0;
for(int i : ar){
answer += i;
}
return answer;
}
Key Value에 null을 허용하지만, 동기화를 보장하지 않는다. 동기화 처리를 하지 않기 때문에 데이터 탐색 속도는 빠르지만, 신뢰성과 안정성이 떨어진다.
HashTable
Key Value에 null을 허용하지 않고, 동기화를 보장한다.
get() put() remove()등의 메서드에 synchronized 키워드가 붙어 있어, 멀티 쓰레드 환경에서 사용할 수 있다. synchronized 키워드 때문에 동작이 느리다. (쓰레드 간 동기화Lock때문에...)
ConCurrentHashMap
Key Value에 null을 허용하지 않으며 동기화를 보장한다. 동기화 처리 시, 조작하는 버킷(index)에 대해서만 락을 걸기 때문에, 같은 멀티 쓰레드 환경에서 사용하더라도 HashTable 대비 속도가 빠르다.
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {
public V get(Object key) {}
public boolean containsKey(Object key) { }
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
}
ConcurrentHashMap클래스의 일부 API이다. Hashtable과 달리 synchronized 키워드가 메서드 전체에 붙어있지 않다. get() 은 아예 존재하지도 않는다. put() 메서드에는 중간에 synchronized 키워드가 있다.
즉 ConcurrentHashMap은 읽기 작업에는 여러 쓰레드가 동시에 읽을 수 있지만, 쓰기 작업에서는 특정 버킷에 대한 Lock을 사용한다는 것을 의미한다.
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {
private static final int DEFAULT_CAPACITY = 16;
// 동시에 업데이트를 수행하는 쓰레드 수
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
}
DEFAULT_CAPACITY, DEFAULT_CONCURRENCY_LEVEL이 16으로 설정되어 있는 것을 볼 수 있다. DEFAULT_CAPACITY는 버킷의 수이며, DEFAULT_CONCURRENCY_LEVEL는 동시에 작업 가능한 쓰레드 수라고 생각한다.
버킷의 수가 동시작업 가능한 쓰레드의 수인 이유는 버킷 단위로 lock을 사용하기 때문에 같은 버킷만 아니라면 Lock을 기다릴 필요가 없다. 동시에 데이터를 삽입, 참조하더라도 그 데이터가 다른 버킷에 위치하면 서로 락을 얻기위해 경쟁하지 않는다는 뜻이다.
위의 특징들을 바탕으로 정리해 볼 때, ConcurrentHashMap은 읽기보다는 쓰기 작업에서, 성능이 중요한 상황에서 쓰는 것이 적합하다. 같은 버킷이 아니라면 여러 쓰레드가 동시에 삽입하는 것이 가능하기 때문이다.
코드 테스트
위의 컬렉션들을 10개의 스레드에서 각각 1000번을 반복하여 랜덤 값을 입력할 때 엔트리의 사이즈를 비교하는 코드이다. (성능 테스트가 아님) (코드테스트 출처)
import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class MultiThreadsTest {
private static final int MAX_THREADS = 10;
private static Hashtable<String, Integer> ht = new Hashtable<>();
private static HashMap<String, Integer> hm = new HashMap<>();
private static HashMap<String, Integer> hmSyn = new HashMap<>();
private static Map<String, Integer> hmSyn2 = Collections.synchronizedMap(new HashMap<String, Integer>());
private static ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
ExecutorService es = Executors.newFixedThreadPool(MAX_THREADS);
for( int j = 0 ; j < MAX_THREADS; j++ ){
es.execute(new Runnable() {
@Override
public void run() {
for( int i = 0; i < 1000; i++ ){
String key = String.valueOf(i);
ht.put(key, i);
hm.put(key, i);
chm.put(key, i);
hmSyn2.put(key, i);
synchronized (hmSyn) {
hmSyn.put(key, i);
}
}
}
});
}
es.shutdown();
try {
es.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Hashtable size is "+ ht.size());
System.out.println("HashMap size is "+ hm.size());
System.out.println("ConcurrentHashMap size is "+ chm.size());
System.out.println("HashMap(synchronized) size is "+ hmSyn.size());
System.out.println("synchronizedMap size is "+ hmSyn2.size());
/*
for( String s : hm.keySet() ){
System.out.println("["+s+"] " + hm.get(s));
}
*/
}
}
Hashtable size is 1000
HashMap size is 1281
ConcurrentHashMap size is 1000
HashMap(synchronized) size is 1000
synchronizedMap size is 1000
HashMap은 동기화가 이루어지지 않아 엔트리 사이즈가 비정상적으로 나오지만 HashMap을 synchronized 키워드를 사용하면 정상적으로 동작한다.
동기화 이슈가 있다면 일반적인 HashMap을 사용하지 말고, 동기화를 보장하는 컬렉션 또는 synchronized 키워드를 사용해 반드시 동기화 처리를 해주어야 한다.
[Project 회고록] 친구 추가 관련 기능 구현 시 테이블에 대한 고찰(친구 목록과 친구 요청에 대한 테이블을 나눌 것인가?)
Tech/Java & Spring2023. 5. 30. 00:01
728x90
728x90
개요
위 사진은 현재 개발중인 토이 프로젝트의 User - Friend의 테이블 구조이다.
친구 요청을 수락하여 서로 친구인 사용자 간의 개인 채팅방을 이용 가능하게 할 생각이었으며
친구 요청과 응답에 대한 프로세스를 하나의 테이블로 관리하고, 서로 친구가 된 친구목록을 또다른 테이블에 관리하려고 하였다.
(여기서 말하는 친구 요청에 대한 응답은, 웹소켓으로 구현했음)
현재 요청과 응답에 대한 프로세스
또한 요청과 응답을 관리하는 테이블의 STATUS 컬럼의 도메인값을 NOREAD, READ, REJECT, ACCEPT 의 네 경우를 두고, 요청을 보낸 사용자가 친구 요청을 받은 사용자가 친구 요청을 보지 않았는지, 봤는데 수락도 거절도 아닌지, 거절했는지에 대한 상황을 View할 수 있게 설계했다. 수락한 요청은 바로바로 FriendList에 테이블의 row로 추가할 예정이었다.
예를 들면 IDX가 3인 사용자가 IDX가 1인 사용자에게 요청을 보냈을 때,
위와 같은 row가 insert 될 것이다.
위의 예시처럼, IDX가 3인 사용자 A가 친구 요청 상황에 대한 조회를 원할 경우에, 아래와 같은 테이블의 상황이라고 가정하고, 내가 원하는 비즈니스 로직을 생각해 보았다.
A가 친구 추가를 보낸 것들에 대한 데이터를 요청할 때 1. A의 IDX를 가지고 FriendReqRes 테이블로 Go!! 2. IDX에 대한 요청이 있는지 Select 3. 있다면 어떤 유저에게 보냈는지(TO), 현재 A가 보낸 요청에 대한 상태(STATUS)를 List<Entity>에 담기 4. View 시켜주기
또한 해당 유저가 상대방에게 다시 친구 요청을 할 수 있는 상황은 상대방이 거절했을 때 즉 STATUS = REJECT일 때 라고 정책을 세웠음.
마지막으로 ACCEPT에 대한 row는 FriendList로 insert되며 상대방이 수락하여 서로 친구인 상황이기 때문에 FriendList에 아래와 같이 추가시켜 줄 생각이었다.
여기서 COMMENT 이하 컬럼값들은 생각하지말고, USER_IDX와 FRIEND_IDX만 살펴보자. 서로 친구인 상황이기 때문에, IDX가 3의 친구목록 뿐 아니라, IDX가 4인 친구목록에도 서로가 있어야 한다. 위의 row 하나만 추가할 경우, 서로가 친구인 상황을 DB를 조회하여 얻어내기 힘들기 때문에 친구 요청을 수락한 경우, FriendList 테이블에 두 개의 row를 insert하는 것이 맞다고 판단했다.
의문점
FriendList 테이블 하나에서 관리해도 되지 않을까??? 라는 의문이 들었다.
위와 같이, A라는 유저가 다시 IDX가 4인 사용자에게 친구 요청을 보냈다고 가정하자.
위와 같은 테이블의 row가 insert될 것이다. 4번 유저가 요청을 읽었을 때, 두 테이블의 row 모두 READ로 update해주고 거절했다면 REJECT, 수락했다면 FRIEND로 변경하여 서로 친구임을 나타낼 수 있다.
위와 같이 한 테이블로 관리하면 되지 않을까????? 라는 생각을 했다.
두 경우를 나름 정리해보았다.
1. 도메인 관리를 해야하는가 : NO - 어짜피 두 경우 다 도메인 관리를 해야함. 하다못해 도메인값이 뭐가 뭔지 내가 알아야함 - 여기서 말하는 도메인이란 create domain을 통한 도메인생성이 아니라, 해당 컬럼에 대한 범위 라고 정의했다.
2. 무결성을 고려해야 하는가 : 하긴 해야함 - 당연히 데이터의 무결성을 고려해야한다고 생각은 하지만 두 경우 모두 대비해야함 - IDX는 User의 PK기 때문에 중복될 일이 없다고 판단 - 트랜잭션으로 누락관리가 가능한 코드를 이미 짰음.
3. 성능 및 기타 issue - 테이블 분할 시 조인여부 : NO(요청을 수락하면 해당 IDX들을 들고가서 FriendList에 추가만 하면 된다.) - 유지보수, 확장성 : 아무래도 분할하는 것이 더 낫지 않나... 라는 생각
결국 두 테이블을 사용하는 방법은 데이터의 일관성이나 무결성을 보장하고, 복잡성이 감소할 것이고 FriendList에 STATUS column만 추가하여 사용하면 간단하게 데이터를 관리할 수 있지만, 요청과 응답, 해당 친구의 COMMENT update 혹은 친구 삭제 등 모든 로직을 한 테이블로 수행해야하기 때문에 로직이 추가되고 추가되고 많이 추가되다보면 헷갈리고 꼬일 수 있다고 생각한다.
결론
그래서 어떤 방법을 사용해야 할까? 우선 테이블을 분할하여 관리하기로 했다.
사실 두 방법다 나쁘지 않은 것 같다. 더 좋은 방법도 있는 것 같기도 한데 생각이 나지 않음..
결론 쪽을 쓰면서 하나 더 생각난 것이 있는데,
FriendList에서 친구 요청 하나에 두 row를 생성하는 것을, 컬럼 하나를 추가하여
3번이 4번에게 요청 보냈을 때, 컬럼값을 3→4 수락했을 때, 해당 컬럼에 →3, 거절했다면 →X
이런식으로 관리하여 →로 split하여 요청에 대한 응답 여부들을 파악한다면 row가 두 배로 늘어날 일이 없지 않을까.. 라는 생각도 해보고
row가 엄청 늘어날 경우 일정 기간이 지난 요청과 응답들은 삭제시키거나, 로그화하여 로그테이블을 따로 만드는 것도 방법이 될 것같다.