1.  AJax와 JASON : 댓글 기능 구현 

기존 작업한 게시판에 댓글 기능을 구현
클라이언트는 Ajax(Axios 라이브러리) · 서버는 RESTful · JPA는 다대일 방식으로 구현


(1)  Ajax와 REST 방식의 이해

 

1)  Ajax Asynchronous JavaScript And XML


  💫  Ajax 방식은 브라우저에서 서버를 호출하지만 모든 작업이 브라우저 내부에서 이루어지기 때문에 현재 브라우저의 브라우저 화면 변화 없이 서버와 통신할 수 있음

 

    ✓  실제 구현은 자바 스크립트를 이용해서 XML을 주고 받는 방식을 이용
    ✓  최근에는 JSON을 이용하는 방식을 더 선호. 스프링 부트는 Springweb을 추가하면 자동으로 관련 라이브러리를 받음

  💫  클라이언트 중심의 개발

    ✓  Ajax가 가져온 변화는 모바일에서도 Ajax 방식으로 데이터를 교환할 수 있기 때문에 활용이 커짐
    ✓  모바일에서도 일반 웹과 마찬가지로 서버의 데이터가 필요한데 이때 화면과 관련된 부분은 필요하지 않기 때문에 서버에서 순수한 데이터만 전송하는 방식이라면 클라이언트의 구현이 웹 / 앱에 관계없이 데이터를 재사용할 수 있음

 

 

2)  JSON 문자열

 

    📍 "서버에서 순수한 데이터를 보내고 클라이언트가 적극적으로 이를 처리한다"라는 개발 방식에서 핵심은 '문자열'

  • '문자열'은 어떠한 프로그래밍 언어나 기술에 종속되지 X
  • 문자열을 이용하면 데이터를 주고 받는 것에 신경 써야 하는 일은 없지만, 문자열로 복잡한 구조의 데이터를 표현하는데 문제가 발생
  • 문자열로 복잡한 데이터를 표현하기 위해서 고려되는 것이 XML과 JSON 이라는 형태.
  • JSON은 자바스크립트 문법에 맞는 문자열로 데이터를 표현하기 때문에 클라이언트에서 어떤 기술을 이용하든 공통적으로 인식할 수 있음
  • 스프링 부트 역시 JSON 관련 라이브러리 jackson-databind가 이미 포함되어 있으므로 별도의 설정 없이 바로 JSON 데이터를 만들어 낼 수 있음

 

3)  REST 방식

 

  💫  html이 아니라 json or xml 형식이 response에 사용

    ✓  REST 방식은 클라이언트 프로그램인 브라우저나 앱이 서버와 데이터를 어떻게주고 받는 것이 좋을지에 대한 가이드
    ✓  예전의 웹 개발 방식에서는 특정한 URL이 원하는 '행위나 작업'을 의미하고, GET / POST 등은 데이터를 전송하는 위치를 의미
      Ajax를 이용하면 브라우저의 주소가 이동할 필요 없이 서버와 데이터를 교환할수 있기 때문에 URL은 '행위나 작업'이 아닌 '원하는 대상' 그 자체를 의미하고, GET / POST 방식과 PUT / DELETE 등의 추가적인 전송방식을 활용해서 '행위나 작업'을 의미하게 됨

 

이전 표현 REST 방식 표현
/board/modify  ▶️  게시물의 수정 (행위 / 목적)
<form>  ▶️  데이터의 묶음
 /board/123  ▶️  게시물 지원 자체
PUT 방식  ▶️  행위나 목적



4)  REST 방식의 URL 설계

Method URI 의미 추가 데이터
GET /board/123 123번 게시물 조회  
POST /board/ 새로운 게시물 등록 신규 데이터 게시물
PUT /board/123 123번 게시물 수정 수정에 필요한 데이터
DELETE /board/123 123번 게시물 삭제  

 

 

5) springdoc-openapi 준비

 

  ⚡️  REST 방식의 테스트는 특별한 화면을 구성하는 것이 아니라 데이터를 전송하고 결과를 확인하는 방법
          ➡️  예를 들어 브라우저는 GET 방식의 데이터를 확인할 때는 유용하지만 POST 방식으로 데이터를 처리할 때는 상당히 불편하고, 사용자도 측정한 경로를 어떻게 호출하는지 알 수 없으므로 상세한 정보를 전달하기 어려운 단점이 있음
  ⚡️  REST 방식을 이용할 때는 전문적으로 API를 테스트 할 수 있는 Postman이나 springdoc-openapi 등을 이용

 

Swagger UI
  • 부트 2까지는 Swagger UI를 사용. 부트 3부터는 Swagger UI사용이 안되고 springdoc-openapi를 지원
  • openapi는 개발할 때 어노테이션 설정으로 API 문서와 테스트 할 수 있는 화면을 생성할 수 있으므로 개발자는 한번에 테스트 환경까지 구성할 수 있다는 장점이 있음

(2)  프로젝트의 준비

1)  modelMapper 설정 변경

  • RootConfig의 modelMapper 설정을 변경. STRICT ▶️ LOOSE로 변경
