디자인패턴은 개발하면서 공통적으로 발생하는 문제들을 어떻게 해결 할 것인지에 대한 해결 방안으로 다양한 해결책 중에서 많은 사람들이 인정한 모범사례이다.

바로 전환될 수 있는 완성된 디자인은 아니며, 다른 상황에 맞게 사용될 수 있는 문제들을 해결하는데에 쓰이는 서술이나 템플릿이다.

디자인패턴은 객체지향 4대 특성(캡슐화, 상속, 추상화, 다형성)과 설계원칙(SOLID)을 기반으로 구현되어 있다.

 

디자인패턴은 재사용성, 가독성, 유지보수성, 확장성, 안정성과 신뢰성이라는 장점이 있다.

재사용성은 반복적인 문제에 대한 일반적인 해결책을 제공하는 것으로 이를 재사용하여 유사한 상황에서 코드를 더 쉽게 작성할 수 있다.

가독성은 명확하게 작성하여 개발자가 코드를 이해하고 유지보수하기 쉽게 만드는 것이다.

유지보수성은 코드를 쉽게 모듈화 할 수 있으며, 변경이 필요한 경우 해당 모듈만 수정하는 것으로 유지보수가 가능하도록 하는 것이다.

확장성은 새로운 기능을 추가하거나 변경할 때 기존코드를 변경하지 않고도 새로운 기능을 통합할 수 있는 것이다.

안정성과 신뢰성은 수많은 사람들이 인정한 모범사례로 검증된 솔루션이기 때문에 안정적이고 신뢰성있는 패턴이라고 할 수 있다.

 

 

디자인 패턴의 종류

디자인 패턴으로 가장 유명한 GoF 디자인 패턴에서는  크게 생성패턴(Creational Pattern), 구조패턴(Structural Pattern), 행동(행위)패턴(Behavioral Pattern)으로 분류되며 각각 5개, 7개, 11개의 패턴으로 총 23개의 패턴이 존재한다. 

 

생성패턴(Creational Pattern)

1. 싱글톤(Singleton)

2. 빌더(Builder)

3. 팩토리 메소드(Factory Method)

4. 추상 팩토리(Abstract Factory)

5. 프로토타입(Prototype)

 

구조패턴(Structural Pattern)

1. 어댑터(Adapter)

2. 브릿지(Bridge)

3. 컴포지트(Composite)

4. 데코레이터(Decorator)

5. 퍼사드(Facade)

6. 플라이웨이트(flyweight)

7. 프록시(Proxy)

 

행동(행위) 패턴(Behavioral Pattern)

1. 책임 연쇄(Chain of Responsibility)

2. 커맨드(Command)

3. 인터프리터(Interpreter)

4. 반복자(Iterator)

5. 중재자(Mediator)

6. 메멘토(Memento)

7. 옵저버(Observer)

8. 상태(State)

9. 전략(Strategy)

10. 템플릿 메소드(Template Method)

11. 방문자(Visitor)

 

 

계속해서 디자인패턴을 학습할 계획이며, 포스팅 할 때마다 위 패턴 목록에 링크를 추가할 계획이다.

 

Reference

 

[OOP] 디자인 패턴(Design Pattern)이란? - 장점 및 종류

디자인 패턴(Design Pattern)이란? 디자인 패턴은 개발하면서 발생하는 반복적인 문제들을 어떻게 해결할 것인지에 대한 해결 방안으로 실제 현업에서 비즈니스 요구 사항을 프로그래밍으로 처리하

ittrue.tistory.com

 

소프트웨어 디자인 패턴 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 소프트웨어 디자인 패턴(software design pattern)은 소프트웨어 공학의 소프트웨어 디자인에서 특정 문맥에서 공통적으로 발생하는 문제에 대해 재사용 가능한 해결

ko.wikipedia.org

 

기존 JWT를 사용한 프로젝트를 진행할 때 Redis를 활용한다는 것은 알고 있었지만 쓸 줄 몰랐기에 DB를 통해 처리하는 방법으로 마무리를 했었다.

 

이번에 Redis를 학습하면서 이 프로젝트를 리펙토링하고자 리펙토링 계획을 세우고 있는데 고려해야 하는 사항이 많이 발생해서 그 내용을 정리한다.

 

고민에 대한 정리글이기 때문에 문제 해결에 대한 정답이나 명확한 방법을 찾은것은 아니고 개인적인 의견이니 보시고 다른 의견이나 피드백은 환영합니다.

 

 

일단 현재 구조를 먼저 정리.

AccessToken과 RefreshToken 두 가지를 생성해 처리 중.

AccessToken의 Claim으로는 userId를 갖고 있으며 RefreshToken은 DB 조회를 위해 rtIndex라는 난수를 Claim으로 갖고 있음.

AccessToken이 만료되어 재발급 받아야 하는 경우 RefreshToken 역시 재발급 해 갱신하는 RefreshToken Rotation 방식으로 처리하는 중.

목적은 RDB가 아닌 Redis로 토큰을 관리하는 것이 목적이며, 여러 디바이스에서의 로그인을 허용할 수 있도록 하는 것이 목표.

 

 

고민 및 문제점

Redis로 토큰을 관리하는 것은 Redis를 어떻게 사용해야 하는지만 배우면 되는 부분이라 문제가 되지 않았다.

문제는 RDB처럼 여러 필드를 통해 저장하지 않고 K:V 형태로 어떻게 관리를 할 것이며,

다중 로그인에 대한 관리 및 탈취에 대한 대비는 어떻게 할 것인가였다.

 

몇일을 JWT 다중 로그인에 대해 알아봤지만 마땅한 답을 얻기 어려웠다.

 

그러다 팁을 얻을 수 있는 블로그 포스팅을 발견했다.

https://junior-datalist.tistory.com/352

 

Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응

I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론

junior-datalist.tistory.com

 

다중 로그인을 처리하신 것은 아니지만 생각하지 못했던 부분을 잡아 낼 수 있었다.

생각지 못했던 탈취당한 토큰으로 사용자보다 먼저 재발급을 받게 되는 경우까지 고려한 글이었다.

탈취자가 사용자보다 먼저 재발급을 받게 된다면 사용자는 정상적인 재발급을 받을 수 없게 되고, 로그아웃 처리가 되는 형태가 되어버린다.

그럼 탈취자는 재발급 받은 토큰으로 계속 사용이 가능하게 되며 사용자는 재로그인을 하게 되면서 한 아이디에 토큰이 두개가 되는 형태가 될 수 있다.

이걸 방지하기 위해 DB에 RefreshToken을 저장하는 것이고 사용자가 요청을 보냈을 때 자신의 아이디로 이미 RefreshToken이 존재한다면 탈취되었다고 판단하고 해당 데이터를 삭제하는 방법으로 탈취자의 사용을 막을 수 있게 된다.

 

이 포스팅을 보고 각 토큰의 Redis 저장 형태를 잡을 수 있었다.

rtuserId : RefreshTokenValue, atuserId : AccessTokenValue

 

처음에는 RefreshTokenValue : AccessTokenValue,  AccessTokenValue : userId 형태로 처리하려고 했다.

