다음 코드를 보자(주석을 주의깊게 볼 것)

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시 스레드 종료 개념을 추가적으로 배울 수 있다.

주석을 주의깊에 보면 되기 때문에 따로 설명은 안하겠다.

반응형

'Programming > JAVA' 카테고리의 다른 글

BiFunction, Function을 사용한 함수형 프로그래밍  (0) 2024.01.01
java Optional  (1) 2023.11.04
java enum  (0) 2023.11.03
IntelliJ 팁  (0) 2023.11.03
java공부  (0) 2023.08.12

다음 코드를 보자.

public class SimpleFunctionExample {

    public static void main(String[] args) {
        double powResult = power(2, 3);
        String result = formatResult(powResult);

        System.out.println(result);
    }

    private static double power(int a, int b) {
        return Math.pow(a, b);
    }

    private static String formatResult(double number) {
        return "Result: " + number;
    }
}

실행결과 Result: 8

 

power함수와 formatResult함수를 만들고 farmatResult(power(2,3)) 이렇게 중첩함수를 사용했다.

반면 BiFunction, Function을 사용하면 다음처럼 andThen, apply를 통해 함수형 프로그래밍으로 체이닝을 할 수 있다.

코드출처: https://codechacha.com/ko/java8-bifunction-example/

import java.util.function.BiFunction;
import java.util.function.Function;

public class BiFunctionExample2 {

    public static void main(String[] args) {

        BiFunction<Integer, Integer, Double> func1 = (a1, a2) -> Math.pow(a1, a2);
        Function<Double, String> func2 = (a1) -> "Result: " + a1;

        String result = func1.andThen(func2).apply(2, 3);

        System.out.println(result);
    }
}

실행결과는 동일하다.

 

반응형

'Programming > JAVA' 카테고리의 다른 글

reactor #1  (1) 2024.01.01
java Optional  (1) 2023.11.04
java enum  (0) 2023.11.03
IntelliJ 팁  (0) 2023.11.03
java공부  (0) 2023.08.12

 

null처리를 하다보면 코드가 지저분해지고, 실수하기가 쉬우며, 실수했을때는 NullPointException이 발생하게 된다. 

java8부터는 이것을 개선하기위해 null이 될 수 있는 객체를 Optional에 넣어서 명시적으로 메소드를 통해 다룰수 있도록 만들었다. 이를 통해 체이닝이 가능해지며 코딩오류 가능성이 줄게 되었다.

(단 람다함수 및 method reference등으로 인해 다소 이해하기 복잡해보일 순 있다)

 

코드예시는 여기 참조

내가 만든 코드 예시는 아래 참조

https://github.com/sevity/problem_solving/blob/main/java/OptionalExample.java

반응형

'Programming > JAVA' 카테고리의 다른 글

reactor #1  (1) 2024.01.01
BiFunction, Function을 사용한 함수형 프로그래밍  (0) 2024.01.01
java enum  (0) 2023.11.03
IntelliJ 팁  (0) 2023.11.03
java공부  (0) 2023.08.12

c++ enum과 다르게 java의 enum은 좀 더 발전된 형태로 상수의 집합이 아닌 타입 자체가 객체이다.

따라서 enum type 마다 속성과 메서드를 가질 수 있으며 생성자로 초기화 한다.

public enum CoffeeSize {
    SMALL(200, 3000),
    MEDIUM(400, 5000),
    LARGE(600, 7000),
    XLARGE(1000, 9000);

    private final int volumeInMl;
    private final long priceInWon;

    CoffeeSize(int volumeInMl, long priceInWon) {
        this.volumeInMl = volumeInMl;
        this.priceInWon = priceInWon;
    }
}

위 예시를 보면 커피 사이즈를 enum으로 SMAL, MEDIUM, LARGE, XLARGE로 나누는데, 각각 볼륨과 가격의 속성을 추가로 가진다.

반응형

'Programming > JAVA' 카테고리의 다른 글

BiFunction, Function을 사용한 함수형 프로그래밍  (0) 2024.01.01
java Optional  (1) 2023.11.04
IntelliJ 팁  (0) 2023.11.03
java공부  (0) 2023.08.12
react/next.js로 만드는 online-judge 사이트 #4 문제관리 프론트엔드  (0) 2023.08.05

 

 

Call Hierachy

Source Insight 처럼 런타임이 아닌데도 콜스택 그래프를 볼 수 있도록 해준다.