@Configuration
public class RootConfig {
    @Bean
    public ModelMapper getMapper() {
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration()
            .setFieldMatchingEnabled(true)
            .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
            .setMatchingStrategy(MatchingStrategies.LOOSE);
       return modelMapper;
    }
}

 

 

2) bulid.gradle 설정 추가

  • 프로젝트의 bulid.gradle 파일에 'OpenAPI Starter WebMVC UI'으로 검색한 라이브러리를 추가
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

 

  • 서버 실행을 하고 아래 주소로 접근
    http://localhost:8080/swagger-ui/index.html

 

3) Config 클래스 추가

  • 프로젝트의 config 패키지에 SpringdocOpenapiConfig 클래스를 추가
  • 일반 컨트롤러와 REST 컨트롤러를 분리해서 설정
  • 경로에 /api가 포함된 컨트롤러의 경우에는 REST API로 인식
  • 경로에 /api가 포함안된 컨트롤러의 경우에는 COMMON API로 인식
@Configuration
public class SpringdocOpenapiConfig {

    @Bean
    public GroupedOpenApi restApi() {
        return GroupedOpenApi.builder()
                .pathsToMatch("/api/**")
                .group("REST API")
                .build();
    }

    @Bean
    public GroupedOpenApi commonApi() {
        return GroupedOpenApi.builder()
                .pathsToMatch("/**/*")
                .pathsToExclude("/api/**/*")
                .group("COMMON API")
                .build();
    }
}

 

SampleJasonController 경로 수정
@Log4j2
@RestController
public class SampleJSONController {

    @GetMapping("/api/helloArr")
    public String[] helloArr() {
        log.info("HelloArr...");

        return new String[] {"AAA", "BBB", "CCC"};
    }
}

SampleController에 @Operation 추가
@Operation(summary = "hello")
@GetMapping("/hello")
public void hello(Model model) {
    log.info("hello...");
    model.addAttribute("msg", "Hello World");
}

@Operation(summary = "ex/ex1")
@GetMapping("/ex/ex1")
public void ex1(Model model) {
    List<String> list = Arrays.asList("AAA", "BBB", "CCC", "DDD");

    model.addAttribute("list", list);
}


 

  ⚡️  현재 설정은 기본적으로 가장 많이 사용하는 패키지를 기반으로 패키지의 모든 클래스에 대해서 API 테스트 환경을 만들어 냄

 

 

 

 ✓  화면의 board-controller를 선택하면 다음과 같이 메서드를 선택할 수 있는 화면이 나옴

✓  특정한 메서드를 선택하고 'Try it out' 버튼을 클릭하면 파라미터를 입력할 수 있음

✓  파라미터를 입력하고 'Execute'를 클릭하면 아래에서 결과 화면을 볼수 있음

 

 

 

 

 

 

 

정적 파일 경로 문제


  ✓  Swagger UI 설정이 끝나면 기존에 동작하던 /board/list/ 에는 문제가 생길 수 있음

  ✓  이 문제를 해결하려면 Spring Web 관련 설정을 추가해 주어야만 함
  ✓  config 폴더에 CustomServletConfig 클래스를 추가

 

  • 클래스에 @EnableWebMvc 어노테이션을 추가하는 것이 가장 중요
  • Swagger UI 가 적용되면서 정적 파일의 경로가 달라졌기 때문인데 이를 CustomServletConfig로 WebMvcConfigurer 인터페이스를 구현하도록하고 addResourceHandlers를 재정의해 수정
@Configuration
@EnableWebMvc
public class CustomServletConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
        registry.addResourceHandler("/fonts/**").addResourceLocations("classpath:/static/fonts/");
        registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
        registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/static/assets/");
    }
}

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  메인 스레드와 백그라운드 스레드

프로세스(process)란?

 

  🚀  프로세스(process)란 단순히 실행 중인 프로그램(program)으로 사용자가 작성한 프로그램이 운영체제에 의해 메모리 공간을 할당받아 실행 중인 것을 말함
  🚀  이러한 프로세스는 프로그램에 사용되는 데이터와 메모리 등의 자원 그리고 스레드로 구성

 

스레드(thread)란?


  🚀  스레드(thread)란 프로세스(process) 내에서 실제로 작업을 수행하는 주체를 의미
         ➡️  모든 프로세스에는 한 개 이상의 스레드가 존재하여 작업을 수행
  🚀  두 개 이상의 스레드를 가지는 프로세스를 멀티스레드 프로세스(multi-threaded process)라고 함

 

  📍  앱이 처음 시작될 때 시스템이 스레드 하나를 생성하는데 이를 메인 스레드라고 함
     

    ⚡️  메인 스레드의 역할

  • 액티비티의 모든 생명 주기 관련 콜백 실행을 담당
  • 버튼, 에디트텍스트와 같은 UI위젯을 사용한 사용자 이벤트와 UI드로잉 이벤트를 담당. UI 스레드라고도 불림.