다중 로그인을 처리하고자 하다보니 중복되지 않은 키값으로 각 토큰의 값을 key로 갖는것이 좋겠다고 생각했기 때문이다.

그리고 현재 구현되어있는 구조도 그런 구조로 되어있다.

RefreshToken의 Claim인 rtIndex가 기본키로 되어있고 RefreshTokenValue와 userId를 담고 있다.

이 구조에서도 이에 대한 대비가 부족하다고 이번에 느꼈다.

RefreshToken에서 Claim을 꺼내더라도 userId는 찾을 수 없고, DB 조회를 통해서만 알 수 있었다.

그럼 이미 갱신되어버린 이전 값으로는 사용자의 아이디도 찾을 수 없기 때문에 토큰 존재여부를 확인할 수 없다.

 

그래서 해당 블로그에서는 userId를 키값으로 갖는 구조를 얘기한다.

userId : RefreshTokenValue 이 구조라면 key값은 유지되고 value값만 갱신되기 때문에 value와 서버에서 받은 RefreshTokenValue가 다르다면 탈취한것으로 판단할 수 있다는 것이다.

 

그래서 RefreshToken의 Claim 역시 사용자 아이디로 수정하기로 했다.

그로인해 서버에서 받은 RefreshToken의 claim으로 key값을 알 수 있게 되고, value를 비교할 수 있게 된다.

 

 

 

 

 

그럼 탈취에 대한 대비는 어느정도 되었으니 다음 문제다.

'key가 userId 구조라면 다중 디바이스 로그인 처리에서 중복될텐데 이걸 어떻게 처리할 것인가?'

 

이 문제 역시 다양한 방안을 고민해봤다.

처음에는 기기 정보를 얻을 수 있으면 해결되지 않나 싶었다.

Mobile, Tablet, else 형태로 구분을 하는 방법은 있었다.

하지만, 각 디바이스별 앱인지, 웹앱인지를 또 구분해야 하고 그에 따른 차이도 필요했다.

그럼 디바이스 식별자 정보로 key를 생성하는것도 조금 애매해졌다.

 

https://wildeveloperetrain.tistory.com/297

 

JWT 인증 단점과 중복 로그인 관련 해결 방안

JWT 인증 기능의 단점과 문제점 그리고 중복 로그인 문제 해당 포스팅은 'jwt 인증 기능을 사용하면서 느꼈던 단점과 발생했던 문제점, 그리고 중복 로그인에 대한 나름의 해결 방안'을 정리한 것

wildeveloperetrain.tistory.com

이 문제는 여기서 답을 찾을 수 있었다.

 

로그인시 고유값을 하나 만들어서 클라이언트에 전달해주는 방법이다.

이 방법으로 UUID를 통해 임시로 tno라는 변수를 만들고 이걸 클라이언트에게 전달하는 방법을 생각했다.

 

그럼 디바이스별, 브라우저별로 각각 tno가 모두 다를것이기 때문에 key가 중복되는 일을 없앨 수 있다고 생각했다.

그래서 구조를 이렇게 생각했다.

"rt" + tno + userId : RefreshTokenValue,   "at" + tno + userId : AccessTokenValue

그럼 모바일 기준 앱 환경에서 로그인 할때의 tno와 크롬이나 브라우저 애플리케이션에서 로그인할때의 tno가 다를 것이고 다중 로그인을 성공적으로 해결할 수 있다.

 

하지만 여기서 고려해야할 사항이 하나 더 있다.

tno는 사용자가 로그인을 할 때 마다 생성해서는 안된다.

처음에는 tno를 로그인 요청 프로세스 사이에 끼워서 매번 생성하고자 했다.

 

하지만 그렇게 처리하면 첫번째 고려사항인 탈취 대비에 대해 문제가 발생한다.

coco라는 아이디의 유저가 처음 로그인을 하고 asdf 라는 tno가 생성되었다고 치자.

그럼 rfasdfcoco : RefreshTokenValue, atasdfcoco : AccessTokenValue 이렇게 redis에 저장될 것이다.

여기서. 탈취가 되었다고 가정하자.

그럼 탈취자는 rfasdfcoco의 value값을 재발급 받게 될 것이다.

이때 사용자가 다시 접근한다면 동일한 tno를 갖고 있기 때문에 RefreshTokenValue가 다르다는 것을 서버가 알게되고 Redis에서 데이터를 삭제하게 될 것이다.

 

하지만, 만약 사용자가 RefreshToken의 만료기간 이상의 기간동안 접속하지 않는다면?

사용자는 RefreshToken 쿠키가 만료되어 토큰 정보를 전혀 갖고 있지 않게 될 것이다.

그럼 재 로그인을 해야될 것이고 그때 새로운 tno를 생성해 로그인 처리를 한다면

rfqwercoco : RefreshTokenValue 라는 데이터가 Redis에 담기게 될 것이고, 탈취자는 아무런 지장없이 rfasdfcoco 데이터를 통해 계속해서 기능 사용이 가능할 것이다.

 

이걸 방지하기 위해서는 최초 로그인시에 발급된 tno를 디바이스에서 계속 보관하도록 해야 하며, 만료기간이 없어야 한다.

그리고 재 로그인시에는 최초 발급된 tno를 통해 처리하도록 해야 중복되는 키값을 확인할 수 있고 서버가 탈취에 대한 대비를 할 수 있게 된다.

 

 

그럼 이제 아직 해결하지 못한 문제 하나는

이 tno를 어디다 저장할 것인가 이다.

쿠키의 경우 브라우저 설정에서 쿠키 삭제가 가능하다.

이걸 막을 수 있는 방법이 있는지는 아직 잘 모르겠고, 설정중에 브라우저 종료시마다 쿠키를 삭제하는 설정도 있었던 기억이 있다.

그럼 tno가 쿠키에 담기는 경우 이런 불상사가 일어날 수 있다.

그럼 localStorage에 저장해야 하나? 라는 생각을 했지만 

localStorage는 아무래도 js에서 접근하기가 수월해 이것도 애매하다.

이 문제는.....

어떻게 해결할지 아직 답이 없다..

 

 

 

정리

1. Redis에 RefreshToken만 담는 것 보다 AccessToken까지 담는것이 재발급 요청에서의 토큰 탈취에 대비하기 더 좋다.

    재발급 요청이 왔을 때 AccessToken의 만료기간을 체크해 아직 만료기간이 남아있다면 탈취된 토큰이라고 볼 수 있기 때문이다.

 

2. 토큰 탈취에 대비해 key값은 사용자 아이디 같은 사용자 고유의 정보가 포함되는 것이 좋다.

    탈취자가 사용자보다 먼저 재발급을 받는 경우에도 토큰을 수월하게 비교할 수 있기 때문이다.

 

3. 다중 로그인을 허용하기 위해서는 각 디바이스별, 각 브라우저별 고유값을 최초 로그인시에 부여하고 삭제되지 않도록 해 관리하는것이 좋다.

    고유값을 로그인마다 재 생성해 반환한다면 이전 고유값의 데이터와 토큰값이 탈취당한 경우 잡아낼 수 없다.

    단, 이 고유값을 클라이언트에서 어디에 어떻게 저장하고 관리하도록 할지에 대한 고민이 남아있다.

 

