비트와 바이트

컴퓨터는 정보를 표현하기 위해 전기 신호를 주고받으며 의사소통을 한다.
전기 신호가 있으면 1, 없으면 0, 이때 0 혹은 1의 값이 데이터의 최소 단위가 되고 이를 비트(bit) 라고 표현한다.
따라서 컴퓨터에 저장된 데이터는 0 혹은 1 2진수로 표현되는 것이다.
하지만 이 비트(bit)만 가지고서는 2가지 밖에 표현하지 못하기 때문에 다른 저장 단위가 필요하게 된다.

그래서 연속된 8bit를 묶어 더 많은 값을 표현할 수 있는 바이트(byte)가 등장하게 된다.
1byte는 컴퓨터의 최소 저장 단위로서8bit를 의미한다. 00101100과 같은 방식으로 총 2^8가지 즉 256가지의 정보를 표현 가능하다.

 

아스키코드 (ASCII)

아스키코드는 우리가 사용하는 인간의 언어와 컴퓨터가 사용하는 언어를 매칭시킨 규칙이라고 할 수 있다. 예를 들어 A1000001 B1000010 과 같이 표현하겠다고 약속한 것이다. 이때 아스키코드는 1바이트의 데이터로 표현 가능하다.

img

그런데 표를 자세히 보면 1바이트 즉 8비트로 표현되는 것이 아니라 7비트만 사용되는 것처럼 보인다.
이는 패리티 비트라는 것 때문인데, 간단히 말하면 데이터의 에러를 감지하기 위해 사용되는 비트이다. (링크 : 패리티 비트란 무엇인가)

결국 이를 제외하면 아스키코드는 7비트로 표현가능하기 때문에 총 2^7개인 128개의 문자를 표현할 수 있다.
하지만 128가지로 영어는 모두 표현 가능 했지만, 전 세계의 다양한 문자를 표현하기는 부족했다.

 

EUC (Extended Unix code)

멀티 바이트를 사용해 한, 중, 일 등의 다양한 언어를 표현하기 위한 인코딩 방식이다.
EUC-KR, EUC-JP, EUC-CN 등 다양한 언어를 표현하기 위해 사용되었지만 한계가 있었다.

EUC-KR로 인코딩한 페이지에 한국어와 일본어가 동시에 있다면? 일본어가 깨져버린다. 반대도 마찬가지.
또한 이모티콘도 표현할 수 없었기 때문에 다른 대안이 필요했다.

 

유니코드

따라서 전 세계 언어의 문자를 정의하기 위한 국제 표준인 유니코드가 등장하게 되었다. (현재까지 약 14만개 문자, 약 2^18비트 필요)

그러나 유니코드 또한 단점이 있는데, 모든 문자를 표현할 수 있지만 메모리 낭비가 너무 심했다. 간단한 문자를 표현하는데도 4바이트씩 필요하게 된 것이다.

간단한 영어는 아스키코드를 사용해 효율적으로 읽고, 다른 언어는 유니코드로 사용하고 싶어서 등장한 것이 대표적으로 UTF-8등의 인코딩 방식들이다.

 

UTF-8 (8-bit Unicode Transformation Format)

다양한 유니코드 인코딩 방식 중 우리에게 익숙한 UTF-8 방식을 살펴보자.
UTF-8은 이름에서도 알 수 있듯이 문자열 집합과 인코딩 형태를 8bit 단위로 한다는 의미를 가지고 있다.

메모리를 효율적으로 사용하기 위해서 문자 하나당 1byte ~ 4byte까지 사용한다.


그렇다면 어떤 문자를 어떤 바이트로 읽을지 어떻게 알 수 있을까?

그것은 첫번째 바이트를 보고 판단할 수 있다.

바이트 수 첫번째 바이트 두번째 바이트 세번째 바이트 네번째 바이트
1 0xxxxxxx - - -
2 110xxxxx 10xxxxxx - -
3 1110xxxx 10xxxxxx 10xxxxxx -
4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

위 표에 xxx 부분이 실제 문자를 나타내기 위한 비트값이 들어가는 부분이다. 예시를 보자면 다음과 같은 형태로 문자를 읽어들이는 것이다.

 

문자 유니코드 코드 포인트 UTF-8 바이트
A U+0041 (0000 0000 0100 0001) 01000001 (0x41) 1btye
U+AC41 (1010 1100 0100 0001) 11101010 10110001 10000001 (0xEA 0xB1 0x81) 3byte


아스키 코드와도 완벽히 호환되고 UTF-16, UTF-32에서 발생하는 엔디언 문제도 없기 때문에 현재 모든 웹페이지의 93%가 UTF-8을 사용하고 있다.

 

엔디안 문제 : 1바이트를 초과하는 데이터를 저장할 때 메모리에 어떤 순서로 저장할 것이냐

'개발자의 기본기 > 컴퓨터 기초' 카테고리의 다른 글

패리티 비트(parity bit)  (0) 2021.08.10

 

먼저 회원가입과 로그인 기본 요구사항은 다음과 같다.

  1. 로그인, 회원가입 전 먼저 휴대폰 인증을 완료한다.
  2. 휴대폰 인증 정보를 통해 가입된 유저인지 판단 후 가입된 유저면 --> 로그인 성공
  3. 가입되지 않은 유저라면 회원가입에 필요한 추가정보 입력페이지로 들어가서 회원가입을 한다.
  4. 로그인이 필요한 페이지는 로그인 하지 않은 사용자는 접근할 수 없어야 한다.

 

 

 

휴대폰 인증을 위해서 Firebase auth 기능을 사용했고, 사용자 로그인과 관리를 위해 Spring security의 기능들을 같이 활용하여 구현했다.

 

전체적인 백엔드 처리 흐름을 먼저 살펴보고 각 단계에서 어떤 코드가 어떤 일을 하는지 자세히 살펴보도록 하자.