작업량이 큰 연산이나, 네트워크 통신, 데이터베이스 쿼리 등은 처리에 긴 시간이 걸림. 이 모든 작업을 메인 스레드의 큐에 넣고 작업하면 한 작업의 처리가 완료될 때까지 다른 작업을 처리하지 못하기 때문에 사용자 입장에서는 마치 앱이 먹통이 된 것처럼 보이게 됨. 또, 몇 초 이상 메인 스레드가 멈추면 '앱이 응답하지 않습니다.'라는 메시지를 받게 됨

  📍  백그라운드 스레드를 활용하면 이러한 먹통 현상을 피할 수 있음. * 백그라운드 스레드 = 워커 스레드
      ✓  메인 스레드에서 너무 많은 일을 처리하지 않도록 백그라운드 스레드를 만들어 일을 덜어주는 것
      ✓  백그라운드 스레드에서 복잡한 연산이나, 네트워크 작업, 데이터베이스 작업 등을 수행
      ✓  주의할 점은 '백그라운드 스레드에서는 절대로 UI 관련 작업을 하면 안 된다는 점
            ➡️  각 백그라운드 스레드가 언제 처리를 끝내고 UI에 접근할지 순서를 알 수 없기 때문에 UI는 메인 스레드에서만 수정할 수있게 한 것. 따라서 백그라운드 스레드에서 UI 자원을 사용하려면 메인 스레드에 UI 자원 사용 메시지를 전달하는 방법을 이용해야 함

    📍  UI 스레드에서 UI 작업을 하는데 Handler 클래스, AsyncTask 클래스, runOnUiThread() 메서드 등을 활용할 수 있음

 


1)  runOnUiThread() 메서드

  🚀  runOnUiThread()는 UI 스레드(메인 스레드)에서 코드를 실행시킬 때 쓰는 액티비티 클래스의 메서드

Activity.java에 정의된 메서드
public final void runOnUiThread(Runnable action) {
    if (Thread.currentThread() != mUiThread) {
        mHandler.post(action);
    } else {
        action.run();
    }
}

 

  ✓  if문을 살펴보면, 만약 현재 스레드가 UI 스레드가 아니면 핸들러를 이용해 UI 스레드의 이벤트큐에 action을 전달 post
  ✓  만약 UI 스레드이면 action.run()을 수행. 즉 어떤 스레드에 있던지 runOnUiThread() 메서드는 UI스레드에서 Runable 객체를 실행

 

  📍  다음과 같은 UI 관련 코드를 runOnUiThread()로 감싸주어 사용

runOnUiThread(object : Runnable {
    override fun run() {
        // 여기에 원하는 로직을 구현
    }
})

 

   📍  코틀린의 SAM Single Abstract Method를 사용하면 더 간단하게 표현

runOnUiThread {
    // 여기에 원하는 로직을 구현
}

 


2.  스톱워치 만들기

 

1)  기본 레이아웃 설정

colors.xml과 strings.xml 설정
  • 스톱워치에서 4가지 색상과 3가지 문자열을 사용
  • [app] - [res] - [values] colors.xml파랑, 빨강, 노랑을 추가
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#FFBB86FC</color>
    <color name="purple_500">#FF6200EE</color>
    <color name="purple_700">#FF3700B3</color>
    <color name="teal_200">#FF03DAC5</color>
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>

    <!-- 직접 추가한 색 -->
    <color name="blue">#603CFF</color>
    <color name="red">#FF6767</color>
    <color name="yellow">#E1BF5A</color>
</resources>

 

  • [app] - [res] - [values] strings.xml에서 '시작', '일시정지', '초기화' 문자열을 추가
<resources>
    <string name="app_name">thread_0529</string>

    <!-- 추가한 문자열 -->
    <string name="start">시작</string>
    <string name="pause">일시정지</string>
    <string name="refresh">초기화</string>
</resources>

 

 

버튼 추가
  • [초기화]와 [시작] 버튼 생성
     <Button
        android:id="@+id/btnStart"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="80dp"
        android:backgroundTint="@color/blue"
        android:padding="20dp"
        android:text="@string/start"
        android:textColor="@color/white"
        android:textSize="16sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/btnRefresh"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="50dp"
        android:backgroundTint="@color/yellow"
        android:padding="20dp"
        android:text="@string/refresh"
        android:textColor="@color/white"
        android:textSize="16sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toTopOf="@+id/btnStart"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

 

 