오픈소스 분석시 매우 유용하다.

우측 callers of countByProblemId 주목

접근: 메뉴 > 탐색 > 호출계층구조 (Ctrl+Alt+H)

런타임이 아니기 때문에 디버깅시 나오는 콜스택이 외길인데 반해서 콜러가 여러군데로 나오는점이 있으니 분석시 유의.

반응형

time

long currentTime = System.currentTimeMillis();

 

표준입출력처리

leetcode
2
leet code

아래 예시는 위 인풋을 처리할 수 있다.

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()));

 

자료구조 관련

LinkedList 사용하기

헷갈리는 linkedList사용법을 정리한다.

예시문제:
두 개의 정렬된 연결 리스트로부터 중앙값을 계산하십시오.
예시 1:
L1 = 1 -> 3
L2 = 2
중앙값은 2.0입니다.
예시 2:
L1 = 1->2
L2 = 3->4
중앙값은 (2+3)/2 = 2.5입니다.

input1:
2
1 2
1
3
output1:
2.0

input2:
3
1 3 5
3
2 4 6
output2:
3.5

솔루션

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를 사용한 작업
});

 

Queue

이 문제에 대한 솔루션인데, 단순한 BFS로 풀린다.

queue사용법을 눈여겨보자(add, poll, size)

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도 하는..)

top()만 하려면 peek()를 쓰면된다.(아래 샘플엔 없다)

아래 샘플참조..

import java.util.*;

public class StackStringDecoder {
    
    public static String decodeString(String s) {
        Stack<Integer> stack_repeat = new Stack<>();
        Stack<String> stack_str = new Stack<>();
        String ret_str = new String("");
        int repeat = 0;
        for(int i=0;i<s.length();i++){
            char c = s.charAt(i);
            if(Character.isDigit(c)){
                repeat *= 10;
                repeat += c-'0';
            }else if(c=='['){
                stack_repeat.push(repeat);  // push하는 부분
                stack_str.push(ret_str);
                ret_str = "";
                repeat = 0;
            }else if(c==']'){
                String pop_str = stack_str.pop();  // pop하는 부분
                repeat = stack_repeat.pop();
                for(int j=0;j<repeat;j++)
                    pop_str += ret_str;
                ret_str = pop_str;
                //repeat = 0;
            }else{
                assert(Character.isAlphabetic(c));
                ret_str+=c;
            }
        }
        return ret_str;
    }

    public static void main(String[] args) {
        System.out.println(decodeString("3[a2[c]]")); // "accaccacc"
        System.out.println(decodeString("3[a]2[bc]")); // "aaabcbc"
        System.out.println(decodeString("2[abc]3[cd]ef")); // "abcabccdcdcdef"
        System.out.println(decodeString("2[a3[b]]")); // "abbbabbb"
        System.out.println(decodeString("1[ab2[c]]")); // "abcc"
    }
}

char[]

String은 immutable이라 글자단위로 수정하려면 char[]로 변환해주어야 한다.

char[] chars = myString.toCharArray(); //String에서 char[]로 변환
String myString = new String(chars);  //char[]에서 String으로 변환

한번 println의 경우는 다음과 같이 String으로 변환해주지 않으면 주소가 출력된다.

char[] result = {'H', 'e', 'l', 'l', 'o'};
System.out.println(new String(result)); // "Hello"를 출력합니다.

 

리턴값이 2개 필요할때

아래처럼 int[] r = func(); 해서 r[0], r[1]을 쓰면된다.

static int[] parseNumber(String s, int ix) {
    int number = 0;
    while (ix < s.length() && Character.isDigit(s.charAt(ix))) {
        number = number * 10 + (s.charAt(ix) - '0');
        ix++;
    }
    return new int[] {number, ix};
}

public static void main(String[] args) {
    String expression = "12345+6789";
    int ix = 0;

    int[] result = parseNumber(expression, ix);
    int parsedNumber = result[0];
    int nextIndex = result[1];

    System.out.println("Parsed number: " + parsedNumber); // 출력: Parsed number: 12345
    System.out.println("Next index: " + nextIndex);       // 출력: Next index: 5
}

 

String 핸들링

시간과 같이 특정 글자로 split할때는 아래코드 참조하자(아래는 12포맷을 AM, PM글자 떼버리고 24포맷으로 바꾸는 코드이다)

자바는 아예 split()함수가 있어서 오히려 c++보다 수월하다.