매번 MyBatis, JPA 만 쓰다가 이번에 Servlet&JSP로 구현하면서 JDBCTemplate을 처음 사용해봤다.

크게 어려운점도 없었고 문제도 없었지만 동적쿼리를 사용하는 점에서 고민이 있었다.

 

MyBatis나 JPA에서 동적쿼리를 사용하는데에 있어서는 처리 방안이 따로 마련되어 있다.

하지만 JDBCTempate의 경우 String 타입의 문자열로 쿼리를 작성해두고 사용하기 때문에 막상 동적쿼리를 사용하려고 하니 어? 하는 부분이 생겼다.

 

일단 사용하는 부분들은 insert 처리와 delete 처리였다.

INSERT INTO board(boardNo, boardTitle, boardContent)
VALUES(?, ?, ?)
,(?, ?, ?);

DELETE FROM board WHERE boardNo IN (?, ?);

동적쿼리가 필요한 만큼 value의 개수는 매번 달라질 수 있었기 때문에 방법이 필요했다.

 

일단 insert의 경우 List<DTO> 타입으로 받아와서 처리를 했고,

delete의 경우 List<Long> 타입으로 받아오도록 처리를 했다.

 

그럼 이 List의 size에 따라 value 부분이 조정되도록 처리해야 했다.

 

가장 먼저 든 생각은 반복문으로 필요한 부분을 추가해서 만들자는 것이었다.

JDBCTemplate은 문자열로 쿼리를 먼저 작성하니 원하는 만큼 반복해서 붙여넣으면 되지 않나? 라는 생각이었다.

 

근데 효율성에서 고민이 들었다.

그렇게 처리하고자 한다면 크게 봤을 때, 쿼리문을 만드는데 반복문 한번, PreparedStatment를 통해 값을 넣을 때 또 반복문 한번.

이렇게 두번의 반복문을 거쳐 처리할 필요가 있었다.

그리고 세부적으로 본다면 쿼리문을 만드는 반복문 안에서 , 의 위치를 맞춰서 넣어줄 수 있도록 조건문 역시 필요하겠다는 생각이 들었다.

 

그럼 반복문을 돌리면서 조건문도 체크하고 이거 끝나면 반복문이 또 돌아가야된다.

그래서 String 클래스에서 뭔가 방법이 있지 않을까 하고 찾아봤다.

 

역시 방법은 있었다.

찾은 방법은 String.join() 이었다.

전체적인 코드를 먼저 보면 아래와 같다.

//insert sql 
//List<DTO> insertDTO size = 3 

String insertSQL = "INSERT INTO board(boardNo, boardTitle, boardContent) VALUES";
String valueSQL = String.join(",", Collections.nCopies(insertDTO.size(), "(?, ?, ?)"));
String insertSQL = insertSQL.concat(valueSQL);
/*
insertSQL = "INSERT INTO board(boardNo, boardTitle, boardContent)
VALUES(?, ?, ?)
,(?, ?, ?)
,(?, ?, ?)"
*/


//delete sql
//list<Long> deleteNoList size = 3 

String deleteSQL = "DELETE FROM board WHERE boardNo IN (%s)";
String valueSQL = String.join(",", Collections.nCopies(deleteNoList.size(), "?"));
String deleteSQL = String.format(deleteSQL, valueSQL);

/*
deleteSQL = "DELETE FROM board WEHRE boardNo IN (?, ?, ?)"
*/

String.join은 (추가 문자열 사이에 들어갈 문자열, 추가하고자 하는 문자열의 리스트 혹은 배열) 형태로 처리한다.

insert 처리의 valueSQL을 나눠서 보면 ","과 Collections~~~로 나눠서 볼 수 있다.

그럼 추가 문자열 사이에 ' , '를 넣겠다는 것이고, 추가 문자열은 insertDTO.size 만큼의 "(?, ?, ?)"가 들어있는 리스트라는 것이다.

그럼 insertDTO의 size를 3으로 가정한 만큼 (?, ?, ?) (?, ?, ?) (?, ?, ?) 이렇게 세개의 값을 가진 리스트가 생성이 되는것이다.

그리고 join을 통해 저 값 사이사이에 ' , '가 들어가게 되어 (?, ?, ?), (?, ?, ?), (?, ?, ?)라는 문자열이 완성이 된다.

 

delete의 경우도 마찬가지이다. insert와 구조적인 차이는 있지만 ? ? ? 라는 세개의 값을 가진 리스트를 만든 것이고,

이 세개의 값 사이사이에 ' , '를 넣어 문자열을 만들었기 때문에 ?,?,?라는 문자열이 만들어지게 된다.

 

이후 처리에서는 insert와 delete가 다른 방법으로 처리된다.

concat()과 format()으로 처리한 경우인데 일단 format부터 보자면 printf와 같은 원리다.

deleteSQL을 보면 IN 절 뒤에 (%s)가 들어가게 된다.

그래서 format을 통해 deleteSQL의 %s 부분에 valueSQL이 들어가게 되고

IN (?,?,?) 형태로 문자열이 완성될 수 있는것이다.

printf 로 보자면 System.out.printf("~~~IN (%s)", valueSQL); 이렇게 볼 수 있다.

 

concat은 문자열 뒤에 문자열을 추가해준다.

그래서 VALUES 까지만 작성된 insertSQL뒤에 필요했던 (?, ?, ?), (?, ?, ?), (?, ?, ?) 형태로 문자열을 완성할 수 있는것이다.

 

concat의 경우는 조금 더 알아야 할 점이 있다.

언뜻 보기에는 StringBuilder에서 append와 동일해 보일 수 있다.

하지만 차이는 있다.

 

가장 먼저 concat()에서 기존 문자열에 null은 들어갈 수 없다.

기존 문자열이 null인 경우에는 NullPointerException이 발생하게 된다.

그리고 기존 문자열과 concat 수행 후 문자열의 주소값이 달라진다.

Hello와 World를 concat으로 연결해준다고 가정했을 때

예를 들어 Hello의 주소값이 100이었다면 HelloWorld로 concat을 수행한 뒤에는 200으로 변경된다.

 

반면 StringBuilder는 기존값이 null이어도 정상적으로 수행한다.

단, null뒤에 append를 통해 World를 붙이게 된다면 World가 되는것이 아닌 nullWorld가 된다.

이점은 주의해야 한다.

그리고 StringBuilder의 경우는 주소값이 변경되지 않는다.

 

문자열을 합칠 때 + 연산자도 많이 사용하게 되는데 이 + 연산자는 자바 버전에 따라 다르다고 한다.

1.5버전을 기준으로 하는데 1.5 이전에는 concat과 같은 처리였지만 1.5 이후에는 StringBuilder와 같은 처리가 된다고 한다.

 

 

Reference

https://devdy.tistory.com/9

 

[JAVA] 문자열 붙이는 방식의 차이(concat, +, StringBuilder)