텍스트뷰 추가
  • 흐르는 시간을 표현해줄 텍스트뷰를 생성
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/tvMinute"
android:text="00"
android:textSize="45sp" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/tvSecond"
android:text=":00"
android:textSize="45sp" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/tvMillisecond"
android:text=".00"
android:textSize="30sp" />

 

  • 레이아웃 미리보기 창에서 텍스트뷰들을 드래그하여 원하는 위치로 놓아줌

 

  📍 수직 방향 제약 추가하기

  • 세 텍스트 뷰의 수직 방향에 제약을 추가 ▶️  초를 나타내는 텍스트뷰를 중심으로 왼쪽에는 분을 나타내는 텍스트뷰, 오른쪽에
    는 밀리초를 나타내는 텍스트 뷰를 위치
  • 먼저 초 텍스트뷰의 위쪽을 레이아웃의 위쪽에 초 텍스트 아래쪽을 [초기화] 버튼의 상단에 드래그 ▶️ 그럼 텍스트뷰가 상하 제약의 중간 지점에 놓임
  • 분 텍스트뷰와 밀리초 텍스트뷰를 일직선 위에 놓이도록 제약을 추가.

         모든 텍스트뷰를 일직선 위에 놓으려면 베이스라인을 사용

              분 텍스트뷰 위에서 마우스 우클릭 -> [Show Baseline]을 선택. 그럼 텍스트의 아래쪽에 베이스라인 막대가 보임.분

              텍스트뷰 막대를 초 텍스트뷰 막대 모양에 드래그. 밀리초 텍스트뷰 역시 똑같은 방법으로 베이스라인을 정렬.

 

 

  📍 수평 방향 제약 추가하기

 

      ⚡️  컨스트레인트 레이아웃에는 뷰 여러 개의 수직 또는 수평 여백을 손쉽게 관리하는 체인 Chain을 제공

  • Ctrl 키를 누른 상태에서 세 텍스트뷰를 모두 클릭하여 선택
  • 선택한 텍스트뷰 위에서 마우스 우클릭 후, [Chains] - [Create Horizontal Chain]을 선택
  • 그럼 다음과 같이 세 텍스트뷰가 수평 방향으로 균등한 여백을 두고 위치

 

  • 딱 붙어있어야 되는 경우 세 텍스트뷰 위에서 마우스 우클릭 후, [Chains] - [Horizontal Chain Style] - [packed] 선택


 

2)  버튼에 이벤트 연결하기

MainActivity.kt
class MainActivity : AppCompatActivity(), View.OnClickListener {
    var isRunning = false // 실행 여부 확인용 변수

    private lateinit var btnStart: Button
    private lateinit var btnRefresh: Button
    private lateinit var tvMinute: TextView
    private lateinit var tvSecond: TextView
    private lateinit var tvMillisecond: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 뷰 가져오기
        btnStart = findViewById(R.id.btnStart)
        btnRefresh = findViewById(R.id.btnRefresh)
        tvMinute = findViewById(R.id.tvMinute)
        tvSecond = findViewById(R.id.tvSecond)
        tvMillisecond = findViewById(R.id.tvMillisecond)

        // 버튼별 OnClickListener 등록
        btnStart.setOnClickListener(this)
        btnRefresh.setOnClickListener(this)
    }

    override fun onClick(p0: View?) {
        when(p0?.id) {
            R.id.btnStart -> {
                if(isRunning) {
                    pause()
                } else {
                    start()
                }
            }
            R.id.btnRefresh -> {
                refresh()
            }
        }
    }

    private fun start() {
        // 스톱워치 측정을 시작하는 로직
    }

    private fun pause() {
        // 스톱워치 측정을 일시정지하는 로직
    }

    private fun refresh() {
        // 초기화하는 로직
    }
}

 

  ✓  클릭 이벤트를 처리하는 View.OnClickListener 인터페이스를 구현

  ✓  스톱워치가 현재 실행되고 있는지를 확인하는 데 사용하는 isRunning 변수를 false로 초기화해 생성

  ✓  findViewById() 메서드로 xml 레이아웃 파일에서 정의한 뷰들을 액티비티에서 사용할 수 있게 가져옴

  ✓  btnStart와 btnRefresh에 구현한 리스너를 등록. setOnClickListener() 메서드를 이용해서 onClickListener를 추가해주어야 클릭이 가능

  ✓  클릭 이벤트가 발생했을 때 어떤 기능을 수행할 지 구현. 따라서 View.OnClickListener 인터페이스는 반드시 onClick() 메서드를 오버라이딩해야함.

 


3)  스톱워치 시작 기능 구현하기

  [시작] 버튼을 누르면 스톱워치가 시작되고 [시작] 버튼 텍스트가 '일시정지'로 바뀜

 

MainActivity에 타이머 관련 변수 timer와 time을 추가하고 초기화
class MainActivity : AppCompatActivity(), View.OnClickListener {
    var isRunning = false // 실행 여부 확인용 변수
    var timer: Timer? = null
    var time = 0

 

start() 메서드를 구현
private fun start() {
    btnStart.text = "일시정지" // 버튼 텍스트 변경
    btnStart.setBackgroundColor(getColor(R.color.red)) // 배경색을 빨강으로 변경
    isRunning = true // 실행상태 변경

    // 스톱워치 측정을 시작하는 로직
    timer = timer(period = 10) {
        time ++ // 0.01초마다 time에 1을 더함

        // 시간 계산
        val milliSecond = time % 100
        val second = (time % 6000) / 100
        val minute = time / 6000

        runOnUiThread { // 텍스트뷰가 UI 스레드에서 실행되도록 메서드 사용
        
            // 시간이 한 자리일 때 전체 텍스트 길이를 두 자리로 유지
            if (isRunning) {
                tvMillisecond.text = if (milliSecond < 10) ".0${milliSecond}" else 
                  ".${milliSecond}" // 밀리초
                tvSecond.text = if (second < 10) ":0${second}" else ":${second}" //초
                tvMinute.text = if (minute < 10)"0${minute}" else "${minute}"//분
             }
        }
    }
}
💡  코틀린에서 제공하는 timer(period = [주기]) {} 메서드는 일정한 주기로 반복하는 동작을 수행할 때 사용
      {} 안에 쓰인 코드들은 모두 백그라운드 스레드에서 실행
      주기를 나타내는 period 변수를 10으로 지정했으므로 10밀리초마다 실행

 


4)  스톱워치 멈춤 기능 구현

