문제

임스가 미니게임을 같이할 사람을 찾고 있습니다.

플레이할 미니게임으로는 윷놀이 , 같은 그림 찾기 , 원카드 가 있습니다. 각각 2, 3, 4 명이서 플레이하는 게임이며 인원수가 부족하면 게임을 시작할 수 없습니다.

사람들이 임스와 같이 플레이하기를 신청한 횟수 과 임스가 플레이할 게임의 종류가 주어질 때, 최대 몇 번이나 임스와 함께 게임을 플레이할 수 있는지 구하시오.

임스와 여러 번 미니게임을 플레이하고자 하는 사람이 있으나, 임스는 한 번 같이 플레이한 사람과는 다시 플레이하지 않습니다.

임스와 함께 플레이하고자 하는 사람 중 동명이인은 존재하지 않습니다. 임스와 lms0806은 서로 다른 인물입니다.

입력

첫 번째 줄에는 사람들이 임스와 같이 플레이하기를 신청한 횟수 과 같이 플레이할 게임의 종류가 주어진다. 

두 번째 줄부터 개의 줄에는 같이 플레이하고자 하는 사람들의 이름이 문자열로 주어진다.  문자열 길이 

사람들의 이름은 숫자 또는 영문 대소문자로 구성되어 있다.

출력

임스가 최대로 몇 번이나 게임을 플레이할 수 있는지 구하시오.

 

예제 입력 1 

7 Y
lms0806
lms0806
exponentiale
lms0806
jthis
lms0806
leo020630

예제 출력 1 

4

 

풀이코드

public class B20260213_25757 {
    public static void main(String[] args) throws IOException {

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        int num = Integer.parseInt(st.nextToken());
        String game = st.nextToken();
        int result = 0;

        Set<String> set = new HashSet<>();

        for(int i = 0; i < num; i++) {
            set.add(br.readLine());
        }
        
        if("Y".equals(game)) {
            result = set.size();
        } else if("F".equals(game)) {
            result = set.size() / 2;
        } else if("O".equals(game)) {
            result = set.size() / 3;
        }

        System.out.println(result);

        br.close();

    }
}

 

미니게임을 플레이하고자 하는 사람 중 동일한 사람과는 게임하지 않기 때문에 인원수에 카운팅될 필요가 없다.

중복 방지를 위해 Set 자료구조에 저장하여 신청한 인원수만을 구한 뒤 임스를 제외한 게임에 필요한 인원수로 나누어 
진행할 수 있는 게임의 횟수를 구할 수 있다.

'알고리즘 > 백준' 카테고리의 다른 글

[백준]숨바꼭질 1697  (1) 2024.01.05
[백준]촌수계산 2644  (1) 2024.01.04
[백준]좋다 1253  (0) 2023.12.13
[백준] 배열돌리기4 17406  (1) 2023.12.07
[백준]감시 15683  (2) 2023.12.06

  로그인 처리를 Controller에서 하지 않고 Spring Security에서 동작하도록 설정

.formLogin(form -> form
    .loginProcessingUrl("/login") //리액트에서 POST 요청을 보낼 주소
    .successHandler((request, response, authentication) -> {
        response.setStatus(HttpServletResponse.SC_OK); // 성공 시 200 OK 반환
    })
    .failureHandler((request, response, exception) -> {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 실패 시 401 반환
    })
)

 

리액트와 같은 클라이언트 사이드 렌더링 방식 에서는 서버가 HTML 파일을 반환하지 않고, 상태값으로 반환해야 한다.

 

접근 URL 별 다른 화면을 보여주기 위해 라우터 라이브러리 설치

npm install react-router-dom

 

 

리액트에서는 Route Path = "URL"를 통해 URL별 페이지 분기처리가 가능하다.

 

app.jsx

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Login from './Login';