자바에서 String타입을 붙일 때 사용하는 방법은 다양하다. 기본 연산자인 +를 비롯하여 String Builder, concat 모두 들어보거나 써본 용어일 것이다. 근데 동작 방식에 어떤 차이가 있을까? 먼저 결과

devdy.tistory.com

 

Spring에서는 Ajax 요청 후 응답을 받고자 하면 보통 컨트롤러에서 해당 메소드에 @ResponseEntity Annotation을 달아놓고 return 으로 반환하면 잘 받는다.

 

하지만 Servlet에는 @ResponseEntity Annotation이 없으니 다르게 리턴해야 했다.

 

요청을 처리 후 결과는 PrintWriter로 처리하면 응답을 보낼 수 있었다.

@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
                                             throws ServletException, IOException {
    
    String result = boardService.delete(request);
    
    PrintWriter out = response.getWriter();
    out.print(result);
}

 

자 여기서 문제점.

String 타입으로 응답을 받고자 했고 정상적으로 처리가 된 경우 "success"를 받고 문제가 발생한 경우에는 "fail"을 받고자 했었다.

Ajax에서는 if(result == "success")로 처리를 하고 있었다.

근데 여기서 해당 조건에 걸리질 않았다.

 

ajax에서 console로 찍어봐도 success를 출력하는데 전혀 조건에 걸리질 않았다.

javaScript에서 문자열 비교할 때 다른 방법이 있나 찾아보고 다 해봤지만 하나도 안됐다.

 

문제는 PrintWriter 처리에서 있었다.

out.print(result)로 보내야 했지만 코드를 마저 다 쓰는것이 귀찮았던지라 자동완성으로 out.println(result)를 찍었던것..

 

out.print로 수정하니 정상적으로 잘 체크할 수 있었다.

 

정확하게 아! 이거때문에! 라고 할 순 없지만 아마 내 생각에는

print의 경우 "success"로 넘어왔을 것이고 개행을 하는 println의 경우는 "success\n"으로 넘어와서 그 차이에 비교를 할 수 없었던 것 아닐까 싶다.

 

 

 

정리하는김에 getJSON 요청에 대한 처리도 정리.

jQuery를 통해 getJSON 요청을 보내 데이터를 파싱하고 처리하는 부분이 있었다.

이것 역시 Spring에서는 ResponseEntity로 리턴을 해 쉽게 처리했었다.

Servlet에서는 역시 ResponseEntity가 없다..

 

그럼 이걸 처리하기 위해서는 json으로 변환을 해줘야 할 필요가 있었고, 그 데이터를 보내줘야 했다.

json 변환을 처리하기 위해서는 gson 라이브러리를 사용했고 코드는 아래와 같이 처리했다.

 

@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
                                             throws ServletException, IOException {
    CommentDTO dto = commentService.getComment(request);
    
    response.setContentType("application/json");
    response.setCharacterEncoding("UTF-8");
    
    String gson = new GsonBuilder().setDateFormat("yyyy-mm-dd").create().toJson(dto);
    
    response.getWriter().write(gson);
}

일단 gson 처리에서 문제가 있다.

이 프로젝트에서 Date 구조의 경우 yyyy-mm-dd 구조로 데이터베이스에 담겨있고 이 구조대로 출력이 된다.

하지만 gson으로 변환하면 Date 구조가 00월 00일 00년 형태로 지맘대로 바꿔버린다.

그래서 변환을 할 때 setDateFormat으로 설정해줄 필요가 있다.

 

그리고 응답의 경우 print() 가 아닌 write()로 응답하면 된다.

 

Servlet&JSP에 대해 공부만 해봤지 프로젝트를 진행해본적이 없어서 이번에 한번 해봤다.

설정이나 Annotation 사용에 있어서 조금 고민이 필요했던 부분이 있었을 뿐 크게 문제가 된 부분은 없었지만 파일 업로드 요청을 서버가 처리하는데 있어서는 방법이 달라 이래저래 알아보고 정리한다.

 

개발환경

  • Java8
  • MySQL8.0
  • Servlet 4.0
  • commons-io-2.11.0
  • commons-fileupload-1.5
  • jQuery(Ajax)

 

기능은 게시판으로 제목, 내용, 이미지 파일을 작성하고 출력한다.

요청은 FormData에 담아 Ajax로 요청하도록 처리했다.

 

스프링에서 파일 저장은 @RequestParam Annotation과 List<Multipart>로 간단하게 받을 수 있었지만 Servlet에서는 doPost를 통해 받도록 했기 때문에 어떻게 해야할지 고민이었다.

알아보기 전에 먼저 ajax로 보낸 데이터를 request.getParameter()를 통해 꺼낼 수 있는지 테스트해봤지만 꺼낼 수 없었다.

그래서 이래저래 알아보니 방법이 여러가지가 있었으나 대체로 단일 파일만 처리하는 예제가 많았다.

다중 파일 업로드를 처리하는 방법을 알면 단일 파일 업로드는 쉽게 처리할 수 있기 때문에 조건에 맞는 예제나 팁을 찾았다.

 

예제 참고한 포스팅

https://blog.miyam.net/144

 

Ajax 와 Servlet 을 이용한 파일 업로드

자료를 찾아보면 Spring을 이용한 파일 업로드가 대다수 이며 오렐리에서 제공하는 라이브러리가 주를 이룬다. Spring은 사용하기 싫고 오렐리에서 제공하는 라이브러리는 몇가지 버그가 있는 듯

blog.miyam.net

 

기존 Spring 기반의 프로젝트에서도 commons-io와 commons-fileupload 라이브러리를 통해 처리해서 이걸 그대로 써야 하나 고민하고 있었는데 마침 이 분이 해당 라이브러리를 사용해 처리하는 방법을 남겨두셨었다.

 

//Ajax 요청


$('#imageInsert').on('click', function() {
    var form = $('#uploadForm')[0];
    var formData = new FormData(form);
    
    for(var index = 0; index < Object.keys(files).length; index++) {
        formData.append('files', files[index]);
    }
    
    $.ajax({
        type: 'POST',
        enctype: 'multipart/form-data',
        processData: false,
        contentType: false,
        cache: false,
        url: '/imageBoard/imageInsert',
        data: formData,
        success: function(data) {
            if(data == 0 || data == -1) {
                alert('오류가 발생했습니다.');
            }else {
                location.href='/imageBoard/imageDetail?imageNo=' + data;
            }
        }
    });
});
//Servlet

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(urlPatterns = "/imageBoard/imageInsert")
public class ImageBoardInsertServlet extends HttpServlet {
    private static final long serialVersionUID = 1;
    
    private ImageBoardService imageBoardService = new ImageBoardServiceImpl();
    
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
                                                 throws ServletException, IOException {
    
        request.setCharacterEncoding("UTF-8");
        
        long result = imageBoardService.insert(request);
        PrintWriter out = response.getWriter();
        out.print(result);
    }
    
}
//imageBoardService

import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.UUID;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;

public class ImageBoardServiceImpl implements ImageBoardService {
    private ImageBoardDao imageBoardDAO = new ImageBoardDaoImpl();
    