private fun pause() {
    // 텍스트 속성 변경
    btnStart.text = "시작"
    btnStart.setBackgroundColor(getColor(R.color.blue))

    // 스톱워치 측정을 일시정지하는 로직
    isRunning = false // 멈춤 상태로 전환
    timer?.cancel() // 타이머 멈추기
}

 


5)  스톱워치 초기화 기능 구현

private fun refresh() {
    timer?.cancel() // 백그라운드 타이머 멈추기

    btnStart.text = "시작"
    btnStart.setBackgroundColor(getColor(R.color.blue))
    isRunning = false // 멈춤 상태로 전환

    // 초기화하는 로직
    time = 0
    tvMillisecond.text = ".00"
    tvSecond.text = ":00"
    tvMinute.text = "00"
}

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  반응형 UI 만들기 : Guideline

가이드 라인은 실제 화면에는 보이지 않으며, 레이아웃을 구성할 때만 사용하는 도구
어떤 기기 해상도에서도 일정한 비율로 레이아웃을 구성하고 싶을 때 유용하게 사용

 

  📍  자동 생성된 가이드라인 ID는 @+id/guideline[숫자] 형식. [숫자]는 생성할 때마다 1씩 올라감

android:id="@+id/guideline2"

 

   📍  가이드라인 위치 지정

// 부모 레이아웃의 시작점을 기준으로 xdp만큼 떨어진 가이드라인
app:layout_constraintGuide_begin="xdp" 

// 부모 레이아웃의 끝점을 기준으로 xdp만큼 떨어진 가이드라인
app:layout_constraintGuide_end="xdp"

// 수평 방향 가이드라인이면 위쪽, 수직 방향 가이드라인이면 왼쪽을 기준으로 몇 퍼센트 지점에 위치하는지를 정함
app:layout_constraintGuide_percent="0.x"

 

  ✓  반응형으로 만들려면 고정된 dp값이 아니라 백분률로 위치를 정해주어야 함

 

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/vertical"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_percent="0.4" /> 
        // 수직 방향 가이드 라인이 전체 너비 중 40%에 해당하는 곳에 위치

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/horizontal"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.3" />
        // 수평 방향 가이드라인이 부모 레이아웃의 높이의 30%에 해당하는 곳에 위치

    <TextView
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:background="#A0CAC7"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="@+id/horizontal"
        android:text="TOP"
        android:textSize="30dp"
        android:gravity="center" />

    <TextView
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="#ff7E67"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="@+id/vertical"
        app:layout_constraintTop_toTopOf="@+id/horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        android:text="LEFT"
        android:textSize="30dp"
        android:gravity="center" />

    <TextView
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="#006A71"
        app:layout_constraintStart_toStartOf="@+id/vertical"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@id/horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        android:text="RIGHT"
        android:textSize="30dp"
        android:gravity="center" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  컨스트레인트 레이아웃  ConstraintLayout

한 화면을 구성하는 뷰들에 서로 제약을 주는 레이아웃. 컨스트레인트 레이아웃을 자주 쓰는 가장 큰 이유는 '다양한 화면 크기에 대응하는 반응형 UI를 쉽게 구성'할 수 있기 때문. 또 중첩된 레이아웃을 사용하지 않고도 크고 복잡한 레이아웃을 만들 수 있어 성능면에서 유리함

 


1)  기본 속성

  👾  컨스트레인트 레이아웃에서 자식 뷰의 위치를 정의하려면 자식 뷰의 수직 / 수평 방향에 제약 조건을 각각 하나 이상 추가
  👾  자식 뷰에 아무런 제약도 추가하지 않으면 왼쪽 상단에 배치. 컨스트레인트 레이아웃에서 자주 쓰는 속성은 다음과 같음.

app:layout_constraint[내 방향]_to[기준 뷰 방향]Of = "[기준 뷰 ID or parent]"


  ✓  [내 방향]을 [기준 뷰 방향]에 맞추고 그 기준 뷰가 무엇인지 알려줌

 

예제
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:id="@+id/button_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        
        // 시작 부분을 부모의 시작부분에 맞춤
        app:layout_constraintStart_toStartOf="parent"
        android:layout_marginStart="32dp"
        // 윗면을 부모의 윗면에 맞춤
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginTop="100dp"
        
        android:text="Button 1" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        
        // 시작부분을 버튼 끝부분에 맞춤
        app:layout_constraintStart_toEndOf="@+id/button_1"
        // 밑부분을 버튼부분에 맞춤
        app:layout_constraintBottom_toBottomOf="@+id/button_1"
        // 윗부분을 버튼 윗부분과 맞춤
        app:layout_constraintTop_toTopOf="@+id/button_1"
        android:layout_marginStart="32dp"
        android:text="버튼을 클릭해주세요!" />


</androidx.constraintlayout.widget.ConstraintLayout>

 