function App() {
  return (
    <Router>
      <div style={{ padding: '20px' }}>
        <h1>Spring Boot, React 연동</h1>
        <nav>
           <a href="/login">로그인 이동</a> | <a href="/join">회원가입 이동</a>
        </nav>
        <hr />

	//URL 변경에 따라 화면 변경
        <Routes>
          <Route path="/login" element={<Login />} />
          <Route path="/main" element={<h2>메인 페이지입니다.</h2>} />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

route에 의해 /login에 접근 시 Login.jsx 파일의 화면 실행

 

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

function Login() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  const handleLogin = (e) => {
    e.preventDefault(); // 폼 제출 시 페이지 새로고침 방지

    // 스프링 시큐리티는 기본적으로 x-www-form-urlencoded 형식을 기대한다.
    const formData = new FormData();
    formData.append('username', username);
    formData.append('password', password);

    axios.post('http://localhost:8080/login', formData, {
      withCredentials: true // 세션 쿠키를 받아오기 위해 필수
    })
    .then(res => {
        alert("로그인 성공!");
        window.location.reload(); // 성공 후 상태 업데이트를 위해 새로고침
    })
    .catch(err => {
        alert("로그인 실패: 아이디 또는 비밀번호를 확인하세요.");
    });
  };

  return (
    <div style={{ maxWidth: '300px', margin: '50px auto' }}>
      <h2>커스텀 로그인</h2>
      <form onSubmit={handleLogin}>
        <input
          type="text"
          placeholder="아이디"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          style={{ display: 'block', width: '100%', marginBottom: '10px' }}
        />
        <input
          type="password"
          placeholder="비밀번호"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          style={{ display: 'block', width: '100%', marginBottom: '10px' }}
        />
        <button type="submit" style={{ width: '100%' }}>로그인</button>
      </form>
    </div>
  );
}

export default Login;

 

로그인 시 전송 데이터 포맷을 FormData 로 하는 이유 :

스프링 시큐리티의 기본 필터인 UsernamePasswordAuthenticationFilter는 application/x-www-form-urlencoded 형식의 데이터를 기대한다.

그 이외의 타입이나, 변수명이 들어오면 값을 제대로 받지 못해 인증로직을 타지않는다.(별도의 인증 로직 구현 필요)

 

Spring Boot는 API 서버, React는 화면(UI)을 담당하는 구조로 분리하여
프론트엔드와 백엔드를 독립적으로 개발하기 위해 React를 결합하였다.
이 경우 두 서버의 포트가 달라지므로 브라우저 보안 정책에 따라 CORS 에러가 발생한다.

 

1. 리액트 설치 : npm create vite@latest

2. 서버와 연결을 위해 axios 설치 : npm install axios

3. 리액트 실행 : npm run dev

 

Spring Boot 서버에 접근하여 데이터를 받아오기 위해 axios를 임포트하고 코드를 추가한다.

const [sessionInfo, setSessionInfo] = useState("데이터를 가져오는 중...");

  useEffect(() => {
    // 스프링 부트에서 만든 세션 체크 API 호출
    axios.get('http://localhost:8080/check', {
      withCredentials: true // 세션 쿠키를 함께 보내기 위한 필수 옵션!
    })
    .then(response => {
      setSessionInfo(JSON.stringify(response.data));
    })
    .catch(error => {
      setSessionInfo("에러 발생! 콘솔(F12)을 확인하세요.");
      console.error("통신 에러:", error);
    });
  }, []);

 

리액트는 외부 데이터(API) 호출 표준 패턴으로 axios를 사용한다.

 

1. useState 정의 : 리액트는 변수의 값이 변경되어도 자동으로 새로고침 되지 않는다. 

                             setSessionInfo를 통해 값을 변경해야만 화면이 새로고침 된다.

2. useEffect : useEffect 없이 axios.get을 사용하면 화면이 그려질 때 API를 무한 호출

                      useEffect(() => {...}, []); 마지막에 붙는 빈 배열의 의미는 
                      "이 페이지가 처음으로 그려질 때 딱 한 번만 코드를 실행"의 의미를 가진다.

3.axios.get('url', {withCredentials: true}) : url에 get으로 접근한다.

                          withCredentials:true => 리액트가 가진 세션의 정보를 서버에 전달

4. then(response => {}) 서버에서 보낸 JSON 데이터가 리액트의 response 객체로 들어옴

 

 

 

리액트 서버 URL CORS 허용을 위해 아래의 코드를 SecurityConfig에 추가한다.

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();

    // 리액트 서버 주소 허용
    configuration.setAllowedOrigins(List.of("http://localhost:5173"));
    configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    configuration.setAllowedHeaders(List.of("*"));

    // 자격 증명(쿠키/세션) 허용
    configuration.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

 

Spring Security는 자체 필터 체인을 사용하기 때문에,
CORS 설정을 SecurityFilterChain에 직접 등록하지 않으면
브라우저 요청이 Security 단계에서 차단되므로 filterChain 에도 추가해준다

.cors(cors -> cors.configurationSource(corsConfigurationSource()))

 

 

setAllowCredentials(true) 를 사용할 때는 서버 주소를 와일드카드(*)로 사용할 수 없다.

특정 URL을 명시하여야 쿠키 전송이 허용된다.

로그인이 된 사용자 정보는 세션 안의 SecurityContext 에 보관된다.

이를 바로 꺼내 활용하는 방법이다.

 

이전에 만든 사용자 데이터를 가지고 있는 UserDetails의 구현체를 파라미터로 주입받아 사용한다.  

@GetMapping("/userInfo")
@ResponseBody
public String userInfo(@AuthenticationPrincipal CustomUserLogin customUser) {
    
    return customUser.getUsername();
}

 

@AuthenticationPrincipal : SecurityContext에 저장된 Authentication 객체의 principal 부분을 자동으로 캐스팅하여 주입,

별도의 복잡한 코드 없이 파라미터만으로 현재 로그인한 유저의 상세 객체를 불러올 수 있음.

 

용어정리 

Authentication : 사용자 정보가 담긴 데이터 객체

SecurityContext : Authentication 을 담는 보관함 

SecurityContextHolder : 현재 실행중인 스레드 관리자

HttpSession : 브라우저 종료 전까지 서버 메모리에 유지되는 저장소

 

로그인 성공 시 흐름

1. loadUserByUsername 를 통해 가져온 사용자 정보의 인증이 성공하면 Authentication 객체 생성

2. 생성된 Authentication 객체를 SecurityContext에 넣고 SecurityContextHolder 를 통해 현재 작업환경에 등록

3. Spring Security가 SecurityContext를 HttpSession에 저장, 쿠키 발급

 

페이지 이동 시 흐름

1. 다른 페이지 이동 시 가지고 있던 쿠키를 서버로 전송

2. 쿠키를 보고 HttpSession에 SecurityContext이 있는지 확인 후 다시 SecurityContextHolder 에 등록

 

세션 관리 설정

application.properties에

# 세션 유지 시간 설정(30분)
server.servlet.session.timeout=30m   

를 추가하여 세션 유지 시간을 설정할 수 있다.

 

SecurityConfig 세션관련 설정

http	
    .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 세션 생성 전략
        .maximumSessions(1) // 동시 접속 허용 개수 (1이면 중복 로그인 방지)
        .maxSessionsPreventsLogin(false) // true: 신규 로그인 차단, false: 기존 세션 만료
        .expiredUrl("/login?expired=true") // 세션이 만료되었을 때 이동될 페이지
    );