public class Main {
    public static void main(String[] args) {
        String inputTime = "07:05:45PM";
        String outputTime = timeConversion(inputTime);
        System.out.println(outputTime); // 출력: 19:05:45
    }

    public static String timeConversion(String s) {
        String[] timeParts = s.substring(0, 8).split(":");
        int hour = Integer.parseInt(timeParts[0]);

        if (s.endsWith("PM") && hour != 12) {
            hour += 12;
        } else if (s.endsWith("AM") && hour == 12) {
            hour = 0;
        }

        return String.format("%02d:%s:%s", hour, timeParts[1], timeParts[2]);
    }
}

참고삼아 c++버전도 기록해둔다.

#include <iostream>
#include <sstream>
#include <iomanip>

std::string timeConversion(const std::string& s) {
    int hour, minute, second;
    char colon, am_pm;
    std::istringstream ss(s.substr(0, 8));
    ss >> hour >> colon >> minute >> colon >> second;

    if (s.find("PM") != std::string::npos && hour != 12) {
        hour += 12;
    } else if (s.find("AM") != std::string::npos && hour == 12) {
        hour = 0;
    }

    std::ostringstream result;
    result << std::setw(2) << std::setfill('0') << hour << ":"
           << std::setw(2) << std::setfill('0') << minute << ":"
           << std::setw(2) << std::setfill('0') << second;

    return result.str();
}

int main() {
    std::string inputTime = "07:05:45PM";
    std::string outputTime = timeConversion(inputTime);
    std::cout << outputTime; // 출력: 19:05:45
    return 0;
}

sort

기본적인 sorting법

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에 대해 정방향 정렬
    }
});
반응형

다음 명령을 통해 next.js 프로젝트를 생성한다.

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? - 추천: 아니오

 

Bootstrap 테마변경

 Bootswatch와 같은 웹사이트에서 무료로 제공하는 Bootstrap 테마를 선택가능

테마적용방법


1. 테마 CSS 파일 다운로드: 먼저, 테마 페이지에 접속. 페이지 상단의 "Download" 버튼을 클릭하고, "bootstrap.min.css" 파일을 다운로드

2. 프로젝트에 CSS 파일 추가: 다운로드한 CSS 파일을 public/css 디렉토리를 만들고 그 안에 CSS 파일을 추가

3. CSS 파일 import: _app.js 파일을 열고, 다음 코드를 추가

import '../public/css/bootstrap.min.css';

 

next.js pages

/create 아래와 같이 문제를 새로 입력한다.(아래는 아직 실제입력,실제출력등 빠진부분들이 많다)

 

/problems 문제 목록을 아래와 같이 출력한다.

(로그인 상태가 아니라서 username이 없을 경우, frontend-service/login 으로 direct 된다)

 

 

 

 

 

 

 

반응형

문제관리 백엔드 서비스는 online-judge프로젝트의 7개 서비스중 3번째 서비스이다.

  1. 인증 서비스 (Backend): 사용자의 회원 가입, 로그인, 로그아웃, 세션 관리 등을 담당
  2. 인증 서비스 (Frontend): 사용자 인터페이스를 제공 (로그인 폼, 회원가입 폼 등)
  3. 문제 관리 서비스 (Backend): 문제의 추가, 삭제, 수정 등을 관리
  4. 문제 관리 서비스 (Frontend): 문제를 보여주고, 문제 추가, 삭제, 수정 등의 인터페이스를 제공
  5. 제출 관리 서비스 (Backend): 사용자의 코드 제출 및 제출 기록을 관리
  6. 제출 관리 서비스 (Frontend): 코드 제출 인터페이스와 제출 기록 확인 인터페이스를 제공
  7. 채점 서비스 (Backend): 제출된 코드를 채점

다른 서비스는 링크를 눌러확인하자.

 

문제관리 백엔드 서비스는 java/vscod가 아닌 kotlin/IntelliJ로 해보기로 했다. (kotlin호환성이 IntelliJ가 훨씬 좋음)

디렉토리 구성은 다음과 같다.