    @Override
    public long insert(HttpServletRequest request) {
        ImageBoardInsertDTO dto = new ImageboardInsertDTO();
        List<ImageDataDTO> imageDTOList = new ArrayList<ImageDataDTO>();
        String title = null;
        String content = null;
        HttpSession session = request.getSession();
        String uid = (String) session.getAttribute("id");
        
        try{
            // 1.DiskFileItemFactory
            DiskFileItemFactory = diskFactory = new DiskFileFactory();
            disFactory.setSizeThreshold(4096);
            diskFactory.setRepository(new File(FileProperties.TEMP_PATH));
            
            // 2.ServletFileUpload
            ServletFileUpload upload = new ServletFileUpload(disFactory);
            upload.setSizeMax(FileProperties.FILE_SIZE);
            
            List<FileItem> items = upload.parseRequest(request);
            Iterator<FileItem> iter = items.iterator();
            
            
            while(iter.hasNext()) {
                FileItem item = (FileItem) iter.next();
                
                //3. isFormField() == true
                if(item.isFormField()) {
                    String fieldName = item.getFieldName();
                    
                    if(fieldName.equals("imagetitle"))
                        title = item.getString("UTF-8");
                    else if(fieldName.equals("imageContent"))
                        content = item.getString("UTF-8");
                        
                //4. isFormField() == false
                }else {
                    if(item.getSize() > 0)
                        saveFile(item, imageDTOList, step);
                }
            }
        }catch(Exception e) {
            return -1;
        }
        
        dto = new ImageBoardInsertDTO.ImageBoardInsertDTOBuilder()
                    .imageTitle(title)
                    .imageContent(content)
                    .userId(uid)
                    .build();
        
        return imageBoardDAO.insert(dto, imageDTOList);
    }
    