1. 먼저 유저가 휴대폰 인증을 완료한 이후 인증완료 버튼을 누르게 되면 로그인을 의미하는 http요청이 서버로 들어온다.
2. 이때 서버에서는 Spring Security의 필터가 이 요청을 가로채 먼저 검증이 필요한 요청인지 확인한다.

3. 검증이 필요한 요청이라면 해당 유저의 로그인 정보를 검증하는 사용자 정의 필터인 JwtFilter가 실행된다.

4. JwtFilter에서 요청 헤더에 담긴 authorization 토큰을 검증하고 유저 정보를 확인하여 authentication객체를 생성한다.

5. 생성된 authentication객체를 controller에 매개변수로 넘겨준다.

6. controller에서 받은 authetication객체에 getPrincipal() 메소드를 활용하면 인증된 유저 객체를 얻을 수 있다.

 


 

 

1. 먼저 유저가 휴대폰 인증을 완료한 이후 '인증확인' 버튼을 누르게 되면 로그인을 의미하는 http요청이 서버로 들어온다.

  • 인증확인 버튼 클릭시
    • 클라이언트 -----> HTTP요청메시지[ GET users/me ] -----> 서버
  • image-20211128211117571

 

 

 

2. 이때 서버에서는 Spring Security의 필터가 이 요청을 가로채 먼저 검증이 필요한 요청인지 확인한다.

  1. ../config/SecurityConfig.java파일 실행
  2. 해당 http 요청 URL이 web.ignoring().antMatchers()에 포함되어 있는지 확인
  3. 포함되어 있지 않다면 모든 http 요청에 대해서 인증을 수행한다. .anyRequest().authenticated()

web.ignoring().antMatchers(): 이 안에 포함된 요청은 인증을 수행하지 않겠다는 의미

.anyRequest().authenticated(): 모든 요청에대해 인증을 수행하겠다는 의미

 

 

 

 

3. 검증이 필요한 요청이라면 해당 유저의 로그인 정보를 검증하는 사용자 정의 필터인 JwtFilter가 실행된다.

  1. .addFilterBefore(authFilterContainer.getFilter() : authFilterContainer에서 현재 수행할 필터를 객체를 받아온다.
  2. authConfig.java 파일을 보면 @profile어노테이션을 통해 application.properties 파일에 있는 spring.profiles.active값을 참조하여 해당 값이 local이라면 테스트 필터인 MockAuthFilter를 생성하고 local이 아니라면 실제 인증을 수행하는 JwtFilter가 생성하도록 되어있다.
  3. authFilterConainer.getFilter()JwtFilter를 반환한다.

 

../config/SecurityConfig.java

...생략
    
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    UserService userService;

    @Autowired
    private AuthFilterContainer authFilterContainer;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable() // CSRF 보호기능 disable
                .authorizeRequests() // 요청에대한 권한 지정
                .anyRequest().authenticated() // 모든 요청이 인증되어야한다.
                .and()
                .addFilterBefore(authFilterContainer.getFilter(),// 커스텀 필터인 JwtFilter를 먼저 수행한다.
                        UsernamePasswordAuthenticationFilter.class)        // 이후 UsernamePasswordAuthenticationFilter 실행
                .exceptionHandling() // 예외처리 기능 작동
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); // 인증실패시처리
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 인증 예외 URL설정
        web.ignoring().antMatchers(HttpMethod.POST, "/users")
                .antMatchers("/")
                .antMatchers("/locations")
                .antMatchers("/locations/**")
                .antMatchers("/courts")
                .antMatchers("/courts/**")
                .antMatchers(HttpMethod.GET, "/games")
                .antMatchers(HttpMethod.GET, "/games/*")
                .antMatchers("/assets/**")
                .antMatchers("/images/**")
                .antMatchers("/favicon.ico")
                .antMatchers("/static/**")
                .antMatchers("/error")
                .antMatchers("/error/**")
                .antMatchers("/swagger-ui/", "/swagger-ui/**", "/swagger-resources/**",
                        "/swagger-ui.html", "/v3/api-docs", "/webjars/**", "/v2/api-docs")
                .antMatchers("/users/nickname/**")
                .antMatchers("/profile/pic")
                .antMatchers("/pages/**")
        //.antMatchers(HttpMethod.GET, "/users/**")
        ;
    }
}

 

../config/auth/AuthConfig.java

...생략

@Configuration
public class AuthConfig {
    @Autowired
    private UserService userService;

    @Bean
    @Profile("local")
    public AuthFilterContainer mockAuthFilter() {
        AuthFilterContainer authFilterContainer = new AuthFilterContainer();
        authFilterContainer.setAuthFilter(new MockAuthFilter(userService));
        return authFilterContainer;
    }

    @Bean
    @Profile("!local")
    public AuthFilterContainer jwtAuthFilter() throws IOException {
        AuthFilterContainer authFilterContainer = new AuthFilterContainer();
        authFilterContainer.setAuthFilter(new JwtFilter(userService, firebaseAuth()));
        return authFilterContainer;
    }
    
...생략

 

...생략

public class AuthFilterContainer {
    private OncePerRequestFilter authFilter;

    public void setAuthFilter(final OncePerRequestFilter authFilter) {
        this.authFilter = authFilter;
    }

    public OncePerRequestFilter getFilter() {
        return authFilter;
    }
}

 

 

4. JwtFilter에서 http요청 헤더에 담긴 authorization 토큰을 검증하고 유저 정보를 확인하여 authentication객체를 생성한다.