sevity@sevityubuntu:~/workspace/online_judge/problem-service$ tree -I target
.
├── HELP.md
├── log
│   └── application.log
├── mvnw
├── mvnw.cmd
├── pom.xml
├── run.sh
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── sevity
    │   │           └── problemservice
    │   │               ├── controller
    │   │               │   └── ProblemController.kt
    │   │               ├── domain
    │   │               │   ├── Problem.kt
    │   │               │   └── ProblemRepository.kt
    │   │               ├── ProblemServiceApplication.kt
    │   │               └── service
    │   │                   └── ProblemService.kt
    │   └── resources
    │       ├── application.properties
    │       ├── static
    │       └── templates
    └── test
        └── kotlin
            └── com
                └── sevity
                    └── problemservice
                        └── ProblemServiceApplicationTests.kt

18 directories, 13 files

 

문제에 대한 스키마를 다음과 같이 설정했다.

제대로 하려면, 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 = ""
)

JPA로 DB와 연동을 자동화해주는 Repository는 다음과 같이 작성

package com.sevity.problemservice.domain

import com.sevity.problemservice.domain.Problem
import org.springframework.data.jpa.repository.JpaRepository

interface ProblemRepository : JpaRepository<Problem, Long>

 

MVC에서 C에 해당하는 Control 클래스를 아래와 같이 작성

package com.sevity.problemservice.controller

import com.sevity.problemservice.domain.Problem
import com.sevity.problemservice.service.ProblemService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/problems")
class ProblemController(private val problemService: ProblemService) {

    @GetMapping
    fun getAllProblems() = problemService.getAllProblems()

    @GetMapping("/{id}")
    fun getProblem(@PathVariable id: Long): Problem = problemService.getProblem(id)


    @PostMapping
    fun createProblem(@RequestBody problem: Problem): Problem = problemService.createProblem(problem)

    @PutMapping("/{id}")
    fun updateProblem(@PathVariable id: Long, @RequestBody problem: Problem): Problem = problemService.updateProblem(id, problem)

    @DeleteMapping("/{id}")
    fun deleteProblem(@PathVariable id: Long): ResponseEntity<Void> {
        problemService.deleteProblem(id)
        return ResponseEntity<Void>(HttpStatus.NO_CONTENT)
    }

    // Add more methods as needed for CRUD operations
}

여기서 /problems라는 하나의 자원(동사가 아닌명사)에 대해서 GET, POST, PUT, DELETE를 모두 사용하는 RESTFul 권장 패턴을 사용하고 있다. problems로 복수형으로 지칭하는 것도 권장 가이드.

 

실제로 호출해 보는 것은 curl을 써도 되지만, postman을 쓰면 편하게 할 수 있다.

postman은 여기서 다운받고,

실행후 new > HTTP를 통해서 GET, POST, PUT, DELETE를 하나씩 테스트 하면된다.

GET은 문제 전체나 특정 id를 받아올 수 있고 아래처럼 url만 지정하고 SEND하면 된다.(결과에서 200 OK확인)

POST는 문제를 등록하는 과정이고, 

headers 탭에 Key: Content-Type, Value: application/json 을 추가하고

body에서 raw를 선택하고 아래처럼 문제내용을 입력하고 SEND해주면 된다.(결과에서 200OK확인)

PUT은 문제를 업데이트 하는 과정이고 POST와 마찬가지 설정으로 하면 된다.(POST, PUT만 다르고 나머진 동일)

DELETE는 지우고자 하는 문제ID를 url끝에 넣어주기만 하면 되고, 200OK가 아닌 204 No Content가 나오면 성공.

 

MVC에서 M과 C를 연결하는 비즈니스 로직에 해당하는 Service클래스를 아래와 같이 작성

package com.sevity.problemservice.service

import com.sevity.problemservice.domain.Problem
import com.sevity.problemservice.domain.ProblemRepository
import org.springframework.stereotype.Service

@Service
class ProblemService(private val problemRepository: ProblemRepository) {

    fun getAllProblems(): List<Problem> = problemRepository.findAll()
    fun createProblem(problem: Problem): Problem = problemRepository.save(problem)
    fun getProblem(id: Long): Problem = problemRepository.findById(id).orElseThrow { NoSuchElementException("Problem not found") }
    fun updateProblem(id: Long, problem: Problem): Problem {
        val existingProblem = problemRepository.findById(id).orElseThrow { NoSuchElementException("Problem not found") }
        val updatedProblem = existingProblem.copy(
            title = problem.title,
            description = problem.description,
            exampleInput = problem.exampleInput,
            exampleOutput = problem.exampleOutput
        )
        return problemRepository.save(updatedProblem)
    }
    fun deleteProblem(id: Long) {
        val existingProblem = problemRepository.findById(id).orElseThrow { NoSuchElementException("Problem not found") }
        problemRepository.delete(existingProblem)
    }

}

 

