그다음, 도커 이미지 받아서 그 안에서 연습해볼수도 있지만, 제대로 하려면 아래처럼 다운받고 설정하는 과정이 필요
# 하둡의 공식 웹사이트에서 최신 버전을 다운받아 설치
sudo wget https://dlcdn.apache.org/hadoop/common/hadoop-3.3.6/hadoop-3.3.6.tar.gz
# 압축풀고 설정진행
tar -xvf hadoop-3.3.0.tar.gz
sudo mv hadoop-3.3.0 /usr/local/hadoop
#~/.bashrc에 아래 것들 추가
export HADOOP_HOME=/usr/local/hadoop
export PATH=$PATH:$HADOOP_HOME/bin
export PATH=$PATH:$HADOOP_HOME/sbin
export HADOOP_MAPRED_HOME=$HADOOP_HOME
export HADOOP_COMMON_HOME=$HADOOP_HOME
export HADOOP_HDFS_HOME=$HADOOP_HOME
export YARN_HOME=$HADOOP_HOME
export HADOOP_COMMON_LIB_NATIVE_DIR=$HADOOP_HOME/lib/native
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
#반영
source ~/.bashrc
그다음 어떤 파일시스템을 하부로 쓸것인지 설정파일들을 좀 만져야함. 기본으로 깔렸을때는 hdfs가 아닌 로컬파일시스템을 쓰도록 되어 있음(;;)
# sudo vi $HADOOP_HOME/etc/hadoop/core-site.xml
# <configuration> 태그 사이에 아래내용 추가
<property>
<name>fs.defaultFS</name>
<value>hdfs://localhost:9000</value><!--이거 해줘야 로컬파일시스템이 아닌 hdfs씀-->
</property>
# sudo vi $HADOOP_HOME/etc/hadoop/hdfs-site.xml
# <configuration> 태그 사이에 아래내용 추가
<property>
<name>dfs.replication</name>
<value>1</value> <!--HDFS에 저장되는 각 데이터 블록이 클러스터 전체에서 복제되는 횟수를 결정-->
</property>
단일노드가 아닌 클러스터 구성시 추가 설정들이 필요(여기서는 생략)
다음은 포맷하고 전체시작
# 처음에 포맷해줘야 함
hdfs namenode -format
#전체 재시작
cd /usr/local/hadoop/sin
./stop-all.sh
./start-all.sh
#java ps. 아래처럼 NameNode, DataNode, SecondaryNameNode가 뜨는것을 확인
jps
128290 SecondaryNameNode
127865 NameNode
128026 DataNode
HDFS기본 핸들링 방법
아래처럼 hdfs dfs로 시작하는 명령을 주거나, Hadoop이 제공하는 Hadoop FUSE(FIlesystem in USErspace) 모듈을 이용하면 기존파일시스템에 mount해서 ls,mv,cp등 그대로 사용하는 것도 가능
# /logs라는 폴더 만들기
hdfs dfs -mkdir /logs
# ls하기. Hadoop Distributed File System (HDFS)의 루트 디렉토리 내용을 나열
hadoop dfs -ls /
Found 1 items
drwxr-xr-x - sevity supergroup 0 2023-07-29 13:20 /logs
# 파일 복사하기.
hdfs dfs -put iis_logs/* /logs
# 헬스체크
hdfs fsck /
# 아래 명령으로 이유 파악
grep -C 5 'ERROR' $HADOOP_HOME/logs/hadoop-sevity-namenode-sevityubuntu.log
# 만약 아래처럼 /tmp 아래 디렉토리 접근이 안된다는 것이면
2023-07-30 16:03:30,469 ERROR org.apache.hadoop.hdfs.server.namenode.NameNode: Failed to start namenode.
org.apache.hadoop.hdfs.server.common.InconsistentFSStateException: Directory /tmp/hadoop-sevity/dfs/name is in an inconsistent state: storage directory does not exist or is not accessible.
at org.apache.hadoop.hdfs.server.namenode.FSImage.recoverStorageDirs(FSImage.java:392)
# hdfs-site.xml을 열어서 경로를 /tmp가 아닌 /var/lib등 임시가 아닌곳으로 옮겨준다.
vi /usr/local/hadoop/etc/hadoop/hdfs-site.xml
아래 내용 추가
<property>
<name>dfs.namenode.name.dir</name>
<value>/var/lib/hadoop-hdfs/cache/hdfs/dfs/name</value>
</property>
# 관련 폴더 만들고 권한 부여
sudo mkdir -p /var/lib/hadoop-hdfs/cache/hdfs/dfs/name
sudo chown -R sevity:sevity /var/lib/hadoop-hdfs/cache/hdfs/dfs/name
# 아래명령어로 다시 실행
stop-dfs.sh
start-dfs.sh
datanode가 시작되지 않을때
# jps 했는데 datanode관련된게 안보이면
grep -C 5 "ERROR" $HADOOP_HOME/logs/hadoop-*-datanode-*.log
# 아래와 같은 로그면
2023-07-30 16:24:23,819 ERROR org.apache.hadoop.hdfs.server.datanode.DataNode: Initialization failed for Block pool <registering> (Datanode Uuid 6b5e2ab5-f8a4-4eb8-b3d3-fe37de728e60) service to localhost/127.0.0.1:9000. Exiting.
# 다음처럼 /hadoop/data 안에 있는 데이터를 날려준다(데이터 포맷은 각오)
rm -rf /hadoop/data/*
부팅할때마다 hdfs 구동되도록 하기(기본적으로는 항상 start-dfs.sh로 시작해줘야함)
sudo vi /etc/systemd/system/hadoop-hdfs.service # 해서 아래내용 입력
[Unit]
Description=Hadoop HDFS
After=network.target
[Service]
User=sevity
Environment=JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ExecStart=/usr/local/hadoop/sbin/start-dfs.sh
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target
# 그 다음 다음을 입력해서 systemctl 리로딩하고, enable을 통해서 부팅시마다 시작되도록 함
sudo systemctl daemon-reload
sudo systemctl enable hadoop-hdfs
# 재부팅해서 jps해서 다음처럼 namenode, datanode, SecondaryNameNode 3가지 띁거 확인
jps
4673 SecondaryNameNode
3929 NameNode
4255 DataNode
# 마지막으로 헬스체크
hdfs fsck /
병렬처리 관련 중요 포인트
mapper가 병렬처리되는것으로 보이나 reducer는 모든 mapper가 끝나야 작업을 시작할 수 있나? 네, 맞습니다. MapReduce 모델에서 Mapper와 Reducer의 작업은 서로 다른 단계에 속하며, 이는 병렬 처리와 데이터 의존성 문제 때문입니다. Mapper: 각 Mapper는 독립적으로 입력 데이터의 한 부분을 처리하며, 그 결과를 키-값 쌍의 형태로 출력합니다. 이 과정은 병렬로 진행될 수 있으며, 여러 컴퓨터 또는 프로세서 코어에서 동시에 일어날 수 있습니다. Shuffle and Sort: Mapper의 출력은 모든 Reducer에게 보내지기 전에 "Shuffle and Sort" 단계를 거칩니다. 이 단계에서는 동일한 키를 가진 모든 데이터를 같은 Reducer로 라우팅하고, 키에 대한 데이터를 정렬합니다. Reducer: Reducer는 키별로 그룹화된 데이터를 받아 처리합니다. 이 과정에서 Reducer는 자신에게 할당된 특정 키에 대한 모든 데이터를 받아야만 작업을 시작할 수 있습니다. 이는 각 키에 대한 모든 값들이 Reducer에서 합쳐지거나 처리되어야 하기 때문입니다. 따라서 Reducer는 모든 Mapper의 작업이 끝나야만 시작됩니다. 이와 같은 방식은 분산 환경에서 대규모 데이터를 처리하는 데 효율적인 방법을 제공합니다. Mapper는 독립적으로 실행되므로 컴퓨터 클러스터의 모든 머신을 활용할 수 있습니다. Reducer는 특정 키에 대한 모든 데이터를 처리하므로, 결과를 재결합하거나 요약하는 작업을 처리할 수 있습니다. 이 모델의 중요한 특성 중 하나는 개발자가 병렬 처리에 대한 세부 사항을 걱정할 필요가 없다는 것입니다. 이는 MapReduce 프레임워크가 처리하기 때문입니다.
ubuntu 기준으로는 sudo apt install docker.io로 설치가능하나, 라즈베리파이 기준으로는 너무 오래된 docker가 설치되거나 설치가 안될수 있어서 다음 방법으로 설치하는걸 추천한다고 함
curl -sSL https://get.docker.com | sh
만약 sudo apt-get install로 하려면 다음과정을 거쳐도 됨
# 또는 아래 방법도 가능
#1. 먼저, 기존에 설치되어 있는 Docker 패키지를 제거합니다(있는 경우):
sudo apt-get remove docker docker-engine docker.io containerd runc
#2. Docker설치를 위한 패키지 업데이트
sudo apt-get update
#3. Dockertㅓㄹ치에 필요한 패키지들 미리 설치
sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release
#4. Docker의 공식 GPG추가(Docker에서 제공했고 신뢰할수 있다는 디지털 서명)
curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg | sudo apt-key add -
#5. Docker repository를 APT sources에 추가합니다(APT:패키지관리자 가 인식할수있도록 저장소추가)
echo "deb [arch=armhf] https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list
#6. 다시 패키지를 업데이트한 후 Docker를 설치합니다: (이제 apt-get사용가능)
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io
설치후 잘 설치됐는지 확인하고 특정 사용자에게 docker그룹추가를 통한 일종의 docker sudo권한 부여
# 잘 설치되었는지 확인
sudo docker images
sudo docker run hello-world
# sevity사용자를 Docker그룹에추가(해당사용자가 root가 아니어도 docker명령을 다룰수 있게 해줌)
sudo usermod -aG docker sevity
docker images해보면 현재 서버에 있는 도커이미지들 목록이 나온다. (일종의 git으로 치면 로컬 저장소개념으로 원격서버에 저장된건 아님)
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
auth-service_auth-service latest cf72ade83f7f 7 hours ago 455MB
postgres 13 9ca11a5f9994 12 days ago 350MB
hello-world latest 38d49488e3b0 2 months ago 4.85kB
특정 image를 지우려면 docker rmi 사용가능. 예를들어 docker rmi 9ca11a5f9994
Dockerfile을 만들고 아래처럼 build하면 된다.(맨뒤 .은 생략불가하면 Dockerfile 위치를 나타낸다)
docker-compose up을 하면 docker build와 docker run을 모두 수행하며,
docker-compose build를 하면 docker build까지만 된다.
docker-compose를 쓰면 여러 도커이미지를 동시에 켜거나 끄는등의 동시 핸들링에도 용이하다.
docker-compose up --build를 해서 --build 옵션을 주면 실행전 무조건 빌드를 거치게 된다.
AWS와 연동하기
Elastic Container Service (ECS)는 Docker 컨테이너를 쉽게 배포, 실행 및 스케일링할 수 있게 해주는 완전관리형 컨테이너 오케스트레이션 서비스입니다.이걸 사용해서 연동해보겠습니다.
ECS는 아마존 EC2 인스턴스나 AWS Fargate를 사용하여 실행할 수 있습니다.
AWS Elastic Container Service(ECS)를 사용해 애플리케이션을 배포하려면 다음과 같은 단계를 따릅니다.
1. AWS 계정 생성: AWS에서 제공하는 서비스를 이용하려면 먼저 AWS 계정이 필요합니다. 계정이 없다면 AWS 웹사이트에서 생성할 수 있습니다.
2. Docker 이미지를 Docker Hub에 푸시: 먼저, 로컬에서 Docker 이미지를 생성한 후 이를 Docker Hub에 푸시합니다.
# 로컬에서 Docker 이미지를 빌드합니다.
docker build -t online-judge-auth-service .
# Docker Hub에 로그인합니다.
docker login --username=your-username --password=your-password
# Docker Hub에 이미지를 푸시합니다.
docker tag online-judge-auth-service:latest your-username/online-judge-auth-service:latest
docker push your-username/online-judge-auth-service:latest
3. ECS 클러스터 생성: AWS Management Console에 로그인한 후, ECS 페이지로 이동해 새 클러스터를 생성합니다.
4. Task Definition 생성: ECS 클러스터 내에 'Task Definitions' 페이지로 이동해 새로운 Task Definition을 생성합니다. 이 과정에서 Docker 이미지의 위치(Docker Hub의 URL), 컨테이너에 필요한 CPU와 메모리, 포트 매핑 등을 설정합니다. * 이 과정이 좀 중복적으로 느껴지고 직관적이지 않았지만 필요했다.
5. ECS Service 생성: 생성한 Task Definition을 이용해 ECS Service를 생성합니다. 이 과정에서 원하는 수의 Task를 실행하도록 설정하고, 필요한 경우 로드 밸런서를 설정합니다.
* 이번 프로젝트에서는 로드밸런서는 사용하지 않고 그냥 EC2인스턴스로 실행
6. 보안 그룹 설정 확인: 생성한 ECS Service가 외부에서 접근 가능하도록, 해당 서비스가 사용하는 보안 그룹의 인바운드 규칙을 확인합니다. 필요한 경우 포트를 열어줍니다. * 보안규칙을 통해 8080포트를 열어줘야 했다.
7. 서비스 확인: ECS Service가 성공적으로 시작되면, Task의 IP 주소나 연결된 로드 밸런서의 DNS 이름을 이용해 애플리케이션에 접근해봅니다.
* curl로 접근해보니 잘 되었다.
이러한 단계들은 AWS Management Console을 통해 수행할 수 있지만, AWS CLI나 SDK를 이용해 스크립트로 자동화할 수도 있습니다. 비록 초기 설정에는 약간의 시간이 소요될 수 있지만, 한 번 환경을 구성하고 나면 새로운 버전의 애플리케이션을 쉽게 배포할 수 있습니다.
이해하기 따라로웠던개념들
1. VPC: 이건 가상서버라는 개념이 아니라 가상랜환경이란 의미에 가까웠다. 랜환경안에 여러 호스트가 있을 수 있는..
2. 서브넷개념: 서브넷을 여러개 분리해서 둘 수 있었는데 물리적으로 다른 위치로 구성할 수 있었다. 서브넷은 쉽게 말해서 공유기나 스위치로 묶이는 작은 단위라고 볼 수 있다.
sevity@sevityubuntu:~/workspace/online_judge/problem-frontend$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
frontend-service latest 34ae6e43cd4e 4 hours ago 1.92GB
problem-service latest a360d436d839 4 hours ago 517MB
auth-service latest befdc9e37ac1 4 hours ago 526MB
<none> <none> 96430f927c57 4 hours ago 526MB
<none> <none> 58e82ecb1610 11 hours ago 1.92GB
<none> <none> f9bb20adb713 35 hours ago 1.92GB
<none> <none> de59f27bcff9 2 days ago 1.92GB
<none> <none> c4cf8ec43872 3 days ago 1.92GB
<none> <none> 25e3f42a4b64 4 days ago 1.92GB
docker system prune: 불필요한 이미지, 컨테이너, 볼륨 및 네트워크를 일괄 삭제
위의 <none>처럼 누적되는 image들 정리가능(Docker 이미지 빌드 과정에서 새로운 이미지 태그를 생성할 때마다 새로운 이미지 ID가 생성되는데, 만약 이전 이미지 태그를 유지하지 않고 새로운 태그를 계속 생성한다면, <none> 태그와 함께 이전 버전의 이미지가 누적될 수 있다. 이러한 누적은 디스크 공간을 차지하므로, 불필요한 이미지를 정리하는 것이 좋다.)
sevity@sevityubuntu:~/workspace/online_judge/problem-frontend$ docker system prune
WARNING! This will remove:
- all stopped containers
- all networks not used by at least one container
- all dangling images
- all dangling build cache
Are you sure you want to continue? [y/N] y
Deleted Containers:
7e93e0f073dccae0ea2ec28b7051138a38fb8137feb8bd1b9e9c5d9df1a084ab
c18f484efede43192c18b4987096409454ac358a6ca802c93061ad233d3a214a
Deleted Networks:
auth-service_default
my-network
problem-service_default
Deleted Images:
deleted: sha256:2d86f8a15708197b0dcdb54d2461d060b289ac122e1f49d27e79ada0b34f01b2
deleted: sha256:52c9722d7ab889e55af2f0f8d1540f5b218a6e40df5b5fa331bcb1a08ac6aa5b
deleted: sha256:d6d5fafa7731073ac0bf4e2d731d04dc413fae64a39de29255e6b530fada5f97
deleted: sha256:708bb9cc93ee48ba372c9d329441b1735717e09516670cf08dbe2518f02261d5
Deleted build cache objects:
i8eaq1edr2a8koj2n1o043xva
wpr37m52re2lf3gmkndy630t6
Total reclaimed space: 31.52GB
docker rmi: 특정 image 삭제
모든 image삭제하기(아래 과정을 거쳐야 의존성 문제가 해결되면서 다 지워진다)
# 모든 컨테이너 정지:
docker stop $(docker ps -aq)
# 모든 컨테이너 삭제:
docker rm $(docker ps -aq)
# 모든 이미지 삭제:
docker image prune -a
Docker Container 관련
Docker Container는 현재 실행 중인 Docker image를 의미하며
docker ps를 하면 컨테이너의 목록을 나열한다(어떤 포트를 listen하고 있는지도 알 수 있다)
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7e93e0f073dc problem-service "java -jar /app/app.…" 3 hours ago Up 3 hours 0.0.0.0:9993->9993/tcp, :::9993->9993/tcp cranky_yonath
b2fb854a135c frontend-service "docker-entrypoint.s…" 3 hours ago Up 3 hours frontend-service
b4bf33f71cf4 auth-service "java -jar /app/app.…" 3 hours ago Up 3 hours affectionate_feynman4
docker ps --filter "expose=9993"를 하면 특정 포트만 필터링해서 볼 수도 있다.
$ docker ps --filter "expose=9993"
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7e93e0f073dc problem-service "java -jar /app/app.…" 3 hours ago Up 3 hours 0.0.0.0:9993->9993/tcp, :::9993->9993/tcp cranky_yonath
docker stop을 통해 실행중인 docker container를 종료할 수 있다.
주의할점은 위의 docker ps결과에 나온 컬럼기준으로 IMAGE이름으로는 안되고 CONTAINER_ID또는 맨 오른쪽 컬럼인 NAMES를 인자로 주어야 동작한다는 점이다.
# IMAGE이름으로는 stop이 안된다.
$ docker stop problem-service
Error response from daemon: No such container: problem-service
# CONTAINER_ID나 NAME으로는 가능
$ docker stop 7e93e0f073dc
7e93e0f073dc
만약 IMAGE이름으로 stop하고 싶다면 다음처럼 해준다.
# problem-service라는 이름의 IMAGE로 부터 생성된 container 모두를 stop한다.
$ docker ps -q --filter ancestor=problem-service | xargs docker stop
7e93e0f073dc
docker stop을 했다고 해도 지워진건 아니며 docker rm을 통해 지워줘야 한다.
(coin) sevity@raspberrypi:~/workspace/online_judge/frontend-service $ npx create-next-app@latest .
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
Creating a new Next.js app in /home/sevity/workspace/online_judge/frontend-service.
Using npm.
Initializing project with template: app
Installing dependencies:
- react
- react-dom
- next
added 22 packages, and audited 23 packages in 41s
4 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Success! Created frontend-service at /home/sevity/workspace/online_judge/frontend-service
-- 데이터베이스 생성
CREATE DATABASE online_judge;
-- 사용자 생성
CREATE USER online_judge_admin WITH ENCRYPTED PASSWORD '****';
GRANT ALL PRIVILEGES ON DATABASE online_judge TO online_judge_admin;
-- 데이터베이스 접속
\c online_judge;
-- 문제 테이블 생성
CREATE TABLE problems (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
description TEXT NOT NULL,
example_input TEXT NOT NULL,
example_output TEXT NOT NULL
);
-- 사용자 테이블 생성
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL,
password VARCHAR(100) NOT NULL,
name VARCHAR(50)
);
-- 제출 테이블 생성
CREATE TABLE submissions (
id SERIAL PRIMARY KEY,
problem_id INT NOT NULL,
user_id INT NOT NULL,
code TEXT NOT NULL,
result TEXT NOT NULL,
FOREIGN KEY (problem_id) REFERENCES problems (id),
FOREIGN KEY (user_id) REFERENCES users (id)
);
SpringBoot Service는 다음과 같이 7개로 잡아 보았다. (진행하면서 변경가능, 링크를 누르면 각 서비스에 대한 블로그로 이동가능)
문제 관리 서비스 (Frontend): 문제를 보여주고, 문제 추가, 삭제, 수정 등의 인터페이스를 제공
제출 관리 서비스 (Backend): 사용자의 코드 제출 및 제출 기록을 관리
제출 관리 서비스 (Frontend): 코드 제출 인터페이스와 제출 기록 확인 인터페이스를 제공
채점 서비스 (Backend): 제출된 코드를 채점
이 중 1번 인증 서비스부터 Spring Boot Initializr를 통해 프로젝트를 생성해보자.
위에서 생성된 zip파일을 ~/workspace/online-judge/auth-service에 풀어주고, github에 등록
VSCode에서 Spring환경세팅하고 ssh-remote설정해서 서버와 연동함
src/main/resources/appliction.properties에 다음 내용을 설정하여 PostgreSQL과 연결
Spring Boot의 일반적인 패키지(폴더) 구조는 아래와 같다.
Spring Boot 프로젝트 구조는 대체로 다음과 같습니다:
com.companyname.projectname : 이는 프로젝트의 메인 패키지입니다. 이 패키지 아래에는 Spring Boot Application 클래스와 기타 구성 클래스가 위치합니다.
com.companyname.projectname.controller : 이 패키지에는 모든 컨트롤러 클래스가 위치합니다. 컨트롤러 클래스는 HTTP 요청을 처리하고 응답을 반환하는 역할을 합니다.
com.companyname.projectname.service : 이 패키지에는 모든 서비스 클래스가 위치합니다. 서비스 클래스는 비즈니스 로직을 수행하는 역할을 합니다.
com.companyname.projectname.repository : 이 패키지에는 모든 레포지토리 클래스가 위치합니다. 레포지토리 클래스는 데이터베이스와 상호작용하는 역할을 합니다.
com.companyname.projectname.model or com.companyname.projectname.entity : 이 패키지에는 모든 모델 또는 엔티티 클래스가 위치합니다. 이러한 클래스는 데이터베이스 테이블을 표현합니다.
com.companyname.projectname.config : 이 패키지에는 모든 구성 클래스가 위치합니다. 구성 클래스는 프로젝트의 구성을 담당합니다.
com.companyname.projectname.exception : 이 패키지에는 사용자 정의 예외 클래스가 위치합니다.
나는 여기서 repository와 entity를 DDD에 따라 domain으로 합쳤다.
이제 도메인모델설정을 진행해보자.
domain 패키지(폴더) 만들고 그안에 User.java를 만들어서 users DB 테이블에 대응하는 엔티티 클래스작성
package com.yourcompany.authservice.domain;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String name;
// getters and setters
}
UserRepository 인터페이스를 작성하여 DB와 연동하는 동작을 작성
package com.yourcompany.authservice.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsername(String username);
}
이제 Spring Security를 통해 사용자등록, 로그인, 로그아웃을 처리하는 컨트롤러와 서비스를 작성해보자.
1. Spring Security 설정
다음과 같은 SecurityConfig.java를 작성
@EnableWebSecurity 어노테이션을 붙여서 Spring Boot에게 이 클래스가 보안 설정을 담당하는 클래스임을 알림.
//UserDetails는 스프링에서 정의한 인터페이스이며 이파일은 그 구현체입니다.
//도메인 모델 클래스인 User와 UserDetails 인터페이스를 구현한 UserDetailsImpl(이파일)은 서로 다른 목적을 가지고 있습니다.
//User는 데이터베이스의 users 테이블과 매핑되어 데이터베이스에서 사용자 정보를 조회하는 데 사용되며,
//UserDetailsImpl은 Spring Security에서 사용자 인증 정보를 관리하는 데 사용됩니다.
//그렇다면 왜 UserDetails 인터페이스를 구현하는 클래스를 따로 만들 필요가 있을까요? 이는 Spring Security의 유연성 때문입니다.
//Spring Security는 다양한 인증 방식과 사용자 정보 형태를 지원하기 위해 UserDetails 인터페이스를 제공합니다.
//이를 통해 개발자는 자신의 애플리케이션에 맞는 사용자 인증 정보 형태를 자유롭게 구현할 수 있습니다.
package com.sevity.authservice.service;
import com.sevity.authservice.domain.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Arrays;
import java.util.Collection;
public class UserDetailsImpl implements UserDetails {
private User user;
public UserDetailsImpl(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("USER"));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
package com.sevity.authservice.service;
import com.sevity.authservice.domain.User;
import com.sevity.authservice.domain.UserRepository;
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.Service;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException(username);
}
return new UserDetailsImpl(user);
}
}
다음 단계는 사용자 등록, 로그인, 로그아웃을 처리하는 서비스와 컨트롤러를 작성하는 것입니다.
//이 인터페이스는 사용자 등록과 로그인 기능을 정의합니다.
package com.sevity.authservice.service;
import com.sevity.authservice.domain.User;
import com.sevity.authservice.dto.UserRegistrationDto;
public interface AuthService {
User register(UserRegistrationDto registrationDto);
User login(String username, String password);
}
그 다음 AuthService 인터페이스의 구현체인 AuthServiceImpl 클래스를 작성합니다. 이 클래스는 AuthService 인터페이스의 메소드를 구현하며, UserRepository와 PasswordEncoder를 주입받아 사용합니다.
//AuthService 인터페이스의 구현체인 AuthServiceImpl 클래스를 작성합니다.
//이 클래스는 AuthService 인터페이스의 메소드를 구현하며, UserRepository와 PasswordEncoder를 주입받아 사용합니다.
//웹에 접속한 사용자가 userid/password를 서버에 전달하면 db랑 매칭시켜서 등록하거나, 로그인검증하는 역할을 한다.
package com.sevity.authservice.service;
import com.sevity.authservice.domain.User;
import com.sevity.authservice.domain.UserRepository;
import com.sevity.authservice.dto.UserRegistrationDto;
import com.sevity.authservice.exception.UsernameAlreadyExistsException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthServiceImpl implements AuthService {
private UserRepository userRepository;
private PasswordEncoder passwordEncoder;
public AuthServiceImpl(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public User register(UserRegistrationDto registrationDto) {
if (userRepository.existsByUsername(registrationDto.getUsername())) {
throw new UsernameAlreadyExistsException("Username already exists: " + registrationDto.getUsername());
}
User user = new User();
user.setUsername(registrationDto.getUsername());
user.setPassword(passwordEncoder.encode(registrationDto.getPassword()));
return userRepository.save(user);
}
@Override
public User login(String username, String password) {
User user = userRepository.findByUsername(username);
if (user != null && passwordEncoder.matches(password, user.getPassword())) {
return user;
} else {
return null;
}
}
}
AuthController 컨트롤러 생성(컨트롤러는 처음 등장했는데 모델(서비스)과 뷰를 연결하는 역할을 한다) 이제 클라이언트의 요청을 처리할 AuthController를 작성합니다.
이 컨트롤러는 AuthService를 주입받아 사용하며, 사용자 등록과 로그인 요청을 처리합니다.
AuthController를 부르는 것은 스프링 프레임워크이며 웹으로 사용자요청이 왔을때 부른다.(@RestController)
//이 Rest컨트롤러는 AuthService를 주입받아 사용하며, 클라이언트의 요청을 받아, 사용자 등록과 로그인을 처리합니다.
package com.sevity.authservice.controller;
import com.sevity.authservice.domain.User;
import com.sevity.authservice.dto.UserRegistrationDto;
import com.sevity.authservice.service.AuthService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AuthController {
private AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public User register(@RequestBody UserRegistrationDto registrationDto) {
return authService.register(registrationDto);
}
@PostMapping("/login")
public User login(@RequestBody UserRegistrationDto loginDto) {
return authService.login(loginDto.getUsername(), loginDto.getPassword());
}
}
UserRegistrationDto는 클라이언트로부터 받은 데이터를 도메인 모델에 바인딩하기 위해 사용하는 DTO(Data Transfer Object)입니다. UserRegistrationDto는 사용자 등록 요청을 처리하는 메소드에서 사용됩니다.
//이거랑 User.java랑 유사하게 생겼는데
//User 클래스와 UserRegistrationDto 클래스는 서로 다른 목적으로 사용됩니다.
//User 클래스는 데이터베이스의 users 테이블에 대응하는 엔티티 클래스입니다. 이 클래스는 JPA가 데이터베이스와의 상호 작용을 위해 사용하는 도메인 모델입니다.
//UserRegistrationDto 클래스는 클라이언트로부터 받은 사용자 등록 요청 데이터를 바인딩하기 위해 사용하는 DTO(Data Transfer Object)입니다.
//이 클래스는 사용자 등록 API의 요청 본문에 담긴 데이터를 Java 객체로 변환하는 데 사용되며 이유는 다음과 같음
/*
1. 표현 계층과 영속성 계층 분리: 엔티티 클래스는 영속성 계층에서 사용되며, DTO는 표현 계층에서 사용됩니다. 이 두 계층을 분리함으로써 각 계층의 책임을 명확히 할 수 있습니다.
2. API 스펙 변경에 유연하게 대응: 클라이언트와 서버 간에 주고받는 데이터의 형태가 변경되더라도, 이에 따라 엔티티 클래스를 변경하지 않고 DTO만 변경하면 되므로 유연하게 대응할 수 있습니다.
3. 데이터 유효성 검사: DTO에서는 클라이언트로부터 받은 데이터의 유효성을 검사할 수 있습니다. 예를 들어, 사용자 등록 요청에서 비밀번호와 비밀번호 확인이 일치하는지 검사하는 것은 DTO에서 수행할 수 있습니다. */
package com.sevity.authservice.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UserRegistrationDto {
private String username;
private String password;
}
/user 엔트포인트 구현(이것은 사용자정보 페이지에 해당한다)
//authentication은 Spring Security에서 제공하는 인터페이스이며, 사용자 인증에 성공하면
//인증정보를 담는다. authentication.getName()은 UserDetailsService인터페이스를 통해 결국 DB에서
//username값을 가져오게 된다.
package com.sevity.authservice.controller;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties.Admin;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@GetMapping("/user")
public ResponseEntity<?> getUser(Authentication authentication) {
// 이 부분에서는 인증된 사용자의 정보를 가져와서 반환하는 로직이 들어갑니다.
return ResponseEntity.ok("User page. user: "+authentication.getName());
}
}
admin과 user를 나누기위한 Role구현
먼저 DB스키마를 아래처럼 넣는다.
-- 역할 테이블 생성
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
-- 사용자와 역할의 관계 테이블 생성
CREATE TABLE user_roles (
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE
);
그다음 User에서 했듯이 Role.java , RoleRepository.java 구현
package com.sevity.authservice.domain;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Table;
import com.fasterxml.jackson.annotation.JsonBackReference;
import java.util.Set;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
@Entity
@Table(name = "roles")
@Getter
@Setter
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
//아래 users는 이역할(예를들어 name="ROLE_ADMIN")을 가지는 모든 user를 의미함. online judge에서는 필요없을수 있으나 학습용으로 유지함.
//User와 Role은 n:n관계 이지만 관계의 주인은 User쪽에 있다. 왜냐면 mappedBy키워드를 쓰는순간 spring JPA에서 노예로 판정함!
//JPA에서는 관계의 주인쪽에서만 데이터베이스 연산이 수행됨!!!
//mappedBy = "roles"는 User.java안의 User class의 roles 멤버변수를 의미
//문자열형태로 표현하는 이유는 Java에서 직접적인 참조를 할경우 상호참조 형태가 되어 순환참조 문제를 일으킬 수 있기 때문
@ManyToMany(mappedBy = "roles")
@JsonBackReference // 이게 없으면 무한반복 오류로 죽어버림
private Set<User> users;
// getter, setter, etc.
}
package com.sevity.authservice.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RoleRepository extends JpaRepository<Role, Long> {
Role findByName(String name);
}
postgreSQL기준 다음과 같은 권한부여 필요
GRANT ALL PRIVILEGES ON TABLE roles TO online_judge_admin;
GRANT ALL PRIVILEGES ON TABLE user_roles TO online_judge_admin;
실행시 user, admin role을 table에 등록하도록 코딩(db에 하드코딩하는 것보다 이렇게 해두는게 추적하거나 재설치시 유리)
@SpringBootApplication
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}
@Bean
CommandLineRunner init(RoleRepository roleRepository) {
return args -> {
Role adminRole = roleRepository.findByName("ROLE_ADMIN");
if (adminRole == null) {
Role newAdminRole = new Role();
newAdminRole.setName("ROLE_ADMIN");
roleRepository.save(newAdminRole);
}
Role userRole = roleRepository.findByName("ROLE_USER");
if (userRole == null) {
Role newUserRole = new Role();
newUserRole.setName("ROLE_USER");
roleRepository.save(newUserRole);
}
};
}
}
이렇게 하면 백엔드 인증서비스 구현이 완료되었다. 아래 curl명령어로 테스트 할 수 있다.
2. 로그인(register와 다르게 json형태가 아닌 key&value형태임에 주의.. 역사적/관습적인 이유로 이게 디폴트라한다)
curl -X POST http://localhost:8080/login -c cookies.txt -d 'username=testuser&password=testpassword'
3. 사용자정보페이지 접근
curl -X GET http://localhost:8080/user -b cookies.txt
4. 어드민페이지 접근
curl -X GET http://localhost:8080/admin -b cookies.txt
frontend-service와 연동할때, 브라우저로 호출하는 경우에, 스프링부트와 next.js등이 HOST나 PORT가 다른점 때문에 도메인내 호출이 아닌걸로 판단돼서, CORS가 문제 될 수 있다. 그러면 SecurityConfig.java를 아래처럼 허용적으로 바꾸어서 넘어갈 수 있다. 아래는 무지성으로 모든걸 허용하는 버전이나 필요한 만큼 조절하면 된다.
package com.sevity.authservice.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserDetailsService userDetailsService;
private final AuthenticationSuccessHandler authenticationSuccessHandler;
private final AuthenticationFailureHandler authenticationFailureHandler;
public SecurityConfig(UserDetailsService userDetailsService,
AuthenticationSuccessHandler authenticationSuccessHandler,
AuthenticationFailureHandler authenticationFailureHandler) {
this.userDetailsService = userDetailsService;
this.authenticationSuccessHandler = authenticationSuccessHandler;
this.authenticationFailureHandler = authenticationFailureHandler;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
CorsConfigurationSource corsConfigurationSource() { // 아래 .cors()가 불릴때 주입된다.
CorsConfiguration configuration = new CorsConfiguration();
//아래는 최대한 허용적인 구성. 실제로는 필요한 만큼 허용해야한다.
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().and() // CORS 설정 적용
.csrf().disable()
.authorizeRequests()
.antMatchers("/register", "/login").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/home")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.permitAll()
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
}
도커로 배포
도커배포를 위해서는 먼저 다음과 같은 Dockerfile이 필요하다. Dockerfile은 Docker image를 생성하는데 필요한 명령을 담고 있으며, 필수적이다.
# 첫 번째 단계: Maven Wrapper를 사용하여 어플리케이션 빌드
# 아래 AS build부분은 docker build명령을 실행하면 진행됨
# FROM base_image형태이며 여기서는 java를 포함한 debian기반 운영체제의 이미지를 베이스로 해서 추가설정을 한다는 의미
FROM eclipse-temurin:17-jdk-jammy AS build
# 도커이미지내 작업디렉토리를 /workspace/app으로 설정. 이는 후속 RUN, CMD, ENTROYPOINT, COPY, ADD명령의 실행경로가 됨
WORKDIR /workspace/app
# maven, spring 관련 파일들을 도커로 복사
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
COPY src src
# maven으로 스프링부트프로젝트 빌드(도커아니어도 하는거)
RUN ./mvnw install -DskipTests
# 필요한디렉토리를 만들고 빌드된 jar 압축해재. 필수는 아니고 최적화전략의 일환
RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar)
# 멀티스테이지 전략의 두 번째 단계
# jdk가 아닌 jre만 포함하는 가벼운 이미지로 전환
# 이렇게 하면 최종 이미지는 jre기준으로 되어 크기를 줄일수 있음.
FROM eclipse-temurin:17-jre-jammy
VOLUME /tmp # 일종의 nas설정처럼 컨테이너간 또는 컨테이너와 호스트간 데이터 공유공간을 만드는것
ARG DEPENDENCY=/workspace/app/target/dependency
# 첫번째 빌드단계에서 생성된 파일들을 새 이미지로 복사.
# 멀티스테이지 구성의 경우 첫번째 이미지는 소멸되기 때문에 아래와 같은 복사과정이 필요.
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
# 아래 부분은 docker run명령을 사용하면 진행됨(아래 ENTRYPOINT부분. 또는 CMD도 가능)
ENTRYPOINT ["java","-cp","app:app/lib/*","com.sevity.authservice.AuthServiceApplication"]
아래처럼 docker내 빌드과정을 생략하고 jar만 복사 실행하는 간단한 버전도 가능하다.
docker-compose up을 하면 docker build와 docker run을 모두 수행하며,
docker-compose build를 하면 docker build까지만 된다. (docker-compose를 쓰더라도 Dockerfile은 빌드를 위해 필요하다)
docker-compose를 쓰면 여러 도커이미지를 동시에 켜거나 끄는등의 동시 핸들링에도 용이하다.
docker-compose up --build를 해서 --build 옵션을 주면 실행전 무조건 빌드를 거치게 된다.
세션을 Redis로 바꾸기
스프링부트에서는 다음과 같이 기본적으로 SpringSecurity를 통한 세션관리를 지원한다.
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
이 설정은 전통적인 세션 기반 인증을 사용하고 있으며, 서버 측에서 세션을 관리하고 클라이언트에게는 JSESSIONID 쿠키를 통해 세션을 식별. Spring Security의 기본 세션 관리 방식은 일반적으로 단일 노드에서 작동하며, 여러 서비스나 여러 인스턴스 간에 세션을 공유하기 어려움. 이는 각 서비스나 인스턴스가 자체적인 세션 저장소를 가지고 있기 때문에 발생하는 문제.
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.5.0</version> <!-- 버전은 프로젝트에 맞게 조정 -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
import org.springframework.context.annotation.Configuration;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {
}
단계5: Entity클래스들을 수정해서 Serialize가능하게 만들어주기
import java.io.Serializable;
@Entity
public class User implements Serializable {
private static final long serialVersionUID = 1L;
// 기존 코드
}
...
import java.io.Serializable;
@Entity
public class Role implements Serializable { // Serializable 인터페이스 구현
private static final long serialVersionUID = 1L; // serialVersionUID 추가
// 기존코드
}
실제로 redis에 인증정보가 저장되는지는 터미널에서 redis-cli한 뒤 keys *로 확인 가능하다.
git push를 위한 인증정보 설정(GitHub는 2021년 8월 13일부터 패스워드를 사용한 인증 방식을 지원하지 않음)
ssh키를 생성해서 github사이트에 등록해준다.
아래처럼 ssh-keygen실행후 ~/.ssh/id_rsa의 내용을 cat해서 복사한다음
$ ssh-keygen -t rsa
Generating public/private rsa key pair.
Enter file in which to save the key (/home/sevity/.ssh/id_rsa):
/home/sevity/.ssh/id_rsa already exists.
Overwrite (y/n)?