  • http 요청 헤더 중 authorization에 담긴 토큰을 인자로 받아 토큰 형식이 올바른지 확인하는 RequestUtil.getAuthrizationToken() 함수를 실행한다.
  • RequestUtil.getAuthrizationToken() 함수는 토큰이 {Bearer TOKEN}형태 라면 TOKEN값만 리턴 해주고 그렇지 않다면 예외를 리턴해준다.
  • 정상적으로 받은 TOKEN값은 firebaseAuth.verifyIdToken() 함수를 통해 유효한 토큰인지 검증을 거치고 유효하지 않다면 예외를 리턴해준다.
  • userDetailsService.loadUserByUsername(decodedToken.getUid()) uid를 통해 저장된 user를 조회하고 UserDetail객체로 가져온다. 이때 유저가 없으면 예외를 리턴해준다.
  • 가져온 유저정보를 가지고 유저, 비밀번호, 권한을 매개변수로 받는 UsernamePasswordAuthenticationToken 객체를 생성하여 authentication변수에 저장한다.
  • SecurityContextHolder.getContext().setAuthentication(authentication) 생성한 authentication객체를 SecurityContextHolder에 넣어준다.

 

../config/auth/JwtFilter.java

...생략

@Slf4j
public class JwtFilter extends OncePerRequestFilter{

    private UserDetailsService userDetailsService;
    private FirebaseAuth firebaseAuth;

    public JwtFilter(UserDetailsService userDetailsService, FirebaseAuth firebaseAuth){

        this.userDetailsService = userDetailsService;
        this.firebaseAuth = firebaseAuth;
    }

    @Override
    protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

        FirebaseToken decodedToken;

        // 토큰을 받아와 검증
        try{

            String header = RequestUtil.getAuthorizationToken((request.getHeader("Authorization")));
            decodedToken = firebaseAuth.verifyIdToken(header);

        } catch (FirebaseAuthException | IllegalArgumentException e){
            // ErrorMessage 응답 전송
            log.info("token verify exception: " + e.getMessage());
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.getMessage() + "\"}");
            return ;
        }

        // User를 가져와 SecurityContext에 저장
        try{
            UserDetails user = userDetailsService.loadUserByUsername(decodedToken.getUid());
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    user, null, user.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);

        } catch(NoSuchElementException | CustomException e){
            log.info("user found exception : " + e.getMessage());
            // ErrorMessage 응답 전송
            response.setStatus(HttpStatus.SC_NOT_FOUND);
            response.setContentType("application/json");
            response.getWriter().write("{\"code\":\"USER_NOT_FOUND\"}");
            return;
        }
        // 요청, 응답시 filter호출
        filterChain.doFilter(request, response);
    }
}

 

../util/RequestUtil.java

package kr.couchcoding.tennis_together.util;

import kr.couchcoding.tennis_together.exception.CustomException;
import kr.couchcoding.tennis_together.exception.ErrorCode;

public class RequestUtil {

    // 헤더값 검증
    public static String getAuthorizationToken(String header){

        // 헤더값에 Authorization 값이 없거나 유효하지 않은경우
        if (header == null || !header.startsWith("Bearer ")){
            throw new CustomException(ErrorCode.INVALID_AUTHORIZATION);
        }

        // parts[0] : bearer, parts[1] : token
        String[] parts = header.split(" ");
        if (parts.length != 2){
            throw new CustomException(ErrorCode.INVALID_AUTHORIZATION);
        }

        // Token return
        return parts[1];

    }
}

 

 

 

#2에서 계속!

 

프로젝트를 하며 Entity를 설계할 때 멤버변수로 기본 자료형이 아닌 참조형 Wrapper 클래스를 사용하는 것이 좋다는 피드백을 받고 자료형을 수정하였다. Wrapper 클래스란 무엇이고 왜 사용하는지 알아보자

 

Wrapper클래스란 기본 자료형을 객체화 한것이다.

즉 다른 참조형 객체와 동일하게 대입 (=) 할때 값복사가 아닌 주소 복사가 일어난다.

 

 

다음과 같이 기본 자료형에 대응되는 Wrapper class들이 있다.

기본형과 Wrapper 클래스의 구별방법은 앞에 대문자가 있으면 Wrapper클래스이다.

 

Primitive Type Wrapper Class
boolean Boolean
byte Byte
char Character
float Float
int Integer
long Long
short Short
double Double

 

primitive Type사용하면 좋은 점

  • 기본형은 산술 연산이 가능(auto unboxing없이)
  • 성능 측면에서 더 좋다

 

 

Wrapper 클래스 사용하면 좋은 점

  1. 데이터 타입을 Object타입으로 변환할 수 있다. (null값이 가능하다)
  2. java.util패키지의 클래스는 객체만 처리하므로 이 경우도 도움이 된다.
  3. Generic(<>) 은 객체만 저장하게된다. 기본 자료형 사용시 자동 boxingunboxing이 일어난다.
  4. 멀티스레딩에서 동기화를 지원하려면 객체가 필요하다.

 

boxing : 기본자료형을 wrapper클래스로 바꾸어 주는것

unboxing : wrapper클래스를 기본 자료형으로 바꿔주는 것

 

 

 

 

웹개발을 할때 항상 사용하지만 늘 알쏭달쏭한... HTTP에 대한 모든 것을 한번 정리해보자!!

 

인터넷 네크워크

 

인터넷네트워크의 흐름

 

IP (INTERNET PROTOCOL)

  • 지정한 IP주소에 데이터 전달
  • 패킷이라는 통신 단위로 데이터 전달
  • 한계점
    • 비연결성 (패킷을 받을 대상이 없거나 서비스 불능 상태인지 판단할 수 없다)
    • 비신뢰성 (패킷의 도착순서, 패킷이 도착했는지 확인할 수 없다)
    • 프로그램 구분 (같은 IP에서 어떤 프로그램과 통신해야 하는지 알 수 없다)

 

TCP (Transmission Control Protocol)

  • IP의 패킷의 한계점을 보완
  • TCP 3 way handshake
    • 출발지와 목적지 간의 연결 상태를 먼저 확인한다.
    • SYN, SYN + ACK, ACK 3단계로 이루어진다.
  • 데이터 전달 보증
    • 데이터를 잘 받았는지 응답을 보내준다.
  • 순서 보장
    • 순서 정보를 패킷에 담아서 보낸다.

 