    public void saveFile(FileItem item, List<ImageDataDTO> imageDTOList) {
        try{
            //5. file name
            String name = item.getFieldName();
            String fileName = item.getName();
            
            StringBuffer sb = new StringBuffer();
            String saveName = sb.append(new SimpleDateFormat("yyyyMMddHHmmss")
                                          .format(System.currentTimeMillis()))
                                .append(UUID.randomUUID())
                                .append(fileName.substring(fileName.lastIndexOf(".")))
                                .toString();
            
            //6. path
            Path filePath = Paths.get(FileProperties.FILE_PATH + "/" + saveName);
            File uploadFile = filePath.toFile();
            item.write(uploadFile);
            
            imageDTOList.add(new ImageDataDTO.ImageDataDTOBuilder()
                                    .imageName(saveName)
                                    .oldName(fileName)
                                    .build();
        }catch(Exception e) {
            e.printStackTrace();
        }
    }
}

1. DiskFileItemFactory

DiskFileItemFactory는 FileItemFactory의 구현체이다.

FileItem Object를 생성하는 클래스이며, 작은 항목은 메모리에 큰 항목은 디스크의 임시 파일로 보관하는 인스턴스를 생성한다.

아무 설정도 하지 않는다면 메모리 버퍼의 기본 임계값은 10KB이다.

그래서 setSizeThreshold(4096)로 4MB의 크기로 설정해줬다. 이때, 매개변수의 단위는 byte단위이다.

데이터의 크기가 이 임계값을 초과하는 경우에는 일시영역에 데이터가 보존된다.

 

setRepository는 임시보관되는 일시영역을 지정한다.

그래서 파일의 저장 경로로 설정해둔 FILE_PATH가 아닌 TEMP_PATH라는 디렉터리를 새로 생성하고 사용하도록 했다.

 

DiskFileItemFactory Document

https://commons.apache.org/proper/commons-fileupload/apidocs/org/apache/commons/fileupload/disk/DiskFileItemFactory.html

 

DiskFileItemFactory (Apache Commons FileUpload 1.5 API)

The default FileItemFactory implementation. This implementation creates FileItem instances which keep their content either in memory, for smaller items, or in a temporary file on disk, for larger items. The size threshold, above which content will be store

commons.apache.org

 

2. ServletFileUpload

Servlet 프로그램을 경유하고 파일 데이터를 취득하는 클래스이다.

ServletFileUpload의 생성은 이전에 생성한 DiskFileItemFactory 인스턴스를 매개변수로 생성한다.

그리고 setSizeMax로 업로드 할 수 있는 최대 용량을 설정해준다.

이때, 이 용량 단위 역시 byte 단위로 설정하게 된다.

무한대로 설정하는 경우에는 -1로 설정해 줄 수 있다.

 

다음으로 FileItem 타입 리스트를 .parseRequest(request)로 생성하게 되는데,

parseRequest의 경우 FileItem의 목록을 얻을 수 있게 해준다.

 

FileItem은 'multipart/form-data'로 들어온 POST 요청 내에서 수신 된 파일 또는 양식 항목을 나타낼 수 있다.

그럼 parseRequest로 FileItem 타입의 리스트를 생성한다는 것은 POST 요청으로 들어온 'multipart/form-data'의 리스트를 생성한다고 볼 수 있다.

그래서 ServletFileUpload 문서에서 역시 FileItem 목록을 얻으려면 해당 메소드를 사용하라고 명시하고 있다.

 

이렇게 만들어준 리스트는 Iterator.next()로 접근해 풀어내게 된다.

 

3. item.isFormField() == true

FileItem에서 isFormFiled()의 경우 파일데이터라면 false를 파일 데이터가 아니라면 true를 반환하도록 되어있다.

현재 이 프로젝트에서 넘어오는 FileItem들로는 텍스트 형태의 제목, 내용이 있고 파일 형태의 이미지 파일로 구성되어있다.

그럼 제목과 내용은 파일 데이터가 아니기 때문에 true를 반환받게 되고 파일 데이터인 이미지 파일의 경우 false를 반환받아 구분할 수 있게 된다.

 

.getFieldName()은 필드명이다. formData의 구조를 보면 imageTitle : "testImageTitle"  이런 형태의 K : V로 구성되어 있다.

여기서 getFieldName()이라는 것은 키값인 필드명을 의미한다.

그리고 getString()은 값을 의미한다. 그래서 testImageTitle이라는 값을 받아올 수 있게 된다.

 

그로인해 이 코드에서는 FieldName이 imageTitle이라면 title 변수에 값을 담아주고, imageContent라면 content 변수에 내용을 담아 추후 DTO에 담을 수 있게 처리가 가능한 것이다.

 

4. item.isFormField() == false

위에서 얘기했듯이 파일 데이터인 경우 false를 반환받는다.

item.getSize()는 이 FileItem의 크기를 체크하는 것이다.

만약 크기가 0이라면 제대로 된 파일이 아니라고 볼 수 있기 때문에 검증하는 정도로 생각할 수 있다.

이 프로젝트에서는 게시글 수정 기능 역시 존재하고 수정 기능 처리에서도 파일 저장 처리를 수행해야 하기 때문에 saveFile() 이라는 메소드에 파일 저장 처리를 분리했다.

 

5. filename

파일 데이터에서 역시 getFieldName은 formData의 키값에 해당하는 필드명을 가져온다.

Ajax 코드를 보면 이미지 파일의 경우 'files'라는 필드명을 사용했기 때문에 files를 가져오게 된다.

 

getName()의 경우 파일명을 가져온다.

기존의 파일명 자체를 가져오기 때문에 여기서는 확장자명을 분리하는것과 oldName이라는 기존 파일명을 저장하는 컬럼에 담기 위해 사용했다.

 

6. Path

Path 인터페이스의 경우 아직 제대로 이해하지는 못했다.

document를 확인해보고 여러 포스팅을 봤을 때는 파일 경로에 대한 처리를 담당하는 인터페이스이다.

 

Paths는 Path 객체로 변환하여 URI를 반환하는데 사용한다.

Paths.get(url) 로 저장 경로를 Path 객체로 변환하는 것이다.

 

Path 인터페이스에 대해서는 전부 이해하지 못했지만 이 코드 처리과정을 보자면

Paths.get(url)로 Path 객체를 만들어준다.

그리고 toFile()로 해당 경로를 나타내는 객체인 Path 객체를 File 타입으로 변환하게 된다.

 

그 후, FileItem.write(File)을 통해 파일을 저장하게 된다.

 

이 Path를 사용하는 방법은 상단에 참고한 포스팅에 있는 코드를 가져다 사용한건데 처음본거고 이해도 좀 어려웠다.

 

다시 좀 정리해보자면

Path filePath = Paths.get(FILE_PATH + "/" + saveName);

여기서 파일 저장 경로를 Path 객체로 변환해 filePath라는 객체를 만들어준다.

 

File uploadFile = filePath.toFile();

그 후, Path객체에서 해당 경로의 객체를 File로 반환하게 되고

 

item.write(uploadFile)

File 객체를 저장해준다.

라고 볼 수 있다.

 

이 과정을 좀 다르게 풀어보기도 했었다.

String saveName = ........;

File uploadFile = new File(FileProperties.FILE_PATH + "/" + saveName);
item.write(uploadFile);

이렇게 처리하더라도 동일하게 처리할 수 있다.

 

Path가 다르게 어떻게 또 쓰임이 있을지는 아직 모르겠지만 적어도 이 처리 과정내에서는 파일 저장 경로 설정 및 파일 객체로의 반환을 처리하는 용도로 사용되고 있다고 볼 수 있지 않나 싶다.

 

 

 

마지막으로 한가지 짚고 넘어가야 할 점!

수정 처리에서는 삭제해야 하는 이미지 파일이 존재할 수 있다.

새로 등록하는 파일의 경우 기존 등록 처리와 동일한 구조로 오겠지만 삭제해야 하는 이미지 파일의 경우 이미지명만 List화 해서 받도록 구현했다.

즉, List<String> 타입으로 넘어오게 되는데, 진짜 아무생각없이 그냥 테스트를 먼저 해봤다.

결과는 삭제해야하는 파일의 리스트도 결국에는 파일 데이터가 아니기 때문에 isFormField에서 true를 반환하게 된다.

isFormField()는 파일 데이터인지 아닌지만 구분한다는 것을 잘 생각해야 한다.

 

 

Reference

참고 포스팅

https://kumbange.tistory.com/180

 

파일업로드 (FileItem, DiskFileItemFactory)

파일업로드 (FileItem, DiskFileItemFactory) Servlet에서 파일 업로드를 행하는 프로그램을 작성한다. Jakarta Commons에서는 파일업로드를 생하는 프로그램을 간단히 작성하는 라이브러리 FileUpload을 제공하

kumbange.tistory.com

 

FileItem Document

https://commons.apache.org/proper/commons-fileupload/apidocs/org/apache/commons/fileupload/FileItem.html

 

FileItem (Apache Commons FileUpload 1.5 API)

A convenience method to write an uploaded item to disk. The client code is not concerned with whether or not the item is stored in memory, or on disk in a temporary location. They just want to write the uploaded item to a file. This method is not guarantee

commons.apache.org

ServletFileUpload Document

https://commons.apache.org/proper/commons-fileupload/apidocs/org/apache/commons/fileupload/servlet/ServletFileUpload.html

 

ServletFileUpload (Apache Commons FileUpload 1.5 API)

High level API for processing file uploads. This class handles multiple files per single HTML widget, sent using multipart/mixed encoding type, as specified by RFC 1867. Use parseRequest(HttpServletRequest) to acquire a list of FileItems associated with a

commons.apache.org

Path Document

https://docs.oracle.com/javase/8/docs/api/java/nio/file/Path.html#getFileName--

 

Path (Java Platform SE 8 )

Returns the parent path, or null if this path does not have a parent. The parent of this path object consists of this path's root component, if any, and each element in the path except for the farthest from the root in the directory hierarchy. This method

docs.oracle.com

Paths Document

https://docs.oracle.com/javase/8/docs/api/java/nio/file/Paths.html

 

Paths (Java Platform SE 8 )

Converts the given URI to a Path object. This method iterates over the installed providers to locate the provider that is identified by the URI scheme of the given URI. URI schemes are compared without regard to case. If the provider is found then its getP

docs.oracle.com

 

'JAVA' 카테고리의 다른 글

멀티쓰레드(Multi Thread)  (0) 2021.02.14
쓰레드(Thread)  (0) 2021.02.13
입출력스트림(IOStream)  (0) 2021.02.12
스트림(Stream)  (0) 2021.02.11
람다식(Lambda)  (0) 2021.02.10

공부를 하면 할 수록 프로젝트하면서 중요하게 처리해야 하는 것 중 하나가 예외처리라는 점을 느끼는 중이다..

 

근데 이게 너무 어렵다..

단순하게 오류가 발생하면 catch에서 오류가 발생했다는 것을 의미하는 응답을 하도록 하는 정도야 쉽지만 데이터베이스에 접근하는 과정이 있는 처리에서 오류가 발생한다면 롤백을 생각해야 한다.

 

그걸 간단하게 처리해줄 수 있는 어노테이션이 @Transactional 이다.

물론 이 어노테이션은 UncheckedException에 대해서만 롤백을 처리해준다.

CheckedException은 개발자가 예외처리를 할 수 있는 오류라고 판단해서 롤백을 해주지 않도록 만들었다고 한다.

 

하지만 방법이 없는것은 아니다. @Transactional(rollbackFor = {Exception.class}) 이렇게 rollbackFor 옵션을 통해 CheckedException이 발생했을 때도 롤백을 하도록 처리할 수 있다.

 

간단하게 하나의 메소드에서 모든 처리를 담당한다고 했을 때, 이 메소드에 어노테이션을 붙여주면 롤백에 대한 걱정은 크게 하지 않아도 된다.

 

하지만 그렇지 않은 경우에는?

여러가지를 고려해야 할 수 밖에 없다.

여러 테스트를 해보면서 알게 된 것은 상위 메소드에 @Transactional을 달아두고 하위 메소드에서 데이터베이스에 요청을 보낸 뒤 오류가 발생하는 경우에는 롤백처리가 되지 않았다.

이런 경우 하위메소드에 @Transactional을 달아줘야 문제가 해결된다.

 

그럼 그렇다고 모든 메소드에 어노테이션을 달아야 하는가?

매번 그런 조건을 달고서만 메소드 분리를 해야하는가?

이런 고민이 생기게 되었다.

또한, 하위메소드에서 데이터베이스에 요청을 보내지 않아도 무언가를 처리하다가 오류가 발생할 수도 있는데 그럼 그때는 다른 메소드에서 발생한 요청에 대해서는 어떻게 롤백을 해야하지?

 

이렇게 그냥 밑도끝도 없이 내려가게 되었다.

 

트랜잭션이라는 것이 '데이터베이스 상태를 변환시키기 위한 하나의 작업의 단위다.' 라고 배웠고,  상위 메소드에 달아주면 당연히 호출한 메소드들의 것들까지 다 처리해줄거라고 생각했는데 그건 또 아니었던것 같다..

 

그리고 또 하나의 문제가 있었다.

try-catch로 예외처리를 하는 경우에는 롤백이 동작하지 않는다.

예외가 발생하는 경우 catch에서 강제로 Exception을 발생시키도록 하면 롤백이 될거라고 생각했으나 되지 않았다.

근데 된다는 포스팅들도 있다. 그래서 이건 아직 테스트가 더 필요할 것 같다..

 

 

문제해결

문제를 해결하긴 했으나 막 만족스럽진 않고 일차원적인 해결이 아닐까 라고 생각한다.

 

쇼핑몰 프로젝트였고 상품 정보를 수정하는 과정이다.

서비스 호출 -> 대표 썸네일 저장 처리 -> 상품 데이터 수정 요청 -> 상품 옵션 수정 요청

-> 삭제해야할 대표 썸네일 삭제처리 -> 삭제해야할 썸네일 삭제 처리 -> 삭제해야할 썸네일 데이터 삭제 요청

-> 삭제해야할 상품 정보 이미지 삭제 처리 -> 삭제해야할 상품 정보 이미지 데이터 삭제 요청

-> 새로운 썸네일 저장 처리 -> 새로운 썸네일 데이터 리스트 삽입 요청

-> 새로운 상품 정보 이미지 저장 처리 -> 새로운 상품 정보 이미지 데이터 리스트 삽입 요청

 

 

상품 관련 처리이다 보니 겹치는 기능이 많았다. 그래서 메소드를 분리했는데

파일 저장 처리 메소드, 파일 삭제 처리 메소드가 분리되어 있으며 각 처리 메소드에서는 데이터베이스에 요청을 보내는것 까지가 초기 설계였다.

 

좀 편하게 정리하기 위해 각 메소드를 아래처럼 쓴다.

