package org.example;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
public class Main {
public static void main(String[] args) {
Flux.just("a", "b", "c", "d") // 1,2,3,4 네개의 아이템을 순차적으로 방출하는 단일 스트림 생성
.map(s -> { // 스레드 분기 없이 호출 스레드에서 해당 스트림 아이템에 대해서 대문자변환 연산수행(아래 .subscribe를 만나기 전까지 지연됨!)
System.out.println("Map 1 - Thread: " + Thread.currentThread().getName());
return s.toUpperCase();
})
.publishOn(Schedulers.boundedElastic()) //이후 작업은 boundedElastic이라는 사전정의된 별도 스레드에서 하도록 지정함!
.map(s -> {
System.out.println("Map 2 - Thread: " + Thread.currentThread().getName());
return s + "!";
})
.subscribe( // 지연 실행을 시작하는 시점이며, 마지막 스케줄러 지정이 boundedElastic이라 boundedElastic 스레드를 통해 수행됨
s -> System.out.println("Received " + s + " on Thread: " + Thread.currentThread().getName()));
}
}
실행결과
> Task :Main.main()
Map 1 - Thread: main
Map 1 - Thread: main
Map 1 - Thread: main
Map 1 - Thread: main
Map 2 - Thread: boundedElastic-1
Received A! on Thread: boundedElastic-1
Map 2 - Thread: boundedElastic-1
Received B! on Thread: boundedElastic-1
Map 2 - Thread: boundedElastic-1
Received C! on Thread: boundedElastic-1
Map 2 - Thread: boundedElastic-1
Received D! on Thread: boundedElastic-1
예제를 조금 수정한 코드를 보자.
package org.example;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
public class Main {
public static void main(String[] args) {
Flux.just(1, 2, 0, 4) // 1,2,0,4 네개의 아이템을 순차적으로 방출하는 단일 스트림 생성
.map(n -> { // 스레드 분기 없이 호출 스레드에서 해당 스트림 아이템에 대해서 나누기변환 연산수행(아래 .subscribe를 만나기 전까지 지연됨!)
System.out.println("Map 1 - Thread: " + Thread.currentThread().getName());
return 10 / n;
})
.publishOn(Schedulers.boundedElastic()) //이후 작업은 boundedElastic이라는 사전정의된 별도 스레드에서 하도록 지정함!
.map(s -> {
System.out.println("Map 2 - Thread: " + Thread.currentThread().getName());
return s + "!";
})
.doOnNext(s -> { // 스트림에 영향을 주지 않으면 로깅등 부가작업을 할때 사용
System.out.println("doOnNext - Thread: " + Thread.currentThread().getName() + " with value: " + s);
})
.doOnError(error -> { // Exception이 발생한 경우만 여기로 떨어진다.
System.out.println("Error occurred - Thread: " + Thread.currentThread().getName() + " with error: " + error.getMessage());
})
.subscribe( // 지연 실행을 시작하는 시점이며, 마지막 스케줄러 지정이 boundedElastic이라 boundedElastic 스레드를 통해 수행됨
s -> System.out.println("Received " + s + " on Thread: " + Thread.currentThread().getName()));
// boundElastic스레드로 중간에 분기되기 때문에 이 출력이 마지막 줄이 아닐 수 있다!
// 자바에서는 main함수가 종료되더라도 다른 스레드가 강제종료되지 않는다!
System.out.println("main end");
}
}
실행결과
> Task :Main.main()
Map 1 - Thread: main
Map 1 - Thread: main
Map 1 - Thread: main
Map 2 - Thread: boundedElastic-1
doOnNext - Thread: boundedElastic-1 with value: 10!
Received 10! on Thread: boundedElastic-1
Map 2 - Thread: boundedElastic-1
doOnNext - Thread: boundedElastic-1 with value: 5!
Received 5! on Thread: boundedElastic-1
main end
Error occurred - Thread: boundedElastic-1 with error: / by zero
이 예시에서는 onDoNext, onDoError, main end시 스레드 종료 개념을 추가적으로 배울 수 있다.
import java.util.*
public class coupang1 {
public static void main(String args[]){
Scanner scanner = new Scanner(System.in);
String s = scanner.nextLine(); // 문자열 s 입력
int n = Integer.parseInt(scanner.nextLine()); // 사전 단어의 개수 입력
String[] wordDictArray = scanner.nextLine().split(" "); // 공백으로 구분된 사전 단어 입력
List<String> wordDict = Arrays.asList(wordDictArray); // 배열을 리스트로 변환
}
}
Scanner로 속도가 느릴때
인풋개수가 클때는 Scanner가 느릴 수 있다. 이때 bufferedReader를 쓰면 몇 배 빠르게 할 수 있다.
// before
Scanner sc = new Scanner(System.in);
int n = sc.nextInt()
List<Integer> arr = new ArrayList<>();
for(int i=0;i<n;i++) arr.add(sc.nextInt());
// after
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
int n = Integer.parseInt(br.readLine());
List<Integer> arr = new ArrayList<>();
StringTokenizer st = new StringTokenizer(br.readLine());
for (int i = 0; i < n; i++) arr.add(Integer.parseInt(st.nextToken()));
import java.util.*;
public class Main {
public static void main(String args[]){
LinkedList<Integer> list1 = new LinkedList<>();
LinkedList<Integer> list2 = new LinkedList<>();
Scanner sc = new Scanner(System.in);
int n1 = sc.nextInt();
for(int i=0;i<n1;i++) list1.add(sc.nextInt()); // 추가는 add()
int n2 = sc.nextInt();
for(int i=0;i<n2;i++) list2.add(sc.nextInt());
int N = n1+n2;
//합친 리스트의 개수가 홀수개이면 n/2 번째인덱스
//짝수개이면 n/2-1, n/2번째 인덱스의 평균이 답
int mid_six = N%2==1?N/2:N/2-1;
int mid_eix = N%2==1?N/2:N/2;
ListIterator<Integer> it1 = list1.listIterator(); // next()하려면 Iterator필요
ListIterator<Integer> it2 = list2.listIterator();
int median1 = 0, median2 = 0;
for (int i = 0; i <= mid_eix; i++) {
int v1 = it1.hasNext() ? it1.next() : Integer.MAX_VALUE; // hasNext()눈여겨 보자
int v2 = it2.hasNext() ? it2.next() : Integer.MAX_VALUE;
int value;
if (v1 < v2) {
value = v1;
if (v2 < Integer.MAX_VALUE) it2.previous(); // previous()도 가능
} else {
value = v2;
if (v1 < Integer.MAX_VALUE) it1.previous();
}
if (i == mid_six) median1 = value;
if (i == mid_eix) median2 = value;
}
System.out.println((double)(median1 + median2) / 2.0);
sc.close();
}
}
LinkedList 선언부, ListIterator, next()/previous() 처리 등을 눈여겨 보자.
근데 사실 이 문제의 경우에는 꼭 LinkedList를 쓸필요는 없다. ArrayList로도 충분.
ArrayList
c++ vector에 해당하며 다음과 같이 쓸 수 있다.
ArrayList<Integer> list = new ArrayList<>();
list.add(10);
list.add(20);
int value = list.get(1); // 20을 가져옵니다.
c++과 다르게 list[1]등으로 배열인덱스는 쓰지 못함에 주의
HashMap
c++ map에 해당하며 다음과 같이 쓸 수 있다.
HashMap<Integer, String> map = new HashMap<>();
HashMap<Integer, List<Integer>> map2 = new HashMap<>();
for (int i = 0; i < N; i++) {
String a = map.getOrDefault(C[i],"");
List<Integer> b = map2.getOrDefault(C[i],new ArrayList<>());
a+=S[i];map.put(C[i],a);
b.add(i);map2.put(C[i],b);
}
getOrDefault()에 주목해보자. 이걸안쓰고 get()을 쓰면 항상null체크를 해줘야 한다.
(C++의 std::map에서는 요청한 키가 존재하지 않으면 해당 키를 자동으로 생성하고 해당 값 타입의 기본 생성자를 호출하여 값을 초기화)
containKey(key)를 쓰면 put()했는지 체크할 수 있다. (c++에서 map.count()와 같은 기능)
iteration하기
다음 4가지 정도 방법이 있다.
//방법1. 전통적인 방법.. Entry개념때문에 복잡하다.
int total_anagrams = 0;
Iterator<Map.Entry<String,Integer>> iterator = ana_map.entrySet().iterator();
int total_anagrams = 0;
while(iterator.hasNext()){
Map.Entry<String, Integer> entry = iterator.next();
total_anagrams += entry.getValue();
}
//방법2. key나 value한쪽만 필요한 경우 다음처럼 간략화 가능
int total_anagrams = 0;
for (Integer value : ana_map.values()) {
total_anagrams += value;
}
//방법3. key나 value 둘다 필요하면서 약간 더 간단한 방법
for (Map.Entry<String, Integer> entry : ana_map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
// key와 value를 사용한 작업
}
//방법4. 조금 더 간단한 방법(람다 표현식)
ana_map.forEach((key, value) -> {
// key와 value를 사용한 작업
});
class Solution {
private void check(Queue<Integer> q, int[][] mat, int[][] dist, int py, int px, int y, int x){
int h = mat.length;
int w = mat[0].length;
if(y<0||y>=h) return;
if(x<0||x>=w) return;
if(dist[y][x]>dist[py][px] + 1) {
dist[y][x] = dist[py][px]+1;
q.add(y);
q.add(x);
}
}
public int[][] updateMatrix(int[][] mat) {
int h = mat.length;
int w = mat[0].length;
int[][] dist = new int[h][w];
Queue<Integer> q = new ArrayDeque<>();
for(int y=0;y<h;y++)for(int x=0;x<w;x++)dist[y][x]=Integer.MAX_VALUE;
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
if(mat[y][x]==0) {
dist[y][x]=0;
q.add(y);q.add(x);
}
}
while(q.size()>0){
int cy = q.poll();
int cx = q.poll();
check(q,mat,dist,cy,cx,cy-1, cx);
check(q,mat,dist,cy,cx,cy+1, cx);
check(q,mat,dist,cy,cx,cy, cx-1);
check(q,mat,dist,cy,cx,cy, cx+1);
}
return dist;
}
}
Stack
c++과 비슷하게 push(), pop()인데 pop()이 top()+pop()이라고 보면된다. (값을 리턴하면서 즉시 pop도 하는..)
Array의 경우 다음과 같이 Arrays.sort() 또는 Collections.sort()를 쓴다.
import java.util.Arrays;
public class SortArrayExample {
public static void main(String[] args) {
int[] numbers = {5, 2, 8, 1, 3, 7};
// 배열 정렬
Arrays.sort(numbers); // 이경우는 Collections.sort()는 못쓴다.
// 정렬된 배열 출력
System.out.println("Sorted array: " + Arrays.toString(numbers));
}
}
역순으로 정렬하려면 다음과 같이 한다.
// 기본 배열은 Collections를 지원하지 않고 reverseOrder()도 사용할 수 없다.
// 따라서 List<Integer>로 변환후 수행한다.
Integer[] numbers = {5, 2, 8, 1, 3, 7};
List<Integer> number_list = Arrays.asList(numbers);
Collections.sort(number_list, Collections.reverseOrder()); // 이경우는 Collections.sort()는 못쓴다.
System.out.println("Sorted array: " + Arrays.toString(numbers));
구조체 정렬시 특정 필드에 대해 정렬하기
class Tweet {
Long time;
int tweetId;
}
List<Tweet> feed;
...
//time필드에 대해 정렬
feed.sort((t1, t2) -> t1.time.compareTo(t2.time));
//time필드에 대해 역순정렬
feed.sort((t1, t2) -> t2.time.compareTo(t1.time));
//아래처럼 전통적인 compare함수를 쓸 수도 있다.
Collections.sort(feed, new Comparator<Tweet>() {
@Override
public int compare(Tweet t1, Tweet t2) {
return t1.time.compareTo(t2.time);
}
});
//먼저 time에 대해 역순정렬하고, tweetId에 대해서 정방향정렬하기
feed.sort(Comparator.comparing((Tweet t) -> -t.time)
.thenComparing(t -> t.tweetId));
//방법2
feed.sort(Comparator.comparing((Tweet t) -> t.time)
.thenComparing(Comparator.comparing((Tweet t) -> t.tweetId).reversed()));
//방법3
feed.sort(Comparator.comparing((Tweet t) -> t.time)
.thenComparing((t1, t2) -> t2.tweetId - t1.tweetId));
//올드스쿨
feed.sort(new Comparator<Tweet>() {
@Override
public int compare(Tweet o1, Tweet o2) {
if (o1.time != o2.time) {
return o2.time - o1.time; // time에 대해 역순 정렬
}
return o1.tweetId - o2.tweetId; // tweetId에 대해 정방향 정렬
}
});
sevity@sevityubuntu:~/workspace/online_judge$ mkdir problem-frontend
sevity@sevityubuntu:~/workspace/online_judge/problem-frontend$ npx create-next-app .
✔ 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
Would you like to use TypeScript? - 추천: 예 Would you like to use ESLint? - 추천: 예 Would you like to use Tailwind CSS? - 추천: 아니오 Would you like to use src/ directory? - 추천: 예 Would you like to use App Router? - 추천: 예 Would you like to customize the default import alias? - 추천: 아니오
제대로 하려면, real_input, real_output, solution.cpp 등이 추가되어야 하지만, 우선은 간단하게 했다.
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
);
초기 IntelliJ설정
Ultimate버전과 Community버전이 있는데 전자만 SpringBoot관련 기능이 제공된다.
먼저 로컬환경에서 IntelliJ를 실행한 후, File > New > Project > Spring Initializr를 통해 프로젝트를 로컬에 생성한다.
그다음 생성된 파일들을 ssh환경으로 원격복사한다.
그다음음 다음과 같이 Remote Development > SSH로 들어가서 원격 개발환경을 설정한다.
application.properties 파일을 다음과 같이 설정. 포트번호는 7개 서비스중 3번째라는 의미로 8083으로 부여.
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${DATABASE_USERNAME}
spring.datasource.password=${DATABASE_PASSWORD}
spring.jpa.hibernate.ddl-auto=update
logging.file.name=log/application.log
# without below line, web login is needed.
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
server.port=8083
알고리즘 문제(problem)에 대한 도메인 entity와 repository를 domain이라는 패키지(폴더)에 다음과 같이 작성한다. 위의 DB스키마와 알맞도록 작성.
먼저 Problem entity는 다음과 같이 작성
package com.sevity.problemservice.domain
import javax.persistence.*
@Entity
@Table(name = "problems")
data class Problem(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
val title: String = "",
@Column(nullable = false)
val description: String = "",
@Column(name = "example_input", nullable = false)
val exampleInput: String = "",
@Column(name = "example_output", nullable = false)
val exampleOutput: String = ""
)
(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 *로 확인 가능하다.