UDP

  • IP와 거의 같고, PORT 정보 정도만 추가되는 형태이다.
  • HTTP3에서 TCP의 3way handshake 등의 과정을 최적화 하려다보니 최근 주목을 받고있음.

PORT

  • 0 ~ 65535 할당 가능
  • 0 ~ 1023 : 잘 알려진 포트, 사용하지 않는 것이 좋음
  • IP가 아파트라면 PORT는 몇동 몇호 인지

 

 

DNS (Domain Name System)

  • IP주소는 기억하기 어렵다
  • IP주소는 변경될 수 있다
  • 도메인명을 IP주소로 변환해준다
    1. 도메인명으로 접속
    2. DNS서버에서 해당 도메인의 IP주소를 리턴
    3. 해당 IP주소를 가지고 접속

 

 

URI (Uniform resource Identifier)

  • 리소스를 식별하는 유일한 식별자
  • 주로 URL을 의미한다
    • URL (Uniform resource Locator) 리소스의 위치
  • URL문법형식
    • scheme://[userinfo@]host:port/path?query
    • http://www.google.com:443/search?q=hello&hl=ko
    • 프로토콜, 호스트명, 포트, 패스, 쿼리파라미터
    • 포트는 주로 생략가능 (프로토콜마다 주로 사용하는 포트가 있다)

 

 

웹 브라우저 요청 흐름

 

 

HTTP (HyperText Transfer Protocol)

  • HTTP 메시지로 모든것을 전송 가능
    • HTML, TEXT, 이미지, 음성, 영상, 파일, JSON, XML 등 거의 모든 형태의 데이터
  • HTTP/1.1 우리가 주로 사용하는 버전 (HTTP/2, HTTP/3도 점점 증가중)
  •  
  • 클라이언트 서버 구조
    • 클라이언트는 요청, 서버는 응답
    • UI와 사용성은 클라이언트에서 / 로직과 데이터는 서버에서
    •  
  • 무상태 프로토콜 (Stateless)
    • 클라이언트의 상태를 서버가 보존하지 않는다는 의미
    • 가능한 무상태로 설계하는 것이 좋다
    • 상태유지 ex) 로그인 정보 / 무상태 ex) 단순 소개 페이지
      • 상태유지는 브라우저 쿠키서버 세션등을 사용해서 상태 유지한다.
    • 클라이언트 쪽에서 서버가 필요한 정보를 한번에 전송해준다.
    • 서버 확장성이 높다. (scale out)
    • 서버장애발생시
      • 상태유지 (Stateful)
        • 클라이언트 -> 서버1 (항상 서버1 쪽으로 요청해야한다)
        • 통신중 서버1 장애가 발생하면?
        • 클라이언트 -> 서버2 처음부터 다시 요청해야한다.
      • 무상태
        • 통신중 서버1 장애가 발생하면?
        • HTTP 요청 메시지를 그냥 서버2로 전달하면된다.
        •  
  • 비 연결성
    • 서버에 요청해서 응답 받으면 서버와의 연결 종료
    • 서버와의 연결상태를 유지하지 않아서 서버자원 효율적으로 사용가능
    • TCP/IP 연결을 계속 새로 맺는데 걸리는 시간 문제
    • HTTP 지속연결로 문제 해결
      • 기존방식 -> 연결, HTML요청/응답, 종료 | 연결,이미지요청/응답, 종료 ....
      • 지속연결 -> 연결, (HTML, 이미지 등)요청/응답, 종료

 

HTTP 메시지

 

HTTP메시지 구성

  요청메시지 응답메시지
시작라인 HTTP메서드 요청대상 HTTP버전
ex) GET /search?q=hello&hl=ko HTTP/1.1
HTTP버전 HTTP상태코드 이유문구
ex) HTTP/1.1 200 OK
헤더 Host:도메인명
ex) Host: www.google.com
HTTP전송에 필요한 모든 부가정보..
ex) Content-Type: text/html;charset=UTF-8;Content-Length: 3423
바디 있어도 되고 없어도됨. 보통 넣지않는다. 실제 전송 데이터
ex)...

 

 

 


참고

인프런 - 모든 개발자를 위한 HTTP 웹 기본 지식(김영한님)

 

Optional 클래스 사용법

프로젝트를 진행하며 처음으로 Optional이란 클래스를 사용하게 되었다. 사용하면 어떤 것이 좋은지, 어떻게 잘 활용할 수 있는지 알아보고 코드에 적용해보자!!

 

 

Optional 클래스

  • Optional 클래스란 자바 8부터 null값에 대한 처리를 좀 더 깔끔하게 할 수 있도록 추가된 클래스이다. 좀 더 가독성 좋은 코드를 만들기 위해서 사용한다.
  • null 값일 수도 있는 어떤 변수를 Optional 클래스로 감싸주는 방식으로 사용한다.

 

 

사용예시

  1. 유저 객체를 조회해 유저가 있으면 리턴, 없으면 예외를 발생
    • orElseThrow()
      • 객체가 null이면 함수형 인자를 통해 생성된 예외 발생, null이라면 객체를 반환

 

Optional 사용 전

public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    User user = userRepository.findById(username);
    if (user == null){
        return user;   
    }else{
        throw new CustomException(ErrorCode.NOT_FOUND_USER);
    }
}

 

Optional 사용

public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Optional<User> user = userRepository.findById(username);
    return user.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER);
}

 

 

  1. 유저를 조회해 유저가 있으면 활동구분코드 검증, 유저가 없으면 예외발생
    • ifPresentOrElse()
      • 함수형 인자 2개를 매개변수로 받는다.
      • 첫번째 인자는 객체에 값이 존재할 경우 실행된다.
      • 두번째 인자는 객체에 값이 null일 경우 실행된다.

 