2)  컨스트레인트 레이아웃에서 마진을 줄 때 주의점

  👾  자식 뷰 사이에 여백을 정해줄 때 레이아웃에서는 layout_margin 속성을 사용
  👾  리니어 레이아웃과 상대적 레이아웃에서는 별다른 고려 없이 사용해도 되지만, 컨스트레인드 레이아웃을 사용할 때는 반드시 해당 방향으로 제약이 존재해야 마진값이 적용되는 규칙이 있음

예를 들면 위쪽 방향으로 여백을 100dp 만큼 주고 싶을 경우, 위쪽 방향에 제약이 없는 상태에서 android:layout_marginTop = "100dp"를 하면 적용이 되지 않음. 그러므로 반드시 android:layout_constraintTop_toTopOf="parent"와 같은 위쪽 방향에 제약을 추가할 수 있는 속성을 추가해야 함.

 


3)  match_constaint 속성

컨스트레인트 레이아웃을 이용해 앱의 레이아웃을 구성하다 보면 layout_width와 layout_height 속성에서 빈번하게 0dp 값을 보게 됨
그 뷰의 너비나 높이가 0dp라는 것은 match_constraint와 같음. match_parent가 부모 레이아웃 크기에 뷰 크기를 맞춘다는 것이라
 match_constraint는 제약에 뷰 크기를 맞춤.

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Button
        android:id="@+id/button_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginTop="100dp"
        android:text="Button 1" />

    <Button
        android:id="@+id/button_2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@+id/button_1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@+id/button_1"
        android:text="Button 2" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

  ✓  [Button 2]의 너비가 제약에 맞춰짐. [Button 1]의 끝점과 부모 레이아웃의 끝점만큼의 크기를 차지

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


구글 맵 클러스터링 Google Map Clustering 을 사용하면 지도에 나타나는 여러 개의 마커를 묶어서 하나의 그룹으로 표시 할 수 있음. 직방과 같은 앱에서 화면을 줌아웃하면 마커의 수가 숫자로 표시되는 것과 같은 기능


1.  의존성 추가하기

구글 맵 클러스팅을 사용하기 위해서는 부가적으로 maps-utils 라이브러리가 필요
bulid.gradle 에 의존성 추가.

implementation 'com.google.maps.android:android-maps-utils:2.2.3'

 


2.  데이터 클래스에 ClusterItem 상속받기

  📍 각각의 마커에 해당하는 클래스에 ClusterItem을 상속받고 코드를 추가해야 함
  📍 마커에 해당하는 클래스가 Row이기 때문에 Row 클래스에서 ClusterItem을 상속받은 후 반환하는 메서드와 부가 정보를 반환하는 메서드들을 구현

data class Row(
    val ADRES: String,
    val CODE_VALUE: String,
    val FDRM_CLOSE_DATE: String,
    val GU_CODE: String,
    val HMPG_URL: String,
    val LBRRY_NAME: String,
    val LBRRY_SEQ_NO: String,
    val LBRRY_SE_NAME: String,
    val OP_TIME: String,
    val TEL_NO: String,
    val XCNTS: String,
    val YDNTS: String
): ClusterItem {
    override fun getPosition(): LatLng { // 개별 마커가 표시될 좌표 반환
        return LatLng(XCNTS.toDouble(), YDNTS.toDouble())
    }

    override fun getTitle(): String? { // 마커를 클릭했을 때 나타나는 타이틀
        return LBRRY_NAME
    }

    override fun getSnippet(): String? { // 마커를 클릭했을 때 나타나는 서브타이틀
        return ADRES
    }

    override fun hashCode(): Int { // id 에 해당하는 유일한 값을 Int 로 반환
        return LBRRY_SEQ_NO.toInt()
    }
}

 

  ✓  앱이 실행되고 지도에 마커가 표시될 때 안드로이드는 Row 클래스의 getPosition() 메서드를 호출해서 해당 마커의 좌표를 계산

  ✓  특정한 범위 안에 있는 마커들을 묶어서 하나의 마커로 만들고, 몇 개의 마커가 표현되어 있는지 숫자로 표시해 주는 것을 가르켜 클러스터링이라 함

 


3.  클러스터 매니저

클러스터링은 클러스터 매니저 ClusterManager를 통해서 사용할 수 있음
클러스터 매니저를 액티비티에 선언해두고, 마커를 표시하는 코드에서 클러스터 매니저에 추가하는 코드를 작성

 

1)  클러스터 매니저 선언하기

 

  📍  MapsActivity 클래스 안에 clusterManager 프로퍼티를 선언

class MapsActivity : AppCompatActivity(), OnMapReadyCallback {

    private lateinit var mMap: GoogleMap
    private lateinit var binding: ActivityMapsBinding
    private lateinit var clusterManager: ClusterManager<Row>

 

2)  클러스터 매니저 초기화

 

  📍  clusterManager를 초기화하고 필요한 설정을 함. 마커를 표시하기 전에 설정해야 하기 때문에 Map이 생성된 직후인 onMapReady()에서 설정하는 것이 좋음

