Supported
## 💁 개요
웹 : https://www.someone-might-like-you.com/
안드로이드 : https://play.google.com/store/apps/details?id=com.cupid.joalarm
주변 100m 이내의 사용자들은 서로의 위치를 공유합니다
하트를 송수신 할 수 있으며, 하트가 매칭된 사용자들은 1:1 채팅이 자동으로 생성합니다
이모지 변경, 유저 신고 등 부가적인 기능을 제공합니다
HTML5 | CSS3 | javascript |
React | Stomp | TypeScript |
Java | Spring-Boot | Spring-JPA | Spring-Security | JWT |
MySQL | Mongodb |
NGiNX | aws | docker | Jenkins | Kubernetes |
Git | JIRA | Figma | Notion | Mattermost | Discord |
Kotlin | Android Studio | Swift | Xcode |
Content | Main | Detail |
---|---|---|
위치 공유 | Spring / TypeScript | Socket / SockJS / Stomp / Axios |
하트 송수신 및 채팅 | Spring / TypeScript | Socket / SockJS / Stomp / Axios |
kubernetes | Kubernetes 1.14.0 | Kubernetes 1.14.0 |
파이프라인 구축 | Jenkins Pipeline | Jenkins Pipeline |
CI/CD | Docker, Jenkins, Dockerhub, Kubernetes | Docker, Jenkins, Dockerhub, Kubernetes Rollout |
Ingress Nginx | Kubernetes ingress-nginx | Nginx, Let's encrypt, Kubernetes ingress-nginx |
배포 | AWS | EC2(Ubuntu Server 20.04 LTS) |
회원관리 | JWT / Spring Security | JWT / HS512 / Spring Security |
Android | Kotlin | Android Studio 4.1.1 / Web View |
iOS | Swift | Xcode 13.3.1 / Web View / WebKit |
BE: Springboot Websocket
FE: [email protected] / @stomp/[email protected] / @types/[email protected] / @types/[email protected]
제일 쉬운 방법은, 내 위치를 중심으로 모든 유저들과의 거리를 계산 후 100m 이내인 유저를 걸러내면 된다.
허나 이는 유저의 수가 많아지면 많아질수록 효율이 급감하며, 계속해서 유저들의 실시간 위치를 받아내야만 한다.
좋은 방법이 아닐 것 같아 많은 고민을 하던 중, 우아한 Tech 분산 이벤트 스트리밍 영상과 카카오의 W3W에서 영감을 얻어 전 세계를 정해진 크기의 구획으로 나눈 후 내 구획을 기준으로 주변의 구획 데이터를 얻어오면 될 것 같았다.
해당하는 방식이라면 주변 구획에 접근하여 유저 목록을 생성할 수 있으니, 굉장히 좋은 방법이라 생각했다.
아쉽게도 카카오의 W3W나 일반 W3W가 근처 구획만을 생성, 획득하는 방법으로는 방향성이 맞지 않다고 느껴졌기 때문에, 직접 gps 데이터를 계산하여 구획을 생성하는 방식을 채택하였다.
보통 gps 데이터를 수신할 경우, 37.xxxxxxx, 127.xxxxxxx 의 값을 가져오게 된다.
해당하는 값을 하나의 구획으로 바꾸어야 한다. 구획은 몇 m로 세팅해야 하며, 손 쉽게 나눌 수 있는 방법은 무엇이 있을까?
우선 1m가 gps상에서 몇 차이가 나는지 확인해 보았다. 해당 글을 참고하자면, 0.00001도 차이가 대략 1m라 한다. 정확하게는 물론 아니겠지만, 해당 방법으로 전 세계 구획을 나눈 후 작업해도 괜찮을 것 같다는 생각을 했다. 그러나 더욱 정확한 계측을 위해, 두 지역 사이의 거리를 측정해 주는 사이트를 찾아갔다.
해당 사이트의 계산에 의하면, 3m
는 도분초 좌표계 기준으로 0.0973초
만큼 차이가 있었다.
0.1초
차이는 3.083m
만큼 차이가 났으며, 1초
차이는 30.83m
차이였다.
그렇다면, gps 데이터를 수신한 후 도분초 좌표계로 변환하고, 소수점을 제외한 데이터를 구획으로 삼으면 되지 않을까 하는 생각이 들어 바로 착수했다. 정확히 100m는 어렵겠지만, 대략적인 100m 데이터를 얻을 수 있으므로..
지구의 모든 구역을 30m * 30m 2차원 배열로 만들기에는 메모리 낭비가 너무 크다. index도 계산해줘야 하는 이슈가 있으므로 HashMap을 만든 후 gps 구획 데이터를 key로 들고 있기로 했다.
비로그인 사용자도 이용할 수 있어야 하므로 소켓 세션 id를 해당 구획 value의 key로 잡고, pk와 이모지URL을 value로 지니고 있게끔 했다.
{
"36/21/101/127/20/583": {
"5ubuuxi3": {
"pk": 1,
"emojiURL": "emoji"
}
}
}
또한 유저가 이동해 구획이 변경되거나 로그인 등으로 이모지가 변경된다면 basic 채널에 메세지를 전송하여 모든 유저가 알 수 있게끔 메세지를 보내도록 했다. 이는 스케쥴러를 사용하였다.
@Scheduled(fixedRate = 5000)
public void sendBasicChat() {
if (gpsRepository.getOperationCommand()) {
messageTemplate.convertAndSend("/sub/basic", gpsRepository.getAllGpsSectorData());
gpsRepository.setOffOperationCommand();
}
}
소켓통신이 끊긴다면 해당하는 구획에서 데이터를 삭제해야 하므로, 소켓 연결 및 구획 변경 시마다 각각의 세션에 현재 구획을 들고 있다가, 끊길 경우 구획으로 접근해 해당하는 세션id값을 제거하도록 했다.
@EventListener
public void handleSessionConnect(SessionConnectEvent event) {
SimpAttributesContextHolder.currentAttributes().setAttribute("GPS", "");
}
@EventListener
public void handleSessionDisconnect(SessionDisconnectEvent event) {
String gpsKey = (String) SimpAttributesContextHolder.currentAttributes().getAttribute("GPS");
String sessionId = event.getSessionId();
gpsRepository.dropUser(gpsKey, sessionId);
}
대략 해당 그림과 같은 구조가 만들어진다.
100m 이내 유저들을 얻어 왔으므로, 하트 버튼을 누른다면 각각의 유저에게 메세지를 보내는 방법으로 구성하였다. 비로그인 사용자들도 하트를 얻을 수 있도록, sessionId를 얻어와 해당 채널을 subscribe한 후, 하트 메세지를 받으면 이벤트를 호출하도록 했다.
const sessionId = (
(client.webSocket as any)._transport.url as string
).split('/')[6];
...
client.subscribe(`/sub/heart/${sessionId}`, (message) => {
const whisper: whisper = JSON.parse(message.body);
changeSignal();
receiveMessageDispatch(whisper);
});
changeSignal
에서는 단순 boolean 값을 true, false로 바꿔주어 하트를 받았는지 아닌지 확인하게끔 했으며, receiveMessageDispatch
를 통해 하트를 전송한 유저와의 관련성을 체크한다. 만약 하트가 교환되었으면서 채팅방이 미생성된 유저라면, 채팅방 생성 api에 접근하여 채팅방을 신설한 후 양쪽 유저에게 채팅방 생성 알림을 보낸다.
FE에서 채팅방 생성 알림이 수신되면, 생성된 채팅방을 목록에 추가한 후 방의 pk로 subscribe를 수행한다. Stomp 특성 상 subscribe와 함께 어떠한 동작을 할 지 Callback을 지정해줘야 하기 때문에 한 번에 작성하였다. 그러나 이 둘을 따로 분리시켜 구독 / 구독이벤트
형태로 구축했으면 좀 더 가독성이 좋은 코드가 되지 않았을까 싶다.
if (!chatUserSet.has(action.person)) {
chatUserSet.add(action.person);
setAlertText("채팅방이 생성되었습니다!");
openAlert();
const newChatRoom: chatBox = {
chatroomSeq: action.chatRoom,
userList: [seq, action.person],
activate: true,
};
setChatRoomList((pre) => [newChatRoom, ...pre]);
chatsDispatch({
type: "INSERT",
idx: action.chatRoom,
messages: new Array<messageType>(),
messageType: {} as messageType,
});
client.subscribe(`/sub/chat/room/${action.chatRoom}`, (message) => {
setMessageCount((pre) => {
pre[action.chatRoom] += 1;
return pre;
});
chatsDispatch({
type: "CHAT_MESSAGE",
idx: action.chatRoom,
messages: [],
messageType: JSON.parse(message.body) as messageType,
});
});
}
방 내부에서는 채팅방의 데이터를 담아 publish를 수행한다. BE에서는 한 곳에 채팅 메세지를 받고, 데이터에 따라 어떠한 채팅방에 어떻게 전달할 것인지를 선택하는 구조로 하였다.
client.publish({
destination: "/pub/chat/message",
body: JSON.stringify({
type: "TALK",
roomId: `${idx}`,
sender: `${seq}`,
message: `${message}`,
}),
});
@MessageMapping("chat/message")
public void message(ChatMessageDTO message) {
switch (message.getType()) {
case TALK:
chatService.CreateChat(message);
break;
...
}
}
@Transactional
public void CreateChat(ChatMessageDTO DTO) {
String pattern = "yyyy-MM-dd a KK:mm ss:SSS";
DateFormat df = new SimpleDateFormat(pattern);
ChatEntity chatEntity = ChatEntity.builder()
.type(DTO.getType())
.roomId(DTO.getRoomId())
.sender(DTO.getSender())
.message(DTO.getMessage())
.sendTime(df.format(new Date()))
.build();
chatRepository.save(chatEntity);
messageTemplate.convertAndSend("/sub/chat/room/" + DTO.getRoomId(), chatEntity);
}
해당하는 방법으로 100m 이내 유저를 얻어왔으며, 하트를 교환하고 채팅을 나눌 수 있도록 하였다.
- WebKit
- Open Safari
- SFSafariViewController
이들 중 iOS 13 버전 이상 부터는 WebKit 사용을 권장하고 있기 때문에, WebKit을 사용하기로 결정했습니다.
WebKit은 인터넷 창을 띄워 줄 수 있게 WebView 기능을 제공해주는 라이브러리 입니다.
- WKWebView 프레임워크를 프로젝트에 추가해줍니다.
- 사용하려는 ViewController에 WebKit을 import해줍니다.
- 필요에 따라 plist 수정 하면서 기능을 조정합니다.
View가 Load되면 사전에 제작해놓은 웹 페이지를 띄워주도록 구현했습니다
import UIKit
import WebKit
import CoreLocation
class ViewController: UIViewController, CLLocationManagerDelegate{
// MARK: - Property
// extension으로 CLLocationManagerDelegate 구현하기
weak var webKitView: WKWebView?
var locationManager:CLLocationManager! // 변수 선언할때 !를 붙히넴
var lat: Double?
var long: Double?
override func viewDidLoad() {
super.viewDidLoad()
locationManager = CLLocationManager()
loadUrl()
locationManager.delegate = self
// 아래 함수 요청시 위치권한 팝업 출력
self.locationManager.requestWhenInUseAuthorization()
// 스와이프를 통해서 뒤로가기 앞으로가기를 할수 있게 해주는 설정값
self.webKitView?.allowsBackForwardNavigationGestures = true
// 정확한 위치 요청
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
// kCLLocationAccuracyBest -> 기본값 , 가장 높은 정확도
// kCLLocationAccuracyHundredMeters -> 100m 내에서 정확하게 파악하기.
locationManager.startUpdatingLocation()
// 앱이 백그라운드 상태에서 위치가 변경되어도 추적
// 배터리 이슈가 존재 할 수도
locationManager.allowsBackgroundLocationUpdates = true
// 앱을 종료하면 백그라운드에서 더 이상 위치 안씀 -> 설명을 따로 해줘여 할까
let space = locationManager.location?.coordinate
lat = space?.latitude
long = space?.longitude }
// weak : 약한 참조
// 해당 인스턴스의 소유권을 가지지 않고, 주소값만을 가지고 있는 포인터 개념
// 자신이 참조는 하지만 weak 메모리를 해제할 수 있는 권한은 다른 클래스에 있음.
// MARK: - View Life Cycle
override func loadView() {
// rootView
let view = UIView()
self.view = view
// WebKitView
let webConfiguration = WKWebViewConfiguration()
let webKitView: WKWebView = WKWebView(frame: .zero, configuration: webConfiguration)
self.webKitView = webKitView
webKitView.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(webKitView)
// WebKitView 제약사항
NSLayoutConstraint.activate([
webKitView.widthAnchor.constraint(equalTo: self.view.widthAnchor),
webKitView.heightAnchor.constraint(equalTo: self.view.heightAnchor),
webKitView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
webKitView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor)
])
}
// MARK: - Func
func loadUrl() {
if let url = URL(string: "https://www.someone-might-like-you.com") {
let urlRequest = URLRequest(url: url)
webKitView?.load(urlRequest)
} else {
// 에러처리문.. 예를들어서 alert를 띄워주거나..
print("접속에 실패했습니다.")
}
}
}
info.plist 파일에서 오른쪽을 클릭해서 Add Row를 클릭해줍니다.
Allow Arbitrary Loads - True(1이 True 2가 False)를 추가해줍니다.
Allow Arbitrary Loads 는 모든 URL에 한에서 http, https 상관없이 Bool값으로 처리 하겠다
라는 뜻입니다.
값이 True라면 http라도 통신을 하겠다는 의미입니다.
이렇게 해두면 만들어놓은 웹 페이지가 정상적으로 뜨는걸 확인 할 수 있습니다
추후 예외 URL(웹사이트) 만 허용시키는 방식을 추가로 적용해서 보안을 좀 더 강화하려고 합니다.
저희 서비스는 위치 정보가 반드시 필요한 서비스기 때문에, 디바이스에 요청을 받아오는 과정이 필요 했습니다.
ViewController에 extension을 통해 기능을 구현했습니다.
디바이스에 위치 정보 요청을 하고 만약 요청을 거절 했다면, 앱의 설정창으로 보내서 권한을 다시 설정 할 수 있게 해주었습니다.
extension ViewController {
func getLocationUsagePermission() {
self.locationManager.requestWhenInUseAuthorization()
}
// 위치 정보 권한 없을 시, 앱 설정 창으로 보내주는 코드
func setLocationAuth() {
let authAlertController: UIAlertController
authAlertController = UIAlertController(title: "위치 정보 권한 요청", message: "저희 서비스를 이용하시기 위해서는 위치 정보가 필요합니다. 위치 접근 허용을 해주세요.", preferredStyle: .alert)
// 앱 설명에 필수권한으로 적어두기.
let getAuthAction: UIAlertAction
getAuthAction = UIAlertAction(title: "네 알겠습니다.", style: UIAlertAction.Style.default, handler: { _ in
// 보내주면 해당 앱의 설정 창으로 가게 된다. 가능하다면 설명을 자세히 적어 주기.
if let appSettings = URL(string: UIApplication.openSettingsURLString){
UIApplication.shared.open(appSettings, options: [:], completionHandler: nil)
}
})
authAlertController.addAction(getAuthAction)
self.present(authAlertController, animated: true, completion: nil)
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
switch status {
case.authorizedAlways, .authorizedWhenInUse:
print("GPS 권한 설정 완료")
// case.restricted, .notDetermined:
// print("GPS 권한 설정 되지 않음")
case.denied:
// print("GPS 권한 거부 됨") 굳이 케이스 다 필요없이 디나이랑 디폴트만 있으면 될듯. 스위치는 해야하니까.
setLocationAuth()
default:
print("GPS Default")
}
}
}
react-ga 라이브러리를 사용해 React에 Google Analytics(이하 GA)를 적용하는 방법
- Google Analytics 사이트에 접속해 계정을 생성합니다.
- 속성 설정 > 고급 옵션 보기 > 유니버설 애널리틱스 속성만 만들기 를 선택합니다.
- react-ga 라이브러리가 UA(유니버설 애널리틱스)만 지원하기 때문입니다.
- 계정 생성이 완료되면 "UA-XXXX" 포맷의 추적 ID를 확인합니다.
주의사항: 22/05/02 기준 react-ga는 react v17까지만 지원합니다.
아래 명령어로 react-ga
라이브러리를 설치할 수 있습니다.
# npm
$ npm install react-ga
ReactGA.initialize
시 앞에서 확인한 추적 ID를 적용합니다.
// hooks/useGA
import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
import ReactGA from "react-ga";
const useGA = () => {
const location = useLocation();
const [initialized, setInitialized] = useState(false);
// 개발환경이 아닐 경우에만 GA initialize
useEffect(() => {
if (!window.location.href.includes("localhost")) {
ReactGA.initialize(`${process.env.REACT_APP_GA_TRACKING_ID}`);
}
setInitialized(true);
}, []);
// GA initialize가 되어있다면 pageview 전송
useEffect(() => {
if (initialized) {
ReactGA.pageview(location.pathname + location.search);
}
}, [initialized, location]);
/*
* GA 디버깅용 코드
* 개발환경에서도 GA initialize.
* debug 옵션이 설정되어 console에 트래킹 정보를 출력.
*/
// useEffect(() => {
// ReactGA.initialize(`${process.env.REACT_APP_GA_TRACKING_ID}`, {
// debug: true,
// });
// ReactGA.pageview(location.pathname + location.search);
// }, [location]);
};
export default useGA;
react-router-dom
의 useLocation
hook은 Router 내부에서만 동작하는 hook입니다.
때문에 useGA
훅을 바로 App.js
에 적용하면 에러가 발생합니다.
이를 방지하기 위해 useGA
훅을 사용하는 커스텀 Router
컴포넌트를 만들어 사용합니다.
// components/GARoutes
import { ReactNode } from "react";
import { Routes } from "react-router-dom";
import useGA from "../hooks/useGA";
const GARoutes = ({ children }: { children: ReactNode }) => {
useGA();
return <Routes>{children}</Routes>;
};
export default GARoutes;
이렇게 만든 GARouter
컴포넌트를 App.tsx
에 적용합니다.
// App.tsx
import GARoutes from "./components/GARoutes";
function App() {
return (
<BrowserRouter>
<GARoutes>
<Route path="/" element={<IndexPage />} />
<Route path="/info" element={<InfoPage />} />
</GARoutes>
</BrowserRouter>
);
}
export default App;
ReactGA.event(args)
API를 사용해 이벤트를 추적할 수 있습니다. category
, action
는 필수로 입력해야하는 인자입니다.
이벤트 핸들러 내부에 작성하면 해당 이벤트를 추적합니다.
// Home.tsx
...
const heartClickHandler = () => {
ReactGA.event({
category: '하트 버튼 클릭',
action: '하트 버튼 클릭',
});
updatePushHeart();
sendHeart();
};
...
높은 트래픽과 소켓 통신으로 인한 부하를 예상했다. 해당 트래픽과 부하를 Devops가 나 혼자인 우리 팀이 감당하기 위해 쿠버네티스를 도입했다.
쿠버네티스 설계부터 CI/CD 적용까지, 처음이라 어려웠고, 자동화까지 완성했을 때는 그만큼 보람을 느꼈다.
- 무중단 배포
- 부하분산
- 오토힐링
- 컨테이너의 관리 용이
-
EC2서버 4개가 가용자원으로, 마스터 노드 1개와 워커 노드 3개로 구성한다.
-
Mysql, Mongodb로 DB 이원화를 적용한다. (채팅의 Read속도 고려)
-
해당 DB는 PVC-PV 마운트하여 영구적으로 보관한다.
-
Desired State : 백엔드는 팟 10개 / 프론트엔드는 팟 7개로 설정하였다.
-
Ingress-nginx를 적용하였고, Let's Encrypt를 통해 HTTPS를 적용하였다.
-
Jenkins Pipeline을 구축하여, 일련의 과정을 자동화한다.
- Git의 변화를 감지하여 Code를 받아오고,
- 해당 코드로 Docker Image를 생성한다.
- 생성된 Docker Image를 Dockerhub에 Push한다.
- Kube와 연동하여 해당 Deployment를 Rollout한다.
-
Mattermost와 연동하여 빌드 현황과 로그를 공유한다.
- 간편하고 쉽게 적용 가능
- 중앙의 인증 서버 , 데이터 스토어에 대한 의존성 없음
- 시스템 수평 확장 유리
- Spring Security 는 Spring 기반의 애플리케이션의 보안( 인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.
- 모든 URL을 가로채어 인증을 요구하고, 해당 URL에 접근할 수 있는 권한 설정이 가능하다. 아직까진 USER 에 대한 API 가 대부분이지만, 관리자 페이지 등을 만들시에 확장 가능성이 높다.
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
jwt:
header:[header key]
secret:[secret key]
token-validity-in-seconds:[seconds]
@Component
public class TokenProvider implements InitializingBean {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInMilliseconds*1000;
}
@Override
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
}
implements InitializingBean
afterPropertiesSet()
을 오버라이드 한 이유는
BEAN이 생성이 되고 주입을 받은 후에 secret값을 Base64 Decode해서 Key 변수에 할당
createToken(Authentication authentication)
- Authentication 객체의 권한정보를 이용해서 토큰을 생성하는 메소드
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY,authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
authentication 파라미터를 받아서 권한들,
application.yml에서 설정했던 만료 시간 ( expire time ) 도 설정 후,
JWT 토큰을 생성해서 리턴합니다!
getAuthentication(String token)
- token에 담겨있는 정보를 이용해 Authentication 객체를 리턴하는 메소드 생성
- 토큰으로 클레임을 만들고 이를 이용해 user 객체(
org.springframework.security.core.userdetails.User
)를 만들어서 최종적으로 Authentication 객체를 리턴
public Authentication getAuthentication(String token){
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(),"",authorities);
return new UsernamePasswordAuthenticationToken(principal,token,authorities);
}
validateToken(String token)
- 토큰을 parameter로 받아서 토큰의 유효성 검증을 수행하는 validateToken 메소드
- 토큰을 파싱해보고 발생하는 예외들을 캐치, 문제가 있으면 false, 정상이면 true
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e){
logger.info("잘못된 JWT 서명입니다.");
}catch (ExpiredJwtException e){
logger.info("만료된 JWT 토큰입니다.");
}catch (UnsupportedJwtException e){
logger.info("지원되지 않은 JWT 토큰입니다.");
}catch (IllegalArgumentException e){
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
-
JWT를 위한 커스텀 필터를 만들기 위해 JwtFilter 클래스 생성
-
JwtFilter는 TokenProvider를 주입 받음
-
GenericFilterBean 을 extends 해서 doFilter Override ⇒ 실제 필터링 로직은 doFilter 내부에 작성
-
resolveToken()
필터링을 하기 위해 토큰 정보가 필요함 ⇒ 토큰 정보를 꺼내오기 위한resolveToken()
메소드 추가 -
doFilter()
토큰의 인증 정보를 securityContext에 저장하는 역할 수행- resolveToken() 을 통해 토큰을 받아와서 유효성 검증을 하고 정상 토큰이면 SecurityContext에 저장
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpServletRequest =(HttpServletRequest) request; String jwt = resolveToken(httpServletRequest); String requestURI = httpServletRequest.getRequestURI(); if(StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)){ Authentication authentication = tokenProvider.getAuthentication(jwt); SecurityContextHolder.getContext().setAuthentication(authentication); logger.debug("Security Contexted에 '{}' 인증정보를 저장했ㅅ브니다.. uri {}",authentication.getAuthorities(),requestURI); }else{ logger.debug("유효한 JWT 토큰이 없습니다. uri {}",requestURI); } chain.doFilter(request,response); }
TokenProvider, JwtFilter 를 SecurityConfig에 적용할 때 사용할 JwtSecurityConfig 클래스 추가
- SecurityConfigurerAdapter 를 extends
- TokenProvider를 주입받아서 JwtFilter를 통해 Security로직에 필터를 등록합니다.
유효한 자격 증명을 제공하지 않고 접근하려 할 때 401 Unauthorized
에러를 반환하는 클래스
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized
}
}
필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴하기
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_FORBIDDEN); // 403 Forbidden
}
}
-
@EnableGlobalMethodSecurity(prePostEnabled = true) @PreAuthorize
어노테이션 메소드 단위로 추가하기 위해서 적용 -
tokenProvider , jwtAuthenticationEntryPoint , jwtAccessDeniedHandler 주입
private final TokenProvider tokenProvider; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; public SecurityConfig(TokenProvider tokenProvider,JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler){ this.tokenProvider=tokenProvider; this.jwtAuthenticationEntryPoint =jwtAuthenticationEntryPoint; this.jwtAccessDeniedHandler=jwtAccessDeniedHandler; }
-
password encoder
@Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); }
-
오버라이드한
configure()
수정@Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() // token 방식을 사용하기 때문에 disable .exceptionHandling() // Exception 핸들링 클래스 추가 .authenticationEntryPoint(jwtAuthenticationEntryPoint) .accessDeniedHandler(jwtAccessDeniedHandler) .and() // h2 console을 위한 설정 .headers() .frameOptions() .sameOrigin() .and() // 세션을 사용하지 않기 떄문에 stateless 설정정 .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/hello").permitAll() .antMatchers("/accounts").permitAll() // singup .antMatchers("/accounts/login").permitAll() // login 토큰이 없는 상태에서 요청 .anyRequest().authenticated() .and() .apply(new JwtSecurityConfig(tokenProvider)); // addFilterBefore로 등록했던 JwtSecurityConfig 적용 }
findOneWithAuthoritiesByUsername
username으로 권한정보도 같이 가지고 오는 메소드
@EntityGraph(attributePaths = "authorities")
쿼리가 수행될때 Lazy 조회가 아니고, Eager 조회로 authorities정보를 같이 가져옴
Spring Security에서 중요한 부분 중 하나인 UserDetailsService를 구현한 CustomUserDetailsService 클래스
- 로그인시에 DB에서 유저정보와 권한정보를 가지고 옴
- 해당 정보를 기반으로 userdetails.user 객체를 생성해서 리턴
import com.cupid.joalarm.accout.entity.User; import com.cupid.joalarm.accout.repository.UserRepository; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import java.util.List; import java.util.stream.Collectors; @Component("userDetailsService") public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; public CustomUserDetailsService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return userRepository.findOneWithAuthoritiesByUsername(username) .map(user -> createUser(username, user)) .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다.")); } private org.springframework.security.core.userdetails.User createUser(String username, User user) { if (!user.isActivated()) { throw new RuntimeException(username + " -> 활성화되어 있지 않습니다."); } List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream() .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName())) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), grantedAuthorities); } }
TokenProvider
,AuthenticationManagerBuilder
주입authenticate
가 실행이 될떄,CustomUserDetailsService
.loadUserByUsername
메소드가 실행 되서 얻은Authentication
객체 생성- 이 객체를
SecurityContextHolder
에 저장 createToken
을 통해서 JWT Token 생성- JWT token을 Response Header 에도 추가
- TokenDto에 body로 반환
리액트에서 대중적으로 쓰이는 jQuery slick 라이브러리로 캐러셀을 만들때 주로 사용한다.
$ npm install react-slick
slider를 import하고 settings를 설정한뒤, Slider 내부에 settings를 넣으면 실행된다.
// 공식 문서의 일부
import Slider from "react-slick"; // slider 불러오기
// styled-component를 사용할때 필요한 import문
import 'slick-carousel/slick/slick.css';
import 'slick-carousel/slick/slick-theme.css';
const settings = {
dots: true, // 컨텐츠 이동 버튼
infinite: true, // 슬라이드의 마지막 부분과 처음부분을 이어 무한 재생
speed: 500, // 넘기는 속도
slidesToShow: 1, // 한 화면에 보이는 갯수
slidesToScroll: 1 // 한번에 넘어가는 컨텐츠 수
arrows: false, // 양옆 이동 화살표
beforeChange: (current: any, next: any) => setState(next), // 현재 센터 번호를 지정
};
<Slider {...settings}>
<div>
<h3>1</h3>
</div>
</Slider>
공식 문서에 나온 캐러셀은 내부가 정해져 있기에 자신이 원하는 곳마다 꾸미기가 가능하였다.
그러나 우리가 사용하는 캐러셀 내부는 이모지를 모두 불러와 나눠주기에 하나하나 수정이 불가능하였으며
공식문서에도 따로 알려주는 것이 없었다. 그렇기에 코드 해부와 중앙 및 양옆의 값을 구분해서 css를 다르게
설정하였다.
실제 캐러셀을 개발자 모드로 분석한 결과 중앙과 앙옆의 클래스가 구분되어 지정되어 있었다. 그렇기에
slick-center, slick-current로 중앙을 구분하였다.
.slick-current {
transform: scale(1.6) // 크기를 1.6배로 확대
}
css를 위와 같이 설정함으로써 가운데 이모지의 크기를 확대하여 3d형태로 표현하였다.
이모지 좌우 움직임은 css animation을 활용
animation-name: move; // 애니메이션 종류 (@keyframes이름)
animation-duration: 10s; // 구동 시간
animation-fill-mode: both; // 애니메이션을 전과 후에 스타일을 적용
animation-timing-function: linear; // 애니메이션 진행 방식
animation-iteration-count: infinite; // 애니메이션을 얼마나 재생할지
animation-direction: alternate; // 방향 설정
@keyframes move {
from {
transform: translateX(-250px); // -250px 부터 시작
}
to {
transform: translateX(250px); // 250px 까지 이동
}
}
핸드폰마다 너비, 높이가 다르기에 location page에서 이모지가 작거나 넘처흐르는 현상을 수정해야 했다.
따라서, @media를 활용하여 이모지 크기를 변경하였다.
@media (max-height: 450px) {
width: 20px;
height: 20px;
}
@media (min-height: 1100px) {
width: 100px;
height: 100px;
}
@media (min-height: 800px) {
width: 80px;
height: 80px;
}
리액트에서 가장 많이 사용되고 있는 css in js방식으로 앱에 맞는 CSS 라이브러리이다.
대표적인 장점으로 코드가독성, 재사용성, props 전달 가능이 있다.
$ npm install styled-components
styled를 import하고 사용하고 싶은 태그를 커스텀마이징해서 사용하면 된다.
- 기본 태그의 css 설정
const 컴포넌트명 = styled.태그명
- 컴포넌트 상속
const 컴포넌트명 = styled.상속명
- 사용할때
${props => css설정}
- 변수명 변경
const 컴포넌트명 = styled(기존 컴포넌트)
- 스타일만을 위한 변수가 기본 React 노드로 전달되거나 DOM 요소로 렌더링되는 것을 방지하려면 변수 이름 앞에
$
기호를 붙이면 된다.
import styled from "styled-components";
const Example = () => {
return (
<>
<Button>Hello</Button>
<NewButton color="blue">new Button</NewButton>
</>
);
}
// 기본 button태그 설정
const Button = styled.button`
width: 200px;
padding: 30px;
`;
// Button 컴포넌트 상속
const NewButton = styled.Button`
color: ${props => props.color || "red"};
`;
export default Example;