return http.build();

 

세션 생성 전략 (sessionCreationPolicy)

Spring Security가 세션을 언제 만들지 결정한다.

  • ALWAYS: 항상 생성
  • IF_REQUIRED: 필요할 때만 생성 (기본값, 가장 많이 씀)
  • NEVER: 직접 생성하진 않지만 있으면 사용
  • STATELESS: 세션을 전혀 생성하지 않고 사용하지 않음 (JWT 방식에서 사용)

중복로그인 체크 시 로그인한 사용자 정보를 서로 비교하여 같은 사람인지를 판단해야 하지만, 기본적으로는 판단근거가 부족하다.

실제 유저 정보를 가지고있는 UserDetails의 구현체에서 @EqualsAndHashCode(of "id") 의 형식으로 어떤 필드값을 통해 동일인인지 비교하는 로직을 추가해야한다.

 

 

만약 UserDetails 구현체에서 필드값을 받지 않고 user 객체로 받아서 처리하고 있다면 @EqualsAndHashCode(of = "user") 로도 처리가 가능하다. 이러한 경우 엔티티에서 어떤 값을 통해 비교할지 필드명으로 한번 더 제어를 해야한다.

@EqualsAndHashCode(of = "id")
public class User {
    @Id
    @GeneratedValue //AutoIncrement
    @Column
    private Long id;
@EqualsAndHashCode(of = "user")
public class CustomUserLogin implements UserDetails {