  • 컨트롤러부터 호출되는 메소드 - 최상위 메소드
  • 파일 저장 및 데이터 삽입 요청 메소드 - 저장 메소드
  • 파일 삭제 및 데이터 삭제 요청 메소드 - 삭제 메소드

 

최상위 메소드에 @Transactional 어노테이션을 붙여주고 rollbackFor 옵션을 설정한 뒤

테스트 했을 때는 위에서 얘기했다 시피 파일 저장이나 삭제 처리에서 오류가 발생한 경우 하위 메소드에서 발생한 요청에 대해 롤백 처리가 되지 않았다.

 

그래서 각 하위 메소드들에 try-catch로 예외처리를 하고 강제로 Exception을 발생시켜 봤지만 그것도 안됐고, throws로 떠넘겨도 처리할 수 없었다.

 

이유는 throws로 떠넘겨 봤자 최상위 메소드에서 역시 try-catch로 감싸줘야 했고, catch에서 강제로 Exception을 발생시켜도 롤백은 처리되지 않았기 때문이다.

 

이래저래 고민해보고 테스트해보고 해봤지만 마땅한 해결책을 찾을 수 없어 메소드들의 처리 구조를 바꿔주게 되었다.

일단 하위 메소드 두개에서 데이터베이스에 요청을 보내지 않도록 수정했다.

 

하위 메소드들은 파일 저장, 삭제 처리 후 해당 데이터들을 상위 메소드로 리턴하도록 했다.

그럼 최상위 메소드에서는 그 데이터를 받아 데이터베이스에 요청을 보내도록 수정했다.

 

이렇게 처리하니 하위메소드에서 오류가 발생하더라도 최상위 메소드에서 모든 데이터베이스 요청을 처리하기 때문에 정상적으로 롤백을 수행할 수 있게 되었다.

 

 

일차원적으로 문제를 해결했다는 이유가 이것 때문이었다.

파일 저장 메소드는 상품 수정 뿐만 아니라 상품 등록 처리에서도 사용되어야 한다.

그리고 파일 삭제 메소드는 상품 삭제 처리에서 사용되어야 한다.

 

그나마 상품 삭제에서 파일 삭제 메소드는 cascade 설정으로 인해 파일 삭제만 처리하고 결과를 반환해도 되지만

저장 메소드는 데이터 삽입 요청이기 때문에 이걸 분리하는게 맞나 싶었다.

 

'중복되는 코드를 최소화 하고 재사용성을 높인다'에 이게 부합하는건지,

어차피 하위 메소드에서 데이터를 처리한 뒤 그 데이터를 반환하면 상위 메소드에서는 리턴받은 데이터로 요청만 하면 되니까 최소화를 한 것이다 라고 봐야할지 고민이 되었다.

 

 

 

예외처리가 너무 어렵다................................

프로젝트를 진행하면서 더미데이터를 몇백만건씩 넣어본적도 없고 넣기도 빡세고 해서 count 쿼리에 대한 고민은 사실 해본적이 없었다.

 

매번 그냥 count(*) 로 처리했었는데 이번에 한번 대량의 데이터를 넣어서 해보고자 200만건의 데이터를 넣어봤다.

그리고 서버를 돌리고 해당 페이지에 접근했더니 한~참있다가 페이지가 뜬다..

그래서 테스트를 좀 해봤다.

 

테스트 환경