public User findById(String uid){
    Optional<User> user = userRepository.findById(uid);
    user.ifPresentOrElse(
        // user객체가 있으면 실행
        findUser -> {
            if (findUser.getActDvCd() != '1')
                throw new CustomException(ErrorCode.DELETED_USER);
        },
        // user 객체가 없으면 실행
        () -> {
            throw new CustomException(ErrorCode.NOT_FOUND_USER);
        });
    return user.get();
}

 

  1. 유저를 조회해 유저가 있으면 유저 프로필 이미지 업데이트, 없으면 예외발생
    • isPresent()
      • 객체에 값이 있으면 True, 없으면 false
    • get()
      • Optional 객체 안에 들어있는 값을 리턴
      • 값이 null일 경우 NoSuchElementException이 발생하기 때문에 값이 있는걸 확인하고 사용하는 것이 좋다.

 

public void uploadImage(String uid, String filePath){
Optional<User> user = userRepository.findById(uid);
    // #1
    if (user.isPresent()){
        user.get().updateUserProfileUrl(filePath);
    }else{
        throw new CustomException(ErrorCode.NOT_FOUND_USER);
    }
}

 

public void uploadImage(String uid, String filePath){
Optional<User> user = userRepository.findById(uid);
    // #2
    user.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER))
                .updateUserProfileUrl(filePath);
}

#1#2는 같은 기능의 코드지만 가독성 측면에서 #2형태로 되도록이면 사용하는 것이 좋다.

 

 

사용 시 주의할 점

  • Optional을 필드의 타입으로 사용하지 말자. Optional은 반환 타입을 위해 설계된 타입이다.
  • orElse() 메서드는 객체가 있던 없던 무조건 메서드의 인자가 평가된다. orElseGet() 메서드는 값이 없을 때만 수행된다. 따라서 NULL값 대신 기본값을 리턴하고 싶을 때 객체의 생성비용이 큰 경우라면 orElseGet()을 사용하자.
  • 빈 컬렉션이나 배열을 반환하는 경우는 Optional을 사용하지말자. (그냥 빈 컬렉션이나 배열을 리턴하는 것이 더 좋다.)

 

 

 

 

 

 

 

 

참고

https://www.latera.kr/blog/2019-07-02-effective-optional/

 

프로젝트에 github actions를 통해 CI/CD를 적용해보았다

먼저 CI/CD란 무엇인가?


CI (continuos integration)

 - 지속적인 통합. 새로운 커밋이 추가될 때 이를 기존코드와 병합하는 작업을 의미한다.

CD (continuos delivery / deployment)

- 지속적인 제공 혹은 지속적인 배포. 병합된 코드를 공유 레포지토리를 넘어 실제 운영 서버에 배포하는 것을 말한다.


작업한 소스 코드를 빌드하고, 저장소에 전달 후 배포까지 하는 과정을 통상적으로 CI/CD라고 부른다.

 

 

CI/CD를 적용하면 다음과 같은 장점이 있다

  • 통합 및 배포를 위한 작업 시간 효율성 증대
  • 소스코드 충돌방지
  • 소프트웨어 버전문제로인한 커뮤니케이션문제 예방

대표적인 툴로는 Jenkins, github actions, Travis 등이 있다.

 

 

github actions

 - github에서 제공하는 CI/CD도구로 배포 뿐만 아니라 다양한 작업들을 자동화 할 수있다.

 

우리의 프로젝트의 백엔드 개발시 workflow를 보면 기능을 개발할 때는 feature/기능 브랜치를 추가하여 개발하고

테스트가 어느정도 끝나면 feature -> develop 브랜치로 소스를 통합한다.

실제 서버에 배포할 때는 develop -> main 브랜치로 올려서 해당 소스를 배포한다.

 

따라서 우리는 develop 브랜치와 main브랜치에 push 명령이 수행될때 배포를 자동화 해주면 좋을 것같다!

(제대로 하려면 develop서버와 main서버를 따로 구성해 각각 배포해야 하지만 일단 여기서는 하나의 서버로 모두 배포하자)

 

 

작업 순서는 간단하다.

- 가장 먼저 CI/CD를 적용할 레포지토리에 .github/workflows/deploy.yml 파일을 생성한다.

- 해당 파일에 다음과 같은 옵션을 사용하여 파일을 작성해준다.

 

 

yml 파일 작성법

on, push

workflow가 실행되는 조건을 의미한다.

 

main, develop 브랜치에 push가 되면 해당 workflow가 수행된다.

# main, develop 브랜치에 push시 동작한다
on:
  push:
    branches: [main, develop]

 

jobs

실제 단계별로 수행할 작업들을 의미한다.

 

build라는 job을 생성하고, 그 아래에 2개의 step이 존재하는 구조이다.
runs-on은 어떤 OS에서 실행될지를 의미한다.
steps의 uses는 어떤 액션을 사용할지 지정한다. 이미 만들어진 액션을 사용할 때 지정한다.

백엔드와 프론트엔드 리포지토리를 체크아웃 한다. with명령으로 원격지 레포지토리에 접근가능하게 해준다.

jobs:
  build:
    # 해당 동작들을 돌릴 OS
    runs-on: ubuntu-latest
    steps:
      # 실제 main branch에 push하면 동작할 내용들

      # dependencies 설치 
      # java 설치
      - uses: actions/setup-java@v2
        with:
          distribution: 'temurin'
          java-version: '11'

      # node 설치
      - uses: actions/setup-node@v2
        with:
          node-version: '14'

      # checkout 하기
      # backend
      - name: checkout backend
        uses: actions/checkout@v2

      # frontend
      - name: checkout frontend
        uses: actions/checkout@v2
        with:
          repository: ssso-pro1/tennis-together-fe
          path: ./frontend

 

run

run 옵션으로 build.sh라는 쉘파일을 실행시켜준다.