override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap
    
    // 클러스터 매니저 세팅
    clusterManager = ClusterManager(this, mMap)
    mMap.setOnCameraIdleListener(clusterManager) // 화면을 이동 후 멈췄을 때 설정
    mMap.setOnMarkerClickListener(clusterManager) // 마커 클릭 설정
    loadLibrary()
}

 

 

3) 클러스터 매니저에 데이터 추가하기


  📍  반복문에서 마커를 생성하는 코드를 삭제하고 clusterManager에 직접 데이터를 추가

private fun showLibrary(libraryResponse: LibraryResponse) {
    val latLngBounds = LatLngBounds.builder()

    for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
        // 기존 마커 세팅코드는 삭제하고 클러스터 메니저에 데이터를 추가하는 코드만 넣어줌.
        clusterManager.addItem(lib)

        // 첫 화면에 보여줄 범위를 정하기 위해서 아래 코드 두 줄은 남겨둠.
        val position = LatLng(lib.XCNTS.toDouble(),lib.YDNTS.toDouble())
        latLngBounds.include(position)
    }

    val bounds = latLngBounds.build()
    val padding = 0
    val updated = CameraUpdateFactory.newLatLngBounds(bounds,padding)
    
    mMap.moveCamera(updated)
}

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  프로젝트 생성하고 의존성 추가하기

서울 공공도서관 앱은 지도 정보가 필요하므로 Google Maps Activity를 사용

AndroidManifest.xml
  • API 키 입력
  • 인터넷 접근 권한 추가
  • 도서관 정보 API가 보안 프로토콜인 HTTPS가 아니라 HTTP를 사용하기 때문에 application 태그에 usesCleartextTraffic 속성 추가
<meta-data
    android:name="com.google.android.geo.API_KEY"
    android:value="API_KEY 입력" />
<uses-permission android:name="android.permission.INTERNET"/>
<application
    android:usesCleartextTraffic="true"

 

build.gradle
  • 레트로핏과 viewBinding 설정
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
buildFeatures {
    viewBinding true
}


2.  데이터 클래스 생성

웹 브라우저에 주소를 요청해서 받은 json 샘플 데이터로 Kotlin 데이터 클래스를 생성


1)  레트로핏 관련 파일만 모아둘 패키지를 생성


2)  data 클래스 생성

  • retrofit 패키지를 우클릭 - [New] - [Kotlin data class File from JSON] 을 클릭
  • 복사한 JSON 데이터를 그대로 넣어줌. 클래스의 이름을 LibraryResponse 로 지정
  • [Enable Inner Class Model]가 체크 해제된 것을 확인 


3)  레트로핏 클래스 작성

  • retrofit 패키지에 RetrofitConnection 클래스 생성
  • 레트로핏 객체를 생성하는 getInstance() 메서드 생성
class RetrofitConnection {
    // 객체를 하나만 생성하는 싱글턴 패턴을 적용.
    companion object {
        // API 서버의 주소가 BASE_URL이 돰.
        private const val BASE_URL = "http://openapi.seoul.go.kr:8088/"
        private var INSTANCE: Retrofit? = null

        fun getInstance(): Retrofit {
            if (INSTANCE == null) {
                INSTANCE = Retrofit.Builder()
                    .baseUrl(BASE_URL)
                    .addConverterFactory(GsonConverterFactory.create())
                    .build()
            }
            return INSTANCE!!
        }
    }
}

 

  ✓  컨버퍼 팩토리서버에서 온 JSON 응답을 데이터 클래스 객체로 변환. Gson이라는 레트로핏 기본 컨버터 팩토리를 사용


4)  HTTP 메서드를 정의해놓은 인터페이스 작성


  🚀  HTTP 메서드를 작성해 레트로핏이 데이터를 가져올 수 있도록 작업.
  🚀  인터페이스를 작성하면 레트로핏 라이브러리가 인터페이스에 정의된 API 엔드포인트들을 자동으로 구현


  ①  인터페이스를 추가

      -  LibraryService 인터페이스 생성

 

  ②  인터페이스를 작성
      -  LibraryService 인터페이스를 작성. 필요한 API는 GET 메서드.

interface LibraryService {
    @GET("{apiKey}/json/SeoulPublicLibraryInfo/1/200/")
    fun getLibrary(@Path("apiKey") key: String): Call<LibraryResponse>
}

 


5)  레트로핏으로 데이터 불러오기


  🚀  작업한 인터페이스를 적용하고 데이터를 불러오는 코드를 작성

  ①  MapActivity에 onMapReady() 아래에 loadLibrary() 메서드 작성

override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap

    val sydney = LatLng(-34.0, 151.0)
    mMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
    mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))

    loadLibrary()
}
private fun loadLibrary() {
}

 

  ②  정의한 인터페이스를 실행 가능한 객체로 변환

// 레트로핏 객체를 이용하면 LibraryService 인터페이스 구현체를 가져올 수 있음
val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)

 

  ③  인터페이스에 정의된 getLibrary() 메서드에 api키를 입력하고, enqueue() 메서드를 호출해서 서버에 요청