    private final User user;

 

Spring Security는 기본적으로 세션 고정 공격을 방어하기 위해 

로그인이 성공하면 기존의 세션을 버리고 신규 세션을 생성하여 브라우저에 새 쿠키를 할당하도록 되어있다.

(http.sessionFixation().changeSessionId())

 

사용자의 비밀번호를 DB에 평문으로 저장하는 것은 보안상 매우 위험하다.

DB가 유출되더라도 실제 비밀번호를 알 수 없도록 단방향 암호화 함수를 이용해 암호화 해야한다.

 

PasswordEncoder 빈 등록

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • BCryptPasswordEncoder: 현재 가장 널리 사용되는 암호화 알고리즘. 암호화할 때마다 같은 비밀번호라도 매번 다른 결과값생성
  • Spring Security에서 기본적으로 BCryptPasswordEncoder를 권장한다.

 

domain 비밀번호 암호화

public void encodePassword(PasswordEncoder passwordEncoder) {
    this.password = passwordEncoder.encode(this.password);
}

Entity의 비밀번호를 암호화해주는 메소드를 도메인에 선언한다.

 

Entity는 단순히 데이터만 담는 바구니가 아니라, 데이터와 그 데이터를 처리하는 메소드를 함께 가지고 있는 객체이다.(캡슐화)

 

Service단에서 암호화 로직을 생성하고 암호화가 필요한 곳에서 사용해도 되지만, 

이는 도메인이 가지고 있는 평문 비밀번호를 외부 Service에서 get해서 암호화 후 다시 set 해줘야하는 번거로움이 있고,

Service에서 암호화가 누락되어 무결성에 위배될 수 있다.

Spring Security에서는 로그인 인증을 통해 사용자를 확인한다.

Spring Security에서 지원하는 로그인 처리의 흐름에 맞춰 사용자 조회 로직을 구현하여 쉽게 로그인 인증 구현이 가능하다.

 

Spring Security의 기본 로그인 동작 흐름이다.

1. 사용자 로그인 요청

2. Spring Security가 username(ID)를 추출

3. UserDetailsService.loadUserByUsername() 호출

4. username으로 조회된 사용자정보를 기반으로 Spring Security의 AuthenticationManager가 내부적으로 비밀번호 검증 및 인증

 

@Service
@RequiredArgsConstructor
// UserDetailsService 빈 등록
public class CustomUserLoginService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {

        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자 없음"));

        return new CustomUserLogin(user);
    }
}

UserDetailsService를 상속받은 클래스에서 위의 메소드 구현 ( 로그인 시 자동으로 호출됨 )

 

JPA Repository를 통해 사용자 조회 개발자가 직접 구현 필요

조회 실패 시 예외처리(UsernameNotFoundException) 필수

'프로젝트 끄적이기' 카테고리의 다른 글

6. 로그인 session  (0) 2026.01.27
5.PasswordEncoder를 이용한 비밀번호 암호화  (0) 2026.01.27
3. 의존성 주입(DI) 방법 비교  (0) 2026.01.22
0. 프로젝트 세팅  (0) 2026.01.21
2. 도메인(Entity) 생성  (0) 2026.01.20

의존성 주입(DI) 방법 비교

Spring 에서는 객체 간 의존성을 직접 생성하지 않고 컨테이너가 대신 주입(DI)해준다.

 

DI의 방식들에 대해 비교해보았다.

 

1. 생성자 직접 생성 

public class UserService {
    private UserRepository userRepository = new UserRepository();
}

 

개발자가 직접 의존성을 주입하며 Spring DI의 장점을 모두 포기한 방식으로 Spring 환경에서는 사용하면 안된다.

(의존성 직접 생성)

 

2. 세터 주입

@Service
public class UserService {

    private UserRepository userRepository;