  • MySQL
  • 데이터 200만건.
  • MySQL Workbench에서 테스트

결과는 count(*)로 조회 했을 때 200만건을 처리하는데 16초가 걸렸다.

 

이래서 느렸구나 싶어 방법을 찾아봤고, 마땅한 해결책을 찾지 못해 처음에는 테이블을 새로 만들었다.

테이블에 각 게시판명을 기본키로 잡고 다른 컬럼에는 데이터의 총 개수를 넣어주었다.

그리고 trigger를 통해 insert, delete 발생시에 수정하도록 했다.

 

당연히 속도는 엄청 빨랐다.

하지만 이 문제를 좀 해결했으면 했다.

 

그래서 프로젝트가 다 마무리되고 나서 다시 방법을 좀 찾아봤다.

찾은 방법으로는 count(*)로 처리할것이 아니라 count(distinct(pk)) 형태로 처리하면 더 빠르게 조회할 수 있다는 것이었다.

그래서 테스트를 바로 해봤다.

 

count(distinct(boardNo))

결과는 0.6초가 걸렸다.

그냥 count(*) 쿼리가 16초가 걸린 반면 엄청난 시간차이였다.

 

여기서 만족할 수 없었는데, 이유는 게시판 특성상 검색 기능이 존재해야 하고 그럼 검색한 게시글의 개수가 많다면?

또 같은 문제가 발생할 수 있기 때문에 테스트가 필요했다.

 

제목으로 검색했고, 조회된 데이터 수는 130만건이었다.

count(*) = 1.093

count(distinct(boardNo)) = 1.297

WHERE 조건이 걸리지 않았을 때는 count(distinct(pk))가 훨씬 더 빠른 처리를 보여줬던 반면

조건이 걸려있을때는 count(*)가 더 빨랐다.

 

테스트를 하나 더 해보고 싶었다.

인덱스를 통해 처리하면 좀 더 빠르게 처리할 수 있다는 말은 데이터베이스에 대해 처음 공부할 때부터 계속 듣던 말이다.

그럼 여기에 인덱스까지 설정해주면????

그래서 boardNo, title 두가지를 묶어서 인덱스를 생성하고 다시 테스트를 해봤다.

count(*) = 0.641

count(distinct(boardNo)) = 0.953

결과는 count(*)가 더 빨랐다.

 

 

이렇게 몇가지 테스트를 해본 결과 count 쿼리를 처리하는데 있어서 전체 데이터를 조회해야 한다면 count(*)보다는 count(distinct(pk))가 훨씬 빠른 처리시간을 보여주지만,

WHERE 조건이 붙는 경우에는 count(*)가 더 빠르며, 인덱스까지 생성해준다면 더 빠르게 처리할 수 있다는 결과를 볼 수 있었다.

 

더 많은 양의 데이터를 처리하면 또 다른 결과를 볼 수 있을지도 모르겠지만...

아직 쿼리 효율에 대해 고민하고 공부해야 할 점이 너무 많다는 생각이 들었다.

 

프로젝트 리펙토링 중에 게시판 댓글 처리에 대해 좀 고민을 하게 되었다.

계층형 구조인데 댓글을 삭제했을 때 어떻게 처리할것인가에 대한 고민이었다.

 

처음에는 그냥 해당 댓글을 삭제하도록 구현했었고, 고민하다 보니 그것도 구조가 좀 이상하지 않나 싶어 하위 모든 댓글을 삭제 하는것으로 할까 하다가 게시판은 그렇게 처리했으니 다르게 처리하고 싶었다.

 

그래서 결정한 방법이 많은 사이트에서 볼 수 있는 '삭제된 댓글입니다'를 출력하는 것이었다.

 

이렇게 처리하기 위해 댓글 상태를 표현하는 필드가 필요했고 그거 추가하는거야 어렵지 않으니 추가해줬다.

default '0', 삭제 요청이 들어오면 데이터를 delete처리하는것이 아닌 1로 update 처리를 하도록 했다.

 

그럼 이제 여기서 고민이 있었다.

그냥 select * from comment 이렇게 조회해온다면 삭제된 댓글임에도 내용이 그대로 조회될 것이다.

 

여기서 고민끝에 생각난 방법이 세가지.

 

1. 프론트로 응답하기 전 서버에서 데이터 리스트를 하나하나 검증해 status가 1이라면 내용을 수정한다.

   이 방법은 떠오르자마자 아니다 싶었다.

   리스트를 하나하나 보면서 체크하고 해당되는 데이터를 수정한다? 그건 좀 아니다 싶었다.

 

2. 데이터를 응답받은 프론트에서 파싱하는 과정 중 status가 1인 데이터는 내용을 수정하도록 한다.

    괜찮은 방법이라고 생각했다.

    어차피 파싱 과정에서 데이터를 차례대로 파싱해 나가야 하고, 그 과정에서 status값 하나를 체크하는 것 뿐이니

    처리 시간에 지장을 주지 않고 되지 않겠나 싶었다.

    하지만 문득 이 방법으로 처리하면 내용이 노출될 가능성이 있다는 생각이 들었다.

    그래서 이 방법은 보류.

 

3. 쿼리로 조회할 때 부터 수정된 상태로 받으면 이 문제가 다 해결되지 않을까?

    그래서 쿼리문에서 조건문을 사용할 수 있는지를 찾아봤다.

    그리고 동적쿼리로 if문을 사용할 수 있다는 것을 알아냈다.

 

 

문제해결

if 조건문을 통해 처리하는 방법(MySQL)

SELECT commentNo
    , IF(commentStatus > 0, '삭제된 댓글입니다', commentContent) AS commentContent
    , ...
FROM comment

IF( 조건, true, false) AS 컬럼 표시명

이런 구조로 작성해주면 된다.

그럼 commentStatus는 0, 1두가지로만 구성되어있기 때문에 > 0 을 만족한다는 것은 삭제된 댓글이라는 의미이므로 

'삭제된 댓글입니다'를 content로 가져오게 되고 false의 경우 0이므로 삭제된 댓글이 아니기 때문에 원래 내용을 가져오게 된다.

 

하지만 여기서 문제가 있었다.

JPQL에서는 nativeQuery로 처리하지 않으면 Syntax 오류가 발생한다.

물론 nativeQuery로 처리하면 된다.

하지만 그렇게 되면 클래스 타입의 DTO로 받을 수 없었고, 프로젝트에서는 이 데이터를 또 파싱해야 했어서 무조건 List 타입의 엔티티로 받아야 했기 때문에 다른 방법을 찾아야 했다.

 

 

CASE WHEN

SELECT commentNo
    , CASE
        WHEN (commentStatus > 0) THEN '삭제된 댓글입니다'
        ELSE commentContent
      END AS commentContent
    , ...
FROM comment

다르게 조건문을 사용할 수 있는 CASE WHEN이다.

IF의 경우 단일 조건에 대해서만 처리가 가능하지만 CASE WHEN은 자바에서 switch case 처럼 다양한 조건에 대해 처리가 가능하다.

예를들어 

SELECT commentNo
    , CASE
        WHEN (commentStatus == 0) THEN null
        WHEN (commentStatus == 1) THEN '삭제된 댓글입니다'
        ELSE commentContent
      END AS commentContent
    , ...
FROM comment

이런식으로도 사용이 가능하다.

JPQL에 이걸 그대로 적용하게 되면 END뒤의 AS와 매핑할 DTO의 괄호 부분에 빨간 밑줄이 생기긴 한데 처리하는데 문제가 발생하지는 않았다.

아직까지는 JPQL에서 이 동적쿼리를 사용하는데 있어서 문제가 발생하지는 않았지만 빨간 밑줄이 생기는건 좀 찝찝하긴 해서 원인을 좀 찾아보긴 해야할듯 하다.

 

그리고 이 CASE WHEN의 경우는 Oracle에서도 사용이 가능하다!

'DB' 카테고리의 다른 글

Redis 정리  (1) 2024.12.07
다량의 데이터 count 처리  (0) 2023.09.28
Oracle Scheduler, MySQL Event Scheduler  (0) 2022.04.21
mysql 계층형 쿼리(함수, 재귀)  (0) 2022.04.04
EC2에 설치한 MySQL 데스크탑 workbench에서 접근  (0) 2021.11.09

+ Recent posts