트러블슈팅


원격으로 열었더니 ._로 시작하는 중복파일들이 생길 경우

._로 시작하는 파일들은 macOS에서 생성하는 메타데이터 파일들이다. 이 파일들은 macOS 외의 시스템에서는 필요하지 않으며 삭제해도 안전하다. 다음 명령으로 삭제

find . -name '._*' -type f -delete

1회 지운 이후로 문제가 생긴적은 없다.

 

javax.persistance 관련 오류

springboot 버전을 3이상으로 했더니, import javax.persistance 에 실패하는 현상 발견됨

여기 참고해서, pom.xml에서 아래처럼 2.5.3으로 버전을 낮춰서 해결

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

 

 

 

반응형

React/Next.js 환경을 선택했고, 폴더구조는 다음처럼 잡았다(frontend-service부분 주목)

(coin) sevity@raspberrypi:~/workspace/online_judge $ tree -L 2
.
├── auth-service
│   ├── application.log
│   ├── cookies.txt
│   ├── HELP.md
│   ├── mvnw
│   ├── mvnw.cmd
│   ├── pom.xml
│   ├── src
│   └── target
├── frontend-service
│   ├── jsconfig.json
│   ├── next.config.js
│   ├── node_modules
│   ├── package.json
│   ├── package-lock.json
│   ├── public
│   ├── README.md
│   └── src
├── install
│   └── auth-service.zip
└── README.md

8 directories, 13 files

한가지 주목할만한 점은 프론트엔드의 경우 스프링부트 서비스로 만들 필요가 없다는 점이었다.

 

다음 명령어를 통해 node관련 설치(apt-get으로 시스템전역에 node를 설치하지 않고 nvm을 사용하기로 함)

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash
. ~/.bashrc
nvm install node
nvm install 16.8.0

다음명령을 통해 next.js react 설치

npm install --save next react react-dom

 

 

다음 명령어를 통해 Next.js 앱을 생성

(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

 

bootstrap을 통한 디자인 적용

먼저 아래 명령을 통해 프로젝트에 bootstrap을 설치

npm install bootstrap

 Next.js 프로젝트에서 Bootstrap을 사용하려면, _app.js 파일에 Bootstrap CSS를 import해야함.

_app.js 파일을 만들고, 다음 코드를 추가(맨위가 디자인 커스텀이며 나머지 내용은 _app.js를 만든이상 필수로 넣어야함)

_app.js는  모든 페이지에 공통으로 적용되는 컴포넌트를 정의하는 곳으로 전역 상태 관리, 레이아웃, 스타일링, 인증 등과 같이 여러 페이지에서 공유되는 로직을 처리하는 데 사용됨.

import 'bootstrap/dist/css/bootstrap.css'
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />;
}

export default MyApp;

login.js를 만들고 로그인 정보는 브라우저의 local storage를 활용해서 다음과 같이 저장했다.

import { useState, useEffect } from 'react';
import axios from 'axios';

export default function Login() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [userInfo, setUserInfo] = useState(null);

  // 페이지 로드 시 Local Storage에서 로그인 정보 불러오기
  useEffect(() => {
    const storedUserInfo = localStorage.getItem('userInfo');
    const storedIsLoggedIn = localStorage.getItem('isLoggedIn');

    if (storedUserInfo && storedIsLoggedIn === 'true') {
      setIsLoggedIn(true);
      setUserInfo(JSON.parse(storedUserInfo));
    }
  }, []);

  const handleSubmit = async (event) => {
    event.preventDefault();

    try {
      const response = await axios.post('http://sevity.com:9991/login', `username=${username}&password=${password}`, {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      });

      // 서버에서 받은 세션정보를 local storage에 저장
      console.log('Login successful:', response.data);
      setIsLoggedIn(true);
      setUserInfo(response.data);

      // Local Storage에 사용자 정보 저장
      localStorage.setItem('userInfo', JSON.stringify(response.data));
      localStorage.setItem('isLoggedIn', 'true');
    } catch (error) {
      console.error('Login error:', error);
    }
  };
...

  if (isLoggedIn) {
    return (
      <div>
        Welcome, {userInfo}!
        <button onClick={handleLogout}>Logout</button>
      </div>
    );
  }
...
}
반응형

라즈베리파이 환경에서..