    @Autowired
    public void setUserRepository(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

 

의존성을 선택적으로 주입할 수 있지만,

객체 생성 이후에 의존성이 주입되므로 실제 사용하기 전까지 시스템이 정상적으로 동작하는 것으로 보이지만,

실제 호출 시점에 NullPointException 발생 위험 있어 코드를 신뢰할 수 없음.

 

3. 생성자 주입 + final 

 

3-1. 기존 생성자 주입 방식

private final UserRepository userRepository;
public UserService(UserRepository userRepository) { // Spring이 밖에서 넣어줌
    this.userRepository = userRepository;
}

3-2 @RequiredArgsConstructor을 사용한 생성자 주입

@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
}

객체가 생성되는 시점에 의존성이 주입되며 생성자 파라미터가 존재하지 않으면 컴파일 단계에서 오류 발생,

생성자가 생성되지 않으면 컴파일조차 되지 않으므로 NPE 발생 위험 낮고 안정적이다.

final 키워드가 붙은 필드만 생성자를 만들어주므로 final 필수

 

Spring에서는 3.생성자 주입을 통해 불변 객체를 만드는 것이 가장 안전하고 권장되는 방식이다.

 

 

application.properties 세팅값 

# JPA가 실행하는 SQL을 콘솔에 출력
spring.jpa.show-sql=true

# 출력되는 SQL을 보기 좋게 포맷팅
spring.jpa.properties.hibernate.format_sql=true

# DDL(create, alter, drop) 자동 생성 전략
# create : 기존 테이블 삭제 후 다시 생성
# update : 변경된 부분만 반영 (운영 환경에서는 주의)
# validate : 테이블 생성, 수정 절대 X, 어플리케이션 시작 시 엔티티 <> DB 스키마 일치 여부만 검증하여 불일치 시 기동 X
# none : DDL 관련 작업 X, 엔티티와 DB 구조가 달라도 실행됨
spring.jpa.hibernate.ddl-auto=update

# 세션 유지 시간 설정
server.servlet.session.timeout=30m  #30분

 

Spring.jpa.show-sql = true를 사용한다면 디버깅에는 좋지만 로그가 많이 쌓이므로 운영 환경에서는 성능 저하가 발생할 수 있다.

도메인은 애플리케이션이 다루는 데이터 구조와 책임을 표시하고, 실제 DB에 저장되는 물리적 데이터 구조

 

package com.project.project1.domain;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.time.LocalDateTime;

@Entity
@Table(name = "T_USER")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User {
    @Id
    @GeneratedValue //AutoIncrement
    private Long id;
    private String username;
    private String password;
    private String email;
    private LocalDateTime createTime;
    private LocalDateTime modifyTime;

    @PrePersist
    protected void onCreate() {
        this.createTime = LocalDateTime.now();
        this.modifyTime = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.modifyTime = LocalDateTime.now();
    }

    public void encodePassword(PasswordEncoder passwordEncoder) {
        this.password = passwordEncoder.encode(this.password);
    }

}

 

1.@Entity

-> JPA Entity라는 것을 의미, 이 클래스를 기반으로 테이블 매핑

 

2.Table()

-> 테이블 명시적 매핑, 생략 시 클래스명으로 테이블 매핑

 

3.Lombok

@Getter : getter 자동 생성 -> Entity는 setter를 열어두지 않음

@NoArgsConstructor(access = AccessLevel.PROTECTED) -> 기본 생성자 protected로 생성
@AllArgsConstructor + @Builder -> 모든 필드를 받는 생성자 생성, Builder 패턴으로 객체 생성 가능

 

Builder 패턴

User user = User.builder()
        .name("남기문")
        .loginId("ngm011")
        .password("1234")
        .email("ngm011@naver.com")
        .createTime(LocalDateTime.now())
        .build();

 

 

4.  @PrePersist, @PreUpdate 

-> Entity의 상태가 변경될 때 호출하여 JPA 내부에서만 사용하는 생명주기 메서드

Entity가 새로 생성될 때, 수정될 때 각각 동작한다.

하지만 모든 Entity마다 공통된 컬럼을 PrePersist, PreUpdate를 사용하여 관리하는 경우 불필요한 코드가 반복된다.

이 때, Auditing를 사용한다면 엔티티를 직접 구현하지 않고 공통 규칙으로 처리할 수 있다.

 

+ Recent posts