private fun loadLibrary() {

    val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)
    retrofitAPI.getLibrary("API키").enqueue(object : Callback<LibraryResponse> {
        override fun onResponse(call: Call<LibraryResponse>, response: Response<LibraryResponse>) {
            TODO("Not yet implemented")
        }

        override fun onFailure(call: Call<LibraryResponse>, t: Throwable) {
            TODO("Not yet implemented")
        }
    })
}

 

 

  ④  오버라이드한 메서드를 구현

  • 서버 요청이 실패했을 경우 간단한 토스트 메시지로 알려줌
  • 서버에서 데이터를 정상적으로 받았다면 지도에 마커를 표시하는 메서드를 호출하도록 코드를 추가
private fun loadLibrary() {
    // 레트로핏 객체를 이용하면 LibraryService 인터페이스 구현체를 가져올 수 있음.
    val retrofitAPI = RetrofitConnection.getInstance().create(LibraryService::class.java)
    retrofitAPI.getLibrary("4f666a5957736f6d35367479796e4e").enqueue(object: Callback<LibraryResponse> {
    
        override fun onResponse(
            call: Call<LibraryResponse>, 
            response: Response<LibraryResponse>) { // 지도에 마커 표시
        
            showLibrary(response.body() as LibraryResponse)
        }

        override fun onFailure(call: Call<LibraryResponse>, t: Throwable) {
            Toast.makeText(baseContext, "서버에서 데이터를 가져올 수 없습니다.", Toast.LENGTH_SHORT).show()
        }
    })
}

 


6)  지도에 도서관 마커 표시하기

 

  ①  지도에 마커를 표시하는 showLibrary() 메서드를 loadLibrary() 메서드 아래에 생성

private fun showLibrary(libraryResponse: LibraryResponse) { }

 

  ②  파라미터로 전달된 LibraryResponse의 SeoulPublicLibraryInfo.row에 도서관 목록이 담겨 있음

private fun showLibrary(libraryResponse: LibraryResponse) {
    // 도서관 목록을 반복문으로 하나씩 꺼냄.
    for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
        // 마커의 좌표를 생성
        val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
        // 좌표와 도서관 이름으로 마커를 생성
        val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
        // 지도에 마커를 추가
        mMap.addMarker(marker)
    }
}

 

  ③  지도를 보여주는 카메라가 시드니를 가르키므로 카메라의 위치 조정이 필요

  • 수동으로 카메라의 좌표를 직접 입력해 주는 방법도 있지만 마커 전체의 영역을 구하고, 마커의 영역만큼 보여주는 코드를 작성
private fun showLibrary(libraryResponse: LibraryResponse) {
    val latLngBounds = LatLngBounds.builder() // 마커 영역 지정

    // 도서관 목록을 반복문으로 하나씩 꺼냄.
    for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
        val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
        val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
        mMap.addMarker(marker)

        latLngBounds.include(marker.position) // 마커 추가
    }

    val bounds = latLngBounds.build() // 저장해 둔 마커의 영역을 구함
    val padding = 0
    // bounds와 padding으로 카메라를 업데이트
    val updated = CameraUpdateFactory.newLatLngBounds(bounds, padding)

    mMap.moveCamera(updated)
}

 


7)  onMapReady에서 loadLibrary() 메서드 호출하기


  🚀  onMapReady()에 기본으로 작성되어 있는 코드를 주석 처리하고 loadLibrary() 메서드를 호출

 override fun onMapReady(googleMap: GoogleMap) {
    mMap = googleMap

//        val sydney = LatLng(-34.0, 151.0)
//        mMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
//        mMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))

    loadLibrary()
}

 


8)  도서관 클릭 시 홈페이지로 이동하기


  🚀  클릭리스너로 새 창을 띄우거나 추가적인 처리를 할 수 있음. 여기서는 도서관 홈페이지의 url이 있는지 검사하고, 있으면 홈페이지를 웹 브라우저에 띄우는 코드를 작성

 

  ①  마커에 tag 정보를 추가

  • 마커를 클릭하면 id와 같은 구분 값을 tag에 저장해두고 사용할 수 있음
  • 지도에 마커를 추가하는 코드로 수정하고 tag 값에 홈페이지 주소를 저장
MapsActivity에서 showLibrary() 메서드의 다음 부분을 수정
for (lib in libraryResponse.SeoulPublicLibraryInfo.row) {
    val position = LatLng(lib.XCNTS.toDouble(), lib.YDNTS.toDouble())
    val marker = MarkerOptions().position(position).title(lib.LBRRY_NAME)
    //mMap.addMarker(marker)
    
    val obj = mMap.addMarker(marker)
    obj?.tag = lib.HMPG_URL

    latLngBounds.include(marker.position) // 마커 추가
}

 

  ②  클릭리스너를 달고 tag 홈페이지 주소를 웹 브라우저에 띄움

  • onMapReady() 안에서 추가로 코드를 작성
  • 지도에 마커클릭리스너를 달고 리스너를 통해 전달되는 마커의 tag를 검사해서 값이 있으면 인텐트로 홈페이지를 띄움
mMap.setOnMarkerClickListener {
    if (it.tag != null) {
        var url = it.tag as String
        if (!url.startsWith("http")) {
            url = "http://${url}"
        }
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
        startActivity(intent)
    }
    true
}

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

+ Recent posts