일단 DB는 postgreSQL로 세팅했고, DB스키마는 다음으로 설정했다.

-- 데이터베이스 생성
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개로 잡아 보았다. (진행하면서 변경가능, 링크를 누르면 각 서비스에 대한 블로그로 이동가능)

  1. 인증 서비스 (Backend): 사용자의 회원 가입, 로그인, 로그아웃, 세션 관리 등을 담당
  2. 인증 서비스 (Frontend): 사용자 인터페이스를 제공 (로그인 폼, 회원가입 폼 등)
  3. 문제 관리 서비스 (Backend): 문제의 추가, 삭제, 수정 등을 관리
  4. 문제 관리 서비스 (Frontend): 문제를 보여주고, 문제 추가, 삭제, 수정 등의 인터페이스를 제공
  5. 제출 관리 서비스 (Backend): 사용자의 코드 제출 및 제출 기록을 관리
  6. 제출 관리 서비스 (Frontend): 코드 제출 인터페이스와 제출 기록 확인 인터페이스를 제공
  7. 채점 서비스 (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에게 이 클래스가 보안 설정을 담당하는 클래스임을 알림.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/register", "/login").permitAll()
                .anyRequest().authenticated()
            .and()
            .formLogin()
                .loginPage("/login")
                .defaultSuccessURL("/home")
                .permitAll()
            .and()
            .logout()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}

 

 

다음 단계는 UserDetailsService와 UserDetails를 구현(주석참조)

//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명령어로 테스트 할 수 있다.

 

1. 사용자등록

curl -X POST http://localhost:8080/register -H 'Content-type:application/json' -d '{
  "username": "testuser",
  "password": "testpassword"
}'

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만 복사 실행하는 간단한 버전도 가능하다.

FROM openjdk:17
WORKDIR /app
COPY target/auth-service-0.0.2-SNAPSHOT.jar /app
CMD ["java", "-jar", "auth-service-0.0.2-SNAPSHOT.jar"]

버전이 하드코딩된게 단점인데, 아래처럼 보완가능(약간꼼수)

FROM openjdk:17
WORKDIR /app
COPY target/*.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

 

그다음 다음 명령으로 build하면 된다.(맨뒤 .은 생략불가하며 Dockerfile 위치를 나타낸다)

docker build -t auth-service .

실행은 다음처럼 docker run으로 하면 된다.

docker run auth-service

환경변수 설정이 필요한 경우 다음처럼 -e를 쓰면됨

docker run -e "ENV_VAR_NAME1=value1" -e "ENV_VAR_NAME2=value2" my-image

또는 .env파일을 쓴다면 아래와 같이 가능

docker run --env-file .env auth-service

보통은 포트도 연결해야 서비스기능이 가능하니 아래처럼 -p 옵션까지 주자

docker run --env-file .env -p 8080:8080 auth-service

 

하지만 커멘드라인이 길어지니 docker-compose.yml에 다음처럼 기록가능

version: '3.8'
services:
  auth-service:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:  # 아래 환경변수는 .env에 정의하면 연동된다.
      - DATABASE_URL
      - DATABASE_USERNAME
      - DATABASE_PASSWORD

이경우 docker run이 아닌 docker-compose 를 쓰면 된다.

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의 기본 세션 관리 방식은 일반적으로 단일 노드에서 작동하며, 여러 서비스나 여러 인스턴스 간에 세션을 공유하기 어려움. 이는 각 서비스나 인스턴스가 자체적인 세션 저장소를 가지고 있기 때문에 발생하는 문제.

이를 해결하기위해 Redis세션 저장소를 활용할 수 있다.

단계1: Redis설치

sudo apt-get update
sudo apt-get install redis-server

단계2: Spring Session Redis의존성 추가
pom.xml에 다음 내용추가

<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>

단계3: application.properties 파일에 Redis 연결 정보를 추가

spring.redis.host=localhost
spring.redis.port=6379

단계4: 아래 클래스를 config 폴더내 추가

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 *로 확인 가능하다.

sevity@sevityubuntu:~$ redis-cli
127.0.0.1:6379> keys *
1) "spring:session:expirations:1696602840000"
2) "spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:sevity"
3) "spring:session:sessions:expires:c5a0bf12-504a-45e9-b970-fe41e93c3aa3"
4) "spring:session:sessions:c5a0bf12-504a-45e9-b970-fe41e93c3aa3"
127.0.0.1:6379>
반응형

+ Recent posts