build.sh 파일은 npm 설치와 build를 실행하고 체크아웃받은 프론트엔드 소스를 백엔드로 복사해주는 역할을 한다.

      # backend 폴더에 frontend 프로젝트 설치
      # build 하기
      - name: build frontend to resources
        run: |
          sh build.sh
#! build.sh 파일 소스
#!/bin/sh
cd frontend
npm install
npm run build
cd ..
cp -r frontend/build/* src/main/resources/static/

 

배포

      # heroku에 배포하기
      - name: deploy to heroku
        uses: {서버명}
        with:
          heroku_api_key: ${{secrets.HEROKU_API_KEY}}
          heroku_app_name: "tennis-togeter" #
          heroku_email: "{이메일}"

heroku서버에 배포한다. heroku_api_key는 설정에서 따로 세팅해줘야한다.(리포지토리 소유자만 가능!)

 

 

 

다음과 같이 github에서 actions탭을 통해 수행된 빌드 과정과 결과를 확인할 수 있다.

 

deploy.yml 전체 소스코드

name: deploy

# main, develop 브랜치에 push시 동작한다
on:
  push:
    branches: [main, develop]

jobs:
  build:
    # 해당 동작들을 돌릴 OS
    runs-on: ubuntu-latest
    steps:
      # 실제 main branch에 push하면 동작할 내용들

      # dependencies 설치 
      # java 설치
      - uses: actions/setup-java@v2
        with:
          distribution: 'temurin'
          java-version: '11'

      # node 설치
      - uses: actions/setup-node@v2
        with:
          node-version: '14'


      # checkout 하기
      # backend
      - name: checkout backend
        uses: actions/checkout@v2

      # frontend
      - name: checkout frontend
        uses: actions/checkout@v2
        with:
          repository: ssso-pro1/tennis-together-fe
          path: ./frontend


      # backend 폴더에 frontend 프로젝트 설치
      # build 하기
      - name: build frontend to resources
        run: |
          sh build.sh


      # heroku에 배포하기
      - name: deploy to heroku
        uses: {서버명}
        with:
          heroku_api_key: ${{secrets.HEROKU_API_KEY}}
          heroku_app_name: "tennis-togeter" #
          heroku_email: "{이메일}"

프로젝트 소개

프로젝트명 : 테니스 투게더
프로젝트 기간 : 2021.10.12 ~ 2021.11.30
주요 기능

  • 로그인, 회원가입(구글 Oauth 인증, Spring Security 기반 )
  • 테니스장 조회 (kakao지도 api)
  • 경기 모집글 및 댓글 CRUD
  • 모집글 수락 및 거절
  • 친구 등록
  • 게임 한 유저 리뷰 작성
  • 유저 프로필 수정

기술 스택

  • frontend : react, javaScript, html/css
  • backend : java, spring boot, JPA, spring security
  • Database : postgreSQL
  • CI/CD : github actions, heroku
  • etc : firebase storage, firebase oAuth

 

 

카우치 코딩이라는 곳에서 멘토님들의 도움을 받아 다른 개발자분들 함께 6주간 프로젝트를 하나 시작하게되었다!!

프로젝트의 주제는 이름은 "테니스 투게더" 쉽게 말해 테니스를 함께 할 친구를 찾는 사이트이다.

팀원은 프론트 2명 / 백엔드 3명 총 5명이다. 여기서 나는 백엔드 개발 파트를 맡았다.

Java/spring 프로젝트를 거의 3년만에 처음 해보는 것이다 보니 기억도 가물가물하고..

한번도 안써본 기술들도 많아서 해야될 것들이 너무 많다.

직장을 다니면서 병행하려면 정말 부지런히 해야할 것 같다!! 화이팅!!!

 

기능명세서

처음엔 채팅도 있고, 지도로 테니스장 조회하는 것도 있고, Oauth 로그인도 여러개, 친구 신청 및 거절 등등..

수많은 기능들이 있었지만 회의를 할수록 기능을 단순화하고 핵심로직에 집중하려고 했다.

멘토님이 피드백을 통해 기획에서 불필요한 부분을 많이 다듬어 주셔서 기획을 마무리 하는데 많은 도움이 되었다

https://www.notion.so/5856162f5b9e494f8b874d5cc23847bc

 

기능명세서

남은 작업

www.notion.so

 

 

API

테이블의 PK를 복합키로 설정한 부분은 api 설계할 때도 두 개의 key 를 www.xx.com/keyA/keyB 와 같이 보내는 방식으로 설계를 했었는데, 그렇게 하면 URI가 복잡해지고 의미를 식별하기도 어렵다는 피드백을 받았다.

복합키 대신 의미가 없는 대체키를 테이블에 만들어서 그것을 PK로 API설계를 변경하였다.

나중에 JPA Entity 매핑을 하면서 더 느낀 거지만 테이블에 pk를 복합키로 사용하면 뭔가 더 생각해야할게 많아진다..

꼭 필요한 경우가 아니면 사용하지 않는 편이 좋겠다

HTTP 정리

 

https://www.notion.so/API-8817ad4aae54409cbce3f1a89452f2d3

 

API

A new tool for teams & individuals that blends everyday work apps into one.

www.notion.so

 

 

 

ERD

ERD작성은 ERD 클라우드 라는 무료 ERD도구를 사용하였다.

항상 쿼리를 기반으로 데이터를 관리하고 작업을 하고 테이블 조인을 하는 것에 익숙해져 있었고 내가 지금 일하면서 주로 쓰는 방식대로 ERD를 설계 하였는데, JPA를 사용하며 객체와 테이블의 관계를 매핑하다 보니 매끄럽지 못한 부분들이 많았다.

이 부분은 앞으로 공부가 좀 더 필요할 것 같다.

https://www.notion.so/ERD-485b1bf4cd464f2884e3518fff6d1f06

 

ERD

1차 작성

www.notion.so

 

코딩인터뷰 완전분석 (게일 라크만 맥도웰)

 

 

01. 배열과 문자열

문제 1.5

하나 빼기 : 문자열을 편집하는 방법에는 세 가지 종류가 있다. 문자 삽입, 문자 삭제, 문자 교체. 문자열 두 개가 주어졌을 때, 문자열을 같게 만들기 위한 편집 횟수가 1회 이내인지 확인하는 함수를 작성하라.

 

풀어보기

문자열의 등장 횟수를 비교하는 방식으로 접근했지만, 생각해보니 그렇게 되면 등장횟수는 같은데 배열이 다른 문자열들을 고려할 수 없기 때문에 다른 방식으로 접근 해야한다.

 

1. 문자열의 길이를 비교하고, 각 케이스 별로 문자열을 순회하면서 비교하며 판단한다.

먼저 각 문자열의 길이를 비교한다.

두 문자열의 길이 차이가 2이상이면 1회의 편집으로는 절대 같아질 수 없다.

두 문자열의 길이 차이가 1이거나 0이면 서로 다른 문자가 1개만 존재해야한다.

길이가 1차이 나는 경우과 길이가 같은 경우를 나눠서 생각한다.

길이가 같은 경우는 문자열을 하나씩 순회하면서 같은 인덱스의 값을 비교하고 서로 다른 문자열의 개수를 센다.

길이가 1차이 나는 경우는 문자열을 하나씩 순회하면서 같은 인덱스의 값을 비교하고 서로 다른 문자열이 나왔을 경우

길이가 짧은 문자열의 i번째 문자와 길이가 긴 문자열의 i+1번째 문자열의 값을 비교한다. 이때 또 한번 다른 문자열이 등장하면 편집횟수가 1이상이다.

# 1.5 하나 빼기

from collections import Counter

s1 = "zale"
s2 = "plze"

def solution(s1, s2):
    longer = ""
    shorter = ""

    if len(s1) == len(s2):
        pass
    else:
        if len(s1) - len(s2) > 0:
            longer, shorter = s1, s2
        elif len(s1) - len(s2) < 0:
            longer, shorter = s2, s1

    if len(longer) - len(shorter) >= 2:
        return False
    else:
        cnt = 0
        if len(longer) == 0:
            for i in range(len(s1)):
                if s1[i] != s2[i]:
                    cnt += 1
                if cnt >=2:
                    return False
        else:

            for i in range(len(shorter)):
                if cnt == 1:
                    if longer[i+1] != shorter[i]:
                        print(longer[i+1], shorter[i])
                        return False

                if longer[i] != shorter[i]:
                    cnt += 1


    return True


solution("pale", "bake")

해답

1. 삽입과 삭제를 하나로 합치고, 교체연산을 확인한다.

한번의 교체연산으로 같게 만들 수 있다는 의미는 두 문자열이 하나의 문자열만 다르고 모두 같다는 것을 의미한다. 삽입 혹은 삭제 연산으로 같게 만들 수 있다는 의미는 짧은 문자열 특정 위치에 공백을 포함하면 나머지 문자들은 모두 같다는 것을 의미한다. 이 중 어떤 연산을 사용할 것인지는 두 문자열의 길이를 보고 판단 할 수 있다.

두 문자열의 길이가 같다면 교체 연산, 두 문자열의 길이가 다르면 삽입 혹은 삭제 연산을 통해 다른 문자가 몇번 등장하는지 확인한다.

코딩인터뷰 완전분석 (게일 라크만 맥도웰)

 

 

01. 배열과 문자열

문제 1.4

회문 순열 : 주어진 문자열이 회문의 순열인지 아닌지 확인하는 함수를 작성하라. 회문이란 앞으로 읽으나 뒤로 읽으나 같은 단어 혹은 구절을 의미하며, 순열이란 문자열을 재배치하는 것을 뜻한다. 회문이 꼭 사전에 등장하는 단어로 제한될 필요는 없다.

 

풀어보기

1. 각 문자의 등장 횟수를 구하고 홀수 번 반복되는 문자가 2개 이상이면 회문이 아니라고 판단한다.

문제를 다시 쉽게 해석해보면 예를 들어 "토토마" 라는 문자가 주어졌을 때 이를 재배치 하여 "토마토" 라는 회문을 만들 수 있는지 없는지 확인하는 문제이다.

먼저 대문자와 소문자를 구별할 것인지, 공백은 무시할 것인지 아니면 하나의 문자로 볼 것인지 확인하는 것이 좋아보인다. 일단 대소문자와 공백을 무시한다고 가정하고 문제를 풀어보자.

회문의 특징은 문자열의 등장 횟수를 count했을 때 홀수번 반복되는 문자가 1개 이거나 혹은 0개 이어야 한다.

# 1.4 회문순열
from collections import Counter

s = "Tact Coab"

def solution(s):
    s = s.lower() # 소문자로 치환
    s = s.replace(" ", "") # 공백제거
    s_list = list(s)
    odd_cnt = 0

    counter = Counter(s_list)

    for x in counter:
        if counter[x] % 2 != 0:
            odd_cnt += 1
    if odd_cnt > 1:
        return False
    else:
        return True
solution(s)

# -결과----------------------------------------------------------------------------------
# False

해답

1. 각 문자가 몇 번 등장했는지 세고, 홀수 번 등장한 문자가 한 개 이상인지 확인한다.

한 번 순회하며 각 문자열의 등장 횟수를 세고, 이후 각 문자마다 몇번 등장 했는지 확인하며 홀수 번 등장한 문자가 몇개인지 확인한다.

O(N)의 시간 복잡도가 소요 된다. 충분히 좋은 알고리즘 이지만 최선의 방향을 찾는 노력은 중요하다. 좀 더 개선할 수 있는 방법을 생각해보자.

같은 아이디어 이지만 내 풀이는 홀수개의 문자열이 몇 개인지 다 센후 검증하지만, 우리는 2개 이상인지만 확인하면 되기 때문에 홀수 개 문자열이 2개 이상 등장하는 순간 바로 프로그램을 종료할 수 있다.

// 회문순열인지 판단하는 함수
boolean isPermutationOfPalindrome(String phrase){
    int [] table = buildCharFrequencyTable(phrase);
    return checkMaxOneOdd(table);
}

boolean checkMaxOneOdd(int[] table){
    boolean foundOdd = false;
    for (int count : table){
        if (count % 2 == 1){
            if (foundOdd) {
                return false; // 홀수개 문자가 2개 이상 나오는 순간 false
            }
            foundOdd = true;
        }
    }
    return true ;
}

// 문자에 해당하는 숫자를 리턴한다. a=1, b=2 ...
int getCharNumber(Character c){
    int a = Character.getNumericValue('a');
    int z = Character.getNumericValue('z');
    int val = Character.getNumericValue(c);

    if (a <= val && val <= z){
        return val - a;
    }
    return -1;
}


// 각 문자가 몇 번씩 등장하는지 기록하여 저장하는 함수
int [] buildCharFrequencyTable(String phrase){
    int [] table = new int[Character.getNumericValue('z') - Character.getNumericValue('a') + 1]; // a~z 길이만큼 배열 생성

    for (char c : phrase.toCharArray()){
        int x = getCharNumber(c);
        if (x != -1){
            table[x]++;
        }
    }
    return table
}

2. 1번과 같지만, 홀수 개 인지 여부를 문자열을 순회하면서 동시에 확인한다.

루프를 2번에서 1번으로 줄일 수 있다. 그러나 각 문자마다 수행되는 코드가 더 많아져 성능이 향상되었다고 얘기할 수는 없다.

 

 

3. 등장 횟수를 세지 않고, 문자열이 등장할 때마다 해당 위치의 비트값을 바꿔주며 홀수, 짝수 여부를 판단한다.

시간 복잡도는 O(N)으로 똑같다. 그러나 정수 하나만 가지고 확인할 수 있기 때문에 메모리는 확실히 효율적이다.!

boolean isPermutationOfPalindrome(String phrase){
    int bitVector = createBitVector(phrase);

}

int createBitVector(String phrase){
    int bitVector = 0;
    for (char c : phrase.toCharArray()){
        int x = getCharNumber(c);
        bitVector = toggle(bitVector, x);
    }
}

// 정수의 i번째 비트값을 바꾼다.
int toggle(int bitVector, int index){
    if (index < 0 ) return bitVector;

    int mask = 1 << index;
    if ((bitVector * mask) == 0){
        bitVector |= mask;
    }else{
        bitVector &= ~mask;
    }
    return bitVector;
}

boolean checkExactlyOneBitSet(int bitVector){
    return (bitVector & (bitVector - 1)) == 0;
}

비트벡터 개념과 비트연산자 문법 정리..!

코딩인터뷰 완전분석 (게일 라크만 맥도웰)

 

 

01. 배열과 문자열

문제 1.3

URL화 : 문자열에 들어 있는 모든 공백을 '%20'으로 바꿔 주는 메서드를 작성하라. 최종적으로 모든 문자를 다 담을 수 있을 만큼 충분한 공간이 이미 확보되어 있으며 문자열의 최종 길이가 함께 주어진다고 가정해도 된다.

풀어보기

1. 문자열 순회하면서 이어붙이기

단순하게 문자열을 하나씩 확인하며 공백일 때는 '%20', 공백이 아닐 때는 기존 문자열을 덧붙여서 새로운 결과 문자열을 만들어 리턴한다.

# 1.3 URL화
s = "Mr John Smith"

def toURL(str):
    newS = ""
    for x in s:
        if x == ' ':
            newS += "%20"
        else:
            newS += x
    return newS

print(toURL(s))

# -결과----------------------------------------------------------------------------------
# Mr%20John%20Smith

수정된 풀이(하나의 문자열로 편집하여 리턴)

# 1.3 URL화
s = "Mr John Smith"

def toURL(str):
    n = len(str)

    # 마지막 부터 첫번째까지 순회
    for i in range(len(str)-1, 0-1, -1):
        if str[i] == ' ':
            str = str[:i] + "%20" + str[i+1:]

    return str

print(toURL(s))
# -결과----------------------------------------------------------------------------------
# Mr%20John%20Smith

해답

문제에 정확히 나오지 않았지만 주의해야할 사항은 이 문제는 새로운 문자열을 만들어 변환 하는 것이 아니라, 기존의 문자열을 가지고 조작하여 풀어야 하는 문제!

따라서 문자열을 앞에서 부터 편집하는 것이 아니라, 뒤에서 부터 편집한다. 앞에서 부터 편집하면 아직 편집해야 할 문자가 덮어쓰여질 우려가 있기 때문이다.

 

1. 공백의 갯수를 세고, 문자열을 뒤에서부터 거꾸로 편집하며 복사해나간다. 

void replaceSpaces(char [] str, int trueLength){    // truelength는 주어진 문자열의 길이
    int spaceCount = 0, index, i = 0;
    for (i = 0; i < trueLength; i++){
        if (srt[i] == ' '){
            spaceCount++;        // 공백의 숫자를 센다
        }
    }

    index = trueLength + spaceCount * 2; // 문자열의 길이에다가 URL화로 인해 치환될 문자열의 길이를 더한 값이 index

    if (trueLength < str.length){ // 만약 주어진 문자열의 길이가 실제 문자열의 길이보다 작다면 중간에 종료
        str[trueLength] = '\0'
    }
    // str[trueLength] -> str[index] 로 문자열 복사
    for (i = trueLength - 1; i>=0; i--){
        if (str[i] == ' '){
            str[index - 1] == '0';
            str[index - 2] == '2';
            str[index - 3] == '%';
            index = index-3;
        }
        else{
            str[index - 1] = str[i];
            Index--;
        }
    }
}

+ Recent posts